董国庆
2026-06-22 406f2c8defe72dbc7bcff98e4ff76a73aeae9795
文件上传修改-文件直传
4个文件已修改
224 ■■■■■ 已修改文件
src/service/api/common.ts 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/upload.ts 158 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/appVersion/appVersionManage/add.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/appVersion/appVersionManage/index.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/common.ts
@@ -19,3 +19,28 @@
export function uploadFile(formData: FormData) {
  return request.Post<Service.ResponseResult<{ id: string, name: string, url: string }>>('/operation/base/v1/oss/upload', formData)
}
export interface UploadOSSUrlParams {
  /** 前端生成的文件唯一标识,直传成功后保存时回传给业务接口 */
  fileId: string
}
export interface UploadOSSUrlResult {
  /** 可直接用于浏览器上传的 OSS 预签名地址 */
  uploadUrl?: string
  /** 文件最终访问地址 */
  url?: string
  /** 兼容后端返回的其他文件地址字段 */
  fileUrl?: string
  /** 兼容后端返回的其他文件地址字段 */
  ossUrl?: string
}
/**
 * 获取 OSS 直传地址
 * @param params
 * @returns
 */
export function getUploadOSSUrl(params: UploadOSSUrlParams) {
  return request.Post<Service.ResponseResult<string | UploadOSSUrlResult>>('/operation/base/v1/oss/getUploadOSSUrl', params)
}
src/utils/upload.ts
@@ -1,5 +1,6 @@
import type { UploadFileInfo } from 'naive-ui'
import { uploadFile } from '@/service/api/common'
import type { UploadCustomRequestOptions, UploadFileInfo } from 'naive-ui'
import { v4 as uuidv4 } from 'uuid'
import { getUploadOSSUrl, uploadFile } from '@/service/api/common'
export async function uploadSingleFile(file: File) {
  try {
@@ -87,6 +88,159 @@
  }
}
interface DirectUploadUrlResult {
  /** OSS 预签名上传地址 */
  uploadUrl: string
  /** 文件直传成功后的访问地址 */
  url?: string
}
interface CustomDirectUploadRequestOptions extends UploadCustomRequestOptions {
  /** 可选:外部指定 fileId,不传则公共方法内部自动生成 */
  fileId?: string
  /** 可选:允许业务侧覆盖“获取上传地址”的实现 */
  getUploadUrl?: (params: { fileId: string, file: File }) => Promise<string | { uploadUrl?: string, url?: string, fileUrl?: string, ossUrl?: string }>
}
/**
 * 统一兼容后端返回的直传地址格式,便于页面只关心上传行为本身。
 */
function normalizeDirectUploadUrlResult(raw: string | { uploadUrl?: string, url?: string, fileUrl?: string, ossUrl?: string } | null | undefined): DirectUploadUrlResult | null {
  if (!raw)
    return null
  if (typeof raw === 'string') {
    const uploadUrl = raw.trim()
    if (!uploadUrl)
      return null
    return {
      uploadUrl,
      url: uploadUrl.split('?')[0] || '',
    }
  }
  const uploadUrl = String(raw.uploadUrl || raw.url || raw.fileUrl || raw.ossUrl || '').trim()
  if (!uploadUrl)
    return null
  const fileUrl = String(raw.url || raw.fileUrl || raw.ossUrl || '').trim()
  return {
    uploadUrl,
    url: fileUrl || uploadUrl.split('?')[0] || '',
  }
}
/**
 * 使用浏览器原生 XHR 直传文件:
 * 1. 便于拿到上传进度
 * 2. 不受当前 alova/fetch 封装限制
 */
function uploadFileToSignedUrl({
  uploadUrl,
  file,
  onProgress,
}: {
  uploadUrl: string
  file: File
  onProgress?: (e: { percent: number }) => void
}) {
  return new Promise<void>((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    xhr.open('PUT', uploadUrl, true)
    xhr.upload.onprogress = (event) => {
      if (!event.lengthComputable)
        return
      onProgress?.({
        percent: Math.min(100, Math.round((event.loaded / event.total) * 100)),
      })
    }
    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        onProgress?.({ percent: 100 })
        resolve()
        return
      }
      reject(new Error(`Upload failed with status ${xhr.status}`))
    }
    xhr.onerror = () => {
      reject(new Error('Network error'))
    }
    xhr.onabort = () => {
      reject(new Error('Upload aborted'))
    }
    xhr.send(file)
  })
}
/**
 * Naive UI n-upload 前端直传封装:
 * 1. 先获取 OSS 直传地址
 * 2. 再通过浏览器直接上传文件
 */
