Files
userbase/src/views/role-authz/AppModuleCheckTree.vue
T
2026-08-18 23:31:27 +08:00

452 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="authz-panel role-authz-tree">
<div class="search-wrap authz-toolbar">
<div class="toolbar-left">
<Select v-model="selectedCategoryId" class="type-filter-select" placeholder="全部应用类型" clearable>
<Option v-for="opt in categoryOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</Option>
</Select>
<h5 class="title mb-0">应用授权</h5>
<div class="role-text">
当前角色:<span class="role-name">{{ roleName }}</span>
</div>
</div>
<div class="toolbar-right">
<Input v-model="searchKeyword" placeholder="输入应用或模块名称搜索,回车过滤" clearable suffix="ios-search"
style="width: 260px" @on-enter="onSearch" @on-clear="onClearSearch" />
</div>
</div>
<Spin fix v-if="loading" />
<div v-if="hasVisibleCategories" class="cat-columns">
<div v-for="(col, ci) in columns" :key="ci" class="cat-col">
<div v-for="item in col" :key="String(item.cat.id)" class="cat-card">
<div class="cat-card-header" @click="toggleCollapse(item.cat.id)">
<Icon class="collapse-icon"
:type="collapsedMap[String(item.cat.id)] ? 'ios-arrow-forward' : 'ios-arrow-down'" />
<span class="cat-name" :title="item.cat.name">{{ item.cat.name }}</span>
<span class="cat-count">{{ item.treeData.length }}个应用</span>
</div>
<div v-show="!collapsedMap[String(item.cat.id)]" class="cat-card-body">
<MyTree :treeData="item.treeData" :setting="treeSetting" :autoClick="false"
@on-tree-check="onTreeCheck" />
</div>
</div>
</div>
</div>
<div v-else class="empty-tip">
<template v-if="categoryList.length === 0">该角色暂无可授权的应用</template>
<template v-else>未找到匹配的应用或模块</template>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, shallowRef, reactive, watch } from "vue";
import { debounce } from "lodash";
import Apis from "@/api";
import MyTree from "@/components/MyTree.vue";
const props = defineProps<{ roleId: number | null; roleName?: string }>();
type AppModuleVo = {
id?: number;
name?: string;
level?: number;
checked?: boolean;
children?: AppModuleVo[];
};
type AppItem = { id?: number; name?: string; sortNo?: number; modules?: AppModuleVo[] };
type CategoryItem = { id?: number; name?: string; apps?: AppItem[] };
type AuthzTreeNode = {
id: string;
name: string;
open: boolean;
checked: boolean;
nodeType: "app" | "module";
rawId: number;
appId: number;
children?: AuthzTreeNode[];
};
type CategoryTreeItem = { cat: CategoryItem; treeData: AuthzTreeNode[]; weight: number };
const COLUMN_COUNT = 3;
const jq = () => (window as any).$;
const loading = ref(false);
const categoryList = shallowRef<CategoryItem[]>([]);
const selectedCategoryId = ref<number | null>(null);
const searchKeyword = ref("");
const appliedKeyword = ref("");
const collapsedMap = reactive<Record<string, boolean>>({});
const treeSetting = {
view: { dblClickExpand: false, showIcon: false },
check: {
enable: true,
chkStyle: "checkbox",
// 不自动级联:模块逐条独立授权(角色无"子组继承"概念)
chkboxType: { Y: "", N: "" },
},
callback: {},
};
// ---------------- 工具 ----------------
function moduleNameHit(node: AppModuleVo, kw: string): boolean {
if ((node.name || "").toLowerCase().includes(kw)) return true;
return (node.children || []).some((c) => moduleNameHit(c, kw));
}
function countTotalModules(nodes: AppModuleVo[]): number {
let count = 0;
for (const node of nodes) {
count++;
count += countTotalModules(node.children || []);
}
return count;
}
function isAppChecked(app: AppItem): boolean {
const modules = app.modules || [];
if (modules.length === 0) return false;
return modules.every((m) => isModuleAllChecked(m));
}
function isModuleAllChecked(node: AppModuleVo): boolean {
if (!node.checked) return false;
return (node.children || []).every((c) => isModuleAllChecked(c));
}
function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
return {
id: `mod_${m.id}`,
name: String(m.name || ""),
open: true,
checked: !!m.checked,
nodeType: "module",
rawId: Number(m.id),
appId,
children: (m.children || []).map((c) => buildModuleNode(c, appId)),
};
}
function buildAppNode(app: AppItem): AuthzTreeNode {
const appId = Number(app.id);
return {
id: `app_${appId}`,
name: String(app.name || ""),
open: true,
checked: isAppChecked(app),
nodeType: "app",
rawId: appId,
appId,
children: (app.modules || []).map((m) => buildModuleNode(m, appId)),
};
}
// ---------------- 数据加载 ----------------
async function loadRoleModuleTree(roleId: number) {
loading.value = true;
try {
const [apps, appListRes, typeRes] = await Promise.all([
Apis.AppRole.roleModuleTree({ params: { roleId }, cacheFor: null }),
Apis.AppSettings.appList({ params: { pageIndex: 1, pageSize: 9999 }, cacheFor: null }),
Apis.AppSettings.appTypeList({ cacheFor: null }),
]);
const appArr: any[] = appListRes?.content ?? appListRes ?? [];
const catMap = new Map<number, number>();
appArr.forEach((a: any) => { if (a.id != null) catMap.set(Number(a.id), Number(a.categoryId)); });
const typeMap = new Map<number, string>();
(typeRes ?? []).forEach((t: any) => typeMap.set(Number(t.id), String(t.name || `分类${t.id}`)));
const groups = new Map<number, CategoryItem>();
(apps ?? []).forEach((app: any) => {
const cid = catMap.get(Number(app.id)) ?? -1;
if (!groups.has(cid)) {
groups.set(cid, { id: cid, name: typeMap.get(cid) || (cid === -1 ? "未分类" : `分类${cid}`), apps: [] });
}
groups.get(cid)!.apps!.push(app);
});
const list = Array.from(groups.values());
list.forEach((g) => (g.apps = (g.apps || []).slice().sort((a: any, b: any) => (a.sortNo ?? 0) - (b.sortNo ?? 0))));
list.sort((a, b) => Number(a.id) - Number(b.id));
categoryList.value = list;
} finally {
loading.value = false;
}
}
const categoryOptions = computed(() =>
categoryList.value.map((c) => ({ value: Number(c.id), label: String(c.name || `分类${c.id}`) }))
);
watch(categoryOptions, (opts) => {
if (selectedCategoryId.value != null && !opts.some((o) => o.value === Number(selectedCategoryId.value))) {
selectedCategoryId.value = null;
}
});
const categoryTreeList = computed<CategoryTreeItem[]>(() => {
const kw = (appliedKeyword.value || "").trim().toLowerCase();
return categoryList.value
.filter((cat) => selectedCategoryId.value == null || Number(cat.id) === Number(selectedCategoryId.value))
.map((cat) => {
const apps = (cat.apps || []).filter((app) => {
if (!kw) return true;
if ((app.name || "").toLowerCase().includes(kw)) return true;
return (app.modules || []).some((m) => moduleNameHit(m, kw));
});
return { cat, apps };
})
.filter((x) => x.apps.length > 0)
.map(({ cat, apps }) => ({
cat,
treeData: apps.map((app) => buildAppNode(app)),
weight: 2 + apps.reduce((sum, a) => sum + 1 + countTotalModules(a.modules || []), 0),
}));
});
const hasVisibleCategories = computed(() => categoryTreeList.value.length > 0);
const columns = computed<CategoryTreeItem[][]>(() => {
const cols: CategoryTreeItem[][] = Array.from({ length: COLUMN_COUNT }, () => []);
const weights = new Array(COLUMN_COUNT).fill(0);
for (const item of categoryTreeList.value) {
let idx = 0;
for (let i = 1; i < COLUMN_COUNT; i++) if (weights[i] < weights[idx]) idx = i;
cols[idx].push(item);
weights[idx] += item.weight;
}
return cols;
});
function toggleCollapse(catId: number | undefined) {
const key = String(catId);
collapsedMap[key] = !collapsedMap[key];
}
// ---------------- 本地数据同步 ----------------
function findLocalApp(appId: number): AppItem | null {
for (const cat of categoryList.value || []) {
const app = (cat.apps || []).find((a) => Number(a.id) === appId);
if (app) return app;
}
return null;
}
function walkModules(nodes: AppModuleVo[], fn: (n: AppModuleVo) => void) {
for (const node of nodes) {
fn(node);
walkModules(node.children || [], fn);
}
}
function setLocalAppChecked(appId: number, checked: boolean) {
const app = findLocalApp(appId);
if (!app) return;
walkModules(app.modules || [], (n) => { n.checked = checked; });
}
function setLocalModuleState(appId: number, moduleId: number, patch: Partial<AppModuleVo>) {
const app = findLocalApp(appId);
if (!app) return;
walkModules(app.modules || [], (n) => { if (Number(n.id) === moduleId) Object.assign(n, patch); });
}
function getTreeObjByNode(node: any) {
const $ = jq();
const treeId = String(node?.tId || "").replace(/_\d+$/, "");
return $?.fn?.zTree?.getZTreeObj(treeId) || null;
}
function syncAppNodeState(treeObj: any, appId: number) {
if (!treeObj) return;
const app = findLocalApp(appId);
if (!app) return;
const appNode = treeObj.getNodesByFilter((n: any) => n.id === `app_${appId}`, true);
if (!appNode) return;
appNode.checked = isAppChecked(app);
treeObj.checkNode(appNode, appNode.checked, false);
}
// ---------------- 授权操作(勾选即保存,全量覆盖 + 防抖) ----------------
function onTreeCheck(node: any) {
const roleId = props.roleId;
if (!roleId || !node) return;
const checked = !!node.checked;
const isApp = node.nodeType === "app";
const treeObj = getTreeObjByNode(node);
if (isApp) {
setLocalAppChecked(node.appId, checked);
if (treeObj) {
const affected = treeObj.transformToArray([node]) || [];
affected.forEach((n: any) => {
if (n !== node) treeObj.checkNode(n, checked, false);
n.checked = checked;
});
}
} else {
setLocalModuleState(node.appId, node.rawId, { checked });
syncAppNodeState(treeObj, node.appId);
}
scheduleSave();
}
const saveNow = async () => {
const roleId = props.roleId;
if (!roleId) return;
const moduleIds: number[] = [];
for (const cat of categoryList.value || []) {
for (const app of cat.apps || []) {
walkModules(app.modules || [], (n) => { if (n.checked) moduleIds.push(Number(n.id)); });
}
}
try {
await Apis.AppRole.saveRoleModules({ data: { roleId, moduleIds }, cacheFor: null });
} catch (e: any) {
console.warn(e?.message || "保存失败");
await loadRoleModuleTree(roleId);
}
};
const scheduleSave = debounce(saveNow, 400);
// ---------------- 搜索 ----------------
function onSearch() {
appliedKeyword.value = searchKeyword.value;
}
function onClearSearch() {
appliedKeyword.value = "";
}
watch(
() => props.roleId,
(id) => {
if (id != null) loadRoleModuleTree(id);
else categoryList.value = [];
},
{ immediate: true }
);
</script>
<style lang="scss" scoped>
.role-authz-tree {
position: relative;
flex: 1;
min-height: 320px;
padding: 0;
background: #fff;
overflow: auto;
}
.authz-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 10px 0;
position: sticky;
top: 0;
background: #fff;
z-index: 2;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.type-filter-select {
width: 180px;
flex-shrink: 0;
}
.role-text {
color: #666;
font-size: 13px;
}
.role-name {
color: #2d8cf0;
font-weight: 600;
}
.cat-columns {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
}
.cat-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.cat-card {
border: 1px solid #e8eaec;
border-radius: 4px;
background: #fff;
overflow: hidden;
}
.cat-card-header {
display: flex;
align-items: center;
padding: 8px 12px;
background: #fafafa;
border-bottom: 1px solid #e8eaec;
cursor: pointer;
user-select: none;
&:hover {
background: #f0f7ff;
}
}
.collapse-icon {
color: #999;
font-size: 14px;
flex-shrink: 0;
}
.cat-name {
font-weight: 600;
font-size: 13px;
color: #333;
margin-left: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cat-count {
color: #999;
font-size: 12px;
margin-left: 8px;
flex: 1;
white-space: nowrap;
}
.cat-card-body {
padding: 6px 8px;
}
.empty-tip {
color: #999;
padding: 48px 0;
text-align: center;
}
:deep(.ztree li a) {
max-width: calc(100% - 30px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
</style>