(
+ 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);
+ }
+}
+
+export const http = new PureHttp();
diff --git a/web/src/utils/http/types.d.ts b/web/src/utils/http/types.d.ts
new file mode 100644
index 0000000..ef7c25f
--- /dev/null
+++ b/web/src/utils/http/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/web/src/utils/localforage/index.ts b/web/src/utils/localforage/index.ts
new file mode 100644
index 0000000..013545f
--- /dev/null
+++ b/web/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/web/src/utils/localforage/types.d.ts b/web/src/utils/localforage/types.d.ts
new file mode 100644
index 0000000..b013c5b
--- /dev/null
+++ b/web/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/web/src/utils/message.ts b/web/src/utils/message.ts
new file mode 100644
index 0000000..40898ac
--- /dev/null
+++ b/web/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/web/src/utils/mitt.ts b/web/src/utils/mitt.ts
new file mode 100644
index 0000000..63816f1
--- /dev/null
+++ b/web/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/web/src/utils/preventDefault.ts b/web/src/utils/preventDefault.ts
new file mode 100644
index 0000000..42da8df
--- /dev/null
+++ b/web/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/web/src/utils/print.ts b/web/src/utils/print.ts
new file mode 100644
index 0000000..6d2051c
--- /dev/null
+++ b/web/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/web/src/utils/progress/index.ts b/web/src/utils/progress/index.ts
new file mode 100644
index 0000000..d309862
--- /dev/null
+++ b/web/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/web/src/utils/propTypes.ts b/web/src/utils/propTypes.ts
new file mode 100644
index 0000000..a4d67ec
--- /dev/null
+++ b/web/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/web/src/utils/responsive.ts b/web/src/utils/responsive.ts
new file mode 100644
index 0000000..356efb4
--- /dev/null
+++ b/web/src/utils/responsive.ts
@@ -0,0 +1,42 @@
+// 响应式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(
+ {
+ // 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/web/src/utils/sso.ts b/web/src/utils/sso.ts
new file mode 100644
index 0000000..18021d0
--- /dev/null
+++ b/web/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/web/src/utils/tree.ts b/web/src/utils/tree.ts
new file mode 100644
index 0000000..629b727
--- /dev/null
+++ b/web/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("render_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/web/src/views/error/403.vue b/web/src/views/error/403.vue
new file mode 100644
index 0000000..a16be3c
--- /dev/null
+++ b/web/src/views/error/403.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+ 403
+
+
+ 抱歉,你无权访问该页面
+
+
+ 返回首页
+
+
+
+
diff --git a/web/src/views/error/404.vue b/web/src/views/error/404.vue
new file mode 100644
index 0000000..cd780b7
--- /dev/null
+++ b/web/src/views/error/404.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+ 404
+
+
+ 抱歉,你访问的页面不存在
+
+
+ 返回首页
+
+
+
+
diff --git a/web/src/views/error/500.vue b/web/src/views/error/500.vue
new file mode 100644
index 0000000..e55a090
--- /dev/null
+++ b/web/src/views/error/500.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+ 500
+
+
+ 抱歉,服务器出错了
+
+
+ 返回首页
+
+
+
+
diff --git a/web/src/views/login/index.vue b/web/src/views/login/index.vue
new file mode 100644
index 0000000..5b17ef3
--- /dev/null
+++ b/web/src/views/login/index.vue
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/login/utils/motion.ts b/web/src/views/login/utils/motion.ts
new file mode 100644
index 0000000..2b1182c
--- /dev/null
+++ b/web/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/web/src/views/login/utils/rule.ts b/web/src/views/login/utils/rule.ts
new file mode 100644
index 0000000..6b73d5a
--- /dev/null
+++ b/web/src/views/login/utils/rule.ts
@@ -0,0 +1,28 @@
+import { reactive } from "vue";
+import type { FormRules } from "element-plus";
+
+/** 密码正则(密码格式应为8-18位数字、字母、符号的任意两种组合) */
+export const REGEXP_PWD =
+ /^(?![0-9]+$)(?![a-z]+$)(?![A-Z]+$)(?!([^(0-9a-zA-Z)]|[()])+$)(?!^.*[\u4E00-\u9FA5].*$)([^(0-9a-zA-Z)]|[()]|[a-z]|[A-Z]|[0-9]){8,18}$/;
+
+/** 登录校验 */
+const loginRules = reactive({
+ password: [
+ {
+ validator: (rule, value, callback) => {
+ if (value === "") {
+ callback(new Error("请输入密码"));
+ } else if (!REGEXP_PWD.test(value)) {
+ callback(
+ new Error("密码格式应为8-18位数字、字母、符号的任意两种组合")
+ );
+ } else {
+ callback();
+ }
+ },
+ trigger: "blur"
+ }
+ ]
+});
+
+export { loginRules };
diff --git a/web/src/views/login/utils/static.ts b/web/src/views/login/utils/static.ts
new file mode 100644
index 0000000..18268d8
--- /dev/null
+++ b/web/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/web/src/views/permission/button/index.vue b/web/src/views/permission/button/index.vue
new file mode 100644
index 0000000..e71fc42
--- /dev/null
+++ b/web/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/web/src/views/permission/page/index.vue b/web/src/views/permission/page/index.vue
new file mode 100644
index 0000000..27fb26a
--- /dev/null
+++ b/web/src/views/permission/page/index.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+ 模拟后台根据不同角色返回对应路由,观察左侧菜单变化(管理员角色可查看系统管理菜单、普通角色不可查看系统管理菜单)
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/welcome/index.vue b/web/src/views/welcome/index.vue
new file mode 100644
index 0000000..8db10d2
--- /dev/null
+++ b/web/src/views/welcome/index.vue
@@ -0,0 +1,9 @@
+
+
+
+ Pure-Admin-Thin(非国际化版本)
+
diff --git a/web/stylelint.config.js b/web/stylelint.config.js
new file mode 100644
index 0000000..2417ddf
--- /dev/null
+++ b/web/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/web/tailwind.config.ts b/web/tailwind.config.ts
new file mode 100644
index 0000000..8f58f44
--- /dev/null
+++ b/web/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/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..5dd960a
--- /dev/null
+++ b/web/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",
+ "types/*.d.ts",
+ "vite.config.ts"
+ ],
+ "exclude": [
+ "dist",
+ "**/*.js",
+ "node_modules"
+ ]
+}
\ No newline at end of file
diff --git a/web/types/directives.d.ts b/web/types/directives.d.ts
new file mode 100644
index 0000000..8725698
--- /dev/null
+++ b/web/types/directives.d.ts
@@ -0,0 +1,26 @@
+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;
+ /** 按钮权限指令 */
+ vAuth: Directive>;
+ /** 文本复制指令(默认双击复制) */
+ vCopy: Directive;
+ /** 长按指令 */
+ vLongpress: Directive;
+ /** 防抖、节流指令 */
+ vOptimize: 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/web/types/global-components.d.ts b/web/types/global-components.d.ts
new file mode 100644
index 0000000..71314d4
--- /dev/null
+++ b/web/types/global-components.d.ts
@@ -0,0 +1,133 @@
+declare module "vue" {
+ /**
+ * 自定义全局组件获得 Volar 提示(自定义的全局组件需要在这里声明下才能获得 Volar 类型提示哦)
+ */
+ export interface GlobalComponents {
+ IconifyIconOffline: (typeof import("../src/components/ReIcon"))["IconifyIconOffline"];
+ IconifyIconOnline: (typeof import("../src/components/ReIcon"))["IconifyIconOnline"];
+ FontIcon: (typeof import("../src/components/ReIcon"))["FontIcon"];
+ Auth: (typeof import("../src/components/ReAuth"))["Auth"];
+ }
+}
+
+/**
+ * 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/web/types/global.d.ts b/web/types/global.d.ts
new file mode 100644
index 0000000..0126664
--- /dev/null
+++ b/web/types/global.d.ts
@@ -0,0 +1,193 @@
+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_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/web/types/index.d.ts b/web/types/index.d.ts
new file mode 100644
index 0000000..67d7459
--- /dev/null
+++ b/web/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/web/types/router.d.ts b/web/types/router.d.ts
new file mode 100644
index 0000000..a03ba0e
--- /dev/null
+++ b/web/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/web/types/shims-tsx.d.ts b/web/types/shims-tsx.d.ts
new file mode 100644
index 0000000..199f979
--- /dev/null
+++ b/web/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/web/types/shims-vue.d.ts b/web/types/shims-vue.d.ts
new file mode 100644
index 0000000..c7260cc
--- /dev/null
+++ b/web/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/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..1d1b01a
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,62 @@
+import { getPluginsList } from "./build/plugins";
+import { include, exclude } from "./build/optimize";
+import { type UserConfigExport, type ConfigEnv, loadEnv } from "vite";
+import {
+ root,
+ alias,
+ wrapperEnv,
+ pathResolve,
+ __APP_INFO__
+} from "./build/utils";
+
+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: {
+ // 端口号
+ port: VITE_PORT,
+ host: "0.0.0.0",
+ // 本地跨域代理 https://cn.vitejs.dev/config/server-options.html#server-proxy
+ proxy: {},
+ // 预热文件以提前转换和缓存结果,降低启动期间的初始页面加载时长并防止转换瀑布
+ warmup: {
+ clientFiles: ["./index.html", "./src/{views,components}/*"]
+ }
+ },
+ plugins: getPluginsList(VITE_CDN, VITE_COMPRESSION),
+ // https://cn.vitejs.dev/config/dep-optimization-options.html#dep-optimization-options
+ optimizeDeps: {
+ include,
+ exclude
+ },
+ build: {
+ // https://cn.vitejs.dev/guide/build.html#browser-compatibility
+ target: "es2015",
+ sourcemap: false,
+ // 消除打包大小超过500kb警告
+ chunkSizeWarningLimit: 4000,
+ rollupOptions: {
+ input: {
+ index: pathResolve("./index.html", import.meta.url)
+ },
+ // 静态资源分类打包
+ output: {
+ chunkFileNames: "static/js/[name]-[hash].js",
+ entryFileNames: "static/js/[name]-[hash].js",
+ assetFileNames: "static/[ext]/[name]-[hash].[ext]"
+ }
+ }
+ },
+ define: {
+ __INTLIFY_PROD_DEVTOOLS__: false,
+ __APP_INFO__: JSON.stringify(__APP_INFO__)
+ }
+ };
+};