feat(需求): ①创建/编辑用户菜单权限勾选父菜单自动全选子菜单(el-tree去check-strictly走父子联动,提交前压缩为最小直接授权集,父勾选时后代不重复落库) ②修复管理员勾选店铺数据菜单仍提示'无权查看店铺数据任务'——admin分区授权后若有效授权含店铺数据任务/重复检查菜单(含经'店铺管理'分组展开),自动补内部数据权限 admin_shop_data_crawl_task_data(镜像V84惯例只增不删,回收仍走超管数据范围授权UI)

This commit is contained in:
2026-09-07 14:22:53 +08:00
parent e6cf4c2461
commit 4994142a67
5 changed files with 305 additions and 3 deletions
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import type { MenuOptionNode } from './user-menu-auth'
import { compactDirectGrantIds } from './user-menu-auth'
import { fetchGrantableMenus } from './user-menu-auth-api'
const props = defineProps<{ checked?: number[] }>()
@@ -32,7 +33,10 @@ function onCheck(): void {
return (Array.isArray(keys) ? keys : []).map((key) => Number(key)).filter((id) => id > 0)
}
const ids = [...collect(adminTree.value), ...collect(appTree.value)]
emit('update:checked', ids)
// 父子联动模式(未启用严格勾选)下勾选父菜单会自动全选全部子菜单;
// 提交前压缩为最小直接授权集:父级已勾选时后代不再重复提交(后端按“父级授权
// 展开全部后代”判断权限,与旧 admin.html 勾父清后代语义一致)。
emit('update:checked', compactDirectGrantIds(ids, data.value))
}
async function load(): Promise<void> {
@@ -62,12 +66,13 @@ watch(
<div class="menu-group-columns">
<div v-if="adminNodes.length" class="menu-group-block">
<div class="menu-group-title">后台</div>
<!-- 父子联动模式未启用严格勾选勾选父菜单自动全选全部子菜单
勾父即代表全部后代与后端父级授权展开后代的权限语义一致 -->
<el-tree
ref="adminTree"
:data="adminNodes"
node-key="id"
show-checkbox
check-strictly
default-expand-all
:props="{ label: 'name', children: 'children' }"
@check="onCheck"
@@ -80,7 +85,6 @@ watch(
:data="appNodes"
node-key="id"
show-checkbox
check-strictly
default-expand-all
:props="{ label: 'name', children: 'children' }"
@check="onCheck"
@@ -105,3 +105,29 @@ export function normalizeMenuIds(raw: unknown): number[] {
export function buildUserColumnPermissionPayload(raw: unknown): { columnIds: number[] } {
return { columnIds: normalizeMenuIds(raw) }
}
/**
* 把权限树勾选 id 压缩为后端意义上的“直接授权最小集”:父级已勾选时后代无需
* 重复提交(后端读取/校验时父级授权会向全部后代展开,与旧 admin.html“勾父清
* 后代”语义一致,避免 user_column_permission 存储冗余行)。未勾选父级但单独
* 勾选子级的部分授权原样保留。
*/
export function compactDirectGrantIds(checkedIds: unknown, tree: MenuOptionNode[]): number[] {
const ids = normalizeMenuIds(checkedIds)
if (ids.length === 0 || tree.length === 0) return ids
const idSet = new Set(ids)
/** 因“有已勾选祖先”而被父级覆盖、无需直接授权的 id。 */
const coveredByAncestor = new Set<number>()
const walk = (nodes: MenuOptionNode[], ancestorChecked: boolean): void => {
for (const node of nodes) {
if (ancestorChecked) {
coveredByAncestor.add(node.id)
if (node.children?.length) walk(node.children, true)
continue
}
if (node.children?.length) walk(node.children, idSet.has(node.id))
}
}
walk(tree, false)
return ids.filter((id) => !coveredByAncestor.has(id))
}
@@ -0,0 +1,70 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { compactDirectGrantIds, type MenuOptionNode } from '../src/pages/account/user-menu-auth.ts'
// 验收反馈:创建/编辑用户的权限菜单里,勾选父菜单要自动全选子菜单。
// 实现:el-tree 去掉 check-strictly 走父子联动;提交时把勾选 id 压缩为最小
// 直接授权集(父级已勾选时后代不再重复提交,后端按“父级授权展开后代”判断权限)。
const tree: MenuOptionNode[] = [
{
id: 1,
key: 'root',
name: '账号权限',
sort: 1,
parentId: null,
type: 'admin',
children: [
{ id: 11, key: 'users', name: '用户管理', sort: 1, parentId: 1, type: 'admin' },
{ id: 12, key: 'menus', name: '菜单管理', sort: 2, parentId: 1, type: 'admin' },
{
id: 13,
key: 'sub',
name: '子分组',
sort: 3,
parentId: 1,
type: 'admin',
children: [
{ id: 131, key: 'leaf-a', name: '叶子A', sort: 1, parentId: 13, type: 'admin' },
{ id: 132, key: 'leaf-b', name: '叶子B', sort: 2, parentId: 13, type: 'admin' },
],
},
],
},
{ id: 2, key: 'wb', name: '视频', sort: 1, parentId: null, type: 'app' },
]
test('compactDirectGrantIds_keep_parent_only_when_children_also_checked', () => {
// 勾选父 + 联动全选的子:压缩后只保留最上层父级。
assert.deepEqual(compactDirectGrantIds([1, 11, 12, 13, 131, 132], tree), [1])
})
test('compactDirectGrantIds_keep_partial_children_when_parent_unchecked', () => {
// 父级未勾选、只勾部分子级:原样保留(父级半选,子级为直接授权)。
assert.deepEqual(compactDirectGrantIds([11, 131], tree), [11, 131])
})
test('compactDirectGrantIds_keep_topmost_checked_in_multi_level', () => {
// 多层嵌套:爷勾选后,父/孙虽也被勾选但均被覆盖,只留爷。
assert.deepEqual(compactDirectGrantIds([1, 13, 131], tree), [1])
// 爷未勾、子分组勾选时保留子分组(覆盖其叶子)。
assert.deepEqual(compactDirectGrantIds([13, 131], tree), [13])
})
test('compactDirectGrantIds_merges_two_groups_and_dedupes', () => {
const merged = [...tree, { id: 21, key: 'dup', name: '重复', sort: 1, parentId: null, type: 'admin' }]
assert.deepEqual(compactDirectGrantIds([1, 11, 2, 21, 21], merged), [1, 2, 21])
})
test('compactDirectGrantIds_empty_inputs', () => {
assert.deepEqual(compactDirectGrantIds([], tree), [])
assert.deepEqual(compactDirectGrantIds([11], []), [11])
})
test('user_menu_auth_tree_uses_linked_check_and_compacts', () => {
const component = readSource('src/pages/account/UserMenuAuthTree.vue')
assert.ok(!/check-strictly/.test(component), '必须去掉 check-strictly,勾父联动全选子')
assert.match(component, /compactDirectGrantIds/, '@check 提交需压缩为最小直接授权集')
assert.match(component, /default-expand-all/, '默认展开全部菜单')
})