20260920220520
This commit is contained in:
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` 的新路径,已通过的编码可能瞬时冲突 —— 目前遮罩层挡住画布交互,不存在该路径。
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
+27
-15
@@ -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 兜底) -->
|
||||
|
||||
+58
-6
@@ -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>
|
||||
|
||||
|
||||
+111
@@ -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>
|
||||
+23
-34
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in new issue
Block a user