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 @@ + + + + + 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 @@
+ + + + + + +
+
+
+ +
+
+ + {{ 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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" > - - - - - + + - + - - @@ -284,15 +365,17 @@ const preview = computed(() => { + + @@ -300,7 +383,7 @@ const preview = computed(() => { @@ -308,7 +391,7 @@ const preview = computed(() => { @@ -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" > - - - - - - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + - - - - - - - - + + + - - - - - + + + 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 @@ + + +