20260723173317

This commit is contained in:
oneao committed 2026-07-23 17:33:18 +08:00
1 parent 59b3ccfb1c
commit 5f73b54802
33 files changed
+3064 -2108

No files matched your search

@@ -0,0 +1,183 @@
---
name: module-management-full-implementation
overview: 基于新数据库结构.md和开发规范.md完整实现模块管理页面。左侧模块树+右侧7个子Tab,子Tab操作按钮靠左。不创建独立的service文件,数据加载/保存逻辑直接写在index.vue中。
todos:
- id: create-main-page
content: 重写 index.vue 主页面:左侧 a-tree 模块树 + 右侧 a-tabs 布局,编写全部数据加载/保存逻辑(loadDataApi、saveObjectApi、diff、typeFromSqlType)
status: completed
- id: create-basic-panel
content: 使用 [skill:antdv-next] 创建 ModuleBasicPanel.vue 基本信息表单,根据模块类型控制字段可见性,支持新增和编辑
status: completed
dependencies:
- create-main-page
- id: create-fields-panel
content: 创建 ModuleFieldsPanel.vue 字段定义面板,FmsTable 展示字段列表,实现「同步字段」功能,操作按钮靠左
status: completed
dependencies:
- create-main-page
- id: create-list-edit-query-panels
content: 创建 ModuleListConfigPanel.vue、ModuleEditConfigPanel.vue、ModuleQueryConfigPanel.vue 三个配置面板,FmsTable 展示数据,操作按钮靠左
status: completed
dependencies:
- create-main-page
- id: create-group-manager
content: 创建 FieldGroupManager.vue 共用分组管理弹窗,a-modal + a-tree 管理分组树增删改
status: completed
dependencies:
- create-list-edit-query-panels
- id: create-powers-i18n-panels
content: 创建 ModulePowersPanel.vue 权限配置面板和 ModuleI18nPanel.vue 多语言面板,FmsTable 管理数据,操作按钮靠左
status: completed
dependencies:
- create-main-page
---
## 用户需求
根据新数据库结构.md和开发规范.md,完整实现模块管理页面。当前页面是空占位,需要构建左侧模块树+右侧子Tab布局。
## 核心功能
### 主页面布局
- 左侧:使用 `a-tree` 展示模块树(directory/page/data 三级),选中节点加载详情
- 右侧:使用 `a-tabs` 展示子Tab,根据选中模块的 `b_module_type` 动态显示对应Tab
### 模块类型与Tab对应
| 模块类型 | 可见Tab |
| --- | --- |
| directory | 基本信息、权限配置、多语言 |
| page | 基本信息、权限配置、多语言 |
| data | 基本信息、字段定义、列表配置、编辑配置、查询配置、权限配置、多语言 |
### 各子Tab功能
1. **基本信息**:`a-form` 编辑 `b_module` 核心字段(b_code、b_name、b_module_type、b_parent_id、b_route、b_viewtable、b_savetable、b_keyfield、b_orderfield、b_canmenu、b_canuse、b_xh),根据模块类型控制字段可见性。新增模块先调 `nextIdApi`
2. **字段定义**:`FmsTable` 展示 `b_module_field`,不可手工增删行。提供「同步字段」按钮调用 `describeApi` 获取 `b_viewtable` 列结构并与已有字段合并(保留已有配置、追加新列、标记已删列为不可用)。可编辑 `b_name`、`b_type`、`b_default_value`、`b_options`、`b_canuse`
3. **列表配置**:`FmsTable` 展示 `b_module_field_list`,支持新增/删除行。可选字段来自 `b_module_field`(b_canuse=1)。`FieldGroupManager` 弹窗管理 `b_module_field_group` 分组树
4. **编辑配置**:`FmsTable` 展示 `b_module_field_edit`,支持新增/删除行。同样通过 `FieldGroupManager` 管理分组
5. **查询配置**:`FmsTable` 展示 `b_module_field_query`(b_component、b_operator 下拉选择),支持新增/删除行
6. **权限配置**:`FmsTable` 展示 `b_module_power`(b_code、b_name),支持新增/删除行
7. **多语言**:`FmsTable` 展示 `b_i18n`(b_key、b_locale、b_value),支持新增/删除行
### 操作按钮排列
所有子Tab面板内,新增、删除、保存等操作按钮**靠左对齐**(`justify-content: flex-start`)
### 数据架构(不创建独立 service 文件)
- `index.vue` 承担全部数据职责:加载模块树、加载完整配置、维护草稿、对比diff、调用 `saveObjectApi`
- 子组件只通过 `props` 接收数据,通过 `emit('update', newRows)` 上报变更
- `index.vue` 保存流程:`diffRows(draft, original)` → `allocateTemporaryIds` → `saveObjectApi`
## 技术方案
### 实现策略
**数据流集中管理**:`index.vue` 作为数据中枢,直接调用 `loadDataApi`、`saveObjectApi`、`nextIdApi`、`describeApi` 等通用接口。所有子组件是无状态的展示/编辑组件,仅通过 props/emit 与父组件通信。
### 数据流
```
index.vue 选中模块
→ loadDataApi 加载 b_module + 各子表(Promise.all)
→ cloneData 生成 originals(原始快照)和 drafts(编辑草稿)
→ 各子组件接收 drafts 对应部分,用户编辑后 emit('update', newRows)
→ index.vue 更新对应 draft
→ 用户点击「保存」
→ 遍历各子表 diffRows(draft, original)
→ allocateTemporaryIds 分配临时ID
→ 构建 saveObjectApi 请求数组
→ 提交保存 → 重新加载刷新 originals
```
### 关键设计决策
1. **无独立 service 文件**:所有 `loadDataApi`/`saveObjectApi` 调用直接写在 `index.vue` 的 `<script setup>` 中
2. **组件纯展示**:所有子组件只负责展示和编辑,不调用 API
3. **SQL 类型映射**(字段同步用):`typeFromSqlType()` 作为 `index.vue` 内的普通函数
4. **分组管理复用**:`FieldGroupManager.vue` 接收 groups 数组并通过 emit 返回变更,不直接操作 API
5. **b_module_field 不手工增删**:同步通过 `describeApi`,字段定义面板中只有编辑功能无新增/删除按钮
### 实现要点
- b_id 全部使用 `String()` 包裹进行比对
- 布尔字段为 int 0/1(b_canuse、b_visible、b_sortable、b_required、b_readonly、b_disabled、b_canmenu)
- 子表新增行用 `createTempId()`,保存前 `allocateTemporaryIds()` 换真实ID
- 主表新增模块用 `nextIdApi`
- 操作按钮在子Tab面板内靠左(`a-space` 或 flex 布局 `justify-content: flex-start`)
- 样式使用 SCSS scoped,遵循 `--fms-*` CSS 变量主题
## 目录结构
```
fms-vue/src/views/module-management/
├── index.vue # [MODIFY] 主页面
│ - 左侧 a-tree 模块树,右侧 a-tabs
│ - 全部数据加载/保存逻辑(loadDataApi、saveObjectApi、nextIdApi、describeApi)
│ - 维护 drafts 和 originals,构建 diff 和 saveObjectApi 请求
│ - typeFromSqlType() 字段类型映射函数
│ - 选中节点变更、Tab切换、保存按钮事件处理
└── components/
├── ModuleBasicPanel.vue # [NEW] 基本信息表单
│ - a-form 编辑 b_module 字段
│ - props: moduleId, data(object), loading
│ - emit: update(modifiedData)
│ - 根据 b_module_type 控制字段可见性
├── ModuleFieldsPanel.vue # [NEW] 字段定义表格
│ - FmsTable 展示 b_module_field 列表
│ - 工具栏「同步字段」+「保存」按钮靠左
│ - props: moduleId, fields(array), viewTable, loading
│ - emit: update(newFields), sync(viewTable)
│ - 不可新增/删除行,可编辑 b_name/b_type/b_default_value/b_options/b_canuse
├── ModuleListConfigPanel.vue # [NEW] 列表配置表格
│ - FmsTable 展示 b_module_field_list
│ - 工具栏新增/删除/保存按钮靠左
│ - props: moduleId, listConfig(array), fields(array), groups(array), loading
│ - emit: update(newListConfig), openGroupManager
│ - 字段选择器展示可用字段,分组列可点击打开分组管理
├── ModuleEditConfigPanel.vue # [NEW] 编辑配置表格
│ - FmsTable 展示 b_module_field_edit
│ - 工具栏新增/删除/保存按钮靠左
│ - props: moduleId, editConfig(array), fields(array), groups(array), loading
│ - emit: update(newEditConfig), openGroupManager
├── ModuleQueryConfigPanel.vue # [NEW] 查询配置表格
│ - FmsTable 展示 b_module_field_query
│ - 工具栏新增/删除/保存按钮靠左
│ - props: moduleId, queryConfig(array), fields(array), loading
│ - emit: update(newQueryConfig)
│ - b_component、b_operator 使用下拉选择
├── ModulePowersPanel.vue # [NEW] 权限配置表格
│ - FmsTable 展示 b_module_power
│ - 工具栏新增/删除/保存按钮靠左
│ - props: moduleId, powers(array), loading
│ - emit: update(newPowers)
├── ModuleI18nPanel.vue # [NEW] 多语言表格
│ - FmsTable 展示 b_i18n
│ - 工具栏新增/删除/保存按钮靠左
│ - props: moduleId, i18n(array), loading
│ - emit: update(newI18n)
│ - 支持按 b_locale 筛选
└── FieldGroupManager.vue # [NEW] 共用分组管理弹窗
- a-modal 内含 a-tree 展示分组树
- 支持新增/编辑/删除分组节点
- props: visible, groups(array), moduleId
- emit: update:visible, update(newGroups)
- 被 ModuleListConfigPanel 和 ModuleEditConfigPanel 引用
```
## 使用的扩展
### Skill
- **antdv-next**
- 目的:查询 a-tree、a-tabs、a-form、a-form-item、a-modal、a-select、a-input、a-input-number、a-switch、a-button、a-space、a-empty 等组件的 API 文档
- 预期结果:获取准确的 props/events/slots 用法,确保模板中 API 使用正确
### SubAgent
- **code-explorer**
- 目的:在实现过程中快速查找项目中 FmsTable 的 columns 配置模式、dataChanges.js 的 diffRows 调用方式、现有表单校验示例等
- 预期结果:获得准确的现有代码引用,确保新代码风格与项目一致
@@ -0,0 +1,292 @@
---
name: module-management-full-implementation
overview: 基于新数据库结构.md和开发规范.md完整实现模块管理页面。左侧模块树+右侧7个子Tab(基本信息、字段定义、列表配置、编辑配置、查询配置、权限配置、多语言),子Tab操作按钮靠左。
todos:
- id: create-service
content: 创建 src/services/moduleManagement.js 服务层,封装 loadModuleTree、loadModuleConfig、saveModuleConfig、syncFields、typeFromSqlType 等方法
status: pending
- id: create-main-page
content: 重写 src/views/module-management/index.vue 主页面,实现左侧 FmsTree 模块树 + 右侧 a-tabs 动态Tab布局,集成数据加载与保存流程
status: pending
dependencies:
- create-service
- id: create-basic-panel
content: 使用 [skill:antdv-next] 创建 ModuleBasicPanel.vue 基本信息面板,表单编辑 b_module 字段,根据模块类型控制字段可见性
status: pending
dependencies:
- create-main-page
- id: create-fields-panel
content: 创建 ModuleFieldsPanel.vue 字段定义面板,FmsTable 展示字段列表,实现「同步字段」功能(调用 describeApi 合并视图列)
status: pending
dependencies:
- create-main-page
- id: create-list-edit-query-panels
content: 创建 ModuleListConfigPanel.vue、ModuleEditConfigPanel.vue、ModuleQueryConfigPanel.vue 三个配置面板,FmsTable 展示各自数据,操作按钮靠左
status: pending
dependencies:
- create-main-page
- id: create-group-manager
content: 创建 FieldGroupManager.vue 共用分组管理组件,弹窗内管理 b_module_field_group 树形分组增删改
status: pending
dependencies:
- create-list-edit-query-panels
- id: create-powers-i18n-panels
content: 创建 ModulePowersPanel.vue 权限配置面板和 ModuleI18nPanel.vue 多语言面板,FmsTable 管理 b_module_power 和 b_i18n 数据
status: pending
dependencies:
- create-main-page
---
## 用户需求
根据新数据库结构.md和开发规范.md,完整实现模块管理页面。当前模块管理页面只有一个空占位(`<el-button>`),需要构建完整的左侧模块树+右侧子Tab布局。
## 核心功能
### 主页面布局
- **左侧模块树**:以树形结构展示所有模块(directory/page/data),支持选中节点后在右侧加载详情
- **右侧详情区**:使用 `a-tabs` 展示子Tab,根据选中模块的 `b_module_type` 动态显示对应Tab
### 模块类型与Tab对应关系
| 模块类型 | 可用Tab |
| --- | --- |
| directory | 基本信息、权限配置、多语言 |
| page | 基本信息、权限配置、多语言 |
| data | 基本信息、字段定义、列表配置、编辑配置、查询配置、权限配置、多语言 |
### 各子Tab功能
1. **基本信息**:编辑 `b_module` 的核心字段(b_code、b_name、b_module_type、b_route、b_viewtable、b_savetable、b_keyfield、b_orderfield、b_parent_id、b_canmenu、b_canuse、b_xh),表单保存
2. **字段定义**:展示 `b_module_field` 列表,**不可手工增删**。提供「同步字段」按钮,调用 `describeApi` 获取 `b_viewtable` 的列结构,与已有字段合并(保留已有配置、追加新列、标记已删列为不可用)。支持编辑 `b_name`、`b_type`、`b_default_value`、`b_options`、`b_canuse`
3. **列表配置**:管理 `b_module_field_list`(字段列配置)和 `b_module_field_group`(多级表头分组)。表格展示列表配置行,支持新增/删除。分组管理弹窗支持树形分组增删改
4. **编辑配置**:管理 `b_module_field_edit`(编辑属性)和 `b_module_field_group`(编辑分组)。表格展示编辑配置行,支持新增/删除。共享分组管理组件
5. **查询配置**:管理 `b_module_field_query`,表格展示查询条件配置,支持新增/删除
6. **权限配置**:管理 `b_module_power`,表格展示操作权限(b_code、b_name),支持新增/删除
7. **多语言**:管理 `b_i18n`,表格展示翻译条目(b_key、b_locale、b_value),支持新增/删除
### 操作按钮排列
所有子Tab面板内,新增、删除、保存等操作按钮栏**靠左对齐**显示。
### 数据保存策略
- **主表 b_module**:选中模块后直接编辑表单并保存,新增模块先调用 `nextIdApi` 获取雪花ID
- **子表**(b_module_field/b_module_field_list等):新增行使用 `createTempId()` 生成临时负数ID,调用 `saveObjectApi` 统一保存变更
- **保存时**:使用 `diffRows()` 对比草稿与原始数据,通过 `tableChange()` 构建变更请求,多表变更一次提交到 `saveObjectApi`
## Tech Stack
- 前端框架:Vue 3(`<script setup>` SFCs)
- 状态管理:组件内部 ref/reactive(不引入额外 Pinia store)
- UI 组件库:antdv-next(自动导入)+ 项目封装组件(FmsTable、FmsTree、FmsSelect)
- 样式:SCSS scoped + CSS 变量主题(`--fms-*`)
- 数据获取:复用现有通用接口 `loadDataApi`、`saveObjectApi`、`nextIdApi`、`describeApi`
- 工具函数:`createTempId`(临时ID)、`diffRows`/`allocateTemporaryIds`/`remapForeignKeys`/`tableChange`/`hasChanges`(变更追踪)
## Implementation Approach
### 整体策略
采用**声明式数据驱动**模式:每个子Tab面板作为独立组件接收模块ID和完整配置数据,内部维护编辑态草稿(`cloneData`深拷贝),通过 `diffRows` 计算变更后交给父组件统一保存。
### 关键设计决策
1. **数据加载集中在 Service 层**:`moduleManagement.js` 封装 `loadModuleTree()`(加载树)、`loadModuleConfig(moduleId)`(加载单个模块的完整配置)、`saveModuleConfig(moduleId, changes)`(保存变更),各面板组件调用 Sem 方法获取和回存数据。
2. **字段同步采用合并策略**(与记忆 ID: 93228844 一致):调用 `describeApi(b_viewtable)` 获取视图列 → 按列名匹配已有 `b_module_field` → 视图中已存在的保留原配置不覆盖、新增列追加默认行、视图中不存在的已有字段标记 `b_canuse=0`。
3. **子Tab操作按钮左对齐**:每个面板组件内部在表格上方设置工具栏,使用 `justify-content: flex-start` 确保按钮靠左排列。
4. **分组管理复用**:列表配置和编辑配置共享 `b_module_field_group`,通过 `FieldGroupManager.vue` 组件统一管理分组树增删改。
5. **保存事务性**:所有变更通过 `saveObjectApi` 一次提交多表变更数组,后端在同一事务内执行,任何表失败整体回滚。
### Implementation Notes
- **b_id 全部字符串化**:后端返回的 bigint 在 `http.js` 拦截器中统一转为字符串,前端所有ID比较使用 `String()` 包裹
- **布尔字段为 int 0/1**:b_canuse、b_visible、b_sortable、b_required、b_readonly、b_disabled、b_canmenu 均使用 0/1
- **b_module_field 同步后不覆盖已有配置**:b_name、b_type、b_options 等由管理员手动维护的值在同步时保留
- **临时ID流转**:新增子表行 → `createTempId()` → 保存时 `allocateTemporaryIds()` 请求后端真实ID → 保存完成后 `loadAll` 重建草稿
## Architecture Design
```mermaid
flowchart TB
subgraph 主页面
A[index.vue<br/>左侧树 + 右侧Tab]
end
subgraph Service
S[moduleManagement.js<br/>loadModuleTree<br/>loadModuleConfig<br/>saveModuleConfig]
end
subgraph 组件
B1[ModuleBasicPanel<br/>基本信息表单]
B2[ModuleFieldsPanel<br/>字段定义表格+同步]
B3[ModuleListConfigPanel<br/>列表配置表格]
B4[ModuleEditConfigPanel<br/>编辑配置表格]
B5[ModuleQueryConfigPanel<br/>查询配置表格]
B6[ModulePowersPanel<br/>权限表格]
B7[ModuleI18nPanel<br/>多语言表格]
G[FieldGroupManager<br/>共用分组管理]
end
subgraph API
API1[loadDataApi]
API2[saveObjectApi]
API3[nextIdApi]
API4[describeApi]
end
A --> S
S --> API1
S --> API2
S --> API3
S --> API4
A --> B1
A --> B2
A --> B3
A --> B4
A --> B5
A --> B6
A --> B7
B3 --> G
B4 --> G
```
## Directory Structure
```
fms-vue/src/
├── services/
│ └── moduleManagement.js # [NEW] 模块管理服务层
│ # - loadModuleTree(): 加载所有模块并构建树形数据
│ # - loadModuleConfig(moduleId): 加载单个模块的完整配置
│ # (b_module + b_module_field + b_module_field_list
│ # + b_module_field_edit + b_module_field_query
│ # + b_module_field_group + b_module_power + b_i18n)
│ # - saveModuleConfig(moduleId, changes, oldData): 保存模块配置
│ # 对比草稿和原始数据,构建多表变更请求
│ # - syncFields(moduleId, viewTable): 同步字段
│ # 调用describeApi获取列结构并合并
│ # - typeFromSqlType(sqlType): SQL类型到b_type映射
├── views/
│ └── module-management/
│ ├── index.vue # [MODIFY] 主页面容器
│ │ # - 左侧 FmsTree 展示模块树(directory/page/data)
│ │ # - 右侧 a-tabs 根据 b_module_type 动态显示子Tab
│ │ # - 选中节点加载完整配置,传递给各子面板
│ │ # - 统一收集各面板变更并调用保存
│ └── components/
│ ├── ModuleBasicPanel.vue # [NEW] 基本信息面板
│ │ # - 表单编辑 b_module 核心字段
│ │ # - b_code/b_name/b_module_type/b_parent_id 必填
│ │ # - directory类型隐藏 route/viewtable/savetable
│ │ # - page类型必须填写 route,data类型必须填写 viewtable
│ │ # - 新增模块先调用 nextIdApi 获取ID
│ ├── ModuleFieldsPanel.vue # [NEW] 字段定义面板
│ │ # - FmsTable 展示 b_module_field 列表
│ │ # - 工具栏「同步字段」按钮 + 保存按钮(靠左)
│ │ # - 同步逻辑:describeApi获取列 → 合并已有字段
│ │ # - 不可手工新增/删除行,仅可编辑属性
│ ├── ModuleListConfigPanel.vue # [NEW] 列表配置面板
│ │ # - FmsTable 展示 b_module_field_list
│ │ # - 工具栏新增/删除/保存按钮(靠左)
│ │ # - 可选字段来自 b_module_field(b_canuse=1)
│ │ # - 分组列可打开 FieldGroupManager 弹窗
│ ├── ModuleEditConfigPanel.vue # [NEW] 编辑配置面板
│ │ # - FmsTable 展示 b_module_field_edit
│ │ # - 工具栏新增/删除/保存按钮(靠左)
│ │ # - 可选字段来自 b_module_field(b_canuse=1)
│ │ # - 分组列可打开 FieldGroupManager 弹窗
│ ├── ModuleQueryConfigPanel.vue # [NEW] 查询配置面板
│ │ # - FmsTable 展示 b_module_field_query
│ │ # - 工具栏新增/删除/保存按钮(靠左)
│ │ # - b_component和b_operator使用下拉选择
│ ├── ModulePowersPanel.vue # [NEW] 权限配置面板
│ │ # - FmsTable 展示 b_module_power
│ │ # - 工具栏新增/删除/保存按钮(靠左)
│ │ # - b_code 唯一校验
│ ├── ModuleI18nPanel.vue # [NEW] 多语言面板
│ │ # - FmsTable 展示 b_i18n
│ │ # - 工具栏新增/删除/保存按钮(靠左)
│ │ # - 支持 zh-CN/en-US 等语言切换筛选
│ └── FieldGroupManager.vue # [NEW] 共用分组管理组件
│ # - 弹窗内展示分组树(b_module_field_group)
│ # - 支持新增/编辑/删除分组节点
│ # - 被 ModuleListConfigPanel 和 ModuleEditConfigPanel 引用
```
## Key Code Structures
### moduleManagement.js 核心接口
```js
// 加载模块树(用于左侧树展示)
export async function loadModuleTree()
// 加载单个模块的完整配置(所有关联表数据)
export async function loadModuleConfig(moduleId)
// 返回: { module, fields, fieldList, fieldEdit, fieldQuery, groups, powers, i18n }
// 保存模块配置变更
export async function saveModuleConfig(moduleId, draftData, originalData)
// draftData 和 originalData 结构同 loadModuleConfig 返回值
// 内部使用 diffRows 对比各子表,构建 saveObjectApi 请求
// 同步字段:从 b_viewtable 拉取列结构,与已有字段合并
export async function syncFields(moduleId, viewTable, existingFields)
// SQL类型到 b_type 的映射
export function typeFromSqlType(sqlType)
// varchar/char/nvarchar → input, text/ntext → textarea
// int/bigint/decimal/numeric/float → number, money/smallmoney → money
// date → date, datetime/datetime2 → datetime, bit → switch
```
### 各面板组件统一 Props/Emits 模式
```js
// 所有面板组件遵循相同的接口约定
defineProps({
moduleId: { type: String, required: true }, // 当前模块ID
data: { type: Array, required: true }, // 面板对应的数据行数组
loading: { type: Boolean, default: false }, // 加载状态
})
const emit = defineEmits(['update']) // 数据变更时触发,传递新的行数组
```
### 数据流
```
index.vue 选中模块
→ loadModuleConfig(moduleId) 获取原始数据
→ cloneData 生成 draftData(编辑态草稿)
→ 各面板接收 draftData 对应部分,用户编辑后 emit('update', newRows)
→ index.vue 更新 draftData
→ 用户点击「保存全部」
→ saveModuleConfig(moduleId, draftData, originalData)
→ diffRows 对比各子表变更
→ allocateTemporaryIds 分配临时ID为真实ID
→ 构建 saveObjectApi 请求数组
→ 提交保存
→ 重新 loadModuleConfig 刷新原始数据
```
## Agent Extensions
### Skill
- **antdv-next**
- Purpose: 查询 antdv-next 组件库的 API 文档,确认 a-tree、a-tabs、a-form、a-modal、a-select、a-button、a-space 等组件的 props/events/slots 用法
- Expected outcome: 获取准确的组件属性定义和事件签名,确保模板中使用正确的 API
### SubAgent
- **code-explorer**
- Purpose: 在实现过程中需要快速搜索项目中的代码模式(如 FmsTable 使用方式、dataChanges.js 调用示例、现有表单校验模式等)时使用
- Expected outcome: 获得准确的现有代码引用,确保新代码风格一致
@@ -0,0 +1,206 @@
---
name: module-management-page
overview: 基于新数据库结构与开发规范,从零重建 module-management 页面(替换当前占位页),并把路由 /system/metadata 改为 /system/module-management。页面以「左侧模块树 + 右侧多 Tab 配置」组织,覆盖 b_module 及其字段/分组/列表/编辑/查询/权限/多语言配置,复用现有通用接口与工具函数,不新增后端端点与服务文件。
design:
styleKeywords:
- Enterprise Admin
- Clean Professional
- Ant Design Vue
- Two-column Tree + Tabs
- Subtle Blue Accent
- Desktop-first
fontSystem:
fontFamily: PingFang SC, Microsoft YaHei
heading:
size: 16px
weight: 600
subheading:
size: 14px
weight: 500
body:
size: 13px
weight: 400
colorSystem:
primary:
- "#1677ff"
- "#4096ff"
- "#0958d9"
background:
- "#ffffff"
- "#f7f8fa"
- "#f0f5ff"
text:
- "#1f2329"
- "#8a8f99"
- "#ffffff"
functional:
- "#52c41a"
- "#ff4d4f"
- "#faad14"
- "#e5e7eb"
todos:
- id: update-route-menu
content: 修改 routes.js 与 AppSidebar.vue,将路由 /system/metadata 改为 /system/module-management(moduleCode 保留 m_metadata)
status: completed
- id: build-view-shell
content: 新建 index.vue 与 composables/useModuleManagement.js:左树右 Tabs 布局、草稿加载/保存编排、权限受控工具条
status: completed
dependencies:
- update-route-menu
- id: build-basic-tree
content: 实现 ModuleTreePanel 与 ModuleBasicInfoPanel:模块树增删改、基本信息表单与类型联动校验
status: completed
dependencies:
- build-view-shell
- id: build-field-group
content: 实现 ModuleFieldDefinitionPanel(describe 同步合并)与 ModuleGroupPanel(多级分组树)
status: completed
dependencies:
- build-view-shell
- id: build-list-edit-query
content: 实现 ModuleListPanel/ModuleEditPanel/ModuleQueryPanel,用 FmsTable 配置列表/编辑/查询场景
status: completed
dependencies:
- build-view-shell
- id: build-power-i18n
content: 实现 ModulePowerPanel 与 ModuleTranslationsPanel,维护操作权限与多语言文案
status: completed
dependencies:
- build-view-shell
- id: polish-verify
content: 用 [skill:antdv-next] 核对组件用法,补全 scoped scss 与空态,说明未执行构建/测试
status: completed
dependencies:
- build-basic-tree
- build-field-group
- build-list-edit-query
- build-power-i18n
---
## 用户需求
- 依据 `新数据库结构.md` 的 schema 从零重建「模块管理」页面(替换当前占位页 `index.vue`),覆盖 `b_module` 及其字段定义、字段分组、列表/编辑/查询配置、操作权限、多语言等完整配置能力。
- 路由由 `/system/metadata` 改为 `/system/module-management`(仅改路径与 `menuKey`,`moduleCode` 仍保留 `m_metadata` 以匹配数据库种子与权限)。
- 严格遵循既有设计决策:字段定义来自模块查询视图(经 `POST /data/describe` 同步,不可手工增删);子表新增行用前端本地临时负数 ID(`createTempId`),主表 `b_module` 保留 `nextIdApi` 取号;保存后重建草稿使临时 ID 被真实 ID 覆盖。
- 复用项目现有通用接口(`loadDataApi`/`saveObjectApi`/`nextIdApi`/`describeApi`)、`FmsTable`、antdv-next 组件,以及 `utils/dataChanges.js`、`utils/tempId.js`、`utils/i18n.js`,不新增后端端点与服务文件。
- 遵守开发规范:表格尽量用 `fms-table`,桌面端实现,scoped scss 复用现有 `--fms-*` token,不执行构建/测试。
## 核心功能
- 左侧模块树(目录/页面/数据三类,支持增删改与选中)。
- 右侧多 Tab:基本信息、字段定义(含视图同步)、字段分组、列表配置、编辑配置、查询配置、操作权限、多语言。
- 顶部工具条(保存/刷新)与权限受控按钮(create/update/delete 来自 `b_module_power`,sync 为同步字段)。
- 基于通用接口与工具函数的「按模块加载草稿 → 本地编辑 → 差异保存(整事务)」工作流。
## 技术栈
- 前端:Vue 3 `<script setup>` + Vite + Pinia;UI 组件库 **antdv-next**(项目已通过 `vite.config.js` 自动按需引入,禁止手动 import 已由 resolver 处理的组件);表格统一使用项目封装的 `FmsTable`。
- 后端:完全复用现有通用端点,不新增任何接口或服务文件。
- 状态/工具:`stores/permissions.js`(权限门控)、`utils/dataChanges.js`、`utils/tempId.js`、`utils/i18n.js`。
## 实施方法
整体采用「单一编排视图 + 子面板」架构:`ModuleManagementView`(`index.vue`)负责选中模块、加载全量草稿、保存编排;各 `components/*.vue` 面板只负责自身区块的展示与编辑,所有可变数据集中在视图层响应式 `draft` 中,保存时由编排层统一 `diffRows` + `saveObjectApi`。
### 数据编排(核心)
1. **加载草稿 `loadDraft(moduleId)`**:并发 `loadDataApi` 拉取该模块相关的 8 张表:`b_module`(按 id)、`b_module_field`、`b_module_field_group`、`b_module_field_list`、`b_module_field_edit`、`b_module_field_query`、`b_module_power`,以及 `b_i18n`(按 `module.`/字段键前缀过滤)。结果存入响应式 `draft`,并深拷贝一份 `snapshot` 供保存时差异比对。
2. **本地编辑**:面板直接 `push`/`splice`/`Object.assign` 修改 `draft` 中的数组与对象;子表新增行 `b_id = createTempId()`(负数字符串)。
3. **保存 `saveDraft()`**:
- 主表 `b_module` 新增:先 `nextIdApi()` 取真实 ID 写入 `draft.b_module.b_id`。
- 对每张表 `diffRows(draft[table], snapshot[table])` → `tableChange(table, diff)`,汇聚为 `requests[]`。
- 子表 insert 含临时负数 ID 时,调用 `allocateTemporaryIds([...所有子表 insert 行])` 由后端生成真实 ID 并就地替换;随后 `remapForeignKeys` 把 `b_field_id`/`b_group_id` 指向的临时 ID 同步映射到真实 ID。
- 单事务 `saveObjectApi(requests)`;成功后 `snapshot = cloneData(draft)`,临时 ID 已被真实 ID 覆盖。
4. **视图同步 `syncFields(module)`**:`describeApi(module.b_viewtable)` 返回列 `[{name,type,nullable,size}]`;按 `(b_module_id,b_field)` 合并——视图已有列保留原 `b_name/b_type/b_options/b_default_value` 不覆盖;视图新增列追加默认行(`createTempId`,`b_type` 由 SQL 类型映射:字符串→input、长文本→textarea、数值→number、金额→money、date→date、datetime→datetime、bit→switch);视图已删列保留配置但 `b_canuse=0`。该按钮受 `canPower('m_metadata','sync')` 门控,仅 `data` 且配置了 `b_viewtable` 时可用。
### 关键设计决策与权衡
- **集中草稿 + 差异保存**:复用现有 `dataChanges.js`,避免各面板各自拼请求,降低出错面;保存走 `saveobjt` 整事务,任一表失败全回滚,符合设计决策。
- **临时 ID 策略**:严格遵循记忆决策——子表用 `createTempId`,主表用 `nextIdApi`;FK 映射在保存前完成,杜绝脏引用。
- **FmsTable 复用**:列表/编辑/查询/权限/多语言等表格统一用 `FmsTable`,单元格编辑沿用 `TableDemo.vue` 的 `makeCellEditor` 模式(编辑行渲染控件、其余行纯文本),不重复实现表格能力,契合新规范。
- **约束校验前置**:`b_module_type` 与字段联动校验在表单层做(directory 不可配 route/viewtable/savetable;page 必配 route;data 不可菜单/不可 route 且必配 viewtable),保存前校验 `b_code` 正则 `^[a-z][a-z0-9_]{1,49},减少后端往返报错。
- **性能**:每模块仅加载自身数据,树节点选中才加载草稿;`describe` 仅在点击同步时调用;表格虚拟滚动(`FmsTable` 默认开启)支撑大数据量。
### 架构关系
```mermaid
flowchart TD
View[ModuleManagementView index.vue] -->|load/save 编排| Draft[(响应式 draft + snapshot)]
View --> Tree[ModuleTreePanel]
View --> Tabs[Tab 区]
Tabs --> Basic[ModuleBasicInfoPanel]
Tabs --> Field[ModuleFieldDefinitionPanel 含 sync]
Tabs --> Group[ModuleGroupPanel]
Tabs --> List[ModuleListPanel]
Tabs --> Edit[ModuleEditPanel]
Tabs --> Query[ModuleQueryPanel]
Tabs --> Power[ModulePowerPanel]
Tabs --> I18n[ModuleTranslationsPanel]
Draft -.diffRows/tableChange.-> Save[saveObjectApi 单事务]
Field -.describeApi.-> Sync[视图列合并]
```
## 目录结构
仅修改 2 个文件、新建 1 个视图与 8 个面板组件(含 1 个编排 composable):
```
fms-vue/src/
├── router/routes.js # [MODIFY] path "/system/metadata"→"/system/module-management",
│ # name→system-module-management,meta.menuKey 同步,moduleCode 保留 "m_metadata"
├── layouts/components/AppSidebar.vue # [MODIFY] systemPages 的 key 改为 "/system/module-management"
└── views/module-management/
├── index.vue # [NEW] ModuleManagementView:左树+右 Tabs 布局、工具条、
│ # 草稿加载/保存编排、权限受控按钮(create/update/delete/sync)
├── composables/useModuleManagement.js # [NEW] 编排逻辑:loadDraft/saveDraft/syncFields、
│ # diffRows+tableChange+allocateTemporaryIds+remapForeignKeys、SQL 类型→b_type 映射
└── components/
├── ModuleTreePanel.vue # [NEW] 模块树(目录/页面/数据),选中/新增/删除,b_xh 排序
├── ModuleBasicInfoPanel.vue # [NEW] 基本信息表单:b_code/b_name/b_module_type/b_route/
│ # b_viewtable/b_savetable/b_keyfield/b_orderfield/b_canmenu/b_canuse/b_xh
│ # + 类型联动校验
├── ModuleFieldDefinitionPanel.vue # [NEW] 字段定义 FmsTable + 「同步字段」按钮(describe 合并)
├── ModuleGroupPanel.vue # [NEW] 字段分组树(b_module_field_group 自引用),列表/编辑共用
├── ModuleListPanel.vue # [NEW] 列表配置 FmsTable(b_module_field_list,引用 group)
├── ModuleEditPanel.vue # [NEW] 编辑配置 FmsTable(b_module_field_edit,required/readonly/disabled/group)
├── ModuleQueryPanel.vue # [NEW] 查询配置 FmsTable(b_module_field_query,component/operator)
├── ModulePowerPanel.vue # [NEW] 操作权限 FmsTable(b_module_power)
└── ModuleTranslationsPanel.vue # [NEW] 多语言 FmsTable(b_i18n,按 locale 分列编辑)
```
## 关键代码结构
- 编排 composable 暴露:`loadDraft(moduleId)`、`saveDraft()`、`syncFields()`、`draft`(响应式)、`currentModuleId`、`permissions`(canPower 结果);各面板通过 `props` 接收对应数组、`emits` 回传变更,避免跨面板直接耦合。
- `b_type` SQL 类型映射函数(放置于 composable):`mapSqlTypeToFieldType(typeName) → 'input'|'textarea'|'number'|'money'|'date'|'datetime'|'switch'`。
## 设计风格
企业级元数据管理后台,采用「左侧模块树 + 右侧多 Tab 配置」的经典后台布局,桌面端优先。视觉以 Ant Design Vue 设计语言为基底,主色沿用项目侧栏蓝 `#1677ff`,辅以 `--fms-*` 设计变量(边框 `#e5e7eb`、正文 `#1f2329`、次要文字 `#8a8f99`、表面 `#ffffff`/`#f7f8fa`)。整体克制、清晰、信息密度适中,重点突出当前选中模块与可编辑表格,hover/选中态用浅蓝底与蓝色描边强化。
## 页面结构(自上而下分区)
1. **顶栏工具条**:左侧显示当前模块名与 `b_code` 面包屑;右侧权限受控按钮——新增模块(create)、保存(update)、同步字段(sync)、删除(delete),按钮按 `m_metadata` 的 `b_module_power` 与 `canPower` 显隐禁用。
2. **主体双栏**:
- 左栏 `ModuleTreePanel`:模块树(目录可展开,页面/数据为叶子),顶部「新建目录/页面/数据」入口,节点 hover 显示删除。选中节点高亮蓝底。
- 右栏 Tab 区:基本信息 / 字段定义 / 字段分组 / 列表配置 / 编辑配置 / 查询配置 / 操作权限 / 多语言,共 8 个标签页,内容区统一白底卡片 + `FmsTable`。
3. **表单与表格**:基本信息用 antdv-next `Form`(标签右对齐、栅格两列);各配置 Tab 用 `FmsTable`,编辑行内联控件(Input/InputNumber/Select/Switch),非编辑行纯文本;分组树用 `FmsTree` 或 `Tree` 组件展示多级 `b_module_field_group`。
4. **空态**:未选中模块时主区展示引导空态(图标 + 「请选择或新建模块」),不显示报错。
## 交互与动效
- 树节点选中平滑高亮;Tab 切换保留各面板本地编辑(草稿集中在视图层,不随 Tab 卸载丢失)。
- 表格编辑行浅蓝底(`rgba(22,119,255,0.08)`),hover 行浅灰底;按钮、输入框 focus 蓝色描边过渡 0.2s。
- 保存成功 `message.success` 轻提示;删除/同步等有副作用操作使用 `Popconfirm` 二次确认。
- 响应式:固定双栏,左栏 260px 可随侧栏收起;右栏内容随容器宽度自适应,表格横向虚拟滚动。
## 可复用的智能体扩展
### Skill
- **antdv-next**
- 目的:在编写 `ModuleBasicInfoPanel` 表单、`FmsTable` 单元格编辑器、树与弹窗等 UI 时,查询 antdv-next 组件的 API、props/events/slots 与用法示例,确保组件用法正确。
- 预期结果:各面板正确使用 antdv-next 组件(Form/Select/Switch/Tree/Popconfirm/Tabs 等),避免属性误用;保证 UI 实现符合组件库规范且与项目自动引入配置兼容。
@@ -0,0 +1,123 @@
---
name: module-management-subtable-layout
overview: 修复 module-management 模块管理页子表(字段/列表/表单/查询/权限/翻译)的布局:1) 让子表区域通过 flex:1 正确填满可用高度(表格宽高 100%);2) 缩小各子表列宽,避免列沾满整个页面。
todos:
- id: fix-container-height
content: 修复 index.vue 容器高度链,使子表区可纵向撑满
status: completed
- id: constrain-table-width
content: 调整 MetadataTablePanel 约束表格宽度跟随内容,避免列拉伸占满
status: completed
dependencies:
- fix-container-height
- id: shrink-column-widths
content: 缩小字段/列表/表单/权限/翻译/查询六个子表面板列宽至紧凑值
status: completed
dependencies:
- constrain-table-width
---
## 用户需求
module-management(模块配置)页面中涉及子表的地方布局不合理,需要修正:
1. 子表区域通过外层 `flex:1` 撑满可用高度,表格宽高 `100%`。
2. 各子表列宽缩小、不要过大,且不要拉伸占满整个页面。
## 产品概述
优化模块管理页各子表面板(字段定义、列表配置、表单配置、查询配置、操作权限、翻译)的布局与列宽。这些面板共用 `MetadataTablePanel` + 共享组件 `FmsTable`(基于 stk-table-vue),目标是让子表纵向铺满、列宽紧凑、不拉伸占满整页。基本信息(表单)不在本次范围。
## 核心功能
- 子表纵向填满:修复 index.vue 容器高度链,使表格区随内容区高度自适应铺满。
- 列宽紧凑化:缩小各子表列定义的 `width` 数值,避免列过宽。
- 表格不拉伸:确保列宽合计小于页面时,表格宽度跟随内容,不自动拉伸占满整页。
## 技术栈
- 前端:Vue 3 `<script setup>` + Vite + antdv-next + SCSS(scoped)
- 表格:共享组件 `FmsTable`(封装 `stk-table-vue` 的 `StkTable`),子表统一通过 `MetadataTablePanel` 使用
- 仅调整样式与列宽数值,不新增/修改任何后端接口或业务逻辑
## 实施方案
### 1. 高度链修复(主因)
当前 `.metadata-page` 是 grid,但只定义了 `grid-template-columns`,未定义行,导致 `.workspace` 高度塌缩为内容高度;其下游 `.config-tabs` 的 `height: calc(100% - 58px)` 与 `MetadataTablePanel` 的 `height:100%` / `.table-body` 的 `flex:1` 全部失效,表格区拿不到高度。
修复方式(仅 CSS):
- `.metadata-page` 增加 `grid-template-rows: minmax(0, 1fr)`,让第二列行撑满。
- `.workspace` 改为 `display:flex; flex-direction:column; height:100%`,使 header + tabs 纵向堆叠并铺满。
- `.workspace-spin` 及其 `.ant-spin-container`(deep)改为 `display:flex; flex-direction:column; flex:1; min-height:0`。
- `.config-tabs` 用 `display:flex; flex-direction:column; flex:1; min-height:0` 替代 `height: calc(100% - 58px)`。
- `MetadataTablePanel` 结构已正确(`.metadata-table-panel` flex 列 + 无 height prop 时 `height:100%`;`.table-body` `flex:1`;`FmsTable` 已是 `width/height:100%`),高度链补通后即可纵向铺满。
### 2. 宽度不拉伸约束
`FmsTable` 是共享组件(还被 authorization-management / i18n-management / dashboard 使用),**不可全局改宽**。在 `MetadataTablePanel` 内以 scoped `:deep` 选择器约束表格宽度跟随内容,确保列宽合计小于页面时不被拉伸占满:
```
.table-body {
flex: 1;
min-height: 0;
:deep(.fms-table) {
width: fit-content;
max-width: 100%;
height: 100%;
}
}
```
### 3. 列宽紧凑化
在各子表面板中把列 `width` 改为紧凑值,规则:顺序/开关类 `60–80`、短文本(编码/类型/字段名/标题/语言)`120–150`、中长文本(名称/默认值/提示/多语言键)`160–220`、长文本(说明/JSON/翻译内容)`220–300`。示例目标值:
- `ModuleFieldsPanel`:字段 130 / 默认名称 150 / 多语言键 200 / 字段类型 110 / 字典编码 130 / 默认值 150 / 说明 200 / 启用 64
- `ModuleListPanel`:节点类型 90 / 关联字段 170 / 上级分组 140 / 分组·列标题 160 / 标题多语言键 200 / 显示 64 / 顺序 70 / 列宽 70 / 显示类型 110 / 排序 64 / 表内编辑 80
- `ModuleEditPanel`:分组表(分组编码 140 / 默认标题 160 / 标题多语言键 200 / 顺序 70 / 启用 64);编辑表(字段 170 / 编辑器 100 / 表单分组 140 / 输入提示 160 / 必填·只读·禁用·Form 各 64 / 顺序 70 / 栅格 70 / 编辑器JSON 240)
- `ModulePowersPanel`:操作编码 140 / 默认名称 160 / 多语言键 220 / 顺序 70 / 启用 64
- `ModuleTranslationsPanel`:多语言键 260 / 语言 130 / 翻译内容 300
- `ModuleQueryPanel`:字段 180 / 查询组件 120 / 操作符 90 / 默认值 200 / 顺序 70 / 启用 64
## 实施注意
- 仅改样式与列 `width` 数值,不改动保存/同步/临时 ID/校验等任何业务逻辑,保留用户未提交改动。
- 不修改 `FmsTable.vue` 全局宽度,避免影响其他管理页。
- 浏览器核对(不自行 build):切换字段/列表/表单/查询/权限/翻译各 Tab 时表格纵向铺满、列不拉伸占满、必要时出现横向滚动而非撑满整页。
- 按开发规范,完成后不执行构建/测试,交付时说明未执行。
## 架构设计
布局容器链(自上而下高度传递):
```mermaid
graph TD
A[.metadata-page grid 行=1fr] --> B[.workspace flex column]
B --> C[.workspace-spin flex:1]
C --> D[header.workspace-header 固定]
C --> E[.config-tabs flex:1]
E --> F[.ant-tabs-tabpane height:100%]
F --> G[MetadataTablePanel height:100% flex column]
G --> H[.panel-toolbar 固定]
G --> I[.table-body flex:1]
I --> J[FmsTable width:fit-content height:100%]
```
## 目录结构
本次仅修改前端 module-management 下的样式与列宽,不影响后端与其他页面。
```
fms-vue/src/views/module-management/
├── index.vue # [MODIFY] 修复容器高度链:.metadata-page 增加 grid-template-rows;.workspace 改 flex column;.workspace-spin 与 .config-tabs 用 flex:1 替代 calc,确保子表纵向铺满
└── components/
├── MetadataTablePanel.vue # [MODIFY] 以 scoped :deep(.fms-table) 约束表格 width:fit-content / max-width:100% / height:100%,防止列拉伸占满整页
├── ModuleFieldsPanel.vue # [MODIFY] 缩小字段定义各列 width 至紧凑值
├── ModuleListPanel.vue # [MODIFY] 缩小列表列/字段节点各列 width 至紧凑值
├── ModuleEditPanel.vue # [MODIFY] 缩小表单分组表与编辑字段表各列 width 至紧凑值
├── ModulePowersPanel.vue # [MODIFY] 缩小操作权限各列 width 至紧凑值
├── ModuleTranslationsPanel.vue # [MODIFY] 缩小翻译各列 width 至紧凑值
└── ModuleQueryPanel.vue # [MODIFY] 缩小查询条件各列 width 至紧凑值
```
@@ -0,0 +1,125 @@
---
name: 重构元数据数据库结构
overview: 按 新数据库结构.md 重写 fms-api/config/migrations/000_reset_metadata.sql,使表结构完全符合目标 schema;b_user 表保持不变,迁移脚本末尾仅种子 m_system 与 m_metadata。
todos:
- id: rewrite-b-module-field
content: 重写 b_module_field:b_type/b_default_value/b_options 列与约束,删冗余列
status: pending
- id: add-b-module-field-group
content: 新增 b_module_field_group 表(含自引用树),取代 b_module_field_edit_group
status: pending
- id: refactor-b-module-field-list
content: 重构 b_module_field_list:引用分组表,移除 b_parent_id/b_kind/display/canedit
status: pending
dependencies:
- add-b-module-field-group
- id: refactor-b-module-field-edit
content: 重构 b_module_field_edit:引用分组表,精简编辑器列,统一 b_xh
status: pending
dependencies:
- add-b-module-field-group
- id: update-seed-data
content: 收敛种子数据仅保留 m_system 与 m_metadata
status: pending
dependencies:
- rewrite-b-module-field
- refactor-b-module-field-list
- refactor-b-module-field-edit
---
## 用户需求
根据 `新数据库结构.md` 重构元数据数据库结构,使表结构完全符合目标 schema;`b_user` 表保持不变;迁移脚本末尾的种子数据仅保留"系统管理"目录与"模块管理"模块,移除多语言管理与用户授权相关全部数据。
## 产品概述
本次为纯数据库结构重构,不涉及前后端功能页面。成果为重写 `fms-api/config/migrations/000_reset_metadata.sql` 迁移脚本,使其中所有元数据表(除 b_user 外)与目标设计文档逐一对应,并收敛种子数据。
## 核心改动
- `b_module_field`:重命名列并采用新控件类型集合,删除冗余列。
- `b_module_field_list`:移除自包含树(b_parent_id/b_kind/b_display_type/b_canedit),改为引用统一分组表 `b_module_field_group`。
- 新增 `b_module_field_group`:统一承载列表多级表头与编辑分组的布局分组树(取代旧的 `b_module_field_edit_group`)。
- `b_module_field_edit`:剔除编辑器细节列,改为引用分组表的 `b_group_id`,统一显示顺序列为 `b_xh`。
- `b_module_field_query`、`b_module_power`、`b_user_module`、`b_user_power`、`b_i18n` 已符合目标,无需改动。
- 种子数据仅保留 `m_system` 目录与 `m_metadata` 模块及其操作权限、管理员授权;删除 `m_i18n`、`m_authorization` 全部相关行。
## 技术栈
- 目标数据库:SQL Server(Druid 连接池,按机构独立库)。
- 变更载体:单文件 SQL 迁移脚本 `fms-api/config/migrations/000_reset_metadata.sql`,属于"RESET"脚本(执行前删除除 b_user 外的全部用户表、保留视图,再重建并插入种子)。
- 不涉及后端 Java / 前端 Vue 代码:已 grep 确认旧列名仅在迁移脚本内出现,通用接口为表/列透传,模块管理前端当前为空占位页。
## 实施方法
完全重写 `000_reset_metadata.sql` 的建表与种子部分,使每个元数据表逐字段对齐 `新数据库结构.md`。脚本开头的"删除旧表 + 保留视图 + 重置管理员会话上下文"逻辑保持不变(它依赖 b_user,符合"b_user 不改"约束)。
### 关键修改点(按目标文档)
**1. b_module_field(混合旧列拆分)**
- `b_field_type varchar(30) NOT NULL` → 重命名为 `b_type`,CHECK 改为 `('input','textarea','number','money','date','datetime','switch','select')`。
- `b_value nvarchar(500) NULL` → 重命名为 `b_default_value nvarchar(500) NULL`。
- `b_dict_code varchar(100) NULL` → 重命名为 `b_options nvarchar(max) NULL`,新增 `CHECK (b_options IS NULL OR ISJSON(b_options) = 1)`。
- 删除 `b_remark`、`b_inputdatetime` 两列。
- 保留 `b_id/b_module_id/b_field/b_name/b_i18n/b_canuse` 及外键、唯一索引 `(b_module_id, b_field)`、索引 `(b_module_id, b_canuse, b_id)`。
**2. b_module_field_group(新增,取代 b_module_field_edit_group)**
- 列:`b_id bigint NOT NULL`、`b_module_id bigint NOT NULL`、`b_parent_id bigint NULL`、`b_title nvarchar(100) NOT NULL`、`b_i18n varchar(150) NULL`、`b_xh int NOT NULL DEFAULT 0`、`b_canuse int NOT NULL DEFAULT 1`。
- 外键:`FK..._module` → b_module(b_id);`FK..._parent` → 自引用 b_module_field_group(b_id)。
- 约束:`CK..._canuse CHECK (b_canuse IN (0,1))`;索引 `IX (b_module_id, b_canuse, b_xh, b_id)`。
- 注意:旧的 `b_code` 列不再需要(被 b_id 稳定关联键取代),不保留。
**3. b_module_field_list(引用统一分组表)**
- 删除 `b_parent_id`、`b_kind`、`b_display_type`、`b_canedit` 及其自引用 FK `FK_b_module_field_list_parent`、shape CHECK `CK_b_module_field_list_kind` / `CK_b_module_field_list_shape` / `CK_b_module_field_list_canedit` / `CK_b_module_field_list_width`(宽度校验可保留为简单 `>0`)。
- 新增 `b_group_id bigint NULL`,`FK_b_module_field_list_group` → b_module_field_group(b_id)(同模块分组;为空时为顶层列)。
- 保留 `b_id/b_module_id/b_field_id/b_title/b_i18n/b_visible/b_xh/b_width/b_sortable` 及 CHECK `b_visible/b_sortable IN (0,1)`。
- 唯一索引 `UX (b_module_id, b_field_id)`;索引 `IX (b_module_id, b_group_id, b_xh, b_id)`。
**4. b_module_field_edit(通用编辑配置)**
- 删除 `b_editor`、`b_edit_format`、`b_editor_options`、`b_placeholder`、`b_canform`、`b_edit_group`、`b_edit_span` 七列。
- `b_edit_xh` → 重命名为 `b_xh`(DEFAULT 0)。
- 新增 `b_group_id bigint NULL`,`FK_b_module_field_edit_group` → b_module_field_group(b_id)。
- 保留 `b_id/b_module_id/b_field_id/b_required/b_readonly/b_disabled` 及对应 CHECK;唯一 `(b_module_id, b_field_id)`;索引 `(b_module_id, b_xh, b_id)`。
**5. 无需改动(已符合目标)**
- `b_module`(含模块类型校验、目录/页面/数据三类约束)、`b_module_field_query`、`b_module_power`、`b_user_module`、`b_user_power`、`b_i18n` 结构、约束、索引保持不变。
### 建表顺序(满足外键依赖)
`b_module` → `b_module_field_group` → `b_module_field` → `b_module_field_list` / `b_module_field_edit` / `b_module_field_query` → `b_module_power` → `b_user_module` / `b_user_power` → `b_i18n` → 种子插入。
说明:`b_module_field_group` 必须在 `b_module_field` 之前建(list/edit 的 group_id 引用它);`b_module_field_list` 与 `b_module_field_edit` 的 `b_field_id` 依赖 `b_module_field`。
### 种子数据(按"只保留元数据")
- 保留模块:`m_system`(directory,b_canmenu=1)、`m_metadata`(page,/system/metadata,b_canmenu=1)。
- 保留操作权限(b_module_power):`m_metadata` 的 create/update/delete/sync 四条。
- 保留授权(b_user_module / b_user_power):管理员对 `m_metadata` 的模块权限及上述四条操作权限。
- 移除:`m_i18n`、`m_authorization` 模块及其全部 `b_module_power`、`b_user_module`、`b_user_power` 行。
- 固定雪花种子 ID(9 开头)相应精简为 system/metadata 两套,避免与已删除模块 ID 冲突。
## 实施注意
- 脚本头部 DROP 逻辑保留:仅删用户表(排除 b_user)、保留视图,重置 admin 会话上下文(依赖 b_user,符合约束)。
- 保持 `SET XACT_ABORT ON` 与事务包裹,确保失败时回滚。
- 不引入任何前端/后端代码改动;现有通用接口对列名透传,无需适配。
- 按开发规范,不主动执行脚本或构建;交付时说明未执行,建议用户在选定的组织库(g3hd)手动执行验证。
## 目录结构
本次仅修改一个文件:
```
fms-api/config/migrations/
└── 000_reset_metadata.sql # [MODIFY] 重写建表与种子部分,对齐 新数据库结构.md:
# - b_module_field 列重命名/精简
# - 新增 b_module_field_group 取代 b_module_field_edit_group
# - b_module_field_list 改引用分组表、移除自包含树
# - b_module_field_edit 精简并引用分组表
# - 种子仅保留 m_system + m_metadata
# 头部 DROP 与 admin 上下文逻辑保持不变
```
@@ -2,6 +2,9 @@
WARNING: This script removes every user table except dbo.b_user.
Database views are intentionally preserved. Run it only against a selected
organization database after confirming the target connection.
The metadata tables are rebuilt from scratch to match 新数据库结构.md.
Only the b_user table is preserved; every other user table is dropped.
*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
@@ -15,7 +18,7 @@ BEGIN TRANSACTION;
WHERE b_account = N'g3soft' AND b_canuse = 1
);
IF @AdminId IS NULL
THROW 51000, '未找到启用的 g3soft 用户,已取消重置。', 1;
THROW 51000, N'未找到启用的 g3soft 用户,已取消重置。', 1;
EXEC sys.sp_set_session_context @key = N'reset_admin_id', @value = @AdminId;
DECLARE @Sql nvarchar(max) = N'';
@@ -26,7 +29,7 @@ BEGIN TRANSACTION;
FROM sys.foreign_keys
WHERE OBJECT_NAME(parent_object_id) <> N'b_user'
OR OBJECT_NAME(referenced_object_id) <> N'b_user';
IF @Sql <> N'' EXEC sys.sp_executesql @Sql;
IF @Sql <> N'' EXEC sys.sp_executesql @Sql;
SET @Sql = N'';
SELECT @Sql = @Sql + N'DROP TABLE '
@@ -39,7 +42,7 @@ GO
DECLARE @AdminId bigint = TRY_CONVERT(bigint, SESSION_CONTEXT(N'reset_admin_id'));
IF @AdminId IS NULL
THROW 51001, '重置会话缺少管理员 ID。', 1;
THROW 51001, N'重置会话缺少管理员 ID。', 1;
CREATE TABLE dbo.b_module (
b_id bigint NOT NULL,
@@ -69,7 +72,7 @@ GO
CONSTRAINT CK_b_module_canuse CHECK (b_canuse IN (0, 1)),
CONSTRAINT CK_b_module_directory CHECK (b_module_type <> 'directory' OR (b_route IS NULL AND b_viewtable IS NULL AND b_savetable IS NULL)),
CONSTRAINT CK_b_module_page CHECK (b_module_type <> 'page' OR b_route IS NOT NULL),
CONSTRAINT CK_b_module_data CHECK (b_module_type <> 'data' OR b_canmenu = 0)
CONSTRAINT CK_b_module_data CHECK (b_module_type <> 'data' OR (b_canmenu = 0 AND b_route IS NULL))
);
CREATE UNIQUE INDEX UX_b_module_route ON dbo.b_module (b_route) WHERE b_route IS NOT NULL;
CREATE INDEX IX_b_module_parent ON dbo.b_module (b_parent_id, b_xh, b_id);
@@ -80,93 +83,75 @@ GO
b_field varchar(50) NOT NULL,
b_name nvarchar(100) NOT NULL,
b_i18n varchar(150) NULL,
b_field_type varchar(30) NOT NULL,
b_value nvarchar(500) NULL,
b_dict_code varchar(100) NULL,
b_type varchar(30) NOT NULL,
b_default_value nvarchar(500) NULL,
b_options nvarchar(max) NULL,
b_canuse int NOT NULL CONSTRAINT DF_b_module_field_canuse DEFAULT (1),
b_remark nvarchar(500) NULL,
b_inputdatetime datetime NOT NULL CONSTRAINT DF_b_module_field_inputdatetime DEFAULT (GETDATE()),
CONSTRAINT PK_b_module_field PRIMARY KEY (b_id),
CONSTRAINT FK_b_module_field_module FOREIGN KEY (b_module_id) REFERENCES dbo.b_module (b_id),
CONSTRAINT UQ_b_module_field UNIQUE (b_module_id, b_field),
CONSTRAINT CK_b_module_field_type CHECK (b_field_type IN ('string', 'text', 'number', 'date', 'datetime', 'boolean', 'select_id', 'select_input')),
CONSTRAINT CK_b_module_field_canuse CHECK (b_canuse IN (0, 1))
CONSTRAINT CK_b_module_field_type CHECK (b_type IN ('input', 'textarea', 'number', 'money', 'date', 'datetime', 'switch', 'select')),
CONSTRAINT CK_b_module_field_canuse CHECK (b_canuse IN (0, 1)),
CONSTRAINT CK_b_module_field_options CHECK (b_options IS NULL OR ISJSON(b_options) = 1)
);
CREATE INDEX IX_b_module_field_module ON dbo.b_module_field (b_module_id, b_canuse, b_id);
CREATE TABLE dbo.b_module_field_group (
b_id bigint NOT NULL,
b_module_id bigint NOT NULL,
b_parent_id bigint NULL,
b_title nvarchar(100) NOT NULL,
b_i18n varchar(150) NULL,
b_xh int NOT NULL CONSTRAINT DF_b_module_field_group_xh DEFAULT (0),
b_canuse int NOT NULL CONSTRAINT DF_b_module_field_group_canuse DEFAULT (1),
CONSTRAINT PK_b_module_field_group PRIMARY KEY (b_id),
CONSTRAINT FK_b_module_field_group_module FOREIGN KEY (b_module_id) REFERENCES dbo.b_module (b_id),
CONSTRAINT FK_b_module_field_group_parent FOREIGN KEY (b_parent_id) REFERENCES dbo.b_module_field_group (b_id),
CONSTRAINT CK_b_module_field_group_canuse CHECK (b_canuse IN (0, 1))
);
CREATE INDEX IX_b_module_field_group_module ON dbo.b_module_field_group (b_module_id, b_canuse, b_xh, b_id);
CREATE TABLE dbo.b_module_field_list (
b_id bigint NOT NULL,
b_module_id bigint NOT NULL,
b_parent_id bigint NULL,
b_kind varchar(20) NOT NULL,
b_field_id bigint NULL,
b_group_id bigint NULL,
b_title nvarchar(100) NULL,
b_i18n varchar(150) NULL,
b_visible int NOT NULL CONSTRAINT DF_b_module_field_list_visible DEFAULT (1),
b_xh int NOT NULL CONSTRAINT DF_b_module_field_list_xh DEFAULT (0),
b_width int NULL,
b_sortable int NOT NULL CONSTRAINT DF_b_module_field_list_sortable DEFAULT (0),
b_display_type varchar(30) NULL,
b_canedit int NOT NULL CONSTRAINT DF_b_module_field_list_canedit DEFAULT (0),
CONSTRAINT PK_b_module_field_list PRIMARY KEY (b_id),
CONSTRAINT FK_b_module_field_list_module FOREIGN KEY (b_module_id) REFERENCES dbo.b_module (b_id),
CONSTRAINT FK_b_module_field_list_parent FOREIGN KEY (b_parent_id) REFERENCES dbo.b_module_field_list (b_id),
CONSTRAINT FK_b_module_field_list_field FOREIGN KEY (b_field_id) REFERENCES dbo.b_module_field (b_id),
CONSTRAINT CK_b_module_field_list_kind CHECK (b_kind IN ('group', 'field')),
CONSTRAINT CK_b_module_field_list_shape CHECK (
(b_kind = 'group' AND b_field_id IS NULL AND b_width IS NULL AND b_display_type IS NULL AND b_canedit = 0)
OR (b_kind = 'field' AND b_field_id IS NOT NULL AND b_display_type IS NOT NULL)
),
CONSTRAINT FK_b_module_field_list_group FOREIGN KEY (b_group_id) REFERENCES dbo.b_module_field_group (b_id),
CONSTRAINT CK_b_module_field_list_visible CHECK (b_visible IN (0, 1)),
CONSTRAINT CK_b_module_field_list_sortable CHECK (b_sortable IN (0, 1)),
CONSTRAINT CK_b_module_field_list_canedit CHECK (b_canedit IN (0, 1)),
CONSTRAINT CK_b_module_field_list_width CHECK (b_width IS NULL OR b_width > 0)
);
CREATE UNIQUE INDEX UX_b_module_field_list_field ON dbo.b_module_field_list (b_module_id, b_field_id) WHERE b_field_id IS NOT NULL;
CREATE INDEX IX_b_module_field_list_tree ON dbo.b_module_field_list (b_module_id, b_parent_id, b_xh, b_id);
CREATE TABLE dbo.b_module_field_edit_group (
b_id bigint NOT NULL,
b_module_id bigint NOT NULL,
b_code varchar(100) NOT NULL,
b_title nvarchar(100) NOT NULL,
b_i18n varchar(150) NULL,
b_xh int NOT NULL CONSTRAINT DF_b_module_field_edit_group_xh DEFAULT (0),
b_canuse int NOT NULL CONSTRAINT DF_b_module_field_edit_group_canuse DEFAULT (1),
CONSTRAINT PK_b_module_field_edit_group PRIMARY KEY (b_id),
CONSTRAINT FK_b_module_field_edit_group_module FOREIGN KEY (b_module_id) REFERENCES dbo.b_module (b_id),
CONSTRAINT UQ_b_module_field_edit_group UNIQUE (b_module_id, b_code),
CONSTRAINT CK_b_module_field_edit_group_canuse CHECK (b_canuse IN (0, 1))
);
CREATE INDEX IX_b_module_field_edit_group_module ON dbo.b_module_field_edit_group (b_module_id, b_canuse, b_xh, b_id);
CREATE INDEX IX_b_module_field_list_tree ON dbo.b_module_field_list (b_module_id, b_group_id, b_xh, b_id);
CREATE TABLE dbo.b_module_field_edit (
b_id bigint NOT NULL,
b_module_id bigint NOT NULL,
b_field_id bigint NOT NULL,
b_editor varchar(30) NOT NULL,
b_edit_format varchar(100) NULL,
b_editor_options nvarchar(max) NULL,
b_required int NOT NULL CONSTRAINT DF_b_module_field_edit_required DEFAULT (0),
b_readonly int NOT NULL CONSTRAINT DF_b_module_field_edit_readonly DEFAULT (0),
b_disabled int NOT NULL CONSTRAINT DF_b_module_field_edit_disabled DEFAULT (0),
b_placeholder nvarchar(200) NULL,
b_canform int NOT NULL CONSTRAINT DF_b_module_field_edit_canform DEFAULT (1),
b_edit_group varchar(100) NULL,
b_edit_xh int NOT NULL CONSTRAINT DF_b_module_field_edit_xh DEFAULT (0),
b_edit_span int NOT NULL CONSTRAINT DF_b_module_field_edit_span DEFAULT (24),
b_group_id bigint NULL,
b_xh int NOT NULL CONSTRAINT DF_b_module_field_edit_xh DEFAULT (0),
CONSTRAINT PK_b_module_field_edit PRIMARY KEY (b_id),
CONSTRAINT FK_b_module_field_edit_module FOREIGN KEY (b_module_id) REFERENCES dbo.b_module (b_id),
CONSTRAINT FK_b_module_field_edit_field FOREIGN KEY (b_field_id) REFERENCES dbo.b_module_field (b_id),
CONSTRAINT FK_b_module_field_edit_group FOREIGN KEY (b_group_id) REFERENCES dbo.b_module_field_group (b_id),
CONSTRAINT UQ_b_module_field_edit UNIQUE (b_module_id, b_field_id),
CONSTRAINT CK_b_module_field_edit_required CHECK (b_required IN (0, 1)),
CONSTRAINT CK_b_module_field_edit_readonly CHECK (b_readonly IN (0, 1)),
CONSTRAINT CK_b_module_field_edit_disabled CHECK (b_disabled IN (0, 1)),
CONSTRAINT CK_b_module_field_edit_canform CHECK (b_canform IN (0, 1)),
CONSTRAINT CK_b_module_field_edit_span CHECK (b_edit_span BETWEEN 1 AND 24),
CONSTRAINT CK_b_module_field_edit_options CHECK (b_editor_options IS NULL OR ISJSON(b_editor_options) = 1)
CONSTRAINT CK_b_module_field_edit_disabled CHECK (b_disabled IN (0, 1))
);
CREATE INDEX IX_b_module_field_edit_module ON dbo.b_module_field_edit (b_module_id, b_edit_xh, b_id);
CREATE INDEX IX_b_module_field_edit_module ON dbo.b_module_field_edit (b_module_id, b_xh, b_id);
CREATE TABLE dbo.b_module_field_query (
b_id bigint NOT NULL,
@@ -238,26 +223,18 @@ GO
DECLARE @SystemId bigint = 910000000000000001;
DECLARE @MetadataId bigint = 910000000000000002;
DECLARE @I18nId bigint = 910000000000000003;
DECLARE @AuthorizationId bigint = 910000000000000004;
INSERT INTO dbo.b_module (b_id, b_code, b_parent_id, b_name, b_i18n, b_module_type, b_route, b_canmenu, b_canuse, b_xh) VALUES
(@SystemId, 'm_system', NULL, N'系统管理', 'module.m_system', 'directory', NULL, 1, 1, 100),
(@MetadataId, 'm_metadata', @SystemId, N'元数据配置', 'module.m_metadata', 'page', '/system/metadata', 1, 1, 10),
(@I18nId, 'm_i18n', @SystemId, N'多语言管理', 'module.m_i18n', 'page', '/system/i18n', 1, 1, 20),
(@AuthorizationId, 'm_authorization', @SystemId, N'用户授权', 'module.m_authorization', 'page', '/system/authorization', 1, 1, 30);
(@MetadataId, 'm_metadata', @SystemId, N'元数据配置', 'module.m_metadata', 'page', '/system/metadata', 1, 1, 10);
INSERT INTO dbo.b_module_power (b_id, b_module_id, b_code, b_name, b_i18n, b_xh, b_canuse) VALUES
(910000000000000101, @MetadataId, 'create', N'新增', 'power.m_metadata.create', 10, 1),
(910000000000000102, @MetadataId, 'update', N'保存', 'power.m_metadata.update', 20, 1),
(910000000000000103, @MetadataId, 'delete', N'删除', 'power.m_metadata.delete', 30, 1),
(910000000000000104, @MetadataId, 'sync', N'同步字段', 'power.m_metadata.sync', 40, 1),
(910000000000000105, @I18nId, 'update', N'保存', 'power.m_i18n.update', 10, 1),
(910000000000000106, @AuthorizationId, 'grant', N'授权', 'power.m_authorization.grant', 10, 1);
(910000000000000104, @MetadataId, 'sync', N'同步字段', 'power.m_metadata.sync', 40, 1);
INSERT INTO dbo.b_user_module (b_id, b_user_id, b_module_id, b_inputuser_id) VALUES
(910000000000000201, @AdminId, @MetadataId, @AdminId),
(910000000000000202, @AdminId, @I18nId, @AdminId),
(910000000000000203, @AdminId, @AuthorizationId, @AdminId);
(910000000000000201, @AdminId, @MetadataId, @AdminId);
INSERT INTO dbo.b_user_power (b_id, b_user_id, b_power_id, b_inputuser_id)
SELECT 910000000000000300 + ROW_NUMBER() OVER (ORDER BY b_id), @AdminId, b_id, @AdminId
FROM dbo.b_module_power;
@@ -8,11 +8,13 @@ import cn.g3soft.fmsapi.utils.snowflake.idgen.IdGenerator;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -56,8 +58,15 @@ public class DataController {
}
@GetMapping("/nextid")
public ApiResponse<Long> nextId() {
return ApiResponse.success(IdGenerator.nextId());
public ApiResponse<?> nextId(@RequestParam(required = false, defaultValue = "1") int count) {
if (count <= 1) {
return ApiResponse.success(IdGenerator.nextId());
}
List<Long> ids = new ArrayList<>();
for (int i = 0; i < count; i++) {
ids.add(IdGenerator.nextId());
}
return ApiResponse.success(ids);
}
@PostMapping("/describe")
@@ -76,7 +76,7 @@ public class DbUtils {
sql.append(" WHERE ").append(searchCondition.trim());
}
if (hasText(orderBy)) {
sql.append(" ORDER BY ").append(buildOrderBy(orderBy));
sql.append(" ORDER BY ").append(buildPageOrderBy(orderBy));
}
String querySql = sql.toString();
@@ -14,9 +14,7 @@ const openKeys = ref(["system"]);
const effectiveCollapsed = computed(() => appStore.sidebarCollapsed);
const menuItems = computed(() => {
const systemPages = [
{ key: "/system/metadata", label: "元数据配置", moduleCode: "m_metadata" },
{ key: "/system/i18n", label: "多语言管理", moduleCode: "m_i18n" },
{ key: "/system/authorization", label: "用户授权", moduleCode: "m_authorization" },
{ key: "/system/module-management", label: "模块管理", moduleCode: "m_metadata" },
].filter((item) => permissionStore.canModule(item.moduleCode));
const items = [{ key: "/dashboard", icon: menuIcon(House), label: "工作台" }];
if (systemPages.length)
@@ -175,7 +175,7 @@ function toggleMaximize() {
<ArrowLeft />
</button>
</a-tooltip>
<span class="tab-action__divider" />
<a-tooltip title="刷新当前页" placement="bottom">
<button
type="button"
@@ -186,7 +186,7 @@ function toggleMaximize() {
<RotateCw />
</button>
</a-tooltip>
<span class="tab-action__divider" />
<a-tooltip title="返回控制台" placement="bottom">
<button
type="button"
@@ -357,7 +357,7 @@ function toggleMaximize() {
.tab-nav-button {
display: grid;
flex: none;
width: 26px;
width: 28px;
height: 28px;
padding: 0;
place-items: center;
@@ -386,23 +386,21 @@ function toggleMaximize() {
.tab-actions {
display: flex;
flex: none;
height: 28px;
align-items: center;
border: 1px solid var(--fms-border);
border-radius: 6px;
background: var(--fms-surface);
overflow: hidden;
gap: 4px;
}
.tab-action {
display: grid;
width: 30px;
height: 100%;
flex: none;
width: 28px;
height: 28px;
padding: 0;
place-items: center;
border: 0;
background: transparent;
border-radius: 4px;
color: var(--fms-text-secondary);
background: var(--tabs-idle-bg);
cursor: pointer;
transition:
color 0.15s ease,
@@ -414,19 +412,8 @@ function toggleMaximize() {
}
.lucide {
width: 15px;
height: 15px;
width: 14px;
height: 14px;
}
}
.tab-action__divider {
flex: none;
width: 1px;
height: 16px;
background: var(--fms-border);
}
.tab-maximize {
margin-left: 2px;
}
</style>
+4 -26
View File
@@ -25,38 +25,16 @@ const routes = [
},
},
{
path: "system/metadata",
name: "system-metadata",
path: "system/module-management",
name: "system-module-management",
component: () => import("@/views/module-management/index.vue"),
meta: {
title: "元数据配置",
title: "模块管理",
section: "系统管理",
menuKey: "/system/metadata",
menuKey: "/system/module-management",
moduleCode: "m_metadata",
},
},
{
path: "system/authorization",
name: "system-authorization",
component: () => import("@/views/authorization-management/index.vue"),
meta: {
title: "用户授权",
section: "系统管理",
menuKey: "/system/authorization",
moduleCode: "m_authorization",
},
},
{
path: "system/i18n",
name: "system-i18n",
component: () => import("@/views/i18n-management/index.vue"),
meta: {
title: "多语言管理",
section: "系统管理",
menuKey: "/system/i18n",
moduleCode: "m_i18n",
},
},
],
},
{
+5 -4
View File
@@ -89,11 +89,12 @@ export function saveObjectApi(requests) {
}
/**
* 获取下一个雪花 ID。
* @returns {Promise<ApiResult<string>>} 取号响应,雪花 ID 使用字符串避免精度丢失。
* 获取下一个雪花 ID;传入 count 可批量获取指定数量的 ID(返回数组)。
* @param {number} [count=1] 需要获取的 ID 数量,缺省 1。
* @returns {Promise<ApiResult<string|string[]>>} count 为 1 时 data 为单个 ID 字符串,大于 1 时为 ID 字符串数组。
*/
export function nextIdApi() {
return get("/data/nextid");
export function nextIdApi(count = 1) {
return get("/data/nextid", { params: { count } });
}
/**
+5 -3
View File
@@ -25,11 +25,13 @@ export async function allocateTemporaryIds(rowGroups) {
rowGroups
.flat()
.map((row) => String(row?.b_id ?? ""))
.filter((id) => /^-\d+$/.test(id)),
.filter((id) => /^-\d+$/.test(id))
),
];
const generated = await Promise.all(temporaryIds.map(() => nextIdApi()));
const mapping = new Map(temporaryIds.map((id, index) => [id, String(generated[index].data)]));
if (!temporaryIds.length) return new Map();
const res = await nextIdApi(temporaryIds.length);
const ids = Array.isArray(res.data) ? res.data : [res.data];
const mapping = new Map(temporaryIds.map((id, index) => [id, String(ids[index])]));
rowGroups.flat().forEach((row) => {
const id = mapping.get(String(row?.b_id));
if (id) row.b_id = id;
@@ -1,300 +0,0 @@
<script setup>
import { computed, h, onMounted, ref } from "vue";
import { App, Checkbox } from "antdv-next";
import { RefreshCw, Save, ShieldCheck } from "@lucide/vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { loadDataApi, saveObjectApi } from "@/services/api";
import { useAuthStore } from "@/stores/auth";
import { allocateTemporaryIds, diffRows, tableChange } from "@/utils/dataChanges";
import { createTempId } from "@/utils/tempId";
const { message } = App.useApp();
const authStore = useAuthStore();
const loading = ref(false);
const saving = ref(false);
const users = ref([]);
const modules = ref([]);
const powers = ref([]);
const selectedUserId = ref();
const moduleIds = ref([]);
const powerIds = ref([]);
const originalModules = ref([]);
const originalPowers = ref([]);
const userOptions = computed(() =>
users.value.map((item) => ({
value: item.b_id,
label: `${item.b_name} (${item.b_account})`,
})),
);
const moduleRows = computed(() =>
modules.value.filter((item) => item.b_module_type !== "directory"),
);
const powersByModule = computed(() => {
const result = new Map();
powers.value.forEach((power) => {
const key = String(power.b_module_id);
if (!result.has(key)) result.set(key, []);
result.get(key).push(power);
});
return result;
});
const moduleIdSet = computed(() => new Set(moduleIds.value.map(String)));
const powerIdSet = computed(() => new Set(powerIds.value.map(String)));
const columns = computed(() => [
{ dataIndex: "b_name", title: "模块", width: 220 },
{ dataIndex: "b_code", title: "模块编码", width: 160 },
{
dataIndex: "__moduleAccess",
title: "访问",
width: 80,
align: "center",
customCell: ({ row }) =>
h(Checkbox, {
checked: moduleIdSet.value.has(String(row.b_id)),
"onUpdate:checked": (checked) => toggleModule(row, checked),
}),
},
{
dataIndex: "__powers",
title: "操作权限",
width: 520,
customCell: ({ row }) =>
h(
"div",
{ class: "power-options" },
(powersByModule.value.get(String(row.b_id)) || []).map((power) =>
h(
Checkbox,
{
checked: powerIdSet.value.has(String(power.b_id)),
"onUpdate:checked": (checked) => togglePower(row, power, checked),
},
() => power.b_name,
),
),
),
},
]);
async function loadBase() {
loading.value = true;
try {
const [userResult, moduleResult, powerResult] = await Promise.all([
loadDataApi("b_user", "b_canuse = 1", "b_name ASC", [
"b_id",
"b_account",
"b_name",
"b_canuse",
]),
loadDataApi("b_module", "b_canuse = 1", "b_xh ASC"),
loadDataApi("b_module_power", "b_canuse = 1", "b_xh ASC"),
]);
users.value = userResult.data || [];
modules.value = moduleResult.data || [];
powers.value = powerResult.data || [];
if (!selectedUserId.value && users.value.length) selectedUserId.value = users.value[0].b_id;
await loadPermissions();
} catch (error) {
message.error(error.message || "授权数据加载失败");
} finally {
loading.value = false;
}
}
async function loadPermissions() {
if (!selectedUserId.value) return;
const userId = String(selectedUserId.value);
if (!/^\d+$/.test(userId)) return message.warning("用户 ID 格式不正确");
try {
const [moduleResult, powerResult] = await Promise.all([
loadDataApi("b_user_module", `b_user_id = ${userId}`, "b_id ASC"),
loadDataApi("b_user_power", `b_user_id = ${userId}`, "b_id ASC"),
]);
originalModules.value = moduleResult.data || [];
originalPowers.value = powerResult.data || [];
moduleIds.value = originalModules.value.map((item) => item.b_module_id);
powerIds.value = originalPowers.value.map((item) => item.b_power_id);
} catch (error) {
message.error(error.message || "用户权限加载失败");
}
}
function toggleModule(module, checked) {
const values = new Set(moduleIds.value.map(String));
if (checked) values.add(String(module.b_id));
else {
values.delete(String(module.b_id));
const modulePowerIds = new Set(
(powersByModule.value.get(String(module.b_id)) || []).map((item) => String(item.b_id)),
);
powerIds.value = powerIds.value.filter((value) => !modulePowerIds.has(String(value)));
}
moduleIds.value = [...values];
}
function togglePower(module, power, checked) {
if (checked && !moduleIdSet.value.has(String(module.b_id))) {
moduleIds.value = [...moduleIds.value, module.b_id];
}
const values = new Set(powerIds.value.map(String));
if (checked) values.add(String(power.b_id));
else values.delete(String(power.b_id));
powerIds.value = [...values];
}
async function save() {
if (!selectedUserId.value) return;
saving.value = true;
try {
const currentModules = moduleIds.value.map((moduleId) => {
const old = originalModules.value.find(
(item) => String(item.b_module_id) === String(moduleId),
);
return (
old || {
b_id: createTempId(),
b_user_id: selectedUserId.value,
b_module_id: moduleId,
b_inputuser_id: authStore.user.id,
b_inputdatetime: new Date().toISOString().slice(0, 19).replace("T", " "),
}
);
});
const currentPowers = powerIds.value.map((powerId) => {
const old = originalPowers.value.find((item) => String(item.b_power_id) === String(powerId));
return (
old || {
b_id: createTempId(),
b_user_id: selectedUserId.value,
b_power_id: powerId,
b_inputuser_id: authStore.user.id,
b_inputdatetime: new Date().toISOString().slice(0, 19).replace("T", " "),
}
);
});
await allocateTemporaryIds([currentModules, currentPowers]);
const moduleChanges = diffRows(currentModules, originalModules.value);
const powerChanges = diffRows(currentPowers, originalPowers.value);
await saveObjectApi([
tableChange("b_user_power", powerChanges),
tableChange("b_user_module", moduleChanges),
]);
message.success("用户授权已保存");
await loadPermissions();
} catch (error) {
message.error(error.message || "授权保存失败");
} finally {
saving.value = false;
}
}
onMounted(loadBase);
</script>
<template>
<section class="authorization-page">
<header class="page-header">
<div class="page-title">
<h1><ShieldCheck />用户授权</h1>
<span>模块访问与操作权限</span>
</div>
<div class="header-actions">
<a-button :loading="loading" @click="loadBase"><RefreshCw />刷新</a-button>
<a-button type="primary" :loading="saving" :disabled="!selectedUserId" @click="save">
<Save />保存授权
</a-button>
</div>
</header>
<div class="user-bar">
<span>用户</span>
<a-select
v-model:value="selectedUserId"
show-search
:options="userOptions"
@change="loadPermissions"
/>
</div>
<div class="table-body">
<a-spin :spinning="loading">
<FmsTable
:columns="columns"
:data-source="moduleRows"
row-key="b_id"
:col-resizable="false"
/>
</a-spin>
</div>
</section>
</template>
<style scoped lang="scss">
.authorization-page {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
gap: 12px;
padding: 12px;
border: 1px solid var(--fms-border);
background: var(--fms-surface);
}
.page-header,
.page-title,
.page-title h1,
.header-actions,
.user-bar {
display: flex;
align-items: center;
}
.page-header {
flex: none;
justify-content: space-between;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--fms-border);
}
.page-title,
.page-title h1,
.header-actions,
.user-bar {
gap: 8px;
}
.page-title h1 {
margin: 0;
font-size: 18px;
letter-spacing: 0;
}
.page-title span {
color: var(--fms-text-secondary);
font-size: 12px;
}
.user-bar {
flex: none;
.ant-select {
width: 300px;
}
}
.table-body {
flex: 1;
min-height: 0;
:deep(.ant-spin-nested-loading),
:deep(.ant-spin-container) {
height: 100%;
}
:deep(.power-options) {
display: flex;
align-items: center;
gap: 12px;
}
}
</style>
@@ -1,193 +0,0 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { App } from "antdv-next";
import { Languages, Plus, RefreshCw, Save, Trash2 } from "@lucide/vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { loadDataApi, saveObjectApi } from "@/services/api";
import { allocateTemporaryIds, cloneData, diffRows, tableChange } from "@/utils/dataChanges";
import { I18N_LOCALES } from "@/utils/i18n";
import { createTempId } from "@/utils/tempId";
const { message } = App.useApp();
const loading = ref(false);
const saving = ref(false);
const keyword = ref("");
const rows = ref([]);
const original = ref([]);
const activeRowKey = ref(null);
const selectedRowKeys = ref([]);
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys;
},
}));
const columns = [
{ dataIndex: "b_key", title: "多语言键", width: 320, editor: { maxlength: 150 } },
{
dataIndex: "b_locale",
title: "语言",
width: 150,
editor: { type: "select", options: I18N_LOCALES },
},
{ dataIndex: "b_value", title: "翻译内容", width: 420, editor: { maxlength: 500 } },
];
const filteredRows = computed(() => {
const value = keyword.value.trim().toLowerCase();
if (!value) return rows.value;
return rows.value.filter((row) =>
`${row.b_key} ${row.b_locale} ${row.b_value}`.toLowerCase().includes(value),
);
});
async function load() {
loading.value = true;
try {
rows.value = (await loadDataApi("b_i18n", null, "b_key ASC")).data || [];
original.value = cloneData(rows.value);
selectedRowKeys.value = [];
activeRowKey.value = null;
} catch (error) {
message.error(error.message || "多语言加载失败");
} finally {
loading.value = false;
}
}
function add() {
const row = { b_id: createTempId(), b_key: "", b_locale: "en-US", b_value: "" };
rows.value.unshift(row);
activeRowKey.value = row.b_id;
}
function removeSelected() {
const selected = new Set(selectedRowKeys.value.map(String));
rows.value = rows.value.filter((row) => !selected.has(String(row.b_id)));
selectedRowKeys.value = [];
activeRowKey.value = null;
}
async function save() {
if (rows.value.some((row) => !row.b_key?.trim() || !row.b_locale || !row.b_value?.trim())) {
return message.warning("多语言键、语言和翻译内容不能为空");
}
const compound = rows.value.map((row) => `${row.b_key}\0${row.b_locale}`);
if (new Set(compound).size !== compound.length) return message.warning("多语言键和语言不能重复");
saving.value = true;
try {
await allocateTemporaryIds([rows.value]);
const changes = diffRows(rows.value, original.value);
if (!changes.inserts.length && !changes.updates.length && !changes.deletes.length) {
return message.info("没有需要保存的修改");
}
await saveObjectApi([tableChange("b_i18n", changes)]);
message.success("多语言已保存");
await load();
} catch (error) {
message.error(error.message || "保存失败");
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="i18n-page">
<header class="page-header">
<div class="page-title">
<h1><Languages />多语言管理</h1>
<span>{{ rows.length }} 条翻译</span>
</div>
<div class="header-actions">
<a-button :loading="loading" @click="load"><RefreshCw />刷新</a-button>
<a-button @click="add"><Plus />新增</a-button>
<a-button danger :disabled="!selectedRowKeys.length" @click="removeSelected">
<Trash2 />删除
</a-button>
<a-button type="primary" :loading="saving" @click="save"><Save />保存全部</a-button>
</div>
</header>
<a-input-search
v-model:value="keyword"
allow-clear
placeholder="搜索多语言键、语言或内容"
class="search"
/>
<div class="table-body">
<a-spin :spinning="loading">
<FmsTable
:columns="columns"
:data-source="filteredRows"
row-key="b_id"
:col-resizable="false"
:row-selection="rowSelection"
:active-row-key="activeRowKey"
@row-click="(_event, row) => (activeRowKey = row.b_id)"
/>
</a-spin>
</div>
</section>
</template>
<style scoped lang="scss">
.i18n-page {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid var(--fms-border);
background: var(--fms-surface);
}
.page-header,
.page-title,
.page-title h1,
.header-actions {
display: flex;
align-items: center;
}
.page-header {
flex: none;
justify-content: space-between;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--fms-border);
}
.page-title,
.page-title h1,
.header-actions {
gap: 8px;
}
.page-title h1 {
margin: 0;
font-size: 18px;
letter-spacing: 0;
}
.page-title span {
color: var(--fms-text-secondary);
font-size: 12px;
}
.search {
width: 360px;
flex: none;
}
.table-body {
flex: 1;
min-height: 0;
:deep(.ant-spin-nested-loading),
:deep(.ant-spin-container) {
height: 100%;
}
}
</style>
@@ -0,0 +1,256 @@
<script setup>
import { computed, ref, watch } from "vue";
import { message } from "antdv-next";
import FmsTree from "@/components/FmsTree.vue";
import { saveObjectApi } from "@/services/api";
import { cloneData, diffRows, allocateTemporaryIds, tableChange, hasChanges } from "@/utils/dataChanges";
import { createTempId } from "@/utils/tempId";
const props = defineProps({
visible: { type: Boolean, default: false },
moduleId: { type: String, required: true },
groups: { type: Array, default: () => [] },
});
const emit = defineEmits(["update:visible", "update"]);
const localGroups = ref([]);
const selectedGroupId = ref(null);
const editForm = ref(null);
watch(
() => props.groups,
(val) => {
localGroups.value = cloneData(val || []);
},
{ immediate: true },
);
const treeData = computed(() => {
return buildTreeData(localGroups.value);
});
function buildTreeData(items) {
const map = new Map();
const roots = [];
items.forEach((item) => {
item.key = String(item.b_id);
map.set(item.key, item);
});
items.forEach((item) => {
if (item.b_parent_id && map.has(String(item.b_parent_id))) {
const parent = map.get(String(item.b_parent_id));
if (!parent.children) parent.children = [];
parent.children.push(item);
} else {
roots.push(item);
}
});
return roots.map((r) => ({
key: r.key,
title: r.b_title || `分组-${r.b_id}`,
children: r.children?.map((c) => ({
key: c.key,
title: c.b_title || `分组-${c.b_id}`,
children: c.children?.map((gc) => ({
key: gc.key,
title: gc.b_title || `分组-${gc.b_id}`,
})),
})) || [],
}));
}
const selectedGroup = computed(() =>
localGroups.value.find((g) => String(g.b_id) === selectedGroupId.value),
);
function onTreeSelect(keys) {
if (keys.length > 0) {
selectedGroupId.value = String(keys[0]);
const g = selectedGroup.value;
editForm.value = g ? { ...g } : null;
} else {
selectedGroupId.value = null;
editForm.value = null;
}
}
function addGroup(parentId) {
const newGroup = {
b_id: createTempId(),
b_module_id: props.moduleId,
b_parent_id: parentId || null,
b_title: "",
b_i18n: null,
b_xh: localGroups.value.length * 10,
b_canuse: 1,
};
localGroups.value.push(newGroup);
selectedGroupId.value = String(newGroup.b_id);
editForm.value = { ...newGroup };
}
function deleteGroup() {
if (!selectedGroupId.value) return;
localGroups.value = localGroups.value.filter((g) => String(g.b_id) !== selectedGroupId.value);
selectedGroupId.value = null;
editForm.value = null;
}
async function saveAllGroups() {
const original = cloneData(props.groups || []);
const changes = diffRows(localGroups.value, original);
const req = tableChange("b_module_field_group", changes);
if (!hasChanges(req)) {
message.info("没有需要保存的变更");
return;
}
try {
await allocateTemporaryIds([req.inserts || [], req.updates || [], req.deletes || []]);
await saveObjectApi([req]);
emit("update", cloneData(localGroups.value));
message.success("分组已保存");
emit("update:visible", false);
} catch (e) {
message.error("保存失败:" + (e.message || e));
}
}
function onClose() {
emit("update:visible", false);
}
</script>
<template>
<a-modal
:open="visible"
title="分组管理"
width="650px"
@cancel="onClose"
@ok="saveAllGroups"
>
<div class="group-manager">
<div class="group-sidebar">
<div class="group-sidebar-header">
<span>分组树</span>
<a-button size="small" type="primary" @click="addGroup(null)">新增</a-button>
</div>
<FmsTree
v-if="treeData.length > 0"
class="group-tree"
:tree-data="treeData"
:field-names="{ title: 'title', key: 'key', children: 'children' }"
:selected-keys="selectedGroupId ? [selectedGroupId] : []"
@select="onTreeSelect"
>
<template #title="{ key, title: nodeTitle }">
<span class="group-tree-node">
<span>{{ nodeTitle }}</span>
<span class="group-tree-node-actions">
<a-button size="small" type="text" @click.stop="addGroup(key)">+</a-button>
</span>
</span>
</template>
</FmsTree>
<a-empty v-else description="暂无分组" />
</div>
<div class="group-detail" v-if="editForm">
<a-form layout="vertical">
<a-form-item label="分组标题" required>
<a-input
:value="editForm.b_title"
@update:value="(v) => editForm.b_title = v"
/>
</a-form-item>
<a-form-item label="多语言键">
<a-input
:value="editForm.b_i18n"
@update:value="(v) => editForm.b_i18n = v || null"
/>
</a-form-item>
<a-form-item label="排序号">
<a-input-number
:value="editForm.b_xh"
:min="0"
@update:value="(v) => editForm.b_xh = v"
/>
</a-form-item>
<a-form-item label="启用">
<a-switch
:checked="editForm.b_canuse === 1"
@update:checked="(v) => editForm.b_canuse = v ? 1 : 0"
/>
</a-form-item>
<a-form-item>
<a-button danger size="small" @click="deleteGroup">删除此分组</a-button>
</a-form-item>
</a-form>
</div>
<a-empty v-else description="选择左侧分组节点编辑" class="group-detail-empty" />
</div>
</a-modal>
</template>
<style scoped lang="scss">
.group-manager {
display: flex;
gap: 16px;
min-height: 350px;
}
.group-sidebar {
display: flex;
flex-direction: column;
flex: none;
width: 240px;
border: 1px solid var(--fms-border);
border-radius: 4px;
overflow: hidden;
}
.group-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
flex: none;
padding: 8px 12px;
border-bottom: 1px solid var(--fms-border);
font-size: 13px;
font-weight: 600;
}
.group-tree {
flex: 1;
min-height: 0;
overflow: auto;
padding: 4px 0;
}
.group-tree-node {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.group-tree-node-actions {
display: none;
flex: none;
}
.group-tree-node:hover .group-tree-node-actions {
display: flex;
}
.group-detail {
flex: 1;
min-width: 0;
}
.group-detail-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
</style>
@@ -1,115 +0,0 @@
<script setup>
import { computed, nextTick, ref } from "vue";
import { Plus, Trash2 } from "@lucide/vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
const props = defineProps({
title: { type: String, required: true },
columns: { type: Array, required: true },
addText: { type: String, default: "新增" },
canAdd: { type: Boolean, default: true },
canDelete: { type: Boolean, default: true },
height: { type: [String, Number], default: null },
});
const rows = defineModel("rows", { type: Array, default: () => [] });
const emit = defineEmits(["add", "remove"]);
const activeRowKey = ref(null);
const selectedRowKeys = ref([]);
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys;
},
}));
function editRow(_event, row) {
activeRowKey.value = String(row.b_id);
}
function removeSelected() {
const selected = new Set(selectedRowKeys.value.map(String));
const removed = rows.value.filter((row) => selected.has(String(row.b_id)));
rows.value = rows.value.filter((row) => !selected.has(String(row.b_id)));
if (selected.has(String(activeRowKey.value))) activeRowKey.value = null;
selectedRowKeys.value = [];
emit("remove", removed);
}
async function addRow() {
emit("add");
await nextTick();
const row = rows.value.at(-1);
if (row?.b_id != null) activeRowKey.value = String(row.b_id);
}
defineExpose({ editRow: (row) => (activeRowKey.value = String(row.b_id)) });
</script>
<template>
<section class="metadata-table-panel" :style="height ? null : { height: '100%' }">
<header class="panel-toolbar">
<strong>{{ title }}</strong>
<div class="panel-actions">
<slot name="actions" />
<a-button v-if="canAdd" type="primary" @click="addRow"> <Plus />{{ addText }} </a-button>
<a-button
v-if="canDelete"
danger
:disabled="!selectedRowKeys.length"
@click="removeSelected"
>
<Trash2 />删除
</a-button>
</div>
</header>
<div
class="table-body"
:style="
height
? { flex: 'none', height: typeof height === 'number' ? `${height}px` : height }
: null
"
>
<FmsTable
:columns="props.columns"
:col-resizable="false"
:data-source="rows"
row-key="b_id"
:row-selection="canDelete ? rowSelection : null"
:active-row-key="activeRowKey"
@row-click="editRow"
/>
</div>
</section>
</template>
<style scoped lang="scss">
.metadata-table-panel {
display: flex;
min-height: 0;
flex-direction: column;
gap: 8px;
}
.panel-toolbar,
.panel-actions {
display: flex;
align-items: center;
}
.panel-toolbar {
min-height: 40px;
flex: none;
justify-content: space-between;
gap: 12px;
}
.panel-actions {
gap: 8px;
}
.table-body {
flex: 1;
min-height: 180px;
}
</style>
@@ -1,65 +1,174 @@
<script setup>
defineProps({
parentOptions: { type: Array, default: () => [] },
routeOptions: { type: Array, default: () => [] },
import { computed, ref, watch } from "vue";
const props = defineProps({
moduleId: { type: String, required: true },
data: { type: Object, default: null },
loading: { type: Boolean, default: false },
});
const module = defineModel("module", { type: Object, required: true });
function setBoolean(field, checked) {
module.value[field] = checked ? 1 : 0;
const emit = defineEmits(["update"]);
const formData = ref(null);
watch(
() => props.data,
(val) => {
formData.value = val ? { ...val } : null;
},
{ immediate: true },
);
const isDirectory = computed(() => formData.value?.b_module_type === "directory");
const isPage = computed(() => formData.value?.b_module_type === "page");
const isData = computed(() => formData.value?.b_module_type === "data");
function emitUpdate() {
if (formData.value) emit("update", { ...formData.value });
}
function setField(field, value) {
if (!formData.value) return;
formData.value[field] = value === "" ? null : value;
emitUpdate();
}
</script>
<template>
<a-form layout="vertical" class="basic-form">
<a-form-item label="模块类型">
<a-segmented
v-model:value="module.b_module_type"
:options="[
{ label: '目录', value: 'directory' },
{ label: '页面', value: 'page' },
{ label: '数据', value: 'data' },
]"
/>
</a-form-item>
<a-form-item label="模块编码"
><a-input v-model:value="module.b_code" :maxlength="50"
/></a-form-item>
<a-form-item label="默认名称"
><a-input v-model:value="module.b_name" :maxlength="100"
/></a-form-item>
<a-form-item label="多语言键"
><a-input v-model:value="module.b_i18n" :maxlength="150"
/></a-form-item>
<a-form-item label="上级模块">
<a-select v-model:value="module.b_parent_id" allow-clear :options="parentOptions" />
</a-form-item>
<a-form-item v-if="module.b_module_type === 'page'" label="已注册页面">
<a-select v-model:value="module.b_route" :options="routeOptions" />
</a-form-item>
<template v-if="module.b_module_type === 'data'">
<a-form-item label="查询视图"><a-input v-model:value="module.b_viewtable" /></a-form-item>
<a-form-item label="保存表"><a-input v-model:value="module.b_savetable" /></a-form-item>
<a-form-item label="主键字段"><a-input v-model:value="module.b_keyfield" /></a-form-item>
<a-form-item label="默认排序"><a-input v-model:value="module.b_orderfield" /></a-form-item>
</template>
<a-form-item label="同级顺序"
><a-input-number v-model:value="module.b_xh" :min="0"
/></a-form-item>
<a-form-item label="启用">
<a-switch :checked="module.b_canuse === 1" @change="setBoolean('b_canuse', $event)" />
</a-form-item>
<a-form-item v-if="module.b_module_type !== 'data'" label="显示菜单">
<a-switch :checked="module.b_canmenu === 1" @change="setBoolean('b_canmenu', $event)" />
</a-form-item>
</a-form>
<div class="basic-panel" v-if="formData">
<div class="panel-body">
<a-form layout="vertical" class="basic-form">
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="模块编码" required>
<a-input
:value="formData.b_code"
placeholder="如 m_sea"
@update:value="(v) => setField('b_code', v)"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="模块名称" required>
<a-input
:value="formData.b_name"
placeholder="模块显示名称"
@update:value="(v) => setField('b_name', v)"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="模块类型" required>
<a-select
:value="formData.b_module_type"
:options="[
{ label: '目录', value: 'directory' },
{ label: '页面', value: 'page' },
{ label: '数据', value: 'data' },
]"
@update:value="(v) => { formData.b_module_type = v; emitUpdate(); }"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="上级模块">
<a-input :value="formData.b_parent_id" disabled placeholder="通过左侧树选中后自动设置" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="多语言键">
<a-input
:value="formData.b_i18n"
placeholder="如 module.m_sea"
@update:value="(v) => setField('b_i18n', v)"
/>
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item label="排序号">
<a-input-number
:value="formData.b_xh"
:min="0"
@update:value="(v) => setField('b_xh', v)"
/>
</a-form-item>
</a-col>
</a-row>
<!-- 页面/数据 专用字段 -->
<template v-if="!isDirectory">
<a-row :gutter="16">
<a-col :span="8" v-if="isPage">
<a-form-item label="页面路由" required>
<a-input
:value="formData.b_route"
placeholder="如 /sea/list"
@update:value="(v) => setField('b_route', v)"
/>
</a-form-item>
</a-col>
<a-col :span="8" v-if="isData">
<a-form-item label="查询视图" required>
<a-input
:value="formData.b_viewtable"
placeholder="如 v_sea"
@update:value="(v) => setField('b_viewtable', v)"
/>
</a-form-item>
</a-col>
<a-col :span="8" v-if="isData">
<a-form-item label="保存表">
<a-input
:value="formData.b_savetable"
placeholder="如 d_sea"
@update:value="(v) => setField('b_savetable', v)"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16" v-if="isData">
<a-col :span="8">
<a-form-item label="主键字段">
<a-input
:value="formData.b_keyfield"
placeholder="如 b_id"
@update:value="(v) => setField('b_keyfield', v)"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="默认排序">
<a-input
:value="formData.b_orderfield"
placeholder="如 b_xh ASC"
@update:value="(v) => setField('b_orderfield', v)"
/>
</a-form-item>
</a-col>
</a-row>
</template>
</a-form>
</div>
</div>
</template>
<style scoped lang="scss">
.basic-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-body {
flex: 1;
overflow: auto;
padding: 16px 20px;
}
.basic-form {
display: grid;
max-width: 1080px;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 0 16px;
padding-top: 14px;
max-width: 960px;
}
</style>
@@ -0,0 +1,163 @@
<script setup>
import { computed, ref, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createTempId } from "@/utils/tempId";
import FieldGroupManager from "./FieldGroupManager.vue";
import { createDeleteCell, createSelectCell, createToggleCell } from "./cellRenderers";
const props = defineProps({
moduleId: { type: String, required: true },
editConfig: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(["update", "updateGroups"]);
const localEdit = ref([]);
const groupManagerVisible = ref(false);
watch(
() => props.editConfig,
(val) => {
localEdit.value = val?.map((r) => ({ ...r })) || [];
},
{ immediate: true },
);
function emitUpdate() {
emit("update", localEdit.value.map((r) => ({ ...r })));
}
function setField(row, field, value) {
row[field] = value === undefined || value === null ? null : value;
emitUpdate();
}
const availableFields = computed(() =>
(props.fields || [])
.filter((f) => f.b_canuse === 1)
.map((f) => ({ label: f.b_name || f.b_field, value: String(f.b_id) })),
);
const groupOptions = computed(() =>
(props.groups || [])
.filter((g) => g.b_canuse === 1)
.map((g) => ({ label: g.b_title || `分组-${g.b_id}`, value: String(g.b_id) })),
);
function addRow() {
localEdit.value.push({
b_id: createTempId(),
b_module_id: props.moduleId,
b_field_id: null,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_group_id: null,
b_xh: localEdit.value.length * 10,
});
emitUpdate();
}
function deleteRow(record) {
localEdit.value = localEdit.value.filter((r) => r.b_id !== record.b_id);
emitUpdate();
}
function makeSwitchCol(field, title, width) {
return {
dataIndex: field,
title,
width: width || 60,
align: "center",
customCell: createToggleCell(field, (row) => setField(row, field, row[field] === 1 ? 0 : 1)),
};
}
const columns = [
{
dataIndex: "b_field_id",
title: "字段",
width: 160,
customCell: createSelectCell({
value: "b_field_id",
getOptions: () => availableFields.value,
onChange: (row, v) => setField(row, "b_field_id", v),
placeholder: "选择字段",
}),
},
makeSwitchCol("b_required", "必填"),
makeSwitchCol("b_readonly", "只读"),
makeSwitchCol("b_disabled", "禁用"),
{
dataIndex: "b_group_id",
title: "编辑分组",
width: 160,
customCell: createSelectCell({
value: "b_group_id",
getOptions: () => groupOptions.value,
onChange: (row, v) => setField(row, "b_group_id", v),
placeholder: "选择分组",
}),
},
{ dataIndex: "b_xh", title: "排序号", width: 80 },
{
dataIndex: "actions",
title: "操作",
width: 60,
align: "center",
customCell: createDeleteCell((row) => deleteRow(row)),
},
];
</script>
<template>
<div class="edit-config-panel">
<div class="panel-toolbar">
<a-space>
<a-button type="primary" @click="addRow">新增行</a-button>
<a-button @click="groupManagerVisible = true">分组管理</a-button>
</a-space>
</div>
<div class="panel-body">
<FmsTable :columns="columns" :data-source="localEdit" row-key="b_id" />
</div>
<FieldGroupManager
v-model:visible="groupManagerVisible"
:module-id="moduleId"
:groups="groups"
@update="(v) => emit('updateGroups', v)"
/>
</div>
</template>
<style scoped lang="scss">
.edit-config-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-toolbar {
display: flex;
flex: none;
gap: 8px;
padding: 12px 20px;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 16px 20px;
display: flex;
flex-direction: column;
}
.panel-body > :deep(.fms-table) {
flex: 1;
min-height: 0;
width: 100%;
}
</style>
@@ -1,131 +0,0 @@
<script setup>
import { computed } from "vue";
import { createTempId } from "@/utils/tempId";
import MetadataTablePanel from "./MetadataTablePanel.vue";
const props = defineProps({ fields: { type: Array, default: () => [] } });
const groups = defineModel("groups", { type: Array, default: () => [] });
const edits = defineModel("edits", { type: Array, default: () => [] });
const fieldOptions = computed(() =>
props.fields.map((item) => ({ value: item.b_id, label: `${item.b_name} (${item.b_field})` })),
);
const groupOptions = computed(() =>
groups.value.map((item) => ({ value: item.b_code, label: item.b_title })),
);
const groupColumns = [
{ dataIndex: "b_code", title: "分组编码", width: 150, editor: { maxlength: 100 } },
{ dataIndex: "b_title", title: "默认标题", width: 180, editor: { maxlength: 100 } },
{ dataIndex: "b_i18n", title: "标题多语言键", width: 250, editor: { maxlength: 150 } },
{ dataIndex: "b_xh", title: "顺序", width: 80, editor: { type: "number", min: 0 } },
{ dataIndex: "b_canuse", title: "启用", width: 70, align: "center", editor: "checkbox" },
];
const editColumns = computed(() => [
{
dataIndex: "b_field_id",
title: "字段",
width: 190,
editor: { type: "select", options: fieldOptions.value },
},
{
dataIndex: "b_editor",
title: "编辑器",
width: 110,
editor: {
type: "select",
options: ["input", "textarea", "select", "date", "switch"].map((value) => ({
value,
label: value,
})),
},
},
{
dataIndex: "b_edit_group",
title: "表单分组",
width: 150,
editor: { type: "select", options: groupOptions.value },
},
{ dataIndex: "b_placeholder", title: "输入提示", width: 180, editor: { maxlength: 200 } },
{ dataIndex: "b_required", title: "必填", width: 65, align: "center", editor: "checkbox" },
{ dataIndex: "b_readonly", title: "只读", width: 65, align: "center", editor: "checkbox" },
{ dataIndex: "b_disabled", title: "禁用", width: 65, align: "center", editor: "checkbox" },
{ dataIndex: "b_canform", title: "Form", width: 65, align: "center", editor: "checkbox" },
{ dataIndex: "b_edit_xh", title: "顺序", width: 75, editor: { type: "number", min: 0 } },
{
dataIndex: "b_edit_span",
title: "栅格",
width: 75,
editor: { type: "number", min: 1, max: 24 },
},
{ dataIndex: "b_editor_options", title: "编辑器 JSON", width: 260, editor: {} },
]);
function addGroup() {
groups.value.push({
b_id: createTempId(),
b_code: `group_${groups.value.length + 1}`,
b_title: "新分组",
b_i18n: null,
b_xh: Math.max(0, ...groups.value.map((item) => Number(item.b_xh) || 0)) + 10,
b_canuse: 1,
});
}
function addEdit() {
const used = new Set(edits.value.map((item) => String(item.b_field_id)));
const field = props.fields.find((item) => !used.has(String(item.b_id)));
if (!field) return;
edits.value.push({
b_id: createTempId(),
b_field_id: field.b_id,
b_editor: "input",
b_edit_format: null,
b_editor_options: null,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_placeholder: null,
b_canform: 1,
b_edit_group: null,
b_edit_xh: Math.max(0, ...edits.value.map((item) => Number(item.b_edit_xh) || 0)) + 10,
b_edit_span: 24,
});
}
function handleGroupRemove(removed) {
const removedCodes = new Set(removed.map((item) => item.b_code));
edits.value.forEach((edit) => {
if (removedCodes.has(edit.b_edit_group)) edit.b_edit_group = null;
});
}
</script>
<template>
<div class="edit-panels">
<MetadataTablePanel
v-model:rows="groups"
title="表单分组"
add-text="新增分组"
:columns="groupColumns"
height="220px"
@add="addGroup"
@remove="handleGroupRemove"
/>
<MetadataTablePanel
v-model:rows="edits"
title="编辑字段"
add-text="新增字段"
:columns="editColumns"
@add="addEdit"
/>
</div>
</template>
<style scoped lang="scss">
.edit-panels {
display: grid;
height: 100%;
min-height: 0;
grid-template-rows: auto minmax(260px, 1fr);
gap: 12px;
}
</style>
@@ -1,52 +1,116 @@
<script setup>
import { computed } from "vue";
import { RefreshCw } from "@lucide/vue";
import MetadataTablePanel from "./MetadataTablePanel.vue";
import { ref, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createSelectCell, createToggleCell } from "./cellRenderers";
defineProps({ syncing: Boolean });
defineEmits(["sync"]);
const fields = defineModel("fields", { type: Array, default: () => [] });
const columns = computed(() => [
{ dataIndex: "b_field", title: "字段", width: 150 },
{ dataIndex: "b_name", title: "默认名称", width: 160, editor: { maxlength: 100 } },
{ dataIndex: "b_i18n", title: "多语言键", width: 240, editor: { maxlength: 150 } },
{
dataIndex: "b_field_type",
title: "字段类型",
width: 130,
editor: {
type: "select",
options: [
"string",
"text",
"number",
"date",
"datetime",
"boolean",
"select_id",
"select_input",
].map((value) => ({ value, label: value })),
},
const FIELD_TYPES = [
{ label: "输入框", value: "input" },
{ label: "文本域", value: "textarea" },
{ label: "数字", value: "number" },
{ label: "金额", value: "money" },
{ label: "日期", value: "date" },
{ label: "日期时间", value: "datetime" },
{ label: "开关", value: "switch" },
{ label: "下拉选择", value: "select" },
];
const props = defineProps({
moduleId: { type: String, required: true },
fields: { type: Array, default: () => [] },
viewTable: { type: String, default: null },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(["update", "sync"]);
const localFields = ref([]);
watch(
() => props.fields,
(val) => {
localFields.value = val?.map((r) => ({ ...r })) || [];
},
{ dataIndex: "b_dict_code", title: "字典编码", width: 150, editor: { maxlength: 100 } },
{ dataIndex: "b_value", title: "默认值", width: 160, editor: { maxlength: 500 } },
{ dataIndex: "b_remark", title: "说明", width: 220, editor: { maxlength: 500 } },
{ dataIndex: "b_canuse", title: "启用", width: 70, align: "center", editor: "checkbox" },
]);
{ immediate: true },
);
function emitUpdate() {
emit("update", localFields.value.map((r) => ({ ...r })));
}
function setField(row, field, value) {
row[field] = value === "" ? null : value;
emitUpdate();
}
const columns = [
{ dataIndex: "b_field", title: "字段名", width: 140 },
{ dataIndex: "b_name", title: "字段名称", width: 140 },
{
dataIndex: "b_type",
title: "控件类型",
width: 130,
customCell: createSelectCell({
value: "b_type",
getOptions: () => FIELD_TYPES,
onChange: (row, v) => setField(row, "b_type", v),
}),
},
{ dataIndex: "b_default_value", title: "默认值", width: 120 },
{ dataIndex: "b_i18n", title: "多语言键", width: 140 },
{
dataIndex: "b_canuse",
title: "启用",
width: 70,
align: "center",
customCell: createToggleCell("b_canuse", (row) => setField(row, "b_canuse", row.b_canuse === 1 ? 0 : 1)),
},
];
</script>
<template>
<MetadataTablePanel
v-model:rows="fields"
title="字段定义"
:columns="columns"
:can-add="false"
:can-delete="false"
>
<template #actions>
<a-button type="primary" :loading="syncing" @click="$emit('sync')">
<RefreshCw />从视图同步
</a-button>
</template>
</MetadataTablePanel>
<div class="fields-panel">
<div class="panel-toolbar">
<a-space>
<a-button
v-if="viewTable"
type="primary"
@click="emit('sync')"
>
同步字段 ({{ viewTable }})
</a-button>
</a-space>
</div>
<div class="panel-body">
<FmsTable :columns="columns" :data-source="localFields" row-key="b_id" />
</div>
</div>
</template>
<style scoped lang="scss">
.fields-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-toolbar {
display: flex;
flex: none;
gap: 8px;
padding: 12px 20px;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 16px 20px;
display: flex;
flex-direction: column;
}
.panel-body > :deep(.fms-table) {
flex: 1;
min-height: 0;
width: 100%;
}
</style>
@@ -0,0 +1,140 @@
<script setup>
import { computed, ref, watch } from "vue";
import { message } from "antdv-next";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { loadDataApi, saveObjectApi } from "@/services/api";
import { cloneData, diffRows, allocateTemporaryIds, tableChange, hasChanges } from "@/utils/dataChanges";
import { createTempId } from "@/utils/tempId";
import { createDeleteCell } from "./cellRenderers";
const props = defineProps({
moduleId: { type: String, required: true },
loading: { type: Boolean, default: false },
});
const i18nData = ref([]);
const originals = ref([]);
const isLoading = ref(false);
const isSaving = ref(false);
const searchKey = ref("");
async function loadI18n() {
isLoading.value = true;
try {
const res = await loadDataApi("b_i18n", null, "b_key ASC, b_locale ASC");
originals.value = res.data || [];
i18nData.value = cloneData(originals.value);
} catch (e) {
message.error("加载多语言失败:" + (e.message || e));
} finally {
isLoading.value = false;
}
}
watch(() => props.moduleId, loadI18n, { immediate: true });
function addRow() {
i18nData.value.push({
b_id: createTempId(),
b_key: "",
b_locale: "zh-CN",
b_value: "",
});
}
function deleteRow(record) {
i18nData.value = i18nData.value.filter((r) => r.b_id !== record.b_id);
}
async function save() {
const changes = diffRows(i18nData.value, originals.value);
const req = tableChange("b_i18n", changes);
if (!hasChanges(req)) {
message.info("没有需要保存的变更");
return;
}
isSaving.value = true;
try {
await allocateTemporaryIds([req.inserts || [], req.updates || [], req.deletes || []]);
await saveObjectApi([req]);
originals.value = cloneData(i18nData.value);
message.success("保存成功");
} catch (e) {
message.error("保存失败:" + (e.message || e));
} finally {
isSaving.value = false;
}
}
const displayedData = computed(() => {
if (!searchKey.value) return i18nData.value;
const kw = searchKey.value.toLowerCase();
return i18nData.value.filter(
(r) =>
(r.b_key || "").toLowerCase().includes(kw) ||
(r.b_value || "").toLowerCase().includes(kw),
);
});
const columns = [
{ dataIndex: "b_key", title: "键", width: 200 },
{ dataIndex: "b_locale", title: "语言", width: 100 },
{ dataIndex: "b_value", title: "翻译文本", width: 300 },
{
dataIndex: "actions",
title: "操作",
width: 60,
align: "center",
customCell: createDeleteCell((row) => deleteRow(row)),
},
];
</script>
<template>
<div class="i18n-panel">
<div class="panel-toolbar">
<a-space>
<a-input-search
v-model:value="searchKey"
placeholder="搜索键或值"
style="width: 200px"
allow-clear
/>
<a-button type="primary" @click="addRow">新增翻译</a-button>
<a-button :loading="isSaving" @click="save">保存</a-button>
</a-space>
</div>
<div class="panel-body">
<FmsTable :columns="columns" :data-source="displayedData" row-key="b_id" />
</div>
</div>
</template>
<style scoped lang="scss">
.i18n-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-toolbar {
display: flex;
flex: none;
gap: 8px;
padding: 12px 20px;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 16px 20px;
display: flex;
flex-direction: column;
}
.panel-body > :deep(.fms-table) {
flex: 1;
min-height: 0;
width: 100%;
}
</style>
@@ -0,0 +1,168 @@
<script setup>
import { computed, ref, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createTempId } from "@/utils/tempId";
import FieldGroupManager from "./FieldGroupManager.vue";
import { createDeleteCell, createSelectCell, createToggleCell } from "./cellRenderers";
const props = defineProps({
moduleId: { type: String, required: true },
listConfig: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(["update", "updateGroups"]);
const localList = ref([]);
const groupManagerVisible = ref(false);
watch(
() => props.listConfig,
(val) => {
localList.value = val?.map((r) => ({ ...r })) || [];
},
{ immediate: true },
);
function emitUpdate() {
emit("update", localList.value.map((r) => ({ ...r })));
}
function setField(row, field, value) {
row[field] = value === undefined || value === null ? null : value;
emitUpdate();
}
const availableFields = computed(() =>
(props.fields || [])
.filter((f) => f.b_canuse === 1)
.map((f) => ({ label: f.b_name || f.b_field, value: String(f.b_id) })),
);
const groupOptions = computed(() =>
(props.groups || [])
.filter((g) => g.b_canuse === 1)
.map((g) => ({ label: g.b_title || `分组-${g.b_id}`, value: String(g.b_id) })),
);
function addRow() {
localList.value.push({
b_id: createTempId(),
b_module_id: props.moduleId,
b_field_id: null,
b_group_id: null,
b_title: null,
b_i18n: null,
b_visible: 1,
b_xh: localList.value.length * 10,
b_width: null,
b_sortable: 0,
});
emitUpdate();
}
function deleteRow(record) {
localList.value = localList.value.filter((r) => r.b_id !== record.b_id);
emitUpdate();
}
const columns = [
{
dataIndex: "b_field_id",
title: "字段",
width: 160,
customCell: createSelectCell({
value: "b_field_id",
getOptions: () => availableFields.value,
onChange: (row, v) => setField(row, "b_field_id", v),
placeholder: "选择字段",
}),
},
{ dataIndex: "b_title", title: "列标题", width: 120 },
{ dataIndex: "b_width", title: "列宽", width: 80 },
{
dataIndex: "b_group_id",
title: "分组",
width: 140,
customCell: createSelectCell({
value: "b_group_id",
getOptions: () => groupOptions.value,
onChange: (row, v) => setField(row, "b_group_id", v),
placeholder: "选择分组",
}),
},
{
dataIndex: "b_visible",
title: "显示",
width: 60,
align: "center",
customCell: createToggleCell("b_visible", (row) => setField(row, "b_visible", row.b_visible === 1 ? 0 : 1)),
},
{
dataIndex: "b_sortable",
title: "排序",
width: 60,
align: "center",
customCell: createToggleCell("b_sortable", (row) => setField(row, "b_sortable", row.b_sortable === 1 ? 0 : 1)),
},
{ dataIndex: "b_xh", title: "排序号", width: 80 },
{
dataIndex: "actions",
title: "操作",
width: 60,
align: "center",
customCell: createDeleteCell((row) => deleteRow(row)),
},
];
</script>
<template>
<div class="list-config-panel">
<div class="panel-toolbar">
<a-space>
<a-button type="primary" @click="addRow">新增列</a-button>
<a-button @click="groupManagerVisible = true">分组管理</a-button>
</a-space>
</div>
<div class="panel-body">
<FmsTable :columns="columns" :data-source="localList" row-key="b_id" />
</div>
<FieldGroupManager
v-model:visible="groupManagerVisible"
:module-id="moduleId"
:groups="groups"
@update="(v) => emit('updateGroups', v)"
/>
</div>
</template>
<style scoped lang="scss">
.list-config-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-toolbar {
display: flex;
flex: none;
gap: 8px;
padding: 12px 20px;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 16px 20px;
display: flex;
flex-direction: column;
}
.panel-body > :deep(.fms-table) {
flex: 1;
min-height: 0;
width: 100%;
}
</style>
@@ -1,142 +0,0 @@
<script setup>
import { computed } from "vue";
import { createTempId } from "@/utils/tempId";
import MetadataTablePanel from "./MetadataTablePanel.vue";
const props = defineProps({ fields: { type: Array, default: () => [] } });
const nodes = defineModel("nodes", { type: Array, default: () => [] });
const fieldOptions = computed(() =>
props.fields.map((item) => ({ value: item.b_id, label: `${item.b_name} (${item.b_field})` })),
);
const groupOptions = computed(() => [
{ value: null, label: "顶层" },
...nodes.value
.filter((item) => item.b_kind === "group")
.map((item) => ({ value: item.b_id, label: item.b_title || "未命名分组" })),
]);
const columns = computed(() => [
{
dataIndex: "b_kind",
title: "节点类型",
width: 100,
editor: {
type: "select",
options: [
{ value: "group", label: "分组" },
{ value: "field", label: "字段" },
],
onChange: (value, row) => {
if (value === "group") {
row.b_field_id = null;
row.b_width = null;
row.b_display_type = null;
row.b_canedit = 0;
} else {
row.b_display_type ||= "text";
row.b_width ||= 120;
}
},
},
},
{
dataIndex: "b_field_id",
title: "关联字段",
width: 190,
editor: {
type: "select",
options: fieldOptions.value,
disabled: (row) => row.b_kind === "group",
},
},
{
dataIndex: "b_parent_id",
title: "上级分组",
width: 150,
editor: {
type: "select",
options: (row) =>
groupOptions.value.filter((item) => String(item.value) !== String(row.b_id)),
},
},
{ dataIndex: "b_title", title: "分组/列标题", width: 170, editor: { maxlength: 100 } },
{ dataIndex: "b_i18n", title: "标题多语言键", width: 230, editor: { maxlength: 150 } },
{ dataIndex: "b_visible", title: "显示", width: 65, align: "center", editor: "checkbox" },
{ dataIndex: "b_xh", title: "顺序", width: 75, editor: { type: "number", min: 0 } },
{
dataIndex: "b_width",
title: "列宽",
width: 80,
editor: { type: "number", min: 40, disabled: (row) => row.b_kind === "group" },
},
{
dataIndex: "b_display_type",
title: "显示类型",
width: 120,
editor: {
type: "select",
options: ["text", "money", "date", "datetime", "tag"].map((value) => ({
value,
label: value,
})),
disabled: (row) => row.b_kind === "group",
},
},
{
dataIndex: "b_sortable",
title: "排序",
width: 65,
align: "center",
editor: { type: "checkbox", disabled: (row) => row.b_kind === "group" },
},
{
dataIndex: "b_canedit",
title: "表内编辑",
width: 80,
align: "center",
editor: { type: "checkbox", disabled: (row) => row.b_kind === "group" },
},
]);
function addNode() {
nodes.value.push({
b_id: createTempId(),
b_parent_id: null,
b_kind: "field",
b_field_id: null,
b_title: null,
b_i18n: null,
b_visible: 1,
b_xh: Math.max(0, ...nodes.value.map((item) => Number(item.b_xh) || 0)) + 10,
b_width: 120,
b_sortable: 0,
b_display_type: "text",
b_canedit: 0,
});
}
function handleRemove(removed) {
const removedIds = new Set(removed.map((item) => String(item.b_id)));
let changed = true;
while (changed) {
changed = false;
nodes.value.forEach((node) => {
if (removedIds.has(String(node.b_parent_id)) && !removedIds.has(String(node.b_id))) {
removedIds.add(String(node.b_id));
changed = true;
}
});
}
nodes.value = nodes.value.filter((node) => !removedIds.has(String(node.b_id)));
}
</script>
<template>
<MetadataTablePanel
v-model:rows="nodes"
title="列表列与多级表头"
add-text="新增节点"
:columns="columns"
@add="addNode"
@remove="handleRemove"
/>
</template>
@@ -1,34 +1,112 @@
<script setup>
import { ref, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createTempId } from "@/utils/tempId";
import MetadataTablePanel from "./MetadataTablePanel.vue";
import { createDeleteCell, createToggleCell } from "./cellRenderers";
const powers = defineModel("powers", { type: Array, default: () => [] });
const columns = [
{ dataIndex: "b_code", title: "操作编码", width: 150, editor: { maxlength: 50 } },
{ dataIndex: "b_name", title: "默认名称", width: 180, editor: { maxlength: 100 } },
{ dataIndex: "b_i18n", title: "多语言键", width: 260, editor: { maxlength: 150 } },
{ dataIndex: "b_xh", title: "顺序", width: 80, editor: { type: "number", min: 0 } },
{ dataIndex: "b_canuse", title: "启用", width: 70, align: "center", editor: "checkbox" },
];
const props = defineProps({
moduleId: { type: String, required: true },
powers: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
});
function addPower() {
powers.value.push({
const emit = defineEmits(["update"]);
const localPowers = ref([]);
watch(
() => props.powers,
(val) => {
localPowers.value = val?.map((r) => ({ ...r })) || [];
},
{ immediate: true },
);
function emitUpdate() {
emit("update", localPowers.value.map((r) => ({ ...r })));
}
function addRow() {
localPowers.value.push({
b_id: createTempId(),
b_module_id: props.moduleId,
b_code: "",
b_name: "新操作",
b_name: "",
b_i18n: null,
b_xh: Math.max(0, ...powers.value.map((item) => Number(item.b_xh) || 0)) + 10,
b_xh: localPowers.value.length * 10,
b_canuse: 1,
});
emitUpdate();
}
function deleteRow(record) {
localPowers.value = localPowers.value.filter((r) => r.b_id !== record.b_id);
emitUpdate();
}
const columns = [
{ dataIndex: "b_code", title: "操作编码", width: 160 },
{ dataIndex: "b_name", title: "操作名称", width: 160 },
{ dataIndex: "b_i18n", title: "多语言键", width: 160 },
{ dataIndex: "b_xh", title: "排序", width: 80 },
{
dataIndex: "b_canuse",
title: "启用",
width: 70,
align: "center",
customCell: createToggleCell("b_canuse", (row) => {
row.b_canuse = row.b_canuse === 1 ? 0 : 1;
emitUpdate();
}),
},
{
dataIndex: "actions",
title: "操作",
width: 60,
align: "center",
customCell: createDeleteCell((row) => deleteRow(row)),
},
];
</script>
<template>
<MetadataTablePanel
v-model:rows="powers"
title="模块操作权限"
add-text="新增操作"
:columns="columns"
@add="addPower"
/>
<div class="powers-panel">
<div class="panel-toolbar">
<a-space>
<a-button type="primary" @click="addRow">新增权限</a-button>
</a-space>
</div>
<div class="panel-body">
<FmsTable :columns="columns" :data-source="localPowers" row-key="b_id" />
</div>
</div>
</template>
<style scoped lang="scss">
.powers-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-toolbar {
display: flex;
flex: none;
gap: 8px;
padding: 12px 20px;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 16px 20px;
display: flex;
flex-direction: column;
}
.panel-body > :deep(.fms-table) {
flex: 1;
min-height: 0;
width: 100%;
}
</style>
@@ -0,0 +1,165 @@
<script setup>
import { computed, ref, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createTempId } from "@/utils/tempId";
import { createDeleteCell, createSelectCell, createToggleCell } from "./cellRenderers";
const COMPONENT_OPTIONS = [
{ label: "输入框", value: "input" },
{ label: "下拉选择", value: "select" },
{ label: "日期选择", value: "date-picker" },
{ label: "数字范围", value: "number-range" },
];
const OPERATOR_OPTIONS = [
{ label: "等于 (=)", value: "=" },
{ label: "模糊 (like)", value: "like" },
{ label: "范围 (between)", value: "between" },
{ label: "包含 (in)", value: "in" },
];
const props = defineProps({
moduleId: { type: String, required: true },
queryConfig: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(["update"]);
const localQuery = ref([]);
watch(
() => props.queryConfig,
(val) => {
localQuery.value = val?.map((r) => ({ ...r })) || [];
},
{ immediate: true },
);
function emitUpdate() {
emit("update", localQuery.value.map((r) => ({ ...r })));
}
function setField(row, field, value) {
row[field] = value === undefined || value === null ? null : value;
emitUpdate();
}
const availableFields = computed(() =>
(props.fields || [])
.filter((f) => f.b_canuse === 1)
.map((f) => ({ label: f.b_name || f.b_field, value: String(f.b_id) })),
);
function addRow() {
localQuery.value.push({
b_id: createTempId(),
b_module_id: props.moduleId,
b_field_id: null,
b_component: "input",
b_operator: "=",
b_default_value: null,
b_xh: localQuery.value.length * 10,
b_canuse: 1,
});
emitUpdate();
}
function deleteRow(record) {
localQuery.value = localQuery.value.filter((r) => r.b_id !== record.b_id);
emitUpdate();
}
const columns = [
{
dataIndex: "b_field_id",
title: "字段",
width: 160,
customCell: createSelectCell({
value: "b_field_id",
getOptions: () => availableFields.value,
onChange: (row, v) => setField(row, "b_field_id", v),
placeholder: "选择字段",
}),
},
{
dataIndex: "b_component",
title: "查询组件",
width: 140,
customCell: createSelectCell({
value: "b_component",
getOptions: () => COMPONENT_OPTIONS,
onChange: (row, v) => setField(row, "b_component", v),
}),
},
{
dataIndex: "b_operator",
title: "操作符",
width: 140,
customCell: createSelectCell({
value: "b_operator",
getOptions: () => OPERATOR_OPTIONS,
onChange: (row, v) => setField(row, "b_operator", v),
}),
},
{ dataIndex: "b_default_value", title: "默认查询值", width: 140 },
{
dataIndex: "b_canuse",
title: "启用",
width: 60,
align: "center",
customCell: createToggleCell("b_canuse", (row) => setField(row, "b_canuse", row.b_canuse === 1 ? 0 : 1)),
},
{ dataIndex: "b_xh", title: "排序号", width: 80 },
{
dataIndex: "actions",
title: "操作",
width: 60,
align: "center",
customCell: createDeleteCell((row) => deleteRow(row)),
},
];
</script>
<template>
<div class="query-config-panel">
<div class="panel-toolbar">
<a-space>
<a-button type="primary" @click="addRow">新增查询条件</a-button>
</a-space>
</div>
<div class="panel-body">
<FmsTable :columns="columns" :data-source="localQuery" row-key="b_id" />
</div>
</div>
</template>
<style scoped lang="scss">
.query-config-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-toolbar {
display: flex;
flex: none;
gap: 8px;
padding: 12px 20px;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 16px 20px;
display: flex;
flex-direction: column;
}
.panel-body > :deep(.fms-table) {
flex: 1;
min-height: 0;
width: 100%;
}
</style>
@@ -1,65 +0,0 @@
<script setup>
import { computed } from "vue";
import { createTempId } from "@/utils/tempId";
import MetadataTablePanel from "./MetadataTablePanel.vue";
const props = defineProps({ fields: { type: Array, default: () => [] } });
const queries = defineModel("queries", { type: Array, default: () => [] });
const fieldOptions = computed(() =>
props.fields.map((item) => ({ value: item.b_id, label: `${item.b_name} (${item.b_field})` })),
);
const columns = computed(() => [
{
dataIndex: "b_field_id",
title: "字段",
width: 210,
editor: { type: "select", options: fieldOptions.value },
},
{
dataIndex: "b_component",
title: "查询组件",
width: 130,
editor: {
type: "select",
options: ["input", "select", "date", "date-range"].map((value) => ({ value, label: value })),
},
},
{
dataIndex: "b_operator",
title: "操作符",
width: 100,
editor: {
type: "select",
options: ["=", "like", "between", "in"].map((value) => ({ value, label: value })),
},
},
{ dataIndex: "b_default_value", title: "默认值", width: 220, editor: { maxlength: 500 } },
{ dataIndex: "b_xh", title: "顺序", width: 80, editor: { type: "number", min: 0 } },
{ dataIndex: "b_canuse", title: "启用", width: 70, align: "center", editor: "checkbox" },
]);
function addQuery() {
const used = new Set(queries.value.map((item) => String(item.b_field_id)));
const field = props.fields.find((item) => !used.has(String(item.b_id)));
if (!field) return;
queries.value.push({
b_id: createTempId(),
b_field_id: field.b_id,
b_component: "input",
b_operator: "like",
b_default_value: null,
b_xh: Math.max(0, ...queries.value.map((item) => Number(item.b_xh) || 0)) + 10,
b_canuse: 1,
});
}
</script>
<template>
<MetadataTablePanel
v-model:rows="queries"
title="列表查询条件"
add-text="新增条件"
:columns="columns"
@add="addQuery"
/>
</template>
@@ -1,44 +0,0 @@
<script setup>
import { computed } from "vue";
import { createTempId } from "@/utils/tempId";
import { I18N_LOCALES } from "@/utils/i18n";
import MetadataTablePanel from "./MetadataTablePanel.vue";
const props = defineProps({ keys: { type: Array, default: () => [] } });
const translations = defineModel("translations", { type: Array, default: () => [] });
const columns = computed(() => [
{
dataIndex: "b_key",
title: "多语言键",
width: 300,
editor: { type: "select", options: props.keys.map((value) => ({ value, label: value })) },
},
{
dataIndex: "b_locale",
title: "语言",
width: 150,
editor: { type: "select", options: I18N_LOCALES },
},
{ dataIndex: "b_value", title: "翻译内容", width: 380, editor: { maxlength: 500 } },
]);
function addTranslation() {
translations.value.push({
b_id: createTempId(),
b_key: props.keys[0] || "",
b_locale: "en-US",
b_value: "",
});
}
</script>
<template>
<MetadataTablePanel
v-model:rows="translations"
title="模块关联翻译"
add-text="新增翻译"
:columns="columns"
:can-delete="false"
@add="addTranslation"
/>
</template>
@@ -1,164 +0,0 @@
<script setup>
import { computed, ref } from "vue";
import { RefreshCw, Search } from "@lucide/vue";
import FmsTree from "@/components/FmsTree.vue";
const props = defineProps({
modules: { type: Array, required: true },
protectedKeys: { type: Array, default: () => [] },
selectedKey: { type: String, default: "" },
loading: Boolean,
canCreate: Boolean,
canDelete: Boolean,
});
const emit = defineEmits(["select", "add", "delete", "refresh"]);
const searchText = ref("");
const contextNode = ref(null);
const treeData = computed(() => {
const keyword = searchText.value.trim().toLowerCase();
const codeById = new Map(props.modules.map((item) => [item.b_id, item.b_code]));
const visible = keyword
? new Set(
props.modules
.filter(
(item) =>
String(item.b_code).toLowerCase().includes(keyword) ||
String(item.b_name).toLowerCase().includes(keyword),
)
.flatMap((item) => [item.b_code, codeById.get(item.b_parent_id)].filter(Boolean)),
)
: null;
const children = new Map();
props.modules.forEach((item) => {
const parent = item.b_parent_id || "__root__";
if (!children.has(parent)) children.set(parent, []);
children.get(parent).push(item);
});
const build = (parent) =>
(children.get(parent) || [])
.filter((item) => !visible || visible.has(item.b_code))
.map((item) => {
const nodeChildren = build(item.b_id);
return {
key: item.b_code,
title: item.b_name,
children: nodeChildren,
isLeaf: nodeChildren.length === 0,
};
});
return build("__root__");
});
function onSelect(keys) {
if (keys[0]) emit("select", keys[0]);
}
function onRightClick({ node }) {
contextNode.value = node;
emit("select", node.key);
}
const contextMenu = computed(() => {
const node = contextNode.value;
return {
items: [
{ key: "directory", label: "新增目录", disabled: !props.canCreate },
{ key: "page", label: "新增页面", disabled: !props.canCreate },
{ key: "data", label: "新增数据模块", disabled: !props.canCreate },
{ type: "divider" },
{
key: "delete",
label: "删除",
danger: true,
disabled: !node || !props.canDelete || props.protectedKeys.includes(String(node.key)),
},
],
onClick: ({ key }) => {
if (!node) return;
if (key === "delete") {
emit("delete", node.key);
return;
}
emit("add", key, node.key);
},
};
});
</script>
<template>
<aside class="module-tree-panel">
<header>
<div>
<strong>模块</strong>
<span>{{ modules.length }}</span>
</div>
<a-tooltip title="刷新">
<a-button type="text" size="small" :loading="loading" @click="emit('refresh')">
<RefreshCw :size="16" />
</a-button>
</a-tooltip>
</header>
<a-input v-model:value="searchText" allow-clear placeholder="搜索名称或编码">
<template #prefix><Search :size="15" /></template>
</a-input>
<a-spin :spinning="loading" class="tree-spin">
<a-dropdown :menu="contextMenu" :trigger="['contextmenu']">
<FmsTree
block-node
default-expand-all
:tree-data="treeData"
:selected-keys="selectedKey ? [selectedKey] : []"
@right-click="onRightClick"
@select="onSelect"
/>
</a-dropdown>
</a-spin>
</aside>
</template>
<style scoped>
.module-tree-panel {
display: flex;
min-height: 0;
flex-direction: column;
gap: 8px;
border-right: 1px solid #e5e7eb;
background: #fff;
padding: 10px 8px;
}
header,
header > div {
display: flex;
align-items: center;
}
header {
justify-content: space-between;
}
header > div {
gap: 8px;
}
header span {
color: #8c8c8c;
font-size: 12px;
}
.tree-spin {
min-height: 0;
flex: 1 1 auto;
overflow: auto;
}
:deep(.ant-spin-nested-loading),
:deep(.ant-spin-container) {
min-height: 0;
}
</style>
@@ -0,0 +1,64 @@
import { h } from "vue";
import { Button, Popconfirm, Select } from "antdv-next";
// 单元格渲染器统一采用 stk-table-vue 的 customCell 写法:
// customCell 会被当作函数式组件调用(props 含 row/col/rowIndex/cellValue),
// 必须返回 h(...) 生成的 VNode,返回普通对象会渲染成 [object Object]。
// 删除操作单元格
export function createDeleteCell(onDelete) {
return (props) =>
h(
Popconfirm,
{
title: "确定删除该记录?",
okText: "删除",
okButtonProps: { danger: true },
onConfirm: () => onDelete(props.row),
},
{
default: () =>
h(
Button,
{ type: "link", size: "small", danger: true, onClick: (e) => e.stopPropagation() },
"删除",
),
},
);
}
// 是否(0/1)切换单元格
export function createToggleCell(field, onChange) {
return (props) => {
const checked = props.row[field] === 1;
return h(
"span",
{
style: { cursor: "pointer" },
onClick: (e) => {
e.stopPropagation();
onChange(props.row, field);
},
},
checked ? "是" : "否",
);
};
}
// 下拉选择单元格。getOptions 返回最新选项(保持响应式)。
export function createSelectCell({ value, getOptions, onChange, placeholder, allowClear }) {
return (props) =>
h(Select, {
value:
props.row[value] != null && props.row[value] !== ""
? String(props.row[value])
: undefined,
size: "small",
style: { width: "100%" },
options: getOptions(),
allowClear: allowClear !== false,
placeholder: placeholder || "请选择",
"onUpdate:value": (v) => onChange(props.row, v),
onClick: (e) => e.stopPropagation(),
});
}
File diff suppressed because it is too large. Load diff
+2
View File
@@ -2,6 +2,7 @@
## 一、构建与测试
- **不要过多封装**
- 完成功能后不主动执行构建和测试。
- 只有用户明确要求时,才执行构建、测试或启动服务。
- 交付时说明本次未执行构建和测试。
@@ -27,6 +28,7 @@
- 不使用 CSS 重复实现组件已有的尺寸、状态、交互和响应式能力。
- 新增 CSS 应限制在当前组件作用域内,避免影响其他页面。
- 不需要兼容移动端,按桌面端实现即可。
- 表格尽量使用 `fms-table` 组件(项目封装的表格组件),不重复从零实现表格能力。
## 四、变更范围
+85 -91
View File
@@ -6,6 +6,7 @@
- 字段来源于数据模块配置的查询视图。系统不创建业务字段,只同步视图字段,并维护其展示、编辑、查询和权限配置。
- 页面和业务逻辑不完全元数据化。海运等复杂业务页面由 Vue 页面显式组合多个数据模块,元数据只提供 Table、Form 和 Query 配置。
- 字段基础定义、列表、编辑、查询四个职责分离;一个字段可以只参与其中任一场景。
- 编辑规则与列表布局解耦:`b_module_field_edit` 是通用编辑配置,FmsForm 和 FmsTable 可按自身规则消费;`b_module_field_list` 只描述列表展示,不保存编辑状态。
- 模块权限控制访问,操作权限控制按钮动作;操作权限必须依赖模块访问权限。
- 表名和字段名沿用旧系统的 `b_` 风格。全部 `b_id` 使用 `bigint` 雪花 ID,返回前端时序列化为字符串。
- 布尔字段统一使用 `int`,`1` 表示是、`0` 表示否;`b_xh` 数值越小越靠前。
@@ -17,16 +18,16 @@
b_module
|-- parent_id --> b_module
|-- 1:N --------> b_module_field
|-- 1:N --------> b_module_field_edit_group
|-- 1:N --------> b_module_field_group -- parent_id --> b_module_field_group
|-- 1:N --------> b_module_power ---- 1:N ----> b_user_power
`-- 1:N --------> b_user_module
b_module_field
|-- 0:N --------> b_module_field_list -- parent_id --> b_module_field_list
|-- 0:1 --------> b_module_field_edit
|-- 0:N --------> b_module_field_list -- group_id --> b_module_field_group
|-- 0:1 --------> b_module_field_edit -- group_id --> b_module_field_group
`-- 0:1 --------> b_module_field_query
b_module / b_module_field / b_module_field_list / b_module_field_edit_group / b_module_power
b_module / b_module_field / b_module_field_list / b_module_field_group / b_module_power
`-- b_i18n --> b_i18n.b_key
```
@@ -58,15 +59,15 @@ b_module / b_module_field / b_module_field_list / b_module_field_edit_group / b_
- `b_parent_id` 外键引用 `b_module.b_id`;保存服务校验父节点不形成循环。
- `b_code` 唯一,格式为 `^[a-z][a-z0-9_]{1,49}$`;`b_route` 非空时唯一。
- `directory` 不能配置 `b_route`、`b_viewtable` 或 `b_savetable`。
- `page` 必须配置 `b_route`,用于路由、菜单和页面编排;其业务数据由页面组合的数据模块提供。
- `data` 不能显示菜单;应配置 `b_viewtable`,保存场景再按需配置 `b_savetable` 与 `b_keyfield`。
- `page` 必须配置 `b_route`;其业务数据由页面组合的数据模块提供。
- `data` 不能显示菜单且不能配置 `b_route`;必须配置 `b_viewtable`,保存场景再按需配置 `b_savetable` 与 `b_keyfield`。
- `b_canmenu`、`b_canuse` 只能为 `0` 或 `1`。建议建立 `(b_parent_id, b_xh, b_id)` 索引。
## 字段来源与同步
### `dbo.b_module_field`
保存字段的基础定义,不保存列宽、表单位置、是否显示或查询位置等场景属性。字段来自数据模块的 `b_viewtable`,并以 `(b_module_id, b_field)` 作为稳定标识。
保存字段的基础定义。字段来自数据模块的 `b_viewtable`,并以 `(b_module_id, b_field)` 作为稳定标识;列表、编辑和查询场景属性均由独立配置表维护。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
@@ -75,19 +76,18 @@ b_module / b_module_field / b_module_field_list / b_module_field_edit_group / b_
| `b_field` | `varchar(50)` | 视图字段名 |
| `b_name` | `nvarchar(100)` | 默认字段名称,由管理员维护 |
| `b_i18n` | `varchar(150)` | 多语言键 |
| `b_field_type` | `varchar(30)` | `string`、`text`、`number`、`date`、`datetime`、`boolean`、`select_id` 或 `select_input` |
| `b_value` | `nvarchar(500)` | 新增时默认值 |
| `b_dict_code` | `varchar(100)` | 字典编码;非字典字段为空 |
| `b_type` | `varchar(30)` | 字段控件类型:`input`、`textarea`、`number`、`money`、`date`、`datetime`、`switch` 或 `select` |
| `b_default_value` | `nvarchar(500)` | 新增记录时的默认值 |
| `b_options` | `nvarchar(max)` | 类型专属 JSON 配置,例如选择器来源、日期格式或金额精度 |
| `b_canuse` | `int` | 是否启用 |
| `b_remark` | `nvarchar(500)` | 字段说明 |
| `b_inputdatetime` | `datetime` | 首次同步创建时间 |
约束:
- `b_module_id` 外键引用 `b_module.b_id`,`(b_module_id, b_field)` 唯一。
- `b_canuse` 只能为 `0` 或 `1`。建议建立 `(b_module_id, b_canuse, b_id)` 索引。
- `b_canuse` 只能为 `0` 或 `1`;`b_options` 非空时通过 `ISJSON` 校验。建议建立 `(b_module_id, b_canuse, b_id)` 索引。
- 字段没有 list、edit 或 query 配置记录时,不参与对应场景。
- `select_id` 保存选项 ID;`select_input` 支持从选项中选择或直接输入文本。字典型选择器使用 `b_dict_code`;视图型选择器的来源配置保存在 `b_module_field_edit.b_editor_options`。
- `b_type` 是展示和编辑的唯一类型来源:Table 按它渲染默认显示器,Form 或可编辑 Table 按它选择控件。
- `select` 是否允许自由输入、选项来源、值字段和显示字段均由 `b_options` 控制。
### 视图同步规则
@@ -95,90 +95,82 @@ b_module / b_module_field / b_module_field_list / b_module_field_edit_group / b_
1. 后端校验视图名并读取列名、SQL 类型和可空性等结构信息。
2. 按列名查找同模块的 `b_module_field`;新列创建字段基础记录。
3. 同步更新 `b_field`、`b_field_type` 和 `b_canuse`。SQL 类型映射为 `string`、`text`、`number`、`date`、`datetime`、`boolean` 等基础类型;`select_id`、`select_input` 由管理员在同步后按业务语义调整。
3. 新字段按 SQL 类型初始化 `b_type`,例如字符串映射为 `input`、长文本映射为 `textarea`、数值映射为 `number`、日期映射为 `date` 或 `datetime`、位值映射为 `switch`。已有字段不覆盖管理员维护的 `b_type` 与 `b_options`;`select`、`money` 等业务类型由管理员按语义调整。
4. 本次视图中不存在的已有字段保留其记录和全部场景配置,但将 `b_canuse` 置为 `0`。
5. 不覆盖人工维护的 `b_name`、`b_i18n`、`b_value`、`b_dict_code` 和 `b_remark`,不物理删除字段或场景配置。
5. 不覆盖人工维护的 `b_name`、`b_i18n`、`b_default_value` 与 `b_options`,不物理删除字段或场景配置。
## 列表配置
### `dbo.b_module_field_list`
控制 Table 列、显示方式和多级表头。分组节点与字段节点通过 `b_parent_id` 组成任意层级的表头树;只读列表和可编辑 Table 共用本表。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `b_id` | `bigint` | 雪花 ID 主键 |
| `b_module_id` | `bigint` | 所属模块 ID |
| `b_parent_id` | `bigint` | 上级表头分组 ID;顶层为空 |
| `b_kind` | `varchar(20)` | `group` 或 `field` |
| `b_field_id` | `bigint` | 字段节点关联 `b_module_field.b_id`;分组节点为空 |
| `b_title` | `nvarchar(100)` | 分组标题或字段标题覆盖值;字段为空时使用默认字段名称 |
| `b_i18n` | `varchar(150)` | 分组多语言键或字段标题多语言键覆盖值 |
| `b_visible` | `int` | 是否显示,默认 `1` |
| `b_xh` | `int` | 同一父节点下的显示顺序,默认 `0` |
| `b_width` | `int` | 字段列宽;分组节点为空 |
| `b_sortable` | `int` | 是否允许排序,默认 `0` |
| `b_display_type` | `varchar(30)` | 显示器类型,例如 `text`、`money`、`date`、`datetime`、`tag`;前端按类型使用统一显示规则 |
| `b_canedit` | `int` | 是否允许表格单元格编辑,默认 `0` |
约束:
- `b_module_id` 外键引用 `b_module.b_id`,`b_parent_id` 外键引用本表 `b_id`,`b_field_id` 外键引用 `b_module_field.b_id`。
- `group` 节点必须没有 `b_field_id`、`b_width`,且 `b_canedit = 0`;`field` 节点必须有 `b_field_id`。
- 同一字段在同一模块的列树中最多出现一次,使用非空 `b_field_id` 的过滤唯一索引约束。
- `b_parent_id` 必须指向同模块的 `group` 节点,且不能形成循环,由保存服务校验。
- `b_visible`、`b_sortable`、`b_canedit` 只能为 `0` 或 `1`;`b_canedit = 1` 时必须存在对应编辑规则。
- `b_display_type` 决定显示器及其格式化规则,例如 `money`、`date`、`datetime` 均由前端按统一规则渲染。建议建立 `(b_module_id, b_parent_id, b_xh, b_id)` 索引。
- 不保存 `level`、`colspan`、`rowspan`;前端一次加载全部节点后,按 `b_parent_id`、`b_xh` 组装嵌套 `children` 并计算表头。隐藏分组的后代不显示;没有可见字段后代的分组不渲染。
示例:
```text
费用(group)
|-- 人工费(field)
`-- 海运费(field)
```
## 编辑配置
### `dbo.b_module_field_edit`
控制 Form 和可编辑 Table 的共享编辑规则,以及字段在 Form 中的布局。表单分组仅是视觉分组,不表示页签、动态页面区域或主子表关系;分组标题和多语言由独立分组表维护。
控制列表字段列。多级表头由 `b_module_field_group` 的 `list` 分组树表达;本表不保存编辑状态。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `b_id` | `bigint` | 雪花 ID 主键 |
| `b_module_id` | `bigint` | 所属模块 ID |
| `b_field_id` | `bigint` | 关联 `b_module_field.b_id` |
| `b_editor` | `varchar(30)` | 例如 `input`、`textarea`、`select`、`date`、`switch` |
| `b_edit_format` | `varchar(100)` | 编辑值格式 |
| `b_editor_options` | `nvarchar(max)` | 编辑器专属 JSON 配置 |
| `b_required` | `int` | 是否必填,默认 `0` |
| `b_readonly` | `int` | 是否只读,默认 `0` |
| `b_disabled` | `int` | 是否禁用,默认 `0` |
| `b_placeholder` | `nvarchar(200)` | 输入提示 |
| `b_canform` | `int` | 是否进入 Form,默认 `1` |
| `b_edit_group` | `varchar(100)` | 关联 `b_module_field_edit_group.b_code`;不分组时为空 |
| `b_edit_xh` | `int` | 编辑区域中的全局顺序,默认 `0` |
| `b_edit_span` | `int` | Form 栅格宽度,默认 `24`,范围 `1` 至 `24` |
| `b_group_id` | `bigint` | 所属分组 ID;为空时为顶层列 |
| `b_title` | `nvarchar(100)` | 字段标题覆盖值;为空时使用默认字段名称 |
| `b_i18n` | `varchar(150)` | 字段标题多语言键覆盖值 |
| `b_visible` | `int` | 是否显示,默认 `1` |
| `b_xh` | `int` | 同一分组内的显示顺序,默认 `0` |
| `b_width` | `int` | 字段列宽 |
| `b_sortable` | `int` | 是否允许排序,默认 `0` |
约束:
- `b_module_id` 外键引用 `b_module.b_id`,`b_field_id` 外键引用 `b_module_field.b_id`;`(b_module_id, b_field_id)` 唯一,保存服务校验字段属于同一模块。
- `b_required`、`b_readonly`、`b_disabled`、`b_canform` 只能为 `0` 或 `1`;`b_editor_options` 非空时通过 `ISJSON` 校验。
- 非空 `b_edit_group` 必须引用同模块的启用分组;保存服务校验模块一致性。
- 已分组字段按所属分组的 `b_xh` 排列,组内按 `b_edit_xh` 排列;未分组字段作为末尾默认区域,按 `b_edit_xh` 排列。建议建立 `(b_module_id, b_edit_xh, b_id)` 索引。
- `b_canform = 0` 时,编辑规则仍可由可编辑 Table 使用。
- `b_module_id` 外键引用 `b_module.b_id`,`b_field_id` 外键引用 `b_module_field.b_id`,`b_group_id` 引用同模块的 `b_module_field_group`。
- `b_field_id` 必填;同一字段在同一模块的列表中最多出现一次,使用 `(b_module_id, b_field_id)` 唯一约束。
- `b_visible`、`b_sortable` 只能为 `0` 或 `1`。
- 字段列的显示器由关联字段的 `b_type` 与 `b_options` 决定,例如 `money`、`date`、`datetime` 均由前端按统一规则渲染。建议建立 `(b_module_id, b_group_id, b_xh, b_id)` 索引。
- 不保存 `level`、`colspan`、`rowspan`;前端加载 Table 分组树和字段列后,按分组的 `b_parent_id`、`b_xh` 组装嵌套表头。隐藏分组的后代不显示;没有可见字段后代的分组不渲染。
### `dbo.b_module_field_edit_group`
示例:
保存 Form 的视觉分组标题和多语言配置。一个模块可以有多个分组,字段通过 `b_edit_group` 引用其稳定编码。
```text
费用(`b_module_field_group`)
|-- 人工费(`b_module_field_list.b_group_id` 指向费用)
`-- 海运费(`b_module_field_list.b_group_id` 指向费用)
```
## 编辑配置
### `dbo.b_module_field_edit`
控制字段的通用编辑属性与编辑分组。控件类型、显示规则和类型专属参数统一由 `b_module_field.b_type`、`b_options` 决定;FmsForm 与 FmsTable 自行决定何时使用本配置。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `b_id` | `bigint` | 雪花 ID 主键 |
| `b_module_id` | `bigint` | 所属模块 ID |
| `b_code` | `varchar(100)` | 分组稳定编码,例如 `basic`、`booking` |
| `b_field_id` | `bigint` | 关联 `b_module_field.b_id` |
| `b_required` | `int` | 是否必填,默认 `0` |
| `b_readonly` | `int` | 是否只读,默认 `0` |
| `b_disabled` | `int` | 是否禁用,默认 `0` |
| `b_group_id` | `bigint` | 所属编辑分组 ID;不分组时为空 |
| `b_xh` | `int` | 编辑场景中的显示顺序,默认 `0` |
约束:
- `b_module_id` 外键引用 `b_module.b_id`,`b_field_id` 外键引用 `b_module_field.b_id`;`(b_module_id, b_field_id)` 唯一,保存服务校验字段属于同一模块。
- `b_required`、`b_readonly`、`b_disabled` 只能为 `0` 或 `1`。
- `b_group_id` 必须关联同模块的 `b_module_field_group`;分组为空时字段进入默认编辑区域。
- 已分组字段按分组的 `b_xh` 排列,组内按 `b_xh` 排列;默认区域字段按 `b_xh` 排列。
## 布局分组
### `dbo.b_module_field_group`
统一保存列表多级表头和编辑分组。分组只负责布局,不定义编辑能力;列表或编辑用途由引用它的配置表决定。
被 `b_module_field_list` 引用时,分组组成同一份 FmsTable 的多级表头;被 `b_module_field_edit` 引用时,分组组成同一份编辑界面中的业务区块,例如提单、订舱、报关、备注,不代表拆分为多个 Form。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `b_id` | `bigint` | 雪花 ID 主键 |
| `b_module_id` | `bigint` | 所属模块 ID |
| `b_parent_id` | `bigint` | 上级分组 ID;顶层为空 |
| `b_title` | `nvarchar(100)` | 默认分组标题 |
| `b_i18n` | `varchar(150)` | 分组标题多语言键 |
| `b_xh` | `int` | 分组显示顺序,默认 `0` |
@@ -186,11 +178,13 @@ b_module / b_module_field / b_module_field_list / b_module_field_edit_group / b_
约束:
- `b_module_id` 外键引用 `b_module.b_id`,`(b_module_id, b_code)` 唯一。
- `b_module_id` 外键引用 `b_module.b_id`;`b_id` 是分组的稳定关联键。
- `b_parent_id` 必须引用同模块的分组且不能形成循环。
- 多级分组可用于 FmsTable 表头;FmsForm 与 FmsTable 如何消费编辑分组由各自实现决定。
- `b_canuse` 只能为 `0` 或 `1`。建议建立 `(b_module_id, b_canuse, b_xh, b_id)` 索引。
- 无字段引用的分组不渲染;分组停用时,其关联字段不在 Form 中显示。
- 无字段引用的分组不渲染;分组停用时,其关联字段或列不显示。
视图型选择器的 `b_editor_options` 示例:
视图型选择器的 `b_options` 示例:
```json
{
@@ -202,7 +196,7 @@ b_module / b_module_field / b_module_field_list / b_module_field_edit_group / b_
}
```
`select_id` 使用 `allowInput: false` 并保存 `valueField` 的值;`select_input` 可使用 `allowInput: true` 并保存选择值或用户输入文本。视图名、值字段和标签字段均须由后端校验。
`select` 使用 `allowInput: false` 时保存 `valueField` 的值;设为 `true` 时可保存选择值或用户输入文本。视图名、值字段和标签字段均须由后端校验。
## 查询配置
@@ -299,13 +293,13 @@ b_module / b_module_field / b_module_field_list / b_module_field_edit_group / b_
```text
海运页面 m_sea(page:路由、菜单、权限、页面编排)
|-- 海运主单 d_sea(data:Table / Form / Query)
|-- 费用 d_charge(data:可编辑 Table)
|-- 订舱 d_booking(data:独立表和主键时)
`-- 箱明细 d_container(data:可编辑 Table)
|-- 海运主单 d_sea(data)
|-- 费用 d_charge(data)
|-- 订舱 d_booking(data,独立表和主键时)
`-- 箱明细 d_container(data)
```
若订舱字段实际保存于海运主单表,不创建 `d_booking`,而在 `d_sea` 的 Form 中用 `b_module_field_edit_group` 和 `b_edit_group` 表达视觉分组。主子表关联、加载时机、保存顺序和页面布局均由业务 Vue 页面明确实现。
若订舱字段实际保存于海运主单表,不创建 `d_booking`,而在 `d_sea` 的编辑配置中用 `b_group_id` 和 `b_xh` 定义编辑分组与顺序,分组标题来自 `b_module_field_group`。主子表关联、加载时机、保存顺序和页面布局均由业务 Vue 页面明确实现。
## 现有字段迁移
@@ -313,19 +307,19 @@ b_module / b_module_field / b_module_field_list / b_module_field_edit_group / b_
| 当前字段 | 目标位置 |
| --- | --- |
| `b_type` | `b_module_field.b_field_type` |
| `b_type` | `b_module_field.b_type` |
| `b_canlist` | `b_module_field_list.b_visible` |
| `b_width` | `b_module_field_list.b_width` |
| `b_xh` | 初始化 `b_module_field_list.b_xh`、`b_module_field_edit.b_edit_xh` 与 `b_module_field_query.b_xh` |
| `b_canform` | `b_module_field_edit.b_canform` |
| `b_xh` | 初始化 `b_module_field_list.b_xh`、`b_module_field_edit.b_xh` 与 `b_module_field_query.b_xh` |
| `b_canform` | 为原 Form 字段创建 `b_module_field_edit` 记录 |
| `b_must` | `b_module_field_edit.b_required` |
| `b_readonly` | `b_module_field_edit.b_readonly` |
| `b_canquery` | 创建 `b_module_field_query` 记录 |
| `b_value` | 保留在 `b_module_field.b_value` |
| `b_value` | 迁移为 `b_module_field.b_default_value` |
迁移步骤:
1. 新建 list、edit、edit_group、query 表和必要索引,新增 `b_module.b_module_type` 与 `b_module_field.b_field_type` 的兼容列。
2. 为原有字段创建顶层 `field` 列配置;根据原 `b_canform`、`b_canquery` 创建编辑和查询配置;初始 `b_canedit = 0`。
1. 新建 list、edit、group、query 表和必要索引,新增 `b_module.b_module_type` 与 `b_module_field.b_type` 的兼容列。
2. 为原有字段创建列表配置;根据原 `b_canform`、`b_canquery` 初始化编辑和查询配置。
3. 前端和后端改为读取新场景表,查询不再读取 list 配置;模块类型切换为读取 `b_module_type`。
4. 完成视图同步服务并验证新结构的读写稳定后,删除旧混合列和兼容字段。