init: UserBaseFront (frontend)

This commit is contained in:
chenxing
2026-08-10 14:41:13 +08:00
commit eea881b616
174 changed files with 32833 additions and 0 deletions
+513
View File
@@ -0,0 +1,513 @@
<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" class="user-item"
:class="{ active: selectedUser?.id === user.id }" @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, Message } 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;
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) {
Message.error(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) {
Message.error(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;
const groupId = selectedGroup.value?.id;
// 三块数据并行加载,互不阻塞
// 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 {
Message.error(permRes.reason?.message || "加载权限树失败");
permissionTree.value = [];
}
if (deptRes.status === "fulfilled") {
deptTree.value = formatDeptTree((deptRes.value as GroupNode[]) || []);
} else {
Message.error(deptRes.reason?.message || "加载所属部门失败");
deptTree.value = [];
}
if (roleRes.status === "fulfilled") {
roleTree.value = formatRoleTree((roleRes.value as RoleItem[]) || []);
} else {
Message.error(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>