This commit is contained in:
oneao committed 2026-10-15 22:47:42 +08:00
1 parent bf9dca19de
commit e7c84a82a2
31 files changed
+4063 -2362

No files matched your search

+152 -17
View File
@@ -25,7 +25,7 @@ description: 高性能虚拟滚动表格,支持排序、固定列、多选、
- **序号列与选择列已经固定在左侧**,不需要业务再配;业务列的 `fixed: 'left'` 会自动接在它们后面,`fixed: 'right'` 的列要排在 `columns` 最后。
- **固定列必须写 `width`**:左固定列的吸附位移由「它左侧那些固定列的列宽累加」算出,缺宽度就没有位移(右固定列若不需要吸附效果可以不写,但统一写上最省心)。
- 固定列被滚动内容遮住时才画阴影(`fixedColShadow`,默认关)。它不影响功能,但冻结列与滚过去的列底色同为白、又紧贴在一起,没有这层渐变就看不出边界在哪,业务侧建议打开。
- **固定列的边界只剩阴影了**:表体的纵向格线已关闭(表头仍保留列界,见「实现说明」),冻结列与滚过去的列底色同为白、又紧贴在一起时,`fixedColShadow`(默认关)那 10px 渐变是唯一的边界线索。它不影响功能,但有左右冻结列时务必打开。
固定列只有在**总列宽超过容器宽**时才看得出效果 —— 整表都看得见时,固定与不固定没有任何区别。
@@ -81,16 +81,7 @@ description: 高性能虚拟滚动表格,支持排序、固定列、多选、
## 行内编辑
列上加 `editor` 即可编辑:单击单元格进入。
| 按键 | 行为 |
| --- | --- |
| `Enter` | **全选当前内容**(便于直接覆写),不提交 |
| `Esc` | **取消编辑**:把该行恢复到进入编辑前的值,然后退出编辑 |
| `Tab` / `Shift+Tab` | **提交并跳到下一个 / 上一个可编辑格**(见下方「跳格」) |
| 失焦(点别处) | **提交**并抛出 `change` |
`Enter` 不做提交是刻意的:单元格里的值通常要整体替换,全选后直接输入即可,不必先手动清空。提交一律靠失焦。
列上加 `editor` 即可编辑:**单击**单元格进入(不需要双击)。完整键位见下方「键盘:单击即编辑,方向键分工」。
### 跳格
@@ -114,6 +105,32 @@ Tab / Shift+Tab 只在**可编辑格**之间走动:没有 `editor` 的列、
编辑器的输入值先写进内部草稿,**失焦才落库并抛出 `change`**。这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。用 `editable={false}` 可整体禁用编辑。
### 键盘:单击即编辑,方向键分工
**单击单元格即进入编辑**(挂上编辑器并自动聚焦,可以直接打字;同行切列时重新挂载并全选,输入即替换整格内容)。这是本组件的既有交互:少一次点击。
在这个前提下,方向键必须**显式分工** —— 否则会全部被输入框吃掉,人在表格里走不动:
| 按键 | 归谁 | 为什么 |
| --- | --- | --- |
| **左右** | 输入框(移动光标 / `Shift+左右` 选中文本) | 编辑文本的刚需:想改中间一位、想选中再覆盖 |
| **上下**(单行) | **表格**(切到上 / 下一行的同一列,并进入编辑) | 单行输入框里上下键**没有任何原生用途**,让出去是零损失 |
| `Shift+上下` | 输入框(选中到开头 / 结尾) | 原生的有效操作 |
| **上下**(`textarea`) | 输入框(跨行移动光标) | 多行里那是有效操作 |
其它键:
| 按键 | 行为 |
| --- | --- |
| `Enter`(单行) | 确认 + 移到下一行同列(表尾即停,不循环) |
| `Enter`(`textarea`) | 换行(浏览器原生语义) |
| `Esc` | 放弃本次编辑(回滚到进入前的值),并**把焦点还给该格** —— 可以接着用上下键走 |
| `Tab` / `Shift+Tab` | 确认并跳到下一个**可编辑**格(跳过只读列与按行禁用的格、行尾自动接下一行、两端循环) |
| `Space`(`checkbox`) | 切换勾选(原生 checkbox 的切换键);`Enter` 同样有效 —— 连续录入时手不用离开 Enter |
| 失焦(点别处 / 用上下键走开) | **提交**并抛出 `change` |
⚠️ **左右键不能让给表格**:它一旦用来切列,就没法在输入框里移动光标 —— 同一个键不可能既移光标又切格。横向切换请用 `Tab`(它还会跳过只读列,比按列 ±1 更贴合录入语境)。
### 编辑器形态:单元格内无外间距、无常驻边框
默认(`editorSeamless`)下编辑器的**外间距为 0、自身不画常驻边框**,整格铺满、融进表格:
@@ -187,7 +204,7 @@ watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) }
- 整列只读 / 整列禁用的列**不会**被注入 `customCell`(走 td 上的类名),零运行时开销;
只有「按行禁用」的列会常驻注入,代价落在这些列上;
- 「既禁用、又校验不过」的格子显示为**灰**:既然改不了,「这里错了」提示了也没用;
- 灰底用 `--td-bgc` 叠中性色得到,亮 / 暗主题自动跟随。
- 灰底色是 `--td-bgc` 叠一层中性色(亮 6% / 暗 8%),亮 / 暗主题自动跟随。**它必须比斑马纹深一档**(斑马纹是 2%):表体没有纵向列线之后,两者都是一条横带,只剩浓度能区分「这行是条纹」和「这格改不了」。
<demo vue="table/editable.vue" />
@@ -249,13 +266,18 @@ watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) }
| `resizable` | `boolean` | 该列是否可拖宽;操作列 / 展开列建议设 `false` |
| `editor` | `TableEditor<T>` | 行内编辑器,见下 |
| `actions` | `TableAction<T>[]` | 操作列按钮 |
| `format` | `FormatNumberOptions \| ((value, row, col) => string)` | 展示态格式化,见「数值精度与金额」 |
| `customCell` | `(props) => VNode \| string` | 自定义单元格渲染(逃生舱),传了就不再自动包装 |
### `editor`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `type` | `'text' \| 'number' \| 'password' \| 'textarea' \| 'select' \| 'checkbox'` | 编辑器类型 |
| `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` |
@@ -297,16 +319,129 @@ watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) }
| 值 | 效果 |
| --- | --- |
| `'cell'`(默认) | 单元格铺**浅红底 / 浅琥珀底**;**正在编辑的那一格例外**(见下) |
| `'inline'` | 只在编辑器自身画文字色与 1px 内描边,**不**铺底 |
| `'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`
| 字段 | 类型 | 说明 |
@@ -356,10 +491,10 @@ watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) }
- **主题通过覆盖库的 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'` 档(只借它的竖线),再自己补外框:
- **边框:外框自绘,横线走底座的渐变,竖线一条都不画**。组件把 `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` 一个入口,亮 / 暗主题自动跟随。