(
+ url: string,
+ params?: AxiosRequestConfig,
+ config?: PureHttpRequestConfig
+ ): Promise {
+ return this.request("post", url, params, config);
+ }
+
+ /** 单独抽离的`get`工具函数 */
+ public get(
+ url: string,
+ params?: AxiosRequestConfig,
+ config?: PureHttpRequestConfig
+ ): Promise {
+ return this.request("get", url, params, config);
+ }
+
+ /** 请求拦截 */
+ private httpInterceptorsRequest(): void {
+ PureHttp.axiosInstance.interceptors.request.use(
+ async (config: PureHttpRequestConfig): Promise => {
+ // 开启进度条动画
+ NProgress.start();
+ // 优先判断post/get等方法是否传入回调,否则执行初始化设置等回调
+ if (typeof config.beforeRequestCallback === "function") {
+ config.beforeRequestCallback(config);
+ return config;
+ }
+ if (PureHttp.initConfig.beforeRequestCallback) {
+ 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);
+ }
+ });
+ },
+ error => {
+ return Promise.reject(error);
+ }
+ );
+ }
+
+ /** 响应拦截 */
+ private httpInterceptorsResponse(): void {
+ const instance = PureHttp.axiosInstance;
+ instance.interceptors.response.use(
+ (response: PureHttpResponse) => {
+ const $config = response.config;
+ // 关闭进度条动画
+ NProgress.done();
+ // 优先判断post/get等方法是否传入回调,否则执行初始化设置等回调
+ if (typeof $config.beforeResponseCallback === "function") {
+ $config.beforeResponseCallback(response);
+ return response.data;
+ }
+ if (PureHttp.initConfig.beforeResponseCallback) {
+ PureHttp.initConfig.beforeResponseCallback(response);
+ return response.data;
+ }
+ return response.data;
+ },
+ (error: PureHttpError) => {
+ const $error = error;
+ $error.isCancelRequest = Axios.isCancel($error);
+ // 关闭进度条动画
+ NProgress.done();
+ // 所有的响应异常 区分来源为取消请求/非取消请求
+ return Promise.reject($error);
+ }
+ );
+ }
+}
+
+export const http = new PureHttp();
diff --git a/src/api/service/mockRequest.ts b/src/api/service/mockRequest.ts
new file mode 100644
index 0000000..8eebe92
--- /dev/null
+++ b/src/api/service/mockRequest.ts
@@ -0,0 +1,191 @@
+import Axios, {
+ type AxiosInstance,
+ type AxiosRequestConfig,
+ type CustomParamsSerializer
+} from "axios";
+import type {
+ PureHttpError,
+ PureHttpRequestConfig,
+ PureHttpResponse,
+ RequestMethods
+} from "./types";
+import { stringify } from "qs";
+import NProgress from "../../utils/progress";
+import { formatToken, getToken } from "@/utils/auth";
+import { useUserStoreHook } from "@/store/modules/user";
+
+// 相关配置请参考:www.axios-js.com/zh-cn/docs/#axios-request-config-1
+const defaultConfig: AxiosRequestConfig = {
+ timeout: import.meta.env.VITE_BASE_API_TIMEOUT,
+ baseURL: import.meta.env.VITE_MOCK_BASE_API || "/mock",
+ headers: {
+ Accept: "application/json, text/plain, */*",
+ "Content-Type": "application/json",
+ "X-Requested-With": "XMLHttpRequest"
+ },
+ // 数组格式参数序列化(https://github.com/axios/axios/issues/5142)
+ paramsSerializer: {
+ serialize: stringify as unknown as CustomParamsSerializer
+ }
+};
+
+class PureHttp {
+ /** `token`过期后,暂存待执行的请求 */
+ private static requests = [];
+ /** 防止重复刷新`token` */
+ private static isRefreshing = false;
+ /** 初始化配置对象 */
+ private static initConfig: PureHttpRequestConfig = {};
+ /** 保存当前`Axios`实例对象 */
+ private static axiosInstance: AxiosInstance = Axios.create(defaultConfig);
+
+ constructor() {
+ this.httpInterceptorsRequest();
+ this.httpInterceptorsResponse();
+ }
+
+ /** 重连原始请求 */
+ private static retryOriginalRequest(config: PureHttpRequestConfig) {
+ return new Promise(resolve => {
+ PureHttp.requests.push((token: string) => {
+ config.headers["Authorization"] = formatToken(token);
+ resolve(config);
+ });
+ });
+ }
+
+ /** 通用请求工具函数 */
+ public request(
+ method: RequestMethods,
+ url: string,
+ param?: AxiosRequestConfig,
+ axiosConfig?: PureHttpRequestConfig
+ ): Promise {
+ const config = {
+ method,
+ url,
+ ...param,
+ ...axiosConfig
+ } as PureHttpRequestConfig;
+
+ // 单独处理自定义请求/响应回调
+ return new Promise((resolve, reject) => {
+ PureHttp.axiosInstance
+ .request(config)
+ .then((response: undefined) => {
+ resolve(response);
+ })
+ .catch(error => {
+ reject(error);
+ });
+ });
+ }
+
+ /** 单独抽离的`post`工具函数 */
+ public post(
+ url: string,
+ params?: AxiosRequestConfig,
+ config?: PureHttpRequestConfig
+ ): Promise {
+ return this.request("post", url, params, config);
+ }
+
+ /** 单独抽离的`get`工具函数 */
+ public get(
+ url: string,
+ params?: AxiosRequestConfig,
+ config?: PureHttpRequestConfig
+ ): Promise {
+ return this.request("get", url, params, config);
+ }
+
+ /** 请求拦截 */
+ private httpInterceptorsRequest(): void {
+ PureHttp.axiosInstance.interceptors.request.use(
+ async (config: PureHttpRequestConfig): Promise => {
+ // 开启进度条动画
+ NProgress.start();
+ // 优先判断post/get等方法是否传入回调,否则执行初始化设置等回调
+ if (typeof config.beforeRequestCallback === "function") {
+ config.beforeRequestCallback(config);
+ return config;
+ }
+ if (PureHttp.initConfig.beforeRequestCallback) {
+ 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);
+ }
+ });
+ },
+ error => {
+ return Promise.reject(error);
+ }
+ );
+ }
+
+ /** 响应拦截 */
+ private httpInterceptorsResponse(): void {
+ const instance = PureHttp.axiosInstance;
+ instance.interceptors.response.use(
+ (response: PureHttpResponse) => {
+ const $config = response.config;
+ // 关闭进度条动画
+ NProgress.done();
+ // 优先判断post/get等方法是否传入回调,否则执行初始化设置等回调
+ if (typeof $config.beforeResponseCallback === "function") {
+ $config.beforeResponseCallback(response);
+ return response.data;
+ }
+ if (PureHttp.initConfig.beforeResponseCallback) {
+ PureHttp.initConfig.beforeResponseCallback(response);
+ return response.data;
+ }
+ return response.data;
+ },
+ (error: PureHttpError) => {
+ const $error = error;
+ $error.isCancelRequest = Axios.isCancel($error);
+ // 关闭进度条动画
+ NProgress.done();
+ // 所有的响应异常 区分来源为取消请求/非取消请求
+ return Promise.reject($error);
+ }
+ );
+ }
+}
+
+export const http = new PureHttp();
diff --git a/src/api/service/types.d.ts b/src/api/service/types.d.ts
new file mode 100644
index 0000000..ef7c25f
--- /dev/null
+++ b/src/api/service/types.d.ts
@@ -0,0 +1,47 @@
+import type {
+ Method,
+ AxiosError,
+ AxiosResponse,
+ AxiosRequestConfig
+} from "axios";
+
+export type resultType = {
+ accessToken?: string;
+};
+
+export type RequestMethods = Extract<
+ Method,
+ "get" | "post" | "put" | "delete" | "patch" | "option" | "head"
+>;
+
+export interface PureHttpError extends AxiosError {
+ isCancelRequest?: boolean;
+}
+
+export interface PureHttpResponse extends AxiosResponse {
+ config: PureHttpRequestConfig;
+}
+
+export interface PureHttpRequestConfig extends AxiosRequestConfig {
+ beforeRequestCallback?: (request: PureHttpRequestConfig) => void;
+ beforeResponseCallback?: (response: PureHttpResponse) => void;
+}
+
+export default class PureHttp {
+ request(
+ method: RequestMethods,
+ url: string,
+ param?: AxiosRequestConfig,
+ axiosConfig?: PureHttpRequestConfig
+ ): Promise;
+ post(
+ url: string,
+ params?: P,
+ config?: PureHttpRequestConfig
+ ): Promise;
+ get(
+ url: string,
+ params?: P,
+ config?: PureHttpRequestConfig
+ ): Promise;
+}
diff --git a/src/api/v1/i18n.ts b/src/api/v1/i18n.ts
new file mode 100644
index 0000000..23d2a61
--- /dev/null
+++ b/src/api/v1/i18n.ts
@@ -0,0 +1,9 @@
+import { http } from "@/api/service/mockRequest";
+import type { Result } from "@/types/store/baseStoreState";
+
+/**
+ * * 获取多语言内容
+ */
+export const fetchGetI18n = () => {
+ return http.request>("get", "getI18n");
+};
diff --git a/src/api/v1/routes.ts b/src/api/v1/routes.ts
new file mode 100644
index 0000000..58b1699
--- /dev/null
+++ b/src/api/v1/routes.ts
@@ -0,0 +1,10 @@
+import { http } from "@/api/service/mockRequest";
+
+type Result = {
+ success: boolean;
+ data: Array;
+};
+
+export const getAsyncRoutes = () => {
+ return http.request("get", "/get-async-routes");
+};
diff --git a/src/api/v1/system.ts b/src/api/v1/system.ts
new file mode 100644
index 0000000..e4db238
--- /dev/null
+++ b/src/api/v1/system.ts
@@ -0,0 +1,85 @@
+import { http } from "@/utils/http";
+
+type Result = {
+ success: boolean;
+ data?: Array;
+};
+
+type ResultTable = {
+ success: boolean;
+ data?: {
+ /** 列表数据 */
+ list: Array;
+ /** 总条目数 */
+ total?: number;
+ /** 每页显示条目个数 */
+ pageSize?: number;
+ /** 当前页数 */
+ currentPage?: number;
+ };
+};
+
+/** 获取系统管理-用户管理列表 */
+export const getUserList = (data?: object) => {
+ return http.request("post", "/user", { data });
+};
+
+/** 系统管理-用户管理-获取所有角色列表 */
+export const getAllRoleList = () => {
+ return http.request("get", "/list-all-role");
+};
+
+/** 系统管理-用户管理-根据userId,获取对应角色id列表(userId:用户id) */
+export const getRoleIds = (data?: object) => {
+ return http.request("post", "/list-role-ids", { data });
+};
+
+/** 获取系统管理-角色管理列表 */
+export const getRoleList = (data?: object) => {
+ return http.request("post", "/role", { data });
+};
+
+/** 获取系统管理-菜单管理列表 */
+export const getMenuList = (data?: object) => {
+ return http.request("post", "/menu", { data });
+};
+
+/** 获取系统管理-部门管理列表 */
+export const getDeptList = (data?: object) => {
+ return http.request("post", "/dept", { data });
+};
+
+/** 获取系统监控-在线用户列表 */
+export const getOnlineLogsList = (data?: object) => {
+ return http.request("post", "/online-logs", { data });
+};
+
+/** 获取系统监控-登录日志列表 */
+export const getLoginLogsList = (data?: object) => {
+ return http.request("post", "/login-logs", { data });
+};
+
+/** 获取系统监控-操作日志列表 */
+export const getOperationLogsList = (data?: object) => {
+ return http.request("post", "/operation-logs", { data });
+};
+
+/** 获取系统监控-系统日志列表 */
+export const getSystemLogsList = (data?: object) => {
+ return http.request("post", "/system-logs", { data });
+};
+
+/** 获取系统监控-系统日志-根据 id 查日志详情 */
+export const getSystemLogsDetail = (data?: object) => {
+ return http.request("post", "/system-logs-detail", { data });
+};
+
+/** 获取角色管理-权限-菜单权限 */
+export const getRoleMenu = (data?: object) => {
+ return http.request("post", "/role-menu", { data });
+};
+
+/** 获取角色管理-权限-菜单权限-根据角色 id 查对应菜单 */
+export const getRoleMenuIds = (data?: object) => {
+ return http.request("post", "/role-menu-ids", { data });
+};
diff --git a/src/api/v1/user.ts b/src/api/v1/user.ts
new file mode 100644
index 0000000..3d044a9
--- /dev/null
+++ b/src/api/v1/user.ts
@@ -0,0 +1,45 @@
+import { http } from "@/api/service/mockRequest";
+
+export type UserResult = {
+ success: boolean;
+ data: {
+ /** 头像 */
+ avatar: string;
+ /** 用户名 */
+ username: string;
+ /** 昵称 */
+ nickname: string;
+ /** 当前登录用户的角色 */
+ roles: Array;
+ /** 按钮级别权限 */
+ permissions: Array;
+ /** `token` */
+ accessToken: string;
+ /** 用于调用刷新`accessToken`的接口时所需的`token` */
+ refreshToken: string;
+ /** `accessToken`的过期时间(格式'xxxx/xx/xx xx:xx:xx') */
+ expires: Date;
+ };
+};
+
+export type RefreshTokenResult = {
+ success: boolean;
+ data: {
+ /** `token` */
+ accessToken: string;
+ /** 用于调用刷新`accessToken`的接口时所需的`token` */
+ refreshToken: string;
+ /** `accessToken`的过期时间(格式'xxxx/xx/xx xx:xx:xx') */
+ expires: Date;
+ };
+};
+
+/** 登录 */
+export const getLogin = (data?: object) => {
+ return http.request("post", "/login", { data });
+};
+
+/** 刷新`token` */
+export const refreshTokenApi = (data?: object) => {
+ return http.request("post", "/refresh-token", { data });
+};
diff --git a/src/assets/iconfont/iconfont.css b/src/assets/iconfont/iconfont.css
new file mode 100644
index 0000000..9a153df
--- /dev/null
+++ b/src/assets/iconfont/iconfont.css
@@ -0,0 +1,27 @@
+@font-face {
+ font-family: "iconfont"; /* Project id 2208059 */
+ src:
+ url("iconfont.woff2?t=1671895108120") format("woff2"),
+ url("iconfont.woff?t=1671895108120") format("woff"),
+ url("iconfont.ttf?t=1671895108120") format("truetype");
+}
+
+.iconfont {
+ font-family: "iconfont" !important;
+ font-size: 16px;
+ font-style: normal;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.pure-iconfont-tabs:before {
+ content: "\e63e";
+}
+
+.pure-iconfont-logo:before {
+ content: "\e620";
+}
+
+.pure-iconfont-new:before {
+ content: "\e615";
+}
diff --git a/src/assets/iconfont/iconfont.js b/src/assets/iconfont/iconfont.js
new file mode 100644
index 0000000..64d8bd8
--- /dev/null
+++ b/src/assets/iconfont/iconfont.js
@@ -0,0 +1,69 @@
+(window._iconfont_svg_string_2208059 =
+ ''),
+ (function (e) {
+ var t = (t = document.getElementsByTagName("script"))[t.length - 1],
+ c = t.getAttribute("data-injectcss"),
+ t = t.getAttribute("data-disable-injectsvg");
+ if (!t) {
+ var n,
+ l,
+ i,
+ o,
+ a,
+ h = function (t, c) {
+ c.parentNode.insertBefore(t, c);
+ };
+ if (c && !e.__iconfont__svg__cssinject__) {
+ e.__iconfont__svg__cssinject__ = !0;
+ try {
+ document.write(
+ ""
+ );
+ } catch (t) {
+ console && console.log(t);
+ }
+ }
+ (n = function () {
+ var t,
+ c = document.createElement("div");
+ (c.innerHTML = e._iconfont_svg_string_2208059),
+ (c = c.getElementsByTagName("svg")[0]) &&
+ (c.setAttribute("aria-hidden", "true"),
+ (c.style.position = "absolute"),
+ (c.style.width = 0),
+ (c.style.height = 0),
+ (c.style.overflow = "hidden"),
+ (c = c),
+ (t = document.body).firstChild
+ ? h(c, t.firstChild)
+ : t.appendChild(c));
+ }),
+ document.addEventListener
+ ? ~["complete", "loaded", "interactive"].indexOf(document.readyState)
+ ? setTimeout(n, 0)
+ : ((l = function () {
+ document.removeEventListener("DOMContentLoaded", l, !1), n();
+ }),
+ document.addEventListener("DOMContentLoaded", l, !1))
+ : document.attachEvent &&
+ ((i = n),
+ (o = e.document),
+ (a = !1),
+ v(),
+ (o.onreadystatechange = function () {
+ "complete" == o.readyState &&
+ ((o.onreadystatechange = null), d());
+ }));
+ }
+ function d() {
+ a || ((a = !0), i());
+ }
+ function v() {
+ try {
+ o.documentElement.doScroll("left");
+ } catch (t) {
+ return void setTimeout(v, 50);
+ }
+ d();
+ }
+ })(window);
diff --git a/src/assets/iconfont/iconfont.json b/src/assets/iconfont/iconfont.json
new file mode 100644
index 0000000..cec4806
--- /dev/null
+++ b/src/assets/iconfont/iconfont.json
@@ -0,0 +1,30 @@
+{
+ "id": "2208059",
+ "name": "pure-admin",
+ "font_family": "iconfont",
+ "css_prefix_text": "pure-iconfont-",
+ "description": "pure-admin-iconfont",
+ "glyphs": [
+ {
+ "icon_id": "20594647",
+ "name": "Tabs",
+ "font_class": "tabs",
+ "unicode": "e63e",
+ "unicode_decimal": 58942
+ },
+ {
+ "icon_id": "22129506",
+ "name": "PureLogo",
+ "font_class": "logo",
+ "unicode": "e620",
+ "unicode_decimal": 58912
+ },
+ {
+ "icon_id": "7795615",
+ "name": "New",
+ "font_class": "new",
+ "unicode": "e615",
+ "unicode_decimal": 58901
+ }
+ ]
+}
diff --git a/src/assets/iconfont/iconfont.ttf b/src/assets/iconfont/iconfont.ttf
new file mode 100644
index 0000000..82efcf8
Binary files /dev/null and b/src/assets/iconfont/iconfont.ttf differ
diff --git a/src/assets/iconfont/iconfont.woff b/src/assets/iconfont/iconfont.woff
new file mode 100644
index 0000000..0fdaa0a
Binary files /dev/null and b/src/assets/iconfont/iconfont.woff differ
diff --git a/src/assets/iconfont/iconfont.woff2 b/src/assets/iconfont/iconfont.woff2
new file mode 100644
index 0000000..e957d74
Binary files /dev/null and b/src/assets/iconfont/iconfont.woff2 differ
diff --git a/src/assets/login/avatar.svg b/src/assets/login/avatar.svg
new file mode 100644
index 0000000..a63d2b1
--- /dev/null
+++ b/src/assets/login/avatar.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/login/bg.png b/src/assets/login/bg.png
new file mode 100644
index 0000000..8cdd300
Binary files /dev/null and b/src/assets/login/bg.png differ
diff --git a/src/assets/login/illustration.svg b/src/assets/login/illustration.svg
new file mode 100644
index 0000000..b58ffd0
--- /dev/null
+++ b/src/assets/login/illustration.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/status/403.svg b/src/assets/status/403.svg
new file mode 100644
index 0000000..ba3ce29
--- /dev/null
+++ b/src/assets/status/403.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/status/404.svg b/src/assets/status/404.svg
new file mode 100644
index 0000000..aacb740
--- /dev/null
+++ b/src/assets/status/404.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/status/500.svg b/src/assets/status/500.svg
new file mode 100644
index 0000000..ea23a37
--- /dev/null
+++ b/src/assets/status/500.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/svg/back_top.svg b/src/assets/svg/back_top.svg
new file mode 100644
index 0000000..f8e6aa0
--- /dev/null
+++ b/src/assets/svg/back_top.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/svg/dark.svg b/src/assets/svg/dark.svg
new file mode 100644
index 0000000..b5c4d2d
--- /dev/null
+++ b/src/assets/svg/dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/svg/day.svg b/src/assets/svg/day.svg
new file mode 100644
index 0000000..b760034
--- /dev/null
+++ b/src/assets/svg/day.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/svg/enter_outlined.svg b/src/assets/svg/enter_outlined.svg
new file mode 100644
index 0000000..45e0baf
--- /dev/null
+++ b/src/assets/svg/enter_outlined.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/svg/exit_screen.svg b/src/assets/svg/exit_screen.svg
new file mode 100644
index 0000000..007c0b6
--- /dev/null
+++ b/src/assets/svg/exit_screen.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/svg/full_screen.svg b/src/assets/svg/full_screen.svg
new file mode 100644
index 0000000..fff93a5
--- /dev/null
+++ b/src/assets/svg/full_screen.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/svg/globalization.svg b/src/assets/svg/globalization.svg
new file mode 100644
index 0000000..5f6bce6
--- /dev/null
+++ b/src/assets/svg/globalization.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/svg/keyboard_esc.svg b/src/assets/svg/keyboard_esc.svg
new file mode 100644
index 0000000..bd67165
--- /dev/null
+++ b/src/assets/svg/keyboard_esc.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/svg/system.svg b/src/assets/svg/system.svg
new file mode 100644
index 0000000..9ad39a5
--- /dev/null
+++ b/src/assets/svg/system.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/table-bar/collapse.svg b/src/assets/table-bar/collapse.svg
new file mode 100644
index 0000000..0823ae6
--- /dev/null
+++ b/src/assets/table-bar/collapse.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/table-bar/drag.svg b/src/assets/table-bar/drag.svg
new file mode 100644
index 0000000..8ac32a7
--- /dev/null
+++ b/src/assets/table-bar/drag.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/table-bar/expand.svg b/src/assets/table-bar/expand.svg
new file mode 100644
index 0000000..bb41c35
--- /dev/null
+++ b/src/assets/table-bar/expand.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/table-bar/refresh.svg b/src/assets/table-bar/refresh.svg
new file mode 100644
index 0000000..140288c
--- /dev/null
+++ b/src/assets/table-bar/refresh.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/table-bar/settings.svg b/src/assets/table-bar/settings.svg
new file mode 100644
index 0000000..4ecd077
--- /dev/null
+++ b/src/assets/table-bar/settings.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/user.jpg b/src/assets/user.jpg
new file mode 100644
index 0000000..a2973ac
Binary files /dev/null and b/src/assets/user.jpg differ
diff --git a/src/components/AnimateSelector/index.ts b/src/components/AnimateSelector/index.ts
new file mode 100644
index 0000000..87c9008
--- /dev/null
+++ b/src/components/AnimateSelector/index.ts
@@ -0,0 +1,7 @@
+import { withInstall } from "@pureadmin/utils";
+import reAnimateSelector from "./src/index.vue";
+
+/** [animate.css](https://animate.style/) 选择器组件 */
+export const ReAnimateSelector = withInstall(reAnimateSelector);
+
+export default ReAnimateSelector;
diff --git a/src/components/AnimateSelector/src/animate.ts b/src/components/AnimateSelector/src/animate.ts
new file mode 100644
index 0000000..2b0593c
--- /dev/null
+++ b/src/components/AnimateSelector/src/animate.ts
@@ -0,0 +1,114 @@
+export const animates = [
+ /* Attention seekers */
+ "bounce",
+ "flash",
+ "pulse",
+ "rubberBand",
+ "shakeX",
+ "headShake",
+ "swing",
+ "tada",
+ "wobble",
+ "jello",
+ "heartBeat",
+ /* Back entrances */
+ "backInDown",
+ "backInLeft",
+ "backInRight",
+ "backInUp",
+ /* Back exits */
+ "backOutDown",
+ "backOutLeft",
+ "backOutRight",
+ "backOutUp",
+ /* Bouncing entrances */
+ "bounceIn",
+ "bounceInDown",
+ "bounceInLeft",
+ "bounceInRight",
+ "bounceInUp",
+ /* Bouncing exits */
+ "bounceOut",
+ "bounceOutDown",
+ "bounceOutLeft",
+ "bounceOutRight",
+ "bounceOutUp",
+ /* Fading entrances */
+ "fadeIn",
+ "fadeInDown",
+ "fadeInDownBig",
+ "fadeInLeft",
+ "fadeInLeftBig",
+ "fadeInRight",
+ "fadeInRightBig",
+ "fadeInUp",
+ "fadeInUpBig",
+ "fadeInTopLeft",
+ "fadeInTopRight",
+ "fadeInBottomLeft",
+ "fadeInBottomRight",
+ /* Fading exits */
+ "fadeOut",
+ "fadeOutDown",
+ "fadeOutDownBig",
+ "fadeOutLeft",
+ "fadeOutLeftBig",
+ "fadeOutRight",
+ "fadeOutRightBig",
+ "fadeOutUp",
+ "fadeOutUpBig",
+ "fadeOutTopLeft",
+ "fadeOutTopRight",
+ "fadeOutBottomRight",
+ "fadeOutBottomLeft",
+ /* Flippers */
+ "flip",
+ "flipInX",
+ "flipInY",
+ "flipOutX",
+ "flipOutY",
+ /* Lightspeed */
+ "lightSpeedInRight",
+ "lightSpeedInLeft",
+ "lightSpeedOutRight",
+ "lightSpeedOutLeft",
+ /* Rotating entrances */
+ "rotateIn",
+ "rotateInDownLeft",
+ "rotateInDownRight",
+ "rotateInUpLeft",
+ "rotateInUpRight",
+ /* Rotating exits */
+ "rotateOut",
+ "rotateOutDownLeft",
+ "rotateOutDownRight",
+ "rotateOutUpLeft",
+ "rotateOutUpRight",
+ /* Specials */
+ "hinge",
+ "jackInTheBox",
+ "rollIn",
+ "rollOut",
+ /* Zooming entrances */
+ "zoomIn",
+ "zoomInDown",
+ "zoomInLeft",
+ "zoomInRight",
+ "zoomInUp",
+ /* Zooming exits */
+ "zoomOut",
+ "zoomOutDown",
+ "zoomOutLeft",
+ "zoomOutRight",
+ "zoomOutUp",
+ /* Sliding entrances */
+ "slideInDown",
+ "slideInLeft",
+ "slideInRight",
+ "slideInUp",
+ /* Sliding exits */
+ "slideOutDown",
+ "slideOutLeft",
+ "slideOutRight",
+ "slideOutUp"
+];
diff --git a/src/components/AnimateSelector/src/index.vue b/src/components/AnimateSelector/src/index.vue
new file mode 100644
index 0000000..e10056b
--- /dev/null
+++ b/src/components/AnimateSelector/src/index.vue
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+ -
+
+ {{ animate }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Auth/index.ts b/src/components/Auth/index.ts
new file mode 100644
index 0000000..975ed2c
--- /dev/null
+++ b/src/components/Auth/index.ts
@@ -0,0 +1,5 @@
+import auth from "./src/auth";
+
+const Auth = auth;
+
+export { Auth };
diff --git a/src/components/Auth/src/auth.tsx b/src/components/Auth/src/auth.tsx
new file mode 100644
index 0000000..d2cf9b3
--- /dev/null
+++ b/src/components/Auth/src/auth.tsx
@@ -0,0 +1,20 @@
+import { defineComponent, Fragment } from "vue";
+import { hasAuth } from "@/router/utils";
+
+export default defineComponent({
+ name: "Auth",
+ props: {
+ value: {
+ type: undefined,
+ default: []
+ }
+ },
+ setup(props, { slots }) {
+ return () => {
+ if (!slots) return null;
+ return hasAuth(props.value) ? (
+ {slots.default?.()}
+ ) : null;
+ };
+ }
+});
diff --git a/src/components/BaseDialog/index.ts b/src/components/BaseDialog/index.ts
new file mode 100644
index 0000000..b471764
--- /dev/null
+++ b/src/components/BaseDialog/index.ts
@@ -0,0 +1,69 @@
+import { ref } from "vue";
+import reDialog from "./index.vue";
+import { useTimeoutFn } from "@vueuse/core";
+import { withInstall } from "@pureadmin/utils";
+import type {
+ EventType,
+ ArgsType,
+ DialogProps,
+ ButtonProps,
+ DialogOptions
+} from "./type";
+
+const dialogStore = ref>([]);
+
+/** 打开弹框 */
+const addDialog = (options: DialogOptions) => {
+ const open = () =>
+ dialogStore.value.push(Object.assign(options, { visible: true }));
+ if (options?.openDelay) {
+ useTimeoutFn(() => {
+ open();
+ }, options.openDelay);
+ } else {
+ open();
+ }
+};
+
+/** 关闭弹框 */
+const closeDialog = (options: DialogOptions, index: number, args?: any) => {
+ dialogStore.value[index].visible = false;
+ options.closeCallBack && options.closeCallBack({ options, index, args });
+
+ const closeDelay = options?.closeDelay ?? 200;
+ useTimeoutFn(() => {
+ dialogStore.value.splice(index, 1);
+ }, closeDelay);
+};
+
+/**
+ * @description 更改弹框自身属性值
+ * @param value 属性值
+ * @param key 属性,默认`title`
+ * @param index 弹框索引(默认`0`,代表只有一个弹框,对于嵌套弹框要改哪个弹框的属性值就把该弹框索引赋给`index`)
+ */
+const updateDialog = (value: any, key = "title", index = 0) => {
+ dialogStore.value[index][key] = value;
+};
+
+/** 关闭所有弹框 */
+const closeAllDialog = () => {
+ dialogStore.value = [];
+};
+
+/** 千万别忘了在下面这三处引入并注册下,放心注册,不使用`addDialog`调用就不会被挂载
+ * https://github.com/pure-admin/vue-pure-admin/blob/main/src/App.vue#L4
+ * https://github.com/pure-admin/vue-pure-admin/blob/main/src/App.vue#L12
+ * https://github.com/pure-admin/vue-pure-admin/blob/main/src/App.vue#L22
+ */
+const ReDialog = withInstall(reDialog);
+
+export type { EventType, ArgsType, DialogProps, ButtonProps, DialogOptions };
+export {
+ ReDialog,
+ dialogStore,
+ addDialog,
+ closeDialog,
+ updateDialog,
+ closeAllDialog
+};
diff --git a/src/components/BaseDialog/index.vue b/src/components/BaseDialog/index.vue
new file mode 100644
index 0000000..23a0106
--- /dev/null
+++ b/src/components/BaseDialog/index.vue
@@ -0,0 +1,206 @@
+
+
+
+
+
+
+
+ {{ options?.title }}
+ {
+ fullscreen = !fullscreen;
+ eventsCallBack(
+ 'fullscreenCallBack',
+ { ...options, fullscreen },
+ index,
+ true
+ );
+ }
+ "
+ >
+
+
+
+
+
+ handleClose(options, index, args)"
+ />
+
+
+
+
+
+
+
+
+
+ {{ btn?.label }}
+
+
+
+ {{ btn?.label }}
+
+
+
+
+
+
diff --git a/src/components/BaseDialog/type.ts b/src/components/BaseDialog/type.ts
new file mode 100644
index 0000000..7efbe20
--- /dev/null
+++ b/src/components/BaseDialog/type.ts
@@ -0,0 +1,275 @@
+import type { CSSProperties, VNode, Component } from "vue";
+
+type DoneFn = (cancel?: boolean) => void;
+type EventType =
+ | "open"
+ | "close"
+ | "openAutoFocus"
+ | "closeAutoFocus"
+ | "fullscreenCallBack";
+type ArgsType = {
+ /** `cancel` 点击取消按钮、`sure` 点击确定按钮、`close` 点击右上角关闭按钮或空白页或按下了esc键 */
+ command: "cancel" | "sure" | "close";
+};
+type ButtonType =
+ | "primary"
+ | "success"
+ | "warning"
+ | "danger"
+ | "info"
+ | "text";
+
+/** https://element-plus.org/zh-CN/component/dialog.html#attributes */
+type DialogProps = {
+ /** `Dialog` 的显示与隐藏 */
+ visible?: boolean;
+ /** `Dialog` 的标题 */
+ title?: string;
+ /** `Dialog` 的宽度,默认 `50%` */
+ width?: string | number;
+ /** 是否为全屏 `Dialog`(会一直处于全屏状态,除非弹框关闭),默认 `false`,`fullscreen` 和 `fullscreenIcon` 都传时只有 `fullscreen` 会生效 */
+ fullscreen?: boolean;
+ /** 是否显示全屏操作图标,默认 `false`,`fullscreen` 和 `fullscreenIcon` 都传时只有 `fullscreen` 会生效 */
+ fullscreenIcon?: boolean;
+ /** `Dialog CSS` 中的 `margin-top` 值,默认 `15vh` */
+ top?: string;
+ /** 是否需要遮罩层,默认 `true` */
+ modal?: boolean;
+ /** `Dialog` 自身是否插入至 `body` 元素上。嵌套的 `Dialog` 必须指定该属性并赋值为 `true`,默认 `false` */
+ appendToBody?: boolean;
+ /** 是否在 `Dialog` 出现时将 `body` 滚动锁定,默认 `true` */
+ lockScroll?: boolean;
+ /** `Dialog` 的自定义类名 */
+ class?: string;
+ /** `Dialog` 的自定义样式 */
+ style?: CSSProperties;
+ /** `Dialog` 打开的延时时间,单位毫秒,默认 `0` */
+ openDelay?: number;
+ /** `Dialog` 关闭的延时时间,单位毫秒,默认 `0` */
+ closeDelay?: number;
+ /** 是否可以通过点击 `modal` 关闭 `Dialog`,默认 `true` */
+ closeOnClickModal?: boolean;
+ /** 是否可以通过按下 `ESC` 关闭 `Dialog`,默认 `true` */
+ closeOnPressEscape?: boolean;
+ /** 是否显示关闭按钮,默认 `true` */
+ showClose?: boolean;
+ /** 关闭前的回调,会暂停 `Dialog` 的关闭. 回调函数内执行 `done` 参数方法的时候才是真正关闭对话框的时候 */
+ beforeClose?: (done: DoneFn) => void;
+ /** 为 `Dialog` 启用可拖拽功能,默认 `false` */
+ draggable?: boolean;
+ /** 是否让 `Dialog` 的 `header` 和 `footer` 部分居中排列,默认 `false` */
+ center?: boolean;
+ /** 是否水平垂直对齐对话框,默认 `false` */
+ alignCenter?: boolean;
+ /** 当关闭 `Dialog` 时,销毁其中的元素,默认 `false` */
+ destroyOnClose?: boolean;
+};
+
+//element-plus.org/zh-CN/component/popconfirm.html#attributes
+type Popconfirm = {
+ /** 标题 */
+ title?: string;
+ /** 确定按钮文字 */
+ confirmButtonText?: string;
+ /** 取消按钮文字 */
+ cancelButtonText?: string;
+ /** 确定按钮类型,默认 `primary` */
+ confirmButtonType?: ButtonType;
+ /** 取消按钮类型,默认 `text` */
+ cancelButtonType?: ButtonType;
+ /** 自定义图标,默认 `QuestionFilled` */
+ icon?: string | Component;
+ /** `Icon` 颜色,默认 `#f90` */
+ iconColor?: string;
+ /** 是否隐藏 `Icon`,默认 `false` */
+ hideIcon?: boolean;
+ /** 关闭时的延迟,默认 `200` */
+ hideAfter?: number;
+ /** 是否将 `popover` 的下拉列表插入至 `body` 元素,默认 `true` */
+ teleported?: boolean;
+ /** 当 `popover` 组件长时间不触发且 `persistent` 属性设置为 `false` 时, `popover` 将会被删除,默认 `false` */
+ persistent?: boolean;
+ /** 弹层宽度,最小宽度 `150px`,默认 `150` */
+ width?: string | number;
+};
+
+type BtnClickDialog = {
+ options?: DialogOptions;
+ index?: number;
+};
+type BtnClickButton = {
+ btn?: ButtonProps;
+ index?: number;
+};
+/** https://element-plus.org/zh-CN/component/button.html#button-attributes */
+type ButtonProps = {
+ /** 按钮文字 */
+ label: string;
+ /** 按钮尺寸 */
+ size?: "large" | "default" | "small";
+ /** 按钮类型 */
+ type?: "primary" | "success" | "warning" | "danger" | "info";
+ /** 是否为朴素按钮,默认 `false` */
+ plain?: boolean;
+ /** 是否为文字按钮,默认 `false` */
+ text?: boolean;
+ /** 是否显示文字按钮背景颜色,默认 `false` */
+ bg?: boolean;
+ /** 是否为链接按钮,默认 `false` */
+ link?: boolean;
+ /** 是否为圆角按钮,默认 `false` */
+ round?: boolean;
+ /** 是否为圆形按钮,默认 `false` */
+ circle?: boolean;
+ /** 确定按钮的 `Popconfirm` 气泡确认框相关配置 */
+ popconfirm?: Popconfirm;
+ /** 是否为加载中状态,默认 `false` */
+ loading?: boolean;
+ /** 自定义加载中状态图标组件 */
+ loadingIcon?: string | Component;
+ /** 按钮是否为禁用状态,默认 `false` */
+ disabled?: boolean;
+ /** 图标组件 */
+ icon?: string | Component;
+ /** 是否开启原生 `autofocus` 属性,默认 `false` */
+ autofocus?: boolean;
+ /** 原生 `type` 属性,默认 `button` */
+ nativeType?: "button" | "submit" | "reset";
+ /** 自动在两个中文字符之间插入空格 */
+ autoInsertSpace?: boolean;
+ /** 自定义按钮颜色, 并自动计算 `hover` 和 `active` 触发后的颜色 */
+ color?: string;
+ /** `dark` 模式, 意味着自动设置 `color` 为 `dark` 模式的颜色,默认 `false` */
+ dark?: boolean;
+ /** 自定义元素标签 */
+ tag?: string | Component;
+ /** 点击按钮后触发的回调 */
+ btnClick?: ({
+ dialog,
+ button
+ }: {
+ /** 当前 `Dialog` 信息 */
+ dialog: BtnClickDialog;
+ /** 当前 `button` 信息 */
+ button: BtnClickButton;
+ }) => void;
+};
+
+interface DialogOptions extends DialogProps {
+ /** 内容区组件的 `props`,可通过 `defineProps` 接收 */
+ props?: any;
+ /** 是否隐藏 `Dialog` 按钮操作区的内容 */
+ hideFooter?: boolean;
+ /** 确定按钮的 `Popconfirm` 气泡确认框相关配置 */
+ popconfirm?: Popconfirm;
+ /** 点击确定按钮后是否开启 `loading` 加载动画 */
+ sureBtnLoading?: boolean;
+ /**
+ * @description 自定义对话框标题的内容渲染器
+ * @see {@link https://element-plus.org/zh-CN/component/dialog.html#%E8%87%AA%E5%AE%9A%E4%B9%89%E5%A4%B4%E9%83%A8}
+ */
+ headerRenderer?: ({
+ close,
+ titleId,
+ titleClass
+ }: {
+ close: Function;
+ titleId: string;
+ titleClass: string;
+ }) => VNode | Component;
+ /** 自定义内容渲染器 */
+ contentRenderer?: ({
+ options,
+ index
+ }: {
+ options: DialogOptions;
+ index: number;
+ }) => VNode | Component;
+ /** 自定义按钮操作区的内容渲染器,会覆盖`footerButtons`以及默认的 `取消` 和 `确定` 按钮 */
+ footerRenderer?: ({
+ options,
+ index
+ }: {
+ options: DialogOptions;
+ index: number;
+ }) => VNode | Component;
+ /** 自定义底部按钮操作 */
+ footerButtons?: Array;
+ /** `Dialog` 打开后的回调 */
+ open?: ({
+ options,
+ index
+ }: {
+ options: DialogOptions;
+ index: number;
+ }) => void;
+ /** `Dialog` 关闭后的回调(只有点击右上角关闭按钮或空白页或按下了esc键关闭页面时才会触发) */
+ close?: ({
+ options,
+ index
+ }: {
+ options: DialogOptions;
+ index: number;
+ }) => void;
+ /** `Dialog` 关闭后的回调。 `args` 返回的 `command` 值解析:`cancel` 点击取消按钮、`sure` 点击确定按钮、`close` 点击右上角关闭按钮或空白页或按下了esc键 */
+ closeCallBack?: ({
+ options,
+ index,
+ args
+ }: {
+ options: DialogOptions;
+ index: number;
+ args: any;
+ }) => void;
+ /** 点击全屏按钮时的回调 */
+ fullscreenCallBack?: ({
+ options,
+ index
+ }: {
+ options: DialogOptions;
+ index: number;
+ }) => void;
+ /** 输入焦点聚焦在 `Dialog` 内容时的回调 */
+ openAutoFocus?: ({
+ options,
+ index
+ }: {
+ options: DialogOptions;
+ index: number;
+ }) => void;
+ /** 输入焦点从 `Dialog` 内容失焦时的回调 */
+ closeAutoFocus?: ({
+ options,
+ index
+ }: {
+ options: DialogOptions;
+ index: number;
+ }) => void;
+ /** 点击底部取消按钮的回调,会暂停 `Dialog` 的关闭. 回调函数内执行 `done` 参数方法的时候才是真正关闭对话框的时候 */
+ beforeCancel?: (
+ done: Function,
+ {
+ options,
+ index
+ }: {
+ options: DialogOptions;
+ index: number;
+ }
+ ) => void;
+ /** 点击底部确定按钮的回调,会暂停 `Dialog` 的关闭. 回调函数内执行 `done` 参数方法的时候才是真正关闭对话框的时候 */
+ beforeSure?: (
+ done: Function,
+ {
+ options,
+ index,
+ closeLoading
+ }: {
+ options: DialogOptions;
+ index: number;
+ /** 关闭确定按钮的 `loading` 加载动画 */
+ closeLoading: Function;
+ }
+ ) => void;
+}
+
+export type { EventType, ArgsType, DialogProps, ButtonProps, DialogOptions };
diff --git a/src/components/CommonIcon/index.ts b/src/components/CommonIcon/index.ts
new file mode 100644
index 0000000..86efe72
--- /dev/null
+++ b/src/components/CommonIcon/index.ts
@@ -0,0 +1,12 @@
+import iconifyIconOffline from "./src/iconifyIconOffline";
+import iconifyIconOnline from "./src/iconifyIconOnline";
+import fontIcon from "./src/iconfont";
+
+/** 本地图标组件 */
+const IconifyIconOffline = iconifyIconOffline;
+/** 在线图标组件 */
+const IconifyIconOnline = iconifyIconOnline;
+/** `iconfont`组件 */
+const FontIcon = fontIcon;
+
+export { IconifyIconOffline, IconifyIconOnline, FontIcon };
diff --git a/src/components/CommonIcon/src/hooks.ts b/src/components/CommonIcon/src/hooks.ts
new file mode 100644
index 0000000..5a377da
--- /dev/null
+++ b/src/components/CommonIcon/src/hooks.ts
@@ -0,0 +1,61 @@
+import type { iconType } from "./types";
+import { h, defineComponent, type Component } from "vue";
+import { IconifyIconOnline, IconifyIconOffline, FontIcon } from "../index";
+
+/**
+ * 支持 `iconfont`、自定义 `svg` 以及 `iconify` 中所有的图标
+ * @see 点击查看文档图标篇 {@link https://pure-admin.github.io/pure-admin-doc/pages/icon/}
+ * @param icon 必传 图标
+ * @param attrs 可选 iconType 属性
+ * @returns Component
+ */
+export function useRenderIcon(icon: any, attrs?: iconType): Component {
+ // iconfont
+ const ifReg = /^IF-/;
+ // typeof icon === "function" 属于SVG
+ if (ifReg.test(icon)) {
+ // iconfont
+ const name = icon.split(ifReg)[1];
+ const iconName = name.slice(
+ 0,
+ name.indexOf(" ") == -1 ? name.length : name.indexOf(" ")
+ );
+ const iconType = name.slice(name.indexOf(" ") + 1, name.length);
+ return defineComponent({
+ name: "FontIcon",
+ render() {
+ return h(FontIcon, {
+ icon: iconName,
+ iconType,
+ ...attrs
+ });
+ }
+ });
+ } else if (typeof icon === "function" || typeof icon?.render === "function") {
+ // svg
+ return attrs ? h(icon, { ...attrs }) : icon;
+ } else if (typeof icon === "object") {
+ return defineComponent({
+ name: "OfflineIcon",
+ render() {
+ return h(IconifyIconOffline, {
+ icon: icon,
+ ...attrs
+ });
+ }
+ });
+ } else {
+ // 通过是否存在 : 符号来判断是在线还是本地图标,存在即是在线图标,反之
+ return defineComponent({
+ name: "Icon",
+ render() {
+ const IconifyIcon =
+ icon && icon.includes(":") ? IconifyIconOnline : IconifyIconOffline;
+ return h(IconifyIcon, {
+ icon: icon,
+ ...attrs
+ });
+ }
+ });
+ }
+}
diff --git a/src/components/CommonIcon/src/iconfont.ts b/src/components/CommonIcon/src/iconfont.ts
new file mode 100644
index 0000000..c110451
--- /dev/null
+++ b/src/components/CommonIcon/src/iconfont.ts
@@ -0,0 +1,48 @@
+import { h, defineComponent } from "vue";
+
+// 封装iconfont组件,默认`font-class`引用模式,支持`unicode`引用、`font-class`引用、`symbol`引用 (https://www.iconfont.cn/help/detail?spm=a313x.7781069.1998910419.20&helptype=code)
+export default defineComponent({
+ name: "FontIcon",
+ props: {
+ icon: {
+ type: String,
+ default: ""
+ }
+ },
+ render() {
+ const attrs = this.$attrs;
+ if (Object.keys(attrs).includes("uni") || attrs?.iconType === "uni") {
+ return h(
+ "i",
+ {
+ class: "iconfont",
+ ...attrs
+ },
+ this.icon
+ );
+ } else if (
+ Object.keys(attrs).includes("svg") ||
+ attrs?.iconType === "svg"
+ ) {
+ return h(
+ "svg",
+ {
+ class: "icon-svg",
+ "aria-hidden": true
+ },
+ {
+ default: () => [
+ h("use", {
+ "xlink:href": `#${this.icon}`
+ })
+ ]
+ }
+ );
+ } else {
+ return h("i", {
+ class: `iconfont ${this.icon}`,
+ ...attrs
+ });
+ }
+ }
+});
diff --git a/src/components/CommonIcon/src/iconifyIconOffline.ts b/src/components/CommonIcon/src/iconifyIconOffline.ts
new file mode 100644
index 0000000..b47aa99
--- /dev/null
+++ b/src/components/CommonIcon/src/iconifyIconOffline.ts
@@ -0,0 +1,30 @@
+import { h, defineComponent } from "vue";
+import { Icon as IconifyIcon, addIcon } from "@iconify/vue/dist/offline";
+
+// Iconify Icon在Vue里本地使用(用于内网环境)
+export default defineComponent({
+ name: "IconifyIconOffline",
+ components: { IconifyIcon },
+ props: {
+ icon: {
+ default: null
+ }
+ },
+ render() {
+ if (typeof this.icon === "object") addIcon(this.icon, this.icon);
+ const attrs = this.$attrs;
+ return h(
+ IconifyIcon,
+ {
+ icon: this.icon,
+ style: attrs?.style
+ ? Object.assign(attrs.style, { outline: "none" })
+ : { outline: "none" },
+ ...attrs
+ },
+ {
+ default: () => []
+ }
+ );
+ }
+});
diff --git a/src/components/CommonIcon/src/iconifyIconOnline.ts b/src/components/CommonIcon/src/iconifyIconOnline.ts
new file mode 100644
index 0000000..a5f5822
--- /dev/null
+++ b/src/components/CommonIcon/src/iconifyIconOnline.ts
@@ -0,0 +1,30 @@
+import { h, defineComponent } from "vue";
+import { Icon as IconifyIcon } from "@iconify/vue";
+
+// Iconify Icon在Vue里在线使用(用于外网环境)
+export default defineComponent({
+ name: "IconifyIconOnline",
+ components: { IconifyIcon },
+ props: {
+ icon: {
+ type: String,
+ default: ""
+ }
+ },
+ render() {
+ const attrs = this.$attrs;
+ return h(
+ IconifyIcon,
+ {
+ icon: `${this.icon}`,
+ style: attrs?.style
+ ? Object.assign(attrs.style, { outline: "none" })
+ : { outline: "none" },
+ ...attrs
+ },
+ {
+ default: () => []
+ }
+ );
+ }
+});
diff --git a/src/components/CommonIcon/src/offlineIcon.ts b/src/components/CommonIcon/src/offlineIcon.ts
new file mode 100644
index 0000000..fc5f912
--- /dev/null
+++ b/src/components/CommonIcon/src/offlineIcon.ts
@@ -0,0 +1,14 @@
+// 这里存放本地图标,在 src/layout/index.vue 文件中加载,避免在首启动加载
+import { addIcon } from "@iconify/vue/dist/offline";
+
+// 本地菜单图标,后端在路由的 icon 中返回对应的图标字符串并且前端在此处使用 addIcon 添加即可渲染菜单图标
+// @iconify-icons/ep
+import Lollipop from "@iconify-icons/ep/lollipop";
+import HomeFilled from "@iconify-icons/ep/home-filled";
+addIcon("ep:lollipop", Lollipop);
+addIcon("ep:home-filled", HomeFilled);
+// @iconify-icons/ri
+import Search from "@iconify-icons/ri/search-line";
+import InformationLine from "@iconify-icons/ri/information-line";
+addIcon("ri:search-line", Search);
+addIcon("ri:information-line", InformationLine);
diff --git a/src/components/CommonIcon/src/types.ts b/src/components/CommonIcon/src/types.ts
new file mode 100644
index 0000000..000bdc5
--- /dev/null
+++ b/src/components/CommonIcon/src/types.ts
@@ -0,0 +1,20 @@
+export interface iconType {
+ // iconify (https://docs.iconify.design/icon-components/vue/#properties)
+ inline?: boolean;
+ width?: string | number;
+ height?: string | number;
+ horizontalFlip?: boolean;
+ verticalFlip?: boolean;
+ flip?: string;
+ rotate?: number | string;
+ color?: string;
+ horizontalAlign?: boolean;
+ verticalAlign?: boolean;
+ align?: string;
+ onLoad?: Function;
+ includes?: Function;
+ // svg 需要什么SVG属性自行添加
+ fill?: string;
+ // all icon
+ style?: object;
+}
diff --git a/src/components/CountTo/README.md b/src/components/CountTo/README.md
new file mode 100644
index 0000000..b5048f3
--- /dev/null
+++ b/src/components/CountTo/README.md
@@ -0,0 +1,2 @@
+normal 普通数字动画组件
+rebound 回弹式数字动画组件
diff --git a/src/components/CountTo/index.ts b/src/components/CountTo/index.ts
new file mode 100644
index 0000000..8c19aa2
--- /dev/null
+++ b/src/components/CountTo/index.ts
@@ -0,0 +1,11 @@
+import reNormalCountTo from './src/normal';
+import reboundCountTo from './src/rebound';
+import { withInstall } from '@pureadmin/utils';
+
+/** 普通数字动画组件 */
+const ReNormalCountTo = withInstall(reNormalCountTo);
+
+/** 回弹式数字动画组件 */
+const ReboundCountTo = withInstall(reboundCountTo);
+
+export { ReNormalCountTo, ReboundCountTo };
diff --git a/src/components/CountTo/src/normal/index.tsx b/src/components/CountTo/src/normal/index.tsx
new file mode 100644
index 0000000..37af509
--- /dev/null
+++ b/src/components/CountTo/src/normal/index.tsx
@@ -0,0 +1,154 @@
+import { computed, defineComponent, onMounted, reactive, unref, watch } from 'vue';
+import { countToProps } from './props';
+import { isNumber } from '@pureadmin/utils';
+
+export default defineComponent({
+ name: 'ReNormalCountTo',
+ props: countToProps,
+ emits: ['mounted', 'callback'],
+ setup(props, { emit }) {
+ const state = reactive<{
+ localStartVal: number;
+ printVal: number | null;
+ displayValue: string;
+ paused: boolean;
+ localDuration: number | null;
+ startTime: number | null;
+ timestamp: number | null;
+ rAF: any;
+ remaining: number | null;
+ color: string;
+ fontSize: string;
+ }>({
+ localStartVal: props.startVal,
+ displayValue: formatNumber(props.startVal),
+ printVal: null,
+ paused: false,
+ localDuration: props.duration,
+ startTime: null,
+ timestamp: null,
+ remaining: null,
+ rAF: null,
+ color: null,
+ fontSize: '16px',
+ });
+
+ const getCountDown = computed(() => {
+ return props.startVal > props.endVal;
+ });
+
+ watch([() => props.startVal, () => props.endVal], () => {
+ if (props.autoplay) {
+ start();
+ }
+ });
+
+ function start() {
+ const { startVal, duration, color, fontSize } = props;
+ state.localStartVal = startVal;
+ state.startTime = null;
+ state.localDuration = duration;
+ state.paused = false;
+ state.color = color;
+ state.fontSize = fontSize;
+ state.rAF = requestAnimationFrame(count);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ function pauseResume() {
+ if (state.paused) {
+ resume();
+ state.paused = false;
+ } else {
+ pause();
+ state.paused = true;
+ }
+ }
+
+ function pause() {
+ cancelAnimationFrame(state.rAF);
+ }
+
+ function resume() {
+ state.startTime = null;
+ state.localDuration = +(state.remaining as number);
+ state.localStartVal = +(state.printVal as number);
+ requestAnimationFrame(count);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ function reset() {
+ state.startTime = null;
+ cancelAnimationFrame(state.rAF);
+ state.displayValue = formatNumber(props.startVal);
+ }
+
+ function count(timestamp: number) {
+ const { useEasing, easingFn, endVal } = props;
+ if (!state.startTime) state.startTime = timestamp;
+ state.timestamp = timestamp;
+ const progress = timestamp - state.startTime;
+ state.remaining = (state.localDuration as number) - progress;
+ if (useEasing) {
+ if (unref(getCountDown)) {
+ state.printVal = state.localStartVal - easingFn(progress, 0, state.localStartVal - endVal, state.localDuration as number);
+ } else {
+ state.printVal = easingFn(progress, state.localStartVal, endVal - state.localStartVal, state.localDuration as number);
+ }
+ } else {
+ if (unref(getCountDown)) {
+ state.printVal = state.localStartVal - (state.localStartVal - endVal) * (progress / (state.localDuration as number));
+ } else {
+ state.printVal = state.localStartVal + (endVal - state.localStartVal) * (progress / (state.localDuration as number));
+ }
+ }
+ if (unref(getCountDown)) {
+ state.printVal = state.printVal < endVal ? endVal : state.printVal;
+ } else {
+ state.printVal = state.printVal > endVal ? endVal : state.printVal;
+ }
+ state.displayValue = formatNumber(state.printVal);
+ if (progress < (state.localDuration as number)) {
+ state.rAF = requestAnimationFrame(count);
+ } else {
+ emit('callback');
+ }
+ }
+
+ function formatNumber(num: number | string) {
+ const { decimals, decimal, separator, suffix, prefix } = props;
+ num = Number(num).toFixed(decimals);
+ num += '';
+ const x = num.split('.');
+ let x1 = x[0];
+ const x2 = x.length > 1 ? decimal + x[1] : '';
+ const rgx = /(\d+)(\d{3})/;
+ if (separator && !isNumber(separator)) {
+ while (rgx.test(x1)) {
+ x1 = x1.replace(rgx, '$1' + separator + '$2');
+ }
+ }
+ return prefix + x1 + x2 + suffix;
+ }
+
+ onMounted(() => {
+ if (props.autoplay) {
+ start();
+ }
+ emit('mounted');
+ });
+
+ return () => (
+ <>
+
+ {state.displayValue}
+
+ >
+ );
+ },
+});
diff --git a/src/components/CountTo/src/normal/props.ts b/src/components/CountTo/src/normal/props.ts
new file mode 100644
index 0000000..03bb082
--- /dev/null
+++ b/src/components/CountTo/src/normal/props.ts
@@ -0,0 +1,30 @@
+import type { PropType } from 'vue';
+import propTypes from '@/utils/propTypes';
+
+export const countToProps = {
+ startVal: propTypes.number.def(0),
+ endVal: propTypes.number.def(2020),
+ duration: propTypes.number.def(1300),
+ autoplay: propTypes.bool.def(true),
+ decimals: {
+ type: Number as PropType,
+ required: false,
+ default: 0,
+ validator(value: number) {
+ return value >= 0;
+ },
+ },
+ color: propTypes.string.def(),
+ fontSize: propTypes.string.def(),
+ decimal: propTypes.string.def('.'),
+ separator: propTypes.string.def(','),
+ prefix: propTypes.string.def(''),
+ suffix: propTypes.string.def(''),
+ useEasing: propTypes.bool.def(true),
+ easingFn: {
+ type: Function as PropType<(t: number, b: number, c: number, d: number) => number>,
+ default(t: number, b: number, c: number, d: number) {
+ return (c * (-Math.pow(2, (-10 * t) / d) + 1) * 1024) / 1023 + b;
+ },
+ },
+};
diff --git a/src/components/CountTo/src/rebound/index.tsx b/src/components/CountTo/src/rebound/index.tsx
new file mode 100644
index 0000000..0a77e19
--- /dev/null
+++ b/src/components/CountTo/src/rebound/index.tsx
@@ -0,0 +1,60 @@
+import './rebound.css';
+import { defineComponent, onBeforeMount, onBeforeUnmount, ref, unref } from 'vue';
+import { reboundProps } from './props';
+
+export default defineComponent({
+ name: 'ReboundCountTo',
+ props: reboundProps,
+ setup(props) {
+ const ulRef = ref();
+ const timer = ref(null);
+
+ onBeforeMount(() => {
+ const ua = navigator.userAgent.toLowerCase();
+ const testUA = regexp => regexp.test(ua);
+ const isSafari = testUA(/safari/g) && !testUA(/chrome/g);
+
+ // Safari浏览器的兼容代码
+ isSafari &&
+ (timer.value = setTimeout(() => {
+ ulRef.value.setAttribute(
+ 'style',
+ `
+ animation: none;
+ transform: translateY(calc(var(--i) * -9.09%))
+ `,
+ );
+ }, props.delay * 1000));
+ });
+
+ onBeforeUnmount(() => {
+ clearTimeout(unref(timer));
+ });
+
+ return () => (
+ <>
+
+ >
+ );
+ },
+});
diff --git a/src/components/CountTo/src/rebound/props.ts b/src/components/CountTo/src/rebound/props.ts
new file mode 100644
index 0000000..b0f94b6
--- /dev/null
+++ b/src/components/CountTo/src/rebound/props.ts
@@ -0,0 +1,15 @@
+import type { PropType } from 'vue';
+import propTypes from '@/utils/propTypes';
+
+export const reboundProps = {
+ delay: propTypes.number.def(1),
+ blur: propTypes.number.def(2),
+ i: {
+ type: Number as PropType,
+ required: false,
+ default: 0,
+ validator(value: number) {
+ return value < 10 && value >= 0 && Number.isInteger(value);
+ },
+ },
+};
diff --git a/src/components/CountTo/src/rebound/rebound.css b/src/components/CountTo/src/rebound/rebound.css
new file mode 100644
index 0000000..0ff414b
--- /dev/null
+++ b/src/components/CountTo/src/rebound/rebound.css
@@ -0,0 +1,76 @@
+.scroll-num {
+ animation: enhance-bounce-in-down 1s calc(var(--delay) * 1s) forwards;
+ color: var(--color, #333);
+ font-size: var(--height, calc(var(--width, 20px) * 1.1));
+ height: var(--height, calc(var(--width, 20px) * 1.8));
+ line-height: var(--height, calc(var(--width, 20px) * 1.8));
+ overflow: hidden;
+ text-align: center;
+ width: var(--width, 20px);
+}
+
+ul {
+ animation: move 0.3s linear infinite,
+ bounce-in-down 1s calc(var(--delay) * 1s) forwards;
+}
+
+@keyframes move {
+ from {
+ transform: translateY(-90%);
+ filter: url(#blur);
+ }
+
+ to {
+ transform: translateY(1%);
+ filter: url(#blur);
+ }
+}
+
+@keyframes bounce-in-down {
+ from {
+ transform: translateY(calc(var(--i) * -9.09% - 7%));
+ filter: none;
+ }
+
+ 25% {
+ transform: translateY(calc(var(--i) * -9.09% + 3%));
+ }
+
+ 50% {
+ transform: translateY(calc(var(--i) * -9.09% - 1%));
+ }
+
+ 70% {
+ transform: translateY(calc(var(--i) * -9.09% + 0.6%));
+ }
+
+ 85% {
+ transform: translateY(calc(var(--i) * -9.09% - 0.3%));
+ }
+
+ to {
+ transform: translateY(calc(var(--i) * -9.09%));
+ }
+}
+
+@keyframes enhance-bounce-in-down {
+ 25% {
+ transform: translateY(8%);
+ }
+
+ 50% {
+ transform: translateY(-4%);
+ }
+
+ 70% {
+ transform: translateY(2%);
+ }
+
+ 85% {
+ transform: translateY(-1%);
+ }
+
+ to {
+ transform: translateY(0);
+ }
+}
diff --git a/src/components/MyCol/index.ts b/src/components/MyCol/index.ts
new file mode 100644
index 0000000..7a6c937
--- /dev/null
+++ b/src/components/MyCol/index.ts
@@ -0,0 +1,29 @@
+import { ElCol } from "element-plus";
+import { h, defineComponent } from "vue";
+
+// 封装element-plus的el-col组件
+export default defineComponent({
+ name: "ReCol",
+ props: {
+ value: {
+ type: Number,
+ default: 24
+ }
+ },
+ render() {
+ const attrs = this.$attrs;
+ const val = this.value;
+ return h(
+ ElCol,
+ {
+ xs: val,
+ sm: val,
+ md: val,
+ lg: val,
+ xl: val,
+ ...attrs
+ },
+ { default: () => this.$slots.default() }
+ );
+ }
+});
diff --git a/src/components/Perms/index.ts b/src/components/Perms/index.ts
new file mode 100644
index 0000000..3701c3c
--- /dev/null
+++ b/src/components/Perms/index.ts
@@ -0,0 +1,5 @@
+import perms from "./src/perms";
+
+const Perms = perms;
+
+export { Perms };
diff --git a/src/components/Perms/src/perms.tsx b/src/components/Perms/src/perms.tsx
new file mode 100644
index 0000000..da01bc1
--- /dev/null
+++ b/src/components/Perms/src/perms.tsx
@@ -0,0 +1,20 @@
+import { defineComponent, Fragment } from "vue";
+import { hasPerms } from "@/utils/auth";
+
+export default defineComponent({
+ name: "Perms",
+ props: {
+ value: {
+ type: undefined,
+ default: []
+ }
+ },
+ setup(props, { slots }) {
+ return () => {
+ if (!slots) return null;
+ return hasPerms(props.value) ? (
+ {slots.default?.()}
+ ) : null;
+ };
+ }
+});
diff --git a/src/components/ReCropperPreview/index.ts b/src/components/ReCropperPreview/index.ts
new file mode 100644
index 0000000..e7949fe
--- /dev/null
+++ b/src/components/ReCropperPreview/index.ts
@@ -0,0 +1,7 @@
+import reCropperPreview from "./src/index.vue";
+import { withInstall } from "@pureadmin/utils";
+
+/** 图片裁剪预览组件 */
+export const ReCropperPreview = withInstall(reCropperPreview);
+
+export default ReCropperPreview;
diff --git a/src/components/ReCropperPreview/src/index.vue b/src/components/ReCropperPreview/src/index.vue
new file mode 100644
index 0000000..c34cc94
--- /dev/null
+++ b/src/components/ReCropperPreview/src/index.vue
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+ 温馨提示:右键上方裁剪区可开启功能菜单
+
+
+
+
+
+
+
+ 图像大小:{{ parseInt(infos.width) }} ×
+ {{ parseInt(infos.height) }}像素
+
+
+ 文件大小:{{ formatBytes(infos.size) }}({{ infos.size }} 字节)
+
+
+
+
+
+
diff --git a/src/components/ReIcon/data.ts b/src/components/ReIcon/data.ts
new file mode 100644
index 0000000..b5769e8
--- /dev/null
+++ b/src/components/ReIcon/data.ts
@@ -0,0 +1,3869 @@
+/**
+ * 想要哪个图标集 自行添加即可 请注意此处添加的图标集均为在线图标(https://iconify.design/docs/api/#public-api)
+ * 如果项目在内网环境下 参考 https://www.bilibili.com/video/BV17S4y1J79d?p=4&vd_source=5a992808de6229d78e7810536c5f9ab3 教程自行离线部署图标
+ * https://icones.js.org/collections/图标集前缀名-meta.json(如:https://icones.js.org/collections/ri-meta.json 取icons字段,可获得当前图标集的所有图标)
+ */
+export const IconJson = {
+ // https://icones.js.org/collections/ep-meta.json
+ "ep:": [
+ "add-location",
+ "aim",
+ "alarm-clock",
+ "apple",
+ "arrow-down",
+ "arrow-down-bold",
+ "arrow-left",
+ "arrow-left-bold",
+ "arrow-right",
+ "arrow-right-bold",
+ "arrow-up",
+ "arrow-up-bold",
+ "avatar",
+ "back",
+ "baseball",
+ "basketball",
+ "bell",
+ "bell-filled",
+ "bicycle",
+ "bottom",
+ "bottom-left",
+ "bottom-right",
+ "bowl",
+ "box",
+ "briefcase",
+ "brush",
+ "brush-filled",
+ "burger",
+ "calendar",
+ "camera",
+ "camera-filled",
+ "caret-bottom",
+ "caret-left",
+ "caret-right",
+ "caret-top",
+ "cellphone",
+ "chat-dot-round",
+ "chat-dot-square",
+ "chat-line-round",
+ "chat-line-square",
+ "chat-round",
+ "chat-square",
+ "check",
+ "checked",
+ "cherry",
+ "chicken",
+ "chrome-filled",
+ "circle-check",
+ "circle-check-filled",
+ "circle-close",
+ "circle-close-filled",
+ "circle-plus",
+ "circle-plus-filled",
+ "clock",
+ "close",
+ "close-bold",
+ "cloudy",
+ "coffee",
+ "coffee-cup",
+ "coin",
+ "cold-drink",
+ "collection",
+ "collection-tag",
+ "comment",
+ "compass",
+ "connection",
+ "coordinate",
+ "copy-document",
+ "cpu",
+ "credit-card",
+ "crop",
+ "d-arrow-left",
+ "d-arrow-right",
+ "d-caret",
+ "data-analysis",
+ "data-board",
+ "data-line",
+ "delete",
+ "delete-filled",
+ "delete-location",
+ "dessert",
+ "discount",
+ "dish",
+ "dish-dot",
+ "document",
+ "document-add",
+ "document-checked",
+ "document-copy",
+ "document-delete",
+ "document-remove",
+ "download",
+ "drizzling",
+ "edit",
+ "edit-pen",
+ "eleme",
+ "eleme-filled",
+ "element-plus",
+ "expand",
+ "failed",
+ "female",
+ "files",
+ "film",
+ "filter",
+ "finished",
+ "first-aid-kit",
+ "flag",
+ "fold",
+ "folder",
+ "folder-add",
+ "folder-checked",
+ "folder-delete",
+ "folder-opened",
+ "folder-remove",
+ "food",
+ "football",
+ "fork-spoon",
+ "fries",
+ "full-screen",
+ "goblet",
+ "goblet-full",
+ "goblet-square",
+ "goblet-square-full",
+ "gold-medal",
+ "goods",
+ "goods-filled",
+ "grape",
+ "grid",
+ "guide",
+ "handbag",
+ "headset",
+ "help",
+ "help-filled",
+ "hide",
+ "histogram",
+ "home-filled",
+ "hot-water",
+ "house",
+ "ice-cream",
+ "ice-cream-round",
+ "ice-cream-square",
+ "ice-drink",
+ "ice-tea",
+ "info-filled",
+ "iphone",
+ "key",
+ "knife-fork",
+ "lightning",
+ "link",
+ "list",
+ "loading",
+ "location",
+ "location-filled",
+ "location-information",
+ "lock",
+ "lollipop",
+ "magic-stick",
+ "magnet",
+ "male",
+ "management",
+ "map-location",
+ "medal",
+ "memo",
+ "menu",
+ "message",
+ "message-box",
+ "mic",
+ "microphone",
+ "milk-tea",
+ "minus",
+ "money",
+ "monitor",
+ "moon",
+ "moon-night",
+ "more",
+ "more-filled",
+ "mostly-cloudy",
+ "mouse",
+ "mug",
+ "mute",
+ "mute-notification",
+ "no-smoking",
+ "notebook",
+ "notification",
+ "odometer",
+ "office-building",
+ "open",
+ "operation",
+ "opportunity",
+ "orange",
+ "paperclip",
+ "partly-cloudy",
+ "pear",
+ "phone",
+ "phone-filled",
+ "picture",
+ "picture-filled",
+ "picture-rounded",
+ "pie-chart",
+ "place",
+ "platform",
+ "plus",
+ "pointer",
+ "position",
+ "postcard",
+ "pouring",
+ "present",
+ "price-tag",
+ "printer",
+ "promotion",
+ "quartz-watch",
+ "question-filled",
+ "rank",
+ "reading",
+ "reading-lamp",
+ "refresh",
+ "refresh-left",
+ "refresh-right",
+ "refrigerator",
+ "remove",
+ "remove-filled",
+ "right",
+ "scale-to-original",
+ "school",
+ "scissor",
+ "search",
+ "select",
+ "sell",
+ "semi-select",
+ "service",
+ "set-up",
+ "setting",
+ "share",
+ "ship",
+ "shop",
+ "shopping-bag",
+ "shopping-cart",
+ "shopping-cart-full",
+ "shopping-trolley",
+ "smoking",
+ "soccer",
+ "sold-out",
+ "sort",
+ "sort-down",
+ "sort-up",
+ "stamp",
+ "star",
+ "star-filled",
+ "stopwatch",
+ "success-filled",
+ "sugar",
+ "suitcase",
+ "suitcase-line",
+ "sunny",
+ "sunrise",
+ "sunset",
+ "switch",
+ "switch-button",
+ "switch-filled",
+ "takeaway-box",
+ "ticket",
+ "tickets",
+ "timer",
+ "toilet-paper",
+ "tools",
+ "top",
+ "top-left",
+ "top-right",
+ "trend-charts",
+ "trophy",
+ "trophy-base",
+ "turn-off",
+ "umbrella",
+ "unlock",
+ "upload",
+ "upload-filled",
+ "user",
+ "user-filled",
+ "van",
+ "video-camera",
+ "video-camera-filled",
+ "video-pause",
+ "video-play",
+ "view",
+ "wallet",
+ "wallet-filled",
+ "warn-triangle-filled",
+ "warning",
+ "warning-filled",
+ "watch",
+ "watermelon",
+ "wind-power",
+ "zoom-in",
+ "zoom-out"
+ ],
+ // https://icones.js.org/collections/ri-meta.json
+ "ri:": [
+ "24-hours-fill",
+ "24-hours-line",
+ "4k-fill",
+ "4k-line",
+ "a-b",
+ "account-box-fill",
+ "account-box-line",
+ "account-circle-fill",
+ "account-circle-line",
+ "account-pin-box-fill",
+ "account-pin-box-line",
+ "account-pin-circle-fill",
+ "account-pin-circle-line",
+ "add-box-fill",
+ "add-box-line",
+ "add-circle-fill",
+ "add-circle-line",
+ "add-fill",
+ "add-line",
+ "admin-fill",
+ "admin-line",
+ "advertisement-fill",
+ "advertisement-line",
+ "ai-generate",
+ "airplay-fill",
+ "airplay-line",
+ "alarm-fill",
+ "alarm-line",
+ "alarm-warning-fill",
+ "alarm-warning-line",
+ "album-fill",
+ "album-line",
+ "alert-fill",
+ "alert-line",
+ "aliens-fill",
+ "aliens-line",
+ "align-bottom",
+ "align-center",
+ "align-justify",
+ "align-left",
+ "align-right",
+ "align-top",
+ "align-vertically",
+ "alipay-fill",
+ "alipay-line",
+ "amazon-fill",
+ "amazon-line",
+ "anchor-fill",
+ "anchor-line",
+ "ancient-gate-fill",
+ "ancient-gate-line",
+ "ancient-pavilion-fill",
+ "ancient-pavilion-line",
+ "android-fill",
+ "android-line",
+ "angularjs-fill",
+ "angularjs-line",
+ "anticlockwise-2-fill",
+ "anticlockwise-2-line",
+ "anticlockwise-fill",
+ "anticlockwise-line",
+ "app-store-fill",
+ "app-store-line",
+ "apple-fill",
+ "apple-line",
+ "apps-2-fill",
+ "apps-2-line",
+ "apps-fill",
+ "apps-line",
+ "archive-2-fill",
+ "archive-2-line",
+ "archive-drawer-fill",
+ "archive-drawer-line",
+ "archive-fill",
+ "archive-line",
+ "arrow-down-circle-fill",
+ "arrow-down-circle-line",
+ "arrow-down-double-fill",
+ "arrow-down-double-line",
+ "arrow-down-fill",
+ "arrow-down-line",
+ "arrow-down-s-fill",
+ "arrow-down-s-line",
+ "arrow-drop-down-fill",
+ "arrow-drop-down-line",
+ "arrow-drop-left-fill",
+ "arrow-drop-left-line",
+ "arrow-drop-right-fill",
+ "arrow-drop-right-line",
+ "arrow-drop-up-fill",
+ "arrow-drop-up-line",
+ "arrow-go-back-fill",
+ "arrow-go-back-line",
+ "arrow-go-forward-fill",
+ "arrow-go-forward-line",
+ "arrow-left-circle-fill",
+ "arrow-left-circle-line",
+ "arrow-left-double-fill",
+ "arrow-left-double-line",
+ "arrow-left-down-fill",
+ "arrow-left-down-line",
+ "arrow-left-fill",
+ "arrow-left-line",
+ "arrow-left-right-fill",
+ "arrow-left-right-line",
+ "arrow-left-s-fill",
+ "arrow-left-s-line",
+ "arrow-left-up-fill",
+ "arrow-left-up-line",
+ "arrow-right-circle-fill",
+ "arrow-right-circle-line",
+ "arrow-right-double-fill",
+ "arrow-right-double-line",
+ "arrow-right-down-fill",
+ "arrow-right-down-line",
+ "arrow-right-fill",
+ "arrow-right-line",
+ "arrow-right-s-fill",
+ "arrow-right-s-line",
+ "arrow-right-up-fill",
+ "arrow-right-up-line",
+ "arrow-turn-back-fill",
+ "arrow-turn-back-line",
+ "arrow-turn-forward-fill",
+ "arrow-turn-forward-line",
+ "arrow-up-circle-fill",
+ "arrow-up-circle-line",
+ "arrow-up-double-fill",
+ "arrow-up-double-line",
+ "arrow-up-down-fill",
+ "arrow-up-down-line",
+ "arrow-up-fill",
+ "arrow-up-line",
+ "arrow-up-s-fill",
+ "arrow-up-s-line",
+ "artboard-2-fill",
+ "artboard-2-line",
+ "artboard-fill",
+ "artboard-line",
+ "article-fill",
+ "article-line",
+ "aspect-ratio-fill",
+ "aspect-ratio-line",
+ "asterisk",
+ "at-fill",
+ "at-line",
+ "attachment-2",
+ "attachment-fill",
+ "attachment-line",
+ "auction-fill",
+ "auction-line",
+ "award-fill",
+ "award-line",
+ "baidu-fill",
+ "baidu-line",
+ "ball-pen-fill",
+ "ball-pen-line",
+ "bank-card-2-fill",
+ "bank-card-2-line",
+ "bank-card-fill",
+ "bank-card-line",
+ "bank-fill",
+ "bank-line",
+ "bar-chart-2-fill",
+ "bar-chart-2-line",
+ "bar-chart-box-fill",
+ "bar-chart-box-line",
+ "bar-chart-fill",
+ "bar-chart-grouped-fill",
+ "bar-chart-grouped-line",
+ "bar-chart-horizontal-fill",
+ "bar-chart-horizontal-line",
+ "bar-chart-line",
+ "barcode-box-fill",
+ "barcode-box-line",
+ "barcode-fill",
+ "barcode-line",
+ "bard-fill",
+ "bard-line",
+ "barricade-fill",
+ "barricade-line",
+ "base-station-fill",
+ "base-station-line",
+ "basketball-fill",
+ "basketball-line",
+ "battery-2-charge-fill",
+ "battery-2-charge-line",
+ "battery-2-fill",
+ "battery-2-line",
+ "battery-charge-fill",
+ "battery-charge-line",
+ "battery-fill",
+ "battery-line",
+ "battery-low-fill",
+ "battery-low-line",
+ "battery-saver-fill",
+ "battery-saver-line",
+ "battery-share-fill",
+ "battery-share-line",
+ "bear-smile-fill",
+ "bear-smile-line",
+ "beer-fill",
+ "beer-line",
+ "behance-fill",
+ "behance-line",
+ "bell-fill",
+ "bell-line",
+ "bike-fill",
+ "bike-line",
+ "bilibili-fill",
+ "bilibili-line",
+ "bill-fill",
+ "bill-line",
+ "billiards-fill",
+ "billiards-line",
+ "bit-coin-fill",
+ "bit-coin-line",
+ "blaze-fill",
+ "blaze-line",
+ "blender-fill",
+ "blender-line",
+ "bluetooth-connect-fill",
+ "bluetooth-connect-line",
+ "bluetooth-fill",
+ "bluetooth-line",
+ "blur-off-fill",
+ "blur-off-line",
+ "body-scan-fill",
+ "body-scan-line",
+ "bold",
+ "book-2-fill",
+ "book-2-line",
+ "book-3-fill",
+ "book-3-line",
+ "book-fill",
+ "book-line",
+ "book-mark-fill",
+ "book-mark-line",
+ "book-open-fill",
+ "book-open-line",
+ "book-read-fill",
+ "book-read-line",
+ "booklet-fill",
+ "booklet-line",
+ "bookmark-2-fill",
+ "bookmark-2-line",
+ "bookmark-3-fill",
+ "bookmark-3-line",
+ "bookmark-fill",
+ "bookmark-line",
+ "bootstrap-fill",
+ "bootstrap-line",
+ "box-1-fill",
+ "box-1-line",
+ "box-2-fill",
+ "box-2-line",
+ "box-3-fill",
+ "box-3-line",
+ "boxing-fill",
+ "boxing-line",
+ "braces-fill",
+ "braces-line",
+ "brackets-fill",
+ "brackets-line",
+ "brain-fill",
+ "brain-line",
+ "briefcase-2-fill",
+ "briefcase-2-line",
+ "briefcase-3-fill",
+ "briefcase-3-line",
+ "briefcase-4-fill",
+ "briefcase-4-line",
+ "briefcase-5-fill",
+ "briefcase-5-line",
+ "briefcase-fill",
+ "briefcase-line",
+ "bring-forward",
+ "bring-to-front",
+ "broadcast-fill",
+ "broadcast-line",
+ "brush-2-fill",
+ "brush-2-line",
+ "brush-3-fill",
+ "brush-3-line",
+ "brush-4-fill",
+ "brush-4-line",
+ "brush-fill",
+ "brush-line",
+ "bubble-chart-fill",
+ "bubble-chart-line",
+ "bug-2-fill",
+ "bug-2-line",
+ "bug-fill",
+ "bug-line",
+ "building-2-fill",
+ "building-2-line",
+ "building-3-fill",
+ "building-3-line",
+ "building-4-fill",
+ "building-4-line",
+ "building-fill",
+ "building-line",
+ "bus-2-fill",
+ "bus-2-line",
+ "bus-fill",
+ "bus-line",
+ "bus-wifi-fill",
+ "bus-wifi-line",
+ "cactus-fill",
+ "cactus-line",
+ "cake-2-fill",
+ "cake-2-line",
+ "cake-3-fill",
+ "cake-3-line",
+ "cake-fill",
+ "cake-line",
+ "calculator-fill",
+ "calculator-line",
+ "calendar-2-fill",
+ "calendar-2-line",
+ "calendar-check-fill",
+ "calendar-check-line",
+ "calendar-close-fill",
+ "calendar-close-line",
+ "calendar-event-fill",
+ "calendar-event-line",
+ "calendar-fill",
+ "calendar-line",
+ "calendar-todo-fill",
+ "calendar-todo-line",
+ "camera-2-fill",
+ "camera-2-line",
+ "camera-3-fill",
+ "camera-3-line",
+ "camera-fill",
+ "camera-lens-fill",
+ "camera-lens-line",
+ "camera-line",
+ "camera-off-fill",
+ "camera-off-line",
+ "camera-switch-fill",
+ "camera-switch-line",
+ "candle-fill",
+ "candle-line",
+ "capsule-fill",
+ "capsule-line",
+ "car-fill",
+ "car-line",
+ "car-washing-fill",
+ "car-washing-line",
+ "caravan-fill",
+ "caravan-line",
+ "cash-fill",
+ "cash-line",
+ "cast-fill",
+ "cast-line",
+ "cellphone-fill",
+ "cellphone-line",
+ "celsius-fill",
+ "celsius-line",
+ "centos-fill",
+ "centos-line",
+ "character-recognition-fill",
+ "character-recognition-line",
+ "charging-pile-2-fill",
+ "charging-pile-2-line",
+ "charging-pile-fill",
+ "charging-pile-line",
+ "chat-1-fill",
+ "chat-1-line",
+ "chat-2-fill",
+ "chat-2-line",
+ "chat-3-fill",
+ "chat-3-line",
+ "chat-4-fill",
+ "chat-4-line",
+ "chat-check-fill",
+ "chat-check-line",
+ "chat-delete-fill",
+ "chat-delete-line",
+ "chat-download-fill",
+ "chat-download-line",
+ "chat-follow-up-fill",
+ "chat-follow-up-line",
+ "chat-forward-fill",
+ "chat-forward-line",
+ "chat-heart-fill",
+ "chat-heart-line",
+ "chat-history-fill",
+ "chat-history-line",
+ "chat-new-fill",
+ "chat-new-line",
+ "chat-off-fill",
+ "chat-off-line",
+ "chat-poll-fill",
+ "chat-poll-line",
+ "chat-private-fill",
+ "chat-private-line",
+ "chat-quote-fill",
+ "chat-quote-line",
+ "chat-settings-fill",
+ "chat-settings-line",
+ "chat-smile-2-fill",
+ "chat-smile-2-line",
+ "chat-smile-3-fill",
+ "chat-smile-3-line",
+ "chat-smile-fill",
+ "chat-smile-line",
+ "chat-upload-fill",
+ "chat-upload-line",
+ "chat-voice-fill",
+ "chat-voice-line",
+ "check-double-fill",
+ "check-double-line",
+ "check-fill",
+ "check-line",
+ "checkbox-blank-circle-fill",
+ "checkbox-blank-circle-line",
+ "checkbox-blank-fill",
+ "checkbox-blank-line",
+ "checkbox-circle-fill",
+ "checkbox-circle-line",
+ "checkbox-fill",
+ "checkbox-indeterminate-fill",
+ "checkbox-indeterminate-line",
+ "checkbox-line",
+ "checkbox-multiple-blank-fill",
+ "checkbox-multiple-blank-line",
+ "checkbox-multiple-fill",
+ "checkbox-multiple-line",
+ "china-railway-fill",
+ "china-railway-line",
+ "chrome-fill",
+ "chrome-line",
+ "circle-fill",
+ "circle-line",
+ "clapperboard-fill",
+ "clapperboard-line",
+ "clipboard-fill",
+ "clipboard-line",
+ "clockwise-2-fill",
+ "clockwise-2-line",
+ "clockwise-fill",
+ "clockwise-line",
+ "close-circle-fill",
+ "close-circle-line",
+ "close-fill",
+ "close-line",
+ "closed-captioning-fill",
+ "closed-captioning-line",
+ "cloud-fill",
+ "cloud-line",
+ "cloud-off-fill",
+ "cloud-off-line",
+ "cloud-windy-fill",
+ "cloud-windy-line",
+ "cloudy-2-fill",
+ "cloudy-2-line",
+ "cloudy-fill",
+ "cloudy-line",
+ "code-box-fill",
+ "code-box-line",
+ "code-fill",
+ "code-line",
+ "code-s-fill",
+ "code-s-line",
+ "code-s-slash-fill",
+ "code-s-slash-line",
+ "code-view",
+ "codepen-fill",
+ "codepen-line",
+ "coin-fill",
+ "coin-line",
+ "coins-fill",
+ "coins-line",
+ "collage-fill",
+ "collage-line",
+ "command-fill",
+ "command-line",
+ "community-fill",
+ "community-line",
+ "compass-2-fill",
+ "compass-2-line",
+ "compass-3-fill",
+ "compass-3-line",
+ "compass-4-fill",
+ "compass-4-line",
+ "compass-discover-fill",
+ "compass-discover-line",
+ "compass-fill",
+ "compass-line",
+ "compasses-2-fill",
+ "compasses-2-line",
+ "compasses-fill",
+ "compasses-line",
+ "computer-fill",
+ "computer-line",
+ "contacts-book-2-fill",
+ "contacts-book-2-line",
+ "contacts-book-fill",
+ "contacts-book-line",
+ "contacts-book-upload-fill",
+ "contacts-book-upload-line",
+ "contacts-fill",
+ "contacts-line",
+ "contract-left-fill",
+ "contract-left-line",
+ "contract-left-right-fill",
+ "contract-left-right-line",
+ "contract-right-fill",
+ "contract-right-line",
+ "contract-up-down-fill",
+ "contract-up-down-line",
+ "contrast-2-fill",
+ "contrast-2-line",
+ "contrast-drop-2-fill",
+ "contrast-drop-2-line",
+ "contrast-drop-fill",
+ "contrast-drop-line",
+ "contrast-fill",
+ "contrast-line",
+ "copilot-fill",
+ "copilot-line",
+ "copper-coin-fill",
+ "copper-coin-line",
+ "copper-diamond-fill",
+ "copper-diamond-line",
+ "copyleft-fill",
+ "copyleft-line",
+ "copyright-fill",
+ "copyright-line",
+ "coreos-fill",
+ "coreos-line",
+ "corner-down-left-fill",
+ "corner-down-left-line",
+ "corner-down-right-fill",
+ "corner-down-right-line",
+ "corner-left-down-fill",
+ "corner-left-down-line",
+ "corner-left-up-fill",
+ "corner-left-up-line",
+ "corner-right-down-fill",
+ "corner-right-down-line",
+ "corner-right-up-fill",
+ "corner-right-up-line",
+ "corner-up-left-double-fill",
+ "corner-up-left-double-line",
+ "corner-up-left-fill",
+ "corner-up-left-line",
+ "corner-up-right-double-fill",
+ "corner-up-right-double-line",
+ "corner-up-right-fill",
+ "corner-up-right-line",
+ "coupon-2-fill",
+ "coupon-2-line",
+ "coupon-3-fill",
+ "coupon-3-line",
+ "coupon-4-fill",
+ "coupon-4-line",
+ "coupon-5-fill",
+ "coupon-5-line",
+ "coupon-fill",
+ "coupon-line",
+ "cpu-fill",
+ "cpu-line",
+ "creative-commons-by-fill",
+ "creative-commons-by-line",
+ "creative-commons-fill",
+ "creative-commons-line",
+ "creative-commons-nc-fill",
+ "creative-commons-nc-line",
+ "creative-commons-nd-fill",
+ "creative-commons-nd-line",
+ "creative-commons-sa-fill",
+ "creative-commons-sa-line",
+ "creative-commons-zero-fill",
+ "creative-commons-zero-line",
+ "criminal-fill",
+ "criminal-line",
+ "crop-2-fill",
+ "crop-2-line",
+ "crop-fill",
+ "crop-line",
+ "cross-fill",
+ "cross-line",
+ "crosshair-2-fill",
+ "crosshair-2-line",
+ "crosshair-fill",
+ "crosshair-line",
+ "css3-fill",
+ "css3-line",
+ "cup-fill",
+ "cup-line",
+ "currency-fill",
+ "currency-line",
+ "cursor-fill",
+ "cursor-line",
+ "customer-service-2-fill",
+ "customer-service-2-line",
+ "customer-service-fill",
+ "customer-service-line",
+ "dashboard-2-fill",
+ "dashboard-2-line",
+ "dashboard-3-fill",
+ "dashboard-3-line",
+ "dashboard-fill",
+ "dashboard-line",
+ "database-2-fill",
+ "database-2-line",
+ "database-fill",
+ "database-line",
+ "delete-back-2-fill",
+ "delete-back-2-line",
+ "delete-back-fill",
+ "delete-back-line",
+ "delete-bin-2-fill",
+ "delete-bin-2-line",
+ "delete-bin-3-fill",
+ "delete-bin-3-line",
+ "delete-bin-4-fill",
+ "delete-bin-4-line",
+ "delete-bin-5-fill",
+ "delete-bin-5-line",
+ "delete-bin-6-fill",
+ "delete-bin-6-line",
+ "delete-bin-7-fill",
+ "delete-bin-7-line",
+ "delete-bin-fill",
+ "delete-bin-line",
+ "delete-column",
+ "delete-row",
+ "device-fill",
+ "device-line",
+ "device-recover-fill",
+ "device-recover-line",
+ "dingding-fill",
+ "dingding-line",
+ "direction-fill",
+ "direction-line",
+ "disc-fill",
+ "disc-line",
+ "discord-fill",
+ "discord-line",
+ "discuss-fill",
+ "discuss-line",
+ "dislike-fill",
+ "dislike-line",
+ "disqus-fill",
+ "disqus-line",
+ "divide-fill",
+ "divide-line",
+ "donut-chart-fill",
+ "donut-chart-line",
+ "door-closed-fill",
+ "door-closed-line",
+ "door-fill",
+ "door-line",
+ "door-lock-box-fill",
+ "door-lock-box-line",
+ "door-lock-fill",
+ "door-lock-line",
+ "door-open-fill",
+ "door-open-line",
+ "dossier-fill",
+ "dossier-line",
+ "douban-fill",
+ "douban-line",
+ "double-quotes-l",
+ "double-quotes-r",
+ "download-2-fill",
+ "download-2-line",
+ "download-cloud-2-fill",
+ "download-cloud-2-line",
+ "download-cloud-fill",
+ "download-cloud-line",
+ "download-fill",
+ "download-line",
+ "draft-fill",
+ "draft-line",
+ "drag-drop-fill",
+ "drag-drop-line",
+ "drag-move-2-fill",
+ "drag-move-2-line",
+ "drag-move-fill",
+ "drag-move-line",
+ "draggable",
+ "dribbble-fill",
+ "dribbble-line",
+ "drive-fill",
+ "drive-line",
+ "drizzle-fill",
+ "drizzle-line",
+ "drop-fill",
+ "drop-line",
+ "dropbox-fill",
+ "dropbox-line",
+ "dropdown-list",
+ "dual-sim-1-fill",
+ "dual-sim-1-line",
+ "dual-sim-2-fill",
+ "dual-sim-2-line",
+ "dv-fill",
+ "dv-line",
+ "dvd-fill",
+ "dvd-line",
+ "e-bike-2-fill",
+ "e-bike-2-line",
+ "e-bike-fill",
+ "e-bike-line",
+ "earth-fill",
+ "earth-line",
+ "earthquake-fill",
+ "earthquake-line",
+ "edge-fill",
+ "edge-line",
+ "edge-new-fill",
+ "edge-new-line",
+ "edit-2-fill",
+ "edit-2-line",
+ "edit-box-fill",
+ "edit-box-line",
+ "edit-circle-fill",
+ "edit-circle-line",
+ "edit-fill",
+ "edit-line",
+ "eject-fill",
+ "eject-line",
+ "emoji-sticker-fill",
+ "emoji-sticker-line",
+ "emotion-2-fill",
+ "emotion-2-line",
+ "emotion-fill",
+ "emotion-happy-fill",
+ "emotion-happy-line",
+ "emotion-laugh-fill",
+ "emotion-laugh-line",
+ "emotion-line",
+ "emotion-normal-fill",
+ "emotion-normal-line",
+ "emotion-sad-fill",
+ "emotion-sad-line",
+ "emotion-unhappy-fill",
+ "emotion-unhappy-line",
+ "empathize-fill",
+ "empathize-line",
+ "emphasis",
+ "emphasis-cn",
+ "english-input",
+ "equal-fill",
+ "equal-line",
+ "equalizer-fill",
+ "equalizer-line",
+ "eraser-fill",
+ "eraser-line",
+ "error-warning-fill",
+ "error-warning-line",
+ "evernote-fill",
+ "evernote-line",
+ "exchange-box-fill",
+ "exchange-box-line",
+ "exchange-cny-fill",
+ "exchange-cny-line",
+ "exchange-dollar-fill",
+ "exchange-dollar-line",
+ "exchange-fill",
+ "exchange-funds-fill",
+ "exchange-funds-line",
+ "exchange-line",
+ "expand-left-fill",
+ "expand-left-line",
+ "expand-left-right-fill",
+ "expand-left-right-line",
+ "expand-right-fill",
+ "expand-right-line",
+ "expand-up-down-fill",
+ "expand-up-down-line",
+ "external-link-fill",
+ "external-link-line",
+ "eye-2-fill",
+ "eye-2-line",
+ "eye-close-fill",
+ "eye-close-line",
+ "eye-fill",
+ "eye-line",
+ "eye-off-fill",
+ "eye-off-line",
+ "facebook-box-fill",
+ "facebook-box-line",
+ "facebook-circle-fill",
+ "facebook-circle-line",
+ "facebook-fill",
+ "facebook-line",
+ "fahrenheit-fill",
+ "fahrenheit-line",
+ "feedback-fill",
+ "feedback-line",
+ "file-2-fill",
+ "file-2-line",
+ "file-3-fill",
+ "file-3-line",
+ "file-4-fill",
+ "file-4-line",
+ "file-add-fill",
+ "file-add-line",
+ "file-chart-2-fill",
+ "file-chart-2-line",
+ "file-chart-fill",
+ "file-chart-line",
+ "file-close-fill",
+ "file-close-line",
+ "file-cloud-fill",
+ "file-cloud-line",
+ "file-code-fill",
+ "file-code-line",
+ "file-copy-2-fill",
+ "file-copy-2-line",
+ "file-copy-fill",
+ "file-copy-line",
+ "file-damage-fill",
+ "file-damage-line",
+ "file-download-fill",
+ "file-download-line",
+ "file-edit-fill",
+ "file-edit-line",
+ "file-excel-2-fill",
+ "file-excel-2-line",
+ "file-excel-fill",
+ "file-excel-line",
+ "file-fill",
+ "file-forbid-fill",
+ "file-forbid-line",
+ "file-gif-fill",
+ "file-gif-line",
+ "file-history-fill",
+ "file-history-line",
+ "file-hwp-fill",
+ "file-hwp-line",
+ "file-image-fill",
+ "file-image-line",
+ "file-info-fill",
+ "file-info-line",
+ "file-line",
+ "file-list-2-fill",
+ "file-list-2-line",
+ "file-list-3-fill",
+ "file-list-3-line",
+ "file-list-fill",
+ "file-list-line",
+ "file-lock-fill",
+ "file-lock-line",
+ "file-mark-fill",
+ "file-mark-line",
+ "file-music-fill",
+ "file-music-line",
+ "file-paper-2-fill",
+ "file-paper-2-line",
+ "file-paper-fill",
+ "file-paper-line",
+ "file-pdf-2-fill",
+ "file-pdf-2-line",
+ "file-pdf-fill",
+ "file-pdf-line",
+ "file-ppt-2-fill",
+ "file-ppt-2-line",
+ "file-ppt-fill",
+ "file-ppt-line",
+ "file-reduce-fill",
+ "file-reduce-line",
+ "file-search-fill",
+ "file-search-line",
+ "file-settings-fill",
+ "file-settings-line",
+ "file-shield-2-fill",
+ "file-shield-2-line",
+ "file-shield-fill",
+ "file-shield-line",
+ "file-shred-fill",
+ "file-shred-line",
+ "file-text-fill",
+ "file-text-line",
+ "file-transfer-fill",
+ "file-transfer-line",
+ "file-unknow-fill",
+ "file-unknow-line",
+ "file-upload-fill",
+ "file-upload-line",
+ "file-user-fill",
+ "file-user-line",
+ "file-video-fill",
+ "file-video-line",
+ "file-warning-fill",
+ "file-warning-line",
+ "file-word-2-fill",
+ "file-word-2-line",
+ "file-word-fill",
+ "file-word-line",
+ "file-zip-fill",
+ "file-zip-line",
+ "film-fill",
+ "film-line",
+ "filter-2-fill",
+ "filter-2-line",
+ "filter-3-fill",
+ "filter-3-line",
+ "filter-fill",
+ "filter-line",
+ "filter-off-fill",
+ "filter-off-line",
+ "find-replace-fill",
+ "find-replace-line",
+ "finder-fill",
+ "finder-line",
+ "fingerprint-2-fill",
+ "fingerprint-2-line",
+ "fingerprint-fill",
+ "fingerprint-line",
+ "fire-fill",
+ "fire-line",
+ "firefox-fill",
+ "firefox-line",
+ "first-aid-kit-fill",
+ "first-aid-kit-line",
+ "flag-2-fill",
+ "flag-2-line",
+ "flag-fill",
+ "flag-line",
+ "flashlight-fill",
+ "flashlight-line",
+ "flask-fill",
+ "flask-line",
+ "flickr-fill",
+ "flickr-line",
+ "flight-land-fill",
+ "flight-land-line",
+ "flight-takeoff-fill",
+ "flight-takeoff-line",
+ "flood-fill",
+ "flood-line",
+ "flow-chart",
+ "flutter-fill",
+ "flutter-line",
+ "focus-2-fill",
+ "focus-2-line",
+ "focus-3-fill",
+ "focus-3-line",
+ "focus-fill",
+ "focus-line",
+ "foggy-fill",
+ "foggy-line",
+ "folder-2-fill",
+ "folder-2-line",
+ "folder-3-fill",
+ "folder-3-line",
+ "folder-4-fill",
+ "folder-4-line",
+ "folder-5-fill",
+ "folder-5-line",
+ "folder-add-fill",
+ "folder-add-line",
+ "folder-chart-2-fill",
+ "folder-chart-2-line",
+ "folder-chart-fill",
+ "folder-chart-line",
+ "folder-download-fill",
+ "folder-download-line",
+ "folder-fill",
+ "folder-forbid-fill",
+ "folder-forbid-line",
+ "folder-history-fill",
+ "folder-history-line",
+ "folder-image-fill",
+ "folder-image-line",
+ "folder-info-fill",
+ "folder-info-line",
+ "folder-keyhole-fill",
+ "folder-keyhole-line",
+ "folder-line",
+ "folder-lock-fill",
+ "folder-lock-line",
+ "folder-music-fill",
+ "folder-music-line",
+ "folder-open-fill",
+ "folder-open-line",
+ "folder-received-fill",
+ "folder-received-line",
+ "folder-reduce-fill",
+ "folder-reduce-line",
+ "folder-settings-fill",
+ "folder-settings-line",
+ "folder-shared-fill",
+ "folder-shared-line",
+ "folder-shield-2-fill",
+ "folder-shield-2-line",
+ "folder-shield-fill",
+ "folder-shield-line",
+ "folder-transfer-fill",
+ "folder-transfer-line",
+ "folder-unknow-fill",
+ "folder-unknow-line",
+ "folder-upload-fill",
+ "folder-upload-line",
+ "folder-user-fill",
+ "folder-user-line",
+ "folder-video-fill",
+ "folder-video-line",
+ "folder-warning-fill",
+ "folder-warning-line",
+ "folder-zip-fill",
+ "folder-zip-line",
+ "folders-fill",
+ "folders-line",
+ "font-color",
+ "font-family",
+ "font-mono",
+ "font-sans",
+ "font-sans-serif",
+ "font-size",
+ "font-size-2",
+ "football-fill",
+ "football-line",
+ "footprint-fill",
+ "footprint-line",
+ "forbid-2-fill",
+ "forbid-2-line",
+ "forbid-fill",
+ "forbid-line",
+ "format-clear",
+ "forward-10-fill",
+ "forward-10-line",
+ "forward-15-fill",
+ "forward-15-line",
+ "forward-30-fill",
+ "forward-30-line",
+ "forward-5-fill",
+ "forward-5-line",
+ "fridge-fill",
+ "fridge-line",
+ "fullscreen-exit-fill",
+ "fullscreen-exit-line",
+ "fullscreen-fill",
+ "fullscreen-line",
+ "function-fill",
+ "function-line",
+ "functions",
+ "funds-box-fill",
+ "funds-box-line",
+ "funds-fill",
+ "funds-line",
+ "gallery-fill",
+ "gallery-line",
+ "gallery-upload-fill",
+ "gallery-upload-line",
+ "game-fill",
+ "game-line",
+ "gamepad-fill",
+ "gamepad-line",
+ "gas-station-fill",
+ "gas-station-line",
+ "gatsby-fill",
+ "gatsby-line",
+ "genderless-fill",
+ "genderless-line",
+ "ghost-2-fill",
+ "ghost-2-line",
+ "ghost-fill",
+ "ghost-line",
+ "ghost-smile-fill",
+ "ghost-smile-line",
+ "gift-2-fill",
+ "gift-2-line",
+ "gift-fill",
+ "gift-line",
+ "git-branch-fill",
+ "git-branch-line",
+ "git-close-pull-request-fill",
+ "git-close-pull-request-line",
+ "git-commit-fill",
+ "git-commit-line",
+ "git-merge-fill",
+ "git-merge-line",
+ "git-pull-request-fill",
+ "git-pull-request-line",
+ "git-repository-commits-fill",
+ "git-repository-commits-line",
+ "git-repository-fill",
+ "git-repository-line",
+ "git-repository-private-fill",
+ "git-repository-private-line",
+ "github-fill",
+ "github-line",
+ "gitlab-fill",
+ "gitlab-line",
+ "global-fill",
+ "global-line",
+ "globe-fill",
+ "globe-line",
+ "goblet-fill",
+ "goblet-line",
+ "google-fill",
+ "google-line",
+ "google-play-fill",
+ "google-play-line",
+ "government-fill",
+ "government-line",
+ "gps-fill",
+ "gps-line",
+ "gradienter-fill",
+ "gradienter-line",
+ "graduation-cap-fill",
+ "graduation-cap-line",
+ "grid-fill",
+ "grid-line",
+ "group-2-fill",
+ "group-2-line",
+ "group-fill",
+ "group-line",
+ "guide-fill",
+ "guide-line",
+ "h-1",
+ "h-2",
+ "h-3",
+ "h-4",
+ "h-5",
+ "h-6",
+ "hail-fill",
+ "hail-line",
+ "hammer-fill",
+ "hammer-line",
+ "hand-coin-fill",
+ "hand-coin-line",
+ "hand-heart-fill",
+ "hand-heart-line",
+ "hand-sanitizer-fill",
+ "hand-sanitizer-line",
+ "handbag-fill",
+ "handbag-line",
+ "hard-drive-2-fill",
+ "hard-drive-2-line",
+ "hard-drive-3-fill",
+ "hard-drive-3-line",
+ "hard-drive-fill",
+ "hard-drive-line",
+ "hashtag",
+ "haze-2-fill",
+ "haze-2-line",
+ "haze-fill",
+ "haze-line",
+ "hd-fill",
+ "hd-line",
+ "heading",
+ "headphone-fill",
+ "headphone-line",
+ "health-book-fill",
+ "health-book-line",
+ "heart-2-fill",
+ "heart-2-line",
+ "heart-3-fill",
+ "heart-3-line",
+ "heart-add-fill",
+ "heart-add-line",
+ "heart-fill",
+ "heart-line",
+ "heart-pulse-fill",
+ "heart-pulse-line",
+ "hearts-fill",
+ "hearts-line",
+ "heavy-showers-fill",
+ "heavy-showers-line",
+ "hexagon-fill",
+ "hexagon-line",
+ "history-fill",
+ "history-line",
+ "home-2-fill",
+ "home-2-line",
+ "home-3-fill",
+ "home-3-line",
+ "home-4-fill",
+ "home-4-line",
+ "home-5-fill",
+ "home-5-line",
+ "home-6-fill",
+ "home-6-line",
+ "home-7-fill",
+ "home-7-line",
+ "home-8-fill",
+ "home-8-line",
+ "home-fill",
+ "home-gear-fill",
+ "home-gear-line",
+ "home-heart-fill",
+ "home-heart-line",
+ "home-line",
+ "home-office-fill",
+ "home-office-line",
+ "home-smile-2-fill",
+ "home-smile-2-line",
+ "home-smile-fill",
+ "home-smile-line",
+ "home-wifi-fill",
+ "home-wifi-line",
+ "honor-of-kings-fill",
+ "honor-of-kings-line",
+ "honour-fill",
+ "honour-line",
+ "hospital-fill",
+ "hospital-line",
+ "hotel-bed-fill",
+ "hotel-bed-line",
+ "hotel-fill",
+ "hotel-line",
+ "hotspot-fill",
+ "hotspot-line",
+ "hourglass-2-fill",
+ "hourglass-2-line",
+ "hourglass-fill",
+ "hourglass-line",
+ "hq-fill",
+ "hq-line",
+ "html5-fill",
+ "html5-line",
+ "ie-fill",
+ "ie-line",
+ "image-2-fill",
+ "image-2-line",
+ "image-add-fill",
+ "image-add-line",
+ "image-edit-fill",
+ "image-edit-line",
+ "image-fill",
+ "image-line",
+ "inbox-2-fill",
+ "inbox-2-line",
+ "inbox-archive-fill",
+ "inbox-archive-line",
+ "inbox-fill",
+ "inbox-line",
+ "inbox-unarchive-fill",
+ "inbox-unarchive-line",
+ "increase-decrease-fill",
+ "increase-decrease-line",
+ "indent-decrease",
+ "indent-increase",
+ "indeterminate-circle-fill",
+ "indeterminate-circle-line",
+ "infinity-fill",
+ "infinity-line",
+ "information-fill",
+ "information-line",
+ "infrared-thermometer-fill",
+ "infrared-thermometer-line",
+ "ink-bottle-fill",
+ "ink-bottle-line",
+ "input-cursor-move",
+ "input-method-fill",
+ "input-method-line",
+ "insert-column-left",
+ "insert-column-right",
+ "insert-row-bottom",
+ "insert-row-top",
+ "instagram-fill",
+ "instagram-line",
+ "install-fill",
+ "install-line",
+ "instance-fill",
+ "instance-line",
+ "invision-fill",
+ "invision-line",
+ "italic",
+ "javascript-fill",
+ "javascript-line",
+ "kakao-talk-fill",
+ "kakao-talk-line",
+ "key-2-fill",
+ "key-2-line",
+ "key-fill",
+ "key-line",
+ "keyboard-box-fill",
+ "keyboard-box-line",
+ "keyboard-fill",
+ "keyboard-line",
+ "keynote-fill",
+ "keynote-line",
+ "kick-fill",
+ "kick-line",
+ "knife-blood-fill",
+ "knife-blood-line",
+ "knife-fill",
+ "knife-line",
+ "landscape-fill",
+ "landscape-line",
+ "layout-2-fill",
+ "layout-2-line",
+ "layout-3-fill",
+ "layout-3-line",
+ "layout-4-fill",
+ "layout-4-line",
+ "layout-5-fill",
+ "layout-5-line",
+ "layout-6-fill",
+ "layout-6-line",
+ "layout-bottom-2-fill",
+ "layout-bottom-2-line",
+ "layout-bottom-fill",
+ "layout-bottom-line",
+ "layout-column-fill",
+ "layout-column-line",
+ "layout-fill",
+ "layout-grid-fill",
+ "layout-grid-line",
+ "layout-left-2-fill",
+ "layout-left-2-line",
+ "layout-left-fill",
+ "layout-left-line",
+ "layout-line",
+ "layout-masonry-fill",
+ "layout-masonry-line",
+ "layout-right-2-fill",
+ "layout-right-2-line",
+ "layout-right-fill",
+ "layout-right-line",
+ "layout-row-fill",
+ "layout-row-line",
+ "layout-top-2-fill",
+ "layout-top-2-line",
+ "layout-top-fill",
+ "layout-top-line",
+ "leaf-fill",
+ "leaf-line",
+ "lifebuoy-fill",
+ "lifebuoy-line",
+ "lightbulb-fill",
+ "lightbulb-flash-fill",
+ "lightbulb-flash-line",
+ "lightbulb-line",
+ "line-chart-fill",
+ "line-chart-line",
+ "line-fill",
+ "line-height",
+ "line-line",
+ "link",
+ "link-m",
+ "link-unlink",
+ "link-unlink-m",
+ "linkedin-box-fill",
+ "linkedin-box-line",
+ "linkedin-fill",
+ "linkedin-line",
+ "links-fill",
+ "links-line",
+ "list-check",
+ "list-check-2",
+ "list-check-3",
+ "list-indefinite",
+ "list-ordered",
+ "list-ordered-2",
+ "list-radio",
+ "list-settings-fill",
+ "list-settings-line",
+ "list-unordered",
+ "live-fill",
+ "live-line",
+ "loader-2-fill",
+ "loader-2-line",
+ "loader-3-fill",
+ "loader-3-line",
+ "loader-4-fill",
+ "loader-4-line",
+ "loader-5-fill",
+ "loader-5-line",
+ "loader-fill",
+ "loader-line",
+ "lock-2-fill",
+ "lock-2-line",
+ "lock-fill",
+ "lock-line",
+ "lock-password-fill",
+ "lock-password-line",
+ "lock-unlock-fill",
+ "lock-unlock-line",
+ "login-box-fill",
+ "login-box-line",
+ "login-circle-fill",
+ "login-circle-line",
+ "logout-box-fill",
+ "logout-box-line",
+ "logout-box-r-fill",
+ "logout-box-r-line",
+ "logout-circle-fill",
+ "logout-circle-line",
+ "logout-circle-r-fill",
+ "logout-circle-r-line",
+ "loop-left-fill",
+ "loop-left-line",
+ "loop-right-fill",
+ "loop-right-line",
+ "luggage-cart-fill",
+ "luggage-cart-line",
+ "luggage-deposit-fill",
+ "luggage-deposit-line",
+ "lungs-fill",
+ "lungs-line",
+ "mac-fill",
+ "mac-line",
+ "macbook-fill",
+ "macbook-line",
+ "magic-fill",
+ "magic-line",
+ "mail-add-fill",
+ "mail-add-line",
+ "mail-check-fill",
+ "mail-check-line",
+ "mail-close-fill",
+ "mail-close-line",
+ "mail-download-fill",
+ "mail-download-line",
+ "mail-fill",
+ "mail-forbid-fill",
+ "mail-forbid-line",
+ "mail-line",
+ "mail-lock-fill",
+ "mail-lock-line",
+ "mail-open-fill",
+ "mail-open-line",
+ "mail-send-fill",
+ "mail-send-line",
+ "mail-settings-fill",
+ "mail-settings-line",
+ "mail-star-fill",
+ "mail-star-line",
+ "mail-unread-fill",
+ "mail-unread-line",
+ "mail-volume-fill",
+ "mail-volume-line",
+ "map-2-fill",
+ "map-2-line",
+ "map-fill",
+ "map-line",
+ "map-pin-2-fill",
+ "map-pin-2-line",
+ "map-pin-3-fill",
+ "map-pin-3-line",
+ "map-pin-4-fill",
+ "map-pin-4-line",
+ "map-pin-5-fill",
+ "map-pin-5-line",
+ "map-pin-add-fill",
+ "map-pin-add-line",
+ "map-pin-fill",
+ "map-pin-line",
+ "map-pin-range-fill",
+ "map-pin-range-line",
+ "map-pin-time-fill",
+ "map-pin-time-line",
+ "map-pin-user-fill",
+ "map-pin-user-line",
+ "mark-pen-fill",
+ "mark-pen-line",
+ "markdown-fill",
+ "markdown-line",
+ "markup-fill",
+ "markup-line",
+ "mastercard-fill",
+ "mastercard-line",
+ "mastodon-fill",
+ "mastodon-line",
+ "medal-2-fill",
+ "medal-2-line",
+ "medal-fill",
+ "medal-line",
+ "medicine-bottle-fill",
+ "medicine-bottle-line",
+ "medium-fill",
+ "medium-line",
+ "megaphone-fill",
+ "megaphone-line",
+ "memories-fill",
+ "memories-line",
+ "men-fill",
+ "men-line",
+ "mental-health-fill",
+ "mental-health-line",
+ "menu-2-fill",
+ "menu-2-line",
+ "menu-3-fill",
+ "menu-3-line",
+ "menu-4-fill",
+ "menu-4-line",
+ "menu-5-fill",
+ "menu-5-line",
+ "menu-add-fill",
+ "menu-add-line",
+ "menu-fill",
+ "menu-fold-fill",
+ "menu-fold-line",
+ "menu-line",
+ "menu-search-fill",
+ "menu-search-line",
+ "menu-unfold-fill",
+ "menu-unfold-line",
+ "merge-cells-horizontal",
+ "merge-cells-vertical",
+ "message-2-fill",
+ "message-2-line",
+ "message-3-fill",
+ "message-3-line",
+ "message-fill",
+ "message-line",
+ "messenger-fill",
+ "messenger-line",
+ "meta-fill",
+ "meta-line",
+ "meteor-fill",
+ "meteor-line",
+ "mic-2-fill",
+ "mic-2-line",
+ "mic-fill",
+ "mic-line",
+ "mic-off-fill",
+ "mic-off-line",
+ "mickey-fill",
+ "mickey-line",
+ "microscope-fill",
+ "microscope-line",
+ "microsoft-fill",
+ "microsoft-line",
+ "microsoft-loop-fill",
+ "microsoft-loop-line",
+ "mind-map",
+ "mini-program-fill",
+ "mini-program-line",
+ "mist-fill",
+ "mist-line",
+ "money-cny-box-fill",
+ "money-cny-box-line",
+ "money-cny-circle-fill",
+ "money-cny-circle-line",
+ "money-dollar-box-fill",
+ "money-dollar-box-line",
+ "money-dollar-circle-fill",
+ "money-dollar-circle-line",
+ "money-euro-box-fill",
+ "money-euro-box-line",
+ "money-euro-circle-fill",
+ "money-euro-circle-line",
+ "money-pound-box-fill",
+ "money-pound-box-line",
+ "money-pound-circle-fill",
+ "money-pound-circle-line",
+ "moon-clear-fill",
+ "moon-clear-line",
+ "moon-cloudy-fill",
+ "moon-cloudy-line",
+ "moon-fill",
+ "moon-foggy-fill",
+ "moon-foggy-line",
+ "moon-line",
+ "more-2-fill",
+ "more-2-line",
+ "more-fill",
+ "more-line",
+ "motorbike-fill",
+ "motorbike-line",
+ "mouse-fill",
+ "mouse-line",
+ "movie-2-fill",
+ "movie-2-line",
+ "movie-fill",
+ "movie-line",
+ "music-2-fill",
+ "music-2-line",
+ "music-fill",
+ "music-line",
+ "mv-fill",
+ "mv-line",
+ "navigation-fill",
+ "navigation-line",
+ "netease-cloud-music-fill",
+ "netease-cloud-music-line",
+ "netflix-fill",
+ "netflix-line",
+ "newspaper-fill",
+ "newspaper-line",
+ "nft-fill",
+ "nft-line",
+ "node-tree",
+ "notification-2-fill",
+ "notification-2-line",
+ "notification-3-fill",
+ "notification-3-line",
+ "notification-4-fill",
+ "notification-4-line",
+ "notification-badge-fill",
+ "notification-badge-line",
+ "notification-fill",
+ "notification-line",
+ "notification-off-fill",
+ "notification-off-line",
+ "notion-fill",
+ "notion-line",
+ "npmjs-fill",
+ "npmjs-line",
+ "number-0",
+ "number-1",
+ "number-2",
+ "number-3",
+ "number-4",
+ "number-5",
+ "number-6",
+ "number-7",
+ "number-8",
+ "number-9",
+ "numbers-fill",
+ "numbers-line",
+ "nurse-fill",
+ "nurse-line",
+ "octagon-fill",
+ "octagon-line",
+ "oil-fill",
+ "oil-line",
+ "omega",
+ "open-arm-fill",
+ "open-arm-line",
+ "open-source-fill",
+ "open-source-line",
+ "openai-fill",
+ "openai-line",
+ "openbase-fill",
+ "openbase-line",
+ "opera-fill",
+ "opera-line",
+ "order-play-fill",
+ "order-play-line",
+ "organization-chart",
+ "outlet-2-fill",
+ "outlet-2-line",
+ "outlet-fill",
+ "outlet-line",
+ "overline",
+ "p2p-fill",
+ "p2p-line",
+ "page-separator",
+ "pages-fill",
+ "pages-line",
+ "paint-brush-fill",
+ "paint-brush-line",
+ "paint-fill",
+ "paint-line",
+ "palette-fill",
+ "palette-line",
+ "pantone-fill",
+ "pantone-line",
+ "paragraph",
+ "parent-fill",
+ "parent-line",
+ "parentheses-fill",
+ "parentheses-line",
+ "parking-box-fill",
+ "parking-box-line",
+ "parking-fill",
+ "parking-line",
+ "pass-expired-fill",
+ "pass-expired-line",
+ "pass-pending-fill",
+ "pass-pending-line",
+ "pass-valid-fill",
+ "pass-valid-line",
+ "passport-fill",
+ "passport-line",
+ "patreon-fill",
+ "patreon-line",
+ "pause-circle-fill",
+ "pause-circle-line",
+ "pause-fill",
+ "pause-line",
+ "pause-mini-fill",
+ "pause-mini-line",
+ "paypal-fill",
+ "paypal-line",
+ "pen-nib-fill",
+ "pen-nib-line",
+ "pencil-fill",
+ "pencil-line",
+ "pencil-ruler-2-fill",
+ "pencil-ruler-2-line",
+ "pencil-ruler-fill",
+ "pencil-ruler-line",
+ "pentagon-fill",
+ "pentagon-line",
+ "percent-fill",
+ "percent-line",
+ "phone-camera-fill",
+ "phone-camera-line",
+ "phone-fill",
+ "phone-find-fill",
+ "phone-find-line",
+ "phone-line",
+ "phone-lock-fill",
+ "phone-lock-line",
+ "picture-in-picture-2-fill",
+ "picture-in-picture-2-line",
+ "picture-in-picture-exit-fill",
+ "picture-in-picture-exit-line",
+ "picture-in-picture-fill",
+ "picture-in-picture-line",
+ "pie-chart-2-fill",
+ "pie-chart-2-line",
+ "pie-chart-box-fill",
+ "pie-chart-box-line",
+ "pie-chart-fill",
+ "pie-chart-line",
+ "pin-distance-fill",
+ "pin-distance-line",
+ "ping-pong-fill",
+ "ping-pong-line",
+ "pinterest-fill",
+ "pinterest-line",
+ "pinyin-input",
+ "pixelfed-fill",
+ "pixelfed-line",
+ "plane-fill",
+ "plane-line",
+ "planet-fill",
+ "planet-line",
+ "plant-fill",
+ "plant-line",
+ "play-circle-fill",
+ "play-circle-line",
+ "play-fill",
+ "play-line",
+ "play-list-2-fill",
+ "play-list-2-line",
+ "play-list-add-fill",
+ "play-list-add-line",
+ "play-list-fill",
+ "play-list-line",
+ "play-mini-fill",
+ "play-mini-line",
+ "playstation-fill",
+ "playstation-line",
+ "plug-2-fill",
+ "plug-2-line",
+ "plug-fill",
+ "plug-line",
+ "polaroid-2-fill",
+ "polaroid-2-line",
+ "polaroid-fill",
+ "polaroid-line",
+ "police-car-fill",
+ "police-car-line",
+ "presentation-fill",
+ "presentation-line",
+ "price-tag-2-fill",
+ "price-tag-2-line",
+ "price-tag-3-fill",
+ "price-tag-3-line",
+ "price-tag-fill",
+ "price-tag-line",
+ "printer-cloud-fill",
+ "printer-cloud-line",
+ "printer-fill",
+ "printer-line",
+ "product-hunt-fill",
+ "product-hunt-line",
+ "profile-fill",
+ "profile-line",
+ "prohibited-fill",
+ "prohibited-line",
+ "projector-2-fill",
+ "projector-2-line",
+ "projector-fill",
+ "projector-line",
+ "psychotherapy-fill",
+ "psychotherapy-line",
+ "pulse-fill",
+ "pulse-line",
+ "pushpin-2-fill",
+ "pushpin-2-line",
+ "pushpin-fill",
+ "pushpin-line",
+ "qq-fill",
+ "qq-line",
+ "qr-code-fill",
+ "qr-code-line",
+ "qr-scan-2-fill",
+ "qr-scan-2-line",
+ "qr-scan-fill",
+ "qr-scan-line",
+ "question-answer-fill",
+ "question-answer-line",
+ "question-fill",
+ "question-line",
+ "question-mark",
+ "questionnaire-fill",
+ "questionnaire-line",
+ "quill-pen-fill",
+ "quill-pen-line",
+ "quote-text",
+ "radar-fill",
+ "radar-line",
+ "radio-2-fill",
+ "radio-2-line",
+ "radio-button-fill",
+ "radio-button-line",
+ "radio-fill",
+ "radio-line",
+ "rainbow-fill",
+ "rainbow-line",
+ "rainy-fill",
+ "rainy-line",
+ "reactjs-fill",
+ "reactjs-line",
+ "record-circle-fill",
+ "record-circle-line",
+ "record-mail-fill",
+ "record-mail-line",
+ "rectangle-fill",
+ "rectangle-line",
+ "recycle-fill",
+ "recycle-line",
+ "red-packet-fill",
+ "red-packet-line",
+ "reddit-fill",
+ "reddit-line",
+ "refresh-fill",
+ "refresh-line",
+ "refund-2-fill",
+ "refund-2-line",
+ "refund-fill",
+ "refund-line",
+ "registered-fill",
+ "registered-line",
+ "remixicon-fill",
+ "remixicon-line",
+ "remote-control-2-fill",
+ "remote-control-2-line",
+ "remote-control-fill",
+ "remote-control-line",
+ "repeat-2-fill",
+ "repeat-2-line",
+ "repeat-fill",
+ "repeat-line",
+ "repeat-one-fill",
+ "repeat-one-line",
+ "replay-10-fill",
+ "replay-10-line",
+ "replay-15-fill",
+ "replay-15-line",
+ "replay-30-fill",
+ "replay-30-line",
+ "replay-5-fill",
+ "replay-5-line",
+ "reply-all-fill",
+ "reply-all-line",
+ "reply-fill",
+ "reply-line",
+ "reserved-fill",
+ "reserved-line",
+ "rest-time-fill",
+ "rest-time-line",
+ "restart-fill",
+ "restart-line",
+ "restaurant-2-fill",
+ "restaurant-2-line",
+ "restaurant-fill",
+ "restaurant-line",
+ "rewind-fill",
+ "rewind-line",
+ "rewind-mini-fill",
+ "rewind-mini-line",
+ "rfid-fill",
+ "rfid-line",
+ "rhythm-fill",
+ "rhythm-line",
+ "riding-fill",
+ "riding-line",
+ "road-map-fill",
+ "road-map-line",
+ "roadster-fill",
+ "roadster-line",
+ "robot-2-fill",
+ "robot-2-line",
+ "robot-fill",
+ "robot-line",
+ "rocket-2-fill",
+ "rocket-2-line",
+ "rocket-fill",
+ "rocket-line",
+ "rotate-lock-fill",
+ "rotate-lock-line",
+ "rounded-corner",
+ "route-fill",
+ "route-line",
+ "router-fill",
+ "router-line",
+ "rss-fill",
+ "rss-line",
+ "ruler-2-fill",
+ "ruler-2-line",
+ "ruler-fill",
+ "ruler-line",
+ "run-fill",
+ "run-line",
+ "safari-fill",
+ "safari-line",
+ "safe-2-fill",
+ "safe-2-line",
+ "safe-fill",
+ "safe-line",
+ "sailboat-fill",
+ "sailboat-line",
+ "save-2-fill",
+ "save-2-line",
+ "save-3-fill",
+ "save-3-line",
+ "save-fill",
+ "save-line",
+ "scales-2-fill",
+ "scales-2-line",
+ "scales-3-fill",
+ "scales-3-line",
+ "scales-fill",
+ "scales-line",
+ "scan-2-fill",
+ "scan-2-line",
+ "scan-fill",
+ "scan-line",
+ "school-fill",
+ "school-line",
+ "scissors-2-fill",
+ "scissors-2-line",
+ "scissors-cut-fill",
+ "scissors-cut-line",
+ "scissors-fill",
+ "scissors-line",
+ "screenshot-2-fill",
+ "screenshot-2-line",
+ "screenshot-fill",
+ "screenshot-line",
+ "sd-card-fill",
+ "sd-card-line",
+ "sd-card-mini-fill",
+ "sd-card-mini-line",
+ "search-2-fill",
+ "search-2-line",
+ "search-eye-fill",
+ "search-eye-line",
+ "search-fill",
+ "search-line",
+ "secure-payment-fill",
+ "secure-payment-line",
+ "seedling-fill",
+ "seedling-line",
+ "send-backward",
+ "send-plane-2-fill",
+ "send-plane-2-line",
+ "send-plane-fill",
+ "send-plane-line",
+ "send-to-back",
+ "sensor-fill",
+ "sensor-line",
+ "seo-fill",
+ "seo-line",
+ "separator",
+ "server-fill",
+ "server-line",
+ "service-fill",
+ "service-line",
+ "settings-2-fill",
+ "settings-2-line",
+ "settings-3-fill",
+ "settings-3-line",
+ "settings-4-fill",
+ "settings-4-line",
+ "settings-5-fill",
+ "settings-5-line",
+ "settings-6-fill",
+ "settings-6-line",
+ "settings-fill",
+ "settings-line",
+ "shake-hands-fill",
+ "shake-hands-line",
+ "shape-2-fill",
+ "shape-2-line",
+ "shape-fill",
+ "shape-line",
+ "shapes-fill",
+ "shapes-line",
+ "share-box-fill",
+ "share-box-line",
+ "share-circle-fill",
+ "share-circle-line",
+ "share-fill",
+ "share-forward-2-fill",
+ "share-forward-2-line",
+ "share-forward-box-fill",
+ "share-forward-box-line",
+ "share-forward-fill",
+ "share-forward-line",
+ "share-line",
+ "shield-check-fill",
+ "shield-check-line",
+ "shield-cross-fill",
+ "shield-cross-line",
+ "shield-fill",
+ "shield-flash-fill",
+ "shield-flash-line",
+ "shield-keyhole-fill",
+ "shield-keyhole-line",
+ "shield-line",
+ "shield-star-fill",
+ "shield-star-line",
+ "shield-user-fill",
+ "shield-user-line",
+ "shining-2-fill",
+ "shining-2-line",
+ "shining-fill",
+ "shining-line",
+ "ship-2-fill",
+ "ship-2-line",
+ "ship-fill",
+ "ship-line",
+ "shirt-fill",
+ "shirt-line",
+ "shopping-bag-2-fill",
+ "shopping-bag-2-line",
+ "shopping-bag-3-fill",
+ "shopping-bag-3-line",
+ "shopping-bag-fill",
+ "shopping-bag-line",
+ "shopping-basket-2-fill",
+ "shopping-basket-2-line",
+ "shopping-basket-fill",
+ "shopping-basket-line",
+ "shopping-cart-2-fill",
+ "shopping-cart-2-line",
+ "shopping-cart-fill",
+ "shopping-cart-line",
+ "showers-fill",
+ "showers-line",
+ "shuffle-fill",
+ "shuffle-line",
+ "shut-down-fill",
+ "shut-down-line",
+ "side-bar-fill",
+ "side-bar-line",
+ "signal-tower-fill",
+ "signal-tower-line",
+ "signal-wifi-1-fill",
+ "signal-wifi-1-line",
+ "signal-wifi-2-fill",
+ "signal-wifi-2-line",
+ "signal-wifi-3-fill",
+ "signal-wifi-3-line",
+ "signal-wifi-error-fill",
+ "signal-wifi-error-line",
+ "signal-wifi-fill",
+ "signal-wifi-line",
+ "signal-wifi-off-fill",
+ "signal-wifi-off-line",
+ "sim-card-2-fill",
+ "sim-card-2-line",
+ "sim-card-fill",
+ "sim-card-line",
+ "single-quotes-l",
+ "single-quotes-r",
+ "sip-fill",
+ "sip-line",
+ "sketching",
+ "skip-back-fill",
+ "skip-back-line",
+ "skip-back-mini-fill",
+ "skip-back-mini-line",
+ "skip-down-fill",
+ "skip-down-line",
+ "skip-forward-fill",
+ "skip-forward-line",
+ "skip-forward-mini-fill",
+ "skip-forward-mini-line",
+ "skip-left-fill",
+ "skip-left-line",
+ "skip-right-fill",
+ "skip-right-line",
+ "skip-up-fill",
+ "skip-up-line",
+ "skull-2-fill",
+ "skull-2-line",
+ "skull-fill",
+ "skull-line",
+ "skype-fill",
+ "skype-line",
+ "slack-fill",
+ "slack-line",
+ "slash-commands",
+ "slash-commands-2",
+ "slice-fill",
+ "slice-line",
+ "slideshow-2-fill",
+ "slideshow-2-line",
+ "slideshow-3-fill",
+ "slideshow-3-line",
+ "slideshow-4-fill",
+ "slideshow-4-line",
+ "slideshow-fill",
+ "slideshow-line",
+ "slow-down-fill",
+ "slow-down-line",
+ "smartphone-fill",
+ "smartphone-line",
+ "snapchat-fill",
+ "snapchat-line",
+ "snowy-fill",
+ "snowy-line",
+ "sort-asc",
+ "sort-desc",
+ "sound-module-fill",
+ "sound-module-line",
+ "soundcloud-fill",
+ "soundcloud-line",
+ "space",
+ "space-ship-fill",
+ "space-ship-line",
+ "spam-2-fill",
+ "spam-2-line",
+ "spam-3-fill",
+ "spam-3-line",
+ "spam-fill",
+ "spam-line",
+ "sparkling-2-fill",
+ "sparkling-2-line",
+ "sparkling-fill",
+ "sparkling-line",
+ "speak-fill",
+ "speak-line",
+ "speaker-2-fill",
+ "speaker-2-line",
+ "speaker-3-fill",
+ "speaker-3-line",
+ "speaker-fill",
+ "speaker-line",
+ "spectrum-fill",
+ "spectrum-line",
+ "speed-fill",
+ "speed-line",
+ "speed-mini-fill",
+ "speed-mini-line",
+ "speed-up-fill",
+ "speed-up-line",
+ "split-cells-horizontal",
+ "split-cells-vertical",
+ "spotify-fill",
+ "spotify-line",
+ "spy-fill",
+ "spy-line",
+ "square-fill",
+ "square-line",
+ "stack-fill",
+ "stack-line",
+ "stack-overflow-fill",
+ "stack-overflow-line",
+ "stackshare-fill",
+ "stackshare-line",
+ "star-fill",
+ "star-half-fill",
+ "star-half-line",
+ "star-half-s-fill",
+ "star-half-s-line",
+ "star-line",
+ "star-s-fill",
+ "star-s-line",
+ "star-smile-fill",
+ "star-smile-line",
+ "steam-fill",
+ "steam-line",
+ "steering-2-fill",
+ "steering-2-line",
+ "steering-fill",
+ "steering-line",
+ "stethoscope-fill",
+ "stethoscope-line",
+ "sticky-note-2-fill",
+ "sticky-note-2-line",
+ "sticky-note-fill",
+ "sticky-note-line",
+ "stock-fill",
+ "stock-line",
+ "stop-circle-fill",
+ "stop-circle-line",
+ "stop-fill",
+ "stop-line",
+ "stop-mini-fill",
+ "stop-mini-line",
+ "store-2-fill",
+ "store-2-line",
+ "store-3-fill",
+ "store-3-line",
+ "store-fill",
+ "store-line",
+ "strikethrough",
+ "strikethrough-2",
+ "subscript",
+ "subscript-2",
+ "subtract-fill",
+ "subtract-line",
+ "subway-fill",
+ "subway-line",
+ "subway-wifi-fill",
+ "subway-wifi-line",
+ "suitcase-2-fill",
+ "suitcase-2-line",
+ "suitcase-3-fill",
+ "suitcase-3-line",
+ "suitcase-fill",
+ "suitcase-line",
+ "sun-cloudy-fill",
+ "sun-cloudy-line",
+ "sun-fill",
+ "sun-foggy-fill",
+ "sun-foggy-line",
+ "sun-line",
+ "supabase-fill",
+ "supabase-line",
+ "superscript",
+ "superscript-2",
+ "surgical-mask-fill",
+ "surgical-mask-line",
+ "surround-sound-fill",
+ "surround-sound-line",
+ "survey-fill",
+ "survey-line",
+ "swap-box-fill",
+ "swap-box-line",
+ "swap-fill",
+ "swap-line",
+ "switch-fill",
+ "switch-line",
+ "sword-fill",
+ "sword-line",
+ "syringe-fill",
+ "syringe-line",
+ "t-box-fill",
+ "t-box-line",
+ "t-shirt-2-fill",
+ "t-shirt-2-line",
+ "t-shirt-air-fill",
+ "t-shirt-air-line",
+ "t-shirt-fill",
+ "t-shirt-line",
+ "table-2",
+ "table-alt-fill",
+ "table-alt-line",
+ "table-fill",
+ "table-line",
+ "tablet-fill",
+ "tablet-line",
+ "takeaway-fill",
+ "takeaway-line",
+ "taobao-fill",
+ "taobao-line",
+ "tape-fill",
+ "tape-line",
+ "task-fill",
+ "task-line",
+ "taxi-fill",
+ "taxi-line",
+ "taxi-wifi-fill",
+ "taxi-wifi-line",
+ "team-fill",
+ "team-line",
+ "telegram-fill",
+ "telegram-line",
+ "temp-cold-fill",
+ "temp-cold-line",
+ "temp-hot-fill",
+ "temp-hot-line",
+ "tent-fill",
+ "tent-line",
+ "terminal-box-fill",
+ "terminal-box-line",
+ "terminal-fill",
+ "terminal-line",
+ "terminal-window-fill",
+ "terminal-window-line",
+ "test-tube-fill",
+ "test-tube-line",
+ "text",
+ "text-direction-l",
+ "text-direction-r",
+ "text-spacing",
+ "text-wrap",
+ "thermometer-fill",
+ "thermometer-line",
+ "threads-fill",
+ "threads-line",
+ "thumb-down-fill",
+ "thumb-down-line",
+ "thumb-up-fill",
+ "thumb-up-line",
+ "thunderstorms-fill",
+ "thunderstorms-line",
+ "ticket-2-fill",
+ "ticket-2-line",
+ "ticket-fill",
+ "ticket-line",
+ "tiktok-fill",
+ "tiktok-line",
+ "time-fill",
+ "time-line",
+ "timer-2-fill",
+ "timer-2-line",
+ "timer-fill",
+ "timer-flash-fill",
+ "timer-flash-line",
+ "timer-line",
+ "todo-fill",
+ "todo-line",
+ "toggle-fill",
+ "toggle-line",
+ "token-swap-fill",
+ "token-swap-line",
+ "tools-fill",
+ "tools-line",
+ "tornado-fill",
+ "tornado-line",
+ "trademark-fill",
+ "trademark-line",
+ "traffic-light-fill",
+ "traffic-light-line",
+ "train-fill",
+ "train-line",
+ "train-wifi-fill",
+ "train-wifi-line",
+ "translate",
+ "translate-2",
+ "travesti-fill",
+ "travesti-line",
+ "treasure-map-fill",
+ "treasure-map-line",
+ "tree-fill",
+ "tree-line",
+ "trello-fill",
+ "trello-line",
+ "triangle-fill",
+ "triangle-line",
+ "trophy-fill",
+ "trophy-line",
+ "truck-fill",
+ "truck-line",
+ "tumblr-fill",
+ "tumblr-line",
+ "tv-2-fill",
+ "tv-2-line",
+ "tv-fill",
+ "tv-line",
+ "twitch-fill",
+ "twitch-line",
+ "twitter-fill",
+ "twitter-line",
+ "twitter-x-fill",
+ "twitter-x-line",
+ "typhoon-fill",
+ "typhoon-line",
+ "u-disk-fill",
+ "u-disk-line",
+ "ubuntu-fill",
+ "ubuntu-line",
+ "umbrella-fill",
+ "umbrella-line",
+ "underline",
+ "uninstall-fill",
+ "uninstall-line",
+ "unpin-fill",
+ "unpin-line",
+ "unsplash-fill",
+ "unsplash-line",
+ "upload-2-fill",
+ "upload-2-line",
+ "upload-cloud-2-fill",
+ "upload-cloud-2-line",
+ "upload-cloud-fill",
+ "upload-cloud-line",
+ "upload-fill",
+ "upload-line",
+ "usb-fill",
+ "usb-line",
+ "user-2-fill",
+ "user-2-line",
+ "user-3-fill",
+ "user-3-line",
+ "user-4-fill",
+ "user-4-line",
+ "user-5-fill",
+ "user-5-line",
+ "user-6-fill",
+ "user-6-line",
+ "user-add-fill",
+ "user-add-line",
+ "user-fill",
+ "user-follow-fill",
+ "user-follow-line",
+ "user-forbid-fill",
+ "user-forbid-line",
+ "user-heart-fill",
+ "user-heart-line",
+ "user-line",
+ "user-location-fill",
+ "user-location-line",
+ "user-received-2-fill",
+ "user-received-2-line",
+ "user-received-fill",
+ "user-received-line",
+ "user-search-fill",
+ "user-search-line",
+ "user-settings-fill",
+ "user-settings-line",
+ "user-shared-2-fill",
+ "user-shared-2-line",
+ "user-shared-fill",
+ "user-shared-line",
+ "user-smile-fill",
+ "user-smile-line",
+ "user-star-fill",
+ "user-star-line",
+ "user-unfollow-fill",
+ "user-unfollow-line",
+ "user-voice-fill",
+ "user-voice-line",
+ "verified-badge-fill",
+ "verified-badge-line",
+ "video-add-fill",
+ "video-add-line",
+ "video-chat-fill",
+ "video-chat-line",
+ "video-download-fill",
+ "video-download-line",
+ "video-fill",
+ "video-line",
+ "video-upload-fill",
+ "video-upload-line",
+ "vidicon-2-fill",
+ "vidicon-2-line",
+ "vidicon-fill",
+ "vidicon-line",
+ "vimeo-fill",
+ "vimeo-line",
+ "vip-crown-2-fill",
+ "vip-crown-2-line",
+ "vip-crown-fill",
+ "vip-crown-line",
+ "vip-diamond-fill",
+ "vip-diamond-line",
+ "vip-fill",
+ "vip-line",
+ "virus-fill",
+ "virus-line",
+ "visa-fill",
+ "visa-line",
+ "voice-recognition-fill",
+ "voice-recognition-line",
+ "voiceprint-fill",
+ "voiceprint-line",
+ "volume-down-fill",
+ "volume-down-line",
+ "volume-mute-fill",
+ "volume-mute-line",
+ "volume-off-vibrate-fill",
+ "volume-off-vibrate-line",
+ "volume-up-fill",
+ "volume-up-line",
+ "volume-vibrate-fill",
+ "volume-vibrate-line",
+ "vuejs-fill",
+ "vuejs-line",
+ "walk-fill",
+ "walk-line",
+ "wallet-2-fill",
+ "wallet-2-line",
+ "wallet-3-fill",
+ "wallet-3-line",
+ "wallet-fill",
+ "wallet-line",
+ "water-flash-fill",
+ "water-flash-line",
+ "water-percent-fill",
+ "water-percent-line",
+ "webcam-fill",
+ "webcam-line",
+ "wechat-2-fill",
+ "wechat-2-line",
+ "wechat-channels-fill",
+ "wechat-channels-line",
+ "wechat-fill",
+ "wechat-line",
+ "wechat-pay-fill",
+ "wechat-pay-line",
+ "weibo-fill",
+ "weibo-line",
+ "whatsapp-fill",
+ "whatsapp-line",
+ "wheelchair-fill",
+ "wheelchair-line",
+ "wifi-fill",
+ "wifi-line",
+ "wifi-off-fill",
+ "wifi-off-line",
+ "window-2-fill",
+ "window-2-line",
+ "window-fill",
+ "window-line",
+ "windows-fill",
+ "windows-line",
+ "windy-fill",
+ "windy-line",
+ "wireless-charging-fill",
+ "wireless-charging-line",
+ "women-fill",
+ "women-line",
+ "wordpress-fill",
+ "wordpress-line",
+ "wubi-input",
+ "xbox-fill",
+ "xbox-line",
+ "xing-fill",
+ "xing-line",
+ "youtube-fill",
+ "youtube-line",
+ "yuque-fill",
+ "yuque-line",
+ "zcool-fill",
+ "zcool-line",
+ "zhihu-fill",
+ "zhihu-line",
+ "zoom-in-fill",
+ "zoom-in-line",
+ "zoom-out-fill",
+ "zoom-out-line",
+ "zzz-fill",
+ "zzz-line"
+ ],
+ // https://icones.js.org/collections/fa-solid-meta.json
+ "fa-solid:": [
+ "abacus",
+ "ad",
+ "address-book",
+ "address-card",
+ "adjust",
+ "air-freshener",
+ "align-center",
+ "align-justify",
+ "align-left",
+ "align-right",
+ "allergies",
+ "ambulance",
+ "american-sign-language-interpreting",
+ "anchor",
+ "angle-double-down",
+ "angle-double-left",
+ "angle-double-right",
+ "angle-double-up",
+ "angle-down",
+ "angle-left",
+ "angle-right",
+ "angle-up",
+ "angry",
+ "ankh",
+ "apple-alt",
+ "archive",
+ "archway",
+ "arrow-alt-circle-down",
+ "arrow-alt-circle-left",
+ "arrow-alt-circle-right",
+ "arrow-alt-circle-up",
+ "arrow-circle-down",
+ "arrow-circle-left",
+ "arrow-circle-right",
+ "arrow-circle-up",
+ "arrow-down",
+ "arrow-left",
+ "arrow-right",
+ "arrow-up",
+ "arrows-alt",
+ "arrows-alt-h",
+ "arrows-alt-v",
+ "assistive-listening-systems",
+ "asterisk",
+ "at",
+ "atlas",
+ "atom",
+ "audio-description",
+ "award",
+ "baby",
+ "baby-carriage",
+ "backspace",
+ "backward",
+ "bacon",
+ "bacteria",
+ "bacterium",
+ "bahai",
+ "balance-scale",
+ "balance-scale-left",
+ "balance-scale-right",
+ "ban",
+ "band-aid",
+ "barcode",
+ "bars",
+ "baseball-ball",
+ "basketball-ball",
+ "bath",
+ "battery-empty",
+ "battery-full",
+ "battery-half",
+ "battery-quarter",
+ "battery-three-quarters",
+ "bed",
+ "beer",
+ "bell",
+ "bell-slash",
+ "bezier-curve",
+ "bible",
+ "bicycle",
+ "biking",
+ "binoculars",
+ "biohazard",
+ "birthday-cake",
+ "blender",
+ "blender-phone",
+ "blind",
+ "blog",
+ "bold",
+ "bolt",
+ "bomb",
+ "bone",
+ "bong",
+ "book",
+ "book-dead",
+ "book-medical",
+ "book-open",
+ "book-reader",
+ "bookmark",
+ "border-all",
+ "border-none",
+ "border-style",
+ "bowling-ball",
+ "box",
+ "box-open",
+ "box-tissue",
+ "boxes",
+ "braille",
+ "brain",
+ "bread-slice",
+ "briefcase",
+ "briefcase-medical",
+ "broadcast-tower",
+ "broom",
+ "brush",
+ "bug",
+ "building",
+ "bullhorn",
+ "bullseye",
+ "burn",
+ "bus",
+ "bus-alt",
+ "business-time",
+ "calculator",
+ "calculator-alt",
+ "calendar",
+ "calendar-alt",
+ "calendar-check",
+ "calendar-day",
+ "calendar-minus",
+ "calendar-plus",
+ "calendar-times",
+ "calendar-week",
+ "camera",
+ "camera-retro",
+ "campground",
+ "candy-cane",
+ "cannabis",
+ "capsules",
+ "car",
+ "car-alt",
+ "car-battery",
+ "car-crash",
+ "car-side",
+ "caravan",
+ "caret-down",
+ "caret-left",
+ "caret-right",
+ "caret-square-down",
+ "caret-square-left",
+ "caret-square-right",
+ "caret-square-up",
+ "caret-up",
+ "carrot",
+ "cart-arrow-down",
+ "cart-plus",
+ "cash-register",
+ "cat",
+ "certificate",
+ "chair",
+ "chalkboard",
+ "chalkboard-teacher",
+ "charging-station",
+ "chart-area",
+ "chart-bar",
+ "chart-line",
+ "chart-pie",
+ "check",
+ "check-circle",
+ "check-double",
+ "check-square",
+ "cheese",
+ "chess",
+ "chess-bishop",
+ "chess-board",
+ "chess-king",
+ "chess-knight",
+ "chess-pawn",
+ "chess-queen",
+ "chess-rook",
+ "chevron-circle-down",
+ "chevron-circle-left",
+ "chevron-circle-right",
+ "chevron-circle-up",
+ "chevron-down",
+ "chevron-left",
+ "chevron-right",
+ "chevron-up",
+ "child",
+ "church",
+ "circle",
+ "circle-notch",
+ "city",
+ "clinic-medical",
+ "clipboard",
+ "clipboard-check",
+ "clipboard-list",
+ "clock",
+ "clone",
+ "closed-captioning",
+ "cloud",
+ "cloud-download-alt",
+ "cloud-meatball",
+ "cloud-moon",
+ "cloud-moon-rain",
+ "cloud-rain",
+ "cloud-showers-heavy",
+ "cloud-sun",
+ "cloud-sun-rain",
+ "cloud-upload-alt",
+ "cocktail",
+ "code",
+ "code-branch",
+ "coffee",
+ "cog",
+ "cogs",
+ "coins",
+ "columns",
+ "comment",
+ "comment-alt",
+ "comment-dollar",
+ "comment-dots",
+ "comment-medical",
+ "comment-slash",
+ "comments",
+ "comments-dollar",
+ "compact-disc",
+ "compass",
+ "compress",
+ "compress-alt",
+ "compress-arrows-alt",
+ "concierge-bell",
+ "cookie",
+ "cookie-bite",
+ "copy",
+ "copyright",
+ "couch",
+ "credit-card",
+ "crop",
+ "crop-alt",
+ "cross",
+ "crosshairs",
+ "crow",
+ "crown",
+ "crutch",
+ "cube",
+ "cubes",
+ "cut",
+ "database",
+ "deaf",
+ "democrat",
+ "desktop",
+ "dharmachakra",
+ "diagnoses",
+ "dice",
+ "dice-d20",
+ "dice-d6",
+ "dice-five",
+ "dice-four",
+ "dice-one",
+ "dice-six",
+ "dice-three",
+ "dice-two",
+ "digital-tachograph",
+ "directions",
+ "disease",
+ "divide",
+ "dizzy",
+ "dna",
+ "dog",
+ "dollar-sign",
+ "dolly",
+ "dolly-flatbed",
+ "donate",
+ "door-closed",
+ "door-open",
+ "dot-circle",
+ "dove",
+ "download",
+ "drafting-compass",
+ "dragon",
+ "draw-polygon",
+ "drum",
+ "drum-steelpan",
+ "drumstick-bite",
+ "dumbbell",
+ "dumpster",
+ "dumpster-fire",
+ "dungeon",
+ "edit",
+ "egg",
+ "eject",
+ "ellipsis-h",
+ "ellipsis-v",
+ "empty-set",
+ "envelope",
+ "envelope-open",
+ "envelope-open-text",
+ "envelope-square",
+ "equals",
+ "eraser",
+ "ethernet",
+ "euro-sign",
+ "exchange-alt",
+ "exclamation",
+ "exclamation-circle",
+ "exclamation-triangle",
+ "expand",
+ "expand-alt",
+ "expand-arrows-alt",
+ "external-link-alt",
+ "external-link-square-alt",
+ "eye",
+ "eye-dropper",
+ "eye-slash",
+ "fan",
+ "fast-backward",
+ "fast-forward",
+ "faucet",
+ "fax",
+ "feather",
+ "feather-alt",
+ "female",
+ "fighter-jet",
+ "file",
+ "file-alt",
+ "file-archive",
+ "file-audio",
+ "file-code",
+ "file-contract",
+ "file-csv",
+ "file-download",
+ "file-excel",
+ "file-export",
+ "file-image",
+ "file-import",
+ "file-invoice",
+ "file-invoice-dollar",
+ "file-medical",
+ "file-medical-alt",
+ "file-pdf",
+ "file-powerpoint",
+ "file-prescription",
+ "file-signature",
+ "file-upload",
+ "file-video",
+ "file-word",
+ "fill",
+ "fill-drip",
+ "film",
+ "filter",
+ "fingerprint",
+ "fire",
+ "fire-alt",
+ "fire-extinguisher",
+ "first-aid",
+ "fish",
+ "fist-raised",
+ "flag",
+ "flag-checkered",
+ "flag-usa",
+ "flask",
+ "flushed",
+ "folder",
+ "folder-minus",
+ "folder-open",
+ "folder-plus",
+ "font",
+ "football-ball",
+ "forward",
+ "frog",
+ "frown",
+ "frown-open",
+ "function",
+ "funnel-dollar",
+ "futbol",
+ "gamepad",
+ "gas-pump",
+ "gavel",
+ "gem",
+ "genderless",
+ "ghost",
+ "gift",
+ "gifts",
+ "glass-cheers",
+ "glass-martini",
+ "glass-martini-alt",
+ "glass-whiskey",
+ "glasses",
+ "globe",
+ "globe-africa",
+ "globe-americas",
+ "globe-asia",
+ "globe-europe",
+ "golf-ball",
+ "gopuram",
+ "graduation-cap",
+ "greater-than",
+ "greater-than-equal",
+ "grimace",
+ "grin",
+ "grin-alt",
+ "grin-beam",
+ "grin-beam-sweat",
+ "grin-hearts",
+ "grin-squint",
+ "grin-squint-tears",
+ "grin-stars",
+ "grin-tears",
+ "grin-tongue",
+ "grin-tongue-squint",
+ "grin-tongue-wink",
+ "grin-wink",
+ "grip-horizontal",
+ "grip-lines",
+ "grip-lines-vertical",
+ "grip-vertical",
+ "guitar",
+ "h-square",
+ "hamburger",
+ "hammer",
+ "hamsa",
+ "hand-holding",
+ "hand-holding-heart",
+ "hand-holding-medical",
+ "hand-holding-usd",
+ "hand-holding-water",
+ "hand-lizard",
+ "hand-middle-finger",
+ "hand-paper",
+ "hand-peace",
+ "hand-point-down",
+ "hand-point-left",
+ "hand-point-right",
+ "hand-point-up",
+ "hand-pointer",
+ "hand-rock",
+ "hand-scissors",
+ "hand-sparkles",
+ "hand-spock",
+ "hands",
+ "hands-helping",
+ "hands-wash",
+ "handshake",
+ "handshake-alt-slash",
+ "handshake-slash",
+ "hanukiah",
+ "hard-hat",
+ "hashtag",
+ "hat-cowboy",
+ "hat-cowboy-side",
+ "hat-wizard",
+ "hdd",
+ "head-side-cough",
+ "head-side-cough-slash",
+ "head-side-mask",
+ "head-side-virus",
+ "heading",
+ "headphones",
+ "headphones-alt",
+ "headset",
+ "heart",
+ "heart-broken",
+ "heartbeat",
+ "helicopter",
+ "highlighter",
+ "hiking",
+ "hippo",
+ "history",
+ "hockey-puck",
+ "holly-berry",
+ "home",
+ "horse",
+ "horse-head",
+ "hospital",
+ "hospital-alt",
+ "hospital-symbol",
+ "hospital-user",
+ "hot-tub",
+ "hotdog",
+ "hotel",
+ "hourglass",
+ "hourglass-end",
+ "hourglass-half",
+ "hourglass-start",
+ "house-damage",
+ "house-user",
+ "hryvnia",
+ "i-cursor",
+ "ice-cream",
+ "icicles",
+ "icons",
+ "id-badge",
+ "id-card",
+ "id-card-alt",
+ "igloo",
+ "image",
+ "images",
+ "inbox",
+ "indent",
+ "industry",
+ "infinity",
+ "info",
+ "info-circle",
+ "integral",
+ "intersection",
+ "italic",
+ "jedi",
+ "joint",
+ "journal-whills",
+ "kaaba",
+ "key",
+ "keyboard",
+ "khanda",
+ "kiss",
+ "kiss-beam",
+ "kiss-wink-heart",
+ "kiwi-bird",
+ "lambda",
+ "landmark",
+ "language",
+ "laptop",
+ "laptop-code",
+ "laptop-house",
+ "laptop-medical",
+ "laugh",
+ "laugh-beam",
+ "laugh-squint",
+ "laugh-wink",
+ "layer-group",
+ "leaf",
+ "lemon",
+ "less-than",
+ "less-than-equal",
+ "level-down-alt",
+ "level-up-alt",
+ "life-ring",
+ "lightbulb",
+ "link",
+ "lira-sign",
+ "list",
+ "list-alt",
+ "list-ol",
+ "list-ul",
+ "location-arrow",
+ "lock",
+ "lock-open",
+ "long-arrow-alt-down",
+ "long-arrow-alt-left",
+ "long-arrow-alt-right",
+ "long-arrow-alt-up",
+ "low-vision",
+ "luggage-cart",
+ "lungs",
+ "lungs-virus",
+ "magic",
+ "magnet",
+ "mail-bulk",
+ "male",
+ "map",
+ "map-marked",
+ "map-marked-alt",
+ "map-marker",
+ "map-marker-alt",
+ "map-pin",
+ "map-signs",
+ "marker",
+ "mars",
+ "mars-double",
+ "mars-stroke",
+ "mars-stroke-h",
+ "mars-stroke-v",
+ "mask",
+ "medal",
+ "medkit",
+ "meh",
+ "meh-blank",
+ "meh-rolling-eyes",
+ "memory",
+ "menorah",
+ "mercury",
+ "meteor",
+ "microchip",
+ "microphone",
+ "microphone-alt",
+ "microphone-alt-slash",
+ "microphone-slash",
+ "microscope",
+ "minus",
+ "minus-circle",
+ "minus-square",
+ "mitten",
+ "mobile",
+ "mobile-alt",
+ "money-bill",
+ "money-bill-alt",
+ "money-bill-wave",
+ "money-bill-wave-alt",
+ "money-check",
+ "money-check-alt",
+ "monument",
+ "moon",
+ "mortar-pestle",
+ "mosque",
+ "motorcycle",
+ "mountain",
+ "mouse",
+ "mouse-pointer",
+ "mug-hot",
+ "music",
+ "network-wired",
+ "neuter",
+ "newspaper",
+ "not-equal",
+ "notes-medical",
+ "object-group",
+ "object-ungroup",
+ "oil-can",
+ "om",
+ "omega",
+ "otter",
+ "outdent",
+ "pager",
+ "paint-brush",
+ "paint-roller",
+ "palette",
+ "pallet",
+ "paper-plane",
+ "paperclip",
+ "parachute-box",
+ "paragraph",
+ "parking",
+ "passport",
+ "pastafarianism",
+ "paste",
+ "pause",
+ "pause-circle",
+ "paw",
+ "peace",
+ "pen",
+ "pen-alt",
+ "pen-fancy",
+ "pen-nib",
+ "pen-square",
+ "pencil-alt",
+ "pencil-ruler",
+ "people-arrows",
+ "people-carry",
+ "pepper-hot",
+ "percent",
+ "percentage",
+ "person-booth",
+ "phone",
+ "phone-alt",
+ "phone-slash",
+ "phone-square",
+ "phone-square-alt",
+ "phone-volume",
+ "photo-video",
+ "pi",
+ "piggy-bank",
+ "pills",
+ "pizza-slice",
+ "place-of-worship",
+ "plane",
+ "plane-arrival",
+ "plane-departure",
+ "plane-slash",
+ "play",
+ "play-circle",
+ "plug",
+ "plus",
+ "plus-circle",
+ "plus-square",
+ "podcast",
+ "poll",
+ "poll-h",
+ "poo",
+ "poo-storm",
+ "poop",
+ "portrait",
+ "pound-sign",
+ "power-off",
+ "pray",
+ "praying-hands",
+ "prescription",
+ "prescription-bottle",
+ "prescription-bottle-alt",
+ "print",
+ "procedures",
+ "project-diagram",
+ "pump-medical",
+ "pump-soap",
+ "puzzle-piece",
+ "qrcode",
+ "question",
+ "question-circle",
+ "quidditch",
+ "quote-left",
+ "quote-right",
+ "quran",
+ "radiation",
+ "radiation-alt",
+ "rainbow",
+ "random",
+ "receipt",
+ "record-vinyl",
+ "recycle",
+ "redo",
+ "redo-alt",
+ "registered",
+ "remove-format",
+ "reply",
+ "reply-all",
+ "republican",
+ "restroom",
+ "retweet",
+ "ribbon",
+ "ring",
+ "road",
+ "robot",
+ "rocket",
+ "route",
+ "rss",
+ "rss-square",
+ "ruble-sign",
+ "ruler",
+ "ruler-combined",
+ "ruler-horizontal",
+ "ruler-vertical",
+ "running",
+ "rupee-sign",
+ "sad-cry",
+ "sad-tear",
+ "satellite",
+ "satellite-dish",
+ "save",
+ "school",
+ "screwdriver",
+ "scroll",
+ "sd-card",
+ "search",
+ "search-dollar",
+ "search-location",
+ "search-minus",
+ "search-plus",
+ "seedling",
+ "server",
+ "shapes",
+ "share",
+ "share-alt",
+ "share-alt-square",
+ "share-square",
+ "shekel-sign",
+ "shield-alt",
+ "shield-virus",
+ "ship",
+ "shipping-fast",
+ "shoe-prints",
+ "shopping-bag",
+ "shopping-basket",
+ "shopping-cart",
+ "shower",
+ "shuttle-van",
+ "sigma",
+ "sign",
+ "sign-in-alt",
+ "sign-language",
+ "sign-out-alt",
+ "signal",
+ "signal-alt",
+ "signal-alt-slash",
+ "signal-slash",
+ "signature",
+ "sim-card",
+ "sink",
+ "sitemap",
+ "skating",
+ "skiing",
+ "skiing-nordic",
+ "skull",
+ "skull-crossbones",
+ "slash",
+ "sleigh",
+ "sliders-h",
+ "smile",
+ "smile-beam",
+ "smile-wink",
+ "smog",
+ "smoking",
+ "smoking-ban",
+ "sms",
+ "snowboarding",
+ "snowflake",
+ "snowman",
+ "snowplow",
+ "soap",
+ "socks",
+ "solar-panel",
+ "sort",
+ "sort-alpha-down",
+ "sort-alpha-down-alt",
+ "sort-alpha-up",
+ "sort-alpha-up-alt",
+ "sort-amount-down",
+ "sort-amount-down-alt",
+ "sort-amount-up",
+ "sort-amount-up-alt",
+ "sort-down",
+ "sort-numeric-down",
+ "sort-numeric-down-alt",
+ "sort-numeric-up",
+ "sort-numeric-up-alt",
+ "sort-up",
+ "spa",
+ "space-shuttle",
+ "spell-check",
+ "spider",
+ "spinner",
+ "splotch",
+ "spray-can",
+ "square",
+ "square-full",
+ "square-root",
+ "square-root-alt",
+ "stamp",
+ "star",
+ "star-and-crescent",
+ "star-half",
+ "star-half-alt",
+ "star-of-david",
+ "star-of-life",
+ "step-backward",
+ "step-forward",
+ "stethoscope",
+ "sticky-note",
+ "stop",
+ "stop-circle",
+ "stopwatch",
+ "stopwatch-20",
+ "store",
+ "store-alt",
+ "store-alt-slash",
+ "store-slash",
+ "stream",
+ "street-view",
+ "strikethrough",
+ "stroopwafel",
+ "subscript",
+ "subway",
+ "suitcase",
+ "suitcase-rolling",
+ "sun",
+ "superscript",
+ "surprise",
+ "swatchbook",
+ "swimmer",
+ "swimming-pool",
+ "synagogue",
+ "sync",
+ "sync-alt",
+ "syringe",
+ "table",
+ "table-tennis",
+ "tablet",
+ "tablet-alt",
+ "tablets",
+ "tachometer-alt",
+ "tag",
+ "tags",
+ "tally",
+ "tape",
+ "tasks",
+ "taxi",
+ "teeth",
+ "teeth-open",
+ "temperature-high",
+ "temperature-low",
+ "tenge",
+ "terminal",
+ "text-height",
+ "text-width",
+ "th",
+ "th-large",
+ "th-list",
+ "theater-masks",
+ "thermometer",
+ "thermometer-empty",
+ "thermometer-full",
+ "thermometer-half",
+ "thermometer-quarter",
+ "thermometer-three-quarters",
+ "theta",
+ "thumbs-down",
+ "thumbs-up",
+ "thumbtack",
+ "ticket-alt",
+ "tilde",
+ "times",
+ "times-circle",
+ "tint",
+ "tint-slash",
+ "tired",
+ "toggle-off",
+ "toggle-on",
+ "toilet",
+ "toilet-paper",
+ "toilet-paper-slash",
+ "toolbox",
+ "tools",
+ "tooth",
+ "torah",
+ "torii-gate",
+ "tractor",
+ "trademark",
+ "traffic-light",
+ "trailer",
+ "train",
+ "tram",
+ "transgender",
+ "transgender-alt",
+ "trash",
+ "trash-alt",
+ "trash-restore",
+ "trash-restore-alt",
+ "tree",
+ "trophy",
+ "truck",
+ "truck-loading",
+ "truck-monster",
+ "truck-moving",
+ "truck-pickup",
+ "tshirt",
+ "tty",
+ "tv",
+ "umbrella",
+ "umbrella-beach",
+ "underline",
+ "undo",
+ "undo-alt",
+ "union",
+ "universal-access",
+ "university",
+ "unlink",
+ "unlock",
+ "unlock-alt",
+ "upload",
+ "user",
+ "user-alt",
+ "user-alt-slash",
+ "user-astronaut",
+ "user-check",
+ "user-circle",
+ "user-clock",
+ "user-cog",
+ "user-edit",
+ "user-friends",
+ "user-graduate",
+ "user-injured",
+ "user-lock",
+ "user-md",
+ "user-minus",
+ "user-ninja",
+ "user-nurse",
+ "user-plus",
+ "user-secret",
+ "user-shield",
+ "user-slash",
+ "user-tag",
+ "user-tie",
+ "user-times",
+ "users",
+ "users-cog",
+ "users-slash",
+ "utensil-spoon",
+ "utensils",
+ "value-absolute",
+ "vector-square",
+ "venus",
+ "venus-double",
+ "venus-mars",
+ "vest",
+ "vest-patches",
+ "vial",
+ "vials",
+ "video",
+ "video-slash",
+ "vihara",
+ "virus",
+ "virus-slash",
+ "viruses",
+ "voicemail",
+ "volleyball-ball",
+ "volume",
+ "volume-down",
+ "volume-mute",
+ "volume-off",
+ "volume-slash",
+ "volume-up",
+ "vote-yea",
+ "vr-cardboard",
+ "walking",
+ "wallet",
+ "warehouse",
+ "water",
+ "wave-square",
+ "weight",
+ "weight-hanging",
+ "wheelchair",
+ "wifi",
+ "wifi-slash",
+ "wind",
+ "window-close",
+ "window-maximize",
+ "window-minimize",
+ "window-restore",
+ "wine-bottle",
+ "wine-glass",
+ "wine-glass-alt",
+ "won-sign",
+ "wrench",
+ "x-ray",
+ "yen-sign",
+ "yin-yang"
+ ]
+};
diff --git a/src/components/ReIcon/index.ts b/src/components/ReIcon/index.ts
new file mode 100644
index 0000000..9f77a1e
--- /dev/null
+++ b/src/components/ReIcon/index.ts
@@ -0,0 +1,15 @@
+import iconifyIconOffline from "./src/iconifyIconOffline";
+import iconifyIconOnline from "./src/iconifyIconOnline";
+import iconSelect from "./src/Select.vue";
+import fontIcon from "./src/iconfont";
+
+/** 本地图标组件 */
+const IconifyIconOffline = iconifyIconOffline;
+/** 在线图标组件 */
+const IconifyIconOnline = iconifyIconOnline;
+/** `IconSelect`图标选择器组件 */
+const IconSelect = iconSelect;
+/** `iconfont`组件 */
+const FontIcon = fontIcon;
+
+export { IconifyIconOffline, IconifyIconOnline, IconSelect, FontIcon };
diff --git a/src/components/ReIcon/src/Select.vue b/src/components/ReIcon/src/Select.vue
new file mode 100644
index 0000000..aad1042
--- /dev/null
+++ b/src/components/ReIcon/src/Select.vue
@@ -0,0 +1,268 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 清空
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ReIcon/src/hooks.ts b/src/components/ReIcon/src/hooks.ts
new file mode 100644
index 0000000..5a377da
--- /dev/null
+++ b/src/components/ReIcon/src/hooks.ts
@@ -0,0 +1,61 @@
+import type { iconType } from "./types";
+import { h, defineComponent, type Component } from "vue";
+import { IconifyIconOnline, IconifyIconOffline, FontIcon } from "../index";
+
+/**
+ * 支持 `iconfont`、自定义 `svg` 以及 `iconify` 中所有的图标
+ * @see 点击查看文档图标篇 {@link https://pure-admin.github.io/pure-admin-doc/pages/icon/}
+ * @param icon 必传 图标
+ * @param attrs 可选 iconType 属性
+ * @returns Component
+ */
+export function useRenderIcon(icon: any, attrs?: iconType): Component {
+ // iconfont
+ const ifReg = /^IF-/;
+ // typeof icon === "function" 属于SVG
+ if (ifReg.test(icon)) {
+ // iconfont
+ const name = icon.split(ifReg)[1];
+ const iconName = name.slice(
+ 0,
+ name.indexOf(" ") == -1 ? name.length : name.indexOf(" ")
+ );
+ const iconType = name.slice(name.indexOf(" ") + 1, name.length);
+ return defineComponent({
+ name: "FontIcon",
+ render() {
+ return h(FontIcon, {
+ icon: iconName,
+ iconType,
+ ...attrs
+ });
+ }
+ });
+ } else if (typeof icon === "function" || typeof icon?.render === "function") {
+ // svg
+ return attrs ? h(icon, { ...attrs }) : icon;
+ } else if (typeof icon === "object") {
+ return defineComponent({
+ name: "OfflineIcon",
+ render() {
+ return h(IconifyIconOffline, {
+ icon: icon,
+ ...attrs
+ });
+ }
+ });
+ } else {
+ // 通过是否存在 : 符号来判断是在线还是本地图标,存在即是在线图标,反之
+ return defineComponent({
+ name: "Icon",
+ render() {
+ const IconifyIcon =
+ icon && icon.includes(":") ? IconifyIconOnline : IconifyIconOffline;
+ return h(IconifyIcon, {
+ icon: icon,
+ ...attrs
+ });
+ }
+ });
+ }
+}
diff --git a/src/components/ReIcon/src/iconfont.ts b/src/components/ReIcon/src/iconfont.ts
new file mode 100644
index 0000000..c110451
--- /dev/null
+++ b/src/components/ReIcon/src/iconfont.ts
@@ -0,0 +1,48 @@
+import { h, defineComponent } from "vue";
+
+// 封装iconfont组件,默认`font-class`引用模式,支持`unicode`引用、`font-class`引用、`symbol`引用 (https://www.iconfont.cn/help/detail?spm=a313x.7781069.1998910419.20&helptype=code)
+export default defineComponent({
+ name: "FontIcon",
+ props: {
+ icon: {
+ type: String,
+ default: ""
+ }
+ },
+ render() {
+ const attrs = this.$attrs;
+ if (Object.keys(attrs).includes("uni") || attrs?.iconType === "uni") {
+ return h(
+ "i",
+ {
+ class: "iconfont",
+ ...attrs
+ },
+ this.icon
+ );
+ } else if (
+ Object.keys(attrs).includes("svg") ||
+ attrs?.iconType === "svg"
+ ) {
+ return h(
+ "svg",
+ {
+ class: "icon-svg",
+ "aria-hidden": true
+ },
+ {
+ default: () => [
+ h("use", {
+ "xlink:href": `#${this.icon}`
+ })
+ ]
+ }
+ );
+ } else {
+ return h("i", {
+ class: `iconfont ${this.icon}`,
+ ...attrs
+ });
+ }
+ }
+});
diff --git a/src/components/ReIcon/src/iconifyIconOffline.ts b/src/components/ReIcon/src/iconifyIconOffline.ts
new file mode 100644
index 0000000..b47aa99
--- /dev/null
+++ b/src/components/ReIcon/src/iconifyIconOffline.ts
@@ -0,0 +1,30 @@
+import { h, defineComponent } from "vue";
+import { Icon as IconifyIcon, addIcon } from "@iconify/vue/dist/offline";
+
+// Iconify Icon在Vue里本地使用(用于内网环境)
+export default defineComponent({
+ name: "IconifyIconOffline",
+ components: { IconifyIcon },
+ props: {
+ icon: {
+ default: null
+ }
+ },
+ render() {
+ if (typeof this.icon === "object") addIcon(this.icon, this.icon);
+ const attrs = this.$attrs;
+ return h(
+ IconifyIcon,
+ {
+ icon: this.icon,
+ style: attrs?.style
+ ? Object.assign(attrs.style, { outline: "none" })
+ : { outline: "none" },
+ ...attrs
+ },
+ {
+ default: () => []
+ }
+ );
+ }
+});
diff --git a/src/components/ReIcon/src/iconifyIconOnline.ts b/src/components/ReIcon/src/iconifyIconOnline.ts
new file mode 100644
index 0000000..a5f5822
--- /dev/null
+++ b/src/components/ReIcon/src/iconifyIconOnline.ts
@@ -0,0 +1,30 @@
+import { h, defineComponent } from "vue";
+import { Icon as IconifyIcon } from "@iconify/vue";
+
+// Iconify Icon在Vue里在线使用(用于外网环境)
+export default defineComponent({
+ name: "IconifyIconOnline",
+ components: { IconifyIcon },
+ props: {
+ icon: {
+ type: String,
+ default: ""
+ }
+ },
+ render() {
+ const attrs = this.$attrs;
+ return h(
+ IconifyIcon,
+ {
+ icon: `${this.icon}`,
+ style: attrs?.style
+ ? Object.assign(attrs.style, { outline: "none" })
+ : { outline: "none" },
+ ...attrs
+ },
+ {
+ default: () => []
+ }
+ );
+ }
+});
diff --git a/src/components/ReIcon/src/offlineIcon.ts b/src/components/ReIcon/src/offlineIcon.ts
new file mode 100644
index 0000000..2283a55
--- /dev/null
+++ b/src/components/ReIcon/src/offlineIcon.ts
@@ -0,0 +1,70 @@
+// 这里存放本地图标,在 src/layout/index.vue 文件中加载,避免在首启动加载
+import { addIcon } from "@iconify/vue/dist/offline";
+
+// 本地菜单图标,后端在路由的 icon 中返回对应的图标字符串并且前端在此处使用 addIcon 添加即可渲染菜单图标
+// @iconify-icons/ep
+import Menu from "@iconify-icons/ep/menu";
+import Edit from "@iconify-icons/ep/edit";
+import SetUp from "@iconify-icons/ep/set-up";
+import Guide from "@iconify-icons/ep/guide";
+import Monitor from "@iconify-icons/ep/monitor";
+import Lollipop from "@iconify-icons/ep/lollipop";
+import Histogram from "@iconify-icons/ep/histogram";
+import HomeFilled from "@iconify-icons/ep/home-filled";
+addIcon("ep:menu", Menu);
+addIcon("ep:edit", Edit);
+addIcon("ep:set-up", SetUp);
+addIcon("ep:guide", Guide);
+addIcon("ep:monitor", Monitor);
+addIcon("ep:lollipop", Lollipop);
+addIcon("ep:histogram", Histogram);
+addIcon("ep:home-filled", HomeFilled);
+// @iconify-icons/ri
+import Tag from "@iconify-icons/ri/bookmark-2-line";
+import Ppt from "@iconify-icons/ri/file-ppt-2-line";
+import Card from "@iconify-icons/ri/bank-card-line";
+import Role from "@iconify-icons/ri/admin-fill";
+import Info from "@iconify-icons/ri/file-info-line";
+import Dept from "@iconify-icons/ri/git-branch-line";
+import Table from "@iconify-icons/ri/table-line";
+import Links from "@iconify-icons/ri/links-fill";
+import Search from "@iconify-icons/ri/search-line";
+import FlUser from "@iconify-icons/ri/admin-line";
+import Setting from "@iconify-icons/ri/settings-3-line";
+import MindMap from "@iconify-icons/ri/mind-map";
+import BarChart from "@iconify-icons/ri/bar-chart-horizontal-line";
+import LoginLog from "@iconify-icons/ri/window-line";
+import Artboard from "@iconify-icons/ri/artboard-line";
+import SystemLog from "@iconify-icons/ri/file-search-line";
+import ListCheck from "@iconify-icons/ri/list-check";
+import UbuntuFill from "@iconify-icons/ri/ubuntu-fill";
+import OnlineUser from "@iconify-icons/ri/user-voice-line";
+import EditBoxLine from "@iconify-icons/ri/edit-box-line";
+import OperationLog from "@iconify-icons/ri/history-fill";
+import InformationLine from "@iconify-icons/ri/information-line";
+import TerminalWindowLine from "@iconify-icons/ri/terminal-window-line";
+import CheckboxCircleLine from "@iconify-icons/ri/checkbox-circle-line";
+addIcon("ri:bookmark-2-line", Tag);
+addIcon("ri:file-ppt-2-line", Ppt);
+addIcon("ri:bank-card-line", Card);
+addIcon("ri:admin-fill", Role);
+addIcon("ri:file-info-line", Info);
+addIcon("ri:git-branch-line", Dept);
+addIcon("ri:links-fill", Links);
+addIcon("ri:table-line", Table);
+addIcon("ri:search-line", Search);
+addIcon("ri:admin-line", FlUser);
+addIcon("ri:settings-3-line", Setting);
+addIcon("ri:mind-map", MindMap);
+addIcon("ri:bar-chart-horizontal-line", BarChart);
+addIcon("ri:window-line", LoginLog);
+addIcon("ri:file-search-line", SystemLog);
+addIcon("ri:artboard-line", Artboard);
+addIcon("ri:list-check", ListCheck);
+addIcon("ri:ubuntu-fill", UbuntuFill);
+addIcon("ri:user-voice-line", OnlineUser);
+addIcon("ri:edit-box-line", EditBoxLine);
+addIcon("ri:history-fill", OperationLog);
+addIcon("ri:information-line", InformationLine);
+addIcon("ri:terminal-window-line", TerminalWindowLine);
+addIcon("ri:checkbox-circle-line", CheckboxCircleLine);
diff --git a/src/components/ReIcon/src/types.ts b/src/components/ReIcon/src/types.ts
new file mode 100644
index 0000000..000bdc5
--- /dev/null
+++ b/src/components/ReIcon/src/types.ts
@@ -0,0 +1,20 @@
+export interface iconType {
+ // iconify (https://docs.iconify.design/icon-components/vue/#properties)
+ inline?: boolean;
+ width?: string | number;
+ height?: string | number;
+ horizontalFlip?: boolean;
+ verticalFlip?: boolean;
+ flip?: string;
+ rotate?: number | string;
+ color?: string;
+ horizontalAlign?: boolean;
+ verticalAlign?: boolean;
+ align?: string;
+ onLoad?: Function;
+ includes?: Function;
+ // svg 需要什么SVG属性自行添加
+ fill?: string;
+ // all icon
+ style?: object;
+}
diff --git a/src/components/ReSegmented/index.ts b/src/components/ReSegmented/index.ts
new file mode 100644
index 0000000..de4253c
--- /dev/null
+++ b/src/components/ReSegmented/index.ts
@@ -0,0 +1,8 @@
+import reSegmented from "./src/index";
+import { withInstall } from "@pureadmin/utils";
+
+/** 分段控制器组件 */
+export const ReSegmented = withInstall(reSegmented);
+
+export default ReSegmented;
+export type { OptionsType } from "./src/type";
diff --git a/src/components/ReSegmented/src/index.css b/src/components/ReSegmented/src/index.css
new file mode 100644
index 0000000..503bbe4
--- /dev/null
+++ b/src/components/ReSegmented/src/index.css
@@ -0,0 +1,157 @@
+.pure-segmented {
+ --pure-control-padding-horizontal: 12px;
+ --pure-control-padding-horizontal-sm: 8px;
+ --pure-segmented-track-padding: 2px;
+ --pure-segmented-line-width: 1px;
+
+ --pure-segmented-border-radius-small: 4px;
+ --pure-segmented-border-radius-base: 6px;
+ --pure-segmented-border-radius-large: 8px;
+
+ box-sizing: border-box;
+ display: inline-block;
+ padding: var(--pure-segmented-track-padding);
+ font-size: var(--el-font-size-base);
+ color: rgba(0, 0, 0, 0.65);
+ background-color: rgb(0 0 0 / 4%);
+ border-radius: var(--pure-segmented-border-radius-base);
+}
+
+.pure-segmented-block {
+ display: flex;
+}
+
+.pure-segmented-block .pure-segmented-item {
+ flex: 1;
+ min-width: 0;
+}
+
+.pure-segmented-block .pure-segmented-item > .pure-segmented-item-label > span {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+/* small */
+.pure-segmented.pure-segmented--small {
+ border-radius: var(--pure-segmented-border-radius-small);
+}
+.pure-segmented.pure-segmented--small .pure-segmented-item {
+ border-radius: var(--el-border-radius-small);
+}
+.pure-segmented.pure-segmented--small .pure-segmented-item > div {
+ min-height: calc(
+ var(--el-component-size-small) - var(--pure-segmented-track-padding) * 2
+ );
+ line-height: calc(
+ var(--el-component-size-small) - var(--pure-segmented-track-padding) * 2
+ );
+ padding: 0
+ calc(
+ var(--pure-control-padding-horizontal-sm) -
+ var(--pure-segmented-line-width)
+ );
+}
+
+/* large */
+.pure-segmented.pure-segmented--large {
+ border-radius: var(--pure-segmented-border-radius-large);
+}
+.pure-segmented.pure-segmented--large .pure-segmented-item {
+ border-radius: calc(
+ var(--el-border-radius-base) + var(--el-border-radius-small)
+ );
+}
+.pure-segmented.pure-segmented--large .pure-segmented-item > div {
+ min-height: calc(
+ var(--el-component-size-large) - var(--pure-segmented-track-padding) * 2
+ );
+ line-height: calc(
+ var(--el-component-size-large) - var(--pure-segmented-track-padding) * 2
+ );
+ padding: 0
+ calc(
+ var(--pure-control-padding-horizontal) - var(--pure-segmented-line-width)
+ );
+ font-size: var(--el-font-size-medium);
+}
+
+/* default */
+.pure-segmented-item {
+ position: relative;
+ text-align: center;
+ cursor: pointer;
+ border-radius: var(--el-border-radius-base);
+ transition: all 0.1s cubic-bezier(0.645, 0.045, 0.355, 1);
+}
+.pure-segmented .pure-segmented-item > div {
+ min-height: calc(
+ var(--el-component-size) - var(--pure-segmented-track-padding) * 2
+ );
+ line-height: calc(
+ var(--el-component-size) - var(--pure-segmented-track-padding) * 2
+ );
+ padding: 0
+ calc(
+ var(--pure-control-padding-horizontal) - var(--pure-segmented-line-width)
+ );
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ transition: 0.1s;
+}
+
+.pure-segmented-group {
+ position: relative;
+ display: flex;
+ align-items: stretch;
+ justify-items: flex-start;
+ width: 100%;
+}
+
+.pure-segmented-item-selected {
+ position: absolute;
+ top: 0;
+ left: 0;
+ box-sizing: border-box;
+ display: none;
+ width: 0;
+ height: 100%;
+ padding: 4px 0;
+ background-color: #fff;
+ border-radius: 4px;
+ box-shadow:
+ 0 2px 8px -2px rgb(0 0 0 / 5%),
+ 0 1px 4px -1px rgb(0 0 0 / 7%),
+ 0 0 1px rgb(0 0 0 / 7%);
+ transition:
+ transform 0.5s cubic-bezier(0.645, 0.045, 0.355, 1),
+ width 0.5s cubic-bezier(0.645, 0.045, 0.355, 1);
+ will-change: transform, width;
+}
+
+.pure-segmented-item > input {
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline-start: 0;
+ width: 0;
+ height: 0;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.pure-segmented-item-label {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.pure-segmented-item-icon svg {
+ width: 16px;
+ height: 16px;
+}
+
+.pure-segmented-item-disabled {
+ color: rgba(0, 0, 0, 0.25);
+ cursor: not-allowed;
+}
diff --git a/src/components/ReSegmented/src/index.tsx b/src/components/ReSegmented/src/index.tsx
new file mode 100644
index 0000000..39580ed
--- /dev/null
+++ b/src/components/ReSegmented/src/index.tsx
@@ -0,0 +1,216 @@
+import "./index.css";
+import type { OptionsType } from "./type";
+import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import {
+ useDark,
+ isNumber,
+ isFunction,
+ useResizeObserver
+} from "@pureadmin/utils";
+import {
+ type PropType,
+ h,
+ ref,
+ toRef,
+ watch,
+ nextTick,
+ defineComponent,
+ getCurrentInstance
+} from "vue";
+
+const props = {
+ options: {
+ type: Array,
+ default: () => []
+ },
+ /** 默认选中,按照第一个索引为 `0` 的模式,可选(`modelValue`只有传`number`类型时才为响应式) */
+ modelValue: {
+ type: undefined,
+ require: false,
+ default: "0"
+ },
+ /** 将宽度调整为父元素宽度 */
+ block: {
+ type: Boolean,
+ default: false
+ },
+ /** 控件尺寸 */
+ size: {
+ type: String as PropType<"small" | "default" | "large">
+ },
+ /** 是否全局禁用,默认 `false` */
+ disabled: {
+ type: Boolean,
+ default: false
+ },
+ /** 当内容发生变化时,设置 `resize` 可使其自适应容器位置 */
+ resize: {
+ type: Boolean,
+ default: false
+ }
+};
+
+export default defineComponent({
+ name: "ReSegmented",
+ props,
+ emits: ["change", "update:modelValue"],
+ setup(props, { emit }) {
+ const width = ref(0);
+ const translateX = ref(0);
+ const { isDark } = useDark();
+ const initStatus = ref(false);
+ const curMouseActive = ref(-1);
+ const segmentedItembg = ref("");
+ const instance = getCurrentInstance()!;
+ const curIndex = isNumber(props.modelValue)
+ ? toRef(props, "modelValue")
+ : ref(0);
+
+ function handleChange({ option, index }, event: Event) {
+ if (props.disabled || option.disabled) return;
+ event.preventDefault();
+ isNumber(props.modelValue)
+ ? emit("update:modelValue", index)
+ : (curIndex.value = index);
+ segmentedItembg.value = "";
+ emit("change", { index, option });
+ }
+
+ function handleMouseenter({ option, index }, event: Event) {
+ if (props.disabled) return;
+ event.preventDefault();
+ curMouseActive.value = index;
+ if (option.disabled || curIndex.value === index) {
+ segmentedItembg.value = "";
+ } else {
+ segmentedItembg.value = isDark.value
+ ? "#1f1f1f"
+ : "rgba(0, 0, 0, 0.06)";
+ }
+ }
+
+ function handleMouseleave(_, event: Event) {
+ if (props.disabled) return;
+ event.preventDefault();
+ curMouseActive.value = -1;
+ }
+
+ function handleInit(index = curIndex.value) {
+ nextTick(() => {
+ const curLabelRef = instance?.proxy?.$refs[`labelRef${index}`] as ElRef;
+ if (!curLabelRef) return;
+ width.value = curLabelRef.clientWidth;
+ translateX.value = curLabelRef.offsetLeft;
+ initStatus.value = true;
+ });
+ }
+
+ function handleResizeInit() {
+ useResizeObserver(".pure-segmented", () => {
+ nextTick(() => {
+ handleInit(curIndex.value);
+ });
+ });
+ }
+
+ (props.block || props.resize) && handleResizeInit();
+
+ watch(
+ () => curIndex.value,
+ index => {
+ nextTick(() => {
+ handleInit(index);
+ });
+ },
+ {
+ immediate: true
+ }
+ );
+
+ watch(() => props.size, handleResizeInit, {
+ immediate: true
+ });
+
+ const rendLabel = () => {
+ return props.options.map((option, index) => {
+ return (
+
+ );
+ });
+ };
+
+ return () => (
+
+ );
+ }
+});
diff --git a/src/components/ReSegmented/src/type.ts b/src/components/ReSegmented/src/type.ts
new file mode 100644
index 0000000..205e34d
--- /dev/null
+++ b/src/components/ReSegmented/src/type.ts
@@ -0,0 +1,20 @@
+import type { VNode, Component } from "vue";
+import type { iconType } from "@/components/ReIcon/src/types.ts";
+
+export interface OptionsType {
+ /** 文字 */
+ label?: string | (() => VNode | Component);
+ /**
+ * @description 图标,采用平台内置的 `useRenderIcon` 函数渲染
+ * @see {@link 用法参考 https://pure-admin.github.io/pure-admin-doc/pages/icon/#%E9%80%9A%E7%94%A8%E5%9B%BE%E6%A0%87-userendericon-hooks }
+ */
+ icon?: string | Component;
+ /** 图标属性、样式配置 */
+ iconAttrs?: iconType;
+ /** 值 */
+ value?: any;
+ /** 是否禁用 */
+ disabled?: boolean;
+ /** `tooltip` 提示 */
+ tip?: string;
+}
diff --git a/src/components/Segmented/index.ts b/src/components/Segmented/index.ts
new file mode 100644
index 0000000..de4253c
--- /dev/null
+++ b/src/components/Segmented/index.ts
@@ -0,0 +1,8 @@
+import reSegmented from "./src/index";
+import { withInstall } from "@pureadmin/utils";
+
+/** 分段控制器组件 */
+export const ReSegmented = withInstall(reSegmented);
+
+export default ReSegmented;
+export type { OptionsType } from "./src/type";
diff --git a/src/components/Segmented/src/index.css b/src/components/Segmented/src/index.css
new file mode 100644
index 0000000..503bbe4
--- /dev/null
+++ b/src/components/Segmented/src/index.css
@@ -0,0 +1,157 @@
+.pure-segmented {
+ --pure-control-padding-horizontal: 12px;
+ --pure-control-padding-horizontal-sm: 8px;
+ --pure-segmented-track-padding: 2px;
+ --pure-segmented-line-width: 1px;
+
+ --pure-segmented-border-radius-small: 4px;
+ --pure-segmented-border-radius-base: 6px;
+ --pure-segmented-border-radius-large: 8px;
+
+ box-sizing: border-box;
+ display: inline-block;
+ padding: var(--pure-segmented-track-padding);
+ font-size: var(--el-font-size-base);
+ color: rgba(0, 0, 0, 0.65);
+ background-color: rgb(0 0 0 / 4%);
+ border-radius: var(--pure-segmented-border-radius-base);
+}
+
+.pure-segmented-block {
+ display: flex;
+}
+
+.pure-segmented-block .pure-segmented-item {
+ flex: 1;
+ min-width: 0;
+}
+
+.pure-segmented-block .pure-segmented-item > .pure-segmented-item-label > span {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+/* small */
+.pure-segmented.pure-segmented--small {
+ border-radius: var(--pure-segmented-border-radius-small);
+}
+.pure-segmented.pure-segmented--small .pure-segmented-item {
+ border-radius: var(--el-border-radius-small);
+}
+.pure-segmented.pure-segmented--small .pure-segmented-item > div {
+ min-height: calc(
+ var(--el-component-size-small) - var(--pure-segmented-track-padding) * 2
+ );
+ line-height: calc(
+ var(--el-component-size-small) - var(--pure-segmented-track-padding) * 2
+ );
+ padding: 0
+ calc(
+ var(--pure-control-padding-horizontal-sm) -
+ var(--pure-segmented-line-width)
+ );
+}
+
+/* large */
+.pure-segmented.pure-segmented--large {
+ border-radius: var(--pure-segmented-border-radius-large);
+}
+.pure-segmented.pure-segmented--large .pure-segmented-item {
+ border-radius: calc(
+ var(--el-border-radius-base) + var(--el-border-radius-small)
+ );
+}
+.pure-segmented.pure-segmented--large .pure-segmented-item > div {
+ min-height: calc(
+ var(--el-component-size-large) - var(--pure-segmented-track-padding) * 2
+ );
+ line-height: calc(
+ var(--el-component-size-large) - var(--pure-segmented-track-padding) * 2
+ );
+ padding: 0
+ calc(
+ var(--pure-control-padding-horizontal) - var(--pure-segmented-line-width)
+ );
+ font-size: var(--el-font-size-medium);
+}
+
+/* default */
+.pure-segmented-item {
+ position: relative;
+ text-align: center;
+ cursor: pointer;
+ border-radius: var(--el-border-radius-base);
+ transition: all 0.1s cubic-bezier(0.645, 0.045, 0.355, 1);
+}
+.pure-segmented .pure-segmented-item > div {
+ min-height: calc(
+ var(--el-component-size) - var(--pure-segmented-track-padding) * 2
+ );
+ line-height: calc(
+ var(--el-component-size) - var(--pure-segmented-track-padding) * 2
+ );
+ padding: 0
+ calc(
+ var(--pure-control-padding-horizontal) - var(--pure-segmented-line-width)
+ );
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ transition: 0.1s;
+}
+
+.pure-segmented-group {
+ position: relative;
+ display: flex;
+ align-items: stretch;
+ justify-items: flex-start;
+ width: 100%;
+}
+
+.pure-segmented-item-selected {
+ position: absolute;
+ top: 0;
+ left: 0;
+ box-sizing: border-box;
+ display: none;
+ width: 0;
+ height: 100%;
+ padding: 4px 0;
+ background-color: #fff;
+ border-radius: 4px;
+ box-shadow:
+ 0 2px 8px -2px rgb(0 0 0 / 5%),
+ 0 1px 4px -1px rgb(0 0 0 / 7%),
+ 0 0 1px rgb(0 0 0 / 7%);
+ transition:
+ transform 0.5s cubic-bezier(0.645, 0.045, 0.355, 1),
+ width 0.5s cubic-bezier(0.645, 0.045, 0.355, 1);
+ will-change: transform, width;
+}
+
+.pure-segmented-item > input {
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline-start: 0;
+ width: 0;
+ height: 0;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.pure-segmented-item-label {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.pure-segmented-item-icon svg {
+ width: 16px;
+ height: 16px;
+}
+
+.pure-segmented-item-disabled {
+ color: rgba(0, 0, 0, 0.25);
+ cursor: not-allowed;
+}
diff --git a/src/components/Segmented/src/index.tsx b/src/components/Segmented/src/index.tsx
new file mode 100644
index 0000000..33668e6
--- /dev/null
+++ b/src/components/Segmented/src/index.tsx
@@ -0,0 +1,216 @@
+import { useRenderIcon } from "@/components/CommonIcon/src/hooks";
+import {
+ isFunction,
+ isNumber,
+ useDark,
+ useResizeObserver
+} from "@pureadmin/utils";
+import {
+ type PropType,
+ defineComponent,
+ getCurrentInstance,
+ h,
+ nextTick,
+ ref,
+ toRef,
+ watch
+} from "vue";
+import "./index.css";
+import type { OptionsType } from "./type";
+
+const props = {
+ options: {
+ type: Array,
+ default: () => []
+ },
+ /** 默认选中,按照第一个索引为 `0` 的模式,可选(`modelValue`只有传`number`类型时才为响应式) */
+ modelValue: {
+ type: undefined,
+ require: false,
+ default: "0"
+ },
+ /** 将宽度调整为父元素宽度 */
+ block: {
+ type: Boolean,
+ default: false
+ },
+ /** 控件尺寸 */
+ size: {
+ type: String as PropType<"small" | "default" | "large">
+ },
+ /** 是否全局禁用,默认 `false` */
+ disabled: {
+ type: Boolean,
+ default: false
+ },
+ /** 当内容发生变化时,设置 `resize` 可使其自适应容器位置 */
+ resize: {
+ type: Boolean,
+ default: false
+ }
+};
+
+export default defineComponent({
+ name: "ReSegmented",
+ props,
+ emits: ["change", "update:modelValue"],
+ setup(props, { emit }) {
+ const width = ref(0);
+ const translateX = ref(0);
+ const { isDark } = useDark();
+ const initStatus = ref(false);
+ const curMouseActive = ref(-1);
+ const segmentedItembg = ref("");
+ const instance = getCurrentInstance()!;
+ const curIndex = isNumber(props.modelValue)
+ ? toRef(props, "modelValue")
+ : ref(0);
+
+ function handleChange({ option, index }, event: Event) {
+ if (props.disabled || option.disabled) return;
+ event.preventDefault();
+ isNumber(props.modelValue)
+ ? emit("update:modelValue", index)
+ : (curIndex.value = index);
+ segmentedItembg.value = "";
+ emit("change", { index, option });
+ }
+
+ function handleMouseenter({ option, index }, event: Event) {
+ if (props.disabled) return;
+ event.preventDefault();
+ curMouseActive.value = index;
+ if (option.disabled || curIndex.value === index) {
+ segmentedItembg.value = "";
+ } else {
+ segmentedItembg.value = isDark.value
+ ? "#1f1f1f"
+ : "rgba(0, 0, 0, 0.06)";
+ }
+ }
+
+ function handleMouseleave(_, event: Event) {
+ if (props.disabled) return;
+ event.preventDefault();
+ curMouseActive.value = -1;
+ }
+
+ function handleInit(index = curIndex.value) {
+ nextTick(() => {
+ const curLabelRef = instance?.proxy?.$refs[`labelRef${index}`] as ElRef;
+ if (!curLabelRef) return;
+ width.value = curLabelRef.clientWidth;
+ translateX.value = curLabelRef.offsetLeft;
+ initStatus.value = true;
+ });
+ }
+
+ function handleResizeInit() {
+ useResizeObserver(".pure-segmented", () => {
+ nextTick(() => {
+ handleInit(curIndex.value);
+ });
+ });
+ }
+
+ (props.block || props.resize) && handleResizeInit();
+
+ watch(
+ () => curIndex.value,
+ index => {
+ nextTick(() => {
+ handleInit(index);
+ });
+ },
+ {
+ immediate: true
+ }
+ );
+
+ watch(() => props.size, handleResizeInit, {
+ immediate: true
+ });
+
+ const rendLabel = () => {
+ return props.options.map((option, index) => {
+ return (
+
+ );
+ });
+ };
+
+ return () => (
+
+ );
+ }
+});
diff --git a/src/components/Segmented/src/type.ts b/src/components/Segmented/src/type.ts
new file mode 100644
index 0000000..249f6de
--- /dev/null
+++ b/src/components/Segmented/src/type.ts
@@ -0,0 +1,20 @@
+import type { Component, VNode } from "vue";
+import type { iconType } from "@/components/CommonIcon/src/types.ts";
+
+export interface OptionsType {
+ /** 文字 */
+ label?: string | (() => VNode | Component);
+ /**
+ * @description 图标,采用平台内置的 `useRenderIcon` 函数渲染
+ * @see {@link 用法参考 https://pure-admin.github.io/pure-admin-doc/pages/icon/#%E9%80%9A%E7%94%A8%E5%9B%BE%E6%A0%87-userendericon-hooks }
+ */
+ icon?: string | Component;
+ /** 图标属性、样式配置 */
+ iconAttrs?: iconType;
+ /** 值 */
+ value?: any;
+ /** 是否禁用 */
+ disabled?: boolean;
+ /** `tooltip` 提示 */
+ tip?: string;
+}
diff --git a/src/components/TableBar/index.ts b/src/components/TableBar/index.ts
new file mode 100644
index 0000000..31b8a16
--- /dev/null
+++ b/src/components/TableBar/index.ts
@@ -0,0 +1,5 @@
+import pureTableBar from "./src/bar";
+import { withInstall } from "@pureadmin/utils";
+
+/** 配合 `@pureadmin/table` 实现快速便捷的表格操作 https://github.com/pure-admin/pure-admin-table */
+export const PureTableBar = withInstall(pureTableBar);
diff --git a/src/components/TableBar/src/TablePlus.vue b/src/components/TableBar/src/TablePlus.vue
new file mode 100644
index 0000000..08da498
--- /dev/null
+++ b/src/components/TableBar/src/TablePlus.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
diff --git a/src/components/TableBar/src/TablePlusBar.vue b/src/components/TableBar/src/TablePlusBar.vue
new file mode 100644
index 0000000..2ef4572
--- /dev/null
+++ b/src/components/TableBar/src/TablePlusBar.vue
@@ -0,0 +1,466 @@
+
+
+
+
+
+
+
+
+
+ {{ $t("buttons.search") }}
+
+
+ {{ $t("buttons.rest") }}
+
+
+
+
+
+
+
+
+
+ {{ tableTitle ? tableTitle : t(route.meta.title) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("style.larger") }}
+
+
+ {{ t("style.default") }}
+
+
+ {{ t("style.small") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("buttons.rest") }}
+
+
+
+
+
+
+
+
+
+
+ {{ item }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 修改
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/TableBar/src/TablePlusPage.vue b/src/components/TableBar/src/TablePlusPage.vue
new file mode 100644
index 0000000..c6b44d2
--- /dev/null
+++ b/src/components/TableBar/src/TablePlusPage.vue
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
diff --git a/src/components/TableBar/src/TablePlusQuery.vue b/src/components/TableBar/src/TablePlusQuery.vue
new file mode 100644
index 0000000..5e75ec7
--- /dev/null
+++ b/src/components/TableBar/src/TablePlusQuery.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
diff --git a/src/components/TableBar/utils/tableConfig.ts b/src/components/TableBar/utils/tableConfig.ts
new file mode 100644
index 0000000..12feefc
--- /dev/null
+++ b/src/components/TableBar/utils/tableConfig.ts
@@ -0,0 +1,7 @@
+export const rendTipProps = (content: string) => ({
+ content,
+ offset: [0, 18],
+ duration: [300, 0],
+ followCursor: true,
+ hideOnClick: 'toggle',
+});
diff --git a/src/components/TableBar/utils/tableStyle.ts b/src/components/TableBar/utils/tableStyle.ts
new file mode 100644
index 0000000..a22964e
--- /dev/null
+++ b/src/components/TableBar/utils/tableStyle.ts
@@ -0,0 +1,28 @@
+import { computed } from 'vue';
+import { useEpThemeStoreHook } from '@/store/epTheme';
+
+/**
+ * * 表格头部样式
+ */
+export const cellHeaderStyle = () => ({
+ background: 'var(--el-fill-color-light)',
+ color: 'var(--el-text-color-primary)',
+});
+
+// * icon 样式
+export const iconClass = () => 'text-black dark:text-white duration-100 hover:!text-primary cursor-pointer outline-none ';
+
+// * 顶部样式
+export const topClass = () => 'flex justify-between pt-[3px] px-[11px] border-b-[1px] border-solid border-[#dcdfe6] dark:border-[#303030]';
+
+/**
+ * * 拖拽列样式
+ */
+export const getDropdownItemStyle = computed(() => {
+ return (size: string, s: string) => {
+ return {
+ background: s === size ? useEpThemeStoreHook().epThemeColor : '',
+ color: s === size ? '#fff' : 'var(--el-text-color-primary)',
+ };
+ };
+});
diff --git a/src/components/Text/index.ts b/src/components/Text/index.ts
new file mode 100644
index 0000000..6213566
--- /dev/null
+++ b/src/components/Text/index.ts
@@ -0,0 +1,7 @@
+import reText from "./src/index.vue";
+import { withInstall } from "@pureadmin/utils";
+
+/** 支持`Tooltip`提示的文本省略组件 */
+export const ReText = withInstall(reText);
+
+export default ReText;
diff --git a/src/components/Text/src/index.vue b/src/components/Text/src/index.vue
new file mode 100644
index 0000000..9ec8030
--- /dev/null
+++ b/src/components/Text/src/index.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
diff --git a/src/config/index.ts b/src/config/index.ts
new file mode 100644
index 0000000..c81d1c4
--- /dev/null
+++ b/src/config/index.ts
@@ -0,0 +1,55 @@
+import axios from "axios";
+import type { App } from "vue";
+
+let config: object = {};
+const { VITE_PUBLIC_PATH } = import.meta.env;
+
+const setConfig = (cfg?: unknown) => {
+ config = Object.assign(config, cfg);
+};
+
+const getConfig = (key?: string): PlatformConfigs => {
+ if (typeof key === "string") {
+ const arr = key.split(".");
+ if (arr && arr.length) {
+ let data = config;
+ arr.forEach(v => {
+ if (data && typeof data[v] !== "undefined") {
+ data = data[v];
+ } else {
+ data = null;
+ }
+ });
+ return data;
+ }
+ }
+ return config;
+};
+
+/** 获取项目动态全局配置 */
+export const getPlatformConfig = async (app: App): Promise => {
+ app.config.globalProperties.$config = getConfig();
+ return axios({
+ method: "get",
+ url: `${VITE_PUBLIC_PATH}platform-config.json`
+ })
+ .then(({ data: config }) => {
+ let $config = app.config.globalProperties.$config;
+ // 自动注入系统配置
+ if (app && $config && typeof config === "object") {
+ $config = Object.assign($config, config);
+ app.config.globalProperties.$config = $config;
+ // 设置全局配置
+ setConfig($config);
+ }
+ return $config;
+ })
+ .catch(() => {
+ throw "请在public文件夹下添加platform-config.json配置文件";
+ });
+};
+
+/** 本地响应式存储的命名空间 */
+const responsiveStorageNameSpace = () => getConfig().ResponsiveStorageNameSpace;
+
+export { getConfig, setConfig, responsiveStorageNameSpace };
diff --git a/src/directives/auth/index.ts b/src/directives/auth/index.ts
new file mode 100644
index 0000000..2fc6490
--- /dev/null
+++ b/src/directives/auth/index.ts
@@ -0,0 +1,15 @@
+import { hasAuth } from "@/router/utils";
+import type { Directive, DirectiveBinding } from "vue";
+
+export const auth: Directive = {
+ mounted(el: HTMLElement, binding: DirectiveBinding>) {
+ const { value } = binding;
+ if (value) {
+ !hasAuth(value) && el.parentNode?.removeChild(el);
+ } else {
+ throw new Error(
+ "[Directive: auth]: need auths! Like v-auth=\"['btn.add','btn.edit']\""
+ );
+ }
+ }
+};
diff --git a/src/directives/copy/index.ts b/src/directives/copy/index.ts
new file mode 100644
index 0000000..b71fa19
--- /dev/null
+++ b/src/directives/copy/index.ts
@@ -0,0 +1,33 @@
+import { message } from "@/utils/message";
+import { useEventListener } from "@vueuse/core";
+import { copyTextToClipboard } from "@pureadmin/utils";
+import type { Directive, DirectiveBinding } from "vue";
+
+export interface CopyEl extends HTMLElement {
+ copyValue: string;
+}
+
+/** 文本复制指令(默认双击复制) */
+export const copy: Directive = {
+ mounted(el: CopyEl, binding: DirectiveBinding) {
+ const { value } = binding;
+ if (value) {
+ el.copyValue = value;
+ const arg = binding.arg ?? "dblclick";
+ // Register using addEventListener on mounted, and removeEventListener automatically on unmounted
+ useEventListener(el, arg, () => {
+ const success = copyTextToClipboard(el.copyValue);
+ success
+ ? message("复制成功", { type: "success" })
+ : message("复制失败", { type: "error" });
+ });
+ } else {
+ throw new Error(
+ '[Directive: copy]: need value! Like v-copy="modelValue"'
+ );
+ }
+ },
+ updated(el: CopyEl, binding: DirectiveBinding) {
+ el.copyValue = binding.value;
+ }
+};
diff --git a/src/directives/index.ts b/src/directives/index.ts
new file mode 100644
index 0000000..d01fe71
--- /dev/null
+++ b/src/directives/index.ts
@@ -0,0 +1,6 @@
+export * from "./auth";
+export * from "./copy";
+export * from "./longpress";
+export * from "./optimize";
+export * from "./perms";
+export * from "./ripple";
diff --git a/src/directives/longpress/index.ts b/src/directives/longpress/index.ts
new file mode 100644
index 0000000..4eec6a2
--- /dev/null
+++ b/src/directives/longpress/index.ts
@@ -0,0 +1,63 @@
+import { useEventListener } from "@vueuse/core";
+import type { Directive, DirectiveBinding } from "vue";
+import { subBefore, subAfter, isFunction } from "@pureadmin/utils";
+
+export const longpress: Directive = {
+ mounted(el: HTMLElement, binding: DirectiveBinding) {
+ const cb = binding.value;
+ if (cb && isFunction(cb)) {
+ let timer = null;
+ let interTimer = null;
+ let num = 500;
+ let interNum = null;
+ const isInter = binding?.arg?.includes(":") ?? false;
+
+ if (isInter) {
+ num = Number(subBefore(binding.arg, ":"));
+ interNum = Number(subAfter(binding.arg, ":"));
+ } else if (binding.arg) {
+ num = Number(binding.arg);
+ }
+
+ const clear = () => {
+ if (timer) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ if (interTimer) {
+ clearInterval(interTimer);
+ interTimer = null;
+ }
+ };
+
+ const onDownInter = (ev: PointerEvent) => {
+ ev.preventDefault();
+ if (interTimer === null) {
+ interTimer = setInterval(() => cb(), interNum);
+ }
+ };
+
+ const onDown = (ev: PointerEvent) => {
+ clear();
+ ev.preventDefault();
+ if (timer === null) {
+ timer = isInter
+ ? setTimeout(() => {
+ cb();
+ onDownInter(ev);
+ }, num)
+ : setTimeout(() => cb(), num);
+ }
+ };
+
+ // Register using addEventListener on mounted, and removeEventListener automatically on unmounted
+ useEventListener(el, "pointerdown", onDown);
+ useEventListener(el, "pointerup", clear);
+ useEventListener(el, "pointerleave", clear);
+ } else {
+ throw new Error(
+ '[Directive: longpress]: need callback and callback must be a function! Like v-longpress="callback"'
+ );
+ }
+ }
+};
diff --git a/src/directives/optimize/index.ts b/src/directives/optimize/index.ts
new file mode 100644
index 0000000..7b92538
--- /dev/null
+++ b/src/directives/optimize/index.ts
@@ -0,0 +1,68 @@
+import {
+ isArray,
+ throttle,
+ debounce,
+ isObject,
+ isFunction
+} from "@pureadmin/utils";
+import { useEventListener } from "@vueuse/core";
+import type { Directive, DirectiveBinding } from "vue";
+
+export interface OptimizeOptions {
+ /** 事件名 */
+ event: string;
+ /** 事件触发的方法 */
+ fn: (...params: any) => any;
+ /** 是否立即执行 */
+ immediate?: boolean;
+ /** 防抖或节流的延迟时间(防抖默认:`200`毫秒、节流默认:`1000`毫秒) */
+ timeout?: number;
+ /** 传递的参数 */
+ params?: any;
+}
+
+/** 防抖(v-optimize或v-optimize:debounce)、节流(v-optimize:throttle)指令 */
+export const optimize: Directive = {
+ mounted(el: HTMLElement, binding: DirectiveBinding) {
+ const { value } = binding;
+ const optimizeType = binding.arg ?? "debounce";
+ const type = ["debounce", "throttle"].find(t => t === optimizeType);
+ if (type) {
+ if (value && value.event && isFunction(value.fn)) {
+ let params = value?.params;
+ if (params) {
+ if (isArray(params) || isObject(params)) {
+ params = isObject(params) ? Array.of(params) : params;
+ } else {
+ throw new Error(
+ "[Directive: optimize]: `params` must be an array or object"
+ );
+ }
+ }
+ // Register using addEventListener on mounted, and removeEventListener automatically on unmounted
+ useEventListener(
+ el,
+ value.event,
+ type === "debounce"
+ ? debounce(
+ params ? () => value.fn(...params) : value.fn,
+ value?.timeout ?? 200,
+ value?.immediate ?? false
+ )
+ : throttle(
+ params ? () => value.fn(...params) : value.fn,
+ value?.timeout ?? 1000
+ )
+ );
+ } else {
+ throw new Error(
+ "[Directive: optimize]: `event` and `fn` are required, and `fn` must be a function"
+ );
+ }
+ } else {
+ throw new Error(
+ "[Directive: optimize]: only `debounce` and `throttle` are supported"
+ );
+ }
+ }
+};
diff --git a/src/directives/perms/index.ts b/src/directives/perms/index.ts
new file mode 100644
index 0000000..073c918
--- /dev/null
+++ b/src/directives/perms/index.ts
@@ -0,0 +1,15 @@
+import { hasPerms } from "@/utils/auth";
+import type { Directive, DirectiveBinding } from "vue";
+
+export const perms: Directive = {
+ mounted(el: HTMLElement, binding: DirectiveBinding>) {
+ const { value } = binding;
+ if (value) {
+ !hasPerms(value) && el.parentNode?.removeChild(el);
+ } else {
+ throw new Error(
+ "[Directive: perms]: need perms! Like v-perms=\"['btn.add','btn.edit']\""
+ );
+ }
+ }
+};
diff --git a/src/directives/ripple/index.scss b/src/directives/ripple/index.scss
new file mode 100644
index 0000000..061c82c
--- /dev/null
+++ b/src/directives/ripple/index.scss
@@ -0,0 +1,48 @@
+/* stylelint-disable-next-line scss/dollar-variable-colon-space-after */
+$ripple-animation-transition-in:
+ transform 0.4s cubic-bezier(0, 0, 0.2, 1),
+ opacity 0.2s cubic-bezier(0, 0, 0.2, 1) !default;
+$ripple-animation-transition-out: opacity 0.5s cubic-bezier(0, 0, 0.2, 1) !default;
+$ripple-animation-visible-opacity: 0.25 !default;
+
+.v-ripple {
+ &__container {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ pointer-events: none;
+ border-radius: inherit;
+ contain: strict;
+ }
+
+ &__animation {
+ position: absolute;
+ top: 0;
+ left: 0;
+ overflow: hidden;
+ pointer-events: none;
+ background: currentcolor;
+ border-radius: 50%;
+ opacity: 0;
+ will-change: transform, opacity;
+
+ &--enter {
+ opacity: 0;
+ transition: none;
+ }
+
+ &--in {
+ opacity: $ripple-animation-visible-opacity;
+ transition: $ripple-animation-transition-in;
+ }
+
+ &--out {
+ opacity: 0;
+ transition: $ripple-animation-transition-out;
+ }
+ }
+}
diff --git a/src/directives/ripple/index.ts b/src/directives/ripple/index.ts
new file mode 100644
index 0000000..3fd94d9
--- /dev/null
+++ b/src/directives/ripple/index.ts
@@ -0,0 +1,229 @@
+import "./index.scss";
+import { isObject } from "@pureadmin/utils";
+import type { Directive, DirectiveBinding } from "vue";
+
+export interface RippleOptions {
+ /** 自定义`ripple`颜色,支持`tailwindcss` */
+ class?: string;
+ /** 是否从中心扩散 */
+ center?: boolean;
+ circle?: boolean;
+}
+
+export interface RippleDirectiveBinding
+ extends Omit {
+ value?: boolean | { class: string };
+ modifiers: {
+ center?: boolean;
+ circle?: boolean;
+ };
+}
+
+function transform(el: HTMLElement, value: string) {
+ el.style.transform = value;
+ el.style.webkitTransform = value;
+}
+
+const calculate = (
+ e: PointerEvent,
+ el: HTMLElement,
+ value: RippleOptions = {}
+) => {
+ const offset = el.getBoundingClientRect();
+
+ // 获取点击位置距离 el 的垂直和水平距离
+ let localX = e.clientX - offset.left;
+ let localY = e.clientY - offset.top;
+
+ let radius = 0;
+ let scale = 0.3;
+ // 计算点击位置到 el 顶点最远距离,即为圆的最大半径(勾股定理)
+ if (el._ripple?.circle) {
+ scale = 0.15;
+ radius = el.clientWidth / 2;
+ radius = value.center
+ ? radius
+ : radius + Math.sqrt((localX - radius) ** 2 + (localY - radius) ** 2) / 4;
+ } else {
+ radius = Math.sqrt(el.clientWidth ** 2 + el.clientHeight ** 2) / 2;
+ }
+
+ // 中心点坐标
+ const centerX = `${(el.clientWidth - radius * 2) / 2}px`;
+ const centerY = `${(el.clientHeight - radius * 2) / 2}px`;
+
+ // 点击位置坐标
+ const x = value.center ? centerX : `${localX - radius}px`;
+ const y = value.center ? centerY : `${localY - radius}px`;
+
+ return { radius, scale, x, y, centerX, centerY };
+};
+
+const ripples = {
+ show(e: PointerEvent, el: HTMLElement, value: RippleOptions = {}) {
+ if (!el?._ripple?.enabled) {
+ return;
+ }
+
+ // 创建 ripple 元素和 ripple 父元素
+ const container = document.createElement("span");
+ const animation = document.createElement("span");
+
+ container.appendChild(animation);
+ container.className = "v-ripple__container";
+
+ if (value.class) {
+ container.className += ` ${value.class}`;
+ }
+
+ const { radius, scale, x, y, centerX, centerY } = calculate(e, el, value);
+
+ // ripple 圆大小
+ const size = `${radius * 2}px`;
+
+ animation.className = "v-ripple__animation";
+ animation.style.width = size;
+ animation.style.height = size;
+
+ el.appendChild(container);
+
+ // 获取目标元素样式表
+ const computed = window.getComputedStyle(el);
+ // 防止 position 被覆盖导致 ripple 位置有问题
+ if (computed && computed.position === "static") {
+ el.style.position = "relative";
+ el.dataset.previousPosition = "static";
+ }
+
+ animation.classList.add("v-ripple__animation--enter");
+ animation.classList.add("v-ripple__animation--visible");
+ transform(
+ animation,
+ `translate(${x}, ${y}) scale3d(${scale},${scale},${scale})`
+ );
+ animation.dataset.activated = String(performance.now());
+
+ setTimeout(() => {
+ animation.classList.remove("v-ripple__animation--enter");
+ animation.classList.add("v-ripple__animation--in");
+ transform(animation, `translate(${centerX}, ${centerY}) scale3d(1,1,1)`);
+ }, 0);
+ },
+
+ hide(el: HTMLElement | null) {
+ if (!el?._ripple?.enabled) return;
+
+ const ripples = el.getElementsByClassName("v-ripple__animation");
+
+ if (ripples.length === 0) return;
+ const animation = ripples[ripples.length - 1] as HTMLElement;
+
+ if (animation.dataset.isHiding) return;
+ else animation.dataset.isHiding = "true";
+
+ const diff = performance.now() - Number(animation.dataset.activated);
+ const delay = Math.max(250 - diff, 0);
+
+ setTimeout(() => {
+ animation.classList.remove("v-ripple__animation--in");
+ animation.classList.add("v-ripple__animation--out");
+
+ setTimeout(() => {
+ const ripples = el.getElementsByClassName("v-ripple__animation");
+ if (ripples.length === 1 && el.dataset.previousPosition) {
+ el.style.position = el.dataset.previousPosition;
+ delete el.dataset.previousPosition;
+ }
+
+ if (animation.parentNode?.parentNode === el)
+ el.removeChild(animation.parentNode);
+ }, 300);
+ }, delay);
+ }
+};
+
+function isRippleEnabled(value: any): value is true {
+ return typeof value === "undefined" || !!value;
+}
+
+function rippleShow(e: PointerEvent) {
+ const value: RippleOptions = {};
+ const element = e.currentTarget as HTMLElement | undefined;
+
+ if (!element?._ripple || element._ripple.touched) return;
+
+ value.center = element._ripple.centered;
+ if (element._ripple.class) {
+ value.class = element._ripple.class;
+ }
+
+ ripples.show(e, element, value);
+}
+
+function rippleHide(e: Event) {
+ const element = e.currentTarget as HTMLElement | null;
+ if (!element?._ripple) return;
+
+ window.setTimeout(() => {
+ if (element._ripple) {
+ element._ripple.touched = false;
+ }
+ });
+ ripples.hide(element);
+}
+
+function updateRipple(
+ el: HTMLElement,
+ binding: RippleDirectiveBinding,
+ wasEnabled: boolean
+) {
+ const { value, modifiers } = binding;
+ const enabled = isRippleEnabled(value);
+ if (!enabled) {
+ ripples.hide(el);
+ }
+
+ el._ripple = el._ripple ?? {};
+ el._ripple.enabled = enabled;
+ el._ripple.centered = modifiers.center;
+ el._ripple.circle = modifiers.circle;
+ if (isObject(value) && value.class) {
+ el._ripple.class = value.class;
+ }
+
+ if (enabled && !wasEnabled) {
+ el.addEventListener("pointerdown", rippleShow);
+ el.addEventListener("pointerup", rippleHide);
+ } else if (!enabled && wasEnabled) {
+ removeListeners(el);
+ }
+}
+
+function removeListeners(el: HTMLElement) {
+ el.removeEventListener("pointerdown", rippleShow);
+ el.removeEventListener("pointerup", rippleHide);
+}
+
+function mounted(el: HTMLElement, binding: RippleDirectiveBinding) {
+ updateRipple(el, binding, false);
+}
+
+function unmounted(el: HTMLElement) {
+ delete el._ripple;
+ removeListeners(el);
+}
+
+function updated(el: HTMLElement, binding: RippleDirectiveBinding) {
+ if (binding.value === binding.oldValue) {
+ return;
+ }
+
+ const wasEnabled = isRippleEnabled(binding.oldValue);
+ updateRipple(el, binding, wasEnabled);
+}
+
+export const Ripple: Directive = {
+ mounted,
+ unmounted,
+ updated
+};
diff --git a/src/layout/components/lay-content/index.vue b/src/layout/components/lay-content/index.vue
new file mode 100644
index 0000000..5810d66
--- /dev/null
+++ b/src/layout/components/lay-content/index.vue
@@ -0,0 +1,215 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-footer/index.vue b/src/layout/components/lay-footer/index.vue
new file mode 100644
index 0000000..7763134
--- /dev/null
+++ b/src/layout/components/lay-footer/index.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-frame/index.vue b/src/layout/components/lay-frame/index.vue
new file mode 100644
index 0000000..b2bb9d5
--- /dev/null
+++ b/src/layout/components/lay-frame/index.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-navbar/index.vue b/src/layout/components/lay-navbar/index.vue
new file mode 100644
index 0000000..760a010
--- /dev/null
+++ b/src/layout/components/lay-navbar/index.vue
@@ -0,0 +1,191 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-notice/components/NoticeItem.vue b/src/layout/components/lay-notice/components/NoticeItem.vue
new file mode 100644
index 0000000..823d9cd
--- /dev/null
+++ b/src/layout/components/lay-notice/components/NoticeItem.vue
@@ -0,0 +1,177 @@
+
+
+
+
+
+
+
+
+
+ {{ noticeItem.title }}
+
+
+
+
+
+
+
+ {{ noticeItem.description }}
+
+
+
+ {{ noticeItem.datetime }}
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-notice/components/NoticeList.vue b/src/layout/components/lay-notice/components/NoticeList.vue
new file mode 100644
index 0000000..3bc0090
--- /dev/null
+++ b/src/layout/components/lay-notice/components/NoticeList.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-notice/data.ts b/src/layout/components/lay-notice/data.ts
new file mode 100644
index 0000000..bd49f5e
--- /dev/null
+++ b/src/layout/components/lay-notice/data.ts
@@ -0,0 +1,99 @@
+import { $t } from "@/plugins/i18n";
+
+export interface ListItem {
+ avatar: string;
+ title: string;
+ datetime: string;
+ type: string;
+ description: string;
+ status?: "primary" | "success" | "warning" | "info" | "danger";
+ extra?: string;
+}
+
+export interface TabItem {
+ key: string;
+ name: string;
+ list: ListItem[];
+ emptyText: string;
+}
+
+export const noticesData: TabItem[] = [
+ {
+ key: "1",
+ name: $t("status.pureNotify"),
+ list: [],
+ emptyText: $t("status.pureNoNotify")
+ },
+ {
+ key: "2",
+ name: $t("status.pureMessage"),
+ list: [
+ {
+ avatar: "https://xiaoxian521.github.io/hyperlink/svg/smile1.svg",
+ title: "小铭 评论了你",
+ description: "诚在于心,信在于行,诚信在于心行合一。",
+ datetime: "今天",
+ type: "2"
+ },
+ {
+ avatar: "https://xiaoxian521.github.io/hyperlink/svg/smile2.svg",
+ title: "李白 回复了你",
+ description: "长风破浪会有时,直挂云帆济沧海。",
+ datetime: "昨天",
+ type: "2"
+ },
+ {
+ avatar: "https://xiaoxian521.github.io/hyperlink/svg/smile5.svg",
+ title: "标题",
+ description:
+ "请将鼠标移动到此处,以便测试超长的消息在此处将如何处理。本例中设置的描述最大行数为2,超过2行的描述内容将被省略并且可以通过tooltip查看完整内容",
+ datetime: "时间",
+ type: "2"
+ }
+ ],
+ emptyText: $t("status.pureNoMessage")
+ },
+ {
+ key: "3",
+ name: $t("status.pureTodo"),
+ list: [
+ {
+ avatar: "",
+ title: "第三方紧急代码变更",
+ description:
+ "小林提交于 2024-05-10,需在 2024-05-11 前完成代码变更任务",
+ datetime: "",
+ extra: "马上到期",
+ status: "danger",
+ type: "3"
+ },
+ {
+ avatar: "",
+ title: "版本发布",
+ description: "指派小铭于 2024-06-18 前完成更新并发布",
+ datetime: "",
+ extra: "已耗时 8 天",
+ status: "warning",
+ type: "3"
+ },
+ {
+ avatar: "",
+ title: "新功能开发",
+ description: "开发多租户管理",
+ datetime: "",
+ extra: "进行中",
+ type: "3"
+ },
+ {
+ avatar: "",
+ title: "任务名称",
+ description: "任务需要在 2030-10-30 10:00 前启动",
+ datetime: "",
+ extra: "未开始",
+ status: "info",
+ type: "3"
+ }
+ ],
+ emptyText: $t("status.pureNoTodo")
+ }
+];
diff --git a/src/layout/components/lay-notice/index.vue b/src/layout/components/lay-notice/index.vue
new file mode 100644
index 0000000..45bf757
--- /dev/null
+++ b/src/layout/components/lay-notice/index.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-panel/index.vue b/src/layout/components/lay-panel/index.vue
new file mode 100644
index 0000000..fb4fb20
--- /dev/null
+++ b/src/layout/components/lay-panel/index.vue
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+
+
+ {{ t("panel.pureSystemSet") }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("panel.pureClearCache") }}
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-search/components/SearchFooter.vue b/src/layout/components/lay-search/components/SearchFooter.vue
new file mode 100644
index 0000000..d8350d0
--- /dev/null
+++ b/src/layout/components/lay-search/components/SearchFooter.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-search/components/SearchHistory.vue b/src/layout/components/lay-search/components/SearchHistory.vue
new file mode 100644
index 0000000..87d5488
--- /dev/null
+++ b/src/layout/components/lay-search/components/SearchHistory.vue
@@ -0,0 +1,204 @@
+
+
+
+
+
+
+ {{ t("search.pureHistory") }}
+
+
+
+
+
+
+
+ {{
+ `${t("search.pureCollect")}${collectList.length > 1 ? t("search.pureDragSort") : ""}`
+ }}
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-search/components/SearchHistoryItem.vue b/src/layout/components/lay-search/components/SearchHistoryItem.vue
new file mode 100644
index 0000000..91c9858
--- /dev/null
+++ b/src/layout/components/lay-search/components/SearchHistoryItem.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+ {{ $t(item.meta?.title) }}
+
+
+
+
+
+
diff --git a/src/layout/components/lay-search/components/SearchModal.vue b/src/layout/components/lay-search/components/SearchModal.vue
new file mode 100644
index 0000000..1171673
--- /dev/null
+++ b/src/layout/components/lay-search/components/SearchModal.vue
@@ -0,0 +1,340 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-search/components/SearchResult.vue b/src/layout/components/lay-search/components/SearchResult.vue
new file mode 100644
index 0000000..e7c0750
--- /dev/null
+++ b/src/layout/components/lay-search/components/SearchResult.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+ {{ $t(item.meta?.title) }}
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-search/index.vue b/src/layout/components/lay-search/index.vue
new file mode 100644
index 0000000..123d6a6
--- /dev/null
+++ b/src/layout/components/lay-search/index.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/src/layout/components/lay-search/types.ts b/src/layout/components/lay-search/types.ts
new file mode 100644
index 0000000..a39adbd
--- /dev/null
+++ b/src/layout/components/lay-search/types.ts
@@ -0,0 +1,20 @@
+interface optionsItem {
+ path: string;
+ type: "history" | "collect";
+ meta: {
+ icon?: string;
+ title?: string;
+ };
+}
+
+interface dragItem {
+ oldIndex: number;
+ newIndex: number;
+}
+
+interface Props {
+ value: string;
+ options: Array;
+}
+
+export type { optionsItem, dragItem, Props };
diff --git a/src/layout/components/lay-setting/index.vue b/src/layout/components/lay-setting/index.vue
new file mode 100644
index 0000000..4145b14
--- /dev/null
+++ b/src/layout/components/lay-setting/index.vue
@@ -0,0 +1,642 @@
+
+
+
+
+
+
{{ t("panel.pureOverallStyle") }}
+
{
+ theme.index === 1 && theme.index !== 2
+ ? (dataTheme = true)
+ : (dataTheme = false);
+ overallStyle = theme.option.theme;
+ dataThemeChange(theme.option.theme);
+ theme.index === 2 && watchSystemThemeChange();
+ }
+ "
+ />
+
+ {{ t("panel.pureThemeColor") }}
+
+
+ {{ t("panel.pureLayoutModel") }}
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+
+
+
+ {{ t("panel.pureStretch") }}
+
+ setStretch(value)"
+ />
+
+
+
+ {{ t("panel.pureTagsStyle") }}
+
+
+
+ {{ t("panel.pureInterfaceDisplay") }}
+
+
+ -
+ {{ t("panel.pureGreyModel") }}
+
+
+ -
+ {{ t("panel.pureWeakModel") }}
+
+
+ -
+ {{ t("panel.pureHiddenTags") }}
+
+
+ -
+ {{ t("panel.pureHiddenFooter") }}
+
+
+ -
+ Logo
+
+
+ -
+
+ {{ t("panel.pureMultiTagsCache") }}
+
+
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-sidebar/NavHorizontal.vue b/src/layout/components/lay-sidebar/NavHorizontal.vue
new file mode 100644
index 0000000..c5baa72
--- /dev/null
+++ b/src/layout/components/lay-sidebar/NavHorizontal.vue
@@ -0,0 +1,176 @@
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-sidebar/NavMix.vue b/src/layout/components/lay-sidebar/NavMix.vue
new file mode 100644
index 0000000..43cf7e7
--- /dev/null
+++ b/src/layout/components/lay-sidebar/NavMix.vue
@@ -0,0 +1,197 @@
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-sidebar/NavVertical.vue b/src/layout/components/lay-sidebar/NavVertical.vue
new file mode 100644
index 0000000..0e9fa12
--- /dev/null
+++ b/src/layout/components/lay-sidebar/NavVertical.vue
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-sidebar/components/SidebarBreadCrumb.vue b/src/layout/components/lay-sidebar/components/SidebarBreadCrumb.vue
new file mode 100644
index 0000000..8e038f2
--- /dev/null
+++ b/src/layout/components/lay-sidebar/components/SidebarBreadCrumb.vue
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+ {{ $t(item.meta.title) }}
+
+
+
+
+
diff --git a/src/layout/components/lay-sidebar/components/SidebarCenterCollapse.vue b/src/layout/components/lay-sidebar/components/SidebarCenterCollapse.vue
new file mode 100644
index 0000000..0f41fcc
--- /dev/null
+++ b/src/layout/components/lay-sidebar/components/SidebarCenterCollapse.vue
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-sidebar/components/SidebarExtraIcon.vue b/src/layout/components/lay-sidebar/components/SidebarExtraIcon.vue
new file mode 100644
index 0000000..fb40641
--- /dev/null
+++ b/src/layout/components/lay-sidebar/components/SidebarExtraIcon.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-sidebar/components/SidebarFullScreen.vue b/src/layout/components/lay-sidebar/components/SidebarFullScreen.vue
new file mode 100644
index 0000000..4d38bd0
--- /dev/null
+++ b/src/layout/components/lay-sidebar/components/SidebarFullScreen.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-sidebar/components/SidebarItem.vue b/src/layout/components/lay-sidebar/components/SidebarItem.vue
new file mode 100644
index 0000000..b3eb270
--- /dev/null
+++ b/src/layout/components/lay-sidebar/components/SidebarItem.vue
@@ -0,0 +1,223 @@
+
+
+
+
+
+
+
+ {{ $t(onlyOneChild.meta.title) }}
+
+
+
+
+
+ {{ $t(onlyOneChild.meta.title) }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t(item.meta.title) }}
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-sidebar/components/SidebarLeftCollapse.vue b/src/layout/components/lay-sidebar/components/SidebarLeftCollapse.vue
new file mode 100644
index 0000000..c007d3b
--- /dev/null
+++ b/src/layout/components/lay-sidebar/components/SidebarLeftCollapse.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-sidebar/components/SidebarLinkItem.vue b/src/layout/components/lay-sidebar/components/SidebarLinkItem.vue
new file mode 100644
index 0000000..8911c12
--- /dev/null
+++ b/src/layout/components/lay-sidebar/components/SidebarLinkItem.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-sidebar/components/SidebarLogo.vue b/src/layout/components/lay-sidebar/components/SidebarLogo.vue
new file mode 100644
index 0000000..c2713e7
--- /dev/null
+++ b/src/layout/components/lay-sidebar/components/SidebarLogo.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-sidebar/components/SidebarTopCollapse.vue b/src/layout/components/lay-sidebar/components/SidebarTopCollapse.vue
new file mode 100644
index 0000000..c2f1b5a
--- /dev/null
+++ b/src/layout/components/lay-sidebar/components/SidebarTopCollapse.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
diff --git a/src/layout/components/lay-tag/components/TagChrome.vue b/src/layout/components/lay-tag/components/TagChrome.vue
new file mode 100644
index 0000000..137365b
--- /dev/null
+++ b/src/layout/components/lay-tag/components/TagChrome.vue
@@ -0,0 +1,33 @@
+
+
+
diff --git a/src/layout/components/lay-tag/index.scss b/src/layout/components/lay-tag/index.scss
new file mode 100644
index 0000000..b881216
--- /dev/null
+++ b/src/layout/components/lay-tag/index.scss
@@ -0,0 +1,371 @@
+@keyframes schedule-in-width {
+ from {
+ width: 0;
+ }
+
+ to {
+ width: 100%;
+ }
+}
+
+@keyframes schedule-out-width {
+ from {
+ width: 100%;
+ }
+
+ to {
+ width: 0;
+ }
+}
+
+.tags-view {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ font-size: 14px;
+ color: var(--el-text-color-primary);
+ background: #fff;
+ box-shadow: 0 0 1px #888;
+
+ .scroll-item {
+ position: relative;
+ display: inline-block;
+ height: 34px;
+ padding-left: 6px;
+ line-height: 34px;
+ cursor: pointer;
+ transition: all 0.4s;
+
+ &:not(:first-child) {
+ padding-right: 24px;
+ }
+
+ &.chrome-item {
+ padding-right: 0;
+ padding-left: 0;
+ margin-right: -18px;
+ box-shadow: none;
+ }
+
+ .el-icon-close {
+ position: absolute;
+ top: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ color: var(--el-color-primary);
+ cursor: pointer;
+ border-radius: 4px;
+ transition:
+ background-color 0.12s,
+ color 0.12s;
+ transform: translate(0, -50%);
+
+ &:hover {
+ color: rgb(0 0 0 / 88%) !important;
+ background-color: rgb(0 0 0 / 6%);
+ }
+ }
+ }
+
+ .tag-title {
+ padding: 0 4px;
+ color: var(--el-text-color-primary);
+ text-decoration: none;
+ }
+
+ .scroll-container {
+ position: relative;
+ flex: 1;
+ overflow: hidden;
+ white-space: nowrap;
+
+ &.chrome-scroll-container {
+ padding-top: 4px;
+
+ .fixed-tag {
+ padding: 0 !important;
+ }
+ }
+
+ .tab {
+ position: relative;
+ float: left;
+ overflow: visible;
+ white-space: nowrap;
+ list-style: none;
+
+ .scroll-item {
+ transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
+
+ &:nth-child(1) {
+ padding: 0 12px;
+ }
+
+ &.chrome-item {
+ &:nth-child(1) {
+ padding: 0;
+ }
+ }
+ }
+
+ .fixed-tag {
+ padding: 0 12px;
+ }
+ }
+ }
+
+ /* 右键菜单 */
+ .contextmenu {
+ position: absolute;
+ padding: 5px 0;
+ margin: 0;
+ font-size: 13px;
+ font-weight: normal;
+ color: var(--el-text-color-primary);
+ white-space: nowrap;
+ list-style-type: none;
+ background: #fff;
+ border-radius: 4px;
+ outline: 0;
+ box-shadow: 0 2px 8px rgb(0 0 0 / 15%);
+
+ li {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding: 7px 12px;
+ margin: 0;
+ cursor: pointer;
+
+ &:hover {
+ color: var(--el-color-primary);
+ }
+
+ svg {
+ display: block;
+ margin-right: 0.5em;
+ }
+ }
+ }
+}
+
+.el-dropdown-menu {
+ li {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ margin: 0;
+ cursor: pointer;
+
+ svg {
+ display: block;
+ margin-right: 0.5em;
+ }
+ }
+}
+
+.el-dropdown-menu__item:not(.is-disabled):hover {
+ color: #606266;
+ background: #f0f0f0;
+}
+
+:deep(.el-dropdown-menu__item) i {
+ margin-right: 10px;
+}
+
+:deep(.el-dropdown-menu__item--divided) {
+ margin: 1px 0;
+}
+
+.el-dropdown-menu__item--divided::before {
+ margin: 0;
+}
+
+.el-dropdown-menu__item.is-disabled {
+ cursor: not-allowed;
+}
+
+.scroll-item.is-active {
+ position: relative;
+ color: #fff;
+ box-shadow: 0 0 0.7px #888;
+
+ .chrome-tab {
+ z-index: 10;
+ }
+
+ .chrome-tab__bg {
+ color: var(--el-color-primary-light-9) !important;
+ }
+
+ .tag-title {
+ color: var(--el-color-primary) !important;
+ }
+
+ .chrome-close-btn {
+ color: var(--el-color-primary);
+
+ &:hover {
+ background-color: var(--el-color-primary);
+ }
+ }
+
+ .chrome-tab-divider {
+ opacity: 0;
+ }
+}
+
+.arrow-left,
+.arrow-right,
+.arrow-down {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 34px;
+ color: var(--el-text-color-primary);
+
+ svg {
+ width: 20px;
+ height: 20px;
+ }
+}
+
+.arrow-left {
+ box-shadow: 5px 0 5px -6px #ccc;
+
+ &:hover {
+ cursor: w-resize;
+ }
+}
+
+.arrow-right {
+ border-right: 0.5px solid #ccc;
+ box-shadow: -5px 0 5px -6px #ccc;
+
+ &:hover {
+ cursor: e-resize;
+ }
+}
+
+/* 卡片模式下鼠标移入显示蓝色边框 */
+.card-in {
+ color: var(--el-color-primary);
+
+ .tag-title {
+ color: var(--el-color-primary);
+ }
+}
+
+/* 卡片模式下鼠标移出隐藏蓝色边框 */
+.card-out {
+ color: #666;
+ border: none;
+
+ .tag-title {
+ color: #666;
+ }
+}
+
+/* 灵动模式 */
+.schedule-active {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 2px;
+ background: var(--el-color-primary);
+}
+
+/* 灵动模式下鼠标移入显示蓝色进度条 */
+.schedule-in {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 2px;
+ background: var(--el-color-primary);
+ animation: schedule-in-width 200ms ease-in;
+}
+
+/* 灵动模式下鼠标移出隐藏蓝色进度条 */
+.schedule-out {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 0;
+ height: 2px;
+ background: var(--el-color-primary);
+ animation: schedule-out-width 200ms ease-in;
+}
+
+/* 谷歌风格的页签 */
+.chrome-tab {
+ position: relative;
+ display: inline-flex;
+ gap: 16px;
+ align-items: center;
+ justify-content: center;
+ padding: 0 24px;
+ white-space: nowrap;
+ cursor: pointer;
+
+ .tag-title {
+ padding: 0;
+ }
+
+ .chrome-tab-divider {
+ position: absolute;
+ right: 7px;
+ width: 1px;
+ height: 14px;
+ background-color: #2b2d2f;
+ }
+
+ &:hover {
+ z-index: 10;
+
+ .chrome-tab__bg {
+ color: #dee1e6;
+ }
+
+ .tag-title {
+ color: #1f1f1f;
+ }
+
+ .chrome-tab-divider {
+ opacity: 0;
+ }
+ }
+
+ .chrome-tab__bg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: -10;
+ width: 100%;
+ height: 100%;
+ color: transparent;
+ pointer-events: none;
+ }
+
+ .chrome-close-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ color: #666;
+ border-radius: 50%;
+
+ &:hover {
+ color: white;
+ background-color: #b1b3b8;
+ }
+ }
+}
diff --git a/src/layout/components/lay-tag/index.vue b/src/layout/components/lay-tag/index.vue
new file mode 100644
index 0000000..6f78acb
--- /dev/null
+++ b/src/layout/components/lay-tag/index.vue
@@ -0,0 +1,686 @@
+
+
+
+
+
+
+
diff --git a/src/layout/frame.vue b/src/layout/frame.vue
new file mode 100644
index 0000000..4243b57
--- /dev/null
+++ b/src/layout/frame.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/layout/hooks/useBoolean.ts b/src/layout/hooks/useBoolean.ts
new file mode 100644
index 0000000..1d14031
--- /dev/null
+++ b/src/layout/hooks/useBoolean.ts
@@ -0,0 +1,26 @@
+import { ref } from "vue";
+
+export function useBoolean(initValue = false) {
+ const bool = ref(initValue);
+
+ function setBool(value: boolean) {
+ bool.value = value;
+ }
+ function setTrue() {
+ setBool(true);
+ }
+ function setFalse() {
+ setBool(false);
+ }
+ function toggle() {
+ setBool(!bool.value);
+ }
+
+ return {
+ bool,
+ setBool,
+ setTrue,
+ setFalse,
+ toggle
+ };
+}
diff --git a/src/layout/hooks/useDataThemeChange.ts b/src/layout/hooks/useDataThemeChange.ts
new file mode 100644
index 0000000..80db6dd
--- /dev/null
+++ b/src/layout/hooks/useDataThemeChange.ts
@@ -0,0 +1,145 @@
+import { ref } from "vue";
+import { getConfig } from "@/config";
+import { useLayout } from "./useLayout";
+import { removeToken } from "@/utils/auth";
+import { routerArrays } from "@/layout/types";
+import { router, resetRouter } from "@/router";
+import type { themeColorsType } from "../types";
+import { useAppStoreHook } from "@/store/modules/app";
+import { useGlobal, storageLocal } from "@pureadmin/utils";
+import { useEpThemeStoreHook } from "@/store/modules/epTheme";
+import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
+import {
+ darken,
+ lighten,
+ toggleTheme
+} from "@pureadmin/theme/dist/browser-utils";
+
+export function useDataThemeChange() {
+ const { layoutTheme, layout } = useLayout();
+ const themeColors = ref>([
+ /* 亮白色 */
+ { color: "#ffffff", themeColor: "light" },
+ /* 道奇蓝 */
+ { color: "#1b2a47", themeColor: "default" },
+ /* 深紫罗兰色 */
+ { color: "#722ed1", themeColor: "saucePurple" },
+ /* 深粉色 */
+ { color: "#eb2f96", themeColor: "pink" },
+ /* 猩红色 */
+ { color: "#f5222d", themeColor: "dusk" },
+ /* 橙红色 */
+ { color: "#fa541c", themeColor: "volcano" },
+ /* 绿宝石 */
+ { color: "#13c2c2", themeColor: "mingQing" },
+ /* 酸橙绿 */
+ { color: "#52c41a", themeColor: "auroraGreen" }
+ ]);
+
+ const { $storage } = useGlobal();
+ const dataTheme = ref($storage?.layout?.darkMode);
+ const overallStyle = ref($storage?.layout?.overallStyle);
+ const body = document.documentElement as HTMLElement;
+
+ function toggleClass(flag: boolean, clsName: string, target?: HTMLElement) {
+ const targetEl = target || document.body;
+ let { className } = targetEl;
+ className = className.replace(clsName, "").trim();
+ targetEl.className = flag ? `${className} ${clsName}` : className;
+ }
+
+ /** 设置导航主题色 */
+ function setLayoutThemeColor(
+ theme = getConfig().Theme ?? "light",
+ isClick = true
+ ) {
+ layoutTheme.value.theme = theme;
+ toggleTheme({
+ scopeName: `layout-theme-${theme}`
+ });
+ // 如果非isClick,保留之前的themeColor
+ const storageThemeColor = $storage.layout.themeColor;
+ $storage.layout = {
+ layout: layout.value,
+ theme,
+ darkMode: dataTheme.value,
+ sidebarStatus: $storage.layout?.sidebarStatus,
+ epThemeColor: $storage.layout?.epThemeColor,
+ themeColor: isClick ? theme : storageThemeColor,
+ overallStyle: overallStyle.value
+ };
+
+ if (theme === "default" || theme === "light") {
+ setEpThemeColor(getConfig().EpThemeColor);
+ } else {
+ const colors = themeColors.value.find(v => v.themeColor === theme);
+ setEpThemeColor(colors.color);
+ }
+ }
+
+ function setPropertyPrimary(mode: string, i: number, color: string) {
+ document.documentElement.style.setProperty(
+ `--el-color-primary-${mode}-${i}`,
+ dataTheme.value ? darken(color, i / 10) : lighten(color, i / 10)
+ );
+ }
+
+ /** 设置 `element-plus` 主题色 */
+ const setEpThemeColor = (color: string) => {
+ useEpThemeStoreHook().setEpThemeColor(color);
+ document.documentElement.style.setProperty("--el-color-primary", color);
+ for (let i = 1; i <= 2; i++) {
+ setPropertyPrimary("dark", i, color);
+ }
+ for (let i = 1; i <= 9; i++) {
+ setPropertyPrimary("light", i, color);
+ }
+ };
+
+ /** 浅色、深色整体风格切换 */
+ function dataThemeChange(overall?: string) {
+ overallStyle.value = overall;
+ if (useEpThemeStoreHook().epTheme === "light" && dataTheme.value) {
+ setLayoutThemeColor("default", false);
+ } else {
+ setLayoutThemeColor(useEpThemeStoreHook().epTheme, false);
+ }
+
+ if (dataTheme.value) {
+ document.documentElement.classList.add("dark");
+ } else {
+ if ($storage.layout.themeColor === "light") {
+ setLayoutThemeColor("light", false);
+ }
+ document.documentElement.classList.remove("dark");
+ }
+ }
+
+ /** 清空缓存并返回登录页 */
+ function onReset() {
+ removeToken();
+ storageLocal().clear();
+ const { Grey, Weak, MultiTagsCache, EpThemeColor, Layout } = getConfig();
+ useAppStoreHook().setLayout(Layout);
+ setEpThemeColor(EpThemeColor);
+ useMultiTagsStoreHook().multiTagsCacheChange(MultiTagsCache);
+ toggleClass(Grey, "html-grey", document.querySelector("html"));
+ toggleClass(Weak, "html-weakness", document.querySelector("html"));
+ router.push("/login");
+ useMultiTagsStoreHook().handleTags("equal", [...routerArrays]);
+ resetRouter();
+ }
+
+ return {
+ body,
+ dataTheme,
+ overallStyle,
+ layoutTheme,
+ themeColors,
+ onReset,
+ toggleClass,
+ dataThemeChange,
+ setEpThemeColor,
+ setLayoutThemeColor
+ };
+}
diff --git a/src/layout/hooks/useLayout.ts b/src/layout/hooks/useLayout.ts
new file mode 100644
index 0000000..5fb0235
--- /dev/null
+++ b/src/layout/hooks/useLayout.ts
@@ -0,0 +1,64 @@
+import { computed } from "vue";
+import { useI18n } from "vue-i18n";
+import { routerArrays } from "../types";
+import { useGlobal } from "@pureadmin/utils";
+import { useMultiTagsStore } from "@/store/modules/multiTags";
+
+export function useLayout() {
+ const { $storage, $config } = useGlobal();
+
+ const initStorage = () => {
+ /** 路由 */
+ if (
+ useMultiTagsStore().multiTagsCache &&
+ (!$storage.tags || $storage.tags.length === 0)
+ ) {
+ $storage.tags = routerArrays;
+ }
+ /** 国际化 */
+ if (!$storage.locale) {
+ $storage.locale = { locale: $config?.Locale ?? "zh" };
+ useI18n().locale.value = $config?.Locale ?? "zh";
+ }
+ /** 导航 */
+ if (!$storage.layout) {
+ $storage.layout = {
+ layout: $config?.Layout ?? "vertical",
+ theme: $config?.Theme ?? "light",
+ darkMode: $config?.DarkMode ?? false,
+ sidebarStatus: $config?.SidebarStatus ?? true,
+ epThemeColor: $config?.EpThemeColor ?? "#409EFF",
+ themeColor: $config?.Theme ?? "light",
+ overallStyle: $config?.OverallStyle ?? "light"
+ };
+ }
+ /** 灰色模式、色弱模式、隐藏标签页 */
+ if (!$storage.configure) {
+ $storage.configure = {
+ grey: $config?.Grey ?? false,
+ weak: $config?.Weak ?? false,
+ hideTabs: $config?.HideTabs ?? false,
+ hideFooter: $config.HideFooter ?? true,
+ showLogo: $config?.ShowLogo ?? true,
+ showModel: $config?.ShowModel ?? "smart",
+ multiTagsCache: $config?.MultiTagsCache ?? false,
+ stretch: $config?.Stretch ?? false
+ };
+ }
+ };
+
+ /** 清空缓存后从platform-config.json读取默认配置并赋值到storage中 */
+ const layout = computed(() => {
+ return $storage?.layout.layout;
+ });
+
+ const layoutTheme = computed(() => {
+ return $storage.layout;
+ });
+
+ return {
+ layout,
+ layoutTheme,
+ initStorage
+ };
+}
diff --git a/src/layout/hooks/useMultiFrame.ts b/src/layout/hooks/useMultiFrame.ts
new file mode 100644
index 0000000..73a779d
--- /dev/null
+++ b/src/layout/hooks/useMultiFrame.ts
@@ -0,0 +1,25 @@
+const MAP = new Map();
+
+export const useMultiFrame = () => {
+ function setMap(path, Comp) {
+ MAP.set(path, Comp);
+ }
+
+ function getMap(path?) {
+ if (path) {
+ return MAP.get(path);
+ }
+ return [...MAP.entries()];
+ }
+
+ function delMap(path) {
+ MAP.delete(path);
+ }
+
+ return {
+ setMap,
+ getMap,
+ delMap,
+ MAP
+ };
+};
diff --git a/src/layout/hooks/useNav.ts b/src/layout/hooks/useNav.ts
new file mode 100644
index 0000000..82977fa
--- /dev/null
+++ b/src/layout/hooks/useNav.ts
@@ -0,0 +1,173 @@
+import { storeToRefs } from "pinia";
+import { getConfig } from "@/config";
+import { useRouter } from "vue-router";
+import { emitter } from "@/utils/mitt";
+import Avatar from "@/assets/user.jpg";
+import { getTopMenu } from "@/router/utils";
+import { useFullscreen } from "@vueuse/core";
+import type { routeMetaType } from "../types";
+import { remainingPaths, router } from "@/router";
+import { computed, type CSSProperties } from "vue";
+import { useAppStoreHook } from "@/store/modules/app";
+import { useUserStoreHook } from "@/store/modules/user";
+import { isAllEmpty, useGlobal } from "@pureadmin/utils";
+import { useEpThemeStoreHook } from "@/store/modules/epTheme";
+import { usePermissionStoreHook } from "@/store/modules/permission";
+import ExitFullscreen from "@iconify-icons/ri/fullscreen-exit-fill";
+import Fullscreen from "@iconify-icons/ri/fullscreen-fill";
+import { $t } from "@/plugins/i18n";
+
+const errorInfo =
+ "The current routing configuration is incorrect, please check the configuration";
+
+export function useNav() {
+ const pureApp = useAppStoreHook();
+ const routers = useRouter().options.routes;
+ const { isFullscreen, toggle } = useFullscreen();
+ const { wholeMenus } = storeToRefs(usePermissionStoreHook());
+ /** 平台`layout`中所有`el-tooltip`的`effect`配置,默认`light` */
+ const tooltipEffect = getConfig()?.TooltipEffect ?? "light";
+
+ const getDivStyle = computed((): CSSProperties => {
+ return {
+ width: "100%",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ overflow: "hidden"
+ };
+ });
+
+ /** 头像(如果头像为空则使用 src/assets/user.jpg ) */
+ const userAvatar = computed(() => {
+ return isAllEmpty(useUserStoreHook()?.avatar)
+ ? Avatar
+ : useUserStoreHook()?.avatar;
+ });
+
+ /** 昵称(如果昵称为空则显示用户名) */
+ const username = computed(() => {
+ return isAllEmpty(useUserStoreHook()?.nickname)
+ ? useUserStoreHook()?.username
+ : useUserStoreHook()?.nickname;
+ });
+
+ /** 设置国际化选中后的样式 */
+ const getDropdownItemStyle = computed(() => {
+ return (locale, t) => {
+ return {
+ background: locale === t ? useEpThemeStoreHook().epThemeColor : "",
+ color: locale === t ? "#f4f4f5" : "#000"
+ };
+ };
+ });
+
+ const getDropdownItemClass = computed(() => {
+ return (locale, t) => {
+ return locale === t ? "" : "dark:hover:!text-primary";
+ };
+ });
+
+ const avatarsStyle = computed(() => {
+ return username.value ? { marginRight: "10px" } : "";
+ });
+
+ const isCollapse = computed(() => {
+ return !pureApp.getSidebarStatus;
+ });
+
+ const device = computed(() => {
+ return pureApp.getDevice;
+ });
+
+ const { $storage, $config } = useGlobal();
+ const layout = computed(() => $storage?.layout?.layout);
+
+ const title = computed(() => {
+ return $config.Title;
+ });
+
+ /** 动态title */
+ function changeTitle(meta: routeMetaType) {
+ const Title = getConfig().Title;
+ if (Title) document.title = `${meta.title} | ${Title}`;
+ else document.title = $t(meta.title);
+ }
+
+ /** 退出登录 */
+ function logout() {
+ useUserStoreHook().logOut();
+ }
+
+ function backTopMenu() {
+ router.push(getTopMenu()?.path);
+ }
+
+ function onPanel() {
+ emitter.emit("openPanel");
+ }
+
+ function toggleSideBar() {
+ pureApp.toggleSideBar();
+ }
+
+ function handleResize(menuRef) {
+ menuRef?.handleResize();
+ }
+
+ function resolvePath(route) {
+ if (!route.children) return console.error(errorInfo);
+ const httpReg = /^http(s?):\/\//;
+ const routeChildPath = route.children[0]?.path;
+ if (httpReg.test(routeChildPath)) {
+ return route.path + "/" + routeChildPath;
+ } else {
+ return routeChildPath;
+ }
+ }
+
+ function menuSelect(indexPath: string) {
+ if (wholeMenus.value.length === 0 || isRemaining(indexPath)) return;
+ emitter.emit("changLayoutRoute", indexPath);
+ }
+
+ /** 判断路径是否参与菜单 */
+ function isRemaining(path: string) {
+ return remainingPaths.includes(path);
+ }
+
+ /** 获取`logo` */
+ function getLogo() {
+ return new URL("/logo.png", import.meta.url).href;
+ }
+
+ return {
+ title,
+ device,
+ layout,
+ logout,
+ routers,
+ $storage,
+ isFullscreen,
+ Fullscreen,
+ ExitFullscreen,
+ toggle,
+ backTopMenu,
+ onPanel,
+ getDivStyle,
+ changeTitle,
+ toggleSideBar,
+ menuSelect,
+ handleResize,
+ resolvePath,
+ getLogo,
+ isCollapse,
+ pureApp,
+ username,
+ userAvatar,
+ avatarsStyle,
+ tooltipEffect,
+ getDropdownItemStyle,
+ getDropdownItemClass
+ };
+}
diff --git a/src/layout/hooks/useTag.ts b/src/layout/hooks/useTag.ts
new file mode 100644
index 0000000..93ee879
--- /dev/null
+++ b/src/layout/hooks/useTag.ts
@@ -0,0 +1,247 @@
+import {
+ computed,
+ type CSSProperties,
+ getCurrentInstance,
+ onMounted,
+ reactive,
+ ref,
+ unref
+} from "vue";
+import type { tagsViewsType } from "../types";
+import { useRoute, useRouter } from "vue-router";
+import { $t } from "@/plugins/i18n";
+import { responsiveStorageNameSpace } from "@/config";
+import { useSettingStoreHook } from "@/store/modules/settings";
+import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
+import {
+ hasClass,
+ isBoolean,
+ isEqual,
+ storageLocal,
+ toggleClass
+} from "@pureadmin/utils";
+
+import Fullscreen from "@iconify-icons/ri/fullscreen-fill";
+import CloseAllTags from "@iconify-icons/ri/subtract-line";
+import CloseOtherTags from "@iconify-icons/ri/text-spacing";
+import CloseRightTags from "@iconify-icons/ri/text-direction-l";
+import CloseLeftTags from "@iconify-icons/ri/text-direction-r";
+import RefreshRight from "@iconify-icons/ep/refresh-right";
+import Close from "@iconify-icons/ep/close";
+
+export function useTags() {
+ const route = useRoute();
+ const router = useRouter();
+ const instance = getCurrentInstance();
+ const pureSetting = useSettingStoreHook();
+
+ const buttonTop = ref(0);
+ const buttonLeft = ref(0);
+ const translateX = ref(0);
+ const visible = ref(false);
+ const activeIndex = ref(-1);
+ // 当前右键选中的路由信息
+ const currentSelect = ref({});
+ const isScrolling = ref(false);
+
+ /** 显示模式,默认灵动模式 */
+ const showModel = ref(
+ storageLocal().getItem(
+ `${responsiveStorageNameSpace()}configure`
+ )?.showModel || "smart"
+ );
+ /** 是否隐藏标签页,默认显示 */
+ const showTags =
+ ref(
+ storageLocal().getItem(
+ `${responsiveStorageNameSpace()}configure`
+ ).hideTabs
+ ) ?? ref("false");
+ const multiTags: any = computed(() => {
+ return useMultiTagsStoreHook().multiTags;
+ });
+
+ const tagsViews = reactive>([
+ {
+ icon: RefreshRight,
+ text: $t("buttons.pureReload"),
+ divided: false,
+ disabled: false,
+ show: true
+ },
+ {
+ icon: Close,
+ text: $t("buttons.pureCloseCurrentTab"),
+ divided: false,
+ disabled: multiTags.value.length <= 1,
+ show: true
+ },
+ {
+ icon: CloseLeftTags,
+ text: $t("buttons.pureCloseLeftTabs"),
+ divided: true,
+ disabled: multiTags.value.length <= 1,
+ show: true
+ },
+ {
+ icon: CloseRightTags,
+ text: $t("buttons.pureCloseRightTabs"),
+ divided: false,
+ disabled: multiTags.value.length <= 1,
+ show: true
+ },
+ {
+ icon: CloseOtherTags,
+ text: $t("buttons.pureCloseOtherTabs"),
+ divided: true,
+ disabled: multiTags.value.length <= 2,
+ show: true
+ },
+ {
+ icon: CloseAllTags,
+ text: $t("buttons.pureCloseAllTabs"),
+ divided: false,
+ disabled: multiTags.value.length <= 1,
+ show: true
+ },
+ {
+ icon: Fullscreen,
+ text: $t("buttons.pureContentFullScreen"),
+ divided: true,
+ disabled: false,
+ show: true
+ }
+ ]);
+
+ function conditionHandle(item, previous, next) {
+ if (isBoolean(route?.meta?.showLink) && route?.meta?.showLink === false) {
+ if (Object.keys(route.query).length > 0) {
+ return isEqual(route.query, item.query) ? previous : next;
+ } else {
+ return isEqual(route.params, item.params) ? previous : next;
+ }
+ } else {
+ return route.path === item.path ? previous : next;
+ }
+ }
+
+ const isFixedTag = computed(() => {
+ return item => {
+ return isBoolean(item?.meta?.fixedTag) && item?.meta?.fixedTag === true;
+ };
+ });
+
+ const iconIsActive = computed(() => {
+ return (item, index) => {
+ if (index === 0) return;
+ return conditionHandle(item, true, false);
+ };
+ });
+
+ const linkIsActive = computed(() => {
+ return item => {
+ return conditionHandle(item, "is-active", "");
+ };
+ });
+
+ const scheduleIsActive = computed(() => {
+ return item => {
+ return conditionHandle(item, "schedule-active", "");
+ };
+ });
+
+ const getTabStyle = computed((): CSSProperties => {
+ return {
+ transform: `translateX(${translateX.value}px)`,
+ transition: isScrolling.value ? "none" : "transform 0.5s ease-in-out"
+ };
+ });
+
+ const getContextMenuStyle = computed((): CSSProperties => {
+ return { left: buttonLeft.value + "px", top: buttonTop.value + "px" };
+ });
+
+ const closeMenu = () => {
+ visible.value = false;
+ };
+
+ /** 鼠标移入添加激活样式 */
+ function onMouseenter(index) {
+ if (index) activeIndex.value = index;
+ if (unref(showModel) === "smart") {
+ if (hasClass(instance.refs["schedule" + index][0], "schedule-active"))
+ return;
+ toggleClass(true, "schedule-in", instance.refs["schedule" + index][0]);
+ toggleClass(false, "schedule-out", instance.refs["schedule" + index][0]);
+ } else {
+ if (hasClass(instance.refs["dynamic" + index][0], "is-active")) return;
+ toggleClass(true, "card-in", instance.refs["dynamic" + index][0]);
+ toggleClass(false, "card-out", instance.refs["dynamic" + index][0]);
+ }
+ }
+
+ /** 鼠标移出恢复默认样式 */
+ function onMouseleave(index) {
+ activeIndex.value = -1;
+ if (unref(showModel) === "smart") {
+ if (hasClass(instance.refs["schedule" + index][0], "schedule-active"))
+ return;
+ toggleClass(false, "schedule-in", instance.refs["schedule" + index][0]);
+ toggleClass(true, "schedule-out", instance.refs["schedule" + index][0]);
+ } else {
+ if (hasClass(instance.refs["dynamic" + index][0], "is-active")) return;
+ toggleClass(false, "card-in", instance.refs["dynamic" + index][0]);
+ toggleClass(true, "card-out", instance.refs["dynamic" + index][0]);
+ }
+ }
+
+ function onContentFullScreen() {
+ pureSetting.hiddenSideBar
+ ? pureSetting.changeSetting({ key: "hiddenSideBar", value: false })
+ : pureSetting.changeSetting({ key: "hiddenSideBar", value: true });
+ }
+
+ onMounted(() => {
+ if (!showModel.value) {
+ const configure = storageLocal().getItem(
+ `${responsiveStorageNameSpace()}configure`
+ );
+ configure.showModel = "card";
+ storageLocal().setItem(
+ `${responsiveStorageNameSpace()}configure`,
+ configure
+ );
+ }
+ });
+
+ return {
+ Close,
+ route,
+ router,
+ visible,
+ showTags,
+ instance,
+ multiTags,
+ showModel,
+ tagsViews,
+ buttonTop,
+ buttonLeft,
+ translateX,
+ isFixedTag,
+ pureSetting,
+ activeIndex,
+ getTabStyle,
+ isScrolling,
+ iconIsActive,
+ linkIsActive,
+ currentSelect,
+ scheduleIsActive,
+ getContextMenuStyle,
+ $t,
+ closeMenu,
+ onMounted,
+ onMouseenter,
+ onMouseleave,
+ onContentFullScreen
+ };
+}
diff --git a/src/layout/hooks/useTranslationLang.ts b/src/layout/hooks/useTranslationLang.ts
new file mode 100644
index 0000000..a8ee305
--- /dev/null
+++ b/src/layout/hooks/useTranslationLang.ts
@@ -0,0 +1,40 @@
+import { useNav } from "./useNav";
+import { useI18n } from "vue-i18n";
+import { useRoute } from "vue-router";
+import { onBeforeMount, type Ref, watch } from "vue";
+
+export function useTranslationLang(ref?: Ref) {
+ const { $storage, changeTitle, handleResize } = useNav();
+ const { locale } = useI18n();
+ const route = useRoute();
+
+ function translationCh() {
+ $storage.locale = { locale: "zh" };
+ locale.value = "zh";
+ ref && handleResize(ref.value);
+ }
+
+ function translationEn() {
+ $storage.locale = { locale: "en" };
+ locale.value = "en";
+ ref && handleResize(ref.value);
+ }
+
+ watch(
+ () => locale.value,
+ () => {
+ changeTitle(route.meta);
+ }
+ );
+
+ onBeforeMount(() => {
+ locale.value = $storage.locale?.locale ?? "zh";
+ });
+
+ return {
+ route,
+ locale,
+ translationCh,
+ translationEn
+ };
+}
diff --git a/src/layout/index.vue b/src/layout/index.vue
new file mode 100644
index 0000000..f3fac76
--- /dev/null
+++ b/src/layout/index.vue
@@ -0,0 +1,237 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/layout/redirect.vue b/src/layout/redirect.vue
new file mode 100644
index 0000000..6e16339
--- /dev/null
+++ b/src/layout/redirect.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/src/layout/theme/index.ts b/src/layout/theme/index.ts
new file mode 100644
index 0000000..f7b4d47
--- /dev/null
+++ b/src/layout/theme/index.ts
@@ -0,0 +1,129 @@
+/**
+ * @description ⚠️:此文件仅供主题插件使用,请不要在此文件中导出别的工具函数(仅在页面加载前运行)
+ */
+
+import type { multipleScopeVarsOptions } from "@pureadmin/theme";
+
+/** 预设主题色 */
+const themeColors = {
+ /* 亮白色 */
+ light: {
+ subMenuActiveText: "#000000d9",
+ menuBg: "#fff",
+ menuHover: "#f6f6f6",
+ subMenuBg: "#fff",
+ subMenuActiveBg: "#e0ebf6",
+ menuText: "rgb(0 0 0 / 60%)",
+ sidebarLogo: "#fff",
+ menuTitleHover: "#000",
+ menuActiveBefore: "#4091f7"
+ },
+ /* 道奇蓝 */
+ default: {
+ subMenuActiveText: "#fff",
+ menuBg: "#001529",
+ menuHover: "rgb(64 145 247 / 15%)",
+ subMenuBg: "#0f0303",
+ subMenuActiveBg: "#4091f7",
+ menuText: "rgb(254 254 254 / 65%)",
+ sidebarLogo: "#002140",
+ menuTitleHover: "#fff",
+ menuActiveBefore: "#4091f7"
+ },
+ /* 深紫罗兰色 */
+ saucePurple: {
+ subMenuActiveText: "#fff",
+ menuBg: "#130824",
+ menuHover: "rgb(105 58 201 / 15%)",
+ subMenuBg: "#000",
+ subMenuActiveBg: "#693ac9",
+ menuText: "#7a80b4",
+ sidebarLogo: "#1f0c38",
+ menuTitleHover: "#fff",
+ menuActiveBefore: "#693ac9"
+ },
+ /* 深粉色 */
+ pink: {
+ subMenuActiveText: "#fff",
+ menuBg: "#28081a",
+ menuHover: "rgb(216 68 147 / 15%)",
+ subMenuBg: "#000",
+ subMenuActiveBg: "#d84493",
+ menuText: "#7a80b4",
+ sidebarLogo: "#3f0d29",
+ menuTitleHover: "#fff",
+ menuActiveBefore: "#d84493"
+ },
+ /* 猩红色 */
+ dusk: {
+ subMenuActiveText: "#fff",
+ menuBg: "#2a0608",
+ menuHover: "rgb(225 60 57 / 15%)",
+ subMenuBg: "#000",
+ subMenuActiveBg: "#e13c39",
+ menuText: "rgb(254 254 254 / 65.1%)",
+ sidebarLogo: "#42090c",
+ menuTitleHover: "#fff",
+ menuActiveBefore: "#e13c39"
+ },
+ /* 橙红色 */
+ volcano: {
+ subMenuActiveText: "#fff",
+ menuBg: "#2b0e05",
+ menuHover: "rgb(232 95 51 / 15%)",
+ subMenuBg: "#0f0603",
+ subMenuActiveBg: "#e85f33",
+ menuText: "rgb(254 254 254 / 65%)",
+ sidebarLogo: "#441708",
+ menuTitleHover: "#fff",
+ menuActiveBefore: "#e85f33"
+ },
+ /* 绿宝石 */
+ mingQing: {
+ subMenuActiveText: "#fff",
+ menuBg: "#032121",
+ menuHover: "rgb(89 191 193 / 15%)",
+ subMenuBg: "#000",
+ subMenuActiveBg: "#59bfc1",
+ menuText: "#7a80b4",
+ sidebarLogo: "#053434",
+ menuTitleHover: "#fff",
+ menuActiveBefore: "#59bfc1"
+ },
+ /* 酸橙绿 */
+ auroraGreen: {
+ subMenuActiveText: "#fff",
+ menuBg: "#0b1e15",
+ menuHover: "rgb(96 172 128 / 15%)",
+ subMenuBg: "#000",
+ subMenuActiveBg: "#60ac80",
+ menuText: "#7a80b4",
+ sidebarLogo: "#112f21",
+ menuTitleHover: "#fff",
+ menuActiveBefore: "#60ac80"
+ }
+};
+
+/**
+ * @description 将预设主题色处理成主题插件所需格式
+ */
+export const genScssMultipleScopeVars = (): multipleScopeVarsOptions[] => {
+ const result = [] as multipleScopeVarsOptions[];
+ Object.keys(themeColors).forEach(key => {
+ result.push({
+ scopeName: `layout-theme-${key}`,
+ varsContent: `
+ $subMenuActiveText: ${themeColors[key].subMenuActiveText} !default;
+ $menuBg: ${themeColors[key].menuBg} !default;
+ $menuHover: ${themeColors[key].menuHover} !default;
+ $subMenuBg: ${themeColors[key].subMenuBg} !default;
+ $subMenuActiveBg: ${themeColors[key].subMenuActiveBg} !default;
+ $menuText: ${themeColors[key].menuText} !default;
+ $sidebarLogo: ${themeColors[key].sidebarLogo} !default;
+ $menuTitleHover: ${themeColors[key].menuTitleHover} !default;
+ $menuActiveBefore: ${themeColors[key].menuActiveBefore} !default;
+ `
+ } as multipleScopeVarsOptions);
+ });
+ return result;
+};
diff --git a/src/layout/types.ts b/src/layout/types.ts
new file mode 100644
index 0000000..3a5acb0
--- /dev/null
+++ b/src/layout/types.ts
@@ -0,0 +1,93 @@
+import type { IconifyIcon } from "@iconify/vue";
+
+const { VITE_HIDE_HOME } = import.meta.env;
+
+export const routerArrays: Array =
+ VITE_HIDE_HOME === "false"
+ ? [
+ {
+ path: "/welcome",
+ meta: {
+ title: "menus.home",
+ icon: "ep:home-filled"
+ }
+ }
+ ]
+ : [];
+
+export type routeMetaType = {
+ title?: string;
+ icon?: string | IconifyIcon;
+ showLink?: boolean;
+ savedPosition?: boolean;
+ auths?: Array;
+};
+
+export type RouteConfigs = {
+ path?: string;
+ query?: object;
+ params?: object;
+ meta?: routeMetaType;
+ children?: RouteConfigs[];
+ name?: string;
+};
+
+export type multiTagsType = {
+ tags: Array;
+};
+
+export type tagsViewsType = {
+ icon: string | IconifyIcon;
+ text: string;
+ divided: boolean;
+ disabled: boolean;
+ show: boolean;
+};
+
+export interface setType {
+ sidebar: {
+ opened: boolean;
+ withoutAnimation: boolean;
+ isClickCollapse: boolean;
+ };
+ device: string;
+ fixedHeader: boolean;
+ classes: {
+ hideSidebar: boolean;
+ openSidebar: boolean;
+ withoutAnimation: boolean;
+ mobile: boolean;
+ };
+ hideTabs: boolean;
+}
+
+export type menuType = {
+ id?: number;
+ name?: string;
+ path?: string;
+ noShowingChildren?: boolean;
+ children?: menuType[];
+ value: unknown;
+ meta?: {
+ icon?: string;
+ title?: string;
+ rank?: number;
+ showParent?: boolean;
+ extraIcon?: string;
+ };
+ showTooltip?: boolean;
+ parentId?: number;
+ pathList?: number[];
+ redirect?: string;
+};
+
+export type themeColorsType = {
+ color: string;
+ themeColor: string;
+};
+
+export interface scrollbarDomType extends HTMLElement {
+ wrap?: {
+ offsetWidth: number;
+ };
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..ac9e6db
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,68 @@
+import { useI18n } from "@/plugins/i18n";
+import { setupStore } from "@/store";
+import { MotionPlugin } from "@vueuse/motion";
+import App from "./App.vue";
+import { getPlatformConfig } from "./config";
+import router from "./router";
+// import { useEcharts } from "@/plugins/echarts";
+import { useElementPlus } from "@/plugins/elementPlus";
+import { injectResponsiveStorage } from "@/utils/responsive";
+import { createApp, type Directive } from "vue";
+
+import Table from "@pureadmin/table";
+// import PureDescriptions from "@pureadmin/descriptions";
+// 引入重置样式
+import "./style/reset.scss";
+// 导入公共样式
+import "./style/index.scss";
+// 一定要在main.ts中导入tailwind.css,防止vite每次hmr都会请求src/style/index.scss整体css文件导致热更新慢的问题
+import "element-plus/dist/index.css";
+import "./style/tailwind.css";
+// 导入字体图标
+import "./assets/iconfont/iconfont.css";
+import "./assets/iconfont/iconfont.js";
+// 自定义指令
+import * as directives from "@/directives";
+// 全局注册@iconify/vue图标库
+import {
+ FontIcon,
+ IconifyIconOffline,
+ IconifyIconOnline
+} from "./components/CommonIcon";
+// 全局注册按钮级别权限组件
+import { Auth } from "@/components/Auth";
+import { Perms } from "@/components/Perms";
+// 全局注册vue-tippy
+import "tippy.js/dist/tippy.css";
+import "tippy.js/themes/light.css";
+import VueTippy from "vue-tippy";
+import { useEcharts } from "@/plugins/echarts";
+
+const app = createApp(App);
+
+Object.keys(directives).forEach(key => {
+ app.directive(key, (directives as { [key: string]: Directive })[key]);
+});
+
+app.component("IconifyIconOffline", IconifyIconOffline);
+app.component("IconifyIconOnline", IconifyIconOnline);
+app.component("FontIcon", FontIcon);
+app.component("Auth", Auth);
+app.component("Perms", Perms);
+
+app.use(VueTippy);
+
+getPlatformConfig(app).then(async config => {
+ setupStore(app);
+ app.use(router);
+ await router.isReady();
+ injectResponsiveStorage(app, config);
+ app
+ .use(MotionPlugin)
+ .use(useI18n)
+ .use(useElementPlus)
+ .use(Table)
+ // .use(PureDescriptions)
+ .use(useEcharts);
+ app.mount("#app");
+});
diff --git a/src/plugins/echarts.ts b/src/plugins/echarts.ts
new file mode 100644
index 0000000..cb62d96
--- /dev/null
+++ b/src/plugins/echarts.ts
@@ -0,0 +1,44 @@
+import type { App } from "vue";
+import * as echarts from "echarts/core";
+import { PieChart, BarChart, LineChart } from "echarts/charts";
+import { CanvasRenderer, SVGRenderer } from "echarts/renderers";
+import {
+ GridComponent,
+ TitleComponent,
+ PolarComponent,
+ LegendComponent,
+ GraphicComponent,
+ ToolboxComponent,
+ TooltipComponent,
+ DataZoomComponent,
+ VisualMapComponent
+} from "echarts/components";
+
+const { use } = echarts;
+
+use([
+ PieChart,
+ BarChart,
+ LineChart,
+ CanvasRenderer,
+ SVGRenderer,
+ GridComponent,
+ TitleComponent,
+ PolarComponent,
+ LegendComponent,
+ GraphicComponent,
+ ToolboxComponent,
+ TooltipComponent,
+ DataZoomComponent,
+ VisualMapComponent
+]);
+
+/**
+ * @description 按需引入echarts,具体看 https://echarts.apache.org/handbook/zh/basics/import/#%E5%9C%A8-typescript-%E4%B8%AD%E6%8C%89%E9%9C%80%E5%BC%95%E5%85%A5
+ * @see 温馨提示:必须将 `$echarts` 添加到全局 `globalProperties` ,具体看 https://pure-admin-utils.netlify.app/hooks/useECharts/useECharts#%E4%BD%BF%E7%94%A8%E5%89%8D%E6%8F%90
+ */
+export function useEcharts(app: App) {
+ app.config.globalProperties.$echarts = echarts;
+}
+
+export default echarts;
diff --git a/src/plugins/elementPlus.ts b/src/plugins/elementPlus.ts
new file mode 100644
index 0000000..8363187
--- /dev/null
+++ b/src/plugins/elementPlus.ts
@@ -0,0 +1,248 @@
+// 按需引入element-plus(该方法稳定且明确。当然也支持:https://element-plus.org/zh-CN/guide/quickstart.html#%E6%8C%89%E9%9C%80%E5%AF%BC%E5%85%A5)
+import type { App, Component } from "vue";
+import {
+ /**
+ * 为了方便演示平台将 element-plus 导出的所有组件引入,实际使用中如果你没用到哪个组件,将其注释掉就行
+ * 导出来源:https://github.com/element-plus/element-plus/blob/dev/packages/element-plus/component.ts#L111-L211
+ * */
+ ElAffix,
+ ElAlert,
+ ElAutocomplete,
+ ElAutoResizer,
+ ElAvatar,
+ ElAnchor,
+ ElAnchorLink,
+ ElBacktop,
+ ElBadge,
+ ElBreadcrumb,
+ ElBreadcrumbItem,
+ ElButton,
+ ElButtonGroup,
+ ElCalendar,
+ ElCard,
+ ElCarousel,
+ ElCarouselItem,
+ ElCascader,
+ ElCascaderPanel,
+ ElCheckTag,
+ ElCheckbox,
+ ElCheckboxButton,
+ ElCheckboxGroup,
+ ElCol,
+ ElCollapse,
+ ElCollapseItem,
+ ElCollapseTransition,
+ ElColorPicker,
+ ElConfigProvider,
+ ElContainer,
+ ElAside,
+ ElFooter,
+ ElHeader,
+ ElMain,
+ ElDatePicker,
+ ElDescriptions,
+ ElDescriptionsItem,
+ ElDialog,
+ ElDivider,
+ ElDrawer,
+ ElDropdown,
+ ElDropdownItem,
+ ElDropdownMenu,
+ ElEmpty,
+ ElForm,
+ ElFormItem,
+ ElIcon,
+ ElImage,
+ ElImageViewer,
+ ElInput,
+ ElInputNumber,
+ ElLink,
+ ElMenu,
+ ElMenuItem,
+ ElMenuItemGroup,
+ ElSubMenu,
+ ElPageHeader,
+ ElPagination,
+ ElPopconfirm,
+ ElPopover,
+ ElPopper,
+ ElProgress,
+ ElRadio,
+ ElRadioButton,
+ ElRadioGroup,
+ ElRate,
+ ElResult,
+ ElRow,
+ ElScrollbar,
+ ElSelect,
+ ElOption,
+ ElOptionGroup,
+ ElSelectV2,
+ ElSkeleton,
+ ElSkeletonItem,
+ ElSlider,
+ ElSpace,
+ ElStatistic,
+ ElCountdown,
+ ElSteps,
+ ElStep,
+ ElSwitch,
+ ElTable,
+ ElTableColumn,
+ ElTableV2,
+ ElTabs,
+ ElTabPane,
+ ElTag,
+ ElText,
+ ElTimePicker,
+ ElTimeSelect,
+ ElTimeline,
+ ElTimelineItem,
+ ElTooltip,
+ ElTransfer,
+ ElTree,
+ ElTreeSelect,
+ ElTreeV2,
+ ElUpload,
+ ElWatermark,
+ ElTour,
+ ElTourStep,
+ ElSegmented,
+ /**
+ * 为了方便演示平台将 element-plus 导出的所有插件引入,实际使用中如果你没用到哪个插件,将其注释掉就行
+ * 导出来源:https://github.com/element-plus/element-plus/blob/dev/packages/element-plus/plugin.ts#L11-L16
+ * */
+ ElLoading, // v-loading 指令
+ ElInfiniteScroll, // v-infinite-scroll 指令
+ ElPopoverDirective, // v-popover 指令
+ ElMessage, // $message 全局属性对象globalProperties
+ ElMessageBox, // $msgbox、$alert、$confirm、$prompt 全局属性对象globalProperties
+ ElNotification // $notify 全局属性对象globalProperties
+} from "element-plus";
+
+const components = [
+ ElAffix,
+ ElAlert,
+ ElAutocomplete,
+ ElAutoResizer,
+ ElAvatar,
+ ElAnchor,
+ ElAnchorLink,
+ ElBacktop,
+ ElBadge,
+ ElBreadcrumb,
+ ElBreadcrumbItem,
+ ElButton,
+ ElButtonGroup,
+ ElCalendar,
+ ElCard,
+ ElCarousel,
+ ElCarouselItem,
+ ElCascader,
+ ElCascaderPanel,
+ ElCheckTag,
+ ElCheckbox,
+ ElCheckboxButton,
+ ElCheckboxGroup,
+ ElCol,
+ ElCollapse,
+ ElCollapseItem,
+ ElCollapseTransition,
+ ElColorPicker,
+ ElConfigProvider,
+ ElContainer,
+ ElAside,
+ ElFooter,
+ ElHeader,
+ ElMain,
+ ElDatePicker,
+ ElDescriptions,
+ ElDescriptionsItem,
+ ElDialog,
+ ElDivider,
+ ElDrawer,
+ ElDropdown,
+ ElDropdownItem,
+ ElDropdownMenu,
+ ElEmpty,
+ ElForm,
+ ElFormItem,
+ ElIcon,
+ ElImage,
+ ElImageViewer,
+ ElInput,
+ ElInputNumber,
+ ElLink,
+ ElMenu,
+ ElMenuItem,
+ ElMenuItemGroup,
+ ElSubMenu,
+ ElPageHeader,
+ ElPagination,
+ ElPopconfirm,
+ ElPopover,
+ ElPopper,
+ ElProgress,
+ ElRadio,
+ ElRadioButton,
+ ElRadioGroup,
+ ElRate,
+ ElResult,
+ ElRow,
+ ElScrollbar,
+ ElSelect,
+ ElOption,
+ ElOptionGroup,
+ ElSelectV2,
+ ElSkeleton,
+ ElSkeletonItem,
+ ElSlider,
+ ElSpace,
+ ElStatistic,
+ ElCountdown,
+ ElSteps,
+ ElStep,
+ ElSwitch,
+ ElTable,
+ ElTableColumn,
+ ElTableV2,
+ ElTabs,
+ ElTabPane,
+ ElTag,
+ ElText,
+ ElTimePicker,
+ ElTimeSelect,
+ ElTimeline,
+ ElTimelineItem,
+ ElTooltip,
+ ElTransfer,
+ ElTree,
+ ElTreeSelect,
+ ElTreeV2,
+ ElUpload,
+ ElWatermark,
+ ElTour,
+ ElTourStep,
+ ElSegmented
+];
+
+const plugins = [
+ ElLoading,
+ ElInfiniteScroll,
+ ElPopoverDirective,
+ ElMessage,
+ ElMessageBox,
+ ElNotification
+];
+
+/** 按需引入`element-plus` */
+export function useElementPlus(app: App) {
+ // 全局注册组件
+ components.forEach((component: Component) => {
+ app.component(component.name, component);
+ });
+ // 全局注册插件
+ plugins.forEach(plugin => {
+ app.use(plugin);
+ });
+}
diff --git a/src/plugins/i18n.ts b/src/plugins/i18n.ts
new file mode 100644
index 0000000..20b77ea
--- /dev/null
+++ b/src/plugins/i18n.ts
@@ -0,0 +1,24 @@
+// 多组件库的国际化和本地项目国际化兼容
+import { createI18n } from "vue-i18n";
+import type { App } from "vue";
+
+// ? 从本地存储中获取数据
+const languageData = localStorage.getItem("i18nStore");
+
+// 配置多语言
+export const i18n = createI18n({
+ // 如果要支持 compositionAPI,此项必须设置为 false
+ legacy: false,
+ // locale: 'zh',
+ fallbackLocale: "en",
+ // ? 全局注册$t方法
+ globalInjection: true,
+ // 本地内容存在时,首次加载如果本地存储没有多语言需要再刷新
+ messages: languageData ? JSON.parse(languageData).i18n : {}
+});
+
+export const $t: any = (i18n.global as any).t as any;
+
+export function useI18n(app: App) {
+ app.use(i18n);
+}
diff --git a/src/router/index.ts b/src/router/index.ts
new file mode 100644
index 0000000..cbdd631
--- /dev/null
+++ b/src/router/index.ts
@@ -0,0 +1,209 @@
+// import "@/utils/sso";
+import Cookies from "js-cookie";
+import { getConfig } from "@/config";
+import NProgress from "@/utils/progress";
+import { $t } from "@/plugins/i18n";
+import { buildHierarchyTree } from "@/utils/tree";
+import remainingRouter from "./modules/remaining";
+import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
+import { usePermissionStoreHook } from "@/store/modules/permission";
+import { isAllEmpty, isUrl, openLink, storageLocal } from "@pureadmin/utils";
+import {
+ ascending,
+ findRouteByPath,
+ formatFlatteningRoutes,
+ formatTwoStageRoutes,
+ getHistoryMode,
+ getTopMenu,
+ handleAliveRoute,
+ initRouter,
+ isOneOfArray
+} from "./utils";
+import {
+ createRouter,
+ type RouteComponent,
+ type Router,
+ type RouteRecordRaw
+} from "vue-router";
+import {
+ type DataInfo,
+ multipleTabsKey,
+ removeToken,
+ userKey
+} from "@/utils/auth";
+
+/** 自动导入全部静态路由,无需再手动引入!匹配 src/router/modules 目录(任何嵌套级别)中具有 .ts 扩展名的所有文件,除了 remaining.ts 文件
+ * 如何匹配所有文件请看:https://github.com/mrmlnc/fast-glob#basic-syntax
+ * 如何排除文件请看:https://cn.vitejs.dev/guide/features.html#negative-patterns
+ */
+const modules: Record = import.meta.glob(
+ ["./modules/**/*.ts", "!./modules/**/remaining.ts"],
+ {
+ eager: true
+ }
+);
+
+/** 原始静态路由(未做任何处理) */
+const routes = [];
+
+Object.keys(modules).forEach(key => {
+ routes.push(modules[key].default);
+});
+
+/** 导出处理后的静态路由(三级及以上的路由全部拍成二级) */
+export const constantRoutes: Array = formatTwoStageRoutes(
+ formatFlatteningRoutes(buildHierarchyTree(ascending(routes.flat(Infinity))))
+);
+
+/** 用于渲染菜单,保持原始层级 */
+export const constantMenus: Array = ascending(
+ routes.flat(Infinity)
+).concat(...remainingRouter);
+
+/** 不参与菜单的路由 */
+export const remainingPaths = Object.keys(remainingRouter).map(v => {
+ return remainingRouter[v].path;
+});
+
+/** 创建路由实例 */
+export const router: Router = createRouter({
+ history: getHistoryMode(import.meta.env.VITE_ROUTER_HISTORY),
+ routes: constantRoutes.concat(...(remainingRouter as any)),
+ strict: true,
+ scrollBehavior(to, from, savedPosition) {
+ return new Promise(resolve => {
+ if (savedPosition) {
+ return savedPosition;
+ } else {
+ if (from.meta.saveSrollTop) {
+ const top: number =
+ document.documentElement.scrollTop || document.body.scrollTop;
+ resolve({ left: 0, top });
+ }
+ }
+ });
+ }
+});
+
+/** 重置路由 */
+export function resetRouter() {
+ router.getRoutes().forEach(route => {
+ const { name, meta } = route;
+ if (name && router.hasRoute(name) && meta?.backstage) {
+ router.removeRoute(name);
+ router.options.routes = formatTwoStageRoutes(
+ formatFlatteningRoutes(
+ buildHierarchyTree(ascending(routes.flat(Infinity)))
+ )
+ );
+ }
+ });
+ usePermissionStoreHook().clearAllCachePage();
+}
+
+/** 路由白名单 */
+const whiteList = ["/login"];
+
+const { VITE_HIDE_HOME } = import.meta.env;
+
+router.beforeEach((to: ToRouteType, _from, next) => {
+ if (to.meta?.keepAlive) {
+ handleAliveRoute(to, "add");
+ // 页面整体刷新和点击标签页刷新
+ if (_from.name === undefined || _from.name === "Redirect") {
+ handleAliveRoute(to);
+ }
+ }
+ const userInfo = storageLocal().getItem>(userKey);
+ NProgress.start();
+ const externalLink = isUrl(to?.name as string);
+ if (!externalLink) {
+ to.matched.some(item => {
+ if (!item.meta.title) return "";
+ const Title = getConfig().Title;
+ if (Title) document.title = `${$t(item.meta.title)} | ${Title}`;
+ else document.title = $t(item.meta.title);
+ });
+ }
+
+ /** 如果已经登录并存在登录信息后不能跳转到路由白名单,而是继续保持在当前页面 */
+ function toCorrectRoute() {
+ whiteList.includes(to.fullPath) ? next(_from.fullPath) : next();
+ }
+
+ if (Cookies.get(multipleTabsKey) && userInfo) {
+ // 无权限跳转403页面
+ if (to.meta?.roles && !isOneOfArray(to.meta?.roles, userInfo?.roles)) {
+ next({ path: "/error/403" });
+ }
+ // 开启隐藏首页后在浏览器地址栏手动输入首页welcome路由则跳转到404页面
+ if (VITE_HIDE_HOME === "true" && to.fullPath === "/welcome") {
+ next({ path: "/error/404" });
+ }
+ if (_from?.name) {
+ // name为超链接
+ if (externalLink) {
+ openLink(to?.name as string);
+ NProgress.done();
+ } else {
+ toCorrectRoute();
+ }
+ } else {
+ // 刷新
+ if (
+ usePermissionStoreHook().wholeMenus.length === 0 &&
+ to.path !== "/login"
+ ) {
+ initRouter().then((router: Router) => {
+ if (!useMultiTagsStoreHook().getMultiTagsCache) {
+ const { path } = to;
+ const route = findRouteByPath(
+ path,
+ router.options.routes[0].children
+ );
+ getTopMenu(true);
+ // query、params模式路由传参数的标签页不在此处处理
+ if (route && route.meta?.title) {
+ if (isAllEmpty(route.parentId) && route.meta?.backstage) {
+ // 此处为动态顶级路由(目录)
+ const { path, name, meta } = route.children[0];
+ useMultiTagsStoreHook().handleTags("push", {
+ path,
+ name,
+ meta
+ });
+ } else {
+ const { path, name, meta } = route;
+ useMultiTagsStoreHook().handleTags("push", {
+ path,
+ name,
+ meta
+ });
+ }
+ }
+ }
+ // 确保动态路由完全加入路由列表并且不影响静态路由(注意:动态路由刷新时router.beforeEach可能会触发两次,第一次触发动态路由还未完全添加,第二次动态路由才完全添加到路由列表,如果需要在router.beforeEach做一些判断可以在to.name存在的条件下去判断,这样就只会触发一次)
+ if (isAllEmpty(to.name)) router.push(to.fullPath);
+ });
+ }
+ toCorrectRoute();
+ }
+ } else {
+ if (to.path !== "/login") {
+ if (whiteList.indexOf(to.path) !== -1) {
+ next();
+ } else {
+ removeToken();
+ next({ path: "/login" });
+ }
+ } else {
+ next();
+ }
+ }
+});
+
+router.afterEach(() => {
+ NProgress.done();
+});
+
+export default router;
diff --git a/src/router/modules/error.ts b/src/router/modules/error.ts
new file mode 100644
index 0000000..6dbd36d
--- /dev/null
+++ b/src/router/modules/error.ts
@@ -0,0 +1,38 @@
+import { $t } from "@/plugins/i18n";
+
+export default {
+ path: "/error",
+ redirect: "/error/403",
+ meta: {
+ icon: "ri:information-line",
+ showLink: false,
+ title: "menus.pureAbnormal",
+ rank: 9
+ },
+ children: [
+ {
+ path: "/error/403",
+ name: "403",
+ component: () => import("@/views/error/403.vue"),
+ meta: {
+ title: $t("menus.pureFourZeroOne")
+ }
+ },
+ {
+ path: "/error/404",
+ name: "404",
+ component: () => import("@/views/error/404.vue"),
+ meta: {
+ title: $t("menus.pureFourZeroFour")
+ }
+ },
+ {
+ path: "/error/500",
+ name: "500",
+ component: () => import("@/views/error/500.vue"),
+ meta: {
+ title: $t("menus.pureFive")
+ }
+ }
+ ]
+} satisfies RouteConfigsTable;
diff --git a/src/router/modules/home.ts b/src/router/modules/home.ts
new file mode 100644
index 0000000..c44f1e2
--- /dev/null
+++ b/src/router/modules/home.ts
@@ -0,0 +1,25 @@
+const { VITE_HIDE_HOME } = import.meta.env;
+const Layout = () => import("@/layout/index.vue");
+
+export default {
+ path: "/",
+ name: "Home",
+ component: Layout,
+ redirect: "/welcome",
+ meta: {
+ icon: "ep:home-filled",
+ title: "menus.home",
+ rank: 0
+ },
+ children: [
+ {
+ path: "/welcome",
+ name: "Welcome",
+ component: () => import("@/views/welcome/index.vue"),
+ meta: {
+ title: "menus.home",
+ showLink: VITE_HIDE_HOME !== "true"
+ }
+ }
+ ]
+} satisfies RouteConfigsTable;
diff --git a/src/router/modules/remaining.ts b/src/router/modules/remaining.ts
new file mode 100644
index 0000000..11f2272
--- /dev/null
+++ b/src/router/modules/remaining.ts
@@ -0,0 +1,32 @@
+import { $t } from "@/plugins/i18n";
+
+const Layout = () => import("@/layout/index.vue");
+
+export default [
+ {
+ path: "/login",
+ name: "Login",
+ component: () => import("@/views/login/index.vue"),
+ meta: {
+ title: "menus.pureLogin",
+ showLink: false,
+ rank: 101
+ }
+ },
+ {
+ path: "/redirect",
+ component: Layout,
+ meta: {
+ title: $t("status.pureLoad"),
+ showLink: false,
+ rank: 102
+ },
+ children: [
+ {
+ path: "/redirect/:path(.*)",
+ name: "Redirect",
+ component: () => import("@/layout/redirect.vue")
+ }
+ ]
+ }
+] satisfies Array;
diff --git a/src/router/utils.ts b/src/router/utils.ts
new file mode 100644
index 0000000..435b0d3
--- /dev/null
+++ b/src/router/utils.ts
@@ -0,0 +1,408 @@
+import {
+ createWebHashHistory,
+ createWebHistory,
+ type RouteComponent,
+ type RouteRecordRaw,
+ type RouterHistory
+} from "vue-router";
+import { router } from "./index";
+import { isProxy, toRaw } from "vue";
+import { useTimeoutFn } from "@vueuse/core";
+import {
+ cloneDeep,
+ intersection,
+ isAllEmpty,
+ isIncludeAllChildren,
+ isString,
+ storageLocal
+} from "@pureadmin/utils";
+import { getConfig } from "@/config";
+import { buildHierarchyTree } from "@/utils/tree";
+import { type DataInfo, userKey } from "@/utils/auth";
+import { type menuType, routerArrays } from "@/layout/types";
+import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
+import { usePermissionStoreHook } from "@/store/modules/permission";
+// 动态路由
+import { getAsyncRoutes } from "@/api/v1/routes";
+
+const IFrame = () => import("@/layout/frame.vue");
+// https://cn.vitejs.dev/guide/features.html#glob-import
+const modulesRoutes = import.meta.glob("/src/views/**/*.{vue,tsx}");
+
+function handRank(routeInfo: any) {
+ const { name, path, parentId, meta } = routeInfo;
+ return isAllEmpty(parentId)
+ ? isAllEmpty(meta?.rank) ||
+ (meta?.rank === 0 && name !== "Home" && path !== "/")
+ ? true
+ : false
+ : false;
+}
+
+/** 按照路由中meta下的rank等级升序来排序路由 */
+function ascending(arr: any[]) {
+ arr.forEach((v, index) => {
+ // 当rank不存在时,根据顺序自动创建,首页路由永远在第一位
+ if (handRank(v)) v.meta.rank = index + 2;
+ });
+ return arr.sort(
+ (a: { meta: { rank: number } }, b: { meta: { rank: number } }) => {
+ return a?.meta.rank - b?.meta.rank;
+ }
+ );
+}
+
+/** 过滤meta中showLink为false的菜单 */
+function filterTree(data: RouteComponent[]) {
+ const newTree = cloneDeep(data).filter(
+ (v: { meta: { showLink: boolean } }) => v.meta?.showLink !== false
+ );
+ newTree.forEach(
+ (v: { children }) => v.children && (v.children = filterTree(v.children))
+ );
+ return newTree;
+}
+
+/** 过滤children长度为0的的目录,当目录下没有菜单时,会过滤此目录,目录没有赋予roles权限,当目录下只要有一个菜单有显示权限,那么此目录就会显示 */
+function filterChildrenTree(data: RouteComponent[]) {
+ const newTree = cloneDeep(data).filter((v: any) => v?.children?.length !== 0);
+ newTree.forEach(
+ (v: { children }) => v.children && (v.children = filterTree(v.children))
+ );
+ return newTree;
+}
+
+/** 判断两个数组彼此是否存在相同值 */
+function isOneOfArray(a: Array, b: Array) {
+ return Array.isArray(a) && Array.isArray(b)
+ ? intersection(a, b).length > 0
+ ? true
+ : false
+ : true;
+}
+
+/** 从localStorage里取出当前登录用户的角色roles,过滤无权限的菜单 */
+function filterNoPermissionTree(data: RouteComponent[]) {
+ const currentRoles =
+ storageLocal().getItem>(userKey)?.roles ?? [];
+ const newTree = cloneDeep(data).filter((v: any) =>
+ isOneOfArray(v.meta?.roles, currentRoles)
+ );
+ newTree.forEach(
+ (v: any) => v.children && (v.children = filterNoPermissionTree(v.children))
+ );
+ return filterChildrenTree(newTree);
+}
+
+/** 通过指定 `key` 获取父级路径集合,默认 `key` 为 `path` */
+function getParentPaths(value: string, routes: RouteRecordRaw[], key = "path") {
+ // 深度遍历查找
+ function dfs(routes: RouteRecordRaw[], value: string, parents: string[]) {
+ for (let i = 0; i < routes.length; i++) {
+ const item = routes[i];
+ // 返回父级path
+ if (item[key] === value) return parents;
+ // children不存在或为空则不递归
+ if (!item.children || !item.children.length) continue;
+ // 往下查找时将当前path入栈
+ parents.push(item.path);
+
+ if (dfs(item.children, value, parents).length) return parents;
+ // 深度遍历查找未找到时当前path 出栈
+ parents.pop();
+ }
+ // 未找到时返回空数组
+ return [];
+ }
+
+ return dfs(routes, value, []);
+}
+
+/** 查找对应 `path` 的路由信息 */
+function findRouteByPath(path: string, routes: RouteRecordRaw[]) {
+ let res = routes.find((item: { path: string }) => item.path == path);
+ if (res) {
+ return isProxy(res) ? toRaw(res) : res;
+ } else {
+ for (let i = 0; i < routes.length; i++) {
+ if (
+ routes[i].children instanceof Array &&
+ routes[i].children.length > 0
+ ) {
+ res = findRouteByPath(path, routes[i].children);
+ if (res) {
+ return isProxy(res) ? toRaw(res) : res;
+ }
+ }
+ }
+ return null;
+ }
+}
+
+function addPathMatch() {
+ if (!router.hasRoute("pathMatch")) {
+ router.addRoute({
+ path: "/:pathMatch(.*)",
+ name: "pathMatch",
+ redirect: "/error/404"
+ });
+ }
+}
+
+/** 处理动态路由(后端返回的路由) */
+function handleAsyncRoutes(routeList) {
+ if (routeList.length === 0) {
+ usePermissionStoreHook().handleWholeMenus(routeList);
+ } else {
+ formatFlatteningRoutes(addAsyncRoutes(routeList)).map(
+ (v: RouteRecordRaw) => {
+ // 防止重复添加路由
+ if (
+ router.options.routes[0].children.findIndex(
+ value => value.path === v.path
+ ) !== -1
+ ) {
+ return;
+ } else {
+ // 切记将路由push到routes后还需要使用addRoute,这样路由才能正常跳转
+ router.options.routes[0].children.push(v);
+ // 最终路由进行升序
+ ascending(router.options.routes[0].children);
+ if (!router.hasRoute(v?.name)) router.addRoute(v);
+ const flattenRouters: any = router
+ .getRoutes()
+ .find(n => n.path === "/");
+ router.addRoute(flattenRouters);
+ }
+ }
+ );
+ usePermissionStoreHook().handleWholeMenus(routeList);
+ }
+ if (!useMultiTagsStoreHook().getMultiTagsCache) {
+ useMultiTagsStoreHook().handleTags("equal", [
+ ...routerArrays,
+ ...usePermissionStoreHook().flatteningRoutes.filter(
+ v => v?.meta?.fixedTag
+ )
+ ]);
+ }
+ addPathMatch();
+}
+
+/** 初始化路由(`new Promise` 写法防止在异步请求中造成无限循环)*/
+function initRouter() {
+ if (getConfig()?.CachingAsyncRoutes) {
+ // 开启动态路由缓存本地localStorage
+ const key = "async-routes";
+ const asyncRouteList = storageLocal().getItem(key) as any;
+ if (asyncRouteList && asyncRouteList?.length > 0) {
+ return new Promise(resolve => {
+ handleAsyncRoutes(asyncRouteList);
+ resolve(router);
+ });
+ } else {
+ return new Promise(resolve => {
+ getAsyncRoutes().then(({ data }) => {
+ handleAsyncRoutes(cloneDeep(data));
+ storageLocal().setItem(key, data);
+ resolve(router);
+ });
+ });
+ }
+ } else {
+ return new Promise(resolve => {
+ getAsyncRoutes().then(({ data }) => {
+ handleAsyncRoutes(cloneDeep(data));
+ resolve(router);
+ });
+ });
+ }
+}
+
+/**
+ * 将多级嵌套路由处理成一维数组
+ * @param routesList 传入路由
+ * @returns 返回处理后的一维路由
+ */
+function formatFlatteningRoutes(routesList: RouteRecordRaw[]) {
+ if (routesList.length === 0) return routesList;
+ let hierarchyList = buildHierarchyTree(routesList);
+ for (let i = 0; i < hierarchyList.length; i++) {
+ if (hierarchyList[i].children) {
+ hierarchyList = hierarchyList
+ .slice(0, i + 1)
+ .concat(hierarchyList[i].children, hierarchyList.slice(i + 1));
+ }
+ }
+ return hierarchyList;
+}
+
+/**
+ * 一维数组处理成多级嵌套数组(三级及以上的路由全部拍成二级,keep-alive 只支持到二级缓存)
+ * https://github.com/pure-admin/vue-pure-admin/issues/67
+ * @param routesList 处理后的一维路由菜单数组
+ * @returns 返回将一维数组重新处理成规定路由的格式
+ */
+function formatTwoStageRoutes(routesList: RouteRecordRaw[]) {
+ if (routesList.length === 0) return routesList;
+ const newRoutesList: RouteRecordRaw[] = [];
+ routesList.forEach((v: RouteRecordRaw) => {
+ if (v.path === "/") {
+ newRoutesList.push({
+ component: v.component,
+ name: v.name,
+ path: v.path,
+ redirect: v.redirect,
+ meta: v.meta,
+ children: []
+ });
+ } else {
+ newRoutesList[0]?.children.push({ ...v });
+ }
+ });
+ return newRoutesList;
+}
+
+/** 处理缓存路由(添加、删除、刷新) */
+function handleAliveRoute({ name }: ToRouteType, mode?: string) {
+ switch (mode) {
+ case "add":
+ usePermissionStoreHook().cacheOperate({
+ mode: "add",
+ name
+ });
+ break;
+ case "delete":
+ usePermissionStoreHook().cacheOperate({
+ mode: "delete",
+ name
+ });
+ break;
+ case "refresh":
+ usePermissionStoreHook().cacheOperate({
+ mode: "refresh",
+ name
+ });
+ break;
+ default:
+ usePermissionStoreHook().cacheOperate({
+ mode: "delete",
+ name
+ });
+ useTimeoutFn(() => {
+ usePermissionStoreHook().cacheOperate({
+ mode: "add",
+ name
+ });
+ }, 100);
+ }
+}
+
+/** 过滤后端传来的动态路由 重新生成规范路由 */
+function addAsyncRoutes(arrRoutes: Array) {
+ if (!arrRoutes || !arrRoutes.length) return;
+ const modulesRoutesKeys = Object.keys(modulesRoutes);
+ arrRoutes.forEach((v: RouteRecordRaw) => {
+ // 将backstage属性加入meta,标识此路由为后端返回路由
+ v.meta.backstage = true;
+ // 父级的redirect属性取值:如果子级存在且父级的redirect属性不存在,默认取第一个子级的path;如果子级存在且父级的redirect属性存在,取存在的redirect属性,会覆盖默认值
+ if (v?.children && v.children.length && !v.redirect)
+ v.redirect = v.children[0].path;
+ // 父级的name属性取值:如果子级存在且父级的name属性不存在,默认取第一个子级的name;如果子级存在且父级的name属性存在,取存在的name属性,会覆盖默认值(注意:测试中发现父级的name不能和子级name重复,如果重复会造成重定向无效(跳转404),所以这里给父级的name起名的时候后面会自动加上`Parent`,避免重复)
+ if (v?.children && v.children.length && !v.name)
+ v.name = (v.children[0].name as string) + "Parent";
+ if (v.meta?.frameSrc) {
+ v.component = IFrame;
+ } else {
+ // 对后端传component组件路径和不传做兼容(如果后端传component组件路径,那么path可以随便写,如果不传,component组件路径会跟path保持一致)
+ const index = v?.component
+ ? modulesRoutesKeys.findIndex(ev => ev.includes(v.component as any))
+ : modulesRoutesKeys.findIndex(ev => ev.includes(v.path));
+ v.component = modulesRoutes[modulesRoutesKeys[index]];
+ }
+ if (v?.children && v.children.length) {
+ addAsyncRoutes(v.children);
+ }
+ });
+ return arrRoutes;
+}
+
+/** 获取路由历史模式 https://next.router.vuejs.org/zh/guide/essentials/history-mode.html */
+function getHistoryMode(routerHistory): RouterHistory {
+ // len为1 代表只有历史模式 为2 代表历史模式中存在base参数 https://next.router.vuejs.org/zh/api/#%E5%8F%82%E6%95%B0-1
+ const historyMode = routerHistory.split(",");
+ const leftMode = historyMode[0];
+ const rightMode = historyMode[1];
+ // no param
+ if (historyMode.length === 1) {
+ if (leftMode === "hash") {
+ return createWebHashHistory("");
+ } else if (leftMode === "h5") {
+ return createWebHistory("");
+ }
+ } //has param
+ else if (historyMode.length === 2) {
+ if (leftMode === "hash") {
+ return createWebHashHistory(rightMode);
+ } else if (leftMode === "h5") {
+ return createWebHistory(rightMode);
+ }
+ }
+}
+
+/** 获取当前页面按钮级别的权限 */
+function getAuths(): Array {
+ return router.currentRoute.value.meta.auths as Array;
+}
+
+/** 是否有按钮级别的权限(根据路由`meta`中的`auths`字段进行判断)*/
+function hasAuth(value: string | Array): boolean {
+ if (!value) return false;
+ /** 从当前路由的`meta`字段里获取按钮级别的所有自定义`code`值 */
+ const metaAuths = getAuths();
+ if (!metaAuths) return false;
+ const isAuths = isString(value)
+ ? metaAuths.includes(value)
+ : isIncludeAllChildren(value, metaAuths);
+ return isAuths ? true : false;
+}
+
+function handleTopMenu(route) {
+ if (route?.children && route.children.length > 1) {
+ if (route.redirect) {
+ return route.children.filter(cur => cur.path === route.redirect)[0];
+ } else {
+ return route.children[0];
+ }
+ } else {
+ return route;
+ }
+}
+
+/** 获取所有菜单中的第一个菜单(顶级菜单)*/
+function getTopMenu(tag = false): menuType {
+ const topMenu = handleTopMenu(
+ usePermissionStoreHook().wholeMenus[0]?.children[0]
+ );
+ tag && useMultiTagsStoreHook().handleTags("push", topMenu);
+ return topMenu;
+}
+
+export {
+ hasAuth,
+ getAuths,
+ ascending,
+ filterTree,
+ initRouter,
+ getTopMenu,
+ addPathMatch,
+ isOneOfArray,
+ getHistoryMode,
+ addAsyncRoutes,
+ getParentPaths,
+ findRouteByPath,
+ handleAliveRoute,
+ formatTwoStageRoutes,
+ formatFlatteningRoutes,
+ filterNoPermissionTree
+};
diff --git a/src/store/i18n/i18n.ts b/src/store/i18n/i18n.ts
new file mode 100644
index 0000000..c6b99c1
--- /dev/null
+++ b/src/store/i18n/i18n.ts
@@ -0,0 +1,34 @@
+// import { fetchGetI18n } from '@/api/mock/i18n';
+import { defineStore } from "pinia";
+import { fetchGetI18n } from "@/api/v1/i18n";
+import type { I18nState } from "@/types/store/i18n";
+
+export const userI18nStore = defineStore("i18nStore", {
+ persist: true,
+ state(): I18nState {
+ return {
+ // ? 多语言内容
+ i18n: {}
+ };
+ },
+ getters: {},
+ actions: {
+ /**
+ * * 获取多语言
+ */
+ async fetchI18n() {
+ const result = await fetchGetI18n();
+
+ if (result.code === 200) {
+ localStorage.removeItem("i18nStore");
+ // 当前的返回参数
+ const data = result.data;
+ // 将返回对象中key设置name,后端不好设置
+ for (let key in data) if (key !== "local") data[key].name = key;
+
+ // 赋值返回参数
+ this.i18n = data;
+ }
+ }
+ }
+});
diff --git a/src/store/index.ts b/src/store/index.ts
new file mode 100644
index 0000000..08091b6
--- /dev/null
+++ b/src/store/index.ts
@@ -0,0 +1,12 @@
+import type { App } from "vue";
+import { createPinia } from "pinia";
+import piniaPluginPersistedState from "pinia-plugin-persistedstate";
+
+const store = createPinia();
+
+export function setupStore(app: App) {
+ store.use(piniaPluginPersistedState);
+ app.use(store);
+}
+
+export { store };
diff --git a/src/store/modules/app.ts b/src/store/modules/app.ts
new file mode 100644
index 0000000..2644aef
--- /dev/null
+++ b/src/store/modules/app.ts
@@ -0,0 +1,89 @@
+import { defineStore } from "pinia";
+import {
+ type appType,
+ store,
+ getConfig,
+ storageLocal,
+ deviceDetection,
+ responsiveStorageNameSpace
+} from "../utils";
+
+export const useAppStore = defineStore({
+ id: "pure-app",
+ state: (): appType => ({
+ sidebar: {
+ opened:
+ storageLocal().getItem(
+ `${responsiveStorageNameSpace()}layout`
+ )?.sidebarStatus ?? getConfig().SidebarStatus,
+ withoutAnimation: false,
+ isClickCollapse: false
+ },
+ // 这里的layout用于监听容器拖拉后恢复对应的导航模式
+ layout:
+ storageLocal().getItem(
+ `${responsiveStorageNameSpace()}layout`
+ )?.layout ?? getConfig().Layout,
+ device: deviceDetection() ? "mobile" : "desktop",
+ // 浏览器窗口的可视区域大小
+ viewportSize: {
+ width: document.documentElement.clientWidth,
+ height: document.documentElement.clientHeight
+ }
+ }),
+ getters: {
+ getSidebarStatus(state) {
+ return state.sidebar.opened;
+ },
+ getDevice(state) {
+ return state.device;
+ },
+ getViewportWidth(state) {
+ return state.viewportSize.width;
+ },
+ getViewportHeight(state) {
+ return state.viewportSize.height;
+ }
+ },
+ actions: {
+ TOGGLE_SIDEBAR(opened?: boolean, resize?: string) {
+ const layout = storageLocal().getItem(
+ `${responsiveStorageNameSpace()}layout`
+ );
+ if (opened && resize) {
+ this.sidebar.withoutAnimation = true;
+ this.sidebar.opened = true;
+ layout.sidebarStatus = true;
+ } else if (!opened && resize) {
+ this.sidebar.withoutAnimation = true;
+ this.sidebar.opened = false;
+ layout.sidebarStatus = false;
+ } else if (!opened && !resize) {
+ this.sidebar.withoutAnimation = false;
+ this.sidebar.opened = !this.sidebar.opened;
+ this.sidebar.isClickCollapse = !this.sidebar.opened;
+ layout.sidebarStatus = this.sidebar.opened;
+ }
+ storageLocal().setItem(`${responsiveStorageNameSpace()}layout`, layout);
+ },
+ async toggleSideBar(opened?: boolean, resize?: string) {
+ await this.TOGGLE_SIDEBAR(opened, resize);
+ },
+ toggleDevice(device: string) {
+ this.device = device;
+ },
+ setLayout(layout) {
+ this.layout = layout;
+ },
+ setViewportSize(size) {
+ this.viewportSize = size;
+ },
+ setSortSwap(val) {
+ this.sortSwap = val;
+ }
+ }
+});
+
+export function useAppStoreHook() {
+ return useAppStore(store);
+}
diff --git a/src/store/modules/epTheme.ts b/src/store/modules/epTheme.ts
new file mode 100644
index 0000000..fa73eff
--- /dev/null
+++ b/src/store/modules/epTheme.ts
@@ -0,0 +1,50 @@
+import { defineStore } from "pinia";
+import {
+ store,
+ getConfig,
+ storageLocal,
+ responsiveStorageNameSpace
+} from "../utils";
+
+export const useEpThemeStore = defineStore({
+ id: "pure-epTheme",
+ state: () => ({
+ epThemeColor:
+ storageLocal().getItem(
+ `${responsiveStorageNameSpace()}layout`
+ )?.epThemeColor ?? getConfig().EpThemeColor,
+ epTheme:
+ storageLocal().getItem(
+ `${responsiveStorageNameSpace()}layout`
+ )?.theme ?? getConfig().Theme
+ }),
+ getters: {
+ getEpThemeColor(state) {
+ return state.epThemeColor;
+ },
+ /** 用于mix导航模式下hamburger-svg的fill属性 */
+ fill(state) {
+ if (state.epTheme === "light") {
+ return "#409eff";
+ } else {
+ return "#fff";
+ }
+ }
+ },
+ actions: {
+ setEpThemeColor(newColor: string): void {
+ const layout = storageLocal().getItem(
+ `${responsiveStorageNameSpace()}layout`
+ );
+ this.epTheme = layout?.theme;
+ this.epThemeColor = newColor;
+ if (!layout) return;
+ layout.epThemeColor = newColor;
+ storageLocal().setItem(`${responsiveStorageNameSpace()}layout`, layout);
+ }
+ }
+});
+
+export function useEpThemeStoreHook() {
+ return useEpThemeStore(store);
+}
diff --git a/src/store/modules/multiTags.ts b/src/store/modules/multiTags.ts
new file mode 100644
index 0000000..fee2234
--- /dev/null
+++ b/src/store/modules/multiTags.ts
@@ -0,0 +1,146 @@
+import { defineStore } from "pinia";
+import {
+ type multiType,
+ type positionType,
+ store,
+ isUrl,
+ isEqual,
+ isNumber,
+ isBoolean,
+ getConfig,
+ routerArrays,
+ storageLocal,
+ responsiveStorageNameSpace
+} from "../utils";
+import { usePermissionStoreHook } from "./permission";
+
+export const useMultiTagsStore = defineStore({
+ id: "pure-multiTags",
+ state: () => ({
+ // 存储标签页信息(路由信息)
+ multiTags: storageLocal().getItem(
+ `${responsiveStorageNameSpace()}configure`
+ )?.multiTagsCache
+ ? storageLocal().getItem(
+ `${responsiveStorageNameSpace()}tags`
+ )
+ : [
+ ...routerArrays,
+ ...usePermissionStoreHook().flatteningRoutes.filter(
+ v => v?.meta?.fixedTag
+ )
+ ],
+ multiTagsCache: storageLocal().getItem(
+ `${responsiveStorageNameSpace()}configure`
+ )?.multiTagsCache
+ }),
+ getters: {
+ getMultiTagsCache(state) {
+ return state.multiTagsCache;
+ }
+ },
+ actions: {
+ multiTagsCacheChange(multiTagsCache: boolean) {
+ this.multiTagsCache = multiTagsCache;
+ if (multiTagsCache) {
+ storageLocal().setItem(
+ `${responsiveStorageNameSpace()}tags`,
+ this.multiTags
+ );
+ } else {
+ storageLocal().removeItem(`${responsiveStorageNameSpace()}tags`);
+ }
+ },
+ tagsCache(multiTags) {
+ this.getMultiTagsCache &&
+ storageLocal().setItem(
+ `${responsiveStorageNameSpace()}tags`,
+ multiTags
+ );
+ },
+ handleTags(
+ mode: string,
+ value?: T | multiType,
+ position?: positionType
+ ): T {
+ switch (mode) {
+ case "equal":
+ this.multiTags = value;
+ this.tagsCache(this.multiTags);
+ break;
+ case "push":
+ {
+ const tagVal = value as multiType;
+ // 不添加到标签页
+ if (tagVal?.meta?.hiddenTag) return;
+ // 如果是外链无需添加信息到标签页
+ if (isUrl(tagVal?.name)) return;
+ // 如果title为空拒绝添加空信息到标签页
+ if (tagVal?.meta?.title.length === 0) return;
+ // showLink:false 不添加到标签页
+ if (isBoolean(tagVal?.meta?.showLink) && !tagVal?.meta?.showLink)
+ return;
+ const tagPath = tagVal.path;
+ // 判断tag是否已存在
+ const tagHasExits = this.multiTags.some(tag => {
+ return tag.path === tagPath;
+ });
+
+ // 判断tag中的query键值是否相等
+ const tagQueryHasExits = this.multiTags.some(tag => {
+ return isEqual(tag?.query, tagVal?.query);
+ });
+
+ // 判断tag中的params键值是否相等
+ const tagParamsHasExits = this.multiTags.some(tag => {
+ return isEqual(tag?.params, tagVal?.params);
+ });
+
+ if (tagHasExits && tagQueryHasExits && tagParamsHasExits) return;
+
+ // 动态路由可打开的最大数量
+ const dynamicLevel = tagVal?.meta?.dynamicLevel ?? -1;
+ if (dynamicLevel > 0) {
+ if (
+ this.multiTags.filter(e => e?.path === tagPath).length >=
+ dynamicLevel
+ ) {
+ // 如果当前已打开的动态路由数大于dynamicLevel,替换第一个动态路由标签
+ const index = this.multiTags.findIndex(
+ item => item?.path === tagPath
+ );
+ index !== -1 && this.multiTags.splice(index, 1);
+ }
+ }
+ this.multiTags.push(value);
+ this.tagsCache(this.multiTags);
+ if (
+ getConfig()?.MaxTagsLevel &&
+ isNumber(getConfig().MaxTagsLevel)
+ ) {
+ if (this.multiTags.length > getConfig().MaxTagsLevel) {
+ this.multiTags.splice(1, 1);
+ }
+ }
+ }
+ break;
+ case "splice":
+ if (!position) {
+ const index = this.multiTags.findIndex(v => v.path === value);
+ if (index === -1) return;
+ this.multiTags.splice(index, 1);
+ } else {
+ this.multiTags.splice(position?.startIndex, position?.length);
+ }
+ this.tagsCache(this.multiTags);
+ return this.multiTags;
+ case "slice":
+ return this.multiTags.slice(-1);
+ }
+ }
+ }
+});
+
+export function useMultiTagsStoreHook() {
+ return useMultiTagsStore(store);
+}
diff --git a/src/store/modules/permission.ts b/src/store/modules/permission.ts
new file mode 100644
index 0000000..2ddca78
--- /dev/null
+++ b/src/store/modules/permission.ts
@@ -0,0 +1,75 @@
+import { defineStore } from "pinia";
+import {
+ type cacheType,
+ store,
+ debounce,
+ ascending,
+ getKeyList,
+ filterTree,
+ constantMenus,
+ filterNoPermissionTree,
+ formatFlatteningRoutes
+} from "../utils";
+import { useMultiTagsStoreHook } from "./multiTags";
+
+export const usePermissionStore = defineStore({
+ id: "pure-permission",
+ state: () => ({
+ // 静态路由生成的菜单
+ constantMenus,
+ // 整体路由生成的菜单(静态、动态)
+ wholeMenus: [],
+ // 整体路由(一维数组格式)
+ flatteningRoutes: [],
+ // 缓存页面keepAlive
+ cachePageList: []
+ }),
+ actions: {
+ /** 组装整体路由生成的菜单 */
+ handleWholeMenus(routes: any[]) {
+ this.wholeMenus = filterNoPermissionTree(
+ filterTree(ascending(this.constantMenus.concat(routes)))
+ );
+ this.flatteningRoutes = formatFlatteningRoutes(
+ this.constantMenus.concat(routes)
+ );
+ },
+ cacheOperate({ mode, name }: cacheType) {
+ const delIndex = this.cachePageList.findIndex(v => v === name);
+ switch (mode) {
+ case "refresh":
+ this.cachePageList = this.cachePageList.filter(v => v !== name);
+ break;
+ case "add":
+ this.cachePageList.push(name);
+ break;
+ case "delete":
+ delIndex !== -1 && this.cachePageList.splice(delIndex, 1);
+ break;
+ }
+ /** 监听缓存页面是否存在于标签页,不存在则删除 */
+ debounce(() => {
+ let cacheLength = this.cachePageList.length;
+ const nameList = getKeyList(useMultiTagsStoreHook().multiTags, "name");
+ while (cacheLength > 0) {
+ nameList.findIndex(v => v === this.cachePageList[cacheLength - 1]) ===
+ -1 &&
+ this.cachePageList.splice(
+ this.cachePageList.indexOf(this.cachePageList[cacheLength - 1]),
+ 1
+ );
+ cacheLength--;
+ }
+ })();
+ },
+ /** 清空缓存页面 */
+ clearAllCachePage() {
+ this.wholeMenus = [];
+ this.cachePageList = [];
+ }
+ }
+});
+
+export function usePermissionStoreHook() {
+ return usePermissionStore(store);
+}
diff --git a/src/store/modules/settings.ts b/src/store/modules/settings.ts
new file mode 100644
index 0000000..7f810f7
--- /dev/null
+++ b/src/store/modules/settings.ts
@@ -0,0 +1,36 @@
+import { defineStore } from "pinia";
+import { type setType, store, getConfig } from "../utils";
+
+export const useSettingStore = defineStore({
+ id: "pure-setting",
+ state: (): setType => ({
+ title: getConfig().Title,
+ fixedHeader: getConfig().FixedHeader,
+ hiddenSideBar: getConfig().HiddenSideBar
+ }),
+ getters: {
+ getTitle(state) {
+ return state.title;
+ },
+ getFixedHeader(state) {
+ return state.fixedHeader;
+ },
+ getHiddenSideBar(state) {
+ return state.hiddenSideBar;
+ }
+ },
+ actions: {
+ CHANGE_SETTING({ key, value }) {
+ if (Reflect.has(this, key)) {
+ this[key] = value;
+ }
+ },
+ changeSetting(data) {
+ this.CHANGE_SETTING(data);
+ }
+ }
+});
+
+export function useSettingStoreHook() {
+ return useSettingStore(store);
+}
diff --git a/src/store/modules/user.ts b/src/store/modules/user.ts
new file mode 100644
index 0000000..7ed0ef1
--- /dev/null
+++ b/src/store/modules/user.ts
@@ -0,0 +1,110 @@
+import { defineStore } from "pinia";
+import {
+ resetRouter,
+ router,
+ routerArrays,
+ storageLocal,
+ store,
+ type userType
+} from "../utils";
+import {
+ getLogin,
+ refreshTokenApi,
+ type RefreshTokenResult,
+ type UserResult
+} from "@/api/v1/user";
+import { useMultiTagsStoreHook } from "./multiTags";
+import { type DataInfo, removeToken, setToken, userKey } from "@/utils/auth";
+
+export const useUserStore = defineStore({
+ id: "pure-user",
+ state: (): userType => ({
+ // 头像
+ avatar: storageLocal().getItem>(userKey)?.avatar ?? "",
+ // 用户名
+ username: storageLocal().getItem>(userKey)?.username ?? "",
+ // 昵称
+ nickname: storageLocal().getItem>(userKey)?.nickname ?? "",
+ // 页面级别权限
+ roles: storageLocal().getItem>(userKey)?.roles ?? [],
+ // 按钮级别权限
+ permissions:
+ storageLocal().getItem>(userKey)?.permissions ?? [],
+ // 是否勾选了登录页的免登录
+ isRemembered: false,
+ // 登录页的免登录存储几天,默认7天
+ loginDay: 7
+ }),
+ actions: {
+ /** 存储头像 */
+ SET_AVATAR(avatar: string) {
+ this.avatar = avatar;
+ },
+ /** 存储用户名 */
+ SET_USERNAME(username: string) {
+ this.username = username;
+ },
+ /** 存储昵称 */
+ SET_NICKNAME(nickname: string) {
+ this.nickname = nickname;
+ },
+ /** 存储角色 */
+ SET_ROLES(roles: Array) {
+ this.roles = roles;
+ },
+ /** 存储按钮级别权限 */
+ SET_PERMS(permissions: Array) {
+ this.permissions = permissions;
+ },
+ /** 存储是否勾选了登录页的免登录 */
+ SET_ISREMEMBERED(bool: boolean) {
+ this.isRemembered = bool;
+ },
+ /** 设置登录页的免登录存储几天 */
+ SET_LOGINDAY(value: number) {
+ this.loginDay = Number(value);
+ },
+ /** 登入 */
+ async loginByUsername(data) {
+ return new Promise((resolve, reject) => {
+ getLogin(data)
+ .then(data => {
+ if (data?.success) setToken(data.data);
+ resolve(data);
+ })
+ .catch(error => {
+ reject(error);
+ });
+ });
+ },
+ /** 前端登出(不调用接口) */
+ logOut() {
+ this.username = "";
+ this.roles = [];
+ this.permissions = [];
+ removeToken();
+ useMultiTagsStoreHook().handleTags("equal", [...routerArrays]);
+ resetRouter();
+ router.push("/login");
+ },
+ /** 刷新`token` */
+ async handRefreshToken(data) {
+ return new Promise((resolve, reject) => {
+ refreshTokenApi(data)
+ .then(data => {
+ if (data) {
+ setToken(data.data);
+ resolve(data);
+ }
+ })
+ .catch(error => {
+ reject(error);
+ });
+ });
+ }
+ }
+});
+
+export function useUserStoreHook() {
+ return useUserStore(store);
+}
diff --git a/src/store/types.ts b/src/store/types.ts
new file mode 100644
index 0000000..c33268a
--- /dev/null
+++ b/src/store/types.ts
@@ -0,0 +1,47 @@
+import type { RouteRecordName } from "vue-router";
+
+export type cacheType = {
+ mode: string;
+ name?: RouteRecordName;
+};
+
+export type positionType = {
+ startIndex?: number;
+ length?: number;
+};
+
+export type appType = {
+ sidebar: {
+ opened: boolean;
+ withoutAnimation: boolean;
+ // 判断是否手动点击Collapse
+ isClickCollapse: boolean;
+ };
+ layout: string;
+ device: string;
+ viewportSize: { width: number; height: number };
+};
+
+export type multiType = {
+ path: string;
+ name: string;
+ meta: any;
+ query?: object;
+ params?: object;
+};
+
+export type setType = {
+ title: string;
+ fixedHeader: boolean;
+ hiddenSideBar: boolean;
+};
+
+export type userType = {
+ avatar?: string;
+ username?: string;
+ nickname?: string;
+ roles?: Array;
+ permissions?: Array;
+ isRemembered?: boolean;
+ loginDay?: number;
+};
diff --git a/src/store/utils.ts b/src/store/utils.ts
new file mode 100644
index 0000000..5dd8c75
--- /dev/null
+++ b/src/store/utils.ts
@@ -0,0 +1,28 @@
+export { store } from "@/store";
+export { routerArrays } from "@/layout/types";
+export { router, resetRouter, constantMenus } from "@/router";
+export { getConfig, responsiveStorageNameSpace } from "@/config";
+export {
+ ascending,
+ filterTree,
+ filterNoPermissionTree,
+ formatFlatteningRoutes
+} from "@/router/utils";
+export {
+ isUrl,
+ isEqual,
+ isNumber,
+ debounce,
+ isBoolean,
+ getKeyList,
+ storageLocal,
+ deviceDetection
+} from "@pureadmin/utils";
+export type {
+ setType,
+ appType,
+ userType,
+ multiType,
+ cacheType,
+ positionType
+} from "./types";
diff --git a/src/style/dark.scss b/src/style/dark.scss
new file mode 100644
index 0000000..746902f
--- /dev/null
+++ b/src/style/dark.scss
@@ -0,0 +1,182 @@
+@use "element-plus/theme-chalk/src/dark/css-vars.scss" as *;
+
+/* 整体暗色风格适配 */
+html.dark {
+ $border-style: #303030;
+ $color-white: #fff;
+
+ /* 自定义深色背景颜色 */
+ // --el-bg-color: #020409;
+
+ /* 常用border-color 需要时可取用 */
+ --pure-border-color: rgb(253 253 253 / 12%);
+
+ /* switch关闭状态下的color 需要时可取用 */
+ --pure-switch-off-color: #ffffff3f;
+
+ .navbar,
+ .tags-view,
+ .contextmenu,
+ .sidebar-container,
+ .horizontal-header,
+ .sidebar-logo-container,
+ .horizontal-header .el-sub-menu__title,
+ .horizontal-header .submenu-title-noDropdown {
+ background: var(--el-bg-color) !important;
+ }
+
+ .app-main,
+ .app-main-nofixed-header {
+ background: #020409 !important;
+ }
+
+ /* 标签页 */
+ .tags-view {
+ .arrow-left,
+ .arrow-right {
+ border-right: 1px solid $border-style;
+ box-shadow: none;
+ }
+
+ .arrow-right {
+ border-left: 1px solid $border-style;
+ }
+
+ .scroll-item {
+ .el-icon-close {
+ &:hover {
+ color: rgb(255 255 255 / 85%) !important;
+ background-color: rgb(255 255 255 / 12%);
+ }
+ }
+
+ .chrome-tab {
+ .tag-title {
+ color: #666;
+ }
+
+ &:hover {
+ .chrome-tab__bg {
+ color: #333;
+ }
+
+ .tag-title {
+ color: #adadad;
+ }
+ }
+ }
+ }
+ }
+
+ /* 系统配置面板 */
+ .right-panel-items {
+ .el-divider__text {
+ --el-bg-color: var(--el-bg-color);
+ }
+
+ .el-divider--horizontal {
+ border-top: none;
+ }
+ }
+
+ .el-card {
+ --el-card-bg-color: var(--el-bg-color);
+ }
+
+ .el-backtop {
+ --el-backtop-bg-color: rgb(72 72 78);
+ --el-backtop-hover-bg-color: var(--el-color-primary);
+
+ transition: background-color 0.25s cubic-bezier(0.7, 0.3, 0.1, 1);
+ }
+
+ .el-dropdown-menu__item:not(.is-disabled):hover {
+ background: transparent;
+ }
+
+ /* 全局覆盖element-plus的el-dialog、el-drawer、el-message-box、el-notification组件右上角关闭图标的样式,表现更鲜明 */
+ .el-icon {
+ &.el-dialog__close,
+ &.el-drawer__close,
+ &.el-message-box__close,
+ &.el-notification__closeBtn {
+ &:hover {
+ color: rgb(255 255 255 / 85%) !important;
+ background-color: rgb(255 255 255 / 12%);
+
+ .pure-dialog-svg {
+ color: rgb(255 255 255 / 85%) !important;
+ }
+ }
+ }
+ }
+
+ /* 克隆并自定义 ElMessage 样式,不会影响 ElMessage 原本样式,在 src/utils/message.ts 中调用自定义样式 ElMessage 方法即可,整体浅色风格在 src/style/element-plus.scss 文件进行了适配 */
+ .pure-message {
+ background-color: rgb(36 37 37) !important;
+ background-image: initial !important;
+ box-shadow:
+ rgb(13 13 13 / 12%) 0 3px 6px -4px,
+ rgb(13 13 13 / 8%) 0 6px 16px 0,
+ rgb(13 13 13 / 5%) 0 9px 28px 8px !important;
+
+ & .el-message__content {
+ color: $color-white !important;
+ pointer-events: all !important;
+ background-image: initial !important;
+ }
+
+ & .el-message__closeBtn {
+ &:hover {
+ color: rgb(255 255 255 / 85%);
+ background-color: rgb(255 255 255 / 12%);
+ }
+ }
+ }
+
+ /* 自定义菜单搜索样式 */
+ .pure-search-dialog {
+ .el-dialog__footer {
+ box-shadow:
+ 0 -1px 0 0 #555a64,
+ 0 -3px 6px 0 rgb(69 98 155 / 12%);
+ }
+
+ .search-footer {
+ .search-footer-item {
+ color: rgb(235 235 235 / 60%);
+
+ .icon {
+ box-shadow: none;
+ }
+ }
+ }
+ }
+
+ /* ReSegmented 组件 */
+ .pure-segmented {
+ color: rgb(255 255 255 / 65%);
+ background-color: #000;
+
+ .pure-segmented-item-selected {
+ background-color: #1f1f1f;
+ }
+
+ .pure-segmented-item-disabled {
+ color: rgb(255 255 255 / 25%);
+ }
+ }
+
+ /* 仿 el-scrollbar 滚动条样式 支持大多数浏览器,如Chrome、Edge、Firefox、Safari等 */
+ .pure-scrollbar {
+ scrollbar-color: rgb(63 64 66) transparent;
+
+ ::-webkit-scrollbar-thumb {
+ background-color: rgb(63 64 66);
+ }
+
+ ::-webkit-scrollbar-thumb:hover {
+ background: rgb(92 93 96);
+ }
+ }
+}
diff --git a/src/style/element-plus.scss b/src/style/element-plus.scss
new file mode 100644
index 0000000..165008f
--- /dev/null
+++ b/src/style/element-plus.scss
@@ -0,0 +1,188 @@
+.el-form-item__label {
+ font-weight: 700;
+}
+
+.el-breadcrumb__inner,
+.el-breadcrumb__inner a {
+ font-weight: 400 !important;
+}
+
+.el-dropdown-menu {
+ padding: 0 !important;
+}
+
+.is-dark {
+ z-index: 9999 !important;
+}
+
+/* 重置 el-button 中 icon 的 margin */
+.reset-margin [class*="el-icon"] + span {
+ margin-left: 2px !important;
+}
+
+/* 自定义 popover 的类名 */
+.pure-popper {
+ padding: 0 !important;
+}
+
+/* nprogress 适配 element-plus 的主题色 */
+#nprogress {
+ & .bar {
+ background-color: var(--el-color-primary) !important;
+ }
+
+ & .peg {
+ box-shadow:
+ 0 0 10px var(--el-color-primary),
+ 0 0 5px var(--el-color-primary) !important;
+ }
+
+ & .spinner-icon {
+ border-top-color: var(--el-color-primary);
+ border-left-color: var(--el-color-primary);
+ }
+}
+
+.pure-dialog {
+ .el-dialog__header.show-close {
+ padding-right: 16px;
+ }
+
+ .el-dialog__headerbtn {
+ top: 16px;
+ right: 12px;
+ width: 24px;
+ height: 24px;
+ }
+
+ .pure-dialog-svg {
+ color: var(--el-color-info);
+ }
+
+ .el-dialog__footer {
+ padding-top: 0;
+ }
+}
+
+/* 全局覆盖element-plus的el-tour、el-dialog、el-drawer、el-message-box、el-notification组件右上角关闭图标和el-upload上传文件列表右侧关闭图标的样式,表现更鲜明 */
+.el-dialog__headerbtn,
+.el-message-box__headerbtn {
+ &:hover {
+ .el-dialog__close {
+ color: var(--el-color-info) !important;
+ }
+ }
+}
+
+.el-icon {
+ &.el-tour__close,
+ &.el-dialog__close,
+ &.el-drawer__close,
+ &.el-message-box__close,
+ &.el-notification__closeBtn,
+ .el-upload-list__item.is-ready &.el-icon--close {
+ width: 24px;
+ height: 24px;
+ border-radius: 4px;
+ outline: none;
+ transition:
+ background-color 0.2s,
+ color 0.2s;
+
+ &:hover {
+ color: rgb(0 0 0 / 88%) !important;
+ text-decoration: none;
+ background-color: rgb(0 0 0 / 6%);
+
+ .pure-dialog-svg {
+ color: rgb(0 0 0 / 88%) !important;
+ }
+ }
+ }
+}
+
+/* 克隆并自定义 ElMessage 样式,不会影响 ElMessage 原本样式,在 src/utils/message.ts 中调用自定义样式 ElMessage 方法即可,整体暗色风格在 src/style/dark.scss 文件进行了适配 */
+.pure-message {
+ background: #fff !important;
+ border-width: 0 !important;
+ box-shadow:
+ 0 3px 6px -4px #0000001f,
+ 0 6px 16px #00000014,
+ 0 9px 28px 8px #0000000d !important;
+
+ & .el-message__content {
+ color: #000000d9 !important;
+ pointer-events: all !important;
+ background-image: initial !important;
+ }
+
+ & .el-message__closeBtn {
+ border-radius: 4px;
+ outline: none;
+ transition:
+ background-color 0.2s,
+ color 0.2s;
+
+ &:hover {
+ background-color: rgb(0 0 0 / 6%);
+ }
+ }
+}
+
+/* 自定义菜单搜索样式 */
+.pure-search-dialog {
+ @media screen and (width > 760px) and (width <= 940px) {
+ .el-input__inner {
+ font-size: 12px;
+ }
+ }
+
+ @media screen and (width <= 470px) {
+ .el-input__inner {
+ font-size: 12px;
+ }
+ }
+
+ .el-dialog__header {
+ display: none;
+ }
+
+ .el-input__inner {
+ font-size: 1.2em;
+ }
+
+ .el-dialog__footer {
+ width: calc(100% + 32px);
+ padding: 10px 20px;
+ margin: auto -16px -16px;
+ box-shadow:
+ 0 -1px 0 0 #e0e3e8,
+ 0 -3px 6px 0 rgb(69 98 155 / 12%);
+ }
+}
+
+/* 仿 el-scrollbar 滚动条样式,支持大多数浏览器,如Chrome、Edge、Firefox、Safari等。整体暗色风格在 src/style/dark.scss 文件进行了适配 */
+.pure-scrollbar {
+ /* Firefox */
+ scrollbar-width: thin; /* 可选值为 'auto', 'thin', 'none' */
+ scrollbar-color: rgb(221 222 224) transparent; /* 滑块颜色、轨道颜色 */
+ ::-webkit-scrollbar {
+ width: 6px; /* 滚动条宽度 */
+ }
+
+ /* 滚动条轨道 */
+ ::-webkit-scrollbar-track {
+ background: transparent; /* 轨道颜色 */
+ }
+
+ /* 滚动条滑块 */
+ ::-webkit-scrollbar-thumb {
+ background-color: rgb(221 222 224);
+ border-radius: 4px;
+ }
+
+ /* 滚动条滑块:hover状态 */
+ ::-webkit-scrollbar-thumb:hover {
+ background: rgb(199 201 203); /* 滑块hover颜色 */
+ }
+}
diff --git a/src/style/index.scss b/src/style/index.scss
new file mode 100644
index 0000000..d267d38
--- /dev/null
+++ b/src/style/index.scss
@@ -0,0 +1,26 @@
+@import "./transition";
+@import "./element-plus";
+@import "./sidebar";
+@import "./dark";
+
+/* 自定义全局 CssVar */
+:root {
+ /* 左侧菜单展开、收起动画时长 */
+ --pure-transition-duration: 0.3s;
+
+ /* 常用border-color 需要时可取用 */
+ --pure-border-color: rgb(5 5 5 / 6%);
+
+ /* switch关闭状态下的color 需要时可取用 */
+ --pure-switch-off-color: #a6a6a6;
+}
+
+/* 灰色模式 */
+.html-grey {
+ filter: grayscale(100%);
+}
+
+/* 色弱模式 */
+.html-weakness {
+ filter: invert(80%);
+}
diff --git a/src/style/login.css b/src/style/login.css
new file mode 100644
index 0000000..3e0a8ab
--- /dev/null
+++ b/src/style/login.css
@@ -0,0 +1,96 @@
+.wave {
+ position: fixed;
+ height: 100%;
+ width: 80%;
+ left: 0;
+ bottom: 0;
+ z-index: -1;
+}
+
+.login-container {
+ width: 100vw;
+ height: 100vh;
+ max-width: 100%;
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ grid-gap: 18rem;
+ padding: 0 2rem;
+}
+
+.img {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+}
+
+.img img {
+ width: 500px;
+}
+
+.login-box {
+ display: flex;
+ align-items: center;
+ text-align: center;
+ overflow: hidden;
+}
+
+.login-form {
+ width: 360px;
+}
+
+.avatar {
+ width: 350px;
+ height: 80px;
+}
+
+.login-form h2 {
+ text-transform: uppercase;
+ margin: 15px 0;
+ color: #999;
+ font:
+ bold 200% Consolas,
+ Monaco,
+ monospace;
+}
+
+@media screen and (max-width: 1180px) {
+ .login-container {
+ grid-gap: 9rem;
+ }
+
+ .login-form {
+ width: 290px;
+ }
+
+ .login-form h2 {
+ font-size: 2.4rem;
+ margin: 8px 0;
+ }
+
+ .img img {
+ width: 360px;
+ }
+
+ .avatar {
+ width: 280px;
+ height: 80px;
+ }
+}
+
+@media screen and (max-width: 968px) {
+ .wave {
+ display: none;
+ }
+
+ .img {
+ display: none;
+ }
+
+ .login-container {
+ grid-template-columns: 1fr;
+ }
+
+ .login-box {
+ justify-content: center;
+ }
+}
diff --git a/src/style/reset.scss b/src/style/reset.scss
new file mode 100644
index 0000000..d79cdd9
--- /dev/null
+++ b/src/style/reset.scss
@@ -0,0 +1,257 @@
+*,
+::before,
+::after {
+ box-sizing: border-box;
+ border-color: currentColor;
+ border-style: solid;
+ border-width: 0;
+}
+
+#app {
+ width: 100%;
+ height: 100%;
+}
+
+html {
+ box-sizing: border-box;
+ width: 100%;
+ height: 100%;
+ line-height: 1.5;
+ tab-size: 4;
+ text-size-adjust: 100%;
+}
+
+body {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB",
+ "Microsoft YaHei", "微软雅黑", Arial, sans-serif;
+ line-height: inherit;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizelegibility;
+}
+
+hr {
+ height: 0;
+ color: inherit;
+ border-top-width: 1px;
+}
+
+abbr:where([title]) {
+ text-decoration: underline dotted;
+}
+
+a {
+ color: inherit;
+ text-decoration: inherit;
+}
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+code,
+kbd,
+samp,
+pre {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
+ "Liberation Mono", "Courier New", monospace;
+ font-size: 1em;
+}
+
+small {
+ font-size: 80%;
+}
+
+sub,
+sup {
+ position: relative;
+ font-size: 75%;
+ line-height: 0;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+table {
+ text-indent: 0;
+ border-collapse: collapse;
+ border-color: inherit;
+}
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ padding: 0;
+ margin: 0;
+ font-family: inherit;
+ font-size: 100%;
+ line-height: inherit;
+ color: inherit;
+}
+
+button,
+select {
+ text-transform: none;
+}
+
+button,
+[type="button"],
+[type="reset"],
+[type="submit"] {
+ background-image: none;
+}
+
+:-moz-focusring {
+ outline: auto;
+}
+
+:-moz-ui-invalid {
+ box-shadow: none;
+}
+
+progress {
+ vertical-align: baseline;
+}
+
+::-webkit-inner-spin-button,
+::-webkit-outer-spin-button {
+ height: auto;
+}
+
+[type="search"] {
+ outline-offset: -2px;
+}
+
+::-webkit-file-upload-button {
+ font: inherit;
+}
+
+summary {
+ display: list-item;
+}
+
+blockquote,
+dl,
+dd,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+hr,
+figure,
+p,
+pre {
+ margin: 0;
+}
+
+fieldset {
+ padding: 0;
+ margin: 0;
+}
+
+legend {
+ padding: 0;
+}
+
+ol,
+ul,
+menu {
+ padding: 0;
+ margin: 0;
+ list-style: none;
+}
+
+textarea {
+ resize: vertical;
+}
+
+input::placeholder,
+textarea::placeholder {
+ color: #9ca3af;
+ opacity: 1;
+}
+
+button,
+[role="button"] {
+ cursor: pointer;
+}
+
+:disabled {
+ cursor: default;
+}
+
+svg,
+video,
+canvas,
+audio,
+iframe,
+embed,
+object {
+ display: block;
+}
+
+img,
+video {
+ max-width: 100%;
+ height: auto;
+}
+
+[hidden] {
+ display: none;
+}
+
+.dark {
+ color-scheme: dark;
+}
+
+label {
+ font-weight: 700;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: inherit;
+}
+
+a:focus,
+a:active {
+ outline: none;
+}
+
+a,
+a:focus,
+a:hover {
+ color: inherit;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+div:focus {
+ outline: none;
+}
+
+.clearfix {
+ &::after {
+ display: block;
+ height: 0;
+ clear: both;
+ font-size: 0;
+ visibility: hidden;
+ content: " ";
+ }
+}
diff --git a/src/style/sidebar.scss b/src/style/sidebar.scss
new file mode 100644
index 0000000..34d05b0
--- /dev/null
+++ b/src/style/sidebar.scss
@@ -0,0 +1,729 @@
+/* $sideBarWidth: vertical 模式下主体内容距离网页文档左侧的距离 */
+@mixin merge-style($sideBarWidth) {
+ $menuActiveText: #7a80b4;
+
+ @media screen and (width >= 150px) and (width <= 420px) {
+ .app-main-nofixed-header {
+ overflow-y: hidden;
+ }
+ }
+
+ @media screen and (width >= 420px) {
+ .app-main-nofixed-header {
+ overflow: hidden;
+ }
+ }
+
+ /* 修复 windows 下双滚动条问题 https://github.com/pure-admin/vue-pure-admin/pull/936#issuecomment-1968125992 */
+ .el-popper.pure-scrollbar {
+ overflow: hidden;
+ }
+
+ /* popper menu 超出内容区可滚动 */
+ .pure-scrollbar {
+ max-height: calc(100vh - calc(50px * 2.5));
+ overflow: hidden auto;
+ }
+
+ .sub-menu-icon {
+ margin-right: 5px;
+ font-size: 18px;
+
+ svg {
+ width: 18px;
+ height: 18px;
+ }
+ }
+
+ .set-icon,
+ .fullscreen-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 48px;
+ cursor: pointer;
+ }
+
+ .main-container {
+ position: relative;
+ height: 100vh;
+ min-height: 100%;
+ margin-left: $sideBarWidth;
+ background: #f0f2f5;
+
+ /* main-content 属性动画 */
+ transition: margin-left var(--pure-transition-duration);
+
+ .el-scrollbar__wrap {
+ height: 100%;
+ overflow: auto;
+ }
+ }
+
+ .fixed-header {
+ position: fixed;
+ top: 0;
+ right: 0;
+ z-index: 998;
+ width: calc(100% - #{$sideBarWidth});
+
+ /* fixed-header 属性左上角动画 */
+ transition: width var(--pure-transition-duration);
+ }
+
+ .main-hidden {
+ margin-left: 0 !important;
+
+ .fixed-header {
+ width: 100% !important;
+
+ + .app-main {
+ padding-top: 37px !important;
+ }
+ }
+ }
+
+ .sidebar-container {
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 1001;
+ width: $sideBarWidth !important;
+ height: 100%;
+ overflow: visible;
+ font-size: 0;
+ background: $menuBg;
+ border-right: 1px solid var(--pure-border-color);
+
+ /* 展开动画 */
+ transition: width var(--pure-transition-duration);
+
+ .scrollbar-wrapper {
+ overflow-x: hidden !important;
+ }
+
+ .el-scrollbar__bar.is-vertical {
+ right: 0;
+ }
+
+ &.has-logo {
+ .el-scrollbar.pc {
+ /* logo: 48px、leftCollapse: 40px、leftCollapse-shadow: 4px */
+ height: calc(100% - 92px);
+ }
+
+ /* logo: 48px */
+ .el-scrollbar.mobile {
+ height: calc(100% - 48px);
+ }
+ }
+
+ &.no-logo {
+ .el-scrollbar.pc {
+ /* leftCollapse: 40px、leftCollapse-shadow: 4px */
+ height: calc(100% - 44px);
+ }
+
+ .el-scrollbar.mobile {
+ height: 100%;
+ }
+ }
+
+ .is-horizontal {
+ display: none;
+ }
+
+ a {
+ display: flex;
+ flex-wrap: wrap;
+ width: 100%;
+ }
+
+ .el-menu {
+ height: 100%;
+ background-color: transparent !important;
+ border: none;
+ }
+
+ .el-menu-item,
+ .el-sub-menu__title {
+ height: 50px;
+ color: $menuText;
+ background-color: transparent !important;
+
+ &:hover {
+ color: $menuTitleHover !important;
+ }
+
+ div,
+ span {
+ height: 50px;
+ line-height: 50px;
+ }
+ }
+
+ .submenu-title-noDropdown,
+ .el-sub-menu__title {
+ &:hover {
+ background-color: transparent;
+ }
+ }
+
+ .is-active > .el-sub-menu__title,
+ .is-active.submenu-title-noDropdown {
+ color: $subMenuActiveText !important;
+
+ i {
+ color: $subMenuActiveText !important;
+ }
+ }
+
+ .is-active {
+ color: $subMenuActiveText !important;
+ transition: color 0.3s;
+ }
+
+ .el-menu-item.is-active.nest-menu > * {
+ z-index: 1;
+ color: #fff;
+ }
+
+ .el-menu-item.is-active.nest-menu::before {
+ position: absolute;
+ inset: 0 8px;
+ margin: 4px 0;
+ clear: both;
+ content: "";
+ background: var(--el-color-primary) !important;
+ border-radius: 3px;
+ }
+
+ .el-menu .el-menu--inline .el-sub-menu__title,
+ & .el-sub-menu .el-menu-item {
+ min-width: $sideBarWidth !important;
+ font-size: 14px;
+ background-color: $subMenuBg !important;
+ }
+
+ /* 有子集的激活菜单左侧小竖条 */
+ .el-menu--collapse
+ .is-active.outer-most.el-sub-menu
+ > .el-sub-menu__title::before {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 2px;
+ height: 100%;
+ clear: both;
+ content: "";
+ background-color: $menuActiveBefore;
+ transition: all var(--pure-transition-duration) ease-in-out;
+ transform: translateY(0);
+ }
+
+ .el-menu--collapse .outer-most.el-sub-menu > .el-sub-menu__title::before {
+ position: absolute;
+ top: 50%;
+ display: block;
+ width: 3px;
+ height: 0;
+ content: "";
+ transform: translateY(-50%);
+ }
+
+ /* 无子集的激活菜单背景 */
+ .is-active.submenu-title-noDropdown.outer-most > * {
+ z-index: 1;
+ color: #fff;
+ }
+
+ .is-active.submenu-title-noDropdown.outer-most::before {
+ position: absolute;
+ inset: 0 8px;
+ margin: 4px 0;
+ clear: both;
+ content: "";
+ background: var(--el-color-primary) !important;
+ border-radius: 3px;
+ }
+ }
+
+ /* vertical 菜单折叠 */
+ .el-menu--vertical {
+ .el-menu--popup {
+ background-color: $subMenuBg !important;
+
+ .el-menu-item {
+ span {
+ font-size: 14px;
+ }
+ }
+ }
+
+ & > .el-menu {
+ i,
+ svg {
+ margin-right: 5px;
+ }
+ }
+
+ .is-active > .el-sub-menu__title,
+ .is-active.submenu-title-noDropdown {
+ color: $subMenuActiveText !important;
+
+ i {
+ color: $subMenuActiveText !important;
+ }
+ }
+
+ /* 子菜单中还有子菜单 */
+ .el-menu .el-sub-menu__title {
+ min-width: $sideBarWidth !important;
+ font-size: 14px;
+ background-color: $subMenuBg !important;
+ }
+
+ .el-menu-item,
+ .el-sub-menu__title {
+ height: 50px;
+ line-height: 50px;
+ color: $menuText;
+ background-color: $subMenuBg;
+
+ &:hover {
+ color: $menuTitleHover !important;
+ }
+ }
+
+ .is-active {
+ color: $subMenuActiveText !important;
+ transition: color 0.3s;
+ }
+
+ .el-menu-item.is-active.nest-menu > * {
+ z-index: 1;
+ color: #fff;
+ }
+
+ .el-menu-item.is-active.nest-menu::before {
+ position: absolute;
+ inset: 0 8px;
+ clear: both;
+ content: "";
+ background: var(--el-color-primary) !important;
+ border-radius: 3px;
+ }
+
+ .el-menu-item,
+ .el-sub-menu {
+ .iconfont {
+ font-size: 18px;
+ }
+
+ .el-menu-tooltip__trigger {
+ width: 54px;
+ padding: 0;
+ }
+ }
+ }
+
+ /* horizontal 菜单 */
+ .el-menu--horizontal {
+ & > .el-sub-menu .el-sub-menu__icon-arrow {
+ position: static !important;
+ margin-top: 0;
+ }
+
+ /* 无子菜单时激活 border-bottom */
+ a > .is-active.submenu-title-noDropdown {
+ border-bottom: 2px solid var(--el-menu-active-color);
+ }
+
+ .el-menu--popup {
+ background-color: $subMenuBg !important;
+
+ a > .is-active.submenu-title-noDropdown {
+ border-bottom: none;
+ }
+
+ .el-menu-item {
+ color: $menuText;
+ background-color: $subMenuBg;
+
+ span {
+ font-size: 14px;
+ }
+ }
+
+ .el-sub-menu__title {
+ color: $menuText;
+ }
+ }
+
+ /* 子菜单中还有子菜单 */
+ .el-menu .el-sub-menu__title {
+ min-width: $sideBarWidth !important;
+ font-size: 14px;
+ background-color: $subMenuBg !important;
+
+ &:hover {
+ color: $menuTitleHover !important;
+ }
+ }
+
+ .is-active > .el-sub-menu__title,
+ .is-active.submenu-title-noDropdown {
+ color: $subMenuActiveText !important;
+
+ i {
+ color: $subMenuActiveText !important;
+ }
+ }
+
+ .nest-menu .el-sub-menu > .el-sub-menu__title,
+ .el-menu-item {
+ &:hover {
+ color: $menuTitleHover !important;
+ }
+ }
+
+ .el-menu-item.is-active {
+ color: $subMenuActiveText !important;
+ transition: color 0.3s;
+ }
+
+ .el-menu-item.is-active.nest-menu > * {
+ z-index: 1;
+ color: #fff;
+ }
+
+ .el-menu-item.is-active.nest-menu::before {
+ position: absolute;
+ inset: 0 5px;
+ clear: both;
+ content: "";
+ background: var(--el-color-primary) !important;
+ border-radius: 3px;
+ }
+ }
+
+ .horizontal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+ width: 100%;
+ height: 48px;
+ background: $menuBg;
+
+ .horizontal-header-left {
+ display: flex;
+ align-items: center;
+ width: auto;
+ min-width: 200px;
+ height: 100%;
+ padding-left: 10px;
+ cursor: pointer;
+ transition: all var(--pure-transition-duration) ease;
+
+ img {
+ display: inline-block;
+ height: 32px;
+ }
+
+ span {
+ display: inline-block;
+ height: 32px;
+ margin: 2px 0 0 12px;
+ overflow: hidden;
+ font-size: 18px;
+ font-weight: 600;
+ line-height: 32px;
+ color: $subMenuActiveText;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+
+ .horizontal-header-menu {
+ flex: 1;
+ align-items: center;
+ min-width: 0;
+ height: 100%;
+ }
+
+ .horizontal-header-right {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ min-width: 340px;
+ color: $subMenuActiveText;
+
+ /* 搜索 */
+ .search-container,
+ /* 国际化 */
+ .globalization,
+ /* 全屏 */
+ .fullscreen-icon,
+ /* 消息通知 */
+ .dropdown-badge,
+ /* 用户名 */
+ .el-dropdown-link,
+ /* 设置 */
+ .set-icon {
+ &:hover {
+ background: $menuHover;
+ }
+ }
+
+ .dropdown-badge {
+ height: 48px;
+ color: $subMenuActiveText;
+ }
+
+ .globalization {
+ width: 40px;
+ height: 48px;
+ padding: 11px;
+ color: $subMenuActiveText;
+ cursor: pointer;
+ outline: none;
+ }
+
+ .el-dropdown-link {
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+ height: 48px;
+ padding: 10px;
+ color: $subMenuActiveText;
+ cursor: pointer;
+
+ p {
+ font-size: 14px;
+ }
+
+ img {
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ }
+ }
+ }
+
+ .el-menu {
+ width: 100% !important;
+ height: 100%;
+ background-color: transparent;
+ border: none;
+ }
+
+ .el-menu-item,
+ .el-sub-menu__title {
+ padding-right: var(--el-menu-base-level-padding);
+ color: $menuText;
+
+ &:hover {
+ color: $menuTitleHover !important;
+ }
+ }
+
+ .submenu-title-noDropdown,
+ .el-sub-menu__title {
+ height: 48px;
+ line-height: 48px;
+ background: $menuBg;
+
+ svg {
+ position: static !important;
+ }
+ }
+
+ .is-active > .el-sub-menu__title,
+ .is-active.submenu-title-noDropdown {
+ color: $subMenuActiveText !important;
+
+ i {
+ color: $subMenuActiveText !important;
+ }
+ }
+
+ .is-active {
+ color: $subMenuActiveText !important;
+ transition: color 0.3s;
+ }
+ }
+
+ .el-menu--collapse .el-menu .el-sub-menu {
+ min-width: $sideBarWidth !important;
+ }
+
+ /* 手机端 */
+ .mobile {
+ .fixed-header {
+ width: 100% !important;
+ transition: width var(--pure-transition-duration);
+ }
+
+ .main-container {
+ margin-left: 0 !important;
+ }
+
+ .sidebar-container {
+ z-index: 2001;
+ width: $sideBarWidth;
+ transition: transform var(--pure-transition-duration);
+ }
+
+ &.hideSidebar {
+ .sidebar-container {
+ pointer-events: none;
+ transition-duration: 0.3s;
+ transform: translate3d(-$sideBarWidth, 0, 0);
+ }
+ }
+ }
+}
+
+body[layout="vertical"] {
+ $sideBarWidth: 210px;
+
+ @include merge-style($sideBarWidth);
+
+ .el-menu--collapse {
+ width: 54px;
+ }
+
+ .sidebar-logo-container {
+ background: $sidebarLogo;
+ }
+
+ .hideSidebar {
+ .fixed-header {
+ width: calc(100% - 54px);
+ transition: width var(--pure-transition-duration);
+ }
+
+ .sidebar-container {
+ width: 54px !important;
+ transition: width var(--pure-transition-duration);
+
+ .is-active.submenu-title-noDropdown.outer-most {
+ background: transparent !important;
+ }
+ }
+
+ .main-container {
+ margin-left: 54px;
+ }
+
+ /* 菜单折叠 */
+ .el-menu--collapse {
+ .el-sub-menu {
+ & > .el-sub-menu__title {
+ & > span {
+ width: 100%;
+ height: 100%;
+ text-align: center;
+ visibility: visible;
+ }
+ }
+ }
+
+ .submenu-title-noDropdown {
+ background: transparent !important;
+ }
+
+ .el-sub-menu__title {
+ padding: 0;
+ }
+ }
+
+ .sub-menu-icon {
+ margin-right: 0;
+ }
+ }
+
+ /* 搜索 */
+ .search-container,
+ /* 国际化 */
+ .globalization,
+ /* 全屏 */
+ .fullscreen-icon,
+ /* 消息通知 */
+ .dropdown-badge,
+ /* 用户名 */
+ .el-dropdown-link,
+ /* 设置 */
+ .set-icon {
+ &:hover {
+ background: #f6f6f6;
+ }
+ }
+}
+
+body[layout="horizontal"] {
+ $sideBarWidth: 0;
+
+ @include merge-style($sideBarWidth);
+
+ .fixed-header,
+ .main-container {
+ transition: none !important;
+ }
+
+ .fixed-header {
+ width: 100%;
+ }
+}
+
+body[layout="mix"] {
+ $sideBarWidth: 210px;
+
+ @include merge-style($sideBarWidth);
+
+ .el-menu--collapse {
+ width: 54px;
+ }
+
+ .el-menu {
+ --el-menu-hover-bg-color: transparent !important;
+ }
+
+ .hideSidebar {
+ .fixed-header {
+ width: calc(100% - 54px);
+ transition: width var(--pure-transition-duration);
+ }
+
+ .sidebar-container {
+ width: 54px !important;
+ transition: width var(--pure-transition-duration);
+
+ .is-active.submenu-title-noDropdown.outer-most {
+ background: transparent !important;
+ }
+ }
+
+ .main-container {
+ margin-left: 54px;
+ }
+
+ /* 菜单折叠 */
+ .el-menu--collapse {
+ .el-sub-menu {
+ & > .el-sub-menu__title {
+ padding: 0;
+
+ & > span {
+ width: 100%;
+ height: 100%;
+ text-align: center;
+ visibility: visible;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/style/tailwind.css b/src/style/tailwind.css
new file mode 100644
index 0000000..3e48b68
--- /dev/null
+++ b/src/style/tailwind.css
@@ -0,0 +1,21 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer components {
+ .flex-c {
+ @apply flex justify-center items-center;
+ }
+
+ .flex-ac {
+ @apply flex justify-around items-center;
+ }
+
+ .flex-bc {
+ @apply flex justify-between items-center;
+ }
+
+ .navbar-bg-hover {
+ @apply dark:text-white dark:hover:!bg-[#242424];
+ }
+}
diff --git a/src/style/transition.scss b/src/style/transition.scss
new file mode 100644
index 0000000..c7274dd
--- /dev/null
+++ b/src/style/transition.scss
@@ -0,0 +1,54 @@
+/* fade */
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.28s;
+}
+
+.fade-enter,
+.fade-leave-active {
+ opacity: 0;
+}
+
+/* fade-transform */
+.fade-transform-leave-active,
+.fade-transform-enter-active {
+ transition: all 0.5s;
+}
+
+.fade-transform-enter-from {
+ opacity: 0;
+ transform: translateX(-30px);
+}
+
+.fade-transform-leave-to {
+ opacity: 0;
+ transform: translateX(30px);
+}
+
+/* breadcrumb transition */
+.breadcrumb-enter-active {
+ transition: all 0.4s;
+}
+
+.breadcrumb-leave-active {
+ position: absolute;
+ transition: all 0.3s;
+}
+
+.breadcrumb-enter-from,
+.breadcrumb-leave-active {
+ opacity: 0;
+ transform: translateX(20px);
+}
+
+/**
+ * @description 重置el-menu的展开收起动画时长
+ */
+.outer-most .el-collapse-transition-leave-active,
+.outer-most .el-collapse-transition-enter-active {
+ transition: 0.2s all ease-in-out !important;
+}
+
+.horizontal-collapse-transition {
+ transition: var(--pure-transition-duration) all !important;
+}
diff --git a/src/types/directives.d.ts b/src/types/directives.d.ts
new file mode 100644
index 0000000..458fd09
--- /dev/null
+++ b/src/types/directives.d.ts
@@ -0,0 +1,28 @@
+import type { Directive } from "vue";
+import type { CopyEl, OptimizeOptions, RippleOptions } from "@/directives";
+
+declare module "vue" {
+ export interface ComponentCustomProperties {
+ /** `Loading` 动画加载指令,具体看:https://element-plus.org/zh-CN/component/loading.html#%E6%8C%87%E4%BB%A4 */
+ vLoading: Directive;
+ /** 按钮权限指令(根据路由`meta`中的`auths`字段进行判断)*/
+ vAuth: Directive>;
+ /** 文本复制指令(默认双击复制) */
+ vCopy: Directive;
+ /** 长按指令 */
+ vLongpress: Directive;
+ /** 防抖、节流指令 */
+ vOptimize: Directive;
+ /** 按钮权限指令(根据登录接口返回的`permissions`字段进行判断)*/
+ vPerms: Directive>;
+ /**
+ * `v-ripple`指令,用法如下:
+ * 1. `v-ripple`代表启用基本的`ripple`功能
+ * 2. `v-ripple="{ class: 'text-red' }"`代表自定义`ripple`颜色,支持`tailwindcss`,生效样式是`color`
+ * 3. `v-ripple.center`代表从中心扩散
+ */
+ vRipple: Directive;
+ }
+}
+
+export {};
diff --git a/src/types/global-components.d.ts b/src/types/global-components.d.ts
new file mode 100644
index 0000000..b9adead
--- /dev/null
+++ b/src/types/global-components.d.ts
@@ -0,0 +1,134 @@
+declare module "vue" {
+ /**
+ * 自定义全局组件获得 Volar 提示(自定义的全局组件需要在这里声明下才能获得 Volar 类型提示哦)
+ */
+ export interface GlobalComponents {
+ IconifyIconOffline: (typeof import("../components/CommonIcon"))["IconifyIconOffline"];
+ IconifyIconOnline: (typeof import("../components/CommonIcon"))["IconifyIconOnline"];
+ FontIcon: (typeof import("../components/CommonIcon"))["FontIcon"];
+ Auth: (typeof import("../components/Auth"))["Auth"];
+ Perms: (typeof import("../components/Perms"))["Perms"];
+ }
+}
+
+/**
+ * TODO https://github.com/element-plus/element-plus/blob/dev/global.d.ts#L2
+ * No need to install @vue/runtime-core
+ */
+declare module "vue" {
+ export interface GlobalComponents {
+ ElAffix: (typeof import("element-plus"))["ElAffix"];
+ ElAlert: (typeof import("element-plus"))["ElAlert"];
+ ElAside: (typeof import("element-plus"))["ElAside"];
+ ElAutocomplete: (typeof import("element-plus"))["ElAutocomplete"];
+ ElAvatar: (typeof import("element-plus"))["ElAvatar"];
+ ElAnchor: (typeof import("element-plus"))["ElAnchor"];
+ ElAnchorLink: (typeof import("element-plus"))["ElAnchorLink"];
+ ElBacktop: (typeof import("element-plus"))["ElBacktop"];
+ ElBadge: (typeof import("element-plus"))["ElBadge"];
+ ElBreadcrumb: (typeof import("element-plus"))["ElBreadcrumb"];
+ ElBreadcrumbItem: (typeof import("element-plus"))["ElBreadcrumbItem"];
+ ElButton: (typeof import("element-plus"))["ElButton"];
+ ElButtonGroup: (typeof import("element-plus"))["ElButtonGroup"];
+ ElCalendar: (typeof import("element-plus"))["ElCalendar"];
+ ElCard: (typeof import("element-plus"))["ElCard"];
+ ElCarousel: (typeof import("element-plus"))["ElCarousel"];
+ ElCarouselItem: (typeof import("element-plus"))["ElCarouselItem"];
+ ElCascader: (typeof import("element-plus"))["ElCascader"];
+ ElCascaderPanel: (typeof import("element-plus"))["ElCascaderPanel"];
+ ElCheckbox: (typeof import("element-plus"))["ElCheckbox"];
+ ElCheckboxButton: (typeof import("element-plus"))["ElCheckboxButton"];
+ ElCheckboxGroup: (typeof import("element-plus"))["ElCheckboxGroup"];
+ ElCol: (typeof import("element-plus"))["ElCol"];
+ ElCollapse: (typeof import("element-plus"))["ElCollapse"];
+ ElCollapseItem: (typeof import("element-plus"))["ElCollapseItem"];
+ ElCollapseTransition: (typeof import("element-plus"))["ElCollapseTransition"];
+ ElColorPicker: (typeof import("element-plus"))["ElColorPicker"];
+ ElContainer: (typeof import("element-plus"))["ElContainer"];
+ ElConfigProvider: (typeof import("element-plus"))["ElConfigProvider"];
+ ElDatePicker: (typeof import("element-plus"))["ElDatePicker"];
+ ElDialog: (typeof import("element-plus"))["ElDialog"];
+ ElDivider: (typeof import("element-plus"))["ElDivider"];
+ ElDrawer: (typeof import("element-plus"))["ElDrawer"];
+ ElDropdown: (typeof import("element-plus"))["ElDropdown"];
+ ElDropdownItem: (typeof import("element-plus"))["ElDropdownItem"];
+ ElDropdownMenu: (typeof import("element-plus"))["ElDropdownMenu"];
+ ElEmpty: (typeof import("element-plus"))["ElEmpty"];
+ ElFooter: (typeof import("element-plus"))["ElFooter"];
+ ElForm: (typeof import("element-plus"))["ElForm"];
+ ElFormItem: (typeof import("element-plus"))["ElFormItem"];
+ ElHeader: (typeof import("element-plus"))["ElHeader"];
+ ElIcon: (typeof import("element-plus"))["ElIcon"];
+ ElImage: (typeof import("element-plus"))["ElImage"];
+ ElImageViewer: (typeof import("element-plus"))["ElImageViewer"];
+ ElInput: (typeof import("element-plus"))["ElInput"];
+ ElInputNumber: (typeof import("element-plus"))["ElInputNumber"];
+ ElLink: (typeof import("element-plus"))["ElLink"];
+ ElMain: (typeof import("element-plus"))["ElMain"];
+ ElMenu: (typeof import("element-plus"))["ElMenu"];
+ ElMenuItem: (typeof import("element-plus"))["ElMenuItem"];
+ ElMenuItemGroup: (typeof import("element-plus"))["ElMenuItemGroup"];
+ ElOption: (typeof import("element-plus"))["ElOption"];
+ ElOptionGroup: (typeof import("element-plus"))["ElOptionGroup"];
+ ElPageHeader: (typeof import("element-plus"))["ElPageHeader"];
+ ElPagination: (typeof import("element-plus"))["ElPagination"];
+ ElPopconfirm: (typeof import("element-plus"))["ElPopconfirm"];
+ ElPopper: (typeof import("element-plus"))["ElPopper"];
+ ElPopover: (typeof import("element-plus"))["ElPopover"];
+ ElProgress: (typeof import("element-plus"))["ElProgress"];
+ ElRadio: (typeof import("element-plus"))["ElRadio"];
+ ElRadioButton: (typeof import("element-plus"))["ElRadioButton"];
+ ElRadioGroup: (typeof import("element-plus"))["ElRadioGroup"];
+ ElRate: (typeof import("element-plus"))["ElRate"];
+ ElRow: (typeof import("element-plus"))["ElRow"];
+ ElScrollbar: (typeof import("element-plus"))["ElScrollbar"];
+ ElSelect: (typeof import("element-plus"))["ElSelect"];
+ ElSlider: (typeof import("element-plus"))["ElSlider"];
+ ElStep: (typeof import("element-plus"))["ElStep"];
+ ElSteps: (typeof import("element-plus"))["ElSteps"];
+ ElSubMenu: (typeof import("element-plus"))["ElSubMenu"];
+ ElSwitch: (typeof import("element-plus"))["ElSwitch"];
+ ElTabPane: (typeof import("element-plus"))["ElTabPane"];
+ ElTable: (typeof import("element-plus"))["ElTable"];
+ ElTableColumn: (typeof import("element-plus"))["ElTableColumn"];
+ ElTabs: (typeof import("element-plus"))["ElTabs"];
+ ElTag: (typeof import("element-plus"))["ElTag"];
+ ElText: (typeof import("element-plus"))["ElText"];
+ ElTimePicker: (typeof import("element-plus"))["ElTimePicker"];
+ ElTimeSelect: (typeof import("element-plus"))["ElTimeSelect"];
+ ElTimeline: (typeof import("element-plus"))["ElTimeline"];
+ ElTimelineItem: (typeof import("element-plus"))["ElTimelineItem"];
+ ElTooltip: (typeof import("element-plus"))["ElTooltip"];
+ ElTransfer: (typeof import("element-plus"))["ElTransfer"];
+ ElTree: (typeof import("element-plus"))["ElTree"];
+ ElTreeV2: (typeof import("element-plus"))["ElTreeV2"];
+ ElTreeSelect: (typeof import("element-plus"))["ElTreeSelect"];
+ ElUpload: (typeof import("element-plus"))["ElUpload"];
+ ElSpace: (typeof import("element-plus"))["ElSpace"];
+ ElSkeleton: (typeof import("element-plus"))["ElSkeleton"];
+ ElSkeletonItem: (typeof import("element-plus"))["ElSkeletonItem"];
+ ElStatistic: (typeof import("element-plus"))["ElStatistic"];
+ ElCheckTag: (typeof import("element-plus"))["ElCheckTag"];
+ ElDescriptions: (typeof import("element-plus"))["ElDescriptions"];
+ ElDescriptionsItem: (typeof import("element-plus"))["ElDescriptionsItem"];
+ ElResult: (typeof import("element-plus"))["ElResult"];
+ ElSelectV2: (typeof import("element-plus"))["ElSelectV2"];
+ ElWatermark: (typeof import("element-plus"))["ElWatermark"];
+ ElTour: (typeof import("element-plus"))["ElTour"];
+ ElTourStep: (typeof import("element-plus"))["ElTourStep"];
+ ElSegmented: (typeof import("element-plus"))["ElSegmented"];
+ }
+
+ interface ComponentCustomProperties {
+ $message: (typeof import("element-plus"))["ElMessage"];
+ $notify: (typeof import("element-plus"))["ElNotification"];
+ $msgbox: (typeof import("element-plus"))["ElMessageBox"];
+ $messageBox: (typeof import("element-plus"))["ElMessageBox"];
+ $alert: (typeof import("element-plus"))["ElMessageBox"]["alert"];
+ $confirm: (typeof import("element-plus"))["ElMessageBox"]["confirm"];
+ $prompt: (typeof import("element-plus"))["ElMessageBox"]["prompt"];
+ $loading: (typeof import("element-plus"))["ElLoadingService"];
+ }
+}
+
+export {};
diff --git a/src/types/global.d.ts b/src/types/global.d.ts
new file mode 100644
index 0000000..45fe1b1
--- /dev/null
+++ b/src/types/global.d.ts
@@ -0,0 +1,194 @@
+import type { ECharts } from "echarts";
+import type { TableColumns } from "@pureadmin/table";
+
+/**
+ * 全局类型声明,无需引入直接在 `.vue` 、`.ts` 、`.tsx` 文件使用即可获得类型提示
+ */
+declare global {
+ /**
+ * 平台的名称、版本、运行所需的`node`和`pnpm`版本、依赖、最后构建时间的类型提示
+ */
+ const __APP_INFO__: {
+ pkg: {
+ name: string;
+ version: string;
+ engines: {
+ node: string;
+ pnpm: string;
+ };
+ dependencies: Recordable;
+ devDependencies: Recordable;
+ };
+ lastBuildTime: string;
+ };
+
+ /**
+ * Window 的类型提示
+ */
+ interface Window {
+ // Global vue app instance
+ __APP__: App;
+ webkitCancelAnimationFrame: (handle: number) => void;
+ mozCancelAnimationFrame: (handle: number) => void;
+ oCancelAnimationFrame: (handle: number) => void;
+ msCancelAnimationFrame: (handle: number) => void;
+ webkitRequestAnimationFrame: (callback: FrameRequestCallback) => number;
+ mozRequestAnimationFrame: (callback: FrameRequestCallback) => number;
+ oRequestAnimationFrame: (callback: FrameRequestCallback) => number;
+ msRequestAnimationFrame: (callback: FrameRequestCallback) => number;
+ }
+
+ /**
+ * Document 的类型提示
+ */
+ interface Document {
+ webkitFullscreenElement?: Element;
+ mozFullScreenElement?: Element;
+ msFullscreenElement?: Element;
+ }
+
+ /**
+ * 打包压缩格式的类型声明
+ */
+ type ViteCompression =
+ | "none"
+ | "gzip"
+ | "brotli"
+ | "both"
+ | "gzip-clear"
+ | "brotli-clear"
+ | "both-clear";
+
+ /**
+ * 全局自定义环境变量的类型声明
+ * @see {@link https://pure-admin.github.io/pure-admin-doc/pages/config/#%E5%85%B7%E4%BD%93%E9%85%8D%E7%BD%AE}
+ */
+ interface ViteEnv {
+ VITE_PORT: number;
+ VITE_APP_URL: string;
+ VITE_PUBLIC_PATH: string;
+ VITE_ROUTER_HISTORY: string;
+ VITE_CDN: boolean;
+ VITE_HIDE_HOME: string;
+ VITE_COMPRESSION: ViteCompression;
+ }
+
+ /**
+ * 继承 `@pureadmin/table` 的 `TableColumns` ,方便全局直接调用
+ */
+ interface TableColumnList extends Array {}
+
+ /**
+ * 对应 `public/platform-config.json` 文件的类型声明
+ * @see {@link https://pure-admin.github.io/pure-admin-doc/pages/config/#platform-config-json}
+ */
+ interface PlatformConfigs {
+ Version?: string;
+ Title?: string;
+ FixedHeader?: boolean;
+ HiddenSideBar?: boolean;
+ MultiTagsCache?: boolean;
+ MaxTagsLevel?: number;
+ KeepAlive?: boolean;
+ Locale?: string;
+ Layout?: string;
+ Theme?: string;
+ DarkMode?: boolean;
+ OverallStyle?: string;
+ Grey?: boolean;
+ Weak?: boolean;
+ HideTabs?: boolean;
+ HideFooter?: boolean;
+ Stretch?: boolean | number;
+ SidebarStatus?: boolean;
+ EpThemeColor?: string;
+ ShowLogo?: boolean;
+ ShowModel?: string;
+ MenuArrowIconNoTransition?: boolean;
+ CachingAsyncRoutes?: boolean;
+ TooltipEffect?: Effect;
+ ResponsiveStorageNameSpace?: string;
+ MenuSearchHistory?: number;
+ }
+
+ /**
+ * 与 `PlatformConfigs` 类型不同,这里是缓存到浏览器本地存储的类型声明
+ * @see {@link https://pure-admin.github.io/pure-admin-doc/pages/config/#platform-config-json}
+ */
+ interface StorageConfigs {
+ version?: string;
+ title?: string;
+ fixedHeader?: boolean;
+ hiddenSideBar?: boolean;
+ multiTagsCache?: boolean;
+ keepAlive?: boolean;
+ locale?: string;
+ layout?: string;
+ theme?: string;
+ darkMode?: boolean;
+ grey?: boolean;
+ weak?: boolean;
+ hideTabs?: boolean;
+ hideFooter?: boolean;
+ sidebarStatus?: boolean;
+ epThemeColor?: string;
+ themeColor?: string;
+ overallStyle?: string;
+ showLogo?: boolean;
+ showModel?: string;
+ menuSearchHistory?: number;
+ username?: string;
+ }
+
+ /**
+ * `responsive-storage` 本地响应式 `storage` 的类型声明
+ */
+ interface ResponsiveStorage {
+ locale: {
+ locale?: string;
+ };
+ layout: {
+ layout?: string;
+ theme?: string;
+ darkMode?: boolean;
+ sidebarStatus?: boolean;
+ epThemeColor?: string;
+ themeColor?: string;
+ overallStyle?: string;
+ };
+ configure: {
+ grey?: boolean;
+ weak?: boolean;
+ hideTabs?: boolean;
+ hideFooter?: boolean;
+ showLogo?: boolean;
+ showModel?: string;
+ multiTagsCache?: boolean;
+ stretch?: boolean | number;
+ };
+ tags?: Array;
+ }
+
+ /**
+ * 平台里所有组件实例都能访问到的全局属性对象的类型声明
+ */
+ interface GlobalPropertiesApi {
+ $echarts: ECharts;
+ $storage: ResponsiveStorage;
+ $config: PlatformConfigs;
+ }
+
+ /**
+ * 扩展 `Element`
+ */
+ interface Element {
+ // v-ripple 作用于 src/directives/ripple/index.ts 文件
+ _ripple?: {
+ enabled?: boolean;
+ centered?: boolean;
+ class?: string;
+ circle?: boolean;
+ touched?: boolean;
+ };
+ }
+}
diff --git a/src/types/index.d.ts b/src/types/index.d.ts
new file mode 100644
index 0000000..67d7459
--- /dev/null
+++ b/src/types/index.d.ts
@@ -0,0 +1,82 @@
+// 此文件跟同级目录的 global.d.ts 文件一样也是全局类型声明,只不过这里存放一些零散的全局类型,无需引入直接在 .vue 、.ts 、.tsx 文件使用即可获得类型提示
+
+type RefType = T | null;
+
+type EmitType = (event: string, ...args: any[]) => void;
+
+type TargetContext = "_self" | "_blank";
+
+type ComponentRef =
+ ComponentElRef | null;
+
+type ElRef = Nullable;
+
+type ForDataType = {
+ [P in T]?: ForDataType;
+};
+
+type AnyFunction = (...args: any[]) => T;
+
+type PropType = VuePropType;
+
+type Writable = {
+ -readonly [P in keyof T]: T[P];
+};
+
+type Nullable = T | null;
+
+type NonNullable = T extends null | undefined ? never : T;
+
+type Recordable = Record;
+
+type ReadonlyRecordable = {
+ readonly [key: string]: T;
+};
+
+type Indexable = {
+ [key: string]: T;
+};
+
+type DeepPartial = {
+ [P in keyof T]?: DeepPartial;
+};
+
+type Without = { [P in Exclude]?: never };
+
+type Exclusive = (Without & U) | (Without & T);
+
+type TimeoutHandle = ReturnType;
+
+type IntervalHandle = ReturnType;
+
+type Effect = "light" | "dark";
+
+interface ChangeEvent extends Event {
+ target: HTMLInputElement;
+}
+
+interface WheelEvent {
+ path?: EventTarget[];
+}
+
+interface ImportMetaEnv extends ViteEnv {
+ __: unknown;
+}
+
+interface Fn {
+ (...arg: T[]): R;
+}
+
+interface PromiseFn {
+ (...arg: T[]): Promise;
+}
+
+interface ComponentElRef {
+ $el: T;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+function parseInt(s: string | number, radix?: number): number;
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+function parseFloat(string: string | number): number;
diff --git a/src/types/router.d.ts b/src/types/router.d.ts
new file mode 100644
index 0000000..a03ba0e
--- /dev/null
+++ b/src/types/router.d.ts
@@ -0,0 +1,108 @@
+// 全局路由类型声明
+
+import type { RouteComponent, RouteLocationNormalized } from "vue-router";
+import type { FunctionalComponent } from "vue";
+
+declare global {
+ interface ToRouteType extends RouteLocationNormalized {
+ meta: CustomizeRouteMeta;
+ }
+
+ /**
+ * @description 完整子路由的`meta`配置表
+ */
+ interface CustomizeRouteMeta {
+ /** 菜单名称(兼容国际化、非国际化,如何用国际化的写法就必须在根目录的`locales`文件夹下对应添加) `必填` */
+ title: string;
+ /** 菜单图标 `可选` */
+ icon?: string | FunctionalComponent | IconifyIcon;
+ /** 菜单名称右侧的额外图标 */
+ extraIcon?: string | FunctionalComponent | IconifyIcon;
+ /** 是否在菜单中显示(默认`true`)`可选` */
+ showLink?: boolean;
+ /** 是否显示父级菜单 `可选` */
+ showParent?: boolean;
+ /** 页面级别权限设置 `可选` */
+ roles?: Array;
+ /** 按钮级别权限设置 `可选` */
+ auths?: Array;
+ /** 路由组件缓存(开启 `true`、关闭 `false`)`可选` */
+ keepAlive?: boolean;
+ /** 内嵌的`iframe`链接 `可选` */
+ frameSrc?: string;
+ /** `iframe`页是否开启首次加载动画(默认`true`)`可选` */
+ frameLoading?: boolean;
+ /** 页面加载动画(两种模式,第二种权重更高,第一种直接采用`vue`内置的`transitions`动画,第二种是使用`animate.css`编写进、离场动画,平台更推荐使用第二种模式,已经内置了`animate.css`,直接写对应的动画名即可)`可选` */
+ transition?: {
+ /**
+ * @description 当前路由动画效果
+ * @see {@link https://next.router.vuejs.org/guide/advanced/transitions.html#transitions}
+ * @see animate.css {@link https://animate.style}
+ */
+ name?: string;
+ /** 进场动画 */
+ enterTransition?: string;
+ /** 离场动画 */
+ leaveTransition?: string;
+ };
+ /** 当前菜单名称或自定义信息禁止添加到标签页(默认`false`) */
+ hiddenTag?: boolean;
+ /** 当前菜单名称是否固定显示在标签页且不可关闭(默认`false`) */
+ fixedTag?: boolean;
+ /** 动态路由可打开的最大数量 `可选` */
+ dynamicLevel?: number;
+ /** 将某个菜单激活
+ * (主要用于通过`query`或`params`传参的路由,当它们通过配置`showLink: false`后不在菜单中显示,就不会有任何菜单高亮,
+ * 而通过设置`activePath`指定激活菜单即可获得高亮,`activePath`为指定激活菜单的`path`)
+ */
+ activePath?: string;
+ }
+
+ /**
+ * @description 完整子路由配置表
+ */
+ interface RouteChildrenConfigsTable {
+ /** 子路由地址 `必填` */
+ path: string;
+ /** 路由名字(对应不要重复,和当前组件的`name`保持一致)`必填` */
+ name?: string;
+ /** 路由重定向 `可选` */
+ redirect?: string;
+ /** 按需加载组件 `可选` */
+ component?: RouteComponent;
+ meta?: CustomizeRouteMeta;
+ /** 子路由配置项 */
+ children?: Array;
+ }
+
+ /**
+ * @description 整体路由配置表(包括完整子路由)
+ */
+ interface RouteConfigsTable {
+ /** 路由地址 `必填` */
+ path: string;
+ /** 路由名字(保持唯一)`可选` */
+ name?: string;
+ /** `Layout`组件 `可选` */
+ component?: RouteComponent;
+ /** 路由重定向 `可选` */
+ redirect?: string;
+ meta?: {
+ /** 菜单名称(兼容国际化、非国际化,如何用国际化的写法就必须在根目录的`locales`文件夹下对应添加)`必填` */
+ title: string;
+ /** 菜单图标 `可选` */
+ icon?: string | FunctionalComponent | IconifyIcon;
+ /** 是否在菜单中显示(默认`true`)`可选` */
+ showLink?: boolean;
+ /** 菜单升序排序,值越高排的越后(只针对顶级路由)`可选` */
+ rank?: number;
+ };
+ /** 子路由配置项 */
+ children?: Array;
+ }
+}
+
+// https://router.vuejs.org/zh/guide/advanced/meta.html#typescript
+declare module "vue-router" {
+ interface RouteMeta extends CustomizeRouteMeta {}
+}
diff --git a/src/types/shims-tsx.d.ts b/src/types/shims-tsx.d.ts
new file mode 100644
index 0000000..199f979
--- /dev/null
+++ b/src/types/shims-tsx.d.ts
@@ -0,0 +1,22 @@
+import Vue, { VNode } from "vue";
+
+declare module "*.tsx" {
+ import Vue from "compatible-vue";
+ export default Vue;
+}
+
+declare global {
+ namespace JSX {
+ interface Element extends VNode {}
+ interface ElementClass extends Vue {}
+ interface ElementAttributesProperty {
+ $props: any;
+ }
+ interface IntrinsicElements {
+ [elem: string]: any;
+ }
+ interface IntrinsicAttributes {
+ [elem: string]: any;
+ }
+ }
+}
diff --git a/src/types/shims-vue.d.ts b/src/types/shims-vue.d.ts
new file mode 100644
index 0000000..c7260cc
--- /dev/null
+++ b/src/types/shims-vue.d.ts
@@ -0,0 +1,10 @@
+declare module "*.vue" {
+ import type { DefineComponent } from "vue";
+ const component: DefineComponent<{}, {}, any>;
+ export default component;
+}
+
+declare module "*.scss" {
+ const scss: Record;
+ export default scss;
+}
diff --git a/src/types/store/baseStoreState.ts b/src/types/store/baseStoreState.ts
new file mode 100644
index 0000000..b9fe68b
--- /dev/null
+++ b/src/types/store/baseStoreState.ts
@@ -0,0 +1,6 @@
+// 返回响应内容
+export interface Result {
+ code: number;
+ data: T;
+ message: string;
+}
diff --git a/src/types/store/i18n.d.ts b/src/types/store/i18n.d.ts
new file mode 100644
index 0000000..f61f9eb
--- /dev/null
+++ b/src/types/store/i18n.d.ts
@@ -0,0 +1,4 @@
+// i18n 仓库state
+export interface I18nState {
+ i18n: any;
+}
diff --git a/src/utils/auth.ts b/src/utils/auth.ts
new file mode 100644
index 0000000..f5eba7d
--- /dev/null
+++ b/src/utils/auth.ts
@@ -0,0 +1,141 @@
+import Cookies from "js-cookie";
+import { useUserStoreHook } from "@/store/modules/user";
+import { isIncludeAllChildren, isString, storageLocal } from "@pureadmin/utils";
+
+export interface DataInfo {
+ /** token */
+ accessToken: string;
+ /** `accessToken`的过期时间(时间戳) */
+ expires: T;
+ /** 用于调用刷新accessToken的接口时所需的token */
+ refreshToken: string;
+ /** 头像 */
+ avatar?: string;
+ /** 用户名 */
+ username?: string;
+ /** 昵称 */
+ nickname?: string;
+ /** 当前登录用户的角色 */
+ roles?: Array;
+ /** 当前登录用户的按钮级别权限 */
+ permissions?: Array;
+}
+
+export const userKey = "user-info";
+export const TokenKey = "authorized-token";
+/**
+ * 通过`multiple-tabs`是否在`cookie`中,判断用户是否已经登录系统,
+ * 从而支持多标签页打开已经登录的系统后无需再登录。
+ * 浏览器完全关闭后`multiple-tabs`将自动从`cookie`中销毁,
+ * 再次打开浏览器需要重新登录系统
+ * */
+export const multipleTabsKey = "multiple-tabs";
+
+/** 获取`token` */
+export function getToken(): DataInfo {
+ // 此处与`TokenKey`相同,此写法解决初始化时`Cookies`中不存在`TokenKey`报错
+ return Cookies.get(TokenKey)
+ ? JSON.parse(Cookies.get(TokenKey))
+ : storageLocal().getItem(userKey);
+}
+
+/**
+ * @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`当浏览器完全关闭后自动销毁)
+ */
+export function setToken(data: DataInfo) {
+ let expires = 0;
+ const { accessToken, refreshToken } = data;
+ const { isRemembered, loginDay } = useUserStoreHook();
+ expires = new Date(data.expires).getTime(); // 如果后端直接设置时间戳,将此处代码改为expires = data.expires,然后把上面的DataInfo改成DataInfo即可
+ const cookieString = JSON.stringify({ accessToken, expires, refreshToken });
+
+ expires > 0
+ ? Cookies.set(TokenKey, cookieString, {
+ expires: (expires - Date.now()) / 86400000
+ })
+ : Cookies.set(TokenKey, cookieString);
+
+ Cookies.set(
+ multipleTabsKey,
+ "true",
+ isRemembered
+ ? {
+ expires: loginDay
+ }
+ : {}
+ );
+
+ function setUserKey({ avatar, username, nickname, roles, permissions }) {
+ useUserStoreHook().SET_AVATAR(avatar);
+ useUserStoreHook().SET_USERNAME(username);
+ useUserStoreHook().SET_NICKNAME(nickname);
+ useUserStoreHook().SET_ROLES(roles);
+ useUserStoreHook().SET_PERMS(permissions);
+ storageLocal().setItem(userKey, {
+ refreshToken,
+ expires,
+ avatar,
+ username,
+ nickname,
+ roles,
+ permissions
+ });
+ }
+
+ if (data.username && data.roles) {
+ const { username, roles } = data;
+ setUserKey({
+ avatar: data?.avatar ?? "",
+ username,
+ nickname: data?.nickname ?? "",
+ roles,
+ permissions: data?.permissions ?? []
+ });
+ } else {
+ const avatar =
+ storageLocal().getItem>(userKey)?.avatar ?? "";
+ const username =
+ storageLocal().getItem>(userKey)?.username ?? "";
+ const nickname =
+ storageLocal().getItem>(userKey)?.nickname ?? "";
+ const roles =
+ storageLocal().getItem>(userKey)?.roles ?? [];
+ const permissions =
+ storageLocal().getItem>(userKey)?.permissions ?? [];
+ setUserKey({
+ avatar,
+ username,
+ nickname,
+ roles,
+ permissions
+ });
+ }
+}
+
+/** 删除`token`以及key值为`user-info`的localStorage信息 */
+export function removeToken() {
+ Cookies.remove(TokenKey);
+ Cookies.remove(multipleTabsKey);
+ storageLocal().removeItem(userKey);
+}
+
+/** 格式化token(jwt格式) */
+export const formatToken = (token: string): string => {
+ return "Bearer " + token;
+};
+
+/** 是否有按钮级别的权限(根据登录接口返回的`permissions`字段进行判断)*/
+export const hasPerms = (value: string | Array): boolean => {
+ if (!value) return false;
+ const allPerms = "*:*:*";
+ const { permissions } = useUserStoreHook();
+ if (!permissions) return false;
+ if (permissions.length === 1 && permissions[0] === allPerms) return true;
+ const isAuths = isString(value)
+ ? permissions.includes(value)
+ : isIncludeAllChildren(value, permissions);
+ return isAuths ? true : false;
+};
diff --git a/src/utils/globalPolyfills.ts b/src/utils/globalPolyfills.ts
new file mode 100644
index 0000000..e9bc9a8
--- /dev/null
+++ b/src/utils/globalPolyfills.ts
@@ -0,0 +1,7 @@
+// 如果项目出现 `global is not defined` 报错,可能是您引入某个库的问题,比如 aws-sdk-js https://github.com/aws/aws-sdk-js
+// 解决办法就是将该文件引入 src/main.ts 即可 import "@/utils/globalPolyfills";
+if (typeof (window as any).global === "undefined") {
+ (window as any).global = window;
+}
+
+export {};
diff --git a/src/utils/localforage/index.ts b/src/utils/localforage/index.ts
new file mode 100644
index 0000000..013545f
--- /dev/null
+++ b/src/utils/localforage/index.ts
@@ -0,0 +1,109 @@
+import forage from "localforage";
+import type { LocalForage, ProxyStorage, ExpiresData } from "./types.d";
+
+class StorageProxy implements ProxyStorage {
+ protected storage: LocalForage;
+ constructor(storageModel) {
+ this.storage = storageModel;
+ this.storage.config({
+ // 首选IndexedDB作为第一驱动,不支持IndexedDB会自动降级到localStorage(WebSQL被弃用,详情看https://developer.chrome.com/blog/deprecating-web-sql)
+ driver: [this.storage.INDEXEDDB, this.storage.LOCALSTORAGE],
+ name: "pure-admin"
+ });
+ }
+
+ /**
+ * @description 将对应键名的数据保存到离线仓库
+ * @param k 键名
+ * @param v 键值
+ * @param m 缓存时间(单位`分`,默认`0`分钟,永久缓存)
+ */
+ public async setItem(k: string, v: T, m = 0): Promise {
+ return new Promise((resolve, reject) => {
+ this.storage
+ .setItem(k, {
+ data: v,
+ expires: m ? new Date().getTime() + m * 60 * 1000 : 0
+ })
+ .then(value => {
+ resolve(value.data);
+ })
+ .catch(err => {
+ reject(err);
+ });
+ });
+ }
+
+ /**
+ * @description 从离线仓库中获取对应键名的值
+ * @param k 键名
+ */
+ public async getItem(k: string): Promise {
+ return new Promise((resolve, reject) => {
+ this.storage
+ .getItem(k)
+ .then((value: ExpiresData) => {
+ value && (value.expires > new Date().getTime() || value.expires === 0)
+ ? resolve(value.data)
+ : resolve(null);
+ })
+ .catch(err => {
+ reject(err);
+ });
+ });
+ }
+
+ /**
+ * @description 从离线仓库中删除对应键名的值
+ * @param k 键名
+ */
+ public async removeItem(k: string) {
+ return new Promise((resolve, reject) => {
+ this.storage
+ .removeItem(k)
+ .then(() => {
+ resolve();
+ })
+ .catch(err => {
+ reject(err);
+ });
+ });
+ }
+
+ /**
+ * @description 从离线仓库中删除所有的键名,重置数据库
+ */
+ public async clear() {
+ return new Promise((resolve, reject) => {
+ this.storage
+ .clear()
+ .then(() => {
+ resolve();
+ })
+ .catch(err => {
+ reject(err);
+ });
+ });
+ }
+
+ /**
+ * @description 获取数据仓库中所有的key
+ */
+ public async keys() {
+ return new Promise((resolve, reject) => {
+ this.storage
+ .keys()
+ .then(keys => {
+ resolve(keys);
+ })
+ .catch(err => {
+ reject(err);
+ });
+ });
+ }
+}
+
+/**
+ * 二次封装 [localforage](https://localforage.docschina.org/) 支持设置过期时间,提供完整的类型提示
+ */
+export const localForage = () => new StorageProxy(forage);
diff --git a/src/utils/localforage/types.d.ts b/src/utils/localforage/types.d.ts
new file mode 100644
index 0000000..b013c5b
--- /dev/null
+++ b/src/utils/localforage/types.d.ts
@@ -0,0 +1,166 @@
+// https://github.com/localForage/localForage/blob/master/typings/localforage.d.ts
+
+interface LocalForageDbInstanceOptions {
+ name?: string;
+
+ storeName?: string;
+}
+
+interface LocalForageOptions extends LocalForageDbInstanceOptions {
+ driver?: string | string[];
+
+ size?: number;
+
+ version?: number;
+
+ description?: string;
+}
+
+interface LocalForageDbMethodsCore {
+ getItem(
+ key: string,
+ callback?: (err: any, value: T | null) => void
+ ): Promise;
+
+ setItem(
+ key: string,
+ value: T,
+ callback?: (err: any, value: T) => void
+ ): Promise;
+
+ removeItem(key: string, callback?: (err: any) => void): Promise;
+
+ clear(callback?: (err: any) => void): Promise;
+
+ length(callback?: (err: any, numberOfKeys: number) => void): Promise;
+
+ key(
+ keyIndex: number,
+ callback?: (err: any, key: string) => void
+ ): Promise;
+
+ keys(callback?: (err: any, keys: string[]) => void): Promise;
+
+ iterate(
+ iteratee: (value: T, key: string, iterationNumber: number) => U,
+ callback?: (err: any, result: U) => void
+ ): Promise;
+}
+
+interface LocalForageDropInstanceFn {
+ (
+ dbInstanceOptions?: LocalForageDbInstanceOptions,
+ callback?: (err: any) => void
+ ): Promise;
+}
+
+interface LocalForageDriverMethodsOptional {
+ dropInstance?: LocalForageDropInstanceFn;
+}
+
+// duplicating LocalForageDriverMethodsOptional to preserve TS v2.0 support,
+// since Partial<> isn't supported there
+interface LocalForageDbMethodsOptional {
+ dropInstance: LocalForageDropInstanceFn;
+}
+
+interface LocalForageDriverDbMethods
+ extends LocalForageDbMethodsCore,
+ LocalForageDriverMethodsOptional {}
+
+interface LocalForageDriverSupportFunc {
+ (): Promise;
+}
+
+interface LocalForageDriver extends LocalForageDriverDbMethods {
+ _driver: string;
+
+ _initStorage(options: LocalForageOptions): void;
+
+ _support?: boolean | LocalForageDriverSupportFunc;
+}
+
+interface LocalForageSerializer {
+ serialize(
+ value: T | ArrayBuffer | Blob,
+ callback: (value: string, error: any) => void
+ ): void;
+
+ deserialize(value: string): T | ArrayBuffer | Blob;
+
+ stringToBuffer(serializedString: string): ArrayBuffer;
+
+ bufferToString(buffer: ArrayBuffer): string;
+}
+
+interface LocalForageDbMethods
+ extends LocalForageDbMethodsCore,
+ LocalForageDbMethodsOptional {}
+
+export interface LocalForage extends LocalForageDbMethods {
+ LOCALSTORAGE: string;
+ WEBSQL: string;
+ INDEXEDDB: string;
+
+ /**
+ * Set and persist localForage options. This must be called before any other calls to localForage are made, but can be called after localForage is loaded.
+ * If you set any config values with this method they will persist after driver changes, so you can call config() then setDriver()
+ * @param {LocalForageOptions} options?
+ */
+ config(options: LocalForageOptions): boolean;
+ config(options: string): any;
+ config(): LocalForageOptions;
+
+ /**
+ * Create a new instance of localForage to point to a different store.
+ * All the configuration options used by config are supported.
+ * @param {LocalForageOptions} options
+ */
+ createInstance(options: LocalForageOptions): LocalForage;
+
+ driver(): string;
+
+ /**
+ * Force usage of a particular driver or drivers, if available.
+ * @param {string} driver
+ */
+ setDriver(
+ driver: string | string[],
+ callback?: () => void,
+ errorCallback?: (error: any) => void
+ ): Promise;
+
+ defineDriver(
+ driver: LocalForageDriver,
+ callback?: () => void,
+ errorCallback?: (error: any) => void
+ ): Promise;
+
+ /**
+ * Return a particular driver
+ * @param {string} driver
+ */
+ getDriver(driver: string): Promise;
+
+ getSerializer(
+ callback?: (serializer: LocalForageSerializer) => void
+ ): Promise;
+
+ supports(driverName: string): boolean;
+
+ ready(callback?: (error: any) => void): Promise;
+}
+
+// Customize
+
+export interface ProxyStorage {
+ setItem(k: string, v: T, m: number): Promise;
+ getItem(k: string): Promise;
+ removeItem(k: string): Promise;
+ clear(): Promise;
+}
+
+export interface ExpiresData {
+ data: T;
+ expires: number;
+}
diff --git a/src/utils/message.ts b/src/utils/message.ts
new file mode 100644
index 0000000..40898ac
--- /dev/null
+++ b/src/utils/message.ts
@@ -0,0 +1,85 @@
+import type { VNode } from "vue";
+import { isFunction } from "@pureadmin/utils";
+import { type MessageHandler, ElMessage } from "element-plus";
+
+type messageStyle = "el" | "antd";
+type messageTypes = "info" | "success" | "warning" | "error";
+
+interface MessageParams {
+ /** 消息类型,可选 `info` 、`success` 、`warning` 、`error` ,默认 `info` */
+ type?: messageTypes;
+ /** 自定义图标,该属性会覆盖 `type` 的图标 */
+ icon?: any;
+ /** 是否将 `message` 属性作为 `HTML` 片段处理,默认 `false` */
+ dangerouslyUseHTMLString?: boolean;
+ /** 消息风格,可选 `el` 、`antd` ,默认 `antd` */
+ customClass?: messageStyle;
+ /** 显示时间,单位为毫秒。设为 `0` 则不会自动关闭,`element-plus` 默认是 `3000` ,平台改成默认 `2000` */
+ duration?: number;
+ /** 是否显示关闭按钮,默认值 `false` */
+ showClose?: boolean;
+ /** 文字是否居中,默认值 `false` */
+ center?: boolean;
+ /** `Message` 距离窗口顶部的偏移量,默认 `20` */
+ offset?: number;
+ /** 设置组件的根元素,默认 `document.body` */
+ appendTo?: string | HTMLElement;
+ /** 合并内容相同的消息,不支持 `VNode` 类型的消息,默认值 `false` */
+ grouping?: boolean;
+ /** 关闭时的回调函数, 参数为被关闭的 `message` 实例 */
+ onClose?: Function | null;
+}
+
+/** 用法非常简单,参考 src/views/components/message/index.vue 文件 */
+
+/**
+ * `Message` 消息提示函数
+ */
+const message = (
+ message: string | VNode | (() => VNode),
+ params?: MessageParams
+): MessageHandler => {
+ if (!params) {
+ return ElMessage({
+ message,
+ customClass: "pure-message"
+ });
+ } else {
+ const {
+ icon,
+ type = "info",
+ dangerouslyUseHTMLString = false,
+ customClass = "antd",
+ duration = 2000,
+ showClose = false,
+ center = false,
+ offset = 20,
+ appendTo = document.body,
+ grouping = false,
+ onClose
+ } = params;
+
+ return ElMessage({
+ message,
+ type,
+ icon,
+ dangerouslyUseHTMLString,
+ duration,
+ showClose,
+ center,
+ offset,
+ appendTo,
+ grouping,
+ // 全局搜 pure-message 即可知道该类的样式位置
+ customClass: customClass === "antd" ? "pure-message" : "",
+ onClose: () => (isFunction(onClose) ? onClose() : null)
+ });
+ }
+};
+
+/**
+ * 关闭所有 `Message` 消息提示函数
+ */
+const closeAllMessage = (): void => ElMessage.closeAll();
+
+export { message, closeAllMessage };
diff --git a/src/utils/mitt.ts b/src/utils/mitt.ts
new file mode 100644
index 0000000..63816f1
--- /dev/null
+++ b/src/utils/mitt.ts
@@ -0,0 +1,13 @@
+import type { Emitter } from "mitt";
+import mitt from "mitt";
+
+/** 全局公共事件需要在此处添加类型 */
+type Events = {
+ openPanel: string;
+ tagViewsChange: string;
+ tagViewsShowModel: string;
+ logoChange: boolean;
+ changLayoutRoute: string;
+};
+
+export const emitter: Emitter = mitt();
diff --git a/src/utils/preventDefault.ts b/src/utils/preventDefault.ts
new file mode 100644
index 0000000..42da8df
--- /dev/null
+++ b/src/utils/preventDefault.ts
@@ -0,0 +1,28 @@
+import { useEventListener } from "@vueuse/core";
+
+/** 是否为`img`标签 */
+function isImgElement(element) {
+ return typeof HTMLImageElement !== "undefined"
+ ? element instanceof HTMLImageElement
+ : element.tagName.toLowerCase() === "img";
+}
+
+// 在 src/main.ts 引入并调用即可 import { addPreventDefault } from "@/utils/preventDefault"; addPreventDefault();
+export const addPreventDefault = () => {
+ // 阻止通过键盘F12快捷键打开浏览器开发者工具面板
+ useEventListener(
+ window.document,
+ "keydown",
+ ev => ev.key === "F12" && ev.preventDefault()
+ );
+ // 阻止浏览器默认的右键菜单弹出(不会影响自定义右键事件)
+ useEventListener(window.document, "contextmenu", ev => ev.preventDefault());
+ // 阻止页面元素选中
+ useEventListener(window.document, "selectstart", ev => ev.preventDefault());
+ // 浏览器中图片通常默认是可拖动的,并且可以在新标签页或窗口中打开,或者将其拖动到其他应用程序中,此处将其禁用,使其默认不可拖动
+ useEventListener(
+ window.document,
+ "dragstart",
+ ev => isImgElement(ev?.target) && ev.preventDefault()
+ );
+};
diff --git a/src/utils/print.ts b/src/utils/print.ts
new file mode 100644
index 0000000..6d2051c
--- /dev/null
+++ b/src/utils/print.ts
@@ -0,0 +1,213 @@
+interface PrintFunction {
+ extendOptions: Function;
+ getStyle: Function;
+ setDomHeight: Function;
+ toPrint: Function;
+}
+
+const Print = function (dom, options?: object): PrintFunction {
+ options = options || {};
+ // @ts-expect-error
+ if (!(this instanceof Print)) return new Print(dom, options);
+ this.conf = {
+ styleStr: "",
+ // Elements that need to dynamically get and set the height
+ setDomHeightArr: [],
+ // Callback before printing
+ printBeforeFn: null,
+ // Callback after printing
+ printDoneCallBack: null
+ };
+ for (const key in this.conf) {
+ if (key && options.hasOwnProperty(key)) {
+ this.conf[key] = options[key];
+ }
+ }
+ if (typeof dom === "string") {
+ this.dom = document.querySelector(dom);
+ } else {
+ this.dom = this.isDOM(dom) ? dom : dom.$el;
+ }
+ if (this.conf.setDomHeightArr && this.conf.setDomHeightArr.length) {
+ this.setDomHeight(this.conf.setDomHeightArr);
+ }
+ this.init();
+};
+
+Print.prototype = {
+ /**
+ * init
+ */
+ init: function (): void {
+ const content = this.getStyle() + this.getHtml();
+ this.writeIframe(content);
+ },
+ /**
+ * Configuration property extension
+ * @param {Object} obj
+ * @param {Object} obj2
+ */
+ extendOptions: function (obj, obj2: T): T {
+ for (const k in obj2) {
+ obj[k] = obj2[k];
+ }
+ return obj;
+ },
+ /**
+ Copy all styles of the original page
+ */
+ getStyle: function (): string {
+ let str = "";
+ const styles: NodeListOf = document.querySelectorAll("style,link");
+ for (let i = 0; i < styles.length; i++) {
+ str += styles[i].outerHTML;
+ }
+ str += ``;
+ return str;
+ },
+ // form assignment
+ getHtml: function (): Element {
+ const inputs = document.querySelectorAll("input");
+ const selects = document.querySelectorAll("select");
+ const textareas = document.querySelectorAll("textarea");
+ const canvass = document.querySelectorAll("canvas");
+
+ for (let k = 0; k < inputs.length; k++) {
+ if (inputs[k].type == "checkbox" || inputs[k].type == "radio") {
+ if (inputs[k].checked == true) {
+ inputs[k].setAttribute("checked", "checked");
+ } else {
+ inputs[k].removeAttribute("checked");
+ }
+ } else if (inputs[k].type == "text") {
+ inputs[k].setAttribute("value", inputs[k].value);
+ } else {
+ inputs[k].setAttribute("value", inputs[k].value);
+ }
+ }
+
+ for (let k2 = 0; k2 < textareas.length; k2++) {
+ if (textareas[k2].type == "textarea") {
+ textareas[k2].innerHTML = textareas[k2].value;
+ }
+ }
+
+ for (let k3 = 0; k3 < selects.length; k3++) {
+ if (selects[k3].type == "select-one") {
+ const child = selects[k3].children;
+ for (const i in child) {
+ if (child[i].tagName == "OPTION") {
+ if ((child[i] as any).selected == true) {
+ child[i].setAttribute("selected", "selected");
+ } else {
+ child[i].removeAttribute("selected");
+ }
+ }
+ }
+ }
+ }
+
+ for (let k4 = 0; k4 < canvass.length; k4++) {
+ const imageURL = canvass[k4].toDataURL("image/png");
+ const img = document.createElement("img");
+ img.src = imageURL;
+ img.setAttribute("style", "max-width: 100%;");
+ img.className = "isNeedRemove";
+ canvass[k4].parentNode.insertBefore(img, canvass[k4].nextElementSibling);
+ }
+
+ return this.dom.outerHTML;
+ },
+ /**
+ create iframe
+ */
+ writeIframe: function (content) {
+ let w: Document | Window;
+ let doc: Document;
+ const iframe: HTMLIFrameElement = document.createElement("iframe");
+ const f: HTMLIFrameElement = document.body.appendChild(iframe);
+ iframe.id = "myIframe";
+ iframe.setAttribute(
+ "style",
+ "position:absolute;width:0;height:0;top:-10px;left:-10px;"
+ );
+
+ w = f.contentWindow || f.contentDocument;
+
+ doc = f.contentDocument || f.contentWindow.document;
+ doc.open();
+ doc.write(content);
+ doc.close();
+
+ const removes = document.querySelectorAll(".isNeedRemove");
+ for (let k = 0; k < removes.length; k++) {
+ removes[k].parentNode.removeChild(removes[k]);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
+ const _this = this;
+ iframe.onload = function (): void {
+ // Before popping, callback
+ if (_this.conf.printBeforeFn) {
+ _this.conf.printBeforeFn({ doc });
+ }
+ _this.toPrint(w);
+ setTimeout(function () {
+ document.body.removeChild(iframe);
+ // After popup, callback
+ if (_this.conf.printDoneCallBack) {
+ _this.conf.printDoneCallBack();
+ }
+ }, 100);
+ };
+ },
+ /**
+ Print
+ */
+ toPrint: function (frameWindow): void {
+ try {
+ setTimeout(function () {
+ frameWindow.focus();
+ try {
+ if (!frameWindow.document.execCommand("print", false, null)) {
+ frameWindow.print();
+ }
+ } catch (e) {
+ frameWindow.print();
+ }
+ frameWindow.close();
+ }, 10);
+ } catch (err) {
+ console.error(err);
+ }
+ },
+ isDOM:
+ typeof HTMLElement === "object"
+ ? function (obj) {
+ return obj instanceof HTMLElement;
+ }
+ : function (obj) {
+ return (
+ obj &&
+ typeof obj === "object" &&
+ obj.nodeType === 1 &&
+ typeof obj.nodeName === "string"
+ );
+ },
+ /**
+ * Set the height of the specified dom element by getting the existing height of the dom element and setting
+ * @param {Array} arr
+ */
+ setDomHeight(arr) {
+ if (arr && arr.length) {
+ arr.forEach(name => {
+ const domArr = document.querySelectorAll(name);
+ domArr.forEach(dom => {
+ dom.style.height = dom.offsetHeight + "px";
+ });
+ });
+ }
+ }
+};
+
+export default Print;
diff --git a/src/utils/progress/index.ts b/src/utils/progress/index.ts
new file mode 100644
index 0000000..d309862
--- /dev/null
+++ b/src/utils/progress/index.ts
@@ -0,0 +1,17 @@
+import NProgress from "nprogress";
+import "nprogress/nprogress.css";
+
+NProgress.configure({
+ // 动画方式
+ easing: "ease",
+ // 递增进度条的速度
+ speed: 500,
+ // 是否显示加载ico
+ showSpinner: false,
+ // 自动递增间隔
+ trickleSpeed: 200,
+ // 初始化时的最小百分比
+ minimum: 0.3
+});
+
+export default NProgress;
diff --git a/src/utils/propTypes.ts b/src/utils/propTypes.ts
new file mode 100644
index 0000000..a4d67ec
--- /dev/null
+++ b/src/utils/propTypes.ts
@@ -0,0 +1,39 @@
+import type { CSSProperties, VNodeChild } from "vue";
+import {
+ createTypes,
+ toValidableType,
+ type VueTypesInterface,
+ type VueTypeValidableDef
+} from "vue-types";
+
+export type VueNode = VNodeChild | JSX.Element;
+
+type PropTypes = VueTypesInterface & {
+ readonly style: VueTypeValidableDef;
+ readonly VNodeChild: VueTypeValidableDef;
+};
+
+const newPropTypes = createTypes({
+ func: undefined,
+ bool: undefined,
+ string: undefined,
+ number: undefined,
+ object: undefined,
+ integer: undefined
+}) as PropTypes;
+
+// 从 vue-types v5.0 开始,extend()方法已经废弃,当前已改为官方推荐的ES6+方法 https://dwightjack.github.io/vue-types/advanced/extending-vue-types.html#the-extend-method
+export default class propTypes extends newPropTypes {
+ // a native-like validator that supports the `.validable` method
+ static get style() {
+ return toValidableType("style", {
+ type: [String, Object]
+ });
+ }
+
+ static get VNodeChild() {
+ return toValidableType("VNodeChild", {
+ type: undefined
+ });
+ }
+}
diff --git a/src/utils/responsive.ts b/src/utils/responsive.ts
new file mode 100644
index 0000000..28f1bcf
--- /dev/null
+++ b/src/utils/responsive.ts
@@ -0,0 +1,46 @@
+// 响应式storage
+import type { App } from "vue";
+import Storage from "responsive-storage";
+import { routerArrays } from "@/layout/types";
+import { responsiveStorageNameSpace } from "@/config";
+
+export const injectResponsiveStorage = (app: App, config: PlatformConfigs) => {
+ const nameSpace = responsiveStorageNameSpace();
+ const configObj = Object.assign(
+ {
+ // 国际化 默认中文zh
+ locale: Storage.getData("locale", nameSpace) ?? {
+ locale: config.Locale ?? "zh"
+ },
+ // layout模式以及主题
+ layout: Storage.getData("layout", nameSpace) ?? {
+ layout: config.Layout ?? "vertical",
+ theme: config.Theme ?? "light",
+ darkMode: config.DarkMode ?? false,
+ sidebarStatus: config.SidebarStatus ?? true,
+ epThemeColor: config.EpThemeColor ?? "#409EFF",
+ themeColor: config.Theme ?? "light", // 主题色(对应系统配置中的主题色,与theme不同的是它不会受到浅色、深色整体风格切换的影响,只会在手动点击主题色时改变)
+ overallStyle: config.OverallStyle ?? "light" // 整体风格(浅色:light、深色:dark、自动:system)
+ },
+ // 系统配置-界面显示
+ configure: Storage.getData("configure", nameSpace) ?? {
+ grey: config.Grey ?? false,
+ weak: config.Weak ?? false,
+ hideTabs: config.HideTabs ?? false,
+ hideFooter: config.HideFooter ?? true,
+ showLogo: config.ShowLogo ?? true,
+ showModel: config.ShowModel ?? "smart",
+ multiTagsCache: config.MultiTagsCache ?? false,
+ stretch: config.Stretch ?? false
+ }
+ },
+ config.MultiTagsCache
+ ? {
+ // 默认显示顶级菜单tag
+ tags: Storage.getData("tags", nameSpace) ?? routerArrays
+ }
+ : {}
+ );
+
+ app.use(Storage, { nameSpace, memory: configObj });
+};
diff --git a/src/utils/sso.ts b/src/utils/sso.ts
new file mode 100644
index 0000000..18021d0
--- /dev/null
+++ b/src/utils/sso.ts
@@ -0,0 +1,59 @@
+import { removeToken, setToken, type DataInfo } from "./auth";
+import { subBefore, getQueryMap } from "@pureadmin/utils";
+
+/**
+ * 简版前端单点登录,根据实际业务自行编写,平台启动后本地可以跳后面这个链接进行测试 http://localhost:8848/#/permission/page/index?username=sso&roles=admin&accessToken=eyJhbGciOiJIUzUxMiJ9.admin
+ * 划重点:
+ * 判断是否为单点登录,不为则直接返回不再进行任何逻辑处理,下面是单点登录后的逻辑处理
+ * 1.清空本地旧信息;
+ * 2.获取url中的重要参数信息,然后通过 setToken 保存在本地;
+ * 3.删除不需要显示在 url 的参数
+ * 4.使用 window.location.replace 跳转正确页面
+ */
+(function () {
+ // 获取 url 中的参数
+ const params = getQueryMap(location.href) as DataInfo;
+ const must = ["username", "roles", "accessToken"];
+ const mustLength = must.length;
+ if (Object.keys(params).length !== mustLength) return;
+
+ // url 参数满足 must 里的全部值,才判定为单点登录,避免非单点登录时刷新页面无限循环
+ let sso = [];
+ let start = 0;
+
+ while (start < mustLength) {
+ if (Object.keys(params).includes(must[start]) && sso.length <= mustLength) {
+ sso.push(must[start]);
+ } else {
+ sso = [];
+ }
+ start++;
+ }
+
+ if (sso.length === mustLength) {
+ // 判定为单点登录
+
+ // 清空本地旧信息
+ removeToken();
+
+ // 保存新信息到本地
+ setToken(params);
+
+ // 删除不需要显示在 url 的参数
+ delete params.roles;
+ delete params.accessToken;
+
+ const newUrl = `${location.origin}${location.pathname}${subBefore(
+ location.hash,
+ "?"
+ )}?${JSON.stringify(params)
+ .replace(/["{}]/g, "")
+ .replace(/:/g, "=")
+ .replace(/,/g, "&")}`;
+
+ // 替换历史记录项
+ window.location.replace(newUrl);
+ } else {
+ return;
+ }
+})();
diff --git a/src/utils/tree.ts b/src/utils/tree.ts
new file mode 100644
index 0000000..f8f3783
--- /dev/null
+++ b/src/utils/tree.ts
@@ -0,0 +1,188 @@
+/**
+ * @description 提取菜单树中的每一项uniqueId
+ * @param tree 树
+ * @returns 每一项uniqueId组成的数组
+ */
+export const extractPathList = (tree: any[]): any => {
+ if (!Array.isArray(tree)) {
+ console.warn("tree must be an array");
+ return [];
+ }
+ if (!tree || tree.length === 0) return [];
+ const expandedPaths: Array = [];
+ for (const node of tree) {
+ const hasChildren = node.children && node.children.length > 0;
+ if (hasChildren) {
+ extractPathList(node.children);
+ }
+ expandedPaths.push(node.uniqueId);
+ }
+ return expandedPaths;
+};
+
+/**
+ * @description 如果父级下children的length为1,删除children并自动组建唯一uniqueId
+ * @param tree 树
+ * @param pathList 每一项的id组成的数组
+ * @returns 组件唯一uniqueId后的树
+ */
+export const deleteChildren = (tree: any[], pathList = []): any => {
+ if (!Array.isArray(tree)) {
+ console.warn("menuTree must be an array");
+ return [];
+ }
+ if (!tree || tree.length === 0) return [];
+ for (const [key, node] of tree.entries()) {
+ if (node.children && node.children.length === 1) delete node.children;
+ node.id = key;
+ node.parentId = pathList.length ? pathList[pathList.length - 1] : null;
+ node.pathList = [...pathList, node.id];
+ node.uniqueId =
+ node.pathList.length > 1 ? node.pathList.join("-") : node.pathList[0];
+ const hasChildren = node.children && node.children.length > 0;
+ if (hasChildren) {
+ deleteChildren(node.children, node.pathList);
+ }
+ }
+ return tree;
+};
+
+/**
+ * @description 创建层级关系
+ * @param tree 树
+ * @param pathList 每一项的id组成的数组
+ * @returns 创建层级关系后的树
+ */
+export const buildHierarchyTree = (tree: any[], pathList = []): any => {
+ if (!Array.isArray(tree)) {
+ console.warn("tree must be an array");
+ return [];
+ }
+ if (!tree || tree.length === 0) return [];
+ for (const [key, node] of tree.entries()) {
+ node.id = key;
+ node.parentId = pathList.length ? pathList[pathList.length - 1] : null;
+ node.pathList = [...pathList, node.id];
+ const hasChildren = node.children && node.children.length > 0;
+ if (hasChildren) {
+ buildHierarchyTree(node.children, node.pathList);
+ }
+ }
+ return tree;
+};
+
+/**
+ * @description 广度优先遍历,根据唯一uniqueId找当前节点信息
+ * @param tree 树
+ * @param uniqueId 唯一uniqueId
+ * @returns 当前节点信息
+ */
+export const getNodeByUniqueId = (
+ tree: any[],
+ uniqueId: number | string
+): any => {
+ if (!Array.isArray(tree)) {
+ console.warn("menuTree must be an array");
+ return [];
+ }
+ if (!tree || tree.length === 0) return [];
+ const item = tree.find(node => node.uniqueId === uniqueId);
+ if (item) return item;
+ const childrenList = tree
+ .filter(node => node.children)
+ .map(i => i.children)
+ .flat(1) as unknown;
+ return getNodeByUniqueId(childrenList as any[], uniqueId);
+};
+
+/**
+ * @description 向当前唯一uniqueId节点中追加字段
+ * @param tree 树
+ * @param uniqueId 唯一uniqueId
+ * @param fields 需要追加的字段
+ * @returns 追加字段后的树
+ */
+export const appendFieldByUniqueId = (
+ tree: any[],
+ uniqueId: number | string,
+ fields: object
+): any => {
+ if (!Array.isArray(tree)) {
+ console.warn("menuTree must be an array");
+ return [];
+ }
+ if (!tree || tree.length === 0) return [];
+ for (const node of tree) {
+ const hasChildren = node.children && node.children.length > 0;
+ if (
+ node.uniqueId === uniqueId &&
+ Object.prototype.toString.call(fields) === "[object Object]"
+ )
+ Object.assign(node, fields);
+ if (hasChildren) {
+ appendFieldByUniqueId(node.children, uniqueId, fields);
+ }
+ }
+ return tree;
+};
+
+/**
+ * @description 构造树型结构数据
+ * @param data 数据源
+ * @param id id字段 默认id
+ * @param parentId 父节点字段,默认parentId
+ * @param children 子节点字段,默认children
+ * @returns 追加字段后的树
+ */
+export const handleTree = (
+ data: any[],
+ id?: string,
+ parentId?: string,
+ children?: string
+): any => {
+ if (!Array.isArray(data)) {
+ console.warn("data must be an array");
+ return [];
+ }
+ const config = {
+ id: id || "id",
+ parentId: parentId || "parentId",
+ childrenList: children || "children"
+ };
+
+ const childrenListMap: any = {};
+ const nodeIds: any = {};
+ const tree = [];
+
+ for (const d of data) {
+ const parentId = d[config.parentId];
+ if (childrenListMap[parentId] == null) {
+ childrenListMap[parentId] = [];
+ }
+ nodeIds[d[config.id]] = d;
+ childrenListMap[parentId].push(d);
+ }
+
+ for (const d of data) {
+ const parentId = d[config.parentId];
+ if (nodeIds[parentId] == null) {
+ tree.push(d);
+ }
+ }
+
+ for (const t of tree) {
+ adaptToChildrenList(t);
+ }
+
+ function adaptToChildrenList(o: Record) {
+ if (childrenListMap[o[config.id]] !== null) {
+ o[config.childrenList] = childrenListMap[o[config.id]];
+ }
+ if (o[config.childrenList]) {
+ for (const c of o[config.childrenList]) {
+ adaptToChildrenList(c);
+ }
+ }
+ }
+ return tree;
+};
diff --git a/src/views/dept/form.vue b/src/views/dept/form.vue
new file mode 100644
index 0000000..eaaa4ff
--- /dev/null
+++ b/src/views/dept/form.vue
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+ {{ data.name }}
+ ({{ data.children.length }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/dept/index.vue b/src/views/dept/index.vue
new file mode 100644
index 0000000..ec4e94f
--- /dev/null
+++ b/src/views/dept/index.vue
@@ -0,0 +1,172 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 搜索
+
+
+ 重置
+
+
+
+
+
+
+
+ 新增部门
+
+
+
+
+
+
+ 修改
+
+
+ 新增
+
+
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/dept/utils/hook.tsx b/src/views/dept/utils/hook.tsx
new file mode 100644
index 0000000..5f02544
--- /dev/null
+++ b/src/views/dept/utils/hook.tsx
@@ -0,0 +1,180 @@
+import dayjs from "dayjs";
+import editForm from "../form.vue";
+import { handleTree } from "@/utils/tree";
+import { message } from "@/utils/message";
+import { getDeptList } from "@/api/v1/system";
+import { usePublicHooks } from "../../hooks";
+import { addDialog } from "@/components/BaseDialog";
+import { h, onMounted, reactive, ref } from "vue";
+import type { FormItemProps } from "../utils/types";
+import { cloneDeep, deviceDetection, isAllEmpty } from "@pureadmin/utils";
+
+export function useDept() {
+ const form = reactive({
+ name: "",
+ status: null
+ });
+
+ const formRef = ref();
+ const dataList = ref([]);
+ const loading = ref(true);
+ const { tagStyle } = usePublicHooks();
+
+ const columns: TableColumnList = [
+ {
+ label: "部门名称",
+ prop: "name",
+ width: 180,
+ align: "left"
+ },
+ {
+ label: "排序",
+ prop: "sort",
+ minWidth: 70
+ },
+ {
+ label: "状态",
+ prop: "status",
+ minWidth: 100,
+ cellRenderer: ({ row, props }) => (
+
+ {row.status === 1 ? "启用" : "停用"}
+
+ )
+ },
+ {
+ label: "创建时间",
+ minWidth: 200,
+ prop: "createTime",
+ formatter: ({ createTime }) =>
+ dayjs(createTime).format("YYYY-MM-DD HH:mm:ss")
+ },
+ {
+ label: "备注",
+ prop: "remark",
+ minWidth: 320
+ },
+ {
+ label: "操作",
+ fixed: "right",
+ width: 210,
+ slot: "operation"
+ }
+ ];
+
+ function handleSelectionChange(val) {
+ console.log("handleSelectionChange", val);
+ }
+
+ function resetForm(formEl) {
+ if (!formEl) return;
+ formEl.resetFields();
+ onSearch();
+ }
+
+ async function onSearch() {
+ loading.value = true;
+ const { data } = await getDeptList(); // 这里是返回一维数组结构,前端自行处理成树结构,返回格式要求:唯一id加父节点parentId,parentId取父节点id
+ let newData = data;
+ if (!isAllEmpty(form.name)) {
+ // 前端搜索部门名称
+ newData = newData.filter(item => item.name.includes(form.name));
+ }
+ if (!isAllEmpty(form.status)) {
+ // 前端搜索状态
+ newData = newData.filter(item => item.status === form.status);
+ }
+ dataList.value = handleTree(newData); // 处理成树结构
+ setTimeout(() => {
+ loading.value = false;
+ }, 500);
+ }
+
+ function formatHigherDeptOptions(treeList) {
+ // 根据返回数据的status字段值判断追加是否禁用disabled字段,返回处理后的树结构,用于上级部门级联选择器的展示(实际开发中也是如此,不可能前端需要的每个字段后端都会返回,这时需要前端自行根据后端返回的某些字段做逻辑处理)
+ if (!treeList || !treeList.length) return;
+ const newTreeList = [];
+ for (let i = 0; i < treeList.length; i++) {
+ treeList[i].disabled = treeList[i].status === 0 ? true : false;
+ formatHigherDeptOptions(treeList[i].children);
+ newTreeList.push(treeList[i]);
+ }
+ return newTreeList;
+ }
+
+ function openDialog(title = "新增", row?: FormItemProps) {
+ addDialog({
+ title: `${title}部门`,
+ props: {
+ formInline: {
+ higherDeptOptions: formatHigherDeptOptions(cloneDeep(dataList.value)),
+ parentId: row?.parentId ?? 0,
+ name: row?.name ?? "",
+ principal: row?.principal ?? "",
+ phone: row?.phone ?? "",
+ email: row?.email ?? "",
+ sort: row?.sort ?? 0,
+ status: row?.status ?? 1,
+ remark: row?.remark ?? ""
+ }
+ },
+ width: "40%",
+ draggable: true,
+ fullscreen: deviceDetection(),
+ fullscreenIcon: true,
+ closeOnClickModal: false,
+ contentRenderer: () => h(editForm, { ref: formRef, formInline: null }),
+ beforeSure: (done, { options }) => {
+ const FormRef = formRef.value.getRef();
+ const curData = options.props.formInline as FormItemProps;
+
+ function chores() {
+ message(`您${title}了部门名称为${curData.name}的这条数据`, {
+ type: "success"
+ });
+ done(); // 关闭弹框
+ onSearch(); // 刷新表格数据
+ }
+
+ FormRef.validate(valid => {
+ if (valid) {
+ console.log("curData", curData);
+ // 表单规则校验通过
+ if (title === "新增") {
+ // 实际开发先调用新增接口,再进行下面操作
+ chores();
+ } else {
+ // 实际开发先调用修改接口,再进行下面操作
+ chores();
+ }
+ }
+ });
+ }
+ });
+ }
+
+ function handleDelete(row) {
+ message(`您删除了部门名称为${row.name}的这条数据`, { type: "success" });
+ onSearch();
+ }
+
+ onMounted(() => {
+ onSearch();
+ });
+
+ return {
+ form,
+ loading,
+ columns,
+ dataList,
+ /** 搜索 */
+ onSearch,
+ /** 重置 */
+ resetForm,
+ /** 新增、修改部门 */
+ openDialog,
+ /** 删除部门 */
+ handleDelete,
+ handleSelectionChange
+ };
+}
diff --git a/src/views/dept/utils/rule.ts b/src/views/dept/utils/rule.ts
new file mode 100644
index 0000000..b20bf67
--- /dev/null
+++ b/src/views/dept/utils/rule.ts
@@ -0,0 +1,37 @@
+import { reactive } from "vue";
+import type { FormRules } from "element-plus";
+import { isPhone, isEmail } from "@pureadmin/utils";
+
+/** 自定义表单规则校验 */
+export const formRules = reactive({
+ name: [{ required: true, message: "部门名称为必填项", trigger: "blur" }],
+ phone: [
+ {
+ validator: (rule, value, callback) => {
+ if (value === "") {
+ callback();
+ } else if (!isPhone(value)) {
+ callback(new Error("请输入正确的手机号码格式"));
+ } else {
+ callback();
+ }
+ },
+ trigger: "blur"
+ // trigger: "click" // 如果想在点击确定按钮时触发这个校验,trigger 设置成 click 即可
+ }
+ ],
+ email: [
+ {
+ validator: (rule, value, callback) => {
+ if (value === "") {
+ callback();
+ } else if (!isEmail(value)) {
+ callback(new Error("请输入正确的邮箱格式"));
+ } else {
+ callback();
+ }
+ },
+ trigger: "blur"
+ }
+ ]
+});
diff --git a/src/views/dept/utils/types.ts b/src/views/dept/utils/types.ts
new file mode 100644
index 0000000..7547d6b
--- /dev/null
+++ b/src/views/dept/utils/types.ts
@@ -0,0 +1,16 @@
+interface FormItemProps {
+ higherDeptOptions: Record[];
+ parentId: number;
+ name: string;
+ principal: string;
+ phone: string | number;
+ email: string;
+ sort: number;
+ status: number;
+ remark: string;
+}
+interface FormProps {
+ formInline: FormItemProps;
+}
+
+export type { FormItemProps, FormProps };
diff --git a/src/views/error/403.vue b/src/views/error/403.vue
new file mode 100644
index 0000000..a16be3c
--- /dev/null
+++ b/src/views/error/403.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+ 403
+
+
+ 抱歉,你无权访问该页面
+
+
+ 返回首页
+
+
+
+
diff --git a/src/views/error/404.vue b/src/views/error/404.vue
new file mode 100644
index 0000000..cd780b7
--- /dev/null
+++ b/src/views/error/404.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+ 404
+
+
+ 抱歉,你访问的页面不存在
+
+
+ 返回首页
+
+
+
+
diff --git a/src/views/error/500.vue b/src/views/error/500.vue
new file mode 100644
index 0000000..e55a090
--- /dev/null
+++ b/src/views/error/500.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+ 500
+
+
+ 抱歉,服务器出错了
+
+
+ 返回首页
+
+
+
+
diff --git a/src/views/hooks.ts b/src/views/hooks.ts
new file mode 100644
index 0000000..5465d94
--- /dev/null
+++ b/src/views/hooks.ts
@@ -0,0 +1,39 @@
+// 抽离可公用的工具函数等用于系统管理页面逻辑
+import { computed } from "vue";
+import { useDark } from "@pureadmin/utils";
+
+export function usePublicHooks() {
+ const { isDark } = useDark();
+
+ const switchStyle = computed(() => {
+ return {
+ "--el-switch-on-color": "#6abe39",
+ "--el-switch-off-color": "#e84749"
+ };
+ });
+
+ const tagStyle = computed(() => {
+ return (status: number) => {
+ return status === 1
+ ? {
+ "--el-tag-text-color": isDark.value ? "#6abe39" : "#389e0d",
+ "--el-tag-bg-color": isDark.value ? "#172412" : "#f6ffed",
+ "--el-tag-border-color": isDark.value ? "#274a17" : "#b7eb8f"
+ }
+ : {
+ "--el-tag-text-color": isDark.value ? "#e84749" : "#cf1322",
+ "--el-tag-bg-color": isDark.value ? "#2b1316" : "#fff1f0",
+ "--el-tag-border-color": isDark.value ? "#58191c" : "#ffa39e"
+ };
+ };
+ });
+
+ return {
+ /** 当前网页是否为`dark`模式 */
+ isDark,
+ /** 表现更鲜明的`el-switch`组件 */
+ switchStyle,
+ /** 表现更鲜明的`el-tag`组件 */
+ tagStyle
+ };
+}
diff --git a/src/views/login/index.vue b/src/views/login/index.vue
new file mode 100644
index 0000000..c4f68b5
--- /dev/null
+++ b/src/views/login/index.vue
@@ -0,0 +1,223 @@
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+ 简体中文
+
+
+
+
+
+ English
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/login/utils/motion.ts b/src/views/login/utils/motion.ts
new file mode 100644
index 0000000..2b1182c
--- /dev/null
+++ b/src/views/login/utils/motion.ts
@@ -0,0 +1,40 @@
+import { h, defineComponent, withDirectives, resolveDirective } from "vue";
+
+/** 封装@vueuse/motion动画库中的自定义指令v-motion */
+export default defineComponent({
+ name: "Motion",
+ props: {
+ delay: {
+ type: Number,
+ default: 50
+ }
+ },
+ render() {
+ const { delay } = this;
+ const motion = resolveDirective("motion");
+ return withDirectives(
+ h(
+ "div",
+ {},
+ {
+ default: () => [this.$slots.default()]
+ }
+ ),
+ [
+ [
+ motion,
+ {
+ initial: { opacity: 0, y: 100 },
+ enter: {
+ opacity: 1,
+ y: 0,
+ transition: {
+ delay
+ }
+ }
+ }
+ ]
+ ]
+ );
+ }
+});
diff --git a/src/views/login/utils/rule.ts b/src/views/login/utils/rule.ts
new file mode 100644
index 0000000..ffeadd4
--- /dev/null
+++ b/src/views/login/utils/rule.ts
@@ -0,0 +1,27 @@
+import { reactive } from "vue";
+import type { FormRules } from "element-plus";
+import { $t } from "@/plugins/i18n";
+
+/** 密码正则(密码格式应为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}$/;
+
+/** 登录校验 */
+const loginRules = reactive({
+ password: [
+ {
+ validator: (rule, value, callback) => {
+ if (value === "") {
+ callback(new Error($t("login.purePassWordReg")));
+ } else if (!REGEXP_PWD.test(value)) {
+ callback(new Error($t("login.purePassWordRuleReg")));
+ } else {
+ callback();
+ }
+ },
+ trigger: "blur"
+ }
+ ]
+});
+
+export { loginRules };
diff --git a/src/views/login/utils/static.ts b/src/views/login/utils/static.ts
new file mode 100644
index 0000000..18268d8
--- /dev/null
+++ b/src/views/login/utils/static.ts
@@ -0,0 +1,5 @@
+import bg from "@/assets/login/bg.png";
+import avatar from "@/assets/login/avatar.svg?component";
+import illustration from "@/assets/login/illustration.svg?component";
+
+export { bg, avatar, illustration };
diff --git a/src/views/menu/README.md b/src/views/menu/README.md
new file mode 100644
index 0000000..99301de
--- /dev/null
+++ b/src/views/menu/README.md
@@ -0,0 +1,26 @@
+## 字段含义
+
+| 字段 | 说明 |
+| :---------------- | :----------------------------------------------------------- |
+| `menuType` | 菜单类型(`0`代表菜单、`1`代表`iframe`、`2`代表外链、`3`代表按钮) |
+| `parentId` | |
+| `title` | 菜单名称(兼容国际化、非国际化,如果用国际化的写法就必须在根目录的`locales`文件夹下对应添加) |
+| `name` | 路由名称(必须唯一并且和当前路由`component`字段对应的页面里用`defineOptions`包起来的`name`保持一致) |
+| `path` | 路由路径 |
+| `component` | 组件路径(传`component`组件路径,那么`path`可以随便写,如果不传,`component`组件路径会跟`path`保持一致) |
+| `rank` | 菜单排序(平台规定只有`home`路由的`rank`才能为`0`,所以后端在返回`rank`的时候需要从非`0`开始 [点击查看更多](https://pure-admin.github.io/pure-admin-doc/pages/routerMenu/#%E8%8F%9C%E5%8D%95%E6%8E%92%E5%BA%8F-rank)) |
+| `redirect` | 路由重定向 |
+| `icon` | 菜单图标 |
+| `extraIcon` | 右侧图标 |
+| `enterTransition` | 进场动画(页面加载动画) |
+| `leaveTransition` | 离场动画(页面加载动画) |
+| `activePath` | 菜单激活(将某个菜单激活,主要用于通过`query`或`params`传参的路由,当它们通过配置`showLink: false`后不在菜单中显示,就不会有任何菜单高亮,而通过设置`activePath`指定激活菜单即可获得高亮,`activePath`为指定激活菜单的`path`) |
+| `auths` | 权限标识(按钮级别权限设置) |
+| `frameSrc` | 链接地址(需要内嵌的`iframe`链接地址) |
+| `frameLoading` | 加载动画(内嵌的`iframe`页面是否开启首次加载动画) |
+| `keepAlive` | 缓存页面(是否缓存该路由页面,开启后会保存该页面的整体状态,刷新后会清空状态) |
+| `hiddenTag` | 标签页(当前菜单名称或自定义信息禁止添加到标签页) |
+| `fixedTag` | 固定标签页(当前菜单名称是否固定显示在标签页且不可关闭) |
+| `showLink` | 菜单(是否显示该菜单) |
+| `showParent` | 父级菜单(是否显示父级菜单 [点击查看更多](https://pure-admin.github.io/pure-admin-doc/pages/routerMenu/#%E7%AC%AC%E4%B8%80%E7%A7%8D-%E8%AF%A5%E6%A8%A1%E5%BC%8F%E9%92%88%E5%AF%B9%E7%88%B6%E7%BA%A7%E8%8F%9C%E5%8D%95%E4%B8%8B%E5%8F%AA%E6%9C%89%E4%B8%80%E4%B8%AA%E5%AD%90%E8%8F%9C%E5%8D%95%E7%9A%84%E6%83%85%E5%86%B5-%E5%9C%A8%E5%AD%90%E8%8F%9C%E5%8D%95%E7%9A%84-meta-%E5%B1%9E%E6%80%A7%E4%B8%AD%E5%8A%A0%E4%B8%8A-showparent-true-%E5%8D%B3%E5%8F%AF)) |
+
diff --git a/src/views/menu/form.vue b/src/views/menu/form.vue
new file mode 100644
index 0000000..573f08d
--- /dev/null
+++ b/src/views/menu/form.vue
@@ -0,0 +1,342 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t(data.title) }}
+ ({{ data.children.length }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ newFormInline.frameLoading = value;
+ }
+ "
+ />
+
+
+
+
+
+ {
+ newFormInline.showLink = value;
+ }
+ "
+ />
+
+
+
+
+ {
+ newFormInline.showParent = value;
+ }
+ "
+ />
+
+
+
+
+
+ {
+ newFormInline.keepAlive = value;
+ }
+ "
+ />
+
+
+
+
+
+ {
+ newFormInline.hiddenTag = value;
+ }
+ "
+ />
+
+
+
+
+ {
+ newFormInline.fixedTag = value;
+ }
+ "
+ />
+
+
+
+
+
diff --git a/src/views/menu/index.vue b/src/views/menu/index.vue
new file mode 100644
index 0000000..97f3fab
--- /dev/null
+++ b/src/views/menu/index.vue
@@ -0,0 +1,163 @@
+
+
+
+
+
+
+
+
+
+
+ 搜索
+
+
+ 重置
+
+
+
+
+
+
+
+ 新增菜单
+
+
+
+
+
+
+ 修改
+
+
+ 新增
+
+
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/menu/utils/enums.ts b/src/views/menu/utils/enums.ts
new file mode 100644
index 0000000..4466b61
--- /dev/null
+++ b/src/views/menu/utils/enums.ts
@@ -0,0 +1,108 @@
+import type { OptionsType } from "@/components/ReSegmented";
+
+const menuTypeOptions: Array = [
+ {
+ label: "菜单",
+ value: 0
+ },
+ {
+ label: "iframe",
+ value: 1
+ },
+ {
+ label: "外链",
+ value: 2
+ },
+ {
+ label: "按钮",
+ value: 3
+ }
+];
+
+const showLinkOptions: Array = [
+ {
+ label: "显示",
+ tip: "会在菜单中显示",
+ value: true
+ },
+ {
+ label: "隐藏",
+ tip: "不会在菜单中显示",
+ value: false
+ }
+];
+
+const fixedTagOptions: Array = [
+ {
+ label: "固定",
+ tip: "当前菜单名称固定显示在标签页且不可关闭",
+ value: true
+ },
+ {
+ label: "不固定",
+ tip: "当前菜单名称不固定显示在标签页且可关闭",
+ value: false
+ }
+];
+
+const keepAliveOptions: Array = [
+ {
+ label: "缓存",
+ tip: "会保存该页面的整体状态,刷新后会清空状态",
+ value: true
+ },
+ {
+ label: "不缓存",
+ tip: "不会保存该页面的整体状态",
+ value: false
+ }
+];
+
+const hiddenTagOptions: Array = [
+ {
+ label: "允许",
+ tip: "当前菜单名称或自定义信息允许添加到标签页",
+ value: false
+ },
+ {
+ label: "禁止",
+ tip: "当前菜单名称或自定义信息禁止添加到标签页",
+ value: true
+ }
+];
+
+const showParentOptions: Array = [
+ {
+ label: "显示",
+ tip: "会显示父级菜单",
+ value: true
+ },
+ {
+ label: "隐藏",
+ tip: "不会显示父级菜单",
+ value: false
+ }
+];
+
+const frameLoadingOptions: Array = [
+ {
+ label: "开启",
+ tip: "有首次加载动画",
+ value: true
+ },
+ {
+ label: "关闭",
+ tip: "无首次加载动画",
+ value: false
+ }
+];
+
+export {
+ menuTypeOptions,
+ showLinkOptions,
+ fixedTagOptions,
+ keepAliveOptions,
+ hiddenTagOptions,
+ showParentOptions,
+ frameLoadingOptions
+};
diff --git a/src/views/menu/utils/hook.tsx b/src/views/menu/utils/hook.tsx
new file mode 100644
index 0000000..3540b49
--- /dev/null
+++ b/src/views/menu/utils/hook.tsx
@@ -0,0 +1,222 @@
+import editForm from "../form.vue";
+import { handleTree } from "@/utils/tree";
+import { message } from "@/utils/message";
+import { getMenuList } from "@/api/v1/system";
+import { $t } from "@/plugins/i18n";
+import { addDialog } from "@/components/BaseDialog";
+import { h, onMounted, reactive, ref } from "vue";
+import type { FormItemProps } from "../utils/types";
+import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { cloneDeep, deviceDetection, isAllEmpty } from "@pureadmin/utils";
+
+export function useMenu() {
+ const form = reactive({
+ title: ""
+ });
+
+ const formRef = ref();
+ const dataList = ref([]);
+ const loading = ref(true);
+
+ const getMenuType = (type, text = false) => {
+ switch (type) {
+ case 0:
+ return text ? "菜单" : "primary";
+ case 1:
+ return text ? "iframe" : "warning";
+ case 2:
+ return text ? "外链" : "danger";
+ case 3:
+ return text ? "按钮" : "info";
+ }
+ };
+
+ const columns: TableColumnList = [
+ {
+ label: "菜单名称",
+ prop: "title",
+ align: "left",
+ cellRenderer: ({ row }) => (
+ <>
+
+ {h(useRenderIcon(row.icon), {
+ style: { paddingTop: "1px" }
+ })}
+
+ {$t(row.title)}
+ >
+ )
+ },
+ {
+ label: "菜单类型",
+ prop: "menuType",
+ width: 100,
+ cellRenderer: ({ row, props }) => (
+
+ {getMenuType(row.menuType, true)}
+
+ )
+ },
+ {
+ label: "路由路径",
+ prop: "path"
+ },
+ {
+ label: "组件路径",
+ prop: "component",
+ formatter: ({ path, component }) =>
+ isAllEmpty(component) ? path : component
+ },
+ {
+ label: "权限标识",
+ prop: "auths"
+ },
+ {
+ label: "排序",
+ prop: "rank",
+ width: 100
+ },
+ {
+ label: "隐藏",
+ prop: "showLink",
+ formatter: ({ showLink }) => (showLink ? "否" : "是"),
+ width: 100
+ },
+ {
+ label: "操作",
+ fixed: "right",
+ width: 210,
+ slot: "operation"
+ }
+ ];
+
+ function handleSelectionChange(val) {
+ console.log("handleSelectionChange", val);
+ }
+
+ function resetForm(formEl) {
+ if (!formEl) return;
+ formEl.resetFields();
+ onSearch();
+ }
+
+ async function onSearch() {
+ loading.value = true;
+ const { data } = await getMenuList(); // 这里是返回一维数组结构,前端自行处理成树结构,返回格式要求:唯一id加父节点parentId,parentId取父节点id
+ let newData = data;
+ if (!isAllEmpty(form.title)) {
+ // 前端搜索菜单名称
+ newData = newData.filter(item => $t(item.title).includes(form.title));
+ }
+ dataList.value = handleTree(newData); // 处理成树结构
+ setTimeout(() => {
+ loading.value = false;
+ }, 500);
+ }
+
+ function formatHigherMenuOptions(treeList) {
+ if (!treeList || !treeList.length) return;
+ const newTreeList = [];
+ for (let i = 0; i < treeList.length; i++) {
+ treeList[i].title = $t(treeList[i].title);
+ formatHigherMenuOptions(treeList[i].children);
+ newTreeList.push(treeList[i]);
+ }
+ return newTreeList;
+ }
+
+ function openDialog(title = "新增", row?: FormItemProps) {
+ addDialog({
+ title: `${title}菜单`,
+ props: {
+ formInline: {
+ menuType: row?.menuType ?? 0,
+ higherMenuOptions: formatHigherMenuOptions(cloneDeep(dataList.value)),
+ parentId: row?.parentId ?? 0,
+ title: row?.title ?? "",
+ name: row?.name ?? "",
+ path: row?.path ?? "",
+ component: row?.component ?? "",
+ rank: row?.rank ?? 99,
+ redirect: row?.redirect ?? "",
+ icon: row?.icon ?? "",
+ extraIcon: row?.extraIcon ?? "",
+ enterTransition: row?.enterTransition ?? "",
+ leaveTransition: row?.leaveTransition ?? "",
+ activePath: row?.activePath ?? "",
+ auths: row?.auths ?? "",
+ frameSrc: row?.frameSrc ?? "",
+ frameLoading: row?.frameLoading ?? true,
+ keepAlive: row?.keepAlive ?? false,
+ hiddenTag: row?.hiddenTag ?? false,
+ fixedTag: row?.fixedTag ?? false,
+ showLink: row?.showLink ?? true,
+ showParent: row?.showParent ?? false
+ }
+ },
+ width: "45%",
+ draggable: true,
+ fullscreen: deviceDetection(),
+ fullscreenIcon: true,
+ closeOnClickModal: false,
+ contentRenderer: () => h(editForm, { ref: formRef, formInline: null }),
+ beforeSure: (done, { options }) => {
+ const FormRef = formRef.value.getRef();
+ const curData = options.props.formInline as FormItemProps;
+
+ function chores() {
+ message(`您${title}了菜单名称为${$t(curData.title)}的这条数据`, {
+ type: "success"
+ });
+ done(); // 关闭弹框
+ onSearch(); // 刷新表格数据
+ }
+
+ FormRef.validate(valid => {
+ if (valid) {
+ console.log("curData", curData);
+ // 表单规则校验通过
+ if (title === "新增") {
+ // 实际开发先调用新增接口,再进行下面操作
+ chores();
+ } else {
+ // 实际开发先调用修改接口,再进行下面操作
+ chores();
+ }
+ }
+ });
+ }
+ });
+ }
+
+ function handleDelete(row) {
+ message(`您删除了菜单名称为${$t(row.title)}的这条数据`, {
+ type: "success"
+ });
+ onSearch();
+ }
+
+ onMounted(() => {
+ onSearch();
+ });
+
+ return {
+ form,
+ loading,
+ columns,
+ dataList,
+ /** 搜索 */
+ onSearch,
+ /** 重置 */
+ resetForm,
+ /** 新增、修改菜单 */
+ openDialog,
+ /** 删除菜单 */
+ handleDelete,
+ handleSelectionChange
+ };
+}
diff --git a/src/views/menu/utils/rule.ts b/src/views/menu/utils/rule.ts
new file mode 100644
index 0000000..90b3548
--- /dev/null
+++ b/src/views/menu/utils/rule.ts
@@ -0,0 +1,10 @@
+import { reactive } from "vue";
+import type { FormRules } from "element-plus";
+
+/** 自定义表单规则校验 */
+export const formRules = reactive({
+ title: [{ required: true, message: "菜单名称为必填项", trigger: "blur" }],
+ name: [{ required: true, message: "路由名称为必填项", trigger: "blur" }],
+ path: [{ required: true, message: "路由路径为必填项", trigger: "blur" }],
+ auths: [{ required: true, message: "权限标识为必填项", trigger: "blur" }]
+});
diff --git a/src/views/menu/utils/types.ts b/src/views/menu/utils/types.ts
new file mode 100644
index 0000000..dad2d48
--- /dev/null
+++ b/src/views/menu/utils/types.ts
@@ -0,0 +1,31 @@
+interface FormItemProps {
+ /** 菜单类型(0代表菜单、1代表iframe、2代表外链、3代表按钮)*/
+ menuType: number;
+ higherMenuOptions: Record[];
+ parentId: number;
+ title: string;
+ name: string;
+ path: string;
+ component: string;
+ rank: number;
+ redirect: string;
+ icon: string;
+ extraIcon: string;
+ enterTransition: string;
+ leaveTransition: string;
+ activePath: string;
+ auths: string;
+ frameSrc: string;
+ frameLoading: boolean;
+ keepAlive: boolean;
+ hiddenTag: boolean;
+ fixedTag: boolean;
+ showLink: boolean;
+ showParent: boolean;
+}
+
+interface FormProps {
+ formInline: FormItemProps;
+}
+
+export type { FormItemProps, FormProps };
diff --git a/src/views/permission/button/index.vue b/src/views/permission/button/index.vue
new file mode 100644
index 0000000..405e2c6
--- /dev/null
+++ b/src/views/permission/button/index.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
当前拥有的code列表:{{ getAuths() }}
+
+
+
+
+
+
+
+
+ 拥有code:'permission:btn:add' 权限可见
+
+
+
+
+ 拥有code:['permission:btn:edit'] 权限可见
+
+
+
+
+ 拥有code:['permission:btn:add', 'permission:btn:edit',
+ 'permission:btn:delete'] 权限可见
+
+
+
+
+
+
+
+
+
+
+
+ 拥有code:'permission:btn:add' 权限可见
+
+
+ 拥有code:['permission:btn:edit'] 权限可见
+
+
+ 拥有code:['permission:btn:add', 'permission:btn:edit',
+ 'permission:btn:delete'] 权限可见
+
+
+
+
+
+
+
+
+
+
+ 拥有code:'permission:btn:add' 权限可见
+
+
+ 拥有code:['permission:btn:edit'] 权限可见
+
+
+ 拥有code:['permission:btn:add', 'permission:btn:edit',
+ 'permission:btn:delete'] 权限可见
+
+
+
+
+
diff --git a/src/views/permission/button/perms.vue b/src/views/permission/button/perms.vue
new file mode 100644
index 0000000..0cdc6b5
--- /dev/null
+++ b/src/views/permission/button/perms.vue
@@ -0,0 +1,109 @@
+
+
+
+
+
当前拥有的code列表:{{ permissions }}
+
+ *:*:* 代表拥有全部按钮级别权限
+
+
+
+
+
+
+
+
+
+ 拥有code:'permission:btn:add' 权限可见
+
+
+
+
+ 拥有code:['permission:btn:edit'] 权限可见
+
+
+
+
+ 拥有code:['permission:btn:add', 'permission:btn:edit',
+ 'permission:btn:delete'] 权限可见
+
+
+
+
+
+
+
+
+
+
+
+ 拥有code:'permission:btn:add' 权限可见
+
+
+ 拥有code:['permission:btn:edit'] 权限可见
+
+
+ 拥有code:['permission:btn:add', 'permission:btn:edit',
+ 'permission:btn:delete'] 权限可见
+
+
+
+
+
+
+
+
+
+
+ 拥有code:'permission:btn:add' 权限可见
+
+
+ 拥有code:['permission:btn:edit'] 权限可见
+
+
+ 拥有code:['permission:btn:add', 'permission:btn:edit',
+ 'permission:btn:delete'] 权限可见
+
+
+
+
+
diff --git a/src/views/permission/page/index.vue b/src/views/permission/page/index.vue
new file mode 100644
index 0000000..27fb26a
--- /dev/null
+++ b/src/views/permission/page/index.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+ 模拟后台根据不同角色返回对应路由,观察左侧菜单变化(管理员角色可查看系统管理菜单、普通角色不可查看系统管理菜单)
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/role/form.vue b/src/views/role/form.vue
new file mode 100644
index 0000000..65d4ef0
--- /dev/null
+++ b/src/views/role/form.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/role/index.vue b/src/views/role/index.vue
new file mode 100644
index 0000000..b5514db
--- /dev/null
+++ b/src/views/role/index.vue
@@ -0,0 +1,344 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 搜索
+
+
+ 重置
+
+
+
+
+
+
+
+
+ 新增角色
+
+
+
+
+
+
+ 修改
+
+
+
+
+ 删除
+
+
+
+
+ 权限
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 菜单权限
+ {{ `${curRow?.name ? `(${curRow.name})` : ""}` }}
+
+
+
+
+
+
+
+
+
+
+ {{ transformI18n(node.label) }}
+
+
+
+
+
+
+
+
diff --git a/src/views/role/utils/hook.tsx b/src/views/role/utils/hook.tsx
new file mode 100644
index 0000000..84b5613
--- /dev/null
+++ b/src/views/role/utils/hook.tsx
@@ -0,0 +1,318 @@
+import dayjs from "dayjs";
+import editForm from "../form.vue";
+import { handleTree } from "@/utils/tree";
+import { message } from "@/utils/message";
+import { ElMessageBox } from "element-plus";
+import { usePublicHooks } from "../../hooks";
+import { addDialog } from "@/components/BaseDialog";
+import type { FormItemProps } from "../utils/types";
+import type { PaginationProps } from "@pureadmin/table";
+import { deviceDetection, getKeyList } from "@pureadmin/utils";
+import { getRoleList, getRoleMenu, getRoleMenuIds } from "@/api/v1/system";
+import { h, onMounted, reactive, type Ref, ref, toRaw, watch } from "vue";
+import { $t } from "@/plugins/i18n";
+
+export function useRole(treeRef: Ref) {
+ const form = reactive({
+ name: "",
+ code: "",
+ status: ""
+ });
+ const curRow = ref();
+ const formRef = ref();
+ const dataList = ref([]);
+ const treeIds = ref([]);
+ const treeData = ref([]);
+ const isShow = ref(false);
+ const loading = ref(true);
+ const isLinkage = ref(false);
+ const treeSearchValue = ref();
+ const switchLoadMap = ref({});
+ const isExpandAll = ref(false);
+ const isSelectAll = ref(false);
+ const { switchStyle } = usePublicHooks();
+ const treeProps = {
+ value: "id",
+ label: "title",
+ children: "children"
+ };
+ const pagination = reactive({
+ total: 0,
+ pageSize: 10,
+ currentPage: 1,
+ background: true
+ });
+ const columns: TableColumnList = [
+ {
+ label: "角色编号",
+ prop: "id"
+ },
+ {
+ label: "角色名称",
+ prop: "name"
+ },
+ {
+ label: "角色标识",
+ prop: "code"
+ },
+ {
+ label: "状态",
+ cellRenderer: scope => (
+ onChange(scope as any)}
+ />
+ ),
+ minWidth: 90
+ },
+ {
+ label: "备注",
+ prop: "remark",
+ minWidth: 160
+ },
+ {
+ label: "创建时间",
+ prop: "createTime",
+ minWidth: 160,
+ formatter: ({ createTime }) =>
+ dayjs(createTime).format("YYYY-MM-DD HH:mm:ss")
+ },
+ {
+ label: "操作",
+ fixed: "right",
+ width: 210,
+ slot: "operation"
+ }
+ ];
+ // const buttonClass = computed(() => {
+ // return [
+ // "!h-[20px]",
+ // "reset-margin",
+ // "!text-gray-500",
+ // "dark:!text-white",
+ // "dark:hover:!text-primary"
+ // ];
+ // });
+
+ function onChange({ row, index }) {
+ ElMessageBox.confirm(
+ `确认要${
+ row.status === 0 ? "停用" : "启用"
+ }${
+ row.name
+ }吗?`,
+ "系统提示",
+ {
+ confirmButtonText: "确定",
+ cancelButtonText: "取消",
+ type: "warning",
+ dangerouslyUseHTMLString: true,
+ draggable: true
+ }
+ )
+ .then(() => {
+ switchLoadMap.value[index] = Object.assign(
+ {},
+ switchLoadMap.value[index],
+ {
+ loading: true
+ }
+ );
+ setTimeout(() => {
+ switchLoadMap.value[index] = Object.assign(
+ {},
+ switchLoadMap.value[index],
+ {
+ loading: false
+ }
+ );
+ message(`已${row.status === 0 ? "停用" : "启用"}${row.name}`, {
+ type: "success"
+ });
+ }, 300);
+ })
+ .catch(() => {
+ row.status === 0 ? (row.status = 1) : (row.status = 0);
+ });
+ }
+
+ function handleDelete(row) {
+ message(`您删除了角色名称为${row.name}的这条数据`, { type: "success" });
+ onSearch();
+ }
+
+ function handleSizeChange(val: number) {
+ console.log(`${val} items per page`);
+ }
+
+ function handleCurrentChange(val: number) {
+ console.log(`current page: ${val}`);
+ }
+
+ function handleSelectionChange(val) {
+ console.log("handleSelectionChange", val);
+ }
+
+ async function onSearch() {
+ loading.value = true;
+ const { data } = await getRoleList(toRaw(form));
+ dataList.value = data.list;
+ pagination.total = data.total;
+ pagination.pageSize = data.pageSize;
+ pagination.currentPage = data.currentPage;
+
+ setTimeout(() => {
+ loading.value = false;
+ }, 500);
+ }
+
+ const resetForm = formEl => {
+ if (!formEl) return;
+ formEl.resetFields();
+ onSearch();
+ };
+
+ function openDialog(title = "新增", row?: FormItemProps) {
+ addDialog({
+ title: `${title}角色`,
+ props: {
+ formInline: {
+ name: row?.name ?? "",
+ code: row?.code ?? "",
+ remark: row?.remark ?? ""
+ }
+ },
+ width: "40%",
+ draggable: true,
+ fullscreen: deviceDetection(),
+ fullscreenIcon: true,
+ closeOnClickModal: false,
+ contentRenderer: () => h(editForm, { ref: formRef, formInline: null }),
+ beforeSure: (done, { options }) => {
+ const FormRef = formRef.value.getRef();
+ const curData = options.props.formInline as FormItemProps;
+
+ function chores() {
+ message(`您${title}了角色名称为${curData.name}的这条数据`, {
+ type: "success"
+ });
+ done(); // 关闭弹框
+ onSearch(); // 刷新表格数据
+ }
+
+ FormRef.validate(valid => {
+ if (valid) {
+ console.log("curData", curData);
+ // 表单规则校验通过
+ if (title === "新增") {
+ // 实际开发先调用新增接口,再进行下面操作
+ chores();
+ } else {
+ // 实际开发先调用修改接口,再进行下面操作
+ chores();
+ }
+ }
+ });
+ }
+ });
+ }
+
+ /** 菜单权限 */
+ async function handleMenu(row?: any) {
+ const { id } = row;
+ if (id) {
+ curRow.value = row;
+ isShow.value = true;
+ const { data } = await getRoleMenuIds({ id });
+ treeRef.value.setCheckedKeys(data);
+ } else {
+ curRow.value = null;
+ isShow.value = false;
+ }
+ }
+
+ /** 高亮当前权限选中行 */
+ function rowStyle({ row: { id } }) {
+ return {
+ cursor: "pointer",
+ background: id === curRow.value?.id ? "var(--el-fill-color-light)" : ""
+ };
+ }
+
+ /** 菜单权限-保存 */
+ function handleSave() {
+ const { id, name } = curRow.value;
+ // 根据用户 id 调用实际项目中菜单权限修改接口
+ console.log(id, treeRef.value.getCheckedKeys());
+ message(`角色名称为${name}的菜单权限修改成功`, {
+ type: "success"
+ });
+ }
+
+ /** 数据权限 可自行开发 */
+ // function handleDatabase() {}
+
+ const onQueryChanged = (query: string) => {
+ treeRef.value!.filter(query);
+ };
+
+ const filterMethod = (query: string, node) => {
+ return $t(node.title)!.includes(query);
+ };
+
+ onMounted(async () => {
+ onSearch();
+ const { data } = await getRoleMenu();
+ treeIds.value = getKeyList(data, "id");
+ treeData.value = handleTree(data);
+ });
+
+ watch(isExpandAll, val => {
+ val
+ ? treeRef.value.setExpandedKeys(treeIds.value)
+ : treeRef.value.setExpandedKeys([]);
+ });
+
+ watch(isSelectAll, val => {
+ val
+ ? treeRef.value.setCheckedKeys(treeIds.value)
+ : treeRef.value.setCheckedKeys([]);
+ });
+
+ return {
+ form,
+ isShow,
+ curRow,
+ loading,
+ columns,
+ rowStyle,
+ dataList,
+ treeData,
+ treeProps,
+ isLinkage,
+ pagination,
+ isExpandAll,
+ isSelectAll,
+ treeSearchValue,
+ onSearch,
+ resetForm,
+ openDialog,
+ handleMenu,
+ handleSave,
+ handleDelete,
+ filterMethod,
+ $t,
+ onQueryChanged,
+ handleSizeChange,
+ handleCurrentChange,
+ handleSelectionChange
+ };
+}
diff --git a/src/views/role/utils/rule.ts b/src/views/role/utils/rule.ts
new file mode 100644
index 0000000..ea1dd19
--- /dev/null
+++ b/src/views/role/utils/rule.ts
@@ -0,0 +1,8 @@
+import { reactive } from "vue";
+import type { FormRules } from "element-plus";
+
+/** 自定义表单规则校验 */
+export const formRules = reactive({
+ name: [{ required: true, message: "角色名称为必填项", trigger: "blur" }],
+ code: [{ required: true, message: "角色标识为必填项", trigger: "blur" }]
+});
diff --git a/src/views/role/utils/types.ts b/src/views/role/utils/types.ts
new file mode 100644
index 0000000..a17e900
--- /dev/null
+++ b/src/views/role/utils/types.ts
@@ -0,0 +1,15 @@
+// 虽然字段很少 但是抽离出来 后续有扩展字段需求就很方便了
+
+interface FormItemProps {
+ /** 角色名称 */
+ name: string;
+ /** 角色编号 */
+ code: string;
+ /** 备注 */
+ remark: string;
+}
+interface FormProps {
+ formInline: FormItemProps;
+}
+
+export type { FormItemProps, FormProps };
diff --git a/src/views/user/form/index.vue b/src/views/user/form/index.vue
new file mode 100644
index 0000000..007835a
--- /dev/null
+++ b/src/views/user/form/index.vue
@@ -0,0 +1,176 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ data.name }}
+ ({{ data.children.length }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/user/form/role.vue b/src/views/user/form/role.vue
new file mode 100644
index 0000000..129b09f
--- /dev/null
+++ b/src/views/user/form/role.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.name }}
+
+
+
+
+
+
+
diff --git a/src/views/user/index.vue b/src/views/user/index.vue
new file mode 100644
index 0000000..da9e451
--- /dev/null
+++ b/src/views/user/index.vue
@@ -0,0 +1,275 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 搜索
+
+
+ 重置
+
+
+
+
+
+
+
+ 新增用户
+
+
+
+
+
+
+ 已选 {{ selectedNum }} 项
+
+
+ 取消选择
+
+
+
+
+
+ 批量删除
+
+
+
+
+
+
+
+ 修改
+
+
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+ 上传头像
+
+
+
+
+ 重置密码
+
+
+
+
+ 分配角色
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/user/svg/expand.svg b/src/views/user/svg/expand.svg
new file mode 100644
index 0000000..bb41c35
--- /dev/null
+++ b/src/views/user/svg/expand.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/views/user/svg/unexpand.svg b/src/views/user/svg/unexpand.svg
new file mode 100644
index 0000000..04b3e9d
--- /dev/null
+++ b/src/views/user/svg/unexpand.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/views/user/tree.vue b/src/views/user/tree.vue
new file mode 100644
index 0000000..d77c2c9
--- /dev/null
+++ b/src/views/user/tree.vue
@@ -0,0 +1,215 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ isExpand ? "折叠全部" : "展开全部" }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ node.label }}
+
+
+
+
+
+
+
+
+
diff --git a/src/views/user/utils/hook.tsx b/src/views/user/utils/hook.tsx
new file mode 100644
index 0000000..c114b1d
--- /dev/null
+++ b/src/views/user/utils/hook.tsx
@@ -0,0 +1,538 @@
+import "./reset.css";
+import dayjs from "dayjs";
+import roleForm from "../form/role.vue";
+import editForm from "../form/index.vue";
+import { zxcvbn } from "@zxcvbn-ts/core";
+import { handleTree } from "@/utils/tree";
+import { message } from "@/utils/message";
+import userAvatar from "@/assets/user.jpg";
+import { usePublicHooks } from "../../hooks";
+import { addDialog } from "@/components/BaseDialog";
+import type { PaginationProps } from "@pureadmin/table";
+import ReCropperPreview from "@/components/ReCropperPreview";
+import type { FormItemProps, RoleFormItemProps } from "../utils/types";
+import {
+ deviceDetection,
+ getKeyList,
+ hideTextAtIndex,
+ isAllEmpty
+} from "@pureadmin/utils";
+import {
+ getAllRoleList,
+ getDeptList,
+ getRoleIds,
+ getUserList
+} from "@/api/v1/system";
+import {
+ ElForm,
+ ElFormItem,
+ ElInput,
+ ElMessageBox,
+ ElProgress
+} from "element-plus";
+import {
+ computed,
+ h,
+ onMounted,
+ reactive,
+ ref,
+ type Ref,
+ toRaw,
+ watch
+} from "vue";
+
+export function useUser(tableRef: Ref, treeRef: Ref) {
+ const form = reactive({
+ // 左侧部门树的id
+ deptId: "",
+ username: "",
+ phone: "",
+ status: ""
+ });
+ const formRef = ref();
+ const ruleFormRef = ref();
+ const dataList = ref([]);
+ const loading = ref(true);
+ // 上传头像信息
+ const avatarInfo = ref();
+ const switchLoadMap = ref({});
+ const { switchStyle } = usePublicHooks();
+ const higherDeptOptions = ref();
+ const treeData = ref([]);
+ const treeLoading = ref(true);
+ const selectedNum = ref(0);
+ const pagination = reactive({
+ total: 0,
+ pageSize: 10,
+ currentPage: 1,
+ background: true
+ });
+ const columns: TableColumnList = [
+ {
+ label: "勾选列", // 如果需要表格多选,此处label必须设置
+ type: "selection",
+ fixed: "left",
+ reserveSelection: true // 数据刷新后保留选项
+ },
+ {
+ label: "用户编号",
+ prop: "id",
+ width: 90
+ },
+ {
+ label: "用户头像",
+ prop: "avatar",
+ cellRenderer: ({ row }) => (
+
+ ),
+ width: 90
+ },
+ {
+ label: "用户名称",
+ prop: "username",
+ minWidth: 130
+ },
+ {
+ label: "用户昵称",
+ prop: "nickname",
+ minWidth: 130
+ },
+ {
+ label: "性别",
+ prop: "sex",
+ minWidth: 90,
+ cellRenderer: ({ row, props }) => (
+
+ {row.sex === 1 ? "女" : "男"}
+
+ )
+ },
+ {
+ label: "部门",
+ prop: "dept.name",
+ minWidth: 90
+ },
+ {
+ label: "手机号码",
+ prop: "phone",
+ minWidth: 90,
+ formatter: ({ phone }) => hideTextAtIndex(phone, { start: 3, end: 6 })
+ },
+ {
+ label: "状态",
+ prop: "status",
+ minWidth: 90,
+ cellRenderer: scope => (
+ onChange(scope as any)}
+ />
+ )
+ },
+ {
+ label: "创建时间",
+ minWidth: 90,
+ prop: "createTime",
+ formatter: ({ createTime }) =>
+ dayjs(createTime).format("YYYY-MM-DD HH:mm:ss")
+ },
+ {
+ label: "操作",
+ fixed: "right",
+ width: 180,
+ slot: "operation"
+ }
+ ];
+ const buttonClass = computed(() => {
+ return [
+ "!h-[20px]",
+ "reset-margin",
+ "!text-gray-500",
+ "dark:!text-white",
+ "dark:hover:!text-primary"
+ ];
+ });
+ // 重置的新密码
+ const pwdForm = reactive({
+ newPwd: ""
+ });
+ const pwdProgress = [
+ { color: "#e74242", text: "非常弱" },
+ { color: "#EFBD47", text: "弱" },
+ { color: "#ffa500", text: "一般" },
+ { color: "#1bbf1b", text: "强" },
+ { color: "#008000", text: "非常强" }
+ ];
+ // 当前密码强度(0-4)
+ const curScore = ref();
+ const roleOptions = ref([]);
+
+ function onChange({ row, index }) {
+ ElMessageBox.confirm(
+ `确认要${
+ row.status === 0 ? "停用" : "启用"
+ }${
+ row.username
+ }用户吗?`,
+ "系统提示",
+ {
+ confirmButtonText: "确定",
+ cancelButtonText: "取消",
+ type: "warning",
+ dangerouslyUseHTMLString: true,
+ draggable: true
+ }
+ )
+ .then(() => {
+ switchLoadMap.value[index] = Object.assign(
+ {},
+ switchLoadMap.value[index],
+ {
+ loading: true
+ }
+ );
+ setTimeout(() => {
+ switchLoadMap.value[index] = Object.assign(
+ {},
+ switchLoadMap.value[index],
+ {
+ loading: false
+ }
+ );
+ message("已成功修改用户状态", {
+ type: "success"
+ });
+ }, 300);
+ })
+ .catch(() => {
+ row.status === 0 ? (row.status = 1) : (row.status = 0);
+ });
+ }
+
+ function handleUpdate(row) {
+ console.log(row);
+ }
+
+ function handleDelete(row) {
+ message(`您删除了用户编号为${row.id}的这条数据`, { type: "success" });
+ onSearch();
+ }
+
+ function handleSizeChange(val: number) {
+ console.log(`${val} items per page`);
+ }
+
+ function handleCurrentChange(val: number) {
+ console.log(`current page: ${val}`);
+ }
+
+ /** 当CheckBox选择项发生变化时会触发该事件 */
+ function handleSelectionChange(val) {
+ selectedNum.value = val.length;
+ // 重置表格高度
+ tableRef.value.setAdaptive();
+ }
+
+ /** 取消选择 */
+ function onSelectionCancel() {
+ selectedNum.value = 0;
+ // 用于多选表格,清空用户的选择
+ tableRef.value.getTableRef().clearSelection();
+ }
+
+ /** 批量删除 */
+ function onbatchDel() {
+ // 返回当前选中的行
+ const curSelected = tableRef.value.getTableRef().getSelectionRows();
+ // 接下来根据实际业务,通过选中行的某项数据,比如下面的id,调用接口进行批量删除
+ message(`已删除用户编号为 ${getKeyList(curSelected, "id")} 的数据`, {
+ type: "success"
+ });
+ tableRef.value.getTableRef().clearSelection();
+ onSearch();
+ }
+
+ async function onSearch() {
+ loading.value = true;
+ const { data } = await getUserList(toRaw(form));
+ dataList.value = data.list;
+ pagination.total = data.total;
+ pagination.pageSize = data.pageSize;
+ pagination.currentPage = data.currentPage;
+
+ setTimeout(() => {
+ loading.value = false;
+ }, 500);
+ }
+
+ const resetForm = formEl => {
+ if (!formEl) return;
+ formEl.resetFields();
+ form.deptId = "";
+ treeRef.value.onTreeReset();
+ onSearch();
+ };
+
+ function onTreeSelect({ id, selected }) {
+ form.deptId = selected ? id : "";
+ onSearch();
+ }
+
+ function formatHigherDeptOptions(treeList) {
+ // 根据返回数据的status字段值判断追加是否禁用disabled字段,返回处理后的树结构,用于上级部门级联选择器的展示(实际开发中也是如此,不可能前端需要的每个字段后端都会返回,这时需要前端自行根据后端返回的某些字段做逻辑处理)
+ if (!treeList || !treeList.length) return;
+ const newTreeList = [];
+ for (let i = 0; i < treeList.length; i++) {
+ treeList[i].disabled = treeList[i].status === 0 ? true : false;
+ formatHigherDeptOptions(treeList[i].children);
+ newTreeList.push(treeList[i]);
+ }
+ return newTreeList;
+ }
+
+ function openDialog(title = "新增", row?: FormItemProps) {
+ addDialog({
+ title: `${title}用户`,
+ props: {
+ formInline: {
+ title,
+ higherDeptOptions: formatHigherDeptOptions(higherDeptOptions.value),
+ parentId: row?.dept.id ?? 0,
+ nickname: row?.nickname ?? "",
+ username: row?.username ?? "",
+ password: row?.password ?? "",
+ phone: row?.phone ?? "",
+ email: row?.email ?? "",
+ sex: row?.sex ?? "",
+ status: row?.status ?? 1,
+ remark: row?.remark ?? ""
+ }
+ },
+ width: "46%",
+ draggable: true,
+ fullscreen: deviceDetection(),
+ fullscreenIcon: true,
+ closeOnClickModal: false,
+ contentRenderer: () => h(editForm, { ref: formRef, formInline: null }),
+ beforeSure: (done, { options }) => {
+ const FormRef = formRef.value.getRef();
+ const curData = options.props.formInline as FormItemProps;
+
+ function chores() {
+ message(`您${title}了用户名称为${curData.username}的这条数据`, {
+ type: "success"
+ });
+ done(); // 关闭弹框
+ onSearch(); // 刷新表格数据
+ }
+
+ FormRef.validate(valid => {
+ if (valid) {
+ console.log("curData", curData);
+ // 表单规则校验通过
+ if (title === "新增") {
+ // 实际开发先调用新增接口,再进行下面操作
+ chores();
+ } else {
+ // 实际开发先调用修改接口,再进行下面操作
+ chores();
+ }
+ }
+ });
+ }
+ });
+ }
+
+ const cropRef = ref();
+
+ /** 上传头像 */
+ function handleUpload(row) {
+ addDialog({
+ title: "裁剪、上传头像",
+ width: "40%",
+ closeOnClickModal: false,
+ fullscreen: deviceDetection(),
+ contentRenderer: () =>
+ h(ReCropperPreview, {
+ ref: cropRef,
+ imgSrc: row.avatar || userAvatar,
+ onCropper: info => (avatarInfo.value = info)
+ }),
+ beforeSure: done => {
+ console.log("裁剪后的图片信息:", avatarInfo.value);
+ // 根据实际业务使用avatarInfo.value和row里的某些字段去调用上传头像接口即可
+ done(); // 关闭弹框
+ onSearch(); // 刷新表格数据
+ },
+ closeCallBack: () => cropRef.value.hidePopover()
+ });
+ }
+
+ watch(
+ pwdForm,
+ ({ newPwd }) =>
+ (curScore.value = isAllEmpty(newPwd) ? -1 : zxcvbn(newPwd).score)
+ );
+
+ /** 重置密码 */
+ function handleReset(row) {
+ addDialog({
+ title: `重置 ${row.username} 用户的密码`,
+ width: "30%",
+ draggable: true,
+ closeOnClickModal: false,
+ fullscreen: deviceDetection(),
+ contentRenderer: () => (
+ <>
+
+
+
+
+
+
+ {pwdProgress.map(({ color, text }, idx) => (
+
+
= idx ? 100 : 0}
+ color={color}
+ stroke-width={10}
+ show-text={false}
+ />
+
+ {text}
+
+
+ ))}
+
+ >
+ ),
+ closeCallBack: () => (pwdForm.newPwd = ""),
+ beforeSure: done => {
+ ruleFormRef.value.validate(valid => {
+ if (valid) {
+ // 表单规则校验通过
+ message(`已成功重置 ${row.username} 用户的密码`, {
+ type: "success"
+ });
+ console.log(pwdForm.newPwd);
+ // 根据实际业务使用pwdForm.newPwd和row里的某些字段去调用重置用户密码接口即可
+ done(); // 关闭弹框
+ onSearch(); // 刷新表格数据
+ }
+ });
+ }
+ });
+ }
+
+ /** 分配角色 */
+ async function handleRole(row) {
+ // 选中的角色列表
+ const ids = (await getRoleIds({ userId: row.id })).data ?? [];
+ addDialog({
+ title: `分配 ${row.username} 用户的角色`,
+ props: {
+ formInline: {
+ username: row?.username ?? "",
+ nickname: row?.nickname ?? "",
+ roleOptions: roleOptions.value ?? [],
+ ids
+ }
+ },
+ width: "400px",
+ draggable: true,
+ fullscreen: deviceDetection(),
+ fullscreenIcon: true,
+ closeOnClickModal: false,
+ contentRenderer: () => h(roleForm),
+ beforeSure: (done, { options }) => {
+ const curData = options.props.formInline as RoleFormItemProps;
+ console.log("curIds", curData.ids);
+ // 根据实际业务使用curData.ids和row里的某些字段去调用修改角色接口即可
+ done(); // 关闭弹框
+ }
+ });
+ }
+
+ onMounted(async () => {
+ treeLoading.value = true;
+ onSearch();
+
+ // 归属部门
+ const { data } = await getDeptList();
+ higherDeptOptions.value = handleTree(data);
+ treeData.value = handleTree(data);
+ treeLoading.value = false;
+
+ // 角色列表
+ roleOptions.value = (await getAllRoleList()).data;
+ });
+
+ return {
+ form,
+ loading,
+ columns,
+ dataList,
+ treeData,
+ treeLoading,
+ selectedNum,
+ pagination,
+ buttonClass,
+ deviceDetection,
+ onSearch,
+ resetForm,
+ onbatchDel,
+ openDialog,
+ onTreeSelect,
+ handleUpdate,
+ handleDelete,
+ handleUpload,
+ handleReset,
+ handleRole,
+ handleSizeChange,
+ onSelectionCancel,
+ handleCurrentChange,
+ handleSelectionChange
+ };
+}
diff --git a/src/views/user/utils/reset.css b/src/views/user/utils/reset.css
new file mode 100644
index 0000000..97f4e4f
--- /dev/null
+++ b/src/views/user/utils/reset.css
@@ -0,0 +1,5 @@
+/** 局部重置 ElProgress 的部分样式 */
+.el-progress-bar__outer,
+.el-progress-bar__inner {
+ border-radius: 0;
+}
diff --git a/src/views/user/utils/rule.ts b/src/views/user/utils/rule.ts
new file mode 100644
index 0000000..f946ee2
--- /dev/null
+++ b/src/views/user/utils/rule.ts
@@ -0,0 +1,39 @@
+import { reactive } from "vue";
+import type { FormRules } from "element-plus";
+import { isPhone, isEmail } from "@pureadmin/utils";
+
+/** 自定义表单规则校验 */
+export const formRules = reactive({
+ nickname: [{ required: true, message: "用户昵称为必填项", trigger: "blur" }],
+ username: [{ required: true, message: "用户名称为必填项", trigger: "blur" }],
+ password: [{ required: true, message: "用户密码为必填项", trigger: "blur" }],
+ phone: [
+ {
+ validator: (rule, value, callback) => {
+ if (value === "") {
+ callback();
+ } else if (!isPhone(value)) {
+ callback(new Error("请输入正确的手机号码格式"));
+ } else {
+ callback();
+ }
+ },
+ trigger: "blur"
+ // trigger: "click" // 如果想在点击确定按钮时触发这个校验,trigger 设置成 click 即可
+ }
+ ],
+ email: [
+ {
+ validator: (rule, value, callback) => {
+ if (value === "") {
+ callback();
+ } else if (!isEmail(value)) {
+ callback(new Error("请输入正确的邮箱格式"));
+ } else {
+ callback();
+ }
+ },
+ trigger: "blur"
+ }
+ ]
+});
diff --git a/src/views/user/utils/types.ts b/src/views/user/utils/types.ts
new file mode 100644
index 0000000..c5ab88c
--- /dev/null
+++ b/src/views/user/utils/types.ts
@@ -0,0 +1,36 @@
+interface FormItemProps {
+ id?: number;
+ /** 用于判断是`新增`还是`修改` */
+ title: string;
+ higherDeptOptions: Record[];
+ parentId: number;
+ nickname: string;
+ username: string;
+ password: string;
+ phone: string | number;
+ email: string;
+ sex: string | number;
+ status: number;
+ dept?: {
+ id?: number;
+ name?: string;
+ };
+ remark: string;
+}
+interface FormProps {
+ formInline: FormItemProps;
+}
+
+interface RoleFormItemProps {
+ username: string;
+ nickname: string;
+ /** 角色列表 */
+ roleOptions: any[];
+ /** 选中的角色列表 */
+ ids: Record[];
+}
+interface RoleFormProps {
+ formInline: RoleFormItemProps;
+}
+
+export type { FormItemProps, FormProps, RoleFormItemProps, RoleFormProps };
diff --git a/src/views/welcome/components/charts/ChartBar.vue b/src/views/welcome/components/charts/ChartBar.vue
new file mode 100644
index 0000000..869763e
--- /dev/null
+++ b/src/views/welcome/components/charts/ChartBar.vue
@@ -0,0 +1,107 @@
+
+
+
+
+
diff --git a/src/views/welcome/components/charts/ChartLine.vue b/src/views/welcome/components/charts/ChartLine.vue
new file mode 100644
index 0000000..fa72ec1
--- /dev/null
+++ b/src/views/welcome/components/charts/ChartLine.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
diff --git a/src/views/welcome/components/charts/ChartRound.vue b/src/views/welcome/components/charts/ChartRound.vue
new file mode 100644
index 0000000..d13ce84
--- /dev/null
+++ b/src/views/welcome/components/charts/ChartRound.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
diff --git a/src/views/welcome/components/charts/index.ts b/src/views/welcome/components/charts/index.ts
new file mode 100644
index 0000000..fa55448
--- /dev/null
+++ b/src/views/welcome/components/charts/index.ts
@@ -0,0 +1,3 @@
+export { default as ChartBar } from "./ChartBar.vue";
+export { default as ChartLine } from "./ChartLine.vue";
+export { default as ChartRound } from "./ChartRound.vue";
diff --git a/src/views/welcome/components/table/columns.tsx b/src/views/welcome/components/table/columns.tsx
new file mode 100644
index 0000000..c6d0b8e
--- /dev/null
+++ b/src/views/welcome/components/table/columns.tsx
@@ -0,0 +1,104 @@
+import { tableData } from "../../data";
+import { delay } from "@pureadmin/utils";
+import { ref, onMounted, reactive } from "vue";
+import type { PaginationProps } from "@pureadmin/table";
+import ThumbUp from "@iconify-icons/ri/thumb-up-line";
+import Hearts from "@iconify-icons/ri/hearts-line";
+import Empty from "./empty.svg?component";
+
+export function useColumns() {
+ const dataList = ref([]);
+ const loading = ref(true);
+ const columns: TableColumnList = [
+ {
+ sortable: true,
+ label: "序号",
+ prop: "id"
+ },
+ {
+ sortable: true,
+ label: "需求人数",
+ prop: "requiredNumber",
+ filterMultiple: false,
+ filterClassName: "pure-table-filter",
+ filters: [
+ { text: "≥16000", value: "more" },
+ { text: "<16000", value: "less" }
+ ],
+ filterMethod: (value, { requiredNumber }) => {
+ return value === "more"
+ ? requiredNumber >= 16000
+ : requiredNumber < 16000;
+ }
+ },
+ {
+ sortable: true,
+ label: "提问数量",
+ prop: "questionNumber"
+ },
+ {
+ sortable: true,
+ label: "解决数量",
+ prop: "resolveNumber"
+ },
+ {
+ sortable: true,
+ label: "用户满意度",
+ minWidth: 100,
+ prop: "satisfaction",
+ cellRenderer: ({ row }) => (
+
+
+ {row.satisfaction}%
+ 98 ? Hearts : ThumbUp}
+ color="#e85f33"
+ />
+
+
+ )
+ },
+ {
+ sortable: true,
+ label: "统计日期",
+ prop: "date"
+ },
+ {
+ label: "操作",
+ fixed: "right",
+ slot: "operation"
+ }
+ ];
+
+ /** 分页配置 */
+ const pagination = reactive({
+ pageSize: 10,
+ currentPage: 1,
+ layout: "prev, pager, next",
+ total: 0,
+ align: "center"
+ });
+
+ function onCurrentChange(page: number) {
+ console.log("onCurrentChange", page);
+ loading.value = true;
+ delay(300).then(() => {
+ loading.value = false;
+ });
+ }
+
+ onMounted(() => {
+ dataList.value = tableData;
+ pagination.total = dataList.value.length;
+ loading.value = false;
+ });
+
+ return {
+ Empty,
+ loading,
+ columns,
+ dataList,
+ pagination,
+ onCurrentChange
+ };
+}
diff --git a/src/views/welcome/components/table/empty.svg b/src/views/welcome/components/table/empty.svg
new file mode 100644
index 0000000..5c8b211
--- /dev/null
+++ b/src/views/welcome/components/table/empty.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/views/welcome/components/table/index.vue b/src/views/welcome/components/table/index.vue
new file mode 100644
index 0000000..5490160
--- /dev/null
+++ b/src/views/welcome/components/table/index.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/welcome/data.ts b/src/views/welcome/data.ts
new file mode 100644
index 0000000..3bb5021
--- /dev/null
+++ b/src/views/welcome/data.ts
@@ -0,0 +1,134 @@
+import { dayjs, cloneDeep, getRandomIntBetween } from "./utils";
+import GroupLine from "@iconify-icons/ri/group-line";
+import Question from "@iconify-icons/ri/question-answer-line";
+import CheckLine from "@iconify-icons/ri/chat-check-line";
+import Smile from "@iconify-icons/ri/star-smile-line";
+
+const days = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
+
+/** 需求人数、提问数量、解决数量、用户满意度 */
+const chartData = [
+ {
+ icon: GroupLine,
+ bgColor: "#effaff",
+ color: "#41b6ff",
+ duration: 2200,
+ name: "需求人数",
+ value: 36000,
+ percent: "+88%",
+ data: [2101, 5288, 4239, 4962, 6752, 5208, 7450] // 平滑折线图数据
+ },
+ {
+ icon: Question,
+ bgColor: "#fff5f4",
+ color: "#e85f33",
+ duration: 1600,
+ name: "提问数量",
+ value: 16580,
+ percent: "+70%",
+ data: [2216, 1148, 1255, 788, 4821, 1973, 4379]
+ },
+ {
+ icon: CheckLine,
+ bgColor: "#eff8f4",
+ color: "#26ce83",
+ duration: 1500,
+ name: "解决数量",
+ value: 16499,
+ percent: "+99%",
+ data: [861, 1002, 3195, 1715, 3666, 2415, 3645]
+ },
+ {
+ icon: Smile,
+ bgColor: "#f6f4fe",
+ color: "#7846e5",
+ duration: 100,
+ name: "用户满意度",
+ value: 100,
+ percent: "+100%",
+ data: [100]
+ }
+];
+
+/** 分析概览 */
+const barChartData = [
+ {
+ requireData: [2101, 5288, 4239, 4962, 6752, 5208, 7450],
+ questionData: [2216, 1148, 1255, 1788, 4821, 1973, 4379]
+ },
+ {
+ requireData: [2101, 3280, 4400, 4962, 5752, 6889, 7600],
+ questionData: [2116, 3148, 3255, 3788, 4821, 4970, 5390]
+ }
+];
+
+/** 解决概率 */
+const progressData = [
+ {
+ week: "周一",
+ percentage: 85,
+ duration: 110,
+ color: "#41b6ff"
+ },
+ {
+ week: "周二",
+ percentage: 86,
+ duration: 105,
+ color: "#41b6ff"
+ },
+ {
+ week: "周三",
+ percentage: 88,
+ duration: 100,
+ color: "#41b6ff"
+ },
+ {
+ week: "周四",
+ percentage: 89,
+ duration: 95,
+ color: "#41b6ff"
+ },
+ {
+ week: "周五",
+ percentage: 94,
+ duration: 90,
+ color: "#26ce83"
+ },
+ {
+ week: "周六",
+ percentage: 96,
+ duration: 85,
+ color: "#26ce83"
+ },
+ {
+ week: "周日",
+ percentage: 100,
+ duration: 80,
+ color: "#26ce83"
+ }
+].reverse();
+
+/** 数据统计 */
+const tableData = Array.from({ length: 30 }).map((_, index) => {
+ return {
+ id: index + 1,
+ requiredNumber: getRandomIntBetween(13500, 19999),
+ questionNumber: getRandomIntBetween(12600, 16999),
+ resolveNumber: getRandomIntBetween(13500, 17999),
+ satisfaction: getRandomIntBetween(95, 100),
+ date: dayjs().subtract(index, "day").format("YYYY-MM-DD")
+ };
+});
+
+/** 最新动态 */
+const latestNewsData = cloneDeep(tableData)
+ .slice(0, 14)
+ .map((item, index) => {
+ return Object.assign(item, {
+ date: `${dayjs().subtract(index, "day").format("YYYY-MM-DD")} ${
+ days[dayjs().subtract(index, "day").day()]
+ }`
+ });
+ });
+
+export { chartData, barChartData, progressData, tableData, latestNewsData };
diff --git a/src/views/welcome/index.vue b/src/views/welcome/index.vue
new file mode 100644
index 0000000..0d5deea
--- /dev/null
+++ b/src/views/welcome/index.vue
@@ -0,0 +1,213 @@
+
+
+
+
+
+
+
+
+
+ {{ item.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 分析概览
+
+
+
+
+
+
+
+
+
+
+
+ 解决概率
+
+
+
+
+ {{ item.week }}
+
+
+
+
+
+
+
+
+ 数据统计
+
+
+
+
+
+
+
+
+ 最新动态
+
+
+
+
+
+ {{
+ `新增 ${item.requiredNumber} 条问题,${item.resolveNumber} 条已解决`
+ }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/welcome/utils.ts b/src/views/welcome/utils.ts
new file mode 100644
index 0000000..7708a7e
--- /dev/null
+++ b/src/views/welcome/utils.ts
@@ -0,0 +1,6 @@
+export { default as dayjs } from "dayjs";
+export { useDark, cloneDeep, randomGradient } from "@pureadmin/utils";
+
+export function getRandomIntBetween(min: number, max: number) {
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+}
diff --git a/stylelint.config.js b/stylelint.config.js
new file mode 100644
index 0000000..2417ddf
--- /dev/null
+++ b/stylelint.config.js
@@ -0,0 +1,87 @@
+// @ts-check
+
+/** @type {import("stylelint").Config} */
+export default {
+ extends: [
+ "stylelint-config-standard",
+ "stylelint-config-html/vue",
+ "stylelint-config-recess-order"
+ ],
+ plugins: ["stylelint-scss", "stylelint-order", "stylelint-prettier"],
+ overrides: [
+ {
+ files: ["**/*.(css|html|vue)"],
+ customSyntax: "postcss-html"
+ },
+ {
+ files: ["*.scss", "**/*.scss"],
+ customSyntax: "postcss-scss",
+ extends: [
+ "stylelint-config-standard-scss",
+ "stylelint-config-recommended-vue/scss"
+ ]
+ }
+ ],
+ rules: {
+ "prettier/prettier": true,
+ "selector-class-pattern": null,
+ "no-descending-specificity": null,
+ "scss/dollar-variable-pattern": null,
+ "selector-pseudo-class-no-unknown": [
+ true,
+ {
+ ignorePseudoClasses: ["deep", "global"]
+ }
+ ],
+ "selector-pseudo-element-no-unknown": [
+ true,
+ {
+ ignorePseudoElements: ["v-deep", "v-global", "v-slotted"]
+ }
+ ],
+ "at-rule-no-unknown": [
+ true,
+ {
+ ignoreAtRules: [
+ "tailwind",
+ "apply",
+ "variants",
+ "responsive",
+ "screen",
+ "function",
+ "if",
+ "each",
+ "include",
+ "mixin",
+ "use"
+ ]
+ }
+ ],
+ "rule-empty-line-before": [
+ "always",
+ {
+ ignore: ["after-comment", "first-nested"]
+ }
+ ],
+ "unit-no-unknown": [true, { ignoreUnits: ["rpx"] }],
+ "order/order": [
+ [
+ "dollar-variables",
+ "custom-properties",
+ "at-rules",
+ "declarations",
+ {
+ type: "at-rule",
+ name: "supports"
+ },
+ {
+ type: "at-rule",
+ name: "media"
+ },
+ "rules"
+ ],
+ { severity: "warning" }
+ ]
+ },
+ ignoreFiles: ["**/*.js", "**/*.ts", "**/*.jsx", "**/*.tsx", "report.html"]
+};
diff --git a/tailwind.config.ts b/tailwind.config.ts
new file mode 100644
index 0000000..8f58f44
--- /dev/null
+++ b/tailwind.config.ts
@@ -0,0 +1,19 @@
+import type { Config } from "tailwindcss";
+
+export default {
+ darkMode: "class",
+ corePlugins: {
+ preflight: false
+ },
+ content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
+ theme: {
+ extend: {
+ colors: {
+ bg_color: "var(--el-bg-color)",
+ primary: "var(--el-color-primary)",
+ text_color_primary: "var(--el-text-color-primary)",
+ text_color_regular: "var(--el-text-color-regular)"
+ }
+ }
+ }
+} satisfies Config;
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..9294205
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,53 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": false,
+ "jsx": "preserve",
+ "importHelpers": true,
+ "experimentalDecorators": true,
+ "strictFunctionTypes": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "isolatedModules": true,
+ "allowSyntheticDefaultImports": true,
+ "forceConsistentCasingInFileNames": true,
+ "sourceMap": true,
+ "baseUrl": ".",
+ "allowJs": false,
+ "resolveJsonModule": true,
+ "lib": [
+ "ESNext",
+ "DOM"
+ ],
+ "paths": {
+ "@/*": [
+ "src/*"
+ ],
+ "@build/*": [
+ "build/*"
+ ]
+ },
+ "types": [
+ "node",
+ "vite/client",
+ "element-plus/global",
+ "@pureadmin/table/volar",
+ "@pureadmin/descriptions/volar"
+ ]
+ },
+ "include": [
+ "mock/*.ts",
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ "src/**/*.vue",
+ "src/types/*.d.ts",
+ "vite.config.ts"
+ ],
+ "exclude": [
+ "dist",
+ "**/*.js",
+ "node_modules"
+ ]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..85751a7
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,32 @@
+import { getPluginsList } from "./build/plugins";
+import { exclude, include } from "./build/optimize";
+import { type ConfigEnv, loadEnv, type UserConfigExport } from "vite";
+import { __APP_INFO__, alias, root, wrapperEnv } from "./build/utils";
+import { serverOptions } from "./build/server";
+import { buildEnvironment } from "./build/buildEnv";
+
+export default ({ mode }: ConfigEnv): UserConfigExport => {
+ const { VITE_CDN, VITE_PORT, VITE_COMPRESSION, VITE_PUBLIC_PATH } =
+ wrapperEnv(loadEnv(mode, root));
+ return {
+ base: VITE_PUBLIC_PATH,
+ root,
+ resolve: { alias },
+ // 服务端渲染
+ server: serverOptions(mode),
+ plugins: getPluginsList(VITE_CDN, VITE_COMPRESSION, VITE_PORT),
+ // https://cn.vitejs.dev/config/dep-optimization-options.html#dep-optimization-options
+ optimizeDeps: { include, exclude },
+ esbuild: {
+ pure: ["console.log", "debugger"],
+ jsxFactory: "h",
+ jsxFragment: "Fragment",
+ jsxInject: "import { h } from 'vue';"
+ },
+ build: buildEnvironment(),
+ define: {
+ __INTLIFY_PROD_DEVTOOLS__: false,
+ __APP_INFO__: JSON.stringify(__APP_INFO__)
+ }
+ };
+};