Files
UserbaseFront/src/views/role-authz/AppModuleCheckTree.vue
T
chenxing 628a914e19
build / build (push) Successful in 3m16s
feat(authz): 应用类型卡片新增「全部展开」开关 + 用户管理列宽让位部门列
- MyTree 新增受控 prop expandAll(默认 false):在 zTree 初始化之后套用并监听变化,
  数据重载(切组/搜索过滤)后自动重新展开,规避调用方与 initTree 抢 nextTick 时序
- 组授权 / 角色授权的应用类型卡片头部加「全部展开 + small Switch」,默认关闭,
  控制该类型下全部应用与模块的展开收起;每个卡片独立,状态按 category id 保存
- 开关外层 @click.stop:卡片头部本身绑定 toggleCollapse,不阻断冒泡会连带折叠卡片
- 角色授权「用户管理」表格:账号/姓名改定宽(110/90)、操作 80,部门 minWidth 吃剩余
  宽度,把空间留给组路径列
2026-09-16 19:14:23 +08:00

440 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>
<!-- 开关外层 @click.stop:否则点击会冒泡到卡片头部触发折叠 -->
<span class="expand-all" @click.stop>
<span class="expand-all-label">全部展开</span>
<Switch size="small" v-model="expandAllMap[String(item.cat.id)]" />
</span>
</div>
<div v-show="!collapsedMap[String(item.cat.id)]" class="cat-card-body">
<MyTree :treeData="item.treeData" :setting="treeSetting" :autoClick="false" :isCheck="true"
:expandAll="!!expandAllMap[String(item.cat.id)]" @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";
import { syncAppModulesFromTree, walkModules } from "@/utils/treeCascade";
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;
isParent?: boolean;
children?: AuthzTreeNode[];
};
type CategoryTreeItem = { cat: CategoryItem; treeData: AuthzTreeNode[]; weight: number };
const COLUMN_COUNT = 3;
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 expandAllMap = reactive<Record<string, boolean>>({});
const treeSetting = {
view: { dblClickExpand: false, showIcon: false },
check: {
enable: true,
chkStyle: "checkbox",
// 原生级联即可满足需求:
// Y="ps" 勾选子->父/祖先全部勾选、勾选父->子孙全部勾选;N="s" 取消父->子孙全部取消、取消子->父/祖先不受影响
chkboxType: { Y: "ps", N: "s" },
},
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));
}
// 空 children 不挂键:zTree 只要见到 children 是数组就强制 isParent=true 而渲染 +/−(叶子也会出现展开图标)
function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
const kids = (m.children || []).map((c) => buildModuleNode(c, appId));
const node: AuthzTreeNode = {
id: `mod_${m.id}`,
name: String(m.name || ""),
open: true,
checked: !!m.checked,
nodeType: "module",
rawId: Number(m.id),
appId,
isParent: kids.length > 0,
};
if (kids.length > 0) node.children = kids;
return node;
}
function buildAppNode(app: AppItem): AuthzTreeNode {
const appId = Number(app.id);
const kids = (app.modules || []).map((m) => buildModuleNode(m, appId));
const node: AuthzTreeNode = {
id: `app_${appId}`,
name: String(app.name || ""),
open: false,
checked: isAppChecked(app),
nodeType: "app",
rawId: appId,
appId,
isParent: kids.length > 0,
};
if (kids.length > 0) node.children = kids;
return node;
}
// ---------------- 数据加载 ----------------
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;
}
// walkModules / 本地勾选态同步(syncAppModulesFromTree)统一在 utils/treeCascade.ts
// ---------------- 授权操作(级联由 zTree 原生 chkboxType 完成,勾选即保存,全量覆盖 + 防抖) ----------------
function onTreeCheck(node: any) {
const roleId = props.roleId;
if (!roleId || !node) return;
// zTree 原生级联已生效,把该应用最终勾选态同步回本地数据(saveNow 的提交依据)
const app = findLocalApp(node.appId);
if (!app) return;
syncAppModulesFromTree(app, node);
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;
}
.expand-all {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 4px;
}
.expand-all-label {
font-size: 12px;
color: #999;
white-space: nowrap;
}
.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>