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`.
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
+16 -2
View File
@@ -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<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`: 全局默认配置,目前包含端口和是否隐藏首页。
+3 -10
View File
@@ -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<ApiResult<Role[]>>("get", `${API_PREFIX}/roles`, {
params
});
export const listRoles = () => {
return http.request<ApiResult<Role[]>>("get", `${API_PREFIX}/roles`);
};
export const getStore = (id: number) => {
+57 -30
View File
@@ -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<string>;
/** 按钮级别权限 */
permissions: Array<string>;
/** `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<UserResult>("post", "/login", { data });
/** 后台登录入口,兼容超级管理员账号和具备后台权限的员工手机号。 */
export const loginAdmin = (data: LoginInput) => {
return http.request<LoginResult>("post", "/api/auth/admin/login", { data });
};
/** 刷新`token` */
export const refreshTokenApi = (data?: object) => {
return http.request<RefreshTokenResult>("post", "/refresh-token", { data });
/** 获取当前 token 对应的账号基础信息。 */
export const getCurrentUser = () => {
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";
export function useDataThemeChange() {
const { layoutTheme, layout } = useLayout();
const { layoutTheme, layout, initStorage } = useLayout();
initStorage();
const themeColors = ref<Array<themeColorsType>>([
/* 亮白色 */
{ color: "#ffffff", themeColor: "light" },
+2
View File
@@ -38,6 +38,8 @@ export type routeMetaType = {
icon?: string | FunctionalComponent;
showLink?: boolean;
savedPosition?: boolean;
permission?: string | Array<string>;
menuKey?: string;
auths?: Array<string>;
};
+49 -1
View File
@@ -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();
+6
View File
@@ -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
}
}
+22 -6
View File
@@ -81,13 +81,28 @@ function isOneOfArray(a: Array<string>, b: Array<string>) {
: 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<DataInfo<number>>(userKey)?.roles ?? [];
const newTree = cloneDeep(data).filter((v: any) =>
isOneOfArray(v.meta?.roles, currentRoles)
);
const userInfo = storageLocal().getItem<DataInfo<number>>(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,
+4 -4
View File
@@ -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() {
+162 -55
View File
@@ -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<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", {
state: (): userType => ({
// 头像
avatar: storageLocal().getItem<DataInfo<number>>(userKey)?.avatar ?? "",
// 用户名
username:
storageLocal().getItem<DataInfo<number>>(userKey)?.username ?? "admin",
// 昵称
nickname:
storageLocal().getItem<DataInfo<number>>(userKey)?.nickname ??
"系统管理员",
// 页面级别权限
roles: storageLocal().getItem<DataInfo<number>>(userKey)?.roles ?? [
"admin"
],
// 按钮级别权限
permissions:
storageLocal().getItem<DataInfo<number>>(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<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) {
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<UserResult>((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<RefreshTokenResult>((resolve, reject) => {
refreshTokenApi(data)
.then(data => {
if (data) {
setToken(data.data);
resolve(data);
}
})
.catch(error => {
reject(error);
});
});
router.push("/login");
}
}
});
+12
View File
@@ -40,8 +40,20 @@ export type userType = {
avatar?: string;
username?: string;
nickname?: string;
accountType?: "SUPER_ADMIN" | "EMPLOYEE" | "";
storeId?: number;
storeName?: string;
roles?: Array<string>;
permissions?: Array<string>;
permissionMenus?: Array<{
key: string;
title: string;
icon?: string;
permission: string;
actions: string[];
}>;
canManage?: boolean;
authLoaded?: boolean;
isRemembered?: boolean;
loginDay?: number;
};
+63 -93
View File
@@ -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<T> {
/** token */
export interface PermissionMenuInfo {
key: string;
title: string;
icon?: string;
permission: string;
actions: string[];
}
export interface DataInfo<T = number> {
/** 后端 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<string>;
/** 当前登录用户的按钮级别权限 */
permissions?: Array<string>;
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<number> | null {
return storageLocal().getItem<DataInfo<number>>(userKey) ?? null;
}
/** 获取`token` */
export function getToken(): DataInfo<number> {
// 此处与`TokenKey`相同,此写法解决初始化时`Cookies`中不存在`TokenKey`报错
return Cookies.get(TokenKey)
? JSON.parse(Cookies.get(TokenKey))
: storageLocal().getItem(userKey);
export function getToken(): DataInfo<number> | null {
const cookieValue = Cookies.get(TokenKey);
if (cookieValue) {
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`方案
* 无感刷新:后端返回`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<Date>) {
let expires = 0;
const { accessToken, refreshToken } = data;
const { isRemembered, loginDay } = useUserStoreHook();
expires = new Date(data.expires).getTime(); // 如果后端直接设置时间戳,将此处代码改为expires = data.expires,然后把上面的DataInfo<Date>改成DataInfo<number>即可
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<number>) {
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<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
});
}
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<string>): 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;
};
+38 -52
View File
@@ -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);
}
+2 -1
View File
@@ -12,7 +12,7 @@ import { subBefore, getQueryMap } from "@pureadmin/utils";
*/
(function () {
// 获取 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 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 的参数
+39 -5
View File
@@ -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 () => {
<p class="eyebrow">门店员工权限管理</p>
<h1>员工管理</h1>
</div>
<el-button type="primary" :icon="Plus" @click="openCreateDialog">
<el-button
v-if="canManageEmployees"
type="primary"
:icon="Plus"
@click="openCreateDialog"
>
新增员工
</el-button>
</div>
@@ -347,6 +375,7 @@ onMounted(async () => {
<div class="toolbar">
<el-select
v-if="canViewAllEmployees"
v-model="query.storeId"
clearable
filterable
@@ -440,7 +469,12 @@ onMounted(async () => {
{{ formatTime(row.updatedAt) }}
</template>
</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 }">
<el-button
link
+39 -28
View File
@@ -10,7 +10,7 @@ import { useEventListener } from "@vueuse/core";
import type { FormInstance } from "element-plus";
import { useLayout } from "@/layout/hooks/useLayout";
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 { useRenderIcon } from "@/components/ReIcon/src/hooks";
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
@@ -38,38 +38,49 @@ const { title } = useNav();
const ruleForm = reactive({
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) => {
if (!formEl) return;
await formEl.validate(valid => {
if (valid) {
loading.value = true;
useUserStoreHook()
.loginByUsername({
username: ruleForm.username,
password: ruleForm.password
})
.then(res => {
if (res.success) {
// 获取后端路由
return initRouter().then(() => {
disabled.value = true;
router
.push(getTopMenu(true).path)
.then(() => {
message("登录成功", { type: "success" });
})
.finally(() => (disabled.value = false));
});
} else {
message("登录失败", { type: "error" });
}
})
.finally(() => (loading.value = false));
try {
await formEl.validate();
} catch {
return;
}
loading.value = true;
try {
const res = await useUserStoreHook().loginByUsername({
username: ruleForm.username,
password: ruleForm.password
});
if (res.success) {
disabled.value = true;
const redirect = router.currentRoute.value.query.redirect as
| string
| undefined;
await router.push(redirect || getTopMenu(true)?.path || "/access-denied");
message("登录成功", { type: "success" });
}
});
} catch (error) {
message(getErrorMessage(error, "登录失败"), { type: "error" });
} finally {
disabled.value = false;
loading.value = false;
}
};
const immediateDebounce: any = debounce(
+3 -6
View File
@@ -1,9 +1,8 @@
import { reactive } from "vue";
import type { FormRules } from "element-plus";
/** 密码正则(密码格式应为8-18位数字、字母、符号的任意两种组合) */
export const REGEXP_PWD =
/^(?![0-9]+$)(?![a-z]+$)(?![A-Z]+$)(?!([^(0-9a-zA-Z)]|[()])+$)(?!^.*[\u4E00-\u9FA5].*$)([^(0-9a-zA-Z)]|[()]|[a-z]|[A-Z]|[0-9]){8,18}$/;
/** 后端登录密码约束:8-128 个字符。 */
export const REGEXP_PWD = /^.{8,128}$/;
/** 登录校验 */
const loginRules = reactive<FormRules>({
@@ -13,9 +12,7 @@ const loginRules = reactive<FormRules>({
if (value === "") {
callback(new Error("请输入密码"));
} else if (!REGEXP_PWD.test(value)) {
callback(
new Error("密码格式应为8-18位数字、字母、符号的任意两种组合")
);
callback(new Error("密码长度应为 8-128 个字符"));
} else {
callback();
}
+26 -5
View File
@@ -14,6 +14,7 @@ import {
type Role,
type RolePayload
} from "@/api/access";
import { hasPerms } from "@/utils/auth";
import Plus from "~icons/ep/plus";
import Search from "~icons/ep/search";
@@ -74,6 +75,7 @@ const systemRoleCount = computed(
() => roles.value.filter(item => item.code === "admin").length
);
const dialogTitle = computed(() => (form.id ? "编辑角色" : "新增角色"));
const canManageRoles = computed(() => hasPerms("role:manage"));
function applyRoleQuery(items: Role[]) {
const keyword = query.keyword.trim().toLowerCase();
@@ -136,9 +138,7 @@ function buildPayload(): RolePayload {
async function fetchRoles() {
tableLoading.value = true;
try {
const result = await listRoles({
keyword: query.keyword.trim() || undefined
});
const result = await listRoles();
roles.value = applyRoleQuery(result.data);
} catch (error) {
ElMessage.error(getErrorMessage(error, "加载角色列表失败"));
@@ -158,11 +158,13 @@ function handleSearch() {
}
function openCreateDialog() {
if (!canManageRoles.value) return;
resetFormState();
dialogVisible.value = true;
}
function openEditDialog(row: Role) {
if (!canManageRoles.value || row.isSystem) return;
Object.assign(form, {
id: row.id,
code: row.code,
@@ -174,6 +176,7 @@ function openEditDialog(row: Role) {
}
async function submitForm() {
if (!canManageRoles.value) return;
await formRef.value?.validate();
submitLoading.value = true;
@@ -198,6 +201,7 @@ async function submitForm() {
}
async function removeRole(row: Role) {
if (!canManageRoles.value || row.isSystem) return;
try {
await ElMessageBox.confirm(
`删除后角色「${row.name}」无法再绑定员工,确认继续?`,
@@ -228,7 +232,12 @@ onMounted(fetchRoles);
<p class="eyebrow">权限角色基础数据</p>
<h1>角色管理</h1>
</div>
<el-button type="primary" :icon="Plus" @click="openCreateDialog">
<el-button
v-if="canManageRoles"
type="primary"
:icon="Plus"
@click="openCreateDialog"
>
新增角色
</el-button>
</div>
@@ -295,9 +304,15 @@ onMounted(fetchRoles);
{{ formatTime(row.updatedAt) }}
</template>
</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 }">
<el-button
v-if="!row.isSystem"
link
type="primary"
:icon="EditPen"
@@ -306,6 +321,7 @@ onMounted(fetchRoles);
编辑
</el-button>
<el-button
v-if="!row.isSystem"
link
type="danger"
:icon="Delete"
@@ -313,6 +329,7 @@ onMounted(fetchRoles);
>
删除
</el-button>
<span v-if="row.isSystem" class="muted">系统内置</span>
</template>
</el-table-column>
</el-table>
@@ -473,6 +490,10 @@ onMounted(fetchRoles);
}
}
.muted {
color: #94a3b8;
}
.management-form {
padding-top: 4px;
}
+20 -5
View File
@@ -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);
<p class="eyebrow">门店基础数据</p>
<h1>门店管理</h1>
</div>
<el-button type="primary" :icon="Plus" @click="openCreateDialog">
<el-button
v-if="canManageStores"
type="primary"
:icon="Plus"
@click="openCreateDialog"
>
新增门店
</el-button>
</div>
@@ -345,7 +355,12 @@ onMounted(fetchStores);
{{ formatTime(row.updatedAt) }}
</template>
</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 }">
<el-button
link
+4
View File
@@ -24,6 +24,10 @@ declare global {
showParent?: boolean;
/** 页面级别权限设置 `可选` */
roles?: Array<string>;
/** 后端权限码,支持单个权限或任意一个权限命中即可访问 */
permission?: string | Array<string>;
/** 后端权限菜单 key,用于和 `/api/permissions/me` 返回菜单对应 */
menuKey?: string;
/** 按钮级别权限设置 `可选` */
auths?: Array<string>;
/** 路由组件缓存(开启 `true`、关闭 `false``可选` */