20260915170028
This commit is contained in:
1 parent
d26afb5fec
commit
1f56cc3efb
89 files changed
+1135
-397
No files matched your search
@@ -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 / 布局缓存 / 跨页面工具时补相关页面与状态测试;若被改动前已存在的缺失文件、旧测试或环境问题阻断,在结果中明确记录,不要为通过检查去改无关代码。
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -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"
|
||||
|
||||
@@ -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__')
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
Reference in new issue
Block a user