<template>
|
<div class="perm-matrix">
|
<el-table :data="tableRows" border class="matrix-table" :span-method="spanMethod">
|
<el-table-column label="板块" width="200">
|
<template #default="{ row }">
|
<div v-if="row.showModuleCell" class="cell-inner">
|
<el-checkbox
|
size="small"
|
:model-value="moduleFullyChecked(row.module)"
|
:indeterminate="moduleIndeterminate(row.module)"
|
:disabled="disabled"
|
@change="(v: CheckboxValue) => toggleModule(row.module, Boolean(v))"
|
/>
|
<span class="cell-label">{{ row.module.label }}</span>
|
</div>
|
</template>
|
</el-table-column>
|
<el-table-column label="页面" width="200">
|
<template #default="{ row }">
|
<div v-if="row.showPageCell" class="cell-inner">
|
<el-checkbox
|
size="small"
|
:model-value="pageFullyChecked(row.page)"
|
:indeterminate="pageIndeterminate(row.page)"
|
:disabled="disabled"
|
@change="(v: CheckboxValue) => togglePage(row.page, row.module, Boolean(v))"
|
/>
|
<span class="cell-label">{{ row.page.label }}</span>
|
</div>
|
</template>
|
</el-table-column>
|
<el-table-column label="权限" min-width="240">
|
<template #default="{ row }">
|
<div v-if="row.perm" class="perm-cell">
|
<el-checkbox
|
size="small"
|
:model-value="hasId(row.perm.id)"
|
:disabled="disabled"
|
@change="(v: CheckboxValue) => togglePerm(row.perm!.id, row.page, row.module, Boolean(v))"
|
/>
|
<span class="perm-label">{{ row.perm.label }}</span>
|
</div>
|
<span v-else class="muted">—</span>
|
</template>
|
</el-table-column>
|
</el-table>
|
</div>
|
</template>
|
|
<script setup lang="ts">
|
import { computed, shallowRef, watch } from "vue";
|
import type { MenuNode } from "../types/sysMenu";
|
|
type CheckboxValue = string | number | boolean;
|
|
const props = withDefaults(
|
defineProps<{
|
menuTree: MenuNode[];
|
modelValue: number[];
|
disabled?: boolean;
|
}>(),
|
{ disabled: false }
|
);
|
|
const emit = defineEmits<{
|
"update:modelValue": [value: number[]];
|
}>();
|
|
/** 每个权限一行;板块、页面列 rowspan */
|
type MatrixRow = {
|
module: MenuNode;
|
page: MenuNode;
|
perm: MenuNode | null;
|
moduleRowspan: number;
|
pageRowspan: number;
|
showModuleCell: boolean;
|
showPageCell: boolean;
|
};
|
|
function permChildren(page: MenuNode): MenuNode[] {
|
return (page.children || []).filter((c) => c.menuType === 3);
|
}
|
|
function pageNodesUnderModule(mod: MenuNode): MenuNode[] {
|
const ch = mod.children || [];
|
const pages = ch.filter((c) => c.menuType === 2);
|
if (pages.length > 0) {
|
return pages;
|
}
|
const perms = ch.filter((c) => c.menuType === 3);
|
if (perms.length > 0) {
|
return [{ ...mod, children: perms }];
|
}
|
return [];
|
}
|
|
function collectDescendantIds(node: MenuNode): number[] {
|
const ids: number[] = [node.id];
|
for (const c of node.children || []) {
|
ids.push(...collectDescendantIds(c));
|
}
|
return ids;
|
}
|
|
function permIdsOnly(page: MenuNode): number[] {
|
return permChildren(page).map((p) => p.id);
|
}
|
|
function pageSubtreeComplete(page: MenuNode, s: Set<number>): boolean {
|
const pids = permIdsOnly(page);
|
if (pids.length === 0) {
|
return s.has(page.id);
|
}
|
return pids.every((id) => s.has(id));
|
}
|
|
function pageHasAny(page: MenuNode, s: Set<number>): boolean {
|
const pids = permIdsOnly(page);
|
if (pids.some((id) => s.has(id))) {
|
return true;
|
}
|
return s.has(page.id);
|
}
|
|
function moduleHasAny(mod: MenuNode, s: Set<number>): boolean {
|
return collectDescendantIds(mod).some((id) => id !== mod.id && s.has(id));
|
}
|
|
function syncPageAndModule(page: MenuNode, mod: MenuNode, s: Set<number>) {
|
const pids = permIdsOnly(page);
|
if (pids.length > 0) {
|
if (pids.every((id) => s.has(id))) {
|
s.add(page.id);
|
} else {
|
s.delete(page.id);
|
}
|
}
|
const pages = pageNodesUnderModule(mod);
|
if (pages.length > 0) {
|
if (pages.every((p) => pageSubtreeComplete(p, s))) {
|
s.add(mod.id);
|
} else {
|
s.delete(mod.id);
|
}
|
}
|
}
|
|
function syncAllAncestors(roots: MenuNode[], s: Set<number>) {
|
for (const mod of roots) {
|
const pages = pageNodesUnderModule(mod);
|
for (const page of pages) {
|
syncPageAndModule(page, mod, s);
|
}
|
}
|
}
|
|
const tableRows = computed<MatrixRow[]>(() => {
|
const roots = props.menuTree || [];
|
const rows: MatrixRow[] = [];
|
for (const mod of roots) {
|
const pages = pageNodesUnderModule(mod);
|
if (pages.length === 0) {
|
continue;
|
}
|
let moduleRowCount = 0;
|
for (const page of pages) {
|
const perms = permChildren(page);
|
moduleRowCount += Math.max(1, perms.length);
|
}
|
|
let isFirstInModule = true;
|
for (const page of pages) {
|
const perms = permChildren(page);
|
const pageRowspan = Math.max(1, perms.length);
|
if (perms.length === 0) {
|
rows.push({
|
module: mod,
|
page,
|
perm: null,
|
moduleRowspan: moduleRowCount,
|
pageRowspan: 1,
|
showModuleCell: isFirstInModule,
|
showPageCell: true,
|
});
|
isFirstInModule = false;
|
} else {
|
perms.forEach((perm, idx) => {
|
rows.push({
|
module: mod,
|
page,
|
perm,
|
moduleRowspan: moduleRowCount,
|
pageRowspan: perms.length,
|
showModuleCell: isFirstInModule && idx === 0,
|
showPageCell: idx === 0,
|
});
|
});
|
isFirstInModule = false;
|
}
|
}
|
}
|
return rows;
|
});
|
|
const innerRef = shallowRef(new Set<number>());
|
|
function sortedJson(ids: Iterable<number>) {
|
return JSON.stringify([...ids].sort((a, b) => a - b));
|
}
|
|
watch(
|
[() => props.modelValue, () => props.menuTree],
|
() => {
|
const inner = new Set(props.modelValue ?? []);
|
const before = sortedJson(inner);
|
syncAllAncestors(props.menuTree || [], inner);
|
innerRef.value = inner;
|
if (sortedJson(inner) !== before) {
|
emit("update:modelValue", [...inner]);
|
}
|
},
|
{ immediate: true, deep: true }
|
);
|
|
function commitSelection(next: Set<number>) {
|
syncAllAncestors(props.menuTree || [], next);
|
innerRef.value = next;
|
emit("update:modelValue", [...next]);
|
}
|
|
function hasId(id: number): boolean {
|
return innerRef.value.has(id);
|
}
|
|
function moduleFullyChecked(mod: MenuNode): boolean {
|
const s = innerRef.value;
|
const pages = pageNodesUnderModule(mod);
|
return pages.length > 0 && pages.every((p) => pageSubtreeComplete(p, s));
|
}
|
|
function moduleIndeterminate(mod: MenuNode): boolean {
|
const s = innerRef.value;
|
return moduleHasAny(mod, s) && !moduleFullyChecked(mod);
|
}
|
|
function pageFullyChecked(page: MenuNode): boolean {
|
return pageSubtreeComplete(page, innerRef.value);
|
}
|
|
function pageIndeterminate(page: MenuNode): boolean {
|
const s = innerRef.value;
|
const pids = permIdsOnly(page);
|
if (pids.length === 0) {
|
return false;
|
}
|
return pageHasAny(page, s) && !pageSubtreeComplete(page, s);
|
}
|
|
function toggleModule(mod: MenuNode, on: boolean) {
|
const inner = new Set(innerRef.value);
|
const ids = collectDescendantIds(mod);
|
if (on) {
|
ids.forEach((id) => inner.add(id));
|
} else {
|
ids.forEach((id) => inner.delete(id));
|
}
|
commitSelection(inner);
|
}
|
|
function togglePage(page: MenuNode, mod: MenuNode, on: boolean) {
|
const inner = new Set(innerRef.value);
|
const ids = collectDescendantIds(page);
|
if (on) {
|
ids.forEach((id) => inner.add(id));
|
} else {
|
ids.forEach((id) => inner.delete(id));
|
}
|
syncPageAndModule(page, mod, inner);
|
commitSelection(inner);
|
}
|
|
function togglePerm(permId: number, page: MenuNode, mod: MenuNode, on: boolean) {
|
const inner = new Set(innerRef.value);
|
if (on) {
|
inner.add(permId);
|
} else {
|
inner.delete(permId);
|
}
|
syncPageAndModule(page, mod, inner);
|
commitSelection(inner);
|
}
|
|
function spanMethod({
|
row,
|
columnIndex,
|
}: {
|
row: MatrixRow;
|
columnIndex: number;
|
}) {
|
if (columnIndex === 0) {
|
if (row.showModuleCell) {
|
return { rowspan: row.moduleRowspan, colspan: 1 };
|
}
|
return { rowspan: 0, colspan: 0 };
|
}
|
if (columnIndex === 1) {
|
if (row.showPageCell) {
|
return { rowspan: row.pageRowspan, colspan: 1 };
|
}
|
return { rowspan: 0, colspan: 0 };
|
}
|
return { rowspan: 1, colspan: 1 };
|
}
|
</script>
|
|
<style scoped>
|
.perm-matrix {
|
width: 100%;
|
}
|
.matrix-table {
|
width: 100%;
|
}
|
/* 行高约压到默认的一半:收紧单元格与勾选 */
|
.matrix-table :deep(.el-table__cell) {
|
padding: 3px 8px;
|
}
|
.matrix-table :deep(.cell) {
|
line-height: 1.2;
|
}
|
.matrix-table :deep(.el-checkbox) {
|
margin-right: 0;
|
height: auto;
|
}
|
.matrix-table :deep(.el-checkbox__label) {
|
padding-left: 6px;
|
}
|
.cell-inner {
|
display: flex;
|
align-items: center;
|
gap: 4px;
|
}
|
.cell-label {
|
font-size: 12px;
|
}
|
.perm-cell {
|
display: flex;
|
align-items: center;
|
gap: 4px;
|
}
|
.perm-label {
|
font-size: 12px;
|
}
|
.muted {
|
color: var(--el-text-color-secondary);
|
font-size: 12px;
|
line-height: 1.2;
|
}
|
</style>
|