This commit is contained in:
oneao committed 2026-10-17 17:17:39 +08:00
1 parent 025d3232c4
commit 1d7d75eeba
98 files changed
+4144 -12063

No files matched your search

@@ -176,7 +176,6 @@ export default defineConfig({
{ text: 'DatePicker 日期选择', link: '/ui/data/date' },
{ text: 'Pagination 分页器', link: '/ui/data/pagination' },
{ text: 'Splitter 分割面板', link: '/ui/data/splitter' },
{ text: 'Table 表格', link: '/ui/data/table' },
{ text: 'Tabs 标签页', link: '/ui/data/tabs' },
{ text: 'Tree 树形控件', link: '/ui/data/tree' },
],
@@ -1,286 +0,0 @@
---
title: Table 表格
description: 高性能虚拟滚动表格,支持排序、固定列、多选、行内编辑、列宽 / 列序拖拽与行拖拽排序
---
# Table 表格
`Table` 用于展示结构化数据,底层是 [`stk-table-vue`](https://ja-plus.github.io/stk-table-vue/),虚拟滚动 / 列宽拖拽 / 列序拖拽 / 区域选择 / 行拖拽排序都由它提供;组件负责**性能门控**、**内部列管理**与**行内编辑态**。
> 本组件与 `fms-vue` 的 `FmsTable` **同源同构**(实现照抄,仅替换命名与令牌体系),
> 两边行为一致、可互相迁移。迁移时需要的改动见文末「从旧版迁移」。
## 何时使用
- 数据量大(成百上千行)且需要滚动流畅时;
- 需要行内编辑、多选批量操作、列宽 / 列序可调时;
- 数据量很小、只要一张静态表时,原生 `<table>` 或 [Grid](/ui/general/grid) 更轻。
## 基础用法
给 `columns` 与 `dataSource` 两份数据即可。序号列(`seq`)默认开启,边框(`bordered`)默认有。
<demo vue="table/basic.vue" />
## 固定列
列上写 `fixed: 'left' | 'right'` 即可固定(底座用 `position: sticky` 实现,非双表格叠加)。三件需要知道的事:
- **序号列与选择列已经固定在左侧**,不需要业务再配;业务列的 `fixed: 'left'` 会自动接在它们后面,`fixed: 'right'` 的列要排在 `columns` 最后。
- **固定列必须写 `width`**:左固定列的吸附位移由「它左侧那些固定列的列宽累加」算出,缺宽度就没有位移。
- 固定列只有在**总列宽超过容器宽**时才看得出效果 —— 整表都看得见时,固定与不固定没有任何区别。
```vue
<!-- 左冻结首列、右冻结末列 -->
<G3Table :columns="columns" :data-source="data" />
```
## 虚拟滚动与性能
组件内置两条门控,**自动**决定是否启用虚拟滚动,不需要业务手动切换:
| 方向 | 阈值 | 低于阈值时 | 原因 |
| --- | --- | --- | --- |
| 纵向 | 行数 > 100 | 全量渲染 | 库的虚拟滚动在「行数略多于单屏行数」时会把最后几行算出视口外,必须滚动才可见;行数少时全量渲染没有性能代价,却换来确定性正确的渲染 |
| 横向 | 列数 > 20 | 关掉横向虚拟 | 库在横向虚拟开启时会把每列宽度钉死为 `minWidth ?? width ?? 100`,未设 `width` 的列会失去「按比例铺满容器」的原生自适应 |
可以用 `virtual` / `virtualX` 关掉,但**不能**把它们强制打开到阈值以下。
<demo vue="table/virtual.vue" />
除了门控,还有两处性能相关设计:
- **尺寸变化 150ms 防抖**:折叠动画 / 窗口缩放期间 ResizeObserver 每帧都会通知,逐帧重算会强制 reflow 并重渲染可见行列;组件合并到尺寸稳定后只刷新一次,并区分「仅宽度变化」只重算横向;
- **选择列用原生 `<input type="checkbox">`**:视觉由 CSS 模拟成 [Checkbox](/ui/form/checkbox) 的外观,但每行不创建组件实例。
## 多选与批量操作
传 `rowSelection` 即在最前注入选择列;`getCheckboxProps` 可禁用个别行(禁用行不计入「全选」的分母)。
<demo vue="table/selection.vue" />
## 排序
列上写 `sorter: true` 即可点表头排序(组件内排序,不请求接口)。`sortType` 决定按数字还是字符串比较,默认字符串;需要自定义规则时 `sorter` 也可以传函数。
<demo vue="table/sorting.vue" />
## 行内编辑
列上加 `editor` 即可编辑:**单击**单元格进入(不需要双击)。
### 编辑器类型
| `editor.type` | 说明 |
| --- | --- |
| `'input'`(默认) | 用 [Input](/ui/form/input) 渲染,**单行**。细分形态由 `inputType` 决定:`'text'`(默认)/ `'password'` / `'number'` / `'textarea'` |
| `'select'` | 用 [Select](/ui/form/select) 渲染;`options` 可以是数组,也可以是接收当前行的函数 |
| `'checkbox'` | 点击即切换,无需先激活整行;约定 `1` = 选中、`0` = 未选中(与后端 tinyint 对齐) |
不传 `type` 时按 `'input'` 处理,所以 `editor:` 这种写法也是合法的(走默认的文本输入)。
编辑器的输入值先写进内部草稿,**失焦才落库并抛出 `change`**。这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。`select` 与 `checkbox` 是「改即落库」,不走草稿。用 `editable={false}` 可整体禁用编辑。
`select` 列在**非编辑态**会把值解析成对应选项的 `label` 展示(找不到就原样显示值)。
### 键盘
| 按键 | 行为 |
| --- | --- |
| `Enter` | 全选当前格内容(直接输入即覆盖),不移动选区 |
| `Esc` | 退出编辑(失焦) |
| 方向键 | 由表格接管、移动单元格选区 |
| 失焦(点别处 / 方向键走开) | **提交**并抛出 `change` |
> 方向键移动会先让编辑器待命:按住连按时**全程不挂编辑器**(避免每帧创建 / 销毁组件导致掉帧),松手后再把编辑器挂到最终落点;快速离散连按(间隔 <150ms)也走 100ms 防抖合并。
### ⚠ 数据源必须是响应式的
**行对象必须能被 Vue 追踪**,否则编辑回写不会触发重渲染。典型的踩坑写法是用 `computed` 派生数据:
```ts
// ❌ 错: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.disabled` 可以是布尔,也可以是接收当前行的函数 —— 「已放舱之后状态不许再改」这类条件随行数据变化,用函数形态:
```ts
{
dataIndex: 'status',
title: '状态',
editor: { type: 'select', options: STATUS_OPTIONS, disabled: (row) => row.status === 2 },
}
```
被禁用的格走纯展示渲染,不会挂编辑器。
<demo vue="table/editable.vue" />
## 操作列
列上加 `actions` 渲染成一排小按钮;`type: 'more'` 的项会收进「更多」下拉。带 `confirm` 的操作先弹确认框,确认后才执行 `onClick`。
操作列建议显式写 `resizable: false`:它的宽度由内容决定,拖了不会落库,列一重建就丢。
<demo vue="table/actions.vue" />
## 列宽、列序与行拖拽
列宽 / 列序拖拽要求用 `v-model:columns` 绑定列定义——否则组件拖完无处回写(松手回弹),会直接关掉这两个功能。行拖拽排序用 `row-draggable`,顺序变化通过 `row-order-change` 回传。
<demo vue="table/resizable.vue" />
## 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` = 无外框,只留行分隔线(嵌进卡片 / 面板时用) |
| `colResizable` | `boolean \| object` | `true` | 列宽拖拽;未绑定 `v-model:columns` 时自动关闭。可用 `{ disabled: (col) => boolean }` 按列禁用 |
| `columnDraggable` | `boolean \| object` | `true` | 列序拖拽;同上要求绑定 `v-model:columns`。`{ mode: 'none' \| 'insert' \| 'swap', disabled }` |
| `rowDraggable` | `boolean` | `false` | 行拖拽排序 |
| `rowSelection` | `TableRowSelection \| null` | `null` | 多选配置,见下 |
| `seq` | `boolean \| { title?, width? }` | `true` | 序号列,**固定左侧**,默认宽 50 |
| `activeRowKey` | `string \| number \| null` | `null` | 当前高亮行 |
| `editable` | `boolean` | `true` | 行内编辑总开关 |
| `noDataFull` | `boolean` | `true` | 空态铺满容器高度 |
| `showOverflow` | `boolean` | `true` | 内容溢出省略 |
| `autoFitWidth` | `boolean` | `false` | 非横向虚拟时铺满容器宽度(开启后关闭列宽拖拽) |
| `theme` | `'light' \| 'dark'` | — | 不传则跟随全局主题(`html[data-g3-theme]`) |
| `footerData` | `T[]` | `[]` | 表尾数据 |
| `footerConfig` | `object` | `` | 表尾配置 |
### 列定义 `G3TableColumn<T>`
在 `stk-table-vue` 的列类型基础上扩展(交叉类型,非 `Omit`);下面只列自有字段,其余(`width` / `minWidth` / `fixed` / `align` / `headerAlign` / `sorter` / `sortType` / `children` / `mergeCells` / `customCell` / `customHeaderCell` 等)原样透传。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `dataIndex` | `keyof T & string` | 取值字段 |
| `title` | `string` | 表头文字 |
| `resizable` | `boolean` | 该列是否可拖宽;操作列 / 展开列建议设 `false` |
| `editor` | `TableEditor<T>` | 行内编辑器,见下 |
| `actions` | `TableAction<T>[]` | 操作列按钮 |
| `customCell` | `(props) => VNode` | 自定义单元格渲染(逃生舱),传了就不再自动包装 |
### `editor`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `type` | `'input' \| 'select' \| 'checkbox'` | 编辑器类型,不传按 `'input'` 处理 |
| `inputType` | `'text' \| 'password' \| 'number' \| 'textarea'` | 仅 `type: 'input'` 生效,默认 `'text'` |
| `options` | `TableSelectOption[] \| ((row) => TableSelectOption[])` | select 选项,可依赖当前行其它字段 |
| `disabled` | `boolean \| ((row) => boolean)` | 是否禁用该格编辑 |
| `allowClear` | `boolean` | select 是否可清空,默认 `true` |
| `emptyValue` | `unknown` | select 清空后落库的值,默认 `null` |
| `onChange` | `(value, row, col) => void` | 本列值变化回调(早于组件的 `change`) |
`TableSelectOption`:`{ label: string; value: string | number | boolean | null; disabled?: boolean }`
### `actions`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `key` | `string \| number` | 唯一键;`type: 'more'` 的子项用它做下拉 key |
| `label` | `string` | 文案 |
| `type` | `'default' \| 'danger' \| 'delete' \| 'more'` | 语义类型 |
| `icon` | `Component` | 图标组件,渲染为 size=14 |
| `title` | `string` | title 提示 |
| `danger` | `boolean` | 危险色 |
| `visible` | `boolean \| ((row) => boolean)` | 是否显示 |
| `disabled` | `boolean \| ((row) => boolean)` | 是否禁用 |
| `confirm` | `string \| object` | 二次确认;字符串为标题,对象支持 `title` / `description` / `content` / `okText` / `cancelText` / `okType` / `width` / `maskClosable` |
| `onClick` | `(row) => void` | 点击回调(带 `confirm` 时在确认后执行) |
| `items` | `TableAction<T>[]` | `type: 'more'` 时的子项 |
### `rowSelection`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `selectedRowKeys` | `Array<string \| number>` | 已选行的 key 数组(受控) |
| `onChange` | `(keys, rows) => void` | 选中变化回调 |
| `getCheckboxProps` | `(row) => { disabled?: boolean }` | 单行复选框属性 |
### Events
| 名称 | 参数 | 说明 |
| --- | --- | --- |
| `update:columns` | `(columns)` | 列宽 / 列序拖拽后回写(**内部列已剥离**) |
| `change` | `(row, col, value)` | 行内编辑的值变化 |
| `row-order-change` | `(rows, dragStartKey, targetRowKey)` | 行拖拽排序完成。后两个参数是**行 key**,不是索引 |
| `area-selection-change` | `(ranges)` | 单元格选区变化(行内编辑依赖它) |
> `row-click` 等底座事件**未声明为 emit**,而是随 `$attrs` 透传到内层 `StkTable`,
> 照常可用:`@row-click="(_, row) => (activeKey = String(row.id))"`。
> 注意这类监听器拿不到组件侧的类型推断,回调参数需显式标注
> (如 `(_: MouseEvent, row: TableRow) => ...`)。
### 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 变量实现**:库把主题值都做成了 `.stk-table` 作用域内的变量,组件把它们映射到 `--g3-*` 令牌(覆盖层在 `src/table/style/_overrides.scss`)。改变量而不是逐个选择器改样式,库升级时不容易失效。
- **区域选择按需自注册**:库的单元格区域选择是全局注册的(`registerFeature(useAreaSelection)`),不注册则 `area-selection-change` 永不触发、行内编辑失效。而且**注册必须早于任何 StkTable 实例的创建**(库在 setup 顶层取值),所以不能放在 `onMounted`。组件在**模块被求值时就注册**;需要显式控制时可调用导出的 `registerTableFeatures()`。
- **边框:外框自绘,横线走真 `border`**。组件把 `bordered` 翻成底座的 `'v'` 档(只借它的竖线),再自己补外框与横线。
- **行编辑态存在 `WeakMap` 里**:按行对象存,不给业务数据打标记;行被回收时状态自动释放。
- **编辑器按编辑态门控注入**:没有任何行处于编辑态时,可编辑列走底座原生 td 渲染路径(减少每格一个函数式组件实例);`checkbox` 与 `select` 例外——前者始终可交互,后者需要解析 label 展示。某一行进入编辑态后,列上才会挂上按行分发的编辑器。
- **RTL**:布局用逻辑属性,带符号位移走 `--g3-dir-sign`。
## 从旧版迁移
本组件曾有一版**自研能力更多**的实现(含校验、精度、日期编辑器、键盘跳格等)。为与
`fms-vue` 的 `FmsTable` 对齐,现已按 fms 重写;下列能力**已移除**,迁移时需改到业务侧:
| 已移除 | 迁移建议 |
| --- | --- |
| `editor.validate` / `editor.validateWarning` / `editor.errorStyle` | 校验改由业务侧负责:保存前遍历 `dataSource` 自行判定并提示 |
| `editor.precision` / `col.format` | 数值格式化与小数位规则移到业务侧(或列上 `customCell` 自定义渲染) |
| `editor.type: 'date'` / `editor.format` / `editor.valueFormat` / `editor.placeholder` | 日期列改为纯展示(`customCell`),或换用可编辑的 `input` |
| `checkPrecision` / `checkDate` 导出 | 已移除;规则由业务侧自行实现 |
| Tab / Shift+Tab 跳格、方向键切格与 Enter 下移 | 方向键仍由表格移动选区;`Enter` 改为「全选当前格」 |
| `editorSeamless` prop | 编辑器形态固定(fms 口径),不再可切换 |
| 只读格「灰底」视觉 | 已移除;不可编辑的列即无 `editor` |
| `stripe` / `fixedColShadow` prop | 已移除;如需斑马纹自行写 CSS |
| `scrollbar` / `scrollRowByRow` / `smoothScroll` prop | 已移除;固定走底座的默认滚动路径 |
| `update:selectedRowKeys` 事件 | 用 `rowSelection.onChange` 接收勾选变化 |
| `row-click` 声明式 emit | 仍可用,但改由 `$attrs` 透传(见「Events」小节的提示) |
| `getTableData()` 暴露方法 | 已移除;需要当前顺序时用 `row-order-change` 回传的 `rows` |
**`editor.type` 的值域变了**:旧版是 `'text' | 'number' | 'date' | 'password' | 'select' | 'checkbox'`,
现在是 `'input' | 'select' | 'checkbox'`(输入框的细分形态走 `inputType`)。
迁移时 `type: 'text'` → `type: 'input'`,`type: 'number'` → `type: 'input', inputType: 'number'`。
@@ -85,85 +85,13 @@ description: 组件库的字号、字重、行高与数字对齐约定,以及
实测比文字中心高约 1.5px。按钮自身的 `align-items: center` 只能居中 `span` 这一层,
治不了 `span` **内部**的基线对齐。
## 数值列:等宽数字
金额、数量、单号、序号这类列要加 `font-variant-numeric: tabular-nums`。
比例数字(默认)里每个字形宽度不同,`1` 比 `8` 窄,于是每换一行小数点与个位
就在水平方向抖一下,整列看着参差不齐。等宽数字把 0–9 钉成同一宽度,数字按位对齐。
`G3Table` 已经在组件内部处理好,**业务方不需要自己加**:
```scss
.g3-table__inner td.text-r {
font-variant-numeric: tabular-nums;
}
```
判据是**右对齐**而不是「内容是数字」:库渲染 `td` 时读不到单元格的值,而本库约定里
金额/数量/数值列一律用 `align: 'right'` 声明(见 `docs/examples/table/*.vue`)。
⚠️ 类名是 `text-r`,由 `stk-table-vue` 的 `getTDProps` 打上:
| `col.align` | 类名 |
|---|---|
| `'right'` | `text-r` |
| `'center'` | `text-c` |
| `'left'`(默认) | **不带类** |
左对齐的列没有任何类,所以 `td.text-r` 不会误伤普通文本列 —— 中文与拉丁文本来就
不需要等宽数字,强行套上去反而会让正文字距松散。
## 表格边框:外框与格线的归属
`G3Table` 的外框(1px 描边 + 8px 圆角)由组件**自绘**在根节点上,格线仍走底座的
`background-image` 渐变。这套组合下,有两条线容易画重或画丢,判据按下面的来。
**为什么格线不能改成真 `border`**:行内编辑器被钉死在 `var(--row-height)`(默认 30px),
而 `td` 高度是按内容撑开的 —— 一旦 `td` 多出 1px 边线,内容盒只剩 29px 放不下 30px 的
编辑器,整行被顶到 31px。行高是虚拟滚动 `translateY` 的计算基准,涨 1px 会一路累积成
行错位。**背景渐变不参与盒模型**,所以格线只能走 `background-image`。
### 最右列竖线:按「是否贴住内右缘」判断
| 情形 | 处理 | 原因 |
|---|---|---|
| 右固定列(`.fixed-cell--right`) | 摘掉自身右线,交给外框 | 它贴住容器内右缘,自己的线会与外框叠成两条 |
| 普通末列 | **保留**自身右线 | 列宽没铺满时它离内缘还有距离,摘了就「线凭空消失」 |
实测:右固定列的 `th`/`td` 右边缘都在内缘上(1242px)且自带 `270deg` 渐变;
而列宽未铺满的表(容器内宽 1084px、列只到 800px)末列离内缘差 284px。
### 最后一行横线:按「行区是否铺满」判断
这是**唯一需要 JS 参与**的一处 —— 纯 CSS 表达不了「行区高度是否等于可用高度」。
组件侧量出行区是否铺满,用 `.g3-table.is-body-fill` 把结论传进来:
| 行区状态 | 最后一行底边线 | 原因 |
|---|---|---|
| 铺满 | 交给外框 | 两者重合,否则叠成 2px |
| 未铺满 | **该行自己画** | 外框内底边在它下面很远,摘了就缺线 |
⚠️ 判据**不能**是「是不是最后一行」(`tr:last-child`)。实测 180px 高的容器里只放
1 行 30px 数据时,该行底边在 440px、外框内底边在 554px —— **差 114px 根本够不着**,
此时摘线等于那唯一一条分隔线消失。这两个条件只有行区铺满时才等价。
### 空态
库的 `.stk-table-no-data.no-data-full` 会撑满剩余高度并**自带一条 `border-bottom`**,
与外框的 `border-bottom` 落在同一条 y 上(实测都在 218px)→ 底部出现重叠线。
组件已移除它自带的底边,**空态底边统一归外框**。
> 以上都由组件内部处理,业务方不需要写任何样式。若自定义了 `--g3-border-color`,
> 外框与格线会一起跟随。
## 硬规定
| 项 | 取值 | 原因 |
|---|---|---|
| 字号 | `12 / 14 / 16`(偶数) | 避开 `13 / 15`:Windows 125% / 150% 缩放下奇数字号的亚像素误差会被放大 |
| 控件高度 | `24 / 32 / 40`(8 的倍数) | 与栅格对齐,缩放后不易出现半像素 |
| 数字列 | `tabular-nums` | 见上 |
| 数字列 | `tabular-nums` | 等宽数字按位对齐,列不随数字宽度抖动 |
字号与控件高度已由 `@g3soft/tokens` 提供:`--g3-font-size-sm/-/lg`、
`--g3-control-height-sm/-/lg`。
@@ -195,7 +123,7 @@ description: 组件库的字号、字重、行高与数字对齐约定,以及
## 自测
改动字体栈或行高后,**整行的基线与行高都会重算**,必须把所有小尺寸组件回归一遍
(按钮三档尺寸、标签、分段控制器、分页器、表格行内编辑器)。
(按钮三档尺寸、标签、分段控制器、分页器)。
- 定性:playground 里逐个尺寸目视比对
- 定量:`FontMetrics` API 可读浏览器实际采用的度量,但**它给不出「这行实际用了哪款字体」**
@@ -207,5 +135,3 @@ description: 组件库的字号、字重、行高与数字对齐约定,以及
- 跨 OS 像素级一致**做不到**,见页首结论。
- 翻译成 `ar` 等语言时字体栈需另行调整(语言包目前只有 `zhCN` / `enUS`)。
- 若业务方覆盖了 `--g3-font-family`,`G3Table` 内部硬编码的 `font-size` 不受影响,
但行高与基线会变 —— 需重新回归。
@@ -1,83 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Pencil, Trash2, Copy, Archive, Download } from '@lucide/vue'
import { G3Message, G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 操作列:列上加 actions 即可,渲染成一排小按钮。
// - type: 'danger' | 'delete' → 危险色
// - visible / disabled 可传 boolean 或 (row) => boolean
// - confirm:字符串=标题,对象=完整配置;确认后才执行 onClick
// - type: 'more' 的项收进「更多」下拉(它的 items 是子项)
//
// 操作列建议显式写 resizable: false —— 宽度由内容决定,拖了不会落库,列一重建就丢。
const data = ref<TableRow[]>([
{ id: 1, name: '框架合同', amount: 120000, status: 'draft' },
{ id: 2, name: '采购订单', amount: 86000, status: 'active' },
{ id: 3, name: '验收单', amount: 43000, status: 'archived' },
])
const columns: G3TableColumn<TableRow>[] = [
{ dataIndex: 'name', title: '名称', width: 180 },
{ dataIndex: 'amount', title: '金额', width: 120, align: 'right' },
{
dataIndex: 'op',
title: '操作',
width: 220,
resizable: false,
actions: [
{
label: '编辑',
icon: Pencil,
onClick: (row) => G3Message.info(`编辑:${row.name}`),
},
{
label: '删除',
icon: Trash2,
type: 'danger',
// 已归档的单据不允许删除
disabled: (row) => row.status === 'archived',
confirm: { title: '确认删除', description: '删除后不可恢复,确定继续吗?' },
onClick: (row) => G3Message.success(`已删除:${row.name}`),
},
{
type: 'more',
label: '更多',
items: [
{
key: 'copy',
label: '复制',
icon: Copy,
onClick: (row) => G3Message.info(`复制:${row.name}`),
},
{
key: 'archive',
label: '归档',
icon: Archive,
// 已归档的不再显示该项(visible 传函数)
visible: (row) => row.status !== 'archived',
onClick: (row) => G3Message.success(`已归档:${row.name}`),
},
{
key: 'export',
label: '导出',
icon: Download,
onClick: (row) => G3Message.info(`导出:${row.name}`),
},
],
},
],
},
]
</script>
<template>
<div>
<p style="margin: 0 0 8px; color: #999; font-size: 12px">
「删除」带二次确认,且对已归档行禁用;「更多」里「归档」对已归档行隐藏。
</p>
<div style="height: 260px">
<G3Table :columns="columns" :data-source="data" />
</div>
</div>
</template>
@@ -1,27 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 最小用法:给 columns 与 dataSource 两份数据即可。
// - seq 默认开启,会在最前列注入序号列(固定左侧)
// - bordered 默认开启:圆角外框由组件自绘 + 行分隔线
// - 没写 width 的列会自动摊掉剩余宽度
const columns: G3TableColumn<TableRow>[] = [
{ dataIndex: 'name', title: '名称', width: 180 },
{ dataIndex: 'amount', title: '金额', width: 120, align: 'right' },
{ dataIndex: 'owner', title: '负责人' },
]
const data = ref<TableRow[]>([
{ id: 1, name: '框架合同', amount: 120000, owner: '张三' },
{ id: 2, name: '采购订单', amount: 86000, owner: '李四' },
{ id: 3, name: '验收单', amount: 43000, owner: '王五' },
])
</script>
<template>
<div style="height: 260px">
<G3Table :columns="columns" :data-source="data" />
</div>
</template>
@@ -1,101 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 行内编辑:列上加 editor 即可。**单击**单元格进入编辑(不需要双击)。
//
// 编辑器类型(注意 `input` 是统称,细分形态走 inputType):
// { type: 'input' } → 单行文本,用 G3Input 渲染
// { type: 'input', inputType: 'number' } → 数字
// { type: 'input', inputType: 'password' }
// { type: 'input', inputType: 'textarea' }
// { type: 'select' } → 用 G3Select 渲染;options 可传数组,
// 也可传「接收当前行的函数」
// { type: 'checkbox' } → 点击即切换,无需先激活整行;
// 约定 1 = 选中、0 = 未选中(对齐后端 tinyint)
//
// 关键行为:输入值先写进内部草稿,**失焦才落库并抛出 change**。
// 这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。
//
// 键盘:编辑态下 Enter 会全选当前格内容(可直接覆盖输入)、Esc 退出编辑;
// 方向键由表格接管移动选区(按住连按时会先等按键松开再挂编辑器,避免掉帧)。
//
// ⚠️ 数据源必须是响应式的:行对象要能被 Vue 追踪,否则改动不会触发重渲染
// (change 照常抛出,但界面仍显示旧值,且要等一次无关重渲染才更新)。
const columns: G3TableColumn<TableRow>[] = [
{ dataIndex: 'name', title: '名称', width: 200, editor: { type: 'input' } },
{
dataIndex: 'status',
title: '状态(select)',
width: 150,
editor: {
type: 'select',
options: [
{ label: '启用', value: 1 },
{ label: '停用', value: 0 },
],
},
},
{ dataIndex: 'enabled', title: '启用', width: 80, editor: { type: 'checkbox' } },
{
dataIndex: 'qty',
title: '数量(number)',
width: 140,
align: 'right',
editor: { type: 'input', inputType: 'number' },
},
{
dataIndex: 'remark',
title: '备注',
width: 180,
// 按行禁用:这一行「改不了」时该格只读(无需先点开)。
// 这里演示常见的「停用之后不许再改备注」——第 2 行 status=0。
editor: { type: 'input', disabled: (row) => row.status === 0 },
},
{
dataIndex: 'owner',
title: '负责人(options 依赖本行)',
width: 200,
// options 传函数时会收到当前行 —— 按「状态」给出不同候选人。
// 注意这类列在非编辑态会把值解析成 label 展示。
editor: {
type: 'select',
options: (row: TableRow) =>
row.status === 1
? [
{ label: '张三', value: '张三' },
{ label: '李四', value: '李四' },
]
: [{ label: '王五', value: '王五' }],
},
},
]
const data = ref<TableRow[]>([
{ id: 1, name: '框架合同', status: 1, enabled: 1, qty: 12, remark: '年度框架', owner: '张三' },
{ id: 2, name: '采购订单', status: 0, enabled: 0, qty: 45, remark: '', owner: '王五' },
])
const lastChange = ref('')
function onChange(row: TableRow, col: G3TableColumn<TableRow>, value: unknown) {
// 只有值**真的变化**才会走到这里(组件内部按编辑前的快照去重),
// 所以「点进去又点出来」不会产生无意义的 change。
lastChange.value = `「${row.name}」的「${col.title}」→ ${value}`
}
</script>
<template>
<div>
<p style="margin: 0 0 8px; color: #666; font-size: 13px">
最近一次 change:{{ lastChange || '(点击单元格试试)' }}
</p>
<p style="margin: 0 0 8px; color: #999; font-size: 12px">
单击进入编辑 · Enter 全选当前格 · Esc 退出 · 方向键移动选区 · 失焦提交
</p>
<div style="height: 240px">
<G3Table :columns="columns" :data-source="data" :seq="false" @change="onChange" />
</div>
</div>
</template>
@@ -1,100 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 列宽 / 列序拖拽要求用 v-model:columns 绑定列定义。
// 组件会检测父级有没有绑 update:columns —— 没绑就**直接关掉**这两个功能,
// 因为拖完无处回写(松手回弹)。这也是最容易踩的一条。
const columns = ref<G3TableColumn<TableRow>[]>([
{ dataIndex: 'name', title: '名称(可拖宽)', width: 200 },
{ dataIndex: 'amount', title: '金额', width: 140, align: 'right' },
{ dataIndex: 'owner', title: '负责人', width: 140 },
// 操作列显式声明 resizable: false —— 它的宽度由内容决定,拖了不会落库、列一重建就丢。
// 也可以在 colResizable 里用 disabled(col) 按列禁用。
{ dataIndex: 'op', title: '操作', width: 100, resizable: false },
])
// 按列禁用拖宽的另一种写法:整列标成 noResize。
const columnsByConfig = ref<G3TableColumn<TableRow>[]>([
{ dataIndex: 'name', title: '名称', width: 180 },
{ dataIndex: 'amount', title: '金额(禁用拖宽)', width: 160, align: 'right' },
])
const data = ref<TableRow[]>([
{ id: 1, name: '框架合同', amount: 120000, owner: '张三', op: '—' },
{ id: 2, name: '采购订单', amount: 86000, owner: '李四', op: '—' },
{ id: 3, name: '验收单', amount: 43000, owner: '王五', op: '—' },
])
// ── 行拖拽排序 ──
// row-draggable 开启后最左侧出现拖拽手柄;拖完通过 row-order-change 回传新顺序。
// 注意回传的是 (rows, sourceIndex, targetIndex) —— rows 是拖拽后的完整数据,直接用即可。
const dragColumns = ref<G3TableColumn<TableRow>[]>([
{ dataIndex: 'name', title: '步骤' },
{ dataIndex: 'amount', title: '耗时(分钟)', align: 'right' },
])
const dragData = ref<TableRow[]>([
{ id: 1, name: '第一步:准备', amount: 100 },
{ id: 2, name: '第二步:执行', amount: 200 },
{ id: 3, name: '第三步:验收', amount: 300 },
])
const orderText = ref('')
// ⚠️ 后两个参数是**行 key**(底座的 dragStartKey / targetRowKey),不是索引。
function onRowOrderChange(rows: TableRow[], dragStartKey: string | number, targetRowKey: string | number) {
dragData.value = rows
orderText.value = `${dragStartKey} → ${targetRowKey}:${rows.map((r) => r.name).join(' → ')}`
}
// 当前列宽(演示 v-model:columns 的回写确实发生了)
const widths = ref('')
function onColumnsUpdate(next: G3TableColumn<TableRow>[]) {
columns.value = next
widths.value = next
.filter((c) => c.dataIndex !== 'op')
.map((c) => `${c.title}=${c.width ?? 'auto'}`)
.join(' · ')
}
</script>
<template>
<div>
<p style="margin: 0 0 8px; color: #999; font-size: 12px">
拖动表头分隔线改列宽、拖动表头文字改列序。「操作」列设了 resizable: false,拖不动。
</p>
<p style="margin: 0 0 8px; color: #666; font-size: 13px">
回写后的列宽:{{ widths || '(拖一下试试)' }}
</p>
<div style="height: 220px">
<G3Table :columns="columns" :data-source="data" @update:columns="onColumnsUpdate" />
</div>
<p style="margin: 16px 0 8px; color: #999; font-size: 12px">
按列禁用拖宽:用 colResizable 的 disabled(col) 回调。
</p>
<div style="height: 180px">
<G3Table
v-model:columns="columnsByConfig"
:data-source="data"
:seq="false"
:col-resizable="{ disabled: (col) => col.dataIndex === 'amount' }"
/>
</div>
<p style="margin: 16px 0 8px; color: #666; font-size: 13px">
行拖拽排序:{{ orderText || '(拖动左侧手柄试试)' }}
</p>
<div style="height: 200px">
<G3Table
v-model:columns="dragColumns"
:data-source="dragData"
:seq="false"
row-draggable
@row-order-change="onRowOrderChange"
/>
</div>
</div>
</template>
@@ -1,51 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 多选:传 rowSelection 即在最前注入选择列。
// - selectedRowKeys 用 v-model 式双向绑定,勾选变化会回写
// - getCheckboxProps 可禁用个别行;**禁用行不计入「全选」的分母**
// (所以下面表格里表头全选只会选中 2 行)
const columns: G3TableColumn<TableRow>[] = [
{ dataIndex: 'name', title: '名称', width: 180 },
{ dataIndex: 'amount', title: '金额', width: 120, align: 'right' },
{ dataIndex: 'owner', title: '负责人', width: 120 },
]
const data: TableRow[] = [
{ id: 1, name: '框架合同', amount: 120000, owner: '张三' },
{ id: 2, name: '采购订单', amount: 86000, owner: '李四', locked: true },
{ id: 3, name: '验收单', amount: 43000, owner: '王五' },
]
const selectedRowKeys = ref<Array<string | number>>([])
const selectedNames = ref<string[]>([])
function onSelectionChange(keys: Array<string | number>, rows: TableRow[]) {
selectedRowKeys.value = keys
selectedNames.value = rows.map((row) => String(row.name))
}
</script>
<template>
<div>
<p style="margin: 0 0 8px; color: #666; font-size: 13px">
已选 {{ selectedRowKeys.length }} 项:{{ selectedNames.join('、') || '(无)' }}
</p>
<p style="margin: 0 0 8px; color: #999; font-size: 12px">
「采购订单」被 getCheckboxProps 禁用,不参与全选。
</p>
<div style="height: 260px">
<G3Table
:columns="columns"
:data-source="data"
:row-selection="{
selectedRowKeys,
onChange: onSelectionChange,
getCheckboxProps: (row) => ({ disabled: Boolean(row.locked) }),
}"
/>
</div>
</div>
</template>
@@ -1,48 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 排序:列上写 sorter: true 即可点表头排序(组件内排序,不请求接口)。
// - sortType 决定按数字还是字符串比较,默认字符串;金额这类数值列要显式写 'number'
// - 需要自定义规则时 sorter 也可以传函数(见「名称长度」列)
const columns: G3TableColumn<TableRow>[] = [
{ dataIndex: 'name', title: '名称', width: 180 },
{
dataIndex: 'amount',
title: '金额(数字排序)',
width: 160,
align: 'right',
sorter: true,
sortType: 'number',
},
{ dataIndex: 'qty', title: '数量', width: 120, align: 'right', sorter: true, sortType: 'number' },
{
// dataIndex 必须指向一个**真实存在的字段**:库按 row[dataIndex] 渲染单元格,
// 复用 'name' 会让这一列显示名称本身(而不是长度),并且与「名称」列重复。
dataIndex: 'nameLength',
title: '名称长度(自定义)',
width: 160,
align: 'right',
// 自定义排序接收的是**整个数组**与排序状态,返回排好的新数组
// (不是 Array.prototype.sort 的 (a, b) 比较器——写成 (a, b) 会静默不生效)
sorter: (rows: TableRow[], { order }: { order: 'asc' | 'desc' | null }) => {
if (!order) return rows
const sign = order === 'asc' ? 1 : -1
return [...rows].sort((a, b) => (Number(a.nameLength) - Number(b.nameLength)) * sign)
},
},
]
const data = ref<TableRow[]>([
{ id: 1, name: '框架合同', nameLength: 4, amount: 120000, qty: 12 },
{ id: 2, name: '采购订单补充协议', nameLength: 8, amount: 86000, qty: 45 },
{ id: 3, name: '验收单', nameLength: 3, amount: 43000, qty: 7 },
])
</script>
<template>
<div style="height: 260px">
<G3Table :columns="columns" :data-source="data" />
</div>
</template>
@@ -1,36 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 性能门控:行数 > 100 才开启纵向虚拟滚动,组件内部按 dataSource.length 自动判断,
// 业务不需要手动切 virtual(也**不能**强制打开到阈值以下)。
// 拖动下面的滑杆可以直观看到阈值前后的差异。
const rowCount = ref(5000)
const virtualColumns: G3TableColumn<TableRow>[] = [
{ dataIndex: 'id', title: 'ID', width: 90 },
{ dataIndex: 'name', title: '名称', width: 200 },
{ dataIndex: 'value', title: '数值', width: 140, align: 'right', sorter: true, sortType: 'number' },
]
const virtualData = computed(() =>
Array.from({ length: rowCount.value }, (_, i) => ({
id: i + 1,
name: `记录 ${i + 1}`,
value: (i + 1) * 7,
})),
)
</script>
<template>
<div>
<p style="margin: 0 0 8px; color: #666; font-size: 13px">
当前 {{ rowCount }} 行 →
{{ rowCount > 100 ? '已开启' : '未开启(行数不足 100 时全量渲染)' }}纵向虚拟滚动
</p>
<input v-model.number="rowCount" type="range" min="10" max="5000" step="10" />
<div style="height: 320px; margin-top: 8px">
<G3Table :columns="virtualColumns" :data-source="virtualData" :seq="false" />
</div>
</div>
</template>
-328
View File
@@ -1,328 +0,0 @@
# G3Table 设计方案(性能优先)
> 参考实现:`D:\workspace\code\fms\fms-vue\src\components\fms-table`(`FmsTable.vue` 954 行 + 4 个 utils)
> 底座:`stk-table-vue@1.1.0`(MIT,零运行时依赖、零 peer 依赖,纯 ESM)
> 状态:**待评审**,确认后再开工
---
## 1. 先讲清楚参考实现到底是什么
这一点决定后面所有取舍,所以放在最前面。
**`FmsTable.vue` 不是一个表格实现,它是 `stk-table-vue` 的一层适配器(约 950 行)。**
虚拟滚动 / 列宽拖拽 / 列序拖拽 / 区域选择 / 行拖拽排序 / 二级表头 / 固定列 / 合并单元格
**全部是库的能力**。该文件真正的增量只有四类:
| 类别 | 内容 | 是否值得搬 |
|---|---|---|
| **A. 性能门控启发式** | `MIN_VIRTUAL_ROW_COUNT = 100`(行数少时**关**虚拟滚动)、`MIN_VIRTUAL_X_COLUMN_COUNT = 20`、`customCell` 恒定注入(列引用与编辑态解耦,见 §4.3) | ✅ 这是真正的核心价值,必须搬 |
| **B. 防抖 / 节流** | resize 150ms trailing 防抖、键盘编辑激活 100ms 合并、rAF 合并同一帧多次通知 | ✅ 直接搬 |
| **C. 针对 v1.1.0 的 workaround** | `effectiveVirtualX`(库在 virtualX 下会把列宽钉死,破坏原生自适应)、`--bg-border-right` 局部清除、`border-left: none`、`td:has(...)` 补高等 | ⚠️ **不抄注释,按我们自己的验证结论重写** |
| **D. 业务耦合** | `useAppStore` 取暗色模式、`@/components/ui/*` 直接 import 内部组件 | ❌ 必须替换成 `@g3soft/*` 对应物 |
**C 类是最大的风险。** 该文件里约 15 条注释在描述「与库内部行为作斗争」,它们修的是
**v1.1.0 特定内部实现**的 bug。整段照搬等于把一堆针对某个版本的补丁搬进我们的库,
之后库一升级就要重新验证每一条。**正确做法**:只搬 A、B 两类,C 类在实现时逐条实测
(用 E2E 锁住几何),确认仍然必要才保留,并在注释里写「为什么」而不是「库做了什么」。
---
## 2. 底座集成(已实测确认的四个硬约束)
这些都是我实际读 `node_modules` 确认的,不是假设:
### 2.1 纯 ESM,可直接用
```
lib/stk-table-vue.js → 真实 import/export,无 require
```
与 `@g3soft/ui` 的 `"type": "module"` + `preserveModules` 兼容。
`vue` 是裸导入(由宿主解析),**不会被我们打包进去**。
### 2.2 样式是**独立文件**,必须单独引入
实测:`lib/stk-table-vue.js` 与 `StkTable-6OMgH-gT.js` 里**没有任何 `.css` import**。
fms 在 `src/main.js` 手动 `import 'stk-table-vue/lib/style.css'`。
**对我们的影响(重要)**:`@g3soft/ui` 的产物是 `dist/style.css`(`sideEffects: ["**/*.css"]`)。
组件库不能要求消费方额外引一次第三方样式。方案:
- 在 `src/table/style/` 下建一个入口,`@import` 库样式 **+** 我们的覆盖层,由 vite 打进 `dist/style.css`;
- 覆盖层用 `.g3-table__inner.stk-table` 双类选择器(权重 (0,2,1) > 库默认 (0,1,1)),
与我们现有「不用 scoped、BEM + `g3-` 前缀」的约定一致。
> 待确认:库样式里是否有 `:root` 级变量可能与 `--g3-*` 冲突。开工第一步先做这件事。
### 2.3 区域选择是**按需注册**的全局副作用
```js
// fms/src/main.js
import { registerFeature, useAreaSelection } from 'stk-table-vue'
registerFeature(useAreaSelection) // 一次性,mount 之前
```
行内编辑依赖 `area-selection-change` 事件,**不注册就没有该事件**。
**对我们的影响**:组件库不拥有 app 启动流程,不能在 bootstrap 里替用户调用。
方案:在 `G3Table` 内部按需自注册(`registerFeature` 幂等,模块级一次性 flag 包住),
并额外暴露 `G3Table.registerFeatures()` 供显式控制。避免「组件静默不工作」这类最难查的
问题——这正好是 `docs-examples.test.ts` 那类隐形失败的同源风险。
### 2.4 完整的 TS 类型可用
`lib/src/StkTable/types/index.d.ts` 导出 `StkTableColumn<T>` / `SortConfig` /
`AreaSelectionRange` / `CustomCellProps<T>` 等。我们的 `G3TableColumn<T>` 应当
**`Omit` + 扩展**它,而不是从零重写一遍(重写会随库版本漂移)。
---
## 3. 组件 API
### 3.1 目录结构
```
packages/ui/src/table/
G3Table.vue # 主组件(对齐 FmsTable.vue 的职责划分)
types.ts # G3TableColumn / G3TableProps / emits / expose
index.ts
style/
index.scss # @import 库样式 + 我们的覆盖层
_overrides.scss
utils/
column.ts # 内部列注入 / 剥离(序号列、选择列、拖拽列)
selection.ts # createSelectionColumn:原生 checkbox,零组件开销
actions.ts # createActionsCell:操作列
editable.ts # createEditableCell:行内编辑
focus.ts # 编辑器焦点恢复 / 草稿保持
G3Table.test.ts # 组件契约
utils/*.test.ts # 纯函数
```
### 3.2 Props(对齐 fms,命名转 kebab-case,全部走 `withDefaults`)
| prop | 类型 | 默认 | 说明 |
|---|---|---|---|
| `columns` | `G3TableColumn<T>[]` | `[]` | 列定义,配合 `v-model:columns`(列宽/列序拖拽回写) |
| `dataSource` | `T[]` | `[]` | 数据 |
| `rowKey` | `string \| (row) => key` | `'id'` | 行唯一键 |
| `virtual` | `boolean` | `true` | 纵向虚拟滚动(**受 §4.1 门控**) |
| `virtualX` | `boolean` | `true` | 横向虚拟滚动(**受 §4.2 门控**) |
| `rowHeight` / `headerRowHeight` | `number` | `30` / `34` | 唯一真源,同时驱动虚拟计算与 CSS 变量 |
| `bordered` | `boolean \| string` | `true` | 自绘外框 + 行分隔线 |
| `colResizable` | `boolean \| object` | `true` | 列宽拖拽(未绑定 `v-model:columns` 时自动关闭) |
| `columnDraggable` | `boolean \| object` | `true` | 列序拖拽 |
| `rowDraggable` | `boolean` | `false` | 行拖拽排序 |
| `rowSelection` | `object \| null` | `null` | `{ selectedRowKeys, onChange, getCheckboxProps }` |
| `seq` | `boolean \| object` | `true` | 序号列 |
| `activeRowKey` | `string \| number \| null` | `null` | 当前高亮行 |
| `editable` | `boolean` | `true` | 行内编辑总开关 |
| `editorSeamless` | `boolean` | `true` | 单元格内无外间距 + 无常驻边框(§5.3) |
| `noDataFull` / `showOverflow` | `boolean` | `true` | 空态铺满 / 溢出省略 |
| `autoFitWidth` | `boolean` | `false` | 非横向虚拟时铺满容器 |
| `theme` | `'light' \| 'dark'` | `undefined` | 不传则跟随全局主题(**按 §4.5 从 ConfigProvider 取,不依赖 pinia**) |
| `footerData` / `footerConfig` | `array` / `object` | `[]` / `` | 表尾合计 |
### 3.3 Events / Expose
```ts
// events
'update:columns' | 'change' (row, col, newValue) | 'row-order-change'
'update:selectedRowKeys' | 'area-selection-change'
// expose
scrollToRow(rowKey) | editRow(rowKey, focusCol?) | initVirtualScroll() | refreshLayout()
getSelectedArea() | clearSelectedArea() | setRowExpand(...)
```
### 3.4 列定义 `G3TableColumn<T>`
```ts
export type G3TableColumn<T> = Omit<StkTableColumn<T>, 'customCell'> & {
dataIndex: keyof T & string
/** 表头/单元格对齐已由 §3.2 的 headerAlign 推断补齐 */
/** 行内编辑(首版按「全量对齐」实现) */
editor?: {
type: 'text' | 'number' | 'select' | 'checkbox' | 'textarea'
options?: SelectOption[] | ((row: T) => SelectOption[]) // 选项可依赖同行其它列
disabled?: boolean | ((row: T) => boolean)
allowClear?: boolean
emptyValue?: unknown
onChange?: (value, row, col) => void
}
/** 操作列 */
actions?: G3TableAction<T>[]
/** 覆盖内置 customCell 渲染(逃生舱) */
customCell?: (props: CustomCellProps<T>) => VNode
resizable?: boolean // false = 该列禁止拖宽(操作列/展开列)
}
```
---
## 4. 性能策略(本次的核心)
按「收益 ÷ 维护成本」排序,全部来自 A/B 两类:
### 4.1 虚拟滚动门控:行数 ≤ 100 时**关闭**虚拟滚动
```ts
const MIN_VIRTUAL_ROW_COUNT = 100
const effectiveVirtual = computed(
() => props.virtual && props.dataSource.length > MIN_VIRTUAL_ROW_COUNT,
)
```
**原因(照搬并保留注释)**:库的虚拟滚动在「行数 > 单屏行数」时开启,会导致
「容器一屏放 18 行、数据 20 行」这种情况下最后几行被虚拟化吞掉、必须滚动才可见——
一个纯粹的正确性 bug。行数少时全量渲染没有任何性能问题。
### 4.2 横向虚拟门控:列数 ≤ 20 时关闭
**原因**:库在 `virtualX = true` 时会把每列宽度**钉死**为 `minWidth ?? width ?? 100`
(`cellStyleMap` 的 `--cw` + `width/min-width/max-width` 三重锁),未设 `width` 的列
会失去「按比例铺满容器」的原生自适应。列数少、总宽不超容器时用不上横向虚拟。
### 4.3 `customCell` 恒定注入(原 `editingActive` 门控已废除)
```ts
// 编辑列恒定注入 customCell;列引用与「有没有行在编辑」完全解耦
const displayColumns = computed(() => {
const cols = props.columns.map((c) => resolveCustomCell(c, editingCtx))
// ...
})
```
> **2026-10 性能排查后的设计反转**:原方案「无编辑行时不注入 `customCell`」的代价在
> **首次点击**——点击使门控翻转 → 整个列数组重建(新列对象 + 新 customCell 闭包)→
> 底座表头 / 几何 / 样式缓存全部失效 → 全表重排重绘,实测阻塞主线程 298~394ms
> (966~1008 次 DOM 变更),是「点击卡顿」的主因。
>
> 现在编辑列**恒定注入**:列引用在编辑态翻转时完全不变,点击不再触碰这条链路。
> 整列禁用的列例外(不注入,走 td 类名,零开销)——它们永远不会出现编辑器,
> 渲染不随编辑态变化。见 `utils/column.ts` 的 `resolveCustomCell` 与
> `utils/editable.ts` 的 `createEditableCell`(编辑器按行分发)。
>
> 代价:非编辑态的编辑列每格多一个函数式组件实例(滚动每帧成本的一个增量项);
> 与点击省下的 300ms+ 相比是划算的交换。滚动卡顿的真正大头是虚拟滚动的每帧
> 全表重渲染(底座侧问题),不在这一个包装层。
### 4.4 尺寸与键盘防抖
- **resize 150ms trailing 防抖**:折叠动画 / 窗口缩放期间 ResizeObserver 每帧通知,
但中间尺寸是瞬态的;逐帧 `initVirtualScroll()` 会每帧强制 reflow 并重渲染可见行列。
合并到尺寸稳定后刷新一次。并区分「仅宽度变化」→ 只重算 X 方向。
- **rAF 合并**同一帧内的多次通知。
- **键盘编辑 100ms 合并**:按住方向键滚动时全程保持 0 编辑器,松手后再激活;
快速离散连按(<150ms)也走防抖,避免每键挂载/卸载整行编辑器。
### 4.5 主题:从 ConfigProvider 取,不依赖 pinia
参考实现用 `useAppStore().darkMode`——那是业务 store。我们改走
`ConfigProvider` / `documentElement.dataset`(与现有组件一致),
并保留 `theme` prop 显式覆盖。
### 4.6 零组件开销的选择列
行 checkbox 用**原生 `<input type="checkbox">` + 自绘方框**,不用 `G3Checkbox` 组件。
每行一个组件实例在滚动时的开销不可接受。样式模拟成 `G3Checkbox` 外观(CSS 层,非组件)。
---
## 4.7 编辑器无缝嵌入单元格(`editorSeamless`,默认开启)
**形态**:单元格内部**无外间距** + 编辑器**无常驻边框**。适合整行编辑、键盘连续
录入、固定列宽以及专业用户(Excel / AG Grid / Handsontable 的表格态)。
**目标**:单元格保持稳定的几何结构,编辑器融进表格,**只有焦点、校验和特殊
交互状态才增强视觉提示**。
三条硬约束(改动这块前必读):
1. **编辑器盒高 = `var(--row-height)`**。td 是 border-box,指定 `height: 30px` 时
内容盒只有 `30 - padding`,而子元素 `height: 100%` 解析的基准是**内容盒**。
所以 td 垂直 padding 必须为 0,且编辑器不能靠 `height: 100%` 撑——
`G3Select` 的触发器自带 `min-height: 32px`,在 30px 的行里会直接把行顶高 2px。
显式 `height: var(--row-height)` + `min-height: 0` 把下限压死。
2. **水平内边距只能由 td 提供**(库的 `--cell-padding-x`)。若改成给编辑器加
透明边框,编辑器与纯文本格会各用一套逻辑像素取整,窄列下出现「编辑前文字
偏移 1px」的漂移;td padding 对两者一视同仁,**进出编辑态零位移**。
3. **无边框 ≠ 无提示**。焦点环画在**外层 td 的 `inset` 阴影**上(`:focus-within`):
内阴影在 padding box 内侧绘制,不参与 td 自身的盒模型计算,几何绝对稳定,
且天然铺满整格;hover 换成格内极淡填充,校验用文字色 + 1px 内描边。
三者都不占用布局空间。
去零的是 `padding` / `border` / `border-radius` / `background`,
**绝不动 height**;被移除的内边距由编辑器内层控件补回一次网格间距
(`space-1` / `space-2`,与纯文本格同一档),因此「无间距」的收益是
**格内不留空白**,而不是让文字失去网格对齐。
---
## 5. 与现有约定的对齐(必改项)
| 参考实现 | 我们必须改成 |
|---|---|
| `<style scoped>` + 非 scoped 全局覆盖 | **禁止 scoped**(CODEBUDDY.md 明确),全走 `g3-` 前缀 + BEM |
| `@/components/ui/button` 等直接 import | `G3Button` / `G3Modal` / `G3Select` / `G3Input` |
| `var(--fms-*)` 业务变量 | `--g3-*` 令牌,**每个都带 fallback** |
| `useAppStore()` 取主题 | ConfigProvider |
| `dataIndex` 无类型约束(JS) | 手写 `types.ts` + 泛型 `G3TableColumn<T>` |
| 无 `resizable: false` 语义 | 保留(操作列/展开列必须禁止拖宽,见 §4.1 注释原因) |
**RTL**:全部用逻辑属性,带符号位移走 `--g3-dir-sign`;水平方向键走
`horizontalArrowStep(key, direction)`。表头拖拽 / 列序拖拽需单独验 RTL 几何。
---
## 6. 新增依赖
```yaml
# packages/ui/package.json
dependencies:
stk-table-vue: ^1.1.0
```
- MIT,零运行时依赖、零 peer 依赖,纯 ESM,体积可控(主包 ~23KB + 分块 185KB)。
- **不改** `pnpm-workspace.yaml` 的 `allowBuilds`(该包无安装脚本)。
- 需确认发布物无 `exports` 字段(实测确实没有),我们按 `lib/stk-table-vue.js` 深路径引用时
要留意——**建议用主入口 `import { StkTable } from 'stk-table-vue'`**,样式单独引。
---
## 7. 测试计划
按仓库三层测试约定:
- **L0 类型**:`pnpm --filter @g3soft/ui typecheck`。必须单独跑——`pnpm build` 不 gate 类型错误。
- **L1/L2 单测**(happy-dom):
- `utils/column.test.ts`:内部列注入 / 剥离、`headerAlign` 推断、列宽回写
- `utils/selection.test.ts`:全选 / 半选 / 禁用行 / 筛选后计数
- `utils/editable.test.ts`:草稿保持、`notifyChange` 去重、焦点判定矩阵
- `G3Table.test.ts`:组件契约(列渲染、门控开关、空态、只读)
- **L3 E2E**(必须在真浏览器,happy-dom 无布局引擎):
- **虚拟滚动几何**:100 行以下不虚拟、101 行以上虚拟;滚动后行位置正确
- **列宽/列序拖拽**:`page.mouse` 前先 `scrollIntoViewIfNeeded()`
- **resize 防抖**:容器尺寸变化后不出现中间态抖动
- **RTL 几何**:LTR / RTL 各测一次(单侧通过可能是巧合)
- **playground**:新增可交互元素必须挂 `data-testid`(E2E 只按 testid 找元素)。
- `tests/docs-examples.test.ts` 会校验文档示例的 prop 契约——**新增示例的 prop 名必须与
运行时 props 对账**,写错会被拦(这正是它存在的意义)。
**注意**:`publint` 在 `prepublishOnly` 跑,新增深路径引用可能触发 `exports` 一致性检查失败。
---
## 8. 实施顺序(建议分 4 批,每批可独立验证)
1. **底座打通**:装依赖 → 样式合流进 `dist/style.css` → 最小可渲染表格 → 验证 ESM / 类型 / 主题变量不冲突
2. **核心只读**:列定义 + 类型 + 虚拟门控(§4.1/4.2)+ 序号列 + 排序 + 固定列 + 空态 + 单测
3. **交互层**:多选列(§4.6)+ resize/键盘防抖(§4.4)+ 列宽/列序拖拽 + E2E 几何
4. **编辑与操作**:`customCell` 恒定注入(§4.3)+ 行内编辑 + 操作列 + 行拖拽 + 表尾合计 + 文档/示例
---
## 9. 待你确认的点
1. **§2.2 样式合流**:接受把库样式打进我们的 `dist/style.css` 吗?(否则消费方要多引一次)
2. **§2.3 区域选择自注册**:组件内自动注册 vs 暴露显式 API?(我倾向自动 + 显式覆盖)
3. **§4.1 门控阈值 100 / 20**:沿用,还是按我们的场景调整?
4. **命名**:`G3Table` / `table`(与现有 `G3Splitter` / `splitter` 一致)
5. **C 类 workaround**:确认「只搬 A/B、C 类逐条实测」这个原则(避免把版本补丁搬进库)