Files
xgy-oa/klp-ui/src/views/crm/components/JSONTableInput.vue
砂糖 5ac2e78a33 feat(crm): 新增JSON表格输入组件并优化客户管理页面
refactor(OrderDetail): 允许orderId为undefined并添加空值检查
feat(JSONTableInput): 新增支持JSON与表格双向绑定的通用组件
refactor(customer): 重构客户详情页面,优化表单交互和字段映射
style(KLPList): 简化列表组件样式并修复字段显示逻辑
2025-12-16 11:47:53 +08:00

319 lines
9.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="json-table-input" ref="tableContainer">
<!-- JSON 解析错误提示 -->
<div v-if="error" class="error-tip">
<i class="el-icon-warning"></i> {{ error }}
</div>
<!-- 表格编辑区域 -->
<el-table :data="tableData" border size="mini" :show-header="columns.length > 0" class="table-container">
<!-- 动态渲染列 -->
<el-table-column v-for="col in columns" :key="col.prop" :label="col.label" :width="col.width">
<template slot-scope="scope">
<!-- 新增聚焦/失焦事件,全局记录聚焦状态 -->
<el-input v-model="scope.row[col.prop]" size="mini" @input="handleCellChange" @focus="handleInputFocus"
@blur="handleInputBlur" placeholder="请输入" />
</template>
</el-table-column>
<!-- 操作列:删除行(空行不可删) -->
<el-table-column label="操作" width="90">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDeleteRow(scope.$index)"
:disabled="isLastEmptyRow(scope.$index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 原始 JSON 预览(可选,方便调试) -->
<div class="json-preview" v-if="showPreview">
<div class="preview-label">JSON 预览自动格式化</div>
<pre>{{ formattedJson }}</pre>
</div>
</div>
</template>
<script>
export default {
name: 'JSONTableInput',
props: {
// 双向绑定的值JSON 数组字符串 / JSON 数组对象)
value: {
type: [String, Array],
default: '[]'
},
// 表格列配置:[{ prop: 'name', label: '姓名', width: 150 }, ...]
columns: {
type: Array,
required: true,
default: () => []
},
// 是否显示 JSON 预览(默认关闭)
showPreview: {
type: Boolean,
default: false
},
// 防抖延迟ms所有输入停止 + 无输入框聚焦 后触发更新
debounceDelay: {
type: Number,
default: 500 // 默认500ms防抖
}
},
data() {
return {
tableData: [], // 表格内部数据
error: '', // JSON 解析错误提示
debounceTimer: null, // 组件级防抖定时器所有input共用
hasFocus: false // 全局标记是否有任意input处于聚焦状态
}
},
computed: {
// 格式化后的 JSON 字符串(输出给 v-model
formattedJson() {
try {
// 过滤空行(所有字段为空的行)
const validData = this.tableData.filter(row => this.isRowValid(row))
// 格式化 JSON缩进2个空格保证可读性
return JSON.stringify(validData, null, 2)
} catch (e) {
this.error = 'JSON 格式化失败:' + e.message
return '[]'
}
}
},
watch: {
// 监听外部传入的 value 变化,解析为表格数据
value: {
immediate: true,
handler(val) {
this.parseValueToTableData(val)
}
},
// 监听聚焦状态变化:当失去所有焦点时,触发防抖检查
hasFocus(newVal) {
if (!newVal) {
this.triggerDebounceChange()
}
}
},
beforeDestroy() {
// 销毁时清除防抖定时器,防止内存泄漏
if (this.debounceTimer) clearTimeout(this.debounceTimer)
},
methods: {
/**
* 解析外部传入的 value 为表格数据
* @param {String|Array} val - 外部绑定的 value
*/
parseValueToTableData(val) {
try {
this.error = ''
let data = []
// 1. 如果是字符串,解析为 JSON 数组
if (typeof val === 'string') {
data = val.trim() ? JSON.parse(val) : []
}
// 2. 如果是数组,直接使用
else if (Array.isArray(val)) {
data = [...val]
}
// 校验是否为数组
if (!Array.isArray(data)) {
throw new Error('数据必须是 JSON 数组')
}
// 3. 赋值并确保空行
this.tableData = data
this.ensureEmptyRow()
} catch (e) {
this.error = 'JSON 解析失败:' + e.message
this.tableData = []
this.ensureEmptyRow()
}
},
/**
* 确保表格最后一行是空行
*/
ensureEmptyRow() {
if (this.tableData.length === 0) {
// 无数据时添加空行
this.tableData.push(this.createEmptyRow())
} else {
const lastRow = this.tableData[this.tableData.length - 1]
// 最后一行非空时,添加空行
if (this.isRowValid(lastRow)) {
this.tableData.push(this.createEmptyRow())
}
}
},
/**
* 创建空行(字段与列配置一致)
*/
createEmptyRow() {
const emptyRow = {}
this.columns.forEach(col => {
emptyRow[col.prop] = ''
})
return emptyRow
},
/**
* 判断行是否有效(非空行)
* @param {Object} row - 表格行数据
*/
isRowValid(row) {
return this.columns.some(col => {
const val = row[col.prop]
return val !== '' && val !== null && val !== undefined
})
},
/**
* 判断是否是最后一行空行(用于禁用删除按钮)
* @param {Number} index - 行索引
*/
isLastEmptyRow(index) {
return index === this.tableData.length - 1 && !this.isRowValid(this.tableData[index])
},
/**
* 单元格内容变化(仅更新表格数据,触发防抖更新)
*/
handleCellChange() {
// 确保始终保留一个空行
this.ensureEmptyRow()
// 触发防抖更新跨input输入时重置同一个定时器
this.triggerDebounceChange()
},
/**
* 任意input聚焦标记全局聚焦状态
*/
handleInputFocus() {
this.hasFocus = true
// 聚焦时清除防抖定时器(用户还在输入,不触发更新)
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = null
}
},
/**
* 任意input失焦延迟判断是否真的无聚焦避免切换input误触发
*/
handleInputBlur() {
// 延迟 100ms 检查聚焦状态切换input时新input的focus会先触发
setTimeout(() => {
try {
// 1. 获取表格内所有 el-input 的原生 input 元素
const tableEl = this.$refs.tableContainer;
if (!tableEl) {
this.hasFocus = false;
return;
}
const inputs = tableEl.querySelectorAll('.el-input__inner'); // 精准匹配 Element UI 输入框
console.log('所有输入框:', inputs)
// 2. 判断是否有任意输入框处于聚焦状态
const hasAnyFocus = Array.from(inputs).some(input => {
// 核心:判断当前输入框是否是文档的激活元素
console.log('当前激活元素:', document.activeElement === input)
return input === document.activeElement;
});
// 3. 只有无任何输入框聚焦时,才标记全局失焦
this.hasFocus = hasAnyFocus;
} catch (e) {
// 异常兜底:标记为失焦
this.hasFocus = false;
}
}, 100);
},
/**
* 删除指定行
* @param {Number} index - 行索引
*/
handleDeleteRow(index) {
if (this.isLastEmptyRow(index)) return // 空行不可删
this.tableData.splice(index, 1)
this.ensureEmptyRow() // 删除后重新检查空行
// 删除后立即触发更新(用户主动操作,无需防抖)
this.emitUpdate()
},
/**
* 防抖触发更新:仅当「无输入框聚焦 + 防抖超时」时触发
*/
triggerDebounceChange() {
// 若还有输入框聚焦,直接返回(用户还在输入)
if (this.hasFocus) return
// 清除原有定时器(重置计时)
if (this.debounceTimer) clearTimeout(this.debounceTimer)
// 设置新定时器,延迟触发更新
this.debounceTimer = setTimeout(() => {
this.emitUpdate()
this.debounceTimer = null // 清空定时器标识
}, this.debounceDelay)
},
/**
* 统一触发更新父组件的逻辑
*/
emitUpdate() {
// 触发 input双向绑定和 change通知父组件
this.$emit('input', this.formattedJson)
this.$emit('change', this.formattedJson)
}
}
}
</script>
<style scoped>
.json-table-input {
width: 100%;
box-sizing: border-box;
}
.error-tip {
color: #f56c6c;
font-size: 12px;
margin-bottom: 8px;
padding: 4px 8px;
background: #fef0f0;
border-radius: 4px;
display: flex;
align-items: center;
}
.error-tip i {
margin-right: 4px;
}
.table-container {
margin-bottom: 12px;
}
.json-preview {
background: #f5f7fa;
padding: 8px;
border-radius: 4px;
font-size: 12px;
}
.json-preview .preview-label {
color: #606266;
margin-bottom: 4px;
}
.json-preview pre {
margin: 0;
color: #303133;
white-space: pre-wrap;
word-wrap: break-word;
}
</style>