feat: 接入真实登录鉴权流程

This commit is contained in:
湛兮
2026-05-26 14:45:15 +08:00
parent a6c9f5dee3
commit 5003628017
21 changed files with 572 additions and 305 deletions
+3 -1
View File
@@ -94,7 +94,9 @@ This repository is not a monorepo and has no `packages/` directory. Important sc
Development mode proxies `/api` to `VITE_API_PROXY_TARGET`, defaulting to `http://localhost:3500`. The relevant files are `.env.development` and `vite.config.ts`. Development mode proxies `/api` to `VITE_API_PROXY_TARGET`, defaulting to `http://localhost:3500`. The relevant files are `.env.development` and `vite.config.ts`.
List search, reset, pagination, status changes, deletes, and save refreshes should go through the API layer. Store list requests may send `includeInactive`, `status`, and `keyword`; role list requests may send `keyword`; employee list requests send `page`, `pageSize`, `storeId`, `status`, and `keyword` as needed. Login uses `POST /api/auth/admin/login`, then the app validates the token through `GET /api/auth/me` and loads menus/actions through `GET /api/permissions/me`. The backend does not expose refresh-token; local `401` or token expiry clears auth state and redirects to `/login`.
List search, reset, pagination, status changes, deletes, and save refreshes should go through the API layer. Store and role pages re-fetch their list before local narrowing because those endpoints do not define keyword filters; employee list requests send `page`, `pageSize`, `storeId`, `status`, and `keyword` as needed.
## Documentation Sync Rule ## Documentation Sync Rule
+16 -2
View File
@@ -103,7 +103,9 @@ http://localhost:8848/
- `src/views/roles/index.vue`: 角色管理,搜索、重置、删除和保存后都会重新调用接口,支持角色编码校验。 - `src/views/roles/index.vue`: 角色管理,搜索、重置、删除和保存后都会重新调用接口,支持角色编码校验。
- `src/views/employees/index.vue`: 员工管理,门店/状态/关键词筛选、重置、分页、启停、删除和保存后都会重新调用接口。 - `src/views/employees/index.vue`: 员工管理,门店/状态/关键词筛选、重置、分页、启停、删除和保存后都会重新调用接口。
- `src/api/access.ts`: 门店、角色、员工接口类型与 HTTP 方法封装。 - `src/api/access.ts`: 门店、角色、员工接口类型与 HTTP 方法封装。
- `src/api/user.ts`: 登录、当前用户、当前权限菜单接口封装。
- `src/router/modules/employees.ts`: 权限管理菜单入口,挂载门店、角色、员工三个页面。 - `src/router/modules/employees.ts`: 权限管理菜单入口,挂载门店、角色、员工三个页面。
- `src/store/modules/user.ts`: 保存 JWT 登录态、当前用户、权限码和后端菜单动作权限。
## 后端对接 ## 后端对接
@@ -114,12 +116,15 @@ http://localhost:8848/
当前已对接接口: 当前已对接接口:
- `GET /api/stores`,管理列表会携带 `includeInactive`,筛选时会携带 `status``keyword` - `POST /api/auth/admin/login`
- `GET /api/auth/me`
- `GET /api/permissions/me`
- `GET /api/stores`,管理列表会携带 `includeInactive`,搜索/重置会重新请求接口后按当前条件收敛结果
- `GET /api/stores/:id` - `GET /api/stores/:id`
- `POST /api/stores` - `POST /api/stores`
- `PATCH /api/stores/:id` - `PATCH /api/stores/:id`
- `DELETE /api/stores/:id` - `DELETE /api/stores/:id`
- `GET /api/roles`,搜索时会携带 `keyword` - `GET /api/roles`,搜索/重置会重新请求接口后按关键词收敛结果
- `GET /api/roles/:id` - `GET /api/roles/:id`
- `POST /api/roles` - `POST /api/roles`
- `PATCH /api/roles/:id` - `PATCH /api/roles/:id`
@@ -133,6 +138,15 @@ http://localhost:8848/
接口响应统一在 `src/api/access.ts` 中使用 `ApiResult<T>``PaginatedData<T>` 描述,页面层只消费 `result.data`,避免在视图里重复拼接接口路径。列表搜索、重置、分页和状态变更后的刷新都应通过接口层完成,不直接依赖页面内存里的旧列表。 接口响应统一在 `src/api/access.ts` 中使用 `ApiResult<T>``PaginatedData<T>` 描述,页面层只消费 `result.data`,避免在视图里重复拼接接口路径。列表搜索、重置、分页和状态变更后的刷新都应通过接口层完成,不直接依赖页面内存里的旧列表。
## 登录与鉴权流程
1. 登录页调用 `POST /api/auth/admin/login`,支持超级管理员用户名和具备后台权限的员工手机号。
2. 前端保存后端返回的 `data.token`,并按 `expiresIn` 换算本地过期时间。
3. 登录成功后立即调用 `GET /api/auth/me` 校验当前账号,再调用 `GET /api/permissions/me` 获取权限码、菜单和动作权限。
4. 路由守卫会在刷新页面后重新拉取当前用户和权限,未登录或 token 过期会跳回 `/login`
5. 菜单按路由 `meta.permission` 与后端权限码过滤,按钮按 `store:manage``role:manage``employee:manage` 等权限码显隐。
6. 后端当前没有 refresh-token 接口,收到 `401` 或本地 token 过期时直接清理登录态并要求重新登录。
## 配置说明 ## 配置说明
- `.env`: 全局默认配置,目前包含端口和是否隐藏首页。 - `.env`: 全局默认配置,目前包含端口和是否隐藏首页。
+3 -10
View File
@@ -44,6 +44,7 @@ export interface RoleOption {
code: string; code: string;
name: string; name: string;
description: string | null; description: string | null;
isSystem: boolean;
} }
export interface Store extends StoreOption { export interface Store extends StoreOption {
@@ -68,12 +69,6 @@ export interface EmployeeListParams {
export interface StoreListParams { export interface StoreListParams {
includeInactive?: boolean; includeInactive?: boolean;
status?: StoreStatus;
keyword?: string;
}
export interface RoleListParams {
keyword?: string;
} }
export interface StorePayload { export interface StorePayload {
@@ -123,10 +118,8 @@ export const listStores = (params?: StoreListParams) => {
}); });
}; };
export const listRoles = (params?: RoleListParams) => { export const listRoles = () => {
return http.request<ApiResult<Role[]>>("get", `${API_PREFIX}/roles`, { return http.request<ApiResult<Role[]>>("get", `${API_PREFIX}/roles`);
params
});
}; };
export const getStore = (id: number) => { export const getStore = (id: number) => {
+57 -30
View File
@@ -1,45 +1,72 @@
import { http } from "@/utils/http"; import { http } from "@/utils/http";
export type UserResult = { export type AuthAccountType = "SUPER_ADMIN" | "EMPLOYEE";
export interface AuthRole {
id: number;
code: string;
name: string;
}
export interface AuthUser {
id: number;
username: string;
displayName: string;
accountType: AuthAccountType;
storeId?: number;
storeName?: string;
roles: AuthRole[];
permissions: string[];
canManage: boolean;
}
export interface PermissionMenu {
key: string;
title: string;
icon?: string;
permission: string;
actions: string[];
}
export interface LoginInput {
username: string;
password: string;
}
export type LoginResult = {
success: boolean; success: boolean;
data: { data: {
/** 头像 */ token: string;
avatar: string; tokenType: "Bearer";
/** 用户名 */ expiresIn: string;
username: string; user: AuthUser;
/** 昵称 */
nickname: string;
/** 当前登录用户的角色 */
roles: Array<string>;
/** 按钮级别权限 */
permissions: Array<string>;
/** `token` */
accessToken: string;
/** 用于调用刷新`accessToken`的接口时所需的`token` */
refreshToken: string;
/** `accessToken`的过期时间(格式'xxxx/xx/xx xx:xx:xx' */
expires: Date;
}; };
}; };
export type RefreshTokenResult = { export type CurrentUserResult = {
success: boolean;
data: AuthUser;
};
export type CurrentPermissionResult = {
success: boolean; success: boolean;
data: { data: {
/** `token` */ permissions: string[];
accessToken: string; menus: PermissionMenu[];
/** 用于调用刷新`accessToken`的接口时所需的`token` */
refreshToken: string;
/** `accessToken`的过期时间(格式'xxxx/xx/xx xx:xx:xx' */
expires: Date;
}; };
}; };
/** 登录 */ /** 后台登录入口,兼容超级管理员账号和具备后台权限的员工手机号。 */
export const getLogin = (data?: object) => { export const loginAdmin = (data: LoginInput) => {
return http.request<UserResult>("post", "/login", { data }); return http.request<LoginResult>("post", "/api/auth/admin/login", { data });
}; };
/** 刷新`token` */ /** 获取当前 token 对应的账号基础信息。 */
export const refreshTokenApi = (data?: object) => { export const getCurrentUser = () => {
return http.request<RefreshTokenResult>("post", "/refresh-token", { data }); return http.request<CurrentUserResult>("get", "/api/auth/me");
};
/** 获取当前账号可见菜单和按钮动作权限。 */
export const getCurrentPermissions = () => {
return http.request<CurrentPermissionResult>("get", "/api/permissions/me");
}; };
+2 -1
View File
@@ -11,7 +11,8 @@ import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
import { darken, lighten, useGlobal, storageLocal } from "@pureadmin/utils"; import { darken, lighten, useGlobal, storageLocal } from "@pureadmin/utils";
export function useDataThemeChange() { export function useDataThemeChange() {
const { layoutTheme, layout } = useLayout(); const { layoutTheme, layout, initStorage } = useLayout();
initStorage();
const themeColors = ref<Array<themeColorsType>>([ const themeColors = ref<Array<themeColorsType>>([
/* 亮白色 */ /* 亮白色 */
{ color: "#ffffff", themeColor: "light" }, { color: "#ffffff", themeColor: "light" },
+2
View File
@@ -38,6 +38,8 @@ export type routeMetaType = {
icon?: string | FunctionalComponent; icon?: string | FunctionalComponent;
showLink?: boolean; showLink?: boolean;
savedPosition?: boolean; savedPosition?: boolean;
permission?: string | Array<string>;
menuKey?: string;
auths?: Array<string>; auths?: Array<string>;
}; };
+49 -1
View File
@@ -3,11 +3,15 @@ import NProgress from "@/utils/progress";
import { buildHierarchyTree } from "@/utils/tree"; import { buildHierarchyTree } from "@/utils/tree";
import remainingRouter from "./modules/remaining"; import remainingRouter from "./modules/remaining";
import { usePermissionStoreHook } from "@/store/modules/permission"; import { usePermissionStoreHook } from "@/store/modules/permission";
import { useUserStoreHook } from "@/store/modules/user";
import { getToken, removeToken, isTokenExpired } from "@/utils/auth";
import { isUrl, openLink, cloneDeep } from "@pureadmin/utils"; import { isUrl, openLink, cloneDeep } from "@pureadmin/utils";
import { import {
ascending, ascending,
getTopMenu,
getHistoryMode, getHistoryMode,
handleAliveRoute, handleAliveRoute,
hasRoutePermission,
formatTwoStageRoutes, formatTwoStageRoutes,
formatFlatteningRoutes formatFlatteningRoutes
} from "./utils"; } from "./utils";
@@ -96,8 +100,9 @@ export function resetRouter() {
} }
const { VITE_HIDE_HOME } = import.meta.env; const { VITE_HIDE_HOME } = import.meta.env;
const publicPaths = new Set(["/login"]);
router.beforeEach((to: ToRouteType, _from, next) => { router.beforeEach(async (to: ToRouteType, _from, next) => {
to.meta.loaded = loadedPaths.has(to.path); to.meta.loaded = loadedPaths.has(to.path);
if (!to.meta.loaded) { if (!to.meta.loaded) {
@@ -130,6 +135,49 @@ router.beforeEach((to: ToRouteType, _from, next) => {
return; return;
} }
const token = getToken();
if (publicPaths.has(to.path)) {
if (!isTokenExpired(token)) {
try {
await useUserStoreHook().loadAuthContext();
next({ path: getTopMenu()?.path ?? "/employees" });
} catch {
removeToken();
next();
}
return;
}
next();
return;
}
if (isTokenExpired(token)) {
removeToken();
next({
path: "/login",
query: to.fullPath ? { redirect: to.fullPath } : undefined
});
return;
}
try {
await useUserStoreHook().loadAuthContext();
} catch {
removeToken();
next({
path: "/login",
query: to.fullPath ? { redirect: to.fullPath } : undefined
});
return;
}
if (!hasRoutePermission(to.meta, useUserStoreHook().permissions ?? [])) {
next({ path: "/access-denied" });
return;
}
if (externalLink) { if (externalLink) {
openLink(to?.name as string); openLink(to?.name as string);
NProgress.done(); NProgress.done();
+6
View File
@@ -23,6 +23,8 @@ export default {
component: () => import("@/views/stores/index.vue"), component: () => import("@/views/stores/index.vue"),
meta: { meta: {
title: "门店管理", title: "门店管理",
menuKey: "stores",
permission: "store:view",
keepAlive: true keepAlive: true
} }
}, },
@@ -32,6 +34,8 @@ export default {
component: () => import("@/views/roles/index.vue"), component: () => import("@/views/roles/index.vue"),
meta: { meta: {
title: "角色管理", title: "角色管理",
menuKey: "roles",
permission: "role:view",
keepAlive: true keepAlive: true
} }
}, },
@@ -41,6 +45,8 @@ export default {
component: () => import("@/views/employees/index.vue"), component: () => import("@/views/employees/index.vue"),
meta: { meta: {
title: "员工管理", title: "员工管理",
menuKey: "employees",
permission: ["employee:view:all", "employee:view:store"],
keepAlive: true keepAlive: true
} }
} }
+22 -6
View File
@@ -81,13 +81,28 @@ function isOneOfArray(a: Array<string>, b: Array<string>) {
: true; : true;
} }
/** 从localStorage里取出当前登录用户的角色roles,过滤无权限的菜单 */ function hasRoutePermission(meta: CustomizeRouteMeta, permissions: string[]) {
const required = meta?.permission;
if (!required) return true;
if (permissions.includes("*") || permissions.includes("*:*:*")) return true;
const requiredList = Array.isArray(required) ? required : [required];
return requiredList.some(permission => permissions.includes(permission));
}
/** 从localStorage里取出当前登录用户权限码,过滤无权限菜单 */
function filterNoPermissionTree(data: RouteComponent[]) { function filterNoPermissionTree(data: RouteComponent[]) {
const currentRoles = const userInfo = storageLocal().getItem<DataInfo<number>>(userKey);
storageLocal().getItem<DataInfo<number>>(userKey)?.roles ?? []; const currentRoles = userInfo?.roles ?? [];
const newTree = cloneDeep(data).filter((v: any) => const currentPermissions = userInfo?.permissions ?? [];
isOneOfArray(v.meta?.roles, currentRoles) const newTree = cloneDeep(data).filter((v: any) => {
); return (
isOneOfArray(v.meta?.roles, currentRoles) &&
hasRoutePermission(v.meta, currentPermissions)
);
});
newTree.forEach( newTree.forEach(
(v: any) => v.children && (v.children = filterNoPermissionTree(v.children)) (v: any) => v.children && (v.children = filterNoPermissionTree(v.children))
); );
@@ -404,6 +419,7 @@ export {
getTopMenu, getTopMenu,
addPathMatch, addPathMatch,
isOneOfArray, isOneOfArray,
hasRoutePermission,
getHistoryMode, getHistoryMode,
addAsyncRoutes, addAsyncRoutes,
getParentPaths, getParentPaths,
+4 -4
View File
@@ -25,12 +25,12 @@ export const usePermissionStore = defineStore("pure-permission", {
actions: { actions: {
/** 组装整体路由生成的菜单 */ /** 组装整体路由生成的菜单 */
handleWholeMenus(routes: any[]) { handleWholeMenus(routes: any[]) {
this.wholeMenus = filterNoPermissionTree( const menus = filterNoPermissionTree(
filterTree(ascending(this.constantMenus.concat(routes))) filterTree(ascending(this.constantMenus.concat(routes)))
); );
this.flatteningRoutes = formatFlatteningRoutes(
this.constantMenus.concat(routes) as any this.wholeMenus = menus;
); this.flatteningRoutes = formatFlatteningRoutes(menus as any);
}, },
/** 监听缓存页面是否存在于标签页,不存在则删除 */ /** 监听缓存页面是否存在于标签页,不存在则删除 */
clearCache() { clearCache() {
+162 -55
View File
@@ -4,42 +4,110 @@ import {
store, store,
router, router,
resetRouter, resetRouter,
routerArrays,
storageLocal storageLocal
} from "../utils"; } from "../utils";
import { import {
type UserResult, type AuthUser,
type RefreshTokenResult, type LoginInput,
getLogin, type LoginResult,
refreshTokenApi type PermissionMenu,
getCurrentPermissions,
getCurrentUser,
loginAdmin
} from "@/api/user"; } from "@/api/user";
import { usePermissionStoreHook } from "./permission";
import { useMultiTagsStoreHook } from "./multiTags"; import { useMultiTagsStoreHook } from "./multiTags";
import { type DataInfo, setToken, removeToken, userKey } from "@/utils/auth"; import {
type DataInfo,
setToken,
getToken,
removeToken,
userKey
} from "@/utils/auth";
const savedUser = storageLocal().getItem<DataInfo<number>>(userKey);
function resolveExpiresAt(expiresIn: string) {
const matched = /^(\d+)([smhd])$/.exec(expiresIn);
const amount = matched ? Number(matched[1]) : 2;
const unit = matched?.[2] ?? "h";
const unitMap = {
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000
};
return Date.now() + amount * unitMap[unit];
}
function toUserContext(
user: AuthUser,
token: Pick<DataInfo<number>, "accessToken" | "expires">,
permissionMenus: PermissionMenu[],
permissions: string[]
): DataInfo<number> {
return {
...token,
username: user.username,
nickname: user.displayName,
accountType: user.accountType,
storeId: user.storeId,
storeName: user.storeName,
roles: user.roles.map(role => role.code),
permissions,
permissionMenus,
canManage: user.canManage
};
}
function collectMenuTags(menus: any[]) {
const result = [];
menus.forEach(item => {
if (item?.children?.length) {
result.push(...collectMenuTags(item.children));
return;
}
if (item?.meta?.showLink === false || item?.meta?.hiddenTag) return;
result.push(item);
});
return result;
}
export const useUserStore = defineStore("pure-user", { export const useUserStore = defineStore("pure-user", {
state: (): userType => ({ state: (): userType => ({
// 头像 avatar: "",
avatar: storageLocal().getItem<DataInfo<number>>(userKey)?.avatar ?? "", username: savedUser?.username ?? "",
// 用户名 nickname: savedUser?.nickname ?? "",
username: accountType: savedUser?.accountType ?? "",
storageLocal().getItem<DataInfo<number>>(userKey)?.username ?? "admin", storeId: savedUser?.storeId,
// 昵称 storeName: savedUser?.storeName,
nickname: roles: savedUser?.roles ?? [],
storageLocal().getItem<DataInfo<number>>(userKey)?.nickname ?? permissions: savedUser?.permissions ?? [],
"系统管理员", permissionMenus: savedUser?.permissionMenus ?? [],
// 页面级别权限 canManage: savedUser?.canManage ?? false,
roles: storageLocal().getItem<DataInfo<number>>(userKey)?.roles ?? [ authLoaded: false,
"admin"
],
// 按钮级别权限
permissions:
storageLocal().getItem<DataInfo<number>>(userKey)?.permissions ?? [],
// 是否勾选了登录页的免登录 // 是否勾选了登录页的免登录
isRemembered: false, isRemembered: false,
// 登录页的免登录存储几天,默认7天 // 登录页的免登录存储几天,默认7天
loginDay: 7 loginDay: 7
}), }),
actions: { actions: {
/** 写入后端认证上下文,并同步 pure-admin 仍在读取的用户字段。 */
SET_USER_CONTEXT(data: Partial<DataInfo<number>>) {
this.username = data.username ?? "";
this.nickname = data.nickname ?? "";
this.accountType = data.accountType ?? "";
this.storeId = data.storeId;
this.storeName = data.storeName;
this.roles = data.roles ?? [];
this.permissions = data.permissions ?? [];
this.permissionMenus = data.permissionMenus ?? [];
this.canManage = data.canManage ?? false;
},
/** 存储头像 */ /** 存储头像 */
SET_AVATAR(avatar: string) { SET_AVATAR(avatar: string) {
this.avatar = avatar; this.avatar = avatar;
@@ -68,44 +136,83 @@ export const useUserStore = defineStore("pure-user", {
SET_LOGINDAY(value: number) { SET_LOGINDAY(value: number) {
this.loginDay = Number(value); this.loginDay = Number(value);
}, },
/** 登入 */ syncPermissionMenus() {
async loginByUsername(data) { const permissionStore = usePermissionStoreHook();
return new Promise<UserResult>((resolve, reject) => {
getLogin(data) permissionStore.handleWholeMenus([]);
.then(data => { if (!useMultiTagsStoreHook().getMultiTagsCache) {
if (data?.success) setToken(data.data); useMultiTagsStoreHook().handleTags(
resolve(data); "equal",
}) collectMenuTags(permissionStore.wholeMenus)
.catch(error => { );
reject(error); }
});
});
}, },
/** 前端登出(不调用接口) */ /** 登入:按 API.md 推荐流程完成 login -> me -> permissions/me。 */
async loginByUsername(data: LoginInput) {
const loginResult: LoginResult = await loginAdmin(data);
if (loginResult?.success) {
const context = toUserContext(
loginResult.data.user,
{
accessToken: loginResult.data.token,
expires: resolveExpiresAt(loginResult.data.expiresIn)
},
[],
loginResult.data.user.permissions
);
setToken(context);
this.SET_USER_CONTEXT(context);
await this.loadAuthContext(true);
}
return loginResult;
},
/** 页面刷新或重新进入受保护页面时,向后端校验 token 并恢复权限上下文。 */
async loadAuthContext(force = false) {
const token = getToken();
if (!token?.accessToken) {
throw new Error("请先登录");
}
if (this.authLoaded && !force) {
this.syncPermissionMenus();
return;
}
const [userResult, permissionResult] = await Promise.all([
getCurrentUser(),
getCurrentPermissions()
]);
const context = toUserContext(
userResult.data,
token,
permissionResult.data.menus,
permissionResult.data.permissions
);
setToken(context);
this.SET_USER_CONTEXT(context);
this.authLoaded = true;
this.syncPermissionMenus();
},
/** 前端登出(后端当前无登出接口) */
logOut() { logOut() {
this.username = "admin"; this.username = "";
this.nickname = "系统管理员"; this.nickname = "";
this.roles = ["admin"]; this.accountType = "";
this.storeId = undefined;
this.storeName = undefined;
this.roles = [];
this.permissions = []; this.permissions = [];
this.permissionMenus = [];
this.canManage = false;
this.authLoaded = false;
removeToken(); removeToken();
useMultiTagsStoreHook().handleTags("equal", [...routerArrays]); useMultiTagsStoreHook().handleTags("equal", []);
resetRouter(); resetRouter();
router.push("/employees"); router.push("/login");
},
/** 刷新`token` */
async handRefreshToken(data) {
return new Promise<RefreshTokenResult>((resolve, reject) => {
refreshTokenApi(data)
.then(data => {
if (data) {
setToken(data.data);
resolve(data);
}
})
.catch(error => {
reject(error);
});
});
} }
} }
}); });
+12
View File
@@ -40,8 +40,20 @@ export type userType = {
avatar?: string; avatar?: string;
username?: string; username?: string;
nickname?: string; nickname?: string;
accountType?: "SUPER_ADMIN" | "EMPLOYEE" | "";
storeId?: number;
storeName?: string;
roles?: Array<string>; roles?: Array<string>;
permissions?: Array<string>; permissions?: Array<string>;
permissionMenus?: Array<{
key: string;
title: string;
icon?: string;
permission: string;
actions: string[];
}>;
canManage?: boolean;
authLoaded?: boolean;
isRemembered?: boolean; isRemembered?: boolean;
loginDay?: number; loginDay?: number;
}; };
+63 -93
View File
@@ -1,24 +1,28 @@
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { useUserStoreHook } from "@/store/modules/user";
import { storageLocal, isString, isIncludeAllChildren } from "@pureadmin/utils"; import { storageLocal, isString, isIncludeAllChildren } from "@pureadmin/utils";
export interface DataInfo<T> { export interface PermissionMenuInfo {
/** token */ key: string;
title: string;
icon?: string;
permission: string;
actions: string[];
}
export interface DataInfo<T = number> {
/** 后端 JWT token,接口请求时会转换成 Bearer token。 */
accessToken: string; accessToken: string;
/** `accessToken`的过期时间(时间戳) */ /** token 本地过期时间戳。后端当前返回 expiresIn,前端换算成时间戳存储。 */
expires: T; expires: T;
/** 用于调用刷新accessToken的接口时所需的token */
refreshToken: string;
/** 头像 */
avatar?: string;
/** 用户名 */
username?: string; username?: string;
/** 昵称 */
nickname?: string; nickname?: string;
/** 当前登录用户的角色 */ accountType?: "SUPER_ADMIN" | "EMPLOYEE" | "";
storeId?: number;
storeName?: string;
roles?: Array<string>; roles?: Array<string>;
/** 当前登录用户的按钮级别权限 */
permissions?: Array<string>; permissions?: Array<string>;
permissionMenus?: PermissionMenuInfo[];
canManage?: boolean;
} }
export const userKey = "user-info"; export const userKey = "user-info";
@@ -31,88 +35,44 @@ export const TokenKey = "authorized-token";
* */ * */
export const multipleTabsKey = "multiple-tabs"; export const multipleTabsKey = "multiple-tabs";
export function getUserInfo(): DataInfo<number> | null {
return storageLocal().getItem<DataInfo<number>>(userKey) ?? null;
}
/** 获取`token` */ /** 获取`token` */
export function getToken(): DataInfo<number> { export function getToken(): DataInfo<number> | null {
// 此处与`TokenKey`相同,此写法解决初始化时`Cookies`中不存在`TokenKey`报错 const cookieValue = Cookies.get(TokenKey);
return Cookies.get(TokenKey)
? JSON.parse(Cookies.get(TokenKey)) if (cookieValue) {
: storageLocal().getItem(userKey); return JSON.parse(cookieValue);
}
return getUserInfo();
}
export function isTokenExpired(data?: DataInfo<number> | null) {
return !data?.accessToken || Number(data.expires) - Date.now() <= 0;
} }
/** /**
* @description 设置`token`以及一些必要信息并采用无感刷新`token`方案 * 保存 access-manage 签发的 JWT 和账号权限上下文。
* 无感刷新:后端返回`accessToken`(访问接口使用的`token`)、`refreshToken`(用于调用刷新`accessToken`的接口时所需的`token``refreshToken`的过期时间(比如30天)应大于`accessToken`的过期时间(比如2小时))、`expires``accessToken`的过期时间) *
* 将`accessToken`、`expires`、`refreshToken`这三条信息放在key值为authorized-token的cookie里(过期自动销毁) * 后端当前没有 refresh-token 接口,因此这里不再保留模板的无感刷新逻辑;
* 将`avatar`、`username`、`nickname`、`roles`、`permissions`、`refreshToken`、`expires`这七条信息放在key值为`user-info`的localStorage里(利用`multipleTabsKey`当浏览器完全关闭后自动销毁) * token 过期后统一清理本地登录态并跳回登录页。
*/ */
export function setToken(data: DataInfo<Date>) { export function setToken(data: DataInfo<number>) {
let expires = 0; const { accessToken, expires } = data;
const { accessToken, refreshToken } = data; const cookieString = JSON.stringify({ accessToken, expires });
const { isRemembered, loginDay } = useUserStoreHook(); const cookieOptions =
expires = new Date(data.expires).getTime(); // 如果后端直接设置时间戳,将此处代码改为expires = data.expires,然后把上面的DataInfo<Date>改成DataInfo<number>即可 expires > 0
const cookieString = JSON.stringify({ accessToken, expires, refreshToken });
expires > 0
? Cookies.set(TokenKey, cookieString, {
expires: (expires - Date.now()) / 86400000
})
: Cookies.set(TokenKey, cookieString);
Cookies.set(
multipleTabsKey,
"true",
isRemembered
? { ? {
expires: loginDay expires: (expires - Date.now()) / 86400000
} }
: {} : undefined;
);
function setUserKey({ avatar, username, nickname, roles, permissions }) { Cookies.set(TokenKey, cookieString, cookieOptions);
useUserStoreHook().SET_AVATAR(avatar); Cookies.set(multipleTabsKey, "true");
useUserStoreHook().SET_USERNAME(username); storageLocal().setItem(userKey, data);
useUserStoreHook().SET_NICKNAME(nickname);
useUserStoreHook().SET_ROLES(roles);
useUserStoreHook().SET_PERMS(permissions);
storageLocal().setItem(userKey, {
refreshToken,
expires,
avatar,
username,
nickname,
roles,
permissions
});
}
if (data.username && data.roles) {
const { username, roles } = data;
setUserKey({
avatar: data?.avatar ?? "",
username,
nickname: data?.nickname ?? "",
roles,
permissions: data?.permissions ?? []
});
} else {
const avatar =
storageLocal().getItem<DataInfo<number>>(userKey)?.avatar ?? "";
const username =
storageLocal().getItem<DataInfo<number>>(userKey)?.username ?? "";
const nickname =
storageLocal().getItem<DataInfo<number>>(userKey)?.nickname ?? "";
const roles =
storageLocal().getItem<DataInfo<number>>(userKey)?.roles ?? [];
const permissions =
storageLocal().getItem<DataInfo<number>>(userKey)?.permissions ?? [];
setUserKey({
avatar,
username,
nickname,
roles,
permissions
});
}
} }
/** 删除`token`以及key值为`user-info`的localStorage信息 */ /** 删除`token`以及key值为`user-info`的localStorage信息 */
@@ -127,15 +87,25 @@ export const formatToken = (token: string): string => {
return "Bearer " + token; return "Bearer " + token;
}; };
/** 是否有按钮级别的权限(根据登录接口返回的`permissions`字段进行判断)*/ /** 是否有后端权限码。后端使用 `*` 表示超级管理员。 */
export const hasPerms = (value: string | Array<string>): boolean => { export const hasPerms = (value: string | Array<string>): boolean => {
if (!value) return false; if (!value) return false;
const allPerms = "*:*:*"; const permissions = getUserInfo()?.permissions ?? [];
const { permissions } = useUserStoreHook();
if (!permissions) return false; if (!permissions?.length) return false;
if (permissions.length === 1 && permissions[0] === allPerms) return true; if (permissions.includes("*") || permissions.includes("*:*:*")) return true;
const isAuths = isString(value)
return isString(value)
? permissions.includes(value) ? permissions.includes(value)
: isIncludeAllChildren(value, permissions); : isIncludeAllChildren(value, permissions);
return isAuths ? true : false; };
/** 是否拥有后端菜单动作权限,用于比权限码更细的按钮显隐。 */
export const hasMenuAction = (menuKey: string, action: string): boolean => {
if (hasPerms("*")) return true;
const menus = getUserInfo()?.permissionMenus ?? [];
const menu = menus.find(item => item.key === menuKey);
return menu?.actions?.includes(action) ?? false;
}; };
+38 -52
View File
@@ -10,8 +10,12 @@ import type {
PureHttpRequestConfig PureHttpRequestConfig
} from "./types.d"; } from "./types.d";
import { stringify } from "qs"; import { stringify } from "qs";
import { getToken, formatToken } from "@/utils/auth"; import {
import { useUserStoreHook } from "@/store/modules/user"; getToken,
removeToken,
formatToken,
isTokenExpired
} from "@/utils/auth";
// 相关配置请参考:www.axios-js.com/zh-cn/docs/#axios-request-config-1 // 相关配置请参考:www.axios-js.com/zh-cn/docs/#axios-request-config-1
const defaultConfig: AxiosRequestConfig = { const defaultConfig: AxiosRequestConfig = {
@@ -34,26 +38,28 @@ class PureHttp {
this.httpInterceptorsResponse(); this.httpInterceptorsResponse();
} }
/** `token`过期后,暂存待执行的请求 */
private static requests = [];
/** 防止重复刷新`token` */
private static isRefreshing = false;
/** 初始化配置对象 */ /** 初始化配置对象 */
private static initConfig: PureHttpRequestConfig = {}; private static initConfig: PureHttpRequestConfig = {};
/** 保存当前`Axios`实例对象 */ /** 保存当前`Axios`实例对象 */
private static axiosInstance: AxiosInstance = Axios.create(defaultConfig); private static axiosInstance: AxiosInstance = Axios.create(defaultConfig);
/** 重连原始请求 */ /** access-manage 当前只有登录接口不需要 token。 */
private static retryOriginalRequest(config: PureHttpRequestConfig) { private static isPublicRequest(url = "") {
return new Promise(resolve => { return [
PureHttp.requests.push((token: string) => { "/api/auth/login",
config.headers["Authorization"] = formatToken(token); "/api/auth/admin/login",
resolve(config); "/api/auth/employee/login"
}); ].some(item => url.endsWith(item));
}); }
private static redirectToLogin() {
removeToken();
if (location.hash.startsWith("#/login")) return;
const redirect = location.hash.replace(/^#/, "") || "/";
location.hash = `#/login?redirect=${encodeURIComponent(redirect)}`;
} }
/** 请求拦截 */ /** 请求拦截 */
@@ -69,42 +75,19 @@ class PureHttp {
PureHttp.initConfig.beforeRequestCallback(config); PureHttp.initConfig.beforeRequestCallback(config);
return config; return config;
} }
/** 请求白名单,放置一些不需要`token`的接口(通过设置请求白名单,防止`token`过期后再请求造成的死循环问题) */ if (PureHttp.isPublicRequest(config.url)) {
const whiteList = ["/refresh-token", "/login"]; return config;
return whiteList.some(url => config.url.endsWith(url)) }
? config
: new Promise(resolve => { const data = getToken();
const data = getToken();
if (data) { if (isTokenExpired(data)) {
const now = new Date().getTime(); PureHttp.redirectToLogin();
const expired = parseInt(data.expires) - now <= 0; return Promise.reject(new Error("登录已过期,请重新登录"));
if (expired) { }
if (!PureHttp.isRefreshing) {
PureHttp.isRefreshing = true; config.headers["Authorization"] = formatToken(data.accessToken);
// token过期刷新 return config;
useUserStoreHook()
.handRefreshToken({ refreshToken: data.refreshToken })
.then(res => {
const token = res.data.accessToken;
config.headers["Authorization"] = formatToken(token);
PureHttp.requests.forEach(cb => cb(token));
PureHttp.requests = [];
})
.finally(() => {
PureHttp.isRefreshing = false;
});
}
resolve(PureHttp.retryOriginalRequest(config));
} else {
config.headers["Authorization"] = formatToken(
data.accessToken
);
resolve(config);
}
} else {
resolve(config);
}
});
}, },
error => { error => {
return Promise.reject(error); return Promise.reject(error);
@@ -132,6 +115,9 @@ class PureHttp {
(error: PureHttpError) => { (error: PureHttpError) => {
const $error = error; const $error = error;
$error.isCancelRequest = Axios.isCancel($error); $error.isCancelRequest = Axios.isCancel($error);
if ($error.response?.status === 401) {
PureHttp.redirectToLogin();
}
// 所有的响应异常 区分来源为取消请求/非取消请求 // 所有的响应异常 区分来源为取消请求/非取消请求
return Promise.reject($error); return Promise.reject($error);
} }
+2 -1
View File
@@ -12,7 +12,7 @@ import { subBefore, getQueryMap } from "@pureadmin/utils";
*/ */
(function () { (function () {
// 获取 url 中的参数 // 获取 url 中的参数
const params = getQueryMap(location.href) as DataInfo<Date>; const params = getQueryMap(location.href) as unknown as DataInfo<number>;
const must = ["username", "roles", "accessToken"]; const must = ["username", "roles", "accessToken"];
const mustLength = must.length; const mustLength = must.length;
if (Object.keys(params).length !== mustLength) return; if (Object.keys(params).length !== mustLength) return;
@@ -37,6 +37,7 @@ import { subBefore, getQueryMap } from "@pureadmin/utils";
removeToken(); removeToken();
// 保存新信息到本地 // 保存新信息到本地
params.expires = Number(params.expires) || Date.now() + 2 * 60 * 60 * 1000;
setToken(params); setToken(params);
// 删除不需要显示在 url 的参数 // 删除不需要显示在 url 的参数
+39 -5
View File
@@ -21,6 +21,7 @@ import {
type RoleOption, type RoleOption,
type StoreOption type StoreOption
} from "@/api/access"; } from "@/api/access";
import { hasPerms } from "@/utils/auth";
import Plus from "~icons/ep/plus"; import Plus from "~icons/ep/plus";
import Search from "~icons/ep/search"; import Search from "~icons/ep/search";
@@ -41,6 +42,13 @@ type EmployeeFormState = EmployeePayload & {
/** 员工手机号按中国大陆手机号做前端第一层校验,最终唯一性仍由后端保证。 */ /** 员工手机号按中国大陆手机号做前端第一层校验,最终唯一性仍由后端保证。 */
const phonePattern = /^1[3-9]\d{9}$/; const phonePattern = /^1[3-9]\d{9}$/;
const bindableRoleCodes = new Set([
"store_manager",
"cashier",
"kitchen",
"part_time",
"admin"
]);
const tableLoading = ref(false); const tableLoading = ref(false);
const catalogLoading = ref(false); const catalogLoading = ref(false);
const submitLoading = ref(false); const submitLoading = ref(false);
@@ -99,6 +107,8 @@ const inactiveCount = computed(
() => employees.value.filter(item => item.status === "INACTIVE").length () => employees.value.filter(item => item.status === "INACTIVE").length
); );
const dialogTitle = computed(() => (form.id ? "编辑员工" : "新增员工")); const dialogTitle = computed(() => (form.id ? "编辑员工" : "新增员工"));
const canManageEmployees = computed(() => hasPerms("employee:manage"));
const canViewAllEmployees = computed(() => hasPerms("employee:view:all"));
function getErrorMessage(error: unknown, fallback: string) { function getErrorMessage(error: unknown, fallback: string) {
const message = ( const message = (
@@ -147,14 +157,22 @@ function buildPayload(): EmployeePayload {
/** 门店和角色是员工表单的基础字典,打开弹窗前必须先加载。 */ /** 门店和角色是员工表单的基础字典,打开弹窗前必须先加载。 */
async function fetchCatalog() { async function fetchCatalog() {
const shouldLoadStores =
canViewAllEmployees.value || canManageEmployees.value;
const shouldLoadRoles = canManageEmployees.value;
if (!shouldLoadStores && !shouldLoadRoles) return;
catalogLoading.value = true; catalogLoading.value = true;
try { try {
const [storeResult, roleResult] = await Promise.all([ const [storeResult, roleResult] = await Promise.all([
listStores(), shouldLoadStores ? listStores() : Promise.resolve({ data: [] }),
listRoles() shouldLoadRoles ? listRoles() : Promise.resolve({ data: [] })
]); ]);
stores.value = storeResult.data; stores.value = storeResult.data;
roles.value = roleResult.data; roles.value = roleResult.data.filter(role =>
bindableRoleCodes.has(role.code)
);
} catch (error) { } catch (error) {
ElMessage.error(getErrorMessage(error, "加载门店和角色选项失败")); ElMessage.error(getErrorMessage(error, "加载门店和角色选项失败"));
} finally { } finally {
@@ -209,12 +227,14 @@ function handleSizeChange(pageSize: number) {
} }
function openCreateDialog() { function openCreateDialog() {
if (!canManageEmployees.value) return;
resetFormState(); resetFormState();
dialogVisible.value = true; dialogVisible.value = true;
} }
/** 编辑前重新拉详情,确保角色绑定不是来自列表摘要的过期数据。 */ /** 编辑前重新拉详情,确保角色绑定不是来自列表摘要的过期数据。 */
async function openEditDialog(row: Employee) { async function openEditDialog(row: Employee) {
if (!canManageEmployees.value) return;
try { try {
const result = await getEmployee(row.id); const result = await getEmployee(row.id);
const employee = result.data; const employee = result.data;
@@ -235,6 +255,7 @@ async function openEditDialog(row: Employee) {
} }
async function submitForm() { async function submitForm() {
if (!canManageEmployees.value) return;
await formRef.value?.validate(); await formRef.value?.validate();
submitLoading.value = true; submitLoading.value = true;
@@ -259,6 +280,7 @@ async function submitForm() {
} }
async function toggleStatus(row: Employee) { async function toggleStatus(row: Employee) {
if (!canManageEmployees.value) return;
const nextStatus: EmployeeStatus = const nextStatus: EmployeeStatus =
row.status === "ACTIVE" ? "INACTIVE" : "ACTIVE"; row.status === "ACTIVE" ? "INACTIVE" : "ACTIVE";
const action = nextStatus === "ACTIVE" ? "启用" : "停用"; const action = nextStatus === "ACTIVE" ? "启用" : "停用";
@@ -284,6 +306,7 @@ async function toggleStatus(row: Employee) {
} }
async function removeEmployee(row: Employee) { async function removeEmployee(row: Employee) {
if (!canManageEmployees.value) return;
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`删除后员工「${row.name}」会被软删除并停用,确认继续?`, `删除后员工「${row.name}」会被软删除并停用,确认继续?`,
@@ -321,7 +344,12 @@ onMounted(async () => {
<p class="eyebrow">门店员工权限管理</p> <p class="eyebrow">门店员工权限管理</p>
<h1>员工管理</h1> <h1>员工管理</h1>
</div> </div>
<el-button type="primary" :icon="Plus" @click="openCreateDialog"> <el-button
v-if="canManageEmployees"
type="primary"
:icon="Plus"
@click="openCreateDialog"
>
新增员工 新增员工
</el-button> </el-button>
</div> </div>
@@ -347,6 +375,7 @@ onMounted(async () => {
<div class="toolbar"> <div class="toolbar">
<el-select <el-select
v-if="canViewAllEmployees"
v-model="query.storeId" v-model="query.storeId"
clearable clearable
filterable filterable
@@ -440,7 +469,12 @@ onMounted(async () => {
{{ formatTime(row.updatedAt) }} {{ formatTime(row.updatedAt) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="260" fixed="right"> <el-table-column
v-if="canManageEmployees"
label="操作"
width="260"
fixed="right"
>
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
link link
+39 -28
View File
@@ -10,7 +10,7 @@ import { useEventListener } from "@vueuse/core";
import type { FormInstance } from "element-plus"; import type { FormInstance } from "element-plus";
import { useLayout } from "@/layout/hooks/useLayout"; import { useLayout } from "@/layout/hooks/useLayout";
import { useUserStoreHook } from "@/store/modules/user"; import { useUserStoreHook } from "@/store/modules/user";
import { initRouter, getTopMenu } from "@/router/utils"; import { getTopMenu } from "@/router/utils";
import { bg, avatar, illustration } from "./utils/static"; import { bg, avatar, illustration } from "./utils/static";
import { useRenderIcon } from "@/components/ReIcon/src/hooks"; import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange"; import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
@@ -38,38 +38,49 @@ const { title } = useNav();
const ruleForm = reactive({ const ruleForm = reactive({
username: "admin", username: "admin",
password: "admin123" password: "Admin@123456"
}); });
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;
}
const onLogin = async (formEl: FormInstance | undefined) => { const onLogin = async (formEl: FormInstance | undefined) => {
if (!formEl) return; if (!formEl) return;
await formEl.validate(valid => {
if (valid) { try {
loading.value = true; await formEl.validate();
useUserStoreHook() } catch {
.loginByUsername({ return;
username: ruleForm.username, }
password: ruleForm.password
}) loading.value = true;
.then(res => { try {
if (res.success) { const res = await useUserStoreHook().loginByUsername({
// 获取后端路由 username: ruleForm.username,
return initRouter().then(() => { password: ruleForm.password
disabled.value = true; });
router
.push(getTopMenu(true).path) if (res.success) {
.then(() => { disabled.value = true;
message("登录成功", { type: "success" }); const redirect = router.currentRoute.value.query.redirect as
}) | string
.finally(() => (disabled.value = false)); | undefined;
}); await router.push(redirect || getTopMenu(true)?.path || "/access-denied");
} else { message("登录成功", { type: "success" });
message("登录失败", { type: "error" });
}
})
.finally(() => (loading.value = false));
} }
}); } catch (error) {
message(getErrorMessage(error, "登录失败"), { type: "error" });
} finally {
disabled.value = false;
loading.value = false;
}
}; };
const immediateDebounce: any = debounce( const immediateDebounce: any = debounce(
+3 -6
View File
@@ -1,9 +1,8 @@
import { reactive } from "vue"; import { reactive } from "vue";
import type { FormRules } from "element-plus"; import type { FormRules } from "element-plus";
/** 密码正则(密码格式应为8-18位数字、字母、符号的任意两种组合) */ /** 后端登录密码约束:8-128 个字符。 */
export const REGEXP_PWD = export const REGEXP_PWD = /^.{8,128}$/;
/^(?![0-9]+$)(?![a-z]+$)(?![A-Z]+$)(?!([^(0-9a-zA-Z)]|[()])+$)(?!^.*[\u4E00-\u9FA5].*$)([^(0-9a-zA-Z)]|[()]|[a-z]|[A-Z]|[0-9]){8,18}$/;
/** 登录校验 */ /** 登录校验 */
const loginRules = reactive<FormRules>({ const loginRules = reactive<FormRules>({
@@ -13,9 +12,7 @@ const loginRules = reactive<FormRules>({
if (value === "") { if (value === "") {
callback(new Error("请输入密码")); callback(new Error("请输入密码"));
} else if (!REGEXP_PWD.test(value)) { } else if (!REGEXP_PWD.test(value)) {
callback( callback(new Error("密码长度应为 8-128 个字符"));
new Error("密码格式应为8-18位数字、字母、符号的任意两种组合")
);
} else { } else {
callback(); callback();
} }
+26 -5
View File
@@ -14,6 +14,7 @@ import {
type Role, type Role,
type RolePayload type RolePayload
} from "@/api/access"; } from "@/api/access";
import { hasPerms } from "@/utils/auth";
import Plus from "~icons/ep/plus"; import Plus from "~icons/ep/plus";
import Search from "~icons/ep/search"; import Search from "~icons/ep/search";
@@ -74,6 +75,7 @@ const systemRoleCount = computed(
() => roles.value.filter(item => item.code === "admin").length () => roles.value.filter(item => item.code === "admin").length
); );
const dialogTitle = computed(() => (form.id ? "编辑角色" : "新增角色")); const dialogTitle = computed(() => (form.id ? "编辑角色" : "新增角色"));
const canManageRoles = computed(() => hasPerms("role:manage"));
function applyRoleQuery(items: Role[]) { function applyRoleQuery(items: Role[]) {
const keyword = query.keyword.trim().toLowerCase(); const keyword = query.keyword.trim().toLowerCase();
@@ -136,9 +138,7 @@ function buildPayload(): RolePayload {
async function fetchRoles() { async function fetchRoles() {
tableLoading.value = true; tableLoading.value = true;
try { try {
const result = await listRoles({ const result = await listRoles();
keyword: query.keyword.trim() || undefined
});
roles.value = applyRoleQuery(result.data); roles.value = applyRoleQuery(result.data);
} catch (error) { } catch (error) {
ElMessage.error(getErrorMessage(error, "加载角色列表失败")); ElMessage.error(getErrorMessage(error, "加载角色列表失败"));
@@ -158,11 +158,13 @@ function handleSearch() {
} }
function openCreateDialog() { function openCreateDialog() {
if (!canManageRoles.value) return;
resetFormState(); resetFormState();
dialogVisible.value = true; dialogVisible.value = true;
} }
function openEditDialog(row: Role) { function openEditDialog(row: Role) {
if (!canManageRoles.value || row.isSystem) return;
Object.assign(form, { Object.assign(form, {
id: row.id, id: row.id,
code: row.code, code: row.code,
@@ -174,6 +176,7 @@ function openEditDialog(row: Role) {
} }
async function submitForm() { async function submitForm() {
if (!canManageRoles.value) return;
await formRef.value?.validate(); await formRef.value?.validate();
submitLoading.value = true; submitLoading.value = true;
@@ -198,6 +201,7 @@ async function submitForm() {
} }
async function removeRole(row: Role) { async function removeRole(row: Role) {
if (!canManageRoles.value || row.isSystem) return;
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`删除后角色「${row.name}」无法再绑定员工,确认继续?`, `删除后角色「${row.name}」无法再绑定员工,确认继续?`,
@@ -228,7 +232,12 @@ onMounted(fetchRoles);
<p class="eyebrow">权限角色基础数据</p> <p class="eyebrow">权限角色基础数据</p>
<h1>角色管理</h1> <h1>角色管理</h1>
</div> </div>
<el-button type="primary" :icon="Plus" @click="openCreateDialog"> <el-button
v-if="canManageRoles"
type="primary"
:icon="Plus"
@click="openCreateDialog"
>
新增角色 新增角色
</el-button> </el-button>
</div> </div>
@@ -295,9 +304,15 @@ onMounted(fetchRoles);
{{ formatTime(row.updatedAt) }} {{ formatTime(row.updatedAt) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="170" fixed="right"> <el-table-column
v-if="canManageRoles"
label="操作"
width="170"
fixed="right"
>
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
v-if="!row.isSystem"
link link
type="primary" type="primary"
:icon="EditPen" :icon="EditPen"
@@ -306,6 +321,7 @@ onMounted(fetchRoles);
编辑 编辑
</el-button> </el-button>
<el-button <el-button
v-if="!row.isSystem"
link link
type="danger" type="danger"
:icon="Delete" :icon="Delete"
@@ -313,6 +329,7 @@ onMounted(fetchRoles);
> >
删除 删除
</el-button> </el-button>
<span v-if="row.isSystem" class="muted">系统内置</span>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -473,6 +490,10 @@ onMounted(fetchRoles);
} }
} }
.muted {
color: #94a3b8;
}
.management-form { .management-form {
padding-top: 4px; padding-top: 4px;
} }
+20 -5
View File
@@ -15,6 +15,7 @@ import {
type StorePayload, type StorePayload,
type StoreStatus type StoreStatus
} from "@/api/access"; } from "@/api/access";
import { hasPerms } from "@/utils/auth";
import Plus from "~icons/ep/plus"; import Plus from "~icons/ep/plus";
import Search from "~icons/ep/search"; import Search from "~icons/ep/search";
@@ -70,6 +71,7 @@ const inactiveCount = computed(
() => stores.value.filter(item => item.status === "INACTIVE").length () => stores.value.filter(item => item.status === "INACTIVE").length
); );
const dialogTitle = computed(() => (form.id ? "编辑门店" : "新增门店")); const dialogTitle = computed(() => (form.id ? "编辑门店" : "新增门店"));
const canManageStores = computed(() => hasPerms("store:manage"));
function applyStoreQuery(items: Store[]) { function applyStoreQuery(items: Store[]) {
const keyword = query.keyword.trim().toLowerCase(); const keyword = query.keyword.trim().toLowerCase();
@@ -136,9 +138,7 @@ async function fetchStores() {
tableLoading.value = true; tableLoading.value = true;
try { try {
const result = await listStores({ const result = await listStores({
includeInactive: true, includeInactive: true
status: query.status,
keyword: query.keyword.trim() || undefined
}); });
stores.value = applyStoreQuery(result.data); stores.value = applyStoreQuery(result.data);
} catch (error) { } catch (error) {
@@ -160,11 +160,13 @@ function handleSearch() {
} }
function openCreateDialog() { function openCreateDialog() {
if (!canManageStores.value) return;
resetFormState(); resetFormState();
dialogVisible.value = true; dialogVisible.value = true;
} }
function openEditDialog(row: Store) { function openEditDialog(row: Store) {
if (!canManageStores.value) return;
Object.assign(form, { Object.assign(form, {
id: row.id, id: row.id,
name: row.name, name: row.name,
@@ -177,6 +179,7 @@ function openEditDialog(row: Store) {
} }
async function submitForm() { async function submitForm() {
if (!canManageStores.value) return;
await formRef.value?.validate(); await formRef.value?.validate();
submitLoading.value = true; submitLoading.value = true;
@@ -201,6 +204,7 @@ async function submitForm() {
} }
async function toggleStatus(row: Store) { async function toggleStatus(row: Store) {
if (!canManageStores.value) return;
const nextStatus: StoreStatus = const nextStatus: StoreStatus =
row.status === "ACTIVE" ? "INACTIVE" : "ACTIVE"; row.status === "ACTIVE" ? "INACTIVE" : "ACTIVE";
const action = nextStatus === "ACTIVE" ? "启用" : "停用"; const action = nextStatus === "ACTIVE" ? "启用" : "停用";
@@ -226,6 +230,7 @@ async function toggleStatus(row: Store) {
} }
async function removeStore(row: Store) { async function removeStore(row: Store) {
if (!canManageStores.value) return;
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`删除后门店「${row.name}」不会再出现在员工新增下拉里,确认继续?`, `删除后门店「${row.name}」不会再出现在员工新增下拉里,确认继续?`,
@@ -256,7 +261,12 @@ onMounted(fetchStores);
<p class="eyebrow">门店基础数据</p> <p class="eyebrow">门店基础数据</p>
<h1>门店管理</h1> <h1>门店管理</h1>
</div> </div>
<el-button type="primary" :icon="Plus" @click="openCreateDialog"> <el-button
v-if="canManageStores"
type="primary"
:icon="Plus"
@click="openCreateDialog"
>
新增门店 新增门店
</el-button> </el-button>
</div> </div>
@@ -345,7 +355,12 @@ onMounted(fetchStores);
{{ formatTime(row.updatedAt) }} {{ formatTime(row.updatedAt) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="260" fixed="right"> <el-table-column
v-if="canManageStores"
label="操作"
width="260"
fixed="right"
>
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
link link
+4
View File
@@ -24,6 +24,10 @@ declare global {
showParent?: boolean; showParent?: boolean;
/** 页面级别权限设置 `可选` */ /** 页面级别权限设置 `可选` */
roles?: Array<string>; roles?: Array<string>;
/** 后端权限码,支持单个权限或任意一个权限命中即可访问 */
permission?: string | Array<string>;
/** 后端权限菜单 key,用于和 `/api/permissions/me` 返回菜单对应 */
menuKey?: string;
/** 按钮级别权限设置 `可选` */ /** 按钮级别权限设置 `可选` */
auths?: Array<string>; auths?: Array<string>;
/** 路由组件缓存(开启 `true`、关闭 `false``可选` */ /** 路由组件缓存(开启 `true`、关闭 `false``可选` */