feat: 移除mock并接入真实权限控制

This commit is contained in:
湛兮
2026-05-26 16:24:03 +08:00
parent 5003628017
commit 304589bf8b
30 changed files with 965 additions and 1037 deletions
+117 -7
View File
@@ -58,17 +58,74 @@ export interface Role extends RoleOption {
updatedAt: string;
}
/** 员工列表是服务端分页,筛选条件统一通过查询参数传递。 */
export interface EmployeeListParams {
storeId?: number;
status?: EmployeeStatus;
keyword?: string;
export interface PermissionPolicyMenu {
key: string;
title: string;
actions: string[];
}
export interface PermissionPolicy {
roleId: number;
roleCode: string;
roleName: string;
roleDescription: string | null;
isSystem: boolean;
editable: boolean;
scope: string;
permissions: string[];
menus: PermissionPolicyMenu[];
}
export interface PermissionDefinition {
code: string;
title: string;
description: string;
groupKey: string;
groupTitle: string;
}
export interface PermissionDefinitionGroup {
key: string;
title: string;
permissions: PermissionDefinition[];
}
export interface PermissionDefinitionMenu {
key: string;
title: string;
icon?: string;
permission: string;
actions: string[];
actionLabels: Record<string, string>;
}
export interface PermissionDefinitions {
permissions: PermissionDefinition[];
groups: PermissionDefinitionGroup[];
menus: PermissionDefinitionMenu[];
}
/** 列表接口是服务端分页,筛选条件统一通过查询参数传递。 */
export interface PageParams {
page: number;
pageSize: number;
}
export interface StoreListParams {
export interface EmployeeListParams extends PageParams {
storeId?: number;
status?: EmployeeStatus;
keyword?: string;
}
export interface StoreListParams extends PageParams {
includeInactive?: boolean;
status?: StoreStatus;
keyword?: string;
}
export interface RoleListParams extends PageParams {
keyword?: string;
isSystem?: boolean;
}
export interface StorePayload {
@@ -111,17 +168,42 @@ export interface PaginatedData<T> {
pagination: Pagination;
}
export type PermissionPolicyResult = ApiResult<PermissionPolicy[]>;
export type PermissionDefinitionsResult = ApiResult<PermissionDefinitions>;
/** 门店接口:管理门店基础资料,并给员工下拉选项提供数据源。 */
export const listStores = (params?: StoreListParams) => {
export const listStoreOptions = () => {
return http.request<ApiResult<StoreOption[]>>("get", `${API_PREFIX}/stores`);
};
export const listAllStores = (
params?: Pick<StoreListParams, "includeInactive">
) => {
return http.request<ApiResult<Store[]>>("get", `${API_PREFIX}/stores`, {
params
});
};
export const listStores = (params: StoreListParams) => {
return http.request<ApiResult<PaginatedData<Store>>>(
"get",
`${API_PREFIX}/stores`,
{ params }
);
};
export const listRoles = () => {
return http.request<ApiResult<Role[]>>("get", `${API_PREFIX}/roles`);
};
export const listRolePage = (params: RoleListParams) => {
return http.request<ApiResult<PaginatedData<Role>>>(
"get",
`${API_PREFIX}/roles`,
{ params }
);
};
export const getStore = (id: number) => {
return http.request<ApiResult<Store>>("get", `${API_PREFIX}/stores/${id}`);
};
@@ -204,3 +286,31 @@ export const updateEmployeeStatus = (id: number, status: EmployeeStatus) => {
export const deleteEmployee = (id: number) => {
return http.request<void>("delete", `${API_PREFIX}/employees/${id}`);
};
/** 权限接口:读取权限定义、查看角色策略,并把权限点分配给角色。 */
export const getPermissionPolicies = () => {
return http.request<PermissionPolicyResult>(
"get",
`${API_PREFIX}/permissions/policies`
);
};
export const getPermissionDefinitions = () => {
return http.request<PermissionDefinitionsResult>(
"get",
`${API_PREFIX}/permissions/definitions`
);
};
export const updateRolePermissions = (
roleId: number,
permissions: string[]
) => {
return http.request<ApiResult<PermissionPolicy>>(
"put",
`${API_PREFIX}/permissions/roles/${roleId}`,
{
data: { permissions }
}
);
};
-10
View File
@@ -1,10 +0,0 @@
import { http } from "@/utils/http";
type Result = {
success: boolean;
data: Array<any>;
};
export const getAsyncRoutes = () => {
return http.request<Result>("get", "/get-async-routes");
};
+2 -2
View File
@@ -1,5 +1,5 @@
import { defineComponent, Fragment } from "vue";
import { hasAuth } from "@/router/utils";
import { hasPerms } from "@/utils/auth";
export default defineComponent({
name: "Auth",
@@ -12,7 +12,7 @@ export default defineComponent({
setup(props, { slots }) {
return () => {
if (!slots) return null;
return hasAuth(props.value) ? (
return hasPerms(props.value) ? (
<Fragment>{slots.default?.()}</Fragment>
) : null;
};
+3 -3
View File
@@ -1,14 +1,14 @@
import { hasAuth } from "@/router/utils";
import { hasPerms } from "@/utils/auth";
import type { Directive, DirectiveBinding } from "vue";
export const auth: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<string | Array<string>>) {
const { value } = binding;
if (value) {
!hasAuth(value) && el.parentNode?.removeChild(el);
!hasPerms(value) && el.parentNode?.removeChild(el);
} else {
throw new Error(
"[Directive: auth]: need auths! Like v-auth=\"['btn.add','btn.edit']\""
"[Directive: auth]: need permissions! Like v-auth=\"['store:manage']\""
);
}
}
+8 -1
View File
@@ -30,6 +30,14 @@ export const routerArrays: Array<RouteConfigs> = [
title: "员工管理",
icon: "ep/user-filled"
}
},
{
path: "/permissions",
name: "PermissionPolicies",
meta: {
title: "权限策略",
icon: "ep/key"
}
}
];
@@ -40,7 +48,6 @@ export type routeMetaType = {
savedPosition?: boolean;
permission?: string | Array<string>;
menuKey?: string;
auths?: Array<string>;
};
export type RouteConfigs = {
+7 -1
View File
@@ -173,7 +173,13 @@ router.beforeEach(async (to: ToRouteType, _from, next) => {
return;
}
if (!hasRoutePermission(to.meta, useUserStoreHook().permissions ?? [])) {
if (
!hasRoutePermission(
to.meta,
useUserStoreHook().permissions ?? [],
useUserStoreHook().permissionMenus ?? []
)
) {
next({ path: "/access-denied" });
return;
}
+12 -1
View File
@@ -3,7 +3,7 @@ const Layout = () => import("@/layout/index.vue");
/**
* 权限管理业务菜单。
*
* 三个子页面都是静态路由,菜单展示顺序由这里的 children 决定;
* 子页面都是静态路由,菜单展示顺序由这里的 children 决定;
* 默认访问该模块时进入员工管理,保证和登录/登出后的主工作流一致。
*/
export default {
@@ -49,6 +49,17 @@ export default {
permission: ["employee:view:all", "employee:view:store"],
keepAlive: true
}
},
{
path: "/permissions",
name: "PermissionPolicies",
component: () => import("@/views/permissions/index.vue"),
meta: {
title: "权限策略",
menuKey: "permissions",
permission: "permission:view",
keepAlive: true
}
}
]
} satisfies RouteConfigsTable;
+33 -141
View File
@@ -9,25 +9,16 @@ import { router } from "./index";
import { isProxy, toRaw } from "vue";
import { useTimeoutFn } from "@vueuse/core";
import {
isString,
cloneDeep,
isAllEmpty,
intersection,
storageLocal,
isIncludeAllChildren
storageLocal
} from "@pureadmin/utils";
import { getConfig } from "@/config";
import { buildHierarchyTree } from "@/utils/tree";
import { userKey, type DataInfo } from "@/utils/auth";
import { type menuType, routerArrays } from "@/layout/types";
import { userKey, type DataInfo, type PermissionMenuInfo } from "@/utils/auth";
import type { menuType } from "@/layout/types";
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
import { usePermissionStoreHook } from "@/store/modules/permission";
const IFrame = () => import("@/layout/frame.vue");
// https://cn.vitejs.dev/guide/features.html#glob-import
const modulesRoutes = import.meta.glob("/src/views/**/*.{vue,tsx}");
// 动态路由
import { getAsyncRoutes } from "@/api/routes";
function handRank(routeInfo: any) {
const { name, path, parentId, meta } = routeInfo;
@@ -81,26 +72,49 @@ function isOneOfArray(a: Array<string>, b: Array<string>) {
: true;
}
function hasRoutePermission(meta: CustomizeRouteMeta, permissions: string[]) {
const required = meta?.permission;
function hasWildcardPermission(permissions: string[]) {
return permissions.includes("*") || permissions.includes("*:*:*");
}
if (!required) return true;
if (permissions.includes("*") || permissions.includes("*:*:*")) return true;
function hasMenuAccess(
meta: CustomizeRouteMeta,
permissions: string[],
permissionMenus: PermissionMenuInfo[] = []
) {
if (!meta?.menuKey) return true;
if (hasWildcardPermission(permissions)) return true;
return permissionMenus.some(menu => menu.key === meta.menuKey);
}
function hasRoutePermission(
meta: CustomizeRouteMeta,
permissions: string[],
permissionMenus: PermissionMenuInfo[] = []
) {
const required = meta?.permission;
const hasMenu = hasMenuAccess(meta, permissions, permissionMenus);
if (!required) return hasMenu;
if (hasWildcardPermission(permissions)) return true;
const requiredList = Array.isArray(required) ? required : [required];
return requiredList.some(permission => permissions.includes(permission));
return (
hasMenu && requiredList.some(permission => permissions.includes(permission))
);
}
/** 从localStorage取出当前登录用户权限码,过滤无权限菜单 */
/** 从 localStorage 取出后端菜单和权限码,过滤无权限菜单 */
function filterNoPermissionTree(data: RouteComponent[]) {
const userInfo = storageLocal().getItem<DataInfo<number>>(userKey);
const currentRoles = userInfo?.roles ?? [];
const currentPermissions = userInfo?.permissions ?? [];
const currentMenus = userInfo?.permissionMenus ?? [];
const newTree = cloneDeep(data).filter((v: any) => {
return (
isOneOfArray(v.meta?.roles, currentRoles) &&
hasRoutePermission(v.meta, currentPermissions)
hasRoutePermission(v.meta, currentPermissions, currentMenus)
);
});
newTree.forEach(
@@ -169,78 +183,6 @@ function addPathMatch() {
}
}
/** 处理动态路由(后端返回的路由) */
function handleAsyncRoutes(routeList) {
if (routeList.length === 0) {
usePermissionStoreHook().handleWholeMenus(routeList);
} else {
formatFlatteningRoutes(addAsyncRoutes(routeList)).map(
(v: RouteRecordRaw) => {
// 防止重复添加路由
if (
router.options.routes[0].children.findIndex(
value => value.path === v.path
) !== -1
) {
return;
} else {
// 切记将路由push到routes后还需要使用addRoute,这样路由才能正常跳转
router.options.routes[0].children.push(v);
// 最终路由进行升序
ascending(router.options.routes[0].children);
if (!router.hasRoute(v?.name)) router.addRoute(v);
const flattenRouters: any = router
.getRoutes()
.find(n => n.path === "/");
// 保持router.options.routes[0].children与path为"/"的children一致,防止数据不一致导致异常
flattenRouters.children = router.options.routes[0].children;
router.addRoute(flattenRouters);
}
}
);
usePermissionStoreHook().handleWholeMenus(routeList);
}
if (!useMultiTagsStoreHook().getMultiTagsCache) {
useMultiTagsStoreHook().handleTags("equal", [
...routerArrays,
...usePermissionStoreHook().flatteningRoutes.filter(
v => v?.meta?.fixedTag
)
]);
}
addPathMatch();
}
/** 初始化路由(`new Promise` 写法防止在异步请求中造成无限循环)*/
function initRouter() {
if (getConfig()?.CachingAsyncRoutes) {
// 开启动态路由缓存本地localStorage
const key = "async-routes";
const asyncRouteList = storageLocal().getItem(key) as any;
if (asyncRouteList && asyncRouteList?.length > 0) {
return new Promise(resolve => {
handleAsyncRoutes(asyncRouteList);
resolve(router);
});
} else {
return new Promise(resolve => {
getAsyncRoutes().then(({ data }) => {
handleAsyncRoutes(cloneDeep(data));
storageLocal().setItem(key, data);
resolve(router);
});
});
}
} else {
return new Promise(resolve => {
getAsyncRoutes().then(({ data }) => {
handleAsyncRoutes(cloneDeep(data));
resolve(router);
});
});
}
}
/**
* 将多级嵌套路由处理成一维数组
* @param routesList 传入路由
@@ -320,35 +262,6 @@ function handleAliveRoute({ name }: ToRouteType, mode?: string) {
}
}
/** 过滤后端传来的动态路由 重新生成规范路由 */
function addAsyncRoutes(arrRoutes: Array<RouteRecordRaw>) {
if (!arrRoutes || !arrRoutes.length) return;
const modulesRoutesKeys = Object.keys(modulesRoutes);
arrRoutes.forEach((v: RouteRecordRaw) => {
// 将backstage属性加入meta,标识此路由为后端返回路由
v.meta.backstage = true;
// 父级的redirect属性取值:如果子级存在且父级的redirect属性不存在,默认取第一个子级的path;如果子级存在且父级的redirect属性存在,取存在的redirect属性,会覆盖默认值
if (v?.children && v.children.length && !v.redirect)
v.redirect = v.children[0].path;
// 父级的name属性取值:如果子级存在且父级的name属性不存在,默认取第一个子级的name;如果子级存在且父级的name属性存在,取存在的name属性,会覆盖默认值(注意:测试中发现父级的name不能和子级name重复,如果重复会造成重定向无效(跳转404),所以这里给父级的name起名的时候后面会自动加上`Parent`,避免重复)
if (v?.children && v.children.length && !v.name)
v.name = (v.children[0].name as string) + "Parent";
if (v.meta?.frameSrc) {
v.component = IFrame;
} else {
// 对后端传component组件路径和不传做兼容(如果后端传component组件路径,那么path可以随便写,如果不传,component组件路径会跟path保持一致)
const index = v?.component
? modulesRoutesKeys.findIndex(ev => ev.includes(v.component as any))
: modulesRoutesKeys.findIndex(ev => ev.includes(v.path));
v.component = modulesRoutes[modulesRoutesKeys[index]];
}
if (v?.children && v.children.length) {
addAsyncRoutes(v.children);
}
});
return arrRoutes;
}
/** 获取路由历史模式 https://next.router.vuejs.org/zh/guide/essentials/history-mode.html */
function getHistoryMode(routerHistory): RouterHistory {
// len为1 代表只有历史模式 为2 代表历史模式中存在base参数 https://next.router.vuejs.org/zh/api/#%E5%8F%82%E6%95%B0-1
@@ -372,23 +285,6 @@ function getHistoryMode(routerHistory): RouterHistory {
}
}
/** 获取当前页面按钮级别的权限 */
function getAuths(): Array<string> {
return router.currentRoute.value.meta.auths as Array<string>;
}
/** 是否有按钮级别的权限(根据路由`meta`中的`auths`字段进行判断)*/
function hasAuth(value: string | Array<string>): boolean {
if (!value) return false;
/** 从当前路由的`meta`字段里获取按钮级别的所有自定义`code`值 */
const metaAuths = getAuths();
if (!metaAuths) return false;
const isAuths = isString(value)
? metaAuths.includes(value)
: isIncludeAllChildren(value, metaAuths);
return isAuths ? true : false;
}
function handleTopMenu(route) {
if (route?.children && route.children.length > 1) {
if (route.redirect) {
@@ -411,17 +307,13 @@ function getTopMenu(tag = false): menuType {
}
export {
hasAuth,
getAuths,
ascending,
filterTree,
initRouter,
getTopMenu,
addPathMatch,
isOneOfArray,
hasRoutePermission,
getHistoryMode,
addAsyncRoutes,
getParentPaths,
findRouteByPath,
handleAliveRoute,
+9
View File
@@ -100,6 +100,15 @@ export const hasPerms = (value: string | Array<string>): boolean => {
: isIncludeAllChildren(value, permissions);
};
/** 是否拥有后端返回的菜单入口。 */
export const hasMenuAccess = (menuKey: string): boolean => {
if (hasPerms("*")) return true;
const menus = getUserInfo()?.permissionMenus ?? [];
return menus.some(item => item.key === menuKey);
};
/** 是否拥有后端菜单动作权限,用于比权限码更细的按钮显隐。 */
export const hasMenuAction = (menuKey: string, action: string): boolean => {
if (hasPerms("*")) return true;
+1 -1
View File
@@ -2,7 +2,7 @@ import { removeToken, setToken, type DataInfo } from "./auth";
import { subBefore, getQueryMap } from "@pureadmin/utils";
/**
* 简版前端单点登录,根据实际业务自行编写,平台启动后本地可以跳后面这个链接进行测试 http://localhost:8848/#/permission/page/index?username=sso&roles=admin&accessToken=eyJhbGciOiJIUzUxMiJ9.admin
* 简版前端单点登录,根据实际业务自行编写,平台启动后本地可以跳后面这个链接进行测试 http://localhost:8848/#/employees?username=sso&roles=admin&accessToken=eyJhbGciOiJIUzUxMiJ9.admin
* 划重点:
* 判断是否为单点登录,不为则直接返回不再进行任何逻辑处理,下面是单点登录后的逻辑处理
* 1.清空本地旧信息;
+27 -17
View File
@@ -12,7 +12,7 @@ import {
getEmployee,
listEmployees,
listRoles,
listStores,
listStoreOptions,
updateEmployee,
updateEmployeeStatus,
type Employee,
@@ -21,7 +21,7 @@ import {
type RoleOption,
type StoreOption
} from "@/api/access";
import { hasPerms } from "@/utils/auth";
import { hasMenuAction, hasPerms } from "@/utils/auth";
import Plus from "~icons/ep/plus";
import Search from "~icons/ep/search";
@@ -107,7 +107,12 @@ const inactiveCount = computed(
() => employees.value.filter(item => item.status === "INACTIVE").length
);
const dialogTitle = computed(() => (form.id ? "编辑员工" : "新增员工"));
const canManageEmployees = computed(() => hasPerms("employee:manage"));
const canCreateEmployee = computed(() => hasMenuAction("employees", "create"));
const canUpdateEmployee = computed(() => hasMenuAction("employees", "update"));
const canDeleteEmployee = computed(() => hasMenuAction("employees", "delete"));
const canOperateEmployee = computed(
() => canUpdateEmployee.value || canDeleteEmployee.value
);
const canViewAllEmployees = computed(() => hasPerms("employee:view:all"));
function getErrorMessage(error: unknown, fallback: string) {
@@ -158,15 +163,17 @@ function buildPayload(): EmployeePayload {
/** 门店和角色是员工表单的基础字典,打开弹窗前必须先加载。 */
async function fetchCatalog() {
const shouldLoadStores =
canViewAllEmployees.value || canManageEmployees.value;
const shouldLoadRoles = canManageEmployees.value;
canViewAllEmployees.value ||
canCreateEmployee.value ||
canUpdateEmployee.value;
const shouldLoadRoles = canCreateEmployee.value || canUpdateEmployee.value;
if (!shouldLoadStores && !shouldLoadRoles) return;
catalogLoading.value = true;
try {
const [storeResult, roleResult] = await Promise.all([
shouldLoadStores ? listStores() : Promise.resolve({ data: [] }),
shouldLoadStores ? listStoreOptions() : Promise.resolve({ data: [] }),
shouldLoadRoles ? listRoles() : Promise.resolve({ data: [] })
]);
stores.value = storeResult.data;
@@ -227,14 +234,14 @@ function handleSizeChange(pageSize: number) {
}
function openCreateDialog() {
if (!canManageEmployees.value) return;
if (!canCreateEmployee.value) return;
resetFormState();
dialogVisible.value = true;
}
/** 编辑前重新拉详情,确保角色绑定不是来自列表摘要的过期数据。 */
async function openEditDialog(row: Employee) {
if (!canManageEmployees.value) return;
if (!canUpdateEmployee.value) return;
try {
const result = await getEmployee(row.id);
const employee = result.data;
@@ -255,7 +262,7 @@ async function openEditDialog(row: Employee) {
}
async function submitForm() {
if (!canManageEmployees.value) return;
if (form.id ? !canUpdateEmployee.value : !canCreateEmployee.value) return;
await formRef.value?.validate();
submitLoading.value = true;
@@ -280,7 +287,7 @@ async function submitForm() {
}
async function toggleStatus(row: Employee) {
if (!canManageEmployees.value) return;
if (!canUpdateEmployee.value) return;
const nextStatus: EmployeeStatus =
row.status === "ACTIVE" ? "INACTIVE" : "ACTIVE";
const action = nextStatus === "ACTIVE" ? "启用" : "停用";
@@ -306,7 +313,7 @@ async function toggleStatus(row: Employee) {
}
async function removeEmployee(row: Employee) {
if (!canManageEmployees.value) return;
if (!canDeleteEmployee.value) return;
try {
await ElMessageBox.confirm(
`删除后员工「${row.name}」会被软删除并停用,确认继续?`,
@@ -345,7 +352,7 @@ onMounted(async () => {
<h1>员工管理</h1>
</div>
<el-button
v-if="canManageEmployees"
v-if="canCreateEmployee"
type="primary"
:icon="Plus"
@click="openCreateDialog"
@@ -470,13 +477,14 @@ onMounted(async () => {
</template>
</el-table-column>
<el-table-column
v-if="canManageEmployees"
v-if="canOperateEmployee"
label="操作"
width="260"
fixed="right"
>
<template #default="{ row }">
<el-button
v-if="canUpdateEmployee"
link
type="primary"
:icon="EditPen"
@@ -485,6 +493,7 @@ onMounted(async () => {
编辑
</el-button>
<el-button
v-if="canUpdateEmployee"
link
:type="row.status === 'ACTIVE' ? 'warning' : 'success'"
:icon="row.status === 'ACTIVE' ? CircleClose : CircleCheck"
@@ -493,6 +502,7 @@ onMounted(async () => {
{{ row.status === "ACTIVE" ? "停用" : "启用" }}
</el-button>
<el-button
v-if="canDeleteEmployee"
link
type="danger"
:icon="Delete"
@@ -618,9 +628,9 @@ onMounted(async () => {
.page-heading {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
h1 {
@@ -733,9 +743,9 @@ onMounted(async () => {
.pagination-row {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px 0;
font-size: 13px;
color: #64748b;
@@ -761,8 +771,8 @@ onMounted(async () => {
}
.page-heading {
align-items: flex-start;
flex-direction: column;
align-items: flex-start;
}
.summary-strip {
@@ -781,8 +791,8 @@ onMounted(async () => {
}
.pagination-row {
align-items: flex-start;
flex-direction: column;
align-items: flex-start;
}
.form-grid {
-99
View File
@@ -1,99 +0,0 @@
<script setup lang="ts">
import { hasAuth, getAuths } from "@/router/utils";
defineOptions({
name: "PermissionButtonRouter"
});
</script>
<template>
<div>
<p class="mb-2!">当前拥有的code列表{{ getAuths() }}</p>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">组件方式判断权限</div>
</template>
<el-space wrap>
<Auth value="permission:btn:add">
<el-button plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
</Auth>
<Auth :value="['permission:btn:edit']">
<el-button plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
</Auth>
<Auth
:value="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
>
<el-button plain type="danger">
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</Auth>
</el-space>
</el-card>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">函数方式判断权限</div>
</template>
<el-space wrap>
<el-button v-if="hasAuth('permission:btn:add')" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button v-if="hasAuth(['permission:btn:edit'])" plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-if="
hasAuth([
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
])
"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
<el-card shadow="never">
<template #header>
<div class="card-header">
指令方式判断权限(该方式不能动态修改权限)
</div>
</template>
<el-space wrap>
<el-button v-auth="'permission:btn:add'" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button v-auth="['permission:btn:edit']" plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-auth="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
</div>
</template>
-109
View File
@@ -1,109 +0,0 @@
<script setup lang="ts">
import { hasPerms } from "@/utils/auth";
import { useUserStoreHook } from "@/store/modules/user";
const { permissions } = useUserStoreHook();
defineOptions({
name: "PermissionButtonLogin"
});
</script>
<template>
<div>
<p class="mb-2!">当前拥有的code列表{{ permissions }}</p>
<p v-show="permissions?.[0] === '*:*:*'" class="mb-2!">
*:*:* 代表拥有全部按钮级别权限
</p>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">组件方式判断权限</div>
</template>
<el-space wrap>
<Perms value="permission:btn:add">
<el-button plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
</Perms>
<Perms :value="['permission:btn:edit']">
<el-button plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
</Perms>
<Perms
:value="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
>
<el-button plain type="danger">
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</Perms>
</el-space>
</el-card>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">函数方式判断权限</div>
</template>
<el-space wrap>
<el-button v-if="hasPerms('permission:btn:add')" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button
v-if="hasPerms(['permission:btn:edit'])"
plain
type="primary"
>
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-if="
hasPerms([
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
])
"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
<el-card shadow="never">
<template #header>
<div class="card-header">
指令方式判断权限(该方式不能动态修改权限)
</div>
</template>
<el-space wrap>
<el-button v-perms="'permission:btn:add'" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button v-perms="['permission:btn:edit']" plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-perms="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
</div>
</template>
-66
View File
@@ -1,66 +0,0 @@
<script setup lang="ts">
import { initRouter } from "@/router/utils";
import { storageLocal } from "@pureadmin/utils";
import { type CSSProperties, ref, computed } from "vue";
import { useUserStoreHook } from "@/store/modules/user";
import { usePermissionStoreHook } from "@/store/modules/permission";
defineOptions({
name: "PermissionPage"
});
const elStyle = computed((): CSSProperties => {
return {
width: "85vw",
justifyContent: "start"
};
});
const username = ref(useUserStoreHook()?.username);
const options = [
{
value: "admin",
label: "管理员角色"
},
{
value: "common",
label: "普通角色"
}
];
function onChange() {
useUserStoreHook()
.loginByUsername({ username: username.value, password: "admin123" })
.then(res => {
if (res.success) {
storageLocal().removeItem("async-routes");
usePermissionStoreHook().clearAllCachePage();
initRouter();
}
});
}
</script>
<template>
<div>
<p class="mb-2!">
模拟后台根据不同角色返回对应路由观察左侧菜单变化管理员角色可查看系统管理菜单普通角色不可查看系统管理菜单
</p>
<el-card shadow="never" :style="elStyle">
<template #header>
<div class="card-header">
<span>当前角色{{ username }}</span>
</div>
</template>
<el-select v-model="username" class="w-[160px]!" @change="onChange">
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-card>
</div>
</template>
+533
View File
@@ -0,0 +1,533 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import {
getPermissionDefinitions,
getPermissionPolicies,
updateRolePermissions,
type PermissionDefinitionGroup,
type PermissionPolicy
} from "@/api/access";
import { useUserStoreHook } from "@/store/modules/user";
import { hasMenuAction } from "@/utils/auth";
import Check from "~icons/ep/check";
import Edit from "~icons/ep/edit";
import Refresh from "~icons/ep/refresh";
defineOptions({
name: "PermissionPolicies"
});
const tableLoading = ref(false);
const saving = ref(false);
const drawerVisible = ref(false);
const policies = ref<PermissionPolicy[]>([]);
const definitionGroups = ref<PermissionDefinitionGroup[]>([]);
const editingPolicy = ref<PermissionPolicy | null>(null);
const checkedPermissions = ref<string[]>([]);
const canUpdatePermissions = computed(() =>
hasMenuAction("permissions", "update")
);
const editableRoleCount = computed(
() => policies.value.filter(item => item.editable).length
);
const menuTotal = computed(() =>
policies.value.reduce((total, item) => total + item.menus.length, 0)
);
const permissionTotal = computed(() =>
policies.value.reduce(
(total, item) =>
total + item.permissions.filter(permission => permission !== "*").length,
0
)
);
const drawerTitle = computed(() =>
editingPolicy.value ? `分配权限:${editingPolicy.value.roleName}` : "分配权限"
);
function getErrorMessage(error: unknown, fallback: string) {
const message = (
error as { response?: { data?: { error?: { message?: string } } } }
)?.response?.data?.error?.message;
if (message) return message;
if (error instanceof Error && error.message) return error.message;
return fallback;
}
function formatActions(actions: string[]) {
const actionMap: Record<string, string> = {
view: "查看",
create: "新增",
update: "编辑",
delete: "删除"
};
return actions.map(action => actionMap[action] ?? action).join(" / ");
}
function formatPermissionTitle(code: string) {
for (const group of definitionGroups.value) {
const found = group.permissions.find(permission => permission.code === code);
if (found) return found.title;
}
return code;
}
function isGroupChecked(group: PermissionDefinitionGroup) {
const codes = group.permissions.map(permission => permission.code);
return codes.every(code => checkedPermissions.value.includes(code));
}
function isGroupIndeterminate(group: PermissionDefinitionGroup) {
const codes = group.permissions.map(permission => permission.code);
const checkedCount = codes.filter(code =>
checkedPermissions.value.includes(code)
).length;
return checkedCount > 0 && checkedCount < codes.length;
}
function toggleGroup(group: PermissionDefinitionGroup, checked: boolean) {
const nextPermissions = new Set(checkedPermissions.value);
for (const permission of group.permissions) {
if (checked) {
nextPermissions.add(permission.code);
} else {
nextPermissions.delete(permission.code);
}
}
checkedPermissions.value = [...nextPermissions];
}
function openEditor(policy: PermissionPolicy) {
if (!policy.editable || !canUpdatePermissions.value) return;
editingPolicy.value = policy;
checkedPermissions.value = policy.permissions.filter(
permission => permission !== "*"
);
drawerVisible.value = true;
}
async function fetchPermissionData() {
tableLoading.value = true;
try {
const [policyResult, definitionResult] = await Promise.all([
getPermissionPolicies(),
getPermissionDefinitions()
]);
policies.value = policyResult.data;
definitionGroups.value = definitionResult.data.groups;
} catch (error) {
ElMessage.error(getErrorMessage(error, "加载权限数据失败"));
} finally {
tableLoading.value = false;
}
}
async function savePermissions() {
if (!editingPolicy.value) return;
saving.value = true;
try {
await updateRolePermissions(
editingPolicy.value.roleId,
checkedPermissions.value
);
await fetchPermissionData();
await useUserStoreHook().loadAuthContext(true);
drawerVisible.value = false;
ElMessage.success("权限已更新");
} catch (error) {
ElMessage.error(getErrorMessage(error, "保存权限失败"));
} finally {
saving.value = false;
}
}
onMounted(fetchPermissionData);
</script>
<template>
<section class="permission-page">
<div class="page-heading">
<div>
<p class="eyebrow">动态权限分配</p>
<h1>权限策略</h1>
</div>
<el-button :icon="Refresh" @click="fetchPermissionData">刷新</el-button>
</div>
<div class="summary-strip">
<div class="summary-item">
<span>角色策略</span>
<strong>{{ policies.length }}</strong>
</div>
<div class="summary-item">
<span>可分配角色</span>
<strong>{{ editableRoleCount }}</strong>
</div>
<div class="summary-item">
<span>权限码</span>
<strong>{{ permissionTotal }}</strong>
</div>
<div class="summary-item">
<span>菜单授权</span>
<strong>{{ menuTotal }}</strong>
</div>
</div>
<div class="table-shell">
<el-table
v-loading="tableLoading"
:data="policies"
row-key="roleCode"
stripe
class="permission-table"
>
<el-table-column label="角色" min-width="190">
<template #default="{ row }">
<div class="role-cell">
<strong>{{ row.roleName }}</strong>
<span>{{ row.roleCode }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="scope" label="作用范围" min-width="140" />
<el-table-column label="权限码" min-width="300">
<template #default="{ row }">
<div class="tag-list">
<el-tag
v-for="permission in row.permissions"
:key="permission"
effect="plain"
size="small"
>
{{ permission === "*" ? "全部权限" : formatPermissionTitle(permission) }}
</el-tag>
<span v-if="row.permissions.length === 0" class="muted-text">
未分配
</span>
</div>
</template>
</el-table-column>
<el-table-column label="菜单与动作" min-width="360">
<template #default="{ row }">
<div class="menu-list">
<div v-for="menu in row.menus" :key="menu.key" class="menu-item">
<strong>{{ menu.title }}</strong>
<span>{{ formatActions(menu.actions) }}</span>
</div>
<span v-if="row.menus.length === 0" class="muted-text">
无后台菜单
</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="132" fixed="right">
<template #default="{ row }">
<el-tooltip
:content="row.editable ? '分配权限' : '系统最高权限不可编辑'"
placement="top"
>
<span>
<el-button
:icon="Edit"
:disabled="!row.editable || !canUpdatePermissions"
text
type="primary"
@click="openEditor(row)"
>
分配
</el-button>
</span>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
<el-drawer
v-model="drawerVisible"
:title="drawerTitle"
size="520px"
destroy-on-close
>
<div class="drawer-body">
<section
v-for="group in definitionGroups"
:key="group.key"
class="permission-group"
>
<div class="group-heading">
<strong>{{ group.title }}</strong>
<el-checkbox
:model-value="isGroupChecked(group)"
:indeterminate="isGroupIndeterminate(group)"
@change="value => toggleGroup(group, Boolean(value))"
>
全选
</el-checkbox>
</div>
<div class="permission-options">
<el-checkbox-group v-model="checkedPermissions">
<el-checkbox
v-for="permission in group.permissions"
:key="permission.code"
:label="permission.code"
border
>
<span class="permission-option">
<strong>{{ permission.title }}</strong>
<small>{{ permission.code }}</small>
</span>
</el-checkbox>
</el-checkbox-group>
</div>
</section>
</div>
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button
:icon="Check"
:loading="saving"
type="primary"
@click="savePermissions"
>
保存
</el-button>
</div>
</template>
</el-drawer>
</section>
</template>
<style scoped lang="scss">
.permission-page {
min-height: 100%;
padding: 20px;
background: #f6f8fb;
}
.page-heading {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h1 {
margin: 4px 0 0;
font-size: 24px;
font-weight: 650;
color: #111827;
letter-spacing: 0;
}
}
.eyebrow {
margin: 0;
font-size: 13px;
color: #64748b;
}
.summary-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.summary-item {
padding: 14px 16px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
span {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: #64748b;
}
strong {
font-size: 24px;
font-weight: 700;
color: #111827;
letter-spacing: 0;
}
}
.table-shell {
padding: 8px 0 14px;
overflow: hidden;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.permission-table {
width: 100%;
}
.role-cell {
display: flex;
flex-direction: column;
gap: 3px;
strong {
font-weight: 650;
color: #111827;
}
span {
font-size: 12px;
color: #64748b;
}
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.menu-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.menu-item {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 8px;
strong {
font-weight: 650;
color: #111827;
}
span {
font-size: 13px;
color: #64748b;
}
}
.muted-text {
font-size: 13px;
color: #94a3b8;
}
.drawer-body {
padding-right: 4px;
}
.permission-group {
padding: 14px 0;
border-bottom: 1px solid #e5e7eb;
}
.permission-group:first-child {
padding-top: 0;
}
.permission-group:last-child {
border-bottom: 0;
}
.group-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
strong {
font-size: 15px;
color: #111827;
}
}
.permission-options {
:deep(.el-checkbox-group) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
:deep(.el-checkbox.is-bordered) {
height: auto;
margin-right: 0;
padding: 10px 12px;
}
:deep(.el-checkbox__label) {
min-width: 0;
}
}
.permission-option {
display: inline-flex;
flex-direction: column;
gap: 2px;
min-width: 0;
strong {
font-size: 14px;
font-weight: 650;
color: #111827;
}
small {
overflow: hidden;
font-size: 12px;
color: #64748b;
text-overflow: ellipsis;
}
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (max-width: 760px) {
.permission-page {
padding: 12px;
}
.page-heading {
flex-direction: column;
align-items: flex-start;
}
.summary-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.permission-options :deep(.el-checkbox-group) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.menu-item {
flex-direction: column;
align-items: flex-start;
}
}
</style>
+107 -37
View File
@@ -9,12 +9,12 @@ import {
import {
createRole,
deleteRole,
listRoles,
listRolePage,
updateRole,
type Role,
type RolePayload
} from "@/api/access";
import { hasPerms } from "@/utils/auth";
import { hasMenuAction } from "@/utils/auth";
import Plus from "~icons/ep/plus";
import Search from "~icons/ep/search";
@@ -40,7 +40,16 @@ const formRef = ref<FormInstance>();
const roles = ref<Role[]>([]);
const query = reactive({
keyword: ""
keyword: "",
isSystem: undefined as boolean | undefined,
page: 1,
pageSize: 20
});
/** 角色管理列表由后端分页,这里只记录当前筛选结果的分页摘要。 */
const pagination = reactive({
total: 0,
totalPages: 0
});
const form = reactive<RoleFormState>({
@@ -72,26 +81,15 @@ const describedCount = computed(
() => roles.value.filter(item => Boolean(item.description)).length
);
const systemRoleCount = computed(
() => roles.value.filter(item => item.code === "admin").length
() => roles.value.filter(item => item.isSystem).length
);
const dialogTitle = computed(() => (form.id ? "编辑角色" : "新增角色"));
const canManageRoles = computed(() => hasPerms("role:manage"));
function applyRoleQuery(items: Role[]) {
const keyword = query.keyword.trim().toLowerCase();
if (keyword.length === 0) {
return items;
}
return items.filter(role => {
return (
role.code.toLowerCase().includes(keyword) ||
role.name.toLowerCase().includes(keyword) ||
(role.description ?? "").toLowerCase().includes(keyword)
);
});
}
const canCreateRole = computed(() => hasMenuAction("roles", "create"));
const canUpdateRole = computed(() => hasMenuAction("roles", "update"));
const canDeleteRole = computed(() => hasMenuAction("roles", "delete"));
const canOperateRole = computed(
() => canUpdateRole.value || canDeleteRole.value
);
function getErrorMessage(error: unknown, fallback: string) {
const message = (
@@ -134,12 +132,19 @@ function buildPayload(): RolePayload {
};
}
/** 角色查询必须走接口,避免搜索条件只在前端过滤当前缓存。 */
/** 角色筛选、分页必须走接口,避免查询条件只在前端过滤当前缓存。 */
async function fetchRoles() {
tableLoading.value = true;
try {
const result = await listRoles();
roles.value = applyRoleQuery(result.data);
const result = await listRolePage({
keyword: query.keyword.trim() || undefined,
isSystem: query.isSystem,
page: query.page,
pageSize: query.pageSize
});
roles.value = result.data.items;
pagination.total = result.data.pagination.total;
pagination.totalPages = result.data.pagination.totalPages;
} catch (error) {
ElMessage.error(getErrorMessage(error, "加载角色列表失败"));
} finally {
@@ -149,22 +154,37 @@ async function fetchRoles() {
function handleReset() {
query.keyword = "";
query.isSystem = undefined;
query.page = 1;
query.pageSize = 20;
fetchRoles();
}
function handleSearch() {
query.keyword = query.keyword.trim();
query.page = 1;
fetchRoles();
}
function handlePageChange(page: number) {
query.page = page;
fetchRoles();
}
function handleSizeChange(pageSize: number) {
query.page = 1;
query.pageSize = pageSize;
fetchRoles();
}
function openCreateDialog() {
if (!canManageRoles.value) return;
if (!canCreateRole.value) return;
resetFormState();
dialogVisible.value = true;
}
function openEditDialog(row: Role) {
if (!canManageRoles.value || row.isSystem) return;
if (!canUpdateRole.value || row.isSystem) return;
Object.assign(form, {
id: row.id,
code: row.code,
@@ -176,7 +196,7 @@ function openEditDialog(row: Role) {
}
async function submitForm() {
if (!canManageRoles.value) return;
if (form.id ? !canUpdateRole.value : !canCreateRole.value) return;
await formRef.value?.validate();
submitLoading.value = true;
@@ -201,7 +221,7 @@ async function submitForm() {
}
async function removeRole(row: Role) {
if (!canManageRoles.value || row.isSystem) return;
if (!canDeleteRole.value || row.isSystem) return;
try {
await ElMessageBox.confirm(
`删除后角色「${row.name}」无法再绑定员工,确认继续?`,
@@ -214,6 +234,10 @@ async function removeRole(row: Role) {
);
await deleteRole(row.id);
ElMessage.success("角色已删除");
if (roles.value.length === 1 && query.page > 1) {
query.page -= 1;
}
fetchRoles();
} catch (error) {
if (error !== "cancel") {
@@ -233,7 +257,7 @@ onMounted(fetchRoles);
<h1>角色管理</h1>
</div>
<el-button
v-if="canManageRoles"
v-if="canCreateRole"
type="primary"
:icon="Plus"
@click="openCreateDialog"
@@ -245,14 +269,14 @@ onMounted(fetchRoles);
<div class="summary-strip">
<div class="summary-item">
<span>总角色</span>
<strong>{{ roles.length }}</strong>
<strong>{{ pagination.total }}</strong>
</div>
<div class="summary-item">
<span>已配置说明</span>
<span>当前页有说明</span>
<strong>{{ describedCount }}</strong>
</div>
<div class="summary-item">
<span>系统角色</span>
<span>当前页系统角色</span>
<strong>{{ systemRoleCount }}</strong>
</div>
<div class="summary-item">
@@ -262,6 +286,16 @@ onMounted(fetchRoles);
</div>
<div class="toolbar">
<el-select
v-model="query.isSystem"
clearable
placeholder="全部类型"
class="toolbar-control"
@change="handleSearch"
>
<el-option label="系统内置" :value="true" />
<el-option label="自定义角色" :value="false" />
</el-select>
<el-input
v-model="query.keyword"
clearable
@@ -305,14 +339,14 @@ onMounted(fetchRoles);
</template>
</el-table-column>
<el-table-column
v-if="canManageRoles"
v-if="canOperateRole"
label="操作"
width="170"
fixed="right"
>
<template #default="{ row }">
<el-button
v-if="!row.isSystem"
v-if="canUpdateRole && !row.isSystem"
link
type="primary"
:icon="EditPen"
@@ -321,7 +355,7 @@ onMounted(fetchRoles);
编辑
</el-button>
<el-button
v-if="!row.isSystem"
v-if="canDeleteRole && !row.isSystem"
link
type="danger"
:icon="Delete"
@@ -333,6 +367,22 @@ onMounted(fetchRoles);
</template>
</el-table-column>
</el-table>
<div class="pagination-row">
<span
> {{ pagination.total }} {{ pagination.totalPages }} </span
>
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
background
layout="sizes, prev, pager, next"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<el-dialog
@@ -391,9 +441,9 @@ onMounted(fetchRoles);
.page-heading {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
h1 {
@@ -459,6 +509,10 @@ onMounted(fetchRoles);
width: 280px;
}
.toolbar-control {
width: 180px;
}
.toolbar-actions {
display: flex;
gap: 8px;
@@ -474,6 +528,16 @@ onMounted(fetchRoles);
width: 100%;
}
.pagination-row {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
padding: 14px 16px 0;
font-size: 13px;
color: #64748b;
}
.role-cell {
display: flex;
flex-direction: column;
@@ -510,8 +574,8 @@ onMounted(fetchRoles);
}
.page-heading {
align-items: flex-start;
flex-direction: column;
align-items: flex-start;
}
.summary-strip {
@@ -519,6 +583,7 @@ onMounted(fetchRoles);
}
.keyword-input,
.toolbar-control,
.toolbar-actions {
width: 100%;
margin-left: 0;
@@ -528,6 +593,11 @@ onMounted(fetchRoles);
justify-content: flex-end;
}
.pagination-row {
flex-direction: column;
align-items: flex-start;
}
.form-grid {
grid-template-columns: 1fr;
}
+88 -34
View File
@@ -15,7 +15,7 @@ import {
type StorePayload,
type StoreStatus
} from "@/api/access";
import { hasPerms } from "@/utils/auth";
import { hasMenuAction } from "@/utils/auth";
import Plus from "~icons/ep/plus";
import Search from "~icons/ep/search";
@@ -42,7 +42,15 @@ const stores = ref<Store[]>([]);
const query = reactive({
status: undefined as StoreStatus | undefined,
keyword: ""
keyword: "",
page: 1,
pageSize: 20
});
/** 门店管理列表由后端分页,这里只记录当前筛选结果的分页摘要。 */
const pagination = reactive({
total: 0,
totalPages: 0
});
const form = reactive<StoreFormState>({
@@ -71,23 +79,12 @@ const inactiveCount = computed(
() => stores.value.filter(item => item.status === "INACTIVE").length
);
const dialogTitle = computed(() => (form.id ? "编辑门店" : "新增门店"));
const canManageStores = computed(() => hasPerms("store:manage"));
function applyStoreQuery(items: Store[]) {
const keyword = query.keyword.trim().toLowerCase();
return items.filter(store => {
const matchedStatus =
query.status === undefined || store.status === query.status;
const matchedKeyword =
keyword.length === 0 ||
store.name.toLowerCase().includes(keyword) ||
(store.address ?? "").toLowerCase().includes(keyword) ||
(store.phone ?? "").toLowerCase().includes(keyword);
return matchedStatus && matchedKeyword;
});
}
const canCreateStore = computed(() => hasMenuAction("stores", "create"));
const canUpdateStore = computed(() => hasMenuAction("stores", "update"));
const canDeleteStore = computed(() => hasMenuAction("stores", "delete"));
const canOperateStore = computed(
() => canUpdateStore.value || canDeleteStore.value
);
function getErrorMessage(error: unknown, fallback: string) {
const message = (
@@ -133,14 +130,19 @@ function buildPayload(): StorePayload {
};
}
/** 门店筛选必须走接口,避免查询条件只停留在当前页面内存里。 */
/** 门店筛选、分页必须走接口,避免查询条件只停留在当前页面内存里。 */
async function fetchStores() {
tableLoading.value = true;
try {
const result = await listStores({
includeInactive: true
status: query.status,
keyword: query.keyword.trim() || undefined,
page: query.page,
pageSize: query.pageSize
});
stores.value = applyStoreQuery(result.data);
stores.value = result.data.items;
pagination.total = result.data.pagination.total;
pagination.totalPages = result.data.pagination.totalPages;
} catch (error) {
ElMessage.error(getErrorMessage(error, "加载门店列表失败"));
} finally {
@@ -151,22 +153,36 @@ async function fetchStores() {
function handleReset() {
query.status = undefined;
query.keyword = "";
query.page = 1;
query.pageSize = 20;
fetchStores();
}
function handleSearch() {
query.keyword = query.keyword.trim();
query.page = 1;
fetchStores();
}
function handlePageChange(page: number) {
query.page = page;
fetchStores();
}
function handleSizeChange(pageSize: number) {
query.page = 1;
query.pageSize = pageSize;
fetchStores();
}
function openCreateDialog() {
if (!canManageStores.value) return;
if (!canCreateStore.value) return;
resetFormState();
dialogVisible.value = true;
}
function openEditDialog(row: Store) {
if (!canManageStores.value) return;
if (!canUpdateStore.value) return;
Object.assign(form, {
id: row.id,
name: row.name,
@@ -179,7 +195,7 @@ function openEditDialog(row: Store) {
}
async function submitForm() {
if (!canManageStores.value) return;
if (form.id ? !canUpdateStore.value : !canCreateStore.value) return;
await formRef.value?.validate();
submitLoading.value = true;
@@ -204,7 +220,7 @@ async function submitForm() {
}
async function toggleStatus(row: Store) {
if (!canManageStores.value) return;
if (!canUpdateStore.value) return;
const nextStatus: StoreStatus =
row.status === "ACTIVE" ? "INACTIVE" : "ACTIVE";
const action = nextStatus === "ACTIVE" ? "启用" : "停用";
@@ -230,7 +246,7 @@ async function toggleStatus(row: Store) {
}
async function removeStore(row: Store) {
if (!canManageStores.value) return;
if (!canDeleteStore.value) return;
try {
await ElMessageBox.confirm(
`删除后门店「${row.name}」不会再出现在员工新增下拉里,确认继续?`,
@@ -243,6 +259,10 @@ async function removeStore(row: Store) {
);
await deleteStore(row.id);
ElMessage.success("门店已删除");
if (stores.value.length === 1 && query.page > 1) {
query.page -= 1;
}
fetchStores();
} catch (error) {
if (error !== "cancel") {
@@ -262,7 +282,7 @@ onMounted(fetchStores);
<h1>门店管理</h1>
</div>
<el-button
v-if="canManageStores"
v-if="canCreateStore"
type="primary"
:icon="Plus"
@click="openCreateDialog"
@@ -274,14 +294,14 @@ onMounted(fetchStores);
<div class="summary-strip">
<div class="summary-item">
<span>总门店</span>
<strong>{{ stores.length }}</strong>
<strong>{{ pagination.total }}</strong>
</div>
<div class="summary-item">
<span>启用门店</span>
<span>当前页启用</span>
<strong>{{ activeCount }}</strong>
</div>
<div class="summary-item">
<span>停用门店</span>
<span>当前页停用</span>
<strong>{{ inactiveCount }}</strong>
</div>
<div class="summary-item">
@@ -356,13 +376,14 @@ onMounted(fetchStores);
</template>
</el-table-column>
<el-table-column
v-if="canManageStores"
v-if="canOperateStore"
label="操作"
width="260"
fixed="right"
>
<template #default="{ row }">
<el-button
v-if="canUpdateStore"
link
type="primary"
:icon="EditPen"
@@ -371,6 +392,7 @@ onMounted(fetchStores);
编辑
</el-button>
<el-button
v-if="canUpdateStore"
link
:type="row.status === 'ACTIVE' ? 'warning' : 'success'"
:icon="row.status === 'ACTIVE' ? CircleClose : CircleCheck"
@@ -379,6 +401,7 @@ onMounted(fetchStores);
{{ row.status === "ACTIVE" ? "停用" : "启用" }}
</el-button>
<el-button
v-if="canDeleteStore"
link
type="danger"
:icon="Delete"
@@ -389,6 +412,22 @@ onMounted(fetchStores);
</template>
</el-table-column>
</el-table>
<div class="pagination-row">
<span
> {{ pagination.total }} {{ pagination.totalPages }} </span
>
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
background
layout="sizes, prev, pager, next"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<el-dialog
@@ -453,9 +492,9 @@ onMounted(fetchStores);
.page-heading {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
h1 {
@@ -540,6 +579,16 @@ onMounted(fetchStores);
width: 100%;
}
.pagination-row {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
padding: 14px 16px 0;
font-size: 13px;
color: #64748b;
}
.primary-text {
font-weight: 650;
color: #111827;
@@ -561,8 +610,8 @@ onMounted(fetchStores);
}
.page-heading {
align-items: flex-start;
flex-direction: column;
align-items: flex-start;
}
.summary-strip {
@@ -580,6 +629,11 @@ onMounted(fetchStores);
justify-content: flex-end;
}
.pagination-row {
flex-direction: column;
align-items: flex-start;
}
.form-grid {
grid-template-columns: 1fr;
}