init: UserBaseFront (frontend)
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<RoleForm v-model="visible" :initForm="initForm" :isEdit="false" :loading="submitting"
|
||||
@submit="handleSubmit" @cancel="visible = false" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import RoleForm from "./RoleForm.vue";
|
||||
|
||||
const visible = ref(false);
|
||||
const initForm = ref<Record<string, any>>({});
|
||||
let onSuccessCallback: (() => void) | null = null;
|
||||
|
||||
// useForm 托管提交(角色新增:data 形态与后端 SysAppRoleRequest 一致)
|
||||
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
||||
(dto) => Apis.AppRole.roleAdd({ data: dto }),
|
||||
{
|
||||
initialForm: { name: "", appId: undefined, allAppFlag: false, remark: "", disabled: false },
|
||||
resetAfterSubmiting: true,
|
||||
}
|
||||
);
|
||||
|
||||
onSuccess(() => {
|
||||
Message.success("新增角色成功!");
|
||||
visible.value = false;
|
||||
onSuccessCallback?.();
|
||||
});
|
||||
onError((event: any) => {
|
||||
Message.error(event?.error?.message || "新增失败");
|
||||
});
|
||||
|
||||
function open(onSuccess?: () => void) {
|
||||
initForm.value = {};
|
||||
onSuccessCallback = onSuccess || null;
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
async function handleSubmit(formData: Record<string, any>) {
|
||||
form.value = {
|
||||
name: formData.name,
|
||||
appId: formData.appId,
|
||||
allAppFlag: formData.allAppFlag,
|
||||
remark: formData.remark,
|
||||
disabled: formData.disabled,
|
||||
};
|
||||
await sendForm();
|
||||
}
|
||||
|
||||
defineExpose({ open, visible });
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<RoleForm v-model="visible" :initForm="detailData" :isEdit="true" :loading="submitting"
|
||||
@submit="handleSubmit" @cancel="visible = false" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import RoleForm from "./RoleForm.vue";
|
||||
|
||||
const visible = ref(false);
|
||||
const detailData = ref<Record<string, any>>({});
|
||||
const currentId = ref<number | null>(null);
|
||||
let onSuccessCallback: (() => void) | null = null;
|
||||
|
||||
// useForm 托管提交(角色编辑:仅可改 name/remark/disabled;allAppFlag 与 appId 后端禁止修改)
|
||||
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
||||
(dto) => Apis.AppRole.roleEdit({ pathParams: { id: currentId.value! }, data: dto }),
|
||||
{
|
||||
initialForm: { name: "", appId: undefined, allAppFlag: false, remark: "", disabled: false },
|
||||
resetAfterSubmiting: true,
|
||||
}
|
||||
);
|
||||
|
||||
onSuccess(() => {
|
||||
Message.success("编辑角色成功!");
|
||||
visible.value = false;
|
||||
onSuccessCallback?.();
|
||||
});
|
||||
onError((event: any) => {
|
||||
Message.error(event?.error?.message || "编辑失败");
|
||||
});
|
||||
|
||||
function open(id: number, row: any, onSuccess?: () => void) {
|
||||
currentId.value = id;
|
||||
// 直接用行数据回填(roleList 返回的字段与表单一致)
|
||||
detailData.value = { ...row };
|
||||
onSuccessCallback = onSuccess || null;
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
async function handleSubmit(formData: Record<string, any>) {
|
||||
if (!currentId.value) return;
|
||||
form.value = {
|
||||
id: currentId.value,
|
||||
name: formData.name,
|
||||
appId: formData.appId,
|
||||
allAppFlag: formData.allAppFlag,
|
||||
remark: formData.remark,
|
||||
disabled: formData.disabled,
|
||||
};
|
||||
await sendForm();
|
||||
}
|
||||
|
||||
defineExpose({ open, visible });
|
||||
</script>
|
||||
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div class="page-wrapper role-authz-wrapper">
|
||||
<!-- 左:角色列表(可滚动,应对大量角色;顶部 2 个筛选) -->
|
||||
<div class="role-list-panel">
|
||||
<div class="role-filter">
|
||||
<Select v-model="filterAppId" placeholder="按绑定应用筛选" filterable clearable
|
||||
@on-change="onFilterChange">
|
||||
<Option :value="FILTER_ALL">全部</Option>
|
||||
<Option :value="FILTER_GLOBAL_APP">全局应用</Option>
|
||||
<Option v-for="app in appOptions" :key="app.id" :value="app.id">{{ app.name }}</Option>
|
||||
</Select>
|
||||
<Input v-model="keyword" placeholder="角色名称模糊查询" search clearable suffix="ios-search"
|
||||
@on-search="onSearch" @on-clear="onSearch" />
|
||||
</div>
|
||||
|
||||
<div class="role-list-scroll">
|
||||
<div v-for="role in filteredRoles" :key="role.id"
|
||||
:class="['role-item', selectedRoleId === role.id ? 'active' : '']" @click="selectRole(role)">
|
||||
<div class="role-name">{{ role.name }}</div>
|
||||
<div class="role-app">
|
||||
<Tag v-if="role.allAppFlag" color="blue" size="small">全应用</Tag>
|
||||
<span v-else>{{ role.appName || "-" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!filteredRoles.length" class="role-empty">无匹配角色</div>
|
||||
</div>
|
||||
|
||||
<div class="role-list-footer">
|
||||
<Button type="text" @click="goManage">角色管理</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右:选中角色的授权面板(应用授权 / 用户管理) -->
|
||||
<div class="authz-panel">
|
||||
<div v-if="selectedRole" class="authz-inner">
|
||||
<div class="authz-bar">
|
||||
<span class="authz-title">当前角色:<b>{{ selectedRole.name }}</b></span>
|
||||
<span class="authz-app">
|
||||
<Tag v-if="selectedRole.allAppFlag" color="blue">全应用</Tag>
|
||||
<span v-else>{{ selectedRole.appName || "-" }}</span>
|
||||
</span>
|
||||
<Tag v-if="selectedRole.disabled" color="red">已禁用</Tag>
|
||||
</div>
|
||||
<!-- Tabs 仅作页签头;内容用 v-show 切换,无切换动效 -->
|
||||
<Tabs v-model="activeTab" type="card" class="authz-tabs" :animated="false">
|
||||
<TabPane label="应用授权" name="module" />
|
||||
<TabPane label="用户管理" name="user" />
|
||||
</Tabs>
|
||||
<div class="authz-tab-content">
|
||||
<div v-show="activeTab === 'module'">
|
||||
<AppModuleCheckTree :key="selectedRole.id" :roleId="selectedRole.id" :roleName="selectedRole.name" />
|
||||
</div>
|
||||
<div v-show="activeTab === 'user'">
|
||||
<RoleUserManage :key="selectedRole.id" :roleId="selectedRole.id" :roleName="selectedRole.name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Alert v-else type="info" show-icon>请选择左侧角色以进行应用授权与用户管理。</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
import AppModuleCheckTree from "./AppModuleCheckTree.vue";
|
||||
import RoleUserManage from "./RoleUserManage.vue";
|
||||
|
||||
defineOptions({ name: "RoleAuthzList" });
|
||||
|
||||
const router = useRouter();
|
||||
const { $utils } = useCurrentInstance();
|
||||
|
||||
// 筛选下拉的两个虚拟项:0=全部;-1=全局应用(仅 allAppFlag 角色)
|
||||
const FILTER_ALL = 0;
|
||||
const FILTER_GLOBAL_APP = -1;
|
||||
|
||||
const keyword = ref("");
|
||||
const filterAppId = ref<number | null>(null);
|
||||
const selectedRoleId = ref<number | null>(null);
|
||||
const activeTab = ref<'module' | 'user'>('module');
|
||||
|
||||
// 应用下拉(用于“按应用筛选”)
|
||||
const { data: appOptions } = useRequest(
|
||||
Apis.AppSettings.appList({
|
||||
params: { pageIndex: 1, pageSize: 9999 },
|
||||
transform: (res: any) => res?.content ?? [],
|
||||
cacheFor: null,
|
||||
}),
|
||||
{ initialData: [] }
|
||||
);
|
||||
|
||||
// 角色全量(非分页,角色基数约 40-50,单次拉取 + 客户端筛选)
|
||||
const { data: roleData } = useRequest(
|
||||
Apis.AppRole.roleList({
|
||||
params: { pageIndex: 1, pageSize: 9999 },
|
||||
transform: (res: any) => res?.content ?? [],
|
||||
cacheFor: null,
|
||||
}),
|
||||
{ initialData: [] }
|
||||
);
|
||||
|
||||
const allRoles = computed<Array<any>>(() => roleData.value ?? []);
|
||||
const selectedRole = computed(() => allRoles.value.find((r: any) => r.id === selectedRoleId.value) ?? null);
|
||||
|
||||
// 筛选:全部/未选=不过滤;全局应用=仅 allAppFlag 角色;具体应用=该应用角色 + 全应用角色(原有逻辑)
|
||||
const filteredRoles = computed(() => {
|
||||
const kw = keyword.value.trim();
|
||||
return allRoles.value.filter((r: any) => {
|
||||
let matchApp: boolean;
|
||||
if (!filterAppId.value || filterAppId.value === FILTER_ALL) {
|
||||
matchApp = true;
|
||||
} else if (filterAppId.value === FILTER_GLOBAL_APP) {
|
||||
matchApp = !!r.allAppFlag;
|
||||
} else {
|
||||
matchApp = r.allAppFlag || r.appId === filterAppId.value;
|
||||
}
|
||||
const matchName = !kw || (r.name || "").includes(kw);
|
||||
return matchApp && matchName;
|
||||
});
|
||||
});
|
||||
|
||||
// 默认自动选中第一条,直接进入授权界面
|
||||
watch(
|
||||
allRoles,
|
||||
(list) => {
|
||||
if (!selectedRoleId.value && list && list.length) {
|
||||
selectedRoleId.value = list[0].id;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function selectRole(role: any) {
|
||||
selectedRoleId.value = role.id;
|
||||
activeTab.value = 'module';
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
// 仅客户端过滤,computed 已响应
|
||||
}
|
||||
|
||||
// 搜索手动触发(on-search / on-clear),computed 已响应 keyword
|
||||
function onSearch() {
|
||||
// no-op: 依赖 computed
|
||||
}
|
||||
|
||||
function goManage() {
|
||||
router.push("/role-authz/manage");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.role-authz-wrapper {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.role-list-panel {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #ebedf0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.role-filter {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.role-list-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.role-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #b8c6ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #577afb;
|
||||
background: #f3f6ff;
|
||||
}
|
||||
}
|
||||
|
||||
.role-item .role-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-weight: 500;
|
||||
color: #272829;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.role-item .role-app {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.role-empty {
|
||||
text-align: center;
|
||||
color: #bbb;
|
||||
padding: 30px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.role-list-footer {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 0;
|
||||
text-align: center;
|
||||
border-top: 1px solid #eeeeee;
|
||||
|
||||
.ivu-btn-text {
|
||||
color: #577afb;
|
||||
}
|
||||
}
|
||||
|
||||
.authz-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.authz-inner {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.authz-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
|
||||
.authz-title b {
|
||||
color: #577afb;
|
||||
}
|
||||
|
||||
.authz-app {
|
||||
color: #515a6e;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.authz-tabs {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.authz-tab-content {
|
||||
padding-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<Modal v-model="show" :mask-closable="false" width="640" scrollable :title="title"
|
||||
@on-visible-change="onVisibleChange">
|
||||
<Form ref="formRef" :model="form" :rules="rules" :label-width="110" class="role-form width-95">
|
||||
<FormItem label="角色名称:" prop="name">
|
||||
<Input v-model="form.name" placeholder="请输入角色名称" />
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="关联应用:" prop="appId">
|
||||
<Select v-model="selVal" transfer filterable :disabled="isEdit" placeholder="选择具体应用或「全应用」"
|
||||
@on-change="onSelChange">
|
||||
<Option value="__ALL__" label="全应用(可跨应用授权)" />
|
||||
<OptionGroup v-for="g in appGroups" :key="g.categoryId" :label="g.categoryName">
|
||||
<Option v-for="app in g.apps" :key="app.id" :value="app.id" :label="app.name" />
|
||||
</OptionGroup>
|
||||
</Select>
|
||||
<p v-if="isEdit" class="tip">角色类型与所属应用不可修改,如需变更请删除后重建。</p>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="备注:" prop="remark">
|
||||
<Input v-model="form.remark" type="textarea" :rows="2" placeholder="可选" />
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="状态:" prop="disabled">
|
||||
<i-switch v-model="form.disabled" :true-value="true" :false-value="false" />
|
||||
<span class="ml-8">{{ form.disabled ? "禁用" : "启用" }}</span>
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="loading" @click="handleSubmit">提交</Button>
|
||||
<Button @click="handleCancel">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean;
|
||||
initForm?: Record<string, any>;
|
||||
isEdit?: boolean;
|
||||
loading?: boolean;
|
||||
}>(), {
|
||||
initForm: () => ({}),
|
||||
isEdit: false,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'submit', 'cancel']);
|
||||
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
const title = computed(() => (props.isEdit ? "编辑角色" : "新增角色"));
|
||||
|
||||
const defaultForm = (): Record<string, any> => ({
|
||||
name: "",
|
||||
appId: undefined as number | undefined,
|
||||
allAppFlag: false,
|
||||
remark: "",
|
||||
disabled: false,
|
||||
});
|
||||
const form = reactive(defaultForm());
|
||||
// 选择器值:'__ALL__' 表示全应用;数字表示具体 appId
|
||||
const selVal = ref<any>(undefined);
|
||||
|
||||
// 应用分组(按应用类型)供 Select 的 OptionGroup;全应用角色不归属某分类,单独一个虚拟选项
|
||||
const { data: appTypeList } = useRequest(
|
||||
Apis.AppSettings.appTypeList({ cacheFor: null }),
|
||||
{ initialData: [] }
|
||||
);
|
||||
const { data: appListRes } = useRequest(
|
||||
Apis.AppSettings.appList({ params: { pageIndex: 1, pageSize: 9999 }, cacheFor: null }),
|
||||
{ initialData: { content: [] } }
|
||||
);
|
||||
const appGroups = computed(() => {
|
||||
const types = (appTypeList.value ?? []).slice().sort((a: any, b: any) => (a.sortNo ?? 0) - (b.sortNo ?? 0));
|
||||
const apps = appListRes.value?.content ?? [];
|
||||
return types.map((t: any) => ({
|
||||
categoryId: t.id,
|
||||
categoryName: t.name,
|
||||
apps: apps.filter((a: any) => a.categoryId === t.id),
|
||||
}));
|
||||
});
|
||||
|
||||
// 外部 initForm 变化时回填(编辑模式:全应用 → '__ALL__',否则取 appId)
|
||||
watch(() => props.initForm, (val) => {
|
||||
const f = defaultForm();
|
||||
if (val && Object.keys(val).length) {
|
||||
Object.assign(f, val);
|
||||
}
|
||||
Object.assign(form, f);
|
||||
selVal.value = f.allAppFlag ? "__ALL__" : (f.appId ?? undefined);
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
function onSelChange(v: any) {
|
||||
selVal.value = v;
|
||||
if (v === "__ALL__") {
|
||||
form.allAppFlag = true;
|
||||
form.appId = null;
|
||||
} else {
|
||||
form.allAppFlag = false;
|
||||
form.appId = v as number;
|
||||
}
|
||||
}
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: "请输入角色名称", trigger: "blur" }],
|
||||
appId: [{
|
||||
validator: (rule: any, value: any, cb: any) => {
|
||||
if (!form.allAppFlag && form.appId == null) {
|
||||
cb(new Error("请选择关联应用或「全应用」"));
|
||||
} else {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
trigger: "change",
|
||||
}],
|
||||
};
|
||||
|
||||
const formRef = ref();
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await formRef.value?.validate();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
emit("submit", {
|
||||
name: form.name,
|
||||
appId: form.appId,
|
||||
allAppFlag: form.allAppFlag,
|
||||
remark: form.remark,
|
||||
disabled: form.disabled,
|
||||
});
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("cancel");
|
||||
}
|
||||
|
||||
function onVisibleChange(val: boolean) {
|
||||
if (!val) {
|
||||
emit("cancel");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.role-form {
|
||||
margin: 0 auto;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 12px;
|
||||
|
||||
:deep(.ivu-form-item) {
|
||||
display: block;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.ivu-form-item-label) {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
}
|
||||
|
||||
:deep(.ivu-row) {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
:deep(.ivu-row:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-top: 4px;
|
||||
color: #ed4014;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="main-wrapper">
|
||||
<div class="main-container">
|
||||
<Alert show-icon>角色管理:在此新增 / 编辑 / 删除业务角色。普通角色关联单一应用,全应用角色作用于全部应用。</Alert>
|
||||
<div class="toolbar fix">
|
||||
<Button class="fl" type="text" @click="goAuthz">← 角色授权</Button>
|
||||
<Button class="fl" type="primary" @click="handleAdd">新增角色</Button>
|
||||
<Input class="fr" v-model="keyword" placeholder="输入角色名称筛选" search clearable suffix="ios-search"
|
||||
style="width:240px" @on-search="onSearch" @on-clear="onSearch" />
|
||||
</div>
|
||||
|
||||
<div class="role-grid">
|
||||
<div v-for="role in filteredRoles" :key="role.id" class="role-card">
|
||||
<div class="role-card-hd">
|
||||
<span class="role-card-name">{{ role.name }}</span>
|
||||
<Tag v-if="role.allAppFlag" color="blue">全应用</Tag>
|
||||
<Tag v-else color="default">普通</Tag>
|
||||
</div>
|
||||
<div class="role-card-sub">{{ role.allAppFlag ? "全部应用" : (role.appName || "-") }}</div>
|
||||
<div class="role-card-ft">
|
||||
<span class="act" @click="handleEdit(role)">编辑</span>
|
||||
<span class="act act-del" @click="handleDelete(role)">删除</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!filteredRoles.length" class="role-empty">暂无角色</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddRoleContainer ref="addRef" />
|
||||
<EditRoleContainer ref="editRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import AddRoleContainer from "./AddRoleContainer.vue";
|
||||
import EditRoleContainer from "./EditRoleContainer.vue";
|
||||
|
||||
defineOptions({ name: "RoleManage" });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const keyword = ref("");
|
||||
|
||||
// 非分页:一次性拉全量角色(约 40-50 条),参照 AppCategory 的管理范式
|
||||
const { data: roleData, send: sendRoles } = useRequest(
|
||||
Apis.AppRole.roleList({
|
||||
params: { pageIndex: 1, pageSize: 9999 },
|
||||
transform: (res: any) => res?.content ?? [],
|
||||
cacheFor: null,
|
||||
}),
|
||||
{ initialData: [] }
|
||||
);
|
||||
|
||||
const allRoles = computed<Array<any>>(() => roleData.value ?? []);
|
||||
|
||||
// 名称手动筛选(on-search / on-clear 才触发)
|
||||
const filteredRoles = computed(() => {
|
||||
const kw = keyword.value.trim();
|
||||
if (!kw) return allRoles.value;
|
||||
return allRoles.value.filter((r: any) => (r.name || "").includes(kw));
|
||||
});
|
||||
|
||||
function onSearch() {
|
||||
// computed 已响应 keyword
|
||||
}
|
||||
|
||||
const addRef = ref();
|
||||
function handleAdd() {
|
||||
addRef.value?.open(() => sendRoles());
|
||||
}
|
||||
|
||||
const editRef = ref();
|
||||
function handleEdit(row: any) {
|
||||
editRef.value?.open(row.id, row, () => sendRoles());
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await MyConfirm({
|
||||
msg: `确定删除角色「${row.name || row.id}」吗?`,
|
||||
info: "删除后将一并清理其模块授权与用户绑定,不可恢复!",
|
||||
async onOk() {
|
||||
try {
|
||||
await Apis.AppRole.roleDelete({ pathParams: { id: row.id } });
|
||||
Message.success("删除成功!");
|
||||
await sendRoles();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "删除失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function goAuthz() {
|
||||
router.push("/role-authz/list");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.toolbar {
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
|
||||
.role-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
width: 240px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e9e9e9;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
|
||||
.role-card-hd {
|
||||
height: 56px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 0 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.role-card-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #272829;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.role-card-sub {
|
||||
height: 38px;
|
||||
line-height: 38px;
|
||||
padding: 0 15px;
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.role-card-ft {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
background: #f7f7f7;
|
||||
border-top: 1px solid #e9e9e9;
|
||||
|
||||
.act {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: #577afb;
|
||||
|
||||
&:hover {
|
||||
color: #6c91f0;
|
||||
}
|
||||
}
|
||||
|
||||
.act-del {
|
||||
color: #ed4014;
|
||||
|
||||
&:hover {
|
||||
color: #f16643;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.role-empty {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #bbb;
|
||||
padding: 40px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<Modal v-model="visible" title="添加用户" width="820" :mask-closable="false" @on-visible-change="onVisibleChange">
|
||||
<div class="bind-modal-body">
|
||||
<div class="bind-tree">
|
||||
<GroupTreeSearchPanel ref="refTree" :treeApi="treeApi" :defaultSearchVisible="true"
|
||||
:showCloseButton="false" @on-change="onTreeChange" />
|
||||
</div>
|
||||
<div class="bind-users">
|
||||
<div class="bind-users-bar">
|
||||
<Input v-model="kw" placeholder="按账号/姓名筛选" clearable class="bind-kw" />
|
||||
<span class="bind-sel-tip">已选 {{ selectedIds.size }} 人</span>
|
||||
</div>
|
||||
<div class="bind-users-list">
|
||||
<div v-if="!groupId" class="bind-tip">请选择左侧组织节点</div>
|
||||
<div v-else-if="!candidates.length" class="bind-tip">该组织下暂无用户</div>
|
||||
<div v-for="u in filteredCandidates" :key="u.id" class="bind-user-item">
|
||||
<Checkbox :model-value="isChecked(u)" :disabled="isExisting(u)" @on-change="(v: any) => toggle(u, v)">
|
||||
<span class="bind-user-name">{{ u.realName || u.account }}</span>
|
||||
<span class="bind-user-sub">{{ u.account }}<template v-if="u.groupPath"> · {{ u.groupPath }}</template></span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button @click="visible = false">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="save">确认添加</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Apis from "@/api";
|
||||
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
roleId: number;
|
||||
existingUserIds: number[];
|
||||
visible: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
"update:visible": [boolean];
|
||||
success: [];
|
||||
}>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.visible,
|
||||
set: (v) => emit("update:visible", v),
|
||||
});
|
||||
|
||||
const refTree = ref<any>(null);
|
||||
const groupId = ref<number | null>(null);
|
||||
const candidates = ref<any[]>([]);
|
||||
const selectedIds = ref<Set<number>>(new Set());
|
||||
const kw = ref("");
|
||||
const saving = ref(false);
|
||||
|
||||
// 组织树数据源(与组用户管理一致)
|
||||
const treeApi = () => Apis.SysGroup.getTree();
|
||||
|
||||
const existingSet = computed(() => new Set(props.existingUserIds ?? []));
|
||||
|
||||
const filteredCandidates = computed(() => {
|
||||
const k = kw.value.trim().toLowerCase();
|
||||
if (!k) return candidates.value;
|
||||
return candidates.value.filter(
|
||||
(u: any) =>
|
||||
(u.account || "").toLowerCase().includes(k) ||
|
||||
(u.realName || "").toLowerCase().includes(k)
|
||||
);
|
||||
});
|
||||
|
||||
function isExisting(u: any) {
|
||||
return existingSet.value.has(u.id);
|
||||
}
|
||||
function isChecked(u: any) {
|
||||
return isExisting(u) || selectedIds.value.has(u.id);
|
||||
}
|
||||
function toggle(u: any, v: boolean) {
|
||||
if (isExisting(u)) return;
|
||||
if (v) selectedIds.value.add(u.id);
|
||||
else selectedIds.value.delete(u.id);
|
||||
// 触发响应式
|
||||
selectedIds.value = new Set(selectedIds.value);
|
||||
}
|
||||
|
||||
async function onTreeChange(node: any) {
|
||||
groupId.value = node?.id ?? null;
|
||||
if (!groupId.value) {
|
||||
candidates.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await Apis.GroupAuthz.groupUsers({ params: { groupId: groupId.value }, cacheFor: null });
|
||||
candidates.value = (res as any[]) ?? [];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "加载用户失败");
|
||||
candidates.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
groupId.value = null;
|
||||
candidates.value = [];
|
||||
selectedIds.value = new Set();
|
||||
kw.value = "";
|
||||
}
|
||||
|
||||
function onVisibleChange(v: boolean) {
|
||||
if (v) reset();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
try {
|
||||
// 全量覆盖:现有绑定 ∪ 本次新选
|
||||
const all = new Set<number>([...existingSet.value, ...selectedIds.value]);
|
||||
await Apis.AppRole.saveRoleUsers(
|
||||
{ data: { roleId: props.roleId, userIds: [...all] }, cacheFor: null }
|
||||
);
|
||||
Message.success("添加成功");
|
||||
emit("success");
|
||||
visible.value = false;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 若外部直接改 visible 同步内部状态
|
||||
watch(
|
||||
() => props.visible,
|
||||
(v) => {
|
||||
if (v) reset();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bind-modal-body {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: 460px;
|
||||
}
|
||||
|
||||
.bind-tree {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #ebedf0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bind-users {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bind-users-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bind-kw {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bind-sel-tip {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bind-users-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.bind-tip {
|
||||
text-align: center;
|
||||
color: #bbb;
|
||||
padding: 40px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bind-user-item {
|
||||
padding: 7px 4px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
|
||||
.bind-user-name {
|
||||
font-weight: 500;
|
||||
color: #272829;
|
||||
}
|
||||
|
||||
.bind-user-sub {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="role-user-manage">
|
||||
<div class="rum-toolbar">
|
||||
<span class="rum-count">共 {{ userList.length }} 位用户</span>
|
||||
<Button type="primary" icon="md-person-add" :disabled="!roleId" @click="openBind">添加用户</Button>
|
||||
</div>
|
||||
|
||||
<Table :columns="columns" :data="userList" :loading="loading" size="small" border></Table>
|
||||
|
||||
<RoleUserBindModal v-model:visible="bindVisible" :role-id="roleId" :existing-user-ids="existingIds"
|
||||
@success="onBindSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Apis from "@/api";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import RoleUserBindModal from "./RoleUserBindModal.vue";
|
||||
|
||||
const { $utils } = useCurrentInstance();
|
||||
|
||||
const props = defineProps<{
|
||||
roleId: number;
|
||||
roleName?: string;
|
||||
}>();
|
||||
|
||||
const userList = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const bindVisible = ref(false);
|
||||
|
||||
// 当前角色已绑定的用户 id 集合(传给弹窗做去重/禁用)
|
||||
const existingIds = computed(() => userList.value.map((u: any) => u.id));
|
||||
|
||||
const columns = [
|
||||
{ title: "账号", key: "account", minWidth: 140 },
|
||||
{ title: "姓名", key: "realName", minWidth: 110 },
|
||||
{
|
||||
title: "操作", key: "action", width: 90, align: "center",
|
||||
render: (h: any, { row }: any) => $utils.operatingTextHandle(h, "删除", "text-error", () => removeUser(row)),
|
||||
},
|
||||
];
|
||||
|
||||
async function loadUsers() {
|
||||
if (!props.roleId) {
|
||||
userList.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await Apis.AppRole.roleUsers({ params: { roleId: props.roleId }, cacheFor: null });
|
||||
userList.value = (res as any[]) ?? [];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "加载角色用户失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeUser(row: any) {
|
||||
MyConfirm({
|
||||
title: "移除用户",
|
||||
msg: `确定将用户【${row.realName || row.account}】从此角色移除?`,
|
||||
info: "移除后该用户不再拥有此角色所授权的权限。",
|
||||
async onOk() {
|
||||
try {
|
||||
await Apis.AppRole.removeRoleUser(
|
||||
{ params: { roleId: props.roleId, userId: row.id }, cacheFor: null }
|
||||
);
|
||||
Message.success("已移除");
|
||||
await loadUsers();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "移除失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openBind() {
|
||||
bindVisible.value = true;
|
||||
}
|
||||
|
||||
function onBindSuccess() {
|
||||
bindVisible.value = false;
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
watch(() => props.roleId, () => loadUsers(), { immediate: true });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.role-user-manage {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.rum-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rum-count {
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user