先上效果
项目背景
客户定制需求,需要实现一个类表格数据项管理,每个表格项区分是文本输入还是数字输入,或者其他类型。 其他类型呢,一个输入框或者一个选择框就可以搞定,但是文本不行。 因为文本通常会有很多,以及换行,或者带有序标识,单一的文本框就不足以满足需求。 所以最终采用多行文本域加定位的方式,支持内容编辑撑开自动响应位置,保证随处点开的编辑框能完全展示在页面可视区域内。
技术实现
vue3 + elementPlus + 客户代码中的 vxe-table
刚开始接到需求的时候,我想的特别简单,就是获取元素的位置,获取外容器滚动的距离,以及屏幕可视化区域的高度,一顿相加判定。没错,就实现了。
但是,我说但是,只是实现了20%。只是实现了在页面上部分出弹出框,往下滑动的时候弹窗跟随正常。
当元素从底部开始网上滑动的时候就不行了,因为 他们三者之间的关系真的很巧妙,一旦边界判定差一像素,文本框的位置都是失效的。
你们是不是也遇到过这种问题呢。我真的是肝了好长时间!
各种调试、删减代码,最后发现实际也就是关键位置的一个判定而已,瞬间茅塞顿开。
难点剖析
核心:实际也就是弹层输入框的高度, 页面触发块的位置, 可视区域的高度 三者之间的运算
// 当前触发块的位置
const currentCellReac = currentCell.value.getBoundingClientRect()
// 弹层输入框的位置
const _ref = editTextareaRef.value.$el.getBoundingClientRect()
// 可视区域的高度
const viewportHeight = window.innerHeight;
/*
如果弹层输入框的高度 > 视口的高度 - 页面触发块的top位置,代表着弹层输入框在两者之间已经放不下了,所以就需要根据视口位置定位在底部不可移动
反之,就需要跟随 页面触发块的位置 随之变化
*/
if (_ref.height > viewportHeight - currentCellReac.top) {
textareaStyle.value.position = 'absolute';
textareaStyle.value.bottom = '0';
textareaStyle.value.top = 'auto';
} else {
textareaStyle.value.position = 'fixed';
textareaStyle.value.top = `${currentCellReac.top}px`;
textareaStyle.value.bottom = 'auto';
}
图例展示
注意点
在弹层编辑的时候,页面的渲染与获取高度的时机,可能会有问题,虽然加比较大的 setTimeout 可以实现,但是页面效果会有明显的卡顿
所以小编在这里使用的是 requestAnimationFrame 进行帧校正渲染,可以优雅的解决这个问题。
插个广告,不熟悉 requestAnimationFrame 的小伙伴,可以看一下我的另一篇文章 juejin.cn/post/741591…
requestAnimationFrame(() => {
// 弹层输入框
const _ref = editTextareaRef.value.$el.getBoundingClientRect()
// 视口高度
const viewportHeight = window.innerHeight;
// 弹层加视口高度
const _demo = _ref.height + rect.top
if (_demo > viewportHeight) {
textareaStyle.value.position = 'absolute';
textareaStyle.value.bottom = '0';
textareaStyle.value.top = 'auto';
} else {
textareaStyle.value.position = 'fixed';
textareaStyle.value.top = `${rect.top}px`;
textareaStyle.value.bottom = 'auto';
}
// 主动聚焦到弹层输入框
if (editTextareaRef.value) editTextareaRef.value.focus()
})
-
关于弹层定位的数据《一定不要用响应式》,普通对象定义即可
const _textareaStyle = { position: 'fixed', top: '0px', left: '0px', width: '400px', // 设置较大的宽度 zIndex: 1000, fontSize: '14px', border: '1px solid #dcdfe6', borderRadius: '4px', boxShadow: '0 0 8px rgba(0, 0, 0, 0.1)', backgroundColor: '#fff', }
因为 弹层 是文本编辑公用的,且是频繁更改的,vue 会代理这份数据,且下一次的弹层的位置数据会根据上一次的弹层最后关闭的位置信息,会有影响。
具体的体现就是,弹层的位置总是一次正确一次不正确,轮询展示。
总结一波
-
上文提到的 弹层输入框的高度, 页面触发块的位置, 可视区域的高度 这三者的关系,在很多需求中是通用的。 当然 也不局限于这三种,重要的是 关键位置数据的提取在当前需求中的灵活运用。
-
小编个人认为,很多
模版类型数据,就用普通对象定义就好,没有必要用响应式。 如果要用,一定要注意 复杂类型数据的引用关系,否者会产生很多难以排查的 BUG。
源码部分
老生常谈,因为时间问题,就没有太细的提取封装,源码跟项目需求走,如有需要,自行提取。 源码包含 类表格数据渲染部分
大家如果有什么更好的思路,欢迎在评论区讨论。
<template>
<div>
<div class="expand-box" v-for="(item, index) in tableBoxData" :key="item.id">
<div class="top">
<div class="left">
<el-icon @click="item.isExpand = !item.isExpand">
<CaretBottom v-if="item.isExpand" />
<CaretRight v-else />
</el-icon>
<el-input placeholder="请输入规则主题" v-model="item.name" v-if="EditRulesActionStatus == 1" style="width: 200px;" />
<span @click="item.isExpand = !item.isExpand" v-else>{{ item.name }}</span>
</div>
<div class="right" v-if="EditRulesActionStatus == 1">
<el-button type="danger" size="small" @click="handleBoxDelete(index)">移除</el-button>
</div>
</div>
<div v-show="item.isExpand">
<vxe-table :show-footer="EditRulesActionStatus >= 5" ref="xTable" border min-height="50"
@cell-click="handleCellClick" size="small" :footer-method="() => footerMethod(index)" :data="item.standardDtos"
header-align="center">
<vxe-column field="name" title="名称" :class-name="ClassNameType(1)" width="160"></vxe-column>
<vxe-column field="describes" title="描述" :class-name="ClassNameType(1)"></vxe-column>
<vxe-column field="measure" title="行量标准" :class-name="ClassNameType(1)"></vxe-column>
<vxe-column field="bigNum" title="最高分" :width="EditRulesActionStatus > 1 ? 60 : 300" align="center">
<template #default="scope">
<template v-if="EditRulesActionStatus == 1">
<el-input-number v-model="scope.row.bigNum" :min="0" size="small" style="width: 140px !important;"
:controls="false" />
</template>
<template v-else>
{{ scope.row.bigNum }}
</template>
</template>
</vxe-column>
<vxe-column field="targetNum" title="目标量" :width="EditRulesActionStatus > 1 ? 100 : 300" align="center">
<template #default="scope">
<template v-if="EditRulesActionStatus == 1">
<el-input v-model="scope.row.targetNum" size="small" style="width: 140px !important;"
placeholder="请输入"></el-input>
<el-select size="small" v-model="scope.row.unit" placeholder="请选择" clearable class="rulesTopDiySelect"
style="width: 100px !important;">
<el-option v-for="item in examineUnits" :key="item.label" :label="item.label"
:value="item.label"></el-option>
</el-select>
</template>
<template v-else>
{{ scope.row.targetNum }}{{ scope.row.unit }}
</template>
</template>
</vxe-column>
<vxe-column field="setting" title="操作" width="100" v-if="EditRulesActionStatus == 1" align="center">
<template #default="scope">
<el-button type="danger" size="small" @click="handleDelete(scope, index)">移除</el-button>
</template>
</vxe-column>
<vxe-column field="targetNumEdit" title="设定目标" v-if="EditRulesActionStatus >= 2"
:width="EditRulesActionStatus >= 5 ? 100 : 300" align="center">
<template #default="scope">
<template v-if="EditRulesActionStatus == 3">
<el-input v-model="scope.row.targetNumEdit" size="small" style="width: 180px !important;"
placeholder="请输入"></el-input>
</template>
<template v-else>
{{ scope.row.targetNumEdit || '' }}{{ scope.row.targetNumEdit ? scope.row.unit : '' }}
</template>
</template>
</vxe-column>
<vxe-colgroup title="自评得分" v-if="EditRulesActionStatus >= 5">
<template v-if="EditRulesActionStatus == 6">
<vxe-column field="myScore" title="评分" width="120">
<template #default="scope">
<el-input-number v-model="scope.row.myScore" :min="0" :max="scope.row.bigNum" size="small"
style="width: 114px !important;" :controls="false" />
</template>
</vxe-column>
<vxe-column field="myRemark" title="内容" :class-name="ClassNameType(6)" width="200"></vxe-column>
</template>
<template v-else-if="EditRulesActionStatus >= 5">
<vxe-column field="myScore" title="评分" :width="EditRulesActionStatus >= 6 ? 120 : 80" align="center">
<template #default="scope">
{{ scope.row.myScore || '' }}
</template>
</vxe-column>
<vxe-column field="myRemark" title="内容" :class-name="ClassNameType(6)"
:width="EditRulesActionStatus >= 6 ? 120 : 80"></vxe-column>
</template>
</vxe-colgroup>
<vxe-colgroup title="团队负责人得分" v-if="EditRulesActionStatus >= 5">
<template v-if="EditRulesActionStatus == 9">
<vxe-column field="bossScore" title="评分" width="120">
<template #default="scope">
<el-input-number v-model="scope.row.bossScore" :min="0" :max="scope.row.bigNum" size="small"
style="width: 114px !important;" :controls="false" />
</template>
</vxe-column>
<vxe-column field="bossRemark" title="内容" :class-name="ClassNameType(9)" width="200"></vxe-column>
</template>
<template v-else-if="EditRulesActionStatus >= 5">
<vxe-column field="bossScore" title="评分" :width="EditRulesActionStatus >= 9 ? 120 : 80" align="center">
<template #default="scope">
{{ scope.row.bossScore || '' }}
</template>
</vxe-column>
<vxe-column field="bossRemark" title="内容" :class-name="ClassNameType(8)"
:width="EditRulesActionStatus >= 8 ? 120 : 80"></vxe-column>
</template>
</vxe-colgroup>
<vxe-column field="sumScoreCount" title="得分" width="80" v-if="EditRulesActionStatus == 10"
align="center"></vxe-column>
</vxe-table>
<el-button type="primary" size="mini" @click.stop="handleAdd(index)" v-if="EditRulesActionStatus == 1"
style="margin-top: 10px !important;">
<el-icon class="el-icon--right">
<Plus />
</el-icon>自定义考核指标
</el-button>
</div>
</div>
<!-- 弹出的多行文本框 -->
<Teleport to="body">
<vxe-textarea ref="editTextareaRef" v-if="showTextarea" v-model="editValue" :style="textareaStyle"
@change="handleInputTextarea" @blur="confirmEdit" :autosize="{ minRows: 6, maxRows: 999 }"
resize="none"></vxe-textarea>
</Teleport>
</div>
</template>
<script setup>
import { ref, toRef, nextTick, defineProps, onMounted, onUnmounted, defineEmits } from 'vue';
import { examineUnits } from '../other/kpiBase.js'
const props = defineProps(['tableBoxData', 'EditRulesActionStatus'])
const emits = defineEmits(['handleDelete', 'handleBoxDelete', 'handleAdd'])
// 整个考核标准的流状态
const EditRulesActionStatus = toRef(props, 'EditRulesActionStatus')
// 表格数据
const tableBoxData = toRef(props, 'tableBoxData')
// console.log(EditRulesActionStatus, "EditRulesActionStatus");
// 当前状态的下输入框样式
const ClassNameType = type => {
return [1, 6, 9].includes(type) && EditRulesActionStatus.value == type ? 'multiline-cell' : 'multiline-cell-two'
}
/**
* textarea编辑板块
*/
// 状态1正在编辑的值
const editValue = ref('');
// 状态1放大输入框是否展示
const showTextarea = ref(false);
// 状态1 放大输出框的样式
const _textareaStyle = {
position: 'fixed',
top: '0px',
left: '0px',
width: '400px', // 设置较大的宽度
zIndex: 1000,
fontSize: '14px',
border: '1px solid #dcdfe6',
borderRadius: '4px',
boxShadow: '0 0 8px rgba(0, 0, 0, 0.1)',
backgroundColor: '#fff',
}
// 多行文本的引用
const textareaStyle = ref();
// 状态1当前操作的行
let currentRow = null;
// 状态1当前操作的列
let currentColumn = null;
// 状态1聚焦到文本框,防止失焦事件错误
const editTextareaRef = ref()
// 当前操作的单元格
let currentCell = ref()
// 状态1设置规则中可进行编辑
const handleCellClick = ({ row, column, cell }) => {
// 非可编辑拦截,不是设置规则状态不可编辑多行
const { field } = column;
if (['bigNum', 'targetNum', 'targetNumEdit', 'myScore', 'bossScore', 'setting'].includes(field)) return
if (![1, 6, 9].includes(EditRulesActionStatus.value)) return
// 当前状态为大于6的时候,6以前的不可以编辑
if (EditRulesActionStatus.value >= 6 && ['name', 'describes', 'measure'].includes(field)) return
// 6以后的不可以编辑
if (EditRulesActionStatus.value == 6 && 'bossRemark' == field) return
// 8以前的不可以编辑
if (EditRulesActionStatus.value == 9 && 'myRemark' == field) return
// 记录当前编辑的行和列
currentRow = row;
currentColumn = column;
// 获取单元格位置并定位文本框
const rect = cell.getBoundingClientRect();
currentCell.value = cell;
textareaStyle.value = { ..._textareaStyle }
textareaStyle.value.top = `${rect.top}px`;
textareaStyle.value.left = `${rect.left}px`;
textareaStyle.value.width = `${rect.width}px`;
// 设置当前值并显示文本框
editValue.value = row[column.property];
showTextarea.value = true;
nextTick(() => {
requestAnimationFrame(() => {
const _ref = editTextareaRef.value.$el.getBoundingClientRect()
const viewportHeight = window.innerHeight;
const _demo = _ref.height + rect.top
console.log(_ref.height, rect.top, _demo, '_demo');
// console.log(_ref, __textareaBottom, viewportHeight);
if (_demo > viewportHeight) {
textareaStyle.value.position = 'absolute';
textareaStyle.value.bottom = '0';
textareaStyle.value.top = 'auto';
} else {
textareaStyle.value.position = 'fixed';
textareaStyle.value.top = `${rect.top}px`;
textareaStyle.value.bottom = 'auto';
}
if (editTextareaRef.value) editTextareaRef.value.focus()
})
});
};
// 父级盒子块滑动事件
const handleScroll = () => {
const bigBoxElement = document.querySelector('.setting-container');
if (bigBoxElement && editTextareaRef.value) {
const currentCellReac = currentCell.value.getBoundingClientRect()
const _ref = editTextareaRef.value.$el.getBoundingClientRect()
const viewportHeight = window.innerHeight;
// console.log(_ref, ref.height + currentCellReac.top, 'handleScroll');
if (_ref.height > viewportHeight - currentCellReac.top) {
textareaStyle.value.position = 'absolute';
textareaStyle.value.bottom = '0';
textareaStyle.value.top = 'auto';
} else {
textareaStyle.value.position = 'fixed';
textareaStyle.value.top = `${currentCellReac.top}px`;
textareaStyle.value.bottom = 'auto';
}
}
};
// 输入框编辑事件,因为输入框高度、位置随着内容变化而变化
const handleInputTextarea = async () => {
if (!editTextareaRef.value) return
handleScroll()
}
// 挂载滑动监听事件
onMounted(() => {
const bigBoxElement = document.querySelector('.setting-container');
if (bigBoxElement) {
bigBoxElement.addEventListener('scroll', handleScroll);
}
});
// 移除滑动监听事件
onUnmounted(() => {
const bigBoxElement = document.querySelector('.setting-container');
if (bigBoxElement) {
bigBoxElement.removeEventListener('scroll', handleScroll);
}
});
// 编辑保存
const confirmEdit = () => {
if (currentRow && currentColumn) {
currentRow[currentColumn.property] = editValue.value;
}
showTextarea.value = false;
};
// 移除单个指标
const handleDelete = (scope, index) => {
const { rowIndex } = scope
emits('handleDelete', rowIndex, index)
}
// 移除维度指标
const handleBoxDelete = (index) => {
emits('handleBoxDelete', index)
}
// 添加新的指标
const handleAdd = (index) => {
emits('handleAdd', index)
}
/**
* 尾部数据板块
*/
// 表格引用
const xTable = ref()
const footerMethod = (index) => {
if (EditRulesActionStatus.value < 5) return []
let returnArray = {
name: '合计'
}
// 计算自评分总分
if (EditRulesActionStatus.value >= 7) {
returnArray['myScore'] = tableBoxData.value[index]['standardDtos'].map(item => Number(item.myScore)).reduce((per, curr) => curr * 10000 + per, 0) / 10000
}
// 计算主管评分完成
if (EditRulesActionStatus.value == 10) {
returnArray['bossScore'] = tableBoxData.value[index]['standardDtos'].map(item => Number(item.bossScore)).reduce((per, curr) => curr * 10000 + per, 0) / 10000
returnArray['sumScoreCount'] = tableBoxData.value[index]['standardDtos'].map(item => Number(item.sumScoreCount)).reduce((per, curr) => curr * 10000 + per, 0) / 10000
}
return [returnArray]
}
</script>
<style>
.multiline-cell {
cursor: pointer;
}
.multiline-cell .vxe-cell .vxe-cell--label {
display: -webkit-box;
-webkit-line-clamp: 4;
/* 限制最多显示 4 行 */
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
white-space: pre-line;
word-wrap: break-word;
/* 自动换行 */
line-height: 1.5;
/* 根据需要调整行高 */
max-height: calc(1.5em * 4);
/* 根据行高限制最大高度,确保显示不超过 4 行 */
}
.multiline-cell-two .vxe-cell {
white-space: pre-line !important;
}
.rulesTopDiySelect {
margin-left: 10px;
}
.rulesTopDiySelect .el-input {
width: 100px !important;
}
</style>
<style lang="scss" scoped>
.expand-box {
padding: 10px;
border-radius: 4px;
box-shadow: 0 1px 5px 1px #7a7a7a;
.top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
.left {
cursor: pointer;
}
}
}
.expand-box+.expand-box {
margin-top: 10px;
}
</style>