---
title: Table 表格
description: 高性能虚拟滚动表格,支持排序、固定列、多选、行内编辑、列宽 / 列序拖拽与行拖拽排序
---
# Table 表格
`Table` 用于展示结构化数据,底层是 [`stk-table-vue`](https://ja-plus.github.io/stk-table-vue/),虚拟滚动 / 列宽拖拽 / 列序拖拽 / 区域选择 / 行拖拽排序都由它提供;组件负责**性能门控**、**内部列管理**与**行内编辑态**。
## 何时使用
- 数据量大(成百上千行)且需要滚动流畅时;
- 需要行内编辑、多选批量操作、列宽 / 列序可调时;
- 数据量很小、只要一张静态表时,原生 `
` 或 [Grid](/ui/general/grid) 更轻。
## 基础用法
给 `columns` 与 `dataSource` 两份数据即可。序号列(`seq`)默认开启,边框(`bordered`)默认有。
## 固定列
列上写 `fixed: 'left' | 'right'` 即可固定(底座用 `position: sticky` 实现,非双表格叠加)。三件需要知道的事:
- **序号列与选择列已经固定在左侧**,不需要业务再配;业务列的 `fixed: 'left'` 会自动接在它们后面,`fixed: 'right'` 的列要排在 `columns` 最后。
- **固定列必须写 `width`**:左固定列的吸附位移由「它左侧那些固定列的列宽累加」算出,缺宽度就没有位移(右固定列若不需要吸附效果可以不写,但统一写上最省心)。
- **固定列的边界只剩阴影了**:表体的纵向格线已关闭(表头仍保留列界,见「实现说明」),冻结列与滚过去的列底色同为白、又紧贴在一起时,`fixedColShadow`(默认关)那 10px 渐变是唯一的边界线索。它不影响功能,但有左右冻结列时务必打开。
固定列只有在**总列宽超过容器宽**时才看得出效果 —— 整表都看得见时,固定与不固定没有任何区别。
固定列的底色是**不透明的表格基色**(否则横滚时下方内容会透上来),行 hover / 当前行高亮改用内阴影叠色;这层叠色与**单元格选中描边并存**(选中框不会被行色替换掉),正在编辑的那一格则与普通列一致 —— 纯基色 + 焦点环,不叠行色。
同理还有**斑马纹**:开了 `stripe` 时,固定列(含序号列)也会跟着条纹 —— 冻结列的底色必须不透明,组件在它上面补了一层同色内阴影把条纹接上(否则就是「整表隔行灰、只有序号列没有」)。这层处理对**宿主页自己的行底色样式**(例如文档站的正文斑马纹)同样有效。
### ⚠ 固定列 + 横向虚拟的已知限制
同时开**固定列**与**横向虚拟**(列数 > 20 且显式要 `virtual-x`)时,**快速横向拖动**(拖滚动条、触控板甩)会出现一帧位移:被虚拟掉的列在表体左侧留了一个宽度可变的占位,而它是**异步**更新的(库内部用 rAF)。滚动已经到位、占位还是旧宽度的那一帧里,固定列的静态位置被占位推着走 —— 而 `position: sticky` 只能把格子往右收、不能把它往左拉回来,于是**左固定列**先偏一下再吸回去(实测偏 258 → 1628,等于占位全长);右固定列偏差为 0,所以现象只出现在左侧。
这是「占位 + sticky」这套布局的固有代价,组件这层无法在绘制前把它抹平(试过在 scroll 里提前重算窗口:无效,且会让表体多出中间态)。两条出路:
- 要**绝对稳**:别让二者同时存在 —— `:virtual-x="false"`(列数不多时没有任何代价);
- 要**根治**:需要把左占位挪到固定列之后(上游 `stk-table-vue` 的行布局改动)。
```vue
```
## 虚拟滚动与性能
组件内置两条门控,**自动**决定是否启用虚拟滚动,不需要业务手动切换:
| 方向 | 阈值 | 低于阈值时 | 原因 |
| --- | --- | --- | --- |
| 纵向 | 行数 > 100 | 全量渲染 | 库的虚拟滚动在「行数略多于单屏行数」时会把最后几行算出视口外,必须滚动才可见;行数少时全量渲染没有性能代价,却换来确定性正确的渲染 |
| 横向 | 列数 > 20 | 关掉横向虚拟 | 库在横向虚拟开启时会把每列宽度钉死为 `minWidth ?? width ?? 100`,未设 `width` 的列会失去「按比例铺满容器」的原生自适应 |
可以用 `virtual` / `virtualX` 关掉,但**不能**把它们强制打开到阈值以下。
除了门控,还有三处性能相关设计:
- **无编辑行时不注入 `customCell`**:纯展示列与非编辑态的可编辑列走库的原生 `td` 渲染路径,滚动时每格少一个函数式组件实例;
- **尺寸变化 150ms 防抖**:折叠动画 / 窗口缩放期间 ResizeObserver 每帧都会通知,逐帧重算会强制 reflow 并重渲染可见行列;组件合并到尺寸稳定后只刷新一次,并区分「仅宽度变化」只重算横向;
- **选择列用原生 ``**:视觉由 CSS 模拟成 [Checkbox](/ui/form/checkbox) 的外观,但每行不创建组件实例。
## 多选与批量操作
传 `rowSelection` 即在最前注入选择列;`getCheckboxProps` 可禁用个别行(禁用行不计入「全选」的分母)。
## 排序
列上写 `sorter: true` 即可点表头排序(组件内排序,不请求接口)。`sortType` 决定按数字还是字符串比较,默认字符串;需要自定义规则时 `sorter` 也可以传函数。
## 行内编辑
列上加 `editor` 即可编辑:**单击**单元格进入(不需要双击)。完整键位见下方「键盘:单击即编辑,方向键分工」。
### 跳格
Tab / Shift+Tab 只在**可编辑格**之间走动:没有 `editor` 的列、以及被 `disabled` 按行禁用的格都会被跳过;行尾自动接到下一行的第一个可编辑格,到表格两头则**循环**(Excel / AG Grid 的手感)。全表没有任何可编辑格时不接管,Tab 保持浏览器默认行为。
为什么不直接交给浏览器的 Tab 序:
- 编辑态的编辑器**只挂在当前编辑行**上(其它行是纯文本,不可聚焦),浏览器按文档顺序在表格里找不到「下一格」,会把焦点送到页面其它元素上 —— 表现出来就是「按了 Tab 但没选中下一个」;
- `select` 更明显:它展开时会把 DOM 焦点移进**下拉浮层**,而浮层是 `Teleport` 到 `body` 的,浮层里的按键事件到不了表格。所以表格在 `document` 的捕获阶段接管 Tab,并用 ARIA 关联(浮层 id ↔ 触发器 `aria-controls`)把浮层追回它所属的单元格。
还有两种「焦点此刻不在编辑器上,但 Tab 仍属于表格」的情况,同样会被接管:
- **编辑行 / 编辑列被虚拟滚动滚出渲染窗口**(编辑器随之卸载、焦点掉到 `body`):这时按 Tab 会把表格**滚回编辑行**并继续前进一格,而不是把焦点送出表格(放行的话浏览器会按文档顺序落到页面上其它元素上)。判据是「编辑格此刻不在 DOM 里」——编辑格还在渲染时(例如点了页面空白)不接管,页面 / 表单的 Tab 序不受影响。
- **长按 Tab / 连按 Tab**(`keydown` 带 `repeat`,或间隔小于底座 100ms 的连按防抖):底座的导航优化在长按期间会**把编辑器全卸载**(连「当前编辑行」都置空,等松手再挂回来),焦点因此落在 `body`。这时 Tab 仍然被认作表格的,一格一格继续走,松手后编辑器落到最后一格。从**只读列**起步(如「订舱号」,没有编辑器)也走这条路径。
| 编辑器类型 | 说明 |
| --- | --- |
| `text` / `number` / `password` / `textarea` | 用 [Input](/ui/form/input) 渲染 |
| `select` | 用 [Select](/ui/form/select) 渲染;`options` 可以是数组,也可以是接收当前行的函数 |
| `checkbox` | 点击即切换,无需先激活整行;约定 `1` = 选中、`0` = 未选中(与后端 tinyint 对齐)。切换后**该格同时成为当前格**(行高亮 + 选中框 + 进入该行编辑),与点击其它单元格行为一致 |
编辑器的输入值先写进内部草稿,**失焦才落库并抛出 `change`**。这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。用 `editable={false}` 可整体禁用编辑。
### 键盘:单击即编辑,方向键分工
**单击单元格即进入编辑**(挂上编辑器并自动聚焦,可以直接打字;同行切列时重新挂载并全选,输入即替换整格内容)。这是本组件的既有交互:少一次点击。
在这个前提下,方向键必须**显式分工** —— 否则会全部被输入框吃掉,人在表格里走不动:
| 按键 | 归谁 | 为什么 |
| --- | --- | --- |
| **左右** | 输入框(移动光标 / `Shift+左右` 选中文本) | 编辑文本的刚需:想改中间一位、想选中再覆盖 |
| **上下**(单行) | **表格**(切到上 / 下一行的同一列,并进入编辑) | 单行输入框里上下键**没有任何原生用途**,让出去是零损失 |
| `Shift+上下` | 输入框(选中到开头 / 结尾) | 原生的有效操作 |
| **上下**(`textarea`) | 输入框(跨行移动光标) | 多行里那是有效操作 |
其它键:
| 按键 | 行为 |
| --- | --- |
| `Enter`(单行) | 确认 + 移到下一行同列(表尾即停,不循环) |
| `Enter`(`textarea`) | 换行(浏览器原生语义) |
| `Esc` | 放弃本次编辑(回滚到进入前的值),并**把焦点还给该格** —— 可以接着用上下键走 |
| `Tab` / `Shift+Tab` | 确认并跳到下一个**可编辑**格(跳过只读列与按行禁用的格、行尾自动接下一行、两端循环) |
| `Space`(`checkbox`) | 切换勾选(原生 checkbox 的切换键);`Enter` 同样有效 —— 连续录入时手不用离开 Enter |
| 失焦(点别处 / 用上下键走开) | **提交**并抛出 `change` |
⚠️ **左右键不能让给表格**:它一旦用来切列,就没法在输入框里移动光标 —— 同一个键不可能既移光标又切格。横向切换请用 `Tab`(它还会跳过只读列,比按列 ±1 更贴合录入语境)。
### 编辑器形态:单元格内无外间距、无常驻边框
默认(`editorSeamless`)下编辑器的**外间距为 0、自身不画常驻边框**,整格铺满、融进表格:
- 适合整行编辑、键盘连续录入、固定列宽、专业用户(Excel / AG Grid / Handsontable 的表格态);
- 单元格几何在进入 / 离开编辑时**完全不变**——编辑器的盒高就等于 `rowHeight`,同一行的纯文本列不会跟着跳;
- 水平内边距由单元格统一提供(编辑器和纯文本格用同一套网格),**进出编辑态零位移**;
- 视觉提示只在**焦点、hover 与校验**时出现,且都不占用布局空间:
- 焦点 → 单元格内嵌的主色焦点环(内阴影)+ 输入框自身的焦点光晕;**正在编辑的那一格始终有环**(判据是编辑器上的标记类,而不是 `:focus-within`——`select` 展开时焦点会被移进浮层,用 `:focus-within` 会时有时无);
- hover → 格内极淡填充;
- 校验(`status` = `error` / `warning`)→ 文字色 + 1px 内描边。
```vue
```
两种形态的**编辑器盒高都等于 `rowHeight`**,所以切换真值不会改变行高。`textarea` 编辑器在默认的 30px 行里放不下正文,需要显式给足 `row-height`。
### ⚠ 数据源必须是响应式的
**行对象必须能被 Vue 追踪**,否则编辑回写不会触发重渲染。典型的踩坑写法是用 `computed` 派生数据:
```ts
// ❌ 错:computed 返回的是普通对象数组,行字段不可追踪
const rows = computed(() => Array.from({ length: n.value }, (_, i) => makeRow(i)))
// ✅ 对:ref 会递归代理内部对象
const rows = ref([])
watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) }, { immediate: true })
```
用错时的症状**极具迷惑性**,不容易联想到数据源:
- `change` 事件**照常抛出**(数据其实已经改了,父级能拿到)
- 但界面上**仍显示旧值**
- 点一下别处(触发无关重渲染)才「突然」更新
- 尤其是 `select` 编辑器:选完选项下拉关了、值却没变,很像组件的事件时序 bug
开发模式下,进入编辑时会检测并打印一条警告指明改法(每个组件实例只警告一次)。
### 不可改的格子(灰底,常驻)
在**支持行内编辑的表**里:凡是「现在改不了」的格子,都铺一层灰底 + 文字降一档亮度,
不需要业务额外配任何东西、也不加锁图标。三种情形统一走这套视觉:
| 情形 | 怎么配 | 何时变灰 |
| --- | --- | --- |
| 整列只读 | 列上**没有** `editor` | 始终 |
| 整列禁用 | `editor.disabled: true` | 始终 |
| 按行禁用 | `editor.disabled: (row) => boolean` | 该行判定为 true 时(动态) |
**前提:这张表里得先有人能编辑。** 灰底的语义是「这一格现在改不了」,它靠「旁边有可改的格子」传达意义;所以下面两种情况**不铺灰**:
- 表格里**一个可编辑列都没有**(纯展示表:列上都没写 `editor`)—— 每格都改不了时,灰底不指向任何东西,铺满反而让整张表看上去像「禁用状态」;
- `editable="false"`(编辑功能整体关掉)—— 同理,此时每一格都不可编辑。
按行禁用是最常用的那种:「已放舱之后状态不许再改」这类条件随行数据变化:
```ts
{
dataIndex: 'status',
title: '状态',
editor: { type: 'select', options: STATUS_OPTIONS, disabled: (row) => row.status === 2 },
}
```
几件需要知道的事:
- 判定是**行数据的派生值**,每次渲染重算 —— 数据变了灰底自己消失 / 出现,业务不用管;
- 整列只读 / 整列禁用的列**不会**被注入 `customCell`(走 td 上的类名),零运行时开销;
只有「按行禁用」的列会常驻注入,代价落在这些列上;
- 「既禁用、又校验不过」的格子显示为**灰**:既然改不了,「这里错了」提示了也没用;
- 灰底色是 `--td-bgc` 叠一层中性色(亮 6% / 暗 8%),亮 / 暗主题自动跟随。**它必须比斑马纹深一档**(斑马纹是 2%):表体没有纵向列线之后,两者都是一条横带,只剩浓度能区分「这行是条纹」和「这格改不了」。
下面这张 demo 里,第 2 行(状态=停用)的「备注」列就是**按行禁用**的灰格 —— 不点它也能看出来。
## 操作列
列上加 `actions` 渲染成一排小按钮;`type: 'more'` 的项会收进「更多」下拉。带 `confirm` 的操作先弹确认框,确认后才执行 `onClick`。
操作列建议显式写 `resizable: false`:它的宽度由内容决定,拖了不会落库,列一重建就丢。
## 列宽、列序与行拖拽
列宽 / 列序拖拽要求用 `v-model:columns` 绑定列定义——否则组件拖完无处回写(松手回弹),会直接关掉这两个功能。行拖拽排序用 `row-draggable`,顺序变化通过 `row-order-change` 回传。
## API
### Props
| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `columns` | `G3TableColumn[]` | `[]` | 列定义,配合 `v-model:columns` 接收拖拽后的回写 |
| `dataSource` | `T[]` | `[]` | 数据源 |
| `rowKey` | `string \| (row) => string \| number` | `'id'` | 行唯一键 |
| `virtual` | `boolean` | `true` | 纵向虚拟滚动总开关(实际是否开启受行数门控) |
| `virtualX` | `boolean` | `true` | 横向虚拟滚动总开关(实际是否开启受列数门控) |
| `rowHeight` | `number` | `30` | 行高,同时驱动虚拟滚动计算 |
| `headerRowHeight` | `number` | `34` | 表头行高 |
| `bordered` | `boolean` | `true` | 边框。`true` = 自绘圆角外框 + 行分隔线;`false` = 无外框,只留行分隔线(嵌进卡片 / 面板时用) |
| `stripe` | `boolean` | `false` | 斑马纹(隔行底色)。**固定列会一起上条纹**(见「固定列」):库只把条纹画在 `tr` 上,冻结列的底色必须不透明,组件在它上面补一层同色内阴影接上 |
| `fixedColShadow` | `boolean` | `false` | 固定列被滚动内容遮挡时画 10px 渐变阴影(见「固定列」) |
| `colResizable` | `boolean \| object` | `true` | 列宽拖拽;未绑定 `v-model:columns` 时自动关闭。可用 `{ disabled: (col) => boolean }` 按列禁用 |
| `columnDraggable` | `boolean \| object` | `true` | 列序拖拽;同上要求绑定 `v-model:columns` |
| `rowDraggable` | `boolean` | `false` | 行拖拽排序 |
| `rowSelection` | `TableRowSelection \| null` | `null` | 多选配置,见下 |
| `seq` | `boolean \| { title?, width? }` | `true` | 序号列,**固定左侧**(见「固定列」) |
| `activeRowKey` | `string \| number \| null` | `null` | 当前高亮行 |
| `editable` | `boolean` | `true` | 行内编辑总开关 |
| `editorSeamless` | `boolean` | `true` | 编辑器无缝嵌入单元格:格内无外间距、无常驻边框,只有焦点 / hover / 校验态才给视觉提示。传 `false` 回到留间距 + 自身描边的形态 |
| `noDataFull` | `boolean` | `true` | 空态铺满容器高度 |
| `showOverflow` | `boolean` | `true` | 内容溢出省略 |
| `autoFitWidth` | `boolean` | `false` | 非横向虚拟时铺满容器宽度(开启后关闭列宽拖拽) |
| `theme` | `'light' \| 'dark'` | — | 不传则跟随全局主题(`ConfigProvider` / `html[data-g3-theme]`) |
| `footerData` | `T[]` | `[]` | 表尾数据 |
| `footerConfig` | `object` | `` | 表尾配置 |
### 列定义 `G3TableColumn`
在 `stk-table-vue` 的列类型基础上扩展;下面只列自有字段,其余(`width` / `minWidth` / `fixed` / `align` / `headerAlign` / `sorter` / `sortType` / `children` / `mergeCells` 等)原样透传。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `dataIndex` | `keyof T & string` | 取值字段 |
| `title` | `string` | 表头文字 |
| `resizable` | `boolean` | 该列是否可拖宽;操作列 / 展开列建议设 `false` |
| `editor` | `TableEditor` | 行内编辑器,见下 |
| `actions` | `TableAction[]` | 操作列按钮 |
| `format` | `FormatNumberOptions \| ((value, row, col) => string)` | 展示态格式化,见「数值精度与金额」 |
| `customCell` | `(props) => VNode \| string` | 自定义单元格渲染(逃生舱),传了就不再自动包装 |
### `editor`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `type` | `'text' \| 'number' \| 'date' \| 'password' \| 'textarea' \| 'select' \| 'checkbox'` | 编辑器类型 |
| `format` | `string` | 日期格式(**只对 `date` 生效**),默认 `'YYYY-MM-DD'`。见「日期编辑器」 |
| `valueFormat` | `string` | 绑定值格式(只对 `date`),不传时跟随 `format` |
| `placeholder` | `string` | 占位文案(只对 `date`) |
| `precision` | `number \| ((row) => number)` | 数值精度(小数位数),**只对 `number` 生效**。见「数值精度与金额」 |
| `options` | `TableSelectOption[] \| ((row) => TableSelectOption[])` | select 选项,可依赖当前行其它字段 |
| `disabled` | `boolean \| ((row) => boolean)` | 是否禁用该格编辑 |
| `allowClear` | `boolean` | select 是否可清空,默认 `true` |
| `emptyValue` | `unknown` | select 清空后落库的值,默认 `null` |
| `onChange` | `(value, row, col) => void` | 本列值变化回调(早于组件的 `change`) |
| `validate` | `(value, row, col) => boolean \| string` | 校验规则:**返回 falsy 表示通过**,返回非空 string 表示不通过(该字符串即错误原因)。 |
| `errorStyle` | `'cell' \| 'inline'` | 校验失败的展示形态,默认 `'cell'`。见下 |
### 校验(`validate`)
给列配 `validate` 即可获得**实时校验**:
```ts
{
dataIndex: 'qty',
title: '采购数量',
editor: {
type: 'number',
// 返回 falsy = 通过;返回 string = 不通过,字符串即错误原因
validate: (value) => Number(value) > 0 || '采购数量必须大于 0',
},
}
```
三件需要知道的事:
- **返回值的语义是「falsy 通过 / string 不通过」**,不是「true 通过」。所以
`validate: (v) => Number(v) > 0 || '必须大于 0'` 这种最自然的写法是对的;
但若写成 `(v) => Number(v) > 0`(只返回布尔),则**永远不会报错**——
布尔 `true` 被当作「通过」、`false` 也被当作「通过」。要让布尔参与判错,
必须返回字符串。这一点容易踩,故单独说明。
- **校验是派生值,不是一次性状态**:每次渲染都按当前行数据重算,
所以用户把空值改成合法值后,红底在下次渲染就自动消失,
**不需要**业务手动清状态,组件内部也不维护任何 error 注册表。
- **校验函数抛错会被吞掉**并降级为「通过」(仅 DEV 打印警告)。
因为它在渲染期被调用,抛出去会让整格渲染失败,比「没有红底」严重得多。
#### 展示形态 `errorStyle`
| 值 | 效果 |
| --- | --- |
| `'cell'`(默认) | 单元格铺**浅红底 / 浅琥珀底** + **左侧 3px 语义色竖条**;**正在编辑的那一格例外**(见下) |
| `'inline'` | 只在编辑器自身画文字色与 1px 内描边,**不**铺底、也没有竖条 |
浅红底取自设计原型,适合 ERP 明细这类以单元格为视觉单位的场景;
若表格本身已经很密,用 `'inline'` 更克制。
#### 为什么还有一条左侧竖条
表体没有纵向格线(见「实现说明」),于是所有「格级」状态色都成了无边界的横带 —— 与**行级**的斑马纹 / hover / 当前行在形状上趋同,只剩色相一个通道,「这一格有问题」很容易被读成「这一段行有问题」。左侧 3px 的语义色竖条把形状还回来:**竖条 = 格级(校验),横带 = 行级(行状态)**。底色负责"远看一眼能发现",竖条负责"看得出是哪一格"。
它画在 `background-image` 上,而不是 `border` 或 `box-shadow`:
- `border` 会进盒模型,td 多几 px 就把被钉在 `var(--row-height)` 的行内编辑器顶高,而行高是虚拟滚动 `translateY` 的基准;
- `box-shadow` 是**单值**属性,在本组件里已被库的单元格选区描边、焦点环、固定列的 hover / active 叠色三家占用,谁赢谁把别人整条抹掉 —— 竖条再挤进去,就要在每一处手工合并。
竖条与底色同属 `errorStyle: 'cell'`;`'inline'` 形态两者都不要。编辑态让位时(正在编辑的那一格)竖条一并消失,与底色保持一致。
#### 失败原因怎么给用户看
`validate` / `validateWarning` 返回的那个字符串(「金额必须大于 0」)就是失败原因。**鼠标悬停到那一格上**会弹一个自绘浮层显示它 —— 悬停格内任意位置都行,不必精确对准文字;移开或滚动即收。
几件需要知道的事:
- 组件**不主动弹**提示、也不拦着你往下走:底色 + 竖条负责「让你发现哪几格有问题」,浮层负责「让你知道要改成什么」,按需查看。密集录入时这是刻意的 —— 每格都弹提示会变成干扰。
- 原生 `title` 只在**没有**校验原因时使用(它承担「内容太长被省略」的兜底)。有原因时会让位给浮层,免得两个提示框叠在一起。
- 无障碍:有原因时该格的 `aria-label` 会拼成「金额,金额必须大于 0」,屏幕阅读器读得到。
- 想要**保存时一次性列出所有问题**(ERP 的常见诉求),业务侧遍历 `dataSource` 自己调 `validate` 即可 —— 组件不提供 `validateAll()`,因为校验函数本来就归业务所有,包一层只是多一个 API 面:
```ts
const problems = rows.value.flatMap((row, i) =>
columns.value
.map((col) => ({ col, msg: col.editor?.validate?.(row[col.dataIndex!], row, col) }))
.filter((item) => typeof item.msg === 'string' && item.msg)
.map((item) => `第 ${i + 1} 行「${item.col.title}」${item.msg}`),
)
if (problems.length) message.warning(problems.join(';'))
```
**编辑态让位**(默认形态下的行为):正在编辑的那一格**不铺校验底色**,底色回到基色,校验信号只留**语义色焦点环 + 红字**(铺着底色时输入区被染色、对比度下降,录入时「看不清」)。离开该格后底色自动回来(校验本身是按行数据派生的,值改对了自然消失)。
> 早先这里还有一个右上角的 `!` 角标,**已整体去掉**(实际录入反馈):它画的 `!` 会压到文字上 —— 右对齐的数字列最明显(数字正好顶在角标下面),而为了给它让位留出的 14px 右边距又实打实缩窄了录入区。
### 数值精度与金额(`precision` / `format`)
金额、数量这类数值列通常有**固定的小数位**,而且各列不同(金额 2 位、数量 3 位、汇率 6 位)。组件把这件事拆成两半:
| 配置 | 管什么 |
| --- | --- |
| `editor.precision` | **数据的精度**(小数位数)—— 同时驱动「展示格式化」与「超精度校验」,一处真源 |
| `col.format` | **展示细节** —— 千分位、`¥` / `元`、万-亿缩写,或 `Intl.NumberFormat` 自定义函数 |
`format` 里没写 `decimals` 时取 `precision`;只有需要「显示位数 ≠ 数据精度」时才显式写它。
```ts
{
dataIndex: 'amount',
title: '金额',
width: 120,
align: 'right',
format: { thousands: true, prefix: '¥' }, // 展示细节
editor: {
type: 'number',
precision: 2, // 精度(可传函数按行给)
validate: (v) => Number(v) > 0 || '金额必须大于 0',
},
}
```
用户输入 `123.456` 时会发生什么(`precision: 2`):
| | 结果 |
| --- | --- |
| 格子 | 红底 + 左侧竖条;悬停浮层显示「最多 2 位小数」 |
| 显示 | `123.456` —— **不做格式化**(格式化成 `123.46` 会让屏幕合规、数据不合规) |
| 落库的值 | `123.456` —— **组件不改你的数据**,由用户自己改成 `123.46` |
三件需要知道的事:
- **不静默四舍五入**是刻意的:`123.456` 悄悄存成 `123.46`,财务对账时就是「我输的不是这个」。要「自动帮你砍」的场景,业务在 `change` 里自己 round 一次即可 —— 组件不替你做这个决定。
- **不做实时拦截**(敲到第 3 位小数就吞掉):那要和光标位置、输入法组合态、半输入态(`12.` / `-`)搏斗,是公认的坑;「输完再判」既确定又可预测。
- **超精度时不做格式化展示**:否则用户看到的是「合规的显示 + 不合规的数据」,永远发现不了自己错了。
**保存前要拦提交**,用导出的 `checkPrecision` —— 与组件内置的是**同一个函数**,规则只有一份:
```ts
import { checkPrecision } from '@g3soft/ui'
const problems = rows.value.flatMap((row, i) =>
columns.value.flatMap((col) => {
const editor = col.editor
if (!editor) return []
const precision =
typeof editor.precision === 'function' ? editor.precision(row) : editor.precision
const msg =
editor.validate?.(row[col.dataIndex!], row, col) ||
checkPrecision(row[col.dataIndex!], precision)
return typeof msg === 'string' && msg ? [`第 ${i + 1} 行「${col.title}」${msg}`] : []
}),
)
if (problems.length) message.warning(problems.join(';'))
```
**纯展示的金额列**(没有 `editor`)同样支持 `format`;而「小数位」这种数据层面的规则只属于可编辑列(`editor.precision`)。
### 日期编辑器(`type: 'date'`)
```ts
{ dataIndex: 'etd', title: '开航日', width: 140, editor: { type: 'date' } }
// 带时间精度:面板多出时间列,选完要点「确定」才提交
{ dataIndex: 'docClose', title: '截单日', width: 140,
editor: { type: 'date', format: 'YYYY-MM-DD HH:mm' } }
```
- **点格即弹日历**,不必先点输入框(点格的点击落在 `td` 上,组件会主动展开面板);也可以直接手输 `2026-03-15`。
- **值始终是字符串**,不做 `Date` ↔ string 的隐式转换。面板精度与绑定值格式都由 `format` 决定(默认 `YYYY-MM-DD`);界面格式与存储格式不同时写 `valueFormat`(如界面 `2026-03-15`、接口要 `20260315`)。
- 它是**唯一「只给当前格挂编辑器」**的类型:切换格时新格重新挂载,日历才会跟着弹开。其余类型在行编辑态下整行都挂编辑器。
- 校验、失败原因浮层、禁用灰底、`errorStyle` 与其它编辑器**完全一致**:日期列配了 `validate` 就照常出红底 + 竖条 + 悬停原因。
- **手输解析不出来时不丢弃**:原文照常写进行数据,并走**组件内置**的日期校验 —— 红底 + 竖条 + 悬停「日期格式不正确」。这与「金额输 0」「数量超精度」是同一套立场:值不合法不等于要把它抹掉,而是要让人看见、并知道改成什么。想拦提交,用导出的 `checkDate(value, format)`,它与组件内置判定**是同一个函数**。
- 判定口径**跟随组件**(不是另立一套):`2026/03/15` 这种分隔符会被规范化、算合法;而配了时间精度的列只给到「日」算**不合法**(要写全 `2026-03-15 00:00`,或直接用面板点选)。
- 列宽建议 ≥ 140,否则日期加清除按钮会挤到省略。
- **焦点落在日历面板里**(打开即可用键盘选日期),想手输要先点一下输入框。
### `actions`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `key` | `string \| number` | 唯一键;`type: 'more'` 的子项用它做下拉 key |
| `label` | `string` | 文案 |
| `type` | `'default' \| 'danger' \| 'delete' \| 'more'` | 语义类型 |
| `icon` | `Component` | 图标组件 |
| `visible` | `boolean \| ((row) => boolean)` | 是否显示 |
| `disabled` | `boolean \| ((row) => boolean)` | 是否禁用 |
| `confirm` | `string \| object` | 二次确认;字符串为标题,对象支持 `title` / `description` / `okText` / `cancelText` / `okType` / `width` / `maskClosable` |
| `onClick` | `(row) => void` | 点击回调(带 `confirm` 时在确认后执行) |
| `items` | `TableAction[]` | `type: 'more'` 时的子项 |
### Events
| 名称 | 参数 | 说明 |
| --- | --- | --- |
| `update:columns` | `(columns)` | 列宽 / 列序拖拽后回写(内部列已剥离) |
| `update:selectedRowKeys` | `(keys)` | 勾选变化 |
| `change` | `(row, col, value)` | 行内编辑的值变化 |
| `row-order-change` | `(rows, sourceIndex, targetIndex)` | 行拖拽排序完成 |
| `area-selection-change` | `(ranges)` | 单元格选区变化(行内编辑依赖它) |
### Exposed
| 名称 | 类型 | 说明 |
| --- | --- | --- |
| `scrollToRow(rowKey)` | `Promise` | 滚动到指定行 |
| `editRow(rowKey, focusCol?)` | `Promise` | 滚动到指定行并进入编辑 |
| `initVirtualScroll()` / `refreshLayout()` | — | 手动重算虚拟滚动布局 |
| `getSelectedArea()` / `clearSelectedArea()` | — | 读取 / 清空单元格选区 |
| `setRowExpand(...)` | — | 展开 / 收起行(需配 `expand` 列) |
### Slots
| 名称 | 说明 |
| --- | --- |
| `empty` | 空态内容,默认显示「暂无数据」 |
| `expand` | 展开行内容(需列配置 `type: 'expand'`) |
## 实现说明
- **底层是 `stk-table-vue`**:虚拟滚动、列宽 / 列序拖拽、区域选择、行拖拽、二级表头、固定列、合并单元格都由它实现。组件不重复造这些轮子,只做门控与适配。
- **库样式已并入 `dist/style.css`**:库的 CSS 是独立文件(其 JS 里不含任何 `.css` import),组件 `@use` 了它,消费方不需要额外引一次第三方样式。
- 注意 SCSS 里**不能**写 `@use 'stk-table-vue/lib/style.css'`:带 `.css` 后缀时 Sass 不会内联,只原样输出一条 CSS `@import` 语句,产物里会留下一条浏览器解析不了的裸包路径。去掉后缀才会真正内联。
- **主题通过覆盖库的 CSS 变量实现**:库把 41 个主题值都做成了 `.stk-table` 作用域内的变量,组件把它们映射到 `--g3-*` 令牌(覆盖层在 `src/table/style/_overrides.scss`)。改变量而不是逐个选择器改样式,库升级时不容易失效。
- **区域选择按需自注册**:库的单元格区域选择是全局注册的(`registerFeature(useAreaSelection)`),不注册则 `area-selection-change` 永不触发、行内编辑失效。组件库不拥有 app 启动流程,因此**首次挂载时自动注册一次**;需要显式控制时可调用导出的 `registerTableFeatures()`。
- **`data-testid` 透传到内层**:组件 `inheritAttrs: false`,`$attrs` 落在内层 `StkTable` 上,所以写 E2E 时按类名 `.g3-table` 定位根节点。
- **边框:外框自绘,横线走底座的渐变,竖线一条都不画**。组件把 `bordered` 翻成底座的 `'v'` 档(只借它的横线),再自己补外框:
- **外框** → 外层容器上的真 `border` + 圆角令牌(`bordered: false` 时不描边);
- **横线** → 把 `'v'` 档清零的 `--bg-border-bottom` 配方拿回来;
- **竖线** → 表体一条不画,表头画**不占满高度的短竖线**:表体靠覆盖层把底座的 `--bg-border-right` / `--bg-border-left` 涂透明;表头则由 `th` 接管 `background-image`,画一条高 `calc(100% - 8px)`(上下各留 4px)、宽 1px 的线,颜色用比格线更轻的 `--g3-table-header-divider`(亮 8% 黑 / 暗 12% 白)。数据区因此没有纵向格线(固定列边界只剩 `fixedColShadow`,见「固定列」),表头仍一眼看得出列界与拖拽落点,又不至于顶满上下显得硬。
为什么不全交给底座:它的外框是 `border-left` + 三段背景渐变拼的,**做不出圆角**,且其右侧 / 底部边框会因滚动条占位而消失(底座 `table/basic/bordered` 文档明确说明)。
⚠️ 格线**不能**改写成真 `border`:行内编辑器被钉死在 `var(--row-height)`,单元格多出 1px 边线就会被顶高 1px,而行高是虚拟滚动 `translateY` 的计算基准。背景渐变不参与盒模型。
颜色只有 `--g3-border-color` 一个入口,亮 / 暗主题自动跟随。
- **`--th-bgc` 等表头/表尾底色**由覆盖层的变量映射提供,不通过行内 style 注入。
- **行编辑态存在 `WeakMap` 里**:按行对象存,不给业务数据打标记;行被回收时状态自动释放。行与行之间切换编辑**不翻转** `editingActive`,避免整表列重建。
- **编辑器无缝嵌入单元格**(`editorSeamless`):把 `G3Input` / `G3Select` 自带的 `padding` / `border` / `border-radius` / `background` 置零,只保留焦点、hover 与校验提示;编辑器的高度由组件显式钉成 `var(--row-height)`(不能被控件的 `min-height: 32px` 顶高,也不能靠 `height: 100%` —— td 的内容盒不等于行高),因此**编辑前后的行高完全一致**。焦点环画在外层 `td` 的 `inset` 阴影上:不参与盒模型计算,几何零影响。
- **RTL**:布局用逻辑属性,带符号位移走 `--g3-dir-sign`;分隔条拖拽与键盘方向由库的 `useDirection` 处理。