Vue3 + Element-plus + TS —— 动态表格自由编辑

9a69fede8b2044a79dd834e3e48f20b4.png前期回顾   

《 穿越时空的代码、在回首:Evil.js两年后的全新解读 》-CSDN博客

Vue3 + TS + Element-Plus 封装Tree组件 《亲测可用》_ icon-default.png?t=N7T8https://blog.csdn.net/m0_57904695/article/details/131664157?spm=1001.2014.3001.5501  

态表格 自由编辑

 

目录

♻️ 效果图 

 🚩 Vue2 版本

🐗 Vue3 版本


♻️ 效果图 

👉 在线预览

 🚩 Vue2 版本

<template>
  <!-- 可编辑表格V2 -->
  <div id="hello">
    <!-- 表格 -->
    <p class="tips">单击 右键菜单,单击 左键编辑</p>
    <el-table
      :data="tableData"
      height="500px"
      border
      style="width: 100%; margin-top: 10px"
      @cell-click="cellDblclick"
      @header-contextmenu="(column, event) => rightClick(null, column, event)"
      @row-contextmenu="rightClick"
      :row-class-name="tableRowClassName"
    >
      <el-table-column
        v-if="columnList.length > 0"
        type="index"
        :label="'No.'"
      />
      <el-table-column
        v-for="(col, idx) in columnList"
        :key="col.prop"
        :prop="col.prop"
        :label="col.label"
        :index="idx"
      />
    </el-table>
 
    <div>
      <h3 style="text-align: center">实时数据展示</h3>
      <label>当前目标:</label>
      <p>{{ JSON.stringify(curTarget) }}</p>
      <label>表头:</label>
      <p v-for="col in columnList" :key="col.prop">{{ JSON.stringify(col) }}</p>
      <label>数据:</label>
      <p v-for="(data, idx) in tableData" :key="idx">
        {{ JSON.stringify(data) }}
      </p>
    </div>
 
    <!-- 右键菜单框 -->
    <div v-show="showMenu" id="contextmenu" @mouseleave="showMenu = false">
      <p style="margin-bottom: 10px">列:</p>
      <el-button size="mini" type="primary" @click="addColumn()">
        前方插入一列
      </el-button>
      <el-button size="mini" type="primary" @click="addColumn(true)">
        后方插入一列
      </el-button>
 
      <el-button
        type="primary"
        size="mini"
        @click="openColumnOrRowSpringFrame('列')"
      >
        删除当前列
      </el-button>
 
      <el-button size="mini" type="primary" @click="renameCol($event)">
        更改列名
      </el-button>
 
      <div class="line"></div>
 
      <p style="margin-bottom: 12px">行:</p>
      <el-button
        size="mini"
        type="primary"
        @click="addRow()"
        v-show="!curTarget.isHead"
      >
        上方插入一行
      </el-button>
      <el-button
        size="mini"
        type="primary"
        @click="addRow(true)"
        v-show="!curTarget.isHead"
      >
        下方插入一行
      </el-button>
      <el-button
        size="mini"
        type="primary"
        @click="addRowHead(true)"
        v-show="curTarget.isHead"
      >
        下方插入一行
      </el-button>
      <el-button
        type="primary"
        size="mini"
        @click="openColumnOrRowSpringFrame('行')"
        v-show="!curTarget.isHead"
      >
        删除当前行
      </el-button>
    </div>
 
    <!-- 单元格/表头内容编辑框 -->
    <div v-show="showEditInput" id="editInput">
      <el-input
        v-focus
        placeholder="请输入内容"
        v-model="curTarget.val"
        clearable
        @change="updTbCellOrHeader"
        @blur="showEditInput = false"
        @keyup="onKeyUp($event)"
      >
        <template #prepend>{{ curColumn.label || curColumn.prop }}</template>
      </el-input>
    </div>
  </div>
</template>
 
