From b11d43a0a696d7efefaf51b53d12f3179bcb3382 Mon Sep 17 00:00:00 2001 From: luoer Date: Tue, 7 Nov 2023 17:49:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 2 +- src/api/instance/useRequest.ts | 262 ++++++++++++++------------------ src/pages/_login/index.vue | 4 +- src/router/guards/guard-auth.ts | 27 ++-- src/router/menus/index.ts | 68 +++------ src/router/router/index.ts | 28 +++- src/router/routes/base.ts | 14 ++ src/router/routes/index.ts | 7 +- src/store/app/index.ts | 73 ++++----- src/store/menu/index.ts | 31 ++++ src/store/store/index.ts | 3 +- src/store/user/index.ts | 66 ++++---- src/types/vue-router.d.ts | 8 +- 13 files changed, 312 insertions(+), 281 deletions(-) create mode 100644 src/router/routes/base.ts create mode 100644 src/store/menu/index.ts diff --git a/index.html b/index.html index f582cce..c6a7df9 100644 --- a/index.html +++ b/index.html @@ -41,7 +41,7 @@ margin: 0; margin-top: 20px; font-size: 22px; - font-weight: 300; + font-weight: 400; line-height: 1; } .loading-tip { diff --git a/src/api/instance/useRequest.ts b/src/api/instance/useRequest.ts index f9ec47a..6ddb69f 100644 --- a/src/api/instance/useRequest.ts +++ b/src/api/instance/useRequest.ts @@ -1,32 +1,139 @@ +/** + * 请求函数 + * @see "src/api/instance/useRequest.ts" + */ +export function useRequest(fn: T, options: Options = {}) { + type Data = Awaited>; + type Args = Parameters; + const { initialParams, initialData, retry = 0, retryDelay = 0, interval = 0 } = options; + const { onBefore, onSuccess, onError, onFinally } = options; + + /** + * 返回数据 + */ + const data = ref(initialData ?? null); + /** + * 请求错误 + */ + const error = ref(null); + /** + * 是否请求中 + */ + const loading = ref(false); + + let isCanceled = false; + let retryCount = 0; + let retryTimer = 0; + let interTimer = 0; + let latestArgs = initialParams ?? []; + + const _send = async (...args: Args) => { + try { + onBefore?.(args); + loading.value = true; + const res = await fn(...args); + retryCount = 0; + if (isCanceled) { + return []; + } + onSuccess?.(res); + data.value = res; + error.value = null; + } catch (err: any) { + if (isCanceled) { + return []; + } + onError?.(err); + data.value = null; + error.value = err; + if (retry > 0 && retryCount < retry) { + retryCount++; + retryTimer = setTimeout(() => _send(...args), retryDelay) as any; + } + } finally { + loading.value = false; + if (isCanceled) { + return []; + } + onFinally?.(); + if (!retryCount && interval > 0) { + interTimer = setTimeout(() => _send(...args), interval) as any; + } + } + return [error.value, data.value]; + }; + + const clearAllTimer = () => { + clearTimeout(retryTimer); + clearTimeout(interTimer); + }; + + /** + * 取消请求 + */ + const cancel = () => { + isCanceled = true; + clearAllTimer(); + }; + + /** + * 发送请求 + */ + const send = (...args: Args) => { + isCanceled = false; + retryCount = 0; + latestArgs = args; + clearAllTimer(); + return _send(...args); + }; + + onMounted(() => initialParams && send(...initialParams)); + onUnmounted(cancel); + + return { + data, + error, + loading, + send, + cancel, + }; +} + type PromiseFn = (...args: any[]) => Promise; -type Options = { +interface Options { + /** + * 初始请求参数 + * @description 传递此参数会在挂载后立即执行 + */ + initialParams?: Parameters; + /** + * 默认值 + * @description 与请求函数的返回值一致 + */ + initialData?: Awaited>; /** * 是否显示全局的 loading + * @default false */ toast?: boolean | string; - /** - * 是否立即执行 - */ - initialParams?: boolean | Parameters; - /** - * 默认值 - */ - initialData?: Partial>>; /** * 请求失败后重试的次数 + * @default 0 */ retry?: number; /** * 请求失败后重试的间隔(ms) + * @default 0 */ retryDelay?: number; /** * 轮询间隔(ms) + * @default 0 */ interval?: number; /** - * 请求前回调 + * 请求前的回调 */ onBefore?: (args: Parameters) => void; /** @@ -41,139 +148,4 @@ type Options = { * 请求结束回调 */ onFinally?: () => void; -}; - -type State>> = { - /** - * 请求返回的数据 - */ - data: D | undefined; - /** - * 请求返回的错误 - */ - error: unknown; - /** - * 请求是否中 - */ - loading: boolean; - /** - * 发送请求 - */ - send: (...args: Parameters) => Promise<[unknown, undefined] | [undefined, D]>; - /** - * 取消请求 - */ - cancel: () => void; -}; - -const log = (...args: any[]) => { - if (process.env.NODE_ENV === "development") { - console.log(...args); - } -}; - -/** - * 包装请求函数,返回响应式状态和请求方法 - * @see src/api/instance/useRequest.ts - */ -export function useRequest(fn: T, options: Options = {}) { - const { - initialParams, - retry, - retryDelay = 0, - interval, - initialData, - onBefore, - onSuccess, - onError, - onFinally, - } = options; - - const state = reactive>({ - data: initialData, - error: null, - loading: false, - send: null, - cancel: null, - } as any); - - const inner = { - canceled: false, - retryCount: 0, - retryTimer: 0 as any, - intervalTimer: 0 as any, - latestParams: (initialParams || []) as any, - clearAllTimer: () => { - inner.retryTimer && clearTimeout(inner.retryTimer); - inner.intervalTimer && clearTimeout(inner.intervalTimer); - }, - }; - - const _send: any = async (...args: Parameters) => { - let data; - let error; - inner.retryCount && log(`retry: ${inner.retryCount}`); - try { - state.loading = true; - onBefore?.(args); - const res = await fn(...args); - inner.retryCount = 0; - if (!inner.canceled) { - onSuccess?.(res.data); - data = res.data; - } - } catch (err) { - if (!inner.canceled) { - error = err; - onError?.(err); - if (retry && retry > 0 && inner.retryCount < retry) { - inner.retryCount++; - inner.retryTimer = setTimeout(() => { - _send(...args); - }, retryDelay); - } - } - } finally { - log("finally"); - state.loading = false; - state.error = error; - if (!error) { - state.data = data; - } - if (!inner.canceled) { - onFinally?.(); - if (!inner.retryCount && interval && interval > 0) { - inner.intervalTimer = setTimeout(() => { - _send(...args); - }, interval); - } - } - } - return [error, data]; - }; - - state.cancel = () => { - inner.canceled = true; - inner.clearAllTimer(); - }; - - state.send = (...args: Parameters) => { - inner.canceled = false; - inner.retryCount = 0; - inner.latestParams = args; - inner.clearAllTimer(); - return _send(...args); - }; - - onMounted(() => { - if (initialParams) { - state.send(...(Array.isArray(initialParams) ? initialParams : ([] as any))); - } - }); - - onUnmounted(() => { - state.cancel(); - }); - - return state; } diff --git a/src/pages/_login/index.vue b/src/pages/_login/index.vue index ad21b8c..849dde6 100644 --- a/src/pages/_login/index.vue +++ b/src/pages/_login/index.vue @@ -101,7 +101,7 @@ const onSubmitForm = async () => { try { loading.value = true; const res = await api.auth.login(model); - userStore.setAccessToken(res.data.data as unknown as string); + userStore.setAccessToken(res.data.data); Notification.success({ title: "登陆提示", content: `欢迎,您已成功登陆系统!`, @@ -124,7 +124,7 @@ const onSubmitForm = async () => { box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1); } .login-left { - background: rgb(var(--primary-6)) url(/src/pages/_login/image-br.svg) no-repeat center center/90% auto; + background: rgb(var(--primary-6)) url(./image-br.svg) no-repeat center center/90% auto; } diff --git a/src/router/guards/guard-auth.ts b/src/router/guards/guard-auth.ts index a4975ba..11f40ce 100644 --- a/src/router/guards/guard-auth.ts +++ b/src/router/guards/guard-auth.ts @@ -1,30 +1,31 @@ import { store, useUserStore } from "@/store"; -import { Message } from "@arco-design/web-vue"; +import { Notification } from "@arco-design/web-vue"; import { NavigationGuardWithThis } from "vue-router"; -const whitelist = ["/:all(.*)*"]; -const signoutlist = ["/login"]; +const WHITE_LIST = ["/:all(.*)*"]; +const UNSIGNIN_LIST = ["/login"]; +// store不能放在外面,否则 pinia-plugin-peristedstate 插件会失效 export const authGuard: NavigationGuardWithThis = async function (to) { - // 放在外面,pinia-plugin-peristedstate 插件会失效 const userStore = useUserStore(store); - if (whitelist.includes(to.path) || to.name === "_all") { + if (to.meta.auth?.some((i) => i === "*")) { return true; } - if (signoutlist.includes(to.path)) { + if (WHITE_LIST.includes(to.path)) { + return true; + } + if (UNSIGNIN_LIST.includes(to.path)) { if (userStore.accessToken) { - Message.warning(`提示:您已登陆,如需重新请退出后再操作!`); + Notification.warning({ + title: "跳转提示", + content: `提示:您已登陆,如需重新登陆请退出后再操作!`, + }); return false; } return true; } if (!userStore.accessToken) { - return { - path: "/login", - query: { - redirect: to.path, - }, - }; + return { path: "/login", query: { redirect: to.path } }; } return true; }; diff --git a/src/router/menus/index.ts b/src/router/menus/index.ts index 8f581f3..28b51f9 100644 --- a/src/router/menus/index.ts +++ b/src/router/menus/index.ts @@ -4,7 +4,7 @@ import { appRoutes } from "../routes"; /** * 菜单项类型 */ -interface MenuItem { +export interface MenuItem { id: string; parentId?: string; path: string; @@ -23,38 +23,32 @@ interface MenuItem { function routesToItems(routes: RouteRecordRaw[]): MenuItem[] { const items: MenuItem[] = []; - routes.forEach((route) => { + for (const route of routes) { + const { meta = {}, parentMeta, path } = route as any; + const { title, sort, icon } = meta; + let id = path; let paths = route.path.split("/"); - let id = route.path; let parentId = paths.slice(0, -1).join("/"); - - if ((route as any).parentMeta) { - id = `${route.path}/index`; - parentId = route.path; + if (parentMeta) { + const { title, icon, sort } = parentMeta; + id = `${path}/index`; + parentId = path; items.push({ - id: route.path, + title, + icon, + sort, + path, + id: path, parentId: paths.slice(0, -1).join("/"), - path: `${route.path}`, - title: (route as any).parentMeta.title, - icon: (route as any).parentMeta.icon, - sort: (route as any).parentMeta.sort, }); } else { const p = paths.slice(0, -1).join("/"); - if (routes.some((i) => i.path === p && (i as any).parentMeta)) { + if (routes.some((i) => i.path === p) && parentMeta) { parentId = p; } } - - items.push({ - id, - parentId, - path: route.path, - sort: route.meta?.sort, - title: route.meta?.title, - icon: route.meta?.icon, - }); - }); + items.push({ id, title, parentId, path, icon, sort }); + } return items; } @@ -68,18 +62,18 @@ function listToTree(list: MenuItem[]) { const map: Record = {}; const tree: MenuItem[] = []; - list.forEach((item) => { + for (const item of list) { map[item.id] = item; - }); + } - list.forEach((item) => { + for (const item of list) { const parent = map[item.parentId as string]; if (parent) { (parent.children || (parent.children = [])).push(item); } else { tree.push(item); } - }); + } return tree; } @@ -102,31 +96,17 @@ function sort(routes: T[], key }); } -/** - * 转换路由为树形菜单项,并排序 - * @param routes 路由配置 - * @returns - */ -function transformToMenuItems(routes: RouteRecordRaw[]) { - const menus = routesToItems(routes); - const tree = listToTree(menus); - return sort(tree); -} - /** * 扁平化的菜单 */ -const flatedMenus = routesToItems(appRoutes); +export const flatMenus = routesToItems(appRoutes); /** * 树结构菜单 */ -const treeMenus = listToTree(flatedMenus); +export const treeMenus = listToTree(flatMenus); /** * 排序过的树级菜单 */ -const menus = sort(treeMenus); - -export { menus, treeMenus, flatedMenus }; -export type { MenuItem }; +export const menus = sort(treeMenus); diff --git a/src/router/router/index.ts b/src/router/router/index.ts index b6d1767..246e4f7 100644 --- a/src/router/router/index.ts +++ b/src/router/router/index.ts @@ -3,28 +3,40 @@ import { authGuard } from "../guards/guard-auth"; import { progressGuard } from "../guards/guard-progress"; import { titleGuard } from "../guards/guard-title"; import { routes } from "../routes"; +import { baseRoutes } from "../routes/base"; import { api } from "@/api"; import { store, useUserStore } from "@/store"; +/** + * 路由实例 + */ export const router = createRouter({ history: createWebHashHistory(), - routes: [ - { - path: "/", - redirect: "/home/home", - }, - ...routes, - ], + routes: [...baseRoutes, ...routes], }); +/** + * 进度条守卫 + */ router.beforeEach(progressGuard.before); router.afterEach(progressGuard.after); + +/** + * 权限守卫 + */ router.beforeEach(authGuard); + +/** + * 标题守卫 + */ router.afterEach(titleGuard); +/** + * 设置令牌过期处理函数 + */ api.expireHandler = () => { const userStore = useUserStore(store); - userStore.clearUser(); const redirect = router.currentRoute.value.path; + userStore.clearUser(); router.push({ path: "/login", query: { redirect } }); }; diff --git a/src/router/routes/base.ts b/src/router/routes/base.ts new file mode 100644 index 0000000..5c55ffa --- /dev/null +++ b/src/router/routes/base.ts @@ -0,0 +1,14 @@ +import { useMenuStore } from "@/store/menu"; +import { RouteRecordRaw } from "vue-router"; + +export const baseRoutes: RouteRecordRaw[] = [ + { + path: "/", + redirect: (to) => { + const { home } = useMenuStore(); + return { + name: home, + }; + }, + }, +]; diff --git a/src/router/routes/index.ts b/src/router/routes/index.ts index e09945f..32f42f6 100644 --- a/src/router/routes/index.ts +++ b/src/router/routes/index.ts @@ -1,10 +1,11 @@ import generatedRoutes from "virtual:generated-pages"; import { RouteRecordRaw } from "vue-router"; +const TOP_ROUTE_PREF = "_"; const APP_ROUTE_NAME = "_layout"; /** - * 转换一维路由为二维路由 + * 转换路由 * @description 以 _ 开头的路由为顶级路由,其余为应用路由 */ const transformRoutes = (routes: RouteRecordRaw[]) => { @@ -12,11 +13,11 @@ const transformRoutes = (routes: RouteRecordRaw[]) => { const appRoutes: RouteRecordRaw[] = []; for (const route of routes) { - if ((route.name as string)?.startsWith("_")) { + if ((route.name as string)?.startsWith(TOP_ROUTE_PREF)) { if (route.name === APP_ROUTE_NAME) { route.children = appRoutes; } - route.path = route.path.replace("_", ""); + route.path = route.path.replace(TOP_ROUTE_PREF, ""); topRoutes.push(route); continue; } diff --git a/src/store/app/index.ts b/src/store/app/index.ts index c4f222f..787585b 100644 --- a/src/store/app/index.ts +++ b/src/store/app/index.ts @@ -1,43 +1,13 @@ import { defineStore } from "pinia"; -interface PageTag { - id: string; - title: string; - path: string; - closable?: boolean; - closible?: boolean; - actived?: boolean; -} - export const useAppStore = defineStore({ id: "app", - state: () => ({ - /** - * 是否为暗模式 - */ + state: (): AppStore => ({ isDarkMode: false, - /** - * 站点标题 - */ title: import.meta.env.VITE_TITLE, - /** - * 站点副标题 - */ subtitle: import.meta.env.VITE_SUBTITLE, - /** - * 页面是否加载中,用于路由首次加载 - */ pageLoding: false, - pageTags: [ - { - id: "/", - title: "首页", - path: "/", - closable: false, - closible: false, - actived: false, - }, - ] as PageTag[], + pageTags: [], }), actions: { /** @@ -46,6 +16,7 @@ export const useAppStore = defineStore({ toggleDark() { this.isDarkMode ? this.setLight() : this.setDark(); }, + /** * 切换为亮模式 */ @@ -54,6 +25,7 @@ export const useAppStore = defineStore({ document.body.classList.remove("dark"); this.isDarkMode = false; }, + /** * 切换为暗模式 */ @@ -62,12 +34,14 @@ export const useAppStore = defineStore({ document.body.classList.add("dark"); this.isDarkMode = true; }, + /** * 设置页面加载loading */ setPageLoading(loading: boolean) { this.pageLoding = loading; }, + /** * 添加页面标签 * @param tag 标签 @@ -83,14 +57,13 @@ export const useAppStore = defineStore({ actived: false, ...tag, }); - console.log(this.pageTags); }, + /** * 移除页面标签 * @param tag 标签 */ delPageTag(tag: PageTag) { - console.log("del page tag"); const index = this.pageTags.findIndex((i) => i.id === tag.id); if (index > -1) { this.pageTags.splice(index, 1); @@ -99,3 +72,35 @@ export const useAppStore = defineStore({ }, persist: !import.meta.env.DEV, }); + +interface AppStore { + /** + * 是否为暗模式 + */ + isDarkMode: boolean; + /** + * 站点标题 + */ + title: string; + /** + * 站点副标题 + */ + subtitle: string; + /** + * 页面是否加载中,用于路由首次加载 + */ + pageLoding: boolean; + /** + * 标签数组 + */ + pageTags: PageTag[]; +} + +interface PageTag { + id: string; + title: string; + path: string; + closable?: boolean; + closible?: boolean; + actived?: boolean; +} diff --git a/src/store/menu/index.ts b/src/store/menu/index.ts new file mode 100644 index 0000000..dda8ab6 --- /dev/null +++ b/src/store/menu/index.ts @@ -0,0 +1,31 @@ +import { defineStore } from "pinia"; +import { MenuItem } from "@/router"; + +export const useMenuStore = defineStore({ + id: "menu", + state: (): MenuStore => { + return { + menus: [], + home: "/", + }; + }, + actions: { + /** + * 设置菜单 + */ + setMenus(menus: MenuItem[]) { + this.menus = menus; + }, + }, +}); + +export interface MenuStore { + /** + * 路由列表 + */ + menus: MenuItem[]; + /** + * 首页路径 + */ + home: string; +} diff --git a/src/store/store/index.ts b/src/store/store/index.ts index 4b8ac9e..8c4af36 100644 --- a/src/store/store/index.ts +++ b/src/store/store/index.ts @@ -2,4 +2,5 @@ import { createPinia } from "pinia"; import persistedstatePlugin from "pinia-plugin-persistedstate"; export const store = createPinia(); -store.use(persistedstatePlugin); \ No newline at end of file + +store.use(persistedstatePlugin); diff --git a/src/store/user/index.ts b/src/store/user/index.ts index d40c101..050b10e 100644 --- a/src/store/user/index.ts +++ b/src/store/user/index.ts @@ -2,31 +2,13 @@ import { defineStore } from "pinia"; export const useUserStore = defineStore({ id: "user", - state: () => { + state: (): UserStore => { return { - /** - * 用户ID - */ id: 0, - /** - * 登录用户名 - */ username: "juetan", - /** - * 用户昵称 - */ nickname: "绝弹", - /** ` - * 用户头像地址 - */ avatar: "https://github.com/juetan.png", - /** - * JWT令牌 - */ accessToken: "", - /** - * 刷新令牌 - */ refreshToken: undefined, }; }, @@ -38,7 +20,11 @@ export const useUserStore = defineStore({ this.accessToken = token; }, - setAccessToken(token: string) { + /** + * 设置访问令牌 + * @param token 令牌 + */ + setAccessToken(token?: string) { this.accessToken = token; }, @@ -52,13 +38,41 @@ export const useUserStore = defineStore({ /** * 设置用户信息 */ - setUser(user: any) { - this.id = user.id; - this.username = user.username; - this.nickname = user.nickname; - this.avatar = user.avatar; - this.accessToken = user.token; + setUser(user: Partial) { + const { id, username, nickname, avatar, accessToken } = user; + id && (this.id = id); + username && (this.username = username); + nickname && (this.nickname = nickname); + avatar && (this.avatar = avatar); + accessToken && (this.accessToken = accessToken); }, }, persist: true, }); + +export interface UserStore { + /** + * 用户ID + */ + id: number; + /** + * 登录用户名 + */ + username: string; + /** + * 用户昵称 + */ + nickname: string; + /** + * 用户头像地址 + */ + avatar?: string; + /** + * JWT令牌 + */ + accessToken?: string; + /** + * 刷新令牌 + */ + refreshToken?: string; +} diff --git a/src/types/vue-router.d.ts b/src/types/vue-router.d.ts index b6d4cbf..fa752e8 100644 --- a/src/types/vue-router.d.ts +++ b/src/types/vue-router.d.ts @@ -1,7 +1,7 @@ -import 'vue-router'; +import "vue-router"; -declare module 'vue-router' { - interface RouteRecordRaw { +declare module "vue-router" { + interface RouteRecordSingleView { parentMeta: { /** * 页面标题 @@ -34,7 +34,7 @@ declare module 'vue-router' { /** * 是否在菜单导航中隐藏 */ - hidden?: boolean; + hide?: boolean; /** * 所需权限 */