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