diff --git a/code/fms/.tmp-doc.js b/code/fms/.tmp-doc.js new file mode 100644 index 00000000..49d21b0d --- /dev/null +++ b/code/fms/.tmp-doc.js @@ -0,0 +1,10 @@ +const fs = require('fs'); +const file = 'd:/workspace/code/fms/FMS权限设计.md'; +const content = fs.readFileSync(file, 'utf8'); + +const oldPoints = '- 第一列是**模块树**,缩进 + 展开 + 父子半选\n- `page` 节点只配入口,权限列置灰(不直接对应数据表)\n- 动作全在一列,全部默认拒绝,勾选即授权;业务动作与基础操作 chip 同样式\n- **字段是模块行的展开子行**,且只显示**敏感字段 + 字段组**,默认折叠\n- 数据范围是一列,跟着模块走("仅本人"与"禁止删除"是同频操作,不该分开放)'; +const newPoints = '- **顶部用户条**横排选人 + 角色模板 + 批量多选\n- **左:模块树**只负责导航选择,节点带配置数 badge;树顶有「全部模块」概览入口\n- **右:权限面板** 4 个分区(入口 / 动作 / 数据范围 / 敏感字段),只配置当前选中模块\n- 面板内的字段**只显示 IT 标记的敏感字段**\n- 数据范围是面板内一段,不是独立分区(与「增删改」同频)'; + +if (content.indexOf(oldPoints) < 0) { console.log('STILL NOT FOUND'); process.exit(1) } +fs.writeFileSync(file, content.replace(oldPoints, newPoints), 'utf8'); +console.log('OK'); \ No newline at end of file diff --git a/code/fms/.tmp-dom.html b/code/fms/.tmp-dom.html new file mode 100644 index 00000000..5cc2a10a --- /dev/null +++ b/code/fms/.tmp-dom.html @@ -0,0 +1,30 @@ + + + + +FMS 权限中心 · 一屏授权 + + + + + +
F
FMS 权限中心
一客户一库 · 权限点自动生成
IT
系统管理员
客户库:DEMO-FWD
授权对象

费用管理

cw_fee

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

全部模块概览

+

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

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

{{ selName }}

+

{{ state.selected }}

+
+ page 节点 + {{ curUser.name }} · 全部默认拒绝,勾选即授权 +
+ + +
+
入口
+ + {{ hasEntry(state.selected) ? '已开通' : '未开通' }} + {{ entryCode }} +
+ + +
+
动作
+
+ +
+
+
页面节点只配入口,具体操作在下属模块上配置
+ + +
+
数据范围
+ + 未配置视为全部数据 +
+ + +
+
敏感字段
+
+
该模块没有标记敏感字段,无需配置字段权限
+
+
+ {{ f.l }}{{ f.n }} + + + + 已收紧 + 默认 +
+
+
+
+
diff --git a/code/fms/.tmp-replace.js b/code/fms/.tmp-replace.js new file mode 100644 index 00000000..141c692e --- /dev/null +++ b/code/fms/.tmp-replace.js @@ -0,0 +1,14 @@ +const fs = require('fs'); +const file = 'd:/workspace/code/fms/demo.html'; +const panelFile = 'd:/workspace/code/fms/.tmp-panel.html'; +const lines = fs.readFileSync(file, 'utf8').split('\n'); +const panel = fs.readFileSync(panelFile, 'utf8').replace(/\n$/, '').split('\n'); + +// 替换第 108..211 行(1-based)为新的权限面板;保留第 212 行的
+const before = lines.slice(0, 107); +const after = lines.slice(211); +console.log('第108行原内容:', JSON.stringify(lines[107])); +console.log('第211行原内容:', JSON.stringify(lines[210])); +console.log('第212行原内容:', JSON.stringify(lines[211])); +fs.writeFileSync(file, before.concat(panel).concat(after).join('\n'), 'utf8'); +console.log('done, total lines:', before.length + panel.length + after.length); diff --git a/code/fms/.tmp-shot.png b/code/fms/.tmp-shot.png new file mode 100644 index 00000000..67f91853 Binary files /dev/null and b/code/fms/.tmp-shot.png differ diff --git a/code/fms/.tmp-smoke.js b/code/fms/.tmp-smoke.js new file mode 100644 index 00000000..262f04cc --- /dev/null +++ b/code/fms/.tmp-smoke.js @@ -0,0 +1,23 @@ +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const exe = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'; +const shot = process.argv[2] === 'shot'; +let out = ''; +try { + out = execFileSync(exe, ['--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars', + '--window-size=1680,1400', '--virtual-time-budget=12000', + shot ? '--screenshot=d:/workspace/code/fms/.tmp-shot.png' : '--dump-dom', + 'file:///d:/workspace/code/fms/demo.html'], { maxBuffer: 64 * 1024 * 1024 }).toString(); +} catch (e) { out = String(e.stdout || ''); console.log('ERR', String(e.stderr || '').slice(0, 400)) } +if (shot) { console.log('shot ok'); process.exit(0) } +fs.writeFileSync('d:/workspace/code/fms/.tmp-dom.html', out, 'utf8'); +console.log(JSON.stringify({ + len: out.length, + vueWarn: out.indexOf('[Vue warn]') > -1, + hasTreeAside: out.indexOf('模块树') > -1, + hasPanel: out.indexOf('敏感字段') > -1, + hasUserBar: out.indexOf('授权对象') > -1, + hasOverview: out.indexOf('全部模块(概览)') > -1, + hasOldTable: out.indexOf('min-w-[1000px]') > -1, + hasError: out.indexOf('Uncaught') > -1 +}, null, 2)); diff --git a/code/fms/FMS新系统核心表结构设计.md b/code/fms/FMS新系统核心表结构设计.md index beb96e6a..6d5d41fa 100644 --- a/code/fms/FMS新系统核心表结构设计.md +++ b/code/fms/FMS新系统核心表结构设计.md @@ -3,6 +3,16 @@ > 目标结构基于 2026-09-04 只读拉取的 `FMS-NEW` 数据库整理。 > 平台配置使用字符串业务键;未来业务数据表使用 `bigint` 雪花 ID。 +## 0. SQL 驱动与动态配置 + +本系统是 SQL 驱动的内部 ERP。能通过 SQL 完成的查询、关联、过滤、排序、分页、聚合、权限过滤和数据范围过滤,优先在 SQL 层完成,不在业务代码中加载大量数据后再处理。 + +- 模块、字段、查询条件、排序、字段权限和数据范围等规则,优先从本文件定义的配置表读取,由通用查询逻辑动态生成 SQL; +- 动态 SQL 的表名、字段名和操作符只能来自受控的模块元数据、字段定义和白名单,业务值使用参数传递; +- 配置驱动的动态 SQL 不等于保存任意 SQL 字符串。不得把未审查的前端字符串或整段 SQL 直接保存后执行; +- 固定 SQL 仅用于稳定且明确的特殊业务逻辑,且应保持通用,不为单个页面或单个用户复制一套查询接口; +- 前端权限控制只负责界面展示,模块操作、字段访问和数据范围必须在后端查询或写入 SQL 中落实;用户个性化只能调整布局,不能放宽权限。 + ## 1. 用户表 ```sql @@ -345,11 +355,13 @@ create index ix_s_user_data_scope_module 权限计算约定: -- 默认拒绝:用户没有有效授权记录时不具备该权限; -- 用户存在有效 `s_user_power` 记录时获得该权限;停用的权限点或授权记录不生效; -- 字段没有有效策略记录时为 `hidden`;`view` 表示可见只读,`edit` 表示可见可编辑;`s_field_view` / `s_field_edit` 等元数据只能收紧权限; +- 入口权限默认拒绝:`menu/access`、`page/access`、`report/access` 和 `action/execute` 必须存在有效的 `s_user_power` 授权记录;停用的权限点或授权记录不生效; +- 模块操作权限默认放行:`module/read|create|update|delete|export` 没有用户授权记录时视为允许;`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` 限制数据行; +- 模块数据操作权限与数据范围同时生效:先判断操作权限,再按 `s_user_data_scope` 限制数据行;数据范围没有配置时视为 `all`; +- 数据范围优先使用当前操作的 `override`,没有 `override` 时使用 `operation = '*'` 的 `default`;同一层级的多条范围按 OR 合并; +- `read`、`export`、`update`、`delete` 在 SQL 行条件中应用数据范围;`create` 在写入前校验新增数据是否符合数据范围; - 菜单/页面/报表访问权限只控制入口和访问,不能替代模块、字段或数据范围权限。 ## 9. 多语言 diff --git a/code/fms/FMS权限设计.md b/code/fms/FMS权限设计.md new file mode 100644 index 00000000..8ad205f2 --- /dev/null +++ b/code/fms/FMS权限设计.md @@ -0,0 +1,337 @@ +# FMS 权限设计 + +> 本文是权限部分的**定稿设计**,以《FMS新系统核心表结构设计.md》的表结构为基础,替代《字段权限与用户个性化设计.md》中与本文冲突的表述(差异见第 8 节)。 +> 用户个性化(`s_user_field_pref`、`s_user_query_field_pref`)不在本文范围内,后续单独处理。 + +## 0. 设计定位 + +### 0.1 系统特征(决定设计取向的前提) + +| 特征 | 现状 | 对权限设计的影响 | +| --- | --- | --- | +| 部署方式 | **一客户一数据库** | 权限点只需在本库内唯一,编码不带租户前缀;不存在跨租户数据串户风险 | +| 架构 | 元数据驱动(`s_module` / `s_field` / `s_field_view` …) | 权限点必须**自动生成**,不能手工维护清单 | +| 客户可定制 | 可加模块、加字段 | 权限点需要**增量同步**机制 | +| 客户规模 | 中小货代,多数无专职 IT | 模型必须简单到客户管理员能自行配置 | +| 用户规模 | 几十 ~ 几百 | 不需要 SAP 级别的授权对象模型 | + +### 0.2 结论:对应用友 / 金蝶的三层模型 + +**认知模型对标用友 / 金蝶**(功能权限 / 数据权限 / 字段权限 + 角色),理由不是技术最优,而是**客户和实施顾问无需学习成本**,且规模匹配。 + +**不采用**: + +- SAP 授权对象模型(为跨国集团设计,规模不匹配,交付成本高) +- Salesforce 四层共享模型(Profile + Permission Set + OWD + Sharing,计算复杂) +- 通用权限框架 / ABAC / 运行时策略引擎(明确不做) + +**借鉴两点**: + +- 权限点自动生成(Salesforce 思路,利用本系统元数据驱动的优势) +- 数据范围支持选择多个组织节点(CargoWise 的 Branch 思路,覆盖"只看上海 + 深圳分公司") + +--- + +## 1. 权限模型 + +### 1.1 四类权限 + +| 类别 | 回答的问题 | 载体 | 默认策略 | +| --- | --- | --- | --- | +| **入口** | 能不能进 | `menu` / `page` / `report` | 默认拒绝 | +| **动作** | 能不能做 | `module..` | 全部默认拒绝(权限就是权限) | +| **字段** | 能看/改哪些列 | `s_user_field_permission` | 跟随模块默认,只收紧 | +| **数据范围** | 能看哪些行 | `s_user_data_scope` | 未配置视为 `all` | + +### 1.2 入口 ≠ 动作 + +- **入口**是一维的:能不能打开(菜单可见、页面可访问)。勾了"能进费用管理"**不代表**能改数据。 +- **动作**是多维的:对某个模块能做什么。 + +两者互不继承,符合《核心表结构》"菜单权限不自动授予模块权限"的约定。 + +### 1.3 权限就是权限 + +不区分「内置动作」与「业务动作」。查看 / 新增 / 修改 / 删除 / 导出、审核 / 核销 / 关闭 / 作废等都是同一种**动作权限**,只有「勾选」与「未勾选」两种状态。 + +| 状态 | 含义 | +| --- | --- | +| 勾选 | 有 `s_user_power` 授权记录(`b_canuse = 1`) | +| 未勾选 | 无授权记录 = 拒绝 | + +**全部动作统一默认拒绝**。简化点: + +- 一种存储:只存「授权记录」,没有「禁止记录」 +- 一种默认策略:未勾选 = 拒绝 +- 一种行为:勾选即授权 + +> 客户不能新建动作权限点——业务动作需要对应的后端代码,客户建了也无效。客户界面只提供勾选。 + +### 1.4 角色(可选层) + +采用**主角色 + 个人例外**两层,不做角色继承角色: + +``` +用户 → 主角色(继承,只有一个)+ 个人例外(覆盖角色中的少数项) +``` + +- 新员工入职 = 选一个角色 +- 个别人特殊 = 加几条例外 +- 改角色 = 该角色所有人跟着变(与 SAP / 用友行为一致) +- 查询只有两层,不是无限递归 + +角色不是强制项:不引入角色也能用(直接给用户配),但引入后配置量大幅下降。产品内置「货代标准权限包」(操作员 / 客服 / 费用会计 / 财务经理 / 只读查询),实施时导入客户库后微调。 + +--- + +## 2. 权限点编码规范 + +| 类型 | 编码 | 示例 | +| --- | --- | --- | +| 菜单入口 | `menu..access` | `menu.cw_fee.access` | +| 页面入口 | `page..access` | `page.sea_detail.access` | +| 报表入口 | `report..access` | `report.cw_fee_summary.access` | +| 内置动作 | `module..` | `module.cw_fee.delete` | +| 业务动作 | `module..` | `module.cw_fee.audit` | + +规则: + +- 全部小写,点分分隔;`` 用 `s_module.b_id` +- **一客户一库,编码在本库内唯一即可**,不加租户前缀 +- 权限点由系统生成,**不允许手工新增业务动作** +- `s_power.b_code` 是稳定业务键;`s_power.b_id` 是内部主键,业务代码只依赖 `b_code` + +### 与旧模型的差异 + +| 项 | 旧(`b_user_power` / `s_module_power`) | 本文 | +| --- | --- | --- | +| 权限点标识 | `${module_id}.${雪花ID}`,跨环境 ID 不同,配置无法迁移 | 语义化 `b_code`,可跨环境迁移 | +| 动作编码 | `action.module.xx.yy.execute` | `module.xx.yy` | + +> 现有 `stores/permissions.js` 依赖旧表(`b_user_power` / `s_module_power`),迁移到新模型后需同步改造(见第 6 节)。 + +--- + +## 3. 权限点的自动生成与升级同步 + +### 3.1 生成规则 + +| 来源 | 生成内容 | +| --- | --- | +| `s_module` 模块 | `menu..access` + 5 个内置动作 | +| `s_module` 中 `b_module_type = 'page'` 的节点 | `page..access`(仅"需要独立入口控制"的页面) | +| 报表定义 | `report..access` | +| 开发注册的业务动作 | `module..` | + +### 3.2 增量同步三原则 + +产品升级或客户新增模块后需要补权限点,同步逻辑必须幂等: + +1. **只增不删** —— 不删除已有权限点(删了会丢客户配置) +2. **不覆盖已有配置** —— 客户主动停用的 `b_canuse = 0` 不被重置 +3. **废弃置 `b_canuse = 0`** —— 不物理删除,保留历史可追溯 + +### 3.3 客户自定义字段的权限 + +客户新增字段(如"拖车费")默认跟随模块默认配置,即**全员可见可编辑**。 + +防泄漏建议(低优先级,可后置): + +- 新增字段时,字段名命中 `cost / price / amount / profit / 成本 / 价` 等关键词 → 提示是否标记为敏感字段 +- 提供「敏感字段体检」:列出金额类型但未标记敏感的字段供确认 + +--- + +## 4. 权限计算顺序 + +一次页面访问或接口调用按顺序计算: + +1. 用户账号与对应资源是否启用 +2. **入口**:菜单 / 页面 / 报表是否有有效授权(默认拒绝) +3. **动作**:当前动作是否被允许 + - 内置动作:无记录即放行;`b_canuse = 0` 明确禁止 + - 业务动作:必须有有效授权记录 +4. **字段**:按模块字段元数据 + 用户字段策略裁剪(只收紧) +5. **数据范围**:`override` → `default` → `all`,同层多条 OR 合并,生成 SQL 行条件 +6. 角色层:用户例外覆盖角色配置 + +> 页面显示与接口执行必须用同一套规则。前端可提前隐藏,但**最终以后端 SQL 为准**(《开发规范》第 10、23 条)。 + +--- + +## 5. 后端落实 + +### 5.1 统一拦截点 + +通用数据接口是唯一的 SQL 出入口,权限在此单点落实: + +| 接口 | 对应动作 | 需落实 | +| --- | --- | --- | +| `/data/loaddata` | read | 动作校验 + 数据范围 WHERE + 字段裁剪 | +| `/data/page` | read | 同上 | +| `/data/saveobjt` | create / update / delete | 动作校验 + 字段 edit 校验 + **目标行必须落在数据范围内** | +| `/data/loaddatabysql` | — | ⚠️ 见 5.2 | +| `/data/nextid` `/nextcode` `/describe` | — | 无需 | + +流程: + +``` +解析 table → 用 s_module.b_viewtable / b_savetable 反查 module_id + → 确定 action(loaddata/page → read;saveobjt 按 inserts/updates/deletes 分别定) + → ① 动作校验 + → ② 数据范围拼入 WHERE + → ③ SELECT 列剔除 hidden 字段 + → 执行 +``` + +权限结果按 `(user_id, module_id, action)` 缓存,避免每次查询都查权限表。 + +### 5.2 ⚠️ `/data/loaddatabysql` 必须收紧 + +该接口接受任意 SQL,**会完全绕过字段裁剪和数据范围**。不处理则第 4 节所有规则形同虚设。 + +处理方案(择一,建议第一种): + +1. **仅限管理员 / 内部账号可用**(推荐) +2. 改为只接受白名单视图名,不接受 SQL 片段 +3. 解析 SQL 后注入条件(复杂且易出漏洞,不推荐) + +> 这与《开发规范》第 8 条「动态配置不等于任意 SQL」一致。 + +### 5.3 表名 → 模块 反查 + +`loaddata` / `page` 的参数只带表名,需经 `s_module.b_viewtable` / `b_savetable` 反查 `module_id`。 + +**待确认**:是否存在多个模块共用同一视图?若存在,表名 → 模块的映射不唯一,需要前端额外传 `moduleId` 参数。 + +--- + +## 6. 前端落实 + +### 6.1 `stores/permissions.js` 改造 + +现有骨架(并发加载 + 主体去重 + 管理员提权)保留,替换数据源与判断逻辑: + +| 现状 | 改造后 | +| --- | --- | +| 拉 `s_module` / `b_user_module` / `b_user_power` / `s_module_power` | 拉 `s_module` / `s_power` / `s_user_power` / `s_user_field_permission` / `s_user_data_scope` | +| `powerSet` 存 `${module_id}.${雪花ID}` | 存语义化 `b_code` | +| `canPower(scopeCode, powerCode)`(零调用) | `can(code)` 直接传 `b_code` | + +对外暴露: + +```js +can('module.cw_fee.delete') // 动作判断 +fieldPerm(moduleId, field) // → { mode, query, export } +scope(moduleId, action) // 数据范围 +``` + +### 6.2 三个接线点 + +1. **按钮级**:提供 `v-permission` 指令 + `usePermission()`,替换掉零调用的 `canPower` +2. **字段裁剪**:`FmsModuleTable.buildColumns()` 之后过滤 —— `hidden` 移除列、`view` 置只读、`export = 0` 剔除导出列 +3. **菜单过滤**:`stores/menus.js` 目前只按 `g3soft` 管理员裁剪,**未使用授权数据**,需改为按入口权限过滤 + +> 前端裁剪只是展示层,**不可信**。真正的过滤在后端 SQL。 + +--- + +## 7. 配置界面设计 + +### 7.1 核心原则 + +- **IT 定义"能分配什么",客户决定"分给谁"**。IT 不碰人,客户不碰元数据。 +- **一个页面配完**,不分区切换、不重复选模块。 +- 默认展示**已配置项**,而非全量清单(规则是"没配 = 放行",全量铺开只会增加噪音)。 + +### 7.2 树的主体是模块树,不是菜单树 + +`s_module` 本身是树(`b_parent_id` + `b_module_type`),`page` 类型节点天然承载"页面包含哪些模块": + +``` +海运详细页(page 节点,只配入口) +├── 海运主表(module) +├── 装箱主表(module) +└── 费用管理(module) +``` + +菜单 `s_menu` 只是指向模块的指针(`s_menu.b_module_id`)。**不用菜单树做权限骨架**: + +- 菜单不拥有字段 / 动作 / 数据范围,挂上去语义错误 +- 一个模块可能被多个菜单引用 → 同一模块重复出现、配置冲突 +- 与《核心表结构》"权限以业务模块为核心"矛盾 + +菜单入口权限 = 模块树上的「入口」列。只有"一个模块被多个菜单引用且需分别控制"时,才启用 `page..access` 独立入口。 + +### 7.3 主界面形态(一屏配完一个人) + +``` +模块树 入口 │ 动作(默认拒绝,勾选即授权) │ 数据范围 +────────────────────────────────────────────────────────────────────────────── +▾ 海运详细页[page] ☑ │ - - - - - │ - │ - + ▾ 海运主表 ☑ │ ☑ ☑ ☐ ☐ ☐ │ ☑审核 ☐关闭 │ 仅本人▾ + └ 字段(敏感) 金额=只读 · 内部成本=隐藏 + ▾ 装箱主表 ☑ │ ☑ ☐ ☐ ☐ ☐ │ - │ 本部门▾ + ▾ 费用管理 ☑ │ ☑ ☑ ☑ ☐ ☑ │ ☑审核 ☐反审核 │ 本部门▾ + └ 字段(敏感) 金额=只读 · 内部成本=隐藏 · 毛利=隐藏 +▾ 发票管理 ☑ │ ☑ ☑ ☑ ☐ ☑ │ ☑作废 │ 本部门▾ + 客户档案 ☐ │ ☑ ☐ ☐ ☐ ☐ │ - │ 本部门▾ +``` + +要点: + +- 第一列是**模块树**,缩进 + 展开 + 父子半选 +- `page` 节点只配入口,权限列置灰(不直接对应数据表) +- 动作全在一列,全部默认拒绝,勾选即授权;业务动作与基础操作 chip 同样式 +- **字段是模块行的展开子行**,且只显示**敏感字段 + 字段组**,默认折叠 +- 数据范围是一列,跟着模块走("仅本人"与"禁止删除"是同频操作,不该分开放) + +### 7.4 批量 + +- 左侧用户列表支持多选 → 底部出现「应用角色模板」「批量设置数据范围」「清空」 +- 表格支持整列操作(如"所有模块禁止删除") + +### 7.5 IT 专属入口(客户不可见) + +| 内容 | 频率 | +| --- | --- | +| 模块 / 字段元数据 + 敏感标记 | 低 | +| 字段分组(`s_field_group`) | 低 | +| 业务动作注册(需写代码) | 低 | +| 权限点清单 + 重新生成 | 低 | +| 标准权限包维护 | 低 | + +### 7.6 权限诊断(必做) + +- **以某用户身份预览** +- **反查**:选中一个权限点 / 字段 → 列出谁有 → 批量调整 +- **查看生效 SQL**:排查"为什么这个人看不到这条数据" + +--- + +## 8. 与既有文档的差异(决策变更) + +| # | 原表述 | 本文 | 原因 | +| --- | --- | --- | --- | +| 1 | 动作编码 `action.module...execute` | `module..` | CRUD 与业务动作本质是同一概念,统一编码 | +| 2 | 权限直接授予用户,**不引入角色继承** | 可选「主角色 + 个人例外」两层 | 无角色时配置量是 `人 × 模块 × 字段` 的笛卡尔积 | +| 3 | 权限点类型含 `page` / `report`,与 `module` 并列 | 入口与动作分离;动作统一挂模块 | 入口是"能不能进",动作是"能不能做" | +| 4 | 数据范围 `custom` 填单个值 | 支持选择多个组织节点 | "只看上海 + 深圳分公司" | +| 5 | 未说明树的主语 | **模块树**是主体,菜单只是指针 | 权限以模块为核心 | +| 6 | 未涉及 | 权限点自动生成 + 增量同步三原则 | 一客户一库 + 客户可定制 | +| 7 | 动作分内置 / 业务两种,默认策略不同 | 不区分,统一为「动作」,**全部默认拒绝** | 简化模型:只存授权记录,没有禁止记录 | + +> 建议:将第 1、2 条同步修订到《FMS新系统核心表结构设计.md》第 276 行及权限章节,避免两份文档冲突。 + +--- + +## 9. 实施顺序 + +| 阶段 | 内容 | 产出 | +| --- | --- | --- | +| P1 | 建 `s_power` / `s_user_power`(统一编码)+ 权限点自动生成 + `/data/*` 拦截只做动作校验 | 能控制"谁能进哪个模块、能做什么" | +| P2 | 字段裁剪 + 数据范围 WHERE;**收紧 `/data/loaddatabysql`** | 后端强制完整 | +| P3 | 前端 `permissions.js` 改造 + `v-permission` + 菜单过滤 + 表格字段裁剪 | 界面生效 | +| P4 | 配置界面(模块树 + 一屏配置)+ 标准权限包 + 权限诊断 | 好用 | + +P1 与 P2 是同一个拦截层的两批规则,不会返工。**不建议先做 P4**——模型未定,界面做了会返工。 diff --git a/code/fms/demo.html b/code/fms/demo.html index d5d85d87..bdb0151c 100644 --- a/code/fms/demo.html +++ b/code/fms/demo.html @@ -2,38 +2,856 @@ - -ERP 查询构建器 Demo + +FMS 权限中心 · 一屏授权 + + - -
-
费用列表
费用单与账单、发票、收付款状态统一查询
-
当前查询
-
费用数据
已应用 4 个条件 · 共 18 条
☐费用单号 客户 费用类型 金额 状态 创建时间 操作
□FY20260906001上海东方物流有限公司海运费¥12,800.00待审核2026-09-06审核详情
□FY20260906002深圳华南供应链港杂费¥8,620.00待审核2026-09-05审核详情
□FY20260906003宁波海联国际拖车费¥4,200.00已审核2026-09-05详情
□FY20260906004上海东方物流有限公司报关费¥16,300.00待审核2026-09-04审核详情
+ +
+ + +
+
+
+ F +
+
FMS 权限中心
+
一客户一库 · 权限点自动生成
+
+
+
+ +
+
+ IT +
+
系统管理员
+
客户库:DEMO-FWD
+
+
+
+
+ + +
+ + +
+
+ 授权对象 + +
+ + +
+
+
+ +
+ + + + + +
+ + +
+
+

全部模块概览

+

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

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

{{ selName }}

+

{{ state.selected }}

+
+ page 节点 + {{ curUser.name }} · 全部默认拒绝,勾选即授权 +
+ + +
+
入口
+ + {{ hasEntry(state.selected) ? '已开通' : '未开通' }} + {{ entryCode }} +
+ + +
+
动作
+
+ +
+
+
页面节点只配入口,具体操作在下属模块上配置
+ + +
+
数据范围
+ + 未配置视为全部数据 +
+ + +
+
敏感字段
+
+
该模块没有标记敏感字段,无需配置字段权限
+
+
+ {{ f.l }}{{ f.n }} + + + + 已收紧 + 默认 +
+
+
+
+
+
+ + + +
+
+ + +
+
+
IT ADMINISTRATION
+

资源配置(客户不可见)

+

IT 定义「能分配什么」。客户只做分配,不碰元数据,也不能新建业务动作(动作需要后端代码)。

+
+
模块 / 页面
{{ nodeCount }}
+
业务动作
{{ actionDefCount }}
+
敏感字段
{{ sensitiveCount }}
+
权限点(自动生成)
{{ powerCodes.length }}
+
+
+ +
+ +
+
+

模块与敏感字段标记

+

只有标为敏感的字段才进入客户授权界面,其余字段不占配置量

