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;
/**
* 所需权限
*/