diff --git a/code/fms/.codebuddy/plans/module-management-v2-refactor_c50f14c4.md b/code/fms/.codebuddy/plans/module-management-v2-refactor_c50f14c4.md new file mode 100644 index 00000000..cc578219 --- /dev/null +++ b/code/fms/.codebuddy/plans/module-management-v2-refactor_c50f14c4.md @@ -0,0 +1,210 @@ +--- +name: module-management-v2-refactor +overview: 将 fms-vue 的 module-management 从旧表结构(category/data/query、s_field_group/s_field_view/s_field_edit/s_field_query 行模型、s_i18n 带 b_id/b_module_id)重构为《FMS新系统核心表结构设计》V2:模块类型 module/data/virtual + 模块树 b_depth/b_path,布局统一存 s_module_schema(view/edit/query) 的 b_schema_json JSON 节点,s_field 精简为 b_source_field/b_type,i18n 主键改 (b_key,b_locale),并同步更新 menu-management 的模块类型过滤。 +todos: + - id: rewrite-utils + content: 重写 module-management/utils.js:模块类型映射、view/edit/query JSON 默认结构与 stableStringify、受管 i18n 键、树 b_depth/b_path 计算;同步改 menu-management 类型过滤 + status: completed + - id: refactor-orchestrator + content: 重构 index.vue 编排层:maindata/fields/view_schema/edit_schema/query_schema/autocode/i18n 成对基线与加载/校验/diff 保存/重载、树增删移与 b_depth/b_path、按新键删除清理 + status: completed + dependencies: + - rewrite-utils + - id: refactor-basic + content: 重构 ModuleBasicPanel:新类型 module/data/virtual 与新 s_module 列及类型联动校验 + status: completed + dependencies: + - refactor-orchestrator + - id: refactor-fields + content: 重构 ModuleFieldsPanel:新 s_field 列、同步字段写 b_source_field、参与勾选改为 JSON 节点增删 + status: completed + dependencies: + - refactor-orchestrator + - id: rewrite-editor-kernel + content: 用 [subagent:code-explorer] 梳理设计器依赖后,重写 useStructureEditing 与 fieldDefaults 为 JSON 节点模型内核 + status: completed + dependencies: + - rewrite-utils + - id: rewrite-editors + content: 重写 TableDesignEditor/FormDesignEditor 及子组件消费产出 view/edit JSON,并更新 ModuleListConfigPanel/ModuleEditPanel + status: completed + dependencies: + - rewrite-editor-kernel + - id: refactor-query-i18n + content: 重构 ModuleQueryConfigPanel 为 query JSON,重构 ModuleI18nPanel 与 i18n 工具为 (b_key,b_locale) 复合键 + status: completed + dependencies: + - refactor-orchestrator + - id: update-tests + content: 用 [skill:lsp-code-analysis] 全局核对引用,更新受影响单测并运行 pnpm test/lint/build + status: completed + dependencies: + - rewrite-editors + - refactor-fields + - refactor-query-i18n +--- + +## 产品概述 + +模块管理页(左树右面板)与 V2 表结构(`FMS新系统核心表结构设计.md` + `sql/fms_core.sql`)对齐,并把 `开发规范.md` 的面板式配置页约定落实到编排层与各面板。界面外观与既有交互(树搜索/右键/拖拽、分节 Tab、字段表格、列表/表单设计器的拖拽与预览、查询条件表、自动编码预览、多语言透视表)保持不变,仅调整底层数据模型、加载与保存逻辑。 + +## 用户需求 + +- 按 V2 重构 `fms-vue/src/views/module/module-management/` 全部代码。 +- 布局配置完整迁移为 JSON:列表/表单/查询分别存 `s_module_schema` 的 `view/edit/query` 三行 `b_schema_json`,字段分组由独立表变为 JSON 节点。 +- 模块类型改为 `module / data / virtual`(category→module、query→virtual、data 不变),新增/移动节点时维护模块树 `b_depth / b_path`。 +- 同步更新 `menu-management` 中对模块类型的过滤条件。 +- 不新增 `s_relation` 面板。 + +## 核心功能 + +- 模块树:加载/搜索/展开/右键新增/删除/拖拽排序;节点承载 b_depth、b_path,并随新增与移动同步维护。 +- 基础信息:维护新 `s_module` 列(b_view_table/b_save_table/b_key_field/b_order_sql/b_query_sql/b_config_json 等)与类型联动校验。 +- 字段定义:维护新 `s_field`(b_source_field/b_name/b_i18n/b_type/b_canuse/b_xh);按查询表/视图同步字段;表单/列表/查询“参与”勾选改为在对应 JSON 中增删字段节点。 +- 界面配置:列表(view JSON,支持分组列/多级表头)、表单(edit JSON,分组+行+字段、栅格/只读/必填)、查询(query JSON,quick + 条件组)。 +- 自动编码:单条 `s_autocode` 规则与实时预览(列名已与 V2 一致)。 +- 多语言:基于 `s_i18n` 的 `(b_key, b_locale)` 复合键维护受管资源键与翻译。 +- 保存:`handleSave → beforeSave → buildSaveData → 通用保存接口 → afterSave`,按 `_org` 基线 diff,无变化不提交;切换模块保留未保存确认。 + +## 技术栈 + +- 前端:Vue 3(Composition API + ` - - - - -
F
FMS 权限中心
一客户一库 · 权限点自动生成
IT
系统管理员
客户库:DEMO-FWD
授权对象

费用管理

cw_fee

张三 · 全部默认拒绝,勾选即授权
入口
已开通menu.cw_fee.access
动作
数据范围
未配置视为全部数据
敏感字段
金额amount已收紧
内部成本internal_cost已收紧
毛利gross_profit已收紧
- - -
- - - diff --git a/code/fms/.tmp-panel.html b/code/fms/.tmp-panel.html deleted file mode 100644 index 82977c89..00000000 --- a/code/fms/.tmp-panel.html +++ /dev/null @@ -1,109 +0,0 @@ - -
- - -
-
-

全部模块概览

-

{{ curUser.name }} · 点模块名进入详细配置

-
- - - - - - - - - - - - - - - - - -
模块入口已授权动作数据范围
-
{{ m.name }}
-
{{ m.id }}
-
- - - 未授权(默认拒绝) - - {{ a }} - - - {{ scopeName(scopeOf(m.id)) }} -
-
- - -
-
-
-

{{ selName }}

-

{{ state.selected }}

