20260719215738

This commit is contained in:
oneao committed 2026-07-19 21:57:40 +08:00
1 parent 35aa108a34
commit cda7f16d3a
116 files changed
+11826 -3669

No files matched your search

@@ -0,0 +1,71 @@
# 功能树与模块配置重构计划
## 1. 目标
本次重构解决两个问题:
1. 技术人员能够按业务结构定位页面下的主模块、明细模块和隐藏模块,不再依赖扁平列表逐个搜索。
2. 字段和表单分组在同一个工作区维护,常用配置直接编辑,高级配置按需展开。
功能树不生成页面或 Tab。页面布局和模块组合继续由手写 Vue 页面负责。
## 2. 数据边界
| 数据 | 责任 |
| --- | --- |
| `s_function_node` | 目录、页面、技术分组和模块管理位置;菜单由可见节点投影 |
| `s_module` | 查询对象、保存对象、业务主键和模块级状态 |
| `s_module_field` | 列表、查询和表单字段配置 |
| `s_module_group` | 表单字段分组 |
功能节点父子关系只表示管理位置,不定义保存事务或业务组合。页面节点可以绑定主模块,权限继续按菜单入口、模块和动作独立校验。
## 3. 实施阶段
### 阶段 A:功能树基础
- 创建 `s_function_node`,页面菜单允许直接绑定模块。
- 写入“系统管理 / 模块管理 / 模块定义”最小种子。
- 模块管理加载完整功能树,未被功能节点引用的模块进入“未归档模块”。
- 搜索匹配节点名称、模块编码、模块名称、查询对象和保存对象,并保留祖先节点。
### 阶段 B:模块配置工作区
- 左侧用功能树代替分页模块表格。
- 目录和页面节点只负责展开;模块节点打开右侧配置。
- 保留模块启停快捷开关。
- 提供“模块”和“菜单”两个显示开关,隐藏菜单层级时仍能直接定位模块。
- 从当前树节点新建模块时自动带入归属位置,保存模块后同步创建树节点。
- 右侧显示模块完整路径和模块编码。
### 阶段 C:字段设计器
- 合并“字段配置”和“表单分组”为一个“字段设计”Tab。
- 左侧显示“全部字段、未分组、已配置分组”。
- 分组支持新增、编辑、删除和排序。
- 右侧字段表格直接编辑标题、控件、分组、列表显示、表单显示、必填和启停。
- 字段表格一次只允许一行进入编辑态,高级属性在当前行下方展开,不使用字段抽屉。
- 字段表格占满字段设计工作区剩余高度。
- 保存仍复用模块、字段、分组一次事务提交。
### 阶段 D:菜单投影
- 完成现有静态菜单到功能树的迁移清单。
- 侧边栏读取 `b_show_in_menu = 1` 的目录和页面,并按节点编码或页面路由与现有静态菜单合并。
- 功能目录修改后刷新菜单投影;在迁移完成前保留未登记的静态菜单,避免功能入口丢失。
- 所有菜单节点和路由核对完成后,再删除静态回退配置。
## 4. 验收标准
- 可以从“海运业务 / 海运业务单 / 装箱”路径定位主表和明细模块。
- 隐藏模块不出现在业务菜单,但可在模块管理中定位。
- 同一个模块可以被多个功能节点引用。
- 页面和 Tab 不由配置自动生成。
- 未归档模块始终可搜索和打开。
- 字段常用属性无需逐个打开弹窗即可修改。
- 删除分组时仍阻止删除被字段引用的分组。
- 功能树、模块配置和权限的职责没有混用。
## 5. 当前迁移策略
当前版本已经完成阶段 A、B、C,并以兼容合并方式启动阶段 D。功能树中的可见目录和页面会立即投影到侧边栏;尚未登记的旧菜单继续使用静态配置。待现有菜单和路由全部登记并核对后,侧边栏再切换为完全由功能树驱动。
@@ -0,0 +1,250 @@
# 模块管理功能设计
> 范围:模块管理这一功能本身的设计定义,覆盖 **模块、字段定义、菜单、多语言、权限** 五大能力。
> 定位:本文档描述「模块管理页面能配置什么、各能力如何分工」,**不描述**通用 `ModuleTable`/`ModuleForm` 的渲染实现,也不描述角色赋权页面(授权在另一处)。
> 配套原型:`fms-vue/src/views/ModuleManageDemo.vue`(纯前端,仅作演示)。
---
## 1. 设计目标与边界
模块管理是一个**配置中枢**,让实施人员用「填表」的方式定义一套业务功能,而不必改代码。它同时服务于四类下游消费者:
| 消费者 | 从模块管理取走什么 |
| --- | --- |
| 通用列表 `ModuleTable` | 字段清单、列标题、列表/表单开关、排序 |
| 通用表单 `ModuleForm` | 字段控件类型、校验、表单分组 |
| 菜单投影 | 哪些功能节点可见、显示什么名字 |
| 权限内核 | 该模块声明了哪些操作(编码)可供授权 |
### 核心原则:各自负责各自
1. **权限负责「有什么操作」**,只声明编码目录,不持有任何界面文案。
2. **多语言负责「所有界面显示名」**,是唯一文案数据源;但**只覆盖菜单 / 模块 / 字段三类显示名,不覆盖权限项**(权限名是给管理员看的配置标签,不是给终端用户的按钮文案)。
3. **字段定义驱动列表与表单**,自身不关心权限、不关心翻译落点。
4. **菜单是功能树的可投影子集**,不单独存一份菜单数据。
---
## 2. 模块(Module)
模块是配置的中心对象,聚合「数据源 + 字段 + 表单分组 + 操作目录」。
### 2.1 基本信息
| 项 | 说明 |
| --- | --- |
| 模块编码 `b_id` | 唯一主键,如 `M_BIZ`、`M_USER` |
| 模块名称 | 在「多语言」中以 `module.<编码>` 维护(见 §5) |
| 查询对象 `view_name` | 只读裸对象名(视图),如 `v_bs_business` |
| 保存对象 `save_name` | 可写裸表名,如 `b_business`;只读模块允许为空 |
| 业务主键 | 可选,用于展示与去重的业务字段,不代替系统主键 `b_id` |
| 默认排序 | 如 `b_id DESC` |
| 表单分组 | 见 §3.3 |
### 2.2 操作目录(权限项来源,见 §6)
模块声明它拥有哪些操作:`actions: [{ code, name }, ...]`。例如 `M_BIZ` 声明 `1=新增 … 7=导出`。
### 2.3 与其它能力的关联
```
Module
├─ fields[] → 驱动 ModuleTable / ModuleForm(§3)
├─ groups[] → 组织 Form 分组(§3.3)
├─ actions[] → 声明可被授权的操作编码(§6)
└─ 被功能树 page/module 节点引用 → 投影进菜单(§4)
```
---
## 3. 字段定义(Field)
字段是模块下的最小元数据单元,**通用列表与通用表单都由字段配置驱动**。
### 3.1 字段属性
| 属性 | 说明 |
| --- | --- |
| 字段编码 `code` | 模块对前端暴露的稳定字段名,如 `b_ywno`、`b_amount` |
| 标题 `caption` | 在「多语言」中以 `field.<模块>.<字段>` 维护,列表/表单共用 |
| 数据类型 `dataType` | `string / text / number / select / date / boolean` |
| 表单分组 `groupId` | 指向模块的分组,Form 按分组渲染 |
| 列表显示 `showInList` | 是否作为列表列 |
| 表单显示 `showInForm` | 是否在表单中出现 |
| 必填 `required` | 表单校验 |
> 字段配置不保存查询操作符、不保存固定 SQL;高级查询运行时按字段类型给出合法操作符。
### 3.2 驱动关系
- **列表列** = `fields` 中 `showInList = true` 的子集,按所属分组/排序渲染列与标题。
- **表单控件** = `fields` 中 `showInForm = true` 的子集,`dataType` 决定控件(文本/数字/下拉/日期/布尔),`required` 决定校验。
### 3.3 表单分组(Group)
模块下可设多个分组(如「主信息」「费用信息」),字段通过 `groupId` 归属分组。简单表单可不配置分组,落入默认分组。
---
## 4. 菜单(Menu / 功能树)
菜单**不是独立数据**,而是「功能树」中满足条件的节点投影。
### 4.1 功能树节点
统一功能树 `s_function_node` 组织全部入口,节点类型:
| 类型 | 含义 | 能否投影菜单 |
| --- | --- | --- |
| `directory` | 业务域/目录 | 可(`showInMenu`) |
| `page` | 已注册手写页面,可绑定模块 | 可(`showInMenu`) |
| `module` | 纯配置定位,供技术归类 | 否 |
| `group` | 配置分组 | 否 |
### 4.2 菜单投影规则
一个节点真正出现在某角色的菜单里,需同时满足:
1. 节点类型 ∈ {`directory`, `page`};
2. `showInMenu = true`;
3. 当前角色对该节点拥有「查看」权限。
### 4.3 关键场景:同模块、不同页面、不同权限
多个 `page` 节点可绑定**同一个模块**(如 `M_BIZ` 被 `海运业务单 / 业务单查询 / 业务单审核` 三个页面引用)。它们共享字段与操作目录,但:
- 可见性各自独立(`业务单审核` 设 `showInMenu=false`,不进菜单);
- 权限各自独立(操作员在「海运业务单」可提交,在「业务单审核」仅可审核)——授权由角色赋权页另配(不在本页面)。
- 模块父子关系(含 `module` 节点)仅用于技术归类与定位,**不**定义保存事务、主子表外键或业务流程。
---
## 5. 多语言(i18n)
所有**界面显示名**唯一数据源,以稳定 `i18nKey` 为中心,多种语言并列。
### 5.1 覆盖范围(仅三类显示名)
| 命名空间 | i18nKey 规则 | 示例 |
| --- | --- | --- |
| 菜单/功能树 | `menu.<节点id>` | `menu.page_biz` |
| 模块 | `module.<模块编码>` | `module.M_BIZ` |
| 字段 | `field.<模块>.<字段code>` | `field.M_BIZ.b_ywno` |
> **权限项名称不在此列**(见 §6 说明)。
### 5.2 数据结构
```js
i18nData = {
"menu.page_biz": { "zh-CN": "海运业务单", "en-US": "Sea Business Orders", "zh-TW": "海運業務單" },
"field.M_BIZ.b_ywno": { "zh-CN": "业务单号", "en-US": "Order No.", "zh-TW": "業務單號" }
// ...
}
```
- 实体本身**只持有 `i18nKey`,不持有任何文案**,显示一律走 `t(i18nKey)`。
- 回退顺序:当前语言 → `zh-CN` → `i18nKey` 本身。
- 切换语言时,功能树、字段标题、菜单预览实时联动。
### 5.3 管理界面要求
- 按命名空间类型筛选(菜单 / 模块 / 字段)。
- 以「Key + 各语言列」并列编辑;某语言留空即回退简体。
- **缺失翻译检测**:非默认语言留空时标红,并汇总「N 条缺失」,提示补录。
---
## 6. 权限(Permission)
权限负责**声明一个模块有哪些可授权操作**,仅此而已。
### 6.1 权限项结构
```js
// 模块声明
actions: [
{ code: 1, name: "新增" },
{ code: 5, name: "审核" },
{ code: 7, name: "导出" }
]
```
- `code`:稳定操作编码,前端与后端统一引用(如 `5` 永远代表「审核」)。**授权与校验只认 `code`,不认名字。**
- `name`:管理员可读的默认标签,仅一份(简体)。它是「这个编码是干嘛的」的配置注释,**不是终端用户的按钮文案**。
### 6.2 为什么不进多语言
- 授权逻辑只认 `code`,名字不参与校验,因此没有翻译必要。
- 界面上的按钮文案常与权限名不同(权限叫「审核」,按钮可能写「通过」),把权限名塞进 i18n 反而把「权限定义」与「按钮文案」错误耦合。
- 结论:**权限定义自洽于 `{code, name}`,与多语言彻底解耦**;终端用户看到的按钮文案由各自页面/组件自行在多语言里维护。
### 6.3 与授权层的关系(边界)
- 本页面**只负责声明**操作目录(`code` 集合)。
- 真正的「角色 → 节点 → 允许哪些 `code`」赋权在**另一页面**配置,不在模块管理内。
- 模块管理里的菜单预览可用一份写死的授权结果做示意,但不在此维护。
### 6.4 扩展约定
- 模块新增一个权限项(如 `8=核销`),授权层无需改结构,直接照单收编码即可——两层天然对齐。
---
## 7. 五者关系总览
```
功能树 (directory/page/module/group)
│ page 节点绑定 模块
▼
模块 (Module: 查询/保存对象, 字段, 分组, 操作目录)
├─ 字段 ─────────────► 驱动 ModuleTable / ModuleForm
├─ 分组 ─────────────► 组织 Form
└─ 操作目录(code) ────► 权限内核(角色赋权页另行消费)
▲
│ 各自只声明,不持有文案
│
多语言 (i18nKey → 多语言值) ──► 菜单名 / 模块名 / 字段标题 的唯一文案源
(权限名不在内)
菜单 = 功能树中 showInMenu=1 且角色有查看权限的节点投影
```
---
## 8. 与当前原型(Demo)的对应关系
| 设计项 | 原型位置 | 备注 |
| --- | --- | --- |
| 功能树四类节点 | `functionTree` | 已是响应式可编辑 |
| 模块基本信息/字段/分组 | `modules` | `M_MODULE/M_USER/M_BIZ/M_FEE` |
| 字段驱动列表/表单 | `字段配置` Tab | 列表/表单/必填开关 |
| 菜单投影 | `菜单预览` Tab | 按角色 + 语言渲染 |
| 多语言 | `多语言` Tab | Key + 三语,含缺失标红 |
| 权限目录 | `权限项` Tab | `code + name`,可增删 |
### 与原型的对齐情况
原型已按本文设计落地:
- **多语言仅覆盖菜单 / 模块 / 字段三类显示名**;`action.*` 条目已从 `i18nData` 彻底移除,权限项名不再进多语言(符合 §6)。
- **权限项仅含 `{code, name}`**,`name` 为管理员可读标签,不在多语言维护;新增权限项不再生成 i18nKey。
- **字段多语言可在字段配置处点击设置**:标题单元格带多语言按钮,点击打开该字段的简体/English/繁體编辑框(i18nKey 形如 `field.<模块>.<字段>`)。
- **字段 `i18nKey` 统一为 `field.<模块>.<字段>`**,与模块/菜单 `<id>` 前缀、整体「命名空间.容器.标识」规则一致。
- 授权 `roleNodePerm` 仍为写死 mock,仅用于菜单预览示意,符合 §6.3 边界(角色赋权在另一页面,不在模块管理内)。
---
## 9. 数据模型概览(落库参考)
| 表 | 用途 |
| --- | --- |
| `s_module` | 模块定义(查询/保存对象、排序) |
| `s_module_field` | 字段配置(编码、标题键、类型、列表/表单/必填、分组) |
| `s_module_group` | 表单分组 |
| `s_function_node` | 功能树(目录/页面/分组/模块定位) |
| `s_i18n` | 多语言键值(key → 各语言值),覆盖菜单/模块/字段 |
| 权限相关表 | 独立系统表,不属于 Table/Form 渲染配置 |
> 角色与权限内核使用独立系统表,不写入模块渲染配置;后端按 `module_id` + 操作 `code` 校验权限,不能只依赖前端隐藏。
@@ -0,0 +1,81 @@
# 阶段一:模块管理
## 目标
在不开发通用 `ModuleTable`、`ModuleForm` 的前提下,完成可通过页面操作验证的模块管理纵向闭环。
## 本阶段范围
- 建立模块、字段、表单分组、选项来源和功能节点的数据库结构。
- 写入模块管理自身的种子配置。
- 复用现有通用查询、分页和事务保存接口。
- 提供手写的功能树与模块详情工作区。
- 支持模块基本信息、字段设计、表单分组、菜单绑定和功能树位置维护。
- 统一将 SQL Server `bigint` 作为字符串返回前端,避免雪花 ID 精度丢失。
## 数据库对象
| 对象 | 用途 |
| --- | --- |
| `dbo.s_module` | 模块定义 |
| `dbo.s_module_field` | 模块字段配置 |
| `dbo.s_module_group` | 表单分组配置 |
| `dbo.s_option_source` | 固定或模块查询选项来源 |
| `dbo.s_option_item` | 固定选项明细 |
| `dbo.s_function_node` | 目录、页面、技术分组和模块位置 |
数据库脚本位于:
`fms-api/src/main/resources/db/migration/001_module_management.sql`
脚本可以重复执行,首次执行时创建配置表并写入 `s_module` 模块种子数据。
## 页面入口
- 菜单:`系统管理 / 模块管理`
- 路由:`/system/modules`
页面采用左侧功能树、右侧模块详情的桌面工作布局(不兼容移动端)。未被功能节点引用的模块统一进入“未归档模块”。详情包含:
- 基本信息。
- 字段设计,字段与表单分组在同一工作区维护。
- 功能树中的菜单绑定和隐藏子模块位置。
功能树可以在页面内维护目录、页面、模块绑定和隐藏子模块。目录与页面的 `b_show_in_menu` 配置会投影到侧边栏,并与尚未迁移的静态菜单合并,避免迁移期间丢失入口。
## 接口边界
本阶段没有新增模块管理 CRUD 接口,继续复用:
- `/api/data/page`
- `/api/data/loaddata`
- `/api/data/saveobjt`
- `/api/data/nextid`
模块、字段和分组通过一次 `saveobjt` 请求在同一事务中保存,功能树节点独立保存。删除模块时同时清理功能节点引用;存在下级功能节点时禁止直接删除。
## 页面验收步骤
1. 使用机构 `G3HD` 登录新系统。
2. 进入“系统管理 / 模块管理”。
3. 确认能够看到内置的“模块管理”模块。
4. 打开该模块,检查基本信息、5 个字段配置和 1 个表单分组。
5. 在功能树管理中新增一个临时目录和页面节点。
6. 新建一个临时模块,填写裸查询对象名、裸保存对象名、可选业务主键字段和默认排序;页面不应出现主键策略和保存模式。
7. 为临时模块新增字段、表单分组和功能树位置并保存。
8. 将一个页面菜单绑定到临时模块,并在它下面增加隐藏子模块。
9. 重新进入临时模块,确认字段、分组和树形位置能够完整加载。
10. 修改字段标题或排序并保存,重新加载后确认修改生效。
11. 删除临时模块,确认模块、字段、分组和无下级节点的功能树引用一起删除。
12. 确认内置的 `s_module` 模块不能从页面删除。
## 本阶段暂不包含
- 通用 `ModuleTable`。
- 通用 `ModuleForm`。
- 独立的选项来源管理页面。
- 正式模块权限内核。
- 结构化模块查询接口。
- 业务模块迁移。
这些能力在模块管理页面验证配置模型稳定后继续建设。
-5
View File
@@ -43,11 +43,6 @@
<artifactId>druid</artifactId>
<version>1.2.18</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
@@ -0,0 +1,18 @@
package cn.g3soft.fmsapi.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import tools.jackson.databind.module.SimpleModule;
import tools.jackson.databind.ser.std.ToStringSerializer;
@Configuration
public class JacksonConfig {
@Bean
public SimpleModule longAsStringModule() {
SimpleModule module = new SimpleModule("long-as-string");
module.addSerializer(Long.class, ToStringSerializer.instance);
module.addSerializer(Long.TYPE, ToStringSerializer.instance);
return module;
}
}
@@ -39,6 +39,13 @@ public class DataController {
return ApiResponse.success(dataService.loadDataBySql(params));
}
@PostMapping("/page")
public ApiResponse<Map<String, Object>> page(
@RequestBody(required = false) Map<String, Object> params
) throws SQLException {
return ApiResponse.success(dataService.page(params));
}
@PostMapping("/saveobjt")
public ApiResponse<Void> saveObject(
@RequestBody(required = false) List<Map<String, Object>> requests
@@ -15,7 +15,7 @@ public class OrgDataSourceFactory {
public DruidDataSource create(String orgId, OrgDatabaseConfig config) {
OrgDatabaseProperties.Pool poolProperties = properties.getPool();
DruidDataSource dataSource = new DruidDataSource();
dataSource.setName("fms-" + orgId);
dataSource.setName(orgId);
dataSource.setUrl(config.url());
dataSource.setUsername(config.username());
dataSource.setPassword(config.password());
@@ -34,13 +34,9 @@ public class AuthService {
) throws SQLException {
String normalizedOrgId = orgId.trim().toUpperCase(Locale.ROOT);
String normalizedUserId = userId.trim();
List<Map<String, Object>> users = dbUtils.loadData(
List<Map<String, Object>> users = dbUtils.loadUserForAuthentication(
normalizedOrgId,
"b_user",
"b_id = " + dbUtils.toSqlStringLiteral(normalizedUserId)
+ " AND b_canuse = '1'",
null,
List.of("b_id", "b_name", "b_password")
normalizedUserId
);
if (users.isEmpty()) {
@@ -53,7 +49,7 @@ public class AuthService {
return Optional.empty();
}
String accountId = getString(account, "b_id");
String accountId = getString(account, "b_account");
String sessionId = UUID.randomUUID().toString();
String token = jwtUtils.generateToken(accountId, normalizedOrgId, sessionId);
activeSessionRegistry.activate(normalizedOrgId, accountId, sessionId);
@@ -70,10 +66,14 @@ public class AuthService {
}
private String getString(Map<String, Object> row, String column) {
Object value = getValue(row, column);
return value == null ? null : value.toString();
}
private Object getValue(Map<String, Object> row, String column) {
for (Map.Entry<String, Object> entry : row.entrySet()) {
if (entry.getKey().equalsIgnoreCase(column)) {
Object value = entry.getValue();
return value == null ? null : value.toString();
return entry.getValue();
}
}
return null;
@@ -1,11 +1,13 @@
package cn.g3soft.fmsapi.service;
import cn.g3soft.fmsapi.exception.BusinessException;
import cn.g3soft.fmsapi.utils.DbUtils;
import cn.g3soft.fmsapi.utils.ParamUtils;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.sql.SQLException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -19,7 +21,7 @@ public class DataService {
return dbUtils.loadData(
ParamUtils.getRequiredString(params, "view_name"),
ParamUtils.getString(params, "search_condition"),
ParamUtils.getString(params, "order_field"),
ParamUtils.getString(params, "order_by"),
ParamUtils.getList(params, "search_columns")
);
}
@@ -29,4 +31,46 @@ public class DataService {
ParamUtils.getRequiredString(params, "sql")
);
}
public Map<String, Object> page(Map<String, Object> params) throws SQLException {
int pageNo = positiveInteger(params, "page_no", 1);
int pageSize = positiveInteger(params, "page_size", 20);
DbUtils.PageResult page = dbUtils.page(
ParamUtils.getRequiredString(params, "view_name"),
ParamUtils.getList(params, "search_columns"),
ParamUtils.getString(params, "search_condition"),
ParamUtils.getRequiredString(params, "order_by"),
pageNo,
pageSize
);
Map<String, Object> result = new LinkedHashMap<>();
result.put("rows", page.rows());
result.put("row_count", page.rows().size());
result.put("total", page.total());
result.put("page_no", page.pageNo());
result.put("page_size", page.pageSize());
return result;
}
private int positiveInteger(
Map<String, Object> params,
String field,
int defaultValue
) {
Number number = ParamUtils.get(params, field, Number.class);
if (number == null) {
return defaultValue;
}
long value = number.longValue();
if (value < 1
|| value > Integer.MAX_VALUE
|| Double.compare(number.doubleValue(), value) != 0) {
throw new BusinessException(
field + " 必须是大于 0 的整数"
);
}
return (int) value;
}
}
@@ -26,6 +26,7 @@ import java.util.stream.Collectors;
@Component
public class DbUtils {
private static final String PAGE_TOTAL_COLUMN = "__fms_page_total_count";
private static final Pattern SAFE_IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*");
private static final Pattern READ_SQL_PATTERN = Pattern.compile("(?is)^\\s*(select|with)\\b");
private static final Pattern WRITE_SQL_PATTERN = Pattern.compile(
@@ -46,10 +47,10 @@ public class DbUtils {
public List<Map<String, Object>> loadData(
String viewName,
String searchCondition,
String orderField,
String orderBy,
List<String> searchColumns
) throws SQLException {
String sql = buildLoadDataSql(viewName, searchCondition, orderField, searchColumns);
String sql = buildLoadDataSql(viewName, searchCondition, orderBy, searchColumns);
Connection connection = DataSourceUtils.getConnection(dataSource);
try {
return executeQuery(connection, sql);
@@ -58,23 +59,10 @@ public class DbUtils {
}
}
public List<Map<String, Object>> loadData(
String orgId,
String viewName,
String searchCondition,
String orderField,
List<String> searchColumns
) throws SQLException {
String sql = buildLoadDataSql(viewName, searchCondition, orderField, searchColumns);
try (Connection connection = dataSourceManager.getConnection(orgId)) {
return executeQuery(connection, sql);
}
}
private String buildLoadDataSql(
String viewName,
String searchCondition,
String orderField,
String orderBy,
List<String> searchColumns
) {
String columnsSql = buildColumns(searchColumns);
@@ -86,8 +74,8 @@ public class DbUtils {
if (hasText(searchCondition)) {
sql.append(" WHERE ").append(searchCondition.trim());
}
if (hasText(orderField)) {
sql.append(" ORDER BY ").append(buildOrderBy(orderField));
if (hasText(orderBy)) {
sql.append(" ORDER BY ").append(buildOrderBy(orderBy));
}
String querySql = sql.toString();
@@ -118,6 +106,97 @@ public class DbUtils {
return executeQuery(connection, sql);
}
public PageResult page(
String viewName,
List<String> searchColumns,
String searchCondition,
String orderBy,
int pageNo,
int pageSize
) throws SQLException {
if (pageNo < 1) {
throw new BusinessException("page_no 必须大于 0");
}
if (pageSize < 1) {
throw new BusinessException("page_size 必须大于 0");
}
String querySql = buildLoadDataSql(
viewName,
searchCondition,
null,
searchColumns
);
String orderBySql = buildPageOrderBy(orderBy);
String pageSql = "SELECT [page_source].*, COUNT_BIG(1) OVER() AS ["
+ PAGE_TOTAL_COLUMN
+ "] FROM ("
+ querySql
+ ") AS [page_source] ORDER BY "
+ orderBySql
+ " OFFSET ? ROWS FETCH NEXT ? ROWS ONLY";
long offset = (long) (pageNo - 1) * pageSize;
Connection connection = DataSourceUtils.getConnection(dataSource);
try {
PageResult result = executePageQuery(
connection,
pageSql,
offset,
pageSize,
pageNo
);
if (!result.rows().isEmpty() || pageNo == 1) {
return result;
}
return new PageResult(
result.rows(),
countRows(connection, querySql),
pageNo,
pageSize
);
} finally {
DataSourceUtils.releaseConnection(connection, dataSource);
}
}
public List<Map<String, Object>> loadUserForAuthentication(
String orgId,
String account
) throws SQLException {
try (Connection connection = dataSourceManager.getConnection(orgId)) {
SQLException failure = null;
boolean privileged = false;
try {
setSystemUserVisibility(connection, 1);
privileged = true;
String sql = "SELECT [b_id], [b_account], [b_name], [b_password] "
+ "FROM [b_user] WHERE [b_account] = ? AND [b_canuse] = 1";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, account);
try (ResultSet resultSet = statement.executeQuery()) {
return readRows(resultSet);
}
}
} catch (SQLException exception) {
failure = exception;
throw exception;
} finally {
if (privileged) {
try {
setSystemUserVisibility(connection, null);
} catch (SQLException clearException) {
if (failure != null) {
failure.addSuppressed(clearException);
} else {
throw clearException;
}
}
}
}
}
}
public TableMetadata loadTableMetadata(
Connection connection,
String tableName
@@ -255,6 +334,59 @@ public class DbUtils {
}
}
private PageResult executePageQuery(
Connection connection,
String sql,
long offset,
int pageSize,
int pageNo
) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, offset);
statement.setInt(2, pageSize);
try (ResultSet resultSet = statement.executeQuery()) {
ResultSetMetaData metadata = resultSet.getMetaData();
int totalColumnIndex = -1;
for (int index = 1; index <= metadata.getColumnCount(); index++) {
if (PAGE_TOTAL_COLUMN.equalsIgnoreCase(metadata.getColumnLabel(index))) {
totalColumnIndex = index;
}
}
if (totalColumnIndex < 0) {
throw new SQLException("分页查询缺少总记录数字段");
}
List<Map<String, Object>> rows = new ArrayList<>();
long total = 0;
while (resultSet.next()) {
total = resultSet.getLong(totalColumnIndex);
Map<String, Object> row = new LinkedHashMap<>();
for (int index = 1; index <= metadata.getColumnCount(); index++) {
if (index != totalColumnIndex) {
row.put(
metadata.getColumnLabel(index),
resultSet.getObject(index)
);
}
}
rows.add(row);
}
return new PageResult(rows, total, pageNo, pageSize);
}
}
}
private long countRows(Connection connection, String sql) throws SQLException {
String countSql = "SELECT COUNT_BIG(1) FROM ("
+ sql
+ ") AS [count_source]";
try (Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery(countSql)) {
resultSet.next();
return resultSet.getLong(1);
}
}
public String toSqlStringLiteral(String value) {
if (value == null) {
return "NULL";
@@ -316,6 +448,22 @@ public class DbUtils {
return result.toString();
}
private void setSystemUserVisibility(
Connection connection,
Integer value
) throws SQLException {
String sql = "EXEC sys.sp_set_session_context @key = ?, @value = ?";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, "allow_g3soft");
if (value == null) {
statement.setNull(2, java.sql.Types.INTEGER);
} else {
statement.setInt(2, value);
}
statement.execute();
}
}
private String buildColumns(List<String> searchColumns) {
if (searchColumns == null || searchColumns.isEmpty()) {
return "*";
@@ -325,8 +473,8 @@ public class DbUtils {
.collect(Collectors.joining(", "));
}
private String buildOrderBy(String orderField) {
String[] parts = orderField.trim().split("\\s+");
private String buildOrderBy(String orderBy) {
String[] parts = orderBy.trim().split("\\s+");
if (parts.length > 2) {
throw new BusinessException("排序字段格式不正确");
}
@@ -341,6 +489,14 @@ public class DbUtils {
return quoteQualifiedIdentifier(parts[0]) + direction;
}
private String buildPageOrderBy(String orderBy) {
String requiredOrderBy = requireText(orderBy, "order_by");
return Arrays.stream(requiredOrderBy.split(","))
.map(String::trim)
.map(this::buildOrderBy)
.collect(Collectors.joining(", "));
}
public String quoteQualifiedIdentifier(String identifier) {
if (!hasText(identifier)) {
throw new BusinessException("数据库标识符不能为空");
@@ -454,4 +610,12 @@ public class DbUtils {
LinkedHashMap<String, ColumnMetadata> columnsByName
) {
}
public record PageResult(
List<Map<String, Object>> rows,
long total,
int pageNo,
int pageSize
) {
}
}
@@ -3,12 +3,14 @@ package cn.g3soft.fmsapi.utils.snowflake.idgen;
import cn.g3soft.fmsapi.utils.snowflake.contract.IIdGenerator;
import cn.g3soft.fmsapi.utils.snowflake.contract.IdGeneratorException;
import cn.g3soft.fmsapi.utils.snowflake.contract.IdGeneratorOptions;
import lombok.Getter;
public class IdGenerator {
@Getter
private static IIdGenerator idGenInstance = null;
public static IIdGenerator getIdGenInstance() {
return idGenInstance;
}
/**
* 设置参数,建议程序初始化时执行一次
*/
@@ -0,0 +1,67 @@
package cn.g3soft.fmsapi.service;
import cn.g3soft.fmsapi.utils.DbUtils;
import cn.g3soft.fmsapi.utils.JwtUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class AuthServiceTests {
private DbUtils dbUtils;
private JwtUtils jwtUtils;
private ActiveSessionRegistry activeSessionRegistry;
private AuthService authService;
@BeforeEach
void setUp() {
dbUtils = mock(DbUtils.class);
jwtUtils = mock(JwtUtils.class);
activeSessionRegistry = new ActiveSessionRegistry();
authService = new AuthService();
ReflectionTestUtils.setField(authService, "dbUtils", dbUtils);
ReflectionTestUtils.setField(authService, "jwtUtils", jwtUtils);
ReflectionTestUtils.setField(
authService,
"activeSessionRegistry",
activeSessionRegistry
);
}
@Test
void hiddenSuperAdministratorCanLoginWithEmptyPassword() throws Exception {
when(dbUtils.loadUserForAuthentication(
"FMS",
"g3soft"
)).thenReturn(List.of(Map.of(
"b_id", 828084517703744L,
"b_account", "g3soft",
"b_name", "G3Soft",
"b_password", ""
)));
when(jwtUtils.generateToken(
eq("g3soft"),
eq("FMS"),
org.mockito.ArgumentMatchers.anyString()
))
.thenReturn("token");
Optional<Map<String, Object>> result = authService.login("fms", "g3soft", "");
assertThat(result).isPresent();
assertThat(result.orElseThrow()).containsEntry("token", "token");
Map<?, ?> user = (Map<?, ?>) result.orElseThrow().get("user");
assertThat(user.get("id")).isEqualTo("g3soft");
assertThat(user.get("name")).isEqualTo("G3Soft");
}
}
-134
View File
@@ -1,134 +0,0 @@
export const mockRecords = [
{
id: "FY-2026-0001",
name: "华东区域运费结算",
state: "草稿",
customer: "华东客户中心",
businessType: "海运",
owner: "张三",
amount: "4,960.00",
currency: "CNY",
updated_at: "2026-07-16 09:20",
},
{
id: "FY-2026-0002",
name: "华南客户月度账单",
state: "处理中",
customer: "华南供应链有限公司",
businessType: "空运",
owner: "李四",
amount: "6,240.00",
currency: "CNY",
updated_at: "2026-07-16 08:45",
},
{
id: "FY-2026-0003",
name: "北方港口代理费用",
state: "已完成",
customer: "北方港口服务公司",
businessType: "陆运",
owner: "王五",
amount: "7,520.00",
currency: "USD",
updated_at: "2026-07-15 17:32",
},
{
id: "FY-2026-0004",
name: "国际空运服务费",
state: "处理中",
customer: "国际物流有限公司",
businessType: "空运",
owner: "张三",
amount: "8,800.00",
currency: "CNY",
updated_at: "2026-07-15 16:10",
},
{
id: "FY-2026-0005",
name: "出口报关服务费用",
state: "已完成",
customer: "远洋贸易有限公司",
businessType: "海运",
owner: "李四",
amount: "10,080.00",
currency: "CNY",
updated_at: "2026-07-15 14:56",
},
{
id: "FY-2026-0006",
name: "华中干线运输费用",
state: "草稿",
customer: "华中运输服务公司",
businessType: "陆运",
owner: "王五",
amount: "11,360.00",
currency: "USD",
updated_at: "2026-07-15 11:24",
},
{
id: "FY-2026-0007",
name: "仓储增值服务结算",
state: "已取消",
customer: "城市仓储有限公司",
businessType: "铁路",
owner: "张三",
amount: "12,640.00",
currency: "CNY",
updated_at: "2026-07-14 18:05",
},
{
id: "FY-2026-0008",
name: "跨境电商物流账单",
state: "已完成",
customer: "跨境电商有限公司",
businessType: "空运",
owner: "李四",
amount: "13,920.00",
currency: "CNY",
updated_at: "2026-07-14 15:40",
},
{
id: "FY-2026-0009",
name: "西南区域配送费用",
state: "处理中",
customer: "西南配送服务中心",
businessType: "陆运",
owner: "王五",
amount: "15,200.00",
currency: "USD",
updated_at: "2026-07-14 13:18",
},
{
id: "FY-2026-0010",
name: "危险品运输服务费",
state: "草稿",
customer: "特种运输有限公司",
businessType: "海运",
owner: "张三",
amount: "16,480.00",
currency: "CNY",
updated_at: "2026-07-14 10:02",
},
{
id: "FY-2026-0011",
name: "铁路联运代理费用",
state: "已完成",
customer: "铁路联运服务公司",
businessType: "铁路",
owner: "李四",
amount: "17,760.00",
currency: "CNY",
updated_at: "2026-07-13 16:27",
},
{
id: "FY-2026-0012",
name: "客户临时加急费用",
state: "已取消",
customer: "重点客户服务中心",
businessType: "空运",
owner: "王五",
amount: "19,040.00",
currency: "USD",
updated_at: "2026-07-13 09:51",
},
];
+668
View File
@@ -0,0 +1,668 @@
/**
* 模块管理 Demo 数据。
*
* 设计边界:
* - 模块配置服务于通用数据接口和标准 ModuleTable / ModuleForm。
* - 字段配置标准字段的显隐、必填、类型、字典与顺序,不定义页面整体布局。
* - 菜单由模块树中启用页面能力的模块投影,不单独维护菜单数据。
* - 权限仅用于前端菜单、路由和操作控制。
*/
export const localeOptions = [
{ label: "简体中文", value: "zh-CN" },
{ label: "English", value: "en-US" },
{ label: "繁體中文", value: "zh-TW" },
];
export const roles = [
{ id: "admin", name: "系统管理员" },
{ id: "operator", name: "业务操作员" },
{ id: "finance", name: "财务人员" },
{ id: "guest", name: "只读访客" },
];
export const fieldTypeOptions = [
{ label: "单行文本", value: "string" },
{ label: "多行文本", value: "text" },
{ label: "数字", value: "number" },
{ label: "字典选择", value: "select" },
{ label: "日期", value: "date" },
{ label: "开关", value: "boolean" },
];
export const dictionaryOptions = [
{ label: "业务类型", value: "BIZ_TYPE" },
{ label: "业务状态", value: "BIZ_STATUS" },
{ label: "用户状态", value: "USER_STATUS" },
{ label: "币种", value: "CURRENCY" },
];
export const dictionaries = {
BIZ_TYPE: [
{ value: "SEA", i18nKey: "dict.BIZ_TYPE.SEA" },
{ value: "AIR", i18nKey: "dict.BIZ_TYPE.AIR" },
],
BIZ_STATUS: [
{ value: "DRAFT", i18nKey: "dict.BIZ_STATUS.DRAFT" },
{ value: "SUBMITTED", i18nKey: "dict.BIZ_STATUS.SUBMITTED" },
{ value: "APPROVED", i18nKey: "dict.BIZ_STATUS.APPROVED" },
],
USER_STATUS: [
{ value: "ENABLED", i18nKey: "dict.USER_STATUS.ENABLED" },
{ value: "DISABLED", i18nKey: "dict.USER_STATUS.DISABLED" },
],
CURRENCY: [
{ value: "CNY", i18nKey: "dict.CURRENCY.CNY" },
{ value: "USD", i18nKey: "dict.CURRENCY.USD" },
{ value: "EUR", i18nKey: "dict.CURRENCY.EUR" },
],
};
export const modules = {
M_SYSTEM: {
code: "M_SYSTEM",
parentId: null,
sort: 10,
i18nKey: "module.M_SYSTEM",
isDirectory: true,
isPage: false,
route: "",
showInMenu: true,
querySource: "",
saveTarget: "",
primaryKey: "",
defaultOrder: "",
enabled: true,
fields: [],
actions: [],
sampleRows: [],
},
M_MODULE: {
code: "M_MODULE",
parentId: "M_SYSTEM",
sort: 10,
i18nKey: "module.M_MODULE",
isDirectory: false,
isPage: true,
route: "/demo/modules",
showInMenu: true,
querySource: "",
saveTarget: "",
primaryKey: "",
defaultOrder: "",
enabled: true,
fields: [],
actions: [{ code: "view", name: "查看" }],
sampleRows: [{}],
},
M_BUSINESS: {
code: "M_BUSINESS",
parentId: null,
sort: 20,
i18nKey: "module.M_BUSINESS",
isDirectory: true,
isPage: false,
route: "",
showInMenu: true,
querySource: "",
saveTarget: "",
primaryKey: "",
defaultOrder: "",
enabled: true,
fields: [],
actions: [],
sampleRows: [],
},
M_BIZ: {
code: "M_BIZ",
i18nKey: "module.M_BIZ",
parentId: "M_BUSINESS",
sort: 10,
isDirectory: false,
isPage: true,
route: "/business/orders",
showInMenu: true,
querySource: "v_bs_business",
saveTarget: "b_business",
primaryKey: "b_id",
defaultOrder: "b_id DESC",
enabled: true,
fields: [
{
id: "F_BIZ_NO",
code: "b_ywno",
i18nKey: "field.M_BIZ.b_ywno",
dataType: "string",
dictionaryCode: null,
showInList: true,
showInForm: true,
required: true,
queryable: true,
sort: 10,
width: 150,
},
{
id: "F_BIZ_CUSTOMER",
code: "b_customer",
i18nKey: "field.M_BIZ.b_customer",
dataType: "string",
dictionaryCode: null,
showInList: true,
showInForm: true,
required: true,
queryable: true,
sort: 20,
width: 160,
},
{
id: "F_BIZ_TYPE",
code: "b_type",
i18nKey: "field.M_BIZ.b_type",
dataType: "select",
dictionaryCode: "BIZ_TYPE",
showInList: true,
showInForm: true,
required: true,
queryable: true,
sort: 30,
width: 110,
},
{
id: "F_BIZ_AMOUNT",
code: "b_amount",
i18nKey: "field.M_BIZ.b_amount",
dataType: "number",
dictionaryCode: null,
showInList: true,
showInForm: true,
required: false,
queryable: true,
sort: 40,
width: 110,
},
{
id: "F_BIZ_STATUS",
code: "b_state",
i18nKey: "field.M_BIZ.b_state",
dataType: "select",
dictionaryCode: "BIZ_STATUS",
showInList: true,
showInForm: true,
required: true,
queryable: true,
sort: 50,
width: 110,
},
{
id: "F_BIZ_REMARK",
code: "b_remark",
i18nKey: "field.M_BIZ.b_remark",
dataType: "text",
dictionaryCode: null,
showInList: false,
showInForm: true,
required: false,
queryable: false,
sort: 60,
width: 180,
},
],
actions: [
{ code: "view", name: "查看" },
{ code: "create", name: "新增" },
{ code: "update", name: "修改" },
{ code: "delete", name: "删除" },
{ code: "submit", name: "提交" },
{ code: "audit", name: "审核" },
{ code: "export", name: "导出" },
],
sampleRows: [
{
b_id: "10001",
b_ywno: "SE-2026-0719",
b_customer: "远海贸易",
b_type: "SEA",
b_amount: 128600,
b_state: "SUBMITTED",
b_remark: "等待审核",
},
{
b_id: "10002",
b_ywno: "AE-2026-0720",
b_customer: "华东供应链",
b_type: "AIR",
b_amount: 35600,
b_state: "DRAFT",
b_remark: "",
},
],
},
M_USER: {
code: "M_USER",
i18nKey: "module.M_USER",
parentId: "M_SYSTEM",
sort: 20,
isDirectory: false,
isPage: true,
route: "/system/users",
showInMenu: true,
querySource: "b_user",
saveTarget: "b_user",
primaryKey: "b_id",
defaultOrder: "b_userid ASC",
enabled: true,
fields: [
{
id: "F_USER_ACCOUNT",
code: "b_userid",
i18nKey: "field.M_USER.b_userid",
dataType: "string",
dictionaryCode: null,
showInList: true,
showInForm: true,
required: true,
queryable: true,
sort: 10,
width: 140,
},
{
id: "F_USER_NAME",
code: "b_name",
i18nKey: "field.M_USER.b_name",
dataType: "string",
dictionaryCode: null,
showInList: true,
showInForm: true,
required: true,
queryable: true,
sort: 20,
width: 120,
},
{
id: "F_USER_DEPT",
code: "b_dept",
i18nKey: "field.M_USER.b_dept",
dataType: "string",
dictionaryCode: null,
showInList: true,
showInForm: true,
required: false,
queryable: true,
sort: 30,
width: 150,
},
{
id: "F_USER_STATUS",
code: "b_canuse",
i18nKey: "field.M_USER.b_canuse",
dataType: "select",
dictionaryCode: "USER_STATUS",
showInList: true,
showInForm: true,
required: true,
queryable: true,
sort: 40,
width: 100,
},
],
actions: [
{ code: "view", name: "查看" },
{ code: "create", name: "新增" },
{ code: "update", name: "修改" },
{ code: "disable", name: "禁用" },
{ code: "reset-password", name: "重置密码" },
],
sampleRows: [
{ b_id: "U001", b_userid: "zhangsan", b_name: "张三", b_dept: "操作部", b_canuse: "ENABLED" },
{ b_id: "U002", b_userid: "lisi", b_name: "李四", b_dept: "财务部", b_canuse: "DISABLED" },
],
},
M_BIZ_QUERY: {
code: "M_BIZ_QUERY",
parentId: "M_BUSINESS",
sort: 20,
i18nKey: "module.M_BIZ_QUERY",
isDirectory: false,
isPage: true,
route: "/business/query",
showInMenu: true,
querySource: "",
saveTarget: "",
primaryKey: "",
defaultOrder: "",
enabled: true,
fields: [],
actions: [
{ code: "view", name: "查看" },
{ code: "export", name: "导出" },
],
sampleRows: [{}],
},
M_BIZ_AUDIT: {
code: "M_BIZ_AUDIT",
parentId: "M_BUSINESS",
sort: 30,
i18nKey: "module.M_BIZ_AUDIT",
isDirectory: false,
isPage: true,
route: "/business/audit",
showInMenu: false,
querySource: "",
saveTarget: "",
primaryKey: "",
defaultOrder: "",
enabled: true,
fields: [],
actions: [
{ code: "view", name: "查看" },
{ code: "audit", name: "审核" },
],
sampleRows: [{}],
},
M_BIZ_FEE: {
code: "M_BIZ_FEE",
parentId: "M_BIZ",
sort: 10,
i18nKey: "module.M_BIZ_FEE",
isDirectory: false,
isPage: false,
route: "",
showInMenu: false,
querySource: "v_bs_business_fee",
saveTarget: "b_business_fee",
primaryKey: "b_id",
defaultOrder: "b_id ASC",
enabled: true,
fields: [
{
id: "F_BIZ_FEE_AMOUNT",
code: "b_amount",
i18nKey: "field.M_BIZ_FEE.b_amount",
dataType: "number",
dictionaryCode: null,
showInList: true,
showInForm: true,
required: true,
queryable: true,
sort: 10,
width: 110,
},
{
id: "F_BIZ_FEE_CURRENCY",
code: "b_currency",
i18nKey: "field.M_BIZ_FEE.b_currency",
dataType: "select",
dictionaryCode: "CURRENCY",
showInList: true,
showInForm: true,
required: true,
queryable: true,
sort: 20,
width: 100,
},
],
actions: [
{ code: "view", name: "查看" },
{ code: "create", name: "新增" },
{ code: "update", name: "修改" },
{ code: "delete", name: "删除" },
],
sampleRows: [{ b_id: "BF01", b_amount: 6800, b_currency: "CNY" }],
},
M_BIZ_ATTACHMENT: {
code: "M_BIZ_ATTACHMENT",
parentId: "M_BIZ",
sort: 20,
i18nKey: "module.M_BIZ_ATTACHMENT",
isDirectory: false,
isPage: false,
route: "",
showInMenu: false,
querySource: "b_business_attachment",
saveTarget: "b_business_attachment",
primaryKey: "b_id",
defaultOrder: "b_id DESC",
enabled: true,
fields: [
{
id: "F_ATTACHMENT_NAME",
code: "b_filename",
i18nKey: "field.M_BIZ_ATTACHMENT.b_filename",
dataType: "string",
dictionaryCode: null,
showInList: true,
showInForm: true,
required: true,
queryable: true,
sort: 10,
width: 180,
},
{
id: "F_ATTACHMENT_REMARK",
code: "b_remark",
i18nKey: "field.M_BIZ_ATTACHMENT.b_remark",
dataType: "text",
dictionaryCode: null,
showInList: false,
showInForm: true,
required: false,
queryable: false,
sort: 20,
width: 180,
},
],
actions: [
{ code: "view", name: "查看" },
{ code: "upload", name: "上传" },
{ code: "delete", name: "删除" },
],
sampleRows: [{ b_id: "A01", b_filename: "booking.pdf", b_remark: "订舱附件" }],
},
M_FINANCE: {
code: "M_FINANCE",
parentId: null,
sort: 30,
i18nKey: "module.M_FINANCE",
isDirectory: true,
isPage: false,
route: "",
showInMenu: true,
querySource: "",
saveTarget: "",
primaryKey: "",
defaultOrder: "",
enabled: true,
fields: [],
actions: [],
sampleRows: [],
},
M_FEE: {
code: "M_FEE",
i18nKey: "module.M_FEE",
parentId: "M_FINANCE",
sort: 10,
isDirectory: false,
isPage: true,
route: "/finance/receivables",
showInMenu: true,
querySource: "v_fee_receivable",
saveTarget: "b_fee_receivable",
primaryKey: "b_id",
defaultOrder: "b_id DESC",
enabled: true,
fields: [
{
id: "F_FEE_NO",
code: "b_feeno",
i18nKey: "field.M_FEE.b_feeno",
dataType: "string",
dictionaryCode: null,
showInList: true,
showInForm: true,
required: true,
queryable: true,
sort: 10,
width: 150,
},
{
id: "F_FEE_BIZ_NO",
code: "b_ywno",
i18nKey: "field.M_FEE.b_ywno",
dataType: "string",
dictionaryCode: null,
showInList: true,
showInForm: true,
required: true,
queryable: true,
sort: 20,
width: 150,
},
{
id: "F_FEE_AMOUNT",
code: "b_amount",
i18nKey: "field.M_FEE.b_amount",
dataType: "number",
dictionaryCode: null,
showInList: true,
showInForm: true,
required: true,
queryable: true,
sort: 30,
width: 110,
},
{
id: "F_FEE_CURRENCY",
code: "b_currency",
i18nKey: "field.M_FEE.b_currency",
dataType: "select",
dictionaryCode: "CURRENCY",
showInList: true,
showInForm: true,
required: true,
queryable: true,
sort: 40,
width: 90,
},
],
actions: [
{ code: "view", name: "查看" },
{ code: "create", name: "新增" },
{ code: "write-off", name: "核销" },
{ code: "export", name: "导出" },
],
sampleRows: [
{
b_id: "F001",
b_feeno: "AR-260719-01",
b_ywno: "SE-2026-0719",
b_amount: 128600,
b_currency: "CNY",
},
{
b_id: "F002",
b_feeno: "AR-260719-02",
b_ywno: "AE-2026-0720",
b_amount: 5200,
b_currency: "USD",
},
],
},
};
export const rolePermissions = {
admin: [
...Object.values(modules).flatMap((module) =>
module.actions.map((action) => `module.${module.code}.${action.code}`),
),
],
operator: [
"module.M_BIZ.view",
"module.M_BIZ.create",
"module.M_BIZ.update",
"module.M_BIZ.submit",
"module.M_BIZ.export",
"module.M_BIZ_QUERY.view",
"module.M_BIZ_QUERY.export",
"module.M_BIZ_FEE.view",
"module.M_BIZ_FEE.create",
"module.M_BIZ_FEE.update",
"module.M_BIZ_ATTACHMENT.view",
"module.M_BIZ_ATTACHMENT.upload",
"module.M_FEE.view",
],
finance: [
"module.M_BIZ_QUERY.view",
"module.M_BIZ_QUERY.export",
"module.M_FEE.view",
"module.M_FEE.create",
"module.M_FEE.write-off",
"module.M_FEE.export",
],
guest: ["module.M_BIZ_QUERY.view"],
};
export const i18nData = {
"module.M_SYSTEM": { "zh-CN": "系统管理", "en-US": "System", "zh-TW": "系統管理" },
"module.M_MODULE": { "zh-CN": "模块管理", "en-US": "Module Management", "zh-TW": "模組管理" },
"module.M_USER": { "zh-CN": "用户管理", "en-US": "Users", "zh-TW": "用戶管理" },
"module.M_BUSINESS": { "zh-CN": "业务管理", "en-US": "Operations", "zh-TW": "業務管理" },
"module.M_BIZ": { "zh-CN": "海运业务单", "en-US": "Sea Orders", "zh-TW": "海運業務單" },
"module.M_BIZ_QUERY": { "zh-CN": "业务单查询", "en-US": "Order Query", "zh-TW": "業務單查詢" },
"module.M_BIZ_AUDIT": { "zh-CN": "业务单审核", "en-US": "Order Audit", "zh-TW": "業務單審核" },
"module.M_BIZ_FEE": { "zh-CN": "业务单费用", "en-US": "Order Fees", "zh-TW": "業務單費用" },
"module.M_BIZ_ATTACHMENT": {
"zh-CN": "业务单附件",
"en-US": "Order Attachments",
"zh-TW": "業務單附件",
},
"module.M_FINANCE": { "zh-CN": "财务中心", "en-US": "Finance", "zh-TW": "財務中心" },
"module.M_FEE": { "zh-CN": "应收费用", "en-US": "Receivable Fee", "zh-TW": "應收費用" },
"field.M_BIZ.b_ywno": { "zh-CN": "业务单号", "en-US": "Order No.", "zh-TW": "業務單號" },
"field.M_BIZ.b_customer": { "zh-CN": "客户", "en-US": "Customer", "zh-TW": "客戶" },
"field.M_BIZ.b_type": { "zh-CN": "业务类型", "en-US": "Business Type", "zh-TW": "業務類型" },
"field.M_BIZ.b_amount": { "zh-CN": "金额", "en-US": "Amount", "zh-TW": "金額" },
"field.M_BIZ.b_state": { "zh-CN": "状态", "en-US": "Status", "zh-TW": "狀態" },
"field.M_BIZ.b_remark": { "zh-CN": "备注", "en-US": "Remark", "zh-TW": "備註" },
"field.M_BIZ_FEE.b_amount": { "zh-CN": "费用金额", "en-US": "Fee Amount", "zh-TW": "費用金額" },
"field.M_BIZ_FEE.b_currency": { "zh-CN": "币种", "en-US": "Currency", "zh-TW": "幣別" },
"field.M_BIZ_ATTACHMENT.b_filename": {
"zh-CN": "文件名",
"en-US": "File Name",
"zh-TW": "檔案名稱",
},
"field.M_BIZ_ATTACHMENT.b_remark": {
"zh-CN": "附件说明",
"en-US": "Description",
"zh-TW": "附件說明",
},
"field.M_USER.b_userid": { "zh-CN": "账号", "en-US": "Account", "zh-TW": "帳號" },
"field.M_USER.b_name": { "zh-CN": "姓名", "en-US": "Name", "zh-TW": "姓名" },
"field.M_USER.b_dept": { "zh-CN": "部门", "en-US": "Department", "zh-TW": "部門" },
"field.M_USER.b_canuse": { "zh-CN": "状态", "en-US": "Status", "zh-TW": "狀態" },
"field.M_FEE.b_feeno": { "zh-CN": "费用单号", "en-US": "Fee No.", "zh-TW": "費用單號" },
"field.M_FEE.b_ywno": { "zh-CN": "业务单号", "en-US": "Order No.", "zh-TW": "業務單號" },
"field.M_FEE.b_amount": { "zh-CN": "金额", "en-US": "Amount", "zh-TW": "金額" },
"field.M_FEE.b_currency": { "zh-CN": "币种", "en-US": "Currency", "zh-TW": "幣別" },
"dict.BIZ_TYPE.SEA": { "zh-CN": "海运", "en-US": "Sea", "zh-TW": "海運" },
"dict.BIZ_TYPE.AIR": { "zh-CN": "空运", "en-US": "Air", "zh-TW": "空運" },
"dict.BIZ_STATUS.DRAFT": { "zh-CN": "草稿", "en-US": "Draft", "zh-TW": "草稿" },
"dict.BIZ_STATUS.SUBMITTED": { "zh-CN": "已提交", "en-US": "Submitted", "zh-TW": "已提交" },
"dict.BIZ_STATUS.APPROVED": { "zh-CN": "已审核", "en-US": "Approved", "zh-TW": "已審核" },
"dict.USER_STATUS.ENABLED": { "zh-CN": "启用", "en-US": "Enabled", "zh-TW": "啟用" },
"dict.USER_STATUS.DISABLED": { "zh-CN": "禁用", "en-US": "Disabled", "zh-TW": "停用" },
"dict.CURRENCY.CNY": { "zh-CN": "人民币", "en-US": "CNY", "zh-TW": "人民幣" },
"dict.CURRENCY.USD": { "zh-CN": "美元", "en-US": "USD", "zh-TW": "美元" },
"dict.CURRENCY.EUR": { "zh-CN": "欧元", "en-US": "EUR", "zh-TW": "歐元" },
};
@@ -36,15 +36,18 @@ import AppToolbar from "./components/AppToolbar.vue";
}
.app-main {
display: flex;
height: 100%;
min-width: 0;
min-height: 0;
flex-direction: column;
overflow: hidden;
background: var(--fms-bg);
}
.app-content {
width: 100%;
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
@@ -20,8 +20,9 @@ const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const menuIcon = (icon) => () => h(icon, { size: 16, strokeWidth: 1.8 });
const effectiveCollapsed = computed(() => appStore.sidebarCollapsed);
const menuItems = [
const fallbackMenuItems = [
{
key: "/dashboard",
icon: menuIcon(House),
@@ -139,8 +140,8 @@ const menuItems = [
{ key: "/system/field", label: "字段配置" },
{ key: "/system/statisticalSettings", label: "查询设置" },
{ key: "/system/mappingConfig", label: "映射配置" },
{ key: "/system/moduleSettings", label: "模块设置" },
{ key: "/system/autoId", label: "自动编码" },
{ key: "/demo/modules", label: "模块管理" },
],
},
{
@@ -160,20 +161,30 @@ const menuItems = [
},
];
const menuItems = ref(fallbackMenuItems);
const selectedKeys = computed(() => [route.meta.menuKey || route.path]);
const openKeys = ref([]);
const parentKeyByRoute = new Map(
menuItems.flatMap((item) =>
item.children?.map((child) => [child.key, item.key]) ?? [],
),
);
const parentKeysByRoute = computed(() => {
const result = new Map();
const visit = (items, parents = []) => {
items.forEach((item) => {
if (item.children?.length) visit(item.children, [...parents, item.key]);
else result.set(item.key, parents);
});
};
visit(menuItems.value);
return result;
});
watch(
() => route.meta.menuKey || route.path,
(menuKey) => {
const parentKey = parentKeyByRoute.get(menuKey);
if (parentKey && !openKeys.value.includes(parentKey)) {
openKeys.value = [...openKeys.value, parentKey];
[() => route.meta.menuKey || route.path, menuItems],
([menuKey]) => {
const parentKeys = parentKeysByRoute.value.get(menuKey) || [];
const nextKeys = new Set(openKeys.value);
parentKeys.forEach((key) => nextKeys.add(key));
if (nextKeys.size !== openKeys.value.length) {
openKeys.value = Array.from(nextKeys);
}
},
{ immediate: true },
@@ -193,16 +204,16 @@ function updateOpenKeys(keys) {
<template>
<a-layout-sider
class="app-sider"
:collapsed="appStore.sidebarCollapsed"
:collapsed="effectiveCollapsed"
:trigger="null"
:collapsed-width="56"
collapsible
theme="light"
width="208"
>
<div class="brand" :class="{ 'brand--collapsed': appStore.sidebarCollapsed }">
<div class="brand" :class="{ 'brand--collapsed': effectiveCollapsed }">
<div class="brand__mark">F</div>
<div class="brand__copy" :aria-hidden="appStore.sidebarCollapsed">
<div class="brand__copy" :aria-hidden="effectiveCollapsed">
<strong>FMS 管理系统</strong>
</div>
</div>
@@ -219,7 +230,7 @@ function updateOpenKeys(keys) {
</div>
<div class="sider-footer">
<a-tooltip :title="appStore.sidebarCollapsed ? '展开侧栏' : '收起侧栏'" placement="right">
<a-tooltip :title="effectiveCollapsed ? '展开侧栏' : '收起侧栏'" placement="right">
<a-button
type="text"
class="sider-collapse"
@@ -227,7 +238,7 @@ function updateOpenKeys(keys) {
@click="appStore.toggleSidebar"
>
<template #icon>
<PanelLeftOpen v-if="appStore.sidebarCollapsed" />
<PanelLeftOpen v-if="effectiveCollapsed" />
<PanelLeftClose v-else />
</template>
</a-button>
+19 -1
View File
@@ -36,6 +36,7 @@ const routes = [
section: "业务管理",
menuKey: "/business/orders",
source: "v_bs_business",
orderBy: "b_id DESC",
},
},
{
@@ -48,6 +49,7 @@ const routes = [
section: "业务管理",
menuKey: "/business/orders",
listPath: "/business/orders",
source: "v_bs_business",
detail: true,
},
},
@@ -60,6 +62,7 @@ const routes = [
section: "费用管理",
menuKey: "/fees/receivable",
source: "v_fee_receivable",
orderBy: "b_id DESC",
},
},
{
@@ -72,6 +75,7 @@ const routes = [
section: "费用管理",
menuKey: "/fees/receivable",
listPath: "/fees/receivable",
source: "v_fee_receivable",
detail: true,
},
},
@@ -84,6 +88,7 @@ const routes = [
section: "报表中心",
menuKey: "/reports/operations",
source: "v_report_operations",
orderBy: "b_id DESC",
},
},
{
@@ -96,6 +101,7 @@ const routes = [
section: "报表中心",
menuKey: "/reports/operations",
listPath: "/reports/operations",
source: "v_report_operations",
detail: true,
},
},
@@ -107,7 +113,8 @@ const routes = [
title: "用户管理",
section: "系统设置",
menuKey: "/system/users",
source: "sys_user",
source: "b_user",
orderBy: "b_id ASC",
},
},
{
@@ -120,9 +127,20 @@ const routes = [
section: "系统设置",
menuKey: "/system/users",
listPath: "/system/users",
source: "b_user",
detail: true,
},
},
{
path: "demo/modules",
name: "demo-modules",
component: () => import("../views/ModuleManageDemo.vue"),
meta: {
title: "模块管理",
section: "系统管理",
menuKey: "/demo/modules",
},
},
],
},
{
+62 -7
View File
@@ -1,11 +1,20 @@
import { post } from "./http";
import { get, post } from "./http";
/**
* @template T
* @typedef {object} ApiResult
* @property {boolean} success 前端根据 code 补充的成功标记。
* @property {number} code 后端业务状态码,0 表示成功。
* @property {string} message 响应消息。
* @property {T} data 响应数据。
*/
/**
* 机构用户登录。
* @param {string} orgId 机构码。
* @param {string} userId 用户 ID。
* @param {string} password 密码。
* @returns {Promise<{success: boolean, code: number, message: string, data: {token: string, orgid: string, user: {id: string, name: string}}}>} 登录响应。
* @returns {Promise<ApiResult<{token: string, orgid: string, user: {id: string, name: string}}>>} 登录响应。
*/
export function loginApi(orgId, userId, password) {
return post("/auth/login", {
@@ -19,15 +28,15 @@ export function loginApi(orgId, userId, password) {
* 按表或视图加载数据。
* @param {string} viewName 表名或视图名,必填。
* @param {string} [searchCondition] 查询条件,不包含 WHERE。
* @param {string} [orderField] 排序字段,可带 ASC 或 DESC。
* @param {string} [orderBy] 排序字段,可带 ASC 或 DESC。
* @param {string[]} [searchColumns] 需要返回的字段列表,省略时返回全部字段。
* @returns {Promise<{success: boolean, code: number, message: string, data: object[]}>} 查询响应。
* @returns {Promise<ApiResult<object[]>>} 查询响应。
*/
export function loadDataApi(viewName, searchCondition, orderField, searchColumns) {
export function loadDataApi(viewName, searchCondition, orderBy, searchColumns) {
return post("/data/loaddata", {
view_name: viewName,
search_condition: searchCondition,
order_field: orderField,
order_by: orderBy,
search_columns: searchColumns,
});
}
@@ -35,8 +44,54 @@ export function loadDataApi(viewName, searchCondition, orderField, searchColumns
/**
* 执行只读 SQL 查询。
* @param {string} sql 完整的 SELECT 或 WITH 查询语句。
* @returns {Promise<{success: boolean, code: number, message: string, data: object[]}>} 查询响应。
* @returns {Promise<ApiResult<object[]>>} 查询响应。
*/
export function loadDataBySqlApi(sql) {
return post("/data/loaddatabysql", { sql });
}
/**
* 分页加载表或视图数据。
* @param {object} options 分页查询参数。
* @param {string} options.viewName 表名或视图名,必填。
* @param {string} options.orderBy 排序字段,必填,可用逗号分隔多个字段。
* @param {string[]} [options.searchColumns] 需要返回的字段列表。
* @param {string} [options.searchCondition] 查询条件,不包含 WHERE。
* @param {number} [options.pageNo=1] 页码,从 1 开始。
* @param {number} [options.pageSize=20] 每页记录数。
* @returns {Promise<ApiResult<{rows: object[], row_count: number, total: number, page_no: number, page_size: number}>>} 分页响应。
*/
export function pageDataApi({
viewName,
orderBy,
searchColumns,
searchCondition,
pageNo = 1,
pageSize = 20,
}) {
return post("/data/page", {
view_name: viewName,
search_columns: searchColumns,
search_condition: searchCondition,
order_by: orderBy,
page_no: pageNo,
page_size: pageSize,
});
}
/**
* 在同一事务中保存一个或多个表的数据变更。
* @param {{table: string, key_field: string, inserts?: object[], updates?: object[], deletes?: object[]}[]} requests 保存配置,不能为空数组。
* @returns {Promise<ApiResult<null>>} 保存响应。
*/
export function saveObjectApi(requests) {
return post("/data/saveobjt", requests);
}
/**
* 获取下一个雪花 ID。
* @returns {Promise<ApiResult<string>>} 取号响应,雪花 ID 使用字符串避免精度丢失。
*/
export function nextIdApi() {
return get("/data/nextid");
}
+4
View File
@@ -87,4 +87,8 @@ export function post(path, data, config) {
return http.post(path, data, config);
}
export function get(path, config) {
return http.get(path, config);
}
export default http;
+47 -20
View File
@@ -2,7 +2,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Ellipsis, ListFilter, Plus, Search } from "@lucide/vue";
import { loadDataApi } from "../services/api";
import { loadDataApi, pageDataApi } from "../services/api";
const route = useRoute();
const router = useRouter();
@@ -53,6 +53,9 @@ const advancedFilters = reactive(createEmptyAdvancedFilters());
const draftFilterCount = computed(
() => Object.values(advancedFilters).filter(isFilterValueFilled).length,
);
const hasActiveFilters = computed(
() => Boolean(filters.keyword.trim()) || appliedFilterCount.value > 0,
);
function createEmptyAdvancedFilters() {
return {
@@ -89,9 +92,22 @@ function getRowValue(row, candidates) {
function normalizeRow(row, index) {
const id = getRowValue(row, ["id", "b_id", "user_id", "userid"]);
const name = getRowValue(row, ["name", "b_name", "customer_name", "title"]);
const state = getRowValue(row, ["state", "b_state", "status"]);
const updatedAt = getRowValue(row, ["updated_at", "update_time", "updated_time", "b_updated_at"]);
const name = getRowValue(row, [
"name",
"b_name",
"customer_name",
"v_customer_company",
"b_ywno",
"title",
]);
const state = getRowValue(row, ["state", "b_state", "status", "b_canuse"]);
const updatedAt = getRowValue(row, [
"updated_at",
"b_updatedatetime",
"update_time",
"updated_time",
"b_updated_at",
]);
return {
...row,
@@ -176,7 +192,7 @@ function applyLocalFilters() {
function searchData() {
pagination.current = 1;
applyLocalFilters();
loadData();
}
async function loadData() {
@@ -188,11 +204,27 @@ async function loadData() {
loadError.value = "";
try {
const response = await loadDataApi(viewName);
const result = response.data;
const response = hasActiveFilters.value
? await loadDataApi(viewName)
: await pageDataApi({
viewName,
orderBy: String(route.meta.orderBy || "b_id DESC"),
pageNo: pagination.current,
pageSize: pagination.pageSize,
});
if (currentSequence !== loadSequence) return;
sourceRows.value = (Array.isArray(result) ? result : []).map(normalizeRow);
applyLocalFilters();
if (hasActiveFilters.value) {
sourceRows.value = (Array.isArray(response.data) ? response.data : []).map(normalizeRow);
applyLocalFilters();
} else {
const result = response.data ?? {};
sourceRows.value = [];
rows.value = (Array.isArray(result.rows) ? result.rows : []).map(normalizeRow);
total.value = Number(result.total) || 0;
pagination.current = Number(result.page_no) || pagination.current;
pagination.pageSize = Number(result.page_size) || pagination.pageSize;
}
} catch (error) {
if (currentSequence !== loadSequence) return;
sourceRows.value = [];
@@ -211,7 +243,11 @@ function openDetail(record) {
function changePage(page, pageSize) {
pagination.current = page;
pagination.pageSize = pageSize;
applyLocalFilters();
if (hasActiveFilters.value) {
applyLocalFilters();
} else {
loadData();
}
}
function resetAdvancedFilters() {
@@ -222,7 +258,7 @@ function applyAdvancedFilters() {
pagination.current = 1;
appliedFilterCount.value = draftFilterCount.value;
advancedOpen.value = false;
applyLocalFilters();
loadData();
}
function updateTableHeight() {
@@ -593,13 +629,4 @@ watch(
max-width: 100vw;
}
@media (max-width: 520px) {
.advanced-filter-form {
grid-template-columns: minmax(0, 1fr);
}
.filter-span-full {
grid-column: 1;
}
}
</style>
+80 -26
View File
@@ -1,33 +1,79 @@
<script setup>
import { computed, ref } from "vue";
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ArrowLeft } from "@lucide/vue";
import { mockRecords } from "../data/mockRecords";
import { loadDataApi } from "../services/api";
const route = useRoute();
const router = useRouter();
const remark = ref("");
const loading = ref(false);
const loadError = ref("");
const sourceRecord = ref(null);
let loadSequence = 0;
const recordId = computed(() => String(route.params.id ?? ""));
const detailTitle = computed(() => route.meta.listTitle || route.meta.title || "详情");
const record = computed(
() =>
mockRecords.find((item) => item.id === recordId.value) ?? {
id: recordId.value,
name: `${detailTitle.value} ${recordId.value}`,
state: "草稿",
businessType: "未设置",
owner: "未设置",
customer: "未设置",
amount: "0.00",
currency: "CNY",
updated_at: "--",
},
);
function getRowValue(row, candidates) {
if (!row) return undefined;
const keys = Object.keys(row);
for (const candidate of candidates) {
const matchedKey = keys.find((key) => key.toLowerCase() === candidate.toLowerCase());
if (matchedKey !== undefined && row[matchedKey] != null) return row[matchedKey];
}
return undefined;
}
const record = computed(() => {
const row = sourceRecord.value;
return {
id: recordId.value,
name: getRowValue(row, ["name", "b_name", "v_customer_company", "b_ywno", "title"]) ?? "--",
state: getRowValue(row, ["state", "b_state", "status", "b_canuse"]) ?? "--",
businessType: getRowValue(row, ["business_type", "b_ywtype", "b_type"]) ?? "--",
owner: getRowValue(row, ["owner", "owner_name", "v_sales_name", "b_owner_id"]) ?? "--",
customer: getRowValue(row, ["customer", "customer_name", "v_customer_company"]) ?? "--",
amount: getRowValue(row, ["amount", "total_amount", "b_amount"]) ?? "--",
currency: getRowValue(row, ["currency", "currency_code", "b_currency"]) ?? "",
updated_at:
getRowValue(row, ["updated_at", "b_updatedatetime", "update_time", "updated_time"]) ?? "--",
};
});
function escapeSqlString(value) {
return String(value).replaceAll("'", "''");
}
async function loadDetail() {
const currentSequence = ++loadSequence;
const source = String(route.meta.source || "");
if (!source || !recordId.value) {
sourceRecord.value = null;
return;
}
loading.value = true;
loadError.value = "";
sourceRecord.value = null;
try {
const response = await loadDataApi(source, `b_id = N'${escapeSqlString(recordId.value)}'`);
if (currentSequence !== loadSequence) return;
sourceRecord.value = Array.isArray(response.data) ? (response.data[0] ?? null) : null;
if (!sourceRecord.value) loadError.value = "未找到对应记录";
} catch (error) {
if (currentSequence !== loadSequence) return;
loadError.value = error.message || "详情加载失败";
} finally {
if (currentSequence === loadSequence) loading.value = false;
}
}
function backToList() {
router.push(String(route.meta.listPath || "/dashboard"));
}
watch([() => route.meta.source, recordId], loadDetail, { immediate: true });
</script>
<template>
@@ -44,16 +90,24 @@ function backToList() {
</header>
<main class="detail-content">
<a-descriptions bordered size="small" :column="{ xs: 1, sm: 2, md: 3 }">
<a-descriptions-item label="编号">{{ recordId }}</a-descriptions-item>
<a-descriptions-item label="名称">{{ record.name }}</a-descriptions-item>
<a-descriptions-item label="状态">{{ record.state }}</a-descriptions-item>
<a-descriptions-item label="客户">{{ record.customer }}</a-descriptions-item>
<a-descriptions-item label="业务类型">{{ record.businessType }}</a-descriptions-item>
<a-descriptions-item label="负责人">{{ record.owner }}</a-descriptions-item>
<a-descriptions-item label="金额">{{ record.amount }} {{ record.currency }}</a-descriptions-item>
<a-descriptions-item label="更新时间">{{ record.updated_at }}</a-descriptions-item>
</a-descriptions>
<a-spin :spinning="loading">
<a-alert v-if="loadError" type="error" show-icon :message="loadError">
<template #action><a-button size="small" @click="loadDetail">重试</a-button></template>
</a-alert>
<a-descriptions v-else bordered size="small" :column="3">
<a-descriptions-item label="编号">{{ recordId }}</a-descriptions-item>
<a-descriptions-item label="名称">{{ record.name }}</a-descriptions-item>
<a-descriptions-item label="状态">{{ record.state }}</a-descriptions-item>
<a-descriptions-item label="客户">{{ record.customer }}</a-descriptions-item>
<a-descriptions-item label="业务类型">{{ record.businessType }}</a-descriptions-item>
<a-descriptions-item label="负责人">{{ record.owner }}</a-descriptions-item>
<a-descriptions-item label="金额">
{{ record.amount }} {{ record.currency }}
</a-descriptions-item>
<a-descriptions-item label="更新时间">{{ record.updated_at }}</a-descriptions-item>
</a-descriptions>
</a-spin>
<section class="detail-remark">
<div class="detail-remark__label">备注</div>
File diff suppressed because it is too large. Load diff
+100 -51
View File
@@ -17,8 +17,9 @@
- 页面、查询条件、SQL、数据组织、校验和普通业务流程目前主要由前端完成。
- 当前阶段暂不重点考虑客户端直接提交查询条件和 SQL 带来的安全风险。
- 后端通用接口完成后应尽量保持稳定,普通业务优先修改前端配置和数据库配置。
- 文件、报表、导入、导出以及通用接口无法覆盖的功能,可以增加特殊接口。
- 文件、报表、导入和导出等能力也应建设为稳定的平台级通用接口,后续不得为具体业务模块增加特殊接口或后端代码。
- 新数据库启用后,前端应逐步改为只提交 `module_id` 和结构化数据,不再直接指定任意表名或 SQL。
- `G3HY2025` 旧数据库仅用于参考表结构和业务含义,新代码只面向 `FMS` 新数据库,不保留旧表结构的运行时兼容分支。
## 3. 现有通用接口
@@ -29,48 +30,60 @@
| `POST` | `/api/data/loaddatabysql` | 执行完整查询 SQL |
| `POST` | `/api/data/page` | 执行分页查询 |
| `POST` | `/api/data/saveobjt` | 多表事务保存 |
| `GET` | `/api/data/nextid` | 获取下一个雪花 ID |
### 3.1 登录
```json
{
"orgid": "G3HD",
"username": "admin",
"userid": "admin",
"password": "123456"
}
```
登录成功后返回登录凭证和用户信息。后续请求携带登录凭证,不再重复提交 `orgid`。
```json
{
"code": 0,
"message": "操作成功",
"data": {
"token": "<jwt>",
"orgid": "G3HD",
"user": {
"id": "admin",
"name": "管理员"
}
}
}
```
### 3.2 loaddata
用于表或视图的普通查询,不执行分页。
```json
{
"source": "v_bs_business",
"fields": ["b_id", "b_state", "customer_name"],
"where": "b_state = '草拟'",
"view_name": "v_bs_business",
"search_columns": ["b_id", "b_state", "v_customer_company"],
"search_condition": "b_state = '草拟'",
"order_by": "b_id desc"
}
```
- `source` 必填,表示表名或视图名。
- `fields` 可省略,省略时返回全部字段。
- `where` 可省略,由前端直接拼接,不包含 `WHERE` 关键字。
- `order_by` 可省略,不包含 `ORDER BY` 关键字。
- `view_name` 必填,表示表名或视图名。
- `search_columns` 可省略,省略时返回全部字段。
- `search_condition` 可省略,由前端直接拼接,不包含 `WHERE` 关键字。
- `order_by` 可省略,只支持一个排序字段及可选的 `ASC` 或 `DESC`。
成功响应:
```json
{
"success": true,
"code": 0,
"message": "ok",
"data": {
"rows": [],
"row_count": 0
}
"message": "操作成功",
"data": []
}
```
@@ -84,7 +97,7 @@
}
```
成功响应与 `loaddata` 相同。
成功响应与 `loaddata` 相同,`data` 直接为查询结果数组。
### 3.4 page
@@ -92,26 +105,29 @@
```json
{
"sql": "select * from v_bs_business where b_state = '草拟'",
"view_name": "v_bs_business",
"search_columns": ["b_id", "b_state", "v_customer_company"],
"search_condition": "b_state = '草拟'",
"order_by": "b_id desc",
"page_no": 1,
"page_size": 20
}
```
- `sql` 必填,不包含分页语句。
- `order_by` 必填,用于保证分页顺序稳定。
- `view_name` 必填,表示分页查询使用的表或视图名称。
- `search_columns` 可省略,省略时返回全部字段。
- `search_condition` 可省略,由前端直接拼接,不包含 `WHERE` 关键字。
- `order_by` 必填,用于保证分页顺序稳定;支持使用逗号分隔多个查询结果字段。
- `page_no` 从 1 开始,默认值为 `1`。
- `page_size` 默认值为 `20`。
- 后端负责生成分页 SQL 和计算总记录数。
- SQL Server 2022 使用 `COUNT_BIG(*) OVER()` 与 `OFFSET/FETCH` 在一次查询中返回当前页和总记录数;仅页码越界且当前页为空时补充执行总数查询。
成功响应:
```json
{
"success": true,
"code": 0,
"message": "ok",
"message": "操作成功",
"data": {
"rows": [],
"row_count": 0,
@@ -168,7 +184,6 @@
```json
{
"success": true,
"code": 0,
"message": "保存成功",
"data": null
@@ -179,7 +194,6 @@
```json
{
"success": false,
"code": 2001,
"message": "保存失败",
"data": {
@@ -190,6 +204,18 @@
}
```
### 3.6 nextid
无请求体。成功时返回一个雪花 ID:
```json
{
"code": 0,
"message": "操作成功",
"data": 1946123456789012345
}
```
## 4. 统一约定
- JavaScript 和 Java 内部函数、方法及变量使用小驼峰命名法。
@@ -198,11 +224,12 @@
- API 路径使用全小写名称,JSON 多单词字段使用 `snake_case`。
- `code = 0` 表示成功,非零值表示失败。
- 前端根据 `code` 生成 `success` 字段,`code = 0` 时 `success = true`。
- 查询结果统一放在 `data.rows` 中,没有数据时返回空数组。
- `loaddata` 和 `loaddatabysql` 的查询结果直接放在 `data` 数组中;`page` 的分页记录放在 `data.rows` 中。
- 过渡期查询条件和 SQL 由前端拼接,不使用参数占位符。
- 不增加功能相同但名称不同的接口。
- 不使用 `V2`、`V3`、`new`、`old`、`test` 或 `bak` 等临时名称。
- 接口发生不兼容升级时,统一通过 `/api/v2` 等版本路径升级。
- 前端和后端代码修改完成后默认不执行构建或测试命令,只进行静态检查;仅在用户明确要求时运行构建或测试。
## 5. 新数据库重构方案
@@ -213,10 +240,12 @@
- 所有业务页面、页面布局、按钮、标签页、弹窗和业务流程仍由前端手工开发。
- 模块配置只服务于通用 Table 和 Form 组件,不根据配置自动生成完整页面、路由或业务流程。
- Table 根据模块配置渲染列、格式、查询项和排序;Form 根据模块配置渲染字段、控件、校验和分组。
- 模块定义查询对象、保存对象、主键、字段配置和保存策略。
- 模块定义查询对象、保存对象、可选业务主键和字段配置。
- 前端只提交 `module_id`、结构化查询条件和待保存数据。
- 后端根据模块配置解析允许访问的对象和字段,并使用参数化 SQL。
- 通用保存负责简单单表模块,复杂业务由明确的业务处理器完成。
- 单表和主子表保存都复用 `saveobjt`;手写页面负责组织多张表的变更,后端在一个事务中统一提交或回滚。
- 后端只保留稳定的通用接口,不为模块分别新增查询、新增、修改、删除接口或处理器代码。
- 复杂业务优先通过手写页面、数据库约束和通用事务保存组合实现,不建立模块处理器注册与分派机制。
- 不将全部业务逻辑塞入配置表或动态 SQL。
### 5.2 数据库版本与基础规范
@@ -225,7 +254,7 @@
- 继续采用一个机构对应一个独立业务数据库的隔离方式,业务表不重复保存 `orgid`。
- 技术主键统一使用雪花 ID,数据库类型为 `bigint`。主表主键固定为 `b_id`,子表主键固定为 `subid`。
- 中文和多语言文本使用 `nvarchar`,长文本使用 `nvarchar(max)`,不使用 `text`。
- 布尔值使用 `bit`,不再使用字符串形式的 `0`、`1`、空字符串或 `NULL` 表示多种状态。
- 标志和状态字段使用 `tinyint` 等整数类型,不使用 `bit` 或字符串类型。二态字段仍以数字 `0`、`1` 表示,业务需要时允许扩展为 `2`、`3`、`4` 等状态;Java 端按数字处理,不映射为 `Boolean`。
- 时间字段使用 `datetime2`。
- 金额、单价、数量、汇率和税率不强制统一精度,根据每个字段的业务含义分别定义 `decimal` 的精度和小数位数。
- 迁移旧字段时保留原有精度;新增字段必须在需求或表结构中明确精度,不使用 `float` 代替精确金额。
@@ -237,7 +266,7 @@
- 新数据库的所有业务表、配置表和视图统一创建在 `dbo` 下,不创建其他 Schema。
- 领域划分继续使用现有表名前缀,避免增加对象名称和通用接口的复杂度。
- 模块配置保存数据库对象名称时统一按 `dbo.<对象名>` 处理,例如 `dbo.bs_business`、`dbo.cw_bill` 和 `dbo.v_bs_business`。
- 模块配置中的数据库对象只保存裸对象名,不保存 `dbo.` 或其他架构前缀,例如 `bs_business`、`cw_bill` 和 `v_bs_business`。
| 表名前缀 | 用途 |
| --- | --- |
@@ -262,8 +291,9 @@
| `dbo.s_module_group` | Form 分组子表,字段使用 `mx_` 前缀 |
| `dbo.s_option_source` | 下拉框和字典数据源主表 |
| `dbo.s_option_item` | 固定选项子表,字段使用 `mx_` 前缀 |
| `dbo.s_function_node` | 统一功能树,组织目录、页面、分组和模块位置 |
菜单、角色和权限仍可使用独立系统表,但不属于 Table/Form 渲染配置。主子页面关系由手写页面负责组织,第一阶段不需要通过配置表生成。
角色和权限仍使用独立系统表,不属于 Table/Form 渲染配置。菜单是功能树中 `b_show_in_menu = 1` 的目录和页面节点投影,页面节点可以直接绑定主模块;隐藏子模块可以挂在页面或其他模块下。主子页面关系仍由手写页面负责组织,功能树不自动生成页面或 Tab。
### 5.5 模块定义
@@ -275,15 +305,13 @@
| `b_name` | 模块名称 |
| `b_query_object` | 查询表或稳定查询视图 |
| `b_save_object` | 保存目标表,只读模块允许为空 |
| `b_key_field` | 保存对象主键字段 |
| `b_key_strategy` | `snowflake`、`identity` 或 `manual` |
| `b_save_mode` | `generic`、`custom` 或 `readonly` |
| `b_handler_code` | 自定义处理器标识,通用模块为空 |
| `b_key_field` | 系统主键字段,平台内部使用,普通主表固定为 `b_id` |
| `b_business_key_field` | 可选业务主键字段,例如业务单号 |
| `b_default_order` | 默认排序 |
| `b_enabled` | 是否启用 |
| `b_sort_order` | 模块排序 |
查询对象与保存对象必须分开:查询视图负责组合展示数据,保存表负责持久化可写字段。
查询对象与保存对象必须分开:查询视图负责组合展示数据,保存表负责持久化可写字段。系统主键固定由后端生成雪花 ID,不在配置页面重复设置;业务主键是带唯一约束的业务字段,例如 `HY20270601`,通过 `b_business_key_field` 标识但不代替系统主键。
### 5.6 模块字段配置
@@ -294,14 +322,14 @@
| 主键与归属 | `subid`、`mx_module_id` |
| 字段映射 | `mx_field_code`、`mx_query_field`、`mx_save_field`、`mx_caption` |
| 列表显示 | `mx_list_visible`、`mx_list_order`、`mx_list_width`、`mx_sortable`、`mx_display_format` |
| 查询设置 | `mx_searchable`、`mx_search_order`、`mx_search_operator` |
| 查询设置 | `mx_searchable`、`mx_search_order` |
| 表单编辑 | `mx_form_visible`、`mx_form_group_code`、`mx_form_order`、`mx_form_span`、`mx_editable`、`mx_required`、`mx_component_type` |
| 数据来源 | `mx_option_source_id`、`mx_default_rule`、`mx_validation_rule` |
- `mx_field_code` 是模块对前端暴露的稳定字段名。
- `mx_query_field` 对应查询对象返回的字段。
- `mx_save_field` 对应保存表字段,计算字段和只读字段可以为空。
- `mx_search_operator` 使用受控枚举,例如 `eq`、`like`、`between` 或 `in`。
- 字段配置不保存固定查询操作符。高级查询运行时根据字段类型提供 `eq`、`like`、`between`、`in` 等合法操作符,由用户为每条条件选择。
- 主要配置使用明确字段保存,只有少量不稳定的组件扩展属性才考虑使用 JSON。
### 5.7 表单分组与选项来源
@@ -312,9 +340,29 @@
- 没有分组的字段放入默认分组,简单表单不强制配置分组数据。
- 固定下拉选项保存在 `dbo.s_option_item`。
- 数据库字典选项通过 `dbo.s_option_source` 指定允许查询的对象、值字段、显示字段和排序字段。
- 复杂选项通过 `b_handler_code` 交给后端处理,不在配置表保存任意可执行 SQL。
- 非固定、非模块查询的复杂选项由手写页面组件负责,不在配置表保存任意可执行 SQL,也不增加后端选项处理器。
### 5.8 通用组件与手写页面的边界
### 5.8 功能树、菜单与模块
`dbo.s_function_node` 是技术管理和业务菜单共用的功能结构,不是页面生成配置。节点类型固定为 `directory`、`page`、`group` 和 `module`:
| 节点类型 | 用途 |
| --- | --- |
| `directory` | 业务域或功能目录,可以显示在菜单中 |
| `page` | 已注册的手写页面,可以配置路由并显示在菜单中 |
| `group` | 页面内部的技术分组,只在配置管理中显示 |
| `module` | 指向一个 `s_module`,只在配置管理中显示 |
- 功能节点使用 `b_parent_id` 形成树,并包含 `b_name`、`b_page_code`、`b_module_id`、`b_route`、`b_icon`、`b_show_in_menu`、`b_enabled` 和 `b_sort_order`。
- `module` 节点必须指定 `b_module_id`;`page` 节点也可以直接绑定主模块,同一个模块允许被多个功能节点引用。
- `b_show_in_menu` 只允许用于 `directory` 和 `page` 节点。菜单隐藏不代表模块停用,也不代替权限判断。
- 功能树中的模块父子关系只用于技术人员定位和分类,不定义保存事务、主子表外键或业务流程。
- 页面和 Tab 继续手写。功能树只帮助定位模块和投影菜单,不根据树节点自动生成组件。
- 迁移期间,功能树中的可见目录和页面按节点编码或路由与静态菜单合并;未登记菜单继续保留,完成全部路由核对后再移除静态回退。
模块管理页面左侧按功能树展示完整路径,并提供“未归档模块”虚拟节点。搜索至少匹配功能节点名称、模块编码、模块名称、查询对象和保存对象,结果必须保留祖先路径。左侧可以分别显示或隐藏模块节点和菜单节点;页面支持直接维护目录、页面、模块绑定和隐藏子模块位置。
### 5.9 通用组件与手写页面的边界
| 层级 | 负责内容 |
| --- | --- |
@@ -350,7 +398,7 @@
- `ModuleForm` 负责渲染、收集数据和校验,但不自动决定保存时机;页面验证业务条件后再调用保存。
- 特殊控件和复杂区域可以通过插槽由页面自行实现,不要求全部配置化。
### 5.9 查询与保存边界
### 5.10 查询与保存边界
模块化后的推荐调用关系:
@@ -367,12 +415,11 @@ module_id
- 前端不再提交任意 `view_name`、`table`、`key_field` 或完整 SQL。
- 查询条件使用结构化格式提交,字段和操作符必须来自模块配置白名单。
- 雪花 ID 由后端保存流程生成,不依赖前端提前申请。
- `b_save_mode = generic` 只适用于基础资料等简单单表模块。
- `b_save_mode = readonly` 用于报表、统计和日志查询。
- `b_save_mode = custom` 用于业务单、费用、账单、发票等涉及主子表、状态流转和业务校验的模块。
- 自定义处理器仍通过统一模块接口调用,不为每个页面随意增加不同形式的接口。
- 配置 `b_save_object` 的模块可以参与通用保存,未配置保存对象的模块只读。
- 手写主子页面可以将多张表的新增、修改和删除一次提交给 `saveobjt`,整个请求使用同一事务。
- 后端不按模块编码分派自定义处理器,也不为具体页面增加特殊保存接口。
### 5.10 业务表设计原则
### 5.11 业务表设计原则
- 每张业务表必须定义真实主键:主表固定使用 `b_id`,子表固定使用 `subid`。
- 主表普通字段使用 `b_` 前缀,子表普通字段使用 `mx_` 前缀。
@@ -381,20 +428,22 @@ module_id
- 唯一性、非空、默认值和可校验的状态范围优先使用数据库约束保证。
- 不使用空字符串同时表达未知、未填写和数值零。
- 重要且允许多人编辑的业务表可以增加 `b_row_version` 或 `mx_row_version`,用于乐观锁控制。
- 创建人、修改人和时间字段按业务审计要求添加,不由通用保存接口无条件补充。
- 创建人、创建时间、修改人和修改时间字段仅在业务确有审计需求时添加,不作为所有表的固定字段;例如 `b_user` 不添加这些字段。
- 删除策略根据业务确定;财务和审批数据通常使用作废或状态流转,不直接物理删除。
### 5.11 权限原则
### 5.12 权限原则
- 权限至少控制模块的查看、新增、修改、删除和导出操作。
- 菜单与模块分离,同一个模块可以出现在不同菜单位置,也可以作为隐藏子模块使用。
- 菜单是功能树可见目录和页面节点的投影;页面菜单可以绑定模块,后续权限和多语言都以菜单节点为入口扩展。
- 后端必须根据登录用户和 `module_id` 校验权限,不能只依赖前端隐藏按钮。
- 字段级权限仅在确有业务需求时增加,第一阶段不提前复杂化。
- `g3soft` 是内置超级管理员账号,初始密码为空。超级管理员身份由固定账号名识别,不在 `b_user` 中增加超级管理员或隐藏标志字段。
- 普通 SQL 允许直接查询物理表 `dbo.b_user`,但数据库行级安全策略必须自动排除 `g3soft`;只有认证流程可以在当前数据库连接内临时读取该账号,且查询结束后必须立即清除临时权限。
### 5.12 重构实施顺序
### 5.13 重构实施顺序
1. 确定数据库版本、字段命名、表名前缀和主键策略,所有对象固定使用 `dbo`。
2. 建立模块、模块字段、表单分组和选项来源等最小配置表。
1. 确定数据库版本、字段命名和表名前缀;系统主键固定使用雪花 ID,业务主键使用独立唯一业务字段。
2. 建立模块、功能树、模块字段、表单分组和选项来源等最小配置表。
3. 先实现一个简单基础资料页面,验证 `ModuleTable`、`ModuleForm`、通用查询和单表保存。
4. 手工实现一个业务主子页面,验证组件组合、事务和自定义保存。
5. 实现一个只读报表模块,验证查询、分页和导出边界。
+521
View File
@@ -0,0 +1,521 @@
# FMS 模块化重构实施计划
## 1. 文档目的
本文档用于指导旧 `g3hd` 系统向新 `fms` 系统逐步迁移。重构始终保留“以模块为核心”的系统结构,但不照搬旧系统中前端提交任意表名、SQL 和多版本通用接口的实现。
计划的核心目标是先建立稳定的模块运行内核,再逐批迁移业务,保证每一阶段都有可验收的业务闭环。
## 2. 重构原则
1. `module_id` 是查询、保存、权限、字段配置和页面入口的共同标识。
2. 菜单不等于模块,但二者使用同一棵功能树。页面菜单可以直接绑定主模块,隐藏模块可以挂在页面或其他模块下;一个模块也可以被多个功能节点引用。
3. 查询对象与保存对象分离。查询优先使用稳定视图,保存只能写入明确配置的物理表。
4. 前端保留手写页面。模块配置只驱动通用 Table、Form、查询条件和选项,不生成完整业务页面。
5. 单表和主子表都使用通用事务保存;手写页面负责组织多张表的数据变更。
6. 权限必须由后端校验,前端隐藏按钮只用于改善交互。
7. 新接口只接收 `module_id`、结构化条件和结构化数据,不接收任意表名、字段名或 SQL。
8. 后端只提供少量稳定的平台级模块接口,普通模块不得单独增加查询、新增、修改、删除接口。
9. 后端不建立按模块分派的业务处理器,也不因业务差异新增 Controller。
10. 一个能力只保留一个正式接口,不复制旧系统的 V1、V2、V3 和临时兼容版本。
11. 每迁移一个模块,就完成数据、接口、页面、权限和验收的完整闭环,不长期维护半迁移状态。
12. 旧代码用于确认业务行为,旧数据库用于核对字段含义;新代码、新接口和新数据库不兼容旧系统数据库结构。
13. 前端仅面向桌面端浏览器开发,不需要兼容移动端布局;后续开发也不做移动端适配,不引入为窄屏服务的响应式断点与自动收起逻辑。
### 2.1 后端接口准入规则
1. 查询、详情、新增、修改和删除优先复用统一模块接口,不为表、视图或业务页面分别创建 CRUD 接口。
2. 配置保存对象的模块使用通用查询和通用保存;主子页面一次提交多张表的变更。
3. 所有表变更统一进入 `saveobjt`,后端不建立业务处理器注册表或模块专用保存入口。
4. “前端调用方便”“某个页面参数不同”或“旧系统原来有接口”不能作为新增接口的理由。
## 3. 目标运行链路
```text
路由或页面提供 module_id
-> 后端加载模块定义
-> 校验机构、用户和模块权限
-> 加载模块字段与选项配置
-> 解析结构化查询条件
-> 生成参数化 SQL
-> 返回模块元数据和业务数据
-> 前端 ModuleTable / ModuleForm 渲染
-> 页面执行自身业务流程
-> saveobjt 提交单表或多表事务
```
模块是这条链路的中心,页面、数据库对象和权限都不能绕开模块直接建立隐式关系。
## 4. 当前基础
新 `fms` 已具备以下基础能力:
- 机构数据库配置加载与动态路由。
- JWT 登录、活动会话和同账号会话替换。
- 统一响应、异常处理和鉴权过滤。
- 普通查询、SQL 查询、分页查询、事务保存和雪花 ID。
- Vue 登录、会话管理、基础布局、列表和详情页面骨架。
这些通用数据接口作为过渡接口继续存在,但不再作为最终模块接口继续扩展。
## 5. 总体实施顺序
| 阶段 | 目标 | 代表性成果 |
| --- | --- | --- |
| 0 | 固化边界与迁移清单 | 模块台账、对象映射、决策记录 |
| 1 | 建立最小模块数据模型 | 模块、字段、分组、选项配置表 |
| 2 | 建立后端模块内核 | 模块定义、结构化查询、权限校验 |
| 3 | 建立权限和菜单基础 | 后端授权、菜单和动作权限 |
| 4 | 建立前端模块运行层 | ModuleTable、ModuleForm、查询组件 |
| 5 | 完成简单模块闭环 | 一个基础资料模块完整上线 |
| 6 | 完成复杂业务闭环 | 业务单主子表通用事务保存 |
| 7 | 完成只读模块闭环 | 统计报表、分页和导出 |
| 8 | 建立配置管理与校验 | 配置页面、发布校验和缓存失效 |
| 9 | 分批迁移业务域 | 合作伙伴、业务、财务、审批等 |
| 10 | 数据迁移与切换 | 全量迁移、增量迁移、对账、切换 |
| 11 | 收口旧能力 | 停用过渡接口和旧系统 |
## 6. 分阶段执行计划
### 阶段 0:固化重构边界
#### 工作内容
1. 建立旧模块台账,至少记录:
- 旧 `module_id`。
- 菜单位置和页面路由。
- 查询视图、保存主表和子表。
- 主键、业务编号和默认排序。
- 查询项、列表列、表单字段和选项来源。
- 查看、新增、修改、删除、导出及特殊操作权限。
- 审批、自动编号、存储过程、报表和附件依赖。
2. 为每个旧模块确定新系统处置方式:`迁移`、`合并`、`拆分`、`只读归档` 或 `废弃`。
3. 建立旧数据库对象到新数据库对象的映射表,禁止只凭相似名称直接迁移。
4. 确认新数据库的 SQL Server 版本、排序规则、时区、金额精度和雪花 ID worker 分配方式。
5. 建立架构决策记录,后续关键变化必须说明原因和影响。
#### 产物
- `docs/refactor/模块迁移台账.md` 或同目录下的对应表格。
- `docs/refactor/数据库对象映射.md`。
- `docs/refactor/架构决策记录.md`。
阶段 0 的重构治理文档统一放在 `docs/refactor/` 目录,不散落在项目根目录。后续产生的模块迁移说明、数据迁移说明和决策记录也优先归档到该目录。
#### 验收标准
- 所有准备迁移的旧路由都能对应到一个明确模块或明确废弃结论。
- 每个模块都能说明查询对象、保存对象和权限边界。
- 不存在“先复制页面,后面再确认数据来源”的模块。
### 阶段 1:建立最小模块数据模型
本阶段采用纵向闭环方式交付:配置表、必要后端支持和手写模块管理页面必须同时完成,阶段结束后可以直接从“系统管理 / 模块管理”页面验证成果。此阶段不开发通用 `ModuleTable` 和 `ModuleForm`。
#### 工作内容
1. 创建并固化以下配置表:
- `dbo.s_module`:模块定义。
- `dbo.s_module_field`:模块字段定义。
- `dbo.s_module_group`:表单分组。
- `dbo.s_option_source`:动态选项来源。
- `dbo.s_option_item`:固定选项。
2. 在 `s_module` 中明确:
- `b_query_object`。
- `b_save_object`。
- `b_key_field`。
- `b_default_order`。
- 系统主键固定由后端生成雪花 ID,不配置主键策略。
- 业务主键使用独立唯一字段,例如业务单号,可在模块中标识对应字段。
3. 在 `s_module_field` 中分开配置:
- 对前端稳定的字段编码。
- 查询视图字段。
- 保存表字段。
- 列表、查询和表单属性。
4. 为配置表建立主键、唯一索引、外键和启用状态约束。
5. 复用现有通用查询、分页和事务保存接口,不新增模块管理专用 CRUD 接口。
6. 开发手写的模块管理页面,至少支持:
- 按业务目录、页面、技术分组和模块位置组织的功能树。
- 模块、查询对象、路径和启停状态搜索。
- 模块基本信息新增、修改和删除。
- 模块字段与表单分组在同一设计器内维护。
- 页面菜单与主模块绑定,以及隐藏子模块的树形归档。
- 模块、字段和分组一次事务保存;功能树节点独立保存。
7. 使用初始化脚本写入“模块管理”自身的种子配置,确保模块系统可以管理自身。
8. 选项来源先建立数据库结构和字段引用,本阶段不要求完成独立的选项来源管理页面。
#### 产物
- 可重复执行的配置表 DDL。
- 第一批模块种子数据脚本。
- 模块字段与选项来源的数据字典。
- 可操作的“系统管理 / 模块管理”页面。
#### 验收标准
- 给定一个 `module_id`,可以唯一确定查询对象、保存对象和主键字段。
- 重复字段、无效分组、无效选项来源能够被约束或检测。
- 查询视图字段和保存字段不存在隐式同名假设。
- 可以通过页面完成模块、字段和表单分组的新增、修改、删除与重新加载。
- 可以通过功能树定位隐藏模块并维护菜单绑定,未归档模块始终可搜索。
- 页面保存使用现有通用事务接口,后端没有增加模块管理专用 CRUD Controller。
### 阶段 2:建立后端模块内核
#### 工作内容
1. 实现模块定义读取层,并按机构缓存稳定配置。
2. 定义唯一一套平台级模块接口,建议只包含:
- 获取模块元数据。
- 模块分页查询。
- 获取单条记录。
- 保存模块数据。
- 获取字段选项。
- 执行模块声明的特殊动作。
3. 上述接口按 `module_id` 服务所有模块,不为 `b_contact`、`bs_business`、`cw_bill` 等模块复制 Controller 和 CRUD 路径。
4. 定义结构化查询格式,例如:
```json
{
"filters": [
{ "field": "customer_name", "operator": "like", "value": "物流" },
{ "field": "business_date", "operator": "between", "value": ["2026-01-01", "2026-01-31"] }
],
"sort": [
{ "field": "business_no", "direction": "desc" }
],
"page_no": 1,
"page_size": 20
}
```
5. 后端只允许使用模块字段白名单中的字段和操作符。
6. 所有值使用预编译参数,数据库对象名和排序字段从模块配置解析。
7. 建立统一保存服务:配置保存对象的模块允许保存,未配置保存对象的模块保持只读;主子页面使用 `saveobjt` 提交多表事务。
8. 雪花 ID 在后端保存事务中生成,并以字符串形式通过 JSON 返回,避免 JavaScript 精度损失。
9. 为模块定义错误、字段错误、权限错误、并发冲突和业务校验失败定义稳定错误码。
10. 过渡接口继续保留,但新页面不得继续增加对任意 SQL 接口的依赖,也不得增加模块专用 CRUD 接口。
#### 产物
- `ModuleDefinitionService`。
- `ModuleQueryService`。
- `ModuleSaveService`。
- 查询请求、分页响应和保存请求契约。
#### 验收标准
- 修改请求中的 `module_id` 不能访问另一个模块未授权的对象或字段。
- 客户端不能通过条件、排序或字段名注入任意 SQL。
- 通用单表保存、通用多表事务和只读模块的边界明确,且不依赖保存模式字段。
### 阶段 3:建立权限和菜单基础
#### 工作内容
1. 建立统一功能树,节点区分 `directory`、`page`、`group` 和 `module`;菜单从可见目录与页面节点投影,模块定义和权限仍独立建模。
2. 确定授权模型。建议使用角色授权为主,确有需要时提供用户级例外,而不是完整照搬旧系统逐用户配置。
3. 至少支持以下模块操作:
- `view`。
- `create`。
- `update`。
- `delete`。
- `export`。
- 模块声明的特殊动作,例如 `submit`、`approve`、`reject`、`void`。
4. 查询、详情、保存、导出和特殊动作都在后端校验权限。
5. 登录后返回或按需加载用户可见菜单和模块动作。
6. 保留 `g3soft` 内置管理员规则,但数据库普通查询必须继续隐藏该账号。
#### 产物
- 功能树和权限表 DDL。
- 后端模块授权服务。
- 前端功能树投影菜单和动作权限指令/组合函数。
#### 验收标准
- 手工调用接口不能绕过页面按钮权限。
- 菜单隐藏不影响隐藏子模块的合法访问。
- 一个模块可被多个菜单复用且权限结果一致。
### 阶段 4:建立前端模块运行层
#### 工作内容
1. 实现统一模块服务层,页面不直接拼接模块接口请求。
2. 实现 `ModuleTable`:
- 模块元数据加载。
- 动态列、格式、排序和分页。
- 结构化快速查询和高级查询;高级查询由用户为每条条件选择合法操作符。
- 行选择、加载、空数据和错误状态。
- 列插槽、工具栏插槽和事件。
3. 实现 `ModuleForm`:
- 字段分组与桌面端表单布局(不做移动端适配)。
- 输入、数字、日期、选择、文本等基础控件。
- 必填、类型、长度和配置规则校验。
- 新增、编辑和只读模式。
- 字段插槽和分组插槽。
4. 实现选项来源服务,只处理固定选项和模块查询选项。
5. 明确页面职责:页面负责按钮、弹窗、标签页、主子区域和业务流程,组件不擅自保存。
6. 统一加载、权限不足、无数据、校验失败、并发冲突和会话失效的交互。
#### 产物
- `ModuleTable.vue`。
- `ModuleForm.vue`。
- 模块查询组件和选项组件。
- 模块前端服务与类型/契约说明。
#### 验收标准
- 同一查询视图可被两个模块以不同字段配置展示。
- 页面可通过插槽覆盖特殊列和特殊表单区域。
- 组件内部不出现具体业务表名。
### 阶段 5:完成简单模块闭环
#### 推荐试点
优先选择币种、港口、联系人等简单基础资料;不要用用户、权限或业务单作为第一个通用模块。
#### 工作内容
1. 为试点模块创建新业务表、查询视图和模块配置。
2. 使用 `ModuleTable` 实现列表、查询、分页和排序。
3. 使用 `ModuleForm` 实现新增、编辑和只读详情。
4. 配置保存对象,通过通用保存完成单表新增、修改和删除。
5. 接入模块查看、新增、修改、删除和导出权限。
6. 从旧数据库抽取少量脱敏数据验证迁移映射。
#### 验收标准
- 页面不提交表名、主键名或 SQL。
- 字段配置变更后,Table/Form 无需修改页面代码即可反映常规变化。
- 数据库约束、后端校验和前端提示三者一致。
### 阶段 6:完成业务单事务闭环
#### 试点范围
以 `bs_business` 为主模块,包含业务主表、箱信息、应收费用和应付费用。提单、运输、附件和审批先保留扩展点,不要求一次全部完成。
#### 工作内容
1. 重新确认业务单状态机,例如草稿、已提交、审核中、已完成、已作废。
2. 明确主表与各子表的外键、唯一约束、删除规则和并发策略。
3. 手写业务页面组织一个 `saveobjt` 请求,在同一个事务中提交:
- 主表新增或修改。
- 箱信息差异保存。
- 应收、应付费用差异保存。
- 业务编号生成。
- 状态和锁定校验。
- 必要的审计记录。
4. 前端保留手写业务详情页,用 `ModuleForm` 处理主表常规字段,用手写子表组件处理箱和费用。
5. 将旧页面中的金额计算、箱量描述、负责人整理和删除限制逐条分类:
- 展示计算留在前端。
- 数据完整性和状态限制移到后端。
- 可由数据库约束保证的规则下沉到数据库。
6. 页面根据已注册的模块保存对象构造表变更,统一调用 `saveobjt`;后端不增加业务单专用保存代码。
#### 验收标准
- 主表和所有子表要么全部成功,要么全部回滚。
- 已审核、已制单或已锁定费用不能通过直接请求绕过限制。
- 两人同时修改时能够检测冲突,不静默覆盖。
- 保存后重新加载的数据与页面提交结果一致。
### 阶段 7:完成只读报表闭环
#### 工作内容
1. 选择一个典型统计模块,例如应收未收明细或业务员汇总。
2. 将旧前端拼接的统计 SQL 固化为数据库视图。
3. 配置允许查询、排序和汇总的字段。
4. 实现只读分页、汇总、导出和权限校验。
5. 导出必须复用同一模块查询规则,不能由前端另拼一套 SQL。
#### 验收标准
- 页面查询结果、总数、汇总和导出结果使用同一过滤语义。
- 报表模块不能调用保存接口。
- 大数据量查询有稳定排序、索引和可接受的执行计划。
### 阶段 8:建立配置管理与校验工具
三类试点稳定后,再开发配置管理页面。
#### 工作内容
1. 开发模块、字段、分组和选项来源的管理页面。
2. 建立配置发布前校验:
- 查询对象和保存对象是否存在。
- 主键和字段映射是否有效。
- 列表、查询、表单排序是否重复。
- 保存字段是否属于保存对象。
- 选项来源是否有效。
3. 配置修改增加版本号或更新时间,使后端和前端缓存可以准确失效。
4. 生产环境配置修改需要权限和审计记录。
#### 验收标准
- 无效配置不能发布到运行状态。
- 配置错误能够定位到模块和字段,而不是只返回数据库异常。
- 配置更新后缓存可控刷新。
### 阶段 9:按业务域分批迁移
建议迁移顺序如下:
#### 第一批:基础资料
- 币种、港口、运输方式等字典。
- 合作伙伴和联系人。
- 选择项及基础资料维护。
目标是扩大通用模块覆盖面,并稳定选项来源和字段配置。
#### 第二批:合同与价格
- 客户合同。
- 价目表和价目明细。
- 默认价格选择。
目标是验证多模块复用、主子表和业务选择组件。
#### 第三批:业务执行
- 业务单。
- 箱信息、提单、运输和业务附件。
- 批量更新、标记和业务导出。
目标是完成核心业务运行闭环。
#### 第四批:财务链路
- 应收、应付费用。
- 账单及明细。
- 发票及明细。
- 收款、付款及核销。
- 日常费用和收付账户。
财务模块仍使用通用事务保存;审核、制单、核销和锁定状态必须由权限白名单、行版本和数据库约束共同保护。
#### 第五批:审批、统计和办公
- 审批设置、送审、审核、驳回和审批记录。
- 应收、应付和利润统计。
- 报表、Excel、附件和打印。
- 公告、提醒和其他办公功能。
#### 第六批:系统管理
- 用户、角色、权限和菜单管理。
- 模块、字段、查询和选项配置管理。
- 自动编号、审计日志和运维工具。
系统管理页面后迁移,但底层权限和模块配置表必须在前期通过脚本先可用。
### 阶段 10:数据迁移与正式切换
#### 工作内容
1. 每个业务域建立字段级迁移映射和转换规则。
2. 为状态、标志、空字符串、日期、金额和旧主键定义明确转换。
3. 迁移顺序遵循外键依赖:基础资料先于业务,业务先于财务,主表先于子表。
4. 先执行可重复的演练迁移,再执行正式迁移。
5. 建立对账指标:
- 表记录数。
- 主子关系完整性。
- 业务状态分布。
- 应收、应付、账单、发票和收付款金额。
- 孤立记录和重复业务编号。
6. 切换前冻结旧系统写入,执行最后一次增量迁移和对账。
7. 准备回退条件和回退步骤,回退不能依赖人工修改数据库。
#### 验收标准
- 关键业务数量和金额对账一致。
- 所有外键、唯一索引和检查约束通过。
- 新系统完成业务、财务和权限验收后才能切换。
### 阶段 11:收口过渡能力
#### 工作内容
1. 统计新前端对以下过渡接口的调用并逐一清零:
- 任意表/视图查询。
- 任意 SQL 查询。
- 客户端指定表名和主键的保存。
2. 禁止新代码继续引用这些接口。
3. 移除不再使用的 mock 页面、临时路由和兼容代码。
4. 旧系统切换为只读归档,保留规定期限后再下线。
5. 更新运维、部署、备份、恢复和故障处理文档。
#### 验收标准
- 正式模块全部通过模块接口运行。
- 生产环境不再开放任意 SQL 和任意表保存接口。
- 旧系统退出不影响历史数据查询和审计需要。
## 7. 每个模块的标准迁移步骤
以后迁移任何模块,都按以下顺序执行:
1. 确认旧模块业务目的,不以旧页面文件名代替需求。
2. 列出旧查询视图、保存表、子表、存储过程和报表依赖。
3. 确认新模块是可保存还是只读,并配置保存对象;主子页面明确一次事务涉及的表。
4. 设计新表、视图、约束、索引和状态字段。
5. 配置模块、字段、分组、选项和默认排序。
6. 配置查看、编辑、删除、导出和特殊动作权限。
7. 实现或复用后端查询和保存能力。
8. 实现手写页面,并组合 `ModuleTable`、`ModuleForm` 和业务组件。
9. 编写旧数据到新数据的迁移规则。
10. 核对查询、保存、权限、并发、导出和异常流程。
11. 完成用户验收后切换该模块。
12. 停止旧模块写入并记录迁移结论。
## 8. 当前下一步任务清单
按照现状,下一步应从阶段 0 和阶段 1 开始,建议严格按以下顺序执行:
1. 创建模块迁移台账,先录入 `b_hl`、`b_contact`、`bs_business` 和一个只读统计模块。
2. 核对新 FMS 数据库中现有配置表,确定哪些保留、哪些重建。
3. 完成 `s_module`、`s_module_field`、`s_module_group`、`s_option_source`、`s_option_item` 的最终 DDL。
4. 为一个简单基础资料模块编写种子配置。
5. 定义模块元数据、结构化查询和模块保存的 JSON 契约。
6. 实现后端模块定义读取和配置校验。
7. 实现后端模块分页查询,并停止让新页面直接传 `view_name`。
8. 实现最小模块权限校验。
9. 实现前端模块服务层、最小版 `ModuleTable` 和最小版 `ModuleForm`。
10. 用简单基础资料模块完成第一次端到端闭环。
11. 闭环验收后再实现 `ModuleForm` 的完整分组和选项能力。
12. 最后使用 `bs_business` 验证主子表 `saveobjt` 事务,不提前迁移财务模块。
## 9. 明确禁止的做法
- 不按旧路由顺序逐页复制代码。
- 不把旧 `frontend/src/backup` 中的文件迁入新项目。
- 不为每张表、每个模块或每个页面创建独立的查询和保存 Controller。
- 不因请求字段不同或前端调用方便而新增功能重复的接口。
- 不在新模块接口中接受任意 SQL、表名、主键名或排序片段。
- 不为了兼容旧调用继续增加 `V2`、`V3`、`new`、`old` 接口。
- 不只依赖前端控制业务状态流转、审核、核销和锁定,必须同时配置权限和数据库约束。
- 不用配置表描述完整页面和任意可执行脚本。
- 不在数据库约束尚未确定时先批量迁移数据。
- 不只做前端权限隐藏而缺少后端授权。
- 不在一个模块中长期混用旧接口和新模块接口。
- 不一次性迁移全部业务后再统一验收。
## 10. 完成定义
一个模块只有同时满足以下条件才算迁移完成:
- 新数据库表、视图、约束和索引已经确定。
- 模块、字段、选项和权限配置完整。
- 页面只通过 `module_id` 使用正式模块接口。
- 后端完成字段白名单、权限、业务校验和事务处理。
- 查询、详情、保存、删除、导出和异常流程可用。
- 旧数据迁移和业务对账完成。
- 旧模块停止写入,并有明确回退方案。
重构的衡量标准不是“旧页面复制了多少”,而是“有多少模块已经在新内核上形成可独立运行、可验证、可切换的完整闭环”。