Files
workspace/code/g3soft-libs/docs/content/ui/data/table.md
T
2026-10-15 22:47:42 +08:00

35 KiB
Raw Blame History

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 里不含任何 .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 处理。