Files
userbase/src/views/PermissionView.vue
T
2026-09-02 08:41:35 +08:00

518 lines
15 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="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>