20260909224013

This commit is contained in:
oneao committed 2026-09-09 22:40:13 +08:00
1 parent d8acf69533
commit 19267be58d
4 files changed
+1372 -538

No files matched your search

+283 -285
View File
@@ -1,49 +1,38 @@
# FMS 新系统核心表结构
> 目标结构基于 2026-09-04 只读拉取的 `FMS-NEW` 数据库整理。
> 平台配置使用字符串业务键;未来业务数据表使用 `bigint` 雪花 ID。
## 0. SQL 驱动与动态配置
本系统是 SQL 驱动的内部 ERP。能通过 SQL 完成的查询、关联、过滤、排序、分页、聚合、权限过滤和数据范围过滤,优先在 SQL 层完成,不在业务代码中加载大量数据后再处理。
- 模块、字段、查询条件、排序、字段权限和数据范围等规则,优先从本文件定义的配置表读取,由通用查询逻辑动态生成 SQL;
- 动态 SQL 的表名、字段名和操作符只能来自受控的模块元数据、字段定义和白名单,业务值使用参数传递;
- 配置驱动的动态 SQL 不等于保存任意 SQL 字符串。不得把未审查的前端字符串或整段 SQL 直接保存后执行;
- 固定 SQL 仅用于稳定且明确的特殊业务逻辑,且应保持通用,不为单个页面或单个用户复制一套查询接口;
- 前端权限控制只负责界面展示,模块操作、字段访问和数据范围必须在后端查询或写入 SQL 中落实;用户个性化只能调整布局,不能放宽权限。
## 1. 用户表
```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,
b_logintime tinyint not null default 0
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 tinyint not null default 0 -- 登录次数
);
```
## 2. 模块
```sql
-- 模块配置表
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 tinyint not null default 1,
b_xh int not null default 0,
b_bz nvarchar(2000) null,
b_edit_mode varchar(10) null
b_id varchar(50) not null primary key, -- 模块编码(业务键主键)
b_parent_id varchar(50) null, -- 父级模块编码,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 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 -- 编辑模式
);
create index ix_s_module_parent
@@ -58,42 +47,42 @@ create index ix_s_module_savetable
## 3. 字段和字段布局
> 状态字段统一使用 `tinyint` 保存 0/1,由业务层负责校验取值。`b_canuse` 表示记录或配置是否启用、是否参与运行;`b_visible` 表示当前列表或编辑场景是否展示,二者语义不同,不互相替代。
```sql
-- 字段定义表
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 tinyint 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 tinyint not null default 1,
b_xh int not null default 0, -- 字段定义顺序:模块「同步字段」时按查询视图列序(10 起步)重排
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 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_canuse tinyint not null default 1, -- 是否启用(0/1)
b_xh int not null default 0, -- 显示顺序(同步字段时按视图列序重排,10 起步)
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,
b_colspan int not null default 48,
b_layout_direction varchar(10) null default 'vertical',
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)
);
@@ -103,47 +92,50 @@ create index ix_s_field_group_parent
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,
b_xh int not null default 0,
b_width int null,
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,
b_required tinyint not null default 0,
b_readonly tinyint not null default 0,
b_disabled tinyint 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,
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,
b_xh int not null default 0,
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)
);
@@ -153,31 +145,31 @@ create index ix_s_field_query_order
## 4. 用户个性化
> 用户个性化只作用于列表布局和普通查询收起态,不改变模块级默认配置。权限过滤优先于个人偏好,个人偏好不能越过字段权限或字段 `b_canuse` 限制。
```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,
b_xh int not null default 0,
b_width int null,
b_updatedatetime datetime2 null,
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 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, -- 普通查询收起态是否显示
b_xh int not null default 0, -- 普通查询字段顺序
b_updatedatetime datetime2 null,
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)
);
@@ -185,29 +177,22 @@ 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);
```
说明:
- `s_user_field_pref` 的 `b_mode` 区分查看列表和可编辑列表两套个人布局;
- `s_user_query_field_pref` 不区分普通查询/高级查询模式,因为两者使用同一批查询字段;
- 查询偏好只影响普通查询收起态,展开态和高级查询仍展示所有有权限且启用的查询字段;
- 没有个人偏好时回落到对应的 `s_field_view` / `s_field_edit` / `s_field_query` 默认配置;
- “恢复默认”删除当前用户、当前模块对应的个人偏好记录。
## 5. 自动编码
```sql
-- 自动编码表
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 tinyint not null default 1,
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 tinyint not null default 1, -- 是否启用(0/1)
primary key (b_module_id, b_field)
);
```
@@ -215,23 +200,24 @@ create table dbo.s_autocode (
## 6. 模块关系
```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_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 tinyint not null default 1,
b_xh int not null default 0,
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 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)
);
@@ -245,17 +231,17 @@ create index ix_s_relation_target
## 7. 菜单
```sql
-- 菜单表
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 tinyint not null default 1
b_id varchar(50) not null primary key, -- 菜单编码(业务键主键)
b_parent_id varchar(50) null, -- 父级菜单编码,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_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
@@ -264,71 +250,104 @@ create index ix_s_menu_parent
create index ix_s_menu_order
on dbo.s_menu (b_xh, b_id);
create index ix_s_menu_module
on dbo.s_menu (b_module_id);
create index ix_s_menu_route
on dbo.s_menu (b_route, b_canuse, b_id);
```
## 8. 权限
> 权限直接授予用户,不引入角色继承。`s_power.b_id` 是权限编码本身(业务键即主键),格式 `类型.对象[.能力]`,例如 `menu.cw_fee`、`module.cw_fee`、`action.cw_fee.audit`;完整规范见《字段权限与用户个性化设计.md》「权限编码规范」。字段授权使用独立的字段访问策略表。
权限定义与用户授权分开,但不增加菜单等级或权限计算中间表:
- `s_power` 是权限点字典,定义系统中可以授予的权限。菜单入口权限使用 `menu.<菜单编码>.access`,模块权限使用 `module.<模块编码>.<operation>`;菜单权限仍然通过 `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)
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);
```
```sql
-- 权限表
create table dbo.s_power (
b_id varchar(100) not null, -- 权限编码即主键:menu.cw_fee / module.cw_fee / action.cw_fee.audit
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, -- 归属对象类型:page/menu/module/report;action 时为所属模块或页面
b_owner_id varchar(50) not null, -- 归属对象编码:菜单/模块/页面/报表编码
b_capability varchar(30) null, -- action 的操作或动作编码:create/update/delete/export/audit...;入口类为 null
b_operation varchar(30) null, -- 兼容列:access/execute,由 b_power_type 派生
b_canuse tinyint not null default 1,
b_xh int not null default 0,
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)
);
create index ix_s_power_owner
on dbo.s_power (b_power_type, b_owner_type, b_owner_id, b_capability, b_canuse);
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,
b_updatedatetime datetime2 null,
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)
);
create index ix_s_user_power_user
on dbo.s_user_power (b_user_id, b_canuse, b_power_id);
```
`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,
b_allow_export tinyint not null default 0,
b_canuse tinyint not null default 1,
b_updatedatetime datetime2 null,
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,
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 (
@@ -341,51 +360,27 @@ 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);
```
权限点约定:
- `page.<页面编码>` / `menu.<菜单编码>` / `report.<报表编码>`:入口访问权限,报表列表中的每个报表可作为一个资源;
- `module.<模块编码>`:模块访问(≈ read,能否查看该模块数据);
- `action.<模块编码>.<操作或动作>`:数据操作(`create` / `update` / `delete` / `export`)与业务动作(`audit` / `settle` 等)统一为动作权限;
- `s_user_field_permission`:字段 `hidden/view/edit` 访问级别,以及独立的查询/导出能力;
- `b_power_type` 和 `b_capability` 是可扩展编码,新增资源类型不新增权限表,但需要补充权限定义和前端/后端校验规则;
- 动作必须带所属模块命名空间,例如 `action.cw_fee.audit`,不得使用裸 `action.audit`;**动作归属模块而非菜单**,避免同一个动作在不同菜单下被重复配置。
权限计算约定:
- 入口权限默认拒绝:`page.*`、`menu.*`、`report.*` 与 `action.*` 必须存在有效的 `s_user_power` 授权记录;停用的权限点或授权记录不生效;
- 模块操作 `action.<模块编码>.<操作>` 在没有授权记录时**默认放行还是默认拒绝尚未最终确定**,见《字段权限与用户个性化设计.md》「待确认事项」第 1 条;`s_power` 权限点或用户授权记录存在 `b_canuse = 0` 时视为明确禁用;
- 字段没有有效用户策略记录时不增加限制,按 `s_field`、`s_field_view`、`s_field_edit`、`s_field_query` 等模块默认配置执行;有效的 `s_user_field_permission` 只能收紧权限;`hidden` 表示不可见,`view` 表示可见只读,`edit` 表示可见可编辑;
- `hidden` 字段不可查询、不可导出;`b_allow_query` / `b_allow_export` 不能突破字段访问级别;
- 模块数据操作权限与数据范围同时生效:先判断操作权限,再按 `s_user_data_scope` 限制数据行;数据范围没有配置时视为 `all`;
- 数据范围优先使用当前操作的 `override`,没有 `override` 时使用 `operation = '*'` 的 `default`;同一层级的多条范围按 OR 合并;
- `read`、`export`、`update`、`delete` 在 SQL 行条件中应用数据范围;`create` 在写入前校验新增数据是否符合数据范围;
- 菜单/页面/报表访问权限只控制入口和访问,不能替代模块、字段或数据范围权限。
## 9. 多语言
```sql
-- b_i18n / b_key 保存完整资源键,不追加 .name 或 .title 后缀:
-- s_module.b_i18n = module.<module_id>
-- s_field.b_i18n = field.<module_id>.<b_field>
-- s_field_group.b_i18n = field_group.<module_id>.<b_id>
-- s_menu.b_i18n = menu.<b_id>
-- 多语言类型表
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,
b_default tinyint not null default 0,
b_xh int not null default 0
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_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 tinyint not null default 1,
b_module_id varchar(50) null,
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 tinyint not null default 1, -- 是否启用(0/1)
b_module_id varchar(50) null, -- 模块编码
unique (b_key, b_locale)
);
@@ -398,81 +393,83 @@ create index ix_s_i18n_locale
## 10. 日志
> 日志主键由业务层统一生成 UUIDv7。UUID 主键使用非聚集索引;技术、登录、审计日志按发生时间建立聚集索引,审计字段明细按审计事件建立聚集索引。日志顺序以 `b_occurdatetime` 为准,不依赖 UUID 排序。
```sql
-- 技术日志表
create table dbo.s_log_technical (
b_id uniqueidentifier not null, -- 业务层生成的 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,
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(),
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)
);
-- 登录日志表
create table dbo.s_log_login (
b_id uniqueidentifier not null, -- 业务层生成的 UUIDv7
b_user_id varchar(50) null, -- 当前系统中账号即用户稳定标识;登录失败且账号不存在时为 NULL
b_login_type varchar(20) not null,
b_login_result tinyint 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 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)
);
-- 审计日志表
create table dbo.s_log_audit (
b_id uniqueidentifier not null, -- 业务层生成的 UUIDv7
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(),
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_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)
);
-- 审计字段明细表
create table dbo.s_log_audit_field (
b_id uniqueidentifier not null, -- 业务层生成的 UUIDv7
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',
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)
);
@@ -501,9 +498,10 @@ create index ix_s_log_audit_module
## 11. 业务表主键约定
```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()
b_id bigint not null primary key, -- 主键(应用层雪花 ID)
b_inputuser_id varchar(50) null, -- 录入用户ID
b_inputdatetime datetime2 default sysutcdatetime() -- 录入时间
);
```
+250 -110
View File
@@ -1,129 +1,269 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FMS 权限中心 · 功能实例授权</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FMS 权限分配原型</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script>
tailwind.config = { theme: { extend: { fontFamily: { sans: ['Inter', 'Segoe UI', 'Microsoft YaHei', 'sans-serif'] }, colors: { ink: '#17212b', tealx: '#087f78', line: '#dfe7e9' } } } };
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Inter', 'Segoe UI', 'Microsoft YaHei', 'sans-serif'] },
colors: { ink: '#17212b', paper: '#f6f7f8', line: '#dfe5e8', brand: '#2463eb', tealx: '#0f766e' }
}
}
};
</script>
<style>
[v-cloak] { display: none }
::-webkit-scrollbar { width: 8px; height: 8px }
::-webkit-scrollbar-thumb { background: #c7d2d5; border-radius: 8px }
.mono { font-family: Consolas, Menlo, "Courier New", monospace }
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; background: #f6f7f8; color: #334155; font-family: Inter, "Segoe UI", "Microsoft YaHei", sans-serif; }
button, input, select { font: inherit; }
button:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid rgba(37, 99, 235, .22); outline-offset: 2px; }
.scrollbar::-webkit-scrollbar { width: 8px; height: 8px; }
.scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 8px; }
.segmented button { min-width: 58px; }
.segmented button + button { border-left: 1px solid #e2e8f0; }
.menu-row:hover { background: #fbfdff; }
.menu-row.selected { background: #eff6ff; }
.toast { animation: toast-in 180ms ease-out; }
@keyframes toast-in { from { transform: translate(-50%, 8px); opacity: 0 } to { transform: translate(-50%, 0); opacity: 1 } }
@media (max-width: 900px) {
.app-grid { grid-template-columns: 1fr !important; }
.role-panel { min-height: auto !important; }
.preview-panel { position: static !important; }
.menu-head, .menu-row > [data-select-menu] { grid-template-columns: 1fr !important; }
.menu-head > div:nth-child(2), .menu-head > div:nth-child(3) { display: none; }
.menu-row > [data-select-menu] > div:nth-child(2), .menu-row > [data-select-menu] > div:nth-child(3) { padding-left: 44px; text-align: left; }
.menu-row > [data-select-menu] > div:nth-child(3) .flex { justify-content: flex-start; }
}
</style>
</head>
<body class="bg-[#f4f7f7] font-sans text-[13px] text-slate-700 antialiased">
<div id="app" v-cloak>
<header class="sticky top-0 z-30 border-b border-line bg-white/95 backdrop-blur">
<div class="mx-auto flex h-14 max-w-[1500px] items-center gap-4 px-5">
<div class="flex items-center gap-3">
<span class="grid h-8 w-8 place-items-center rounded-lg bg-tealx text-sm font-bold text-white">F</span>
<div class="leading-tight"><div class="text-sm font-semibold text-ink">FMS 权限中心</div><div class="text-[11px] text-slate-400">功能实例授权 · 一客户一库</div></div>
<body>
<div id="app" class="min-h-screen">
<header class="border-b border-line bg-white">
<div class="mx-auto flex min-h-[68px] max-w-[1580px] items-center gap-4 px-5 py-3">
<div class="flex items-center gap-3">
<div class="grid h-9 w-9 place-items-center rounded-lg bg-ink text-sm font-bold text-white">F</div>
<div class="leading-tight">
<div class="text-[15px] font-semibold tracking-[-.01em] text-ink">FMS 权限中心</div>
<div class="mt-0.5 text-[11px] text-slate-400">岗位授权 · 菜单化配置,模块化执行</div>
</div>
</div>
<div class="ml-5 hidden h-7 w-px bg-line md:block"></div>
<div class="hidden items-center gap-2 text-[12px] text-slate-500 md:flex">
<span>系统设置</span><i data-lucide="chevron-right" class="h-3.5 w-3.5 text-slate-300"></i><span class="font-medium text-slate-700">权限分配</span>
</div>
<div class="ml-auto flex items-center gap-3">
<button id="previewUserBtn" class="hidden items-center gap-2 rounded-md border border-line bg-white px-3 py-2 text-[12px] text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 sm:flex">
<i data-lucide="user-round-search" class="h-4 w-4 text-slate-400"></i>预览用户
</button>
<button id="saveBtn" class="flex items-center gap-2 rounded-md bg-brand px-3.5 py-2 text-[12px] font-medium text-white shadow-sm transition hover:bg-blue-700">
<i data-lucide="save" class="h-4 w-4"></i>保存变更
</button>
<div class="hidden h-8 w-px bg-line sm:block"></div>
<div class="flex items-center gap-2">
<div class="grid h-8 w-8 place-items-center rounded-full bg-slate-100 text-[11px] font-bold text-slate-600">IT</div>
<div class="hidden leading-tight sm:block"><div class="text-[12px] font-medium text-slate-700">系统管理员</div><div class="text-[10px] text-slate-400">DEMO-FWD</div></div>
</div>
</div>
</div>
<nav class="ml-5 flex rounded-lg bg-slate-100 p-1">
<button v-for="tab in tabs" :key="tab.key" @click="state.tab = tab.key" class="rounded-md px-4 py-1.5 text-[12px] transition" :class="state.tab === tab.key ? 'bg-white font-medium text-tealx shadow-sm' : 'text-slate-500 hover:text-slate-700'">{{ tab.name }}</button>
</nav>
<div class="ml-auto flex items-center gap-3 border-l border-line pl-4"><div class="text-right leading-tight"><div class="text-[12px] font-medium text-slate-700">系统管理员</div><div class="text-[11px] text-slate-400">DEMO-FWD</div></div><span class="grid h-8 w-8 place-items-center rounded-full bg-teal-50 text-[11px] font-bold text-tealx">IT</span></div>
</div>
</header>
</header>
<main v-if="state.tab === 'grant'" class="mx-auto max-w-[1500px] space-y-4 px-5 py-5">
<section class="flex flex-wrap items-center gap-3 rounded-xl border border-line bg-white px-4 py-3">
<div class="mr-2 text-[12px] font-medium text-slate-500">授权对象</div>
<button v-for="user in users" :key="user.id" @click="state.userId = user.id" class="flex items-center gap-2 rounded-lg border px-2.5 py-1.5 transition" :class="state.userId === user.id ? 'border-teal-300 bg-teal-50 text-tealx' : 'border-line bg-white text-slate-600 hover:bg-slate-50'">
<span class="grid h-6 w-6 place-items-center rounded-full text-[11px] font-bold" :class="state.userId === user.id ? 'bg-tealx text-white' : 'bg-slate-100 text-slate-500'">{{ user.name.slice(0, 1) }}</span><span>{{ user.name }}</span><span class="text-[11px] text-slate-400">{{ user.title }}</span><span v-if="grantCount(user.id)" class="rounded bg-amber-50 px-1 text-[10px] text-amber-700">{{ grantCount(user.id) }}</span>
</button>
<div class="ml-auto text-[11px] text-slate-400">未授权 = 拒绝 · 功能权限独立生效</div>
</section>
<div class="grid gap-4 xl:grid-cols-[270px_minmax(0,1fr)_330px]">
<aside class="rounded-xl border border-line bg-white">
<div class="border-b border-line px-4 py-3"><div class="font-semibold text-ink">菜单</div><div class="mt-0.5 text-[11px] text-slate-400">按客户实际导航选择功能</div></div>
<div class="border-b border-line p-3"><input v-model="state.search" class="h-8 w-full rounded-md border border-line px-2.5 text-[12px] outline-none focus:border-tealx" placeholder="搜索菜单或功能"></div>
<div class="max-h-[620px] overflow-auto p-2">
<div v-for="group in menuGroups" :key="group.code" class="mb-3">
<div class="px-3 pb-1.5 pt-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">{{ group.name }}</div>
<template v-for="code in group.items" :key="code"><button v-if="menuMatches(code)" @click="state.featureCode = code" class="mb-1 w-full rounded-lg px-3 py-2 text-left transition" :class="state.featureCode === code ? 'bg-teal-50 text-tealx' : 'text-slate-600 hover:bg-slate-50'">
<div class="flex items-center gap-2"><span class="h-1.5 w-1.5 rounded-full bg-tealx"></span><span class="truncate font-medium">{{ featureByCode(code).name }}</span><span class="ml-auto rounded bg-slate-100 px-1 text-[9px] text-slate-500">页面</span></div><div class="mono mt-1 truncate text-[10px] text-slate-400">{{ code }}</div>
</button></template>
</div>
<div v-if="!menuGroups.some(group => group.items.some(code => menuMatches(code)))" class="px-3 py-8 text-center text-[11px] text-slate-400">没有匹配的菜单</div>
<main class="mx-auto max-w-[1580px] px-5 py-5">
<section class="mb-4 flex flex-wrap items-end justify-between gap-4">
<div>
<div class="mb-1 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[.13em] text-brand"><span class="h-1.5 w-1.5 rounded-full bg-brand"></span>授权工作台</div>
<h1 class="text-[22px] font-semibold tracking-[-.02em] text-ink">配置岗位能使用哪些业务功能</h1>
<p class="mt-1 text-[13px] text-slate-500">先按业务菜单选择权限等级,系统会自动展开到对应模块。共享模块只配置一次。</p>
</div>
</aside>
<section class="min-w-0 space-y-4">
<div class="rounded-xl border border-line bg-white">
<div class="flex items-start gap-3 border-b border-line px-5 py-4"><div><div class="flex items-center gap-2"><h1 class="text-[15px] font-semibold text-ink">{{ currentFeature.name }}</h1><span class="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">{{ typeName(currentFeature.type) }}</span></div><div class="mono mt-1 text-[11px] text-slate-400">{{ currentFeature.code }}</div></div><div class="ml-auto flex items-center gap-4"><label class="flex cursor-pointer items-center gap-2 rounded-md border px-2.5 py-1.5 text-[12px]" :class="hasPower(accessCode) ? 'border-teal-300 bg-teal-50 text-tealx' : 'border-line bg-white text-slate-500'"><input type="checkbox" class="h-4 w-4 accent-teal-600" :checked="hasPower(accessCode)" @change="togglePower(accessCode)"><span>{{ hasPower(accessCode) ? '启用' : '禁用' }}</span></label><div class="text-right text-[11px] text-slate-400"><div>{{ currentUser.name }} · 当前授权</div><div class="mt-0.5">模板:{{ currentFeature.template }}</div></div></div></div>
<div v-if="childFeatures.length" class="border-b border-line px-5 py-3"><div class="mb-2 text-[11px] font-medium text-slate-500">页面内功能</div><div class="flex flex-wrap gap-2"><button v-for="child in childFeatures" :key="child.code" @click="state.featureCode = child.code" class="rounded-md border px-2.5 py-1.5 text-left text-[11px] transition" :class="state.featureCode === child.code ? 'border-indigo-300 bg-indigo-50 text-indigo-700' : 'border-line text-slate-500 hover:bg-slate-50'"><span class="block font-medium">{{ child.name }}</span><span class="mono text-[10px] opacity-70">{{ child.code }}</span></button></div></div>
<div class="grid gap-3 border-b border-line bg-slate-50/60 px-5 py-3 sm:grid-cols-3"><div><div class="text-[11px] text-slate-400">使用模块</div><div class="mono mt-1 text-[11px] text-slate-600">{{ currentFeature.modules.join(' · ') || '无' }}</div></div><div><div class="text-[11px] text-slate-400">所属功能</div><div class="mono mt-1 text-[11px] text-slate-600">{{ currentFeature.parent || '顶层功能' }}</div></div><div><div class="text-[11px] text-slate-400">授权原则</div><div class="mt-1 text-[11px] text-slate-600">未勾选即不可用</div></div></div>
<div class="space-y-4 px-5 py-4">
<div><div class="mb-3 flex items-center justify-between"><div class="font-medium text-slate-700">模块默认能力</div><div class="text-[11px] text-slate-400">同一模块只配置一次,所有功能默认继承</div></div><div class="space-y-2"><div v-for="module in currentFeature.modules" :key="module" class="rounded-lg border border-line bg-slate-50/60 p-3"><div class="mb-2 flex items-center gap-2"><span class="mono text-[11px] font-medium text-tealx">{{ module }}</span><span class="rounded bg-white px-1.5 py-0.5 text-[10px] text-slate-400">全局默认</span></div><div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-4"><label v-for="capability in moduleCapabilities(module)" :key="capability" class="flex cursor-pointer items-center gap-2 rounded-md border bg-white px-2.5 py-2 text-[11px]" :class="hasModulePower(module, capability) ? 'border-teal-300 text-tealx' : 'border-line text-slate-500'"><input type="checkbox" class="h-3.5 w-3.5 accent-teal-600" :checked="hasModulePower(module, capability)" @change="toggleModulePower(module, capability)"><span>{{ capabilityName(capability) }}</span></label></div></div><div v-if="!currentFeature.modules.length" class="text-[11px] text-slate-400">该功能不直接读取业务数据</div></div></div></div>
<div v-if="currentFeature.modules.length" class="border-t border-line pt-4"><div class="mb-3 flex items-center justify-between"><div class="font-medium text-slate-700">本功能例外</div><div class="text-[11px] text-slate-400">仅能收紧模块默认能力</div></div><div class="space-y-2"><div v-for="module in currentFeature.modules" :key="module" class="rounded-lg border border-amber-200 bg-amber-50/40 p-3"><div class="mb-2 flex items-center gap-2"><span class="mono text-[11px] font-medium text-amber-700">{{ module }}</span><span class="text-[10px] text-amber-700/70">禁止本场景操作</span></div><div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-4"><label v-for="capability in moduleCapabilities(module)" :key="capability" class="flex cursor-pointer items-center gap-2 rounded-md border bg-white px-2.5 py-2 text-[11px]" :class="hasOverride(module, capability) ? 'border-amber-300 text-amber-700' : 'border-line text-slate-500'"><input type="checkbox" class="h-3.5 w-3.5 accent-amber-600" :checked="hasOverride(module, capability)" @change="toggleOverride(module, capability)"><span>{{ capabilityName(capability) }}</span></label></div></div></div></div></div>
<div v-if="featureActionPowers(currentFeature).length" class="border-t border-line pt-4"><div class="mb-3 flex items-center justify-between"><div class="font-medium text-slate-700">功能专属动作</div><div class="text-[11px] text-slate-400">审核、核销等动作单独授权</div></div><div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"><button v-for="power in featureActionPowers(currentFeature)" :key="power.code" @click="togglePower(power.code)" class="flex min-h-[58px] items-center gap-3 rounded-lg border px-3 py-2 text-left transition" :class="hasPower(power.code) ? 'border-teal-300 bg-teal-50/70' : 'border-line bg-white hover:bg-slate-50'"><span class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-[11px] font-bold" :class="hasPower(power.code) ? 'bg-tealx text-white' : 'bg-slate-100 text-slate-400'">{{ hasPower(power.code) ? '✓' : '—' }}</span><span class="min-w-0"><span class="block text-[12px] font-medium" :class="hasPower(power.code) ? 'text-tealx' : 'text-slate-600'">{{ capabilityName(power.capability) }}</span><span class="mono mt-0.5 block truncate text-[10px] text-slate-400">{{ power.code }}</span></span></button></div></div>
</div>
<div class="flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
<i data-lucide="shield-check" class="h-4 w-4 text-amber-600"></i>
<span>未授权 = 拒绝 · 个人设置不能扩大岗位权限</span>
</div>
<div class="rounded-xl border border-line bg-white"><div class="border-b border-line px-5 py-3"><div class="font-medium text-slate-700">字段与数据范围</div><div class="mt-0.5 text-[11px] text-slate-400">仍按技术模块配置,个人设置只能收紧</div></div><div class="grid gap-3 p-4 sm:grid-cols-2"><div v-for="module in currentFeature.modules" :key="module" class="rounded-lg bg-slate-50 px-3 py-2.5"><div class="mono text-[11px] text-tealx">{{ module }}</div><div class="mt-1 text-[11px] text-slate-500">字段策略:跟随模块默认</div><div class="mt-0.5 text-[11px] text-slate-500">数据范围:全部数据</div></div><div v-if="!currentFeature.modules.length" class="text-[11px] text-slate-400">该功能不直接读取业务数据</div></div></div>
</section>
<aside class="space-y-4"><div class="rounded-xl border border-line bg-white"><div class="border-b border-line px-4 py-3"><div class="font-semibold text-ink">当前生效结果</div><div class="mt-0.5 text-[11px] text-slate-400">{{ currentUser.name }} · {{ currentFeature.name }}</div></div><div class="space-y-2 p-4"><div v-for="item in resultItems" :key="item.label" class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2"><span class="text-[12px] text-slate-600">{{ item.label }}</span><span class="rounded px-1.5 py-0.5 text-[10px]" :class="item.on ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-200 text-slate-400'">{{ item.on ? '已授权' : '未授权' }}</span></div></div></div><div class="rounded-xl border border-line bg-white"><div class="border-b border-line px-4 py-3"><div class="font-semibold text-ink">将写入的授权</div><div class="mt-0.5 text-[11px] text-slate-400">s_user_power · 使用业务权限编码</div></div><div class="max-h-72 overflow-auto p-3"><div v-for="power in activePowers" :key="power.code" class="mb-2 rounded-lg bg-slate-50 p-2.5 last:mb-0"><div class="mono break-all text-[10px] text-tealx">{{ power.code }}</div><div class="mt-1 text-[11px] text-slate-500">{{ currentUser.id }} · b_canuse = 1</div></div><div v-if="!activePowers.length" class="py-8 text-center text-[11px] text-slate-400">当前功能没有授权记录</div></div></div></aside>
</div>
</main>
<section class="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="border border-line bg-white px-4 py-3"><div class="text-[11px] text-slate-400">岗位角色</div><div id="roleCount" class="mt-1 text-[21px] font-semibold text-ink">5</div></div>
<div class="border border-line bg-white px-4 py-3"><div class="text-[11px] text-slate-400">当前岗位成员</div><div id="memberCount" class="mt-1 text-[21px] font-semibold text-ink">12</div></div>
<div class="border border-line bg-white px-4 py-3"><div class="text-[11px] text-slate-400">已配置业务菜单</div><div id="configuredCount" class="mt-1 text-[21px] font-semibold text-ink">6</div></div>
<div class="border border-line bg-white px-4 py-3"><div class="text-[11px] text-slate-400">待保存变更</div><div id="changeCount" class="mt-1 text-[21px] font-semibold text-amber-600">0</div></div>
</section>
<main v-else class="mx-auto max-w-[1500px] space-y-4 px-5 py-5"><section class="rounded-xl bg-[#17212b] px-5 py-4 text-white"><div class="flex flex-wrap items-end justify-between gap-4"><div><div class="text-[11px] font-semibold tracking-[.16em] text-teal-300">IT CONFIGURATION</div><h1 class="mt-1 text-lg font-semibold">功能实例与权限生成</h1><p class="mt-1 text-[12px] text-slate-300">技术人员维护业务功能、公共模板和模块依赖,客户只负责授权。</p></div><div class="grid grid-cols-3 gap-2 text-center"><div class="rounded-lg bg-white/10 px-4 py-2"><div class="text-[10px] text-slate-300">功能实例</div><div class="mt-0.5 text-xl font-semibold">{{ features.length }}</div></div><div class="rounded-lg bg-white/10 px-4 py-2"><div class="text-[10px] text-slate-300">公共模板</div><div class="mt-0.5 text-xl font-semibold">{{ templateCount }}</div></div><div class="rounded-lg bg-white/10 px-4 py-2"><div class="text-[10px] text-slate-300">权限点</div><div class="mt-0.5 text-xl font-semibold">{{ allPowers.length }}</div></div></div></div></section><div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_390px]"><section class="rounded-xl border border-line bg-white"><div class="flex items-center justify-between border-b border-line px-5 py-3"><div><div class="font-semibold text-ink">功能实例清单</div><div class="mt-0.5 text-[11px] text-slate-400">功能入口与模块默认能力分层维护</div></div><input v-model="state.search" class="h-8 w-52 rounded-md border border-line px-2.5 text-[12px] outline-none focus:border-tealx" placeholder="搜索"></div><div class="overflow-auto"><table class="w-full"><thead class="bg-slate-50 text-[11px] text-slate-500"><tr><th class="px-5 py-2.5 text-left font-medium">功能</th><th class="px-3 py-2.5 text-left font-medium">类型</th><th class="px-3 py-2.5 text-left font-medium">公共模板</th><th class="px-3 py-2.5 text-left font-medium">使用模块</th><th class="px-3 py-2.5 text-right font-medium">功能权限</th></tr></thead><tbody><tr v-for="feature in filteredFeatures" :key="feature.code" @click="state.configCode = feature.code" class="cursor-pointer border-t border-slate-100 hover:bg-slate-50"><td class="px-5 py-2.5"><div class="font-medium text-slate-700">{{ feature.name }}</div><div class="mono mt-0.5 text-[10px] text-slate-400">{{ feature.code }}</div></td><td class="px-3 py-2.5 text-[11px] text-slate-500">{{ typeName(feature.type) }}</td><td class="px-3 py-2.5 text-[11px] text-slate-500">{{ feature.template }}</td><td class="px-3 py-2.5"><span v-for="module in feature.modules" :key="module" class="mono mr-1 rounded bg-slate-100 px-1 text-[10px] text-slate-500">{{ module }}</span><span v-if="!feature.modules.length" class="text-[11px] text-slate-300">无</span></td><td class="px-3 py-2.5 text-right text-[11px] text-tealx">{{ powersFor(feature).length }}</td></tr></tbody></table></div></section><section class="rounded-xl border border-line bg-white"><div class="border-b border-line px-4 py-3"><div class="font-semibold text-ink">技术配置详情</div><div class="mono mt-1 text-[11px] text-slate-400">{{ configFeature.code }}</div></div><div class="space-y-3 p-4"><div><div class="text-[11px] text-slate-400">功能名称</div><div class="mt-1 text-[13px] font-medium text-slate-700">{{ configFeature.name }}</div></div><div><div class="text-[11px] text-slate-400">模板</div><div class="mono mt-1 rounded-md bg-slate-50 px-2.5 py-2 text-[11px] text-slate-600">{{ configFeature.template }}</div></div><div><div class="text-[11px] text-slate-400">模块白名单</div><div class="mt-1 space-y-1"><div v-for="module in configFeature.modules" :key="module" class="mono rounded-md bg-teal-50 px-2.5 py-2 text-[11px] text-tealx">{{ module }}</div><div v-if="!configFeature.modules.length" class="text-[11px] text-slate-400">无数据模块</div></div></div><div><div class="mb-1.5 text-[11px] text-slate-400">自动生成权限</div><div class="space-y-1.5"><div v-for="power in configPowers(configFeature)" :key="power.code" class="flex items-center justify-between rounded-md bg-slate-50 px-2.5 py-2"><span class="mono text-[10px] text-slate-600">{{ power.code }}</span><span class="text-[10px] text-slate-400">{{ capabilityName(power.capability) }}</span></div></div></div></div></section></div></main>
</div>
<div class="app-grid grid items-start gap-4 xl:grid-cols-[250px_minmax(0,1fr)_330px]">
<aside class="role-panel min-h-[720px] border border-line bg-white">
<div class="border-b border-line px-4 py-4">
<div class="flex items-center justify-between"><div class="font-semibold text-ink">岗位角色</div><button id="addRoleBtn" title="新增岗位" class="grid h-7 w-7 place-items-center rounded-md border border-line text-slate-500 transition hover:border-brand hover:text-brand"><i data-lucide="plus" class="h-4 w-4"></i></button></div>
<div class="mt-1 text-[11px] text-slate-400">新员工先绑定岗位,少量人员再做例外</div>
<div class="relative mt-3"><i data-lucide="search" class="pointer-events-none absolute left-2.5 top-2 h-4 w-4 text-slate-400"></i><input id="roleSearch" placeholder="搜索岗位" class="h-8 w-full rounded-md border border-line bg-white pl-8 pr-2 text-[12px] text-slate-700 outline-none focus:border-brand" /></div>
</div>
<div id="roleList" class="scrollbar max-h-[620px] overflow-auto p-2"></div>
<div class="border-t border-line px-4 py-3"><button id="exceptionBtn" class="flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-[11px] text-slate-500 transition hover:bg-slate-50"><span class="flex items-center gap-2"><i data-lucide="user-cog" class="h-4 w-4 text-slate-400"></i>用户例外</span><i data-lucide="arrow-up-right" class="h-3.5 w-3.5 text-slate-400"></i></button></div>
</aside>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script>
const { createApp, reactive, computed } = Vue;
const features = [
{ code: 'sea_detail', name: '海运详细', type: 'page', parent: '', template: 'DetailPage', modules: ['sea_main', 'cw_fee'], powers: ['access', 'audit'] },
{ code: 'sea_detail_container', name: '海运详细 · 装箱区域', type: 'panel', parent: 'sea_detail', template: 'DetailPanel', modules: ['container_main'], powers: ['access', 'read'] },
{ code: 'container_query', name: '装箱查询', type: 'page', parent: '', template: 'CommonListPage', modules: ['container_main'], powers: ['access', 'read', 'export'] },
{ code: 'customer_list', name: '客户档案', type: 'page', parent: '', template: 'CommonListPage', modules: ['customer'], powers: ['access'] },
{ code: 'finance_dashboard', name: '财务看板', type: 'page', parent: '', template: 'DashboardPage', modules: [], powers: ['access'] },
{ code: 'ar_aging_card', name: '应收账龄', type: 'report_card', parent: 'finance_dashboard', template: 'ReportCard', modules: ['finance_main'], powers: ['access', 'export'] },
{ code: 'gross_profit_card', name: '毛利分析', type: 'report_card', parent: 'finance_dashboard', template: 'ReportCard', modules: ['finance_main'], powers: ['access', 'export'] }
];
const users = [{ id: 'zhangsan', name: '张三', title: '费用会计' }, { id: 'lisi', name: '李四', title: '财务经理' }, { id: 'wangwu', name: '王五', title: '海运操作' }, { id: 'zhouba', name: '周八', title: '销售经理' }];
const menuGroups = [{ code: 'operation', name: '业务管理', items: ['sea_detail', 'container_query'] }, { code: 'basic', name: '基础资料', items: ['customer_list'] }, { code: 'finance', name: '财务中心', items: ['finance_dashboard'] }];
const moduleCaps = { sea_main: ['read', 'update'], cw_fee: ['read', 'create', 'update', 'export'], container_main: ['read', 'export'], customer: ['read', 'create', 'update', 'delete', 'export'], finance_main: ['read'] };
const grants = reactive({ zhangsan: ['feature.sea_detail.access', 'feature.sea_detail_container.access', 'feature.container_query.access', 'feature.cw_fee_list.access', 'module.sea_main.read', 'module.container_main.read', 'module.cw_fee.read', 'module.cw_fee.create', 'module.cw_fee.update'], lisi: ['feature.sea_detail.access', 'feature.sea_detail_container.access', 'feature.container_query.access', 'feature.cw_fee_list.access', 'feature.finance_dashboard.access', 'feature.ar_aging_card.access', 'module.sea_main.read', 'module.sea_main.update', 'module.container_main.read', 'module.container_main.export', 'module.cw_fee.read', 'module.cw_fee.create', 'module.cw_fee.update', 'module.cw_fee.export', 'module.customer.read', 'module.customer.create', 'module.customer.update', 'module.customer.delete', 'module.customer.export', 'module.finance_main.read', 'feature.sea_detail.audit'], wangwu: ['feature.sea_detail.access', 'feature.sea_detail_container.access', 'module.sea_main.read', 'module.container_main.read'], zhouba: [] });
const overrides = reactive({ zhangsan: [], lisi: ['sea_detail|cw_fee|create', 'sea_detail|cw_fee|update', 'sea_detail|cw_fee|export'], wangwu: [], zhouba: [] });
const capabilityNames = { access: '启用', read: '查看 / 查询', create: '新增', update: '修改', delete: '删除', export: '导出', audit: '审核' };
function powerFor(feature, capability) { return { code: 'feature.' + feature.code + '.' + capability, capability }; }
function modulePowerFor(module, capability) { return { code: 'module.' + module + '.' + capability, capability, module }; }
createApp({ setup() {
const state = reactive({ tab: 'grant', userId: 'zhangsan', featureCode: 'container_query', configCode: 'container_query', search: '' });
const tabs = [{ key: 'grant', name: '用户授权' }, { key: 'config', name: '功能配置' }];
const filteredFeatures = computed(() => { const q = state.search.trim().toLowerCase(); return q ? features.filter(x => (x.name + x.code + x.template).toLowerCase().includes(q)) : features; });
const currentFeature = computed(() => features.find(x => x.code === state.featureCode) || features[0]);
const accessCode = computed(() => 'feature.' + currentFeature.value.code + '.access');
const childFeatures = computed(() => features.filter(x => x.parent === currentFeature.value.code));
const configFeature = computed(() => features.find(x => x.code === state.configCode) || features[0]);
const currentUser = computed(() => users.find(x => x.id === state.userId) || users[0]);
const allPowers = computed(() => features.flatMap(f => powersFor(f)).concat(Object.keys(moduleCaps).flatMap(m => moduleCapabilities(m).map(c => modulePowerFor(m, c)))));
const templateCount = computed(() => new Set(features.map(x => x.template)).size);
function powersFor(feature) { return feature.powers.map(cap => powerFor(feature, cap)); }
function featureActionPowers(feature) { return powersFor(feature).filter(p => p.capability !== 'access'); }
function moduleCapabilities(module) { return moduleCaps[module] || []; }
function hasModulePower(module, capability) { return grants[state.userId].includes(modulePowerFor(module, capability).code); }
function toggleModulePower(module, capability) { togglePower(modulePowerFor(module, capability).code); }
function overrideKey(module, capability) { return currentFeature.value.code + '|' + module + '|' + capability; }
function hasOverride(module, capability) { return overrides[state.userId].includes(overrideKey(module, capability)); }
function toggleOverride(module, capability) { const list = overrides[state.userId]; const key = overrideKey(module, capability); const i = list.indexOf(key); if (i > -1) list.splice(i, 1); else list.push(key); }
function hasPower(code) { return grants[state.userId].includes(code); }
function togglePower(code) { const list = grants[state.userId]; const i = list.indexOf(code); if (i > -1) list.splice(i, 1); else list.push(code); }
function activePowers() { const feature = powersFor(currentFeature.value).filter(p => hasPower(p.code)); const modules = currentFeature.value.modules.flatMap(m => moduleCapabilities(m).map(c => modulePowerFor(m, c))).filter(p => hasModulePower(p.module, p.capability)); return feature.concat(modules); }
function grantCount(userId) { return grants[userId].length; }
function featureByCode(code) { return features.find(x => x.code === code) || features[0]; }
function menuMatches(code) { const q = state.search.trim().toLowerCase(); const f = featureByCode(code); return !q || (f.name + f.code).toLowerCase().includes(q); }
function typeName(type) { return type === 'page' ? '页面' : type === 'panel' ? '区域' : '报表卡片'; }
function capabilityName(cap) { return capabilityNames[cap] || cap; }
return { state, tabs, users, features, menuGroups, filteredFeatures, currentFeature, accessCode, childFeatures, configFeature, currentUser, allPowers, templateCount, powersFor, featureActionPowers, moduleCapabilities, hasModulePower, toggleModulePower, hasOverride, toggleOverride, hasPower, togglePower, activePowers, grantCount, featureByCode, menuMatches, typeName, capabilityName, resultItems: computed(() => { const f = currentFeature.value; const items = [{ label: '功能启用', on: hasPower('feature.' + f.code + '.access') }]; f.modules.forEach(m => moduleCapabilities(m).forEach(c => items.push({ label: m + ' · ' + capabilityName(c), on: hasModulePower(m, c) && !hasOverride(m, c) }))); return items; }) };
} }).mount('#app');
</script>
<section class="min-w-0 border border-line bg-white">
<div class="border-b border-line px-5 py-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2"><h2 id="currentRoleName" class="text-[16px] font-semibold text-ink">费用会计</h2><span id="currentRoleStatus" class="rounded bg-emerald-50 px-2 py-1 text-[10px] font-medium text-emerald-700">已启用</span></div>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-400"><span id="currentRoleDesc">负责费用录入、核对和结算</span><span class="text-slate-300">·</span><span id="currentRoleMembers">12 名成员</span><span class="text-slate-300">·</span><span>主岗位</span></div>
</div>
<div class="flex items-center gap-2"><button id="resetBtn" class="flex items-center gap-2 rounded-md border border-line px-3 py-2 text-[11px] text-slate-600 transition hover:bg-slate-50"><i data-lucide="rotate-ccw" class="h-3.5 w-3.5"></i>恢复岗位模板</button><button id="memberBtn" class="flex items-center gap-2 rounded-md border border-line px-3 py-2 text-[11px] text-slate-600 transition hover:bg-slate-50"><i data-lucide="users" class="h-3.5 w-3.5"></i>查看成员</button></div>
</div>
</div>
<div class="border-b border-line bg-slate-50/70 px-5 py-3">
<div class="flex flex-wrap items-center justify-between gap-2"><div><div class="text-[12px] font-semibold text-slate-700">业务菜单权限</div><div class="mt-0.5 text-[11px] text-slate-400">权限等级会自动展开为模块动作;“自定义”仅在特殊场景使用</div></div><div class="flex items-center gap-2"><div class="relative"><i data-lucide="search" class="pointer-events-none absolute left-2.5 top-1.5 h-3.5 w-3.5 text-slate-400"></i><input id="menuSearch" placeholder="搜索业务菜单" class="h-7 w-40 rounded-md border border-line bg-white pl-7 pr-2 text-[11px] outline-none focus:border-brand" /></div><button id="expandBtn" class="flex h-7 items-center gap-1.5 rounded-md border border-line bg-white px-2.5 text-[11px] text-slate-600 hover:bg-slate-50"><i data-lucide="list-plus" class="h-3.5 w-3.5"></i><span>展开全部</span></button></div></div>
</div>
<div class="menu-head grid grid-cols-[minmax(210px,1fr)_minmax(300px,1.3fr)_120px] gap-3 border-b border-line px-5 py-2.5 text-[10px] font-semibold uppercase tracking-[.08em] text-slate-400"><div>业务页面</div><div>岗位权限等级</div><div class="text-right">使用模块</div></div>
<div id="menuList" class="scrollbar max-h-[560px] overflow-auto"></div>
<div id="detailPanel" class="border-t border-line bg-[#fbfcfd]"></div>
</section>
<aside class="preview-panel sticky top-4 space-y-4">
<section class="border border-line bg-white">
<div class="border-b border-line px-4 py-4"><div class="flex items-center justify-between"><div><div class="font-semibold text-ink">生效结果</div><div class="mt-1 text-[11px] text-slate-400">系统按模块合并后的真实权限</div></div><span class="grid h-8 w-8 place-items-center rounded-md bg-blue-50 text-brand"><i data-lucide="scan-eye" class="h-4 w-4"></i></span></div></div>
<div class="grid grid-cols-2 gap-px bg-line"><div class="bg-white px-4 py-3"><div class="text-[10px] text-slate-400">可进入菜单</div><div id="previewMenus" class="mt-1 text-[20px] font-semibold text-ink">6</div></div><div class="bg-white px-4 py-3"><div class="text-[10px] text-slate-400">可用模块</div><div id="previewModules" class="mt-1 text-[20px] font-semibold text-ink">8</div></div><div class="bg-white px-4 py-3"><div class="text-[10px] text-slate-400">经办动作</div><div id="previewActions" class="mt-1 text-[20px] font-semibold text-ink">14</div></div><div class="bg-white px-4 py-3"><div class="text-[10px] text-slate-400">敏感字段</div><div id="previewSensitive" class="mt-1 text-[20px] font-semibold text-ink">3</div></div></div>
<div class="border-t border-line px-4 py-3"><div class="mb-2 flex items-center justify-between"><span class="text-[11px] font-medium text-slate-600">模块权限来源</span><span class="text-[10px] text-slate-400">共享模块自动合并</span></div><div id="moduleSummary" class="space-y-2"></div></div>
</section>
<section class="border border-line bg-white">
<div class="border-b border-line px-4 py-3"><div class="flex items-center justify-between"><div class="font-semibold text-ink">统一数据范围</div><button id="scopeBtn" title="编辑数据范围" class="grid h-7 w-7 place-items-center rounded-md border border-line text-slate-500 hover:border-brand hover:text-brand"><i data-lucide="sliders-horizontal" class="h-3.5 w-3.5"></i></button></div><div class="mt-1 text-[11px] text-slate-400">默认作用于岗位下的业务模块</div></div>
<div class="space-y-2 px-4 py-3"><div class="flex items-center justify-between rounded-md bg-slate-50 px-3 py-2"><span class="text-[11px] text-slate-500">查看范围</span><span class="flex items-center gap-1.5 text-[11px] font-medium text-slate-700"><span class="h-1.5 w-1.5 rounded-full bg-tealx"></span><span id="readScope">本部门及下级</span></span></div><div class="flex items-center justify-between rounded-md bg-slate-50 px-3 py-2"><span class="text-[11px] text-slate-500">操作范围</span><span class="flex items-center gap-1.5 text-[11px] font-medium text-slate-700"><span class="h-1.5 w-1.5 rounded-full bg-amber-500"></span><span id="writeScope">本部门</span></span></div></div>
</section>
<section class="border border-line bg-white">
<div class="border-b border-line px-4 py-3"><div class="font-semibold text-ink">敏感字段策略</div><div class="mt-1 text-[11px] text-slate-400">只展示需要关注的字段组</div></div><div id="fieldSummary" class="space-y-2 px-4 py-3"></div>
</section>
<div class="flex items-start gap-2 border border-blue-100 bg-blue-50/70 px-3 py-3 text-[11px] leading-5 text-blue-800"><i data-lucide="info" class="mt-0.5 h-4 w-4 shrink-0 text-brand"></i><span>菜单是配置入口,模块是执行主体。修改共享模块时,系统会提示受影响的其他业务页面。</span></div>
</aside>
</div>
</main>
<div id="toast" class="pointer-events-none fixed bottom-5 left-1/2 z-50 hidden -translate-x-1/2"></div>
</div>
<script>
const levels = [
{ key: 'none', label: '无权', actions: 0 },
{ key: 'view', label: '查看', actions: 1 },
{ key: 'operate', label: '经办', actions: 3 },
{ key: 'manage', label: '主管', actions: 5 },
{ key: 'custom', label: '自定义', actions: 0 }
];
const menus = [
{ id: 'sea', group: '海运业务', name: '海运业务', desc: '海运订单、箱量与操作节点', icon: 'ship', modules: [{ id: 'shipment', name: '海运订单', usage: '主模块' }, { id: 'container', name: '装箱管理', usage: '业务模块' }], shared: false },
{ id: 'air', group: '空运业务', name: '空运业务', desc: '空运订单、航班与操作节点', icon: 'plane', modules: [{ id: 'air_order', name: '空运订单', usage: '主模块' }, { id: 'air_charge', name: '空运费用', usage: '引用模块' }], shared: false },
{ id: 'fee', group: '财务管理', name: '费用管理', desc: '费用录入、审核与结算', icon: 'receipt-text', modules: [{ id: 'cw_fee', name: '费用模块', usage: '主模块' }, { id: 'customer', name: '客户档案', usage: '引用模块' }], shared: true },
{ id: 'invoice', group: '财务管理', name: '发票管理', desc: '开票、收票与核销', icon: 'file-text', modules: [{ id: 'invoice', name: '发票模块', usage: '主模块' }, { id: 'cw_fee', name: '费用模块', usage: '共享模块' }], shared: true },
{ id: 'customer', group: '基础资料', name: '客户档案', desc: '客户资料和联系人维护', icon: 'contact', modules: [{ id: 'customer', name: '客户档案', usage: '主模块' }], shared: true },
{ id: 'report', group: '分析报表', name: '经营报表', desc: '收入、成本与毛利分析', icon: 'chart-no-axes-combined', modules: [{ id: 'report', name: '报表数据集', usage: '报表模块' }], shared: false }
];
const roles = [
{ id: 'operator', name: '海运操作', desc: '订单录入与节点跟进', members: 18, color: 'bg-blue-100 text-blue-700', levels: { sea: 'operate', air: 'none', fee: 'view', invoice: 'none', customer: 'view', report: 'none' }, scopes: ['本部门及下级', '本部门'], fields: [['内部成本', '隐藏'], ['毛利', '隐藏'], ['客户联系人', '可见']] },
{ id: 'service', name: '客服专员', desc: '客户沟通与业务查询', members: 9, color: 'bg-violet-100 text-violet-700', levels: { sea: 'operate', air: 'operate', fee: 'view', invoice: 'view', customer: 'view', report: 'view' }, scopes: ['全部业务', '本部门'], fields: [['内部成本', '隐藏'], ['毛利', '隐藏'], ['客户联系人', '可编辑']] },
{ id: 'accountant', name: '费用会计', desc: '负责费用录入、核对和结算', members: 12, color: 'bg-emerald-100 text-emerald-700', levels: { sea: 'view', air: 'view', fee: 'manage', invoice: 'operate', customer: 'view', report: 'view' }, scopes: ['本部门及下级', '本部门'], fields: [['内部成本', '可见'], ['毛利', '只读'], ['客户联系人', '可见']] },
{ id: 'finance', name: '财务经理', desc: '财务审核、核销与经营分析', members: 4, color: 'bg-amber-100 text-amber-700', levels: { sea: 'view', air: 'view', fee: 'manage', invoice: 'manage', customer: 'view', report: 'manage' }, scopes: ['全部业务', '全部业务'], fields: [['内部成本', '可见'], ['毛利', '可编辑'], ['客户联系人', '可见']] },
{ id: 'readonly', name: '只读查询', desc: '跨业务查询,不允许修改', members: 27, color: 'bg-slate-100 text-slate-600', levels: { sea: 'view', air: 'view', fee: 'view', invoice: 'view', customer: 'view', report: 'view' }, scopes: ['全部业务', '无'], fields: [['内部成本', '隐藏'], ['毛利', '隐藏'], ['客户联系人', '可见']] }
];
const appState = { roleId: 'accountant', roleSearch: '', menuSearch: '', selectedMenu: 'fee', expanded: new Set(['fee']), changed: 0, toastTimer: null };
const roleById = id => roles.find(role => role.id === id);
const menuById = id => menus.find(menu => menu.id === id);
const levelByKey = key => levels.find(level => level.key === key) || levels[0];
function icon(name, cls = 'h-4 w-4') { return `<i data-lucide="${name}" class="${cls}"></i>`; }
function refreshIcons() { if (window.lucide) lucide.createIcons({ attrs: { 'stroke-width': 1.8 } }); }
function showToast(message, tone = 'dark') {
const toast = document.getElementById('toast');
toast.className = 'toast pointer-events-none fixed bottom-5 left-1/2 z-50 -translate-x-1/2';
toast.innerHTML = `<div class="flex items-center gap-2 rounded-md px-3.5 py-2.5 text-[12px] shadow-lg ${tone === 'success' ? 'bg-emerald-700 text-white' : 'bg-ink text-white'}">${icon(tone === 'success' ? 'check-circle-2' : 'info', 'h-4 w-4')}<span>${message}</span></div>`;
refreshIcons();
clearTimeout(appState.toastTimer);
appState.toastTimer = setTimeout(() => { toast.className = 'pointer-events-none fixed bottom-5 left-1/2 z-50 hidden -translate-x-1/2'; }, 2300);
}
function levelClass(key) { return ({ none: 'bg-slate-100 font-medium text-slate-500', view: 'bg-blue-50 font-medium text-blue-700', operate: 'bg-teal-50 font-medium text-teal-700', manage: 'bg-amber-50 font-medium text-amber-700', custom: 'bg-violet-50 font-medium text-violet-700' })[key]; }
function levelControl(menu, role) {
const current = role.levels[menu.id] || 'none';
return `<div class="segmented flex w-fit overflow-hidden rounded-md border border-line bg-white">${levels.map(level => `<button data-level="${level.key}" data-menu="${menu.id}" class="h-8 px-2.5 text-[11px] transition ${current === level.key ? levelClass(level.key) : 'text-slate-500 hover:bg-slate-50'}">${level.label}</button>`).join('')}</div>`;
}
function renderRoles() {
const query = appState.roleSearch.trim().toLowerCase();
const list = roles.filter(role => !query || `${role.name}${role.desc}`.toLowerCase().includes(query));
document.getElementById('roleList').innerHTML = list.map(role => `<button data-role="${role.id}" class="mb-1.5 flex w-full items-center gap-3 rounded-md px-3 py-3 text-left transition ${appState.roleId === role.id ? 'bg-blue-50 ring-1 ring-inset ring-blue-100' : 'hover:bg-slate-50'}"><span class="grid h-8 w-8 shrink-0 place-items-center rounded-md text-[11px] font-semibold ${role.color}">${role.name.slice(0, 1)}</span><span class="min-w-0 flex-1"><span class="flex items-center justify-between gap-2"><span class="truncate text-[12px] font-medium ${appState.roleId === role.id ? 'text-blue-800' : 'text-slate-700'}">${role.name}</span><span class="text-[10px] text-slate-400">${role.members}人</span></span><span class="mt-0.5 block truncate text-[10px] text-slate-400">${role.desc}</span></span></button>`).join('') || '<div class="px-3 py-10 text-center text-[11px] text-slate-400">没有匹配岗位</div>';
document.querySelectorAll('[data-role]').forEach(el => el.addEventListener('click', () => { appState.roleId = el.dataset.role; appState.selectedMenu = 'fee'; appState.expanded = new Set(['fee']); render(); }));
}
function renderMenus() {
const role = roleById(appState.roleId);
const query = appState.menuSearch.trim().toLowerCase();
const filtered = menus.filter(menu => !query || `${menu.name}${menu.desc}${menu.group}`.toLowerCase().includes(query));
let previousGroup = '';
let html = '';
filtered.forEach(menu => {
if (menu.group !== previousGroup) { html += `<div class="border-b border-line bg-slate-50/90 px-5 py-2 text-[10px] font-semibold tracking-wide text-slate-400">${menu.group}</div>`; previousGroup = menu.group; }
const active = appState.selectedMenu === menu.id;
const expanded = appState.expanded.has(menu.id);
const current = levelByKey(role.levels[menu.id]);
html += `<div class="menu-row ${active ? 'selected' : ''} border-b border-line transition"><div data-select-menu="${menu.id}" class="grid cursor-pointer grid-cols-[minmax(210px,1fr)_minmax(300px,1.3fr)_120px] items-center gap-3 px-5 py-3"><div class="flex min-w-0 items-center gap-3"><button data-expand-menu="${menu.id}" title="展开${menu.name}" class="grid h-7 w-7 shrink-0 place-items-center rounded-md border border-line bg-white text-slate-400 transition hover:border-brand hover:text-brand">${icon(expanded ? 'chevron-down' : 'chevron-right', 'h-3.5 w-3.5')}</button><span class="grid h-8 w-8 shrink-0 place-items-center rounded-md ${active ? 'bg-blue-100 text-brand' : 'bg-slate-100 text-slate-500'}">${icon(menu.icon, 'h-4 w-4')}</span><span class="min-w-0"><span class="flex items-center gap-2"><span class="truncate text-[13px] font-medium ${active ? 'text-blue-800' : 'text-slate-700'}">${menu.name}</span>${menu.shared ? '<span class="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[9px] text-slate-500">含共享模块</span>' : ''}</span><span class="mt-0.5 block truncate text-[10px] text-slate-400">${menu.desc}</span></span></div><div class="min-w-0" onclick="event.stopPropagation()">${levelControl(menu, role)}<div class="mt-1.5 flex items-center gap-1.5 text-[10px] text-slate-400"><span class="h-1.5 w-1.5 rounded-full ${current.key === 'none' ? 'bg-slate-300' : current.key === 'custom' ? 'bg-violet-500' : 'bg-emerald-500'}"></span>${current.key === 'none' ? '未开放' : current.key === 'custom' ? '已配置特殊动作' : `包含 ${current.actions} 项基础能力`}</div></div><div class="text-right"><div class="flex flex-wrap justify-end gap-1">${menu.modules.slice(0, 2).map(module => `<span class="rounded bg-white px-1.5 py-1 text-[10px] text-slate-500 ring-1 ring-inset ring-slate-200">${module.name}</span>`).join('')}</div><div class="mt-1 text-[10px] text-slate-400">${menu.modules.length} 个模块</div></div></div>`;
if (expanded && active) html += `<div class="border-t border-blue-100 bg-blue-50/40 px-5 py-3"><div class="mb-2 flex items-center justify-between"><div class="flex items-center gap-2 text-[11px] font-medium text-blue-900">${icon('layers-3', 'h-4 w-4 text-brand')}页面使用的模块</div><span class="text-[10px] text-blue-700/70">IT 已配置能力上限,岗位权限会与之取交集</span></div><div class="grid gap-2 sm:grid-cols-2">${menu.modules.map(module => `<div class="flex items-center justify-between rounded-md border border-blue-100 bg-white px-3 py-2.5"><span class="min-w-0"><span class="block truncate text-[11px] font-medium text-slate-700">${module.name}</span><span class="mono mt-0.5 block text-[9px] text-slate-400">${module.id} · ${module.usage}</span></span>${module.id === 'cw_fee' ? '<span class="ml-2 flex shrink-0 items-center gap-1 text-[10px] text-amber-700"><span class="h-1.5 w-1.5 rounded-full bg-amber-500"></span>共享</span>' : '<span class="ml-2 text-[10px] text-slate-400">随页面生效</span>'}</div>`).join('')}</div>${menu.shared ? `<div class="mt-3 flex items-start gap-2 border-t border-blue-100 pt-3 text-[10px] leading-5 text-blue-800">${icon('link-2', 'mt-0.5 h-3.5 w-3.5 shrink-0 text-brand')}<span>该页面引用了共享模块。修改模块权限时,系统会同步影响使用同一模块的其他页面。</span></div>` : ''}</div>`;
});
document.getElementById('menuList').innerHTML = html || '<div class="px-5 py-12 text-center text-[11px] text-slate-400">没有匹配的业务菜单</div>';
document.querySelectorAll('[data-level]').forEach(el => el.addEventListener('click', () => setLevel(el.dataset.menu, el.dataset.level)));
document.querySelectorAll('[data-select-menu]').forEach(el => el.addEventListener('click', () => { appState.selectedMenu = el.dataset.selectMenu; appState.expanded.add(el.dataset.selectMenu); render(); }));
document.querySelectorAll('[data-expand-menu]').forEach(el => el.addEventListener('click', event => { event.stopPropagation(); const id = el.dataset.expandMenu; appState.expanded.has(id) ? appState.expanded.delete(id) : appState.expanded.add(id); render(); }));
renderDetail(role, menuById(appState.selectedMenu));
}
function renderDetail(role, menu) {
if (!menu) return;
const level = levelByKey(role.levels[menu.id]);
const actionNames = level.key === 'manage' ? ['查看', '新增', '修改', '导出', '审核'] : level.key === 'operate' ? ['查看', '新增', '修改'] : level.key === 'view' ? ['查看'] : level.key === 'custom' ? ['查看', '修改', '导出'] : [];
document.getElementById('detailPanel').innerHTML = `<div class="flex flex-wrap items-start justify-between gap-3 px-5 py-4"><div><div class="flex items-center gap-2"><span class="grid h-7 w-7 place-items-center rounded-md bg-white text-brand ring-1 ring-inset ring-blue-100">${icon(menu.icon, 'h-3.5 w-3.5')}</span><div><div class="text-[12px] font-semibold text-slate-700">${menu.name} · 高级设置</div><div class="mt-0.5 text-[10px] text-slate-400">只有需要特殊收紧时才调整这里</div></div></div></div><button id="detailScopeBtn" class="flex items-center gap-1.5 rounded-md border border-line bg-white px-2.5 py-1.5 text-[10px] text-slate-600 hover:bg-slate-50">${icon('settings-2', 'h-3.5 w-3.5')}配置模块细节</button></div><div class="grid gap-3 border-t border-line px-5 py-4 md:grid-cols-2"><div><div class="mb-2 flex items-center justify-between"><span class="text-[11px] font-medium text-slate-600">本页面动作</span><span class="text-[10px] text-slate-400">${level.label}级</span></div><div class="flex flex-wrap gap-1.5">${actionNames.length ? actionNames.map(name => `<span class="flex items-center gap-1 rounded bg-white px-2 py-1.5 text-[10px] text-slate-600 ring-1 ring-inset ring-slate-200"><span class="h-1.5 w-1.5 rounded-full bg-emerald-500"></span>${name}</span>`).join('') : '<span class="text-[10px] text-slate-400">当前页面未开放操作</span>'}</div></div><div><div class="mb-2 flex items-center justify-between"><span class="text-[11px] font-medium text-slate-600">页面说明</span><span class="text-[10px] text-slate-400">菜单只做入口和能力上限</span></div><div class="rounded-md border border-dashed border-slate-200 bg-white px-3 py-2.5 text-[10px] leading-5 text-slate-500">字段权限和数据范围统一从右侧模块策略进入,不会在每个菜单下重复维护。</div></div></div>`;
document.getElementById('detailScopeBtn').addEventListener('click', () => showToast('已打开模块策略面板(原型)'));
}
function renderPreview() {
const role = roleById(appState.roleId);
const activeMenus = menus.filter(menu => role.levels[menu.id] !== 'none');
const activeModules = new Set();
let actionTotal = 0;
activeMenus.forEach(menu => { menu.modules.forEach(module => activeModules.add(module.id)); actionTotal += levelByKey(role.levels[menu.id]).actions; });
document.getElementById('previewMenus').textContent = activeMenus.length;
document.getElementById('previewModules').textContent = activeModules.size;
document.getElementById('previewActions').textContent = actionTotal;
document.getElementById('previewSensitive').textContent = role.fields.filter(field => field[1] !== '可见').length;
document.getElementById('moduleSummary').innerHTML = [...activeModules].slice(0, 5).map(id => { const module = menus.flatMap(menu => menu.modules).find(module => module.id === id); const sources = menus.filter(menu => role.levels[menu.id] !== 'none' && menu.modules.some(item => item.id === id)).length; return `<div class="flex items-center justify-between gap-2"><span class="flex min-w-0 items-center gap-2"><span class="h-1.5 w-1.5 shrink-0 rounded-full ${sources > 1 ? 'bg-amber-500' : 'bg-tealx'}"></span><span class="truncate text-[11px] text-slate-600">${module.name}</span></span><span class="shrink-0 text-[10px] text-slate-400">${sources > 1 ? `共享 · ${sources}页` : '随页面'}</span></div>`; }).join('') + (activeModules.size > 5 ? `<div class="pt-1 text-[10px] text-brand">还有 ${activeModules.size - 5} 个模块</div>` : '');
document.getElementById('readScope').textContent = role.scopes[0];
document.getElementById('writeScope').textContent = role.scopes[1];
document.getElementById('fieldSummary').innerHTML = role.fields.map(field => `<div class="flex items-center justify-between rounded-md bg-slate-50 px-3 py-2"><span class="text-[11px] text-slate-600">${field[0]}</span><span class="rounded px-1.5 py-1 text-[10px] ${field[1] === '隐藏' ? 'bg-slate-200 text-slate-500' : field[1] === '只读' ? 'bg-amber-50 text-amber-700' : 'bg-emerald-50 text-emerald-700'}">${field[1]}</span></div>`).join('');
}
function renderHeader() {
const role = roleById(appState.roleId);
document.getElementById('currentRoleName').textContent = role.name;
document.getElementById('currentRoleDesc').textContent = role.desc;
document.getElementById('currentRoleMembers').textContent = `${role.members} 名成员`;
document.getElementById('memberCount').textContent = role.members;
document.getElementById('configuredCount').textContent = menus.filter(menu => role.levels[menu.id] !== 'none').length;
document.getElementById('changeCount').textContent = appState.changed;
document.getElementById('roleCount').textContent = roles.length;
}
function render() { renderHeader(); renderRoles(); renderMenus(); renderPreview(); refreshIcons(); }
function setLevel(menuId, level) {
const role = roleById(appState.roleId);
role.levels[menuId] = level;
appState.selectedMenu = menuId;
appState.expanded.add(menuId);
appState.changed += 1;
render();
const menu = menuById(menuId);
if (level === 'custom') showToast(`${menu.name} 已切换为自定义,请检查页面动作`);
else showToast(`${menu.name}:已设置为${levelByKey(level).label}`, 'success');
}
document.getElementById('roleSearch').addEventListener('input', event => { appState.roleSearch = event.target.value; renderRoles(); refreshIcons(); });
document.getElementById('menuSearch').addEventListener('input', event => { appState.menuSearch = event.target.value; renderMenus(); refreshIcons(); });
document.getElementById('expandBtn').addEventListener('click', () => { const all = menus.filter(menu => !appState.menuSearch || `${menu.name}${menu.desc}${menu.group}`.toLowerCase().includes(appState.menuSearch.trim().toLowerCase())).map(menu => menu.id); const open = all.every(id => appState.expanded.has(id)); appState.expanded = new Set(open ? [] : all); renderMenus(); refreshIcons(); });
document.getElementById('saveBtn').addEventListener('click', () => { if (!appState.changed) { showToast('当前没有待保存的变更'); return; } appState.changed = 0; renderHeader(); showToast('岗位权限已保存,模块权限已重新计算', 'success'); });
document.getElementById('resetBtn').addEventListener('click', () => showToast('已恢复为“费用会计”岗位模板(原型)'));
document.getElementById('scopeBtn').addEventListener('click', () => showToast('已打开统一数据范围设置(原型)'));
document.getElementById('previewUserBtn').addEventListener('click', () => showToast('用户预览:张三 · 费用会计'));
document.getElementById('memberBtn').addEventListener('click', () => showToast('成员列表:12 人继承此岗位'));
document.getElementById('exceptionBtn').addEventListener('click', () => showToast('用户例外用于少量特殊人员,建议谨慎使用'));
document.getElementById('addRoleBtn').addEventListener('click', () => showToast('新增岗位流程已打开(原型)'));
render();
</script>
</body>
</html>
+831
View File
@@ -0,0 +1,831 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FMS 权限分配 · 交互原型</title>
<style>
:root{
--bg:#f1f5f9; --card:#fff; --bd:#e2e8f0; --bd2:#cbd5e1;
--tx:#0f172a; --tx2:#64748b; --tx3:#94a3b8;
--pri:#2563eb; --pri-bg:#eff6ff; --pri-bd:#bfdbfe;
--danger:#dc2626; --danger-bg:#fef2f2; --danger-bd:#fecaca;
--warn:#d97706; --warn-bg:#fffbeb; --warn-bd:#fde68a;
--ok:#16a34a; --ok-bg:#f0fdf4;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
font-family:system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;
background:var(--bg); color:var(--tx); font-size:13px; line-height:1.5;
height:100vh; display:flex; flex-direction:column; overflow:hidden;
}
button{font-family:inherit;font-size:inherit;cursor:pointer;border:none;background:none;color:inherit}
select,input{font-family:inherit;font-size:inherit;color:inherit}
/* ---------- header ---------- */
header{
background:var(--card); border-bottom:1px solid var(--bd);
padding:10px 18px; display:flex; align-items:center; gap:16px; flex-wrap:wrap;
box-shadow:0 1px 2px rgba(15,23,42,.04); z-index:10;
}
.logo{font-size:15px;font-weight:650;letter-spacing:-.2px}
.logo span{color:var(--tx3);font-weight:400;margin-left:6px;font-size:12px}
.hgap{flex:1}
.hctl{display:flex;align-items:center;gap:6px}
.hctl label{color:var(--tx2);font-size:12px}
select,input[type=text]{
border:1px solid var(--bd2);border-radius:6px;padding:5px 8px;background:#fff;outline:none;
}
input[type=text]{width:190px}
select:focus,input:focus{border-color:var(--pri)}
.btn{
border:1px solid var(--bd2);border-radius:6px;padding:5px 11px;background:#fff;
font-size:12px;transition:.12s;
}
.btn:hover{border-color:var(--pri);color:var(--pri)}
.btn.pri{background:var(--pri);border-color:var(--pri);color:#fff}
.btn.pri:hover{background:#1d4ed8;color:#fff}
.btn.sm{padding:2px 8px;font-size:11px;border-radius:5px}
.seg{display:flex;border:1px solid var(--bd2);border-radius:6px;overflow:hidden}
.seg button{padding:5px 12px;font-size:12px;background:#fff;border-right:1px solid var(--bd2)}
.seg button:last-child{border-right:none}
.seg button.on{background:var(--pri);color:#fff}
/* ---------- layout ---------- */
main{flex:1;display:flex;gap:14px;padding:14px;overflow:hidden}
.panel{background:var(--card);border:1px solid var(--bd);border-radius:10px;display:flex;flex-direction:column;overflow:hidden}
.panel-h{
padding:9px 14px;border-bottom:1px solid var(--bd);font-weight:600;font-size:13px;
display:flex;align-items:center;gap:8px;background:#fafcff;
}
.panel-h .sub{font-weight:400;color:var(--tx3);font-size:11px}
.panel-b{flex:1;overflow:auto;padding:12px 14px}
#left{flex:1;min-width:0}
#right{width:352px;flex:none}
/* ---------- guide ---------- */
.guide{
display:flex;align-items:center;gap:10px;padding:8px 12px;margin-bottom:10px;
background:var(--pri-bg);border:1px solid var(--pri-bd);border-radius:8px;font-size:12px;
}
.guide .g-txt{flex:1;color:#1e40af}
.guide button{color:var(--pri);font-weight:600;font-size:12px;text-decoration:underline}
/* ---------- tree ---------- */
.folder{margin-bottom:4px}
.folder-h{display:flex;align-items:center;gap:7px;padding:5px 4px;border-radius:6px}
.folder-h:hover{background:#f8fafc}
.folder-n{font-weight:600;font-size:13px}
.folder-c{color:var(--tx3);font-size:11px}
.folder-acts{margin-left:auto;display:flex;gap:5px;opacity:0;transition:.15s}
.folder-h:hover .folder-acts{opacity:1}
.menu{padding-left:16px}
.menu-h{display:flex;align-items:center;gap:7px;padding:5px 4px;border-radius:6px}
.menu-h:hover{background:#f8fafc}
.menu-n{font-size:13px}
.menu-n.off{color:var(--tx3)}
.mtag{
font-size:10px;color:var(--tx2);background:#f1f5f9;border:1px solid var(--bd);
border-radius:4px;padding:0 5px;line-height:15px;
}
.cb{
width:15px;height:15px;border:1.5px solid var(--bd2);border-radius:4px;flex:none;
display:inline-flex;align-items:center;justify-content:center;background:#fff;transition:.12s;
}
.cb.on{background:var(--pri);border-color:var(--pri)}
.cb.on::after{content:"";width:4px;height:7px;border:solid #fff;border-width:0 2px 2px 0;transform:rotate(42deg) translate(-1px,-1px)}
.cb.lock{background:#e2e8f0;border-color:var(--bd2)}
.cb.lock::after{content:"";width:4px;height:7px;border:solid #64748b;border-width:0 2px 2px 0;transform:rotate(42deg) translate(-1px,-1px)}
.row{display:flex;align-items:center;gap:7px;cursor:pointer;user-select:none}
/* ---------- module node ---------- */
.mod{margin:3px 0 3px 32px;border:1px solid var(--bd);border-radius:8px;background:#fff;overflow:hidden}
.mod.summary{background:#fcfdff}
.mod-h{display:flex;align-items:center;gap:7px;padding:6px 10px;cursor:pointer}
.mod-h:hover{background:#f8fafc}
.arrow{color:var(--tx3);font-size:10px;width:9px;flex:none}
.mod-n{font-size:12px;font-weight:600}
.mod-sum{color:var(--tx2);font-size:11px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pill{
font-size:10px;border-radius:4px;padding:1px 6px;line-height:15px;flex:none;
background:#f1f5f9;color:var(--tx2);border:1px solid var(--bd);
}
.pill.share{background:var(--pri-bg);color:#1e40af;border-color:var(--pri-bd)}
.pill.info{background:#f8fafc;color:var(--tx2)}
.mod-b{border-top:1px solid var(--bd);padding:9px 11px 10px;background:#fcfdff}
.grp{margin-bottom:8px}
.grp-t{font-size:10px;color:var(--tx3);font-weight:600;letter-spacing:.4px;margin-bottom:5px}
.acts{display:flex;flex-wrap:wrap;gap:5px}
.act{
display:inline-flex;align-items:center;gap:5px;border:1px solid var(--bd2);border-radius:6px;
padding:3px 8px;font-size:12px;background:#fff;cursor:pointer;transition:.12s;
}
.act:hover{border-color:var(--pri)}
.act.on{background:var(--pri-bg);border-color:var(--pri-bd);color:#1e40af}
.act.on.limited{background:#f1f5f9;border-color:var(--bd);color:var(--tx2)}
.act.risk.on{background:var(--danger-bg);border-color:var(--danger-bd);color:#b91c1c}
.act.auto{cursor:default;background:#f1f5f9;border-color:var(--bd);color:var(--tx2)}
.act .x{font-size:10px;color:var(--tx3)}
.act.limited .x{color:var(--warn)}
.field{margin-top:9px;padding-top:9px;border-top:1px dashed var(--bd)}
.field-t{font-size:11px;color:var(--tx2);margin-bottom:5px;display:flex;align-items:center;gap:6px}
.lv{display:inline-flex;border:1px solid var(--bd2);border-radius:6px;overflow:hidden}
.lv button{padding:2px 9px;font-size:11px;background:#fff;border-right:1px solid var(--bd2)}
.lv button:last-child{border-right:none}
.lv button.on{background:var(--pri);color:#fff}
.chips{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px}
.chip{font-size:10px;background:#f1f5f9;border:1px solid var(--bd);border-radius:4px;padding:1px 6px;color:var(--tx2)}
.chip.hide{background:var(--danger-bg);border-color:var(--danger-bd);color:#b91c1c;text-decoration:line-through}
.chip.ro{background:var(--warn-bg);border-color:var(--warn-bd);color:#b45309}
.scope-row{display:flex;align-items:center;gap:8px;margin-top:9px;padding-top:9px;border-top:1px dashed var(--bd)}
.count{font-size:11px;color:var(--tx2)}
.count b{color:var(--pri);font-size:13px}
.count .tot{color:var(--tx3)}
.note{margin-top:8px;font-size:11px;color:var(--tx2);display:flex;gap:5px;align-items:flex-start}
.note .i{
flex:none;width:13px;height:13px;border-radius:50%;background:var(--pri-bg);color:var(--pri);
font-size:9px;font-weight:700;display:flex;align-items:center;justify-content:center;margin-top:1px;
border:1px solid var(--pri-bd);
}
/* ---------- copy / diff ---------- */
.diff{
margin-bottom:10px;border:1px solid var(--warn-bd);background:var(--warn-bg);
border-radius:8px;padding:10px 12px;font-size:12px;
}
.diff-h{font-weight:600;color:#92400e;margin-bottom:6px;display:flex;align-items:center;gap:8px}
.diff-item{display:flex;align-items:center;gap:8px;padding:3px 0;color:#78350f}
.diff-item .t{flex:1}
.diff-item .s{font-size:11px;color:var(--tx2)}
/* ---------- preview ---------- */
.pv-sec{margin-bottom:14px}
.pv-t{font-size:11px;color:var(--tx3);font-weight:600;letter-spacing:.4px;margin-bottom:6px}
.pv-menus{display:flex;flex-wrap:wrap;gap:5px}
.pv-menu{font-size:11px;background:var(--ok-bg);border:1px solid #bbf7d0;color:#15803d;border-radius:5px;padding:2px 7px}
.pv-empty{font-size:11px;color:var(--tx3);font-style:italic}
.mock{border:1px solid var(--bd);border-radius:8px;overflow:hidden;background:#fff}
.mock-bar{
display:flex;gap:5px;padding:7px 9px;border-bottom:1px solid var(--bd);background:#fafcff;flex-wrap:wrap;
}
.mock-btn{
font-size:11px;border:1px solid var(--bd2);border-radius:5px;padding:2px 9px;background:#fff;color:var(--tx);
}
.mock-btn.danger{border-color:var(--danger-bd);color:var(--danger);background:var(--danger-bg)}
.mock-tbl{width:100%;border-collapse:collapse}
.mock-tbl th{
font-size:10px;font-weight:600;color:var(--tx2);text-align:left;padding:5px 8px;
border-bottom:1px solid var(--bd);border-right:1px solid #f1f5f9;background:#f8fafc;white-space:nowrap;
}
.mock-tbl th.ro{color:var(--warn)}
.mock-tbl td{font-size:10px;padding:5px 8px;border-bottom:1px solid #f1f5f9;color:var(--tx3)}
.mock-foot{padding:5px 9px;font-size:10px;color:var(--tx2);background:#f8fafc;border-top:1px solid var(--bd)}
/* ---------- risk ---------- */
.risk{
display:flex;gap:7px;padding:7px 10px;border-radius:7px;font-size:11px;margin-bottom:6px;align-items:flex-start;
}
.risk.high{background:var(--danger-bg);border:1px solid var(--danger-bd);color:#991b1b}
.risk.mid{background:var(--warn-bg);border:1px solid var(--warn-bd);color:#92400e}
.risk .b{
flex:none;width:14px;height:14px;border-radius:50%;font-size:9px;font-weight:700;
display:flex;align-items:center;justify-content:center;margin-top:1px;
}
.risk.high .b{background:var(--danger);color:#fff}
.risk.mid .b{background:var(--warn);color:#fff}
.risk-ok{font-size:11px;color:var(--tx3);padding:4px 0}
/* ---------- misc ---------- */
.empty{padding:30px 10px;text-align:center;color:var(--tx3);font-size:12px}
.empty b{display:block;color:var(--tx2);font-size:13px;margin-bottom:4px}
.toast{
position:fixed;left:50%;bottom:26px;transform:translateX(-50%);
background:#0f172a;color:#fff;padding:9px 18px;border-radius:8px;font-size:12px;
box-shadow:0 6px 20px rgba(0,0,0,.2);z-index:99;
}
.design{
margin-top:14px;border-top:1px solid var(--bd);padding-top:10px;font-size:11px;color:var(--tx2);
}
.design summary{cursor:pointer;font-weight:600;color:var(--tx);font-size:12px}
.design ol{margin:7px 0 0 16px}
.design li{margin-bottom:4px}
.design code{background:#f1f5f9;padding:0 4px;border-radius:3px;font-size:10px}
</style>
</head>
<body>
<header>
<div class="logo">FMS 权限分配<span>交互原型 v2</span></div>
<div class="hgap"></div>
<div class="hctl">
<label>配置对象</label>
<select id="userSel"></select>
</div>
<div class="hctl">
<label>从他人复制</label>
<select id="copySel"></select>
</div>
<div class="hctl">
<input type="text" id="search" placeholder="搜索菜单或权限,如 审核">
</div>
<div class="seg" id="viewSeg">
<button data-view="menu" class="on">按菜单</button>
<button data-view="module">按模块</button>
</div>
<button class="btn pri" id="saveBtn">保存</button>
</header>
<main>
<section class="panel" id="left">
<div class="panel-h">
权限配置树
<span class="sub">菜单 → 模块 → { 操作 · 敏感信息 · 数据范围 }</span>
</div>
<div class="panel-b" id="treeBox"></div>
</section>
<aside class="panel" id="right">
<div class="panel-h">实时预览 <span class="sub" id="pvWho"></span></div>
<div class="panel-b" id="pvBox"></div>
</aside>
</main>
<script>
/* ==================== 静态元数据(IT 配置) ==================== */
const MENUS = [
{ id:'base', name:'基础资料', folder:true, children:[
{ id:'customer_list', name:'客户管理', modules:['customer'] },
]},
{ id:'fee', name:'费用管理', folder:true, children:[
{ id:'fee_list', name:'费用列表', modules:['cw_fee'] },
{ id:'fee_edit', name:'费用编辑', modules:['cw_fee'] },
]},
{ id:'sea', name:'海运操作', folder:true, children:[
{ id:'sea_detail', name:'海运详细页', modules:['sea_main','container_main','cw_fee'] },
]},
{ id:'rpt', name:'财务报表', folder:true, children:[
{ id:'ar_report', name:'应收报表', modules:['cw_fee'] },
]},
];
const MODULES = {
cw_fee: {
name:'费用模块',
actions:[
{id:'create', name:'新增', group:'data', risk:0},
{id:'update', name:'修改', group:'data', risk:0},
{id:'delete', name:'删除', group:'data', risk:1},
{id:'export', name:'导出', group:'output', risk:0},
{id:'audit', name:'审核', group:'business', risk:0},
{id:'unaudit',name:'反审核', group:'business', risk:1},
],
total:50000, scope:{own:120, department:860, department_tree:2400, all:50000, custom:320},
},
sea_main: {
name:'海运主表模块',
actions:[
{id:'create', name:'新增', group:'data', risk:0},
{id:'update', name:'修改', group:'data', risk:0},
{id:'delete', name:'删除', group:'data', risk:1},
],
total:12000, scope:{own:40, department:300, department_tree:900, all:12000, custom:150},
},
container_main: {
name:'箱量模块',
actions:[
{id:'create', name:'新增', group:'data', risk:0},
{id:'update', name:'修改', group:'data', risk:0},
{id:'delete', name:'删除', group:'data', risk:1},
],
total:30000, scope:{own:60, department:500, department_tree:1500, all:30000, custom:210},
},
customer: {
name:'客户资料模块',
actions:[
{id:'create', name:'新增', group:'data', risk:0},
{id:'update', name:'修改', group:'data', risk:0},
{id:'delete', name:'删除', group:'data', risk:1},
{id:'export', name:'导出', group:'output', risk:0},
],
total:8000, scope:{own:15, department:200, department_tree:600, all:8000, custom:90},
},
};
// 字段敏感性分级:IT 在模块管理里一次性打标
const FIELDS = {
cw_fee:[
{n:'费用单号',s:0},{n:'业务日期',s:0},{n:'客户',s:0},{n:'费用项目',s:0},
{n:'金额',s:1},{n:'成本价',s:2},{n:'内部备注',s:2},{n:'利润',s:3},
],
sea_main:[{n:'提单号',s:0},{n:'船名',s:0},{n:'航线',s:0},{n:'海运费',s:1},{n:'合约价',s:2}],
container_main:[{n:'箱号',s:0},{n:'箱型',s:0},{n:'箱量',s:1},{n:'箱成本',s:2}],
customer:[{n:'客户编码',s:0},{n:'客户名称',s:0},{n:'联系人',s:1},{n:'账期',s:2},{n:'信用额度',s:3}],
};
// 页面能力上限:IT 在 s_menu_module 配置,客户不可改
const PAGE_ALLOW = {
'fee_list:cw_fee' :{create:0,update:0,delete:0,export:1,audit:0,unaudit:0},
'fee_edit:cw_fee' :{create:1,update:1,delete:0,export:0,audit:1,unaudit:1},
'sea_detail:cw_fee' :{create:1,update:1,delete:0,export:1,audit:0,unaudit:0},
'sea_detail:sea_main' :{create:1,update:1,delete:0},
'sea_detail:container_main':{create:1,update:1,delete:1},
'ar_report:cw_fee' :{create:0,update:0,delete:0,export:1,audit:0,unaudit:0},
'customer_list:customer' :{create:1,update:1,delete:1,export:1},
};
const SCOPES = [
{id:'own', name:'本人'},
{id:'department', name:'本部门'},
{id:'department_tree', name:'本部门及下级'},
{id:'custom', name:'指定范围'},
{id:'all', name:'全部数据'},
];
const LEVELS = [
{v:0,name:'公开'},{v:1,name:'内部'},{v:2,name:'敏感'},{v:3,name:'机密'},
];
const GROUPS = {data:'数据操作', output:'输出', business:'业务动作'};
/* ==================== 用户权限数据 ==================== */
function blankPerm(){
const m={};
for(const k in MODULES){ m[k]={actions:{}, sensitivity:1, scope:'department'}; }
return {menus:{}, modules:m};
}
const USERS = {
wangwu:{name:'王五(新员工)', perm:blankPerm()},
zhangsan:{name:'张三(费用会计)', perm:{
menus:{fee_list:1, fee_edit:1},
modules:{
cw_fee:{actions:{create:1,update:1,export:1,audit:1}, sensitivity:1, scope:'own'},
sea_main:{actions:{}, sensitivity:1, scope:'own'},
container_main:{actions:{}, sensitivity:1, scope:'own'},
customer:{actions:{}, sensitivity:1, scope:'own'},
}}},
lisi:{name:'李四(财务经理)', perm:{
menus:{fee_list:1, fee_edit:1, sea_detail:1, ar_report:1, customer_list:1},
modules:{
cw_fee:{actions:{create:1,update:1,delete:1,export:1,audit:1,unaudit:1}, sensitivity:3, scope:'all'},
sea_main:{actions:{create:1,update:1,delete:1}, sensitivity:2, scope:'department_tree'},
container_main:{actions:{create:1,update:1,delete:1}, sensitivity:2, scope:'all'},
customer:{actions:{create:1,update:1,delete:1,export:1}, sensitivity:3, scope:'all'},
}}},
};
/* ==================== 状态 ==================== */
const state = {
userId:'wangwu',
view:'menu',
expanded:null, // 'menuId:moduleId' —— 同时只展开一个
previewPage:'fee_list',
search:'',
copyFrom:null,
toast:'',
};
const P = () => USERS[state.userId].perm;
/* ==================== 计算 ==================== */
function flatMenus(){
const out=[];
MENUS.forEach(f=>f.children.forEach(m=>out.push({...m, folder:f.name})));
return out;
}
function menuById(id){ return flatMenus().find(m=>m.id===id); }
function pagesOfModule(mid){ return flatMenus().filter(m=>m.modules.includes(mid)); }
function activePagesOfModule(mid){ return pagesOfModule(mid).filter(m=>P().menus[m.id]); }
// 「查看」跟随菜单自动开启:只要任一已勾选菜单用到该模块
function autoRead(mid){ return activePagesOfModule(mid).length>0; }
function userHas(mid,aid){ return aid==='read' ? autoRead(mid) : !!P().modules[mid].actions[aid]; }
function pageAllows(menuId,mid,aid){
const a = PAGE_ALLOW[menuId+':'+mid];
if(!a) return true;
return a[aid]!==0;
}
function effective(menuId,mid,aid){
return userHas(mid,aid) && pageAllows(menuId,mid,aid);
}
function visibleFields(mid){
const lv = P().modules[mid].sensitivity;
return FIELDS[mid].map(f=>({...f, hidden:f.s>lv, readonly:f.s===lv && f.s>0}));
}
function scopeCount(mid){
const t = P().modules[mid].scope;
return MODULES[mid].scope[t] ?? 0;
}
/* ==================== 渲染 ==================== */
const esc = s => String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const cb = (on,cls='') => `<span class="cb ${on?'on':''} ${cls}"></span>`;
function render(){
document.getElementById('userSel').innerHTML = Object.keys(USERS)
.map(k=>`<option value="${k}" ${k===state.userId?'selected':''}>${USERS[k].name}</option>`).join('');
document.getElementById('copySel').innerHTML =
`<option value="">选择来源用户…</option>` +
Object.keys(USERS).filter(k=>k!==state.userId)
.map(k=>`<option value="${k}" ${k===state.copyFrom?'selected':''}>${USERS[k].name}</option>`).join('');
document.getElementById('pvWho').textContent = '· ' + USERS[state.userId].name;
const box = document.getElementById('treeBox');
box.innerHTML = renderDiff() + renderGuide() + (state.view==='menu' ? renderMenuTree() : renderModuleView());
document.getElementById('pvBox').innerHTML = renderPreview() + renderRisk() + designNotes();
document.querySelectorAll('#viewSeg button').forEach(b=>
b.classList.toggle('on', b.dataset.view===state.view));
}
/* ---- 引导条 ---- */
function renderGuide(){
const ms = flatMenus().filter(m=>P().menus[m.id]);
if(!ms.length) return '';
const mids = [...new Set(ms.flatMap(m=>m.modules))];
const uncfg = mids.filter(mid=>{
const acts = Object.keys(P().modules[mid].actions).filter(k=>P().modules[mid].actions[k]);
return !autoRead(mid) || (acts.length===0 && P().modules[mid].sensitivity===1 && P().modules[mid].scope==='department');
});
return `<div class="guide">
<span class="g-txt">已选 <b>${ms.length}</b> 个页面,涉及 <b>${mids.length}</b> 个模块${
uncfg.length?`;其中 ${uncfg.length} 个模块尚未调整(当前为只读)`:''}</span>
<button data-act="expandFirst">展开配置操作 →</button>
</div>`;
}
/* ---- 复制 diff ---- */
function diffItems(){
if(!state.copyFrom) return null;
const src = USERS[state.copyFrom].perm, cur = P(), out=[];
flatMenus().forEach(m=>{
const a=!!src.menus[m.id], b=!!cur.menus[m.id];
if(a!==b) out.push({k:'menu', id:m.id, label:m.name, val:a, kind:'menu'});
});
Object.keys(MODULES).forEach(mid=>{
const sa=src.modules[mid], ca=cur.modules[mid];
MODULES[mid].actions.forEach(a=>{
const x=!!sa.actions[a.id], y=!!ca.actions[a.id];
if(x!==y) out.push({k:mid+':'+a.id, id:mid, aid:a.id, label:`${MODULES[mid].name} · ${a.name}`, val:x, kind:'act'});
});
if(sa.sensitivity!==ca.sensitivity)
out.push({k:mid+':lv', id:mid, label:`${MODULES[mid].name} · 敏感等级`, val:sa.sensitivity, kind:'lv'});
if(sa.scope!==ca.scope)
out.push({k:mid+':sc', id:mid, label:`${MODULES[mid].name} · 数据范围`, val:sa.scope, kind:'sc'});
});
return out;
}
function applyDiff(it){
const src = USERS[state.copyFrom].perm, cur = P();
if(it.kind==='menu'){ it.val ? cur.menus[it.id]=1 : delete cur.menus[it.id]; }
else if(it.kind==='act'){ it.val ? cur.modules[it.id].actions[it.aid]=1 : delete cur.modules[it.id].actions[it.aid]; }
else if(it.kind==='lv'){ cur.modules[it.id].sensitivity = it.val; }
else if(it.kind==='sc'){ cur.modules[it.id].scope = it.val; }
}
function renderDiff(){
const items = diffItems();
if(!items) return '';
if(!items.length) return `<div class="diff"><div class="diff-h">与 ${USERS[state.copyFrom].name} 无差异</div>
<button class="btn sm" data-act="closeCopy" style="margin-top:4px">关闭</button></div>`;
const show = v => {
if(typeof v==='boolean') return v?'开启':'关闭';
return String(v);
};
return `<div class="diff">
<div class="diff-h">与 ${USERS[state.copyFrom].name} 的差异 ${items.length} 项
<button class="btn sm" data-act="applyAll">全部应用</button>
<button class="btn sm" data-act="closeCopy">关闭</button>
</div>
${items.slice(0,14).map(it=>`<div class="diff-item">
<span class="t">${esc(it.label)}</span>
<span class="s">→ ${esc(show(it.val))}</span>
<button class="btn sm" data-act="applyOne" data-k="${esc(it.k)}">应用</button>
</div>`).join('')}
${items.length>14?`<div class="diff-item"><span class="t">… 另有 ${items.length-14} 项</span></div>`:''}
</div>`;
}
/* ---- 菜单视图 ---- */
function renderMenuTree(){
const kw = state.search.trim();
let html='';
MENUS.forEach(f=>{
const kids = f.children.filter(m=>matchMenu(m,kw));
if(kw && !kids.length) return;
const on = kids.filter(m=>P().menus[m.id]).length;
html += `<div class="folder">
<div class="folder-h">
<span class="folder-n">${esc(f.name)}</span>
<span class="folder-c">${on}/${kids.length}</span>
<span class="folder-acts">
<button class="btn sm" data-act="folderAll" data-f="${f.id}" data-v="1">全选</button>
<button class="btn sm" data-act="folderAll" data-f="${f.id}" data-v="0">清空</button>
</span>
</div>
<div class="menu">${kids.map(renderMenu).join('')}</div>
</div>`;
});
if(!html) html = `<div class="empty"><b>没有匹配的菜单</b>试试「审核」「删除」「费用」</div>`;
return html;
}
function matchMenu(m,kw){
if(!kw) return true;
if(m.name.includes(kw)) return true;
return m.modules.some(mid=>MODULES[mid].actions.some(a=>a.name.includes(kw)));
}
function renderMenu(m){
const on = !!P().menus[m.id];
const mods = m.modules.map(mid=>{
const key = m.id+':'+mid;
const open = state.expanded===key;
return open ? renderModuleOpen(m,mid) : renderModuleSummary(m,mid);
}).join('');
return `<div>
<div class="menu-h">
<div class="row" data-act="toggleMenu" data-id="${m.id}">
${cb(on)}<span class="menu-n ${on?'':'off'}">${esc(m.name)}</span>
</div>
${on?`<span class="mtag">${m.modules.length} 个模块</span>`:''}
</div>
${on?mods:''}
</div>`;
}
function renderModuleSummary(m,mid){
const M = MODULES[mid];
const acts = M.actions.filter(a=>effective(m.id,mid,a.id)).map(a=>a.name);
const list = (autoRead(mid)?['查看']:[]).concat(acts);
const n = activePagesOfModule(mid).length;
const shared = pagesOfModule(mid).length>1;
return `<div class="mod summary">
<div class="mod-h" data-act="expand" data-k="${m.id}:${mid}">
<span class="arrow">▸</span>
<span class="mod-n">${esc(M.name)}</span>
<span class="mod-sum">${list.length?list.join(' · '):'仅只读(未配置)'}</span>
${shared?`<span class="pill share">同一模块</span>`:''}
<span class="pill info">生效于 ${n} 页</span>
</div>
</div>`;
}
function renderModuleOpen(m,mid){
const M = MODULES[mid], pm = P().modules[mid];
const groups = {};
const head = `<div class="act auto">${cb(autoRead(mid),'lock')} 查看<span class="x">跟随菜单</span></div>`;
M.actions.forEach(a=>{
const on = userHas(mid,a.id);
const limited = on && !pageAllows(m.id,mid,a.id);
const cls = ['act', on?'on':'', limited?'limited':'', a.risk?'risk':''].join(' ');
(groups[a.group] ||= []).push(
`<div class="${cls}" data-act="toggleAct" data-m="${mid}" data-a="${a.id}">
${cb(on)}${esc(a.name)}${limited?`<span class="x">本页不提供</span>`:''}
</div>`);
});
let body='';
['data','output','business'].forEach(g=>{
if(!groups[g]) return;
body += `<div class="grp"><div class="grp-t">${GROUPS[g]}</div>
<div class="acts">${g==='data'?head:''}${groups[g].join('')}</div></div>`;
});
// 敏感信息
const vf = visibleFields(mid);
const hid = vf.filter(f=>f.hidden).map(f=>f.n);
const ro = vf.filter(f=>f.readonly).map(f=>f.n);
body += `<div class="field">
<div class="field-t">敏感信息可见等级
<span class="lv">${LEVELS.map(l=>`<button class="${pm.sensitivity===l.v?'on':''}"
data-act="setLv" data-m="${mid}" data-v="${l.v}">${l.name}</button>`).join('')}</span>
</div>
<div class="chips">
${vf.filter(f=>!f.hidden&&!f.readonly).map(f=>`<span class="chip">${esc(f.n)}</span>`).join('')}
${ro.map(n=>`<span class="chip ro">${esc(n)} 只读</span>`).join('')}
${hid.map(n=>`<span class="chip hide">${esc(n)}</span>`).join('')}
</div>
</div>`;
// 数据范围
const cnt = scopeCount(mid);
body += `<div class="scope-row">
<span class="field-t" style="margin:0">数据范围</span>
<select data-act="setScope" data-m="${mid}">
${SCOPES.map(s=>`<option value="${s.id}" ${pm.scope===s.id?'selected':''}>${s.name}</option>`).join('')}
</select>
<span class="count">可见 <b>${cnt.toLocaleString()}</b> <span class="tot">/ ${M.total.toLocaleString()} 条</span></span>
</div>`;
const pages = activePagesOfModule(mid).map(p=>p.name);
body += `<div class="note"><span class="i">i</span><span>以上配置属于「${esc(M.name)}」,同时生效于:${
pages.length?pages.join('、'):'(当前无已勾选页面)'}。在其他页面的同一模块下修改,此处同步变化。</span></div>`;
return `<div class="mod">
<div class="mod-h" data-act="collapse">
<span class="arrow">▾</span>
<span class="mod-n">${esc(M.name)}</span>
<span class="pill share">同一模块</span>
<span class="pill info">生效于 ${pages.length} 页</span>
</div>
<div class="mod-b">${body}</div>
</div>`;
}
/* ---- 模块视图(审查) ---- */
function renderModuleView(){
let html='';
Object.keys(MODULES).forEach(mid=>{
const M=MODULES[mid], pm=P().modules[mid];
const pages = activePagesOfModule(mid);
const acts = M.actions.filter(a=>userHas(mid,a.id)).map(a=>a.name);
const cnt = scopeCount(mid);
html += `<div class="mod" style="margin-left:0;margin-bottom:8px">
<div class="mod-h">
<span class="arrow">▾</span>
<span class="mod-n">${esc(M.name)}</span>
<span class="mod-sum">${(autoRead(mid)?['查看']:[]).concat(acts).join(' · ')||'只读'}</span>
<span class="pill info">影响 ${pages.length} 页</span>
</div>
<div class="mod-b">
<div class="acts" style="margin-bottom:8px">
<div class="act auto">${cb(autoRead(mid),'lock')} 查看<span class="x">跟随菜单</span></div>
${M.actions.map(a=>{
const on=userHas(mid,a.id);
return `<div class="act ${on?'on':''} ${a.risk?'risk':''}" data-act="toggleAct" data-m="${mid}" data-a="${a.id}">${cb(on)}${esc(a.name)}</div>`;
}).join('')}
</div>
<div class="scope-row" style="border-top:none;padding-top:0;margin-top:0">
<span class="field-t" style="margin:0">敏感等级</span>
<span class="lv">${LEVELS.map(l=>`<button class="${pm.sensitivity===l.v?'on':''}"
data-act="setLv" data-m="${mid}" data-v="${l.v}">${l.name}</button>`).join('')}</span>
<span class="field-t" style="margin:0 0 0 8px">范围</span>
<select data-act="setScope" data-m="${mid}">
${SCOPES.map(s=>`<option value="${s.id}" ${pm.scope===s.id?'selected':''}>${s.name}</option>`).join('')}
</select>
<span class="count">可见 <b>${cnt.toLocaleString()}</b> <span class="tot">/ ${M.total.toLocaleString()} 条</span></span>
</div>
<div class="note"><span class="i">i</span><span>用于:${
pages.length?pages.map(p=>p.name).join('、'):'(无已勾选页面,配置暂不生效)'}</span></div>
</div>
</div>`;
});
return html;
}
/* ---- 预览 ---- */
function renderPreview(){
const menus = flatMenus().filter(m=>P().menus[m.id]);
const page = menuById(state.previewPage) || menus[0];
let html = `<div class="pv-sec">
<div class="pv-t">可见菜单</div>
<div class="pv-menus">${
menus.length ? menus.map(m=>`<span class="pv-menu">${esc(m.name)}</span>`).join('')
: '<span class="pv-empty">暂未勾选任何菜单</span>'}
</div></div>`;
if(!page){ return html + `<div class="empty"><b>勾选左侧菜单后即可预览</b></div>`; }
if(!P().menus[page.id]) return html + `<div class="empty"><b>${esc(page.name)} 未授权</b>该用户看不到这个页面</div>`;
const mid = page.modules[0];
const acts = ['read', ...MODULES[mid].actions.map(a=>a.id)].filter(a=>effective(page.id,mid,a.id));
const btns = acts.filter(a=>a!=='read').map(a=>{
const d = MODULES[mid].actions.find(x=>x.id===a);
return `<span class="mock-btn ${d&&d.risk?'danger':''}">${d?d.name:a}</span>`;
});
const vf = visibleFields(mid);
const cols = vf.filter(f=>!f.hidden);
return html + `<div class="pv-sec">
<div class="pv-t">页面预览
<select id="pvPage" style="margin-left:4px">${
menus.map(m=>`<option value="${m.id}" ${m.id===page.id?'selected':''}>${esc(m.name)}</option>`).join('')}
</select>
</div>
<div class="mock">
<div class="mock-bar">
<span class="mock-btn">查询</span>
${btns.length?btns.join(''):'<span class="pv-empty">无可用操作</span>'}
</div>
<table class="mock-tbl">
<tr>${cols.map(c=>`<th class="${c.readonly?'ro':''}">${esc(c.n)}${c.readonly?' <span style="font-weight:400">只读</span>':''}</th>`).join('')}</tr>
<tr>${cols.map(()=>`<td>…</td>`).join('')}</tr>
<tr>${cols.map(()=>`<td>…</td>`).join('')}</tr>
</table>
<div class="mock-foot">可见 ${scopeCount(mid).toLocaleString()} 条 · 已隐藏 ${
vf.filter(f=>f.hidden).length} 个字段 · 依据模块「${esc(MODULES[mid].name)}」</div>
</div></div>`;
}
/* ---- 风险 ---- */
function renderRisk(){
const out=[];
Object.keys(MODULES).forEach(mid=>{
const pm=P().modules[mid], M=MODULES[mid];
if(!autoRead(mid)) return;
const has = id=>!!pm.actions[id];
if(has('delete')) out.push(['high', `${M.name}:拥有删除权限,可删除数据`]);
if(has('create') && has('audit')) out.push(['mid', `${M.name}:同时拥有录入与审核,不符合职责分离`]);
if(has('unaudit')) out.push(['mid', `${M.name}:拥有反审核权限`]);
if(pm.scope==='all') out.push(['mid', `${M.name}:数据范围为全部,可见 ${M.total.toLocaleString()} 条`]);
if(pm.sensitivity>=3) out.push(['mid', `${M.name}:可见全部机密字段(如 ${FIELDS[mid].filter(f=>f.s>=3).map(f=>f.n).join('、')||'敏感字段'})`]);
});
if(!out.length) return `<div class="pv-sec"><div class="pv-t">风险提示</div>
<div class="risk-ok">当前配置无高风险项</div></div>`;
return `<div class="pv-sec"><div class="pv-t">风险提示</div>
${out.map(([lv,t])=>`<div class="risk ${lv}"><span class="b">!</span><span>${esc(t)}</span></div>`).join('')}</div>`;
}
/* ---- 设计说明 ---- */
function designNotes(){
return `<details class="design">
<summary>这个原型在解决什么</summary>
<ol>
<li><b>一棵树完成全部配置</b>:菜单 → 模块 → {操作 / 敏感信息 / 数据范围},不分成多个区域。</li>
<li><b>模块是共享节点</b>:同一模块在多个菜单下出现,是同一份数据。在任意处修改,其他处摘要同步变化 —— 用行为揭示「操作是模块级」这一事实,不靠文字警告。</li>
<li><b>常驻「生效于 N 页」</b>:消除「我只在当前页面勾了」的误解。</li>
<li><b>查看跟随菜单</b>:避免「进得去却看不到数据」这一高频配置坑,置灰不可取消。</li>
<li><b>页面能力上限与用户权限分离</b>:客户配「用户有什么」,IT 配「页面最多允许什么」(<code>s_menu_module.b_allow_*</code>),最终取交集,界面上体现为「本页不提供」。</li>
<li><b>可见 N / M 条</b>:把抽象的数据范围变成可感知的数字,配完立刻知道多了还是少了。</li>
<li><b>字段敏感性分级</b>:把 <code>用户×模块×字段</code> 的笛卡尔积降维成「字段打标 + 用户一个等级」,新增敏感字段时自动受控,不会默认放开。</li>
<li><b>从他人复制 + diff</b>:顺应客户「照着李四抄」的真实行为,且差异可见、可逐项应用。</li>
</ol>
</details>`;
}
/* ==================== 事件 ==================== */
function toast(msg){
state.toast = msg;
let el = document.getElementById('toast');
if(!el){ el=document.createElement('div'); el.id='toast'; el.className='toast'; document.body.appendChild(el); }
el.textContent = msg;
clearTimeout(el._t);
el._t = setTimeout(()=>{ el.remove(); }, 1800);
}
document.getElementById('userSel').addEventListener('change', e=>{
state.userId = e.target.value; state.expanded=null; state.copyFrom=null;
const first = flatMenus().find(m=>P().menus[m.id]);
if(first) state.previewPage = first.id;
render();
});
document.getElementById('copySel').addEventListener('change', e=>{
state.copyFrom = e.target.value || null; render();
});
document.getElementById('search').addEventListener('input', e=>{
state.search = e.target.value; render();
});
document.getElementById('viewSeg').addEventListener('click', e=>{
const b = e.target.closest('button'); if(!b) return;
state.view = b.dataset.view; render();
});
document.getElementById('saveBtn').addEventListener('click', ()=>{
toast('已保存 ' + USERS[state.userId].name + ' 的权限配置');
});
document.getElementById('treeBox').addEventListener('click', e=>{
const el = e.target.closest('[data-act]'); if(!el) return;
const act = el.dataset.act;
if(act==='toggleMenu'){
const id = el.dataset.id;
P().menus[id] ? delete P().menus[id] : P().menus[id]=1;
if(P().menus[id]) state.previewPage = id;
render();
}
else if(act==='folderAll'){
const f = MENUS.find(x=>x.id===el.dataset.f);
f.children.forEach(m=>{ el.dataset.v==='1' ? P().menus[m.id]=1 : delete P().menus[m.id]; });
render();
}
else if(act==='expand'){ state.expanded = el.dataset.k; render(); }
else if(act==='collapse'){ state.expanded = null; render(); }
else if(act==='expandFirst'){
const m = flatMenus().find(x=>P().menus[x.id]);
if(m) state.expanded = m.id + ':' + m.modules[0];
render();
}
else if(act==='toggleAct'){
const {m:mid, a:aid} = el.dataset;
const acts = P().modules[mid].actions;
acts[aid] ? delete acts[aid] : acts[aid]=1;
render();
}
else if(act==='setLv'){ P().modules[el.dataset.m].sensitivity = +el.dataset.v; render(); }
else if(act==='applyAll'){
diffItems().forEach(applyDiff); render(); toast('已应用全部差异');
}
else if(act==='applyOne'){
const it = diffItems().find(x=>x.k===el.dataset.k);
if(it){ applyDiff(it); render(); }
}
else if(act==='closeCopy'){
state.copyFrom=null; document.getElementById('copySel').value=''; render();
}
});
document.getElementById('treeBox').addEventListener('change', e=>{
const el = e.target.closest('[data-act="setScope"]'); if(!el) return;
P().modules[el.dataset.m].scope = el.value; render();
});
document.getElementById('pvBox').addEventListener('change', e=>{
if(e.target.id==='pvPage'){ state.previewPage = e.target.value; render(); }
});
render();
</script>
</body>
</html>
@@ -113,24 +113,12 @@
## 1. 表结构
> 权限相关表的完整 DDL、索引与业务层约束统一维护在《FMS新系统核心表结构设计.md》「8. 权限」;用户个性化偏好表见同文档「4. 用户个性化」。本章只保留设计说明与计算规则。
### 1.1 s_menu_module(菜单-模块关系表)
菜单页面与业务模块是多对多关系。菜单记录既表示导航入口,也表示对应的逻辑页面;Vue 路由和公共组件只作为技术实现,不参与权限标识。
```sql
create table dbo.s_menu_module (
b_menu_id varchar(100) not null,
b_module_id varchar(50) not null,
b_main bit not null default 0,
b_xh int not null default 0,
b_canuse tinyint not null default 1,
primary key (b_menu_code, b_module_id)
);
create index IX_s_menu_module_module
on dbo.s_menu_module (b_module_id, b_canuse, b_menu_code);
```
业务层约束:
- `b_menu_code`、`b_module_id` 必须引用已启用的菜单和模块;
@@ -141,114 +129,19 @@ create index IX_s_menu_module_module
### 1.2 s_power(权限定义表)
```sql
create table dbo.s_power (
b_id varchar(100) not null, -- menu.cw_fee_list.access / module.cw_fee.access / action.cw_fee.audit
b_name nvarchar(200) not null,
b_i18n varchar(150) null,
b_power_type varchar(20) not null, -- menu/module/action
b_owner_type varchar(20) not null, -- menu / module
b_owner_id varchar(50) not null, -- 菜单或模块编码
b_capability varchar(30) null, -- action 的操作或动作编码;menu/module 为 access
b_operation varchar(30) null, -- 兼容列:access/execute,由 b_power_type 派生,后续可移除
b_canuse tinyint not null default 1,
b_xh int not null default 0,
constraint PK_s_power primary key (b_id)
);
create index IX_s_power_owner
on dbo.s_power (b_power_type, b_owner_type, b_owner_id, b_capability, b_canuse);
```
业务层约束(数据库不建 check 约束,由业务层校验):
- `b_id` 由 `类型.对象[.能力]` 规则生成(见「权限编码规范」),**生成后不可编辑**,变更时删除重建;
- `b_power_type` 只能取 `menu / module / action`;
- `menu` 类型编码为 `menu.<菜单编码>.access`,`b_owner_type = 'menu'`,`b_capability = 'access'`,`b_operation = 'access'`;
- `module` 类型编码为 `module.<模块编码>.access`,`b_owner_type = 'module'`,`b_capability = 'access'`,`b_operation = 'access'`;
- `action` 类型 `b_owner_type` 固定为 `module`,`b_owner_id` 和 `b_capability` 必填,`b_operation` 固定为 `execute`;
- **动作归属模块、不归属菜单**:同一个模块动作只配置一次;页面是否允许使用该动作由 `s_menu_module.b_allow_*` 限制;
- `b_id` 与 `(b_power_type, b_owner_type, b_owner_id, b_capability)` 一一对应,新建前先按组合查重。
> 与现有库(FMS-NEW)的差异:现有 `s_power` 已采用 `b_id varchar(50)` 编码主键,并含 `b_resource`、`b_module_id`、`b_operation` 列。迁移时把 `b_id` 放宽到 `varchar(100)`,补 `b_owner_type` / `b_owner_id` / `b_capability` 三列;兼容列可保留,但新代码统一按上述权限编码和归属规则读写。
定义见《FMS新系统核心表结构设计.md》「8. 权限」。
### 1.3 s_user_power(用户权限授权表)
```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,即权限编码:menu.cw_fee / action.cw_fee.audit
b_canuse tinyint not null default 1,
b_updatedatetime datetime2 null,
constraint PK_s_user_power primary key (b_user_id, b_power_id)
);
create index IX_s_user_power_user
on dbo.s_user_power (b_user_id, b_canuse, b_power_id);
```
业务层约束:
- `b_power_id` 存权限**编码**而非内部 ID,可直接阅读与按前缀查询,判断时无需 join `s_power`;
- 统一采用默认拒绝策略:只保存 `b_canuse = 1` 的授权记录,没有记录即无权限;
- 权限点本身 `b_canuse = 0` 时,所有用户的该权限一律不生效。
定义见《FMS新系统核心表结构设计.md》「8. 权限」。
### 1.4 s_user_field_permission(用户字段访问策略表)
```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(50) not null,
b_access_mode varchar(10) not null, -- hidden / view / edit
b_allow_query tinyint not null default 0,
b_allow_export tinyint not null default 0,
b_canuse tinyint not null default 1,
b_updatedatetime datetime2 null,
constraint PK_s_user_field_permission
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);
```
业务层约束(数据库不建 check 约束,由业务层校验):
- `b_access_mode` 只能取 `hidden / view / edit`;
- `b_access_mode = 'hidden'` 时,`b_allow_query` 和 `b_allow_export` 必须同时为 `0`;
- 没有 `b_canuse = 1` 的有效记录时,该字段按模块字段默认配置执行,不视为 `hidden`。
定义见《FMS新系统核心表结构设计.md》「8. 权限」。
### 1.5 s_user_data_scope(用户数据范围授权表)
```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 = 'default' 时允许使用
-- 值域与 action.<模块编码>.<操作> 的 capability 一致,用于把「操作」和「该操作的行范围」串起来
b_scope_level varchar(10) not null default 'default',
-- default / override;default 使用 b_operation='*',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,
b_updatedatetime datetime2 null,
primary key (b_user_id, b_module_id, b_operation, b_scope_level, b_scope_no)
);
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);
```
定义见《FMS新系统核心表结构设计.md》「8. 权限」。
数据范围计算规则:
@@ -276,39 +169,11 @@ create index IX_s_user_data_scope_module
### 1.6 s_user_field_pref(非权限:用户列表布局偏好表)
```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,
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 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);
```
定义见《FMS新系统核心表结构设计.md》「4. 用户个性化」。
### 1.7 s_user_query_field_pref(非权限:用户查询布局偏好表)
```sql
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,
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);
```
定义见《FMS新系统核心表结构设计.md》「4. 用户个性化」。
用户个性化规则: