feat(后台/店铺数据): 结果下载增加进度弹窗

批量打包下载与单店下载接入 axios onDownloadProgress,progress 弹窗显示
百分比;后端未回 Content-Length 时退化为 indeterminate 流动条,避免用户
在大 zip 下载期间以为页面无响应。
This commit is contained in:
2026-09-12 11:42:33 +08:00
parent 037a689316
commit 441917bfce
2 changed files with 66 additions and 6 deletions
@@ -56,6 +56,25 @@ const pagedPermissionItems = computed(() =>
const busyKey = ref('') const busyKey = ref('')
const deletingKey = ref('') const deletingKey = ref('')
/** 下载进度弹窗:percent=0~100indeterminate=true 表示后端未回 Content-Length,只显示流动条。 */
const downloadProgress = reactive({ visible: false, percent: 0, indeterminate: false, label: '' })
function startDownloadProgress(label: string) {
downloadProgress.label = label
downloadProgress.percent = 0
downloadProgress.indeterminate = false
downloadProgress.visible = true
}
function updateDownloadProgress(percent: number) {
if (percent < 0) {
downloadProgress.indeterminate = true
return
}
downloadProgress.indeterminate = false
downloadProgress.percent = percent
}
/** 旧版状态 label 映射(对齐 admin.js 状态徽章)。 */ /** 旧版状态 label 映射(对齐 admin.js 状态徽章)。 */
function statusLabel(status: string): string { function statusLabel(status: string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
@@ -185,9 +204,11 @@ async function downloadRow(row: ShopDataResultRow) {
return return
} }
busyKey.value = row.resultId busyKey.value = row.resultId
startDownloadProgress(`正在下载「${row.shopName || '-'}」的数据文件`)
try { try {
const { blob, filename } = await fetchShopDataResultDownload(row.resultId) const { blob, filename } = await fetchShopDataResultDownload(row.resultId, updateDownloadProgress)
saveBlob(blob, filename) saveBlob(blob, filename)
downloadProgress.visible = false
ElMessage.success('下载已开始') ElMessage.success('下载已开始')
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '下载失败') ElMessage.error(error instanceof Error ? error.message : '下载失败')
@@ -203,9 +224,11 @@ async function downloadBatch() {
return return
} }
busyKey.value = 'batch' busyKey.value = 'batch'
startDownloadProgress(`正在打包下载 ${resultIds.length} 个结果文件`)
try { try {
const zip = await requestShopDataZipDownload(resultIds) const zip = await requestShopDataZipDownload(resultIds, updateDownloadProgress)
saveBlob(zip.blob, `店铺数据批量下载_${resultIds.length}.zip`) saveBlob(zip.blob, `店铺数据批量下载_${resultIds.length}.zip`)
downloadProgress.visible = false
const note = zip.errorCount ? `${zip.errorCount} 个失败(详见包内错误清单)` : '' const note = zip.errorCount ? `${zip.errorCount} 个失败(详见包内错误清单)` : ''
ElMessage.success(`已下载 ${zip.fileCount ?? resultIds.length} 个文件${note}`) ElMessage.success(`已下载 ${zip.fileCount ?? resultIds.length} 个文件${note}`)
} catch (error) { } catch (error) {
@@ -401,6 +424,16 @@ onMounted(load)
<el-button type="primary" :loading="permissionSaving" @click="savePermission">保存</el-button> <el-button type="primary" :loading="permissionSaving" @click="savePermission">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="downloadProgress.visible" :title="downloadProgress.label" width="440px" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
<el-progress
:percentage="downloadProgress.indeterminate ? 50 : downloadProgress.percent"
:indeterminate="downloadProgress.indeterminate"
:duration="downloadProgress.indeterminate ? 3 : undefined"
:stroke-width="14"
/>
<p class="sd-progress-hint">{{ downloadProgress.indeterminate ? '正在接收数据,大小未知,请稍候…' : `已完成 ${downloadProgress.percent}%` }}</p>
</el-dialog>
</div> </div>
</template> </template>
@@ -712,6 +745,12 @@ h3 {
color: #8293a5; color: #8293a5;
font-size: 12px; font-size: 12px;
} }
.sd-progress-hint {
margin: 10px 0 0;
text-align: center;
color: #5b6f83;
font-size: 13px;
}
.empty-tip { .empty-tip {
grid-column: 1 / -1; grid-column: 1 / -1;
padding: 44px 24px; padding: 44px 24px;
@@ -27,10 +27,15 @@ export interface ShopDataZipDownloadResult {
errorCount?: number errorCount?: number
} }
/** 批量下载选中结果文件为 zipPOST /download-zip(blob);从响应头读成功/失败计数。 */ /** 批量下载选中结果文件为 zipPOST /download-zip(blob);从响应头读成功/失败计数。
export async function requestShopDataZipDownload(resultIds: readonly (number | string)[]): Promise<ShopDataZipDownloadResult> { * onProgress: 可选下载进度回调(0-100,后端未回 Content-Length 时退化为不定进度)。 */
export async function requestShopDataZipDownload(
resultIds: readonly (number | string)[],
onProgress?: (percent: number) => void,
): Promise<ShopDataZipDownloadResult> {
const { data, headers } = await http.post<Blob>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/download-zip`, toShopDataZipRequest(resultIds), { const { data, headers } = await http.post<Blob>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/download-zip`, toShopDataZipRequest(resultIds), {
responseType: 'blob', responseType: 'blob',
...(onProgress ? { onDownloadProgress: toAxiosProgress(onProgress) } : {}),
}) })
const counts = downloadZipHeaderCounts(headers as Record<string, string>) const counts = downloadZipHeaderCounts(headers as Record<string, string>)
return { blob: data, fileCount: counts.fileCount, errorCount: counts.errorCount } return { blob: data, fileCount: counts.fileCount, errorCount: counts.errorCount }
@@ -41,15 +46,31 @@ export interface ShopDataResultDownload {
filename: string filename: string
} }
/** 下载单店最新采集文件(按每日累计档 id):GET /api/admin/shop-data-crawl-tasks/daily-files/{id}/download。 */ /** 下载单店最新采集文件(按每日累计档 id):GET /api/admin/shop-data-crawl-tasks/daily-files/{id}/download。
export async function fetchShopDataResultDownload(resultId: number | string): Promise<ShopDataResultDownload> { * onProgress: 可选下载进度回调(0-100)。 */
export async function fetchShopDataResultDownload(
resultId: number | string,
onProgress?: (percent: number) => void,
): Promise<ShopDataResultDownload> {
const { data, headers } = await http.get<Blob>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/daily-files/${resultId}/download`, { const { data, headers } = await http.get<Blob>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/daily-files/${resultId}/download`, {
responseType: 'blob', responseType: 'blob',
...(onProgress ? { onDownloadProgress: toAxiosProgress(onProgress) } : {}),
}) })
const disposition = (headers as Record<string, string> | undefined)?.['content-disposition'] const disposition = (headers as Record<string, string> | undefined)?.['content-disposition']
return { blob: data, filename: shopDataFilenameFromDisposition(disposition, `shop-data-task-${resultId}.xlsx`) } return { blob: data, filename: shopDataFilenameFromDisposition(disposition, `shop-data-task-${resultId}.xlsx`) }
} }
/** 将 axios 进度事件转为 0-100 百分比;响应未回 Content-Length 时报 -1,前端显示不定进度。 */
function toAxiosProgress(onProgress: (percent: number) => void) {
return (event: { loaded: number; total?: number }) => {
if (event.total) {
onProgress(Math.min(100, Math.round((event.loaded / event.total) * 100)))
} else {
onProgress(-1)
}
}
}
/** 删除该店这条店铺数据记录(按每日累计档 id,管理端显式操作):DELETE .../daily-files/{id}。 */ /** 删除该店这条店铺数据记录(按每日累计档 id,管理端显式操作):DELETE .../daily-files/{id}。 */
export async function deleteShopDataResultHistory(resultId: number | string): Promise<void> { export async function deleteShopDataResultHistory(resultId: number | string): Promise<void> {
await http.delete<unknown>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/daily-files/${resultId}`) await http.delete<unknown>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/daily-files/${resultId}`)