518 lines
15 KiB
Vue
518 lines
15 KiB
Vue
<template>
|
||
<div class="main-wrapper tree-wrapper">
|
||
<div class="tree-container">
|
||
<Input v-model="userKeyword" placeholder="输入用户,回车查找" suffix="ios-search" search clearable
|
||
class="search-input" @on-search="onSearchUser" @on-clear="onClearUser" />
|
||
<GroupTreeSearchPanel ref="groupTreeRef" :treeApi="fetchGroupTree" :autoClick="true"
|
||
:showCloseButton="false" cacheKey="permission-view.group" @on-change="onGroupChange" />
|
||
</div>
|
||
<div class="main-container">
|
||
<div class="user-panel">
|
||
<div class="panel-title">
|
||
<template v-if="mode === 'group'">组用户列表</template>
|
||
<template v-else>用户搜索结果</template>
|
||
</div>
|
||
<div class="panel-body">
|
||
<div v-if="userLoading" class="loading-tip">加载中...</div>
|
||
<div v-else-if="userList.length === 0" class="empty-tip">
|
||
<template v-if="mode === 'group'">请选择左侧组节点</template>
|
||
<template v-else>未找到匹配用户</template>
|
||
</div>
|
||
<div v-else class="user-list">
|
||
<div v-for="user in userList" :key="`${user.id}-${user.groupId ?? ''}`" class="user-item"
|
||
:class="{ active: selectedUser?.id === user.id && selectedUser?.groupId === user.groupId }"
|
||
@click="onSelectUser(user)">
|
||
<div class="user-name">{{ userDisplayName(user) }}</div>
|
||
<div v-if="mode === 'user' && user.groupPath" class="user-path">{{ user.groupPath }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="perm-panel">
|
||
<div class="panel-title">
|
||
<template v-if="selectedUser">{{ selectedUser.realName || selectedUser.account }}({{
|
||
selectedUser.account }}) 权限浏览</template>
|
||
<template v-else>权限浏览</template>
|
||
</div>
|
||
<div v-if="!selectedUser" class="panel-body">
|
||
<div class="empty-tip">请选择左侧用户查看权限</div>
|
||
</div>
|
||
<div v-else class="perm-body">
|
||
<!-- 左列:所属部门(上) + 所属角色(下) -->
|
||
<div class="perm-side">
|
||
<div class="side-block">
|
||
<div class="side-title">所属部门</div>
|
||
<div class="side-body">
|
||
<div v-if="treeLoading" class="loading-tip small">加载中...</div>
|
||
<div v-else-if="deptTree.length === 0" class="empty-tip small">暂无部门归属</div>
|
||
<MyTree v-else :treeData="deptTree" :setting="treeSetting" />
|
||
</div>
|
||
</div>
|
||
<div class="side-block">
|
||
<div class="side-title">所属角色</div>
|
||
<div class="side-body">
|
||
<div v-if="treeLoading" class="loading-tip small">加载中...</div>
|
||
<div v-else-if="roleTree.length === 0" class="empty-tip small">暂无所属角色</div>
|
||
<MyTree v-else :treeData="roleTree" :setting="treeSetting" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 右列:已授权应用(原有) -->
|
||
<div class="perm-main">
|
||
<div class="side-title">已授权应用</div>
|
||
<div class="side-body">
|
||
<div v-if="treeLoading" class="loading-tip">权限加载中...</div>
|
||
<div v-else-if="permissionTree.length === 0" class="empty-tip">该用户暂无已授权应用模块</div>
|
||
<MyTree v-else ref="permTreeRef" :treeData="permissionTree" :setting="treeSetting" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { ref } from "vue";
|
||
import { Input } from "view-ui-plus";
|
||
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
|
||
import MyTree from "@/components/MyTree.vue";
|
||
import Apis from "@/api";
|
||
|
||
interface UserItem {
|
||
id?: number;
|
||
account?: string;
|
||
realName?: string;
|
||
isMain?: number;
|
||
/** 该行对应的组ID:同一用户多组时后端每个组出一行,选中行按该组查看权限 */
|
||
groupId?: number;
|
||
groupPath?: string;
|
||
}
|
||
|
||
interface ModuleNode {
|
||
id?: number;
|
||
parentId?: number;
|
||
name?: string;
|
||
customId?: string;
|
||
path?: string;
|
||
level?: number;
|
||
sortNo?: number;
|
||
children?: ModuleNode[];
|
||
}
|
||
|
||
interface AppNode {
|
||
id?: number;
|
||
name?: string;
|
||
appKey?: number;
|
||
pcIcon?: string;
|
||
sortNo?: number;
|
||
modules?: ModuleNode[];
|
||
}
|
||
|
||
interface CategoryNode {
|
||
id?: number;
|
||
name?: string;
|
||
sortNo?: number;
|
||
apps?: AppNode[];
|
||
}
|
||
|
||
interface GroupNode {
|
||
id?: number;
|
||
name?: string;
|
||
own?: boolean;
|
||
children?: GroupNode[];
|
||
}
|
||
|
||
interface RoleItem {
|
||
id?: number;
|
||
name?: string;
|
||
allAppFlag?: boolean;
|
||
appId?: number;
|
||
appName?: string;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
const groupTreeRef = ref<InstanceType<typeof GroupTreeSearchPanel> | null>(null);
|
||
const permTreeRef = ref<InstanceType<typeof MyTree> | null>(null);
|
||
|
||
const mode = ref<"group" | "user">("group");
|
||
const userKeyword = ref("");
|
||
const selectedGroup = ref<any>(null);
|
||
const selectedUser = ref<UserItem | null>(null);
|
||
const userList = ref<UserItem[]>([]);
|
||
const permissionTree = ref<any[]>([]);
|
||
const deptTree = ref<any[]>([]);
|
||
const roleTree = ref<any[]>([]);
|
||
const userLoading = ref(false);
|
||
const treeLoading = ref(false);
|
||
|
||
const treeSetting = ref({
|
||
view: { dblClickExpand: false, showIcon: false, showLine: true },
|
||
edit: { enable: false, showRemoveBtn: false, showRenameBtn: false, drag: false },
|
||
check: { enable: false },
|
||
data: {
|
||
simpleData: { enable: false },
|
||
key: { children: "children" },
|
||
},
|
||
callback: {},
|
||
});
|
||
|
||
function fetchGroupTree() {
|
||
return Apis.SysGroup.getTree();
|
||
}
|
||
|
||
function userDisplayName(user: UserItem): string {
|
||
const name = user.realName || user.account || "";
|
||
const account = user.account || "";
|
||
return name === account ? name : `${name}(${account})`;
|
||
}
|
||
|
||
function onGroupChange(node: any) {
|
||
mode.value = "group";
|
||
selectedGroup.value = node;
|
||
selectedUser.value = null;
|
||
clearPermPanels();
|
||
loadGroupUsers(node?.id);
|
||
}
|
||
|
||
function clearPermPanels() {
|
||
permissionTree.value = [];
|
||
deptTree.value = [];
|
||
roleTree.value = [];
|
||
}
|
||
|
||
async function loadGroupUsers(groupId?: number) {
|
||
if (!groupId) {
|
||
userList.value = [];
|
||
return;
|
||
}
|
||
userLoading.value = true;
|
||
try {
|
||
userList.value = (await Apis.GroupAuthz.groupUsers({ params: { groupId }, cacheFor: null })) as UserItem[];
|
||
} catch (e: any) {
|
||
console.warn(e?.message || "加载组用户失败");
|
||
userList.value = [];
|
||
} finally {
|
||
userLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function onSearchUser() {
|
||
const kw = userKeyword.value.trim();
|
||
if (!kw) {
|
||
onClearUser();
|
||
return;
|
||
}
|
||
mode.value = "user";
|
||
selectedGroup.value = null;
|
||
selectedUser.value = null;
|
||
clearPermPanels();
|
||
userLoading.value = true;
|
||
try {
|
||
userList.value = (await Apis.GroupAuthz.searchUsers({ params: { keyword: kw }, cacheFor: null })) as UserItem[];
|
||
} catch (e: any) {
|
||
console.warn(e?.message || "搜索用户失败");
|
||
userList.value = [];
|
||
} finally {
|
||
userLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function onClearUser() {
|
||
userKeyword.value = "";
|
||
mode.value = "group";
|
||
selectedUser.value = null;
|
||
clearPermPanels();
|
||
if (selectedGroup.value?.id) {
|
||
loadGroupUsers(Number(selectedGroup.value.id));
|
||
} else {
|
||
userList.value = [];
|
||
}
|
||
}
|
||
|
||
async function onSelectUser(user: UserItem) {
|
||
selectedUser.value = user;
|
||
treeLoading.value = true;
|
||
const userId = user.id as number;
|
||
// 行自带组ID(多组用户每个组一行)优先;组列表模式回退左侧选中组
|
||
const groupId = user.groupId ?? (selectedGroup.value?.id ? Number(selectedGroup.value.id) : undefined);
|
||
// 三块数据并行加载,互不阻塞
|
||
// userGroupTree 为新增接口:apiDefinitions 未重新生成(npm run api:gen)时方法不存在,降级为友好报错
|
||
const userGroupTreeApi = (Apis.GroupAuthz as any).userGroupTree
|
||
? Apis.GroupAuthz.userGroupTree({ params: { userId }, cacheFor: null })
|
||
: Promise.reject(new Error("所属部门接口未生成,请重启后端后执行 npm run api:gen"));
|
||
const [permRes, deptRes, roleRes] = await Promise.allSettled([
|
||
Apis.GroupAuthz.userPermissionTree({
|
||
params: { userId, groupId: groupId ? Number(groupId) : undefined },
|
||
cacheFor: null,
|
||
}),
|
||
userGroupTreeApi,
|
||
Apis.GroupAuthz.userRoles({ params: { userId }, cacheFor: null }),
|
||
]);
|
||
if (permRes.status === "fulfilled") {
|
||
permissionTree.value = formatPermissionTree((permRes.value as CategoryNode[]) || []);
|
||
} else {
|
||
console.warn(permRes.reason?.message || "加载权限树失败");
|
||
permissionTree.value = [];
|
||
}
|
||
if (deptRes.status === "fulfilled") {
|
||
deptTree.value = formatDeptTree((deptRes.value as GroupNode[]) || []);
|
||
} else {
|
||
console.warn(deptRes.reason?.message || "加载所属部门失败");
|
||
deptTree.value = [];
|
||
}
|
||
if (roleRes.status === "fulfilled") {
|
||
roleTree.value = formatRoleTree((roleRes.value as RoleItem[]) || []);
|
||
} else {
|
||
console.warn(roleRes.reason?.message || "加载所属角色失败");
|
||
roleTree.value = [];
|
||
}
|
||
treeLoading.value = false;
|
||
}
|
||
|
||
// 所属部门:祖先链树,用户直接归属的组加「本组」标记
|
||
function formatDeptTree(nodes: GroupNode[]): any[] {
|
||
return (nodes || []).map((n) => ({
|
||
id: `grp_${n.id}`,
|
||
name: n.own ? `${n.name || ""}(本组)` : n.name || "",
|
||
open: true,
|
||
children: formatDeptTree(n.children || []),
|
||
isParent: (n.children || []).length > 0,
|
||
}));
|
||
}
|
||
|
||
// 所属角色:2 级树 —— 一级为「平台角色」(全应用) / 应用名分组,二级为角色
|
||
function formatRoleTree(roles: RoleItem[]): any[] {
|
||
const groups = new Map<string, any>();
|
||
for (const r of roles) {
|
||
const key = r.allAppFlag ? "平台角色" : r.appName || "未知应用";
|
||
if (!groups.has(key)) {
|
||
groups.set(key, { id: `rgrp_${key}`, name: key, open: true, isParent: true, children: [] });
|
||
}
|
||
groups.get(key).children.push({
|
||
id: `role_${r.id}`,
|
||
name: r.disabled ? `${r.name}(已禁用)` : r.name,
|
||
});
|
||
}
|
||
// 平台角色置顶,其余按应用名
|
||
return [...groups.values()].sort((a, b) =>
|
||
a.name === "平台角色" ? -1 : b.name === "平台角色" ? 1 : a.name.localeCompare(b.name)
|
||
);
|
||
}
|
||
|
||
function formatPermissionTree(categories: CategoryNode[]): any[] {
|
||
const result: any[] = [];
|
||
for (const cat of categories) {
|
||
const catNode: any = {
|
||
id: `cat_${cat.id}`,
|
||
name: cat.name || `应用类型${cat.id}`,
|
||
open: true,
|
||
children: formatApps(cat.apps || [], cat.id),
|
||
};
|
||
catNode.isParent = catNode.children.length > 0;
|
||
result.push(catNode);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function formatApps(apps: AppNode[], catId?: number): any[] {
|
||
return (apps || []).map((app) => ({
|
||
id: `app_${app.id}_${catId || 0}`,
|
||
name: app.name || `应用${app.id}`,
|
||
open: true,
|
||
children: formatModules(app.modules || []),
|
||
}));
|
||
}
|
||
|
||
function formatModules(modules: ModuleNode[]): any[] {
|
||
return (modules || []).map((m) => {
|
||
const children = formatModules(m.children || []);
|
||
return {
|
||
id: `mod_${m.id}`,
|
||
name: m.name || `模块${m.id}`,
|
||
open: true,
|
||
children,
|
||
isParent: children.length > 0,
|
||
};
|
||
});
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.tree-container {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 8px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.main-container {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: row;
|
||
background: #fff;
|
||
border-radius: 4px;
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.user-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
width: 240px;
|
||
min-width: 240px;
|
||
border-right: 1px solid #e8eaec;
|
||
}
|
||
|
||
.user-search {
|
||
padding: 12px 16px;
|
||
|
||
.search-input {
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
.perm-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.panel-title {
|
||
padding: 10px 16px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
border-bottom: 1px solid #e8eaec;
|
||
background: #fafafa;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.panel-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
padding: 8px 0;
|
||
}
|
||
|
||
/* 权限浏览 3 块布局:左列(所属部门/所属角色) + 右列(已授权应用) */
|
||
.perm-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: row;
|
||
}
|
||
|
||
.perm-side {
|
||
width: 260px;
|
||
min-width: 260px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
border-right: 1px solid #e8eaec;
|
||
}
|
||
|
||
.side-block {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
&+.side-block {
|
||
border-top: 1px solid #e8eaec;
|
||
}
|
||
}
|
||
|
||
.perm-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
}
|
||
|
||
.side-title {
|
||
flex-shrink: 0;
|
||
padding: 8px 16px;
|
||
font-weight: 600;
|
||
color: #515a6e;
|
||
font-size: 13px;
|
||
background: #fafafa;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.side-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
padding: 6px 0;
|
||
}
|
||
|
||
.empty-tip.small,
|
||
.loading-tip.small {
|
||
padding: 20px 12px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.user-item {
|
||
padding: 10px 16px;
|
||
cursor: pointer;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
transition: background 0.15s;
|
||
|
||
&:hover {
|
||
background: #f5f7fa;
|
||
}
|
||
|
||
&.active {
|
||
background: #fff3e0;
|
||
}
|
||
}
|
||
|
||
.user-name {
|
||
color: #333;
|
||
font-size: 14px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.user-path {
|
||
color: #999;
|
||
font-size: 12px;
|
||
margin-top: 4px;
|
||
line-height: 1.3;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.empty-tip,
|
||
.loading-tip {
|
||
color: #999;
|
||
padding: 48px 16px;
|
||
text-align: center;
|
||
}
|
||
|
||
.loading-tip {
|
||
color: #2d8cf0;
|
||
}
|
||
|
||
:deep(.group-tree-search-panel) {
|
||
height: 100%;
|
||
}
|
||
|
||
:deep(.ztree) {
|
||
font-size: 14px;
|
||
|
||
li {
|
||
line-height: 26px;
|
||
}
|
||
|
||
a {
|
||
color: #333;
|
||
}
|
||
}
|
||
</style>
|