提交 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 }) => { ...@@ -106,7 +106,7 @@ const AudioModal = ({ open, onClose, payload = {}, runtime }) => {
<div className='editor-content-form'> <div className='editor-content-form'>
<Form layout='vertical' name='validate_other' form={form} onFinish={onFinish}> <Form layout='vertical' name='validate_other' form={form} onFinish={onFinish}>
<Form.Item label='上传音频' name='audioUrl'> <Form.Item label='上传音频' name='audioUrl'>
<Spin spinning={uploading} tip='上传中'> <Spin spinning={uploading} description='上传中'>
<div className='editor-dragger'> <div className='editor-dragger'>
<Dragger {...uploadProps} showUploadList={false}> <Dragger {...uploadProps} showUploadList={false}>
{!audioUrl && ( {!audioUrl && (
......
import { useState, useEffect, forwardRef } from 'react' 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 { CloudUploadOutlined } from '@ant-design/icons'
import { DomEditor, SlateTransforms } from '@wangeditor/editor' import { DomEditor, SlateTransforms } from '@wangeditor/editor'
import '../../index.less' import '../../index.less'
...@@ -20,6 +20,7 @@ const ImageModal = (props, ref) => { ...@@ -20,6 +20,7 @@ const ImageModal = (props, ref) => {
const fileAccept = ['.png', '.jpg', '.jpeg', '.svg'] const fileAccept = ['.png', '.jpg', '.jpeg', '.svg']
const [form] = Form.useForm() const [form] = Form.useForm()
const [uploading, setUploading] = useState(false) // 文件上传状态 const [uploading, setUploading] = useState(false) // 文件上传状态
const [uploadProgress, setUploadProgress] = useState(0)
const [initValues, setInitValues] = useState({ imgUrl: '', imgTitle: '', imgDescript: '' }) const [initValues, setInitValues] = useState({ imgUrl: '', imgTitle: '', imgDescript: '' })
const [tempNodeInfo, setTempNodeInfo] = useState(null) const [tempNodeInfo, setTempNodeInfo] = useState(null)
...@@ -47,14 +48,21 @@ const ImageModal = (props, ref) => { ...@@ -47,14 +48,21 @@ const ImageModal = (props, ref) => {
} }
return true return true
}, },
customRequest: async ({ file, onSuccess, onError }) => { customRequest: async ({ file, onProgress, onSuccess, onError }) => {
setUploading(true) setUploading(true)
setUploadProgress(0)
try { try {
const url = await uploadFile(file) const url = await uploadFile(file, {
onProgress: (percent) => {
setUploadProgress(percent)
onProgress?.({ percent })
},
})
setImgUrl(url) setImgUrl(url)
form.setFieldsValue({ imgUrl: url }) form.setFieldsValue({ imgUrl: url })
onSuccess?.({ url }) onSuccess?.({ url })
} catch (error) { } catch (error) {
setUploadProgress(0)
message.error('图片上传失败,请重试') message.error('图片上传失败,请重试')
onError?.(error instanceof Error ? error : new Error('图片上传失败')) onError?.(error instanceof Error ? error : new Error('图片上传失败'))
} finally { } finally {
...@@ -66,6 +74,7 @@ const ImageModal = (props, ref) => { ...@@ -66,6 +74,7 @@ const ImageModal = (props, ref) => {
const clear = () => { const clear = () => {
setImgUrl('') setImgUrl('')
setUploading(false) setUploading(false)
setUploadProgress(0)
setTempNodeInfo(null) setTempNodeInfo(null)
setInitValues({ imgUrl: imgUrl, imgTitle: '', imgDescript: '' }) setInitValues({ imgUrl: imgUrl, imgTitle: '', imgDescript: '' })
form.resetFields() form.resetFields()
...@@ -161,43 +170,43 @@ const ImageModal = (props, ref) => { ...@@ -161,43 +170,43 @@ const ImageModal = (props, ref) => {
name='imgUrl' name='imgUrl'
rules={[{ required: true, message: '请上传图片' }]} rules={[{ required: true, message: '请上传图片' }]}
> >
<Spin spinning={uploading}> <div className='editor-dragger'>
<div className='editor-dragger'> <Dragger {...uploadProps} disabled={uploading} showUploadList={false}>
<Dragger {...uploadProps} showUploadList={false}> {uploading ? (
{!imgUrl && ( <div className='editor-uploader-progress'>
<div className='editor-uploader-process'> <Progress percent={uploadProgress} status='active' />
<p className='ant-upload-drag-icon'> <p className='ant-upload-hint'>图片上传中,请勿关闭窗口</p>
<CloudUploadOutlined style={{ fontSize: 40 }} /> </div>
</p> ) : !imgUrl ? (
<p className='ant-upload-text'>点击上传或拖拽到此处上传</p> <div className='editor-uploader-process'>
<p className='ant-upload-hint'> <p className='ant-upload-drag-icon'>
支持上传 .png、.jpg、.jpeg、.svg格式的图片 <CloudUploadOutlined style={{ fontSize: 40 }} />
</p> </p>
<p className='ant-upload-text'>点击上传或拖拽到此处上传</p>
<p className='ant-upload-hint'>支持上传 .png、.jpg、.jpeg、.svg格式的图片</p>
</div>
) : (
<div className='editor-uploader-result'>
<div className='editor-uploader-result-img'>
<img src={imgUrl} alt='' />
</div> </div>
)} <div className='editor-uploader-result-tips'>
{imgUrl && ( <Button
<div className='editor-uploader-result'> size='small'
<div className='editor-uploader-result-img'> color='primary'
<img src={imgUrl} alt='' /> variant='outlined'
</div> onClick={() => {
<div className='editor-uploader-result-tips'> setImgUrl(null)
<Button form.setFieldsValue({ imgUrl: '' })
size='small' }}
color='primary' >
variant='outlined' 替换
onClick={() => { </Button>
setImgUrl(null)
form.setFieldsValue({ imgUrl: '' })
}}
>
替换
</Button>
</div>
</div> </div>
)} </div>
</Dragger> )}
</div> </Dragger>
</Spin> </div>
</Form.Item> </Form.Item>
)} )}
<Form.Item label='图片标题' name='imgTitle' extra='最多输入100字'> <Form.Item label='图片标题' name='imgTitle' extra='最多输入100字'>
......
import { useState, useEffect } from 'react' 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 { CloudUploadOutlined } from '@ant-design/icons'
import { SlateTransforms } from '@wangeditor/editor' import { SlateTransforms } from '@wangeditor/editor'
import '../../index.less' import '../../index.less'
...@@ -20,6 +20,7 @@ const VideoModal = ({ open, onClose, payload = {}, runtime }) => { ...@@ -20,6 +20,7 @@ const VideoModal = ({ open, onClose, payload = {}, runtime }) => {
const fileAccept = ['.ogg', '.mp4', '.webm'] const fileAccept = ['.ogg', '.mp4', '.webm']
const [form] = Form.useForm() const [form] = Form.useForm()
const [uploading, setUploading] = useState(false) // 文件上传状态 const [uploading, setUploading] = useState(false) // 文件上传状态
const [uploadProgress, setUploadProgress] = useState(0)
const [videoUrl, setVideoUrl] = useState('') const [videoUrl, setVideoUrl] = useState('')
useEffect(() => { useEffect(() => {
...@@ -44,14 +45,23 @@ const VideoModal = ({ open, onClose, payload = {}, runtime }) => { ...@@ -44,14 +45,23 @@ const VideoModal = ({ open, onClose, payload = {}, runtime }) => {
} }
return true return true
}, },
customRequest: async ({ file, onSuccess, onError }) => { customRequest: async ({ file, onProgress, onSuccess, onError }) => {
setUploading(true) setUploading(true)
setUploadProgress(0)
try { try {
const url = await uploadFile(file) const url = await uploadFile(file, {
parallel: 3,
retries: 3,
onProgress: (percent) => {
setUploadProgress(percent)
onProgress?.({ percent })
},
})
setVideoUrl(url) setVideoUrl(url)
form.setFieldsValue({ videoUrl: url }) form.setFieldsValue({ videoUrl: url })
onSuccess?.({ url }) onSuccess?.({ url })
} catch (error) { } catch (error) {
setUploadProgress(0)
message.error('视频上传失败,请重试') message.error('视频上传失败,请重试')
onError?.(error instanceof Error ? error : new Error('视频上传失败')) onError?.(error instanceof Error ? error : new Error('视频上传失败'))
} finally { } finally {
...@@ -84,6 +94,7 @@ const VideoModal = ({ open, onClose, payload = {}, runtime }) => { ...@@ -84,6 +94,7 @@ const VideoModal = ({ open, onClose, payload = {}, runtime }) => {
setVideoUrl('') setVideoUrl('')
setUploading(false) setUploading(false)
setUploadProgress(0)
form.resetFields() form.resetFields()
onClose() onClose()
} }
...@@ -112,40 +123,35 @@ const VideoModal = ({ open, onClose, payload = {}, runtime }) => { ...@@ -112,40 +123,35 @@ const VideoModal = ({ open, onClose, payload = {}, runtime }) => {
name='videoUrl' name='videoUrl'
rules={[{ required: true, message: '请上传视频' }]} rules={[{ required: true, message: '请上传视频' }]}
> >
<Spin spinning={uploading} tip='上传中'> <div className='editor-dragger'>
<div className='editor-dragger'> <Dragger {...uploadProps} disabled={uploading} showUploadList={false}>
<Dragger {...uploadProps} showUploadList={false}> {uploading ? (
{!videoUrl && ( <div className='editor-uploader-progress'>
<div className='editor-uploader-process'> <Progress percent={uploadProgress} status='active' />
<p className='ant-upload-drag-icon'> <p className='ant-upload-hint'>视频上传中,请勿关闭窗口</p>
<CloudUploadOutlined style={{ fontSize: 40 }} /> </div>
</p> ) : !videoUrl ? (
<p className='ant-upload-text'>点击上传或拖拽到此处上传</p> <div className='editor-uploader-process'>
<p className='ant-upload-hint'>支持上传 .ogg、.mp4、.webm 格式的文件</p> <p className='ant-upload-drag-icon'>
<CloudUploadOutlined style={{ fontSize: 40 }} />
</p>
<p className='ant-upload-text'>点击上传或拖拽到此处上传</p>
<p className='ant-upload-hint'>支持上传 .ogg、.mp4、.webm 格式的文件</p>
</div>
) : (
<div className='editor-uploader-result'>
<div className='editor-uploader-result-img'>
<video src={videoUrl} autoPlay height='110' width='200' alt='' />
</div> </div>
)} <div className='editor-uploader-result-tips'>
{videoUrl && ( <Button size='small' type='primary' ghost onClick={() => setVideoUrl(null)}>
<> 替换
<div className='editor-uploader-result'> </Button>
<div className='editor-uploader-result-img'> </div>
<video src={videoUrl} autoPlay height='110' width='200' alt='' /> </div>
</div> )}
<div className='editor-uploader-result-tips'> </Dragger>
<Button </div>
size='small'
type='primary'
ghost
onClick={() => setVideoUrl(null)}
>
替换
</Button>
</div>
</div>
</>
)}
</Dragger>
</div>
</Spin>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label='视频标题' label='视频标题'
......
...@@ -23,7 +23,7 @@ export async function getOSSClient(bucket = 'zxts-book-file') { ...@@ -23,7 +23,7 @@ export async function getOSSClient(bucket = 'zxts-book-file') {
const client = new OSS({ const client = new OSS({
bucket, bucket,
...config, ...config,
timeout: 180000, timeout: 600000,
refreshSTSToken: async () => { refreshSTSToken: async () => {
const { accessKeyId, accessKeySecret, stsToken } = await getOSSConfig() const { accessKeyId, accessKeySecret, stsToken } = await getOSSConfig()
return { accessKeyId, accessKeySecret, stsToken } return { accessKeyId, accessKeySecret, stsToken }
...@@ -38,17 +38,47 @@ function getExtension(file) { ...@@ -38,17 +38,47 @@ function getExtension(file) {
const name = typeof file.name === 'string' ? file.name : '' const name = typeof file.name === 'string' ? file.name : ''
const fromName = name.includes('.') ? `.${name.split('.').pop().toLowerCase()}` : '' const fromName = name.includes('.') ? `.${name.split('.').pop().toLowerCase()}` : ''
if (fromName) return fromName 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)}` const filename = `${filenamePrefix}-${Date.now()}-${Math.random().toString(36).slice(2)}${getExtension(file)}`
return pathPrefix ? `${pathPrefix.replace(/\/$/, '')}/${filename}` : filename return pathPrefix ? `${pathPrefix.replace(/\/$/, '')}/${filename}` : filename
} }
export async function uploadFile(file, options = {}) { export async function uploadFile(file, options = {}) {
const oss = await getOSSClient(options.bucket) 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 return result.url
} }
......
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论