+
+
+ + + + + + + + + + + +
模块字段敏感
+
+
+ + +
+
+
+

权限点清单

+

随元数据自动生成,只增不删、不覆盖已有配置

+
+
+ +
+
+
+ + + + + + + + + + + + + + + +
权限点编码名称默认
{{ p.code }}{{ p.name }}拒绝
+
+
+ 业务动作(如 module.cw_fee.audit)由开发注册代码后生成,客户界面只能勾选、不能新建。 +
+
+
+
+ + + +
+
+ 已选 {{ state.picked.length }} 人 + + + +
+
+ 有 {{ diffList.length }} 项未保存变更 + + + +
+
+ + +
+
+
+
+
保存前确认
+
共 {{ diffList.length }} 项变更,保存后立即生效
+
+ +
+
+ + + + + + + + + + + + + + + + + + + +
用户类别对象变更前变更后
{{ d.user }}{{ d.type }}{{ d.target }}{{ d.from }}{{ d.to }}
+
+
+ + +
+
+
+ +
{{ toastMsg }}
+ + + + +
-
高级查询当前列表列是快捷字段,也可以查询未展示的关联业务字段
普通列条件与关联单据条件会组合成同一个查询
快捷字段 · 点击即可添加
-
- diff --git a/code/fms/fms-vue/src/components/ui/button/index.scss b/code/fms/fms-vue/src/components/ui/button/index.scss index 3639b3e8..3a5dd48a 100644 --- a/code/fms/fms-vue/src/components/ui/button/index.scss +++ b/code/fms/fms-vue/src/components/ui/button/index.scss @@ -110,14 +110,17 @@ } /* 加载态 —— spinner 在前,隐藏自带图标 */ -.button-loading { - cursor: default; -} - .button-loading :deep(svg):not(.button-spinner) { display: none; } +/* 加载态虽因防重复提交而原生 disabled,但视觉是“进行中”而非“禁用”: + 保持原配色(不降透明度、悬停不变色),光标用纯加载样式 */ +.button-loading:disabled { + opacity: 1; + cursor: wait; +} + :deep(.button-spinner) { width: 1em; height: 1em; diff --git a/code/fms/fms-vue/src/views/dashboard/index.vue b/code/fms/fms-vue/src/views/dashboard/index.vue index 9f8f6f50..55fdd496 100644 --- a/code/fms/fms-vue/src/views/dashboard/index.vue +++ b/code/fms/fms-vue/src/views/dashboard/index.vue @@ -243,15 +243,12 @@ const searchForm = reactive(makeEmptyForm()) const queryPrefs = reactive(Object.fromEntries(SEARCH_FIELD_DEFS.map((field) => [field.key, { area: field.area || 'advanced', order: field.order || 999 }]))) const QUICK_VISIBLE_LIMIT = 4 const quickSearchExpanded = ref(false) -const drawerOpen = ref(false) -const drawerTab = ref('conditions') const queryApplied = ref(false) -const activeQueryMode = ref('quick') -const queryMode = ref('quick') +const activeQueryMode = ref('quick') // 'quick' 搜索卡 / 'advanced' 左侧常驻高级面板 +const settingsOpen = ref(false) // 查询设置右侧抽屉 const advancedLogic = ref('and') const advancedNodes = reactive([]) const advancedGroups = reactive([]) -const advancedSnapshot = ref(null) const draggingFieldKey = ref('') const dragOverFieldKey = ref('') const allAdvancedNodes = computed(() => advancedNodes.concat(advancedGroups.flatMap((group) => group.nodes))) @@ -282,13 +279,8 @@ const extraQuickConditionCount = computed(() => extraQuickSearchFields.value.fil if (field.type === 'numrange') return searchForm[`${field.key}Min`] || searchForm[`${field.key}Max`] return searchForm[field.key] !== '' && searchForm[field.key] !== null && searchForm[field.key] !== undefined }).length) -// 常用区已填条件总数:高级模式的说明条要用 —— 告诉用户这些值还在,只是不参与 -const quickConditionCount = computed(() => quickSearchFields.value.filter((field) => { - if (field.type === 'numrange') return searchForm[`${field.key}Min`] || searchForm[`${field.key}Max`] - return searchForm[field.key] !== '' && searchForm[field.key] !== null && searchForm[field.key] !== undefined -}).length) -// 高级模式下常用区强制收起:保留上下文(知道有哪几个常用条件),但不占版面 -const showAllQuickFields = computed(() => quickSearchExpanded.value && activeQueryMode.value === 'quick') +// 高级模式下面板即状态呈现,不再需要「常用条件暂不参与」的说明 +const showAllQuickFields = computed(() => quickSearchExpanded.value) const advancedSearchFields = computed(() => searchFields.value.filter((field) => field.queryable !== false && queryPrefs[field.key]?.area !== 'hidden'), ) @@ -379,30 +371,11 @@ function removeSummaryItem(item) { } if (!querySummary.value.length) queryApplied.value = false } -function openQueryDrawer(tab = 'conditions') { - if (tab === 'conditions' && !drawerOpen.value) { - advancedSnapshot.value = { - logic: advancedLogic.value, - nodes: JSON.parse(JSON.stringify(advancedNodes)), - groups: JSON.parse(JSON.stringify(advancedGroups)), - } - } - drawerTab.value = tab - drawerOpen.value = true +function openSettings() { + settingsOpen.value = true } -function closeQueryDrawer() { - if (advancedSnapshot.value) { - advancedLogic.value = advancedSnapshot.value.logic - advancedNodes.splice(0, advancedNodes.length, ...advancedSnapshot.value.nodes) - advancedGroups.splice(0, advancedGroups.length, ...advancedSnapshot.value.groups) - advancedSnapshot.value = null - queryMode.value = activeQueryMode.value - } - drawerOpen.value = false -} -function selectQueryMode(mode) { - queryMode.value = mode - if (mode === 'advanced') openQueryDrawer('conditions') +function closeSettings() { + settingsOpen.value = false } function setFieldArea(field, area) { if (field.queryable === false) return @@ -443,6 +416,11 @@ function resetQuickQuery() { Object.assign(searchForm, makeEmptyForm()) if (activeQueryMode.value === 'quick') queryApplied.value = false } +function resetAllQueries() { + resetQuickQuery() + resetAdvancedQuery() + queryApplied.value = false +} function startFieldDrag(field, event) { draggingFieldKey.value = field.key // Firefox 下不写 dataTransfer 不会触发 drop,同时声明「移动」语义拿到正确的光标 @@ -476,28 +454,52 @@ function dropField(field, area) { } function onSearch() { activeQueryMode.value = 'quick' - queryMode.value = 'quick' queryApplied.value = true } -function applyAdvancedQuery() { +// 进入高级模式:滑出左侧常驻面板(无遮罩),搜索卡隐藏、表格占满剩余宽高 +function enterAdvancedMode() { activeQueryMode.value = 'advanced' - queryMode.value = 'advanced' - queryApplied.value = true - advancedSnapshot.value = null - drawerOpen.value = false -} -function resetAllQueries() { - resetQuickQuery() - resetAdvancedQuery() - queryApplied.value = false - advancedSnapshot.value = null } +// 回到简单模式:收起面板、恢复搜索卡 function returnToQuickQuery() { - if (drawerOpen.value && drawerTab.value === 'conditions') closeQueryDrawer() activeQueryMode.value = 'quick' - queryMode.value = 'quick' - queryApplied.value = false } +// 面板内应用:编辑即暂存,应用只固化生效态 +function applyAdvancedQuery() { + queryApplied.value = true +} + +/* ============================================================ + * 布局原型扩展(模拟验证用,正式页面删除): + * pageLayout = 'flat' 纯列表 | 'tree' 树+表格。 + * 验证「左栏槽位」约定:树(快速模式)与高级面板(高级模式)轮班占同一列, + * 表格宽度开销恒等于一列;树选中条件以 chip 呈现,两种模式都可见可清除。 + * ============================================================ */ +const pageLayout = ref('flat') +const TREE_NODES = [ + { id: 'all', label: '全部分类', children: [ + { id: 'c1', label: '华东区', children: [ + { id: 'c11', label: '上海' }, + { id: 'c12', label: '杭州' }, + ] }, + { id: 'c2', label: '华南区', children: [ + { id: 'c21', label: '广州' }, + { id: 'c22', label: '深圳' }, + ] }, + { id: 'c3', label: '华北区' }, + ] }, +] +function findTreeLabel(nodes, id) { + for (const node of nodes) { + if (node.id === id) return node.label + const childLabel = findTreeLabel(node.children || [], id) + if (childLabel) return childLabel + } + return '' +} +// 树选中条件(页面固定条件):chips 条数据源之一,两种模式都渲染 +const selectedTreeNodeId = ref('') +const selectedTreeNodeLabel = computed(() => findTreeLabel(TREE_NODES, selectedTreeNodeId.value) || '') /* ---------- fms-table 大数据量 + 单行编辑 demo ---------- */ const TYPE_OPTIONS = [ @@ -614,7 +616,7 @@ const selectedText = computed(() => ? t('demo.statusbar.selected').replace('{n}', selectedRowKeys.value.length) : '', ) -const totalText = computed(() => t('demo.footer.total').replace('{n}', allRows.value.length)) +const totalText = computed(() => t('demo.footer.total').replace('{n}', filteredRows.value.length)) function onRefresh() { allRows.value = makeRows(DEFAULT_ROW_COUNT) @@ -625,25 +627,138 @@ function onMoreAction({ key }) { // 打印 / 密度 / 全屏:占位,后续接入 void key } +/* 树选中过滤表格行(模拟;正式页面由 SQL 条件承担) */ +const filteredRows = computed(() => { + if (!selectedTreeNodeLabel.value || selectedTreeNodeLabel.value === '全部分类') return allRows.value + return allRows.value.filter((row) => String(row.name).length % 2 === Number(selectedTreeNodeId.value.slice(-1)) % 2) +}) +/* 左栏是否有内容占位:树(快速模式)或高级面板(高级模式) */ +const leftColumnOccupied = computed(() => pageLayout.value === 'tree' || activeQueryMode.value === 'advanced') +/* 树扁平化渲染(模拟;正式页面用 ui/tree 组件) */ +function flattenTree(nodes, depth = 0) { + const result = [] + for (const node of nodes) { + result.push({ id: node.id, label: node.label, depth }) + if (node.children?.length) result.push(...flattenTree(node.children, depth + 1)) + } + return result +}