diff --git a/code/fms/.trae/documents/分组编码手填与新建分组弹窗.md b/code/fms/.trae/documents/分组编码手填与新建分组弹窗.md new file mode 100644 index 00000000..554f03b9 --- /dev/null +++ b/code/fms/.trae/documents/分组编码手填与新建分组弹窗.md @@ -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, reservedIds?: Iterable, 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 行)旁挂 ``。 + +表单画布只建顶级分组(`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:`,`!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. 模板挂载 ``。 + +--- + +## 不变量与边界 + +- **编码创建后不可修改**靠三件事保证:① 弹窗只在新建路径打开(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` 的新路径,已通过的编码可能瞬时冲突 —— 目前遮罩层挡住画布交互,不存在该路径。 \ No newline at end of file diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/FmsApiApplicationTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/FmsApiApplicationTests.class index 16e04d81..ba7577c4 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/FmsApiApplicationTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/FmsApiApplicationTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/config/JwtAuthFilterTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/config/JwtAuthFilterTests.class index 3818e35d..b68ad01c 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/config/JwtAuthFilterTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/config/JwtAuthFilterTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceFactoryTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceFactoryTests.class index 6f18a08a..50c335c9 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceFactoryTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceFactoryTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests$1.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests$1.class index 87d0c86e..e741e737 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests$1.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests$1.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests.class index cae9c258..4b8e906a 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoaderTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoaderTests.class index b771450e..8dd518fe 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoaderTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoaderTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests$1.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests$1.class index 0c37f74f..109918f7 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests$1.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests$1.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests.class index f6a9a417..27facbf7 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.class index c4ed5132..62a855e6 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class index 6310143e..acc383b6 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/AuthServiceTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/AuthServiceTests.class index e9bfccb4..6ab19287 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/AuthServiceTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/AuthServiceTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests$JdbcFixture.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests$JdbcFixture.class index 25c40ab4..4565f29a 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests$JdbcFixture.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests$JdbcFixture.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests.class index 00725152..5cf40135 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class index b2ce9c11..42ba670b 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class index 47e20269..67525ed0 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests$1.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests$1.class index 843a79c3..f0ba2163 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests$1.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests$1.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.class index b03b69d7..035c5217 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/JwtUtilsTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/JwtUtilsTests.class index 09ae1d75..e9485923 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/JwtUtilsTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/JwtUtilsTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/snowflake/DefaultIdGeneratorTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/snowflake/DefaultIdGeneratorTests.class index ff9a76ed..e777433d 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/snowflake/DefaultIdGeneratorTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/snowflake/DefaultIdGeneratorTests.class differ diff --git a/code/fms/fms-api/tools/migration/PowerProbe.java b/code/fms/fms-api/tools/migration/PowerProbe.java new file mode 100644 index 00000000..e1c397ac --- /dev/null +++ b/code/fms/fms-api/tools/migration/PowerProbe.java @@ -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)); + } + } + } + } +} diff --git a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue index fb04594e..6148ecd5 100644 --- a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue +++ b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue @@ -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 diff --git a/code/fms/fms-vue/src/router/index.js b/code/fms/fms-vue/src/router/index.js index 2c89f532..ba190f61 100644 --- a/code/fms/fms-vue/src/router/index.js +++ b/code/fms/fms-vue/src/router/index.js @@ -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)。 diff --git a/code/fms/fms-vue/src/stores/menus.js b/code/fms/fms-vue/src/stores/menus.js index decc7c54..31b87d0b 100644 --- a/code/fms/fms-vue/src/stores/menus.js +++ b/code/fms/fms-vue/src/stores/menus.js @@ -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, } }) diff --git a/code/fms/fms-vue/src/stores/permissions.js b/code/fms/fms-vue/src/stores/permissions.js index f4059312..498180c1 100644 --- a/code/fms/fms-vue/src/stores/permissions.js +++ b/code/fms/fms-vue/src/stores/permissions.js @@ -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, } }) diff --git a/code/fms/fms-vue/src/utils/powerCodes.js b/code/fms/fms-vue/src/utils/powerCodes.js new file mode 100644 index 00000000..6adc74e3 --- /dev/null +++ b/code/fms/fms-vue/src/utils/powerCodes.js @@ -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}` +} diff --git a/code/fms/fms-vue/src/utils/schemaJson.js b/code/fms/fms-vue/src/utils/schemaJson.js index 574ab1fb..5d8b27dc 100644 --- a/code/fms/fms-vue/src/utils/schemaJson.js +++ b/code/fms/fms-vue/src/utils/schemaJson.js @@ -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, reservedIds?: Iterable, 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 +} diff --git a/code/fms/fms-vue/src/views/module/menu-management/index.vue b/code/fms/fms-vue/src/views/module/menu-management/index.vue index c422c72a..86a33889 100644 --- a/code/fms/fms-vue/src/views/module/menu-management/index.vue +++ b/code/fms/fms-vue/src/views/module/menu-management/index.vue @@ -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 } diff --git a/code/fms/fms-vue/src/views/module/menu-management/utils.js b/code/fms/fms-vue/src/views/module/menu-management/utils.js index 8a3990a5..8e8fef87 100644 --- a/code/fms/fms-vue/src/views/module/menu-management/utils.js +++ b/code/fms/fms-vue/src/views/module/menu-management/utils.js @@ -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, + } +} diff --git a/code/fms/fms-vue/src/views/module/module-management/components/design-editor/FormDesignEditor.vue b/code/fms/fms-vue/src/views/module/module-management/components/design-editor/FormDesignEditor.vue index 73cf992e..0e24a609 100644 --- a/code/fms/fms-vue/src/views/module/module-management/components/design-editor/FormDesignEditor.vue +++ b/code/fms/fms-vue/src/views/module/module-management/components/design-editor/FormDesignEditor.vue @@ -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(() => {
-
+ + + diff --git a/code/fms/fms-vue/src/views/module/module-management/components/design-editor/TableDesignEditor.vue b/code/fms/fms-vue/src/views/module/module-management/components/design-editor/TableDesignEditor.vue index da69f3e5..6c43cbd1 100644 --- a/code/fms/fms-vue/src/views/module/module-management/components/design-editor/TableDesignEditor.vue +++ b/code/fms/fms-vue/src/views/module/module-management/components/design-editor/TableDesignEditor.vue @@ -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: */ +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(() => ({
- - 已保存 - - -
- - -
- - -
-
-
系统设置权限管理
-
-
-

用户权限分配

-

先给入口,再给模块能力;模块权限会在所有菜单和接口中保持一致。

-
-
-
-
- - 当前授权原则:菜单控制能否进入页面,模块控制能否使用数据;选择“海运”只做批量操作,不会通过数据关联自动扩大权限。 -
- -
-
- 授权来源 -

海运·经办角色方案

-

角色方案会一次生成菜单入口和模块动作,之后仍可按用户单独微调。

-
-
- - - -
-
- -
-
-
推荐授权方式

按业务方案分配

管理员只需要选择业务域的工作方式,系统会自动准备菜单和模块权限。

- -
-
-

业务域

先选择客户熟悉的业务域,再选择该业务域的工作方式。

当前配置:海运
-
-
-
-
-

海运业务

包含主单、装箱信息和费用模块

-
-

选择授权方案

点击后可查看授权预览
-
-
-
-

授权预览

选择方案后,这里会显示实际授权内容

确认后一次保存
-
-
-
-
-
“只读 / 经办 / 主管”只是管理员的快捷入口。保存后仍按具体模块、动作、字段和数据范围校验,不会因为数据关联自动扩大权限。
-
- - -
-
-
- - - - - - -
- - - -