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
+452
View File
@@ -0,0 +1,452 @@
<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>
</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="categoryList.length === 0">该角色暂无可授权的应用</template>
<template v-else>未找到匹配的应用或模块</template>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, shallowRef, reactive, watch } from "vue";
import { Message } from "view-ui-plus";
import { debounce } from "lodash";
import Apis from "@/api";
import MyTree from "@/components/MyTree.vue";
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;
children?: AuthzTreeNode[];
};
type CategoryTreeItem = { cat: CategoryItem; treeData: AuthzTreeNode[]; weight: number };
const COLUMN_COUNT = 3;
const jq = () => (window as any).$;
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 treeSetting = {
view: { dblClickExpand: false, showIcon: false },
check: {
enable: true,
chkStyle: "checkbox",
// 不自动级联:模块逐条独立授权(角色无"子组继承"概念)
chkboxType: { Y: "", N: "" },
},
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));
}
function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
return {
id: `mod_${m.id}`,
name: String(m.name || ""),
open: true,
checked: !!m.checked,
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),
nodeType: "app",
rawId: appId,
appId,
children: (app.modules || []).map((m) => buildModuleNode(m, appId)),
};
}
// ---------------- 数据加载 ----------------
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;
}
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; });
}
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); });
}
function getTreeObjByNode(node: any) {
const $ = jq();
const treeId = String(node?.tId || "").replace(/_\d+$/, "");
return $?.fn?.zTree?.getZTreeObj(treeId) || null;
}
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);
treeObj.checkNode(appNode, appNode.checked, false);
}
// ---------------- 授权操作(勾选即保存,全量覆盖 + 防抖) ----------------
function onTreeCheck(node: any) {
const roleId = props.roleId;
if (!roleId || !node) return;
const checked = !!node.checked;
const isApp = node.nodeType === "app";
const treeObj = getTreeObjByNode(node);
if (isApp) {
setLocalAppChecked(node.appId, checked);
if (treeObj) {
const affected = treeObj.transformToArray([node]) || [];
affected.forEach((n: any) => {
if (n !== node) treeObj.checkNode(n, checked, false);
n.checked = checked;
});
}
} else {
setLocalModuleState(node.appId, node.rawId, { checked });
syncAppNodeState(treeObj, node.appId);
}
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) {
Message.error(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;
}
.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>