20260719215738
This commit is contained in:
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`。
|
||||
- 独立的选项来源管理页面。
|
||||
- 正式模块权限内核。
|
||||
- 结构化模块查询接口。
|
||||
- 业务模块迁移。
|
||||
|
||||
这些能力在模块管理页面验证配置模型稳定后继续建设。
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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
@@ -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. 实现一个只读报表模块,验证查询、分页和导出边界。
|
||||
|
||||
@@ -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` 使用正式模块接口。
|
||||
- 后端完成字段白名单、权限、业务校验和事务处理。
|
||||
- 查询、详情、保存、删除、导出和异常流程可用。
|
||||
- 旧数据迁移和业务对账完成。
|
||||
- 旧模块停止写入,并有明确回退方案。
|
||||
|
||||
重构的衡量标准不是“旧页面复制了多少”,而是“有多少模块已经在新内核上形成可独立运行、可验证、可切换的完整闭环”。
|
||||
Reference in new issue
Block a user