u
This commit is contained in:
1 parent
7bd5f1dde9
commit
f3936cec61
70 files changed
+13074
-187
No files matched your search
@@ -0,0 +1,94 @@
|
||||
feat(table): 重构行内编辑——无缝编辑器、全表校验态、列宽拖拽、行高亮
|
||||
|
||||
## 概述
|
||||
|
||||
重做 G3Table 行内编辑的视觉与交互模型:编辑器「无缝嵌入单元格」(无外间距、
|
||||
无常驻边框),校验态全表可见(不再依赖点击行),列宽拖拽与行高亮的行为与
|
||||
fms-vue 的 FmsTable 对齐,并补上缺失的 row-click 事件与 warning 级校验。
|
||||
|
||||
## 组件库(g3soft-libs)
|
||||
|
||||
### G3Table.vue
|
||||
- 新增 `editorSeamless` prop(默认 true):编辑格无外间距、编辑器无常驻边框,
|
||||
焦点提示由 td 的 `:focus-within` inset 环承担,hover 用 outline、校验用文字色
|
||||
+ 内描边;编辑格底色用 `--td-bgc`(不透明、主题自适应),仅在未选中时铺在
|
||||
td 上,避免盖住库的边框渐变与选区底色。
|
||||
- 新增 `row-click` 事件透传(配合受控 `active-row-key` 实现「点击行高亮」,
|
||||
与 fms 的 `@row-click="(_, row) => activeKey = ..."` 用法一致)。
|
||||
- `active-row-key` 判定修复:`vnode.props` 里存的是父级模板的**原始写法**,
|
||||
需同时匹配 camelCase 与 kebab-case(`activeRowKey` / `active-row-key`),
|
||||
此前只认驼峰导致官方推荐的 kebab 写法完全失效。
|
||||
- `activeRowKey` watcher 改为同步(去掉 `await nextTick()`),修复编辑态下
|
||||
点击行时「高亮设上又被清掉」的竞态;仅在未受控时才由内部设置当前行。
|
||||
- 校验态(error / warning)抽取为参数化 mixin,两种等级共用;选择器覆盖
|
||||
**编辑器**与**展示态 span** 两种形态(校验全表可见的关键)。
|
||||
- 列宽拖拽:恢复 `columnsBound` 门控(未绑 `v-model:columns` 时关闭,与 fms
|
||||
一致);移除实测无效的「内部自存列宽」方案(库会把拖拽结果写进自己的列
|
||||
对象后直接丢弃,不绑 v-model 根本无法回写)。
|
||||
- 只读列灰底判据修正:从 `tr:has(编辑器)` 改为「该行有 input/select 编辑器」,
|
||||
并排除序号列(`.seq-column`)。此前 checkbox/select 始终挂载导致「除了启用列
|
||||
全灰」的误判。
|
||||
|
||||
### utils/column.ts
|
||||
- `resolveCustomCell` 门控新增:配了 `validate` / `validateWarning` 的列,
|
||||
**无论编辑态与否**都注入 customCell(走 displayValue)。这是「校验态全表可见」
|
||||
的根因修复——此前 text/number 列在非编辑态走库原生 td,校验态根本不渲染。
|
||||
|
||||
### utils/editable.ts
|
||||
- `displayValue` 在非编辑态也求校验态并打状态类(error/warning 全表可见)。
|
||||
- 新增 `validateWarning` 支持:与 `validate` 同约定(falsy 通过 / string 有问题),
|
||||
error 优先于 warning;抽 `editorStatusClass` 统一 input/select/checkbox 三条路径。
|
||||
- 修复 `resolveEditorStatus` 的布尔语义:此前 `if (!result) return ''` 是
|
||||
「truthy 报错」,与文档「falsy 通过」**正好相反**,导致 `validate: v => v > 0`
|
||||
这类最自然写法判定翻转。
|
||||
|
||||
### select/Select.vue + types.ts
|
||||
- 新增 `seamless` prop:去掉触发器自身描边/圆角/底色,`min-height: 0` 释放
|
||||
32px 下限;根节点改 `display:flex; align-items:stretch` 让触发器撑满、文字
|
||||
垂直居中(与 input 行为一致)。
|
||||
- 网格间距从内层 input 上移到触发器(flex 容器),让清除/箭头图标一起内缩,
|
||||
不再贴到格右边界。
|
||||
|
||||
### types.ts
|
||||
- `TableEditor` 新增 `validate`、`validateWarning`、`errorStyle` 字段。
|
||||
- `TableEmits` 新增 `row-click` 事件。
|
||||
|
||||
### 测试
|
||||
- `utils/editable.test.ts`:新增 validate / validateWarning 语义、error 优先、
|
||||
抛错降级、三种编辑器类型共用状态类等 6 组用例(合计 47 个)。
|
||||
- `e2e/table.spec.ts`:新增「校验态全表可见」「校验态落在正确列」「列宽拖拽
|
||||
需绑 v-model」「点击行进入编辑时高亮跟随」4 条真浏览器断言(合计 26 个)。
|
||||
- `playground/main.ts`:金额列加 error 校验、备注列加 warning 校验,提供三级
|
||||
状态的真实样例数据。
|
||||
|
||||
### 文档
|
||||
- `README.md`:补「构建组件库前必须停 ERP dev server」的约定(含真实报错日志)。
|
||||
- `docs/content/ui/data/table.md`、`docs/table-design.md`、`docs/examples/table/editable.vue`:
|
||||
同步 editorSeamless、validate/validateWarning、row-click、只读列灰底的说明。
|
||||
|
||||
## ERP(g3soft-erp)
|
||||
|
||||
### web/src/views/ocean/Booking.vue
|
||||
- 重写订舱单范例:覆盖 text/number/select/checkbox 四种编辑器、error/warning
|
||||
校验、按行禁用、只读列灰底、当前行高亮、保存时整表校验并定位首个错误格。
|
||||
- 删除「编辑明细/取消编辑」按钮与 snapshot 回滚机制,页面**始终可编辑**,
|
||||
且不默认激活任何一行(编辑目标由用户点击产生)。
|
||||
- 给 G3Table 绑定 `v-model:columns`(列宽拖拽的必要条件,与 fms 用法一致)。
|
||||
|
||||
### README.md
|
||||
- 补「本地联调组件库:构建时必须先停 dev server」的约定。
|
||||
|
||||
## 验证
|
||||
|
||||
- `pnpm --filter @g3soft/ui typecheck` 通过
|
||||
- `pnpm --filter @g3soft/ui exec vitest run src/table` → 124 passed
|
||||
- `pnpm --filter @g3soft/ui exec playwright test e2e/table.spec.ts` → 26 passed
|
||||
|
||||
## 备注
|
||||
|
||||
- **本次改动仅涉及前端**。`git diff` 中出现的 ERP server 端 Java 文件
|
||||
(GlobalExceptionHandler.java、JwtAuthFilter.java、RequestLoggingFilter.java、
|
||||
logback-spring.xml 等)及 `log-viewer.html`、`_gen.demologs.js` 等,属于仓库
|
||||
其它工作流的改动,**不在本次提交范围**,请勿一并提交。
|
||||
- 校验态底色比例(error 12% / warning 8%,暗色 8% / 6%)为观感推定的档位,
|
||||
非实测调优值,可按实际观感调整。
|
||||
@@ -47,6 +47,39 @@ pnpm build:docs # 构建文档站静态文件
|
||||
|
||||
文档站静态产物在 `docs/.output/public`,可直接部署到任意静态托管。
|
||||
|
||||
### 改完组件库,怎么让业务方生效
|
||||
|
||||
`g3soft-erp` 通过 `overrides` link 到本仓库的 `packages/ui`,**消费的是构建产物 `dist/`**(`exports` 指向 `./dist/index.js` 与 `./dist/style.css`),不是源码。所以改了 `packages/ui/src` 之后不重新 build,业务方一点变化都看不到。
|
||||
|
||||
关键在于**不能边跑 ERP dev server 边构建**。ERP 的 Vite 监视着 `dist/`,而 `vite build` 会先清空再逐个写回产物文件,于是:
|
||||
|
||||
1. 每个产物文件被重写都触发一次 `page reload`,浏览器反复刷新;
|
||||
2. 构建中途 `dist/style.css` 会**短暂不存在**,而 ERP 的 `src/styles/index.scss` 里有 `@use '@g3soft/ui/style.css';`,Sass 解析时文件已经没了 —— dev server 进程直接崩溃:
|
||||
|
||||
```
|
||||
[vite] Internal server error: [sass] ENOENT: no such file or directory,
|
||||
open 'D:\workspace\code\g3soft-libs\packages\ui\dist\style.css'
|
||||
╷
|
||||
9 │ @use '@g3soft/ui/style.css';
|
||||
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
╵
|
||||
src\styles\index.scss 9:1 root stylesheet
|
||||
```
|
||||
|
||||
随后是 `ELIFECYCLE Command failed with exit code 4294967295.`
|
||||
|
||||
还有更隐蔽的一种:server 没崩,但缓存到了**写入中途**的模块图,页面白屏、控制台报 `Failed to resolve import "./tooltip/Tooltip.js" from ".../dist/index.js"`。此时 `dist` 盘上的产物其实是完整的,问题纯粹在 Vite 缓存。
|
||||
|
||||
**因此顺序只能是「先停、再构建、后启动」**:
|
||||
|
||||
1. 停掉 ERP 的 dev server(占用 5090 端口)
|
||||
2. 在本仓库执行 `pnpm --filter @g3soft/ui build`
|
||||
3. 在 `g3soft-erp/web` 下执行 `pnpm dev` 重新启动
|
||||
|
||||
若怀疑是缓存问题(白屏、找不到模块),额外删掉 `g3soft-erp/web/node_modules/.vite` 再启动即可。改 `packages/tokens` 同理 —— 它也要 build,且要在业务方 dev server 停掉时做。
|
||||
|
||||
> 这跟 `pnpm dev`(文档站)无关:文档站通过 alias 直连 `packages/ui/src`,改源码即时热更,不需要也不应该为它重新构建组件库。
|
||||
|
||||
### 部署到子路径
|
||||
|
||||
文档站支持挂载到子路径,构建时通过环境变量指定:
|
||||
|
||||
@@ -70,6 +70,25 @@ description: 高性能虚拟滚动表格,支持排序、固定列、多选、
|
||||
|
||||
编辑器的输入值先写进内部草稿,**失焦才落库并抛出 `change`**。这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。用 `editable={false}` 可整体禁用编辑。
|
||||
|
||||
### 编辑器形态:单元格内无外间距、无常驻边框
|
||||
|
||||
默认(`editorSeamless`)下编辑器的**外间距为 0、自身不画常驻边框**,整格铺满、融进表格:
|
||||
|
||||
- 适合整行编辑、键盘连续录入、固定列宽、专业用户(Excel / AG Grid / Handsontable 的表格态);
|
||||
- 单元格几何在进入 / 离开编辑时**完全不变**——编辑器的盒高就等于 `rowHeight`,同一行的纯文本列不会跟着跳;
|
||||
- 水平内边距由单元格统一提供(编辑器和纯文本格用同一套网格),**进出编辑态零位移**;
|
||||
- 视觉提示只在**焦点、hover 与校验**时出现,且都不占用布局空间:
|
||||
- 焦点 → 单元格内嵌的主色焦点环(`td:focus-within` 的内阴影)+ 输入框自身的焦点光晕;
|
||||
- hover → 格内极淡填充;
|
||||
- 校验(`status` = `error` / `warning`)→ 文字色 + 1px 内描边。
|
||||
|
||||
```vue
|
||||
<!-- 回到「格内留一圈间距 + 自身描边」的悬浮卡片观感 -->
|
||||
<G3Table :editor-seamless="false" :columns="columns" :data-source="data" />
|
||||
```
|
||||
|
||||
两种形态的**编辑器盒高都等于 `rowHeight`**,所以切换真值不会改变行高。`textarea` 编辑器在默认的 30px 行里放不下正文,需要显式给足 `row-height`。
|
||||
|
||||
### ⚠ 数据源必须是响应式的
|
||||
|
||||
**行对象必须能被 Vue 追踪**,否则编辑回写不会触发重渲染。典型的踩坑写法是用 `computed` 派生数据:
|
||||
@@ -92,6 +111,15 @@ watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) }
|
||||
|
||||
开发模式下,进入编辑时会检测并打印一条警告指明改法(每个组件实例只警告一次)。
|
||||
|
||||
### 编辑期的只读列
|
||||
|
||||
某一列没有配 `editor` 时,它在编辑期**不可修改**。为了让人一眼看出「这格点不动」,
|
||||
该行里**没有编辑器的格子会自动铺一层灰底**、文字也降一档亮度——
|
||||
不需要业务额外配任何东西,也不需要加锁图标。
|
||||
|
||||
灰底只出现在**正在编辑的那一行**(其余行保持原样),且用 `--td-bgc` 叠中性色得到,
|
||||
所以亮 / 暗主题自动跟随。
|
||||
|
||||
<demo vue="table/editable.vue" />
|
||||
|
||||
## 操作列
|
||||
@@ -129,6 +157,7 @@ watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) }
|
||||
| `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` | 非横向虚拟时铺满容器宽度(开启后关闭列宽拖拽) |
|
||||
@@ -159,6 +188,47 @@ watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) }
|
||||
| `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'`(默认) | 单元格铺**浅红底** + 右上角 **`!` 角标**;聚焦时焦点环转为错误色 |
|
||||
| `'inline'` | 只在编辑器自身画文字色与 1px 内描边,**不**铺底、**不**加角标 |
|
||||
|
||||
浅红底取自设计原型,适合 ERP 明细这类以单元格为视觉单位的场景;
|
||||
若表格本身已经很密、或那一列很窄(角标会挤到文字),用 `'inline'` 更克制。
|
||||
|
||||
### `actions`
|
||||
|
||||
@@ -215,4 +285,5 @@ watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) }
|
||||
- 没有圆角;`bordered: false` 时四边与格线全部消失。
|
||||
- **`--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` 处理。
|
||||
@@ -10,6 +10,13 @@ import type { G3TableColumn, TableRow } from '@g3soft/ui'
|
||||
// select → 用 G3Select 渲染;options 可传数组,也可传「接收当前行的函数」
|
||||
// checkbox → 点击即切换,无需先激活整行;约定 1 = 选中、0 = 未选中(对齐后端 tinyint)
|
||||
//
|
||||
// 形态(editorSeamless,默认 true):**单元格内无外间距 + 编辑器无常驻边框**。
|
||||
// 编辑器整格铺满、融进表格,只有焦点(格内主色环)、hover(极淡填充)
|
||||
// 和校验(红色文字)才出现视觉提示——这是整行编辑 / 键盘连续录入 /
|
||||
// 固定列宽 / 专业用户(Excel、AG Grid)的表格态。
|
||||
// 传 :editor-seamless="false" 则回到「格内留一圈间距 + 自身描边」的悬浮卡片观感。
|
||||
// 两种形态下编辑器的盒高都等于行高,切换不会改变行高、也不会让同行其它格跳动。
|
||||
//
|
||||
// 关键行为:输入值先写进内部草稿,**失焦才落库并抛出 change**。
|
||||
// 这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。
|
||||
const columns: G3TableColumn<TableRow>[] = [
|
||||
@@ -73,7 +80,8 @@ function onChange(row: TableRow, col: G3TableColumn<TableRow>, value: unknown) {
|
||||
最近一次 change:{{ lastChange || '(点击单元格试试)' }}
|
||||
</p>
|
||||
<p style="margin: 0 0 8px; color: #999; font-size: 12px">
|
||||
单击进入编辑 · Enter 确认 · Esc 取消 · Tab / 方向键可换格
|
||||
单击进入编辑 · Enter 确认 · Esc 取消 · Tab / 方向键可换格 ·
|
||||
单元格内无外间距、无常驻边框,焦点时才出现主色焦点环
|
||||
</p>
|
||||
<div style="height: 240px">
|
||||
<G3Table :columns="columns" :data-source="data" :seq="false" @change="onChange" />
|
||||
|
||||
@@ -116,6 +116,7 @@ packages/ui/src/table/
|
||||
| `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**) |
|
||||
@@ -214,6 +215,36 @@ const displayColumns = computed(() => {
|
||||
|
||||
---
|
||||
|
||||
## 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. 与现有约定的对齐(必改项)
|
||||
|
||||
| 参考实现 | 我们必须改成 |
|
||||
|
||||
@@ -154,6 +154,292 @@ test.describe('G3Table 选择列(真浏览器命中)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 行内编辑的几何契约。
|
||||
*
|
||||
* happy-dom 没有布局引擎,这些只能在真浏览器里量:编辑器的盒高必须等于行高,
|
||||
* 且进入编辑**不得改变**所在行的高度(同行的纯文本列也不能跟着跳)。
|
||||
*/
|
||||
test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => {
|
||||
/** 量表格里每一行的高度(取整) */
|
||||
async function rowHeights(page: import('@playwright/test').Page, wrapSelector: string) {
|
||||
return page.locator(wrapSelector).locator('.stk-tbody-main tr').evaluateAll((rows) =>
|
||||
rows.map((el) => Math.round(el.getBoundingClientRect().height)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按**列标题**定位单元格,不写死列序号。
|
||||
*
|
||||
* 写死 `td:nth-child(n)` 是错的来源:列顺序由调用方决定(演示数据里
|
||||
* 「备注」是第 7 列、「状态」是第 5 列),列一变断言就指到别的格子,
|
||||
* 表现为「莫名其妙失败」。
|
||||
*/
|
||||
async function cellByTitle(
|
||||
page: import('@playwright/test').Page,
|
||||
wrapSelector: string,
|
||||
title: string,
|
||||
) {
|
||||
const wrap = page.locator(wrapSelector)
|
||||
const index = await wrap
|
||||
.locator('thead th')
|
||||
.evaluateAll((ths, t) => ths.findIndex((th) => (th.textContent || '').trim() === t), title)
|
||||
if (index < 0) throw new Error(`找不到列标题「${title}」`)
|
||||
return wrap.locator('.stk-tbody-main tr').first().locator('td').nth(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器根节点选择器。
|
||||
*
|
||||
* ⚠️ 不能只用 `.g3-table__cell-editor-root`:checkbox 编辑器也用同一个类
|
||||
* (`label.g3-table__checkbox.g3-table__cell-editor-root`),演示数据里每行
|
||||
* 有两个复选框列,于是 strict mode 直接报「resolved to 3 elements」。
|
||||
* 这里只取 Input / Select 这两种「带外框的」编辑器。
|
||||
*/
|
||||
const EDITOR = '.g3-table__cell-editor-root.g3-input, .g3-table__cell-editor-root.g3-select'
|
||||
|
||||
test('根节点带 is-editor-seamless(默认开启)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
await expect(wrap.locator('.g3-table').first()).toHaveClass(/is-editor-seamless/)
|
||||
})
|
||||
|
||||
test('进入编辑:行高不变,且编辑器铺满单元格(无外间距)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
const before = await rowHeights(page, '[data-testid="table-wrap"]')
|
||||
|
||||
await (await cellByTitle(page, '[data-testid="table-wrap"]', '备注')).click()
|
||||
const editor = wrap.locator(EDITOR).first()
|
||||
await expect(editor).toBeVisible()
|
||||
|
||||
// 编辑器盒高 == 所在 td 的高(td 上下 padding 为 0 → 两者相等)
|
||||
const metrics = await editor.evaluate((el) => {
|
||||
const td = el.closest('td') as HTMLElement
|
||||
const style = getComputedStyle(el)
|
||||
const tdStyle = getComputedStyle(td)
|
||||
return {
|
||||
editor: Math.round(el.getBoundingClientRect().height),
|
||||
td: Math.round(td.getBoundingClientRect().height),
|
||||
tdPadTop: tdStyle.paddingTop,
|
||||
tdPadBottom: tdStyle.paddingBottom,
|
||||
border: style.borderTopWidth,
|
||||
radius: style.borderTopLeftRadius,
|
||||
background: style.backgroundColor,
|
||||
}
|
||||
})
|
||||
|
||||
// 无外间距:编辑器与格同高,td 垂直 padding 为 0(编辑器不被内缩)
|
||||
expect(metrics.editor).toBe(metrics.td)
|
||||
expect(metrics.tdPadTop).toBe('0px')
|
||||
expect(metrics.tdPadBottom).toBe('0px')
|
||||
// 无常驻边框 / 圆角 / 底色
|
||||
expect(metrics.border).toBe('0px')
|
||||
expect(metrics.radius).toBe('0px')
|
||||
expect(metrics.background).toBe('rgba(0, 0, 0, 0)')
|
||||
|
||||
// 关键回归:编辑后行高不变(历史 bug 是从 30px 变 42px)
|
||||
expect(await rowHeights(page, '[data-testid="table-wrap"]')).toEqual(before)
|
||||
})
|
||||
|
||||
test('Input 编辑器:内边距转移到内层原生 input,不落在外壳上', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
await (await cellByTitle(page, '[data-testid="table-wrap"]', '备注')).click()
|
||||
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
|
||||
|
||||
const padding = await wrap
|
||||
.locator('.g3-table__cell-editor-root.g3-input')
|
||||
.first()
|
||||
.evaluate((el) => ({
|
||||
shell: getComputedStyle(el).padding,
|
||||
// 精确取原生控件;`.g3-input input` 会连 password/clear 按钮一起算进来
|
||||
control: getComputedStyle(el.querySelector('.g3-input__control') as HTMLElement).padding,
|
||||
}))
|
||||
|
||||
// 外壳无内边距 = 无外间距;网格间距由内层控件补回
|
||||
expect(padding.shell).toBe('0px')
|
||||
expect(padding.control).not.toBe('0px')
|
||||
})
|
||||
|
||||
test('焦点态才出现视觉提示:单元格内嵌焦点环', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
const target = await cellByTitle(page, '[data-testid="table-wrap"]', '备注')
|
||||
|
||||
// 未聚焦时该格没有内嵌焦点环
|
||||
const idle = await target.evaluate((el) => getComputedStyle(el).boxShadow)
|
||||
expect(idle).not.toContain('inset')
|
||||
|
||||
await target.click()
|
||||
await expect(wrap.locator(EDITOR).first()).toBeVisible()
|
||||
|
||||
// 焦点环画在**被点的那一格**上(限定 target,避免匹配到同行其它格)
|
||||
const focused = await target.evaluate((el) => getComputedStyle(el).boxShadow)
|
||||
expect(focused).toContain('inset')
|
||||
})
|
||||
|
||||
test('select 编辑器同样铺满:触发器的描边 / 圆角 / 32px 下限都被去掉', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
const before = await rowHeights(page, '[data-testid="table-wrap"]')
|
||||
|
||||
await (await cellByTitle(page, '[data-testid="table-wrap"]', '状态')).click()
|
||||
const select = wrap.locator('.g3-table__cell-editor-root.g3-select').first()
|
||||
await expect(select).toBeVisible()
|
||||
|
||||
/**
|
||||
* 关键:量**触发器**而不是组件根。
|
||||
*
|
||||
* 触发器的真实 DOM 位置是 `div.g3-select > [Popover] > span.g3-select__trigger`,
|
||||
* 不在根的直接子树里 —— 只量根会漏掉真正带描边/圆角的那个元素
|
||||
* (这正是此前「改了但看着没变」的原因)。
|
||||
*/
|
||||
const metrics = await select.evaluate((el) => {
|
||||
const trigger = el.querySelector('.g3-select__trigger') as HTMLElement
|
||||
const s = getComputedStyle(trigger)
|
||||
const root = getComputedStyle(el)
|
||||
return {
|
||||
rootClass: el.className,
|
||||
triggerHeight: Math.round(trigger.getBoundingClientRect().height),
|
||||
triggerBorder: s.borderTopWidth,
|
||||
triggerRadius: s.borderTopLeftRadius,
|
||||
triggerBg: s.backgroundColor,
|
||||
rootBorder: root.borderTopWidth,
|
||||
rootRadius: root.borderTopLeftRadius,
|
||||
}
|
||||
})
|
||||
|
||||
// 无缝修饰类必须真的落到 G3Select 根上(否则下面的样式规则不生效)
|
||||
expect(metrics.rootClass).toContain('is-seamless')
|
||||
|
||||
expect(metrics.triggerBorder).toBe('0px')
|
||||
expect(metrics.triggerRadius).toBe('0px')
|
||||
expect(metrics.triggerBg).toBe('rgba(0, 0, 0, 0)')
|
||||
expect(metrics.rootBorder).toBe('0px')
|
||||
expect(metrics.rootRadius).toBe('0px')
|
||||
|
||||
// 触发器不再锁 32px(行高 30px),否则会把行顶高 2px
|
||||
const rowHeight = await wrap
|
||||
.locator('.stk-tbody-main tr')
|
||||
.first()
|
||||
.evaluate((el) => Math.round(el.getBoundingClientRect().height))
|
||||
expect(metrics.triggerHeight).toBeLessThanOrEqual(rowHeight)
|
||||
|
||||
expect(await rowHeights(page, '[data-testid="table-wrap"]')).toEqual(before)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 校验态、列宽拖拽、当前行高亮 —— 这三条是本组件反复回归过的行为,
|
||||
* 专门用真浏览器锁住(happy-dom 没有布局引擎,也测不了 :has() 命中)。
|
||||
*
|
||||
* 宿主导入的 playground 数据里:
|
||||
* - 金额列 validate:> 0 否则 error(第 2 行 amount=0 → 必有一处 error)
|
||||
* - 备注列 validateWarning:非空否则 warning(第 2 行 remark='' → 必有一处 warning)
|
||||
* - table-wrap 绑了 v-model:columns(列宽拖拽的前提,缺了拖不动)
|
||||
*/
|
||||
test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => {
|
||||
test('校验态全表可见:不点任何行就能看到 error 与 warning', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
// 关键前提:进入页面后**不点击任何单元格**,直接断言校验态存在。
|
||||
// 这锁的是「校验是数据的状态,不依赖哪一行被交互过」——此前 text/number
|
||||
// 列在非编辑态走库原生 td,校验态根本不渲染(必须点行才出现)。
|
||||
const errCells = wrap.locator('td:has(> .is-editor-error)')
|
||||
const warnCells = wrap.locator('td:has(> .is-editor-warning)')
|
||||
await expect(errCells).toHaveCount(1) // 金额列第 2 行 amount=0
|
||||
await expect(warnCells).toHaveCount(1) // 备注列第 2 行 remark=''
|
||||
})
|
||||
|
||||
test('校验态落在正确的列上(error=金额、warning=备注)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
// 用列标题定位,不写死索引
|
||||
const ths = await wrap.locator('thead th').evaluateAll((all) =>
|
||||
all.map((t) => (t.textContent || '').trim()),
|
||||
)
|
||||
const amountIdx = ths.findIndex((t) => t === '金额')
|
||||
const remarkIdx = ths.findIndex((t) => t === '备注')
|
||||
|
||||
// 第 2 行(合同 B)的金额格是 error、备注格是 warning
|
||||
const row = wrap.locator('.stk-tbody-main tr').nth(1)
|
||||
await expect(row.locator('td').nth(amountIdx).locator('.is-editor-error')).toHaveCount(1)
|
||||
await expect(row.locator('td').nth(remarkIdx).locator('.is-editor-warning')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('列宽拖拽:绑定 v-model:columns 后可拖动,宽度真实变化', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
// 表格应带 col-resizable 类(列宽拖拽已启用)
|
||||
await expect(wrap.locator('.stk-table')).toHaveClass(/col-resizable/)
|
||||
|
||||
// 取「名称」列表头宽度(第 2 个 th,序号列是第 1 个)
|
||||
const th = wrap.locator('thead th').nth(2)
|
||||
const before = await th.evaluate((el) => Math.round(el.getBoundingClientRect().width))
|
||||
|
||||
// 拖它的右边缘手柄 +60px
|
||||
const handle = th.locator('.table-header-resizer.right')
|
||||
const hb = await handle.boundingBox()
|
||||
expect(hb).not.toBeNull()
|
||||
if (hb) {
|
||||
const cx = hb.x + hb.width / 2
|
||||
const cy = hb.y + hb.height / 2
|
||||
await page.mouse.move(cx, cy)
|
||||
await page.mouse.down()
|
||||
for (let i = 1; i <= 15; i += 1) {
|
||||
await page.mouse.move(cx + i * 4, cy)
|
||||
await page.waitForTimeout(15)
|
||||
}
|
||||
await page.mouse.up()
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
const after = await th.evaluate((el) => Math.round(el.getBoundingClientRect().width))
|
||||
expect(after).toBeGreaterThan(before)
|
||||
})
|
||||
|
||||
test('点击行进入编辑时,当前行高亮(active)跟随', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
const rows = wrap.locator('.stk-tbody-main tr')
|
||||
|
||||
// 初始无 active 行
|
||||
const activeCount = async () =>
|
||||
wrap.locator('.stk-tbody-main tr.active').count()
|
||||
expect(await activeCount()).toBe(0)
|
||||
|
||||
// 点第 2 行的一个可编辑格(金额列),进入编辑并高亮
|
||||
const ths = await wrap.locator('thead th').evaluateAll((all) =>
|
||||
all.map((t) => (t.textContent || '').trim()),
|
||||
)
|
||||
const amountIdx = ths.findIndex((t) => t === '金额')
|
||||
await rows.nth(1).locator('td').nth(amountIdx).click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// 第 2 行应带 active
|
||||
await expect(rows.nth(1)).toHaveClass(/active/)
|
||||
expect(await activeCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 这两条盯的是「库的 flex 布局 + 自绘分隔线」这对组合:
|
||||
* 库的 DOM 里 table.stk-table-main 是 .stk-table-scroll-container 的 flex item,
|
||||
|
||||
@@ -197,13 +197,31 @@ const App = {
|
||||
// 注意必须是 ref:模板里 v-model:columns 要能回写,否则拖列宽无处落库(松手回弹)
|
||||
const tableColumns = ref([
|
||||
{ dataIndex: 'name', title: '名称', width: 180 },
|
||||
{ dataIndex: 'amount', title: '金额', width: 120, align: 'right' },
|
||||
{
|
||||
dataIndex: 'amount',
|
||||
title: '金额',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
editor: {
|
||||
type: 'number',
|
||||
// 金额必须 > 0:让「校验态全表可见」在 E2E 里有可断言的 error 数据
|
||||
validate: (v: unknown) => Number(v) > 0 || '金额必须大于 0',
|
||||
},
|
||||
},
|
||||
{ dataIndex: 'status', title: '状态', width: 110, editor: { type: 'select', options: [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 0 },
|
||||
] } },
|
||||
{ dataIndex: 'enabled', title: '启用', width: 80, editor: { type: 'checkbox' } },
|
||||
{ dataIndex: 'remark', title: '备注', editor: { type: 'text' } },
|
||||
{
|
||||
dataIndex: 'remark',
|
||||
title: '备注',
|
||||
editor: {
|
||||
type: 'text',
|
||||
// 备注非空:提供 warning 级校验(金额是 error,这里是 warning,两级都有样例)
|
||||
validateWarning: (v: unknown) => String(v ?? '').length > 0 || '建议填写备注',
|
||||
},
|
||||
},
|
||||
{ dataIndex: 'op', title: '操作', width: 140, resizable: false, actions: [
|
||||
{ label: '编辑', onClick: () => {} },
|
||||
{ label: '删除', type: 'danger', confirm: '确认删除该行?', onClick: () => {} },
|
||||
@@ -211,7 +229,7 @@ const App = {
|
||||
])
|
||||
const tableData = ref([
|
||||
{ id: 1, name: '合同 A', amount: 12000, status: 1, enabled: 1, remark: '已归档' },
|
||||
{ id: 2, name: '合同 B', amount: 8600, status: 0, enabled: 0, remark: '' },
|
||||
{ id: 2, name: '合同 B', amount: 0, status: 0, enabled: 0, remark: '' },
|
||||
{ id: 3, name: '合同 C', amount: 43000, status: 1, enabled: 1, remark: '待复核' },
|
||||
])
|
||||
const tableSelectedKeys = ref([])
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ChevronUp, RotateCcw } from '@lucide/vue'
|
||||
import { G3Button } from '../button'
|
||||
import { useNamespace } from '../_composables/useNamespace'
|
||||
import type { FpsMonitorProps } from './types'
|
||||
|
||||
/**
|
||||
* FPS 性能监控浮层。
|
||||
*
|
||||
* 形态:右上角固定的折叠胶囊(状态色圆点 + 当前 FPS),点击展开完整面板
|
||||
* (当前 FPS 大字 + 时序柱状图 + avg/min 汇总)。可拖拽、双击隐藏。
|
||||
*
|
||||
* 纯展示组件,显隐由调用方控制(v-if / ref 调 show/hide)。偏好开关、持久化
|
||||
* 属应用层,不进组件库。
|
||||
*/
|
||||
|
||||
defineOptions({ name: 'G3FpsMonitor', inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<FpsMonitorProps>(), {
|
||||
sampleInterval: 500,
|
||||
historySize: 48,
|
||||
baseFps: 60,
|
||||
collapsed: true,
|
||||
})
|
||||
|
||||
const { b, e, m, is } = useNamespace('fps-monitor')
|
||||
|
||||
/* ---------- 采样状态 ---------- */
|
||||
const currentFps = ref(0)
|
||||
const averageFps = ref(0)
|
||||
const minimumFps = ref<number | null>(null)
|
||||
const history = ref<number[]>([])
|
||||
|
||||
/* 折叠态:默认只留一颗迷你胶囊。胶囊 pointer-events: none,展开入口走窗口捕获阶段
|
||||
* 坐标判断,避免固定浮层吞掉页面右上角控件的 hover 命中区。 */
|
||||
const collapsed = ref(props.collapsed)
|
||||
const pillEl = ref<HTMLElement | null>(null)
|
||||
let pillDrag: {
|
||||
pointerX: number
|
||||
pointerY: number
|
||||
originX: number
|
||||
originY: number
|
||||
width: number
|
||||
height: number
|
||||
moved: boolean
|
||||
} | null = null
|
||||
const PILL_DRAG_THRESHOLD = 4
|
||||
|
||||
/* 非法入参归一化,避免除零 / 空图 */
|
||||
const normalizedSampleInterval = computed(() => Math.max(100, props.sampleInterval))
|
||||
const normalizedHistorySize = computed(() => Math.max(1, Math.floor(props.historySize)))
|
||||
const normalizedBaseFps = computed(() => Math.max(1, props.baseFps))
|
||||
|
||||
/* rAF 高频窗口计数用普通变量,避免响应式开销 */
|
||||
let animationFrameId = 0
|
||||
let sampleFrames = 0
|
||||
let sampleStartedAt = 0
|
||||
let totalFrames = 0
|
||||
let totalTime = 0
|
||||
|
||||
function resetSampleWindow() {
|
||||
sampleFrames = 0
|
||||
sampleStartedAt = performance.now()
|
||||
}
|
||||
|
||||
function tick(now: number) {
|
||||
sampleFrames += 1
|
||||
totalFrames += 1
|
||||
|
||||
const elapsed = now - sampleStartedAt
|
||||
if (elapsed >= normalizedSampleInterval.value) {
|
||||
const fps = Math.round((sampleFrames * 1000) / elapsed)
|
||||
currentFps.value = fps
|
||||
minimumFps.value = minimumFps.value == null ? fps : Math.min(minimumFps.value, fps)
|
||||
totalTime += elapsed
|
||||
averageFps.value = Math.round((totalFrames * 1000) / totalTime)
|
||||
history.value = [...history.value, fps].slice(-normalizedHistorySize.value)
|
||||
sampleFrames = 0
|
||||
sampleStartedAt = now
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
/* ---------- 统计重置 ---------- */
|
||||
function reset() {
|
||||
currentFps.value = 0
|
||||
averageFps.value = 0
|
||||
minimumFps.value = null
|
||||
history.value = []
|
||||
totalFrames = 0
|
||||
totalTime = 0
|
||||
resetSampleWindow()
|
||||
}
|
||||
|
||||
/* 切后台浏览器暂停 rAF,恢复前台后重置窗口,避免把暂停时长计入均值 */
|
||||
function handleVisibilityChange() {
|
||||
if (!document.hidden) resetSampleWindow()
|
||||
}
|
||||
|
||||
/* ---------- 颜色分级与柱状图 ----------
|
||||
* 业界惯例(stats.js / react-fps):绿=流畅、黄=卡顿、红=掉帧。
|
||||
* 阈值取基准 FPS 的 5/6 与 1/2。 */
|
||||
function getFpsColor(fps: number) {
|
||||
if (fps >= normalizedBaseFps.value * (5 / 6)) return 'success'
|
||||
if (fps >= normalizedBaseFps.value / 2) return 'warning'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
const currentFpsColor = computed(() => getFpsColor(currentFps.value))
|
||||
|
||||
/* 柱高按基准 FPS 归一化并夹在 8%–100%,保证低帧率时仍有可见柱体 */
|
||||
const barStyles = computed(() =>
|
||||
history.value.map((fps) => ({
|
||||
height: `${Math.max(8, Math.min(100, (fps / normalizedBaseFps.value) * 100))}%`,
|
||||
type: getFpsColor(fps),
|
||||
})),
|
||||
)
|
||||
|
||||
/* ---------- 拖拽移动 ---------- */
|
||||
const rootEl = ref<HTMLElement | null>(null)
|
||||
const pos = ref<{ x: number; y: number } | null>(null)
|
||||
const isDragging = ref(false)
|
||||
let dragStart: {
|
||||
pointerX: number
|
||||
pointerY: number
|
||||
originX: number
|
||||
originY: number
|
||||
width: number
|
||||
height: number
|
||||
} | null = null
|
||||
|
||||
/* ---------- 双击隐藏(也可由父级 ref 调 show/hide) ---------- */
|
||||
const visible = ref(true)
|
||||
|
||||
function onDblClick() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function show() {
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
function hide() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
defineExpose({ show, hide, reset })
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if ((e.target as HTMLElement).closest('[data-fps-no-drag]')) return
|
||||
const rect = rootEl.value!.getBoundingClientRect()
|
||||
dragStart = {
|
||||
pointerX: e.clientX,
|
||||
pointerY: e.clientY,
|
||||
originX: pos.value ? pos.value.x : rect.left,
|
||||
originY: pos.value ? pos.value.y : rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
}
|
||||
isDragging.value = true
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!dragStart) return
|
||||
const dx = e.clientX - dragStart.pointerX
|
||||
const dy = e.clientY - dragStart.pointerY
|
||||
const x = Math.max(0, Math.min(dragStart.originX + dx, window.innerWidth - dragStart.width))
|
||||
const y = Math.max(0, Math.min(dragStart.originY + dy, window.innerHeight - dragStart.height))
|
||||
pos.value = { x, y }
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragStart = null
|
||||
isDragging.value = false
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
|
||||
/* 折叠态交互:捕获阶段判断是否点在胶囊上 */
|
||||
function onPillPointerDownCapture(e: PointerEvent) {
|
||||
if (!collapsed.value || !visible.value || !pillEl.value) return
|
||||
const rect = pillEl.value.getBoundingClientRect()
|
||||
if (
|
||||
e.clientX < rect.left ||
|
||||
e.clientX > rect.right ||
|
||||
e.clientY < rect.top ||
|
||||
e.clientY > rect.bottom
|
||||
) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
pillDrag = {
|
||||
pointerX: e.clientX,
|
||||
pointerY: e.clientY,
|
||||
originX: rect.left,
|
||||
originY: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
moved: false,
|
||||
}
|
||||
window.addEventListener('pointermove', onPillPointerMove)
|
||||
window.addEventListener('pointerup', onPillPointerUp)
|
||||
}
|
||||
|
||||
function onPillPointerMove(e: PointerEvent) {
|
||||
if (!pillDrag) return
|
||||
const dx = e.clientX - pillDrag.pointerX
|
||||
const dy = e.clientY - pillDrag.pointerY
|
||||
if (!pillDrag.moved && Math.abs(dx) < PILL_DRAG_THRESHOLD && Math.abs(dy) < PILL_DRAG_THRESHOLD) {
|
||||
return
|
||||
}
|
||||
pillDrag.moved = true
|
||||
isDragging.value = true
|
||||
const x = Math.max(0, Math.min(pillDrag.originX + dx, window.innerWidth - pillDrag.width))
|
||||
const y = Math.max(0, Math.min(pillDrag.originY + dy, window.innerHeight - pillDrag.height))
|
||||
pos.value = { x, y }
|
||||
}
|
||||
|
||||
function onPillPointerUp() {
|
||||
const wasClick = Boolean(pillDrag && !pillDrag.moved)
|
||||
pillDrag = null
|
||||
isDragging.value = false
|
||||
window.removeEventListener('pointermove', onPillPointerMove)
|
||||
window.removeEventListener('pointerup', onPillPointerUp)
|
||||
if (wasClick) collapsed.value = false
|
||||
}
|
||||
|
||||
/* ---------- 生命周期 ---------- */
|
||||
onMounted(() => {
|
||||
resetSampleWindow()
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.addEventListener('pointerdown', onPillPointerDownCapture, true)
|
||||
animationFrameId = requestAnimationFrame(tick)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.removeEventListener('pointerdown', onPillPointerDownCapture, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
v-show="visible"
|
||||
ref="rootEl"
|
||||
:class="[b(), { [is('dragging')]: isDragging, [is('collapsed')]: collapsed }]"
|
||||
:style="pos ? { left: pos.x + 'px', top: pos.y + 'px' } : undefined"
|
||||
aria-label="FPS 性能监控"
|
||||
v-bind="$attrs"
|
||||
@pointerdown="onPointerDown"
|
||||
@dblclick="onDblClick"
|
||||
>
|
||||
<button
|
||||
v-if="collapsed"
|
||||
ref="pillEl"
|
||||
type="button"
|
||||
:class="e('pill')"
|
||||
title="点击展开 FPS 面板"
|
||||
aria-label="展开 FPS 性能监控面板"
|
||||
>
|
||||
<i :class="[e('pill-dot'), m('pill-dot', currentFpsColor)]" aria-hidden="true" />
|
||||
<b :class="e('pill-value')">{{ currentFps || '--' }}</b>
|
||||
</button>
|
||||
|
||||
<template v-else>
|
||||
<div :class="e('header')">
|
||||
<span :class="e('label')">FPS</span>
|
||||
<strong :class="[e('current'), m('current', currentFpsColor)]">
|
||||
{{ currentFps || '--' }}
|
||||
</strong>
|
||||
<G3Button
|
||||
type="ghost"
|
||||
:class="e('icon-button')"
|
||||
data-fps-no-drag
|
||||
title="收起面板"
|
||||
aria-label="收起 FPS 面板"
|
||||
@click="collapsed = true"
|
||||
>
|
||||
<ChevronUp />
|
||||
</G3Button>
|
||||
<G3Button
|
||||
type="ghost"
|
||||
:class="[e('icon-button'), e('reset')]"
|
||||
data-fps-no-drag
|
||||
title="重置统计"
|
||||
aria-label="重置 FPS 统计"
|
||||
@click="reset"
|
||||
>
|
||||
<RotateCcw />
|
||||
</G3Button>
|
||||
</div>
|
||||
|
||||
<div :class="e('chart')" aria-hidden="true">
|
||||
<span
|
||||
v-for="(bar, index) in barStyles"
|
||||
:key="index"
|
||||
:class="[e('bar'), m('bar', bar.type)]"
|
||||
:style="{ height: bar.height }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="e('summary')">
|
||||
<span>avg <b>{{ averageFps || '--' }}</b></span>
|
||||
<span>min <b>{{ minimumFps ?? '--' }}</b></span>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@g3soft/tokens/scss' as *;
|
||||
|
||||
.#{b('fps-monitor')} {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 132px;
|
||||
padding: 8px 10px;
|
||||
color: v('text-1', #1f2329);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
user-select: none;
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
background: v('bg-container', #fff);
|
||||
border: 1px solid v('border-color', #e5e6eb);
|
||||
border-radius: v('radius', 6px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
|
||||
transition: box-shadow v('duration', 0.2s) v('ease', ease);
|
||||
|
||||
&.#{is('dragging')} {
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
/* 折叠态:容器收缩到胶囊尺寸,整体退出鼠标命中 */
|
||||
.#{b('fps-monitor')}.#{is('collapsed')} {
|
||||
width: auto;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.#{b('fps-monitor')}.#{is('collapsed')}.#{is('dragging')} .#{e('fps-monitor', 'pill')} {
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
/* 折叠胶囊 */
|
||||
.#{e('fps-monitor', 'pill')} {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 18px;
|
||||
padding: 0 7px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: v('text-2', rgba(0, 0, 0, 0.65));
|
||||
cursor: pointer;
|
||||
background: v('bg-container', #fff);
|
||||
border: 1px solid v('border-color', #e5e6eb);
|
||||
border-radius: 9px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.#{e('fps-monitor', 'pill-dot')} {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: v('color-success', #16c079);
|
||||
|
||||
&.#{m('fps-monitor', 'pill-dot-warning')} {
|
||||
background: v('color-warning', #f5a623);
|
||||
}
|
||||
|
||||
&.#{m('fps-monitor', 'pill-dot-danger')} {
|
||||
background: v('color-danger', #f53f3f);
|
||||
}
|
||||
}
|
||||
|
||||
.#{e('fps-monitor', 'pill-value')} {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: v('text-1', #1f2329);
|
||||
}
|
||||
|
||||
/* 展开面板 */
|
||||
.#{e('fps-monitor', 'header')} {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.#{e('fps-monitor', 'label')} {
|
||||
color: v('text-2', rgba(0, 0, 0, 0.65));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.#{e('fps-monitor', 'current')} {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
color: v('color-success', #16c079);
|
||||
|
||||
&.#{m('fps-monitor', 'current-warning')} {
|
||||
color: v('color-warning', #f5a623);
|
||||
}
|
||||
|
||||
&.#{m('fps-monitor', 'current-danger')} {
|
||||
color: v('color-danger', #f53f3f);
|
||||
}
|
||||
}
|
||||
|
||||
.#{e('fps-monitor', 'chart')} {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
height: 32px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.#{e('fps-monitor', 'bar')} {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-radius: 1px;
|
||||
background: v('color-success', #16c079);
|
||||
|
||||
&.#{m('fps-monitor', 'bar-warning')} {
|
||||
background: v('color-warning', #f5a623);
|
||||
}
|
||||
|
||||
&.#{m('fps-monitor', 'bar-danger')} {
|
||||
background: v('color-danger', #f53f3f);
|
||||
}
|
||||
}
|
||||
|
||||
.#{e('fps-monitor', 'summary')} {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: v('text-2', rgba(0, 0, 0, 0.65));
|
||||
}
|
||||
|
||||
.#{e('fps-monitor', 'summary')} b {
|
||||
color: v('text-1', #1f2329);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 面板内图标按钮:压缩为 20×20 小方钮 */
|
||||
.#{b('fps-monitor')} .#{e('fps-monitor', 'icon-button')} {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: v('radius-sm', 4px);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: v('text-2', rgba(0, 0, 0, 0.65));
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background: v('fill-hover', rgba(0, 0, 0, 0.08));
|
||||
color: v('text-1', #1f2329);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as G3FpsMonitor } from './FpsMonitor.vue'
|
||||
export type { FpsMonitorProps } from './types'
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface FpsMonitorProps {
|
||||
/** 采样间隔(毫秒):FPS 上报与柱状图刷新周期。低于 100 会被钳到 100。 */
|
||||
sampleInterval?: number
|
||||
/** 柱状图保留的历史样本数。低于 1 会被钳到 1。 */
|
||||
historySize?: number
|
||||
/** 基准 FPS:用于颜色分级与柱高换算。低于 1 会被钳到 1。 */
|
||||
baseFps?: number
|
||||
/** 初始是否折叠为迷你胶囊。默认 true。 */
|
||||
collapsed?: boolean
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { G3ColorPicker } from './color-picker'
|
||||
import { G3CalendarPanel, G3DatePicker, G3RangePicker, G3TimeSelect } from './date'
|
||||
import { G3Drawer } from './drawer'
|
||||
import { G3Dropdown, G3DropdownItem } from './dropdown'
|
||||
import { G3FpsMonitor } from './fps-monitor'
|
||||
import { G3Form, G3FormGroup, G3FormItem } from './form'
|
||||
import { G3Col, G3Row } from './grid'
|
||||
import { G3Input } from './input'
|
||||
@@ -45,6 +46,7 @@ export * from './color-picker'
|
||||
export * from './date'
|
||||
export * from './drawer'
|
||||
export * from './dropdown'
|
||||
export * from './fps-monitor'
|
||||
export * from './form'
|
||||
export * from './grid'
|
||||
export * from './input'
|
||||
@@ -136,6 +138,7 @@ const components = {
|
||||
G3Drawer,
|
||||
G3Dropdown,
|
||||
G3DropdownItem,
|
||||
G3FpsMonitor,
|
||||
G3Form,
|
||||
G3FormGroup,
|
||||
G3FormItem,
|
||||
|
||||
@@ -28,6 +28,7 @@ const props = withDefaults(defineProps<SelectProps>(), {
|
||||
allowFreeInput: false,
|
||||
labelKey: 'label',
|
||||
valueKey: 'value',
|
||||
seamless: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -38,7 +39,7 @@ const emit = defineEmits<{
|
||||
loadMore: []
|
||||
}>()
|
||||
|
||||
const { b, e, m, is } = useNamespace('select')
|
||||
const { b, e, is } = useNamespace('select')
|
||||
const locale = useLocale()
|
||||
|
||||
interface NormalizedOption {
|
||||
@@ -482,7 +483,17 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[b(), { [is('disabled')]: disabled }]">
|
||||
<div
|
||||
:class="[
|
||||
b(),
|
||||
{
|
||||
[is('disabled')]: disabled,
|
||||
[is('seamless')]: seamless,
|
||||
[is('trigger-focused')]: triggerFocused,
|
||||
[is('trigger-open')]: open,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<G3Popover
|
||||
:open="open"
|
||||
trigger="manual"
|
||||
@@ -650,6 +661,104 @@ defineExpose({
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 无缝模式(`seamless`):交给表格这类「编辑器要融进容器」的场景。
|
||||
*
|
||||
* 触发器的体量(32px 高 + 8px 水平内边距 + 1px 描边 + 6px 圆角 + 白底)
|
||||
* 是给**表单**用的;嵌进 30px 的表格行里就变成「一格一个悬浮小卡片」,
|
||||
* 32 > 30 还会把行顶高。
|
||||
*
|
||||
* ⚠️ 必须写在这里而不是外层表格样式里:触发器的真实 DOM 位置是
|
||||
* `div.g3-select > [Popover] > span.g3-select__trigger`
|
||||
* —— Popover 的零包裹模式会把事件注入用户 vnode,但**不会**把 span 变成
|
||||
* 组件的直接子元素,所以 `&.g3-select .g3-select__trigger` 之类的后代选择器
|
||||
* 从编辑器根出发是命中不到的(实测:`border` 仍是 1px、`radius` 仍是 6px)。
|
||||
* 在触发器自己的样式块里用「根修饰类 + 后代」才是稳定的。
|
||||
*
|
||||
* 只改 padding / border / radius / background,**不动 min-height 之外的任何尺寸**:
|
||||
* 高度由调用方(表格)显式钉成行高,这里只把 32px 的下限放开。
|
||||
*/
|
||||
.#{b('select')}.#{is('seamless')} {
|
||||
/**
|
||||
* 根容器改成 flex 拉伸。
|
||||
*
|
||||
* 基础样式里根是 `inline-block`(见上),它**没有**能力居中或拉伸子元素:
|
||||
* 子元素按行内基线排列,`min-height: 0` 之后触发器塌到内容高度(14px 字号
|
||||
* × 1.5 行高 ≈ 21px),就贴着容器上沿、下方空出一截 ——
|
||||
* 表现为「同一行里 select 比 input 偏上」。
|
||||
* 实测证据:触发器 `offsetTop: 0 / offsetBottom: 11`(容器 32px、触发器 21px)。
|
||||
*
|
||||
* ⚠️ 只用 `align-items: center` 是不够的:那只是把 21px 的触发器**居中**,
|
||||
* 上下各留 5.5px,看起来仍是「格子里浮着一个小控件」。
|
||||
* 对照 G3Input 的真实行为:它的内层 `__control` 被拉伸到 **29px**
|
||||
* (32px 容器减 1.5px 上下边),内容盒基本撑满整格。
|
||||
* 所以这里用 `stretch` 让触发器**撑满十字轴**,与 input 完全一致。
|
||||
*
|
||||
* 只动 display / 对齐,`width: 100%` 保持,宽度几何不变。
|
||||
*/
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
.#{e('select', 'trigger')} {
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
|
||||
/**
|
||||
* 网格间距加在**触发器**上,而不是内层 input 上。
|
||||
*
|
||||
* 触发器的 flex 子项是「input + 箭头/清除按钮」两个:
|
||||
* 只给 `__input` 加 padding-right 的话,文本框被压窄了,
|
||||
* 但图标按钮排在它**外面**,仍然紧贴容器右边缘 ——
|
||||
* 实测曾出现清除按钮 right=698 与单元格 right=698 完全重合(间距 0px)。
|
||||
* 加在容器上则所有子项(含图标)一起内缩,且与纯文本单元格的
|
||||
* `--cell-padding-x` 同源,编辑/展示切换时文字不跳。
|
||||
*/
|
||||
padding: 0 var(--cell-padding-x, #{v('space-2', 8px)});
|
||||
|
||||
/* 撑满容器:与 G3Input 内层控件的拉伸行为对齐 */
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
||||
/* hover 不再靠描边提示(没有边框可变了) */
|
||||
&:hover {
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚焦 / 展开态**不画任何东西**。
|
||||
*
|
||||
* ⚠️ 这里原先保留了 `box-shadow: 0 0 0 2px ...`,那是**多余的**:
|
||||
* 焦点提示已经由外层单元格的 `td:focus-within` inset 环统一承担,
|
||||
* 触发器再加一圈就在格内形成第二个框 —— 正是用户看到的
|
||||
* 「select 上还有边框」。与 G3Input 的无缝行为保持一致:
|
||||
* input 在 `--focused` 上已经被清成 `box-shadow: none`。
|
||||
*/
|
||||
&.#{is('trigger-focused')} .#{e('select', 'trigger')},
|
||||
&.#{is('trigger-open')} .#{e('select', 'trigger')} {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&.#{is('disabled')} .#{e('select', 'trigger')} {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内层 input **不加**横向内边距 —— 横向间距已经由触发器统一提供
|
||||
* (见上方 `__trigger` 的 padding 注释)。这里再加一次会双重内缩,
|
||||
* 文字会比纯文本单元格多缩进 12px,编辑/展示切换时"跳一下"。
|
||||
*
|
||||
* 纵向仍与 G3Input 的 `__control` 保持同一档(space-1):
|
||||
* 让两者的行盒几何完全一致,行高变化时不会一个先撑高、一个后撑高。
|
||||
*/
|
||||
.#{e('select', 'input')} {
|
||||
padding: v('space-1', 4px) 0;
|
||||
}
|
||||
}
|
||||
.#{e('select', 'input')} {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -30,4 +30,12 @@ export interface SelectProps {
|
||||
labelKey?: string
|
||||
/** 选项对象中作为值的字段名 */
|
||||
valueKey?: string
|
||||
/**
|
||||
* 无缝模式:触发框不画自身的描边 / 圆角 / 底色,也不留内边距。
|
||||
*
|
||||
* 用于「编辑器要融进容器」的场景(表格行内编辑)。默认形态的体量
|
||||
* (32px 高 + 8px 内边距 + 1px 描边 + 6px 圆角)是给表单用的,
|
||||
* 嵌进 30px 的行里会变成悬浮小卡片,且 32 > 30 直接把行顶高。
|
||||
*/
|
||||
seamless?: boolean
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -14,6 +14,8 @@ export type {
|
||||
TableRow,
|
||||
TableRowKey,
|
||||
TableRowSelection,
|
||||
TableScrollbarConfig,
|
||||
TableScrollRowByRow,
|
||||
TableSelectOption,
|
||||
TableSeqConfig,
|
||||
} from './types'
|
||||
@@ -21,6 +21,9 @@ export interface TableSelectOption {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** 编辑器校验态:'' 正常,'error' 危险,'warning' 提醒 */
|
||||
export type TableEditorStatus = '' | 'error' | 'warning'
|
||||
|
||||
/** 行内编辑器配置 */
|
||||
export interface TableEditor<T extends TableRow = TableRow> {
|
||||
type: 'text' | 'number' | 'password' | 'textarea' | 'select' | 'checkbox'
|
||||
@@ -37,6 +40,51 @@ export interface TableEditor<T extends TableRow = TableRow> {
|
||||
emptyValue?: unknown
|
||||
/** 本列值变化时的回调(早于组件的 change 事件) */
|
||||
onChange?: (value: unknown, row: T, col: G3TableColumn<T>) => void
|
||||
/**
|
||||
* 校验:返回 falsy 表示通过;返回 string 表示不通过,且该字符串作为错误原因。
|
||||
*
|
||||
* 为什么用「返回值即结论」而不是传统的 `validate(value) => boolean`:
|
||||
* 表格里校验失败几乎总要告诉用户**为什么**("必须大于 0" / "编码已存在"),
|
||||
* 布尔值会逼调用方再挂一个 message 字段,两者容易不同步。
|
||||
* 让失败原因直接坐在返回值上,只有一处真源。
|
||||
*
|
||||
* 触发时机(与 G3Input 的 status prop 是两回事,见下方 status 的注释):
|
||||
* 校验是**派生值**,不是一次性判定——每次渲染都按当前行数据重算,
|
||||
* 所以用户把 qty 从空改成 12 后,红底与角标会在下一次渲染自动消失,
|
||||
* 不需要业务手动去清状态,也不需要组件内部维护任何 error 注册表。
|
||||
*
|
||||
* 性能:只在**编辑器真正挂载的那一行**上被调用(非编辑行走 displayValue,
|
||||
* 压根不碰这个函数),因此普通滚动路径零开销。
|
||||
*/
|
||||
validate?: (value: unknown, row: T, col: G3TableColumn<T>) => boolean | string
|
||||
/**
|
||||
* **警告**级校验:与 `validate` 同样「返回 falsy 通过 / string 表示有问题」,
|
||||
* 但结果渲染成 warning(琥珀底 + ! 角标)而不是 error(红底)。
|
||||
*
|
||||
* 为什么需要单独一个字段,而不是让 `validate` 返回 {level, message}:
|
||||
* 两者是**独立的规则**,不是同一条规则的两个等级。典型场景是
|
||||
* 「必填(error)」与「箱量偏少,疑似录入错误(warning)」——
|
||||
* 同一格可能同时命中这两条,而「更严重的那个赢」的优先级
|
||||
* 由 resolveEditorStatus 统一裁决(error > warning),
|
||||
* 调用方不必自己写 if/else 去比较等级。
|
||||
* 若合成一个返回值,调用方就得在每条规则里重复这段优先级逻辑。
|
||||
*
|
||||
* 与 `validate` 同时配置时:error 优先。
|
||||
*/
|
||||
validateWarning?: (value: unknown, row: T, col: G3TableColumn<T>) => boolean | string
|
||||
/**
|
||||
* 校验态的**展示**形态,与 validate 正交:
|
||||
* - 不传(默认):在单元格(td)上画「浅红底 + 右上角 ! 角标」。
|
||||
* - `'inline'`:只在编辑器自身画 1px 内描边 + 文字色,不铺底、不加角标。
|
||||
*
|
||||
* 为什么留这个开关:浅红底来自设计原型 `.cell.is-error`,适合 ERP 明细这类
|
||||
* **逐格录入**的场景;但密度极高的只读表偶尔需要「很轻」的提示,
|
||||
* 一铺底会把整行色带切碎。两种都是既有行为,用枚举而不是布尔,
|
||||
* 便于将来再加 'warning-badge' 之类而不用改签名。
|
||||
*
|
||||
* 注意:无论哪种形态,**文字色变红都照旧生效**(见 G3Table.vue 的 g3-input--error 规则)。
|
||||
*/
|
||||
errorStyle?: 'cell' | 'inline'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,6 +181,33 @@ export interface TableFooterConfig {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 内置滚动条配置。
|
||||
*
|
||||
* 与底座 `stk-table-vue` 的 `ScrollbarOptions` 字段一一对应(该类型未从库主入口导出,
|
||||
* 这里按 `useScrollbar.ts` 的定义镜像一份,避免 deep import 到库内部路径)。
|
||||
*/
|
||||
export interface TableScrollbarConfig {
|
||||
/** 是否启用内置滚动条 */
|
||||
enabled?: boolean
|
||||
/** 纵向滚动条宽度(px) */
|
||||
width?: number
|
||||
/** 横向滚动条高度(px) */
|
||||
height?: number
|
||||
/** 纵向滚动条滑块最小高度(px) */
|
||||
minHeight?: number
|
||||
/** 横向滚动条滑块最小宽度(px) */
|
||||
minWidth?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 按行滚动取值:
|
||||
* - `false`(默认)—— 库原生平滑滚动(transform 虚拟滚动,滚快时可能白屏);
|
||||
* - `true` —— 滚动量按整行对齐,根治白屏,代价是滚动有一格一格的顿挫;
|
||||
* - `'scrollbar'` —— 仅在拖动滚动条时按行对齐,鼠标滚轮仍走平滑。
|
||||
*/
|
||||
export type TableScrollRowByRow = boolean | 'scrollbar'
|
||||
|
||||
/** 组件 props */
|
||||
export interface TableProps<T extends TableRow = TableRow> {
|
||||
/** 列定义 */
|
||||
@@ -165,6 +240,15 @@ export interface TableProps<T extends TableRow = TableRow> {
|
||||
activeRowKey?: string | number | null
|
||||
/** 行内编辑总开关 */
|
||||
editable?: boolean
|
||||
/**
|
||||
* 编辑器是否无缝嵌入单元格,默认 true。
|
||||
*
|
||||
* true:单元格内无外间距,编辑器自身也不画常驻边框——整格铺满,
|
||||
* 只有焦点 / 校验 / 特殊交互状态才出现视觉提示。适合整行编辑、
|
||||
* 键盘连续录入、固定列宽以及专业用户(Excel / AG Grid 的表格态)。
|
||||
* false:编辑器在单元格内留一圈间距并保留自身描边。
|
||||
*/
|
||||
editorSeamless?: boolean
|
||||
/** 空态是否铺满容器 */
|
||||
noDataFull?: boolean
|
||||
/** 溢出省略 */
|
||||
@@ -177,6 +261,29 @@ export interface TableProps<T extends TableRow = TableRow> {
|
||||
footerData?: TableRow[]
|
||||
/** 表尾配置 */
|
||||
footerConfig?: TableFooterConfig
|
||||
/**
|
||||
* 内置滚动条。默认 `false`(关闭,回到库的原生滚动路径)。
|
||||
*
|
||||
* ⚠️ 开启会触发库的实验性 scrollY(overflow:hidden + translateY 模拟),
|
||||
* 在 @wheel 被动监听下滚轮会彻底滚不动(详见 G3Table.vue 默认值处注释)。
|
||||
* 需要自绘滚动条时再显式开启。
|
||||
*/
|
||||
scrollbar?: boolean | TableScrollbarConfig
|
||||
/**
|
||||
* 按行滚动。默认 `false`(关闭)。
|
||||
*
|
||||
* ⚠️ 与 `scrollbar`(实验性 scrollY)组合时,真值会把可视行的 translateY 钉死为 0,
|
||||
* 导致滚轮滚不动(详见 G3Table.vue 默认值处的长注释)。取值与取舍见 TableScrollRowByRow。
|
||||
*/
|
||||
scrollRowByRow?: TableScrollRowByRow
|
||||
/**
|
||||
* 平滑滚动。默认 `false`。
|
||||
*
|
||||
* 底座 `stk-table-vue` 的语义:
|
||||
* · `false` —— 走 onwheel 拦截路径,专为防止「滚动过快白屏」,且滚轮能正确手动推进;
|
||||
* · `true` —— 不用 onwheel,滚轮更丝滑,但滚动过快会白屏。
|
||||
*/
|
||||
smoothScroll?: boolean
|
||||
}
|
||||
|
||||
/** 组件事件 */
|
||||
@@ -186,6 +293,25 @@ export interface TableEmits<T extends TableRow = TableRow> {
|
||||
(e: 'change', row: T, col: G3TableColumn<T>, value: unknown): void
|
||||
(e: 'row-order-change', rows: T[], sourceIndex: number, targetIndex: number): void
|
||||
(e: 'area-selection-change', ranges: TableAreaRange[]): void
|
||||
/**
|
||||
* 行被点击。透传底座 `stk-table-vue` 的 `row-click`。
|
||||
*
|
||||
* 用途:配合 `v-model:active-row-key` 实现「点击哪行高亮哪行」——
|
||||
* 这是 fms-vue 的 FmsTable 沿用已久的写法:
|
||||
*
|
||||
* ```vue
|
||||
* <G3Table
|
||||
* :active-row-key="activeKey"
|
||||
* @row-click="(_, row) => (activeKey = String(row.id))"
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* 为什么由**事件 + 受控 prop** 两个绑定合起来做,而不是表格内部自动高亮:
|
||||
* 当前行只应有一个真源。内部再设一次就有两个写入者,二者互相覆盖
|
||||
* (表现为「有概率不高亮」)。交给调用方后,也能支持
|
||||
* 「点击只选中不编辑」「某些行禁止高亮」等要求。
|
||||
*/
|
||||
(e: 'row-click', event: MouseEvent, row: T, extra: { rowIndex: number }): void
|
||||
}
|
||||
|
||||
/** 组件实例方法 */
|
||||
|
||||
@@ -87,7 +87,30 @@ export function resolveCustomCell<T extends TableRow>(
|
||||
if (col.customCell) return col
|
||||
|
||||
const editor = col.editor
|
||||
if (editor && (editingActive || editor.type === 'checkbox' || editor.type === 'select')) {
|
||||
/**
|
||||
* 注入 customCell 的判据(四种情况):
|
||||
*
|
||||
* 1. `editingActive` —— 有行正在编辑,可编辑列都要挂编辑器;
|
||||
* 2. `checkbox` —— 始终可点,必须常驻;
|
||||
* 3. `select` —— 非编辑态也要把值解析成 label 展示,必须常驻;
|
||||
* 4. **配了 `validate` / `validateWarning`** —— 校验是「数据的状态」,
|
||||
* 不该依赖「哪一行被点过」。若不常驻,text/number 列在非编辑态
|
||||
* 走库的原生 td 渲染(连 cell-display span 都没有),
|
||||
* 它的 error/warning 就只有在点过那一行之后才可见 ——
|
||||
* 用户必须一行行点过去才能发现错误(这正是被反馈的问题)。
|
||||
*
|
||||
* 第 4 条的**开销可控性**:只有显式配了校验规则的列才常驻,
|
||||
* 且常驻时走的是 displayValue(纯 span,不是编辑器组件)。
|
||||
* 没配校验的 text/number 列仍保持原样——非编辑态零组件开销。
|
||||
*/
|
||||
const hasValidation = Boolean(editor?.validate || editor?.validateWarning)
|
||||
if (
|
||||
editor &&
|
||||
(editingActive ||
|
||||
editor.type === 'checkbox' ||
|
||||
editor.type === 'select' ||
|
||||
hasValidation)
|
||||
) {
|
||||
return {
|
||||
...col,
|
||||
customCell: createEditableCell(col, editingContext) as unknown as G3TableColumn<T>['customCell'],
|
||||
|
||||
@@ -37,6 +37,15 @@ function makeCtx(overrides: Partial<EditableContext<Row>> = {}): {
|
||||
consumePendingFocus: () => true,
|
||||
onEditorFocus: (row) => focused.push(row.id),
|
||||
onEditorBlur: (row) => blurred.push(row.id),
|
||||
/**
|
||||
* 无缝编辑器形态:G3Table 的 editorSeamless 默认开启。
|
||||
*
|
||||
* 桩里必须给这个成员,否则 `EditableContext<Row>` 不满足
|
||||
* (TS2322:`(() => boolean) | undefined` 不能赋给 `() => boolean`)。
|
||||
* 与组件默认值保持一致取 true —— 于是下面的断言测的就是**默认形态**,
|
||||
* 而不是一个测试专用的特例。
|
||||
*/
|
||||
isSeamless: () => true,
|
||||
shouldNotify: () => {
|
||||
notified += 1
|
||||
return true
|
||||
@@ -164,6 +173,215 @@ describe('编辑态:input 编辑器', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 校验态(editor.validate)。
|
||||
*
|
||||
* 这一组盯的是「错误态的**判据**是什么」,因为它是本功能最容易回归的点:
|
||||
* 样式锚在 `is-editor-error` 类上,而这个类**必须**在 input / select / checkbox
|
||||
* 三条路径上都出现 —— 一旦哪条漏了,那一列的「浅红底 + 角标」会静默失效
|
||||
* (CSS 不报错,只是不命中)。
|
||||
*
|
||||
* 另一个重点是**派生性**:状态由当前行数据实时求出,不是需要业务维护的
|
||||
* 一次性标记。所以「值改了 → 错误消失」必须靠重新渲染自动成立。
|
||||
*/
|
||||
describe('校验态:editor.validate', () => {
|
||||
/** 取 vnode 的 class(数组 / 字符串 / 对象三种形态都可能) */
|
||||
function classesOf(vnode: { props: Record<string, unknown> }): string {
|
||||
const raw = vnode.props.class
|
||||
const list = Array.isArray(raw) ? raw : [raw]
|
||||
return list
|
||||
.flat()
|
||||
.filter((c): c is string => typeof c === 'string' && c.length > 0)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
it('validate 返回 true 时不打错误类、也不传 status', () => {
|
||||
const col = makeColumn({
|
||||
editor: { type: 'text', validate: () => true },
|
||||
} as never)
|
||||
const { ctx } = makeCtx()
|
||||
const vnode = render(col, ctx, { id: 1, name: 'a', status: 1 })
|
||||
expect(classesOf(vnode)).not.toContain('is-editor-error')
|
||||
expect(vnode.props.status).toBe('')
|
||||
})
|
||||
|
||||
it('validate 返回字符串时打错误类,并把 error 传给 G3Input 的 status', () => {
|
||||
const col = makeColumn({
|
||||
editor: { type: 'text', validate: () => '不能为空' },
|
||||
} as never)
|
||||
const { ctx } = makeCtx()
|
||||
const vnode = render(col, ctx, { id: 1, name: '', status: 1 })
|
||||
expect(classesOf(vnode)).toContain('is-editor-error')
|
||||
// status 走 G3Input,负责 aria-invalid 与文字色(无障碍语义)
|
||||
expect(vnode.props.status).toBe('error')
|
||||
})
|
||||
|
||||
it('validate 收到的是「当前行数据」——值改了错误就自动消失(派生而非一次性状态)', () => {
|
||||
// 校验规则:name 不能为空
|
||||
const col = makeColumn({
|
||||
editor: { type: 'text', validate: (v: unknown) => String(v ?? '').length > 0 || '不能为空' },
|
||||
} as never)
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
// 空值 → 错误
|
||||
const invalid = render(col, ctx, { id: 1, name: '', status: 1 })
|
||||
expect(classesOf(invalid)).toContain('is-editor-error')
|
||||
|
||||
// 同一列、同一上下文,仅行数据不同 → 不错误
|
||||
// 这条是本实现最重要的性质:无需任何 clearError(),重渲染即自愈
|
||||
const valid = render(col, ctx, { id: 1, name: '有值了', status: 1 })
|
||||
expect(classesOf(valid)).not.toContain('is-editor-error')
|
||||
})
|
||||
|
||||
it('validate 抛错时降级为「通过」而不是连累渲染', () => {
|
||||
// 校验写在业务侧,很容易因字段缺失抛错;它在**渲染期**被调用,
|
||||
// 一旦抛出会让整个单元格渲染失败(比"没有红底"严重得多)
|
||||
const col = makeColumn({
|
||||
editor: {
|
||||
type: 'text',
|
||||
validate: () => {
|
||||
throw new Error('业务校验炸了')
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
expect(() => render(col, ctx, { id: 1, name: 'x', status: 1 })).not.toThrow()
|
||||
const vnode = render(col, ctx, { id: 1, name: 'x', status: 1 })
|
||||
expect(classesOf(vnode)).not.toContain('is-editor-error')
|
||||
})
|
||||
|
||||
it('errorStyle: "inline" 时不打错误类(只要编辑器自身内描边,不要铺底与角标)', () => {
|
||||
const col = makeColumn({
|
||||
editor: { type: 'text', validate: () => '错误', errorStyle: 'inline' },
|
||||
} as never)
|
||||
const { ctx } = makeCtx()
|
||||
const vnode = render(col, ctx, { id: 1, name: '', status: 1 })
|
||||
// 不打类 → 单元格不铺底、不出角标;但 status 仍传,文字色与 aria 还在
|
||||
expect(classesOf(vnode)).not.toContain('is-editor-error')
|
||||
expect(vnode.props.status).toBe('error')
|
||||
})
|
||||
|
||||
it('validateWarning 命中时打 warning 类(不是 error 类)', () => {
|
||||
const col = makeColumn({
|
||||
editor: { type: 'text', validateWarning: () => '箱量偏少,请确认' },
|
||||
} as never)
|
||||
const { ctx } = makeCtx()
|
||||
const vnode = render(col, ctx, { id: 1, name: 'x', status: 1 })
|
||||
// 两种等级的类是**互斥**的:命中 warning 就不该同时是 error,
|
||||
// 否则表格侧两个 :has() 选择器会同时命中同一格,底色规则互相覆盖、
|
||||
// 谁赢取决于书写顺序 —— 正是本文件反复强调要避免的「顺序不可控」。
|
||||
expect(classesOf(vnode)).toContain('is-editor-warning')
|
||||
expect(classesOf(vnode)).not.toContain('is-editor-error')
|
||||
})
|
||||
|
||||
it('error 与 warning 同时命中时 error 优先', () => {
|
||||
// 两条规则是独立的(如「必填」与「箱量偏少」),同一格可能同时命中。
|
||||
// 优先级在组件内统一裁决,调用方不必自己比较等级。
|
||||
const col = makeColumn({
|
||||
editor: {
|
||||
type: 'text',
|
||||
validate: () => '必填',
|
||||
validateWarning: () => '偏少',
|
||||
},
|
||||
} as never)
|
||||
const { ctx } = makeCtx()
|
||||
const vnode = render(col, ctx, { id: 1, name: '', status: 1 })
|
||||
expect(classesOf(vnode)).toContain('is-editor-error')
|
||||
expect(classesOf(vnode)).not.toContain('is-editor-warning')
|
||||
})
|
||||
|
||||
it('validateWarning 返回 falsy 时不打任何状态类', () => {
|
||||
const col = makeColumn({
|
||||
editor: { type: 'text', validateWarning: () => '' },
|
||||
} as never)
|
||||
const { ctx } = makeCtx()
|
||||
const vnode = render(col, ctx, { id: 1, name: 'x', status: 1 })
|
||||
expect(classesOf(vnode)).not.toContain('is-editor-warning')
|
||||
expect(classesOf(vnode)).not.toContain('is-editor-error')
|
||||
})
|
||||
|
||||
it('validateWarning 抛错时降级为通过(与 validate 同一套保护)', () => {
|
||||
const col = makeColumn({
|
||||
editor: {
|
||||
type: 'text',
|
||||
validateWarning: () => {
|
||||
throw new Error('警告校验炸了')
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
const { ctx } = makeCtx()
|
||||
expect(() => render(col, ctx, { id: 1, name: 'x', status: 1 })).not.toThrow()
|
||||
const vnode = render(col, ctx, { id: 1, name: 'x', status: 1 })
|
||||
expect(classesOf(vnode)).not.toContain('is-editor-warning')
|
||||
})
|
||||
|
||||
it('errorStyle: "inline" 时 warning 类同样不打(inline 形态不铺底不加角标)', () => {
|
||||
const col = makeColumn({
|
||||
editor: { type: 'text', validateWarning: () => '提醒', errorStyle: 'inline' },
|
||||
} as never)
|
||||
const { ctx } = makeCtx()
|
||||
const vnode = render(col, ctx, { id: 1, name: 'x', status: 1 })
|
||||
expect(classesOf(vnode)).not.toContain('is-editor-warning')
|
||||
})
|
||||
|
||||
it('select 编辑器同样支持 warning 类(类名是唯一通道)', () => {
|
||||
const col = {
|
||||
dataIndex: 'status',
|
||||
title: '状态',
|
||||
editor: {
|
||||
type: 'select',
|
||||
options: [{ label: '启用', value: 1 }],
|
||||
validateWarning: () => '请确认',
|
||||
},
|
||||
} as G3TableColumn<Row>
|
||||
const { ctx } = makeCtx()
|
||||
const vnode = render(col, ctx, { id: 1, name: 'x', status: 1 })
|
||||
expect(classesOf(vnode)).toContain('is-editor-warning')
|
||||
})
|
||||
|
||||
it('select 编辑器同样打错误类(G3Select 没有 status prop,类名是唯一通道)', () => {
|
||||
const col = {
|
||||
dataIndex: 'status',
|
||||
title: '状态',
|
||||
editor: {
|
||||
type: 'select',
|
||||
options: [{ label: '启用', value: 1 }],
|
||||
validate: () => '必须选择',
|
||||
},
|
||||
} as G3TableColumn<Row>
|
||||
const { ctx } = makeCtx()
|
||||
const vnode = render(col, ctx, { id: 1, name: 'x', status: 0 })
|
||||
// 若这条漏了,select 列的错误态会静默失效(CSS 不报错,只是不命中)
|
||||
expect(classesOf(vnode)).toContain('is-editor-error')
|
||||
})
|
||||
|
||||
it('checkbox 编辑器同样打错误类', () => {
|
||||
const col = {
|
||||
dataIndex: 'status',
|
||||
title: '启用',
|
||||
editor: { type: 'checkbox', validate: () => '必须勾选' },
|
||||
} as G3TableColumn<Row>
|
||||
const { ctx } = makeCtx()
|
||||
const vnode = render(col, ctx, { id: 1, name: 'x', status: 0 })
|
||||
// checkbox 的根是 label,错误类挂在 label 的 class 上
|
||||
const label = vnode.type === 'label' ? vnode : (vnode.children as unknown[])?.[0]
|
||||
const raw = (label as { props?: Record<string, unknown> })?.props?.class
|
||||
const text = (Array.isArray(raw) ? raw.flat() : [raw])
|
||||
.filter((c): c is string => typeof c === 'string')
|
||||
.join(' ')
|
||||
expect(text).toContain('is-editor-error')
|
||||
})
|
||||
|
||||
it('未配置 validate 时不打错误类(不给既有列增加任何行为)', () => {
|
||||
const col = makeColumn({ editor: { type: 'text' } } as never)
|
||||
const { ctx } = makeCtx()
|
||||
const vnode = render(col, ctx, { id: 1, name: 'x', status: 1 })
|
||||
expect(classesOf(vnode)).not.toContain('is-editor-error')
|
||||
expect(vnode.props.status).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('草稿机制:输入过程中不落库', () => {
|
||||
it('onUpdate:modelValue 只写草稿,不改行数据、不通知父级', () => {
|
||||
const col = makeColumn({ editor: { type: 'text' } } as never)
|
||||
|
||||
@@ -2,7 +2,13 @@ import { h, toRaw } from 'vue'
|
||||
import { G3Input } from '../../input'
|
||||
import { G3Select } from '../../select'
|
||||
import { useNamespace } from '../../_composables/useNamespace'
|
||||
import type { G3TableColumn, TableEditor, TableRow, TableSelectOption } from '../types'
|
||||
import type {
|
||||
G3TableColumn,
|
||||
TableEditor,
|
||||
TableEditorStatus,
|
||||
TableRow,
|
||||
TableSelectOption,
|
||||
} from '../types'
|
||||
|
||||
/**
|
||||
* 行内编辑单元格(照抄 fms 的 createEditableCellRenderer.js)。
|
||||
@@ -37,6 +43,11 @@ export interface EditableContext<T extends TableRow> {
|
||||
consumePendingFocus: (row: T) => boolean
|
||||
onEditorFocus: (row: T) => void
|
||||
onEditorBlur: (row: T) => void
|
||||
/**
|
||||
* 是否使用「无缝」编辑器形态(G3Table 的 editorSeamless,默认 true):
|
||||
* 单元格内无外间距、编辑器无常驻边框,只有焦点 / 校验 / 特殊交互态给提示。
|
||||
*/
|
||||
isSeamless: () => boolean
|
||||
/** 值真正变化时才返回 true(用于去重通知) */
|
||||
shouldNotify: (row: T, col: G3TableColumn<T>, value: unknown) => boolean
|
||||
/** 通知父级 */
|
||||
@@ -109,6 +120,100 @@ function isEditorDisabled<T extends TableRow>(editor: TableEditor<T>, row: T): b
|
||||
: Boolean(editor.disabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* 求当前单元格的校验态。
|
||||
*
|
||||
* 每次渲染按当前行数据**重新求值**,这是本实现最重要的性质:
|
||||
* 校验结果是行数据的**派生值**,不是需要业务手动维护的一次性状态。
|
||||
* 用户把空的数量改成 12 → 下一次渲染 validate 返回 true → 红底与角标自动消失,
|
||||
* 不需要业务调任何 clearError(),组件内部也不留任何 error 注册表
|
||||
* (注册表还要处理行被删除 / 数据源整体替换时的清理,是纯粹的负债)。
|
||||
*
|
||||
* 为什么把 validate 的返回值同时当作「是否错误」和「错误原因」:
|
||||
* 见 types.ts 里 validate 的注释——失败原因只有一处真源,不会与布尔标记脱节。
|
||||
*
|
||||
* 注意**不做**任何缓存:只在编辑器真正挂载的那一行调用(非编辑行走 displayValue),
|
||||
* 调用频率等于「一行的可编辑列数」,换来的是永远不会出现「数据改了红底还在」的脏状态。
|
||||
*/
|
||||
/**
|
||||
* 跑一个校验函数并把结果归一成「是否命中 + 原因」。
|
||||
*
|
||||
* 抽出来是因为 validate 与 validateWarning 的**调用约定完全相同**
|
||||
* (都是 falsy 通过 / string 表示有问题),只有「命中后归到哪个等级」不同。
|
||||
* 两处各写一遍 try/catch 与类型判断,等于把最容易出错的那段逻辑复制两份
|
||||
* (此前「布尔语义反向」的 bug 就是在这个判断里,见下方注释)。
|
||||
*/
|
||||
function runValidator<T extends TableRow>(
|
||||
fn: ((value: unknown, row: T, col: G3TableColumn<T>) => boolean | string) | undefined,
|
||||
which: 'validate' | 'validateWarning',
|
||||
row: T,
|
||||
col: G3TableColumn<T>,
|
||||
): string {
|
||||
if (!fn) return ''
|
||||
let result: boolean | string
|
||||
try {
|
||||
result = fn(row[col.dataIndex], row, col)
|
||||
} catch (error) {
|
||||
/**
|
||||
* 校验函数抛错不能连累整格渲染。
|
||||
*
|
||||
* 校验写在业务侧、按行数据求值,很容易踩到「字段缺失 / 类型意外」而抛错,
|
||||
* 而它是在**渲染期**被调用的——一旦抛出,整个 customCell 渲染失败,
|
||||
* 表现为「这一格空白,且控制台报错」,比「这一格没有红底」严重得多。
|
||||
* 所以吞掉异常并降级为「通过」,同时保留警告便于定位。
|
||||
*/
|
||||
if (import.meta.env?.DEV) {
|
||||
console.warn(
|
||||
`[g3-ui] G3Table 列「${col.title || col.dataIndex}」的 editor.${which} 抛错,` +
|
||||
'本次按“通过”处理以保住单元格渲染。请检查校验函数对空值 / 缺字段的处理。',
|
||||
error,
|
||||
)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 语义(与 types.ts 的文档严格一致):**falsy 表示通过,string 表示不通过**。
|
||||
*
|
||||
* ⚠️ 这里曾经写成 `if (!result) return ''; return 'error'` ——
|
||||
* 那是「truthy 就报错」,与文档的「falsy 表示通过」**正好相反**。
|
||||
* 后果不是样式不生效,而是**判定整个翻转**:
|
||||
* validate: (v) => Number(v) > 0 // 最自然的写法
|
||||
* 值为 0(非法)→ 返回 false → 被判为「通过」,红底不出现
|
||||
* 值为 5(合法)→ 返回 true → 被判为「错误」,合法值全标红
|
||||
* 单测实测(布尔返回值逐个枚举):
|
||||
* () => true => status "error" ❌ 应为 ''
|
||||
* () => false => status "" ❌ 应为 'error'
|
||||
* 这种「沉默地反向」比抛错难查得多,所以判据必须是**明确的类型判断**,
|
||||
* 而不是对返回值做真值取反:
|
||||
* · 返回非空 string → 不通过,且该字符串就是原因
|
||||
* · 其余(boolean / 空串 / undefined / null)→ 一律按「通过」处理
|
||||
* 之所以把 `true` 也当作通过而不是当作「显式报错」:布尔值在这里是
|
||||
* 「校验函数顺带返回的通过标记」,真正携带错误信息的是 string。
|
||||
*/
|
||||
return typeof result === 'string' ? result : ''
|
||||
}
|
||||
|
||||
function resolveEditorStatus<T extends TableRow>(
|
||||
editor: TableEditor<T>,
|
||||
row: T,
|
||||
col: G3TableColumn<T>,
|
||||
): TableEditorStatus {
|
||||
/**
|
||||
* error 优先于 warning。
|
||||
*
|
||||
* 两条规则是**独立**的(如「必填」与「箱量偏少」),同一格可能同时命中。
|
||||
* 优先级在这里统一裁决,调用方就不必在每条规则里自己比较等级。
|
||||
* 若反过来让 warning 优先,会出现「数据为空(严重)却只给个琥珀提示」的
|
||||
* 危险表现——宁可多报一次严重的。
|
||||
*/
|
||||
if (runValidator(editor.validate, 'validate', row, col).length > 0) return 'error'
|
||||
if (runValidator(editor.validateWarning, 'validateWarning', row, col).length > 0) {
|
||||
return 'warning'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function notifyChange<T extends TableRow>(
|
||||
ctx: EditableContext<T>,
|
||||
editor: TableEditor<T>,
|
||||
@@ -121,13 +226,29 @@ function notifyChange<T extends TableRow>(
|
||||
ctx.onChange(row, col, value)
|
||||
}
|
||||
|
||||
/** 非编辑态的展示内容:select 需要把值解析成 label */
|
||||
/**
|
||||
* 非编辑态的展示内容:select 需要把值解析成 label。
|
||||
*
|
||||
* ⚠️ 展示态**同样要渲染校验态**(红底/琥珀底 + ! 角标)。
|
||||
*
|
||||
* 原因是很实际的一条:编辑器只在**被点击过的那一行**才挂载
|
||||
* (见 column.ts 的 resolveCustomCell 门控),如果校验态只在编辑器里求值,
|
||||
* 那么「哪一格有错」就只有在点过那一行之后才看得见 ——
|
||||
* 用户必须一行行点过去才能发现错误,这显然不可接受。
|
||||
*
|
||||
* 校验是**数据的状态**,不该依赖「哪一行被交互过」。
|
||||
* 所以这里在展示态也求一次 status,把它作为状态类吐在 span 上,
|
||||
* 表格侧的 `:has()` 选择器就能命中并铺底 + 出角标。
|
||||
*
|
||||
* 为什么不在展示态也挂编辑器:那正是 resolveCustomCell 极力避免的开销
|
||||
* (滚动时每格一个函数式组件)。这里只多打一个 class,成本可忽略。
|
||||
*/
|
||||
function displayValue<T extends TableRow>(
|
||||
row: T,
|
||||
col: G3TableColumn<T>,
|
||||
editor: TableEditor<T>,
|
||||
) {
|
||||
const { e } = useNamespace('table')
|
||||
const { e, is } = useNamespace('table')
|
||||
const value = row[col.dataIndex]
|
||||
const text =
|
||||
editor.type === 'select'
|
||||
@@ -135,9 +256,26 @@ function displayValue<T extends TableRow>(
|
||||
?.label ?? value)
|
||||
: value
|
||||
const content = text ?? ''
|
||||
const status = resolveEditorStatus(editor, row, col)
|
||||
const statusClass = editorStatusClass(status, editor.errorStyle, is)
|
||||
return h(
|
||||
'span',
|
||||
{ class: e('cell-display'), title: String(content) },
|
||||
{
|
||||
class: [
|
||||
e('cell-display'),
|
||||
/*
|
||||
* 校验态类(is-editor-error / is-editor-warning)打在**展示态的 span** 上。
|
||||
*
|
||||
* 这样表格侧不必区分「编辑器里的状态类」与「展示态的状态类」:
|
||||
* 两种形态都带同一个类名,`:has()` 选择器写一套就够。
|
||||
* 这也是为什么不在 span 上用 e('cell-display-error') 之类的私有类名 ——
|
||||
* 那会让 CSS 不得不写两遍(编辑器一遍、展示态一遍),
|
||||
* 而本文件已经有过「改一处忘另一处」的教训。
|
||||
*/
|
||||
statusClass ? { [statusClass]: true } : {},
|
||||
],
|
||||
title: String(content),
|
||||
},
|
||||
content as string,
|
||||
)
|
||||
}
|
||||
@@ -183,14 +321,60 @@ const INPUT_TYPE_MAP: Record<string, 'default' | 'password' | 'number' | 'textar
|
||||
textarea: 'textarea',
|
||||
}
|
||||
|
||||
/**
|
||||
* 把校验态翻译成编辑器根节点上的**状态类**,供单元格选择器命中。
|
||||
*
|
||||
* 抽出来是因为 input / select / checkbox 三条渲染路径都要打这个类,
|
||||
* 而它们的判据必须完全一致——某一条漏打,那一列的校验视觉就会静默失效
|
||||
* (CSS 不报错,只是选择器不命中,非常难查)。
|
||||
*
|
||||
* 语义:
|
||||
* · `errorStyle: 'inline'` 时**两个类都不打**:inline 形态只要编辑器自身
|
||||
* 的内描边与文字色(见 types.ts 的 errorStyle 说明),
|
||||
* 铺底与角标是「单元格级」的表达,与 inline 的意图相反。
|
||||
* · 其余情况按等级吐 `is-editor-error` / `is-editor-warning`。
|
||||
*
|
||||
* 注意这只是**视觉通道之一**。G3Input 还会另外收到 `status` prop,
|
||||
* 负责 `aria-invalid` 与文字色(无障碍语义);G3Select 没有 status prop,
|
||||
* 只能靠这个类,这也是它必须存在的原因。
|
||||
*/
|
||||
function editorStatusClass(
|
||||
status: TableEditorStatus,
|
||||
errorStyle: TableEditor['errorStyle'],
|
||||
is: (...states: (string | false | null | undefined)[]) => string,
|
||||
): string {
|
||||
if (errorStyle === 'inline') return ''
|
||||
if (status === 'error') return is('editor-error')
|
||||
if (status === 'warning') return is('editor-warning')
|
||||
return ''
|
||||
}
|
||||
|
||||
function createInput<T extends TableRow>(
|
||||
row: T,
|
||||
col: G3TableColumn<T>,
|
||||
editor: TableEditor<T>,
|
||||
ctx: EditableContext<T>,
|
||||
) {
|
||||
const { e } = useNamespace('table')
|
||||
const { e, is } = useNamespace('table')
|
||||
const focus = createFocusHandler(ctx, row, col, true)
|
||||
/**
|
||||
* 校验态在此求值并**同时**流向两个消费方:
|
||||
*
|
||||
* · G3Input 的 `status` prop —— 拿到语义化的 `--error` 类,
|
||||
* 它自带文字色与 `aria-invalid`,是无障碍与「文字变红」的正确落点。
|
||||
* · 编辑器根节点上的 `is-editor-error` 类 —— 供**单元格**选择器命中。
|
||||
*
|
||||
* 为什么要第二个通道(而不是直接让 CSS 盯 `.g3-input--error`):
|
||||
* `G3Select` **没有** status prop(只有 G3Input 有),select 编辑器
|
||||
* 拿不到 `--error` 类。若样式锚在 `.g3-input--error` 上,
|
||||
* 「select 列校验失败没有红底和角标」就成了必然。统一由本模块吐一个
|
||||
* 自己的状态类,input / select / checkbox 三条路径才能用**同一个**选择器,
|
||||
* 表格样式也就不必知道 G3Input 的内部类名。
|
||||
*
|
||||
* `errorStyle: 'inline'` 时不再打这个类:inline 形态只要编辑器自身的内描边,
|
||||
* 不要铺底和角标(见 types.ts 里 errorStyle 的说明)。
|
||||
*/
|
||||
const status = resolveEditorStatus(editor, row, col)
|
||||
/**
|
||||
* 本次失焦是否为「Esc 取消」触发的。
|
||||
*
|
||||
@@ -205,7 +389,12 @@ function createInput<T extends TableRow>(
|
||||
return h(G3Input, {
|
||||
type: INPUT_TYPE_MAP[editor.type] ?? 'default',
|
||||
modelValue: (getInputDraft(row, col.dataIndex) ?? '') as string,
|
||||
class: [e('cell-editor'), e('cell-editor-root')],
|
||||
status,
|
||||
class: [
|
||||
e('cell-editor'),
|
||||
e('cell-editor-root'),
|
||||
editorStatusClass(status, editor.errorStyle, is),
|
||||
],
|
||||
'aria-label': col.title || col.dataIndex,
|
||||
'onUpdate:modelValue': (value: string | number) => {
|
||||
setInputDraft(row, col.dataIndex, value)
|
||||
@@ -256,15 +445,36 @@ function createSelect<T extends TableRow>(
|
||||
editor: TableEditor<T>,
|
||||
ctx: EditableContext<T>,
|
||||
) {
|
||||
const { e } = useNamespace('table')
|
||||
const { e, is } = useNamespace('table')
|
||||
const options = resolveOptions(editor, row)
|
||||
const focus = createFocusHandler(ctx, row, col)
|
||||
/**
|
||||
* select 的校验态**只走类名**,不传 status prop —— G3Select 根本没有这个 prop
|
||||
* (只有 G3Input 有)。所以这里的 `is-editor-error` / `is-editor-warning`
|
||||
* 是 select 列拿到「铺底 + ! 角标」的唯一通道,不是 G3Input 那条路径的冗余。
|
||||
* 详见 createInput 里 status 的注释与 editorStatusClass。
|
||||
*/
|
||||
const status = resolveEditorStatus(editor, row, col)
|
||||
return h(G3Select, {
|
||||
modelValue: (row[col.dataIndex] ?? undefined) as any,
|
||||
options: options as any,
|
||||
clearable: editor.allowClear ?? true,
|
||||
filterable: false,
|
||||
class: [e('cell-editor'), e('cell-editor-root')],
|
||||
/**
|
||||
* 无缝模式交给 G3Select 自己处理。
|
||||
*
|
||||
* ⚠️ 不能只靠外层表格样式去覆盖 `__trigger`:触发器的真实 DOM 位置是
|
||||
* `div.g3-select > [Popover] > span.g3-select__trigger`,
|
||||
* 从 `.g3-table__cell-editor-root`(= 那个 div)出发的后代选择器
|
||||
* 看似能命中,但样式权重相同、顺序不可控,实测残留 border/radius。
|
||||
* 真正可靠的落点是触发器自己的样式块(见 Select.vue 的 is-seamless)。
|
||||
*/
|
||||
seamless: ctx.isSeamless?.(),
|
||||
class: [
|
||||
e('cell-editor'),
|
||||
e('cell-editor-root'),
|
||||
editorStatusClass(status, editor.errorStyle, is),
|
||||
],
|
||||
'aria-label': col.title || col.dataIndex,
|
||||
'onUpdate:modelValue': (value: unknown) => {
|
||||
const resolved = value ?? editor.emptyValue ?? null
|
||||
@@ -287,6 +497,15 @@ function createCheckbox<T extends TableRow>(
|
||||
// 约定:1 = 选中,0 = 未选中(与后端 tinyint 对齐,fms 同此约定)
|
||||
const checked = row[col.dataIndex] === 1
|
||||
const focus = ctx.isEditing(row) ? createFocusHandler(ctx, row, col) : undefined
|
||||
/**
|
||||
* checkbox 也支持校验态。
|
||||
*
|
||||
* 它虽然不需要「给角标让位」的 padding(没有文字会被压住),
|
||||
* 但仍要参与「单元格亮红底」——否则同一行里 input 格报错、checkbox 格不报,
|
||||
* 用户会以为那一列没问题。统一给类,让表格侧的选择器只写一次。
|
||||
*/
|
||||
const status = resolveEditorStatus(editor, row, col)
|
||||
const statusClass = editorStatusClass(status, editor.errorStyle, is)
|
||||
const update = (value: number) => {
|
||||
row[col.dataIndex] = value as T[keyof T & string]
|
||||
notifyChange(ctx, editor, row, col, value)
|
||||
@@ -294,7 +513,15 @@ function createCheckbox<T extends TableRow>(
|
||||
return h(
|
||||
'label',
|
||||
{
|
||||
class: [e('checkbox'), e('cell-editor-root'), { [is('checked')]: checked }],
|
||||
class: [
|
||||
e('checkbox'),
|
||||
e('cell-editor-root'),
|
||||
{
|
||||
[is('checked')]: checked,
|
||||
// 用计算出的类名做 key,值为 false 时 Vue 会自动跳过
|
||||
[statusClass]: statusClass.length > 0,
|
||||
},
|
||||
],
|
||||
onClick: stop,
|
||||
// 库在 mousedown 阶段处理选区与行编辑激活;checkbox 是始终可直接操作的控件,
|
||||
// 必须在此阶段隔离,否则第一次点击只激活编辑态、第二次才真正切换。
|
||||
|
||||
Reference in new issue
Block a user