From e7c84a82a2421b5dbe3ed64de4772055ff0d5b61 Mon Sep 17 00:00:00 2001 From: oneao Date: Thu, 15 Oct 2026 22:47:42 +0800 Subject: [PATCH] u --- .../service/auth/client/IpRegionSearcher.java | 2 +- code/g3soft-erp/web/src/utils/powerCodes.ts | 127 + code/g3soft-erp/web/src/utils/schemaJson.ts | 10 +- .../module-management/ModuleI18nPanel.vue | 235 ++ .../module-management/ModulePowerPanel.vue | 564 ++++ .../module-management/ModuleQueryPanel.vue | 2522 ++++------------- .../views/module/module-management/index.vue | 151 +- .../views/module/module-management/utils.ts | 208 +- .../web/src/views/table-test/index.vue | 122 +- .../ModuleQueryPanel.spec.ts | 136 + .../.changeset/table-cell-status-shapes.md | 18 + .../.changeset/table-date-editor.md | 18 + .../.changeset/table-keyboard-arrows-enter.md | 25 + .../.changeset/table-no-vertical-lines.md | 14 + .../.changeset/table-number-precision.md | 23 + .../g3soft-libs/docs/content/ui/data/table.md | 169 +- .../g3soft-libs/packages/ui/e2e/table.spec.ts | 497 +++- .../packages/ui/playground/main.ts | 85 + .../packages/ui/src/date/DatePicker.vue | 120 +- .../g3soft-libs/packages/ui/src/date/types.ts | 8 + .../packages/ui/src/table/G3Table.vue | 395 ++- .../packages/ui/src/table/index.ts | 23 + .../ui/src/table/style/_overrides.scss | 103 +- .../packages/ui/src/table/types.ts | 59 +- .../packages/ui/src/table/utils/column.ts | 29 +- .../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 | 72 +- .../packages/ui/src/table/utils/editable.ts | 391 ++- .../ui/src/table/utils/precision.test.ts | 107 + .../packages/ui/src/table/utils/precision.ts | 123 + 31 files changed, 4063 insertions(+), 2362 deletions(-) create mode 100644 code/g3soft-erp/web/src/views/module/module-management/ModuleI18nPanel.vue create mode 100644 code/g3soft-erp/web/src/views/module/module-management/ModulePowerPanel.vue create mode 100644 code/g3soft-erp/web/tests/views/module/module-management/ModuleQueryPanel.spec.ts create mode 100644 code/g3soft-libs/.changeset/table-cell-status-shapes.md create mode 100644 code/g3soft-libs/.changeset/table-date-editor.md create mode 100644 code/g3soft-libs/.changeset/table-keyboard-arrows-enter.md create mode 100644 code/g3soft-libs/.changeset/table-no-vertical-lines.md create mode 100644 code/g3soft-libs/.changeset/table-number-precision.md create mode 100644 code/g3soft-libs/packages/ui/src/table/utils/date-value.test.ts create mode 100644 code/g3soft-libs/packages/ui/src/table/utils/date-value.ts create mode 100644 code/g3soft-libs/packages/ui/src/table/utils/precision.test.ts create mode 100644 code/g3soft-libs/packages/ui/src/table/utils/precision.ts diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/client/IpRegionSearcher.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/client/IpRegionSearcher.java index 758895af..e0879dd9 100644 --- a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/client/IpRegionSearcher.java +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/client/IpRegionSearcher.java @@ -30,7 +30,7 @@ public class IpRegionSearcher { LongByteArray buffer = new LongByteArray(); buffer.append(bytes); searcher = Searcher.newWithBuffer(Version.IPv4, buffer); - log.info("ip2region Searcher 初始化完成,数据大小 {} 字节", bytes.length); + log.info("ip2region Searcher 初始化完成,数据大小 {} 字节", bytes.length); } catch (IOException e) { log.error("未找到 classpath 下的 ip2region.xdb,IP 归属地将返回「未知」", e); searcher = null; diff --git a/code/g3soft-erp/web/src/utils/powerCodes.ts b/code/g3soft-erp/web/src/utils/powerCodes.ts index 31f72f10..c847b500 100644 --- a/code/g3soft-erp/web/src/utils/powerCodes.ts +++ b/code/g3soft-erp/web/src/utils/powerCodes.ts @@ -27,6 +27,133 @@ export function typeOfAction(action: string): 'module' | 'action' { return isStandardModuleAction(action) ? 'module' : 'action' } +/** + * 动作 → 默认权限名称(新增权限点时的兜底名称,不覆盖人工输入)。 + * 标准动作与业务动作都给中文名,未登记的动作回退动作编码本身。 + * + * 这套动词是**跨模块公用**的:「通过」就是「通过」,与它挂在哪个模块下无关。 + * 因此权限库(历史动作去重列表)的名称也按这里生成 —— 库里若直接用 s_power.b_name, + * 拿到的会是「模块名 - 动作名」(写入侧的口径,见 12_fms_workflow_power.sql), + * 在按动作去重的库表里显示模块名是噪音。 + */ +export const ACTION_NAMES: Record = { + [MODULE_ACCESS_ACTION]: '模块使用', + // 标准操作 + read: '查看', + create: '新增', + update: '修改', + delete: '删除', + export: '导出', + // 业务动作(工作流审批) + submit: '送审', + approve: '通过', + reject: '驳回', + return: '退回', + withdraw: '撤回', + transfer: '转交', + delegate: '委托', + reassign: '转派', + add_approver: '加签', + urge: '催办', + workflow_admin: '流程管理员', + // 通用业务动作 + audit: '审核', + unaudit: '反审', +} + +/** + * 标准动作的共用动作动词资源键(按钮 + 权限共用)。 + * 「新增」与权限名称共用 action.insert(SQL 语义);未登记动作回退 action.{动作}。 + */ +export const ACTION_I18N_KEYS: Record = { + read: 'action.read', + create: 'action.insert', + update: 'action.update', + delete: 'action.delete', + export: 'action.export', + audit: 'action.audit', + unaudit: 'action.unaudit', +} + +/** + * 动作码 → 展示名:优先取登记的中文动词,未登记的回落动作码本身。 + * 这是「动作」这一层的唯一命名口径,权限库与新增权限都用它,不在调用方各写一份。 + */ +export function actionLabelOf(action: string): string { + const value = String(action || '').trim() + if (!value) return '' + return ACTION_NAMES[value] || value +} + +/** 动作 → 共用动作动词资源键:通用词不分模块,同一动词只保存一份翻译 */ +export function buildActionI18nKey(action: string): string | null { + const value = String(action || '').trim() + if (!value) return null + return ACTION_I18N_KEYS[value] || `action.${value}` +} + +/** + * 权限编码派生:模块使用权限为 module.{模块编码}(不带动作后缀),业务动作为 + * action.{模块编码}.{动作};动作或模块编码为空时返回 ''(行还没填完,保存时跳过)。 + * + * 维护界面(模块管理 → 权限)的单元格编辑器与保存前物化共用,不在各自目录重写。 + */ +export function buildPowerCode(action: string, moduleId: string): string { + const normalizedAction = String(action || '').trim() + const objectId = String(moduleId || '').trim() + if (!normalizedAction || !objectId) return '' + return isStandardModuleAction(normalizedAction) + ? (buildModulePowerCode(objectId) ?? '') + : (buildActionPowerCode(objectId, normalizedAction) ?? '') +} + +/** + * 权限点引用的多语言键:模块使用权限复用模块名称键 module.{模块编码} + * (它显示的就是模块名);业务动作复用共用动词键 action.{动词}(跨模块公用, + * 同一动词只维护一份翻译)。 + */ +export function buildPowerI18nKey(action: string, moduleCode: string): string | null { + const value = String(action || '').trim() + if (!value) return null + return typeOfAction(value) === 'module' + ? buildModuleI18nKey(moduleCode) + : buildActionI18nKey(value) +} + +/** + * 权限库(历史录入的动作)只读查询:跨模块去重,列出全系统用过的业务动作, + * 供「模块管理 → 权限」一键导入。 + * + * 标准动作(access / read / create / update / delete / export)不进库:它们的权限编码是 + * module.{模块编码}(整模块一个使用权限点),导入到某个模块会与该模块的模块使用权限撞编码; + * 只有业务动作按 action.{模块编码}.{动作} 派生,才能跨模块复用。 + * + * **只取 b_action,不取 b_name**:s_power.b_name 由写入侧拼成「模块名 - 动作名」 + * (见 12_fms_workflow_power.sql),而本表是**按动作跨模块去重**的, + * 同一个动作在多个模块下会得到多个不同的名字,「取哪一个」没有意义 —— + * 显示出来就是某个模块的名称(用户看到的「流程定义 - 通过」正是这么来的)。 + * 动作是跨模块公用的动词,名称统一由 actionLabelOf(action) 在展示侧生成。 + */ +export function buildPowerLibrarySql(): string { + const excluded = [MODULE_ACCESS_ACTION, ...STANDARD_MODULE_ACTIONS] + .map((action) => `'${action}'`) + .join(', ') + return ( + 'select distinct [b_action] ' + + 'from [s_power] ' + + "where [b_object_type] = 'module' and [b_canuse] = 1 " + + "and [b_action] is not null and [b_action] <> '' " + + `and [b_action] not in (${excluded}) ` + + 'order by [b_action] asc' + ) +} + +/** 模块多语言资源键:module.{模块编码} */ +export function buildModuleI18nKey(moduleCode: string): string | null { + const code = String(moduleCode || '').trim() + return code ? `module.${code}` : null +} + /** * 菜单入口权限编码:menu.{菜单编码}。 * @returns 权限编码;编码为空时返回 null diff --git a/code/g3soft-erp/web/src/utils/schemaJson.ts b/code/g3soft-erp/web/src/utils/schemaJson.ts index 8bbc607e..4eeda7f4 100644 --- a/code/g3soft-erp/web/src/utils/schemaJson.ts +++ b/code/g3soft-erp/web/src/utils/schemaJson.ts @@ -37,15 +37,11 @@ export function createDefaultSchema(schemaType: string): SchemaJson { // (v1 是平铺字段节点 + newline 标记,加载时仍可容错折成行) if (schemaType === 'edit') return { schemaVersion: 2, children: [] } if (schemaType === 'query') { - // schemaVersion 3:页签可分组(组内单选、组间叠加),页签条件直写 SQL, - // 更多筛选由 queryFields 自动铺开(不再手工分组); - // 关键词只配可搜索字段,占位文案与触发方式运行时定 + // schemaVersion 4:只保留工作队列页签(组内单选、组间叠加,条件直写 SQL)。 + // 查询能力(关键词字段、更多筛选字段、控件与算子)由列表页按 s_field 运行时推导,不再配置。 return { - schemaVersion: 3, + schemaVersion: 4, views: [], - keywordSearch: { fields: [] }, - quickFilters: [], - queryFields: {}, } } return { schemaVersion: 1 } 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 new file mode 100644 index 00000000..94431c02 --- /dev/null +++ b/code/g3soft-erp/web/src/views/module/module-management/ModuleI18nPanel.vue @@ -0,0 +1,235 @@ + + + + + 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 new file mode 100644 index 00000000..43e3fbeb --- /dev/null +++ b/code/g3soft-erp/web/src/views/module/module-management/ModulePowerPanel.vue @@ -0,0 +1,564 @@ + + + + + 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 index 8e6a15a3..058d88f6 100644 --- a/code/g3soft-erp/web/src/views/module/module-management/ModuleQueryPanel.vue +++ b/code/g3soft-erp/web/src/views/module/module-management/ModuleQueryPanel.vue @@ -1,67 +1,53 @@ @@ -875,13 +1003,11 @@ onMounted(() => { v-else-if="section.key === 'fields'" v-model:fields="moduleFields" /> - + { :fields="moduleFields" @update:enabled="(value) => (autoCodeEnabled = value)" /> + + + +
{{ section.label }}面板将按 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 81002af2..ce2444b1 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 @@ -101,49 +101,21 @@ export interface ModuleAutoCodeRow { } /* ================= 查询设置(s_module_schema.b_schema_type = 'query') ================= - * 查询配置在编辑器里是一份「扁平草稿」(ModuleQueryDraft,四块内容各一个数组/对象), - * 落库形态是 query JSON(schemaVersion 3);两个方向的转换集中在这里, - * 口径与 fms-vue 的 schemaModel.js 一致。 + * 只配一件事:工作队列页签(views)。组内单选、组间叠加,条件直接写 SQL。 * - * 四块内容: - * - fields(查询字段):参与条件查询的字段;未进快捷筛选的,运行时按 b_xh 铺进「更多筛选」抽屉; - * - views(工作队列页签):组内单选、组间叠加,条件直接写 SQL; - * - keyword(关键词字段):只配可搜索字段,占位文案运行时按字段名生成; - * - quickFilters(快捷筛选字段):铺在列表页查询卡上,按 order 排列。 + * 查询能力不再配置:关键词搜哪些字段、更多筛选铺哪些字段、用什么控件与算子, + * 列表页按 s_field 就能推导(控件与算子看业务类型、关键词看文本类字段、 + * 更多筛选铺全部启用字段并按 b_xh 排序)。配一遍是重复劳动,还容易漏配 —— + * 新模块零配置就该有查询能力。相对时间的快捷预设属于日期控件自己的能力,同样不归这里。 * - * 未建模属性经行上的 _node 原样带回:草稿只承载面板认识的列, + * 控件与算子的取值口径(列表页运行时用):组件 input 输入框 / select 下拉 / date 日期 / + * datetime 日期时间 / number 数字 / checkbox 复选框;操作符 eq 等于 / like 模糊 / + * starts_with 开头是 / gt ge lt le 比较 / in 包含 / between 范围。 + * + * 落库形态是 query JSON(schemaVersion 4);未建模属性经行上的 _node 原样带回, * JSON 里未来的扩展键不会在编辑往返中被静默丢掉。 */ -/** - * 查询组件与操作符(queryFields[字段].component / .operator)。 - * - * 没有界面入口——查询控件由字段的业务类型决定即可(日期字段当然是日期框、下拉字段当然是下拉), - * 配置里已有的值由面板原样保留、不回写不认识的东西,这里只给「新参与查询的字段」兜一个初值。 - * 取值:组件 input 输入框 / select 下拉 / date 日期 / datetime 日期时间 / number 数字 / checkbox 复选框; - * 操作符 eq 等于 / like 模糊 / starts_with 开头是 / gt ge lt le 比较 / in 包含 / between 范围。 - */ - -/** 按字段业务类型给出查询组件 / 操作符默认值;未列出的(input / textarea)走通用 like */ -const QUERY_COMPONENT_DEFAULTS: Record = { - number: { component: 'number', operator: 'eq' }, - money: { component: 'number', operator: 'eq' }, - date: { component: 'date', operator: 'ge' }, - datetime: { component: 'datetime', operator: 'ge' }, - select: { component: 'select', operator: 'eq' }, - checkbox: { component: 'checkbox', operator: 'eq' }, -} - -/** 字段 → 查询组件 / 操作符默认值(新参与查询的字段按业务类型兜初值) */ -export function queryRowDefaults(field: ModuleFieldRow): { - b_component: string - b_operator: string -} { - const type = String(field?.b_type ?? '') - const defaults = QUERY_COMPONENT_DEFAULTS[type] || { component: 'input', operator: 'like' } - return { b_component: defaults.component, b_operator: defaults.operator } -} - /** * 页签所属分组:模型上页签「一定有组」——组既是列表页上的一行页签, * 也是「组内单选、组间叠加」的作用域,不存在不属于任何组的页签。 @@ -151,57 +123,6 @@ export function queryRowDefaults(field: ModuleFieldRow): { */ export const DEFAULT_VIEW_GROUP = '默认' -/** - * 快捷搜索项在卡上的呈现方式: - * control = 直接把控件摆出来(用户自己填),preset = 给一排相对时间的预设(点一下就筛)。 - */ -export const QUERY_QUICK_MODES = [ - { label: '直接放控件', value: 'control' }, - { label: '给预设', value: 'preset' }, -] - -/** - * 相对时间的预设。存的**是枚举不是日期**:配的时候不知道用户哪天开页面, - * 运行时才算成具体区间(如 this_month → 本月 1 号 ~ 今天)。 - */ -export const QUERY_DATE_PRESETS = [ - { value: 'today', label: '今天' }, - { value: 'yesterday', label: '昨天' }, - { value: 'this_week', label: '本周' }, - { value: 'last_week', label: '上周' }, - { value: 'this_month', label: '本月' }, - { value: 'last_month', label: '上月' }, - { value: 'last_7_days', label: '最近7天' }, - { value: 'last_30_days', label: '最近30天' }, -] - -/** 查询字段行(草稿):身份是 _rowKey,落库后按 b_field 收进 queryFields */ -export interface ModuleQueryFieldRow { - _rowKey: string - /** 未建模属性(原 queryFields[字段] 的其余键),回写时原样带回 */ - _node: Record - b_module_id: string - b_field: string | null - b_component: string - b_operator: string - b_default_value: string | null - /** 卡上的呈现方式:control 直接放控件 / preset 给一排预设(不写按 control) */ - b_mode: string - /** preset 模式下要露出的预设(相对时间枚举) */ - b_presets: string[] - /** preset 模式下默认选中哪个预设(空 = 打开页面不带条件) */ - b_default_preset: string | null - b_canuse: number - b_xh: number - /** - * 由「列表」勾选自动补出的行(fms 语义):面板里改过组件 / 算子 / 默认值后摘掉标记, - * 转为自定义行,取消列表勾选时不再被自动回收。列表面板尚未移植, - * 保留该列只为与落库结构一致。 - */ - fromList: boolean - [key: string]: unknown -} - /** 工作队列页签行(草稿):code 为空的行不落库(页签没有编码就无法被运行时选中) */ export interface ModuleQueryViewRow { _rowKey: string @@ -216,52 +137,17 @@ export interface ModuleQueryViewRow { [key: string]: unknown } -/** 快捷筛选行(草稿) */ -export interface ModuleQueryQuickRow { - _rowKey: string - field: string - order: number - [key: string]: unknown -} - -/** 查询设置草稿:四个分节的编辑态,与落库 JSON 经下面两个函数双向适配 */ +/** 查询设置草稿:与落库 JSON 经下面两个函数双向适配 */ export interface ModuleQueryDraft { - fields: ModuleQueryFieldRow[] views: ModuleQueryViewRow[] - keyword: { fields: string[] } - quickFilters: ModuleQueryQuickRow[] } -/** query JSON → 编辑器草稿 */ -export function querySchemaToDraft(schema: SchemaJson | null, moduleId: string): ModuleQueryDraft { +/** query JSON → 编辑器草稿(只取页签;其余键不再建模,由回写统一清理) */ +export function querySchemaToDraft(schema: SchemaJson | null): ModuleQueryDraft { const source = (schema && typeof schema === 'object' ? schema : {}) as SchemaJson & { - queryFields?: Record views?: Array> - keywordSearch?: { fields?: unknown[] } - quickFilters?: Array> defaultViewCode?: string } - const fieldRows = Object.entries(source.queryFields || {}).map(([field, config], index) => { - const node = - config && typeof config === 'object' ? { ...(config as Record) } : {} - const fromList = node.fromList === true - delete node.fromList - return { - _rowKey: createTempId(), - _node: node, - b_module_id: String(moduleId), - b_field: field, - b_component: String(node.component ?? 'input'), - b_operator: String(node.operator ?? 'like'), - b_default_value: node.defaultValue == null ? null : String(node.defaultValue), - b_mode: String(node.mode ?? 'control'), - b_presets: Array.isArray(node.presets) ? node.presets.map((item) => String(item)) : [], - b_default_preset: node.defaultPreset == null ? null : String(node.defaultPreset), - b_canuse: 1, - b_xh: (index + 1) * 10, - fromList, - } as ModuleQueryFieldRow - }) // 旧结构的 defaultViewCode 等价于「该页签 isDefault」,加载时折进来 const legacyDefaultCode = String(source.defaultViewCode ?? '') const views = (source.views || []).map((view, index) => { @@ -278,18 +164,7 @@ export function querySchemaToDraft(schema: SchemaJson | null, moduleId: string): conditionSql: String(view?.conditionSql ?? '').trim(), } as ModuleQueryViewRow }) - return { - fields: fieldRows, - views, - keyword: { - fields: (source.keywordSearch?.fields || []).map((field) => String(field)), - }, - quickFilters: (source.quickFilters || []).map((entry, index) => ({ - _rowKey: createTempId(), - field: String(entry?.field ?? ''), - order: Number(entry?.order) || (index + 1) * 10, - })), - } + return { views } } /** 编辑器草稿 → query JSON(未建模属性经 _node / base 保留,建模属性以草稿为准) */ @@ -298,49 +173,22 @@ export function queryDraftToSchema( 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 queryFields: Record = {} - for (const row of draft?.fields || []) { - const field = String(row.b_field ?? '').trim() - if (!field) continue - const config: Record = { ...(row._node || {}) } - if (row.b_component) config.component = row.b_component - else delete config.component - if (row.b_operator) config.operator = row.b_operator - else delete config.operator - if (row.b_default_value != null && row.b_default_value !== '') { - config.defaultValue = row.b_default_value - } else { - delete config.defaultValue - } - // 呈现方式与预设:control 是默认形态,不写进 JSON(省得每条都挂一堆默认值) - if (row.b_mode === 'preset') { - config.mode = 'preset' - const presets = (row.b_presets || []).map((item) => String(item)).filter(Boolean) - if (presets.length) config.presets = presets - else delete config.presets - if (row.b_default_preset) config.defaultPreset = row.b_default_preset - else delete config.defaultPreset - } else { - delete config.mode - delete config.presets - delete config.defaultPreset - } - if (row.fromList) config.fromList = true - else delete config.fromList - queryFields[field] = config - } - const views = (draft?.views || []) .map((row) => { const code = String(row.code ?? '').trim() if (!code) return null - const view: Record = { ...(row._node || {}), code } + const view: Record = { ...row._node, code } if (String(row.label ?? '').trim()) view.label = String(row.label).trim() else delete view.label // 页签一定有分组:空的一律落到默认组(历史配置在此被补全) @@ -357,22 +205,10 @@ export function queryDraftToSchema( }) .filter((view): view is Record => view != null) - const keywordFields = [ - ...new Set((draft?.keyword?.fields || []).map((field) => String(field)).filter(Boolean)), - ] - return { ...base, - schemaVersion: 3, + schemaVersion: 4, views, - keywordSearch: { fields: keywordFields }, - quickFilters: (draft?.quickFilters || []) - .map((row, index) => ({ - field: String(row.field ?? '').trim(), - order: Number(row.order) || (index + 1) * 10, - })) - .filter((entry) => entry.field), - queryFields, } } 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 fe563f7c..ff84efc4 100644 --- a/code/g3soft-erp/web/src/views/table-test/index.vue +++ b/code/g3soft-erp/web/src/views/table-test/index.vue @@ -1,7 +1,7 @@ + + + +
+ {{ cellTip.message }} +
+
@@ -1695,7 +1910,10 @@ const fixedPatchOff = (() => { /* 边框:`bordered` 只翻译成底座的档位(true → 'v',false → false),真正的 圆角外框与「横线怎么补回来」都在 `style/_overrides.scss` 的「边框」一节 —— 为什么外框不再交给底座(做不出圆角、右侧底部边框被滚动条吃掉)、为什么格线 - 不能用真 border(会把行撑高 1px),都写在那一节,别在这里重复。 */ + 不能用真 border(会把行撑高 1px),都写在那一节,别在这里重复。 + 纵向列线的设计决定:**表体一条不画,表头画一条不占满高度的短竖线**。 + 覆盖层把底座的 --bg-border-right / --bg-border-left 对表体涂透明,表头则由 th + 自己接管 background-image(短线 + 更轻的列界色),不在这里按列摘线。 */ .#{e('table', 'inner')} { width: 100%; @@ -1767,7 +1985,8 @@ const fixedPatchOff = (() => { .#{e('table', 'inner')}.stk-table { .#{e('table', 'cell-editor-root')} { &.g3-input, - &.g3-select { + &.g3-select, + &.g3-date-picker { width: 100%; /** * 高度必须显式钉死为行高,不能只靠 `height: 100%` 撑: @@ -1889,7 +2108,8 @@ const fixedPatchOff = (() => { 所以内部盒子的描边 / 圆角一律在各自组件的样式块里用 seamless 修饰类去掉 (见 Select.vue 的 is-seamless 与 G3Select 的 seamless prop)。 */ &.g3-input, - &.g3-select { + &.g3-select, + &.g3-date-picker { padding: 0 !important; border: 0 !important; border-radius: 0 !important; @@ -1980,7 +2200,8 @@ const fixedPatchOff = (() => { * hover 只是"这里可编辑"的轻提示,主色描边会与焦点环混淆。 */ &.g3-input:not(.#{m('input', 'disabled')}):hover, - &.g3-select: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; } @@ -2082,6 +2303,50 @@ const fixedPatchOff = (() => { 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; } /** @@ -2301,12 +2566,13 @@ const fixedPatchOff = (() => { * 权重这里同样要压过亮色规则(亮色那条是 `.g3-table .g3-table__inner.stk-table …` * = (0,6,2),本条多了 `.dark` = (0,7,2),无条件取胜,与顺序无关)。 * - * 比例换成「基色 + 白 4%」而不是「基色 + 黑 3%」: - * 亮色下 3% 的黑能压暗白底;但暗色底已经是 #141414,再叠黑等于没叠 + * 比例换成「基色 + 白 8%」而不是「基色 + 黑 6%」: + * 亮色下 6% 的黑能压暗白底;但暗色底已经是 #141414,再叠黑等于没叠 * (叠完还是 #141414,肉眼零差异——灰底彻底不可见)。 * 暗色下要表达「比底色暗一档」,物理上必须**提亮**, - * 所以前景取 text-1(暗色下是 rgba(255,255,255,.88)),4% ≈ 亮色 3% 的感知强度。 - * 这正是用令牌而非写死 rgba(0,0,0,.03) 的价值:方向由主题自动决定。 + * 所以前景取 text-1(暗色下是 rgba(255,255,255,.88)),8% 对应亮色 6% 的感知强度。 + * (暗色的斑马纹是 4% 的白,这里同样要与它拉开一档,理由见亮色那段的说明。) + * 这正是用令牌而非写死 rgba(0,0,0,.06) 的价值:方向由主题自动决定。 */ .#{b('table')} .#{e('table', 'inner')}.stk-table.dark { /* ⚠️ 判据必须与亮色规则**完全一致**(同样的两个来源、同样的类名)。 @@ -2318,9 +2584,14 @@ const fixedPatchOff = (() => { 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)) 4%, + 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%; } } @@ -2366,15 +2637,30 @@ const fixedPatchOff = (() => { * * ── 与其它状态的优先级 ── * - * 底色都落在 `background-color` 上,本规则权重((0,6,1) 起)高于校验态的红 / 黄底 - * ((0,4,1)),所以「既禁用、又校验不过」的格子显示为**灰**。这是刻意的: + * 状态色都落在 `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),不必再手写一份暗色常量。 - * 比例 3%:2% 在浅灰表头(#f5f5f5)旁边几乎看不出, - * 5% 又重到像「禁用」,3% 刚好形成「比白底暗、比表头浅」的中间层。 + * 比例 6%(**表体列线去掉之后从 3% 提上来的**): + * 灰底与斑马纹都是「无边界横带」,两者只能靠亮度区分 —— 而斑马纹是 + * `--stripe-bgc`(2% 的黑 ≈ #fafafa),3% 的灰底 ≈ #f7f7f7,两者差约 1%, + * 中间还隔着一行白,肉眼基本分不出「这行是斑马纹」还是「这格改不了」。 + * 以前有列线时靠「矩形 vs 长带」的形状差异兜着,列线去掉后这层保障没了, + * 只能靠浓度拉开:6% ≈ #efefef,与斑马纹 2% 差出可辨的一档。 + * (与表头的 #f5f5f5 数值接近但不会混:表头是不透明实色 + 更重的字, + * 且两者在垂直方向上永远相邻、可以直接对比。) * (此比例为观感推定的档位,非实测调优值,以实际观感为准。) * * ── 为什么**不**限定 `is-editor-seamless` ── @@ -2399,9 +2685,19 @@ const fixedPatchOff = (() => { 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)) 3%, + 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%; } /** @@ -2551,6 +2847,71 @@ const fixedPatchOff = (() => { } } +/* ---------- 编辑器文字对齐跟随列 ---------- */ + +/** + * 列右对齐(库把它落成 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', 'actions-cell')} { display: inline-flex; diff --git a/code/g3soft-libs/packages/ui/src/table/index.ts b/code/g3soft-libs/packages/ui/src/table/index.ts index 008bad7f..098c8bee 100644 --- a/code/g3soft-libs/packages/ui/src/table/index.ts +++ b/code/g3soft-libs/packages/ui/src/table/index.ts @@ -1,5 +1,28 @@ 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, 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 5ac81d68..930fc01b 100644 --- a/code/g3soft-libs/packages/ui/src/table/style/_overrides.scss +++ b/code/g3soft-libs/packages/ui/src/table/style/_overrides.scss @@ -31,6 +31,26 @@ --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 一个不透明实色(含完整原因与实测数据)。 @@ -97,6 +117,9 @@ .#{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))}; @@ -137,7 +160,7 @@ } // ========================================================================== -// 边框:外框圆角描边 + 行分隔线由组件自绘,纵向列线仍借底座的渐变 +// 边框:外框圆角描边 + 行分隔线由组件自绘,纵向列线只在表头保留 // -------------------------------------------------------------------------- // 底座不用 `border` 画线,而是把四条边做成 `background-image` 渐变 // (--bg-border-*,由 --border-color 派生),并给 `bordered` 提供三档: @@ -146,15 +169,19 @@ // · 外框是「`border-left` + 三段背景渐变」拼出来的,**做不出圆角**;且底座文档 // (table/basic/bordered)写明:受滚动条占位影响,表格**右侧与底部的边框会 // 消失**,需要时自行补 CSS; -// · 越往后越多边线要单独摘掉(最右列外侧线该归外框管,右固定列的左侧线又必须 -// 留着),而它们共用 `--border-color` 一个派生入口 —— 只能去覆盖渐变本身。 +// · 我们只要横向行分隔线,纵向列线**只在表头保留**:表体靠横线分行、保持干净, +// 表头的列界留着(拖拽列宽、认列都靠它,见本节末尾)。底座的 'h' 档虽然也能 +// 关表体竖线,但它顺手把外框的 `border-left` 置 none、并保留 --bg-border-top +// 渐变(与自绘外框叠成双线),所以档位仍用 'v',竖线改由覆盖那两个变量来关。 // -// 于是这里定成:**外框改用真 border 自绘,所有格线(横线 + 竖线)都保持底座的 -// 背景渐变画法**。 +// 于是这里定成:**外框改用真 border 自绘,横线保持底座的背景渐变画法, +// 竖线对表体置透明、在表头 th 上再恢复回来**。 // 外框 → `.g3-table.is-bordered` 上的真 `border` + 圆角令牌(外层容器已有 // `overflow: hidden`,圆角能把内部内容裁掉); // 横线 → 把 `bordered-v` 清零的 --bg-border-bottom **恢复成底座那份配方**; -// 竖线 → 保留底座的 --bg-border-right 渐变,只抹掉最右列那一条。 +// 竖线 → 表体 td:基础变量块的 --bg-border-right / --bg-border-left 涂透明; +// 表头 th:本节末尾把这两个变量重设回底座配方(含左固定列的 +// --bg-border-left 分支)。 // // 组件侧只负责把 `bordered` 翻成 `true` / `'v'` / `false`(见 G3Table.vue 的 // `:bordered` 绑定)——`'v'` 必然同时带上 `.bordered` 与 `.bordered-v` 两个类 @@ -221,21 +248,44 @@ } } +// ── 表头列界:自己接管 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 摘线」 +// 最后一行横线:为什么不能再「按 :last-child 摘线」 // -------------------------------------------------------------------------- -// 老做法是给 `th:last-child, td:last-child` 把 --bg-border-right 涂成透明, -// 指望「最右列的外侧线归外框管」。**这个假设只在「列宽铺满容器」时成立**, -// 实测有两种常见情形会破: -// -// 1. 列宽之和 < 容器宽(有列没写 width、或容器被拉宽): -// 实测 table-wrap —— 容器内宽 1084px,列只铺到 800px, -// **右侧留 284px 空白,而那条竖线已被摘掉,于是「线没了 + 留白」**。 -// 2. 有右固定列且横向滚动:DOM 里 `:last-child` 是滚动区的最后一列 -// (实测右边缘 1698px,早已滚出可视区),**视觉最右**的反而是固定列 -// (右边缘贴住 1242px 的内缘)。摘 DOM 末列等于摘错了对象。 -// -// 最后一行同理:横线由每个单元格自己的 --bg-border-bottom 画。 +// 横线由每个单元格自己的 --bg-border-bottom 画。 // **判据不能是「是不是最后一行」,而是「这行的底边有没有贴住外框内底边」**—— // 二者只有行区铺满时才等价。实测(180px 高容器): // · 铺满时:最后一行 bottom 与外框内底边重合 → 线该交给外框(否则叠成 2px); @@ -249,12 +299,10 @@ background-color: v('bg-container', #fff); .#{e('table', 'inner')}.stk-table.bordered-v { - // ── 贴住内右缘的右固定列:自己的右线让给外框,避免两条线并排。 - // 表头与表体都要处理 —— 实测两处都会画,且都会与外框叠。 - th.fixed-cell--right, - td.fixed-cell--right { - --bg-border-right: linear-gradient(transparent, transparent); - } + // ── 表头右固定列不用在这里单独处理:上面那条表头列界规则已用 + // `:not(.fixed-cell--right)` 把它排除掉了。 + // 「DOM 末列」**不排除**:它才是拖拽该列宽度的落点,列宽没铺满容器时也靠它 + // 指出末列到哪儿结束(历史踩坑,见本文件 git 记录)。 // ── 空态:底边线归外框。 // 库的 `.stk-table-no-data.no-data-full` 会撑满剩余高度并**自带一条 @@ -322,6 +370,11 @@ // ========================================================================== // 固定列:不透明底色 + 行状态叠色(防横向滚动时透出下方内容) // -------------------------------------------------------------------------- +// ⚠ 配套前提:**表体**的竖线已在文件开头的「纵向格线」块里关闭(表头仍保留 —— +// 那是给「认列 / 拖列宽」用的),所以冻结列与滚过去的列之间在数据区没有任何 +// 格线 —— 它们的边界只剩 `fixedColShadow`(10px 渐变)能表达。用到固定列的 +// 页面务必打开这个 prop,否则两边同为不透明基色、紧贴在一起时会被读成一整列。 +// // 症状:鼠标悬停、或正在编辑(当前行)的那一行里,**冻结列是半透明的** —— // 被横向滚过去的列从冻结列底下透出来(用户报的「客户这列是透明的」; // 静止时不易发现,一横滚就是「透字」)。 diff --git a/code/g3soft-libs/packages/ui/src/table/types.ts b/code/g3soft-libs/packages/ui/src/table/types.ts index 54fe81a1..5668903f 100644 --- a/code/g3soft-libs/packages/ui/src/table/types.ts +++ b/code/g3soft-libs/packages/ui/src/table/types.ts @@ -1,5 +1,5 @@ import type { Component, VNode } from 'vue' -import type { StkTableColumn } from 'stk-table-vue' +import type { FormatNumberOptions, StkTableColumn } from 'stk-table-vue' /** * G3Table 类型定义。 @@ -26,7 +26,46 @@ export type TableEditorStatus = '' | 'error' | 'warning' /** 行内编辑器配置 */ export interface TableEditor { - type: 'text' | 'number' | 'password' | 'textarea' | 'select' | 'checkbox' + 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`(见下)。 + */ + 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) /** * select 的选项。 * 传函数时接收当前行——列选项依赖同行其它列时用它(如「按目标模块列出目标字段」)。 @@ -138,6 +177,20 @@ export interface TableAction { * `dataIndex` 收紧为 `keyof T & string`:底座的 `keyof T & string` 本身就是这样, * 这里显式写出仅为可读性,不产生新的不兼容。 */ +/** + * 展示态格式化。**只影响显示** —— 落库的值始终是原始值,别拿展示文本回写数据。 + * + * 传选项对象 → 走底座的 `formatNumber`(千分位 / `¥` 前后缀 / 万-亿缩写 / 百分比); + * 传函数 → 完全自定义,`Intl.NumberFormat` 是正解(多语言的小数点与千分位符号、 + * 会计的括号负数这些 locale 规则,重写一遍必然缺选项)。 + * + * 小数位**优先配在 `editor.precision` 上**(它同时管显示与校验,一处真源); + * `format.decimals` 只在需要「显示位数 ≠ 数据精度」时才写,写了以它为准。 + */ +export type TableCellFormat = + | FormatNumberOptions + | ((value: unknown, row: T, col: G3TableColumn) => string) + export type G3TableColumn = StkTableColumn & { /** 该列是否允许拖拽调宽。操作列 / 展开列必须显式设 false */ resizable?: boolean @@ -145,6 +198,8 @@ export type G3TableColumn = StkTableColumn & { editor?: TableEditor /** 操作列按钮 */ actions?: TableAction[] + /** 展示态格式化(只影响显示,落库值不变) */ + format?: TableCellFormat } /** 行多选配置 */ diff --git a/code/g3soft-libs/packages/ui/src/table/utils/column.ts b/code/g3soft-libs/packages/ui/src/table/utils/column.ts index 8ecfb974..dcde481d 100644 --- a/code/g3soft-libs/packages/ui/src/table/utils/column.ts +++ b/code/g3soft-libs/packages/ui/src/table/utils/column.ts @@ -1,5 +1,6 @@ import { createActionsCell } from './actions' import { createEditableCell, type EditableContext } from './editable' +import { createFormatCell } from './precision' import { isSelectionColumn } from './selection' import type { G3TableColumn, TableRow } from '../types' @@ -88,7 +89,7 @@ export function resolveCustomCell( const editor = col.editor /** - * 注入 customCell 的判据(四种情况): + * 注入 customCell 的判据(五种情况): * * 1. `editingActive` —— 有行正在编辑,可编辑列都要挂编辑器; * 2. `checkbox` —— 始终可点,必须常驻; @@ -98,10 +99,12 @@ export function resolveCustomCell( * 走库的原生 td 渲染(连 cell-display span 都没有), * 它的 error/warning 就只有在点过那一行之后才可见 —— * 用户必须一行行点过去才能发现错误(这正是被反馈的问题)。 + * 5. **配了 `precision`** —— 同因:精度也是「数据的状态」,而且它还要负责 + * 非编辑态的格式化展示,不常驻就连格式化都不会生效。 * - * 第 4 条的**开销可控性**:只有显式配了校验规则的列才常驻, + * 第 4 / 5 条的**开销可控性**:只有显式配了校验或精度的列才常驻, * 且常驻时走的是 displayValue(纯 span,不是编辑器组件)。 - * 没配校验的 text/number 列仍保持原样——非编辑态零组件开销。 + * 没配这两个的 text/number 列仍保持原样——非编辑态零组件开销。 */ const hasValidation = Boolean(editor?.validate || editor?.validateWarning) /** @@ -113,13 +116,16 @@ export function resolveCustomCell( * 按行禁用**的列上(普通 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) + hasRowDisabled || + hasPrecision) ) { return { ...col, @@ -127,6 +133,21 @@ export function resolveCustomCell( } } + /** + * 纯展示列(**没有** `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) { 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 new file mode 100644 index 00000000..c4afdf3d --- /dev/null +++ b/code/g3soft-libs/packages/ui/src/table/utils/date-value.test.ts @@ -0,0 +1,34 @@ +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 new file mode 100644 index 00000000..4e3f7128 --- /dev/null +++ b/code/g3soft-libs/packages/ui/src/table/utils/date-value.ts @@ -0,0 +1,35 @@ +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 index 1a7a3106..2ac05dc6 100644 --- a/code/g3soft-libs/packages/ui/src/table/utils/editable.test.ts +++ b/code/g3soft-libs/packages/ui/src/table/utils/editable.test.ts @@ -752,7 +752,14 @@ describe('键盘:Enter 与 Esc', () => { } } - it('Enter:阻止默认并全选内容(语义是"全选以便覆写",不是提交)', () => { + /** + * 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') @@ -761,11 +768,70 @@ describe('键盘:Enter 与 Esc', () => { expect(event.preventDefault).toHaveBeenCalled() expect(event.stopPropagation).toHaveBeenCalled() - expect(event.target.select).toHaveBeenCalled() - // 关键:Enter 不该提交,也不该 blur + // 不再全选:那与「确认并走」的新语义冲突 + 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 } 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 f95cf097..4817bd76 100644 --- a/code/g3soft-libs/packages/ui/src/table/utils/editable.ts +++ b/code/g3soft-libs/packages/ui/src/table/utils/editable.ts @@ -1,7 +1,12 @@ import { h, toRaw } from 'vue' +import type { VNode } from 'vue' +import { DEFAULT_DATE_FORMAT, G3DatePicker } from '../../date' +import type { DateValue } from '../../date' 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, @@ -79,6 +84,26 @@ export interface EditableContext { * 其它编辑器不需要:它们的格子本来就得先点进编辑(那条路径已经同步过当前格)。 */ 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 } /* ---------- 草稿 ---------- */ @@ -88,7 +113,12 @@ function getInputDraft(row: T, dataIndex: string): unknown { return drafts?.has(dataIndex) ? drafts.get(dataIndex) : row[dataIndex] } -function setInputDraft(row: T, dataIndex: string, value: unknown) { +/** + * 写入编辑器草稿。**导出**是因为「就绪态下打字进编辑」要用它: + * 表格在进入编辑之前先把用户敲的那个字符放进草稿,编辑器挂载时读到的就是它 + * (覆盖语义,与 Excel 一致)。见 G3Table 的 enterEditingAt。 + */ +export function setInputDraft(row: T, dataIndex: string, value: unknown) { let drafts = inputDrafts.get(row) if (!drafts) { drafts = new Map() @@ -133,7 +163,13 @@ function resolveOptions( return typeof editor.options === 'function' ? editor.options(row) : (editor.options ?? []) } -function isEditorDisabled(editor: TableEditor, row: T): boolean { +/** + * 这一格现在改不了吗(`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) @@ -213,11 +249,29 @@ function runValidator( 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, -): TableEditorStatus { +): ResolvedEditorStatus { /** * error 优先于 warning。 * @@ -225,12 +279,47 @@ function resolveEditorStatus( * 优先级在这里统一裁决,调用方就不必在每条规则里自己比较等级。 * 若反过来让 warning 优先,会出现「数据为空(严重)却只给个琥珀提示」的 * 危险表现——宁可多报一次严重的。 + * + * 同时命中时**消息也只带 error 那条**:用户此刻要处理的就是它, + * 两条一起塞进浮层反而让人分不清先改哪个。 */ - if (runValidator(editor.validate, 'validate', row, col).length > 0) return 'error' - if (runValidator(editor.validateWarning, 'validateWarning', row, col).length > 0) { - return 'warning' - } - return '' + 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: '' } } function notifyChange( @@ -270,14 +359,22 @@ function displayValue( ) { 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) - : value + : formatDisplayValue(value, row, col, precision) const content = text ?? '' const status = resolveEditorStatus(editor, row, col) - const statusClass = editorStatusClass(status, editor.errorStyle, is) + const statusClass = editorStatusClass(status.level, editor.errorStyle, is) /** * 按行动态禁用的格子:这里挂「不可改」标记,表格侧据此常驻灰底。 * @@ -311,7 +408,31 @@ function displayValue( statusClass ? { [statusClass]: true } : {}, readonly ? { [readonly]: true } : {}, ], - title: String(content), + /** + * 有校验原因时**撤掉** 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, ) @@ -325,25 +446,34 @@ function createFocusHandler( col: G3TableColumn, selectText = false, ) { - return ({ component, el }: { component?: any; el?: any }) => { - if (!ctx.shouldFocus(row, col)) return + /** + * 返回值 = 「这一次**真的**把焦点交给了编辑器」。 + * + * 日期编辑器用它决定顺手弹不弹日历面板(见 createDate):只有「用户刚激活这一格」 + * 的那一次才该弹,重渲染引起的重复聚焦不该弹。其余调用点忽略返回值即可 —— + * 所以这个改动是向后兼容的。 + */ + return ({ component, el }: { component?: any; el?: any }): boolean => { + if (!ctx.shouldFocus(row, col)) return false // 仅在两种场景聚焦:用户刚激活该格(一次性),或重渲染前本就持有焦点 const pending = ctx.consumePendingFocus?.(row) const wasFocused = Boolean(ctx.wasFocused?.(row)) - if (!pending && !wasFocused) return + if (!pending && !wasFocused) return false const input = el?.matches?.('input') ? el : el?.querySelector?.('input') - if (document.activeElement === input) return + // 焦点已经在这一格:不算「这次交给它」,否则日期面板会被重复弹开 + if (document.activeElement === input) return false const instance = component?.exposed || component?.proxy const shouldSelect = selectText && (pending || wasFocused) const opts = shouldSelect ? { cursor: 'all', preventScroll: true } : { preventScroll: true } if (instance?.focus) { instance.focus(opts) - return + return true } input?.focus(opts) if (shouldSelect) input?.select() + return true } } @@ -429,6 +559,32 @@ function activeCellClass( 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', @@ -506,14 +662,17 @@ function createInput( return h(G3Input, { type: INPUT_TYPE_MAP[editor.type] ?? 'default', modelValue: (getInputDraft(row, col.dataIndex) ?? '') as string, - status, + status: status.level, class: [ e('cell-editor'), e('cell-editor-root'), activeCellClass(ctx, row, col, is), - editorStatusClass(status, editor.errorStyle, is), + editorStatusClass(status.level, editor.errorStyle, is), ], - 'aria-label': col.title || col.dataIndex, + 'aria-label': status.message + ? `${col.title || col.dataIndex},${status.message}` + : col.title || col.dataIndex, + 'data-validate-message': status.message || undefined, 'onUpdate:modelValue': (value: string | number) => { setInputDraft(row, col.dataIndex, value) }, @@ -532,10 +691,66 @@ function createInput( }, 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 + } + if (event.key === 'Enter') { + /** + * 多行编辑器:Enter 就是换行(浏览器原生语义),一点都不拦。 + * 它与单行共用这段 onKeydown,早先没按类型分流,导致备注这类多行格 + * 按回车会把整段文本**全选**掉、根本换不了行。 + */ + if (editor.type === 'textarea') return + event.preventDefault() event.stopPropagation() - ;(event.target as HTMLInputElement).select() + /** + * 单行:**确认 + 下移到下一行同列** —— 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) } else if (event.key === 'Escape') { // Esc = **放弃本次编辑**(恢复到进入编辑前的值),符合 Excel / AG Grid / // Handsontable / antd 的通用约定(Enter 提交、Esc 取消)。 @@ -543,11 +758,14 @@ function createInput( // ⚠ 顺序与配套缺一不可: // 1) 置 canceling —— 让随后 onBlur 跳过提交(关键,否则回滚被用户输入覆盖) // 2) revertRow —— 把行数据恢复到进入编辑时的快照 - // 3) 清草稿 + blur —— 退出编辑态,输入框值经受控渲染回到行数据的原值 + // 3) 清草稿 + blur —— 输入框值经受控渲染回到行数据的原值 canceling = true ctx.revertRow(row) clearInputDraft(row, col.dataIndex) ;(event.target as HTMLInputElement).blur() + // 4) 退出编辑态(回到就绪态)—— 与 Excel 的「Esc 退出编辑」一致。 + // blur 本身做不到这件事:编辑态是**行级**的,失焦不等于退出编辑。 + ctx.exitEditing?.() } }, onClick: stop, @@ -592,9 +810,12 @@ function createSelect( e('cell-editor'), e('cell-editor-root'), activeCellClass(ctx, row, col, is), - editorStatusClass(status, editor.errorStyle, is), + editorStatusClass(status.level, editor.errorStyle, is), ], - 'aria-label': col.title || col.dataIndex, + 'aria-label': status.message + ? `${col.title || col.dataIndex},${status.message}` + : col.title || col.dataIndex, + 'data-validate-message': status.message || undefined, 'onUpdate:modelValue': (value: unknown) => { const resolved = value ?? editor.emptyValue ?? null row[col.dataIndex] = resolved as T[keyof T & string] @@ -606,6 +827,104 @@ 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, @@ -623,7 +942,7 @@ function createCheckbox( * 用户会以为那一列没问题。统一给类,让表格侧的选择器只写一次。 */ const status = resolveEditorStatus(editor, row, col) - const statusClass = editorStatusClass(status, editor.errorStyle, is) + const statusClass = editorStatusClass(status.level, editor.errorStyle, is) const update = (value: number) => { row[col.dataIndex] = value as T[keyof T & string] notifyChange(ctx, editor, row, col, value) @@ -644,6 +963,8 @@ function createCheckbox( [statusClass]: statusClass.length > 0, }, ], + // 失败原因挂在这里(checkbox 的根节点,与 input / select 用同一个属性名) + 'data-validate-message': status.message || undefined, onClick: stop, // 库在 mousedown 阶段处理选区与行编辑激活;checkbox 是始终可直接操作的控件, // 必须在此阶段隔离,否则第一次点击只激活编辑态、第二次才真正切换。 @@ -658,7 +979,12 @@ function createCheckbox( update((event.target as HTMLInputElement).checked ? 1 : 0) }, onKeydown: (event: KeyboardEvent) => { - if (event.key !== 'Enter') return + /** + * Space 是**原生** checkbox 的切换键(原生对 Enter 不响应); + * Enter 是表格用户的既有期待(连续录入时手不离 Enter)。 + * 两个都接:一个符合原生,一个符合表格习惯,都不会让人意外。 + */ + if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() event.stopPropagation() const value = row[col.dataIndex] === 1 ? 0 : 1 @@ -691,6 +1017,21 @@ export function createEditableCell( 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) + } 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 new file mode 100644 index 00000000..f46089a3 --- /dev/null +++ b/code/g3soft-libs/packages/ui/src/table/utils/precision.test.ts @@ -0,0 +1,107 @@ +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 new file mode 100644 index 00000000..4c8c27be --- /dev/null +++ b/code/g3soft-libs/packages/ui/src/table/utils/precision.ts @@ -0,0 +1,123 @@ +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)) ?? '', + ), + ) +}