-
- page 节点 - {{ curUser.name }} · 全部默认拒绝,勾选即授权 -
- - -
-
入口
- - {{ hasEntry(state.selected) ? '已开通' : '未开通' }} - {{ entryCode }} -
- - -
-
动作
-
- -
-
-
页面节点只配入口,具体操作在下属模块上配置
- - -
-
数据范围
- - 未配置视为全部数据 -
- - -
-
敏感字段
-
-
该模块没有标记敏感字段,无需配置字段权限
-
-
- {{ f.l }}{{ f.n }} - - - - 已收紧 - 默认 -
-
-
-
-
diff --git a/code/fms/.tmp-replace.js b/code/fms/.tmp-replace.js deleted file mode 100644 index 141c692e..00000000 --- a/code/fms/.tmp-replace.js +++ /dev/null @@ -1,14 +0,0 @@ -const fs = require('fs'); -const file = 'd:/workspace/code/fms/demo.html'; -const panelFile = 'd:/workspace/code/fms/.tmp-panel.html'; -const lines = fs.readFileSync(file, 'utf8').split('\n'); -const panel = fs.readFileSync(panelFile, 'utf8').replace(/\n$/, '').split('\n'); - -// 替换第 108..211 行(1-based)为新的权限面板;保留第 212 行的
-const before = lines.slice(0, 107); -const after = lines.slice(211); -console.log('第108行原内容:', JSON.stringify(lines[107])); -console.log('第211行原内容:', JSON.stringify(lines[210])); -console.log('第212行原内容:', JSON.stringify(lines[211])); -fs.writeFileSync(file, before.concat(panel).concat(after).join('\n'), 'utf8'); -console.log('done, total lines:', before.length + panel.length + after.length); diff --git a/code/fms/.tmp-shot.png b/code/fms/.tmp-shot.png deleted file mode 100644 index 67f91853..00000000 Binary files a/code/fms/.tmp-shot.png and /dev/null differ diff --git a/code/fms/.tmp-smoke.js b/code/fms/.tmp-smoke.js deleted file mode 100644 index 262f04cc..00000000 --- a/code/fms/.tmp-smoke.js +++ /dev/null @@ -1,23 +0,0 @@ -const { execFileSync } = require('child_process'); -const fs = require('fs'); -const exe = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'; -const shot = process.argv[2] === 'shot'; -let out = ''; -try { - out = execFileSync(exe, ['--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars', - '--window-size=1680,1400', '--virtual-time-budget=12000', - shot ? '--screenshot=d:/workspace/code/fms/.tmp-shot.png' : '--dump-dom', - 'file:///d:/workspace/code/fms/demo.html'], { maxBuffer: 64 * 1024 * 1024 }).toString(); -} catch (e) { out = String(e.stdout || ''); console.log('ERR', String(e.stderr || '').slice(0, 400)) } -if (shot) { console.log('shot ok'); process.exit(0) } -fs.writeFileSync('d:/workspace/code/fms/.tmp-dom.html', out, 'utf8'); -console.log(JSON.stringify({ - len: out.length, - vueWarn: out.indexOf('[Vue warn]') > -1, - hasTreeAside: out.indexOf('模块树') > -1, - hasPanel: out.indexOf('敏感字段') > -1, - hasUserBar: out.indexOf('授权对象') > -1, - hasOverview: out.indexOf('全部模块(概览)') > -1, - hasOldTable: out.indexOf('min-w-[1000px]') > -1, - hasError: out.indexOf('Uncaught') > -1 -}, null, 2)); diff --git a/code/fms/历史记录选择器配置设计讨论.md b/code/fms/FMS历史记录选择器配置设计讨论.md similarity index 100% rename from code/fms/历史记录选择器配置设计讨论.md rename to code/fms/FMS历史记录选择器配置设计讨论.md diff --git a/code/fms/FMS新系统核心表结构设计.md b/code/fms/FMS新系统核心表结构设计.md index a8fe59f8..7c3af6a8 100644 --- a/code/fms/FMS新系统核心表结构设计.md +++ b/code/fms/FMS新系统核心表结构设计.md @@ -1,427 +1,594 @@ +# FMS 新系统核心表结构设计 V2 + +> 本文用于记录新版核心表结构方案,权限设计见第 14 节。旧版文档保留作为历史参考。 + **本系统以 SQL 为核心驱动。设计时应优先保证查询和数据处理的灵活性,并在各模块预留 SQL 扩展能力,支持传入 SQL、动态拼接查询条件、组合查询及自定义数据处理逻辑,以便通过 SQL 快速适配和解决复杂业务场景。** -# 数据表规范 +## 1. 数据表规范 本规范用于指导 AI 设计、新增和调整数据库表结构。除非已有表结构或业务要求明确指定,否则应优先遵循以下约定。 -1. **表名规范** - - 统一使用小写 `snake_case` 命名,单词之间使用下划线分隔。 - - 系统配置、元数据和权限相关表使用 `s_` 前缀,例如 `s_module`、`s_field`。 - - 业务数据表使用 `b_` 前缀或明确的业务域前缀,例如 `b_user`、`cw_fee`。 - - 表名应表达稳定的业务含义,避免使用页面名称、路由名称或临时功能名称作为表名。 +### 1.1 表名规范 -2. **表职责规范** - - 一张表应围绕一个明确的业务对象、配置对象或关系对象设计。 - - 系统表用于保存系统配置、元数据、权限和运行记录;业务表用于保存具体业务数据;关系表用于表达对象之间的关联关系。 - - 不要为了适配单个页面重复创建业务表;页面差异优先通过模块配置、字段布局和 SQL 组合实现。 +- 统一使用小写 `snake_case` 命名,单词之间使用下划线分隔。 +- 系统配置、元数据和权限相关表使用 `s_` 前缀,例如 `s_module`、`s_field`。 +- 业务数据表使用 `b_` 前缀或明确的业务域前缀,例如 `cw_fee`;组织与用户相关的基础主数据沿用现有 `b_` 前缀,例如 `b_user`、`b_dept`。 +- 表名应表达稳定的业务含义,避免使用页面名称、路由名称或临时功能名称作为表名。 -3. **主键和关联字段规范** - - 每张表必须定义稳定、明确的主键。业务对象优先使用 `b_id` 作为业务编码主键;关系表可根据业务关系使用联合主键。 - - 表之间的关联字段统一使用 `<对象>_id` 或已有的 `b_module_id` 等明确命名,不使用无法判断来源的通用字段名。 - - 主键和关联字段的数据类型、长度必须保持一致。 +### 1.2 表职责规范 -4. **字段命名规范** - - 字段统一使用小写 `snake_case` 命名。 - - 主表或主业务对象字段使用 `b_` 前缀;明细、子表或扩展业务字段使用 `mx_` 前缀。 - - 系统公共字段沿用现有命名,例如 `b_id`、`b_name`、`b_bz`、`b_canuse`、`b_xh`。 - - 字段名应表达业务含义,避免使用 `value`、`data`、`type1` 等无法说明用途的名称;同一含义在不同表中应使用统一命名。 +- 一张表应围绕一个明确的业务对象、配置对象或关系对象设计。 +- 系统表用于保存系统配置、元数据、权限和运行记录;业务表用于保存具体业务数据;关系表用于表达对象之间的关联关系。 +- 不要为了适配单个页面重复创建业务表;页面差异优先通过模块配置、JSON 布局和 SQL 组合实现。 +- 核心、稳定且需要被 SQL 查询的数据使用关系表;层级化、变化频繁的界面配置和用户偏好使用 JSON。 -5. **通用字段规范** - - 启用/停用统一使用 `b_canuse`,取值为 `0/1`。 - - 排序字段统一使用 `b_xh`,默认值为 `0`;需要稳定排序时增加业务主键作为次排序字段。 - - 名称、备注和多语言文本使用 `nvarchar`;编码、状态、类型和 SQL 标识类字段使用 `varchar`。 - - 日期时间优先使用 `datetime2`;金额、数量等数值字段根据业务精度明确设置 `precision` 和 `scale`。 - - 是否允许为空、默认值和数据库默认值必须根据业务语义明确设置,不能无理由全部允许为空或全部设置默认值。 +### 1.3 主键和关联字段规范 -6. **字段与布局规范** - - 数据字段定义、列表布局、编辑布局和查询布局分开保存,避免把页面展示规则硬编码到业务表中。 - - 字段配置、字段分组和用户个性化配置必须通过 `b_module_id` 与所属数据模块关联。 - - 页面差异优先通过字段布局、查询条件和 SQL 组合实现,不通过复制数据表实现。 +- 每张表必须定义稳定、明确的主键。业务对象优先使用 `b_id` 作为业务编码主键;关系表可根据业务关系使用联合主键。 +- 表之间的关联字段使用 `<对象>_id` 或已有的 `b_module_id` 等明确命名,不使用无法判断来源的通用字段名。 +- 主键和关联字段的数据类型、长度必须保持一致。 +- 本设计不依赖数据库外键约束保证关联完整性,关联存在性和删除规则由业务层处理。 -7. **SQL 驱动规范** - - 查询和数据处理应优先采用可组合的 SQL 方案,避免把业务逻辑固化在单个页面或固定查询中。 - - 各模块应预留 SQL 扩展入口,支持传入 SQL、动态拼接 `where` 条件、选择字段、排序、分页、关联查询和组合查询。 - - 当通用配置无法覆盖业务场景时,优先通过 SQL 视图、查询 SQL、条件片段或数据处理 SQL 扩展,不要立即新增重复表或重复模块。 - - SQL 参数、查询字段、排序字段和关联关系应使用明确的元数据或配置进行描述,保证 AI 能够根据模块定义生成和组合查询。 +### 1.4 字段命名规范 -8. **索引和约束规范** - - 主键、外键关联字段、常用查询条件、排序字段和唯一业务键应根据实际查询场景建立索引。 - - 索引应服务于明确的查询或关联场景,避免无依据地为每个字段单独建索引。 - - 数据库层只保留主键、联合主键、索引和必要的唯一约束;唯一约束可用于保证业务键或组合业务键不重复。 - - 除主键、唯一约束和索引外,不增加 `CHECK`、触发器等业务规则约束。字段取值、字段组合、状态流转和其他业务校验统一交给业务层处理。 - - 非空和默认值属于字段定义的一部分,应根据业务语义设置;不要使用数据库约束代替业务逻辑。 +- 字段统一使用小写 `snake_case` 命名。 +- 主表或主业务对象字段使用 `b_` 前缀;明细、子表或扩展业务字段使用 `mx_` 前缀。 +- 系统公共字段沿用现有命名,例如 `b_id`、`b_name`、`b_bz`、`b_canuse`、`b_xh`。 +- 字段名应表达业务含义,避免使用 `value`、`data`、`type1` 等无法说明用途的名称;同一含义在不同表中应使用统一命名。 +### 1.5 通用字段规范 +- 启用和停用统一使用 `b_canuse`,取值为 `0/1`。 +- 排序字段统一使用 `b_xh`,默认值为 `0`;需要稳定排序时增加业务主键作为次排序字段。 +- 名称、备注和多语言文本使用 `nvarchar`;编码、状态、类型和 SQL 标识类字段使用 `varchar`。 +- 日期时间优先使用 `datetime2`;金额、数量等数值字段根据业务精度明确设置 `precision` 和 `scale`。 +- 是否允许为空及默认值应根据业务语义明确设置,不能无理由全部允许为空或全部设置默认值。 -# FMS 新系统核心表结构 +### 1.6 通用系统审计字段 -## 1. 用户表 +- 通用创建人使用 `b_created_by`,保存用户编码,不保存用户名称。 +- 通用创建时间使用 `b_created_at`,类型使用 `datetime2`。 +- 通用最后修改人使用 `b_updated_by`,保存最后修改用户编码,不保存用户名称。 +- 通用最后修改时间使用 `b_updated_at`,类型使用 `datetime2`。 +- 以上字段按表的实际审计需求选用;创建人、修改人和对应时间应保持成对出现。 +- 审计字段不是所有表的必选项,由客户维护、需要追溯变更的业务表才需要;系统内部使用的关联表、权限表等可以不加。 +- 例外:授权类表(`s_user_power`、`s_user_field_power`、`s_user_data_power`)由用户在授权界面操作,需要追溯授权变更人,因此保留审计字段;`s_power` 属于开发期维护的系统元数据,不设审计字段。 +- 这些字段只表示系统层面的创建和修改信息,不替代业务字段中的录入人、审核人、负责人或当前操作人。 + +### 1.7 树结构规范 + +- 需要维护层级、支持新增和移动的树,统一采用“邻接表为主 + `b_depth`、`b_path` 冗余”的方案。 +- `b_parent_id` 是树结构的权威关系,表示当前节点的直接父节点;`b_depth` 和 `b_path` 是由业务层维护的派生字段。 +- 根节点的 `b_parent_id` 为 `NULL`,`b_depth` 从 `0` 开始计算。 +- `b_path` 使用稳定节点编码组成,并在每个节点编码前后保留 `/` 分隔符,例如 `/sea/container/`;节点编码创建后原则上不可修改。 +- `b_path` 用于前缀查询子树,`b_parent_id` 配合递归 CTE 用于查询父级、祖先和复杂层级关系。 +- 新增和移动节点时,必须在同一业务事务中同步更新当前节点及其全部后代的 `b_depth` 和 `b_path`。 +- 业务层必须校验节点不能指向自身,不能形成循环;这些规则不使用数据库 `CHECK` 或触发器约束。 +- 当冗余字段可能不一致时,以 `b_parent_id` 为准,并提供按父子关系重建 `b_depth`、`b_path` 的维护能力。 +- 模块树、菜单树和部门树使用该规范;JSON 内的布局树只在 JSON 内维护,不额外建立树表;主子表和引用关系使用 `s_relation`,多对多通过中间表模块加两条 `one_to_many` 表达,都不使用树字段表达。 + +### 1.8 JSON 配置规范 + +- SQL Server 使用 `nvarchar(max)` 保存 JSON,由业务层负责序列化、反序列化和结构校验。 +- JSON 配置如果存在结构升级需求,可以在顶层包含 `schemaVersion`,用于业务层迁移;不单独在数据库表中保存重复的版本字段。 +- JSON 中通过字段编码引用 `s_field`,不得重复定义数据库类型、长度、精度等核心字段元数据。 +- 布局 JSON 保存完整的模块默认配置;用户偏好 JSON 只保存相对默认配置的偏离量。 +- 表单显示、只读、必填、禁用、条件显示、默认值、选项、格式化和字段计算等界面行为配置放在对应的布局 JSON 中,不放入 `s_field`。 +- 读取用户配置时,先加载模块默认配置,再应用用户偏好覆盖;恢复默认时删除对应偏好或清空偏离配置。 +- JSON 可以扩展新属性,但不得随意改变已有属性语义;不再使用的属性由业务层兼容或迁移。 + +### 1.9 SQL 驱动规范 + +- 查询和数据处理优先采用可组合的 SQL 方案,避免把业务逻辑固化在单个页面或固定查询中。 +- 各模块应预留 SQL 扩展入口,支持传入 SQL、动态拼接 `where` 条件、选择字段、排序、分页、关联查询和组合查询。 +- 通用配置无法覆盖业务场景时,优先通过 SQL 视图、查询 SQL、条件片段或数据处理 SQL 扩展,不要立即新增重复表或重复模块。 +- SQL 参数、查询字段、排序字段和关联关系应由明确的模块元数据描述,使 AI 能根据模块定义生成和组合 SQL。 +- SQL 配置只描述可复用的数据处理能力,具体运行参数由调用方传入。 + +### 1.10 索引和约束规范 + +- 主键、关联字段、常用查询条件、排序字段和唯一业务键应根据实际查询场景建立索引。 +- 索引应服务于明确的查询或关联场景,避免无依据地为每个字段单独建立索引。 +- 数据库层只保留主键、联合主键、索引和必要的唯一约束;唯一约束可用于保证业务键或组合业务键不重复。 +- 不增加 `CHECK`、触发器等业务规则约束。字段取值、字段组合、状态流转、关联完整性和其他业务校验统一交给业务层处理。 +- 非空和默认值属于字段定义的一部分,应根据业务语义设置,不使用数据库约束代替业务逻辑。 + +## 2. 总体设计 + +新版结构强化“模块”概念,同时避免为字段布局和表单结构建立过多关系表。 + +```text +s_module +├── module 业务模块,可包含下级 module、data 或 virtual +├── data 数据模块,对应表、视图或查询 SQL,拥有字段定义 +└── virtual 虚拟模块,不要求对应标准数据表,可由 SQL 或业务实现驱动 +``` + +“子模块”不是模块类型,由 `b_parent_id` 表达任意层级的父子关系。 + +核心存储原则: + +| 内容 | 存储方式 | 说明 | +| --- | --- | --- | +| 模块定义 | 关系表 | 系统核心业务结构 | +| 字段定义 | 关系表 | 参与 SQL 生成、保存和数据处理 | +| 列表布局 | JSON | 支持列分组、冻结、格式化和扩展属性 | +| 表单布局 | JSON | 支持分组、栅格、选项卡和条件显示 | +| 查询配置 | JSON | 支持同字段多条件、条件组和嵌套逻辑 | +| 字段分组 | 布局 JSON 节点 | 不再建立独立字段分组表 | +| 用户偏好 | JSON 偏离量 | 只保存用户相对默认配置的修改 | +| 多语言 | 关系表 | 支持按语言查询、导入、导出和回退 | +| 用户与部门 | 关系表 | 用户归属部门,部门支持层级树 | +| 权限 | 关系表 | 见第 14 节,不与界面布局混合 | + +## 3. 用户与部门 + +### 3.1 用户 ```sql -- 用户表 create table dbo.b_user ( b_id varchar(50) not null primary key, -- 用户编码(业务键主键) b_name nvarchar(100) null, -- 用户名 + b_dept_id varchar(50) null, -- 所属部门编码 b_bz nvarchar(400) null, -- 备注 - b_password nvarchar(255) not null, -- 密码 + b_password nvarchar(255) not null, -- 密码(明文保存) b_canuse tinyint not null default 1, -- 是否启用(0/1) - b_logintime tinyint not null default 0 -- 登录次数 + b_logincount int not null default 0, -- 登录次数 + b_created_by varchar(50) null, -- 创建人 + b_created_at datetime2 null, -- 创建时间 + b_updated_by varchar(50) null, -- 最后修改人 + b_updated_at datetime2 null -- 最后修改时间 ); + +create index ix_b_user_dept + on dbo.b_user (b_dept_id, b_canuse, b_id); ``` -## 2. 模块 +### 3.2 部门 ```sql --- 模块配置表 +-- 部门表 +create table dbo.b_dept ( + b_id varchar(50) not null primary key, -- 部门编码(业务键主键) + b_parent_id varchar(50) null, -- 父部门编码,NULL 表示根节点 + b_depth int not null default 0, -- 树层级,根节点为 0 + b_path varchar(1000) not null, -- 根到当前部门的路径,例如 /cn/east/ + b_name nvarchar(200) not null, -- 部门名称 + b_i18n varchar(150) null, -- 多语言资源键 + b_canuse tinyint not null default 1, -- 是否启用(0/1) + b_xh int not null default 0, -- 同级显示顺序 + b_bz nvarchar(2000) null, -- 备注 + b_created_by varchar(50) null, -- 创建人 + b_created_at datetime2 null, -- 创建时间 + b_updated_by varchar(50) null, -- 最后修改人 + b_updated_at datetime2 null -- 最后修改时间 +); + +create index ix_b_dept_parent + on dbo.b_dept (b_parent_id, b_canuse, b_depth, b_xh, b_id); + +create index ix_b_dept_path + on dbo.b_dept (b_path, b_canuse, b_id); +``` + +部门树使用 1.7 的树结构规范,`b_parent_id` 是权威关系,`b_depth`、`b_path` 是派生字段;`b_path` 的节点编码前缀用于查询下级部门。部门编码创建后原则上不可修改。`b_user.b_dept_id` 指向 `b_dept.b_id`,数据范围中的 `dept`、`dept_tree` 依赖该归属关系。 + +## 4. 模块 + +### 4.1 模块定义 + +```sql +-- 模块定义表 create table dbo.s_module ( b_id varchar(50) not null primary key, -- 模块编码(业务键主键) - b_parent_id varchar(50) null, -- 父级模块编码,NULL 表示根节点 + b_parent_id varchar(50) null, -- 父模块编码,NULL 表示根节点 + b_depth int not null default 0, -- 树层级,根节点为 0 + b_path varchar(1000) not null, -- 根到当前节点的路径,例如 /sea/container/ b_name nvarchar(200) not null, -- 模块名称 b_i18n varchar(150) null, -- 多语言资源键 - b_module_type varchar(20) not null, -- 模块类型 - b_viewtable varchar(128) null, -- 视图表名(列表查询主表) - b_savetable varchar(128) null, -- 保存表名(写入目标表) - b_keyfield varchar(50) null, -- 主键字段 - b_orderfield varchar(500) null, -- 排序字段 - b_delete_policy varchar(20) null, -- 删除策略 + b_module_type varchar(20) not null, -- module / data / virtual + b_view_table varchar(128) null, -- 默认查询表或视图 + b_save_table varchar(128) null, -- 默认保存目标表 + b_key_field varchar(50) null, -- 主键字段 + b_order_sql varchar(500) null, -- 默认排序 SQL + b_query_sql nvarchar(max) null, -- 可选查询 SQL 或 SQL 模板 + b_config_json nvarchar(max) null, -- 低频模块扩展配置 b_canuse tinyint not null default 1, -- 是否启用(0/1) - b_xh int not null default 0, -- 显示顺序 - b_bz nvarchar(2000) null, -- 备注 - b_edit_mode varchar(10) null -- 编辑模式 + b_xh int not null default 0, -- 同级显示顺序 + b_bz nvarchar(2000) null -- 备注 ); create index ix_s_module_parent - on dbo.s_module (b_parent_id, b_xh, b_canuse, b_id); + on dbo.s_module (b_parent_id, b_canuse, b_depth, b_xh, b_id); -create index ix_s_module_order - on dbo.s_module (b_xh, b_id); +create index ix_s_module_path + on dbo.s_module (b_path, b_canuse, b_id); -create index ix_s_module_savetable - on dbo.s_module (b_savetable); +create index ix_s_module_type + on dbo.s_module (b_module_type, b_canuse, b_xh, b_id); + +create index ix_s_module_view_table + on dbo.s_module (b_view_table, b_canuse, b_id); + +create index ix_s_module_save_table + on dbo.s_module (b_save_table, b_canuse, b_id); ``` -## 3. 字段和字段布局 +### 4.2 模块类型约定 + +#### `module` + +- 表示业务模块或业务域,例如海运、空运、财务、客户管理。 +- 可以包含下级 `module`、`data` 和 `virtual`。 +- 默认不配置字段、查询表、保存表和主键字段。 +- 可作为模块级配置、统计、导航上下文和权限继承的边界。 + +#### `data` + +- 表示具体数据对象,例如海运主单、集装箱、费用明细。 +- 必须配置 `b_view_table` 或 `b_query_sql` 作为查询来源。 +- 可以配置 `b_save_table` 作为默认保存目标。 +- `b_key_field` 表示该数据对象的主键字段;没有稳定主键的查询数据可以为空。 +- 字段定义保存在 `s_field`,界面配置保存在 `s_module_schema`。 +- 主子表和引用关系通过 `s_relation` 表达,不使用模块树表达。 + +#### `virtual` + +- 表示不适合使用标准数据表和通用 CRUD 表达的业务能力。 +- 可以由查询 SQL、组合 SQL、专用接口或业务处理器实现。 +- 可以拥有字段定义和界面配置,但不强制配置 `b_save_table`。 +- 没有标准保存表时,保存和业务动作由 SQL 或业务处理器实现。 + +模块类型、父子组合、字段适用规则和 SQL 配置的完整性由业务层校验,不建立 `CHECK` 约束。 + +### 4.3 模块树与数据关系边界 + +- `b_parent_id`、`b_depth`、`b_path` 共同构成模块树,其中 `b_parent_id` 是权威关系,后两者是冗余查询字段。 +- `b_path` 使用 `/` 包裹节点编码,例如 `/sea/`、`/sea/container/`;查询模块子树时使用路径前缀匹配。 +- `module` 可以包含下级 `module`、`data` 和 `virtual`;`data` 和 `virtual` 默认作为叶子节点。 +- 新增或移动模块时,业务层必须在同一事务中更新当前模块及全部后代的 `b_depth` 和 `b_path`。 +- 不允许模块指向自身或形成循环;`b_id` 创建后原则上不可修改;这些规则由业务层校验。 +- `b_depth`、`b_path` 与 `b_parent_id` 不一致时,以 `b_parent_id` 为准并重建冗余字段。 +- 数据之间的主子表、引用和多对多关系统一由 `s_relation` 表达。 +- 模块级扩展配置使用 `b_config_json`,只保存低频、非核心的扩展属性,不重复保存字段定义、权限或界面布局。 + +### 4.4 查询来源优先级 + +数据加载时按以下规则确定默认查询来源: + +1. 配置 `b_query_sql` 时,使用查询 SQL 或 SQL 模板; +2. 未配置 `b_query_sql` 时,使用 `b_view_table`; +3. 两者均未配置时,由虚拟模块的业务实现负责提供数据; +4. 调用方传入的筛选、字段、排序和分页配置在默认查询来源上继续组合。 + +保存时按以下规则确定默认保存方式: + +1. `data` 模块配置 `b_save_table` 时,默认写入该表; +2. 未配置 `b_save_table` 或需要特殊处理时,由业务层使用模块对应的 SQL 保存逻辑或业务处理器负责保存; +3. `virtual` 模块不要求具备通用保存能力。 + +## 5. 字段定义 + +字段定义保持关系表结构。数据库表或视图是物理字段结构的来源,`s_field` 只保存模块使用字段时所需的业务元数据。 + +当前阶段不在 `s_field` 中保存数据库类型、长度、精度、小数位等物理结构信息。保存器或 SQL 生成器需要这些信息时,直接读取数据库表或视图的实际结构;后续确定元数据同步方案后再补充持久化字段。 ```sql -- 字段定义表 create table dbo.s_field ( - b_module_id varchar(50) not null, -- 所属模块编码 - b_field varchar(50) not null, -- 字段名 + b_module_id varchar(50) not null, -- 所属 data / virtual 模块编码 + b_field varchar(50) not null, -- 模块字段编码或查询结果别名 b_name nvarchar(200) not null, -- 字段名称 b_i18n varchar(150) null, -- 多语言资源键 - b_type varchar(30) not null, -- 字段类型(业务类型) - b_dbtype varchar(30) not null, -- 数据库类型 - b_length int null, -- 长度 - b_precision int null, -- 精度 - b_scale int null, -- 小数位 - b_nullable tinyint not null default 1, -- 是否可空(0/1) - b_default_value nvarchar(1000) null, -- 默认值 - b_db_default nvarchar(1000) null, -- 数据库默认值 - b_writemode varchar(20) not null default 'direct', -- 写入模式 - b_options nvarchar(max) null, -- 选项(下拉等) + b_type varchar(30) not null, -- 业务类型 b_canuse tinyint not null default 1, -- 是否启用(0/1) - b_xh int not null default 0, -- 显示顺序(同步字段时按视图列序重排,10 起步) + b_xh int not null default 0, -- 默认顺序 primary key (b_module_id, b_field) ); create index ix_s_field_module_order - on dbo.s_field (b_module_id, b_xh, b_field); - --- 字段分组表 -create table dbo.s_field_group ( - b_id varchar(50) not null, -- 分组编码 - b_module_id varchar(50) not null, -- 所属模块编码 - b_parent_id varchar(50) null, -- 父级分组编码,NULL 表示根节点 - b_title nvarchar(200) not null, -- 分组标题 - b_i18n varchar(150) null, -- 多语言资源键 - b_xh int not null default 0, -- 显示顺序 - b_canuse tinyint not null default 1, -- 是否启用(0/1) - b_colspan int not null default 48, -- 列跨度 - b_layout_direction varchar(10) null default 'vertical', -- 布局方向(vertical/horizontal) - primary key (b_module_id, b_id) -); - -create index ix_s_field_group_parent - on dbo.s_field_group (b_module_id, b_parent_id, b_xh, b_id); - -create index ix_s_field_group_order - on dbo.s_field_group (b_module_id, b_xh, b_id); - --- 列表视图字段配置表 -create table dbo.s_field_view ( - b_module_id varchar(50) not null, -- 所属模块编码 - b_field varchar(50) not null, -- 字段名 - b_group_id varchar(50) null, -- 所属分组(同模块 s_field_group.b_id) - b_visible tinyint not null default 1, -- 是否展示(0/1) - b_xh int not null default 0, -- 显示顺序 - b_width int null, -- 列宽 - primary key (b_module_id, b_field) -); - -create index ix_s_field_view_order - on dbo.s_field_view (b_module_id, b_xh, b_field); - --- 编辑字段配置表 -create table dbo.s_field_edit ( - b_module_id varchar(50) not null, -- 所属模块编码 - b_field varchar(50) not null, -- 字段名 - b_group_id varchar(50) null, -- 所属分组(同模块 s_field_group.b_id) - b_visible tinyint not null default 1, -- 是否展示(0/1) - b_required tinyint not null default 0, -- 是否必填(0/1) - b_readonly tinyint not null default 0, -- 是否只读(0/1) - b_disabled tinyint not null default 0, -- 是否禁用(0/1) - b_xh int not null default 0, -- 显示顺序 - b_width int null, -- 宽度 - b_placeholder nvarchar(400) null, -- 占位提示 - b_colspan int not null default 12, -- 列跨度 - b_height int null, -- 高度 - primary key (b_module_id, b_field) -); - -create index ix_s_field_edit_order - on dbo.s_field_edit (b_module_id, b_xh, b_field); - --- 查询字段配置表 -create table dbo.s_field_query ( - b_module_id varchar(50) not null, -- 所属模块编码 - b_field varchar(50) not null, -- 字段名 - b_component varchar(30) null, -- 查询组件 - b_condition_no int not null default 1, -- 条件序号 - b_operator varchar(20) not null, -- 操作符 - b_default_value nvarchar(1000) null, -- 默认值 - b_canuse tinyint not null default 1, -- 是否启用(0/1) - b_xh int not null default 0, -- 显示顺序 - primary key (b_module_id, b_field, b_condition_no) -); - -create index ix_s_field_query_order - on dbo.s_field_query (b_module_id, b_xh, b_field, b_condition_no); + on dbo.s_field (b_module_id, b_canuse, b_xh, b_field); ``` -## 4. 用户个性化 +字段同步约定: + +- `data` 模块可以从 `b_view_table` 或 `b_query_sql` 的结果结构增量同步字段。 +- 同步负责按来源表或视图的列结构对齐字段定义,不覆盖人工维护的字段名称和业务类型。 +- 数据库中已删除的字段不直接物理删除,可由业务层标记为停用并提示人工确认。 +- 计算字段、关联显示字段和虚拟字段可以人工登记,并通过模块查询 SQL 或界面配置 JSON 产生。 +- 字段是否可以直接写入、是否只读以及是否需要自定义保存逻辑,不在 `s_field` 中增加固定模式字段;由表单 JSON、模块保存配置或业务处理器根据具体场景处理。 +- `b_type` 的取值集合由业务层维护,本版不做固定枚举。 + +## 6. 模块界面配置 + +列表布局、表单布局、查询配置和字段分组统一保存为 JSON,不再建立以下独立表: + +```text +s_field_group +s_field_view +s_field_edit +s_field_query +``` ```sql --- 用户字段偏好表 -create table dbo.s_user_field_pref ( - b_user_id varchar(50) not null, -- 用户编码 - b_module_id varchar(50) not null, -- 模块编码 - b_field varchar(50) not null, -- 字段名 - b_mode varchar(10) not null, -- 模式(view / edit) - b_visible tinyint not null default 1, -- 是否展示(0/1) - b_xh int not null default 0, -- 显示顺序 - b_width int null, -- 列宽 - b_updatedatetime datetime2 null, -- 最后更新时间 - primary key (b_user_id, b_module_id, b_field, b_mode) +-- 模块界面配置表 +create table dbo.s_module_schema ( + b_module_id varchar(50) not null, -- 所属 data / virtual 模块编码 + b_schema_type varchar(20) not null, -- view / edit / query + b_schema_json nvarchar(max) not null, -- 完整 JSON 配置 + b_canuse tinyint not null default 1, -- 是否启用(0/1) + b_updated_by varchar(50) null, -- 最后修改人 + b_updated_at datetime2 null, -- 最后修改时间 + primary key (b_module_id, b_schema_type) ); -create index ix_s_user_field_pref_order - on dbo.s_user_field_pref (b_user_id, b_module_id, b_mode, b_xh, b_field); - --- 用户查询字段偏好表 -create table dbo.s_user_query_field_pref ( - b_user_id varchar(50) not null, -- 用户编码 - b_module_id varchar(50) not null, -- 模块编码 - b_field varchar(50) not null, -- 字段名 - b_quick_visible tinyint not null default 1, -- 普通查询收起态是否显示(0/1) - b_xh int not null default 0, -- 显示顺序 - b_updatedatetime datetime2 null, -- 最后更新时间 - primary key (b_user_id, b_module_id, b_field) -); - -create index ix_s_user_query_field_pref_order - on dbo.s_user_query_field_pref (b_user_id, b_module_id, b_xh, b_field); +create index ix_s_module_schema_type + on dbo.s_module_schema ( + b_module_id, + b_schema_type, + b_canuse + ); ``` -## 5. 自动编码 +每个模块默认维护三行配置,分别对应列表、表单和查询: + +```text +view +edit +query +``` + +当前阶段不维护同一类型的多套命名配置。若未来确实需要多套列表、表单或查询方案,再增加配置编码字段,或在 JSON 内增加方案节点。 + +### 6.1 列表配置示例 + +```json +{ + "schemaVersion": 1, + "columns": [ + { + "field": "b_no", + "visible": true, + "width": 140, + "fixed": "left" + }, + { + "type": "group", + "title": "费用信息", + "i18n": "group.cw_fee.amount", + "children": [ + { "field": "mx_amount", "width": 120, "format": "money" }, + { "field": "mx_currency", "width": 90 } + ] + } + ] +} +``` + +### 6.2 表单配置示例 + +```json +{ + "schemaVersion": 1, + "children": [ + { + "type": "group", + "title": "基础信息", + "i18n": "group.sea_main.basic", + "children": [ + { + "type": "row", + "children": [ + { "field": "b_no", "span": 12, "readonly": true }, + { "field": "b_name", "span": 12, "required": true } + ] + } + ] + } + ] +} +``` + +表单 JSON 可以描述字段的展示和交互行为,例如: + +```json +{ + "field": "mx_amount", + "readonly": true, + "visibleWhen": { "field": "mx_type", "operator": "eq", "value": "tax" }, + "formula": "mx_qty * mx_price" +} +``` + +其中 `readonly`、`required`、`disabled`、`visibleWhen`、`defaultValue`、`options`、`format` 和 `formula` 都属于界面配置,不放入 `s_field`。如果计算字段需要作为查询结果参与 SQL、排序或筛选,应在模块的 `b_query_sql` 中定义,而不是依赖表单 JSON。 + +### 6.3 查询配置示例 + +```json +{ + "schemaVersion": 1, + "quick": ["b_no", "b_name", "b_status"], + "groups": [ + { + "logic": "and", + "conditions": [ + { "field": "b_status", "operator": "eq" }, + { + "logic": "or", + "conditions": [ + { "field": "b_no", "operator": "like" }, + { "field": "b_name", "operator": "like" } + ] + } + ] + } + ] +} +``` + +## 7. 用户个性化 + +原有列表字段偏好和查询字段偏好合并为一张表。用户偏好只保存相对模块默认配置的偏离量,不复制完整模块配置。 + +```sql +-- 用户模块偏好表 +create table dbo.s_user_module_pref ( + b_user_id varchar(50) not null, -- 用户编码 + b_module_id varchar(50) not null, -- 模块编码 + b_pref_json nvarchar(max) not null, -- 用户偏离配置 JSON + b_updated_by varchar(50) null, -- 最后修改人 + b_updated_at datetime2 null, -- 最后修改时间 + primary key (b_user_id, b_module_id) +); + +create index ix_s_user_module_pref_module + on dbo.s_user_module_pref (b_module_id, b_user_id); +``` + +偏好示例: + +```json +{ + "schemaVersion": 1, + "view": { + "default": { + "hidden": ["b_bz"], + "order": ["b_no", "b_name", "b_status"], + "width": { + "b_no": 160, + "b_name": 240 + } + } + }, + "query": { + "default": { + "quick": ["b_no", "b_status"] + } + } +} +``` + +偏好 JSON 中的 `view.default`、`query.default` 是当前唯一方案的占位,与第 6 节“当前阶段不维护同一类型的多套命名配置”一致;将来出现多套方案时才用该层级区分。 + +合并规则: + +1. 读取 `s_module_schema` 中的模块默认配置; +2. 读取当前用户的 `s_user_module_pref.b_pref_json`; +3. 按配置类型应用偏离量; +4. 已停用、已删除或无权限的字段不得因用户偏好重新出现; +5. 恢复默认时删除当前模块偏好记录,或删除 JSON 中对应配置片段。 + +## 8. 自动编码 ```sql -- 自动编码表 create table dbo.s_autocode ( - b_module_id varchar(50) not null, -- 模块编码 - b_field varchar(50) not null, -- 字段名 + b_module_id varchar(50) not null, -- data 模块编码 + b_field varchar(50) not null, -- 编码字段 b_prefix nvarchar(200) not null default N'', -- 编码前缀 b_dateformat varchar(30) not null default 'yyyyMM', -- 日期格式 b_separator nvarchar(20) not null default N'-', -- 分隔符 b_seqwidth int not null default 5, -- 序号宽度 b_resettype varchar(10) not null default 'month', -- 重置类型 b_startvalue bigint not null default 1, -- 起始值 - b_currentperiod varchar(8) null, -- 当前周期 + b_currentperiod varchar(20) null, -- 当前周期 b_currentvalue bigint not null default 0, -- 当前值 b_canuse tinyint not null default 1, -- 是否启用(0/1) primary key (b_module_id, b_field) ); ``` -## 6. 模块关系 +编码格式、重置类型和字段适用性由业务层校验。 + +## 9. 模块关系 + +模块树只表达业务模块层级,主子表、引用和多对多关系统一由 `s_relation` 表达。 ```sql -- 模块关系表 create table dbo.s_relation ( - b_module_id varchar(50) not null, -- 源模块编码 - b_target_module_id varchar(50) not null, -- 目标模块编码 - b_relation_type varchar(20) not null, -- 关系类型 - b_field varchar(50) not null, -- 源字段 + b_source_module_id varchar(50) not null, -- 源模块 + b_source_field varchar(50) not null, -- 源字段 + b_target_module_id varchar(50) not null, -- 目标模块 b_target_field varchar(50) not null, -- 目标字段 - b_foreign_side varchar(10) null, -- 外键侧 - b_junction_module_id varchar(50) null, -- 中间表模块编码 - b_junction_field varchar(50) null, -- 中间表字段 - b_junction_target_field varchar(50) null, -- 中间表目标字段 - b_ownership varchar(20) not null default 'reference', -- 归属关系 - b_owner_side varchar(10) null, -- 属主侧 - b_delete_policy varchar(20) null, -- 删除策略 - b_order_module_id varchar(50) null, -- 排序模块编码 - b_order_field varchar(50) null, -- 排序字段 + b_relation_type varchar(20) not null, -- one_to_one / one_to_many / many_to_one b_canuse tinyint not null default 1, -- 是否启用(0/1) b_xh int not null default 0, -- 显示顺序 - primary key (b_module_id, b_field, b_target_module_id, b_target_field) + primary key ( + b_source_module_id, + b_source_field, + b_target_module_id, + b_target_field + ) ); -create index ix_s_relation_module - on dbo.s_relation (b_module_id, b_canuse, b_xh, b_field, b_target_module_id); +create index ix_s_relation_source + on dbo.s_relation (b_source_module_id, b_canuse, b_xh, b_source_field, b_target_module_id); create index ix_s_relation_target - on dbo.s_relation (b_target_module_id); + on dbo.s_relation (b_target_module_id, b_canuse, b_source_module_id); ``` -## 7. 菜单 +关系类型取值由业务层校验;主子表和引用关系都通过"源字段 → 目标字段"这一组映射表达。暂时不提供 `many_to_many`,多对多通过中间表模块加两条 `one_to_many` 关系表达。 + +## 10. 菜单 + +菜单负责导航和页面入口,模块负责业务结构和数据能力。一个菜单可以使用多个 `data` 或 `virtual` 模块,一个模块也可以被多个菜单复用。 ```sql -- 菜单表 create table dbo.s_menu ( b_id varchar(50) not null primary key, -- 菜单编码(业务键主键) - b_parent_id varchar(50) null, -- 父级菜单编码,NULL 表示根节点 + b_parent_id varchar(50) null, -- 父菜单编码,NULL 表示根节点 + b_depth int not null default 0, -- 树层级,根节点为 0 + b_path varchar(1000) not null, -- 根到当前菜单的路径,例如 /system/user/ b_name nvarchar(200) not null, -- 菜单名称 b_i18n varchar(150) null, -- 多语言资源键 - b_menu_type varchar(20) not null, -- 菜单类型 - b_route varchar(200) null, -- 路由 + b_menu_type varchar(20) not null, -- directory / page / external + b_route varchar(500) null, -- 页面路由或外链地址 b_icon varchar(50) null, -- 图标 b_xh int not null default 0, -- 显示顺序 b_canuse tinyint not null default 1 -- 是否启用(0/1) ); create index ix_s_menu_parent - on dbo.s_menu (b_parent_id, b_xh, b_canuse, b_id); + on dbo.s_menu (b_parent_id, b_canuse, b_depth, b_xh, b_id); -create index ix_s_menu_order - on dbo.s_menu (b_xh, b_id); +create index ix_s_menu_path + on dbo.s_menu (b_path, b_canuse, b_id); create index ix_s_menu_route on dbo.s_menu (b_route, b_canuse, b_id); -``` -## 8. 权限 - -权限定义与用户授权分开,但不增加菜单等级或权限计算中间表: - -- `s_power` 是权限点字典,定义系统中可以授予的权限。菜单入口权限使用 `menu.<菜单编码>.access`,模块权限使用 `module.<模块编码>.`;菜单权限仍然通过 `s_power` 定义,不另建菜单权限字典; -- `s_menu_module` 只描述一个业务页面使用哪些模块,不直接授予用户权限;页面与模块的父子授权关系由授权界面统一处理,不在关系表中增加额外的必需标记或权限等级。 -- `s_user_power` 保存用户直接拥有的权限记录,客户授权界面可以维护这张表;菜单页面只是按业务场景组织这些权限,不产生另一套授权来源; -- 同一个模块被多个菜单引用时,页面中展示的是同一个模块权限。模块权限记录只保存一份,撤销某个菜单入口不会自动删除该模块权限。 -- 授权界面按“菜单 → 页面模块 → 模块能力”展示父子层级,使用父子级联勾选和半选状态;级联只是配置交互,最终仍保存为各权限点对应的 `s_user_power` 记录。 - -```sql --- 菜单模块关联表 +-- 菜单使用模块关系表 create table dbo.s_menu_module ( - b_menu_id varchar(50) not null, -- 菜单编码,引用 s_menu.b_id - b_module_id varchar(50) not null, -- 模块编码,引用 s_module.b_id - b_xh int not null default 0, -- 页面内模块顺序 - b_canuse tinyint not null default 1, -- 是否启用(0/1) + b_menu_id varchar(50) not null, -- 菜单编码 + b_module_id varchar(50) not null, -- 页面实际使用的 data / virtual 模块编码 + b_xh int not null default 0, -- 页面内模块顺序 + b_canuse tinyint not null default 1, -- 是否启用(0/1) primary key (b_menu_id, b_module_id) ); -create index IX_s_menu_module_module +create index ix_s_menu_module_module on dbo.s_menu_module (b_module_id, b_canuse, b_menu_id); ``` -```sql --- 权限表 -create table dbo.s_power ( - b_id varchar(100) not null, -- 权限编码即主键 - b_name nvarchar(200) not null, -- 权限名称 - b_i18n varchar(150) null, -- 多语言资源键 - b_power_type varchar(20) not null, -- 权限类型(page / menu / module / action / report) - b_owner_type varchar(20) not null, -- 归属类型(menu / module) - b_owner_id varchar(50) not null, -- 归属对象编码 - b_operation varchar(30) not null, -- 权限操作(access / read / create / update / delete / export / audit...) - b_canuse tinyint not null default 1, -- 是否启用(0/1) - b_xh int not null default 0, -- 显示顺序 - primary key (b_id) -); +菜单树同样使用 `b_parent_id` 作为权威关系,并冗余维护 `b_depth`、`b_path`。菜单节点新增、移动和删除时,由业务层同步处理后代节点路径和层级;菜单编码创建后原则上不可修改。 -create index ix_s_power_owner - on dbo.s_power (b_power_type, b_owner_type, b_owner_id, b_operation, b_canuse); -``` +业务模块上下文可以根据所绑定数据模块的父级模块获得;暂不增加独立的菜单上下文字段。 -```sql --- 用户权限表 -create table dbo.s_user_power ( - b_user_id varchar(50) not null, -- 用户编码 - b_power_id varchar(100) not null, -- 权限编码(s_power.b_id) - b_canuse tinyint not null default 1, -- 是否启用(0/1) - b_updatedatetime datetime2 null, -- 最后更新时间 - primary key (b_user_id, b_power_id) -); +菜单权限(`menu.*`)只控制页面入口;页面内各 `data`、`virtual` 模块的操作权限按模块编码独立判定,一个菜单绑定多个模块时不做权限合并。 -create index ix_s_user_power_user - on dbo.s_user_power (b_user_id, b_canuse, b_power_id); -``` +## 11. 多语言 -`s_user_power` 的授权约定: - -- 菜单入口权限由 `s_power` 定义为 `menu.<菜单编码>.access`,授权时直接写入对应用户的 `s_user_power`; -- 模块查看、新增、修改、删除、导出及业务动作同样由 `s_power` 定义,授权时直接写入对应用户的 `s_user_power`; -- 客户界面可以在菜单页面下同时勾选入口权限和页面所用模块的权限,但保存的仍是同一张 `s_user_power` 表; -- 页面只是配置展示层,后端执行时按权限编码和 `s_menu_module` 校验当前菜单使用的模块;角色表暂不在本阶段引入。 - -```sql --- 用户字段权限表 -create table dbo.s_user_field_permission ( - b_user_id varchar(50) not null, -- 用户编码 - b_module_id varchar(50) not null, -- 模块编码 - b_field varchar(128) not null, -- 字段名 - b_access_mode varchar(10) not null, -- 访问模式(hidden / view / edit) - b_allow_query tinyint not null default 0, -- 是否允许查询(0/1) - b_allow_export tinyint not null default 0, -- 是否允许导出(0/1) - b_canuse tinyint not null default 1, -- 是否启用(0/1) - b_updatedatetime datetime2 null, -- 最后更新时间 - primary key (b_user_id, b_module_id, b_field) -); - -create index ix_s_user_field_permission_module - on dbo.s_user_field_permission (b_user_id, b_module_id, b_canuse, b_field); -``` - -```sql --- 用户数据范围表 -create table dbo.s_user_data_scope ( - b_user_id varchar(50) not null, -- 用户编码 - b_module_id varchar(50) not null, -- 模块编码 - b_operation varchar(30) not null, -- 操作(* / read / create / update / delete / export) - b_scope_level varchar(10) not null default 'default', -- 范围级别(default / override) - b_scope_no int not null default 1, -- 范围序号 - b_scope_type varchar(30) not null, -- 范围类型(own / department / department_tree / all / custom) - b_scope_field varchar(50) null, -- 业务数据中的归属用户/部门字段 - b_scope_value varchar(100) null, -- custom 时每个用户/部门值一行 - b_updatedatetime datetime2 null, -- 最后更新时间 - primary key (b_user_id, b_module_id, b_operation, b_scope_level, b_scope_no), - constraint ck_s_user_data_scope_level check (b_scope_level in ('default','override')), - constraint ck_s_user_data_scope_operation check ( - (b_scope_level = 'default' and b_operation = '*') - or (b_scope_level = 'override' and b_operation in ('read','create','update','delete','export')) - ) -); - -create index ix_s_user_data_scope_module - on dbo.s_user_data_scope (b_user_id, b_module_id, b_operation, b_scope_level, b_scope_type); -``` - -## 9. 多语言 +多语言继续使用关系表。`s_i18n_type` 保存系统支持的语言区域(例如 `zh-CN`、`en`),模块、菜单、字段和 JSON 布局节点只保存资源键,具体语言文本统一保存到 `s_i18n`。资源来源由资源键命名空间表达,例如 `module.sea.name`、`menu.main.title`、`field.sea.b_no`;不在翻译表中重复保存多态来源字段。 ```sql -- 多语言类型表 create table dbo.s_i18n_type ( - b_id varchar(20) not null primary key, -- 类型编码 - b_name nvarchar(200) not null, -- 类型名称 + b_id varchar(20) not null primary key, -- 语言区域编码,例如 zh-CN / en + b_name nvarchar(200) not null, -- 语言名称 b_canuse tinyint not null default 1, -- 是否启用(0/1) b_default tinyint not null default 0, -- 是否默认(0/1) b_xh int not null default 0 -- 显示顺序 @@ -429,134 +596,375 @@ create table dbo.s_i18n_type ( -- 多语言资源表 create table dbo.s_i18n ( - b_id varchar(200) not null primary key, -- 资源编码(b_key + ':' + b_locale) b_key varchar(150) not null, -- 资源键 b_locale varchar(20) not null, -- 语言区域 - b_value nvarchar(1000) not null, -- 值 - b_i18ntype varchar(20) null, -- 多语言类型 + b_value nvarchar(1000) not null, -- 翻译值 b_canuse tinyint not null default 1, -- 是否启用(0/1) - b_module_id varchar(50) null, -- 模块编码 - unique (b_key, b_locale) + primary key (b_key, b_locale) ); -create index ix_s_i18n_module_key - on dbo.s_i18n (b_module_id, b_key, b_locale); - create index ix_s_i18n_locale - on dbo.s_i18n (b_locale, b_canuse); + on dbo.s_i18n (b_locale, b_canuse, b_key); ``` -## 10. 日志 +`s_i18n.b_locale` 的取值应存在于 `s_i18n_type.b_id`,由业务层校验,不建立外键。`b_default = 1` 表示默认语言,默认语言只能有一个有效项,由业务层维护。资源清理按资源键或资源键前缀执行,不依赖 `b_source_id`。 + +资源键命名应保持稳定并体现来源边界,推荐使用以下前缀: + +| 前缀 | 用途 | 示例 | +| --- | --- | --- | +| `module.` | 模块名称或模块业务文案 | `module.sea.name` | +| `menu.` | 菜单名称 | `menu.main.title` | +| `field.` | 字段名称 | `field.sea.b_no` | +| `group.` | 列表/表单布局分组标题 | `group.sea.basic` | +| `action.` | 动作动词(按钮 + 权限共用) | `action.insert` | + +资源键一旦投入使用原则上不可修改;模块、菜单或字段删除时,由业务层根据已知资源键清理对应翻译行。 + +`action.*` 保存通用的动作动词资源键(例如 `action.insert`、`action.update`、`action.audit`),按钮文案和权限点名称共用同一翻译,同一动词只维护一份;动作动词是通用词,不按模块或对象分段。 + +## 12. 日志 ```sql -- 技术日志表 create table dbo.s_log_technical ( - b_id uniqueidentifier not null, -- 日志ID(UUIDv7) - b_user_id varchar(50) null, -- 用户编码(未认证请求时为 NULL) - b_sourcetype varchar(20) null, -- 来源类型 - b_loglevel varchar(16) not null, -- 日志级别 - b_logcategory varchar(64) null, -- 日志分类 - b_operation varchar(100) null, -- 操作 - b_module_id varchar(50) null, -- 模块编码 - b_request_id varchar(64) null, -- 请求ID - b_trace_id varchar(64) null, -- 链路ID - b_server_node varchar(128) null, -- 服务器节点 - b_duration_ms bigint null, -- 耗时(毫秒) - b_result_code varchar(50) null, -- 结果码 - b_error_code varchar(50) null, -- 错误码 - b_error_message nvarchar(4000) null, -- 错误信息 - b_exception_text nvarchar(max) null, -- 异常文本 - b_context_text nvarchar(max) null, -- 上下文文本 - b_occurdatetime datetime2 default sysutcdatetime(), -- 发生时间 - constraint pk_s_log_technical primary key nonclustered (b_id) + b_id uniqueidentifier not null primary key, -- 日志 ID(UUIDv7) + b_user_id varchar(50) null, -- 用户编码 + b_sourcetype varchar(20) null, -- 来源类型 + b_loglevel varchar(16) not null, -- 日志级别 + b_logcategory varchar(64) null, -- 日志分类 + b_operation varchar(100) null, -- 操作 + b_module_id varchar(50) null, -- 模块编码 + b_request_id varchar(64) null, -- 请求 ID + b_trace_id varchar(64) null, -- 链路 ID + b_server_node varchar(128) null, -- 服务节点 + b_duration_ms bigint null, -- 耗时(毫秒) + b_result_code varchar(50) null, -- 结果码 + b_error_code varchar(50) null, -- 错误码 + b_error_message nvarchar(4000) null, -- 错误信息 + b_exception_text nvarchar(max) null, -- 异常文本 + b_context_text nvarchar(max) null, -- 上下文文本 + b_occurdatetime datetime2 default sysutcdatetime() -- 发生时间 ); -- 登录日志表 create table dbo.s_log_login ( - b_id uniqueidentifier not null, -- 日志ID(UUIDv7) - b_user_id varchar(50) null, -- 用户编码(账号不存在时为 NULL) - b_login_type varchar(20) not null, -- 登录类型 - b_login_result tinyint not null default 0, -- 登录结果(0/1) - b_fail_reason nvarchar(400) null, -- 失败原因 - b_ip varchar(50) null, -- IP - b_ip_location nvarchar(400) null, -- IP 归属地 - b_browser varchar(100) null, -- 浏览器 - b_browser_version varchar(50) null, -- 浏览器版本 - b_os varchar(100) null, -- 操作系统 - b_os_version varchar(50) null, -- 系统版本 - b_device_type varchar(20) null, -- 设备类型 - b_user_agent nvarchar(max) null, -- UserAgent - b_session_id varchar(50) null, -- 会话ID - b_request_id varchar(64) null, -- 请求ID - b_trace_id varchar(64) null, -- 链路ID - b_occurdatetime datetime2 default sysutcdatetime(), -- 发生时间 - constraint pk_s_log_login primary key nonclustered (b_id) + b_id uniqueidentifier not null primary key, -- 日志 ID(UUIDv7) + b_user_id varchar(50) null, -- 用户编码 + b_login_type varchar(20) not null, -- 登录类型 + b_login_result tinyint not null default 0, -- 登录结果(0/1) + b_fail_reason nvarchar(400) null, -- 失败原因 + b_ip varchar(50) null, -- IP + b_ip_location nvarchar(400) null, -- IP 归属地 + b_browser varchar(100) null, -- 浏览器 + b_browser_version varchar(50) null, -- 浏览器版本 + b_os varchar(100) null, -- 操作系统 + b_os_version varchar(50) null, -- 系统版本 + b_device_type varchar(20) null, -- 设备类型 + b_user_agent nvarchar(max) null, -- UserAgent + b_session_id varchar(50) null, -- 会话 ID + b_request_id varchar(64) null, -- 请求 ID + b_trace_id varchar(64) null, -- 链路 ID + b_occurdatetime datetime2 default sysutcdatetime() -- 发生时间 ); -- 审计日志表 create table dbo.s_log_audit ( - b_id uniqueidentifier not null, -- 日志ID(UUIDv7) - b_event_code varchar(50) not null, -- 事件编码 - b_module_id varchar(50) null, -- 模块编码 - b_data_id varchar(50) null, -- 数据ID - b_business_no varchar(50) null, -- 业务单号 - b_operation varchar(100) null, -- 操作 - b_operator_id varchar(50) null, -- 操作人ID - b_operator_name nvarchar(200) null, -- 操作人名称 + b_id uniqueidentifier not null primary key, -- 日志 ID(UUIDv7) + b_event_code varchar(50) not null, -- 事件编码 + b_module_id varchar(50) null, -- 模块编码 + b_data_id varchar(50) null, -- 数据 ID + b_business_no varchar(50) null, -- 业务单号 + b_operation varchar(100) null, -- 操作 + b_operator_id varchar(50) null, -- 操作人 ID + b_operator_name nvarchar(200) null, -- 操作人名称 b_source_type varchar(20) not null default 'user', -- 来源类型 b_visibility varchar(20) not null default 'internal', -- 可见性 - b_request_id varchar(64) null, -- 请求ID - b_trace_id varchar(64) null, -- 链路ID - b_payload_text nvarchar(max) null, -- 载荷文本 - b_occurdatetime datetime2 default sysutcdatetime(), -- 发生时间 - constraint pk_s_log_audit primary key nonclustered (b_id) + b_request_id varchar(64) null, -- 请求 ID + b_trace_id varchar(64) null, -- 链路 ID + b_payload_text nvarchar(max) null, -- 载荷文本 + b_occurdatetime datetime2 default sysutcdatetime() -- 发生时间 ); -- 审计字段明细表 create table dbo.s_log_audit_field ( - b_id uniqueidentifier not null, -- 日志ID(UUIDv7) - b_event_id uniqueidentifier not null, -- 审计事件ID - b_module_id varchar(50) null, -- 模块编码 - b_data_id varchar(50) null, -- 数据ID - b_field varchar(50) not null, -- 字段名 - b_field_label nvarchar(200) null, -- 字段标签 - b_before_value nvarchar(max) null, -- 变更前值 - b_after_value nvarchar(max) null, -- 变更后值 - b_before_display nvarchar(1000) null, -- 变更前显示值 - b_after_display nvarchar(1000) null, -- 变更后显示值 - b_visibility varchar(20) not null default 'internal', -- 可见性 - constraint pk_s_log_audit_field primary key nonclustered (b_id) + b_id uniqueidentifier not null primary key, -- 日志 ID(UUIDv7) + b_event_id uniqueidentifier not null, -- 审计事件 ID + b_module_id varchar(50) null, -- 模块编码 + b_data_id varchar(50) null, -- 数据 ID + b_field varchar(50) not null, -- 字段编码 + b_field_label nvarchar(200) null, -- 字段名称 + b_before_value nvarchar(max) null, -- 变更前值 + b_after_value nvarchar(max) null, -- 变更后值 + b_before_display nvarchar(1000) null, -- 变更前显示值 + b_after_display nvarchar(1000) null, -- 变更后显示值 + b_visibility varchar(20) not null default 'internal' -- 可见性 ); -create clustered index cx_s_log_technical_occur +create index ix_s_log_technical_occur on dbo.s_log_technical (b_occurdatetime, b_id); -create clustered index cx_s_log_login_occur - on dbo.s_log_login (b_occurdatetime, b_id); - -create clustered index cx_s_log_audit_occur - on dbo.s_log_audit (b_occurdatetime, b_id); - -create clustered index cx_s_log_audit_field_event - on dbo.s_log_audit_field (b_event_id, b_id); - create index ix_s_log_technical_trace on dbo.s_log_technical (b_trace_id, b_occurdatetime); +create index ix_s_log_login_occur + on dbo.s_log_login (b_occurdatetime, b_id); + create index ix_s_log_login_user on dbo.s_log_login (b_user_id, b_occurdatetime); +create index ix_s_log_audit_occur + on dbo.s_log_audit (b_occurdatetime, b_id); + create index ix_s_log_audit_module on dbo.s_log_audit (b_module_id, b_data_id, b_occurdatetime); + +create index ix_s_log_audit_field_event + on dbo.s_log_audit_field (b_event_id, b_id); ``` -## 11. 业务表主键约定 +审计日志中的 `b_data_id` 统一使用 `varchar(50)` 保存业务主键:业务表使用 `bigint` 雪花 ID 时按字符串写入,不额外记录主键原始类型;用户、模块等 `varchar(50)` 主键直接原样保存。 + +## 13. 业务表主键约定 + +业务表使用应用层生成的雪花 ID。需要跨系统公开、离线生成或按时间有序的标识时,可以单独评估 UUIDv7,但同一业务域内应保持一致。 ```sql -- 业务表示例 create table dbo.b_example ( b_id bigint not null primary key, -- 主键(应用层雪花 ID) - b_inputuser_id varchar(50) null, -- 录入用户ID + b_inputuser_id varchar(50) null, -- 录入用户 ID b_inputdatetime datetime2 default sysutcdatetime() -- 录入时间 ); ``` + +## 14. 权限设计 + +权限部分只保留四张表,分别回答四个问题:"系统有哪些权限点""用户拥有哪些权限点""用户对字段能做什么""用户能看到哪些数据"。 + +| 表 | 职责 | +| --- | --- | +| `s_power` | 定义系统有哪些权限点(菜单、模块、动作) | +| `s_user_power` | 定义用户拥有哪几个权限点 | +| `s_user_field_power` | 定义用户对字段能做什么 | +| `s_user_data_power` | 定义用户能看到/操作哪些数据 | + +边界约定: + +- 菜单、模块和动作权限统一由 `s_power` + `s_user_power` 表达,不按权限类型拆分独立表; +- 字段权限是"用户 + 模块 + 字段"的能力矩阵,不展开成 `field.xxx.read`、`field.xxx.edit` 这类权限点; +- 数据范围由 `s_user_data_power` 单独表达,不写入 `s_power`; +- 当前阶段采用**用户直接授权**,不引入角色、权限模板和用户组;授权记录只表达"拥有",不做拒绝授权;后续如需角色继承或权限模板,可在不改变本表语义的前提下扩展来源字段或另建角色授权表; +- 权限的加载、合并、继承、条件生成和 SQL 拼接由业务层权限引擎处理,不建立外键、触发器或数据库约束。 + +权限判定的整体顺序: + +```text +用户 + ├── 菜单权限 s_user_power (menu.*) → 能否进入页面 + ├── 模块权限 s_user_power (module.*) → 能否使用该模块(访问模块数据) + ├── 动作权限 s_user_power (action.*) → 能否审核 / 反审 / 核销 / 结算 + ├── 字段权限 s_user_field_power → 哪些字段能看 / 编辑 / 查询 / 导出 + └── 数据范围 s_user_data_power → 哪些数据行可操作 +``` + +### 14.1 权限点定义 + +```sql +create table dbo.s_power ( + b_id varchar(150) not null primary key, -- 权限编码,如 module.sea、menu.sea_list + b_name nvarchar(200) not null, + b_i18n varchar(150) null, -- 多语言资源键 + b_type varchar(20) not null, -- menu / module / action(权限点性质) + b_object_type varchar(20) not null, -- menu / module(挂在谁身上) + b_object_id varchar(50) not null, -- s_menu.b_id 或 s_module.b_id + b_action varchar(30) not null, -- access(menu / module 使用权限)或具体业务动作(action) + b_canuse tinyint not null default 1, + b_xh int not null default 0, + b_bz nvarchar(500) null +); + +create index ix_s_power_type + on dbo.s_power (b_type, b_canuse, b_xh, b_id); + +create index ix_s_power_object + on dbo.s_power (b_object_type, b_object_id, b_canuse, b_action, b_id); + +create unique index ux_s_power_object_action + on dbo.s_power (b_object_type, b_object_id, b_action); +``` + +`b_type` 只保留三类。权限编码分段命名:`menu`、`module` 为 `<类型>.<对象编码>` 两段,表达"能否使用";`action` 为 `<类型>.<对象编码>.<动作>` 三段: + +| b_type | 含义 | 示例 | +| --- | --- | --- | +| `menu` | 菜单/页面访问权限 | `menu.sea_list` | +| `module` | 模块使用权限 | `module.sea` | +| `action` | 业务动作权限 | `action.sea.audit` | + +```text +menu.sea_list +menu.sea_edit + +module.sea + +action.sea.audit +action.sea.unaudit +``` + +权限编码创建后原则上不可修改;编码分段规则和 `b_type` 取值由业务层校验。字段权限和数据范围不进入 `s_power`。 + +`s_power.b_i18n` 保存权限点名称的多语言资源键,全部复用既有资源键命名空间,不设独立的 `power.*` 前缀。`action` 类型权限点使用通用动作动词资源键,例如 `action.insert`、`action.update`、`action.audit`,与按钮文案共用同一翻译;`menu`、`module` 类型权限点的名称与对应菜单、模块名称一致,直接复用 `menu.*`、`module.*` 名称资源键,不再单独保存权限点名称翻译。资源键的稳定性、清理和回退规则见第 11 节。 + +### 14.2 用户功能权限 + +```sql +-- 用户权限表 +create table dbo.s_user_power ( + b_user_id varchar(50) not null, -- 用户编码 + b_power_id varchar(150) not null, -- 权限编码 + b_canuse tinyint not null default 1, -- 是否启用(0/1) + b_created_by varchar(50) null, -- 创建人 + b_created_at datetime2 null, -- 创建时间 + b_updated_by varchar(50) null, -- 最后修改人 + b_updated_at datetime2 null, -- 最后修改时间 + primary key (b_user_id, b_power_id) +); + +create index ix_s_user_power_power + on dbo.s_user_power (b_power_id, b_user_id); +``` + +示例: + +| b_user_id | b_power_id | b_canuse | +| --- | --- | --- | +| u001 | `menu.sea_list` | 1 | +| u001 | `module.sea` | 1 | +| u001 | `action.sea.audit` | 1 | + +判定规则: + +1. **无记录即无权限**(白名单):`s_user_power` 中不存在该用户该权限点的记录时视为无权限; +2. `b_canuse = 0` 或对应 `s_power.b_canuse = 0` 时,该授权不生效。 + +### 14.3 用户字段权限 + +字段权限是能力矩阵,直接以"用户 + 模块 + 字段"记录,不拆分为多个权限点。 + +```sql +-- 用户字段权限表 +create table dbo.s_user_field_power ( + b_user_id varchar(50) not null, -- 用户编码 + b_module_id varchar(50) not null, -- 模块编码 + b_field_id varchar(50) not null, -- 字段编码,对应 s_field.b_field + b_view tinyint not null default 1, -- 查看(0/1) + b_edit tinyint not null default 0, -- 编辑(0/1) + b_query tinyint not null default 1, -- 查询(0/1) + b_export tinyint not null default 1, -- 导出(0/1) + b_canuse tinyint not null default 1, -- 是否启用(0/1) + b_created_by varchar(50) null, -- 创建人 + b_created_at datetime2 null, -- 创建时间 + b_updated_by varchar(50) null, -- 最后修改人 + b_updated_at datetime2 null, -- 最后修改时间 + primary key (b_user_id, b_module_id, b_field_id) +); + +create index ix_s_user_field_power_module + on dbo.s_user_field_power (b_module_id, b_user_id, b_field_id); +``` + +示例: + +| 用户 | 模块 | 字段 | view | edit | query | export | +| --- | --- | --- | --- | --- | --- | --- | +| u001 | sea | `b_amount` | 1 | 1 | 1 | 1 | +| u001 | sea | `b_cost` | 1 | 0 | 0 | 0 | +| u001 | sea | `b_profit` | 0 | 0 | 0 | 0 | + +判定规则: + +1. **无记录时取模块默认行为**:可查看、可查询、可导出,不可编辑; +2. 有记录时按 `b_view`、`b_edit`、`b_query`、`b_export` 逐项取值,四项相互独立; +3. `b_view = 0` 的字段在列表、表单、查询和导出中统一移除,且**不得因用户偏好重新出现**(与第 7 节偏好合并规则一致); +4. `b_query = 0` 的字段不出现在查询配置和快捷搜索中,也不得作为调用方传入的筛选条件。 + +### 14.4 用户数据范围 + +```sql +-- 用户数据范围表 +create table dbo.s_user_data_power ( + b_user_id varchar(50) not null, -- 用户编码 + b_module_id varchar(50) not null, -- 模块编码 + b_operation varchar(30) not null default '*', -- 操作,* 表示全部操作 + b_scope_type varchar(20) not null, -- all / self / dept / dept_tree / custom + b_scope_field varchar(50) null, -- 模块中用于范围判断的字段 + b_condition_sql nvarchar(max) null, -- 自定义条件 SQL(custom 时使用) + b_xh int not null default 0, -- 同操作内规则顺序 + b_canuse tinyint not null default 1, -- 是否启用(0/1) + b_created_by varchar(50) null, -- 创建人 + b_created_at datetime2 null, -- 创建时间 + b_updated_by varchar(50) null, -- 最后修改人 + b_updated_at datetime2 null, -- 最后修改时间 + primary key (b_user_id, b_module_id, b_operation, b_xh) +); + +create index ix_s_user_data_power_module + on dbo.s_user_data_power (b_module_id, b_user_id, b_operation, b_xh); +``` + +`b_scope_type` 先固定五类: + +| 类型 | 含义 | 说明 | +| --- | --- | --- | +| `all` | 全部数据 | 不追加范围条件 | +| `self` | 仅本人 | `b_scope_field = 当前用户编码` | +| `dept` | 本部门 | `b_scope_field = b_user.b_dept_id` | +| `dept_tree` | 本部门及下级部门 | 用当前用户部门的 `b_dept.b_path` 前缀匹配出部门集合,再过滤 `b_scope_field` | +| `custom` | 自定义规则 | 由 `b_condition_sql` 提供条件 | + +`b_operation` 取值:`read`、`create`、`update`、`delete`、`export` 为通用模块操作;业务动作使用对应的 `action` 权限编码(例如 `action.sea.audit`);`*` 表示对该模块的所有操作生效,精确值优先于 `*`。 + +示例: + +```text +b_user_id = u001 +b_module_id = sea +b_operation = read +b_scope_type = self +b_scope_field = b_inputuser_id +``` + +表示 u001 查看海运单时只能看 `b_inputuser_id = 当前用户` 的数据。 + +`b_scope_field` 表示**当前模块中用于执行数据范围判断的字段**(例如 `sea.b_inputuser_id`、`sea.b_department_id`),不是用户表的字段。`self`、`dept`、`dept_tree` 必须配置该字段;`b_scope_field` 应是模块 `s_field` 中已登记且可直接参与 SQL 过滤的字段。`dept`、`dept_tree` 依赖 `b_user.b_dept_id` 与 `b_dept` 部门树,用户未归属部门时该规则不生效。 + +判定规则: + +1. 按 `b_operation` **精确匹配优先**,未命中时使用 `b_operation = '*'` 的规则; +2. 同一用户、同一模块、同一操作的**多条生效规则之间按 OR 组合**,最终整体再与其他来源条件按 AND 组合; +3. `b_canuse = 0` 的规则不参与组合;无任何生效规则时,视为该模块无数据范围限制。 + +### 14.5 `b_condition_sql` 的使用边界 + +- 该字段**保留用于复杂场景的出口**,只由管理员配置,不开放给普通用户直接编写 SQL; +- 运行时必须由后端权限引擎处理参数替换(例如 `{userId}`、`{deptId}`),并以预编译参数或受控片段方式拼装,**不得直接拼接前端传入内容**; +- 仅当 `b_scope_type = 'custom'` 时生效; +- 后续如规则体系成熟,优先将 `b_condition_sql` 升级为结构化条件(字段 / 操作符 / 值的 AST),避免让 SQL 成为权限系统的核心表达方式。 + +示例: + +```sql +b_scope_type = 'custom' +b_condition_sql = 'b_status = ''正常''' + +b_scope_type = 'custom' +b_condition_sql = 'b_salesman_id = {userId}' +``` + +### 14.6 与模块、菜单和关系的关系 + +- `s_power` 中的 `menu.*` 对应 `s_menu`,`module.*` 对应 `s_module`,`action.*` 对应业务动作;权限点编码与对象编码的对应关系由业务层维护,不建立外键; +- `s_user_field_power.b_module_id`、`s_user_data_power.b_module_id` 指向 `s_module.b_id`,字段还需在 `s_field` 中存在; +- 数据范围只作用于本表的 `b_user_id + b_module_id + b_operation`;是否向关联模块延伸(例如子表、明细模块)由权限引擎根据业务语义决定,不依赖 `s_relation` 自动传播; +- 权限缓存、批量校验和 SQL 条件注入点由权限引擎实现,业务 SQL 生成时统一追加范围条件。 diff --git a/code/fms/FMS新系统核心表结构设计.pdf b/code/fms/FMS新系统核心表结构设计.pdf deleted file mode 100644 index 0763050b..00000000 Binary files a/code/fms/FMS新系统核心表结构设计.pdf and /dev/null differ diff --git a/code/fms/FMS新系统核心表结构设计V2.md b/code/fms/FMS新系统核心表结构设计V2.md deleted file mode 100644 index d6c1620c..00000000 --- a/code/fms/FMS新系统核心表结构设计V2.md +++ /dev/null @@ -1,697 +0,0 @@ -# FMS 新系统核心表结构设计 V2 - -> 本文用于记录新版核心表结构方案,当前不包含权限设计。旧版文档保留作为历史参考。 - -**本系统以 SQL 为核心驱动。设计时应优先保证查询和数据处理的灵活性,并在各模块预留 SQL 扩展能力,支持传入 SQL、动态拼接查询条件、组合查询及自定义数据处理逻辑,以便通过 SQL 快速适配和解决复杂业务场景。** - -## 1. 数据表规范 - -本规范用于指导 AI 设计、新增和调整数据库表结构。除非已有表结构或业务要求明确指定,否则应优先遵循以下约定。 - -### 1.1 表名规范 - -- 统一使用小写 `snake_case` 命名,单词之间使用下划线分隔。 -- 系统配置、元数据和权限相关表使用 `s_` 前缀,例如 `s_module`、`s_field`。 -- 业务数据表使用 `b_` 前缀或明确的业务域前缀,例如 `b_user`、`cw_fee`。 -- 表名应表达稳定的业务含义,避免使用页面名称、路由名称或临时功能名称作为表名。 - -### 1.2 表职责规范 - -- 一张表应围绕一个明确的业务对象、配置对象或关系对象设计。 -- 系统表用于保存系统配置、元数据、权限和运行记录;业务表用于保存具体业务数据;关系表用于表达对象之间的关联关系。 -- 不要为了适配单个页面重复创建业务表;页面差异优先通过模块配置、JSON 布局和 SQL 组合实现。 -- 核心、稳定且需要被 SQL 查询的数据使用关系表;层级化、变化频繁的界面配置和用户偏好使用 JSON。 - -### 1.3 主键和关联字段规范 - -- 每张表必须定义稳定、明确的主键。业务对象优先使用 `b_id` 作为业务编码主键;关系表可根据业务关系使用联合主键。 -- 表之间的关联字段使用 `<对象>_id` 或已有的 `b_module_id` 等明确命名,不使用无法判断来源的通用字段名。 -- 主键和关联字段的数据类型、长度必须保持一致。 -- 本设计不依赖数据库外键约束保证关联完整性,关联存在性和删除规则由业务层处理。 - -### 1.4 字段命名规范 - -- 字段统一使用小写 `snake_case` 命名。 -- 主表或主业务对象字段使用 `b_` 前缀;明细、子表或扩展业务字段使用 `mx_` 前缀。 -- 系统公共字段沿用现有命名,例如 `b_id`、`b_name`、`b_bz`、`b_canuse`、`b_xh`。 -- 字段名应表达业务含义,避免使用 `value`、`data`、`type1` 等无法说明用途的名称;同一含义在不同表中应使用统一命名。 - -### 1.5 通用字段规范 - -- 启用和停用统一使用 `b_canuse`,取值为 `0/1`。 -- 排序字段统一使用 `b_xh`,默认值为 `0`;需要稳定排序时增加业务主键作为次排序字段。 -- 名称、备注和多语言文本使用 `nvarchar`;编码、状态、类型和 SQL 标识类字段使用 `varchar`。 -- 日期时间优先使用 `datetime2`;金额、数量等数值字段根据业务精度明确设置 `precision` 和 `scale`。 -- 是否允许为空及默认值应根据业务语义明确设置,不能无理由全部允许为空或全部设置默认值。 - -### 1.6 通用系统审计字段 - -- 通用创建人使用 `b_created_by`,保存用户编码,不保存用户名称。 -- 通用创建时间使用 `b_created_at`,类型使用 `datetime2`。 -- 通用最后修改人使用 `b_updated_by`,保存最后修改用户编码,不保存用户名称。 -- 通用最后修改时间使用 `b_updated_at`,类型使用 `datetime2`。 -- 以上字段按表的实际审计需求选用;创建人、修改人和对应时间应保持成对出现。 -- 这些字段只表示系统层面的创建和修改信息,不替代业务字段中的录入人、审核人、负责人或当前操作人。 - -### 1.7 树结构规范 - -- 需要维护层级、支持新增和移动的树,统一采用“邻接表为主 + `b_depth`、`b_path` 冗余”的方案。 -- `b_parent_id` 是树结构的权威关系,表示当前节点的直接父节点;`b_depth` 和 `b_path` 是由业务层维护的派生字段。 -- 根节点的 `b_parent_id` 为 `NULL`,`b_depth` 从 `0` 开始计算。 -- `b_path` 使用稳定节点编码组成,并在每个节点编码前后保留 `/` 分隔符,例如 `/sea/container/`;节点编码创建后原则上不可修改。 -- `b_path` 用于前缀查询子树,`b_parent_id` 配合递归 CTE 用于查询父级、祖先和复杂层级关系。 -- 新增和移动节点时,必须在同一业务事务中同步更新当前节点及其全部后代的 `b_depth` 和 `b_path`。 -- 业务层必须校验节点不能指向自身,不能形成循环;这些规则不使用数据库 `CHECK` 或触发器约束。 -- 当冗余字段可能不一致时,以 `b_parent_id` 为准,并提供按父子关系重建 `b_depth`、`b_path` 的维护能力。 -- 模块树和菜单树使用该规范;JSON 内的布局树只在 JSON 内维护,不额外建立树表;主子表、引用和多对多关系使用 `s_relation`,不使用树字段表达。 - -### 1.8 JSON 配置规范 - -- SQL Server 使用 `nvarchar(max)` 保存 JSON,由业务层负责序列化、反序列化和结构校验。 -- JSON 配置如果存在结构升级需求,可以在顶层包含 `schemaVersion`,用于业务层迁移;不单独在数据库表中保存重复的版本字段。 -- JSON 中通过字段编码引用 `s_field`,不得重复定义数据库类型、长度、精度等核心字段元数据。 -- 布局 JSON 保存完整的模块默认配置;用户偏好 JSON 只保存相对默认配置的偏离量。 -- 表单显示、只读、必填、禁用、条件显示、默认值、选项、格式化和字段计算等界面行为配置放在对应的布局 JSON 中,不放入 `s_field`。 -- 读取用户配置时,先加载模块默认配置,再应用用户偏好覆盖;恢复默认时删除对应偏好或清空偏离配置。 -- JSON 可以扩展新属性,但不得随意改变已有属性语义;不再使用的属性由业务层兼容或迁移。 - -### 1.9 SQL 驱动规范 - -- 查询和数据处理优先采用可组合的 SQL 方案,避免把业务逻辑固化在单个页面或固定查询中。 -- 各模块应预留 SQL 扩展入口,支持传入 SQL、动态拼接 `where` 条件、选择字段、排序、分页、关联查询和组合查询。 -- 通用配置无法覆盖业务场景时,优先通过 SQL 视图、查询 SQL、条件片段或数据处理 SQL 扩展,不要立即新增重复表或重复模块。 -- SQL 参数、查询字段、排序字段和关联关系应由明确的模块元数据描述,使 AI 能根据模块定义生成和组合 SQL。 -- SQL 配置只描述可复用的数据处理能力,具体运行参数由调用方传入。 - -### 1.10 索引和约束规范 - -- 主键、关联字段、常用查询条件、排序字段和唯一业务键应根据实际查询场景建立索引。 -- 索引应服务于明确的查询或关联场景,避免无依据地为每个字段单独建立索引。 -- 数据库层只保留主键、联合主键、索引和必要的唯一约束;唯一约束可用于保证业务键或组合业务键不重复。 -- 不增加 `CHECK`、触发器等业务规则约束。字段取值、字段组合、状态流转、关联完整性和其他业务校验统一交给业务层处理。 -- 非空和默认值属于字段定义的一部分,应根据业务语义设置,不使用数据库约束代替业务逻辑。 - -## 2. 总体设计 - -新版结构强化“模块”概念,同时避免为字段布局和表单结构建立过多关系表。 - -```text -s_module -├── module 业务模块,可包含下级 module、data 或 virtual -├── data 数据模块,对应表、视图或查询 SQL,拥有字段定义 -└── virtual 虚拟模块,不要求对应标准数据表,可由 SQL 或业务实现驱动 -``` - -“子模块”不是模块类型,由 `b_parent_id` 表达任意层级的父子关系。 - -核心存储原则: - -| 内容 | 存储方式 | 说明 | -| --- | --- | --- | -| 模块定义 | 关系表 | 系统核心业务结构 | -| 字段定义 | 关系表 | 参与 SQL 生成、保存和数据处理 | -| 列表布局 | JSON | 支持列分组、冻结、格式化和扩展属性 | -| 表单布局 | JSON | 支持分组、栅格、选项卡和条件显示 | -| 查询配置 | JSON | 支持同字段多条件、条件组和嵌套逻辑 | -| 字段分组 | 布局 JSON 节点 | 不再建立独立字段分组表 | -| 用户偏好 | JSON 偏离量 | 只保存用户相对默认配置的修改 | -| 多语言 | 关系表 | 支持按语言查询、导入、导出和回退 | -| 字段权限 | 单独设计 | 不属于本文范围,不与界面布局混合 | - -## 3. 用户 - -```sql --- 用户表 -create table dbo.b_user ( - b_id varchar(50) not null primary key, -- 用户编码(业务键主键) - b_name nvarchar(100) null, -- 用户名 - b_bz nvarchar(400) null, -- 备注 - b_password nvarchar(255) not null, -- 密码 - b_canuse tinyint not null default 1, -- 是否启用(0/1) - b_logintime int not null default 0 -- 登录次数 -); -``` - -## 4. 模块 - -### 4.1 模块定义 - -```sql --- 模块定义表 -create table dbo.s_module ( - b_id varchar(50) not null primary key, -- 模块编码(业务键主键) - b_parent_id varchar(50) null, -- 父模块编码,NULL 表示根节点 - b_depth int not null default 0, -- 树层级,根节点为 0 - b_path varchar(1000) not null, -- 根到当前节点的路径,例如 /sea/container/ - b_name nvarchar(200) not null, -- 模块名称 - b_i18n varchar(150) null, -- 多语言资源键 - b_module_type varchar(20) not null, -- module / data / virtual - b_view_table varchar(128) null, -- 默认查询表或视图 - b_save_table varchar(128) null, -- 默认保存目标表 - b_key_field varchar(50) null, -- 主键字段 - b_order_sql varchar(500) null, -- 默认排序 SQL - b_query_sql nvarchar(max) null, -- 可选查询 SQL 或 SQL 模板 - b_delete_policy varchar(20) null, -- 删除策略编码,由业务层解释 - b_edit_mode varchar(20) null, -- table / sql / custom - b_config_json nvarchar(max) null, -- 低频模块扩展配置 - b_canuse tinyint not null default 1, -- 是否启用(0/1) - b_xh int not null default 0, -- 同级显示顺序 - b_bz nvarchar(2000) null -- 备注 -); - -create index ix_s_module_parent - on dbo.s_module (b_parent_id, b_canuse, b_depth, b_xh, b_id); - -create index ix_s_module_path - on dbo.s_module (b_path, b_canuse, b_id); - -create index ix_s_module_type - on dbo.s_module (b_module_type, b_canuse, b_xh, b_id); - -create index ix_s_module_view_table - on dbo.s_module (b_view_table, b_canuse, b_id); - -create index ix_s_module_save_table - on dbo.s_module (b_save_table, b_canuse, b_id); -``` - -### 4.2 模块类型约定 - -#### `module` - -- 表示业务模块或业务域,例如海运、空运、财务、客户管理。 -- 可以包含下级 `module`、`data` 和 `virtual`。 -- 默认不配置字段、查询表、保存表和主键字段。 -- 可作为模块级配置、统计、导航上下文和权限继承的边界。 - -#### `data` - -- 表示具体数据对象,例如海运主单、集装箱、费用明细。 -- 必须配置 `b_view_table` 或 `b_query_sql` 作为查询来源。 -- 可以配置 `b_save_table` 作为默认保存目标。 -- `b_key_field` 表示该数据对象的主键字段;没有稳定主键的查询数据可以为空。 -- 字段定义保存在 `s_field`,界面配置保存在 `s_module_schema`。 -- 主子表和引用关系通过 `s_relation` 表达,不使用模块树表达。 - -#### `virtual` - -- 表示不适合使用标准数据表和通用 CRUD 表达的业务能力。 -- 可以由查询 SQL、组合 SQL、专用接口或业务处理器实现。 -- 可以拥有字段定义和界面配置,但不强制配置 `b_save_table`。 -- 没有标准保存表时,保存和业务动作由 SQL 或业务处理器实现。 - -模块类型、父子组合、字段适用规则和 SQL 配置的完整性由业务层校验,不建立 `CHECK` 约束。 - -### 4.3 模块树与数据关系边界 - -- `b_parent_id`、`b_depth`、`b_path` 共同构成模块树,其中 `b_parent_id` 是权威关系,后两者是冗余查询字段。 -- `b_path` 使用 `/` 包裹节点编码,例如 `/sea/`、`/sea/container/`;查询模块子树时使用路径前缀匹配。 -- `module` 可以包含下级 `module`、`data` 和 `virtual`;`data` 和 `virtual` 默认作为叶子节点。 -- 新增或移动模块时,业务层必须在同一事务中更新当前模块及全部后代的 `b_depth` 和 `b_path`。 -- 不允许模块指向自身或形成循环;`b_id` 创建后原则上不可修改;这些规则由业务层校验。 -- `b_depth`、`b_path` 与 `b_parent_id` 不一致时,以 `b_parent_id` 为准并重建冗余字段。 -- 数据之间的主子表、引用和多对多关系统一由 `s_relation` 表达。 -- 模块级扩展配置使用 `b_config_json`,只保存低频、非核心的扩展属性,不重复保存字段定义、权限或界面布局。 - -### 4.4 查询来源优先级 - -数据加载时按以下规则确定默认查询来源: - -1. 配置 `b_query_sql` 时,使用查询 SQL 或 SQL 模板; -2. 未配置 `b_query_sql` 时,使用 `b_view_table`; -3. 两者均未配置时,由虚拟模块的业务实现负责提供数据; -4. 调用方传入的筛选、字段、排序和分页配置在默认查询来源上继续组合。 - -保存时按以下规则确定默认保存方式: - -1. `data` 模块配置 `b_save_table` 时,默认写入该表; -2. `b_edit_mode = 'sql'` 时,由业务层使用模块对应的 SQL 保存逻辑; -3. `b_edit_mode = 'custom'` 时,由业务处理器负责保存; -4. `virtual` 模块不要求具备通用保存能力。 - -## 5. 字段定义 - -字段定义保持关系表结构。数据库表或视图是物理字段结构的来源,`s_field` 只保存模块使用字段时所需的业务元数据和来源映射。 - -当前阶段不在 `s_field` 中保存数据库类型、长度、精度、小数位等物理结构信息。保存器或 SQL 生成器需要这些信息时,直接读取数据库表或视图的实际结构;后续确定元数据同步方案后再补充持久化字段。 - -```sql --- 字段定义表 -create table dbo.s_field ( - b_module_id varchar(50) not null, -- 所属 data / virtual 模块编码 - b_field varchar(50) not null, -- 模块字段编码或查询结果别名 - b_source_field varchar(128) null, -- 来源字段;计算字段可为空 - b_name nvarchar(200) not null, -- 字段名称 - b_i18n varchar(150) null, -- 多语言资源键 - b_type varchar(30) not null, -- 业务类型 - b_canuse tinyint not null default 1, -- 是否启用(0/1) - b_xh int not null default 0, -- 默认顺序 - primary key (b_module_id, b_field) -); - -create index ix_s_field_module_order - on dbo.s_field (b_module_id, b_canuse, b_xh, b_field); - -create index ix_s_field_source - on dbo.s_field (b_module_id, b_source_field); -``` - -字段同步约定: - -- `data` 模块可以从 `b_view_table` 或 `b_query_sql` 的结果结构增量同步字段。 -- 同步负责发现来源字段和维护 `b_source_field` 映射,不覆盖人工维护的字段名称和业务类型。 -- 数据库中已删除的字段不直接物理删除,可由业务层标记为停用并提示人工确认。 -- 计算字段、关联显示字段和虚拟字段可以人工登记,其 `b_source_field` 可以为空,并通过模块查询 SQL 或界面配置 JSON 产生。 -- 字段是否可以直接写入、是否只读以及是否需要自定义保存逻辑,不在 `s_field` 中增加固定模式字段;由表单 JSON、模块保存配置或业务处理器根据具体场景处理。 - -## 6. 模块界面配置 - -列表布局、表单布局、查询配置和字段分组统一保存为 JSON,不再建立以下独立表: - -```text -s_field_group -s_field_view -s_field_edit -s_field_query -``` - -```sql --- 模块界面配置表 -create table dbo.s_module_schema ( - b_module_id varchar(50) not null, -- 所属 data / virtual 模块编码 - b_schema_type varchar(20) not null, -- view / edit / query - b_schema_json nvarchar(max) not null, -- 完整 JSON 配置 - b_canuse tinyint not null default 1, -- 是否启用(0/1) - b_updated_at datetime2 null, -- 最后更新时间 - primary key (b_module_id, b_schema_type) -); - -create index ix_s_module_schema_type - on dbo.s_module_schema ( - b_module_id, - b_schema_type, - b_canuse - ); -``` - -每个模块默认维护三行配置,分别对应列表、表单和查询: - -```text -view -edit -query -``` - -当前阶段不维护同一类型的多套命名配置。若未来确实需要多套列表、表单或查询方案,再增加配置编码字段,或在 JSON 内增加方案节点。 - -### 6.1 列表配置示例 - -```json -{ - "schemaVersion": 1, - "columns": [ - { - "field": "b_no", - "visible": true, - "width": 140, - "fixed": "left" - }, - { - "type": "group", - "title": "费用信息", - "i18n": "group.cw_fee.amount", - "children": [ - { "field": "mx_amount", "width": 120, "format": "money" }, - { "field": "mx_currency", "width": 90 } - ] - } - ] -} -``` - -### 6.2 表单配置示例 - -```json -{ - "schemaVersion": 1, - "children": [ - { - "type": "group", - "title": "基础信息", - "i18n": "group.sea_main.basic", - "children": [ - { - "type": "row", - "children": [ - { "field": "b_no", "span": 12, "readonly": true }, - { "field": "b_name", "span": 12, "required": true } - ] - } - ] - } - ] -} -``` - -表单 JSON 可以描述字段的展示和交互行为,例如: - -```json -{ - "field": "mx_amount", - "readonly": true, - "visibleWhen": { "field": "mx_type", "operator": "eq", "value": "tax" }, - "formula": "mx_qty * mx_price" -} -``` - -其中 `readonly`、`required`、`disabled`、`visibleWhen`、`defaultValue`、`options`、`format` 和 `formula` 都属于界面配置,不放入 `s_field`。如果计算字段需要作为查询结果参与 SQL、排序或筛选,应在模块的 `b_query_sql` 中定义,而不是依赖表单 JSON。 - -### 6.3 查询配置示例 - -```json -{ - "schemaVersion": 1, - "quick": ["b_no", "b_name", "b_status"], - "groups": [ - { - "logic": "and", - "conditions": [ - { "field": "b_status", "operator": "eq" }, - { - "logic": "or", - "conditions": [ - { "field": "b_no", "operator": "like" }, - { "field": "b_name", "operator": "like" } - ] - } - ] - } - ] -} -``` - -## 7. 用户个性化 - -原有列表字段偏好和查询字段偏好合并为一张表。用户偏好只保存相对模块默认配置的偏离量,不复制完整模块配置。 - -```sql --- 用户模块偏好表 -create table dbo.s_user_module_pref ( - b_user_id varchar(50) not null, -- 用户编码 - b_module_id varchar(50) not null, -- 模块编码 - b_pref_json nvarchar(max) not null, -- 用户偏离配置 JSON - b_updated_at datetime2 null, -- 最后更新时间 - primary key (b_user_id, b_module_id) -); - -create index ix_s_user_module_pref_module - on dbo.s_user_module_pref (b_module_id, b_user_id); -``` - -偏好示例: - -```json -{ - "schemaVersion": 1, - "view": { - "default": { - "hidden": ["b_bz"], - "order": ["b_no", "b_name", "b_status"], - "width": { - "b_no": 160, - "b_name": 240 - } - } - }, - "query": { - "default": { - "quick": ["b_no", "b_status"] - } - } -} -``` - -合并规则: - -1. 读取 `s_module_schema` 中的模块默认配置; -2. 读取当前用户的 `s_user_module_pref.b_pref_json`; -3. 按配置类型应用偏离量; -4. 已停用、已删除或无权限的字段不得因用户偏好重新出现; -5. 恢复默认时删除当前模块偏好记录,或删除 JSON 中对应配置片段。 - -## 8. 自动编码 - -```sql --- 自动编码表 -create table dbo.s_autocode ( - b_module_id varchar(50) not null, -- data 模块编码 - b_field varchar(50) not null, -- 编码字段 - b_prefix nvarchar(200) not null default N'', -- 编码前缀 - b_dateformat varchar(30) not null default 'yyyyMM', -- 日期格式 - b_separator nvarchar(20) not null default N'-', -- 分隔符 - b_seqwidth int not null default 5, -- 序号宽度 - b_resettype varchar(10) not null default 'month', -- 重置类型 - b_startvalue bigint not null default 1, -- 起始值 - b_currentperiod varchar(20) null, -- 当前周期 - b_currentvalue bigint not null default 0, -- 当前值 - b_canuse tinyint not null default 1, -- 是否启用(0/1) - primary key (b_module_id, b_field) -); -``` - -编码格式、重置类型和字段适用性由业务层校验。 - -## 9. 模块关系 - -模块树只表达业务模块层级,主子表、引用和多对多关系统一由 `s_relation` 表达。 - -```sql --- 模块关系表 -create table dbo.s_relation ( - b_module_id varchar(50) not null, -- 源 data 模块编码 - b_target_module_id varchar(50) not null, -- 目标 data 模块编码 - b_relation_type varchar(20) not null, -- one_to_one / one_to_many / many_to_many - b_field varchar(50) not null, -- 源字段 - b_target_field varchar(50) not null, -- 目标字段 - b_foreign_side varchar(10) null, -- 外键所在侧 - b_junction_module_id varchar(50) null, -- 中间表模块编码 - b_junction_field varchar(50) null, -- 中间表源字段 - b_junction_target_field varchar(50) null, -- 中间表目标字段 - b_ownership varchar(20) not null default 'reference', -- reference / composition - b_owner_side varchar(10) null, -- 属主侧 - b_delete_policy varchar(20) null, -- 删除策略编码 - b_order_module_id varchar(50) null, -- 排序所属模块编码 - b_order_field varchar(50) null, -- 排序字段 - b_condition_sql nvarchar(max) null, -- 附加关联条件 SQL - b_canuse tinyint not null default 1, -- 是否启用(0/1) - b_xh int not null default 0, -- 显示顺序 - primary key (b_module_id, b_field, b_target_module_id, b_target_field) -); - -create index ix_s_relation_module - on dbo.s_relation (b_module_id, b_canuse, b_xh, b_field, b_target_module_id); - -create index ix_s_relation_target - on dbo.s_relation (b_target_module_id, b_canuse, b_module_id); -``` - -## 10. 菜单 - -菜单负责导航和页面入口,模块负责业务结构和数据能力。一个菜单可以使用多个 `data` 或 `virtual` 模块,一个模块也可以被多个菜单复用。 - -```sql --- 菜单表 -create table dbo.s_menu ( - b_id varchar(50) not null primary key, -- 菜单编码(业务键主键) - b_parent_id varchar(50) null, -- 父菜单编码,NULL 表示根节点 - b_depth int not null default 0, -- 树层级,根节点为 0 - b_path varchar(1000) not null, -- 根到当前菜单的路径,例如 /system/user/ - b_name nvarchar(200) not null, -- 菜单名称 - b_i18n varchar(150) null, -- 多语言资源键 - b_menu_type varchar(20) not null, -- directory / page / external - b_route varchar(500) null, -- 页面路由或外链地址 - b_icon varchar(50) null, -- 图标 - b_xh int not null default 0, -- 显示顺序 - b_canuse tinyint not null default 1 -- 是否启用(0/1) -); - -create index ix_s_menu_parent - on dbo.s_menu (b_parent_id, b_canuse, b_depth, b_xh, b_id); - -create index ix_s_menu_path - on dbo.s_menu (b_path, b_canuse, b_id); - -create index ix_s_menu_route - on dbo.s_menu (b_route, b_canuse, b_id); - --- 菜单使用模块关系表 -create table dbo.s_menu_module ( - b_menu_id varchar(50) not null, -- 菜单编码 - b_module_id varchar(50) not null, -- 页面实际使用的 data / virtual 模块编码 - b_xh int not null default 0, -- 页面内模块顺序 - b_canuse tinyint not null default 1, -- 是否启用(0/1) - primary key (b_menu_id, b_module_id) -); - -create index ix_s_menu_module_module - on dbo.s_menu_module (b_module_id, b_canuse, b_menu_id); -``` - -菜单树同样使用 `b_parent_id` 作为权威关系,并冗余维护 `b_depth`、`b_path`。菜单节点新增、移动和删除时,由业务层同步处理后代节点路径和层级;菜单编码创建后原则上不可修改。 - -业务模块上下文可以根据所绑定数据模块的父级模块获得;暂不增加独立的菜单上下文字段。 - -## 11. 多语言 - -多语言继续使用关系表。模块、字段和 JSON 布局节点只保存资源键,具体语言文本统一保存到 `s_i18n`。资源通过 `b_source`、`b_source_id` 标识来源,便于按来源维护和级联清理。 - -```sql --- 多语言类型表 -create table dbo.s_i18n_type ( - b_id varchar(20) not null primary key, -- 类型编码 - b_name nvarchar(200) not null, -- 类型名称 - b_canuse tinyint not null default 1, -- 是否启用(0/1) - b_default tinyint not null default 0, -- 是否默认(0/1) - b_xh int not null default 0 -- 显示顺序 -); - --- 多语言资源表 -create table dbo.s_i18n ( - b_key varchar(150) not null, -- 资源键 - b_locale varchar(20) not null, -- 语言区域 - b_value nvarchar(1000) not null, -- 翻译值 - b_source varchar(20) not null, -- 来源类型,例如 module / common - b_source_id varchar(50) null, -- 来源对象编码;module 来源时保存模块编码 - b_canuse tinyint not null default 1, -- 是否启用(0/1) - primary key (b_key, b_locale) -); - -create index ix_s_i18n_source - on dbo.s_i18n (b_source, b_source_id, b_key, b_locale); - -create index ix_s_i18n_locale - on dbo.s_i18n (b_locale, b_canuse, b_key); -``` - -## 12. 日志 - -```sql --- 技术日志表 -create table dbo.s_log_technical ( - b_id uniqueidentifier not null primary key, -- 日志 ID(UUIDv7) - b_user_id varchar(50) null, -- 用户编码 - b_sourcetype varchar(20) null, -- 来源类型 - b_loglevel varchar(16) not null, -- 日志级别 - b_logcategory varchar(64) null, -- 日志分类 - b_operation varchar(100) null, -- 操作 - b_module_id varchar(50) null, -- 模块编码 - b_request_id varchar(64) null, -- 请求 ID - b_trace_id varchar(64) null, -- 链路 ID - b_server_node varchar(128) null, -- 服务节点 - b_duration_ms bigint null, -- 耗时(毫秒) - b_result_code varchar(50) null, -- 结果码 - b_error_code varchar(50) null, -- 错误码 - b_error_message nvarchar(4000) null, -- 错误信息 - b_exception_text nvarchar(max) null, -- 异常文本 - b_context_text nvarchar(max) null, -- 上下文文本 - b_occurdatetime datetime2 default sysutcdatetime() -- 发生时间 -); - --- 登录日志表 -create table dbo.s_log_login ( - b_id uniqueidentifier not null primary key, -- 日志 ID(UUIDv7) - b_user_id varchar(50) null, -- 用户编码 - b_login_type varchar(20) not null, -- 登录类型 - b_login_result tinyint not null default 0, -- 登录结果(0/1) - b_fail_reason nvarchar(400) null, -- 失败原因 - b_ip varchar(50) null, -- IP - b_ip_location nvarchar(400) null, -- IP 归属地 - b_browser varchar(100) null, -- 浏览器 - b_browser_version varchar(50) null, -- 浏览器版本 - b_os varchar(100) null, -- 操作系统 - b_os_version varchar(50) null, -- 系统版本 - b_device_type varchar(20) null, -- 设备类型 - b_user_agent nvarchar(max) null, -- UserAgent - b_session_id varchar(50) null, -- 会话 ID - b_request_id varchar(64) null, -- 请求 ID - b_trace_id varchar(64) null, -- 链路 ID - b_occurdatetime datetime2 default sysutcdatetime() -- 发生时间 -); - --- 审计日志表 -create table dbo.s_log_audit ( - b_id uniqueidentifier not null primary key, -- 日志 ID(UUIDv7) - b_event_code varchar(50) not null, -- 事件编码 - b_module_id varchar(50) null, -- 模块编码 - b_data_id varchar(50) null, -- 数据 ID - b_business_no varchar(50) null, -- 业务单号 - b_operation varchar(100) null, -- 操作 - b_operator_id varchar(50) null, -- 操作人 ID - b_operator_name nvarchar(200) null, -- 操作人名称 - b_source_type varchar(20) not null default 'user', -- 来源类型 - b_visibility varchar(20) not null default 'internal', -- 可见性 - b_request_id varchar(64) null, -- 请求 ID - b_trace_id varchar(64) null, -- 链路 ID - b_payload_text nvarchar(max) null, -- 载荷文本 - b_occurdatetime datetime2 default sysutcdatetime() -- 发生时间 -); - --- 审计字段明细表 -create table dbo.s_log_audit_field ( - b_id uniqueidentifier not null primary key, -- 日志 ID(UUIDv7) - b_event_id uniqueidentifier not null, -- 审计事件 ID - b_module_id varchar(50) null, -- 模块编码 - b_data_id varchar(50) null, -- 数据 ID - b_field varchar(50) not null, -- 字段编码 - b_field_label nvarchar(200) null, -- 字段名称 - b_before_value nvarchar(max) null, -- 变更前值 - b_after_value nvarchar(max) null, -- 变更后值 - b_before_display nvarchar(1000) null, -- 变更前显示值 - b_after_display nvarchar(1000) null, -- 变更后显示值 - b_visibility varchar(20) not null default 'internal' -- 可见性 -); - -create index ix_s_log_technical_occur - on dbo.s_log_technical (b_occurdatetime, b_id); - -create index ix_s_log_technical_trace - on dbo.s_log_technical (b_trace_id, b_occurdatetime); - -create index ix_s_log_login_occur - on dbo.s_log_login (b_occurdatetime, b_id); - -create index ix_s_log_login_user - on dbo.s_log_login (b_user_id, b_occurdatetime); - -create index ix_s_log_audit_occur - on dbo.s_log_audit (b_occurdatetime, b_id); - -create index ix_s_log_audit_module - on dbo.s_log_audit (b_module_id, b_data_id, b_occurdatetime); - -create index ix_s_log_audit_field_event - on dbo.s_log_audit_field (b_event_id, b_id); -``` - -## 13. 业务表主键约定 - -业务表使用应用层生成的雪花 ID。需要跨系统公开、离线生成或按时间有序的标识时,可以单独评估 UUIDv7,但同一业务域内应保持一致。 - -```sql --- 业务表示例 -create table dbo.b_example ( - b_id bigint not null primary key, -- 主键(应用层雪花 ID) - b_inputuser_id varchar(50) null, -- 录入用户 ID - b_inputdatetime datetime2 default sysutcdatetime() -- 录入时间 -); -``` - -## 14. 当前不包含的内容 - -以下内容单独设计,不在本文中定义: - -- 菜单、模块和动作权限; -- 字段权限和数据范围; -- 角色及用户授权; -- JSON 配置的前端设计器实现; -- 通用 SQL 网关的具体接口协议。 diff --git a/code/fms/FMS权限设计.md b/code/fms/FMS权限设计.md deleted file mode 100644 index 8ad205f2..00000000 --- a/code/fms/FMS权限设计.md +++ /dev/null @@ -1,337 +0,0 @@ -# FMS 权限设计 - -> 本文是权限部分的**定稿设计**,以《FMS新系统核心表结构设计.md》的表结构为基础,替代《字段权限与用户个性化设计.md》中与本文冲突的表述(差异见第 8 节)。 -> 用户个性化(`s_user_field_pref`、`s_user_query_field_pref`)不在本文范围内,后续单独处理。 - -## 0. 设计定位 - -### 0.1 系统特征(决定设计取向的前提) - -| 特征 | 现状 | 对权限设计的影响 | -| --- | --- | --- | -| 部署方式 | **一客户一数据库** | 权限点只需在本库内唯一,编码不带租户前缀;不存在跨租户数据串户风险 | -| 架构 | 元数据驱动(`s_module` / `s_field` / `s_field_view` …) | 权限点必须**自动生成**,不能手工维护清单 | -| 客户可定制 | 可加模块、加字段 | 权限点需要**增量同步**机制 | -| 客户规模 | 中小货代,多数无专职 IT | 模型必须简单到客户管理员能自行配置 | -| 用户规模 | 几十 ~ 几百 | 不需要 SAP 级别的授权对象模型 | - -### 0.2 结论:对应用友 / 金蝶的三层模型 - -**认知模型对标用友 / 金蝶**(功能权限 / 数据权限 / 字段权限 + 角色),理由不是技术最优,而是**客户和实施顾问无需学习成本**,且规模匹配。 - -**不采用**: - -- SAP 授权对象模型(为跨国集团设计,规模不匹配,交付成本高) -- Salesforce 四层共享模型(Profile + Permission Set + OWD + Sharing,计算复杂) -- 通用权限框架 / ABAC / 运行时策略引擎(明确不做) - -**借鉴两点**: - -- 权限点自动生成(Salesforce 思路,利用本系统元数据驱动的优势) -- 数据范围支持选择多个组织节点(CargoWise 的 Branch 思路,覆盖"只看上海 + 深圳分公司") - ---- - -## 1. 权限模型 - -### 1.1 四类权限 - -| 类别 | 回答的问题 | 载体 | 默认策略 | -| --- | --- | --- | --- | -| **入口** | 能不能进 | `menu` / `page` / `report` | 默认拒绝 | -| **动作** | 能不能做 | `module..` | 全部默认拒绝(权限就是权限) | -| **字段** | 能看/改哪些列 | `s_user_field_permission` | 跟随模块默认,只收紧 | -| **数据范围** | 能看哪些行 | `s_user_data_scope` | 未配置视为 `all` | - -### 1.2 入口 ≠ 动作 - -- **入口**是一维的:能不能打开(菜单可见、页面可访问)。勾了"能进费用管理"**不代表**能改数据。 -- **动作**是多维的:对某个模块能做什么。 - -两者互不继承,符合《核心表结构》"菜单权限不自动授予模块权限"的约定。 - -### 1.3 权限就是权限 - -不区分「内置动作」与「业务动作」。查看 / 新增 / 修改 / 删除 / 导出、审核 / 核销 / 关闭 / 作废等都是同一种**动作权限**,只有「勾选」与「未勾选」两种状态。 - -| 状态 | 含义 | -| --- | --- | -| 勾选 | 有 `s_user_power` 授权记录(`b_canuse = 1`) | -| 未勾选 | 无授权记录 = 拒绝 | - -**全部动作统一默认拒绝**。简化点: - -- 一种存储:只存「授权记录」,没有「禁止记录」 -- 一种默认策略:未勾选 = 拒绝 -- 一种行为:勾选即授权 - -> 客户不能新建动作权限点——业务动作需要对应的后端代码,客户建了也无效。客户界面只提供勾选。 - -### 1.4 角色(可选层) - -采用**主角色 + 个人例外**两层,不做角色继承角色: - -``` -用户 → 主角色(继承,只有一个)+ 个人例外(覆盖角色中的少数项) -``` - -- 新员工入职 = 选一个角色 -- 个别人特殊 = 加几条例外 -- 改角色 = 该角色所有人跟着变(与 SAP / 用友行为一致) -- 查询只有两层,不是无限递归 - -角色不是强制项:不引入角色也能用(直接给用户配),但引入后配置量大幅下降。产品内置「货代标准权限包」(操作员 / 客服 / 费用会计 / 财务经理 / 只读查询),实施时导入客户库后微调。 - ---- - -## 2. 权限点编码规范 - -| 类型 | 编码 | 示例 | -| --- | --- | --- | -| 菜单入口 | `menu..access` | `menu.cw_fee.access` | -| 页面入口 | `page..access` | `page.sea_detail.access` | -| 报表入口 | `report..access` | `report.cw_fee_summary.access` | -| 内置动作 | `module..` | `module.cw_fee.delete` | -| 业务动作 | `module..` | `module.cw_fee.audit` | - -规则: - -- 全部小写,点分分隔;`` 用 `s_module.b_id` -- **一客户一库,编码在本库内唯一即可**,不加租户前缀 -- 权限点由系统生成,**不允许手工新增业务动作** -- `s_power.b_code` 是稳定业务键;`s_power.b_id` 是内部主键,业务代码只依赖 `b_code` - -### 与旧模型的差异 - -| 项 | 旧(`b_user_power` / `s_module_power`) | 本文 | -| --- | --- | --- | -| 权限点标识 | `${module_id}.${雪花ID}`,跨环境 ID 不同,配置无法迁移 | 语义化 `b_code`,可跨环境迁移 | -| 动作编码 | `action.module.xx.yy.execute` | `module.xx.yy` | - -> 现有 `stores/permissions.js` 依赖旧表(`b_user_power` / `s_module_power`),迁移到新模型后需同步改造(见第 6 节)。 - ---- - -## 3. 权限点的自动生成与升级同步 - -### 3.1 生成规则 - -| 来源 | 生成内容 | -| --- | --- | -| `s_module` 模块 | `menu..access` + 5 个内置动作 | -| `s_module` 中 `b_module_type = 'page'` 的节点 | `page..access`(仅"需要独立入口控制"的页面) | -| 报表定义 | `report..access` | -| 开发注册的业务动作 | `module..` | - -### 3.2 增量同步三原则 - -产品升级或客户新增模块后需要补权限点,同步逻辑必须幂等: - -1. **只增不删** —— 不删除已有权限点(删了会丢客户配置) -2. **不覆盖已有配置** —— 客户主动停用的 `b_canuse = 0` 不被重置 -3. **废弃置 `b_canuse = 0`** —— 不物理删除,保留历史可追溯 - -### 3.3 客户自定义字段的权限 - -客户新增字段(如"拖车费")默认跟随模块默认配置,即**全员可见可编辑**。 - -防泄漏建议(低优先级,可后置): - -- 新增字段时,字段名命中 `cost / price / amount / profit / 成本 / 价` 等关键词 → 提示是否标记为敏感字段 -- 提供「敏感字段体检」:列出金额类型但未标记敏感的字段供确认 - ---- - -## 4. 权限计算顺序 - -一次页面访问或接口调用按顺序计算: - -1. 用户账号与对应资源是否启用 -2. **入口**:菜单 / 页面 / 报表是否有有效授权(默认拒绝) -3. **动作**:当前动作是否被允许 - - 内置动作:无记录即放行;`b_canuse = 0` 明确禁止 - - 业务动作:必须有有效授权记录 -4. **字段**:按模块字段元数据 + 用户字段策略裁剪(只收紧) -5. **数据范围**:`override` → `default` → `all`,同层多条 OR 合并,生成 SQL 行条件 -6. 角色层:用户例外覆盖角色配置 - -> 页面显示与接口执行必须用同一套规则。前端可提前隐藏,但**最终以后端 SQL 为准**(《开发规范》第 10、23 条)。 - ---- - -## 5. 后端落实 - -### 5.1 统一拦截点 - -通用数据接口是唯一的 SQL 出入口,权限在此单点落实: - -| 接口 | 对应动作 | 需落实 | -| --- | --- | --- | -| `/data/loaddata` | read | 动作校验 + 数据范围 WHERE + 字段裁剪 | -| `/data/page` | read | 同上 | -| `/data/saveobjt` | create / update / delete | 动作校验 + 字段 edit 校验 + **目标行必须落在数据范围内** | -| `/data/loaddatabysql` | — | ⚠️ 见 5.2 | -| `/data/nextid` `/nextcode` `/describe` | — | 无需 | - -流程: - -``` -解析 table → 用 s_module.b_viewtable / b_savetable 反查 module_id - → 确定 action(loaddata/page → read;saveobjt 按 inserts/updates/deletes 分别定) - → ① 动作校验 - → ② 数据范围拼入 WHERE - → ③ SELECT 列剔除 hidden 字段 - → 执行 -``` - -权限结果按 `(user_id, module_id, action)` 缓存,避免每次查询都查权限表。 - -### 5.2 ⚠️ `/data/loaddatabysql` 必须收紧 - -该接口接受任意 SQL,**会完全绕过字段裁剪和数据范围**。不处理则第 4 节所有规则形同虚设。 - -处理方案(择一,建议第一种): - -1. **仅限管理员 / 内部账号可用**(推荐) -2. 改为只接受白名单视图名,不接受 SQL 片段 -3. 解析 SQL 后注入条件(复杂且易出漏洞,不推荐) - -> 这与《开发规范》第 8 条「动态配置不等于任意 SQL」一致。 - -### 5.3 表名 → 模块 反查 - -`loaddata` / `page` 的参数只带表名,需经 `s_module.b_viewtable` / `b_savetable` 反查 `module_id`。 - -**待确认**:是否存在多个模块共用同一视图?若存在,表名 → 模块的映射不唯一,需要前端额外传 `moduleId` 参数。 - ---- - -## 6. 前端落实 - -### 6.1 `stores/permissions.js` 改造 - -现有骨架(并发加载 + 主体去重 + 管理员提权)保留,替换数据源与判断逻辑: - -| 现状 | 改造后 | -| --- | --- | -| 拉 `s_module` / `b_user_module` / `b_user_power` / `s_module_power` | 拉 `s_module` / `s_power` / `s_user_power` / `s_user_field_permission` / `s_user_data_scope` | -| `powerSet` 存 `${module_id}.${雪花ID}` | 存语义化 `b_code` | -| `canPower(scopeCode, powerCode)`(零调用) | `can(code)` 直接传 `b_code` | - -对外暴露: - -```js -can('module.cw_fee.delete') // 动作判断 -fieldPerm(moduleId, field) // → { mode, query, export } -scope(moduleId, action) // 数据范围 -``` - -### 6.2 三个接线点 - -1. **按钮级**:提供 `v-permission` 指令 + `usePermission()`,替换掉零调用的 `canPower` -2. **字段裁剪**:`FmsModuleTable.buildColumns()` 之后过滤 —— `hidden` 移除列、`view` 置只读、`export = 0` 剔除导出列 -3. **菜单过滤**:`stores/menus.js` 目前只按 `g3soft` 管理员裁剪,**未使用授权数据**,需改为按入口权限过滤 - -> 前端裁剪只是展示层,**不可信**。真正的过滤在后端 SQL。 - ---- - -## 7. 配置界面设计 - -### 7.1 核心原则 - -- **IT 定义"能分配什么",客户决定"分给谁"**。IT 不碰人,客户不碰元数据。 -- **一个页面配完**,不分区切换、不重复选模块。 -- 默认展示**已配置项**,而非全量清单(规则是"没配 = 放行",全量铺开只会增加噪音)。 - -### 7.2 树的主体是模块树,不是菜单树 - -`s_module` 本身是树(`b_parent_id` + `b_module_type`),`page` 类型节点天然承载"页面包含哪些模块": - -``` -海运详细页(page 节点,只配入口) -├── 海运主表(module) -├── 装箱主表(module) -└── 费用管理(module) -``` - -菜单 `s_menu` 只是指向模块的指针(`s_menu.b_module_id`)。**不用菜单树做权限骨架**: - -- 菜单不拥有字段 / 动作 / 数据范围,挂上去语义错误 -- 一个模块可能被多个菜单引用 → 同一模块重复出现、配置冲突 -- 与《核心表结构》"权限以业务模块为核心"矛盾 - -菜单入口权限 = 模块树上的「入口」列。只有"一个模块被多个菜单引用且需分别控制"时,才启用 `page..access` 独立入口。 - -### 7.3 主界面形态(一屏配完一个人) - -``` -模块树 入口 │ 动作(默认拒绝,勾选即授权) │ 数据范围 -────────────────────────────────────────────────────────────────────────────── -▾ 海运详细页[page] ☑ │ - - - - - │ - │ - - ▾ 海运主表 ☑ │ ☑ ☑ ☐ ☐ ☐ │ ☑审核 ☐关闭 │ 仅本人▾ - └ 字段(敏感) 金额=只读 · 内部成本=隐藏 - ▾ 装箱主表 ☑ │ ☑ ☐ ☐ ☐ ☐ │ - │ 本部门▾ - ▾ 费用管理 ☑ │ ☑ ☑ ☑ ☐ ☑ │ ☑审核 ☐反审核 │ 本部门▾ - └ 字段(敏感) 金额=只读 · 内部成本=隐藏 · 毛利=隐藏 -▾ 发票管理 ☑ │ ☑ ☑ ☑ ☐ ☑ │ ☑作废 │ 本部门▾ - 客户档案 ☐ │ ☑ ☐ ☐ ☐ ☐ │ - │ 本部门▾ -``` - -要点: - -- 第一列是**模块树**,缩进 + 展开 + 父子半选 -- `page` 节点只配入口,权限列置灰(不直接对应数据表) -- 动作全在一列,全部默认拒绝,勾选即授权;业务动作与基础操作 chip 同样式 -- **字段是模块行的展开子行**,且只显示**敏感字段 + 字段组**,默认折叠 -- 数据范围是一列,跟着模块走("仅本人"与"禁止删除"是同频操作,不该分开放) - -### 7.4 批量 - -- 左侧用户列表支持多选 → 底部出现「应用角色模板」「批量设置数据范围」「清空」 -- 表格支持整列操作(如"所有模块禁止删除") - -### 7.5 IT 专属入口(客户不可见) - -| 内容 | 频率 | -| --- | --- | -| 模块 / 字段元数据 + 敏感标记 | 低 | -| 字段分组(`s_field_group`) | 低 | -| 业务动作注册(需写代码) | 低 | -| 权限点清单 + 重新生成 | 低 | -| 标准权限包维护 | 低 | - -### 7.6 权限诊断(必做) - -- **以某用户身份预览** -- **反查**:选中一个权限点 / 字段 → 列出谁有 → 批量调整 -- **查看生效 SQL**:排查"为什么这个人看不到这条数据" - ---- - -## 8. 与既有文档的差异(决策变更) - -| # | 原表述 | 本文 | 原因 | -| --- | --- | --- | --- | -| 1 | 动作编码 `action.module...execute` | `module..` | CRUD 与业务动作本质是同一概念,统一编码 | -| 2 | 权限直接授予用户,**不引入角色继承** | 可选「主角色 + 个人例外」两层 | 无角色时配置量是 `人 × 模块 × 字段` 的笛卡尔积 | -| 3 | 权限点类型含 `page` / `report`,与 `module` 并列 | 入口与动作分离;动作统一挂模块 | 入口是"能不能进",动作是"能不能做" | -| 4 | 数据范围 `custom` 填单个值 | 支持选择多个组织节点 | "只看上海 + 深圳分公司" | -| 5 | 未说明树的主语 | **模块树**是主体,菜单只是指针 | 权限以模块为核心 | -| 6 | 未涉及 | 权限点自动生成 + 增量同步三原则 | 一客户一库 + 客户可定制 | -| 7 | 动作分内置 / 业务两种,默认策略不同 | 不区分,统一为「动作」,**全部默认拒绝** | 简化模型:只存授权记录,没有禁止记录 | - -> 建议:将第 1、2 条同步修订到《FMS新系统核心表结构设计.md》第 276 行及权限章节,避免两份文档冲突。 - ---- - -## 9. 实施顺序 - -| 阶段 | 内容 | 产出 | -| --- | --- | --- | -| P1 | 建 `s_power` / `s_user_power`(统一编码)+ 权限点自动生成 + `/data/*` 拦截只做动作校验 | 能控制"谁能进哪个模块、能做什么" | -| P2 | 字段裁剪 + 数据范围 WHERE;**收紧 `/data/loaddatabysql`** | 后端强制完整 | -| P3 | 前端 `permissions.js` 改造 + `v-permission` + 菜单过滤 + 表格字段裁剪 | 界面生效 | -| P4 | 配置界面(模块树 + 一屏配置)+ 标准权限包 + 权限诊断 | 好用 | - -P1 与 P2 是同一个拦截层的两批规则,不会返工。**不建议先做 P4**——模型未定,界面做了会返工。 diff --git a/code/fms/demo.html b/code/fms/demo.html deleted file mode 100644 index 3dd21a63..00000000 --- a/code/fms/demo.html +++ /dev/null @@ -1,269 +0,0 @@ - - - - - - FMS 权限分配原型 - - - - - - -
-
-
-
-
F
-
-
FMS 权限中心
-
岗位授权 · 菜单化配置,模块化执行
-
-
- - -
- - - -
-
IT
- -
-
-
-
- -
-
-
-
授权工作台
-

