feat(system): 实现RuoYi风格的RBAC管理页面
新增系统资源工具函数库,包含树形结构构建、树过滤和数据范围中文标签转换;重构系统资源编辑器组件,改用树形选择器优化权限配置并新增表单验证;重构系统管理页面布局,统一使用RuoYi风格样式,新增多维度筛选搜索功能,优化各资源表格展示;新增用户密码重置功能,移除过时的useSystemResource钩子并更新测试用例。
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { computed, nextTick, reactive, ref, watch } from "vue";
|
||||
import { buildSystemTree } from "@/lib/system-resource";
|
||||
import type {
|
||||
SystemDeptItem,
|
||||
SystemMenuItem,
|
||||
@@ -79,8 +80,22 @@ function blankForm(parentId: string | null = null): FormState {
|
||||
|
||||
const form = reactive<FormState>(blankForm());
|
||||
const formRef = ref<{ validate: () => Promise<boolean> } | null>(null);
|
||||
const roleMenuTreeRef = ref<{ setCheckedKeys: (keys: string[]) => void } | null>(null);
|
||||
const roleDeptTreeRef = ref<{ setCheckedKeys: (keys: string[]) => void } | null>(null);
|
||||
const title = computed(() => `${props.mode === "create" ? "新增" : "编辑"}${({ users: "用户", roles: "角色", menus: "菜单", depts: "部门" } as Record<SystemResourceName, string>)[props.resource]}`);
|
||||
const menuParents = computed(() => props.menus.filter((item) => item.id !== props.item?.id && item.type !== "button"));
|
||||
const menuTree = computed(() => buildSystemTree(props.menus));
|
||||
const deptTree = computed(() => buildSystemTree(props.depts.filter((item) => item.id !== props.item?.id)));
|
||||
const formRules = computed(() => {
|
||||
const rules: Record<string, Array<{ required: boolean; message: string; trigger: string }>> = {
|
||||
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
|
||||
};
|
||||
if (props.resource === "users") {
|
||||
rules.email = [{ required: true, message: "请输入登录邮箱", trigger: "blur" }];
|
||||
rules.password = props.mode === "create" ? [{ required: true, message: "请输入登录密码", trigger: "blur" }] : [];
|
||||
}
|
||||
if (props.resource === "roles") rules.code = [{ required: true, message: "请输入角色编码", trigger: "blur" }];
|
||||
return rules;
|
||||
});
|
||||
|
||||
function syncForm(item: EditorItem | null) {
|
||||
Object.assign(form, blankForm(props.initialParentId));
|
||||
@@ -100,7 +115,39 @@ function syncForm(item: EditorItem | null) {
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [props.item, props.resource, props.mode, props.initialParentId], () => syncForm(props.item), { immediate: true });
|
||||
function syncRoleTrees() {
|
||||
if (props.resource !== "roles") return;
|
||||
roleMenuTreeRef.value?.setCheckedKeys(form.menuIds);
|
||||
roleDeptTreeRef.value?.setCheckedKeys(form.deptIds);
|
||||
}
|
||||
|
||||
function checkedIds(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map((item) => String(item)) : [];
|
||||
}
|
||||
|
||||
function onRoleMenuCheck(_data: unknown, state: { checkedKeys?: unknown[] }) {
|
||||
form.menuIds = checkedIds(state.checkedKeys);
|
||||
}
|
||||
|
||||
function onRoleDeptCheck(_data: unknown, state: { checkedKeys?: unknown[] }) {
|
||||
form.deptIds = checkedIds(state.checkedKeys);
|
||||
}
|
||||
|
||||
function selectAllRoleMenus() {
|
||||
form.menuIds = props.menus.map((menu) => menu.id);
|
||||
roleMenuTreeRef.value?.setCheckedKeys(form.menuIds);
|
||||
}
|
||||
|
||||
function selectAllRoleDepts() {
|
||||
form.deptIds = props.depts.map((dept) => dept.id);
|
||||
roleDeptTreeRef.value?.setCheckedKeys(form.deptIds);
|
||||
}
|
||||
|
||||
watch(() => [props.item, props.resource, props.mode, props.initialParentId, props.menus, props.depts], async () => {
|
||||
syncForm(props.item);
|
||||
await nextTick();
|
||||
syncRoleTrees();
|
||||
}, { immediate: true });
|
||||
|
||||
async function submit() {
|
||||
if (formRef.value && !(await formRef.value.validate())) return;
|
||||
@@ -122,8 +169,8 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog :model-value="modelValue" :title="title" width="720px" destroy-on-close @update:model-value="emit('update:modelValue', $event)">
|
||||
<el-form ref="formRef" :model="form" label-width="110px" class="system-editor-form">
|
||||
<el-dialog :model-value="modelValue" :title="title" width="760px" destroy-on-close class="system-editor-dialog" @update:model-value="emit('update:modelValue', $event)">
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-width="110px" class="system-editor-form">
|
||||
<template v-if="resource === 'users'">
|
||||
<el-form-item label="登录邮箱" prop="email" required><el-input v-model="form.email" :disabled="mode === 'edit'" /></el-form-item>
|
||||
<el-form-item label="姓名" prop="name" required><el-input v-model="form.name" maxlength="80" /></el-form-item>
|
||||
@@ -138,19 +185,29 @@ async function submit() {
|
||||
<el-form-item label="角色编码" required><el-input v-model="form.code" maxlength="80" /></el-form-item>
|
||||
<el-form-item label="数据范围"><el-select v-model="form.dataScope" class="full-width"><el-option label="全部数据" value="all" /><el-option label="当前部门" value="dept" /><el-option label="当前部门及子部门" value="dept_and_children" /><el-option label="自定义部门" value="custom_dept" /><el-option label="本人数据" value="self" /></el-select></el-form-item>
|
||||
<el-form-item label="说明"><el-input v-model="form.description" type="textarea" :rows="3" maxlength="500" /></el-form-item>
|
||||
<el-form-item label="菜单权限"><el-select v-model="form.menuIds" multiple filterable class="full-width"><el-option v-for="menu in menus" :key="menu.id" :label="`${menu.name} (${menu.permission || menu.type})`" :value="menu.id" /></el-select></el-form-item>
|
||||
<el-form-item label="部门范围"><el-select v-model="form.deptIds" multiple filterable class="full-width"><el-option v-for="dept in depts" :key="dept.id" :label="dept.name" :value="dept.id" /></el-select></el-form-item>
|
||||
<el-form-item label="菜单权限">
|
||||
<div class="system-tree-panel">
|
||||
<div class="system-tree-panel__heading"><span>勾选后授予菜单、页面和按钮权限</span><el-button link type="primary" @click="selectAllRoleMenus">全选</el-button></div>
|
||||
<el-tree ref="roleMenuTreeRef" :data="menuTree" node-key="id" show-checkbox default-expand-all :props="{ label: 'name', children: 'children' }" @check="onRoleMenuCheck" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="部门范围">
|
||||
<div class="system-tree-panel">
|
||||
<div class="system-tree-panel__heading"><span>数据范围为自定义部门时生效</span><el-button link type="primary" @click="selectAllRoleDepts">全选</el-button></div>
|
||||
<el-tree ref="roleDeptTreeRef" :data="deptTree" node-key="id" show-checkbox default-expand-all :props="{ label: 'name', children: 'children' }" @check="onRoleDeptCheck" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态"><el-switch v-model="form.isActive" active-text="启用" inactive-text="停用" /></el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="resource === 'menus'">
|
||||
<el-form-item label="菜单名称" required><el-input v-model="form.name" maxlength="80" /></el-form-item>
|
||||
<el-form-item label="菜单类型" required><el-select v-model="form.type" class="full-width"><el-option label="目录" value="directory" /><el-option label="页面" value="page" /><el-option label="按钮" value="button" /></el-select></el-form-item>
|
||||
<el-form-item label="父级菜单"><el-select v-model="form.parentId" clearable class="full-width"><el-option v-for="menu in menuParents" :key="menu.id" :label="menu.name" :value="menu.id" /></el-select></el-form-item>
|
||||
<el-form-item label="路径"><el-input v-model="form.path" placeholder="/example" /></el-form-item>
|
||||
<el-form-item label="组件白名单"><el-input v-model="form.componentKey" placeholder="HomePage" /></el-form-item>
|
||||
<el-form-item label="权限编码"><el-input v-model="form.permissionCode" placeholder="admin:example:read" /></el-form-item>
|
||||
<el-form-item label="图标"><el-input v-model="form.icon" /></el-form-item>
|
||||
<el-form-item label="菜单类型" required><el-radio-group v-model="form.type"><el-radio value="directory">目录</el-radio><el-radio value="page">页面</el-radio><el-radio value="button">按钮</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item label="父级菜单"><el-tree-select v-model="form.parentId" :data="menuTree.filter((menu) => menu.type !== 'button')" node-key="id" check-strictly clearable filterable class="full-width" :props="{ label: 'name', children: 'children' }" placeholder="选择上级菜单" /></el-form-item>
|
||||
<el-form-item label="路由地址"><el-input v-model="form.path" placeholder="/example" /></el-form-item>
|
||||
<el-form-item label="组件路径"><el-input v-model="form.componentKey" placeholder="Dashboard" /></el-form-item>
|
||||
<el-form-item label="权限标识"><el-input v-model="form.permissionCode" placeholder="system:example:list" /></el-form-item>
|
||||
<el-form-item label="菜单图标"><el-input v-model="form.icon" placeholder="House" /></el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" /></el-form-item>
|
||||
<el-form-item label="显示状态"><el-switch v-model="form.isVisible" active-text="显示" inactive-text="隐藏" /></el-form-item>
|
||||
<el-form-item label="启用状态"><el-switch v-model="form.isActive" active-text="启用" inactive-text="停用" /></el-form-item>
|
||||
@@ -158,7 +215,7 @@ async function submit() {
|
||||
|
||||
<template v-else>
|
||||
<el-form-item label="部门名称" required><el-input v-model="form.name" maxlength="120" /></el-form-item>
|
||||
<el-form-item label="父级部门"><el-select v-model="form.parentId" clearable class="full-width"><el-option v-for="dept in depts.filter((item) => item.id !== props.item?.id)" :key="dept.id" :label="dept.name" :value="dept.id" /></el-select></el-form-item>
|
||||
<el-form-item label="父级部门"><el-tree-select v-model="form.parentId" :data="deptTree" node-key="id" check-strictly clearable filterable class="full-width" :props="{ label: 'name', children: 'children' }" placeholder="选择上级部门" /></el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" /></el-form-item>
|
||||
<el-form-item label="状态"><el-switch v-model="form.isActive" active-text="启用" inactive-text="停用" /></el-form-item>
|
||||
</template>
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import { nextTick, ref } from "vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useSystemResource } from "./system-resource";
|
||||
import { buildSystemTree, dataScopeLabel, filterSystemTree } from "@/lib/system-resource";
|
||||
|
||||
describe("system resource loader", () => {
|
||||
it("reloads records when the route resource changes", async () => {
|
||||
const resource = ref("users");
|
||||
const requestedResources: string[] = [];
|
||||
const state = useSystemResource(resource, async (name) => {
|
||||
requestedResources.push(name);
|
||||
return { items: [name] };
|
||||
});
|
||||
describe("system resource tree helpers", () => {
|
||||
const items = [
|
||||
{ id: "root", name: "系统管理", parentId: null, sortOrder: 10 },
|
||||
{ id: "child", name: "用户管理", parentId: "root", sortOrder: 20 },
|
||||
{ id: "other", name: "首页", parentId: null, sortOrder: 5 },
|
||||
];
|
||||
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
expect(requestedResources).toEqual(["users"]);
|
||||
expect(state.items.value).toEqual(["users"]);
|
||||
it("builds a stable parent-child tree ordered by sort", () => {
|
||||
expect(buildSystemTree(items)).toEqual([
|
||||
{ ...items[2], children: [] },
|
||||
{ ...items[0], children: [{ ...items[1], children: [] }] },
|
||||
]);
|
||||
});
|
||||
|
||||
resource.value = "roles";
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
expect(requestedResources).toEqual(["users", "roles"]);
|
||||
expect(state.items.value).toEqual(["roles"]);
|
||||
it("keeps matching ancestors when filtering a tree", () => {
|
||||
expect(filterSystemTree(buildSystemTree(items), "用户")).toEqual([
|
||||
{ ...items[0], children: [{ ...items[1], children: [] }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses RuoYi-compatible Chinese labels for data scope", () => {
|
||||
expect(dataScopeLabel("all")).toBe("全部数据");
|
||||
expect(dataScopeLabel("dept_and_children")).toBe("本部门及以下");
|
||||
expect(dataScopeLabel("custom_dept")).toBe("自定义数据");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,50 @@
|
||||
import { ref, watch, type Ref } from "vue";
|
||||
import { getSystemResource } from "@/api/client";
|
||||
import type { SystemRoleItem } from "@/types";
|
||||
|
||||
export type SystemResourceResponse = { items: unknown[] };
|
||||
export type SystemResourceFetcher = (resource: string) => Promise<SystemResourceResponse>;
|
||||
|
||||
type TreeSource = {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export type SystemTreeNode<T extends TreeSource> = T & { children: Array<SystemTreeNode<T>> };
|
||||
|
||||
export function buildSystemTree<T extends TreeSource>(items: T[]): Array<SystemTreeNode<T>> {
|
||||
const nodes = new Map(items.map((item) => [item.id, { ...item, children: [] as Array<SystemTreeNode<T>> }]));
|
||||
const roots: Array<SystemTreeNode<T>> = [];
|
||||
const ordered = [...nodes.values()].sort((left, right) => (left.sortOrder ?? 0) - (right.sortOrder ?? 0) || left.name.localeCompare(right.name));
|
||||
for (const node of ordered) {
|
||||
if (node.parentId && nodes.has(node.parentId)) nodes.get(node.parentId)!.children.push(node);
|
||||
else roots.push(node);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
export function filterSystemTree<T extends TreeSource>(nodes: Array<SystemTreeNode<T>>, keyword: string): Array<SystemTreeNode<T>> {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
if (!normalized) return nodes;
|
||||
return nodes.reduce<Array<SystemTreeNode<T>>>((result, node) => {
|
||||
const children = filterSystemTree(node.children, normalized);
|
||||
if (node.name.toLowerCase().includes(normalized) || children.length) result.push({ ...node, children });
|
||||
return result;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function dataScopeLabel(scope: SystemRoleItem["dataScope"] | string): string {
|
||||
return {
|
||||
all: "全部数据",
|
||||
dept: "本部门数据",
|
||||
dept_and_children: "本部门及以下",
|
||||
custom_dept: "自定义数据",
|
||||
self: "本人数据",
|
||||
}[scope] ?? scope;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "系统数据加载失败";
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import LayoutIcon from "@/components/layout/LayoutIcon.vue";
|
||||
import SystemResourceEditor, { type SystemEditorSavePayload, type SystemResourceName } from "@/components/system/SystemResourceEditor.vue";
|
||||
import { getMenuIconName } from "@/lib/menu-display";
|
||||
import { buildSystemTree, dataScopeLabel, filterSystemTree } from "@/lib/system-resource";
|
||||
import type {
|
||||
SystemDeptCreate,
|
||||
SystemDeptItem,
|
||||
@@ -53,7 +54,12 @@ const saving = ref(false);
|
||||
const deletingId = ref("");
|
||||
const menuTableKey = ref(0);
|
||||
const menuExpanded = ref(true);
|
||||
const resourceTableKey = ref(0);
|
||||
const deptExpanded = ref(true);
|
||||
const menuQuery = reactive<{ name: string; type: SystemMenuItem["type"] | ""; status: "" | "enabled" | "disabled" }>({ name: "", type: "", status: "" });
|
||||
const userQuery = reactive({ keyword: "", status: "", roleId: "", deptId: "" });
|
||||
const roleQuery = reactive({ keyword: "", status: "" });
|
||||
const deptQuery = reactive({ keyword: "", status: "" });
|
||||
|
||||
const titles: Record<SystemResourceName, string> = { users: "用户管理", roles: "角色管理", menus: "菜单管理", depts: "部门管理" };
|
||||
const title = computed(() => titles[resource.value]);
|
||||
@@ -61,6 +67,25 @@ const updatePermission = computed(() => `system:${resource.value.slice(0, -1)}:u
|
||||
const createPermission = computed(() => `system:${resource.value.slice(0, -1)}:create`);
|
||||
const deletePermission = computed(() => `system:${resource.value.slice(0, -1)}:delete`);
|
||||
const filteredMenuTree = computed(() => filterMenuTree(menuTree.value, menuQuery));
|
||||
const roleNameMap = computed(() => new Map(roles.value.map((item) => [item.id, item.name])));
|
||||
const deptNameMap = computed(() => new Map(depts.value.map((item) => [item.id, item.name])));
|
||||
const filteredUsers = computed(() => items.value.filter((item): item is SystemUserItem => "email" in item).filter((item) => {
|
||||
const keyword = userQuery.keyword.trim().toLowerCase();
|
||||
const matchesKeyword = !keyword || [item.name, item.email, item.role].some((value) => value.toLowerCase().includes(keyword));
|
||||
const matchesStatus = !userQuery.status || (userQuery.status === "enabled" ? item.isActive !== false : item.isActive === false);
|
||||
const matchesRole = !userQuery.roleId || item.roleIds.includes(userQuery.roleId);
|
||||
const matchesDept = !userQuery.deptId || item.deptIds.includes(userQuery.deptId);
|
||||
return matchesKeyword && matchesStatus && matchesRole && matchesDept;
|
||||
}));
|
||||
const filteredRoles = computed(() => items.value.filter((item): item is SystemRoleItem => "code" in item && "dataScope" in item).filter((item) => {
|
||||
const keyword = roleQuery.keyword.trim().toLowerCase();
|
||||
return (!keyword || item.name.toLowerCase().includes(keyword) || item.code.toLowerCase().includes(keyword))
|
||||
&& (!roleQuery.status || (roleQuery.status === "enabled" ? item.isActive : !item.isActive));
|
||||
}));
|
||||
const deptTree = computed(() => buildSystemTree(items.value.filter((item): item is SystemDeptItem => "sortOrder" in item && !("type" in item))));
|
||||
const filteredDeptTree = computed(() => filterDepartmentTree(deptTree.value, deptQuery));
|
||||
const visibleCount = computed(() => resource.value === "users" ? filteredUsers.value.length : resource.value === "roles" ? filteredRoles.value.length : resource.value === "depts" ? items.value.filter((item) => !deptQuery.status || (deptQuery.status === "enabled" ? item.isActive : !item.isActive)).length : menuItems.value.length);
|
||||
const resourceLabel = computed(() => ({ users: "用户", roles: "角色", menus: "菜单", depts: "部门" } as Record<SystemResourceName, string>)[resource.value]);
|
||||
|
||||
function normalizeResource(value: unknown): SystemResourceName {
|
||||
return value === "roles" || value === "menus" || value === "depts" ? value : "users";
|
||||
@@ -79,6 +104,17 @@ function filterMenuTree(nodes: SystemMenuTreeItem[], query: typeof menuQuery): S
|
||||
}, []);
|
||||
}
|
||||
|
||||
function filterDepartmentTree<T extends SystemDeptItem & { children: T[] }>(nodes: T[], query: typeof deptQuery): T[] {
|
||||
const keyword = query.keyword.trim().toLowerCase();
|
||||
return nodes.reduce<T[]>((result, node) => {
|
||||
const children = filterDepartmentTree(node.children ?? [], query);
|
||||
const matchesKeyword = !keyword || node.name.toLowerCase().includes(keyword);
|
||||
const matchesStatus = !query.status || (query.status === "enabled" ? node.isActive : !node.isActive);
|
||||
if ((matchesKeyword && matchesStatus) || children.length) result.push({ ...node, children });
|
||||
return result;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function buildMenuTree(flatItems: SystemMenuItem[]) {
|
||||
const nodes = new Map(flatItems.map((item) => [item.id, { ...item, children: [] as SystemMenuTreeItem[] }]));
|
||||
const roots: SystemMenuTreeItem[] = [];
|
||||
@@ -93,15 +129,29 @@ async function loadResource() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (resource.value === "users") items.value = (await getSystemUsers()).items;
|
||||
if (resource.value === "roles") items.value = (await getSystemRoles()).items;
|
||||
if (resource.value === "users") {
|
||||
const [userResult, roleResult, deptResult] = await Promise.all([getSystemUsers(), getSystemRoles(), getSystemDepts()]);
|
||||
items.value = userResult.items;
|
||||
roles.value = roleResult.items;
|
||||
depts.value = deptResult.items;
|
||||
}
|
||||
if (resource.value === "roles") {
|
||||
const [roleResult, menuResult, deptResult] = await Promise.all([getSystemRoles(), getSystemMenus(), getSystemDepts()]);
|
||||
items.value = roleResult.items;
|
||||
menus.value = menuResult.items;
|
||||
depts.value = deptResult.items;
|
||||
}
|
||||
if (resource.value === "menus") {
|
||||
const result = await getSystemMenus();
|
||||
menuItems.value = result.items;
|
||||
menuTree.value = result.tree?.length ? result.tree : buildMenuTree(result.items);
|
||||
items.value = result.items;
|
||||
}
|
||||
if (resource.value === "depts") items.value = (await getSystemDepts()).items;
|
||||
if (resource.value === "depts") {
|
||||
const result = await getSystemDepts();
|
||||
items.value = result.items;
|
||||
depts.value = result.items;
|
||||
}
|
||||
} catch (loadError) {
|
||||
error.value = loadError instanceof Error ? loadError.message : "系统数据加载失败";
|
||||
items.value = [];
|
||||
@@ -216,6 +266,63 @@ function resetMenuQuery() {
|
||||
menuQuery.status = "";
|
||||
}
|
||||
|
||||
function resetResourceQuery() {
|
||||
userQuery.keyword = "";
|
||||
userQuery.status = "";
|
||||
userQuery.roleId = "";
|
||||
userQuery.deptId = "";
|
||||
roleQuery.keyword = "";
|
||||
roleQuery.status = "";
|
||||
deptQuery.keyword = "";
|
||||
deptQuery.status = "";
|
||||
resourceTableKey.value += 1;
|
||||
}
|
||||
|
||||
function resetAllQuery() {
|
||||
resetMenuQuery();
|
||||
resetResourceQuery();
|
||||
}
|
||||
|
||||
function searchResource() {
|
||||
resourceTableKey.value += 1;
|
||||
}
|
||||
|
||||
function toggleDeptTree() {
|
||||
deptExpanded.value = !deptExpanded.value;
|
||||
resourceTableKey.value += 1;
|
||||
}
|
||||
|
||||
function userRoleLabel(item: SystemUserItem) {
|
||||
const labels = item.roleIds.map((id) => roleNameMap.value.get(id)).filter(Boolean);
|
||||
return labels.length ? labels.join("、") : item.role || "未分配";
|
||||
}
|
||||
|
||||
function userDeptLabel(item: SystemUserItem) {
|
||||
const labels = item.deptIds.map((id) => deptNameMap.value.get(id)).filter(Boolean);
|
||||
return labels.length ? labels.join("、") : "未分配";
|
||||
}
|
||||
|
||||
function parentDeptLabel(parentId?: string | null) {
|
||||
return parentId ? deptNameMap.value.get(parentId) || "未找到父部门" : "顶级部门";
|
||||
}
|
||||
|
||||
async function resetPassword(item: SystemUserItem) {
|
||||
try {
|
||||
const result = await ElMessageBox.prompt("请输入新的登录密码(至少 8 位)", "重置密码", {
|
||||
inputType: "password",
|
||||
inputPattern: /^.{8,}$/,
|
||||
inputErrorMessage: "密码至少 8 位",
|
||||
confirmButtonText: "保存密码",
|
||||
cancelButtonText: "取消",
|
||||
closeOnClickModal: false,
|
||||
});
|
||||
await updateSystemUser(item.id, { password: result.value });
|
||||
ElMessage.success("密码已重置");
|
||||
} catch (resetError) {
|
||||
if (resetError instanceof Error) ElMessage.error(resetError.message);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMenuTree() {
|
||||
menuExpanded.value = !menuExpanded.value;
|
||||
menuTableKey.value += 1;
|
||||
@@ -242,32 +349,63 @@ function menuTypeTag(type: SystemMenuItem["type"]) {
|
||||
|
||||
watch(() => route.params.resource, (value) => {
|
||||
resource.value = normalizeResource(value);
|
||||
resetAllQuery();
|
||||
loadResource();
|
||||
});
|
||||
onMounted(loadResource);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-stack system-resource-page">
|
||||
<div class="page-intro">
|
||||
<div><p class="eyebrow">SYSTEM / RBAC</p><h2>{{ title }}</h2><p>管理用户、角色、菜单和部门,并在保存后刷新权限缓存。</p></div>
|
||||
<el-button v-if="resource !== 'menus'" v-permission="createPermission" type="primary" @click="openCreate()">新增</el-button>
|
||||
<div><p class="eyebrow">SYSTEM / RBAC</p><h2>{{ title }}</h2><p>按 RuoYi 风格维护系统{{ resourceLabel }}、权限和数据范围。</p></div>
|
||||
</div>
|
||||
|
||||
<el-card v-if="resource === 'menus'" shadow="never" class="panel-card system-menu-filter-card">
|
||||
<el-form inline @submit.prevent>
|
||||
<el-card v-if="resource === 'menus'" shadow="never" class="panel-card system-filter-card">
|
||||
<el-form inline @submit.prevent="searchResource">
|
||||
<el-form-item label="菜单名称"><el-input v-model="menuQuery.name" clearable placeholder="请输入菜单名称、路径或权限" /></el-form-item>
|
||||
<el-form-item label="菜单类型"><el-select v-model="menuQuery.type" clearable placeholder="全部类型" style="width: 150px"><el-option label="全部类型" value="" /><el-option label="目录" value="directory" /><el-option label="页面" value="page" /><el-option label="按钮" value="button" /></el-select></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="menuQuery.status" clearable placeholder="全部状态" style="width: 150px"><el-option label="全部状态" value="" /><el-option label="正常" value="enabled" /><el-option label="停用" value="disabled" /></el-select></el-form-item>
|
||||
<el-form-item><el-button type="primary">搜索</el-button><el-button @click="resetMenuQuery">重置</el-button></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="searchResource">搜索</el-button><el-button @click="resetMenuQuery">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-else shadow="never" class="panel-card system-filter-card">
|
||||
<el-form inline @submit.prevent="searchResource">
|
||||
<template v-if="resource === 'users'">
|
||||
<el-form-item label="用户名称"><el-input v-model="userQuery.keyword" clearable placeholder="请输入用户名称、邮箱" /></el-form-item>
|
||||
<el-form-item label="角色"><el-select v-model="userQuery.roleId" clearable filterable placeholder="全部角色" style="width: 170px"><el-option label="全部角色" value="" /><el-option v-for="role in roles" :key="role.id" :label="role.name" :value="role.id" /></el-select></el-form-item>
|
||||
<el-form-item label="部门"><el-select v-model="userQuery.deptId" clearable filterable placeholder="全部部门" style="width: 170px"><el-option label="全部部门" value="" /><el-option v-for="dept in depts" :key="dept.id" :label="dept.name" :value="dept.id" /></el-select></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="userQuery.status" clearable placeholder="全部状态" style="width: 140px"><el-option label="全部状态" value="" /><el-option label="启用" value="enabled" /><el-option label="停用" value="disabled" /></el-select></el-form-item>
|
||||
</template>
|
||||
<template v-else-if="resource === 'roles'">
|
||||
<el-form-item label="角色名称"><el-input v-model="roleQuery.keyword" clearable placeholder="请输入角色名称或编码" /></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="roleQuery.status" clearable placeholder="全部状态" style="width: 150px"><el-option label="全部状态" value="" /><el-option label="启用" value="enabled" /><el-option label="停用" value="disabled" /></el-select></el-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form-item label="部门名称"><el-input v-model="deptQuery.keyword" clearable placeholder="请输入部门名称" /></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="deptQuery.status" clearable placeholder="全部状态" style="width: 150px"><el-option label="全部状态" value="" /><el-option label="启用" value="enabled" /><el-option label="停用" value="disabled" /></el-select></el-form-item>
|
||||
</template>
|
||||
<el-form-item><el-button type="primary" @click="searchResource">搜索</el-button><el-button @click="resetResourceQuery">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false"><template #default><el-button link type="primary" @click="loadResource">重试</el-button></template></el-alert>
|
||||
<el-card shadow="never" class="panel-card"><el-skeleton v-if="loading" :rows="6" animated /><el-empty v-else-if="!items.length" description="暂无数据" />
|
||||
<template v-else-if="resource === 'menus'">
|
||||
<div class="system-table-heading"><div><strong>菜单管理列表</strong><span>共 {{ menuItems.length }} 条记录</span></div><div class="system-action-bar"><el-button v-permission="createPermission" type="primary" plain @click="openCreate()">新增</el-button><el-button v-permission="'system:menu:sort'" type="warning" plain :loading="saving" @click="saveMenuSort">保存排序</el-button><el-button plain @click="toggleMenuTree">{{ menuExpanded ? '折叠' : '展开' }}</el-button></div></div>
|
||||
<el-table :key="menuTableKey" :data="filteredMenuTree" row-key="id" :tree-props="{ children: 'children' }" :default-expand-all="menuExpanded" stripe>
|
||||
<el-card shadow="never" class="panel-card system-list-card">
|
||||
<el-skeleton v-if="loading" :rows="6" animated />
|
||||
<el-empty v-else-if="!visibleCount" description="暂无数据" />
|
||||
<template v-else>
|
||||
<div class="system-table-heading">
|
||||
<div><strong>{{ title }}列表</strong><span>共 {{ visibleCount }} 条记录</span></div>
|
||||
<div class="system-action-bar">
|
||||
<el-button v-permission="createPermission" type="primary" plain @click="openCreate()">新增{{ resourceLabel }}</el-button>
|
||||
<el-button v-if="resource === 'menus'" v-permission="'system:menu:sort'" type="warning" plain :loading="saving" @click="saveMenuSort">保存排序</el-button>
|
||||
<el-button v-if="resource === 'menus'" plain @click="toggleMenuTree">{{ menuExpanded ? '折叠' : '展开' }}</el-button>
|
||||
<el-button v-if="resource === 'depts'" plain @click="toggleDeptTree">{{ deptExpanded ? '折叠' : '展开' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-if="resource === 'menus'" :key="menuTableKey" :data="filteredMenuTree" row-key="id" :tree-props="{ children: 'children' }" :default-expand-all="menuExpanded" stripe>
|
||||
<el-table-column label="菜单名称" min-width="220"><template #default="{ row }"><div class="system-menu-name"><LayoutIcon :name="getMenuIconName(row)" :size="16" /><span>{{ row.name }}</span></div></template></el-table-column>
|
||||
<el-table-column label="类型" width="90"><template #default="{ row }"><el-tag :type="menuTypeTag(row.type)" effect="plain">{{ menuTypeLabel(row.type) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="排序" width="120"><template #default="{ row }"><el-input-number :model-value="row.sortOrder" :min="0" :step="1" controls-position="right" @update:model-value="updateMenuSort(row.id, $event)" /></template></el-table-column>
|
||||
@@ -277,13 +415,36 @@ onMounted(loadResource);
|
||||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.isActive && row.isVisible ? 'success' : 'info'" effect="plain">{{ row.isActive && row.isVisible ? '正常' : '停用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="230" fixed="right"><template #default="{ row }"><el-button v-permission="updatePermission" link type="primary" @click="openEdit(row)">修改</el-button><el-button v-if="row.type !== 'button'" v-permission="createPermission" link type="primary" @click="openCreate(row.id)">新增</el-button><el-button v-permission="deletePermission" link type="danger" :loading="deletingId === row.id" @click="removeItem(row)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-table v-else-if="resource === 'users'" :key="resourceTableKey" :data="filteredUsers" stripe>
|
||||
<el-table-column prop="name" label="用户名称" min-width="145" />
|
||||
<el-table-column prop="email" label="登录邮箱" min-width="215" show-overflow-tooltip />
|
||||
<el-table-column label="角色" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ userRoleLabel(row) }}</template></el-table-column>
|
||||
<el-table-column label="部门" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ userDeptLabel(row) }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.isActive ? 'success' : 'info'" effect="plain">{{ row.isActive ? "启用" : "停用" }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="更新时间" min-width="165"><template #default="{ row }">{{ formatDate(row.updatedAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" fixed="right" width="220"><template #default="{ row }"><el-button v-permission="updatePermission" link type="primary" @click="openEdit(row)">编辑</el-button><el-button v-permission="updatePermission" link type="primary" @click="resetPassword(row)">重置密码</el-button><el-button v-permission="deletePermission" link type="danger" :loading="deletingId === row.id" @click="removeItem(row)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-table v-else-if="resource === 'roles'" :key="resourceTableKey" :data="filteredRoles" stripe>
|
||||
<el-table-column prop="name" label="角色名称" min-width="150" />
|
||||
<el-table-column prop="code" label="角色编码" min-width="170" />
|
||||
<el-table-column label="数据范围" min-width="145"><template #default="{ row }">{{ dataScopeLabel(row.dataScope) }}</template></el-table-column>
|
||||
<el-table-column label="菜单权限" width="100"><template #default="{ row }">{{ row.menuIds.length }} 项</template></el-table-column>
|
||||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.isActive ? 'success' : 'info'" effect="plain">{{ row.isActive ? "启用" : "停用" }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="更新时间" min-width="165"><template #default="{ row }">{{ formatDate(row.updatedAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" fixed="right" width="150"><template #default="{ row }"><el-button v-permission="updatePermission" link type="primary" @click="openEdit(row)">编辑</el-button><el-button v-permission="deletePermission" link type="danger" :loading="deletingId === row.id" @click="removeItem(row)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-table v-else :key="resourceTableKey" :data="filteredDeptTree" row-key="id" :tree-props="{ children: 'children' }" :default-expand-all="deptExpanded" stripe>
|
||||
<el-table-column label="部门名称" min-width="220"><template #default="{ row }"><span class="system-tree-label">{{ row.name }}</span></template></el-table-column>
|
||||
<el-table-column label="父级部门" min-width="160"><template #default="{ row }">{{ parentDeptLabel(row.parentId) }}</template></el-table-column>
|
||||
<el-table-column prop="sortOrder" label="排序" width="100" />
|
||||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.isActive ? 'success' : 'info'" effect="plain">{{ row.isActive ? "启用" : "停用" }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="更新时间" min-width="165"><template #default="{ row }">{{ formatDate(row.updatedAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" fixed="right" width="220"><template #default="{ row }"><el-button v-permission="createPermission" link type="primary" @click="openCreate(row.id)">新增下级</el-button><el-button v-permission="updatePermission" link type="primary" @click="openEdit(row)">编辑</el-button><el-button v-permission="deletePermission" link type="danger" :loading="deletingId === row.id" @click="removeItem(row)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<el-table v-else :data="items" stripe>
|
||||
<template v-if="resource === 'users'"><el-table-column prop="email" label="邮箱" min-width="210" /><el-table-column prop="name" label="姓名" /><el-table-column prop="role" label="旧角色" /><el-table-column label="状态"><template #default="{ row }"><el-tag :type="row.isActive ? 'success' : 'info'" effect="plain">{{ row.isActive ? "启用" : "停用" }}</el-tag></template></el-table-column></template>
|
||||
<template v-else-if="resource === 'roles'"><el-table-column prop="name" label="角色名称" /><el-table-column prop="code" label="编码" /><el-table-column prop="dataScope" label="数据范围" /><el-table-column label="权限数"><template #default="{ row }">{{ row.menuIds.length }}</template></el-table-column><el-table-column label="状态"><template #default="{ row }"><el-tag :type="row.isActive ? 'success' : 'info'" effect="plain">{{ row.isActive ? "启用" : "停用" }}</el-tag></template></el-table-column></template>
|
||||
<template v-else><el-table-column prop="name" label="部门名称" /><el-table-column prop="parentId" label="父级部门" /><el-table-column prop="sortOrder" label="排序" width="90" /><el-table-column label="状态"><template #default="{ row }"><el-tag :type="row.isActive ? 'success' : 'info'" effect="plain">{{ row.isActive ? "启用" : "停用" }}</el-tag></template></el-table-column></template>
|
||||
<el-table-column label="更新时间" min-width="165"><template #default="{ row }">{{ formatDate(row.updatedAt) }}</template></el-table-column><el-table-column label="操作" fixed="right" width="160"><template #default="{ row }"><el-button v-permission="updatePermission" link type="primary" :loading="saving" @click="openEdit(row)">编辑</el-button><el-button v-permission="deletePermission" link type="danger" :loading="deletingId === row.id" @click="removeItem(row)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<SystemResourceEditor v-model="editorOpen" :resource="resource" :mode="editorMode" :item="editorItem" :initial-parent-id="initialParentId" :roles="roles" :menus="menus" :depts="depts" @save="save" />
|
||||
</div>
|
||||
|
||||
@@ -137,6 +137,41 @@ button, input { font: inherit; }
|
||||
.home-content-panel .panel-heading span { color: var(--muted); font-size: 12px; font-weight: 400; }
|
||||
.home-content-options { margin-top: 20px; }
|
||||
.system-action-bar .el-button:hover { color: #fff; }
|
||||
|
||||
/* RuoYi-style system management pages: compact filters, bordered tables and explicit RBAC hierarchy. */
|
||||
.system-resource-page .page-intro { margin-bottom: 18px; }
|
||||
.system-filter-card, .system-list-card { margin-bottom: 16px; border-radius: 4px; }
|
||||
.system-filter-card .el-card__body { padding: 16px 18px 2px; }
|
||||
.system-filter-card .el-form { display: flex; flex-wrap: wrap; align-items: center; gap: 0 16px; }
|
||||
.system-filter-card .el-form-item { margin-right: 0; margin-bottom: 14px; }
|
||||
.system-filter-card .el-input { width: 230px; }
|
||||
.system-filter-card .el-form-item:last-child { margin-left: auto; }
|
||||
.system-list-card .el-card__body { padding: 0; }
|
||||
.system-table-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 18px; border-bottom: 1px solid var(--line); }
|
||||
.system-table-heading > div:first-child { display: flex; align-items: baseline; gap: 10px; }
|
||||
.system-table-heading strong { color: #303133; font-size: 14px; }
|
||||
.system-table-heading span { color: #909399; font-size: 12px; }
|
||||
.system-action-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||
.system-action-bar .el-button { margin-left: 0; }
|
||||
.system-menu-name, .system-tree-label { display: inline-flex; align-items: center; gap: 8px; min-height: 24px; }
|
||||
.system-menu-name .layout-icon { flex: 0 0 auto; }
|
||||
.system-list-card .el-table th.el-table__cell { color: #606266; font-weight: 600; background: #f5f7fa; }
|
||||
.system-list-card .el-table td.el-table__cell, .system-list-card .el-table th.el-table__cell { border-bottom-color: #ebeef5; }
|
||||
.system-list-card .el-table .el-button + .el-button { margin-left: 8px; }
|
||||
.system-editor-dialog .el-dialog__body { padding-top: 8px; }
|
||||
.system-editor-form .el-form-item { margin-bottom: 18px; }
|
||||
.system-editor-form .full-width { width: 100%; }
|
||||
.system-tree-panel { width: 100%; overflow: hidden; border: 1px solid var(--line); border-radius: 4px; background: #fff; }
|
||||
.system-tree-panel__heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 12px; color: #909399; font-size: 12px; border-bottom: 1px solid var(--line); background: #f8fafc; }
|
||||
.system-tree-panel .el-tree { max-height: 260px; overflow: auto; padding: 8px 12px; }
|
||||
.system-tree-panel .el-tree-node__content { height: 32px; }
|
||||
.system-editor-form .el-radio-group { display: flex; gap: 18px; }
|
||||
@media (max-width: 760px) {
|
||||
.system-table-heading { align-items: flex-start; flex-direction: column; }
|
||||
.system-action-bar { justify-content: flex-start; width: 100%; }
|
||||
.system-filter-card .el-form-item:last-child { margin-left: 0; }
|
||||
.system-filter-card .el-input { width: min(230px, 100%); }
|
||||
}
|
||||
@media (max-width: 880px) { .wanfa-workspace { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 980px) { .media-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } .lead-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 760px) { .concierge-row { align-items: flex-start; flex-direction: column; } .concierge-actions { justify-content: flex-start; } .lead-filters { grid-template-columns: 1fr 1fr; } .lead-filters .el-input { grid-column: 1 / -1; } .operations-grid { grid-template-columns: 1fr; } .media-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
|
||||
Reference in New Issue
Block a user