<script>
export default {
  data() {
    return {
      columnList: [
        { prop: "name", label: "姓名" },
        { prop: "age", label: "年龄" },
        { prop: "city", label: "城市" },
        { prop: "tel", label: "电话" }
      ],
      tableData: [
        { name: "张三", age: 24, city: "广州", tel: "13312345678" },
        { name: "李四", age: 25, city: "九江", tel: "18899998888" }
      ],
      showMenu: false, // 显示右键菜单
      showEditInput: false, // 显示单元格/表头内容编辑输入框
      curTarget: {
        // 当前目标信息
        rowIdx: null, // 行下标
        colIdx: null, // 列下标
        val: null, // 单元格内容/列名
        isHead: undefined // 当前目标是表头?
      },
      countCol: 0 // 新建列计数
    };
  },
  computed: {
    curColumn() {
      return this.columnList[this.curTarget.colIdx] || {};
    }
  },
  methods: {
    // 删除当前列或当前行
    openColumnOrRowSpringFrame(type) {
      this.$confirm(
        `此操作将永久删除该 ${type === "列" ? "列" : "行"}, 是否继续 ?, '提示'`,
        {
          confirmButtonText: "确定",
          cancelButtonText: "取消",
          type: "warning"
        }
      )
        .then(() => {
          if (type === "列") {
            this.delColumn();
          } else if (type === "行") {
            this.delRow();
          }
          this.$message({
            type: "success",
            message: "删除成功!"
          });
        })
        .catch(() => {
          this.$message({
            type: "info",
            message: "已取消删除"
          });
        });
    },
    // 回车键关闭编辑框
    onKeyUp(e) {
      if (e.keyCode === 13) {
        this.showEditInput = false;
      }
    },
    // 单元格双击事件 - 更改单元格数值
    cellDblclick(row, column, cell, event) {
      this.showEditInput = false;
      if (column.index == null) return;
      this.locateMenuOrEditInput("editInput", 200, event); // 编辑框定位
      this.showEditInput = true;
      // 当前目标
      this.curTarget = {
        rowIdx: row.row_index,
        colIdx: column.index,
        val: row[column.property],
        isHead: false
      };
    },
    // 单元格/表头右击事件 - 打开菜单
    rightClick(row, column, event) {
      // 阻止浏览器自带的右键菜单弹出
      event.preventDefault(); // window.event.returnValue = false
      this.showMenu = false;
      if (column.index == null) return;
      this.locateMenuOrEditInput("contextmenu", 140, event); // 菜单定位
      this.showMenu = true;
      // 当前目标
      this.curTarget = {
        rowIdx: row ? row.row_index : null, // 目标行下标,表头无 row_index
        colIdx: column.index, // 目标项下标
        val: row ? row[column.property] : column.label, // 目标值,表头记录名称
        isHead: !row
      };
    },
    // 去更改列名
    renameCol($event) {
      this.showEditInput = false;
      if (this.curTarget.colIdx === null) return;
      this.locateMenuOrEditInput("editInput", 200, $event); // 编辑框定位
      this.showEditInput = true;
    },
    // 更改单元格内容/列名
    updTbCellOrHeader(val) {
      if (!this.curTarget.isHead)
        // 更改单元格内容
        this.tableData[this.curTarget.rowIdx][this.curColumn.prop] = val;
      else {
        // 更改列名
        if (!val) return;
        this.columnList[this.curTarget.colIdx].label = val;
      }
    },
    // 新增行
    addRow(later) {
      this.showMenu = false;
      const idx = later ? this.curTarget.rowIdx + 1 : this.curTarget.rowIdx;
      let obj = {};
      this.columnList.forEach((p) => (obj[p.prop] = ""));
      this.tableData.splice(idx, 0, obj);
    },
    // 表头下新增行
    addRowHead() {
      // 关闭菜单
      this.showMenu = false;
      // 新增行
      let obj = {};
      // 初始化行数据
      this.columnList.forEach((p) => (obj[p.prop] = ""));
      // 插入行数据
      this.tableData.unshift(obj);
    },
    // 删除行
    delRow() {
      this.showMenu = false;
      this.curTarget.rowIdx !== null &&
        this.tableData.splice(this.curTarget.rowIdx, 1);
    },
    // 新增列
    addColumn(later) {
      this.showMenu = false;
      const idx = later ? this.curTarget.colIdx + 1 : this.curTarget.colIdx;
      const colStr = { prop: "col_" + ++this.countCol, label: "" };
      this.columnList.splice(idx, 0, colStr);
      this.tableData.forEach((p) => (p[colStr.prop] = ""));
    },
    // 删除列
    delColumn() {
      this.showMenu = false;
      this.tableData.forEach((p) => {
        delete p[this.curColumn.prop];
      });
      this.columnList.splice(this.curTarget.colIdx, 1);
    },
    // 添加表格行下标
    tableRowClassName({ row, rowIndex }) {
      row.row_index = rowIndex;
    },
    // 定位菜单/编辑框
    locateMenuOrEditInput(eleId, eleWidth, event) {
      let ele = document.getElementById(eleId);
      ele.style.top = event.clientY - 100 + "px";
      ele.style.left = event.clientX - 50 + "px";
      if (window.innerWidth - eleWidth < event.clientX) {
        ele.style.left = "unset";
        ele.style.right = 0;
      }
    }
  }
};
</script>
 