配置岗位能使用哪些业务功能

-

先按业务菜单选择权限等级,系统会自动展开到对应模块。共享模块只配置一次。

-
-
- - 未授权 = 拒绝 · 个人设置不能扩大岗位权限 -
-
- -
-
岗位角色
5
-
当前岗位成员
12
-
已配置业务菜单
6
-
待保存变更
0
-
- -
- - -
-
-
-
-

费用会计

已启用
-
负责费用录入、核对和结算·12 名成员·主岗位
-
-
-
-
-
-
业务菜单权限
权限等级会自动展开为模块动作;“自定义”仅在特殊场景使用
-
- - -
-
- - -
-
- -
- - - - diff --git a/code/fms/demo2.html b/code/fms/demo2.html deleted file mode 100644 index a83d07fb..00000000 --- a/code/fms/demo2.html +++ /dev/null @@ -1,831 +0,0 @@ - - - - - -FMS 权限分配 · 交互原型 - - - - -
- -
-
- - -
-
- - -
-
- -
-
- - -
- -
- -
-
-
- 权限配置树 - 菜单 → 模块 → { 操作 · 敏感信息 · 数据范围 } -
-
-
- - -
- - - - diff --git a/code/fms/fms-api/config/migrations/tools/AddSPowerI18nColumn.java b/code/fms/fms-api/config/migrations/tools/AddSPowerI18nColumn.java new file mode 100644 index 00000000..ddd34214 --- /dev/null +++ b/code/fms/fms-api/config/migrations/tools/AddSPowerI18nColumn.java @@ -0,0 +1,62 @@ +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.Properties; + +/** + * One-off migration: adds b_i18n varchar(150) null to dbo.s_power + * (i18n resource key for permission point names, see FMS新系统核心表结构设计.md 14.1). + * Idempotent: skips the ALTER TABLE when the column already exists. + */ +public final class AddSPowerI18nColumn { + public static void main(String[] args) throws Exception { + Path apiRoot = Path.of("").toAbsolutePath().normalize(); + Path configPath = apiRoot.resolve("config/dbconfigs/g3hd.properties").normalize(); + + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(configPath)) { + properties.load(input); + } + + try (Connection connection = DriverManager.getConnection( + required(properties, "url"), + required(properties, "username"), + required(properties, "password"))) { + if (!"FMS".equalsIgnoreCase(connection.getCatalog())) { + throw new IllegalStateException("Refusing to alter database: " + connection.getCatalog()); + } + try (Statement statement = connection.createStatement()) { + boolean exists; + try (ResultSet rows = statement.executeQuery( + "SELECT CASE WHEN COL_LENGTH(N'dbo.s_power', N'b_i18n') IS NULL THEN 0 ELSE 1 END")) { + rows.next(); + exists = rows.getInt(1) == 1; + } + if (exists) { + System.out.println("SKIP|dbo.s_power.b_i18n already exists"); + } else { + statement.executeUpdate("ALTER TABLE dbo.s_power ADD b_i18n varchar(150) NULL"); + System.out.println("APPLIED|ALTER TABLE dbo.s_power ADD b_i18n varchar(150) NULL"); + } + try (ResultSet rows = statement.executeQuery( + "SELECT c.name, t.name, c.max_length, c.is_nullable " + + "FROM sys.columns c JOIN sys.types t ON t.user_type_id = c.user_type_id " + + "WHERE c.object_id = OBJECT_ID(N'dbo.s_power') AND c.name = N'b_i18n'")) { + if (!rows.next()) throw new IllegalStateException("Column b_i18n not found after migration"); + System.out.println("VERIFY|" + rows.getString(1) + "|" + rows.getString(2) + + "(" + rows.getInt(3) + ")|nullable=" + rows.getInt(4)); + } + } + } + } + + private static String required(Properties properties, String key) { + String value = properties.getProperty(key); + if (value == null || value.isBlank()) throw new IllegalStateException("Missing property: " + key); + return value; + } +} diff --git a/code/fms/fms-api/config/migrations/tools/GenerateBusinessMigrations.java b/code/fms/fms-api/config/migrations/tools/GenerateBusinessMigrations.java deleted file mode 100644 index d4af7a16..00000000 --- a/code/fms/fms-api/config/migrations/tools/GenerateBusinessMigrations.java +++ /dev/null @@ -1,633 +0,0 @@ -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.Statement; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Properties; -import java.util.Set; - -/** - * Reads the legacy SQL Server catalog and emits reviewed, schema-only FMS migrations. - * The generated SQL never connects back to the legacy database and never contains data. - */ -public final class GenerateBusinessMigrations { - private static final Set EXCLUDED_TABLES = lowerSet( - "b_contact", "b_contact_type", "b_othercompany", "b_othercompany_bmfl", - "b_user", "b_user_power", "b_user_qx", "b_user_record", - "dtproperties", "log_datas" - ); - private static final Set TREE_TABLES = lowerSet( - "bc_pricelist_bmfl", "oa_title_bmfl" - ); - private static final Set EXCLUDED_VIEWS = lowerSet( - "v_autoidrule", "v_b_contact", "v_b_employee", "v_b_user_record" - ); - private static final Set BOOLEAN_COLUMNS = lowerSet( - "b_black", "b_canuse", "b_visible", "b_used", "b_default", "b_isdefault", - "b_finish", "b_sh", "b_bill", "b_zx", "b_toall", "b_reminder", - "b_othermachinelogin", "b_salesman", "b_docman", "b_dealman", - "b_visible_customer", "b_visible_supply", "b_levelvisible", "b_fromcustomer", - "b_logintime", "b_checkxy", "b_loginfee", "mx_finish", "mx_sh" - ); - private static final Set TRUE_DEFAULT_COLUMNS = lowerSet( - "b_canuse", "b_visible", "b_used" - ); - private static final Set UNICODE_HINTS = lowerSet( - "name", "memo", "bz", "addr", "address", "subject", "content", "info", - "desc", "caption", "department", "duty", "bank", "goods", "mark", - "remark", "taste", "habit", "situation", "place", "body" - ); - private static final Map BUSINESS_CODE_FIELDS = Map.ofEntries( - Map.entry("bc_khht", "b_htno"), - Map.entry("bs_business", "b_ywno"), - Map.entry("cw_account", "b_account"), - Map.entry("cw_bill", "b_ywno"), - Map.entry("cw_invoice", "b_ywno"), - Map.entry("cw_sfd", "b_ywno") - ); - private static final Map CHILD_PARENTS = Map.ofEntries( - Map.entry("b_othercompany_accountbank", "b_othercompany"), - Map.entry("bc_pricelist_sub", "bc_pricelist"), - Map.entry("bs_business_container", "bs_business"), - Map.entry("bs_business_cw", "bs_business"), - Map.entry("bs_business_hdbl", "bs_business"), - Map.entry("bs_business_transport", "bs_business"), - Map.entry("cw_billsub", "cw_bill"), - Map.entry("cw_invoicesub", "cw_invoice"), - Map.entry("cw_sfdsub", "cw_sfd"), - Map.entry("oa_notices_attach", "oa_notices"), - Map.entry("oa_noticessub", "oa_notices") - ); - private static final Map RENAMED_COLUMNS = Map.ofEntries( - Map.entry("bs_business_cw.mx_type_id", "mx_direction"), - Map.entry("bs_business_transport.mx_hstype_id", "mx_source_type"), - Map.entry("cw_bill.b_module_id", "b_direction"), - Map.entry("cw_bill.b_type_id", "b_document_type"), - Map.entry("cw_invoice.b_module_id", "b_direction"), - Map.entry("cw_invoice.b_type_id", "b_document_type"), - Map.entry("cw_sfd.b_module_id", "b_direction"), - Map.entry("cw_sfd.b_type_id", "b_document_type") - ); - - private record Column( - String name, - String type, - int maxLength, - int precision, - int scale, - boolean nullable, - int ordinal - ) {} - - private record Table(String name, List columns) {} - - public static void main(String[] args) throws Exception { - Path apiRoot = Path.of("").toAbsolutePath().normalize(); - Path sourceConfig = apiRoot.resolve("config/dbconfigs/G3HY2025.properties"); - Path migrations = apiRoot.resolve("config/migrations"); - - Properties properties = new Properties(); - try (InputStream input = Files.newInputStream(sourceConfig)) { - properties.load(input); - } - - try (Connection connection = DriverManager.getConnection( - properties.getProperty("url"), - properties.getProperty("username"), - properties.getProperty("password") - )) { - Map tables = loadTables(connection); - writeDomain(migrations.resolve("016_create_base_tables.sql"), "基础资料", tables, "base"); - writeDomain( - migrations.resolve("017_create_contract_file_tables.sql"), - "合同和文件资料", - tables, - "contract-file" - ); - writeDomain(migrations.resolve("018_create_business_tables.sql"), "业务单据", tables, "business"); - writeDomain(migrations.resolve("019_create_finance_tables.sql"), "财务", tables, "finance"); - writeDomain(migrations.resolve("020_create_oa_tables.sql"), "OA", tables, "oa"); - writeViews(migrations.resolve("021_create_business_views.sql"), connection); - writeVerification(migrations.resolve("verify_business_schema.sql"), tables, connection); - } - } - - private static Map loadTables(Connection connection) throws Exception { - String sql = """ - SELECT t.name AS table_name, c.name AS column_name, ty.name AS type_name, - c.max_length, c.precision, c.scale, c.is_nullable, c.column_id - FROM sys.tables AS t - INNER JOIN sys.schemas AS s ON s.schema_id = t.schema_id - INNER JOIN sys.columns AS c ON c.object_id = t.object_id - INNER JOIN sys.types AS ty ON ty.user_type_id = c.user_type_id - WHERE s.name = N'dbo' AND t.is_ms_shipped = 0 - ORDER BY t.name, c.column_id - """; - Map> columnsByTable = new LinkedHashMap<>(); - try (Statement statement = connection.createStatement(); ResultSet rows = statement.executeQuery(sql)) { - while (rows.next()) { - String tableName = rows.getString("table_name"); - columnsByTable.computeIfAbsent(tableName, ignored -> new ArrayList<>()).add(new Column( - rows.getString("column_name"), - rows.getString("type_name"), - rows.getInt("max_length"), - rows.getInt("precision"), - rows.getInt("scale"), - rows.getBoolean("is_nullable"), - rows.getInt("column_id") - )); - } - } - - Map result = new LinkedHashMap<>(); - for (Map.Entry> entry : columnsByTable.entrySet()) { - if (!includeTable(entry.getKey())) continue; - result.put(entry.getKey().toLowerCase(Locale.ROOT), new Table(entry.getKey(), entry.getValue())); - } - return result; - } - - private static boolean includeTable(String tableName) { - String name = tableName.toLowerCase(Locale.ROOT); - if (EXCLUDED_TABLES.contains(name)) return false; - return name.startsWith("b_") - || name.startsWith("bc_") - || name.startsWith("bf_") - || name.startsWith("bs_") - || name.startsWith("cw_") - || name.startsWith("oa_"); - } - - private static String domainOf(String tableName) { - String name = tableName.toLowerCase(Locale.ROOT); - if (name.startsWith("bc_") || name.startsWith("bf_")) return "contract-file"; - if (name.startsWith("bs_")) return "business"; - if (name.startsWith("cw_")) return "finance"; - if (name.startsWith("oa_")) return "oa"; - return "base"; - } - - private static void writeDomain( - Path target, - String title, - Map tables, - String domain - ) throws Exception { - StringBuilder sql = new StringBuilder(header(title)); - tables.values().stream() - .filter(table -> domainOf(table.name()).equals(domain)) - .sorted(Comparator.comparing(Table::name, String.CASE_INSENSITIVE_ORDER)) - .forEach(table -> sql.append(createTable(table))); - if (domain.equals("finance")) appendFinanceRelations(sql); - sql.append("\nCOMMIT TRANSACTION;\n"); - Files.writeString(target, sql.toString(), StandardCharsets.UTF_8); - } - - private static String header(String title) { - return """ - /* Generated schema-only migration: %s. No legacy data is copied. */ - SET NOCOUNT ON; - SET XACT_ABORT ON; - - IF DB_NAME() <> N'FMS' - THROW 51501, N'本迁移只能在 FMS 目标数据库执行。', 1; - - BEGIN TRANSACTION; - - """.formatted(title); - } - - private static String createTable(Table table) { - String lowerName = table.name().toLowerCase(Locale.ROOT); - if (TREE_TABLES.contains(lowerName)) return createTreeTable(table.name()); - - boolean child = hasColumn(table, "subid"); - String primaryKey = child ? originalColumnName(table, "subid") : "b_id"; - boolean sourceHasStringId = !child && table.columns().stream().anyMatch(column -> - column.name().equalsIgnoreCase("b_id") && isTextType(column.type()) - ); - String businessCode = businessCodeField(table, sourceHasStringId); - - List definitions = new ArrayList<>(); - if (!child && !hasColumn(table, "b_id")) { - definitions.add(" b_id bigint NOT NULL"); - } - for (Column column : table.columns()) { - if (isPluralIdColumn(column.name())) continue; - String targetName = targetColumnName(table, column.name()); - String definition = " " + quote(targetName) + " " + targetType(table, column); - boolean requiredCode = businessCode != null && column.name().equalsIgnoreCase(businessCode); - boolean requiredKey = column.name().equalsIgnoreCase(primaryKey) - || (!child && column.name().equalsIgnoreCase("b_id")); - if (BOOLEAN_COLUMNS.contains(column.name().toLowerCase(Locale.ROOT))) { - int defaultValue = TRUE_DEFAULT_COLUMNS.contains(column.name().toLowerCase(Locale.ROOT)) ? 1 : 0; - definition += " NOT NULL CONSTRAINT " - + quote("DF_" + safeName(table.name()) + "_" + safeName(column.name())) - + " DEFAULT (" + defaultValue + ")"; - } else { - definition += requiredCode || requiredKey ? " NOT NULL" : column.nullable() ? " NULL" : " NOT NULL"; - } - definitions.add(definition); - } - if (sourceHasStringId && businessCode != null && !hasColumn(table, businessCode)) { - definitions.add(" " + quote(businessCode) + " varchar(50) NOT NULL"); - } - definitions.add(" CONSTRAINT " + quote("PK_" + safeName(table.name())) - + " PRIMARY KEY (" + quote(primaryKey) + ")"); - if (businessCode != null) { - definitions.add(" CONSTRAINT " + quote("UQ_" + safeName(table.name()) + "_" + businessCode) - + " UNIQUE (" + quote(businessCode) + ")"); - } - String parentTable = CHILD_PARENTS.get(lowerName); - if (child && parentTable != null && hasColumn(table, "father")) { - definitions.add(" CONSTRAINT " + quote("FK_" + safeName(table.name()) + "_father") - + " FOREIGN KEY (" + quote(originalColumnName(table, "father")) + ") REFERENCES dbo." - + quote(parentTable) + " (b_id)"); - } - - StringBuilder result = new StringBuilder(); - result.append(" IF OBJECT_ID(N'dbo.").append(escape(table.name())).append("', N'U') IS NULL\n"); - result.append(" BEGIN\n"); - result.append(" CREATE TABLE dbo.").append(quote(table.name())).append(" (\n"); - result.append(String.join(",\n", definitions)).append("\n );\n"); - appendIndexes(result, table, primaryKey, businessCode); - result.append(" END;\n\n"); - return result.toString(); - } - - private static String createTreeTable(String tableName) { - String safe = safeName(tableName); - return """ - IF OBJECT_ID(N'dbo.%s', N'U') IS NULL - BEGIN - CREATE TABLE dbo.%s ( - b_id bigint NOT NULL, - b_parent_id bigint NULL, - b_name nvarchar(150) NOT NULL, - b_level int NOT NULL CONSTRAINT %s DEFAULT (1), - b_path varchar(1000) NOT NULL, - b_xh int NOT NULL CONSTRAINT %s DEFAULT (0), - b_canuse int NOT NULL CONSTRAINT %s DEFAULT (1), - b_inputuser_id bigint NULL, - b_inputdatetime datetime2(0) NOT NULL CONSTRAINT %s DEFAULT (SYSDATETIME()), - b_updateuser_id bigint NULL, - b_updatedatetime datetime2(0) NULL, - CONSTRAINT %s PRIMARY KEY (b_id), - CONSTRAINT %s CHECK (b_parent_id IS NULL OR b_parent_id <> b_id), - CONSTRAINT %s FOREIGN KEY (b_parent_id) REFERENCES dbo.%s (b_id) - ); - CREATE INDEX %s ON dbo.%s (b_parent_id, b_xh, b_id); - CREATE INDEX %s ON dbo.%s (b_path, b_xh, b_id); - END; - - """.formatted( - escape(tableName), quote(tableName), - quote("DF_" + safe + "_level"), quote("DF_" + safe + "_xh"), - quote("DF_" + safe + "_canuse"), quote("DF_" + safe + "_inputdatetime"), - quote("PK_" + safe), quote("CK_" + safe + "_parent"), quote("FK_" + safe + "_parent"), - quote(tableName), quote("IX_" + safe + "_parent"), quote(tableName), - quote("IX_" + safe + "_path"), quote(tableName) - ); - } - - private static void appendIndexes( - StringBuilder sql, - Table table, - String primaryKey, - String businessCode - ) { - Set indexed = new LinkedHashSet<>(); - for (Column column : table.columns()) { - String targetName = targetColumnName(table, column.name()); - String lower = targetName.toLowerCase(Locale.ROOT); - if (lower.equals(primaryKey.toLowerCase(Locale.ROOT))) continue; - if (businessCode != null && lower.equals(businessCode.toLowerCase(Locale.ROOT))) continue; - if (lower.equals("father") || (lower.endsWith("_id") && !isAuditUserColumn(lower))) { - if (!indexed.add(lower)) continue; - String indexName = "IX_" + safeName(table.name()) + "_" + safeName(column.name()); - sql.append(" CREATE INDEX ").append(quote(indexName)).append(" ON dbo.") - .append(quote(table.name())).append(" (").append(quote(targetName)) - .append(", ").append(quote(primaryKey)).append(");\n"); - } - } - } - - private static String businessCodeField(Table table, boolean sourceHasStringId) { - String lowerName = table.name().toLowerCase(Locale.ROOT); - String configured = BUSINESS_CODE_FIELDS.get(lowerName); - if (configured != null) return configured; - if (hasColumn(table, "b_code")) return originalColumnName(table, "b_code"); - return sourceHasStringId ? "b_code" : null; - } - - private static String targetType(Table table, Column column) { - String name = column.name().toLowerCase(Locale.ROOT); - String sourceType = column.type().toLowerCase(Locale.ROOT); - String renameKey = table.name().toLowerCase(Locale.ROOT) + "." + name; - if (renameKey.equals("bs_business_cw.mx_type_id") - || renameKey.equals("cw_bill.b_module_id") - || renameKey.equals("cw_invoice.b_module_id") - || renameKey.equals("cw_sfd.b_module_id")) { - return "varchar(20)"; - } - if (renameKey.equals("bs_business_transport.mx_hstype_id") - || renameKey.endsWith(".b_type_id")) { - return "varchar(50)"; - } - if (name.equals("b_id") || name.equals("subid") || name.equals("father") || isReferenceId(name)) { - return "bigint"; - } - if (BOOLEAN_COLUMNS.contains(name) || sourceType.equals("bit")) return "int"; - if (name.endsWith("datetime") || name.endsWith("_datetime") || name.equals("b_inputdate")) { - return "datetime2(0)"; - } - if (name.equals("b_inputtime")) return "time(0)"; - if (name.equals("b_birthday") || name.endsWith("begindate") || name.endsWith("enddate") - || name.endsWith("expiredate")) { - return "date"; - } - if (sourceType.equals("text") || sourceType.equals("ntext")) return "nvarchar(max)"; - if (sourceType.equals("image")) return "varbinary(max)"; - if (sourceType.equals("datetime") || sourceType.equals("smalldatetime")) return "datetime2(0)"; - if (sourceType.equals("float") || sourceType.equals("real")) return "decimal(18,6)"; - if (sourceType.equals("money") || sourceType.equals("smallmoney")) return "decimal(19,4)"; - if (sourceType.equals("decimal") || sourceType.equals("numeric")) { - return sourceType + "(" + column.precision() + "," + column.scale() + ")"; - } - if (sourceType.equals("varchar") || sourceType.equals("char") - || sourceType.equals("nvarchar") || sourceType.equals("nchar")) { - boolean unicode = sourceType.startsWith("n") || hasUnicodeHint(name); - String base = unicode ? "nvarchar" : "varchar"; - int bytesPerChar = sourceType.startsWith("n") ? 2 : 1; - String length = column.maxLength() < 0 ? "max" : String.valueOf(column.maxLength() / bytesPerChar); - return base + "(" + length + ")"; - } - if (sourceType.equals("binary") || sourceType.equals("varbinary")) { - String length = column.maxLength() < 0 ? "max" : String.valueOf(column.maxLength()); - return sourceType + "(" + length + ")"; - } - if (sourceType.equals("timestamp") || sourceType.equals("rowversion")) return "binary(8)"; - if (sourceType.equals("uniqueidentifier")) return "bigint"; - return sourceType; - } - - private static boolean isReferenceId(String name) { - return name.endsWith("_id") - || name.equals("userid") - || name.equals("moduleid") - || name.endsWith("userid") - || name.endsWith("moduleid"); - } - - private static boolean isAuditUserColumn(String name) { - return name.equals("b_inputuser_id") - || name.equals("b_updateuser_id") - || name.equals("mx_inputuser_id") - || name.equals("mx_updateuser_id"); - } - - private static boolean isPluralIdColumn(String name) { - String lower = name.toLowerCase(Locale.ROOT); - return lower.endsWith("_ids") || lower.equals("b_yw_ids") || lower.equals("b_bill_ids") - || lower.equals("b_inv_ids"); - } - - private static boolean hasUnicodeHint(String name) { - for (String hint : UNICODE_HINTS) { - if (name.contains(hint)) return true; - } - return false; - } - - private static boolean isTextType(String type) { - String lower = type.toLowerCase(Locale.ROOT); - return lower.equals("varchar") || lower.equals("nvarchar") || lower.equals("char") - || lower.equals("nchar") || lower.equals("text") || lower.equals("ntext"); - } - - private static boolean hasColumn(Table table, String name) { - return table.columns().stream().anyMatch(column -> column.name().equalsIgnoreCase(name)); - } - - private static String originalColumnName(Table table, String name) { - return table.columns().stream() - .filter(column -> column.name().equalsIgnoreCase(name)) - .findFirst() - .map(Column::name) - .orElse(name); - } - - private static void appendFinanceRelations(StringBuilder sql) { - appendRelation(sql, "cw_bill_business", "b_bill_id", "cw_bill", "b_business_id", "bs_business"); - appendRelation(sql, "cw_invoice_bill", "b_invoice_id", "cw_invoice", "b_bill_id", "cw_bill"); - appendRelation(sql, "cw_invoice_business", "b_invoice_id", "cw_invoice", "b_business_id", "bs_business"); - appendRelation(sql, "cw_sfd_business", "b_sfd_id", "cw_sfd", "b_business_id", "bs_business"); - appendRelation(sql, "cw_sfd_invoice", "b_sfd_id", "cw_sfd", "b_invoice_id", "cw_invoice"); - appendRelation(sql, "cw_sfd_bill", "b_sfd_id", "cw_sfd", "b_bill_id", "cw_bill"); - } - - private static void appendRelation( - StringBuilder sql, - String table, - String leftColumn, - String leftTable, - String rightColumn, - String rightTable - ) { - String safe = safeName(table); - sql.append(" IF OBJECT_ID(N'dbo.").append(table).append("', N'U') IS NULL\n") - .append(" BEGIN\n") - .append(" CREATE TABLE dbo.").append(quote(table)).append(" (\n") - .append(" subid bigint NOT NULL,\n") - .append(" ").append(quote(leftColumn)).append(" bigint NOT NULL,\n") - .append(" ").append(quote(rightColumn)).append(" bigint NOT NULL,\n") - .append(" b_xh int NOT NULL CONSTRAINT ").append(quote("DF_" + safe + "_xh")) - .append(" DEFAULT (0),\n") - .append(" CONSTRAINT ").append(quote("PK_" + safe)).append(" PRIMARY KEY (subid),\n") - .append(" CONSTRAINT ").append(quote("UQ_" + safe)).append(" UNIQUE (") - .append(quote(leftColumn)).append(", ").append(quote(rightColumn)).append("),\n") - .append(" CONSTRAINT ").append(quote("FK_" + safe + "_left")).append(" FOREIGN KEY (") - .append(quote(leftColumn)).append(") REFERENCES dbo.").append(quote(leftTable)).append(" (b_id),\n") - .append(" CONSTRAINT ").append(quote("FK_" + safe + "_right")).append(" FOREIGN KEY (") - .append(quote(rightColumn)).append(") REFERENCES dbo.").append(quote(rightTable)).append(" (b_id)\n") - .append(" );\n") - .append(" END;\n\n"); - } - - private static void writeViews(Path target, Connection connection) throws Exception { - String query = """ - SELECT v.name, sm.definition - FROM sys.views AS v - INNER JOIN sys.schemas AS s ON s.schema_id = v.schema_id - INNER JOIN sys.sql_modules AS sm ON sm.object_id = v.object_id - WHERE s.name = N'dbo' - ORDER BY v.name - """; - StringBuilder sql = new StringBuilder(header("业务视图")); - try (Statement statement = connection.createStatement(); ResultSet rows = statement.executeQuery(query)) { - while (rows.next()) { - String name = rows.getString("name"); - if (!includeView(name)) continue; - String definition = rows.getString("definition"); - if (name.equalsIgnoreCase("v_b_othercompany_bmfl")) { - definition = "CREATE VIEW dbo.v_b_othercompany_bmfl AS " - + "SELECT b_id, b_name FROM dbo.b_othercompany_bmfl"; - } else if (name.equalsIgnoreCase("v_b_feetype")) { - definition = "CREATE VIEW dbo.v_b_feetype AS SELECT " - + "fee.b_id, fee.b_name, fee.b_name_e, fee.b_bz, fee.b_default, " - + "fee.b_amount, fee.b_tax, fee.b_money_id, fee.b_inputuser_id, " - + "fee.b_inputdatetime, fee.b_updateuser_id, fee.b_updatedatetime, money.b_hl " - + "FROM dbo.b_feetype AS fee " - + "LEFT JOIN dbo.b_money AS money ON money.b_id = fee.b_money_id"; - } - definition = removeLegacyPluralIds(definition); - definition = renameLegacySemanticIds(name, definition); - sql.append(" IF OBJECT_ID(N'dbo.").append(escape(name)).append("', N'V') IS NOT NULL\n") - .append(" DROP VIEW dbo.").append(quote(name)).append(";\n") - .append(" EXEC sys.sp_executesql N'") - .append(escape(definition)) - .append("';\n\n"); - } - } - sql.append("COMMIT TRANSACTION;\n"); - Files.writeString(target, sql.toString(), StandardCharsets.UTF_8); - } - - private static boolean includeView(String viewName) { - String name = viewName.toLowerCase(Locale.ROOT); - if (EXCLUDED_VIEWS.contains(name)) return false; - return name.startsWith("v_b_") || name.startsWith("v_bc_") - || name.startsWith("v_bs_") || name.startsWith("v_cw_") - || name.equals("v_feetype"); - } - - private static String removeLegacyPluralIds(String definition) { - String result = definition; - for (String field : List.of("b_yw_ids", "b_bill_ids", "b_inv_ids")) { - result = result.replaceAll( - "(?i)(?:dbo\\.)?[A-Za-z0-9_]+\\." + field + "\\s*,\\s*", - "" - ); - result = result.replaceAll( - "(?i),\\s*(?:dbo\\.)?[A-Za-z0-9_]+\\." + field + "(?=\\s+FROM)", - "" - ); - } - return result; - } - - private static String renameLegacySemanticIds(String viewName, String definition) { - String result = definition; - result = replaceIgnoreCase(result, "dbo.bs_business_cw.mx_type_id", "dbo.bs_business_cw.mx_direction"); - result = replaceIgnoreCase(result, "dbo.bs_business_transport.mx_hstype_id", "dbo.bs_business_transport.mx_source_type"); - result = replaceIgnoreCase(result, "dbo.cw_bill.b_module_id", "dbo.cw_bill.b_direction"); - result = replaceIgnoreCase(result, "dbo.cw_bill.b_type_id", "dbo.cw_bill.b_document_type"); - result = replaceIgnoreCase(result, "dbo.cw_invoice.b_module_id", "dbo.cw_invoice.b_direction"); - result = replaceIgnoreCase(result, "dbo.cw_invoice.b_type_id", "dbo.cw_invoice.b_document_type"); - result = replaceIgnoreCase(result, "dbo.cw_sfd.b_module_id", "dbo.cw_sfd.b_direction"); - result = replaceIgnoreCase(result, "dbo.cw_sfd.b_type_id", "dbo.cw_sfd.b_document_type"); - if (viewName.equalsIgnoreCase("v_bs_business_cw") - || viewName.equalsIgnoreCase("v_bs_business_cw_yf") - || viewName.equalsIgnoreCase("v_bs_business_cw_ys")) { - result = replaceIgnoreCase(result, "mx_type_id", "mx_direction"); - } - if (viewName.equalsIgnoreCase("v_cw_account")) { - result = replaceIgnoreCase(result, "b_type_id", "b_direction"); - result = replaceIgnoreCase( - result, - "a.b_account_id = dbo.cw_account.b_account", - "a.b_account_id = dbo.cw_account.b_id" - ); - } - return result; - } - - private static String replaceIgnoreCase(String value, String source, String replacement) { - return value.replaceAll("(?i)" + java.util.regex.Pattern.quote(source), replacement); - } - - private static String targetColumnName(Table table, String sourceName) { - String key = table.name().toLowerCase(Locale.ROOT) + "." + sourceName.toLowerCase(Locale.ROOT); - return RENAMED_COLUMNS.getOrDefault(key, sourceName); - } - - private static void writeVerification( - Path target, - Map tables, - Connection connection - ) throws Exception { - int expectedTables = tables.size() + 6 + 5; - int expectedViews = 0; - try (Statement statement = connection.createStatement(); ResultSet rows = statement.executeQuery( - "SELECT name FROM sys.views WHERE is_ms_shipped = 0" - )) { - while (rows.next()) if (includeView(rows.getString(1))) expectedViews++; - } - String sql = """ - SET NOCOUNT ON; - - IF DB_NAME() <> N'FMS' - THROW 51510, N'验证脚本只能在 FMS 目标数据库执行。', 1; - - SELECT - (SELECT COUNT(*) FROM sys.tables WHERE is_ms_shipped = 0) AS table_count, - (SELECT COUNT(*) FROM sys.views WHERE is_ms_shipped = 0) AS view_count, - %d AS generated_business_table_minimum, - %d AS generated_business_view_count; - - SELECT OBJECT_SCHEMA_NAME(c.object_id) AS schema_name, - OBJECT_NAME(c.object_id) AS object_name, - c.name AS column_name, - TYPE_NAME(c.user_type_id) AS type_name - FROM sys.columns AS c - INNER JOIN sys.tables AS t ON t.object_id = c.object_id - WHERE t.is_ms_shipped = 0 - AND (c.name = N'b_id' OR c.name = N'subid' OR c.name = N'father' OR c.name LIKE N'%%[_]id') - AND TYPE_NAME(c.user_type_id) <> N'bigint' - ORDER BY schema_name, object_name, c.column_id; - - SELECT s.name AS schema_name, v.name AS view_name - FROM sys.views AS v - INNER JOIN sys.schemas AS s ON s.schema_id = v.schema_id - WHERE v.is_ms_shipped = 0 AND OBJECTPROPERTY(v.object_id, 'IsSchemaBound') IN (0, 1) - AND EXISTS ( - SELECT 1 FROM sys.sql_expression_dependencies AS d - WHERE d.referencing_id = v.object_id AND d.referenced_id IS NULL - AND d.referenced_entity_name IS NOT NULL - ) - ORDER BY s.name, v.name; - """.formatted(expectedTables, expectedViews); - Files.writeString(target, sql, StandardCharsets.UTF_8); - } - - private static String quote(String identifier) { - return "[" + identifier.replace("]", "]]" ) + "]"; - } - - private static String escape(String value) { - return value.replace("'", "''"); - } - - private static String safeName(String value) { - return value.replaceAll("[^A-Za-z0-9_]", "_"); - } - - private static Set lowerSet(String... values) { - Set result = new HashSet<>(); - for (String value : values) result.add(value.toLowerCase(Locale.ROOT)); - return result; - } -} diff --git a/code/fms/fms-api/config/migrations/tools/SimplifyPowerKeys.java b/code/fms/fms-api/config/migrations/tools/SimplifyPowerKeys.java new file mode 100644 index 00000000..62ba0ccc --- /dev/null +++ b/code/fms/fms-api/config/migrations/tools/SimplifyPowerKeys.java @@ -0,0 +1,98 @@ +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +/** + * One-off migration: simplifies s_power keys to bare usage permission codes. + * menu/module permission points drop the action suffix: + * module.s_i18n_type.read/.create/.update/.delete -> module.s_i18n_type + * b_action of the merged rows becomes 'access'; b_name is taken from s_module.b_name when available. + * Idempotent: old-style rows (b_type='module' with CRUD action) are merged only once. + */ +public final class SimplifyPowerKeys { + public static void main(String[] args) throws Exception { + Path apiRoot = Path.of("").toAbsolutePath().normalize(); + Path configPath = apiRoot.resolve("config/dbconfigs/g3hd.properties").normalize(); + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(configPath)) { + properties.load(input); + } + try (Connection connection = DriverManager.getConnection( + properties.getProperty("url"), + properties.getProperty("username"), + properties.getProperty("password"))) { + if (!"FMS".equalsIgnoreCase(connection.getCatalog())) { + throw new IllegalStateException("Refusing to alter database: " + connection.getCatalog()); + } + connection.setAutoCommit(false); + try { + List oldRows = new ArrayList<>(); + String findSql = "SELECT DISTINCT b_object_type, b_object_id FROM dbo.s_power " + + "WHERE b_type = 'module' AND b_action <> 'access'"; + try (Statement statement = connection.createStatement(); + ResultSet rows = statement.executeQuery(findSql)) { + while (rows.next()) { + oldRows.add(new String[]{rows.getString(1), rows.getString(2)}); + } + } + try (PreparedStatement delete = connection.prepareStatement( + "DELETE FROM dbo.s_power WHERE b_type = 'module' AND b_object_type = ? AND b_object_id = ?"); + PreparedStatement insert = connection.prepareStatement( + "INSERT INTO dbo.s_power (b_id, b_name, b_i18n, b_type, b_object_type, b_object_id, b_action, b_canuse, b_xh) " + + "VALUES (?, ?, NULL, 'module', ?, ?, 'access', 1, 0)")) { + for (String[] objectKey : oldRows) { + String objectType = objectKey[0]; + String objectId = objectKey[1]; + delete.setString(1, objectType); + delete.setString(2, objectId); + int removed = delete.executeUpdate(); + String name = lookupModuleName(connection, objectId); + insert.setString(1, "module." + objectId); + insert.setString(2, name); + insert.setString(3, objectType); + insert.setString(4, objectId); + insert.executeUpdate(); + System.out.println("MERGED|" + objectType + ":" + objectId + + "|removed=" + removed + "|new=module." + objectId + "|name=" + name); + } + } + connection.commit(); + System.out.println("COMMITTED|merged_objects=" + oldRows.size()); + } catch (Exception exception) { + connection.rollback(); + throw exception; + } + try (Statement statement = connection.createStatement(); + ResultSet rows = statement.executeQuery( + "SELECT b_id, b_name, b_type, b_object_type, b_object_id, b_action, b_canuse " + + "FROM dbo.s_power ORDER BY b_type, b_id")) { + while (rows.next()) { + System.out.println("ROW|" + rows.getString(1) + "|" + rows.getString(2) + + "|type=" + rows.getString(3) + "|object=" + rows.getString(4) + ":" + rows.getString(5) + + "|action=" + rows.getString(6) + "|canuse=" + rows.getInt(7)); + } + } + } + } + + private static String lookupModuleName(Connection connection, String moduleId) throws Exception { + try (PreparedStatement statement = connection.prepareStatement( + "SELECT b_name FROM dbo.s_module WHERE b_id = ?")) { + statement.setString(1, moduleId); + try (ResultSet rows = statement.executeQuery()) { + if (rows.next() && rows.getString(1) != null && !rows.getString(1).isBlank()) { + return rows.getString(1); + } + } + } + return moduleId; + } +} diff --git a/code/fms/fms-api/tools/migration/FieldNameSync.java b/code/fms/fms-api/tools/migration/FieldNameSync.java index 65ce7661..18a681b6 100644 --- a/code/fms/fms-api/tools/migration/FieldNameSync.java +++ b/code/fms/fms-api/tools/migration/FieldNameSync.java @@ -1,16 +1,19 @@ import java.sql.*; /** - * 从旧库 G3HY2025.dbo.s_columnLib 同步到新库 fms: + * 从旧库 G3HY2025.dbo.s_columnLib 同步到新库 FMS: * 1. col_Caption → s_module_field.b_name(字段名称) * 2. col_Visible → s_module_field_list.b_visible(列表显示,不存在则插入记录) * 3. col_edit → s_module_field_edit.b_visible(编辑显示,不存在则插入记录) * + * 注意:本工具面向 V1 表结构(s_module_field / s_module_field_list / s_module_field_edit), + * 与《FMS新系统核心表结构设计V2》的 s_field / s_module_schema 不兼容,仅作历史参考。 + * * 用法: java -cp ".;mssql-jdbc.jar" FieldNameSync */ public class FieldNameSync { - static final String URL = "jdbc:sqlserver://118.89.70.199:1433;databaseName=fms;encrypt=true;trustServerCertificate=true"; + static final String URL = "jdbc:sqlserver://118.89.70.199:1433;databaseName=FMS;encrypt=true;trustServerCertificate=true"; static final String USER = "sa"; static final String PASSWORD = "zhang520.."; diff --git a/code/fms/fms-api/tools/migration/OtherDataMigration.java b/code/fms/fms-api/tools/migration/OtherDataMigration.java index 91cee0f2..f9f507b7 100644 --- a/code/fms/fms-api/tools/migration/OtherDataMigration.java +++ b/code/fms/fms-api/tools/migration/OtherDataMigration.java @@ -4,9 +4,12 @@ import java.util.*; /** * 其他数据子系统全量迁移工具 * - * 从旧库 G3HY2025 迁移 b_other_bmfl 分类树 + 各基础字典表数据到新库 fms。 + * 从旧库 G3HY2025 迁移 b_other_bmfl 分类树 + 各基础字典表数据到新库 FMS。 * 替代 FieldNameSync.java 的单表单字段同步,一次性完成整个"其他数据"子系统的迁移。 * + * 注意:本工具面向 V1 表结构(s_module_field / s_module_field_list / s_module_field_edit), + * 与《FMS新系统核心表结构设计V2》的 s_field / s_module_schema 不兼容,仅作历史参考。 + * * 4 个阶段: * 1. 发现:读取旧 b_other_bmfl,列出分类和关联表 * 2. 树迁移:创建 category/module 节点树 @@ -23,7 +26,7 @@ import java.util.*; */ public class OtherDataMigration { - static final String URL = "jdbc:sqlserver://118.89.70.199:1433;databaseName=fms;encrypt=true;trustServerCertificate=true"; + static final String URL = "jdbc:sqlserver://118.89.70.199:1433;databaseName=FMS;encrypt=true;trustServerCertificate=true"; static final String USER = "sa"; static final String PASSWORD = "zhang520.."; @@ -121,7 +124,7 @@ public class OtherDataMigration { void printHeader() { System.out.println("=".repeat(60)); System.out.println(" 其他数据子系统全量迁移工具"); - System.out.println(" G3HY2025 → fms"); + System.out.println(" G3HY2025 → FMS"); System.out.println("=".repeat(60)); System.out.println(" dry-run: " + dryRun); System.out.println(" clean-tree: " + cleanTree); diff --git a/code/fms/fms-api/tools/migration/README.md b/code/fms/fms-api/tools/migration/README.md index 97737335..28274f92 100644 --- a/code/fms/fms-api/tools/migration/README.md +++ b/code/fms/fms-api/tools/migration/README.md @@ -1,6 +1,10 @@ # FieldNameSync — 模块字段配置迁移工具 -从旧库 `G3HY2025.dbo.s_columnLib` 同步字段配置到新库 `fms`。 +> **状态:面向 V1 表结构的历史工具。** 本工具操作 `s_module_field` / `s_module_field_list` / +> `s_module_field_edit`,与《FMS新系统核心表结构设计V2》的 `s_field` / `s_module_schema` +> 不兼容,仅作历史参考;V2 的字段迁移需按新结构重写。 + +从旧库 `G3HY2025.dbo.s_columnLib` 同步字段配置到新库 `FMS`。 ## 同步的三项内容 @@ -39,7 +43,7 @@ ### 3. 数据库连接 -工具硬编码连接 `118.89.70.199:1433`,数据库 `fms`,直连新旧两库(同一实例)。 +工具硬编码连接 `118.89.70.199:1433`,数据库 `FMS`,直连新旧两库(同一实例)。 ### 4. 新库已有 `s_module_field_edit.b_visible` 列 diff --git a/code/fms/fms-vue/form-demo.yaml b/code/fms/fms-vue/form-demo.yaml deleted file mode 100644 index 40357050..00000000 --- a/code/fms/fms-vue/form-demo.yaml +++ /dev/null @@ -1,37 +0,0 @@ -- generic [ref=e1]: - - generic [ref=e2]: - - generic [ref=e4]: - - generic [ref=e5]: - - heading "登录 FMS" [level=1] [ref=e9] - - paragraph [ref=e10]: 企业管理系统 - - generic [ref=e11]: - - generic [ref=e12]: - - generic [ref=e13]: - - text: "*" - - generic [ref=e14]: 机构码 - - textbox [active] [ref=e23] - - generic [ref=e24]: - - generic [ref=e25]: - - text: "*" - - generic [ref=e26]: 账号 - - textbox [ref=e34] - - generic [ref=e35]: - - generic [ref=e36]: 密码 - - generic [ref=e40]: - - textbox [ref=e45] - - button "显示密码" [ref=e46] [cursor=pointer] - - button "登录" [ref=e50] [cursor=pointer] - - paragraph [ref=e51]: © 2026 FMS · 企业管理系统 - - complementary "FPS 性能监控" [ref=e52]: - - generic [ref=e53]: - - generic [ref=e54]: FPS - - strong [ref=e55]: "--" - - button "重置 FPS 统计" [ref=e56] [cursor=pointer] - - generic [ref=e61]: - - generic [ref=e62]: avg -- - - generic [ref=e63]: min -- - - status: - - generic: - - generic: FMS - - generic: 数字化管理系统 - - generic: 正在加载,请稍候… \ No newline at end of file diff --git a/code/fms/fms-vue/src/components/fms-module-common/fieldUtils.js b/code/fms/fms-vue/src/components/fms-module-common/fieldUtils.js index 309803fc..93c3b938 100644 --- a/code/fms/fms-vue/src/components/fms-module-common/fieldUtils.js +++ b/code/fms/fms-vue/src/components/fms-module-common/fieldUtils.js @@ -5,33 +5,48 @@ import { isEmptyValue } from './queryUtils' const padDatePart = (number) => String(number).padStart(2, '0') -export function parseOptions(field) { - if (!field?.b_options) return { optionConfig: {}, options: [] } - try { - const parsed = JSON.parse(field.b_options) - let optionConfig = {} - let source = [] - if (Array.isArray(parsed)) { - source = parsed - } else if (parsed && typeof parsed === 'object') { - optionConfig = parsed - if (Array.isArray(parsed.options)) { - source = parsed.options - } else if (Array.isArray(parsed.items)) { - source = parsed.items - } +/** 归一化选项定义:数组 / { options: [] } / { items: [] } */ +function normalizeOptions(parsed) { + let optionConfig = {} + let source = [] + if (Array.isArray(parsed)) { + source = parsed + } else if (parsed && typeof parsed === 'object') { + optionConfig = parsed + if (Array.isArray(parsed.options)) { + source = parsed.options + } else if (Array.isArray(parsed.items)) { + source = parsed.items } - const options = source.map((option) => { - if (option && typeof option === 'object') { - const value = option.value ?? option.key ?? option.id - return { ...option, value, label: String(option.label ?? option.name ?? value ?? '') } - } - return { value: option, label: String(option ?? '') } - }) - return { optionConfig, options } - } catch { - return { optionConfig: {}, options: [] } } + const options = source.map((option) => { + if (option && typeof option === 'object') { + const value = option.value ?? option.key ?? option.id + return { ...option, value, label: String(option.label ?? option.name ?? value ?? '') } + } + return { value: option, label: String(option ?? '') } + }) + return { optionConfig, options } +} + +/** + * 解析字段选项。 + * V2:选项定义在界面配置 JSON 节点上(node.options),优先读取; + * 兼容旧字段行:field.b_options 为 JSON 字符串。 + */ +export function parseOptions(source) { + if (!source) return { optionConfig: {}, options: [] } + if (source.options !== undefined && source.options !== null) { + return normalizeOptions(source.options) + } + if (source.b_options) { + try { + return normalizeOptions(JSON.parse(source.b_options)) + } catch { + return { optionConfig: {}, options: [] } + } + } + return { optionConfig: {}, options: [] } } export function formatDateValue(value, withTime = false) { diff --git a/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js b/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js new file mode 100644 index 00000000..3a9bdeed --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js @@ -0,0 +1,205 @@ +/** + * 界面配置 JSON → 运行时渲染配置 适配层(s_module_schema.b_schema_json 消费侧)。 + * + * module-management 负责生产 JSON(view/edit/query),本文件负责把 JSON 还原为 + * fms-module-list / fms-module-edit 已使用的「行/分组」渲染结构,使运行时组件 + * 无需感知 JSON 节点差异。覆盖: + * - view → 列表列配置行(b_field / b_width / b_visible / b_xh,含节点 options) + * - edit → 编辑分组 + 编辑行(b_group_id / b_colspan / b_readonly / b_required 等) + * - query → 查询条件(field / operator / component / defaultValue) + * - 列宽/顺序回写(栏位设置保存)→ 更新 view/edit JSON 节点 + */ +import { loadDataApi } from '@/services/api' +import { parseSchema } from '@/utils/schemaJson' + +/** 需要还原的界面配置类型 */ +const RENDER_SCHEMA_TYPES = ['view', 'edit', 'query'] + +function sqlText(value) { + return `N'${String(value ?? '').replace(/'/g, "''")}'` +} + +/** 加载模块的三类界面配置对象;缺行时回退默认结构,并记录是否已有持久化行 */ +export async function loadModuleSchemas(moduleId) { + const id = String(moduleId || '').trim() + const result = { + view: parseSchema('view'), + edit: parseSchema('edit'), + query: parseSchema('query'), + existed: { view: false, edit: false, query: false }, + } + if (!id) return result + const res = await loadDataApi( + 's_module_schema', + `b_module_id = ${sqlText(id)}`, + 'b_schema_type ASC', + ) + for (const type of RENDER_SCHEMA_TYPES) { + const row = (res.data || []).find((item) => String(item.b_schema_type) === type) + result[type] = parseSchema(type, row ? row.b_schema_json : null) + result.existed[type] = !!row + } + return result +} + +/** 按文档顺序收集节点树中的所有叶子字段节点(分组节点递归展开) */ +function collectFieldNodes(nodes) { + const out = [] + const walk = (list) => { + for (const node of list || []) { + if (!node || typeof node !== 'object') continue + if (node.type === 'group') { + walk(node.children) + continue + } + if (node.field != null) out.push(node) + } + } + walk(nodes) + return out +} + +/** + * view JSON → 列表配置行(列表页/表格消费)。 + * 返回结构与旧 s_field_view 行兼容,便于复用既有表格渲染。 + */ +export function buildListConfig(viewSchema) { + return collectFieldNodes(viewSchema?.columns || []).map((node, index) => ({ + b_field: String(node.field), + b_width: node.width == null ? null : Number(node.width), + b_visible: node.visible === false ? 0 : 1, + b_xh: (index + 1) * 10, + options: node.options, + })) +} + +/** 编辑分组 id(JSON 无主键,进入运行时生成临时编码) */ +let groupSequence = 0 +function nextGroupId() { + groupSequence += 1 + return `g${Date.now().toString(36)}${groupSequence.toString(36)}` +} + +/** + * edit JSON → { groups, rows }(编辑弹窗消费)。 + * groups 为分组节点(b_id/b_title/b_xh),rows 为字段行(含栅格/只读/必填/默认值)。 + */ +export function buildEditRender(editSchema) { + const groups = [] + const rows = [] + const walk = (list, parentGroupId) => { + for (const node of list || []) { + if (!node || typeof node !== 'object') continue + if (node.type === 'group') { + const gid = nextGroupId() + groups.push({ + b_id: gid, + b_parent_id: parentGroupId, + b_title: node.title || '', + b_i18n: node.i18n ?? null, + b_xh: (groups.length + 1) * 10, + b_canuse: 1, + b_colspan: Number(node.colspan) || 48, + options: node.options, + node, + }) + walk(node.children, gid) + continue + } + if (node.field == null) continue + rows.push({ + b_field: String(node.field), + b_group_id: parentGroupId, + b_visible: node.visible === false ? 0 : 1, + b_required: node.required ? 1 : 0, + b_readonly: node.readonly ? 1 : 0, + b_disabled: node.disabled ? 1 : 0, + b_colspan: Number(node.span) || 24, + b_height: node.height ?? null, + b_placeholder: node.placeholder ?? null, + b_default_value: node.defaultValue ?? null, + b_xh: 0, + options: node.options, + node, + }) + } + } + walk(editSchema?.children || [], null) + rows.forEach((row, index) => { + row.b_xh = (index + 1) * 10 + }) + return { groups, rows } +} + +/** query JSON → 查询条件列表(含 quick 顺序,供页面排序) */ +export function buildQueryRender(querySchema) { + const conditions = [] + for (const group of querySchema?.groups || []) { + for (const condition of group?.conditions || []) { + if (condition?.field == null) continue + conditions.push({ + field: String(condition.field), + operator: condition.operator || 'like', + component: condition.component || '', + defaultValue: condition.defaultValue ?? null, + options: condition.options, + node: condition, + }) + } + } + const quick = Array.isArray(querySchema?.quick) ? [...querySchema.quick] : [] + return { quick, conditions } +} + +/** 按 quick 顺序优先排列条件(quick 未列出的保持原顺序追加在后) */ +export function orderQueryConditions(conditions, quick) { + if (!quick?.length) return conditions + const rank = new Map(quick.map((field, index) => [String(field), index])) + return [...conditions].toSorted((a, b) => { + const ra = rank.has(a.field) ? rank.get(a.field) : Number.MAX_SAFE_INTEGER + const rb = rank.has(b.field) ? rank.get(b.field) : Number.MAX_SAFE_INTEGER + return ra - rb + }) +} + +/** 节点(含分组子树)在 settings 顺序中的最小序号,用于同层稳定排序 */ +function minRank(node, rank) { + if (node?.type === 'group') { + const ranks = (node.children || []).map((child) => minRank(child, rank)).filter((r) => r != null) + return ranks.length ? Math.min(...ranks) : null + } + const key = String(node?.field) + return rank.has(key) ? rank.get(key) : null +} + +/** + * 栏位设置(列宽/顺序)回写到 view/edit JSON 对象,返回新的配置对象。 + * 仅调整同层节点顺序并更新 width,保留分组结构与未知扩展属性。 + */ +export function applyColumnSettings(schemaType, schema, settings) { + const rootKey = schemaType === 'view' ? 'columns' : 'children' + const rank = new Map(settings.map((item, index) => [String(item.b_field), index])) + const width = new Map( + settings.map((item) => [String(item.b_field), item.b_width]).filter(([, w]) => w != null), + ) + const patch = (nodes) => { + const mapped = (nodes || []).map((node) => { + if (!node || typeof node !== 'object') return node + if (node.type === 'group') return { ...node, children: patch(node.children) } + const next = { ...node } + const field = String(next.field) + if (width.has(field)) next.width = Number(width.get(field)) + return next + }) + return mapped.toSorted((a, b) => { + const ra = minRank(a, rank) + const rb = minRank(b, rank) + if (ra == null && rb == null) return 0 + if (ra == null) return 1 + if (rb == null) return -1 + return ra - rb + }) + } + const base = schema || parseSchema(schemaType) + return { ...base, [rootKey]: patch(base[rootKey] || []) } +} diff --git a/code/fms/fms-vue/src/components/fms-module-edit/FmsModuleEditModal.vue b/code/fms/fms-vue/src/components/fms-module-edit/FmsModuleEditModal.vue index d65c7eeb..2c1f4342 100644 --- a/code/fms/fms-vue/src/components/fms-module-edit/FmsModuleEditModal.vue +++ b/code/fms/fms-vue/src/components/fms-module-edit/FmsModuleEditModal.vue @@ -3,7 +3,7 @@ * FmsModuleEditModal —— 元数据驱动的通用新增/编辑弹窗。 * * 与 FmsModuleListPage 同族:传 data-code,组件按模块元数据自动渲染编辑表单, - * 字段/分组/校验/控件类型全部来自数据库(s_field_edit + s_field_group + s_field)。 + * 字段/分组/校验/控件类型全部来自数据库(s_module_schema.edit JSON + s_field)。 * * 渲染结构对齐表单设计器的 FormPreview(组内 24 栅格): * Form(columns=24) → FormGroup(span=整行, 组内 24 栅格) → FormItem(span=b_colspan) @@ -37,6 +37,7 @@ import { loadDataApi, loadDataBySqlApi, nextIdApi, saveObjectApi } from '@/servi import { diffRow } from '@/utils/dataChanges' import { identifierRe, isEmptyValue } from '@/components/fms-module-common/queryUtils' import { parseOptions } from '@/components/fms-module-common/fieldUtils' +import { loadModuleSchemas, buildEditRender } from '@/components/fms-module-common/schemaRender' const props = defineProps({ /** 受控打开 */ @@ -65,8 +66,8 @@ const saving = ref(false) const configError = ref('') const moduleMeta = ref(null) // s_module 行 const fields = ref([]) // s_field -const groups = ref([]) // s_field_group -const editRows = ref([]) // s_field_edit 可见行(含关联字段引用) +const groups = ref([]) // 编辑分组节点(由 edit JSON 还原) +const editRows = ref([]) // 编辑行(由 edit JSON 还原,含关联字段引用) const formData = ref({}) const keyField = ref('b_id') /** 编辑基线(origin):打开弹窗时对 record 深拷贝快照,保存只提交与它的差异;新建模式为 null */ @@ -141,24 +142,18 @@ async function loadConfig() { if (!module) throw new Error(`未找到编码为 ${code} 的启用数据模块`) const moduleId = String(module.b_id) - // 字符串业务键:模块与字段/布局行均以编码/列名字符串关联 - const [fieldRes, editRes, groupRes] = await Promise.all([ - loadDataApi('s_field', `b_module_id = N'${moduleId.replaceAll("'", "''")}'`, 'b_xh ASC, b_field ASC'), + // 字段(s_field)+ 编辑界面配置(s_module_schema.edit JSON) + const [fieldRes, schemas] = await Promise.all([ loadDataApi( - 's_field_edit', + 's_field', `b_module_id = N'${moduleId.replaceAll("'", "''")}'`, 'b_xh ASC, b_field ASC', ), - loadDataApi( - 's_field_group', - `b_module_id = N'${moduleId.replaceAll("'", "''")}'`, - 'b_xh ASC, b_id ASC', - ), + loadModuleSchemas(moduleId), ]) moduleMeta.value = module - keyField.value = module.b_keyfield || 'b_id' - groups.value = groupRes.data || [] + keyField.value = module.b_key_field || 'b_id' // 关联字段信息(字段身份 = 字段列名 b_field) const fieldByField = new Map() @@ -170,9 +165,11 @@ async function loadConfig() { } fields.value = validFields - // 只保留可见行并注入字段对象 + // edit JSON → 分组 + 编辑行;只保留可见行并注入字段对象 + const render = buildEditRender(schemas.edit) + groups.value = render.groups const rows = [] - for (const row of editRes.data || []) { + for (const row of render.rows) { if (Number(row.b_visible) !== 1) continue const field = fieldByField.get(String(row.b_field)) if (!field) continue @@ -204,10 +201,10 @@ function buildFormData() { data[fieldName] = existing } else if (row.field.b_type === 'checkbox') { data[fieldName] = 0 - } else if (isEmptyValue(row.field.b_default_value)) { + } else if (isEmptyValue(row.b_default_value)) { data[fieldName] = row.field.b_type === 'number' || row.field.b_type === 'money' ? null : '' } else { - data[fieldName] = row.field.b_default_value + data[fieldName] = row.b_default_value } } formData.value = data @@ -294,9 +291,9 @@ const resolvedWidth = computed(() => { /** 保存过程上下文:beforeSave 阶段准备的数据,buildSaveData / afterSave 阶段消费 */ let saveCtx = { isNew: false, row: null, extraUpdates: [] } -/** 模块保存表(s_module.b_savetable 优先,其次 b_viewtable) */ +/** 模块保存表(s_module.b_save_table 优先,其次 b_view_table) */ function saveTableName() { - return String(moduleMeta.value?.b_savetable || moduleMeta.value?.b_viewtable || '') + return String(moduleMeta.value?.b_save_table || moduleMeta.value?.b_view_table || '') } /** 构造完整合并行(含主键;checkbox→0/1、''→null 归一化),供保存前准备与 diff 使用 */ @@ -321,7 +318,7 @@ function collectRowData() { /** 保存前校验、数据准备与业务钩子;返回 false 终止保存 */ async function beforeSave() { - // 必填校验:s_field_edit.b_required=1 且值为空 → 提示并定位 + // 必填校验:编辑节点 required 且值为空 → 提示并定位 for (const editRow of editableRows.value) { if (Number(editRow.b_required) !== 1) continue if (editRow.field.b_type === 'checkbox') continue @@ -346,7 +343,7 @@ async function beforeSave() { !Number(row[props.orderField]) ) { try { - const viewTable = String(moduleMeta.value?.b_viewtable || '') + const viewTable = String(moduleMeta.value?.b_view_table || '') if (viewTable) { const res = await loadDataBySqlApi( `SELECT ISNULL(MAX([${props.orderField}]), 0) AS mx FROM [${viewTable}]`, diff --git a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue index 4665ab76..7afde6cf 100644 --- a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue +++ b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue @@ -12,6 +12,15 @@ import { escapeSqlText, } from '@/components/fms-module-common/queryUtils' import { parseOptions } from '@/components/fms-module-common/fieldUtils' +import { + loadModuleSchemas, + buildListConfig, + buildEditRender, + buildQueryRender, + orderQueryConditions, + applyColumnSettings, +} from '@/components/fms-module-common/schemaRender' +import { stableStringify } from '@/utils/schemaJson' import FmsModuleTable from './FmsModuleTable.vue' import FmsQueryToolbar from './FmsQueryToolbar.vue' @@ -56,6 +65,10 @@ const activeFieldById = shallowRef(new Map()) const listConfig = shallowRef([]) const editConfigById = shallowRef(new Map()) const queryItems = shallowRef([]) +// 界面配置 JSON(s_module_schema):栏位设置回写时使用 +const viewSchema = shallowRef(null) +const editSchema = shallowRef(null) +const schemaExisted = shallowRef({ view: false, edit: false, query: false }) const dataSource = shallowRef([]) const searchConditions = shallowRef([]) const keyField = ref('') @@ -106,7 +119,7 @@ const cellSlotNames = computed(() => Object.keys(slots).filter((name) => name.st // ── 模块配置加载 ── function resolveKeyField(module, fields) { - if (identifierRe.test(module.b_keyfield || '')) return module.b_keyfield + if (identifierRe.test(module.b_key_field || '')) return module.b_key_field for (const field of fields) { if (field.b_field === 'b_id') return field.b_field } @@ -153,59 +166,65 @@ async function loadModuleConfig() { 'b_id ASC', ) runtimeData = moduleRes.data?.[0] - if (!runtimeData || !runtimeData.b_viewtable) + if (!runtimeData || !runtimeData.b_view_table) throw new Error('当前路由没有配置可用的数据模块') } const dataId = String(runtimeData.b_id || '').trim() - // 列显示配置:普通模式取列表配置,可编辑模式取编辑配置,二者只取其一。 - const listTable = props.editable ? 's_field_edit' : 's_field_view' - const responses = await Promise.all([ + // 元数据:字段(s_field)+ 三类界面配置(s_module_schema JSON) + const [fieldRes, schemas] = await Promise.all([ loadDataApi('s_field', `b_module_id = N'${escapeSqlText(dataId)}'`, 'b_xh ASC, b_field ASC'), - loadDataApi(listTable, `b_module_id = N'${escapeSqlText(dataId)}'`, 'b_xh ASC, b_field ASC'), - loadDataApi( - 's_field_query', - `b_module_id = N'${escapeSqlText(dataId)}' AND b_canuse = 1`, - 'b_xh ASC, b_field ASC', - ), + loadModuleSchemas(dataId), ]) if (getDataCode() !== code) return false - // 字符串业务键:字段与布局行均以字段列名 b_field 关联 - // (s_field_query 主键含 b_field,只引用本模块字段,无跨模块 b_field_module_id) const fields = [] const fieldById = new Map() - for (const field of responses[0].data || []) { + for (const field of fieldRes.data || []) { if (Number(field.b_canuse) !== 1 || !identifierRe.test(field.b_field || '')) continue fields.push(field) fieldById.set(String(field.b_field), field) } if (getDataCode() !== code) return false - // 可编辑模式下:记录编辑配置的 readonly/disabled,用于生成只读编辑器。 + // 列显示配置:普通模式取 view JSON,可编辑模式取 edit JSON + let listRows = [] const editConfigByIdMap = new Map() - for (const item of props.editable ? responses[1].data || [] : []) { - if (Number(item.b_visible) !== 1) continue - if (!fieldById.has(String(item.b_field))) continue - editConfigByIdMap.set(String(item.b_field), item) + if (props.editable) { + const render = buildEditRender(schemas.edit) + listRows = render.rows + for (const row of render.rows) { + if (Number(row.b_visible) !== 1) continue + if (!fieldById.has(String(row.b_field))) continue + editConfigByIdMap.set(String(row.b_field), row) + } + } else { + listRows = buildListConfig(schemas.view) } editConfigById.value = editConfigByIdMap - // 列表配置:普通模式为 s_field_view,可编辑模式为 s_field_edit。 const lists = [] - for (const item of responses[1].data || []) { - if (item.b_visible !== 1) continue + for (const item of listRows) { + if (Number(item.b_visible) !== 1) continue if (!fieldById.has(String(item.b_field))) continue lists.push(item) } + // 查询条件:query JSON(quick 顺序优先) + const queryRender = buildQueryRender(schemas.query) + const orderedConditions = orderQueryConditions(queryRender.conditions, queryRender.quick) const queries = [] - for (const config of responses[2].data || []) { - const field = fieldById.get(String(config.b_field)) + for (const condition of orderedConditions) { + const field = fieldById.get(condition.field) if (!field) continue - const optionInfo = parseOptions(field) + const optionInfo = parseOptions({ options: condition.options }) queries.push({ - config, + config: { + b_field: condition.field, + b_operator: condition.operator, + b_component: condition.component, + b_default_value: condition.defaultValue, + }, field, optionConfig: optionInfo.optionConfig, options: optionInfo.options, @@ -224,6 +243,9 @@ async function loadModuleConfig() { listConfig.value = lists queryItems.value = queries searchConditions.value = conditions + viewSchema.value = schemas.view + editSchema.value = schemas.edit + schemaExisted.value = schemas.existed keyField.value = resolveKeyField(runtimeData, fields) dirtyRows.value = new Map() newRowKeys.value = new Set() @@ -243,17 +265,17 @@ async function loadModuleConfig() { // ── 数据查询 ── function resolveOrderBy() { if (!dataModule.value) throw new Error('数据模块尚未加载') - const configured = String(dataModule.value.b_orderfield || '').trim() + const configured = String(dataModule.value.b_order_sql || '').trim() if (configured) return configured if (keyField.value) return `${keyField.value} DESC` return 'b_id DESC' } async function fetchData() { - if (!dataModule.value || !dataModule.value.b_viewtable) return + if (!dataModule.value || !dataModule.value.b_view_table) return const currentModule = dataModule.value - const viewName = currentModule.b_viewtable + const viewName = currentModule.b_view_table const currentCode = getDataCode() const requestVersion = ++dataRequestVersion const conditions = [] @@ -307,6 +329,9 @@ async function initialize() { listConfig.value = [] editConfigById.value = new Map() queryItems.value = [] + viewSchema.value = null + editSchema.value = null + schemaExisted.value = { view: false, edit: false, query: false } searchConditions.value = [] keyField.value = '' dirtyRows.value = new Map() @@ -367,34 +392,33 @@ async function saveColumnSettings() { Message.info('当前没有可保存的栏位设置') return } + const moduleId = String(dataModule.value?.b_id || '').trim() + if (!moduleId) return - const updates = [] - for (const row of settings) { - // 字符串业务键:布局配置行主键为 (b_module_id, b_field),无独立 b_id - updates.push({ - b_module_id: row.b_module_id, - b_field: row.b_field, - b_xh: row.b_xh, - b_width: row.b_width, - }) - } + // V2:栏位设置回写到界面配置 JSON(可编辑模式 → edit,普通模式 → view) + const schemaType = props.editable ? 'edit' : 'view' + const baseSchema = schemaType === 'edit' ? editSchema.value : viewSchema.value + const nextSchema = applyColumnSettings(schemaType, baseSchema, settings) columnSaving.value = true try { - // 可编辑表格的列来自编辑配置,栏位设置保存到 s_field_edit;普通模式保存到列表配置。 - const table = props.editable ? 's_field_edit' : 's_field_view' - await saveObjectApi([ - { - table, - key_field: 'b_module_id,b_field', - updates, - }, - ]) - - const currentByField = new Map() - for (const row of listConfig.value) { - currentByField.set(String(row.b_field), row) + const row = { + b_module_id: moduleId, + b_schema_type: schemaType, + b_schema_json: stableStringify(nextSchema), + b_canuse: 1, } + const request = { table: 's_module_schema', key_field: 'b_module_id,b_schema_type' } + if (schemaExisted.value?.[schemaType]) request.updates = [row] + else request.inserts = [row] + await saveObjectApi([request]) + + if (schemaType === 'edit') editSchema.value = nextSchema + else viewSchema.value = nextSchema + + // 本地列表列宽/顺序同步,避免等待整页重载 + const currentByField = new Map() + for (const item of listConfig.value) currentByField.set(String(item.b_field), item) const savedListConfig = [] for (const saved of settings) { const current = currentByField.get(String(saved.b_field)) @@ -440,7 +464,7 @@ function handleCellChange({ row, field }) { function resolveSaveTable() { if (!dataModule.value) throw new Error('数据模块尚未加载') - return String(dataModule.value.b_savetable || dataModule.value.b_viewtable || '') + return String(dataModule.value.b_save_table || dataModule.value.b_view_table || '') } function dirtyUpdateRows() { diff --git a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleTable.vue b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleTable.vue index 9c352766..49099883 100644 --- a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleTable.vue +++ b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleTable.vue @@ -92,14 +92,15 @@ function buildEditor(field, config) { } if (field.b_type === 'checkbox') return { type: 'checkbox' } if (field.b_type === 'select') { - const { options } = parseOptions(field) + // V2:选项来自界面配置节点(config.options);兼容旧字段行 b_options + const { options } = parseOptions({ options: config?.options, b_options: field.b_options }) return { type: 'select', options } } return { type: 'input' } } function createRuntimeColumn(config, field) { - const optionInfo = parseOptions(field) + const optionInfo = parseOptions({ options: config?.options, b_options: field.b_options }) const extension = resolveExtension(field.b_field) const { formatter, clickable, cellClassName, onClick: _onClick, onChange, disabled: cellDisabled, ...columnProps } = extension const slot = slots[`cell-${field.b_field}`] diff --git a/code/fms/fms-vue/src/composables/useHotkeys.js b/code/fms/fms-vue/src/composables/useHotkeys.js index 05e6d92c..8bbeb887 100644 --- a/code/fms/fms-vue/src/composables/useHotkeys.js +++ b/code/fms/fms-vue/src/composables/useHotkeys.js @@ -1,4 +1,4 @@ -import { onBeforeUnmount, onMounted } from 'vue' +import { onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue' // 输入态判断:焦点在 input / textarea / 可编辑区域 function isTypingTarget(e) { @@ -6,9 +6,21 @@ function isTypingTarget(e) { return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable) } +/** + * 主键匹配:优先用 e.key;中文输入法组合中(拼音尚未上屏)时 e.key 为 + * 'Process' / 'Unidentified',此时退回物理键位 e.code('KeyS' → 's')兜底。 + * 否则「输入法激活时按 Ctrl+S」会概率性不匹配,表现为快捷键时灵时不灵。 + */ +function keyMatches(e, key) { + const target = String(key).toLowerCase() + if (String(e.key || '').toLowerCase() === target) return true + const code = String(e.code || '') + return code.startsWith('Key') && code.slice(3).toLowerCase() === target +} + // 快捷键匹配:精确匹配(未声明的修饰符按下则不匹配) function matches(e, s) { - if (e.key.toLowerCase() !== String(s.key).toLowerCase()) return false + if (!keyMatches(e, s.key)) return false // 主修饰键:声明了 mod / ctrl / meta 中任一 → 要求 Ctrl 或 Meta 按下 const needsMod = s.mod === true || s.ctrl === true || s.meta === true const modPressed = e.ctrlKey || e.metaKey @@ -38,7 +50,12 @@ function matches(e, s) { * 组合保存类快捷键(如 Ctrl+S)通常传 false,保证输入时也能保存。 * handler(e) —— 触发回调,默认已 preventDefault 阻止浏览器默认行为。 * - * 生命周期:onMounted 注册、onBeforeUnmount 卸载,组件销毁后自动解绑。 + * 生命周期: + * - 监听注册在 window 的**捕获阶段**:后代元素上的 @keydown.stop / stopPropagation + * 只阻断冒泡,捕获阶段仍能收到,避免个别控件(如结构树重命名输入框)内快捷键失效; + * - onMounted 注册、onBeforeUnmount 卸载; + * - keep-alive 缓存的页面切走后实例仍存活,故 onDeactivated 解绑、onActivated 重新绑定: + * 否则同时打开的多个页签(如模块管理 / 菜单管理)会一起响应同一次 Ctrl+S 而互相干扰。 */ export function useHotkeys(shortcuts) { function onKeydown(e) { @@ -51,6 +68,21 @@ export function useHotkeys(shortcuts) { } } - onMounted(() => window.addEventListener('keydown', onKeydown)) - onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown)) + let bound = false + function bind() { + if (bound) return + window.addEventListener('keydown', onKeydown, true) + bound = true + } + function unbind() { + if (!bound) return + window.removeEventListener('keydown', onKeydown, true) + bound = false + } + + onMounted(bind) + // keep-alive 内首次挂载后也会触发 activated,bind 幂等无需额外判断 + onActivated(bind) + onDeactivated(unbind) + onBeforeUnmount(unbind) } diff --git a/code/fms/fms-vue/src/layouts/components/AppSidebar.vue b/code/fms/fms-vue/src/layouts/components/AppSidebar.vue index 3242bf71..4834ef47 100644 --- a/code/fms/fms-vue/src/layouts/components/AppSidebar.vue +++ b/code/fms/fms-vue/src/layouts/components/AppSidebar.vue @@ -8,6 +8,7 @@ import { Component, GalleryVerticalEnd, LayoutGrid, + Settings, } from '@lucide/vue' import { useAppStore } from '@/stores/app' import { useMenuStore } from '@/stores/menus' @@ -27,7 +28,7 @@ const teams = [ ] const activeTeam = ref(teams[0]) -/* ---- 导航菜单:固定入口(工作台/组件库)+ s_menu 动态菜单 ---- */ +/* ---- 导航菜单:固定入口(工作台/组件库)+ s_menu 动态菜单 + 临时静态菜单 ---- */ const navMain = computed(() => { const items = [ { title: '工作台', url: '/dashboard', icon: LayoutGrid }, @@ -36,14 +37,55 @@ const navMain = computed(() => { // 动态菜单(s_menu):由「菜单管理」维护的导航树追加渲染。 // 可见性已在菜单 store 裁剪:非 g3soft 隐藏编码 module 的菜单,空目录不显示 + const configuredUrls = new Set() for (const node of menuStore.visibleMenus) { const item = toMenuItem(node) - if (item) items.push(item) + if (!item) continue + items.push(item) + collectUrls(item, configuredUrls) } + // 临时静态菜单:模块中心(模块管理 / 菜单管理 / 多语言类型) + const staticItem = buildStaticModuleMenu(configuredUrls) + if (staticItem) items.push(staticItem) + return items }) +/** + * 临时静态菜单 —— 模块中心(模块管理 / 菜单管理 / 多语言类型)。 + * + * 这三个页面已实现并注册路由,但尚未在「菜单管理」(s_menu) 中配置, + * 先在侧栏静态挂上以保证开发期可访问(不受 s_menu 权限裁剪影响)。 + * + * 隐藏方式:在 s_menu 中配置好同路由菜单后,把 STATIC_MODULE_MENU 改为 null + * (或连同下方 buildStaticModuleMenu 调用一并删除)。 + * 去重:s_menu 中已存在的同路由项优先,静态项不会重复渲染。 + */ +const STATIC_MODULE_MENU = { + title: '模块中心', + url: '#', + icon: Settings, + items: [ + { title: '模块管理', url: '/module/module-management' }, + { title: '菜单管理', url: '/module/menu-management' }, + { title: '多语言类型', url: '/module/i18n-type' }, + ], +} + +/** 静态菜单构造:过滤掉 s_menu 已配置的同路由子项;无剩余子项时不渲染该目录 */ +function buildStaticModuleMenu(configuredUrls) { + if (!STATIC_MODULE_MENU) return null + const children = STATIC_MODULE_MENU.items.filter((child) => !configuredUrls.has(child.url)) + return children.length ? { ...STATIC_MODULE_MENU, items: children } : null +} + +/** 递归收集菜单项的路由地址(跳过目录占位 '#'),用于与静态菜单去重 */ +function collectUrls(item, target) { + if (item.url && item.url !== '#') target.add(item.url) + for (const child of item.items || []) collectUrls(child, target) +} + /** s_menu 动态菜单节点 → NavMenuItem 结构(目录/页面/外链,见 NavMenuItem.vue JSDoc) */ function toMenuItem(node) { const type = node.b_menu_type diff --git a/code/fms/fms-vue/src/utils/schemaJson.js b/code/fms/fms-vue/src/utils/schemaJson.js new file mode 100644 index 00000000..be193b74 --- /dev/null +++ b/code/fms/fms-vue/src/utils/schemaJson.js @@ -0,0 +1,55 @@ +/** + * 界面配置 JSON 公共工具(s_module_schema.b_schema_json)。 + * 配置链路(module-management)与运行时渲染链路(fms-module-common)共用: + * 解析库中 JSON、生成默认结构、稳定序列化(用于基线比较与落库)。 + */ + +/** 界面配置类型(对应 s_module_schema.b_schema_type) */ +export const SCHEMA_TYPES = ['view', 'edit', 'query'] + +/** 各类界面配置的默认(空)结构 */ +export function createDefaultSchema(schemaType) { + if (schemaType === 'view') return { schemaVersion: 1, columns: [] } + if (schemaType === 'edit') return { schemaVersion: 1, children: [] } + if (schemaType === 'query') return { schemaVersion: 1, quick: [], groups: [] } + return { schemaVersion: 1 } +} + +/** 解析库中 b_schema_json 文本/对象为配置对象;为空或非法时回退默认结构 */ +export function parseSchema(schemaType, raw) { + let value = raw + if (typeof raw === 'string') { + const text = raw.trim() + if (!text) return createDefaultSchema(schemaType) + try { + value = JSON.parse(text) + } catch { + return createDefaultSchema(schemaType) + } + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return createDefaultSchema(schemaType) + } + if (value.schemaVersion == null) value.schemaVersion = 1 + return value +} + +/** 递归按键排序,得到稳定序列化结果(数组保序),用于基线与脏判断比较 */ +function sortForSerialize(value) { + if (Array.isArray(value)) return value.map(sortForSerialize) + if (value && typeof value === 'object') { + const sorted = {} + for (const key of Object.keys(value).toSorted()) sorted[key] = sortForSerialize(value[key]) + return sorted + } + return value +} + +export function stableStringify(value) { + return JSON.stringify(sortForSerialize(value)) +} + +/** 配置对象 → 稳定 JSON 文本(缺省时用默认结构) */ +export function schemaToJsonText(schemaType, schema) { + return stableStringify(schema ?? createDefaultSchema(schemaType)) +} diff --git a/code/fms/fms-vue/src/views/module/menu-management/MenuPowerLibraryPanel.vue b/code/fms/fms-vue/src/views/module/menu-management/MenuPowerLibraryPanel.vue deleted file mode 100644 index e0d8f516..00000000 --- a/code/fms/fms-vue/src/views/module/menu-management/MenuPowerLibraryPanel.vue +++ /dev/null @@ -1,191 +0,0 @@ - - - - - diff --git a/code/fms/fms-vue/src/views/module/menu-management/MenuPowersPanel.vue b/code/fms/fms-vue/src/views/module/menu-management/MenuPowersPanel.vue deleted file mode 100644 index 3c53ec9e..00000000 --- a/code/fms/fms-vue/src/views/module/menu-management/MenuPowersPanel.vue +++ /dev/null @@ -1,208 +0,0 @@ - - - - - diff --git a/code/fms/fms-vue/src/views/module/menu-management/index.vue b/code/fms/fms-vue/src/views/module/menu-management/index.vue index 6cc2b853..7e0f5121 100644 --- a/code/fms/fms-vue/src/views/module/menu-management/index.vue +++ b/code/fms/fms-vue/src/views/module/menu-management/index.vue @@ -1,17 +1,18 @@