35 KiB
title, description
| title | description |
|---|---|
| Table 表格 | 高性能虚拟滚动表格,支持排序、固定列、多选、行内编辑、列宽 / 列序拖拽与行拖拽排序 |
Table 表格
Table 用于展示结构化数据,底层是 stk-table-vue,虚拟滚动 / 列宽拖拽 / 列序拖拽 / 区域选择 / 行拖拽排序都由它提供;组件负责性能门控、内部列管理与行内编辑态。
何时使用
- 数据量大(成百上千行)且需要滚动流畅时;
- 需要行内编辑、多选批量操作、列宽 / 列序可调时;
- 数据量很小、只要一张静态表时,原生
<table>或 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的行布局改动)。
<!-- 左冻结首列、右冻结末列 -->
<G3Table :columns="columns" :data-source="data" fixed-col-shadow />
虚拟滚动与性能
组件内置两条门控,自动决定是否启用虚拟滚动,不需要业务手动切换:
| 方向 | 阈值 | 低于阈值时 | 原因 |
|---|---|---|---|
| 纵向 | 行数 > 100 | 全量渲染 | 库的虚拟滚动在「行数略多于单屏行数」时会把最后几行算出视口外,必须滚动才可见;行数少时全量渲染没有性能代价,却换来确定性正确的渲染 |
| 横向 | 列数 > 20 | 关掉横向虚拟 | 库在横向虚拟开启时会把每列宽度钉死为 minWidth ?? width ?? 100,未设 width 的列会失去「按比例铺满容器」的原生自适应 |
可以用 virtual / virtualX 关掉,但不能把它们强制打开到阈值以下。
除了门控,还有三处性能相关设计:
- 无编辑行时不注入
customCell:纯展示列与非编辑态的可编辑列走库的原生td渲染路径,滚动时每格少一个函数式组件实例; - 尺寸变化 150ms 防抖:折叠动画 / 窗口缩放期间 ResizeObserver 每帧都会通知,逐帧重算会强制 reflow 并重渲染可见行列;组件合并到尺寸稳定后只刷新一次,并区分「仅宽度变化」只重算横向;
- 选择列用原生
<input type="checkbox">:视觉由 CSS 模拟成 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 渲染 |
select |
用 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 内描边。
- 焦点 → 单元格内嵌的主色焦点环(内阴影)+ 输入框自身的焦点光晕;正在编辑的那一格始终有环(判据是编辑器上的标记类,而不是
<!-- 回到「格内留一圈间距 + 自身描边」的悬浮卡片观感 -->
<G3Table :editor-seamless="false" :columns="columns" :data-source="data" />
两种形态的编辑器盒高都等于 rowHeight,所以切换真值不会改变行高。textarea 编辑器在默认的 30px 行里放不下正文,需要显式给足 row-height。
⚠ 数据源必须是响应式的
行对象必须能被 Vue 追踪,否则编辑回写不会触发重渲染。典型的踩坑写法是用 computed 派生数据:
// ❌ 错:computed 返回的是普通对象数组,行字段不可追踪
const rows = computed(() => Array.from({ length: n.value }, (_, i) => makeRow(i)))
// ✅ 对:ref 会递归代理内部对象
const rows = ref<Row[]>([])
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"(编辑功能整体关掉)—— 同理,此时每一格都不可编辑。
按行禁用是最常用的那种:「已放舱之后状态不许再改」这类条件随行数据变化:
{
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<T>[] |
[] |
列定义,配合 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<T>
在 stk-table-vue 的列类型基础上扩展;下面只列自有字段,其余(width / minWidth / fixed / align / headerAlign / sorter / sortType / children / mergeCells 等)原样透传。
| 字段 | 类型 | 说明 |
|---|---|---|
dataIndex |
keyof T & string |
取值字段 |
title |
string |
表头文字 |
resizable |
boolean |
该列是否可拖宽;操作列 / 展开列建议设 false |
editor |
TableEditor<T> |
行内编辑器,见下 |
actions |
TableAction<T>[] |
操作列按钮 |
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 即可获得实时校验:
{
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 面:
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;只有需要「显示位数 ≠ 数据精度」时才显式写它。
{
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 —— 与组件内置的是同一个函数,规则只有一份:
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')
{ 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<T>[] |
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<void> |
滚动到指定行 |
editRow(rowKey, focusCol?) |
Promise<void> |
滚动到指定行并进入编辑 |
initVirtualScroll() / refreshLayout() |
— | 手动重算虚拟滚动布局 |
getSelectedArea() / clearSelectedArea() |
— | 读取 / 清空单元格选区 |
setRowExpand(...) |
— | 展开 / 收起行(需配 expand 列) |
Slots
| 名称 | 说明 |
|---|---|
empty |
空态内容,默认显示「暂无数据」 |
expand |
展开行内容(需列配置 type: 'expand') |
实现说明
- 底层是
stk-table-vue:虚拟滚动、列宽 / 列序拖拽、区域选择、行拖拽、二级表头、固定列、合并单元格都由它实现。组件不重复造这些轮子,只做门控与适配。 - 库样式已并入
dist/style.css:库的 CSS 是独立文件(其 JS 里不含任何.cssimport),组件@use了它,消费方不需要额外引一次第三方样式。- 注意 SCSS 里不能写
@use 'stk-table-vue/lib/style.css':带.css后缀时 Sass 不会内联,只原样输出一条 CSS@import语句,产物里会留下一条浏览器解析不了的裸包路径。去掉后缀才会真正内联。
- 注意 SCSS 里不能写
- 主题通过覆盖库的 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处理。