<style lang="scss" scoped>
#hello {
  position: relative;
  height: 100%;
  width: 100%;
}
.tips {
  margin-top: 10px;
  color: #999;
}
#contextmenu {
  position: absolute;
  top: 0;
  left: 0;
  height: auto;
  width: 120px;
  border-radius: 3px;
  box-shadow: 0 0 10px 10px #e4e7ed;
  background-color: #fff;
  border-radius: 6px;
  padding: 15px 0 10px 15px;
  button {
    display: block;
    margin: 0 0 5px;
  }
}
.hideContextMenu {
  position: absolute;
  top: -4px;
  right: -5px;
}
#editInput,
#headereditInput {
  position: absolute;
  top: 0;
  left: 0;
  height: auto;
  min-width: 200px;
  max-width: 400px;
  padding: 0;
}
#editInput .el-input,
#headereditInput .el-input {
  outline: 0;
  border: 1px solid #c0f2f9;
  border-radius: 5px;
  box-shadow: 0px 0px 10px 0px #c0f2f9;
}
.line {
  width: 100%;
  border: 1px solid #e4e7ed;
  margin: 10px 0;
}
</style>

🐗 Vue3 版本

<template>
	<div id="table-wrap">
		<!-- 可编辑表格-Vue3 + ElementPlus -->
		<el-table
			:data="questionChoiceVOlist"
			stripe
			border
			@cell-click="cellClick"
			@row-contextmenu="rightClick"
			:row-class-name="tableRowClassName"
			@header-contextmenu="(column: any, event: MouseEvent) => rightClick(null, column, event)"
		>
			<el-table-column
				type="index"
				label="序号"
				align="center"
				:resizable="false"
				width="70"
			/>

			<template #empty>
				<el-empty description="暂无数据" />
			</template>

			<el-table-column
				:resizable="false"
				align="center"
				v-for="(col, idx) in columnList"
				:key="col.prop"
				:prop="col.prop"
				:label="col.label"
				:index="idx"
			>
				<template #default="{ row }">
					<div
						v-if="col.type === 'button'"
						style="height: 75px; padding-top: 26px; width: 100%"
					>
						<el-badge type="warning" :value="getRiskLenght(row.riskIds)">
							<el-button size="small">
								{{ paramsIdType == 'detail' ? '查看' : '选择' }}
							</el-button>
						</el-badge>
					</div>
					<el-input-number
						v-if="col.type === 'input-number'"
						v-model.number="row[col.prop]"
						:min="0"
						:max="10"
						:step="0.1"
						:precision="2"
					/>
				</template>
			</el-table-column>
		</el-table>

		<!-- 右键菜单框 -->
		<div v-show="showMenu" id="contextmenu" @mouseleave="showMenu = false">
			<p style="margin-bottom: 10px; text-align: left">列:</p>
			<el-button :icon="CaretTop" @click="addColumn(false)"> 前方插入一列 </el-button>
			<el-button :icon="CaretBottom" @click="addColumn(true)"> 后方插入一列 </el-button>
			<el-button :icon="DeleteFilled" @click="openColumnOrRowSpringFrame('列')">
				删除当前列
			</el-button>
			<el-button @click="renameCol" :icon="EditPen"> 更改列名 </el-button>

			<div style="color: #ccc">-----------------------</div>

			<p style="margin-bottom: 12px">行:</p>
			<el-button :icon="CaretTop" @click="addRow(false)" v-show="!curTarget.isHead">
				上方插入一行
			</el-button>
			<el-button :icon="CaretBottom" @click="addRow(true)" v-show="!curTarget.isHead">
				下方插入一行
			</el-button>
			<el-button :icon="DeleteFilled" @click="addRowHead" v-show="curTarget.isHead">
				下方插入一行
			</el-button>
			<el-button
				:icon="DeleteFilled"
				@click="openColumnOrRowSpringFrame('行')"
				v-show="!curTarget.isHead"
			>
				删除当前行
			</el-button>
		</div>

		<!-- 输入框 -->
		<div v-show="showEditInput" id="editInput">
			<el-input
				ref="iptRef"
				placeholder="请输入内容"
				v-model="curTarget.val"
				clearable
				@change="updTbCellOrHeader"
				@blur="showEditInput = false"
				@keyup="onKeyUp($event)"
			>
				<template #prepend>{{ curColumn.label || curColumn.prop }}</template>
			</el-input>
		</div>

		<!-- 实时数据展示 Start-->
		<!-- 
		第二个和第三个参数来格式化JSON输出,其中null作为替换函数(这里不进行替换),2表示缩进级别。
		这样JSON数据会以格式化的形式展示,增加了可读性
		 -->
		<div>
			<h3 style="text-align: center; margin-top: 15px">实时数据展示</h3>
			<label>当前目标:</label>
			<pre><code>{{ JSON.stringify(curTarget, null, 2) }}</code></pre>
			<div style="width: 100%; height: auto">
				<label>表头:</label>
				<pre><code v-for="col in columnList" :key="col.prop">{{ JSON.stringify(col, null, 2) }}</code></pre>
			</div>

			<div>
				<label>数据:</label>
				<pre><code v-for="(data, idx) in questionChoiceVOlist" :key="idx">
					{{ JSON.stringify(data, null, 2) }}
				</code></pre>
			</div>
		</div>
		<!-- 实时数据展示 End-->
	</div>
