完善平台角色菜单树与授权登录导航
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
<!--
|
||||
功能:以父子树方式分配平台角色菜单,仅向表单写入叶子菜单标识。
|
||||
版本:v1.0.0
|
||||
-->
|
||||
<template>
|
||||
<div class="menu-permission-tree">
|
||||
<a-input v-model="keyword" allow-clear placeholder="搜索菜单名称或完整路径" />
|
||||
<div v-if="visibleGroups.length" class="menu-groups">
|
||||
<section v-for="group in visibleGroups" :key="String(group.identity)" class="menu-group">
|
||||
<a-checkbox
|
||||
v-if="group.standalone"
|
||||
:model-value="selected.has(String(group.identity))"
|
||||
@change="(checked: unknown) => toggleLeaf(String(group.identity), checked === true)"
|
||||
>
|
||||
{{ group.name }}
|
||||
</a-checkbox>
|
||||
<a-checkbox
|
||||
v-else
|
||||
:model-value="groupChecked(group)"
|
||||
:indeterminate="groupIndeterminate(group)"
|
||||
@change="(checked: unknown) => toggleGroup(group, checked === true)"
|
||||
>
|
||||
{{ group.name }}
|
||||
</a-checkbox>
|
||||
<div v-if="!group.standalone" class="menu-children">
|
||||
<a-checkbox
|
||||
v-for="child in group.children"
|
||||
:key="String(child.identity)"
|
||||
:model-value="selected.has(String(child.identity))"
|
||||
@change="(checked: unknown) => toggleLeaf(String(child.identity), checked === true)"
|
||||
>
|
||||
{{ group.name }} / {{ child.name }}
|
||||
</a-checkbox>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<a-empty v-else description="没有匹配的菜单" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { ResourceRow } from '@/api/resource-page-rules';
|
||||
|
||||
type MenuGroup = ResourceRow & { children: ResourceRow[]; standalone: boolean };
|
||||
|
||||
const props = defineProps<{ options: ResourceRow[] }>();
|
||||
const model = defineModel<string[]>({ required: true });
|
||||
const keyword = ref('');
|
||||
const selected = computed(() => new Set((model.value ?? []).map(String)));
|
||||
|
||||
/** 将静态菜单扁平数据整理为两层展示树,父节点不进入提交值。 */
|
||||
const groups = computed<MenuGroup[]>(() => {
|
||||
const children = new Map<string, ResourceRow[]>();
|
||||
for (const item of props.options) {
|
||||
const parent = String(item.parent_identity ?? '');
|
||||
if (!parent) continue;
|
||||
children.set(parent, [...(children.get(parent) ?? []), item]);
|
||||
}
|
||||
return props.options
|
||||
.filter((item) => !String(item.parent_identity ?? ''))
|
||||
.map((item) => {
|
||||
const items = children.get(String(item.identity)) ?? [];
|
||||
return { ...item, children: items, standalone: items.length === 0 };
|
||||
});
|
||||
});
|
||||
|
||||
/** 搜索命中父级时保留全部子级,命中子级时保留其父级上下文。 */
|
||||
const visibleGroups = computed<MenuGroup[]>(() => {
|
||||
const query = keyword.value.trim().toLowerCase();
|
||||
if (!query) return groups.value;
|
||||
return groups.value.flatMap((group) => {
|
||||
const parentMatches = String(group.name ?? '')
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
const matchedChildren = parentMatches
|
||||
? group.children
|
||||
: group.children.filter((child) =>
|
||||
`${String(group.name ?? '')} / ${String(child.name ?? '')}`
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
);
|
||||
return matchedChildren.length
|
||||
? [{ ...group, children: matchedChildren }]
|
||||
: [];
|
||||
});
|
||||
});
|
||||
|
||||
function groupChecked(group: MenuGroup) {
|
||||
return (
|
||||
group.children.length > 0 &&
|
||||
group.children.every((child) => selected.value.has(String(child.identity)))
|
||||
);
|
||||
}
|
||||
|
||||
function groupIndeterminate(group: MenuGroup) {
|
||||
const count = group.children.filter((child) =>
|
||||
selected.value.has(String(child.identity)),
|
||||
).length;
|
||||
return count > 0 && count < group.children.length;
|
||||
}
|
||||
|
||||
/** 父级仅执行当前分组全部叶子的批量选择,不作为独立权限保存。 */
|
||||
function toggleGroup(group: MenuGroup, checked: boolean) {
|
||||
const next = new Set(selected.value);
|
||||
for (const child of group.children) {
|
||||
const identity = String(child.identity);
|
||||
checked ? next.add(identity) : next.delete(identity);
|
||||
}
|
||||
model.value = [...next];
|
||||
}
|
||||
|
||||
function toggleLeaf(identity: string, checked: boolean) {
|
||||
const next = new Set(selected.value);
|
||||
checked ? next.add(identity) : next.delete(identity);
|
||||
model.value = [...next];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.menu-permission-tree { display: grid; gap: 12px; }
|
||||
.menu-groups { max-height: 420px; overflow: auto; border: 1px solid var(--color-border-2); border-radius: 4px; }
|
||||
.menu-group { padding: 12px 16px; border-bottom: 1px solid var(--color-border-1); }
|
||||
.menu-group:last-child { border-bottom: 0; }
|
||||
.menu-children { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 20px; padding: 10px 0 0 24px; }
|
||||
@media (max-width: 700px) { .menu-children { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -40,7 +40,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
|
||||
import { resourceApi } from '@/api/resource';
|
||||
@@ -62,6 +62,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
const form = reactive<Record<string, any>>({});
|
||||
const submitting = ref(false);
|
||||
const emptyMenuAssignmentConfirmed = ref(false);
|
||||
const relations = useResourceRelations();
|
||||
const isProductLifecycleAction = computed(
|
||||
() => props.action?.resource === '/product_info/:identity/lifecycle',
|
||||
@@ -232,10 +233,14 @@ watch(
|
||||
async ([visible]) => {
|
||||
if (!visible || !props.action) return;
|
||||
for (const field of actionFields.value) {
|
||||
form[field.key] = isOwnershipAction.value
|
||||
? props.record[field.key]
|
||||
: undefined;
|
||||
form[field.key] =
|
||||
field.type === 'menu-tree'
|
||||
? []
|
||||
: isOwnershipAction.value
|
||||
? props.record[field.key]
|
||||
: undefined;
|
||||
}
|
||||
emptyMenuAssignmentConfirmed.value = false;
|
||||
if (isGasorderAssignmentAction.value) {
|
||||
await initializeGasorderAssignment();
|
||||
return;
|
||||
@@ -264,6 +269,22 @@ function close() {
|
||||
async function submit() {
|
||||
const action = props.action;
|
||||
if (!action) return;
|
||||
if (
|
||||
action.resource.includes('/menu') &&
|
||||
!emptyMenuAssignmentConfirmed.value &&
|
||||
(!Array.isArray(form.menu_identities) || form.menu_identities.length === 0)
|
||||
) {
|
||||
Modal.warning({
|
||||
title: '确认清空菜单权限',
|
||||
content: '保存后,该角色下的账号将无法访问任何业务页面。是否继续?',
|
||||
hideCancel: false,
|
||||
onOk: () => {
|
||||
emptyMenuAssignmentConfirmed.value = true;
|
||||
void submit();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
actionFields.value.some(
|
||||
(field) => field.required && isMissingField(form[field.key]),
|
||||
|
||||
@@ -56,6 +56,11 @@
|
||||
:disabled="disabledSet.has(field.key)"
|
||||
:placeholder="requiredKeys.includes(field.key) ? '请输入至少 6 个字符' : '留空表示不修改密码'"
|
||||
/>
|
||||
<MenuPermissionTree
|
||||
v-else-if="field.type === 'menu-tree'"
|
||||
v-model="model[field.key]"
|
||||
:options="relationOptions[field.relation ?? ''] ?? []"
|
||||
/>
|
||||
<a-select
|
||||
v-else-if="field.key === 'platform_role_code'"
|
||||
v-model="model[field.key]"
|
||||
@@ -156,6 +161,7 @@ import IdentityText from '@/components/IdentityText.vue';
|
||||
import ContractAttachmentField, {
|
||||
type ContractAttachmentFieldState,
|
||||
} from './ContractAttachmentField.vue';
|
||||
import MenuPermissionTree from './MenuPermissionTree.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -199,6 +205,7 @@ function isWide(field: ResourceField) {
|
||||
return (
|
||||
field.type === 'textarea' ||
|
||||
field.type === 'identity-list' ||
|
||||
field.type === 'menu-tree' ||
|
||||
/(address|terms|content|body|remark|reason|params|args)$/.test(field.key)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user