From 025d3232c4beb62eecd19ed275d907e203401f6d Mon Sep 17 00:00:00 2001 From: ZhangAo Date: Fri, 16 Oct 2026 17:30:17 +0800 Subject: [PATCH] u --- code/g3soft-erp/CODEBUDDY.md | 2 + code/g3soft-erp/web/TROUBLESHOOTING.md | 34 + code/g3soft-erp/web/src/utils/schemaJson.ts | 8 +- .../web/src/views/module/MenuManagement.vue | 190 +- .../menu-management/ModuleBindPanel.vue | 179 - .../src/views/module/menu-management/utils.ts | 67 - .../module-management/ModuleAutoCodePanel.vue | 106 +- .../module-management/ModuleBasicPanel.vue | 85 +- .../module-management/ModuleFieldsPanel.vue | 4 +- .../module-management/ModuleI18nPanel.vue | 6 +- .../module-management/ModulePowerPanel.vue | 4 +- .../module-management/ModuleQueryPanel.vue | 816 ---- .../views/module/module-management/index.vue | 177 +- .../views/module/module-management/utils.ts | 137 +- .../web/src/views/table-test/index.vue | 280 +- .../web/table-test-性能报告与修改方向.html | 692 ++++ .../web/table-test-性能排查报告-终版.html | 312 ++ .../web/table-test-性能排查报告.html | 338 ++ .../module/menu-management/menuSave.spec.ts | 2 +- .../module/menu-management/moduleBind.spec.ts | 199 - .../ModuleAutoCodePanel.spec.ts | 76 +- .../ModuleQueryPanel.spec.ts | 136 - .../.changeset/table-keyboard-arrows-enter.md | 41 +- code/g3soft-libs/.gitignore | 3 + .../g3soft-libs/docs/content/ui/data/table.md | 388 +- .../g3soft-libs/docs/examples/table/basic.vue | 9 +- .../docs/examples/table/editable.vue | 49 +- .../docs/examples/table/resizable.vue | 5 +- code/g3soft-libs/docs/table-design.md | 26 +- .../packages/ui/playground/main.ts | 124 +- .../packages/ui/probe-table-ab.mjs | 383 ++ .../packages/ui/probe-table-attrib.mjs | 254 ++ .../packages/ui/probe-table-attrib2.mjs | 450 +++ .../packages/ui/probe-table-churn.mjs | 231 ++ .../packages/ui/probe-table-decisive.mjs | 430 +++ .../packages/ui/probe-table-final.mjs | 378 ++ .../packages/ui/probe-table-gpu.mjs | 173 + .../packages/ui/probe-table-loaf.mjs | 278 ++ .../packages/ui/probe-table-perf.mjs | 284 ++ .../packages/ui/probe-table-prod.mjs | 260 ++ .../packages/ui/probe-table-separate.mjs | 259 ++ .../packages/ui/probe-table-trace.mjs | 334 ++ .../packages/ui/src/date/DatePicker.vue | 8 + .../packages/ui/src/table/G3Table.test.ts | 497 --- .../packages/ui/src/table/G3Table.vue | 2776 +++----------- .../packages/ui/src/table/attrs.spec.test.ts | 61 - .../packages/ui/src/table/index.ts | 28 +- .../ui/src/table/style/_overrides.scss | 455 +-- .../packages/ui/src/table/style/index.scss | 5 +- .../packages/ui/src/table/types.ts | 321 +- .../packages/ui/src/table/utils/actions.ts | 166 +- .../ui/src/table/utils/column.test.ts | 184 - .../packages/ui/src/table/utils/column.ts | 208 - .../ui/src/table/utils/date-value.test.ts | 34 - .../packages/ui/src/table/utils/date-value.ts | 35 - .../ui/src/table/utils/editable.test.ts | 981 ----- .../packages/ui/src/table/utils/editable.ts | 980 +---- .../ui/src/table/utils/precision.test.ts | 107 - .../packages/ui/src/table/utils/precision.ts | 123 - .../ui/src/table/utils/selection.test.ts | 237 -- .../packages/ui/src/table/utils/selection.ts | 36 +- code/thinking-effort-loaded.json | 6 + dsh-thinking-effort-fix.md | 96 + table-two-state-keyboard-2025.changeset.md | 42 + table-two-state-keyboard-2025.patch | 3354 +++++++++++++++++ 65 files changed, 9838 insertions(+), 9111 deletions(-) create mode 100644 code/g3soft-erp/web/TROUBLESHOOTING.md delete mode 100644 code/g3soft-erp/web/src/views/module/menu-management/ModuleBindPanel.vue delete mode 100644 code/g3soft-erp/web/src/views/module/module-management/ModuleQueryPanel.vue create mode 100644 code/g3soft-erp/web/table-test-性能报告与修改方向.html create mode 100644 code/g3soft-erp/web/table-test-性能排查报告-终版.html create mode 100644 code/g3soft-erp/web/table-test-性能排查报告.html delete mode 100644 code/g3soft-erp/web/tests/views/module/menu-management/moduleBind.spec.ts delete mode 100644 code/g3soft-erp/web/tests/views/module/module-management/ModuleQueryPanel.spec.ts create mode 100644 code/g3soft-libs/packages/ui/probe-table-ab.mjs create mode 100644 code/g3soft-libs/packages/ui/probe-table-attrib.mjs create mode 100644 code/g3soft-libs/packages/ui/probe-table-attrib2.mjs create mode 100644 code/g3soft-libs/packages/ui/probe-table-churn.mjs create mode 100644 code/g3soft-libs/packages/ui/probe-table-decisive.mjs create mode 100644 code/g3soft-libs/packages/ui/probe-table-final.mjs create mode 100644 code/g3soft-libs/packages/ui/probe-table-gpu.mjs create mode 100644 code/g3soft-libs/packages/ui/probe-table-loaf.mjs create mode 100644 code/g3soft-libs/packages/ui/probe-table-perf.mjs create mode 100644 code/g3soft-libs/packages/ui/probe-table-prod.mjs create mode 100644 code/g3soft-libs/packages/ui/probe-table-separate.mjs create mode 100644 code/g3soft-libs/packages/ui/probe-table-trace.mjs delete mode 100644 code/g3soft-libs/packages/ui/src/table/G3Table.test.ts delete mode 100644 code/g3soft-libs/packages/ui/src/table/attrs.spec.test.ts delete mode 100644 code/g3soft-libs/packages/ui/src/table/utils/column.test.ts delete mode 100644 code/g3soft-libs/packages/ui/src/table/utils/column.ts delete mode 100644 code/g3soft-libs/packages/ui/src/table/utils/date-value.test.ts delete mode 100644 code/g3soft-libs/packages/ui/src/table/utils/date-value.ts delete mode 100644 code/g3soft-libs/packages/ui/src/table/utils/editable.test.ts delete mode 100644 code/g3soft-libs/packages/ui/src/table/utils/precision.test.ts delete mode 100644 code/g3soft-libs/packages/ui/src/table/utils/precision.ts delete mode 100644 code/g3soft-libs/packages/ui/src/table/utils/selection.test.ts create mode 100644 code/thinking-effort-loaded.json create mode 100644 dsh-thinking-effort-fix.md create mode 100644 table-two-state-keyboard-2025.changeset.md create mode 100644 table-two-state-keyboard-2025.patch diff --git a/code/g3soft-erp/CODEBUDDY.md b/code/g3soft-erp/CODEBUDDY.md index 2edec739..5912a6a2 100644 --- a/code/g3soft-erp/CODEBUDDY.md +++ b/code/g3soft-erp/CODEBUDDY.md @@ -150,3 +150,5 @@ pnpm test:e2e:ui # 带 UI 的调试模式 所以浏览器每个新会话/新 context 都是未登录态 —— 这正是自动登录要解决的问题。 - **`docs/` 目录当前不存在**(提交 `15e0ae5b` 删掉了)。文档写进 `README.md` / `server/README.md` 或本文件,别再新增指向 `docs/` 的链接。 +- **疑难问题的排查记录在 `web/TROUBLESHOOTING.md`**(按「现象 → 根因 → 修复 → 约束」四段式): + 遇到"看起来坏了"的表单 / 样式类问题先按现象关键词搜它,别从零排查;解决新问题后追加条目。 diff --git a/code/g3soft-erp/web/TROUBLESHOOTING.md b/code/g3soft-erp/web/TROUBLESHOOTING.md new file mode 100644 index 00000000..affb94f4 --- /dev/null +++ b/code/g3soft-erp/web/TROUBLESHOOTING.md @@ -0,0 +1,34 @@ +# 疑难问题排查记录(web) + +给 AI 协作者:本文件按「现象 → 根因 → 修复 → 约束」四段式记录前端已排查过的疑难问题。 +遇到**看起来像坏了**的样式 / 交互问题,先按现象关键词在这里搜一遍,别从零排查; +解决新问题后按同样结构在末尾追加编号条目。 + +--- + +## 1. 表单控件聚焦光晕被裁(模块管理 / 菜单管理) + +**现象**(2026-10-16):表单里的输入框获得焦点时,聚焦环(2px 外发光)上下左右都可能被裁掉一部分, +看起来像"折叠"了一截。菜单管理、模块管理里的表单都有此问题。 + +**根因**: +- 聚焦环不是边框,是画在控件边框**外侧**的 2px `box-shadow` + (`g3soft-libs/packages/ui/src/input/Input.vue:486`;Select / DatePicker / RangePicker 同款)。 +- 页面结构:白卡 `.mm-config-stack`(此前带 `padding: 16px`)→ 滚动容器 `.mm-config-pane` + (`overflow: auto`,自身此前无 padding)。 +- 关键机制:**滚动容器在自己的 padding 边缘裁切内容**。内边距放在外层白卡上,保护不了 + 贴着滚动容器边缘的控件——控件贴着滚动容器左缘(水平 padding 为 0,永远贴边), + 光晕外侧的 2px 就被裁掉。 +- 上下方向同理:Tab / 点选靠边的字段时,浏览器只把它自动滚到"刚好进入视野"(贴边),光晕照样被裁。 + +**修复**: +- 文件:`web/src/views/module/module-management/index.vue`、`web/src/views/module/MenuManagement.vue`。 +- 把内边距从 `.mm-config-stack` 挪到滚动容器 `.mm-config-pane` 自己身上(`padding: 16px`,视觉位置不变)。 +- `.mm-config-pane` 同时加 `scroll-padding: 16px`:让「聚焦自动滚动」也留出同样余量。 +- 原型 fms-vue 本来就是这么写的(`.mm-config-pane { padding: 12px 16px 16px }`),移植时放错了层。 + +**约束**: +- 滚动容器的内边距**必须放在滚动容器自己身上**,放外层不生效。 +- 滚动过程中正掠过边缘的字段,光晕会被裁一瞬——这是滚动容器的固有行为(同页面视口裁切), + 属正常现象;不要为它改组件库的焦点环("画在控件内侧"是设计变更,需另行决策)。 +- 同类结构(外层 padding + 内层 overflow 滚动容器)出现在别处时,套用同一条:让滚动容器自己带 padding。 diff --git a/code/g3soft-erp/web/src/utils/schemaJson.ts b/code/g3soft-erp/web/src/utils/schemaJson.ts index 4eeda7f4..f964f860 100644 --- a/code/g3soft-erp/web/src/utils/schemaJson.ts +++ b/code/g3soft-erp/web/src/utils/schemaJson.ts @@ -37,11 +37,11 @@ export function createDefaultSchema(schemaType: string): SchemaJson { // (v1 是平铺字段节点 + newline 标记,加载时仍可容错折成行) if (schemaType === 'edit') return { schemaVersion: 2, children: [] } if (schemaType === 'query') { - // schemaVersion 4:只保留工作队列页签(组内单选、组间叠加,条件直写 SQL)。 - // 查询能力(关键词字段、更多筛选字段、控件与算子)由列表页按 s_field 运行时推导,不再配置。 + // schemaVersion 6:查询入口 = 快捷查询(keyword)+ 平铺条件(filters)+ 筛选组(groups) return { - schemaVersion: 4, - views: [], + schemaVersion: 6, + filters: [], + groups: [], } } return { schemaVersion: 1 } diff --git a/code/g3soft-erp/web/src/views/module/MenuManagement.vue b/code/g3soft-erp/web/src/views/module/MenuManagement.vue index 26dbb2d3..5b8e4711 100644 --- a/code/g3soft-erp/web/src/views/module/MenuManagement.vue +++ b/code/g3soft-erp/web/src/views/module/MenuManagement.vue @@ -21,11 +21,9 @@ * 菜单入口权限点(menu.*)由本页**自动派生** —— 页面 / 外链菜单各对应一行, * 名称 / 顺序 / 启用状态跟随菜单,保存时同步、删除时一并删除,因此没有单独的编辑界面。 * - * 布局:左侧菜单树 + 右侧两块(菜单配置 / 绑定模块,后者见 ModuleBindPanel)。 + * 布局:左侧菜单树 + 右侧菜单配置卡。 * * 尚未移植(fms-vue 有、本页暂无): - * - 绑定模块面板里的「模块权限点表 + 权限库」两块(就地增删 s_power、从历史动作库导入)—— - * 目前只做了模块绑定(s_menu_module); * - 菜单名称的多语言弹窗(s_i18n)。 */ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' @@ -57,22 +55,17 @@ import { } from '@/utils/tree' import MenuBasicPanel from './menu-management/MenuBasicPanel.vue' import MenuTreePanel from './menu-management/MenuTreePanel.vue' -import ModuleBindPanel from './menu-management/ModuleBindPanel.vue' import { buildMenuI18nKey, - buildMenuModuleRows, buildMenuPowerCode, buildMenuPowerRow, buildPowerDeleteDependents, menuRowOf, MENU_NON_LEAF_TYPES, MENU_TYPE_ICON_MAP, - moduleBindSignature, - normalizeMenuModulesByType, normalizeMenuPowerRow, type MenuNode, } from './menu-management/utils' -import { MODULE_NON_LEAF_TYPES, MODULE_TYPE_ICON_MAP } from './module-management/utils' defineOptions({ name: 'ModuleMenuManagement' }) @@ -339,46 +332,6 @@ const configSaving = ref(false) /** 菜单入口权限点的加载基线(s_power 行,无则 null) */ const menuPowerOrg = ref | null>(null) -/* ---- 绑定模块(s_menu_module):模块清单 + 绑定草稿 ---- */ - -/** 模块树(module 分类 + data / virtual 数据模块),绑定面板勾选用 */ -const moduleTreeData = ref([]) -/** 模块编码 → b_module_type(判断哪些节点可绑定、过滤回写用) */ -const moduleTypes = ref>({}) -const moduleLoading = ref(false) -let moduleTreeLoaded = false - -/** 已绑模块编码草稿(绑定面板 v-model,随菜单「保存」一起提交) */ -const menuModules = ref([]) -/** 绑定关系的加载基线(保存时 diff 的对照物) */ -const menuModulesOrg = ref([]) - -/** - * 加载模块树:绑定面板的勾选来源。 - * 整页只加载一次(模块清单不随菜单切换变化),失败也置 moduleTreeLoaded 之外的标志, - * 避免每次切菜单都重试一遍打接口 —— 面板会显示空树,用户刷新页面即可重来。 - */ -async function loadModuleTree() { - if (moduleTreeLoaded) return - moduleLoading.value = true - try { - const rows = (await loadDataApi('s_module', '1 = 1', 'b_xh ASC, b_id ASC')) as TreeRow[] - moduleTreeData.value = decorateTreeNodes(buildSortedTree(rows), { - typeField: 'b_module_type', - nonLeafValues: MODULE_NON_LEAF_TYPES, - iconMap: MODULE_TYPE_ICON_MAP, - }) - const types: Record = {} - for (const row of rows) types[String(row.b_id)] = String(row.b_module_type ?? '') - moduleTypes.value = types - moduleTreeLoaded = true - } catch (e) { - G3Message.error('加载模块清单失败:' + errorText(e)) - } finally { - moduleLoading.value = false - } -} - const busy = computed(() => configLoading.value || configSaving.value) /** 卡头展示的菜单:优先读草稿 —— 名称 / 启用态与配置面板实时一致 */ @@ -390,29 +343,13 @@ const basicPanel = ref | null>(null) /** * 是否有未保存改动(草稿与基线不一致)。 * - * 除主表外还要比绑定模块:切菜单前用它拦一下,否则刚勾好的模块绑定会被静默丢掉。 - * 用顺序无关签名比较(勾父级会带回子树键、顺序可能不同),避免假脏。 + * 切菜单前用它拦一下,避免用户手上的修改被静默丢掉。 */ const hasUnsavedChanges = computed(() => { if (!menuDraft.value || !menuDraftOrg.value) return false - if (JSON.stringify(menuDraft.value) !== JSON.stringify(menuDraftOrg.value)) return true - return moduleBindSignature(menuModules.value) !== moduleBindSignature(menuModulesOrg.value) + return JSON.stringify(menuDraft.value) !== JSON.stringify(menuDraftOrg.value) }) -/** - * 菜单类型变化时清空绑定草稿:只有页面菜单绑定模块。 - * - * 清在这里而不是保存时,是因为绑定面板在非页面类型下不渲染树(草稿留着也看不见), - * 一旦切回页面类型就会「凭空冒出」一批用户以为已经丢掉的绑定。 - * 保存前还会按最终类型再收敛一次(buildSaveData),两道防线保证非页面菜单不写关系行。 - */ -watch( - () => menuDraft.value?.b_menu_type, - (type) => { - menuModules.value = normalizeMenuModulesByType(type, []) - }, -) - /** 选中变化:重建草稿并加载权限点基线 */ watch(menuSelectedId, (id) => { void loadMenuConfig(id) @@ -422,8 +359,6 @@ async function loadMenuConfig(id: string | null) { menuDraft.value = null menuDraftOrg.value = null menuPowerOrg.value = null - menuModules.value = [] - menuModulesOrg.value = [] loadedMenuId.value = null if (id == null) return @@ -431,7 +366,6 @@ async function loadMenuConfig(id: string | null) { // 未落库的新节点:临时 id 在库里查不到行,不能走接口 // (会得到空数据,模板按 null 渲染直接崩溃)。从树节点重建主行继续编辑。 - // 绑定关系同理:库里还没有关系行,草稿从空开始。 if (unsavedMenuIds.has(requested)) { const node = findMenuNode(requested) if (!node) return @@ -464,16 +398,6 @@ async function loadMenuConfig(id: string | null) { if (String(menuSelectedId.value ?? '') !== requested) return menuPowerOrg.value = normalizeMenuPowerRow(powerRows?.[0] ?? null) - // 绑定模块基线(s_menu_module):行身份 (b_menu_id, b_module_id),只记模块编码 - const moduleRows = await loadDataApi( - 's_menu_module', - `b_menu_id = ${sqlStringLiteral(requested)}`, - 'b_xh ASC, b_module_id ASC', - ) - if (String(menuSelectedId.value ?? '') !== requested) return - menuModulesOrg.value = (moduleRows ?? []).map((row) => String(row.b_module_id)) - menuModules.value = [...menuModulesOrg.value] - loadedMenuId.value = requested } catch (e) { if (String(menuSelectedId.value ?? '') === requested) { @@ -554,33 +478,6 @@ function buildSaveData(): unknown[] | null { reqs.push(powerReq) } - // 模块绑定(s_menu_module):行身份 (b_menu_id, b_module_id),只提交增删行。 - // 草稿按**最终**菜单类型收敛一遍:非页面菜单不绑定模块, - // 改成目录 / 外链后保存时把已有关系行一并删掉(切类型时已清空草稿,这里是第二道防线)。 - // draftRow 来自 Record 的展开,b_menu_type 需要收窄成 string - const saveMenuType = String(draftRow.b_menu_type ?? '') - const bindDraft = normalizeMenuModulesByType(saveMenuType, menuModules.value) - const bindOrg = normalizeMenuModulesByType(saveMenuType, menuModulesOrg.value) - const moduleChanges = diffRows( - buildMenuModuleRows(String(draftRow.b_id), bindDraft), - buildMenuModuleRows(String(draftRow.b_id), bindOrg), - { keyFields: ['b_menu_id', 'b_module_id'] }, - ) - if ( - moduleChanges.inserts.length || - moduleChanges.updates.length || - moduleChanges.deletes.length - ) { - const moduleReq: Record = { - table: 's_menu_module', - key_field: 'b_menu_id,b_module_id', - } - if (moduleChanges.inserts.length) moduleReq.inserts = moduleChanges.inserts - if (moduleChanges.updates.length) moduleReq.updates = moduleChanges.updates - if (moduleChanges.deletes.length) moduleReq.deletes = moduleChanges.deletes - reqs.push(moduleReq) - } - return reqs.length ? reqs : null } @@ -690,12 +587,6 @@ async function deleteMenu(id: string | null) { if (i18nKey) { reqs.push({ table: 's_i18n', key_field: 'b_key', deletes: [{ b_key: i18nKey }] }) } - // 菜单绑定的模块随菜单删除(b_menu_id 为复合主键首列,按它批量删) - reqs.push({ - table: 's_menu_module', - key_field: 'b_menu_id', - deletes: [{ b_menu_id: menuId }], - }) // 入口权限点随菜单删除:删除是条件式删除、0 行不报错, // 所以目录菜单(没有权限点)与历史数据都能安全走这里。 // 引用权限点的授权行由 dependent_deletes 声明,随权限点一并删除。 @@ -723,8 +614,6 @@ async function deleteMenu(id: string | null) { onMounted(() => { void loadMenuTree() - // 模块清单供右侧「绑定模块」面板勾选;整页只加载一次,与菜单树的加载互不阻塞 - void loadModuleTree() }) @@ -795,36 +684,22 @@ onMounted(() => { - -
-
-
-
-
-
菜单配置
-
- 维护菜单编码、名称、类型、路由地址与排序。 -
+ +
+
+
+
+
菜单配置
+
+ 维护菜单编码、名称、类型、路由地址与排序。
-
-
- -
-
@@ -925,39 +800,20 @@ onMounted(() => { margin-left: auto; } -/* 内容卡:左「菜单配置」+ 右「绑定模块」并排。 - 两块各自是白卡、各自滚动(左侧配置项长、右侧模块树长,互不牵制)。 */ -.mm-config-split { - display: flex; - flex: 1; - gap: 12px; - min-height: 0; - align-items: stretch; -} - -/* 左:菜单配置卡(原 .mm-config-stack,占剩余宽度) */ +/* 内容卡:菜单配置。 + 绑定模块面板已移除,这里只剩一张通栏白卡,纵向吃掉剩余高度、内容自己滚动。 + 内边距不放在这里:外层的内边距保护不了滚动容器的裁切(见 .mm-config-pane)。 */ .mm-config-stack { display: flex; - flex: 1 1 auto; + flex: 1; flex-direction: column; min-width: 0; min-height: 0; - padding: 16px; overflow: hidden; background: var(--g3-color-bg-container, #fff); border-radius: var(--g3-radius-base, 0.5rem); } -/* 右:绑定模块卡。定宽上限 320px:模块树层级浅、名称短,再宽只是空着一片; - 窄于 240px 时节点名会被省略号截得看不清,所以给 min-width 兜底 */ -.mm-config-bind { - display: flex; - flex: 0 1 300px; - min-width: 240px; - max-width: 320px; - min-height: 0; -} - .mm-panel-head { display: flex; align-items: flex-start; @@ -982,12 +838,18 @@ onMounted(() => { font-size: 12px; } +/* 内容滚动容器。内边距必须在滚动容器自己身上:滚动容器在自己的 padding 边缘 + * 裁切内容,焦点环是控件边框外的 2px 光晕,控件贴边(左缘无内边距时总是贴边) + * 就会被裁掉一截。scroll-padding 让聚焦自动滚动也留出同样余量 —— 否则 Tab 到 + * 新字段时浏览器把它滚到刚好贴边,光晕照样被裁。 */ .mm-config-pane { display: flex; flex: 1; flex-direction: column; min-height: 0; overflow: auto; + padding: 16px; + scroll-padding: 16px; } .mm-empty { diff --git a/code/g3soft-erp/web/src/views/module/menu-management/ModuleBindPanel.vue b/code/g3soft-erp/web/src/views/module/menu-management/ModuleBindPanel.vue deleted file mode 100644 index a47b6fff..00000000 --- a/code/g3soft-erp/web/src/views/module/menu-management/ModuleBindPanel.vue +++ /dev/null @@ -1,179 +0,0 @@ - - - - - diff --git a/code/g3soft-erp/web/src/views/module/menu-management/utils.ts b/code/g3soft-erp/web/src/views/module/menu-management/utils.ts index 0a84f8eb..d6008a7a 100644 --- a/code/g3soft-erp/web/src/views/module/menu-management/utils.ts +++ b/code/g3soft-erp/web/src/views/module/menu-management/utils.ts @@ -160,70 +160,3 @@ export function buildMenuPowerRow( /** 删入口权限点时的依赖声明(授权行随权限点删除),与删除入口共用一份 */ export { buildMenuPowerCode, buildPowerDeleteDependents } - -/* ================= 模块绑定(s_menu_module) ================= */ - -/** 可绑定模块的类型:module 只是模块树上的分类,不参与绑定 */ -export const BINDABLE_MODULE_TYPES = ['data', 'virtual'] - -/** 只有页面菜单绑定模块 */ -export const MENU_BIND_MENU_TYPE = 'page' - -/** - * 该模块编码是否可绑定。 - * 类型未知(模块清单里没有该键)时按**可绑定**处理:宁可保留历史绑定行, - * 也不因为清单没加载全就把用户已配好的关系当成脏数据删掉。 - */ -export function isBindableModule(moduleType: string | null | undefined): boolean { - if (moduleType == null || moduleType === '') return true - return BINDABLE_MODULE_TYPES.includes(String(moduleType)) -} - -/** 过滤出可绑定的模块编码(剔除分类节点,保持原顺序) */ -export function filterBindableModules( - keys: readonly unknown[], - moduleTypes: Record, -): string[] { - return (keys || []).map(String).filter((key) => isBindableModule(moduleTypes[key])) -} - -/** - * 顺序无关签名:比较两份编码集合是否一致。 - * 树回写(勾父级会带回整棵子树的键,且顺序可能不同)不该被判定成「外部值变了」, - * 否则刚点亮的分类节点会因为回写而闪掉。 - */ -export function moduleBindSignature(keys: readonly unknown[]): string { - return [...(keys || [])].map(String).toSorted().join(',') -} - -/** - * 已绑模块编码 → s_menu_module 行。 - * b_xh 记录页面内模块顺序(按当前勾选顺序 10 起步),顺序变化会产生 update,可接受。 - */ -export function buildMenuModuleRows( - menuId: string, - moduleIds: readonly unknown[], -): Array> { - const id = String(menuId || '').trim() - if (!id) return [] - return (moduleIds || []).map(String).map((moduleId, index) => ({ - b_menu_id: id, - b_module_id: moduleId, - b_xh: (index + 1) * 10, - b_canuse: 1, - })) -} - -/** - * 按菜单类型收敛绑定草稿:非页面菜单不绑定模块,改为非页面类型时清空。 - * - * 由父级在**菜单类型变化时**调用(面板在非页面类型下不渲染树,草稿留着也看不见, - * 一旦切回页面类型就会「凭空冒出」一批用户以为已经丢掉的绑定)。 - * 保存前还会再按最终类型收敛一次,两道防线保证非页面菜单不会写进关系行。 - */ -export function normalizeMenuModulesByType( - menuType: string | null | undefined, - moduleIds: readonly unknown[], -): string[] { - return String(menuType || '') === MENU_BIND_MENU_TYPE ? (moduleIds || []).map(String) : [] -} diff --git a/code/g3soft-erp/web/src/views/module/module-management/ModuleAutoCodePanel.vue b/code/g3soft-erp/web/src/views/module/module-management/ModuleAutoCodePanel.vue index c0ec58e7..9f637232 100644 --- a/code/g3soft-erp/web/src/views/module/module-management/ModuleAutoCodePanel.vue +++ b/code/g3soft-erp/web/src/views/module/module-management/ModuleAutoCodePanel.vue @@ -9,19 +9,21 @@ * 惰性建行:模块从未配置自动编码时,草稿保持空数组——打开面板即建行会让「无变更」 * 被 diff 成一条 insert;占位行只用于渲染与校验,用户首次操作(改任一参数)才落行。 * - * 布局(4 列紧凑网格 + 纵排 label,横排时 88px 的标签会把控件压到几十像素): - * ┌ 第一行:业务编号字段 / 重置周期 / 前缀 / 日期 - * ├ 第二行:分隔符 / 位数 / 起始值 - * └ 第三行:编号预览(跨整行) + * 布局(5 列紧凑网格 + 纵排 label,横排时 88px 的标签会把控件压到几十像素): + * ┌ 第一行:启用(总开关,独占整行) + * ├ 第二行:业务编号字段 / 重置周期(自然宽度,不拉伸铺满) + * ├ 第三行:前缀 / 日期 / 分隔符 / 位数 / 起始值(line-start 强制起新行,五格铺满) + * └ 第四行:编号预览(跨整行) * - * 「启用」开关不在这里:它是本面板的总开关,与它管辖的参数同级铺在网格里 - * 会被读成「其中一个字段」,且一关就整片置灰。开关上提到页面级面板头 - * (index.vue,与「同步字段」「预览查询入口」同一操作区),本面板通过 - * defineExpose 暴露 enabled / toggleEnabled 供父级驱动,并把 enabled 传回来 - * 决定参数是否可编辑。 + * 分组即填写顺序:行内从左到右、行间从上到下照着填即可。 + * + * 「启用」开关(b_canuse)独占表单第一行、在它管辖的参数上方:一关下方整片置灰, + * 开关本身不受启用态约束(它就是回来的路),只随 loading 锁。不能与参数同行同格 + * ——会被读成「其中一个字段」(原型教训);独占一行也有先例:编号预览同样是 + * 整行的独立行。 */ import { computed, reactive, ref, watch } from 'vue' -import { G3Form, G3FormItem, G3Input, G3Select } from '@g3soft/ui' +import { G3Form, G3FormItem, G3Input, G3Select, G3Switch } from '@g3soft/ui' import type { FormRule } from '@g3soft/ui' import type { ModuleAutoCodeRow, ModuleFieldRow } from './utils' @@ -31,22 +33,12 @@ const props = withDefaults( moduleId: string /** 本模块字段清单(s_field):业务编号字段下拉的候选与类型校验依据 */ fields?: ModuleFieldRow[] - /** 父级忙碌态(加载 / 保存中):期间锁掉全部控件 */ + /** 父级忙碌态(加载 / 保存中):期间锁掉全部控件(含启用开关) */ loading?: boolean - /** - * 启用态(由父级面板头的开关驱动)。 - * 面板内不再自持开关,参数区的可编辑性读它;未传时退化为「按草稿行判断」。 - */ - enabled?: boolean }>(), - { fields: () => [], loading: false, enabled: undefined }, + { fields: () => [], loading: false }, ) -const emit = defineEmits<{ - /** 草稿行被本面板改动(含开关翻转)后回报启用态,父级据此点亮面板头开关 */ - 'update:enabled': [value: boolean] -}>() - /** 自动编码规则草稿;空数组表示本模块未配置自动编码 */ const autoCodeModel = defineModel('autocode', { default: () => [] }) @@ -118,12 +110,6 @@ const formRow = computed(() => localRow.value ?? emptyRow.val const autoCodeEnabled = computed(() => Number(localRow.value?.b_canuse) === 1) -/** - * 参数区实际使用的启用态:以父级传进来的 enabled 为准(开关在面板头), - * 未传时退化成本地判断——单独挂这个面板(如单测)时行为不变。 - */ -const isEnabled = computed(() => props.enabled ?? autoCodeEnabled.value) - /* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */ const formRules = computed>(() => { const field = (props.fields || []).find( @@ -186,14 +172,12 @@ const formRef = ref | null>(null) * 校验全表:返回 [{ name, errors }],空数组即通过(供保存前调用) */ async function validate() { - // 未启用不校验:未配置(占位行)或已停用的规则都不该拦保存。 - // 用 isEnabled(父级口径)而不是本地 autoCodeEnabled:开关在面板头, - // 父级的 enabled 是权威值。 - if (!isEnabled.value) return [] + // 未启用不校验:未配置(占位行)或已停用的规则都不该拦保存 + if (!autoCodeEnabled.value) return [] return (await formRef.value?.validateAll()) ?? [] } -/* ---------- 启用态:开关在页面级面板头,这里只提供读写口 ---------- */ +/* ---------- 启用态:表单第一行的开关驱动惰性建行,参数按它置灰 ---------- */ /** 业务编号字段下拉候选 */ const fieldOptions = computed(() => @@ -243,19 +227,6 @@ function toggleEnabled(checked: boolean) { if (!existed) rowCreatedByToggle = true } -// 启用态回报给父级(驱动面板头开关)。 -// immediate:草稿在挂载时就带行(打开已配置的模块)时也要报一次, -// 否则父级开关会停在默认的 false,与面板内容不一致。 -// 这里回报的是「行内的真实 b_canuse」,拨开关导致的行变化随后会纠偏一次—— -// 两条路径收敛到同一真相,父级无需自己推算。 -watch( - localRow, - (row) => { - emit('update:enabled', Number(row?.b_canuse) === 1) - }, - { immediate: true }, -) - // 草稿被整体换掉(切模块 / 重载)时清掉建行意图: // 上一模块留下的 true 会让新模块的第一次「关」误丢行 watch( @@ -268,10 +239,6 @@ watch( defineExpose({ /** 保存前校验(未启用直接通过) */ validate, - /** 当前启用态(面板头开关读它) */ - enabled: isEnabled, - /** 拨开关:置 b_canuse,首次拨会惰性建行 */ - toggleEnabled, }) /** 就地写草稿行字段:ensureRow 保证行已存在(用户首次操作时才落行) */ @@ -334,15 +301,24 @@ const preview = computed(() => { :model="formRow" :rules="formRules" layout="vertical" - :columns="4" + :columns="5" max-width="720px" > - + + + + + + @@ -350,14 +326,16 @@ const preview = computed(() => { - + + @@ -365,17 +343,15 @@ const preview = computed(() => { - - @@ -383,7 +359,7 @@ const preview = computed(() => { @@ -391,18 +367,18 @@ const preview = computed(() => { - + diff --git a/code/g3soft-erp/web/src/views/module/module-management/ModuleBasicPanel.vue b/code/g3soft-erp/web/src/views/module/module-management/ModuleBasicPanel.vue index 5ccf3cb3..ed990fdd 100644 --- a/code/g3soft-erp/web/src/views/module/module-management/ModuleBasicPanel.vue +++ b/code/g3soft-erp/web/src/views/module/module-management/ModuleBasicPanel.vue @@ -7,10 +7,13 @@ * 编码(b_id 即主键):仅未落库草稿可改,改动时同步派生多语言键 module.xxx; * 类型切换会清掉不适用字段(module 无数据来源;virtual 无保存表)。 * - * 布局:三列栅格,一行三个字段;不分组(与菜单基础信息同款理由)—— + * 布局:三列栅格 + 竖排(label 在上、控件在下,与自动编码面板一致),一行三个字段; + * 不分组(与菜单基础信息同款理由)—— * 分组标题只是把十来个字段切成几段,字段本身并没有多到需要分段,去掉反而更紧凑。 * 多语言键(b_i18n)不在界面上显示 —— 它由编码派生、用户改不了, * 占一个字段位只是噪音(派生逻辑照旧,见 onCodeInput)。 + * 扩展配置 JSON(b_config_json)也不在界面上显示 —— 文档未定义模块级具体键, + * 先不开放编辑;列仍在表里,行数据照常透传。 * * 启用态(b_canuse)不在本表单里:它在左树行尾勾选(点一下即时写库)与卡头徽标展示。 * 表单里再放一个开关会与之语义冲突 —— 树里那个立即生效,表单这个要等「保存」, @@ -18,7 +21,9 @@ * * 校验规则(随输入实时提示;保存前由父级调 validate() 统一跑一遍): * - 编码:必填、2-50 位、小写字母开头、只含小写字母/数字/下划线、禁止负数临时编码; - * - 名称 / 类型必填;data 的查询来源(表或 SQL)、主键字段、保存表必填。 + * - 名称 / 类型必填; + * - data 必须有查询来源:查询表/视图与查询 SQL 互斥,只能填一个; + * - 主键字段 / 保存表可留空。 */ import { computed, ref } from 'vue' import { G3Form, G3FormItem, G3Input, G3Select } from '@g3soft/ui' @@ -48,8 +53,8 @@ const isVirtual = computed(() => moduleModel.value?.b_module_type === 'virtual') /** data / virtual 拥有查询来源与字段定义 */ const hasDataSource = computed(() => isData.value || isVirtual.value) -/** 数据范围字段候选(本模块字段),空 b_field 的行直接剔除 */ -const scopeFieldOptions = computed(() => +/** 本模块字段候选(空 b_field 的行剔除):主键字段 / 数据范围字段共用 */ +const moduleFieldOptions = computed(() => props.fields .filter((field) => field.b_field != null && String(field.b_field) !== '') .map((field) => ({ @@ -75,19 +80,29 @@ const formRules = computed>(() => ({ ], b_name: [{ required: true, message: '请填写模块名称' }], b_module_type: [{ required: true, message: '请选择模块类型' }], - b_view_table: isData.value - ? [ - { - validator: (value) => { - if (String(value ?? '').trim()) return true - if (String(moduleModel.value?.b_query_sql ?? '').trim()) return true - return '请填写查询表/视图,或配置查询 SQL' - }, - }, - ] - : [], - b_key_field: isData.value ? [{ required: true, message: '请填写主键字段' }] : [], - b_save_table: isData.value ? [{ required: true, message: '请填写保存表' }] : [], + b_view_table: [ + { + validator: (value) => { + const view = String(value ?? '').trim() + const sql = String(moduleModel.value?.b_query_sql ?? '').trim() + // 互斥:两个都有是配置冲突,不是「SQL 优先」 + if (view && sql) return '查询表/视图与查询 SQL 互斥,只能配置一个' + if (isData.value && !view && !sql) return '请填写查询表/视图,或配置查询 SQL' + return true + }, + }, + ], + // 互斥的另一半挂在这里:校验按字段触发,只在 view 上挂的话, + // 先填查询表再填 SQL 时不会出现任何提示 + b_query_sql: [ + { + validator: (value) => { + const sql = String(value ?? '').trim() + const view = String(moduleModel.value?.b_view_table ?? '').trim() + return sql && view ? '查询表/视图与查询 SQL 互斥,只能配置一个' : true + }, + }, + ], })) const formRef = ref | null>(null) @@ -136,9 +151,9 @@ function onModuleTypeChange(value: unknown) { ref="formRef" :model="moduleModel" :rules="formRules" + layout="vertical" :columns="3" max-width="720px" - label-width="96px" > - - + + - + + + + + @@ -180,18 +205,12 @@ function onModuleTypeChange(value: unknown) { - - - - + - - - []>([ // 没配 editor 的列在编辑期自动灰底:字段名是数据库列名,不可改 { dataIndex: 'b_field', title: '字段名', width: 160 }, - { dataIndex: 'b_name', title: '字段名称', width: 160, editor: { type: 'text' } }, + { dataIndex: 'b_name', title: '字段名称', width: 160, editor: { type: 'input' } }, { dataIndex: 'b_type', title: '业务类型', @@ -66,7 +66,7 @@ const activeKey = ref(null) row-key="b_field" :row-height="28" :active-row-key="activeKey" - @row-click="(_, row) => (activeKey = String(row.b_field ?? ''))" + @row-click="(_: MouseEvent, row: TableRow) => (activeKey = String(row.b_field ?? ''))" />
diff --git a/code/g3soft-erp/web/src/views/module/module-management/ModuleI18nPanel.vue b/code/g3soft-erp/web/src/views/module/module-management/ModuleI18nPanel.vue index 94431c02..c9a1cd24 100644 --- a/code/g3soft-erp/web/src/views/module/module-management/ModuleI18nPanel.vue +++ b/code/g3soft-erp/web/src/views/module/module-management/ModuleI18nPanel.vue @@ -130,7 +130,7 @@ const columns = computed[]>(() => { dataIndex: 'b_key', title: '多语言键', width: 260, - editor: { type: 'text', disabled: (row: TableRow) => isManagedRow(row) }, + editor: { type: 'input', disabled: (row: TableRow) => isManagedRow(row) }, }, ] for (const language of props.languages) { @@ -140,7 +140,7 @@ const columns = computed[]>(() => { title: language.name, width: 200, editor: { - type: 'text', + type: 'input', disabled: (row: TableRow) => isDefault && isNameDrivenRow(row), }, }) @@ -188,7 +188,7 @@ const columns = computed[]>(() => { row-key="_rowKey" :row-selection="rowSelection" :active-row-key="activeKey" - @row-click="(_, row) => (activeKey = String((row as I18nPivotRow)._rowKey ?? ''))" + @row-click="(_: MouseEvent, row: I18nPivotRow) => (activeKey = String(row._rowKey ?? ''))" />
diff --git a/code/g3soft-erp/web/src/views/module/module-management/ModulePowerPanel.vue b/code/g3soft-erp/web/src/views/module/module-management/ModulePowerPanel.vue index 43e3fbeb..7e36f67c 100644 --- a/code/g3soft-erp/web/src/views/module/module-management/ModulePowerPanel.vue +++ b/code/g3soft-erp/web/src/views/module/module-management/ModulePowerPanel.vue @@ -377,8 +377,8 @@ const displayRows = computed(() => const columns = shallowRef[]>([ { dataIndex: 'b_id', title: '权限编码', width: 190 }, - { dataIndex: 'b_name', title: '权限名称', width: 140, editor: { type: 'text' } }, - { dataIndex: 'b_action', title: '动作', width: 110, editor: { type: 'text' } }, + { dataIndex: 'b_name', title: '权限名称', width: 140, editor: { type: 'input' } }, + { dataIndex: 'b_action', title: '动作', width: 110, editor: { type: 'input' } }, { dataIndex: 'b_canuse', title: '启用', diff --git a/code/g3soft-erp/web/src/views/module/module-management/ModuleQueryPanel.vue b/code/g3soft-erp/web/src/views/module/module-management/ModuleQueryPanel.vue deleted file mode 100644 index 058d88f6..00000000 --- a/code/g3soft-erp/web/src/views/module/module-management/ModuleQueryPanel.vue +++ /dev/null @@ -1,816 +0,0 @@ - - - - - diff --git a/code/g3soft-erp/web/src/views/module/module-management/index.vue b/code/g3soft-erp/web/src/views/module/module-management/index.vue index 4a1ff882..48464a28 100644 --- a/code/g3soft-erp/web/src/views/module/module-management/index.vue +++ b/code/g3soft-erp/web/src/views/module/module-management/index.vue @@ -4,15 +4,15 @@ * * 当前已落地左侧模块树:加载(s_module)/ 搜索 / 新增草稿 / 删除 / 拖拽排序 / 未保存标记; * 右侧已搭好配置骨架:横向标签导航(nav-only,标签集随模块类型增减)+ 常驻 pane 堆叠区, - * 各配置面板(基础 / 字段 / 列表 / 表单 / 查询 / 自动编码 / 权限 / 关联 / 规则 / 多语言) - * 将按 fms-vue 的 module-management 逐节填进对应 pane,当前已落地基础 / 字段 / 查询 / + * 各配置面板(基础 / 字段 / 列表 / 表单 / 自动编码 / 权限 / 关联 / 规则 / 多语言) + * 将按 fms-vue 的 module-management 逐节填进对应 pane,当前已落地基础 / 字段 / * 自动编码(各面板只做编辑,改动落在父级持有的草稿上);保存编排落地时本文件按 fms 的分层 * 补齐「当前编辑值 / _org 基线成对持有 + 保存时逐表 diff」的编排职责。 * * 模块类型(s_module.b_module_type):module 模块 / data 数据模块 / virtual 虚拟模块。 */ import { computed, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from 'vue' -import { Ellipsis, Eye, FolderTree, RefreshCw, Save, Trash2 } from '@lucide/vue' +import { Ellipsis, FolderTree, RefreshCw, Save, Trash2 } from '@lucide/vue' import { useHotkeys } from '@/composables/useHotkeys' import { G3Button, @@ -21,7 +21,6 @@ import { G3Modal, G3Splitter, G3SplitterPanel, - G3Switch, G3Tabs, G3Tag, } from '@g3soft/ui' @@ -29,12 +28,7 @@ import type { DropdownClickPayload, TableRow, TreeDropInfo, TreeKey } from '@g3s import { loadDataApi, saveObjectApi } from '@/services/api' import { createTempId } from '@/utils/common' import { buildPowerDeleteDependents } from '@/utils/powerCodes' -import { - createDefaultSchema, - normalizeGroupIds, - parseSchema, - type SchemaJson, -} from '@/utils/schemaJson' +import { normalizeGroupIds, parseSchema } from '@/utils/schemaJson' import { applyTreePosition, buildSortedTree, @@ -52,7 +46,6 @@ import ModuleBasicPanel from './ModuleBasicPanel.vue' import ModuleFieldsPanel from './ModuleFieldsPanel.vue' import ModuleI18nPanel, { type I18nLanguage, type I18nPivotRow } from './ModuleI18nPanel.vue' import ModulePowerPanel from './ModulePowerPanel.vue' -import ModuleQueryPanel from './ModuleQueryPanel.vue' import ModuleTreePanel from './ModuleTreePanel.vue' import { MODULE_NON_LEAF_TYPES, @@ -238,7 +231,7 @@ interface ConfigSection { key: string label: string /** 面板头里标题下面的一句说明(右下内容卡的左上角) */ - desc: string + desc?: string scope: SectionScope } @@ -247,12 +240,12 @@ interface ConfigSection { * 原型的分组(概览 / 数据结构 / 界面配置 / 业务能力 / 高级设置)只用来决定先后顺序, * 平铺成一行标签、不显示组名——10 个标签 + 组名会多一层噪音。 * - * 标签不带图标:数据模块下 10 个标签全显示,图标让每个标签多出 18px、 + * 标签不带图标:数据模块下 9 个标签全显示,图标让每个标签多出 18px、 * 总宽超出卡片可用宽度(末两个标签被截、而标签栏滚动条是隐藏的), * 且 Table2 / List / Search 这类图标本身也分不出来。 * - * desc 用于右下内容卡的面板头(左上标题 + 说明),与标签栏分离: - * 标签栏只回答「在哪一页」,面板头回答「这一页是干什么的」。 + * desc 用于右下内容卡的面板头(左上标题 + 说明),与标签栏分工: + * 标签栏只回答「在哪一页」,面板头补一句「这一页是干什么的」并承载该面板的操作。 */ const CONFIG_SECTIONS: ConfigSection[] = [ { @@ -279,12 +272,6 @@ const CONFIG_SECTIONS: ConfigSection[] = [ desc: '配置列表显示列、筛选项与默认排序。', scope: 'config', }, - { - key: 'query', - label: '查询配置', - desc: '配置列表页查询入口:条件字段、工作队列页签、关键词与快捷筛选。', - scope: 'config', - }, { key: 'auto-code', label: '自动编码', @@ -358,9 +345,6 @@ const moduleFields = ref([]) /** 选中模块的自动编码规则草稿(s_autocode,一模块至多一条;空数组即未配置) */ const moduleAutoCode = ref([]) -/** 选中模块的查询设置草稿(s_module_schema.b_schema_type = 'query' 的配置对象) */ -const moduleQuery = ref(createDefaultSchema('query')) - /** 选中模块的权限点草稿(s_power,b_object_type='module') */ const modulePowers = ref([]) @@ -396,45 +380,34 @@ async function loadI18nLanguages() { } /** - * 自动编码的启用态:开关挂在面板头(与「同步字段」「预览查询入口」同一操作区), - * 状态由面板回报(@update:enabled),拨动时调面板暴露的 toggleEnabled, - * 面板负责惰性建行与写 b_canuse——父级不碰 s_autocode 行的构造规则。 + * 自动编码规则加载中:窗口期内锁掉整个面板(开关与参数)。 + * 加载一开始草稿就被清空、响应到达时被整体替换——窗口内的拨动 / 编辑 + * 会被静默丢弃。只跟本面板自己的请求走,与模块树等其它加载无关。 */ -const autoCodeEnabled = ref(false) - -function onToggleAutoCode(value: boolean) { - autoCodePanel.value?.toggleEnabled(value) -} +const moduleAutoCodeLoading = ref(false) /** - * 面板实例引用:自动编码(保存前 validate / 面板头 toggleEnabled)、 - * 查询设置(面板头预览弹窗)。 + * 面板实例引用:自动编码(保存前 validate)。 * - * 必须用「函数 ref」而不是 `ref="name"`:两个面板都写在 + * 必须用「函数 ref」而不是 `ref="name"`:面板写在 * `v-for="section in configSections"` 里面,模板编译器会给标签加上 * `ref_for: true`,此时 Vue 赋给 ref 的是**实例数组**而不是单个实例—— - * 于是 `autoCodePanelRef.value?.toggleEnabled(...)` 变成 + * 于是 `autoCodePanel.value?.toggleEnabled(...)` 变成 * 「数组上找 toggleEnabled」→ undefined → is not a function。 * 函数 ref 由自己接收实例,不触发 ref_for 的数组收集,天然免疫。 */ const autoCodePanel = shallowRef | null>(null) -const queryPanel = shallowRef | null>(null) /** 函数 ref:模板里写 :ref="setAutoCodePanel"(不能用 ref="autoCodePanel") */ function setAutoCodePanel(instance: unknown) { autoCodePanel.value = (instance as InstanceType) ?? null } -function setQueryPanel(instance: unknown) { - queryPanel.value = (instance as InstanceType) ?? null -} - -/** 选中变化:重建草稿并拉字段 / 自动编码(异步响应带过期保护,切走即丢弃) */ +/** 选中变化:重建草稿并拉各面板数据(异步响应带过期保护,切走即丢弃) */ watch(moduleSelectedId, (id) => { moduleDraft.value = id == null ? null : toModuleDraft(String(id)) void loadModuleFields(id) void loadModuleAutoCode(id) - void loadModuleQuery(id) void loadModulePowers(id) void loadModuleI18n(id) }) @@ -462,50 +435,49 @@ async function loadModuleFields(id: string | null) { } } +/** + * 自动编码最近一次加载的令牌:只有最新请求的响应能落草稿、清加载态。 + * + * 只比对模块 id 挡不住「同一模块被先后加载两次」(选中 A → 中间切走 → 再选回 A, + * 会为 A 发出两个请求,响应可能乱序到达):后发响应先清掉加载态后,旧响应若随后 + * 到达,id 比对照样通过——它会覆盖刚落下的编辑,并把启用态一起带回旧值。 + * 令牌在每次加载(含早退分支)开头换新,旧响应对不上就整体作废, + * 由此保证「开关可拨 ⟺ 没有还能写入的响应在途」。 + */ +let autoCodeRequestToken: symbol | null = null + /** * 加载本模块的自动编码规则(s_autocode)。 - * 只有数据模块有保存表、编号才有落库位置,其余模块直接按「未配置」处理,不发请求。 + * 编号只在数据模块保存记录时写入,其余模块直接按「未配置」处理,不发请求。 + * 加载窗口由 moduleAutoCodeLoading 标记:整个面板(开关与参数)随之锁定。 + * 过期响应(选择已切走 / 同一模块被重新选中,即令牌被顶替)整体丢弃。 */ async function loadModuleAutoCode(id: string | null) { moduleAutoCode.value = [] - autoCodeEnabled.value = false + // 换新令牌 + 归零加载态:任何在途的旧请求就此作废。 + // 切到非数据模块 / 清空选择时下面直接早退不发请求,加载态只能靠这里归零 + //(旧请求的 finally 已被令牌挡下,不会再清)。 + const token = Symbol('autocode-load') + autoCodeRequestToken = token + moduleAutoCodeLoading.value = false if (id == null) return if (findModuleNode(id)?.b_module_type !== 'data') return const requested = String(id) + moduleAutoCodeLoading.value = true try { const rows = await loadDataApi( 's_autocode', `b_module_id = ${sqlStringLiteral(requested)}`, 'b_field ASC', ) - // 选择已切走时丢弃过期响应 - if (String(moduleSelectedId.value ?? '') !== requested) return + // 过期响应整体丢弃:不落草稿 + if (autoCodeRequestToken !== token) return moduleAutoCode.value = (rows ?? []) as ModuleAutoCodeRow[] - // 启用态随规则一起落定:规则行可能存在但 b_canuse = 0(配过又停用) - autoCodeEnabled.value = Number(moduleAutoCode.value[0]?.b_canuse) === 1 } catch { // 规则加载失败不打断页面:面板按「未配置」展示 - } -} - -/** - * 加载本模块的查询设置(s_module_schema 的 query 行)。 - * 没有该行即「未配置」,用默认空结构;查询设置目前只做编辑,不回写库。 - */ -async function loadModuleQuery(id: string | null) { - moduleQuery.value = createDefaultSchema('query') - if (id == null) return - const requested = String(id) - try { - const rows = await loadDataApi( - 's_module_schema', - `b_module_id = ${sqlStringLiteral(requested)} AND b_schema_type = ${sqlStringLiteral('query')}`, - ) - // 选择已切走时丢弃过期响应 - if (String(moduleSelectedId.value ?? '') !== requested) return - moduleQuery.value = parseSchema('query', rows?.[0]?.b_schema_json) - } catch { - // 查询设置加载失败不打断页面:面板按「未配置」展示 + } finally { + // 只有最新请求能清加载态;被顶替的旧请求不清,否则会误清新加载的「加载中」 + if (autoCodeRequestToken === token) moduleAutoCodeLoading.value = false } } @@ -610,17 +582,6 @@ function onMoreAction({ key }: DropdownClickPayload) { if (id != null) void deleteModule(String(id)) } -/** - * 自动编码的跨面板前置条件:启用编码的模块必须先配置保存表。 - * 编号是在保存记录时写进去的,没有保存表就没有落库位置,规则配了也不会生效。 - */ -function validateAutoCode(): string { - const rule = moduleAutoCode.value[0] - if (!rule || Number(rule.b_canuse) !== 1) return '' - if (!String(moduleDraft.value?.b_save_table ?? '').trim()) return '启用自动编码前请先配置保存表' - return '' -} - /** * 卡头「保存」:保存编排(校验 → 逐表 diff → 重载)随配置面板一起移植。 * 目前先把自动编码的校验串上:有错就跳到对应标签并高亮。 @@ -632,12 +593,6 @@ async function onSave() { G3Message.warning('请修正表单中标红的错误项') return } - const autoCodeError = validateAutoCode() - if (autoCodeError) { - activeSection.value = 'auto-code' - G3Message.warning(autoCodeError) - return - } G3Message.info('保存编排随配置面板逐步移植') } @@ -941,7 +896,7 @@ onMounted(() => { />
- +
{ 同步字段
- -
- - - 预览查询入口 - -
- -
- 启用 - -
@@ -1003,21 +937,14 @@ onMounted(() => { v-else-if="section.key === 'fields'" v-model:fields="moduleFields" /> - - { } /* 下块内容卡:所有 pane 常驻堆叠(v-show 切换),滚动交给当前显示的 pane。 - * overflow: hidden 让圆角裁住里面的内容区。 */ + * overflow: hidden 让圆角裁住里面的内容区。 + * 内边距不放在这里:外层的内边距保护不了滚动容器的裁切(见 .mm-config-pane)。 */ .mm-config-stack { display: flex; flex: 1; flex-direction: column; min-height: 0; - padding: 16px; overflow: hidden; background: var(--g3-color-bg-container, #fff); border-radius: var(--g3-radius-base, 0.5rem); @@ -1205,18 +1132,18 @@ onMounted(() => { margin-left: auto; } -/* 自动编码开关的标签:与 G3Switch 同排,字号对齐面板头的说明文案 */ -.mm-panel-head__switch-label { - color: var(--g3-color-text, #1f2329); - font-size: 13px; -} - +/* 内容滚动容器。内边距必须在滚动容器自己身上:滚动容器在自己的 padding 边缘 + * 裁切内容,焦点环是控件边框外的 2px 光晕,控件贴边(左缘无内边距时总是贴边) + * 就会被裁掉一截。scroll-padding 让聚焦自动滚动也留出同样余量 —— 否则 Tab 到 + * 新字段时浏览器把它滚到刚好贴边,光晕照样被裁。 */ .mm-config-pane { display: flex; flex: 1; flex-direction: column; min-height: 0; overflow: auto; + padding: 16px; + scroll-padding: 16px; } /* 骨架期占位:具体面板按 fms-vue 逐节移植 */ diff --git a/code/g3soft-erp/web/src/views/module/module-management/utils.ts b/code/g3soft-erp/web/src/views/module/module-management/utils.ts index ce2444b1..3f68c193 100644 --- a/code/g3soft-erp/web/src/views/module/module-management/utils.ts +++ b/code/g3soft-erp/web/src/views/module/module-management/utils.ts @@ -2,18 +2,12 @@ * 模块管理公共工具(移植自 fms-vue 的 module-management/utils.js,按需逐步补齐)。 * * 当前提供:模块树所需的类型 / 图标 / 新增菜单项,删除模块时的多语言键收集 - * (模块名键、字段键、界面配置分组键),以及查询设置(query schema)的草稿模型 - * ——后续面板(列表 / 表单 / 权限等)用到的其它工具随对应步骤补充。 + * (模块名键、字段键、界面配置分组键)——后续面板(表单 / 列表 / 权限等) + * 用到的其它工具随对应步骤补充。 */ import { Database, Folder, FolderPlus, Search } from '@lucide/vue' import type { Component } from 'vue' -import { - buildGroupI18nKey, - createDefaultSchema, - type SchemaJson, - type SchemaNode, -} from '@/utils/schemaJson' -import { createTempId } from '@/utils/common' +import { buildGroupI18nKey, type SchemaJson, type SchemaNode } from '@/utils/schemaJson' import type { TreeRow } from '@/utils/tree' /** 模块类型(s_module.b_module_type):module 模块 / data 数据模块 / virtual 虚拟模块 */ @@ -100,131 +94,6 @@ export interface ModuleAutoCodeRow { [key: string]: unknown } -/* ================= 查询设置(s_module_schema.b_schema_type = 'query') ================= - * 只配一件事:工作队列页签(views)。组内单选、组间叠加,条件直接写 SQL。 - * - * 查询能力不再配置:关键词搜哪些字段、更多筛选铺哪些字段、用什么控件与算子, - * 列表页按 s_field 就能推导(控件与算子看业务类型、关键词看文本类字段、 - * 更多筛选铺全部启用字段并按 b_xh 排序)。配一遍是重复劳动,还容易漏配 —— - * 新模块零配置就该有查询能力。相对时间的快捷预设属于日期控件自己的能力,同样不归这里。 - * - * 控件与算子的取值口径(列表页运行时用):组件 input 输入框 / select 下拉 / date 日期 / - * datetime 日期时间 / number 数字 / checkbox 复选框;操作符 eq 等于 / like 模糊 / - * starts_with 开头是 / gt ge lt le 比较 / in 包含 / between 范围。 - * - * 落库形态是 query JSON(schemaVersion 4);未建模属性经行上的 _node 原样带回, - * JSON 里未来的扩展键不会在编辑往返中被静默丢掉。 - */ - -/** - * 页签所属分组:模型上页签「一定有组」——组既是列表页上的一行页签, - * 也是「组内单选、组间叠加」的作用域,不存在不属于任何组的页签。 - * 历史配置里没写 group 的,落到这个默认组,落库时补上。 - */ -export const DEFAULT_VIEW_GROUP = '默认' - -/** 工作队列页签行(草稿):code 为空的行不落库(页签没有编码就无法被运行时选中) */ -export interface ModuleQueryViewRow { - _rowKey: string - _node: Record - code: string - label: string - group: string - order: number - showCount: boolean - isDefault: boolean - conditionSql: string - [key: string]: unknown -} - -/** 查询设置草稿:与落库 JSON 经下面两个函数双向适配 */ -export interface ModuleQueryDraft { - views: ModuleQueryViewRow[] -} - -/** query JSON → 编辑器草稿(只取页签;其余键不再建模,由回写统一清理) */ -export function querySchemaToDraft(schema: SchemaJson | null): ModuleQueryDraft { - const source = (schema && typeof schema === 'object' ? schema : {}) as SchemaJson & { - views?: Array> - defaultViewCode?: string - } - // 旧结构的 defaultViewCode 等价于「该页签 isDefault」,加载时折进来 - const legacyDefaultCode = String(source.defaultViewCode ?? '') - const views = (source.views || []).map((view, index) => { - const code = String(view?.code ?? '') - return { - _rowKey: createTempId(), - _node: view && typeof view === 'object' ? { ...view } : {}, - code, - label: String(view?.label ?? ''), - group: String(view?.group ?? '').trim() || DEFAULT_VIEW_GROUP, - order: Number(view?.order) || (index + 1) * 10, - showCount: view?.showCount === true, - isDefault: view?.isDefault === true || (legacyDefaultCode !== '' && code === legacyDefaultCode), - conditionSql: String(view?.conditionSql ?? '').trim(), - } as ModuleQueryViewRow - }) - return { views } -} - -/** 编辑器草稿 → query JSON(未建模属性经 _node / base 保留,建模属性以草稿为准) */ -export function queryDraftToSchema( - draft: ModuleQueryDraft | null, - baseSchema?: SchemaJson | null, -): SchemaJson { - const base = { ...(baseSchema || createDefaultSchema('query')) } - // 查询能力由列表页按 s_field 推导:这几个配置键不再使用,改写时一并清掉 - // (queryFields 里还挂着历史遗留的 mode / presets / defaultPreset,随整个键一起清) - delete base.queryFields - delete base.keywordSearch - delete base.quickFilters - // 更早版本的遗留键 - delete base.groups - delete base.quick - delete base.filterGroups - delete base.defaultViewCode - - const views = (draft?.views || []) - .map((row) => { - const code = String(row.code ?? '').trim() - if (!code) return null - const view: Record = { ...row._node, code } - if (String(row.label ?? '').trim()) view.label = String(row.label).trim() - else delete view.label - // 页签一定有分组:空的一律落到默认组(历史配置在此被补全) - view.group = String(row.group ?? '').trim() || DEFAULT_VIEW_GROUP - view.order = Number(row.order) || 10 - if (row.showCount) view.showCount = true - else delete view.showCount - if (row.isDefault) view.isDefault = true - else delete view.isDefault - const conditionSql = String(row.conditionSql ?? '').trim() - if (conditionSql) view.conditionSql = conditionSql - else delete view.conditionSql - return view - }) - .filter((view): view is Record => view != null) - - return { - ...base, - schemaVersion: 4, - views, - } -} - -/** - * 校验页签条件 SQL:只拦注入类写法(分号 / 注释),不校验字段与语法。 - * 返回 true 通过,返回字符串即错误文案。 - */ -export function validateConditionSql(text: string): true | string { - const value = String(text ?? '').trim() - if (!value) return true - if (value.includes(';')) return '不能包含分号' - if (value.includes('--')) return '不能包含单行注释 --' - if (/\/\*/.test(value)) return '不能包含块注释 /* */' - return true -} - /* ================= 多语言资源键(s_i18n,主键 b_key + b_locale) ================= */ export function buildModuleItemI18nKey( diff --git a/code/g3soft-erp/web/src/views/table-test/index.vue b/code/g3soft-erp/web/src/views/table-test/index.vue index ff84efc4..975e53b3 100644 --- a/code/g3soft-erp/web/src/views/table-test/index.vue +++ b/code/g3soft-erp/web/src/views/table-test/index.vue @@ -1,8 +1,8 @@ diff --git a/code/g3soft-libs/packages/ui/src/table/G3Table.test.ts b/code/g3soft-libs/packages/ui/src/table/G3Table.test.ts deleted file mode 100644 index 599de068..00000000 --- a/code/g3soft-libs/packages/ui/src/table/G3Table.test.ts +++ /dev/null @@ -1,497 +0,0 @@ -import { mount } from '@vue/test-utils' -import { describe, expect, it, vi } from 'vitest' -import { nextTick } from 'vue' -import G3Table from './G3Table.vue' -import { isSelectionColumn } from './utils/selection' -import type { G3TableColumn, TableRow } from './types' - -/** - * G3Table 组件契约测试。 - * - * 重点锁住**性能门控**:这是整个组件最核心也最容易在后续重构中被改坏的逻辑。 - * 门控是纯计算(行数 / 列数与阈值比较),不依赖布局引擎,所以能在 happy-dom 里测。 - * - * 依赖实测几何的行为(虚拟滚动的真实行位置、拖拽)放 E2E。 - */ - -interface Row extends TableRow { - id: number - name: string - amount: number -} - -function makeRows(count: number): Row[] { - return Array.from({ length: count }, (_, i) => ({ - id: i + 1, - name: `行 ${i + 1}`, - amount: (i + 1) * 100, - })) -} - -// 组件 props 是按 TableRow 声明的,泛型不变性下 G3TableColumn 不能直接赋值给它, -// 这里统一用 G3TableColumn 声明,测试里仍按 Row 取值。 -const columns: G3TableColumn[] = [ - { dataIndex: 'name', title: '名称' }, - { dataIndex: 'amount', title: '金额' }, -] - -function mountTable(props: Record = {}) { - return mount(G3Table, { - props: { columns, dataSource: makeRows(3), seq: false, ...props }, - attachTo: document.body, - }) -} - -/** - * 读取传给底座 StkTable 的实际 props。 - * 门控结果最终体现在 `virtual` / `virtual-x` 这两个 prop 上。 - */ -function stkProps(wrapper: ReturnType) { - const stk = wrapper.findComponent({ name: 'StkTable' }) - expect(stk.exists()).toBe(true) - return stk.props() as Record -} - -describe('性能门控:纵向虚拟滚动', () => { - it('行数 ≤ 100 时关闭虚拟滚动(避免最后几行被吞)', () => { - const wrapper = mountTable({ dataSource: makeRows(20) }) - expect(stkProps(wrapper).virtual).toBe(false) - }) - - it('恰好 100 行仍关闭(边界:阈值是 > 100 才开)', () => { - const wrapper = mountTable({ dataSource: makeRows(100) }) - expect(stkProps(wrapper).virtual).toBe(false) - }) - - it('101 行时开启虚拟滚动', () => { - const wrapper = mountTable({ dataSource: makeRows(101) }) - expect(stkProps(wrapper).virtual).toBe(true) - }) - - it('显式传 virtual=false 时不开启(即便行数超过阈值)', () => { - const wrapper = mountTable({ dataSource: makeRows(500), virtual: false }) - expect(stkProps(wrapper).virtual).toBe(false) - }) - - it('空数据不开启', () => { - const wrapper = mountTable({ dataSource: [] }) - expect(stkProps(wrapper).virtual).toBe(false) - }) -}) - -describe('性能门控:横向虚拟滚动', () => { - function manyColumns(count: number): G3TableColumn[] { - return Array.from({ length: count }, (_, i) => ({ - dataIndex: `c${i}`, - title: `列 ${i}`, - })) as G3TableColumn[] - } - - it('列数少时关闭横向虚拟(保住原生列宽自适应)', () => { - const wrapper = mountTable({ columns: manyColumns(5), seq: false }) - expect(stkProps(wrapper).virtualX).toBe(false) - }) - - it('恰好 30 列仍关闭(边界:阈值是 > 30 才开)', () => { - const wrapper = mountTable({ columns: manyColumns(30), seq: false }) - expect(stkProps(wrapper).virtualX).toBe(false) - }) - - it('31 列时开启', () => { - const wrapper = mountTable({ columns: manyColumns(31), seq: false }) - expect(stkProps(wrapper).virtualX).toBe(true) - }) - - it('内部列(序号列)也计入列数', () => { - // 既有契约:按 displayColumns 计,内部注入列也算。 - // 30 个业务列 + 序号列 = 31 > 30,应当开启。 - const wrapper = mountTable({ columns: manyColumns(30), seq: true }) - expect(stkProps(wrapper).virtualX).toBe(true) - }) - - it('显式传 virtual-x=false 时不开启', () => { - const wrapper = mountTable({ columns: manyColumns(40), virtualX: false, seq: false }) - expect(stkProps(wrapper).virtualX).toBe(false) - }) - - /** - * 门控与固定列**解耦**:固定列不参与横虚门控,只看列数。 - * - * 这一组锁住的是「解耦」这个决定本身 —— 若有人把固定列判据加回 - * effectiveVirtualX,这里会红。理由见 G3Table.vue 里 effectiveVirtualX - * 的注释:固定列是业务侧的展示选择,不该反过来决定性能策略 - * (序号列默认开启且自带 fixed:'left',一旦参与门控等于永久关闭横虚)。 - * - * ⚠️ 代价(已知边界,未修复):列数 > 30 且带固定列时,冻结列仍会抖动 - * (上游横虚的窗口重排所致)。下面的用例刻意固定了这个事实, - * 避免它被误认为「已修好」。 - */ - it('左固定列不影响门控:列数够就开启(与固定列无关)', () => { - const cols = manyColumns(31) - cols[0].fixed = 'left' - const wrapper = mountTable({ columns: cols, seq: false }) - expect(stkProps(wrapper).virtualX).toBe(true) - }) - - it('右固定列同样不影响门控', () => { - const cols = manyColumns(31) - cols[cols.length - 1].fixed = 'right' - const wrapper = mountTable({ columns: cols, seq: false }) - expect(stkProps(wrapper).virtualX).toBe(true) - }) - - it('序号列不参与门控:22 列(21 业务列 + 序号)仍关闭横虚', () => { - // table-test 页的真实形态:阈值 30 之下,走的是横虚关闭的原生路径, - // 因此固定列走 sticky、不抖。 - const wrapper = mountTable({ columns: manyColumns(21), seq: true }) - expect(stkProps(wrapper).virtualX).toBe(false) - }) - - it('带固定列且列数超阈值时仍开启横虚(记录已知缺陷边界)', () => { - // 31 列 + 固定列 → 横虚开启 → 冻结列会抖(上游缺陷,见上方说明)。 - // 这条用例是**事实记录**:它保证将来修好之后,这个期望必须被显式改写, - // 而不是悄悄变化。 - const cols = manyColumns(31) - cols[0].fixed = 'left' - const wrapper = mountTable({ columns: cols, seq: true }) - expect(stkProps(wrapper).virtualX).toBe(true) - }) -}) - -describe('内部列注入', () => { - it('seq 默认开启,注入序号列', () => { - const wrapper = mountTable({ seq: true }) - const cols = stkProps(wrapper).columns as G3TableColumn[] - expect(cols[0].type).toBe('seq') - }) - - it('seq=false 时不注入序号列', () => { - const wrapper = mountTable({ seq: false }) - const cols = stkProps(wrapper).columns as G3TableColumn[] - expect(cols.some((c) => c.type === 'seq')).toBe(false) - }) - - it('传入 rowSelection 时在最前注入选择列', () => { - const wrapper = mountTable({ - seq: false, - rowSelection: { selectedRowKeys: [], onChange: () => {} }, - }) - const cols = stkProps(wrapper).columns as G3TableColumn[] - // 用导出的判定函数读内部标识,不直接摸私有字段 - expect(isSelectionColumn(cols[0])).toBe(true) - }) - - it('未传 rowSelection 时不注入选择列', () => { - const wrapper = mountTable({ seq: false }) - const cols = stkProps(wrapper).columns as G3TableColumn[] - expect(cols.some((c) => isSelectionColumn(c))).toBe(false) - }) - - it('rowDraggable 开启时注入拖拽列', () => { - const wrapper = mountTable({ seq: false, rowDraggable: true }) - const cols = stkProps(wrapper).columns as G3TableColumn[] - expect(cols[0].type).toBe('dragRow') - }) - - it('业务列不透传我们自己的 editor / actions 字段给库', () => { - const wrapper = mountTable({ - columns: [ - { dataIndex: 'name', title: '名称', editor: { type: 'text' } }, - ] as G3TableColumn[], - seq: false, - editable: false, - }) - const cols = stkProps(wrapper).columns as Array> - const nameCol = cols.find((c) => c.dataIndex === 'name')! - // editor 是我们自己的扩展字段,不应该被当作库的列配置传下去 - expect(nameCol.editor).toBeDefined() - // 但 customCell 在无编辑态时**不该**被注入(性能关键) - expect(nameCol.customCell).toBeUndefined() - }) -}) - -describe('editingActive 门控:无编辑行时不注入 customCell', () => { - it('全表无编辑行时,可编辑列不注入 customCell(走库原生 td)', () => { - const wrapper = mountTable({ - columns: [{ dataIndex: 'name', title: '名称', editor: { type: 'text' } }] as G3TableColumn[], - seq: false, - }) - const cols = stkProps(wrapper).columns as Array> - expect(cols[0].customCell).toBeUndefined() - }) - - it('select 列例外:始终注入(需要把值解析成 label 展示)', () => { - const wrapper = mountTable({ - columns: [ - { - dataIndex: 'name', - title: '名称', - editor: { type: 'select', options: [{ label: 'A', value: 'a' }] }, - }, - ] as G3TableColumn[], - seq: false, - }) - const cols = stkProps(wrapper).columns as Array> - expect(typeof cols[0].customCell).toBe('function') - }) - - it('checkbox 列例外:始终注入(要能随时点)', () => { - const wrapper = mountTable({ - columns: [{ dataIndex: 'amount', title: '启用', editor: { type: 'checkbox' } }] as G3TableColumn[], - seq: false, - }) - const cols = stkProps(wrapper).columns as Array> - expect(typeof cols[0].customCell).toBe('function') - }) - - it('actions 列始终注入 customCell', () => { - const wrapper = mountTable({ - columns: [ - { dataIndex: 'name', title: '操作', actions: [{ label: '编辑', onClick: () => {} }] }, - ] as G3TableColumn[], - seq: false, - }) - const cols = stkProps(wrapper).columns as Array> - expect(typeof cols[0].customCell).toBe('function') - }) - - it('调用方自带的 customCell 被尊重,不被覆盖', () => { - const custom = () => 'x' - const wrapper = mountTable({ - columns: [{ dataIndex: 'name', title: '名称', customCell: custom }] as unknown as G3TableColumn[], - seq: false, - }) - const cols = stkProps(wrapper).columns as Array> - expect(cols[0].customCell).toBe(custom) - }) -}) - -describe('列宽 / 列序拖拽的绑定门控', () => { - it('未绑定 v-model:columns 时关闭列宽拖拽(拖了无处回写)', () => { - const wrapper = mountTable() - expect(stkProps(wrapper).colResizable).toBe(false) - }) - - it('绑定了 v-model:columns 时开启列宽拖拽', () => { - const wrapper = mount(G3Table, { - props: { - columns, - dataSource: makeRows(3), - seq: false, - 'onUpdate:columns': () => {}, - }, - attachTo: document.body, - }) - const resizable = stkProps(wrapper).colResizable - expect(resizable).not.toBe(false) - expect(typeof (resizable as { disabled: unknown }).disabled).toBe('function') - }) - - it('autoFitWidth 开启时关闭列宽拖拽(两者语义冲突)', () => { - const wrapper = mount(G3Table, { - props: { - columns, - dataSource: makeRows(3), - seq: false, - autoFitWidth: true, - 'onUpdate:columns': () => {}, - }, - attachTo: document.body, - }) - expect(stkProps(wrapper).colResizable).toBe(false) - }) - - it('列宽拖拽的 disabled:操作列被禁止拖宽', () => { - const wrapper = mount(G3Table, { - props: { - columns: [ - { dataIndex: 'name', title: '名称' }, - { - dataIndex: 'op', - title: '操作', - resizable: false, - actions: [{ label: '删' }], - } as G3TableColumn, - ], - dataSource: makeRows(3), - seq: false, - editable: false, - 'onUpdate:columns': () => {}, - }, - attachTo: document.body, - }) - const config = stkProps(wrapper).colResizable as { disabled: (col: unknown) => boolean } - expect(config.disabled({ dataIndex: 'name', title: '名称' })).toBe(false) - expect(config.disabled({ dataIndex: 'op', resizable: false })).toBe(true) - }) -}) - -describe('主题', () => { - it('不传 theme 时跟随 documentElement(默认 light)', () => { - const wrapper = mountTable() - expect(stkProps(wrapper).theme).toBe('light') - }) - - it('显式传 theme 时以 prop 为准', () => { - const wrapper = mountTable({ theme: 'dark' }) - expect(stkProps(wrapper).theme).toBe('dark') - }) - - it('documentElement 标记为 dark 时跟随', async () => { - document.documentElement.dataset.g3Theme = 'dark' - const wrapper = mountTable() - await nextTick() - expect(stkProps(wrapper).theme).toBe('dark') - delete document.documentElement.dataset.g3Theme - }) -}) - -describe('空态', () => { - it('无数据时渲染默认空态文案', () => { - const wrapper = mountTable({ dataSource: [] }) - expect(wrapper.text()).toContain('暂无数据') - }) - - it('提供 empty 插槽时用插槽内容', () => { - const wrapper = mount(G3Table, { - props: { columns, dataSource: [], seq: false }, - slots: { empty: '
自定义空态
' }, - attachTo: document.body, - }) - expect(wrapper.find('.my-empty').exists()).toBe(true) - }) -}) - -describe('事件', () => { - it('列回写时剥离内部列后才 emit', async () => { - const onUpdate = vi.fn() - const wrapper = mount(G3Table, { - props: { - columns, - dataSource: makeRows(3), - seq: true, - 'onUpdate:columns': onUpdate, - }, - attachTo: document.body, - }) - const stk = wrapper.findComponent({ name: 'StkTable' }) - // 模拟库回写:把序号列也带回来 - await stk.vm.$emit('update:columns', [ - { type: 'seq', dataIndex: '', width: 70 }, - { dataIndex: 'name', title: '名称', width: 200 }, - { dataIndex: 'amount', title: '金额' }, - ]) - await nextTick() - - expect(onUpdate).toHaveBeenCalled() - const emitted = onUpdate.mock.calls[0][0] as G3TableColumn[] - // 序号列必须被剥离 - expect(emitted.some((c) => c.type === 'seq')).toBe(false) - expect(emitted.map((c) => c.dataIndex)).toEqual(['name', 'amount']) - // 业务列的新宽度要带上 - expect(emitted[0].width).toBe(200) - }) - - it('行拖拽回传索引(库给的是 key,组件负责换算)', async () => { - const onOrder = vi.fn() - const rows = makeRows(3) - const wrapper = mount(G3Table, { - props: { - columns, - dataSource: rows, - seq: false, - rowDraggable: true, - 'onRowOrderChange': onOrder, - }, - attachTo: document.body, - }) - const stk = wrapper.findComponent({ name: 'StkTable' }) - // 库回传的是行 key,不是索引 - await stk.vm.$emit('row-order-change', '3', '1') - await nextTick() - - expect(onOrder).toHaveBeenCalled() - const [emittedRows, sourceIndex, targetIndex] = onOrder.mock.calls[0] - expect(emittedRows).toHaveLength(3) - // id=3 在下标 2,id=1 在下标 0 - expect(sourceIndex).toBe(2) - expect(targetIndex).toBe(0) - }) -}) - -describe('暴露的方法', () => { - it('暴露预期的方法', () => { - const wrapper = mountTable() - const vm = wrapper.vm as unknown as Record - for (const name of [ - 'scrollToRow', - 'editRow', - 'initVirtualScroll', - 'refreshLayout', - 'getSelectedArea', - 'clearSelectedArea', - 'setRowExpand', - ]) { - expect(typeof vm[name]).toBe('function') - } - }) -}) - -describe('编辑态:值去重(initialValues)', () => { - /** - * 通过 editRow 进入编辑,再模拟库抛选区变化落到 name 列上, - * 从而拿到真实的 change 链路——直接调内部 ctx 是拿不到 emit 的。 - */ - async function enterEditing(wrapper: ReturnType) { - const vm = wrapper.vm as unknown as { editRow: (k: string | number, c?: string | null) => Promise } - await vm.editRow(1, 'name') - await nextTick() - } - - function editableColumns(): G3TableColumn[] { - return [ - { dataIndex: 'name', title: '名称', editor: { type: 'text' } }, - { dataIndex: 'amount', title: '金额', editor: { type: 'number' } }, - ] as G3TableColumn[] - } - - it('值确实变化时抛出 change', async () => { - const onChange = vi.fn() - const rows = makeRows(3) - const wrapper = mount(G3Table, { - props: { columns: editableColumns(), dataSource: rows, seq: false, onChange }, - attachTo: document.body, - }) - await enterEditing(wrapper) - - const stk = wrapper.findComponent({ name: 'StkTable' }) - // 库的编辑器失焦后会走 notifyChange;这里直接从渲染出的编辑器触发 blur - const input = wrapper.find('input') - expect(input.exists()).toBe(true) - await input.setValue('新名称') - await input.trigger('blur') - await nextTick() - - expect(onChange).toHaveBeenCalled() - expect(onChange.mock.calls[0][2]).toBe('新名称') - void stk - }) - - it('值未变化时不抛 change(点进去又点出来不该触发)', async () => { - const onChange = vi.fn() - const rows = makeRows(3) - const wrapper = mount(G3Table, { - props: { columns: editableColumns(), dataSource: rows, seq: false, onChange }, - attachTo: document.body, - }) - await enterEditing(wrapper) - - const input = wrapper.find('input') - expect(input.exists()).toBe(true) - // 不改变值,直接失焦:初始值快照与当前值相等,应被去重挡掉 - await input.trigger('blur') - await nextTick() - - expect(onChange).not.toHaveBeenCalled() - }) -}) \ No newline at end of file diff --git a/code/g3soft-libs/packages/ui/src/table/G3Table.vue b/code/g3soft-libs/packages/ui/src/table/G3Table.vue index 165db8dd..26f2ee4a 100644 --- a/code/g3soft-libs/packages/ui/src/table/G3Table.vue +++ b/code/g3soft-libs/packages/ui/src/table/G3Table.vue @@ -8,20 +8,13 @@ import { registerFeature, useAreaSelection } from 'stk-table-vue' * 不注册则 `area-selection-change` 永不触发、行内编辑整个失效。 * * ⚠ 注册时机是硬约束:**必须早于任何 StkTable 实例的创建**。 - * 库在 `StkTable.vue` 的 **setup 顶层**做这件事: - * } = ON_DEMAND_FEATURE[useAreaSelectionName](props, emits, ...) - * 即组件一被创建就立刻从注册表取值。若那时还没注册,取到的是库内置的 - * **降级 stub**(`config: computed(() => ({ enabled: false }))`), - * 此后 `area-selection` 类永不出现、单击单元格不会进入编辑。 + * 库在 `StkTable.vue` 的 setup 顶层就从注册表取值;若那时还没注册,取到的是 + * 库内置的降级 stub(`enabled: false`),此后单击单元格不会进入编辑。 + * 所以**不能**在 `onMounted` 里注册 —— 挂载晚于子组件的 setup,已经来不及了。 * - * 所以不能在 `onMounted` 里注册 —— 挂载晚于子组件的 setup,已经来不及了。 - * 正确做法是本模块**被求值时就注册**(见文件末尾的模块级调用)。 - * - * 为什么在模块顶层调用而不是要求消费方在 main.ts 里调用: - * 组件库不拥有 app 启动流程,不该要求用户改自己的入口文件;而本模块一旦 - * 被 import(用 G3Table 就必然发生),注册就已经完成,先于任何组件创建。 - * - * `registerFeature` 本身幂等;这里再兜一层模块级 + 全局 flag,兼容多入口/多副本。 + * fms 是在自己的 `main.js` 里手动注册的(它拥有 app 启动流程);组件库不拥有 + * 消费方的启动流程,不能要求用户改入口文件。所以这里**模块求值即注册**: + * 只要 import 了 G3Table,注册就已经完成,先于任何组件创建。 */ const AREA_SELECTION_FEATURE_KEY = '__g3TableAreaSelectionRegistered__' let areaSelectionRegistered = false @@ -44,41 +37,22 @@ registerTableFeatures() import { computed, getCurrentInstance, - isReactive, nextTick, onBeforeUnmount, onMounted, - reactive, ref, shallowRef, useSlots, watch, + type ShallowRef, } from 'vue' import { StkTable } from 'stk-table-vue' import type { StkTableColumn } from 'stk-table-vue' import { useNamespace } from '../_composables/useNamespace' -import { nextZIndex, releaseZIndex } from '../_utils/z-index' -import { createSelectionColumn, isSelectionColumn, SELECTION_COLUMN_KEY } from './utils/selection' -import { - DRAG_ROW_COLUMN, - DRAG_ROW_COLUMN_TYPE, - getHeaderAlign, - readInternalWidths, - resolveCustomCell, - resolveSeqConfig, - stripInternalColumns, -} from './utils/column' -import { findAdjacentEditableCell, findCellBelow } from './utils/editable' +import { createSelectionColumn, isSelectionColumn } from './utils/selection' +import { resolveCustomCell } from './utils/actions' import type { EditableContext } from './utils/editable' -import type { - G3TableColumn, - TableRow, - TableRowKey, - TableRowSelection, - TableScrollbarConfig, - TableScrollRowByRow, -} from './types' -import type { ShallowRef } from 'vue' +import type { G3TableColumn, TableRow, TableRowKey, TableRowSelection } from './types' /** * G3Table —— 高性能表格。 @@ -86,154 +60,89 @@ import type { ShallowRef } from 'vue' * 底座 `stk-table-vue`:虚拟滚动 / 列宽拖拽 / 列序拖拽 / 区域选择 / 行拖拽排序 * 都由它提供。本组件负责三件事:**性能门控**、**内部列管理**、**编辑态管理**。 * - * 实现照搬 fms 的 `FmsTable.vue`(已上线验证),只把命名与令牌换成 @g3soft 体系。 + * **本文件照抄 fms-vue 的 `FmsTable.vue`**(已上线验证),只做三类等价替换: + * 1. 命名与令牌:`--fms-*` → `--g3-*`,`fms-` 前缀 → `g3-` BEM 类名; + * 2. 主题:`useAppStore().darkMode`(业务 pinia)→ `documentElement[data-g3-theme]`; + * 3. 内部组件:`@/components/ui/*` → `G3Input` / `G3Select` / `G3Modal` / `G3Dropdown`。 */ defineOptions({ name: 'G3Table', inheritAttrs: false }) const props = withDefaults( defineProps<{ - columns?: G3TableColumn[] - dataSource?: TableRow[] - rowKey?: TableRowKey virtual?: boolean virtualX?: boolean - rowHeight?: number - headerRowHeight?: number - /** - * 边框:`true`(默认)= 圆角外框 + 行分隔线;`false` = 无外框、只留行分隔线 - * (嵌进卡片 / 面板时用)。 - * - * 不直接透传给底座(设计见 `docs/table-design.md` §3.2):底座的外框由 - * `border-left` + 三段背景渐变拼成,**做不出圆角**,且其右侧 / 底部边框会被 - * 滚动条占位吃掉。所以 `true` 会翻成底座的 `'v'` 档(只借它的竖线),**外框** - * 改由组件用真 `border` + 圆角令牌画;**横线**则把底座清零的那条渐变配方拿回来 - * —— 格线为什么不一起用真 `border`,见 `style/_overrides.scss` 的「边框」一节 - * (真边线会把行撑高 1px,而行高是虚拟滚动的计算基准)。 - */ + /** 是否在非横向虚拟滚动时自动铺满容器;默认按列配置宽度展示 */ + autoFitWidth?: boolean + /** 列宽拖拽;false=关闭;对象=按列禁用(铺满容器的弹性列应禁用) */ + colResizable?: boolean | { disabled?: boolean | ((col: G3TableColumn) => boolean) } + /** true=外框圆角描边+行分隔线(默认);false=无外框仅行分隔线(嵌入卡片场景) */ bordered?: boolean | string - /** - * 斑马纹(隔行底色),默认 `false`;等价于给底座的 `stripe` 传 true。 - * - * 为什么值得单独声明成 prop(而不是让它走 `$attrs` 透传):它需要组件**配合**—— - * 库只把条纹画在 `tr` 上,而冻结列的单元格底色必须不透明,会把条纹盖掉。 - * 声明成 prop 后,组件内部才能明确地「打开斑马纹 = 同时给冻结列接上条纹」 - * (见 style/_overrides.scss 的 stripe 段)。 - */ - stripe?: boolean - /** - * 固定列滚动遮挡时是否画阴影,默认 `false`。 - * - * 为什么默认关:阴影是一条 10px 的渐变叠在单元格右/左侧,只在「下面确实压着列」 - * 时才该出现(库按滚动位置判断),**没有固定列时它什么也不画**。之所以仍不默认开, - * 是因为它属于观感选择而非功能——有的业务觉得这层渐变糊在密集表格上很脏。 - * - * 但冻结列 + 不画阴影的观感是有问题的:滚动中固定列与滚过去的列紧贴在一起、 - * 底色又同为白,用户看不出「边界在哪、哪边的内容被压住了」,所以业务侧建议显式开启。 - */ - fixedColShadow?: boolean - colResizable?: boolean | { disabled?: boolean | ((col: G3TableColumn) => boolean) } + noDataFull?: boolean + showOverflow?: boolean + columns?: G3TableColumn[] + /** 序号列默认显示;false=关闭;对象=自定义 title / width */ + seq?: boolean | { title?: string; width?: number } + /** 列头拖拽默认开启;false=关闭;对象=自定义 mode / disabled */ columnDraggable?: | boolean | { mode?: 'none' | 'insert' | 'swap' - disabled?: boolean | ((col: G3TableColumn) => boolean) + disabled?: boolean | ((col: G3TableColumn) => boolean) } - rowDraggable?: boolean - rowSelection?: TableRowSelection | null - seq?: boolean | { title?: string; width?: number } + /** 行多选:提供时在最前列插入复选框列 */ + rowSelection?: TableRowSelection | null + dataSource?: TableRow[] + rowKey?: TableRowKey activeRowKey?: string | number | null + /** 行拖拽排序:开启后显示拖拽手柄,并通过 row-order-change 回传排序后的数据 */ + rowDraggable?: boolean + /** 存在 editor 配置的列默认可编辑;false 可整体禁用编辑 */ editable?: boolean - /** - * 编辑器是否无缝嵌入单元格(默认 true)。 - * - * true —— 「无外间距 + 无常驻边框」:编辑器铺满整格、自身不加内缩, - * 只有焦点 / 校验 / 特殊交互态才给视觉提示。整行编辑、键盘连续录入、 - * 固定列宽、专业用户(Excel / AG Grid / Handsontable 的表格态)用这种。 - * false —— 「悬浮卡片」观感:编辑器在格内留一圈 2px 间距并保留自身描边, - * 面向偏表单、偏鼠标的用户。 - * - * 几何稳定的关键:两种取值下编辑器的**盒高都等于 --row-height**, - * 所以切换真值不会改变行高,也不会让同一行的其它单元格跟着跳。 - */ - editorSeamless?: boolean - noDataFull?: boolean - showOverflow?: boolean - autoFitWidth?: boolean + /** 默认跟随应用深浅色模式,也允许单个表格显式覆盖 */ theme?: 'light' | 'dark' + /** 行高:唯一真源,同时驱动虚拟滚动计算与库注入的 --row-height 等 CSS 变量 */ + rowHeight?: number + headerRowHeight?: number footerData?: TableRow[] footerConfig?: Record - scrollbar?: boolean | TableScrollbarConfig - scrollRowByRow?: TableScrollRowByRow - smoothScroll?: boolean }>(), { - columns: () => [], - dataSource: () => [], - rowKey: 'id', virtual: true, virtualX: true, - rowHeight: 30, - headerRowHeight: 34, - bordered: true, - stripe: false, - fixedColShadow: false, + autoFitWidth: false, colResizable: true, - columnDraggable: true, - rowDraggable: false, - rowSelection: null, - seq: true, - activeRowKey: null, - editable: true, - editorSeamless: true, + bordered: true, noDataFull: true, showOverflow: true, - autoFitWidth: false, + columns: () => [], + seq: true, + columnDraggable: true, + rowSelection: null, + dataSource: () => [], + rowKey: 'id', + activeRowKey: null, + rowDraggable: false, + editable: true, theme: undefined, + rowHeight: 30, + headerRowHeight: 34, footerData: () => [], - // 空对象默认值:必须返回新对象(对象字面量直接写会所有实例共享同一引用) - footerConfig: () => ({}) as Record, - // 内置滚动条:默认**关闭**(false),回到库的原生滚动路径。 - // - // ⚠️ 为什么默认关掉(而不是开启): - // 开启 scrollbar=true 会触发库的实验性 scrollY(isExperimentalScrollY → 根节点加 - // `exp-scroll-y` 类、容器 overflow:hidden、纵向滚动靠 tbody transform:translateY 模拟、 - // DOM scrollTop 恒 0)。而滚轮滚动依赖 onTableWheel 里 e.preventDefault() 拦截 + 手动推进 - // scrollTop,但 Vue 对 @wheel 默认加 passive,preventDefault 静默失效(实测 wheel 事件 - // cancelable=false、scrollTop 与 translateY 恒 0),导致**鼠标滚轮彻底滚不动**, - // 只有拖滚动条(走独立的占位 tr 布局)能滚。 - // 关闭后容器 overflow:auto、走原生 scrollTop,wheel 由浏览器原生驱动,滚轮恢复正常。 - // 白屏由 smoothScroll=false 的 onwheel 防白屏路径解决(见下)。 - scrollbar: false, - // 按行滚动:默认 false(关闭)。实验模式下真值会把 translateY 钉 0,详见 scrollbar 注释。 - scrollRowByRow: false, - // 平滑滚动:默认 false。库的语义(StkTable.vue 的 smoothScroll 注释): - // · false —— 走 onwheel 拦截路径,专为防止「滚动过快白屏」,且滚轮能正确手动推进; - // · true —— 不用 onwheel,滚轮更丝滑,但滚动过快会白屏。 - // 曾写死 :smooth-scroll="true",导致滚轮失效(onTableWheel 首行提前 return)且白屏; - // 现在改为受控,默认 false 走防白屏路径,同时修复滚轮滚动。 - smoothScroll: false, + footerConfig: () => ({}), }, ) const emit = defineEmits<{ - (e: 'update:columns', columns: G3TableColumn[]): void - (e: 'update:selectedRowKeys', keys: Array): void - (e: 'change', row: TableRow, col: G3TableColumn, value: unknown): void - (e: 'row-order-change', rows: TableRow[], sourceIndex: number, targetIndex: number): void - (e: 'area-selection-change', ranges: unknown[]): void + (e: 'update:columns', columns: G3TableColumn[]): void + (e: 'change', row: TableRow, col: G3TableColumn, value: unknown): void /** - * 行被点击。透传库的 `row-click`(stk-table-vue 的 StkTable.js 里 emits)。 + * 行拖拽排序完成。 * - * 为什么必须暴露它:调用方要实现「点击某行 → 该行高亮」这一最普通的交互, - * 唯一手段就是 `@row-click` 里把行 key 写进 `v-model:active-row-key` 绑定的变量 - * (受控模式,与 fms-vue 的 FmsTable 用法一致)。没有这个事件, - * 调用方就只能靠表格内部副作用去猜当前行,行为不可控。 - * - * 参数用与库一致的顺序 (事件, 行, 附加信息);`row` 放在第 2 位与 fms 的 - * `@row-click="(_, row) => ..."` 写法对齐,便于迁移既有代码。 + * ⚠️ 第 2 / 3 个参数是**行 key**(底座签名 `(dragStartKey, targetRowKey)`), + * 不是索引 —— fms 里形参名写作 sourceIndex / targetIndex 属误标, + * 这里按实际语义命名,取值与 fms 完全一致。 */ - (e: 'row-click', event: MouseEvent, row: TableRow, extra: { rowIndex: number }): void + (e: 'row-order-change', rows: TableRow[], dragStartKey: string | number, targetRowKey: string | number): void }>() const { b, e, is } = useNamespace('table') @@ -241,64 +150,27 @@ const slots = useSlots() /* ---------- 性能门控 ---------- */ -/** - * 纵向虚拟滚动门控:行数不超过阈值时**关闭**。 - * - * 库的虚拟滚动在「行数 > 单屏行数」时开启,会导致行数略大于一屏 - * (如容器一屏 18 行、数据 20 行)时最后几行被虚拟化吞掉、必须滚动才可见。 - * 行数少时全量渲染没有性能问题,却换来确定性正确的渲染。 - */ +// stk-table 的虚拟滚动在「行数 > 单屏页行数(pageSize)」时开启,会导致行数略大于一屏 +// (如容器一屏 18 行、数据 20 行)时最后几行被虚拟化吞掉、需要滚动才可见。 +// 数据量小于阈值时关闭虚拟滚动,全量渲染(行少时无性能问题);超过阈值再启用。 const MIN_VIRTUAL_ROW_COUNT = 100 const effectiveVirtual = computed( () => props.virtual && props.dataSource.length > MIN_VIRTUAL_ROW_COUNT, ) -/** - * 横向虚拟滚动门控:列数不超过阈值时关闭。 - * - * 库在 `virtualX = true` 时会把每列宽度**钉死**为 minWidth ?? width ?? 100 - * (cellStyleMap 的 --cw + width/min-width/max-width 三重锁), - * 未设 width 的列会失去「按比例铺满容器」的原生自适应。 - * 列数少、总宽不超容器时用不上横向虚拟,关掉以保住原生列宽分配。 - * - * 阈值 30 的由来:20 太低 —— 带序号列的常规业务表(20 业务列 + 序号 = 21) - * 就会越过它,横虚在这些表上收益很小却带来下面那条已知缺陷。 - * 30 让常规模块表留在「横虚关闭」的原生列宽路径上。 - * - * ⚠️ 列数按 `displayColumns` 计,**内部注入列(序号 / 选择 / 拖拽)也算**。 - * 这是既有契约(有测试锁定),不要为了「只数业务列」而改 —— 那会让 - * 「30 业务列 + 序号列」这类边界在开与不开之间反复摇摆。 - */ -const MIN_VIRTUAL_X_COLUMN_COUNT = 30 - -/** - * 横向虚拟滚动门控。 - * - * ⚠️ 门控**只看列数,与固定列无关**。这是刻意的解耦:固定列是业务侧的 - * 展示选择,不该反过来决定性能策略(序号列默认开启且自带 fixed:'left', - * 若让固定列参与门控,等于所有常规表都永久关闭横虚)。 - * - * 但必须知道这条已知边界(**未修复,属上游缺陷**): - * 横虚开启时,固定列**不是靠 sticky 吸附的**,而是被重排到渲染数组两侧 —— - * 库的 virtualX_columnPart 单级表头分支: - * leftCols + tableHeaderLast.slice(startIndex, endIndex) + rightCols - * 其中 slice 的窗口随 scrollLeft 每帧重算(updateVirtualScrollX 二分查找起始列)。 - * 于是滚动时「列集合在增删、表格总宽在跳变」,固定列是拼在这个不断重建的 - * 数组两端 —— 表现为**固定列抖动、横向滚动不顺**。 - * (症状与 sticky 的 `fixed-cell--active` 翻转很像,别误判成吸附问题。) - * - * ⇒ 因此:**列数 > 30 且存在固定列时,冻结列仍会抖动**。 - * 这是当前阈值下的已知代价。若将来出现 31+ 列且必须冻结列的宽表, - * 在本组件修好之前,业务侧可用 `:virtual-x="false"` 显式兜底 - * (关掉横虚后固定列走 sticky 路径,实测不抖)。 - */ +// 横向虚拟门控:列数不超过阈值时关闭。库在 virtualX prop 为 true 时会把每列 +// 宽度钉死为 minWidth ?? width ?? 100(cellStyleMap 的 --cw + width/min-width/ +// max-width 三重锁),未设 width 的列失去「按比例铺满容器」的原生自适应。 +// 列数少、总宽不超容器的表用不上横向虚拟,关掉以恢复原生列宽分配。 +const MIN_VIRTUAL_X_COLUMN_COUNT = 20 const effectiveVirtualX = computed( () => props.virtualX && displayColumns.value.length > MIN_VIRTUAL_X_COLUMN_COUNT, ) /* ---------- 主题 ---------- */ -/** 主题:显式 prop 优先,否则跟随 documentElement(tokens 的 data-g3-theme 约定) */ +// fms 用 `useAppStore().darkMode`(业务 pinia);组件库不依赖业务 store, +// 改读 `html[data-g3-theme]`(与 ConfigProvider / 文档站同一约定)。 const documentTheme = ref<'light' | 'dark'>('light') let themeObserver: MutationObserver | null = null const tableTheme = computed(() => props.theme ?? documentTheme.value) @@ -314,191 +186,114 @@ const rootRef = ref(null) const tableRef = ref<{ initVirtualScroll?: () => void initVirtualScrollX?: () => void - getTableData?: () => TableRow[] scrollTo?: (top: number, left: number | null) => void - setCurrentRow?: (row: TableRow | undefined, options: object) => void + getTableData?: () => TableRow[] + setCurrentRow?: (row: TableRow | undefined, options?: { silent?: boolean }) => void getSelectedArea?: () => unknown clearSelectedArea?: () => void - setAreaSelection?: ( - ranges?: { begin: { row: number; col: number }; end?: { row: number; col: number } }, - options?: { silent?: boolean; scrollToView?: boolean }, - ) => unknown setRowExpand?: (...args: unknown[]) => unknown } | null>(null) -/** rAF 合并同一帧内的多次通知,避免重复重排 */ +// Tab 面板从隐藏恢复时,等到容器拥有有效尺寸再刷新虚拟滚动布局。 +// rAF 将同一帧内的多次 ResizeObserver 通知合并,避免重复重排。 let refreshRafId = 0 function refreshLayout(xOnly = false) { - if (typeof requestAnimationFrame === 'undefined') return if (refreshRafId) cancelAnimationFrame(refreshRafId) refreshRafId = requestAnimationFrame(async () => { refreshRafId = 0 await nextTick() - // 高度未变、仅宽度变化时只重算 X 方向,省掉 Y 方向重算 + // 高度未变、仅宽度变化时只重算 X 方向虚拟滚动,省掉 Y 方向重算 if (xOnly) tableRef.value?.initVirtualScrollX?.() else tableRef.value?.initVirtualScroll?.() await nextTick() - // 行区高度可能因数据/尺寸变化而改变,与重排同一批次量一次 - measureBodyFill() }) } -/** - * 数据区最后一行的底边,是否已经贴到(或越过)外框的内底边。 - * - * 只服务一个目的:决定**数据区最后一行的底边线归谁**(见 _overrides.scss 的边框一节)。 - * · 贴到/越过(true)→ 两者重合,线交给外框,否则叠成 2px; - * · 没贴到(false)→ 最后一行悬在容器中间,外框那条线在它下面够不着, - * 这一行**必须自己画**底边线,否则就是「线凭空消失」。 - * - * ⚠️⚠️ 判据必须是**这一个几何量**,不能用「表头 + 行区高度 ≥ 容器可用高」去推算。 - * 推算式看着等价,实测却在两个方向都出错(容器高度线性扫一遍,3 行 90px 数据): - * - * 容器高 | avail | 表头+行区 | 推算结果 | 实际几何 | 症状 - * -------|-------|-----------|----------|----------|------------------ - * 140 | 138 | 124 | 未铺满 | 已错开 | ok - * 128 | 126 | 124 | 未铺满 | **已贴上** | ★双线(重叠) - * 126 | 124 | 124 | 铺满 | 已贴上 | ok - * 120 | 118 | 124 | 铺满 | **已错开** | 缺线 - * - * 两处错的根因: - * 1. **表头不该参与比较**。决定「两条线是否重合」的是 tbody 底边这一个量, - * 表头再高也不会让 tbody 底边移动,把它加进 used 只是给判据掺噪声。 - * 2. **阈值容差方向反了**。容器变小时 tbody 高度**不会跟着缩** - * (实测行高恒定 30px×3=90px 始终不变),变的是它后面的剩余空间; - * 用 `>= available - 1` 这种带容差的推算去卡,128px、120px 这种 - * 「差一点点」的情形必然被卡到错误的一侧。 - * - * 直接量几何则没有这个问题:`tbody 底边 >= 外框内底边` 本身就是 - * 「线会不会撞上」的充分必要条件。容差留 1px 只为吃掉亚像素取整。 - * - * 为什么需要 JS:纯 CSS 表达不了「tbody 底边是否到达容器底部」。 - */ -const bodyFillsHeight = ref(false) -function measureBodyFill() { - const root = rootRef.value - if (!root) return - const inner = root.querySelector('.stk-table') - if (!inner) return - const tbody = root.querySelector('.stk-tbody-main') - // 没表体(空态)时不接管:底边归外框,空态节点自己那条线已由 CSS 去掉 - if (!tbody) { - if (bodyFillsHeight.value) bodyFillsHeight.value = false - return - } - // 有真实数据行才进入判定:空表时 rows.length 为 0,不该把线交给外框 - const rows = tbody.querySelectorAll('tr') - if (rows.length === 0) { - if (bodyFillsHeight.value) bodyFillsHeight.value = false - return - } - - const innerRect = inner.getBoundingClientRect() - const cs = getComputedStyle(inner) - // 外框的内底边:容器内容盒底边(扣掉自身的下边框) - const innerBottom = innerRect.bottom - (parseFloat(cs.borderBottomWidth) || 0) - - // 最后一行的底边(取最后一个**有单元格**的行,虚拟滚动下末行可能是占位空行) - let lastBottom: number | null = null - for (let i = rows.length - 1; i >= 0; i -= 1) { - const tr = rows[i] - if (tr.querySelector('td')) { - lastBottom = tr.getBoundingClientRect().bottom - break - } - } - if (lastBottom == null) { - if (bodyFillsHeight.value) bodyFillsHeight.value = false - return - } - - const fills = lastBottom >= innerBottom - 1 - if (fills !== bodyFillsHeight.value) bodyFillsHeight.value = fills -} - -/** - * 尺寸防抖(trailing)。 - * - * 折叠动画 / 窗口缩放 / 拖拽调宽期间 ResizeObserver 每帧都会通知, - * 但中间尺寸是瞬态的——逐帧 initVirtualScroll 会每帧强制 reflow 并重渲染可见行列, - * 造成动画掉帧。这里合并通知,尺寸稳定后才刷新一次,动画期间表格仅随 CSS 拉伸。 - */ +/* 尺寸防抖(trailing):折叠动画 / 窗口缩放 / 拖拽调宽等连续尺寸变化期间, + * ResizeObserver 每帧都会通知,但中间尺寸是瞬态的——逐帧 initVirtualScroll + * 会每帧强制 reflow 并重渲染可见行列,造成动画掉帧(如侧栏折叠时 dashboard + * 10 万单元格表格的卡顿)。这里合并通知,尺寸稳定(约 150ms 无变化)后才 + * 刷新一次。动画期间表格仅随 CSS 拉伸,结束后一次到位。 */ const RESIZE_DEBOUNCE_MS = 150 let resizeDebounceTimer: ReturnType | undefined +// 上次生效尺寸:用于判断是否仅宽度变化(只重算 X 方向) let lastSize: { width: number; height: number } | null = null let resizeObserver: ResizeObserver | null = null +onMounted(() => { + readDocumentTheme() + if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') { + themeObserver = new MutationObserver(readDocumentTheme) + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-g3-theme'], + }) + } + + if (typeof ResizeObserver !== 'undefined' && rootRef.value) { + resizeObserver = new ResizeObserver(([entry]) => { + const { width, height } = entry.contentRect + if (width <= 0 || height <= 0) return + clearTimeout(resizeDebounceTimer) + resizeDebounceTimer = setTimeout(() => { + resizeDebounceTimer = undefined + const xOnly = lastSize != null && lastSize.height === height && lastSize.width !== width + lastSize = { width, height } + refreshLayout(xOnly) + }, RESIZE_DEBOUNCE_MS) + }) + resizeObserver.observe(rootRef.value) + } +}) + /* ---------- 行编辑态 ---------- */ -/** - * 单行的编辑状态。 - * - * `focused` 在 `activateEditing` 里刻意不重置(fms 同此):它记录的是「重渲染前 - * 编辑器是否持有焦点」,供编辑器焦点恢复判定用,不是 UI 状态。 - */ interface RowEditingState { editing: boolean focusCol: string | null - focused: boolean - /** 编辑开始时的各可编辑列取值快照,供 shouldNotify 去重 */ initialValues: Map | null + /** 重渲染前该行编辑器是否持有焦点(blur 时置 false) */ + focused?: boolean } -/** - * 每行的编辑状态存在 WeakMap 里(key 是行对象本身)。 - * 用 WeakMap 而不是给行对象打标记:不污染业务数据,行被回收时状态自动释放。 - */ +const NAVIGATION_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab']) const rowEditingStates = new WeakMap>() let activeEditingRow: TableRow | null = null let pendingEditingTarget: { row: TableRow; focusCol: string | null } | null = null const pressedNavigationKeys = new Set() - -/** - * 是否存在活动编辑行。 - * - * **性能关键**:为 false 时可编辑列不注入 customCell,全部走库原生 td 渲染, - * 滚动时每格省掉一个函数式组件。行与行之间切换编辑**不翻转**此值,避免整表列重建。 - */ +// 是否存在活动编辑行。驱动 displayColumns 决定是否注入编辑器渲染: +// 有行在编辑时列才带 customCell(可编辑列显示编辑器);无编辑行时全部走库原生 td, +// 避免滚动时每格函数式组件重渲染。行与行之间切换编辑不翻转此值,避免整表列重建。 const editingActive = ref(false) function getRowKey(row: TableRow): string | number { - if (typeof props.rowKey === 'function') return props.rowKey(row) - const key = props.rowKey as string - return row?.[key] as string | number + return typeof props.rowKey === 'function' ? props.rowKey(row) : row?.[props.rowKey] } function getRowEditingState(row: TableRow): ShallowRef | null { if (!row || typeof row !== 'object') return null let state = rowEditingStates.get(row) if (!state) { - state = shallowRef({ - editing: false, - focusCol: null, - focused: false, - initialValues: null, - }) + state = shallowRef({ editing: false, focusCol: null, initialValues: null }) rowEditingStates.set(row, state) } return state } -/** 编辑开始时的各可编辑列取值快照,供 shouldNotify 去重(fms 的 initialValues) */ function captureEditableValues(row: TableRow): Map { return new Map( props.columns .filter((col) => col.editor) - .map((col) => [col.dataIndex, row[col.dataIndex]]), + .map((col) => [col.dataIndex as string, row[col.dataIndex as string]]), ) } -/** - * 仅清除某行的编辑态(不触碰 activeEditingRow / editingActive)。 - * 行与行之间切换编辑时使用,避免 editingActive 翻转导致整表列重建。 - */ +// 仅清除某行的编辑态(不触碰 activeEditingRow / editingActive)。 +// 行与行之间切换编辑时使用,避免 editingActive 翻转导致整表列重建。 function deactivateRowEditing(row: TableRow) { const state = rowEditingStates.get(row) if (state && state.value.editing) { - state.value = { editing: false, focusCol: null, focused: false, initialValues: null } + state.value = { editing: false, focusCol: null, initialValues: null } } } @@ -510,14 +305,11 @@ function clearActiveEditing() { // 仅在确实处于编辑态时写 ref:长按方向键滚动期间每键都会调用本函数, // 跳过无变化的写入可避免活动行单元格每键重复渲染。 if (state && state.value.editing) { - state.value = { editing: false, focusCol: null, focused: false, initialValues: null } + state.value = { editing: false, focusCol: null, initialValues: null } } } -/** - * 父级可能在 change 后回灌同 rowKey 的新对象,编辑状态需要随行对象迁移。 - * 丢了这个迁移,用户正在编辑时一旦父级刷新数据,编辑态与 focus 就会凭空消失。 - */ +// 父级可能在 change 后回灌同 rowKey 的新对象,编辑状态需要随行对象迁移。 function rebindActiveEditingRow(rows: TableRow[]) { if (!activeEditingRow || rows.includes(activeEditingRow)) return @@ -535,55 +327,18 @@ function rebindActiveEditingRow(rows: TableRow[]) { } const state = getRowEditingState(replacement) + if (!state) return activeEditingRow = replacement editingActive.value = true - if (state) { - state.value = { - editing: true, - focusCol: previousState.focusCol, - focused: previousState.focused, - initialValues: captureEditableValues(replacement), - } + state.value = { + editing: true, + focusCol: previousState.focusCol, + initialValues: captureEditableValues(replacement), } } -/** 开发模式:数据源非响应式时只警告一次(避免每次点单元格都刷屏) */ -let warnedNonReactive = false - -/** - * 校验数据源是否满足行内编辑的前提:**行对象必须可被 Vue 追踪**。 - * - * 编辑回写是直接改 row 上的字段(utils/editable.ts 的 onUpdate:modelValue), - * 若行对象是普通 JS 对象(典型来源:`computed(() => [...])` 派生出的数组), - * 这次写入不会触发任何重渲染。症状极具迷惑性: - * · `change` 事件照常抛出(数据确实改了,父级能拿到) - * · 但界面上仍显示旧值 - * · 点一下别处(触发无关重渲染)才"突然"更新 - * 实测:computed 数组里 row.pol 从 CNSHA 改成 CNNGB 后, - * G3Select 收到的 modelValue 仍是 CNSHA。 - * - * 之所以在这里检测而不是在 props 变化时检测:只有真正进入编辑才会踩到, - * 且此时行对象一定已存在。用 isReactive 判断(Vue 的响应式代理带 __v_isReactive)。 - */ -function warnIfDataSourceNotReactive(row: TableRow) { - if (!import.meta.env?.DEV || warnedNonReactive) return - if (isReactive(row) || isReactive(props.dataSource)) return - - warnedNonReactive = true - console.warn( - '[g3-ui] G3Table 行内编辑需要响应式数据源,但当前 dataSource 的行对象不是响应式代理。\n' + - ' 症状:编辑器里改了值,界面上不刷新(change 事件仍会抛出,数据其实已改),' + - '点其他位置触发重渲染后才显示新值。\n' + - ' 原因:编辑回写是直接改 row 字段,普通对象无法被 Vue 追踪。\n' + - ' 修法:用 ref() 而不是 computed() 返回普通对象数组,例如\n' + - ' const rows = ref([])\n' + - ' watch(source, (v) => { rows.value = build(v) }, { immediate: true })', - ) -} - function activateEditing(row: TableRow, focusCol: string | null) { if (!props.editable || !row) return - warnIfDataSourceNotReactive(row) const state = getRowEditingState(row) if (!state) return @@ -594,7 +349,6 @@ function activateEditing(row: TableRow, focusCol: string | null) { state.value = { editing: true, focusCol, - focused: false, initialValues: captureEditableValues(row), } pendingFocusKey = String(getRowKey(row)) @@ -608,186 +362,63 @@ function activateEditing(row: TableRow, focusCol: string | null) { pendingFocusKey = String(getRowKey(row)) } -/** - * 值比较:select / checkbox 用 Object.is,其余按字符串比(Input 落库的是 string)。 - * 用于 shouldNotify 去重——值没真变就不 emit,否则「点进去又点出来」也会触发 change, - * 业务回灌数据后整表重渲染,进而抢焦点、再失焦,表现为高亮一闪而过。 - */ -function editorValuesEqual(col: G3TableColumn, previous: unknown, current: unknown) { +function editorValuesEqual(col: G3TableColumn, previous: unknown, current: unknown) { if (col.editor?.type === 'select' || col.editor?.type === 'checkbox') { return Object.is(previous, current) } return String(previous ?? '') === String(current ?? '') } -/** - * 最近激活、等待编辑器挂载后聚焦的行 key。用于区分「用户激活单元格(点击/键盘/程序化)」 - * 与「数据变更引发的重挂载」:前者需要自动聚焦+全选,后者不应聚焦——否则失焦引发的 - * 重渲染会把焦点抢回来(表现为离开单元格时高亮一闪而过)。 - */ +// 最近激活、等待编辑器挂载后聚焦的行 key。用于区分「用户激活单元格(点击/键盘/程序化)」 +// 与「数据变更引发的重挂载」:前者需要自动聚焦+全选,后者不应聚焦——否则失焦引发的 +// 重渲染会把焦点抢回来(表现为离开单元格时高亮一闪而过)。 let pendingFocusKey: string | null = null -/** - * 「不可改的格子」标记要不要生效 —— 也就是说,**这张表里到底有没有人能编辑**。 - * - * 灰底的语义是「这一格现在改不了」,它只有在**旁边存在可改的格子**时才是一条信息。 - * 一张纯展示表(列上都没有 `editor`)里每个格子都改不了,若照旧逐格铺灰, - * 整张表看上去就是「禁用状态」—— 文档里 6/7 张 demo 正是这种纯展示表, - * 用户看到的就是「demo 一片灰、像是被禁用了」。 - * `editable === false` 同理:编辑功能整个关掉时,「不能改」不再指向任何东西。 - * - * ⚠️ 判据取「存在**真的可编辑**的列」:`editor.disabled === true` 的列虽然声明了 - * `editor`,但它自己也改不了 —— 一张表如果只由这种列组成,仍然没有任何格子可改。 - * `disabled` 写成**函数**的列算「可编辑」(总有行能改,且灰底正是要表达它)。 - */ -const editableColumnsExist = computed(() => - props.columns.some((col) => col.editor && col.editor.disabled !== true), -) - -/** 见 editableColumnsExist:灰底(以及内容节点上的 `cell-readonly` 标记)的总开关 */ -const shouldMarkReadonly = () => props.editable && editableColumnsExist.value - -const editingCtx: EditableContext = { +const editingCtx: EditableContext = { isEditing: (row) => props.editable && Boolean(getRowEditingState(row)?.value.editing), shouldFocus: (row, col) => getRowEditingState(row)?.value.focusCol === col.dataIndex, + // 焦点恢复判定依据:重渲染前该行编辑器是否持有焦点(blur 时已置 false) wasFocused: (row) => Boolean(getRowEditingState(row)?.value.focused), consumePendingFocus: (row) => { if (pendingFocusKey == null) return false if (pendingFocusKey !== String(getRowKey(row))) return false pendingFocusKey = null - // 目标格的编辑器即将接管焦点 → 上次 Tab 跳格已落地,body 上的 Tab 不再算我们的 - tabJumpPendingUntil = 0 return true }, - /** - * Esc:退出编辑态回到就绪态。 - * - * 顺带把焦点**还给单元格**:Esc 之后用户往往还想用方向键接着走(Excel 同此)。 - * 不还的话焦点会掉到 `body` —— 下一次按键压根不经过表格,表现就是「Esc 之后 - * 方向键失灵、连打字也进不了编辑」。用 `td[data-cs-s]`(库标在选中格上的属性) - * 找回那一格,它带 `tabindex="-1"`,可以编程聚焦。 - */ - exitEditing: () => { - clearActiveEditing() - nextTick(() => { - rootRef.value?.querySelector('td[data-cs-s]')?.focus?.({ preventScroll: true }) - }) - }, onEditorFocus: (row) => { const state = getRowEditingState(row) - if (state?.value) state.value = { ...state.value, focused: true } + if (state) state.value = { ...state.value, focused: true } }, onEditorBlur: (row) => { const state = getRowEditingState(row) - if (state?.value) state.value = { ...state.value, focused: false } + if (state) state.value = { ...state.value, focused: false } }, - isSeamless: () => props.editorSeamless, - shouldMarkReadonly, shouldNotify: (row, col, newValue) => { const values = getRowEditingState(row)?.value.initialValues - if (!values?.has(col.dataIndex)) return true - if (editorValuesEqual(col, values.get(col.dataIndex), newValue)) return false - values.set(col.dataIndex, newValue) + const key = col.dataIndex as string + if (!values?.has(key)) return true + if (editorValuesEqual(col, values.get(key), newValue)) return false + values.set(key, newValue) return true }, - onChange: (row, col, value) => { - emit('change', row, col, value) - }, - /** - * checkbox 被操作后,把这一格同步成**当前格** —— 与点击其它单元格行为一致。 - * - * 为什么只有 checkbox 需要:它把 mousedown 停掉了(换「一次点击就切换」,见 editable.ts - * 里 createCheckbox 的说明),底座因此收不到 mousedown,就不会像点击其它格那样 - * 设选区、激活该行编辑、置当前行。不补这一下的实测后果:数据已改、`change` 已抛, - * 但整行不高亮、格子没有选中框;且表格里**没有任何选区** —— 接着按 Tab / 方向键会从 - * (0,0) 起步、焦点掉到 `body`,键盘用户从复选框出发就走不动了。 - * - * 实现刻意**走选区**而不是直接 activateEditing:鼠标点击(handleAreaSelectionChange) - * 与键盘跳格已经是同一条链路,这里接进同一条,避免出现第三套行为。 - * `scrollToView: false`:复选框本来就在眼前,不该动滚动位置。 - */ - onEditorInteract: (row, col) => { - if (!props.editable) return - const rows = (tableRef.value?.getTableData?.() ?? props.dataSource) as TableRow[] - const rowIndex = rows.findIndex( - (item) => item === row || String(getRowKey(item)) === String(getRowKey(row)), - ) - const colIndex = displayColumns.value.findIndex((item) => item.dataIndex === col.dataIndex) - if (rowIndex < 0 || colIndex < 0) return - tableRef.value?.setAreaSelection?.( - { begin: { row: rowIndex, col: colIndex } }, - { scrollToView: false }, - ) - }, - /** - * Enter 确认后往下走一格 —— 移到**下一行的同一列**(跳过不可编辑的行)。 - * - * 与 Tab 跳格同一种做法:**改选区**而不是直接 activateEditing,让底座的 - * `area-selection-change` 链路去负责激活编辑、定位并聚焦编辑器、滚动进视口。 - * 鼠标点击 / Tab / Enter 三条路径因此共用同一条链路,不会各写一套而行为分叉。 - * - * `scrollToView` 保持默认(要滚):回车是「往下一行走」,目标可能已经在视口外, - * 这与复选框那条「本来就在眼前」的场景不同。 - */ - moveToCellBelow: (row, col) => { - if (!props.editable) return false - const rows = (tableRef.value?.getTableData?.() ?? props.dataSource) as TableRow[] - const rowIndex = rows.findIndex( - (item) => item === row || String(getRowKey(item)) === String(getRowKey(row)), - ) - const colIndex = displayColumns.value.findIndex((item) => item.dataIndex === col.dataIndex) - if (rowIndex < 0 || colIndex < 0) return false - - const target = findCellBelow(rows, displayColumns.value, { rowIndex, colIndex }) - // 表尾 / 下面全是不可编辑行:返回 false,编辑器原地不动(不循环回表头) - if (!target) return false - - /** - * 先改选区(让库把「滚动进视口、当前行、命中测试」都处理好),**再显式进编辑**。 - * - * 为什么不能只靠选区那一步:就绪态模型下 `handleAreaSelectionChange` 对鼠标 / 方向键 - * 只负责「选中」,不再激活编辑。而 Enter 的语义是「确认,并去下一行**继续改**」—— - * 它是唯一一个「移动 + 进编辑」的键,所以这一步得自己补上 - * (方向键是纯移动,Tab 由它自己的链路处理)。 - */ - tableRef.value?.setAreaSelection?.({ begin: { row: target.rowIndex, col: target.colIndex } }) - const nextRow = rows[target.rowIndex] - const nextCol = displayColumns.value[target.colIndex] - if (nextRow && nextCol?.editor) { - activateEditing(nextRow, nextCol.dataIndex) - tableRef.value?.setCurrentRow?.(nextRow, { silent: true }) - } - return true - }, - /** - * Esc 回滚:把该行所有可编辑列恢复到进入编辑态时的值。 - * - * 快照就是 initialValues(activateEditing 时用 captureEditableValues 存的), - * 与 shouldNotify 去重共用同一份 Map,所以这里**只回滚数据、不改快照**: - * 快照代表的就是「编辑前的原值」,回滚到原值后二者自然一致, - * 之后用户再编辑时去重逻辑仍然正确。 - * - * 只做数据回滚,不负责退出编辑态(调用方随后 blur 即可)。 - * 返回是否发生了实际变化。 - */ - revertRow: (row) => { - const state = getRowEditingState(row) - const values = state?.value.initialValues - if (!values) return false - - let changed = false - for (const [dataIndex, original] of values) { - if (Object.is(row[dataIndex], original)) continue - ;(row as TableRow)[dataIndex] = original - changed = true - } - return changed + onChange: (row, col, newValue) => { + emit('change', row, col, newValue) }, } /* ---------- 列 ---------- */ -const seqConfig = computed(() => resolveSeqConfig(props.seq)) +// 由 seq 派生:enabled / title / width +const seqConfig = computed(() => { + const enabled = !!props.seq + if (!enabled) return { enabled: false, title: '序号', width: 50 } + const cfg: { title?: string; width?: number } = props.seq === true ? {} : props.seq + return { + enabled: true, + title: cfg.title ?? '序号', + width: Number(cfg.width ?? 50), + } +}) const seqWidth = ref(seqConfig.value.width) watch( () => seqConfig.value.width, @@ -796,82 +427,29 @@ watch( }, { immediate: true }, ) -const selectionWidth = ref(44) -const dragRowWidth = ref(DRAG_ROW_COLUMN.width) - -const hasRowSelection = computed(() => Boolean(props.rowSelection)) - -const selectionColumn = createSelectionColumn({ - getDataSource: () => props.dataSource, - getRowKey, - getSelectedRowKeys: () => props.rowSelection?.selectedRowKeys ?? [], - isRowDisabled: (row) => Boolean(props.rowSelection?.getCheckboxProps?.(row)?.disabled), - onChange: (keys, rows) => { - props.rowSelection?.onChange?.(keys, rows) - emit('update:selectedRowKeys', keys) - }, -}) /** - * 列宽拖拽 / 列序拖拽都要求父级绑定 v-model:columns,否则拖了无处回写(松手回弹),直接关掉。 + * 列宽 / 列序拖拽都要求父级绑定 `v-model:columns`,否则拖了无处回写(松手回弹),直接关掉。 * - * ⚠️ 与 `activeRowKeyBound` 同一个坑:`vnode.props` 里是**父级模板的原始写法**。 - * 好在 `v-model:columns` 的编译产物固定是 `onUpdate:columns`(含冒号、驼峰), - * 不存在 kebab 变体,所以这里只判断一种写法就够了。 - * 但**不能**因此掉以轻心 —— 凡是要在 vnode.props 里找 prop, - * 都得先想清楚「父级可能怎么写」,activeRowKey 那次就是因为只认驼峰 - * 而让 `:active-row-key` 这种官方推荐写法完全失效(详见其注释)。 + * 声明了 update:columns emit 后监听器不会进 attrs,需从 vnode.props 探测; + * 绑定关系在模板编写时确定,运行期不会变化,无需响应式。 */ const columnsBound = 'onUpdate:columns' in (getCurrentInstance()?.vnode.props ?? {}) -/** - * 内部列宽表:`dataIndex -> 用户拖拽后的宽度`。 - * - * 为什么需要它:库的列宽拖拽必须「拖完有地方落库」,否则松手回弹。 - * 原实现要求调用方绑 `v-model:columns`,没绑就直接把拖拽整个关掉 - * (`if (!columnsBound …) return false`)。 - * - * 但列宽拖拽是表格的**基础能力**,不该以「父级有没有写 v-model」为前提 —— - * 不写就用不了,对使用者是个莫名其妙的门槛。ERP 的 /ocean/booking 正是 - * 没绑 v-model,于是「拖不动列宽」,而调用方并不知道原因。 - * - * 现在改成:**无论父级有没有绑,组件内部都能自己记住宽度并生效**。 - * · 绑了:额外把结果 emit 出去,父级照旧可持久化(对既有用法无影响); - * · 没绑:只更新内部表,列宽在本组件生命周期内保持,拖拽完全可用。 - * - * 用 `dataIndex` 而非索引做 key:列可能被拖拽换序或被父级重排, - * 索引会错位,dataIndex 稳定。 - */ -// 注:曾有一个 columnWidths(内部自存列宽,让不绑 v-model 也能拖)的方案, -// 实测无效已移除 —— 原因见 effectiveColResizable 里的说明。 - +// 列宽拖拽开关:库只提供「全局开关 + disabled(col) 回调」这一种按列控制方式, +// 没有按列的 resizable,所以开启时统一转成回调形式,顺带落实列上显式声明的 +// resizable: false —— 操作列 / 展开列的宽度由代码算(拖了不落库、列重建即丢)。 +// 必须按列禁掉操作列:它 fixed: 'right' 且左边缘压在最后一列上,库对右侧固定列的 +// 「左侧把手」拖的是这一列自己,用户拖那根分割线就会改到操作列宽度(拖左列、动操作列)。 const effectiveColResizable = (() => { - /** - * 未绑 `v-model:columns` 时关闭列宽拖拽 —— 这一点与 fms-vue 的 FmsTable 完全一致 - * (其 `if (!columnsBound || props.autoFitWidth || !props.colResizable) return false`)。 - * - * ⚠️ 曾经试着「去掉这个门控 + 内部自存宽度」,让不绑 v-model 也能拖。 - * 实测**失败**:库在 onThResizeMouseUp 里把宽度写进它自己的列对象 - * (`curCol.width = w + 'px'`,curCol 取自 tableHeaderLast 的按 key 查找), - * 若调用方没有接住 update:columns 并回传新列,这次拖拽就在库内部被丢弃 —— - * 手柄能拖动、指示器会跟着走,但松手后 --cw 毫无变化(实测 150px 不变)。 - * 库的源码路径:`const curCol = tableHeaderLast.value.find(...); if (curCol) { curCol.width = ...; emits("update:columns") }`, - * 也就是「没有回写目标 = 拖拽结果被静默丢弃」。 - * - * 所以正确做法不是绕过门控,而是**要求调用方绑 v-model:columns** - * (列宽的真源归调用方)。这样:「拖不动」这件事发生时有明确的原因, - * 而不是「能拖但拖完弹回去」这种更难查的表现。 - * ERP 的 /ocean/booking 此前正是没绑,现在已按此约定补上。 - */ if (!columnsBound || props.autoFitWidth || !props.colResizable) return false - const configured: { disabled?: boolean | ((col: G3TableColumn) => boolean) } = - props.colResizable === true ? { } : props.colResizable + const configured: { disabled?: boolean | ((col: G3TableColumn) => boolean) } = + props.colResizable === true ? {} : props.colResizable return { - // 入参用库自己的列类型:库的 props 泛型是 any,若这里收窄成 G3TableColumn, + // 入参用库自己的列类型:库的 props 泛型是 any,若这里收窄成 G3TableColumn, // 函数参数逆变会判定不兼容(库可能传任意列进来)。收宽再在内部窄化是唯一正解。 - disabled: (column: StkTableColumn) => { - const col = column as G3TableColumn - // 操作列 / 展开列宽度由代码算,拖了不落库、列重建即丢,一律禁止 + disabled: (column: StkTableColumn) => { + const col = column as G3TableColumn if (col.resizable === false) return true return typeof configured.disabled === 'function' ? Boolean(configured.disabled(col)) @@ -884,60 +462,51 @@ const columnDragConfig = computed(() => { if (!columnsBound || !props.columnDraggable) return false const configured: { mode?: 'none' | 'insert' | 'swap' - disabled?: boolean | ((col: G3TableColumn) => boolean) - } = props.columnDraggable === true ? { } : props.columnDraggable + disabled?: boolean | ((col: G3TableColumn) => boolean) + } = props.columnDraggable === true ? {} : props.columnDraggable return { ...configured, - disabled: (column: StkTableColumn) => { - const col = column as G3TableColumn + disabled: (column: StkTableColumn) => { + const col = column as G3TableColumn if ( isSelectionColumn(col) || col.type === 'seq' || - col.type === DRAG_ROW_COLUMN_TYPE || + col.type === 'dragRow' || col.type === 'expand' || col.dataIndex === '' || col.dataIndex === '__actions__' ) { return true } - return typeof configured.disabled === 'function' - ? configured.disabled(col) - : Boolean(configured.disabled) + if (typeof configured.disabled === 'function') return configured.disabled(col) + return Boolean(configured.disabled) }, } }) -const displayColumns = computed[]>(() => { - const cols = props.columns.map((col) => { - const resolved = resolveCustomCell(col, editingCtx, editingActive.value) - /** - * 整列不可改(没有 `editor` / `editor.disabled === true`)→ 往列自带的 - * `className` 上补一个标记类,表格侧据此**常驻**灰底。 - * - * 为什么用 className:库渲染 td 时 class 只取 `col.className`(整列常量), - * 这是**零运行时开销**的路子 —— 不必为这些列注入 customCell, - * 否则每个可见格都会多一个函数式组件实例(滚动时按格计费)。 - * `className` 是调用方可见的公开字段,所以是**追加**而不是覆盖。 - * - * 按行禁用(`disabled` 是函数)没法这样表达,走内容节点标记: - * 见 utils/editable.ts 的 displayValue 与 utils/column.ts 的 hasRowDisabled。 - * - * ⚠️ 只处理 `props.columns`:内部注入的序号 / 选择 / 拖拽列在这之后才 unshift, - * 不能给它们染灰(序号列本来就没有编辑器,会被误判成只读列)。 - * - * ⚠️ 前置条件 `shouldMarkReadonly()`:纯展示表(没有任何可编辑列)/ `editable: false` - * 时**不标**。否则整表每格都会铺灰、看上去像禁用状态(详见那个函数的注释)。 - */ - if (shouldMarkReadonly() && (!resolved.editor || resolved.editor.disabled === true)) { - return { - ...resolved, - // ⚠️ JS 侧的 e() 是 e(element, block?),block 默认就是本组件的 `table` - // —— 与 SCSS 里的 e(block, element) **参数顺序相反**,别写混。 - className: [col.className, e('cell-readonly')].filter(Boolean).join(' '), - } - } - return resolved - }) +const hasRowSelection = computed(() => Boolean(props.rowSelection)) +const selectionWidth = ref(44) +const dragRowColumn = { + type: 'dragRow', + dataIndex: '__drag_row__', + title: '', + width: 40, +} +const dragRowWidth = ref(dragRowColumn.width) + +function getHeaderAlign(column: G3TableColumn): 'left' | 'center' | 'right' { + if (column.headerAlign) return column.headerAlign as 'left' | 'center' | 'right' + if (column.type === 'seq') return 'center' + if (isSelectionColumn(column)) return 'center' + if (column.dataIndex === 'actions') return 'center' + if (column.actions && column.actions.length) return 'center' + if ((column as { checkbox?: boolean }).checkbox === true) return 'center' + if (column.editor && column.editor.type === 'checkbox') return 'center' + return 'left' +} + +const displayColumns = computed(() => { + const cols = props.columns.map((col) => resolveCustomCell(col, editingCtx, editingActive.value)) const seq = seqConfig.value if (seq.enabled) { cols.unshift({ @@ -945,147 +514,96 @@ const displayColumns = computed[]>(() => { width: seqWidth.value, dataIndex: '', title: seq.title, - /** - * 序号列**固定左侧**,与选择列(selection.ts 里同样是 `fixed: 'left'`)一致。 - * - * 理由:序号是「这一行是哪一行」的锚点,横向滚动时恰恰是最不该丢的信息 —— - * 一旦它跟着滚走,用户看到的是「一屏数据但不知道自己在第几行」。 - * 而它又在最左侧,冻结它不需要额外代价(sticky 列本来就要付的绘制成本)。 - * - * 宽度是必须的:库计算左固定列的吸附位移时,用的是**前一个左固定列的宽度累加** - * (stk-table-vue 的 useGetFixedColPosition),所以调用方的业务左固定列会自动 - * 从 `seqWidth` 之后开始吸附;这里不写宽度,调用方的左冻结列就会与序号列重叠。 - * - * 不设开关:与选择列保持一致(那儿也没有开关)。真需要序号随内容滚动的场景 - * 目前不存在;等第二个真实使用方出现再加 `seq: { fixed: false }` 这类口子, - * 而不是现在就为想象中的需求留参数。 - */ - fixed: 'left', - } as unknown as G3TableColumn) + } as G3TableColumn) } if (props.rowDraggable) { - cols.unshift({ - ...DRAG_ROW_COLUMN, - width: dragRowWidth.value, - } as unknown as G3TableColumn) + cols.unshift({ ...dragRowColumn, width: dragRowWidth.value } as G3TableColumn) } if (hasRowSelection.value) { - cols.unshift({ - ...selectionColumn, - width: selectionWidth.value, - } as G3TableColumn) + cols.unshift({ ...selectionColumn, width: selectionWidth.value }) } - /** - * 只展开 headerAlign,**不再叠加任何内部记录的列宽**。 - * - * 列宽的真源是调用方(经 v-model:columns 回传的新列定义); - * 内部再存一份会两边打架:父级把列宽重置回原值后,内部记录却还留着旧宽度, - * 表现为「重置无效」或「拖完又弹回去」。 - * 而且实测证明「内部自存」根本救不了「未绑 v-model」的场景 —— 库会把 - * 拖拽结果写进它自己的列对象、然后直接丢弃(详见 effectiveColResizable)。 - */ return cols.map((column) => ({ ...column, headerAlign: getHeaderAlign(column), })) }) -/** 库回写列:同步内部列宽度,剥离内部列后回传业务列 */ +// 剥离内部列(选择列、序号列、拖拽列)后回传,避免污染父组件 columns // 入参用库的类型(props 泛型是 any),内部再窄化——与 colResizable 同因 -function onColumnsUpdate(rawColumns: StkTableColumn[]) { - const columns = rawColumns as G3TableColumn[] - const widths = readInternalWidths(columns) - if (widths.selection != null) selectionWidth.value = widths.selection - if (widths.seq != null) seqWidth.value = widths.seq - if (widths.dragRow != null) dragRowWidth.value = widths.dragRow - - /** - * 业务列的宽度**原样回传给调用方**,组件内部不保留副本。 - * - * 列宽的真源只有一个 —— 调用方 `v-model:columns` 绑定的那份列定义。 - * 这里(以及 displayColumns)都不该再存一份: - * 两个真源必然不同步,表现为「父级重置列宽却弹回旧值」。 - * - * 这也解释了为什么 `effectiveColResizable` 必须在未绑 v-model 时关闭拖拽: - * 没有真源可回写时,库的拖拽结果会被它自己丢弃(详见那里的注释)。 - */ - emit('update:columns', stripInternalColumns(columns, props.columns)) +function onColumnsUpdate(rawColumns: StkTableColumn[]) { + const columns = rawColumns as G3TableColumn[] + for (const column of columns || []) { + if (isSelectionColumn(column) && column.width != null) { + selectionWidth.value = Number.parseInt(String(column.width), 10) || selectionWidth.value + } else if (column.type === 'seq' && column.width != null) { + seqWidth.value = Number.parseInt(String(column.width), 10) || seqWidth.value + } else if (column.type === 'dragRow' && column.width != null) { + dragRowWidth.value = Number.parseInt(String(column.width), 10) || dragRowWidth.value + } + } + const originalByKey = new Map( + props.columns.map((col) => [String(col.key ?? col.dataIndex ?? ''), col]), + ) + const real = (columns || []) + .filter( + (c) => + !isSelectionColumn(c) && c.type !== 'seq' && c.type !== 'dragRow' && c.dataIndex !== '', + ) + .map((col) => { + const original = originalByKey.get(String(col.key ?? col.dataIndex ?? '')) + return original ? { ...original, width: col.width ?? original.width } : col + }) + emit('update:columns', real) } /** - * 行拖拽结束。 - * - * 库回传的是**行 key**(dragStartKey / targetRowKey),不是索引; - * 这里换算成索引再抛给业务——索引更通用,业务不必知道 rowKey 怎么取值。 + * 行拖拽结束:透传底座回传的行 key 与排序后的完整数据。 + * (底座签名是 `(dragStartKey, targetRowKey)`,回传 key 而非索引。) */ function handleRowOrderChange(dragStartKey: string, targetRowKey: string) { const rows = tableRef.value?.getTableData?.() ?? props.dataSource tableRef.value?.clearSelectedArea?.() - const sourceIndex = rows.findIndex((row) => String(getRowKey(row)) === String(dragStartKey)) - const targetIndex = rows.findIndex((row) => String(getRowKey(row)) === String(targetRowKey)) - emit('row-order-change', [...rows], sourceIndex, targetIndex) + emit('row-order-change', [...rows], dragStartKey, targetRowKey) } -/** - * 行点击:原样透传给调用方。 - * - * 刻意**不做**任何内部副作用(不在这里 setCurrentRow、不激活编辑)。 - * 原因是对照 fms-vue 的 FmsTable 后发现的分工: - * fms 的「点击行 → 高亮」是由**调用方**通过 - * :active-row-key="activeKey" - * @row-click="(_, row) => (activeKey = String(row._rowKey))" - * 两个绑定合起来实现的,表格只负责抛出事件。 - * - * 这种分工比「表格内部偷偷设置当前行」更好,有两个具体理由: - * 1. **单一真源**:当前行只在 activeRowKey 一个地方表示, - * 内部再设一次就有了两个写入者,二者会互相覆盖 - * (我们此前踩过的竞态正是这个:点击路径设、watcher 清, - * 谁后执行谁赢,表现为「有概率不高亮」)。 - * 2. **调用方可控**:业务可能需要「点击只选中但不编辑」, - * 或对某些行禁止高亮;事件交给调用方才有这个自由度。 - * - * 编辑激活仍由内部处理(那是表格的职责),但**当前行不再由内部写**—— - * 需要高亮的调用方按 fms 那样绑 activeRowKey 即可。 - */ -function onRowClick(event: MouseEvent, row: TableRow, extra: { rowIndex: number }) { - emit('row-click', event, row, extra) +// 行高/表头行高通过 prop 传给 StkTable:库内部会将其注入为滚动容器上的 +// --row-height / --header-row-height / --footer-row-height,并参与虚拟滚动计算, +// 此处只补充库不管理的表头/表尾背景色。 +const customVars = { + '--th-bgc': 'var(--g3-fill, rgba(0, 0, 0, 0.02))', + '--tf-bgc': 'var(--g3-bg-container, #fff)', } -/* ---------- 对外方法 ---------- */ +// 选择列:已选状态管理 + 渲染逻辑全部内聚在 createSelectionColumn 中 +const selectionColumn = createSelectionColumn({ + getDataSource: () => props.dataSource, + getRowKey, + getSelectedRowKeys: () => props.rowSelection?.selectedRowKeys ?? [], + isRowDisabled: (row) => Boolean(props.rowSelection?.getCheckboxProps?.(row)?.disabled), + onChange: (keys, rows) => props.rowSelection?.onChange?.(keys, rows), +}) +// 滚动到指定行(新增后聚焦) async function scrollToRow(rowKey: string | number) { await nextTick() + const table = tableRef.value + if (!table) return const idx = props.dataSource.findIndex((row) => String(getRowKey(row)) === String(rowKey)) if (idx < 0) return - tableRef.value?.scrollTo?.(idx * props.rowHeight, null) + table.scrollTo?.(idx * props.rowHeight, null) } +// 新增行等程序化场景使用:滚动到目标行,并让该行立即进入编辑状态。 +// activeRowKey 仅控制当前行高亮,不应隐式触发编辑,因此单独暴露此方法。 async function editRow(rowKey: string | number, focusCol: string | null = null) { await scrollToRow(rowKey) await nextTick() const row = props.dataSource.find((item) => String(getRowKey(item)) === String(rowKey)) if (!row) return - /** - * 这里**不**加 `activeRowKeyBound` 判断,与内部副作用路径刻意区分: - * - * · `editRow` 是调用方**显式发起**的命令式 API —— 「编辑这一行」 - * 本身就蕴含「把这一行设为当前行」,此时设置高亮是请求的一部分, - * 不是偷偷加的副作用。若在这里也看 activeRowKey 是否绑定, - * 受控调用方调 editRow 后还得自己再设一次当前行,反而别扭。 - * · 受控调用方若确实不想要高亮,它可以在 @row-click / 自己的逻辑里 - * 不写 activeKey;但一旦调 editRow,语义上就是在指定「当前操作的行」。 - * - * 判断标准是「谁发起」:外部命令式 API 可以设;内部对交互的**隐式**响应 - * 则要克制(见 handleAreaSelectionChange / commitPendingEditing 里的判断)。 - */ tableRef.value?.setCurrentRow?.(row, { silent: true }) activateEditing(row, focusCol) } -function initVirtualScroll() { - tableRef.value?.initVirtualScroll?.() -} - function getSelectedArea() { return tableRef.value?.getSelectedArea?.() } @@ -1098,9 +616,13 @@ function setRowExpand(...args: unknown[]) { return tableRef.value?.setRowExpand?.(...args) } +function initVirtualScroll() { + return tableRef.value?.initVirtualScroll?.() +} + + /* ---------- 键盘导航:编辑器激活防抖 ---------- */ -const NAVIGATION_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab']) let editActivateTimer: ReturnType | null = null let lastEditActivateAt = 0 let isRepeatNavigation = false @@ -1112,10 +634,8 @@ function clearEditActivateTimer() { } } -/** - * 键盘导航停止(松开按键或停顿)后再激活编辑器,合并连续/按住按键, - * 避免滚动过程中每帧创建/销毁编辑器组件导致掉帧。 - */ +// 键盘导航停止(松开按键或停顿)后再激活编辑器,合并连续/按住按键, +// 避免滚动过程中每帧创建/销毁编辑器组件导致掉帧。 function scheduleEditActivation() { clearEditActivateTimer() editActivateTimer = setTimeout(() => { @@ -1133,8 +653,6 @@ function commitPendingEditing() { ) if (row) { activateEditing(row, target.focusCol) - // 键盘防抖路径的收尾:与另外两处一致,当前行无条件同步 - // (键盘不产生 click,调用方的 @row-click 收不到消息,见 handleAreaSelectionChange)。 tableRef.value?.setCurrentRow?.(row, { silent: true }) lastEditActivateAt = performance.now() } @@ -1152,7 +670,7 @@ function handleKeydownCapture(event: KeyboardEvent) { window.addEventListener('blur', handleWindowBlur) } pressedNavigationKeys.add(event.key) - // event.repeat=true 表示系统自动重复(按住方向键不松),用于区分"按住滚动"与"离散点按" + // event.repeat=true 表示系统自动重复(按住方向键不松),用于区分“按住滚动”与“离散点按” if (event.repeat) isRepeatNavigation = true } @@ -1182,15 +700,7 @@ function handleWindowBlur() { commitPendingEditing() } -/** - * 选区变化后,目标行进入编辑,活动单元格获得焦点。 - * - * 三档策略(照搬 fms): - * - 按住方向键滚动(event.repeat):全程保持 0 编辑器,避免长按下反复创建组件导致卡顿/闪烁; - * 松手后再激活到最终行。 - * - 慢速离散点按(非 repeat):立即激活,编辑器逐行跟随、跟手。 - * - 快速连按(间隔 <150ms):防抖合并,避免连按时每键挂载掉帧。 - */ +// 选区变化后,目标行进入编辑,活动单元格获得焦点。 function handleAreaSelectionChange(ranges: unknown[]) { if (!props.editable) return @@ -1205,7 +715,12 @@ function handleAreaSelectionChange(ranges: unknown[]) { return } - const focusCol = col?.editor ? col.dataIndex : null + const focusCol = col?.editor ? (col.dataIndex as string) : null + // 键盘导航期间(按下方向键等): + // - 按住方向键滚动(event.repeat=true):全程保持 0 编辑器,避免长按下反复创建组件导致卡顿/闪烁; + // 松手后再激活到最终行。 + // - 慢速离散点按(非 repeat):立即激活,编辑器逐行跟随、跟手; + // - 快速连按(间隔 <150ms):防抖合并,避免连按时每键挂载掉帧。 if (pressedNavigationKeys.size > 0) { if (isRepeatNavigation) { pendingEditingTarget = { row, focusCol } @@ -1216,9 +731,6 @@ function handleAreaSelectionChange(ranges: unknown[]) { pendingEditingTarget = null clearEditActivateTimer() activateEditing(row, focusCol) - // 与鼠标路径同源:当前行**无条件**同步。 - // 键盘导航同样不产生 click,调用方的 @row-click 一样等不到消息 - // (两处判据必须一致,否则会变成「键盘改了当前行、鼠标不改」的新不一致)。 tableRef.value?.setCurrentRow?.(row, { silent: true }) lastEditActivateAt = now } else { @@ -1229,544 +741,27 @@ function handleAreaSelectionChange(ranges: unknown[]) { return } - /** - * 鼠标点击:**立即进入编辑**(本组件的既有交互:点一格就能直接打字,少一次点击)。 - * - * 编辑器挂上并自动聚焦;同行切列时重挂载并全选(直接输入即替换整格内容); - * 点击已聚焦的编辑器内部则由原生行为定位光标。 - * - * 方向键的分工不在这一层,而在编辑器自己的 `onKeydown`: - * **左右归输入框移光标、上下让给表格导航**(见 editable.ts 的 createInput)。 - */ + // 非键盘导航(鼠标点击等):立即激活。同行切换列时编辑器重挂载并恢复焦点+全选 + // (直接输入即替换整格内容);点击已聚焦编辑器内部则由原生行为定位光标。 pendingEditingTarget = null clearEditActivateTimer() activateEditing(row, focusCol) - /** - * 当前行**无条件**同步。 - * - * 这里的判据曾经是「只有调用方没绑 `activeRowKey` 时才写」,结果是: - * **受控调用方(fms 那套 `:active-row-key` + `@row-click`)点格进入编辑时永远不高亮**。 - * - * 原因是浏览器行为,不是我们的状态管理: - * 点可编辑格 → 编辑器挂载 → 单元格内容从 `.cell-display` 那个 span 换成 `G3Input`, - * **mousedown 命中的节点在 mouseup 之前就被移除了**。Chromium 在这种情况下 - * **根本不派发 click 事件**(实测:document 捕获阶段连一条 click 都没有, - * 而点只读格/已挂编辑器的格时 click 正常)。库的 `row-click` 由 click 驱动, - * 于是永远不触发,调用方那个「唯一写入者」永远等不到消息。 - * - * 换句话说:对「点格进编辑」这个手势,**本方法是唯一能知道发生了什么的代码**, - * 内部这次写入不是「第二个写入者」,而是这种情况下**唯一的写入者** —— - * 受控调用方那套绑定在这里拿不到任何事件,谈不上竞争。 - * 真正踩过的竞态是「watcher 把当前行清掉」(已由 `activeRowKeyBound` 判断挡掉), - * 与这里无关。 - * - * 若调用方的 `@row-click` 也写了一次(点只读格、或点已挂编辑器的格), - * 两次写的是**同一行**,幂等,不会互相覆盖。 - * - * `silent: true` —— 只更新高亮,不触发库的选中事件(不干扰业务)。 - */ - tableRef.value?.setCurrentRow?.(row, { silent: true }) lastEditActivateAt = performance.now() - emit('area-selection-change', ranges) } -/* ---------- Tab / Shift+Tab:跳格 ---------- */ - -/** - * select 下拉浮层的容器类名。 - * - * 写 G3Select 的内部类名属于**跨组件耦合**,这里是有意接受的:浮层被 Teleport 到了 - * body(见下),表格没有别的手段认出「这个浮层属于我」。归属判定本身不靠类名, - * 而是靠 ARIA 关联(浮层 id ↔ 触发器 aria-controls),类名只用来找浮层这个容器。 - */ -const SELECT_DROPDOWN_CLASS = 'g3-select__dropdown' - -/** - * 上一次 Tab 跳格发出后,等新编辑器接管焦点的宽限时间(毫秒)。 - * - * 跳格是「改选区 → 激活编辑 → 重挂编辑器 → 新编辑器聚焦」这条异步链路, - * 中间有一小段时间**焦点没有任何落点**(旧编辑器已卸载、新的还没聚焦), - * 此时 `document.activeElement` 是 `body`。这段窗口里再按 Tab,事件源既不在 - * 编辑器里、也不在浮层里 —— 若就此判定「不是给我的」放行,浏览器会把焦点 - * 按文档顺序送到表格外,表现出来就是「Tab 按快一点就跳出表格」。 - * - * 所以记一个截止时间:窗口内即使焦点在 body 上也认这记 Tab(起点取选区,见下)。 - * 取值要覆盖「底座的连按防抖(100ms)+ 一帧渲染」,又不至于久到把用户 - * 点走之后的 Tab 也吞掉;200ms 是防抖窗口的两倍。 - */ -const TAB_JUMP_GRACE_MS = 200 -let tabJumpPendingUntil = 0 - -/** 起点:选区终点(库在每次跳格后**同步**更新它) */ -function readSelectionEnd(): { rowIndex: number; colIndex: number } | null { - const area = tableRef.value?.getSelectedArea?.() as - | { ranges?: Array<{ index?: { end?: { row?: unknown; col?: unknown } } }> } - | undefined - const end = area?.ranges?.at(-1)?.index?.end - if (!end || typeof end.row !== 'number' || typeof end.col !== 'number') return null - return { rowIndex: end.row, colIndex: end.col } -} - -/** - * 解析「这一记 Tab 的起点格」,并判断它到底是不是给本表格的。 - * - * 为什么要判这么细:监听挂在 document 上(见 handleDocumentTab),必须自己划边界, - * 否则页面里其它表格、其它行、乃至页面上的按钮都会把它当成自己的。 - * 归属判据全部取自 DOM 数据属性(库给 `tr` 写了 `data-row-key` / `data-row-i`、 - * 给 `td` 写了 `data-col-key`)再与编辑态的行对齐。 - * - * ── 起点为什么优先取**选区**而不是编辑态的 focusCol ── - * 选区是同步更新的(每次跳格立刻写),而 `focusCol` 会被底座的**连按防抖**推迟 - * 最多 100ms 才生效。若以 focusCol 为起点,连按时第二次按键算出来的还是同一个目标格, - * 表现为「按快了就卡住不往前走」。 - * 但选区在「点 checkbox / 点只读格」时不会更新(那两条路径 stop 了 mousedown), - * 所以**没有跳格在途**时以焦点所在格为准 —— 两者各管一段,判据写在下面。 - */ -function resolveTabOrigin( - target: EventTarget | null, -): { rowIndex: number; colIndex: number } | null { - /** - * 「当前在编辑哪一行」要取两个来源之一,缺一不可: - * · `activeEditingRow` —— 正常编辑态; - * · `pendingEditingTarget.row` —— 长按 / 连按导航期间,底座调用 `clearActiveEditing()` - * **把 `activeEditingRow` 也置空了**(见那个函数),编辑器全卸载、只留一个待激活目标。 - * 少了这一支,按住 Tab 走两格之后这里就直接返回了(实测:`hasRow: false`, - * 于是后两下 Tab 全部放行给浏览器)。 - */ - const pending = pendingEditingTarget - const row = activeEditingRow ?? pending?.row ?? null - if (!row) return null - const state = getRowEditingState(row)?.value - /** - * 「编辑会话在进行」有**两种**形态,缺一不可: - * · `state.editing` —— 正常的编辑态; - * · `pendingEditingTarget != null` —— 底座的**连按 / 长按优化**把编辑器全卸载了 - * (按住 Tab 时它刻意保持 0 编辑器,等松手再挂回来,见 handleAreaSelectionChange)。 - * 此时 `state.editing` 是 false,但用户明明还在表格里用键盘走 —— - * 少了这一支,**按住 Tab 走两格之后就会掉出表格**(实测:从只读列起步, - * 第 3 下起 focus 被浏览器按文档顺序送出表格,100% 复现)。 - */ - if (!state?.editing && !pending) return null - - const el = target as HTMLElement | null - const jumpInFlight = performance.now() < tabJumpPendingUntil - const selectionEnd = readSelectionEnd() - - // 焦点在**本表格的编辑器**里(含 select 浮层):那是最可靠的「用户在编辑哪一行」的信号 - const editorEl = el?.closest?.(`.${e('cell-editor-root')}`) ?? null - let cellEl = editorEl?.closest('td[data-col-key]') ?? null - if (!editorEl) { - /** - * 焦点可能在 select 的浮层里 —— G3Select 展开时会把 DOM 焦点移进浮层(`dropdownEl.focus()`)。 - * - * ⚠️ 浮层是 **Teleport 到 body** 的,因此它的 keydown **永远不经过 `.stk-table`**, - * 也就到不了底座的 Tab 导航监听器(那个监听器挂在滚动容器上)。 - * 这就是「select 里按 Tab 跳到了页面其它元素」的根因:表格里没有可聚焦的下一格 - * (编辑器只挂在编辑行上),浏览器只能按文档顺序找下一个可聚焦元素, - * 而浮层在 body 末尾 → 焦点回到页面开头的第一个可聚焦元素。 - * 这里用 ARIA 关联把浮层追回它的触发器,等价于「焦点其实还在那一格里」。 - */ - const overlay = el?.closest?.(`.${SELECT_DROPDOWN_CLASS}`) - const trigger = overlay?.id - ? rootRef.value?.querySelector(`[aria-controls="${overlay.id}"]`) - : null - cellEl = trigger?.closest('td[data-col-key]') ?? null - } - - /** - * 行要对上(`data-row-key` 与编辑态用同一个 rowKey 生成器): - * checkbox 编辑器在**所有**行常驻渲染,只认「编辑器 + 有 td」会把别的行也认成自己。 - * 这里**不要求列等于 focusCol** —— 连按时 focusCol 还停在上一格,那样判定会把 - * 连按的第二下挡在门外(正是「按快就出问题」的一半原因)。 - */ - let focusedCell: { rowIndex: number; colIndex: number } | null = null - if (cellEl) { - const tr = cellEl.closest('tr[data-row-i]') - if (tr && tr.dataset.rowKey === String(getRowKey(row))) { - const colIndex = displayColumns.value.findIndex( - (col) => col.dataIndex === cellEl?.getAttribute('data-col-key'), - ) - const rowIndex = Number(tr.dataset.rowI) - if (colIndex >= 0 && Number.isInteger(rowIndex)) focusedCell = { rowIndex, colIndex } - } - } - - /** - * 焦点既不在本行的编辑器里、也不是下面两种「其实还是我们」的情形 → 这记 Tab 不是给我们的, - * 放行(页面 / 表单的 Tab 序不能被表格吃掉)。 - * - * 「焦点没有落在别的可交互控件上」而不是「恰好等于 body」:换格那几十毫秒里焦点可能 - * 落在 body,也可能被底座收回到表格容器上,两者都说明「此刻没人接管键盘」。 - */ - const focusTakenOver = Boolean( - el?.closest?.('input, textarea, select, button, a[href], [contenteditable="true"]'), - ) - - /** - * 情形二:编辑会话还在,但**此刻 DOM 里一个编辑器都没有**。两种成因: - * · 编辑行 / 编辑列被**虚拟滚动滚出渲染窗口**,编辑器被卸载(用户滚走表格再按 Tab); - * · 底座的**连按 / 长按优化**:按住 Tab 时它刻意把编辑器全卸掉(`clearActiveEditing`), - * 等松手再挂回来。 - * 两种情况下焦点都会掉到 `body`,而编辑会话与选区都还在:此时按 Tab 依然是 - * 「想在表格里继续走」,放行的话浏览器会把焦点按文档顺序送出表格 —— - * 实测两种都必现(滚走再按 Tab 落到侧边栏;按住 Tab 从只读列起步,第 3 下就掉出去)。 - * - * 判据严格限定「编辑格此刻不在 DOM 里」:若编辑格还在渲染(用户点了页面空白、 - * 焦点只是不在它身上),就**不**接管,否则会把页面上的 Tab 序吃掉。 - */ - const editingCellRendered = Boolean( - rootRef.value?.querySelector(`.${e('cell-editor-root')}.${is('cell-editing')}`), - ) - const sessionWithoutEditor = !editingCellRendered && selectionEnd !== null && !focusTakenOver - - if (!focusedCell && !sessionWithoutEditor && !(jumpInFlight && !focusTakenOver)) return null - - /** - * 起点: - * · 跳格在途(连按)或「会话在但没编辑器」→ 以**选区**为准(它是同步的,且这两种 - * 情况下焦点都不在编辑器上,无从取「焦点所在格」); - * · 其余 → 以焦点所在格为准(点 checkbox / 点只读格时选区不会更新,见上)。 - */ - if ((jumpInFlight || sessionWithoutEditor) && selectionEnd) return selectionEnd - return focusedCell ?? selectionEnd -} - -/** - * Tab / Shift+Tab 的接管(挂在 document 的**捕获**阶段)。 - * - * 为什么不交给底座的键盘导航(它自己就处理 Tab): - * 1. 事件源可能在 Teleport 出去的 select 浮层里,压根到不了 `.stk-table`(见上); - * 2. 底座的规则是「列 ±1」,会把焦点送到只读格甚至表格外 —— - * 而编辑语境里要的是「下一个可编辑格」(见 utils/editable.ts 的说明)。 - * - * 移动方式选择「改选区」而不是直接 activateEditing:底座的 `area-selection-change` - * 那条链路已经处理好了激活编辑、定位并聚焦编辑器、滚动进视口, - * 复用它可以保证鼠标点击与键盘跳格走的是**同一条**路径(不会各写一套而行为分叉)。 - * - * 只拦**本表格自己的编辑器**(含它的浮层),其余 Tab 一律放行: - * 组件不能影响页面/表单的 Tab 序。 - */ -function handleDocumentTab(event: KeyboardEvent) { - if (event.key !== 'Tab' || event.defaultPrevented || !props.editable) return - // 组合键不拦(Ctrl+Tab 切标签页之类是浏览器/系统的) - if (event.metaKey || event.ctrlKey || event.altKey) return - - const cell = resolveTabOrigin(event.target) - if (!cell) return - - const rows = (tableRef.value?.getTableData?.() ?? props.dataSource) as TableRow[] - const target = findAdjacentEditableCell(rows, displayColumns.value, cell, event.shiftKey ? -1 : 1) - /** - * null 只有一种情况:**全表一个可编辑格都没有**。此时不拦 —— 没有可跳的目标, - * 接管只会把用户的 Tab 吞掉。(「走到表格两头」不是这种情况:那里是循环。) - */ - if (!target) return - - /** - * 复用「键盘导航中」那段状态机(handleKeydownCapture:按住连发时合并、松手后再挂编辑器)。 - * 不喂给它的话,按住 Tab 连发会每 30ms 挂载/卸载一次编辑器 —— 这正是那段逻辑存在的理由。 - */ - handleKeydownCapture(event) - event.preventDefault() - /** - * 必须 stopPropagation:底座在 `.stk-table` 上也挂了 Tab 导航, - * 不掐掉它会「我们先移到下一格、底座再按列 ±1 移一次」→ 一次按键跳两格。 - */ - event.stopPropagation() - // 下一格没落地(新编辑器还没聚焦)之前,body 上的 Tab 也算我们的:见 TAB_JUMP_GRACE_MS - tabJumpPendingUntil = performance.now() + TAB_JUMP_GRACE_MS - tableRef.value?.setAreaSelection?.( - { begin: { row: target.rowIndex, col: target.colIndex } }, - { scrollToView: true }, - ) -} - -/* ---------- 校验原因浮层(全表共用一个) ---------- */ - -/** - * 表格里的「格级」状态很多,但**原因**只在悬停时才需要 —— 所以不给每个出错格挂一个 - * 浮层组件:密集表里那是几百个实例、每个两组监听器,虚拟滚动下还要随行反复挂载销毁, - * 再叠上固定列 sticky 的定位与层叠,足够写出一串「只在某个滚动位置下错位」的 bug。 - * - * 改为在**表格根节点上做一次事件委托**,全表共用一个浮层 DOM。代价: - * · 常态下:定时器 / 监听器 / 浮层 DOM 全都不存在(`v-if` + 只在打开期间挂关闭监听); - * · 鼠标划过任何元素:一次 `closest()` + 一次引用比较(同一格内移动直接返回); - * · 落到出错格上:一次 `getBoundingClientRect()`;滚动即关闭、**不做跟随**, - * 所以滚动路径上永远不会出现逐帧测量。 - * - * 消息来源是内容节点上的 `data-validate-message`(见 utils/editable.ts)—— 挂在 - * 展示 span / 编辑器根节点上而不是 `td`,因为按行变化的东西打不到 td 上 - * (库渲染 td 时只认 `col.className`,那是整列常量,与灰底标记同理)。 - */ -/** 悬停多久才弹:比原生 title(约 1s)快得多,又不至于划过就闪 */ -const CELL_TIP_DELAY_MS = 150 -/** 浮层与锚点格子的间距 */ -const CELL_TIP_GAP = 6 -/** 浮层单行高度(px)。真实高度要渲染完才知道,判定上下方向时按它粗估 */ -const CELL_TIP_HEIGHT = 28 - -const cellTip = reactive({ - open: false, - message: '', - x: 0, - y: 0, - /** true = 挂在格子下方,false = 上方 */ - below: false, - zIndex: 0, -}) -let cellTipTimer: ReturnType | undefined -/** - * 当前浮层对应的**格子**(td):用来跳过「同一格内反复 mouseover」的重复触发。 - * 用格子而不是消息节点做标识,是因为委托的判定单位就是格子(见下面那个函数)。 - */ -let cellTipCell: HTMLElement | null = null - -const cellTipStyle = computed(() => ({ - left: `${cellTip.x}px`, - top: `${cellTip.y}px`, - zIndex: cellTip.zIndex, - // 水平居中在格子上(对齐锚点中心),垂直方向按方向上/下翻 - transform: cellTip.below ? 'translate(-50%, 0)' : 'translate(-50%, -100%)', -})) - -function unbindCellTipDismiss() { - window.removeEventListener('scroll', closeCellTip, true) - window.removeEventListener('resize', closeCellTip) -} - -function closeCellTip() { - clearTimeout(cellTipTimer) - cellTipTimer = undefined - cellTipCell = null - if (!cellTip.open) return - cellTip.open = false - releaseZIndex(cellTip.zIndex) - cellTip.zIndex = 0 - unbindCellTipDismiss() -} - -function showCellTip(anchor: HTMLElement, message: string) { - // 元素已被虚拟滚动回收 / 已换行:此时 rect 会全 0,别弹 - if (!anchor.isConnected) return - const rect = anchor.getBoundingClientRect() - if (rect.width === 0 && rect.height === 0) return - - cellTipCell = anchor - cellTip.message = message - /** - * 上下方向:**上方放得下、且不会压住表头**才朝上。 - * - * 表头是 sticky 的,数据第一行的正上方就是表头 —— 朝上会把列名整个盖掉, - * 而用户恰恰是盯着列名在找提示(截图里实测过:第一行的提示正好糊在「金额」上)。 - * 所以把表头下沿也算进「上方可用空间」,装不下就翻到格子下方。 - * - * `Math.max(…, 0)` 兜住「表头已滚出视口」的情况:那时只受视口顶部约束, - * 否则拿到的负值会让判据永远成立、提示一直挂在下方。 - */ - const header = rootRef.value?.querySelector('thead') as HTMLElement | null - const topLimit = Math.max(header?.getBoundingClientRect().bottom ?? 0, 0) - cellTip.below = rect.top - CELL_TIP_GAP - CELL_TIP_HEIGHT < topLimit - cellTip.x = rect.left + rect.width / 2 - cellTip.y = cellTip.below ? rect.bottom + CELL_TIP_GAP : rect.top - CELL_TIP_GAP - // 层级走统一的 popup 档:表格若在 Modal 里,提示也要浮在弹窗之上 - cellTip.zIndex = nextZIndex('popup') - cellTip.open = true - - /** - * 关闭监听只在**打开期间**挂着 —— 常态下滚动路径上没有任何监听器。 - * capture:表格的滚动容器不冒泡到 window,必须捕获阶段才收得到; - * passive:明确告诉浏览器这里不会 preventDefault,不阻塞滚动。 - */ - window.addEventListener('scroll', closeCellTip, { capture: true, passive: true }) - window.addEventListener('resize', closeCellTip, { passive: true }) -} - -/** 事件委托入口:挂在根节点上(`mouseover` 会冒泡) */ -function handleCellTipMouseOver(event: MouseEvent) { - const cell = (event.target as HTMLElement | null)?.closest?.('td') as HTMLElement | null - // 鼠标在表头 / 表格空白处:收掉 - if (!cell) { - closeCellTip() - return - } - // 同一格内移动会反复触发 mouseover —— 直接返回,连 querySelector 都省掉 - if (cell === cellTipCell) return - - /** - * 在**格子内**找消息节点,而不是从事件目标沿祖先链往上找。 - * - * 这是实测踩出来的:备注列那种空值格的展示 span **宽度为 0**,右对齐的数字格 - * 也常只有文字那么宽 —— 鼠标绝大多数位置落在 `td` 的 padding 上,此时 - * `event.target` 就是 td 本身,祖先链里根本没有 `data-validate-message` - * (它挂在 span / 编辑器根上,见 editable.ts 的说明),浮层永远弹不出来。 - * 以「格子」为单位反查,鼠标落在格内任何位置都能命中。 - */ - const node = cell.querySelector('[data-validate-message]') as HTMLElement | null - // 这一格没有校验态:立刻收掉,否则会一直挂着上一格的原因 - if (!node) { - closeCellTip() - return - } - - const message = node.getAttribute('data-validate-message') || '' - clearTimeout(cellTipTimer) - // 先记下格子:延迟期间移到别的出错格会重开计时,移到普通格则由上面那条收掉。 - // 锚点用**格子**而不是那个 span:窄 span 会让浮层水平偏到一边。 - cellTipCell = cell - cellTipTimer = setTimeout(() => showCellTip(cell, message), CELL_TIP_DELAY_MS) -} - -/* ---------- 生命周期 ---------- */ - -onMounted(() => { - readDocumentTheme() - - /** - * Tab 的接管挂在 document 上(捕获阶段),而不是表格根节点: - * 事件源可能是 Teleport 到 body 的 select 浮层,挂在根节点上收不到。 - * 捕获阶段先于底座挂载在 `.stk-table` 上的导航监听,所以这里的 stopPropagation - * 才能拦住「底座再移一格」的双跳。 - */ - document.addEventListener('keydown', handleDocumentTab, true) - - if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') { - themeObserver = new MutationObserver(readDocumentTheme) - themeObserver.observe(document.documentElement, { - attributes: true, - attributeFilter: ['data-g3-theme'], - }) - } - - if (typeof ResizeObserver !== 'undefined' && rootRef.value) { - resizeObserver = new ResizeObserver(([entry]) => { - const { width, height } = entry.contentRect - if (width <= 0 || height <= 0) return - clearTimeout(resizeDebounceTimer) - resizeDebounceTimer = setTimeout(() => { - resizeDebounceTimer = undefined - const xOnly = lastSize != null && lastSize.height === height && lastSize.width !== width - lastSize = { width, height } - refreshLayout(xOnly) - }, RESIZE_DEBOUNCE_MS) - }) - resizeObserver.observe(rootRef.value) - } - - // 首帧量一次:容器尺寸与行区高度都已就位(nextTick 后 DOM 才完整) - nextTick().then(measureBodyFill) -}) - -onBeforeUnmount(() => { - if (refreshRafId) cancelAnimationFrame(refreshRafId) - clearTimeout(resizeDebounceTimer) - resizeObserver?.disconnect() - resizeObserver = null - themeObserver?.disconnect() - themeObserver = null - document.removeEventListener('keydown', handleDocumentTab, true) - removeNavigationListeners() - clearEditActivateTimer() - pressedNavigationKeys.clear() - // 浮层可能正开着(连同它的定时器与 scroll / resize 监听一起收拾干净) - closeCellTip() -}) - -// 数据被父级刷新后,行对象可能整体换新,编辑态需随 key 迁移 watch( - () => props.dataSource, - (rows) => { - rebindActiveEditingRow(rows) - // 行数变化会改变行区高度 → 重新判定底边线归属(空表↔新增行↔铺满) - nextTick().then(measureBodyFill) - }, -) - -/** - * `activeRowKey` 是**受控**的当前行开关:只有调用方传了它,组件才去动当前行。 - * - * ⚠️ 这个判定不能省。曾经这里是无条件 `watch([activeRowKey, dataSource])`, - * 并在 `rowKey == null` 时调 `setCurrentRow(undefined)` 清空当前行 —— - * 后果是一个很难查的竞态: - * - * · 不传 `activeRowKey` 的调用方(如 ERP 的 /ocean/booking)每次 - * dataSource 换引用都会触发这个 watcher,把「当前行」清掉; - * 而当前行本来是**点击进入编辑**时由 handleAreaSelectionChange 设置的。 - * · 两个来源互相覆盖,谁后执行谁赢 → 表现为「有概率不高亮」。 - * 实测(点数据量下拉切换 → rows 换新数组): - * 进入编辑后 active=[1] ✅ - * 切换数据量后 active=[] ❌ 高亮被清空,但仍在编辑态 - * - * 语义上的错误在于把 `null` 理解成了「请清空当前行」。 - * `null` 的正确含义是「这个 prop 我不控制,别碰当前行」。 - * 需要清空时应由调用方传一个能表达清空的显式值(如传了 key 但找不到行)。 - * - * 用 `getCurrentInstance().vnode.props` 判断「父级是否真的传了这个 prop」, - * 而不是 `props.activeRowKey != null`:后者无法区分「没传」与「显式传 null」, - * 而这两种情况在 Vue 里都是 `null`,语义却完全不同(不控制 vs 主动清空)。 - * 同理不能用 `withDefaults` 的默认值判断——默认值对两种情况一视同仁。 - * - * ── 为什么是 sync 且**不能**有 `await` ── - * - * 这里曾经写成 `async (...) => { ...; await nextTick(); ... }`,导致 - * 「编辑态下点某行,高亮设上后又被清掉」。实测时间线(MutationObserver): - * t+126ms TR.class → "active" active=[3] ← 高亮正确设上 - * t+138ms TR.class → "" active=[] ← 12ms 后被清掉 - * - * 成因是**并发 watcher 的重入**:进入编辑态后一次点击会连着触发两次 - * `activeRowKey` 变化(startEdit 先置 "1",点击再置 "4")。两个回调都在 - * `await nextTick()` 处挂起,恢复顺序不保证——**旧的那次后恢复**, - * 用已经过期的 rowKey 调 setCurrentRow,就把新高亮覆盖掉了。 - * - * 去掉 await 后 watcher 变成同步执行,每次变化立即生效、后到的自然赢, - * 不存在交错。flush: 'sync' 是显式声明这一点:默认的 'pre' 仍会把回调 - * 排进队列,在同一 tick 内有多次变化时同样可能出现顺序问题。 - * - * 顺带说明为什么**不需要** await:`rebindActiveEditingRow` 与 - * `setCurrentRow` 都是同步的状态操作,这里没有任何「必须等渲染完成」的依赖 - * ——原本那句 `await nextTick()` 是从别处顺手抄来的,并无必要,却引入了重入。 - */ -const activeRowKeyBound = computed(() => { - /** - * ⚠️ 必须同时匹配 **camelCase 与 kebab-case** 两种写法。 - * - * `vnode.props` 里存的是**父级模板里怎么写就怎么存**的原始 key, - * 没有经过 Vue 的 props 规范化: - * 父级写 `:active-row-key="k"` → vnode.props 里是 `'active-row-key'` - * 父级写 `:activeRowKey="k"` → vnode.props 里是 `'activeRowKey'` - * 而 `props.activeRowKey`(组件内读到的)**两种写法都会有值**。 - * - * 这里曾经只判断 `'activeRowKey' in vnode.props`,于是模板用 kebab-case - * (Vue 官方推荐的模板写法,也是绝大多数调用方的写法)时恒为 false —— - * 表现为「传了 activeRowKey 却完全不生效」,且没有任何报错。 - * 实测日志: - * vnodePropsKeys = ref,columns,data-source,row-key,row-height,editable, - * active-row-key,onRowClick,onChange - * bound = false ← kebab 没被识别 - * - * 判「是否受控」时用的是 vnode.props 而不是 `props.activeRowKey != null`, - * 原因见下方长注释(要区分「没传」与「显式传 null」)。 - */ - const vp = getCurrentInstance()?.vnode.props ?? {} - return 'activeRowKey' in vp || 'active-row-key' in vp -}) - -// activeRowKey 只控制当前行高亮,不隐式触发编辑 -watch( - [() => props.activeRowKey, () => props.dataSource, activeRowKeyBound], - ([rowKey, , bound]) => { + [() => props.activeRowKey, () => props.dataSource], + async ([rowKey]) => { rebindActiveEditingRow(props.dataSource) - // 调用方没接管当前行:这里什么都别做,把当前行的归属权留给点击 / 键盘路径。 - // 注意 `rebindActiveEditingRow` 仍要执行(它对编辑态迁移是必要的)。 - if (!bound) return + await nextTick() if (rowKey == null) { - // 显式传了 null:视作「清空当前行」,这是受控方的明确意图 tableRef.value?.setCurrentRow?.(undefined, { silent: true }) return } const row = props.dataSource.find((item) => String(getRowKey(item)) === String(rowKey)) - if (row) tableRef.value?.setCurrentRow?.(row, { silent: true }) + tableRef.value?.setCurrentRow?.(row, { silent: true }) }, - { immediate: true, flush: 'sync' }, + { immediate: true }, ) watch( @@ -1779,85 +774,57 @@ watch( isRepeatNavigation = false clearEditActivateTimer() clearActiveEditing() - clearSelectedArea() + tableRef.value?.clearSelectedArea?.() }, ) -// 行高是虚拟滚动计算的输入,变化后必须重算 -watch( - () => [props.rowHeight, props.headerRowHeight], - () => refreshLayout(), -) +onBeforeUnmount(() => { + if (refreshRafId) cancelAnimationFrame(refreshRafId) + clearTimeout(resizeDebounceTimer) + resizeObserver?.disconnect() + resizeObserver = null + themeObserver?.disconnect() + themeObserver = null + removeNavigationListeners() + clearEditActivateTimer() + pressedNavigationKeys.clear() +}) defineExpose({ scrollToRow, editRow, - initVirtualScroll, - refreshLayout: initVirtualScroll, getSelectedArea, clearSelectedArea, setRowExpand, + initVirtualScroll, + refreshLayout: initVirtualScroll, }) - -/** - * 🧪 临时 A/B 开关:固定列 sticky 补丁(style/_overrides.scss)。 - * - * ⚠️ 这是**验证脚手架,不是功能**。补丁本身是否必要尚未实测确认, - * 加这个开关是为了能在同一页面上做对照,而不用反复改 SCSS。 - * 结论确定后应删掉本段与 SCSS 里的 `:not(.g3-fixed-patch-off)`, - * 只保留被验证有效的那种写法。 - * - * 用法:URL 加 `?g3-fixed-patch=off` → 关闭补丁,回到库默认行为。 - * 只在组件初始化时读一次(对照实验不要求运行期切换)。 - */ -const fixedPatchOff = (() => { - if (typeof window === 'undefined') return false - try { - return new URLSearchParams(window.location.search).get('g3-fixed-patch') === 'off' - } catch { - return false - } -})() @@ -1901,827 +856,175 @@ const fixedPatchOff = (() => { @use './style/index'; .#{b('table')} { - position: relative; width: 100%; height: 100%; overflow: hidden; -} -/* 边框:`bordered` 只翻译成底座的档位(true → 'v',false → false),真正的 - 圆角外框与「横线怎么补回来」都在 `style/_overrides.scss` 的「边框」一节 —— - 为什么外框不再交给底座(做不出圆角、右侧底部边框被滚动条吃掉)、为什么格线 - 不能用真 border(会把行撑高 1px),都写在那一节,别在这里重复。 - 纵向列线的设计决定:**表体一条不画,表头画一条不占满高度的短竖线**。 - 覆盖层把底座的 --bg-border-right / --bg-border-left 对表体涂透明,表头则由 th - 自己接管 background-image(短线 + 更轻的列界色),不在这里按列摘线。 */ + /* 自绘外框:描边沿圆弧走,四角闭合(库内边框恒 false,渐变线不参与)。 + false 时不描边,嵌入卡片内只留行分隔线 */ + &.#{is('bordered')} { + border: 1px solid #{v('border-color', #d9d9d9)}; + border-radius: #{v('radius-lg', 8px)}; + } +} .#{e('table', 'inner')} { width: 100%; height: 100%; - /* transform 虚拟滚动模式下把 tbody 提升为合成层: - translateY 走合成器,避免每帧重绘整表。 */ + /* transform 虚拟滚动模式下,把 tbody 提升为合成层,translateY 走合成器、 + 避免每帧重绘整表 */ .stk-tbody-main { will-change: transform; } } -/* 不自适应时按列配置宽度展示;自适应模式不启用列宽拖拽,使用库默认布局。 - 库的 DOM 是 - .stk-table{display:flex;flex-direction:column} - └ .stk-table-scroll-container{display:flex} ← 表头粘性定位的作用域 - └ table.stk-table-main ← 这里是个 flex item - 库默认给 --main 是 flex:1,表头 th 靠 position:sticky 固定、作用域就是这个 - flex 容器,table 一旦被拉伸到容器宽,表头几何就走样(表现为滚动时表头跟着跑)。 - 实测(playwright):去掉本规则并强制 flex:1 后,table 宽度 790px → 1086px - (被拉满容器),表头跟着变形,所以这条必须留着。 - 注意写普通后代选择器:这个 style 块**不是 scoped**,:deep() 在非 scoped - 块里会被编译器整条丢弃(实测产物里该规则消失),必须用真选择器。 */ +/* 不自适应时按列配置宽度展示;自适应模式不启用列宽拖拽,使用库默认布局。 */ .#{b('table')}:not(.#{is('auto-fit-width')}) .#{e('table', 'inner')} .stk-table-main { flex: none; } +/* 双类选择器 .g3-table__inner.stk-table 权重 (0,2,1) > 库默认 .stk-table (0,1,1) */ +.#{e('table', 'inner')}.stk-table { + th, + td { + font-size: #{v('font-size', 14px)}; + } +} + +/* 外框由 .g3-table.is-bordered 负责;StkTable 的 v 模式负责竖向分割线, + 本组件只补充库 v 模式关闭的横向行分隔线。 */ +.#{e('table', 'inner')}.stk-table { + th, + td { + border-bottom: 1px solid #{v('border-color', #d9d9d9)}; + } +} + +/* bordered="v" 仍会给 StkTable 根节点绘制方形外框背景,且最后一列会绘制右侧渐变线; + 这些边缘由外层圆角容器提供,去掉库的外边缘后只保留单元格之间的竖线。 */ +.#{e('table', 'inner')}.stk-table.bordered-v { + border-left: none; + background-image: none; +} + +.#{e('table', 'inner')}.stk-table.bordered-v { + th:last-child, + td:last-child { + /* 只抹掉最右一列的外侧右边线(外框由 .g3-table.is-bordered 画)。 + 不能整体清空 background-image:库会给「被前面内容穿透遮挡」的右固定列补 + fixed-cell--border-left,并用 --bg-border-left 画它左边的分隔线;整份清空会 + 把这根线一起干掉,表现为操作列压住内容却没有分割线。 */ + --bg-border-right: linear-gradient(transparent, transparent); + } +} + +/* ---------- 选择列:原生 checkbox + 自绘方框,零组件开销 ---------- */ + +.#{e('table', 'checkbox')} { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + cursor: pointer; + line-height: 1; + margin: 0; + + &.#{is('disabled')} { + cursor: not-allowed; + opacity: 0.45; + } + + .#{e('table', 'checkbox-input')} { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; + } + + .#{e('table', 'checkbox-box')} { + display: block; + position: relative; + width: 16px; + height: 16px; + border: 1px solid #{v('border-color', #d9d9d9)}; + border-radius: #{v('radius-sm', 4px)}; + background: #{v('bg-container', #fff)}; + /* 只过渡颜色,不用 transition: all(会连带过渡定位/尺寸,属于禁用写法) */ + transition: + background-color #{v('duration-fast', 0.1s)} #{v('ease', ease)}, + border-color #{v('duration-fast', 0.1s)} #{v('ease', ease)}; + box-sizing: border-box; + flex: none; + } + + &.#{is('checked')} .#{e('table', 'checkbox-box')} { + background: #{v('color-primary', #18181b)}; + border-color: #{v('color-primary', #18181b)}; + + &::after { + content: ''; + position: absolute; + left: 5px; + top: 2px; + width: 4px; + height: 8px; + border: solid #{v('color-primary-on', #fafafa)}; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + } + + &.#{is('indeterminate')} .#{e('table', 'checkbox-box')} { + background: #{v('color-primary', #18181b)}; + border-color: #{v('color-primary', #18181b)}; + + &::after { + content: ''; + position: absolute; + left: 3px; + top: 7px; + width: 8px; + height: 2px; + background: #{v('color-primary-on', #fafafa)}; + border: none; + border-radius: 2px; + transform: none; + } + } +} + /* ---------- 行内编辑 ---------- */ -/** - * 编辑态:**单元格内部无外间距 + 无常驻边框**(editorSeamless,默认开启)。 - * - * 目标观感:整行编辑 / 键盘连续录入 / 固定列宽 / 专业用户(Excel、AG Grid、 - * Handsontable 的表格态)。单元格几何在进入、离开编辑时完全不变,编辑器 - * 融进表格,**只有焦点、校验和特殊交互状态才增强视觉提示**。 - * - * ⚠ 三条硬约束,动这个区块前先读完: - * - * 1. **编辑器盒高必须正好等于行高**(默认 rowHeight=30 → 30px)。 - * td 是 border-box,指定 height 30px 时 content box 只有 30 - padding; - * 而子元素 height:100% 解析的基准是**内容盒**。所以: - * · td 垂直 padding 必须是 0(否则内容盒 < 行高,编辑器被压扁); - * · 编辑器不许再给自己加内缩(margin / 透明边框 / 垂直 padding 都会 - * 把它的**外**盒撑过 td,table-cell 只能按内容撑开 → 编辑那行变高, - * 同行的纯文本列也跟着跳)。 - * 历史 bug 正是这条:`padding: 3px` + `height: 100%` 让行从 30px 变 42px。 - * 2. **水平内边距只能由 td 提供,且由库的 --cell-padding-x 统一给**。 - * 若改成给编辑器加透明边框,等于让编辑器和纯文本单元格各用一套逻辑像素 - * 取整,窄列下会出现「编辑前文字偏移 1px」的漂移。td padding 对编辑器和 - * 纯文本一视同仁,进/出编辑态零位移。 - * 3. **无边框 ≠ 无焦点提示**。焦点环改由外层 td 的 inset 阴影画(见下方 - * :focus-within)—— 画在 td 上不会参与自身的盒模型计算,几何绝对稳定。 - */ .#{e('table', 'cell-editor')} { width: 100%; height: 100%; min-width: 0; - font-size: v('font-size', 14px); - box-sizing: border-box; + font-size: #{v('font-size', 14px)}; } -/* 编辑器所在的 td 需要确定高度:非虚拟模式下(≤100 行,库不给 td 定高、只有 tr 有) - 子元素 height:100% 无法解析,Input 会塌成内容高度。 */ -.#{e('table', 'inner')}.stk-table .stk-tbody-main td:has(> .#{e('table', 'cell-editor-root')}) { - height: var(--row-height); -} - -/* 编辑器根节点填满单元格 */ +/* 内部 G3Input / G3Select 默认高度由控件自身控制,表格行内需要填满单元格 */ .#{e('table', 'inner')}.stk-table { .#{e('table', 'cell-editor-root')} { - &.g3-input, - &.g3-select, - &.g3-date-picker { - width: 100%; - /** - * 高度必须显式钉死为行高,不能只靠 `height: 100%` 撑: - * `.g3-select__trigger` 自带 `min-height: control-height(32px)`, - * 而 30px 的行高下 32 > 30,select 编辑器会直接把编辑那行撑高 2px, - * 同行的纯文本列跟着一起跳。flex 子项默认 min-height:auto, - * 只写 `height: 100%` 也压不住这个下限,必须 min-height:0 + 显式 height。 - */ - height: var(--row-height); - min-height: 0; - box-sizing: border-box; - - /** - * vertical-align 必须显式给,不能留默认的 baseline。 - * - * 编辑器是**行内级**元素(G3Input / G3Select 的根是 inline-flex), - * 行盒按 baseline 对齐时会在其下方额外留出 descender 高度(实测约 4px), - * 把整行从 30px 撑到 34px。bisect 实测: - * 基线(inline-flex + baseline) → 34px - * vertical-align: middle → 30px ✅ - * 选 middle 而非 block:既消除基线空隙,又保留编辑器自身的行内布局方式。 - */ - vertical-align: middle; - } + width: 100%; + height: 100%; + min-height: 0; + /* 控件默认 content-box,height:100% 再加边框会溢出单元格,改边框盒 */ + box-sizing: border-box; } } -/** - * ── 无缝模式:把编辑器自己的「外间距 + 常驻边框」拆掉 ── - * - * 默认的体量(32px 高 + 8px 水平内边距 + 1px 描边 + 6px 圆角)是给**表单**用的, - * 放进 30px 的表格行里就变成「一格一个悬浮小卡片」:格内被 32px 控件撑满, - * 再叠上描边和圆角,视觉上与相邻格完全割裂,而且 32 > 30 会直接把行撑高。 - * - * ⚠ 只改 **padding / border / border-radius / background**,**绝不改 height**: - * 高度是行高一致性的锚点(见上),一改就重现「编辑行变高」。 - * 下面每条都是 `!important`:G3Input / G3Select 的样式块没有 :deep(), - * 权重与这里相同,靠顺序无法稳定压制,只有 !important 能保证生效。 - * - * **焦点提示只由外层单元格承担**(`td:focus-within` 的 inset 环)。 - * 编辑器自己不画任何常驻或聚焦态的框——这点与设计原型一致 - * (原型里 input 恒为 `border:0; outline:none`,焦点环只在 `.cell:focus-within`)。 - */ -.#{b('table')}.#{is('editor-seamless')} { - /** - * 编辑态单元格**自身不留内边距**。 - * - * 库的 `th, td { padding: var(--cell-padding-y) var(--cell-padding-x) }` - * (当前 --cell-padding-x = 12px)会把编辑器挤在格内、左右各留 12px, - * 于是「编辑器没有外间距」这条就名不副实——看起来仍是格内悬着一个控件。 - * 设计原型的做法是 `tbody td { padding: 0 }` + 内层 `.cell { padding: 0 10px }`: - * **间距从 td 挪到内容层**,单元格本身是干净的。 - * - * 这里照搬:td padding 归零,间距改由编辑器的内层控件提供 - * (见下方 `.g3-input__control` 的 padding)。 - * - * ⚠️ 只针对**编辑器所在**的格子(`:has()` 限定),纯文本单元格保持原样, - * 否则整表的文字会全部贴到格线上、与列头错位。 - */ - .#{e('table', 'inner')}.stk-table .stk-tbody-main td:has(> .#{e('table', 'cell-editor-root')}) { - padding-left: 0; - padding-right: 0; - } - - /** - * 编辑格的视觉处理(**正在编辑的那一格**,判据是编辑器上的 `is-cell-editing`)。 - * - * 这一格需要同时满足两件互相争抢同一个属性的事: - * A. 底色必须是**不透明的表格基色**,否则行 hover / 当前行高亮透出来, - * 编辑格与整行同色、看不出在编辑(用户要求 4)。 - * B. 要保留**单元格选中效果**(用户要求 3)。 - * - * ⚠️ A 与 B 都落在 td 的 `background-color` 上,直接写必然二选一。 - * 实测证据(本轮): - * · 编辑格 td 永远带 `data-cs-s`(`areaSelection: editable`, - * 点格进入编辑必然产生选区),所以 `:not([data-cs-s])` 那版规则 - * **永远不命中**,底色一直由库的 `.stk-table td[data-cs-s]` - * 给出 `--cs-bgc`(#b9d7f8)—— 这正是用户看到的「不是白色」。 - * - * 解法是**拆开这两个信号**,让它们落在不同属性上: - * · **背景**归 A:本规则用更高权重盖掉库的 `--cs-bgc`,铺不透明基色; - * · **选中**归 B:库的选区还有一条 `box-shadow: inset … var(--cs-bc)` - * (4 个方向的 inset 描边),它是**独立属性**,不受背景改动影响, - * 且仍然画在 td 上 —— 选中框照旧可见,只是底不再是选区色。 - * - * 这样「编辑格 = 白底 + 选中描边」同时成立,也满足用户明确要求的 - * 「选中效果交给单元格,而非输入框」。 - * - * ⚠️ ⚠️ 判据**必须**是编辑器上的类,不能是 `td:focus-within`: - * select 展开时焦点被移进 Teleport 到 body 的下拉浮层(G3Select 的 - * `dropdownEl.focus()`),那一刻 td 的 `:focus-within` 立刻失效, - * 底色退回库的 `--cs-bgc`(浅蓝)—— 表现就是「select 编辑格时白时不白」, - * 而 input 没有浮层、永远正常。类由内容节点带出(见 editable.ts 的 - * activeCellClass),与焦点在格内 / 浮层 / 换格瞬间落到 body 都无关。 - * - * ⚠️ 范围也不能写成 `:has(> .cell-editor-root)`: - * `editingActive` 为真时**整行**的编辑器都会挂载(见 column.ts 的 - * resolveCustomCell 门控),那样会把整行每个可编辑格都刷成不透明底, - * 把 `tr` 上的行高亮盖得只剩边角 —— 就是上一版「当前行背景色不明显」的成因。 - * 实测:限定前 td[3]~td[8] 全被刷成 rgb(255,255,255);限定后只有编辑格有色。 - * - * 取 `--td-bgc` 而非写死 #fff / #141414 是为了**主题自适应**: - * 覆盖层已把它映射成 `bg-container`(亮 #fff、暗 #141414)。 - */ - .#{e('table', 'inner')}.stk-table - .stk-tbody-main - td:has(> .#{e('table', 'cell-editor-root')}.#{is('cell-editing')}) { - background-color: var(--td-bgc, #{v('bg-container', #fff)}); - } - - .#{e('table', 'cell-editor-root')} { - /* 编辑器自身的外壳:去掉外间距与常驻边框。 - ⚠️ 这里**只处理 `cell-editor-root` 这个元素本身**。 - `G3Input` / `G3Select` 内部还有各自的盒子,尤其 select 的 `__trigger` - 被 Popover 包了一层,并不在本元素的直接子树里:`div.g3-select > [Popover] > - span.g3-select__trigger`。从外部覆盖既不稳定(权重相同、顺序不可控) - 也容易漏 —— 实测证据:只从外部覆盖时 `__trigger` 仍是 - `border: 1px solid #d9d9d9` + `border-radius: 6px` + 白底。 - 所以内部盒子的描边 / 圆角一律在各自组件的样式块里用 seamless 修饰类去掉 - (见 Select.vue 的 is-seamless 与 G3Select 的 seamless prop)。 */ - &.g3-input, - &.g3-select, - &.g3-date-picker { - padding: 0 !important; - border: 0 !important; - border-radius: 0 !important; - box-shadow: none !important; - - /** - * 编辑器**必须保持透明**:底色已上移到所在 td(见上方 td 的注释)。 - * - * 这里是整个「编辑格视觉」的承重点,两个方向都不能偏: - * · 编辑器不透明 → 它会从上面盖住 td 的 `background-image`(库画的 - * 横竖边框线)和选区底色,表现为「该行边框全没了」+「选中效果没了」。 - * 这正是上一版的回归。 - * · 编辑器透明 + td 也不铺底色 → 行 hover / 当前行高亮直接透出来, - * 编辑格与整行同色,看不出哪一格在编辑。 - * - * 两者同时满足的唯一解就是:底色的归属权在 td,编辑器只是透明的内容层。 - */ - background: transparent !important; - - /* 命中区域铺满整格:点单元格任意位置都落到控件上,不必瞄准文字 */ - cursor: text; - transition: box-shadow v('duration', 0.2s) v('ease', ease); - } - - /** - * 去掉容器 padding 后,内边距改由**内层原生控件**补一次网格间距。 - * - * 不减这一层就会「文字贴着格线」:无间距的收益是格内不留空白, - * 不是让文字失去网格对齐。给内层控件加内边距与给外层加 padding 等价: - * 控件是流内子项,父盒几何完全不变,行高不受影响。 - * - * 横向用 space-2(与库的 --cell-padding-x 同一档,纯文本单元格也是这个量), - * 纵向用 space-1 让编辑器里的文字与纯文本在行内自然居中。 - * - * ⚠️ 精确命中 `.#{e('input','control')}`,不要写成 `.g3-input input`: - * 那会把 password / clear / resize 这些**按钮**一起加上内边距, - * 16px 的图标按钮当场被挤变形。 - */ - &.g3-input .#{e('input', 'control')}, - &.g3-input .#{e('input', 'textarea')} { - padding: v('space-1', 4px) var(--cell-padding-x); - } - - /* 多行编辑器的 footer(计数 / 拖拽手柄)是绝对定位在框内的, - 容器 padding 归零后要把它压回格内,否则会溢出到相邻行上 */ - &.g3-input.#{m('input', 'textarea')} .#{e('input', 'footer')} { - inset-inline-end: v('space-1', 4px); - bottom: 0; - } - - /** - * 聚焦时**编辑器自己不画任何东西**。 - * - * G3Input 默认在 `--focused` 上给 `border-color: 主色` + `box-shadow` 光晕, - * 而设计原型(erp_inline_edit_demo.html)的做法是:input 永远 - * `border:0; outline:none; background:transparent`,**焦点提示只由外层 - * 单元格承担**(原型的 `.cell:focus-within { box-shadow: var(--focus-ring) }`)。 - * - * ⚠️ 之前这里保留了 `box-shadow: 0 0 0 2px ...` 作为「双层焦点提示」, - * 结果在格内形成一圈看起来就是**边框**的光晕——正是用户反馈的 - * 「input 获取焦点时出现边框」。焦点环只能有一处,在 td 上。 - * `outline` 也要显式清掉:部分浏览器对 input 的 :focus-visible - * 会给默认 outline,那是一圈实打实的框。 - */ - &.g3-input.#{m('input', 'focused')}, - &.g3-input:focus-within { - box-shadow: none !important; - border-color: transparent !important; - outline: none !important; - } - - /* 原生控件自身的 outline 也一并清掉(否则 :focus-visible 会补一圈) */ - &.g3-input, - &.g3-input .#{e('input', 'control')}, - &.g3-input .#{e('input', 'textarea')} { - outline: none !important; - } - - /** - * hover 提示改走 `outline`,**不再用背景填充**。 - * - * 编辑格底色已是不透明的 `--td-bgc`(见上方 `background` 注释), - * 再叠一层 2% 的黑几乎不可见,hover 反馈等于失效。 - * 用 outline 画在盒**外**,与 td 的焦点环同一思路: - * 不参与盒模型计算,几何零影响,且不会盖住文字。 - * - * 颜色取 `--border-color`(库的边框变量,我们映射成了令牌)而不是主色: - * hover 只是"这里可编辑"的轻提示,主色描边会与焦点环混淆。 - */ - &.g3-input:not(.#{m('input', 'disabled')}):hover, - &.g3-select:hover, - &.g3-date-picker:not(.g3-date-picker--disabled):hover { - outline: 1px solid var(--border-color, #{v('border-color', #d9d9d9)}); - outline-offset: -1px; - } - - /* 多行编辑器:外壳同样不留外边距,高度由调用方用 rowHeight 给足 */ - &.g3-input.#{m('input', 'textarea')} { - padding: 0 !important; - } - - /** - * 校验态:没有常驻边框,坏值只能靠**文字色 + 内描边**提示。 - * 不做四边 outline:窄列里会形成一整块红框,把表格切碎。 - */ - &.g3-input.#{m('input', 'error')} { - box-shadow: inset 0 0 0 1px v('color-danger', #ec4246) !important; - } - - &.g3-input.#{m('input', 'warning')} { - box-shadow: inset 0 0 0 1px v('color-warning', #e79a0d) !important; - } - - &.g3-input.#{m('input', 'error')} .#{e('input', 'control')} { - color: v('color-danger', #ec4246); - } - - &.g3-input.#{m('input', 'warning')} .#{e('input', 'control')} { - color: v('color-warning', #e79a0d); - } - - /* - * 这里原先有三组 `padding-right: 14px`(输入控件 / 展示 span / select 触发器), - * 用途都是**给右上角「!」角标让位**。角标整体去掉后它们一起删掉: - * 校验格的文字内边距现在与普通格完全一致(都走 --cell-padding-x), - * 录入区不再被缩窄。 - */ - } - - /** - * ── 校验态:浅底 + 语义色焦点环(error / warning 共用一套结构)── - * - * 判据是**编辑器根节点上的 `is-editor-error` / `is-editor-warning`**, - * 不是 `td` 上的类,也不是 `.g3-input--error`: - * - * 1. 库渲染 td 时(stk-table-vue 1.2.7 的 getTDProps)class 只来自 - * `col.className` + 固定列/对齐等库内部类,而 `customCell` 渲染的是 - * **单元格内容**、碰不到那个 td 元素 —— 所以「按行 / 按值变化的 td 类」 - * 在 JS 侧打不上去,只能用 `:has()` 从内容反查单元格。 - * 2. `G3Select` **没有** status prop,select 编辑器永远拿不到 `--error` 类。 - * 若样式锚在 `.g3-input--error` 上,「select 列校验失败没有视觉提示」 - * 就是必然结果。editable.ts 统一吐自己的状态类,input / select / checkbox - * 三条路径才共用同一套判据。 - * - * 两种等级只差**颜色**与**底色比例**,所以抽成一个 mixin 传参, - * 而不是把六块规则各写两遍 —— 复制出来的规则最容易在改一处时漏掉另一处 - * (本文件的既有教训:input 改了、select 忘了同步)。 - * - * 关于 `:has()` 的支持面:Chrome/Edge 105+、Safari 15.4+、Firefox 121+。 - * 本区块既有的 `td:has(> .cell-editor-root)` 已在用它,不引入新的兼容门槛; - * 不支持时降级为「无底色」,但文字色与内描边仍然生效。 - */ - @mixin validation-state($color, $mix-percent) { - /** - * ⚠️ 底色**只在非编辑态**出现 —— 正在编辑的那一格让位。 - * - * 编辑体验上的硬要求(业务反馈 + 实测):铺着校验底色时输入区被染色、 - * 对比度下降,录入时「看不清」。而「这一格有错」在编辑时并不缺信号: - * 下面的**语义色焦点环** + 红色文字。 - * 于是优先级定成:**编辑态 > 校验态 > 普通态**,正在编辑的格永远是基色底。 - * - * 判据用编辑器上的 `is-cell-editing` 标记,而不是 `:focus-within`: - * select 展开时焦点被移进 Teleport 到 body 的浮层,`:focus-within` 会时有时无 - * (焦点环那一节踩过同一个坑)。 - * - * ── 右上角的「!」角标已**整体去掉**(同样是业务反馈)── - * 它画的 `!` 会压到文字上 —— 右对齐的数字列最明显(数字正好顶在角标下面), - * 为它让位的 `padding-right: 14px` 又实打实缩窄了录入区。 - * 去掉之后:非编辑态的提示只剩浅底,编辑态的提示只剩语义色焦点环 + 红字, - * 同时也少了三处「给角标让位」的 padding 规则和两条「给角标做定位基准」的 - * `position` 规则(那两条曾把固定列的 `sticky` 覆盖掉,见交付记录)。 - */ - &:not(:has(> .#{e('table', 'cell-editor-root')}.#{is('cell-editing')})) { - /** - * 底色必须**不透明**。 - * - * 与编辑格的白底同因:半透明底会让行 hover / 当前行高亮透上来, - * 底色被染色,用户分不清「这格有问题」还是「这行被选中」。 - * 所以在 `--td-bgc` 上叠一层语义色得到不透明结果, - * 亮暗两套主题都不必各写一个色值常量。 - * - * 这里**不再写 `position`**:早先那是给角标做定位基准用的,而它必须按 - * 「是不是固定列」分开给(普通格 `relative`、固定列保持 `sticky`)—— - * 一旦在 mixin 里统一给 `relative`,固定列那条**内联** `left` 就变成普通相对位移、 - * 整格被推出原位(历史 bug:错误底色跑到隔壁两列中间)。角标去掉后这个问题 - * 连根一起没了,那两条 position 规则也一并删除。 - */ - background-color: color-mix( - in srgb, - $color $mix-percent, - var(--td-bgc, #{v('bg-container', #fff)}) - ); - - /** - * ── 左侧语义色竖条:表体没有列线后,「格级状态」需要自己的形状 ── - * - * 为什么补它:列线(--bg-border-right)还在的时候,校验底色被格线围成一个 - * 闭合矩形,与「一整条行级横带」(斑马纹 / hover / 当前行)形状天然不同 —— - * 眼球不必比色值就知道这是「某一格」还是「某一行」。表体的列线去掉之后, - * 校验底色也退化成无边界的横带,只剩色相一个通道, - * 「这一格有问题」很容易被读成「这一段行有问题」。 - * 左侧 3px 竖条把形状还回来:**竖条 = 格级,横带 = 行级**,各走各的通道。 - * - * 为什么走 background-image,而不是真 border 或 box-shadow: - * · 真 border 进盒模型 —— td 多出几个 px,被钉在 var(--row-height) 的 - * 行内编辑器就把整行顶高,而行高是虚拟滚动 translateY 的基准 - * (见 _overrides.scss 的「格线绝对不能用真 border」一节); - * · box-shadow 在本组件里已被三家占用:库的单元格选区描边、本文件的焦点环、 - * 固定列的 hover / active 叠色(_overrides.scss 的 $cs-border 就是为把 - * 选区描边缝进同一条声明而存在的)。它是**单值**属性,谁的规则赢了谁把 - * 别人整条抹掉 —— 竖条再挤进去,就要在普通格 / 固定列 / hover / active / - * 编辑态每一处手工合并,漏一处就是「只有固定列的选中格缺竖条」这类 - * 极难定位的 bug。 - * background-image 与它们**完全正交**:竖条画在背景层,box-shadow 照旧画在 - * 盒边,谁也不用让谁。 - * - * 后两层是库的格线:第 2 层(竖线)已被我们全局涂透明、实际画不出东西, - * 留着是因为 `background-image` 是**按层一一对应**的列表属性 —— 写全三层, - * 第 3 层那条横线才不会被顶掉。`background-size` 同理必须给满三个值, - * 只写一个会被循环套用到所有层上(库那两层会被压成 3px 宽,表头那次踩过)。 - * - * 变量带 `none` 兜底:库若改版删掉这两个变量,缺省值会让整条 - * `background-image` 变成 guaranteed-invalid 而**整体**失效(连竖条一起没), - * 有兜底就只是少一条线。 - * - * `background-origin` 必须按层写:默认是 padding-box,而表体纯文本格有 - * `--cell-padding-x`(12px)内边距 —— 按默认值竖条会飘在距格左缘 12px 的地方。 - * 只把**第 1 层**改成 border-box,库那两层保持原样(否则会顺带改掉横线的绘制 - * 区域,校验格与相邻普通格的行线就对不上了)。 - */ - background-image: - linear-gradient($color, $color), - var(--bg-border-right, none), - var(--bg-border-bottom, none); - background-size: 3px 100%, 100% 100%, 100% 100%; - background-origin: border-box, padding-box, padding-box; - background-repeat: no-repeat; - } - - /** - * 聚焦 / 正在编辑时,焦点环用**语义色**而不是主色 —— - * 这也正是「编辑态让位」之后的主要校验信号(编辑时只剩它 + 红字)。 - * - * 否则会出现自相矛盾的一格:红底红字,外面套一圈主色环, - * 用户会以为「这格没问题,只是被选中」。 - * - * 分层写法(原型同款):`inset 0 0 0 2px <语义色>, inset 0 0 0 3px <基色>`。 - * 外层 2px 色环 + 内层 1px 基色隔离带 —— 那 1px 是必要的: - * 没有它时色环与同色底直接相接、糊成一片,看不出「这是一个环」。 - * - * 判据与底色同源(`:focus-within` 与 `is-cell-editing` 取并集): - * select 展开时焦点在浮层里,只用 `:focus-within` 环会闪。 - * - * ⚠️ 靠**权重**压过共用主色焦点环,不靠书写顺序(本文件对 - * 「顺序不可控」有多次教训,凡权重竞争一律以权重定胜负)。 - */ - &:is(:focus-within, :has(> .#{e('table', 'cell-editor-root')}.#{is('cell-editing')})) { - box-shadow: - inset 0 0 0 2px $color, - inset 0 0 0 3px var(--td-bgc, #{v('bg-container', #fff)}); - } - } - - .#{e('table', 'inner')}.stk-table { - /** - * 两种形态都要命中:**编辑器**(格子正在编辑)与**展示 span**(格子未编辑)。 - * - * 后者是「校验态全表可见」的关键——编辑器只在被点击过的行挂载, - * 若只认编辑器,未点过的行就算数据有错也不显示任何提示, - * 用户必须一行行点过去才能发现(这正是被反馈的问题)。 - * 现在 editable.ts 的 displayValue 也会吐同一个状态类, - * 所以 `:has()` 里两种子元素都列上即可,样式只写一套。 - * - * ⚠️ 这里**不能**用 `:has(:is(> .a, > .b))` 来合并:`:is()` 内不允许出现 - * 前导组合符(`>`),Sass 会直接判定为非法 CSS 并把整条规则**丢弃**, - * 构建只给一条 `bogus-combinators` 警告(Dart Sass 2.0 会升级为错误)。 - * 实测踩过:规则被静默丢掉,页面上红底全没了。 - * 所以老老实实把选择器写全 —— 用 `@extend` 之外的重复用 mixin 承担, - * 结构重复但可读、且不会失效。 - */ - .stk-tbody-main - td:has(> .#{e('table', 'cell-editor-root')}.#{is('editor-error')}), - .stk-tbody-main - td:has(> .#{e('table', 'cell-display')}.#{is('editor-error')}) { - @include validation-state(v('color-danger', #ec4246), 8%); - } - - .stk-tbody-main - td:has(> .#{e('table', 'cell-editor-root')}.#{is('editor-warning')}), - .stk-tbody-main - td:has(> .#{e('table', 'cell-display')}.#{is('editor-warning')}) { - @include validation-state(v('color-warning', #e79a0d), 5%); - } - } - - /* select 的 hover 提示已并到上方 cell-editor-root 的 outline 规则里 - (底色现在是不透明的 --td-bgc,填充式 hover 已不可见) */ +.#{e('table', 'cell-display')} { + display: block; + min-width: 0; + user-select: text; } -/** - * 焦点提示画在**外层单元格**上:inset 阴影绘制在 td 的 padding box 内侧, - * 不参与 td 自身的盒模型计算 —— 对几何零影响,且焦点环天然铺满整格, - * 与「编辑器融进表格」的观感一致。 - * - * 判据是**两个条件的并集**: - * · `:focus-within` —— 格内有输入焦点(任何可聚焦控件,含非编辑态的 checkbox); - * · 编辑器上的 `is-cell-editing` 标记 —— 这一格就是当前编辑格。 - * - * 为什么必须补第二个:select 展开时焦点被移进 Teleport 到 body 的浮层, - * `:focus-within` 会**时有时无**(取决于此刻焦点在触发器还是浮层里), - * 于是编辑格的焦点环也跟着闪。加上标记后,编辑格**始终**有环,与底色同一判据。 - */ -.#{b('table')}.#{is('editor-seamless')} - .#{e('table', 'inner')}.stk-table - .stk-tbody-main - td:is(:focus-within, :has(> .#{e('table', 'cell-editor-root')}.#{is('cell-editing')})) { - box-shadow: inset 0 0 0 2px v('color-primary', #18181b); -} - -.#{b('table')}.#{is('editor-seamless')} { - /** - * 暗色下主色是浅色(zinc-100 系),焦点环必须跟着翻。 - * - * ⚠️ `dark` 类打在内层 `div.stk-table`(= `.g3-table__inner`)上, - * **不是**外层 `.g3-table` 根节点。证据:库渲染表格容器时写的是 - * class: normalizeClass(["stk-table", { light: theme === "light", - * dark: theme === "dark", ... }]) - * (stk-table-vue 1.2.7 的 StkTable.js),而 `theme` 由我们透传给库。 - * - * 这里曾经写成 `&.dark .g3-table__inner.stk-table …`,即 - * `.g3-table.is-editor-seamless.dark …` —— 外层永远不会有 `.dark`, - * 于是这条规则是**死代码**:暗色下焦点环实际仍是主色 #18181b, - * 在深色底上几乎看不见。 - * - * 改写成从内层匹配:`.g3-table__inner.stk-table.dark`。 - * 权重 (0,4,2),高于上方亮色那条 (0,3,2),不依赖书写顺序。 - */ - .#{e('table', 'inner')}.stk-table.dark - .stk-tbody-main - td:is(:focus-within, :has(> .#{e('table', 'cell-editor-root')}.#{is('cell-editing')})) { - box-shadow: inset 0 0 0 2px v('color-primary', #e5e5e5); - } - - /* 暗色**不再**需要单独的 hover 规则: - hover 已改成 outline(见上),而 outline 的颜色走 --border-color, - 该变量在暗色下由覆盖层翻成 #424242,自动跟随主题。 */ -} - -/* ---------- 暗色适配 ---------- */ - -/** - * 暗色适配。 - * - * 底色都写成 `color-mix(<前景色> N%, var(--td-bgc))`,而 `--td-bgc` 已被覆盖层 - * 映射为 `bg-container`(亮 #fff / 暗 #141414,见 style/_overrides.scss 的 `.dark` 块)。 - * 基色跟着主题自动走,所以**不需要**为暗色准备两套色值常量—— - * 这正是刻意选的写法。若像原型那样写死 `#fff1f0` / `#f8fafc`, - * 暗色下就会得到「黑色表格里贴一块刺眼的浅红 / 浅灰」, - * 而且两份常量要手工同步、迟早与 tokens 漂移。 - * - * 但**比例**必须按主题分开给,理由见下面每条的注释: - * 同一个百分比叠在亮底与暗底上的感知强度并不相同。 - * 这也是本节存在的唯一原因——不是换色,是换比例。 - * - * ⚠️ 选择器里 `.dark` 必须挂在**内层** `.stk-table` 上,不能写在 `.g3-table` 后面。 - * 库的根节点渲染是(stk-table-vue 1.2.7,StkTable.js 的根 class 绑定): - * div.stk-table.{light|dark, virtual, bordered, …} - * 而这个 div 就是我们的 `.g3-table__inner`(``), - * 外层 `.g3-table` 只拿到 g3 自己的 `is-editor-seamless` 等类, - * **永远不会有 `.dark`**。 - * 所以 `.g3-table.is-editor-seamless.dark` 这类写法命中不了任何元素 - * —— 本文件第 1457 行的暗色焦点环规则正是这个写法,属于既有的失效规则 - * (本次不动它,避免掺入无关行为变更,仅在此记录,见交付报告)。 - * 本节的写法与 `style/_overrides.scss` 的暗色块保持一致。 - * - * ⚠️ 另一个坑:`.dark` 在这里同时用来**抬权重**。 - * 亮色规则(嵌在 `.g3-table.is-editor-seamless` → `.g3-table__inner.stk-table` 内) - * 的实际权重是 (0,7,1): - * .g3-table + .is-editor-seamless + .g3-table__inner + .stk-table - * + .stk-tbody-main + td + :has(> .cell-editor-root .is-editor-error) - * 若暗色只写 `.g3-table .g3-table__inner.stk-table.dark …`,算下来同样是 (0,7,1) - * —— **平手**,只能靠「本节写在亮色规则之后」取胜。 - * 那正是本文件反复吃过亏的「顺序不可控」模式(打包 / 代码分割都可能改序)。 - * 所以这里补上 `.is-editor-seamless`,把暗色规则抬到 (0,8,1), - * 让它**无条件**胜过亮色,与顺序彻底解耦。 - * 语义上也站得住:需求 A 的这套视觉只在无缝编辑模式下生效。 - * - * 另:`color-danger` 在 tokens 里**没有**暗色覆写(已核对 theme.scss 的暗色块, - * 只翻了 color-primary / text-* / bg-* / border-* / fill 等; - * danger 与 warning 亮暗同值,都是 #ec4246 / #e79a0d)。 - * 所以底色与焦点环用的语义色在两种主题下同值:暗色只需**调低比例**(见下),不必换色。 - */ -.#{b('table')}.#{is('editor-seamless')} .#{e('table', 'inner')}.stk-table.dark { - /** - * 暗色下校验红的比例要**调低**。 - * - * 同一个比例在两种底上的观感完全不同: - * 亮色是「白底 + N% 红」= 很淡的粉,克制、像提示; - * 暗色是「#141414 + N% 红」= 明显泛红的暗块, - * 而 #ec4246 本身在暗底上已经相当抢眼,比例再高就会糊成一大片红, - * 把「哪一格错了」淹没掉。 - * - * 6% 是实测观感档位:仍能一眼分辨出这一格与相邻格不同, - * 又不至于盖过「哪一格错了」这件事本身。 - * - * ⚠️ 判据必须与 mixin 一致:**编辑态不铺底**(`is-cell-editing` 那一格让位)。 - * 这里的暗色规则是**另写**的(只覆写 background-color 与比例), - * 少写这个 `:not(...)` 就会出现「亮色下编辑时干净、暗色下仍然染色」—— - * 正是本文件反复踩过的「只改一半」。 - */ - .stk-tbody-main - td:has(> .#{e('table', 'cell-editor-root')}.#{is('editor-error')}):not(:has(> .#{e('table', 'cell-editor-root')}.#{is('cell-editing')})), - .stk-tbody-main - td:has(> .#{e('table', 'cell-display')}.#{is('editor-error')}):not(:has(> .#{e('table', 'cell-editor-root')}.#{is('cell-editing')})) { - background-color: color-mix( - in srgb, - v('color-danger', #ec4246) 6%, - var(--td-bgc, #{v('bg-container', #141414)}) - ); - } - - /** - * 警告的暗色比例同样调低(与 error 同因)。 - * - * ⚠️ 这两种等级、两种形态(编辑器 / 展示 span)**必须都写上**,且都要带 - * 「非编辑态」的 `:not(...)` 守卫。 - * 暗色规则一旦漏掉某一支,就只有「暗色 + 该形态」这一种组合失效 —— - * 用户看到的是「有些格子在暗色下没有底色」,而亮色完全正常, - * 排查时极易只盯着亮色规则看。本文件已多次出现「只改一半」的问题, - * 这里刻意把四条都列全。 - */ - .stk-tbody-main - td:has(> .#{e('table', 'cell-editor-root')}.#{is('editor-warning')}):not(:has(> .#{e('table', 'cell-editor-root')}.#{is('cell-editing')})), - .stk-tbody-main - td:has(> .#{e('table', 'cell-display')}.#{is('editor-warning')}):not(:has(> .#{e('table', 'cell-editor-root')}.#{is('cell-editing')})) { - background-color: color-mix( - in srgb, - v('color-warning', #e79a0d) 4%, - var(--td-bgc, #{v('bg-container', #141414)}) - ); - } -} - -/** - * 需求 B 的暗色比例。 - * - * ⚠️ 必须**另起一个块**、单独写选择器,不能并进上面那个 `.dark` 块里。 - * 上面那个块的选择器带 `.#{is('editor-seamless')}`(为了压过需求 A 的亮色规则), - * 而需求 B 是两种 editorSeamless 取值都要生效的(理由见上方需求 B 的注释)。 - * 若把 B 的暗色规则塞进那个块,就会平白继承 `.is-editor-seamless` 限定, - * editorSeamless=false 时暗色灰底失效 —— 亮色生效、暗色不生效, - * 正是最难排查的那类「只在暗色下复现」的问题。 - * - * 权重这里同样要压过亮色规则(亮色那条是 `.g3-table .g3-table__inner.stk-table …` - * = (0,6,2),本条多了 `.dark` = (0,7,2),无条件取胜,与顺序无关)。 - * - * 比例换成「基色 + 白 8%」而不是「基色 + 黑 6%」: - * 亮色下 6% 的黑能压暗白底;但暗色底已经是 #141414,再叠黑等于没叠 - * (叠完还是 #141414,肉眼零差异——灰底彻底不可见)。 - * 暗色下要表达「比底色暗一档」,物理上必须**提亮**, - * 所以前景取 text-1(暗色下是 rgba(255,255,255,.88)),8% 对应亮色 6% 的感知强度。 - * (暗色的斑马纹是 4% 的白,这里同样要与它拉开一档,理由见亮色那段的说明。) - * 这正是用令牌而非写死 rgba(0,0,0,.06) 的价值:方向由主题自动决定。 - */ -.#{b('table')} .#{e('table', 'inner')}.stk-table.dark { - /* ⚠️ 判据必须与亮色规则**完全一致**(同样的两个来源、同样的类名)。 - 仅改亮色而漏改暗色,就会出现「亮色正常、暗色没灰底」这类只在暗色复现的问题, - 而它的成因在另一段代码里,极难定位(本文件已有多次「只改一半」的教训)。 */ - .stk-tbody-main - td.#{e('table', 'cell-readonly')}, - .stk-tbody-main - td:has(> .#{e('table', 'cell-display')}.#{e('table', 'cell-readonly')}) { - background-color: color-mix( - in srgb, - v('text-1', rgba(255, 255, 255, 0.88)) 8%, - var(--td-bgc, #{v('bg-container', #141414)}) - ); - - /* 同亮色那条:把校验竖条一起收掉(暗色不写就会「亮色是纯灰、暗色留一条红条」, - 正是本文件反复踩过的「只改一半」)。 */ - background-image: var(--bg-border-right, none), var(--bg-border-bottom, none); - background-size: 100% 100%, 100% 100%; - } -} - -/* ---------- 需求 B:不可改的单元格灰底(常驻) ---------- */ - -/** - * 不可改的格子铺一层灰底 —— **常驻**,与「这一行是否在编辑」无关。 - * - * 它表达的是「**这一格现在改不了**」,三种情形统一走它: - * 1. 整列只读:列上没有 `editor`; - * 2. 整列禁用:`editor.disabled === true`; - * 3. 按行禁用:`editor.disabled` 是**函数**且对该行返回 true(动态条件, - * 如「已放舱后状态不可改」)。 - * - * ── 前两种与第三种走的是不同通路(务必分清,改一处要改两处)── - * - * 关键事实:**JS 侧无法给 td 加「按行变化」的类**。 - * 库渲染 td 时(stk-table-vue 的 getTDProps)class 只取 `col.className` + - * 固定列/对齐等内部类;`customCell` 拿到的是**单元格内容**,碰不到那个 td 元素。 - * - * · 1 / 2 是**整列常量** → 由 G3Table 在 displayColumns 里往 `col.className` - * 补同一个类名(`cell-readonly`),零运行时开销 —— 不需要为这些列注入 - * customCell(那会让每个可见格多一个函数式组件实例); - * · 3 随行数据变 → 只能由**内容节点**带出标记:utils/editable.ts 的 displayValue - * 在 `isEditorDisabled(row)` 为真时给那个 span 打 `cell-readonly`, - * 表格侧用 `:has()` 反查单元格。这类列会被常驻注入 customCell - * (见 utils/column.ts 的 hasRowDisabled),代价只落在「显式用了按行禁用」的列上。 - * - * ── 为什么不沿用「编辑行里没有编辑器的格子」这个旧判据 ── - * - * · 它只在**被点过的那一行**可见 —— 用户仍要一行行点过去才发现某格点不动, - * 而这正是当初被反馈的问题; - * · 行内一旦挂着 checkbox / select(它们始终挂载),它会误判整行, - * 需要靠 `:not(.seq-column)` + 编辑器类型限定层层打补丁(那段历史见本文件 - * git 记录里的一长串注释); - * · 它**表达不了「按行禁用」**,而那恰是业务最常见的诉求。 - * - * ── 为什么不会误伤表头 / 表尾 ── - * - * 选择器锚在 `.stk-tbody-main` 上,thead / tfoot 根本不在这个子树里 - * (库的 DOM 是 .stk-header / .stk-tbody-main / .stk-footer 三块并列)。 - * 表尾 tfoot 用的是同一套列定义,不该跟着变灰。 - * - * ── 与其它状态的优先级 ── - * - * 状态色都落在 `background-color` 上,本规则写在**校验态规则之后**且权重不低于它, - * 所以「既禁用、又校验不过」的格子显示为**灰**。这是刻意的: - * 既然这一格改不了,「这里错了」提示了也没用,用户只能去找上游数据。 - * - * ⚠️ 这里别按注释里的旧权重数字推断(旧文写的是「本规则 (0,6,1) 高于校验态 (0,4,1)」): - * 校验态的选择器带 `:has()`,它的特异性等于**参数中最高者**,按行禁用那条 - * 与校验态实测同为 (0,7,1) —— 胜负实际由「谁写在后面」决定。改这两条规则的 - * 顺序前先确认这一点。 - * - * ⚠️ 校验态的竖条加在 `background-image` **第 1 层**上,而 background-image - * 不能按层覆盖 —— 本规则必须显式写回库那两层格线(见下), - * 否则灰底上会留着一条红 / 黄竖条,「显示为灰」就破了一半。 - * - * 取色:亮色下原型是写死的 `#f8fafc`,这里改用 `--td-bgc` 叠一层中性黑, - * 好处是暗色自动跟随(`--td-bgc` 已由覆盖层映射成 bg-container, - * 亮 #fff / 暗 #141414),不必再手写一份暗色常量。 - * 比例 6%(**表体列线去掉之后从 3% 提上来的**): - * 灰底与斑马纹都是「无边界横带」,两者只能靠亮度区分 —— 而斑马纹是 - * `--stripe-bgc`(2% 的黑 ≈ #fafafa),3% 的灰底 ≈ #f7f7f7,两者差约 1%, - * 中间还隔着一行白,肉眼基本分不出「这行是斑马纹」还是「这格改不了」。 - * 以前有列线时靠「矩形 vs 长带」的形状差异兜着,列线去掉后这层保障没了, - * 只能靠浓度拉开:6% ≈ #efefef,与斑马纹 2% 差出可辨的一档。 - * (与表头的 #f5f5f5 数值接近但不会混:表头是不透明实色 + 更重的字, - * 且两者在垂直方向上永远相邻、可以直接对比。) - * (此比例为观感推定的档位,非实测调优值,以实际观感为准。) - * - * ── 为什么**不**限定 `is-editor-seamless` ── - * 「哪些列不可编辑」与「编辑器是否无缝嵌进单元格」是两个正交的问题: - * editorSeamless=false(悬浮卡片形态)时一样有只读列,一样需要区分。 - * 判据 `cell-editor-root` 在两种模式下都会挂(editable.ts 里无条件添加), - * 所以这条规则两种模式通用,不受 editorSeamless 影响。 - * 对照需求 A 的红底:那一条(以及需求 B 的灰底)是无缝模式的视觉语言(无常驻边框, - * 才需要底色来承载状态),故仍限定在 `.is-editor-seamless` 内。 - */ -.#{b('table')} { - /** - * 灰底的两个来源:td 自带的类名(整列只读 / 整列禁用)与内容节点上的标记 - * (按行禁用)。写成两条独立选择器而不是 `:has(:is(...))` —— `:has()` 里嵌 - * `:is()` 在部分浏览器支持更晚,且不生效时**静默**(只是不染色),很难排查。 - */ - .#{e('table', 'inner')}.stk-table - .stk-tbody-main - td.#{e('table', 'cell-readonly')}, - .#{e('table', 'inner')}.stk-table - .stk-tbody-main - td:has(> .#{e('table', 'cell-display')}.#{e('table', 'cell-readonly')}) { - background-color: color-mix( - in srgb, - v('text-1', rgba(0, 0, 0, 0.88)) 6%, - var(--td-bgc, #{v('bg-container', #fff)}) - ); - - /* 既「改不了」又「校验不过」的格子:连校验竖条一起收掉。 - 优先级本来由「灰底规则写在后面 + 权重不低」决定(旧行为靠这个顺序赢下 - background-color),但竖条是校验态加在 `background-image` **第 1 层**上的 —— - background-image 不能按层覆盖,只改 background-color 的话,灰底上会留一条 - 红 / 黄竖条,与「既然改不了就别再提示这里错了」的既有决策冲突。 - 所以这里显式写回库那两层格线(无竖条)。 - ⚠️ 不能图省事写 `background-image: none` —— 那会把行分隔线一起抹掉。 */ - background-image: var(--bg-border-right, none), var(--bg-border-bottom, none); - background-size: 100% 100%, 100% 100%; - } - - /** - * 只读格里的文字也减一档亮度,与灰底配套(只铺底不调色会显得「脏」—— - * 灰底上的正文仍是纯黑,对比过强)。原型同样两条都写 - * (`.cell.is-readonly` + `.cell.is-readonly .cell-content`)。 - * - * 两种情况分开写: - * · 按行禁用:文字在我们的展示 span 上,只调这个 span; - * · 整列只读 / 禁用:这些列**不注入 customCell**(没有展示 span), - * 只能把颜色落在 td 上。操作列里的按钮、标签自带颜色,不受影响。 - */ - .#{e('table', 'inner')}.stk-table - .stk-tbody-main - td:has(> .#{e('table', 'cell-display')}.#{e('table', 'cell-readonly')}) - .#{e('table', 'cell-display')}, - .#{e('table', 'inner')}.stk-table - .stk-tbody-main - td.#{e('table', 'cell-readonly')} { - color: v('text-2', rgba(0, 0, 0, 0.65)); - } -} - -/* 展示态文本可选中;拖选期间由库禁用选中,故用 :not() 排除避免冲突 */ +/* 纯展示列 / 非编辑态可编辑列走库原生 td 渲染路径(cell-display 不再包裹), + 原生单元格同样需要文本可选中;拖选(.is-area-selecting)期间仍由库禁用选中, + 故用 :not() 排除,避免与 .stk-table.is-area-selecting 的 user-select: none 冲突。 */ .#{e('table', 'inner')}.stk-table:not(.is-area-selecting) .stk-tbody-main .table-cell-wrapper { user-select: text; } @@ -2732,248 +1035,77 @@ const fixedPatchOff = (() => { white-space: nowrap; } -.#{e('table', 'cell-display')} { - display: block; - min-width: 0; - user-select: text; +/* 编辑器所在 td 需要确定高度,否则非虚拟模式下(≤100 行,库不给 td 定高, + 只有 tr 有 height)子元素 height:100% 无法解析,Input 会塌成内容高度(约 20px) */ +.#{e('table', 'inner')}.stk-table .stk-tbody-main td:has(> .#{e('table', 'cell-editor-root')}) { + height: var(--row-height); } -/** - * 数值列用等宽数字(tabular-nums)。 - * - * 比例数字(默认)里每个字形的宽度不同,`1` 比 `8` 窄 —— 于是「金额 / 数量 / 单号」 - * 这类列每换一行,小数点与个位就在水平方向上抖一下,整列看起来参差不齐。 - * 等宽数字把 0-9 钉成同一宽度,数字按位对齐,列才会形成整齐的数值感。 - * - * 判据取「右对齐」而不是「内容是数字」: - * 库渲染 td 时只会带上列配置派生的对齐类,运行期读不到单元格的值, - * 且本库约定里金额/数量/数值这类列一律用 `align: 'right'` 声明 - * (见 docs/examples/table/*.vue),所以右对齐就是数值列的可靠代理。 - * 这样不会波及左对齐的文本列 —— 中文与拉丁文本来就不需要等宽数字, - * 强行套上去反而会让正文的字距变得松散。 - * - * ⚠️ 类名是 `text-r`,不是 `text-right`:由 stk-table-vue 的 getTDProps 打上 - * (`col.align === 'right' → 'text-r'`,center → `text-c`,header 另走 text-l/text-c/text-r)。 - * 左对齐的列**不带任何类**,所以只写 `td.text-r` 即可,不会误伤普通文本列。 - * - * 只打在 td 上,靠继承覆盖格内一切内容:展示态的文字、行内编辑时的 input - * 都能拿到 —— 不必再给 `.g3-table__cell-editor` 单独写一条, - * 那会把 select / 文本编辑器也一起套上等宽数字。 - */ -.#{e('table', 'inner')} td.text-r { - font-variant-numeric: tabular-nums; -} - -/* ---------- 选择列:原生 checkbox + 自绘方框,视觉对齐 G3Checkbox ---------- */ -.#{e('table', 'checkbox')} { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - margin: 0; - cursor: pointer; - line-height: 1; - box-sizing: border-box; - - &.#{is('disabled')} { - cursor: not-allowed; - opacity: 0.45; - } - - /* 视觉隐藏但保留可访问性与可聚焦性 */ - input { - position: absolute; - width: 1px; - height: 1px; - margin: 0; - opacity: 0; - pointer-events: none; - } - - .#{e('table', 'checkbox-box')} { - position: relative; - display: block; - flex: none; - width: v('checkbox-size', 16px); - height: v('checkbox-size', 16px); - box-sizing: border-box; - border: 1px solid v('border-color', #d9d9d9); - border-radius: v('radius-sm', 4px); - background: v('bg-container', #fff); - /* 只过渡颜色:transition: all 会连带过渡定位/尺寸 */ - transition: - background-color v('duration', 0.2s) v('ease', ease), - border-color v('duration', 0.2s) v('ease', ease); - } - - &:hover:not(.#{is('disabled')}) .#{e('table', 'checkbox-box')} { - border-color: v('color-primary', #18181b); - } - - &.#{is('checked')} .#{e('table', 'checkbox-box')}, - &.#{is('indeterminate')} .#{e('table', 'checkbox-box')} { - border-color: v('color-primary', #18181b); - background: v('color-primary', #18181b); - } - - /* 对勾:用边框拼,不引入图标组件(逐行渲染场景下要零开销) */ - &.#{is('checked')} .#{e('table', 'checkbox-box')}::after { - content: ''; - position: absolute; - inset-inline-start: 5px; - top: 2px; - width: 4px; - height: 8px; - border: solid v('color-primary-on', #fafafa); - border-width: 0 2px 2px 0; - transform: rotate(45deg); - } - - &.#{is('indeterminate')} .#{e('table', 'checkbox-box')}::after { - content: ''; - position: absolute; - inset-inline-start: 3px; - top: 7px; - width: 8px; - height: 2px; - border: none; - border-radius: 2px; - background: v('color-primary-on', #fafafa); - } - - input:focus-visible + .#{e('table', 'checkbox-box')} { - @include focus-ring(); - } -} - -/* ---------- 编辑器文字对齐跟随列 ---------- */ - -/** - * 列右对齐(库把它落成 td 上的 `.text-r`)时,编辑器里的文字也要右对齐。 - * - * 为什么必须显式写:`text-align` 本是**继承**属性,但**表单控件**的 UA 样式表给 - * input / textarea 设了 `text-align: start`,其优先级高于继承 —— 实测点进金额格后 - * input 拿到的是 `start`(LTR 下即左对齐),于是文字与光标从右边跳到左边, - * 进出编辑的位移很扎眼(与「编辑器无缝嵌入单元格、进出零位移」的目标正好相反)。 - * - * 为什么放在表格侧而不是给 G3Input 加 prop:右对齐是**列**的属性(`align`), - * 只有表格知道;G3Input 自己无从判断该不该右对齐。将来表单里若有真实诉求, - * 再给 Input 加通用 prop 也不迟 —— 现在加只是让公共组件多一个没人用的 API。 - * - * ⚠️ 判据用库的列对齐类名(`.text-l` / `.text-c` / `.text-r`):它由列上的 `align` - * 生成、打在 **td** 上(已实测:`td.className === 'text-r'`)。库若改名,这一层会 - * **静默失效**(表现只是「编辑时又变回左对齐」),排查时 grep 库样式里的 - * `text-align` 就能定位 —— 代价可接受,好过为此在 TS 侧多加一套标记类。 - * ⚠️ 中间档 `.text-c` 同样会跳,一起接上。 - * ⚠️ 只管 input / textarea:右对齐的 select 极少见,而它的对齐由触发器内部的 - * flex 布局决定,不是一条 `text-align` 能解决的。 - * ⚠️ 不限定 `is-editor-seamless`:列对齐与「编辑器是不是无缝形态」正交, - * 悬浮卡片形态下右对齐列同样会跳。 - */ -.#{e('table', 'inner')}.stk-table { - .stk-tbody-main td.text-r .#{e('table', 'cell-editor-root')} :is(input, textarea) { - text-align: right; - } - - .stk-tbody-main td.text-c .#{e('table', 'cell-editor-root')} :is(input, textarea) { - text-align: center; - } -} - -/* ---------- 校验原因浮层 ---------- */ - -/** - * 表体上 hover 出错格时弹出失败原因。全表只有这一个 DOM(`v-if` 控制), - * 显示哪个格子的原因由 G3Table 根节点上的 `mouseover` 委托切换 —— - * 为什么不做成每格一个 Tooltip,见组件 script 里「校验原因浮层」一节的性能账。 - * - * 浮层语言对齐 Popover / Tooltip(同令牌、同阴影、同圆角),但更紧凑: - * 它只是一行解释文字,用不上气泡那 4px 内边距与 14px 字号。 - */ -.#{e('table', 'cell-tip')} { - position: fixed; - max-width: 260px; - padding: 4px v('space-2', 8px); - border-radius: v('radius-sm', 4px); - background: v('bg-container', #fff); - color: v('text-1', rgba(0, 0, 0, 0.88)); - font-size: v('font-size-sm', 12px); - line-height: v('line-height', 1.5); - box-shadow: v('shadow-popup', 0 6px 16px rgba(0, 0, 0, 0.12)); - word-break: break-word; - /** - * 不接收任何鼠标事件。 - * - * 浮层就悬在光标附近,一旦它能命中鼠标,就会出现「浮层挡住光标 → 触发 - * mouseout / mouseover → 浮层闪烁」的抖动;而且被它盖住的正是我们要 hover - * 的那一格,命中它会直接打断委托链路。 - */ - pointer-events: none; +.#{e('table', 'inner')} td:has(> .#{e('table', 'cell-editor-root')}) { + padding: 3px; } /* ---------- 操作列 ---------- */ -.#{e('table', 'actions-cell')} { + +.g3-actions-cell { display: inline-flex; align-items: center; - gap: v('space-1', 4px); + gap: 4px; } -.#{e('table', 'action-btn')} { +.g3-action-btn { display: inline-flex; align-items: center; gap: 3px; - padding: 2px 4px; border: none; background: transparent; - color: v('color-primary', #18181b); - font-size: v('font-size-sm', 12px); + cursor: pointer; + padding: 2px 4px; + color: #{v('color-primary', #18181b)}; + font-size: 12px; line-height: 20px; white-space: nowrap; - cursor: pointer; &:hover { opacity: 0.75; } &:disabled { - color: v('text-disabled', rgba(0, 0, 0, 0.25)); cursor: not-allowed; + color: #{v('text-disabled', rgba(0, 0, 0, 0.25))}; opacity: 1; } - &.#{e('table', 'action-btn-danger')} { - color: v('color-danger', #ec4246); + &.is-danger { + color: #{v('color-danger', #dc2626)}; } } -.#{e('table', 'action-more')} { - &.#{e('table', 'action-more-open')} { +/* "更多" 展开按钮样式 */ +.g3-action-more { + color: #{v('color-primary', #18181b)}; + font-size: 12px; + line-height: 20px; + padding: 2px 4px; + + &.is-open { opacity: 0.75; } } -.#{e('table', 'confirm-content')} { - color: v('text-2', rgba(0, 0, 0, 0.65)); - font-size: v('font-size', 14px); +/* 确认弹窗内容文字 */ +.g3-confirm-content { + color: #{v('text-2', rgba(0, 0, 0, 0.65))}; + font-size: 14px; line-height: 22px; } -/* ---------- 空态 ---------- */ -.#{e('table', 'empty')} { - display: flex; - align-items: center; - justify-content: center; - padding: v('space-5', 24px); - color: v('text-3', rgba(0, 0, 0, 0.45)); - font-size: v('font-size', 14px); -} +/* ---------- 合计行:与数据区之间加一条分隔线,背景保持与表头一致 ---------- */ -/* ---------- 表尾合计 ---------- */ .#{b('table')} .stk-footer { td { - border-top: 1px solid v('border-color', #d9d9d9); - background: v('fill', rgba(0, 0, 0, 0.02)); + border-top: 1px solid #{v('border-color', #d9d9d9)}; + background: #{v('bg-container', #fff)}; font-weight: 500; } } diff --git a/code/g3soft-libs/packages/ui/src/table/attrs.spec.test.ts b/code/g3soft-libs/packages/ui/src/table/attrs.spec.test.ts deleted file mode 100644 index 3f85b082..00000000 --- a/code/g3soft-libs/packages/ui/src/table/attrs.spec.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { mount } from '@vue/test-utils' -import { describe, expect, it } from 'vitest' -import G3Table from './G3Table.vue' -import type { G3TableColumn, TableRow } from './types' - -/** - * 验证 inheritAttrs: false + v-bind="$attrs" 的实际落点。 - * - * 结论应当是:非 prop 的 attrs 落在**内层 StkTable 的根元素**上, - * 而不是 G3Table 自己的根 div(.g3-table)。 - */ -const columns: G3TableColumn[] = [ - { dataIndex: 'name', title: '名称' }, -] - -const rows: TableRow[] = [{ id: 1, name: 'A' }] - -describe('attrs 落点', () => { - it('非 prop 属性(data-testid / class / style)落在内层,而不是 .g3-table 根 div', () => { - const wrapper = mount(G3Table, { - props: { columns, dataSource: rows, seq: false }, - attrs: { - 'data-testid': 'my-table', - class: 'my-extra-class', - style: 'outline: 1px solid red', - 'aria-label': '测试表', - }, - attachTo: document.body, - }) - - const rootEl = wrapper.element as HTMLElement - console.log('ROOT div classes: ' + rootEl.className) - console.log('ROOT div outerHTML head: ' + rootEl.outerHTML.slice(0, 160)) - - const stk = wrapper.findComponent({ name: 'StkTable' }) - const stkEl = stk.element as HTMLElement - console.log('STK element classes: ' + stkEl.className) - - // 根 div 不应带上用户传的 class / testid - expect(rootEl.className).not.toContain('my-extra-class') - expect(rootEl.getAttribute('data-testid')).toBeNull() - - // 内层应当拿到 - expect(stkEl.className).toContain('my-extra-class') - expect(stkEl.getAttribute('data-testid')).toBe('my-table') - expect(stkEl.getAttribute('aria-label')).toBe('测试表') - }) - - it('已声明的 prop 不会被当 attrs 透传下去(如 bordered / seq)', () => { - const wrapper = mount(G3Table, { - props: { columns, dataSource: rows, seq: false, bordered: false }, - attachTo: document.body, - }) - const stk = wrapper.findComponent({ name: 'StkTable' }) - const props = stk.props() as Record - // bordered 是我们声明过的 prop,经我们处理后传给库(false → false) - expect(props.bordered).toBe(false) - // seq 是组件自己的 prop,不应原样出现在 $attrs 里影响库 - expect(props.seq).toBeUndefined() - }) -}) diff --git a/code/g3soft-libs/packages/ui/src/table/index.ts b/code/g3soft-libs/packages/ui/src/table/index.ts index 098c8bee..3e45e362 100644 --- a/code/g3soft-libs/packages/ui/src/table/index.ts +++ b/code/g3soft-libs/packages/ui/src/table/index.ts @@ -1,44 +1,20 @@ export { default as G3Table } from './G3Table.vue' export { registerTableFeatures } from './G3Table.vue' -/** - * 精度判定(`''` = 通过,否则是失败原因)。 - * - * 与组件内置的那条规则**是同一个函数**:业务在保存前做提交拦截时应当复用它, - * 而不是照着自己的列配置再写一遍 —— 两边不一致就会出现「格子红了但存档能过」 - * (或反过来),而且极难排查。用法见 `docs/content/ui/data/table.md` 的 - * 「数值精度与金额」一节。 - */ -export { checkPrecision } from './utils/precision' -/** - * 日期判定(`''` = 通过,否则是失败原因)。 - * - * 与 `type: 'date'` 列内置的那条规则**是同一个函数**:业务在保存前做提交拦截时 - * 应当复用它。判据与 `G3DatePicker` 解析用户手输用的是同一个 `parseDate`, - * 所以不会出现「组件认为合法、校验认为非法」这种两边不一致。 - */ -export { checkDate } from './utils/date-value' -/** - * 底座的数字格式化(本组件 `col.format` 的对象形态就是它),供业务在表格之外 - * 复用同一套格式化(导出、汇总行、打印等),避免两处格式不一致。 - */ -export { formatNumber } from 'stk-table-vue' -export type { FormatNumberOptions } from 'stk-table-vue' export type { G3TableColumn, TableAction, - TableAreaRange, TableColResizableConfig, TableColumnDraggableConfig, TableEditor, + TableEditorType, TableEmits, TableExpose, TableFooterConfig, + TableInputType, TableProps, TableRow, TableRowKey, TableRowSelection, - TableScrollbarConfig, - TableScrollRowByRow, TableSelectOption, TableSeqConfig, } from './types' diff --git a/code/g3soft-libs/packages/ui/src/table/style/_overrides.scss b/code/g3soft-libs/packages/ui/src/table/style/_overrides.scss index 930fc01b..cee8b11a 100644 --- a/code/g3soft-libs/packages/ui/src/table/style/_overrides.scss +++ b/code/g3soft-libs/packages/ui/src/table/style/_overrides.scss @@ -1,65 +1,37 @@ -// G3Table 覆盖层:把 stk-table-vue 的主题变量映射到 @g3soft 令牌体系。 +// G3Table 主题桥:把 stk-table-vue 的主题变量映射到 @g3soft 令牌体系。 // // 为什么改变量而不是改选择器: -// 库把 41 个主题值全部做成了 `.stk-table` 作用域内的 CSS 变量(实测见 style.css)。 -// 覆盖变量既能一次改全套(hover、斑马纹、排序箭头、滚动条…), -// 又不会因为库内部 DOM 结构调整而失效。 +// 库把主题值全部做成了 `.stk-table` 作用域内的 CSS 变量。覆盖变量既能一次改全套 +// (hover、斑马纹、排序箭头、滚动条…),又不会因为库内部 DOM 结构调整而失效。 // -// ⚠ 唯一例外是**边框**(外框 + 横线):那部分由组件自绘,不走变量路线, -// 见下方「边框」一节。底座的边框画法做不出圆角,也不是「改一个变量」能解决的 -// ——它更像结构问题,硬套变量映射只会把改动散到四处。 +// 这与 fms 的做法同源 —— fms 在 FmsTable.vue 里用 `customVars` 把 `--th-bgc` / +// `--tf-bgc` 指到 `--fms-*` 业务变量;这里把同一件事做全(映射整张变量表), +// 因为组件库的消费方不拥有这张表,得由库统一给出。 // // 所有令牌都带 fallback:未加载 @g3soft/tokens 时组件仍可正常渲染。 @use '@g3soft/tokens/scss' as *; // 选择器说明:内层 `` 渲染后自带库的 `.stk-table` 类, -// 所以这里写 `.g3-table__inner.stk-table`(双类,权重 (0,2,0))去压库默认的 `.stk-table` -// ((0,1,0))。注意 `.stk-table` 是库的固定类名,**不能**用 b('table') 生成—— -// 那会得到 `.g3-table`,而内层元素上并没有这个类,覆盖会静默失效。 +// 所以这里写 `.g3-table__inner.stk-table`(双类)去压库默认的 `.stk-table`。 +// 注意 `.stk-table` 是库的固定类名,**不能**用 b('table') 生成 —— 那会得到 +// `.g3-table`,而内层元素上并没有这个类,覆盖会静默失效。 .#{b('table')} { .#{e('table', 'inner')}.stk-table { - // ── 尺寸:行高由组件以行内 style 注入(唯一的真源),这里只兜底结构尺寸 + // ── 尺寸 --cell-padding-x: #{v('space-3', 12px)}; --cell-padding-y: 0; --border-width: 1px; --resize-handle-width: 4px; // ── 边框与底色 - // 库用 --border-color 派生 --bg-border-* 四条渐变(top/right/bottom/left)。 - // 改这一个变量,四条边线一起跟随——不需要像 fms 那样在 body 上手写 - // 三个 --bg-border-* 覆盖(那是在跟派生关系较劲,而且写到了全局作用域)。 + // 库用 --border-color 派生 --bg-border-* 四条渐变,改这一个变量四条边线一起跟随。 --border-color: #{v('border-color', #d9d9d9)}; --td-bgc: #{v('bg-container', #fff)}; - // ── 纵向格线:**表体**关闭,整表只留横向行分隔线(设计决定) - // 库给每个 th/td 铺的是 `background-image: var(--bg-border-right), var(--bg-border-bottom)` - // (style.css 的 `.stk-table.bordered th, td`),所以关竖线就是把这**一个变量** - // 涂透明:横线(--bg-border-bottom)与自绘外框(根节点上的真 border)都不受影响。 - // 左固定列的左边线是它在 `.fixed-cell--border-left` 分支里单独叠的 - // --bg-border-left,一并关掉 —— 冻结列与滚过去的列之间的边界改由 - // fixedColShadow 的渐变承载(见文件末尾「固定列」一节)。 - // 层级:这里是 (0,3,0),压得住库在 `.stk-table` / `.stk-table.bordered-h` - // 上对这两个变量的定义。 - // ⚠ 表头是例外:它自己接管 background-image,画一条**不占满高度**的短竖线 —— - // 既不经过这两个变量、也不用格线色,见「边框」一节末尾。 - --bg-border-right: linear-gradient(transparent, transparent); - --bg-border-left: linear-gradient(transparent, transparent); - - // ── 表头列界线的颜色:比格线更轻 - // 格线走 --border-color(#d9d9d9,不透明),在 #f5f5f5 的表头底色上偏重; - // 这里用同色系、只叠 8% 的黑(叠出来≈ #e1e1e1),弱一档但不至于看不见。 - // 暗色块里换成 12% 的白(见下)。 - --g3-table-header-divider: rgba(0, 0, 0, 0.08); - - // 表头 / 表尾底色:这里**不设** --th-bgc / --tf-bgc。 - // 库那两个变量是 alpha 值、且在 sticky 滚动时会透出下方行内容; - // 改为在文件末尾直接给 th / 表尾 td 一个不透明实色(含完整原因与实测数据)。 --th-color: #{v('text-1', rgba(0, 0, 0, 0.88))}; --stripe-bgc: #{v('fill', rgba(0, 0, 0, 0.02))}; // ── hover / 选中:走令牌,不用库默认的蓝色系(hsl(207, ...)) - // 库默认 --td-hover-color 是 hsl(207,90%,70%)、--tr-hover-bgc 是 #e6f7ff, - // 那是另一套蓝色主题,与我们的主色(默认 zinc-900,可换肤)不一致。 --tr-hover-bgc: #{v('fill', rgba(0, 0, 0, 0.04))}; --tr-active-bgc: #{v('color-primary-soft', rgba(24, 24, 27, 0.12))}; --td-hover-color: #{v('fill-hover', rgba(0, 0, 0, 0.08))}; @@ -75,7 +47,7 @@ --fold-icon-color: #{v('text-3', rgba(0, 0, 0, 0.45))}; --fold-icon-hover-color: #{v('text-1', rgba(0, 0, 0, 0.88))}; - // ── 固定列阴影:用主色系中性阴影,与浮层阴影同一档 + // ── 固定列阴影 --fixed-col-shadow-color-from: rgba(0, 0, 0, 0.1); --fixed-col-shadow-color-to: rgba(0, 0, 0, 0); @@ -96,31 +68,23 @@ // ── 单元格高亮(程序化 highlight) --highlight-color: #{v('color-primary', #18181b)}; - // ── 空态底色:库的 `.stk-table-no-data` 用它当背景。 - // 我们不传 empty 插槽时由组件渲染 `.g3-table__empty`,但库的空态节点仍在 - // (noDataFull 时铺满),漏掉会让底色停在库默认的 #fff/dark 上,与本库主题不一致。 + // ── 空态底色:库的 `.stk-table-no-data` 用它当背景 --table-bgc: #{v('bg-container', #fff)}; - // ── 树形懒加载 loading 图标颜色(1.2.x 新增,1.1.0 没有) - --tree-loading-color: #{v('text-3', rgba(0, 0, 0, 0.45))}; - - // ── 字体:库用 inherit,这里显式跟随令牌 + // ── 字体 font-family: v('font-family', inherit); font-size: v('font-size', 14px); color: v('text-1', rgba(0, 0, 0, 0.88)); } } -// ── 暗色主题:库自带 `.stk-table.dark` 的一套默认值,同样以变量覆盖 -// 组件会把 theme 透传给库(跟随 ConfigProvider / documentElement)。 +// ── 暗色主题:库自带 `.stk-table.dark` 的一套默认值,同样以变量覆盖。 +// 组件会把 theme 透传给库(跟随 documentElement[data-g3-theme] 或显式 prop)。 .#{b('table')} { .#{e('table', 'inner')}.stk-table.dark { --border-color: #{v('border-color', #424242)}; --td-bgc: #{v('bg-container', #141414)}; - // 表头列界:暗色下换成 12% 的白(对应亮色那 8% 的黑,见文件开头的基础变量块) - --g3-table-header-divider: rgba(255, 255, 255, 0.12); - // 表头 / 表尾底色改在文件末尾统一处理(不透明基底 + fill 叠加),此处不设 --th-color: #{v('text-1', rgba(255, 255, 255, 0.88))}; --stripe-bgc: #{v('fill', rgba(255, 255, 255, 0.04))}; @@ -152,393 +116,6 @@ --rs-bc: #{v('color-primary', #e5e5e5)}; --highlight-color: #{v('color-primary', #e5e5e5)}; - - // 与亮色同因:空态底色 + 树形懒加载图标色(见上方亮色块注释) --table-bgc: #{v('bg-container', #141414)}; - --tree-loading-color: #{v('text-3', rgba(255, 255, 255, 0.45))}; } } - -// ========================================================================== -// 边框:外框圆角描边 + 行分隔线由组件自绘,纵向列线只在表头保留 -// -------------------------------------------------------------------------- -// 底座不用 `border` 画线,而是把四条边做成 `background-image` 渐变 -// (--bg-border-*,由 --border-color 派生),并给 `bordered` 提供三档: -// true=外框+横竖线 / 'h'=只要横线 / 'v'=只要竖线 / false=全不要。 -// 这套画法有两处绕不过去: -// · 外框是「`border-left` + 三段背景渐变」拼出来的,**做不出圆角**;且底座文档 -// (table/basic/bordered)写明:受滚动条占位影响,表格**右侧与底部的边框会 -// 消失**,需要时自行补 CSS; -// · 我们只要横向行分隔线,纵向列线**只在表头保留**:表体靠横线分行、保持干净, -// 表头的列界留着(拖拽列宽、认列都靠它,见本节末尾)。底座的 'h' 档虽然也能 -// 关表体竖线,但它顺手把外框的 `border-left` 置 none、并保留 --bg-border-top -// 渐变(与自绘外框叠成双线),所以档位仍用 'v',竖线改由覆盖那两个变量来关。 -// -// 于是这里定成:**外框改用真 border 自绘,横线保持底座的背景渐变画法, -// 竖线对表体置透明、在表头 th 上再恢复回来**。 -// 外框 → `.g3-table.is-bordered` 上的真 `border` + 圆角令牌(外层容器已有 -// `overflow: hidden`,圆角能把内部内容裁掉); -// 横线 → 把 `bordered-v` 清零的 --bg-border-bottom **恢复成底座那份配方**; -// 竖线 → 表体 td:基础变量块的 --bg-border-right / --bg-border-left 涂透明; -// 表头 th:本节末尾把这两个变量重设回底座配方(含左固定列的 -// --bg-border-left 分支)。 -// -// 组件侧只负责把 `bordered` 翻成 `true` / `'v'` / `false`(见 G3Table.vue 的 -// `:bordered` 绑定)——`'v'` 必然同时带上 `.bordered` 与 `.bordered-v` 两个类 -// (底座 `{[`bordered-${bordered}`]: typeof bordered === 'string'}`), -// 所以下面既要清掉 `.bordered` 画的外框,也要接受 `.bordered-v` 关掉的上下渐变 -// (只把横线那条拿回来,`--bg-border-top` 继续透明,免得与自绘外框叠成双线)。 -// -// ⚠⚠ 格线**绝对不能**改成真 `border-bottom` / `border-top`(这是第一版踩的坑, -// e2e `行内编辑 › 进入编辑:行高不变` 当场变红):行内编辑器被钉死在 -// `var(--row-height)`(30px,见 G3Table.vue 的 editorSeamless 区块),而 td 的 -// 高度是「按内容撑开」的 —— 一旦 td 多出 1px 边线,内容盒只剩 29px 放不下 30px -// 的编辑器,整行被顶到 31px。行高是虚拟滚动 `translateY` 的计算基准,涨 1px -// 会一路累积成行错位。**背景渐变不参与盒模型**,所以格线只能走 background-image。 -// -// ⚠ 颜色统一走 `--g3-border-color` 令牌,亮 / 暗主题自动跟随,别再写死色值。 -// ========================================================================== - -// 外框:只有 `bordered` 为真才有(false = 嵌进卡片,只留行分隔线)。 -// `box-sizing: border-box` 必须有:根节点默认是 content-box,加 1px 描边会让它 -// 比父容器高 2px(`:not(.is-bordered)` 时不描边,故不设)。 -// -// 圆角与外框都画在**根节点**上,靠一层真正的不透明底把内部内容挡住: -// 见下面 `.g3-table__inner` 的 background-color。**不要**再给根节点加 -// `overflow: hidden` —— 库的浮层(列宽拖拽把手、筛选下拉、区域选择框)都 -// 挂在滚动容器内部,裁掉会让它们被切边。 -.#{b('table')}.#{is('bordered')} { - box-sizing: border-box; - border: 1px solid #{v('border-color', #d9d9d9)}; - border-radius: v('radius-lg', 8px); -} - -.#{b('table')} { - .#{e('table', 'inner')}.stk-table { - // 横线:拿回 `bordered-v` 清零的那条。配方照抄底座(style.css 的 - // `--bg-border-bottom`),因为这是它的变量、也只能按它的语法写。 - // - // 两条规则都挂在 `.bordered-v` 下:它只在真开了边框时才存在(见 G3Table.vue - // 的 `:bordered`),所以 `bordered: false` 的无框模式不会被这里添线。 - &.bordered-v { - --bg-border-bottom: linear-gradient( - 0deg, - var(--border-color) var(--border-width), - transparent var(--border-width) - ); - - // 合计行:分隔线画在 footer 单元格的**上**边(数据区最后一行被 sticky 的 - // footer 压住,它那条横线指望不上)。同样用渐变,理由见上。 - .stk-footer td { - background-image: - var(--bg-border-right), - linear-gradient( - 180deg, - var(--border-color) var(--border-width), - transparent var(--border-width) - ); - } - } - } - - // 去掉底座的外框:根节点上那条 `border-left` 与三段背景渐变(外框已由上面画)。 - .#{e('table', 'inner')}.stk-table.bordered-v { - border-left: none; - background-image: none; - } -} - -// ── 表头列界:自己接管 background-image,画一条**不占满高度**的短竖线 -// 为什么只给表头留列界:认列、拖列宽都落在表头上;表体靠横线与行高分行, -// 多一条竖线只是噪音(表体已在文件开头全局置透明)。 -// -// 为什么不用库的 --bg-border-right(它也是竖线): -// · 它是**整格高**的 —— 铺满 th 的整个背景区,而表头行高比数据行还大, -// 上下顶格在视觉上很硬。这里把线压到 `calc(100% - 8px)`、上下各留 4px。 -// · 它的色值来自 --border-color(与格线同源),在 245 的表头底色上偏重, -// 改用更轻的 --g3-table-header-divider(见基础变量块)。 -// -// 为什么必须「接管整个 background-image」而不是只改其中一层: -// background-image / -size / -position 都是**按层一一对应**的列表属性,只想动 -// 其中一层的高度与位置,就得把 size / position 的列表也全部补齐 —— 而库那套层数 -// 本身就不固定(表头首行是 top/right/bottom 三层,多级表头的其它行只有两层), -// 对不齐就会把某一层的尺寸套到另一层上(实测会把竖线铺成整格色块)。 -// 与其跟库的层数对齐,不如直接声明我们要的两层 —— 第 2 层仍引用库的横线配方, -// 横向分隔线保持原样。 -// -// ⚠ 限定 `.bordered`:无框模式(`bordered: false`)不该出现表头线(横线同理)。 -// ⚠ 排除 `.fixed-cell--right`:贴住外框内右缘的右固定列,那条线会与外框的 border -// 并排成 2px。被排除的 th 走库的原路径(透明竖线 + 我们恢复的横线),无需另写规则。 -// ⚠ `100%` 是**物理**右缘:RTL 下这两列的归属会换边,但相邻两列之间仍然只有一条线, -// 视觉位置不受影响。 -.#{b('table')} { - .#{e('table', 'inner')}.stk-table.bordered thead th:not(.fixed-cell--right) { - background-image: - linear-gradient(var(--g3-table-header-divider), var(--g3-table-header-divider)), - var(--bg-border-bottom); - background-size: 1px calc(100% - 8px), 100% 100%; - background-position: 100% 4px, 0 0; - background-repeat: no-repeat; - } -} - -// ========================================================================== -// 最后一行横线:为什么不能再「按 :last-child 摘线」 -// -------------------------------------------------------------------------- -// 横线由每个单元格自己的 --bg-border-bottom 画。 -// **判据不能是「是不是最后一行」,而是「这行的底边有没有贴住外框内底边」**—— -// 二者只有行区铺满时才等价。实测(180px 高容器): -// · 铺满时:最后一行 bottom 与外框内底边重合 → 线该交给外框(否则叠成 2px); -// · 没铺满:只放 1 行 30px 数据时,该行 bottom=440、外框内底边=554, -// **差了 114px 够不着** → 这行必须自己画线,否则唯一那条分隔线凭空消失。 -// 纯 CSS 表达不了「行区高度是否等于可用高度」,所以由组件侧量出结果、 -// 用 `.g3-table.is-body-fill` 这个类传进来(见 G3Table.vue 的 measureBodyFill)。 -.#{b('table')}.#{is('bordered')} { - // 兜底:行区与外框之间的空隙铺上容器底色,避免露出突兀的空带。 - // 这层底必须**不透明**,否则圆角处会透出内部内容。 - background-color: v('bg-container', #fff); - - .#{e('table', 'inner')}.stk-table.bordered-v { - // ── 表头右固定列不用在这里单独处理:上面那条表头列界规则已用 - // `:not(.fixed-cell--right)` 把它排除掉了。 - // 「DOM 末列」**不排除**:它才是拖拽该列宽度的落点,列宽没铺满容器时也靠它 - // 指出末列到哪儿结束(历史踩坑,见本文件 git 记录)。 - - // ── 空态:底边线归外框。 - // 库的 `.stk-table-no-data.no-data-full` 会撑满剩余高度并**自带一条 - // border-bottom**,与外框的 border-bottom 落在同一条 y 上(实测都在 218) - // → 两条线叠在一起,看着就是「底部重叠」。这里把它的底边交还给外框。 - .stk-table-no-data { - border-bottom: none; - } - } - - // ── 仅当行区真的铺满容器时,才把数据区最后一行的底边线交给外框。 - // 没铺满时保留每行自己的线(缺线比双线更难排查,优先保证有线)。 - &.#{is('body-fill')} { - .#{e('table', 'inner')}.stk-table.bordered-v .stk-tbody-main tr:last-child > td { - --bg-border-bottom: linear-gradient(transparent, transparent); - } - } -} - - -// ========================================================================== -// 表头 / 表尾:不透明底色(防 sticky 滚动时透出下方内容) -// -------------------------------------------------------------------------- -// 症状:虚拟滚动 demo 里滚动时能看到表体行的内容从表头下面一闪而过; -// 静止时看不出来(下方恰好是纯白行)。 -// -// 根因:库把表头底色做成 **alpha** 值并画在 `thead tr` 上: -// .stk-table thead tr { background-color: var(--th-bgc) } -// .stk-table th { background-color: inherit } ← th 继承这份半透明色 -// th 又是 position:sticky,滚动时下方行高速掠过,浏览器必须把表头每个像素 -// 与「此刻经过的那一行」实时合成 —— 于是内容透出来。 -// -// ⚠ 踩过的坑(别重犯):曾试图在 `thead tr` 上铺「实色 + fill 渐变」两层, -// 结果表头渲染成**纯白 255**(本该是 245)——因为 th 自己还有一层 -// background-image(库用它画边框渐变,`.stk-table.bordered th`), -// th 的背景绘制在 tr 之上,把 tr 的渐变压掉了。 -// 实测三种写法(截图取主色): -// 写在 tr(实色+渐变) → 255,255,255 ❌ 被 th 盖住 -// 写在 th(直接给实色 #f5f5f5)→ 245,245,245 ✅ -// 写在 th(白底+4%渐变) → 245,245,245 ✅ -// 故采用最简单的一种:直接给 th 一个不透明实色。 -// -// 令牌体系里 fill 全是 alpha(rgba),没有「不透明的浅灰面」这一类;而 v() 返回 -// var(...) 字符串,编译期拿不到色值、Sass mix() 也用不上。所以这里写死实色: -// 245,245,245 正是 rgba(0,0,0,.04) 叠在白底上的等效结果,观感与改动前一致。 -// 暗色侧同理(rgba(255,255,255,.04) 叠 #141414 ≈ 20,20,20)。 -.#{b('table')} { - .#{e('table', 'inner')}.stk-table { - thead th, - .stk-footer td { - background-color: #f5f5f5; - } - } -} - -.#{b('table')} { - .#{e('table', 'inner')}.stk-table.dark { - thead th, - .stk-footer td { - background-color: #141414; - } - } -} - -// ========================================================================== -// 固定列:不透明底色 + 行状态叠色(防横向滚动时透出下方内容) -// -------------------------------------------------------------------------- -// ⚠ 配套前提:**表体**的竖线已在文件开头的「纵向格线」块里关闭(表头仍保留 —— -// 那是给「认列 / 拖列宽」用的),所以冻结列与滚过去的列之间在数据区没有任何 -// 格线 —— 它们的边界只剩 `fixedColShadow`(10px 渐变)能表达。用到固定列的 -// 页面务必打开这个 prop,否则两边同为不透明基色、紧贴在一起时会被读成一整列。 -// -// 症状:鼠标悬停、或正在编辑(当前行)的那一行里,**冻结列是半透明的** —— -// 被横向滚过去的列从冻结列底下透出来(用户报的「客户这列是透明的」; -// 静止时不易发现,一横滚就是「透字」)。 -// -// 根因:库给固定列的是 `background-color: inherit`(继承 tr 的行底色), -// 而行的 hover / 当前行底色是 **alpha 值**: -// --tr-hover-bgc = rgba(0, 0, 0, 0.04)(令牌 fill 就是 alpha) -// --tr-active-bgc = color-primary-soft(应用皮肤常给 hsl(... / 0.15)) -// 非固定列继承它没问题 —— tr 底下就是表格基色,合成结果是不透明的; -// 冻结列却在**被横滚的列之上**,它自己必须不透明。 -// -// 修法:冻结列底色写死成不透明的 `--td-bgc`(亮 #fff / 暗 #141414), -// 行状态再用**内阴影**叠一层同色。为什么是内阴影而不是别的: -// · 不能用 `background-image` 叠渐变 —— 会压掉库用 background-image -// 画的边框渐变(本文件表头那节已踩过这个坑,见上方 ⚠); -// · 不能用伪元素 —— 绝对定位的伪元素要求 td 是定位元素,而固定列的 -// `position` 被库占着(sticky + 内联 left/right),static 时伪元素 -// 会挂到外层容器上、把整表涂色; -// · 内阴影画在**背景之上、内容之下**:不遮文字、不动几何、不需要定位, -// 半透明色叠在不透明底上,观感与整行一致。 -// -// 只覆盖 hover 与当前行两个状态:拖拽掠过 / 区域选择行用的是另外的变量 -// (--tr-drag-over / --rs-bgc),本规则不动它们,保持库的默认表现。 -// -// ⚠️ 叠色必须**带上库的选区描边**(下面的 $cs-border): -// `box-shadow` 是单值属性,只写叠色那一条会把库画在 td 上的选中框整体替换掉, -// 症状是「冻结列上的选中格(正在编辑那一格)没有蓝色选中边」——非冻结列没有 -// 本规则,所以只有冻结列会缺,很容易被当成「选中态没生效」。 -// 库的原式:`.stk-table td[data-cs-s] { box-shadow: inset 0 var(--cs-t,0) 0 0 -// var(--cs-bc), … }`,其中 --cs-t/b/l/r 由库按「这条边是不是选区边界」内联给出 -// (非选区边界为 0),--cs-bc 是选区描边色(主题变量)。 -// 顺序:先写描边、后写叠色 —— box-shadow 里**前面的画在上层**,叠色若排在后面 -// 才压不住描边。 -$cs-border: inset 0 var(--cs-t, 0) 0 0 var(--cs-bc, transparent), - inset 0 var(--cs-b, 0) 0 0 var(--cs-bc, transparent), - inset var(--cs-l, 0) 0 0 0 var(--cs-bc, transparent), - inset var(--cs-r, 0) 0 0 0 var(--cs-bc, transparent); - -// ========================================================================== -// 固定列:让「是否吸附」恒定,不再由滚动位置决定(修横滚/拖滚动条抖动) -// -------------------------------------------------------------------------- -// 症状:横向滚动、或拖动横向滚动条时,冻结列不是稳稳钉住,而是**来回抖动** -// (吸附一下就弹回,再吸附一下)。 -// -// 根因:库把 `position: sticky` **只挂在 `.fixed-cell--active` 上** -// (style.less 的 `.fixed-cell--active { position: sticky }`), -// 而这个类是由**滚动位置算出来的**,见 useFixedCol.ts 的 updateFixedShadow: -// · 左固定列:`if (isFixedLeft && position + scrollLeft > left)` -// · 右固定列:`const needFix = scrollLeft + clientWidth - left < position` -// 于是滚动过程中该 class 被反复增删 → `position` 在 sticky / static 之间跳变 -// → 每一帧「吸附 ↔ 脱离」交替,视觉上就是抖动。 -// -// 关键:库其实**已经把内联 left/right 无条件写在了 `.fixed-cell` 上** -// (useFixedStyle.ts:`if (fixed) { … left/right = getFixedColPosition(col) }`, -// 只看 col.fixed,与滚动位置无关),且该值来自**声明列宽累加**、静态可靠。 -// 也就是说坐标一直是对的、一直在,**缺的只是那条 position: sticky**。 -// -// 修法:给所有 `.fixed-cell` 补恒常 sticky。`fixed` 是静态配置,是否吸附本来 -// 就不该依赖滚动位置——这样无论怎么滚都不会再翻转 class。 -// -// ⚠️ z-index 必须**一起补**,这是本修法唯一的陷阱: -// 库的 z-index 同样只挂在 `--active` 上(style.less): -// th.fixed-cell--active { z-index: 3 } -// td.fixed-cell--active { z-index: 1 } -// .stk-footer td.fixed-cell--active { z-index: 2 } -// 只补 position 会造出「sticky 但没有 z-index」的单元格:它参与了吸附定位, -// 却不再创建层叠上下文,**会被横滚过去的普通单元格盖住**(症状是冻结列内容 -// 时隐时现、被别的列压住)。所以这里按库同样的三档补齐,取值照抄,保证 -// `--active` 加上去时与不加时视觉完全一致。 -// -// ⚠️ 必须是 sticky 而不是 absolute:absolute 会脱离文档流,单元格宽度不再参与 -// 表格布局,列宽会塌掉。sticky 保留占位。 -// ⚠️ 只加 position / z-index,不动 left/right —— 那两个值由库内联给出,这里写死 -// 会破坏「按声明列宽累加」的计算(多级表头/多固定列下每列偏移都不同)。 -// -// -------------------------------------------------------------------------- -// 🧪 A/B 开关:本段补丁的作用**尚未经过实测确认**(见上方的背景说明)。 -// 在页面 URL 后加 `?g3-fixed-patch=off` 即可整体关掉它,用于对照: -// · 开(默认):所有固定列恒常 sticky -// · 关(?g3-fixed-patch=off):回到库的默认行为(只有 --active 才 sticky) -// 对照结论确定后,应当**删掉这个开关**并只保留被验证有效的那一种写法。 -// -------------------------------------------------------------------------- -.#{b('table')}:not(.g3-fixed-patch-off) { - .#{e('table', 'inner')}.stk-table { - thead th.fixed-cell { - position: sticky; - z-index: 3; - } - - .stk-tbody-main td.fixed-cell { - position: sticky; - z-index: 1; - } - - .stk-footer td.fixed-cell { - position: sticky; - z-index: 2; - } - } -} - -// 编辑格**不参与**行状态叠色:它与普通列的编辑格必须长得一样(纯基色 + 焦点环)。 -// 普通列的编辑格由 G3Table 的规则刷成不透明的 `--td-bgc`、不叠行色;冻结列若再叠一层, -// 同一行里就会出现「冻结列的编辑格有色、普通列的编辑格纯色」这种一眼可见的不一致。 -$editing-cell: ':not(:has(> .#{e('table', 'cell-editor-root')}.#{is('cell-editing')}))'; - -// 「这一行是条纹行,且没有别的行状态接管它的底色」——供下面的 stripe 段复用。 -// 判据与库一致:条纹行由库按 nth-child 决定(纵向虚拟时取 odd,见 style 里的说明); -// 不含 :hover / .active / [data-rs-s]:这些状态是**替换**行底色,条纹该让位。 -$striped-row: ':not(:hover):not(.active):not([data-rs-s])'; - -.#{b('table')} { - .#{e('table', 'inner')}.stk-table { - .stk-tbody-main td.fixed-cell { - background-color: var(--td-bgc, #{v('bg-container', #fff)}); - } - - // 与库同一判据:hover 底色本身也是被 .row-hover 门控的(选择器照抄库那条) - &.row-hover .stk-tbody-main tr:hover > td.fixed-cell#{$editing-cell} { - box-shadow: $cs-border, inset 0 0 0 100vmax var(--tr-hover-bgc); - } - } -} - -// 当前行(.active):**必须带上 `.row-active`**,否则权重低于上面那条 hover -// (`:hover` 是伪类,和类同档 —— hover 那条 (0,7,2),不带 row-active 的这条 -// 只有 (0,6,2)),会出现「当前行 + 鼠标悬停时,冻结列显示的是 hover 色」。 -// 写成 (0,7,2) 与其平手,再靠源序(本块在其后)取胜。 -.#{b('table')} { - .#{e('table', 'inner')}.stk-table.row-active { - .stk-tbody-main tr.active > td.fixed-cell#{$editing-cell} { - box-shadow: $cs-border, inset 0 0 0 100vmax var(--tr-active-bgc); - } - } -} - -// -------------------------------------------------------------------------- -// 斑马纹(stripe):冻结列必须自己接上条纹 -// -// 库只把条纹画在 `tr` 上(`.stk-table.stripe … .stk-tbody-main tr:nth-child(even)` -// 用 background-color: var(--stripe-bgc)),而冻结列的单元格被上面那条规则 -// 写死成**不透明的** `--td-bgc` —— 于是条纹被盖住,**只有冻结列没有斑马纹** -// (序号列首当其冲;宿主页自己有行斑马纹样式时症状一模一样,那是文档站踩到的 -// VitePress `.vp-doc tr:nth-child(2n)` 泄漏)。 -// -// 修法与行状态同款:不透明底 + 内阴影叠条纹色。 -// ⚠️ 不能直接把 background-color 设成 `--stripe-bgc` —— 那是 **alpha** 色 -// (亮 rgba(0,0,0,.02) / 暗 rgba(255,255,255,.04)),拿它当底色等于把冻结列 -// 变回半透明,正是本文件开头那个「横滚透字」bug。 -// -// ⚠️ 排除三种行:`:hover` / `.active` 由上面的内阴影叠色表达,再压一层条纹会让 -// 冻结列比同行普通列更暗(库的 hover / 当前行是**替换** tr 底色,不是叠加); -// `[data-rs-s]`(区域选择行)用 --rs-bgc,本规则不参与,保持库的默认表现。 -// 编辑格同样排除:它要与普通列的编辑格长得一样(纯基色 + 焦点环)。 -// -// ⚠️ 必须带 $cs-border:box-shadow 是单值属性,只写叠色会把库画在 td 上的选区 -// 描边整体替换掉(症状:冻结列上的选中格没有蓝色选中边)。 -// -------------------------------------------------------------------------- -.#{b('table')} { - .#{e('table', 'inner')}.stk-table.stripe { - &:not(.vt-on) .stk-tbody-main tr:nth-child(even)#{$striped-row} > td.fixed-cell#{$editing-cell}, - &.vt-on .stk-tbody-main tr:nth-child(odd)#{$striped-row} > td.fixed-cell#{$editing-cell} { - box-shadow: $cs-border, inset 0 0 0 100vmax var(--stripe-bgc); - } - } -} - diff --git a/code/g3soft-libs/packages/ui/src/table/style/index.scss b/code/g3soft-libs/packages/ui/src/table/style/index.scss index 4ba3750e..e1e60e73 100644 --- a/code/g3soft-libs/packages/ui/src/table/style/index.scss +++ b/code/g3soft-libs/packages/ui/src/table/style/index.scss @@ -1,11 +1,11 @@ // G3Table 的底座样式层。 // // stk-table-vue 的样式是**独立文件**(实测其 JS 里没有任何 .css import), -// 组件库不能要求消费方额外引一次第三方样式,所以在这里 @import 进来, +// 组件库不能要求消费方额外引一次第三方样式,所以在这里 @use 进来, // 与我们的覆盖层一起由 vite 打进 dist/style.css。 // // 覆盖策略:**优先覆盖库的 CSS 变量,而不是逐个选择器改样式**。 -// 库把 41 个主题值全做成了 .stk-table 作用域内的变量,改变量既省事, +// 库把主题值全做成了 .stk-table 作用域内的变量,改变量既省事, // 也不会因为库内部结构调整而失效(改选择器则会)。 @use '@g3soft/tokens/scss' as *; @@ -14,7 +14,6 @@ // ⚠️ 路径**不能带 `.css` 后缀**:Sass 对以 .css 结尾的 @import/@use 有特殊规则—— // 它不会内联,只原样输出一条 CSS `@import 'stk-table-vue/lib/style.css'` 语句。 // 那样产物里留下的是一条裸包路径,浏览器无法解析,线上直接掉样式。 -// 实测对比:带后缀产出 38 字符(仅一条 import 语句),不带后缀产出 19012 字符(真正内联)。 // 同理用 @use 而非 @import:后者在 Dart Sass 3.0 会被移除。 @use 'stk-table-vue/lib/style'; diff --git a/code/g3soft-libs/packages/ui/src/table/types.ts b/code/g3soft-libs/packages/ui/src/table/types.ts index 5668903f..d3eb7a71 100644 --- a/code/g3soft-libs/packages/ui/src/table/types.ts +++ b/code/g3soft-libs/packages/ui/src/table/types.ts @@ -1,77 +1,65 @@ -import type { Component, VNode } from 'vue' -import type { FormatNumberOptions, StkTableColumn } from 'stk-table-vue' +import type { StkTableColumn } from 'stk-table-vue' +import type { Component } from 'vue' /** - * G3Table 类型定义。 + * G3Table 类型定义 —— **照抄 fms-vue 的 `fms-table`**。 * * 底座是 `stk-table-vue`:虚拟滚动 / 列宽拖拽 / 列序拖拽 / 区域选择 / 行拖拽排序 - * 都由它提供。这里**不重写**它的列类型,而是 `Omit` 后扩展——重写会随库版本漂移。 + * 全部由它提供。本组件只做三件事:**性能门控**、**内部列管理**、**行内编辑态**。 + * + * ⚠️ 与重构前的 G3Table 差别(迁移时必看): + * · `editor.type` 的值域是 `'input' | 'select' | 'checkbox'`(不是 `'text' | 'number' | ...`), + * 输入框的细分形态走 `editor.inputType`; + * · **没有** `validate` / `validateWarning` / `precision` / `errorStyle` / `format`, + * 也没有 `type: 'date'` 编辑器与 Tab / 方向键跳格 —— 这些是旧版自有能力, + * 本次按 fms 对齐后移除。 */ -/** 行数据的约束:必须是对象,且键为字符串 */ +/** 行数据类型:必须是对象,键为字符串 */ export type TableRow = Record /** 行唯一键:字段名或取值函数 */ -export type TableRowKey = keyof T & string | ((row: T) => string | number) +export type TableRowKey = + | (keyof T & string) + | ((row: T) => string | number) -/** 下拉选项(行内编辑 select 用) */ +/** select 选项 */ export interface TableSelectOption { label: string value: string | number | boolean | null disabled?: boolean } -/** 编辑器校验态:'' 正常,'error' 危险,'warning' 提醒 */ -export type TableEditorStatus = '' | 'error' | 'warning' +/** + * 编辑器类型。 + * + * `'input'` 是**统称**,具体形态由 `inputType` 决定(fms 的约定): + * `{ type: 'input' }` → 普通单行文本 + * `{ type: 'input', inputType: 'number' }` → 数字输入 + * `{ type: 'input', inputType: 'password' }` + * `{ type: 'input', inputType: 'textarea' }` + */ +export type TableEditorType = 'input' | 'select' | 'checkbox' + +/** input 编辑器的细分形态 */ +export type TableInputType = 'default' | 'text' | 'password' | 'number' | 'textarea' /** 行内编辑器配置 */ export interface TableEditor { - type: 'text' | 'number' | 'password' | 'textarea' | 'select' | 'checkbox' | 'date' /** - * 日期格式。**只对 `type: 'date'` 生效**,默认 `'YYYY-MM-DD'`。 - * - * 它同时决定**面板精度**与**绑定值格式**(G3DatePicker 的语义): - * · `'YYYY-MM-DD'` → 面板选到「日」为止; - * · `'YYYY-MM-DD HH:mm'` → 面板多出时间列,选完还要点「确定」才提交。 - * - * 值始终是**字符串、不做 Date ↔ string 的隐式转换**(与后端常见的 `2026-03-15` - * 一致);要另一种存储格式写 `valueFormat`(见下)。 + * 编辑器类型。 + * 不传 `type` 时按 `{ type: 'input' }` 处理(fms 里有 `editor:` 这种写法, + * 走默认的文本输入)。 */ - format?: string - /** - * 绑定值格式(仅 `type: 'date'`)。不传时跟随 `format`。 - * - * 只有「面板展示格式 ≠ 数据存储格式」时才需要它,例如界面显示 `2026-03-15`、 - * 但接口要 `20260315`:`{ format: 'YYYY-MM-DD', valueFormat: 'YYYYMMDD' }`。 - */ - valueFormat?: string - /** 占位文案(仅 `type: 'date'`)。不传则不显示占位。 */ - placeholder?: string - /** - * 数值精度(小数位数)。**只对 `type: 'number'` 生效**,不传 = 组件不做任何精度处理。 - * - * 语义是「这一列的数值规范」,组件拿它管两件事: - * · **展示**:非编辑态按它格式化(`1200.5` → `1200.50`); - * · **校验**:超出精度即 error(红底 + 左侧竖条),悬停浮层显示「最多 N 位小数」。 - * - * ⚠️ 组件**不改你的数据**:用户输了 `123.456` 而精度是 2,值**仍然是** `123.456`, - * 只是这一格被标红,由用户自己改成 `123.46`。静默四舍五入会让「屏幕上看到的」 - * 与「落库的」不是一回事 —— 财务对账时就是「我输的不是这个」。 - * ⚠️ 精度不合法时**不做格式化展示**(原样裸露真实值):否则按精度格式化成 - * `123.46`,屏幕上完全合规、数据却不合规,用户永远发现不了自己错了。 - * - * 传函数可按行给不同精度(与 `disabled` 同一模式),如不同币种/单位的小数位不同。 - * - * 录入时**不做实时拦截**(输到第 3 位小数就直接吞掉):那要和光标位置、输入法 - * 组合态、半输入态(`12.` / `-`)搏斗,是公认的坑;「输完再判」既确定又可预测。 - */ - precision?: number | ((row: T) => number) + type?: TableEditorType + /** input 的细分形态,仅 `type === 'input'` 生效 */ + inputType?: TableInputType /** * select 的选项。 - * 传函数时接收当前行——列选项依赖同行其它列时用它(如「按目标模块列出目标字段」)。 + * 传函数时接收当前行 —— 列选项依赖同行其它列时用它。 */ options?: TableSelectOption[] | ((row: T) => TableSelectOption[]) - /** 是否禁用该单元格编辑 */ + /** 是否禁用该单元格编辑(函数形态按行判定) */ disabled?: boolean | ((row: T) => boolean) /** select 是否可清空,默认 true */ allowClear?: boolean @@ -79,52 +67,6 @@ export interface TableEditor { emptyValue?: unknown /** 本列值变化时的回调(早于组件的 change 事件) */ onChange?: (value: unknown, row: T, col: G3TableColumn) => void - /** - * 校验:返回 falsy 表示通过;返回 string 表示不通过,且该字符串作为错误原因。 - * - * 为什么用「返回值即结论」而不是传统的 `validate(value) => boolean`: - * 表格里校验失败几乎总要告诉用户**为什么**("必须大于 0" / "编码已存在"), - * 布尔值会逼调用方再挂一个 message 字段,两者容易不同步。 - * 让失败原因直接坐在返回值上,只有一处真源。 - * - * 触发时机(与 G3Input 的 status prop 是两回事,见下方 status 的注释): - * 校验是**派生值**,不是一次性判定——每次渲染都按当前行数据重算, - * 所以用户把 qty 从空改成 12 后,红底与角标会在下一次渲染自动消失, - * 不需要业务手动去清状态,也不需要组件内部维护任何 error 注册表。 - * - * 性能:只在**编辑器真正挂载的那一行**上被调用(非编辑行走 displayValue, - * 压根不碰这个函数),因此普通滚动路径零开销。 - */ - validate?: (value: unknown, row: T, col: G3TableColumn) => 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) => boolean | string - /** - * 校验态的**展示**形态,与 validate 正交: - * - 不传(默认):在单元格(td)上铺「浅红底 / 浅琥珀底」;**正在编辑的那一格例外**—— - * 底色回到基色(铺底会让输入区变暗难读),校验信号改由语义色焦点环 + 红字承载。 - * - `'inline'`:只在编辑器自身画 1px 内描边 + 文字色,不铺底(任何状态下都不铺)。 - * - * 为什么留这个开关:浅红底来自设计原型 `.cell.is-error`,适合 ERP 明细这类 - * **逐格录入**的场景;但密度极高的只读表偶尔需要「很轻」的提示, - * 一铺底会把整行色带切碎。两种都是既有行为,用枚举而不是布尔, - * 便于将来再加第三种形态而不用改签名。 - * - * 注意:无论哪种形态,**文字色变红都照旧生效**(见 G3Table.vue 的 g3-input--error 规则)。 - */ - errorStyle?: 'cell' | 'inline' } /** @@ -137,16 +79,16 @@ export interface TableAction { key?: string | number /** 文案 */ label?: string - /** 语义类型:danger / delete 走危险色 */ + /** 语义类型:danger / delete 走危险色;more 收进「更多」下拉 */ type?: 'default' | 'danger' | 'delete' | 'more' - /** 图标组件(lucide),渲染为 size=14 */ + /** 图标组件,渲染为 size=14 */ icon?: Component title?: string danger?: boolean /** 是否显示该操作 */ visible?: boolean | ((row: T) => boolean) disabled?: boolean | ((row: T) => boolean) - /** 二次确认:字符串=标题;对象=完整配置 */ + /** 二次确认:字符串 = 标题;对象 = 完整配置 */ confirm?: | string | { @@ -160,53 +102,33 @@ export interface TableAction { width?: number maskClosable?: boolean } + /** 点击回调(带 confirm 时在确认后执行) */ onClick?: (row: T) => void - /** type === 'more' 时的子项 */ + /** `type: 'more'` 时的子项 */ items?: TableAction[] } /** - * 列定义:底座 `StkTableColumn` 的**严格超集**。 - * - * 刻意用 `&` 交叉而不是 `Omit` 重写字段:底座的 `sortField?: keyof T` 在 - * `T extends Record` 下会展开成 `string | number | symbol`, - * 一旦我们把它收窄成 `string`,`G3TableColumn` 就不再能赋值给 - * `StkTableColumn`,整棵列数组传不进库(会连锁报一串类型错误)。 - * 交叉则只增不改,天然兼容。 - * - * `dataIndex` 收紧为 `keyof T & string`:底座的 `keyof T & string` 本身就是这样, - * 这里显式写出仅为可读性,不产生新的不兼容。 + * 列定义:在底座 `StkTableColumn` 基础上追加本组件的字段 + * (`width` / `minWidth` / `fixed` / `align` / `headerAlign` / `sorter` / `sortType` / + * `children` / `mergeCells` 等一律原样透传)。 */ -/** - * 展示态格式化。**只影响显示** —— 落库的值始终是原始值,别拿展示文本回写数据。 - * - * 传选项对象 → 走底座的 `formatNumber`(千分位 / `¥` 前后缀 / 万-亿缩写 / 百分比); - * 传函数 → 完全自定义,`Intl.NumberFormat` 是正解(多语言的小数点与千分位符号、 - * 会计的括号负数这些 locale 规则,重写一遍必然缺选项)。 - * - * 小数位**优先配在 `editor.precision` 上**(它同时管显示与校验,一处真源); - * `format.decimals` 只在需要「显示位数 ≠ 数据精度」时才写,写了以它为准。 - */ -export type TableCellFormat = - | FormatNumberOptions - | ((value: unknown, row: T, col: G3TableColumn) => string) - export type G3TableColumn = StkTableColumn & { - /** 该列是否允许拖拽调宽。操作列 / 展开列必须显式设 false */ + /** 该列是否可拖宽;操作列 / 展开列应显式设 false */ resizable?: boolean /** 行内编辑器 */ editor?: TableEditor /** 操作列按钮 */ actions?: TableAction[] - /** 展示态格式化(只影响显示,落库值不变) */ - format?: TableCellFormat } -/** 行多选配置 */ +/** 多选配置 */ export interface TableRowSelection { + /** 已选行的 key 数组(受控) */ selectedRowKeys?: Array + /** 选中变化回调 */ onChange?: (keys: Array, rows: T[]) => void - /** 单行是否禁用勾选 */ + /** 单行复选框属性,目前只用 disabled */ getCheckboxProps?: (row: T) => { disabled?: boolean } } @@ -223,175 +145,48 @@ export interface TableColResizableConfig { /** 列序拖拽配置 */ export interface TableColumnDraggableConfig { - mode?: 'insert' | 'swap' + mode?: 'none' | 'insert' | 'swap' disabled?: boolean | ((col: G3TableColumn) => boolean) } -/** 区域选择范围(透传底座) */ -export interface TableAreaRange { - index: { start: { row: number; col: number }; end: { row: number; col: number } } -} - -/** 表尾合计列配置 */ -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' +/** 表尾配置(透传底座) */ +export type TableFooterConfig = Record /** 组件 props */ export interface TableProps { - /** 列定义 */ columns?: G3TableColumn[] - /** 数据源 */ dataSource?: T[] - /** 行唯一键 */ rowKey?: TableRowKey - /** 纵向虚拟滚动总开关(实际是否开启受行数门控,见 G3Table.vue 注释) */ virtual?: boolean - /** 横向虚拟滚动总开关(实际是否开启受列数门控) */ virtualX?: boolean - /** 行高:唯一真源,同时驱动虚拟滚动计算与 CSS 变量 */ rowHeight?: number - /** 表头行高 */ headerRowHeight?: number - /** - * 边框:`true`(默认)= 圆角外框 + 行分隔线;`false` = 无外框仅行分隔线。 - * 外框由本组件用真 `border` 自绘(底座的外框做不出圆角),格线仍走底座的背景 - * 渐变,见 `style/_overrides.scss` 的「边框」一节。 - */ - bordered?: boolean | string - /** - * 斑马纹(隔行底色),默认 `false`。透传给底座的 `stripe`。 - * - * ⚠️ 冻结列要单独接:行底色由库画在 `tr` 上,而冻结列的单元格必须是**不透明**底色 - * (否则横向滚动时被压住的内容会透字),它自己那层底色会把 `tr` 上的条纹盖掉 —— - * 于是组件在冻结列上补一层同色内阴影把条纹接上(见 `style/_overrides.scss`)。 - * 这也是「宿主页自己有行斑马纹样式时,只有序号列没条纹」的成因。 - */ - stripe?: boolean - /** - * 固定列被滚动内容遮挡时是否画阴影,默认 false。 - * - * 纯观感开关(没有固定列时什么都不画),但冻结列与滚过去的列同为白底、 - * 紧贴在一起时,没有这层 10px 渐变就看不出边界,业务侧建议打开。 - */ - fixedColShadow?: boolean - /** 列宽拖拽 */ + bordered?: boolean colResizable?: boolean | TableColResizableConfig - /** 列序拖拽 */ columnDraggable?: boolean | TableColumnDraggableConfig - /** 行拖拽排序 */ rowDraggable?: boolean - /** 行多选 */ rowSelection?: TableRowSelection | null - /** 序号列 */ seq?: boolean | TableSeqConfig - /** 当前高亮行 key */ activeRowKey?: string | number | null - /** 行内编辑总开关 */ editable?: boolean - /** - * 编辑器是否无缝嵌入单元格,默认 true。 - * - * true:单元格内无外间距,编辑器自身也不画常驻边框——整格铺满, - * 只有焦点 / 校验 / 特殊交互状态才出现视觉提示。适合整行编辑、 - * 键盘连续录入、固定列宽以及专业用户(Excel / AG Grid 的表格态)。 - * false:编辑器在单元格内留一圈间距并保留自身描边。 - */ - editorSeamless?: boolean - /** 空态是否铺满容器 */ noDataFull?: boolean - /** 溢出省略 */ showOverflow?: boolean - /** 非横向虚拟时铺满容器 */ autoFitWidth?: boolean - /** 主题;不传则跟随全局主题(ConfigProvider / documentElement) */ theme?: 'light' | 'dark' - /** 表尾数据 */ 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 } /** 组件事件 */ export interface TableEmits { (e: 'update:columns', columns: G3TableColumn[]): void - (e: 'update:selectedRowKeys', keys: Array): void (e: 'change', row: T, col: G3TableColumn, 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 - * - * ``` - * - * 为什么由**事件 + 受控 prop** 两个绑定合起来做,而不是表格内部自动高亮: - * 当前行只应有一个真源。内部再设一次就有两个写入者,二者互相覆盖 - * (表现为「有概率不高亮」)。交给调用方后,也能支持 - * 「点击只选中不编辑」「某些行禁止高亮」等要求。 - */ - (e: 'row-click', event: MouseEvent, row: T, extra: { rowIndex: number }): void + (e: 'row-order-change', rows: T[], dragStartKey: string | number, targetRowKey: string | number): void + (e: 'area-selection-change', ranges: unknown[]): void } -/** 组件实例方法 */ -export interface TableExpose { +/** 组件对外暴露的方法 */ +export interface TableExpose { scrollToRow(rowKey: string | number): Promise editRow(rowKey: string | number, focusCol?: string | null): Promise initVirtualScroll(): void diff --git a/code/g3soft-libs/packages/ui/src/table/utils/actions.ts b/code/g3soft-libs/packages/ui/src/table/utils/actions.ts index 9a36f0f5..84a1dfe3 100644 --- a/code/g3soft-libs/packages/ui/src/table/utils/actions.ts +++ b/code/g3soft-libs/packages/ui/src/table/utils/actions.ts @@ -1,17 +1,19 @@ -import { h, ref } from 'vue' +// shallowRef 而非 ref:ref 会对泛型 T 做深解包(UnwrapRef), +// 传给 createConfirmDialog 时与 TableAction/T 不再兼容。 +import { h, shallowRef, type VNode } from 'vue' import { G3Button } from '../../button' import { G3Dropdown } from '../../dropdown' import { G3Modal } from '../../modal' -import { useNamespace } from '../../_composables/useNamespace' -import type { DropdownClickPayload, DropdownItemData } from '../../dropdown' +import { createEditableCell, type EditableContext } from './editable' import type { G3TableColumn, TableAction, TableRow } from '../types' /** - * 操作列(照抄 fms 的 createCellRenderer.js 中操作列部分)。 + * 操作列与 customCell 注入 —— 照抄 fms 的 `createCellRenderer.js`。 * - * 两类按钮:内联的(直接渲染成小按钮)与「更多」下拉(`type: 'more'`)。 - * 带 `confirm` 的操作先弹确认框——用**状态化**的 G3Modal 而不是命令式 `Modal.confirm()`, - * 后者依赖全局挂载容器,组件库不该有这种隐式依赖。 + * 两处与 G3 组件对齐的改写(fms 用的是它自己的内部组件): + * · `Modal` → `G3Modal`:确定事件是 **`confirm`**(不是 `ok`),按钮文案 prop 是 + * **`confirmText`**(不是 `okText`),且组件**没有 `onConfirm` prop**; + * · `Dropdown` → `G3Dropdown`:点击载荷是 **`{ key, item }`**(不是 `{ key }`)。 */ const stop = (event: Event) => event.stopPropagation() @@ -27,28 +29,23 @@ function isActionVisible(action: TableAction, row: T): bo return action.visible === undefined || resolveActionState(action.visible, row) } -/** 单个内联操作按钮 */ function actionButton( action: TableAction, row: T, triggerAction: (action: TableAction, row: T) => void, - e: (el: string) => string, -) { + className: string, +): VNode { const disabled = resolveActionState(action.disabled, row) const content: unknown[] = [] if (action.icon) content.push(h(action.icon, { size: 14 })) if (action.label || action.type) content.push(action.label || action.type) - return h( 'button', { type: 'button', class: [ - e('action-btn'), - { - [e('action-btn-danger')]: - action.danger || action.type === 'danger' || action.type === 'delete', - }, + className, + { 'is-danger': action.danger || action.type === 'danger' || action.type === 'delete' }, ], title: action.title || action.label, disabled, @@ -63,27 +60,36 @@ function actionButton( } /** - * 二次确认弹窗。 + * 确认弹窗:状态化渲染(替代命令式调用),由 `G3Modal` 承载。 * - * 两个 prop/事件名极易写错(Vue 会静默吞掉未声明者,不报错、只是功能失效): - * - 按钮文案是 `confirmText` / `cancelText`,**不是** antdv 的 `okText`; - * - 确认事件是 `onConfirm`,**不是** antdv 的 `onOk`。 + * ⚠️ G3Modal 与 fms 内部 Modal 的差异(照搬 fms 写法会静默失效): + * 确定事件叫 `confirm`(不是 `ok`)、文案 prop 叫 `confirmText`(不是 `okText`), + * 且**没有 `onConfirm` prop**。这里自绘 footer,确定动作完全由本函数接管。 */ function createConfirmDialog( state: { value: { action: TableAction; row: T } | null }, close: () => void, - e: (el: string) => string, -) { - const current = state.value - if (!current) return null - const { action, row } = current - const config = typeof action.confirm === 'string' ? { title: action.confirm } : action.confirm || {} +): VNode { + const { action, row } = state.value! + const config = + typeof action.confirm === 'string' ? { title: action.confirm } : action.confirm || {} const title = config.title || '确认操作' const description = config.description ?? config.content ?? '确定要执行此操作吗?' const confirmText = config.okText || '确定' const cancelText = config.cancelText || '取消' const danger = (config.okType ?? 'danger') === 'danger' + // 先 close 再执行:与 fms 一致,避免确认过程中弹窗还挂在界面上 + function onConfirm() { + close() + action.onClick?.(row) + } + + const footer = () => [ + h(G3Button, { type: 'outline', onClick: close }, () => cancelText), + h(G3Button, danger ? { type: 'danger', onClick: onConfirm } : { onClick: onConfirm }, () => confirmText), + ] + return h( G3Modal, { @@ -100,50 +106,25 @@ function createConfirmDialog( 'onUpdate:open': (open: boolean) => { if (!open) close() }, + // Esc / 遮罩走的都是 cancel;footer 由我们自绘,故不接 confirm onCancel: close, - onConfirm: () => { - close() - action.onClick?.(row) - }, }, { - default: () => h('div', { class: e('confirm-content') }, description), - footer: danger - ? () => [ - h(G3Button, { type: 'outline', onClick: close }, () => cancelText), - h( - G3Button, - { - type: 'danger', - onClick: () => { - close() - action.onClick?.(row) - }, - }, - () => confirmText, - ), - ] - : undefined, + default: () => h('div', { class: 'g3-confirm-content' }, description), + footer, }, ) } -/** - * 生成操作列的单元格渲染函数。 - * - * `actions` 为空时返回 undefined(调用方据此决定是否注入 customCell)。 - */ -export function createActionsCell(actions: TableAction[]) { +export function createActionsCell( + actions: TableAction[] | undefined, +): ((props: { row: T }) => VNode) | undefined { if (!actions?.length) return undefined - const { e } = useNamespace('table') - const inlineActions = actions.filter((action) => action.type !== 'more') const moreAction = actions.find((action) => action.type === 'more') - const openRow = ref(null) - const confirmState = ref<{ action: TableAction; row: T } | null>(null) as { - value: { action: TableAction; row: T } | null - } + const openRow = shallowRef(null) + const confirmState = shallowRef<{ action: TableAction; row: T } | null>(null) const itemByKey = new Map( moreAction?.items?.map((item, index) => [String(item.key ?? item.type ?? index), item]) ?? [], ) @@ -165,20 +146,17 @@ export function createActionsCell(actions: TableAction[]) confirmState.value = { action, row } } - return ({ row }: { row: T }) => { - const children: unknown[] = inlineActions + return ({ row }: { row: T }): VNode => { + const children: VNode[] = inlineActions .filter((action) => isActionVisible(action, row)) - .map((action) => actionButton(action, row, triggerAction, e)) - - // 确认弹窗必须先于「无更多项」的提前 return 渲染——否则只配内联操作(如删除)时, - // confirmState 已置位但弹窗永远不出现(表现为「删除按钮点了没反应」)。 - const dialog = createConfirmDialog(confirmState, closeConfirm, e) - if (dialog) children.push(dialog) - + .map((action) => actionButton(action, row, triggerAction, 'g3-action-btn')) const visibleMoreItems = (moreAction?.items || []).filter((item) => isActionVisible(item, row)) - if (!visibleMoreItems.length) return h('span', { class: e('actions-cell') }, children as never) + // 确认弹窗必须先于「无更多项」的提前 return 渲染——否则仅内联操作(如删除)点击后 + // confirmState 已设置但弹窗永远不会出现(表现为「删除按钮没有反应」)。 + if (confirmState.value) children.push(createConfirmDialog(confirmState, closeConfirm)) + if (!visibleMoreItems.length) return h('span', { class: 'g3-actions-cell' }, children) - const menuItems: DropdownItemData[] = visibleMoreItems.map((item, index) => ({ + const menuItems = visibleMoreItems.map((item, index) => ({ key: String(item.key ?? item.type ?? index), label: item.label, icon: item.icon ? h(item.icon, { size: 14 }) : undefined, @@ -195,7 +173,7 @@ export function createActionsCell(actions: TableAction[]) 'button', { type: 'button', - class: [e('action-btn'), e('action-more')], + class: 'g3-action-btn g3-action-more', title: moreAction?.title || '更多操作', 'aria-label': moreAction?.title || '更多操作', onMousedown: stop, @@ -219,13 +197,9 @@ export function createActionsCell(actions: TableAction[]) 'onUpdate:open': (open: boolean) => { if (!open) closeMenu() }, - onClick: (payload: DropdownClickPayload) => { - const item = - visibleItemByKey.get(String(payload?.key)) ?? - itemByKey.get(String(payload?.key)) ?? - visibleMoreItems.find( - (it, index) => String(it.key ?? it.type ?? index) === String(payload?.key), - ) + // G3Dropdown 的载荷是 { key, item }(不是 fms 的 { key }) + onClick: ({ key }: { key?: string | number }) => { + const item = visibleItemByKey.get(String(key)) || itemByKey.get(String(key)) closeMenu() if (item) triggerAction(item, row) }, @@ -236,7 +210,7 @@ export function createActionsCell(actions: TableAction[]) 'button', { type: 'button', - class: [e('action-btn'), e('action-more'), e('action-more-open')], + class: 'g3-action-btn g3-action-more is-open', title: moreAction?.title || '更多操作', 'aria-label': moreAction?.title || '更多操作', onMousedown: stop, @@ -249,6 +223,38 @@ export function createActionsCell(actions: TableAction[]) ) } - return h('span', { class: e('actions-cell') }, children as never) + return h('span', { class: 'g3-actions-cell' }, children) } } + +/** + * 给一列决定是否注入 `customCell` —— 与 fms 的 `resolveCustomCell` 同构。 + * + * 编辑列在「有行在编辑」时才注入(`editingActive` 门控),checkbox / select 例外: + * 前者始终可交互,后者需要解析 label 展示。纯展示列走底座原生 td 渲染路径。 + */ +export function resolveCustomCell( + col: G3TableColumn, + editingContext: EditableContext, + editingActive = true, +): G3TableColumn { + if (col.customCell) return col + // 可编辑列:仅在存在活动编辑行时注入编辑器渲染(非编辑态走库原生 td,避免滚动时 + // 每格函数式组件重渲染)。checkbox 列始终可交互、select 列需解析 label 展示, + // 两者保持注入 customCell。 + if ( + col.editor && + (editingActive || col.editor.type === 'checkbox' || col.editor.type === 'select') + ) { + return { ...col, customCell: createEditableCell(col, editingContext) as never } + } + + if (col.actions?.length) { + const customCell = createActionsCell(col.actions) + if (customCell) return { ...col, customCell: customCell as never } + } + // 纯展示列(无 editor、无 actions)与非编辑态的可编辑列:不再注入 customCell, + // 走 StkTable 原生 td 渲染路径(减少每格函数式组件数量)。mousedown 照常冒泡到 + // tbody 以支持点击激活行编辑,文字选中通过 CSS 层 user-select 保障。 + return col +} diff --git a/code/g3soft-libs/packages/ui/src/table/utils/column.test.ts b/code/g3soft-libs/packages/ui/src/table/utils/column.test.ts deleted file mode 100644 index 6cabfaf3..00000000 --- a/code/g3soft-libs/packages/ui/src/table/utils/column.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - DRAG_ROW_COLUMN_TYPE, - SEQ_COLUMN_TYPE, - getHeaderAlign, - isInternalColumn, - readInternalWidths, - resolveSeqConfig, - stripInternalColumns, -} from './column' -import { createSelectionColumn, isSelectionColumn, SELECTION_COLUMN_KEY } from './selection' -import type { G3TableColumn, TableRow } from '../types' - -/** 造一列的最小工具 */ -function col(dataIndex: string, extra: Partial> = {}) { - return { dataIndex, title: dataIndex, ...extra } as G3TableColumn -} - -describe('resolveSeqConfig', () => { - it('seq 为假时不启用', () => { - expect(resolveSeqConfig(false).enabled).toBe(false) - expect(resolveSeqConfig(undefined).enabled).toBe(false) - }) - - it('seq 为 true 时用默认标题与宽度', () => { - const cfg = resolveSeqConfig(true) - expect(cfg).toEqual({ enabled: true, title: '序号', width: 60 }) - }) - - it('传对象时接受自定义标题与宽度', () => { - expect(resolveSeqConfig({ title: 'No.', width: 80 })).toEqual({ - enabled: true, - title: 'No.', - width: 80, - }) - }) - - it('宽度转成数字(字符串也接受)', () => { - expect(resolveSeqConfig({ width: '64' as unknown as number }).width).toBe(64) - }) - - it('只给 title 时宽度回落默认值', () => { - expect(resolveSeqConfig({ title: '序号列' }).width).toBe(60) - }) -}) - -describe('getHeaderAlign', () => { - it('显式声明优先于一切推断', () => { - expect(getHeaderAlign(col('a', { headerAlign: 'right' }))).toBe('right') - }) - - it('序号列居中', () => { - expect(getHeaderAlign(col('', { type: SEQ_COLUMN_TYPE }))).toBe('center') - }) - - it('选择列居中', () => { - const selection = { dataIndex: SELECTION_COLUMN_KEY, [SELECTION_COLUMN_KEY]: true } as unknown as G3TableColumn - expect(getHeaderAlign(selection)).toBe('center') - }) - - it('操作列居中(按 dataIndex 与 actions 两种写法都能识别)', () => { - expect(getHeaderAlign(col('actions'))).toBe('center') - expect(getHeaderAlign(col('op', { actions: [{ label: '删除' }] }))).toBe('center') - }) - - it('checkbox 编辑器列居中', () => { - expect(getHeaderAlign(col('flag', { editor: { type: 'checkbox' } }))).toBe('center') - }) - - it('普通列左对齐', () => { - expect(getHeaderAlign(col('name'))).toBe('left') - }) -}) - -describe('isInternalColumn', () => { - it('识别选择列', () => { - const selection = { dataIndex: SELECTION_COLUMN_KEY, [SELECTION_COLUMN_KEY]: true } as unknown as G3TableColumn - expect(isInternalColumn(selection)).toBe(true) - expect(isSelectionColumn(selection)).toBe(true) - }) - - it('识别序号列与拖拽列', () => { - expect(isInternalColumn(col('', { type: SEQ_COLUMN_TYPE }))).toBe(true) - expect(isInternalColumn(col('__g3DragRow', { type: DRAG_ROW_COLUMN_TYPE }))).toBe(true) - }) - - it('业务列不算内部列', () => { - expect(isInternalColumn(col('name'))).toBe(false) - expect(isInternalColumn(col('amount'))).toBe(false) - }) -}) - -describe('stripInternalColumns', () => { - const original = [col('name'), col('amount')] - - it('剥离内部列,只回传业务列', () => { - const written = [ - col(SELECTION_COLUMN_KEY, { [SELECTION_COLUMN_KEY]: true } as never), - col('', { type: SEQ_COLUMN_TYPE }), - col('name', { width: 120 }), - col('amount', { width: 90 }), - ] as G3TableColumn[] - const result = stripInternalColumns(written, original) - expect(result.map((c) => c.dataIndex)).toEqual(['name', 'amount']) - }) - - it('把新宽度回写到原始列(保留原始列的其它配置)', () => { - const withTitle = [{ dataIndex: 'name', title: '名称', width: 100 }] as G3TableColumn[] - const written = [col('name', { width: 260 })] as G3TableColumn[] - const result = stripInternalColumns(written, withTitle) - expect(result[0].width).toBe(260) - // 原始 title 必须保留:回写的列对象来自库,不带着我们的配置 - expect(result[0].title).toBe('名称') - }) - - it('空数组安全', () => { - expect(stripInternalColumns([], original)).toEqual([]) - expect(stripInternalColumns(undefined as never, original)).toEqual([]) - }) -}) - -describe('readInternalWidths', () => { - it('读回三个内部列的宽度', () => { - const written = [ - col(SELECTION_COLUMN_KEY, { [SELECTION_COLUMN_KEY]: true, width: 60 } as never), - col('', { type: SEQ_COLUMN_TYPE, width: 70 }), - col('__g3DragRow', { type: DRAG_ROW_COLUMN_TYPE, width: 40 }), - col('name', { width: 200 }), - ] as G3TableColumn[] - expect(readInternalWidths(written)).toEqual({ selection: 60, seq: 70, dragRow: 40 }) - }) - - it('业务列宽度不进入结果', () => { - expect(readInternalWidths([col('name', { width: 200 })] as G3TableColumn[])).toEqual({}) - }) - - it('宽度为空或非数字时跳过', () => { - const written = [ - col('', { type: SEQ_COLUMN_TYPE }), - col(SELECTION_COLUMN_KEY, { [SELECTION_COLUMN_KEY]: true, width: 'abc' } as never), - ] as G3TableColumn[] - expect(readInternalWidths(written)).toEqual({}) - }) -}) - -describe('createSelectionColumn', () => { - function setup(selectedRowKeys: Array = [], disabledKeys: Array = []) { - const data = [{ id: 1 }, { id: 2 }, { id: 3 }] - const changes: Array<{ keys: Array; rows: TableRow[] }> = [] - const column = createSelectionColumn({ - getDataSource: () => data, - getRowKey: (row) => row.id as number, - getSelectedRowKeys: () => selectedRowKeys, - isRowDisabled: (row) => disabledKeys.includes(row.id as number), - onChange: (keys, rows) => changes.push({ keys, rows }), - }) - return { column, changes, data } - } - - it('返回的列带内部标识,且固定在左侧居中', () => { - const { column } = setup() - expect(isSelectionColumn(column)).toBe(true) - expect(column.fixed).toBe('left') - expect(column.align).toBe('center') - expect(column.headerAlign).toBe('center') - expect(column.width).toBe(44) - }) - - it('行单元格是函数(供库作 customCell 调用),且能渲染出原生 checkbox', () => { - const { column } = setup([1]) - const render = column.customCell as unknown as (p: { row: TableRow }) => { children: unknown[] } - const vnode = render({ row: { id: 1 } }) - // 第一个子节点是 - const input = (vnode.children as Array<{ type: string; props: Record }>)[0] - expect(input.type).toBe('input') - expect(input.props.type).toBe('checkbox') - expect(input.props.checked).toBe(true) - }) - - it('表头单元格存在且是函数', () => { - const { column } = setup() - expect(typeof column.customHeaderCell).toBe('function') - }) -}) diff --git a/code/g3soft-libs/packages/ui/src/table/utils/column.ts b/code/g3soft-libs/packages/ui/src/table/utils/column.ts deleted file mode 100644 index dcde481d..00000000 --- a/code/g3soft-libs/packages/ui/src/table/utils/column.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { createActionsCell } from './actions' -import { createEditableCell, type EditableContext } from './editable' -import { createFormatCell } from './precision' -import { isSelectionColumn } from './selection' -import type { G3TableColumn, TableRow } from '../types' - -/** - * 列处理的公共逻辑:内部列注入/剥离、对齐推断、customCell 注入。 - * - * 独立成模块是因为 `resolveCustomCell` 同时依赖 editable 与 actions 两条链路, - * 塞进任何一边都会形成别扭的跨模块参数传递。 - */ - -/** 序号列的内部类型标识(库用它渲染行号) */ -export const SEQ_COLUMN_TYPE = 'seq' -/** 行拖拽列的内部类型标识(库用它渲染拖拽手柄) */ -export const DRAG_ROW_COLUMN_TYPE = 'dragRow' - -/** 行拖拽列定义(fms 里是模块级常量,宽度需要响应式所以只复用这个模板) */ -export const DRAG_ROW_COLUMN = { - type: DRAG_ROW_COLUMN_TYPE, - dataIndex: '__g3DragRow', - title: '', - width: 40, -} - -/** 内部列(选择 / 序号 / 拖拽)的 dataIndex,用于回写时识别并剥离 */ -const INTERNAL_DATA_INDEXES = new Set(['', '__g3Selection', '__g3DragRow', '__actions__']) - -export interface SeqConfig { - enabled: boolean - title: string - width: number -} - -/** 由 seq prop 派生序号列配置 */ -export function resolveSeqConfig( - seq: boolean | { title?: string; width?: number } | undefined, -): SeqConfig { - if (!seq) return { enabled: false, title: '序号', width: 60 } - const cfg: { title?: string; width?: number } = seq === true ? { } : seq - return { - enabled: true, - title: cfg.title ?? '序号', - width: Number(cfg.width ?? 60), - } -} - -/** - * 表头对齐推断。 - * - * 规则:显式声明优先;序号列 / 选择列 / 操作列 / checkbox 列一律居中—— - * 这些列的内容是图标或短数字,左对齐会显得浮。 - */ -export function getHeaderAlign( - column: G3TableColumn, -): 'left' | 'center' | 'right' { - if (column.headerAlign) return column.headerAlign as 'left' | 'center' | 'right' - if (column.type === SEQ_COLUMN_TYPE) return 'center' - if (isSelectionColumn(column)) return 'center' - if (column.dataIndex === 'actions') return 'center' - if (column.actions && column.actions.length) return 'center' - // 库自己的 checkbox 列类型(与我们的 editor.type === 'checkbox' 是两回事) - if ((column as { checkbox?: boolean }).checkbox === true) return 'center' - if (column.editor && column.editor.type === 'checkbox') return 'center' - return 'left' -} - -/** - * 给一列决定是否注入 `customCell`。 - * - * **这是整个表格性能上最要紧的一处判断**: - * 纯展示列与非编辑态的可编辑列**不注入** customCell,让它们走库的原生 td 渲染路径。 - * 每格少一个函数式组件实例;滚动时可见格数以百计,省下的是实打实的挂载/更新开销。 - * - * `editingActive` 表示「全表是否有任意一行处于编辑态」: - * - false:可编辑列也走原生 td(非编辑态本来只展示文本) - * - true:可编辑列挂编辑器渲染(内部再按行判断是否真处于编辑态) - * - * checkbox 与 select 例外,始终注入:前者要能随时点,后者要把值解析成 label 展示。 - */ -export function resolveCustomCell( - col: G3TableColumn, - editingContext: EditableContext, - editingActive: boolean, -): G3TableColumn { - // 调用方显式给了渲染函数:尊重它,不再包装 - if (col.customCell) return col - - const editor = col.editor - /** - * 注入 customCell 的判据(五种情况): - * - * 1. `editingActive` —— 有行正在编辑,可编辑列都要挂编辑器; - * 2. `checkbox` —— 始终可点,必须常驻; - * 3. `select` —— 非编辑态也要把值解析成 label 展示,必须常驻; - * 4. **配了 `validate` / `validateWarning`** —— 校验是「数据的状态」, - * 不该依赖「哪一行被点过」。若不常驻,text/number 列在非编辑态 - * 走库的原生 td 渲染(连 cell-display span 都没有), - * 它的 error/warning 就只有在点过那一行之后才可见 —— - * 用户必须一行行点过去才能发现错误(这正是被反馈的问题)。 - * 5. **配了 `precision`** —— 同因:精度也是「数据的状态」,而且它还要负责 - * 非编辑态的格式化展示,不常驻就连格式化都不会生效。 - * - * 第 4 / 5 条的**开销可控性**:只有显式配了校验或精度的列才常驻, - * 且常驻时走的是 displayValue(纯 span,不是编辑器组件)。 - * 没配这两个的 text/number 列仍保持原样——非编辑态零组件开销。 - */ - const hasValidation = Boolean(editor?.validate || editor?.validateWarning) - /** - * `editor.disabled` 写成**函数**时(「这一行这一列能不能改」随行数据变), - * 常驻注入:非编辑行也要给这些格子打上「不可改」标记, - * 否则灰底只能在被点过的那一行里看到,用户仍要一行行点过去才发现某格点不动。 - * - * 代价是这些列的非编辑格也会多一个函数式组件实例 —— 只发生在**显式用了 - * 按行禁用**的列上(普通 text/number 列照旧零开销)。 - */ - const hasRowDisabled = typeof editor?.disabled === 'function' - /** 配了精度的 number 列:非编辑态要能看出「小数位超了」,也要按精度格式化展示 */ - const hasPrecision = Boolean(editor?.precision) - if ( - editor && - (editingActive || - editor.type === 'checkbox' || - editor.type === 'select' || - hasValidation || - hasRowDisabled || - hasPrecision) - ) { - return { - ...col, - customCell: createEditableCell(col, editingContext) as unknown as G3TableColumn['customCell'], - } - } - - /** - * 纯展示列(**没有** `editor`)配了 `format`:注入一个只做格式化的 cell。 - * - * 为什么不让业务改用底座的 `customCell: createNumberCell(...)` —— 那确实也行, - * 但 `format` 会因此变成「有 editor 时组件管、没 editor 时业务自己管」, - * 同一个字段两种行为,迟早有人踩。这里统一由组件渲染, - * 代价同样只落在**显式配了 format** 的列上。 - */ - if (!editor && col.format) { - return { - ...col, - customCell: createFormatCell(col) as unknown as G3TableColumn['customCell'], - } - } - - if (col.actions?.length) { - const actionsCell = createActionsCell(col.actions) - if (actionsCell) { - return { ...col, customCell: actionsCell as unknown as G3TableColumn['customCell'] } - } - } - - return col -} - -/** 判断是否为内部生成的列(不可被拖拽调宽 / 拖拽换序 / 回写给父级) */ -export function isInternalColumn(column: G3TableColumn): boolean { - return ( - isSelectionColumn(column) || - column.type === SEQ_COLUMN_TYPE || - column.type === DRAG_ROW_COLUMN_TYPE || - INTERNAL_DATA_INDEXES.has(String(column.dataIndex)) - ) -} - -/** - * 从库回写的 columns 里剥离内部列,只回传业务列。 - * - * 库在拖拽列宽/列序后会回写**全部**列(含我们注入的选择列、序号列), - * 直接把它 emit 给父级会污染父组件的 columns(表现为父级数据里多出几列, - * 下次渲染重复注入、列越滚越多)。这里按 key 找回原始列,只更新 width。 - */ -export function stripInternalColumns( - columns: G3TableColumn[], - originalColumns: G3TableColumn[], -): G3TableColumn[] { - const originalByKey = new Map( - originalColumns.map((col) => [String(col.key ?? col.dataIndex ?? ''), col]), - ) - return (columns || []) - .filter((col) => !isInternalColumn(col)) - .map((col) => { - const original = originalByKey.get(String(col.key ?? col.dataIndex ?? '')) - return original ? { ...original, width: col.width ?? original.width } : col - }) -} - -/** - * 从回写的列里同步内部列(选择列 / 序号列 / 拖拽列)的宽度。 - * 用户可能拖宽这几列,宽度要留在组件内部状态里,而不是回传给父级。 - */ -export function readInternalWidths(columns: G3TableColumn[]) { - const result: { selection?: number; seq?: number; dragRow?: number } = {} - for (const column of columns || []) { - if (column.width == null) continue - const width = Number.parseInt(String(column.width), 10) - if (!Number.isFinite(width)) continue - if (isSelectionColumn(column)) result.selection = width - else if (column.type === SEQ_COLUMN_TYPE) result.seq = width - else if (column.type === DRAG_ROW_COLUMN_TYPE) result.dragRow = width - } - return result -} diff --git a/code/g3soft-libs/packages/ui/src/table/utils/date-value.test.ts b/code/g3soft-libs/packages/ui/src/table/utils/date-value.test.ts deleted file mode 100644 index c4afdf3d..00000000 --- a/code/g3soft-libs/packages/ui/src/table/utils/date-value.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { checkDate } from './date-value' - -describe('checkDate', () => { - it('空值通过 —— 必填是 validate 的事,日期判定不越界管', () => { - for (const value of [null, undefined, '']) expect(checkDate(value, 'YYYY-MM-DD')).toBe('') - }) - - it('合法日期通过(含带时间精度的格式)', () => { - expect(checkDate('2026-03-15', 'YYYY-MM-DD')).toBe('') - expect(checkDate('2026-03-12 18:00', 'YYYY-MM-DD HH:mm')).toBe('') - // 没传 format 时按默认 YYYY-MM-DD - expect(checkDate('2026-03-15', undefined)).toBe('') - }) - - it('斜杠分隔也算合法 —— 组件会规范化文本,判定必须跟它一致', () => { - // 若这里判非法,就会出现「在格子里手输 `2026/03/15` 组件接受了、 - // 退回展示态又被标红」这种自相矛盾。 - expect(checkDate('2026/03/15', 'YYYY-MM-DD')).toBe('') - }) - - it('越界 / 非日期文本 → 「日期格式不正确」', () => { - // 月份越界最典型:用户以为输了日期,其实压根不是日期 - expect(checkDate('2026-13-45', 'YYYY-MM-DD')).toBe('日期格式不正确') - expect(checkDate('2026-03-32', 'YYYY-MM-DD')).toBe('日期格式不正确') - expect(checkDate('abc', 'YYYY-MM-DD')).toBe('日期格式不正确') - }) - - it('带时间精度的列:只给到「日」算**不合法**(组件的既有口径,本函数跟随)', () => { - // 写下来是为了让后来的人知道这是"有意跟随"而不是漏了补零: - // 这一列想手输就得写全 `2026-03-15 00:00`,或者直接用面板点选。 - expect(checkDate('2026-03-15', 'YYYY-MM-DD HH:mm')).toBe('日期格式不正确') - }) -}) diff --git a/code/g3soft-libs/packages/ui/src/table/utils/date-value.ts b/code/g3soft-libs/packages/ui/src/table/utils/date-value.ts deleted file mode 100644 index 4e3f7128..00000000 --- a/code/g3soft-libs/packages/ui/src/table/utils/date-value.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { DEFAULT_DATE_FORMAT, parseDateInput } from '../../date' - -/** - * 日期值的判定与规范化 —— 与 `precision.ts`(数值精度)对称的一块。 - * - * 为什么单独成文件:它同样是**纯函数、可单测**,而且 `checkDate` 要对外导出 —— - * 业务在保存前做提交拦截时必须能跑**同一套**判定(规则只藏在组件里的话, - * 两边迟早不一致:格子红了但存档能过、或者反过来)。 - */ - -/** - * 日期判定:返回失败原因(`''` = 通过)。 - * - * 判据刻意复用 `G3DatePicker` 内部解析用户手输的那个 `parseDateInput`:两边用 - * **同一个函数**,才不会出现「组件认为这串合法、表格校验认为它非法」这种自相矛盾。 - * - * ⚠️ 不要换成 `parseDate`:它是**宽松解析**(逐格式匹配失败后还会规范化重试、 - * 最后退到原生 `new Date()` 兜底),会把 `2026-13-45` 这类越界文本「救活」。 - * `parseDateInput` 里那道 `DATE_LIKE_TEXT` 保护专门就是拦这个的。 - * - * 边界(与 `checkPrecision` 保持一致的取舍): - * · 空值(`null` / `undefined` / `''`)→ **通过**。必填是 `validate` 的事,日期判定不越界管; - * · 非空但解析不出来 → 报错。这正是「用户敲了 `2026-13-45` 却没被拦住」那条路径。 - * - * 口径跟随组件(宽松):`format` 带时间精度时,只给到「日」的文本**算合法** - * (缺的时间会被补零)—— 与在格子里手输同样的文本得到的结果一致。 - * - * ⚠️ 存量数据的格式必须与 `format`(或 `valueFormat`)一致,否则整列会被判非法。 - * 后端给 `20260315` 而这里写 `YYYY-MM-DD` 就是典型误配 —— 那种情况该配 `valueFormat`。 - */ -export function checkDate(value: unknown, format?: string): string { - if (value === null || value === undefined || value === '') return '' - const result = parseDateInput(String(value), format ?? DEFAULT_DATE_FORMAT) - return result.isValid ? '' : '日期格式不正确' -} diff --git a/code/g3soft-libs/packages/ui/src/table/utils/editable.test.ts b/code/g3soft-libs/packages/ui/src/table/utils/editable.test.ts deleted file mode 100644 index 2ac05dc6..00000000 --- a/code/g3soft-libs/packages/ui/src/table/utils/editable.test.ts +++ /dev/null @@ -1,981 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createEditableCell, findAdjacentEditableCell, type EditableContext } from './editable' -import type { G3TableColumn, TableRow } from '../types' - -interface Row extends TableRow { - id: number - name: string - status: number -} - -function makeColumn(extra: Partial> = {}): G3TableColumn { - return { dataIndex: 'name', title: '名称', ...extra } as G3TableColumn -} - -/** - * 造一个可控的编辑上下文桩。 - * 默认:该行处于编辑态、当前编辑列就是目标列、用户刚激活(需要聚焦)。 - */ -function makeCtx(overrides: Partial> = {}): { - ctx: EditableContext - changes: Array<{ row: Row; value: unknown }> - focused: boolean[] - blurred: boolean[] - /** revertRow 被调用的行 id 列表(Esc 回滚) */ - reverted: number[] - /** onEditorInteract 收到的 (行, 列) —— checkbox 切换后要把该格同步成当前格 */ - interacted: Array<{ row: Row; col: string }> - notified: number -} { - const changes: Array<{ row: Row; value: unknown }> = [] - const focused: number[] = [] - const blurred: number[] = [] - const reverted: number[] = [] - const interacted: Array<{ row: Row; col: string }> = [] - let notified = 0 - const ctx: EditableContext = { - isEditing: () => true, - shouldFocus: () => true, - wasFocused: () => false, - consumePendingFocus: () => true, - onEditorFocus: (row) => focused.push(row.id), - onEditorBlur: (row) => blurred.push(row.id), - onEditorInteract: (row, col) => interacted.push({ row, col: col.dataIndex }), - /** - * 无缝编辑器形态:G3Table 的 editorSeamless 默认开启。 - * - * 桩里必须给这个成员,否则 `EditableContext` 不满足 - * (TS2322:`(() => boolean) | undefined` 不能赋给 `() => boolean`)。 - * 与组件默认值保持一致取 true —— 于是下面的断言测的就是**默认形态**, - * 而不是一个测试专用的特例。 - */ - isSeamless: () => true, - /** - * 「不可改的格子」标记的总开关:桩里默认开启(= 一张有可编辑列的、编辑功能打开的表), - * 于是下面那些断言测的是**默认行为** —— 单独特例(纯展示表 / editable: false) - * 由各自的用例显式覆盖。 - */ - shouldMarkReadonly: () => true, - shouldNotify: () => { - notified += 1 - return true - }, - onChange: (row, _col, value) => changes.push({ row, value }), - revertRow: (row) => { - reverted.push(row.id) - return true - }, - ...overrides, - } - return { - ctx, - changes, - focused, - blurred, - reverted, - interacted, - // 用 getter 读实时值 - get notified() { - return notified - }, - } as never -} - -/** 取出渲染函数返回的 vnode */ -function render(col: G3TableColumn, ctx: EditableContext, row: Row) { - const cell = createEditableCell(col, ctx) as unknown as (p: { row: Row }) => { - type: unknown - props: Record - children?: unknown - } - return cell({ row }) -} - -/** - * 展平 vnode 上的 class(可能是字符串、数组、或 `{ 类名: 布尔 }` 对象)。 - * - * 编辑器把状态类(校验 / 只读 / 当前格)都写成对象形式,直接断言 `props.class` - * 会得到一堆难读的嵌套结构 —— 展平成字符串数组后 `toContain('类名')` 才是可读的。 - */ -function classesOf(vnode: { props?: Record }) { - const raw = vnode.props?.class as unknown - const list = Array.isArray(raw) ? raw : [raw] - return list.flatMap((item) => { - if (typeof item === 'string') return item.split(/\s+/).filter(Boolean) - if (item && typeof item === 'object') { - return Object.entries(item as Record) - .filter(([, on]) => Boolean(on)) - .map(([name]) => name) - } - return [] - }) -} - -describe('非编辑态:走纯展示路径', () => { - it('渲染成 span,不挂任何编辑器组件', () => { - const col = makeColumn({ editor: { type: 'text' } } as never) - const { ctx } = makeCtx({ isEditing: () => false }) - const vnode = render(col, ctx, { id: 1, name: '合同.pdf', status: 1 }) - expect(vnode.type).toBe('span') - expect(vnode.children).toBe('合同.pdf') - }) - - it('展示态也带 title,便于悬停看全名', () => { - const col = makeColumn({ editor: { type: 'text' } } as never) - const { ctx } = makeCtx({ isEditing: () => false }) - const vnode = render(col, ctx, { id: 1, name: '很长的文件名.pdf', status: 1 }) - expect(vnode.props.title).toBe('很长的文件名.pdf') - }) - - it('select 展示态把值解析成 label(不是原始 value)', () => { - const col = makeColumn({ - editor: { - type: 'select', - options: [ - { label: '启用', value: 1 }, - { label: '停用', value: 0 }, - ], - }, - } as never) - const { ctx } = makeCtx({ isEditing: () => false }) - const vnode = render(col, ctx, { id: 1, name: 'x', status: 1 }) - // 这里用 status 列演示:直接构造一列绑到 status - const statusCol = { dataIndex: 'status', title: '状态', editor: col.editor } as G3TableColumn - const vnode2 = render(statusCol, ctx, { id: 1, name: 'x', status: 1 }) - expect(vnode2.children).toBe('启用') - void vnode - }) - - it('select 值找不到对应选项时回落显示原值', () => { - const col = { - dataIndex: 'status', - title: '状态', - editor: { type: 'select', options: [{ label: '启用', value: 1 }] }, - } as G3TableColumn - const { ctx } = makeCtx({ isEditing: () => false }) - const vnode = render(col, ctx, { id: 1, name: 'x', status: 99 }) - // 展示层统一转字符串(同一个值还要供 title 属性用),故这里是 '99' 而非 99 - expect(vnode.children).toBe('99') - }) - - it('值为 null/undefined 时展示空字符串而不是 "null"', () => { - const col = makeColumn({ editor: { type: 'text' } } as never) - const { ctx } = makeCtx({ isEditing: () => false }) - const vnode = render(col, ctx, { id: 1, name: null as never, status: 1 }) - expect(vnode.children).toBe('') - }) -}) - -describe('编辑态:input 编辑器', () => { - it('挂的是 G3Input 组件(不是原生 input)', () => { - const col = makeColumn({ editor: { type: 'text' } } as never) - const { ctx } = makeCtx() - const vnode = render(col, ctx, { id: 1, name: 'abc', status: 1 }) - // 组件 vnode 的 type 是对象(组件定义) - expect(typeof vnode.type).toBe('object') - }) - - it('初始值取自行数据', () => { - const col = makeColumn({ editor: { type: 'text' } } as never) - const { ctx } = makeCtx() - const vnode = render(col, ctx, { id: 1, name: 'abc', status: 1 }) - expect(vnode.props.modelValue).toBe('abc') - }) - - it('text 类型映射到 G3Input 的 default(不是原生 text)', () => { - const col = makeColumn({ editor: { type: 'text' } } as never) - const { ctx } = makeCtx() - const vnode = render(col, ctx, { id: 1, name: 'abc', status: 1 }) - expect(vnode.props.type).toBe('default') - }) - - it('number 类型正确映射', () => { - const col = { dataIndex: 'status', title: '状态', editor: { type: 'number' } } as G3TableColumn - const { ctx } = makeCtx() - const vnode = render(col, ctx, { id: 1, name: 'x', status: 5 }) - expect(vnode.props.type).toBe('number') - }) - - it('textarea 类型正确映射', () => { - const col = makeColumn({ editor: { type: 'textarea' } } as never) - const { ctx } = makeCtx() - const vnode = render(col, ctx, { id: 1, name: 'x', status: 1 }) - expect(vnode.props.type).toBe('textarea') - }) -}) - -/** - * 校验态(editor.validate)。 - * - * 这一组盯的是「错误态的**判据**是什么」,因为它是本功能最容易回归的点: - * 样式锚在 `is-editor-error` 类上,而这个类**必须**在 input / select / checkbox - * 三条路径上都出现 —— 一旦哪条漏了,那一列的「浅红底」会静默失效 - * (CSS 不报错,只是不命中)。 - * - * 另一个重点是**派生性**:状态由当前行数据实时求出,不是需要业务维护的 - * 一次性标记。所以「值改了 → 错误消失」必须靠重新渲染自动成立。 - */ -describe('校验态:editor.validate', () => { - /** 取 vnode 的 class(数组 / 字符串 / 对象三种形态都可能) */ - function classesOf(vnode: { props: Record }): 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 - 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 - 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 - 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 })?.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) - const row = { id: 1, name: '原始', status: 1 } - const { ctx, changes } = makeCtx() - const vnode = render(col, ctx, row) - - const update = vnode.props['onUpdate:modelValue'] as (v: string) => void - update('输入中') - update('输入中更多') - - // 行数据未被改动——这是草稿机制的核心保证 - expect(row.name).toBe('原始') - expect(changes).toHaveLength(0) - }) - - it('重渲染时回显草稿值(而不是行数据里的旧值)', () => { - const col = makeColumn({ editor: { type: 'text' } } as never) - const row = { id: 1, name: '原始', status: 1 } - const { ctx } = makeCtx() - - const first = render(col, ctx, row) - ;(first.props['onUpdate:modelValue'] as (v: string) => void)('草稿值') - - // 同一行再次渲染(模拟父级触发重渲染) - const second = render(col, ctx, row) - expect(second.props.modelValue).toBe('草稿值') - }) - - it('失焦才落库并通知父级', () => { - const col = makeColumn({ editor: { type: 'text' } } as never) - const row = { id: 1, name: '原始', status: 1 } - const { ctx, changes, blurred } = makeCtx() - const vnode = render(col, ctx, row) - - const update = vnode.props['onUpdate:modelValue'] as (v: string) => void - update('提交值') - const blur = vnode.props.onBlur as (e: unknown) => void - blur({ target: { value: '提交值' } }) - - expect(row.name).toBe('提交值') - expect(changes).toHaveLength(1) - expect(changes[0].value).toBe('提交值') - expect(blurred).toEqual([1]) - }) - - it('blur 后草稿被清掉,再渲染回到行数据的值', () => { - const col = makeColumn({ editor: { type: 'text' } } as never) - const row = { id: 1, name: '原始', status: 1 } - const { ctx } = makeCtx() - const vnode = render(col, ctx, row) - - ;(vnode.props['onUpdate:modelValue'] as (v: string) => void)('提交值') - ;(vnode.props.onBlur as (e: unknown) => void)({ target: { value: '提交值' } }) - - const again = render(col, ctx, row) - expect(again.props.modelValue).toBe('提交值') - }) - - it('编辑器卸载时把草稿写回行数据(避免输入凭空丢失)', () => { - const col = makeColumn({ editor: { type: 'text' } } as never) - const row = { id: 1, name: '原始', status: 1 } - const { ctx, changes } = makeCtx() - const vnode = render(col, ctx, row) - - ;(vnode.props['onUpdate:modelValue'] as (v: string) => void)('未提交的值') - ;(vnode.props.onVnodeBeforeUnmount as () => void)() - - // 兜底写回:值保住了,但**不触发通知**(那是 blur 的职责) - expect(row.name).toBe('未提交的值') - expect(changes).toHaveLength(0) - }) -}) - -describe('checkbox 编辑器', () => { - const col = { - dataIndex: 'status', - title: '启用', - editor: { type: 'checkbox' }, - } as G3TableColumn - - it('渲染成 label + 原生 checkbox(零组件开销)', () => { - const { ctx } = makeCtx() - const vnode = render(col, ctx, { id: 1, name: 'x', status: 1 }) - expect(vnode.type).toBe('label') - const children = vnode.children as Array<{ type: string; props: Record }> - expect(children[0].props.type).toBe('checkbox') - }) - - it('约定 1 = 选中', () => { - const { ctx } = makeCtx() - const vnode = render(col, ctx, { id: 1, name: 'x', status: 1 }) - const input = (vnode.children as Array<{ props: Record }>)[0] - expect(input.props.checked).toBe(true) - }) - - it('约定 0 = 未选中', () => { - const { ctx } = makeCtx() - const vnode = render(col, ctx, { id: 1, name: 'x', status: 0 }) - const input = (vnode.children as Array<{ props: Record }>)[0] - expect(input.props.checked).toBe(false) - }) - - it('勾选直接改行数据并通知(不需要先激活整行编辑)', () => { - const row = { id: 1, name: 'x', status: 0 } - const { ctx, changes } = makeCtx() - const vnode = render(col, ctx, row) - const input = (vnode.children as Array<{ props: Record }>)[0] - - ;(input.props.onChange as (e: unknown) => void)({ target: { checked: true } }) - expect(row.status).toBe(1) - expect(changes[0].value).toBe(1) - }) - - it('取消勾选写回 0', () => { - const row = { id: 1, name: 'x', status: 1 } - const { ctx, changes } = makeCtx() - const vnode = render(col, ctx, row) - const input = (vnode.children as Array<{ props: Record }>)[0] - - ;(input.props.onChange as (e: unknown) => void)({ target: { checked: false } }) - expect(row.status).toBe(0) - expect(changes[0].value).toBe(0) - }) - - /** - * checkbox 是**常驻可直接操作**的编辑器(mousedown 被 stop 掉了), - * 底座不会像点击其它格那样把这一格设为当前格 —— 所以切换之后必须显式通知表格, - * 否则「数据改了、但整行不高亮、格子没有选中框,且表格里没有任何选区」(实测)。 - */ - it('切换后通知表格「这一格成为当前格」(与点击其它格行为一致)', () => { - const row = { id: 1, name: 'x', status: 0 } - const { ctx, interacted } = makeCtx() - const vnode = render(col, ctx, row) - const input = (vnode.children as Array<{ props: Record }>)[0] - - ;(input.props.onChange as (e: unknown) => void)({ target: { checked: true } }) - expect(interacted).toEqual([{ row, col: 'status' }]) - }) - - it('仅仅渲染不会触发它(只有用户操作才算)', () => { - const { ctx, interacted } = makeCtx() - render(col, ctx, { id: 1, name: 'x', status: 0 }) - expect(interacted).toHaveLength(0) - }) - - it('Enter 切换(键盘路径)同样通知表格', () => { - const row = { id: 1, name: 'x', status: 1 } - const { ctx, interacted } = makeCtx() - const vnode = render(col, ctx, row) - const input = (vnode.children as Array<{ props: Record }>)[0] - - ;(input.props.onKeydown as (e: unknown) => void)({ - key: 'Enter', - preventDefault: () => {}, - stopPropagation: () => {}, - currentTarget: { checked: true }, - }) - expect(interacted).toEqual([{ row, col: 'status' }]) - }) -}) - -describe('禁用与字段级开关', () => { - it('editor.disabled 为 true 时走展示态', () => { - const col = makeColumn({ editor: { type: 'text', disabled: true } } as never) - const { ctx } = makeCtx() - const vnode = render(col, ctx, { id: 1, name: 'abc', status: 1 }) - expect(vnode.type).toBe('span') - }) - - it('editor.disabled 传函数时按行判断', () => { - const col = makeColumn({ - editor: { type: 'text', disabled: (row: Row) => row.id === 1 }, - } as never) - const { ctx } = makeCtx() - - expect(render(col, ctx, { id: 1, name: 'a', status: 1 }).type).toBe('span') - expect(typeof render(col, ctx, { id: 2, name: 'b', status: 1 }).type).toBe('object') - }) - - it('按行禁用时,展示态的 span 带 cell-readonly(表格侧据此铺灰底)', () => { - const col = makeColumn({ - editor: { type: 'text', disabled: (row: Row) => row.id === 1 }, - } as never) - const { ctx } = makeCtx() - expect(classesOf(render(col, ctx, { id: 1, name: 'a', status: 1 }))).toContain( - 'g3-table__cell-readonly', - ) - }) - - /** - * 灰底的语义是「这一格现在改不了」,只有**旁边存在可改的格子**时才是信息。 - * 纯展示表(没有任何可编辑列)/ `editable: false` 时由 G3Table 关掉这个标记 —— - * 否则整表逐格铺灰,看上去像「禁用状态」(文档里 6/7 张 demo 正是纯展示表)。 - */ - it('总开关关闭(纯展示表 / editable: false)时不打 cell-readonly', () => { - const col = makeColumn({ - editor: { type: 'text', disabled: (row: Row) => row.id === 1 }, - } as never) - const { ctx } = makeCtx({ shouldMarkReadonly: () => false }) - const span = render(col, ctx, { id: 1, name: 'a', status: 1 }) - expect(span.type).toBe('span') - expect(classesOf(span)).not.toContain('g3-table__cell-readonly') - }) - - it('editor.onChange 先于上下文的 onChange 触发', () => { - const order: string[] = [] - const col = makeColumn({ - editor: { - type: 'text', - onChange: () => order.push('editor'), - }, - } as never) - const row = { id: 1, name: 'x', status: 1 } - const { ctx } = makeCtx({ - onChange: () => order.push('ctx'), - }) - const vnode = render(col, ctx, row) - ;(vnode.props.onBlur as (e: unknown) => void)({ target: { value: 'v' } }) - - expect(order).toEqual(['editor', 'ctx']) - }) - - it('shouldNotify 返回 false 时两者都不触发(值没变就不通知)', () => { - const editorOnChange = vi.fn() - const ctxOnChange = vi.fn() - const col = makeColumn({ editor: { type: 'text', onChange: editorOnChange } } as never) - const { ctx } = makeCtx({ shouldNotify: () => false, onChange: ctxOnChange }) - const vnode = render(col, ctx, { id: 1, name: 'x', status: 1 }) - ;(vnode.props.onBlur as (e: unknown) => void)({ target: { value: 'x' } }) - - expect(editorOnChange).not.toHaveBeenCalled() - expect(ctxOnChange).not.toHaveBeenCalled() - }) -}) - -describe('select 编辑器', () => { - it('挂 G3Select,且显式关掉 filterable', () => { - const col = { - dataIndex: 'status', - title: '状态', - editor: { type: 'select', options: [{ label: '启用', value: 1 }] }, - } as G3TableColumn - const { ctx } = makeCtx() - const vnode = render(col, ctx, { id: 1, name: 'x', status: 1 }) - expect(typeof vnode.type).toBe('object') - expect(vnode.props.filterable).toBe(false) - }) - - it('选择后直接改行数据并通知', () => { - const col = { - dataIndex: 'status', - title: '状态', - editor: { type: 'select', options: [{ label: '启用', value: 1 }] }, - } as G3TableColumn - const row = { id: 1, name: 'x', status: 0 } - const { ctx, changes } = makeCtx() - const vnode = render(col, ctx, row) - ;(vnode.props['onUpdate:modelValue'] as (v: unknown) => void)(1) - - expect(row.status).toBe(1) - expect(changes[0].value).toBe(1) - }) - - it('清空时落到 editor.emptyValue', () => { - const col = { - dataIndex: 'status', - title: '状态', - editor: { type: 'select', options: [{ label: '启用', value: 1 }], emptyValue: -1 }, - } as G3TableColumn - const row = { id: 1, name: 'x', status: 1 } - const { ctx, changes } = makeCtx() - const vnode = render(col, ctx, row) - ;(vnode.props['onUpdate:modelValue'] as (v: unknown) => void)(undefined) - - expect(row.status).toBe(-1) - expect(changes[0].value).toBe(-1) - }) - - it('清空且未配 emptyValue 时落 null', () => { - const col = { - dataIndex: 'status', - title: '状态', - editor: { type: 'select', options: [{ label: '启用', value: 1 }] }, - } as G3TableColumn - const row = { id: 1, name: 'x', status: 1 } - const { ctx, changes } = makeCtx() - const vnode = render(col, ctx, row) - ;(vnode.props['onUpdate:modelValue'] as (v: unknown) => void)(undefined) - - expect(row.status).toBe(null) - expect(changes[0].value).toBe(null) - }) - - it('options 可传函数并按当前行求值', () => { - const col = { - dataIndex: 'status', - title: '状态', - editor: { - type: 'select', - options: (row: Row) => [{ label: `行${row.id}`, value: row.id }], - }, - } as G3TableColumn - const { ctx } = makeCtx() - const vnode = render(col, ctx, { id: 7, name: 'x', status: 7 }) - const options = vnode.props.options as Array<{ label: string }> - expect(options[0].label).toBe('行7') - }) -}) - -/** - * 键盘语义:Enter 与 Esc。 - * - * 这一组此前**完全缺失** —— 正因为没有测试守着,Esc 才会长期是 - * 「blur 提交」(按 Esc 反而保存),与 Excel / AG Grid / Handsontable / antd - * 的通用约定(Enter 提交、Esc 取消)相反。 - */ -describe('键盘:Enter 与 Esc', () => { - /** 取出某列编辑器 vnode 的 onKeydown */ - function keydownOf(col: G3TableColumn, ctx: EditableContext, row: Row) { - const vnode = render(col, ctx, row) - return vnode.props.onKeydown as (e: unknown) => void - } - - /** 造一个足够真实的 KeyboardEvent 替身(编辑器只用到 key / target / preventDefault / stopPropagation) */ - function makeKeyEvent(key: string, value: string) { - const target = { - value, - select: vi.fn(), - blur: vi.fn(), - } - return { - key, - target, - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - } - } - - /** - * Enter(单行):**确认 + 下移到下一行同一列** —— Excel / Sheets / AG Grid / Handsontable - * 的通行约定。 - * - * 旧行为是「阻止默认 + 全选内容、且**不提交**」,它既不是浏览器行为、也不是表格软件的行为, - * 而且「确认」这个动作整个缺失:改完按回车值还没落库。见 G3Table 的 moveToCellBelow。 - */ - it('Enter(单行):提交当前值,并要求往下走一格(不再是「全选」)', () => { - const col = makeColumn({ editor: { type: 'text' } } as never) - const { ctx } = makeCtx() - const event = makeKeyEvent('Enter', 'abc') - - keydownOf(col, ctx, { id: 1, name: 'abc', status: 1 })(event) - - expect(event.preventDefault).toHaveBeenCalled() - expect(event.stopPropagation).toHaveBeenCalled() - // 不再全选:那与「确认并走」的新语义冲突 - expect(event.target.select).not.toHaveBeenCalled() - // 也不 blur:移动交给 G3Table(改选区那条链路),自己不负责退场 - expect(event.target.blur).not.toHaveBeenCalled() - }) - - it('Enter(多行):完全不拦 —— 浏览器原生语义就是换行', () => { - const col = makeColumn({ editor: { type: 'textarea' } } as never) - const { ctx } = makeCtx() - const event = makeKeyEvent('Enter', '第一行') - - keydownOf(col, ctx, { id: 1, name: '第一行', status: 1 })(event) - - // 多行与单行共用一段 onKeydown,早先没按类型分流: - // 备注这类多行格按回车会把整段文本全选掉、根本换不了行。 - expect(event.preventDefault).not.toHaveBeenCalled() - expect(event.stopPropagation).not.toHaveBeenCalled() - expect(event.target.select).not.toHaveBeenCalled() - }) - - it('左右键:只 stopPropagation 交给输入框,不 preventDefault', () => { - const col = makeColumn({ editor: { type: 'text' } } as never) - const { ctx } = makeCtx() - const event = makeKeyEvent('ArrowLeft', 'abc') - - keydownOf(col, ctx, { id: 1, name: 'abc', status: 1 })(event) - - /** - * 阻止冒泡是为了**不让它冒到底座的表格导航**(否则光标被吃掉、整格跳走); - * 不动默认行为是为了让浏览器照常移动光标 / 用 Shift 选文本 —— - * 与底座内置编辑器(custom-cells/EditableCell)的处理逐字一致。 - */ - expect(event.stopPropagation).toHaveBeenCalled() - expect(event.preventDefault).not.toHaveBeenCalled() - }) - - it('上下键(单行):放行给表格导航,本层不做任何拦截', () => { - const col = makeColumn({ editor: { type: 'text' } } as never) - const { ctx } = makeCtx() - const event = makeKeyEvent('ArrowDown', 'abc') - - keydownOf(col, ctx, { id: 1, name: 'abc', status: 1 })(event) - - // 单行输入框里上下键没有原生用途 → 让给表格切换单元格(换行) - expect(event.preventDefault).not.toHaveBeenCalled() - expect(event.stopPropagation).not.toHaveBeenCalled() - }) - - it('上下键(多行):必须 stopPropagation,否则会冒到表格导航上切走格子', () => { - const col = makeColumn({ editor: { type: 'textarea' } } as never) - const { ctx } = makeCtx() - const event = makeKeyEvent('ArrowUp', 'abc') - - keydownOf(col, ctx, { id: 1, name: 'abc', status: 1 })(event) - - /** - * 多行里上下键要**跨行移动光标**,是有效操作。 - * ⚠️ 只「不主动放行」是不够的 —— 事件照样会冒泡到底座的选区导航上把格子切走 - * (实测:`textarea` 里按 ↑ 变成跳到上一行的格子里,光标没动)。 - */ - expect(event.stopPropagation).toHaveBeenCalled() - expect(event.preventDefault).not.toHaveBeenCalled() - }) - - it('Esc:先回滚数据,再失焦(语义是"取消",不是提交)', () => { - const col = makeColumn({ editor: { type: 'text' } } as never) - const row = { id: 1, name: '已改动', status: 1 } - const { ctx, reverted, changes } = makeCtx() - const event = makeKeyEvent('Escape', '已改动') - - keydownOf(col, ctx, row)(event) - - // 必须先调用 revertRow 再 blur —— 顺序反了就等于「按 Esc 保存了」 - expect(reverted).toEqual([1]) - expect(event.target.blur).toHaveBeenCalled() - expect(changes).toHaveLength(0) - }) - - it('Esc 回滚后失焦:不会再抛出 change(值已复原,被去重挡掉)', () => { - const col = makeColumn({ editor: { type: 'text' } } as never) - const row = { id: 1, name: '原始', status: 1 } - // 真实场景:revertRow 会把行数据恢复成原值 - const { ctx, changes } = makeCtx({ - revertRow: (r) => { - r.name = '原始' - return true - }, - }) - const vnode = render(col, ctx, row) - - // 用户改了内容 - ;(vnode.props['onUpdate:modelValue'] as (v: string) => void)('改过的') - // 按 Esc - ;(vnode.props.onKeydown as (e: unknown) => void)(makeKeyEvent('Escape', '改过的')) - // 随后 blur(blur 会 commit 当前 input 的值) - ;(vnode.props.onBlur as (e: unknown) => void)({ target: { value: '原始' } }) - - expect(row.name).toBe('原始') - }) - - it('Esc 会清掉草稿:再渲染回到行数据的值而不是残留的输入', () => { - const col = makeColumn({ editor: { type: 'text' } } as never) - const row = { id: 1, name: '原始', status: 1 } - const { ctx } = makeCtx() - const vnode = render(col, ctx, row) - - ;(vnode.props['onUpdate:modelValue'] as (v: string) => void)('输入中') - ;(vnode.props.onKeydown as (e: unknown) => void)(makeKeyEvent('Escape', '输入中')) - - // 草稿已清,重新渲染应显示行数据的值 - const again = render(col, ctx, row) - expect(again.props.modelValue).toBe('原始') - }) -}) - -/** - * Tab 跳格的目标计算。 - * - * 这一组锁的是「跳过不可编辑格 + 跨行回绕」这条规则本身 —— 它**刻意**不同于底座的 - * 「列 ±1」:底座会把焦点送到只读格上(编辑器在那里不存在,表现为按下 Tab 后焦点消失), - * 而这里只落在真能接收焦点的格上。 - */ -describe('Tab 跳格:findAdjacentEditableCell', () => { - const textCol = (dataIndex: string) => - ({ dataIndex, title: dataIndex, editor: { type: 'text' } }) as G3TableColumn - /** 只读列(没有 editor):Tab 必须跳过它 */ - const readonlyCol = (dataIndex: string) => ({ dataIndex, title: dataIndex }) as G3TableColumn - - const rows: Row[] = [ - { id: 1, name: 'a', status: 1 }, - { id: 2, name: 'b', status: 0 }, - ] - - it('前进:跳过中间没有编辑器的列', () => { - const cols = [textCol('id'), readonlyCol('name'), textCol('status')] - expect(findAdjacentEditableCell(rows, cols, { rowIndex: 0, colIndex: 0 }, 1)).toEqual({ - rowIndex: 0, - colIndex: 2, - }) - }) - - it('行尾回绕到下一行第一个可编辑格(Excel 手感)', () => { - const cols = [readonlyCol('id'), textCol('name'), textCol('status')] - // 第一行最后一列 → 第二行的 name(下一行的 id 是只读,跳过) - expect(findAdjacentEditableCell(rows, cols, { rowIndex: 0, colIndex: 2 }, 1)).toEqual({ - rowIndex: 1, - colIndex: 1, - }) - }) - - it('后退(Shift+Tab):方向反过来,同样跳只读格并跨行回绕', () => { - const cols = [readonlyCol('id'), textCol('name'), textCol('status')] - // 第二行第一列(readonly)再往前 → 第一行的 status - expect(findAdjacentEditableCell(rows, cols, { rowIndex: 1, colIndex: 1 }, -1)).toEqual({ - rowIndex: 0, - colIndex: 2, - }) - }) - - it('按行禁用(disabled 函数为真)的格也跳过 —— 那里的编辑器根本不渲染', () => { - const cols = [ - textCol('name'), - { - dataIndex: 'status', - title: '状态', - editor: { type: 'select', options: [], disabled: (row: Row) => row.status === 0 }, - } as G3TableColumn, - textCol('id'), - ] - // 第一行 status=1 可编辑 → 正常落到 status - expect(findAdjacentEditableCell(rows, cols, { rowIndex: 0, colIndex: 0 }, 1)).toEqual({ - rowIndex: 0, - colIndex: 1, - }) - // 第二行 status=0 被禁用 → 越过它落到再后面一列 - expect(findAdjacentEditableCell(rows, cols, { rowIndex: 1, colIndex: 0 }, 1)).toEqual({ - rowIndex: 1, - colIndex: 2, - }) - }) - - it('到头 / 到底:循环回另一端(不交给浏览器原生 Tab)', () => { - const cols = [readonlyCol('id'), textCol('name')] - // 全表最后一个可编辑格再往前 → 回到全表第一个可编辑格 - expect(findAdjacentEditableCell(rows, cols, { rowIndex: 1, colIndex: 1 }, 1)).toEqual({ - rowIndex: 0, - colIndex: 1, - }) - // 全表第一个可编辑格再往后 → 落到全表最后一个可编辑格 - expect(findAdjacentEditableCell(rows, cols, { rowIndex: 0, colIndex: 1 }, -1)).toEqual({ - rowIndex: 1, - colIndex: 1, - }) - }) - - it('全表只有一格可编辑:循环结果就是它自己(不会跳到只读格上)', () => { - const single: Row[] = [{ id: 1, name: 'a', status: 1 }] - const cols = [readonlyCol('id'), textCol('name')] - expect(findAdjacentEditableCell(single, cols, { rowIndex: 0, colIndex: 1 }, 1)).toEqual({ - rowIndex: 0, - colIndex: 1, - }) - }) - - it('一个可编辑格都没有时返回 null(不抛错):调用方据此放行原生 Tab', () => { - expect(findAdjacentEditableCell(rows, [], { rowIndex: 0, colIndex: 0 }, 1)).toBeNull() - expect( - findAdjacentEditableCell(rows, [readonlyCol('id'), readonlyCol('name')], { rowIndex: 0, colIndex: 0 }, 1), - ).toBeNull() - }) -}) diff --git a/code/g3soft-libs/packages/ui/src/table/utils/editable.ts b/code/g3soft-libs/packages/ui/src/table/utils/editable.ts index 4817bd76..a689861c 100644 --- a/code/g3soft-libs/packages/ui/src/table/utils/editable.ts +++ b/code/g3soft-libs/packages/ui/src/table/utils/editable.ts @@ -1,124 +1,66 @@ -import { h, toRaw } from 'vue' -import type { VNode } from 'vue' -import { DEFAULT_DATE_FORMAT, G3DatePicker } from '../../date' -import type { DateValue } from '../../date' +import { h, toRaw, type VNode } from 'vue' import { G3Input } from '../../input' import { G3Select } from '../../select' import { useNamespace } from '../../_composables/useNamespace' -import { checkDate } from './date-value' -import { checkPrecision, formatDisplayValue, resolvePrecision } from './precision' -import type { - G3TableColumn, - TableEditor, - TableEditorStatus, - TableRow, - TableSelectOption, -} from '../types' +import type { G3TableColumn, TableEditor, TableRow, TableSelectOption } from '../types' /** - * 行内编辑单元格(照抄 fms 的 createEditableCellRenderer.js)。 + * 行内编辑单元格渲染器 —— 照抄 fms 的 `createEditableCellRenderer.js`。 * - * 三条关键设计(都来自 fms 的实战结论,此处照搬): + * 三个必须知道的机制: * - * 1. **草稿机制**:Input 受控值先写进 `inputDrafts`(WeakMap,按 row 分组), - * 失焦才落库并通知父级。不这么做的话,每敲一个字符都会改到行数据、触发父级重渲染, - * 光标会丢、输入法会断。 - * 2. **焦点恢复靠三态判定**:`consumePendingFocus`(用户刚激活该格,一次性)/ - * `wasFocused`(重渲染前本来就持有焦点)。两者都不满足时**不聚焦**—— - * 否则「数据回灌 → 重挂载 → 抢焦点 → 失焦 → 再重渲染」会自我驱动, - * 表现为「离开单元格时高亮一闪而过」。 - * 3. **事件隔离**:库在 mousedown 阶段处理单元格选区与行编辑激活, - * 编辑器内部的点击必须 stop,否则第一次点击只激活编辑态、第二次才生效。 + * 1. **草稿(draft)**:input 的每次输入只写进 `inputDrafts`(WeakMap,按行对象存), + * **失焦才落库并通知父级**。这样每敲一个字符不会触发父级重渲染 + * (否则光标会丢、输入法会断)。select / checkbox 是「改即落库」,不走草稿。 + * + * 2. **焦点恢复**:只有两类重挂载需要聚焦 —— 用户刚激活该格(`consumePendingFocus` + * 一次性消费),或重渲染前编辑器本就持有焦点(`wasFocused`)。其余重挂载不聚焦, + * 否则「失焦引发的更新」会把焦点抢回来(表现为离开单元格时高亮一闪而过)。 + * + * 3. **卸载兜底**:编辑器被卸载时用 `toRaw(row)` 写回草稿 —— 不触发响应式更新 + * (正常路径已经走了 blur 提交与通知)。 */ +const { e, is } = useNamespace('table') + const stop = (event: Event) => event.stopPropagation() -/** Input 受控值草稿:row -> (dataIndex -> value) */ -const inputDrafts = new WeakMap>() +/** 草稿:行对象 -> (字段名 -> 未落库的输入值) */ +const inputDrafts = new WeakMap>() -/** 编辑器对外的上下文(由 G3Table 提供) */ -export interface EditableContext { - /** 该行是否处于编辑态 */ +/** fms 的 inputType 枚举映射到 G3Input 的 type 枚举 */ +const INPUT_TYPE_MAP: Record = { + text: 'default', + default: 'default', + password: 'password', + number: 'number', + textarea: 'textarea', +} + +/** 编辑器上下文:由 G3Table 提供(见 G3Table.vue 的 editingCtx) */ +export interface EditableContext { + /** 该行当前是否处于编辑态 */ isEditing: (row: T) => boolean - /** 该行的当前编辑列是否为该列 */ + /** 目标列是否是该行待聚焦的列 */ shouldFocus: (row: T, col: G3TableColumn) => boolean /** 重渲染前该行编辑器是否持有焦点 */ wasFocused: (row: T) => boolean - /** 消费一次「待聚焦」标记(用户激活时置位) */ - consumePendingFocus: (row: T) => boolean - onEditorFocus: (row: T) => void - onEditorBlur: (row: T) => void - /** - * 是否使用「无缝」编辑器形态(G3Table 的 editorSeamless,默认 true): - * 单元格内无外间距、编辑器无常驻边框,只有焦点 / 校验 / 特殊交互态给提示。 - */ - isSeamless: () => boolean - /** - * 「不可改的格子」标记(`cell-readonly` → 常驻灰底)要不要生效。 - * - * 灰底的语义是「这一格现在改不了」,只有**旁边存在可改的格子**时它才是信息。 - * 纯展示表(列上都没有 `editor`)里每格都改不了,逐格铺灰会让整表看上去像禁用状态; - * `editable === false` 同理。两种情况都由 G3Table 的这个判据挡掉(详见其实现注释)。 - */ - shouldMarkReadonly: () => boolean - /** 值真正变化时才返回 true(用于去重通知) */ + /** 一次性消费「待聚焦」标记:仅用户主动激活该格时为真 */ + consumePendingFocus?: (row: T) => boolean + onEditorFocus?: (row: T) => void + onEditorBlur?: (row: T) => void + /** 值变化去重:与进入编辑时的初值相同则返回 false */ shouldNotify: (row: T, col: G3TableColumn, value: unknown) => boolean - /** 通知父级 */ + /** 抛出组件的 change 事件 */ onChange: (row: T, col: G3TableColumn, value: unknown) => void - /** - * 把该行恢复到「进入编辑态时」的值(Esc 用)。 - * - * 快照由 G3Table 在 activateEditing 时用 captureEditableValues 存下, - * 与 shouldNotify 去重共用同一份 initialValues。 - * 返回是否真的发生了回滚(值本来就没变则返回 false,避免无谓 emit)。 - */ - revertRow: (row: T) => boolean - /** - * 「用户用了这一格的编辑器」→ 把该格同步成表格的当前格(选区 + 激活编辑 + 当前行)。 - * - * 只有 checkbox 需要它:它是**常驻可直接操作**的编辑器(见 createCheckbox 里 - * `stop` 掉 mousedown 的原因),底座因此收不到 mousedown,也就不会像点击其它格那样 - * 把这一格设为当前格。不补这一下的后果(都是实测): - * · 数据已经改了(`change` 也抛出去了),但整行不高亮、格子没有选中框,看不出在哪儿改; - * · 表格里**一个选区都没有**,接着按 Tab / 方向键会从 (0,0) 起步、焦点掉到 body。 - * 其它编辑器不需要:它们的格子本来就得先点进编辑(那条路径已经同步过当前格)。 - */ - onEditorInteract?: (row: T, col: G3TableColumn) => void - /** - * 「Enter 确认后往下走一格」:移到**下一行的同一列**(跳过不可编辑的行), - * 返回是否真的移动了(没有下一行可编辑格时返回 false,调用方原地不动)。 - * - * 交给 G3Table 实现,是因为移动必须走**改选区**的方式 —— 底座的 - * `area-selection-change` 那条链路已经处理好「激活编辑、定位并聚焦编辑器、 - * 滚动进视口」,复用它与 Tab 跳格 / 鼠标点击走同一条路, - * 避免出现第三套行为(见 G3Table 里 handleDocumentTab 的同一段理由)。 - */ - moveToCellBelow?: (row: T, col: G3TableColumn) => boolean - /** - * 退出编辑态(回到就绪态)。Esc 用。 - * - * 为什么 blur 不够:本组件的编辑态是**行级**的(`activeEditingRow`), - * 输入框失焦只会让编辑器不再持有焦点、**不会**退出编辑。于是 Esc 之后那一行仍在编辑 — - * 表现是「Esc 一下没反应、再点回来还是编辑态」,与 Excel 的「Esc 退出编辑」不符; - * 更实际的影响是「打字进编辑」这类路径:行已在编辑态时编辑器不会重挂, - * 新设置的草稿读不进去。 - */ - exitEditing?: () => void } -/* ---------- 草稿 ---------- */ - -function getInputDraft(row: T, dataIndex: string): unknown { +function getInputDraft(row: TableRow, dataIndex: string): unknown { const drafts = inputDrafts.get(row) return drafts?.has(dataIndex) ? drafts.get(dataIndex) : row[dataIndex] } -/** - * 写入编辑器草稿。**导出**是因为「就绪态下打字进编辑」要用它: - * 表格在进入编辑之前先把用户敲的那个字符放进草稿,编辑器挂载时读到的就是它 - * (覆盖语义,与 Excel 一致)。见 G3Table 的 enterEditingAt。 - */ -export function setInputDraft(row: T, dataIndex: string, value: unknown) { +function setInputDraft(row: TableRow, dataIndex: string, value: unknown) { let drafts = inputDrafts.get(row) if (!drafts) { drafts = new Map() @@ -127,199 +69,44 @@ export function setInputDraft(row: T, dataIndex: string, val drafts.set(dataIndex, value) } -function clearInputDraft(row: T, dataIndex: string) { +function clearInputDraft(row: TableRow, dataIndex: string) { const drafts = inputDrafts.get(row) if (!drafts) return drafts.delete(dataIndex) if (!drafts.size) inputDrafts.delete(row) } -function commitInputDraft(row: T, dataIndex: string, value: unknown) { +function commitInputDraft(row: TableRow, dataIndex: string, value: unknown) { clearInputDraft(row, dataIndex) - // 泛型 T 只能按已知键读取,写入需退到 TableRow(索引签名为 string) - ;(row as TableRow)[dataIndex] = value + row[dataIndex] = value } -/** - * 卸载兜底:把草稿写回 raw 对象但**不触发响应式更新**。 - * 正常路径会在 blur 中响应式提交并通知父级;这里只在「拖拽/切数据导致强制卸载」 - * 时避免用户输入凭空丢失。 - */ -function preserveInputDraft(row: T, dataIndex: string) { +function preserveInputDraft(row: TableRow, dataIndex: string) { const drafts = inputDrafts.get(row) if (!drafts?.has(dataIndex)) return - // toRaw 同样是泛型,写入前退到 TableRow;这里刻意绕过响应式,故不走 row 本身 - ;(toRaw(row) as TableRow)[dataIndex] = drafts.get(dataIndex) + // 卸载兜底不触发整表更新;正常路径会在 blur 中响应式提交并通知父级。 + toRaw(row)[dataIndex] = drafts.get(dataIndex) clearInputDraft(row, dataIndex) } -/* ---------- 值处理 ---------- */ - /** options 允许传函数并接收当前行:列选项依赖同行其它列时用它 */ -function resolveOptions( - editor: TableEditor, - row: T, -): TableSelectOption[] { +function resolveOptions(editor: TableEditor, row: T): TableSelectOption[] { return typeof editor.options === 'function' ? editor.options(row) : (editor.options ?? []) } -/** - * 这一格现在改不了吗(`editor.disabled`,可传按行函数)。 - * - * **导出**供 G3Table 用:「就绪态下进编辑」要先判这一条 —— 只读 / 按行禁用的格子 - * 不该被 Enter / 双击 / 打字带进编辑态(方向键仍可以在它上面停留,见 APG 的 grid pattern)。 - */ -export function isEditorDisabled(editor: TableEditor, row: T): boolean { - return typeof editor.disabled === 'function' - ? Boolean(editor.disabled(row)) - : Boolean(editor.disabled) -} - -/** - * 求当前单元格的校验态。 - * - * 每次渲染按当前行数据**重新求值**,这是本实现最重要的性质: - * 校验结果是行数据的**派生值**,不是需要业务手动维护的一次性状态。 - * 用户把空的数量改成 12 → 下一次渲染 validate 返回 true → 红底自动消失, - * 不需要业务调任何 clearError(),组件内部也不留任何 error 注册表 - * (注册表还要处理行被删除 / 数据源整体替换时的清理,是纯粹的负债)。 - * - * 为什么把 validate 的返回值同时当作「是否错误」和「错误原因」: - * 见 types.ts 里 validate 的注释——失败原因只有一处真源,不会与布尔标记脱节。 - * - * 注意**不做**任何缓存:只在编辑器真正挂载的那一行调用(非编辑行走 displayValue), - * 调用频率等于「一行的可编辑列数」,换来的是永远不会出现「数据改了红底还在」的脏状态。 - */ -/** - * 跑一个校验函数并把结果归一成「是否命中 + 原因」。 - * - * 抽出来是因为 validate 与 validateWarning 的**调用约定完全相同** - * (都是 falsy 通过 / string 表示有问题),只有「命中后归到哪个等级」不同。 - * 两处各写一遍 try/catch 与类型判断,等于把最容易出错的那段逻辑复制两份 - * (此前「布尔语义反向」的 bug 就是在这个判断里,见下方注释)。 - */ -function runValidator( - fn: ((value: unknown, row: T, col: G3TableColumn) => boolean | string) | undefined, - which: 'validate' | 'validateWarning', +function displayValue( row: T, col: G3TableColumn, -): 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 : '' -} - -/** - * 归一后的校验结果:**等级 + 失败原因**。 - * - * 原因就是 `validate` / `validateWarning` 返回的那个字符串(「金额必须大于 0」)。 - * 以前这里只取它的 `.length` 判等级、把内容丢掉 —— 业务在 types.ts 里精心写的 - * 失败原因因此没有任何出口:用户只看得到「这格红了」,看不到为什么。 - * 现在把它一起带出来,调用方挂成 `data-validate-message`, - * 表格侧 hover 时读它弹自绘浮层(见 G3Table 的 cellTip)。 - * - * ⚠️ 仍然**只调一次**校验函数:调用方复用这一份结果,不允许为了「拿原因」 - * 再调一遍 `validate` —— 那会让每格的校验函数在每次渲染里被调用两次, - * 而校验是写在业务侧的、可能有真实开销。 - */ -interface ResolvedEditorStatus { - level: TableEditorStatus - message: string -} - -function resolveEditorStatus( editor: TableEditor, - row: T, - col: G3TableColumn, -): ResolvedEditorStatus { - /** - * error 优先于 warning。 - * - * 两条规则是**独立**的(如「必填」与「箱量偏少」),同一格可能同时命中。 - * 优先级在这里统一裁决,调用方就不必在每条规则里自己比较等级。 - * 若反过来让 warning 优先,会出现「数据为空(严重)却只给个琥珀提示」的 - * 危险表现——宁可多报一次严重的。 - * - * 同时命中时**消息也只带 error 那条**:用户此刻要处理的就是它, - * 两条一起塞进浮层反而让人分不清先改哪个。 - */ - const errorMessage = runValidator(editor.validate, 'validate', row, col) - if (errorMessage.length > 0) return { level: 'error', message: errorMessage } - - /** - * `editor.precision` 是**组件内置**的一条 error 规则,排业务 `validate` 之后: - * - * · 排在后面是因为业务规则的语义更具体(「金额必须大于 0」比「最多 2 位小数」 - * 更能说明该改成什么),两者同时命中时先给业务那条; - * · 与 `validate` 同样返回 string,所以它**天然复用**同一条链路 - * (单元格红底 + 左侧竖条 + 悬停浮层的原因),不需要另开一套视觉; - * · 只有 `type: 'number'` 会有精度(`resolvePrecision` 已限制), - * select / checkbox 拿到的永远是 `undefined`,判定为空。 - * - * ⚠️ 这里只**判定**、不改数据:值原样留在 row 里(见 types.ts 的说明)。 - */ - const precisionError = checkPrecision(row[col.dataIndex], resolvePrecision(col, row)) - if (precisionError.length > 0) return { level: 'error', message: precisionError } - - /** - * 日期列内置的一条 error 规则,与 `precision` 完全对称:**值不合规范就标红、 - * 但组件不改值**(用户敲的 `2026-13-45` 就留在行里,由他自己改)。 - * - * 它接住的是「手输非法」:DatePicker 有意不为这种文本 emit(否则表单的 `v-model` - * 会被脏值污染),所以表格侧在编辑器卸载时把原文写回(见 createDate 的 - * `onVnodeBeforeUnmount`),再交给这条规则把原因说出来 —— 用户看到的仍是 - * 那套统一的「红底 + 左侧竖条 + 悬停浮层」,不需要另做提示。 - * - * ⚠️ 判据必须限定 `type === 'date'`:`checkDate` 是**导出的公共函数**(刻意不绑类型), - * 而任何一段普通文本都解析不出日期 —— 不限定的话 `type: 'text'` 的列会满屏报错。 - */ - const dateError = editor.type === 'date' ? checkDate(row[col.dataIndex], editor.format) : '' - if (dateError.length > 0) return { level: 'error', message: dateError } - - const warningMessage = runValidator(editor.validateWarning, 'validateWarning', row, col) - if (warningMessage.length > 0) return { level: 'warning', message: warningMessage } - - return { level: '', message: '' } +): VNode { + const value = row[col.dataIndex as string] + const text = + editor.type === 'select' + ? (resolveOptions(editor, row).find((item) => String(item.value) === String(value))?.label ?? + value) + : value + const content = text ?? '' + return h('span', { class: e('cell-display'), title: String(content) }, String(content)) } function notifyChange( @@ -334,444 +121,88 @@ function notifyChange( ctx.onChange(row, col, value) } -/** - * 非编辑态的展示内容:select 需要把值解析成 label。 - * - * ⚠️ 展示态**同样要渲染校验态**(红底 / 琥珀底)。 - * - * 原因是很实际的一条:编辑器只在**被点击过的那一行**才挂载 - * (见 column.ts 的 resolveCustomCell 门控),如果校验态只在编辑器里求值, - * 那么「哪一格有错」就只有在点过那一行之后才看得见 —— - * 用户必须一行行点过去才能发现错误,这显然不可接受。 - * - * 校验是**数据的状态**,不该依赖「哪一行被交互过」。 - * 所以这里在展示态也求一次 status,把它作为状态类吐在 span 上, - * 表格侧的 `:has()` 选择器就能命中并铺底。 - * - * 为什么不在展示态也挂编辑器:那正是 resolveCustomCell 极力避免的开销 - * (滚动时每格一个函数式组件)。这里只多打一个 class,成本可忽略。 - */ -function displayValue( - ctx: EditableContext, - row: T, - col: G3TableColumn, - editor: TableEditor, -) { - const { e, is } = useNamespace('table') - const value = row[col.dataIndex] - const precision = resolvePrecision(col, row) - /** - * 展示文本的两个来源: - * · select → 把值解析成选项 label(既有行为); - * · 其余 → `formatDisplayValue`:没配 `format` / `precision` 的列原样返回, - * 走的是**零开销路径**(多数列如此);配了的按规则格式化, - * 精度不合法时原样裸露真实值(见 precision.ts 的说明)。 - */ - const text = - editor.type === 'select' - ? (resolveOptions(editor, row).find((item) => String(item.value) === String(value)) - ?.label ?? value) - : formatDisplayValue(value, row, col, precision) - const content = text ?? '' - const status = resolveEditorStatus(editor, row, col) - const statusClass = editorStatusClass(status.level, editor.errorStyle, is) - /** - * 按行动态禁用的格子:这里挂「不可改」标记,表格侧据此常驻灰底。 - * - * 为什么标记打在 span 上:JS 侧给 `td` 加**按行变化**的类做不到 - * (见 column.ts 的注释:库渲染 td 时只取 `col.className`,那是整列常量)。 - * 而「这一行这一列能不能改」是行数据的派生值,只能由内容节点带出来, - * 表格侧再用 `td:has(> .cell-readonly)` 反查。 - * - * 静态不可改的列(没有 editor / `disabled: true`)走另一条更便宜的路: - * 由 G3Table 直接往 `col.className` 上写同一个类名,一个字符串搞定, - * 不为它们注入 customCell(见 column.ts 的性能注释)。 - * - * 前置条件 `shouldMarkReadonly()`:纯展示表 / `editable: false` 时不标 —— - * 否则整表逐格铺灰、看上去像禁用状态(见该成员在 EditableContext 里的说明)。 - */ - const readonly = ctx.shouldMarkReadonly() && isEditorDisabled(editor, row) ? e('cell-readonly') : '' - return h( - 'span', - { - class: [ - e('cell-display'), - /* - * 校验态类(is-editor-error / is-editor-warning)打在**展示态的 span** 上。 - * - * 这样表格侧不必区分「编辑器里的状态类」与「展示态的状态类」: - * 两种形态都带同一个类名,`:has()` 选择器写一套就够。 - * 这也是为什么不在 span 上用 e('cell-display-error') 之类的私有类名 —— - * 那会让 CSS 不得不写两遍(编辑器一遍、展示态一遍), - * 而本文件已经有过「改一处忘另一处」的教训。 - */ - statusClass ? { [statusClass]: true } : {}, - readonly ? { [readonly]: true } : {}, - ], - /** - * 有校验原因时**撤掉** title:原因交给表格侧的自绘浮层显示, - * 原生 tooltip 若同时弹出就会与浮层叠在一起(两个框、两套样式)。 - * 没有原因时 title 仍是「值」,继续承担「内容被省略」的兜底。 - */ - title: status.message ? undefined : String(content), - /** - * 失败原因挂成 data 属性(**有消息才挂**:`undefined` 会让 Vue 直接移除该属性, - * 普通格零成本)。表格侧 hover 时读它弹自绘浮层(见 G3Table 的 cellTip)。 - * - * 为什么不用原生 `title`:延迟约 1 秒、样式不可控、亮暗主题跟不上 —— - * 而浮层可以完全对齐本库的浮层语言。所以 `title` 留给「值太长被省略」这件事, - * 两者不要混在一个属性里。 - * - * 为什么挂在**展示节点**上而不是 `td` 上:与灰底标记同理 —— 库渲染 td 时 - * class / 属性只认 `col.className`(整列常量),按行变化的东西打不上去。 - */ - 'data-validate-message': status.message || undefined, - /** - * 无障碍:把原因也说给屏幕阅读器(自绘浮层对它们不可靠)。 - * 只在**有消息时**才拼这个字符串,普通格不为此多分配一次。 - */ - 'aria-label': status.message - ? `${col.title || col.dataIndex},${status.message}` - : undefined, - }, - content as string, - ) +function isEditorDisabled(editor: TableEditor, row: T): boolean { + return typeof editor.disabled === 'function' + ? Boolean(editor.disabled(row)) + : Boolean(editor.disabled) } -/* ---------- 焦点 ---------- */ - +/** + * 焦点处理:返回 vnode 钩子,在 mounted / updated 时决定要不要聚焦。 + * `selectText` 为真时聚焦后全选(直接输入即替换整格内容)。 + */ function createFocusHandler( ctx: EditableContext, row: T, col: G3TableColumn, selectText = false, ) { - /** - * 返回值 = 「这一次**真的**把焦点交给了编辑器」。 - * - * 日期编辑器用它决定顺手弹不弹日历面板(见 createDate):只有「用户刚激活这一格」 - * 的那一次才该弹,重渲染引起的重复聚焦不该弹。其余调用点忽略返回值即可 —— - * 所以这个改动是向后兼容的。 - */ - return ({ component, el }: { component?: any; el?: any }): boolean => { - if (!ctx.shouldFocus(row, col)) return false - // 仅在两种场景聚焦:用户刚激活该格(一次性),或重渲染前本就持有焦点 + return ({ component, el }: { component?: any; el?: any }) => { + if (!ctx.shouldFocus(row, col)) return + // 仅两类场景聚焦:用户刚激活该单元格(consumePendingFocus 一次性), + // 或重渲染前本编辑器就持有焦点(wasFocused,数据回灌重挂载后恢复焦点)。 const pending = ctx.consumePendingFocus?.(row) const wasFocused = Boolean(ctx.wasFocused?.(row)) - if (!pending && !wasFocused) return false + if (!pending && !wasFocused) return - const input = el?.matches?.('input') ? el : el?.querySelector?.('input') - // 焦点已经在这一格:不算「这次交给它」,否则日期面板会被重复弹开 - if (document.activeElement === input) return false + // G3Input 根节点是包装 div,需向下找原生 input + const input: HTMLInputElement | null = el?.matches?.('input') + ? el + : (el?.querySelector?.('input') ?? null) + if (document.activeElement === input) return const instance = component?.exposed || component?.proxy const shouldSelect = selectText && (pending || wasFocused) - const opts = shouldSelect ? { cursor: 'all', preventScroll: true } : { preventScroll: true } + const opts = shouldSelect + ? { cursor: 'all' as const, preventScroll: true } + : { preventScroll: true } if (instance?.focus) { instance.focus(opts) - return true + return } + input?.focus(opts) if (shouldSelect) input?.select() - return true } } -/* ---------- Tab 跳格 ---------- */ - -/** - * 求「Tab / Shift+Tab 该跳到哪一格」。 - * - * 规则与底座的 Tab 导航**刻意不同**,这一点是核心: - * · 底座(stk-table-vue 的 useAreaSelection)按「列 ±1」移动,落到哪列算哪列 —— - * 它服务的是纯选区模型,没有「这一格能不能改」的概念; - * · 行内编辑的语境里,只有**有编辑器、且这一行没被禁用**的格子才接得住焦点。 - * 底座的规则会把焦点送到只读格(`focusCol` 为 null),表现为「按 Tab 之后 - * 焦点凭空消失」;再按一次就掉到表格外面去(焦点无处可去,浏览器按文档顺序找下一个)。 - * - * 所以这里在**扁平化后的可编辑格序列**上前进 / 后退:跳过不可编辑的格, - * 行尾自动接到下一行第一个可编辑格(Shift+Tab 反向),也就是 Excel / AG Grid 的手感。 - * - * `from` 必须是**已解析出的真实单元格**(`colIndex >= 0`):本函数只负责「往后 / 往前找」, - * 不负责纠正一个未知位置 —— 当前位置的解析属于 DOM/编辑态的事,见 G3Table。 - * - * 走到头时**循环回另一端**(Tab 是 Excel / AG Grid 的手感),而不是交给浏览器: - * · 放行浏览器:焦点会被原生 Tab 序接管,而表格里**下一行的 checkbox 编辑器是可聚焦的** - * (checkbox 常驻渲染,不像其它编辑器只挂在编辑行),一次空格就改了数据 —— - * 比「不跳」危险得多; - * · 原地不动:键盘用户被卡死(按 Tab 毫无反应)。 - * 循环不覆盖「全表一个可编辑格都没有」这种情况:那时返回 null,调用方放行原生 Tab - * (没有可跳的目标,也就谈不上接管)。 - */ -export function findAdjacentEditableCell( - rows: T[], - columns: G3TableColumn[], - from: { rowIndex: number; colIndex: number }, - step: 1 | -1, -): { rowIndex: number; colIndex: number } | null { - const columnCount = columns.length - const total = rows.length * columnCount - if (!columnCount || total <= 0) return null - - const isEditableAt = (index: number) => { - const editor = columns[index % columnCount].editor as TableEditor | undefined - return Boolean(editor) && !isEditorDisabled(editor as TableEditor, rows[Math.floor(index / columnCount)]) - } - const toCell = (index: number) => ({ - rowIndex: Math.floor(index / columnCount), - colIndex: index % columnCount, - }) - - // 先顺着方向找;找不到再回绕扫另一头(含当前格:全表只有一格可编辑时就停在自己身上) - const forwardStart = from.rowIndex * columnCount + from.colIndex + step - for (let index = forwardStart; index >= 0 && index < total; index += step) { - if (isEditableAt(index)) return toCell(index) - } - for (let index = step === 1 ? 0 : total - 1; index >= 0 && index < total; index += step) { - if (isEditableAt(index)) return toCell(index) - } - return null -} - -/* ---------- 编辑器 ---------- */ - -/** - * 「这一格就是当前编辑格」的标记类(td 侧用 `:has(> …)` 命中)。 - * - * 为什么需要它,而不是用 CSS 的 `:focus-within`: - * **select 展开时焦点会被移进 Teleport 到 body 的下拉浮层**,那一刻 td 的 - * `:focus-within` 当场失效,编辑格的白底就退回库的选区底色(`--cs-bgc` 浅蓝)—— - * 表现是「select 编辑格的白底时有时无」,而 input 没有浮层、永远正常。 - * - * 改由**内容节点**带标记(与「按行禁用的灰底」「校验态」同一套路子): - * td 无法按行加类(库渲染 td 时 class 只取 `col.className`),但编辑器能, - * 于是 `td:has(> .is-cell-editing)` 就能精确命中这一格,且不再随焦点在格内 / 浮层 - * / 短暂落到 body(换格的一瞬间)之间漂移。 - * - * 三种编辑器都必须打这个类:漏掉任何一条,那种编辑器就会退回「时白时不白」。 - */ -function activeCellClass( - ctx: EditableContext, - row: T, - col: G3TableColumn, - is: (...states: (string | false | null | undefined)[]) => string, -): string { - return ctx.shouldFocus(row, col) ? is('cell-editing') : '' -} - -/* 方向键的分工写在 createInput / createDate 的 onKeydown 里(左右归输入框、上下给导航), - 没有共用常量 —— 两处的条件都带类型判断(textarea 例外),抽成集合反而更绕。 */ - -/** - * 求「Enter 确认后该去哪一格」:**下一行的同一列**,跳过不可编辑的行。 - * - * 为什么跳过,而不是像 Excel 那样「不管下一格能不能改都落下去」:本组件的 - * 「选中」与「进入编辑」是一体的(点格即挂载编辑器),落到只读格上表现成 - * 「编辑态凭空消失」,比停在原地更让人困惑 —— 与 Tab 跳格跳过只读格同一条理由。 - * - * 扫到表尾没有可编辑格就返回 null(调用方原地不动):与 Excel 到表尾即停一致, - * 不循环回表头(回车一路按到底突然跳回第一行会很意外)。 - */ -export function findCellBelow( - rows: T[], - columns: G3TableColumn[], - from: { rowIndex: number; colIndex: number }, -): { rowIndex: number; colIndex: number } | null { - const editor = columns[from.colIndex]?.editor as TableEditor | undefined - if (!editor) return null - for (let rowIndex = from.rowIndex + 1; rowIndex < rows.length; rowIndex += 1) { - if (!isEditorDisabled(editor, rows[rowIndex])) return { rowIndex, colIndex: from.colIndex } - } - return null -} - -/** 编辑器到 G3Input 的 type 映射:fms 用 antdv 的 inputType,我们用自己的枚举 */ -const INPUT_TYPE_MAP: Record = { - text: 'default', - default: 'default', - password: 'password', - number: 'number', - 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( row: T, col: G3TableColumn, editor: TableEditor, ctx: EditableContext, -) { - const { e, is } = useNamespace('table') +): VNode { + const dataIndex = col.dataIndex as string 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 取消」触发的。 - * - * 为什么需要它:onBlur 读的是 `event.target.value`(**DOM 里用户敲进去的文本**), - * 而 ctx.revertRow 只恢复**行数据**、不会碰 DOM input。若只回滚数据就 blur, - * onBlur 会把用户输入又写回 row,回滚当场被覆盖(实测确认过这条)。 - * - * 用标志位而不是手动改 input.value:后者要直接操作 DOM,还要处理 G3Input - * 内部受控值同步,容易与组件的受控渲染打架。 - */ - let canceling = false return h(G3Input, { - type: INPUT_TYPE_MAP[editor.type] ?? 'default', - modelValue: (getInputDraft(row, col.dataIndex) ?? '') as string, - status: status.level, - class: [ - e('cell-editor'), - e('cell-editor-root'), - activeCellClass(ctx, row, col, is), - editorStatusClass(status.level, editor.errorStyle, is), - ], - 'aria-label': status.message - ? `${col.title || col.dataIndex},${status.message}` - : col.title || col.dataIndex, - 'data-validate-message': status.message || undefined, + type: INPUT_TYPE_MAP[(editor.inputType as string) || 'text'] ?? 'default', + modelValue: (getInputDraft(row, dataIndex) ?? '') as string, + class: [e('cell-editor'), e('cell-editor-root')], + 'aria-label': col.title || dataIndex, 'onUpdate:modelValue': (value: string | number) => { - setInputDraft(row, col.dataIndex, value) + setInputDraft(row, dataIndex, value) }, onFocus: () => ctx.onEditorFocus?.(row), onBlur: (event: FocusEvent) => { ctx.onEditorBlur?.(row) - if (canceling) { - // Esc 取消:丢弃用户输入,不落库、不通知父级 - canceling = false - clearInputDraft(row, col.dataIndex) - return - } const value = (event.target as HTMLInputElement).value - commitInputDraft(row, col.dataIndex, value) + commitInputDraft(row, dataIndex, value) notifyChange(ctx, editor, row, col, value) }, onKeydown: (event: KeyboardEvent) => { - // G3Input 根节点是包装 div,currentTarget 不是原生 input,需用 event.target - const input = event.target as HTMLInputElement - - /** - * 左右键:**交给输入框**移动光标 / 选中文本(含 `Shift+左右`)。 - * - * 这是编辑文本的刚需 —— 想改金额中间一位、想 Shift 选中再覆盖,都离不开它。 - * 所以这两个键 `stopPropagation`,不让事件冒到表格的选区导航上。 - */ - if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { - event.stopPropagation() - return - } - - /** - * 上下键:**单行交给表格导航**(切到上 / 下一行的同一列),**多行留给输入框**。 - * - * 单行输入框里上下键**没有任何原生用途**(不移动光标、也不滚动),拿它做「换行」 - * 是零损失的:改完按 ↓ 直接走到下一行同一列,手不用离开键盘。 - * 左右键则相反(见上)—— 它在输入框里有明确用途,不能让出去。 - * - * 两种去向的条件: - * · **放行给表格**(`return`,不 stopPropagation):单行、且没按 `Shift` - * —— 按下 `Shift` 时原生语义是「选中到开头 / 结尾」,那是有效操作; - * · **留给输入框**(`stopPropagation`):`textarea` 要跨行移动光标、 - * 或单行按了 `Shift`。 - * - * ⚠️ 第二条的 `stopPropagation` 不能省:只「不主动放行」是不够的 —— - * 事件照样会冒泡到底座的选区导航上,把格子切走(实测踩到: - * `textarea` 里按 ↑ 变成跳到上一行的格子里,光标没动)。 - */ - if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { - if (editor.type !== 'textarea' && !event.shiftKey) return - event.stopPropagation() - return - } - + // G3Input 根节点是包装 div(currentTarget 非原生 input),需用 event.target 执行 select/blur if (event.key === 'Enter') { - /** - * 多行编辑器:Enter 就是换行(浏览器原生语义),一点都不拦。 - * 它与单行共用这段 onKeydown,早先没按类型分流,导致备注这类多行格 - * 按回车会把整段文本**全选**掉、根本换不了行。 - */ - if (editor.type === 'textarea') return - event.preventDefault() event.stopPropagation() - /** - * 单行:**确认 + 下移到下一行同列** —— Excel / Sheets / AG Grid / - * Handsontable 的通行约定,也是录入场景最顺手的动作(改完这行去下一行同一列)。 - * - * ⚠️ 必须**显式提交**,不能依赖随之而来的失焦:移动会改选区 → 编辑器 vnode - * 被卸载,而 DOM 元素被移除时浏览器**不派发 blur**(做日期编辑器时踩过这个坑), - * 只靠 onBlur 的话,敲完按回车值就没了。 - * ⚠️ 移动交给 `moveToCellBelow`(内部改选区),与 Tab 跳格同一条链路。 - * 早先这里是 `select()` 全选 —— 那既不是浏览器行为、也不是表格软件的行为, - * 而且**不提交**(改完按回车,值还没落库)。 - */ - commitInputDraft(row, col.dataIndex, input.value) - notifyChange(ctx, editor, row, col, input.value) - ctx.moveToCellBelow?.(row, col) + ;(event.target as HTMLInputElement).select() } else if (event.key === 'Escape') { - // Esc = **放弃本次编辑**(恢复到进入编辑前的值),符合 Excel / AG Grid / - // Handsontable / antd 的通用约定(Enter 提交、Esc 取消)。 - // - // ⚠ 顺序与配套缺一不可: - // 1) 置 canceling —— 让随后 onBlur 跳过提交(关键,否则回滚被用户输入覆盖) - // 2) revertRow —— 把行数据恢复到进入编辑时的快照 - // 3) 清草稿 + blur —— 输入框值经受控渲染回到行数据的原值 - canceling = true - ctx.revertRow(row) - clearInputDraft(row, col.dataIndex) ;(event.target as HTMLInputElement).blur() - // 4) 退出编辑态(回到就绪态)—— 与 Excel 的「Esc 退出编辑」一致。 - // blur 本身做不到这件事:编辑态是**行级**的,失焦不等于退出编辑。 - ctx.exitEditing?.() } }, onClick: stop, onVnodeMounted: focus, onVnodeUpdated: focus, - onVnodeBeforeUnmount: () => preserveInputDraft(row, col.dataIndex), + onVnodeBeforeUnmount: () => preserveInputDraft(row, dataIndex), }) } @@ -780,45 +211,23 @@ function createSelect( col: G3TableColumn, editor: TableEditor, ctx: EditableContext, -) { - const { e, is } = useNamespace('table') +): VNode { + const dataIndex = col.dataIndex as string 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, + modelValue: (row[dataIndex] ?? undefined) as never, + // 库的 options 类型是宽泛的 (string | number | Record)[], + // 我们的 TableSelectOption 结构一致但没有索引签名,这里显式放行 + options: options as never, clearable: editor.allowClear ?? true, filterable: false, - /** - * 无缝模式交给 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'), - activeCellClass(ctx, row, col, is), - editorStatusClass(status.level, editor.errorStyle, is), - ], - 'aria-label': status.message - ? `${col.title || col.dataIndex},${status.message}` - : col.title || col.dataIndex, - 'data-validate-message': status.message || undefined, + class: [e('cell-editor'), e('cell-editor-root')], + 'aria-label': col.title || dataIndex, 'onUpdate:modelValue': (value: unknown) => { const resolved = value ?? editor.emptyValue ?? null - row[col.dataIndex] = resolved as T[keyof T & string] + // T 是泛型,只能按下标读;写入走 TableRow 视图(行数据本就是可变对象) + ;(row as TableRow)[dataIndex] = resolved notifyChange(ctx, editor, row, col, resolved) }, onClick: stop, @@ -827,167 +236,42 @@ function createSelect( }) } -function createDate( - row: T, - col: G3TableColumn, - editor: TableEditor, - ctx: EditableContext, -) { - const { e, is } = useNamespace('table') - const focus = createFocusHandler(ctx, row, col) - const status = resolveEditorStatus(editor, row, col) - return h(G3DatePicker, { - modelValue: (row[col.dataIndex] ?? null) as DateValue, - /** - * `format` 同时决定面板精度与绑定值格式(见 types.ts)。 - * 默认给 `'YYYY-MM-DD'`:与 G3DatePicker 自己的默认一致,也和后端常见的 - * 日期串一致;带时间精度就写 `'YYYY-MM-DD HH:mm'`,面板会多出时间列。 - */ - format: editor.format ?? DEFAULT_DATE_FORMAT, - valueFormat: editor.valueFormat, - placeholder: editor.placeholder, - clearable: editor.allowClear ?? true, - /** - * 无缝模式交给 G3DatePicker 自己处理(与 select 同因):触发器的真实 DOM 是 - * `div.g3-date-picker > [Popover] > span.g3-date-picker__trigger`,从表格侧写 - * 后代选择器压不住它自己的 border / radius / min-height(权重相同、顺序不可控)。 - */ - seamless: ctx.isSeamless?.(), - class: [ - e('cell-editor'), - e('cell-editor-root'), - activeCellClass(ctx, row, col, is), - editorStatusClass(status.level, editor.errorStyle, is), - ], - 'aria-label': status.message - ? `${col.title || col.dataIndex},${status.message}` - : col.title || col.dataIndex, - 'data-validate-message': status.message || undefined, - /** - * 照 select 的路子**立即写回**,不用 input 那套草稿: - * G3DatePicker 内部自己管草稿(手输的半成品文本存在它的 `editingText` 里), - * 只在真正确定的时刻才 emit —— 手输失焦、点选日期、或带时间精度时点「确定」。 - * 所以表格侧拿到的一定是完整值,不存在「每敲一个字符就回写行数据」的问题。 - */ - 'onUpdate:modelValue': (value: unknown) => { - const resolved = value ?? editor.emptyValue ?? null - row[col.dataIndex] = resolved as T[keyof T & string] - notifyChange(ctx, editor, row, col, resolved) - }, - onClick: stop, - /** - * 与 createInput 同一套分工:左右归输入框(日期是文本,用户会想改其中一段), - * 上下不拦、让给表格导航(切行)。 - * 面板打开时焦点在面板里,事件源不在这个输入框上,走不到这里 —— 无需特判。 - */ - onKeydown: (event: KeyboardEvent) => { - if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') event.stopPropagation() - }, - /** - * 挂载时聚焦,**并且**顺手把日历弹开 —— 「点日期格就出日历」是 ERP 里的常规预期 - * (点格的那次点击落在 td 上,触发不到 G3DatePicker 自己的「点输入框才展开」)。 - * - * 只在 focus 真的把焦点交出去的那一次弹(它返回值就是干这个用的):重渲染引起的 - * 重复聚焦不该把面板重新弹开 —— 用户可能刚选完日期把它关掉。 - */ - onVnodeMounted: (vnode: VNode) => { - if (focus(vnode)) openDatePanel(vnode) - }, - /** - * 手输的日期解析不出来时,**把原文写进行数据** —— 由上面那条内置的日期校验 - * 去标红并说明原因(与「金额输 0」「数量超精度」同一套处理:值不合法不等于 - * 要把它丢掉,而是要让人看见、并知道改成什么)。 - * - * 为什么必须由组件主动抛(而不是等卸载时去读输入框):DatePicker 在解析失败时 - * 会把内部草稿清掉、输入框随即被受控渲染回**原值**,等 vnode 卸载时再读 DOM - * 拿到的已经是原值了(实测踩到)。 - * - * ⚠️ 只在解析失败时写:合法值走的是 `onUpdate:modelValue`(已按 `valueFormat` - * 格式化好),这里拿到的原文格式未必等于存储格式,不能拿来覆盖。 - */ - onInvalidInput: (text: string) => { - row[col.dataIndex] = text as T[keyof T & string] - notifyChange(ctx, editor, row, col, text) - }, - onVnodeUpdated: focus, - }) -} - -/** - * 打开 VNode 上那个 G3DatePicker 的日历面板。 - * - * 编辑器是**函数式渲染**、没有 setup 上下文,直接拿不到组件实例;但 vnode 上带着 - * `component`,从它取 `exposed` 是 Vue 3 里可靠的做法(比用 DOM 去模拟点击触发器稳)。 - * 取不到就静默跳过 —— 降级结果只是「面板要再点一下才展开」,不值得为此抛错。 - */ -function openDatePanel(vnode: VNode) { - const exposed = (vnode.component as { exposed?: { openPanel?: () => void } } | null)?.exposed - exposed?.openPanel?.() -} - function createCheckbox( row: T, col: G3TableColumn, editor: TableEditor, ctx: EditableContext, -) { - const { e, is } = useNamespace('table') - // 约定:1 = 选中,0 = 未选中(与后端 tinyint 对齐,fms 同此约定) - const checked = row[col.dataIndex] === 1 +): VNode { + const dataIndex = col.dataIndex as string + const checked = row[dataIndex] === 1 const focus = ctx.isEditing(row) ? createFocusHandler(ctx, row, col) : undefined - /** - * checkbox 也支持校验态。 - * - * 它要参与「单元格亮红底」——否则同一行里 input 格报错、checkbox 格不报, - * 用户会以为那一列没问题。统一给类,让表格侧的选择器只写一次。 - */ - const status = resolveEditorStatus(editor, row, col) - const statusClass = editorStatusClass(status.level, editor.errorStyle, is) const update = (value: number) => { - row[col.dataIndex] = value as T[keyof T & string] + ;(row as TableRow)[dataIndex] = value notifyChange(ctx, editor, row, col, value) - // 与点击其它格**行为一致**:这一格成为当前格(行高亮 / 选中框 / 编辑会话)。 - // 顺序放在「值已落库 + 已通知父级」之后:镜像先落,重渲染再发生。 - ctx.onEditorInteract?.(row, col) } return h( 'label', { - class: [ - e('checkbox'), - e('cell-editor-root'), - activeCellClass(ctx, row, col, is), - { - [is('checked')]: checked, - // 用计算出的类名做 key,值为 false 时 Vue 会自动跳过 - [statusClass]: statusClass.length > 0, - }, - ], - // 失败原因挂在这里(checkbox 的根节点,与 input / select 用同一个属性名) - 'data-validate-message': status.message || undefined, + class: [e('checkbox'), e('cell-editor-root'), { [is('checked')]: checked }], onClick: stop, - // 库在 mousedown 阶段处理选区与行编辑激活;checkbox 是始终可直接操作的控件, - // 必须在此阶段隔离,否则第一次点击只激活编辑态、第二次才真正切换。 + // StkTable 在 mousedown 阶段处理单元格选区和行编辑。checkbox 是 + // 始终可直接操作的控件,需要在该阶段隔离事件,避免第一次点击 + // 只激活编辑态、第二次点击才真正切换。 onMousedown: stop, }, [ h('input', { type: 'checkbox', checked, - 'aria-label': col.title || col.dataIndex, + 'aria-label': col.title || dataIndex, onChange: (event: Event) => { update((event.target as HTMLInputElement).checked ? 1 : 0) }, onKeydown: (event: KeyboardEvent) => { - /** - * Space 是**原生** checkbox 的切换键(原生对 Enter 不响应); - * Enter 是表格用户的既有期待(连续录入时手不离 Enter)。 - * 两个都接:一个符合原生,一个符合表格习惯,都不会让人意外。 - */ - if (event.key !== 'Enter' && event.key !== ' ') return + if (event.key !== 'Enter') return event.preventDefault() event.stopPropagation() - const value = row[col.dataIndex] === 1 ? 0 : 1 + const value = row[dataIndex] === 1 ? 0 : 1 ;(event.currentTarget as HTMLInputElement).checked = value === 1 update(value) }, @@ -1002,36 +286,32 @@ function createCheckbox( } /** - * 生成某列的单元格渲染函数。 + * 为一个可编辑列生成 customCell 渲染函数(按行分发)。 * - * 注意返回值有三档:禁用 → 纯展示;checkbox → 始终可交互(无需先激活整行编辑); - * 其余 → 非编辑态走展示、编辑态挂编辑器。 + * 分发顺序与 fms 一致: + * 1. 按行禁用 → 纯展示(displayValue) + * 2. checkbox → 始终可交互,点击即切换,无需先让整行进入编辑态 + * 3. 该行不在编辑态 → 纯展示 + * 4. select → G3Select + * 5. 其余 → G3Input */ export function createEditableCell( col: G3TableColumn, ctx: EditableContext, ) { - const editor = col.editor as TableEditor - return ({ row }: { row: T }) => { - if (isEditorDisabled(editor, row)) return displayValue(ctx, row, col, editor) - if (editor.type === 'checkbox') return createCheckbox(row, col, editor, ctx) - if (!ctx.isEditing(row)) return displayValue(ctx, row, col, editor) - if (editor.type === 'select') return createSelect(row, col, editor, ctx) - /** - * 日期是**唯一「只给当前格挂编辑器」**的类型,其余类型在行编辑态下整行都挂。 - * - * 两个理由: - * 1. 切换格时新格是**挂载**,`onVnodeMounted` 才会发生 —— 日历面板才能跟着弹开。 - * 若跟着整行挂载,同行内从别的列切到日期格时不会重新挂载,面板就不弹了, - * 表现为「有时弹、有时不弹」这种最难解释的不一致。 - * 2. 顺带更省:日期格不激活时走纯展示(一个 span),而 DatePicker 是浮层组件, - * 比 input 重得多。 - */ - if (editor.type === 'date') { - return ctx.shouldFocus(row, col) - ? createDate(row, col, editor, ctx) - : displayValue(ctx, row, col, editor) + const editor = (col.editor ?? {}) as TableEditor + return ({ row }: { row: T }): VNode => { + if (isEditorDisabled(editor, row)) { + return displayValue(row, col, editor) } + // checkbox 始终可交互,点击即切换,无需先让整行进入编辑态 + if (editor.type === 'checkbox') { + return createCheckbox(row, col, editor, ctx) + } + if (!ctx.isEditing(row)) { + return displayValue(row, col, editor) + } + if (editor.type === 'select') return createSelect(row, col, editor, ctx) return createInput(row, col, editor, ctx) } } diff --git a/code/g3soft-libs/packages/ui/src/table/utils/precision.test.ts b/code/g3soft-libs/packages/ui/src/table/utils/precision.test.ts deleted file mode 100644 index f46089a3..00000000 --- a/code/g3soft-libs/packages/ui/src/table/utils/precision.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { G3TableColumn, TableRow } from '../types' -import { checkPrecision, formatDisplayValue, resolvePrecision } from './precision' - -const col = (over: Partial = {}): G3TableColumn => - ({ dataIndex: 'amount', title: '金额', ...over }) as G3TableColumn - -const row: TableRow = { amount: 0 } - -describe('checkPrecision', () => { - it('没配精度:一律通过(组件不做任何精度干预)', () => { - expect(checkPrecision(1.23456, undefined)).toBe('') - expect(checkPrecision('abc', undefined)).toBe('') - }) - - it('空值 / 非数字 / 展示文本:不归精度管(必填与「这不是个数字」是 validate 的事)', () => { - for (const value of [null, undefined, '']) expect(checkPrecision(value, 2)).toBe('') - expect(checkPrecision('abc', 2)).toBe('') - // 带千分位的是展示文本、不是数据,别拿它去判精度 - expect(checkPrecision('1,200.50', 2)).toBe('') - expect(checkPrecision('1e-7', 2)).toBe('') - }) - - it('精度内通过;超出即给出原因(原因里带位数,直接进悬停浮层)', () => { - expect(checkPrecision(12, 2)).toBe('') - expect(checkPrecision(12.5, 2)).toBe('') - expect(checkPrecision(12.56, 2)).toBe('') - expect(checkPrecision(12.567, 2)).toBe('最多 2 位小数') - // 负数:符号不参与位数计算 - expect(checkPrecision(-12.567, 3)).toBe('') - expect(checkPrecision(-12.567, 2)).toBe('最多 2 位小数') - expect(checkPrecision('12.567', 2)).toBe('最多 2 位小数') - expect(checkPrecision(0, 0)).toBe('') - expect(checkPrecision(0.5, 0)).toBe('最多 0 位小数') - }) - - it('靠字符串数位数:1.005 这种「看着两位、二进制里不是」的值不会误判', () => { - /** - * 这是本函数不用数值运算的原因:`1.005 * 100 = 100.49999999999999`, - * 按 `Math.round` 那一套会被算成 `1`(即「0 位小数」)—— - * 精度判定直接失效。字符串判定不受影响。 - */ - expect(checkPrecision(1.005, 3)).toBe('') - expect(checkPrecision(1.005, 2)).toBe('最多 2 位小数') - }) -}) - -describe('resolvePrecision', () => { - it('只对 number 编辑器生效', () => { - expect(resolvePrecision(col({ editor: { type: 'text', precision: 2 } }), row)).toBeUndefined() - expect(resolvePrecision(col({ editor: { type: 'select', precision: 2 } }), row)).toBeUndefined() - expect(resolvePrecision(col(), row)).toBeUndefined() - expect(resolvePrecision(col({ editor: { type: 'number', precision: 2 } }), row)).toBe(2) - }) - - it('非法配置当作没配(宁可不动,也别按半截配置改校验结果)', () => { - expect(resolvePrecision(col({ editor: { type: 'number', precision: -1 } }), row)).toBeUndefined() - expect( - resolvePrecision(col({ editor: { type: 'number', precision: Number.NaN } }), row), - ).toBeUndefined() - expect( - resolvePrecision( - col({ editor: { type: 'number', precision: Number.POSITIVE_INFINITY } }), - row, - ), - ).toBeUndefined() - }) - - it('函数形态按行取值(不同行/不同币种可以有不同精度)', () => { - const c = col({ - editor: { type: 'number', precision: (r) => (r.currency === 'JPY' ? 0 : 2) }, - }) - expect(resolvePrecision(c, { currency: 'JPY' })).toBe(0) - expect(resolvePrecision(c, { currency: 'CNY' })).toBe(2) - }) -}) - -describe('formatDisplayValue', () => { - it('什么都没配:原值返回(零开销路径,不多造字符串)', () => { - const value = { any: 'thing' } - expect(formatDisplayValue(value, row, col(), undefined)).toBe(value) - }) - - it('只配精度:按小数位格式化,且**不**隐式加千分位(precision 只管小数位)', () => { - expect(formatDisplayValue(1200, row, col(), 2)).toBe('1200.00') - }) - - it('配了 format 对象:走底座的 formatNumber(千分位 + 前缀)', () => { - const c = col({ format: { thousands: true, prefix: '¥' } }) - expect(formatDisplayValue(1200, row, c, 2)).toBe('¥1,200.00') - }) - - it('format.decimals 显式写了以它为准(需要「显示位数 ≠ 数据精度」时才写)', () => { - const c = col({ format: { decimals: 0 } }) - expect(formatDisplayValue(1200.6, row, c, 2)).toBe('1,201') - }) - - it('format 是函数:完全交给业务,组件不插手显示', () => { - const c = col({ format: (value: unknown) => `#${value}` }) - expect(formatDisplayValue(12, row, c, 2)).toBe('#12') - }) - - it('精度不合法:原样返回、不做任何格式化(屏幕上必须是真实值)', () => { - const c = col({ format: { thousands: true, prefix: '¥' } }) - expect(formatDisplayValue(123.456, row, c, 2)).toBe(123.456) - }) -}) diff --git a/code/g3soft-libs/packages/ui/src/table/utils/precision.ts b/code/g3soft-libs/packages/ui/src/table/utils/precision.ts deleted file mode 100644 index 4c8c27be..00000000 --- a/code/g3soft-libs/packages/ui/src/table/utils/precision.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { formatNumber } from 'stk-table-vue' -import type { FormatNumberOptions } from 'stk-table-vue' -import { h } from 'vue' -import { useNamespace } from '../../_composables/useNamespace' -import type { G3TableColumn, TableRow } from '../types' - -/** - * 数值精度(`editor.precision`)与展示格式化(`col.format`)的唯一实现处。 - * - * 抽成独立模块有两个理由: - * - * 1. 两者都是**纯函数**,与 Vue 渲染无关 —— 独立成文件才好单测。精度判定的边界 - * 全在字符串处理上(空值/非数字/半输入态/科学计数法),用真浏览器跑 E2E 去覆盖 - * 这些是最慢也最容易漏的验证方式。 - * 2. `checkPrecision` 要**对外导出**:业务在保存前做提交拦截时,必须能跑同一套判定。 - * 规则只藏在组件内部的话,业务只能自己再写一遍,两边迟早不一致 —— - * 要么「格子红了但存档能过」,要么「格子没红但存档被后端拒了」,两种都很难查。 - */ - -/** - * 解析某一格当前生效的精度。 - * - * `precision` 只对 `number` 编辑器生效 —— 在 text 列上写它没有意义,直接忽略, - * 免得出现「text 列也被判小数位」这种没人预期得到的行为。 - * - * 非法配置(负数 / NaN / 非数字)一律当作**没配**:宁可什么都不做,也不要拿着半截 - * 配置去改用户的校验结果。 - * - * ⚠️ 一格的一次渲染里它可能被调用**两次**(一次判校验态、一次做展示格式化), - * 所以业务写在 `precision` 里的函数要保持轻量 —— 与 `validate` 的约定一致。 - */ -export function resolvePrecision( - col: G3TableColumn, - row: T, -): number | undefined { - const editor = col.editor - if (!editor || editor.type !== 'number') return undefined - const raw = editor.precision - if (raw === undefined || raw === null) return undefined - const value = typeof raw === 'function' ? raw(row) : raw - if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined - return Math.floor(value) -} - -/** - * 精度判定:返回失败原因(`''` = 通过)。 - * - * 为什么用**字符串**数小数位,而不是做数值运算:`Math.round(v * 100) / 100` 那套在 - * `1.005` 这种值上给出的是 `1` 而不是 `1.01`(`1.005 * 100 = 100.49999999999999`), - * 而金额恰恰最容易撞上这种「看着是两位小数、二进制里不是」的值。 - * `String(value)` 就是用户敲进输入框的那串十进制数字,拿它数小数点后的位数既准确, - * 又不会引入新的浮点误差。 - * - * 边界(都是有意的): - * · 空值(`null` / `undefined` / `''`)→ **通过**。必填是 `validate` 的事,精度不越界管。 - * · 非数字(`abc`)→ **通过**。同上,「这不是个数字」也该由业务规则表达。 - * · 带千分位的字符串(`1,200.50`)→ **通过**。那是展示文本不是数据,不该拿来判精度。 - * · 科学计数法(`1e-7`)→ **通过**(没有小数点可数)。金额不会小到这个量级; - * 真到那一步,业务该用 `format` 函数自己格式化。 - */ -export function checkPrecision(value: unknown, precision?: number): string { - if (precision === undefined) return '' - if (value === null || value === undefined || value === '') return '' - const text = String(value).trim() - // 只认纯粹的十进制数字,其余一律放行(见上方边界说明) - if (!/^[+-]?\d*\.?\d+$/.test(text)) return '' - const decimals = text.split('.')[1]?.length ?? 0 - return decimals > precision ? `最多 ${precision} 位小数` : '' -} - -/** - * 展示态取值:返回**格式化后的字符串,或原值**(由调用方决定要不要再 `String()`)。 - * - * 优先级: - * 1. `format` 是函数 → 直接交给它。业务既然接管了显示,组件就不插手 —— - * 注意精度仍然照常参与**校验**,两者正交; - * 2. 精度不合法 → **原样返回**。理由见 types.ts 里 `precision` 的注释: - * 把 `123.456` 格式化成 `123.46` 会让屏幕完全合规、数据却不合规; - * 3. `format` 是对象 / 只配了 `precision` → 走底座的 `formatNumber`: - * 小数位取 `format.decimals ?? precision`(一处真源); - * 4. 什么都没配 → 原样返回。**这是零开销路径,多数列走它。** - */ -export function formatDisplayValue( - value: unknown, - row: T, - col: G3TableColumn, - precision: number | undefined, -): unknown { - const format = col.format - if (typeof format === 'function') return format(value, row, col) - if (precision === undefined && format === undefined) return value - if (checkPrecision(value, precision).length > 0) return value - - const options: FormatNumberOptions = { - ...format, - decimals: format?.decimals ?? precision, - // 只配了 precision 时**不要**隐式打开千分位:precision 的语义只是「小数位数」, - // 要不要千分位是 format 的事(底座默认开,这里显式关掉,免得越界改观感) - thousands: format ? (format.thousands ?? true) : false, - } - return formatNumber(value, options) -} - -/** - * 纯展示列(**没有** `editor`)配了 `format` 时用的单元格渲染。 - * - * 有 `editor` 的列走 `createEditableCell`(它内部同样调 `formatDisplayValue`), - * 这一条只服务「不可编辑但想格式化」的列 —— `format` 的语义不该因为 - * 「这一列能不能改」而变。 - * - * 代价只落在显式配了 `format` 的列上;其它纯展示列照旧走库的原生 td 渲染路径。 - */ -export function createFormatCell(col: G3TableColumn) { - const { e } = useNamespace('table') - return (props: { row: T; cellValue: unknown }) => - h( - 'span', - { class: e('cell-display') }, - String( - formatDisplayValue(props.cellValue, props.row, col, resolvePrecision(col, props.row)) ?? '', - ), - ) -} diff --git a/code/g3soft-libs/packages/ui/src/table/utils/selection.test.ts b/code/g3soft-libs/packages/ui/src/table/utils/selection.test.ts deleted file mode 100644 index 104bc9f8..00000000 --- a/code/g3soft-libs/packages/ui/src/table/utils/selection.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createSelectionColumn, isSelectionColumn } from './selection' -import type { TableRow } from '../types' - -interface Row extends TableRow { - id: number - name: string -} - -/** - * 选择列的测试。 - * - * 造的是「函数式组件」返回的 vnode,所以直接读 vnode 结构断言, - * 不挂载组件——完全确定性,也不依赖 happy-dom 的布局。 - */ - -function setup(options: { - data?: Row[] - selected?: Array - disabled?: number[] - multiple?: boolean -} = {}) { - const data = options.data ?? [ - { id: 1, name: 'a' }, - { id: 2, name: 'b' }, - { id: 3, name: 'c' }, - ] - const changes: Array<{ keys: Array; rows: Row[] }> = [] - const column = createSelectionColumn({ - getDataSource: () => data, - getRowKey: (row) => row.id, - getSelectedRowKeys: () => options.selected ?? [], - isRowDisabled: (row) => (options.disabled ?? []).includes(row.id), - onChange: (keys, rows) => changes.push({ keys, rows: rows as Row[] }), - }) - return { column, changes, data } -} - -type VNodeLike = { - type: string - props: Record - children?: unknown -} - -/** - * 把 vnode 的 class 读成扁平字符串。 - * - * 组件里 class 是 `[字符串, { 条件类: 布尔 }]` 这种「数组 + 对象」混合形式 - * (Vue 的标准写法),不能直接 join——第二个元素是对象。 - */ -function classOf(vnode: VNodeLike): string { - const raw = vnode.props.class - const out: string[] = [] - const walk = (value: unknown) => { - if (!value) return - if (typeof value === 'string') { - out.push(value) - return - } - if (Array.isArray(value)) { - value.forEach(walk) - return - } - if (typeof value === 'object') { - for (const [key, enabled] of Object.entries(value as Record)) { - if (enabled) out.push(key) - } - } - } - walk(raw) - return out.join(' ') -} - -/** 行单元格渲染函数 */ -function rowRender(column: ReturnType['column']) { - return column.customCell as unknown as (p: { row: Row }) => VNodeLike -} - -/** 表头单元格渲染函数 */ -function headerRender(column: ReturnType['column']) { - return column.customHeaderCell as unknown as () => VNodeLike -} - -function checkboxOf(vnode: VNodeLike) { - const children = vnode.children as VNodeLike[] - return children[0] -} - -function boxOf(vnode: VNodeLike) { - const children = vnode.children as VNodeLike[] - return children[1] -} - -/** 触发 input 的 change(模拟用户点击) */ -function fireChange(input: VNodeLike, checked: boolean) { - const handler = input.props.onChange as (e: unknown) => void - handler({ target: { checked }, stopPropagation: () => undefined }) -} - -describe('createSelectionColumn', () => { - it('列定义:内部标识 + 固定左侧 + 居中 + 默认宽度', () => { - const { column } = setup() - expect(isSelectionColumn(column)).toBe(true) - expect(column.fixed).toBe('left') - expect(column.align).toBe('center') - expect(column.headerAlign).toBe('center') - expect(column.width).toBe(44) - }) - - it('行复选框用原生 input,不引入 Checkbox 组件(零组件开销)', () => { - const { column } = setup() - const vnode = rowRender(column)({ row: { id: 1, name: 'a' } }) - // 根是 label,子节点第一个是原生 input - expect(vnode.type).toBe('label') - const input = checkboxOf(vnode) - expect(input.type).toBe('input') - expect(input.props.type).toBe('checkbox') - }) - - it('选中态:input.checked 与 is-checked 类同时反映', () => { - const { column } = setup({ selected: [2] }) - const render = rowRender(column) - - const checkedOne = render({ row: { id: 2, name: 'b' } }) - expect(checkboxOf(checkedOne).props.checked).toBe(true) - expect(classOf(checkedOne)).toContain('is-checked') - - const uncheckedOne = render({ row: { id: 1, name: 'a' } }) - expect(checkboxOf(uncheckedOne).props.checked).toBe(false) - expect(classOf(uncheckedOne)).not.toContain('is-checked') - }) - - it('勾选一行:把该 key 加入并回调(带行对象)', () => { - const { column, changes } = setup({ selected: [] }) - fireChange(checkboxOf(rowRender(column)({ row: { id: 2, name: 'b' } })), true) - expect(changes).toHaveLength(1) - expect(changes[0].keys).toEqual([2]) - expect(changes[0].rows.map((r) => r.id)).toEqual([2]) - }) - - it('取消勾选:从已选里移除', () => { - const { column, changes } = setup({ selected: [1, 2] }) - fireChange(checkboxOf(rowRender(column)({ row: { id: 1, name: 'a' } })), false) - expect(changes[0].keys).toEqual([2]) - }) - - it('禁用行:点击不触发回调', () => { - const { column, changes } = setup({ disabled: [2] }) - const vnode = rowRender(column)({ row: { id: 2, name: 'b' } }) - expect(checkboxOf(vnode).props.disabled).toBe(true) - fireChange(checkboxOf(vnode), true) - expect(changes).toHaveLength(0) - }) - - it('禁用行:类名带 is-disabled', () => { - const { column } = setup({ disabled: [2] }) - const vnode = rowRender(column)({ row: { id: 2, name: 'b' } }) - expect(classOf(vnode)).toContain('is-disabled') - }) - - it('表格根节点是 label,子节点为 input + 自绘方框', () => { - const { column } = setup() - const vnode = rowRender(column)({ row: { id: 1, name: 'a' } }) - expect(boxOf(vnode).type).toBe('span') - }) -}) - -describe('表头复选框:全选 / 半选判定', () => { - it('全选:所有行都在已选里', () => { - const { column } = setup({ selected: [1, 2, 3] }) - const header = headerRender(column)() - expect(checkboxOf(header).props.checked).toBe(true) - const cls = classOf(header) - expect(cls).toContain('is-checked') - expect(cls).not.toContain('is-indeterminate') - }) - - it('半选:部分选中', () => { - const { column } = setup({ selected: [1] }) - const header = headerRender(column)() - expect(checkboxOf(header).props.checked).toBe(false) - const cls = classOf(header) - expect(cls).toContain('is-indeterminate') - expect(cls).not.toContain('is-checked') - }) - - it('全不选:勾选框为空', () => { - const { column } = setup({ selected: [] }) - const header = headerRender(column)() - expect(checkboxOf(header).props.checked).toBe(false) - const cls = classOf(header) - expect(cls).not.toContain('is-checked') - expect(cls).not.toContain('is-indeterminate') - }) - - it('禁用行不算进「全选」分母:3 行里 1 行禁用、2 行选中即为全选', () => { - const { column } = setup({ selected: [1, 2], disabled: [3] }) - const header = headerRender(column)() - expect(checkboxOf(header).props.checked).toBe(true) - expect(classOf(header)).toContain('is-checked') - }) - - it('筛选后隐藏的 key 不干扰判定:数据里只剩 1 行时,选中它即为全选', () => { - // 已选里有 id=9(不在当前数据里,模拟翻页/筛选后残留) - const { column } = setup({ - data: [{ id: 1, name: 'a' }], - selected: [1, 9], - }) - const header = headerRender(column)() - expect(checkboxOf(header).props.checked).toBe(true) - expect(classOf(header)).not.toContain('is-indeterminate') - }) - - it('全选:把所有行 key 加进去', () => { - const { column, changes } = setup({ selected: [] }) - fireChange(checkboxOf(headerRender(column)()), true) - expect(changes[0].keys.sort()).toEqual([1, 2, 3]) - }) - - it('全选不包含禁用行', () => { - const { column, changes } = setup({ selected: [], disabled: [2] }) - fireChange(checkboxOf(headerRender(column)()), true) - expect(changes[0].keys.sort()).toEqual([1, 3]) - }) - - it('取消全选:清空', () => { - const { column, changes } = setup({ selected: [1, 2, 3] }) - fireChange(checkboxOf(headerRender(column)()), false) - expect(changes[0].keys).toEqual([]) - }) - - it('空数据:表头不显示为全选', () => { - const { column } = setup({ data: [], selected: [] }) - const header = headerRender(column)() - expect(checkboxOf(header).props.checked).toBe(false) - }) -}) diff --git a/code/g3soft-libs/packages/ui/src/table/utils/selection.ts b/code/g3soft-libs/packages/ui/src/table/utils/selection.ts index 7aa28e4f..796cf466 100644 --- a/code/g3soft-libs/packages/ui/src/table/utils/selection.ts +++ b/code/g3soft-libs/packages/ui/src/table/utils/selection.ts @@ -1,20 +1,22 @@ -import { computed, h } from 'vue' +import { computed, h, type VNode } from 'vue' import { useNamespace } from '../../_composables/useNamespace' import type { G3TableColumn, TableRow } from '../types' /** - * 多选列(照抄 fms 的 createSelectionCellRenderers.js)。 + * 多选列 —— 照抄 fms 的 `createSelectionCellRenderers.js`。 * * **为什么不用 `G3Checkbox` 组件**:表格是逐行渲染的,一个 Checkbox 组件实例 * = 一次 setup + 响应式 + vnode 树。滚动时这些实例不断挂载/卸载,是纯粹的浪费。 * 这里用原生 `` + 自绘方框,零组件开销; - * 视觉规格(16px、radius-sm、主色描边/填充、focus-ring)与 G3Checkbox 一致, - * 由 CSS 层模拟,不引入组件依赖。 + * 视觉规格(16px、圆角、主色描边/填充)由 CSS 层模拟,不引入组件依赖。 */ /** 单例事件处理器:避免每行渲染时创建新闭包 */ const stop = (event: Event) => event.stopPropagation() +/** 选择列的内部标识:用于从回写的 columns 里识别并剥离内部列 */ +export const SELECTION_COLUMN_KEY = '__g3Selection' + export interface SelectionColumnOptions { /** 取当前数据源(响应式) */ getDataSource: () => T[] @@ -28,17 +30,12 @@ export interface SelectionColumnOptions { onChange?: (keys: Array, rows: T[]) => void } -/** 选择列的内部标识:用于从回写的 columns 里识别并剥离内部列 */ -export const SELECTION_COLUMN_KEY = '__g3Selection' - /** 判断某列是否为选择列 */ export function isSelectionColumn(col: unknown): boolean { return (col as Record | null)?.[SELECTION_COLUMN_KEY] === true } -/** - * 创建选择列定义,返回可直接插入 columns 的列对象。 - */ +/** 创建选择列定义,返回可直接插入 columns 的列对象 */ export function createSelectionColumn( options: SelectionColumnOptions, ): G3TableColumn { @@ -81,21 +78,15 @@ export function createSelectionColumn( updateSelection(Array.from(nextKeys)) } - /** 行复选框 */ - const SelectionCell = ({ row }: { row: T }) => { + /** 行复选框:原生 ,零组件开销 */ + const SelectionCell = ({ row }: { row: T }): VNode => { const key = getRowKey(row) const disabled = isRowDisabled(row) const checked = selectedRowKeySet.value.has(key) return h( 'label', { - class: [ - e('checkbox'), - { - [is('checked')]: checked, - [is('disabled')]: disabled, - }, - ], + class: [e('checkbox'), { [is('checked')]: checked, [is('disabled')]: disabled }], onClick: stop, onMousedown: stop, }, @@ -122,7 +113,7 @@ export function createSelectionColumn( * 只统计**当前数据源里**的选中项:否则筛选/翻页后被隐藏的 key 会干扰 * 「全选」与「半选」的判定(表现为明明只选了一行,表头却是全选态)。 */ - const SelectionHeaderCell = () => { + const SelectionHeaderCell = (): VNode => { const rows = getDataSource().filter((row) => !isRowDisabled(row)) let selectedCount = 0 for (const key of selectedRowKeySet.value) { @@ -136,10 +127,7 @@ export function createSelectionColumn( { class: [ e('checkbox'), - { - [is('checked')]: allChecked, - [is('indeterminate')]: indeterminate, - }, + { [is('checked')]: allChecked, [is('indeterminate')]: indeterminate }, ], onClick: stop, }, diff --git a/code/thinking-effort-loaded.json b/code/thinking-effort-loaded.json new file mode 100644 index 00000000..1817e872 --- /dev/null +++ b/code/thinking-effort-loaded.json @@ -0,0 +1,6 @@ +{ + "event": "filled-12", + "name": "@hytime/dsh-thinking-effort", + "at": "2026-10-16T00:35:11.925Z", + "pid": 15040 +} \ No newline at end of file diff --git a/dsh-thinking-effort-fix.md b/dsh-thinking-effort-fix.md new file mode 100644 index 00000000..728b55a7 --- /dev/null +++ b/dsh-thinking-effort-fix.md @@ -0,0 +1,96 @@ +# dsh 思考强度报错解决方案(11128 请求被拦截 / developer role 400) + +> 适用症状:dsh(DeepSeek Harness)在 `settings.yaml` 给模型声明 `reasoningEfforts` 后(注意:**声明即触发,与选了哪个档位无关,含 off**),请求第三方/私有 OpenAI 兼容网关时失败: +> +> - 网关带内容审计时:`本轮运行失败 {"error":{"code":"11128","message":"请求被拦截,请重新发送","type":"invalid_request_error"}}` +> - 网关严格校验 role 时:`400 ... messages[0].role: unknown variant 'developer', expected one of 'system', 'user', 'assistant', 'tool', ...` +> +> 已于 2026-10-16 在 Windows + dsh 0.1.1-rc.2 上验证解决(示例 provider:coderelay,本地网关 `http://127.0.0.1:11435/v1`,上游带内容审计)。 + +## 根因(代码级因果链) + +1. `settings.yaml` 给模型声明 `reasoningEfforts`(字典)后,dsh-llm-pi-ai 的 `resolveModelReasoning` 把该模型标记为 `reasoning: true` 并生成 `thinkingLevelMap`。 +2. pi-ai 构造请求时据此决定系统提示词是否改用 `developer` role: + `openai-completions.js:787` — `useDeveloperRole = model.reasoning && compat.supportsDeveloperRole` +3. `supportsDeveloperRole` 的默认值按 provider 名 / baseURL 识别:只有非标准名单(nvidia / cerebras / xai / together / deepseek.com / zai / moonshot / opencode 等)默认 false;**私有网关不在名单里,默认 true**(`openai-completions.js:1148`)。 +4. 于是系统提示词以 `developer` role 发出。上游网关不认识(或审计层直接拦截)时:严格校验的报 400 反序列化错误;带内容审计的直接拦截报 11128「请求被拦截」。 + +上游参考:deepseek-ai/deepseek-harness **Discussion #636**(第三方模型设置思考强度报错),评论区有腾讯系网关 content-audit 拒绝 developer role 的实测结论。 + +## 解决方案:加一行 compat + +在 `~/.dsh/settings.yaml`(Windows:`C:\Users\<用户名>\.dsh\settings.yaml`)对应 provider 下加: + +```yaml +llm-pi-ai: + providers: + : + displayName: ... + apiKeyEnv: ... + api: openai-completions + baseURL: ... + compat: + supportsDeveloperRole: false # ← 新增:系统提示词改回 system role 发送 + models: + - id: ... + reasoningEfforts: + ... +``` + +- 配在 provider(路由)级即对该 provider 下所有模型生效;也可配在单个 model 级。逐字段优先级:**模型 → 路由 → 内置 catalog → pi-ai 自动检测**。 +- 思考强度功能保留,`reasoning_effort` 照常传递,只是 role 从 `developer` 改回 `system`。 +- 改完重启 dsh 生效。多个 provider 都报同样错时每个都加。 + +本机实例(coderelay): + +```yaml + coderelay: + displayName: coderelay + apiKeyEnv: CODERELAY_API_KEY + api: openai-completions + baseURL: http://127.0.0.1:11435/v1 + compat: + supportsDeveloperRole: false + models: + - id: deepseek-v4-pro + ... + - id: deepseek-v4.1-flash + ... + reasoningEfforts: + # ③ 逐模型声明可选档位 + off: # 提供"关闭"档,发送时省略参数 + low: low + medium: medium + high: high + max: max +``` + +## 新机器上先检查版本 + +`compat.supportsDeveloperRole` 需要 dsh-llm-pi-ai **0.1.1-rc.2 及以上**(更早版本 compat schema 没有这个开关,配置会被拒绝)。验证: + +```bash +grep -c "supportsDeveloperRole" "$(npm root -g)/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js" +``` + +返回 >0 即支持本方案;否则先把 dsh 升级到 0.1.1-rc.2(本机版本)再改配置。 + +## 改了还没好时的备用手段 + +按网关实际报错继续补 compat(同在 provider 的 compat 下): + +- 输出上限字段被拒:`maxTokensField: max_tokens`(未识别网关默认发 `max_completion_tokens`) +- 思考参数传输格式不对:`thinkingFormat: deepseek` 等 +- 替代路线: + 1. 本地起一个几十行的 Node 转发器:把请求体里 `messages[].role === "developer"` 改写为 `"system"` 后原样透传(含 reasoning_effort),settings.yaml 的 `baseURL` 指向转发器。 + 2. `dsh plugin --profile web add pi2dsh` + 对应网关的 Pi provider 包(Discussion #636 评论里的通用插件方案,较重)。 + 3. 保底:删掉模型的 `reasoningEfforts` 声明,并把 `agent-default-model.reasoningEffort` 一起去掉;代价是思考强度功能不可用。 + +## 相关路径速查 + +| 内容 | 路径 | +| --- | --- | +| 配置 | `~/.dsh/settings.yaml` | +| pi-ai developer role 判定 | `/node_modules/@deepseek-ai/dsh/node_modules/@earendil-works/pi-ai/dist/api/openai-completions.js`(787、1148 行) | +| 插件 compat schema | `/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js`(compatProfile,884 行起) | +| 插件中文文档 | 同目录 `README.zh.md`(含 `supportsDeveloperRole: false` 的官方私有网关示例) | diff --git a/table-two-state-keyboard-2025.changeset.md b/table-two-state-keyboard-2025.changeset.md new file mode 100644 index 00000000..a547b6f5 --- /dev/null +++ b/table-two-state-keyboard-2025.changeset.md @@ -0,0 +1,42 @@ +--- +'@g3soft/ui': minor +--- + +G3Table 行内编辑改为**两态模型**(选中态 / 编辑态),键盘交互按 ERP 表格规格重做。 + +**两种状态** + +| 状态 | 长什么样 | 怎么进入 | +| --- | --- | --- | +| 选中态 | 单元格有选中框,**格子里没有 input** | 单击、方向键 / `Tab` 移动 | +| 编辑态 | 格子里有 `input` / `textarea` | 双击、`Enter`、`F2`、直接打字、`editRow()` | + +**行为变更(会影响既有交互)** + +- **单击只选中,不再直接进入编辑**(改为双击)。这是本次最显眼的变化 —— 依赖"点一格就能打字"的页面需要改用双击,或保留单击后按 `Enter` / 直接打字。 +- 新增**选中态键位**:`Enter` 进编辑并全选、`F2` 进编辑且光标在末尾、可打印字符进编辑并**覆盖原值**、方向键只移动选中框。 +- **左右键改为"input 先消费、到边界才放行"**:光标在文本中间时归输入框移光标(有选区时同样归输入框),光标已贴在该方向边界或值为空时才交给表格切格。此前左右键一律归输入框,导致编辑态里无法用 `→` 逐格往右走。横向到行边界即停,不跨行(跨行用 `Tab` / `Enter`)。 +- `↑` `↓`(单行)由"不拦、放行给底座"改为由组件显式提交并移动;`Shift+↑↓` 与 `textarea` 的上下仍归输入框。 +- `F2`(编辑态)光标移到末尾。 + +**新增事件** + +- `edit-start`(编辑器已挂载、已聚焦、光标已就位)、`edit-end`(值已提交并离开该格)、`edit-cancel`(`Esc`,值已还原)、`value-change`(值实际变化)。 +- `value-change` 与既有的 `change` **同时抛出、语义相同**:改名会让已上线的 `@change` 绑定静默失效(Vue 对未声明的监听不报错),因此保留旧名。 + +- `Esc` 取消编辑现在对**所有**编辑器有效。`select` / `date` 是"立即写回并已抛过 `change`"的,取消会把该格改回进入时的值**并再抛一次 `change`**,父级据此回退(此前 `Esc` 只对文本框有效,在下拉 / 日历格里按 `Esc` 值已经改了却退不回去)。只回滚当前这一格,不牵连同一行里先前改过的其它格。 + +**缺陷修复** + +- **输入法(IME)**:组合态(`event.isComposing` 或 `keyCode === 229`)下不再拦截任何键。修前在候选框上按 `Enter` 确认候选词会被当成"确认并下移"——候选词上不了屏、光标还跳到下一行;候选框里 `↑↓` 选字也会被当成切行。 +- **`Esc` 在下拉 / 日历格里退不出编辑**:`select` / `date` 的值是立即写回行数据的,而 `Esc` 的取消分支只挂在文本框编辑器上,于是这两类格子按 `Esc` 既不还原也不退出编辑。现在统一接上,并做成**两段式**(第一记 `Esc` 关下拉 / 关日历,第二记才取消整格编辑)。 +- **`Esc` 的"还原"对 `select` / `date` 会还原成刚改的值**:去重用的快照(`initialValues`)与"进入编辑时的值"此前共用一份 Map,而 `shouldNotify` 每次真正变化都会把它推进到新值——对文本框无碍(提交前不通知),对立即写回的 `select` / `date` 则等于把"原值"换成了新值,回滚于是无效。现在两格概念分开存:去重一份(可推进)、进入值一份(只写一次)。 +- **跨行移动不提交、不抛 `change`**:编辑器只挂在当前编辑行,跨行移动时旧编辑器被**卸载**,而 DOM 元素被移除时浏览器**不派发 blur**,`onBlur` 那条提交路径整个不执行,值只剩兜底写入(写 raw、不响应式、不抛事件)。表现为"同一行内跳格会保存、跨行跳格静默丢失通知"。现在 `Tab` / 方向键 / `Enter` 移动前都显式提交一次。 +- **`Esc` 之后方向键 / 打字失灵**:原先把焦点还给 `td[data-cs-s]`,但库渲染表体 `td` 时**没有** `tabindex`(唯一带 `tabindex="-1"` 的是它内层的 `.table-cell-wrapper`),对 `td` 调 `focus()` 是静默无效的,焦点实际留在 `body`。改为把焦点交给内层容器 `.stk-table`(库给了它 `tabindex="0"`,底座的键盘导航也挂在那里)。 +- `edit-start` / `edit-end` 对同一次编辑会话各只抛一次(重渲染、显式提交后又补 `blur` 等重复路径不会重复抛)。 + +**其它** + +- 内部:`startEdit` 成为进入编辑态的**唯一入口**(规格 §六),插入符落点由触发源通过 `caret` 显式传达;`activateEditing` / `moveToCellBelow` 相应改名与泛化(`moveToAdjacent` 支持四向)。 +- 文档(`docs/content/ui/data/table.md`)与 `editable` 示例已同步到两态模型。 +- 单测从 162 增至 192:新增左右键边界判据、IME 组合态、四向移动、草稿显式提交等用例。 diff --git a/table-two-state-keyboard-2025.patch b/table-two-state-keyboard-2025.patch new file mode 100644 index 00000000..6d749b58 --- /dev/null +++ b/table-two-state-keyboard-2025.patch @@ -0,0 +1,3354 @@ +diff --git a/code/g3soft-libs/docs/content/ui/data/table.md b/code/g3soft-libs/docs/content/ui/data/table.md +index 3afa1b17..c044a2c7 100644 +--- a/code/g3soft-libs/docs/content/ui/data/table.md ++++ b/code/g3soft-libs/docs/content/ui/data/table.md +@@ -81,7 +81,7 @@ description: 高性能虚拟滚动表格,支持排序、固定列、多选、 + + ## 行内编辑 + +-列上加 `editor` 即可编辑:**单击**单元格进入(不需要双击)。完整键位见下方「键盘:单击即编辑,方向键分工」。 ++列上加 `editor` 即可编辑:**单击**选中单元格、**双击**(或 `Enter` / `F2` / 直接打字)进入编辑。完整键位见下方「两种状态:选中态与编辑态」。 + + ### 跳格 + +@@ -103,33 +103,91 @@ Tab / Shift+Tab 只在**可编辑格**之间走动:没有 `editor` 的列、 + | `select` | 用 [Select](/ui/form/select) 渲染;`options` 可以是数组,也可以是接收当前行的函数 | + | `checkbox` | 点击即切换,无需先激活整行;约定 `1` = 选中、`0` = 未选中(与后端 tinyint 对齐)。切换后**该格同时成为当前格**(行高亮 + 选中框 + 进入该行编辑),与点击其它单元格行为一致 | + +-编辑器的输入值先写进内部草稿,**失焦才落库并抛出 `change`**。这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。用 `editable={false}` 可整体禁用编辑。 ++编辑器的输入值先写进内部草稿,**提交时才落库并抛出 `change` / `value-change`**。这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。用 `editable={false}` 可整体禁用编辑。 + +-### 键盘:单击即编辑,方向键分工 ++### 两种状态:选中态与编辑态 + +-**单击单元格即进入编辑**(挂上编辑器并自动聚焦,可以直接打字;同行切列时重新挂载并全选,输入即替换整格内容)。这是本组件的既有交互:少一次点击。 ++表格里的键盘行为由**两种状态**决定,先认清状态,再看键位就不会困惑: + +-在这个前提下,方向键必须**显式分工** —— 否则会全部被输入框吃掉,人在表格里走不动: ++| 状态 | 长什么样 | 怎么进入 | 说明 | ++| --- | --- | --- | --- | ++| **选中态** | 单元格有选中框,**格子里没有输入框** | 单击单元格、方向键 / `Tab` 移动 | 默认状态;此时方向键只挪选中框,**不会**进编辑 | ++| **编辑态** | 格子内有 `input` / `textarea`,正在改值 | 双击、`Enter`、`F2`、直接打字、`editRow()` | 方向键归输入框,到边界才放行换格 | + +-| 按键 | 归谁 | 为什么 | +-| --- | --- | --- | +-| **左右** | 输入框(移动光标 / `Shift+左右` 选中文本) | 编辑文本的刚需:想改中间一位、想选中再覆盖 | +-| **上下**(单行) | **表格**(切到上 / 下一行的同一列,并进入编辑) | 单行输入框里上下键**没有任何原生用途**,让出去是零损失 | +-| `Shift+上下` | 输入框(选中到开头 / 结尾) | 原生的有效操作 | +-| **上下**(`textarea`) | 输入框(跨行移动光标) | 多行里那是有效操作 | ++一句话概括:**单击选中,双击编辑,打字覆盖;非编辑态方向键归表格,编辑态方向键先归输入框(到边界才放行)。** + +-其它键: ++### 选中态键位 + + | 按键 | 行为 | + | --- | --- | +-| `Enter`(单行) | 确认 + 移到下一行同列(表尾即停,不循环) | ++| `↑` `↓` `←` `→` | 切换选中单元格(上下换行 / 左右换列),**不进入编辑** | ++| `Tab` / `Shift+Tab` | 向右 / 向左切换选中格(跳过只读列,行尾接下一行并可循环) | ++| `Enter` | 进入编辑态,**全选**内容(直接输入即替换整格) | ++| `F2` | 进入编辑态,**光标在末尾**(想接着改末尾时用它) | ++| 可打印字符(`a-z`、`0-9`、中文…) | 进入编辑态,**清空原值、只填入该字符**(Excel 的覆盖语义) | ++| `Esc` | 无操作 | ++ ++`Space` 刻意**不算**可打印字符(它在表格里的通行语义是翻页 / 触发,也是复选框的切换键)。 ++ ++### 编辑态键位 ++ ++| 按键 | 行为 | ++| --- | --- | ++| `←` `→` | **先让输入框移动光标**;仅当**值为空**、或光标已在该方向**边界**时,才切到相邻单元格 | ++| `↑` `↓`(单行) | 提交当前格,切到上 / 下行的同列并**进入编辑** | ++| `Shift+↑/↓/←/→` | 归输入框(选中到首尾 / 收缩选区),不切格 | ++| `↑` `↓`(`textarea`) | 归输入框(跨行移动光标),不切格 | ++| `Tab` / `Shift+Tab` | 提交当前格,切到右 / 左单元格并**进入编辑**(跳过只读列、行尾接下一行、两端循环) | ++| `Enter`(单行) | 提交当前格,切到**下一行同列**并进入编辑(表尾即停,不循环) | + | `Enter`(`textarea`) | 换行(浏览器原生语义) | +-| `Esc` | 放弃本次编辑(回滚到进入前的值),并**把焦点还给该格** —— 可以接着用上下键走 | +-| `Tab` / `Shift+Tab` | 确认并跳到下一个**可编辑**格(跳过只读列与按行禁用的格、行尾自动接下一行、两端循环) | +-| `Space`(`checkbox`) | 切换勾选(原生 checkbox 的切换键);`Enter` 同样有效 —— 连续录入时手不用离开 Enter | +-| 失焦(点别处 / 用上下键走开) | **提交**并抛出 `change` | ++| `F2` | 光标移到末尾(已在编辑态时的合理响应) | ++| `Esc` | 取消编辑,**还原为原值**,回到选中态(焦点交给表格,可接着用方向键走) | ++| `Space`(`checkbox`) | 切换勾选(原生 checkbox 的切换键);`Enter` 同样有效 | ++| 失焦(点别处 / 走开) | **提交**并抛出 `change` | ++ ++⚠️ **左右键的"边界才放行"是本组件最容易做错的一处**:若一律让给输入框,编辑态里就永远无法横向换格(只能靠 `Tab`,手感是"跳"而不是"逐格走");若一律给表格,就没法改金额中间那一位。判据因此取"输入框还能不能消费这一记按键"——有选区、或光标不在边界时归输入框,否则归表格。 ++ ++⚠️ 横向移动**不跨行**:在行末按 `→` 停在原地(要跨行请用 `Tab` / `Enter`)。纵向到表尾同理即停,不循环。 ++ ++⚠️ **`Esc` 对下拉 / 日历是两段式的**:`select` 展开时第一记 `Esc` 关下拉,第二记才取消整格编辑;`date` 面板同理(第一记关面板、第二记取消编辑)。这是刻意的——面板开着时用户按 `Esc` 想关的多半是面板,一次按键同时关面板又退编辑会让人措手不及。 ++ ++⚠️ `Esc` 取消对**所有**编辑器都有效,但"还原"的实现按值的存放位置分两类:`text` / `number` / `textarea` 的值在草稿里、提交前不曾通知父级,取消即丢弃草稿;`select` / `date` 的值是**立即写回并已抛过 `change`** 的,取消会把这一格改回进入时的值**并再抛一次 `change`**(父级据此回退,两侧数据不会分叉)。`Esc` 只回滚**当前这一格**,不会牵连同一行里先前改过的其它格。 ++ ++#### 输入法(IME) ++ ++组合态(`event.isComposing` 或 `keyCode === 229`)下**一律不拦截任何键**。这是中文录入的硬要求:候选框上按 `Enter` 确认候选词时,`key` 同样是 `'Enter'`——不挡就会被当成"确认并下移",表现为**候选词上不了屏、光标还跳到了下一行**。同理候选框里的 `↑↓` 选字也不会被当成切行。 ++ ++### 鼠标 ++ ++| 操作 | 行为 | ++| --- | --- | ++| 单击 | **选中**单元格(进入选中态,不出现输入框) | ++| 双击 | **进入编辑态**,光标定位到双击处的字符 | ++| 单击已选中的单元格 | 保持选中,不进编辑 | ++| 点页面别处 | 退出编辑并**提交**当前格 | ++ ++### 事件 ++ ++| 事件 | 触发时机 | ++| --- | --- | ++| `edit-start` | 进入编辑态**之后**(编辑器已挂载、已聚焦、光标已就位) | ++| `edit-end` | 编辑完成、值已提交(移动 / 失焦都算),随后离开这一格 | ++| `edit-cancel` | 取消编辑(`Esc`),值**已还原** | ++| `value-change` | 值实际发生变化(提交后新旧值不同) | ++| `change` | 与 `value-change` 同时抛出,语义完全相同(既有名字,保留兼容) | ++ ++```vue ++ ++``` + +-⚠️ **左右键不能让给表格**:它一旦用来切列,就没法在输入框里移动光标 —— 同一个键不可能既移光标又切格。横向切换请用 `Tab`(它还会跳过只读列,比按列 ±1 更贴合录入语境)。 ++⚠️ **`edit-start` 只在"用户刚激活这一格"时抛一次**,重渲染引起的重复聚焦不会再抛——否则按它做埋点会收到一串重复事件。同理 `edit-end` / `edit-cancel` 对同一次会话各只抛一次。 + + ### 编辑器形态:单元格内无外间距、无常驻边框 + +diff --git a/code/g3soft-libs/docs/examples/table/editable.vue b/code/g3soft-libs/docs/examples/table/editable.vue +index 2e73d38c..a37e5738 100644 +--- a/code/g3soft-libs/docs/examples/table/editable.vue ++++ b/code/g3soft-libs/docs/examples/table/editable.vue +@@ -3,7 +3,9 @@ import { ref } from 'vue' + import { G3Table } from '@g3soft/ui' + import type { G3TableColumn, TableRow } from '@g3soft/ui' + +-// 行内编辑:列上加 editor 即可。单击单元格进入编辑,Enter 确认、Esc 取消。 ++// 行内编辑:列上加 editor 即可。 ++// 两种状态:单击**选中**单元格(不出现输入框);双击 / Enter / F2 / 直接打字进入编辑。 ++// 编辑态下 Enter 提交并下移、Esc 取消还原、Tab 提交并跳格、上下键换行、左右键到边界才换格。 + // + // 编辑器类型: + // text / number / password / textarea → 用 G3Input 渲染 +@@ -17,7 +19,7 @@ import type { G3TableColumn, TableRow } from '@g3soft/ui' + // 传 :editor-seamless="false" 则回到「格内留一圈间距 + 自身描边」的悬浮卡片观感。 + // 两种形态下编辑器的盒高都等于行高,切换不会改变行高、也不会让同行其它格跳动。 + // +-// 关键行为:输入值先写进内部草稿,**失焦才落库并抛出 change**。 ++// 关键行为:输入值先写进内部草稿,**提交时**才落库并抛出 change / value-change。 + // 这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。 + const columns: G3TableColumn[] = [ + { dataIndex: 'name', title: '名称', width: 200, editor: { type: 'text' } }, +@@ -82,7 +84,8 @@ function onChange(row: TableRow, col: G3TableColumn, value: unknown) { + 最近一次 change:{{ lastChange || '(点击单元格试试)' }} +

+

+- 单击进入编辑 · Enter 确认 · Esc 取消 · Tab / 方向键可换格 · ++ 单击选中 · 双击(或 Enter / F2 / 直接打字)进入编辑 · 编辑态里 Enter 提交并下移 · Esc 取消 · ++ Tab / 方向键换格 · + 单元格内无外间距、无常驻边框,焦点时才出现主色焦点环 +

+
+diff --git a/code/g3soft-libs/packages/ui/e2e/table.spec.ts b/code/g3soft-libs/packages/ui/e2e/table.spec.ts +index 32a03ae1..4c34e6f5 100644 +--- a/code/g3soft-libs/packages/ui/e2e/table.spec.ts ++++ b/code/g3soft-libs/packages/ui/e2e/table.spec.ts +@@ -1,11 +1,47 @@ + import { expect, test } from '@playwright/test' ++import type { Locator } from '@playwright/test' ++ ++/** ++ * 让某一格进入**编辑态**(双击 —— 规格 §四:单击只选中,双击才编辑)。 ++ * ++ * 为什么抽成公共函数而不是每处写 `.click()`:进编辑的路径换了(单击 → 双击), ++ * 而用例里有几十处 `await cell.click()` 本意都是"进编辑"。集中成一个语义化的名字, ++ * 下次交互再变时只需改这一处,而不是靠 grep 猜哪些 click 是"进编辑"、哪些是"纯点击"。 ++ * ++ * ⚠️ 不要改成两次 `click()`:Playwright 的 `dblclick` 会带上正确的 `detail: 2` 与 ++ * 双击间隔,手写两次 click 在部分内核上不会被识别成双击。 ++ */ ++async function enterEdit(cell: Locator) { ++ await cell.dblclick() ++} ++ ++/** ++ * 当前**被选中**的那一格(选中态)。 ++ * ++ * 两态模型下,"哪一格是当前格"在选中态里由**库的选区**表达(`td[data-cs-s]`), ++ * 而不是焦点 —— 选中态下焦点在表格容器上(`td` 没有 tabindex、不可聚焦), ++ * 所以 `focusedCell` 那套读法在选中态里恒为 null。两个 helper 各管一个状态。 ++ */ ++async function selectedCell(page: import('@playwright/test').Page) { ++ return page.evaluate(() => { ++ const td = document.querySelector('td[data-cs-s]') as HTMLElement | null ++ if (!td) return null ++ return { ++ colKey: td.getAttribute('data-col-key'), ++ rowIndex: td.closest('tr')?.getAttribute('data-row-i') ?? null, ++ } ++ }) ++} + + /** + * G3Table 的几何 / 命中类断言 —— happy-dom 没有布局引擎,这些只能在真浏览器里量。 + * + * 用例只按 data-testid 找元素(不依赖文案与结构),宿主页在 playground/main.ts。 ++ * ++ * ⚠️ **进入编辑态用 `enterEdit`(双击),不要用单次 `.click()`**。 ++ * 表格是**两态模型**:单击只选中(不出现输入框),双击 / `Enter` / `F2` / 打字才进编辑。 ++ * 早先的用例普遍用 `.click()` 直接进编辑,那是旧的"单击即编辑"交互。 + */ +- + test.describe('G3Table 表格', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') +@@ -216,7 +252,7 @@ test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => { + + const before = await rowHeights(page, '[data-testid="table-wrap"]') + +- await (await cellByTitle(page, '[data-testid="table-wrap"]', '备注')).click() ++ await enterEdit(await cellByTitle(page, '[data-testid="table-wrap"]', '备注')) + const editor = wrap.locator(EDITOR).first() + await expect(editor).toBeVisible() + +@@ -254,7 +290,7 @@ test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => { + const wrap = page.locator('[data-testid="table-wrap"]') + await wrap.scrollIntoViewIfNeeded() + +- await (await cellByTitle(page, '[data-testid="table-wrap"]', '备注')).click() ++ await enterEdit(await cellByTitle(page, '[data-testid="table-wrap"]', '备注')) + await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible() + + const padding = await wrap +@@ -282,7 +318,7 @@ test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => { + const idle = await target.evaluate((el) => getComputedStyle(el).boxShadow) + expect(idle).not.toContain('inset') + +- await target.click() ++ await enterEdit(target) + await expect(wrap.locator(EDITOR).first()).toBeVisible() + + // 焦点环画在**被点的那一格**上(限定 target,避免匹配到同行其它格) +@@ -298,7 +334,7 @@ test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => { + const before = await rowHeights(page, '[data-testid="table-wrap"]') + + // 双击进编辑(表格是「单击选中、进编辑另有其键」的两态模型,见 startEdit 的说明) +- await (await cellByTitle(page, '[data-testid="table-wrap"]', '状态')).click() ++ await enterEdit(await cellByTitle(page, '[data-testid="table-wrap"]', '状态')) + const select = wrap.locator('.g3-table__cell-editor-root.g3-select').first() + await expect(select).toBeVisible() + +@@ -360,7 +396,7 @@ test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => { + await wrap.scrollIntoViewIfNeeded() + + const cell = await cellByTitle(page, '[data-testid="table-wrap"]', '状态') +- await cell.click() ++ await enterEdit(cell) + + // 前置条件:浮层确实拿到了焦点,且焦点已不在表格里(这正是修前的失效条件) + await expect(page.locator('.g3-select__dropdown')).toBeVisible() +@@ -550,12 +586,12 @@ test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => { + await expect(cell.locator('.g3-table__cell-display')).toHaveText('¥12,000.00') + + /** +- * ② 输入一个超精度的值,再点**另一个数据格**让它失焦提交。 ++ * ② 输入一个超精度的值(双击进入编辑),再点**另一个数据格**让它提交并退出编辑。 + * + * 这里不能点表头:编辑态的退出是由单元格选区变化驱动的,点表头不会退出编辑 —— + * 那样这一格还挂着编辑器,下面针对「展示态 span」的断言会直接找不到元素。 + */ +- await cell.click() ++ await enterEdit(cell) + await cell.locator('input').fill('1.2345') + await wrap.locator('.stk-tbody-main tr').nth(2).locator('td[data-col-key="amount"]').click() + await page.waitForTimeout(200) +@@ -571,8 +607,8 @@ test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => { + */ + await expect(cell.locator('.g3-table__cell-display')).toHaveText('1.2345') + +- // ⑤ 值也没被四舍五入:再点开编辑,输入框里还是用户敲的那串 +- await cell.click() ++ // ⑤ 值也没被四舍五入:再双击回到编辑,输入框里还是用户敲的那串 ++ await enterEdit(cell) + await expect(cell.locator('input')).toHaveValue('1.2345') + + /** +@@ -624,15 +660,15 @@ test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => { + .evaluate((el) => getComputedStyle(el).paddingRight) + expect(await displayPad(1)).toBe(await displayPad(0)) + +- // ② 进入编辑:底色 = 基色(亮色主题即 #fff),只剩语义色焦点环 +- await errCell.click() ++ // ② 进入编辑(双击):底色 = 基色(亮色主题即 #fff),只剩语义色焦点环 ++ await enterEdit(errCell) + await expect(wrap.locator('.is-cell-editing')).toHaveCount(1) + const editing = await read() + expect(editing.bg).toBe('rgb(255, 255, 255)') + expect(editing.badge).toBe('none') + expect(editing.shadow).toContain('2px') + +- // ③ 换到别的格 → 它不再是编辑格,底色回来 ++ // ③ 单击别的格 → 退出编辑,它不再是编辑格,底色回来 + await wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="amount"]').click() + await expect.poll(async () => (await read()).bg).not.toBe('rgb(255, 255, 255)') + }) +@@ -670,7 +706,7 @@ test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => { + expect(after).toBeGreaterThan(before) + }) + +- test('点击行进入编辑时,当前行高亮(active)跟随', async ({ page }) => { ++ test('单击可编辑格:当前行高亮(active)跟随', async ({ page }) => { + await page.goto('/') + const wrap = page.locator('[data-testid="table-wrap"]') + await wrap.scrollIntoViewIfNeeded() +@@ -682,7 +718,13 @@ test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => { + wrap.locator('.stk-tbody-main tr.active').count() + expect(await activeCount()).toBe(0) + +- // 点第 2 行的一个可编辑格(金额列),进入编辑并高亮 ++ /** ++ * 单击第 2 行的一个可编辑格(金额列)→ 选中它并高亮该行。 ++ * ++ * ⚠️ 单击**只选中**(两态模型),不进入编辑 —— 但当前行高亮必须照样跟上: ++ * 键盘不产生 click,调用方的 `@row-click` 收不到消息,所以"哪一行是当前行" ++ * 只能由组件在选区变化时同步(见 handleAreaSelectionChange 里那段注释)。 ++ */ + const ths = await wrap.locator('thead th').evaluateAll((all) => + all.map((t) => (t.textContent || '').trim()), + ) +@@ -961,9 +1003,9 @@ test.describe('G3Table 固定列 + 校验态', () => { + const firstRow = rows.first() + await expect(firstRow).not.toHaveClass(/active/) + +- // 「名称(校验列)」是这张表里的可编辑列(带 editor),点它进入编辑 ++ // 「名称(校验列)」是这张表里的可编辑列(带 editor),双击它进入编辑 + const editableCell = await cellByTitleIn(page, WRAP, '名称(校验列)') +- await editableCell.click() ++ await enterEdit(editableCell) + await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible() + + // 当前行必须高亮 —— 这正是调用方靠 row-click 拿不到的那次点击 +@@ -1015,7 +1057,7 @@ test.describe('G3Table 固定列 + 校验态', () => { + + const firstRow = wrap.locator('.stk-tbody-main tr').first() + const nameCell = await cellByTitleIn(page, WRAP, '名称(校验列)') +- await nameCell.click() ++ await enterEdit(nameCell) + + /** + * 编辑格:应当只有「一圈 2px 内描边」(焦点环),**不带**行状态的大范围叠色。 +@@ -1101,7 +1143,7 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => { + await wrap.scrollIntoViewIfNeeded() + + // 进入第一行「金额」格编辑(number 编辑器,焦点在原生 input 上) +- await wrap.locator('td[data-col-key="amount"]').first().click() ++ await enterEdit(wrap.locator('td[data-col-key="amount"]').first()) + await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible() + expect(await focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '0' }) + +@@ -1137,7 +1179,7 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => { + await wrap.scrollIntoViewIfNeeded() + + // 从第二行「金额」往回:先跨过只读的「操作」列落到上一行「备注」 +- await wrap.locator('td[data-col-key="amount"]').nth(1).click() ++ await enterEdit(wrap.locator('td[data-col-key="amount"]').nth(1)) + await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible() + expect(await focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '1' }) + +@@ -1169,7 +1211,7 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => { + const wrap = page.locator(WRAP) + await wrap.scrollIntoViewIfNeeded() + +- await wrap.locator('td[data-col-key="amount"]').first().click() ++ await enterEdit(wrap.locator('td[data-col-key="amount"]').first()) + await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible() + expect(await focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '0' }) + +@@ -1204,7 +1246,7 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => { + const big = page.locator(BIG) + await big.scrollIntoViewIfNeeded() + +- await big.locator('.stk-tbody-main tr[data-row-i="0"] td[data-col-key="name"]').click() ++ await enterEdit(big.locator('.stk-tbody-main tr[data-row-i="0"] td[data-col-key="name"]')) + await expect(big.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible() + expect(await focusedCell(page)).toEqual({ colKey: 'name', rowIndex: '0' }) + +@@ -1229,21 +1271,26 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => { + * 用户实测 100% 复现的手势:在 ERP 里点「订舱号」(只读、没有编辑器)那一格, + * 然后一直按着 Tab 往右走,走到「起运港」时就掉出表格了。 + * +- * 成因是两条叠加出来的同一个状态 ——「编辑会话在,但 DOM 里一个编辑器都没有」: +- * · 只读列起步:编辑态是 `editing=true` 而 `focusCol=null`,没有任何编辑器被挂载; +- * · 底座的**连按 / 长按优化**:处理 repeat 事件时会 `clearActiveEditing()`(把 +- * `editing` 也置 false),长按期间刻意保持 0 编辑器,等松手再挂回来。 ++ * 成因是两条叠加出来的同一个状态 ——「会话在,但 DOM 里一个编辑器都没有」: ++ * · 只读列起步:没有任何编辑器会被挂载; ++ * · 底座的**连按 / 长按优化**:处理 repeat 事件时会 `clearActiveEditing()`, ++ * 长按期间刻意保持 0 编辑器,等松手再挂回来。 + * 旧判据只认 `editing === true`,于是从第 3 下起就认为「不是给表格的 Tab」而放行, + * 浏览器按文档顺序把焦点送出表格。 ++ * ++ * ⚠️ 两态模型下这条路径落在**选中态**(点只读列只选中、不进编辑), ++ * 所以断言的是**选区**(`selectedCell`)而不是焦点 —— 选中态下焦点在表格容器上, ++ * 没有"聚焦在某格输入框"可言。要点不变:Tab 必须被接管、不许掉出表格。 + */ + test('按住 Tab(autoRepeat)从只读列起步:一格一格前进,不掉出表格', async ({ page }) => { + await page.goto('/') + const wrap = page.locator(WRAP) + await wrap.scrollIntoViewIfNeeded() + +- // 「名称」列没有 editor(对应 ERP 的「订舱号」),点它建立编辑会话 ++ // 「名称」列没有 editor(对应 ERP 的「订舱号」)—— 点它只选中,不产生编辑会话 + await wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="name"]').click() +- await expect(wrap.locator('.is-cell-editing')).toHaveCount(0) ++ await expect.poll(() => selectedCell(page)).toEqual({ colKey: 'name', rowIndex: '0' }) ++ expect(await wrap.locator('.is-cell-editing').count()).toBe(0) + + // 按住不放:Playwright 对**已按下**的键会把后续 keydown 标成 autoRepeat,与真实长按一致 + await page.keyboard.down('Tab') +@@ -1251,26 +1298,27 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => { + await page.keyboard.down('Tab') + await page.waitForTimeout(250) + } +- // 长按期间底座刻意保持 0 编辑器 —— 这正是「会话在但没编辑器」那个状态 +- expect(await wrap.locator('.is-cell-editing').count()).toBe(0) + await page.keyboard.up('Tab') + +- // 四下 = 名称(只读) → 金额 → 状态 → 启用 → 备注;松手后编辑器落到最后一格 +- await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' }) ++ // 四下 = 名称(只读) → 金额 → 状态 → 启用 → 备注(一格一格走,没有掉出表格) ++ await expect.poll(() => selectedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' }) ++ // 焦点仍在表格里(修前会掉到页面其它元素上) + expect( + await page.evaluate(() => !!document.activeElement?.closest('[data-testid="table-wrap"]')), + ).toBe(true) + }) + + /** +- * 复选框与其它单元格**行为一致**:点一下就切换(不需要先激活整行), +- * 并且这一格随之成为当前格(行高亮 + 选中框 + 编辑会话),键盘也能从这里继续走。 ++ * 复选框:点一下就切换(不需要先进编辑),并且这一格随之成为**当前格**。 + * + * 回归的 bug:为了「一次点击就切换」,createCheckbox 把 mousedown 停掉了(浏览器在 +- * 节点被替换时不派发 click,不停就会变成「第一次点击只进编辑、第二次才切换」)。 +- * 但停了之后底座收不到 mousedown,也就不会像点击其它格那样设选区、激活该行编辑、 +- * 置当前行 —— 实测后果是:值改了、`change` 也抛了,可整行不高亮、格子没有选中框, ++ * 节点被替换时不派发 click,不停就会变成「第一次点击进编辑、第二次才切换」)。 ++ * 但停了之后底座收不到 mousedown,也就不会像点击其它格那样设选区、置当前行 —— ++ * 实测后果是:值改了、`change` 也抛了,可整行不高亮、格子没有选中框, + * 而且表格里**没有任何选区**,接着按 Tab 会从 (0,0) 起步、焦点掉到 `body`。 ++ * ++ * ⚠️ 两态模型下点复选框与点其它格一致:**只选中**(`onEditorInteract` 把它设为当前格), ++ * 不再顺带激活整行编辑 —— 复选框自己始终可点,不需要编辑态。 + */ + test('点复选框:一次点击即切换,且该格成为当前格(键盘可继续走)', async ({ page }) => { + await page.goto('/') +@@ -1286,14 +1334,13 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => { + // ① 一次点击就切换(这条是原本就正确的行为,别改坏) + expect(await cell.locator('input').isChecked()).toBe(!before) + +- // ② 这一格成为当前格:行高亮 + 选中框 + 编辑格标记 ++ // ② 这一格成为当前格:行高亮 + 选中框 + await expect(firstRow).toHaveClass(/active/) + await expect(cell).toHaveAttribute('data-cs-s') +- expect(await cell.evaluate((el) => !!el.querySelector('.is-cell-editing'))).toBe(true) + + // ③ 于是键盘可以从这里继续走:启用 → 备注(同行下一个可编辑列) + await page.keyboard.press('Tab') +- await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' }) ++ await expect.poll(() => selectedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' }) + }) + }) + +@@ -1323,37 +1370,116 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => { + /** + * 方向键的分工(编辑器 vs 表格)。 + * +- * 本组件的交互是**单击即进入行编辑**(点一格就能直接打字,少一次点击)。在这个前提下, +- * 方向键必须显式分工 —— 否则会全部被输入框吃掉,人在表格里根本走不动: ++ * 表格是**两态模型**(见 docs 的「两种状态:选中态与编辑态」)。方向键的归属跟着状态走: + * +- * · **左右** → 交给输入框移动光标 / `Shift+左右` 选中文本(「想改中间一位」的刚需); +- * · **上下** → 让给表格导航,切到上 / 下一行的同一列 +- * (单行输入框里上下键**没有任何原生用途**,让出去是零损失); +- * · `Shift+上下` → 仍归输入框(原生语义是「选中到首尾」); +- * · `textarea` 里的上下 → 也归输入框(要跨行移动光标)。 ++ * · **选中态**:方向键只挪选中框,不进编辑(本组前半段); ++ * · **编辑态**:`←` `→` **先归输入框**(有选区、或光标不在边界), ++ * 到边界 / 值为空才**交给表格切格**;`↑` `↓` 单行归表格(切上 / 下行同列并继续编辑); ++ * · `Shift+左右/上下` → 永远归输入框(选中文本 / 收缩选区,是有效操作); ++ * · `textarea` 里的 `↑↓` → 归输入框(跨行移动光标)。 + * + * 宿主是 playground 的 `table-keys-wrap`:text / number / textarea 三列。 + */ +-test.describe('G3Table 方向键分工:左右归输入框、上下给导航', () => { +- test('单击即进入编辑:挂上编辑器并自动聚焦', async ({ page }) => { ++test.describe('G3Table 方向键分工:边界放行、上下给导航', () => { ++ test('单击只选中:不挂编辑器、不聚焦输入框', async ({ page }) => { + await page.goto('/') + const wrap = page.locator('[data-testid="table-keys-wrap"]') + await wrap.scrollIntoViewIfNeeded() + const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]') + + await cell.click() ++ ++ // 选中态:格子被选中(库的 data-cs-s),但**没有** input ++ await expect(cell).toHaveAttribute('data-cs-s') ++ await expect(cell.locator('input')).toHaveCount(0) ++ }) ++ ++ test('双击进入编辑:挂上编辑器并自动聚焦', async ({ page }) => { ++ await page.goto('/') ++ const wrap = page.locator('[data-testid="table-keys-wrap"]') ++ await wrap.scrollIntoViewIfNeeded() ++ const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]') ++ ++ await enterEdit(cell) + await expect(cell.locator('input')).toHaveValue('K-001') + await expect(cell.locator('input')).toBeFocused() + }) + +- test('上下键:切到上 / 下一行的同一列(并且进编辑,可以直接接着改)', async ({ page }) => { ++ /** ++ * 选中态下按 Enter 进编辑并**全选**(规格 §二)。 ++ * ++ * 这是"打字即覆盖"的前置条件:全选之后敲任何字符都会替换整格内容。 ++ */ ++ test('选中态按 Enter:进入编辑并全选内容', async ({ page }) => { ++ await page.goto('/') ++ const wrap = page.locator('[data-testid="table-keys-wrap"]') ++ await wrap.scrollIntoViewIfNeeded() ++ const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]') ++ ++ await cell.click() ++ await page.keyboard.press('Enter') ++ ++ const input = cell.locator('input') ++ await expect(input).toBeFocused() ++ // 全选:selectionEnd - selectionStart 等于文本长度 ++ const selected = await input.evaluate((el) => { ++ const node = el as HTMLInputElement ++ return { len: node.value.length, span: (node.selectionEnd ?? 0) - (node.selectionStart ?? 0) } ++ }) ++ expect(selected.span).toBe(selected.len) ++ }) ++ ++ /** ++ * 选中态按 F2 进编辑,**光标在末尾**(规格 §二)。 ++ * ++ * 与 Enter 的差别就在插入符落点上:F2 是"接着改末尾",Enter 是"整体替换"。 ++ */ ++ test('选中态按 F2:进入编辑且光标在末尾', async ({ page }) => { ++ await page.goto('/') ++ const wrap = page.locator('[data-testid="table-keys-wrap"]') ++ await wrap.scrollIntoViewIfNeeded() ++ const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]') ++ ++ await cell.click() ++ await page.keyboard.press('F2') ++ ++ const input = cell.locator('input') ++ await expect(input).toBeFocused() ++ const caret = await input.evaluate((el) => { ++ const node = el as HTMLInputElement ++ return { start: node.selectionStart, end: node.selectionEnd, len: node.value.length } ++ }) ++ // 光标贴在末尾且没有选区(不是全选) ++ expect(caret.start).toBe(caret.len) ++ expect(caret.end).toBe(caret.len) ++ }) ++ ++ /** ++ * 选中态直接打字:进入编辑,**清空原值、只留这个字符**(规格 §二,Excel 的覆盖语义)。 ++ */ ++ test('选中态直接打字:进入编辑并只保留新字符(覆盖原值)', async ({ page }) => { ++ await page.goto('/') ++ const wrap = page.locator('[data-testid="table-keys-wrap"]') ++ await wrap.scrollIntoViewIfNeeded() ++ const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]') ++ ++ await cell.click() ++ await page.keyboard.press('X') ++ ++ const input = cell.locator('input') ++ await expect(input).toBeFocused() ++ // 原值 'K-001' 被覆盖,只剩用户敲的那个字符 ++ await expect(input).toHaveValue('X') ++ }) ++ ++ test('上下键(编辑态):切到上 / 下一行的同一列,并继续编辑', async ({ page }) => { + await page.goto('/') + const wrap = page.locator('[data-testid="table-keys-wrap"]') + await wrap.scrollIntoViewIfNeeded() + const rows = wrap.locator('.stk-tbody-main tr') + const first = rows.nth(0).locator('td[data-col-key="code"]') + +- await first.click() ++ await enterEdit(first) + await page.keyboard.press('ArrowDown') + await expect(rows.nth(1).locator('td[data-col-key="code"] input')).toBeFocused() + +@@ -1362,13 +1488,13 @@ test.describe('G3Table 方向键分工:左右归输入框、上下给导航', + await expect(first.locator('input')).toBeFocused() + }) + +- test('左右键:在输入框内移动光标,不切格', async ({ page }) => { ++ test('左右键(编辑态,光标在中间):在输入框内移动光标,不切格', async ({ page }) => { + await page.goto('/') + const wrap = page.locator('[data-testid="table-keys-wrap"]') + await wrap.scrollIntoViewIfNeeded() + const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="qty"]') + +- await cell.click() ++ await enterEdit(cell) + const input = cell.locator('input') + await input.fill('12345') + +@@ -1379,17 +1505,44 @@ test.describe('G3Table 方向键分工:左右归输入框、上下给导航', + + expect(after).toBe((before ?? 0) - 1) + await expect(input).toBeFocused() +- // 还在同一格:左右键没有冒到表格导航上 ++ // 还在同一格:光标没到边界,左右键没有移交给表格 + await expect(cell.locator('input')).toHaveCount(1) + }) + ++ /** ++ * 左右键的**边界放行**(规格 §三 核心原则):光标已贴在最右时按 `→`, ++ * 输入框再也动不了 → 这一记按键交给表格,切到右边那一格。 ++ * ++ * 这是与旧行为(左右键一律归输入框、永不移交)最关键的一处差别: ++ * 少了它,编辑态里就没法用 `→` 逐格往右走。 ++ */ ++ test('左右键(编辑态,光标在边界):放行给表格,切到相邻格', async ({ page }) => { ++ await page.goto('/') ++ const wrap = page.locator('[data-testid="table-keys-wrap"]') ++ await wrap.scrollIntoViewIfNeeded() ++ const rows = wrap.locator('.stk-tbody-main tr') ++ const codeCell = rows.nth(0).locator('td[data-col-key="code"]') ++ ++ await enterEdit(codeCell) ++ const input = codeCell.locator('input') ++ // 把光标放到末尾('K-001' → 位置 5) ++ await input.evaluate((el) => { ++ const node = el as HTMLInputElement ++ node.setSelectionRange(node.value.length, node.value.length) ++ }) ++ ++ // 末尾再按 → :交给表格,切到右列「数量」 ++ await input.press('ArrowRight') ++ await expect(rows.nth(0).locator('td[data-col-key="qty"] input')).toBeFocused() ++ }) ++ + test('Shift+左右:留给输入框选中文本,不切格', async ({ page }) => { + await page.goto('/') + const wrap = page.locator('[data-testid="table-keys-wrap"]') + await wrap.scrollIntoViewIfNeeded() + const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]') + +- await cell.click() ++ await enterEdit(cell) + const input = cell.locator('input') + // 进入编辑时编辑器会**全选**内容(为「直接输入即替换」服务),所以先记下初始选区长度 + const before = await input.evaluate((el) => { +@@ -1414,7 +1567,7 @@ test.describe('G3Table 方向键分工:左右归输入框、上下给导航', + await wrap.scrollIntoViewIfNeeded() + const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="note"]') + +- await cell.click() ++ await enterEdit(cell) + const textarea = cell.locator('textarea') + await textarea.fill('第一行') + await textarea.press('Enter') +@@ -1436,7 +1589,7 @@ test.describe('G3Table 方向键分工:左右归输入框、上下给导航', + * · Enter(多行)→ 换行(浏览器原生;多行与单行共用一段 onKeydown,早先没按类型分流, + * 按回车会把整段文本全选掉、根本换不了行)。 + * +- * 方向键的分工(左右归输入框、上下给导航)在另一个 describe 里;宿主相同 —— ++ * 方向键的分工(边界放行、上下给导航)在另一个 describe 里;宿主相同 —— + * `table-wrap` 的列序被 Tab 跳格用例逐格锁着,而且它里面没有多行编辑器, + * 锁不住「单行 / 多行 Enter 语义不同」这条区分。 + */ +@@ -1448,7 +1601,7 @@ test.describe('G3Table 键盘行为:Enter 与多行编辑器', () => { + const rows = wrap.locator('.stk-tbody-main tr') + const firstCell = rows.nth(0).locator('td[data-col-key="code"]') + +- await firstCell.click() ++ await enterEdit(firstCell) + await firstCell.locator('input').fill('K-999') + await firstCell.locator('input').press('Enter') + await page.waitForTimeout(300) +@@ -1467,7 +1620,7 @@ test.describe('G3Table 键盘行为:Enter 与多行编辑器', () => { + await wrap.scrollIntoViewIfNeeded() + const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="note"]') + +- await cell.click() ++ await enterEdit(cell) + const textarea = cell.locator('textarea') + await textarea.fill('第一行') + await textarea.press('Enter') +@@ -1491,13 +1644,13 @@ test.describe('G3Table 日期编辑器(editor.type = date)', () => { + await expect(cell.locator('.g3-table__cell-display')).toHaveText('2026-03-15') + + /** +- * ① 点格 → 自动聚焦 **且自动弹开日历面板**。 ++ * ① 双击进编辑 → 自动聚焦 **且自动弹开日历面板**。 + * +- * 这是 `type: 'date'` 的核心交互:点格的那次点击落在 `td` 上,触发不到 DatePicker ++ * 这是 `type: 'date'` 的核心交互:进编辑的点击落在 `td` 上,触发不到 DatePicker + * 自己的「点输入框才展开」,所以要由挂载钩子主动调它的 `openPanel()` + * (见 `utils/editable.ts` 的 openDatePanel)。手感与 ERP 里的日期格一致。 + */ +- await cell.click() ++ await enterEdit(cell) + /** + * 焦点**落在面板里**(CalendarPanel 的日期格是 `