</template>

<script setup lang="ts">
import { ref, reactive, computed, toRefs, nextTick } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { DeleteFilled, CaretBottom, CaretTop, EditPen } from '@element-plus/icons-vue';
// Tips: locateMenuOrEditInput 可调整编辑框位置
interface Column {
	prop: string;
	label: string;
	type?: string;
}

interface Data {
	choiceCode: string;
	choiceContent: string;
	riskIds: string;
	itemScore: string | number;
	[key: string]: unknown;
}

interface Target {
	rowIdx: number | null;
	colIdx: number | null;
	val: string | null;
	isHead: boolean | undefined;
}

// 接收addEdit父组件传过来的数据,用于判断是新增、编辑、详情页面
const paramsIdType = 'detail';

const state = reactive({
	columnList: [
		{ prop: 'choiceCode', label: '选项编码' },
		{ prop: 'choiceContent', label: '选项内容' },
		{ prop: 'riskIds', label: '风险点', type: 'button' },
		{ prop: 'itemScore', label: '选项分值', type: 'input-number' },
	] as Column[],
	questionChoiceVOlist: [
		{
			choiceCode: 'A',
			choiceContent: '是',
			riskIds: '45,47',
			itemScore: 1,
			isClickCheckBtn: true,
			id: 1,
		},
		{
			choiceCode: 'B',
			choiceContent: '否',
			riskIds: '46',
			itemScore: 4,
			isClickCheckBtn: true,
			id: 2,
		},
		{
			choiceCode: 'C',
			choiceContent: '否',
			riskIds: '',
			itemScore: 4,
			isClickCheckBtn: true,
			id: 3,
		},
	] as Data[],
	showMenu: false, // 显示右键菜单
	showEditInput: false, // 显示单元格/表头内容编辑输入框
	curTarget: {
		// 当前目标信息
		rowIdx: null, // 行下标
		colIdx: null, // 列下标
		val: null, // 单元格内容/列名
		isHead: undefined, // 当前目标是表头?
	} as Target,
	countCol: 0, // 新建列计数
});
const iptRef = ref();

const { columnList, questionChoiceVOlist, showMenu, showEditInput, curTarget } = toRefs(state);

// 当前列
const curColumn = computed(() => {
	return curTarget.value.colIdx !== null
		? columnList.value[curTarget.value.colIdx]
		: { prop: '', label: '' };
});

// 计算风险点数量
const getRiskLenght = computed(() => {
	return (riskIds: string) => riskIds.split(',').filter(Boolean).length;
});

/**
 * 删除列/行
 * @method  delColumn
 * @param {  string }  type - '列' | '行'
 **/
const openColumnOrRowSpringFrame = (type: string) => {
	ElMessageBox.confirm(`此操作将永久删除该${type === '列' ? '列' : '行'}, 是否继续 ?, '提示'`, {
		confirmButtonText: '确定',
		cancelButtonText: '取消',
		type: 'warning',
	})
		.then(() => {
			if (type === '列') {
				delColumn();
			} else if (type === '行') delRow();

			ElMessage.success('删除成功');
		})
		.catch(() => ElMessage.info('已取消删除'));
};

// 回车键关闭编辑框
const onKeyUp = (e: KeyboardEvent) => {
	if (e.key === 'Enter') {
		showEditInput.value = false;
	}
};

// 控制某字段不能打开弹框
const isPop = (column: { label: string }) => {
	return column.label === '风险点' || column.label === '选项分值';
};

// 左键输入框
const cellClick = (
	row: { [x: string]: any; row_index: any },
	column: { index: null; property: string | number; label: string },
	_cell: any,
	event: MouseEvent
) => {
	// 如果是风险点或选项分值,不执行后续代码
	if (isPop(column)) return;

	iptRef.value.focus();
	if (column.index == null) return;
	locateMenuOrEditInput('editInput', -300, event); // 左键输入框定位 Y
	showEditInput.value = true;
	iptRef.value.focus();

	// 当前目标
	curTarget.value = {
		rowIdx: row.row_index,
		colIdx: column.index,
		val: row[column.property],
		isHead: false,
	};
};

// 表头右击事件 - 打开菜单
const rightClick = (row: any, column: any, event: MouseEvent) => {
	event.preventDefault();

	if (column.index == null) return;
	// 如果tableData有数据并且当前目标是表头,那么就返回,不执行后续操作
	// if (questionChoiceVOlist.value.length > 0 && !row) return;
	if (isPop(column)) return;

	showMenu.value = false;
	locateMenuOrEditInput('contextmenu', -500, event); // 右键输入框
	showMenu.value = true;
	curTarget.value = {
		rowIdx: row ? row.row_index : null,
		colIdx: column.index,
		val: row ? row[column.property] : column.label,
		isHead: !row,
	};
};

// 更改列名
const renameCol = () => {
	showEditInput.value = false;
	if (curTarget.value.colIdx === null) return;
	showEditInput.value = true;
	nextTick(() => {
		iptRef.value.focus();
	});
};

