编辑
vtable-guild 通过 bodyCell 插槽支持单元格编辑和整行编辑。表格负责稳定地渲染列、行与单元格,业务代码负责草稿、校验和提交,这样编辑器可以直接使用项目里已有的表单控件。
单元格编辑
点击任意单元格进入编辑。输入内容先留在独立草稿中,按 Enter 或移开焦点提交,按 Escape 放弃。Enter 处理会避开输入法的候选词阶段。
vue
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import { VTable } from '@vtable-guild/vtable-guild'
import type { TableColumnsType } from '@vtable-guild/vtable-guild'
interface MemberRow {
key: string
name: string
team: string
role: string
}
type EditableField = 'name' | 'team' | 'role'
interface CellDraft {
rowKey: string
field: EditableField
value: string
}
const columns: TableColumnsType<MemberRow> = [
{ title: '成员', dataIndex: 'name', key: 'name', width: 160 },
{ title: '团队', dataIndex: 'team', key: 'team', width: 140 },
{ title: '职责', dataIndex: 'role', key: 'role' },
]
const dataSource = ref<MemberRow[]>([
{ key: 'member-001', name: '陈嘉', team: '平台', role: '前端开发' },
{ key: 'member-002', name: '林悦', team: '交易', role: '产品设计' },
{ key: 'member-003', name: '周野', team: '数据', role: '数据分析' },
])
const draft = ref<CellDraft | null>(null)
const editor = ref<HTMLInputElement | null>(null)
const composing = ref(false)
function isEditableField(value: unknown): value is EditableField {
return value === 'name' || value === 'team' || value === 'role'
}
function isEditing(rowKey: string, dataIndex: unknown) {
return (
isEditableField(dataIndex) && draft.value?.rowKey === rowKey && draft.value.field === dataIndex
)
}
async function startEditing(record: MemberRow, dataIndex: unknown) {
if (!isEditableField(dataIndex)) return
draft.value = {
rowKey: record.key,
field: dataIndex,
value: record[dataIndex],
}
await nextTick()
editor.value?.focus()
editor.value?.select()
}
function updateDraft(event: Event) {
if (draft.value) draft.value.value = (event.target as HTMLInputElement).value
}
function commitEditing(rowKey: string, dataIndex: unknown) {
const current = draft.value
if (!current || !isEditableField(dataIndex)) return
if (current.rowKey !== rowKey || current.field !== dataIndex) return
dataSource.value = dataSource.value.map((record) =>
record.key === rowKey ? { ...record, [dataIndex]: current.value } : record,
)
draft.value = null
composing.value = false
}
function cancelEditing() {
draft.value = null
composing.value = false
}
function handleEditorKeydown(event: KeyboardEvent, rowKey: string, dataIndex: unknown) {
if (event.key === 'Enter') {
if (event.isComposing || composing.value) return
event.preventDefault()
commitEditing(rowKey, dataIndex)
} else if (event.key === 'Escape') {
event.preventDefault()
cancelEditing()
}
}
function replaceAndReverseRows() {
dataSource.value = dataSource.value
.slice()
.reverse()
.map((record) => ({ ...record }))
}
</script>
<template>
<div class="editing-demo">
<div class="editing-demo__toolbar">
<span>数据版本 {{ dataSource.map((record) => record.key).join(' / ') }}</span>
<button
type="button"
class="editing-demo__toolbar-action"
data-testid="replace-rows"
@mousedown.prevent
@click="replaceAndReverseRows"
>
倒序并替换数据
</button>
</div>
<VTable row-key="key" :columns="columns" :data-source="dataSource" bordered hoverable>
<template #bodyCell="{ column, record, text }">
<template v-if="isEditableField(column.dataIndex)">
<input
v-if="isEditing(record.key, column.dataIndex)"
ref="editor"
class="editing-demo__field"
:data-editor="`${record.key}:${String(column.dataIndex)}`"
:value="draft?.value ?? ''"
@input="updateDraft"
@blur="commitEditing(record.key, column.dataIndex)"
@compositionstart="composing = true"
@compositionend="composing = false"
@keydown="handleEditorKeydown($event, record.key, column.dataIndex)"
/>
<button
v-else
type="button"
class="editing-demo__value"
:data-cell="`${record.key}:${String(column.dataIndex)}`"
@click="startEditing(record, column.dataIndex)"
>
{{ text }}
</button>
</template>
<template v-else>{{ text }}</template>
</template>
</VTable>
</div>
</template>
<style scoped>
.editing-demo {
min-width: 0;
}
.editing-demo__toolbar {
display: flex;
min-height: 32px;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
color: var(--color-muted);
font-size: 12px;
}
.editing-demo__toolbar-action,
.editing-demo__value {
border: 0;
color: inherit;
font: inherit;
background: transparent;
cursor: pointer;
}
.editing-demo__toolbar-action {
flex: none;
padding: 4px 8px;
border: 1px solid var(--color-border);
border-radius: var(--vtg-btn-border-radius-sm);
color: var(--color-primary);
}
.editing-demo__toolbar-action:hover {
border-color: var(--color-primary);
}
.editing-demo__value {
display: block;
width: 100%;
min-height: 24px;
padding: 2px 4px;
border-radius: var(--vtg-input-border-radius);
overflow: hidden;
color: inherit;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.editing-demo__value:hover {
background: var(--color-control-item-hover-bg);
}
.editing-demo__value:focus-visible,
.editing-demo__toolbar-action:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
.editing-demo__field {
box-sizing: border-box;
width: 100%;
height: var(--vtg-input-height);
padding: 0 var(--vtg-input-padding-inline);
border: 1px solid var(--color-primary);
border-radius: var(--vtg-input-border-radius);
outline: 0;
color: var(--color-on-surface);
font: inherit;
background: var(--color-surface);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 14%, transparent);
}
@media (width <= 640px) {
.editing-demo__toolbar {
align-items: flex-start;
flex-direction: column;
}
}
</style>示例里的编辑状态由稳定的 rowKey 和列的 dataIndex 共同定位。点击“倒序并替换数据”会创建新的记录对象并调整顺序,正在编辑的单元格仍会跟随同一条记录,不会因为行索引变化而串行。
输入框维护本地 draft,不会在每次输入时直接改写 dataSource。这对表格重渲染尤其重要,可以避免输入过程中编辑器反复更新导致光标跳动。
整行编辑
整行编辑在进入编辑时复制一份行级草稿。多个字段修改完成后由“保存”一次性写回;“取消”只清理草稿,不会污染源记录。
vue
<script setup lang="ts">
import { ref } from 'vue'
import { VTable } from '@vtable-guild/vtable-guild'
import type { TableColumnsType } from '@vtable-guild/vtable-guild'
interface MemberRow {
key: string
name: string
team: string
role: string
}
type EditableField = 'name' | 'team' | 'role'
const columns: TableColumnsType<MemberRow> = [
{ title: '成员', dataIndex: 'name', key: 'name', width: 150 },
{ title: '团队', dataIndex: 'team', key: 'team', width: 130 },
{ title: '职责', dataIndex: 'role', key: 'role' },
{ title: '操作', key: 'actions', width: 136 },
]
const dataSource = ref<MemberRow[]>([
{ key: 'member-101', name: '苏晚', team: '设计系统', role: '交互设计' },
{ key: 'member-102', name: '何川', team: '平台', role: '前端开发' },
{ key: 'member-103', name: '顾言', team: '交易', role: '质量保障' },
])
const editingRowKey = ref<string | null>(null)
const rowDraft = ref<MemberRow | null>(null)
function isEditableField(value: unknown): value is EditableField {
return value === 'name' || value === 'team' || value === 'role'
}
function startEditing(record: MemberRow) {
editingRowKey.value = record.key
rowDraft.value = { ...record }
}
function draftValue(dataIndex: unknown) {
return rowDraft.value && isEditableField(dataIndex) ? rowDraft.value[dataIndex] : ''
}
function updateDraft(event: Event, dataIndex: unknown) {
if (!rowDraft.value || !isEditableField(dataIndex)) return
rowDraft.value[dataIndex] = (event.target as HTMLInputElement).value
}
function saveRow(rowKey: string) {
if (!rowDraft.value || editingRowKey.value !== rowKey) return
dataSource.value = dataSource.value.map((record) =>
record.key === rowKey ? { ...rowDraft.value! } : record,
)
cancelEditing()
}
function cancelEditing() {
editingRowKey.value = null
rowDraft.value = null
}
</script>
<template>
<VTable row-key="key" :columns="columns" :data-source="dataSource" bordered hoverable>
<template #bodyCell="{ column, record, text }">
<template v-if="editingRowKey === record.key && isEditableField(column.dataIndex)">
<select
v-if="column.dataIndex === 'team'"
class="row-editing-demo__field"
:data-row-field="`${record.key}:team`"
:value="draftValue(column.dataIndex)"
@change="updateDraft($event, column.dataIndex)"
>
<option>平台</option>
<option>交易</option>
<option>数据</option>
<option>设计系统</option>
</select>
<input
v-else
class="row-editing-demo__field"
:data-row-field="`${record.key}:${String(column.dataIndex)}`"
:value="draftValue(column.dataIndex)"
@input="updateDraft($event, column.dataIndex)"
/>
</template>
<template v-else-if="column.key === 'actions'">
<span v-if="editingRowKey === record.key" class="row-editing-demo__actions">
<button
type="button"
class="row-editing-demo__action row-editing-demo__action--primary"
:data-row-action="`${record.key}:save`"
@click="saveRow(record.key)"
>
保存
</button>
<button
type="button"
class="row-editing-demo__action"
:data-row-action="`${record.key}:cancel`"
@click="cancelEditing"
>
取消
</button>
</span>
<button
v-else
type="button"
class="row-editing-demo__action row-editing-demo__action--primary"
:data-row-action="`${record.key}:edit`"
@click="startEditing(record)"
>
编辑
</button>
</template>
<template v-else>{{ text }}</template>
</template>
</VTable>
</template>
<style scoped>
.row-editing-demo__field {
box-sizing: border-box;
width: 100%;
height: var(--vtg-input-height);
padding: 0 var(--vtg-input-padding-inline);
border: 1px solid var(--color-border);
border-radius: var(--vtg-input-border-radius);
outline: 0;
color: var(--color-on-surface);
font: inherit;
background: var(--color-surface);
}
.row-editing-demo__field:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 14%, transparent);
}
.row-editing-demo__actions {
display: inline-flex;
align-items: center;
gap: 10px;
}
.row-editing-demo__action {
padding: 2px 0;
border: 0;
outline: 0;
color: var(--color-muted);
font: inherit;
background: transparent;
cursor: pointer;
}
.row-editing-demo__action:hover,
.row-editing-demo__action--primary {
color: var(--color-primary);
}
.row-editing-demo__action:focus-visible {
border-radius: 2px;
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
</style>示例使用原生 input 和 select,因此可以同时运行在文档站和源码 Playground。实际项目里可原位替换成 a-input、el-input、Select、DatePicker 或已有的表单字段组件,表格不限制编辑器类型。
实现原则
- 始终设置
row-key="key",并按稳定行键保存编辑状态,不要使用渲染时的行索引。 - 用
dataIndex标识字段;提交时用行键查找记录,即使数据重排或替换也不会写到另一行。 - 单元格输入先写本地 draft,只在 Enter 或 blur 时提交到
dataSource。 - Enter 事件同时检查
event.isComposing和 composition 状态,避免中文输入法选词时提前保存。 - 整行编辑复制记录形成草稿,Save 时整体提交,Cancel 时直接丢弃。
能力边界
bodyCell 已经能承载单元格编辑和整行编辑,但 vtable-guild 当前不内置以下能力:
- 编辑状态管理和表单校验协议。
- 事务提交、撤销历史或服务端保存流程。
- Excel 式方向键、Tab 连续编辑和单元格选区。
如果这些能力是核心需求,建议在 bodyCell 上组合项目现有的表单方案;需要完整电子表格式编辑引擎时,vxe-table 等功能更完整的方案会更合适。
虚拟滚动
虚拟滚动只保留可视区附近的行。编辑中的单元格离开可视区后,编辑器组件可能被卸载;草稿只要按稳定行键存储就不会串到其他记录,但业务仍需明确离屏时是自动提交、取消,还是保留草稿等待该行再次出现。