20260920220520

This commit is contained in:
oneao committed 2026-09-20 22:05:20 +08:00
1 parent 6f03d92425
commit b3364fe6dc
44 files changed
+2313 -2737

No files matched your search

@@ -0,0 +1,229 @@
# 分组编码手填(新建分组弹窗)实现方案
## Context
**问题**:模块管理页(/module/module-management)界面配置设计器里的分组节点 id(形如 `group1`)由前端自动取号,用户既看不见也无法指定。这个 id 同时是分组在配置内的稳定身份、运行时归组的键、以及多语言资源键 `group.{模块编码}.{schema类型}.{id}` 的派生源。将来页面代码要按分组引用时(例如把同一主表的多个分组拆到不同 tab,参见 g3hd 旧系统 `detail.vue` 里 `groupName="提单"` 的写法),技术人员不知道哪个分组对应哪个 id。
**目标**:新建分组时**必须手填编码**,创建后**不可修改**;弹窗同时可填分组名称。
**已确认的四条决策**
1. 新建分组必填编码,不再自动取号;创建后无编辑入口。
2. 弹窗字段 = 分组编码 + 分组名称;名称填了直接完成,留空则关闭后仍进入现有的「创建后立即内联命名」。
3. 编码格式与模块编码同构:`/^[a-z][a-z0-9_]*$/`,长度 2-30。
4. 校验必须含**同配置内唯一性**(已有分组 id + 从多语言表反解的保留编号)。
**为什么唯一性校验是硬要求**:`normalizeGroupIds`([schemaJson.js](file:///d:/workspace/code/fms/fms-vue/src/utils/schemaJson.js#L126-L138))对重复 id 的处理是**静默改号**(首次出现保留、重复的重新发号,已有单测固化 `group1, group1, basic` → `group1, group2, basic`)。漏过校验时用户填的编码会被悄悄替换,其多语言键随之漂移;设计器内部还全程用 `b_id` 当 Map 键([useStructureEditing.js](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/components/design-editor/common/useStructureEditing.js#L37-L58)),重复会导致分组互相覆盖、字段渲染两遍。
**明确不做**:不给分组加 tab/area 建模(含子表的 tab 是页面编排单元,不是分组分区);不新增任何「编辑编码」入口。
---
## 改动清单
### 1. 校验纯函数 — [schemaJson.js](file:///d:/workspace/code/fms/fms-vue/src/utils/schemaJson.js)
在文件末尾新增「分组编码校验」区块:
```js
/** 编码长度上限:键 = group.(模块编码≤50).(view|edit).(编码) ≤ 93 < s_i18n.b_key varchar(150) */
export const GROUP_CODE_MAX_LENGTH = 30
const GROUP_CODE_RE = /^[a-z][a-z0-9_]*$/
/** 结构树用 'g:ungrouped' 做虚拟「未分组」节点 key,真实分组不能叫这个名字 */
const RESERVED_GROUP_CODES = new Set(['ungrouped'])
/**
* 分组编码校验,契约与 ui Form 的 validator 一致:true 通过 / 非空字符串为错误文案。
* @param {unknown} rawCode
* @param {{ existingIds?: Iterable<string>, reservedIds?: Iterable<string>, maxLength?: number }} [opts]
* @returns {true|string}
*/
export function validateGroupCode(rawCode, { existingIds = [], reservedIds = [], maxLength = GROUP_CODE_MAX_LENGTH } = {})
```
校验顺序与文案:
| # | 条件 | 返回 |
|---|---|---|
| 1 | trim 后为空 | `分组编码不能为空` |
| 2 | 长度 < 2 或 > maxLength | `分组编码长度需为 2-${maxLength} 个字符` |
| 3 | 不匹配 `GROUP_CODE_RE` | `分组编码须以小写字母开头,且只能包含小写字母、数字和下划线` |
| 4 | 命中 `RESERVED_GROUP_CODES` | `分组编码 ${code} 为系统保留字,请换一个` |
| 5 | 命中 existingIds | `分组编码 ${code} 已被本配置的其他分组使用,请换一个` |
| 6 | 命中 reservedIds | `分组编码 ${code} 曾被使用(多语言资源中仍留有记录),请换一个` |
实现要点:
- 复用文件内已有的私有 `textOf`;入参用 `new Set([...ids].map(textOf).filter(Boolean))` 归一,**同时兼容 Array 与 Set**。
- 大小写敏感比较,与 `normalizeGroupIds` 的 `claimed` 集合语义一致。
- **不传 moduleCode / schemaType**:长度上限是静态常量(推算见上方注释);view/edit 编号本就独立,传 schemaType 会引入"跨 schema 比较"的错觉,调用方只传当前 schema 的那一套。
第 4 条(保留字 `ungrouped`)略超出已确认范围,但必须加:真实分组若叫 `ungrouped`,其树 key `g:ungrouped` 会与虚拟「未分组」节点正面冲突([TableDesignEditor.vue](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/components/design-editor/TableDesignEditor.vue#L237-L240) 的删除判断会直接忽略它)。
`nextGroupId`(96-105 行)**保留导出**:移除两处调用后它只剩单测引用,但它是老数据 `group{n}` 编号规则的唯一可执行说明,且删除需连带删除 `schema-json.spec.js` 里的 4 条断言,不值得。
### 2. 新增共用弹窗 — `components/design-editor/common/GroupCreateModal.vue`
与 `FieldLibrary.vue` / `EditDataView.vue` 同目录(被两个设计器共用,满足「全局/共用样式至少两处使用」的项目约定)。
```
props: open(Boolean) / parentLabel(String) / existingIds(Array) / reservedIds(Array) / loading(Boolean)
emits: update:open / confirm({ id, title }) // 均已 trim
```
- 标题:`parentLabel` 非空 → 「新增子分组」并显示父分组名;否则「新增分组」。
- 校验用 `Form` + `FormItem` 的 `rules` + `validator`,validator 直接返回 `validateGroupCode(...)` 的结果 —— [validate.js](file:///d:/workspace/code/fms/fms-vue/src/components/ui/form/validate.js#L92-L106) 明确「返回非空字符串即错误消息」,与 [ModuleBasicPanel.vue](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/ModuleBasicPanel.vue#L36-L56) 的写法同构,无需手写 `validateStatus/help`。`rules` 用 `computed` 包一层,两集合变化时自动重算,输入即报「编码已被使用」。
- `onOk()`:`await formRef.value.validateAll()`([form.vue](file:///d:/workspace/code/fms/fms-vue/src/components/ui/form/form.vue) expose)→ 有错误则 `return`(不关弹窗)→ `emit('confirm', ...)` + `emit('update:open', false)`。
- Modal 必须显式 `:fullscreen="false" :resizable="false"`(两者默认 true),写法参照 [ModuleRuleEditModal.vue](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/ModuleRuleEditModal.vue);点确定只 `emit('ok')` 不会自动关闭,由 `onOk` 自己 emit。
- `watch(() => props.open)` 打开时重置两个字段。
- 两个输入:`分组编码`(`placeholder="如 basic_info,创建后不可修改"`,`@keydown.enter.prevent="onOk"`)、`分组名称`(`maxlength="100"`,与内联重命名一致,`placeholder="可留空,创建后立即命名"`)。
### 3. 表单设计器 — [FormDesignEditor.vue](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/components/design-editor/FormDesignEditor.vue)
1. 删除 `nextGroupCode()`(569-575)与第 54 行的 `import { nextGroupId }`(否则 `pnpm lint` 因未使用导入失败)。**保留 `nextGroupXh()`**(b_xh 仍需用于画布分区顺序)。
2. `reservedGroupIds` prop 保留,用途从「取号时避开」变为「传给弹窗做唯一性校验」。
3. 新增状态:`groupCreateOpen = ref(false)`、`groupIdList = computed(() => groups.value.map(g => String(g.b_id)))`、`openGroupCreate()`(`props.loading` 时直接 return)。
4. `addGroup()` 拆为「打开弹窗」+「确认创建」:
```js
function onGroupCreateConfirm({ id, title }) {
if (props.loading || !String(id || '').trim()) return
flushEditingGroupTitle() // 与原 addGroup 一致
const newGroup = {
b_id: String(id), b_module_id: props.moduleId, b_parent_id: null,
b_title: String(title ?? ''), b_xh: nextGroupXh(), b_canuse: 1,
b_layout_direction: 'vertical', // 必须保留新分组默认值
b_colspan: GROUP_GRID, // 必须保留
}
groups.value = [...(groups.value || []), newGroup]
if (newGroup.b_title) return // 填了名称 → 直接完成
editingGroupKey.value = `g-${newGroup.b_id}` // 没填名称 → 原双重 nextTick 内联命名
groupTitleDraft.value = ''
nextTick(() => nextTick(() => sectionEditInput.value?.focus({ cursor: 'all' })))
}
```
5. 模板:第 1422 行按钮改 `@click="openGroupCreate"`;在现有「分组设置」Modal(1805-1865 行)旁挂 `<GroupCreateModal v-model:open="groupCreateOpen" :existing-ids="groupIdList" :reserved-ids="reservedGroupIds" :loading="loading" @confirm="onGroupCreateConfirm" />`。
表单画布只建顶级分组(`b_parent_id: null`,现状不变),故不传 `parentLabel`;数据里已存在的嵌套分组其 id 也在 `groupIdList` 内,唯一性天然覆盖。
### 4. 结构编辑内核 — [useStructureEditing.js](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/components/design-editor/common/useStructureEditing.js)
唯一消费者是 TableDesignEditor(已确认),故可安全改签名。
1. 删除 `nextGroupCode()`(537-542)与第 23 行 `import { nextGroupId }`;`nextGroupXh()` 保留。
2. 删除选项参数 `reservedGroupIds`(417 行 + 402-403 行 JSDoc):内核里已无消费者,保留编号改由宿主直接喂给弹窗。
3. 收敛为单一创建函数,去掉 band 对象依赖:
```js
function addRootGroup({ id, title } = {}) { return createBand({ id, title, parentGid: null }) }
function addSubGroup(parentGid, { id, title } = {}) {
const gid = parentGid == null ? null : String(parentGid)
if (!gid || !bandMeta.value.metaMap.has(gid)) return // 父分组已被删除:静默放弃
return createBand({ id, title, parentGid: gid })
}
function createBand({ id, title, parentGid }) {
if (loading()) return
const groupId = String(id ?? '').trim()
if (!groupId) return // 域函数边界:不落空编码
const newGroup = {
b_id: groupId, b_module_id: moduleId(), b_parent_id: parentGid,
b_title: String(title ?? '').trim(),
b_xh: nextGroupXh(), b_canuse: 1, b_colspan: 48, // 列表侧无 b_layout_direction,保持现状
}
emitGroupsRenumbered([...(getGroups() || []), newGroup])
if (newGroup.b_title) selectBandAfterCreate(groupId) // 跳过内联命名,仅选中
else focusNewBandTitle(groupId) // 原行为
}
```
4. `focusNewBandTitle`(574-580) 不变,调用点改为「仅 title 为空时」;新增 `selectBandAfterCreate(gid)`:`nextTick` 内 `if (bandMeta.value.metaMap.has(gid)) selectedNodeKey.value = 'g:' + gid`(不调 `requestRename`)。
5. 删除 `onNodeAddSub()`(778-782)及 return 清单里的同名项(1002 行)—— 它原先只做 `addSubGroup(band)`,签名变更后不可用,改由宿主解析 key。
6. **不保留自动取号兜底**:兜底会让「人工编码」与「自动号」同时进入同一份 JSON,静默改号的风险重新出现。空编码只做「不创建」短路(有 bug 时表现为「点了没反应」,比编码漂移安全且好排查)。
### 5. 列表设计器 — [TableDesignEditor.vue](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/components/design-editor/TableDesignEditor.vue)
1. 弹窗状态(沿用同族 `sectionSettingsKey` 的「null 即关闭」惯例):
```js
const groupCreate = ref(null) // { parentGid, parentLabel } | null
const groupCreateOpen = computed({ get: () => groupCreate.value !== null, set: (v) => { if (!v) groupCreate.value = null } })
const groupIdList = computed(() => (groups.value || []).map((g) => String(g.b_id)))
function openGroupCreate(parentGid = null) {
if (props.loading) return
const gid = parentGid == null ? null : String(parentGid)
const band = gid ? bandMeta.value.metaMap.get(gid) : null
if (gid && !band) return
groupCreate.value = { parentGid: gid, parentLabel: band ? band.group.b_title || `分组-${gid}` : '' }
}
function openSubGroupCreate(gidOrBand) { // 兼容入口 2/4 传 band 对象、入口 3 传 gid 字符串
const gid = gidOrBand && typeof gidOrBand === 'object' ? gidOrBand.gid : gidOrBand
if (gid == null) return
openGroupCreate(gid)
}
function onGroupCreateConfirm({ id, title }) {
const parentGid = groupCreate.value?.parentGid ?? null
if (parentGid) addSubGroup(parentGid, { id, title })
else addRootGroup({ id, title })
groupCreate.value = null
}
```
2. **4 个入口全部改为打开弹窗**:
- 工具栏按钮(473-475)→ `@click="openGroupCreate()"`;
- 结构树右键(`onStructureContextMenuClick` 263-266 的 `add-sub`)→ `openSubGroupCreate(band.gid)`(band 存在性判断已内聚进 `openGroupCreate`);
- `@node-add-sub`(549)→ 不再接 hook 的 `onNodeAddSub`,改为本地 `onNodeAddSubRequest({ key })`:`key` 形如 `g:<gid>`,`!key?.startsWith('g:') || key === 'g:ungrouped'` 直接 return,否则 `openSubGroupCreate(key.slice(2))`;同时从第 204 行解构里删掉 `onNodeAddSub`;
- 属性面板 `@add-sub-group`(573)→ `openSubGroupCreate(selectedBand)`(`selectedBand` 为 null 时必须不退化成分组创建,`openSubGroupCreate` 已挡住)。
3. `useStructureEditing({...})` 里删掉 `reservedGroupIds: () => props.reservedGroupIds`(178 行);props 本体保留给弹窗用。
4. 模板挂载 `<GroupCreateModal v-model:open="groupCreateOpen" :parent-label="groupCreate?.parentLabel || ''" :existing-ids="groupIdList" :reserved-ids="props.reservedGroupIds" :loading="loading" @confirm="onGroupCreateConfirm" />`。
---
## 不变量与边界
- **编码创建后不可修改**靠三件事保证:① 弹窗只在新建路径打开(5 个入口全为 create);② `b_id` 只出现在 `createBand` / `onGroupCreateConfirm` 的构造里,写回路径(`onRenameCommit`、`onBandTitleChange`、`commitEditGroupTitle`、`commitBandDrag`、`renumberAllSiblings`)只改 `b_title` / `b_xh` / `b_canuse` / `b_parent_id`;③ 本次不把编码加进 Inspector 或「分组设置」弹窗。
- 「未分组」是虚拟分区(表单侧 `section.key === 'ungrouped'`,树侧 `g:ungrouped`),不参与创建,仅由保留字校验挡住同名冲突。
- view / edit 各自独立编号:[index.vue](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/index.vue#L253-L265) 的 `reservedGroupIds` 已按 schema 分桶,`existingIds` 取各自设计器的 `groups`,**不跨 schema 校验**(同一编码可同时存在于列表与表单)。
- 填写名称的新分组**不再进入内联命名**;留空的仍进入,与改造前一致。
- 空分组、嵌套子分组均不受影响(子分组编码在整份 schema 内全局唯一,不分层判定)。
- 正向副作用:编码一落库,`collectSchemaGroupItems` 自动按 `group.{模块编码}.{schema类型}.{编码}` 生成受管多语言行,默认译文跟随 `b_title`,无需额外改动。
## 测试计划
在 [schema-json.spec.js](file:///d:/workspace/code/fms/fms-vue/tests/unit/schema-json.spec.js) 追加 `describe('validateGroupCode')`(沿用该文件的东西、纯数据、中文 it 描述风格):
1. 空值:`''` / `' '` / `null` / `undefined` → 非空文案。
2. 长度边界:`'a'`、`'a'.repeat(31)` → 长度文案;`'ab'`、`'a'.repeat(30)` → `true`。
3. 正则:`'1abc'` / `'Abc'` / `'a-b'` / `'a b'` / `'分组'` → 正则文案。
4. 与已有冲突:`validateGroupCode('basic', { existingIds: ['basic'] })` → 含 `basic`、不含 `多语言`。
5. 与保留编号冲突:`validateGroupCode('group2', { reservedIds: ['group2'] })` → 含 `多语言`(与第 4 条文案可区分)。
6. 合法自定义:`'basic_info'`、`'cw_fee_detail'` → `true`。
7. `group{n}` 形式不冲突即允许:`validateGroupCode('group7', { existingIds: ['group1'], reservedIds: ['group2'] })` → `true`。
8. 集合类型兼容:`existingIds: new Set(['basic'])` 与数组结果一致。
9. 首尾空白归一:`' basic '` 命中冲突;`' ok_code '` → `true`。
10. 保留字:`'ungrouped'` → 保留字文案。
11. 上限可覆盖:`{ maxLength: 10 }` 下 11 字符报错、10 字符通过。
**回归**(会经过被改文件):`schema-json.spec.js` 现有全部用例(尤其「重复 id 后来者重新发号」—— 本次风险的反向锚点);`module-table-edit-panel-dnd.spec.js`、`module-list-config-panel-dnd.spec.js`(`group1/group2` 期望值);`form-edit-panel-mount.spec.js`(新增弹窗在 `open=false` 时不得渲染、不得引入递归更新);`module-i18n-reconcile.spec.js`。
## 验证步骤
```bash
cd d:\workspace\code\fms\fms-vue
pnpm test # 或 pnpm test:file tests/unit/schema-json.spec.js
pnpm lint && pnpm fmt:check
pnpm dev # 起前端
```
手工路径(模块管理 → 选一个 data/virtual 模块):
**表单设计器**:工具栏「添加分组」→ 弹窗标题「新增分组」;点确定停住报「不能为空」;填 `A-b` 报正则;填已存在的编码报冲突;编码 `basic` + 名称留空 → 创建后进入内联命名;编码 `extra` + 名称 `附加信息` → 直接出现带标题分区、不进入命名态;保存后切走再切回,编码未被改号,重命名只改标题。
**列表设计器 4 个入口逐一验证**(都应弹窗、确定后编码即手填值):① 工具栏「添加分组」;② 结构树节点行内「新增子分组」;③ 结构树右键「新增子分组」;④ 属性面板「新增子分组」(未选中分组时点它不应弹出顶级创建弹窗)。
**交叉验证**:列表里用过的编码在表单设计器里仍可再用(view/edit 独立);新建分组后切「多语言」页签,出现 `group.{模块编码}.{view|edit}.{编码}` 行且默认语言值 = 分组名称。
## 风险
**最高**:`useStructureEditing.js` 上「删 `nextGroupCode` + 改 `addRootGroup`/`addSubGroup` 签名 + 删 `onNodeAddSub` 导出」这组联动是全案唯一的破坏性接口变更,且它服务 4 个入口(其中 2 个由 StructureTree / TableInspector 事件间接驱动)。漏改一处的后果是「点添加分组没反应」或模板调用 `undefined` 抛错,而单测只覆盖到 `schemaModel` 层、抓不到 UI 入口。缓解:创建逻辑收敛到唯一的 `createBand`,宿主与模板不再出现任何取号逻辑;4 个入口 + 第 204 行解构 + return 清单在同一提交内改完,并强制走一遍上面的 4 条点击路径。
**次高**:弹窗唯一性校验依赖宿主 `existingIds` 的实时快照(`computed` 保证与 `groups` 同步),若将来出现弹窗打开期间改写 `groups` 的新路径,已通过的编码可能瞬时冲突 —— 目前遮罩层挡住画布交互,不存在该路径。
@@ -0,0 +1,93 @@
import java.sql.*;
import java.util.*;
/** 只读核对:s_power / s_user_power / s_menu_module 现状,定位「菜单权限勾了不生效」的根因。 */
public class PowerProbe {
public static void main(String[] args) throws Exception {
String url = "jdbc:sqlserver://118.89.70.199:1433;databaseName=FMS;encrypt=true;trustServerCertificate=true";
try (Connection conn = DriverManager.getConnection(url, "sa", System.getenv("FMS_SA_PWD"))) {
System.out.println("=== 1. s_power 实际列名 ===");
try (PreparedStatement ps = conn.prepareStatement(
"select column_name, data_type from information_schema.columns "
+ "where table_name = 's_power' order by ordinal_position");
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
System.out.printf(" %-20s %s%n", rs.getString(1), rs.getString(2));
}
}
System.out.println("\n=== 2. s_power 按 b_object_type 统计 ===");
try (PreparedStatement ps = conn.prepareStatement(
"select b_object_type, count(*) c from s_power group by b_object_type");
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
System.out.printf(" %-10s %d%n", String.valueOf(rs.getString(1)), rs.getInt(2));
}
}
System.out.println("\n=== 3. s_power 中 menu 类型明细 ===");
try (PreparedStatement ps = conn.prepareStatement(
"select b_id, b_object_id, b_action, b_canuse from s_power "
+ "where b_object_type = 'menu' order by b_id");
ResultSet rs = ps.executeQuery()) {
int count = 0;
while (rs.next()) {
count++;
System.out.printf(" %-22s obj=%-14s action=%-8s canuse=%d%n",
rs.getString("b_id"), rs.getString("b_object_id"),
String.valueOf(rs.getString("b_action")), rs.getInt("b_canuse"));
}
System.out.println(" 共 " + count + " 行");
}
System.out.println("\n=== 4. s_power 中 module 类型的 b_object_id(覆盖了哪些模块)===");
try (PreparedStatement ps = conn.prepareStatement(
"select distinct b_object_id from s_power where b_object_type = 'module' order by b_object_id");
ResultSet rs = ps.executeQuery()) {
StringBuilder sb = new StringBuilder();
while (rs.next()) {
sb.append(rs.getString(1)).append(' ');
}
System.out.println(" " + sb);
}
System.out.println("\n=== 5. 启用 page/external 菜单(应有对应 menu.* 权限点)===");
int pageMenus = 0;
try (PreparedStatement ps = conn.prepareStatement(
"select b_id, b_name, b_menu_type from s_menu "
+ "where b_canuse = 1 and b_menu_type in ('page','external') order by b_id");
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
pageMenus++;
System.out.printf(" %-14s %-9s %s%n", rs.getString("b_id"),
rs.getString("b_menu_type"), rs.getString("b_name"));
}
System.out.println(" 共 " + pageMenus + " 个");
}
System.out.println("\n=== 6. s_user_power 明细 ===");
try (PreparedStatement ps = conn.prepareStatement(
"select b_user_id, b_power_id, b_canuse from s_user_power order by b_user_id, b_power_id");
ResultSet rs = ps.executeQuery()) {
int count = 0;
while (rs.next()) {
count++;
System.out.printf(" %-8s %-34s canuse=%d%n", rs.getString("b_user_id"),
rs.getString("b_power_id"), rs.getInt("b_canuse"));
}
System.out.println(" 共 " + count + " 行");
}
System.out.println("\n=== 7. s_menu_module 按菜单统计 ===");
try (PreparedStatement ps = conn.prepareStatement(
"select b_menu_id, count(*) c from s_menu_module group by b_menu_id order by b_menu_id");
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
System.out.printf(" %-14s %d 个模块%n", rs.getString(1), rs.getInt(2));
}
}
}
}
}
@@ -58,7 +58,7 @@ const props = defineProps({
rowSelection: { type: Object, default: null },
rowClassName: { type: Function, default: undefined },
expandable: { type: [Boolean, Object], default: false },
pageSize: { type: Number, default: 20 },
pageSize: { type: Number, default: 50 },
inlineActionLimit: { type: Number, default: 2 },
// 可编辑表格模式:取编辑配置生成行内编辑器,支持保存/批量删除。
editable: { type: Boolean, default: false },
@@ -195,9 +195,7 @@ function withRevealedInOrder(pref, visibleRows, revealedRows) {
if (!orderList.length) return pref
// 按 b_xh 归并:揭示列插到第一个声明序号比它大的可见列之前,与真实列序一致
const sequence = mergeRevealedColumns(visibleRows, revealedRows).map((row) =>
String(row.b_field),
)
const sequence = mergeRevealedColumns(visibleRows, revealedRows).map((row) => String(row.b_field))
// 只保留确实存在于本次列集合里的字段,并保持个人层的相对顺序
const kept = orderList.filter((field) => sequence.includes(field))
const merged = sequence.filter((field) => kept.includes(field) || !orderList.includes(field))
@@ -228,7 +226,7 @@ const configError = ref('')
const dataError = ref('')
const pagination = reactive({
current: 1,
pageSize: Number(props.pageSize) || 20,
pageSize: Number(props.pageSize) || 50,
total: 0,
})
let dataRequestVersion = 0
+8 -4
View File
@@ -39,10 +39,7 @@ router.beforeEach(async (to) => {
// 菜单一次性到位、不会“跳出”;load 内部按主体去重,重复进入同一主体不重复请求
const permissionStore = usePermissionStore()
const menuStore = useMenuStore()
await Promise.all([
permissionStore.load().catch(() => {}),
menuStore.load().catch(() => {}),
])
await Promise.all([permissionStore.load().catch(() => {}), menuStore.load().catch(() => {})])
// 业务数据多语言配置(s_i18n_type 语言清单 + s_i18n 译文):随权限一并加载,
// 供菜单/模块等多语言编辑读取;内部去重、失败不阻断页面。
@@ -56,6 +53,13 @@ router.beforeEach(async (to) => {
return '/dashboard'
}
// 菜单入口:路由命中 s_menu 里的页面菜单时,必须有该菜单的 menu.* 授权。
// 侧栏已按授权裁剪(stores/menus),这里兜住「手输地址直接访问」。
const menu = menuStore.findMenuByRoute(to.path)
if (menu && !permissionStore.canEnterMenu(menu.b_id)) {
return '/dashboard'
}
// 系统管理员专用页面(meta.adminOnly,当前为模块中心 /module/*):
// 非 g3soft 一律拦回工作台。按路由标记而非路径前缀判断,避免「菜单上看不见、
// 手输地址却能进」的越权;侧栏入口同样仅对 g3soft 渲染(AppSidebar)。
+40 -6
View File
@@ -2,18 +2,26 @@ import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { loadDataApi } from '@/services/api'
import { useAuthStore } from './auth'
import { usePermissionStore } from './permissions'
import { buildTree, sortTree } from '@/utils/tree'
/** 递归裁剪菜单树:目录仅当存在可见子菜单时渲染(空目录不显示) */
function pruneByAccess(nodes) {
/**
* 递归裁剪菜单树:
* - 页面 / 外链菜单:没有该菜单的 menu.* 授权时不渲染(白名单:无记录即无入口);
* - 目录:仅当存在可见子菜单时渲染(空目录不显示)。
* @param {Array} nodes 菜单树
* @param {(node: Object) => boolean} canEnter 是否能进入该菜单页面
*/
function pruneByAccess(nodes, canEnter) {
const result = []
for (const node of nodes) {
const children = node.children?.length ? pruneByAccess(node.children) : []
const children = node.children?.length ? pruneByAccess(node.children, canEnter) : []
// 目录:没有可访问子菜单时不渲染
if (node.b_menu_type === 'directory') {
if (children.length) result.push({ ...node, children })
continue
}
if (!canEnter(node)) continue
result.push(children.length ? { ...node, children } : { ...node })
}
return result
@@ -24,13 +32,15 @@ function pruneByAccess(nodes) {
*
* 菜单树由「菜单管理」维护在 s_menu 表,含目录 / 页面 / 外链三类节点。
* 可见性规则由前端执行(不改后端):
* - 全部启用菜单按登录主体加载,目录仅当存在可见子菜单时渲染(空目录不显示);
* - 全部启用菜单按登录主体加载,再按 menu.* 授权裁剪(无授权不渲染),
* 目录仅当存在可见子菜单时渲染(空目录不显示);
* - 模块中心等系统管理员专用页面不进 s_menu:入口固定写在 AppSidebar(仅 g3soft 渲染),
* 路由层由 meta.adminOnly 守卫拦截非 g3soft,菜单管理也配不出这些路由。
* 与权限 store 一致:同一登录主体加载去重、并发请求复用、登录主体切换清空缓存。
*/
export const useMenuStore = defineStore('menus', () => {
const authStore = useAuthStore()
const permissionStore = usePermissionStore()
/** s_menu 原始记录(b_canuse = 1,接口按 b_xh / b_id 排序返回) */
const rawMenus = ref([])
const loadedPrincipalKey = ref('')
@@ -67,14 +77,37 @@ export const useMenuStore = defineStore('menus', () => {
}
/**
* 当前主体可见的菜单树(s_menu 原始行结构,含 children 子树)。
* 当前主体可见的菜单树(s_menu 原始行结构,含 children 子树):
* 已按 menu.* 授权裁剪,未授权的页面菜单与因此变空的目录都不出现。
*/
const visibleMenus = computed(() => {
if (!rawMenus.value.length) return []
const tree = sortTree(buildTree(JSON.parse(JSON.stringify(rawMenus.value))))
return pruneByAccess(tree)
return pruneByAccess(tree, (node) => permissionStore.canEnterMenu(node.b_id))
})
/**
* 按路由地址找菜单:b_route 精确匹配,或命中它的子路径(如详情页 /base/othercompany/123)。
* 返回 null 表示该路由不在 s_menu(工作台、模块中心等),路由守卫据此放行。
* @param {string} path 路由 path(不含 query)
* @returns {Object|null} s_menu 行
*/
function findMenuByRoute(path) {
const target = String(path || '')
.split('?')[0]
.replace(/\/+$/, '')
if (!target) return null
return (
rawMenus.value.find((row) => {
const route = String(row.b_route || '')
.trim()
.replace(/\/+$/, '')
if (!route) return false
return target === route || target.startsWith(`${route}/`)
}) || null
)
}
function clear() {
// 仅当“正在进行中的请求属于别的主体”时才作废它(如切换账号),
// 否则保留进行中请求、只清空已加载数据,交由后续 load 结果填充。
@@ -105,5 +138,6 @@ export const useMenuStore = defineStore('menus', () => {
clear,
rawMenus,
visibleMenus,
findMenuByRoute,
}
})
+66 -77
View File
@@ -1,33 +1,43 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { loadDataApi } from '@/services/api'
import {
buildActionPowerCode,
buildMenuPowerCode,
buildModulePowerCode,
isStandardModuleAction,
} from '@/utils/powerCodes'
import { useAuthStore } from './auth'
/**
* 权限 store:负责拉取模块 / 用户授权数据并暴露访问与功能点判断。
* 逻辑与 fms-vue-old2 完全一致:
* - 管理员账号(g3soft)提权,直接可见全部启用模块
* - 普通用户按 b_user_module / b_user_power 授权
* - 同一主体(orgId:userId)加载结果去重,并发请求复用
* 权限 store:按《FMS新系统核心表结构设计》§14 读取新表并暴露访问与功能点判断。
*
* s_power 系统有哪些权限点(menu.* / module.* / action.*)
* s_user_power 当前用户拥有哪些权限点(白名单:无记录即无权限)
*
* 判定口径(与后端 SqlPermissionService 一致):
* - s_power 中不存在该权限点、或任一侧 b_canuse = 0,授权一律不生效;
* - 标准操作(read / create / update / delete / export)由「模块使用权限」统一表达,
* 有 module.* 即允许(见 @/utils/powerCodes);
* - 业务动作(审核 / 反审 / 结算 …)按 action.{模块}.{动作} 单独授权;
* - 菜单入口(menu.*)只控制「能否进入页面」,与模块权限相互独立。
*
* - 管理员账号(g3soft)提权:全部启用模块可见、一切判断放行,不查授权表;
* - 同一主体(orgId:userId)加载结果去重,并发请求复用。
*/
export const usePermissionStore = defineStore('permissions', () => {
const authStore = useAuthStore()
const loadedPrincipalKey = ref('')
const modules = ref([])
const grantedPageIds = ref([])
const moduleCodes = ref([])
const powerCodes = ref([])
/** 当前用户拥有的权限点编码(s_user_power ∩ s_power 启用行) */
const grantedPowerCodes = ref([])
let loadingPromise = null
let loadingPrincipalKey = ''
let loadVersion = 0
// 字符串业务键:模块编码即主键 b_id
const moduleByCode = computed(
() => new Map(modules.value.map((m) => [String(m.b_id || ''), m])),
)
const moduleById = computed(() => new Map(modules.value.map((m) => [String(m.b_id || ''), m])))
const grantedPageSet = computed(() => new Set(grantedPageIds.value))
const powerSet = computed(() => new Set(powerCodes.value))
const moduleByCode = computed(() => new Map(modules.value.map((m) => [String(m.b_id || ''), m])))
const grantedPowerSet = computed(() => new Set(grantedPowerCodes.value))
const isG3soft = computed(() => {
return (
String(authStore.loginInfo.user?.account || '')
@@ -55,9 +65,7 @@ export const usePermissionStore = defineStore('permissions', () => {
.then((res) => {
if (requestVersion !== loadVersion) return
modules.value = res.data || []
grantedPageIds.value = []
moduleCodes.value = []
powerCodes.value = []
grantedPowerCodes.value = []
loadedPrincipalKey.value = elevatedKey
})
.finally(() => {
@@ -74,34 +82,28 @@ export const usePermissionStore = defineStore('permissions', () => {
const requestVersion = ++loadVersion
loadingPrincipalKey = principalKey
// 授权表(b_user_module/b_user_power/s_module_power)在部分机构库不存在,
// 失败时按无授权处理,不阻塞登录主流程。
// 授权表在部分机构库可能尚未建立:失败时按无授权处理,不阻塞登录主流程。
const failSafe = () => ({ data: [] })
const quotedUserId = `N'${String(userId).replaceAll("'", "''")}'`
const request = Promise.all([
loadDataApi('s_module', 'b_canuse = 1', 'b_id ASC'),
loadDataApi('b_user_module', `b_user_id = ${quotedUserId}`, 'b_id ASC').catch(failSafe),
loadDataApi('b_user_power', `b_user_id = ${quotedUserId}`, 'b_id ASC').catch(failSafe),
loadDataApi('s_module_power', 'b_canuse = 1', 'b_id ASC').catch(failSafe),
loadDataApi('s_power', 'b_canuse = 1', 'b_id ASC').catch(failSafe),
loadDataApi(
's_user_power',
`b_user_id = ${quotedUserId} AND b_canuse = 1`,
'b_power_id ASC',
).catch(failSafe),
])
.then(([moduleResponse, userModules, userPowers, powers]) => {
.then(([moduleResponse, powerResponse, userPowerResponse]) => {
if (requestVersion !== loadVersion) return
const activeModules = moduleResponse.data || []
const mById = new Map(activeModules.map((m) => [String(m.b_id), m]))
const pById = new Map(powers.data.map((p) => [String(p.b_id), p]))
const pageIds = userModules.data
.map((item) => String(item.b_module_id || ''))
.filter(Boolean)
modules.value = activeModules
grantedPageIds.value = pageIds
// 模块编码即主键 b_id
moduleCodes.value = userModules.data
.map((item) => mById.get(String(item.b_module_id))?.b_id)
.filter(Boolean)
powerCodes.value = userPowers.data
.map((item) => pById.get(String(item.b_power_id)))
.filter((p) => Boolean(p))
.map((power) => `${String(power.b_module_id)}.${power.b_id}`)
// 只有 s_power 中启用的权限点、且用户被授权才算数(设计文档 14.2 判定规则 2)
const activePowers = new Set(
(powerResponse.data || []).map((row) => String(row.b_id || '')),
)
modules.value = moduleResponse.data || []
grantedPowerCodes.value = (userPowerResponse.data || [])
.map((row) => String(row.b_power_id || ''))
.filter((code) => code && activePowers.has(code))
loadedPrincipalKey.value = principalKey
})
.finally(() => {
@@ -114,47 +116,37 @@ export const usePermissionStore = defineStore('permissions', () => {
}
/**
* 由模块编码向上查找其所属 page 类型模块的 ID。
*/
function resolvePageId(code) {
let module = moduleByCode.value.get(String(code || ''))
const visited = new Set()
while (module) {
const moduleId = String(module.b_id || '')
if (!moduleId || visited.has(moduleId)) return ''
visited.add(moduleId)
if (module.b_module_type === 'page') return moduleId
const parentId = String(module.b_parent_id || '')
module = parentId ? moduleById.value.get(parentId) : undefined
}
return ''
}
/**
* 判断当前用户是否有某模块的访问权限(管理员恒为 true)。
* 判断当前用户是否有某模块的使用权限(管理员恒为 true)。
* 白名单语义:没有 module.{模块编码} 授权即不可访问。
*/
function canAccess(moduleCode) {
if (isG3soft.value || !moduleCode) return true
const module = moduleByCode.value.get(String(moduleCode))
if (!module) return false
const moduleId = String(module.b_id || '')
const pageId = resolvePageId(moduleCode)
return grantedPageSet.value.has(moduleId) || (!!pageId && grantedPageSet.value.has(pageId))
if (!moduleByCode.value.has(String(moduleCode))) return false
return grantedPowerSet.value.has(buildModulePowerCode(moduleCode))
}
/**
* 判断当前用户是否有某模块的功能点权限。
* 标准操作(read / create / update / delete / export)由模块使用权限统一表达,
* 有 module.* 即允许;业务动作(audit 等)要求单独的 action.{模块}.{动作} 授权。
*/
function canPower(scopeCode, powerCode) {
function canPower(scopeCode, action) {
if (isG3soft.value) return true
const module = moduleByCode.value.get(String(scopeCode || ''))
if (!module || !canAccess(scopeCode)) return false
const moduleId = String(module.b_id || '')
const pageId = resolvePageId(scopeCode || '')
return (
powerSet.value.has(`${moduleId}.${powerCode}`) ||
(!!pageId && powerSet.value.has(`${pageId}.${powerCode}`))
)
const code = String(scopeCode || '')
const actionCode = String(action || '')
if (!code || !actionCode) return false
if (!canAccess(code)) return false
if (isStandardModuleAction(actionCode)) return true
return grantedPowerSet.value.has(buildActionPowerCode(code, actionCode))
}
/**
* 判断当前用户能否进入某个菜单页面(侧栏渲染与路由入口共用)。
* 白名单语义:没有 menu.{菜单编码} 授权即不可见、不可进入。
*/
function canEnterMenu(menuCode) {
if (isG3soft.value || !menuCode) return true
return grantedPowerSet.value.has(buildMenuPowerCode(menuCode))
}
function clear() {
@@ -170,9 +162,7 @@ export const usePermissionStore = defineStore('permissions', () => {
}
loadedPrincipalKey.value = ''
modules.value = []
grantedPageIds.value = []
moduleCodes.value = []
powerCodes.value = []
grantedPowerCodes.value = []
}
// 登录主体变化(退出/切换账号)时清空权限缓存
@@ -190,11 +180,10 @@ export const usePermissionStore = defineStore('permissions', () => {
load,
canAccess,
canPower,
canEnterMenu,
clear,
modules,
grantedPageIds,
moduleCodes,
powerCodes,
grantedPowerCodes,
isG3soft,
}
})
+59
View File
@@ -0,0 +1,59 @@
/**
* 权限点编码与分类(《FMS新系统核心表结构设计》§14.1)。
*
* 系统只有三类权限点:
* menu.{菜单编码} 菜单入口 —— 能否进入菜单页面
* module.{模块编码} 模块使用 —— 标准操作(查看 / 新增 / 修改 / 删除 / 导出)
* action.{模块编码}.{业务动作} 业务动作 —— 审核 / 反审 / 结算 等
*
* 标准操作不生成独立权限点(整模块一个使用权限点),因此新口径下
* 「能否新增」等价于「能否使用该模块」;业务动作才需要单独授权。
*
* 权限点的维护方(menu-management / module-management)与校验方(stores/permissions)
* 共用本文件,避免两侧口径漂移。
*/
/** 标准操作:归入模块使用权限,不再按操作拆分 */
export const STANDARD_MODULE_ACTIONS = ['read', 'create', 'update', 'delete', 'export']
/** 模块使用权限的固定动作(s_power.b_action) */
export const MODULE_ACCESS_ACTION = 'access'
/** 动作是否属于「模块使用权限」:access 与标准操作都算 */
export function isStandardModuleAction(action) {
const value = String(action || '').trim()
return value === MODULE_ACCESS_ACTION || STANDARD_MODULE_ACTIONS.includes(value)
}
/**
* 菜单入口权限编码:menu.{菜单编码}。
* @param {string} menuCode 菜单编码(s_menu.b_id)
* @returns {string|null} 权限编码;编码为空时返回 null
*/
export function buildMenuPowerCode(menuCode) {
const code = String(menuCode || '').trim()
return code ? `menu.${code}` : null
}
/**
* 模块使用权限编码:module.{模块编码}(标准操作也归到它)。
* @param {string} moduleCode 模块编码(s_module.b_id)
* @returns {string|null} 权限编码;编码为空时返回 null
*/
export function buildModulePowerCode(moduleCode) {
const code = String(moduleCode || '').trim()
return code ? `module.${code}` : null
}
/**
* 业务动作权限编码:action.{模块编码}.{动作}。
* @param {string} moduleCode 模块编码(s_module.b_id)
* @param {string} action 业务动作(audit / unaudit / ...)
* @returns {string|null} 权限编码;任一段为空时返回 null
*/
export function buildActionPowerCode(moduleCode, action) {
const moduleId = String(moduleCode || '').trim()
const actionCode = String(action || '').trim()
if (!moduleId || !actionCode) return null
return `action.${moduleId}.${actionCode}`
}
+59
View File
@@ -173,3 +173,62 @@ export function buildGroupI18nKey(moduleCode, schemaType, groupId) {
const prefix = buildGroupI18nKeyPrefix(moduleCode, schemaType)
return prefix && groupIdValue ? `${prefix}${groupIdValue}` : null
}
/* ================= 分组编码校验 ================= */
/**
* 分组编码长度上限:键为 group.{模块编码}.{schema 类型}.{分组编码},
* 即 6 + 1 + 50 + 1 + 5 + 1 + 30 = 94,远小于 s_i18n.b_key varchar(150)。
*/
export const GROUP_CODE_MAX_LENGTH = 30
/** 分组编码格式与模块编码(s_module.b_id)同构,另见 ModuleBasicPanel 的同款校验 */
const GROUP_CODE_RE = /^[a-z][a-z0-9_]*$/
/** 保留字:结构树用 'g:ungrouped' 作虚拟「未分组」节点 key,真实分组不能占用该名字 */
const RESERVED_GROUP_CODES = new Set(['ungrouped'])
/** 编码集合归一:兼容数组与 Set,去空白后丢弃空值 */
function normalizeCodes(codes) {
const set = new Set()
for (const value of codes || []) {
const code = textOf(value)
if (code) set.add(code)
}
return set
}
/**
* 校验分组编码(新建分组时手填)。契约与 ui Form 的 validator 一致:
* 返回 true 表示通过,返回非空字符串即错误文案。
*
* 唯一性只在单个配置(单份 b_schema_json)内判定:view 与 edit 各自独立编号,
* 同一编码可以同时存在于列表与表单,故不需要 schema 类型入参。
*
* @param {unknown} rawCode 待校验的编码
* @param {{ existingIds?: Iterable<string>, reservedIds?: Iterable<string>, maxLength?: number }} [options]
* existingIds 本配置内已占用的编码;reservedIds 多语言资源里仍留有记录的编码
* (已删除分组的编号,复用会连带继承其残留译文)
* @returns {true|string}
*/
export function validateGroupCode(
rawCode,
{ existingIds = [], reservedIds = [], maxLength = GROUP_CODE_MAX_LENGTH } = {},
) {
const code = textOf(rawCode)
if (!code) return '分组编码不能为空'
if (code.length < 2 || code.length > maxLength) {
return `分组编码长度需为 2-${maxLength} 个字符`
}
if (!GROUP_CODE_RE.test(code)) {
return '分组编码须以小写字母开头,且只能包含小写字母、数字和下划线'
}
if (RESERVED_GROUP_CODES.has(code)) return `分组编码 ${code} 为系统保留字,请换一个`
if (normalizeCodes(existingIds).has(code)) {
return `分组编码 ${code} 已被本配置的其他分组使用,请换一个`
}
if (normalizeCodes(reservedIds).has(code)) {
return `分组编码 ${code} 曾被使用(多语言资源中仍留有记录),请换一个`
}
return true
}
@@ -13,7 +13,9 @@
* 面板各自的字段编辑与表格交互均在对应面板组件内部,
* 数据以 v-model 直接读写本文件持有的草稿,不存在第二份状态源。
*
* 权限点(s_power)统一在「模块管理 → 权限」维护,菜单保存不再派生 menu.* 权限点。
* 权限点分两类:模块使用与业务动作权限点(module.* / action.*)在「模块管理 → 权限」维护;
* 菜单入口权限点(menu.*)由本页自动派生 —— 保存菜单时同步(名称/顺序/启用状态跟随菜单),
* 删除菜单时连同 s_user_power 里的授权行一并清理。
*/
import { computed, nextTick, ref, watch } from 'vue'
import { useHotkeys } from '@/composables/useHotkeys'
@@ -38,7 +40,15 @@ import I18nNameModal from '@/components/I18nNameModal.vue'
import MenuTreePanel from './MenuTreePanel.vue'
import MenuBasicPanel from './MenuBasicPanel.vue'
import ModulePickerModal from './ModulePickerModal.vue'
import { MENU_NON_LEAF_TYPES, MENU_TYPE_ICON_MAP, buildMenuI18nKey, menuRowOf } from './utils'
import {
MENU_NON_LEAF_TYPES,
MENU_TYPE_ICON_MAP,
buildMenuI18nKey,
buildMenuPowerCode,
buildMenuPowerRow,
menuRowOf,
normalizeMenuPowerRow,
} from './utils'
import { useI18nStore } from '@/stores/i18n'
/** 生成安全 SQL 字符串字面量(search_condition 为后端拼 SQL,需要转义引号) */
@@ -109,6 +119,8 @@ const i18nModalVisible = ref(false)
const menuModules = ref([])
/** 绑定模块加载基线(保存时行级 diffRows 的原始集合) */
const menuModules_org = ref([])
/** 菜单入口权限点(s_power, b_object_type='menu')加载基线;保存时按菜单行派生并 diff 同步 */
const menuPower_org = ref(null)
/** 模块绑定选择弹层 */
const modulePickerOpen = ref(false)
@@ -331,6 +343,19 @@ async function deleteMenu(id) {
// 模块绑定关系随菜单删除(b_menu_id 为复合主键首列,按它批量删)
{ table: 's_menu_module', key_field: 'b_menu_id', deletes: [{ b_menu_id: menuId }] },
]
// 入口权限点与它的授权一起清理:删除是条件式删除、0 行不报错,
// 所以目录菜单(没有权限点)与历史数据都能安全走这里。
// s_user_power 主键是 (b_user_id, b_power_id),按 b_power_id 一次删掉所有用户对该菜单的授权,
// 避免留下指向已删除菜单的孤儿行。
const powerCode = buildMenuPowerCode(menuId)
if (powerCode) {
reqs.push({
table: 's_user_power',
key_field: 'b_power_id',
deletes: [{ b_power_id: powerCode }],
})
reqs.push({ table: 's_power', key_field: 'b_id', deletes: [{ b_id: powerCode }] })
}
reqs.push({ table: 's_menu', key_field: 'b_id', deletes: [{ b_id: menuId }] })
await saveObjectApi(reqs)
collectUnsavedMenuIds(menu).forEach((k) => unsavedMenuIds.delete(k))
@@ -386,6 +411,15 @@ async function loadMenuConfig(menuId) {
if (String(menuSelectedId.value) !== id) return
menuModules_org.value = (moduleRes.data || []).map((row) => String(row.b_module_id))
menuModules.value = [...menuModules_org.value]
// 菜单入口权限点(s_power):页面/外链菜单各一行 menu.{编码},完全由菜单行派生
// (见 buildMenuPowerRow),这里只取基线供保存时 diff
const powerRes = await loadDataApi(
's_power',
`b_object_type = 'menu' and b_object_id = ${sqlText(id)}`,
'b_id ASC',
)
if (String(menuSelectedId.value) !== id) return
menuPower_org.value = normalizeMenuPowerRow(powerRes.data?.[0] || null)
loadedMenuId.value = id
} catch (e) {
if (String(menuSelectedId.value) === String(menuId)) {
@@ -410,6 +444,7 @@ watch(menuSelectedId, (id) => {
maindata.value = null
menuModules.value = []
menuModules_org.value = []
menuPower_org.value = null
return
}
// 未落库的新节点:临时 id 在库里查不到行,不能走 loadMenuConfig
@@ -430,6 +465,7 @@ watch(menuSelectedId, (id) => {
i18n_org.value = []
menuModules.value = []
menuModules_org.value = []
menuPower_org.value = null
loadedMenuId.value = String(id)
return
}
@@ -655,6 +691,23 @@ async function buildSaveData() {
reqs.push(moduleReq)
}
// 菜单入口权限点(s_power):页面 / 外链菜单必须有一行 menu.{编码},目录没有。
// 它完全由菜单行派生(名称、多语言键、启用状态、顺序跟随菜单),不单独做草稿;
// 保存时按目标行与该菜单的加载基线 diff,只提交插入 / 更新 / 删除。
const targetPowerRow = buildMenuPowerRow({ ...menuData, b_id: menuId })
const powerChanges = diffRows(
targetPowerRow ? [targetPowerRow] : [],
menuPower_org.value ? [menuPower_org.value] : [],
{ keyFields: ['b_id'] },
)
if (powerChanges.inserts.length || powerChanges.updates.length || powerChanges.deletes.length) {
const powerReq = { table: 's_power', key_field: 'b_id' }
if (powerChanges.inserts.length) powerReq.inserts = powerChanges.inserts
if (powerChanges.updates.length) powerReq.updates = powerChanges.updates
if (powerChanges.deletes.length) powerReq.deletes = powerChanges.deletes
reqs.push(powerReq)
}
return reqs.length ? reqs : null
}
@@ -4,6 +4,7 @@
* 树本身的通用查找/过滤/插入/移除/位置派生见 @/utils/tree,不在此重复。
*/
import { ExternalLink, FileText, Folder } from '@lucide/vue'
import { buildMenuPowerCode } from '@/utils/powerCodes'
/** 菜单类型下拉选项(directory 目录 / page 页面 / external 外链) */
export const MENU_TYPE_OPTIONS = [
@@ -66,3 +67,67 @@ export function buildMenuI18nKey(menuId) {
const normalizedMenuId = String(menuId || '').trim()
return normalizedMenuId ? `menu.${normalizedMenuId}` : null
}
/* ================= 菜单入口权限点(s_power,b_object_type = 'menu') ================= */
/**
* 需要入口权限点的菜单类型:目录只是导航分组(侧栏按子菜单裁剪),
* 页面与外链才是「能否进入」的入口,各对应一行 menu.{菜单编码}(b_action = 'access')。
*/
export const MENU_POWER_MENU_TYPES = ['page', 'external']
/**
* 菜单入口权限编码:menu.{菜单编码}(定义在 @/utils/powerCodes,与授权校验共用)。
* 与 buildMenuI18nKey 字符串同形但语义不同:前者是 s_power.b_id,后者是 s_i18n.b_key。
*/
export { buildMenuPowerCode }
/** 参与同步的 s_power 列:b_bz 是备注,不随菜单变化,不参与 diff */
const MENU_POWER_FIELDS = [
'b_id',
'b_name',
'b_i18n',
'b_type',
'b_object_type',
'b_object_id',
'b_action',
'b_canuse',
'b_xh',
]
/**
* 归一化 s_power 行为入口权限点的可比字段(剔除 b_bz 等不参与同步的列,
* 数字列统一为 number,避免 '10' 与 10 产生假差异)。
* @param {Object|null} row s_power 行
* @returns {Object|null} 可比行;入参为空时返回 null
*/
export function normalizeMenuPowerRow(row) {
if (!row) return null
const picked = {}
for (const field of MENU_POWER_FIELDS) picked[field] = row[field] ?? null
picked.b_canuse = Number(picked.b_canuse ?? 0)
picked.b_xh = Number(picked.b_xh ?? 0)
return picked
}
/**
* 由菜单行派生入口权限点行:名称、多语言键、启用状态、顺序全部跟随菜单。
* 保存菜单时按它与加载基线 diff,权限点因此不需要单独的编辑界面。
* @param {Object} menuRow s_menu 行(b_id 为最终菜单编码)
* @returns {Object|null} s_power 行;目录等不需要入口权限的菜单返回 null
*/
export function buildMenuPowerRow(menuRow) {
const menuId = String(menuRow?.b_id || '').trim()
if (!menuId || !MENU_POWER_MENU_TYPES.includes(String(menuRow?.b_menu_type || ''))) return null
return {
b_id: buildMenuPowerCode(menuId),
b_name: String(menuRow.b_name ?? ''),
b_i18n: buildMenuI18nKey(menuId),
b_type: 'menu',
b_object_type: 'menu',
b_object_id: menuId,
b_action: 'access',
b_canuse: Number(menuRow.b_canuse ?? 1) === 0 ? 0 : 1,
b_xh: Number(menuRow.b_xh ?? 0) || 0,
}
}
@@ -40,6 +40,7 @@ import {
} from './common/useStructureEditing'
import FieldLibrary from './common/FieldLibrary.vue'
import EditDataView from './common/EditDataView.vue'
import GroupCreateModal from './common/GroupCreateModal.vue'
import FormInspector from './form/FormInspector.vue'
import FormPreview from './form/FormPreview.vue'
import {
@@ -51,7 +52,6 @@ import {
sectionEndIndex,
} from './form/useFormGrid'
import { schemaToEditor, editorToSchema, stableStringify } from './common/schemaModel'
import { nextGroupId } from '@/utils/schemaJson'
import { createDefaultSchema } from '../../utils'
const props = defineProps({
@@ -566,31 +566,34 @@ function nextGroupXh() {
return (groups.value || []).reduce((m, g) => Math.max(m, Number(g.b_xh || 0)), 0) + 10
}
/** 新分组编号:避开本配置已有编号 + 宿主保留编号(多语言表里已存在的分组键) */
function nextGroupCode() {
return nextGroupId([
...(groups.value || []).map((group) => group.b_id),
...(props.reservedGroupIds || []),
])
/** 本配置内已占用的分组编码(新建分组时判唯一性) */
const groupIdList = computed(() => (groups.value || []).map((group) => String(group.b_id)))
/* 新建分组弹窗:编码手填,创建后不可修改(弹窗不提供编辑入口) */
const groupCreateOpen = ref(false)
function openGroupCreate() {
if (props.loading) return
groupCreateOpen.value = true
}
/** 添加分组:插入一个空分组并立即进入内联命名 */
function addGroup() {
if (props.loading) return
/** 弹窗确认:按手填编码插入空分组;名称留空则仍进入内联命名 */
function onGroupCreateConfirm({ id, title }) {
if (props.loading || !String(id || '').trim()) return
flushEditingGroupTitle()
const newGroup = {
b_id: nextGroupCode(),
b_id: String(id),
b_module_id: props.moduleId,
b_parent_id: null,
b_title: '',
b_title: String(title ?? ''),
b_xh: nextGroupXh(),
b_canuse: 1,
b_layout_direction: 'vertical',
b_colspan: GROUP_GRID,
}
groups.value = [...(groups.value || []), newGroup]
const key = `g-${newGroup.b_id}`
editingGroupKey.value = key
if (newGroup.b_title) return // 已填名称:直接完成,不再进入命名态
editingGroupKey.value = `g-${newGroup.b_id}`
groupTitleDraft.value = ''
// 双重 nextTick:第一次等父组件更新 groups.value,第二次等新分区的 Input 组件渲染到 DOM
nextTick(() => {
@@ -1419,7 +1422,7 @@ onBeforeUnmount(() => {
</Segmented>
</div>
<div class="mm-edit__head-right">
<Button type="outline" :disabled="loading" @click="addGroup">
<Button type="outline" :disabled="loading" @click="openGroupCreate">
<Plus :size="14" /> 添加分组
</Button>
<Button type="ghost" :disabled="loading" @click="previewOpen = true">
@@ -1864,6 +1867,15 @@ onBeforeUnmount(() => {
</div>
</Modal>
<!-- ================= 新建分组(编码手填,创建后不可修改) ================= -->
<GroupCreateModal
v-model:open="groupCreateOpen"
:existing-ids="groupIdList"
:reserved-ids="reservedGroupIds"
:loading="loading"
@confirm="onGroupCreateConfirm"
/>
<!-- ================= 表单预览 ================= -->
<!-- 48 宫格 + 分组并排需要足够展示宽度;760px 下两组各占半行会挤成不可读,
加宽默认值并允许拖拽调整(防溢出由 Form 的弹性 gap 兜底) -->
@@ -44,6 +44,7 @@ import FieldLibrary from './common/FieldLibrary.vue'
import TablePreview from './table/TablePreview.vue'
import TableInspector from './table/TableInspector.vue'
import EditDataView from './common/EditDataView.vue'
import GroupCreateModal from './common/GroupCreateModal.vue'
const props = defineProps({
/** 编辑场景:subtable 子表编辑 | list 列表配置 */
@@ -175,7 +176,6 @@ const editing = useStructureEditing({
rows.value = nextRows
},
requestRename: (gid) => structureTreeRef.value?.startRename(gid),
reservedGroupIds: () => props.reservedGroupIds,
// 子表:全量键集;列表:仅 列宽
clipboardKeys: isSubtable.value ? undefined : ['b_width'],
fieldNameOf: (row) => fieldName(row),
@@ -201,7 +201,6 @@ const {
removeRow,
removeSelected,
onNodeMove,
onNodeAddSub,
selectRow,
selectField,
onFieldDrop,
@@ -258,11 +257,54 @@ function onBandDrop({ gid, line }) {
commitBandDrag(gid, line)
}
/* ================= 新建分组(编码手填,创建后不可修改) ================= */
// 弹窗上下文:null 即关闭;parentGid 非空表示新建子分组
const groupCreate = ref(null)
const groupCreateOpen = computed({
get: () => groupCreate.value !== null,
set: (value) => {
if (!value) groupCreate.value = null
},
})
/** 本配置内已占用的分组编码(新建分组时判唯一性) */
const groupIdList = computed(() => (groups.value || []).map((group) => String(group.b_id)))
function openGroupCreate(parentGid = null) {
if (props.loading) return
const gid = parentGid == null ? null : String(parentGid)
const band = gid ? bandMeta.value.metaMap.get(gid) : null
if (gid && !band) return // 目标父分组已不存在:不打开弹窗
groupCreate.value = {
parentGid: gid,
parentLabel: band ? band.group.b_title || `分组-${gid}` : '',
}
}
/** 子分组入口:右键与属性面板传 band 元信息,结构树事件传 gid 字符串 */
function openSubGroupCreate(gidOrBand) {
const gid = gidOrBand && typeof gidOrBand === 'object' ? gidOrBand.gid : gidOrBand
if (gid == null) return
openGroupCreate(gid)
}
function onGroupCreateConfirm({ id, title }) {
const parentGid = groupCreate.value?.parentGid ?? null
if (parentGid) addSubGroup(parentGid, { id, title })
else addRootGroup({ id, title })
groupCreate.value = null
}
/** 结构树「新增子分组」:节点 key 形如 g:<gid> */
function onNodeAddSubRequest({ key }) {
if (props.loading || !key?.startsWith('g:') || key === 'g:ungrouped') return
openSubGroupCreate(key.slice(2))
}
function onStructureContextMenuClick({ item, node }) {
if (node?.kind === 'group') {
const band = bandMeta.value.metaMap.get(String(node.gid))
if (!band) return
if (item.key === 'add-sub') addSubGroup(band)
if (item.key === 'add-sub') openSubGroupCreate(band.gid)
else if (item.key === 'rename') structureTreeRef.value?.startRename(String(node.gid))
else if (item.key === 'enable') {
editing.selectBand(band)
@@ -470,7 +512,7 @@ const treeFeatures = computed(() => ({
</Segmented>
</div>
<div class="mm-edit__head-right">
<Button type="outline" :disabled="loading" @click="addRootGroup">
<Button type="outline" :disabled="loading" @click="openGroupCreate()">
<Plus :size="14" /> 添加分组
</Button>
<Button
@@ -546,7 +588,7 @@ const treeFeatures = computed(() => ({
@rename-commit="onRenameCommit"
@node-move="onNodeMove"
@node-delete="onNodeDelete"
@node-add-sub="onNodeAddSub"
@node-add-sub="onNodeAddSubRequest"
>
<template #empty>
<span class="mm-fdesign__tree-empty">暂无分组,点击工具栏「添加分组」</span>
@@ -570,7 +612,7 @@ const treeFeatures = computed(() => ({
@band-title-change="onBandTitleChange"
@toggle-band-enable="toggleBandEnable"
@remove-selected="onRemoveSelected"
@add-sub-group="addSubGroup(selectedBand)"
@add-sub-group="openSubGroupCreate(selectedBand)"
@rename="structureTreeRef?.startRename(selectedBand?.gid)"
@delete-band="deleteBand(selectedBand)"
/>
@@ -590,6 +632,16 @@ const treeFeatures = computed(() => ({
@change="() => (rows = localRows)"
@row-order-change="onRowOrderChange"
/>
<!-- ================= 新建分组(编码手填,创建后不可修改) ================= -->
<GroupCreateModal
v-model:open="groupCreateOpen"
:parent-label="groupCreate?.parentLabel || ''"
:existing-ids="groupIdList"
:reserved-ids="reservedGroupIds"
:loading="loading"
@confirm="onGroupCreateConfirm"
/>
</div>
</template>
@@ -0,0 +1,111 @@
<script setup>
/**
* 新建分组弹窗(表单设计器 FormDesignEditor 与列表设计器 TableDesignEditor 共用)。
*
* 分组节点 id 是分组的稳定身份:多语言资源键 group.{模块编码}.{schema 类型}.{id} 由它派生,
* 因此编码在新建时确定、创建后不可修改——本弹窗只在新建路径打开,不提供编辑入口。
*
* 同配置内唯一性必须在这里拦住:id 重复时 normalizeGroupIds 会把后来者静默改号,
* 用户填的编码会被悄悄替换、其多语言键随之漂移;设计器内部以 b_id 为 Map 键也会互相覆盖。
*/
import { computed, reactive, ref, watch } from 'vue'
import { Button, Form, FormItem, Input, Modal } from '@/components/ui'
import { validateGroupCode } from '@/utils/schemaJson'
const props = defineProps({
open: { type: Boolean, default: false },
/** 父分组名:非空表示新建子分组(仅影响标题与提示文案) */
parentLabel: { type: String, default: '' },
/** 本配置内已占用的分组编码 */
existingIds: { type: Array, default: () => [] },
/** 多语言资源里仍留有记录的编码:复用会连带继承已删除分组的残留译文 */
reservedIds: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['update:open', 'confirm'])
const formRef = ref(null)
const formModel = reactive({ code: '', title: '' })
const modalTitle = computed(() => (props.parentLabel ? '新增子分组' : '新增分组'))
// validator 直接复用纯函数结果(true 通过 / 非空字符串为错误文案);
// 用 computed 保证弹窗打开期间新增分组导致编码冲突时校验规则同步更新
const rules = computed(() => ({
code: [
{
validator: (value) =>
validateGroupCode(value, {
existingIds: props.existingIds,
reservedIds: props.reservedIds,
}),
},
],
}))
// 每次打开重置:不沿用上一次的输入
watch(
() => props.open,
(open) => {
if (!open) return
formModel.code = ''
formModel.title = ''
},
)
function close() {
emit('update:open', false)
}
async function onOk() {
if (props.loading || !formRef.value) return
const errors = await formRef.value.validateAll()
if (errors.length) return // 校验不通过:留在弹窗里改
emit('confirm', { id: formModel.code.trim(), title: formModel.title.trim() })
close()
}
</script>
<template>
<Modal
:open="open"
:title="modalTitle"
:width="480"
centered
:resizable="false"
:fullscreen="false"
@update:open="(value) => emit('update:open', value)"
@ok="onOk"
>
<Form ref="formRef" :model="formModel" :rules="rules" :columns="1">
<FormItem
label="分组编码"
name="code"
required
extra="小写字母开头,可含小写字母、数字与下划线,创建后不可修改"
>
<Input v-model="formModel.code" placeholder="如 basic_info" @keydown.enter.prevent="onOk" />
</FormItem>
<FormItem label="分组名称" name="title">
<Input v-model="formModel.title" maxlength="100" placeholder="可留空,创建后立即命名" />
</FormItem>
</Form>
<p v-if="parentLabel" class="gcm-parent">父分组:{{ parentLabel }}</p>
<template #footer>
<Button @click="close">取消</Button>
<Button type="primary" :disabled="loading" @click="onOk">确定</Button>
</template>
</Modal>
</template>
<style lang="scss" scoped>
.gcm-parent {
margin: 4px 0 0;
color: var(--fms-text-secondary);
font-size: 12px;
}
</style>
@@ -20,7 +20,6 @@
*/
import { computed, nextTick, ref, watch } from 'vue'
import { Message } from '@/components/ui'
import { nextGroupId } from '@/utils/schemaJson'
import { createTempId } from '@/utils/common'
/* ============================================================
@@ -399,8 +398,6 @@ const DEFAULT_CLIP_KEYS = [
* @param {(groups: Array) => void} opts.applyGroups
* @param {(rows: Array) => void} opts.applyRows
* @param {(gid: string) => void} [opts.requestRename] 新建分组后请求结构树进入重命名
* @param {() => string[]} [opts.reservedGroupIds] 额外保留的分组编号(多语言表里已存在的
* 分组键编号),避免新建分组捡到已删除分组的编号而继承其残留译文
* @param {string[]} [opts.clipboardKeys] 列属性复制粘贴键集
* @param {(row: object) => string} [opts.fieldNameOf] 剪贴板条目命名(useFieldLookup 的 fieldName)
* @param {(fieldId: string) => object|null} [opts.getField] 字段库拖入的字段解析(fieldMap.get)
@@ -414,7 +411,6 @@ export function useStructureEditing({
applyGroups,
applyRows,
requestRename = () => {},
reservedGroupIds = () => [],
clipboardKeys = DEFAULT_CLIP_KEYS,
fieldNameOf = (row) => String(row?.b_field || ''),
getField = null,
@@ -533,42 +529,41 @@ export function useStructureEditing({
return (getGroups() || []).reduce((m, g) => Math.max(m, Number(g.b_xh || 0)), 0) + 10
}
/** 新分组编号:避开本配置已有编号 + 宿主保留编号(多语言表里已存在的分组键) */
function nextGroupCode() {
return nextGroupId([
...(getGroups() || []).map((group) => group.b_id),
...(reservedGroupIds() || []),
])
/** 分组编码由宿主弹窗手填(创建后不可修改),此处只负责把它落成草稿分组 */
function addRootGroup({ id, title } = {}) {
return createBand({ id, title, parentGid: null })
}
function addRootGroup() {
function addSubGroup(parentGid, { id, title } = {}) {
const gid = parentGid == null ? null : String(parentGid)
if (!gid || !bandMeta.value.metaMap.has(gid)) return // 父分组已被删除:静默放弃
return createBand({ id, title, parentGid: gid })
}
function createBand({ id, title, parentGid }) {
if (loading()) return
const groupId = String(id ?? '').trim()
if (!groupId) return // 编码必填:空编码不落草稿,避免被归一化静默改号
const newGroup = {
b_id: nextGroupCode(),
b_id: groupId,
b_module_id: moduleId(),
b_parent_id: null,
b_title: '',
b_parent_id: parentGid,
b_title: String(title ?? '').trim(),
b_xh: nextGroupXh(),
b_canuse: 1,
b_colspan: 48,
}
emitGroupsRenumbered([...(getGroups() || []), newGroup])
focusNewBandTitle(newGroup.b_id)
// 已填名称:直接完成(仅选中);未填:沿用原有「创建后立即内联命名」
if (newGroup.b_title) selectBandAfterCreate(groupId)
else focusNewBandTitle(groupId)
}
function addSubGroup(band) {
if (!band || loading()) return
const newGroup = {
b_id: nextGroupCode(),
b_module_id: moduleId(),
b_parent_id: String(band.gid),
b_title: '',
b_xh: nextGroupXh(),
b_canuse: 1,
b_colspan: 48,
}
emitGroupsRenumbered([...(getGroups() || []), newGroup])
focusNewBandTitle(newGroup.b_id)
function selectBandAfterCreate(gid) {
nextTick(() => {
if (!bandMeta.value.metaMap.has(String(gid))) return
selectedNodeKey.value = `g:${gid}`
})
}
function focusNewBandTitle(gid) {
@@ -775,11 +770,6 @@ export function useStructureEditing({
if (band) deleteBand(band)
}
}
function onNodeAddSub({ key }) {
if (loading() || !key || !key.startsWith('g:') || key === 'g:ungrouped') return
const band = bandMeta.value.metaMap.get(key.slice(2))
if (band) addSubGroup(band)
}
/* ================= 字段定位/外部拖入(字段库) ================= */
function scrollToRowCell(fieldId) {
@@ -999,7 +989,6 @@ export function useStructureEditing({
// 树节点常驻操作
onNodeMove,
onNodeDelete,
onNodeAddSub,
// 字段定位
selectField,
scrollToRowCell,
@@ -1,6 +1,11 @@
import { Database, Folder, FolderPlus, Search } from '@lucide/vue'
import { createTempId } from '@/utils/common'
import { diffRows } from '@/utils/dataChanges'
import {
buildActionPowerCode,
buildModulePowerCode,
isStandardModuleAction,
} from '@/utils/powerCodes'
import { buildGroupI18nKey } from '@/utils/schemaJson'
/** 模块类型(s_module.b_module_type):module 模块 / data 数据模块 / virtual 虚拟模块 */
@@ -79,17 +84,15 @@ export function buildModuleI18nKey(moduleCode) {
/* ================= 权限点(s_power,见设计文档第 14 节) ================= */
/** 标准模块操作:动作属于这些值时归入模块使用权限,其余一律按业务动作 action */
export const MODULE_ACTIONS = ['read', 'create', 'update', 'delete', 'export']
/** 模块使用权限的固定动作(s_power.b_action):整模块一个使用权限点,不再按标准操作拆分 */
export const MODULE_ACCESS_ACTION = 'access'
/**
* 编码规则与标准动作口径统一在 @/utils/powerCodes:权限点维护(本目录)与授权校验
* (stores/permissions)共用同一份定义。这里只保留维护界面需要的派生函数与兼容导出。
*/
export { MODULE_ACCESS_ACTION, STANDARD_MODULE_ACTIONS as MODULE_ACTIONS } from '@/utils/powerCodes'
/** 权限点性质由动作派生:模块使用(access / 标准操作)→ module,其它业务动作 → action */
export function typeOfAction(action) {
const value = String(action || '').trim()
if (value === MODULE_ACCESS_ACTION || MODULE_ACTIONS.includes(value)) return 'module'
return 'action'
return isStandardModuleAction(action) ? 'module' : 'action'
}
/**
@@ -125,14 +128,15 @@ export function buildPowerI18nKey(action, moduleCode) {
/**
* 权限编码派生:模块使用权限为 module.{模块编码}(不带动作后缀);业务动作为
* action.{模块编码}.{动作}。编辑器与保存前物化共用——派生列不在加载阶段写回草稿。
* 编码规则本体在 @/utils/powerCodes(与授权校验共用),这里保持 '' 的历史回退值。
*/
export function buildPowerCode(action, moduleId) {
const normalizedAction = String(action || '').trim()
const objectId = String(moduleId || '').trim()
if (!normalizedAction || !objectId) return ''
return typeOfAction(normalizedAction) === 'module'
? buildModuleI18nKey(objectId)
: `action.${objectId}.${normalizedAction}`
return isStandardModuleAction(normalizedAction)
? buildModulePowerCode(objectId) || ''
: buildActionPowerCode(objectId, normalizedAction) || ''
}
/**
@@ -233,7 +237,8 @@ function collectSchemaGroupItems(moduleCode, schemas) {
for (const { schemaType, schema } of schemas || []) {
if (!schema) continue
const visit = (node) => {
const key = String(node.i18n || '').trim() || buildGroupI18nKey(moduleCode, schemaType, node.id)
const key =
String(node.i18n || '').trim() || buildGroupI18nKey(moduleCode, schemaType, node.id)
if (!key || seen.has(key)) return
seen.add(key)
items.push({ key, managed: true, defaultValue: node.title || '', id: `group:${key}` })
@@ -328,7 +333,12 @@ export function reconcileI18nRows(pivotRows, items, { defaultLocale, languages }
}
const previousDefault = row._defaultValue
const nextDefault = item.defaultValue == null ? '' : String(item.defaultValue)
if (defaultLocale && (row[defaultLocale] == null || row[defaultLocale] === '' || row[defaultLocale] === previousDefault)) {
if (
defaultLocale &&
(row[defaultLocale] == null ||
row[defaultLocale] === '' ||
row[defaultLocale] === previousDefault)
) {
row[defaultLocale] = nextDefault
}
row.b_key = String(item.key)
@@ -365,7 +375,8 @@ export function reconcileI18nRows(pivotRows, items, { defaultLocale, languages }
for (const locale of row._persistedLocales || []) hit._persistedLocales?.add(locale)
if (row._managedId && !hit._managedId) hit._managedId = row._managedId
if (row._syncInjected && !hit._syncInjected) hit._syncInjected = row._syncInjected
if (row._defaultValue != null && hit._defaultValue == null) hit._defaultValue = row._defaultValue
if (row._defaultValue != null && hit._defaultValue == null)
hit._defaultValue = row._defaultValue
}
return mergedRows
}
@@ -6,13 +6,15 @@
* s_user_field_power 字段权限(按模块 × 字段的 查看 / 编辑 / 查询 / 导出)
* s_user_data_power 数据范围(按模块 × 操作的范围类型与条件)
*
* 布局:右侧 Drawer + 顶部页签,三个页签分别承载三张表。
* 布局:右侧 Drawer,头部只有一行页签(不渲染标题),关闭按钮由 ui/Drawer 固定在最右端。
* 账号级的「全授权 / 全禁用」在功能权限面板自己的工具栏里(见 UserPowerPanel),
* 不占头部,也不会在切到其它页签时仍显示。
* 页签用 nav-only(Tabs 只管导航),内容由下方 v-show 面板承载 —— 与模块管理的
* 配置区同一形态,切换页签不销毁已挂载的面板。
*
* 当前「功能权限」已接入;字段权限与数据范围仍为空面板。
*/
import { computed, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import { Button, Drawer, Message, Tabs } from '@/components/ui'
import { loadDataApi, saveObjectApi } from '@/services/api'
import UserPowerPanel from './UserPowerPanel.vue'
@@ -34,17 +36,6 @@ const tabItems = [
{ key: 'data', label: '数据范围' },
]
/* ---------- 标题 ---------- */
const userLabel = computed(() => {
const row = props.user
if (!row) return ''
const name = String(row.b_name || '').trim()
const code = String(row.b_id || '').trim()
return name ? `${name}(${code})` : code
})
const title = computed(() => (userLabel.value ? `分配权限 - ${userLabel.value}` : '分配权限'))
/* ================= 功能权限:加载 ================= */
/** 安全 SQL 字符串字面量(search_condition 由后端拼 SQL,需转义单引号) */
function sqlText(value) {
@@ -62,6 +53,8 @@ const menuModules = ref({})
const moduleById = ref(new Map())
/** 模块编码 → 权限点数组(s_power) */
const powerByModule = ref({})
/** 全部启用的权限点编码(s_power 全量,含 menu.*),供面板「全授权」一次勾满 */
const allPowerIds = ref([])
/** 已勾选的权限编码(s_user_power.b_power_id),写入基线用于保存 diff */
const checkedPowerIds = ref([])
@@ -108,15 +101,23 @@ async function loadPowerData() {
}
menuModules.value = byMenu
// s_power → { [b_object_id]: [权限点] },按 b_xh 排序(已由 order_by 保证)
// s_power → { [模块编码]: [权限点] },按 b_xh 排序(已由 order_by 保证)。
// 只收模块(含业务动作)权限点:菜单入口权限点(b_object_type='menu',b_object_id 是菜单编码)
// 由面板的「菜单访问」分区单独渲染;混进来时若菜单编码与模块编码重名会串组。
// 同时留一份全量编码(含 menu.*),供面板工具条「全授权」一次勾满。
const byModule = {}
const powerIds = []
for (const row of powerRes.data || []) {
const powerId = String(row.b_id || '')
if (powerId) powerIds.push(powerId)
if (String(row.b_object_type || '') !== 'module') continue
const objectId = String(row.b_object_id || '')
if (!objectId) continue
if (!byModule[objectId]) byModule[objectId] = []
byModule[objectId].push(row)
}
powerByModule.value = byModule
allPowerIds.value = powerIds
const granted = (grantedRes.data || [])
.filter((row) => Number(row.b_canuse) === 1)
@@ -128,6 +129,7 @@ async function loadPowerData() {
menuTree.value = []
menuModules.value = {}
powerByModule.value = {}
allPowerIds.value = []
checkedPowerIds.value = []
checkedBaseline = new Set()
} finally {
@@ -196,14 +198,15 @@ async function save() {
<template>
<Drawer
:open="open"
:title="title"
placement="right"
width="min(900px, 96%)"
:mask-closable="false"
@update:open="emit('update:open', $event)"
@cancel="close"
>
<div class="power-drawer">
<!-- 头部只有页签这一行(不渲染标题):关闭按钮由 ui/Drawer 固定在最右端;
账号级批量操作(全授权 / 全禁用)在功能权限面板自己的工具栏里 -->
<template #extra>
<Tabs
v-model="activeTab"
nav-only
@@ -211,7 +214,9 @@ async function save() {
:items="tabItems"
aria-label="权限分类"
/>
</template>
<div class="power-drawer">
<div class="power-drawer__body">
<div v-show="activeTab === 'power'" class="power-pane" data-pane="power">
<UserPowerPanel
@@ -220,6 +225,7 @@ async function save() {
:menu-modules="menuModules"
:module-by-id="moduleById"
:power-by-module="powerByModule"
:all-power-ids="allPowerIds"
:loading="loading"
/>
</div>
@@ -238,7 +244,7 @@ async function save() {
</template>
<style lang="scss" scoped>
/* 抽屉内容区纵向铺满:页签固定高度,面板区自行滚动。
/* 抽屉内容区纵向铺满:面板区自行滚动。
body 的 padding 已由 :has() 规则清零(左右两栏要贴边滚动),间距在这里补回 */
.power-drawer {
display: flex;
@@ -248,8 +254,9 @@ async function save() {
padding: 12px 20px;
}
/* 页签占满头部剩余宽度:标签栏的底线正好充当头部与内容的分隔线 */
.power-drawer__nav {
flex: none;
min-width: 0;
}
.power-drawer__body {
@@ -277,11 +284,24 @@ async function save() {
配合 :has() 定位本抽屉的面板(与 FmsFileModal / ui/select 同一做法)。
只有本抽屉的内容才会渲染 .power-drawer,其它抽屉不受影响。 -->
<style lang="scss">
/* 头部不画分割线:页签自带底线即本级导航的分隔,避免两条平行线叠在一起 */
.fms-drawer:has(.power-drawer) .fms-drawer-header {
/* 头部只服务页签一行:上下留白压到最小;底线由 ui/Drawer 的 header 承担
(整条贯穿,含关闭按钮下方),页签自带的底线去掉,避免两条平行线叠在一起 */
.fms-drawer:has(.power-drawer) {
--fms-drawer-header-padding: 6px 20px 0;
}
.fms-drawer:has(.power-drawer) .fms-drawer-header .tabs-nav {
border-bottom: none;
}
/* 页签上移到头部后,标题组与附加区都要能撑满:标签栏才能占满整行、
底线成为完整的分隔线,批量按钮也才能被推到右端 */
.fms-drawer:has(.power-drawer) .fms-drawer-header-main,
.fms-drawer:has(.power-drawer) .fms-drawer-extra {
flex: 1;
min-width: 0;
}
/* 高度链起点:.fms-drawer-body 是普通块容器(ui Drawer 的 scoped 样式,改不了),
.power-drawer 的 flex:1 在块容器里不生效、高度塌成 auto,导致面板 height:100% 全链失效。
这里把它变成 flex 容器,让 .power-drawer 能吃满剩余高度。 */
@@ -2,22 +2,32 @@
/**
* 功能权限面板(用户 → 分配权限 → 功能权限)。
*
* 三张表串起来的授权链路:
* 四张表串起来的授权链路(设计文档 §14.1 / §3.2:菜单为入口、模块为主):
* s_menu 左侧菜单树(b_parent_id 是权威父子关系)
* s_menu_module 选中菜单实际使用的模块清单
* s_power 每个模块的权限点(b_object_id = 模块编码)
* s_power 权限点(menu.* 菜单入口 / module.* 模块使用 / action.* 业务动作)
* s_user_power 勾选结果(用户 × 权限点,主键 b_user_id + b_power_id)
*
* 布局:左右两栏各占满高度、各自滚动。右侧是纵向列表(不用表格):
* 数据模块为一级,模块下的权限点为二级,逐组列下来。
* 布局:左右两栏各占满高度、各自滚动。右栏是两个分区:
* 分区一 = 当前菜单的入口权限(menu.{编码},一个开关);
* 分区二 = 该菜单绑定模块的权限点列表(模块使用开关 + 业务动作 chip)。
* 账号级的「全授权 / 全禁用」在抽屉头部(父级 UserPowerDrawer),不在这两个分区里。
*
* 两层的关系(《模块关系与权限边界重构计划》§3.2):进得了菜单不等于能用菜单里的模块,
* 所以授权仍分两层落库;但交互上入口跟随模块 —— 勾任一模块 / 动作自动补入口,
* 关闭入口则连带收回该菜单下的全部授权(与「模块使用 → 动作」同一联动方向)。
* 没绑定模块的页面 / 外链(自定义页、跳转页)只有入口一个授权单元,独立勾选。
*
* 数据契约:父级持有菜单树与模块清单,本组件只读展示;勾选结果经 v-model 双向绑定
* (选中权限编码数组),差异比对与保存由父级负责。
*/
import { computed, ref, watch } from 'vue'
import { Checkbox, Spin } from '@/components/ui'
import { computed, ref } from 'vue'
import { Check } from '@lucide/vue'
import { Button, Spin, Switch } from '@/components/ui'
import FmsTreePanel from '@/components/fms-tree/FmsTreePanel.vue'
import { buildSortedTree, filterTreeByText } from '@/utils/tree'
import { buildSortedTree, filterTreeByText, findTreeNode } from '@/utils/tree'
import { MODULE_ACCESS_ACTION } from '@/utils/powerCodes'
import { translate } from '@/stores/i18n'
const props = defineProps({
/** 菜单树(s_menu 全量),父级加载 */
@@ -28,6 +38,8 @@ const props = defineProps({
moduleById: { type: Object, default: () => new Map() },
/** 模块编码 → 该模块的权限点数组(s_power),父级加载 */
powerByModule: { type: Object, default: () => ({}) },
/** 全部启用的权限点编码(s_power 全量):供工具栏「全授权」一次勾满 */
allPowerIds: { type: Array, default: () => [] },
/** 树与数据加载中 */
loading: { type: Boolean, default: false },
})
@@ -37,17 +49,67 @@ const checkedPowerIds = defineModel({ type: Array, default: () => [] })
/* ---------- 左侧菜单树 ---------- */
const keyword = ref('')
/**
* 打开时只展开「全部分类」虚拟根(由 FmsTree 自己补 rootKey,不必写在这里),
* 业务目录保持收起;也不默认选中任何菜单(selectedMenuId 为空 = 选中虚拟根),
* 右侧提示「请选择左侧菜单」,由用户自己挑要配权限的菜单。
*/
const expandedKeys = ref([])
const selectedMenuId = ref('')
const treeData = computed(() => buildSortedTree(props.menuTree))
const filteredTree = computed(() => filterTreeByText(treeData.value, keyword.value, ['b_name', 'b_id']))
const filteredTree = computed(() =>
filterTreeByText(treeData.value, keyword.value, ['b_name', 'b_id']),
)
function onMenuSelect(keys) {
selectedMenuId.value = keys.length ? String(keys[0]) : ''
/**
* 菜单树选中。
*
* 传入的 keys 为空有两种来源,必须分开处理(FmsTree 会把空选中键归一化成虚拟根高亮):
* - 点中「全部分类」虚拟根(info.node.isFmsRoot):回到「全部」,清空选中;
* - 重复点击已选中的菜单:ui/Tree 的单选语义是「再点一次 = 取消选中」,
* 但本面板「选中一个菜单」是右侧内容的前提,没有「取消选中」这一说;
* 不忽略的话,高亮会从该菜单跳回「全部分类」。
*/
function onMenuSelect(keys, info) {
if (info?.node?.isFmsRoot) {
selectedMenuId.value = ''
return
}
if (!keys.length) return
selectedMenuId.value = String(keys[0])
}
/* ---------- 右侧:数据一级(模块)+ 权限二级(权限点) ---------- */
/* ---------- 右侧:菜单访问权限(根节点)+ 属下的模块与权限点 ---------- */
/**
* 当前菜单的入口权限编码:menu.{菜单编码}(s_power 中 b_object_type = 'menu',
* 与 s_menu 由菜单管理自动派生,见 menu-management/utils.buildMenuPowerRow)。
* 它只回答「能否进入这个菜单页面」,与下面模块的权限相互独立。
*/
const menuPowerCode = computed(() => (selectedMenuId.value ? `menu.${selectedMenuId.value}` : ''))
/** 当前菜单节点 */
const menuNode = computed(() =>
selectedMenuId.value ? findTreeNode(treeData.value, selectedMenuId.value) : null,
)
/** 菜单名:与左侧树一致按 b_i18n 翻译,名称缺失回退编码 */
const menuName = computed(() => {
const node = menuNode.value
if (!node) return String(selectedMenuId.value || '')
return translate(node.b_i18n, node.b_name || node.b_id || '')
})
/** 菜单类型:directory 目录 / page 页面 / external 外链 */
const menuType = computed(() => String(menuNode.value?.b_menu_type || ''))
/**
* 能否单独授权入口:只有页面 / 外链在 s_power 里派生 menu.{编码}
* (menu-management 的 MENU_POWER_MENU_TYPES),目录只是分组、没有权限点 ——
* 目录若渲染开关,勾上会往 s_user_power 写一条 s_power 里不存在的编码。
*/
const canAuthorizeEntry = computed(() => menuType.value === 'page' || menuType.value === 'external')
/** 当前菜单绑定的模块编码(s_menu_module.b_xh 排序由父级的 order_by 保证) */
const boundModuleIds = computed(() => {
@@ -62,119 +124,300 @@ function isChecked(powerId) {
return checkedSet.value.has(String(powerId))
}
/** 勾选 / 取消单个权限点 */
function togglePower(powerId) {
const id = String(powerId)
const next = checkedPowerIds.value.map(String)
const index = next.indexOf(id)
if (index >= 0) next.splice(index, 1)
else next.push(id)
checkedPowerIds.value = next
}
/** 整组(整模块)全选 / 全不选 */
/** 整组(整模块)全选 / 全不选;全选时同样联动补上菜单入口 */
function toggleModule(row) {
const ids = row.powers.map((power) => String(power.b_id))
const next = checkedPowerIds.value.map(String).filter((id) => !ids.includes(id))
if (!row.allChecked) next.push(...ids)
if (!row.allChecked) {
next.push(...ids)
ensureMenuEntry(next)
}
checkedPowerIds.value = next
}
/** 分组行:一级 = 模块(含全选状态统计),二级 = 权限点 */
/**
* 模块使用开关(保护性单向联动,只保证不出现「有动作权限、没有模块权限」的死配置):
* - 打开:开模块本身,并顺带补上菜单入口(入口跟随模块,见 ensureMenuEntry);
* - 关闭:连带清掉该模块下的操作权限(不然勾着也不生效);
* 动作权限由用户另外勾,要一键全给用行尾的「全选」。
*/
function toggleModuleAccess(group) {
const accessId = String(group.accessPower?.b_id || '')
if (!accessId) return
const next = checkedPowerIds.value.map(String)
const index = next.indexOf(accessId)
if (index < 0) {
next.push(accessId)
ensureMenuEntry(next)
checkedPowerIds.value = next
return
}
const actionIds = group.actionPowers.map((power) => String(power.b_id))
next.splice(index, 1)
checkedPowerIds.value = next.filter((id) => !actionIds.includes(id))
}
/**
* 操作权限 chip:勾上任意一个动作时,自动补上同模块的「模块使用」与菜单入口
* (动作以模块权限为前提、模块以入口为前提,否则是勾了不生效的死配置);
* 取消动作则不动模块开关 —— 可能只是不做这个动作,模块本身还要用。
*/
function toggleActionPower(group, power) {
const id = String(power.b_id)
const next = checkedPowerIds.value.map(String)
const index = next.indexOf(id)
if (index >= 0) {
next.splice(index, 1)
} else {
next.push(id)
const accessId = String(group.accessPower?.b_id || '')
if (accessId && !next.includes(accessId)) next.push(accessId)
ensureMenuEntry(next)
}
checkedPowerIds.value = next
}
/** 模块使用权限点(b_action = 'access'):每个模块最多一个,在行内呈现为开关 */
function isModuleAccessPower(power) {
return String(power?.b_action || '').trim() === MODULE_ACCESS_ACTION
}
/**
* 模块行:模块使用 + 它的操作权限。
*
* 一个模块的权限点分两类,界面上必须分得开(设计文档 §14.1 / §3.3):
* - 模块使用(b_action = 'access'):能不能用这个模块 —— 一个开关;
* - 操作权限(其余业务动作):能用它做哪些动作 —— 一组 chip。
* 只保留「配了权限点」的模块;没配权限点的模块直接不渲染,也不出空态文案。
*/
const groups = computed(() => {
if (!selectedMenuId.value) return []
return boundModuleIds.value.map((moduleId) => {
const module = props.moduleById.get?.(moduleId) || null
const powers = props.powerByModule[moduleId] || []
const checked = powers.filter((power) => checkedSet.value.has(String(power.b_id))).length
return {
moduleId,
moduleName: module?.b_name || moduleId,
powers,
checked,
allChecked: powers.length > 0 && checked === powers.length,
someChecked: checked > 0 && checked < powers.length,
}
})
return boundModuleIds.value
.map((moduleId) => {
const module = props.moduleById.get?.(moduleId) || null
const powers = props.powerByModule[moduleId] || []
const checked = powers.filter((power) => checkedSet.value.has(String(power.b_id))).length
return {
moduleId,
moduleName: translate(module?.b_i18n, module?.b_name || moduleId),
powers,
accessPower: powers.find(isModuleAccessPower) || null,
actionPowers: powers.filter((power) => !isModuleAccessPower(power)),
checked,
allChecked: powers.length > 0 && checked === powers.length,
}
})
.filter((group) => group.powers.length > 0)
})
/* ---------- 默认选中第一个 page 菜单,避免右侧空着 ---------- */
watch(
() => props.menuTree,
(list) => {
if (selectedMenuId.value || !list?.length) return
const firstPage = list.find((row) => row.b_menu_type === 'page')
if (firstPage) selectedMenuId.value = String(firstPage.b_id)
},
{ immediate: true },
/* ---------- 入口联动与分区批量 ---------- */
/** 当前菜单下所有模块的权限点编码(关入口 / 分区清空时连带收回) */
const allMenuPowerIds = computed(() =>
groups.value.flatMap((group) => group.powers.map((power) => String(power.b_id))),
)
/**
* 入口联动:勾任一模块 / 动作时,把该菜单的入口权限一并补上。
*
* 入口是侧栏可见性与路由拦截的白名单(stores/menus 按 menu.* 裁剪),
* 只勾模块不勾入口 = 用户登录后看不到菜单、模块权限形同虚设 ——
* 这正是「菜单和模块都要手动选」的繁琐来源,所以由模块侧自动补齐。
* 目录没有入口权限点(MENU_POWER_MENU_TYPES 只含 page / external),不补。
*/
function ensureMenuEntry(ids) {
if (!canAuthorizeEntry.value) return
const entry = menuPowerCode.value
if (entry && !ids.includes(entry)) ids.push(entry)
}
/**
* 「菜单访问」分区的入口开关。与「模块使用 → 动作」同构的保护性联动:
* - 打开:只补入口本身(可以先开入口、再慢慢配模块);
* - 关闭:连带收回该菜单下所有模块与动作权限 —— 入口关了这些授权都不生效,
* 留着只会变成看不见的僵尸数据,也避免重开时误以为「已经清过了」。
*/
function toggleMenuAccess() {
const entry = menuPowerCode.value
if (!entry) return
const next = checkedPowerIds.value.map(String)
const index = next.indexOf(entry)
if (index < 0) {
next.push(entry)
checkedPowerIds.value = next
return
}
const scoped = allMenuPowerIds.value
next.splice(index, 1)
checkedPowerIds.value = next.filter((id) => !scoped.includes(id))
}
/* ---------- 账号级批量:全授权 / 全禁用 ---------- */
/**
* 作用于该账号的**全部功能权限**(s_power 里所有启用权限点,含所有菜单入口、
* 模块与动作),与左侧树选中哪一部分无关;只改勾选状态,落库由父级保存统一提交。
*/
function grantAll() {
checkedPowerIds.value = props.allPowerIds.map(String)
}
function disableAll() {
checkedPowerIds.value = []
}
</script>
<template>
<div class="power-panel">
<!-- 左:菜单树(占满高度,FmsTreePanel 内部自带滚动区) -->
<div class="power-panel__tree">
<FmsTreePanel
v-model:keyword="keyword"
v-model:expanded-keys="expandedKeys"
placeholder="搜索菜单"
show-root
:tree-data="filteredTree"
:loading="loading"
:selected-keys="selectedMenuId ? [selectedMenuId] : []"
@select="onMenuSelect"
/>
<!-- 工具栏:账号级批量操作(全授权 / 全禁用),作用于该账号的全部功能权限,
与「选中哪个菜单」无关,因此横跨面板放在两栏之上 -->
<div class="power-toolbar">
<span class="power-toolbar__label">该账号的全部功能权限</span>
<!-- 加载中禁用:权限点还没到位,此时「全授权」会勾不满 -->
<div class="power-toolbar__actions">
<Button type="outline" :disabled="loading" @click="grantAll">全授权</Button>
<Button type="outline" :disabled="loading" @click="disableAll">全禁用</Button>
</div>
</div>
<!-- 右:数据一级 / 权限二级 纵向列表(占满高度,本栏自己滚动) -->
<div class="power-panel__main">
<div v-if="loading" class="power-panel__state"><Spin /></div>
<div class="power-panel__body">
<!-- 左:菜单树(占满高度,FmsTreePanel 内部自带滚动区) -->
<div class="power-panel__tree">
<FmsTreePanel
v-model:keyword="keyword"
v-model:expanded-keys="expandedKeys"
placeholder="搜索菜单"
show-root
:tree-data="filteredTree"
:loading="loading"
:selected-keys="selectedMenuId ? [selectedMenuId] : []"
@select="onMenuSelect"
/>
</div>
<div v-else-if="!selectedMenuId" class="power-panel__state">请选择左侧菜单</div>
<!-- 右:菜单访问 + 模块权限(占满高度,本栏自己滚动) -->
<div class="power-panel__main">
<div v-if="loading" class="power-panel__state"><Spin /></div>
<div v-else-if="!groups.length" class="power-panel__state">该菜单未绑定模块</div>
<div v-else-if="!selectedMenuId" class="power-panel__state">请选择左侧菜单</div>
<div v-else class="power-panel__list">
<section v-for="group in groups" :key="group.moduleId" class="power-group">
<!-- 一级:数据模块 -->
<div class="power-group__head">
<Checkbox
:model-value="group.allChecked"
:indeterminate="group.someChecked"
:aria-label="`${group.moduleName} 全选`"
@change="toggleModule(group)"
/>
<span class="power-group__name" :title="group.moduleName">{{ group.moduleName }}</span>
<span class="power-group__count">{{ group.checked }}/{{ group.powers.length }}</span>
</div>
<div v-else class="power-panel__list">
<!-- 分区一:菜单入口(menu.{编码})—— 能否进入这个菜单页面;
目录只是导航分组(menu.* 只由页面 / 外链派生),没有可授权的入口 -->
<section class="power-section">
<h3 class="power-section__title">菜单访问</h3>
<div v-if="canAuthorizeEntry" class="power-row">
<span class="power-row__name" :title="menuName">{{ menuName }}</span>
<div class="power-row__access">
<Switch
:model-value="isChecked(menuPowerCode)"
:aria-label="`${menuName} 菜单访问`"
:title="
groups.length
? `${menuName} 菜单访问(关闭会收回该菜单下的全部授权)`
: `${menuName} 菜单访问`
"
@update:model-value="toggleMenuAccess"
/>
</div>
</div>
<p v-else class="power-panel__hint">
目录只是分组节点,可见性与子菜单一致,不需要单独授权。
</p>
</section>
<!-- 二级:权限点 -->
<div v-if="group.powers.length" class="power-group__items">
<Checkbox
v-for="power in group.powers"
:key="power.b_id"
:model-value="isChecked(power.b_id)"
:aria-label="String(power.b_name || power.b_id)"
@change="togglePower(power.b_id)"
>
{{ power.b_name || power.b_action }}
</Checkbox>
</div>
<div v-else class="power-group__empty">该模块未配置权限点</div>
</section>
<!-- 分区二:该菜单绑定模块的权限点(没配权限点的模块不渲染,也不出空态文案) -->
<section v-if="groups.length" class="power-section">
<h3 class="power-section__title">模块权限</h3>
<div v-for="group in groups" :key="group.moduleId" class="power-row">
<span class="power-row__name" :title="group.moduleName">{{ group.moduleName }}</span>
<!-- 模块使用(能不能用这个模块):开关,与「菜单访问」同一列 -->
<div v-if="group.accessPower" class="power-row__access">
<Switch
:model-value="isChecked(group.accessPower.b_id)"
:aria-label="`${group.moduleName} 模块使用`"
:title="`${group.moduleName} 模块使用`"
@update:model-value="toggleModuleAccess(group)"
/>
</div>
<!-- 操作权限(能用它做哪些动作):一组 chip;没有动作权限点时整列不渲染 -->
<div v-if="group.actionPowers.length" class="power-row__caps">
<button
v-for="power in group.actionPowers"
:key="power.b_id"
type="button"
class="power-chip"
:class="{ 'is-checked': isChecked(power.b_id) }"
role="checkbox"
:aria-checked="isChecked(power.b_id)"
:aria-label="String(power.b_name || power.b_id)"
@click="toggleActionPower(group, power)"
>
<Check
v-if="isChecked(power.b_id)"
:size="12"
:stroke-width="3"
aria-hidden="true"
/>
{{ translate(power.b_i18n, power.b_name || power.b_action) }}
</button>
</div>
<div class="power-row__tail">
<button
v-if="group.powers.length > 1"
type="button"
class="power-row__toggle"
:title="group.allChecked ? '清空该模块' : '全选该模块'"
@click="toggleModule(group)"
>
{{ group.allChecked ? '清空' : '全选' }}
</button>
<span class="power-row__count">{{ group.checked }}/{{ group.powers.length }}</span>
</div>
</div>
</section>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
/* 左右两栏各占满高度:左栏由 FmsTreePanel 内部滚动,右栏自己滚动 */
/* 面板纵向:顶部工具栏 + 下方左右两栏(两栏各占满高度、各自滚动) */
.power-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
/* 工具栏:账号级批量操作,横跨面板;与下方「选菜单 → 配模块」的内容区分开。
左右缩进与两栏内容对齐(左 4px 对齐树、右 12px 对齐列表) */
.power-toolbar {
display: flex;
flex: none;
align-items: center;
gap: 8px;
padding: 0 12px 10px 4px;
}
.power-toolbar__label {
color: var(--fms-text-secondary);
font-size: 12px;
}
.power-toolbar__actions {
display: flex;
gap: 8px;
margin-left: auto;
}
/* 左右两栏:工具栏之下的主体,左栏由 FmsTreePanel 内部滚动,右栏自己滚动 */
.power-panel__body {
display: flex;
flex: 1;
min-height: 0;
}
.power-panel__tree {
display: flex;
flex: none;
@@ -193,48 +436,160 @@ watch(
overflow-y: auto;
}
/* 分区之间留白,一个分区 = 一个授权维度(菜单访问 / 模块权限) */
.power-panel__list {
display: flex;
flex-direction: column;
gap: 4px;
gap: 18px;
padding: 4px 12px 16px 4px;
}
/* 一级组:模块行 */
.power-group__head {
.power-section {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0 2px;
flex-direction: column;
gap: 2px;
}
/* 分区标题:结构锚点,不是说明文案 */
.power-section__title {
margin: 0 0 6px;
padding: 0 8px;
color: var(--fms-text-secondary);
font-size: 12px;
font-weight: 600;
}
.power-group__name {
min-width: 0;
/* 说明文案:目录等「没有可授权项」的节点 */
.power-panel__hint {
margin: 0;
padding: 4px 8px;
color: var(--fms-text-secondary);
font-size: var(--fms-font-size);
}
/* 行式四列:资源名(定宽)| 使用开关(定宽成列)| 操作 chip | 行尾「全选 / 计数」。
开关列把「能不能用」抽成一条竖列,与右侧「能做什么」的 chip 天然分开;
显式声明各列位置,缺开关 / 缺操作时也不会串位。 */
.power-row {
display: grid;
grid-template-columns: 120px 48px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
min-height: 34px;
padding: 3px 8px;
border-radius: var(--fms-control-radius);
transition: background-color var(--fms-duration-fast) var(--fms-ease-standard);
}
.power-row:hover {
background: var(--fms-secondary-hover);
}
.power-row__name {
grid-column: 1;
overflow: hidden;
color: var(--fms-text);
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.power-group__count {
flex: none;
color: var(--fms-text-secondary);
font-size: 12px;
font-weight: 400;
}
/* 二级:权限点缩进挂在模块下 */
.power-group__items {
/* 使用开关列:与「菜单访问」的开关同一列,竖着扫一眼就知道哪些模块能用 */
.power-row__access {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
padding: 0 0 4px 26px;
grid-column: 2;
align-items: center;
}
.power-group__empty {
padding: 0 0 4px 26px;
/* 操作权限列:能用它做哪些动作 */
.power-row__caps {
display: flex;
grid-column: 3;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-width: 0;
}
/* 与左侧「使用」开关用浅分隔线分开 —— 只有该行确实有开关时才画 */
.power-row__access + .power-row__caps {
padding-left: 12px;
border-left: 1px solid var(--fms-border-soft);
}
/* 权限点 chip:勾选 = 主色淡底 + 描边 + 对勾;未勾选 = 灰描边。
权限点是「有没有这个能力」的开关,用 chip 比方框轻,也不与模块并列时抢视线 */
.power-chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: var(--fms-control-height);
padding: 0 10px;
border: 1px solid var(--fms-border);
border-radius: 999px;
background: var(--fms-card);
color: var(--fms-text-secondary);
font-family: inherit;
font-size: 12px;
cursor: pointer;
transition:
color var(--fms-duration-fast) var(--fms-ease-standard),
border-color var(--fms-duration-fast) var(--fms-ease-standard),
background-color var(--fms-duration-fast) var(--fms-ease-standard);
}
.power-chip:hover {
border-color: color-mix(in srgb, var(--fms-primary) 40%, var(--fms-border));
color: var(--fms-text);
}
.power-chip.is-checked {
border-color: color-mix(in srgb, var(--fms-primary) 45%, transparent);
background: var(--fms-primary-soft);
color: var(--fms-primary);
font-weight: 500;
}
.power-chip:focus-visible {
outline: 2px solid color-mix(in srgb, var(--fms-primary) 55%, transparent);
outline-offset: 1px;
}
/* 行尾:整模块全选 / 清空(hover 或键盘聚焦时出现)+ 已选计数 */
.power-row__tail {
display: flex;
grid-column: 4;
align-items: center;
gap: 6px;
}
.power-row__toggle {
min-width: 32px;
padding: 0;
border: none;
background: none;
color: var(--fms-primary);
font-family: inherit;
font-size: 12px;
cursor: pointer;
visibility: hidden;
}
.power-row:hover .power-row__toggle,
.power-row:focus-within .power-row__toggle {
visibility: visible;
}
.power-row__toggle:focus-visible {
outline: 2px solid color-mix(in srgb, var(--fms-primary) 55%, transparent);
outline-offset: 1px;
}
.power-row__count {
min-width: 26px;
color: var(--fms-text-secondary);
font-size: 12px;
text-align: right;
}
.power-panel__state {
+86 -41
View File
@@ -1,7 +1,12 @@
/**
* menus store(s_menu 动态菜单):按 menu.* 授权裁剪可见菜单 +
* 路由地址 → 菜单反查(路由守卫用它兜住手输地址)。
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import { useMenuStore } from '@/stores/menus'
import { usePermissionStore } from '@/stores/permissions'
import { createTestPinia } from './helpers/app.js'
// mock 后端数据接口,避免真实网络请求
vi.mock('@/services/api', () => ({
@@ -14,26 +19,38 @@ import { loadDataApi } from '@/services/api'
// 因此这里用业务菜单构造数据,验证菜单 store 本身无任何编码/账号特判。
const mockMenus = [
{
b_id: '1',
b_code: 'base',
b_id: 'base',
b_name: '基础数据',
b_menu_type: 'directory',
b_parent_id: null,
b_canuse: 1,
},
{
b_id: '2',
b_code: 'contact',
b_id: 'contact',
b_name: '往来单位',
b_menu_type: 'page',
b_parent_id: '1',
b_parent_id: 'base',
b_route: '/base/contact',
b_canuse: 1,
},
{
b_id: 'system',
b_name: '系统管理',
b_menu_type: 'directory',
b_parent_id: null,
b_canuse: 1,
},
{
b_id: 'user',
b_name: '用户管理',
b_menu_type: 'page',
b_parent_id: 'system',
b_route: '/system/user',
b_canuse: 1,
},
{
// 没有任何子项的目录:可见性裁剪后不应渲染
b_id: '3',
b_code: 'empty-dir',
b_id: 'empty-dir',
b_name: '空目录',
b_menu_type: 'directory',
b_parent_id: null,
@@ -41,14 +58,18 @@ const mockMenus = [
},
]
function mockSModuleData(menus) {
loadDataApi.mockImplementation((view) =>
Promise.resolve({
data: view === 's_menu' ? menus : [],
code: 0,
success: true,
}),
)
function mockData({ menus = mockMenus, powers = [], userPowers = [] } = {}) {
loadDataApi.mockImplementation((table) => {
const data =
table === 's_menu'
? menus
: table === 's_power'
? powers
: table === 's_user_power'
? userPowers
: []
return Promise.resolve({ data, code: 0, success: true })
})
}
function setUser(account, id) {
@@ -59,61 +80,64 @@ function setUser(account, id) {
/** 冲刷微任务:让 setSession 触发的 watch(clear) 先执行 */
const flush = () => Promise.resolve()
function collectCodes(nodes) {
const codes = []
function collectIds(nodes) {
const ids = []
for (const node of nodes) {
codes.push(String(node.b_code))
if (node.children) codes.push(...collectCodes(node.children))
ids.push(String(node.b_id))
if (node.children) ids.push(...collectIds(node.children))
}
return codes
return ids
}
beforeEach(() => {
setActivePinia(createPinia())
createTestPinia()
loadDataApi.mockReset()
})
describe('menus store(s_menu 动态菜单)', () => {
it('普通用户:全部启用菜单按树渲染,空目录剪枝(无编码特判)', async () => {
mockSModuleData(mockMenus)
it('普通用户:只渲染有 menu.* 授权的页面菜单,空目录剪枝', async () => {
mockData({
powers: [{ b_id: 'menu.contact' }, { b_id: 'menu.user' }],
// 只授权联系人:用户管理虽在 s_power 里,但用户没有该授权 → 系统管理目录随之消失
userPowers: [{ b_user_id: '123', b_power_id: 'menu.contact', b_canuse: 1 }],
})
setUser('u1', 123)
await flush()
const menus = useMenuStore()
await menus.load()
const codes = collectCodes(menus.visibleMenus)
expect(codes).toContain('base')
expect(codes).toContain('contact')
// 目录没有可见子菜单时不渲染
expect(codes).not.toContain('empty-dir')
const perm = usePermissionStore()
const menus = useMenuStore()
await Promise.all([perm.load(), menus.load()])
expect(collectIds(menus.visibleMenus)).toEqual(['base', 'contact'])
})
it('管理员特判不在菜单 store:g3soft 与普通用户渲染一致', async () => {
it('g3soft 提权:不查授权也全部可见(管理员不裁剪)', async () => {
// 模块中心的管控在路由守卫(meta.adminOnly)与侧栏渲染(仅 g3soft 追加固定入口),
// 菜单 store 对所有登录主体一视同仁
mockSModuleData(mockMenus)
// 菜单 store 对管理员只跳过授权裁剪,不做编码特判
mockData()
setUser('g3soft', 1)
await flush()
const menus = useMenuStore()
await menus.load()
const codes = collectCodes(menus.visibleMenus)
expect(codes).toHaveLength(2)
expect(codes).toEqual(expect.arrayContaining(['base', 'contact']))
const perm = usePermissionStore()
const menus = useMenuStore()
await Promise.all([perm.load(), menus.load()])
expect(collectIds(menus.visibleMenus)).toEqual(['base', 'contact', 'system', 'user'])
})
it('同一主体重复 load 不重复发请求', async () => {
mockSModuleData(mockMenus)
mockData()
setUser('u1', 123)
await flush()
const menus = useMenuStore()
await menus.load()
await menus.load()
// 只请求 s_menu(权限 store 未参与加载)
expect(loadDataApi).toHaveBeenCalledTimes(1)
})
it('切换账号:缓存清空并重新加载', async () => {
mockSModuleData(mockMenus)
mockData()
setUser('u1', 123)
await flush()
const menus = useMenuStore()
@@ -133,3 +157,24 @@ describe('menus store(s_menu 动态菜单)', () => {
expect(menus.rawMenus.length).toBe(mockMenus.length)
})
})
describe('menus store:路由反查(findMenuByRoute)', () => {
it('命中菜单路由与其子路径,非菜单路由返回 null', async () => {
mockData()
setUser('u1', 123)
await flush()
const menus = useMenuStore()
await menus.load()
expect(menus.findMenuByRoute('/base/contact')?.b_id).toBe('contact')
// 详情页等子路径同样算作该菜单的入口
expect(menus.findMenuByRoute('/base/contact/123')?.b_id).toBe('contact')
expect(menus.findMenuByRoute('/base/contact/?x=1')?.b_id).toBe('contact')
// 工作台、演示页、模块中心都不在 s_menu
expect(menus.findMenuByRoute('/dashboard')).toBeNull()
expect(menus.findMenuByRoute('/demo')).toBeNull()
expect(menus.findMenuByRoute('/module/module-management')).toBeNull()
// 目录没有路由,不会被匹配到
expect(menus.findMenuByRoute('/base')).toBeNull()
})
})
+101 -46
View File
@@ -1,7 +1,14 @@
/**
* permissions store(新表口径,设计文档 §14)。
*
* 数据来源:s_module(启用模块)+ s_power(启用权限点)+ s_user_power(用户授权)。
* 判断口径:白名单(无授权即无权限);标准操作由 module.* 统一表达,
* 业务动作走 action.{模块}.{动作};菜单入口走 menu.*。
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import { usePermissionStore } from '@/stores/permissions'
import { createTestPinia } from './helpers/app.js'
// mock 后端数据接口,避免真实网络请求
vi.mock('@/services/api', () => ({
@@ -11,58 +18,69 @@ vi.mock('@/services/api', () => ({
import { loadDataApi } from '@/services/api'
const mockModules = [
{ b_id: 'v_b_contact', b_name: '联系人', b_module_type: 'data', b_parent_id: 'base' },
]
const mockPowers = [
{ b_id: 'menu.contact', b_type: 'menu', b_object_type: 'menu', b_object_id: 'contact' },
{
b_id: '1',
b_code: 'm1',
b_name: 'M1',
b_canuse: 1,
b_canmenu: 1,
b_module_type: 'directory',
b_parent_id: null,
b_icon: '',
b_route: '',
b_id: 'module.v_b_contact',
b_type: 'module',
b_object_type: 'module',
b_object_id: 'v_b_contact',
},
{
b_id: 'action.v_b_contact.audit',
b_type: 'action',
b_object_type: 'module',
b_object_id: 'v_b_contact',
},
]
const mockUserModules = [{ b_id: '1', b_module_id: '1', b_user_id: '123' }]
const mockUserPowers = []
const mockPowers = []
function mockApiResponses() {
loadDataApi.mockImplementation((view) => {
const data =
view === 's_module'
? mockModules
: view === 'b_user_module'
? mockUserModules
: view === 'b_user_power'
? mockUserPowers
: view === 's_module_power'
? mockPowers
: []
return Promise.resolve({ data, code: 0, success: true })
})
/** 用户 123:能进联系人菜单、能用联系人模块,但没有审核动作权限 */
const mockUserPowers = [
{ b_user_id: '123', b_power_id: 'menu.contact', b_canuse: 1 },
{ b_user_id: '123', b_power_id: 'module.v_b_contact', b_canuse: 1 },
// s_power 中不存在的权限点不算数(停用/已删除)
{ b_user_id: '123', b_power_id: 'module.b_money', b_canuse: 1 },
]
function mockApiResponses(overrides = {}) {
const dataByTable = {
s_module: mockModules,
s_power: mockPowers,
s_user_power: mockUserPowers,
...overrides,
}
loadDataApi.mockImplementation((table) =>
Promise.resolve({ data: dataByTable[table] || [], code: 0, success: true }),
)
}
function setLoggedInUser() {
function setLoggedInUser(account = 'u1', id = 123) {
const auth = useAuthStore()
auth.setSession({ token: 't', user: { id: 123, account: 'u1', name: 'U' } }, 'ORG')
auth.setSession({ token: 't', user: { id, account, name: 'U' } }, 'ORG')
}
// 冲刷微任务:让 setSession 触发的 watch(clear) 先执行,隔离“手动 clear”的断言
const flush = () => Promise.resolve()
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
sessionStorage.clear()
createTestPinia()
loadDataApi.mockReset()
})
describe('permissions store', () => {
it('load 成功后写入 modules 且记录 loadedPrincipalKey', async () => {
describe('permissions store:加载', () => {
it('load 成功后写入 modules 与授权码,并记录 loadedPrincipalKey', async () => {
mockApiResponses()
setLoggedInUser()
const perm = usePermissionStore()
await perm.load()
expect(perm.modules.length).toBe(1)
// s_power 中不存在的授权被过滤
expect(perm.grantedPowerCodes).toEqual(['menu.contact', 'module.v_b_contact'])
})
it('同一主体重复 load 不重复发请求(按主体去重)', async () => {
@@ -71,8 +89,8 @@ describe('permissions store', () => {
const perm = usePermissionStore()
await perm.load()
await perm.load()
// 首次发 4 个接口调用;第二次因主体已加载直接 return
expect(loadDataApi).toHaveBeenCalledTimes(4)
// 首次发 3 个接口调用(s_module / s_power / s_user_power);第二次因主体已加载直接 return
expect(loadDataApi).toHaveBeenCalledTimes(3)
})
it('登录场景:load 进行中触发 clear(主体相同)不作废结果', async () => {
@@ -95,27 +113,64 @@ describe('permissions store', () => {
const perm = usePermissionStore()
const p = perm.load() // 主体 ORG:123 进行中
// 模拟切换账号:authStore 变为另一主体,watch 触发 clear
const auth = useAuthStore()
auth.setSession({ token: 't2', user: { id: 999, account: 'u2', name: 'U2' } }, 'ORG2')
setLoggedInUser('u2', 999)
perm.clear()
await p
// 旧请求被作废:结果丢弃,modules 仍为空(且未触发新主体的 load)
expect(perm.modules.length).toBe(0)
})
it('g3soft 提权:仅加载全部启用模块', async () => {
loadDataApi.mockImplementation((view) =>
Promise.resolve({
data: view === 's_module' ? mockModules : [],
code: 0,
success: true,
}),
)
const auth = useAuthStore()
auth.setSession({ token: 't', user: { id: 1, account: 'g3soft', name: 'G' } }, 'ORG')
it('g3soft 提权:仅加载全部启用模块,不查授权表', async () => {
mockApiResponses()
setLoggedInUser('g3soft', 1)
const perm = usePermissionStore()
await perm.load()
expect(perm.isG3soft).toBe(true)
expect(perm.modules.length).toBe(1)
expect(perm.grantedPowerCodes).toEqual([])
expect(loadDataApi).toHaveBeenCalledTimes(1)
expect(loadDataApi).toHaveBeenCalledWith('s_module', 'b_canuse = 1', 'b_id ASC')
})
})
describe('permissions store:判断口径', () => {
async function loadedStore() {
mockApiResponses()
setLoggedInUser()
const perm = usePermissionStore()
await perm.load()
return perm
}
it('canEnterMenu:有 menu.* 授权才能进入该菜单', async () => {
const perm = await loadedStore()
expect(perm.canEnterMenu('contact')).toBe(true)
expect(perm.canEnterMenu('user')).toBe(false)
})
it('canAccess:有 module.* 授权才能使用该模块', async () => {
const perm = await loadedStore()
expect(perm.canAccess('v_b_contact')).toBe(true)
// 未授权模块 / 不存在的模块一律拒绝
expect(perm.canAccess('b_money')).toBe(false)
expect(perm.canAccess('not-exist')).toBe(false)
})
it('canPower:标准操作由模块使用权限统一表达,业务动作需单独授权', async () => {
const perm = await loadedStore()
expect(perm.canPower('v_b_contact', 'create')).toBe(true)
expect(perm.canPower('v_b_contact', 'delete')).toBe(true)
expect(perm.canPower('v_b_contact', 'audit')).toBe(false)
expect(perm.canPower('b_money', 'create')).toBe(false)
})
it('g3soft 一切放行,且不需要任何授权数据', async () => {
mockApiResponses()
setLoggedInUser('g3soft', 1)
const perm = usePermissionStore()
await perm.load()
expect(perm.canAccess('b_money')).toBe(true)
expect(perm.canPower('b_money', 'audit')).toBe(true)
expect(perm.canEnterMenu('user')).toBe(true)
})
})
@@ -5,11 +5,13 @@
*/
import { describe, expect, it } from 'vitest'
import {
GROUP_CODE_MAX_LENGTH,
collectGroupIds,
nextGroupId,
normalizeGroupIds,
parseSchema,
schemaToJsonText,
validateGroupCode,
} from '@/utils/schemaJson'
const group = (over = {}) => ({ type: 'group', title: '分组', children: [], ...over })
@@ -89,3 +91,72 @@ describe('normalizeGroupIds', () => {
expect(() => normalizeGroupIds('view', parseSchema('view', '{bad json'))).not.toThrow()
})
})
describe('validateGroupCode', () => {
const REQUIRED = '分组编码不能为空'
const FORMAT = '分组编码须以小写字母开头,且只能包含小写字母、数字和下划线'
const LENGTH = `分组编码长度需为 2-${GROUP_CODE_MAX_LENGTH} 个字符`
it('空值报不能为空', () => {
expect(validateGroupCode('')).toBe(REQUIRED)
expect(validateGroupCode(' ')).toBe(REQUIRED)
expect(validateGroupCode(null)).toBe(REQUIRED)
expect(validateGroupCode(undefined)).toBe(REQUIRED)
})
it('长度需落在 2 到上限之间(含边界)', () => {
expect(validateGroupCode('a')).toBe(LENGTH)
expect(validateGroupCode('a'.repeat(GROUP_CODE_MAX_LENGTH + 1))).toBe(LENGTH)
expect(validateGroupCode('ab')).toBe(true)
expect(validateGroupCode('a'.repeat(GROUP_CODE_MAX_LENGTH))).toBe(true)
})
it('格式与模块编码同构:小写字母开头,只含小写字母、数字与下划线', () => {
for (const code of ['1abc', 'Abc', 'a-b', 'a b', '分组']) {
expect(validateGroupCode(code)).toBe(FORMAT)
}
})
it('与本配置已有编码冲突时提示已被使用', () => {
const message = validateGroupCode('basic', { existingIds: ['basic'] })
expect(message).toContain('basic')
expect(message).toContain('已被本配置的其他分组使用')
expect(message).not.toContain('多语言')
})
it('与多语言资源里残留的编码冲突时给出可区分的提示', () => {
const message = validateGroupCode('group2', { reservedIds: ['group2'] })
expect(message).toContain('多语言')
})
it('合法自定义编码通过', () => {
expect(validateGroupCode('basic_info')).toBe(true)
expect(validateGroupCode('cw_fee_detail')).toBe(true)
})
it('group{n} 形式不冲突即允许', () => {
expect(validateGroupCode('group7', { existingIds: ['group1'], reservedIds: ['group2'] })).toBe(
true,
)
})
it('编码集合同时兼容数组与 Set', () => {
expect(validateGroupCode('basic', { existingIds: new Set(['basic']) })).toBe(
validateGroupCode('basic', { existingIds: ['basic'] }),
)
})
it('输入首尾空白归一后再比对与判定', () => {
expect(validateGroupCode(' basic ', { existingIds: ['basic'] })).toContain('basic')
expect(validateGroupCode(' ok_code ')).toBe(true)
})
it('保留字 ungrouped 不可占用(结构树虚拟未分组节点用 g:ungrouped)', () => {
expect(validateGroupCode('ungrouped')).toContain('系统保留字')
})
it('长度上限可覆盖', () => {
expect(validateGroupCode('a'.repeat(11), { maxLength: 10 })).toContain('2-10')
expect(validateGroupCode('a'.repeat(10), { maxLength: 10 })).toBe(true)
})
})
@@ -1,9 +1,14 @@
/**
* 回归用例:菜单管理 - 绑定模块(s_menu_module)的加载与保存。
* 回归用例:菜单管理 - 随菜单保存同步的两份子表数据。
*
* s_menu 表没有模块列:绑定关系存 s_menu_module,行身份 = (b_menu_id, b_module_id),
* 只登记 data / virtual 模块(module 只是模块树上的分类,不参与权限)。
* 保存时与加载基线做行级 diff,只提交增删行;目录 / 外链菜单不绑定模块。
* 1) 绑定模块(s_menu_module)
* s_menu 表没有模块列:绑定关系存 s_menu_module,行身份 = (b_menu_id, b_module_id),
* 只登记 data / virtual 模块(module 只是模块树上的分类,不参与权限)。
* 保存时与加载基线做行级 diff,只提交增删行;目录 / 外链菜单不绑定模块。
*
* 2) 菜单入口权限点(s_power, b_object_type = 'menu')
* 页面 / 外链菜单各派生一行 menu.{菜单编码}(b_action = 'access',名称/顺序/启用跟随菜单),
* 目录没有;保存时按菜单行与该菜单的加载基线 diff;删除菜单时连同授权行一并清理。
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
@@ -98,6 +103,8 @@ const modules = [
/** 库中 s_menu_module 的现有行(每个用例前重置) */
let boundRows = []
/** 库中该菜单的 s_power(menu.*) 行(每个用例前重置) */
let powerRows = []
/** 从查询条件里取 N'xxx' 字面量(mock 按 id 过滤用;'1 = 1' 等条件返回 null) */
function literalOf(condition) {
@@ -106,6 +113,7 @@ function literalOf(condition) {
beforeEach(() => {
boundRows = [{ b_menu_id: 'otherdata', b_module_id: 'b_port', b_xh: 10, b_canuse: 1 }]
powerRows = []
loadDataApi.mockReset()
saveObjectApi.mockClear()
loadDataApi.mockImplementation((view, condition) => {
@@ -121,6 +129,12 @@ beforeEach(() => {
data: boundRows.filter((row) => !id || row.b_menu_id === id),
})
}
if (view === 's_power') {
const id = literalOf(condition)
return Promise.resolve({
data: powerRows.filter((row) => !id || row.b_object_id === id),
})
}
return Promise.resolve({ data: [] })
})
})
@@ -193,3 +207,111 @@ describe('菜单管理:绑定模块(s_menu_module)', () => {
expect(request.deletes).toEqual([{ b_menu_id: 'base', b_module_id: 'b_port' }])
})
})
describe('菜单管理:入口权限点(s_power, menu.*)', () => {
/** 库中该菜单的入口权限点行(名称故意与菜单不同,用于验证保存会同步) */
function makePowerRow(over = {}) {
return {
b_id: 'menu.otherdata',
b_name: '其他数据',
b_i18n: 'menu.otherdata',
b_type: 'menu',
b_object_type: 'menu',
b_object_id: 'otherdata',
b_action: 'access',
b_canuse: 1,
b_xh: 10,
...over,
}
}
it('选中页面菜单时按 b_object_id 加载入口权限点基线', async () => {
powerRows = [makePowerRow()]
const { wrapper } = await mountPage(MenuManagement)
await flushPromises()
wrapper.vm.menuSelectedId = 'otherdata'
await flushPromises()
expect(loadDataApi).toHaveBeenCalledWith(
's_power',
"b_object_type = 'menu' and b_object_id = N'otherdata'",
'b_id ASC',
)
expect(wrapper.vm.menuPower_org?.b_id).toBe('menu.otherdata')
})
it('新增页面菜单:自动派生 menu.{编码} 权限点行', async () => {
const { wrapper } = await mountPage(MenuManagement)
await flushPromises()
wrapper.vm.menuSelectedId = 'otherdata'
await flushPromises()
// 改菜单名,保证有可提交的差异
wrapper.vm.maindata.b_name = '其他数据(新)'
await wrapper.vm.handleSave()
const request = lastRequestOf('s_power')
expect(request.key_field).toBe('b_id')
expect(request.inserts).toHaveLength(1)
expect(request.inserts[0]).toMatchObject({
b_id: 'menu.otherdata',
b_name: '其他数据(新)',
b_i18n: 'menu.otherdata',
b_type: 'menu',
b_object_type: 'menu',
b_object_id: 'otherdata',
b_action: 'access',
b_canuse: 1,
b_xh: 10,
})
})
it('保存页面菜单:入口权限点的名称跟随菜单同步更新', async () => {
powerRows = [makePowerRow({ b_name: '旧名称' })]
const { wrapper } = await mountPage(MenuManagement)
await flushPromises()
wrapper.vm.menuSelectedId = 'otherdata'
await flushPromises()
expect(wrapper.vm.menuPower_org?.b_name).toBe('旧名称')
// 菜单本身没有改动,只有权限点名称与菜单不一致 → 也应同步
await wrapper.vm.handleSave()
const request = lastRequestOf('s_power')
expect(request.updates).toHaveLength(1)
expect(request.updates[0]).toMatchObject({ b_id: 'menu.otherdata', b_name: '其他数据' })
})
it('目录菜单没有入口权限点:无差异时不提交任何保存请求', async () => {
const { wrapper } = await mountPage(MenuManagement)
await flushPromises()
wrapper.vm.menuSelectedId = 'base'
await flushPromises()
await wrapper.vm.handleSave()
// 目录不派生权限点;菜单 / 绑定 / 多语言都无差异 → 不调用保存接口
expect(saveObjectApi).not.toHaveBeenCalled()
})
it('删除页面菜单:入口权限点与授权行一并清理', async () => {
const { wrapper } = await mountPage(MenuManagement)
await flushPromises()
wrapper.vm.deleteMenu('otherdata')
await flushPromises()
await wrapper.vm.confirmState.onOk()
await flushPromises()
const requests = saveObjectApi.mock.calls.at(-1)[0]
expect(requests.find((request) => request.table === 's_power').deletes).toEqual([
{ b_id: 'menu.otherdata' },
])
const granted = requests.find((request) => request.table === 's_user_power')
expect(granted.key_field).toBe('b_power_id')
expect(granted.deletes).toEqual([{ b_power_id: 'menu.otherdata' }])
// 菜单主表最后删
expect(requests.at(-1)).toMatchObject({ table: 's_menu' })
})
})
@@ -0,0 +1,65 @@
/**
* 分配权限抽屉(UserPowerDrawer):头部结构。
*
* 数据加载与保存由本组件负责(面板只读展示),测试只锁头部两件事:
* 1. 头部不渲染标题:只有页签一行 + ui/Drawer 的关闭按钮,没有其它元素;
* 2. 三个页签对应三张权限表的内容面板(切换页签不销毁,用 v-show 承载)。
*
* 面板自身的规则由 user-power-panel.spec.js 覆盖(含「全授权 / 全禁用」),这里不重复。
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import UserPowerDrawer from '@/views/system/user/UserPowerDrawer.vue'
import { clearMessages } from '@/stores/i18n'
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(),
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
}))
import { loadDataApi } from '@/services/api'
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
clearMessages()
loadDataApi.mockReset()
loadDataApi.mockImplementation(() => Promise.resolve({ data: [] }))
})
async function mountDrawer() {
const wrapper = mount(UserPowerDrawer, {
props: { open: true, user: { b_id: '001', b_name: '张三' } },
// Drawer 用 Teleport 到 body;打桩后内容留在原地,选择器才能命中
global: { stubs: { teleport: true } },
})
await flushPromises()
return wrapper
}
describe('分配权限抽屉:头部', () => {
it('头部只有页签一行:无标题、无批量按钮,关闭按钮由 ui/Drawer 提供', async () => {
const wrapper = await mountDrawer()
expect(wrapper.find('.fms-drawer-title').exists()).toBe(false)
expect(wrapper.find('.fms-drawer-extra .tabs-nav').exists()).toBe(true)
expect(wrapper.find('.fms-drawer-close').exists()).toBe(true)
// 账号级批量按钮不在头部(在功能权限面板的工具栏里)
expect(wrapper.find('.fms-drawer-header button.button').exists()).toBe(false)
})
it('三个页签对应三张权限表的内容面板', async () => {
const wrapper = await mountDrawer()
expect(wrapper.findAll('.tabs-tab').map((el) => el.text())).toEqual([
'功能权限',
'字段权限',
'数据范围',
])
expect(wrapper.findAll('.power-pane').map((el) => el.attributes('data-pane'))).toEqual([
'power',
'field',
'data',
])
})
})
@@ -0,0 +1,389 @@
/**
* 功能权限面板(用户管理 → 分配权限 → 功能权限)。
*
* 数据加载与保存由父级 UserPowerDrawer 负责,面板自身只做这几件事,测试只锁这几件:
* 1. 打开时的初始状态:只展开「全部分类」虚拟根,业务目录收起、不选中任何菜单;
* 2. 菜单选中语义:重复点击已选中的菜单不取消选中,只有点「全部分类」才清空;
* 3. 右侧两个分区:菜单访问(入口开关)+ 模块权限
* (模块名 | 模块使用开关 | 操作权限 chip | 行尾全选与计数)。
* 目录不渲染入口开关、没配权限点的模块不渲染,都不出空态文案;
* 4. 面板顶部工具栏的「全授权 / 全禁用」作用于该账号的全部功能权限(s_power 全量),
* 与左侧选中哪个菜单无关;
* 5. 勾选结果经 v-model 回传,三层是「保护性单向联动」:
* 勾动作补模块使用与入口,勾模块补入口,关模块清该模块动作,
* 关入口收回该菜单下的全部授权。
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import UserPowerPanel from '@/views/system/user/UserPowerPanel.vue'
import { clearMessages } from '@/stores/i18n'
/** 菜单树夹具:顺序模拟 s_menu 的 b_depth / b_xh 排序(父目录在前) */
const MENU_TREE = [
{ b_id: 'base', b_parent_id: null, b_name: '基础资料', b_menu_type: 'directory', b_xh: 10 },
{ b_id: 'system', b_parent_id: null, b_name: '系统管理', b_menu_type: 'directory', b_xh: 20 },
{ b_id: 'partner', b_parent_id: 'base', b_name: '合作伙伴', b_menu_type: 'page', b_xh: 10 },
// 外链菜单:不绑模块,入口就是唯一的授权单元
{ b_id: 'portal', b_parent_id: 'base', b_name: '外部系统', b_menu_type: 'external', b_xh: 20 },
{ b_id: 'user', b_parent_id: 'system', b_name: '用户管理', b_menu_type: 'page', b_xh: 10 },
]
const MENU_MODULES = {
partner: [{ b_menu_id: 'partner', b_module_id: 'v_b_othercompany', b_xh: 10 }],
// 用户管理绑两个模块,其中币种(b_money)没配权限点 → 验证「未配置权限点的模块不渲染」
user: [
{ b_menu_id: 'user', b_module_id: 'v_b_user', b_xh: 10 },
{ b_menu_id: 'user', b_module_id: 'b_money', b_xh: 20 },
],
}
const MODULE_BY_ID = new Map([
['v_b_othercompany', { b_id: 'v_b_othercompany', b_name: '合作伙伴' }],
['v_b_user', { b_id: 'v_b_user', b_name: '用户管理' }],
['b_money', { b_id: 'b_money', b_name: '币种' }],
])
const POWER_BY_MODULE = {
v_b_othercompany: [
{ b_id: 'module.v_b_othercompany', b_name: '模块使用', b_action: 'access' },
{ b_id: 'action.v_b_othercompany.create', b_name: '新增', b_action: 'create' },
],
v_b_user: [{ b_id: 'module.v_b_user', b_name: '模块使用', b_action: 'access' }],
}
/** s_power 全量启用编码(含 menu.*),父级加载后传给面板,供「全授权」使用 */
const ALL_POWER_IDS = [
'menu.partner',
'menu.portal',
'menu.user',
'module.v_b_othercompany',
'action.v_b_othercompany.create',
'module.v_b_user',
]
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
clearMessages()
})
function mountPanel(props = {}) {
return mount(UserPowerPanel, {
props: {
modelValue: [],
menuTree: MENU_TREE,
menuModules: MENU_MODULES,
moduleById: MODULE_BY_ID,
powerByModule: POWER_BY_MODULE,
allPowerIds: ALL_POWER_IDS,
...props,
},
})
}
/** 选中一个菜单(打开时不再默认选中,多数用例只需把右侧切到目标菜单) */
async function mountWithMenu(menuId, props = {}) {
const wrapper = mountPanel(props)
wrapper.vm.selectedMenuId = menuId
await nextTick()
return wrapper
}
/** 按标题文本找树行(每次重新查询,避免拿着已过期的 DOM 包装器做断言) */
function findRow(wrapper, text) {
return wrapper.findAll('.tree-node').find((row) => row.text().includes(text))
}
/** 分区标题清单 */
function sectionTitles(wrapper) {
return wrapper.findAll('.power-section__title').map((el) => el.text())
}
/** 模块权限区的行名清单(跳过「菜单访问」分区的那一行) */
function moduleNames(wrapper) {
const section = wrapper.findAll('.power-section')[1]
return section ? section.findAll('.power-row__name').map((el) => el.text()) : []
}
/** 「菜单访问」分区的入口开关(目录没有入口权限点时返回 undefined) */
function entrySwitch(wrapper) {
return wrapper.findAll('.power-section')[0]?.find('[role="switch"]')
}
/** 模块权限区的模块使用开关(第 index 行) */
function moduleSwitch(wrapper, index = 0) {
const section = wrapper.findAll('.power-section')[1]
return section ? section.findAll('[role="switch"]')[index] : undefined
}
/** 面板顶部工具栏按钮:0 = 全授权,1 = 全禁用 */
function toolbarButtons(wrapper) {
return wrapper.findAll('.power-toolbar__actions button')
}
/** 回传的勾选结果(取最后一次 emit) */
function lastEmitted(wrapper) {
const events = wrapper.emitted('update:modelValue')
return events[events.length - 1][0]
}
describe('功能权限面板:打开时的初始状态', () => {
it('只展开「全部分类」,业务目录收起、不选中任何菜单', async () => {
const wrapper = mountPanel({ menuTree: [] })
await nextTick()
await wrapper.setProps({ menuTree: MENU_TREE })
await nextTick()
expect(wrapper.vm.selectedMenuId).toBe('')
// 业务目录的展开键不由面板补写:虚拟根的展开由 FmsTree 承担
expect(wrapper.vm.expandedKeys).toEqual([])
expect(findRow(wrapper, '全部分类')).toBeTruthy()
expect(findRow(wrapper, '基础资料')).toBeTruthy()
// 目录收起 → 其下的页面菜单不可见
expect(findRow(wrapper, '合作伙伴')).toBeUndefined()
expect(wrapper.find('.power-panel__main').text()).toContain('请选择左侧菜单')
})
})
describe('功能权限面板:菜单选中语义', () => {
it('重复点击已选中的菜单:保持选中,不跳回「全部分类」', async () => {
const wrapper = mountPanel()
await nextTick()
// 展开目录 → 选中「合作伙伴」→ 再点一次(ui/Tree 会传出空 keys,即「取消选中」)
await findRow(wrapper, '基础资料').find('.tree-node-switcher').trigger('click')
await nextTick()
await findRow(wrapper, '合作伙伴').find('.tree-node-content').trigger('click')
await nextTick()
expect(wrapper.vm.selectedMenuId).toBe('partner')
await findRow(wrapper, '合作伙伴').find('.tree-node-content').trigger('click')
await nextTick()
expect(wrapper.vm.selectedMenuId).toBe('partner')
expect(findRow(wrapper, '合作伙伴').classes()).toContain('is-selected')
expect(findRow(wrapper, '全部分类').classes()).not.toContain('is-selected')
})
it('点「全部分类」虚拟根:清空选中,右侧回到空态提示', async () => {
const wrapper = mountPanel()
await nextTick()
await findRow(wrapper, '基础资料').find('.tree-node-switcher').trigger('click')
await nextTick()
await findRow(wrapper, '合作伙伴').find('.tree-node-content').trigger('click')
await nextTick()
expect(wrapper.vm.selectedMenuId).toBe('partner')
await findRow(wrapper, '全部分类').find('.tree-node-content').trigger('click')
await nextTick()
expect(wrapper.vm.selectedMenuId).toBe('')
expect(wrapper.find('.power-panel__main').text()).toContain('请选择左侧菜单')
})
it('点另一个菜单:选中切到该菜单', async () => {
const wrapper = mountPanel()
await nextTick()
// 「用户管理」在收起的「系统管理」下:先展开目录再点
await findRow(wrapper, '系统管理').find('.tree-node-switcher').trigger('click')
await nextTick()
await findRow(wrapper, '用户管理').find('.tree-node-content').trigger('click')
await nextTick()
expect(wrapper.vm.selectedMenuId).toBe('user')
expect(moduleNames(wrapper)).toEqual(['用户管理'])
})
})
describe('功能权限面板:右侧结构', () => {
it('两个分区:菜单访问 + 模块权限;入口与模块使用各一个开关', async () => {
const wrapper = await mountWithMenu('partner')
expect(sectionTitles(wrapper)).toEqual(['菜单访问', '模块权限'])
expect(wrapper.findAll('.power-section')[0].find('.power-row__name').text()).toBe('合作伙伴')
expect(moduleNames(wrapper)).toEqual(['合作伙伴'])
// 开关只有两处:菜单访问 + 模块使用
expect(wrapper.findAll('[role="switch"]')).toHaveLength(2)
// chip 只剩业务动作,模块使用不再混在里面
expect(wrapper.findAll('.power-chip').map((el) => el.text())).toEqual(['新增'])
})
it('已授权的入口渲染为打开态', async () => {
const wrapper = await mountWithMenu('partner', { modelValue: ['menu.partner'] })
expect(wrapper.vm.menuPowerCode).toBe('menu.partner')
expect(entrySwitch(wrapper).attributes('aria-checked')).toBe('true')
})
it('目录:不渲染入口开关(没有 menu.* 权限点),只给一行说明', async () => {
const wrapper = await mountWithMenu('base')
expect(sectionTitles(wrapper)).toEqual(['菜单访问'])
expect(wrapper.findAll('[role="switch"]')).toHaveLength(0)
expect(wrapper.find('.power-panel__hint').text()).toContain('目录只是分组节点')
})
it('外链菜单:没有可授权模块,入口开关独立可用', async () => {
const wrapper = await mountWithMenu('portal')
expect(sectionTitles(wrapper)).toEqual(['菜单访问'])
expect(moduleNames(wrapper)).toEqual([])
await entrySwitch(wrapper).trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(['menu.portal'])
})
it('页面没绑模块(自定义页):只剩入口开关,勾选即全部权限', async () => {
const wrapper = await mountWithMenu('partner', { menuModules: {} })
expect(sectionTitles(wrapper)).toEqual(['菜单访问'])
expect(wrapper.findAll('[role="switch"]')).toHaveLength(1)
await entrySwitch(wrapper).trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(['menu.partner'])
})
it('未配置权限点的模块不渲染,也不显示空态文案', async () => {
const wrapper = await mountWithMenu('user')
// 「币种」绑定了但没配权限点:不渲染成行,也不出任何提示
expect(moduleNames(wrapper)).toEqual(['用户管理'])
expect(wrapper.find('.power-panel__main').text()).not.toContain('未配置权限点')
})
})
describe('功能权限面板:全授权 / 全禁用', () => {
it('工具栏在面板顶部:说明 + 两个按钮', async () => {
const wrapper = mountPanel()
await nextTick()
expect(wrapper.find('.power-toolbar__label').text()).toBe('该账号的全部功能权限')
expect(toolbarButtons(wrapper).map((el) => el.text())).toEqual(['全授权', '全禁用'])
})
it('全授权:一次勾满 s_power 的全部权限点', async () => {
const wrapper = await mountWithMenu('partner')
await toolbarButtons(wrapper)[0].trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(ALL_POWER_IDS)
})
it('全禁用:清空该账号的全部功能权限', async () => {
const wrapper = await mountWithMenu('partner', { modelValue: ALL_POWER_IDS })
await toolbarButtons(wrapper)[1].trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual([])
})
it('工具栏不依赖选中菜单:未选中菜单时同样可用', async () => {
const wrapper = mountPanel()
await nextTick()
expect(wrapper.find('.power-panel__main').text()).toContain('请选择左侧菜单')
await toolbarButtons(wrapper)[0].trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(ALL_POWER_IDS)
})
})
describe('功能权限面板:三层联动', () => {
it('勾模块使用:自动补上菜单入口', async () => {
const wrapper = await mountWithMenu('partner')
await moduleSwitch(wrapper).trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(['module.v_b_othercompany', 'menu.partner'])
})
it('勾操作权限:自动补上模块使用与菜单入口', async () => {
const wrapper = await mountWithMenu('partner')
await wrapper.findAll('.power-chip')[0].trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual([
'action.v_b_othercompany.create',
'module.v_b_othercompany',
'menu.partner',
])
})
it('关模块使用:只清该模块的操作权限,不动菜单入口', async () => {
const wrapper = await mountWithMenu('partner', {
modelValue: ['menu.partner', 'module.v_b_othercompany', 'action.v_b_othercompany.create'],
})
await moduleSwitch(wrapper).trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(['menu.partner'])
})
it('开菜单入口:只补入口本身,不给任何模块权限', async () => {
const wrapper = await mountWithMenu('partner')
await entrySwitch(wrapper).trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(['menu.partner'])
})
it('关菜单入口:连带收回该菜单下的全部模块与动作授权', async () => {
const wrapper = await mountWithMenu('partner', {
modelValue: ['menu.partner', 'module.v_b_othercompany', 'action.v_b_othercompany.create'],
})
await entrySwitch(wrapper).trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual([])
})
it('取消单个操作权限:不动模块使用与入口', async () => {
const wrapper = await mountWithMenu('partner', {
modelValue: ['menu.partner', 'module.v_b_othercompany', 'action.v_b_othercompany.create'],
})
await wrapper.findAll('.power-chip')[0].trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(['menu.partner', 'module.v_b_othercompany'])
})
it('勾选态回显:操作权限 chip 与模块使用开关各管各的', async () => {
const wrapper = await mountWithMenu('partner', {
modelValue: ['action.v_b_othercompany.create'],
})
expect(wrapper.findAll('.power-chip')[0].classes()).toContain('is-checked')
expect(wrapper.findAll('.power-chip')[0].attributes('aria-checked')).toBe('true')
// 模块使用未授权 → 开关是关的
expect(moduleSwitch(wrapper).attributes('aria-checked')).toBe('false')
})
it('行尾「全选」:补齐该模块的权限点并联动入口', async () => {
const wrapper = await mountWithMenu('partner')
await wrapper.find('.power-row__toggle').trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual([
'module.v_b_othercompany',
'action.v_b_othercompany.create',
'menu.partner',
])
})
})
+46
View File
@@ -0,0 +1,46 @@
/* ============================================================================
FMS 菜单入口权限点(s_power,b_object_type = 'menu')
背景:设计文档 §14.1 与《FMS模块关系与权限边界重构计划》§3.2 规定
menu.* 控制「能否进入菜单页面」,但此前没有任何地方生成它 —— s_power 里
menu 类型权限点为 0 行,「用户管理 → 分配权限 → 功能权限」因此无法给菜单授权。
规则:每个 page / external 菜单一行 menu.{菜单编码},b_action = 'access'。
directory 是纯导航分组,不设权限点(侧栏按子菜单裁剪)。
落库后由「菜单管理」在保存 / 删除菜单时自动维护(新增自动派生、删除自动清理),
本脚本只在首次上线或历史数据修补时执行;可重复执行(幂等,已存在的跳过)。
============================================================================ */
insert into dbo.s_power
(b_id, b_name, b_i18n, b_type, b_object_type, b_object_id, b_action, b_canuse, b_xh, b_bz)
select 'menu.' + m.b_id,
m.b_name,
'menu.' + m.b_id,
'menu',
'menu',
m.b_id,
'access',
1,
m.b_xh,
N'菜单入口权限(菜单管理自动派生)'
from dbo.s_menu m
where m.b_menu_type in ('page', 'external')
and not exists (
select 1 from dbo.s_power p
where p.b_object_type = 'menu' and p.b_object_id = m.b_id
);
GO
/* ----------------------------------------------------------------------------
自检
---------------------------------------------------------------------------- */
select N'可授权菜单数(page + external)' as 项, count(*) as 行数
from dbo.s_menu where b_menu_type in ('page', 'external')
union all
select N's_power(menu.*) 行数', count(*)
from dbo.s_power where b_object_type = 'menu'
union all
select N'其中启用', count(*)
from dbo.s_power where b_object_type = 'menu' and b_canuse = 1;
GO
File diff suppressed because it is too large. Load diff