// 更改单元格内容/列名
const updTbCellOrHeader = (val: string) => {
	if (!curTarget.value.isHead) {
		if (curTarget.value.rowIdx !== null) {
			(questionChoiceVOlist.value[curTarget.value.rowIdx] as Data)[curColumn.value.prop] =
				val;
		}
	} else {
		if (!val) return;
		if (curTarget.value.colIdx !== null) {
			columnList.value[curTarget.value.colIdx].label = val;
		}
	}
};
// 新增行
const addRow = (later: boolean) => {
	showMenu.value = false;
	const idx = later ? curTarget.value.rowIdx! + 1 : curTarget.value.rowIdx!;
	let obj: any = {};
	columnList.value.forEach((p) => obj[p.prop]);
	questionChoiceVOlist.value.splice(idx, 0, obj);
	// 设置新增行数据默认值
	questionChoiceVOlist.value[idx] = {
		choiceCode: '',
		choiceContent: '',
		riskIds: '',
		itemScore: 0,
		id: Math.floor(Math.random() * 100000),
	};
};

// 表头下新增行
const addRowHead = () => {
	showMenu.value = false;
	let obj: any = {};
	columnList.value.forEach((p) => obj[p.prop]);
	questionChoiceVOlist.value.unshift(obj);
	questionChoiceVOlist.value[0] = {
		choiceCode: '',
		choiceContent: '',
		riskIds: '',
		itemScore: 0,
		id: Math.floor(Math.random() * 100000),
	};
};
// 删除行
const delRow = () => {
	showMenu.value = false;
	curTarget.value.rowIdx !== null &&
		questionChoiceVOlist.value.splice(curTarget.value.rowIdx!, 1);
};

// 新增列
const addColumn = (later: boolean) => {
	showMenu.value = false;
	const idx = later ? curTarget.value.colIdx! + 1 : curTarget.value.colIdx!;
	const colStr = { prop: 'Zk-NewCol - ' + ++state.countCol, label: '' };
	columnList.value.splice(idx, 0, colStr);
	questionChoiceVOlist.value.forEach((p) => (p[colStr.prop] = ''));
};

// 删除列
const delColumn = () => {
	showMenu.value = false;
	questionChoiceVOlist.value.forEach((p) => {
		delete p[curColumn.value.prop];
	});
	columnList.value.splice(curTarget.value.colIdx!, 1);
};

// 添加表格行下标
const tableRowClassName = ({ row, rowIndex }: { row: any; rowIndex: number }) => {
	row.row_index = rowIndex;
};

// 定位菜单/编辑框
const locateMenuOrEditInput = (eleId: string, distance: number, event: MouseEvent) => {
	if (window.innerWidth < 1130 || window.innerWidth < 660)
		return ElMessage.warning('窗口太小,已经固定菜单位置,或请重新调整窗口大小');
	const ele = document.getElementById(eleId) as HTMLElement;
	const x = event.pageX;
	const y = event.clientY + 200; //右键菜单位置 Y
	let left = x + distance + 200; //右键菜单位置 X
	let top;
	if (eleId == 'editInput') {
		// 左键
		top = y + distance;
		left = x + distance - 120;
	} else {
		// 右键
		top = y + distance + 170;
	}
	ele.style.left = `${left}px`;
	ele.style.top = `${top}px`;
};

defineExpose({
	questionChoiceVOlist,
});
</script>

<style lang="scss" scoped>
#table-wrap {
	width: 100%;
	height: 100%;
	/* 左键 */
	#contextmenu {
		position: absolute;
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: center;
		z-index: 999999;
		top: 0;
		left: 0;
		height: auto;
		width: 200px;
		border-radius: 3px;
		border: #e2e2e2 1px solid;
		box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
		background-color: #fff;
		border-radius: 6px;
		padding: 15px 10px 14px 12px;

		button {
			display: block;
			margin: 0 0 5px;
		}
	}
	/* 右键 */
	#editInput {
		position: absolute;
		top: 0;
		left: 0;
		z-index: 999999;
	}
	/* 实时数据 */

	pre {
		border: 1px solid #cccccc;
		padding: 10px;
		overflow: auto;
	}
}
</style>

7730e2bd39d64179909767e1967da702.jpeg

 _______________________________  期待再见  _______________________________

  • 14
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

彩色之外

你的打赏是我创作的氮气加速动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值