diff --git a/code/fms/CODEBUDDY.md b/code/fms/CODEBUDDY.md new file mode 100644 index 00000000..94f078ed --- /dev/null +++ b/code/fms/CODEBUDDY.md @@ -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` 取参。 + +### 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 / 布局缓存 / 跨页面工具时补相关页面与状态测试;若被改动前已存在的缺失文件、旧测试或环境问题阻断,在结果中明确记录,不要为通过检查去改无关代码。 diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/FmsApiApplication.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/FmsApiApplication.class new file mode 100644 index 00000000..9a055d80 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/FmsApiApplication.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/AuthProperties.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/AuthProperties.class new file mode 100644 index 00000000..a5187a9b Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/AuthProperties.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties$Local.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties$Local.class new file mode 100644 index 00000000..822c502c Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties$Local.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties$Oss.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties$Oss.class new file mode 100644 index 00000000..feb115f3 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties$Oss.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties.class new file mode 100644 index 00000000..9098305c Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/JacksonConfig.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/JacksonConfig.class new file mode 100644 index 00000000..e57213b4 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/JacksonConfig.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/JwtAuthFilter.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/JwtAuthFilter.class new file mode 100644 index 00000000..dfc53f4b Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/JwtAuthFilter.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/AuthController.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/AuthController.class new file mode 100644 index 00000000..4d749e7b Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/AuthController.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/DataController.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/DataController.class new file mode 100644 index 00000000..4e9c4f4b Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/DataController.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/FileController.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/FileController.class new file mode 100644 index 00000000..5bdd23f5 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/FileController.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgContext.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgContext.class new file mode 100644 index 00000000..05b8fe4f Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgContext.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceFactory.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceFactory.class new file mode 100644 index 00000000..01c250f1 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceFactory.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceManager$PoolEntry.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceManager$PoolEntry.class new file mode 100644 index 00000000..a7514698 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceManager$PoolEntry.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceManager.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceManager.class new file mode 100644 index 00000000..c3c80f8d Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceManager.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfig.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfig.class new file mode 100644 index 00000000..4aefdc8f Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfig.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoader.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoader.class new file mode 100644 index 00000000..9120d513 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoader.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcher.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcher.class new file mode 100644 index 00000000..9c18dc14 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcher.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseProperties$Pool.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseProperties$Pool.class new file mode 100644 index 00000000..4caaae3e Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseProperties$Pool.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseProperties.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseProperties.class new file mode 100644 index 00000000..b88b12ec Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseProperties.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgRoutingDataSource.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgRoutingDataSource.class new file mode 100644 index 00000000..31944e40 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgRoutingDataSource.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/AuthenticationException.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/AuthenticationException.class new file mode 100644 index 00000000..e6a0569d Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/AuthenticationException.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/BusinessException.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/BusinessException.class new file mode 100644 index 00000000..13e0bdd3 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/BusinessException.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.class new file mode 100644 index 00000000..6dc0382c Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/SaveObjectException.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/SaveObjectException.class new file mode 100644 index 00000000..b1e39134 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/SaveObjectException.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ActiveSessionRegistry$AccountKey.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ActiveSessionRegistry$AccountKey.class new file mode 100644 index 00000000..1e7ed09e Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ActiveSessionRegistry$AccountKey.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ActiveSessionRegistry.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ActiveSessionRegistry.class new file mode 100644 index 00000000..ffaa78c5 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ActiveSessionRegistry.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/AuthService.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/AuthService.class new file mode 100644 index 00000000..653f5ecd Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/AuthService.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$AutoCodeFormat.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$AutoCodeFormat.class new file mode 100644 index 00000000..f70dc295 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$AutoCodeFormat.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$AutoCodeRule.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$AutoCodeRule.class new file mode 100644 index 00000000..8da50337 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$AutoCodeRule.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$CodeCandidate.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$CodeCandidate.class new file mode 100644 index 00000000..89938570 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$CodeCandidate.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService.class new file mode 100644 index 00000000..4872822f Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataService.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataService.class new file mode 100644 index 00000000..631a4e27 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataService.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/FileService.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/FileService.class new file mode 100644 index 00000000..78a29caf Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/FileService.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/LoginLogService.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/LoginLogService.class new file mode 100644 index 00000000..f7efd3a6 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/LoginLogService.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ApiResponse.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ApiResponse.class new file mode 100644 index 00000000..2a5766aa Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ApiResponse.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ClientInfo.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ClientInfo.class new file mode 100644 index 00000000..178049c4 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ClientInfo.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ClientInfoExtractor.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ClientInfoExtractor.class new file mode 100644 index 00000000..334490a1 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ClientInfoExtractor.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$ColumnMetadata.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$ColumnMetadata.class new file mode 100644 index 00000000..dc854182 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$ColumnMetadata.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$PageResult.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$PageResult.class new file mode 100644 index 00000000..317643e9 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$PageResult.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$TableMetadata.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$TableMetadata.class new file mode 100644 index 00000000..3ea51b9c Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$TableMetadata.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils.class new file mode 100644 index 00000000..1f8e2769 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/IpRegionSearcher.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/IpRegionSearcher.class new file mode 100644 index 00000000..e1bc47f1 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/IpRegionSearcher.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/IpUtils.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/IpUtils.class new file mode 100644 index 00000000..8e837371 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/IpUtils.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/JwtUtils$TokenClaims.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/JwtUtils$TokenClaims.class new file mode 100644 index 00000000..752775dc Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/JwtUtils$TokenClaims.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/JwtUtils.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/JwtUtils.class new file mode 100644 index 00000000..f2ae6fa9 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/JwtUtils.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ParamUtils.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ParamUtils.class new file mode 100644 index 00000000..779e1855 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ParamUtils.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/StringUtils.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/StringUtils.class new file mode 100644 index 00000000..4b26a3bd Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/StringUtils.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/UuidV7Generator.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/UuidV7Generator.class new file mode 100644 index 00000000..df2a56e9 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/UuidV7Generator.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/IIdGenerator.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/IIdGenerator.class new file mode 100644 index 00000000..2ea9b922 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/IIdGenerator.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/ISnowWorker.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/ISnowWorker.class new file mode 100644 index 00000000..9a5ea458 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/ISnowWorker.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorException.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorException.class new file mode 100644 index 00000000..81bfb7ed Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorException.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorOptions.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorOptions.class new file mode 100644 index 00000000..d7f0b038 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorOptions.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/OverCostActionArg.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/OverCostActionArg.class new file mode 100644 index 00000000..b35764b1 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/OverCostActionArg.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM1.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM1.class new file mode 100644 index 00000000..aed62c84 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM1.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM2.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM2.class new file mode 100644 index 00000000..40d4112c Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM2.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/idgen/DefaultIdGenerator.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/idgen/DefaultIdGenerator.class new file mode 100644 index 00000000..81ba7abd Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/idgen/DefaultIdGenerator.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/idgen/IdGenerator.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/idgen/IdGenerator.class new file mode 100644 index 00000000..9ca7e76a Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/idgen/IdGenerator.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/FmsApiApplicationTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/FmsApiApplicationTests.class index ba7577c4..16e04d81 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/FmsApiApplicationTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/FmsApiApplicationTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceFactoryTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceFactoryTests.class index 50c335c9..6f18a08a 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceFactoryTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceFactoryTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests$1.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests$1.class index e741e737..87d0c86e 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests$1.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests$1.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests.class index 4b8e906a..cae9c258 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoaderTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoaderTests.class index 8dd518fe..b771450e 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoaderTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoaderTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests$1.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests$1.class index 109918f7..0c37f74f 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests$1.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests$1.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests.class index 27facbf7..f6a9a417 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.class index 62a855e6..c4ed5132 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class index 3a1e9e1d..2f3cd581 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/AuthServiceTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/AuthServiceTests.class index 622e41a8..e9bfccb4 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/AuthServiceTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/AuthServiceTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests$JdbcFixture.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests$JdbcFixture.class index f561651b..25c40ab4 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests$JdbcFixture.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests$JdbcFixture.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests.class index c6ca5921..00725152 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/JwtUtilsTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/JwtUtilsTests.class index e9485923..09ae1d75 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/JwtUtilsTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/JwtUtilsTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/snowflake/DefaultIdGeneratorTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/snowflake/DefaultIdGeneratorTests.class index e777433d..ff9a76ed 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/snowflake/DefaultIdGeneratorTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/snowflake/DefaultIdGeneratorTests.class differ diff --git a/code/fms/fms-api/tools/migration/README.md b/code/fms/fms-api/tools/migration/README.md index adbc04ac..d5d678d4 100644 --- a/code/fms/fms-api/tools/migration/README.md +++ b/code/fms/fms-api/tools/migration/README.md @@ -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 ` | 连接配置,默认 `config/dbconfigs/G3HD.properties` | + +> 修复只重算派生字段,全部内容都能由 `b_parent_id` 重新推出,因此可安全重复执行; +> 执行后重跑一次只读报告应得到「待更新 0 行」。 + +--- + ## 已有同步记录 | 模块 / 视图 | 同步日期 | 字段数 | 备注 | diff --git a/code/fms/fms-api/tools/migration/TreePathRepair.java b/code/fms/fms-api/tools/migration/TreePathRepair.java new file mode 100644 index 00000000..7fbff819 --- /dev/null +++ b/code/fms/fms-api/tools/migration/TreePathRepair.java @@ -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 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 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 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 nodes = loadNodes(connection, table); + Set onCycle = findCycleNodes(nodes); + + Map expected = new LinkedHashMap<>(); + Set blocked = new LinkedHashSet<>(); + Set orphan = new LinkedHashSet<>(); + Set tooLong = new LinkedHashSet<>(); + for (String id : nodes.keySet()) { + resolve(id, nodes, expected, onCycle, blocked, orphan, tooLong); + } + + List changes = new ArrayList<>(); + int depthMismatch = 0; + int pathMismatch = 0; + int missingLeadingSlash = 0; + for (Map.Entry 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 loadNodes(Connection connection, String table) throws Exception { + String sql = "SELECT b_id, b_parent_id, b_depth, b_path FROM dbo." + table; + Map 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 findCycleNodes(Map nodes) { + Set onCycle = new LinkedHashSet<>(); + for (String start : nodes.keySet()) { + Map seen = new LinkedHashMap<>(); + String current = start; + while (current != null && nodes.containsKey(current)) { + Integer previous = seen.get(current); + if (previous != null) { + List 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 nodes, + Map cache, + Set onCycle, + Set blocked, + Set orphan, + Set 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 limit(Set values) { + return values.stream().limit(SAMPLE_LIMIT).toList(); + } + + private static String join(List values) { + return String.join(", ", values); + } + + private static void printUsage() { + System.out.println("用法: TreePathRepair [--dry-run | --apply] [--tables s_module,s_menu,...] [--config ]"); + 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) { + } +} diff --git a/code/fms/fms-vue/src/components/fms-table/FmsTable.vue b/code/fms/fms-vue/src/components/fms-table/FmsTable.vue index f137ebc8..39e1e866 100644 --- a/code/fms/fms-vue/src/components/fms-table/FmsTable.vue +++ b/code/fms/fms-vue/src/components/fms-table/FmsTable.vue @@ -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; diff --git a/code/fms/fms-vue/src/components/ui/tree/tree-node.vue b/code/fms/fms-vue/src/components/ui/tree/tree-node.vue index b88e2a42..7f3c692c 100644 --- a/code/fms/fms-vue/src/components/ui/tree/tree-node.vue +++ b/code/fms/fms-vue/src/components/ui/tree/tree-node.vue @@ -182,19 +182,11 @@ function onContextMenu(e) { @dragend="onDragEnd" @contextmenu.stop="onContextMenu" > - - - - - + 比同层非叶子靠左、并与父级标题齐平(缩进不足),层级关系丢失。 + 位置在拖拽手柄之前:占位是「缩进带」的一部分(叶子行它是空白), + 放到手柄之后会让手柄与标题之间空出一个箭头宽,读起来像多余空隙。 --> + + + + + ({ ...row })) + return sortTree(buildTree(copied, idKey, parentKey)) +} + +/** + * 预载节点图标并指定非叶子类型(即使暂无子节点也显示展开箭头,便于新增子节点)。 + * 就地修改并返回原数组。 + * @param {Array} nodes 树节点数组 + * @param {Object} options + * @param {string} options.typeField 类型字段名,如 "b_module_type" + * @param {Array} 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) + } } \ No newline at end of file diff --git a/code/fms/fms-vue/src/views/base/othercompany/index.vue b/code/fms/fms-vue/src/views/base/othercompany/index.vue index 47731883..f6aff343 100644 --- a/code/fms/fms-vue/src/views/base/othercompany/index.vue +++ b/code/fms/fms-vue/src/views/base/othercompany/index.vue @@ -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 diff --git a/code/fms/fms-vue/src/views/base/otherdata/index.vue b/code/fms/fms-vue/src/views/base/otherdata/index.vue index cadb7fbc..e5cdb135 100644 --- a/code/fms/fms-vue/src/views/base/otherdata/index.vue +++ b/code/fms/fms-vue/src/views/base/otherdata/index.vue @@ -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(() => diff --git a/code/fms/fms-vue/src/views/dashboard/index.vue b/code/fms/fms-vue/src/views/dashboard/index.vue index 68514595..b0dfd789 100644 --- a/code/fms/fms-vue/src/views/dashboard/index.vue +++ b/code/fms/fms-vue/src/views/dashboard/index.vue @@ -860,7 +860,6 @@ function flattenTree(nodes, depth = 0) { diff --git a/code/fms/fms-vue/src/views/module/menu-management/ModulePickerModal.vue b/code/fms/fms-vue/src/views/module/menu-management/ModulePickerModal.vue index 9f050678..9d559ee0 100644 --- a/code/fms/fms-vue/src/views/module/menu-management/ModulePickerModal.vue +++ b/code/fms/fms-vue/src/views/module/menu-management/ModulePickerModal.vue @@ -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" >
+
diff --git a/code/fms/fms-vue/src/views/module/menu-management/index.vue b/code/fms/fms-vue/src/views/module/menu-management/index.vue index 8dd31464..34103337 100644 --- a/code/fms/fms-vue/src/views/module/menu-management/index.vue +++ b/code/fms/fms-vue/src/views/module/menu-management/index.vue @@ -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 diff --git a/code/fms/fms-vue/src/views/module/menu-management/utils.js b/code/fms/fms-vue/src/views/module/menu-management/utils.js index e5b8c294..8a3990a5 100644 --- a/code/fms/fms-vue/src/views/module/menu-management/utils.js +++ b/code/fms/fms-vue/src/views/module/menu-management/utils.js @@ -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) - } -} diff --git a/code/fms/fms-vue/src/views/module/module-management/index.vue b/code/fms/fms-vue/src/views/module/module-management/index.vue index de659091..90ed6c60 100644 --- a/code/fms/fms-vue/src/views/module/module-management/index.vue +++ b/code/fms/fms-vue/src/views/module/module-management/index.vue @@ -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 diff --git a/code/fms/fms-vue/src/views/module/module-management/utils.js b/code/fms/fms-vue/src/views/module/module-management/utils.js index 2c4c058c..9d950ebf 100644 --- a/code/fms/fms-vue/src/views/module/module-management/utils.js +++ b/code/fms/fms-vue/src/views/module/module-management/utils.js @@ -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) { diff --git a/code/fms/fms-vue/tests/components/tree.spec.js b/code/fms/fms-vue/tests/components/tree.spec.js index 73aac03a..bb2695f5 100644 --- a/code/fms/fms-vue/tests/components/tree.spec.js +++ b/code/fms/fms-vue/tests/components/tree.spec.js @@ -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', + ]) + }) +}) diff --git a/code/fms/fms-vue/tests/unit/module-picker-modal.spec.js b/code/fms/fms-vue/tests/unit/module-picker-modal.spec.js new file mode 100644 index 00000000..227d2bee --- /dev/null +++ b/code/fms/fms-vue/tests/unit/module-picker-modal.spec.js @@ -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('港口') + }) +}) diff --git a/code/fms/fms-vue/tests/unit/othercompany-utils.spec.js b/code/fms/fms-vue/tests/unit/othercompany-utils.spec.js index 23c149c0..c02c43b6 100644 --- a/code/fms/fms-vue/tests/unit/othercompany-utils.spec.js +++ b/code/fms/fms-vue/tests/unit/othercompany-utils.spec.js @@ -1,11 +1,9 @@ /** - * 往来单位分类树页面级纯函数。 + * 往来单位分类树的页面级纯函数(仍内联在 views/base/othercompany/index.vue)。 * - * 页面单文件自包含后(分类树逻辑内联在 views/base/othercompany/index.vue), - * 纯函数不再单独导出,测试通过挂载页面组件访问