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 {
|
const formData = new FormData()
|
formData.append('file', file)
|
const res = await uploadFile(formData)
|
if (res.code === 200)
|
return res.data
|
|
window.$message.error('文件上传失败')
|
throw new Error('Upload failed')
|
}
|
catch (err) {
|
window.$message.error('文件上传出错')
|
console.error('Upload Error:', err)
|
throw err
|
}
|
}
|
|
/**
|
* Naive UI n-upload 自定义上传请求封装
|
* @param options Naive UI upload options
|
*/
|
export async function customUploadRequest({
|
file,
|
onFinish,
|
onError,
|
onProgress,
|
}: {
|
file: UploadFileInfo
|
onFinish: () => void
|
onError: () => void
|
onProgress: (e: { percent: number }) => void
|
}) {
|
let timer: ReturnType<typeof setInterval> | undefined
|
try {
|
let progress = 0
|
timer = setInterval(() => {
|
progress += 20
|
if (progress <= 100) {
|
onProgress({ percent: progress })
|
}
|
else if (timer) {
|
clearInterval(timer)
|
timer = undefined
|
}
|
}, 100)
|
|
await new Promise(resolve => setTimeout(resolve, 600))
|
|
const formData = new FormData()
|
if (file.file) {
|
formData.append('file', file.file)
|
}
|
const res = await uploadFile(formData)
|
if (res.code === 200) {
|
file.url = res.data.url
|
// 不要修改 file.id,以免破坏 Naive UI 的内部追踪逻辑
|
file.status = 'finished'
|
onFinish()
|
return {
|
id: res.data.id, // 返回后端 ID 给组件使用
|
url: res.data.url,
|
name: file.name,
|
status: 'finished',
|
}
|
}
|
else {
|
file.status = 'error'
|
onError()
|
window.$message.error('文件上传失败')
|
return undefined
|
}
|
}
|
catch (err) {
|
file.status = 'error'
|
onError()
|
window.$message.error('文件上传出错')
|
console.error('Upload Error:', err)
|
return undefined
|
}
|
finally {
|
if (timer)
|
clearInterval(timer)
|
}
|
}
|
|
interface DirectUploadUrlResult {
|
/** OSS 预签名上传地址 */
|
uploadUrl: string
|
/** 文件直传成功后的访问地址 */
|
url?: string
|
}
|
|
type DirectUploadUrlPayload = string | {
|
uploadUrl?: string
|
url?: string
|
fileUrl?: string
|
ossUrl?: string
|
}
|
|
function logDirectUpload(step: string, payload?: unknown) {
|
console.log(`[direct-upload] ${step}`, payload)
|
}
|
|
interface CustomDirectUploadRequestOptions extends UploadCustomRequestOptions {
|
/** 可选:外部指定 fileId,不传则公共方法内部自动生成 */
|
fileId?: string
|
/** 可选:允许业务侧覆盖“获取上传地址”的实现 */
|
getUploadUrl?: (params: { fileId: string, file: File }) => Promise<Service.ResponseResult<DirectUploadUrlPayload> | DirectUploadUrlPayload>
|
}
|
|
/**
|
* 统一兼容后端返回的直传地址格式,便于页面只关心上传行为本身。
|
* 支持两类返回:
|
* 1. 直接返回上传地址字符串 / 对象
|
* 2. 返回标准响应结构,上传地址放在 data 字段里
|
*/
|
function normalizeDirectUploadUrlResult(raw: Service.ResponseResult<DirectUploadUrlPayload> | DirectUploadUrlPayload | null | undefined): DirectUploadUrlResult | null {
|
if (!raw)
|
return null
|
|
const payload = (
|
typeof raw === 'object'
|
&& raw !== null
|
&& 'data' in raw
|
? raw.data
|
: raw
|
)
|
|
if (typeof payload === 'string') {
|
const uploadUrl = payload.trim()
|
if (!uploadUrl)
|
return null
|
return {
|
uploadUrl,
|
url: uploadUrl.split('?')[0] || '',
|
}
|
}
|
|
const uploadUrl = String(payload?.uploadUrl || payload?.url || payload?.fileUrl || payload?.ossUrl || '').trim()
|
if (!uploadUrl)
|
return null
|
|
const fileUrl = String(payload?.url || payload?.fileUrl || payload?.ossUrl || '').trim()
|
return {
|
uploadUrl,
|
url: fileUrl || uploadUrl.split('?')[0] || '',
|
}
|
}
|
|
/**
|
* 使用浏览器原生 XHR 直传文件:
|
* 1. 便于拿到上传进度
|
* 2. 不受当前 alova/fetch 封装限制
|
*/
|
function uploadFileToSignedUrl({
|
uploadUrl,
|
file,
|
onProgress,
|
fileId,
|
}: {
|
uploadUrl: string
|
file: File
|
onProgress?: (e: { percent: number }) => void
|
fileId?: string
|
}) {
|
return new Promise<void>((resolve, reject) => {
|
const xhr = new XMLHttpRequest()
|
logDirectUpload('xhr open', {
|
fileId,
|
fileName: file.name,
|
method: 'PUT',
|
uploadUrl,
|
})
|
xhr.open('PUT', uploadUrl, true)
|
xhr.setRequestHeader('Content-Type', 'application/octet-stream')
|
|
xhr.upload.onprogress = (event) => {
|
if (!event.lengthComputable)
|
return
|
const percent = Math.min(100, Math.round((event.loaded / event.total) * 100))
|
logDirectUpload('upload progress', {
|
fileId,
|
fileName: file.name,
|
loaded: event.loaded,
|
total: event.total,
|
percent,
|
})
|
onProgress?.({
|
percent,
|
})
|
}
|
|
xhr.onload = () => {
|
logDirectUpload('xhr load', {
|
fileId,
|
fileName: file.name,
|
status: xhr.status,
|
responseText: xhr.responseText,
|
})
|
if (xhr.status >= 200 && xhr.status < 300) {
|
onProgress?.({ percent: 100 })
|
resolve()
|
return
|
}
|
reject(new Error(`Upload failed with status ${xhr.status}`))
|
}
|
|
xhr.onerror = () => {
|
logDirectUpload('xhr error', {
|
fileId,
|
fileName: file.name,
|
status: xhr.status,
|
})
|
reject(new Error('Network error'))
|
}
|
|
xhr.onabort = () => {
|
logDirectUpload('xhr abort', {
|
fileId,
|
fileName: file.name,
|
})
|
reject(new Error('Upload aborted'))
|
}
|
|
logDirectUpload('xhr send', {
|
fileId,
|
fileName: file.name,
|
size: file.size,
|
type: file.type,
|
})
|
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
|
logDirectUpload('custom request start', {
|
fileName: file.name,
|
hasRawFile: Boolean(rawFile),
|
source: getUploadUrl ? 'custom getUploadUrl' : 'default getUploadOSSUrl',
|
})
|
if (!rawFile) {
|
logDirectUpload('custom request missing raw file', {
|
fileName: file.name,
|
file,
|
})
|
file.status = 'error'
|
onError()
|
window.$message.error('未获取到上传文件')
|
return undefined
|
}
|
|
// 直传链路里 fileId 由前端先生成,后续保存业务表单时直接提交它
|
const nextFileId = String(fileId || uuidv4())
|
logDirectUpload('generated file id', {
|
fileId: nextFileId,
|
fileName: rawFile.name,
|
size: rawFile.size,
|
type: rawFile.type,
|
})
|
const rawUploadUrlResponse = await (getUploadUrl
|
? getUploadUrl({ fileId: nextFileId, file: rawFile })
|
: getUploadOSSUrl({ fileId: nextFileId }))
|
logDirectUpload('get upload url response', {
|
fileId: nextFileId,
|
fileName: rawFile.name,
|
response: rawUploadUrlResponse,
|
})
|
|
const uploadUrlResult = normalizeDirectUploadUrlResult(rawUploadUrlResponse)
|
logDirectUpload('normalized upload url', {
|
fileId: nextFileId,
|
uploadUrl: uploadUrlResult?.uploadUrl,
|
fileUrl: uploadUrlResult?.url,
|
})
|
|
if (!uploadUrlResult?.uploadUrl) {
|
file.status = 'error'
|
onError()
|
window.$message.error('未获取到上传地址')
|
return undefined
|
}
|
|
logDirectUpload('start upload', {
|
fileId: nextFileId,
|
fileName: rawFile.name,
|
uploadUrl: uploadUrlResult.uploadUrl,
|
})
|
await uploadFileToSignedUrl({
|
uploadUrl: uploadUrlResult.uploadUrl,
|
file: rawFile,
|
onProgress,
|
fileId: nextFileId,
|
})
|
logDirectUpload('upload success', {
|
fileId: nextFileId,
|
fileName: rawFile.name,
|
fileUrl: uploadUrlResult.url,
|
})
|
|
// 回写 n-upload 所需状态,保持页面文件列表展示正常
|
file.status = 'finished'
|
file.url = uploadUrlResult.url || ''
|
logDirectUpload('call onFinish', {
|
fileId: nextFileId,
|
fileName: rawFile.name,
|
})
|
onFinish()
|
|
return {
|
id: nextFileId,
|
url: uploadUrlResult.url || '',
|
name: file.name || rawFile.name,
|
status: 'finished' as const,
|
}
|
}
|
catch (err) {
|
logDirectUpload('upload failed', {
|
error: err,
|
fileName: file.name,
|
})
|
file.status = 'error'
|
onError()
|
window.$message.error('文件上传出错')
|
console.error('Direct Upload Error:', err)
|
return undefined
|
}
|
}
|
|
/**
|
* 文件上传前校验封装
|
* @param data
|
* @param options 校验配置
|
*/
|
export function beforeUploadCheck(
|
data: { file: UploadFileInfo, fileList: UploadFileInfo[] },
|
options: { maxSize?: number, allowedTypes?: string[] } = {},
|
) {
|
// const { maxSize = 5, allowedTypes = ['image/jpeg', 'image/png', 'image/jpg'] } = options
|
const { maxSize = 5, allowedTypes } = options
|
const file = data.file.file
|
logDirectUpload('before upload check start', {
|
fileName: data.file.name,
|
hasRawFile: Boolean(file),
|
size: file?.size,
|
type: file?.type,
|
maxSize,
|
allowedTypes,
|
})
|
if (!file)
|
return false
|
|
// 检查文件类型
|
if (allowedTypes && allowedTypes?.length > 0 && !allowedTypes.includes(file.type)) {
|
logDirectUpload('before upload check failed: type', {
|
fileName: data.file.name,
|
fileType: file.type,
|
allowedTypes,
|
})
|
window.$message.error(`只能上传 ${allowedTypes.join('/')} 格式的文件`)
|
return false
|
}
|
|
// 检查文件大小
|
const isLtSize = file.size / 1024 / 1024 < maxSize
|
if (!isLtSize) {
|
logDirectUpload('before upload check failed: size', {
|
fileName: data.file.name,
|
size: file.size,
|
maxSize,
|
})
|
window.$message.error(`文件大小不能超过 ${maxSize}MB`)
|
return false
|
}
|
|
logDirectUpload('before upload check passed', {
|
fileName: data.file.name,
|
size: file.size,
|
type: file.type,
|
})
|
return true
|
}
|