Explorar o código

封装上传公共组件

zengy hai 5 días
pai
achega
571492aad9

+ 3 - 0
.env.development

@@ -1,3 +1,6 @@
 VITE_APP_BASE_API=/dev-api
 # App/小程序真机调试时填写局域网可访问的服务地址,例如 http://192.168.1.10:8301
 VITE_APP_API_ORIGIN=
+
+# 开发环境 MinIO 地址;生产 H5 使用 .env.production 中的同源反代路径。
+VITE_APP_MINIO_BASE_URL=http://111.23.174.45:9000/pipe/

+ 6 - 0
.env.production

@@ -0,0 +1,6 @@
+# 生产 H5 使用同源网关路径,由 Nginx 转发到 zk-gateway。
+VITE_APP_BASE_API=/pipe
+VITE_APP_API_ORIGIN=
+
+# MinIO 由 Nginx 的 /minio/ 反代到 MinIO,前端不绑定服务器 IP。
+VITE_APP_MINIO_BASE_URL=/minio/pipe/

+ 19 - 7
src/components/AlarmAuditDrawer.vue

@@ -1,10 +1,10 @@
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue'
 import PhotoPreview from './PhotoPreview.vue'
-import { uploadAlarmAttachment } from '@/services/alarms'
+import { uploadAlarmAttachments } from '@/services/alarms'
 
 const props = defineProps<{ modelValue: boolean; alarmId: string; submitting: boolean; error?: string }>()
-const emit = defineEmits<{ 'update:modelValue': [value: boolean]; submitted: [payload: { auditStatus: number; opinion: string; attachmentTokens: string[] }] }>()
+const emit = defineEmits<{ 'update:modelValue': [value: boolean]; submitted: [payload: { auditStatus: number; opinion: string; attachments: string[] }] }>()
 interface Attachment { path: string; token?: string; uploading: boolean; error?: string }
 const result = ref<number>(), opinion = ref(''), files = ref<Attachment[]>([])
 const photoPreview = ref<InstanceType<typeof PhotoPreview>>()
@@ -17,7 +17,7 @@ function close() {
 }
 async function upload(file: Attachment) {
   file.uploading = true; file.error = ''
-  try { file.token = await uploadAlarmAttachment(props.alarmId, file.path) }
+  try { const tokens = await uploadAlarmAttachments('alarm', [file.path]); file.token = tokens[0] }
   catch (e) { file.error = e instanceof Error ? e.message : '上传失败' }
   finally { file.uploading = false }
 }
