feat: 添加文件上传进度和速度显示

- 新增 api.postFormDataWithProgress 方法支持上传进度回调
- 版本创建和编辑页面显示上传进度条和实时速度
- 按钮显示上传百分比
- 添加国际化翻译

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 22:01:16 +08:00
parent c755587a4d
commit b86fc60741
5 changed files with 138 additions and 4 deletions
+28 -2
View File
@@ -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" />
+28 -2
View File
@@ -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" />
+1
View File
@@ -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",
+1
View File
@@ -1513,6 +1513,7 @@
"zipOnly": "只支持ZIP格式文件",
"selectZipFirst": "请先选择ZIP文件",
"uploadSuccess": "文件上传成功",
"uploading": "正在上传...",
"uploadFailed": "上传失败",
"selectAppRequired": "请选择应用",
"versionRequired": "请输入版本号",
+80
View File
@@ -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