ソースを参照

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>
zlwu 2 ヶ月 前
親
コミット
3643a3b14e

+ 144 - 0
web-admin/src/components/business/list-export-dropdown/index.vue

@@ -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>

+ 41 - 0
web-admin/src/utils/list-export-download.ts

@@ -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('导出响应不是文件')
+}