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:
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('导出响应不是文件')
|
||||
}
|
||||
Reference in new issue
Block a user