feat: 接入真实登录鉴权流程
This commit is contained in:
+3
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 的参数
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+4
@@ -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`)`可选` */
|
||||
|
||||
Reference in New Issue
Block a user