@@ -27,25 +27,37 @@ function choose() {
   uni.chooseImage({ count: 5 - files.value.length, sizeType: ['original'],
     success(res) {
       const metadata = res.tempFiles as { name?: string; size: number; path?: string }[]
-      let invalid = false
       const paths = Array.isArray(res.tempFilePaths) ? res.tempFilePaths : [res.tempFilePaths]
-      paths.slice(0, 5 - files.value.length).forEach((path, index) => {
+      const start = files.value.length
+      let invalid = false
+      const picked: string[] = []
+      paths.slice(0, 5 - start).forEach((path, index) => {
         const info = metadata[index], name = info?.name || info?.path || path
         if (!/\.(jpe?g|png)$/i.test(name.split('?')[0]) || !info?.size || info.size > 10 * 1024 * 1024) { invalid = true; return }
         files.value.push({ path, uploading: true })
-        void upload(files.value[files.value.length - 1])
+        picked.push(path)
       })
+      if (picked.length) void uploadBatch(start, picked)
       if (invalid) uni.showToast({ title: '请选择不超过10MB的JPG、JPEG或PNG图片', icon: 'none' })
     },
     complete() { choosing.value = false },
   })
 }
+/** 一次选择的多张图片合并为单次 /minio/uploads 请求,避免逐张调用单文件接口 */
+async function uploadBatch(start: number, paths: string[]) {
+  try {
+    const tokens = await uploadAlarmAttachments('alarm', paths)
+    paths.forEach((_, i) => { files.value[start + i].token = tokens[i]; files.value[start + i].uploading = false })
+  } catch (e) {
+    paths.forEach((_, i) => { files.value[start + i].uploading = false; files.value[start + i].error = e instanceof Error ? e.message : '上传失败' })
+  }
+}
 function remove(index: number) { if (!busy.value) files.value.splice(index, 1) }
 function submit() {
   if (busy.value) return
   if (result.value !== 1 && result.value !== 2) { uni.showToast({ title: '请选择核实结果', icon: 'none' }); return }
   if (files.value.some(file => !file.token)) { uni.showToast({ title: '请重试或移除上传失败的图片', icon: 'none' }); return }
-  emit('submitted', { auditStatus: result.value, opinion: opinion.value.trim(), attachmentTokens: files.value.map(file => file.token!) })
+  emit('submitted', { auditStatus: result.value, opinion: opinion.value.trim(), attachments: files.value.map(file => file.token!) })
 }
 </script>
 

+ 2 - 2
src/components/InspectCompleteDrawer.vue

@@ -2,7 +2,7 @@
 import { ref, watch } from 'vue'
 import FormField from './FormField.vue'
 import UploadPanel from './UploadPanel.vue'
-import { completeMobileInspectTask, uploadInspectTaskPhoto } from '@/services/inspect-tasks'
+import { completeMobileInspectTask, uploadInspectTaskPhotos } from '@/services/inspect-tasks'
 
 const props = defineProps<{ modelValue: boolean; taskId: number }>()
 const emit = defineEmits<{ 'update:modelValue': [value: boolean]; completed: [] }>()
@@ -37,7 +37,7 @@ async function submit() {
   submitting.value = true
   uni.showLoading({ title: '上传并提交...' })
   try {
-    const paths = await Promise.all(photos.value.map(uploadInspectTaskPhoto))
+    const paths = await uploadInspectTaskPhotos(photos.value)
     await completeMobileInspectTask({
       inspectTaskId: props.taskId,
       realTime: time.value,

+ 3 - 3
src/pages/emergency/event-detail.vue

@@ -8,7 +8,7 @@ import UploadPanel from '@/components/UploadPanel.vue'
 import PhotoPreview from '@/components/PhotoPreview.vue'
 import DepartmentUserPicker, { type DepartmentUserOption } from '@/components/DepartmentUserPicker.vue'
 import { hasUserPermission, useUserStore } from '@/stores/user'
-import { eventStatusCode, eventStatusText, eventStatusTone, getEmergencyDict, getEmergencyEvent, getEmergencyHandlerOptions, getEmergencyVerifierOptions, auditEmergencyEvent, reportEmergencyEvent, resolveEmergencyAttachment, splitEmergencyAttachments, startEmergencyEvent, submitVerifyEmergencyEvent, trackEmergencyEvent, transferEmergencyEvent, uploadEmergencyFile, verifyEmergencyEvent, type EmergencyEvent, type EmergencyLog, type EmergencyOption, type UserOption } from '@/services/emergency'
+import { eventStatusCode, eventStatusText, eventStatusTone, getEmergencyDict, getEmergencyEvent, getEmergencyHandlerOptions, getEmergencyVerifierOptions, auditEmergencyEvent, reportEmergencyEvent, resolveEmergencyAttachment, splitEmergencyAttachments, startEmergencyEvent, submitVerifyEmergencyEvent, trackEmergencyEvent, transferEmergencyEvent, uploadEmergencyFiles, verifyEmergencyEvent, type EmergencyEvent, type EmergencyLog, type EmergencyOption, type UserOption } from '@/services/emergency'
 
 type Action = '核实' | '转接' | '报送' | '开始处置' | '处置跟踪' | '提交核验' | '核验'
 const userStore = useUserStore()
@@ -102,7 +102,7 @@ function chooseTrend() { pick('选择态势评估', trends.value, trend.value, v
 function chooseUpgrade() { pick('是否需要升级', yesNo.value, upgrade.value, value => { upgrade.value = value }) }
 function updateVisitTime() { nextVisitTime.value = visitDate.value && visitTime.value ? `${visitDate.value} ${visitTime.value}:00` : '' }
 function timeLabel(value?: string) { return value ? value.slice(11, 16) : '--:--' }
-function logImages(log: EmergencyLog) { return splitEmergencyAttachments(log.attachUrl).map(path => resolveEmergencyAttachment(path, event.value?.attachmentBaseUrl)) }
+function logImages(log: EmergencyLog) { return splitEmergencyAttachments(log.attachUrl).map(path => resolveEmergencyAttachment(path)) }
 function preview(url: string, urls: string[]) { photoPreview.value?.open(url, urls) }
 function dayLabel(value: string) {
   const now = new Date(), today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
@@ -121,7 +121,7 @@ async function submit() {
   if (['转接', '报送'].includes(action.value) && !targets.value.length) return uni.showToast({ title: '请选择至少一个部门', icon: 'none' })
   submitting.value = true
   try {
-    const attachUrl = (await Promise.all(files.value.map(uploadEmergencyFile))).join(',')
+    const attachUrl = (await uploadEmergencyFiles(files.value)).join(',')
     if (action.value === '核实') await verifyEmergencyEvent(id.value, { verifyResult: Number(verifyResult.value), eventCategory: verifyResult.value === '0' ? category.value : undefined, eventLevel: verifyResult.value === '0' ? level.value : undefined, handlerId: handlerId.value, verifierId: reviewerId.value, verifyOpinion: content })
     else if (action.value === '开始处置') await startEmergencyEvent(id.value, { operDesc: content, attachUrl })
     else if (action.value === '处置跟踪') await trackEmergencyEvent(id.value, { operDesc: content, attachUrl, trend: trend.value || undefined, needUpgrade: upgrade.value ? Number(upgrade.value) : undefined, nextVisitTime: nextVisitTime.value || undefined })

+ 1 - 1
src/pages/message/alarm-detail.vue

@@ -56,7 +56,7 @@ onLoad(async options => {
 onUnload(() => { ++requestId })
 onPullDownRefresh(async () => { try { await loadDetail() } finally { uni.stopPullDownRefresh() } })
 function openAudit() { if (canAudit.value && !loading.value) { formError.value = ''; drawerOpen.value = true } }
-async function submit(payload: { auditStatus: number; opinion: string; attachmentTokens: string[] }) {
+async function submit(payload: { auditStatus: number; opinion: string; attachments: string[] }) {
   if (submitting.value || !canAudit.value) return
   submitting.value = true; formError.value = ''
   let success = false

+ 3 - 3
src/pages/message/warning-detail.vue

@@ -8,7 +8,7 @@ import WarningActionDrawer from '@/components/WarningActionDrawer.vue'
 import { useUserStore } from '@/stores/user'
 import {
   downloadWarningAttachment, getMobileWarningDetail, getWarningParticipants, isImageAttachment,
-  processWarning, publishWarning, receiveWarning, startWarning, uploadWarningProcessImage, verifyWarning,
+  processWarning, publishWarning, receiveWarning, startWarning, uploadWarningProcessImages, verifyWarning,
   warningStatusText, warningStatusTone, warningTime, type EarlyWarning, type WarningAttachment,
   type WarningDetail, type WarningDisposal, type WarningParticipant,
 } from '@/services/warnings'
@@ -98,8 +98,8 @@ async function submit(payload: { remark: string; content: string; files: string[
     else if (activeAction.value === 'RECEIVE') await receiveWarning(warningId.value, payload.remark)
     else if (activeAction.value === 'START') await startWarning(warningId.value, payload.remark)
     else if (activeAction.value === 'HANDLE') {
-      const tokens = await Promise.all(payload.files.map(path => uploadWarningProcessImage(warningId.value, path)))
-      await processWarning(warningId.value, payload.content, tokens)
+      const paths = await uploadWarningProcessImages('early-warning', payload.files)
+      await processWarning(warningId.value, payload.content, paths)
     } else if (activeAction.value === 'VERIFY') await verifyWarning(warningId.value, payload.result, payload.opinion)
     drawerOpen.value = false
     await loadDetail()

+ 5 - 5
src/pages/message/workorder-detail.vue

@@ -9,7 +9,7 @@ import { useUserStore } from '@/stores/user'
 import {
   acceptWorkOrder, applyDelayWorkOrder, auditDelayWorkOrder, cancelWorkOrder, dispatchWorkOrder,
   getAssignableUsers, getMobileWorkOrderDetail, processWorkOrder, resolveAttachmentUrl, splitAttachments,
-  submitVerifyWorkOrder, uploadWorkOrderFile, verifyWorkOrder, workOrderLevelText, workOrderStatusText,
+  submitVerifyWorkOrder, uploadWorkOrderFiles, verifyWorkOrder, workOrderLevelText, workOrderStatusText,
   type AssignableUser, type WorkOrder, type WorkOrderDetail, type WorkOrderLog,
 } from '@/services/workorders'
 
@@ -39,8 +39,8 @@ const statusLabel = computed(() => order.value?.orderStatus === 3 && order.value
 const statusTone = computed(() => order.value?.orderStatus === 0 || order.value?.delayApplyStatus === 1 ? 'orange' : [3, 6].includes(Number(order.value?.orderStatus)) ? 'purple' : [4, 5].includes(Number(order.value?.orderStatus)) ? 'green' : order.value?.orderStatus === 7 ? 'gray' : '')
 const currentStep = computed(() => ({ 0: 2, 1: 3, 2: 4, 3: 4, 4: 5, 5: 6, 6: 4, 7: 4 }[Number(order.value?.orderStatus)] || 1))
 const isTerminated = computed(() => order.value?.orderStatus === 7)
-const beforeImages = computed(() => splitAttachments(order.value?.maintenanceBeforeImg).map(path => resolveAttachmentUrl(path, detail.value?.attachmentBaseUrl)))
-const afterImages = computed(() => splitAttachments(order.value?.maintenanceAfterImg).map(path => resolveAttachmentUrl(path, detail.value?.attachmentBaseUrl)))
+const beforeImages = computed(() => splitAttachments(order.value?.maintenanceBeforeImg).map(path => resolveAttachmentUrl(path)))
+const afterImages = computed(() => splitAttachments(order.value?.maintenanceAfterImg).map(path => resolveAttachmentUrl(path)))
 
 const actions = computed(() => {
   const result: string[] = []
@@ -110,7 +110,7 @@ async function submitted(action: string, payload: ActionPayload) {
   submitting.value = true
   uni.showLoading({ title: payload.files.length ? '上传并提交...' : '正在提交...' })
   try {
-    const paths = await Promise.all(payload.files.map(uploadWorkOrderFile))
+    const paths = await uploadWorkOrderFiles(payload.files)
     const common = { content: payload.content || '', attachUrl: paths.join(',') }
     if (action === '派单') await dispatchWorkOrder(orderId.value, { handlerId: payload.handlerId, verifyUser: payload.verifyUser, remarks: payload.remarks, planFinishTime: payload.planFinishTime })
     else if (action === '核查接单') await acceptWorkOrder(orderId.value, common)
@@ -168,7 +168,7 @@ function timeLabel(time?: string) { return time ? time.slice(11, 16) : '--:--' }
           <view class="info-section"><view class="section-heading">附件</view><view class="attachments"><view><view class="attach-head"><text>维修前</text><text>{{ beforeImages.length }} 项</text></view><view class="photos"><image v-for="url in beforeImages" :key="url" :src="url" mode="aspectFill" @tap="preview(url,beforeImages)" /><text v-if="!beforeImages.length">暂无</text></view></view><view><view class="attach-head"><text>维修后</text><text>{{ afterImages.length }} 项</text></view><view class="photos"><image v-for="url in afterImages" :key="url" :src="url" mode="aspectFill" @tap="preview(url,afterImages)" /><text v-if="!afterImages.length">暂无</text></view></view></view></view>
           <view class="info-section"><view class="section-heading">延期申请</view><view v-if="order.delayApplyStatus" class="delay"><text class="status-pill" :class="order.delayApplyStatus === 2 ? 'green' : 'orange'">{{ ['','待延期审核','延期审核通过','延期审核驳回'][order.delayApplyStatus] }}</text><view><text>{{ order.delayedReason || '—' }}</text><text>延期截止:{{ order.delayedDeadline || '—' }}</text></view></view><view v-else class="empty">没有延期信息</view></view>
         </view>
-        <view v-else class="log-panel"><view class="log-tip">按时间倒序分组 · 滑动即可回溯</view><view v-if="!logGroups.length" class="empty">暂无处置记录</view><template v-for="group in logGroups" :key="group.day"><view class="log-day">{{ dayLabel(group.day) }}</view><view v-for="(log, index) in group.items" :key="log.logId" class="log-item" :class="{ 'last-log': index === group.items.length - 1 }"><text class="time">{{ timeLabel(log.operTime) }}</text><view><text class="log-title">{{ log.operUserName || '系统' }} · {{ log.operTypeDesc || '操作' }}</text><text>{{ log.operDesc || '—' }}</text><view v-if="log.attachList?.length" class="log-images"><image v-for="path in log.attachList" :key="path" :src="resolveAttachmentUrl(path,detail?.attachmentBaseUrl)" mode="aspectFill" @tap="preview(resolveAttachmentUrl(path,detail?.attachmentBaseUrl),log.attachList!.map(item=>resolveAttachmentUrl(item,detail?.attachmentBaseUrl)))" /></view></view></view></template></view>
+        <view v-else class="log-panel"><view class="log-tip">按时间倒序分组 · 滑动即可回溯</view><view v-if="!logGroups.length" class="empty">暂无处置记录</view><template v-for="group in logGroups" :key="group.day"><view class="log-day">{{ dayLabel(group.day) }}</view><view v-for="(log, index) in group.items" :key="log.logId" class="log-item" :class="{ 'last-log': index === group.items.length - 1 }"><text class="time">{{ timeLabel(log.operTime) }}</text><view><text class="log-title">{{ log.operUserName || '系统' }} · {{ log.operTypeDesc || '操作' }}</text><text>{{ log.operDesc || '—' }}</text><view v-if="log.attachList?.length" class="log-images"><image v-for="path in log.attachList" :key="path" :src="resolveAttachmentUrl(path)" mode="aspectFill" @tap="preview(resolveAttachmentUrl(path),log.attachList!.map(item=>resolveAttachmentUrl(item)))" /></view></view></view></template></view>
       </view>
       <view v-if="actions.length" class="bottom-actions"><button v-for="action in actions" :key="action" :class="action === '取消工单' ? 'danger' : action === actions[actions.length - 1] ? 'primary' : 'default'" @tap="openAction(action)">{{ action }}</button></view>
       <ActionFormDrawer v-model="drawerOpen" :action="activeAction" domain="workorder" :handler-options="handlers" :reviewer-options="reviewers" :default-reviewer-id="currentUserId" :submitting="submitting" @submitted="submitted" />

+ 1 - 1
src/pages/office/patrol-detail.vue

@@ -11,7 +11,7 @@ const task = ref<InspectTask>()
 const loading = ref(true)
 const drawerOpen = ref(false)
 const photoPreview = ref<InstanceType<typeof PhotoPreview>>()
-const photos = computed(() => (task.value?.photoList || []).map(path => resolveInspectTaskPhoto(path, task.value?.attachmentBaseUrl)))
+const photos = computed(() => (task.value?.photoList || []).map(path => resolveInspectTaskPhoto(path)))
 const base = computed(() => [
   ['巡检内容', task.value?.inspectContent || '—'],
   ['巡检人', task.value?.assignUserName || '—'],

+ 11 - 6
src/services/alarms.ts

@@ -41,19 +41,24 @@ export function getMobileAlarms(params: { pageNum: number; pageSize: number; key
 }
 export const getMobileAlarm = (id: string) => requestApi<DataResponse<Alarm>>(`${BASE}/${encodeURIComponent(id)}`).then(r => r.data)
 export const getMobileAlarmLogs = (id: string) => requestApi<DataResponse<AlarmLog[]>>(`${BASE}/${encodeURIComponent(id)}/logs`).then(r => r.data)
-export function reviewMobileAlarm(id: string, data: { auditStatus: number; opinion: string; attachmentTokens: string[] }) {
+export function reviewMobileAlarm(id: string, data: { auditStatus: number; opinion: string; attachments: string[] }) {
   return requestApi<DataResponse<Alarm>>(`${BASE}/${encodeURIComponent(id)}/reviews`, { method: 'POST', data }).then(r => r.data)
 }
-export function uploadAlarmAttachment(id: string, path: string): Promise<string> {
+export function uploadAlarmAttachments(dir: string, filePaths: string[]): Promise<string[]> {
   return new Promise((resolve, reject) => {
+    if (!filePaths.length) { resolve([]); return }
     uni.uploadFile({
-      url: getApiUrl(`${BASE}/${encodeURIComponent(id)}/attachments`), filePath: path, name: 'file',
+      url: getApiUrl(`/minio/uploads?dir=${encodeURIComponent(dir)}`),
+      files: filePaths.map(p => ({ name: 'files', uri: p })),
       header: { Authorization: `Bearer ${getToken()}` }, timeout: 60000,
       success(response) {
         try {
-          const result = parseApiResponse<DataResponse<{ token: string }>>(response.statusCode, response.data)
-          if (result.data?.token) resolve(result.data.token)
-          else reject(new ApiError(result.msg || '图片上传失败', result.code))
+          const result = parseApiResponse<ApiResponse & { files?: Array<{ relativePath?: string; url?: string }> }>(response.statusCode, response.data)
+          if (result.code === 200 && Array.isArray(result.files)) {
+            resolve(result.files.map(f => f.relativePath || f.url).filter(Boolean) as string[])
+          } else {
+            reject(new ApiError(result.msg || '图片上传失败', result.code))
+          }
         } catch (error) { reject(error instanceof ApiError ? error : new ApiError('图片上传响应异常')) }
       },
       fail() { reject(new ApiError('图片上传失败,请重试')) },

+ 6 - 19
src/services/emergency.ts

@@ -1,5 +1,6 @@
-import { ApiError, getApiUrl, parseApiResponse, requestApi, type ApiResponse } from './api'
-import { getToken } from '@/utils/auth'
+import { requestApi, type ApiResponse } from './api'
+import { uploadMinioFiles } from './minio-upload'
+import { resolveMinioUrl } from './minio'
 
 export interface EmergencyEvent {
   id: number
@@ -34,7 +35,6 @@ export interface EmergencyEvent {
   releaseNote?: string
   createTime?: string
   logs?: EmergencyLog[]
-  attachmentBaseUrl?: string
 }
 
 export interface EmergencyLog {
@@ -58,8 +58,7 @@ export function splitEmergencyAttachments(value?: string) {
 }
 
 export function resolveEmergencyAttachment(path: string, baseUrl?: string) {
-  if (/^(https?:|data:|blob:)/.test(path)) return path
-  return `${(baseUrl || '').replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
+  return resolveMinioUrl(path)
 }
 
 export interface EmergencyPlan {
@@ -120,17 +119,5 @@ export function getEmergencyPlan(id: number) { return requestApi<DataResponse<Em
 export function getEmergencyAudit(query: Record<string, unknown> = {}) { return requestApi<DataResponse<PageResult<EmergencyPlan>>>('/api/mobile/emergency/audit', { data: query }).then(r => r.data) }
 export function reviewEmergencyPlan(id: number, approved: boolean, approveNote = '') { return requestApi<ApiResponse>(`/api/mobile/emergency/audit/${id}`, { method: 'POST', data: { approved, approveNote } }) }
 
-export function uploadEmergencyFile(filePath: string): Promise<string> {
-  return new Promise((resolve, reject) => uni.uploadFile({
-    url: `${getApiUrl('/minio/upload')}?dir=dispatch`, filePath, name: 'file',
-    header: { Authorization: `Bearer ${getToken()}` }, timeout: 30000,
-    success(response) {
-      try {
-        const result = parseApiResponse<ApiResponse & { relativePath?: string }>(response.statusCode, response.data)
-        if (result.relativePath) resolve(result.relativePath)
-        else reject(new ApiError(result.msg || result.message || '附件上传失败'))
-      } catch (error) { reject(error instanceof Error ? error : new ApiError('附件上传响应异常')) }
-    },
-    fail(error) { reject(new ApiError(error.errMsg?.includes('timeout') ? '附件上传超时' : '附件上传失败')) },
-  }))
-}
+/** 应急事件附件批量上传:一次 /minio/uploads 请求,返回与传入顺序一致的相对路径 */
+export const uploadEmergencyFiles = (filePaths: string[]) => uploadMinioFiles('event', filePaths)

+ 6 - 32
src/services/inspect-tasks.ts

@@ -1,5 +1,6 @@
-import { ApiError, getApiUrl, parseApiResponse, requestApi, type ApiResponse } from './api'
-import { getToken } from '@/utils/auth'
+import { requestApi, type ApiResponse } from './api'
+import { uploadMinioFiles } from './minio-upload'
+import { resolveMinioUrl } from './minio'
 
 export interface InspectTask {
   inspectTaskId: number
@@ -13,7 +14,6 @@ export interface InspectTask {
   process?: string
   photoUrl?: string
   photoList?: string[]
-  attachmentBaseUrl?: string
   status?: number
 }
 
@@ -33,7 +33,6 @@ export interface InspectTaskQuery {
 }
 
 interface DataResponse<T> extends ApiResponse { data: T }
-interface UploadResponse extends ApiResponse { relativePath?: string; url?: string }
 
 const BASE = '/api/mobile/ops/inspectTask'
 
@@ -56,34 +55,9 @@ export function completeMobileInspectTask(data: {
   return requestApi<ApiResponse>(`${BASE}/complete`, { method: 'PUT', data })
 }
 
-export function uploadInspectTaskPhoto(filePath: string): Promise<string> {
-  return new Promise((resolve, reject) => {
-    uni.uploadFile({
-      url: `${getApiUrl('/minio/upload')}?dir=inspectTask`,
-      filePath,
-      name: 'file',
-      header: { Authorization: `Bearer ${getToken()}` },
-      timeout: 30000,
-      success(response) {
-        try {
-          const result = parseApiResponse<UploadResponse>(response.statusCode, response.data)
-          if (result.relativePath) {
-            resolve(result.relativePath)
-          } else {
-            reject(new ApiError(result.msg || result.message || '现场照片上传失败', result.code))
-          }
-        } catch (error) {
-          reject(error instanceof ApiError ? error : new ApiError('现场照片上传响应格式异常'))
-        }
-      },
-      fail(error) {
-        reject(new ApiError(error.errMsg?.includes('timeout') ? '现场照片上传超时' : '现场照片上传失败'))
-      },
-    })
-  })
-}
+/** 巡检照片批量上传:一次 /minio/uploads 请求,返回与传入顺序一致的相对路径 */
+export const uploadInspectTaskPhotos = (filePaths: string[]) => uploadMinioFiles('inspect', filePaths)
 
 export function resolveInspectTaskPhoto(path: string, baseUrl?: string) {
-  if (/^(https?:|data:|blob:)/.test(path)) return path
-  return `${(baseUrl || '').replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
+  return resolveMinioUrl(path)
 }

+ 31 - 0
src/services/minio-upload.ts

@@ -0,0 +1,31 @@
+import { ApiError, getApiUrl, parseApiResponse, type ApiResponse } from './api'
+import { getToken } from '@/utils/auth'
+
+/**
+ * 多文件合并为一次 /minio/uploads 请求,避免逐张调用单文件接口 /minio/upload。
+ * 后端按 files 顺序返回 files 列表,结果与传入顺序一致。
+ */
+export function uploadMinioFiles(dir: string, filePaths: string[]): Promise<string[]> {
+  return new Promise((resolve, reject) => {
+    if (!filePaths.length) { resolve([]); return }
+    uni.uploadFile({
+      url: getApiUrl(`/minio/uploads?dir=${encodeURIComponent(dir)}`),
+      files: filePaths.map(p => ({ name: 'files', uri: p })),
+      header: { Authorization: `Bearer ${getToken()}` },
+      timeout: 60000,
+      success(response) {
+        try {
+          const result = parseApiResponse<ApiResponse & { files?: Array<{ relativePath?: string; url?: string }> }>(response.statusCode, response.data)
+          if (result.code === 200 && Array.isArray(result.files)) {
+            resolve(result.files.map(f => f.relativePath || f.url).filter(Boolean) as string[])
+          } else {
+            reject(new ApiError(result.msg || '文件上传失败', result.code))
+          }
+        } catch (error) {
+          reject(error instanceof ApiError ? error : new ApiError('文件上传响应异常'))
+        }
+      },
+      fail() { reject(new ApiError('文件上传失败,请重试')) },
+    })
+  })
+}

+ 18 - 0
src/services/minio.ts

@@ -0,0 +1,18 @@
+const configuredBase = String(import.meta.env.VITE_APP_MINIO_BASE_URL || '/minio/pipe/')
+  .trim()
+  .replace(/\/+$/, '')
+
+/** 将相对路径或历史 MinIO 完整 URL 统一转换为当前站点可访问地址。 */
+export function resolveMinioUrl(value?: string) {
+  const raw = String(value || '').trim()
+  if (!raw || /^(data:|blob:)/i.test(raw)) return raw
+
+  const base = configuredBase
+  if (/^https?:\/\//i.test(raw)) {
+    const marker = '/pipe/'
+    const index = raw.indexOf(marker)
+    if (index >= 0) return `${base}/${raw.slice(index + marker.length).replace(/^\/+/, '')}`
+    return raw
+  }
+  return `${base}/${raw.replace(/^\/+/, '')}`
+}

+ 15 - 10
src/services/profile.ts

@@ -1,4 +1,5 @@
 import { ApiError, getApiUrl, parseApiResponse, requestApi, type ApiResponse } from './api'
+import { resolveMinioUrl } from './minio'
 import type { UserProfile } from './auth'
 import { getToken } from '@/utils/auth'
 
@@ -30,17 +31,22 @@ export function updatePassword(oldPassword: string, newPassword: string) {
 export function uploadAvatar(filePath: string): Promise<AvatarResponse> {
   return new Promise((resolve, reject) => {
     uni.uploadFile({
-      url: getApiUrl('/system/user/profile/avatar'),
+      url: getApiUrl('/minio/upload?dir=user'),
       filePath,
-      name: 'avatarfile',
+      name: 'file',
       header: { Authorization: `Bearer ${getToken()}` },
       timeout: 20000,
       success(response) {
-        try {
-          resolve(parseApiResponse<AvatarResponse>(response.statusCode, response.data))
-        } catch (error) {
-          reject(error instanceof ApiError ? error : new ApiError('头像上传响应格式异常'))
-        }
+        (async () => {
+          try {
+            const upRes = parseApiResponse<ApiResponse & { relativePath?: string; url?: string }>(response.statusCode, response.data)
+            const path = upRes.relativePath || upRes.url
+            if (!path) throw new ApiError('头像上传未返回路径')
+            resolve(await requestApi<AvatarResponse>(`/system/user/profile/avatar?avatar=${encodeURIComponent(path)}`, { method: 'POST' }))
+          } catch (error) {
+            reject(error instanceof ApiError ? error : new ApiError('头像上传失败'))
+          }
+        })()
       },
       fail(error) {
         const message = error.errMsg?.includes('timeout') ? '头像上传超时' : '头像上传失败'
@@ -50,8 +56,7 @@ export function uploadAvatar(filePath: string): Promise<AvatarResponse> {
   })
 }
 
+/** 头像地址统一走 MinIO 前缀(与附件 resolveMinioUrl 一致),而非后端 API 地址 */
 export function resolveAvatarUrl(avatar?: UserProfile['avatar']) {
-  if (!avatar) return ''
-  if (/^(https?:|data:|blob:)/.test(avatar)) return avatar
-  return getApiUrl(avatar)
+  return resolveMinioUrl(avatar)
 }

+ 21 - 14
src/services/warnings.ts

@@ -139,22 +139,29 @@ const post = (id: string, action: string, data: Record<string, unknown>) => requ
 export const publishWarning = (id: string, handler: string, reviewer: string) => post(id, 'publish', { handler, reviewer })
 export const receiveWarning = (id: string, remark: string) => post(id, 'receive', { remark })
 export const startWarning = (id: string, remark: string) => post(id, 'start', { remark })
-export const processWarning = (id: string, content: string, attachmentTokens: string[]) => post(id, 'process', { content, attachmentTokens, reasonIds: [] })
+export const processWarning = (id: string, content: string, attachments: string[]) => post(id, 'process', { content, attachments, reasonIds: [] })
 export const verifyWarning = (id: string, result: 'CLEAR' | 'RETURN', opinion: string) => post(id, 'verify', { result, opinion })
 
-export function uploadWarningProcessImage(id: string, filePath: string): Promise<string> {
-  return new Promise((resolve, reject) => uni.uploadFile({
-    url: getApiUrl(`${BASE}/${encodeURIComponent(id)}/process/attachments`), filePath, name: 'file',
-    header: { Authorization: `Bearer ${getToken()}` }, timeout: 60000,
-    success(response) {
-      try {
-        const result = parseApiResponse<DataResponse<{ token: string }>>(response.statusCode, response.data)
-        if (result.data?.token) resolve(result.data.token)
-        else reject(new ApiError(result.msg || '图片上传失败', result.code))
-      } catch (error) { reject(error instanceof ApiError ? error : new ApiError('图片上传响应异常')) }
-    },
-    fail() { reject(new ApiError('图片上传失败,请重试')) },
-  }))
+export function uploadWarningProcessImages(dir: string, filePaths: string[]): Promise<string[]> {
+  return new Promise((resolve, reject) => {
+    if (!filePaths.length) { resolve([]); return }
+    uni.uploadFile({
+      url: getApiUrl(`/minio/uploads?dir=${encodeURIComponent(dir)}`),
+      files: filePaths.map(p => ({ name: 'files', uri: p })),
+      header: { Authorization: `Bearer ${getToken()}` }, timeout: 60000,
+      success(response) {
+        try {
+          const result = parseApiResponse<ApiResponse & { files?: Array<{ relativePath?: string; url?: string }> }>(response.statusCode, response.data)
+          if (result.code === 200 && Array.isArray(result.files)) {
+            resolve(result.files.map(f => f.relativePath || f.url).filter(Boolean) as string[])
+          } else {
+            reject(new ApiError(result.msg || '图片上传失败', result.code))
+          }
+        } catch (error) { reject(error instanceof ApiError ? error : new ApiError('图片上传响应异常')) }
+      },
+      fail() { reject(new ApiError('图片上传失败,请重试')) },
+    })
+  })
 }
 
 export function downloadWarningAttachment(attachmentId: string): Promise<string> {

+ 6 - 30
src/services/workorders.ts

@@ -1,5 +1,6 @@
-import { ApiError, getApiUrl, parseApiResponse, requestApi, type ApiResponse } from './api'
-import { getToken } from '@/utils/auth'
+import { requestApi, type ApiResponse } from './api'
+import { uploadMinioFiles } from './minio-upload'
+import { resolveMinioUrl } from './minio'
 
 export interface WorkOrder {
   orderId: number
@@ -42,7 +43,6 @@ export interface WorkOrderLog {
 export interface WorkOrderDetail {
   order: WorkOrder
   logs: WorkOrderLog[]
-  attachmentBaseUrl?: string
 }
 
 export interface PageResult<T> {
@@ -69,7 +69,6 @@ export interface AssignableUser {
 }
 
 interface DataResponse<T> extends ApiResponse { data: T }
-interface UploadResponse extends ApiResponse { relativePath?: string; url?: string }
 
 const BASE = '/api/ops/workOrder'
 const MOBILE_BASE = '/api/mobile/ops/workOrder'
@@ -107,36 +106,13 @@ export const cancelWorkOrder = (id: number, data: Record<string, unknown>) => op
 export const applyDelayWorkOrder = (id: number, data: Record<string, unknown>) => operate(id, 'delay/apply', data)
 export const auditDelayWorkOrder = (id: number, data: Record<string, unknown>) => operate(id, 'delay/audit', data)
 
-export function uploadWorkOrderFile(filePath: string): Promise<string> {
-  return new Promise((resolve, reject) => {
-    uni.uploadFile({
-      url: `${getApiUrl('/minio/upload')}?dir=workOrder`,
-      filePath,
-      name: 'file',
-      header: { Authorization: `Bearer ${getToken()}` },
-      timeout: 30000,
-      success(response) {
-        try {
-          const result = parseApiResponse<UploadResponse>(response.statusCode, response.data)
-          if (result.relativePath) {
-            resolve(result.relativePath)
-          } else {
-            reject(new ApiError(result.msg || result.message || '附件上传失败', result.code))
-          }
-        } catch (error) {
-          reject(error instanceof ApiError ? error : new ApiError('附件上传响应格式异常'))
-        }
-      },
-      fail(error) { reject(new ApiError(error.errMsg?.includes('timeout') ? '附件上传超时' : '附件上传失败')) },
-    })
-  })
-}
+/** 工单附件批量上传:一次 /minio/uploads 请求,返回与传入顺序一致的相对路径 */
+export const uploadWorkOrderFiles = (filePaths: string[]) => uploadMinioFiles('work-order', filePaths)
 
 export function splitAttachments(value?: string) {
   return value ? value.split(',').map(item => item.trim()).filter(Boolean) : []
 }
 
 export function resolveAttachmentUrl(path: string, baseUrl?: string) {
-  if (/^(https?:|data:|blob:)/.test(path)) return path
-  return `${(baseUrl || '').replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
+  return resolveMinioUrl(path)
 }