feat(system): 实现RuoYi风格的RBAC管理页面

新增系统资源工具函数库,包含树形结构构建、树过滤和数据范围中文标签转换;重构系统资源编辑器组件,改用树形选择器优化权限配置并新增表单验证;重构系统管理页面布局,统一使用RuoYi风格样式,新增多维度筛选搜索功能,优化各资源表格展示;新增用户密码重置功能,移除过时的useSystemResource钩子并更新测试用例。
This commit is contained in:
duanshuwen
2026-08-26 23:46:41 +08:00
parent 994d07cfca
commit f087663b5b
5 changed files with 350 additions and 52 deletions

View File

@@ -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>

View File

@@ -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("自定义数据");
});
});

View File

@@ -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 : "系统数据加载失败";
}

View File

@@ -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>

View File

@@ -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)); } }