20260915170028

This commit is contained in:
oneao committed 2026-09-15 17:00:29 +08:00
1 parent d26afb5fec
commit 1f56cc3efb
89 files changed
+1135 -397

No files matched your search

+185
View File
@@ -0,0 +1,185 @@
# CODEBUDDY.md
This file provides guidance to CodeBuddy Code when working with code in this repository.
## 仓库结构
| 目录 | 说明 |
| --- | --- |
| `fms-api/` | 后端,Spring Boot 4.1 + Java 21,JDBC + Druid,无 ORM |
| `fms-vue/` | 当前前端,Vue 3.5 + Vite 8 + Pinia 4 + pnpm,JS(非 TS) |
| `fms-vue-old1/`、`fms-vue-old2/` | 旧版前端副本,仅作历史参考(`old2` 是权限/登录逻辑的来源),**不要改** |
| `sql/` | 建表 / 视图 / 模块注册脚本,由 `RunSqlFile` 执行 |
| `fms-api/tools/migration/` | 独立单文件 Java 迁移工具,`javac` 手动编译,不走 Maven |
| `fms-api/config/migrations/tools/` | 早期一次性数据迁移程序(历史) |
| `参考组件库/` | `antdv-next`、`shadcn` 源码,前端组件实现的参照 |
| 根目录 `*.md` | 设计文档(中文):`FMS新系统核心表结构设计.md`(权威表结构)、`FMS业务表设计.md`、`开发规范.md`(AI 工作规范)、`Fms旧系统表结构.md` |
真实数据库是远端 SQL Server 2022(`118.89.70.199:1433`);旧库 `G3HY2025`,新库 `FMS`。
## 常用命令
### 后端(在 `fms-api/` 下)
```bash
./start.cmd # 设置 JAVA_HOME=jdk-21 / Maven 3.8.9 后 spring-boot:run
./mvnw spring-boot:run # 等价;需自行准备 JDK 21
./mvnw test # 全部测试
./mvnw -Dtest=DataSaveServiceTests test # 单个测试类
./mvnw -Dtest=JwtUtilsTests#methodName test # 单个测试方法
# 连接只支持 TLS 1.0 的旧库时:
mvn "-Dspring-boot.run.jvmArguments=-Djava.security.properties=./config/legacy-tls.security" spring-boot:run
```
服务端口 `8088`,`server.servlet.context-path=/api`。
### 前端(在 `fms-vue/` 下,用 pnpm)
```bash
pnpm dev # Vite dev server,端口 5082,/api 代理到 http://127.0.0.1:8088
# 用 VITE_PROXY_TARGET 可指向另一个后端实例
pnpm build
pnpm lint # oxlint . --deny-warnings
pnpm lint:fix
pnpm fmt # oxfmt --write .
pnpm fmt:check
pnpm check # lint + fmt:check + build
pnpm test # vitest run
pnpm test:file tests/views/login.spec.js # 单文件(也可靠目录:tests/stores)
pnpm test:watch
pnpm test:coverage # 只有这个会检查 60% 阈值
```
### 数据库脚本与迁移工具
```bash
# 执行 sql/ 下脚本(按 GO 分批,单事务,catalog 必须是 FMS)
cd fms-api
java -Dstdout.encoding=UTF-8 -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" \
tools/migration/RunSqlFile.java ../sql/fms_core.sql --dry-run
# 去掉 --dry-run 执行;其余工具(FieldNameSync / SchemaDumpMarkdown / OtherDataMigrate)见
# fms-api/tools/migration/README.md,编译时需 -encoding UTF-8
```
## 架构要点
### 1. 元数据驱动的「SQL 核心」
系统是 SQL 驱动的内部 ERP,模块差异靠**配置数据**表达,不为单个页面复制模块专用 SQL。
核心表(定义见 `FMS新系统核心表结构设计.md`,建表见 `sql/fms_core.sql`):
- `s_module` — 模块树。`b_id` 是业务编码主键(非雪花),`b_parent_id` 是权威父子关系,`b_depth`/`b_path` 是派生字段;`b_module_type` ∈ `module` / `data` / `virtual`;查询来源优先级 `b_query_sql` > `b_view_table`;保存目标 `b_save_table`。
- `s_field` — 字段业务元数据,主键 `(b_module_id, b_field)`。只存业务类型 `b_type`、名称、多语言键;**不存**数据库类型/长度/精度,需要时现场读表结构。
- `s_module_schema` — 界面配置,主键 `(b_module_id, b_schema_type)`,`b_schema_type` ∈ `view` / `edit` / `query`,内容整份存 `b_schema_json`。字段分组不再建表,是 JSON 里的 `type: "group"` 节点。
- `s_user_module_pref` — 个人层覆盖,只存相对系统默认的增量;读取顺序「个人 > 系统默认」。
- `s_autocode`、`s_relation`、`s_i18n` / `s_i18n_type`、`s_menu`、日志表。
- 权限:`s_power`(权限点)、`s_user_power`、`s_user_field_power`、`s_user_data_power`。菜单/模块/动作权限统一收在 `s_power`,字段权限和数据范围不进 `s_power`。
表结构硬约束:不使用外键、`CHECK`、触发器;树结构(模块树/菜单树/部门树)用「邻接表 + `b_depth`/`b_path` 冗余」,移动节点时同事务更新全部后代。主键类型取决于表:配置表用业务编码,业务子表用 `bigint` 雪花。
### 2. 后端通用数据接口(`DataController`)
前端所有业务数据都走这组通用接口,没有按页面定制的接口:
- `POST /api/data/loaddata` — 按表/视图 + `search_condition` + `order_by` + `search_columns` 查询
- `POST /api/data/page` — 分页(`page_no`/`page_size`),内部包一层 `COUNT_BIG(1) OVER()` 拿总数,空页回退单独 count
- `POST /api/data/loaddatabysql` — 执行调用方传入的完整 `SELECT`
- `POST /api/data/saveobjt` — 单个事务内保存多张表的变更请求
- `POST /api/data/nextid` / `nextcode` — 雪花 ID / 按 `s_autocode` 规则取业务编号
- `POST /api/data/describe` — 读表或视图列结构
`DbUtils`(`fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java`)是 SQL 生成与执行的唯一出口:
- 标识符一律经 `SAFE_IDENTIFIER` 校验并加 `[...]`,排序字段只允许 `field [ASC|DESC]` 或 `CASE ... END`。
- `validateReadOnlySql()` 强制只读:必须以 `select`/`with` 开头,剔除字符串字面量后禁止 `;`、注释和 `insert|update|delete|merge|drop|...` 关键字。**调用方传入的 SQL 与 `b_query_sql` 同一个入口,都只允许查询**;写入必须走 `DataSaveService`。
- 通用增删改按 `ResultSetMetaData` 现场解析列,`_` 前缀派生列不参与写入。
`DataSaveService` 支持多列业务键(如 `s_field` 的 `(b_module_id, b_field)`),并按 `s_autocode` 生成业务编号。`ParamUtils` 负责从 `Map<String, Object>` 取参。
### 3. 多机构数据源与鉴权
- `AuthController /auth/login` 用 `orgid + userid + password` 登录(`b_user.b_id` 即账号,密码明文保存),签发 JWT;`ActiveSessionRegistry` 保证同一账号单会话,被顶号返回 `40101`。
- `JwtAuthFilter` 校验 token + 会话后写入 `OrgContext`(ThreadLocal),异常一律以 HTTP 200 + 业务 code 返回。
- `OrgDataSourceManager` 按机构码懒加载 Druid 连接池,配置文件 `fms-api/config/dbconfigs/{ORG_ID}.properties`(该目录已 gitignore);`OrgDatabaseConfigWatcher` + 定时核对支持改配置不重启、旧池延迟关闭。**读写业务数据必须经它取连接**,不要直接用注入的默认 `DataSource`(迁移工具除外)。
### 4. 前端运行时装配
- `src/router/routes.js` 只声明路由表与派生纯数据(`collectMenuRoutes` / `menuRouteOptions` 供菜单管理下拉用);`src/router/index.js` 只建实例 + 守卫。分离是为了避免「页面 → router/index → 守卫 → store」循环依赖,不要把两者合并。
- 路由分组节点(`/module`)只写 `path + redirect + children`、**不写 component**,子页面才能继续渲染在 `DefaultLayout` 的同一个 `router-view` 上、保持 keep-alive 层级。
- 守卫里统一 `await` 权限、菜单、语言清单三家 store 的 `load()`;三者都按登录主体(`orgId:userId`)去重、并发复用同一请求、主体切换时 `clear()`。
- `views/module/**` 是 `meta.adminOnly`,仅管理员账号 `g3soft` 可进(路由守卫拦截 + 侧栏入口只在 `AppSidebar` 渲染),且不进 `s_menu`。
- 页面缓存:`layouts/DefaultLayout.vue` 里 keep-alive 外层 key = `fullPath`(稳定),内层 key = `fullPath:refreshKey`(双击页签只重挂内层)。**每个页签一个命名包装组件**,详情页按 `cacheKeyOf(fullPath)` 生成 name;包装组件渲染不能读当前路由,`fullPath` 只能经 props 传入。`src/stores/app.js` 的 `visitedTabs` / `routeRefreshKeys` 与之联动。
### 5. 模块列表 / 编辑的运行时(元数据渲染)
业务页面不手写表格和表单,交给通用组件按模块配置渲染:
```
s_module_schema.b_schema_json ──schemaRender.js──▶ fms-module-list / fms-module-edit 的行/分组结构
```
- `src/components/fms-module-common/schemaRender.js` — JSON→渲染结构适配层:`loadModuleSchemas`、`buildListConfig`(view→列)、`buildEditRender`(edit→分组+行)、`buildQueryRender`(query→条件)、`applyColumnSettings`(列宽/顺序回写)、`applyQueryLayout`(「保存为通用」回写默认查询布局)。运行时组件不需要感知 JSON 节点差异。
- `src/components/fms-module-common/queryUtils.js` — 查询条件 AST → SQL 片段。常用条件与高级条件共用一套 AST(条件项自带 `join` 表达且/或),`OPERATORS` 与操作符文案是唯一定义处;`astToSql` 是唯一出口。字段编码和值在这里做 `[...]` 与 `N'...'` 转义。
- `src/components/fms-module-list/FmsModuleListPage.vue` — 通用列表页容器,props 有 `dataCode`、`fixedSearchCondition`、`rowActions`、`rowSelection`、`editable`、`rowDraggable` 等;个人查询布局偏好走同目录 `useModulePref.js`(`s_user_module_pref`)。
- `src/components/ui/` — 自研 UI 组件库,统一从 `@/components/ui` 具名导入(含命令式 `Message`、`Modal.confirm`)。
### 6. 模块管理页 = 配置中心
`src/views/module/module-management/index.vue` 是生产上述配置数据的页面:左模块树 + 右分节面板(基础/字段/列表/表单/查询/自动编码/权限/多语言),底层就是通用 `loadDataApi` / `saveObjectApi`。它按「编排层只持有数据、各面板 `v-model` 各管各的」组织:
- 每份数据成对持有 `xxx`(当前值,面板绑定)与 `xxx_org`(数据库基线),如 `maindata / maindata_org`、`fields / fields_org`、`view_schema / view_schema_org`。禁止用 `drafts`/`originals` 两个聚合对象。
- 跨面板副作用(删分组后清理引用、自动编码字段在表单中自动只读)由编排层写幂等 `watch`。
- 模块配置表用业务键/联合主键,**不调用 `nextIdApi`**,保存时也不做临时 ID → 雪花 ID 转换;`_`/`v_` 前缀列是内部派生字段,提交前剥离。
- `src/views/module/menu-management/index.vue` 是同类页面(左菜单树 + 右面板),实现时以它和模块管理为参照。
### 7. 文件服务
`FileController` 支持 `local` 与 `aliyun-oss` 两种存储(`fms.file.storage-type`)。本地上传到 `./data/files/{orgId}/{yyyy}/{MM}/{dd}/`,静态访问 `/api/file/static/{path}`(含路径穿越防护)。前端 `src/services/fileService.js` 按 `initFileConfig()` 拿到的模式自动分流,OSS 走「共享凭证 → XHR 直传 → save-record 回调」。
## 编码约定
`开发规范.md` 是 AI 工作规范的权威来源,重点:
- **不写 placeholder / 桩代码 / 待填充 TODO**。
- **后端接口优先通用**;要新增特殊接口必须先征得用户确认。
- **改前先读**,只改完成当前需求必需的文件;不擅自回滚或顺手重构无关代码。
- **能由 SQL 完成的(关联、过滤、排序、分页、聚合、权限过滤、数据范围)在 SQL 层完成**,不要先加载大量数据再在代码里处理。
- **权限过滤以后端 SQL 为准**:前端隐藏菜单/按钮/字段只是体验,模块操作权限、字段可见/可查询/可导出权限、数据范围都必须在后端查询或写入 SQL 中落实;用户个性化配置只能调布局,不能放宽权限。
- 固定 SQL 只用于稳定明确的特殊逻辑,且保持通用,不为单个页面复制一套接口。
命名分层(`开发规范.md` 有完整示例):
- 前端 JS 逻辑标识符用 `camelCase`;后端返回字段、数据库字段、与表结构一一对应的业务数据用 `snake_case`。API 封装层是边界:请求体必须保留后端原字段名,不因前端改名而改接口契约。
- 方法名动词开头、语义完整(`loadModuleConfig` / `handleSave` / `buildSaveData` / `clearModuleData`),不用 `loadMod` / `dealData`。布尔值用 `is/has/can/should` 前缀,ID 用 `xxxId`,集合用复数。
- Vue 组件文件名 PascalCase;模板中 props / 事件 / `v-model` 用 kebab-case(`:module-data`、`@save-success`、`v-model:main-data`)。
- 公共函数只在满足条件时抽取(≥2 个真实调用点 / 逻辑较复杂 / 必须保证多处规则一致);只服务某个业务目录的函数放该目录的 `utils.js`,不进全局 `src/utils`;一个业务目录原则上只有一个 `utils.js`。
- Pinia 统一用 Setup Store(`defineStore('name', () => {}, options)`),一个 store 一个领域;只有确实需要跨刷新保留的字段才配 `persist` 并显式 `pick`(见 `src/stores/app.js`)。
表单页面统一主流程与命名,不要改名或调换顺序:
```
handleSave() 保存入口(saving 用 try/finally 复位)
→ beforeSave() 校验/同步/准备,返回 false 终止
→ buildSaveData() 组装请求,可返回单请求/请求数组/null(可 async)
→ 调用 saveObjectApi
→ afterSave() 成功后重新加载、更新基线、清草稿
```
编辑页加载命名:`load()` 判断新建/编辑 → `loadNew()` / `loadEdit()`;不区分时用语义明确的 `loadXxx()`。
i18n:`module/` 下的管理界面文案固定中文、不接入业务多语言;模块名/字段名/JSON 布局节点等面向客户的资源维护 `s_i18n` 键。语言清单与当前界面语言统一由 `stores/i18n.js` 管理(`s_i18n_type`),默认语言按 `s_i18n_type.b_default` 判断,不取数组第一项。
## 测试
前端测试约定见 `fms-vue/tests/README.md`(唯一权威):
- 文件一律 `*.spec.js`,与被测源码同名,**一个源码文件最多一个 spec**;`describe` 与用例名用中文。
- 页面测试 mock `@/services/api` 这一个接缝并用 `mountPage`(`tests/helpers/app.js`);store 测试必须用 `createTestPinia()`,不要手动 `createPinia()`(pinia 4 下 persist 插件会静默失效)。
- `beforeEach` 必须 `localStorage.clear()` + `sessionStorage.clear()`;异步用 `flushPromises()` + `nextTick()`。
- 覆盖率门槛 60%(lines/functions/branches/statements),只在 `pnpm test:coverage` 下生效。
后端测试在 `fms-api/src/test/java/...`,用 `@SpringBootTest` / 纯 JUnit 类,`mvnw test` 运行。
改动后的验收(`开发规范.md`「变更验收」):先全局搜索确认无旧路径/旧导出/旧方法名残留;至少跑受影响模块的测试,涉及公共 store / 布局缓存 / 跨页面工具时补相关页面与状态测试;若被改动前已存在的缺失文件、旧测试或环境问题阻断,在结果中明确记录,不要为通过检查去改无关代码。
+60 -4
View File
@@ -12,6 +12,7 @@
| `RunSqlFile.java` | 通用 | 在 FMS 库执行 `sql/` 下的建表 / 视图 / 索引脚本(按 GO 分批,单事务) |
| `OtherDataProbe.java` | 迁移辅助(只读) | `list` 列出旧库 `b_other_bmfl` 的 65 个字典模块(启用/旧库行数/新库已建),输出 `otherdata-modules.txt`;`tables <表名...>` 导出新旧结构 + 旧库数据样例 |
| `OtherDataMigrate.java` | **其他数据迁移** | 导入 `base_otherdata` 下 64 个字典模块的数据(雪花主键)、重建 `b_from`/`b_grade`、转换 `b_othercompany` 引用值,输出映射 `otherdata-id-map.tsv`;支持 `--dry-run` |
| `TreePathRepair.java` | 核对 / 修复 | 按 `b_parent_id` 重建树表的 `b_depth` / `b_path`,默认只读报告,`--apply` 才写库 |
---
@@ -162,10 +163,10 @@ del FieldNameSync*.class
javac -encoding UTF-8 -cp "mssql-jdbc-13.4.0.jre11.jar" SchemaDumpMarkdown.java
# 导出新库 FMS 到指定文件
java -Dstdout.encoding=UTF-8 -cp ".;mssql-jdbc-13.4.0.jre11.jar" SchemaDumpMarkdown "D:\path\FMS表结构.md" FMS
java -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -cp ".;mssql-jdbc-13.4.0.jre11.jar" SchemaDumpMarkdown "D:\path\FMS表结构.md" FMS
# 只导出表,不含视图
java -Dstdout.encoding=UTF-8 -cp ".;mssql-jdbc-13.4.0.jre11.jar" SchemaDumpMarkdown "D:\path\FMS表结构.md" FMS --tables-only
java -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -cp ".;mssql-jdbc-13.4.0.jre11.jar" SchemaDumpMarkdown "D:\path\FMS表结构.md" FMS --tables-only
```
参数:`<输出文件绝对路径>` `[数据库名,默认 G3HY2025]` `[--tables-only]`
@@ -184,11 +185,11 @@ catalog 必须是 `FMS`;按 `GO` 把脚本拆成批次,在单个事务中依
cd fms-api
# 先 dry-run 看批次划分
java -Dstdout.encoding=UTF-8 -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" \
java -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" \
tools/migration/RunSqlFile.java ../sql/fms_business_contact.sql --dry-run
# 确认后执行
java -Dstdout.encoding=UTF-8 -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" \
java -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" \
tools/migration/RunSqlFile.java ../sql/fms_business_contact.sql
```
@@ -201,6 +202,61 @@ javac -encoding UTF-8 -cp "mssql-jdbc-13.4.0.jre11.jar" RunSqlFile.java
> 含「重建」段落(`drop ... if exists`)的脚本会删除同名表及其数据,执行前确认表中数据已不需要。
---
## TreePathRepair — 树表 b_depth / b_path 核对与修复
`b_parent_id` 是树结构的权威关系,`b_depth` / `b_path` 是派生字段(见《FMS新系统核心表结构设计》1.7)。
本工具按 `b_parent_id` 重算这两列,用于核对和修复派生字段与父子关系不一致的数据。
历史背景:模块管理 / 菜单管理的拖拽排序曾有一处缺陷,会把**根节点** `b_path` 的前导 `/` 抹掉
(`/base/` 被写成 `base/`)并整树落库。前端已修复,本工具用于核对历史数据、必要时重算。
`b_path` 的约定格式形如 `/base/base_otherdata/`(节点编码前后均带 `/`)。
### 行为
- **默认只读报告,不写库**;确认无误后加 `--apply` 才在单个事务内更新 `b_depth` / `b_path`,失败整体回滚。
- 只更新这两列,`b_parent_id` 与其余字段一律不动;父级不存在的节点按根处理(与前端 `buildTree` 一致)。
- **成环的节点无法派生路径,只报告不修**——父子关系成环是数据问题,需人工先修 `b_parent_id`。
- 报告会区分「只缺前导 `/`」这一缺陷典型症状,并给出变更样例。
### 用法
```bash
cd fms-api
# 只读报告(默认):核对 s_module / s_menu
java -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" \
tools/migration/TreePathRepair.java
# 核对全部四张树表
java -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" \
tools/migration/TreePathRepair.java --tables s_module,s_menu,b_dept,b_othercompany_category
# 确认后执行修复
java -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" \
tools/migration/TreePathRepair.java --apply
# 多机构:指向目标机构的连接配置(默认 config/dbconfigs/G3HD.properties)
java -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" \
tools/migration/TreePathRepair.java --config config/dbconfigs/OTHER.properties
```
参数:
| 参数 | 说明 |
| --- | --- |
| (无) | 只读报告,不写库 |
| `--dry-run` | 同「无参数」,显式表达只读 |
| `--apply` | 执行修复(单事务) |
| `--tables a,b` | 待核对表,默认 `s_module,s_menu`;可选 `s_module` / `s_menu` / `b_dept` / `b_othercompany_category` |
| `--config <path>` | 连接配置,默认 `config/dbconfigs/G3HD.properties` |
> 修复只重算派生字段,全部内容都能由 `b_parent_id` 重新推出,因此可安全重复执行;
> 执行后重跑一次只读报告应得到「待更新 0 行」。
---
## 已有同步记录
| 模块 / 视图 | 同步日期 | 字段数 | 备注 |
@@ -0,0 +1,372 @@
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 树表 b_depth / b_path 核对与修复。
*
* 背景:模块管理 / 菜单管理的拖拽排序曾把根节点 b_path 的前导 / 抹掉
* (`/base/` 被写成 `base/`),并把整棵树按错误规则重新落库。
* b_parent_id 是权威关系,b_depth / b_path 是派生字段(见《FMS新系统核心表结构设计》1.7),
* 因此受损数据可以按 b_parent_id 完整重建,本工具就是做这件事。
*
* 行为:
* - 默认只读报告(不写库);加 --apply 才在单个事务内重建,失败整体回滚。
* - 只处理 b_depth / b_path 两列,b_parent_id 与其余字段一律不动。
* - 成环的节点无法派生路径,只报告不修(父子关系是数据问题,应由人工先修)。
*
* 用法(在 fms-api 目录下运行):
* java -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" tools/migration/TreePathRepair.java
* java -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" tools/migration/TreePathRepair.java --apply
* java -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" tools/migration/TreePathRepair.java \
* --tables s_module,s_menu,b_dept,b_othercompany_category
*/
public final class TreePathRepair {
/** 允许操作的表(树结构规范里的四张树表) */
private static final Set<String> ALLOWED_TABLES = new LinkedHashSet<>(List.of(
"s_module",
"s_menu",
"b_dept",
"b_othercompany_category"
));
private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*");
private static final int PATH_LIMIT = 1000;
private static final int SAMPLE_LIMIT = 30;
public static void main(String[] args) throws Exception {
boolean apply = false;
List<String> tables = List.of("s_module", "s_menu");
String configPath = "config/dbconfigs/G3HD.properties";
for (int i = 0; i < args.length; i++) {
String arg = args[i];
if ("--apply".equalsIgnoreCase(arg)) {
apply = true;
} else if ("--dry-run".equalsIgnoreCase(arg)) {
apply = false;
} else if ("--tables".equalsIgnoreCase(arg) && i + 1 < args.length) {
tables = List.of(args[++i].split(","));
} else if ("--config".equalsIgnoreCase(arg) && i + 1 < args.length) {
configPath = args[++i];
} else {
System.out.println("未知参数: " + arg);
printUsage();
return;
}
}
List<String> normalizedTables = new ArrayList<>();
for (String table : tables) {
String name = table.trim();
if (!normalizedTables.contains(name)) {
normalizedTables.add(name);
}
}
for (String table : normalizedTables) {
if (!IDENTIFIER.matcher(table).matches() || !ALLOWED_TABLES.contains(table)) {
throw new IllegalArgumentException(
"不允许的表: " + table + "(可选: " + String.join(", ", ALLOWED_TABLES) + ")"
);
}
}
Path apiRoot = Path.of("").toAbsolutePath().normalize();
Path propertiesFile = apiRoot.resolve(configPath).normalize();
Properties properties = new Properties();
try (InputStream input = Files.newInputStream(propertiesFile)) {
properties.load(input);
}
System.out.println(apply ? "模式: 修复(会写库)" : "模式: 只读报告(不写库,加 --apply 才修复)");
System.out.println("配置文件: " + propertiesFile);
try (Connection connection = DriverManager.getConnection(
properties.getProperty("url"),
properties.getProperty("username"),
properties.getProperty("password")
)) {
System.out.println("目标库: " + connection.getCatalog());
System.out.println();
int totalChanged = 0;
int totalBlocked = 0;
for (String table : normalizedTables) {
int[] result = repairTable(connection, table, apply);
totalChanged += result[0];
totalBlocked += result[1];
System.out.println();
}
if (apply) {
System.out.println("=== 修复完成:共更新 " + totalChanged + " 行,跳过 " + totalBlocked + " 行 ===");
} else {
System.out.println("=== 只读报告:待更新 " + totalChanged + " 行,跳过 " + totalBlocked + " 行 ===");
if (totalChanged > 0) {
System.out.println("确认无误后加 --apply 执行修复。");
}
}
}
}
/**
* @return {待更新行数, 因成环/超长而跳过的行数}
*/
private static int[] repairTable(Connection connection, String table, boolean apply) throws Exception {
Map<String, Node> nodes = loadNodes(connection, table);
Set<String> onCycle = findCycleNodes(nodes);
Map<String, Position> expected = new LinkedHashMap<>();
Set<String> blocked = new LinkedHashSet<>();
Set<String> orphan = new LinkedHashSet<>();
Set<String> tooLong = new LinkedHashSet<>();
for (String id : nodes.keySet()) {
resolve(id, nodes, expected, onCycle, blocked, orphan, tooLong);
}
List<Change> changes = new ArrayList<>();
int depthMismatch = 0;
int pathMismatch = 0;
int missingLeadingSlash = 0;
for (Map.Entry<String, Node> entry : nodes.entrySet()) {
Position position = expected.get(entry.getKey());
if (position == null) {
continue;
}
Node node = entry.getValue();
boolean depthDiffers = node.depth() != position.depth();
boolean pathDiffers = !position.path().equals(node.path());
if (!depthDiffers && !pathDiffers) {
continue;
}
if (depthDiffers) {
depthMismatch++;
}
if (pathDiffers) {
pathMismatch++;
// 本工具针对的典型症状:路径只是丢了前导 /
if (position.path().equals("/" + node.path())) {
missingLeadingSlash++;
}
}
changes.add(new Change(node, position));
}
System.out.println("=== " + table + "(" + nodes.size() + " 行)===");
System.out.println(" 成环节点(父子关系问题,本工具不修): " + onCycle.size()
+ (onCycle.isEmpty() ? "" : " → " + join(limit(onCycle))));
if (!blocked.isEmpty()) {
System.out.println(" 因父级成环/超长而无法派生: " + blocked.size() + " → " + join(limit(blocked)));
}
if (!orphan.isEmpty()) {
System.out.println(" b_parent_id 指向不存在的节点(按根处理): " + orphan.size()
+ " → " + join(limit(orphan)));
}
if (!tooLong.isEmpty()) {
System.out.println(" 派生路径超过 " + PATH_LIMIT + " 字符(跳过): " + tooLong.size()
+ " → " + join(limit(tooLong)));
}
System.out.println(" b_path 不一致: " + pathMismatch + " 行(其中「只缺前导 /」: " + missingLeadingSlash + " 行)");
System.out.println(" b_depth 不一致: " + depthMismatch + " 行");
System.out.println(" 待更新合计: " + changes.size() + " 行");
if (changes.isEmpty()) {
System.out.println(" 无需修复。");
return new int[]{0, blocked.size()};
}
System.out.println(" 变更样例:");
for (int i = 0; i < Math.min(SAMPLE_LIMIT, changes.size()); i++) {
Change change = changes.get(i);
System.out.printf(" %-28s depth %d→%d path '%s' → '%s'%n",
change.node().id(),
change.node().depth(),
change.position().depth(),
change.node().path(),
change.position().path());
}
if (changes.size() > SAMPLE_LIMIT) {
System.out.println(" ... 其余 " + (changes.size() - SAMPLE_LIMIT) + " 行略");
}
if (!apply) {
return new int[]{changes.size(), blocked.size()};
}
String updateSql = "UPDATE dbo." + table
+ " SET b_depth = ?, b_path = ? WHERE b_id = ?";
boolean originalAutoCommit = connection.getAutoCommit();
connection.setAutoCommit(false);
try (PreparedStatement statement = connection.prepareStatement(updateSql)) {
for (Change change : changes) {
statement.setInt(1, change.position().depth());
statement.setString(2, change.position().path());
statement.setString(3, change.node().id());
statement.addBatch();
}
int[] affected = statement.executeBatch();
for (int count : affected) {
if (count != 1) {
throw new IllegalStateException("更新行数异常: " + count);
}
}
connection.commit();
System.out.println(" 已提交 " + changes.size() + " 行。");
} catch (Exception e) {
connection.rollback();
throw new IllegalStateException(table + " 修复失败,已回滚: " + e.getMessage(), e);
} finally {
if (originalAutoCommit) {
connection.setAutoCommit(true);
}
}
return new int[]{changes.size(), blocked.size()};
}
private static Map<String, Node> loadNodes(Connection connection, String table) throws Exception {
String sql = "SELECT b_id, b_parent_id, b_depth, b_path FROM dbo." + table;
Map<String, Node> nodes = new LinkedHashMap<>();
try (Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery(sql)) {
while (resultSet.next()) {
String id = normalize(resultSet.getString("b_id"));
if (id == null) {
throw new IllegalStateException(table + " 存在 b_id 为空的行");
}
int depth = resultSet.getInt("b_depth");
String path = resultSet.getString("b_path");
nodes.put(id, new Node(
id,
normalize(resultSet.getString("b_parent_id")),
depth,
path == null ? "" : path
));
}
}
return nodes;
}
/** 找出所有真正位于环上的节点(沿 b_parent_id 上溯,出现重复即为环) */
private static Set<String> findCycleNodes(Map<String, Node> nodes) {
Set<String> onCycle = new LinkedHashSet<>();
for (String start : nodes.keySet()) {
Map<String, Integer> seen = new LinkedHashMap<>();
String current = start;
while (current != null && nodes.containsKey(current)) {
Integer previous = seen.get(current);
if (previous != null) {
List<String> chain = new ArrayList<>(seen.keySet());
for (int i = previous; i < chain.size(); i++) {
onCycle.add(chain.get(i));
}
break;
}
seen.put(current, seen.size());
current = nodes.get(current).parentId();
}
}
return onCycle;
}
/** 自底向上派生 b_depth / b_path;父级成环或路径超长时返回 null 并记入 blocked */
private static Position resolve(
String id,
Map<String, Node> nodes,
Map<String, Position> cache,
Set<String> onCycle,
Set<String> blocked,
Set<String> orphan,
Set<String> tooLong
) {
if (id == null || !nodes.containsKey(id)) {
return null;
}
Position cached = cache.get(id);
if (cached != null) {
return cached;
}
if (onCycle.contains(id)) {
blocked.add(id);
return null;
}
Node node = nodes.get(id);
int depth;
String path;
if (node.parentId() == null) {
depth = 0;
path = "/" + id + "/";
} else {
Position parent = resolve(node.parentId(), nodes, cache, onCycle, blocked, orphan, tooLong);
if (parent == null) {
if (nodes.containsKey(node.parentId())) {
// 父级在环上 / 被阻断,本节点同样无法派生
blocked.add(id);
return null;
}
// 父级不存在:与前端 buildTree 一致,提升为根
orphan.add(id);
depth = 0;
path = "/" + id + "/";
} else {
depth = parent.depth() + 1;
path = parent.path() + id + "/";
}
}
if (path.length() > PATH_LIMIT) {
tooLong.add(id);
blocked.add(id);
return null;
}
Position position = new Position(depth, path);
cache.put(id, position);
return position;
}
private static String normalize(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private static List<String> limit(Set<String> values) {
return values.stream().limit(SAMPLE_LIMIT).toList();
}
private static String join(List<String> values) {
return String.join(", ", values);
}
private static void printUsage() {
System.out.println("用法: TreePathRepair [--dry-run | --apply] [--tables s_module,s_menu,...] [--config <properties>]");
System.out.println(" 默认(不带参数)只读报告,不写库;--apply 才在单事务内重建 b_depth / b_path。");
System.out.println(" 可选表: " + String.join(", ", ALLOWED_TABLES));
System.out.println(" --config 默认 config/dbconfigs/G3HD.properties(多机构时指向目标机构的配置)。");
}
private record Node(String id, String parentId, int depth, String path) {
}
private record Position(int depth, String path) {
}
private record Change(Node node, Position position) {
}
}
@@ -650,7 +650,7 @@ defineExpose({
:show-overflow="showOverflow"
:empty-cell-text="''"
:area-selection="editable"
:bordered="false"
:bordered="bordered ? 'v' : false"
@area-selection-change="handleAreaSelectionChange"
@row-order-change="handleRowOrderChange"
>
@@ -710,9 +710,8 @@ defineExpose({
}
}
/* 自绘行分隔线(库 bordered=false 时不画任何边框):
单元格只画 border-bottom,外框(含四角圆弧描边)由外层 .fms-table--bordered 统一承担,
避免库渐变线叠加与最后一行/外框的双线问题 */
/* 外框由 .fms-table--bordered 负责;StkTable 的 v 模式负责竖向分割线,
本组件只补充库 v 模式关闭的横向行分隔线。 */
.fms-table__inner.stk-table {
th,
td {
@@ -720,6 +719,20 @@ defineExpose({
}
}
/* bordered="v" 仍会给 StkTable 根节点绘制方形外框背景,且最后一列会绘制右侧渐变线;
这些边缘由外层圆角容器提供,去掉库的外边缘后只保留单元格之间的竖线。 */
.fms-table__inner.stk-table.bordered-v {
border-left: none;
background-image: none;
}
.fms-table__inner.stk-table.bordered-v {
th:last-child,
td:last-child {
background-image: none;
}
}
.fms-checkbox {
display: flex;
align-items: center;
@@ -182,19 +182,11 @@ function onContextMenu(e) {
@dragend="onDragEnd"
@contextmenu.stop="onContextMenu"
>
<!-- 拖拽手柄:整行唯一可发起拖拽的区域(按下时才把本行置为 draggable);不可拖拽的节点不渲染 -->
<span
v-if="ctx.props.draggable && !dragDisabled"
class="tree-node-grip"
title="拖拽调整顺序"
@mousedown="armDrag"
>
<GripVertical :aria-hidden="true" />
</span>
<!-- 展开/收起箭头:非叶子渲染真实箭头,叶子渲染等宽透明占位。
占位不是多占地方,而是对齐机制:没有它,叶子标题会左移一个箭头宽,
比同层非叶子靠左、并与父级标题齐平(缩进不足),层级关系丢失。 -->
比同层非叶子靠左、并与父级标题齐平(缩进不足),层级关系丢失。
位置在拖拽手柄之前:占位是「缩进带」的一部分(叶子行它是空白),
放到手柄之后会让手柄与标题之间空出一个箭头宽,读起来像多余空隙。 -->
<span
v-if="!leaf"
class="tree-node-switcher"
@@ -213,6 +205,16 @@ function onContextMenu(e) {
</span>
<span v-else class="tree-node-switcher is-placeholder" :aria-hidden="true"></span>
<!-- 拖拽手柄:整行唯一可发起拖拽的区域(按下时才把本行置为 draggable);不可拖拽的节点不渲染 -->
<span
v-if="ctx.props.draggable && !dragDisabled"
class="tree-node-grip"
title="拖拽调整顺序"
@mousedown="armDrag"
>
<GripVertical :aria-hidden="true" />
</span>
<span v-if="ctx.props.checkable" class="tree-node-checkbox">
<Checkbox
:model-value="isChecked"
+118
View File
@@ -187,4 +187,122 @@ export function removeTreeNode(nodes, id, idKey = 'b_id', childrenKey = 'childre
}
if (nodes[i][childrenKey]) removeTreeNode(nodes[i][childrenKey], id, idKey, childrenKey)
}
}
/* ============================================================================
* 可编辑树的位置派生与就地变更
*
* 以下函数面向「FMS 树结构规范」的固定字段(b_id / b_parent_id / b_xh /
* b_depth / b_path),模块树、菜单树、分类树共用同一套约定,因此不再按目录各写一份。
* 约定:b_parent_id 是权威关系,b_depth 与 b_path 是由业务层维护的派生字段,
* b_path 形如 /base/base_otherdata/(节点编码前后均带 /)。
* ============================================================================ */
/**
* 从一维行构建已排序的树。
* 必须在浅拷贝上构建:buildTree 会就地往行上挂 children,直接构建会累积出重复
* 子节点、并污染调用方的唯一数据源。
* @param {Array} list 原始行数组(不被修改)
* @param {string} idKey 节点 ID 字段名,默认 "b_id"
* @param {string} parentKey 父节点关联字段名,默认 "b_parent_id"
* @returns {Array} 排序后的树
*/
export function buildSortedTree(list, idKey = 'b_id', parentKey = 'b_parent_id') {
const copied = (list || []).map((row) => ({ ...row }))
return sortTree(buildTree(copied, idKey, parentKey))
}
/**
* 预载节点图标并指定非叶子类型(即使暂无子节点也显示展开箭头,便于新增子节点)。
* 就地修改并返回原数组。
* @param {Array} nodes 树节点数组
* @param {Object} options
* @param {string} options.typeField 类型字段名,如 "b_module_type"
* @param {Array<string>} options.nonLeafValues 取这些类型值时强制非叶子
* @param {Object} options.iconMap 类型值 → 图标组件
* @returns {Array} 原数组
*/
export function decorateTreeNodes(nodes, { typeField, nonLeafValues = [], iconMap = {} }) {
for (const node of nodes) {
if (nonLeafValues.includes(node[typeField])) node.isLeaf = false
const icon = iconMap[node[typeField]]
if (icon) node.icon = icon
if (node.children) decorateTreeNodes(node.children, { typeField, nonLeafValues, iconMap })
}
return nodes
}
/**
* 在树中查找节点所在的父数组及索引(就地移动节点时配合 splice 使用)。
* @param {Array} nodes 树节点数组
* @param {string|number} id 目标节点 ID
* @returns {{ list: Array, index: number }|null}
*/
export function findNodePosition(nodes, id) {
for (let i = 0; i < nodes.length; i++) {
if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
if (nodes[i].children) {
const found = findNodePosition(nodes[i].children, id)
if (found) return found
}
}
return null
}
/**
* 向 updatedRows 合并一行变更(同一 b_id 累加字段,避免同节点产生多条 update 请求)。
* @param {Array} updatedRows 变更行收集器(就地修改)
* @param {Object} row 含 b_id 的行
*/
export function addRowUpdate(updatedRows, row) {
const existing = updatedRows.find((item) => String(item.b_id) === String(row.b_id))
if (existing) Object.assign(existing, row)
else updatedRows.push({ ...row })
}
/**
* 按父节点派生行的 b_depth / b_path(b_parent_id 是权威关系,二者为派生字段)。
* b_path 非空且无默认值,新增时必须随行写入。
* @param {Object} row 目标行(就地修改,b_id 必须是最终编码)
* @param {Object|null} parentNode 树中的父节点;null 表示根节点
*/
export function applyTreePosition(row, parentNode) {
row.b_depth = parentNode ? Number(parentNode.b_depth ?? 0) + 1 : 0
row.b_path = parentNode ? `${String(parentNode.b_path || '')}${row.b_id}/` : `/${row.b_id}/`
}
/**
* 重排某组兄弟的 b_xh(10 起步、步长 10),把变化的行收集到 updatedRows。
* @param {Array} list 兄弟节点数组(就地修改 b_xh)
* @param {Array} updatedRows 变化行收集器
*/
export function renumberSiblings(list, updatedRows) {
list.forEach((sibling, index) => {
const nextXh = (index + 1) * 10
if (Number(sibling.b_xh) !== nextXh) {
sibling.b_xh = nextXh
addRowUpdate(updatedRows, { b_id: sibling.b_id, b_xh: nextXh })
}
})
}
/**
* 递归重建整树的 b_depth / b_path(b_parent_id 为权威关系,二者为派生字段)。
* 仅在值实际变化时收集,避免打开即脏;父子关系变化后调用即可整树收敛。
* @param {Array} nodes 树节点数组(就地修改)
* @param {Array} updatedRows 变化行收集器
* @param {string} [parentPath] 父节点路径,根层传空串
* @param {number} [depth] 当前层级
*/
export function rebuildTreePositions(nodes, updatedRows, parentPath = '', depth = 0) {
for (const node of nodes) {
// 根层没有 parentPath,必须显式补前导 /:b_path 的约定是节点编码前后均带 /
const path = parentPath ? `${parentPath}${node.b_id}/` : `/${node.b_id}/`
if (Number(node.b_depth) !== depth || String(node.b_path ?? '') !== path) {
node.b_depth = depth
node.b_path = path
addRowUpdate(updatedRows, { b_id: node.b_id, b_depth: depth, b_path: path })
}
if (node.children?.length) rebuildTreePositions(node.children, updatedRows, path, depth + 1)
}
}
@@ -27,7 +27,17 @@ import { escapeSqlText } from '@/components/fms-module-common/queryUtils'
import FmsTree from '@/components/fms-tree/FmsTree.vue'
import { loadDataApi, pageDataApi, saveObjectApi } from '@/services/api'
import { usePermissionStore } from '@/stores/permissions'
import { buildTree, collectAllKeys, filterTreeByText, findTreeNode, sortTree } from '@/utils/tree'
import {
addRowUpdate,
applyTreePosition,
buildSortedTree,
collectAllKeys,
filterTreeByText,
findNodePosition,
findTreeNode,
rebuildTreePositions,
renumberSiblings,
} from '@/utils/tree'
const MODULE_CODE = 'v_b_othercompany' // 右侧列表模块
const CATEGORY_TABLE = 'b_othercompany_category' // 左侧分类树表
@@ -36,25 +46,6 @@ const ALL_CATEGORY_KEY = '__fms_tree_root__'
/* ===================== 分类树纯函数 ===================== */
/**
* 必须在浅拷贝上构建:buildTree 会就地往行上挂 children,
* 直接构建会累积出重复子节点、并污染唯一数据源 categories。
*/
function buildCategoryTree(rows) {
const tree = buildTree((rows || []).map((row) => ({ ...row })))
sortTree(tree)
return tree
}
/**
* 派生 b_depth / b_path(b_parent_id 是权威关系,二者为派生字段)。
* b_path 非空且无默认值,新增时必须随行写入。
*/
function applyCategoryPosition(row, parentNode) {
row.b_depth = parentNode ? Number(parentNode.b_depth ?? 0) + 1 : 0
row.b_path = parentNode ? `${String(parentNode.b_path || '')}${row.b_id}/` : `/${row.b_id}/`
}
/** 同级下一个排序号(最大 b_xh + 10,无同级时取 10) */
function nextSiblingXh(rows, parentId) {
const normalizedParentId = parentId == null ? null : String(parentId)
@@ -73,49 +64,6 @@ function hasChildCategory(rows, id) {
return (rows || []).some((row) => row.b_parent_id != null && String(row.b_parent_id) === targetId)
}
/** 查找节点所在的父数组与索引 */
function findNodePosition(nodes, id) {
for (let i = 0; i < nodes.length; i++) {
if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
if (nodes[i].children) {
const found = findNodePosition(nodes[i].children, id)
if (found) return found
}
}
return null
}
/** 同一 b_id 的字段合并进一行,避免同节点产生多条 update */
function addRowUpdate(updatedRows, row) {
const existing = updatedRows.find((item) => String(item.b_id) === String(row.b_id))
if (existing) Object.assign(existing, row)
else updatedRows.push({ ...row })
}
/** 重排兄弟 b_xh(10 起步、步长 10),只收集实际变化的行 */
function renumberSiblings(list, updatedRows) {
list.forEach((sibling, index) => {
const nextXh = (index + 1) * 10
if (Number(sibling.b_xh) !== nextXh) {
sibling.b_xh = nextXh
addRowUpdate(updatedRows, { b_id: sibling.b_id, b_xh: nextXh })
}
})
}
/** 整树重建 b_depth / b_path(b_parent_id 为权威关系),只收集实际变化的行 */
function rebuildTreePositions(nodes, updatedRows, parentPath = '', depth = 0) {
for (const node of nodes) {
const path = parentPath ? `${parentPath}${node.b_id}/` : `/${node.b_id}/`
if (Number(node.b_depth) !== depth || String(node.b_path ?? '') !== path) {
node.b_depth = depth
node.b_path = path
addRowUpdate(updatedRows, { b_id: node.b_id, b_depth: depth, b_path: path })
}
if (node.children?.length) rebuildTreePositions(node.children, updatedRows, path, depth + 1)
}
}
/* ================= 权限 ================= */
const permissionStore = usePermissionStore()
const canCreateRow = computed(() => permissionStore.canPower(MODULE_CODE, 'create'))
@@ -135,7 +83,7 @@ const categoryById = computed(
)
/** 树是 categories 的派生视图(在浅拷贝上构建) */
const categoryTree = computed(() => buildCategoryTree(categories.value))
const categoryTree = computed(() => buildSortedTree(categories.value))
/** 分类没有 b_code,匹配「名称 + 编码」两列 */
const filteredCategoryTree = computed(() =>
@@ -424,7 +372,7 @@ async function onCategorySubmit(payload) {
const parentNode = categoryParentId.value
? findTreeNode(categoryTree.value, categoryParentId.value)
: null
applyCategoryPosition(row, parentNode)
applyTreePosition(row, parentNode)
await saveObjectApi([{ table: CATEGORY_TABLE, key_field: 'b_id', inserts: [row] }])
selectedCategoryKey.value = String(row.b_id)
}
@@ -500,7 +448,7 @@ async function onCategoryDrop({ dragKey, dropKey, dropPosition }) {
if (dragId === dropId) return
// 树只是 categories 的派生视图,所以一贯「在副本上算差异 → 落库 → 重载」
const tree = buildCategoryTree(categories.value)
const tree = buildSortedTree(categories.value)
const dragPos = findNodePosition(tree, dragId)
const dropPos = findNodePosition(tree, dropId)
if (!dragPos || !dropPos) return
@@ -28,7 +28,7 @@ import { FmsModuleListPage } from '@/components/fms-module-list'
import FmsTree from '@/components/fms-tree/FmsTree.vue'
import { loadDataApi } from '@/services/api'
import { usePermissionStore } from '@/stores/permissions'
import { buildTree, collectAllKeys, filterTreeByText, sortTree } from '@/utils/tree'
import { buildSortedTree, collectAllKeys, filterTreeByText } from '@/utils/tree'
/** 根模块编码:本页所有分类与数据模块都挂在此模块下 */
const ROOT_MODULE_ID = 'base_otherdata'
@@ -53,16 +53,8 @@ const categoryKeyword = ref('')
const moduleById = computed(() => new Map(modules.value.map((item) => [String(item.b_id), item])))
/**
* 树数据:把根之下的所有子孙按 b_parent_id 组织成树。
* 必须在浅拷贝上构建:buildTree 会就地往行上挂 children,直接构建会污染 modules。
*/
const categoryTree = computed(() => {
const rows = modules.value.map((row) => ({ ...row, children: undefined }))
const tree = buildTree(rows)
sortTree(tree)
return tree
})
/** 树数据:把根之下的所有子孙按 b_parent_id 组织成树(在浅拷贝上构建,不污染 modules) */
const categoryTree = computed(() => buildSortedTree(modules.value))
/** 搜索过滤:命中名称 / 编码,保留命中节点的子树 */
const filteredCategoryTree = computed(() =>
@@ -860,7 +860,6 @@ function flattenTree(nodes, depth = 0) {
<FmsTable
:columns="columns"
:data-source="filteredRows"
:row-class-name="rowClassName"
:row-selection="rowSelection"
row-key="id"
/>
@@ -7,7 +7,7 @@
* 落库只登记 data / virtual 模块编码:module 类型只是模块树上的分类,不参与权限,
* 确定时统一过滤掉;勾选草稿(含分类节点)仅存在于弹层内部。
*/
import { computed, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import { Modal } from '@/components/ui'
import FmsTree from '@/components/fms-tree/FmsTree.vue'
@@ -39,9 +39,6 @@ function isBindable(key) {
return type == null || type === 'data' || type === 'virtual'
}
/** 初始展开:只展开第一层根节点,深层分类按需点开,避免一次铺开整棵树 */
const rootKeys = computed(() => (props.treeData || []).map((node) => node.b_id))
function onOk() {
emit(
'confirm',
@@ -60,10 +57,10 @@ function onOk() {
@ok="onOk"
>
<div class="mp-tree">
<!-- 不设 default-expanded-keys:整棵树默认全部收起,由用户按需逐层点开 -->
<FmsTree
v-model:checked-keys="draftChecked"
:tree-data="treeData"
:default-expanded-keys="rootKeys"
checkable
/>
</div>
@@ -21,12 +21,18 @@ import { FolderTree, Save, RefreshCw } from '@lucide/vue'
import { Button, Splitter, SplitterPanel, Modal, Message, Spin } from '@/components/ui'
import { loadDataApi, saveObjectApi } from '@/services/api'
import {
buildTree,
sortTree,
findTreeNode,
addRowUpdate,
applyTreePosition,
buildSortedTree,
decorateTreeNodes,
expandAncestors,
findNodePosition,
findTreeNode,
insertTreeNode,
rebuildTreePositions,
removeTreeNode,
renumberSiblings,
sortTree,
} from '@/utils/tree'
import { cloneData, diffRow, diffRows } from '@/utils/dataChanges'
import { createTempId } from '@/utils/common'
@@ -36,14 +42,10 @@ import MenuTreePanel from './MenuTreePanel.vue'
import MenuBasicPanel from './MenuBasicPanel.vue'
import ModulePickerModal from './ModulePickerModal.vue'
import {
addRowUpdate,
applyMenuPosition,
MENU_NON_LEAF_TYPES,
MENU_TYPE_ICON_MAP,
buildMenuI18nKey,
findNodePosition,
markDirectoryNonLeaf,
menuRowOf,
rebuildTreePositions,
renumberSiblings,
} from './utils'
import { useI18nStore } from '@/stores/i18n'
@@ -158,7 +160,11 @@ async function loadMenuTree() {
menuTreeLoading.value = true
try {
const res = await loadDataApi('s_menu', '1 = 1', 'b_xh ASC, b_id ASC')
menuTreeData.value = markDirectoryNonLeaf(sortTree(buildTree(res.data || [])))
menuTreeData.value = decorateTreeNodes(buildSortedTree(res.data || []), {
typeField: 'b_menu_type',
nonLeafValues: MENU_NON_LEAF_TYPES,
iconMap: MENU_TYPE_ICON_MAP,
})
unsavedMenuIds.clear()
} catch (e) {
Message.error('加载菜单树失败:' + (e?.message || e))
@@ -221,7 +227,7 @@ async function onMenuDrop({ dragKey, dropKey, dropPosition }) {
}
// 只有目录能承载子菜单:页面/外链是末端节点,放入会形成「页面挂子菜单」的非法层级。
// 必须在移除被拖节点之前拦截,否则中途 return 会让节点凭空消失。
if (dropPosition === 'inside' && dropNode.b_menu_type !== 'directory') {
if (dropPosition === 'inside' && !MENU_NON_LEAF_TYPES.includes(dropNode.b_menu_type)) {
Message.warning('只能将菜单放入目录下')
return
}
@@ -330,7 +336,7 @@ async function addMenu(parentId, menuTypeValue = 'page') {
}
// b_depth / b_path 是 b_parent_id 的派生字段(b_path 非空且无默认值,缺失会新增失败)。
// 此处先按父级填一次占位值;保存前会用最终菜单编码重算(临时 id 不落库)。
applyMenuPosition(newMenu, parentNode)
applyTreePosition(newMenu, parentNode)
insertTreeNode(menuTreeData.value, newMenu, { sort: sortTree })
const ancestors = expandAncestors(menuTreeData.value, newId)
if (ancestors.length) {
@@ -611,10 +617,10 @@ async function beforeSave() {
// 否则会把 /-1/ 这类临时路径写进库。父级不在树中(数据异常)时无法派生,保持原值不动。
const parentId = menuData.b_parent_id ? String(menuData.b_parent_id) : null
if (!parentId) {
applyMenuPosition(menuData, null)
applyTreePosition(menuData, null)
} else {
const parentNode = findTreeNode(menuTreeData.value, parentId)
if (parentNode) applyMenuPosition(menuData, parentNode)
if (parentNode) applyTreePosition(menuData, parentNode)
}
// 受管 i18n 键:菜单统一 menu. 前缀
menuData.b_i18n = buildMenuI18nKey(menuCode)
@@ -828,7 +834,7 @@ async function loadModuleOptions() {
const res = await loadDataApi('s_module', 'b_canuse = 1', 'b_path ASC, b_xh ASC, b_id ASC')
const rows = res.data || []
moduleRows.value = rows
moduleTree.value = sortTree(buildTree(rows.map((row) => ({ ...row }))))
moduleTree.value = buildSortedTree(rows)
moduleOptionsLoaded = true
} catch (e) {
// Silently fail; modules will be empty
@@ -1,7 +1,7 @@
/**
* 菜单管理(menu-management)页面级纯函数。
* 只放无 Vue 状态依赖的逻辑:菜单类型转换、树节点定位、排序号重排、目录节点标记。
* 树本身的通用查找/过滤/插入/移除见 @/utils/tree,不在此重复。
* 只放无 Vue 状态依赖的逻辑:菜单类型选项、菜单行提取、多语言资源键。
* 树本身的通用查找/过滤/插入/移除/位置派生见 @/utils/tree,不在此重复。
*/
import { ExternalLink, FileText, Folder } from '@lucide/vue'
@@ -19,21 +19,8 @@ export const MENU_TYPE_ICON_MAP = {
external: ExternalLink,
}
/**
* 目录节点强制非叶子(即使暂无子菜单也显示展开箭头,便于新增子菜单),
* 并预载类型图标到 node.icon。就地修改并返回原数组。
* @param {Array} nodes 树节点数组
* @returns {Array} 原数组
*/
export function markDirectoryNonLeaf(nodes) {
for (const node of nodes) {
if (node.b_menu_type === 'directory') node.isLeaf = false
const icon = MENU_TYPE_ICON_MAP[node.b_menu_type]
if (icon) node.icon = icon
if (node.children) markDirectoryNonLeaf(node.children)
}
return nodes
}
/** 菜单树里能承载子节点的类型:页面 / 外链是末端节点 */
export const MENU_NON_LEAF_TYPES = ['directory']
/** s_menu 主表列(树节点 → 主表草稿行时按此提取,剔除 children/isLeaf/icon 等树注入字段) */
const MENU_ROW_FIELDS = [
@@ -67,18 +54,6 @@ export function menuRowOf(node) {
return row
}
/**
* 按父节点派生菜单行的 b_depth / b_path(b_parent_id 是权威关系,二者为派生字段)。
* 根节点 b_depth = 0,b_path 形如 /system/user/(节点编码前后均带 /)。
* b_path 非空且无默认值,新增时必须随行写入。
* @param {Object} row s_menu 行(就地修改,b_id 必须是最终菜单编码)
* @param {Object|null} parentNode 树中的父节点;null 表示根节点
*/
export function applyMenuPosition(row, parentNode) {
row.b_depth = parentNode ? Number(parentNode.b_depth ?? 0) + 1 : 0
row.b_path = parentNode ? `${String(parentNode.b_path || '')}${row.b_id}/` : `/${row.b_id}/`
}
/* ================= 多语言资源键(s_i18n,主键 b_key + b_locale) ================= */
/**
@@ -91,63 +66,3 @@ export function buildMenuI18nKey(menuId) {
const normalizedMenuId = String(menuId || '').trim()
return normalizedMenuId ? `menu.${normalizedMenuId}` : null
}
/**
* 在树中查找节点所在的父数组及索引。
* @param {Array} nodes 树节点数组
* @param {string|number} id 目标节点 ID
* @returns {{ list: Array, index: number }|null}
*/
export function findNodePosition(nodes, id) {
for (let i = 0; i < nodes.length; i++) {
if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
if (nodes[i].children) {
const found = findNodePosition(nodes[i].children, id)
if (found) return found
}
}
return null
}
/** 向 updatedRows 合并一行变更(同一 b_id 累加字段,避免同节点产生多条 update 请求) */
export function addRowUpdate(updatedRows, row) {
const existing = updatedRows.find((item) => String(item.b_id) === String(row.b_id))
if (existing) Object.assign(existing, row)
else updatedRows.push({ ...row })
}
/**
* 重排某组兄弟的 b_xh(10 起步、步长 10),并把变化的行收集到 updatedRows。
* @param {Array} list 兄弟节点数组(就地修改 b_xh)
* @param {Array} updatedRows 变化行收集器
*/
export function renumberSiblings(list, updatedRows) {
list.forEach((s, i) => {
const nextXh = (i + 1) * 10
if (s.b_xh !== nextXh) {
s.b_xh = nextXh
addRowUpdate(updatedRows, { b_id: s.b_id, b_xh: nextXh })
}
})
}
/**
* 递归重建菜单树的 b_depth / b_path(b_parent_id 为权威关系,二者为派生字段)。
* 根节点 b_depth = 0,b_path 形如 /system/user/(节点编码前后均带 /)。
* 仅在值实际变化时收集,避免打开即脏;父子关系变化后调用即可整树收敛。
* @param {Array} nodes 树节点数组(就地修改)
* @param {Array} updatedRows 变化行收集器
* @param {string} [parentPath] 父节点路径
* @param {number} [depth] 当前层级
*/
export function rebuildTreePositions(nodes, updatedRows, parentPath = '', depth = 0) {
for (const node of nodes) {
const path = `${parentPath}${node.b_id}/`
if (Number(node.b_depth) !== depth || String(node.b_path ?? '') !== path) {
node.b_depth = depth
node.b_path = path
addRowUpdate(updatedRows, { b_id: node.b_id, b_depth: depth, b_path: path })
}
if (node.children?.length) rebuildTreePositions(node.children, updatedRows, path, depth + 1)
}
}
@@ -34,14 +34,21 @@ import {
import { Button, Splitter, SplitterPanel, Tabs, Modal, Message, Spin } from '@/components/ui'
import { loadDataApi, saveObjectApi } from '@/services/api'
import {
buildTree,
sortTree,
findTreeNode,
addRowUpdate,
applyTreePosition,
buildSortedTree,
decorateTreeNodes,
expandAncestors,
findNodePosition,
findTreeNode,
insertTreeNode,
rebuildTreePositions,
removeTreeNode,
renumberSiblings,
sortTree,
} from '@/utils/tree'
import {
MODULE_NON_LEAF_TYPES,
MODULE_TYPE_ICON_MAP,
MODULE_TYPE_LABELS,
SCHEMA_TYPES,
@@ -54,13 +61,9 @@ import {
collectManagedI18nItems,
collectSchemaGroupKeys,
createDefaultSchema,
decorateModuleTree,
findNodePosition,
moduleRowOf,
parseSchema,
rebuildTreePositions,
reconcileI18nRows,
renumberSiblings,
schemaToJsonText,
syncModuleItemI18nKeys,
} from './utils'
@@ -504,7 +507,11 @@ async function loadModuleTree() {
moduleTreeLoading.value = true
try {
const res = await loadDataApi('s_module', '1 = 1', 'b_xh ASC, b_id ASC')
moduleTreeData.value = decorateModuleTree(sortTree(buildTree(res.data || [])))
moduleTreeData.value = decorateTreeNodes(buildSortedTree(res.data || []), {
typeField: 'b_module_type',
nonLeafValues: MODULE_NON_LEAF_TYPES,
iconMap: MODULE_TYPE_ICON_MAP,
})
unsavedModuleIds.clear()
} catch (e) {
Message.error('加载模块树失败:' + (e?.message || e))
@@ -518,13 +525,9 @@ async function addModule(parentId, newType) {
// 未落库节点先用本地临时负数 id 占位,仅用于树 key / 未保存判定,不写入数据库。
const newId = createTempId()
const parentNode = parentId ? findTreeNode(moduleTreeData.value, String(parentId)) : null
const depth = parentNode ? Number(parentNode.b_depth || 0) + 1 : 0
const path = parentNode ? `${String(parentNode.b_path || '')}${newId}/` : `/${newId}/`
const newModule = {
b_id: newId,
b_parent_id: parentId ? String(parentId) : null,
b_depth: depth,
b_path: path,
b_name: '',
b_i18n: null,
b_module_type: newType,
@@ -538,8 +541,9 @@ async function addModule(parentId, newType) {
b_xh: 10,
b_bz: null,
// 模块类型强制非叶子,即使暂无子模块也显示折叠箭头
isLeaf: newType === 'module' ? false : undefined,
isLeaf: MODULE_NON_LEAF_TYPES.includes(newType) ? false : undefined,
}
applyTreePosition(newModule, parentNode)
insertTreeNode(moduleTreeData.value, newModule, { sort: sortTree })
const ancestors = expandAncestors(moduleTreeData.value, newId)
if (ancestors.length) {
@@ -653,9 +657,27 @@ async function onModuleDrop({ dragKey, dropKey, dropPosition }) {
const dropPos = findNodePosition(moduleTreeData.value, dropId)
if (!dragPos || !dropPos) return
const dragNode = dragPos.list[dragPos.index]
const dropNode = dropPos.list[dropPos.index]
// 落点不能是被拖节点自身或它的后代:父子关系成环后树与 b_path 都会失真
if (findTreeNode([dragNode], dropId)) {
Message.warning('不能把模块移动到它自己的下级')
return
}
// 只有 module 类型能承载子模块:data / virtual 是叶子,放入会形成非法层级。
// 必须在移除被拖节点之前拦截,否则中途 return 会让节点凭空消失。
if (dropPosition === 'inside' && !MODULE_NON_LEAF_TYPES.includes(dropNode.b_module_type)) {
Message.warning('只能把模块放入模块类型的节点下')
return
}
// 未保存的模块尚未落库,其临时 id 不能作为持久化的 b_parent_id
if (dropPosition === 'inside' && unsavedModuleIds.has(dropId)) {
Message.warning('请先保存目标模块,再调整层级')
return
}
const dropParentId = dropNode.b_parent_id ?? null
const [moved] = dragPos.list.splice(dragPos.index, 1)
const movedParentId = moved.b_parent_id ?? null
const updatedRows = []
const renumberSets = new Set()
@@ -681,11 +703,30 @@ async function onModuleDrop({ dragKey, dropKey, dropPosition }) {
renumberSets.forEach((list) => renumberSiblings(list, updatedRows))
// 父子关系变化后整树重建 b_depth / b_path,仅收集实际变化的行
rebuildTreePositions(moduleTreeData.value, updatedRows)
// b_parent_id 是权威关系,必须与本节点的派生字段同批落库,
// 否则会留下「路径按新父级、父级列还是旧值」的不一致。
// 单独补一条:renumberSiblings / rebuildTreePositions 只收集实际变化的字段值,
// 它俩不负责父级列本身。
if (String(moved.b_parent_id ?? '') !== String(movedParentId ?? '')) {
addRowUpdate(updatedRows, { b_id: moved.b_id, b_parent_id: moved.b_parent_id ?? null })
}
// 若当前编辑的节点排序号/父级变化,同步到主表草稿
// 排序即时落库(下方 saveObjectApi),草稿与基线需一起同步:
// 只改 maindata 会让已落库的 b_xh / b_parent_id / b_depth / b_path 被
// hasUnsavedChanges 误判为未保存改动。未保存草稿节点例外:
// 它还没有基线可比,改动留在草稿里等保存流程处理。
if (maindata.value) {
const draftRow = updatedRows.find((row) => String(row.b_id) === String(maindata.value.b_id))
if (draftRow) Object.assign(maindata.value, draftRow)
const draftId = String(maindata.value.b_id)
const draftUnsaved = unsavedModuleIds.has(draftId)
const syncBaseline = !draftUnsaved && !!maindata_org.value
const draftRow = updatedRows.find((row) => String(row.b_id) === draftId)
if (draftRow) {
for (const [field, value] of Object.entries(draftRow)) {
if (field === 'b_id') continue
maindata.value[field] = value
if (syncBaseline) maindata_org.value[field] = value
}
}
}
if (!updatedRows.length) return
@@ -15,6 +15,9 @@ export const MODULE_TYPE_LABELS = {
virtual: '虚拟模块',
}
/** 模块树里能承载子模块的类型:data / virtual 是叶子节点 */
export const MODULE_NON_LEAF_TYPES = ['module']
/** 工具栏 / 右键「新增」菜单项(key 即 b_module_type) */
export const MODULE_ADD_OPTIONS = [
{ key: 'module', label: '新增模块', icon: FolderPlus },
@@ -51,20 +54,6 @@ export {
schemaToJsonText,
} from '@/utils/schemaJson'
/**
* 模块类型装饰:module 类型默认可展开(显示折叠箭头,即使暂无子模块),并附加类型图标。
* 就地修改并返回原数组。
*/
export function decorateModuleTree(nodes) {
for (const node of nodes) {
if (node.b_module_type === 'module') node.isLeaf = false
const icon = MODULE_TYPE_ICON_MAP[node.b_module_type]
if (icon) node.icon = icon
if (node.children) decorateModuleTree(node.children)
}
return nodes
}
/** 从树节点提取纯模块行(剔除 children/isLeaf/icon 等树注入字段),供未落库模块切回时重建主表草稿 */
export function moduleRowOf(node) {
const row = {}
@@ -72,60 +61,6 @@ export function moduleRowOf(node) {
return row
}
/**
* 在树中查找节点所在的父数组及索引。
* @returns {{ list: Array, index: number }|null}
*/
export function findNodePosition(nodes, id) {
for (let i = 0; i < nodes.length; i++) {
if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
if (nodes[i].children) {
const found = findNodePosition(nodes[i].children, id)
if (found) return found
}
}
return null
}
/** 向 updatedRows 合并一行变更(同一 b_id 累加字段,避免同节点产生多条 update 请求) */
function addRowUpdate(updatedRows, row) {
const existing = updatedRows.find((item) => String(item.b_id) === String(row.b_id))
if (existing) Object.assign(existing, row)
else updatedRows.push({ ...row })
}
/**
* 重排某组兄弟的 b_xh(10 起步、步长 10),把变化的行收集到 updatedRows。
* @param {Array} list 兄弟节点数组(就地修改 b_xh)
* @param {Array} updatedRows 变化行收集器
*/
export function renumberSiblings(list, updatedRows) {
list.forEach((sibling, index) => {
const nextXh = (index + 1) * 10
if (sibling.b_xh !== nextXh) {
sibling.b_xh = nextXh
addRowUpdate(updatedRows, { b_id: sibling.b_id, b_xh: nextXh })
}
})
}
/**
* 递归重建模块树的 b_depth / b_path(b_parent_id 为权威关系,二者为派生字段)。
* 根节点 b_depth = 0,b_path 形如 /sea/container/(节点编码前后均带 /)。
* 仅在值实际变化时收集,避免打开即脏;父子关系变化后调用即可整树收敛。
*/
export function rebuildTreePositions(nodes, updatedRows, parentPath = '', depth = 0) {
for (const node of nodes) {
const path = `${parentPath}${node.b_id}/`
if (Number(node.b_depth) !== depth || String(node.b_path ?? '') !== path) {
node.b_depth = depth
node.b_path = path
addRowUpdate(updatedRows, { b_id: node.b_id, b_depth: depth, b_path: path })
}
if (node.children?.length) rebuildTreePositions(node.children, updatedRows, path, depth + 1)
}
}
/* ================= 多语言资源键(s_i18n,主键 b_key + b_locale) ================= */
export function buildModuleItemI18nKey(prefix, moduleCode, itemCode) {
@@ -88,3 +88,30 @@ describe('Tree 右键菜单', () => {
wrapper.unmount()
})
})
describe('Tree 行内元素顺序', () => {
/**
* 回归点:箭头槽(非叶为真箭头、叶子为等宽占位)必须排在拖拽手柄之前。
* 放到手柄之后时,叶子行的手柄与标题之间会空出一整个箭头宽,读起来像多余空隙;
* 排在前面则这段空白归入左侧缩进带,手柄紧跟标题(见 tree-node.vue 模板注释)。
*/
const rowClasses = (row) => [...row.element.children].map((el) => el.className)
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'])
})
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',
])
})
})
@@ -0,0 +1,84 @@
/**
* 模块绑定弹层(ModulePickerModal):模块树默认全部收起。
*
* 回归点:弹层曾用 default-expanded-keys 展开第一层根节点,一打开就铺开整棵树;
* 现在不设该 prop,初始只渲染根节点,子节点由用户按需逐层点开。
* 折叠态下子树不渲染(tree-node.vue 用 v-if 而非 v-show),因此可直接数 treeitem。
*/
import { beforeEach, describe, expect, it } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import ModulePickerModal from '@/views/module/menu-management/ModulePickerModal.vue'
const TREE = [
{
b_id: 'base',
b_module_type: 'module',
b_name: '基础资料',
children: [
{
b_id: 'base_otherdata',
b_module_type: 'module',
b_name: '其他数据',
children: [{ b_id: 'b_port', b_module_type: 'data', b_name: '港口' }],
},
],
},
{
b_id: 'system',
b_module_type: 'module',
b_name: '系统',
children: [{ b_id: 'b_user', b_module_type: 'data', b_name: '用户' }],
},
]
const MODULE_TYPES = {
base: 'module',
base_otherdata: 'module',
b_port: 'data',
system: 'module',
b_user: 'data',
}
async function mountModal(props = {}) {
const wrapper = mount(ModulePickerModal, {
props: {
open: true,
treeData: TREE,
checkedKeys: [],
moduleTypes: MODULE_TYPES,
...props,
},
// Modal 用 Teleport 挂到 body;打桩后内容留在组件树内,便于断言
global: { stubs: { teleport: true } },
})
await flushPromises()
await nextTick()
return wrapper
}
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
})
describe('模块绑定弹层:树默认收起', () => {
it('初始只渲染根节点,没有任何节点处于展开态', async () => {
const wrapper = await mountModal()
const items = wrapper.findAll('[role="treeitem"]')
expect(items).toHaveLength(TREE.length)
expect(items.map((item) => item.attributes('aria-expanded'))).toEqual([
'false',
'false',
])
})
it('折叠态下子节点不渲染', async () => {
const wrapper = await mountModal()
expect(wrapper.text()).toContain('基础资料')
expect(wrapper.text()).not.toContain('其他数据')
expect(wrapper.text()).not.toContain('港口')
})
})
@@ -1,11 +1,9 @@
/**
* 往来单位分类树页面级纯函数。
* 往来单位分类树的页面级纯函数(仍内联在 views/base/othercompany/index.vue)。
*
* 页面单文件自包含后(分类树逻辑内联在 views/base/othercompany/index.vue),
* 纯函数不再单独导出,测试通过挂载页面组件访问 <script setup> 的内部绑定。
*
* 重点是 buildCategoryTree 不能污染原始行:buildTree 会就地往行对象上挂 children,
* 若直接在 categories 的行上构建,重复构建会累积出重复子节点。
* 建树、位置派生、排序号重排、整树重建等通用逻辑已收敛到 @/utils/tree,
* 其测试在 tests/unit/utils.spec.js;本文件只覆盖留在页面里的两个业务判定,
* 它们内联在页面中,因此通过挂载页面组件访问 <script setup> 的内部绑定。
*/
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
@@ -42,50 +40,6 @@ beforeEach(async () => {
vm = wrapper.vm
})
describe('othercompany 分类树工具:buildCategoryTree', () => {
it('按 b_parent_id 建树并按 b_xh 排序', () => {
const rows = [
category({ b_id: 'b', b_xh: 20, b_name: 'B' }),
category({ b_id: 'a', b_xh: 10, b_name: 'A' }),
category({ b_id: 'a1', b_parent_id: 'a', b_depth: 1, b_path: '/a/a1/', b_name: 'A1' }),
]
const tree = vm.buildCategoryTree(rows)
expect(tree.map((node) => node.b_id)).toEqual(['a', 'b'])
expect(tree[0].children.map((node) => node.b_id)).toEqual(['a1'])
})
it('不污染原始行,重复构建不会累积出重复子节点', () => {
const rows = [
category({ b_id: 'a' }),
category({ b_id: 'a1', b_parent_id: 'a', b_depth: 1, b_path: '/a/a1/' }),
]
vm.buildCategoryTree(rows)
// buildTree 的 children 挂在浅拷贝上,原始行保持干净
expect(rows[0].children).toBeUndefined()
const tree = vm.buildCategoryTree(rows)
expect(tree[0].children).toHaveLength(1)
expect(tree[0].children[0].b_id).toBe('a1')
})
})
describe('othercompany 分类树工具:位置派生', () => {
it('根节点的 b_depth 为 0、b_path 为 /编码/', () => {
const row = { b_id: 'a' }
vm.applyCategoryPosition(row, null)
expect(row).toMatchObject({ b_depth: 0, b_path: '/a/' })
})
it('子节点继承父级路径并递增层级', () => {
const row = { b_id: 'a1' }
vm.applyCategoryPosition(row, { b_depth: 2, b_path: '/a/b/' })
expect(row).toMatchObject({ b_depth: 3, b_path: '/a/b/a1/' })
})
})
describe('othercompany 分类树工具:排序号与子分类判定', () => {
it('同级最大 b_xh 加 10,无同级时取 10', () => {
const rows = [
@@ -113,62 +67,6 @@ describe('othercompany 分类树工具:排序号与子分类判定', () => {
})
})
describe('othercompany 分类树工具:重排与重建位置', () => {
it('renumberSiblings 按 10 步长重排并只收集变化的行', () => {
const a = { b_id: 'a', b_xh: 30 }
const b = { b_id: 'b', b_xh: 10 }
const updated = []
vm.renumberSiblings([a, b], updated)
expect([a.b_xh, b.b_xh]).toEqual([10, 20])
expect(updated).toEqual([
{ b_id: 'a', b_xh: 10 },
{ b_id: 'b', b_xh: 20 },
])
})
it('rebuildTreePositions 整树收敛 b_depth / b_path,未变化的不重复收集', () => {
const tree = [
{
b_id: 'a',
b_depth: 0,
b_path: '/a/',
children: [{ b_id: 'a1', b_depth: 9, b_path: '/stale/', children: [] }],
},
]
const updated = []
vm.rebuildTreePositions(tree, updated)
expect(tree[0].children[0]).toMatchObject({ b_depth: 1, b_path: '/a/a1/' })
// 根节点本来就是 0 / '/a/',不该产生一条无意义的 update
expect(updated).toEqual([{ b_id: 'a1', b_depth: 1, b_path: '/a/a1/' }])
})
it('addRowUpdate 按 b_id 合并字段,避免同节点多条 update', () => {
const updated = []
vm.addRowUpdate(updated, { b_id: 'a', b_xh: 20 })
vm.addRowUpdate(updated, { b_id: 'a', b_parent_id: 'p' })
vm.addRowUpdate(updated, { b_id: 'b', b_xh: 10 })
expect(updated).toEqual([
{ b_id: 'a', b_xh: 20, b_parent_id: 'p' },
{ b_id: 'b', b_xh: 10 },
])
})
it('findNodePosition 返回所在父数组与索引', () => {
const tree = [{ b_id: 'a', children: [{ b_id: 'a1' }, { b_id: 'a2' }] }]
expect(vm.findNodePosition(tree, 'a')).toMatchObject({ index: 0 })
const found = vm.findNodePosition(tree, 'a2')
expect(found.index).toBe(1)
expect(found.list.map((node) => node.b_id)).toEqual(['a1', 'a2'])
expect(vm.findNodePosition(tree, 'not-exist')).toBe(null)
})
})
describe('othercompany 分类树工具:虚拟根键', () => {
it('与 FmsTree 的 rootKey 约定保持一致', () => {
expect(vm.ALL_CATEGORY_KEY).toBe('__fms_tree_root__')
+151 -1
View File
@@ -1,5 +1,15 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { buildTree, sortTree } from '@/utils/tree'
import {
addRowUpdate,
applyTreePosition,
buildSortedTree,
buildTree,
decorateTreeNodes,
findNodePosition,
rebuildTreePositions,
renumberSiblings,
sortTree,
} from '@/utils/tree'
describe('utils/tree buildTree', () => {
it('空数组返回空树', () => {
@@ -91,6 +101,146 @@ describe('utils/tree sortTree', () => {
})
})
describe('utils/tree 可编辑树:建树与节点装饰', () => {
it('buildSortedTree 建树并排序,且不污染原始行(重复构建不累积子节点)', () => {
const rows = [
{ b_id: 'a', b_parent_id: null, b_xh: 20 },
{ b_id: 'b', b_parent_id: null, b_xh: 10 },
{ b_id: 'a1', b_parent_id: 'a', b_xh: 10 },
]
const tree = buildSortedTree(rows)
expect(tree.map((node) => node.b_id)).toEqual(['b', 'a'])
expect(tree[1].children.map((node) => node.b_id)).toEqual(['a1'])
// children 挂在浅拷贝上,原始行保持干净
expect(rows[0].children).toBeUndefined()
expect(buildSortedTree(rows)[1].children).toHaveLength(1)
})
it('decorateTreeNodes 预载类型图标,并强制指定类型非叶子', () => {
const Folder = () => null
const tree = [
{
b_id: 'a',
b_module_type: 'module',
children: [{ b_id: 'a1', b_module_type: 'data', children: [] }],
},
]
decorateTreeNodes(tree, {
typeField: 'b_module_type',
nonLeafValues: ['module'],
iconMap: { module: Folder },
})
expect(tree[0].isLeaf).toBe(false)
expect(tree[0].icon).toBe(Folder)
// 非 module 类型不改 isLeaf,图标表里没有的类型不写 icon
expect(tree[0].children[0].isLeaf).toBeUndefined()
expect(tree[0].children[0].icon).toBeUndefined()
})
})
describe('utils/tree 可编辑树:位置派生与就地变更', () => {
it('applyTreePosition 根节点的 b_depth 为 0、b_path 为 /编码/', () => {
const row = { b_id: 'a' }
applyTreePosition(row, null)
expect(row).toMatchObject({ b_depth: 0, b_path: '/a/' })
})
it('applyTreePosition 子节点继承父级路径并递增层级', () => {
const row = { b_id: 'a1' }
applyTreePosition(row, { b_depth: 2, b_path: '/a/b/' })
expect(row).toMatchObject({ b_depth: 3, b_path: '/a/b/a1/' })
})
it('findNodePosition 返回所在父数组与索引', () => {
const tree = [{ b_id: 'a', children: [{ b_id: 'a1' }, { b_id: 'a2' }] }]
expect(findNodePosition(tree, 'a')).toMatchObject({ index: 0 })
const found = findNodePosition(tree, 'a2')
expect(found.index).toBe(1)
expect(found.list.map((node) => node.b_id)).toEqual(['a1', 'a2'])
expect(findNodePosition(tree, 'not-exist')).toBe(null)
})
it('addRowUpdate 按 b_id 合并字段,避免同节点多条 update', () => {
const updated = []
addRowUpdate(updated, { b_id: 'a', b_xh: 20 })
addRowUpdate(updated, { b_id: 'a', b_parent_id: 'p' })
addRowUpdate(updated, { b_id: 'b', b_xh: 10 })
expect(updated).toEqual([
{ b_id: 'a', b_xh: 20, b_parent_id: 'p' },
{ b_id: 'b', b_xh: 10 },
])
})
it('renumberSiblings 按 10 步长重排并只收集变化的行', () => {
const a = { b_id: 'a', b_xh: 30 }
const b = { b_id: 'b', b_xh: 10 }
const updated = []
renumberSiblings([a, b], updated)
expect([a.b_xh, b.b_xh]).toEqual([10, 20])
expect(updated).toEqual([
{ b_id: 'a', b_xh: 10 },
{ b_id: 'b', b_xh: 20 },
])
})
it('rebuildTreePositions 整树收敛 b_depth / b_path,未变化的不重复收集', () => {
const tree = [
{
b_id: 'a',
b_depth: 0,
b_path: '/a/',
children: [{ b_id: 'a1', b_depth: 9, b_path: '/stale/', children: [] }],
},
]
const updated = []
rebuildTreePositions(tree, updated)
expect(tree[0].children[0]).toMatchObject({ b_depth: 1, b_path: '/a/a1/' })
// 根节点本来就是 0 / '/a/',不该产生一条无意义的 update
expect(updated).toEqual([{ b_id: 'a1', b_depth: 1, b_path: '/a/a1/' }])
})
it('rebuildTreePositions 根节点路径保留前导斜杠(回归:三份实现曾漂移)', () => {
const tree = [
{
b_id: 'base',
b_depth: 0,
b_path: '/base/',
children: [
{ b_id: 'base_otherdata', b_depth: 1, b_path: '/base/base_otherdata/', children: [] },
],
},
]
const updated = []
rebuildTreePositions(tree, updated)
expect(tree[0].b_path).toBe('/base/')
expect(tree[0].children[0].b_path).toBe('/base/base_otherdata/')
// 与库中既有格式一致,整棵树都不该被改动
expect(updated).toEqual([])
})
it('rebuildTreePositions 修正过期路径时补全前导斜杠', () => {
const tree = [{ b_id: 'base', b_depth: 3, b_path: 'stale/', children: [] }]
const updated = []
rebuildTreePositions(tree, updated)
expect(tree[0]).toMatchObject({ b_depth: 0, b_path: '/base/' })
expect(updated).toEqual([{ b_id: 'base', b_depth: 0, b_path: '/base/' }])
})
})
describe('utils/theme', () => {
beforeEach(() => {
document.documentElement.style.cssText = ''