From 5003628017250cab192fd59e14b462879e852be7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Tue, 26 May 2026 14:45:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E9=89=B4=E6=9D=83=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en-US.md | 4 +- README.md | 18 +- src/api/access.ts | 13 +- src/api/user.ts | 87 ++++++---- src/layout/hooks/useDataThemeChange.ts | 3 +- src/layout/types.ts | 2 + src/router/index.ts | 50 +++++- src/router/modules/employees.ts | 6 + src/router/utils.ts | 28 +++- src/store/modules/permission.ts | 8 +- src/store/modules/user.ts | 217 ++++++++++++++++++------- src/store/types.ts | 12 ++ src/utils/auth.ts | 156 +++++++----------- src/utils/http/index.ts | 90 +++++----- src/utils/sso.ts | 3 +- src/views/employees/index.vue | 44 ++++- src/views/login/index.vue | 67 ++++---- src/views/login/utils/rule.ts | 9 +- src/views/roles/index.vue | 31 +++- src/views/stores/index.vue | 25 ++- types/router.d.ts | 4 + 21 files changed, 572 insertions(+), 305 deletions(-) diff --git a/README.en-US.md b/README.en-US.md index 4faaafe..f169ac4 100644 --- a/README.en-US.md +++ b/README.en-US.md @@ -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`. -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 diff --git a/README.md b/README.md index 76cc8c1..0747b1c 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,9 @@ http://localhost:8848/ - `src/views/roles/index.vue`: 角色管理,搜索、重置、删除和保存后都会重新调用接口,支持角色编码校验。 - `src/views/employees/index.vue`: 员工管理,门店/状态/关键词筛选、重置、分页、启停、删除和保存后都会重新调用接口。 - `src/api/access.ts`: 门店、角色、员工接口类型与 HTTP 方法封装。 +- `src/api/user.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` - `POST /api/stores` - `PATCH /api/stores/:id` - `DELETE /api/stores/:id` -- `GET /api/roles`,搜索时会携带 `keyword` +- `GET /api/roles`,搜索/重置会重新请求接口后按关键词收敛结果 - `GET /api/roles/:id` - `POST /api/roles` - `PATCH /api/roles/:id` @@ -133,6 +138,15 @@ http://localhost:8848/ 接口响应统一在 `src/api/access.ts` 中使用 `ApiResult` 或 `PaginatedData` 描述,页面层只消费 `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`: 全局默认配置,目前包含端口和是否隐藏首页。 diff --git a/src/api/access.ts b/src/api/access.ts index d973d08..1be01b4 100644 --- a/src/api/access.ts +++ b/src/api/access.ts @@ -44,6 +44,7 @@ export interface RoleOption { code: string; name: string; description: string | null; + isSystem: boolean; } export interface Store extends StoreOption { @@ -68,12 +69,6 @@ export interface EmployeeListParams { export interface StoreListParams { includeInactive?: boolean; - status?: StoreStatus; - keyword?: string; -} - -export interface RoleListParams { - keyword?: string; } export interface StorePayload { @@ -123,10 +118,8 @@ export const listStores = (params?: StoreListParams) => { }); }; -export const listRoles = (params?: RoleListParams) => { - return http.request>("get", `${API_PREFIX}/roles`, { - params - }); +export const listRoles = () => { + return http.request>("get", `${API_PREFIX}/roles`); }; export const getStore = (id: number) => { diff --git a/src/api/user.ts b/src/api/user.ts index 87184b5..4144d7a 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -1,45 +1,72 @@ 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; data: { - /** 头像 */ - avatar: string; - /** 用户名 */ - username: string; - /** 昵称 */ - nickname: string; - /** 当前登录用户的角色 */ - roles: Array; - /** 按钮级别权限 */ - permissions: Array; - /** `token` */ - accessToken: string; - /** 用于调用刷新`accessToken`的接口时所需的`token` */ - refreshToken: string; - /** `accessToken`的过期时间(格式'xxxx/xx/xx xx:xx:xx') */ - expires: Date; + token: string; + tokenType: "Bearer"; + expiresIn: string; + user: AuthUser; }; }; -export type RefreshTokenResult = { +export type CurrentUserResult = { + success: boolean; + data: AuthUser; +}; + +export type CurrentPermissionResult = { success: boolean; data: { - /** `token` */ - accessToken: string; - /** 用于调用刷新`accessToken`的接口时所需的`token` */ - refreshToken: string; - /** `accessToken`的过期时间(格式'xxxx/xx/xx xx:xx:xx') */ - expires: Date; + permissions: string[]; + menus: PermissionMenu[]; }; }; -/** 登录 */ -export const getLogin = (data?: object) => { - return http.request("post", "/login", { data }); +/** 后台登录入口,兼容超级管理员账号和具备后台权限的员工手机号。 */ +export const loginAdmin = (data: LoginInput) => { + return http.request("post", "/api/auth/admin/login", { data }); }; -/** 刷新`token` */ -export const refreshTokenApi = (data?: object) => { - return http.request("post", "/refresh-token", { data }); +/** 获取当前 token 对应的账号基础信息。 */ +export const getCurrentUser = () => { + return http.request("get", "/api/auth/me"); +}; + +/** 获取当前账号可见菜单和按钮动作权限。 */ +export const getCurrentPermissions = () => { + return http.request("get", "/api/permissions/me"); }; diff --git a/src/layout/hooks/useDataThemeChange.ts b/src/layout/hooks/useDataThemeChange.ts index 4b08dff..829fca3 100644 --- a/src/layout/hooks/useDataThemeChange.ts +++ b/src/layout/hooks/useDataThemeChange.ts @@ -11,7 +11,8 @@ import { useMultiTagsStoreHook } from "@/store/modules/multiTags"; import { darken, lighten, useGlobal, storageLocal } from "@pureadmin/utils"; export function useDataThemeChange() { - const { layoutTheme, layout } = useLayout(); + const { layoutTheme, layout, initStorage } = useLayout(); + initStorage(); const themeColors = ref>([ /* 亮白色 */ { color: "#ffffff", themeColor: "light" }, diff --git a/src/layout/types.ts b/src/layout/types.ts index 4ffe01f..a2f21f0 100644 --- a/src/layout/types.ts +++ b/src/layout/types.ts @@ -38,6 +38,8 @@ export type routeMetaType = { icon?: string | FunctionalComponent; showLink?: boolean; savedPosition?: boolean; + permission?: string | Array; + menuKey?: string; auths?: Array; }; diff --git a/src/router/index.ts b/src/router/index.ts index fbb42f5..3d2aaf5 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -3,11 +3,15 @@ import NProgress from "@/utils/progress"; import { buildHierarchyTree } from "@/utils/tree"; import remainingRouter from "./modules/remaining"; 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 { ascending, + getTopMenu, getHistoryMode, handleAliveRoute, + hasRoutePermission, formatTwoStageRoutes, formatFlatteningRoutes } from "./utils"; @@ -96,8 +100,9 @@ export function resetRouter() { } 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); if (!to.meta.loaded) { @@ -130,6 +135,49 @@ router.beforeEach((to: ToRouteType, _from, next) => { 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) { openLink(to?.name as string); NProgress.done(); diff --git a/src/router/modules/employees.ts b/src/router/modules/employees.ts index f242b64..c0e0545 100644 --- a/src/router/modules/employees.ts +++ b/src/router/modules/employees.ts @@ -23,6 +23,8 @@ export default { component: () => import("@/views/stores/index.vue"), meta: { title: "门店管理", + menuKey: "stores", + permission: "store:view", keepAlive: true } }, @@ -32,6 +34,8 @@ export default { component: () => import("@/views/roles/index.vue"), meta: { title: "角色管理", + menuKey: "roles", + permission: "role:view", keepAlive: true } }, @@ -41,6 +45,8 @@ export default { component: () => import("@/views/employees/index.vue"), meta: { title: "员工管理", + menuKey: "employees", + permission: ["employee:view:all", "employee:view:store"], keepAlive: true } } diff --git a/src/router/utils.ts b/src/router/utils.ts index 51a1bc5..b17b428 100644 --- a/src/router/utils.ts +++ b/src/router/utils.ts @@ -81,13 +81,28 @@ function isOneOfArray(a: Array, b: Array) { : 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[]) { - const currentRoles = - storageLocal().getItem>(userKey)?.roles ?? []; - const newTree = cloneDeep(data).filter((v: any) => - isOneOfArray(v.meta?.roles, currentRoles) - ); + const userInfo = storageLocal().getItem>(userKey); + const currentRoles = userInfo?.roles ?? []; + const currentPermissions = userInfo?.permissions ?? []; + const newTree = cloneDeep(data).filter((v: any) => { + return ( + isOneOfArray(v.meta?.roles, currentRoles) && + hasRoutePermission(v.meta, currentPermissions) + ); + }); newTree.forEach( (v: any) => v.children && (v.children = filterNoPermissionTree(v.children)) ); @@ -404,6 +419,7 @@ export { getTopMenu, addPathMatch, isOneOfArray, + hasRoutePermission, getHistoryMode, addAsyncRoutes, getParentPaths, diff --git a/src/store/modules/permission.ts b/src/store/modules/permission.ts index b5e3b5d..77f8d5a 100644 --- a/src/store/modules/permission.ts +++ b/src/store/modules/permission.ts @@ -25,12 +25,12 @@ export const usePermissionStore = defineStore("pure-permission", { actions: { /** 组装整体路由生成的菜单 */ handleWholeMenus(routes: any[]) { - this.wholeMenus = filterNoPermissionTree( + const menus = filterNoPermissionTree( 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() { diff --git a/src/store/modules/user.ts b/src/store/modules/user.ts index 25225b0..a038f8e 100644 --- a/src/store/modules/user.ts +++ b/src/store/modules/user.ts @@ -4,42 +4,110 @@ import { store, router, resetRouter, - routerArrays, storageLocal } from "../utils"; import { - type UserResult, - type RefreshTokenResult, - getLogin, - refreshTokenApi + type AuthUser, + type LoginInput, + type LoginResult, + type PermissionMenu, + getCurrentPermissions, + getCurrentUser, + loginAdmin } from "@/api/user"; +import { usePermissionStoreHook } from "./permission"; 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>(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, "accessToken" | "expires">, + permissionMenus: PermissionMenu[], + permissions: string[] +): DataInfo { + 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", { state: (): userType => ({ - // 头像 - avatar: storageLocal().getItem>(userKey)?.avatar ?? "", - // 用户名 - username: - storageLocal().getItem>(userKey)?.username ?? "admin", - // 昵称 - nickname: - storageLocal().getItem>(userKey)?.nickname ?? - "系统管理员", - // 页面级别权限 - roles: storageLocal().getItem>(userKey)?.roles ?? [ - "admin" - ], - // 按钮级别权限 - permissions: - storageLocal().getItem>(userKey)?.permissions ?? [], + avatar: "", + username: savedUser?.username ?? "", + nickname: savedUser?.nickname ?? "", + accountType: savedUser?.accountType ?? "", + storeId: savedUser?.storeId, + storeName: savedUser?.storeName, + roles: savedUser?.roles ?? [], + permissions: savedUser?.permissions ?? [], + permissionMenus: savedUser?.permissionMenus ?? [], + canManage: savedUser?.canManage ?? false, + authLoaded: false, // 是否勾选了登录页的免登录 isRemembered: false, // 登录页的免登录存储几天,默认7天 loginDay: 7 }), actions: { + /** 写入后端认证上下文,并同步 pure-admin 仍在读取的用户字段。 */ + SET_USER_CONTEXT(data: Partial>) { + 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) { this.avatar = avatar; @@ -68,44 +136,83 @@ export const useUserStore = defineStore("pure-user", { SET_LOGINDAY(value: number) { this.loginDay = Number(value); }, - /** 登入 */ - async loginByUsername(data) { - return new Promise((resolve, reject) => { - getLogin(data) - .then(data => { - if (data?.success) setToken(data.data); - resolve(data); - }) - .catch(error => { - reject(error); - }); - }); + syncPermissionMenus() { + const permissionStore = usePermissionStoreHook(); + + permissionStore.handleWholeMenus([]); + if (!useMultiTagsStoreHook().getMultiTagsCache) { + useMultiTagsStoreHook().handleTags( + "equal", + collectMenuTags(permissionStore.wholeMenus) + ); + } }, - /** 前端登出(不调用接口) */ + /** 登入:按 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() { - this.username = "admin"; - this.nickname = "系统管理员"; - this.roles = ["admin"]; + this.username = ""; + this.nickname = ""; + this.accountType = ""; + this.storeId = undefined; + this.storeName = undefined; + this.roles = []; this.permissions = []; + this.permissionMenus = []; + this.canManage = false; + this.authLoaded = false; removeToken(); - useMultiTagsStoreHook().handleTags("equal", [...routerArrays]); + useMultiTagsStoreHook().handleTags("equal", []); resetRouter(); - router.push("/employees"); - }, - /** 刷新`token` */ - async handRefreshToken(data) { - return new Promise((resolve, reject) => { - refreshTokenApi(data) - .then(data => { - if (data) { - setToken(data.data); - resolve(data); - } - }) - .catch(error => { - reject(error); - }); - }); + router.push("/login"); } } }); diff --git a/src/store/types.ts b/src/store/types.ts index c33268a..3c7db5d 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -40,8 +40,20 @@ export type userType = { avatar?: string; username?: string; nickname?: string; + accountType?: "SUPER_ADMIN" | "EMPLOYEE" | ""; + storeId?: number; + storeName?: string; roles?: Array; permissions?: Array; + permissionMenus?: Array<{ + key: string; + title: string; + icon?: string; + permission: string; + actions: string[]; + }>; + canManage?: boolean; + authLoaded?: boolean; isRemembered?: boolean; loginDay?: number; }; diff --git a/src/utils/auth.ts b/src/utils/auth.ts index f2b28cb..2d00371 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -1,24 +1,28 @@ import Cookies from "js-cookie"; -import { useUserStoreHook } from "@/store/modules/user"; import { storageLocal, isString, isIncludeAllChildren } from "@pureadmin/utils"; -export interface DataInfo { - /** token */ +export interface PermissionMenuInfo { + key: string; + title: string; + icon?: string; + permission: string; + actions: string[]; +} + +export interface DataInfo { + /** 后端 JWT token,接口请求时会转换成 Bearer token。 */ accessToken: string; - /** `accessToken`的过期时间(时间戳) */ + /** token 本地过期时间戳。后端当前返回 expiresIn,前端换算成时间戳存储。 */ expires: T; - /** 用于调用刷新accessToken的接口时所需的token */ - refreshToken: string; - /** 头像 */ - avatar?: string; - /** 用户名 */ username?: string; - /** 昵称 */ nickname?: string; - /** 当前登录用户的角色 */ + accountType?: "SUPER_ADMIN" | "EMPLOYEE" | ""; + storeId?: number; + storeName?: string; roles?: Array; - /** 当前登录用户的按钮级别权限 */ permissions?: Array; + permissionMenus?: PermissionMenuInfo[]; + canManage?: boolean; } export const userKey = "user-info"; @@ -31,88 +35,44 @@ export const TokenKey = "authorized-token"; * */ export const multipleTabsKey = "multiple-tabs"; +export function getUserInfo(): DataInfo | null { + return storageLocal().getItem>(userKey) ?? null; +} + /** 获取`token` */ -export function getToken(): DataInfo { - // 此处与`TokenKey`相同,此写法解决初始化时`Cookies`中不存在`TokenKey`报错 - return Cookies.get(TokenKey) - ? JSON.parse(Cookies.get(TokenKey)) - : storageLocal().getItem(userKey); +export function getToken(): DataInfo | null { + const cookieValue = Cookies.get(TokenKey); + + if (cookieValue) { + return JSON.parse(cookieValue); + } + + return getUserInfo(); +} + +export function isTokenExpired(data?: DataInfo | null) { + return !data?.accessToken || Number(data.expires) - Date.now() <= 0; } /** - * @description 设置`token`以及一些必要信息并采用无感刷新`token`方案 - * 无感刷新:后端返回`accessToken`(访问接口使用的`token`)、`refreshToken`(用于调用刷新`accessToken`的接口时所需的`token`,`refreshToken`的过期时间(比如30天)应大于`accessToken`的过期时间(比如2小时))、`expires`(`accessToken`的过期时间) - * 将`accessToken`、`expires`、`refreshToken`这三条信息放在key值为authorized-token的cookie里(过期自动销毁) - * 将`avatar`、`username`、`nickname`、`roles`、`permissions`、`refreshToken`、`expires`这七条信息放在key值为`user-info`的localStorage里(利用`multipleTabsKey`当浏览器完全关闭后自动销毁) + * 保存 access-manage 签发的 JWT 和账号权限上下文。 + * + * 后端当前没有 refresh-token 接口,因此这里不再保留模板的无感刷新逻辑; + * token 过期后统一清理本地登录态并跳回登录页。 */ -export function setToken(data: DataInfo) { - let expires = 0; - const { accessToken, refreshToken } = data; - const { isRemembered, loginDay } = useUserStoreHook(); - expires = new Date(data.expires).getTime(); // 如果后端直接设置时间戳,将此处代码改为expires = data.expires,然后把上面的DataInfo改成DataInfo即可 - 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 +export function setToken(data: DataInfo) { + const { accessToken, expires } = data; + const cookieString = JSON.stringify({ accessToken, expires }); + const cookieOptions = + expires > 0 ? { - expires: loginDay + expires: (expires - Date.now()) / 86400000 } - : {} - ); + : undefined; - function setUserKey({ avatar, username, nickname, roles, permissions }) { - useUserStoreHook().SET_AVATAR(avatar); - useUserStoreHook().SET_USERNAME(username); - 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>(userKey)?.avatar ?? ""; - const username = - storageLocal().getItem>(userKey)?.username ?? ""; - const nickname = - storageLocal().getItem>(userKey)?.nickname ?? ""; - const roles = - storageLocal().getItem>(userKey)?.roles ?? []; - const permissions = - storageLocal().getItem>(userKey)?.permissions ?? []; - setUserKey({ - avatar, - username, - nickname, - roles, - permissions - }); - } + Cookies.set(TokenKey, cookieString, cookieOptions); + Cookies.set(multipleTabsKey, "true"); + storageLocal().setItem(userKey, data); } /** 删除`token`以及key值为`user-info`的localStorage信息 */ @@ -127,15 +87,25 @@ export const formatToken = (token: string): string => { return "Bearer " + token; }; -/** 是否有按钮级别的权限(根据登录接口返回的`permissions`字段进行判断)*/ +/** 是否有后端权限码。后端使用 `*` 表示超级管理员。 */ export const hasPerms = (value: string | Array): boolean => { if (!value) return false; - const allPerms = "*:*:*"; - const { permissions } = useUserStoreHook(); - if (!permissions) return false; - if (permissions.length === 1 && permissions[0] === allPerms) return true; - const isAuths = isString(value) + const permissions = getUserInfo()?.permissions ?? []; + + if (!permissions?.length) return false; + if (permissions.includes("*") || permissions.includes("*:*:*")) return true; + + return isString(value) ? permissions.includes(value) : 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; }; diff --git a/src/utils/http/index.ts b/src/utils/http/index.ts index a975cc9..dba2104 100644 --- a/src/utils/http/index.ts +++ b/src/utils/http/index.ts @@ -10,8 +10,12 @@ import type { PureHttpRequestConfig } from "./types.d"; import { stringify } from "qs"; -import { getToken, formatToken } from "@/utils/auth"; -import { useUserStoreHook } from "@/store/modules/user"; +import { + getToken, + removeToken, + formatToken, + isTokenExpired +} from "@/utils/auth"; // 相关配置请参考:www.axios-js.com/zh-cn/docs/#axios-request-config-1 const defaultConfig: AxiosRequestConfig = { @@ -34,26 +38,28 @@ class PureHttp { this.httpInterceptorsResponse(); } - /** `token`过期后,暂存待执行的请求 */ - private static requests = []; - - /** 防止重复刷新`token` */ - private static isRefreshing = false; - /** 初始化配置对象 */ private static initConfig: PureHttpRequestConfig = {}; /** 保存当前`Axios`实例对象 */ private static axiosInstance: AxiosInstance = Axios.create(defaultConfig); - /** 重连原始请求 */ - private static retryOriginalRequest(config: PureHttpRequestConfig) { - return new Promise(resolve => { - PureHttp.requests.push((token: string) => { - config.headers["Authorization"] = formatToken(token); - resolve(config); - }); - }); + /** access-manage 当前只有登录接口不需要 token。 */ + private static isPublicRequest(url = "") { + return [ + "/api/auth/login", + "/api/auth/admin/login", + "/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); return config; } - /** 请求白名单,放置一些不需要`token`的接口(通过设置请求白名单,防止`token`过期后再请求造成的死循环问题) */ - const whiteList = ["/refresh-token", "/login"]; - return whiteList.some(url => config.url.endsWith(url)) - ? config - : new Promise(resolve => { - const data = getToken(); - if (data) { - const now = new Date().getTime(); - const expired = parseInt(data.expires) - now <= 0; - if (expired) { - if (!PureHttp.isRefreshing) { - PureHttp.isRefreshing = true; - // token过期刷新 - 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); - } - }); + if (PureHttp.isPublicRequest(config.url)) { + return config; + } + + const data = getToken(); + + if (isTokenExpired(data)) { + PureHttp.redirectToLogin(); + return Promise.reject(new Error("登录已过期,请重新登录")); + } + + config.headers["Authorization"] = formatToken(data.accessToken); + return config; }, error => { return Promise.reject(error); @@ -132,6 +115,9 @@ class PureHttp { (error: PureHttpError) => { const $error = error; $error.isCancelRequest = Axios.isCancel($error); + if ($error.response?.status === 401) { + PureHttp.redirectToLogin(); + } // 所有的响应异常 区分来源为取消请求/非取消请求 return Promise.reject($error); } diff --git a/src/utils/sso.ts b/src/utils/sso.ts index 18021d0..d4a34f9 100644 --- a/src/utils/sso.ts +++ b/src/utils/sso.ts @@ -12,7 +12,7 @@ import { subBefore, getQueryMap } from "@pureadmin/utils"; */ (function () { // 获取 url 中的参数 - const params = getQueryMap(location.href) as DataInfo; + const params = getQueryMap(location.href) as unknown as DataInfo; const must = ["username", "roles", "accessToken"]; const mustLength = must.length; if (Object.keys(params).length !== mustLength) return; @@ -37,6 +37,7 @@ import { subBefore, getQueryMap } from "@pureadmin/utils"; removeToken(); // 保存新信息到本地 + params.expires = Number(params.expires) || Date.now() + 2 * 60 * 60 * 1000; setToken(params); // 删除不需要显示在 url 的参数 diff --git a/src/views/employees/index.vue b/src/views/employees/index.vue index 16170c3..9d20ee8 100644 --- a/src/views/employees/index.vue +++ b/src/views/employees/index.vue @@ -21,6 +21,7 @@ import { type RoleOption, type StoreOption } from "@/api/access"; +import { hasPerms } from "@/utils/auth"; import Plus from "~icons/ep/plus"; import Search from "~icons/ep/search"; @@ -41,6 +42,13 @@ type EmployeeFormState = EmployeePayload & { /** 员工手机号按中国大陆手机号做前端第一层校验,最终唯一性仍由后端保证。 */ const phonePattern = /^1[3-9]\d{9}$/; +const bindableRoleCodes = new Set([ + "store_manager", + "cashier", + "kitchen", + "part_time", + "admin" +]); const tableLoading = ref(false); const catalogLoading = ref(false); const submitLoading = ref(false); @@ -99,6 +107,8 @@ const inactiveCount = computed( () => employees.value.filter(item => item.status === "INACTIVE").length ); const dialogTitle = computed(() => (form.id ? "编辑员工" : "新增员工")); +const canManageEmployees = computed(() => hasPerms("employee:manage")); +const canViewAllEmployees = computed(() => hasPerms("employee:view:all")); function getErrorMessage(error: unknown, fallback: string) { const message = ( @@ -147,14 +157,22 @@ function buildPayload(): EmployeePayload { /** 门店和角色是员工表单的基础字典,打开弹窗前必须先加载。 */ async function fetchCatalog() { + const shouldLoadStores = + canViewAllEmployees.value || canManageEmployees.value; + const shouldLoadRoles = canManageEmployees.value; + + if (!shouldLoadStores && !shouldLoadRoles) return; + catalogLoading.value = true; try { const [storeResult, roleResult] = await Promise.all([ - listStores(), - listRoles() + shouldLoadStores ? listStores() : Promise.resolve({ data: [] }), + shouldLoadRoles ? listRoles() : Promise.resolve({ data: [] }) ]); stores.value = storeResult.data; - roles.value = roleResult.data; + roles.value = roleResult.data.filter(role => + bindableRoleCodes.has(role.code) + ); } catch (error) { ElMessage.error(getErrorMessage(error, "加载门店和角色选项失败")); } finally { @@ -209,12 +227,14 @@ function handleSizeChange(pageSize: number) { } function openCreateDialog() { + if (!canManageEmployees.value) return; resetFormState(); dialogVisible.value = true; } /** 编辑前重新拉详情,确保角色绑定不是来自列表摘要的过期数据。 */ async function openEditDialog(row: Employee) { + if (!canManageEmployees.value) return; try { const result = await getEmployee(row.id); const employee = result.data; @@ -235,6 +255,7 @@ async function openEditDialog(row: Employee) { } async function submitForm() { + if (!canManageEmployees.value) return; await formRef.value?.validate(); submitLoading.value = true; @@ -259,6 +280,7 @@ async function submitForm() { } async function toggleStatus(row: Employee) { + if (!canManageEmployees.value) return; const nextStatus: EmployeeStatus = row.status === "ACTIVE" ? "INACTIVE" : "ACTIVE"; const action = nextStatus === "ACTIVE" ? "启用" : "停用"; @@ -284,6 +306,7 @@ async function toggleStatus(row: Employee) { } async function removeEmployee(row: Employee) { + if (!canManageEmployees.value) return; try { await ElMessageBox.confirm( `删除后员工「${row.name}」会被软删除并停用,确认继续?`, @@ -321,7 +344,12 @@ onMounted(async () => {

门店员工权限管理

员工管理

- + 新增员工 @@ -347,6 +375,7 @@ onMounted(async () => {
{ {{ formatTime(row.updatedAt) }} - + - + @@ -473,6 +490,10 @@ onMounted(fetchRoles); } } +.muted { + color: #94a3b8; +} + .management-form { padding-top: 4px; } diff --git a/src/views/stores/index.vue b/src/views/stores/index.vue index 7f9d5ce..cb87a38 100644 --- a/src/views/stores/index.vue +++ b/src/views/stores/index.vue @@ -15,6 +15,7 @@ import { type StorePayload, type StoreStatus } from "@/api/access"; +import { hasPerms } from "@/utils/auth"; import Plus from "~icons/ep/plus"; import Search from "~icons/ep/search"; @@ -70,6 +71,7 @@ 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(); @@ -136,9 +138,7 @@ async function fetchStores() { tableLoading.value = true; try { const result = await listStores({ - includeInactive: true, - status: query.status, - keyword: query.keyword.trim() || undefined + includeInactive: true }); stores.value = applyStoreQuery(result.data); } catch (error) { @@ -160,11 +160,13 @@ function handleSearch() { } function openCreateDialog() { + if (!canManageStores.value) return; resetFormState(); dialogVisible.value = true; } function openEditDialog(row: Store) { + if (!canManageStores.value) return; Object.assign(form, { id: row.id, name: row.name, @@ -177,6 +179,7 @@ function openEditDialog(row: Store) { } async function submitForm() { + if (!canManageStores.value) return; await formRef.value?.validate(); submitLoading.value = true; @@ -201,6 +204,7 @@ async function submitForm() { } async function toggleStatus(row: Store) { + if (!canManageStores.value) return; const nextStatus: StoreStatus = row.status === "ACTIVE" ? "INACTIVE" : "ACTIVE"; const action = nextStatus === "ACTIVE" ? "启用" : "停用"; @@ -226,6 +230,7 @@ async function toggleStatus(row: Store) { } async function removeStore(row: Store) { + if (!canManageStores.value) return; try { await ElMessageBox.confirm( `删除后门店「${row.name}」不会再出现在员工新增下拉里,确认继续?`, @@ -256,7 +261,12 @@ onMounted(fetchStores);

门店基础数据

门店管理

- + 新增门店 @@ -345,7 +355,12 @@ onMounted(fetchStores); {{ formatTime(row.updatedAt) }} - +