This repository has been archived on 2026-09-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
UserBaseFront/src/views/GroupAuth.vue
T
chenxing 02f1fd2b14
deploy-userbase-front / build-and-deploy (push) Successful in 2m27s
修复重复弹msg的bug
2026-08-18 23:31:27 +08:00

725 lines
21 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="page-wrapper tree-wrapper">
<GroupTreeSearchPanel class="tree-container group-auth-tree" v-model:curTreeNode="selectedGroupNode"
:treeApi="groupTreeApi" :defaultSearchVisible="true" :showCloseButton="false" searchPlaceholder="输入组名称搜索"
cacheKey="bsch.group-auth.group-node" @on-change="handleGroupChange" />
<div class="main-container group-auth-main">
<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="group-text">
当前组:
<span class="group-name">{{ currentGroupName || '未选择组' }}</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>
<Alert v-if="!selectedGroupId" type="warning" show-icon>
请选择组后再进行授权设置
</Alert>
<div v-else class="authz-panel">
<Spin fix v-if="panelLoading" />
<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>
<span class="inherit-cap">子组继承</span>
</div>
<div v-show="!collapsedMap[String(item.cat.id)]" class="cat-card-body">
<MyTree :treeData="item.treeData" :setting="treeSetting" :autoClick="false"
@on-tree-check="onTreeCheck" />
</div>
</div>
</div>
</div>
<div v-else class="empty-tip">
<template v-if="nonEmptyCategories.length === 0">
当前组节点未开通任何应用,<router-link class="open-link" :to="{ name: 'AppOpen' }">去开通</router-link>
</template>
<template v-else>未找到匹配的应用或模块</template>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref, shallowRef, watch } from "vue";
import { Message } from "view-ui-plus";
import Apis from "@/api";
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
import MyTree from "@/components/MyTree.vue";
import { useDefaultStore } from "@/store";
defineOptions({ name: "GroupAuth" });
type AppModuleVo = {
id?: number;
name?: string;
level?: number;
checked?: boolean;
inheritFlag?: boolean;
children?: AppModuleVo[];
};
type AppItem = {
id?: number;
name?: string;
sortNo?: number;
modules?: AppModuleVo[];
};
type CategoryItem = {
id?: number;
name?: string;
apps?: AppItem[];
};
type DictItem = {
k: number | string;
v: string;
};
/** zTree 节点:应用为一级节点,模块为其子节点 */
type AuthzTreeNode = {
id: string;
name: string;
open: boolean;
checked: boolean;
inheritFlag: boolean;
nodeType: "app" | "module";
rawId: number;
appId: number;
children?: AuthzTreeNode[];
};
type CategoryTreeItem = {
cat: CategoryItem;
treeData: AuthzTreeNode[];
weight: number;
};
const COLUMN_COUNT = 3;
const jq = () => (window as any).$;
const store = useDefaultStore();
const groupTreeApi = () => Apis.SysGroup.getTree({ cacheFor: null });
const selectedGroupNode = ref<any>({});
// shallowRef:节点勾选状态的局部变更不触发树重建,只有整体重新加载才重建
const categoryList = shallowRef<CategoryItem[]>([]);
const selectedCategoryId = ref<number | null>(null);
const pageLoading = ref(false);
const searchKeyword = ref("");
const appliedKeyword = ref("");
const collapsedMap = reactive<Record<string, boolean>>({});
const fallbackModeItems: DictItem[] = [
{ k: 0, v: "追加" },
{ k: 1, v: "覆盖" },
{ k: 2, v: "删除" },
];
const selectedGroupId = computed(() => {
const id = Number(selectedGroupNode.value?.id);
return Number.isFinite(id) ? id : null;
});
const currentGroupName = computed(() => selectedGroupNode.value?.name || "");
const panelLoading = computed(() => pageLoading.value);
const authzModeItems = computed<DictItem[]>(() => {
const dict = (store.getSysDict as any) || {};
const preferredKeys = [
"groupauthzmode",
"groupAuthzMode",
"authzmode",
"authzMode",
"schoolappmode",
"schoolAppMode",
];
const isMatch = (list: any) => {
if (!Array.isArray(list)) return false;
const keys = list.map((x: any) => Number(x?.k ?? x?.key));
return keys.includes(0) && keys.includes(1) && keys.includes(2);
};
for (const key of preferredKeys) {
if (isMatch(dict[key])) {
return dict[key].map((x: any) => ({ k: Number(x.k ?? x.key), v: String(x.v ?? x.value ?? x.name) }));
}
}
const firstMatched = Object.values(dict).find(isMatch) as any[] | undefined;
if (firstMatched) {
return firstMatched.map((x: any) => ({ k: Number(x.k ?? x.key), v: String(x.v ?? x.value ?? x.name) }));
}
return fallbackModeItems;
});
const addMode = computed(() => Number(authzModeItems.value.find((x) => Number(x.k) === 0)?.k ?? 0));
const deleteMode = computed(() => Number(authzModeItems.value.find((x) => Number(x.k) === 2)?.k ?? 2));
// 仅保留有已开通应用的分类
const nonEmptyCategories = computed(() => (categoryList.value || []).filter((c) => (c.apps || []).length > 0));
const categoryOptions = computed(() =>
nonEmptyCategories.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;
}
});
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 isAppInherit(app: AppItem): boolean {
const modules = app.modules || [];
if (modules.length === 0) return false;
return modules.every((m) => isModuleAllInherit(m));
}
function isModuleAllChecked(node: AppModuleVo): boolean {
if (!node.checked) return false;
return (node.children || []).every((c) => isModuleAllChecked(c));
}
function isModuleAllInherit(node: AppModuleVo): boolean {
if (!node.inheritFlag) return false;
return (node.children || []).every((c) => isModuleAllInherit(c));
}
function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
return {
id: `mod_${m.id}`,
name: String(m.name || ""),
open: true,
checked: !!m.checked,
inheritFlag: !!m.inheritFlag,
nodeType: "module",
rawId: Number(m.id),
appId,
children: (m.children || []).map((c) => buildModuleNode(c, appId)),
};
}
function buildAppNode(app: AppItem): AuthzTreeNode {
const appId = Number(app.id);
return {
id: `app_${appId}`,
name: String(app.name || ""),
open: true,
checked: isAppChecked(app),
inheritFlag: isAppInherit(app),
nodeType: "app",
rawId: appId,
appId,
children: (app.modules || []).map((m) => buildModuleNode(m, appId)),
};
}
// 分类 -> zTree 数据(应用为一级节点,模块为子节点);weight 用于三列均衡
const categoryTreeList = computed<CategoryTreeItem[]>(() => {
const kw = (appliedKeyword.value || "").trim().toLowerCase();
return nonEmptyCategories.value
.filter((cat) => selectedCategoryId.value == null || selectedCategoryId.value === "" || 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];
}
// ---------------- zTree 配置 ----------------
/** 在每个节点行尾追加"子组继承"复选框 */
function addDiyDom(treeId: string, treeNode: any) {
const $ = jq();
if (!$) return;
const inheritId = `${treeNode.tId}_inherit`;
if ($(`#${inheritId}`).length > 0) return;
$(`#${treeNode.tId}_a`).after(
`<span class="inherit-wrap" title="子组继承"><input id="${inheritId}" type="checkbox" class="inherit-chk" /></span>`
);
const el = $(`#${inheritId}`);
el.prop("checked", !!treeNode.inheritFlag);
el.prop("disabled", !treeNode.checked);
el.on("change", function (this: HTMLInputElement) {
onInheritToggle(treeId, treeNode, this.checked);
});
}
const treeSetting = {
view: {
dblClickExpand: false,
showIcon: false,
addDiyDom,
},
check: {
enable: true,
chkStyle: "checkbox",
// 不自动级联:模块逐条独立授权;应用节点勾选后由代码手动同步子节点
chkboxType: { Y: "", N: "" },
},
callback: {},
};
function getTreeObjByNode(node: any) {
const $ = jq();
const treeId = String(node?.tId || "").replace(/_\d+$/, "");
return $?.fn?.zTree?.getZTreeObj(treeId) || null;
}
/** 模块勾选变化后,按本地数据回算应用节点的勾选/继承态并同步到 zTree */
function syncAppNodeState(treeObj: any, appId: number) {
if (!treeObj) return;
const app = findLocalApp(appId);
if (!app) return;
const appNode = treeObj.getNodesByFilter((n: any) => n.id === `app_${appId}`, true);
if (!appNode) return;
appNode.checked = isAppChecked(app);
appNode.inheritFlag = isAppInherit(app);
treeObj.checkNode(appNode, appNode.checked, false);
}
/** 按 zTree 当前节点状态刷新所有"子组继承"复选框的勾选与禁用态 */
function syncInheritDoms(treeObj: any) {
const $ = jq();
if (!treeObj || !$) return;
const nodes = treeObj.transformToArray(treeObj.getNodes()) || [];
nodes.forEach((n: any) => {
const el = $(`#${n.tId}_inherit`);
if (!el.length) return;
el.prop("checked", !!n.inheritFlag);
el.prop("disabled", !n.checked);
});
}
// ---------------- 数据加载 ----------------
async function ensureSysDict() {
if (Object.keys(store.getSysDict || {}).length === 0) {
await store.fetchSysDict();
}
}
async function loadGroupModuleTree(groupId: number) {
pageLoading.value = true;
try {
const data = await Apis.GroupAuthz.groupModuleTreeByCategory({ params: { groupId }, cacheFor: null });
categoryList.value = Array.isArray(data) ? data : [];
} finally {
pageLoading.value = false;
}
}
async function reloadCurrent() {
if (selectedGroupId.value) {
await loadGroupModuleTree(selectedGroupId.value);
}
}
async function handleGroupChange(node: any) {
selectedGroupNode.value = node || {};
const nodeId = Number(node?.id);
if (!Number.isFinite(nodeId)) {
categoryList.value = [];
return;
}
await loadGroupModuleTree(nodeId);
}
// ---------------- 本地数据同步(不触发树重建) ----------------
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;
}
function walkModules(nodes: AppModuleVo[], fn: (n: AppModuleVo) => void) {
for (const node of nodes) {
fn(node);
walkModules(node.children || [], fn);
}
}
function setLocalAppChecked(appId: number, checked: boolean) {
const app = findLocalApp(appId);
if (!app) return;
walkModules(app.modules || [], (n) => {
n.checked = checked;
if (!checked) n.inheritFlag = false;
});
}
function setLocalAppInherit(appId: number, inherit: boolean) {
const app = findLocalApp(appId);
if (!app) return;
walkModules(app.modules || [], (n) => {
if (n.checked) n.inheritFlag = inherit;
});
}
function setLocalModuleState(appId: number, moduleId: number, patch: Partial<AppModuleVo>) {
const app = findLocalApp(appId);
if (!app) return;
walkModules(app.modules || [], (n) => {
if (Number(n.id) === moduleId) Object.assign(n, patch);
});
}
// ---------------- 授权操作 ----------------
/** 勾选/取消勾选:应用节点 => moduleId=0 整应用处理;模块节点 => 单模块处理 */
async function onTreeCheck(node: any) {
const groupId = selectedGroupId.value;
if (!groupId || !node) return;
const checked = !!node.checked;
const isApp = node.nodeType === "app";
const treeObj = getTreeObjByNode(node);
try {
await Apis.GroupAuthz.setGroupModule({
data: [{
groupId,
appId: node.appId,
moduleId: isApp ? 0 : node.rawId,
mode: checked ? addMode.value : deleteMode.value,
isAll: checked && node.inheritFlag ? 1 : 0,
}],
cacheFor: null,
});
if (isApp) {
setLocalAppChecked(node.appId, checked);
// 应用节点整批操作:手动同步 zTree 子节点勾选态与继承标记
if (treeObj) {
const affected = treeObj.transformToArray([node]) || [];
affected.forEach((n: any) => {
if (n !== node) treeObj.checkNode(n, checked, false);
n.checked = checked;
if (!checked) n.inheritFlag = false;
});
}
Message.success(checked ? "已授权该应用全部模块" : "已取消该应用全部模块授权");
} else {
if (!checked) node.inheritFlag = false;
setLocalModuleState(node.appId, node.rawId, checked ? { checked: true } : { checked: false, inheritFlag: false });
syncAppNodeState(treeObj, node.appId);
}
syncInheritDoms(treeObj);
} catch (e: any) {
console.warn(e?.message || "操作失败");
await reloadCurrent();
}
}
/** 子组继承开关:应用节点 => moduleId=0 整应用处理;模块节点 => 单模块处理 */
async function onInheritToggle(treeId: string, node: any, inherit: boolean) {
const $ = jq();
const groupId = selectedGroupId.value;
const revert = () => $ && $(`#${node.tId}_inherit`).prop("checked", !inherit);
if (!groupId || !node.checked) {
revert();
return;
}
const isApp = node.nodeType === "app";
const treeObj = $?.fn?.zTree?.getZTreeObj(treeId) || null;
try {
await Apis.GroupAuthz.setGroupModule({
data: [{
groupId,
appId: node.appId,
moduleId: isApp ? 0 : node.rawId,
mode: addMode.value,
isAll: inherit ? 1 : 0,
}],
cacheFor: null,
});
node.inheritFlag = inherit;
if (isApp) {
setLocalAppInherit(node.appId, inherit);
if (treeObj) {
const affected = treeObj.transformToArray([node]) || [];
affected.forEach((n: any) => {
if (n.checked) n.inheritFlag = inherit;
});
}
} else {
setLocalModuleState(node.appId, node.rawId, { inheritFlag: inherit });
}
syncInheritDoms(treeObj);
} catch (e: any) {
console.warn(e?.message || "操作失败");
await reloadCurrent();
}
}
// ---------------- 搜索 ----------------
function onSearch() {
appliedKeyword.value = searchKeyword.value;
}
function onClearSearch() {
appliedKeyword.value = "";
}
onMounted(async () => {
await ensureSysDict();
});
</script>
<style lang="scss" scoped>
.group-auth-tree {
flex: 1;
min-height: 0;
overflow: hidden;
}
.group-auth-main {
display: flex;
flex-direction: column;
}
.authz-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.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;
}
.group-text {
color: #666;
font-size: 13px;
}
.group-name {
color: #2d8cf0;
font-weight: 600;
}
.authz-panel {
position: relative;
flex: 1;
min-height: 320px;
padding: 12px 16px;
background: #fff;
border-radius: 4px;
overflow: auto;
}
/* 三列平铺布局 */
.cat-columns {
display: flex;
align-items: flex-start;
gap: 12px;
}
.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;
}
.inherit-cap {
font-size: 12px;
color: #999;
flex-shrink: 0;
margin-right: 6px;
}
.cat-card-body {
padding: 6px 8px;
/* 子组继承复选框的绝对定位锚点:保证所有层级节点的复选框右对齐成一列 */
position: relative;
}
.empty-tip {
color: #999;
padding: 48px 0;
text-align: center;
}
.open-link {
color: #2d8cf0;
&:hover {
text-decoration: underline;
}
}
/* zTree 节点行尾的"子组继承"复选框:
float 会跟随节点缩进产生阶梯,改为绝对定位锚定卡片体右边界,所有层级右对齐成一列 */
:deep(.inherit-wrap) {
position: absolute;
right: 14px;
display: inline-flex;
align-items: center;
height: 22px;
}
:deep(.inherit-chk) {
cursor: pointer;
&:disabled {
cursor: not-allowed;
}
}
:deep(.ztree li a) {
max-width: calc(100% - 90px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
</style>