>, Omit>>
+
+/**
+ * 除了提取的属性,其他都是必传属性
+ */
+export type PickNotRequiredUnion
= MergeIntersection, Required>>>
diff --git a/src/types/Attachment.ts b/src/types/Attachment.ts
new file mode 100644
index 0000000..575a718
--- /dev/null
+++ b/src/types/Attachment.ts
@@ -0,0 +1,24 @@
+import { Model } from "./Model";
+
+export type Attachment = {
+ bizType: AttachmentBizType;
+ bizId: number;
+ attachType?: string;
+ mongoId: string;
+ lidTitle?: number;
+ name?: string;
+ size: number;
+} & Model
+
+export type AttachmentView = {
+ title?: string;
+} & Attachment
+
+export enum AttachmentBizType {
+
+ GIT_ISSUE,
+
+ GIT_MERGE,
+
+ GIT_RELEASE
+}
diff --git a/src/types/Comment.ts b/src/types/Comment.ts
new file mode 100644
index 0000000..e7d3734
--- /dev/null
+++ b/src/types/Comment.ts
@@ -0,0 +1,78 @@
+import { Model, Page } from "./Model";
+import { UserView } from "./User";
+
+/** 评论 */
+export type Comment = {
+ bizType: CommentBizType;
+ bizId: number;
+ userId?: number;
+ nick?: string;
+ content: string;
+} & Model
+
+/** 评论视图 */
+export type CommentView = {
+ /** 所属用户 */
+ user?: UserView;
+
+ /** 回复列表 */
+ replies: CommentReplyView[];
+
+ /** 回复分页 */
+ repliesPage: CommentReplyPage;
+
+ /** 用于绑定组件当前页下标 */
+ repliesCurrent: number;
+
+ /** 回复数量 */
+ repliesLength: number;
+
+ /** 关联文章 */
+ article?: object;
+
+ /** 关联 Git 仓库 */
+ repository?: object;
+} & Comment;
+
+export type CommentReply = {
+ replyId?: number;
+ commentId: number;
+ senderId?: number;
+ senderNick?: string;
+ receiverId?: number;
+ receiverNick?: string;
+ content: string;
+} & Model;
+
+export type CommentReplyView = {
+ comment: CommentView;
+ sender?: UserView;
+ receiver?: UserView;
+} & CommentReply;
+
+export type CommentPage = {
+ bizType?: CommentBizType;
+ bizId?: number;
+} & Page;
+
+export enum CommentReplyBizType {
+
+ COMMENT = "COMMENT",
+
+ SENDER = "SENDER",
+
+ RECEIVER = "RECEIVER"
+}
+
+export type CommentReplyPage = {
+ bizType: CommentReplyBizType
+ bizId?: number
+} & Page;
+
+export enum CommentBizType {
+ ARTICLE = "ARTICLE",
+
+ GIT_ISSUE = "GIT_ISSUE",
+
+ GIT_MERGE = "GIT_MERGE",
+}
diff --git a/src/types/Developer.ts b/src/types/Developer.ts
new file mode 100644
index 0000000..859c0c3
--- /dev/null
+++ b/src/types/Developer.ts
@@ -0,0 +1,6 @@
+/** 开发者配置 */
+export type Developer = {
+ developerId?: number;
+ name?: string;
+ rsa?: string;
+}
diff --git a/src/types/Model.ts b/src/types/Model.ts
new file mode 100644
index 0000000..d062fa9
--- /dev/null
+++ b/src/types/Model.ts
@@ -0,0 +1,79 @@
+export enum RunEnv {
+ DEV = "DEV",
+ DEV_SSL = "DEV_SSL",
+ PROD = "PROD"
+}
+
+// 基本实体模型
+export type Model = {
+ id?: number;
+
+ createdAt?: number;
+ updatedAt?: number;
+ deletedAt?: number;
+}
+
+export type Response = {
+ code: number;
+ msg?: string;
+ data: object;
+}
+
+export type Page = {
+ index: number;
+ size: number;
+ keyword?: string;
+ orderMap?: { [key: string]: OrderType };
+}
+
+export enum OrderType {
+ ASC = "ASC",
+ DESC = "DESC"
+}
+
+export type PageResult = {
+ total: number;
+ list: T[];
+}
+
+// 携带验证码的请求体
+export type CaptchaData = {
+ from: string;
+ captcha: string;
+ data: T;
+}
+
+export enum CaptchaFrom {
+
+ LOGIN = "LOGIN",
+
+ REGISTER = "REGISTER",
+
+ /** 评论 */
+ COMMENT = "COMMENT",
+
+ /** 评论回复 */
+ COMMENT_REPLY = "COMMENT_REPLY",
+
+ /** Git 反馈 */
+ GIT_ISSUE = "GIT_ISSUE",
+
+ /** Git 合并请求 */
+ GIT_MERGE = "GIT_MERGE",
+}
+
+export enum ImageType {
+ AUTO = "ir-auto",
+ SMOOTH = "ir-smooth",
+ PIXELATED = "ir-pixelated"
+}
+
+export type KeyValue = {
+ key: string;
+ value: T;
+}
+
+export type LabelValue = {
+ label: string;
+ value: T;
+}
diff --git a/src/types/Setting.ts b/src/types/Setting.ts
new file mode 100644
index 0000000..c25568f
--- /dev/null
+++ b/src/types/Setting.ts
@@ -0,0 +1,28 @@
+export enum SettingKey {
+ RUN_ENV = "RUN_ENV",
+ PUBLIC_RESOURCES = "PUBLIC_RESOURCES",
+
+ DOMAIN_ROOT = "DOMAIN_ROOT",
+ DOMAIN_API = "DOMAIN_API",
+ DOMAIN_GIT = "DOMAIN_GIT",
+ DOMAIN_BLOG = "DOMAIN_BLOG",
+ DOMAIN_SPACE = "DOMAIN_SPACE",
+ DOMAIN_DOWNLOAD = "DOMAIN_DOWNLOAD",
+ DOMAIN_RESOURCE = "DOMAIN_RESOURCE",
+
+ ENABLE_COMMENT = "ENABLE_COMMENT",
+ ENABLE_DEBUG = "ENABLE_DEBUG",
+ ENABLE_LOGIN = "ENABLE_LOGIN",
+ ENABLE_REGISTER = "ENABLE_REGISTER",
+ ENABLE_USER_UPDATE = "ENABLE_USER_UPDATE",
+}
+
+export type PublicResources = {
+ wechatReceiveQRCode: string;
+ user: PublicResourcesUser;
+}
+
+export type PublicResourcesUser = {
+ avatar: string;
+ wrapper: string;
+}
diff --git a/src/types/Template.ts b/src/types/Template.ts
new file mode 100644
index 0000000..0c29d35
--- /dev/null
+++ b/src/types/Template.ts
@@ -0,0 +1,6 @@
+export enum TemplateBizType {
+
+ GIT = "GIT",
+
+ FOREVER_MC = "FOREVER_MC"
+}
diff --git a/src/types/User.ts b/src/types/User.ts
new file mode 100644
index 0000000..4b57d45
--- /dev/null
+++ b/src/types/User.ts
@@ -0,0 +1,89 @@
+import { ImageType, Model } from "./Model";
+import { AttachmentView } from "./Attachment";
+
+export type User = {
+ name: string;
+ email?: string;
+ emailVerifyAt: number;
+ unmuteAt?: number;
+ unbanAt?: number;
+} & Model;
+
+export type UserView = {
+ profile: UserProfileView
+} & User;
+
+export enum UserAttachType {
+
+ AVATAR,
+
+ WRAPPER,
+
+ DEFAULT_AVATAR,
+
+ DEFAULT_WRAPPER,
+}
+
+export type UserProfile = {
+ userId: number;
+ avatarType: ImageType;
+ wrapperType: ImageType;
+
+ exp: number;
+ sex?: number;
+ birthdate?: number;
+ qq?: string;
+ description: string;
+ lastLoginIP?: string;
+ lastLoginAt?: number;
+ updatedAt?: number;
+}
+
+export type UserProfileView = {
+ attachmentList?: AttachmentView[]
+} & UserProfile
+
+export type UserToken = {
+ value: string;
+ expireAt: number;
+}
+
+export type RegisterRequest = {
+ name: string;
+ password: string;
+ email?: string;
+}
+
+export type LoginRequest = {
+ user: string;
+ password: string;
+}
+
+// 登录返回
+export type LoginResponse = {
+ id: number;
+ token: string;
+ expireAt: number;
+}
+
+export type LoginUser = {
+ token?: LoginToken;
+ user?: UserView
+}
+
+export type LoginToken = {
+ id?: number;
+} & UserToken;
+
+export enum LoginType {
+ ALERT,
+ IFRAME,
+ REDIRECT
+}
+
+export type UserLevelType = {
+ exp: number; // 经验数值,和 UserData.exp 一样
+ value: number; // 经验对应等级,[0, 8]
+ percent: number; // 该经验在该等级的百分比 [0, 1]
+ nextLevelUp: number; // 下一级经验值
+}
diff --git a/src/utils/Cooker.ts b/src/utils/Cooker.ts
new file mode 100644
index 0000000..c50911a
--- /dev/null
+++ b/src/utils/Cooker.ts
@@ -0,0 +1,31 @@
+export default class Cooker {
+
+ static set(name: string, value: string, ttlMS: number) {
+ let expires = "";
+ if (ttlMS) {
+ let date = new Date();
+ date.setTime(date.getTime() + ttlMS);
+ expires = "; expires=" + date.toUTCString();
+ }
+ document.cookie = name + "=" + (value || "") + expires + "; path=/";
+ }
+
+ static get(name: string) {
+ let nameSplit = name + "=";
+ let ca = document.cookie.split(";");
+ for (let i = 0; i < ca.length; i++) {
+ let c = ca[i];
+ while (c.charAt(0) == " ") {
+ c = c.substring(1, c.length);
+ }
+ if (c.indexOf(nameSplit) == 0) {
+ return c.substring(nameSplit.length, c.length);
+ }
+ }
+ return undefined;
+ }
+
+ static remove(name: string) {
+ document.cookie = name + "=; Max-Age=-99999999;";
+ }
+}
diff --git a/src/utils/Events.ts b/src/utils/Events.ts
new file mode 100644
index 0000000..a7cb754
--- /dev/null
+++ b/src/utils/Events.ts
@@ -0,0 +1,106 @@
+/**
+ * ### 全局事件管理
+ *
+ * ```js
+ * // 注册
+ * Events.register("eventName", () => {
+ * // 触发执行
+ * });
+ *
+ * // 触发
+ * Events.emit("eventName", '支持参数');
+ *
+ * // 移除
+ * Events.remove("eventName");
+ * ```
+ */
+export default class Events {
+
+ // 监听数组
+ private static listeners = new Map[]>();
+
+ /**
+ * 注册事件(会叠加)
+ *
+ * @param key 事件名称
+ * @param callback 回调函数
+ */
+ public static register(key: T, callback: Function) {
+ const observers: Observer[] | undefined = Events.listeners.get(key);
+ if (!observers) {
+ Events.listeners.set(key, []);
+ }
+ Events.listeners.get(key)?.push(new Observer(key, callback));
+ }
+
+ /**
+ * 重置并注册(不会叠加)
+ *
+ * @param key 事件名称
+ * @param callback 回调函数
+ */
+ public static reset(key: T, callback: Function) {
+ Events.listeners.set(key, []);
+ this.register(key, callback);
+ }
+
+ /**
+ * 移除事件
+ *
+ * @param key 事件名称
+ */
+ public static remove(key: T) {
+ const observers: Observer[] | undefined = Events.listeners.get(key);
+ if (observers) {
+ for (let i = 0, l = observers.length; i < l; i++) {
+ if (observers[i].equals(key)) {
+ observers.splice(i, 1);
+ break;
+ }
+ }
+ }
+ Events.listeners.delete(key);
+ }
+
+ /**
+ * 触发事件
+ *
+ * @param key 事件名称
+ * @param args 参数
+ */
+ public static emit(key: T, ...args: any[]) {
+ const observers: Observer[] | undefined = Events.listeners.get(key);
+ if (observers) {
+ for (const observer of observers) {
+ // 通知
+ observer.notify(...args);
+ }
+ }
+ }
+}
+
+/** 观察者 */
+class Observer {
+
+ private callback: Function = () => {}; // 回调函数
+
+ private readonly key: T;
+
+ constructor(key: T, callback: Function) {
+ this.key = key;
+ this.callback = callback;
+ }
+
+ /**
+ * 发送通知
+ *
+ * @param args 不定参数
+ */
+ notify(...args: any[]): void {
+ this.callback.call(this.key, ...args);
+ }
+
+ equals(name: any): boolean {
+ return name === this.key;
+ }
+}
diff --git a/src/utils/IOSize.ts b/src/utils/IOSize.ts
new file mode 100644
index 0000000..83169af
--- /dev/null
+++ b/src/utils/IOSize.ts
@@ -0,0 +1,80 @@
+export enum Unit {
+
+ /** B */
+ B = "B",
+
+ /** KB */
+ KB = "KB",
+
+ /** MB */
+ MB = "MB",
+
+ /** GB */
+ GB = "GB",
+
+ /** TB */
+ TB = "TB",
+
+ /** PB */
+ PB = "PB",
+
+ /** EB */
+ EB = "EB"
+}
+
+/** 储存单位 */
+export default class IOSize {
+
+ /** 1 字节 */
+ public static BYTE = 1;
+
+ /** 1 KB */
+ public static KB = IOSize.BYTE << 10;
+
+ /** 1 MB */
+ public static MB = IOSize.KB << 10;
+
+ /** 1 GB */
+ public static GB = IOSize.MB << 10;
+
+ /** 1 TB */
+ public static TB = IOSize.GB << 10;
+
+ /** 1 PB */
+ public static PB = IOSize.TB << 10;
+
+ /** 1 EB */
+ public static EB = IOSize.PB << 10;
+
+ public static Unit = Unit;
+
+ /**
+ * 格式化一个储存容量,保留两位小数
+ *
+ * // 返回 100.01 KB
+ * format(102411, 2);
+ *
+ *
+ * @param size 字节大小
+ * @param fixed 保留小数
+ * @param stopUnit 停止单位
+ * @return
+ */
+ public static format(size: number, fixed = 2, stopUnit?: Unit): string {
+ const units = Object.keys(Unit);
+ if (0 < size) {
+ for (let i = 0; i < units.length; i++, size /= 1024) {
+ const unit = units[i];
+ if (size <= 1000 || i === units.length - 1 || unit === stopUnit) {
+ if (i === 0) {
+ // 最小单位不需要小数
+ return size + " B";
+ } else {
+ return `${size.toFixed(fixed)} ${unit}`;
+ }
+ }
+ }
+ }
+ return "0 B";
+ }
+}
diff --git a/src/utils/IconMapper.ts b/src/utils/IconMapper.ts
new file mode 100644
index 0000000..9ffd495
--- /dev/null
+++ b/src/utils/IconMapper.ts
@@ -0,0 +1,26 @@
+import iconJson from "../assets/icon.json";
+
+export default class IconMapper {
+
+ private static instance: IconMapper;
+
+ icons = new Map();
+
+ private constructor() {
+ Object.entries(iconJson).forEach(([key, value]) => this.icons.set(key, value));
+ }
+
+ public getSVG(name: string, scale = 1) {
+ const svg = IconMapper.getInstance().icons.get(name.toLowerCase());
+ return svg?.replace(/\d+(\.\d+)?/gm, n => {
+ return String((Number(n) * scale));
+ });
+ }
+
+ public static getInstance(): IconMapper {
+ if (!IconMapper.instance) {
+ IconMapper.instance = new IconMapper();
+ }
+ return IconMapper.instance;
+ }
+}
diff --git a/src/utils/Markdown.ts b/src/utils/Markdown.ts
new file mode 100644
index 0000000..762fc5a
--- /dev/null
+++ b/src/utils/Markdown.ts
@@ -0,0 +1,209 @@
+import { marked } from "marked";
+import { gfmHeadingId } from "marked-gfm-heading-id";
+import { markedHighlight } from "marked-highlight";
+import { mangle } from "marked-mangle";
+import Prism from "prismjs";
+import "prismjs/themes/prism.css";
+import { Toolkit } from "~/index";
+
+export default class Markdown {
+
+ private static instance: Markdown;
+
+ renderer = new marked.Renderer();
+
+ private constructor() {
+ marked.use(mangle());
+ marked.use(gfmHeadingId());
+ marked.use(markedHighlight({
+ highlight(code: string, lang: string) {
+ if (Prism.languages[lang]) {
+ return Prism.highlight(code, Prism.languages[lang], lang);
+ } else {
+ return code;
+ }
+ }
+ }));
+ // Markdown 解析器配置
+ marked.setOptions({
+ renderer: this.renderer,
+ pedantic: false,
+ gfm: true,
+ breaks: true
+ });
+
+ Prism.hooks.add("complete", (env: any) => {
+ if (!env.code) return;
+
+ // 行号渲染调整
+ const el = env.element;
+
+ const lineNumber = el.querySelector(".line-numbers-rows") as HTMLDivElement;
+ if (lineNumber) {
+ const clone = lineNumber.cloneNode(true);
+ el.removeChild(lineNumber);
+ // 加容器做滚动
+ el.innerHTML = `${el.innerHTML}`;
+ el.insertBefore(clone, el.firstChild);
+
+ if (el.parentNode) {
+ const markdownRoot = el.parentNode.parentNode;
+ if (markdownRoot) {
+ const maxHeight = markdownRoot.dataset.maxHeight;
+ if (maxHeight === "auto") {
+ return;
+ }
+ // 注册双击事件
+ const lines = lineNumber.children.length;
+ if (lines < 18) {
+ return;
+ }
+ const parent = el.parentNode;
+ parent.addEventListener("dblclick", (): void => {
+ const isExpand = parent.classList.contains("expand");
+ if (isExpand) {
+ parent.style.maxHeight = maxHeight;
+ parent.classList.remove("expand");
+ } else {
+ parent.style.maxHeight = lines * 22 + "px";
+ parent.classList.add("expand");
+ }
+ const selection = window.getSelection();
+ if (selection) {
+ selection.removeAllRanges();
+ }
+ });
+ }
+ }
+ }
+ });
+
+ /**
+ * ### 超链渲染方式
+ *
+ * 1. 链接前加 ~ 符号会被渲染为新标签打开。例:`[文本](~链接)`
+ * 3. 没有标题的链接将使用文本作为标题
+ * 4. 没有链接的会被渲染为 span 标签
+ */
+ this.renderer.link = function ({ href, title, text }) {
+ title = title ?? text;
+
+ if (!href) {
+ return `${text}`;
+ }
+ // 新标签打开
+ let target = "_self";
+ if (href.startsWith("~")) {
+ target = "_blank";
+ href = href.substring(1);
+ }
+ // 内部资源链接
+ if (href.indexOf("@") !== -1) {
+ href = Toolkit.toResURL(href);
+ }
+ {
+ // 处理嵌套 markdown,这可能不是最优解
+ const tokens = (marked.lexer(text, { inline: true } as any) as any)[0].tokens;
+ text = this.parser.parseInline(tokens);
+ }
+ return `${text}`;
+ };
+
+ /**
+ * ### 重点内容扩展
+ *
+ * ```md
+ * 默认 `文本` 表现为红色
+ * 使用 `[red bold]文本` 可以自定义类
+ * ```
+ */
+ this.renderer.codespan = ({ text }) => {
+ const clazz = text.match(/\[(.+?)]/);
+ if (clazz) {
+ return `${text.substring(text.indexOf("]") + 1)}`;
+ } else {
+ return `${text}`;
+ }
+ };
+
+ /**
+ * ### 组件渲染方式(原为图像渲染方式)
+ *
+ * ```md
+ * [] 内文本以 # 开始时,该组件带边框
+ * ```
+ *
+ * 1. 渲染为网页:``
+ * 2. 渲染为视频:``
+ * 3. 渲染为音频:``
+ * 4. 渲染为图片:``
+ * 6. 带边框图片:``
+ */
+ this.renderer.image = ({ href, title, text }) => {
+ const clazz = ["media"];
+
+ const hasBorder = text[0] === "#";
+ if (hasBorder) {
+ clazz.push("border");
+ }
+ title = title ?? text;
+
+ const extendTags = ["~", "#", "$"];
+ let extendTag;
+ if (extendTags.indexOf(href.charAt(0)) !== -1) {
+ extendTag = href.charAt(0);
+ }
+
+ // 内部资源链接
+ if (href.indexOf("@") !== -1) {
+ if (extendTag) {
+ href = href.substring(1);
+ }
+ href = Toolkit.toResURL(href);
+ }
+
+ const clazzStr = clazz.join(" ");
+ let elStr;
+ switch (extendTag) {
+ case "~":
+ elStr = ``;
+ break;
+ case "#":
+ elStr = ``;
+ break;
+ case "$":
+ elStr = ``;
+ break;
+ default:
+ elStr = `
`;
+ break;
+ }
+ return elStr + `${title}`;
+ };
+ }
+
+ /**
+ * ### 解析 Markdown 文本为 HTML 节点
+ *
+ * ```js
+ * const html = toHTML('# Markdown Content');
+ * ```
+ *
+ * @param mkData Markdown 文本
+ * @returns HTML 节点
+ */
+ public toHTML(mkData: string | undefined): string | Promise {
+ if (mkData) {
+ return marked(mkData);
+ } else {
+ return "";
+ }
+ }
+
+ public static getInstance(): Markdown {
+ if (!Markdown.instance) {
+ Markdown.instance = new Markdown();
+ }
+ return Markdown.instance;
+ }
+}
diff --git a/src/utils/MethodLocker.ts b/src/utils/MethodLocker.ts
new file mode 100644
index 0000000..244c9fd
--- /dev/null
+++ b/src/utils/MethodLocker.ts
@@ -0,0 +1,48 @@
+export class MethodLocker {
+ private isLocked: boolean = false;
+ private queue: Array<() => Promise> = [];
+
+ /**
+ * 执行被锁定的方法
+ * @param task 需要执行的任务,返回一个 Promise
+ */
+ async execute(task: () => Promise): Promise {
+ // 如果当前没有被锁定,直接执行任务
+ if (!this.isLocked) {
+ this.isLocked = true;
+ try {
+ return await task();
+ } finally {
+ this.isLocked = false;
+ await this.dequeue();
+ }
+ } else {
+ // 如果被锁定,将任务加入队列并等待
+ return new Promise((resolve, reject) => {
+ this.queue.push(async () => {
+ try {
+ const result = await task();
+ resolve(result);
+ } catch (error) {
+ reject(error);
+ } finally {
+ this.dequeue();
+ }
+ });
+ });
+ }
+ }
+
+ /**
+ * 处理队列中的下一个任务
+ */
+ private dequeue(): Promise | undefined {
+ if (this.queue.length > 0) {
+ const nextTask = this.queue.shift();
+ if (nextTask) {
+ return this.execute(nextTask);
+ }
+ }
+ return undefined;
+ }
+}
diff --git a/src/utils/Network.ts b/src/utils/Network.ts
new file mode 100644
index 0000000..4efa410
--- /dev/null
+++ b/src/utils/Network.ts
@@ -0,0 +1,61 @@
+import axios, { InternalAxiosRequestConfig } from "axios";
+import { Response } from "~/types/Model";
+import { Cooker, Time, userStore } from "~/index";
+
+const userTokenInterceptors = async (config: InternalAxiosRequestConfig) => {
+ let token = userStore.loginUser.token?.value;
+ const cookieToken = Cooker.get("Token");
+ if (token && token === cookieToken) {
+ config.headers.set({"Token": token});
+ } else {
+ if (cookieToken) {
+ token = cookieToken;
+ userStore.loginUser.token = {
+ value: token,
+ expireAt: Time.now() + Time.D
+ };
+ }
+ if (token) {
+ await userStore.login4Token({
+ value: token,
+ expireAt: Time.now() + Time.D
+ });
+ }
+ }
+ return config;
+};
+axios.defaults.withCredentials = true;
+axios.interceptors.response.use((response: any) => {
+ if (!response.config.responseType) {
+ // 服务端返回
+ const data = response.data as Response;
+ if (data.code < 40000) {
+ // 200 或 300 HTTP 状态段视为成功
+ return data.data;
+ } else {
+ // 由调用方处理
+ return Promise.reject(data.msg);
+ }
+ }
+ return response.data;
+}, (error: any) => {
+ // 请求错误
+ if (error) {
+ if (error.response && error.response.status) {
+ throw error;
+ }
+ if (error.request) {
+ if (error.message.startsWith("timeout")) {
+ throw new Error("time out");
+ } else {
+ throw new Error(error.message);
+ }
+ }
+ }
+ throw error;
+});
+
+export default {
+ axios,
+ userTokenInterceptors
+};
diff --git a/src/utils/Prismjs.ts b/src/utils/Prismjs.ts
new file mode 100644
index 0000000..411b927
--- /dev/null
+++ b/src/utils/Prismjs.ts
@@ -0,0 +1,96 @@
+export enum PrismjsType {
+ PlainText = "PlainText",
+ Markdown = "Markdown",
+ JavaScript = "JavaScript",
+ TypeScript = "TypeScript",
+ Initialization = "Initialization",
+ PHP = "PHP",
+ SQL = "SQL",
+ XML = "XML",
+ CSS = "CSS",
+ VUE = "VUE",
+ LESS = "LESS",
+ Markup = "Markup",
+ YAML = "YAML",
+ Json = "Json",
+ Java = "Java",
+ Properties = "Properties",
+ NginxConf = "NginxConf",
+ ApacheConf = "ApacheConf"
+}
+
+export type PrismjsProperties = {
+
+ extensions: string[]
+ prismjs: string;
+ viewer: PrismjsViewer;
+}
+
+export enum PrismjsViewer {
+
+ MARKDOWN = "MARKDOWN",
+
+ CODE = "CODE",
+
+ TEXT = "TEXT",
+}
+
+export default class Prismjs {
+
+ private static instance: Prismjs;
+
+ map = new Map();
+
+ private constructor() {
+ this.map.set(PrismjsType.PlainText, {extensions: ["txt"], prismjs: "", viewer: PrismjsViewer.TEXT});
+ this.map.set(PrismjsType.Markdown, {extensions: ["md"], prismjs: "md", viewer: PrismjsViewer.MARKDOWN});
+ this.map.set(PrismjsType.JavaScript, {extensions: ["js"], prismjs: "js", viewer: PrismjsViewer.CODE});
+ this.map.set(PrismjsType.VUE, {extensions: ["vue"], prismjs: "html", viewer: PrismjsViewer.CODE});
+ this.map.set(PrismjsType.TypeScript, {extensions: ["ts"], prismjs: "ts", viewer: PrismjsViewer.CODE});
+ this.map.set(PrismjsType.Initialization, {extensions: ["ini"], prismjs: "ini", viewer: PrismjsViewer.CODE});
+ this.map.set(PrismjsType.PHP, {extensions: ["php"], prismjs: "php", viewer: PrismjsViewer.CODE});
+ this.map.set(PrismjsType.SQL, {extensions: ["sql"], prismjs: "sql", viewer: PrismjsViewer.CODE});
+ this.map.set(PrismjsType.XML, {extensions: ["xml", "fxml"], prismjs: "xml", viewer: PrismjsViewer.CODE});
+ this.map.set(PrismjsType.CSS, {extensions: ["css"], prismjs: "css", viewer: PrismjsViewer.CODE});
+ this.map.set(PrismjsType.LESS, {extensions: ["less"], prismjs: "less", viewer: PrismjsViewer.CODE});
+ this.map.set(PrismjsType.Markup, {extensions: ["htm", "html"], prismjs: "markup", viewer: PrismjsViewer.CODE});
+ this.map.set(PrismjsType.YAML, {extensions: ["yml", "yaml"], prismjs: "yaml", viewer: PrismjsViewer.CODE});
+ this.map.set(PrismjsType.Json, {extensions: ["json"], prismjs: "json", viewer: PrismjsViewer.CODE});
+ this.map.set(PrismjsType.Java, {extensions: ["java"], prismjs: "java", viewer: PrismjsViewer.CODE});
+ this.map.set(PrismjsType.Properties, {extensions: ["properties"], prismjs: "properties", viewer: PrismjsViewer.CODE});
+ this.map.set(PrismjsType.NginxConf, {extensions: [], prismjs: "nginx", viewer: PrismjsViewer.CODE});
+ this.map.set(PrismjsType.ApacheConf, {extensions: [], prismjs: "apacheconf", viewer: PrismjsViewer.CODE});
+ }
+
+ private static getInstance(): Prismjs {
+ if (!Prismjs.instance) {
+ Prismjs.instance = new Prismjs();
+ }
+ return Prismjs.instance;
+ }
+
+ public static typeFromFileName(fileName: string): PrismjsType | undefined {
+ const ext = fileName.substring(fileName.lastIndexOf(".") + 1);
+ if (!ext) {
+ return undefined;
+ }
+ const map = Prismjs.getInstance().map;
+ for (const key of map.keys()) {
+ const value = map.get(key);
+ if (value) {
+ const extensions = value.extensions;
+ for (let i = 0; i < extensions.length; i++) {
+ const extension = extensions[i];
+ if (extension === ext) {
+ return key;
+ }
+ }
+ }
+ }
+ return undefined;
+ }
+
+ public static getFileProperties(type: PrismjsType): PrismjsProperties | undefined {
+ return Prismjs.getInstance().map.get(type);
+ }
+}
diff --git a/src/utils/Resizer.ts b/src/utils/Resizer.ts
new file mode 100644
index 0000000..2b9d288
--- /dev/null
+++ b/src/utils/Resizer.ts
@@ -0,0 +1,83 @@
+/**
+ * ### 浏览器缩放
+ *
+ * 此类对象由 Resizer 注册、触发和销毁
+ *
+ * ```js
+ * new ResizeListener("注册名", () => console.log("回调函数"));
+ * ```
+ */
+class ResizeListener {
+
+ /** 事件名 */
+ name: string;
+
+ /** 回调函数 */
+ listener: (width: number, height: number) => void;
+
+ constructor(name: string, listener: (width: number, height: number) => void) {
+ this.name = name;
+ this.listener = listener;
+ }
+}
+
+/**
+ * ### 浏览器窗体缩放监听触发器
+ *
+ * ```js
+ * Resizer.addListener("Comment", (width: number, height: number) => console.log("缩放中"));
+ * Resizer.removeListener("Comment");
+ * ```
+ */
+export default class Resizer {
+
+ private static instance: Resizer;
+
+ listeners: ResizeListener[];
+
+ private constructor() {
+ this.listeners = [];
+
+ window.addEventListener("resize", resizeEvent => {
+ const width = (resizeEvent.currentTarget as any).innerWidth;
+ const height = (resizeEvent.currentTarget as any).innerHeight;
+ for (const e of this.listeners) {
+ e.listener(width, height);
+ }
+ }, true);
+ }
+
+ private static getInstance(): Resizer {
+ if (!Resizer.instance) {
+ Resizer.instance = new Resizer();
+ }
+ return Resizer.instance;
+ }
+
+ // 添加事件
+ public static addListener(name: string, listener: (width: number, height: number) => void) {
+ const instance = Resizer.getInstance();
+ let e = instance.listeners.find((se) => se.name === name);
+ if (e) {
+ e.listener = listener;
+ } else {
+ instance.listeners.push(e = new ResizeListener(name, listener));
+ }
+ // 默认触发一次
+ e.listener(window.innerWidth, window.innerHeight);
+ }
+
+ // 移除事件
+ public static removeListener(name: string) {
+ const instance = Resizer.getInstance();
+ instance.listeners.splice(instance.listeners.findIndex(e => e.name === name), 1);
+ }
+
+ public static getWidth(): number {
+ return document.documentElement.clientWidth;
+ }
+
+ public static getHeight(): number {
+ return document.documentElement.clientHeight;
+ }
+}
diff --git a/src/utils/Scroller.ts b/src/utils/Scroller.ts
new file mode 100644
index 0000000..5df2c38
--- /dev/null
+++ b/src/utils/Scroller.ts
@@ -0,0 +1,74 @@
+export type ScrollListener = {
+
+ source: Event;
+ viewWidth: number;
+ viewHeight: number;
+ top: number;
+ bottom: number;
+}
+
+export default class Scroller {
+
+ private static instance: Scroller;
+
+ listeners = new Map();
+
+ private constructor() {
+
+ window.addEventListener("scroll", source => {
+ // 滚动距离
+ const top = document.body.scrollTop || document.documentElement.scrollTop;
+ // 可视高度
+ const viewWidth = document.documentElement.clientWidth || document.body.clientWidth;
+ // 可视高度
+ const viewHeight = document.documentElement.clientHeight || document.body.clientHeight;
+ // 滚动高度
+ const sH = document.documentElement.scrollHeight || document.body.scrollHeight;
+ // 触发事件
+ const bottom = sH - top - viewHeight;
+ this.listeners.forEach(listener => listener({
+ source,
+ viewWidth,
+ viewHeight,
+ top,
+ bottom
+ } as ScrollListener));
+ }, true);
+ }
+
+ private static getInstance(): Scroller {
+ if (!Scroller.instance) {
+ Scroller.instance = new Scroller();
+ }
+ return Scroller.instance;
+ }
+
+ /**
+ * 添加监听
+ *
+ * @param name 事件名
+ * @param listener 监听事件
+ */
+ public static addListener(name: string, listener: (event: ScrollListener) => void) {
+ Scroller.getInstance().listeners.set(name, listener);
+ }
+
+ /**
+ * 移除监听
+ *
+ * @param name 事件名
+ */
+ public static removeListener(name: string) {
+ Scroller.getInstance().listeners.delete(name);
+ }
+
+ /** 滚动至顶(平滑地) */
+ public static toTop() {
+ document.body.scrollIntoView({behavior: "smooth"});
+ }
+
+ /** 滚动至指定节点(平滑地) */
+ public static toElement(el: HTMLElement) {
+ el.scrollIntoView({behavior: "smooth"});
+ }
+}
diff --git a/src/utils/SettingMapper.ts b/src/utils/SettingMapper.ts
new file mode 100644
index 0000000..4ac3663
--- /dev/null
+++ b/src/utils/SettingMapper.ts
@@ -0,0 +1,94 @@
+import { SettingKey } from "~/types/Setting";
+import { readonly, Ref, ref } from "vue";
+import CommonAPI from "~/api/CommonAPI";
+import { RunEnv, Toolkit } from "timi-web";
+
+export default class SettingMapper {
+
+ private static instance: SettingMapper;
+
+ private map = new Map>();
+
+ public static async loadSetting(...settings: { key: string, args?: { [key: string]: any }}[]): Promise {
+ const map = new Map();
+ {
+ // 默认配置
+ map.set(SettingKey.RUN_ENV, undefined);
+ map.set(SettingKey.PUBLIC_RESOURCES, {
+ as: "json"
+ });
+ map.set(SettingKey.DOMAIN_ROOT, undefined);
+ map.set(SettingKey.DOMAIN_API, undefined);
+ map.set(SettingKey.DOMAIN_GIT, undefined);
+ map.set(SettingKey.DOMAIN_BLOG, undefined);
+ map.set(SettingKey.DOMAIN_SPACE, undefined);
+ map.set(SettingKey.DOMAIN_RESOURCE, undefined);
+ map.set(SettingKey.DOMAIN_DOWNLOAD, undefined);
+
+ map.set(SettingKey.ENABLE_COMMENT, undefined);
+ map.set(SettingKey.ENABLE_DEBUG, undefined);
+ map.set(SettingKey.ENABLE_LOGIN, undefined);
+ map.set(SettingKey.ENABLE_REGISTER, undefined);
+ map.set(SettingKey.ENABLE_USER_UPDATE, undefined);
+ }
+ {
+ // 附加配置
+ for (let i = 0; i < settings.length; i++) {
+ map.set(settings[i].key, settings[i].args);
+ }
+ }
+ const instance = this.getInstance();
+ const result = await CommonAPI.listSetting(map);
+ for (const [key, value] of result) {
+ instance.map.set(key, ref(value));
+ }
+ }
+
+ public static is(key: SettingKey | string, args?: { [key: string]: any }): boolean {
+ const value = this.getValueRef(key, args).value;
+ return !value && value === 'true';
+ }
+
+ public static getValue(key: SettingKey | string, args?: { [key: string]: any }): string | undefined {
+ return this.getValueRef(key, args).value;
+ }
+
+ public static getValueRef(key: SettingKey| string, args?: { [key: string]: any }): Ref {
+ const instance = this.getInstance();
+ let result = instance.map.get(key);
+ if (result) {
+ return result;
+ }
+ instance.map.set(key, result = ref());
+ Toolkit.async(async () => {
+ const value = instance.map.get(key);
+ if (value) {
+ return value.value;
+ }
+ result.value = await CommonAPI.getSetting(key, args);
+ });
+ return readonly(result);
+ }
+
+ public static getDomainLink(domainKey: SettingKey): string | undefined {
+ const runEnv = (this.getValue(SettingKey.RUN_ENV));
+ let protocol = "https";
+ switch (runEnv) {
+ case RunEnv.DEV:
+ protocol = "http";
+ break;
+ case RunEnv.DEV_SSL:
+ case RunEnv.PROD:
+ protocol = "https";
+ break;
+ }
+ return `${protocol}://${this.getValue(domainKey)}`;
+ }
+
+ public static getInstance(): SettingMapper {
+ if (SettingMapper.instance) {
+ return SettingMapper.instance;
+ }
+ return SettingMapper.instance = new SettingMapper();
+ }
+}
diff --git a/src/utils/Storage.ts b/src/utils/Storage.ts
new file mode 100644
index 0000000..90882ea
--- /dev/null
+++ b/src/utils/Storage.ts
@@ -0,0 +1,129 @@
+export default class Storage {
+
+ /**
+ * 获取为布尔值
+ *
+ * @param key 键
+ * @returns 布尔值
+ */
+ public static is(key: string): boolean {
+ return this.getString(key) === "true";
+ }
+
+ /**
+ * 获取为布尔值并取反
+ *
+ * @param key 键
+ * @returns 布尔值
+ */
+ public static not(key: string): boolean {
+ return !this.is(key);
+ }
+
+ /**
+ * 读取为指定对象
+ *
+ * @template T 对象类型
+ * @param key 键
+ * @returns {T | undefined} 返回对象
+ */
+ public static getObject(key: string): T {
+ if (this.has(key)) {
+ return this.getJSON(key) as T;
+ }
+ throw Error(`not found ${key}`);
+ }
+
+ /**
+ * 获取值,如果没有则储存并使用默认值
+ *
+ * @template T 默认值类型
+ * @param key 键
+ * @param def 默认值
+ * @return {T} 对象
+ */
+ public static getDefault(key: string, def: T): T {
+ if (this.has(key)) {
+ return this.getJSON(key) as T;
+ }
+ this.setObject(key, def);
+ return def;
+ }
+
+ /**
+ * 获取为 JSON
+ *
+ * @param key 键
+ * @returns JSON 对象
+ */
+ public static getJSON(key: string) {
+ return JSON.parse(this.getString(key));
+ }
+
+ /**
+ * 获取为字符串(其他获取方式一般经过这个方法,找不到配置或配置值无效时会抛错)
+ *
+ * @param key 键
+ * @returns 字符串
+ */
+ public static getString(key: string): string {
+ const value = localStorage.getItem(key);
+ if (value) {
+ return value;
+ }
+ throw new Error(`not found: ${key}, ${value}`);
+ }
+
+ /**
+ * 是否存在某配置
+ *
+ * @param key 键
+ * @returns true 为存在
+ */
+ public static has(key: string): boolean {
+ return localStorage.getItem(key) !== undefined && localStorage.getItem(key) !== null;
+ }
+
+ /**
+ * 设置值
+ *
+ * @param key 键
+ * @param value 值
+ */
+ public static setObject(key: string, value: any) {
+ if (value instanceof Object || value instanceof Array) {
+ this.setJSON(key, value);
+ } else {
+ this.setString(key, value);
+ }
+ }
+
+ /**
+ * 设置 JSON 值
+ *
+ * @param key 键
+ * @param json JSON 字符串
+ */
+ public static setJSON(key: string, json: any) {
+ localStorage.setItem(key, JSON.stringify(json));
+ }
+
+ /**
+ * 设置值
+ *
+ * @param key 键
+ * @param value 值
+ */
+ public static setString(key: string, value: any) {
+ localStorage.setItem(key, value);
+ }
+
+ /**
+ * 移除属性
+ *
+ * @param key 键
+ */
+ public static remove(key: string) {
+ localStorage.removeItem(key);
+ }
+}
diff --git a/src/utils/Time.ts b/src/utils/Time.ts
new file mode 100644
index 0000000..b07dd53
--- /dev/null
+++ b/src/utils/Time.ts
@@ -0,0 +1,97 @@
+export default class Time {
+
+ /** 1 秒时间戳 */
+ public static S = 1E3;
+ /** 1 分钟时间戳 */
+ public static M = Time.S * 60;
+ /** 1 小时时间戳 */
+ public static H = Time.M * 60;
+ /** 1 天时间戳 */
+ public static D = Time.H * 24;
+
+ public static now(): number {
+ return new Date().getTime();
+ }
+
+ /**
+ * Unix 时间戳转日期
+ *
+ * @param unix 时间戳
+ */
+ public static toDate(unix?: number): string {
+ if (!unix) return "";
+ const d = new Date(unix);
+ return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d.getDate().toString().padStart(2, "0")}`;
+ }
+
+ /**
+ * Unix 时间戳转时间
+ *
+ * @param unix 时间戳
+ */
+ public static toTime(unix?: number): string {
+ if (!unix) return "";
+ const d = new Date(unix);
+ return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
+ }
+
+ /**
+ * Unix 时间戳转日期和时间
+ *
+ * @param unix 时间戳
+ */
+ public static toDateTime(unix?: number): string {
+ if (!unix) return "";
+ return `${this.toDate(unix)} ${this.toTime(unix)}`;
+ }
+
+ public static toPassedDate(unix?: number): string {
+ return this.toPassedDateTime(unix, false);
+ }
+
+ public static toPassedDateTime(unix?: number, withDetailTime = true): string {
+ if (!unix) {
+ return "";
+ }
+ const now = new Date().getTime();
+ const between = now - unix;
+
+ if (Time.D * 4 <= between) {
+ return withDetailTime ? this.toDateTime(unix) : this.toDate(unix);
+ } else if (Time.D < between) {
+ return `${Math.floor(between / Time.D)} 天前`;
+ } else if (Time.H < between) {
+ return `${Math.floor(between / Time.H)} 小时前`;
+ } else if (Time.M < between) {
+ return `${Math.floor(between / Time.M)} 分钟前`;
+ } else {
+ return "刚刚";
+ }
+ }
+
+ public static between(begin: Date, end?: Date) : any {
+ if (!end) {
+ end = new Date();
+ }
+ const cs = 1000, cm = 6E4, ch = 36E5, cd = 864E5, cy = 31536E6;
+ const l = end.getTime() - begin.getTime();
+ const y = Math.floor(l / cy),
+ d = Math.floor((l / cd) - y * 365),
+ h = Math.floor((l - (y * 365 + d) * cd) / ch),
+ m = Math.floor((l - (y * 365 + d) * cd - h * ch) / cm),
+ s = Math.floor((l - (y * 365 + d) * cd - h * ch - m * cm) / cs),
+ ms = Math.floor(((l - (y * 365 + d) * cd - h * ch - m * cm) / cs - s) * cs);
+ return { l, y, d, h, m, s, ms };
+ }
+
+ public static toMediaTime(seconds: number): string {
+ seconds = Math.floor(seconds);
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const second = seconds % 60;
+ if (0 < hours) {
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${second.toString().padStart(2, "0")}`;
+ }
+ return `${minutes.toString().padStart(2, "0")}:${second.toString().padStart(2, "0")}`;
+ }
+}
diff --git a/src/utils/Toolkit.ts b/src/utils/Toolkit.ts
new file mode 100644
index 0000000..235d6c5
--- /dev/null
+++ b/src/utils/Toolkit.ts
@@ -0,0 +1,354 @@
+import type { App } from "vue";
+import type { InstallRecord } from "~/types";
+import { UserLevelType } from "~/types/User";
+import { CommonAPI, SettingKey, SettingMapper } from "~/index";
+
+export default class Toolkit {
+
+ public static isFunction = (val: any) => typeof val === "function";
+ public static isArray = Array.isArray;
+ public static isString = (val: any) => typeof val === "string";
+ public static isSymbol = (val: any) => typeof val === "symbol";
+ public static isObject = (val: any) => val !== null && typeof val === "object";
+ public static isFile = (val: any) => val instanceof File;
+
+ /**
+ * 添加安装方法
+ * @example
+ * ```JS
+ * import { MeDemo } from './index.vue'
+ *
+ * addInstall(MeDemo)
+ * ```
+ */
+ public static withInstall = (comp: T): InstallRecord => {
+ const _comp = comp as InstallRecord;
+ _comp.install = (app: App) => {
+ app.component((comp as any).name, _comp);
+ };
+ return _comp;
+ };
+
+ public static guid() {
+ const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
+ return `${s4() + s4()}-${s4()}-${s4()}-${s4()}-${s4() + s4() + s4()}`;
+ }
+
+ public static className(...args: any[]) {
+ const classes = [];
+ for (let i = 0; i < args.length; i++) {
+ const value = args[i];
+ if (!value) continue;
+ if (this.isString(value)) {
+ classes.push(value);
+ } else if (this.isArray(value)) {
+ for (let i = 0; i < value.length; i++) {
+ const inner: any = this.className(value[i]);
+ if (inner) {
+ classes.push(inner);
+ }
+ }
+ } else if (this.isObject(value)) {
+ for (const name in value) {
+ if (value[name]) {
+ classes.push(name);
+ }
+ }
+ }
+ }
+ return classes.join(" ");
+ }
+
+ public static isEmpty(obj: any): boolean {
+ if (this.isString(obj)) {
+ return (obj as string).trim().length === 0;
+ }
+ if (this.isArray(obj)) {
+ return (obj as []).length === 0;
+ }
+ if (this.isFunction(obj)) {
+ return this.isEmpty(obj());
+ }
+ if (this.isObject(obj)) {
+ return obj === undefined || obj === null;
+ }
+ return obj === undefined || obj === null;
+ }
+
+ public static isNotEmpty(obj: any): boolean {
+ return !this.isEmpty(obj);
+ }
+
+ /**
+ * ### 延时执行
+ *
+ * ```js
+ * await sleep(1E3)
+ * ```
+ *
+ * @param ms 延时毫秒
+ */
+ public static async sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+ }
+
+ /**
+ * 获取节点属性
+ *
+ * @param el 节点
+ * @param name 属性名
+ * @returns 属性
+ */
+ public static getAttribute(el: Element, name: string): string | null {
+ return el.hasAttribute(name) ? el.getAttribute(name) : null;
+ }
+
+ /**
+ * 转为数字
+ *
+ * @param string 字符串
+ * @param fallback 如果失败,返回该值
+ * @returns 转换后或转换失败数据
+ */
+ public static toNumber(string: string, fallback?: number): number {
+ if (!string) return fallback as number;
+ const number = Number(string);
+ return isFinite(number) ? number : fallback as number;
+ }
+
+ /**
+ * ### 解析字符串为 DOM 节点。此 DOM 字符串必须有且仅有一个根节点
+ *
+ * ```js
+ * toDOM(`
+ *
+ * `)
+ * ```
+ *
+ * @param string 字符串
+ * @returns DOM 节点
+ */
+ public static toDOM(string: string): Document {
+ return new DOMParser().parseFromString(string, "text/xml");
+ }
+
+ /**
+ * 异步执行
+ *
+ * @param event 函数
+ */
+ public static async(event: Function) {
+ setTimeout(event, 0);
+ }
+
+ /**
+ * 生成随机数
+ *
+ * @param min 最小值
+ * @param max 最大值
+ */
+ public static random(min = 0, max = 100): number {
+ return Math.floor(Math.random() * (max + 1 - min)) + min;
+ }
+
+ /**
+ * Base64 数据转文件
+ *
+ * __需要头部元数据__
+ *
+ * __示例:__
+ * data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA
+ *
+ * @param data Base64 数据
+ * @param fileName 文件名
+ * @returns 文件对象
+ */
+ public static base64ToFile(data: string, fileName: string): File {
+ const splitData = data.split(",");
+ const base64 = atob(splitData[1]);
+ let n = base64.length as number;
+ const u8arr = new Uint8Array(n);
+ while (n--) {
+ u8arr[n] = base64.charCodeAt(n);
+ }
+ return new File([u8arr], fileName, {type: splitData[0].split(":")[1]});
+ }
+
+ /**
+ * 深克隆对象
+ *
+ * @param origin 源对象
+ * @param target 递归对象
+ * @returns 克隆对象
+ */
+ public static deepClone(origin: any, target = {} as any) {
+ const toString = Object.prototype.toString;
+ const arrType = "[object Array]";
+ for (const key in origin) {
+ if (Object.prototype.hasOwnProperty.call(origin, key)) {
+ if (typeof origin[key] === "object" && origin[key] !== null) {
+ target[key] = toString.call(origin[key]) === arrType ? [] : {};
+ this.deepClone(origin[key], target[key]);
+ } else {
+ target[key] = origin[key];
+ }
+ }
+ }
+ return target;
+ }
+
+ public static keyValueString(obj: object, assign: string, split: string): string {
+ let result = "";
+ Object.entries(obj).forEach(([k, v]) => result += k + assign + v + split);
+ return result.substring(0, result.length - split.length);
+ }
+
+ public static toURLArgs(obj?: { [key: string]: any }): string {
+ if (!obj) {
+ return "";
+ }
+ const args: string[] = [];
+ for (const key in obj) {
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
+ const value = obj[key];
+ if (Array.isArray(value)) {
+ value.forEach((item) => {
+ args.push(`${encodeURIComponent(key)}=${encodeURIComponent(item)}`);
+ });
+ } else {
+ args.push(`${encodeURIComponent(key)}=${encodeURIComponent(value.toString())}`);
+ }
+ }
+ }
+ return args.join("&");
+ }
+
+ public static toObject(map: Map): object {
+ return Array.from(map.entries()).reduce((acc, [key, value]) => {
+ acc[key] = value ?? null;
+ return acc;
+ }, {} as { [key: string]: string | undefined });
+ }
+
+ public static uuid(): string {
+ const char = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+ const length = [8, 4, 4, 4, 12];
+ let result = "";
+ for (let i = 0; i < length.length; i++) {
+ for (let j = 0; j < length[i]; j++) {
+ result += char[this.random(0, char.length - 1)];
+ }
+ result += "-";
+ }
+ return result.substring(0, result.length - 1);
+ }
+
+ public static keyFromValue(e: any, value: any): string {
+ return Object.keys(e)[Object.values(e).indexOf(value)];
+ }
+
+ // 防抖
+ // eslint-disable-next-line
+ public static debounce any>(callback: T, defaultImmediate = true, delay = 600): T & {
+ cancel(): void
+ } {
+ let timerId: ReturnType | null = null; // 存储定时器
+ let immediate = defaultImmediate;
+ // 定义一个 cancel 办法,用于勾销防抖
+ const cancel = (): void => {
+ if (timerId) {
+ clearTimeout(timerId);
+ timerId = null;
+ }
+ };
+
+ const debounced = function (this: ThisParameterType, ...args: Parameters): void {
+ const context = this;
+ if (timerId) {
+ cancel();
+ }
+ if (immediate) {
+ callback.apply(context, args);
+ immediate = false;
+ timerId = setTimeout(() => {
+ immediate = defaultImmediate;
+ }, delay);
+ } else {
+ // 设置定时器,在延迟时间后执行指标函数
+ timerId = setTimeout(() => {
+ callback.apply(context, args);
+ immediate = defaultImmediate;
+ }, delay);
+ }
+ };
+ // 将 cancel 方法附加到 debounced 函数上
+ (debounced as any).cancel = cancel;
+ return debounced as T & { cancel(): void };
+ }
+
+ public static toUserLevel(exp?: number): UserLevelType {
+ exp = exp ?? 0;
+ const levels = [0, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096];
+ for (let i = 1; i < levels.length; i++) {
+ if (exp < levels[i]) {
+ return {
+ exp,
+ value: i - 1,
+ percent: (exp - levels[i - 1]) / (levels[i] - levels[i - 1]),
+ nextLevelUp: i === levels.length - 1 ? 4096 : levels[i]
+ };
+ }
+ }
+ return { exp, value: 8, percent: 1, nextLevelUp: 4096 };
+ }
+
+ public static toFormData(root?: object): FormData {
+ const form = new FormData();
+ if (!root) {
+ return form;
+ }
+ const run = (parent: string | null, obj: object) => {
+ for (const [key, value] of Object.entries(obj)) {
+ if (this.isObject(value) && !this.isFile(value)) {
+ if (parent) {
+ run(`${parent}.${key}`, value);
+ } else {
+ run(key, value);
+ }
+ } else {
+ if (parent) {
+ form.append(`${parent}.${key}`, value);
+ } else {
+ form.append(key, value);
+ }
+ }
+ }
+ };
+ run(null, root);
+ return form;
+ }
+
+ public static toResURL(val: string): string {
+ const at = val.indexOf("@");
+ const start = val.substring(0, at);
+ const path = val.substring(at + 1);
+ switch (start) {
+ case "res": return SettingMapper.getDomainLink(SettingKey.DOMAIN_RESOURCE) + path;
+ case "dl": return SettingMapper.getDomainLink(SettingKey.DOMAIN_DOWNLOAD) + path;
+ case "attach": return CommonAPI.getAttachmentReadAPI(path);
+ }
+ return val;
+ }
+
+ public static format(template: string, variables: { [key: string]: any }): string {
+ return template.replace(/\$\{(\w+)}/g, (_, key) => variables[key]);
+ }
+
+ public static leftClickCallback(event: MouseEvent, callback: Function): void {
+ if (event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
+ callback();
+ }
+ }
+}
diff --git a/src/utils/directives/Draggable.ts b/src/utils/directives/Draggable.ts
new file mode 100644
index 0000000..6ac9294
--- /dev/null
+++ b/src/utils/directives/Draggable.ts
@@ -0,0 +1,130 @@
+import type { Directive, DirectiveBinding } from "vue";
+
+export type DraggableConfig = {
+
+ /** 点下 */
+ onMouseDown?: (e: MouseEvent | TouchEvent) => void;
+
+ /** 中断条件(返回 true 时不触发 onDragging 及往后事件) */
+ interruptWhen?: (e: MouseEvent | TouchEvent) => boolean;
+
+ /** 拖动 */
+ onDragging: (e: MouseEvent | TouchEvent, relX: number, relY: number, offsetX: number, offsetY: number) => void;
+
+ /** 释放 */
+ onDragged?: (e: MouseEvent | TouchEvent, x: number, y: number) => void;
+}
+
+const getEventPosition = (e: MouseEvent | TouchEvent): { clientX: number, clientY: number, pageX: number, pageY: number } => {
+ if ("touches" in e && e.touches.length > 0) {
+ const touch = e.touches[0];
+ return {
+ clientX: touch.clientX,
+ clientY: touch.clientY,
+ pageX: touch.pageX,
+ pageY: touch.pageY
+ };
+ } else if (e instanceof MouseEvent) {
+ return {
+ clientX: e.clientX,
+ clientY: e.clientY,
+ pageX: e.pageX,
+ pageY: e.pageY
+ };
+ }
+ return { clientX: 0, clientY: 0, pageX: 0, pageY: 0 };
+};
+
+const VDraggable: Directive = {
+ // 挂载
+ mounted(el: HTMLElement, binding: DirectiveBinding) {
+ const config = binding.value as DraggableConfig;
+ let isClicked = false, ox = 0, oy = 0, ol = 0, ot = 0, opx = 0, opy = 0;
+
+ // 按下
+ const handleStart = (e: MouseEvent | TouchEvent) => {
+ e.preventDefault();
+
+ const pos = getEventPosition(e);
+ ox = pos.clientX;
+ oy = pos.clientY;
+ ol = el.offsetLeft;
+ ot = el.offsetTop;
+ opx = pos.pageX;
+ opy = pos.pageY;
+
+ config.onMouseDown?.(e);
+ if (config.interruptWhen?.(e)) return;
+
+ isClicked = true;
+ };
+ // 移动
+ const handleMove = (e: MouseEvent | TouchEvent) => {
+ if (!isClicked) {
+ return;
+ }
+ e.preventDefault();
+
+ const pos = getEventPosition(e);
+ const relX = pos.clientX - (ox - ol);
+ const relY = pos.clientY - (oy - ot);
+ const offsetX = pos.pageX - opx;
+ const offsetY = pos.pageY - opy;
+
+ config.onDragging(e, relX, relY, offsetX, offsetY);
+ };
+ // 释放
+ const handleEnd = (e: MouseEvent | TouchEvent) => {
+ if (!isClicked) {
+ return;
+ }
+ const pos = getEventPosition(e);
+ config.onDragged?.(e, pos.clientX - ox, pos.clientY - oy);
+ isClicked = false;
+ };
+
+ (el.style as any)["user-drag"] = "none";
+ (el.style as any)["touch-action"] = "none";
+
+ // 鼠标
+ el.addEventListener("mousedown", handleStart as EventListener);
+ document.addEventListener("mousemove", handleMove as EventListener);
+ document.addEventListener("mouseup", handleEnd as EventListener);
+
+ // 触控
+ el.addEventListener("touchstart", handleStart as EventListener, { passive: false });
+ document.addEventListener("touchmove", handleMove as EventListener, { passive: false });
+ document.addEventListener("touchend", handleEnd as EventListener, { passive: false });
+
+ // 保存事件处理器以便卸载时使用
+ (el as any)._vDraggableHandlers = {
+ handleStart,
+ handleMove,
+ handleEnd
+ };
+ },
+ // 卸载
+ unmounted(el: HTMLElement) {
+ const handlers = (el as any)._vDraggableHandlers as {
+ handleStart: EventListener,
+ handleMove: EventListener,
+ handleEnd: EventListener
+ };
+ if (handlers) {
+ // 鼠标
+ el.removeEventListener("mousedown", handlers.handleStart);
+ document.removeEventListener("mousemove", handlers.handleMove);
+ document.removeEventListener("mouseup", handlers.handleEnd);
+
+ // 触控
+ el.removeEventListener("touchstart", handlers.handleStart);
+ document.removeEventListener("touchmove", handlers.handleMove);
+ document.removeEventListener("touchend", handlers.handleEnd);
+
+ // 引用
+ delete (el as any)._vDraggableHandlers;
+ }
+ }
+};
+
+export default VDraggable;
diff --git a/src/utils/directives/Popup.ts b/src/utils/directives/Popup.ts
new file mode 100644
index 0000000..2b0ba51
--- /dev/null
+++ b/src/utils/directives/Popup.ts
@@ -0,0 +1,120 @@
+import type { Directive, DirectiveBinding } from "vue";
+import Toolkit from "../Toolkit";
+
+export enum PopupType {
+ TEXT,
+ IMG,
+ HTML,
+ EL
+}
+
+/** */
+export type PopupConfig = {
+
+ type: PopupType,
+ value?: string | HTMLElement;
+ canShow?: () => boolean;
+ beforeShow?: (type: PopupType, value: string | HTMLElement) => Promise;
+ afterHidden?: (type: PopupType, value: string | HTMLElement) => Promise;
+}
+
+// Popup 弹出提示 DOM 节点,全局唯一
+let popup: HTMLElement | null;
+
+const VPopup: Directive = {
+
+ mounted(el: HTMLElement, binding: DirectiveBinding) {
+ // 转配置
+ let config: PopupConfig;
+ if (binding.arg && binding.arg === "config") {
+ config = binding.value as PopupConfig;
+ } else {
+ config = {
+ type: PopupType.TEXT,
+ value: binding.value as any as string,
+ canShow: () => true
+ };
+ }
+ // Popup 节点
+ if (!popup) {
+ popup = document.getElementById("tui-popup");
+ }
+ let isShowing = false;
+ // 显示
+ el.addEventListener("mouseenter", async e => {
+ if (!config.value) {
+ console.warn("not found popup value", config);
+ return;
+ }
+ if (config.beforeShow) {
+ await config.beforeShow(config.type, config.value);
+ }
+ if (config.canShow && config.canShow() && popup) {
+ let el: HTMLElement | null = null;
+ if (!config) {
+ el = document.createElement("div");
+ el.className = "text";
+ el.textContent = config as string;
+ popup.appendChild(el);
+ }
+ switch (config.type) {
+ case PopupType.TEXT:
+ // 文本
+ el = document.createElement("div");
+ el.className = "text";
+ el.textContent = config.value as string;
+ popup.appendChild(el);
+ break;
+ case PopupType.IMG:
+ // 图片
+ el = document.createElement("img");
+ (el as HTMLImageElement).src = config.value as string;
+ popup.appendChild(el);
+ break;
+ case PopupType.HTML:
+ // HTML 字符串
+ popup.appendChild(Toolkit.toDOM(config.value as string));
+ break;
+ case PopupType.EL:
+ // DOM 节点
+ if (config.value instanceof HTMLElement) {
+ const valueEl = config.value as HTMLElement;
+ valueEl.style.display = "block";
+ popup.appendChild(valueEl);
+ break;
+ } else {
+ console.error(config);
+ throw new Error("Vue 指令错误:v-popup:el 的值不是 HTML 元素");
+ }
+ }
+ popup.style.left = (e.x + 20) + "px";
+ popup.style.top = (e.y + 14) + "px";
+ popup.style.visibility = "visible";
+ isShowing = true;
+ }
+ }, false);
+ // 移动
+ el.addEventListener("mousemove", async (e) => {
+ if (config.canShow && config.canShow() && isShowing && popup) {
+ popup.style.left = (e.x + 20) + "px";
+ popup.style.top = (e.y + 14) + "px";
+ }
+ }, false);
+ // 隐藏
+ el.addEventListener("mouseleave", async () => {
+ if (popup) {
+ popup.style.visibility = "hidden";
+ popup.innerText = "";
+ popup.style.left = "0px";
+ popup.style.top = "0px";
+
+ // 隐藏后事件
+ if (config.afterHidden && config.value) {
+ await config.afterHidden(config.type, config.value);
+ }
+ }
+ }, false);
+ }
+};
+
+export default VPopup;
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..f7e5df6
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,44 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "Node",
+ "useDefineForClassFields": true,
+ "jsx": "preserve",
+ "strict": true,
+ "sourceMap": true,
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "lib": [
+ "ESNext",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "paths": {
+ "~/*": [
+ "./src/*"
+ ],
+ "@/*": [
+ "./examples/*"
+ ],
+ "timi-web": [
+ "./src/index.ts"
+ ]
+ }
+ },
+ "include": [
+ "src/**/*.ts",
+ "src/**/*.d.ts",
+ "src/**/*.vue",
+ "examples/**/*.ts",
+ "examples/**/*.d.ts",
+ "examples/**/*.vue"
+ ],
+ "references": [
+ {
+ "path": "./tsconfig.node.json"
+ }
+ ]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..c0d4a4a
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "composite": true,
+ "module": "ESNext",
+ "moduleResolution": "Node"
+ },
+ "include": [
+ "vite.config.ts"
+ ]
+}
diff --git a/tsconfig.types.json b/tsconfig.types.json
new file mode 100644
index 0000000..be8c701
--- /dev/null
+++ b/tsconfig.types.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "declaration": true,
+ "emitDeclarationOnly": true
+ },
+ "include": [
+ "src"
+ ]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..a3f70c2
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,110 @@
+import { resolve } from "path";
+import { Alias, defineConfig } from "vite";
+import vue from "@vitejs/plugin-vue";
+import VueSetupExtend from "vite-plugin-vue-setup-extend";
+import { prismjsPlugin } from "vite-plugin-prismjs";
+import AutoImport from "unplugin-auto-import/vite";
+import Components from "unplugin-vue-components/vite";
+import dts from "vite-plugin-dts";
+
+const alias: Alias[] = [
+ {
+ find: "@",
+ replacement: resolve(__dirname, "./examples")
+ },
+ {
+ find: "~",
+ replacement: resolve(__dirname, "./src")
+ },
+ {
+ find: "*",
+ replacement: resolve("")
+ },
+ {
+ find: /^timi-web(\/(es|lib))?$/,
+ replacement: resolve(__dirname, "./src/index.ts")
+ }
+];
+
+export default defineConfig({
+ server: {
+ port: 3003,
+ host: true
+ },
+ resolve: {
+ alias
+ },
+ build: {
+ outDir: "dist",
+ lib: {
+ entry: resolve(__dirname, "./src/index.ts"),
+ name: "TimiWeb",
+ fileName: "timi-web"
+ },
+ rollupOptions: {
+ external: [
+ "vue"
+ ],
+ output: {
+ globals: {
+ vue: "Vue"
+ }
+ }
+ },
+ minify: "terser",
+ terserOptions: {
+ compress: {
+ // eslint-disable-next-line camelcase
+ drop_console: false,
+ // eslint-disable-next-line camelcase
+ drop_debugger: false
+ }
+ }
+ },
+ plugins: [
+ vue({
+ include: [/\.vue$/, /\.md$/]
+ }),
+ VueSetupExtend(),
+ dts(),
+ prismjsPlugin({
+ languages: [
+ "ini",
+ "php",
+ "sql",
+ "xml",
+ "css",
+ "less",
+ "html",
+ "json",
+ "yaml",
+ "java",
+ "nginx",
+ "javascript",
+ "typescript",
+ "apacheconf",
+ "properties"
+ ],
+ plugins: [
+ "line-numbers"
+ ],
+ theme: "default",
+ css: true
+ }),
+ AutoImport({
+ imports: [
+ "vue"
+ ],
+ dts: "examples/auto-imports.d.ts",
+ eslintrc: {
+ enabled: true,
+ globalsPropValue: true
+ }
+ }),
+ Components({
+ dirs: [
+ "src/components"
+ ]
+ })
+ ]
+});