| | |
| | | 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) |
| | | } |
| | |
| | | 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 { |
| | |
| | | } |
| | | } |
| | | |
| | | 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 |
| | |
| | | <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() |
| | |
| | | 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 || '', |
| | |
| | | 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 || '', |
| | |
| | | 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('请等待文件上传完成') |
| | |
| | | <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' |
| | |
| | | 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('请等待文件上传完成') |
| | |
| | | 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 || '', |
| | |
| | | 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 || '', |