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 loading = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
|
const uploadProgress = ref(0)
|
||||||
|
const uploadSpeed = ref('')
|
||||||
const applications = ref<Application[]>([])
|
const applications = ref<Application[]>([])
|
||||||
|
|
||||||
const formData = ref({
|
const formData = ref({
|
||||||
@@ -168,11 +170,20 @@ async function uploadZipFile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
|
uploadProgress.value = 0
|
||||||
|
uploadSpeed.value = ''
|
||||||
try {
|
try {
|
||||||
const formDataObj = new FormData()
|
const formDataObj = new FormData()
|
||||||
formDataObj.append('file', formData.value.file)
|
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
|
uploadedData.value = response
|
||||||
toast.success(t('admin.versions.createForm.uploadSuccess'))
|
toast.success(t('admin.versions.createForm.uploadSuccess'))
|
||||||
}
|
}
|
||||||
@@ -182,6 +193,8 @@ async function uploadZipFile() {
|
|||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
uploading.value = false
|
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" />
|
<Loader2 v-if="uploading" class="h-4 w-4 mr-2 animate-spin" />
|
||||||
<Upload v-else class="h-4 w-4 mr-2" />
|
<Upload v-else class="h-4 w-4 mr-2" />
|
||||||
{{ t('admin.versions.createForm.uploadAndParse') }}
|
{{ uploading ? `${uploadProgress}%` : t('admin.versions.createForm.uploadAndParse') }}
|
||||||
</UiButton>
|
</UiButton>
|
||||||
<UiButton variant="ghost" size="icon" :disabled="saving" @click="removeFile">
|
<UiButton variant="ghost" size="icon" :disabled="saving" @click="removeFile">
|
||||||
<X class="size-4" />
|
<X class="size-4" />
|
||||||
</UiButton>
|
</UiButton>
|
||||||
</div>
|
</div>
|
||||||
</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 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">
|
<div class="flex items-center gap-2 text-sm text-green-600 mb-2">
|
||||||
<CheckCircle class="size-4" />
|
<CheckCircle class="size-4" />
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ const router = useRouter()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
|
const uploadProgress = ref(0)
|
||||||
|
const uploadSpeed = ref('')
|
||||||
const applications = ref<Application[]>([])
|
const applications = ref<Application[]>([])
|
||||||
|
|
||||||
const formData = ref({
|
const formData = ref({
|
||||||
@@ -149,11 +151,20 @@ async function uploadZipFile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
|
uploadProgress.value = 0
|
||||||
|
uploadSpeed.value = ''
|
||||||
try {
|
try {
|
||||||
const formDataObj = new FormData()
|
const formDataObj = new FormData()
|
||||||
formDataObj.append('file', formData.value.file)
|
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
|
uploadedData.value = response
|
||||||
toast.success(t('admin.versions.createForm.uploadSuccess'))
|
toast.success(t('admin.versions.createForm.uploadSuccess'))
|
||||||
}
|
}
|
||||||
@@ -163,6 +174,8 @@ async function uploadZipFile() {
|
|||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
uploading.value = false
|
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" />
|
<Loader2 v-if="uploading" class="h-4 w-4 mr-2 animate-spin" />
|
||||||
<Upload v-else class="h-4 w-4 mr-2" />
|
<Upload v-else class="h-4 w-4 mr-2" />
|
||||||
{{ t('admin.versions.createForm.uploadAndParse') }}
|
{{ uploading ? `${uploadProgress}%` : t('admin.versions.createForm.uploadAndParse') }}
|
||||||
</UiButton>
|
</UiButton>
|
||||||
<UiButton variant="ghost" size="icon" :disabled="saving" @click="removeFile">
|
<UiButton variant="ghost" size="icon" :disabled="saving" @click="removeFile">
|
||||||
<X class="size-4" />
|
<X class="size-4" />
|
||||||
</UiButton>
|
</UiButton>
|
||||||
</div>
|
</div>
|
||||||
</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 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">
|
<div class="flex items-center gap-2 text-sm text-green-600 mb-2">
|
||||||
<CheckCircle class="size-4" />
|
<CheckCircle class="size-4" />
|
||||||
|
|||||||
@@ -1535,6 +1535,7 @@
|
|||||||
"zipOnly": "Only ZIP format files are supported",
|
"zipOnly": "Only ZIP format files are supported",
|
||||||
"selectZipFirst": "Please select a ZIP file first",
|
"selectZipFirst": "Please select a ZIP file first",
|
||||||
"uploadSuccess": "File uploaded successfully",
|
"uploadSuccess": "File uploaded successfully",
|
||||||
|
"uploading": "Uploading...",
|
||||||
"uploadFailed": "Upload failed",
|
"uploadFailed": "Upload failed",
|
||||||
"selectAppRequired": "Please select an application",
|
"selectAppRequired": "Please select an application",
|
||||||
"versionRequired": "Please enter version number",
|
"versionRequired": "Please enter version number",
|
||||||
|
|||||||
@@ -1513,6 +1513,7 @@
|
|||||||
"zipOnly": "只支持ZIP格式文件",
|
"zipOnly": "只支持ZIP格式文件",
|
||||||
"selectZipFirst": "请先选择ZIP文件",
|
"selectZipFirst": "请先选择ZIP文件",
|
||||||
"uploadSuccess": "文件上传成功",
|
"uploadSuccess": "文件上传成功",
|
||||||
|
"uploading": "正在上传...",
|
||||||
"uploadFailed": "上传失败",
|
"uploadFailed": "上传失败",
|
||||||
"selectAppRequired": "请选择应用",
|
"selectAppRequired": "请选择应用",
|
||||||
"versionRequired": "请输入版本号",
|
"versionRequired": "请输入版本号",
|
||||||
|
|||||||
@@ -67,6 +67,74 @@ const api = {
|
|||||||
return data.data
|
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> {
|
async put<T>(endpoint: string, body?: any): Promise<T> {
|
||||||
return this.request<T>(endpoint, {
|
return this.request<T>(endpoint, {
|
||||||
method: 'PUT',
|
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 { BASE_URL }
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
Reference in New Issue
Block a user