提交 462d9863 authored 作者: 王鹏飞's avatar 王鹏飞

feat: 添加文件上传进度显示,优化上传逻辑

上级 02ee8d01
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { client } = vi.hoisted(() => ({
client: {
generateObjectUrl: vi.fn(),
multipartUpload: vi.fn(),
put: vi.fn(),
},
}))
vi.mock('ali-oss', () => ({
default: vi.fn(function OSSClient() {
return client
}),
}))
vi.mock('@/api/base', () => ({
getSTSToken: vi.fn().mockResolvedValue({
data: {
endpoint: 'https://oss-cn-beijing.aliyuncs.com',
AccessKeyId: 'test-id',
AccessKeySecret: 'test-secret',
SecurityToken: 'test-token',
},
}),
}))
import { uploadFile } from '@/utils/oss'
describe('OSS upload progress', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('keeps the existing single-request upload when progress is not requested', async () => {
client.put.mockResolvedValue({ url: 'https://example.com/image.jpg' })
await expect(uploadFile({ name: 'image.jpg' })).resolves.toBe('https://example.com/image.jpg')
expect(client.put).toHaveBeenCalledOnce()
expect(client.multipartUpload).not.toHaveBeenCalled()
})
it('reports multipart progress as an integer percentage', async () => {
client.multipartUpload.mockImplementation(async (name, file, options) => {
await options.progress(0)
await options.progress(0.42)
await options.progress(1)
return { name }
})
client.generateObjectUrl.mockReturnValue('https://example.com/video.mp4')
const onProgress = vi.fn()
await expect(uploadFile({ name: 'video.mp4' }, { onProgress })).resolves.toBe(
'https://example.com/video.mp4',
)
expect(onProgress.mock.calls.map(([percent]) => percent)).toEqual([0, 42, 100])
expect(client.multipartUpload).toHaveBeenCalledOnce()
expect(client.put).not.toHaveBeenCalled()
})
it('resumes a failed multipart upload from its latest checkpoint', async () => {
const checkpoint = { name: 'video.mp4', doneParts: [{ number: 1 }] }
client.multipartUpload
.mockImplementationOnce(async (name, file, options) => {
await options.progress(0.5, checkpoint)
throw new Error('network error')
})
.mockImplementationOnce(async (name, file, options) => {
await options.progress(1, null)
return { name }
})
client.generateObjectUrl.mockReturnValue('https://example.com/video.mp4')
const onProgress = vi.fn()
await expect(
uploadFile({ name: 'video.mp4' }, { onProgress, parallel: 3, retries: 3 }),
).resolves.toBe('https://example.com/video.mp4')
expect(client.multipartUpload).toHaveBeenCalledTimes(2)
expect(client.multipartUpload.mock.calls[0][2]).toMatchObject({ parallel: 3 })
expect(client.multipartUpload.mock.calls[1][2]).toMatchObject({ checkpoint, parallel: 3 })
expect(onProgress.mock.calls.map(([percent]) => percent)).toEqual([50, 100])
})
})
......@@ -106,7 +106,7 @@ const AudioModal = ({ open, onClose, payload = {}, runtime }) => {
<div className='editor-content-form'>
<Form layout='vertical' name='validate_other' form={form} onFinish={onFinish}>
<Form.Item label='上传音频' name='audioUrl'>
<Spin spinning={uploading} tip='上传中'>
<Spin spinning={uploading} description='上传中'>
<div className='editor-dragger'>
<Dragger {...uploadProps} showUploadList={false}>
{!audioUrl && (
......
import { useState, useEffect, forwardRef } from 'react'
import { Divider, Upload, Input, Space, Button, Form, Spin, message } from 'antd'
import { Divider, Upload, Input, Space, Button, Form, Progress, message } from 'antd'
import { CloudUploadOutlined } from '@ant-design/icons'
import { DomEditor, SlateTransforms } from '@wangeditor/editor'
import '../../index.less'
......@@ -20,6 +20,7 @@ const ImageModal = (props, ref) => {
const fileAccept = ['.png', '.jpg', '.jpeg', '.svg']
const [form] = Form.useForm()
const [uploading, setUploading] = useState(false) // 文件上传状态
const [uploadProgress, setUploadProgress] = useState(0)
const [initValues, setInitValues] = useState({ imgUrl: '', imgTitle: '', imgDescript: '' })
const [tempNodeInfo, setTempNodeInfo] = useState(null)
......@@ -47,14 +48,21 @@ const ImageModal = (props, ref) => {
}
return true
},
customRequest: async ({ file, onSuccess, onError }) => {
customRequest: async ({ file, onProgress, onSuccess, onError }) => {
setUploading(true)
setUploadProgress(0)
try {
const url = await uploadFile(file)
const url = await uploadFile(file, {
onProgress: (percent) => {
setUploadProgress(percent)
onProgress?.({ percent })
},
})
setImgUrl(url)
form.setFieldsValue({ imgUrl: url })
onSuccess?.({ url })
} catch (error) {
setUploadProgress(0)
message.error('图片上传失败,请重试')
onError?.(error instanceof Error ? error : new Error('图片上传失败'))
} finally {
......@@ -66,6 +74,7 @@ const ImageModal = (props, ref) => {
const clear = () => {
setImgUrl('')
setUploading(false)
setUploadProgress(0)
setTempNodeInfo(null)
setInitValues({ imgUrl: imgUrl, imgTitle: '', imgDescript: '' })
form.resetFields()
......@@ -161,21 +170,22 @@ const ImageModal = (props, ref) => {
name='imgUrl'
rules={[{ required: true, message: '请上传图片' }]}
>
<Spin spinning={uploading}>
<div className='editor-dragger'>
<Dragger {...uploadProps} showUploadList={false}>
{!imgUrl && (
<Dragger {...uploadProps} disabled={uploading} showUploadList={false}>
{uploading ? (
<div className='editor-uploader-progress'>
<Progress percent={uploadProgress} status='active' />
<p className='ant-upload-hint'>图片上传中,请勿关闭窗口</p>
</div>
) : !imgUrl ? (
<div className='editor-uploader-process'>
<p className='ant-upload-drag-icon'>
<CloudUploadOutlined style={{ fontSize: 40 }} />
</p>
<p className='ant-upload-text'>点击上传或拖拽到此处上传</p>
<p className='ant-upload-hint'>
支持上传 .png、.jpg、.jpeg、.svg格式的图片
</p>
<p className='ant-upload-hint'>支持上传 .png、.jpg、.jpeg、.svg格式的图片</p>
</div>
)}
{imgUrl && (
) : (
<div className='editor-uploader-result'>
<div className='editor-uploader-result-img'>
<img src={imgUrl} alt='' />
......@@ -197,7 +207,6 @@ const ImageModal = (props, ref) => {
)}
</Dragger>
</div>
</Spin>
</Form.Item>
)}
<Form.Item label='图片标题' name='imgTitle' extra='最多输入100字'>
......
import { useState, useEffect } from 'react'
import { Modal, Divider, Upload, Input, Space, Button, Form, Spin, message } from 'antd'
import { Modal, Divider, Upload, Input, Space, Button, Form, Progress, message } from 'antd'
import { CloudUploadOutlined } from '@ant-design/icons'
import { SlateTransforms } from '@wangeditor/editor'
import '../../index.less'
......@@ -20,6 +20,7 @@ const VideoModal = ({ open, onClose, payload = {}, runtime }) => {
const fileAccept = ['.ogg', '.mp4', '.webm']
const [form] = Form.useForm()
const [uploading, setUploading] = useState(false) // 文件上传状态
const [uploadProgress, setUploadProgress] = useState(0)
const [videoUrl, setVideoUrl] = useState('')
useEffect(() => {
......@@ -44,14 +45,23 @@ const VideoModal = ({ open, onClose, payload = {}, runtime }) => {
}
return true
},
customRequest: async ({ file, onSuccess, onError }) => {
customRequest: async ({ file, onProgress, onSuccess, onError }) => {
setUploading(true)
setUploadProgress(0)
try {
const url = await uploadFile(file)
const url = await uploadFile(file, {
parallel: 3,
retries: 3,
onProgress: (percent) => {
setUploadProgress(percent)
onProgress?.({ percent })
},
})
setVideoUrl(url)
form.setFieldsValue({ videoUrl: url })
onSuccess?.({ url })
} catch (error) {
setUploadProgress(0)
message.error('视频上传失败,请重试')
onError?.(error instanceof Error ? error : new Error('视频上传失败'))
} finally {
......@@ -84,6 +94,7 @@ const VideoModal = ({ open, onClose, payload = {}, runtime }) => {
setVideoUrl('')
setUploading(false)
setUploadProgress(0)
form.resetFields()
onClose()
}
......@@ -112,10 +123,14 @@ const VideoModal = ({ open, onClose, payload = {}, runtime }) => {
name='videoUrl'
rules={[{ required: true, message: '请上传视频' }]}
>
<Spin spinning={uploading} tip='上传中'>
<div className='editor-dragger'>
<Dragger {...uploadProps} showUploadList={false}>
{!videoUrl && (
<Dragger {...uploadProps} disabled={uploading} showUploadList={false}>
{uploading ? (
<div className='editor-uploader-progress'>
<Progress percent={uploadProgress} status='active' />
<p className='ant-upload-hint'>视频上传中,请勿关闭窗口</p>
</div>
) : !videoUrl ? (
<div className='editor-uploader-process'>
<p className='ant-upload-drag-icon'>
<CloudUploadOutlined style={{ fontSize: 40 }} />
......@@ -123,29 +138,20 @@ const VideoModal = ({ open, onClose, payload = {}, runtime }) => {
<p className='ant-upload-text'>点击上传或拖拽到此处上传</p>
<p className='ant-upload-hint'>支持上传 .ogg、.mp4、.webm 格式的文件</p>
</div>
)}
{videoUrl && (
<>
) : (
<div className='editor-uploader-result'>
<div className='editor-uploader-result-img'>
<video src={videoUrl} autoPlay height='110' width='200' alt='' />
</div>
<div className='editor-uploader-result-tips'>
<Button
size='small'
type='primary'
ghost
onClick={() => setVideoUrl(null)}
>
<Button size='small' type='primary' ghost onClick={() => setVideoUrl(null)}>
替换
</Button>
</div>
</div>
</>
)}
</Dragger>
</div>
</Spin>
</Form.Item>
<Form.Item
label='视频标题'
......
......@@ -23,7 +23,7 @@ export async function getOSSClient(bucket = 'zxts-book-file') {
const client = new OSS({
bucket,
...config,
timeout: 180000,
timeout: 600000,
refreshSTSToken: async () => {
const { accessKeyId, accessKeySecret, stsToken } = await getOSSConfig()
return { accessKeyId, accessKeySecret, stsToken }
......@@ -38,17 +38,47 @@ function getExtension(file) {
const name = typeof file.name === 'string' ? file.name : ''
const fromName = name.includes('.') ? `.${name.split('.').pop().toLowerCase()}` : ''
if (fromName) return fromName
return { 'image/jpeg': '.jpg', 'image/png': '.png', 'video/mp4': '.mp4' }[(file.type || '').toLowerCase()] || '.bin'
return (
{ 'image/jpeg': '.jpg', 'image/png': '.png', 'video/mp4': '.mp4' }[
(file.type || '').toLowerCase()
] || '.bin'
)
}
function buildObjectName(file, { pathPrefix = dayjs().format('YYYY/MM/DD'), filenamePrefix = 'file' } = {}) {
function buildObjectName(
file,
{ pathPrefix = dayjs().format('YYYY/MM/DD'), filenamePrefix = 'file' } = {},
) {
const filename = `${filenamePrefix}-${Date.now()}-${Math.random().toString(36).slice(2)}${getExtension(file)}`
return pathPrefix ? `${pathPrefix.replace(/\/$/, '')}/${filename}` : filename
}
export async function uploadFile(file, options = {}) {
const oss = await getOSSClient(options.bucket)
const result = await oss.put(buildObjectName(file, options), file)
const objectName = buildObjectName(file, options)
if (options.onProgress) {
const retries = options.retries ?? 0
let checkpoint
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const result = await oss.multipartUpload(objectName, file, {
checkpoint,
parallel: options.parallel,
progress: (percentage, nextCheckpoint) => {
checkpoint = nextCheckpoint || checkpoint
options.onProgress(Math.round(percentage * 100))
},
})
return oss.generateObjectUrl(result.name)
} catch (error) {
if (attempt === retries) throw error
}
}
}
const result = await oss.put(objectName, file)
return result.url
}
......
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论