diff --git a/code/g3soft-erp/CODEBUDDY.md b/code/g3soft-erp/CODEBUDDY.md index b2d0c412..2edec739 100644 --- a/code/g3soft-erp/CODEBUDDY.md +++ b/code/g3soft-erp/CODEBUDDY.md @@ -124,6 +124,17 @@ pnpm test:e2e:ui # 带 UI 的调试模式 账号密码不用去查库。库里没有的账号靠 `sql/` 下的脚本建(执行顺序见 `sql/README.md`); 要给 `demo_*` 放开更多菜单,用 `sql/README.md` 里「可选:给普通账号授权」那段。 +## UI 文案 + +**不写解释性 / 引导性文案。** 这是内部系统,用的人就是配系统的人,不需要教他下一步点哪。 + +- 别写「创建后会自动新增一条页签,请继续填写页签名称和 SQL 条件」这种"接下来你要干什么"的说明。 +- 表单下面不挂说明段落;也别用文字解释控件本身(禁用态、必填标记、示例 placeholder 就够了)。 +- 别写「改动即时生效」「留空表示不带默认条件」这类系统本该自明的话。 + +仍然要写的:**空态提示、校验报错、图标按钮的 `title`**——它们承载的是「页面没坏 / 哪儿错了 / +这个图标是什么」,不是教操作。 + ## 踩坑备忘 - **同一账号重复登录会互踢**:每次登录都刷新服务端 session(`ActiveSessionRegistry.activate`), diff --git a/code/g3soft-erp/web/src/components/biz-tree/BizTreePanel.vue b/code/g3soft-erp/web/src/components/biz-tree/BizTreePanel.vue index 874d3c10..ab352381 100644 --- a/code/g3soft-erp/web/src/components/biz-tree/BizTreePanel.vue +++ b/code/g3soft-erp/web/src/components/biz-tree/BizTreePanel.vue @@ -27,10 +27,11 @@ * expanded-keys 以 v-model:expanded-keys 暴露:搜索开始 / 结束时面板会抛出「全部展开 / []」, * 用以保证命中项可见;受控与否都生效(不传时面板用内部状态兜住)。 */ -import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { computed, nextTick, ref, watch } from 'vue' import { ChevronsLeft, ChevronsRight, Plus, Search, X } from '@lucide/vue' import { G3Button, G3Input, G3Scrollbar } from '@g3soft/ui' import type { TreeKey } from '@g3soft/ui' +import { useHotkeys } from '@/composables/useHotkeys' import { collectAllKeys, filterTreeByText, type TreeRow } from '@/utils/tree' import BizTree from './BizTree.vue' @@ -160,25 +161,22 @@ function splitByKeyword(text: string): TitleSegment[] { /** * 面板快捷键:Esc 关闭搜索;/ 在非输入状态下聚焦搜索(浏览器里 / 是「站内查找」的惯例键)。 - * 挂在 document 上才能覆盖「焦点在树里」等场景;单页一个树面板,不存在互抢。 + * 经 useHotkeys 注册在 window 捕获阶段,覆盖「焦点在树里」等场景; + * 输入态判断统一由 useHotkeys 负责(input / textarea / select / contenteditable)。 */ -function onGlobalKeydown(event: KeyboardEvent) { - if (event.key === 'Escape' && searchMode.value) { - closeSearch() - return - } - if (event.key !== '/' || searchMode.value) return - const target = event.target as HTMLElement | null - const inInput = - target?.isContentEditable || - ['INPUT', 'TEXTAREA', 'SELECT'].includes((target?.tagName ?? '').toUpperCase()) - if (inInput) return - event.preventDefault() - void openSearch() -} - -onMounted(() => document.addEventListener('keydown', onGlobalKeydown)) -onBeforeUnmount(() => document.removeEventListener('keydown', onGlobalKeydown)) +useHotkeys([ + { + key: 'Escape', + // 只在搜索态接管:否则会把 Esc 一直吞掉,弹窗关闭等行为跟着失效 + when: () => searchMode.value, + handler: () => closeSearch(), + }, + { + key: '/', + when: () => !searchMode.value, + handler: () => void openSearch(), + }, +]) /* ================= 展开 ================= */ diff --git a/code/g3soft-erp/web/src/components/icon-picker/IconPicker.vue b/code/g3soft-erp/web/src/components/icon-picker/IconPicker.vue new file mode 100644 index 00000000..c6d787a5 --- /dev/null +++ b/code/g3soft-erp/web/src/components/icon-picker/IconPicker.vue @@ -0,0 +1,125 @@ + + + + + + emit('update:modelValue', String(value))" + @blur="onBlur" + /> + + + + + + + 无效 + + + + + diff --git a/code/g3soft-erp/web/src/composables/useHotkeys.ts b/code/g3soft-erp/web/src/composables/useHotkeys.ts new file mode 100644 index 00000000..73cec6a7 --- /dev/null +++ b/code/g3soft-erp/web/src/composables/useHotkeys.ts @@ -0,0 +1,125 @@ +import { onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue' + +/** + * 全局键盘快捷键注册(可组合、可复用)。 + * + * 移植自 fms-vue 的 src/composables/useHotkeys.js,两处按本项目口径调整: + * 1. 输入态判断统一走 closest(...)(原版只比 e.target 自身的 tagName): + * 原版漏了 SELECT,且在 input 内层元素上触发时判不出来; + * 本仓库设计器侧本来就是 closest 口径,这里对齐成同一份,避免两套标准。 + * 2. 用 TS 声明参数,调用点写错键名/漏 handler 会在 typecheck 阶段报错。 + * + * 用法(在任意组件 setup 顶层调用): + * useHotkeys([ + * { key: 's', mod: true, ignoreWhenTyping: false, handler: save }, // Ctrl/Cmd + S + * { key: '/', handler: focusSearch }, // 单独键(无修饰符) + * { key: 'r', alt: true, handler: reload }, // Alt + R + * ]) + * + * 修饰符语义(精确匹配,未声明的修饰符按下则不匹配): + * mod —— 主修饰键:Ctrl 或 Meta(Cmd)任一(跨平台主修饰键) + * ctrl / meta / alt / shift —— 单独指定(与 mod 可叠加,如 { key: 's', mod: true, shift: true }) + * + * 其他选项: + * ignoreWhenTyping(默认 true)—— 焦点在 input / textarea / select / 可编辑区域时忽略; + * 组合保存类快捷键(如 Ctrl+S)通常传 false,保证输入时也能保存。 + * handler(e) —— 触发回调,默认已 preventDefault 阻止浏览器默认行为。 + */ + +/** 输入态选择器:这些元素内的按键留给文本编辑自己处理 */ +const TYPING_SELECTOR = 'input, textarea, select, [contenteditable="true"]' + +export interface Hotkey { + /** 主键,不区分大小写;如 's'、'/'、'ArrowLeft'、'Escape' */ + key: string + /** 主修饰键:Ctrl 或 Meta 任一。与 ctrl / meta / alt / shift 可叠加 */ + mod?: boolean + ctrl?: boolean + meta?: boolean + alt?: boolean + shift?: boolean + /** 焦点在输入框时是否忽略,默认 true */ + ignoreWhenTyping?: boolean + /** + * 前置条件:返回 false 表示「这次不归我管」——既不调用 handler,也不 preventDefault。 + * + * 用于键位只有在特定状态下才该生效的场景。典型是 Esc: + * 它在全局始终可匹配,若不加条件就会把 Esc 一直吞掉, + * 连带干掉弹窗关闭这类浏览器/组件自身的 Esc 行为。 + */ + when?: () => boolean + /** 触发回调(已 preventDefault) */ + handler: (e: KeyboardEvent) => void +} + +/** 输入态判断:焦点在 input / textarea / select / 可编辑区域内 */ +function isTypingTarget(e: KeyboardEvent): boolean { + const el = e.target as HTMLElement | null + if (!el || typeof el.closest !== 'function') return false + return el.closest(TYPING_SELECTOR) !== null +} + +/** + * 主键匹配:优先用 e.key;中文输入法组合中(拼音尚未上屏)时 e.key 为 + * 'Process' / 'Unidentified',此时退回物理键位 e.code('KeyS' → 's')兜底。 + * 否则「输入法激活时按 Ctrl+S」会概率性不匹配,表现为快捷键时灵时不灵。 + */ +function keyMatches(e: KeyboardEvent, key: string): boolean { + const target = key.toLowerCase() + if (String(e.key || '').toLowerCase() === target) return true + const code = String(e.code || '') + return code.startsWith('Key') && code.slice(3).toLowerCase() === target +} + +/** 快捷键匹配:精确匹配(未声明的修饰符按下则不匹配) */ +function matches(e: KeyboardEvent, s: Hotkey): boolean { + if (!keyMatches(e, s.key)) return false + // 主修饰键:声明了 mod / ctrl / meta 中任一 → 要求 Ctrl 或 Meta 按下 + const needsMod = s.mod === true || s.ctrl === true || s.meta === true + const modPressed = e.ctrlKey || e.metaKey + if (needsMod !== modPressed) return false + // alt / shift 精确匹配(未声明即要求未按下) + if (!!s.alt !== e.altKey) return false + if (!!s.shift !== e.shiftKey) return false + return true +} + +/** + * 生命周期: + * - 监听注册在 window 的**捕获阶段**:后代元素上的 @keydown.stop / stopPropagation + * 只阻断冒泡,捕获阶段仍能收到,避免个别控件(如结构树重命名输入框)内快捷键失效; + * - onMounted 注册、onBeforeUnmount 卸载; + * - keep-alive 缓存的页面切走后实例仍存活,故 onDeactivated 解绑、onActivated 重新绑定: + * 否则同时打开的多个页签(如模块管理 / 菜单管理)会一起响应同一次 Ctrl+S 而互相干扰。 + */ +export function useHotkeys(shortcuts: Hotkey[]): void { + function onKeydown(e: KeyboardEvent): void { + for (const s of shortcuts) { + if (!matches(e, s)) continue + if (s.ignoreWhenTyping !== false && isTypingTarget(e)) continue + // 条件不满足即让行:不拦截,留给别处处理(见 when 的说明) + if (s.when && !s.when()) continue + e.preventDefault() + s.handler(e) + break + } + } + + let bound = false + function bind(): void { + if (bound) return + window.addEventListener('keydown', onKeydown, true) + bound = true + } + function unbind(): void { + if (!bound) return + window.removeEventListener('keydown', onKeydown, true) + bound = false + } + + onMounted(bind) + // keep-alive 内首次挂载后也会触发 activated,bind 幂等无需额外判断 + onActivated(bind) + onDeactivated(unbind) + onBeforeUnmount(unbind) +} diff --git a/code/g3soft-erp/web/src/layouts/LayoutHeader.vue b/code/g3soft-erp/web/src/layouts/LayoutHeader.vue index e2488906..38018c21 100644 --- a/code/g3soft-erp/web/src/layouts/LayoutHeader.vue +++ b/code/g3soft-erp/web/src/layouts/LayoutHeader.vue @@ -14,7 +14,8 @@ import { Sun, User, } from '@lucide/vue' -import { G3Message } from '@g3soft/ui' +import { G3Dropdown, G3Message } from '@g3soft/ui' +import type { DropdownItemData } from '@g3soft/ui' import { usePermissionStore, useUserStore } from '@/stores' import { usePreferencesStore } from '@/preferences' import { useFullscreen } from '@/composables/useFullscreen' @@ -75,6 +76,25 @@ function logout(): void { router.replace('/login') } +/** + * 用户卡片下拉菜单。 + * + * 退出登录不再和用户卡片并排显示(两个入口挤在一起既占宽度又容易误点), + * 而是收进这里:点用户卡片才展开,与 vben 的「用户区即下拉」一致。 + * + * 用 onClick 而不是监听 @click 事件:G3Dropdown 的项自带回调时优先执行, + * 不必再按 key 二次分发(与 SidebarMenuItem / LayoutHeaderMenu 同一套做法)。 + */ +const userMenuItems = computed(() => [ + { + key: 'logout', + label: '退出登录', + icon: LogOut, + danger: true, + onClick: () => logout(), + }, +]) + /** 当前是否深色(auto 跟随系统时也能正确反映) */ const isDark = computed(() => prefStore.isDark) @@ -208,20 +228,24 @@ function handleNotification(): void { - - - {{ displayName }} - - :内部还有图标/文字多段结构。 --> + + - - - + + {{ displayName }} + + @@ -307,35 +331,39 @@ function handleNotification(): void { color: var(--g3-color-foreground, #1f2329); } + /* 用户卡片:整块可点(下拉触发器),因此要有按钮该有的悬停/聚焦反馈。 + 左右 padding 比图标按钮略大,让它看起来是「一张卡片」而不是一段文字。 */ &__user { display: flex; align-items: center; flex-shrink: 0; gap: 6px; + height: 32px; + padding: 0 8px; font-size: 13px; color: var(--g3-color-foreground, #1f2329); + cursor: pointer; + border-radius: 6px; + outline: none; + transition: background-color 0.2s ease; + + &:hover, + &:focus-visible { + background: var(--g3-color-accent, #f2f3f5); + } + + /* 键盘聚焦也给出可见焦点环(鼠标点不给,避免多一圈突兀描边) */ + &:focus-visible { + box-shadow: 0 0 0 2px var(--g3-color-primary, #1677ff); + } } - /* 退出登录:比工具条按钮小一号,不抢视觉;色值走 inherit,半深色顶栏也不脱色 */ - &__logout { - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 24px; - height: 24px; - padding: 0; - color: inherit; - cursor: pointer; - background: none; - border: 0; - border-radius: 6px; - opacity: 0.7; - - &:hover { - background: var(--g3-color-accent, #f2f3f5); - opacity: 1; - } + /* 用户名过长时截断:顶栏宽度有限,不能让名字把右侧工具区挤变形 */ + &__user-name { + max-width: 120px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } } diff --git a/code/g3soft-erp/web/src/layouts/icons.ts b/code/g3soft-erp/web/src/layouts/icons.ts index fa59d540..d00cb9b4 100644 --- a/code/g3soft-erp/web/src/layouts/icons.ts +++ b/code/g3soft-erp/web/src/layouts/icons.ts @@ -1,89 +1,35 @@ import type { Component } from 'vue' import type { MenuItem, SidebarMenuNode } from '@/types/menu' -import { - Blocks, - Building2, - CircleCheck, - Container, - Database, - FileText, - LayoutDashboard, - ListChecks, - Package, - Plane, - Settings, - Ship, - Table, - Users, - Wallet, -} from '@lucide/vue' +import { getIcon } from '@/utils/iconResolver' /** - * 菜单图标注册表。 + * 菜单图标解析。 * - * s_menu.b_icon 里存的是**图标名字符串**(如 `settings` / `database` / `circle-check`), - * 在此映射到 lucide 组件 —— 数据库只存名字,不存组件,前端换图标库不必洗数据。 + * s_menu.b_icon 里存的是**图标名字符串**(如 `settings` / `circle-check`), + * 在此解析成组件 —— 数据库只存名字,不存组件,前端换图标库不必洗数据。 * - * ⚠️ 现实数据是 **kebab-case**(少数历史数据可能是 PascalCase), - * 因此查表时先按原名、再按归一化名(去连字符、首字母大写)各查一次。 - * 只认一种写法会让另一批菜单**静默丢失图标**(getMenuIcon 返回 undefined,不报错)。 + * 解析规则集中在 @/utils/iconResolver(与「菜单管理」的图标输入共用),支持: + * - 全部 lucide 图标名(kebab / camel / Pascal 各种写法都归一化) + * - `lucide:` 前缀(显式指定,匹配不到就不当 SVG 试) + * - 一段合法 SVG 标记(lucide 覆盖不到的行业专有图标) */ -const iconMap: Record = { - // kebab-case:与 s_menu 实际存量数据一致 - 'circle-check': CircleCheck, - database: Database, - settings: Settings, - users: Users, - ship: Ship, - plane: Plane, - container: Container, - package: Package, - wallet: Wallet, - table: Table, - 'list-checks': ListChecks, - 'building-2': Building2, - 'file-text': FileText, - 'layout-dashboard': LayoutDashboard, - // 模块中心(超管固定入口,见 @/router 的 STATIC_MODULE_MENU): - // 只有一级分组项有图标,子项无图标(STATIC_MODULE_MENU.children 不写 icon) - blocks: Blocks, - // PascalCase:兼容早期常量菜单写下的名字 - CircleCheck, - Database, - Settings, - Users, - Ship, - Plane, - Container, - Package, - Wallet, - Table, - ListChecks, - Building2, - FileText, - LayoutDashboard, - Blocks, -} - -/** 把 `circle-check` 归一化成 `CircleCheck`;已是 PascalCase 的原样返回。 */ -function toPascalCase(name: string): string { - return name - .split('-') - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join('') -} /** * 按图标名取组件。 * - * @param name s_menu.b_icon 的原始值(kebab-case 或 PascalCase) - * @returns 图标组件;未登记的名字返回 undefined(不抛错,菜单照常渲染只是没图标) + * 解析走 @/utils/iconResolver 的 getIcon(与「菜单管理」的图标输入同源): + * 那里支持全部 lucide 图标 + `lucide:` 前缀 + SVG 源码,并做名称归一化。 + * + * ⚠️ 这里曾经是一张 15 项的白名单。菜单图标改成自由输入后,白名单立刻成了 bug 源: + * 用户在菜单管理里填 `truck`(lucide 里确实有)能保存成功,侧栏却**静默没有图标** + * —— 正是本文件开头警告的那种失败。所以改为与输入侧共用同一解析器: + * 输入能预览出来的,侧栏就一定渲染得出来。 + * + * @param name s_menu.b_icon 的原始值(lucide 名 / `lucide:` 前缀 / SVG 标记) + * @returns 图标组件;无法解析时返回 undefined(不抛错,菜单照常渲染只是没图标) */ export function getMenuIcon(name?: string): Component | undefined { - if (!name) return undefined - const key = String(name).trim() - return iconMap[key] ?? iconMap[toPascalCase(key)] + return getIcon(name) ?? undefined } /** @@ -93,8 +39,9 @@ export function getMenuIcon(name?: string): Component | undefined { * 实现,改一处必漏另一处。收敛到这里后,图标解析规则只存在一份。 * * ⚠️ 幂等性不成立:传入的 icon 必须仍是**字符串**。已经是组件的树再走一遍 - * getMenuIcon 会拿到 undefined(组件进不了 iconMap),表现为整棵树突然没图标。 - * 所以调用方只能传 useVisibleMenuItems() 的原始输出,不要传本函数的返回值。 + * getMenuIcon,组件会被当成图标名去解析(解析不出 → undefined), + * 表现为整棵树突然没图标。所以调用方只能传 useVisibleMenuItems() 的原始输出, + * 不要传本函数的返回值。 */ export function mapMenuTree(list: MenuItem[]): SidebarMenuNode[] { return list diff --git a/code/g3soft-erp/web/src/router/index.ts b/code/g3soft-erp/web/src/router/index.ts index e4768d9b..e814baec 100644 --- a/code/g3soft-erp/web/src/router/index.ts +++ b/code/g3soft-erp/web/src/router/index.ts @@ -47,7 +47,7 @@ const HOME_PATH = '/dashboard' * * 新增页面时同样两步:① 本表加一条路由 ② s_menu 录一条菜单。 */ -const protectedRoutes: RouteRecordRaw[] = [ +export const protectedRoutes: RouteRecordRaw[] = [ // ---- 首页(登录后落地页) ---- { path: '/dashboard', @@ -467,3 +467,64 @@ router.afterEach(() => { export { HOME_PATH } export default router + +/* ================= 菜单管理的「路由地址」候选 ================= */ + +/** 拼接父路径与子路径(相对路径形态):'/module' + 'i18n-type' → '/module/i18n-type' */ +function joinRoutePath(parentPath: string, childPath: string): string { + const parent = String(parentPath || '').replace(/\/+$/, '') + const child = String(childPath || '').replace(/^\/+/, '') + if (!parent) return child ? `/${child}` : '' + return child ? `${parent}/${child}` : parent +} + +/** + * 收集可作为菜单入口的路由(对照 fms-vue 的 collectMenuRoutes)。 + * + * 排除规则: + * - meta.adminOnly:超管专用页(模块中心)不作为业务菜单入口 —— + * 「菜单管理」自己的入口若落进 s_menu,误删后就没有修复入口了; + * - 含 children 的节点(布局容器 / 分组)不是最终页面,只向下递归; + * - 含动态段(:id)或通配(*)的路由无法作为固定菜单入口; + * - 403 / 500 兜底页。 + * + * @param list 路由节点数组,默认取整张受保护路由表 + * @param parentPath 父节点完整路径,用于拼接分组内子路由的相对路径 + * @returns 选项(value 为完整 path,label 为「路径(标题)」) + */ +export function collectMenuRoutes( + list: RouteRecordRaw[] = protectedRoutes, + parentPath = '', +): Array<{ value: string; label: string }> { + const excluded = new Set(['/403', '/500']) + const result: Array<{ value: string; label: string }> = [] + for (const route of list || []) { + if (route.meta?.adminOnly) continue + const rawPath = route.path || '' + // 绝对路径原样使用;相对路径(分组节点的 children)拼到父路径上 + const path = rawPath.startsWith('/') ? rawPath : joinRoutePath(parentPath, rawPath) + if (Array.isArray(route.children) && route.children.length) { + result.push(...collectMenuRoutes(route.children, path)) + continue + } + if (!path.startsWith('/')) continue + if (path.includes(':') || path.includes('*')) continue + if (excluded.has(path)) continue + const title = route.meta?.title + result.push({ + value: path, + label: typeof title === 'string' && title ? `${path}(${title})` : path, + }) + } + return result +} + +/** + * 菜单管理「路由地址」下拉选项:按路径排序。 + * + * label 同时含路径与标题,配合 Select 的本地包含匹配, + * 输入路径片段(如 "/base")命中路径、输入标题关键词(如 "联系人")命中标题。 + */ +export const menuRouteOptions = collectMenuRoutes().toSorted((a, b) => + a.value.localeCompare(b.value, 'zh-Hans-CN'), +) diff --git a/code/g3soft-erp/web/src/utils/dataChanges.ts b/code/g3soft-erp/web/src/utils/dataChanges.ts new file mode 100644 index 00000000..321fc2bb --- /dev/null +++ b/code/g3soft-erp/web/src/utils/dataChanges.ts @@ -0,0 +1,226 @@ +/** + * 数据变更比对(对照 fms-vue 的 src/utils/dataChanges.js)。 + * + * 保存编排的通用底座:加载时留一份 `_org` 基线,保存时用这里的函数逐表 diff, + * 只提交真正变化的行。「加载基线 + 逐表 diff」这个口径下,各页面的保存流程长得一样, + * 差异只在「提交哪些表、表之间的主键怎么定」。 + * + * 字符串业务键方案:行身份 = 主键列值组合(keyFields),不再是雪花 id。 + * - 单列业务键:s_menu / s_module 传 ['b_id']; + * - 复合业务键:s_field 传 ['b_module_id','b_field']、s_i18n 传 ['b_key','b_locale']。 + * 因此本模块**不需要** fms 那套 allocateTemporaryIds / remapForeignKeys + * (那是给「草稿行先用临时数字 id、落库后再回灌真实 id 并修正跨表外键」用的); + * 新增菜单就是直接用用户填的编码做主键,不存在 id 回灌。 + */ + +/** 深拷贝(结构化克隆的 JSON 版本):草稿与基线的隔离靠它 */ +export function cloneData(value: T): T { + return JSON.parse(JSON.stringify(value)) as T +} + +/** + * 剔除派生字段:下划线前缀(`_*`)与视图前缀(`v_*`)。 + * + * 前者是前端派生/虚拟字段(如 `_org` 基线、i18n 透视行的 `_id`), + * 后者是视图只读列;两者都不参与比较、也不提交后端。 + * 数组逐元素递归,其余非对象原样返回。 + */ +export function stripInternalKeys(value: T): T { + if (Array.isArray(value)) return value.map(stripInternalKeys) as unknown as T + if (!value || typeof value !== 'object') return value + const stripped: Record = {} + for (const [key, val] of Object.entries(value as Record)) { + if (key.startsWith('_') || key.startsWith('v_')) continue + stripped[key] = val + } + return stripped as T +} + +/** 派生字段判定(`stripInternalKeys` 与字段级比较共用同一口径) */ +function isIgnorableKey(key: string, ignoreSet: Set): boolean { + return key.startsWith('_') || key.startsWith('v_') || ignoreSet.has(key) +} + +/** 版本字段:不参与内容比较,提交时也不携带(后端维护) */ +const VERSION_FIELDS = new Set(['b_version', 'mx_version']) + +/** `diffRows` 的选项 */ +export interface DiffRowsOptions { + /** 行身份列;默认 ['b_id']。单列业务键 / 复合业务键都通过它表达 */ + keyFields?: string[] +} + +/** `diffRows` 的结果:inserts / updates 是 current 里的**原对象引用**(不拷贝) */ +export interface RowsDiff { + inserts: T[] + updates: T[] + deletes: Array> +} + +/** + * 行集合差异比对(子表;单条记录字段级比对用 `diffRow`)。 + * + * 比较时忽略派生字段(`_*` / `v_*`),避免「打开即脏」。 + * inserts / updates 返回 current 中的原对象引用而非副本:调用方常需要在提交前 + * 就地补字段(如按菜单行派生权限点行),返回副本会让那些改动丢失。 + * 派生字段的剥离统一延后到提交前调用 `sanitizeSaveReqs` 处理。 + */ +export function diffRows>( + current: T[], + previous: T[], + options: DiffRowsOptions = {}, +): RowsDiff { + const keyFields = options.keyFields?.length ? options.keyFields : ['b_id'] + const keyOf = (item: T | undefined) => + keyFields.map((field) => String(item?.[field] ?? '')).join('\u0001') + const previousByKey = new Map(previous.map((item) => [keyOf(item), item])) + const currentByKey = new Map(current.map((item) => [keyOf(item), item])) + return { + inserts: current.filter((item) => !previousByKey.has(keyOf(item))), + updates: current.filter((item) => { + const old = previousByKey.get(keyOf(item)) + if (!old) return false + return ( + JSON.stringify(stripInternalKeys(item)) !== JSON.stringify(stripInternalKeys(old)) + ) + }), + deletes: previous + .filter((item) => !currentByKey.has(keyOf(item))) + // delete 只带主键列:行已不存在,带其它列没有意义 + .map((item) => Object.fromEntries(keyFields.map((field) => [field, item[field]]))), + } +} + +/** + * 提交前剥离保存请求中的派生字段(`_*` / `v_*`),返回请求副本,不改原对象。 + * + * 必须在「按草稿补全字段」之后调用:剥离产生副本,提前调用会让请求行与草稿行脱钩。 + */ +export function sanitizeSaveReqs(saveReqs: Array>): Array< + Record +> { + return (saveReqs || []).map((req) => { + const next = { ...req } + if (Array.isArray(next.inserts)) { + next.inserts = (next.inserts as unknown[]).map(stripInternalKeys) + } + if (Array.isArray(next.updates)) { + next.updates = (next.updates as unknown[]).map(stripInternalKeys) + } + return next + }) +} + +/** `diffRow` 的入参 */ +export interface DiffRowInput { + current: Record | null | undefined + original?: Record | null + /** 主键列,默认 'b_id' */ + primaryKey?: string + /** 额外忽略的列名 */ + ignoreFields?: string[] +} + +/** `diffRow` 的结果 */ +export interface RowDiff { + hasChanges: boolean + inserts: Array> + updates: Array> + deletes: Array> +} + +/** 值是否可当数字比较('' 不算,避免空串被当成 0) */ +function isNumberValue(value: unknown): boolean { + if (typeof value === 'number') return !Number.isNaN(value) + if (typeof value === 'string') { + if (value.trim() === '') return false + return !Number.isNaN(Number(value)) + } + return false +} + +/** null / undefined / 空串 归一成 '',避免「空 vs 未填」被判成差异 */ +function normalizeEmpty(value: unknown): unknown { + return value === undefined || value === null || value === '' ? '' : value +} + +/** null / undefined / 空串 等价;两边都是数字时按数值比较(40 === '40.00') */ +function isEqualValue(a: unknown, b: unknown): boolean { + const na = normalizeEmpty(a) + const nb = normalizeEmpty(b) + if (isNumberValue(na) && isNumberValue(nb)) { + return Math.abs(Number(na) - Number(nb)) < 0.000001 + } + return JSON.stringify(na) === JSON.stringify(nb) +} + +/** + * 单条记录字段级差异比对(主表;行集合请用 `diffRows`)。 + * + * updates 只携带「变化的字段 + 主键」——后端按提供的列 SET,字段级更安全 + * (整行 UPDATE 会把并发修改的其它列一起覆盖掉)。 + * 无基线或基线无主键 → 视为新建(inserts 为过滤后的整行)。 + */ +export function diffRow({ + current, + original, + primaryKey = 'b_id', + ignoreFields = [], +}: DiffRowInput): RowDiff { + const ignoreSet = new Set(ignoreFields) + const pk = primaryKey + /** 版本字段与派生字段都不参与比较 */ + const shouldSkip = (key: string) => + isIgnorableKey(key, ignoreSet) || VERSION_FIELDS.has(key) + + const result: RowDiff = { hasChanges: false, inserts: [], updates: [], deletes: [] } + const cur = current || {} + + // 无基线或基线无主键 → 新建整行 + const originalKey = original?.[pk] + if (!original || originalKey === undefined || originalKey === null || originalKey === '') { + const insert: Record = {} + for (const [key, value] of Object.entries(cur)) { + if (shouldSkip(key)) continue + insert[key] = value + } + result.hasChanges = true + result.inserts.push(insert) + return result + } + + // 修改:只携带变化字段 + 主键 + const changed: Record = {} + for (const key of Object.keys(cur)) { + if (key === pk || shouldSkip(key)) continue + if (!isEqualValue(cur[key], original[key])) changed[key] = cur[key] + } + if (Object.keys(changed).length) { + result.hasChanges = true + result.updates.push({ [pk]: cur[pk], ...changed }) + } + return result +} + +/** 组装一条保存请求(key_field 由主键列数组拼出,与后端契约一致) */ +export function tableChange( + table: string, + changes: Partial, + keyFields?: string[], +): Record { + const keys = keyFields?.length ? keyFields : ['b_id'] + return { table, key_field: keys.join(','), ...changes } +} + +/** 数组长度(非数组记 0) */ +function listLength(value: unknown): number { + return Array.isArray(value) ? value.length : 0 +} + +/** 该请求是否含实际变更(全空的请求不必发给后端) */ +export function hasChanges(request: Record | null | undefined): boolean { + if (!request) return false + return Boolean( + listLength(request.inserts) || listLength(request.updates) || listLength(request.deletes), + ) +} diff --git a/code/g3soft-erp/web/src/utils/iconResolver.ts b/code/g3soft-erp/web/src/utils/iconResolver.ts new file mode 100644 index 00000000..7b247617 --- /dev/null +++ b/code/g3soft-erp/web/src/utils/iconResolver.ts @@ -0,0 +1,165 @@ +/** + * 图标值解析:字符串 → 可渲染的 Vue 组件(对照 fms-vue 的 src/utils/lucideIcons.js)。 + * + * s_menu.b_icon 之类的列存的是**字符串**,不存组件 —— 数据库只记名字, + * 前端换图标库不必洗数据。本模块负责把那个字符串解析成组件: + * - `lucide:chevron-down` → 显式指定 lucide 图标(去前缀 + 名称归一化) + * - 其它非空字符串 → 先按 lucide 图标名匹配;匹配不到时, + * 仅当它是一段合法 SVG 标记才渲染(支持粘贴自定义图标) + * - 无效内容 → 返回 null,**不渲染也不抛错** + * + * 为什么支持 SVG 源码:lucide 是通用图标集,覆盖不到行业专有图标 + * (船公司 logo、内部系统标识等),允许直接粘一段 `` 填补这个空缺。 + * + * 为什么放在 ERP 的 utils 而不是组件库:命名归一化 / SVG 校验 / 缓存是纯函数, + * 但「接受 SVG 源码」这条是产品决策而非通用能力,先落在应用侧, + * 等第二个页面复用时再考虑上提(对照 fms 同名模块的定位)。 + */ +import { defineComponent, h, type Component } from 'vue' +import * as LucideIcons from '@lucide/vue' + +/** PascalCase 导出名 → 组件;首次调用时惰性建立 */ +let iconMap: Record | null = null + +function loadIcons(): Record { + if (iconMap) return iconMap + const map: Record = {} + for (const [key, component] of Object.entries(LucideIcons)) { + // lucide 同时导出 createLucideIcon 等小写开头的工具函数,只收组件(首字母大写) + if (key === 'default' || key === 'createLucideIcon') continue + if (key.charAt(0) !== key.charAt(0).toUpperCase()) continue + map[key] = component as Component + } + iconMap = map + return map +} + +/** + * 各种命名风格归一化成 lucide 的 PascalCase 导出名: + * `chevron-down` / `chevron_down` / `chevronDown` / `CHEVRON DOWN` / `CHEVRONDOWN` + * 全部 → `ChevronDown` + */ +export function normalizeIconName(name: string): string { + const raw = String(name || '').trim() + if (!raw) return '' + return raw + .split(/[-_\s]+/) + .filter(Boolean) + .map(capitalizeSegment) + .join('') +} + +/** + * 把一段(已按分隔符切开的)名字转成 PascalCase 里的一个词。 + * + * 两边的坑都要躲开: + * - `CHEVRON` 这种全大写段:直接首字母大写会原样留下 `CHEVRON` + * (lucide 的导出名是 `Chevron`),所以全大写段先整体小写; + * - `chevronDown` 这种没有分隔符的 camelCase:整体转小写又会把词内的大写边界 + * 抹掉(→ `Chevrondown`),所以含小写字母的段要保留原有大小写边界。 + */ +function capitalizeSegment(segment: string): string { + // 全大写段(无分隔符写法里的 CHEVRON):整体小写后再首字母大写 + if (segment === segment.toUpperCase()) { + const lower = segment.toLowerCase() + return lower.charAt(0).toUpperCase() + lower.slice(1) + } + // 含小写字母:保留词内的大小写边界(camelCase 的 D 不能被抹掉) + return segment.charAt(0).toUpperCase() + segment.slice(1) +} + +/** + * 字符串是否为合法 SVG 标记:用 DOMParser 按 SVG 文档解析, + * 解析失败(存在 parsererror)或根元素不是 `` 都算无效。 + * 无 DOMParser 的环境(测试 / SSR)回退到基础启发式判断。 + */ +export function looksLikeSvg(content: string): boolean { + const trimmed = String(content || '').trim() + if (!trimmed) return false + if (typeof DOMParser === 'undefined') { + return /^]/i.test(trimmed) + } + try { + const doc = new DOMParser().parseFromString(trimmed, 'image/svg+xml') + if (doc.querySelector('parsererror')) return false + const root = doc.documentElement + return !!root && root.tagName.toLowerCase() === 'svg' + } catch { + return false + } +} + +/** + * SVG 源码 → 组件,按源码字符串缓存。 + * + * 缓存是必要的:`getIcon` 在渲染函数里被调用,每次返回新组件会让 Vue + * 把整棵子树判定成「类型变了」而卸载重建 —— 表现为图标每帧闪烁。 + */ +const svgComponentCache = new Map() + +function getSvgComponent(svg: string): Component { + const cached = svgComponentCache.get(svg) + if (cached) return cached + const component = defineComponent({ + name: 'RawSvgIcon', + render() { + return h('span', { + class: 'g3-raw-svg-icon', + style: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: 0, + }, + innerHTML: svg, + }) + }, + }) + svgComponentCache.set(svg, component) + return component +} + +/** + * 解析图标值,返回可渲染的组件。 + * + * @param value 图标字符串(`lucide:Name`、lucide 图标名、或 SVG 标记) + * @returns 图标组件;无效内容与空值返回 null(调用方据此不渲染,不抛错) + */ +export function getIcon(value?: string | null): Component | null { + if (!value || typeof value !== 'string') return null + const trimmed = value.trim() + if (!trimmed) return null + + const map = loadIcons() + + // lucide 前缀:显式指定图标名,匹配不到就返回 null(不继续当 SVG 试) + if (trimmed.startsWith('lucide:')) { + return map[normalizeIconName(trimmed.slice('lucide:'.length))] ?? null + } + + // 无前缀:先按 lucide 图标名匹配 + const normalized = normalizeIconName(trimmed) + if (map[normalized]) return map[normalized] + + // 既不是 lucide 名:仅当是合法 SVG 标记才渲染 + return looksLikeSvg(trimmed) ? getSvgComponent(trimmed) : null +} + +/** 图标值是否有效(用于表单校验:让拼错的名字当场报错,而不是静默不显示) */ +export function isValidIconValue(value?: string | null): boolean { + const trimmed = String(value ?? '').trim() + // 留空是合法的(图标可选) + if (!trimmed) return true + return getIcon(trimmed) !== null +} + +/** + * 全部 lucide 图标名(PascalCase,按字母序)。 + * + * ⚠️ 目前没有调用方:菜单的图标输入是自由文本 + 预览(对照 fms 的设计), + * 不做全量下拉 —— 6000+ 项的列表既卡又无法浏览。保留它只为「将来要做一个 + * 可搜索的图标面板」时不必再回来翻 lucide 的导出。 + */ +export function getIconNames(): string[] { + return Object.keys(loadIcons()).toSorted() +} diff --git a/code/g3soft-erp/web/src/views/module/MenuManagement.vue b/code/g3soft-erp/web/src/views/module/MenuManagement.vue index b9e115c5..26dbb2d3 100644 --- a/code/g3soft-erp/web/src/views/module/MenuManagement.vue +++ b/code/g3soft-erp/web/src/views/module/MenuManagement.vue @@ -1,19 +1,1004 @@ - + + + + addMenu(parentId, menuType)" + @delete="(id) => void deleteMenu(id)" + @drop="(payload) => void onMenuDrop(payload)" + @toggle-enabled="(payload) => void onToggleMenuEnabled(payload)" + /> + + + + + + + + + + + {{ headMenu?.b_name || '未命名菜单' }} + + + {{ Number(headMenu?.b_canuse) === 1 ? '启用' : '停用' }} + + + 未保存·保存后才会落库 + + + + + + 刷新 + + + + 保存 + + + + + + + + + + + + + + + + 菜单配置 + + 维护菜单编码、名称、类型、路由地址与排序。 + + + + + + + + + + + + + + + + 请选择左侧菜单节点 + + + + + + + diff --git a/code/g3soft-erp/web/src/views/module/menu-management/MenuBasicPanel.vue b/code/g3soft-erp/web/src/views/module/menu-management/MenuBasicPanel.vue new file mode 100644 index 00000000..ed1e4278 --- /dev/null +++ b/code/g3soft-erp/web/src/views/module/menu-management/MenuBasicPanel.vue @@ -0,0 +1,221 @@ + + + + + + + + + + + + setField('b_name', value)" + /> + + + + + + + + + setField('b_route', value)" + /> + + + + setField('b_route', value)" + /> + + + + + + + + setField('b_icon', value)" + /> + + + + setField('b_xh', Number(value) || 0)" + /> + + + + setField('b_bz', value)" + /> + + + + + + diff --git a/code/g3soft-erp/web/src/views/module/menu-management/MenuTreePanel.vue b/code/g3soft-erp/web/src/views/module/menu-management/MenuTreePanel.vue new file mode 100644 index 00000000..92604647 --- /dev/null +++ b/code/g3soft-erp/web/src/views/module/menu-management/MenuTreePanel.vue @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + + + + {{ segment.text }} + {{ segment.text }} + + + + + + onToggleEnabled(node, value)" + /> + + + + + + + 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 new file mode 100644 index 00000000..a47b6fff --- /dev/null +++ b/code/g3soft-erp/web/src/views/module/menu-management/ModuleBindPanel.vue @@ -0,0 +1,179 @@ + + + + + + 绑定模块 + + {{ boundCount }} + + + {{ stateText }} + + + + + + + + + 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 new file mode 100644 index 00000000..0a84f8eb --- /dev/null +++ b/code/g3soft-erp/web/src/views/module/menu-management/utils.ts @@ -0,0 +1,229 @@ +/** + * 菜单管理公共工具(移植自 fms-vue 的 menu-management/utils.js)。 + * + * 只放无 Vue 状态依赖的纯逻辑:菜单类型选项、行提取、多语言键、入口权限点派生。 + * 树本身的通用查找 / 过滤 / 插入 / 移除 / 位置派生见 @/utils/tree,不在此重复。 + */ +import { ExternalLink, FileText, Folder } from '@lucide/vue' +import type { Component } from 'vue' +import { buildMenuPowerCode, buildPowerDeleteDependents } from '@/utils/powerCodes' +import type { TreeRow } from '@/utils/tree' + +/** 菜单类型(s_menu.b_menu_type):directory 目录 / page 页面 / external 外链 */ +export const MENU_TYPE_OPTIONS: Array<{ label: string; value: string }> = [ + { label: '目录', value: 'directory' }, + { label: '页面', value: 'page' }, + { label: '外链', value: 'external' }, +] + +/** 菜单类型 → 树节点图标(预载到 node.icon,G3Tree 自动渲染) */ +export const MENU_TYPE_ICON_MAP: Record = { + directory: Folder, + page: FileText, + external: ExternalLink, +} + +/** 菜单树里能承载子节点的类型:页面 / 外链是末端节点 */ +export const MENU_NON_LEAF_TYPES = ['directory'] + +/** 工具栏 / 右键「新增」菜单项(key 即 b_menu_type) */ +export const MENU_ADD_OPTIONS: Array<{ key: string; label: string; icon: Component }> = [ + { key: 'directory', label: '新增目录', icon: Folder }, + { key: 'page', label: '新增页面', icon: FileText }, + { key: 'external', label: '新增外链', icon: ExternalLink }, +] + +/** + * 菜单树节点:s_menu 行业务列 + 树运行时挂上的 children / icon 等。 + * 与 @/utils/tree 的 TreeRow 兼容(可直接交给建树 / 拖拽等纯函数)。 + */ +export type MenuNode = TreeRow & { + b_id: string + b_parent_id: string | null + b_name: string + b_i18n: string | null + b_menu_type: string + b_route: string | null + b_icon: string | null + b_xh: number + b_canuse: number + /** 备注 / 描述(s_menu.b_bz)。索引签名兜底,但显式声明便于面板取用 */ + b_bz?: string | null + children?: MenuNode[] +} + +/** s_menu 主表列(树节点 → 主表草稿行时按此提取,剔除 children/isLeaf/icon 等树注入字段) */ +export const MENU_ROW_FIELDS = [ + 'b_id', + 'b_parent_id', + 'b_depth', + 'b_path', + 'b_name', + 'b_i18n', + 'b_menu_type', + 'b_route', + 'b_icon', + 'b_xh', + 'b_canuse', +] as const + +/** + * 从树节点提取纯菜单行(剔除 children/isLeaf/icon 等树注入字段)。 + * 未落库节点切回时,用它从树节点重建主表草稿。 + */ +export function menuRowOf(node: Partial): Record { + const row: Record = {} + // 字符串业务键:b_id 即菜单编码,不再有独立 b_code + for (const field of MENU_ROW_FIELDS) row[field] = node[field] ?? null + row.b_parent_id = node.b_parent_id ? String(node.b_parent_id) : null + // b_path 非空且无默认值:缺省时退化为空串占位,不能为 null + row.b_depth = Number(node.b_depth ?? 0) + row.b_path = node.b_path == null ? '' : String(node.b_path) + return row +} + +/* ================= 多语言资源键(s_i18n,主键 b_key + b_locale) ================= */ + +/** + * 菜单多语言资源键:menu.{菜单编码}。 + * 加载、保存、删除都以此键定位 s_i18n 行(s_i18n 无菜单 / 模块归属列)。 + * + * @returns 资源键;菜单编码为空时返回 null + */ +export function buildMenuI18nKey(menuId: string): string | null { + const normalized = String(menuId || '').trim() + return normalized ? `menu.${normalized}` : null +} + +/* ================= 菜单入口权限点(s_power,b_object_type = 'menu') ================= */ + +/** + * 需要入口权限点的菜单类型:目录只是导航分组(侧栏按子菜单裁剪), + * 页面与外链才是「能否进入」的入口,各对应一行 menu.{菜单编码}(b_action = 'access')。 + */ +export const MENU_POWER_MENU_TYPES = ['page', 'external'] + +/** 参与同步的 s_power 列:b_bz 是备注,不随菜单变化,不参与 diff */ +const MENU_POWER_FIELDS = [ + 'b_id', + 'b_name', + 'b_i18n', + 'b_type', + 'b_object_type', + 'b_object_id', + 'b_action', + 'b_canuse', + 'b_xh', +] as const + +/** + * 归一化 s_power 行为入口权限点的可比字段(剔除 b_bz 等不参与同步的列, + * 数字列统一为 number,避免 '10' 与 10 产生假差异)。 + */ +export function normalizeMenuPowerRow( + row: Record | null | undefined, +): Record | null { + if (!row) return null + const picked: Record = {} + for (const field of MENU_POWER_FIELDS) picked[field] = row[field] ?? null + picked.b_canuse = Number(picked.b_canuse ?? 0) + picked.b_xh = Number(picked.b_xh ?? 0) + return picked +} + +/** + * 由菜单行派生入口权限点行:名称、多语言键、启用状态、顺序全部跟随菜单。 + * + * 保存菜单时按它与加载基线 diff,权限点因此不需要单独的编辑界面 —— + * 改菜单名 / 停用菜单,入口权限点自动跟着变。 + * + * @returns s_power 行;目录等不需要入口权限的菜单返回 null + */ +export function buildMenuPowerRow( + menuRow: Record | null | undefined, +): Record | null { + const menuId = String(menuRow?.b_id || '').trim() + if (!menuId) return null + if (!MENU_POWER_MENU_TYPES.includes(String(menuRow?.b_menu_type || ''))) return null + return { + b_id: buildMenuPowerCode(menuId), + b_name: String(menuRow?.b_name ?? ''), + b_i18n: buildMenuI18nKey(menuId), + b_type: 'menu', + b_object_type: 'menu', + b_object_id: menuId, + b_action: 'access', + b_canuse: Number(menuRow?.b_canuse ?? 1) === 0 ? 0 : 1, + b_xh: Number(menuRow?.b_xh ?? 0) || 0, + } +} + +/** 删入口权限点时的依赖声明(授权行随权限点删除),与删除入口共用一份 */ +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 b785791d..c0ec58e7 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 @@ -7,15 +7,21 @@ * b_currentperiod / b_currentvalue 是运行时流水状态(服务端维护),预览读取它们但从不回写。 * * 惰性建行:模块从未配置自动编码时,草稿保持空数组——打开面板即建行会让「无变更」 - * 被 diff 成一条 insert;占位行只用于渲染与校验,用户首次操作(拨开关或改参数)才落行。 + * 被 diff 成一条 insert;占位行只用于渲染与校验,用户首次操作(改任一参数)才落行。 * - * 布局(5 列紧凑网格 + 纵排 label,横排时 88px 的标签会把控件压到几十像素): - * ┌ 第一行:启用 / 业务编号字段 / 重置周期 - * ├ 第二行:前缀 / 日期 / 分隔符 / 位数 / 起始值 + * 布局(4 列紧凑网格 + 纵排 label,横排时 88px 的标签会把控件压到几十像素): + * ┌ 第一行:业务编号字段 / 重置周期 / 前缀 / 日期 + * ├ 第二行:分隔符 / 位数 / 起始值 * └ 第三行:编号预览(跨整行) + * + * 「启用」开关不在这里:它是本面板的总开关,与它管辖的参数同级铺在网格里 + * 会被读成「其中一个字段」,且一关就整片置灰。开关上提到页面级面板头 + * (index.vue,与「同步字段」「预览查询入口」同一操作区),本面板通过 + * defineExpose 暴露 enabled / toggleEnabled 供父级驱动,并把 enabled 传回来 + * 决定参数是否可编辑。 */ -import { computed, reactive, ref } from 'vue' -import { G3Form, G3FormItem, G3Input, G3Select, G3Switch } from '@g3soft/ui' +import { computed, reactive, ref, watch } from 'vue' +import { G3Form, G3FormItem, G3Input, G3Select } from '@g3soft/ui' import type { FormRule } from '@g3soft/ui' import type { ModuleAutoCodeRow, ModuleFieldRow } from './utils' @@ -27,10 +33,20 @@ const props = withDefaults( fields?: ModuleFieldRow[] /** 父级忙碌态(加载 / 保存中):期间锁掉全部控件 */ loading?: boolean + /** + * 启用态(由父级面板头的开关驱动)。 + * 面板内不再自持开关,参数区的可编辑性读它;未传时退化为「按草稿行判断」。 + */ + enabled?: boolean }>(), - { fields: () => [], loading: false }, + { fields: () => [], loading: false, enabled: undefined }, ) +const emit = defineEmits<{ + /** 草稿行被本面板改动(含开关翻转)后回报启用态,父级据此点亮面板头开关 */ + 'update:enabled': [value: boolean] +}>() + /** 自动编码规则草稿;空数组表示本模块未配置自动编码 */ const autoCodeModel = defineModel('autocode', { default: () => [] }) @@ -97,11 +113,17 @@ const localRow = computed(() => */ const emptyRow = computed(() => createDraft(props.moduleId, props.fields, false)) -/** 表单绑定行:优先真实行,未配置时回退占位行,保证「启用」开关始终可见可拨 */ +/** 表单绑定行:优先真实行,未配置时回退占位行(表格始终有值可渲染) */ const formRow = computed(() => localRow.value ?? emptyRow.value) 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( @@ -133,7 +155,15 @@ const formRules = computed>(() => { message: '起始值必须是大于或等于 0 的整数', }, ], - b_resettype: [{ required: true, message: '请选择重置周期' }], + b_resettype: [ + { + validator: (value) => { + if (!autoCodeEnabled.value) return true + return String(value ?? '') ? true : '请选择重置周期' + }, + message: '请选择重置周期', + }, + ], b_seqwidth: [ { validator: (value) => { @@ -152,12 +182,18 @@ const formRules = computed>(() => { const formRef = ref | null>(null) -/** 校验全表:返回 [{ name, errors }],空数组即通过(供保存前调用) */ +/** + * 校验全表:返回 [{ name, errors }],空数组即通过(供保存前调用) + */ async function validate() { + // 未启用不校验:未配置(占位行)或已停用的规则都不该拦保存。 + // 用 isEnabled(父级口径)而不是本地 autoCodeEnabled:开关在面板头, + // 父级的 enabled 是权威值。 + if (!isEnabled.value) return [] return (await formRef.value?.validateAll()) ?? [] } -defineExpose({ validate }) +/* ---------- 启用态:开关在页面级面板头,这里只提供读写口 ---------- */ /** 业务编号字段下拉候选 */ const fieldOptions = computed(() => @@ -180,10 +216,64 @@ function ensureRow(): ModuleAutoCodeRow { return rows[0] } +/** + * 本次「拨开」是否新建了占位行。 + * + * 判断「关掉时该不该丢弃这行」不能回读 autoCodeModel —— defineModel 写入后 + * prop 要下一轮才回传,紧接着读仍是旧数组(空),会把「刚建的行」误判成 + * 「不存在的行」;连拨两次(开→关)时尤其明显。记一次意图即可,不依赖回读。 + */ +let rowCreatedByToggle = false + +/** + * 拨开关:置 b_canuse(首次拨会建行)。 + * 关掉时同时丢弃「刚建出来又被关掉」的占位行——模块从未配置过自动编码、 + * 用户打开又关掉,草稿里会留下一行 b_canuse = 0 的记录,保存时被 diff 成 insert, + * 「打开过就算配置过」会凭空多出一条规则行。 + * 关掉既有规则(配过又停用)则保留:那是既有规则的停用,要落库。 + */ function toggleEnabled(checked: boolean) { + if (!checked && rowCreatedByToggle) { + autoCodeModel.value = [] + rowCreatedByToggle = false + return + } + const existed = autoCodeModel.value.length > 0 ensureRow().b_canuse = checked ? 1 : 0 + if (!existed) rowCreatedByToggle = true } +// 启用态回报给父级(驱动面板头开关)。 +// immediate:草稿在挂载时就带行(打开已配置的模块)时也要报一次, +// 否则父级开关会停在默认的 false,与面板内容不一致。 +// 这里回报的是「行内的真实 b_canuse」,拨开关导致的行变化随后会纠偏一次—— +// 两条路径收敛到同一真相,父级无需自己推算。 +watch( + localRow, + (row) => { + emit('update:enabled', Number(row?.b_canuse) === 1) + }, + { immediate: true }, +) + +// 草稿被整体换掉(切模块 / 重载)时清掉建行意图: +// 上一模块留下的 true 会让新模块的第一次「关」误丢行 +watch( + () => props.moduleId, + () => { + rowCreatedByToggle = false + }, +) + +defineExpose({ + /** 保存前校验(未启用直接通过) */ + validate, + /** 当前启用态(面板头开关读它) */ + enabled: isEnabled, + /** 拨开关:置 b_canuse,首次拨会惰性建行 */ + toggleEnabled, +}) + /** 就地写草稿行字段:ensureRow 保证行已存在(用户首次操作时才落行) */ function setRowValue(field: string, value: unknown) { ensureRow()[field] = value @@ -244,39 +334,30 @@ const preview = computed(() => { :model="formRow" :rules="formRules" layout="vertical" - :columns="5" + :columns="4" max-width="720px" > - - - - - + + setRowValue('b_field', value)" /> - + setRowValue('b_resettype', value)" /> - - setRowValue('b_prefix', value)" /> @@ -284,15 +365,17 @@ const preview = computed(() => { setRowValue('b_dateformat', value)" /> + + setRowValue('b_separator', value)" /> @@ -300,7 +383,7 @@ const preview = computed(() => { setRowValue('b_seqwidth', value)" /> @@ -308,7 +391,7 @@ const preview = computed(() => { setRowValue('b_startvalue', value)" /> @@ -317,9 +400,9 @@ 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 af98b404..5ccf3cb3 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,12 +7,21 @@ * 编码(b_id 即主键):仅未落库草稿可改,改动时同步派生多语言键 module.xxx; * 类型切换会清掉不适用字段(module 无数据来源;virtual 无保存表)。 * + * 布局:三列栅格,一行三个字段;不分组(与菜单基础信息同款理由)—— + * 分组标题只是把十来个字段切成几段,字段本身并没有多到需要分段,去掉反而更紧凑。 + * 多语言键(b_i18n)不在界面上显示 —— 它由编码派生、用户改不了, + * 占一个字段位只是噪音(派生逻辑照旧,见 onCodeInput)。 + * + * 启用态(b_canuse)不在本表单里:它在左树行尾勾选(点一下即时写库)与卡头徽标展示。 + * 表单里再放一个开关会与之语义冲突 —— 树里那个立即生效,表单这个要等「保存」, + * 同一字段两种口径用户没法预期;且草稿态启停本身是伪需求(启停是运维动作,不是配置内容)。 + * * 校验规则(随输入实时提示;保存前由父级调 validate() 统一跑一遍): * - 编码:必填、2-50 位、小写字母开头、只含小写字母/数字/下划线、禁止负数临时编码; * - 名称 / 类型必填;data 的查询来源(表或 SQL)、主键字段、保存表必填。 */ import { computed, ref } from 'vue' -import { G3Form, G3FormGroup, G3FormItem, G3Input, G3Select, G3Switch } from '@g3soft/ui' +import { G3Form, G3FormItem, G3Input, G3Select } from '@g3soft/ui' import type { FormRule } from '@g3soft/ui' import { buildModuleI18nKey, type ModuleFieldRow, type ModuleNode } from './utils' @@ -131,116 +140,99 @@ function onModuleTypeChange(value: unknown) { max-width="720px" label-width="96px" > - - - - - - setField('b_name', value)" - /> - - - - - - - - + + + + + setField('b_name', value)" + /> + + + + - - - setField('b_view_table', value)" - /> - - - setField('b_key_field', value)" - /> - - - - setField('b_scope_field', value)" - /> - - - setField('b_save_table', value)" - /> - - - setField('b_order_sql', value)" - /> - - + + + setField('b_view_table', value)" + /> + + + setField('b_key_field', value)" + /> + + + + setField('b_scope_field', value)" + /> + + + setField('b_save_table', value)" + /> + + + setField('b_order_sql', value)" + /> + - - - setField('b_query_sql', value)" - /> - - - setField('b_config_json', value)" - /> - - + + + setField('b_query_sql', value)" + /> + + + setField('b_config_json', value)" + /> + - - - setField('b_xh', value)" - /> - - - setField('b_canuse', value ? 1 : 0)" - /> - - + + setField('b_xh', value)" + /> + - - - setField('b_bz', value)" - /> - - + + setField('b_bz', value)" + /> + 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 new file mode 100644 index 00000000..8e6a15a3 --- /dev/null +++ b/code/g3soft-erp/web/src/views/module/module-management/ModuleQueryPanel.vue @@ -0,0 +1,2324 @@ + + + + + + + + + (value ? openGroupCreate() : closeGroupForm())" + > + + + + 新增页签组 + + + + + + 确定 + + + + + + + + + + + + + + + {{ fieldLabelOf(row) }} + 已删除 + 停用 + + {{ tag }} + + + + + 没有匹配的字段 + + + + + + {{ zone.label }} + + + + + onFieldPopover(row, value)" + > + + + {{ fieldLabelOf(row) }} + + {{ presetLabelOf(row.defaultPreset) }} + + + = {{ row.b_default_value }} + + 已删除 + 停用 + + + + + + + 呈现 + patchFieldMode(row, value)" + /> + + + + 默认值 + patchFieldDefault(row, value)" + /> + + + + + 给哪些预设 + + togglePreset(row, preset.value, Boolean(checked)) + " + > + {{ preset.label }} + + + + + 默认选中 + + + {{ preset.label }} + + + + + + + + + + + + + + + + + + + 还没有页签组 + + + + + + + (value ? openGroupRename(group) : closeGroupForm())" + > + + {{ group.name }} + + + + + 确定 + + + + + + + onViewPopover(item, value)" + > + + + + {{ viewLabelOf(item) }} + + (0) + + + + + + + + + 名称 + patchView(item, 'label', value)" + /> + + + 编码 + patchView(item, 'code', value)" + /> + + + toggleViewFlag(item, 'showCount', value)" + > + 显示数量 + + toggleViewFlag(item, 'isDefault', value)" + > + 组内默认 + + + + 条件 SQL + + patchView(item, 'conditionSql', (event.target as HTMLTextAreaElement).value) + " + /> + + {{ conditionErrorOf(item) }} + + + + + + 删除 + + + + + + + + + + + + + + + + + + + + + + + + + {{ viewLabelOf(item) }} + (0) + + + + + + + + {{ fieldLabelOf(row) }} + + + + {{ preset.label }} + + + onPreviewQuickInput(row.b_field, value)" + /> + onPreviewQuickInput(row.b_field, value)" + /> + onPreviewQuickInput(row.b_field, value)" + /> + + + 更多筛选 + + + 当前配置没有产生任何查询入口 + + + + + diff --git a/code/g3soft-erp/web/src/views/module/module-management/ModuleTreePanel.vue b/code/g3soft-erp/web/src/views/module/module-management/ModuleTreePanel.vue index b521bc38..f4807949 100644 --- a/code/g3soft-erp/web/src/views/module/module-management/ModuleTreePanel.vue +++ b/code/g3soft-erp/web/src/views/module/module-management/ModuleTreePanel.vue @@ -6,14 +6,26 @@ * 职责:树的展示与展开 / 选中 / 拖拽 / 右键菜单的 UI 交互;搜索(关键字、过滤、 * 搜索时展开)由 BizTreePanel 内部完成,本组件不再持有搜索状态。 * 数据契约:树数据、选中项、未保存标记均由父级唯一持有,本组件只读并通过事件上报意图: - * select / add / delete / drop。 + * select / add / delete / drop / toggle-enabled。 * 树数据加载、新增/删除落库、拖拽排序持久化及跨面板联动仍由父级(index.vue)决定。 * 展开键需由父级在新建模块后追加祖先路径,故以 v-model:expanded-keys 暴露。 * 折叠态以 v-model:collapsed 透传给 BizTreePanel(宽度动画由页面负责)。 * 「新增」是多类型下拉,经 #add 插槽替换 BizTreePanel 的内置按钮。 + * + * 启用勾选(行尾): + * 不用 G3Tree 内置的 `checkable` —— 那个勾选框落在箭头与名称之间(行首), + * 视觉上是「多选这一批节点」的语义;本处的勾选是逐行切换该模块的启用态, + * 属于行尾的状态开关,所以自己在 #title 插槽里画,位置也更贴近它作用的这一行。 + * 代价是要自己处理三件事:① 点击不能冒泡(否则会连带选中行 / 触发拖拽); + * ② 草稿节点不可勾(库里还没有这行);③ 与「未保存」圆点共处一行时的排布。 + * + * 注意面板上的 `block-node` 是勾选能贴到最右边的前提,不能去掉: + * G3Tree 的节点内容层默认是 `inline-flex` 且不带 flex:1(宽度=文字宽度), + * 只有 blockNode 才给它 `flex: 1`;少了它,下面 .mm-tree-node 的 flex:1 + * 撑的是「贴着文字的那一小段」,勾选框会紧跟名称、而不是贴到行尾。 */ import { Plus, Trash2 } from '@lucide/vue' -import { G3Button, G3Dropdown } from '@g3soft/ui' +import { G3Button, G3Checkbox, G3Dropdown } from '@g3soft/ui' import type { DropdownClickPayload, TreeContextMenuItem, @@ -54,8 +66,24 @@ const emit = defineEmits<{ add: [payload: { parentId: string | null; moduleType: string }] delete: [id: string | null] drop: [payload: TreeDropInfo] + /** 行尾勾选:切换某模块的启用态(父级负责落库并回写 b_canuse) */ + toggleEnabled: [payload: { id: string; enabled: boolean }] }>() +/** 节点是否可勾选:未落库草稿在库里没有行,改不了它的 b_canuse */ +function isNodeCheckable(node: TreeNodeData): boolean { + return !props.unsavedIds.has(String((node as ModuleNode).b_id)) +} + +/** + * 行尾勾选。 + * 勾不起草稿节点(父级据此提示),其余照常上报,由父级决定落库。 + */ +function onToggleEnabled(node: TreeNodeData, enabled: boolean) { + if (!isNodeCheckable(node)) return + emit('toggleEnabled', { id: String((node as ModuleNode).b_id), enabled }) +} + function onExpandedKeysChange(keys: TreeKey[]) { emit('update:expandedKeys', keys) } @@ -119,6 +147,7 @@ function onDrop(info: TreeDropInfo) { title="模块" placeholder="搜索模块" draggable + block-node :tree-data="treeData" :loading="loading" :expanded-keys="expandedKeys" @@ -156,6 +185,24 @@ function onDrop(info: TreeDropInfo) { title="未保存" aria-label="未保存" /> + + + onToggleEnabled(node, value)" + /> + @@ -198,4 +245,22 @@ function onDrop(info: TreeDropInfo) { border-radius: 50%; background: var(--g3-color-warning, #faad14); } + +/* 行尾启用勾选:不参与压缩,始终贴在行尾。 + * 不做「hover 才显形」——树里散布着若干勾选框时,静态可见才能一眼扫出 + * 哪些模块是停用的;隐藏到 hover 反而要逐行划过才知道。 */ +.mm-tree-node__enable { + display: inline-flex; + align-items: center; + flex: none; + /* 负外边距抵消 G3Checkbox 的 8px gap/内边距,避免行尾多出一段空白 */ + margin-left: 2px; +} + +/* 勾选框比正文略小,避免在 28px 行高里显突兀; + * 无文案(未传默认插槽)时 G3Checkbox 是 inline-flex + gap, + * 这里只留勾选框本体,把多余间距压掉。 */ +.mm-tree-node__enable :deep(.g3-checkbox) { + gap: 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 6d8622ea..d75af1b5 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 @@ -5,13 +5,15 @@ * 当前已落地左侧模块树:加载(s_module)/ 搜索 / 新增草稿 / 删除 / 拖拽排序 / 未保存标记; * 右侧已搭好配置骨架:横向标签导航(nav-only,标签集随模块类型增减)+ 常驻 pane 堆叠区, * 各配置面板(基础 / 字段 / 列表 / 表单 / 查询 / 自动编码 / 权限 / 关联 / 规则 / 多语言) - * 与保存编排将按 fms-vue 的 module-management 逐节填进对应 pane;届时本文件按 fms 的分层 + * 将按 fms-vue 的 module-management 逐节填进对应 pane,当前已落地基础 / 字段 / 查询 / + * 自动编码(各面板只做编辑,改动落在父级持有的草稿上);保存编排落地时本文件按 fms 的分层 * 补齐「当前编辑值 / _org 基线成对持有 + 保存时逐表 diff」的编排职责。 * * 模块类型(s_module.b_module_type):module 模块 / data 数据模块 / virtual 虚拟模块。 */ -import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' -import { Ellipsis, FolderTree, RefreshCw, Save, Trash2 } from '@lucide/vue' +import { computed, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from 'vue' +import { Ellipsis, Eye, FolderTree, RefreshCw, Save, Trash2 } from '@lucide/vue' +import { useHotkeys } from '@/composables/useHotkeys' import { G3Button, G3Dropdown, @@ -19,6 +21,7 @@ import { G3Modal, G3Splitter, G3SplitterPanel, + G3Switch, G3Tabs, G3Tag, } from '@g3soft/ui' @@ -26,7 +29,12 @@ import type { DropdownClickPayload, TreeDropInfo, TreeKey } from '@g3soft/ui' import { loadDataApi, saveObjectApi } from '@/services/api' import { createTempId } from '@/utils/common' import { buildPowerDeleteDependents } from '@/utils/powerCodes' -import { normalizeGroupIds, parseSchema } from '@/utils/schemaJson' +import { + createDefaultSchema, + normalizeGroupIds, + parseSchema, + type SchemaJson, +} from '@/utils/schemaJson' import { applyTreePosition, buildSortedTree, @@ -42,6 +50,7 @@ import { import ModuleAutoCodePanel from './ModuleAutoCodePanel.vue' import ModuleBasicPanel from './ModuleBasicPanel.vue' import ModuleFieldsPanel from './ModuleFieldsPanel.vue' +import ModuleQueryPanel from './ModuleQueryPanel.vue' import ModuleTreePanel from './ModuleTreePanel.vue' import { MODULE_NON_LEAF_TYPES, @@ -271,7 +280,7 @@ const CONFIG_SECTIONS: ConfigSection[] = [ { key: 'query', label: '查询配置', - desc: '配置查询条件与默认查询参数。', + desc: '配置列表页查询入口:条件字段、工作队列页签、关键词与快捷筛选。', scope: 'config', }, { @@ -347,14 +356,49 @@ const moduleFields = ref([]) /** 选中模块的自动编码规则草稿(s_autocode,一模块至多一条;空数组即未配置) */ const moduleAutoCode = ref([]) -/** 自动编码面板实例:保存前调它的 validate() 拿表单错误 */ -const autoCodePanelRef = ref | null>(null) +/** 选中模块的查询设置草稿(s_module_schema.b_schema_type = 'query' 的配置对象) */ +const moduleQuery = ref(createDefaultSchema('query')) + +/** + * 自动编码的启用态:开关挂在面板头(与「同步字段」「预览查询入口」同一操作区), + * 状态由面板回报(@update:enabled),拨动时调面板暴露的 toggleEnabled, + * 面板负责惰性建行与写 b_canuse——父级不碰 s_autocode 行的构造规则。 + */ +const autoCodeEnabled = ref(false) + +function onToggleAutoCode(value: boolean) { + autoCodePanel.value?.toggleEnabled(value) +} + +/** + * 面板实例引用:自动编码(保存前 validate / 面板头 toggleEnabled)、 + * 查询设置(面板头预览弹窗)。 + * + * 必须用「函数 ref」而不是 `ref="name"`:两个面板都写在 + * `v-for="section in configSections"` 里面,模板编译器会给标签加上 + * `ref_for: true`,此时 Vue 赋给 ref 的是**实例数组**而不是单个实例—— + * 于是 `autoCodePanelRef.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) }) function toModuleDraft(id: string): ModuleNode | null { @@ -386,6 +430,7 @@ async function loadModuleFields(id: string | null) { */ async function loadModuleAutoCode(id: string | null) { moduleAutoCode.value = [] + autoCodeEnabled.value = false if (id == null) return if (findModuleNode(id)?.b_module_type !== 'data') return const requested = String(id) @@ -398,11 +443,34 @@ async function loadModuleAutoCode(id: string | null) { // 选择已切走时丢弃过期响应 if (String(moduleSelectedId.value ?? '') !== requested) 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 { + // 查询设置加载失败不打断页面:面板按「未配置」展示 + } +} + /* ================= 卡头动作 ================= */ /** 卡头「更多」菜单:模块级动作(删除与左侧树右键菜单是同一入口) */ @@ -432,7 +500,7 @@ function validateAutoCode(): string { * 目前先把自动编码的校验串上:有错就跳到对应标签并高亮。 */ async function onSave() { - const autoCodeErrors = await autoCodePanelRef.value?.validate() + const autoCodeErrors = await autoCodePanel.value?.validate() if (autoCodeErrors?.length) { activeSection.value = 'auto-code' G3Message.warning('请修正表单中标红的错误项') @@ -452,6 +520,17 @@ function onRefresh() { G3Message.info('配置重新加载随配置面板逐步移植') } +/* ================= 快捷键 ================= */ +// ignoreWhenTyping 关掉:输入框里改完配置直接 Ctrl+S 保存,不用先点空白处 +useHotkeys([ + { + key: 's', + mod: true, + ignoreWhenTyping: false, + handler: () => void onSave(), + }, +]) + /** * 字段定义面板的「同步字段」:按数据库查询表 / 视图结构对齐字段定义。 * @@ -580,6 +659,44 @@ async function deleteModule(id: string | null) { }) } +/** + * 树节点行尾勾选:即时切换模块启用态。 + * + * 「快捷」的意义就在于点一下立刻生效,所以这里直接写库,不走页面的草稿保存编排。 + * 三处状态必须一起改,否则会出现「树里关了、卡头还显示启用」: + * ① 树节点(moduleTreeData 里的 ModuleNode) + * ② 右侧卡片的草稿副本(moduleDraft 是「当前编辑值」,headModule 读的就是它) + * ③ 服务端(s_module.b_canuse) + * 先改本地再写库:勾选是即时反馈的交互,等一个来回会有明显的迟滞感。 + * 写库失败则把两者都回滚成原值,并把错误抛给用户——不能留下「界面关了、库里还开着」。 + */ +async function onToggleModuleEnabled({ id, enabled }: { id: string; enabled: boolean }) { + const node = findModuleNode(id) + if (!node) return + const previous = Number(node.b_canuse) === 1 + if (previous === enabled) return + + const nextValue = enabled ? 1 : 0 + node.b_canuse = nextValue + // 草稿是浅拷贝,改树节点不会传到它;只同步当前选中的那个模块 + if (String(moduleDraft.value?.b_id ?? '') === id) { + moduleDraft.value = { ...moduleDraft.value, b_canuse: nextValue } as ModuleNode + } + + try { + await saveObjectApi([ + { table: 's_module', key_field: 'b_id', updates: [{ b_id: id, b_canuse: nextValue }] }, + ]) + G3Message.success(enabled ? '模块已启用' : '模块已停用') + } catch (e) { + node.b_canuse = previous ? 1 : 0 + if (String(moduleDraft.value?.b_id ?? '') === id) { + moduleDraft.value = { ...moduleDraft.value, b_canuse: previous ? 1 : 0 } as ModuleNode + } + G3Message.error('切换启用状态失败:' + errorText(e)) + } +} + async function onModuleDrop({ dragKey, dropKey, dropPosition }: TreeDropInfo) { // 成环 / 类型 / 未保存守卫与 b_xh、b_depth、b_path、b_parent_id 的重算都在 computeTreeMove 内 const result = computeTreeMove(moduleTreeData.value, { @@ -642,6 +759,7 @@ onMounted(() => { @add="({ parentId, moduleType }) => addModule(parentId, moduleType)" @delete="(id) => void deleteModule(id)" @drop="(payload) => void onModuleDrop(payload)" + @toggle-enabled="(payload) => void onToggleModuleEnabled(payload)" /> @@ -722,6 +840,27 @@ onMounted(() => { 同步字段 + + + + + 预览查询入口 + + + + + 启用 + + @@ -736,13 +875,23 @@ onMounted(() => { v-else-if="section.key === 'fields'" v-model:fields="moduleFields" /> + + (autoCodeEnabled = value)" /> {{ section.label }}面板将按 fms-vue 逐步移植 @@ -915,6 +1064,12 @@ onMounted(() => { margin-left: auto; } +/* 自动编码开关的标签:与 G3Switch 同排,字号对齐面板头的说明文案 */ +.mm-panel-head__switch-label { + color: var(--g3-color-text, #1f2329); + font-size: 13px; +} + .mm-config-pane { display: flex; flex: 1; 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 6ea2744a..81002af2 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 @@ -1,13 +1,19 @@ /** * 模块管理公共工具(移植自 fms-vue 的 module-management/utils.js,按需逐步补齐)。 * - * 当前提供:模块树所需的类型 / 图标 / 新增菜单项,以及删除模块时的多语言键收集 - * (模块名键、字段键、界面配置分组键)——后续面板(字段 / 基础 / 多语言等)用到 - * 的其它工具随对应步骤补充。 + * 当前提供:模块树所需的类型 / 图标 / 新增菜单项,删除模块时的多语言键收集 + * (模块名键、字段键、界面配置分组键),以及查询设置(query schema)的草稿模型 + * ——后续面板(列表 / 表单 / 权限等)用到的其它工具随对应步骤补充。 */ import { Database, Folder, FolderPlus, Search } from '@lucide/vue' import type { Component } from 'vue' -import { buildGroupI18nKey, type SchemaJson, type SchemaNode } from '@/utils/schemaJson' +import { + buildGroupI18nKey, + createDefaultSchema, + type SchemaJson, + type SchemaNode, +} from '@/utils/schemaJson' +import { createTempId } from '@/utils/common' import type { TreeRow } from '@/utils/tree' /** 模块类型(s_module.b_module_type):module 模块 / data 数据模块 / virtual 虚拟模块 */ @@ -94,6 +100,295 @@ export interface ModuleAutoCodeRow { [key: string]: unknown } +/* ================= 查询设置(s_module_schema.b_schema_type = 'query') ================= + * 查询配置在编辑器里是一份「扁平草稿」(ModuleQueryDraft,四块内容各一个数组/对象), + * 落库形态是 query JSON(schemaVersion 3);两个方向的转换集中在这里, + * 口径与 fms-vue 的 schemaModel.js 一致。 + * + * 四块内容: + * - fields(查询字段):参与条件查询的字段;未进快捷筛选的,运行时按 b_xh 铺进「更多筛选」抽屉; + * - views(工作队列页签):组内单选、组间叠加,条件直接写 SQL; + * - keyword(关键词字段):只配可搜索字段,占位文案运行时按字段名生成; + * - quickFilters(快捷筛选字段):铺在列表页查询卡上,按 order 排列。 + * + * 未建模属性经行上的 _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 } +} + +/** + * 页签所属分组:模型上页签「一定有组」——组既是列表页上的一行页签, + * 也是「组内单选、组间叠加」的作用域,不存在不属于任何组的页签。 + * 历史配置里没写 group 的,落到这个默认组,落库时补上。 + */ +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 + _node: Record + code: string + label: string + group: string + order: number + showCount: boolean + isDefault: boolean + conditionSql: string + [key: string]: unknown +} + +/** 快捷筛选行(草稿) */ +export interface ModuleQueryQuickRow { + _rowKey: string + field: string + order: number + [key: string]: unknown +} + +/** 查询设置草稿:四个分节的编辑态,与落库 JSON 经下面两个函数双向适配 */ +export interface ModuleQueryDraft { + fields: ModuleQueryFieldRow[] + views: ModuleQueryViewRow[] + keyword: { fields: string[] } + quickFilters: ModuleQueryQuickRow[] +} + +/** query JSON → 编辑器草稿 */ +export function querySchemaToDraft(schema: SchemaJson | null, moduleId: string): 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) => { + 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 { + 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, + })), + } +} + +/** 编辑器草稿 → query JSON(未建模属性经 _node / base 保留,建模属性以草稿为准) */ +export function queryDraftToSchema( + draft: ModuleQueryDraft | null, + baseSchema?: SchemaJson | null, +): SchemaJson { + const base = { ...(baseSchema || createDefaultSchema('query')) } + // 旧结构的这几个键一律不再使用,改写时一并清掉 + 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 } + 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) + + const keywordFields = [ + ...new Set((draft?.keyword?.fields || []).map((field) => String(field)).filter(Boolean)), + ] + + return { + ...base, + schemaVersion: 3, + 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, + } +} + +/** + * 校验页签条件 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/tests/composables/useHotkeys.spec.ts b/code/g3soft-erp/web/tests/composables/useHotkeys.spec.ts new file mode 100644 index 00000000..2a7f171b --- /dev/null +++ b/code/g3soft-erp/web/tests/composables/useHotkeys.spec.ts @@ -0,0 +1,333 @@ +import { describe, expect, it, vi, afterEach } from 'vitest' +import { defineComponent, h, KeepAlive, nextTick } from 'vue' +import { mount } from '@vue/test-utils' +import { useHotkeys, type Hotkey } from '@/composables/useHotkeys' + +/** + * useHotkeys 回归用例。 + * + * 监听挂在 window 的捕获阶段,所以这里一律往 window 上派发 KeyboardEvent + * (而非某个元素),才能覆盖真实路径;输入态用例则把事件派发到输入框上, + * 靠 target 判断触发。 + */ + +/** 往 window 派发一次 keydown(bubbles 让捕获阶段能从 target 一路收到) */ +function press(init: KeyboardEventInit, target?: HTMLElement): void { + const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }) + ;(target ?? window).dispatchEvent(event) +} + +/** + * 挂一个「最小宿主组件」调用 useHotkeys —— 组合式函数依赖 onMounted 等生命周期, + * 必须在组件上下文里跑。用渲染函数直接写,省去为测试造 .vue 文件。 + */ +function mountWithHotkeys(shortcuts: Hotkey[]) { + const Comp = defineComponent({ + setup() { + useHotkeys(shortcuts) + return () => h('div') + }, + }) + return mount(Comp) +} + +describe('useHotkeys', () => { + afterEach(() => { + document.body.innerHTML = '' + }) + + describe('修饰符精确匹配', () => { + it('mod 匹配 Ctrl 与 Meta(跨平台主修饰键)', async () => { + const handler = vi.fn() + mountWithHotkeys([{ key: 's', mod: true, handler }]) + await nextTick() + + press({ key: 's', ctrlKey: true }) + press({ key: 's', metaKey: true }) + + expect(handler).toHaveBeenCalledTimes(2) + }) + + it('未声明的修饰符按下则不匹配', async () => { + const handler = vi.fn() + mountWithHotkeys([{ key: 's', mod: true, handler }]) + await nextTick() + + press({ key: 's' }) // 无 mod + press({ key: 's', ctrlKey: true, shiftKey: true }) // 多按了 shift + + expect(handler).not.toHaveBeenCalled() + }) + + it('无修饰符的单独键要求修饰键未按下', async () => { + const handler = vi.fn() + mountWithHotkeys([{ key: '/', handler }]) + await nextTick() + + press({ key: '/' }) + expect(handler).toHaveBeenCalledTimes(1) + + press({ key: '/', ctrlKey: true }) + expect(handler).toHaveBeenCalledTimes(1) + }) + + it('alt / shift 精确匹配', async () => { + const handler = vi.fn() + mountWithHotkeys([{ key: 'ArrowRight', shift: true, handler }]) + await nextTick() + + press({ key: 'ArrowRight', shiftKey: true }) + expect(handler).toHaveBeenCalledTimes(1) + + press({ key: 'ArrowRight' }) + press({ key: 'ArrowRight', shiftKey: true, altKey: true }) + expect(handler).toHaveBeenCalledTimes(1) + }) + }) + + describe('中文输入法兜底(e.key 不可用时的 e.code 回退)', () => { + it('e.key 为 Process / Unidentified 时按物理键位匹配', async () => { + const handler = vi.fn() + mountWithHotkeys([{ key: 's', mod: true, handler }]) + await nextTick() + + press({ key: 'Process', code: 'KeyS', ctrlKey: true }) + press({ key: 'Unidentified', code: 'KeyS', ctrlKey: true }) + + expect(handler).toHaveBeenCalledTimes(2) + }) + + it('e.code 不是 Key* 时不误匹配', async () => { + const handler = vi.fn() + mountWithHotkeys([{ key: 's', mod: true, handler }]) + await nextTick() + + press({ key: 'Process', code: 'Digit1', ctrlKey: true }) + + expect(handler).not.toHaveBeenCalled() + }) + }) + + describe('输入态判断', () => { + it('默认在输入框内忽略', async () => { + const handler = vi.fn() + mountWithHotkeys([{ key: '/', handler }]) + await nextTick() + + const input = document.createElement('input') + document.body.appendChild(input) + press({ key: '/' }, input) + + expect(handler).not.toHaveBeenCalled() + }) + + it('ignoreWhenTyping: false 时输入框内也触发(保存类)', async () => { + const handler = vi.fn() + mountWithHotkeys([{ key: 's', mod: true, ignoreWhenTyping: false, handler }]) + await nextTick() + + const input = document.createElement('input') + document.body.appendChild(input) + press({ key: 's', ctrlKey: true }, input) + + expect(handler).toHaveBeenCalledTimes(1) + }) + + it('select 也算输入态(原版 fms-vue 漏了这一档)', async () => { + const handler = vi.fn() + mountWithHotkeys([{ key: '/', handler }]) + await nextTick() + + const select = document.createElement('select') + document.body.appendChild(select) + press({ key: '/' }, select) + + expect(handler).not.toHaveBeenCalled() + }) + + it('焦点在输入框内层元素上也判为输入态(closest 口径)', async () => { + const handler = vi.fn() + mountWithHotkeys([{ key: '/', handler }]) + await nextTick() + + const input = document.createElement('input') + const inner = document.createElement('span') + input.appendChild(inner) + document.body.appendChild(input) + press({ key: '/' }, inner) + + expect(handler).not.toHaveBeenCalled() + }) + + it('contenteditable 区域同样忽略', async () => { + const handler = vi.fn() + mountWithHotkeys([{ key: '/', handler }]) + await nextTick() + + const box = document.createElement('div') + box.setAttribute('contenteditable', 'true') + document.body.appendChild(box) + press({ key: '/' }, box) + + expect(handler).not.toHaveBeenCalled() + }) + }) + + describe('when 前置条件', () => { + it('条件不满足时不触发、也不 preventDefault', async () => { + const handler = vi.fn() + let enabled = false + mountWithHotkeys([{ key: 'Escape', when: () => enabled, handler }]) + await nextTick() + + const blocked = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }) + window.dispatchEvent(blocked) + expect(handler).not.toHaveBeenCalled() + // 关键:未接管就必须让行,否则弹窗的 Esc 关闭会失效 + expect(blocked.defaultPrevented).toBe(false) + + enabled = true + const allowed = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }) + window.dispatchEvent(allowed) + expect(handler).toHaveBeenCalledTimes(1) + expect(allowed.defaultPrevented).toBe(true) + }) + + it('前一条条件不满足时,继续尝试后面的条目', async () => { + const blocked = vi.fn() + const fallback = vi.fn() + mountWithHotkeys([ + { key: 'Escape', when: () => false, handler: blocked }, + { key: 'Escape', handler: fallback }, + ]) + await nextTick() + + press({ key: 'Escape' }) + + expect(blocked).not.toHaveBeenCalled() + expect(fallback).toHaveBeenCalledTimes(1) + }) + }) + + it('命中即 preventDefault,未命中不拦截', async () => { + const handler = vi.fn() + mountWithHotkeys([{ key: 's', mod: true, handler }]) + await nextTick() + + const hit = new KeyboardEvent('keydown', { key: 's', ctrlKey: true, cancelable: true }) + window.dispatchEvent(hit) + expect(hit.defaultPrevented).toBe(true) + + const miss = new KeyboardEvent('keydown', { key: 'q', cancelable: true }) + window.dispatchEvent(miss) + expect(miss.defaultPrevented).toBe(false) + }) + + it('一次按键只触发命中的第一条(break 语义)', async () => { + const first = vi.fn() + const second = vi.fn() + mountWithHotkeys([ + { key: 's', mod: true, handler: first }, + { key: 's', mod: true, handler: second }, + ]) + await nextTick() + + press({ key: 's', ctrlKey: true }) + + expect(first).toHaveBeenCalledTimes(1) + expect(second).not.toHaveBeenCalled() + }) + + it('捕获阶段仍能收到:后代在冒泡阶段 stopPropagation 拦不住', async () => { + const handler = vi.fn() + // ignoreWhenTyping 关掉,专测「捕获阶段能收到」这一点(否则输入框会把事件滤掉) + mountWithHotkeys([{ key: 's', mod: true, ignoreWhenTyping: false, handler }]) + await nextTick() + + // 模拟「结构树重命名输入框」那种在冒泡阶段 stop 掉的控件 + const box = document.createElement('div') + const inner = document.createElement('input') + box.appendChild(inner) + document.body.appendChild(box) + inner.addEventListener('keydown', (e) => e.stopPropagation()) + + inner.dispatchEvent( + new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true, cancelable: true }), + ) + + expect(handler).toHaveBeenCalledTimes(1) + }) + + describe('生命周期', () => { + it('卸载后解绑,不再响应', async () => { + const handler = vi.fn() + const wrapper = mountWithHotkeys([{ key: 's', mod: true, handler }]) + await nextTick() + + press({ key: 's', ctrlKey: true }) + expect(handler).toHaveBeenCalledTimes(1) + + wrapper.unmount() + press({ key: 's', ctrlKey: true }) + expect(handler).toHaveBeenCalledTimes(1) + }) + + /** + * keep-alive 页签互抢的回归:切走的页签必须解绑。 + * 这里用 KeepAlive + 动态组件还原真实形态(DefaultLayout 的 include 白名单同源问题)。 + */ + it('keep-alive 切走的页签解绑,切回后重新绑定', async () => { + const handlerA = vi.fn() + const handlerB = vi.fn() + + const PageA = defineComponent({ + name: 'PageA', + setup() { + useHotkeys([{ key: 's', mod: true, handler: handlerA }]) + return () => h('div', 'A') + }, + }) + const PageB = defineComponent({ + name: 'PageB', + setup() { + useHotkeys([{ key: 's', mod: true, handler: handlerB }]) + return () => h('div', 'B') + }, + }) + + // 用 KeepAlive 包动态组件:切走触发 onDeactivated、切回触发 onActivated + const Host = defineComponent({ + props: { current: { type: String, default: 'A' } }, + setup(props) { + return () => + h(KeepAlive, null, { + default: () => h(props.current === 'A' ? PageA : PageB), + }) + }, + }) + + const wrapper = mount(Host, { props: { current: 'A' } }) + await nextTick() + + press({ key: 's', ctrlKey: true }) + expect(handlerA).toHaveBeenCalledTimes(1) + expect(handlerB).not.toHaveBeenCalled() + + // 切到 B:A 应已解绑,只有 B 响应 + await wrapper.setProps({ current: 'B' }) + await nextTick() + + press({ key: 's', ctrlKey: true }) + expect(handlerA).toHaveBeenCalledTimes(1) + expect(handlerB).toHaveBeenCalledTimes(1) + + // 切回 A:A 重新绑定 + await wrapper.setProps({ current: 'A' }) + await nextTick() + + press({ key: 's', ctrlKey: true }) + expect(handlerA).toHaveBeenCalledTimes(2) + expect(handlerB).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/code/g3soft-erp/web/tests/utils/iconResolver.spec.ts b/code/g3soft-erp/web/tests/utils/iconResolver.spec.ts new file mode 100644 index 00000000..8a711fb3 --- /dev/null +++ b/code/g3soft-erp/web/tests/utils/iconResolver.spec.ts @@ -0,0 +1,164 @@ +/** + * 图标解析(@/utils/iconResolver):字符串 → 组件。 + * + * 重点是三条边界,它们各自对应一类真实故障: + * 1. 名称归一化:`chevron-down` / `chevronDown` / `CHEVRON_DOWN` 都要能解析, + * 否则历史数据里换过写法的图标会**静默丢失**; + * 2. SVG 源码:lucide 覆盖不到的行业专有图标靠它兜底,但必须校验合法性, + * 不能把随便一段文本当 SVG 塞进 innerHTML(那是 XSS / 布局炸掉的口子); + * 3. 与侧栏共用同一解析器:菜单管理里能预览出来的图标,侧栏必须也渲染得出来。 + */ + +import { describe, expect, it } from 'vitest' +import { getIcon, getIconNames, isValidIconValue, looksLikeSvg, normalizeIconName } from '@/utils/iconResolver' +import { getMenuIcon } from '@/layouts/icons' + +describe('图标名归一化', () => { + it('kebab / snake / camel / 大写 都归一成 PascalCase', () => { + expect(normalizeIconName('chevron-down')).toBe('ChevronDown') + expect(normalizeIconName('chevron_down')).toBe('ChevronDown') + expect(normalizeIconName('chevronDown')).toBe('ChevronDown') + expect(normalizeIconName('CHEVRON DOWN')).toBe('ChevronDown') + }) + + it('单个词只做首字母大写', () => { + expect(normalizeIconName('ship')).toBe('Ship') + expect(normalizeIconName('Ship')).toBe('Ship') + }) + + it('空值返回空串(不抛错)', () => { + expect(normalizeIconName('')).toBe('') + expect(normalizeIconName(' ')).toBe('') + }) +}) + +describe('getIcon:解析图标名', () => { + it('kebab 写法可解析(与 s_menu 存量数据一致)', () => { + expect(getIcon('circle-check')).toBeTruthy() + expect(getIcon('file-text')).toBeTruthy() + }) + + it('PascalCase 与 camelCase 同样可解析(历史数据兼容)', () => { + expect(getIcon('CircleCheck')).toBeTruthy() + expect(getIcon('circleCheck')).toBeTruthy() + }) + + it('lucide: 前缀显式指定也能解析', () => { + expect(getIcon('lucide:chevron-down')).toBeTruthy() + expect(getIcon('lucide:ship')).toBeTruthy() + }) + + it('白名单时代的 15 个图标全部仍可解析(升级不能弄丢现有菜单的图标)', () => { + const legacy = [ + 'layout-dashboard', + 'file-text', + 'database', + 'table', + 'list-checks', + 'circle-check', + 'users', + 'building-2', + 'ship', + 'plane', + 'container', + 'package', + 'wallet', + 'blocks', + 'settings', + ] + for (const name of legacy) { + expect(getIcon(name), `${name} 应能解析`).toBeTruthy() + } + }) + + it('白名单外的合法 lucide 图标现在也能解析(本次改造的核心)', () => { + expect(getIcon('truck')).toBeTruthy() + expect(getIcon('calendar')).toBeTruthy() + expect(getIcon('receipt')).toBeTruthy() + }) + + it('空值与无效名返回 null(不抛错)', () => { + expect(getIcon('')).toBeNull() + expect(getIcon(null)).toBeNull() + expect(getIcon(undefined)).toBeNull() + expect(getIcon(' ')).toBeNull() + expect(getIcon('这根本不是图标名')).toBeNull() + }) + + it('lucide: 前缀匹配不到时返回 null,不退回当 SVG 试', () => { + expect(getIcon('lucide:not-a-real-icon-xyz')).toBeNull() + }) +}) + +describe('getIcon:SVG 源码', () => { + const SVG = '' + + it('合法 SVG 标记可渲染', () => { + expect(getIcon(SVG)).toBeTruthy() + }) + + it('同一段 SVG 返回同一组件实例(否则每次渲染都会卸载重建、图标闪烁)', () => { + expect(getIcon(SVG)).toBe(getIcon(SVG)) + }) + + it('非 SVG 的普通文本不渲染(不能把任意文本塞进 innerHTML)', () => { + expect(getIcon('hello')).toBeNull() + expect(getIcon('随便一段文字')).toBeNull() + }) + + it('格式错误的 SVG 不渲染', () => { + expect(looksLikeSvg('')).toBe(false) + }) +}) + +describe('looksLikeSvg', () => { + it('识别合法 SVG 与非法片段', () => { + expect(looksLikeSvg('')).toBe(true) + expect(looksLikeSvg('')).toBe(false) + expect(looksLikeSvg('')).toBe(false) + expect(looksLikeSvg('plain text')).toBe(false) + }) +}) + +describe('isValidIconValue(表单校验用)', () => { + it('留空视为合法(图标可选)', () => { + expect(isValidIconValue('')).toBe(true) + expect(isValidIconValue(null)).toBe(true) + expect(isValidIconValue(undefined)).toBe(true) + }) + + it('拼错的名字判为非法,让输入当场报错而不是静默不显示', () => { + expect(isValidIconValue('truck')).toBe(true) + expect(isValidIconValue('trukc')).toBe(false) + }) +}) + +describe('侧栏与菜单管理共用同一解析器(曾经的静默丢图标 bug)', () => { + it('getMenuIcon 能解析白名单外的图标', () => { + // 改造前:侧栏查一张 15 项白名单,填 truck 能存进库但侧栏没有图标 + expect(getMenuIcon('truck')).toBeTruthy() + expect(getMenuIcon('calendar')).toBeTruthy() + }) + + it('getMenuIcon 与 getIcon 对同一输入给出一致结果', () => { + const samples = ['settings', 'circle-check', 'truck', 'lucide:ship', '', 'not-a-real-icon'] + for (const name of samples) { + expect(getMenuIcon(name), name).toBe(getIcon(name) ?? undefined) + } + }) + + it('无法解析时返回 undefined 而不是抛错(菜单照常渲染,只是没图标)', () => { + expect(getMenuIcon('不存在')).toBeUndefined() + expect(getMenuIcon('')).toBeUndefined() + }) +}) + +describe('getIconNames', () => { + it('返回按字母序排列的全部 lucide 图标名', () => { + const names = getIconNames() + + expect(names.length).toBeGreaterThan(100) + expect(names).toEqual([...names].toSorted()) + expect(names).toContain('Ship') + }) +}) diff --git a/code/g3soft-erp/web/tests/views/module/menu-management/MenuTreePanel.spec.ts b/code/g3soft-erp/web/tests/views/module/menu-management/MenuTreePanel.spec.ts new file mode 100644 index 00000000..09310d19 --- /dev/null +++ b/code/g3soft-erp/web/tests/views/module/menu-management/MenuTreePanel.spec.ts @@ -0,0 +1,119 @@ +/** + * MenuTreePanel:行尾启用勾选。 + * + * 与模块树面板同构(勾选框不走 G3Tree 内置的 checkable,而是画在 #title 插槽行尾), + * 因此同样要锁住三件事: + * 1. 勾选上报 toggleEnabled,由父级落库; + * 2. 点击不冒泡(行上挂了 click 选中 / mousedown 武装拖拽); + * 3. 未保存草稿不可勾(库里没有这行),且面板必须开 block-node 才能贴到行尾。 + */ + +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import type { VueWrapper } from '@vue/test-utils' +import { G3Checkbox, G3Tree } from '@g3soft/ui' +import MenuTreePanel from '@/views/module/menu-management/MenuTreePanel.vue' +import type { MenuNode } from '@/views/module/menu-management/utils' + +const TREE = [ + { b_id: 'group_base', b_name: '基础资料', b_menu_type: 'directory', b_canuse: 1 }, + { b_id: 'base_contact', b_name: '联系人', b_menu_type: 'page', b_canuse: 0 }, + { b_id: '-1', b_name: '未保存草稿', b_menu_type: 'page', b_canuse: 1 }, +] as unknown as MenuNode[] + +function mountPanel(unsavedIds = new Set()) { + return mount(MenuTreePanel, { + props: { treeData: TREE, unsavedIds }, + }) +} + +const rowOf = (wrapper: VueWrapper, label: string) => + wrapper.findAll('[role="treeitem"]').find((row) => row.text().includes(label)) + +const checkboxOf = (wrapper: VueWrapper, label: string) => + rowOf(wrapper, label)?.findComponent(G3Checkbox) + +describe('MenuTreePanel 行尾启用勾选', () => { + it('每个节点行都带勾选框,勾选态反映 b_canuse', () => { + const wrapper = mountPanel() + + expect(checkboxOf(wrapper, '基础资料')?.props('modelValue')).toBe(true) + expect(checkboxOf(wrapper, '联系人')?.props('modelValue')).toBe(false) + }) + + it('勾选上报 toggleEnabled(id + 目标启用态)', async () => { + const wrapper = mountPanel() + + checkboxOf(wrapper, '联系人')?.vm.$emit('update:modelValue', true) + await wrapper.vm.$nextTick() + + expect(wrapper.emitted('toggleEnabled')?.[0]?.[0]).toEqual({ + id: 'base_contact', + enabled: true, + }) + }) + + it('取消勾选同样上报(enabled: false)', async () => { + const wrapper = mountPanel() + + checkboxOf(wrapper, '基础资料')?.vm.$emit('update:modelValue', false) + await wrapper.vm.$nextTick() + + expect(wrapper.emitted('toggleEnabled')?.[0]?.[0]).toEqual({ + id: 'group_base', + enabled: false, + }) + }) + + it('点击勾选框不触发行的选中(事件不冒泡)', async () => { + const wrapper = mountPanel() + + const row = rowOf(wrapper, '基础资料')! + const box = checkboxOf(wrapper, '基础资料')!.find('input') + await box.trigger('click') + + expect(wrapper.emitted('select')).toBeUndefined() + // 行自身仍可正常选中,说明拦的是勾选框那一下、不是把整行点击弄坏了 + await row.trigger('click') + expect(wrapper.emitted('select')?.[0]?.[0]).toBe('group_base') + }) + + it('未保存草稿的勾选框禁用且不上报', async () => { + const wrapper = mountPanel(new Set(['-1'])) + + const box = checkboxOf(wrapper, '未保存草稿')! + expect(box.props('disabled')).toBe(true) + + box.vm.$emit('update:modelValue', false) + await wrapper.vm.$nextTick() + expect(wrapper.emitted('toggleEnabled')).toBeUndefined() + }) + + it('勾选框渲染在名称之后的行尾', () => { + const wrapper = mountPanel() + const row = rowOf(wrapper, '基础资料')! + + const nameEl = row.find('.mm-tree-node__name') + const enableEl = row.find('.mm-tree-node__enable') + expect(nameEl.exists()).toBe(true) + expect(enableEl.exists()).toBe(true) + + const relation = nameEl.element.compareDocumentPosition(enableEl.element) + expect(relation & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + + it('面板必须开 block-node:否则内容层不撑满,勾选框贴不到行尾', () => { + const wrapper = mountPanel() + + // 断言落在 G3Tree 上:BizTreePanel / BizTree 是 inheritAttrs:false, + // kebab 的 'block-node' 会留在中间层的 $attrs 里(那些层取不到), + // 只有到 G3Tree 才被归一成 blockNode prop。 + expect(wrapper.findComponent(G3Tree).props('blockNode')).toBe(true) + }) + + it('面板不启用 G3Tree 内置 checkable(避免行首出现多选勾选框)', () => { + const wrapper = mountPanel() + + expect(wrapper.find('.g3-tree-node__checkbox').exists()).toBe(false) + }) +}) diff --git a/code/g3soft-erp/web/tests/views/module/menu-management/menuRouteOptions.spec.ts b/code/g3soft-erp/web/tests/views/module/menu-management/menuRouteOptions.spec.ts new file mode 100644 index 00000000..91e446bc --- /dev/null +++ b/code/g3soft-erp/web/tests/views/module/menu-management/menuRouteOptions.spec.ts @@ -0,0 +1,73 @@ +/** + * menuRouteOptions:菜单管理「路由地址」下拉候选。 + * + * 关键口径(对照 fms-vue 的 collectMenuRoutes): + * - meta.adminOnly 的超管专用页(模块中心 /module/*)必须整体排除 —— + * 菜单管理自己的入口若落进 s_menu,误删后就没有修复入口了; + * - 403 / 500 兜底页排除; + * - 含动态段(:id)/ 通配(*)的路由不能作为固定菜单入口。 + */ + +import { describe, expect, it } from 'vitest' +import { collectMenuRoutes, menuRouteOptions } from '@/router/index' + +describe('collectMenuRoutes', () => { + it('排除 adminOnly 的模块中心页面', () => { + const paths = menuRouteOptions.map((option) => option.value) + + expect(paths).not.toContain('/module/module-management') + expect(paths).not.toContain('/module/menu-management') + expect(paths).not.toContain('/module/i18n-type') + }) + + it('排除 403 / 500 兜底页', () => { + const paths = menuRouteOptions.map((option) => option.value) + + expect(paths).not.toContain('/403') + expect(paths).not.toContain('/500') + }) + + it('保留普通业务页面,label 同时含路径与标题', () => { + const byValue = new Map(menuRouteOptions.map((option) => [option.value, option.label])) + + expect(byValue.get('/dashboard')).toBe('/dashboard(首页)') + expect(byValue.get('/base/contact')).toBe('/base/contact(联系人)') + }) + + it('按路径排序', () => { + const paths = menuRouteOptions.map((option) => option.value) + const sorted = [...paths].toSorted((a, b) => a.localeCompare(b, 'zh-Hans-CN')) + + expect(paths).toEqual(sorted) + }) + + it('过滤掉动态段与通配路由', () => { + const list = collectMenuRoutes([ + { path: '/ok', meta: { title: '正常' } }, + { path: '/detail/:id', meta: { title: '详情' } }, + { path: '/any/*', meta: { title: '通配' } }, + { path: '/admin', meta: { title: '超管', adminOnly: true } }, + ] as never) + + expect(list.map((option) => option.value)).toEqual(['/ok']) + }) + + it('含 children 的容器节点只向下递归、自身不作为候选', () => { + const list = collectMenuRoutes([ + { + path: '/group', + meta: { title: '分组' }, + children: [{ path: 'child', meta: { title: '子页' } }], + }, + ] as never) + + expect(list).toEqual([{ value: '/group/child', label: '/group/child(子页)' }]) + }) + + it('每个候选都是完整绝对路径,且无重复', () => { + const paths = menuRouteOptions.map((option) => option.value) + + for (const path of paths) expect(path.startsWith('/')).toBe(true) + expect(new Set(paths).size).toBe(paths.length) + }) +}) diff --git a/code/g3soft-erp/web/tests/views/module/menu-management/menuSave.spec.ts b/code/g3soft-erp/web/tests/views/module/menu-management/menuSave.spec.ts new file mode 100644 index 00000000..9737ca76 --- /dev/null +++ b/code/g3soft-erp/web/tests/views/module/menu-management/menuSave.spec.ts @@ -0,0 +1,308 @@ +/** + * 菜单管理:菜单入口权限点(s_power)派生 + 新增/删除时的落库请求。 + * + * 这两块是保存编排里最容易出错、也最难靠肉眼验证的部分: + * - 权限点行完全由菜单行派生(menu.{编码}),名称 / 顺序 / 启用态跟随菜单, + * 目录不产生权限点; + * - 删除菜单要连带清掉多语言资源、模块绑定与入口权限点, + * 且权限点删除必须带 dependent_deletes(授权行随权限点删除)。 + */ + +import { describe, expect, it } from 'vitest' +import { + buildMenuI18nKey, + buildMenuPowerCode, + buildMenuPowerRow, + normalizeMenuPowerRow, + menuRowOf, + MENU_NON_LEAF_TYPES, + MENU_TYPE_ICON_MAP, + MENU_TYPE_OPTIONS, + type MenuNode, +} from '@/views/module/menu-management/utils' +import { diffRow, diffRows, stripInternalKeys, cloneData } from '@/utils/dataChanges' + +describe('菜单类型与图标', () => { + it('三种菜单类型都有图标映射,且只有目录能承载子节点', () => { + for (const option of MENU_TYPE_OPTIONS) { + expect(MENU_TYPE_ICON_MAP[option.value]).toBeTruthy() + } + expect(MENU_NON_LEAF_TYPES).toEqual(['directory']) + }) +}) + +describe('菜单入口权限点派生', () => { + it('页面菜单派生 menu.{编码} 行,字段跟随菜单', () => { + const row = buildMenuPowerRow({ + b_id: 'base_contact', + b_name: '联系人', + b_menu_type: 'page', + b_canuse: 1, + b_xh: 30, + }) + + expect(row).toEqual({ + b_id: 'menu.base_contact', + b_name: '联系人', + b_i18n: 'menu.base_contact', + b_type: 'menu', + b_object_type: 'menu', + b_object_id: 'base_contact', + b_action: 'access', + b_canuse: 1, + b_xh: 30, + }) + }) + + it('外链菜单同样派生权限点(外链也是「能否进入」的入口)', () => { + const row = buildMenuPowerRow({ + b_id: 'ext_docs', + b_name: '外部文档', + b_menu_type: 'external', + b_canuse: 1, + }) + + expect(row?.b_id).toBe('menu.ext_docs') + }) + + it('目录不派生权限点(目录只是导航分组)', () => { + const row = buildMenuPowerRow({ + b_id: 'group_base', + b_name: '基础资料', + b_menu_type: 'directory', + }) + + expect(row).toBeNull() + }) + + it('编码为空时返回 null(避免派生出 menu. 这种空编码权限点)', () => { + expect(buildMenuPowerRow({ b_id: '', b_menu_type: 'page' })).toBeNull() + expect(buildMenuPowerRow(null)).toBeNull() + }) + + it('停用菜单派生出停用的权限点(b_canuse 跟随)', () => { + const row = buildMenuPowerRow({ + b_id: 'base_contact', + b_menu_type: 'page', + b_canuse: 0, + }) + + expect(row?.b_canuse).toBe(0) + }) + + it('b_canuse 缺省时按启用处理(不把历史数据的空值当停用)', () => { + const row = buildMenuPowerRow({ b_id: 'base_contact', b_menu_type: 'page' }) + + expect(row?.b_canuse).toBe(1) + }) +}) + +describe('权限点基线归一化', () => { + it('剔除 b_bz 等不参与同步的列,数字列统一为 number', () => { + const normalized = normalizeMenuPowerRow({ + b_id: 'menu.base_contact', + b_name: '联系人', + b_bz: '备注不该参与比较', + b_canuse: '1', + b_xh: '30', + }) + + expect(normalized).not.toHaveProperty('b_bz') + expect(normalized?.b_canuse).toBe(1) + expect(normalized?.b_xh).toBe(30) + }) + + it('空基线返回 null', () => { + expect(normalizeMenuPowerRow(null)).toBeNull() + }) + + it('归一化后与派生行比较:仅备注不同不算变更(避免「打开即脏」)', () => { + // 库里那行带了 b_bz,而派生行没有;归一化会把 b_bz 摘掉,两者应判为无差异 + const org = normalizeMenuPowerRow({ + b_id: 'menu.base_contact', + b_name: '联系人', + b_i18n: 'menu.base_contact', + b_type: 'menu', + b_object_type: 'menu', + b_object_id: 'base_contact', + b_action: 'access', + b_canuse: 1, + b_xh: 30, + b_bz: '人工写的备注', + }) + const target = buildMenuPowerRow({ + b_id: 'base_contact', + b_name: '联系人', + b_menu_type: 'page', + b_canuse: 1, + b_xh: 30, + }) + + const changes = diffRows([target!], [org!], { keyFields: ['b_id'] }) + expect(changes.inserts).toHaveLength(0) + expect(changes.updates).toHaveLength(0) + expect(changes.deletes).toHaveLength(0) + }) +}) + +describe('权限点随菜单变化同步', () => { + const org = buildMenuPowerRow({ + b_id: 'base_contact', + b_name: '联系人', + b_menu_type: 'page', + b_canuse: 1, + b_xh: 30, + })! + + it('菜单改名 → 权限点 update', () => { + const target = buildMenuPowerRow({ + b_id: 'base_contact', + b_name: '联系人(新)', + b_menu_type: 'page', + b_canuse: 1, + b_xh: 30, + })! + const changes = diffRows([target], [org], { keyFields: ['b_id'] }) + + expect(changes.updates).toHaveLength(1) + expect(changes.updates[0]?.b_name).toBe('联系人(新)') + }) + + it('菜单由页面改成目录 → 权限点 delete(目录没有入口权限)', () => { + const target = buildMenuPowerRow({ + b_id: 'base_contact', + b_menu_type: 'directory', + }) + const changes = diffRows(target ? [target] : [], [org], { keyFields: ['b_id'] }) + + expect(changes.deletes).toEqual([{ b_id: 'menu.base_contact' }]) + }) + + it('菜单由目录改成页面 → 权限点 insert', () => { + const target = buildMenuPowerRow({ + b_id: 'group_base', + b_name: '基础资料', + b_menu_type: 'page', + b_canuse: 1, + })! + const changes = diffRows([target], [], { keyFields: ['b_id'] }) + + expect(changes.inserts).toHaveLength(1) + expect(changes.inserts[0]?.b_id).toBe('menu.group_base') + }) + + it('无任何变化 → 不产生请求(保存时提示「没有需要保存的变更」)', () => { + const target = buildMenuPowerRow({ + b_id: 'base_contact', + b_name: '联系人', + b_menu_type: 'page', + b_canuse: 1, + b_xh: 30, + })! + const changes = diffRows([target], [org], { keyFields: ['b_id'] }) + + expect( + changes.inserts.length || changes.updates.length || changes.deletes.length, + ).toBeFalsy() + }) +}) + +describe('多语言键与权限编码', () => { + it('多语言键与权限编码同形但语义不同(一个进 s_i18n,一个进 s_power)', () => { + expect(buildMenuI18nKey('base_contact')).toBe('menu.base_contact') + expect(buildMenuPowerCode('base_contact')).toBe('menu.base_contact') + }) + + it('编码为空时两者都返回 null', () => { + expect(buildMenuI18nKey('')).toBeNull() + expect(buildMenuPowerCode('')).toBeNull() + }) +}) + +describe('menuRowOf:树节点 → 主表行', () => { + it('剔除 children / isLeaf / icon 等树注入字段', () => { + const node = { + b_id: 'base_contact', + b_parent_id: 'group_base', + b_name: '联系人', + b_menu_type: 'page', + b_route: '/base/contact', + b_xh: 10, + b_canuse: 1, + children: [{ b_id: 'x' }], + isLeaf: false, + icon: 'file-text', + } as unknown as MenuNode + + const row = menuRowOf(node) + + expect(row).not.toHaveProperty('children') + expect(row).not.toHaveProperty('isLeaf') + expect(row).not.toHaveProperty('icon') + expect(row.b_id).toBe('base_contact') + }) + + it('b_path 缺省时退化为空串(非空列,不能为 null)', () => { + const row = menuRowOf({ b_id: 'a', b_menu_type: 'page' }) + + expect(row.b_path).toBe('') + expect(row.b_depth).toBe(0) + }) + + it('b_parent_id 空值归一成 null', () => { + const row = menuRowOf({ b_id: 'a', b_parent_id: '', b_menu_type: 'page' }) + + expect(row.b_parent_id).toBeNull() + }) +}) + +describe('菜单主表 diff', () => { + it('新建(无基线)→ 整行 insert', () => { + const change = diffRow({ + current: { b_id: 'new_menu', b_name: '新菜单', b_menu_type: 'page' }, + original: null, + }) + + expect(change.hasChanges).toBe(true) + expect(change.inserts).toHaveLength(1) + expect(change.inserts[0]?.b_id).toBe('new_menu') + }) + + it('字段级 update 只携带变化列 + 主键', () => { + const change = diffRow({ + current: { b_id: 'a', b_name: '新名', b_route: '/x', b_xh: 10 }, + original: { b_id: 'a', b_name: '旧名', b_route: '/x', b_xh: 10 }, + }) + + expect(change.updates).toHaveLength(1) + expect(change.updates[0]).toEqual({ b_id: 'a', b_name: '新名' }) + expect(change.updates[0]).not.toHaveProperty('b_route') + }) + + it('派生字段(_org / v_*)不参与比较', () => { + const change = diffRow({ + current: { b_id: 'a', b_name: '同名', _org: { x: 1 } }, + original: { b_id: 'a', b_name: '同名', _org: { x: 2 } }, + }) + + expect(change.hasChanges).toBe(false) + }) + + it('回环序列化:cloneData 的基线与草稿修改前不产生差异', () => { + const org = { b_id: 'a', b_name: '联系人', b_xh: 10 } + const draft = cloneData(org) + + expect(diffRow({ current: draft, original: org }).hasChanges).toBe(false) + + draft.b_name = '改了' + expect(diffRow({ current: draft, original: org }).hasChanges).toBe(true) + }) + + it('stripInternalKeys 递归剔除 _ / v_ 前缀(数组逐元素)', () => { + const stripped = stripInternalKeys([ + { b_id: 'a', _rowKey: 'k', v_count: 3, b_name: 'n' }, + ]) + + expect(stripped[0]).toEqual({ b_id: 'a', b_name: 'n' }) + }) +}) diff --git a/code/g3soft-erp/web/tests/views/module/menu-management/moduleBind.spec.ts b/code/g3soft-erp/web/tests/views/module/menu-management/moduleBind.spec.ts new file mode 100644 index 00000000..53ebc1de --- /dev/null +++ b/code/g3soft-erp/web/tests/views/module/menu-management/moduleBind.spec.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import ModuleBindPanel from '@/views/module/menu-management/ModuleBindPanel.vue' +import { + buildMenuModuleRows, + filterBindableModules, + isBindableModule, + moduleBindSignature, + normalizeMenuModulesByType, +} from '@/views/module/menu-management/utils' + +/** + * 菜单「绑定模块」(s_menu_module)的纯逻辑与面板行为。 + * + * 关键口径:只有页面(page)菜单绑定模块;落库只登记 data / virtual —— + * module 类型只是模块树上的分类节点,不参与绑定。 + */ + +const MODULE_TREE = [ + { + b_id: 'sales', + b_name: '销售', + children: [ + { b_id: 'v_order', b_name: '订单' }, + { b_id: 'v_customer', b_name: '客户' }, + ], + }, + { b_id: 'v_other', b_name: '其他' }, +] + +/** 编码 → 类型:sales 是分类,其余是可绑定的数据模块 */ +const TYPES: Record = { + sales: 'module', + v_order: 'data', + v_customer: 'virtual', + v_other: 'data', +} + +/** 挂面板;menuType 决定渲染树还是给一句说明,model 是已绑模块编码 */ +function mountPanel(props: Record = {}, model: string[] = []) { + return mount(ModuleBindPanel, { + props: { + treeData: MODULE_TREE, + moduleTypes: TYPES, + menuType: 'page', + menuId: 'm1', + modelValue: model, + 'onUpdate:modelValue': () => {}, + ...props, + }, + }) +} + +describe('isBindableModule', () => { + it('data / virtual 可绑定,module 不可', () => { + expect(isBindableModule('data')).toBe(true) + expect(isBindableModule('virtual')).toBe(true) + expect(isBindableModule('module')).toBe(false) + }) + + it('类型未知时按可绑定处理(避免误删历史绑定)', () => { + // 模块清单没加载全时,宁可保留关系行也不当成脏数据删掉 + expect(isBindableModule(undefined)).toBe(true) + expect(isBindableModule(null)).toBe(true) + expect(isBindableModule('')).toBe(true) + }) +}) + +describe('filterBindableModules', () => { + it('剔除分类节点,保留可绑定模块与原顺序', () => { + expect(filterBindableModules(['v_order', 'sales', 'v_customer'], TYPES)).toEqual([ + 'v_order', + 'v_customer', + ]) + }) + + it('勾父级带回的子树键里,只有可绑定的留下', () => { + // 非严格模式点「销售」会把 sales / v_order / v_customer 一起回传 + expect(filterBindableModules(['sales', 'v_order', 'v_customer'], TYPES)).toEqual([ + 'v_order', + 'v_customer', + ]) + }) +}) + +describe('moduleBindSignature', () => { + it('顺序无关:同集合不同顺序签名相同', () => { + expect(moduleBindSignature(['a', 'b'])).toBe(moduleBindSignature(['b', 'a'])) + }) + + it('集合不同则签名不同', () => { + expect(moduleBindSignature(['a'])).not.toBe(moduleBindSignature(['a', 'b'])) + }) + + it('数字与字符串编码视为同一个', () => { + expect(moduleBindSignature([1, 2])).toBe(moduleBindSignature(['1', '2'])) + }) +}) + +describe('buildMenuModuleRows', () => { + it('按勾选顺序生成关系行,b_xh 从 10 起步', () => { + expect(buildMenuModuleRows('m1', ['v_order', 'v_customer'])).toEqual([ + { b_menu_id: 'm1', b_module_id: 'v_order', b_xh: 10, b_canuse: 1 }, + { b_menu_id: 'm1', b_module_id: 'v_customer', b_xh: 20, b_canuse: 1 }, + ]) + }) + + it('菜单编码为空时返回空数组(不产出悬空关系行)', () => { + expect(buildMenuModuleRows('', ['v_order'])).toEqual([]) + }) + + it('空绑定返回空数组', () => { + expect(buildMenuModuleRows('m1', [])).toEqual([]) + }) +}) + +describe('normalizeMenuModulesByType', () => { + it('页面菜单保留绑定', () => { + expect(normalizeMenuModulesByType('page', ['v_order'])).toEqual(['v_order']) + }) + + it('目录 / 外链一律清空(非页面不绑定模块)', () => { + expect(normalizeMenuModulesByType('directory', ['v_order'])).toEqual([]) + expect(normalizeMenuModulesByType('external', ['v_order'])).toEqual([]) + }) +}) + +describe('ModuleBindPanel', () => { + it('页面菜单渲染可勾选的模块树', () => { + const wrapper = mountPanel() + expect(wrapper.find('.biz-tree').exists()).toBe(true) + // 树默认收起(只渲染顶层节点),所以这里只断言顶层两个节点各有复选框 + const boxes = wrapper.findAllComponents({ name: 'G3Checkbox' }) + expect(boxes.length).toBe(2) + expect(wrapper.text()).toContain('销售') + expect(wrapper.text()).toContain('其他') + }) + + it('目录 / 外链不渲染树,各给一句说明', () => { + const dir = mountPanel({ menuType: 'directory' }) + expect(dir.find('.biz-tree').exists()).toBe(false) + expect(dir.text()).toContain('目录不绑定模块') + + const ext = mountPanel({ menuType: 'external' }) + expect(ext.find('.biz-tree').exists()).toBe(false) + expect(ext.text()).toContain('外链不绑定模块') + }) + + it('初始已绑模块反映在顶层复选框上', async () => { + // 「其他」是顶层节点:默认收起的树里只有顶层可见,用它验证已绑态回填 + const wrapper = mountPanel({}, ['v_other']) + await nextTick() + + const boxes = wrapper.findAllComponents({ name: 'G3Checkbox' }) + // 顶层顺序与 treeData 一致:sales、v_other + expect(boxes[0].props('modelValue')).toBe(false) + expect(boxes[1].props('modelValue')).toBe(true) + }) + + it('切换菜单清掉勾选草稿', async () => { + const wrapper = mountPanel({}, ['v_other']) + await nextTick() + expect(wrapper.findAllComponents({ name: 'G3Checkbox' })[1].props('modelValue')).toBe(true) + + // 切到另一个菜单:父级尚未回填新绑定时,草稿必须清空而不是留着上一个菜单的勾 + await wrapper.setProps({ menuId: 'm2', modelValue: [] }) + await nextTick() + expect(wrapper.findAllComponents({ name: 'G3Checkbox' })[1].props('modelValue')).toBe(false) + }) + + it('勾选后回写模型时剔除分类节点', async () => { + const updates: string[][] = [] + const wrapper = mountPanel({ + 'onUpdate:modelValue': (v: string[]) => updates.push(v), + }) + await nextTick() + + // 点「销售」分类 → 级联勾选整棵子树(含分类自身) + const boxes = wrapper.findAllComponents({ name: 'G3Checkbox' }) + await boxes[0].vm.$emit('change', true) + await nextTick() + + const last = updates.at(-1) ?? [] + expect(last).not.toContain('sales') + expect(last.toSorted()).toEqual(['v_customer', 'v_order']) + }) + + it('数量标记只算可绑定的模块', async () => { + const wrapper = mountPanel({}, ['v_order', 'v_customer']) + await nextTick() + expect(wrapper.find('.g3-tag').text()).toBe('2') + }) + + it('未绑定时不显示数量标记', () => { + const wrapper = mountPanel({}, []) + expect(wrapper.find('.g3-tag').exists()).toBe(false) + }) +}) diff --git a/code/g3soft-erp/web/tests/views/module/module-management/ModuleAutoCodePanel.spec.ts b/code/g3soft-erp/web/tests/views/module/module-management/ModuleAutoCodePanel.spec.ts index 64e647f6..2f397930 100644 --- a/code/g3soft-erp/web/tests/views/module/module-management/ModuleAutoCodePanel.spec.ts +++ b/code/g3soft-erp/web/tests/views/module/module-management/ModuleAutoCodePanel.spec.ts @@ -4,11 +4,14 @@ * 关键点:模块从未配置自动编码时草稿保持空数组(打开面板即建行会把「无变更」 * diff 成一条 insert),只有用户首次操作才落行;且 defineModel 写入后不能立刻 * 回读 prop(要下一轮才回传),所以建行后返回的是 reactive 代理行。 + * + * 「启用」开关不在本面板内(已上提到页面级面板头),面板通过 + * defineExpose 的 toggleEnabled() 拨动、enabled prop 接收状态, + * 测试走同一对口而不是找面板里的 G3Switch。 */ import { describe, expect, it } from 'vitest' import { mount } from '@vue/test-utils' -import { G3Switch } from '@g3soft/ui' import ModuleAutoCodePanel from '@/views/module/module-management/ModuleAutoCodePanel.vue' import type { ModuleAutoCodeRow } from '@/views/module/module-management/utils' @@ -38,14 +41,28 @@ function row(overrides: Partial = {}): ModuleAutoCodeRow { } } -/** 模拟父级 v-model:autocode:emit 回来要显式写回 prop,否则复现不了「立刻回读旧值」的问题 */ -function mountPanel(autocode: ModuleAutoCodeRow[] = []) { +/** + * 模拟父级 v-model:autocode + 面板头开关:emit 回来要显式写回 prop, + * 否则复现不了「立刻回读旧值」的问题。 + * + * 注意 enabled 用可变对象持有、不在 mount 期间引用 wrapper: + * 面板的 update:enabled 是 immediate 的,挂载时就会 emit, + * 处理器里访问尚未赋值的 wrapper 会直接抛 TDZ 错误。 + */ +function mountPanel(autocode: ModuleAutoCodeRow[] = [], enabled = false) { + const state = { enabled } const wrapper = mount(ModuleAutoCodePanel, { props: { moduleId: 'm1', fields: FIELDS, autocode, + get enabled() { + return state.enabled + }, 'onUpdate:autocode': (value: ModuleAutoCodeRow[]) => wrapper.setProps({ autocode: value }), + 'onUpdate:enabled': (value: boolean) => { + state.enabled = value + }, }, }) return wrapper @@ -56,7 +73,7 @@ describe('自动编码面板', () => { const wrapper = mountPanel() // 模块从未配置自动编码:草稿为空,此时拨开关会走 ensureRow 建行 - wrapper.findComponent(G3Switch).vm.$emit('update:modelValue', true) + wrapper.vm.toggleEnabled(true) await wrapper.vm.$nextTick() const emitted = wrapper.emitted('update:autocode') @@ -73,7 +90,7 @@ describe('自动编码面板', () => { const draft = [row({ b_canuse: 0, b_prefix: 'A' })] const wrapper = mountPanel(draft) - wrapper.findComponent(G3Switch).vm.$emit('update:modelValue', true) + wrapper.vm.toggleEnabled(true) await wrapper.vm.$nextTick() // 已落草稿:直接改这一行的属性(父级 ref 是深度响应式,改得见) @@ -82,6 +99,39 @@ describe('自动编码面板', () => { expect(draft[0]?.b_prefix).toBe('A') // 沿用原行,未被占位行覆盖 }) + it('把从未配置过的模块拨开又关掉,不留占位行(否则保存会 diff 出一条 insert)', async () => { + const wrapper = mountPanel() + + wrapper.vm.toggleEnabled(true) + await wrapper.vm.$nextTick() + wrapper.vm.toggleEnabled(false) + await wrapper.vm.$nextTick() + + // 最后一次写回的草稿必须回到空数组 + const emitted = wrapper.emitted('update:autocode')! + expect(emitted[emitted.length - 1][0]).toEqual([]) + }) + + it('停用既有规则时保留该行(那是既有规则的停用,要落库)', async () => { + const draft = [row({ b_canuse: 1 })] + const wrapper = mountPanel(draft, true) + + wrapper.vm.toggleEnabled(false) + await wrapper.vm.$nextTick() + + expect(draft[0]?.b_canuse).toBe(0) + // 没有把数组清空重写 + expect(wrapper.emitted('update:autocode')).toBeUndefined() + }) + + it('启用态回报给父级(驱动面板头开关)', async () => { + const wrapper = mountPanel([row({ b_canuse: 1 })]) + await wrapper.vm.$nextTick() + + expect(wrapper.emitted('update:enabled')).toBeTruthy() + expect(wrapper.emitted('update:enabled')![0][0]).toBe(true) + }) + it('紧凑网格固定竖排且标签左对齐,不跟随全局横排配置', () => { const wrapper = mountPanel() expect(wrapper.find('form').classes()).toContain('g3-form--vertical') @@ -89,6 +139,12 @@ describe('自动编码面板', () => { expect(wrapper.findAll('.g3-form-item__label.is-label-right')).toHaveLength(0) }) + it('面板内不再自带「启用」开关(已上提到页面级面板头)', () => { + const wrapper = mountPanel([row()]) + + expect(wrapper.find('[role="switch"]').exists()).toBe(false) + }) + it('预览按 前缀 + 日期 + 补位流水号 拼接', async () => { const wrapper = mountPanel([ row({ b_prefix: 'G3', b_dateformat: '', b_seqwidth: 4, b_resettype: 'none' }), @@ -101,13 +157,13 @@ describe('自动编码面板', () => { }) it('启用态下的合法规则校验通过', async () => { - const wrapper = mountPanel([row()]) + const wrapper = mountPanel([row()], true) expect(await wrapper.vm.validate()).toEqual([]) }) it('启用但未选业务编号字段时报错(错误挂在对应 FormItem 上)', async () => { - const wrapper = mountPanel([row({ b_field: null })]) + const wrapper = mountPanel([row({ b_field: null })], true) const errors = await wrapper.vm.validate() expect(errors.map((item) => item.name)).toContain('b_field') @@ -115,7 +171,7 @@ describe('自动编码面板', () => { }) it('位数超出 1-18 时报错', async () => { - const wrapper = mountPanel([row({ b_seqwidth: 20 })]) + const wrapper = mountPanel([row({ b_seqwidth: 20 })], true) const errors = await wrapper.vm.validate() expect(errors.find((item) => item.name === 'b_seqwidth')?.errors[0]).toBe( @@ -128,4 +184,11 @@ describe('自动编码面板', () => { expect(await wrapper.vm.validate()).toEqual([]) }) + + it('未启用时即使参数残缺也不报错(重置周期为空同样跳过)', async () => { + // 占位行 / 已停用的规则都不该拦保存:旧写法里 b_resettype 是硬性 required,会误报 + const wrapper = mountPanel([row({ b_canuse: 0, b_resettype: '', b_seqwidth: 0 })], false) + + expect(await wrapper.vm.validate()).toEqual([]) + }) }) diff --git a/code/g3soft-erp/web/tests/views/module/module-management/ModuleTreePanel.spec.ts b/code/g3soft-erp/web/tests/views/module/module-management/ModuleTreePanel.spec.ts new file mode 100644 index 00000000..6727218e --- /dev/null +++ b/code/g3soft-erp/web/tests/views/module/module-management/ModuleTreePanel.spec.ts @@ -0,0 +1,159 @@ +/** + * ModuleTreePanel 行尾启用勾选。 + * + * 勾选框不走 G3Tree 内置的 checkable(那是行首、多选语义),而是画在 #title 插槽的 + * 行尾。自己画就要自己兜住三件事,也正是这里要锁住的行为: + * 1. 勾选上报 toggleEnabled(id + 目标启用态),由父级落库; + * 2. 点击不冒泡 —— 不能顺带选中这一行(行上挂了 click 选中 / mousedown 武装拖拽); + * 3. 未保存草稿不可勾(库里没有这行,改不了它的 b_canuse)。 + */ + +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import type { VueWrapper } from '@vue/test-utils' +import { G3Checkbox, G3Tree } from '@g3soft/ui' +import ModuleTreePanel from '@/views/module/module-management/ModuleTreePanel.vue' +import type { ModuleNode } from '@/views/module/module-management/utils' +import BizTreePanel from '@/components/biz-tree/BizTreePanel.vue' + +const TREE = [ + { + b_id: 'm1', + b_name: '启用的模块', + b_module_type: 'module', + b_canuse: 1, + }, + { + b_id: 'm2', + b_name: '停用的模块', + b_module_type: 'module', + b_canuse: 0, + }, + { + b_id: '-1', + b_name: '未保存草稿', + b_module_type: 'module', + b_canuse: 1, + }, +] as unknown as ModuleNode[] + +function mountPanel(unsavedIds = new Set()) { + return mount(ModuleTreePanel, { + props: { treeData: TREE, unsavedIds }, + }) +} + +/** 按标题取节点行(BizTreePanel 默认插槽由本组件接管,树节点带 role=treeitem) */ +const rowOf = (wrapper: VueWrapper, label: string) => + wrapper.findAll('[role="treeitem"]').find((row) => row.text().includes(label)) + +/** 取某节点行里的勾选框 */ +const checkboxOf = (wrapper: VueWrapper, label: string) => + rowOf(wrapper, label)?.findComponent(G3Checkbox) + +describe('ModuleTreePanel 行尾启用勾选', () => { + it('每个节点行都带一个勾选框,勾选态反映 b_canuse', () => { + const wrapper = mountPanel() + + expect(checkboxOf(wrapper, '启用的模块')?.props('modelValue')).toBe(true) + expect(checkboxOf(wrapper, '停用的模块')?.props('modelValue')).toBe(false) + }) + + it('勾选上报 toggleEnabled(id + 目标启用态)', async () => { + const wrapper = mountPanel() + + checkboxOf(wrapper, '停用的模块')?.vm.$emit('update:modelValue', true) + await wrapper.vm.$nextTick() + + expect(wrapper.emitted('toggleEnabled')?.[0]?.[0]).toEqual({ id: 'm2', enabled: true }) + }) + + it('取消勾选同样上报(enabled: false)', async () => { + const wrapper = mountPanel() + + checkboxOf(wrapper, '启用的模块')?.vm.$emit('update:modelValue', false) + await wrapper.vm.$nextTick() + + expect(wrapper.emitted('toggleEnabled')?.[0]?.[0]).toEqual({ id: 'm1', enabled: false }) + }) + + it('点击勾选框不触发行的选中(事件不冒泡)', () => { + const wrapper = mountPanel() + + const row = rowOf(wrapper, '启用的模块')! + // 勾选框外层用 @click.stop 拦住冒泡:直接点勾选框本体,行上不应收到 click + const box = checkboxOf(wrapper, '启用的模块')!.find('input') + void box.trigger('click') + + expect(wrapper.emitted('select')).toBeUndefined() + // 行自身仍可正常选中,说明拦的是勾选框那一下、不是把整行点击弄坏了 + void row.trigger('click') + expect(wrapper.emitted('select')?.[0]?.[0]).toBe('m1') + }) + + it('未保存草稿的勾选框禁用且不上报(库里还没有这行)', async () => { + const wrapper = mountPanel(new Set(['-1'])) + + const box = checkboxOf(wrapper, '未保存草稿')! + expect(box.props('disabled')).toBe(true) + + box.vm.$emit('update:modelValue', false) + await wrapper.vm.$nextTick() + expect(wrapper.emitted('toggleEnabled')).toBeUndefined() + }) + + it('已保存节点不受草稿集合影响,仍可勾选', async () => { + const wrapper = mountPanel(new Set(['-1'])) + + expect(checkboxOf(wrapper, '启用的模块')?.props('disabled')).toBe(false) + + checkboxOf(wrapper, '启用的模块')?.vm.$emit('update:modelValue', false) + await wrapper.vm.$nextTick() + expect(wrapper.emitted('toggleEnabled')?.[0]?.[0]).toEqual({ id: 'm1', enabled: false }) + }) + + it('勾选框渲染在名称之后的行尾(不是行首的多选位)', () => { + const wrapper = mountPanel() + const row = rowOf(wrapper, '启用的模块')! + + // 行内文本顺序:名称在前;勾选框的 DOM 位置在名称节点之后 + const nameEl = row.find('.mm-tree-node__name') + const enableEl = row.find('.mm-tree-node__enable') + expect(nameEl.exists()).toBe(true) + expect(enableEl.exists()).toBe(true) + + // compareDocumentPosition: 4 = 名称之后(DOCUMENT_POSITION_FOLLOWING) + const relation = nameEl.element.compareDocumentPosition(enableEl.element) + expect(relation & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + + it('面板必须开 block-node:否则内容层不撑满,勾选框贴不到行尾', () => { + const wrapper = mountPanel() + + // G3Tree 的节点内容层默认 inline-flex 且无 flex:1(宽度=文字宽度), + // 只有 blockNode 会给它 flex:1。少了它 .mm-tree-node 的 flex:1 只撑到 + // 「文字那一小段」,勾选框会紧跟名称而不是贴右边 —— 这条断言把这个前提钉住。 + // + // 断言必须落在 G3Tree 上:BizTreePanel / BizTree 都是 inheritAttrs:false, + // 模板写的 'block-node'(kebab)会原样留在 BizTree 的 $attrs 里, + // 所以 BizTree.props('blockNode') 是 undefined —— 但往下传给 G3Tree 时 + // Vue 会把 kebab attr 归一到 camelCase prop,G3Tree 那边是 true。 + // 在中间层断言会得到假阴性(这条一开始就是这么写错的)。 + expect(wrapper.findComponent(G3Tree).props('blockNode')).toBe(true) + }) + + it('block-node 生效:内容层带 is-block(flex:1),勾选才有行尾可贴', () => { + const wrapper = mountPanel() + const row = rowOf(wrapper, '启用的模块')! + + // 库内样式:.g3-tree-node__content 只有 .is-block 才 flex:1 + expect(row.find('.g3-tree-node__content').classes()).toContain('is-block') + }) + + it('面板不启用 G3Tree 内置 checkable(避免行首出现多选勾选框)', () => { + const wrapper = mountPanel() + const panel = wrapper.findComponent(BizTreePanel) + // checkable 未透传:内置勾选框列不渲染 + expect(panel.attributes('checkable')).toBeUndefined() + }) +}) diff --git a/code/g3soft-libs/docs/.vitepress/config.mts b/code/g3soft-libs/docs/.vitepress/config.mts index a88b0cec..42cda046 100644 --- a/code/g3soft-libs/docs/.vitepress/config.mts +++ b/code/g3soft-libs/docs/.vitepress/config.mts @@ -112,6 +112,7 @@ export default defineConfig({ collapsed: false, items: [ { text: '快速上手', link: '/ui/usage/getting-started' }, + { text: '文字排版与居中', link: '/ui/usage/typography' }, { text: '双向布局(RTL)', link: '/ui/usage/rtl' }, ], }, diff --git a/code/g3soft-libs/docs/content/ui/usage/typography.md b/code/g3soft-libs/docs/content/ui/usage/typography.md new file mode 100644 index 00000000..21901f29 --- /dev/null +++ b/code/g3soft-libs/docs/content/ui/usage/typography.md @@ -0,0 +1,211 @@ +--- +title: 文字排版与居中 +description: 组件库的字号、字重、行高与数字对齐约定,以及跨平台偏差的处理边界 +--- + +# 文字排版与居中 + +这一页回答两个问题:组件里的文字**该长什么样**,以及**怎么保证它在不同机器上位置一致**。 + +先说结论,它决定了后面每条规则的分寸: + +> **用系统字体时,跨 OS 的「像素级一致」做不到,也不该追求。** +> 只能保证「每台机器内部一致 + 偏差 ≤1px」,剩下的交给布局层兜底。 + +任何试图把按钮文字在 Windows / macOS / 国产 Linux 上对齐到同一个小数位的方案, +最后都会变成一堆针对特定字体的补丁。**下面所有约定都只保证「稳健」,不承诺「完全相同」。** + +## 字体:组件库不选型 + +`@g3soft/tokens` 只提供一个 **sans-serif 系兜底**,`--g3-font-family` 的真实取值 +由业务方在全局样式里覆盖: + +```scss +// 业务方(g3soft-erp 之类)自己维护,不在组件库里写 +:root { + --g3-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', + 'Noto Sans CJK SC', 'Source Han Sans SC', 'WenQuanYi Micro Hei', sans-serif; +} +``` + +**为什么组件库不自己定字体栈**:组件库不知道宿主跑在什么系统上、装了什么字体, +也不该替业务方决定品牌观感。字体栈是一个**全局决策**,写在组件库里只会让 +每个消费方都要去覆盖它。 + +但有一件事必须提醒,它属于字体栈的责任而非常组件责任: + +- **国产 Linux(麒麟 / UOS)务必带上 `Noto Sans CJK SC` 与 `WenQuanYi Micro Hei`。** + 这两款系统上没有 `Microsoft YaHei`、没有 `PingFang SC`,中文会落到 fontconfig 的 + 兜底字体,出方块或发虚,**且每个发行版兜底结果不一致**。 +- 顺序是**西文在前、中文在后**,让数字和字母优先由西文字体渲染 —— + 这是中英混排最稳的顺序,反过来会让英文跟着中文字体走。 +- Win11 上 `Microsoft YaHei UI` 与 `Microsoft YaHei` 是**两款不同字体**,度量不同, + 先命中 UI 版更接近系统 UI 观感。 + +## 居中:靠 flex,不靠行高 + +组件里的文字居中,**由 `align-items: center` 保证,与 `line-height` 无关**。 + +原因:行盒中心相对基线恒为 `(ascent − descent) / 2`,这个值只由字体度量决定, +`line-height` 设多少都不会改变它。`line-height` 只影响**行盒的高度**,不影响**文字在行盒里的位置**。 + +所以不要再用「`line-height` = 容器高度」去凑居中 —— 那是偏差的主要来源, +而且那个技巧在字体度量变化时立刻失效。 + +`G3Button` 的实际写法: + +```scss +.g3-button { + display: inline-flex; + align-items: center; /* ← 居中在这一条 */ + justify-content: center; + min-height: var(--g3-control-height, 32px); + line-height: 1; /* ← 收紧行盒,不让行距顶开高度 */ +} +``` + +`line-height` 取 **1** 而不是 `1.5`:按钮是定高容器,1.5 会让行盒比字形高出 +0.5em(14px 字号下约 7px),文案一换行就顶开按钮高度、把整排按钮顶得不齐。 + +### 图标容器的额外一层 + +按钮里的图标/文字容器**也必须各自居中**: + +```scss +.g3-button__icon, +.g3-button__content { + display: inline-flex; + align-items: center; +} +``` + +⚠️ 缺了这两条会造成「图标偏上」:`span` 默认 `display: inline`,内部 `svg` 会按 +`vertical-align: baseline` 对齐,于是 svg 坐在**文字基线**上而不是容器中心, +实测比文字中心高约 1.5px。按钮自身的 `align-items: center` 只能居中 `span` 这一层, +治不了 `span` **内部**的基线对齐。 + +## 数值列:等宽数字 + +金额、数量、单号、序号这类列要加 `font-variant-numeric: tabular-nums`。 + +比例数字(默认)里每个字形宽度不同,`1` 比 `8` 窄,于是每换一行小数点与个位 +就在水平方向抖一下,整列看着参差不齐。等宽数字把 0–9 钉成同一宽度,数字按位对齐。 + +`G3Table` 已经在组件内部处理好,**业务方不需要自己加**: + +```scss +.g3-table__inner td.text-r { + font-variant-numeric: tabular-nums; +} +``` + +判据是**右对齐**而不是「内容是数字」:库渲染 `td` 时读不到单元格的值,而本库约定里 +金额/数量/数值列一律用 `align: 'right'` 声明(见 `docs/examples/table/*.vue`)。 + +⚠️ 类名是 `text-r`,由 `stk-table-vue` 的 `getTDProps` 打上: + +| `col.align` | 类名 | +|---|---| +| `'right'` | `text-r` | +| `'center'` | `text-c` | +| `'left'`(默认) | **不带类** | + +左对齐的列没有任何类,所以 `td.text-r` 不会误伤普通文本列 —— 中文与拉丁文本来就 +不需要等宽数字,强行套上去反而会让正文字距松散。 + +## 表格边框:外框与格线的归属 + +`G3Table` 的外框(1px 描边 + 8px 圆角)由组件**自绘**在根节点上,格线仍走底座的 +`background-image` 渐变。这套组合下,有两条线容易画重或画丢,判据按下面的来。 + +**为什么格线不能改成真 `border`**:行内编辑器被钉死在 `var(--row-height)`(默认 30px), +而 `td` 高度是按内容撑开的 —— 一旦 `td` 多出 1px 边线,内容盒只剩 29px 放不下 30px 的 +编辑器,整行被顶到 31px。行高是虚拟滚动 `translateY` 的计算基准,涨 1px 会一路累积成 +行错位。**背景渐变不参与盒模型**,所以格线只能走 `background-image`。 + +### 最右列竖线:按「是否贴住内右缘」判断 + +| 情形 | 处理 | 原因 | +|---|---|---| +| 右固定列(`.fixed-cell--right`) | 摘掉自身右线,交给外框 | 它贴住容器内右缘,自己的线会与外框叠成两条 | +| 普通末列 | **保留**自身右线 | 列宽没铺满时它离内缘还有距离,摘了就「线凭空消失」 | + +实测:右固定列的 `th`/`td` 右边缘都在内缘上(1242px)且自带 `270deg` 渐变; +而列宽未铺满的表(容器内宽 1084px、列只到 800px)末列离内缘差 284px。 + +### 最后一行横线:按「行区是否铺满」判断 + +这是**唯一需要 JS 参与**的一处 —— 纯 CSS 表达不了「行区高度是否等于可用高度」。 +组件侧量出行区是否铺满,用 `.g3-table.is-body-fill` 把结论传进来: + +| 行区状态 | 最后一行底边线 | 原因 | +|---|---|---| +| 铺满 | 交给外框 | 两者重合,否则叠成 2px | +| 未铺满 | **该行自己画** | 外框内底边在它下面很远,摘了就缺线 | + +⚠️ 判据**不能**是「是不是最后一行」(`tr:last-child`)。实测 180px 高的容器里只放 +1 行 30px 数据时,该行底边在 440px、外框内底边在 554px —— **差 114px 根本够不着**, +此时摘线等于那唯一一条分隔线消失。这两个条件只有行区铺满时才等价。 + +### 空态 + +库的 `.stk-table-no-data.no-data-full` 会撑满剩余高度并**自带一条 `border-bottom`**, +与外框的 `border-bottom` 落在同一条 y 上(实测都在 218px)→ 底部出现重叠线。 +组件已移除它自带的底边,**空态底边统一归外框**。 + +> 以上都由组件内部处理,业务方不需要写任何样式。若自定义了 `--g3-border-color`, +> 外框与格线会一起跟随。 + +## 硬规定 + +| 项 | 取值 | 原因 | +|---|---|---| +| 字号 | `12 / 14 / 16`(偶数) | 避开 `13 / 15`:Windows 125% / 150% 缩放下奇数字号的亚像素误差会被放大 | +| 控件高度 | `24 / 32 / 40`(8 的倍数) | 与栅格对齐,缩放后不易出现半像素 | +| 数字列 | `tabular-nums` | 见上 | + +字号与控件高度已由 `@g3soft/tokens` 提供:`--g3-font-size-sm/-/lg`、 +`--g3-control-height-sm/-/lg`。 + +## 关于字重的一个常见误解 + +网上常见「只用 400 和 700,避免 500/600 触发合成加粗」的说法。**这条在我们的 +目标平台上基本不成立**:`Segoe UI`、`Microsoft YaHei` 本身就带 500/600 字重, +不会触发合成,字体度量也不会因此改变。 + +真正需要警惕的是**回退到只有 400/700 的字体**(某些 Linux CJK 字体)时才会合成加粗。 +那属于**字体栈问题**,归业务方管,不是组件该据此砍掉中间字重。 + +组件库因此保留 500/600 —— 它们承载了真实的层级语义(标题、选中态、合计行、 +搜索高亮等),降成 400 会丢失区分度。 + +## 关于 `text-box-trim` + +`text-box-trim: trim-both` + `text-box-edge: ideographic` 是未来的正解, +**但目前没有进组件库**,原因有二: + +1. **值域不同步**:只测 `text-box-trim: trim-both` 就写 `text-box-edge: ideographic`, + 在部分实现里 `ideographic` 不被识别会**退化回 `alphabetic`** —— + 反而把中文按西文边缘裁,比不裁更偏。稳妥写法要两级嵌套 `@supports`。 +2. **引入新的不确定性**:组件库是发布给业务方的,`@supports` 里塞实验特性意味着 + **样式表现依赖消费方的浏览器版本**,同一个包在两台机器上长得不一样。 + 这与本页开头的「跨 OS 一致做不到」是同一个逻辑。 + +## 自测 + +改动字体栈或行高后,**整行的基线与行高都会重算**,必须把所有小尺寸组件回归一遍 +(按钮三档尺寸、标签、分段控制器、分页器、表格行内编辑器)。 + +- 定性:playground 里逐个尺寸目视比对 +- 定量:`FontMetrics` API 可读浏览器实际采用的度量,但**它给不出「这行实际用了哪款字体」** + —— 浏览器没有 API 能查回退结果,只能靠 `document.fonts.check()` 逐个试探, + 那是启发式而非实测。所以工具能定位偏差,但别信它的字体判定。 +- 设计稿差异:Figma 的行高框与浏览器渲染**不等价**,1px 内的差异属于正常,别去追绝对像素一致。 + +## 已知边界 + +- 跨 OS 像素级一致**做不到**,见页首结论。 +- 翻译成 `ar` 等语言时字体栈需另行调整(语言包目前只有 `zhCN` / `enUS`)。 +- 若业务方覆盖了 `--g3-font-family`,`G3Table` 内部硬编码的 `font-size` 不受影响, + 但行高与基线会变 —— 需重新回归。 diff --git a/code/g3soft-libs/packages/tokens/src/scss/theme.scss b/code/g3soft-libs/packages/tokens/src/scss/theme.scss index 8568a3ac..3fc8b130 100644 --- a/code/g3soft-libs/packages/tokens/src/scss/theme.scss +++ b/code/g3soft-libs/packages/tokens/src/scss/theme.scss @@ -62,7 +62,10 @@ $dark-primary: #e5e5e5; #{fn.css-var('font-size')}: 14px; #{fn.css-var('font-size-lg')}: 16px; - /* ---------- 字体 ---------- */ + /* ---------- 字体 ---------- + 只提供「兜底」,不做实际选型:组件库不假定宿主装了什么字体, + 真正的字体栈由业务方(g3soft-erp)在自己的全局样式里覆盖 --g3-font-family。 + 这里保留 sans-serif 系兜底,保证未覆盖时组件仍可读。 */ #{fn.css-var('font-family')}: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif; #{fn.css-var('font-family-mono')}: ui-monospace, 'SF Mono', 'Cascadia Code', 'JetBrains Mono', diff --git a/code/g3soft-libs/packages/ui/probe-boundary.mjs b/code/g3soft-libs/packages/ui/probe-boundary.mjs new file mode 100644 index 00000000..5cef33f5 --- /dev/null +++ b/code/g3soft-libs/packages/ui/probe-boundary.mjs @@ -0,0 +1,65 @@ +import { chromium } from '@playwright/test' + +/** + * 收敛范围:找出「容器小到装不下内容」的那个临界高度。 + * 表头 34 + 一行 30 = 64px(加外框上下各 1px → 66px)。 + * 低于这个高度,表格就是病态输入(连一行都放不下)。 + */ +const BASE = process.env.PROBE_URL || 'http://localhost:5200' +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } }) +await page.goto(BASE, { waitUntil: 'networkidle' }) + +const clipSample = async (x, y, w, h) => { + const shot = await page.screenshot({ clip: { x, y, width: w, height: h } }) + return page.evaluate(async (dataUrl) => { + const img = new Image() + await new Promise((res) => { + img.onload = res + img.src = dataUrl + }) + const cv = document.createElement('canvas') + cv.width = img.width + cv.height = img.height + const ctx = cv.getContext('2d') + ctx.drawImage(img, 0, 0) + const d = ctx.getImageData(0, 0, img.width, img.height).data + const out = [] + for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]]) + return out + }, `data:image/png;base64,${shot.toString('base64')}`) +} + +const heights = [100, 95, 90, 85, 80, 75, 70, 68, 66, 64, 60] +for (const h of heights) { + await page.evaluate((hh) => { + document.querySelector('[data-testid="table-wrap"]').style.height = `${hh}px` + }, h) + await page.waitForTimeout(420) + await page.locator('[data-testid="table-wrap"]').scrollIntoViewIfNeeded() + await page.waitForTimeout(160) + + const g = await page.evaluate(() => { + const wrap = document.querySelector('[data-testid="table-wrap"]') + const root = wrap.querySelector('.g3-table') + const inner = wrap.querySelector('.stk-table') + const r = root.getBoundingClientRect() + return { + left: Math.round(r.left), + bottom: Math.round(r.bottom), + overflow: inner.scrollHeight > inner.clientHeight + 1, + scrollH: inner.scrollHeight, + clientH: inner.clientHeight, + } + }) + const y = Math.max(0, g.bottom - 4) + if (1200 - y < 10) continue + const px = await clipSample(g.left + 300, y, 1, 10) + const vec = px.map(([r, gg, b]) => (r < 246 || gg < 246 || b < 246 ? 1 : 0)) + const n = vec.reduce((a, b) => a + b, 0) + console.log( + `h=${String(h).padStart(3)} 溢出=${String(g.overflow).padStart(5)} (scrollH ${g.scrollH} / clientH ${g.clientH}) 底部 ${vec.join('')} (${n}条) ${n === 1 ? '✅' : '❌'}`, + ) +} + +await browser.close() diff --git a/code/g3soft-libs/packages/ui/probe-correct.mjs b/code/g3soft-libs/packages/ui/probe-correct.mjs new file mode 100644 index 00000000..6f75f019 --- /dev/null +++ b/code/g3soft-libs/packages/ui/probe-correct.mjs @@ -0,0 +1,71 @@ +import { chromium } from '@playwright/test' + +/** + * 用**正确阈值**重测。 + * 之前的探针用 r<246 判"深色",而表头底色是 245 → 被误判成线。 + * 边框线是 217,白底是 255,表头灰是 245。 + * 正确判据:只看接近 217 的像素(容差 ±6),把 245 和 255 都当作"非线"。 + */ +const BASE = process.env.PROBE_URL || 'http://localhost:5200' +const LINE = 217 +const TOL = 6 + +const isLine = ([r, g, b]) => + Math.abs(r - LINE) <= TOL && Math.abs(g - LINE) <= TOL && Math.abs(b - LINE) <= TOL + +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } }) +await page.goto(BASE, { waitUntil: 'networkidle' }) + +const clipSample = async (x, y, w, h) => { + const shot = await page.screenshot({ clip: { x, y, width: w, height: h } }) + return page.evaluate(async (dataUrl) => { + const img = new Image() + await new Promise((res) => { + img.onload = res + img.src = dataUrl + }) + const cv = document.createElement('canvas') + cv.width = img.width + cv.height = img.height + const ctx = cv.getContext('2d') + ctx.drawImage(img, 0, 0) + const d = ctx.getImageData(0, 0, img.width, img.height).data + const out = [] + for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]]) + return out + }, `data:image/png;base64,${shot.toString('base64')}`) +} + +const heights = [500, 400, 350, 300, 250, 200, 180, 160, 150, 140, 130, 120, 110, 100, 90, 80] + +console.log('=== 正确阈值(只认 217±6,245/255 均视为非线)===') +console.log('容器高 | 底边像素(217线) | 线数 | 判定') +let bad = 0 +for (const h of heights) { + await page.evaluate((hh) => { + document.querySelector('[data-testid="table-big-wrap"]').style.height = `${hh}px` + }, h) + await page.waitForTimeout(420) + await page.locator('[data-testid="table-big-wrap"]').scrollIntoViewIfNeeded() + await page.waitForTimeout(180) + + const g = await page.evaluate(() => { + const wrap = document.querySelector('[data-testid="table-big-wrap"]') + const root = wrap.querySelector('.g3-table') + const r = root.getBoundingClientRect() + return { left: Math.round(r.left), bottom: Math.round(r.bottom) } + }) + const y = Math.max(0, g.bottom - 5) + if (1200 - y < 12) continue + const px = await clipSample(g.left + 300, y, 1, 12) + const vec = px.map((p) => (isLine(p) ? 1 : 0)) + const n = vec.reduce((a, b) => a + b, 0) + if (n !== 1) bad += 1 + console.log( + `${String(h).padStart(6)} | ${vec.join('')} | ${String(n).padStart(4)} | ${n === 1 ? '✅ 一条线' : n === 0 ? '❌ 缺线' : '❌ ' + n + ' 条'}`, + ) +} +console.log(`\n失败档位: ${bad} 个`) + +await browser.close() diff --git a/code/g3soft-libs/packages/ui/probe-cover.mjs b/code/g3soft-libs/packages/ui/probe-cover.mjs new file mode 100644 index 00000000..c079989c --- /dev/null +++ b/code/g3soft-libs/packages/ui/probe-cover.mjs @@ -0,0 +1,91 @@ +import { chromium } from '@playwright/test' + +/** + * 方向验证:在滚动容器底部覆盖一条 1px 的同色线(画在格线之上、外框之下), + * 把"被裁切行留下的底边线"盖掉,使可视底边恒为一条。 + * + * 用 ::after 伪元素实现(absolute,画在内容上层)。 + */ +const BASE = process.env.PROBE_URL || 'http://localhost:5200' +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } }) +await page.goto(BASE, { waitUntil: 'networkidle' }) + +const clipSample = async (x, y, w, h) => { + const shot = await page.screenshot({ clip: { x, y, width: w, height: h } }) + return page.evaluate(async (dataUrl) => { + const img = new Image() + await new Promise((res) => { + img.onload = res + img.src = dataUrl + }) + const cv = document.createElement('canvas') + cv.width = img.width + cv.height = img.height + const ctx = cv.getContext('2d') + ctx.drawImage(img, 0, 0) + const d = ctx.getImageData(0, 0, img.width, img.height).data + const out = [] + for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]]) + return out + }, `data:image/png;base64,${shot.toString('base64')}`) +} + +const applyPatch = () => + page.evaluate(() => { + const s = document.createElement('style') + s.id = 'probe-cover' + s.textContent = ` + /* 覆盖式收边:在 .stk-table 可视底部画一条与外框同色的线,压住被裁行的底边 */ + .g3-table.is-bordered .g3-table__inner.stk-table { position: relative; } + .g3-table.is-bordered .g3-table__inner.stk-table::after { + content: ''; + position: absolute; + inset-inline: 0; + bottom: 0; + height: 1px; + background: var(--g3-border-color, #d9d9d9); + pointer-events: none; + z-index: 5; + } + ` + document.head.appendChild(s) + }) + +const heights = [500, 400, 300, 250, 200, 180, 160, 150, 140, 130, 120, 110, 100] + +const sweep = async (label) => { + const bad = [] + for (const h of heights) { + await page.evaluate((hh) => { + document.querySelector('[data-testid="table-big-wrap"]').style.height = `${hh}px` + }, h) + await page.waitForTimeout(420) + await page.locator('[data-testid="table-big-wrap"]').scrollIntoViewIfNeeded() + await page.waitForTimeout(180) + + const g = await page.evaluate(() => { + const wrap = document.querySelector('[data-testid="table-big-wrap"]') + const root = wrap.querySelector('.g3-table') + const r = root.getBoundingClientRect() + return { left: Math.round(r.left), bottom: Math.round(r.bottom) } + }) + const y = Math.max(0, g.bottom - 4) + if (1200 - y < 10) continue + const px = await clipSample(g.left + 300, y, 1, 10) + const vec = px.map(([r, gg, b]) => (r < 246 || gg < 246 || b < 246 ? 1 : 0)) + const n = vec.reduce((a, b) => a + b, 0) + if (n !== 1) bad.push(`h=${h}(${n})`) + console.log(` ${label} h=${String(h).padStart(3)} ${vec.join('')} (${n}条) ${n === 1 ? '✅' : '❌'}`) + } + console.log(` → ${label} 失败档位: ${bad.length ? bad.join(', ') : '无 ✅'}\n`) +} + +console.log('===== 修复前(table-big,真实滚动)=====') +await sweep('before') + +console.log('===== 加覆盖式收边 =====') +await applyPatch() +await sweep('patch ') + +await browser.close() diff --git a/code/g3soft-libs/packages/ui/probe-h80.mjs b/code/g3soft-libs/packages/ui/probe-h80.mjs new file mode 100644 index 00000000..ff5fe3bf --- /dev/null +++ b/code/g3soft-libs/packages/ui/probe-h80.mjs @@ -0,0 +1,106 @@ +import { chromium } from '@playwright/test' + +/** + * 定位 h=80 时底部那 4 个连续深色像素来自哪个元素。 + * 做法:用一个只在上方有元素的地方做"逐元素排除"——逐个隐藏候选元素,看哪一条消失。 + */ +const BASE = process.env.PROBE_URL || 'http://localhost:5200' +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } }) +await page.goto(BASE, { waitUntil: 'networkidle' }) + +const clipSample = async (x, y, w, h) => { + const shot = await page.screenshot({ clip: { x, y, width: w, height: h } }) + return page.evaluate(async (dataUrl) => { + const img = new Image() + await new Promise((res) => { + img.onload = res + img.src = dataUrl + }) + const cv = document.createElement('canvas') + cv.width = img.width + cv.height = img.height + const ctx = cv.getContext('2d') + ctx.drawImage(img, 0, 0) + const d = ctx.getImageData(0, 0, img.width, img.height).data + const out = [] + for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]]) + return out + }, `data:image/png;base64,${shot.toString('base64')}`) +} + +const vec = async (x, y) => { + const px = await clipSample(x, y, 1, 10) + return px.map(([r, g, b]) => (r < 246 || g < 246 || b < 246 ? 1 : 0)).join('') +} + +// 压到 80px +await page.evaluate(() => { + document.querySelector('[data-testid="table-wrap"]').style.height = '80px' +}) +await page.waitForTimeout(600) +await page.locator('[data-testid="table-wrap"]').scrollIntoViewIfNeeded() +await page.waitForTimeout(200) + +const geo = await page.evaluate(() => { + const wrap = document.querySelector('[data-testid="table-wrap"]') + const root = wrap.querySelector('.g3-table') + const r = root.getBoundingClientRect() + const inner = wrap.querySelector('.stk-table') + const tbody = wrap.querySelector('.stk-tbody-main') + return { + left: Math.round(r.left), + rootRect: { top: Math.round(r.top), bottom: Math.round(r.bottom) }, + innerRect: (() => { + const x = inner.getBoundingClientRect() + return { top: Math.round(x.top), bottom: Math.round(x.bottom), scrollH: inner.scrollHeight, clientH: inner.clientHeight } + })(), + tbodyRect: (() => { + const x = tbody.getBoundingClientRect() + return { top: Math.round(x.top), bottom: Math.round(x.bottom) } + })(), + } +}) + +console.log('=== h=80 几何 ===') +console.log(JSON.stringify(geo, null, 2)) + +const x = geo.left + 300 +const base = await vec(x, geo.rootRect.bottom - 4) +console.log(`\n基线采样(root.bottom-4 起 10 行): ${base}`) + +// 逐元素隐藏,看哪一次让多余的线消失 +const candidates = [ + ['tbody 最后一行', '.stk-tbody-main tr:last-child'], + ['tbody', '.stk-tbody-main'], + ['thead', 'thead'], + ['stk-table 根', '.stk-table'], +] +for (const [name, sel] of candidates) { + const v = await page.evaluate( + ({ sel, y, x }) => { + const wrap = document.querySelector('[data-testid="table-wrap"]') + const el = wrap.querySelector(sel) + if (!el) return 'not-found' + const prev = el.style.visibility + el.style.visibility = 'hidden' + return 'hidden' + }, + { sel, y: geo.rootRect.bottom - 4, x }, + ) + if (v === 'not-found') { + console.log(`隐藏 ${name}: 元素不存在`) + continue + } + await page.waitForTimeout(150) + const after = await vec(x, geo.rootRect.bottom - 4) + console.log(`隐藏 ${name.padEnd(16)} → ${after} ${after !== base ? '← 变化' : ''}`) + await page.evaluate((sel) => { + const wrap = document.querySelector('[data-testid="table-wrap"]') + const el = wrap.querySelector(sel) + if (el) el.style.visibility = '' + }, sel) + await page.waitForTimeout(120) +} + +await browser.close() diff --git a/code/g3soft-libs/packages/ui/probe-overflow.mjs b/code/g3soft-libs/packages/ui/probe-overflow.mjs new file mode 100644 index 00000000..a426455e --- /dev/null +++ b/code/g3soft-libs/packages/ui/probe-overflow.mjs @@ -0,0 +1,69 @@ +import { chromium } from '@playwright/test' + +/** + * 上一步的疑点:lastRowBottom 恒为 3053,不随容器高度变化。 + * 猜想:容器变矮 → 行区溢出/被裁 → 该坐标不再是"可见的最后一行底边"。 + * + * 这里把「滚动容器」也一起量出来,看清溢出时到底发生了什么。 + */ +const BASE = process.env.PROBE_URL || 'http://localhost:5200' +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } }) +await page.goto(BASE, { waitUntil: 'networkidle' }) + +for (const h of [400, 200, 128, 126, 120, 80]) { + await page.evaluate((hh) => { + const wrap = document.querySelector('[data-testid="table-wrap"]') + wrap.style.height = `${hh}px` + }, h) + await page.waitForTimeout(500) + + const r = await page.evaluate(() => { + const wrap = document.querySelector('[data-testid="table-wrap"]') + const root = wrap.querySelector('.g3-table') + const inner = wrap.querySelector('.stk-table') + const tbody = wrap.querySelector('.stk-tbody-main') + const rows = [...wrap.querySelectorAll('.stk-tbody-main tr')] + const last = rows[rows.length - 1] + + // 找到所有可能的滚动容器 + const scrollers = [...wrap.querySelectorAll('*')] + .filter((el) => { + const s = getComputedStyle(el) + return /auto|scroll/.test(s.overflowY) && el.scrollHeight > el.clientHeight + 1 + }) + .map((el) => ({ + cls: el.className.toString().slice(0, 40), + clientH: el.clientHeight, + scrollH: el.scrollHeight, + scrollTop: el.scrollTop, + })) + + const hasBottom = (s) => /linear-gradient\(0deg, rgb/.test(s) + const innerRect = inner.getBoundingClientRect() + const cs = getComputedStyle(inner) + + return { + rootCls: root.className, + wrapH: Math.round(wrap.getBoundingClientRect().height), + innerRect: { top: Math.round(innerRect.top), bottom: Math.round(innerRect.bottom) }, + innerBottomEdge: Math.round(innerRect.bottom - (parseFloat(cs.borderBottomWidth) || 0)), + tbodyRect: tbody + ? { top: Math.round(tbody.getBoundingClientRect().top), bottom: Math.round(tbody.getBoundingClientRect().bottom), h: Math.round(tbody.getBoundingClientRect().height) } + : null, + lastRow: last + ? { + top: Math.round(last.getBoundingClientRect().top), + bottom: Math.round(last.getBoundingClientRect().bottom), + draws: [...last.querySelectorAll('td')].some((td) => hasBottom(getComputedStyle(td).backgroundImage)), + } + : null, + rowBottoms: rows.map((tr) => Math.round(tr.getBoundingClientRect().bottom)), + scrollers, + } + }) + console.log(`\n===== container ${h}px =====`) + console.log(JSON.stringify(r, null, 2)) +} + +await browser.close() diff --git a/code/g3soft-libs/packages/ui/probe-pixel-sweep.mjs b/code/g3soft-libs/packages/ui/probe-pixel-sweep.mjs new file mode 100644 index 00000000..4221e0e6 --- /dev/null +++ b/code/g3soft-libs/packages/ui/probe-pixel-sweep.mjs @@ -0,0 +1,81 @@ +import { chromium } from '@playwright/test' + +/** + * 方案验证:外框底边若改用 inset box-shadow(画在 padding box 内侧、覆盖在上层), + * 是否能"永远赢"——即无论最后一行是否贴近,视觉上只有一条线。 + * + * 先做纯 CSS 实验:在页面上注入覆盖样式,然后逐像素扫描底边, + * 数出「深色像素的数量」,1 条 = 正确,2 条 = 双线,0 条 = 缺线。 + */ +const BASE = process.env.PROBE_URL || 'http://localhost:5200' +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } }) +await page.goto(BASE, { waitUntil: 'networkidle' }) + +const clipSample = async (x, y, w, h) => { + const shot = await page.screenshot({ clip: { x, y, width: w, height: h } }) + return page.evaluate(async (dataUrl) => { + const img = new Image() + await new Promise((res) => { + img.onload = res + img.src = dataUrl + }) + const cv = document.createElement('canvas') + cv.width = img.width + cv.height = img.height + const ctx = cv.getContext('2d') + ctx.drawImage(img, 0, 0) + const d = ctx.getImageData(0, 0, img.width, img.height).data + const out = [] + for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]]) + return out + }, `data:image/png;base64,${shot.toString('base64')}`) +} + +const scan = async (label, h) => { + await page.evaluate((hh) => { + const wrap = document.querySelector('[data-testid="table-wrap"]') + wrap.style.height = `${hh}px` + }, h) + await page.waitForTimeout(500) + + // 必须先把表格滚进视口:page.screenshot 的 clip 用视口坐标,页面下方的元素会落空 + const wrapLoc = page.locator('[data-testid="table-wrap"]') + await wrapLoc.scrollIntoViewIfNeeded() + await page.waitForTimeout(200) + + const geo = await page.evaluate(() => { + const wrap = document.querySelector('[data-testid="table-wrap"]') + const root = wrap.querySelector('.g3-table') + const r = root.getBoundingClientRect() + return { + left: Math.round(r.left), + // getBoundingClientRect 已是视口坐标,直接可用 + bottom: Math.round(r.bottom), + isBodyFill: root.classList.contains('is-body-fill'), + } + }) + + // 采样窗口必须完整落在视口内 + const y0 = Math.max(0, geo.bottom - 4) + const available = 1200 - y0 + if (available < 8) { + console.log(`${label} h=${h} 跳过(采样窗口超出视口)`) + return null + } + + const px = await clipSample(geo.left + 300, y0, 1, 8) + const dark = px.map(([r, g, b]) => (r < 246 || g < 246 || b < 246 ? 1 : 0)) + const count = dark.reduce((a, b) => a + b, 0) + console.log( + `${label} h=${String(h).padStart(3)} bodyFill=${String(geo.isBodyFill).padStart(5)} 底边纵向像素: ${dark.join('')} → ${count === 1 ? '一条线 ✅' : count === 0 ? '缺线 ❌' : count + ' 条 ❌'}`, + ) + return count +} + +console.log('========== 修复前(当前实现)==========') +for (const h of [400, 200, 140, 130, 128, 127, 126, 125, 124, 120, 100, 80, 60]) { + await scan('before', h) +} + +await browser.close() diff --git a/code/g3soft-libs/packages/ui/probe-planb.mjs b/code/g3soft-libs/packages/ui/probe-planb.mjs new file mode 100644 index 00000000..c5587deb --- /dev/null +++ b/code/g3soft-libs/packages/ui/probe-planb.mjs @@ -0,0 +1,104 @@ +import { chromium } from '@playwright/test' + +/** + * 路 B 验证:最后一行**永远不画**自己的底边线,底边由外框独占。 + * —— 不加任何开关,不看容器高度,行本身永不画底边。 + * + * 期望:任何容器高度下,底边都恰好一条线。 + * 另需确认:行与行之间的分隔线(第 1、2 行之间)不受影响。 + */ +const BASE = process.env.PROBE_URL || 'http://localhost:5200' +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } }) +await page.goto(BASE, { waitUntil: 'networkidle' }) + +const clipSample = async (x, y, w, h) => { + const shot = await page.screenshot({ clip: { x, y, width: w, height: h } }) + return page.evaluate(async (dataUrl) => { + const img = new Image() + await new Promise((res) => { + img.onload = res + img.src = dataUrl + }) + const cv = document.createElement('canvas') + cv.width = img.width + cv.height = img.height + const ctx = cv.getContext('2d') + ctx.drawImage(img, 0, 0) + const d = ctx.getImageData(0, 0, img.width, img.height).data + const out = [] + for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]]) + return out + }, `data:image/png;base64,${shot.toString('base64')}`) +} + +// 路 B 补丁:最后一行永不画底边(无条件),且保留 is-body-fill 的既有规则不冲突 +const applyB = () => + page.evaluate(() => { + const s = document.createElement('style') + s.id = 'probe-b' + s.textContent = ` + /* 路 B:数据区最后一行永不画自己的底边线 —— 底边永远由外框独占 */ + .g3-table.is-bordered .g3-table__inner.stk-table.bordered-v + .stk-tbody-main tr:last-child > td { + --bg-border-bottom: linear-gradient(transparent, transparent) !important; + } + ` + document.head.appendChild(s) + }) + +const scanColumn = async (label, h) => { + await page.evaluate((hh) => { + const wrap = document.querySelector('[data-testid="table-wrap"]') + wrap.style.height = `${hh}px` + }, h) + await page.waitForTimeout(450) + await page.locator('[data-testid="table-wrap"]').scrollIntoViewIfNeeded() + await page.waitForTimeout(180) + + const geo = await page.evaluate(() => { + const wrap = document.querySelector('[data-testid="table-wrap"]') + const root = wrap.querySelector('.g3-table') + const r = root.getBoundingClientRect() + const rows = [...wrap.querySelectorAll('.stk-tbody-main tr')] + return { + left: Math.round(r.left), + top: Math.round(r.top), + bottom: Math.round(r.bottom), + rowBottoms: rows.map((tr) => Math.round(tr.getBoundingClientRect().bottom)), + } + }) + + // ① 底部:外框底边 + const yB = Math.max(0, geo.bottom - 4) + let bottomVec = 'skip' + if (1200 - yB >= 8) { + const px = await clipSample(geo.left + 300, yB, 1, 8) + const dark = px.map(([r, g, b]) => (r < 246 || g < 246 || b < 246 ? 1 : 0)) + bottomVec = `${dark.join('')} (${dark.reduce((a, b) => a + b, 0)}条)` + } + + // ② 行间分隔线:第 1 行与第 2 行之间那条,必须还在 + let innerVec = 'skip' + if (geo.rowBottoms.length >= 2) { + const yI = Math.max(0, geo.rowBottoms[0] - 4) + if (1200 - yI >= 8) { + const px = await clipSample(geo.left + 300, yI, 1, 8) + const dark = px.map(([r, g, b]) => (r < 246 || g < 246 || b < 246 ? 1 : 0)) + innerVec = `${dark.join('')} (${dark.reduce((a, b) => a + b, 0)}条)` + } + } + + console.log(`${label} h=${String(h).padStart(3)} 底部: ${bottomVec.padEnd(16)} 行间: ${innerVec}`) +} + +const heights = [400, 140, 130, 129, 128, 127, 126, 124, 120, 100, 80, 60] + +console.log('===== 修复前 =====') +for (const h of heights) await scanColumn('before', h) + +console.log('\n===== 路 B:最后一行永不画底边(无开关)=====') +await applyB() +for (const h of heights) await scanColumn('B ', h) + +await browser.close() diff --git a/code/g3soft-libs/packages/ui/probe-rgb.mjs b/code/g3soft-libs/packages/ui/probe-rgb.mjs new file mode 100644 index 00000000..287b9987 --- /dev/null +++ b/code/g3soft-libs/packages/ui/probe-rgb.mjs @@ -0,0 +1,69 @@ +import { chromium } from '@playwright/test' + +/** + * 停止试错,先看清真相:把那 4 个像素的**实际 RGB 和所属元素**打出来。 + */ +const BASE = process.env.PROBE_URL || 'http://localhost:5200' +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } }) +await page.goto(BASE, { waitUntil: 'networkidle' }) + +const clipSample = async (x, y, w, h) => { + const shot = await page.screenshot({ clip: { x, y, width: w, height: h } }) + return page.evaluate(async (dataUrl) => { + const img = new Image() + await new Promise((res) => { + img.onload = res + img.src = dataUrl + }) + const cv = document.createElement('canvas') + cv.width = img.width + cv.height = img.height + const ctx = cv.getContext('2d') + ctx.drawImage(img, 0, 0) + const d = ctx.getImageData(0, 0, img.width, img.height).data + const out = [] + for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]]) + return out + }, `data:image/png;base64,${shot.toString('base64')}`) +} + +// 取一档"坏"的(h=250)和一档"好"的(h=400) +for (const h of [250, 400]) { + await page.evaluate((hh) => { + document.querySelector('[data-testid="table-big-wrap"]').style.height = `${hh}px` + }, h) + await page.waitForTimeout(500) + await page.locator('[data-testid="table-big-wrap"]').scrollIntoViewIfNeeded() + await page.waitForTimeout(220) + + const g = await page.evaluate(() => { + const wrap = document.querySelector('[data-testid="table-big-wrap"]') + const root = wrap.querySelector('.g3-table') + const r = root.getBoundingClientRect() + // 把该 x 位置自上而下的元素列出来(用 elementFromPoint 逐个 y 采样) + return { left: Math.round(r.left), bottom: Math.round(r.bottom) } + }) + + const x = g.left + 300 + console.log(`\n=========== h=${h} root.bottom=${g.bottom} ===========`) + console.log('从 root.bottom-6 到 root.bottom+3,每行的色值与该点元素:') + const px = await clipSample(x, g.bottom - 6, 1, 10) + for (let i = 0; i < 10; i += 1) { + const y = g.bottom - 6 + i + const info = await page.evaluate( + ({ x, y }) => { + const el = document.elementFromPoint(x, y) + if (!el) return 'null' + const s = getComputedStyle(el) + return `${el.tagName}.${String(el.className).slice(0, 45)} | bg=${s.backgroundColor} | bgImg=${s.backgroundImage === 'none' ? 'none' : 'HAS'} | shadow=${s.boxShadow === 'none' ? 'none' : 'HAS'}` + }, + { x, y }, + ) + const [r, gg, b] = px[i] + const mark = r < 246 || gg < 246 || b < 246 ? '◀ 深' : '' + console.log(` y=${y} rgb(${r},${gg},${b}) ${mark} ${info}`) + } +} + +await browser.close() diff --git a/code/g3soft-libs/packages/ui/probe-scroll.mjs b/code/g3soft-libs/packages/ui/probe-scroll.mjs new file mode 100644 index 00000000..d57ab6bf --- /dev/null +++ b/code/g3soft-libs/packages/ui/probe-scroll.mjs @@ -0,0 +1,78 @@ +import { chromium } from '@playwright/test' + +/** + * 真实场景:内容多、需要垂直滚动。 + * 用 table-big-wrap(5000 行虚拟滚动)与 table-wrap 塞满行两种方式, + * 看底部到底是几条线、以及滚动到不同位置时是否稳定。 + */ +const BASE = process.env.PROBE_URL || 'http://localhost:5200' +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } }) +await page.goto(BASE, { waitUntil: 'networkidle' }) + +const clipSample = async (x, y, w, h) => { + const shot = await page.screenshot({ clip: { x, y, width: w, height: h } }) + return page.evaluate(async (dataUrl) => { + const img = new Image() + await new Promise((res) => { + img.onload = res + img.src = dataUrl + }) + const cv = document.createElement('canvas') + cv.width = img.width + cv.height = img.height + const ctx = cv.getContext('2d') + ctx.drawImage(ctx.canvas, 0, 0) + ctx.drawImage(img, 0, 0) + const d = ctx.getImageData(0, 0, img.width, img.height).data + const out = [] + for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]]) + return out + }, `data:image/png;base64,${shot.toString('base64')}`) +} + +const probe = async (testid, label) => { + const el = page.locator(`[data-testid="${testid}"]`) + await el.scrollIntoViewIfNeeded() + await page.waitForTimeout(300) + + const g = await el.evaluate((wrap) => { + const root = wrap.querySelector('.g3-table') + const inner = wrap.querySelector('.stk-table') + const r = root.getBoundingClientRect() + return { + left: Math.round(r.left), + bottom: Math.round(r.bottom), + rootCls: root.className, + overflow: inner.scrollHeight > inner.clientHeight + 1, + scrollH: inner.scrollHeight, + clientH: inner.clientHeight, + rowCount: wrap.querySelectorAll('.stk-tbody-main tr').length, + } + }) + + const y = Math.max(0, g.bottom - 4) + if (1200 - y < 10) { + console.log(`${label}: 采样超出视口 (bottom=${g.bottom})`) + return + } + const px = await clipSample(g.left + 300, y, 1, 10) + const vec = px.map(([r, gg, b]) => (r < 246 || gg < 246 || b < 246 ? 1 : 0)) + const n = vec.reduce((a, b) => a + b, 0) + console.log( + `${label.padEnd(18)} overflow=${String(g.overflow).padStart(5)} (${g.scrollH}/${g.clientH}) rows=${String(g.rowCount).padStart(3)} 底部 ${vec.join('')} (${n}条) ${n === 1 ? '✅' : '❌'} bodyFill=${g.rootCls.includes('is-body-fill')}`, + ) +} + +console.log('===== 现有 playground 表格 =====') +for (const [id, label] of [ + ['table-wrap', 'table-wrap(3行)'], + ['table-big-wrap', 'table-big(5000行)'], + ['table-plain-wrap', 'table-plain(2行)'], + ['table-fixed-wrap', 'table-fixed'], +]) { + if ((await page.locator(`[data-testid="${id}"]`).count()) === 0) continue + await probe(id, label) +} + +await browser.close() diff --git a/code/g3soft-libs/packages/ui/probe-scroll2.mjs b/code/g3soft-libs/packages/ui/probe-scroll2.mjs new file mode 100644 index 00000000..c63f3d6f --- /dev/null +++ b/code/g3soft-libs/packages/ui/probe-scroll2.mjs @@ -0,0 +1,79 @@ +import { chromium } from '@playwright/test' + +/** + * 真正的滚动场景复现:行数固定(多到溢出),容器高度在「行高之和」附近连续变化。 + * 这才是用户说的「表格高度 <= 行高之和」——内容溢出并出现滚动条。 + */ +const BASE = process.env.PROBE_URL || 'http://localhost:5200' +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } }) +await page.goto(BASE, { waitUntil: 'networkidle' }) + +const clipSample = async (x, y, w, h) => { + const shot = await page.screenshot({ clip: { x, y, width: w, height: h } }) + return page.evaluate(async (dataUrl) => { + const img = new Image() + await new Promise((res) => { + img.onload = res + img.src = dataUrl + }) + const cv = document.createElement('canvas') + cv.width = img.width + cv.height = img.height + const ctx = cv.getContext('2d') + ctx.drawImage(img, 0, 0) + const d = ctx.getImageData(0, 0, img.width, img.height).data + const out = [] + for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]]) + return out + }, `data:image/png;base64,${shot.toString('base64')}`) +} + +// 用 5000 行的 table-big(真实滚动)扫高度 +// 表头 34 + 行 30×N;这里固定用大数据量表,只改容器高度 +const heights = [500, 400, 300, 250, 200, 180, 160, 150, 140, 130, 120, 110, 100] + +console.log('=== table-big(5000 行,真实滚动)扫容器高度 ===') +for (const h of heights) { + await page.evaluate((hh) => { + document.querySelector('[data-testid="table-big-wrap"]').style.height = `${hh}px` + }, h) + await page.waitForTimeout(450) + await page.locator('[data-testid="table-big-wrap"]').scrollIntoViewIfNeeded() + await page.waitForTimeout(200) + + const g = await page.evaluate(() => { + const wrap = document.querySelector('[data-testid="table-big-wrap"]') + const root = wrap.querySelector('.g3-table') + const inner = wrap.querySelector('.stk-table') + const r = root.getBoundingClientRect() + const rows = [...wrap.querySelectorAll('.stk-tbody-main tr')] + const last = rows[rows.length - 1] + return { + left: Math.round(r.left), + bottom: Math.round(r.bottom), + bodyFill: root.classList.contains('is-body-fill'), + scrollH: inner.scrollHeight, + clientH: inner.clientHeight, + lastRowBottom: last ? Math.round(last.getBoundingClientRect().bottom) : null, + innerBottom: (() => { + const ir = inner.getBoundingClientRect() + return Math.round(ir.bottom) + })(), + } + }) + + const y = Math.max(0, g.bottom - 4) + if (1200 - y < 10) { + console.log(`h=${h} 超出视口`) + continue + } + const px = await clipSample(g.left + 300, y, 1, 10) + const vec = px.map(([r, gg, b]) => (r < 246 || gg < 246 || b < 246 ? 1 : 0)) + const n = vec.reduce((a, b) => a + b, 0) + console.log( + `h=${String(h).padStart(3)} fill=${String(g.bodyFill).padStart(5)} lastRowBottom=${String(g.lastRowBottom).padStart(5)} innerBottom=${String(g.innerBottom).padStart(5)} 底部 ${vec.join('')} (${n}条) ${n === 1 ? '✅' : '❌'}`, + ) +} + +await browser.close() diff --git a/code/g3soft-libs/packages/ui/probe-shadow.mjs b/code/g3soft-libs/packages/ui/probe-shadow.mjs new file mode 100644 index 00000000..86fb5684 --- /dev/null +++ b/code/g3soft-libs/packages/ui/probe-shadow.mjs @@ -0,0 +1,92 @@ +import { chromium } from '@playwright/test' + +/** + * 验证方向:外框底边改用 inset box-shadow(绘制层级高于背景/格线), + * 并去掉"按 bodyFill 摘线"的开关 —— 让外框永远赢。 + * + * 期望:无论容器多高,底边永远只有一条线。 + */ +const BASE = process.env.PROBE_URL || 'http://localhost:5200' +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } }) +await page.goto(BASE, { waitUntil: 'networkidle' }) + +const clipSample = async (x, y, w, h) => { + const shot = await page.screenshot({ clip: { x, y, width: w, height: h } }) + return page.evaluate(async (dataUrl) => { + const img = new Image() + await new Promise((res) => { + img.onload = res + img.src = dataUrl + }) + const cv = document.createElement('canvas') + cv.width = img.width + cv.height = img.height + const ctx = cv.getContext('2d') + ctx.drawImage(img, 0, 0) + const d = ctx.getImageData(0, 0, img.width, img.height).data + const out = [] + for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]]) + return out + }, `data:image/png;base64,${shot.toString('base64')}`) +} + +const applyPatched = () => + page.evaluate(() => { + const style = document.createElement('style') + style.id = 'probe-patch' + style.textContent = ` + /* 外框改 inset box-shadow:层级高于背景与格线,重合也不会变粗 */ + .g3-table.is-bordered { + border-color: transparent !important; + box-shadow: inset 0 0 0 1px var(--g3-border-color, #d9d9d9) !important; + } + /* 同时保留圆角裁剪 */ + .g3-table.is-bordered { border-radius: var(--g3-radius-lg, 8px) !important; } + ` + document.head.appendChild(style) + }) + +const removePatched = () => + page.evaluate(() => { + document.getElementById('probe-patch')?.remove() + }) + +const scan = async (label, h) => { + await page.evaluate((hh) => { + const wrap = document.querySelector('[data-testid="table-wrap"]') + wrap.style.height = `${hh}px` + }, h) + await page.waitForTimeout(450) + const wrapLoc = page.locator('[data-testid="table-wrap"]') + await wrapLoc.scrollIntoViewIfNeeded() + await page.waitForTimeout(180) + + const geo = await page.evaluate(() => { + const wrap = document.querySelector('[data-testid="table-wrap"]') + const root = wrap.querySelector('.g3-table') + const r = root.getBoundingClientRect() + return { left: Math.round(r.left), bottom: Math.round(r.bottom) } + }) + + const y0 = Math.max(0, geo.bottom - 4) + if (1200 - y0 < 8) return + const px = await clipSample(geo.left + 300, y0, 1, 8) + const dark = px.map(([r, g, b]) => (r < 246 || g < 246 || b < 246 ? 1 : 0)) + const count = dark.reduce((a, b) => a + b, 0) + console.log( + `${label} h=${String(h).padStart(3)} ${dark.join('')} → ${count === 1 ? '一条线 ✅' : count === 0 ? '缺线 ❌' : count + ' 条 ❌'}`, + ) +} + +const heights = [400, 140, 130, 129, 128, 127, 126, 124, 120, 100, 80, 60] + +console.log('===== 修复前 =====') +for (const h of heights) await scan('before', h) + +console.log('\n===== 打补丁:外框改 inset box-shadow =====') +await applyPatched() +for (const h of heights) await scan('patch ', h) + +await removePatched() +await browser.close() diff --git a/code/g3soft-libs/packages/ui/probe-sweep.mjs b/code/g3soft-libs/packages/ui/probe-sweep.mjs new file mode 100644 index 00000000..334e2b7f --- /dev/null +++ b/code/g3soft-libs/packages/ui/probe-sweep.mjs @@ -0,0 +1,78 @@ +import { chromium } from '@playwright/test' + +/** + * 复现「表格高度 <= 行高之和 时底部重叠」。 + * 做法:保持 3 行数据不变,把容器高度从大到小扫一遍, + * 记录 is-body-fill 标志、最后一行是否画线、以及外框内底边与最后一行底边的关系。 + */ +const BASE = process.env.PROBE_URL || 'http://localhost:5200' +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } }) +await page.goto(BASE, { waitUntil: 'networkidle' }) + +const heights = [400, 300, 200, 160, 140, 128, 126, 124, 120, 100, 80, 60, 40] + +const rows = [] +for (const h of heights) { + await page.evaluate((hh) => { + const wrap = document.querySelector('[data-testid="table-wrap"]') + wrap.style.height = `${hh}px` + }, h) + await page.waitForTimeout(450) // 等 resize debounce(150ms) + rAF + nextTick + + const r = await page.evaluate(() => { + const wrap = document.querySelector('[data-testid="table-wrap"]') + const root = wrap.querySelector('.g3-table') + const inner = wrap.querySelector('.stk-table') + const cs = getComputedStyle(inner) + const rrc = root.getBoundingClientRect() + const tbody = wrap.querySelector('.stk-tbody-main') + const rowsEls = [...wrap.querySelectorAll('.stk-tbody-main tr')] + const thead = wrap.querySelector('thead') + + const hasBottom = (s) => /linear-gradient\(0deg, rgb/.test(s) + const last = rowsEls[rowsEls.length - 1] + + const available = + inner.clientHeight - (parseFloat(cs.borderTopWidth) || 0) - (parseFloat(cs.borderBottomWidth) || 0) + const headerH = thead ? thead.getBoundingClientRect().height : 0 + const bodyH = rowsEls.reduce((a, tr) => a + tr.getBoundingClientRect().height, 0) + + return { + wrapH: Math.round(wrap.getBoundingClientRect().height), + frameBottom: Math.round(rrc.bottom), + frameInnerBottom: Math.round(rrc.bottom) - 1, + available: Math.round(available), + headerH: Math.round(headerH), + bodyH: Math.round(bodyH), + used: Math.round(headerH + bodyH), + isBodyFill: root.classList.contains('is-body-fill'), + // 最后一行底边 vs 外框内底边 + lastRowBottom: last ? Math.round(last.getBoundingClientRect().bottom) : null, + // 最后一行是否自己画底线 + lastRowDrawsBottom: last + ? [...last.querySelectorAll('td')].some((td) => hasBottom(getComputedStyle(td).backgroundImage)) + : null, + // 最后一行底边是否与外框内底边重合(重合且自己还在画线 = 双线) + lastRowTouchesFrame: last + ? Math.abs(Math.round(last.getBoundingClientRect().bottom) - (Math.round(rrc.bottom) - 1)) <= 2 + : null, + tbodyBottom: tbody ? Math.round(tbody.getBoundingClientRect().bottom) : null, + rowCount: rowsEls.length, + } + }) + rows.push({ ...r, targetH: h }) +} + +console.log( + 'targetH | avail | hdr | body | used | fill | lastRowBottom | frameInner | touches | drawsLine | 判定', +) +for (const r of rows) { + const double = r.lastRowTouchesFrame && r.lastRowDrawsBottom + const verdict = double ? '★双线!' : !r.lastRowDrawsBottom && !r.lastRowTouchesFrame ? '缺线?' : 'ok' + console.log( + `${String(r.targetH).padStart(6)} | ${String(r.available).padStart(5)} | ${String(r.headerH).padStart(3)} | ${String(r.bodyH).padStart(4)} | ${String(r.used).padStart(4)} | ${String(r.isBodyFill).padStart(5)} | ${String(r.lastRowBottom).padStart(13)} | ${String(r.frameInnerBottom).padStart(10)} | ${String(r.lastRowTouchesFrame).padStart(7)} | ${String(r.lastRowDrawsBottom).padStart(9)} | ${verdict}`, + ) +} + +await browser.close() diff --git a/code/g3soft-libs/packages/ui/probe-which.mjs b/code/g3soft-libs/packages/ui/probe-which.mjs new file mode 100644 index 00000000..e65a8dfd --- /dev/null +++ b/code/g3soft-libs/packages/ui/probe-which.mjs @@ -0,0 +1,41 @@ +import { chromium } from '@playwright/test' + +// 直接确认:dev server 吐给浏览器的 G3Table.vue 里,到底有没有新判据。 +const BASE = process.env.PROBE_URL || 'http://localhost:5200' +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } }) + +const seen = [] +page.on('response', async (res) => { + const u = res.url() + if (u.includes('G3Table.vue')) { + try { + const t = await res.text() + seen.push({ + url: u.split('?')[0], + hasNew: t.includes('innerBottom - 1'), + hasOld: t.includes('used + bodyH >= available'), + len: t.length, + }) + } catch {} + } +}) + +await page.goto(BASE, { waitUntil: 'networkidle' }) +await page.waitForTimeout(800) + +console.log('=== G3Table.vue responses seen by browser ===') +console.log(JSON.stringify(seen, null, 2)) + +// 再看页面里 is-body-fill 的实际状态,以及 measureBodyFill 是否存在新逻辑 +const probe = await page.evaluate(() => { + const wrap = document.querySelector('[data-testid="table-wrap"]') + const root = wrap.querySelector('.g3-table') + return { + isBodyFill: root.classList.contains('is-body-fill'), + wrapH: Math.round(wrap.getBoundingClientRect().height), + } +}) +console.log('page state:', JSON.stringify(probe)) + +await browser.close() diff --git a/code/g3soft-libs/packages/ui/src/button/Button.vue b/code/g3soft-libs/packages/ui/src/button/Button.vue index 378b6c12..446b87e6 100644 --- a/code/g3soft-libs/packages/ui/src/button/Button.vue +++ b/code/g3soft-libs/packages/ui/src/button/Button.vue @@ -93,7 +93,13 @@ function handleClick(ev: MouseEvent) { font-family: inherit; font-size: v('font-size', 14px); font-weight: 500; - line-height: v('line-height', 1.5); + /* line-height 用 1 而不是 v('line-height', 1.5): + * 按钮是定高(min-height = control-height)+ align-items:center 的 flex 容器, + * 行盒中心相对基线恒为 (ascent - descent) / 2,与 line-height 取值无关, + * 真正的居中由 align-items 保证。但 1.5 会让行盒比字形高出 0.5em(14px 字号下约 7px), + * 一旦文案换行或撑高,多出来的行距会顶开按钮高度、把整排按钮顶得不齐。 + * 收成 1 让行盒紧贴字形,高度完全由 control-height 决定。 */ + line-height: 1; white-space: nowrap; cursor: pointer; user-select: none; diff --git a/code/g3soft-libs/packages/ui/src/table/G3Table.vue b/code/g3soft-libs/packages/ui/src/table/G3Table.vue index 19a57f04..9bb8c5fe 100644 --- a/code/g3soft-libs/packages/ui/src/table/G3Table.vue +++ b/code/g3soft-libs/packages/ui/src/table/G3Table.vue @@ -336,9 +336,84 @@ function refreshLayout(xOnly = false) { 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)。 * @@ -1378,6 +1453,9 @@ onMounted(() => { }) resizeObserver.observe(rootRef.value) } + + // 首帧量一次:容器尺寸与行区高度都已就位(nextTick 后 DOM 才完整) + nextTick().then(measureBodyFill) }) onBeforeUnmount(() => { @@ -1398,6 +1476,8 @@ watch( () => props.dataSource, (rows) => { rebindActiveEditingRow(rows) + // 行数变化会改变行区高度 → 重新判定底边线归属(空表↔新增行↔铺满) + nextTick().then(measureBodyFill) }, ) @@ -1549,6 +1629,8 @@ const fixedPatchOff = (() => { autoFitWidth ? is('auto-fit-width') : '', editorSeamless ? is('editor-seamless') : '', fixedPatchOff ? 'g3-fixed-patch-off' : '', + // 行区铺满容器时把「最后一行底边线」交给外框(见 measureBodyFill) + bodyFillsHeight ? is('body-fill') : '', ]" data-slot="table" @keydown.capture="handleKeydownCapture" @@ -2360,6 +2442,32 @@ const fixedPatchOff = (() => { user-select: text; } +/** + * 数值列用等宽数字(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; 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 c828ea14..5ac81d68 100644 --- a/code/g3soft-libs/packages/ui/src/table/style/_overrides.scss +++ b/code/g3soft-libs/packages/ui/src/table/style/_overrides.scss @@ -175,6 +175,11 @@ // 外框:只有 `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)}; @@ -213,20 +218,63 @@ .#{e('table', 'inner')}.stk-table.bordered-v { border-left: none; background-image: none; + } +} - // 最右列的外侧竖线同样归外框管,抹掉单元格自己那条(两条紧挨着会被看成 2px)。 - // - // ⚠️ 只能改 --bg-border-right 这一个变量,**不能整体清空 background-image**: - // 底座会给「被前面内容穿透遮挡」的右固定列补 `.fixed-cell--border-left`, - // 用 --bg-border-left 画它左边的分隔线;整份清空会把这根线一起干掉 - // (症状:操作列压住内容却没有分割线)。 - th:last-child, - td: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 画。 +// **判据不能是「是不是最后一行」,而是「这行的底边有没有贴住外框内底边」**—— +// 二者只有行区铺满时才等价。实测(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 { + // ── 贴住内右缘的右固定列:自己的右线让给外框,避免两条线并排。 + // 表头与表体都要处理 —— 实测两处都会画,且都会与外框叠。 + th.fixed-cell--right, + td.fixed-cell--right { --bg-border-right: linear-gradient(transparent, transparent); } + + // ── 空态:底边线归外框。 + // 库的 `.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 滚动时透出下方内容) // --------------------------------------------------------------------------
没有匹配的字段
还没有页签组
+ {{ conditionErrorOf(item) }} +
当前配置没有产生任何查询入口