支持tab切换、父子表头、拖拽复制列、下方表格列排序

效果图

上方表格(数据源区)

功能

tab切换点击底部标签(1月/2月/3月),表格表头和数据完全独立切换
父子表头支持多级表头(如“综合评估”父级 + “综合评分”“风险等级”子级)
拖拽复制列鼠标按住任意表头(单列/子列/父级组),拖拽到下方“数据仓库”区域,即可复制整列(整组)数据

下方表格(数据仓库区)

功能

接收拖拽列接收从上方拖拽过来的列,自动添加为表格的一列(或一组父子列)
列显示列名会带上月份后缀,例如 综合评分(1月),不同月份的相同字段独立显示
列内数据自动从对应月份的数据源中提取该列所有行的数据,按行对齐
删除列每列头部有一个 ✖ 删除按钮,点击可移除该列及对应数据
列左右排序关键功能:鼠标按住下方表格的列头(“综合评分(1月)”这个标题区域),可以左右拖拽调整整列的顺序(包括普通列和分组列)
编辑/删除行每行末尾有编辑/删除按钮,可操作行数据

html写法(可直接看到效果)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>父子表头|拖拽表头|下方可左右排序</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-plus/dist/index.css">
  <script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/element-plus/dist/index.full.js"></script>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { padding: 20px; background: #f5f7fa; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
    .excel-box { background: #fff; margin-bottom: 20px; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
    .el-table th, .el-table td { text-align: center !important; }
    .tab-bar { display: flex; padding: 12px 16px; background: #f5f7fa; border-top: 1px solid #e4e7ed; }
    .tab-scroll { display: flex; gap: 8px; overflow-x: auto; flex-wrap: wrap; }
    .tab-item { padding: 6px 16px; background: #e4e7ed; border-radius: 20px; cursor: pointer; font-size: 14px; transition: all 0.3s; }
    .tab-item:hover { background: #d0d4dc; }
    .tab-item.active { background: #409eff; color: #fff; font-weight: bold; }
    .draggable-header { cursor: grab; user-select: none; display: inline-block; }
    .draggable-header:active { cursor: grabbing; }
    .drag-over { border: 2px dashed #409eff; background: #ecf5ff; border-radius: 4px; }
    .delete-btn { margin-left: 8px; color: #f56c6c; cursor: pointer; font-size: 12px; }
    .delete-btn:hover { color: #f00; }

    /* 下方列排序样式 */
    .sortable-header {
      cursor: move;
      user-select: none;
      display: inline-block;
      width: 100%;
    }
    .sortable-over {
      background: #e8f3ff !important;
      border-left: 3px solid #409eff !important;
    }
  </style>
</head>
<body>
<div id="app">
  <!-- 上方表格 -->
  <div class="excel-box">
    <el-table :data="currentTableData" border>
      <el-table-column type="index" width="50" fixed label="序号"></el-table-column>

      <!-- 普通单列 -->
      <el-table-column v-for="(col, idx) in currentSingleColumns" :key="'single'+idx" :prop="col.prop" :label="col.label" :width="col.width">
        <template #header>
          <span class="draggable-header"
                :data-type="'single'"
                :data-prop="col.prop"
                :data-label="col.label"
                :data-width="col.width"
                :data-month="currentMonth"
                draggable="true"
                @dragstart="handleDragStart($event, col, currentMonth)"
                @dragend="handleDragEnd($event)">{{ col.label }}</span>
        </template>
        <template #default="{ row }">
          {{ row[col.prop] ?? '—' }}
        </template>
      </el-table-column>

      <!-- 父级表头(多级结构) -->
      <el-table-column v-for="(group, gidx) in currentGroupColumns" :key="'group'+gidx" :label="group.label">
        <template #header>
          <span class="draggable-header"
                :data-type="'group'"
                :data-group-index="gidx"
                :data-month="currentMonth"
                draggable="true"
                @dragstart="handleGroupDragStart($event, group, currentMonth)"
                @dragend="handleDragEnd($event)">{{ group.label }}</span>
        </template>
        <el-table-column v-for="(child, cidx) in group.children" :key="cidx" :prop="child.prop" :label="child.label" :width="child.width">
          <template #header>
            <span class="draggable-header"
                  :data-type="'child'"
                  :data-prop="child.prop"
                  :data-label="child.label"
                  :data-width="child.width"
                  :data-month="currentMonth"
                  draggable="true"
                  @dragstart="handleDragStart($event, child, currentMonth)"
                  @dragend="handleDragEnd($event)">{{ child.label }}</span>
          </template>
          <template #default="{ row }">
            {{ row[child.prop] ?? '—' }}
          </template>
        </el-table-column>
      </el-table-column>
    </el-table>

    <div class="tab-bar">
      <div class="tab-scroll">
        <div class="tab-item" :class="{active:currentMonth === item.value}" @click="switchMonth(item.value)" v-for="item in monthList">
          {{ item.label }}
        </div>
      </div>
    </div>
  </div>

  <!-- 下方表格 -->
  <div class="excel-box" @dragover.prevent @drop="handleDrop">
    <el-table :data="bottomData" border>
      <el-table-column type="index" width="50" fixed label="序号"></el-table-column>
      
      <template v-for="(col, idx) in bottomCols" :key="idx">
        <!-- 分组列 -->
        <el-table-column v-if="col.type === 'group'" :label="col.displayLabel" :key="idx">
          <template #header>
            <div class="sortable-header"
                 draggable="true"
                 @dragstart="onSortStart($event, idx)"
                 @dragover.prevent="onSortOver($event)"
                 @dragenter="onSortEnter($event)"
                 @dragleave="onSortLeave($event)"
                 @drop.prevent="onSortDrop($event, idx)">
              {{ col.displayLabel }}
              <span class="delete-btn" @click="removeColumn(idx)">✖</span>
            </div>
          </template>
          <el-table-column v-for="(child, cidx) in col.children" :key="cidx" :prop="child.fieldKey" :label="child.displayLabel" :width="child.width">
            <template #default="{ row }">{{ row[child.fieldKey] ?? '—' }}</template>
          </el-table-column>
        </el-table-column>

        <!-- 普通列 -->
        <el-table-column v-else :prop="col.fieldKey" :label="col.displayLabel" :width="col.width" :key="idx">
          <template #header>
            <div class="sortable-header"
                 draggable="true"
                 @dragstart="onSortStart($event, idx)"
                 @dragover.prevent="onSortOver($event)"
                 @dragenter="onSortEnter($event)"
                 @dragleave="onSortLeave($event)"
                 @drop.prevent="onSortDrop($event, idx)">
              {{ col.displayLabel }}
              <span class="delete-btn" @click="removeColumn(idx)">✖</span>
            </div>
          </template>
          <template #default="{ row }">{{ row[col.fieldKey] ?? '—' }}</template>
        </el-table-column>
      </template>

      <el-table-column label="操作" width="100" fixed="right">
        <template #default="{ row, $index }">
          <el-button link type="primary" size="small" @click="editRow($index)">编辑</el-button>
          <el-button link type="danger" size="small" @click="deleteRow($index)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</div>

<script>
  const { createApp, ref, computed } = Vue

  // 各月份的表头配置
  const monthColumnsMap = {
    '1月': {
      single: [
        { prop: 'name', label: '项目名称', width: 160 },
        { prop: 'dept', label: '负责部门', width: 140 }
      ],
      groups: [
        {
          label: '综合评估',
          children: [
            { prop: 'score', label: '综合评分', width: 120 },
            { prop: 'risk', label: '风险等级', width: 100 }
          ]
        }
      ]
    },
    '2月': {
      single: [
        { prop: 'product', label: '产品名称', width: 160 },
        { prop: 'sales', label: '销售额(万)', width: 130 }
      ],
      groups: [
        {
          label: '综合评估',
          children: [
            { prop: 'score', label: '综合评分', width: 120 },
            { prop: 'risk', label: '风险等级', width: 100 }
          ]
        }
      ]
    },
    '3月': {
      single: [
        { prop: 'task', label: '任务名称', width: 160 },
        { prop: 'assignee', label: '执行人', width: 120 }
      ],
      groups: [
        {
          label: '综合评估',
          children: [
            { prop: 'score', label: '综合评分', width: 120 },
            { prop: 'risk', label: '风险等级', width: 100 }
          ]
        }
      ]
    }
  }

  const monthDataMap = {
    '1月': [
      { name: '智慧城市平台', dept: '研发中心', score: 88, risk: '低' },
      { name: '数据中台建设', dept: '技术部', score: 92, risk: '中' },
      { name: '安全防护系统', dept: '安全部', score: 78, risk: '高' },
      { name: '用户画像分析', dept: '产品部', score: 85, risk: '低' }
    ],
    '2月': [
      { product: '智能手表Pro', sales: 320, score: 90, risk: '低' },
      { product: '无线耳机X3', sales: 210, score: 82, risk: '中' }
    ],
    '3月': [
      { task: '系统架构升级', assignee: '陈工', score: 95, risk: '低' },
      { task: '数据库迁移', assignee: '刘工', score: 88, risk: '中' },
      { task: '接口文档编写', assignee: '小林', score: 75, risk: '高' },
      { task: '性能测试优化', assignee: '测试组', score: 92, risk: '低' }
    ]
  }

  createApp({
    setup() {
      const monthList = ref([
        { label: '📊 1月 - 项目数据', value: '1月' },
        { label: '💰 2月 - 销售数据', value: '2月' },
        { label: '✅ 3月 - 任务数据', value: '3月' }
      ])
      const currentMonth = ref('1月')

      const currentSingleColumns = computed(() => monthColumnsMap[currentMonth.value]?.single || [])
      const currentGroupColumns = computed(() => monthColumnsMap[currentMonth.value]?.groups || [])
      const currentTableData = computed(() => monthDataMap[currentMonth.value] || [])

      const bottomCols = ref([])
      const bottomData = ref([])
      let sortIndex = -1

      // 重建数据
      function rebuildBottomData() {
        let maxRows = 0
        bottomCols.value.forEach(col => {
          if (col.type === 'group') {
            col.children.forEach(child => {
              const len = (monthDataMap[col.month] || []).length
              if (len > maxRows) maxRows = len
            })
          } else {
            const len = (monthDataMap[col.month] || []).length
            if (len > maxRows) maxRows = len
          }
        })
        bottomData.value = Array.from({ length: maxRows }, () => ({}))
        bottomCols.value.forEach(col => {
          if (col.type === 'group') {
            const src = monthDataMap[col.month] || []
            col.children.forEach(child => {
              bottomData.value.forEach((row, i) => {
                row[child.fieldKey] = src[i]?.[child.prop] ?? ''
              })
            })
          } else {
            const src = monthDataMap[col.month] || []
            bottomData.value.forEach((row, i) => {
              row[col.fieldKey] = src[i]?.[col.prop] ?? ''
            })
          }
        })
      }

      // 删除列
      function removeColumn(index) {
        bottomCols.value.splice(index, 1)
        rebuildBottomData()
      }

      // 切换月份
      function switchMonth(month) {
        currentMonth.value = month
      }

      // 拖拽开始
      function handleDragStart(event, col, month) {
        const data = { type: 'single', prop: col.prop, label: col.label, width: col.width, month }
        event.dataTransfer.setData('text', JSON.stringify(data))
        event.target.style.opacity = '0.5'
      }
      function handleGroupDragStart(event, group, month) {
        const data = { type: 'group', label: group.label, children: group.children, month }
        event.dataTransfer.setData('text', JSON.stringify(data))
        event.target.style.opacity = '0.5'
      }
      function handleDragEnd(event) {
        event.target.style.opacity = '1'
      }

      // 放置到下方
      function handleDrop(event) {
        const raw = event.dataTransfer.getData('text')
        if (!raw) return
        const info = JSON.parse(raw)

        if (info.type === 'group') {
          const key = `${info.label}_${info.month}`
          if (bottomCols.value.some(x => x.fieldKey === key)) return
          bottomCols.value.push({
            type: 'group',
            label: info.label,
            month: info.month,
            fieldKey: key,
            displayLabel: `${info.label}(${info.month})`,
            children: info.children.map(c => ({
              ...c, fieldKey: `${c.prop}_${info.month}`, displayLabel: `${c.label}(${info.month})`
            }))
          })
          rebuildBottomData()
          return
        }

        const key = `${info.prop}_${info.month}`
        if (bottomCols.value.some(x => x.fieldKey === key)) return
        bottomCols.value.push({
          type: 'single',
          prop: info.prop, label: info.label, width: info.width, month: info.month,
          fieldKey: key, displayLabel: `${info.label}(${info.month})`
        })
        rebuildBottomData()
      }

      // ==========================================
      // 下方列 左右拖拽排序核心方法
      // ==========================================
      function onSortStart(e, idx) {
        sortIndex = idx
        e.dataTransfer.effectAllowed = 'move'
      }
      function onSortOver(e) {
        e.preventDefault()
      }
      function onSortEnter(e) {
        e.currentTarget.classList.add('sortable-over')
      }
      function onSortLeave(e) {
        e.currentTarget.classList.remove('sortable-over')
      }
      function onSortDrop(e, targetIndex) {
        e.currentTarget.classList.remove('sortable-over')
        if (sortIndex === targetIndex) return
        const list = [...bottomCols.value]
        const moved = list.splice(sortIndex, 1)
        list.splice(targetIndex, 0, moved[0])
        bottomCols.value = list
        rebuildBottomData()
      }

      function editRow(i) { alert('编辑:' + JSON.stringify(bottomData.value[i])) }
      function deleteRow(i) { bottomData.value.splice(i, 1) }

      return {
        monthList, currentMonth, currentSingleColumns, currentGroupColumns, currentTableData,
        bottomCols, bottomData, switchMonth, removeColumn, editRow, deleteRow,
        handleDragStart, handleGroupDragStart, handleDragEnd, handleDrop,
        onSortStart, onSortOver, onSortEnter, onSortLeave, onSortDrop
      }
    }
  }).use(ElementPlus).mount('#app')
</script>
</body>
</html>

vue3写法

<template>
  <div class="excel-dashboard">
    <!-- 上方表格 -->
    <div class="excel-box">
      <el-table :data="currentTableData" border>
        <el-table-column type="index" width="50" fixed label="序号" />

        <!-- 普通单列 -->
        <el-table-column
          v-for="(col, idx) in currentSingleColumns"
          :key="'single' + idx"
          :prop="col.prop"
          :label="col.label"
          :width="col.width"
        >
          <template #header>
            <span
              class="draggable-header"
              :data-type="'single'"
              :data-prop="col.prop"
              :data-label="col.label"
              :data-width="col.width"
              :data-month="currentMonth"
              draggable="true"
              @dragstart="handleDragStart($event, col, currentMonth)"
              @dragend="handleDragEnd"
            >{{ col.label }}</span>
          </template>
          <template #default="{ row }">
            {{ row[col.prop] || '—' }}
          </template>
        </el-table-column>

        <!-- 父级表头(多级结构) -->
        <el-table-column
          v-for="(group, gidx) in currentGroupColumns"
          :key="'group' + gidx"
          :label="group.label"
        >
          <template #header>
            <span
              class="draggable-header"
              :data-type="'group'"
              :data-group-index="gidx"
              :data-month="currentMonth"
              draggable="true"
              @dragstart="handleGroupDragStart($event, group, currentMonth)"
              @dragend="handleDragEnd"
            >{{ group.label }}</span>
          </template>
          <el-table-column
            v-for="(child, cidx) in group.children"
            :key="cidx"
            :prop="child.prop"
            :label="child.label"
            :width="child.width"
          >
            <template #header>
              <span
                class="draggable-header"
                :data-type="'child'"
                :data-prop="child.prop"
                :data-label="child.label"
                :data-width="child.width"
                :data-month="currentMonth"
                draggable="true"
                @dragstart="handleDragStart($event, child, currentMonth)"
                @dragend="handleDragEnd"
              >{{ child.label }}</span>
            </template>
            <template #default="{ row }">
              {{ row[child.prop] || '—' }}
            </template>
          </el-table-column>
        </el-table-column>
      </el-table>

      <div class="tab-bar">
        <div class="tab-scroll">
          <div
            v-for="item in monthList"
            :key="item.value"
            class="tab-item"
            :class="{ active: currentMonth === item.value }"
            @click="switchMonth(item.value)"
          >
            {{ item.label }}
          </div>
        </div>
      </div>
    </div>

    <!-- 下方表格 -->
    <div class="excel-box" @dragover.prevent @drop="handleTableDrop">
      <el-table :data="bottomData" border>
        <el-table-column type="index" width="50" fixed label="序号" />

        <!-- 所有列统一按 bottomCols 顺序渲染 -->
        <template v-for="col in bottomCols" :key="col.fieldKey">
          <!-- 分组列(父级) -->
          <el-table-column
            v-if="col.type === 'group'"
            :label="col.displayLabel"
          >
            <template #header>
              <div
                class="sortable-header"
                draggable="true"
                @dragstart="onSortStart($event, getColIndex(col.fieldKey))"
                @dragover.prevent="onSortOver"
                @dragenter="onSortEnter"
                @dragleave="onSortLeave"
                @drop.prevent="onSortDrop($event, getColIndex(col.fieldKey))"
              >
                {{ col.displayLabel }}
                <span class="delete-btn" @click="removeColumn(getColIndex(col.fieldKey))">✖</span>
              </div>
            </template>
            <el-table-column
              v-for="child in col.children"
              :key="child.fieldKey"
              :prop="child.fieldKey"
              :label="child.displayLabel"
              :width="child.width"
            >
              <template #default="{ row }">
                {{ row[child.fieldKey] || '—' }}
              </template>
            </el-table-column>
          </el-table-column>

          <!-- 普通单列 -->
          <el-table-column
            v-else
            :prop="col.fieldKey"
            :label="col.displayLabel"
            :width="col.width"
          >
            <template #header>
              <div
                class="sortable-header"
                draggable="true"
                @dragstart="onSortStart($event, getColIndex(col.fieldKey))"
                @dragover.prevent="onSortOver"
                @dragenter="onSortEnter"
                @dragleave="onSortLeave"
                @drop.prevent="onSortDrop($event, getColIndex(col.fieldKey))"
              >
                {{ col.displayLabel }}
                <span class="delete-btn" @click="removeColumn(getColIndex(col.fieldKey))">✖</span>
              </div>
            </template>
            <template #default="{ row }">
              {{ row[col.fieldKey] || '—' }}
            </template>
          </el-table-column>
        </template>

        <el-table-column label="操作" width="100" fixed="right">
          <template #default="{ row, $index }">
            <el-button link type="primary" size="small" @click="editRow($index)">编辑</el-button>
            <el-button link type="danger" size="small" @click="deleteRow($index)">删除</el-button>
          </template>
        </el-table-column>
      </el-table>
    </div>
  </div>
</template>

<script>
export default {
  name: "ExcelDashboard",
  
  data() {
    return {
      monthList: [
        { label: '📊 1月 - 项目数据', value: '1月' },
        { label: '💰 2月 - 销售数据', value: '2月' },
        { label: '✅ 3月 - 任务数据', value: '3月' }
      ],
      currentMonth: '1月',
      
      monthColumnsMap: {
        '1月': {
          single: [
            { prop: 'name', label: '项目名称', width: 160 },
            { prop: 'dept', label: '负责部门', width: 140 }
          ],
          groups: [
            {
              label: '综合评估',
              children: [
                { prop: 'score', label: '综合评分', width: 120 },
                { prop: 'risk', label: '风险等级', width: 100 }
              ]
            }
          ]
        },
        '2月': {
          single: [
            { prop: 'product', label: '产品名称', width: 160 },
            { prop: 'sales', label: '销售额(万)', width: 130 }
          ],
          groups: [
            {
              label: '综合评估',
              children: [
                { prop: 'score', label: '综合评分', width: 120 },
                { prop: 'risk', label: '风险等级', width: 100 }
              ]
            }
          ]
        },
        '3月': {
          single: [
            { prop: 'task', label: '任务名称', width: 160 },
            { prop: 'assignee', label: '执行人', width: 120 }
          ],
          groups: [
            {
              label: '综合评估',
              children: [
                { prop: 'score', label: '综合评分', width: 120 },
                { prop: 'risk', label: '风险等级', width: 100 }
              ]
            }
          ]
        }
      },

      monthDataMap: {
        '1月': [
          { name: '智慧城市平台', dept: '研发中心', score: 88, risk: '低' },
          { name: '数据中台建设', dept: '技术部', score: 92, risk: '中' },
          { name: '安全防护系统', dept: '安全部', score: 78, risk: '高' },
          { name: '用户画像分析', dept: '产品部', score: 85, risk: '低' }
        ],
        '2月': [
          { product: '智能手表Pro', sales: 320, score: 90, risk: '低' },
          { product: '无线耳机X3', sales: 210, score: 82, risk: '中' }
        ],
        '3月': [
          { task: '系统架构升级', assignee: '陈工', score: 95, risk: '低' },
          { task: '数据库迁移', assignee: '刘工', score: 88, risk: '中' },
          { task: '接口文档编写', assignee: '小林', score: 75, risk: '高' },
          { task: '性能测试优化', assignee: '测试组', score: 92, risk: '低' }
        ]
      },

      // 存储所有列(按添加顺序)
      bottomCols: [],
      bottomData: [],
      sortIndex: null
    }
  },

  computed: {
    currentSingleColumns() {
      const monthData = this.monthColumnsMap[this.currentMonth]
      return monthData ? monthData.single : []
    },
    currentGroupColumns() {
      const monthData = this.monthColumnsMap[this.currentMonth]
      return monthData ? monthData.groups : []
    },
    currentTableData() {
      return this.monthDataMap[this.currentMonth] || []
    }
  },

  methods: {
    getColIndex(fieldKey) {
      return this.bottomCols.findIndex(col => col.fieldKey === fieldKey)
    },

    switchMonth(month) {
      this.currentMonth = month
    },

    // 重建下方数据(按 bottomCols 顺序)
    rebuildBottomData() {
      let maxRows = 0
      this.bottomCols.forEach(col => {
        const src = this.monthDataMap[col.month] || []
        if (src.length > maxRows) maxRows = src.length
      })
      
      if (maxRows === 0) {
        this.bottomData = []
        return
      }
      
      const rows = Array(maxRows).fill().map(() => ({}))
      
      // 按 bottomCols 顺序填充数据
      this.bottomCols.forEach(col => {
        const src = this.monthDataMap[col.month] || []
        if (col.type === 'group') {
          col.children.forEach(child => {
            for (let i = 0; i < maxRows; i++) {
              rows[i][child.fieldKey] = src[i]?.[child.prop] || ''
            }
          })
        } else {
          for (let i = 0; i < maxRows; i++) {
            rows[i][col.fieldKey] = src[i]?.[col.prop] || ''
          }
        }
      })
      this.bottomData = rows
    },

    // 删除列
    removeColumn(index) {
      this.bottomCols.splice(index, 1)
      this.rebuildBottomData()
    },

    editRow(idx) {
      alert('编辑:' + JSON.stringify(this.bottomData[idx]))
    },

    deleteRow(idx) {
      this.bottomData.splice(idx, 1)
    },

    // 拖拽开始
    handleDragStart(event, col, month) {
      const data = { type: 'single', prop: col.prop, label: col.label, width: col.width, month }
      event.dataTransfer.setData('text/plain', JSON.stringify(data))
      event.dataTransfer.effectAllowed = 'copy'
      event.target.style.opacity = '0.5'
    },

    handleGroupDragStart(event, group, month) {
      const data = { type: 'group', label: group.label, children: group.children, month }
      event.dataTransfer.setData('text/plain', JSON.stringify(data))
      event.dataTransfer.effectAllowed = 'copy'
      event.target.style.opacity = '0.5'
    },

    handleDragEnd(event) {
      event.target.style.opacity = '1'
    },

    // 放置到下方(按添加顺序追加)
    handleTableDrop(event) {
      event.preventDefault()
      const raw = event.dataTransfer.getData('text/plain')
      if (!raw) return
      
      let info
      try {
        info = JSON.parse(raw)
      } catch(e) {
        return
      }

      // 普通单列
      if (info.type === 'single') {
        const key = `${info.prop}_${info.month}`
        // 检查是否已存在
        if (this.bottomCols.some(x => x.fieldKey === key)) return
        // 追加到末尾(按添加顺序)
        this.bottomCols.push({
          type: 'single',
          prop: info.prop,
          label: info.label,
          width: info.width,
          month: info.month,
          fieldKey: key,
          displayLabel: `${info.label}(${info.month})`
        })
        this.rebuildBottomData()
        return
      }

      // 分组列(父级+子级)
      if (info.type === 'group') {
        const groupKey = `${info.label}_${info.month}`
        // 检查是否已存在
        if (this.bottomCols.some(x => x.fieldKey === groupKey)) return
        // 追加到末尾(按添加顺序)
        this.bottomCols.push({
          type: 'group',
          label: info.label,
          month: info.month,
          fieldKey: groupKey,
          displayLabel: `${info.label}(${info.month})`,
          children: info.children.map(c => ({
            ...c,
            fieldKey: `${c.prop}_${info.month}`,
            displayLabel: `${c.label}(${info.month})`
          }))
        })
        this.rebuildBottomData()
        return
      }
    },

    // 下方列拖拽排序(手动调整顺序后重新渲染)
    onSortStart(e, idx) {
      this.sortIndex = idx
      e.dataTransfer.effectAllowed = 'move'
      e.dataTransfer.setData('text/plain', idx)
    },

    onSortOver(e) {
      e.preventDefault()
      e.dataTransfer.dropEffect = 'move'
    },

    onSortEnter(e) {
      e.currentTarget.classList.add('sortable-over')
    },

    onSortLeave(e) {
      e.currentTarget.classList.remove('sortable-over')
    },

    onSortDrop(e, targetIndex) {
      e.preventDefault()
      e.currentTarget.classList.remove('sortable-over')
      if (this.sortIndex === null || this.sortIndex === targetIndex) return
      
      // 交换顺序
      const moved = this.bottomCols[this.sortIndex]
      this.bottomCols.splice(this.sortIndex, 1)
      this.bottomCols.splice(targetIndex, 0, moved)
      
      // 重新渲染
      this.rebuildBottomData()
      this.sortIndex = null
    }
  },

  mounted() {
    this.rebuildBottomData()
  }
}
</script>

<style scoped>
.excel-dashboard {
  padding: 20px;
  background: #f5f7fa;
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

.excel-box {
  background: #fff;
  margin-bottom: 20px;
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.el-table th,
.el-table td {
  text-align: center !important;
}

.tab-bar {
  display: flex;
  padding: 12px 16px;
  background: #f5f7fa;
  border-top: 1px solid #e4e7ed;
}

.tab-scroll {
  display: flex;
  gap: 8px;
  overflow-x: auto;
  flex-wrap: wrap;
}

.tab-item {
  padding: 6px 16px;
  background: #e4e7ed;
  border-radius: 20px;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.3s;
}

.tab-item:hover {
  background: #d0d4dc;
}

.tab-item.active {
  background: #409eff;
  color: #fff;
  font-weight: bold;
}

.draggable-header {
  cursor: grab;
  user-select: none;
  display: inline-block;
}

.draggable-header:active {
  cursor: grabbing;
}

.delete-btn {
  margin-left: 8px;
  color: #f56c6c;
  cursor: pointer;
  font-size: 12px;
}

.delete-btn:hover {
  color: #f00;
}

.sortable-header {
  cursor: move;
  user-select: none;
  display: inline-block;
  width: 100%;
}

.sortable-over {
  background: #e8f3ff !important;
  border-left: 3px solid #409eff !important;
}
</style>

vue2写法

<template>
  <div class="excel-dashboard">
    <!-- 上方表格 -->
    <div class="excel-box">
      <el-table :data="currentTableData" border>
        <el-table-column type="index" width="50" fixed label="序号" />

        <!-- 普通单列 -->
        <el-table-column
          v-for="(col, idx) in currentSingleColumns"
          :key="'single' + idx"
          :prop="col.prop"
          :label="col.label"
          :width="col.width"
        >
          <template slot="header">
            <span
              class="draggable-header"
              :data-type="'single'"
              :data-prop="col.prop"
              :data-label="col.label"
              :data-width="col.width"
              :data-month="currentMonth"
              draggable="true"
              @dragstart="handleDragStart($event, col, currentMonth)"
              @dragend="handleDragEnd"
            >{{ col.label }}</span>
          </template>
          <template slot-scope="{ row }">
            {{ row[col.prop] || '—' }}
          </template>
        </el-table-column>

        <!-- 父级表头(多级结构) -->
        <el-table-column
          v-for="(group, gidx) in currentGroupColumns"
          :key="'group' + gidx"
          :label="group.label"
        >
          <template slot="header">
            <span
              class="draggable-header"
              :data-type="'group'"
              :data-group-index="gidx"
              :data-month="currentMonth"
              draggable="true"
              @dragstart="handleGroupDragStart($event, group, currentMonth)"
              @dragend="handleDragEnd"
            >{{ group.label }}</span>
          </template>
          <el-table-column
            v-for="(child, cidx) in group.children"
            :key="cidx"
            :prop="child.prop"
            :label="child.label"
            :width="child.width"
          >
            <template slot="header">
              <span
                class="draggable-header"
                :data-type="'child'"
                :data-prop="child.prop"
                :data-label="child.label"
                :data-width="child.width"
                :data-month="currentMonth"
                draggable="true"
                @dragstart="handleDragStart($event, child, currentMonth)"
                @dragend="handleDragEnd"
              >{{ child.label }}</span>
            </template>
            <template slot-scope="{ row }">
              {{ row[child.prop] || '—' }}
            </template>
          </el-table-column>
        </el-table-column>
      </el-table>

      <div class="tab-bar">
        <div class="tab-scroll">
          <div
            v-for="item in monthList"
            :key="item.value"
            class="tab-item"
            :class="{ active: currentMonth === item.value }"
            @click="switchMonth(item.value)"
          >
            {{ item.label }}
          </div>
        </div>
      </div>
    </div>

    <!-- 下方表格 -->
    <div class="excel-box" @dragover.prevent @drop="handleTableDrop">
      <el-table :data="bottomData" border>
        <el-table-column type="index" width="50" fixed label="序号" />

        <!-- 所有列统一按 bottomCols 顺序渲染 -->
        <template v-for="col in bottomCols">
          <!-- 分组列(父级) -->
          <el-table-column
            v-if="col.type === 'group'"
            :key="col.fieldKey"
            :label="col.displayLabel"
          >
            <template slot="header">
              <div
                class="sortable-header"
                draggable="true"
                @dragstart="onSortStart($event, getColIndex(col.fieldKey))"
                @dragover.prevent="onSortOver"
                @dragenter="onSortEnter"
                @dragleave="onSortLeave"
                @drop.prevent="onSortDrop($event, getColIndex(col.fieldKey))"
              >
                {{ col.displayLabel }}
                <span class="delete-btn" @click="removeColumn(getColIndex(col.fieldKey))">✖</span>
              </div>
            </template>
            <el-table-column
              v-for="child in col.children"
              :key="child.fieldKey"
              :prop="child.fieldKey"
              :label="child.displayLabel"
              :width="child.width"
            >
              <template slot-scope="{ row }">
                {{ row[child.fieldKey] || '—' }}
              </template>
            </el-table-column>
          </el-table-column>

          <!-- 普通单列 -->
          <el-table-column
            v-else
            :key="col.fieldKey"
            :prop="col.fieldKey"
            :label="col.displayLabel"
            :width="col.width"
          >
            <template slot="header">
              <div
                class="sortable-header"
                draggable="true"
                @dragstart="onSortStart($event, getColIndex(col.fieldKey))"
                @dragover.prevent="onSortOver"
                @dragenter="onSortEnter"
                @dragleave="onSortLeave"
                @drop.prevent="onSortDrop($event, getColIndex(col.fieldKey))"
              >
                {{ col.displayLabel }}
                <span class="delete-btn" @click="removeColumn(getColIndex(col.fieldKey))">✖</span>
              </div>
            </template>
            <template slot-scope="{ row }">
              {{ row[col.fieldKey] || '—' }}
            </template>
          </el-table-column>
        </template>

        <el-table-column label="操作" width="100" fixed="right">
          <template slot-scope="{ row, $index }">
            <el-button type="text" size="small" @click="editRow($index)">编辑</el-button>
            <el-button type="text" size="small" style="color: #f56c6c" @click="deleteRow($index)">删除</el-button>
          </template>
        </el-table-column>
      </el-table>
    </div>
  </div>
</template>

<script>
export default {
  name: "ExcelDashboard",
  
  data() {
    return {
      monthList: [
        { label: '📊 1月 - 项目数据', value: '1月' },
        { label: '💰 2月 - 销售数据', value: '2月' },
        { label: '✅ 3月 - 任务数据', value: '3月' }
      ],
      currentMonth: '1月',
      
      monthColumnsMap: {
        '1月': {
          single: [
            { prop: 'name', label: '项目名称', width: 160 },
            { prop: 'dept', label: '负责部门', width: 140 }
          ],
          groups: [
            {
              label: '综合评估',
              children: [
                { prop: 'score', label: '综合评分', width: 120 },
                { prop: 'risk', label: '风险等级', width: 100 }
              ]
            }
          ]
        },
        '2月': {
          single: [
            { prop: 'product', label: '产品名称', width: 160 },
            { prop: 'sales', label: '销售额(万)', width: 130 }
          ],
          groups: [
            {
              label: '综合评估',
              children: [
                { prop: 'score', label: '综合评分', width: 120 },
                { prop: 'risk', label: '风险等级', width: 100 }
              ]
            }
          ]
        },
        '3月': {
          single: [
            { prop: 'task', label: '任务名称', width: 160 },
            { prop: 'assignee', label: '执行人', width: 120 }
          ],
          groups: [
            {
              label: '综合评估',
              children: [
                { prop: 'score', label: '综合评分', width: 120 },
                { prop: 'risk', label: '风险等级', width: 100 }
              ]
            }
          ]
        }
      },

      monthDataMap: {
        '1月': [
          { name: '智慧城市平台', dept: '研发中心', score: 88, risk: '低' },
          { name: '数据中台建设', dept: '技术部', score: 92, risk: '中' },
          { name: '安全防护系统', dept: '安全部', score: 78, risk: '高' },
          { name: '用户画像分析', dept: '产品部', score: 85, risk: '低' }
        ],
        '2月': [
          { product: '智能手表Pro', sales: 320, score: 90, risk: '低' },
          { product: '无线耳机X3', sales: 210, score: 82, risk: '中' }
        ],
        '3月': [
          { task: '系统架构升级', assignee: '陈工', score: 95, risk: '低' },
          { task: '数据库迁移', assignee: '刘工', score: 88, risk: '中' },
          { task: '接口文档编写', assignee: '小林', score: 75, risk: '高' },
          { task: '性能测试优化', assignee: '测试组', score: 92, risk: '低' }
        ]
      },

      bottomCols: [],
      bottomData: [],
      sortIndex: null
    }
  },

  computed: {
    currentSingleColumns() {
      const monthData = this.monthColumnsMap[this.currentMonth]
      return monthData ? monthData.single : []
    },
    currentGroupColumns() {
      const monthData = this.monthColumnsMap[this.currentMonth]
      return monthData ? monthData.groups : []
    },
    currentTableData() {
      return this.monthDataMap[this.currentMonth] || []
    }
  },

  methods: {
    getColIndex(fieldKey) {
      return this.bottomCols.findIndex(col => col.fieldKey === fieldKey)
    },

    switchMonth(month) {
      this.currentMonth = month
      this.$nextTick(() => {
        this.attachDragEvents()
      })
    },

    rebuildBottomData() {
      let maxRows = 0
      this.bottomCols.forEach(col => {
        const src = this.monthDataMap[col.month] || []
        if (src.length > maxRows) maxRows = src.length
      })
      
      if (maxRows === 0) {
        this.bottomData = []
        return
      }
      
      const rows = Array(maxRows).fill().map(() => ({}))
      
      this.bottomCols.forEach(col => {
        const src = this.monthDataMap[col.month] || []
        if (col.type === 'group') {
          col.children.forEach(child => {
            for (let i = 0; i < maxRows; i++) {
              rows[i][child.fieldKey] = src[i]?.[child.prop] || ''
            }
          })
        } else {
          for (let i = 0; i < maxRows; i++) {
            rows[i][col.fieldKey] = src[i]?.[col.prop] || ''
          }
        }
      })
      this.bottomData = rows
    },

    removeColumn(index) {
      this.bottomCols.splice(index, 1)
      this.rebuildBottomData()
    },

    clearAllData() {
      this.bottomCols = []
      this.bottomData = []
    },

    editRow(idx) {
      alert('编辑:' + JSON.stringify(this.bottomData[idx]))
    },

    deleteRow(idx) {
      this.bottomData.splice(idx, 1)
    },

    handleDragStart(event, col, month) {
      const data = { type: 'single', prop: col.prop, label: col.label, width: col.width, month }
      event.dataTransfer.setData('text/plain', JSON.stringify(data))
      event.dataTransfer.effectAllowed = 'copy'
      event.target.style.opacity = '0.5'
    },

    handleGroupDragStart(event, group, month) {
      const data = { type: 'group', label: group.label, children: group.children, month }
      event.dataTransfer.setData('text/plain', JSON.stringify(data))
      event.dataTransfer.effectAllowed = 'copy'
      event.target.style.opacity = '0.5'
    },

    handleDragEnd(event) {
      event.target.style.opacity = '1'
    },

    handleTableDrop(event) {
      event.preventDefault()
      const raw = event.dataTransfer.getData('text/plain')
      if (!raw) return
      
      let info
      try {
        info = JSON.parse(raw)
      } catch(e) {
        return
      }

      if (info.type === 'single') {
        const key = `${info.prop}_${info.month}`
        if (this.bottomCols.some(x => x.fieldKey === key)) return
        this.bottomCols.push({
          type: 'single',
          prop: info.prop,
          label: info.label,
          width: info.width,
          month: info.month,
          fieldKey: key,
          displayLabel: `${info.label}(${info.month})`
        })
        this.rebuildBottomData()
        return
      }

      if (info.type === 'group') {
        const groupKey = `${info.label}_${info.month}`
        if (this.bottomCols.some(x => x.fieldKey === groupKey)) return
        this.bottomCols.push({
          type: 'group',
          label: info.label,
          month: info.month,
          fieldKey: groupKey,
          displayLabel: `${info.label}(${info.month})`,
          children: info.children.map(c => ({
            ...c,
            fieldKey: `${c.prop}_${info.month}`,
            displayLabel: `${c.label}(${info.month})`
          }))
        })
        this.rebuildBottomData()
        return
      }
    },

    onSortStart(e, idx) {
      this.sortIndex = idx
      e.dataTransfer.effectAllowed = 'move'
      e.dataTransfer.setData('text/plain', idx)
    },

    onSortOver(e) {
      e.preventDefault()
      e.dataTransfer.dropEffect = 'move'
    },

    onSortEnter(e) {
      e.currentTarget.classList.add('sortable-over')
    },

    onSortLeave(e) {
      e.currentTarget.classList.remove('sortable-over')
    },

    onSortDrop(e, targetIndex) {
      e.preventDefault()
      e.currentTarget.classList.remove('sortable-over')
      if (this.sortIndex === null || this.sortIndex === targetIndex) return
      
      const moved = this.bottomCols[this.sortIndex]
      this.bottomCols.splice(this.sortIndex, 1)
      this.bottomCols.splice(targetIndex, 0, moved)
      
      this.rebuildBottomData()
      this.sortIndex = null
    },

    attachDragEvents() {
      const draggers = document.querySelectorAll('.draggable-header')
      draggers.forEach(el => {
        el.setAttribute('draggable', 'true')
      })
    },

    exportToExcel() {
      if (this.bottomCols.length === 0) {
        alert("暂无数据可导出")
        return
      }
      let csvRows = []
      const headers = ["序号", ...this.bottomCols.map(c => c.displayLabel)]
      csvRows.push(headers.join(","))
      
      this.bottomData.forEach((row, idx) => {
        const rowData = [idx + 1]
        this.bottomCols.forEach(col => {
          let val = row[col.fieldKey] || ''
          if (typeof val === 'string' && (val.includes(',') || val.includes('"'))) {
            val = `"${val.replace(/"/g, '""')}"`
          }
          rowData.push(val)
        })
        csvRows.push(rowData.join(","))
      })
      
      const blob = new Blob(["\uFEFF" + csvRows.join("\n")], { type: "text/csv;charset=utf-8;" })
      const link = document.createElement("a")
      const url = URL.createObjectURL(blob)
      link.href = url
      link.setAttribute("download", "excel_data.csv")
      document.body.appendChild(link)
      link.click()
      document.body.removeChild(link)
      URL.revokeObjectURL(url)
    }
  },

  mounted() {
    this.rebuildBottomData()
    setTimeout(() => {
      this.attachDragEvents()
    }, 200)
  }
}
</script>

<style scoped>
.excel-dashboard {
  padding: 20px;
  background: #f5f7fa;
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

.excel-box {
  background: #fff;
  margin-bottom: 20px;
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.el-table th,
.el-table td {
  text-align: center !important;
}

.tab-bar {
  display: flex;
  padding: 12px 16px;
  background: #f5f7fa;
  border-top: 1px solid #e4e7ed;
}

.tab-scroll {
  display: flex;
  gap: 8px;
  overflow-x: auto;
  flex-wrap: wrap;
}

.tab-item {
  padding: 6px 16px;
  background: #e4e7ed;
  border-radius: 20px;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.3s;
}

.tab-item:hover {
  background: #d0d4dc;
}

.tab-item.active {
  background: #409eff;
  color: #fff;
  font-weight: bold;
}

.draggable-header {
  cursor: grab;
  user-select: none;
  display: inline-block;
}

.draggable-header:active {
  cursor: grabbing;
}

.delete-btn {
  margin-left: 8px;
  color: #f56c6c;
  cursor: pointer;
  font-size: 12px;
}

.delete-btn:hover {
  color: #f00;
}

.sortable-header {
  cursor: move;
  user-select: none;
  display: inline-block;
  width: 100%;
}

.sortable-over {
  background: #e8f3ff !important;
  border-left: 3px solid #409eff !important;
}
</style>

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值