diff --git a/code/fms/FMS新系统核心表结构设计.md b/code/fms/FMS新系统核心表结构设计.md index 9bc30352..c339482f 100644 --- a/code/fms/FMS新系统核心表结构设计.md +++ b/code/fms/FMS新系统核心表结构设计.md @@ -1,781 +1,373 @@ -# FMS 新系统模块核心表结构设计 +# FMS 新系统核心表结构 -> 本方案沿用旧系统以 `s_module` 为核心的元数据模型,删除独立的 `s_entity`;分类节点、数据模块和查询模块统一由 `s_module` 自关联树表达。 -> 平台表以 `s_` 开头,字段以 `b_` 开头,复合词采用驼峰拼接(如 `b_viewtable`、`b_inputdatetime`),逻辑关联字段使用 `b_xxx_id`。 +> 目标结构基于 2026-09-04 只读拉取的 `FMS-NEW` 数据库整理。 +> 平台配置使用字符串业务键;未来业务数据表使用 `bigint` 雪花 ID。 -## 1. 设计原则 - -本方案坚持五项原则: - -1. **沿用模块主体**:`s_module` 继续作为旧系统的数据配置主体,分类节点和数据节点在同一棵树中维护,不再增加 `s_entity`。 -2. **模块与字段分离**:`data/query` 模块声明查询源、保存源和主键字段;字段挂模块保存物理列名、字段类型和数据库列约束,列表、表单、查询行为由字段配置子表承载。 -3. **菜单与模块分离**:`s_menu` 是纯导航树,页面菜单引用数据模块,外链菜单保存外链地址;本方案只恢复旧系统的模块数据主体,不把菜单、路由和角色授权重新塞回 `s_module`。 -4. **字段定义与视图配置分责**:字段定义、列表、表单、查询是四类不同职责;本方案保留 `s_field` 作为字段定义表,并将列表、表单、查询行为分别放入 `s_field_view`、`s_field_edit`、`s_field_query`,避免一张宽表充满互不相关的空字段。 -5. **配置入口与存储职责分离**:管理员可从模块树或菜单树定位数据模块,在一个配置工作台内完成数据源、字段、列表、表单、查询、编码和权限配置;底层仍按模块、菜单和权限分别存储。 - -其余约定: - -- 内部主键 `b_id bigint` 由应用层雪花算法生成(全局唯一、带时间序,配置跨系统导出/导入时引用原样保留、无需重映射);跨环境稳定标识使用 `b_code`(参考旧系统)。 -- 全系统不建立物理外键;关联字段保留索引,引用完整性、跨模块一致性、停用与删除保护统一由服务层维护。 -- 布尔语义使用 `int`(0/1),与旧系统 `s_module` 风格一致。 -- 时间统一使用 UTC:字段类型为 `datetime2`,默认 `sysutcdatetime()`;前端按用户时区转换显示。 -- 审计字段:定义类表保留 `b_inputuser_id / b_inputdatetime / b_updateuser_id / b_updatedatetime`;关联类表只保留 `b_inputdatetime`。 -- `s_module.b_parent_id` 只表达模块树位置;模块间真实的引用、主从、外键和级联关系由 `s_relation` 表达。 -- `b_module_type` 使用 `category/data/query`:分类节点不配置数据源,数据节点可查可存,查询节点只读。 -- 一个数据模块默认只有一套字段和页面配置;菜单只是模块的导航入口,不额外引入菜单级字段覆盖层。 - -## 2. 表清单 - -| # | 表名 | 说明 | -| --- | --- | --- | -| 1 | `s_module` | 模块树(分类/数据/查询节点,数据所有者) | -| 2 | `s_field` | 字段定义(数据库列与通用控件元数据) | -| 3 | `s_field_group` | 字段分组(模块级) | -| 4 | `s_field_view` | 字段视图配置(列表显示、排序、列宽、分组) | -| 5 | `s_field_edit` | 字段表单配置(显示、排序、分组、必填、只读、禁用) | -| 6 | `s_field_query` | 字段查询配置(关联字段、操作符、默认值) | -| 7 | `s_relation` | 模块关系(引用/主从/多对多,驱动外键校验与级联) | -| 8 | `s_menu` | 菜单/页面导航(页面菜单绑定数据模块) | -| 9 | `s_autocode` | 自动编码规则 | -| 10 | `s_role` | 角色 | -| 11 | `s_user_role` | 用户-角色 | -| 12 | `s_power` | 权限点(menu/module/action/field) | -| 13 | `s_role_power` | 角色-权限(允许/拒绝) | -| 14 | `s_role_scope` | 角色-模块数据范围 | -| 15 | `s_i18n` | 多语言资源 | -| 16 | `s_log_technical` | 技术日志(请求链路/SQL/异常/性能) | -| 17 | `s_log_login` | 登录日志(登录/退出/失败/锁定) | -| 18 | `s_log_audit` | 业务审计事件 | -| 19 | `s_log_audit_field` | 审计字段变更明细 | -## 3. 各表结构 - -### 3.1 s_module 模块 - -沿用旧系统核心语义:`s_module` 既是模块树节点,也是数据配置主体。`category` 节点只负责分类;`data` 节点可查询和保存;`query` 节点只查询不保存。菜单、角色和权限仍由独立表维护。 +## 1. 用户表 ```sql -create table s_module ( - b_id bigint not null, -- 雪花主键(应用层生成,全局唯一) - b_code varchar(50) not null, -- 模块编码(全局唯一) - b_parent_id bigint null, -- 父模块(配置树位置) - b_name nvarchar(100) not null, -- 模块名称 - b_i18n varchar(150) null, -- 显示名多语言 key - b_module_type varchar(20) not null, -- category/data/query - b_viewtable varchar(128) null, -- 查询数据源(data/query 使用) - b_savetable varchar(128) null, -- 保存目标(data 可用,query 必须为空) - b_keyfield varchar(50) null, -- 主键字段(data/query 使用) - b_orderfield varchar(500) null, -- 默认排序表达式 - b_delete_policy varchar(20) null, -- restrict/lifecycle/hard;级联由 s_relation 表达 - b_canuse int not null default 1, -- 是否启用 - b_xh int not null default 0, -- 排序号 - b_bz nvarchar(1000) null, -- 描述 - b_inputuser_id bigint null, - b_inputdatetime datetime2 null default sysutcdatetime(), - b_updateuser_id bigint null, - b_updatedatetime datetime2 null, - constraint pk_s_module primary key (b_id), - constraint ux_s_module_code unique (b_code), - constraint ck_s_module_type check (b_module_type in ('category', 'data', 'query')), - constraint ck_s_module_delete_policy check (b_delete_policy is null or b_delete_policy in ('restrict', 'lifecycle', 'hard')), - constraint ck_s_module_config check ( - (b_module_type = 'category' and b_viewtable is null and b_savetable is null and b_keyfield is null and b_orderfield is null and b_delete_policy is null) - or (b_module_type = 'data' and b_viewtable is not null and b_savetable is not null and b_keyfield is not null and b_delete_policy is not null) - or (b_module_type = 'query' and b_viewtable is not null and b_savetable is null and b_delete_policy is null) - ), - constraint ck_s_module_parent check (b_parent_id is null or b_parent_id <> b_id), - -- constraint fk_s_module_parent foreign key (b_parent_id) references s_module (b_id) -); - -create index ix_s_module_parent on s_module (b_parent_id, b_xh, b_canuse, b_id); -create index ix_s_module_savetable on s_module (b_savetable); -``` - -模块的查/存配置方式: - -```text -分类模块: b_module_type = category, 查/存/主键/排序/删除策略全部为空 -数据模块: b_module_type = data, b_viewtable/b_savetable/b_keyfield/b_delete_policy 必填 -查询模块: b_module_type = query, b_viewtable 必填, b_savetable/b_delete_policy 必须为空;汇总查询可不填 b_keyfield -``` - -数据库约束只能判断空值,服务层还必须拒绝空字符串,并校验表、视图、主键和排序字段真实存在。模块树、菜单树和字段分组树在修改父节点时必须检查完整祖先链,禁止 `A -> B -> C -> A` 形式的间接循环。 - -删除策略取值(`b_delete_policy`): - -```text -restrict 主数据:有引用时拒绝删除,只能停用或合并 -lifecycle 业务单据:草稿且无下游时可删,正式后只能取消/作废 -hard 临时记录:校验权限后直接物理删除 -``` - -模块删除策略只控制“直接删除当前模块记录”是否允许;主记录删除时如何处理从记录由具体 `s_relation.b_delete_policy` 决定,因此模块层不再重复设置 `cascade`。 - -### 3.2 s_field 字段 - -只保存字段本身的规范定义,不混入列表、表单和查询布局。字段同步、数据库列校验和业务审计均以本表为准;三类界面行为分别放在后续配置表中。 - -```sql -create table s_field ( - b_id bigint not null, -- 雪花主键(应用层生成,全局唯一) - b_module_id bigint not null, -- 所属数据模块 - b_field varchar(50) not null, -- 物理列名(与真实表列一致) - b_name nvarchar(100) not null, -- 字段名称 - b_i18n varchar(150) null, -- 显示名多语言 key - b_type varchar(30) not null, -- 字段类型(input/textarea/number/money/date/datetime/checkbox/select/selectId...) - b_dbtype varchar(30) not null, -- 数据库存储类型(varchar/nvarchar/int/bigint/decimal/date/datetime2/bit/text) - b_length int null, -- 长度 - b_precision int null, -- 数值精度(总位数) - b_scale int null, -- 数值小数位 - b_nullable int not null default 1, -- 数据库层是否可空 - b_default_value nvarchar(500) null, -- 固定业务/UI 默认值(文本或 JSON 表达) - b_db_default nvarchar(500) null, -- 数据库列默认表达式(结构同步得到) - b_writemode varchar(20) not null default 'direct', -- direct/createOnly/readOnly/computed/system - b_options nvarchar(max) null, -- 字段类型配置(JSON;静态选项或 selectId 选择器配置) - b_canuse int not null default 1, -- 是否启用(停用隐藏并保留数据) - b_inputuser_id bigint null, - b_inputdatetime datetime2 null default sysutcdatetime(), - b_updateuser_id bigint null, - b_updatedatetime datetime2 null, - constraint pk_s_field primary key (b_id), - constraint ux_s_field_module_field unique (b_module_id, b_field), - constraint ux_s_field_module_id unique (b_module_id, b_id), - -- constraint fk_s_field_module foreign key (b_module_id) references s_module (b_id), - constraint ck_s_field_type check (b_type in ('input', 'textarea', 'number', 'money', 'date', 'datetime', 'checkbox', 'select', 'selectId')), - constraint ck_s_field_dbtype check (b_dbtype in ('varchar', 'nvarchar', 'char', 'nchar', 'int', 'bigint', 'smallint', 'tinyint', 'decimal', 'numeric', 'float', 'real', 'date', 'datetime', 'datetime2', 'bit', 'text', 'ntext', 'uniqueidentifier')), - constraint ck_s_field_writemode check (b_writemode in ('direct', 'createOnly', 'readOnly', 'computed', 'system')) -); - -create index ix_s_field_module on s_field (b_module_id, b_canuse, b_id); -``` - -`b_type` 沿用旧系统语义,直接指定字段的业务/控件类型,而不是数据库字段类型。数据库真实列类型记录在 `b_dbtype`,用于同步和结构校验;前端默认控件、值转换和展示规则由 `b_type` 决定。服务层必须执行类型兼容校验:`checkbox` 只能映射 `bit/int`;`number` 映射整数或数值类型;`money` 映射 `decimal/numeric`;`date` 映射 `date/datetime/datetime2`;`datetime` 映射 `datetime/datetime2`;`selectId` 映射可承载目标 ID 的整数、字符或 `uniqueidentifier`;`input/textarea` 映射字符类型;`select` 可映射字符或整数类型。`b_default_value` 只表示新增表单时可直接使用的固定默认值,`b_db_default` 是数据库列默认表达式,两者不能混用。 - -`b_writemode` 是通用保存服务的强制规则:`direct` 可新增可修改,`createOnly` 仅新增时写入,`readOnly/computed` 永不接收客户端写入,`system` 仅允许受信任的系统逻辑写入。查询视图中的显示列、计算列和 `v_xxx` 字段必须标记为 `readOnly/computed`,即使它们出现在 `s_field_edit` 中也不能被保存接口写入。 - -依赖其他业务数据的默认值不在 `s_field` 中配置通用规则,也不增加默认值类型字段。此类值由具体业务模块的服务逻辑处理:例如新增业务单据时,根据单据币种和业务日期查询汇率模块,将汇率写入单据作为当时的业务快照;用户修改币种或日期时,由该业务服务重新计算,是否覆盖人工修改由具体业务规则决定。 - -第一阶段沿用旧系统已有类型: - -```text -input 单行文本 -textarea 多行文本 -number 数字 -money 金额 -date 日期 -datetime 日期时间 -checkbox 布尔/复选框 -select 静态枚举下拉选择,直接保存选项值 -selectId 关联模块选择器,保存目标记录 ID -``` - -`select` 的静态选项直接放在 `b_options`。`selectId` 必须对应一条以当前字段作为外键端的启用 `s_relation`;`b_options` 只保存选择器展示配置,例如 `relationCode`、`labelField`、`searchFields` 和是否允许清空,不保存目标模块的动态数据。服务层保存配置时必须校验 relation、目标模块以及显示/搜索字段真实存在且已启用。后续增加人员选择、附件、级联选择等自定义类型时,继续扩展 `b_type` 的受控枚举,并在 `b_options` 保存该类型的专用配置。 - -示例: - -```json -{ - "relationCode": "booking.customer", - "labelField": "b_name", - "searchFields": ["b_code", "b_name"], - "allowClear": true -} -``` - -### 3.3 s_field_group 字段分组 - -分组是模块级独立定义,支持分组树。列表和表单可以分别通过自己的配置表引用同一个分组,也可以选择不同分组。配置表冗余保存 `b_module_id`,通过复合外键保证字段、分组和配置始终属于同一个模块。 - -```sql -create table s_field_group ( - b_id bigint not null, -- 雪花主键(应用层生成,全局唯一) - b_module_id bigint not null, -- 所属数据模块 - b_parent_id bigint null, -- 父分组(分组树) - b_title nvarchar(100) not null, -- 分组标题 - b_i18n varchar(150) null, -- 标题多语言 key - b_layout_direction varchar(10) null, -- 分组内字段排列方向(vertical/horizontal) - b_colspan int not null default 48, -- 分组在外层 48 宫格中的占格数(默认 48 独占一行) - b_xh int not null default 0, -- 分组排序 - b_canuse int not null default 1, -- 是否启用(停用整组隐藏) - constraint pk_s_field_group primary key (b_id), - constraint ux_s_field_group_module_id unique (b_module_id, b_id), - constraint ck_s_field_group_parent check (b_parent_id is null or b_parent_id <> b_id), - -- constraint fk_s_field_group_module foreign key (b_module_id) references s_module (b_id), - -- constraint fk_s_field_group_parent foreign key (b_module_id, b_parent_id) references s_field_group (b_module_id, b_id) -); - -create index ix_s_field_group_module on s_field_group (b_module_id, b_xh, b_canuse); -``` - -### 3.4 s_field_view 字段视图配置 - -保存字段在列表中的显示、顺序、宽度和分组。每个模块字段最多一条列表配置;未创建配置记录表示该字段不进入列表。 - -```sql -create table s_field_view ( - b_id bigint not null, - b_module_id bigint not null, -- 所属数据模块(与字段、分组一致) - b_field_id bigint not null, -- 字段 - b_group_id bigint null, -- 列表分组 - b_i18n varchar(150) null, -- 列表标题多语言 key(空则继承字段) - b_visible int not null default 1, -- 是否显示 - b_xh int not null default 0, -- 列表排序 - b_width int null, -- 列宽(px) - constraint pk_s_field_view primary key (b_id), - constraint ux_s_field_view_field unique (b_module_id, b_field_id), - -- constraint fk_s_field_view_module foreign key (b_module_id) references s_module (b_id), - -- constraint fk_s_field_view_field foreign key (b_module_id, b_field_id) references s_field (b_module_id, b_id), - -- constraint fk_s_field_view_group foreign key (b_module_id, b_group_id) references s_field_group (b_module_id, b_id) -); - -create index ix_s_field_view_order on s_field_view (b_xh, b_id); -``` - -### 3.5 s_field_edit 字段表单配置 - -保存字段在新增、编辑表单中的布局和交互行为。每个模块字段最多一条表单配置;字段的默认控件类型由 `s_field.b_type` 决定,本表只配置当前表单中的显示、必填、只读和禁用状态。 - -```sql -create table s_field_edit ( - b_id bigint not null, - b_module_id bigint not null, -- 所属数据模块(与字段、分组一致) - b_field_id bigint not null, -- 字段 - b_group_id bigint null, -- 表单分组 - b_visible int not null default 1, -- 是否显示 - b_required int not null default 0, -- 是否必填 - b_readonly int not null default 0, -- 是否只读 - b_disabled int not null default 0, -- 是否禁用 - b_xh int not null default 0, -- 表单排序 - constraint pk_s_field_edit primary key (b_id), - constraint ux_s_field_edit_field unique (b_module_id, b_field_id), - -- constraint fk_s_field_edit_module foreign key (b_module_id) references s_module (b_id), - -- constraint fk_s_field_edit_field foreign key (b_module_id, b_field_id) references s_field (b_module_id, b_id), - -- constraint fk_s_field_edit_group foreign key (b_module_id, b_group_id) references s_field_group (b_module_id, b_id) -); - -create index ix_s_field_edit_order on s_field_edit (b_xh, b_id); -``` - -数据库可空性以 `s_field.b_nullable` 为准,数据库默认值以 `s_field.b_db_default` 为准,表单必填以 `s_field_edit.b_required` 为准;表单组件直接按 `s_field.b_type` 选择,不在 `s_field_edit` 重复配置。元数据校验器应保证:数据库不可空、无数据库默认值且允许客户端写入的字段,新增表单不能配置为非必填;`readOnly/computed/system` 字段不能配置为客户端必填。 - -### 3.6 s_field_query 字段查询配置 - -查询条件是可选集合,只有需要进入查询区的字段才创建记录。第一阶段所有启用条件按 `b_xh` 排序,并统一使用 `AND` 组合;暂不支持 `OR`、嵌套括号和条件组。同一字段可以配置多个不同操作符,也允许通过 `b_condition_no` 配置多个相同操作符的条件。`b_module_id` 表示查询所属模块,`b_field_module_id` 表示条件字段实际所属模块;查询本模块字段时二者相同,关联查询时必须通过 `b_relation_id` 指向一条从查询模块可达的启用关系。查询组件优先使用配置中的 `b_component`;为空时再根据 `s_field.b_type` 和操作符推导。 - -```sql -create table s_field_query ( - b_id bigint not null, - b_module_id bigint not null, -- 查询所属模块 - b_field_module_id bigint not null, -- 条件字段所属模块 - b_field_id bigint not null, -- 条件字段 - b_relation_id bigint null, -- 关联字段使用的模块关系 - b_component varchar(30) null, -- 查询控件覆盖(input/select/date/datetime/number/checkbox...) - b_condition_no int not null default 1, -- 同模块同字段同操作符的条件序号 - b_operator varchar(20) not null, -- eq/like/gt/ge/lt/le/in/between - b_default_value nvarchar(500) null, -- 默认查询值 - b_canuse int not null default 1, -- 是否启用 - b_xh int not null default 0, -- 查询条件排序 - constraint pk_s_field_query primary key (b_id), - constraint ux_s_field_query_condition unique (b_module_id, b_field_module_id, b_field_id, b_relation_id, b_operator, b_condition_no), - -- constraint fk_s_field_query_module foreign key (b_module_id) references s_module (b_id), - -- constraint fk_s_field_query_field foreign key (b_field_module_id, b_field_id) references s_field (b_module_id, b_id) -); - -create index ix_s_field_query_order on s_field_query (b_canuse, b_xh, b_id); -``` - -`b_operator` 表达查询条件的比较语义,默认控件由字段定义和操作符组合推导:`b_options` 非空时优先使用下拉/多选,`datetime` 使用日期或日期时间控件,数值和日期字段在 `between` 操作符下使用范围控件。服务层必须校验操作符与字段类型匹配,并将 `in`/`between` 的默认值按约定 JSON/数组格式解析。若 `b_relation_id` 为空,字段必须属于查询模块;若不为空,关系必须连接查询模块与字段模块,并验证字段确实位于关系指定的一侧。第一阶段运行时对所有启用条件追加 `AND`,未来如需 `OR` 或括号,再增加独立的条件组模型。 - -### 3.7 s_relation 模块关系 - -描述模块间的引用与主子关系,运行时据此执行外键校验、主子事务与级联删除。`b_module_id` 和 `b_target_module_id` 是关系两端,不预设哪一端保存外键;`b_foreign_side` 明确外键位于哪一端,两个端点字段都使用 `s_field` 主键引用,避免只写物理列名导致跨模块歧义。 - -```sql -create table s_relation ( - b_id bigint not null, -- 雪花主键(应用层生成,全局唯一) - b_code varchar(50) not null, -- 跨环境稳定关系编码 - b_module_id bigint not null, -- 关系端 A 模块 - b_target_module_id bigint not null, -- 关系端 B 模块 - b_relation_type varchar(20) not null, -- one-to-one/one-to-many/many-to-one/many-to-many - b_module_field_id bigint not null, -- 端 A 关联字段 - b_target_field_id bigint not null, -- 端 B 关联字段 - b_foreign_side varchar(10) null, -- 普通关系外键所在端:module/target - b_junction_module_id bigint null, -- many-to-many 中间模块 - b_junction_module_field_id bigint null, -- 中间模块指向端 A 字段 - b_junction_target_field_id bigint null, -- 中间模块指向端 B 字段 - b_ownership varchar(20) not null default 'reference', -- reference 引用/owned 主从 - b_owner_side varchar(10) null, -- owned 时主/拥有者端:module/target - b_delete_policy varchar(20) null, -- restrict/set-null/cascade/archive - b_order_module_id bigint null, -- 从/被拥有模块 - b_order_field_id bigint null, -- 从模块排序字段 - b_canuse int not null default 1, -- 是否启用 - b_xh int not null default 0, -- 排序号 - constraint pk_s_relation primary key (b_id), - constraint ux_s_relation_code unique (b_code), - -- constraint fk_s_relation_module foreign key (b_module_id) references s_module (b_id), - -- constraint fk_s_relation_target_module foreign key (b_target_module_id) references s_module (b_id), - -- constraint fk_s_relation_module_field foreign key (b_module_id, b_module_field_id) references s_field (b_module_id, b_id), - -- constraint fk_s_relation_target_field foreign key (b_target_module_id, b_target_field_id) references s_field (b_module_id, b_id), - -- constraint fk_s_relation_junction_module foreign key (b_junction_module_id) references s_module (b_id), - -- constraint fk_s_relation_junction_module_field foreign key (b_junction_module_id, b_junction_module_field_id) references s_field (b_module_id, b_id), - -- constraint fk_s_relation_junction_target_field foreign key (b_junction_module_id, b_junction_target_field_id) references s_field (b_module_id, b_id), - -- constraint fk_s_relation_order_module foreign key (b_order_module_id) references s_module (b_id), - -- constraint fk_s_relation_order_field foreign key (b_order_module_id, b_order_field_id) references s_field (b_module_id, b_id), - constraint ux_s_relation_key unique (b_module_id, b_target_module_id, b_module_field_id, b_target_field_id), - constraint ck_s_relation_self_fields check (b_module_id <> b_target_module_id or b_module_field_id <> b_target_field_id), - constraint ck_s_relation_type check (b_relation_type in ('one-to-one', 'one-to-many', 'many-to-one', 'many-to-many')), - constraint ck_s_relation_foreign_side check (b_foreign_side is null or b_foreign_side in ('module', 'target')), - constraint ck_s_relation_ownership check (b_ownership in ('reference', 'owned')), - constraint ck_s_relation_owner_side check (b_owner_side is null or b_owner_side in ('module', 'target')), - constraint ck_s_relation_shape check ( - (b_relation_type = 'many-to-many' and b_foreign_side is null and b_junction_module_id is not null and b_junction_module_field_id is not null and b_junction_target_field_id is not null) - or (b_relation_type <> 'many-to-many' and b_foreign_side is not null and b_junction_module_id is null and b_junction_module_field_id is null and b_junction_target_field_id is null) - ), - constraint ck_s_relation_owned check ( - (b_ownership = 'reference' and b_owner_side is null) - or (b_ownership = 'owned' and b_owner_side is not null and b_relation_type <> 'many-to-many') - ), - constraint ck_s_relation_order check ( - (b_order_module_id is null and b_order_field_id is null) - or (b_order_module_id is not null and b_order_field_id is not null) - ), - constraint ck_s_relation_delete_policy check (b_delete_policy is null or b_delete_policy in ('restrict', 'set-null', 'cascade', 'archive')) -); - -create index ix_s_relation_target on s_relation (b_target_module_id); - --- 不建物理外键:s_field_query.b_relation_id -> s_relation.b_id 为逻辑外键,由服务层校验 -``` - -关系配置必须满足以下规则: - -- `b_code` 是跨环境稳定标识,`selectId.b_options.relationCode` 和配置导入导出都使用该编码;运行时内部关联仍可使用 `b_id`。 -- 允许同一模块自关联,用于组织树、父子记录等场景;自关联时两端字段不能相同,并仍需通过 `b_foreign_side` 明确父键和外键端。 -- `one-to-one/one-to-many/many-to-one` 不填写中间模块字段且必须明确 `b_foreign_side`;`many-to-many` 必须同时填写中间模块及两个中间外键字段,且不填写 `b_foreign_side`。 -- 普通关系由 `b_foreign_side` 指定哪一端保存外键;外键字段与另一端被引用字段类型必须兼容;`one-to-one` 还要求外键字段具有唯一约束。 -- `one-to-many`/`many-to-one` 必须与 `b_foreign_side` 的方向一致:外键在端 B 时端 A 为“一”,外键在端 A 时端 B 为“一”。`set-null` 仅允许实际外键字段可空时使用。 -- `reference` 不填写 `b_owner_side`,默认删除策略为 `restrict/set-null`;`owned` 必须明确 `b_owner_side`,另一端才是从/被拥有端,只有 owned 关系允许 `cascade/archive`、主从事务保存和从表排序。 -- `b_order_module_id/b_order_field_id` 必须同时为空或同时有值;有值时模块必须是 owned 关系的从端,字段必须属于该模块。 -- 模块自身的 `b_delete_policy` 决定该模块记录能否进入删除流程;关系的 `b_delete_policy` 决定删除发生后如何处理关联记录,两者都必须通过才可执行。 -- 同一对模块可以存在多条关系,但“两端模块 + 两侧字段”必须唯一;配置、导入导出使用关系 `b_code`,运行时和关联查询使用关系 `b_id`。 - -### 3.8 s_menu 菜单/页面 - -菜单是独立导航树,不保存字段、列表或表单配置。`page` 菜单通过 `b_module_id` 绑定一个 `data/query` 模块,并直接使用该模块的字段、列表、表单和查询配置。 - -```sql -create table s_menu ( - b_id bigint not null, -- 雪花主键(应用层生成,全局唯一) - b_parent_id bigint null, -- 父菜单(导航树自引用) - b_code varchar(50) not null, -- 菜单编码 - b_name nvarchar(100) not null, -- 菜单名称 - b_i18n varchar(150) null, -- 显示名多语言 key - b_menu_type varchar(20) not null, -- directory/page/external - b_route varchar(200) null, -- 页面路由或外链地址 - b_module_id bigint null, -- page 页面绑定的数据/查询模块 - b_icon nvarchar(max) null, -- 图标:lucide:Name 引用 lucide 图标;其他字符串视为 SVG 标记 - b_xh int not null default 0, -- 排序号 - b_canuse int not null default 1, -- 是否启用(停用后不显示且不可访问) - constraint pk_s_menu primary key (b_id), - constraint ux_s_menu_code unique (b_code), - -- constraint fk_s_menu_parent foreign key (b_parent_id) references s_menu (b_id), - -- constraint fk_s_menu_module foreign key (b_module_id) references s_module (b_id) -); - -create index ix_s_menu_parent on s_menu (b_parent_id, b_xh, b_canuse); -create index ix_s_menu_module on s_menu (b_module_id); -``` - -菜单类型约束由业务层执行:`directory` 不填业务路由和模块;`page` 必须填 `b_route` 和 `b_module_id`;`external` 必须填 `b_route` 且不绑定模块。已保存菜单不允许修改菜单类型。菜单访问权限由 `b_code` 派生,不再单独配置访问权限编码。目录仅在存在至少一个可访问子页面时显示。 - -菜单配置表单按以下顺序呈现:菜单名称 → 菜单编码 → 菜单类型 → 上级菜单 → 路由/外链地址 → 绑定模块 → 图标 → 排序号。菜单类型、路由和模块关系由菜单管理的业务层校验。 - -没有独立菜单的子模块、费用明细、选择器和公共字典,通过主模块的 `s_relation` 挂在所属数据模块下;它们不需要重复创建菜单,也可以在同一个配置工作台中维护。 - -### 3.9 s_autocode 自动编码 - -挂数据模块 + 目标字段,规则可复用。并发控制:应用层使用原子更新取号(`update ... set b_currentvalue = b_currentvalue + 1 output inserted.b_currentvalue`),避免自增竞争。 - -```sql -create table s_autocode ( - b_id bigint not null, -- 雪花主键(应用层生成,全局唯一) - b_module_id bigint not null, -- 所属数据模块 - b_field_id bigint not null, -- 目标字段 - b_prefix nvarchar(100) not null default '', -- 前缀 - b_dateformat varchar(30) not null default 'yyyyMM', -- 日期格式 - b_separator nvarchar(10) not null default '-', -- 分隔符 - b_seqwidth int not null default 5, -- 流水位宽 - b_resettype varchar(10) not null default 'month', -- 重置周期:none/day/month/year - b_startvalue bigint not null default 1, -- 起始值 - b_currentperiod varchar(8) null, -- 当前周期 - b_currentvalue bigint not null default 0, -- 当前流水值 - b_canuse int not null default 1, -- 是否启用 - constraint pk_s_autocode primary key (b_id), - constraint ux_s_autocode_module_field unique (b_module_id, b_field_id), - -- constraint fk_s_autocode_module foreign key (b_module_id) references s_module (b_id), - -- constraint fk_s_autocode_field foreign key (b_module_id, b_field_id) references s_field (b_module_id, b_id), - constraint ck_s_autocode_resettype check (b_resettype in ('none', 'day', 'month', 'year')), - constraint ck_s_autocode_values check (b_seqwidth > 0 and b_startvalue >= 0 and b_currentvalue >= 0) +create table dbo.b_user ( + b_id varchar(50) not null primary key, -- 用户账号本身 + b_name nvarchar(100) null, + b_department nvarchar(100) null, + b_subcode nvarchar(100) null, + b_machineid nvarchar(100) null, + b_bz nvarchar(400) null, + b_password nvarchar(255) not null, + b_canuse tinyint not null default 1, + b_othermachinelogin tinyint not null default 0, + b_salesman tinyint not null default 0, + b_docman tinyint not null default 0, + b_dealman tinyint not null default 0, + b_receivelevel tinyint null, + b_paylevel tinyint null, + b_visible_customer tinyint not null default 0, + b_visible_supply tinyint not null default 0, + b_levelvisible tinyint not null default 0, + b_fromcustomer tinyint not null default 0, + b_logintime tinyint not null default 0, + b_checkxy tinyint not null default 0, + b_loginfee tinyint not null default 0, + b_phone nvarchar(100) null ); ``` -自动编码目标字段必须是当前模块的可写字符字段;服务层还需校验目标字段未配置为 `computed/system/readOnly`,并保证业务字段上的唯一性约束。规则周期切换、取号和业务保存必须在同一事务内完成。 - -### 3.10 s_role 角色 +## 2. 模块 ```sql -create table s_role ( - b_id bigint not null, -- 雪花主键(应用层生成,全局唯一) - b_code varchar(50) not null, -- 角色编码 - b_name nvarchar(100) not null, -- 角色名称 - b_i18n varchar(150) null, -- 显示名多语言 key - b_remark nvarchar(500) null, -- 描述 - b_is_system int not null default 0, -- 系统内置角色(禁止普通管理员删除) - b_bypass_power int not null default 0, -- 是否绕过业务权限校验(仅超级管理员) - b_canuse int not null default 1, -- 是否启用 - b_inputuser_id bigint null, - b_inputdatetime datetime2 null default sysutcdatetime(), - b_updateuser_id bigint null, - b_updatedatetime datetime2 null, - constraint pk_s_role primary key (b_id), - constraint ux_s_role_code unique (b_code), - constraint ck_s_role_system_bypass check (b_bypass_power = 0 or b_is_system = 1) -); -``` - -### 3.11 s_user_role 用户-角色 - -```sql -create table s_user_role ( - b_id bigint not null, -- 雪花主键(应用层生成,全局唯一) - b_user_id bigint not null, -- 用户(逻辑外键,当前身份系统) - b_role_id bigint not null, -- 角色 - b_canuse int not null default 1, -- 是否启用该关系 - b_inputdatetime datetime2 null default sysutcdatetime(), - constraint pk_s_user_role primary key (b_id), - constraint ux_s_user_role unique (b_user_id, b_role_id), - -- constraint fk_s_user_role_role foreign key (b_role_id) references s_role (b_id) -); - -create index ix_s_user_role_role on s_user_role (b_role_id, b_canuse); -``` - -### 3.12 s_power 权限点 - -```sql -create table s_power ( - b_id bigint not null, -- 雪花主键(应用层生成,全局唯一) - b_code varchar(50) not null, -- 权限编码(如 booking.confirm) - b_name nvarchar(100) not null, -- 权限名称 - b_i18n varchar(150) null, -- 显示名多语言 key - b_power_type varchar(20) not null, -- menu/module/action/field - b_resource varchar(128) null, -- 菜单 code 或扩展资源 code - b_module_id bigint null, -- module/action/field 所属模块 - b_field_id bigint null, -- field 权限对应字段 - b_operation varchar(30) not null, -- access/read/create/update/delete/export/execute/view/edit - b_canuse int not null default 1, -- 是否启用 - b_xh int not null default 0, -- 排序号 - constraint pk_s_power primary key (b_id), - constraint ux_s_power_code unique (b_code), - -- constraint fk_s_power_module foreign key (b_module_id) references s_module (b_id), - -- constraint fk_s_power_field foreign key (b_module_id, b_field_id) references s_field (b_module_id, b_id), - constraint ck_s_power_type check (b_power_type in ('menu', 'module', 'action', 'field')), - constraint ck_s_power_shape check ( - (b_power_type = 'menu' and b_resource is not null and b_module_id is null and b_field_id is null and b_operation = 'access') - or (b_power_type = 'module' and b_resource is null and b_module_id is not null and b_field_id is null and b_operation in ('read', 'create', 'update', 'delete', 'export')) - or (b_power_type = 'action' and b_resource is not null and b_module_id is not null and b_field_id is null and b_operation = 'execute') - or (b_power_type = 'field' and b_resource is null and b_module_id is not null and b_field_id is not null and b_operation in ('view', 'edit')) - ) -); - -create index ix_s_power_resource on s_power (b_power_type, b_resource, b_canuse); -``` - -权限点的资源约束由服务层补充:`menu` 使用菜单编码且操作只能为 `access`;`module` 必须填写 `b_module_id` 且操作为 `read/create/update/delete/export`;`action` 必须填写模块和动作编码且操作为 `execute`;`field` 必须同时填写模块、字段且操作为 `view/edit`。权限编码 `b_code` 在全库唯一,资源停用后对应权限自动失效。 - -### 3.13 s_role_power 角色-权限 - -```sql -create table s_role_power ( - b_id bigint not null, -- 雪花主键(应用层生成,全局唯一) - b_role_id bigint not null, -- 角色 - b_power_id bigint not null, -- 权限点 - b_effect varchar(10) not null default 'allow', -- allow/deny - b_canuse int not null default 1, -- 是否启用 - b_inputdatetime datetime2 null default sysutcdatetime(), - constraint pk_s_role_power primary key (b_id), - constraint ux_s_role_power unique (b_role_id, b_power_id), - -- constraint fk_s_role_power_role foreign key (b_role_id) references s_role (b_id), - -- constraint fk_s_role_power_power foreign key (b_power_id) references s_power (b_id), - constraint ck_s_role_power_effect check (b_effect in ('allow', 'deny')) -); - -create index ix_s_role_power_power on s_role_power (b_power_id, b_canuse); -``` - -### 3.14 s_role_scope 角色-模块数据范围 - -数据范围控制用户通过某个模块操作时可访问哪些业务记录。范围按角色、模块和操作配置,不承担租户隔离;本系统采用一个租户一个数据库,数据范围只解决租户内部的本人、部门、负责人等业务授权。 - -```sql -create table s_role_scope ( - b_id bigint not null, - b_role_id bigint not null, -- 角色 - b_module_id bigint not null, -- 数据模块 - b_operation varchar(20) not null, -- read/update/delete/export - b_scope_type varchar(30) not null, -- all/creator/owner/department/department-tree/custom - b_scope_no int not null default 1, -- 同角色同模块同操作的范围序号 - b_scope_field_id bigint null, -- owner/department/custom 用于匹配的业务字段 - b_scope_value nvarchar(max) null, -- 固定值或自定义条件(JSON) +create table dbo.s_module ( + b_id varchar(50) not null primary key, -- 模块编码本身,创建后不可修改 + b_parent_id varchar(50) null, + 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_canuse int not null default 1, - b_inputdatetime datetime2 null default sysutcdatetime(), - constraint pk_s_role_scope primary key (b_id), - constraint ux_s_role_scope unique (b_role_id, b_module_id, b_operation, b_scope_type, b_scope_field_id, b_scope_no), - -- constraint fk_s_role_scope_role foreign key (b_role_id) references s_role (b_id), - -- constraint fk_s_role_scope_module foreign key (b_module_id) references s_module (b_id), - -- constraint fk_s_role_scope_field foreign key (b_module_id, b_scope_field_id) references s_field (b_module_id, b_id), - constraint ck_s_role_scope_operation check (b_operation in ('read', 'update', 'delete', 'export')), - constraint ck_s_role_scope_type check (b_scope_type in ('all', 'creator', 'owner', 'department', 'department-tree', 'custom')), - constraint ck_s_role_scope_no check (b_scope_no > 0), - constraint ck_s_role_scope_shape check ( - (b_scope_type in ('all', 'creator') and b_scope_field_id is null and b_scope_value is null) - or (b_scope_type in ('owner', 'department', 'department-tree') and b_scope_field_id is not null) - or (b_scope_type = 'custom' and b_scope_value is not null) - ) + b_xh int not null default 0, + b_bz nvarchar(2000) null, + b_inputuser_id varchar(50) null, + b_inputdatetime datetime2 default sysutcdatetime(), + b_updateuser_id varchar(50) null, + b_updatedatetime datetime2 null, + b_edit_mode varchar(10) null ); -create index ix_s_role_scope_role on s_role_scope (b_role_id, b_module_id, b_operation, b_canuse); +create index ix_s_module_parent + on dbo.s_module (b_parent_id, b_xh, b_canuse, b_id); + +create index ix_s_module_savetable + on dbo.s_module (b_savetable); ``` -权限运行规则: - -- 默认拒绝:用户必须通过至少一个启用角色取得权限;`b_bypass_power = 1` 的受保护系统角色除外。 -- 多角色权限先合并,任一有效 `deny` 优先于所有 `allow`;没有 `allow` 视为无权。超级管理员角色只能由受保护的系统管理接口分配,不能通过普通角色维护接口创建或提权。 -- 访问数据页需要同时具备菜单 `access`、模块 `read` 和对应操作权限;前端只负责隐藏,后端接口必须按同一权限编码再次校验。 -- 字段权限区分 `view` 与 `edit`。未配置字段权限时继承模块权限;存在字段级 `deny` 时,查询结果必须移除或脱敏该字段,保存接口必须忽略并拒绝提交无编辑权限的字段。 -- 数据范围在操作权限通过后追加。`creator` 匹配记录创建人,`owner/department/department-tree` 通过 `b_scope_field_id` 与当前登录上下文匹配;`custom` 使用结构化 JSON 条件并只允许引用当前模块字段。 -- 用户拥有多个角色时,同一操作的数据范围取并集;同一角色内多个范围记录也取并集,任一角色/范围为 `all` 即不再追加范围条件。没有有效范围记录时,即使拥有操作权限也默认无数据,避免误放开全表。 - -菜单显示继承页面访问权限:用户拥有由 `s_menu.b_code` 派生的菜单访问权限时菜单才显示;前端只控制显示,后端运行时必须再次校验同一个权限编码。 - -### 3.15 s_i18n 多语言 +## 3. 字段和字段布局 ```sql -create table s_i18n ( - b_id bigint not null, -- 雪花主键(应用层生成,全局唯一) - b_key varchar(150) not null, -- 资源 key(如 booking.name) - b_locale varchar(20) not null, -- 语言区域(如 zh-CN/en-US) - b_value nvarchar(500) not null, -- 该语言下的显示文本 - b_i18ntype varchar(20) null, -- 资源类型:module/field/menu/action - b_canuse int not null default 1, -- 是否启用 - constraint pk_s_i18n primary key (b_id), - constraint ux_s_i18n_key_locale unique (b_key, b_locale) +create table dbo.s_field ( + b_module_id varchar(50) not null, + 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 int not null default 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_canuse int not null default 1, + b_inputuser_id varchar(50) null, + b_inputdatetime datetime2 default sysutcdatetime(), + b_updateuser_id varchar(50) null, + b_updatedatetime datetime2 null, + primary key (b_module_id, b_field) ); -create index ix_s_i18n_locale on s_i18n (b_locale, b_canuse); +create table dbo.s_field_group ( + b_id uniqueidentifier not null primary key, -- 应用层生成的 UUID + b_module_id varchar(50) not null, + b_parent_id uniqueidentifier null, + b_title nvarchar(200) not null, + b_i18n varchar(150) null, + b_xh int not null default 0, + b_canuse int not null default 1, + b_colspan int not null default 48, + b_layout_direction varchar(10) null default 'vertical' +); + +create index ix_s_field_group_module + on dbo.s_field_group (b_module_id, b_parent_id, b_xh, b_canuse, b_id); + +create table dbo.s_field_view ( + b_module_id varchar(50) not null, + b_field varchar(50) not null, + b_group_id uniqueidentifier null, + b_visible int not null default 1, + b_xh int not null default 0, + b_width int null, + primary key (b_module_id, b_field) +); + +create table dbo.s_field_edit ( + b_module_id varchar(50) not null, + b_field varchar(50) not null, + b_group_id uniqueidentifier null, + b_visible int not null default 1, + b_required int not null default 0, + b_readonly int not null default 0, + b_disabled int not null default 0, + 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 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 int not null default 1, + b_xh int not null default 0, + primary key (b_module_id, b_field, b_condition_no) +); ``` -### 3.16 s_log_technical 技术日志 - -用于请求链路、SQL、异常、性能与外部接口排查。高频日志推荐写入 Loki/OpenSearch/Elasticsearch 等日志平台,本表用于集中日志库或短期数据库兜底,不建议长期写入租户业务库。 +## 4. 自动编码 ```sql -create table s_log_technical ( - b_id bigint not null, -- 雪花主键(应用层生成,全局唯一) - b_user_id bigint null, -- 操作用户(逻辑外键) - b_account nvarchar(50) null, -- 登录名快照 - b_sourcetype varchar(20) null, -- 来源类型:web/api - b_loglevel varchar(16) not null, -- 级别:INFO/WARN/ERROR - b_logcategory varchar(64) null, -- 分类:sql/exception/performance - b_operation varchar(100) null, -- 操作名(如 loadData/saveObject) - b_module_code 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(2000) null, -- 错误信息 - b_exception_text nvarchar(max) null, -- 异常堆栈(文本) - b_context_text nvarchar(max) null, -- 上下文信息(文本) - b_occurdatetime datetime2 null default sysutcdatetime(), -- 发生时间(UTC) - constraint pk_s_log_technical primary key (b_id) +create table dbo.s_autocode ( + b_module_id varchar(50) not null, + 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_currentvalue bigint not null default 0, + b_canuse int not null default 1, + primary key (b_module_id, b_field) ); - -create index ix_s_log_technical_trace on s_log_technical (b_trace_id, b_occurdatetime desc); -create index ix_s_log_technical_level on s_log_technical (b_loglevel, b_occurdatetime desc); ``` -### 3.17 s_log_login 登录日志 - -记录登录成功、失败、退出、刷新令牌与账号锁定等安全事件,可通过 `b_request_id` / `b_trace_id` 与技术日志关联。 +## 5. 模块关系 ```sql -create table s_log_login ( - b_id bigint not null, -- 雪花主键(应用层生成,全局唯一) - b_user_id bigint null, -- 用户(逻辑外键) - b_account nvarchar(50) null, -- 登录名 - b_login_type varchar(20) not null, -- 登录类型:password/token/oidc/ldap - b_login_result int not null default 0, -- 登录结果:0失败/1成功 - b_fail_reason nvarchar(200) null, -- 失败原因 - b_ip varchar(50) null, -- 登录 IP - b_ip_location nvarchar(200) 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, -- 设备类型:pc/mobile/tablet - b_user_agent nvarchar(max) null, -- 完整 UA - b_session_id varchar(50) null, -- 会话 ID - b_request_id varchar(64) null, -- 请求 ID - b_trace_id varchar(64) null, -- 追踪 ID - b_occurdatetime datetime2 null default sysutcdatetime(), -- 登录时间(UTC) - constraint pk_s_log_login primary key (b_id) +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_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_canuse int not null default 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_log_login_user on s_log_login (b_user_id, b_occurdatetime desc); -create index ix_s_log_login_account on s_log_login (b_account, b_occurdatetime desc); -create index ix_s_log_login_result on s_log_login (b_login_result, b_occurdatetime desc); +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); ``` -### 3.18 业务审计事件与字段变更 - -审计事件与字段变更分表存储:事件一条、字段变更多条,通过 `b_event_id` 关联。字段级明细是**面向客户的硬需求**(客户可在门户查看某个单号发生过哪些字段变更),**字段一旦发生修改即写入明细**,不按字段配置开关;`b_visibility` 控制内部/客户可见。 +## 6. 菜单 ```sql -create table s_log_audit ( - b_id bigint not null, -- 雪花主键(应用层生成,全局唯一) - b_event_code varchar(50) not null, -- 业务事件编码(如 booking.submit) - b_module_code varchar(50) null, -- 模块编码 - b_data_id varchar(50) null, -- 被审计的业务数据 ID - b_business_no varchar(50) null, -- 业务单号(如订舱号) - b_operation varchar(100) null, -- 操作 key(如 saveObject.submit) - b_operator_id bigint null, -- 操作人(逻辑外键) - b_operator_name nvarchar(100) null, -- 操作人名称(快照) - b_source_type varchar(20) not null default 'user', -- 来源类型:user/ai/api - b_visibility varchar(20) not null default 'internal', -- 可见性:internal 内部/customer 客户可见 - b_request_id varchar(64) null, -- 请求 ID - b_trace_id varchar(64) null, -- 追踪 ID(关联技术日志) - b_payload_text nvarchar(max) null, -- 事件载荷(文本,如前后快照) - b_occurdatetime datetime2 null default sysutcdatetime(), -- 发生时间(UTC) - constraint pk_s_log_audit primary key (b_id) +create table dbo.s_menu ( + b_id varchar(50) not null primary key, -- 菜单编码本身 + b_parent_id varchar(50) null, + b_name nvarchar(200) not null, + b_i18n varchar(150) null, + b_menu_type varchar(20) not null, + b_route varchar(200) null, + b_module_id varchar(50) null, + b_icon varchar(50) null, + b_xh int not null default 0, + b_canuse int not null default 1 ); -create index ix_s_log_audit_module on s_log_audit (b_module_code, b_data_id, b_occurdatetime desc); -create index ix_s_log_audit_trace on s_log_audit (b_trace_id); +create index ix_s_menu_parent + on dbo.s_menu (b_parent_id, b_xh, b_canuse, b_id); + +create index ix_s_menu_module + on dbo.s_menu (b_module_id); ``` -字段级变更明细表 `s_log_audit_field`:`b_event_id` 关联审计事件;客户门户查询按单号 + 客户可见过滤。 +## 7. 权限 ```sql -create table s_log_audit_field ( - b_id bigint not null, -- 雪花主键(应用层生成,全局唯一) - b_event_id bigint not null, -- 所属审计事件 - b_module_code varchar(50) null, -- 模块编码 - b_data_id varchar(50) null, -- 业务数据 ID - b_field varchar(50) not null, -- 变更字段(物理列名) - b_field_label nvarchar(100) null, -- 字段标签快照 - b_before_value nvarchar(max) null, -- 变更前原始值 - b_after_value nvarchar(max) null, -- 变更后原始值 - b_before_display nvarchar(500) null, -- 变更前显示值(如引用字段 label) - b_after_display nvarchar(500) null, -- 变更后显示值 - b_visibility varchar(20) not null default 'internal', -- 可见性:internal 内部/customer 客户可见 - constraint pk_s_log_audit_field primary key (b_id), - -- constraint fk_s_log_audit_field_event foreign key (b_event_id) references s_log_audit (b_id) +create table dbo.s_power ( + b_id varchar(50) not null primary key, -- 权限编码本身 + b_name nvarchar(200) not null, + b_i18n varchar(150) null, + b_power_type varchar(20) not null, + b_resource varchar(128) null, + b_module_id varchar(50) null, + b_field varchar(50) null, + b_operation varchar(30) not null, + b_canuse int not null default 1, + b_xh int not null default 0 ); -create index ix_s_log_audit_field_event on s_log_audit_field (b_event_id); -create index ix_s_log_audit_field_customer on s_log_audit_field (b_module_code, b_data_id, b_visibility, b_event_id); +create index ix_s_power_module + on dbo.s_power (b_module_id, b_field, b_canuse); + +create index ix_s_power_resource + on dbo.s_power (b_power_type, b_resource, b_canuse); ``` -## 4. 关键设计决策说明 +## 8. 多语言 -### 4.1 为什么保留 s_module 并删除 s_entity -旧系统已经以 `s_module` 作为配置、查询和保存的核心主体,现有接口、字段表和迁移工具也普遍使用 `b_module_id`。继续拆出 `s_entity` 会增加概念、外键和迁移成本,却没有形成足够独立的业务职责。因此新方案保留 `s_module` 自关联树,由 `category/data/query` 区分分类节点和数据节点;菜单和权限仍保持独立,避免恢复旧系统全部职责混杂的问题。 +```sql +-- b_i18n / b_key 保存完整资源键,不追加 .name 或 .title 后缀: +-- s_module.b_i18n = module. +-- s_field.b_i18n = field.. +-- s_field_group.b_i18n = field_group.. +-- s_menu.b_i18n = menu. -### 4.2 为什么不设置模块依赖表 -当前 `s_module` 不是可独立安装、发布的软件包,因此不额外维护模块依赖 DAG。数据模块之间的引用和主从关系由 `s_relation` 表达,数据库对象依赖由实际表、视图和 Schema Compiler 校验,部署依赖由构建或插件系统管理。 +create table dbo.s_i18n_type ( + b_id varchar(20) not null primary key, -- 语言编码本身 + b_name nvarchar(200) not null, + b_canuse int not null default 1, + b_default int not null default 0, + b_xh int not null default 0 +); -### 4.3 为什么字段定义与页面行为分表 -旧系统的字段、列表、编辑、查询虽然都挂在 `b_module_id` 下,但运行时和管理端都把它们当作四类独立配置。列表与表单的显示、排序、分组并不相同,查询条件也只覆盖部分字段。强行并入 `s_field` 会形成宽表,并混淆数据库列定义与页面行为。因此本方案保留字段定义、列表、表单和查询条件等职责明确的表;列表、表单通过模块 + 字段/分组复合外键保证同模块,查询条件允许通过明确的 `s_relation` 引用关联模块字段。第一阶段查询条件统一使用 `AND`,暂不增加条件组。 +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_canuse int not null default 1, + b_module_id varchar(50) null, + unique (b_key, b_locale) +); -### 4.4 为什么菜单独立且引用模块 -导航是展示层概念,数据模块是业务数据概念。`s_menu` 引用 `b_module_id`,不重复保存数据源;同一数据模块可以被多个菜单入口引用,但默认共享模块上的同一套字段、列表、表单和查询配置。 +create index ix_s_i18n_module_id + on dbo.s_i18n (b_module_id); -### 4.5 为什么暂不增加页面配置档 -旧系统实际以一个 `data` 模块承载一套字段、列表、编辑和查询配置,没有菜单级字段覆盖机制。当前若再增加 `s_page_profile`、`s_page_field`,会与字段配置表重复,并引入菜单、配置档、模块三者的一致性维护成本。因此第一阶段规定一个数据模块只有一套页面配置。 - -### 4.6 为什么权限引入角色层 -旧系统 `b_user_power` 是用户直挂权限,用户一多授权矩阵爆炸。本方案使用 `s_role` + `s_user_role` + `s_role_power` 管理角色权限,并通过 `s_role_scope` 补充模块级数据范围。权限点 `s_power` 支持 menu/module/action/field 四级粒度,角色权限支持 allow/deny,字段权限支持 view/edit;数据范围按角色取并集,显式 deny 优先,后端在每次请求中重新计算有效权限。 - - -### 4.7 自动编码的并发处理 -`b_currentvalue` 若采用"读-改-写"必然在并发下产生重号。方案要求应用层用原子自增语句取号(`update ... set b_currentvalue = b_currentvalue + 1 output inserted.b_currentvalue`),并把取号与业务保存放同一事务,确保单号不重不漏。 - -### 4.8 日志的写入去向 -三类日志职责不同、量级不同,不建议全部落租户业务库: -- 技术日志高频(每条请求、SQL、异常),推荐写 Loki/ES 等日志平台,`s_log_technical` 仅作集中日志库或短期兜底。 -- 登录日志与业务审计属于安全与合规数据,低频但必须长期留存,落库保存;审计字段明细在字段发生修改时全量写入,不做按字段开关,数据量按变更频率自然收敛。 -- 三类日志统一以 `b_request_id` / `b_trace_id` 串联,便于从业务审计反查技术日志。 - -### 4.9 为什么元数据表主键采用雪花 ID - -配置数据(模块、字段、关系、菜单、权限等)需要跨系统导入导出与搬迁。若使用数据库自增主键,两个系统各自从 1 计数,搬入目标系统后必然 ID 冲突,模块子树下所有引用字段(`b_module_id`、`b_field_id`、`b_relation_id` 等 30+ 处)都必须重映射,而重映射正是搬迁工具中 bug 的高发区。雪花 ID 全局唯一,导出/导入时子树引用原样保留,无需重映射。元数据表行数少,雪花随机写入对聚簇索引的影响可忽略;`b_code` 仍作为跨环境稳定业务键,负责冲突检测与 dev/prod 对齐。业务数据表(模块生成的物理表)和用户表不在此列,用户表见 5.1。 - -## 5. 逻辑外键清单 - -全系统(含元数据表)不建立物理外键;DDL 中的 `fk_*` 约束以注释形式保留,仅作逻辑关系参考,建表时不创建。以下引用统一由服务层校验: - -| 表 | 字段 | 逻辑目标 | -| --- | --- | --- | -| `s_user_role` | `b_user_id` | 当前身份系统的用户 `b_id` | -| `s_menu` | `b_module_id` | 绑定的数据/查询模块 `s_module.b_id` | -| `s_field_group` | `b_parent_id` | 父分组必须与当前分组属于同一数据模块 | -| `s_field_query` | `b_relation_id` | 关联关系必须从查询模块可达条件字段所属模块 | -| `s_menu` | `b_code` | 菜单访问权限资源编码(由菜单编码派生) | -| `s_power` | `b_resource` / `b_operation` | 权限类型、资源和操作必须匹配;field 权限必须引用对应模块字段 | -| `s_role_scope` | `b_scope_field_id` / `b_scope_value` | 数据范围字段和范围值必须与模块及范围类型匹配 | -| `s_relation` | 关系类型与字段组合 | 多对多必须有中间模块;两侧字段必须属于声明的模块 | -| `s_module` | `b_viewtable` / `b_savetable` | Schema Compiler 生成的物理表/视图 | -| `s_log_technical` | `b_user_id` | 当前身份系统的用户 `b_id` | -| `s_log_login` | `b_user_id` | 当前身份系统的用户 `b_id` | -| `s_log_audit` | `b_operator_id` | 当前身份系统的用户 `b_id` | - -逻辑外键写入时应执行以下校验: - -```text -引用是否已启用 --> 目标资源是否存在且类型匹配 --> 字段、分组和关系是否属于正确的数据模块 --> 权限类型、操作、字段和数据范围规则是否匹配 --> 删除或停用前是否仍被其他资源引用 +create index ix_s_i18n_locale + on dbo.s_i18n (b_locale, b_canuse); ``` -### 5.1 用户与身份系统(b_user) +## 9. 日志 -`b_user` 不属于 FMS 业务库,由独立的身份系统维护;FMS 侧只保存用户引用 `b_user_id`(bigint,不透明 ID),不解析账号,也不关心身份系统内部的 ID 生成方案。 +```sql +create table dbo.s_log_technical ( + b_id uniqueidentifier not null primary key, -- 应用层生成的 UUID + b_user_id varchar(50) null, + b_account 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, + b_trace_id varchar(64) null, + 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, -- 应用层生成的 UUID + b_user_id varchar(50) null, + b_account varchar(50) null, + b_login_type varchar(20) not null, + b_login_result int not null default 0, + b_fail_reason nvarchar(400) null, + b_ip varchar(50) null, + b_ip_location nvarchar(400) null, + 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, + b_session_id varchar(50) null, + b_request_id varchar(64) null, + b_trace_id varchar(64) null, + b_occurdatetime datetime2 default sysutcdatetime() +); -- `b_id` bigint 主键:单库自增 `identity(1,1)` 即可;若未来用户需要跨系统迁移或合并,再改为全局唯一 ID(如雪花),FMS 侧无需任何改动。 -- `b_account` nvarchar(50) 唯一键:账号是自然键,允许改名;**绝不用账号做主键**。 -- 账号改名只更新用户表自身,引用 `b_user_id` 的业务数据(`s_user_role`、`s_log_*`)不受影响。 -- 日志表冗余保存 `b_account` 快照(`s_log_login.b_account`、`s_log_technical.b_account`),保证改名后历史日志仍可读、可追溯。 +create table dbo.s_log_audit ( + b_id uniqueidentifier not null primary key, -- 应用层生成的 UUID + b_event_code varchar(50) not null, + b_module_id varchar(50) null, + b_data_id varchar(50) null, + b_business_no varchar(50) null, + b_operation varchar(100) null, + b_operator_id varchar(50) null, + 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, + b_trace_id varchar(64) null, + b_payload_text nvarchar(max) null, + b_occurdatetime datetime2 default sysutcdatetime() +); -## 6. 与旧系统的映射对照 +create table dbo.s_log_audit_field ( + b_id uniqueidentifier not null primary key, -- 应用层生成的 UUID + b_event_id uniqueidentifier not null, + b_module_id varchar(50) null, + b_data_id varchar(50) null, + 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' +); -| 旧系统 | 本方案 | 变化 | -| --- | --- | --- | -| `s_module`(含表/路由/菜单/权限) | `s_module` + `s_menu` | 保留模块数据主体,拆出导航与角色权限 | -| `s_module_field` | `s_field` | 字段挂模块 | -| `s_module_field_list` | `s_field_view` | 改为通过字段关联模块 | -| `s_module_field_edit` | `s_field_edit` | 改为通过字段关联模块 | -| `s_module_field_query` | `s_field_query` | 保留平铺查询配置,增加关联字段与同字段多条件 | -| `s_module_field_group` | `s_field_group` | 分组挂模块 | -| `s_module_auto_code` | `s_autocode` | 挂模块+字段,原子取号 | -| `s_module_power` + `b_user_power` | `s_power` + `s_role` + `s_user_role` + `s_role_power` + `s_role_scope` | 引入角色、允许/拒绝和数据范围 | -| `b_i18n` | `s_i18n` | 增加资源类型维度 | -| `s_login_log` | `s_log_login` | 字段扩充(设备/UA/请求链路) | -| (无) | `s_relation` | 新增模块关系 | -| (无) | `s_log_technical` | 新增技术日志(旧系统无) | -| (无) | `s_log_audit` + `s_log_audit_field` | 新增业务审计与字段变更(旧系统无) | +create index ix_s_log_technical_trace + on dbo.s_log_technical (b_trace_id, b_occurdatetime); +create index ix_s_log_login_user + on dbo.s_log_login (b_user_id, b_occurdatetime); + +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); +``` + +## 10. 业务表主键约定 + +```sql +create table dbo.b_example ( + b_id bigint not null primary key, -- 应用层雪花 ID + b_inputuser_id varchar(50) null, -- 引用 b_user.b_id + b_inputdatetime datetime2 default sysutcdatetime() +); +``` diff --git a/code/fms/FMS新系统核心表结构设计.pdf b/code/fms/FMS新系统核心表结构设计.pdf new file mode 100644 index 00000000..0763050b Binary files /dev/null and b/code/fms/FMS新系统核心表结构设计.pdf differ diff --git a/code/fms/fms-vue/src/components/ui/tree/index.scss b/code/fms/fms-vue/src/components/ui/tree/index.scss index 3da4dfb4..53050c4f 100644 --- a/code/fms/fms-vue/src/components/ui/tree/index.scss +++ b/code/fms/fms-vue/src/components/ui/tree/index.scss @@ -168,6 +168,8 @@ margin-right: 4px; color: #b0b7c3; cursor: grab; + /* 从手柄按下时不选中行文本(拖拽由 mousedown 启动,不能 preventDefault) */ + user-select: none; } .tree-node-grip:active { cursor: grabbing; diff --git a/code/fms/fms-vue/src/components/ui/tree/tree-node.vue b/code/fms/fms-vue/src/components/ui/tree/tree-node.vue index 427773b0..de7d3a3b 100644 --- a/code/fms/fms-vue/src/components/ui/tree/tree-node.vue +++ b/code/fms/fms-vue/src/components/ui/tree/tree-node.vue @@ -109,13 +109,42 @@ function onContentClick(e) { function onCheckChange() { ctx.handleCheck(key.value, props.node) } +/* ---------- 拖拽:仅最左侧 grip 手柄可发起 ---------- */ +// 行默认 draggable=false。整行可拖时,「按下后手抖几像素」就会误起原生拖拽, +// 而松手若没落在合法落点上不会走 drop,行上又没绑 dragend → dragKey 永远清不掉, +// 节点一直挂着 is-dragging(opacity .4),表现就是「点了发灰、移开也灰」。 +const gripArmed = ref(false) +const draggableAttr = computed(() => + ctx.props.draggable && !disabled.value && gripArmed.value ? 'true' : 'false', +) + +/** grip 按下:武装本行拖拽(同步写 DOM,dragstart 可能早于 Vue 的 patch) */ +function armDrag() { + if (disabled.value || !ctx.props.draggable) return + gripArmed.value = true + rowRef.value?.setAttribute('draggable', 'true') + // 按下但没真拖(不会产生 dragend)时的兜底解除 + document.addEventListener('mouseup', disarmDrag, { once: true }) +} +function disarmDrag() { + if (!gripArmed.value) return + gripArmed.value = false + rowRef.value?.setAttribute('draggable', 'false') +} function onDragStart(e) { - if (disabled.value) { + if (disabled.value || !gripArmed.value) { e.preventDefault() return } ctx.onDragStart(props.node, e) } +/** 拖拽结束(无论是否落在合法落点):先解除武装,再让容器清理拖拽态 */ +function onDragEnd(e) { + disarmDrag() + ctx.onDragEnd(e) +} +onBeforeUnmount(() => document.removeEventListener('mouseup', disarmDrag)) + function onDragOver(e) { ctx.onDragOver(props.node, e) } @@ -143,16 +172,23 @@ function onContextMenu(e) { :aria-expanded="leaf ? undefined : expanded" :aria-disabled="disabled || undefined" :tabindex="tabindex" - :draggable="ctx.props.draggable && !disabled ? 'true' : undefined" + :draggable="draggableAttr" @dragstart="onDragStart" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop" + @dragend="onDragEnd" @contextmenu.stop="onContextMenu" > - + + + + appStore.sidebarCollapsed) /* ---- 团队切换 ---- */ @@ -29,76 +27,44 @@ const teams = [ ] const activeTeam = ref(teams[0]) -/* ---- 动态导航菜单(权限模块构建树) ---- */ +/* ---- 导航菜单:固定入口(工作台/组件库)+ s_menu 动态菜单 ---- */ const navMain = computed(() => { const items = [ { title: '工作台', url: '/dashboard', icon: LayoutGrid }, { title: '组件库', url: '/demo', icon: Component }, ] - // 测试页分组:方便演练 Tabs 导航(无权限依赖) - items.push({ - title: '测试页面', - icon: LayoutGrid, - items: [ - { title: '列表', url: '/test/list' }, - { title: '表单', url: '/test/form' }, - { title: '详情', url: '/test/detail' }, - { title: '图表', url: '/test/chart' }, - { title: '表格', url: '/test/table' }, - { title: '设置', url: '/test/settings' }, - ], - }) - - // 系统管理分组(先静态配置,动态菜单/权限后续接入) - items.push({ - title: '系统管理', - icon: Settings, - items: [ - { title: '模块管理', url: '/module/module-management' }, - { title: '菜单管理', url: '/module/menu-management' }, - { title: '多语言类型', url: '/module/i18n-type' }, - ], - }) - - const menuModules = permissionStore.modules.filter( - (m) => m.b_canmenu === 1 && m.b_canuse === 1 && ['directory', 'page'].includes(m.b_module_type), - ) - - if (!menuModules.length) return items - - // 仅保留父节点(若有)也在菜单中的模块 - const allowedIds = new Set(menuModules.map((m) => String(m.b_id))) - const valid = menuModules.filter( - (m) => m.b_parent_id == null || allowedIds.has(String(m.b_parent_id)), - ) - - const cloned = JSON.parse(JSON.stringify(valid)) - const tree = buildTree(cloned) - sortTree(tree) - - for (const node of tree) { - const item = toNavItem(node) + // 动态菜单(s_menu):由「菜单管理」维护的导航树追加渲染。 + // 可见性已在菜单 store 裁剪:非 g3soft 隐藏编码 module 的菜单,空目录不显示 + for (const node of menuStore.visibleMenus) { + const item = toMenuItem(node) if (item) items.push(item) } return items }) -function toNavItem(node) { - const isDir = node.b_module_type === 'directory' - // 与旧系统一致:仅目录项读取图标,页面项只显示文字 - const icon = isDir ? getIcon(node.b_icon) : null +/** s_menu 动态菜单节点 → NavMenuItem 结构(目录/页面/外链,见 NavMenuItem.vue JSDoc) */ +function toMenuItem(node) { + const type = node.b_menu_type + // 页面/外链缺路由地址时不可导航,不渲染(菜单管理保存时已校验必填,这里兜底) + if ((type === 'page' || type === 'external') && !String(node.b_route || '').trim()) return null - const item = { - title: node.b_name || node.b_code, - url: node.b_route ? String(node.b_route) : `#${node.b_code}`, + const item = { title: node.b_name || node.b_code } + if (type === 'external') { + item.external = true + item.url = String(node.b_route).trim() + } else { + item.url = type === 'directory' ? '#' : String(node.b_route).trim() } + if (node.b_code) item.code = String(node.b_code) + + const icon = getIcon(node.b_icon) if (icon) item.icon = icon - if (!isDir && node.b_code) item.moduleId = String(node.b_code) if (node.children?.length) { - item.items = node.children.map((c) => toNavItem(c)).filter(Boolean) + const children = node.children.map((c) => toMenuItem(c)).filter(Boolean) + if (children.length) item.items = children } return item diff --git a/code/fms/fms-vue/src/layouts/components/NavMenuItem.vue b/code/fms/fms-vue/src/layouts/components/NavMenuItem.vue index 3ca7b5c2..637e6be1 100644 --- a/code/fms/fms-vue/src/layouts/components/NavMenuItem.vue +++ b/code/fms/fms-vue/src/layouts/components/NavMenuItem.vue @@ -24,7 +24,8 @@ const appStore = useAppStore() const collapsed = computed(() => appStore.sidebarCollapsed) const isDir = computed(() => Boolean(props.item.items?.length)) -const active = computed(() => !isDir.value && isActive(props.item.url)) +const isExternal = computed(() => props.item.external === true) +const active = computed(() => !isDir.value && !isExternal.value && isActive(props.item.url)) function isActive(url) { if (!url || url === '#') return false @@ -75,6 +76,22 @@ function onToggle() { + + + + {{ + item.title.slice(0, 1) + }} + {{ item.title }} + + import('@/views/login/index.vue'), - meta: { title: '登录' }, - }, - { - path: '/', - component: DefaultLayout, - redirect: '/dashboard', - meta: { requiresAuth: true }, - children: [ - { - path: '/dashboard', - name: 'dashboard', - component: () => import('@/views/dashboard/index.vue'), - meta: { title: '工作台', componentName: 'Dashboard' }, - }, - // 测试页:用于演练 Tabs 导航(无需权限,纯布局验证) - { - path: '/test/list', - name: 'test-list', - component: () => import('@/views/test/TestPage.vue'), - meta: { title: '测试-列表', componentName: 'TestPage' }, - }, - { - path: '/test/form', - name: 'test-form', - component: () => import('@/views/test/TestPage.vue'), - meta: { title: '测试-表单', componentName: 'TestPage' }, - }, - { - path: '/test/detail', - name: 'test-detail', - component: () => import('@/views/test/TestPage.vue'), - meta: { title: '测试-详情', componentName: 'TestPage' }, - }, - { - path: '/test/chart', - name: 'test-chart', - component: () => import('@/views/test/TestPage.vue'), - meta: { title: '测试-图表', componentName: 'TestPage' }, - }, - { - path: '/test/table', - name: 'test-table', - component: () => import('@/views/test/TestPage.vue'), - meta: { title: '测试-表格', componentName: 'TestPage' }, - }, - { - path: '/test/settings', - name: 'test-settings', - component: () => import('@/views/test/TestPage.vue'), - meta: { title: '测试-设置', componentName: 'TestPage' }, - }, - // 组件库演练场:纳入业务布局(带侧栏 / 顶栏 / Tabs) - { - path: '/demo', - name: 'demo', - component: () => import('@/views/demo/index.vue'), - meta: { title: '组件库', componentName: 'Demo' }, - }, - // 附件组件演示 - { - path: '/file-demo', - name: 'file-demo', - component: () => import('@/views/file-demo/index.vue'), - meta: { title: '附件组件演示', componentName: 'FileDemo' }, - }, - // 详情页:同一组件按参数复用,但每个 fullPath 独立缓存、关闭即销毁 - // (per-param 的命名组件由 DefaultLayout 的 getPageWrapper 按路由动态生成) - { - path: '/detail/:id', - name: 'detail', - component: () => import('@/views/detail/DetailPage.vue'), - meta: { title: '详情', cacheByKey: true }, - }, - // 模块管理:元数据驱动的模块配置中心(先静态路由,权限/动态菜单后续接入) - { - path: '/module/module-management', - name: 'module-module-management', - component: () => import('@/views/module/module-management/index.vue'), - meta: { title: '模块管理', componentName: 'ModuleManagement' }, - }, - // 菜单管理:占位页面,待实现 - { - path: '/module/menu-management', - name: 'module-menu-management', - component: () => import('@/views/module/menu-management/index.vue'), - meta: { title: '菜单管理', componentName: 'MenuManagement' }, - }, - // 多语言类型:系统支持的语言清单维护(s_i18n_type) - { - path: '/module/i18n-type', - name: 'module-i18n-type', - component: () => import('@/views/module/i18n-type/index.vue'), - meta: { title: '多语言类型', componentName: 'I18nTypeManagement' }, - }, - // 业务页面后续在此追加,配置 meta.moduleId 后自动走权限校验 - ], - }, - { - path: '/:pathMatch(.*)*', - redirect: '/dashboard', - }, - ], + routes, }) // 登录守卫:未登录访问受保护页跳 /login?redirect=xxx;已登录访问 /login 跳 /dashboard @@ -135,10 +35,14 @@ router.beforeEach(async (to) => { return { path: '/login', query: { redirect: to.fullPath } } } - // 统一在路由守卫加载权限:首屏(/dashboard)与业务页都在挂载前 await 完成, + // 统一在路由守卫加载权限与动态菜单:首屏(/dashboard)与业务页都在挂载前 await 完成, // 菜单一次性到位、不会“跳出”;load 内部按主体去重,重复进入同一主体不重复请求 const permissionStore = usePermissionStore() - await permissionStore.load().catch(() => {}) + const menuStore = useMenuStore() + await Promise.all([ + permissionStore.load().catch(() => {}), + menuStore.load().catch(() => {}), + ]) // 业务数据多语言配置(s_i18n_type 语言清单):随权限一并加载, // 供菜单/模块等多语言编辑读取;内部去重、失败不阻断页面 @@ -149,6 +53,13 @@ router.beforeEach(async (to) => { if (moduleId && !permissionStore.canAccess(moduleId)) { return '/dashboard' } + + // 模块中心(/module/*)是系统管理员的专用页面:非 g3soft 一律拦回工作台。 + // 这是路由层的特殊判断,与菜单树无关(菜单隐藏与路由放行由同一管理员判定决定), + // 避免「菜单上看不见、手输地址却能进」的越权。 + if (to.path.startsWith('/module/') && !permissionStore.isG3soft) { + return '/dashboard' + } } }) diff --git a/code/fms/fms-vue/src/router/routes.js b/code/fms/fms-vue/src/router/routes.js new file mode 100644 index 00000000..83961d58 --- /dev/null +++ b/code/fms/fms-vue/src/router/routes.js @@ -0,0 +1,151 @@ +/** + * 静态路由表(唯一定义处)。 + * + * 单独拆出本文件的原因:「菜单管理」的「路由地址」需要从这里派生可选页面路由, + * 若路由表仍写在 index.js 里,页面组件就会反向依赖「路由实例 + 守卫」模块, + * 形成 页面 → router/index → 守卫 → store 的循环依赖风险。 + * 约定: + * - routes.js :只声明路由表与由其派生的纯数据(无副作用、可被任意模块安全 import) + * - index.js :只负责创建 router 实例、注册守卫、导出默认实例 + */ +import DefaultLayout from '@/layouts/DefaultLayout.vue' + +/** + * 路由表。 + * 受保护页面挂在 DefaultLayout 下(layout 的 meta.requiresAuth 由守卫统一校验); + * 需要按业务模块鉴权的页面,在 meta 上配 moduleId,守卫校验无权限时跳 /dashboard。 + * + * 路由分组(/module、/test):父节点只写 path + redirect + children,**不写 component**。 + * vue-router 的 RouterView 会跳过 matched 中没有 components 的记录,子页面因此仍渲染在 + * DefaultLayout 的那个 router-view 上,keep-alive 层级不变。 + * 切勿给分组节点配「内部再放一个 router-view」的容器组件:那样页面会下沉一层, + * 直接脱离 DefaultLayout 的 keep-alive,页面缓存失效。 + * 子路由 path 写相对路径('module-management'),完整路径由父级拼接, + * 调整分组前缀时只需改父节点一处。 + */ +export const routes = [ + { + path: '/login', + name: 'login', + component: () => import('@/views/login/index.vue'), + meta: { title: '登录' }, + }, + { + path: '/', + component: DefaultLayout, + redirect: '/dashboard', + meta: { requiresAuth: true }, + children: [ + { + path: '/dashboard', + name: 'dashboard', + component: () => import('@/views/dashboard/index.vue'), + meta: { title: '工作台', componentName: 'Dashboard' }, + }, + // 组件库演练场:纳入业务布局(带侧栏 / 顶栏 / Tabs) + { + path: '/demo', + name: 'demo', + component: () => import('@/views/demo/index.vue'), + meta: { title: '组件库', componentName: 'Demo' }, + }, + // 附件组件演示 + { + path: '/file-demo', + name: 'file-demo', + component: () => import('@/views/file-demo/index.vue'), + meta: { title: '附件组件演示', componentName: 'FileDemo' }, + }, + // 详情页:同一组件按参数复用,但每个 fullPath 独立缓存、关闭即销毁 + // (per-param 的命名组件由 DefaultLayout 的 getPageWrapper 按路由动态生成) + { + path: '/detail/:id', + name: 'detail', + component: () => import('@/views/detail/DetailPage.vue'), + meta: { title: '详情', cacheByKey: true }, + }, + // 模块中心:元数据驱动的配置页集合 + // 分组节点(见上方「路由分组」注释:不写 component,子页写相对路径) + { + path: '/module', + redirect: '/module/module-management', + children: [ + // 模块管理:元数据驱动的模块配置中心(先静态路由,权限/动态菜单后续接入) + { + path: 'module-management', + name: 'module-module-management', + component: () => import('@/views/module/module-management/index.vue'), + meta: { title: '模块管理', componentName: 'ModuleManagement' }, + }, + // 菜单管理:占位页面,待实现 + { + path: 'menu-management', + name: 'module-menu-management', + component: () => import('@/views/module/menu-management/index.vue'), + meta: { title: '菜单管理', componentName: 'MenuManagement' }, + }, + // 多语言类型:系统支持的语言清单维护(s_i18n_type) + { + path: 'i18n-type', + name: 'module-i18n-type', + component: () => import('@/views/module/i18n-type/index.vue'), + meta: { title: '多语言类型', componentName: 'I18nTypeManagement' }, + }, + ], + }, + // 业务页面后续在此追加,配置 meta.moduleId 后自动走权限校验 + ], + }, + { + path: '/:pathMatch(.*)*', + redirect: '/dashboard', + }, +] + +/** 不作为菜单入口的路由:独立布局、无需登录即可访问的页面 */ +const EXCLUDED_PATHS = new Set(['/login']) + +/** + * 收集可作为菜单入口的页面路由(递归展开布局容器与分组节点)。 + * 过滤规则: + * - 含 children 的节点(布局容器 / 分组节点)不是最终页面,只向下递归; + * - 含动态段(:id)或通配(*)的路由无法作为固定菜单入口; + * - 登录页等独立布局页排除在外。 + * @param {Array} list 路由节点数组,默认整张路由表 + * @param {string} parentPath 父节点完整路径,用于拼接分组内子路由的相对路径 + * @returns {{ value: string, label: string }[]} 选项(value 为完整 path) + */ +export function collectMenuRoutes(list = routes, parentPath = '') { + const result = [] + for (const route of list || []) { + const rawPath = route.path || '' + // 绝对路径原样使用;相对路径(分组节点的 children)拼到父路径上 + const path = rawPath.startsWith('/') ? rawPath : joinPath(parentPath, rawPath) + if (Array.isArray(route.children) && route.children.length) { + result.push(...collectMenuRoutes(route.children, path)) + continue + } + if (!path.startsWith('/')) continue + if (path.includes(':') || path.includes('*')) continue + if (EXCLUDED_PATHS.has(path)) continue + const title = route.meta?.title + result.push({ value: path, label: title ? `${path}(${title})` : path }) + } + return result +} + +/** 拼接父路径与子路径(相对路径形态):'/module' + 'i18n-type' → '/module/i18n-type' */ +function joinPath(parentPath, childPath) { + const parent = String(parentPath || '').replace(/\/+$/, '') + const child = String(childPath || '').replace(/^\/+/, '') + if (!parent) return child ? `/${child}` : '' + return child ? `${parent}/${child}` : parent +} + +/** + * 菜单管理「路由地址」下拉选项:按路径排序,配合 Select 的本地包含匹配(like)筛选。 + * label 同时含路径与标题,输入 "/module" 或 "模块" 都能命中。 + */ +export const menuRouteOptions = collectMenuRoutes().toSorted((a, b) => + a.value.localeCompare(b.value, 'zh-Hans-CN'), +) diff --git a/code/fms/fms-vue/src/stores/menus.js b/code/fms/fms-vue/src/stores/menus.js new file mode 100644 index 00000000..f3a6bbd2 --- /dev/null +++ b/code/fms/fms-vue/src/stores/menus.js @@ -0,0 +1,120 @@ +import { computed, ref, watch } from 'vue' +import { defineStore } from 'pinia' +import { loadDataApi } from '@/services/api' +import { useAuthStore } from './auth' +import { buildTree, sortTree } from '@/utils/tree' + +/** + * 动态导航菜单 store(侧栏菜单数据源)。 + * + * 菜单树由「菜单管理」维护在 s_menu 表,含目录 / 页面 / 外链三类节点。 + * 可见性规则由前端执行(不改后端): + * - g3soft(管理员)可见全部启用菜单; + * - 其他账号隐藏编码为 module 的菜单(模块管理入口)及其整棵子树; + * - 目录仅当存在可见子菜单时渲染(空目录不显示)。 + * 与权限 store 一致:同一登录主体加载去重、并发请求复用、登录主体切换清空缓存。 + */ +export const useMenuStore = defineStore('menus', () => { + const authStore = useAuthStore() + /** s_menu 原始记录(b_canuse = 1,接口按 b_xh / b_id 排序返回) */ + const rawMenus = ref([]) + const loadedPrincipalKey = ref('') + let loadingPromise = null + let loadingPrincipalKey = '' + let loadVersion = 0 + + const isG3soft = computed(() => { + return ( + String(authStore.loginInfo.user?.account || '') + .trim() + .toLowerCase() === 'g3soft' + ) + }) + + /** + * 加载菜单数据;同一主体重复调用去重,并发调用共享同一请求。 + */ + async function load() { + const userId = String(authStore.loginInfo.user?.id || '') + const principalKey = `${String(authStore.userInfo.orgId || '')}:${userId}` + + if (!authStore.isAuthenticated) return + if (loadedPrincipalKey.value === principalKey) return + if (loadingPromise && loadingPrincipalKey === principalKey) return loadingPromise + + const requestVersion = ++loadVersion + loadingPrincipalKey = principalKey + const request = loadDataApi('s_menu', 'b_canuse = 1', 'b_xh ASC, b_id ASC') + .then((res) => { + if (requestVersion !== loadVersion) return + rawMenus.value = res.data || [] + loadedPrincipalKey.value = principalKey + }) + .finally(() => { + if (requestVersion !== loadVersion) return + loadingPromise = null + loadingPrincipalKey = '' + }) + loadingPromise = request + return request + } + + /** + * 当前主体可见的菜单树(s_menu 原始行结构,含 children 子树)。 + */ + const visibleMenus = computed(() => { + if (!rawMenus.value.length) return [] + const tree = sortTree(buildTree(JSON.parse(JSON.stringify(rawMenus.value)))) + return pruneByAccess(tree) + }) + + /** 递归按可见性裁剪菜单树:非 g3soft 隐藏 module 菜单(含其整棵子树),并剪掉空目录 */ + function pruneByAccess(nodes) { + const result = [] + for (const node of nodes) { + // 模块管理(菜单编码 module)仅 g3soft 可见 + if (!isG3soft.value && String(node.b_code || '').toLowerCase() === 'module') continue + const children = node.children?.length ? pruneByAccess(node.children) : [] + // 目录:没有可访问子菜单时不渲染 + if (node.b_menu_type === 'directory') { + if (children.length) result.push({ ...node, children }) + continue + } + result.push(children.length ? { ...node, children } : { ...node }) + } + return result + } + + function clear() { + // 仅当“正在进行中的请求属于别的主体”时才作废它(如切换账号), + // 否则保留进行中请求、只清空已加载数据,交由后续 load 结果填充。 + const userId = String(authStore.loginInfo.user?.id || '') + const principalKey = `${String(authStore.userInfo.orgId || '')}:${userId}` + if (loadingPrincipalKey && loadingPrincipalKey !== principalKey) { + loadVersion++ + loadingPromise = null + loadingPrincipalKey = '' + } + loadedPrincipalKey.value = '' + rawMenus.value = [] + } + + // 登录主体变化(退出/切换账号)时清空菜单缓存 + watch( + () => [ + authStore.userInfo.orgId, + authStore.loginInfo.user?.id, + authStore.loginInfo.user?.account, + authStore.loginInfo.token, + ], + clear, + ) + + return { + load, + clear, + isG3soft, + rawMenus, + visibleMenus, + } +}) diff --git a/code/fms/fms-vue/src/views/module/menu-management/MenuBasicPanel.vue b/code/fms/fms-vue/src/views/module/menu-management/MenuBasicPanel.vue index c220dc62..30a5ef30 100644 --- a/code/fms/fms-vue/src/views/module/menu-management/MenuBasicPanel.vue +++ b/code/fms/fms-vue/src/views/module/menu-management/MenuBasicPanel.vue @@ -11,6 +11,7 @@ import { computed, ref } from 'vue' import { Languages } from '@lucide/vue' import { Button, Form, FormItem, Input, Select } from '@/components/ui' import IconPicker from '@/components/icon-picker/IconPicker.vue' +import { menuRouteOptions } from '@/router/routes' import { MENU_TYPE_OPTIONS } from './utils' const props = defineProps({ @@ -30,6 +31,21 @@ const maindata = defineModel({ type: Object, default: null }) const isPage = computed(() => maindata.value?.b_menu_type === 'page') const isExternal = computed(() => maindata.value?.b_menu_type === 'external') +/** + * 路由地址候选:由静态路由表(@/router/routes)派生的页面路由, + * Select 的默认筛选即对 label / value 做包含匹配,等价于 like '%关键词%', + * 输入 "/module" 命中路径、输入 "模块" 命中标题。 + * 库里已存但已不在路由表中的旧值(历史数据 / 路由已下线)补进首项, + * 否则它会变成下拉里选不到的孤立值。 + */ +const routeOptions = computed(() => { + const current = String(maindata.value?.b_route ?? '').trim() + if (!current) return menuRouteOptions + return menuRouteOptions.some((option) => option.value === current) + ? menuRouteOptions + : [{ value: current, label: `${current}(已不在路由表中)` }, ...menuRouteOptions] +}) + /* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */ const formRules = computed(() => ({ b_code: [{ required: true, message: '请填写菜单编码' }], @@ -107,9 +123,15 @@ function onMenuTypeChange(menuType) { @update:model-value="onMenuTypeChange" /> - + - +