效果图


上方表格(数据源区)
功能
| 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>

231

被折叠的 条评论
为什么被折叠?



