diff --git a/code/fms/.workbuddy-ai/memory/2026-09-15.md b/code/fms/.workbuddy-ai/memory/2026-09-15.md new file mode 100644 index 00000000..a5ebb459 --- /dev/null +++ b/code/fms/.workbuddy-ai/memory/2026-09-15.md @@ -0,0 +1,461 @@ +# 2026-09-15 + +## 通读 fms-vue 的 views/module 与 views/base + +纯代码阅读,无改动。结论要点: + +- `views/base` 是业务侧页面,几乎全部靠 `FmsModuleListPage` 按 `data-code` 自渲染,页面本身只留业务规则。 + - `othercompany/index.vue`(768 行):唯一有左侧业务分类树的页面,分类树自己维护(增改删 + 拖拽), + 选中分类 → 拼 `[b_cate_id] IN (...)` 作为 `fixedSearchCondition`(含全部下级分类)。 + - `otherdata/index.vue`(479 行):左树是 `s_module` 子树(根 `base_otherdata`),**分类结构不在本页维护**, + 去「模块管理」改。右侧按选中的 `data` 模块切换数据源,可编辑 + 行拖拽排序(`b_xh`)。 + - `contact/index.vue`(130 行):最简形态,无左树,只有列表 + 权限按钮 + 行删除。 + - `contacts/detail.vue`、`othercompany/detail.vue`:**两个都是空壳**(各 22 行,只有一个空 div + 注释), + 注释明确写了「保存 / 重置 / 删除与表单均尚未实现」。新增/编辑跳转过去是死的。 + +- `views/module` 是配置中心(`meta.adminOnly`),三个页面: + - `module-management/index.vue`(1316 行)编排层,严格按 `开发规范.md` 的「面板式配置页」模式: + 8 对 `xxx` / `xxx_org` ref,面板用 `defineModel` 直读写,跨面板副作用走编排层 watch + (自动编码字段在 edit schema 中自动只读)。拖拽排序即时落库并同步基线。 + - `menu-management/index.vue`(1155 行)同类页面,已重构出 `MenuTreePanel / MenuBasicPanel / ModulePickerModal`, + 但注释与 `ai-plans/TASK1.md` 显示还有 `MenuPowersPanel` 未落地(权限点已移到模块管理维护)。 + - `i18n-type/index.vue`(214 行)最小样板:`FmsModuleListPage` + `FmsModuleEditModal` + 少量业务规则 + (默认语言保护、行内切换自动保存)。**做新的简单字典页应以此页为模板。** + +- 设计器 `module-management/components/design-editor/` 是最大的一块(FormDesignEditor 2786 行、 + useStructureEditing 1012 行)。`TableDesignEditor` 用 `mode` 参数化 `subtable` / `list` 两个场景; + `schemaModel.js` 负责 schema JSON ↔ 编辑器行/分组 的双向转换。 + +## 发现的问题(未修,仅记录) + +- `FormDesignEditor.vue` / `TableDesignEditor.vue` 的文件头注释仍写 `s_field_edit` / `s_field_view` / + `s_field_group`(V1 已废弃的表,V2 统一为 `s_module_schema.b_schema_json`),与实际实现不符,属陈旧注释。 +- `ModuleListConfigPanel` / `ModuleEditPanel` 只是 30~43 行的薄壳,真正的编辑器在 `components/design-editor/`。 + +## 本轮改动 + +### 1. 清理设计器子系统里 V1 表结构的陈旧注释(已改) + +改了 7 个文件的注释:`TableDesignEditor.vue`、`FormDesignEditor.vue`、`useStructureEditing.js`、 +`FormPreview.vue`、`fieldDefaults.js`、`dataChanges.js`、`schemaRender.js`。 +统一改为「对外只读写 `s_module_schema.b_schema_json`,行与分组是编辑器内部草稿」。 + +顺带查实两条**代码事实**(不是注释问题,未改,需用户决定): +- `TableDesignEditor` 的 `mode="subtable"` **已无宿主**(全项目只有 `ModuleListConfigPanel` 用 `mode="list"`); + 与之绑定的 `editMode` ref / `onEditModeChange` / `EDIT_MODE_OPTIONS` / 头部 Segmented 全是死代码 + (`editMode` 只在 `v-if="isSubtable"` 分支渲染,而 isSubtable 恒为 false)。 +- `fieldDefaults.js` 的 `createListConfigRow` **无调用点**(只有 `listRowDefaults` / `queryRowDefaults` / + `createQueryConfigRow` 在用)。按 `开发规范.md`「没有调用点的代码必须删除」应删。 + +### 2. 删除 `ai-plans/TASK1.md`(已删,目录随之为空已移除) + +无其他文件引用它。 + +### 3. 树逻辑抽取调研(只出结论,未改代码) + +全项目宿主树拖拽恰好 3 处,约 55 行核心算法**逐行一致**: +`module-management/index.vue:651`(100 行)、`menu-management/index.vue:212`(107 行)、 +`base/othercompany/index.vue:445`(63 行)。差异只有:树 ref 名、提示文案、类型守卫字段 +(`b_module_type` / `b_menu_type`)、未保存集合、表名、基线同步、失败重载。 + +结论: +- **该抽**,抽成 `@/utils/tree.js` 的纯函数(如 `computeTreeMove`),页面只留落库 + 基线同步 + 重载。 +- **不放 FmsTree**:它 148 行纯展示(字段名映射 + i18n 标题 + 虚拟根),零 API 依赖; + 且 6 个消费方里 3 个只读(`otherdata` / `ModulePickerModal` / `FileCategoryPanel`)。 +- **不放 FmsModuleListPage**:`components/fms-module-list/` 下 grep `tree|b_parent_id|b_depth|b_path|dropPosition` + 零命中,树与列表是并列关系(左树右表),塞进去是倒挂依赖。 +- **不合并 ModuleTreePanel / MenuTreePanel**:两文件 477 行零相同行,且 `开发规范.md` 第 8 条 + 原文点名「模块树与菜单树先各自平铺,等共性真正清晰后再合并」。 + +抽取时要保留的行为差异:`othercompany` 失败也重载("以库里为准"),module/menu 只在失败时重载。 + +### 4. 树拖拽抽取落地(已改,本轮核心) + +在 `fms-vue/src/utils/tree.js` 末尾新增纯函数 **`computeTreeMove(nodes, options)`**(约 95 行含 JSDoc)。 +一次调用完成:成环拦截 → 摘除 → 挂载 → 兄弟重排(`renumberSiblings`)→ 整树重建 `b_depth`/`b_path` +(`rebuildTreePositions`)→ 变化时补写 `b_parent_id`。返回 +`{ ok:true, updatedRows, moved, parentChanged }`,失败返回 `{ ok:false, reason, message }`, +`reason ∈ self | notFound | cycle | notContainer | unsavedTarget`,文案由调用方经 `messages` 传入。 + +入参:`dragKey` / `dropKey` / `dropPosition('before'|'inside'|'after')`, +可选 `typeField` + `nonLeafValues`(inside 落点类型守卫)、`isUnsaved(id)`(草稿临时 id 不能当父级)。 + +**三个调用点已改写,行为保持不变**: +- `module-management/index.vue::onModuleDrop`:100 → 约 50 行(`typeField: b_module_type`) +- `menu-management/index.vue::onMenuDrop`:107 → 约 28 行(`typeField: b_menu_type`) +- `base/othercompany/index.vue::onCategoryDrop`:63 → 约 18 行(无类型守卫,失败也重载) + +三个文件的 `@/utils/tree` import 均已收敛:`addRowUpdate` / `findNodePosition` / +`rebuildTreePositions` / `renumberSiblings` 已从页面移除(它们仍是 `computeTreeMove` 的内部实现)。 + +**踩坑**:`tree.js` 追加函数时多留了一个 `}`(第 412 行),导致 vite 报 +`Failed to parse source for import analysis`,整个 `utils.spec.js` 无法运行。删掉即好。 + +### 5. `computeTreeMove` 单测(已加) + +`tests/unit/utils.spec.js` 新增 `describe('utils/tree computeTreeMove:拖拽移动的守卫与落库行')`,12 个用例, +覆盖 5 条拒绝路径(self / notFound / cycle / notContainer / unsavedTarget)与 +inside 换父、before/after 跨父与同级换序、同组索引修正、无变化时 `updatedRows` 为空。 + +写测试时踩到两个**自己对实现的误解**(代码是对的,断言错了): +- `isLeaf` 只在 `dropNode.children` 为 falsy 时才补空数组并置 false;fixture 里给叶子写了 + `children: []`(truthy)就不会走该分支。要测就造**没有 children 字段**的节点。 +- 同级换序后若 `b_xh` 重排结果与原值相同,`renumberSiblings` 不会产生 update, + 该节点在 `updatedRows` 里查不到(`rowById(...)` 返回 `undefined`),别断言它有值。 + +顺带清了该文件两处既有 lint 问题(unused `vi`、`Folder` 提升为模块级 `FolderIcon`), +测试 helper `makeMoveTree` / `rowById` 也提到模块级以满足 `consistent-function-scoping`。 + +### 环境情况 + +- `pnpm lint`(123 warnings + 3 errors,`no-underscore-dangle`)与 `pnpm fmt:check`(155/293 文件) + **均为既有失败**,不是本轮引入。 +- `pnpm vitest run tests/unit`:10 failed / 144 passed,失败项全是既有问题: + `module-list-utils.spec.js` 导入了不存在的 `@/components/fms-module-list/moduleListUtils`; + `_scratch-freeze.spec.js` 是临时 repro;其余是查询条件 UI 测试(`fms-module-list-page` / + `module-list-components` / `module-pref`)。树相关 4 个 suite 全绿(51 passed)。 +- git 对象库损坏(`git stash` / `git diff` 报 `unable to read ` / `no initial commit`), + 无法用 git 做前后对比,验证时改用「改前改后各跑一次测试」的方式。 +- `vite build` 会被本机 safe-delete 守卫拦下(`SAFE_DELETE_BULK_CONFIRM_REQUIRED`,一次要删 + `dist/assets` 下 672 个文件 > 阈值 50),与代码无关;要验证构建需换输出目录 + (`--outDir dist-verify --emptyOutDir`)。 + +### 6. 新增「系统管理 / 用户管理」路由占位(已改) + +- 新建 `fms-vue/src/views/system/user/index.vue`:**空壳占位页**(只有空 div + 注释), + 待实现内容为左部门树(`b_dept`)+ 右用户列表(`b_user`)。 +- `fms-vue/src/router/routes.js` 路由分组风格**统一为 /module 的写法**(本轮第二稿: + 原先 `/system/user` 用绝对路径、`/base/*` 是散列写法,均已归并): + + ```js + { path: '/base', children: [ { path: 'othercompany', name: 'base-othercompany', ... } ] } + { path: '/module', meta: { adminOnly: true }, children: [ ... ] } + { path: '/system', children: [ { path: 'user', name: 'system-user', ... } ] } + ``` + + 三个分组节点一律:**不写 component、不写 redirect**,子页 path 写相对路径 + (含 `'othercompany/detail'`、`'contact/detail'` 这种两级相对路径,vue-router 支持)。 + 完整路径(`/base/othercompany/detail` 等)与 `name` 全部保持不变 → 详情页内部的跳转、 + `AppSidebar` 里写死的 `/module/*`、`tests/router-menu-guard.spec.js` 的断言都不用改。 + + 去掉 `redirect` 的**理由**(原 `/module` 与 `/system` 都配了 redirect 到首个子页): + 分组只是归类,不该自带「默认落地页」语义 —— 否则直接访问裸前缀 `/module`、`/base` + 会静默落在某个业务页上,形成「菜单里没配、却能进」的第二入口。去掉后裸前缀交给表尾 + `/:pathMatch(.*)*` 兜到工作台,业务入口一律由 `s_menu` + 菜单管理的「路由地址」维护。 + +- 未配 `moduleId`(`/system/user` 无对应 `s_module` 记录,配了会被权限守卫按无权限拦回工作台)。 +- `/module` 的 `meta.adminOnly: true` **保留在分组节点上**(守卫按 `to.matched.some(r => r.meta.adminOnly)` + 判断,分组节点带标记即可覆盖整组子页),`collectMenuRoutes` 也靠它整组排除。 + +踩到的要点(新增页面路由时需遵守): +- `meta.componentName` 必填且全局唯一:页签标签同时是 keep-alive 的 `include` 名。 +- `meta.title` 决定页签是否出现(`AppTabs` 只在有 title 时 `addVisitedTab`);三者缺一页签链路就断。 +- **分组节点绝不能带 component**(哪怕只加一层无 router-view 的壳):`resolve()` 的 `matched` + 会多出一条中间层记录(vue-router 会把中间节点的 path 推进 matched,即使它没有 components), + 一旦该节点有 component,路由层级判断就会变。校验方式: + `resolved.matched.filter(r => r.components).map(r => r.path)` 必须只有 `['/', '<页面完整路径>']`。 +- 分组改造的**回归验证方式**:枚举所有页面 fullPath 跑 `router.resolve()`,断言 + 「路径仍能解析 + `name` 不变 + components 链只有 layout 与页面自身」, + 再加一条「裸前缀不落在任何页面」(`matched.some(r => r.components) === false`)。 + +顺带修了两个**既有 lint error**(都不是本轮引入): +- `src/components/fms-tree/FmsTree.vue:47`:`export const ALL_CATEGORY_KEY` 违反 vue + `no-export-in-script-setup`;该常量**无任何外部引用**(注释已写明"调用方不需要也不应引用"), + 去掉 `export` 即可。 +- `tests/router-auth.spec.js:88`:`const pinia = createTestPinia()` 声明未使用, + `createTestPinia()` 靠副作用生效 → 直接改成表达式语句(不要加 `_` 前缀,eslint 的 + `no-unused-vars` 配置此处不认下划线豁免)。 +- 剩余 1 个 error 是既有问题:`src/components/fms-table/utils/createCellRenderer.old.js:214` + 未使用参数 `editingActive`(`.old.js` 文件本身疑似待删除的残留)。 + +### 7. FmsTree 接管 loading 与「全部分类」虚拟根约定(已改) + +用户提的两点:loading 收到 FmsTree 内部维护;`ALL_CATEGORY_KEY` 交给内部处理。 + +**结论与落地**: + +- **loading**:FmsTree 新增 `loading` prop,内部渲染 `.fms-tree__loading`(绝对定位居中 `Spin` + + 60% 白遮罩),并留 `#loading` 插槽覆盖。**注意是覆盖层而非 v-else**:树始终渲染, + 避免加载完成后尺寸跳动。5 个消费方各自那份 loading 标记与 CSS 全部删除: + `othercompany`(`.oc-tree-panel__loading`)、`otherdata`(`.od-tree-panel__loading`)、 + `FileCategoryPanel`(`.fc-panel__loading`)、`ModuleTreePanel`/`MenuTreePanel`(原 + `.mm-tree-empty`「加载中...」纯文本,且是 `v-if/v-else` 替换式,已一并统一为遮罩)。 + +- **虚拟根选中约定**:FmsTree 内部完成 `[]` ⇄ 虚拟根的双向映射 —— + 传入空选中键 → 渲染时补成 `[rootKey]` 让根高亮;`select` 事件里点中根 → 回传 `[]`。 + 调用方用「falsy 的选中键」表达「全部」,**不再需要 import 或书写 `__fms_tree_root__`**。 + 选这个方案(而不是 `v-model:selected-keys`)的依据:5 个消费方里已有 4 个本来就用 + falsy 值表示「未选中」(`otherdata` 用 `''`、`FileCategoryPanel` 用 `''`、 + 两个 TreePanel 用 `null`),且都用同一个表达式 `x ? [String(x)] : []` 派生; + 方案一只是把这个既有约定泛化,`v-model` 反而与两个纯展示型 TreePanel 的定位冲突。 + 常量随之改为 `FmsTree` 内部 local const(**不能 export**:` @@ -31,15 +25,13 @@ function onSelect(keys, info) { -
- -
@@ -56,7 +48,6 @@ function onSelect(keys, info) { } .fc-panel__tree { - position: relative; flex: 1; min-height: 0; overflow: auto; @@ -72,12 +63,4 @@ function onSelect(keys, info) { text-overflow: ellipsis; white-space: nowrap; } - -.fc-panel__loading { - position: absolute; - inset: 0; - display: grid; - place-items: center; - background: rgba(255, 255, 255, 0.6); -} diff --git a/code/fms/fms-vue/src/components/fms-module-common/fieldUtils.js b/code/fms/fms-vue/src/components/fms-module-common/fieldUtils.js index a8d7f7a2..bb192c88 100644 --- a/code/fms/fms-vue/src/components/fms-module-common/fieldUtils.js +++ b/code/fms/fms-vue/src/components/fms-module-common/fieldUtils.js @@ -54,8 +54,13 @@ export function formatDateValue(value, withTime = false) { if (value && typeof value.format === 'function') { return value.format(withTime ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD') } - const text = String(value).replace('T', ' ') - if (/^\d{4}-\d{2}-\d{2}/.test(text)) return withTime ? text.slice(0, 19) : text.slice(0, 10) + // 后端统一输出「显示格式」的墙钟串:date → "2026-09-15",datetime → "2026-09-15 21:16:47" + // (见后端 DbUtils.normalizeJdbcValue,与旧项目读取 ResultSet 时格式化的做法一致)。 + // 因此这里基本是透传:格式化只兜底非标准输入(前端本地数据、数字时间戳、ISO 串等)。 + const text = String(value) + if (/^\d{4}-\d{2}-\d{2}/.test(text)) { + return withTime ? text.slice(0, 19).replace('T', ' ') : text.slice(0, 10) + } const date = new Date(value) if (Number.isNaN(date.getTime())) return text const datePart = `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}` @@ -63,3 +68,13 @@ export function formatDateValue(value, withTime = false) { ? `${datePart} ${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}:${padDatePart(date.getSeconds())}` : datePart } + +/** + * 当前本地时间 → "YYYY-MM-DD HH:mm:ss"(写入用)。 + * 与库中 datetime2、接口输出的语义对齐(本地墙钟值,不走 UTC), + * 避免同一张表出现「库里存 21:16、前端却写 13:16」两种时间基准。 + */ +export function formatLocalDateTime(date = new Date()) { + const datePart = `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}` + return `${datePart} ${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}:${padDatePart(date.getSeconds())}` +} diff --git a/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js b/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js index ce2f181a..167708a8 100644 --- a/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js +++ b/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js @@ -61,7 +61,7 @@ function collectFieldNodes(nodes) { /** * view JSON → 列表配置行(列表页/表格消费)。 - * 返回结构与旧 s_field_view 行兼容,便于复用既有表格渲染。 + * 行结构沿用旧版列表配置行的字段名(b_field / b_width / b_visible / b_xh),便于复用既有表格渲染。 */ export function buildListConfig(viewSchema) { return collectFieldNodes(viewSchema?.columns || []).map((node, index) => ({ diff --git a/code/fms/fms-vue/src/components/fms-module-list/useModulePref.js b/code/fms/fms-vue/src/components/fms-module-list/useModulePref.js index f16a3fc4..1eac503a 100644 --- a/code/fms/fms-vue/src/components/fms-module-list/useModulePref.js +++ b/code/fms/fms-vue/src/components/fms-module-list/useModulePref.js @@ -5,6 +5,7 @@ // 布局只能调整区域与顺序,不得放宽字段权限——合并后仍按字段权限裁剪(开发规范 通用原则 10)。 import { loadDataApi, saveObjectApi } from '@/services/api' import { escapeSqlText } from '@/components/fms-module-common/queryUtils' +import { formatLocalDateTime } from '@/components/fms-module-common/fieldUtils' import { useAuthStore } from '@/stores/auth' const SCHEMA_TYPE = 'query' @@ -123,7 +124,7 @@ export async function saveModulePref(userId, moduleId, pref, rowExists) { b_schema_type: SCHEMA_TYPE, b_schema_json: JSON.stringify(pref), b_updated_by: userId, - b_updated_at: new Date().toISOString().slice(0, 19).replace('T', ' '), + b_updated_at: formatLocalDateTime(), } const request = { table: USER_PREF_TABLE, key_field: USER_PREF_KEY } if (rowExists) request.updates = [row] diff --git a/code/fms/fms-vue/src/components/fms-tree/FmsTree.vue b/code/fms/fms-vue/src/components/fms-tree/FmsTree.vue index 4b192a53..f6401df7 100644 --- a/code/fms/fms-vue/src/components/fms-tree/FmsTree.vue +++ b/code/fms/fms-vue/src/components/fms-tree/FmsTree.vue @@ -5,11 +5,18 @@ * 渲染、交互、拖拽、右键菜单、键盘导航、无障碍全部由 ui/Tree 承担;本组件只做 FMS 业务约定适配: * 1. 默认字段名 b_id / b_name / children,调用方不必重复书写 fieldNames; * 2. 节点标题按 b_i18n 翻译(translate(b_i18n, b_name || b_code || b_id)),关闭 translateTitle 则直接取原值; - * 3. 可选「全部分类」虚拟根节点(showRoot),默认展开、可折叠,调用方无需感知 rootKey。 + * 3. 可选「全部分类」虚拟根节点(showRoot),默认展开、可折叠;调用方只以「选中键为空」 + * 表达「选中了根」,不需要知道 rootKey,详见下方「虚拟根的选中约定」; + * 4. 加载态(loading)由本组件统一渲染遮罩,调用方不必各自写 Spin 与定位样式。 * * 除下表声明的 props 外,其余 props 与事件(draggable / filter / disabled / contextMenu / * selectedKeys / checkedKeys / lazy / v-model:selected-keys …)经 $attrs 原样透传给 ui/Tree, - * 事件名与 ui/Tree 保持一致。 + * 事件名与 ui/Tree 保持一致;其中 select 事件额外做了一层归一化,见下。 + * + * 虚拟根的选中约定(showRoot 时): + * - 传入:selectedKeys 为 [] / 空值时,本组件自动把虚拟根当作选中项渲染(高亮「全部分类」); + * - 传出:用户点中虚拟根时,select 事件回传的 keys 是 [](不是 [rootKey])。 + * 于是调用方只需用「falsy 的选中键」表达「全部」这一个概念,无需 import rootKey、也无需再判 isFmsRoot。 * * 注意:本组件始终向 ui/Tree 提供 #title 插槽(默认渲染翻译后的标题),因此 ui/Tree 内置的 * filter 关键词高亮不再生效(过滤本身照常生效);需要高亮时在 #title 插槽内自行实现。 @@ -17,6 +24,7 @@ * 用法: * * */ -import { computed, ref } from 'vue' -import { Tree } from '@/components/ui' +import { computed, ref, useAttrs } from 'vue' +import { Spin, Tree } from '@/components/ui' import { translate } from '@/stores/i18n' defineOptions({ inheritAttrs: false }) +/** 未声明的 ui/Tree props(含 selectedKeys)经 $attrs 透传,归一化时需读取原值 */ +const attrs = useAttrs() + +/** 虚拟根节点 key:纯内部实现细节,调用方用「空选中键」表达选中根,不需要也不应引用它 */ +const ALL_CATEGORY_KEY = '__fms_tree_root__' + const props = defineProps({ /** 业务树数据(b_id / b_name / children) */ treeData: { type: Array, default: () => [] }, @@ -45,9 +59,11 @@ const props = defineProps({ /** 是否注入「全部分类」虚拟根节点 */ showRoot: { type: Boolean, default: false }, /** 虚拟根节点 key */ - rootKey: { type: [String, Number], default: '__fms_tree_root__' }, + rootKey: { type: [String, Number], default: ALL_CATEGORY_KEY }, /** 虚拟根节点标题,空则取多语言键 common.allCategories */ rootTitle: { type: String, default: '' }, + /** 加载态:树区域盖一层居中 Spin 遮罩(懒加载子节点的局部 loading 仍由 ui/Tree 负责) */ + loading: { type: Boolean, default: false }, /** * 展开键。undefined 表示非受控:无虚拟根时原样交给 ui/Tree(default-expand-all 等初始展开配置照常生效), * 有虚拟根时由本组件用内部状态维护,并照常抛出 update:expandedKeys。 @@ -55,7 +71,7 @@ const props = defineProps({ expandedKeys: { type: Array, default: undefined }, }) -const emit = defineEmits(['update:expandedKeys']) +const emit = defineEmits(['update:expandedKeys', 'select']) /** showRoot 且调用方未受控时,本组件用内部状态持有展开键(始终不含 rootKey) */ const internalExpandedKeys = ref([]) @@ -102,6 +118,32 @@ const rootCollapsed = ref(false) const hasRootKey = (keys) => keys.some((key) => String(key) === String(props.rootKey)) +/** + * 选中键归一化(仅 showRoot 时生效): + * 传入 [] / 空值 → 补成 [rootKey],让虚拟根呈现选中态,调用方用「空」表达「选中全部」; + * 传入非空数组 → 原样透传,业务节点的选中完全由调用方决定。 + * 只补虚拟根这一个 key,不会顺手选中任何业务节点。 + * + * 注意 selectedKeys 与其他 ui/Tree props 一样走 $attrs(本组件未声明它), + * 因此这里必须从 attrs 取值;若从 props 取会永远拿到 undefined, + * 从而把调用方传的真实选中项覆盖成虚拟根。 + */ +const effectiveSelectedKeys = computed(() => { + const keys = attrs.selectedKeys + if (!props.showRoot) return keys + if (keys && keys.length) return keys + return [props.rootKey] +}) + +/** + * select 事件归一化:点中虚拟根时回传 [] 而非 [rootKey]。 + * 与 effectiveSelectedKeys 是一对镜像处理,保证调用方永远看不到 rootKey。 + */ +function onSelect(keys, info) { + const isRoot = info?.node?.isFmsRoot || hasRootKey(keys) + emit('select', isRoot ? [] : keys, info) +} + /** * 虚拟根默认展开(调用方的展开键里不需要出现 rootKey); * 用户收起根之后不再强制补回,避免箭头点不动。 @@ -128,21 +170,46 @@ function onExpandedKeysChange(keys) { + + diff --git a/code/fms/fms-vue/src/components/ui/form/FormGroup.vue b/code/fms/fms-vue/src/components/ui/form/FormGroup.vue index 05604cb3..a7937507 100644 --- a/code/fms/fms-vue/src/components/ui/form/FormGroup.vue +++ b/code/fms/fms-vue/src/components/ui/form/FormGroup.vue @@ -169,10 +169,20 @@ function toggleCollapse() { grid-template-rows: 0fr; } -/* 行高被压缩时子项要真能收起来:min-height: 0(允许被压)+ overflow: hidden(裁掉溢出) */ +/* 行高被压缩时子项要真能收起来:min-height: 0(允许被压)+ 裁剪(裁掉溢出)。 + 展开态用 clip + 外扩 4px 裁剪边界:输入框的聚焦光环是向外扩散 3px 的 box-shadow, + 紧贴网格边缘的一侧会被 overflow: hidden 削掉(最左列削左边、首末行削上下)。 + overflow: hidden 在前作降级:不支持 clip 的浏览器仍能正常折叠,只是光环照旧被裁 */ .fms-form-group__collapse > * { min-height: 0; overflow: hidden; + overflow: clip; + overflow-clip-margin: 4px; +} + +/* 折叠态必须收干净:外扩的裁剪边界会让 0fr 压下去的内容再露出 4px */ +.fms-form-group__collapse.is-collapsed > * { + overflow: hidden; } /* ========== 组内宫格 ========== */ diff --git a/code/fms/fms-vue/src/components/ui/tree/index.scss b/code/fms/fms-vue/src/components/ui/tree/index.scss index 9f2e341a..47cb8530 100644 --- a/code/fms/fms-vue/src/components/ui/tree/index.scss +++ b/code/fms/fms-vue/src/components/ui/tree/index.scss @@ -148,22 +148,9 @@ padding: 0 1px; } -/* ---------- 拖拽手柄(在最左侧) ---------- */ -.tree-node-grip { - display: inline-flex; - align-items: center; - flex: none; - width: var(--fms-tree-grip-size); - height: var(--fms-tree-grip-size); - margin-right: 4px; - color: #b0b7c3; - cursor: grab; - /* 从手柄按下时不选中行文本(拖拽由 mousedown 启动,不能 preventDefault) */ - user-select: none; -} -.tree-node-grip:active { - cursor: grabbing; -} +/* ---------- 拖拽 ---------- + 不再设手柄:整行长按发起(见 tree-node.vue 的 onRowMouseDown)。 + 行内因此不需要为拖拽预留任何列,横向空间全部留给缩进、箭头与标题。 */ /* ---------- 拖拽落点指示:圆点 + 连接线 ---------- */ .tree-node.drop-before::before, diff --git a/code/fms/fms-vue/src/components/ui/tree/tree-node.vue b/code/fms/fms-vue/src/components/ui/tree/tree-node.vue index 7f3c692c..b7c4486e 100644 --- a/code/fms/fms-vue/src/components/ui/tree/tree-node.vue +++ b/code/fms/fms-vue/src/components/ui/tree/tree-node.vue @@ -1,6 +1,6 @@ + + + + diff --git a/code/fms/fms-vue/src/views/system/log/login/index.vue b/code/fms/fms-vue/src/views/system/log/login/index.vue new file mode 100644 index 00000000..f1881319 --- /dev/null +++ b/code/fms/fms-vue/src/views/system/log/login/index.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/code/fms/fms-vue/src/views/system/log/technical/index.vue b/code/fms/fms-vue/src/views/system/log/technical/index.vue new file mode 100644 index 00000000..cc58af3d --- /dev/null +++ b/code/fms/fms-vue/src/views/system/log/technical/index.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/code/fms/fms-vue/src/views/system/user/DeptTreePanel.vue b/code/fms/fms-vue/src/views/system/user/DeptTreePanel.vue new file mode 100644 index 00000000..96e9ad42 --- /dev/null +++ b/code/fms/fms-vue/src/views/system/user/DeptTreePanel.vue @@ -0,0 +1,519 @@ + + + + + diff --git a/code/fms/fms-vue/src/views/system/user/index.vue b/code/fms/fms-vue/src/views/system/user/index.vue new file mode 100644 index 00000000..d0088e17 --- /dev/null +++ b/code/fms/fms-vue/src/views/system/user/index.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/code/fms/fms-vue/tests/components/fms-tree.spec.js b/code/fms/fms-vue/tests/components/fms-tree.spec.js index 23b4b6ce..ce4c09fc 100644 --- a/code/fms/fms-vue/tests/components/fms-tree.spec.js +++ b/code/fms/fms-vue/tests/components/fms-tree.spec.js @@ -118,9 +118,9 @@ describe('FmsTree 虚拟根(showRoot)', () => { const rows = wrapper.findAll('.tree-node') expect(rows).toHaveLength(2) expect(rows[0].text()).toContain('全部分类') - // 有子节点:根不是叶子,渲染真实折叠箭头(不是 is-placeholder 占位) + // 有子节点:根不是叶子,渲染折叠箭头 expect(rows[0].classes()).not.toContain('is-leaf') - expect(rows[0].find('.tree-node-switcher:not(.is-placeholder)').exists()).toBe(true) + expect(rows[0].find('.tree-node-switcher').exists()).toBe(true) // 调用方的 expandedKeys 为空,根的子节点依然可见 expect(rows[1].text()).toContain('根节点') }) @@ -130,7 +130,7 @@ describe('FmsTree 虚拟根(showRoot)', () => { const rows = wrapper.findAll('.tree-node') expect(rows).toHaveLength(1) expect(rows[0].classes()).not.toContain('is-leaf') - expect(rows[0].find('.tree-node-switcher:not(.is-placeholder)').exists()).toBe(true) + expect(rows[0].find('.tree-node-switcher').exists()).toBe(true) }) it('根节点以 rootKey 作为 key,业务节点不带根 key', () => { @@ -161,19 +161,19 @@ describe('FmsTree 虚拟根(showRoot)', () => { expect(wrapper.findAll('.tree-node')).toHaveLength(2) // 第一个真实展开按钮属于虚拟根:点击收起整棵树 - await wrapper.find('.tree-node-switcher:not(.is-placeholder)').trigger('click') + await wrapper.find('.tree-node-switcher').trigger('click') await nextTick() expect(wrapper.findAll('.tree-node')).toHaveLength(1) expect(wrapper.emitted('update:expandedKeys')[0][0]).not.toContain('__fms_tree_root__') // 再点一次展开,业务节点回来 - await wrapper.find('.tree-node-switcher:not(.is-placeholder)').trigger('click') + await wrapper.find('.tree-node-switcher').trigger('click') await nextTick() expect(wrapper.findAll('.tree-node')).toHaveLength(2) expect(wrapper.find('.tree-node-content').text()).toBe('全部分类') // 业务节点自己的展开按钮照常工作 - await wrapper.findAll('.tree-node-switcher:not(.is-placeholder)')[1].trigger('click') + await wrapper.findAll('.tree-node-switcher')[1].trigger('click') await nextTick() expect(wrapper.findAll('.tree-node')).toHaveLength(3) }) @@ -184,6 +184,96 @@ describe('FmsTree 虚拟根(showRoot)', () => { }) }) +describe('FmsTree 虚拟根的选中约定(showRoot)', () => { + it('selectedKeys 为空时虚拟根呈现选中态,调用方无需传 rootKey', () => { + const wrapper = mount(FmsTree, { + props: { treeData: makeTreeData(), showRoot: true, selectedKeys: [] }, + }) + // 第一个节点是虚拟根,应带上选中类 + expect(wrapper.findAll('.tree-node')[0].classes()).toContain('is-selected') + }) + + it('未传 selectedKeys 时同样把虚拟根当作选中(默认选中「全部」)', () => { + const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), showRoot: true } }) + expect(wrapper.findAll('.tree-node')[0].classes()).toContain('is-selected') + }) + + it('选中业务节点时虚拟根不再高亮', () => { + const wrapper = mount(FmsTree, { + props: { treeData: makeTreeData(), showRoot: true, selectedKeys: ['1'] }, + }) + const rows = wrapper.findAll('.tree-node') + expect(rows[0].classes()).not.toContain('is-selected') + expect(rows[1].classes()).toContain('is-selected') + }) + + it('点中虚拟根时 select 回传空数组,调用方看不到 rootKey', async () => { + const onSelect = vi.fn() + const wrapper = mount(FmsTree, { + props: { treeData: makeTreeData(), showRoot: true }, + attrs: { onSelect }, + }) + await wrapper.findAll('.tree-node-content')[0].trigger('click') + expect(onSelect).toHaveBeenCalledTimes(1) + // 归一化成 [],而不是 ['__fms_tree_root__'] + expect(onSelect.mock.calls[0][0]).toEqual([]) + }) + + it('点中业务节点时 select 原样回传该节点的 key', async () => { + const onSelect = vi.fn() + const wrapper = mount(FmsTree, { + props: { treeData: makeTreeData(), showRoot: true }, + attrs: { onSelect }, + }) + await wrapper.findAll('.tree-node-content')[1].trigger('click') + expect(onSelect.mock.calls[0][0]).toEqual(['1']) + }) + + it('无虚拟根时不改写 selectedKeys,也不改写 select 回传值', async () => { + const onSelect = vi.fn() + const wrapper = mount(FmsTree, { + props: { treeData: makeTreeData(), selectedKeys: [] }, + attrs: { onSelect }, + }) + expect(wrapper.find('.tree-node').classes()).not.toContain('is-selected') + + await wrapper.find('.tree-node-content').trigger('click') + expect(onSelect.mock.calls[0][0]).toEqual(['1']) + }) +}) + +describe('FmsTree 加载态(loading)', () => { + it('loading=false 时不渲染遮罩', () => { + const wrapper = mount(FmsTree, { props: { treeData: makeTreeData() } }) + expect(wrapper.find('.fms-tree__loading').exists()).toBe(false) + }) + + it('loading=true 时渲染遮罩,且树仍在渲染(不是 v-else 替换)', () => { + const wrapper = mount(FmsTree, { + props: { treeData: makeTreeData(), loading: true }, + }) + expect(wrapper.find('.fms-tree__loading').exists()).toBe(true) + // 遮罩是覆盖层,业务节点照常存在,避免加载完成后尺寸跳动 + expect(wrapper.find('.tree-node-content').text()).toBe('根节点') + }) + + it('#loading 插槽可覆盖默认的 Spin', () => { + const wrapper = mount(FmsTree, { + props: { treeData: makeTreeData(), loading: true }, + slots: { loading: () => h('span', { class: 'my-loading' }, '载入中') }, + }) + expect(wrapper.find('.my-loading').text()).toBe('载入中') + }) + + it('loading=false 时 #loading 插槽不渲染', () => { + const wrapper = mount(FmsTree, { + props: { treeData: makeTreeData(), loading: false }, + slots: { loading: () => h('span', { class: 'my-loading' }, '载入中') }, + }) + expect(wrapper.find('.my-loading').exists()).toBe(false) + }) +}) + describe('FmsTree 事件透传', () => { it('select 事件原样透传给调用方', async () => { const onSelect = vi.fn() diff --git a/code/fms/fms-vue/tests/components/tree.spec.js b/code/fms/fms-vue/tests/components/tree.spec.js index bb2695f5..e061a299 100644 --- a/code/fms/fms-vue/tests/components/tree.spec.js +++ b/code/fms/fms-vue/tests/components/tree.spec.js @@ -1,4 +1,5 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect, vi, afterEach } from 'vitest' +import { nextTick } from 'vue' import { mount } from '@vue/test-utils' import Tree from '@/components/ui/tree/tree.vue' @@ -91,27 +92,103 @@ describe('Tree 右键菜单', () => { describe('Tree 行内元素顺序', () => { /** - * 回归点:箭头槽(非叶为真箭头、叶子为等宽占位)必须排在拖拽手柄之前。 - * 放到手柄之后时,叶子行的手柄与标题之间会空出一整个箭头宽,读起来像多余空隙; - * 排在前面则这段空白归入左侧缩进带,手柄紧跟标题(见 tree-node.vue 模板注释)。 + * 回归点:行内只有「箭头槽 → 标题」,不再有拖拽手柄列 —— + * 拖拽改由长按整行发起,行与行之间不存在「有手柄 / 无手柄」的宽度差异。 + * 箭头槽是全行唯一的固定槽:非叶为真箭头、叶子为等宽占位, + * 保证同层标题左边界一致(去掉叶子占位会让叶子标题左移一格)。 */ const rowClasses = (row) => [...row.element.children].map((el) => el.className) - it('拖拽开启时:箭头槽在拖拽手柄之前', () => { + it('非叶行:箭头槽在标题之前,且不渲染拖拽手柄', () => { const wrapper = mount(Tree, { props: { treeData, fieldNames, draggable: true } }) const parentRow = wrapper.findAll('.tree-node')[0] - expect(rowClasses(parentRow)).toEqual(['tree-node-switcher', 'tree-node-grip', 'tree-node-content']) + expect(rowClasses(parentRow)).toEqual(['tree-node-switcher', 'tree-node-content']) }) - it('叶子行的占位与手柄顺序一致,保证标题横坐标与非叶兄弟相同', () => { + it('叶子行渲染等宽箭头占位,标题与非叶兄弟对齐', () => { const wrapper = mount(Tree, { props: { treeData, fieldNames, draggable: true, defaultExpandedKeys: ['1'] }, }) const childRow = wrapper.findAll('.tree-node')[1] - expect(rowClasses(childRow)).toEqual([ - 'tree-node-switcher is-placeholder', - 'tree-node-grip', - 'tree-node-content', - ]) + expect(rowClasses(childRow)).toEqual(['tree-node-switcher is-placeholder', 'tree-node-content']) + }) + + it('不可拖拽的节点与可拖拽节点行内结构一致', () => { + const wrapper = mount(Tree, { + props: { + fieldNames, + draggable: true, + defaultExpandedKeys: ['1'], + treeData: [ + { + b_id: '1', + b_name: '全部分类', + draggable: false, + children: [{ b_id: '2', b_name: '子节点' }], + }, + ], + }, + }) + const rows = wrapper.findAll('.tree-node') + expect(rowClasses(rows[0])).toEqual(['tree-node-switcher', 'tree-node-content']) + expect(rowClasses(rows[1])).toEqual(['tree-node-switcher is-placeholder', 'tree-node-content']) + }) +}) + +describe('Tree 长按拖拽', () => { + const rowOf = (wrapper, index = 0) => wrapper.findAll('.tree-node')[index] + const press = (row) => row.trigger('mousedown', { button: 0, clientX: 0, clientY: 0 }) + + afterEach(() => vi.useRealTimers()) + + it('长按到位后才置为可拖拽,短按保持普通行', async () => { + vi.useFakeTimers() + const wrapper = mount(Tree, { props: { treeData, fieldNames, draggable: true } }) + const row = rowOf(wrapper) + + await press(row) + // 未到长按时长:仍是普通行,短按/双击/多选点击的语义不受影响 + expect(row.attributes('draggable')).toBe('false') + + vi.advanceTimersByTime(400) + await nextTick() + expect(row.attributes('draggable')).toBe('true') + wrapper.unmount() + }) + + it('长按期间位移超过余量则放弃本次拖拽', async () => { + vi.useFakeTimers() + const wrapper = mount(Tree, { props: { treeData, fieldNames, draggable: true } }) + const row = rowOf(wrapper) + + await press(row) + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 40, clientY: 0 })) + vi.advanceTimersByTime(400) + await nextTick() + expect(row.attributes('draggable')).toBe('false') + wrapper.unmount() + }) + + it('未开启 draggable 或节点标记 draggable:false 时不进入拖拽', async () => { + vi.useFakeTimers() + const plain = mount(Tree, { props: { treeData, fieldNames } }) + await press(rowOf(plain)) + vi.advanceTimersByTime(500) + await nextTick() + expect(rowOf(plain).attributes('draggable')).toBe('false') + plain.unmount() + + const locked = mount(Tree, { + props: { + fieldNames, + draggable: true, + treeData: [{ b_id: '1', b_name: '根节点', draggable: false, children: [] }], + }, + }) + await press(rowOf(locked)) + vi.advanceTimersByTime(500) + await nextTick() + expect(rowOf(locked).attributes('draggable')).toBe('false') + locked.unmount() }) }) diff --git a/code/fms/fms-vue/tests/router-auth.spec.js b/code/fms/fms-vue/tests/router-auth.spec.js index db2b35e9..386b9427 100644 --- a/code/fms/fms-vue/tests/router-auth.spec.js +++ b/code/fms/fms-vue/tests/router-auth.spec.js @@ -85,7 +85,7 @@ describe('路由守卫 权限加载(三场景)', () => { JSON.stringify({ userInfo: { orgId: 'ORG', account: 'u1' } }), ) // 用 createTestPinia(内部已 install,persist 插件真实注册),等价于刷新页面 - const pinia = createTestPinia() + createTestPinia() const a = useAuthStore() // 首次创建 await a.$hydrate() // 显式从 localStorage 恢复(等价于刷新页面) expect(useAuthStore().isAuthenticated).toBe(true) diff --git a/code/fms/fms-vue/tests/unit/module-fields-panel-participation.spec.js b/code/fms/fms-vue/tests/unit/module-fields-panel-participation.spec.js index 25f2c978..b28305b4 100644 --- a/code/fms/fms-vue/tests/unit/module-fields-panel-participation.spec.js +++ b/code/fms/fms-vue/tests/unit/module-fields-panel-participation.spec.js @@ -118,4 +118,19 @@ describe('ModuleFieldsPanel 参与勾选', () => { expect('__query' in stripped).toBe(false) wrapper.unmount() }) + + /** + * 回归点:b_i18n 是派生值(field.{模块编码}.{字段名}),加载阶段不得自动补齐。 + * 补了会让草稿与基线不一致,表现为「打开模块什么都没改,切换节点却提示有未保存的修改」。 + * 它只在用户编辑(onCellChange)与父级保存前(beforeSave)物化。 + */ + it('模块加载后不自动派生 b_i18n,草稿保持与库中一致', async () => { + setActivePinia(createPinia()) + const { wrapper, localFields } = mountPanel() + await settle() + expect(localFields[0].b_i18n).toBeNull() + expect(localFields[1].b_i18n).toBeNull() + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + wrapper.unmount() + }) }) diff --git a/code/fms/fms-vue/tests/unit/module-list-utils.spec.js b/code/fms/fms-vue/tests/unit/module-list-utils.spec.js index b697e853..4e5557b6 100644 --- a/code/fms/fms-vue/tests/unit/module-list-utils.spec.js +++ b/code/fms/fms-vue/tests/unit/module-list-utils.spec.js @@ -1,12 +1,13 @@ import { describe, expect, it } from 'vitest' -import { - buildQueryCondition, - defaultQueryValue, - formatDateValue, - parseOptions, -} from '@/components/fms-module-list/moduleListUtils' +import { buildConditionSql, defaultQueryValue } from '@/components/fms-module-common/queryUtils' +import { formatDateValue, parseOptions } from '@/components/fms-module-common/fieldUtils' import { setMessages, translate } from '@/stores/i18n' +/** buildConditionSql 需要按字段名查到字段定义,b_type 决定字面量形式(数字裸写 / 文本 N'...') */ +function fieldMapOf(...fields) { + return new Map(fields.map((field) => [field.b_field, field])) +} + function item(over = {}) { return { field: { b_field: 'amount', b_type: 'money', b_options: '' }, @@ -24,26 +25,31 @@ describe('fms-module-list query utilities', () => { }) it('builds numeric and escaped text conditions', () => { - expect(buildQueryCondition(item(), 12.5)).toBe('[amount] = 12.5') expect( - buildQueryCondition( - item({ field: { b_field: 'name', b_type: 'input' }, config: { b_operator: 'like' } }), - "O'Reilly", + buildConditionSql( + { field: 'amount', operator: 'eq', value: 12.5 }, + fieldMapOf({ b_field: 'amount', b_type: 'money' }), + ), + ).toBe('[amount] = 12.5') + expect( + buildConditionSql( + { field: 'name', operator: 'like', value: "O'Reilly" }, + fieldMapOf({ b_field: 'name', b_type: 'input' }), ), ).toBe("[name] LIKE N'%O''Reilly%'") }) it('builds range and IN conditions while ignoring empty values', () => { expect( - buildQueryCondition( - item({ field: { b_field: 'created', b_type: 'date' }, config: { b_operator: 'between' } }), - ['2026-01-01', '2026-01-31'], + buildConditionSql( + { field: 'created', operator: 'between', value: ['2026-01-01', '2026-01-31'] }, + fieldMapOf({ b_field: 'created', b_type: 'date' }), ), ).toBe("[created] BETWEEN N'2026-01-01' AND N'2026-01-31'") expect( - buildQueryCondition( - item({ field: { b_field: 'status', b_type: 'input' }, config: { b_operator: 'in' } }), - ['a', '', 'b'], + buildConditionSql( + { field: 'status', operator: 'in', value: ['a', '', 'b'] }, + fieldMapOf({ b_field: 'status', b_type: 'input' }), ), ).toBe("[status] IN (N'a', N'b')") }) @@ -68,4 +74,18 @@ describe('fms-module-list query utilities', () => { expect(formatDateValue('2026-08-01T12:34:56Z')).toMatch(/^2026-08-01/) expect(formatDateValue(null)).toBe('') }) + + /** + * 时间契约:后端统一输出「显示格式」的墙钟串(见后端 DbUtils.normalizeJdbcValue, + * 与旧项目读取 ResultSet 时格式化的做法一致): + * date → "2026-09-15",datetime → "2026-09-15 21:16:47",即拿即显示、不做时区换算。 + * 展示层对标准输入应为透传;仅非标准输入(数字时间戳等)走 Date 兜底。 + */ + it('passes through display-format strings without timezone math', () => { + expect(formatDateValue('2026-09-15 21:16:47', true)).toBe('2026-09-15 21:16:47') + expect(formatDateValue('2026-09-15 21:16:47', false)).toBe('2026-09-15') + expect(formatDateValue('2026-09-15', false)).toBe('2026-09-15') + // 非日期开头的输入(数字时间戳等)仍走 Date 兜底,按本地时间格式化 + expect(formatDateValue(1789471007000, true)).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/) + }) }) diff --git a/code/fms/fms-vue/tests/unit/othercompany-utils.spec.js b/code/fms/fms-vue/tests/unit/othercompany-utils.spec.js index c02c43b6..59119486 100644 --- a/code/fms/fms-vue/tests/unit/othercompany-utils.spec.js +++ b/code/fms/fms-vue/tests/unit/othercompany-utils.spec.js @@ -1,9 +1,10 @@ /** * 往来单位分类树的页面级纯函数(仍内联在 views/base/othercompany/index.vue)。 * - * 建树、位置派生、排序号重排、整树重建等通用逻辑已收敛到 @/utils/tree, - * 其测试在 tests/unit/utils.spec.js;本文件只覆盖留在页面里的两个业务判定, - * 它们内联在页面中,因此通过挂载页面组件访问