export async function customDirectUploadRequest({
  file,
  onFinish,
  onError,
  onProgress,
  fileId,
  getUploadUrl,
}: CustomDirectUploadRequestOptions) {
  try {
    const rawFile = file.file
    if (!rawFile) {
      file.status = 'error'
      onError()
      window.$message.error('未获取到上传文件')
      return undefined
    }
    // 直传链路里 fileId 由前端先生成,后续保存业务表单时直接提交它
    const nextFileId = String(fileId || uuidv4())
    const uploadUrlResult = normalizeDirectUploadUrlResult(
      await (getUploadUrl
        ? getUploadUrl({ fileId: nextFileId, file: rawFile })
        : getUploadOSSUrl({ fileId: nextFileId })),
    )
    if (!uploadUrlResult?.uploadUrl) {
      file.status = 'error'
      onError()
      window.$message.error('未获取到上传地址')
      return undefined
    }
    await uploadFileToSignedUrl({
      uploadUrl: uploadUrlResult.uploadUrl,
      file: rawFile,
      onProgress,
    })
    // 回写 n-upload 所需状态,保持页面文件列表展示正常
    file.status = 'finished'
    file.url = uploadUrlResult.url || ''
    onFinish()
    return {
      id: nextFileId,
      url: uploadUrlResult.url || '',
      name: file.name || rawFile.name,
      status: 'finished' as const,
    }
  }
  catch (err) {
    file.status = 'error'
    onError()
    window.$message.error('文件上传出错')
    console.error('Direct Upload Error:', err)
    return undefined
  }
}
/**
 * 文件上传前校验封装
 * @param data
src/views/system/appVersion/appVersionManage/add.vue
@@ -1,10 +1,10 @@
<script setup lang="tsx">
import { useMessage } from 'naive-ui'
import type { FormInst, FormRules, UploadFileInfo } from 'naive-ui'
import type { FormInst, FormRules, UploadCustomRequestOptions, UploadFileInfo } from 'naive-ui'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { beforeUploadCheck, customUploadRequest } from '@/utils/upload'
import { releaseAppVersion, stringifyAppVersionFileValue } from '@/service/api/appVersion'
import { beforeUploadCheck, customDirectUploadRequest } from '@/utils/upload'
import { releaseAppVersion } from '@/service/api/appVersion'
const router = useRouter()
const message = useMessage()
@@ -51,13 +51,14 @@
    originalOnError?.()
  }
  try {
    const res = await customUploadRequest(options)
    // 安卓包改为前端直传:先拿上传地址,再将文件直接传到 OSS
    const res = await customDirectUploadRequest(options as UploadCustomRequestOptions)
    if (!res) {
      formModel.value.androidFiles = []
      return
    }
    formModel.value.androidFiles = [{
      id: res.id,
      id: String(res.id || ''),
      name: res.name || 'android.apk',
      status: 'finished',
      url: res.url || '',
@@ -76,13 +77,14 @@
    originalOnError?.()
  }
  try {
    const res = await customUploadRequest(options)
    // iOS 包改为前端直传:上传完成后将 fileId 存入文件项 id 字段
    const res = await customDirectUploadRequest(options as UploadCustomRequestOptions)
    if (!res) {
      formModel.value.iosFiles = []
      return
    }
    formModel.value.iosFiles = [{
      id: res.id,
      id: String(res.id || ''),
      name: res.name || 'ios.ipa',
      status: 'finished',
      url: res.url || '',
@@ -105,8 +107,9 @@
      return
    }
    const androidFile = stringifyAppVersionFileValue(formModel.value.androidFiles[0])
    const iosFile = stringifyAppVersionFileValue(formModel.value.iosFiles[0])
    // 保存时只提交直传阶段生成的 fileId,不再传整段文件信息
    const androidFile = String(formModel.value.androidFiles[0]?.id || '').trim()
    const iosFile = String(formModel.value.iosFiles[0]?.id || '').trim()
    if (!androidFile || !iosFile) {
      message.error('请等待文件上传完成')
src/views/system/appVersion/appVersionManage/index.vue
@@ -1,15 +1,14 @@
<script setup lang="tsx">
import noDataImg from '@/assets/element-img/no-data.png'
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import type { DataTableColumns, FormInst, FormRules, UploadFileInfo } from 'naive-ui'
import type { DataTableColumns, FormInst, FormRules, UploadCustomRequestOptions, UploadFileInfo } from 'naive-ui'
import { NButton } from 'naive-ui'
import dayjs from 'dayjs'
import { beforeUploadCheck, customUploadRequest } from '@/utils/upload'
import { beforeUploadCheck, customDirectUploadRequest } from '@/utils/upload'
import {
  fetchAppVersionPage,
  parseAppVersionFileValue,
  releaseAppVersion,
  stringifyAppVersionFileValue,
} from '@/service/api/appVersion'
import type { AppVersionItem } from '@/service/api/appVersion'
import Pagination from '@/components/common/Pagination.vue'
@@ -200,8 +199,9 @@
      return
    }
    const androidFile = stringifyAppVersionFileValue(formModel.value.androidFiles[0])
    const iosFile = stringifyAppVersionFileValue(formModel.value.iosFiles[0])
    // 弹窗新增改为前端直传,保存时提交上传阶段生成的 fileId
    const androidFile = String(formModel.value.androidFiles[0]?.id || '').trim()
    const iosFile = String(formModel.value.iosFiles[0]?.id || '').trim()
    if (!androidFile || !iosFile) {
      window.$message?.error('请等待文件上传完成')
@@ -247,13 +247,14 @@
    originalOnError?.()
  }
  try {
    const res = await customUploadRequest(options)
    // 安卓包改为前端直传:先换取上传地址,再直接上传到 OSS
    const res = await customDirectUploadRequest(options as UploadCustomRequestOptions)
    if (!res) {
      formModel.value.androidFiles = []
    }
    else {
      formModel.value.androidFiles = [{
        id: res.id,
        id: String(res.id || ''),
        name: res.name || 'android.apk',
        status: 'finished',
        url: res.url || '',
@@ -273,13 +274,14 @@
    originalOnError?.()
  }
  try {
    const res = await customUploadRequest(options)
    // iOS 包改为前端直传:上传成功后将 fileId 存到文件项 id 字段
    const res = await customDirectUploadRequest(options as UploadCustomRequestOptions)
    if (!res) {
      formModel.value.iosFiles = []
    }
    else {
      formModel.value.iosFiles = [{
        id: res.id,
        id: String(res.id || ''),
        name: res.name || 'ios.ipa',
        status: 'finished',
        url: res.url || '',