feat: 添加文件上传进度和速度显示
- 新增 api.postFormDataWithProgress 方法支持上传进度回调 - 版本创建和编辑页面显示上传进度条和实时速度 - 按钮显示上传百分比 - 添加国际化翻译 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,8 @@ const versionId = computed(() => route.params.id as string)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const uploading = ref(false)
|
||||
const uploadProgress = ref(0)
|
||||
const uploadSpeed = ref('')
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const formData = ref({
|
||||
@@ -168,11 +170,20 @@ async function uploadZipFile() {
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
uploadProgress.value = 0
|
||||
uploadSpeed.value = ''
|
||||
try {
|
||||
const formDataObj = new FormData()
|
||||
formDataObj.append('file', formData.value.file)
|
||||
|
||||
const response = await api.postFormData<UploadResponse>('/dev/versions/upload-zip', formDataObj)
|
||||
const response = await api.postFormDataWithProgress<UploadResponse>(
|
||||
'/dev/versions/upload-zip',
|
||||
formDataObj,
|
||||
(progress, speed) => {
|
||||
uploadProgress.value = progress
|
||||
uploadSpeed.value = speed
|
||||
}
|
||||
)
|
||||
uploadedData.value = response
|
||||
toast.success(t('admin.versions.createForm.uploadSuccess'))
|
||||
}
|
||||
@@ -182,6 +193,8 @@ async function uploadZipFile() {
|
||||
}
|
||||
finally {
|
||||
uploading.value = false
|
||||
uploadProgress.value = 0
|
||||
uploadSpeed.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,13 +393,26 @@ onMounted(() => {
|
||||
>
|
||||
<Loader2 v-if="uploading" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Upload v-else class="h-4 w-4 mr-2" />
|
||||
{{ t('admin.versions.createForm.uploadAndParse') }}
|
||||
{{ uploading ? `${uploadProgress}%` : t('admin.versions.createForm.uploadAndParse') }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" size="icon" :disabled="saving" @click="removeFile">
|
||||
<X class="size-4" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 上传进度条 -->
|
||||
<div v-if="uploading" class="space-y-1">
|
||||
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="bg-primary h-full transition-all duration-300 ease-out"
|
||||
:style="{ width: `${uploadProgress}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{{ t('admin.versions.createForm.uploading') }}</span>
|
||||
<span class="font-medium">{{ uploadSpeed }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="uploadedData && uploadedData.file_hash !== formData.file?.name" class="rounded-lg bg-muted/50 p-3">
|
||||
<div class="flex items-center gap-2 text-sm text-green-600 mb-2">
|
||||
<CheckCircle class="size-4" />
|
||||
|
||||
@@ -36,6 +36,8 @@ const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const uploading = ref(false)
|
||||
const uploadProgress = ref(0)
|
||||
const uploadSpeed = ref('')
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const formData = ref({
|
||||
@@ -149,11 +151,20 @@ async function uploadZipFile() {
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
uploadProgress.value = 0
|
||||
uploadSpeed.value = ''
|
||||
try {
|
||||
const formDataObj = new FormData()
|
||||
formDataObj.append('file', formData.value.file)
|
||||
|
||||
const response = await api.postFormData<UploadResponse>('/dev/versions/upload-zip', formDataObj)
|
||||
const response = await api.postFormDataWithProgress<UploadResponse>(
|
||||
'/dev/versions/upload-zip',
|
||||
formDataObj,
|
||||
(progress, speed) => {
|
||||
uploadProgress.value = progress
|
||||
uploadSpeed.value = speed
|
||||
}
|
||||
)
|
||||
uploadedData.value = response
|
||||
toast.success(t('admin.versions.createForm.uploadSuccess'))
|
||||
}
|
||||
@@ -163,6 +174,8 @@ async function uploadZipFile() {
|
||||
}
|
||||
finally {
|
||||
uploading.value = false
|
||||
uploadProgress.value = 0
|
||||
uploadSpeed.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,13 +332,26 @@ onMounted(() => {
|
||||
>
|
||||
<Loader2 v-if="uploading" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Upload v-else class="h-4 w-4 mr-2" />
|
||||
{{ t('admin.versions.createForm.uploadAndParse') }}
|
||||
{{ uploading ? `${uploadProgress}%` : t('admin.versions.createForm.uploadAndParse') }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" size="icon" :disabled="saving" @click="removeFile">
|
||||
<X class="size-4" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 上传进度条 -->
|
||||
<div v-if="uploading" class="space-y-1">
|
||||
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="bg-primary h-full transition-all duration-300 ease-out"
|
||||
:style="{ width: `${uploadProgress}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{{ t('admin.versions.createForm.uploading') }}</span>
|
||||
<span class="font-medium">{{ uploadSpeed }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="uploadedData" class="rounded-lg bg-muted/50 p-3">
|
||||
<div class="flex items-center gap-2 text-sm text-green-600 mb-2">
|
||||
<CheckCircle class="size-4" />
|
||||
|
||||
@@ -1535,6 +1535,7 @@
|
||||
"zipOnly": "Only ZIP format files are supported",
|
||||
"selectZipFirst": "Please select a ZIP file first",
|
||||
"uploadSuccess": "File uploaded successfully",
|
||||
"uploading": "Uploading...",
|
||||
"uploadFailed": "Upload failed",
|
||||
"selectAppRequired": "Please select an application",
|
||||
"versionRequired": "Please enter version number",
|
||||
|
||||
@@ -1513,6 +1513,7 @@
|
||||
"zipOnly": "只支持ZIP格式文件",
|
||||
"selectZipFirst": "请先选择ZIP文件",
|
||||
"uploadSuccess": "文件上传成功",
|
||||
"uploading": "正在上传...",
|
||||
"uploadFailed": "上传失败",
|
||||
"selectAppRequired": "请选择应用",
|
||||
"versionRequired": "请输入版本号",
|
||||
|
||||
@@ -67,6 +67,74 @@ const api = {
|
||||
return data.data
|
||||
},
|
||||
|
||||
async postFormDataWithProgress<T>(
|
||||
endpoint: string,
|
||||
formData: FormData,
|
||||
onProgress?: (progress: number, speed: string) => void
|
||||
): Promise<T> {
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
let startTime = Date.now()
|
||||
let lastLoaded = 0
|
||||
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const progress = Math.round((event.loaded / event.total) * 100)
|
||||
|
||||
// 计算上传速度
|
||||
const currentTime = Date.now()
|
||||
const elapsedTime = (currentTime - startTime) / 1000 // 秒
|
||||
const loadedSinceLast = event.loaded - lastLoaded
|
||||
|
||||
if (elapsedTime > 0) {
|
||||
const speed = loadedSinceLast / elapsedTime // bytes per second
|
||||
const speedStr = formatSpeed(speed)
|
||||
onProgress?.(progress, speedStr)
|
||||
lastLoaded = event.loaded
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
window.location.href = '/login'
|
||||
reject(new Error('登录已过期,请重新登录'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText)
|
||||
if (xhr.status !== 200 || data.code !== 200) {
|
||||
reject(new Error(data.message || '请求失败'))
|
||||
} else {
|
||||
resolve(data.data)
|
||||
}
|
||||
} catch {
|
||||
reject(new Error('解析响应失败'))
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new Error('网络错误'))
|
||||
})
|
||||
|
||||
xhr.addEventListener('abort', () => {
|
||||
reject(new Error('上传已取消'))
|
||||
})
|
||||
|
||||
xhr.open('POST', `${BASE_URL}${endpoint}`)
|
||||
if (token) {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
xhr.send(formData)
|
||||
})
|
||||
},
|
||||
|
||||
async put<T>(endpoint: string, body?: any): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
@@ -82,5 +150,17 @@ const api = {
|
||||
},
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSecond: number): string {
|
||||
if (bytesPerSecond < 1024) {
|
||||
return `${bytesPerSecond.toFixed(0)} B/s`
|
||||
} else if (bytesPerSecond < 1024 * 1024) {
|
||||
return `${(bytesPerSecond / 1024).toFixed(2)} KB/s`
|
||||
} else if (bytesPerSecond < 1024 * 1024 * 1024) {
|
||||
return `${(bytesPerSecond / (1024 * 1024)).toFixed(2)} MB/s`
|
||||
} else {
|
||||
return `${(bytesPerSecond / (1024 * 1024 * 1024)).toFixed(2)} GB/s`
|
||||
}
|
||||
}
|
||||
|
||||
export { BASE_URL }
|
||||
export default api
|
||||
|
||||
Reference in New Issue
Block a user