feat(web-admin): add ListExportDropdown and Excel blob download helper

Support filtered-result primary action and optional export-selected menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-21 17:01:49 +08:00
1 parent eb4233f23f
commit 3643a3b14e
2 files changed
+185

No files matched your search

@@ -0,0 +1,144 @@
<template>
<div v-if="hasSelection" class="list-export-dropdown">
<el-button
class="btn-ghost list-export-dropdown__main"
:disabled="disabled"
:loading="exporting"
@click="handleExportFiltered"
>
<el-icon><Download /></el-icon>
导出筛选结果
</el-button>
<el-dropdown
trigger="click"
:disabled="disabled || exporting"
@command="handleCommand"
>
<el-button class="btn-ghost list-export-dropdown__caret" :disabled="disabled || exporting">
<el-icon><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="selected" :disabled="!selectedIds?.length">
导出已选
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<el-button
v-else
class="btn-ghost"
:disabled="disabled"
:loading="exporting"
@click="handleExportFiltered"
>
<el-icon><Download /></el-icon>
导出
</el-button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ArrowDown, Download } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import {
filenameFromContentDisposition,
pickBlobFromExportResponse,
triggerBlobDownload
} from '@/utils/list-export-download'
defineOptions({ name: 'ListExportDropdown' })
export type ListExportPayload = Record<string, any> & {
ids?: string[]
columns?: string[]
}
const props = withDefaults(
defineProps<{
exportFn: (payload: ListExportPayload) => Promise<any>
getFilterParams: () => Record<string, any>
selectedIds?: string[]
hasSelection?: boolean
visibleColumns?: string[]
filename?: string
disabled?: boolean
}>(),
{
selectedIds: () => [],
hasSelection: false,
visibleColumns: undefined,
filename: '导出.xlsx',
disabled: false
}
)
const exporting = ref(false)
function buildPayload(withIds: boolean): ListExportPayload {
const payload: ListExportPayload = { ...props.getFilterParams() }
if (props.visibleColumns?.length) {
payload.columns = [...props.visibleColumns]
}
if (withIds) {
payload.ids = [...(props.selectedIds || [])]
}
return payload
}
async function runExport(withIds: boolean) {
if (exporting.value || props.disabled) return
if (withIds && !(props.selectedIds?.length > 0)) return
exporting.value = true
try {
const res = await props.exportFn(buildPayload(withIds))
const { blob, filenameHeader } = pickBlobFromExportResponse(res)
const name = filenameFromContentDisposition(filenameHeader, props.filename)
triggerBlobDownload(blob, name)
ElMessage.success('导出成功')
} catch (e: any) {
const msg = e?.message || e?.msg || '导出失败'
ElMessage.error(msg)
} finally {
exporting.value = false
}
}
function handleExportFiltered() {
void runExport(false)
}
function handleCommand(cmd: string) {
if (cmd === 'selected') void runExport(true)
}
</script>
<style scoped lang="scss">
.list-export-dropdown {
display: inline-flex;
align-items: stretch;
vertical-align: middle;
:deep(.el-button) {
.el-icon + span,
span + .el-icon {
margin-left: 6px;
}
}
}
.list-export-dropdown__main {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
margin-right: -1px;
}
.list-export-dropdown__caret {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
min-width: 36px;
padding: 0 10px;
}
</style>
@@ -0,0 +1,41 @@
/** 从 Content-Disposition 解析文件名;失败则用 fallback */
export function filenameFromContentDisposition(
header: string | undefined,
fallback: string
): string {
if (!header) return fallback
const utf8 = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header)
if (utf8?.[1]) {
try {
return decodeURIComponent(utf8[1].trim().replace(/["']/g, ''))
} catch {
/* ignore */
}
}
const plain = /filename\s*=\s*("?)([^";]+)\1/i.exec(header)
if (plain?.[2]) return plain[2].trim()
return fallback
}
export function triggerBlobDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
/** Axios 成功时若拦截器已把 data 设为 Blob,直接用;否则兼容 { data: Blob, headers } */
export function pickBlobFromExportResponse(res: any): { blob: Blob; filenameHeader?: string } {
if (res instanceof Blob) {
return { blob: res }
}
if (res?.data instanceof Blob) {
return {
blob: res.data,
filenameHeader: res.headers?.['content-disposition'] || res.headers?.['Content-Disposition']
}
}
throw new Error('导出响应不是文件')
}