el-table表格全屏/管理显示字段/导出功能封装

为了确保功能的完整性和健壮性,这部分代码的体量确实有点多🤦‍♂️。重点讲解的是导出功能,导出功能为前端导出。

el-table表格全屏/管理显示字段/导出功能封装

  • 单行表头导出
    • 先看效果
    • 完整代码和封装的组件
    • [useTableCommon 使用指南](#useTableCommon 使用指南)
      • [📋 功能概述](#📋 功能概述)
      • [🎯 适用场景](#🎯 适用场景)
      • [📦 API 参数](#📦 API 参数)
      • [🔧 ColumnConfig 类型定义](#🔧 ColumnConfig 类型定义)
      • [💡 使用示例](#💡 使用示例)
      • [1. 基础配置](#1. 基础配置)
      • [2. 完整配置(含导出)](#2. 完整配置(含导出))
      • [3. 模板中使用](#3. 模板中使用)
      • [⚠️ 注意事项](#⚠️ 注意事项)
      • [1. 变量声明顺序](#1. 变量声明顺序)
      • [2. 导出 API 规范](#2. 导出 API 规范)
      • [3. 条件样式颜色格式](#3. 条件样式颜色格式)
      • [4. 事件监听清理](#4. 事件监听清理)
      • [🎨 样式建议](#🎨 样式建议)
      • 表格容器样式
      • 条件样式示例
      • [🔗 相关组件](#🔗 相关组件)
      • [📚 完整示例](#📚 完整示例)
  • 多行表头导出
    • 先看效果
    • 完整代码和封装的组件
    • [useTableExportComplex 使用指南](#useTableExportComplex 使用指南)
      • [📋 功能概述](#📋 功能概述)
      • [🎯 适用场景](#🎯 适用场景)
      • [📦 API 参数](#📦 API 参数)
      • [🔧 ExportColumnDef 类型定义](#🔧 ExportColumnDef 类型定义)
      • [💡 使用示例](#💡 使用示例)
        • [1. 基础配置(分组列 + 平铺列)](#1. 基础配置(分组列 + 平铺列))
        • [2. 组合字段示例(公差范围)](#2. 组合字段示例(公差范围))
        • [3. 嵌套数据提取示例](#3. 嵌套数据提取示例)
        • [4. 完整配置(与 useTableCommon 配合使用)](#4. 完整配置(与 useTableCommon 配合使用))
        • [5. 模板中使用](#5. 模板中使用)
      • [⚠️ 注意事项](#⚠️ 注意事项)
        • [1. 与 useTableCommon 配合使用](#1. 与 useTableCommon 配合使用)
        • [2. prop 字段必须匹配](#2. prop 字段必须匹配)
        • [3. 异常判断函数必须返回布尔值](#3. 异常判断函数必须返回布尔值)
        • [4. 导出 API 规范](#4. 导出 API 规范)
        • [5. 数字格式化](#5. 数字格式化)
        • [6. 变量声明顺序](#6. 变量声明顺序)
      • [🎨 样式建议](#🎨 样式建议)
      • [🔗 与 useTableCommon 的区别](#🔗 与 useTableCommon 的区别)
      • [📚 完整示例](#📚 完整示例)

单行表头导出

先看效果

tip:表格展示的数据和导出的 Excel 数据是 mock 数据,数据是随机的,所以数据对不上。

页面渲染 导出 Excel

完整代码和封装的组件

完整示例:src\views\TestExport\headerExport.vue

javascript 复制代码
<template>
  <!-- 表格外层容器,用于全屏操作 -->
  <div
    ref="tableInfoRef"
    class="table-container"
    :class="{ fullscreen: isFullscreen }"
  >
    <!-- 工具栏 -->
    <div class="toolbar">
      <div class="toolbar-left">
        <h2>单表头导出示例</h2>
        <p class="description">
          演示 useTableCommon 的完整功能:全屏、列管理、单表头导出、条件样式
        </p>
      </div>
      <div class="toolbar-right">
        <!-- 管理显示字段按钮 -->
        <el-button
          type="primary"
          plain
          @click="handleTableSetting"
          :icon="Setting"
        >
          管理显示字段
        </el-button>
        <!-- 全屏切换按钮 -->
        <el-button
          type="primary"
          plain
          @click="handleFullScreen"
          :icon="isFullscreen ? Minus : FullScreen"
        >
          {{ isFullscreen ? "退出全屏" : "全屏展示" }}
        </el-button>
        <!-- 导出按钮 -->
        <el-button type="primary" @click="handleExport" :icon="Download">
          导出 Excel
        </el-button>
      </div>
    </div>

    <!-- 表格区域 -->
    <div class="table-wrapper">
      <el-table
        :key="refreshKey"
        :data="tableData"
        border
        :max-height="tableMaxHeight"
        v-loading="loading"
        ref="tableRef"
      >
        <!-- 序号列(固定) -->
        <el-table-column
          prop="index"
          label="序号"
          width="60"
          fixed="left"
          align="center"
        />

        <!-- 姓名列 -->
        <el-table-column
          prop="name"
          label="姓名"
          :visible="isColVisible('name')"
          :fixed="isColFixed('name')"
          min-width="120"
          align="center"
        />

        <!-- 部门列 -->
        <el-table-column
          prop="department"
          label="部门"
          :visible="isColVisible('department')"
          :fixed="isColFixed('department')"
          min-width="120"
          align="center"
        />

        <!-- 年龄列 -->
        <el-table-column
          prop="age"
          label="年龄"
          :visible="isColVisible('age')"
          :fixed="isColFixed('age')"
          width="80"
          align="center"
        />

        <!-- 入职日期列 -->
        <el-table-column
          prop="hireDate"
          label="入职日期"
          :visible="isColVisible('hireDate')"
          :fixed="isColFixed('hireDate')"
          min-width="120"
          align="center"
        />

        <!-- 薪资列(带条件样式,与导出逻辑一致:salary > 20000 显示红色) -->
        <el-table-column
          prop="salary"
          label="薪资(元)"
          :visible="isColVisible('salary')"
          :fixed="isColFixed('salary')"
          min-width="120"
          align="center"
        >
          <template #default="{ row }">
            <span :class="{ 'salary-highlight': row.salary > 20000 }">
              {{ formatNumber(row.salary) }}
            </span>
          </template>
        </el-table-column>

        <!-- 绩效评分列(带条件样式,与导出逻辑一致:performance < 60 显示红色背景) -->
        <el-table-column
          prop="performance"
          label="绩效评分"
          :visible="isColVisible('performance')"
          :fixed="isColFixed('performance')"
          width="100"
          align="center"
        >
          <template #default="{ row }">
            <span :class="{ 'performance-abnormal': row.performance < 60 }">
              {{ row.performance }}分
            </span>
          </template>
        </el-table-column>

        <!-- 状态列(与导出逻辑一致:在职显示绿色,离职显示橙色) -->
        <el-table-column
          prop="status"
          label="状态"
          :visible="isColVisible('status')"
          :fixed="isColFixed('status')"
          width="100"
          align="center"
        >
          <template #default="{ row }">
            <span
              :class="{
                'status-active': row.status === '在职',
                'status-inactive': row.status === '离职',
              }"
            >
              {{ row.status }}
            </span>
          </template>
        </el-table-column>
      </el-table>
    </div>

    <!-- 列管理弹窗 -->
    <TableColumnManager
      v-model:visible="columnManagerVisible"
      :columns="columnConfig"
      :default-columns="defaultColumnConfig"
      @confirm="handleColumnConfigConfirm"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import type { ColumnConfig } from "@/components/TableColumnManager/index.vue";
import TableColumnManager from "@/components/TableColumnManager/index.vue";
import { useTableCommon } from "@/composables/useTableCommon";
import { Setting, FullScreen, Minus, Download } from "@element-plus/icons-vue";

/**
 * 模拟表格数据
 */
const tableData = ref<Record<string, any>[]>([]);
const loading = ref(false);

/**
 * 生成模拟数据
 */
const generateMockData = () => {
  const departments = ["研发部", "产品部", "市场部", "财务部", "人事部"];
  const statuses = ["在职", "离职"];
  const data: Record<string, any>[] = [];

  for (let i = 1; i <= 50; i++) {
    const salary = Math.floor(Math.random() * 20000) + 5000;
    const performance = Math.floor(Math.random() * 50) + 50;

    data.push({
      index: i,
      name: `员工${i}`,
      department: departments[Math.floor(Math.random() * departments.length)],
      age: Math.floor(Math.random() * 30) + 22,
      hireDate: `20${Math.floor(Math.random() * 20) + 10}-${String(
        Math.floor(Math.random() * 12) + 1,
      ).padStart(2, "0")}-${String(Math.floor(Math.random() * 28) + 1).padStart(
        2,
        "0",
      )}`,
      salary,
      performance,
      status: statuses[Math.floor(Math.random() * statuses.length)],
    });
  }

  return data;
};

/**
 * 格式化数字(保留千分位)
 */
const formatNumber = (val: number): string => {
  if (val == null) return "";
  return val.toLocaleString("zh-CN");
};

/**
 * 模拟导出 API
 * 模拟从后端获取全部数据
 */
const mockExportApi = async (params: { pageNo: number; pageSize: number }) => {
  // 模拟网络延迟
  await new Promise((resolve) => setTimeout(resolve, 800));

  const data = generateMockData();

  return {
    data: {
      records: data,
      total: data.length,
    },
  };
};

/**
 * 表格列定义
 * 定义所有表格列的配置信息,包括:
 * - prop: 字段名,对应数据对象的key
 * - label: 表头显示文字
 * - show: 是否显示(默认true)
 * - fixed: 是否固定(默认false)
 * - sortable: 是否可排序(默认true,由列管理控制)
 * - required: 是否必填(必填列不能隐藏)
 * - order: 显示顺序
 * 注意:sortable 字段保留是为了兼容列管理功能(用户仍可控制排序)
 */
const topLevelColumns: ColumnConfig[] = [
  {
    prop: "index",
    label: "序号",
    show: true,
    fixed: true,
    sortable: false,
    required: true,
    order: 0,
    width: 60,
  },
  {
    prop: "name",
    label: "姓名",
    show: true,
    fixed: false,
    sortable: true,
    required: false,
    order: 1,
    minWidth: 120,
  },
  {
    prop: "department",
    label: "部门",
    show: true,
    fixed: false,
    sortable: true,
    required: false,
    order: 2,
    minWidth: 120,
  },
  {
    prop: "age",
    label: "年龄",
    show: true,
    fixed: false,
    sortable: true,
    required: false,
    order: 3,
    width: 80,
  },
  {
    prop: "hireDate",
    label: "入职日期",
    show: true,
    fixed: false,
    sortable: true,
    required: false,
    order: 4,
    minWidth: 120,
  },
  {
    prop: "salary",
    label: "薪资(元)",
    show: true,
    fixed: false,
    sortable: true,
    required: false,
    order: 5,
    minWidth: 120,
  },
  {
    prop: "performance",
    label: "绩效评分",
    show: true,
    fixed: false,
    sortable: true,
    required: false,
    order: 6,
    width: 100,
  },
  {
    prop: "status",
    label: "状态",
    show: true,
    fixed: false,
    sortable: true,
    required: false,
    order: 7,
    width: 100,
  },
];

/**
 * 使用 useTableCommon 组合式函数
 * 传入必要的配置参数:
 * - configKey: 配置存储的key,用于后端保存/加载列配置(这里使用mock)
 * - topLevelColumns: 表格所有的列定义
 * - exportApi: 导出数据的API函数
 * - exportFileName: 导出文件名
 * - exportFieldFormatters: 自定义字段格式化函数
 * - exportFieldStyles: 自定义字段样式函数(用于Excel条件样式)
 */
const {
  isFullscreen,
  tableInfoRef,
  tableRef,
  columnManagerVisible,
  refreshKey,
  windowHeight,
  columnConfig,
  defaultColumnConfig,
  isColVisible,
  isColFixed,
  handleFullScreen,
  handleTableSetting,
  handleColumnConfigConfirm,
  handleExport,
  setupFullscreenListeners,
  removeFullscreenListeners,
  loadFieldConfig,
} = useTableCommon({
  configKey: "header-export-demo",
  topLevelColumns,
  // 模拟获取列配置(实际项目中调用后端API)
  getFieldConfig: async () => {
    await new Promise((resolve) => setTimeout(resolve, 300));
    return {
      data: {
        config: {
          columns: {}, // 初始为空,使用默认配置
        },
      },
    };
  },
  // 模拟保存列配置(实际项目中调用后端API)
  saveFieldConfig: async (params) => {
    await new Promise((resolve) => setTimeout(resolve, 300));
    console.log("保存列配置:", params);
    return { success: true };
  },
  // 导出API
  exportApi: mockExportApi,
  // 导出文件名
  exportFileName: "员工信息表",
  // 自定义字段格式化函数
  exportFieldFormatters: {
    // 薪资字段添加千分位
    salary: (value: any) => {
      if (value == null) return "";
      return Number(value).toLocaleString("zh-CN");
    },
    // 绩效评分添加百分号
    performance: (value: any) => {
      if (value == null) return "";
      return `${value}分`;
    },
  },
  // 自定义字段样式函数(用于Excel条件样式)
  exportFieldStyles: {
    // 薪资大于20000显示红色
    salary: (value: any) => {
      if (Number(value) > 20000) {
        return { color: "#FF0000", bgColor: "#FFF2F0" };
      }
      return null;
    },
    // 绩效评分低于60显示红色背景
    performance: (value: any) => {
      if (Number(value) < 60) {
        return { color: "#9C0006", bgColor: "#FFC7CE" };
      }
      return null;
    },
  },
});

/**
 * 表格最大高度计算
 * 全屏模式下减去工具栏高度,非全屏模式下使用固定高度
 * 必须在 useTableCommon 解构后定义,因为依赖 isFullscreen 和 windowHeight
 */
const tableMaxHeight = computed(() => {
  return isFullscreen.value ? `${windowHeight.value - 120}px` : "500px";
});

/**
 * 组件挂载时初始化
 */
onMounted(() => {
  // 注册全屏和窗口resize事件监听
  setupFullscreenListeners();
  // 加载列配置
  loadFieldConfig();
  // 加载表格数据
  loading.value = true;
  setTimeout(() => {
    tableData.value = generateMockData();
    loading.value = false;
  }, 500);
});

/**
 * 组件卸载时清理
 */
onUnmounted(() => {
  // 移除事件监听
  removeFullscreenListeners();
});
</script>

<style lang="scss" scoped>
.table-container {
  height: 100%;
  display: flex;
  flex-direction: column;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  transition: all 0.3s ease;

  &.fullscreen {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 9999;
    border-radius: 0;
    margin: 0;
  }
}

.toolbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px 20px;
  border-bottom: 1px solid #eee;

  .toolbar-left {
    h2 {
      margin: 0 0 4px 0;
      font-size: 18px;
      font-weight: 600;
      color: #303133;
    }

    .description {
      margin: 0;
      font-size: 13px;
      color: #909399;
    }
  }

  .toolbar-right {
    display: flex;
    gap: 12px;
  }
}

.table-wrapper {
  flex: 1;
  padding: 16px 20px;
  overflow: auto;
}

:deep(.el-table) {
  width: 100%;
}

/* 薪资高亮样式:薪资 > 20000 显示红色(与导出逻辑一致) */
.salary-highlight {
  color: #ff0000;
  background-color: #fff2f0;
  padding: 2px 6px;
  border-radius: 4px;
}

/* 绩效异常样式:绩效评分 < 60 显示红色背景(与导出逻辑一致) */
.performance-abnormal {
  color: #9c0006;
  background-color: #ffc7ce;
  padding: 2px 6px;
  border-radius: 4px;
}

/* 状态样式(与导出逻辑一致) */
.status-active {
  color: #67c23a;
  font-weight: 500;
}

.status-inactive {
  color: #e6a23c;
  font-weight: 500;
}
</style>

管理显示字段的抽屉弹窗:src\components\TableColumnManager\index.vue

javascript 复制代码
<template>
  <el-drawer
    title="管理显示字段"
    v-model="dialogVisible"
    direction="rtl"
    size="600px"
    :before-close="handleClose"
    custom-class="column-manager-drawer"
  >
    <div class="drawer-content">
      <!-- 固定的头部区域 -->
      <div class="drawer-header-fixed">
        <div class="description-wrapper">
          <div class="description">
            直接拖动字段名称可调整显示字段排序,勾选是否展示可选择需要展示的字段
          </div>
        </div>
        <el-button style="margin-bottom: 10px; float: right;" type="primary" plain @click="handleResetDefault">恢复默认</el-button>
      </div>

      <!-- 可滚动的表格区域 -->
      <div class="table-container">
        <el-table
          :key="refreshKey"
          :data="tableColumns"
          border
          row-key="prop"
          ref="columnTable"
          height="100%"
          :header-cell-style="{ position: 'sticky', top: 0, zIndex: 10 }"
        >
          <el-table-column label="列表字段名称" prop="label" min-width="120" />
          <el-table-column label="展示" min-width="80" align="center">
            <template #header>
              <div class="header-content">
                <el-checkbox
                  v-model="allShowChecked"
                  :indeterminate="isShowIndeterminate"
                  @change="handleShowAllChange"
                />
                <span class="header-text">展示</span>
                <el-tooltip content="选择后的字段会在表格中展示" placement="top">
                  <el-icon class="header-help"><QuestionFilled /></el-icon>
                </el-tooltip>
              </div>
            </template>
            <template #default="{row}">
              <el-checkbox
                v-model="row.show"
                :disabled="row.required || (isOnlyOneVisible && row.show)"
                @change="handleShowChange"
              />
            </template>
          </el-table-column>
          <el-table-column label="冻结" min-width="80" align="center">
            <template #header>
              <div class="header-content">
                <el-checkbox
                  v-model="allFixedChecked"
                  :indeterminate="isFixedIndeterminate"
                  @change="handleFixedAllChange"
                />
                <span class="header-text">冻结</span>
                <el-tooltip content="选择后的字段会按照正序固定排在表格前列" placement="top">
                  <el-icon class="header-help"><QuestionFilled /></el-icon>
                </el-tooltip>
              </div>
            </template>
            <template #default="{row}">
              <el-checkbox
                v-model="row.fixed"
                :disabled="(reachedMaxFixedColumns && !row.fixed)"
                @change="handleFixedChange"
              />
            </template>
          </el-table-column>
          <el-table-column label="排序" min-width="80" align="center">
            <template #header>
              <div class="header-content">
                <el-checkbox
                  v-model="allSortableChecked"
                  :indeterminate="isSortableIndeterminate"
                  @change="handleSortableAllChange"
                />
                <span class="header-text">排序</span>
                <el-tooltip content="选择后,数据列表可以对该字段进行顺序或倒序排列" placement="top">
                  <el-icon class="header-help"><QuestionFilled /></el-icon>
                </el-tooltip>
              </div>
            </template>
            <template #default="{row}">
              <el-checkbox
                v-model="row.sortable"
                :disabled="false"
                @change="handleSortableChange"
              />
            </template>
          </el-table-column>
          <el-table-column label="顺序" min-width="80" align="center">
            <template #header>
              <div class="header-content">
                <span class="header-text">顺序</span>
                <el-tooltip content="拖拽调整顺序保存后,列表表头显示顺序会随之改变" placement="top">
                  <el-icon class="header-help"><QuestionFilled /></el-icon>
                </el-tooltip>
              </div>
            </template>
            <template #default="{row}">
              <el-icon
                class="drag-handle"
                style="color: #697dff; font-size: 18px; cursor: move"
              >
                <Rank />
              </el-icon>
            </template>
          </el-table-column>
        </el-table>
      </div>
    </div>

    <!-- 底部按钮区域 -->
    <div class="drawer-footer">
      <el-button plain @click="handleCancel">取消</el-button>
      <el-button type="primary" @click="handleConfirm">确定</el-button>
    </div>

    <!-- 二次确认对话框 -->
    <el-dialog
      title="提示"
      v-model="confirmDialogVisible"
      width="400px"
      append-to-body
      :close-on-click-modal="false"
    >
      <div style="text-align: center;">{{ confirmMessage }}</div>
      <template #footer>
        <span class="dialog-footer">
          <el-button type="primary" plain @click="confirmDialogVisible = false">取消</el-button>
          <el-button type="primary" @click="confirmAction">确定</el-button>
        </span>
      </template>
    </el-dialog>
  </el-drawer>
</template>

<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { QuestionFilled, Rank } from '@element-plus/icons-vue'
import Sortable from 'sortablejs'

export interface ColumnConfig {
  prop: string
  label: string
  show: boolean
  fixed: boolean
  sortable: boolean
  required: boolean
  order: number
  width?: string | number
  minWidth?: string | number
  [key: string]: any
}

interface Props {
  visible: boolean
  columns: ColumnConfig[]
  defaultColumns?: ColumnConfig[]
}

interface Emits {
  (e: 'update:visible', value: boolean): void
  (e: 'confirm', columns: ColumnConfig[]): void
}

const props = withDefaults(defineProps<Props>(), {
  visible: false,
  defaultColumns: () => []
})

const emit = defineEmits<Emits>()

// 响应式数据
const tableColumns = ref<ColumnConfig[]>([])
const originalColumns = ref<ColumnConfig[]>([])
const allShowChecked = ref(true)
const allFixedChecked = ref(false)
const allSortableChecked = ref(false)
const isShowIndeterminate = ref(false)
const isFixedIndeterminate = ref(false)
const isSortableIndeterminate = ref(false)
const refreshKey = ref(0)
const confirmDialogVisible = ref(false)
const confirmMessage = ref('')
const confirmActionType = ref('')

// 引用
const columnTable = ref()
let sortable: any = null

// 计算属性
const dialogVisible = computed({
  get() {
    return props.visible
  },
  set(value: boolean) {
    emit('update:visible', value)
  }
})

const isOnlyOneVisible = computed(() => {
  return tableColumns.value.filter(item => item.show).length <= 1
})

const fixedColumnsCount = computed(() => {
  return tableColumns.value.filter(item => item.fixed).length
})

const reachedMaxFixedColumns = computed(() => {
  return fixedColumnsCount.value >= 6
})

// 监听器
watch(() => props.visible, (val: boolean) => {
  if (val) {
    initColumns()
    nextTick(() => {
      initSortable()
    })
  }
})

// 初始化列配置
const initColumns = () => {
  // 深拷贝列配置,避免直接修改原始数据
  tableColumns.value = JSON.parse(JSON.stringify(props.columns))
    // 过滤掉序号列,序号列不参与管理
    .filter((col: ColumnConfig) => col.prop !== 'index')

  // 排序
  tableColumns.value.sort((a, b) => a.order - b.order)
  // 保存原始配置,用于取消操作
  originalColumns.value = JSON.parse(JSON.stringify(tableColumns.value))
  // 更新全选状态
  updateCheckStatus()
}

// 初始化拖拽功能
const initSortable = () => {
  if (!columnTable.value) return

  const el = columnTable.value.$el.querySelector('.el-table__body-wrapper tbody')
  if (!el) return

  // 销毁之前的实例
  if (sortable) {
    sortable.destroy()
  }

  sortable = Sortable.create(el, {
    handle: '.drag-handle',
    animation: 150,
    ghostClass: 'sortable-ghost',
    onEnd: handleDragEnd
  })
}

// 拖拽结束处理
const handleDragEnd = (evt: any) => {
  const { oldIndex, newIndex } = evt
  if (oldIndex === newIndex) return

  // 获取被拖动的项
  const draggedItem = tableColumns.value[oldIndex]

  // 从数组中移除该项
  tableColumns.value.splice(oldIndex, 1)

  // 在新位置插入该项
  tableColumns.value.splice(newIndex, 0, draggedItem)

  // 更新所有项的顺序
  updateOrderAfterDrag()
}

// 更新拖拽后的顺序
const updateOrderAfterDrag = () => {
  // 只在拖拽后调用,重新分配order值
  // 直接按照当前列表的顺序设置order值,确保拖拽后的顺序能正确保存
  tableColumns.value.forEach((column, index) => {
    column.order = index
  })
}

// 更新选择状态
const updateCheckStatus = () => {
  // 更新"展示"的全选状态
  const showTotal = tableColumns.value.length
  const showChecked = tableColumns.value.filter(col => col.show).length
  allShowChecked.value = showChecked === showTotal
  isShowIndeterminate.value = showChecked > 0 && showChecked < showTotal

  // 更新"冻结"的全选状态
  const fixedTotal = tableColumns.value.length
  const fixedChecked = tableColumns.value.filter(col => col.fixed).length
  allFixedChecked.value = fixedChecked === fixedTotal
  isFixedIndeterminate.value = fixedChecked > 0 && fixedChecked < fixedTotal

  // 更新"排序"的全选状态
  const sortableTotal = tableColumns.value.filter(col => col.show).length
  const sortableChecked = tableColumns.value.filter(col => col.show && col.sortable).length
  allSortableChecked.value = sortableChecked > 0 && sortableChecked === sortableTotal
  isSortableIndeterminate.value = sortableChecked > 0 && sortableChecked < sortableTotal
}

// 处理展示状态变化
const handleShowChange = () => {
  // 确保至少有一列是选中的
  const visibleColumns = tableColumns.value.filter(col => col.show)
  if (visibleColumns.length === 0) {
    // 如果没有任何一个选中,则选中第一个非必选项
    const firstColumn = tableColumns.value.find(col => !col.required)
    if (firstColumn) {
      firstColumn.show = true
      ElMessage.warning('至少需要保留一个显示字段')
    }
  }

  updateCheckStatus()
}

// 处理展示全选变化
const handleShowAllChange = (val: boolean) => {
  // 全选或取消全选"展示"
  tableColumns.value.forEach(column => {
    if (!column.required) {
      column.show = val
    }
  })

  // 如果全部取消选择,确保至少有一个选中
  if (!val && tableColumns.value.filter(col => col.show).length === 0) {
    // 如果没有任何一个选中,则选中第一个非必选项
    const firstColumn = tableColumns.value.find(col => !col.required)
    if (firstColumn) {
      firstColumn.show = true
    }
  }

  updateCheckStatus()
}

// 处理冻结全选变化
const handleFixedAllChange = () => {
  // 获取当前实际选中的数量
  const currentFixedCount = tableColumns.value.filter(col => col.fixed).length

  // 如果当前有选中的项目,则执行取消全选操作
  if (currentFixedCount > 0) {
    // 取消全选时,将所有列的fixed设为false
    tableColumns.value.forEach(column => {
      column.fixed = false
    })
  } else {
    // 如果当前没有选中项,则执行全选操作,需要限制最多选择6个
    let count = 0

    // 按顺序选择前6个列
    for (const column of tableColumns.value) {
      if (count < 6) {
        column.fixed = true
        count++
      } else {
        column.fixed = false
      }
    }

    // 如果超过6个,显示提示
    if (tableColumns.value.length > 6) {
      ElMessage.warning('最多只能冻结6列')
    }
  }

  updateCheckStatus()
}

// 处理排序全选变化
const handleSortableAllChange = (val: boolean) => {
  // 全选或取消全选"排序"
  tableColumns.value.forEach(column => {
    column.sortable = val
  })
  updateCheckStatus()
}

// 处理冻结变化
const handleFixedChange = () => {
  // 处理冻结列的单项checkbox的@change事件
  const fixedCount = tableColumns.value.filter(col => col.fixed).length
  if (fixedCount >= 6) {
    ElMessage.warning('最多只能冻结6列')
  }
  updateCheckStatus()
}

// 处理排序变化
const handleSortableChange = () => {
  // 处理排序列的单项checkbox的@change事件
  updateCheckStatus()
}

// 处理取消
const handleCancel = () => {
  // 恢复原始配置
  tableColumns.value = JSON.parse(JSON.stringify(originalColumns.value))
  dialogVisible.value = false
}

// 处理恢复默认
const handleResetDefault = () => {
  // 显示恢复默认的确认对话框
  confirmMessage.value = '确定要恢复默认设置吗?'
  confirmActionType.value = 'reset'
  confirmDialogVisible.value = true
}

// 处理确认
const handleConfirm = () => {
  // 显示保存的确认对话框
  confirmMessage.value = '只对本用户生效且记忆该配置,确定保存吗?'
  confirmActionType.value = 'save'
  confirmDialogVisible.value = true
}

// 确认操作
const confirmAction = () => {
  // 关闭确认对话框
  confirmDialogVisible.value = false

  if (confirmActionType.value === 'save') {
    // 保存配置
    saveConfig()
  } else if (confirmActionType.value === 'reset') {
    // 恢复默认配置
    resetToDefault()
  }
}

// 保存配置
const saveConfig = () => {
  // 检查是否至少有一列是选中的
  const visibleColumns = tableColumns.value.filter(col => col.show)
  if (visibleColumns.length === 0) {
    // 如果没有任何一个选中,则选中第一个非必选项
    const firstColumn = tableColumns.value.find(col => !col.required)
    if (firstColumn) {
      firstColumn.show = true
      ElMessage.warning('至少需要保留一个显示字段')
    }
  }

  // 发送更新后的配置,包括添加回序号列
  const indexColumn = props.columns.find(col => col.prop === 'index')
  const finalColumns = indexColumn ? [indexColumn, ...tableColumns.value] : tableColumns.value

  // 保存成功后关闭抽屉
  emit('confirm', finalColumns)
  dialogVisible.value = false
}

// 恢复默认配置
const resetToDefault = () => {
  // 恢复为默认配置
  if (props.defaultColumns && props.defaultColumns.length > 0) {
    tableColumns.value = JSON.parse(JSON.stringify(props.defaultColumns))
      .filter((col: ColumnConfig) => col.prop !== 'index')
    updateCheckStatus()
  } else {
    // 如果没有提供默认配置,则提示错误
    ElMessage.warning('没有可用的默认配置')
  }
}

// 处理关闭
const handleClose = () => {
  handleCancel()
}
</script>

<style lang="scss" scoped>
.column-manager-drawer {
  :deep(.el-drawer__header) {
    padding: 20px;
    margin-bottom: 0;
    font-size: 18px;
    font-weight: bold;
  }

  :deep(.el-drawer__body) {
    padding: 0 20px;
  }
}

.description-wrapper {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 15px;
}

.description {
  color: #606266;
  font-size: 14px;
}

.drawer-content {
  height: calc(100% - 30px); /* 减去底部按钮区域的高度 */
  display: flex;
  flex-direction: column;
}

.drawer-header-fixed {
  flex-shrink: 0; /* 不允许压缩 */
}

.table-container {
  flex: 1; /* 占据剩余空间 */
  overflow: hidden; /* 隐藏容器的滚动条,让表格自己处理滚动 */
}

.header-content {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4px;

  .header-text {
    font-size: 14px;
    white-space: nowrap;
  }

  .header-help {
    font-size: 14px;
  }
}

.sortable-ghost {
  opacity: 0.5;
  background: #c8ebfb !important;
}

.drag-handle {
  cursor: move;
}

.drawer-footer {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  padding: 15px;
  border-top: 1px solid #e4e7ed;
  background: #fff;
  text-align: center;
}

// 只隐藏本组件表格内的复选框标签,不影响其他地方
.column-manager-drawer :deep(.el-table .el-checkbox__label) {
  display: none;
}
</style>

封装全屏/管理显示字段/导出功能的 hooks:src\composables\useTableCommon.ts

javascript 复制代码
/**
 * 表格通用功能组合式函数
 *
 * 封装了三个所有表格都需要的通用功能:
 * 1. 全屏展示 / 退出全屏
 * 2. 管理显示字段(列的显示/隐藏/冻结/排序)
 * 3. 导出 Excel(按当前可见列配置导出全部数据)
 *
 * @param options.configKey         - 配置存储的 key,用于后端保存/加载列配置
 * @param options.topLevelColumns   - 表格所有的列定义(ColumnConfig[])
 * @param options.getFieldConfig    - (可选)获取列配置的 API 函数
 * @param options.saveFieldConfig   - (可选)保存列配置的 API 函数
 * @param options.exportApi         - (可选)导出数据的 API 函数,调用时传入 { pageNo: 1, pageSize: -1 } 获取全部数据
 * @param options.exportFileName    - (可选)导出 Excel 的文件名,默认为 configKey
 */

import { ref, computed, nextTick } from "vue";
import { ElMessage } from "element-plus";
import { TableExportUtil } from "@/utils/TableExportUtil";
import type { ColumnConfig } from "@/components/TableColumnManager/index.vue";

/**
 * 导出 API 的参数类型
 * pageNo: 页码
 * pageSize: 每页条数(传 -1 表示查询全部)
 */
export interface ExportApiParams {
  pageNo: number;
  pageSize: number;
  [key: string]: any;
}

/**
 * 将列索引转为 Excel 列字母(0→A, 1→B, 25→Z, 26→AA ...)
 */
function encodeCol(col: number): string {
  let result = "";
  let n = col;
  while (n >= 0) {
    result = String.fromCharCode(65 + (n % 26)) + result;
    n = Math.floor(n / 26) - 1;
  }
  return result;
}

export function useTableCommon(options: {
  configKey: string;
  topLevelColumns: ColumnConfig[];
  getFieldConfig?: (key: string) => Promise<any>;
  saveFieldConfig?: (params: { page: string; config: any }) => Promise<any>;
  exportApi?: (params: ExportApiParams) => Promise<any>;
  exportFileName?: string;
  exportFieldFormatters?: Record<string, (value: any, record: any) => any>;
  exportFieldStyles?: Record<
    string,
    (value: any, record: any) => { color?: string; bgColor?: string } | null
  >;
}) {
  // ==================== 响应式状态 ====================

  // 是否处于全屏模式
  const isFullscreen = ref(false);
  // 表格外层容器的 DOM 引用(用于全屏操作)
  const tableInfoRef = ref<HTMLElement | null>(null);
  // el-table 组件引用
  const tableRef = ref();
  // 是否显示"管理显示字段"弹窗
  const columnManagerVisible = ref(false);
  // 强制刷新 key,用于列配置变化后重新渲染表格
  const refreshKey = ref(0);
  // 当前窗口高度(用于计算表格最大高度)
  const windowHeight = ref(window.innerHeight);

  // 当前列配置(包含用户自定义的显示/隐藏/冻结状态)
  const columnConfig = ref<ColumnConfig[]>(
    options.topLevelColumns.map((col) => ({ ...col })),
  );

  // 默认列配置(用于"恢复默认"功能)
  const defaultColumnConfig = computed(() =>
    options.topLevelColumns.map((col) => ({ ...col })),
  );

  // ==================== 列可见性与固定判断 ====================

  /** 判断指定 prop 的列是否可见 */
  function isColVisible(prop: string): boolean {
    return columnConfig.value.find((c) => c.prop === prop)?.show ?? true;
  }

  /** 判断指定 prop 的列是否冻结(固定在左侧) */
  function isColFixed(prop: string): boolean {
    return columnConfig.value.find((c) => c.prop === prop)?.fixed ?? false;
  }

  // ==================== 全屏功能 ====================

  /** 切换全屏/退出全屏 */
  function handleFullScreen() {
    const element = tableInfoRef.value;
    if (!element) return;

    if (!isFullscreen.value) {
      // 进入全屏(兼容不同浏览器)
      if (element.requestFullscreen) {
        element.requestFullscreen();
      } else if ((element as any).webkitRequestFullscreen) {
        (element as any).webkitRequestFullscreen();
      } else if ((element as any).mozRequestFullScreen) {
        (element as any).mozRequestFullScreen();
      } else if ((element as any).msRequestFullscreen) {
        (element as any).msRequestFullscreen();
      }
    } else {
      // 退出全屏(兼容不同浏览器)
      if (document.exitFullscreen) {
        document.exitFullscreen();
      } else if ((document as any).webkitExitFullscreen) {
        (document as any).webkitExitFullscreen();
      } else if ((document as any).mozCancelFullScreen) {
        (document as any).mozCancelFullScreen();
      } else if ((document as any).msExitFullscreen) {
        (document as any).msExitFullscreen();
      }
    }
  }

  /** 全屏状态变化时的处理:更新状态、刷新表格、调整尺寸 */
  function fullscreenChangeHandler() {
    isFullscreen.value = !!(
      document.fullscreenElement ||
      (document as any).webkitFullscreenElement ||
      (document as any).mozFullScreenElement ||
      (document as any).msFullscreenElement
    );
    // 全屏切换后强制刷新表格
    nextTick(() => {
      refreshKey.value++;
    });
    // 更新窗口高度,重新计算表格高度
    windowHeight.value = window.innerHeight;
    // 通知 el-table 重新计算自身尺寸
    nextTick(() => {
      tableInfoRef.value
        ?.querySelector?.(".el-table")
        ?.dispatchEvent?.(new Event("resize"));
    });
  }

  /** 窗口大小变化时的处理:更新 windowHeight 并通知 el-table 重算尺寸 */
  function windowResizeHandler() {
    windowHeight.value = window.innerHeight;
    nextTick(() => {
      tableInfoRef.value
        ?.querySelector?.(".el-table")
        ?.dispatchEvent?.(new Event("resize"));
    });
  }

  // ==================== 列配置管理 ====================

  /**
   * 将后端保存的列配置与默认列配置合并
   * 后端只保存了 show/fixed/sortable/order/minWidth 等变更字段,
   * 需要与完整的默认列配置合并,防止新增列时缺少配置
   */
  function mergeColumnConfig(
    savedColumns: Record<string, any>,
  ): ColumnConfig[] {
    const defaults = options.topLevelColumns.map((col) => ({ ...col }));
    return defaults.map((col) => {
      if (savedColumns[col.prop]) {
        return { ...col, ...savedColumns[col.prop] };
      }
      return col;
    });
  }

  /** 从后端加载列配置 */
  async function loadFieldConfig() {
    if (!options.configKey || !options.getFieldConfig) return;
    try {
      const res = await options.getFieldConfig(options.configKey);
      if (res.data && res.data.config && res.data.config.columns) {
        const columnsObj = res.data.config.columns || {};
        columnConfig.value = mergeColumnConfig(columnsObj);
        columnConfig.value.sort((a, b) => a.order - b.order);
      } else {
        // 后端没有配置时,使用默认列配置
        columnConfig.value = options.topLevelColumns.map((col) => ({ ...col }));
      }
    } catch (error) {
      console.error("获取列配置失败:", error);
    }
  }

  /** 点击"管理显示字段"按钮:先刷新配置再打开弹窗 */
  async function handleTableSetting() {
    if (!options.configKey || !options.getFieldConfig) {
      columnManagerVisible.value = true;
      return;
    }
    try {
      const res = await options.getFieldConfig(options.configKey);
      if (res.data && res.data.config && res.data.config.columns) {
        const columnsObj = res.data.config.columns || {};
        columnConfig.value = mergeColumnConfig(columnsObj);
        columnConfig.value.sort((a, b) => a.order - b.order);
      } else {
        columnConfig.value = options.topLevelColumns.map((col) => ({ ...col }));
      }
    } catch (error) {
      console.error("获取最新列配置失败:", error);
      ElMessage.warning("获取最新列配置失败,将使用当前配置");
    }
    columnManagerVisible.value = true;
  }

  /** 弹窗确认后的处理:保存列配置到后端 */
  async function handleColumnConfigConfirm(config: ColumnConfig[]) {
    columnConfig.value = config;

    if (!options.saveFieldConfig) {
      columnManagerVisible.value = false;
      return;
    }

    try {
      // 构建保存的配置对象(只保存需要持久化的字段)
      const configObject = {
        columns: config.reduce((obj, column) => {
          obj[column.prop] = {
            show: column.show,
            fixed: column.fixed,
            sortable: column.sortable,
            order: column.order,
            minWidth: column.minWidth,
          };
          return obj;
        }, {} as Record<string, any>),
        version: "1.0",
      };

      await options.saveFieldConfig({
        page: options.configKey,
        config: configObject,
      });

      ElMessage.success("配置成功");
      await loadFieldConfig();
      refreshKey.value++;
    } catch (error) {
      console.error("保存配置失败:", error);
      ElMessage.error("保存失败");
      refreshKey.value++;
    }

    columnManagerVisible.value = false;
  }

  // ==================== 导出 Excel 功能 ====================

  /**
   * 导出 Excel
   *
   * 流程:
   * 1. 先刷新列配置,确保导出列与"管理显示字段"的配置一致
   * 2. 调用 exportApi 获取全部数据(pageNo=1, pageSize=-1)
   * 3. 从返回数据中提取可见列的字段值,按表格展示顺序排列
   * 4. 使用 TableExportUtil 生成并下载 Excel 文件
   */
  async function handleExport() {
    if (!options.exportApi) {
      ElMessage.warning("未配置导出功能");
      return;
    }

    try {
      ElMessage.info("正在导出数据...");

      // 先刷新列配置,确保导出的列与"管理显示字段"中的配置一致
      await loadFieldConfig();

      // 查询全部数据:pageNo=1, pageSize=-1
      const exportParams: ExportApiParams = {
        pageNo: 1,
        pageSize: -1,
      };

      const res = await options.exportApi(exportParams);
      // 兼容后端返回格式:res.data.records 或 res.data
      const records: Record<string, any>[] =
        res.data?.records || res.data || [];

      if (!records || records.length === 0) {
        ElMessage.warning("没有可导出的数据");
        return;
      }

      // 只导出当前可见的列,按配置顺序排列(与表格展示顺序一致)
      const visibleColumns = columnConfig.value
        .filter((col) => col.show)
        .sort((a, b) => a.order - b.order);

      // 表头字段名(与数据对象 key 对应)
      const jsonHeader = visibleColumns.map((col) => col.prop);
      // 表头中文标签
      const headerLabels = visibleColumns.map((col) => col.label);

      // 构建第一行(中文表头),格式:{ prop: "标签" }
      const headerRow = headerLabels.reduce((obj, label, index) => {
        obj[jsonHeader[index]] = label;
        return obj;
      }, {} as Record<string, string>);

      // 从原始数据中只提取可见列的字段,确保导出的数据不包含隐藏列
      const exportData = records.map((record) => {
        const row: Record<string, any> = {};
        jsonHeader.forEach((prop) => {
          const value = record[prop];
          // 如果有自定义格式化函数,则使用格式化函数处理
          if (
            options.exportFieldFormatters &&
            options.exportFieldFormatters[prop]
          ) {
            row[prop] = options.exportFieldFormatters[prop](value, record);
          } else {
            row[prop] = value;
          }
        });
        return row;
      });

      // 使用 TableExportUtil 导出
      const exportUtil = new TableExportUtil();
      // 添加数据:第一行为中文表头,后面为只含可见列的数据
      exportUtil.addJson([headerRow, ...exportData], jsonHeader);

      // 处理条件样式
      if (options.exportFieldStyles) {
        const styledPositions: {
          pos: string;
          style: { color?: string; bgColor?: string };
        }[] = [];

        for (let rowIdx = 0; rowIdx < records.length; rowIdx++) {
          const record = records[rowIdx];
          for (let colIdx = 0; colIdx < jsonHeader.length; colIdx++) {
            const prop = jsonHeader[colIdx];
            if (options.exportFieldStyles[prop]) {
              const value = record[prop];
              const style = options.exportFieldStyles[prop](value, record);
              if (style && (style.color || style.bgColor)) {
                const colLetter = encodeCol(colIdx);
                styledPositions.push({
                  pos: `${colLetter}${rowIdx + 2}`, // 第1行为表头,数据从第2行开始
                  style,
                });
              }
            }
          }
        }

        if (styledPositions.length > 0) {
          exportUtil.setStyle((ws: any) => {
            styledPositions.forEach((item) => {
              if (ws[item.pos]) {
                ws[item.pos].s = {
                  ...(ws[item.pos].s || {}),
                  ...(item.style.bgColor
                    ? {
                        fill: {
                          patternType: "solid",
                          fgColor: { rgb: item.style.bgColor.replace("#", "") },
                        },
                      }
                    : {}),
                  ...(item.style.color
                    ? {
                        font: {
                          ...(ws[item.pos].s?.font || {}),
                          color: { rgb: item.style.color.replace("#", "") },
                        },
                      }
                    : {}),
                };
              }
            });
          });
        }
      }

      exportUtil.export({
        name: options.exportFileName || options.configKey,
        autoWidth: true,
        border: true,
        skipRow: 1,
      });

      ElMessage.success("导出成功");
    } catch (error) {
      console.error("导出失败:", error);
      ElMessage.error("导出失败");
    }
  }

  // ==================== 事件监听管理 ====================

  /** 注册全屏状态变化的事件监听(兼容各浏览器)及窗口 resize 监听 */
  function setupFullscreenListeners() {
    document.addEventListener("fullscreenchange", fullscreenChangeHandler);
    document.addEventListener(
      "webkitfullscreenchange",
      fullscreenChangeHandler,
    );
    document.addEventListener("mozfullscreenchange", fullscreenChangeHandler);
    document.addEventListener("MSFullscreenChange", fullscreenChangeHandler);
    window.addEventListener("resize", windowResizeHandler);
  }

  /** 移除全屏状态变化的事件监听及窗口 resize 监听 */
  function removeFullscreenListeners() {
    document.removeEventListener("fullscreenchange", fullscreenChangeHandler);
    document.removeEventListener(
      "webkitfullscreenchange",
      fullscreenChangeHandler,
    );
    document.removeEventListener(
      "mozfullscreenchange",
      fullscreenChangeHandler,
    );
    document.removeEventListener("MSFullscreenChange", fullscreenChangeHandler);
    window.removeEventListener("resize", windowResizeHandler);
  }

  // ==================== 导出给组件使用的属性和方法 ====================

  return {
    // 状态
    isFullscreen,
    tableInfoRef,
    tableRef,
    columnManagerVisible,
    refreshKey,
    windowHeight,
    columnConfig,
    defaultColumnConfig,

    // 方法
    isColVisible,
    isColFixed,
    handleFullScreen,
    handleTableSetting,
    handleColumnConfigConfirm,
    handleExport,
    loadFieldConfig,
    setupFullscreenListeners,
    removeFullscreenListeners,
    mergeColumnConfig,
  };
}

excel 导出工具类:src\utils\TableExportUtil.ts

使用的插件版本:

bash 复制代码
"xlsx-js-style": "^1.2.0"
"moment": "^2.30.1",
javascript 复制代码
import XLSX from "xlsx-js-style";
import TimeUtil from "./TimeUtil";

/**
 * 基本通用Xlsx导出
 * @param elDom DOM表格id,class
 * @param name 导出xlsx名字
 * @param jsonData json数据
 * @param jsonHeader json表头
 * @param merges 合并表格
 * @param styleCB 表格样式
 * @param autoWidth 是否动态调整宽度
 * @param border 是否添加边框
 */
export class TableExportUtil {
  public FONT_TITLE_DEFAULT = {
    font: {
      sz: 18,
    },
    alignment: {
      horizontal: "center",
      vertical: "center",
    },
  };
  public FONT_DEFAULT = {
    alignment: {
      horizontal: "center",
      vertical: "center",
    },
  };
  public FONT_RIGHT = {
    alignment: {
      horizontal: "right",
      vertical: "center",
    },
  };
  public FONT_LEFT = {
    alignment: {
      horizontal: "right",
      vertical: "center",
    },
  };
  private workbook = XLSX.utils.book_new();
  private worksheet: any;
  private mysheets: any = {};

  private m_lastCol = -1;
  public getObjectKeys = (obj: any) => {
    const temp: any = [];
    for (const key in obj) {
      temp.push(key);
    }
    return temp;
  };
  // 添加DOM表格
  public addTable = (elDom: string) => {
    let el: any = document.querySelector(elDom);
    if (el.querySelector(".el-table__fixed")) {
      el = el.querySelector(".el-table__fixed");
    }
    if (el.querySelector(".el-table__fixed-right")) {
      el = el.querySelector(".el-table__fixed-right");
    }
    if (this.worksheet == undefined) {
      this.worksheet = XLSX.utils.table_to_sheet(el, { raw: true });
    } else {
      // 确定续接位置
      XLSX.utils.sheet_add_dom(this.worksheet, el, { raw: true, origin: -1 });
    }
  };

  // 添加Json数据
  public addJson = (jsonData: any[], jsonHeader: string[] = []) => {
    if (jsonData.length > 0) {
      if (jsonHeader) {
        if (this.worksheet == undefined) {
          this.worksheet = XLSX.utils.json_to_sheet(jsonData, {
            header: jsonHeader,
            skipHeader: true,
          });
        } else {
          // 确定续接位置
          XLSX.utils.sheet_add_json(this.worksheet, jsonData, {
            header: jsonHeader,
            skipHeader: true,
            origin: -1,
          });
        }
      } else {
        const header = Object.keys(jsonData[0]);
        if (this.worksheet == undefined) {
          this.worksheet = XLSX.utils.json_to_sheet(jsonData, {
            header: header,
            skipHeader: true,
          });
        } else {
          // 确定续接位置
          XLSX.utils.sheet_add_json(this.worksheet, jsonData, {
            header: header,
            skipHeader: true,
            origin: -1,
          });
        }
      }
    }
  };

  //删除指定列数据 0为第一列
  public deleteCol = (col: number) => {
    const letter = XLSX.utils.encode_col(col);

    const wsArray = Object.keys(this.worksheet);
    wsArray.forEach((v) => {
      const v1 = v.replace(/[0-9]+/g, "");
      if (v1 == letter) {
        this.worksheet[v] = { t: "s", v: "" };
      }
    });
  };
  //手动指定多少列为最后一列 0为第一列
  public setLastCol(col: number) {
    this.m_lastCol = col;
    const wsArray = Object.keys(this.worksheet);
    wsArray.forEach((v) => {
      const v1 = v.replace(/[0-9]+/g, "");
      const tempCol = XLSX.utils.decode_col(v1);

      if (tempCol > col) {
        this.worksheet[v] = { t: "s", v: "" };
      }
    });
  }

  // 合并
  public setMerges = (merges: string[]) => {
    if (!this.worksheet["!merges"]) this.worksheet["!merges"] = [];
    merges.forEach((item) => {
      this.worksheet["!merges"].push(XLSX.utils.decode_range(item));
    });
  };
  // 样式
  public setStyle = (styleCB: Function) => {
    styleCB(this.worksheet);
  };
  public getCellWidth(value: any) {
    // 判断是否为null或undefined
    if (value == null || value == "" || value == undefined) {
      return 9;
    } else if (/.*[\u4e00-\u9fa5]+.*$/.test(value)) {
      // 中文的长度
      const chineseLength = value.match(/[\u4e00-\u9fa5]/g).length;
      // 其他不是中文的长度
      const otherLength = value.length - chineseLength;
      return chineseLength * 2.4 + otherLength * 2;
    } else {
      return value.toString().length * 2;
      /* 另一种方案
      value = value.toString()
      return value.replace(/[\u0391-\uFFE5]/g, 'aa').length
      */
    }
  }
  public s2ab(s: any) {
    const buf = new ArrayBuffer(s.length);
    const view = new Uint8Array(buf);
    for (let i = 0; i != s.length; ++i) view[i] = s.charCodeAt(i) & 0xff;
    return buf;
  }
  public openDownloadDialog(url: Blob | string, saveName: string) {
    if (typeof url == "object" && url instanceof Blob) {
      url = URL.createObjectURL(url); // 创建blob地址
    }
    const aLink = document.createElement("a");
    aLink.href = url;
    aLink.download = saveName || ""; // HTML5新增的属性,指定保存文件名,可以不要后缀,注意,file:///模式下不会生效
    let event;
    if (window.MouseEvent) event = new MouseEvent("click");
    else {
      event = document.createEvent("MouseEvents");
      event.initMouseEvent(
        "click",
        true,
        false,
        window,
        0,
        0,
        0,
        0,
        0,
        false,
        false,
        false,
        false,
        0,
        null,
      );
    }
    aLink.dispatchEvent(event);
  }
  public addSheet(SheetName: any) {
    this.mysheets[SheetName] = this.worksheet;
    this.worksheet = null;
  }
  //导出
  public export = ({
    autoWidth = true,
    skipRow = 0, //默认跳过计算的宽度行数,1代表从第2行开始计算列的宽度
    border = true,
    name = "",
  }) => {
    const wsArray = Object.keys(this.worksheet);
    const wObj: any = {};
    wsArray.forEach((v) => {
      if (v[0] == "!") return false;
      const w = this.getCellWidth(this.worksheet[v].v);
      const v1 = v.replace(/[0-9]+/g, "");
      const v2 = Number(v.replace(/[A-Z]+/g, "")); //行数
      if ((!wObj[v1] || wObj[v1] < w) && v2 > skipRow) {
        wObj[v1] = w;
      }
    });
    // 自动宽度设置
    if (autoWidth) {
      this.worksheet["!cols"] = Object.keys(wObj).map((v, index) => {
        const width = wObj[XLSX.utils.encode_col(index)];
        // 设置最小宽度15,最大宽度50
        const clampedWidth = Math.max(15, Math.min(50, width));
        return { wch: clampedWidth };
      });
    } else {
      this.worksheet["!cols"] = Object.keys(wObj).map((v, index) => {
        return { wch: 20 };
      });
    }
    console.log("cols = ", this.worksheet["!cols"]);

    // 设置边框
    if (border) {
      const ref = this.worksheet["!ref"].split(":");
      const v1 = ref[1].replace(/[0-9]+/g, "");
      const lastRow = Number(ref[1].replace(/[A-Z]+/g, ""));
      // 判断最右侧空格
      const wsArray = Object.keys(this.worksheet);
      const isValue = wsArray.some((v) => {
        if (v.indexOf(v1) != -1) {
          if (this.worksheet[v].v) {
            return true;
          }
        }
        return false;
      });
      let lastCol = isValue
        ? XLSX.utils.decode_col(v1)
        : XLSX.utils.decode_col(v1) - 1;
      if (this.m_lastCol != -1) {
        lastCol = this.m_lastCol;
      }
      // 循环设置边框
      for (let i = 0; i <= lastCol; i++) {
        const letter = XLSX.utils.encode_col(i);
        for (let j = 0; j <= lastRow; j++) {
          const name = `${letter}${j}`;
          if (!this.worksheet[name]) this.worksheet[name] = { t: "s", v: "" };
          this.worksheet[name] = {
            ...this.worksheet[name],
            t: "s",
            v:
              this.worksheet[name].v == undefined ? "" : this.worksheet[name].v,
            s: {
              ...this.worksheet[name].s,
              border: {
                // 设置边框
                top: { style: "thin" },
                bottom: { style: "thin" },
                left: { style: "thin" },
                right: { style: "thin" },
              },
              alignment: {
                vertical: "center",
                horizontal: "center",
                ...this.worksheet[name].s?.alignment,
              },
            },
          };
        }
      }
    }
    XLSX.utils.book_append_sheet(this.workbook, this.worksheet, "Sheet1", true);
    const wbout = XLSX.write(this.workbook, {
      bookType: "xlsx",
      bookSST: false,
      type: "binary",
    });
    const XlsxBlob = new Blob([this.s2ab(wbout)], {
      type: "application/octet-stream",
    });
    this.openDownloadDialog(
      XlsxBlob,
      name + TimeUtil.format(new Date(), "YYYYMMDDHHmmss") + ".xlsx",
    );
  };
  //多sheet导出
  public exportBySheets = ({
    autoWidth = true,
    skipRow = 0, //默认跳过计算的宽度行数,1代表从第2行开始计算列的宽度
    border = true,
    name = "",
  }) => {
    for (const key in this.mysheets) {
      const mysheet = this.mysheets[key];
      const wsArray = Object.keys(mysheet);
      const wObj: any = {};
      wsArray.forEach((v) => {
        if (v[0] == "!") return false;
        const w = this.getCellWidth(mysheet[v].v);
        const v1 = v.replace(/[0-9]+/g, "");
        const v2 = Number(v.replace(/[A-Z]+/g, "")); //行数
        if ((!wObj[v1] || wObj[v1] < w) && v2 > skipRow) {
          wObj[v1] = w;
        }
      });
      // 自动宽度设置
      if (autoWidth) {
        mysheet["!cols"] = Object.keys(wObj).map((v, index) => {
          const width = wObj[XLSX.utils.encode_col(index)];
          // 设置最小宽度10,最大宽度50
          const clampedWidth = Math.max(10, Math.min(50, width));
          return { wch: clampedWidth };
        });
      } else {
        mysheet["!cols"] = Object.keys(wObj).map((v, index) => {
          return { wch: 20 };
        });
      }
      console.log("cols = ", mysheet["!cols"]);

      // 设置边框
      if (border) {
        const ref = mysheet["!ref"].split(":");
        const v1 = ref[1].replace(/[0-9]+/g, "");
        const lastRow = Number(ref[1].replace(/[A-Z]+/g, ""));
        // 判断最右侧空格
        const wsArray = Object.keys(mysheet);
        const isValue = wsArray.some((v) => {
          if (v.indexOf(v1) != -1) {
            if (mysheet[v].v) {
              return true;
            }
          }
          return false;
        });
        let lastCol = isValue
          ? XLSX.utils.decode_col(v1)
          : XLSX.utils.decode_col(v1) - 1;
        if (this.m_lastCol != -1) {
          lastCol = this.m_lastCol;
        }
        // 循环设置边框
        for (let i = 0; i <= lastCol; i++) {
          const letter = XLSX.utils.encode_col(i);
          for (let j = 0; j <= lastRow; j++) {
            const name = `${letter}${j}`;
            if (!mysheet[name]) mysheet[name] = { t: "s", v: "" };
            mysheet[name] = {
              ...mysheet[name],
              t: "s",
              v: mysheet[name].v == undefined ? "" : mysheet[name].v,
              s: {
                ...mysheet[name].s,
                border: {
                  // 设置边框
                  top: { style: "thin" },
                  bottom: { style: "thin" },
                  left: { style: "thin" },
                  right: { style: "thin" },
                },
                alignment: {
                  vertical: "center",
                  horizontal: "center",
                  ...mysheet[name].s?.alignment,
                },
              },
            };
          }
        }
      }
      XLSX.utils.book_append_sheet(this.workbook, mysheet, key, true);
    }

    const wbout = XLSX.write(this.workbook, {
      bookType: "xlsx",
      bookSST: false,
      type: "binary",
    });
    const XlsxBlob = new Blob([this.s2ab(wbout)], {
      type: "application/octet-stream",
    });
    this.openDownloadDialog(
      XlsxBlob,
      name + TimeUtil.format(new Date(), "YYYYMMDDHHmmss") + ".xlsx",
    );
  };
}

时间格式化函数:src\utils\TimeUtil.ts

javascript 复制代码
import moment from "moment";
export default class TimeUtil {
  /**
   * @des 时间格式化函数
   * @des 月(M)、日(d)、小时(h)、分(m)、秒(s)、季度(q) 可以用 1-2 个占位符,
   * @des 年(y)可以用 1-4 个占位符,毫秒(S)只能用 1 个占位符(是 1-3 位的数字)
   * @param {Date|number|string} date Date对象或者时间戳
   * @param {string} fmt 格式化字符串 ("YYYY-MM-DD HH:mm:ss") ==> 2006-07-02 08:09:04
   * @returns {string} 格式化后的字符串
   */
  static format(date: string | Date | number, fmt = "YYYY-MM-DD HH:mm:ss") {
    if (!date) return "";
    const d = moment(date).format(fmt);
    return ~d.indexOf("Invalid") ? "" : d;
  }
}

useTableCommon 使用指南

📋 功能概述

useTableCommon 是一个封装了表格通用功能的组合式函数(Composable),适用于单级表头的表格场景。它提供了以下核心功能:

  • 全屏展示:一键切换全屏/退出全屏,自动适配窗口大小
  • 列管理:可视化配置列的显示/隐藏/冻结/排序,支持持久化保存
  • 单表头导出:按当前可见列配置导出 Excel,支持自定义格式化和条件样式

🎯 适用场景

  • 单级表头表格(扁平数据结构)
  • 需要列管理功能的表格
  • 需要导出功能的表格
  • 需要全屏展示的表格

📦 API 参数

typescript 复制代码
useTableCommon(options: {
  configKey: string;                              // 必填:配置存储的 key
  topLevelColumns: ColumnConfig[];                // 必填:表格列定义
  getFieldConfig?: (key: string) => Promise<any>; // 可选:获取列配置的 API
  saveFieldConfig?: (params: { page: string; config: any }) => Promise<any>; // 可选:保存列配置的 API
  exportApi?: (params: ExportApiParams) => Promise<any>; // 可选:导出数据的 API
  exportFileName?: string;                       // 可选:导出文件名
  exportFieldFormatters?: Record<string, (value: any, record: any) => any>; // 可选:字段格式化函数
  exportFieldStyles?: Record<string, (value: any, record: any) => { color?: string; bgColor?: string } | null>; // 可选:条件样式函数
})

🔧 ColumnConfig 类型定义

typescript 复制代码
interface ColumnConfig {
  prop: string; // 字段名,对应数据对象的 key
  label: string; // 表头显示文字
  show: boolean; // 是否显示
  fixed: boolean; // 是否固定在左侧
  sortable: boolean; // 是否可排序
  required: boolean; // 是否必填(必填列不能隐藏)
  order: number; // 显示顺序
  width?: string | number; // 列宽度
  minWidth?: string | number; // 最小宽度
  [key: string]: any; // 其他自定义属性
}

💡 使用示例

1. 基础配置

typescript 复制代码
import { useTableCommon } from "@/composables/useTableCommon";
import type { ColumnConfig } from "@/components/TableColumnManager/index.vue";

// 定义表格列
const topLevelColumns: ColumnConfig[] = [
  {
    prop: "index",
    label: "序号",
    show: true,
    fixed: true,
    sortable: false,
    required: true,
    order: 0,
    width: 60,
  },
  {
    prop: "name",
    label: "姓名",
    show: true,
    fixed: false,
    sortable: true,
    required: false,
    order: 1,
    minWidth: 120,
  },
  {
    prop: "salary",
    label: "薪资(元)",
    show: true,
    fixed: false,
    sortable: true,
    required: false,
    order: 5,
    minWidth: 120,
  },
];

// 使用 composable
const {
  isFullscreen, // 是否全屏
  tableInfoRef, // 表格容器引用
  columnManagerVisible, // 列管理弹窗可见性
  refreshKey, // 强制刷新 key
  windowHeight, // 窗口高度
  columnConfig, // 当前列配置
  defaultColumnConfig, // 默认列配置
  isColVisible, // 判断列是否可见
  isColFixed, // 判断列是否固定
  handleFullScreen, // 切换全屏
  handleTableSetting, // 打开列管理
  handleColumnConfigConfirm, // 确认列配置
  handleExport, // 导出 Excel
  loadFieldConfig, // 加载列配置
  setupFullscreenListeners, // 注册全屏监听
  removeFullscreenListeners, // 移除全屏监听
  mergeColumnConfig, // 合并列配置
} = useTableCommon({
  configKey: "employee-list",
  topLevelColumns,
});

2. 完整配置(含导出)

typescript 复制代码
const { handleExport } = useTableCommon({
  configKey: "employee-list",
  topLevelColumns,

  // 获取列配置 API
  getFieldConfig: async (key) => {
    const res = await api.getFieldConfig(key);
    return res.data;
  },

  // 保存列配置 API
  saveFieldConfig: async (params) => {
    await api.saveFieldConfig(params);
    return { success: true };
  },

  // 导出数据 API
  exportApi: async (params) => {
    // params: { pageNo: 1, pageSize: -1 }
    const res = await api.getEmployeeList(params);
    return res;
  },

  // 导出文件名
  exportFileName: "员工信息表",

  // 字段格式化函数
  exportFieldFormatters: {
    // 薪资添加千分位
    salary: (value) => {
      if (value == null) return "";
      return Number(value).toLocaleString("zh-CN");
    },
    // 绩效评分添加单位
    performance: (value) => {
      if (value == null) return "";
      return `${value}分`;
    },
  },

  // 条件样式函数
  exportFieldStyles: {
    // 薪资大于 20000 显示红色
    salary: (value) => {
      if (Number(value) > 20000) {
        return { color: "#FF0000", bgColor: "#FFF2F0" };
      }
      return null;
    },
    // 绩效低于 60 显示红色背景
    performance: (value) => {
      if (Number(value) < 60) {
        return { color: "#9C0006", bgColor: "#FFC7CE" };
      }
      return null;
    },
  },
});

3. 模板中使用

typescript 复制代码
<template>
  <div ref="tableInfoRef" class="table-container">
    <!-- 工具栏 -->
    <div class="toolbar">
      <el-button @click="handleTableSetting">管理显示字段</el-button>
      <el-button @click="handleFullScreen">
        {{ isFullscreen ? "退出全屏" : "全屏展示" }}
      </el-button>
      <el-button type="primary" @click="handleExport">导出 Excel</el-button>
    </div>

    <!-- 表格 -->
    <el-table
      :key="refreshKey"
      :data="tableData"
      :max-height="tableMaxHeight"
      ref="tableRef"
    >
      <el-table-column
        prop="index"
        label="序号"
        width="60"
        fixed="left"
        align="center"
      />
      <el-table-column
        prop="name"
        label="姓名"
        :visible="isColVisible('name')"
        :fixed="isColFixed('name')"
        min-width="120"
        align="center"
      />
      <el-table-column
        prop="salary"
        label="薪资(元)"
        :visible="isColVisible('salary')"
        :fixed="isColFixed('salary')"
        min-width="120"
        align="center"
      >
        <template #default="{ row }">
          <span :class="{ 'salary-highlight': row.salary > 20000 }">
            {{ formatNumber(row.salary) }}
          </span>
        </template>
      </el-table-column>
    </el-table>

    <!-- 列管理弹窗 -->
    <TableColumnManager
      v-model:visible="columnManagerVisible"
      :columns="columnConfig"
      :default-columns="defaultColumnConfig"
      @confirm="handleColumnConfigConfirm"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";

// 表格数据
const tableData = ref([]);

// 计算表格最大高度
const tableMaxHeight = computed(() => {
  return isFullscreen.value ? `${windowHeight.value - 120}px` : "500px";
});

// 组件挂载
onMounted(() => {
  setupFullscreenListeners();
  loadFieldConfig();
  // 加载数据...
});

// 组件卸载
onUnmounted(() => {
  removeFullscreenListeners();
});
</script>

⚠️ 注意事项

1. 变量声明顺序

重要tableMaxHeight 必须在 useTableCommon 解构之后定义,因为它依赖 isFullscreenwindowHeight

typescript 复制代码
// ❌ 错误:会报错 "isFullscreen is not defined"
const tableMaxHeight = computed(() => {
  return isFullscreen.value ? '...' : '...'
})

const { isFullscreen, windowHeight } = useTableCommon({...})

// ✅ 正确
const { isFullscreen, windowHeight } = useTableCommon({...})

const tableMaxHeight = computed(() => {
  return isFullscreen.value ? '...' : '...'
})

2. 导出 API 规范

导出 API 必须支持 pageSize: -1 参数,表示查询全部数据:

typescript 复制代码
exportApi: async (params) => {
  // params: { pageNo: 1, pageSize: -1 }
  const res = await api.getData(params);
  return res;
};

返回数据格式支持两种:

  • res.data.records(推荐)
  • res.data

3. 条件样式颜色格式

颜色值必须为十六进制格式(带 #),且在设置样式时会自动去掉 #

typescript 复制代码
exportFieldStyles: {
  salary: (value) => {
    if (value > 20000) {
      return {
        color: "#FF0000", // ✅ 正确
        bgColor: "#FFF2F0", // ✅ 正确
      };
    }
    return null;
  };
}

4. 事件监听清理

必须在组件卸载时调用 removeFullscreenListeners() 清理事件监听:

typescript 复制代码
onUnmounted(() => {
  removeFullscreenListeners();
});

🎨 样式建议

表格容器样式

css 复制代码
.table-container {
  height: 100%;
  display: flex;
  flex-direction: column;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  transition: all 0.3s ease;

  &.fullscreen {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 9999;
    border-radius: 0;
    margin: 0;
  }
}

条件样式示例

css 复制代码
// 薪资高亮
.salary-highlight {
  color: #ff0000;
  background-color: #fff2f0;
  padding: 2px 6px;
  border-radius: 4px;
}

// 绩效异常
.performance-abnormal {
  color: #9c0006;
  background-color: #ffc7ce;
  padding: 2px 6px;
  border-radius: 4px;
}

🔗 相关组件

  • TableColumnManager:列管理弹窗组件
  • TableExportUtil:Excel 导出工具类

📚 完整示例

参考 HeaderExport.vue 文件查看完整的使用示例。


更新日期 :2026-06-05

版本:1.0.0

多行表头导出

先看效果

tip:表格展示的数据和导出的 Excel 数据是 mock 数据,数据是随机的,所以数据对不上。

页面渲染 导出 Excel

完整代码和封装的组件

完整示例:src\views\TestExport\HeadersExport.vue

javascript 复制代码
<template>
  <!-- 表格外层容器,用于全屏操作 -->
  <div
    ref="tableInfoRef"
    class="table-container"
    :class="{ fullscreen: isFullscreen }"
  >
    <!-- 工具栏 -->
    <div class="toolbar">
      <div class="toolbar-left">
        <h2>多表头导出示例</h2>
        <p class="description">
          演示 useTableExportComplex
          的完整功能:多级表头、嵌套数据、条件样式标记异常数据
        </p>
      </div>
      <div class="toolbar-right">
        <!-- 管理显示字段按钮 -->
        <el-button
          type="primary"
          plain
          @click="handleTableSetting"
          :icon="Setting"
        >
          管理显示字段
        </el-button>
        <!-- 全屏切换按钮 -->
        <el-button
          type="primary"
          plain
          @click="handleFullScreen"
          :icon="isFullscreen ? Minus : FullScreen"
        >
          {{ isFullscreen ? "退出全屏" : "全屏展示" }}
        </el-button>
        <!-- 导出按钮 -->
        <el-button type="primary" @click="handleExport" :icon="Download">
          导出 Excel
        </el-button>
      </div>
    </div>

    <!-- 表格区域 -->
    <div class="table-wrapper">
      <el-table
        :key="refreshKey"
        :data="tableData"
        border
        :max-height="tableMaxHeight"
        v-loading="loading"
        ref="tableRef"
      >
        <!-- 序号列(固定) -->
        <el-table-column
          prop="index"
          label="序号"
          width="60"
          fixed="left"
          align="center"
        />

        <!-- 基本信息列组 -->
        <el-table-column
          prop="basic"
          label="基本信息"
          :visible="isColVisible('basic')"
          align="center"
        >
          <el-table-column
            prop="productName"
            label="产品名称"
            min-width="150"
            align="center"
          />
          <el-table-column
            prop="batchNo"
            label="批次号"
            min-width="120"
            align="center"
          />
          <el-table-column
            prop="material"
            label="材质"
            min-width="100"
            align="center"
          />
        </el-table-column>

        <!-- 尺寸列组(带公差范围) -->
        <el-table-column
          prop="dimension"
          label="尺寸参数"
          :visible="isColVisible('dimension')"
          align="center"
        >
          <el-table-column
            prop="length"
            label="长度(mm)"
            min-width="120"
            align="center"
          >
            <template #default="{ row }">
              <span :class="{ abnormal: isLengthAbnormal(row) }">
                {{ formatNumber(row.dimension.length) }}
              </span>
            </template>
          </el-table-column>
          <el-table-column
            prop="width"
            label="宽度(mm)"
            min-width="120"
            align="center"
          >
            <template #default="{ row }">
              <span :class="{ abnormal: isWidthAbnormal(row) }">
                {{ formatNumber(row.dimension.width) }}
              </span>
            </template>
          </el-table-column>
          <el-table-column
            prop="thickness"
            label="厚度(mm)"
            min-width="120"
            align="center"
          >
            <template #default="{ row }">
              <span :class="{ abnormal: isThicknessAbnormal(row) }">
                {{ formatNumber(row.dimension.thickness) }}
              </span>
            </template>
          </el-table-column>
        </el-table-column>

        <!-- 性能指标列组 -->
        <el-table-column
          prop="performance"
          label="性能指标"
          :visible="isColVisible('performance')"
          align="center"
        >
          <el-table-column
            prop="tensileStrength"
            label="抗拉强度(MPa)"
            min-width="140"
            align="center"
          >
            <template #default="{ row }">
              <span :class="{ abnormal: isTensileAbnormal(row) }">
                {{ formatNumber(row.performance.tensileStrength) }}
              </span>
            </template>
          </el-table-column>
          <el-table-column
            prop="yieldStrength"
            label="屈服强度(MPa)"
            min-width="140"
            align="center"
          >
            <template #default="{ row }">
              <span :class="{ abnormal: isYieldAbnormal(row) }">
                {{ formatNumber(row.performance.yieldStrength) }}
              </span>
            </template>
          </el-table-column>
          <el-table-column
            prop="elongation"
            label="延伸率(%)"
            min-width="120"
            align="center"
          >
            <template #default="{ row }">
              <span :class="{ abnormal: isElongationAbnormal(row) }">
                {{ formatNumber(row.performance.elongation) }}
              </span>
            </template>
          </el-table-column>
        </el-table-column>

        <!-- 平铺列:检测日期 -->
        <el-table-column
          prop="inspectDate"
          label="检测日期"
          :visible="isColVisible('inspectDate')"
          min-width="120"
          align="center"
        />

        <!-- 平铺列:检测状态 -->
        <el-table-column
          prop="status"
          label="检测状态"
          :visible="isColVisible('status')"
          width="100"
          align="center"
        >
          <template #default="{ row }">
            <span
              :class="{
                'status-pass': row.status === '合格',
                'status-fail': row.status === '不合格',
              }"
            >
              {{ row.status }}
            </span>
          </template>
        </el-table-column>
      </el-table>
    </div>

    <!-- 列管理弹窗 -->
    <TableColumnManager
      v-model:visible="columnManagerVisible"
      :columns="columnConfig"
      :default-columns="defaultColumnConfig"
      @confirm="handleColumnConfigConfirm"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import type { ColumnConfig } from "@/components/TableColumnManager/index.vue";
import TableColumnManager from "@/components/TableColumnManager/index.vue";
import { useTableCommon } from "@/composables/useTableCommon";
import { useTableExportComplex } from "@/composables/useTableExportComplex";
import type { ExportColumnDef } from "@/composables/useTableExportComplex";
import { Setting, FullScreen, Minus, Download } from "@element-plus/icons-vue";

/**
 * 模拟表格数据(嵌套结构)
 */
const tableData = ref<Record<string, any>[]>([]);
const loading = ref(false);

/**
 * 生成模拟数据(带有嵌套结构的产品检测数据)
 */
const generateMockData = () => {
  const materials = ["Q235", "Q345", "Q460", "SS400", "A36"];
  const statuses = ["合格", "不合格"];
  const data: Record<string, any>[] = [];

  for (let i = 1; i <= 50; i++) {
    // 基准尺寸
    const baseLength = 10000;
    const baseWidth = 1500;
    const baseThickness = 20;

    // 带公差的尺寸值(部分数据超出公差范围以模拟异常)
    const length =
      baseLength +
      (Math.random() > 0.8
        ? Math.random() > 0.5
          ? 15
          : -15
        : Math.random() * 10 - 5);
    const width =
      baseWidth +
      (Math.random() > 0.85
        ? Math.random() > 0.5
          ? 8
          : -8
        : Math.random() * 6 - 3);
    const thickness =
      baseThickness +
      (Math.random() > 0.88
        ? Math.random() > 0.5
          ? 1.5
          : -1.5
        : Math.random() * 1 - 0.5);

    // 性能指标
    const tensileStrength = Math.floor(Math.random() * 200) + 400;
    const yieldStrength = Math.floor(Math.random() * 150) + 250;
    const elongation = Math.floor(Math.random() * 15) + 15;

    // 判断是否有异常
    const hasAbnormal =
      Math.abs(length - baseLength) > 10 ||
      Math.abs(width - baseWidth) > 6 ||
      Math.abs(thickness - baseThickness) > 1 ||
      tensileStrength < 420 ||
      yieldStrength < 260 ||
      elongation < 18;

    data.push({
      index: i,
      productName: `钢板${String(i).padStart(4, "0")}`,
      batchNo: `B${2024}${String(Math.floor(Math.random() * 100) + 1).padStart(
        3,
        "0",
      )}`,
      material: materials[Math.floor(Math.random() * materials.length)],
      dimension: {
        length: Number(length.toFixed(2)),
        width: Number(width.toFixed(2)),
        thickness: Number(thickness.toFixed(2)),
        lengthTolerance: "±10",
        widthTolerance: "±6",
        thicknessTolerance: "±1",
      },
      performance: {
        tensileStrength,
        yieldStrength,
        elongation,
      },
      inspectDate: `2024-${String(Math.floor(Math.random() * 12) + 1).padStart(
        2,
        "0",
      )}-${String(Math.floor(Math.random() * 28) + 1).padStart(2, "0")}`,
      status: hasAbnormal ? "不合格" : "合格",
    });
  }

  return data;
};

/**
 * 格式化数字
 */
const formatNumber = (val: number): string => {
  if (val == null) return "";
  return val.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
};

/**
 * 判断长度是否异常
 */
const isLengthAbnormal = (row: any): boolean => {
  return Math.abs(row.dimension.length - 10000) > 10;
};

/**
 * 判断宽度是否异常
 */
const isWidthAbnormal = (row: any): boolean => {
  return Math.abs(row.dimension.width - 1500) > 6;
};

/**
 * 判断厚度是否异常
 */
const isThicknessAbnormal = (row: any): boolean => {
  return Math.abs(row.dimension.thickness - 20) > 1;
};

/**
 * 判断抗拉强度是否异常
 */
const isTensileAbnormal = (row: any): boolean => {
  return row.performance.tensileStrength < 420;
};

/**
 * 判断屈服强度是否异常
 */
const isYieldAbnormal = (row: any): boolean => {
  return row.performance.yieldStrength < 260;
};

/**
 * 判断延伸率是否异常
 */
const isElongationAbnormal = (row: any): boolean => {
  return row.performance.elongation < 18;
};

/**
 * 模拟导出 API
 */
const mockExportApi = async (params: { pageNo: number; pageSize: number }) => {
  await new Promise((resolve) => setTimeout(resolve, 800));
  const data = generateMockData();
  return {
    data: {
      records: data,
      total: data.length,
    },
  };
};

/**
 * 表格列配置(用于列管理)
 * 注意:这里只定义一级列(分组列和平铺列),子列由导出定义处理
 */
const topLevelColumns: ColumnConfig[] = [
  {
    prop: "index",
    label: "序号",
    show: true,
    fixed: true,
    sortable: false,
    required: true,
    order: 0,
    width: 60,
  },
  {
    prop: "basic",
    label: "基本信息",
    show: true,
    fixed: false,
    sortable: false,
    required: false,
    order: 1,
    minWidth: 370,
  },
  {
    prop: "dimension",
    label: "尺寸参数",
    show: true,
    fixed: false,
    sortable: false,
    required: false,
    order: 2,
    minWidth: 360,
  },
  {
    prop: "performance",
    label: "性能指标",
    show: true,
    fixed: false,
    sortable: false,
    required: false,
    order: 3,
    minWidth: 400,
  },
  {
    prop: "inspectDate",
    label: "检测日期",
    show: true,
    fixed: false,
    sortable: true,
    required: false,
    order: 4,
    minWidth: 120,
  },
  {
    prop: "status",
    label: "检测状态",
    show: true,
    fixed: false,
    sortable: true,
    required: false,
    order: 5,
    width: 100,
  },
];

/**
 * 使用 useTableCommon 组合式函数(用于全屏和列管理)
 */
const {
  isFullscreen,
  tableInfoRef,
  tableRef,
  columnManagerVisible,
  refreshKey,
  windowHeight,
  columnConfig,
  defaultColumnConfig,
  isColVisible,
  isColFixed,
  handleFullScreen,
  handleTableSetting,
  handleColumnConfigConfirm,
  setupFullscreenListeners,
  removeFullscreenListeners,
  loadFieldConfig,
} = useTableCommon({
  configKey: "headers-export-demo",
  topLevelColumns,
  getFieldConfig: async () => {
    await new Promise((resolve) => setTimeout(resolve, 300));
    return {
      data: {
        config: {
          columns: {},
        },
      },
    };
  },
  saveFieldConfig: async (params) => {
    await new Promise((resolve) => setTimeout(resolve, 300));
    console.log("保存列配置:", params);
    return { success: true };
  },
});

/**
 * 导出列定义(支持多级表头结构)
 * 这是 useTableExportComplex 的核心配置
 */
const exportColumnDefs: ExportColumnDef[] = [
  // 基本信息组(平铺列,自动展开)
  {
    prop: "basic",
    label: "基本信息",
    children: [
      {
        label: "产品名称",
        accessor: (row: any) => row.productName,
      },
      {
        label: "批次号",
        accessor: (row: any) => row.batchNo,
      },
      {
        label: "材质",
        accessor: (row: any) => row.material,
      },
    ],
  },
  // 尺寸参数组(带公差范围和异常判断)
  {
    prop: "dimension",
    label: "尺寸参数",
    children: [
      {
        label: "长度(mm)",
        accessor: (row: any) =>
          `${row.dimension.length} ~ ${row.dimension.lengthTolerance}`,
        isAbnormal: (row: any) => Math.abs(row.dimension.length - 10000) > 10,
      },
      {
        label: "宽度(mm)",
        accessor: (row: any) =>
          `${row.dimension.width} ~ ${row.dimension.widthTolerance}`,
        isAbnormal: (row: any) => Math.abs(row.dimension.width - 1500) > 6,
      },
      {
        label: "厚度(mm)",
        accessor: (row: any) =>
          `${row.dimension.thickness} ~ ${row.dimension.thicknessTolerance}`,
        isAbnormal: (row: any) => Math.abs(row.dimension.thickness - 20) > 1,
      },
    ],
  },
  // 性能指标组(带异常判断)
  {
    prop: "performance",
    label: "性能指标",
    children: [
      {
        label: "抗拉强度(MPa)",
        accessor: (row: any) => row.performance.tensileStrength,
        isAbnormal: (row: any) => row.performance.tensileStrength < 420,
      },
      {
        label: "屈服强度(MPa)",
        accessor: (row: any) => row.performance.yieldStrength,
        isAbnormal: (row: any) => row.performance.yieldStrength < 260,
      },
      {
        label: "延伸率(%)",
        accessor: (row: any) => row.performance.elongation,
        isAbnormal: (row: any) => row.performance.elongation < 18,
      },
    ],
  },
  // 平铺列:检测日期
  {
    prop: "inspectDate",
    label: "检测日期",
  },
  // 平铺列:检测状态
  {
    prop: "status",
    label: "检测状态",
  },
];

/**
 * 使用 useTableExportComplex 组合式函数(专用于多级表头导出)
 */
const { handleExport } = useTableExportComplex({
  columnDefs: exportColumnDefs,
  columnConfig,
  loadFieldConfig,
  exportApi: mockExportApi,
  exportFileName: "产品检测报告",
});

/**
 * 表格最大高度计算
 * 全屏模式下减去工具栏高度,非全屏模式下使用固定高度
 * 必须在 useTableCommon 解构后定义,因为依赖 isFullscreen 和 windowHeight
 */
const tableMaxHeight = computed(() => {
  return isFullscreen.value ? `${windowHeight.value - 150}px` : "500px";
});

/**
 * 组件挂载时初始化
 */
onMounted(() => {
  setupFullscreenListeners();
  loadFieldConfig();
  loading.value = true;
  setTimeout(() => {
    tableData.value = generateMockData();
    loading.value = false;
  }, 500);
});

/**
 * 组件卸载时清理
 */
onUnmounted(() => {
  removeFullscreenListeners();
});
</script>

<style lang="scss" scoped>
.table-container {
  height: 100%;
  display: flex;
  flex-direction: column;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  transition: all 0.3s ease;

  &.fullscreen {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 9999;
    border-radius: 0;
    margin: 0;
  }
}

.toolbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px 20px;
  border-bottom: 1px solid #eee;

  .toolbar-left {
    h2 {
      margin: 0 0 4px 0;
      font-size: 18px;
      font-weight: 600;
      color: #303133;
    }

    .description {
      margin: 0;
      font-size: 13px;
      color: #909399;
    }
  }

  .toolbar-right {
    display: flex;
    gap: 12px;
  }
}

.table-wrapper {
  flex: 1;
  padding: 16px 20px;
  overflow: auto;
}

:deep(.el-table) {
  width: 100%;
}

.abnormal {
  color: #9c0006;
  background-color: #ffc7ce;
  padding: 2px 6px;
  border-radius: 4px;
}

/* 状态样式 */
.status-pass {
  color: #67c23a;
}

.status-fail {
  color: #9c0006;
}
</style>

封装的多级标题导出hooks:src\composables\useTableExportComplex.ts

javascript 复制代码
/**
 * 复杂表格导出组合式函数
 *
 * 专用于多级表头、嵌套数据、条件样式的表格导出场景。
 * 与 useTableCommon 互不干扰------useTableCommon 只支持扁平一级表头。
 *
 * 核心能力:
 * 1. 多级表头:自动生成两行表头(父级 + 子级),并设置合并单元格
 * 2. 嵌套数据提取:通过 accessor 函数从 row 中提取任意深度嵌套的值
 * 3. 组合字段:如 "xxx ~ yyy" 格式的公差列,通过 accessor 自行拼接
 * 4. 条件样式:异常数据标记为红色背景 + 深红字体
 *
 * @param options.columnDefs     - 导出列定义(含多级结构)
 * @param options.columnConfig   - 列可见性配置(受"管理显示字段"控制)
 * @param options.loadFieldConfig - 刷新列配置的函数
 * @param options.exportApi      - 导出数据的 API 函数
 * @param options.exportFileName - 导出文件名(不含扩展名)
 */

import { type Ref } from "vue";
import { ElMessage } from "element-plus";
import { TableExportUtil } from "@/utils/TableExportUtil";
import type { ColumnConfig } from "@/components/TableColumnManager/index.vue";

/**
 * 叶子列定义(实际数据列)
 * @param label       - 子列表头文字
 * @param accessor    - 从 row 中提取值的函数
 * @param isAbnormal  - (可选)判断该单元格是否异常,用于标记红色样式
 */
export interface ExportLeafDef {
  label: string;
  accessor: (row: any) => any;
  isAbnormal?: (row: any) => boolean;
}

/**
 * 导出列定义(支持平铺和多级)
 * @param prop        - 对应 ColumnConfig 的 prop,用于匹配可见性
 * @param label       - 表头文字(平铺列直接显示,分组列为父级表头)
 * @param children    - (可选)子列定义数组,有 children 表示分组列
 * @param accessor    - (可选)平铺列的数据提取函数,默认 (row) => row[prop]
 * @param isAbnormal  - (可选)平铺列的异常判断
 */
export interface ExportColumnDef {
  prop: string;
  label: string;
  children?: ExportLeafDef[];
  accessor?: (row: any) => any;
  isAbnormal?: (row: any) => boolean;
}

export interface ExportApiParams {
  pageNo: number;
  pageSize: number;
  [key: string]: any;
}

/**
 * 将列索引转为 Excel 列字母(0→A, 1→B, 25→Z, 26→AA ...)
 */
function encodeCol(col: number): string {
  let result = "";
  let n = col;
  while (n >= 0) {
    result = String.fromCharCode(65 + (n % 26)) + result;
    n = Math.floor(n / 26) - 1;
  }
  return result;
}

/**
 * 格式化数字,保留两位小数
 * 若值为 null/undefined/空字符串/非数字,原样返回
 */
function formatNumber(val: any): any {
  if (val == null || val === "") return val;
  const num = Number(val);
  if (isNaN(num)) return val;
  return String(parseFloat(num.toFixed(2)));
}

export function useTableExportComplex(options: {
  columnDefs: ExportColumnDef[];
  columnConfig: Ref<ColumnConfig[]>;
  loadFieldConfig: () => Promise<void>;
  exportApi?: (params: ExportApiParams) => Promise<any>;
  exportFileName?: string;
}) {
  async function handleExport() {
    if (!options.exportApi) {
      ElMessage.warning("未配置导出功能");
      return;
    }

    try {
      ElMessage.info("正在导出数据...");

      // 先刷新列配置,确保导出的列与"管理显示字段"最新配置一致
      await options.loadFieldConfig();

      // ==================== 1. 构建可见列的叶子列列表 ====================

      const visibleDefs = options.columnDefs.filter((def) => {
        const cfg = options.columnConfig.value.find((c) => c.prop === def.prop);
        return cfg ? cfg.show : true;
      });

      /**
       * leafColumns: 扁平化的叶子列列表,用于:
       * - 构建 Excel 列顺序
       * - 提取每行数据
       * - 判断异常单元格
       */
      const leafColumns: {
        parentLabel: string;
        label: string;
        accessor: (row: any) => any;
        isAbnormal?: (row: any) => boolean;
        isGrouped: boolean;
      }[] = [];

      for (const def of visibleDefs) {
        if (def.children && def.children.length > 0) {
          // 分组列:展开为多个叶子列
          for (const child of def.children) {
            leafColumns.push({
              parentLabel: def.label,
              label: child.label,
              accessor: child.accessor,
              isAbnormal: child.isAbnormal,
              isGrouped: true,
            });
          }
        } else {
          // 平铺列:自身即为叶子列
          leafColumns.push({
            parentLabel: def.label,
            label: def.label,
            accessor: def.accessor || ((row: any) => row[def.prop]),
            isAbnormal: def.isAbnormal,
            isGrouped: false,
          });
        }
      }

      const totalCols = leafColumns.length;
      if (totalCols === 0) {
        ElMessage.warning("没有可导出的列");
        return;
      }

      // ==================== 2. 获取全部数据 ====================

      const res = await options.exportApi({
        pageNo: 1,
        pageSize: -1,
      });
      const records: Record<string, any>[] =
        res.data?.records || res.data || [];

      if (!records || records.length === 0) {
        ElMessage.warning("没有可导出的数据");
        return;
      }

      // ==================== 3. 构建表头行 + 合并范围 ====================

      const headerKeys = Array.from(
        { length: totalCols },
        (_, i) => `col_${i}`,
      );

      // 第 1 行:平铺列显示标签,分组列显示父标签(子列位置留空)
      // 第 2 行:平铺列留空(会与第1行合并),分组列显示子标签
      const headerRow1: Record<string, string> = {};
      const headerRow2: Record<string, string> = {};

      for (let i = 0; i < totalCols; i++) {
        const leaf = leafColumns[i];
        if (leaf.isGrouped) {
          headerRow1[headerKeys[i]] = leaf.parentLabel;
          headerRow2[headerKeys[i]] = leaf.label;
        } else {
          headerRow1[headerKeys[i]] = leaf.label;
          headerRow2[headerKeys[i]] = "";
        }
      }

      // 合并范围
      const merges: string[] = [];

      // 遍历可见列定义,计算每列的起始位置
      let colIdx = 0;
      for (const def of visibleDefs) {
        if (!def.children || def.children.length === 0) {
          // 平铺列:纵向合并第1行和第2行
          const colLetter = encodeCol(colIdx);
          merges.push(`${colLetter}1:${colLetter}2`);
          colIdx += 1;
        } else {
          // 分组列:横向合并第1行的子列范围
          const startCol = colIdx;
          const endCol = colIdx + def.children.length - 1;
          if (endCol > startCol) {
            merges.push(`${encodeCol(startCol)}1:${encodeCol(endCol)}1`);
          }
          colIdx = endCol + 1;
        }
      }

      // ==================== 4. 构建数据行 + 收集异常坐标 ====================

      const dataRows: Record<string, any>[] = [];
      const abnormalPositions: string[] = [];

      for (let rowIdx = 0; rowIdx < records.length; rowIdx++) {
        const row = records[rowIdx];
        const dataRow: Record<string, any> = {};

        for (let colI = 0; colI < totalCols; colI++) {
          const leaf = leafColumns[colI];
          const rawValue = leaf.accessor(row);
          dataRow[headerKeys[colI]] = formatNumber(rawValue);

          // 异常单元格:行号 = rowIdx + 3(第1、2行为表头)
          if (leaf.isAbnormal && leaf.isAbnormal(row)) {
            const colLetter = encodeCol(colI);
            abnormalPositions.push(`${colLetter}${rowIdx + 3}`);
          }
        }

        dataRows.push(dataRow);
      }

      // ==================== 5. 导出 Excel ====================

      const exportUtil = new TableExportUtil();
      exportUtil.addJson([headerRow1, headerRow2, ...dataRows], headerKeys);
      exportUtil.setMerges(merges);

      // 异常单元格标记红色背景 + 深红字体
      if (abnormalPositions.length > 0) {
        exportUtil.setStyle((ws: any) => {
          abnormalPositions.forEach((pos) => {
            if (ws[pos]) {
              ws[pos].s = {
                ...ws[pos].s,
                fill: {
                  patternType: "solid",
                  fgColor: { rgb: "FFC7CE" },
                },
                font: {
                  ...(ws[pos].s?.font || {}),
                  color: { rgb: "9C0006" },
                },
              };
            }
          });
        });
      }

      exportUtil.export({
        name: options.exportFileName || "导出",
        autoWidth: true,
        border: true,
        skipRow: 2, // 跳过2行表头再计算列宽
      });

      ElMessage.success("导出成功");
    } catch (error) {
      console.error("导出失败:", error);
      ElMessage.error("导出失败");
    }
  }

  return { handleExport };
}

useTableExportComplex 使用指南

📋 功能概述

useTableExportComplex 是一个专门用于多级表头、嵌套数据表格导出的组合式函数(Composable)。它提供了以下核心能力:

  • 多级表头:自动生成两行表头(父级 + 子级),并设置合并单元格
  • 嵌套数据提取 :通过 accessor 函数从 row 中提取任意深度嵌套的值
  • 组合字段 :如 "xxx ~ yyy" 格式的公差列,通过 accessor 自行拼接
  • 条件样式:异常数据自动标记为红色背景 + 深红字体

🎯 适用场景

  • 多级表头表格(分组列 + 子列)
  • 嵌套数据结构(如 row.dimension.length
  • 需要组合显示的字段(如公差范围)
  • 需要标记异常数据的表格

📦 API 参数

typescript 复制代码
useTableExportComplex(options: {
  columnDefs: ExportColumnDef[];           // 必填:导出列定义(含多级结构)
  columnConfig: Ref<ColumnConfig[]>;       // 必填:列可见性配置
  loadFieldConfig: () => Promise<void>;    // 必填:刷新列配置的函数
  exportApi?: (params: ExportApiParams) => Promise<any>; // 可选:导出数据 API
  exportFileName?: string;                 // 可选:导出文件名
})

🔧 ExportColumnDef 类型定义

typescript 复制代码
interface ExportLeafDef {
  label: string; // 子列表头文字
  accessor: (row: any) => any; // 从 row 中提取值的函数
  isAbnormal?: (row: any) => boolean; // 判断该单元格是否异常
}

interface ExportColumnDef {
  prop: string; // 对应 ColumnConfig 的 prop
  label: string; // 表头文字(分组列为父级,平铺列直接显示)
  children?: ExportLeafDef[]; // 子列定义(有 children 表示分组列)
  accessor?: (row: any) => any; // 平铺列的数据提取函数
  isAbnormal?: (row: any) => boolean; // 平铺列的异常判断
}

💡 使用示例

1. 基础配置(分组列 + 平铺列)
typescript 复制代码
import { useTableExportComplex } from "@/composables/useTableExportComplex";
import type { ExportColumnDef } from "@/composables/useTableExportComplex";

// 导出列定义
const exportColumnDefs: ExportColumnDef[] = [
  // 分组列:基本信息
  {
    prop: "basic",
    label: "基本信息",
    children: [
      {
        label: "产品名称",
        accessor: (row) => row.productName,
      },
      {
        label: "批次号",
        accessor: (row) => row.batchNo,
      },
      {
        label: "材质",
        accessor: (row) => row.material,
      },
    ],
  },
  // 分组列:尺寸参数(带异常判断)
  {
    prop: "dimension",
    label: "尺寸参数",
    children: [
      {
        label: "长度(mm)",
        accessor: (row) => row.dimension.length,
        isAbnormal: (row) => Math.abs(row.dimension.length - 10000) > 10,
      },
      {
        label: "宽度(mm)",
        accessor: (row) => row.dimension.width,
        isAbnormal: (row) => Math.abs(row.dimension.width - 1500) > 6,
      },
    ],
  },
  // 平铺列:检测日期
  {
    prop: "inspectDate",
    label: "检测日期",
  },
  // 平铺列:检测状态
  {
    prop: "status",
    label: "检测状态",
  },
];

// 使用 composable
const { handleExport } = useTableExportComplex({
  columnDefs: exportColumnDefs,
  columnConfig, // 来自 useTableCommon 的 columnConfig
  loadFieldConfig, // 来自 useTableCommon 的 loadFieldConfig
  exportApi: async (params) => {
    const res = await api.getData(params);
    return res;
  },
  exportFileName: "产品检测报告",
});
2. 组合字段示例(公差范围)
typescript 复制代码
const exportColumnDefs: ExportColumnDef[] = [
  {
    prop: "dimension",
    label: "尺寸参数",
    children: [
      {
        label: "长度(mm)",
        // 组合显示:数值 ~ 公差
        accessor: (row) =>
          `${row.dimension.length} ~ ${row.dimension.lengthTolerance}`,
        isAbnormal: (row) => Math.abs(row.dimension.length - 10000) > 10,
      },
      {
        label: "宽度(mm)",
        accessor: (row) =>
          `${row.dimension.width} ~ ${row.dimension.widthTolerance}`,
        isAbnormal: (row) => Math.abs(row.dimension.width - 1500) > 6,
      },
    ],
  },
];
3. 嵌套数据提取示例
typescript 复制代码
const exportColumnDefs: ExportColumnDef[] = [
  {
    prop: "performance",
    label: "性能指标",
    children: [
      {
        label: "抗拉强度(MPa)",
        // 提取嵌套数据:row.performance.tensileStrength
        accessor: (row) => row.performance.tensileStrength,
        isAbnormal: (row) => row.performance.tensileStrength < 420,
      },
      {
        label: "屈服强度(MPa)",
        accessor: (row) => row.performance.yieldStrength,
        isAbnormal: (row) => row.performance.yieldStrength < 260,
      },
    ],
  },
];
4. 完整配置(与 useTableCommon 配合使用)
typescript 复制代码
import { useTableCommon } from "@/composables/useTableCommon";
import { useTableExportComplex } from "@/composables/useTableExportComplex";

// 1. 定义列管理配置(用于列可见性控制)
const topLevelColumns: ColumnConfig[] = [
  {
    prop: "index",
    label: "序号",
    show: true,
    fixed: true,
    sortable: false,
    required: true,
    order: 0,
    width: 60,
  },
  {
    prop: "basic",
    label: "基本信息",
    show: true,
    fixed: false,
    sortable: false,
    required: false,
    order: 1,
    minWidth: 370,
  },
  {
    prop: "dimension",
    label: "尺寸参数",
    show: true,
    fixed: false,
    sortable: false,
    required: false,
    order: 2,
    minWidth: 360,
  },
];

// 2. 使用 useTableCommon 获取列管理功能
const {
  isFullscreen,
  tableInfoRef,
  columnConfig,
  defaultColumnConfig,
  isColVisible,
  handleFullScreen,
  handleTableSetting,
  handleColumnConfigConfirm,
  setupFullscreenListeners,
  removeFullscreenListeners,
  loadFieldConfig,
} = useTableCommon({
  configKey: "product-inspection",
  topLevelColumns,
  getFieldConfig: async (key) => {
    const res = await api.getFieldConfig(key);
    return res.data;
  },
  saveFieldConfig: async (params) => {
    await api.saveFieldConfig(params);
    return { success: true };
  },
});

// 3. 定义导出列配置
const exportColumnDefs: ExportColumnDef[] = [
  {
    prop: "basic",
    label: "基本信息",
    children: [
      { label: "产品名称", accessor: (row) => row.productName },
      { label: "批次号", accessor: (row) => row.batchNo },
      { label: "材质", accessor: (row) => row.material },
    ],
  },
  {
    prop: "dimension",
    label: "尺寸参数",
    children: [
      {
        label: "长度(mm)",
        accessor: (row) => row.dimension.length,
        isAbnormal: (row) => Math.abs(row.dimension.length - 10000) > 10,
      },
    ],
  },
];

// 4. 使用 useTableExportComplex 获取导出功能
const { handleExport } = useTableExportComplex({
  columnDefs: exportColumnDefs,
  columnConfig, // 来自 useTableCommon
  loadFieldConfig, // 来自 useTableCommon
  exportApi: async (params) => {
    const res = await api.getData(params);
    return res;
  },
  exportFileName: "产品检测报告",
});
5. 模板中使用
vue 复制代码
<template>
  <div ref="tableInfoRef" class="table-container">
    <!-- 工具栏 -->
    <div class="toolbar">
      <el-button @click="handleTableSetting">管理显示字段</el-button>
      <el-button @click="handleFullScreen">
        {{ isFullscreen ? "退出全屏" : "全屏展示" }}
      </el-button>
      <el-button type="primary" @click="handleExport">导出 Excel</el-button>
    </div>

    <!-- 表格 -->
    <el-table
      :key="refreshKey"
      :data="tableData"
      :max-height="tableMaxHeight"
      border
    >
      <el-table-column
        prop="index"
        label="序号"
        width="60"
        fixed="left"
        align="center"
      />

      <!-- 基本信息分组列 -->
      <el-table-column
        prop="basic"
        label="基本信息"
        :visible="isColVisible('basic')"
        align="center"
      >
        <el-table-column
          prop="productName"
          label="产品名称"
          min-width="150"
          align="center"
        />
        <el-table-column
          prop="batchNo"
          label="批次号"
          min-width="120"
          align="center"
        />
        <el-table-column
          prop="material"
          label="材质"
          min-width="100"
          align="center"
        />
      </el-table-column>

      <!-- 尺寸参数分组列 -->
      <el-table-column
        prop="dimension"
        label="尺寸参数"
        :visible="isColVisible('dimension')"
        align="center"
      >
        <el-table-column
          prop="length"
          label="长度(mm)"
          min-width="120"
          align="center"
        >
          <template #default="{ row }">
            <span :class="{ abnormal: isLengthAbnormal(row) }">
              {{ formatNumber(row.dimension.length) }}
            </span>
          </template>
        </el-table-column>
      </el-table-column>
    </el-table>

    <!-- 列管理弹窗 -->
    <TableColumnManager
      v-model:visible="columnManagerVisible"
      :columns="columnConfig"
      :default-columns="defaultColumnConfig"
      @confirm="handleColumnConfigConfirm"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";

// 异常判断函数(与导出逻辑一致)
const isLengthAbnormal = (row: any): boolean => {
  return Math.abs(row.dimension.length - 10000) > 10;
};

// 格式化数字
const formatNumber = (val: number): string => {
  if (val == null) return "";
  return val.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
};

// 组件挂载
onMounted(() => {
  setupFullscreenListeners();
  loadFieldConfig();
  // 加载数据...
});

// 组件卸载
onUnmounted(() => {
  removeFullscreenListeners();
});
</script>

<style lang="scss" scoped>
.abnormal {
  color: #9c0006;
  background-color: #ffc7ce;
  padding: 2px 6px;
  border-radius: 4px;
}
</style>

⚠️ 注意事项

1. 与 useTableCommon 配合使用

useTableExportComplex 需要依赖 useTableCommon 提供的 columnConfigloadFieldConfig,因此必须先使用 useTableCommon

typescript 复制代码
// ✅ 正确顺序
const { columnConfig, loadFieldConfig } = useTableCommon({...})

const { handleExport } = useTableExportComplex({
  columnConfig,
  loadFieldConfig,
  ...
})
2. prop 字段必须匹配

ExportColumnDefprop 字段必须与 ColumnConfigprop 字段一致,用于匹配列可见性:

typescript 复制代码
// ColumnConfig(列管理)
const topLevelColumns: ColumnConfig[] = [
  { prop: 'basic', label: '基本信息', ... },
  { prop: 'dimension', label: '尺寸参数', ... }
]

// ExportColumnDef(导出)
const exportColumnDefs: ExportColumnDef[] = [
  { prop: 'basic', label: '基本信息', children: [...] },  // ✅ prop 一致
  { prop: 'dimension', label: '尺寸参数', children: [...] } // ✅ prop 一致
]
3. 异常判断函数必须返回布尔值

isAbnormal 函数必须返回 boolean 类型:

typescript 复制代码
{
  label: '长度(mm)',
  accessor: (row) => row.dimension.length,
  isAbnormal: (row) => Math.abs(row.dimension.length - 10000) > 10  // ✅ 返回 boolean
}
4. 导出 API 规范

导出 API 必须支持 pageSize: -1 参数,表示查询全部数据:

typescript 复制代码
exportApi: async (params) => {
  // params: { pageNo: 1, pageSize: -1 }
  const res = await api.getData(params);
  return res;
};

返回数据格式支持两种:

  • res.data.records(推荐)
  • res.data
5. 数字格式化

useTableExportComplex 会自动对数字进行格式化(保留两位小数),无需手动处理:

typescript 复制代码
// 原始数据
row.dimension.length = 10005.678;

// 导出时自动格式化为
10005.68;
6. 变量声明顺序

tableMaxHeight 必须在 useTableCommon 解构之后定义:

typescript 复制代码
// ✅ 正确
const { isFullscreen, windowHeight } = useTableCommon({...})

const tableMaxHeight = computed(() => {
  return isFullscreen.value ? `${windowHeight.value - 150}px` : '500px'
})

🎨 样式建议

异常单元格样式
scss 复制代码
.abnormal {
  color: #9c0006;
  background-color: #ffc7ce;
  padding: 2px 6px;
  border-radius: 4px;
}
表格容器样式
scss 复制代码
.table-container {
  height: 100%;
  display: flex;
  flex-direction: column;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  transition: all 0.3s ease;

  &.fullscreen {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 9999;
    border-radius: 0;
    margin: 0;
  }
}

🔗 与 useTableCommon 的区别

特性 useTableCommon useTableExportComplex
表头类型 单级表头 多级表头(分组列 + 子列)
数据结构 扁平数据 支持嵌套数据
字段提取 直接通过 prop 通过 accessor 函数
条件样式 自定义 exportFieldStyles 内置 isAbnormal 标记
导出功能 内置 专用于导出
列管理 内置 依赖 useTableCommon

📚 完整示例

参考 HeadersExport.vue 文件查看完整的使用示例。


更新日期 :2026-06-05

版本:1.0.0

相关推荐
超人不会飞_Jay1 小时前
26.6.3Vue笔记
前端·vue.js·笔记
御坂100272 小时前
Vue - @change应用实现下拉框联动功能
前端·javascript·vue.js
瘦瘦瘦大人2 小时前
Vue 项目实现关闭/刷新浏览器窗口前的离开确认提示
前端·javascript·vue.js
belong_my_offer2 小时前
认识前端路由& VSCode 实操
vue.js
吃阿茶搽2 小时前
大模型RAG实战,从被骂不靠谱到成为部门MVP,我的踩坑全记录
vue.js
布兰妮甜3 小时前
Vue 视图不更新?常见赋值踩坑点汇总
前端·javascript·vue.js·vue踩坑·vue视图不更新
rising start3 小时前
三、Vue3 模板语法
vue.js
zhedream4 小时前
十万级列表的跨页多选方案:el-table 踩坑与治理实践
vue.js·element
rising start4 小时前
二、Vue3 核心基础:API 对比、Setup 与响应式详解
前端·javascript·vue.js