diff --git a/README.md b/README.md index ef72fd5..d7c5017 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,10 @@ -# Vue 3 + TypeScript + Vite +# timi-web -This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + diff --git a/src/components/captcha/index.ts b/src/components/captcha/index.ts index 34eb2be..fa88b10 100644 --- a/src/components/captcha/index.ts +++ b/src/components/captcha/index.ts @@ -3,3 +3,4 @@ import Toolkit from "../../utils/Toolkit"; export const Captcha = Toolkit.withInstall(view); export default Captcha; +export type { CaptchaRequest, CaptchaResult } from "./types"; diff --git a/src/components/captcha/index.vue b/src/components/captcha/index.vue index 275aad3..4dce8ed 100644 --- a/src/components/captcha/index.vue +++ b/src/components/captcha/index.vue @@ -13,8 +13,7 @@ + + + + diff --git a/src/components/description-grid/type.ts b/src/components/description-grid/type.ts new file mode 100644 index 0000000..ba0e813 --- /dev/null +++ b/src/components/description-grid/type.ts @@ -0,0 +1,33 @@ +export type DescriptionGridAlign = "left" | "center" | "right"; + +export interface DescriptionGridItem { + /** 唯一标识,同时用于生成具名插槽名称 */ + key: string; + /** 标签文本 */ + label: string; + /** 默认展示值 */ + value?: string | number | boolean | null; + /** 标签单元格自定义类名 */ + labelClass?: string; + /** 内容单元格自定义类名 */ + valueClass?: string; +} + +export interface DescriptionGridProps { + /** 描述项列表 */ + items: DescriptionGridItem[]; + /** 一行显示几组 key/value */ + columns?: number; + /** 空值占位文本 */ + emptyText?: string; + /** 是否显示边框 */ + bordered?: boolean; + /** 是否在窄屏下自动降为单列 */ + singleColumnOnMobile?: boolean; + /** 标签内容对齐方式 */ + labelAlign?: DescriptionGridAlign; + /** 值内容对齐方式 */ + valueAlign?: DescriptionGridAlign; +} + + diff --git a/src/components/index.ts b/src/components/index.ts index e3ceda5..efac085 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,9 +1,9 @@ /** 导出所有组件 */ import Icon from "./icon"; import Popup from "./popup"; +import HoverPopup, { VPopupComponent } from "./v-popup"; import Captcha from "./captcha"; import Loading from "./loading"; -import UserLevel from "./user-level"; import Copyright from "./copyright"; import EmptyTips from "./empty-tips"; import MarkdownView from "./markdown-view"; @@ -11,33 +11,43 @@ import BEFlowerFall from "./background-effect/flower-fall"; import MarkdownEditor from "./markdown-editor"; import PassTimeLabel from "./passtime-label"; import ContactNumber from "./contact-number"; +import AnimatedNumber from "./animated-number"; +import DescriptionGrid from "./description-grid"; export default [ Icon, Popup, + HoverPopup, Captcha, Loading, - UserLevel, Copyright, EmptyTips, MarkdownView, BEFlowerFall, MarkdownEditor, PassTimeLabel, - ContactNumber + ContactNumber, + AnimatedNumber, + DescriptionGrid ]; export { Icon, Popup, + HoverPopup, + VPopupComponent, Captcha, Loading, - UserLevel, Copyright, EmptyTips, MarkdownView, BEFlowerFall, MarkdownEditor, PassTimeLabel, - ContactNumber + ContactNumber, + AnimatedNumber, + DescriptionGrid }; + +export type { CaptchaRequest, CaptchaResult } from "./captcha"; +export type { DescriptionGridAlign, DescriptionGridItem, DescriptionGridProps } from "./description-grid"; diff --git a/src/components/markdown-view/style.less b/src/components/markdown-view/style.less index 6d5b8a5..9a187be 100644 --- a/src/components/markdown-view/style.less +++ b/src/components/markdown-view/style.less @@ -262,7 +262,6 @@ background: transparent; max-height: var(--data-max-height); transition: max-height .5s var(--tui-bezier); - font-family: var(--tui-font); line-height: 1; border-radius: 0; @@ -270,7 +269,6 @@ color: #333; background: transparent; text-shadow: none !important; - font-family: var(--tui-font); .line-numbers-rows { left: 0; diff --git a/src/components/popup/index.vue b/src/components/popup/index.vue index 2c0bda6..dc9256f 100644 --- a/src/components/popup/index.vue +++ b/src/components/popup/index.vue @@ -1,5 +1,7 @@ - - diff --git a/src/components/v-popup/index.ts b/src/components/v-popup/index.ts new file mode 100644 index 0000000..3fb5733 --- /dev/null +++ b/src/components/v-popup/index.ts @@ -0,0 +1,8 @@ +import view from "./index.vue"; +import Toolkit from "../../utils/Toolkit"; + +export const HoverPopup = Toolkit.withInstall(view); + +// 兼容旧导出,后续应统一改为 HoverPopup +export const VPopupComponent = HoverPopup; +export default HoverPopup; diff --git a/src/components/v-popup/index.vue b/src/components/v-popup/index.vue new file mode 100644 index 0000000..4d156d9 --- /dev/null +++ b/src/components/v-popup/index.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/src/editor/article.ts b/src/editor/article.ts deleted file mode 100644 index cb1f026..0000000 --- a/src/editor/article.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * 文章编辑器的公共数据处理。 - * 负责统一内容类型、附件标识和存储前的富文本格式,平台层不参与这些规则。 - */ -import type { ArticleContentType, Attachment } from "../types"; -import { ATTACHMENT_ID_ATTR, isLocalMediaId, normalizeContent, TEMP_FILE_ID_ATTR } from "./tiptap/shared"; - -/** 文章内容中附件和临时文件的标识前缀。 */ -export const ATTACHMENT_ID_PREFIX = "attachment-id:"; -export const TEMP_FILE_ID_PREFIX = "temp-file-id:"; -export const ARTICLE_IMAGE_ATTACH_TYPE = "CONTENT_IMAGE"; -export const ARTICLE_VIDEO_ATTACH_TYPE = "CONTENT_VIDEO"; -/** 支持的文章内容类型和默认类型。 */ -export const DEFAULT_ARTICLE_CONTENT_TYPE: ArticleContentType = "TIP_TAP"; -export const ARTICLE_CONTENT_TYPE_LIST: ArticleContentType[] = ["TEXT", "MARKDOWN", "TIP_TAP"]; - -/** 将后端历史值和未知值归一化为当前支持的内容类型。 */ -export function normalizeArticleContentType(contentType?: string): ArticleContentType { - return ARTICLE_CONTENT_TYPE_LIST.includes(contentType as ArticleContentType) ? contentType as ArticleContentType : DEFAULT_ARTICLE_CONTENT_TYPE; -} - -/** 将空内容转换为空字符串,避免编辑器收到 undefined。 */ -export function normalizeRawArticleContent(content?: string) { - return content || ""; -} - -/** 从媒体 src 中读取持久化附件 ID。 */ -export function getAttachmentIdFromSrc(src?: string) { - if (!src?.startsWith(ATTACHMENT_ID_PREFIX)) { - return ""; - } - return src.slice(ATTACHMENT_ID_PREFIX.length); -} - -/** 从媒体 src 中读取临时文件 ID。 */ -export function getTempFileIdFromSrc(src?: string) { - if (!src?.startsWith(TEMP_FILE_ID_PREFIX)) { - return ""; - } - return src.slice(TEMP_FILE_ID_PREFIX.length); -} - -/** 构造带有附件 ID 的内部媒体地址。 */ -export function buildAttachmentIdSrc(attachmentId: string) { - return `${ATTACHMENT_ID_PREFIX}${attachmentId}`; -} - -/** 根据媒体标签判断附件类型。 */ -function getMediaAttachType(tagName: string) { - return tagName.toLowerCase() === "video" ? ARTICLE_VIDEO_ATTACH_TYPE : ARTICLE_IMAGE_ATTACH_TYPE; -} - -/** 仅为 blob 预览地址保留预览元数据,服务器地址不重复保存。 */ -function buildAttachmentMetadata(src: string) { - if (!src.startsWith("blob:")) { - return undefined; - } - return { - previewUrl: src - }; -} - -/** 过滤本地临时媒体 ID,避免把前端占位 ID 当成可上传附件。 */ -function normalizeAttachmentTempFileId(tempFileId?: string) { - if (!tempFileId || isLocalMediaId(tempFileId)) { - return undefined; - } - return tempFileId; -} - -/** 从文章 HTML 中提取图片和视频附件,并按 ID 去重。 */ -export function buildAttachmentListFromContent(content?: string) { - const rawContent = normalizeContent(content); - if (!rawContent) { - return [] as Attachment[]; - } - const parser = new DOMParser(); - const doc = parser.parseFromString(rawContent, "text/html"); - const mediaList = Array.from(doc.querySelectorAll("img, video")); - const attachmentMap = new Map(); - - for (const media of mediaList) { - const src = media.getAttribute("src") || ""; - const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src); - const tempFileId = media.getAttribute(TEMP_FILE_ID_ATTR) || getTempFileIdFromSrc(src); - const key = attachmentId || tempFileId; - if (!key || attachmentMap.has(key)) { - continue; - } - attachmentMap.set(key, { - id: attachmentId || key, - tempFileId: normalizeAttachmentTempFileId(tempFileId), - name: media.getAttribute("title") || media.getAttribute("alt") || "", - attachType: getMediaAttachType(media.tagName), - metadata: buildAttachmentMetadata(src) - }); - } - - return Array.from(attachmentMap.values()); -} - -/** 将媒体地址统一改写为附件地址,供后端保存文章内容。 */ -export function normalizeContentForStore(content?: string) { - const rawContent = normalizeContent(content); - if (!rawContent) { - return ""; - } - const parser = new DOMParser(); - const doc = parser.parseFromString(rawContent, "text/html"); - const mediaList = Array.from(doc.querySelectorAll("img, video")); - for (const media of mediaList) { - const src = media.getAttribute("src") || ""; - const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src); - const tempFileId = media.getAttribute(TEMP_FILE_ID_ATTR) || getTempFileIdFromSrc(src); - const finalAttachmentId = attachmentId || tempFileId; - if (!finalAttachmentId) { - continue; - } - media.setAttribute(ATTACHMENT_ID_ATTR, finalAttachmentId); - media.removeAttribute(TEMP_FILE_ID_ATTR); - media.setAttribute("src", buildAttachmentIdSrc(finalAttachmentId)); - } - return normalizeContent(doc.body.innerHTML); -} - -/** 比较附件列表的业务字段,不比较对象引用。 */ -export function sameAttachmentList(left?: Attachment[], right?: Attachment[]) { - const leftList = left || []; - const rightList = right || []; - if (leftList.length !== rightList.length) { - return false; - } - for (let i = 0; i < leftList.length; i += 1) { - const leftItem = leftList[i]; - const rightItem = rightList[i]; - if (!rightItem) { - return false; - } - if (leftItem.id !== rightItem.id) { - return false; - } - if ((leftItem.tempFileId || "") !== (rightItem.tempFileId || "")) { - return false; - } - if ((leftItem.accessKey?.value || "") !== (rightItem.accessKey?.value || "")) { - return false; - } - } - return true; -} diff --git a/src/editor/index.ts b/src/editor/index.ts index 514dcfb..4715f3b 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -1,15 +1,9 @@ /** 编辑器公共能力出口。平台组件只依赖这里的无界面逻辑和类型。 */ -export * from "./article"; export * from "./code"; -export * from "./notify"; export * from "./search"; export * from "./tiptap/shared"; export * from "./tiptap/extensions"; export * from "./tiptap/useTiptapEditor"; export * from "./tiptap/useTiptapView"; -export type { Article, ArticleContentType } from "../types/Article"; -export type { Attachment } from "../types/Attachment"; -export type { NotifyDetail } from "../types/Notify"; -export { default as AttachmentAPI } from "../api/AttachmentAPI"; export { default as PrismCodeBlock } from "./tiptap/PrismCodeBlock"; export { enhancePrismCodeBlock } from "./tiptap/PrismCodeBlockView"; diff --git a/src/editor/notify.ts b/src/editor/notify.ts deleted file mode 100644 index 52f7b89..0000000 --- a/src/editor/notify.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** 通知编辑器的公共数据规则。 */ -import type { NotifyContentType } from "../types"; - -/** 通知缺少内容类型时默认使用 Tiptap。 */ -export const DEFAULT_NOTIFY_CONTENT_TYPE: NotifyContentType = "TIP_TAP"; - -/** 规范化通知内容类型,兼容旧通知未返回内容类型的情况。 */ -export function normalizeNotifyContentType(contentType?: string): NotifyContentType { - return contentType === "TEXT" ? "TEXT" : DEFAULT_NOTIFY_CONTENT_TYPE; -} diff --git a/src/index.ts b/src/index.ts index 3d16382..b88135a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,18 +6,14 @@ import "./assets/style/variable.less"; import "./assets/style/timi-web.less"; import "prismjs/themes/prism.css"; import "./components/prism-code-block.less"; -import { Network } from "./utils"; import VPopup from "./utils/directives/Popup"; -export * from "./api"; export * from "./components"; export * from "./editor"; export * from "./store"; export * from "./types"; export * from "./utils"; -export const axios = Network.axios; - const install = function (app: App) { components.forEach(component => { app.use(component as unknown as { install: () => any }); diff --git a/src/store/index.ts b/src/store/index.ts index 5026a79..f145333 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,2 +1,2 @@ export * from "./deviceStore"; -export * from "./userStore"; +export * from "./windowStore"; diff --git a/src/store/userStore.ts b/src/store/userStore.ts deleted file mode 100644 index 69d161b..0000000 --- a/src/store/userStore.ts +++ /dev/null @@ -1,218 +0,0 @@ -import {defineStore} from "pinia"; -import {computed, reactive, readonly, ref} from "vue"; -import {CaptchaData, LoginRequest, LoginResponse, LoginUser} from "../types"; -import Cooker from "../utils/Cooker"; -import Storage from "../utils/Storage"; -import {UserAPI} from "../api"; - -/** 用户 Store 配置项 */ -interface UserStoreOptions { - /** localStorage 储存 key,默认 "loginUser" */ - storageKey?: string; -} - -/** 访问控制匹配模式 */ -export type AccessMatchMode = "and" | "or"; - -let _storageKey = "loginUser"; - -type AccessItem = string | { - moduleCode?: string; - code?: string; -}; - -function normalizeAccessItem(item?: AccessItem): string { - if (!item) { - return ""; - } - - if (typeof item === "string") { - return item.trim(); - } - - const code = item.code?.trim(); - if (!code) { - return ""; - } - - const moduleCode = item.moduleCode?.trim(); - return moduleCode && !code.includes(":") ? `${moduleCode}:${code}` : code; -} - -function normalizeAccessList(list?: AccessItem[]): string[] { - if (!list?.length) { - return []; - } - return list - .map(normalizeAccessItem) - .filter((item): item is string => !!item); -} - -function matchAccess(sourceList?: string[], targetList?: string | string[], mode: AccessMatchMode = "or"): boolean { - const normalizedTargetList = normalizeAccessList(Array.isArray(targetList) ? targetList : [targetList || ""]); - if (!normalizedTargetList.length) { - return true; - } - - const sourceSet = new Set(normalizeAccessList(sourceList)); - if (!sourceSet.size) { - return false; - } - - if (mode === "and") { - return normalizedTargetList.every(item => sourceSet.has(item)); - } - return normalizedTargetList.some(item => sourceSet.has(item)); -} - -/** - * 配置用户 Store,应在使用 useUserStore 之前调用 - * @param options 配置项 - */ -function configureUserStore(options: UserStoreOptions): void { - if (options.storageKey) { - _storageKey = options.storageKey; - } -} - -const useUserStore = defineStore("user", () => { - type LoginUserState = LoginUser & Record; - - const loginUser = reactive({}); - const isRestored = ref(false); - - const token = computed(() => loginUser.token ?? ""); - const isLoggedIn = computed(() => isValidLoginUser(loginUser)); - - function resetLoginUser(nextUser?: LoginUserState): void { - Object.keys(loginUser).forEach((key) => { - delete loginUser[key]; - }); - if (nextUser) { - Object.assign(loginUser, nextUser); - } - } - - function isValidLoginUser(user?: LoginUserState | null): user is LoginResponse { - return !!user?.token && !!user.expireAt && new Date().getTime() < user.expireAt; - } - - function isLogged(): boolean { - return isLoggedIn.value; - } - - function getLoginUser(): T | null { - if (!isLogged()) { - return null; - } - return loginUser as T; - } - - function getRoleList(): string[] { - return normalizeAccessList(loginUser.user?.roleList); - } - - function getPermissionList(): string[] { - return normalizeAccessList(loginUser.user?.permissionList); - } - - function hasRoles(roleList: string | string[], mode: AccessMatchMode = "or"): boolean { - return matchAccess(getRoleList(), roleList, mode); - } - - function hasPermissions(permissionList: string | string[], mode: AccessMatchMode = "or"): boolean { - return matchAccess(getPermissionList(), permissionList, mode); - } - - async function login(req: CaptchaData): Promise { - const result = await UserAPI.login(req); - const nextUser = result as unknown as T; - await updateLoginUser(nextUser); - return nextUser; - } - - async function loginByToken(token: string): Promise { - loginUser.token = token; - // 未过期 - try { - const result = await UserAPI.loginByToken(); - if (!result) { - return null; - } - const nextUser = result as unknown as T; - await updateLoginUser(nextUser); - return nextUser; - } catch (e) { - console.error(e); - clearLoginUser(); - } - return null; - } - - async function loginByStorage(): Promise { - if (loginUser.token) { - isRestored.value = true; - return getLoginUser(); - } - - if (!Storage.has(_storageKey)) { - isRestored.value = true; - return null; - } - - // 未登录,储存有令牌。 - const storageUser = Storage.getJSON(_storageKey) as T; - if (isValidLoginUser(storageUser)) { - const result = await loginByToken(storageUser.token); - isRestored.value = true; - return result; - } - - clearLoginUser(); - isRestored.value = true; - return null; - } - - async function updateLoginUser(user: T): Promise { - resetLoginUser(user as LoginUserState); - Storage.setJSON(_storageKey, user); - } - - function clearLoginUser(): void { - resetLoginUser(); - Cooker.remove("Token"); - Storage.remove(_storageKey); - } - - async function logout(): Promise { - try { - await UserAPI.logout(); - } finally { - clearLoginUser(); - } - } - - return { - token, - isLoggedIn, - isRestored, - getRoleList, - getPermissionList, - hasRoles, - hasPermissions, - isLogged, - loginUser: readonly(loginUser), - getLoginUser, - login, - loginByToken, - loginByStorage, - updateLoginUser, - clearLoginUser, - logout - }; -}); - -export { - useUserStore, - configureUserStore -}; diff --git a/src/store/windowStore.ts b/src/store/windowStore.ts new file mode 100644 index 0000000..4c1ddd4 --- /dev/null +++ b/src/store/windowStore.ts @@ -0,0 +1,12 @@ +/** 窗口级通用状态操作。 */ +function setTitle(title?: string) { + window.document.title = title || ""; +} + +const windowStore = { + setTitle +}; + +export { + windowStore +}; diff --git a/src/types/Article.ts b/src/types/Article.ts deleted file mode 100644 index 9f3e76d..0000000 --- a/src/types/Article.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Attachment } from "./Attachment"; -import type { Comment } from "./Comment"; -import type { Model } from "./Model"; -import type { Setting } from "./Setting"; - -/** 文章业务类型 */ -export type ArticleBizType = "BLOG" | "FEEDBACK"; - -/** 文章正文类型 */ -export type ArticleContentType = "TEXT" | "MARKDOWN" | "TIP_TAP"; - -/** 文章状态 */ -export type ArticleStatus = "DART" | "NORMAL" | "HIDDEN"; - -/** 文章实体 */ -export type Article = { - /** 业务类型 */ - bizType?: ArticleBizType; - - /** 业务 ID */ - bizId?: string; - - /** 标题 */ - title?: string; - - /** 描述 */ - description?: string; - - /** 正文类型 */ - contentType?: ArticleContentType; - - /** 正文内容 */ - content?: string; - - /** 阅读数量 */ - reads?: number; - - /** 喜欢数量 */ - likes?: number; - - /** 文章状态 */ - status?: ArticleStatus; - - /** 设置列表 */ - settingList?: Setting[]; - - /** 评论列表 */ - commentList?: Comment[]; - - /** 附件列表 */ - attachmentList?: Attachment[]; -} & Model; diff --git a/src/types/Attachment.ts b/src/types/Attachment.ts deleted file mode 100644 index 902096f..0000000 --- a/src/types/Attachment.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Model } from "./Model"; - -export type Attachment = { - bizType?: AttachmentBizType; - bizId?: string; - attachType?: string; - mongoId?: string; - title?: string; - name?: string; - mimeType?: string; - metadata?: Record; - accessKey?: AttachmentAccessKey; - size?: number; - md5?: string; - uploaderIp?: string; - isDestroyed?: boolean; - destroyAt?: number; - tempFileId?: string; -} & Model; - -export type TempFile = { - id: string; - expireAt?: number; -}; - -export type BizIdUpdate = { - bizType: AttachmentBizType; - bizId: string; - items: Partial[]; -}; - -export type AttachmentBizType = - | "ARTICLE" - | "USER" - | "GAO_CUSTOMER" - | "GAO_EVENT_RECORD" - | "GAO_REGISTER_RECORD" - | "TEMP_FILE" - | "MIRROR"; - -/** 附件访问密钥 */ -export type AttachmentAccessKey = { - value: string; - attachmentId: string; - isDisposable: boolean; - expiredAt: number; - createdAt: number; -}; diff --git a/src/types/Comment.ts b/src/types/Comment.ts deleted file mode 100644 index 4011c30..0000000 --- a/src/types/Comment.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Model, Page } from "./Model"; -import { User } from "./User"; - -/** 评论 */ -export type Comment = { - bizType?: CommentBizType; - bizId?: number; - userId?: number; - nick?: string; - content?: string; - - /** 所属用户 */ - user?: User; - - /** 回复列表 */ - replies?: Comment[]; - - /** 回复分页 */ - repliesPage?: Page; - - /** 用于绑定组件当前页下标 */ - repliesCurrent?: number; - - /** 回复数量 */ - repliesLength?: number; - - /** 关联文章 */ - article?: object; - - /** 关联 Git 仓库 */ - repository?: object; -} & Model - -export type CommentBizType = "ARTICLE" | "GIT_ISSUE" | "GIT_MERGE"; diff --git a/src/types/Developer.ts b/src/types/Developer.ts deleted file mode 100644 index 859c0c3..0000000 --- a/src/types/Developer.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** 开发者配置 */ -export type Developer = { - developerId?: number; - name?: string; - rsa?: string; -} diff --git a/src/types/Model.ts b/src/types/Model.ts deleted file mode 100644 index bab3bdf..0000000 --- a/src/types/Model.ts +++ /dev/null @@ -1,60 +0,0 @@ -// 基本实体模型 -export type Model = { - id?: string; - - createdAt?: number; - updatedAt?: number; - deletedAt?: number; -} - -export type ApiResponse = { - code: number; - message: string; - messageCode: string; - success: boolean; - fail: boolean; - data: T; -} - -export type Page = { - orderMap?: { [K in keyof T]?: OrderType }; - equalsExample?: Partial; - likesExample?: Partial; -} & BasePage; - -export type BasePage = { - index: number; - size: number; -} - -export type OrderType = "ASC" | "DESC"; - -export type PageResult = { - total: number; - list: T[]; -} - -// 携带验证码的请求体 -export type CaptchaData = { - captchaId: string; - captcha: string; - data: T; -} - -// 图形验证码 -export type CaptchaResult = { - id: string; - data: string; -} - -export type ImageType = "IR-AUTO" | "IR-SMOOTH" | "IR-PIXELATED"; - -export type KeyValue = { - key: K; - value?: V; -} - -export type LabelValue = { - label: L; - value?: V; -} diff --git a/src/types/Notify.ts b/src/types/Notify.ts deleted file mode 100644 index fcbb55c..0000000 --- a/src/types/Notify.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { Model } from "./Model"; -import type { Attachment } from "./Attachment"; -import type { User } from "./User"; - -/** 通知消息类型 */ -export type NotifyMsgType = "INTERNAL" | "SMS" | "MAIL" | "HTTP" | "WECHAT"; - -/** 通知投递状态 */ -export type NotifyStatus = "WAITING" | "REMINDED" | "TIMEOUT" | "CANCEL" | "FAIL"; - -/** 通知内容类型 */ -export type NotifyContentType = "TEXT" | "TIP_TAP"; - -/** 通知 */ -export type Notify = { - /** 业务类型 */ - bizType?: string; - /** 提醒业务类型 */ - bizRemindedType?: string; - /** 提醒业务 ID */ - bizRemindedId?: string; - /** 参数 */ - args?: string; - /** 创建者 */ - createdBy?: string; - /** 通知详情列表 */ - detailList?: NotifyDetail[]; -} & Model; - -/** 通知详情 */ -export type NotifyDetail = { - /** 所属通知 ID */ - notifyId?: string; - /** 消息类型 */ - msgType?: NotifyMsgType; - /** 模板 ID */ - templateId?: string; - /** 标题 */ - subject?: string; - /** 内容 */ - content?: string; - /** 内容类型 */ - contentType?: NotifyContentType; - /** 媒体附件列表 */ - attachmentList?: Attachment[]; - /** 参数 */ - args?: string; - /** 发送来源 */ - sendFrom?: string; - /** 发送去向 */ - sendTo?: string; - /** 发送时间 */ - sendAt?: number; - /** 重试计数 */ - retry?: number; - /** 超时时间 */ - timeoutAt?: number; - /** 结果描述 */ - resultDesc?: string; - /** 投递状态 */ - status?: NotifyStatus; - /** 已读时间,站内通知未读时为空 */ - readAt?: number; - /** 所属通知 */ - notify?: Notify; - /** 发送用户 */ - fromUser?: User; - /** 接收用户 */ - toUser?: User; -} & Model; diff --git a/src/types/Permission.ts b/src/types/Permission.ts deleted file mode 100644 index 06de198..0000000 --- a/src/types/Permission.ts +++ /dev/null @@ -1,24 +0,0 @@ -export type ModuleCode = "CORE" | "GAO"; - -export interface Permission { - id: string; - moduleCode: ModuleCode; - code: string; - nameLangId?: string; - name?: string; - description?: string; - builtin?: boolean; - protectedPermission?: boolean; - ownerType?: AuthOwnerType; - ownerId?: string; - createdBy?: string; - createdAt?: number; - updatedAt?: number; - deletedAt?: number; -} - -export type AuthOwnerType = "SYSTEM" | "MODULE" | "TENANT" | "STORE" | "USER"; - -export type PermissionPayload = Pick & { - id?: string; -}; diff --git a/src/types/Role.ts b/src/types/Role.ts deleted file mode 100644 index 208c6cf..0000000 --- a/src/types/Role.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { AuthOwnerType, ModuleCode, Permission } from "./Permission"; -import { Model } from "./Model"; - -export type Role = { - moduleCode: ModuleCode; - code: string; - nameLangId?: string; - name?: string; - description?: string; - builtin?: boolean; - protectedRole?: boolean; - parentRoleId?: string; - ownerType?: AuthOwnerType; - ownerId?: string; - createdBy?: string; - - permissionList?: Permission[]; - allPermissionList?: Permission[]; - childRoleList?: Role[]; - parentRole?: Role; - delegationPermissionList?: Permission[]; - permissionIdList?: string[]; - childRoleIdList?: string[]; - delegationPermissionIdList?: string[]; -} & Model; - -export type RolePayload = Partial> & { - id?: string; - parentRoleId?: string; - ownerType?: AuthOwnerType; - ownerId?: string; - permissionIdList?: string[]; - childRoleIdList?: string[]; - delegationPermissionIdList?: string[]; -}; - -export interface AuthorizeUserRole { - userId: string; - moduleCode?: ModuleCode; - roleIdList?: string[]; -} diff --git a/src/types/Setting.ts b/src/types/Setting.ts deleted file mode 100644 index be65212..0000000 --- a/src/types/Setting.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {Model} from "./Model"; - -export type SettingValueType = - | "STRING" - | "SELECT_RADIO" - | "SELECT_CHECKBOX" - | "INTEGER" - | "COLOR" - | "FLOAT" - | "DATE" - | "DATETIME" - | "TIME" - | "DURATION" - | "BOOLEAN" - | "JSON_LIST" - | "JSON_OBJECT" -; - -export type Setting = { - module: string; - key: string; - title?: string; - description?: string; - value?: string; - valueType: SettingValueType; - valueArgs?: any; -} & Model; diff --git a/src/types/User.ts b/src/types/User.ts deleted file mode 100644 index 5ca87e6..0000000 --- a/src/types/User.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { ImageType, Model } from "./Model"; -import { Attachment } from "./Attachment"; -import type { Setting } from "./Setting"; -import { Role } from "./Role"; - -export type Gender = "MALE" | "FEMALE"; - -export type User = { - name: string; - nick?: string; - password?: string; - email?: string; - emailVerifyAt?: number; - phoneNo?: string; - phoneNoVerifyAt?: number; - wrapperType?: ImageType; - avatarType?: ImageType; - exp?: number; - gender?: Gender; - birthdate?: number; - qq?: string; - description?: string; - lastLoginIP?: string; - lastLoginAt?: number; - unmuteAt?: number; - unbanAt?: number; - - roleList?: string[]; - roleEntityList?: Role[]; - permissionList?: string[]; - attachmentList?: Attachment[]; - settingList?: Setting[]; - tempFileIdList?: string[]; - muting?: boolean; - banning?: boolean; - emailVerified?: boolean; - phoneNoVerified?: boolean; -} & Model; - -export type UserAttachType = "AVATAR" | "WRAPPER"; - -export type RegisterRequest = { - name: string; - password: string; - email?: string; -}; - -export type LoginRequest = { - user: string; - password: string; -}; - -export type SmsLoginRequest = { - detailId: string; - captcha: string; -}; - -export type UpdatePasswordRequest = { - oldValue: string; - newValue: string; -}; - -// 登录返回 -export type LoginResponse = { - id: string; - token: string; - expireAt: number; - user?: User; -}; - -export type LoginUser = { - id?: string; - token?: string; - expireAt?: number; - user?: User; -}; - -export type UserLevelType = { - exp: number; // 经验数值,和 UserData.exp 一样 - value: number; // 经验对应等级,[0, 8] - percent: number; // 该经验在该等级的百分比 [0, 1] - nextLevelUp: number; // 下一级经验值 -} diff --git a/src/types/index.ts b/src/types/index.ts index dcbf26a..ee4cd1a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,17 +1,5 @@ import type { Plugin } from "vue"; -export * from "./Model"; -export * from "./Article"; -export * from "./Setting"; -export * from "./Attachment"; -export * from "./Model"; -export * from "./Permission"; -export * from "./Role"; -export * from "./User"; -export * from "./Comment"; -export * from "./Developer"; -export * from "./Notify"; - /** * 安装方法 */ diff --git a/src/types/vue-global.d.ts b/src/types/vue-global.d.ts new file mode 100644 index 0000000..7363381 --- /dev/null +++ b/src/types/vue-global.d.ts @@ -0,0 +1,10 @@ +import type { Directive } from "vue"; +import type { PopupConfig } from "../utils/directives/Popup"; + +declare module "vue" { + export interface GlobalDirectives { + popup: Directive; + } +} + +export {}; diff --git a/src/utils/Network.ts b/src/utils/Network.ts deleted file mode 100644 index b3f79a6..0000000 --- a/src/utils/Network.ts +++ /dev/null @@ -1,119 +0,0 @@ -import axios, {type AxiosInstance, type AxiosResponse, type InternalAxiosRequestConfig} from "axios"; -import {ApiResponse} from "../types"; - -export type ErrorCallback = (response: ApiResponse) => void; -export type RequestInterceptor = (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig; -export interface ResponseObserverPayload { - config?: InternalAxiosRequestConfig; - status?: number; - response?: AxiosResponse; - error?: any; - serverResponse?: ApiResponse; -} -export type ResponseObserver = (payload: ResponseObserverPayload) => void; - -let globalErrorCallback: ErrorCallback | null = null; -let globalRequestInterceptor: RequestInterceptor | null = null; -let globalResponseObserver: ResponseObserver | null = null; - -export const setGlobalErrorCallback = (callback: ErrorCallback) => { - globalErrorCallback = callback; -}; - -export const setGlobalRequestInterceptor = (callback: RequestInterceptor | null) => { - globalRequestInterceptor = callback; -}; - -export const setGlobalResponseObserver = (callback: ResponseObserver | null) => { - globalResponseObserver = callback; -}; - -const notifyResponseObserver = (payload: ResponseObserverPayload) => { - if (!globalResponseObserver) { - return; - } - globalResponseObserver(payload); -}; - -const applyDefaultInterceptors = (instance: AxiosInstance) => { - instance.defaults.withCredentials = true; - instance.interceptors.request.use((config) => { - if (globalRequestInterceptor) { - return globalRequestInterceptor(config); - } - return config; - }); - instance.interceptors.response.use((axiosResp: any) => { - if (!axiosResp.config.responseType) { - // 服务端返回 - const serverResp = axiosResp.data as ApiResponse; - if (serverResp.code < 40000) { - notifyResponseObserver({ - config: axiosResp?.config, - status: axiosResp?.status, - response: axiosResp, - serverResponse: serverResp - }); - // 200 或 300 HTTP 状态段视为成功 - return serverResp.data; - } else { - notifyResponseObserver({ - config: axiosResp?.config, - status: axiosResp?.status, - response: axiosResp, - serverResponse: serverResp, - error: serverResp.message - }); - if (globalErrorCallback) { - globalErrorCallback(serverResp); - } - // 由调用方处理 - return Promise.reject(serverResp.message); - } - } - notifyResponseObserver({ - config: axiosResp?.config, - status: axiosResp?.status, - response: axiosResp - }); - return axiosResp.data; - }, (error: any) => { - notifyResponseObserver({ - config: error?.config, - status: error?.response?.status, - response: error?.response, - error - }); - // 请求错误 - if (error) { - if (error.response && error.response.status) { - throw error; - } - if (error.request) { - if (error.message && error.message.startsWith("timeout")) { - throw new Error("time out"); - } else { - throw new Error(error.message || "request error"); - } - } - } - throw error; - }); -}; - -const axiosInstance = axios.create(); -applyDefaultInterceptors(axiosInstance); - -export const createServiceAxios = (baseURL?: string) => { - const instance = axios.create({ baseURL }); - applyDefaultInterceptors(instance); - return instance; -}; - -export default { - axios: axiosInstance, - setGlobalErrorCallback, - setGlobalRequestInterceptor, - setGlobalResponseObserver, - createServiceAxios -}; diff --git a/src/utils/Popup.ts b/src/utils/Popup.ts new file mode 100644 index 0000000..2e6314c --- /dev/null +++ b/src/utils/Popup.ts @@ -0,0 +1,83 @@ +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; +} + +export const POPUP_ID = "tui-popup"; +export const POPUP_CONTENT_ID = "tui-popup-content"; + +export function getPopup() { + return document.getElementById(POPUP_ID); +} + +export function getPopupContent() { + return document.getElementById(POPUP_CONTENT_ID); +} + +export function clearPopup(popup: HTMLElement) { + popup.replaceChildren(); +} + +export function movePopup(popup: HTMLElement, x: number, y: number) { + const offsetX = 20; + const offsetY = 32; + popup.style.left = `${x + offsetX}px`; + popup.style.top = `${y + offsetY}px`; + + const popupRect = popup.getBoundingClientRect(); + if (window.innerWidth < popupRect.right + offsetX) { + popup.style.left = `${Math.max(0, window.innerWidth - popupRect.width - offsetX)}px`; + } + if (window.innerHeight < popupRect.bottom) { + popup.style.top = `${Math.max(0, y - popupRect.height - 6)}px`; + } +} + +export function showPopup(popup: HTMLElement) { + popup.style.visibility = "visible"; +} + +export function hidePopup(popup: HTMLElement) { + popup.style.visibility = "hidden"; + popup.style.left = "0px"; + popup.style.top = "0px"; +} + +export function appendPopupValue(popup: HTMLElement, type: PopupType, value: string | HTMLElement) { + let popupChild: HTMLElement; + switch (type) { + case PopupType.TEXT: + popupChild = document.createElement("div"); + popupChild.className = "text"; + popupChild.textContent = value as string; + popup.appendChild(popupChild); + break; + case PopupType.IMG: + popupChild = document.createElement("img"); + (popupChild as HTMLImageElement).src = value as string; + popup.appendChild(popupChild); + break; + case PopupType.HTML: + popup.appendChild(Toolkit.toDOM(value as string)); + break; + case PopupType.EL: + if (value instanceof HTMLElement) { + value.style.display = "block"; + popup.appendChild(value); + break; + } + throw new Error("Vue 指令错误:v-popup:el 的值不是 HTML 元素"); + } +} diff --git a/src/utils/SettingMapper.ts b/src/utils/SettingMapper.ts deleted file mode 100644 index e0c6900..0000000 --- a/src/utils/SettingMapper.ts +++ /dev/null @@ -1,105 +0,0 @@ -import {ref, Ref} from "vue"; -import {Setting} from "../types"; -import {CommonAPI} from "../api"; - -export default class SettingMapper { - - private static instance: SettingMapper; - - private moduleMap = new Map>>(); - - public static async loadSetting(map: Record): Promise { - const result = await CommonAPI.settingMap(map); - this.appendSettingMap(result); - } - - public static appendSettingMap(result: Map>): void { - for (const [module, settingMap] of result) { - for (const [key, setting] of settingMap) { - this.setValue(module, key, setting); - } - } - } - - public static createModule(module: string): Map> { - const instance = this.getInstance(); - const moduleVal = instance.moduleMap.get(module); - if (moduleVal) { - return moduleVal; - } - const created = new Map>(); - instance.moduleMap.set(module, created); - return created; - } - - public static getModule(module: string): Map> | undefined { - return this.getInstance().moduleMap.get(module); - } - - public static hasModule(module: string): boolean { - return this.getInstance().moduleMap.has(module); - } - - public static deleteModule(module: string): boolean { - return this.getInstance().moduleMap.delete(module); - } - - public static setModule(module: string, moduleVal: Map>): void { - this.getInstance().moduleMap.set(module, moduleVal); - } - - public static is(module: string, key: string): boolean { - const setting = this.getValueRef(module, key).value; - return !!setting.value && setting.value === "true"; - } - - public static not(module: string, key: string): boolean { - return !this.is(module, key); - } - - public static getValue(module: string, key: string): Setting { - return this.getValueRef(module, key).value; - } - - public static setValue(module: string, key: string, setting: Setting): void { - const moduleVal = this.createModule(module); - const settingRef = moduleVal.get(key); - if (settingRef) { - settingRef.value = setting; - return; - } - moduleVal.set(key, ref(setting)); - } - - public static setValueRef(module: string, key: string, settingRef: Ref): void { - const moduleVal = this.createModule(module); - moduleVal.set(key, settingRef); - } - - public static hasValue(module: string, key: string): boolean { - return !!this.getModule(module)?.has(key); - } - - public static deleteValue(module: string, key: string): boolean { - return !!this.getModule(module)?.delete(key); - } - - public static getValueRef(module: string, key: string): Ref { - const moduleVal = this.getModule(module); - if (!moduleVal) { - throw new Error(`Setting module not found: ${module}`); - } - const valueRef = moduleVal.get(key); - if (!valueRef) { - throw new Error(`Setting key not found: ${module}.${key}`); - } - return valueRef; - } - - public static getInstance(): SettingMapper { - if (SettingMapper.instance) { - return SettingMapper.instance; - } - return SettingMapper.instance = new SettingMapper(); - } -} diff --git a/src/utils/TDesignMessage.ts b/src/utils/TDesignMessage.ts new file mode 100644 index 0000000..f4ea0c0 --- /dev/null +++ b/src/utils/TDesignMessage.ts @@ -0,0 +1,28 @@ +import type { AppContext } from "vue"; + +type MessageType = "info" | "success" | "warning" | "error"; + +type MessageHandler = (content: string) => unknown | Promise; + +type MessageApi = { + [type in MessageType]?: MessageHandler; +}; + +export const showTDesignMessage = async ( + appContext: AppContext | undefined, + type: MessageType, + content: string +) => { + const handler = (appContext?.config.globalProperties.$message as MessageApi | undefined)?.[type]; + if (handler) { + await handler(content); + return; + } + if ("error" === type) { + console.error(content); + return; + } + console.warn(content); +}; + + diff --git a/src/utils/Time.ts b/src/utils/Time.ts index 29b8d11..9ea370d 100644 --- a/src/utils/Time.ts +++ b/src/utils/Time.ts @@ -56,6 +56,13 @@ export default class Time { return `${Text.pad(d.getHours())}:${Text.pad(d.getMinutes())}`; } + /** Unix 时间戳转带秒时间,兼容旧业务项目调用。 */ + public static toLongTime(unix?: number): string { + if (!unix) return ""; + const d = new Date(unix); + return `${Text.pad(d.getHours())}:${Text.pad(d.getMinutes())}:${Text.pad(d.getSeconds())}`; + } + public static toShortTime(unix?: number): string { if (!unix) return ""; const d = new Date(unix); @@ -72,6 +79,12 @@ export default class Time { return `${this.toDate(unix)} ${this.toTime(unix)}`; } + /** Unix 时间戳转带秒日期和时间,兼容旧业务项目调用。 */ + public static toLongDateTime(unix?: number): string { + if (!unix) return ""; + return `${this.toDate(unix)} ${this.toLongTime(unix)}`; + } + public static toPassedDate(unix?: number): string { return this.toPassedDateTime(unix, false); } diff --git a/src/utils/Toolkit.ts b/src/utils/Toolkit.ts index acca5a0..48d7165 100644 --- a/src/utils/Toolkit.ts +++ b/src/utils/Toolkit.ts @@ -1,5 +1,5 @@ import type { App } from "vue"; -import { InstallRecord, UserLevelType } from "../types"; +import type { InstallRecord } from "../types"; export default class Toolkit { @@ -301,27 +301,6 @@ export default class Toolkit { return debounced as ((...args: Parameters) => void) & { 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) { @@ -422,11 +401,22 @@ export default class Toolkit { }, interval); } - public static formatJson(jsonStr?: string): string { - if (!jsonStr) { + public static formatJson(jsonValue?: string | object): string { + if (jsonValue === undefined || jsonValue === null) { return ""; } - return JSON.stringify(JSON.parse(jsonStr), null, 4); + if (typeof jsonValue === "string") { + const value = jsonValue.trim(); + if (!value) { + return ""; + } + try { + return JSON.stringify(JSON.parse(value), null, 4); + } catch { + return value; + } + } + return JSON.stringify(jsonValue, null, 4); } public static downloadBlob(blob: Blob, fileName: string): void { diff --git a/src/utils/directives/Access.ts b/src/utils/directives/Access.ts deleted file mode 100644 index b4ea886..0000000 --- a/src/utils/directives/Access.ts +++ /dev/null @@ -1,226 +0,0 @@ -import type { Directive, DirectiveBinding, EffectScope } from "vue"; -import { effectScope, watch } from "vue"; -import { AccessMatchMode, useUserStore } from "../../store"; - -/** 访问控制类型 */ -export type AccessControlType = "permission" | "role"; -/** 指令结果生效方式 */ -export type AccessEffectMode = "if" | "show"; - -/** 自定义回调参数 */ -export type AccessDirectiveCallbackPayload = { - type: AccessControlType; - granted: boolean; - valueList: string[]; - mode: AccessMatchMode; - el: HTMLElement; -} - -/** - * 访问控制指令配置 - * `value` 支持单个值或值数组 - * 数组默认按 `or` 逻辑处理,需要 `and` 时显式传 `mode` - * `callback` 存在时优先执行回调,不再使用 `effect` - */ -export type AccessDirectiveConfig = { - value: string | string[]; - mode?: AccessMatchMode; - effect?: AccessEffectMode; - reverse?: boolean; - display?: string; - callback?: (payload: AccessDirectiveCallbackPayload) => void; -} - -type AccessDirectiveValue = string | string[] | AccessDirectiveConfig; - -type AccessDirectiveState = { - scope: EffectScope; - placeholder: Comment | null; - display: string; - config: AccessDirectiveConfig; - lastGranted?: boolean; -} - -type AccessMatcher = (store: ReturnType, valueList: string[], mode: AccessMatchMode) => boolean; - -const ACCESS_STATE_KEY = "__vAccessState"; - -/** 过滤空字符串并统一裁剪空白 */ -function normalizeValueList(value: string | string[]): string[] { - return (Array.isArray(value) ? value : [value]) - .map(item => item?.trim()) - .filter((item): item is string => !!item); -} - -/** 统一把指令值转成标准配置 */ -function normalizeConfig(value: AccessDirectiveValue): AccessDirectiveConfig { - if (typeof value === "string" || Array.isArray(value)) { - return { - value, - mode: "or", - effect: "if", - reverse: false - }; - } - - return { - value: value?.value || [], - mode: value?.mode || "or", - effect: value?.effect || "if", - reverse: !!value?.reverse, - display: value?.display, - callback: value?.callback - }; -} - -/** 恢复被 `if` 模式替换掉的真实元素 */ -function restoreElement(el: HTMLElement, state: AccessDirectiveState): void { - if (state.placeholder?.parentNode) { - state.placeholder.parentNode.replaceChild(el, state.placeholder); - } - state.placeholder = null; -} - -/** `if` 模式:无权限时直接移出 DOM */ -function applyIfEffect(el: HTMLElement, state: AccessDirectiveState, granted: boolean): void { - if (granted) { - restoreElement(el, state); - return; - } - - if (!state.placeholder && el.parentNode) { - state.placeholder = document.createComment("v-access"); - el.parentNode.replaceChild(state.placeholder, el); - } -} - -/** `show` 模式:保留 DOM,仅切换 display */ -function applyShowEffect(el: HTMLElement, state: AccessDirectiveState, granted: boolean): void { - restoreElement(el, state); - el.style.display = granted ? state.display : "none"; -} - -/** 自定义模式:交给调用方自己处理样式或行为 */ -function applyCustomCallback( - el: HTMLElement, - state: AccessDirectiveState, - granted: boolean, - type: AccessControlType, - valueList: string[], - mode: AccessMatchMode -): void { - restoreElement(el, state); - if (state.lastGranted === granted) { - return; - } - - state.config.callback?.({ - type, - granted, - valueList, - mode, - el - }); -} - -/** 根据当前登录用户状态刷新指令效果 */ -function updateAccess( - el: HTMLElement, - state: AccessDirectiveState, - type: AccessControlType, - matcher: AccessMatcher -): void { - const store = useUserStore(); - const config = state.config; - const mode = config.mode || "or"; - const valueList = normalizeValueList(config.value); - const granted = config.reverse ? !matcher(store, valueList, mode) : matcher(store, valueList, mode); - - if (config.callback) { - applyCustomCallback(el, state, granted, type, valueList, mode); - } else if (config.effect === "show") { - applyShowEffect(el, state, granted); - } else { - applyIfEffect(el, state, granted); - } - - state.lastGranted = granted; -} - -function getState(el: HTMLElement): AccessDirectiveState | undefined { - return (el as HTMLElement & { [ACCESS_STATE_KEY]?: AccessDirectiveState })[ACCESS_STATE_KEY]; -} - -function setState(el: HTMLElement, state: AccessDirectiveState): void { - (el as HTMLElement & { [ACCESS_STATE_KEY]?: AccessDirectiveState })[ACCESS_STATE_KEY] = state; -} - -function clearState(el: HTMLElement): void { - delete (el as HTMLElement & { [ACCESS_STATE_KEY]?: AccessDirectiveState })[ACCESS_STATE_KEY]; -} - -/** 卸载时清掉占位注释,避免遗留无用节点 */ -function clearPlaceholder(state: AccessDirectiveState): void { - if (state.placeholder?.parentNode) { - state.placeholder.parentNode.removeChild(state.placeholder); - } - state.placeholder = null; -} - -/** 创建角色/权限通用指令 */ -function createAccessDirective(type: AccessControlType, matcher: AccessMatcher): Directive { - return { - mounted(el: HTMLElement, binding: DirectiveBinding) { - const state: AccessDirectiveState = { - scope: effectScope(), - placeholder: null, - display: el.style.display, - config: normalizeConfig(binding.value) - }; - - setState(el, state); - - state.scope.run(() => { - const store = useUserStore(); - watch( - () => { - return type === "permission" ? store.getPermissionList().join("|") : store.getRoleList().join("|"); - }, - () => updateAccess(el, state, type, matcher), - { immediate: true } - ); - }); - }, - updated(el: HTMLElement, binding: DirectiveBinding) { - const state = getState(el); - if (!state) { - return; - } - - state.config = normalizeConfig(binding.value); - updateAccess(el, state, type, matcher); - }, - unmounted(el: HTMLElement) { - const state = getState(el); - if (!state) { - return; - } - - state.scope.stop(); - clearPlaceholder(state); - clearState(el); - } - }; -} - -/** 权限指令:`v-can` */ -export const VCan = createAccessDirective("permission", (store, valueList, mode) => { - return store.hasPermissions(valueList, mode); -}); - -/** 角色指令:`v-role` */ -export const VRole = createAccessDirective("role", (store, valueList, mode) => { - return store.hasRoles(valueList, mode); -}); - -export default VCan; diff --git a/src/utils/directives/Popup.ts b/src/utils/directives/Popup.ts index b8d6917..637571b 100644 --- a/src/utils/directives/Popup.ts +++ b/src/utils/directives/Popup.ts @@ -1,115 +1,142 @@ import type { Directive, DirectiveBinding } from "vue"; -import Toolkit from "../Toolkit"; - -export type PopupType = "TEXT" | "IMG" | "HTML" | "ELEMENT"; - -/** */ -export type PopupConfig = { - - type: PopupType, - value?: string | HTMLElement; - canShow?: () => boolean; - beforeShow?: (type: PopupType, value: string | HTMLElement) => Promise; - afterHidden?: (type: PopupType, value: string | HTMLElement) => Promise; -} +import { appendPopupValue, clearPopup, getPopup, getPopupContent, hidePopup, movePopup, PopupType, showPopup, type PopupConfig } from "../Popup"; // Popup 弹出提示 DOM 节点,全局唯一 let popup: HTMLElement | null; +let popupContent: HTMLElement | null; -const VPopup: Directive = { +type PopupBindingValue = string | PopupConfig; - mounted(el: HTMLElement, binding: DirectiveBinding) { - // 转配置 - let config: PopupConfig; - if (binding.arg && binding.arg === "config") { - config = binding.value as PopupConfig; - } else { - config = { - type: "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); +type PopupState = { + config: PopupConfig; + isShowing: boolean; + onMouseEnter: (e: MouseEvent) => Promise; + onMouseMove: (e: MouseEvent) => void; + onMouseLeave: () => Promise; +}; + +const popupStates = new WeakMap(); + +function getPopupElements() { + if (!popup) { + popup = getPopup(); + } + if (!popupContent) { + popupContent = getPopupContent(); + } + return { + popup, + popupContent + }; +} + +function getConfig(binding: DirectiveBinding): PopupConfig { + if (binding.arg === "config") { + const config = binding.value as PopupConfig; + return { + ...config, + type: config.type ?? PopupType.TEXT, + canShow: config.canShow ?? (() => true) + }; + } + return { + type: PopupType.TEXT, + value: binding.value as string, + canShow: () => true + }; +} + +function refreshPopupContent(config: PopupConfig) { + const popupElements = getPopupElements(); + if (!config.value || !popupElements.popupContent) { + return false; + } + clearPopup(popupElements.popupContent); + appendPopupValue(popupElements.popupContent, config.type, config.value); + return true; +} + +function hidePopupContent(state: PopupState) { + const popupElements = getPopupElements(); + state.isShowing = false; + if (popupElements.popup) { + hidePopup(popupElements.popup); + } + // 内容和定位分离,避免直接清空根节点破坏 teleport + if (popupElements.popupContent) { + clearPopup(popupElements.popupContent); + } +} + +const VPopup: Directive = { + + mounted(el, binding) { + const state: PopupState = { + config: getConfig(binding), + isShowing: false, + onMouseEnter: async e => { + const popupElements = getPopupElements(); + let config = state.config; + if (!config.value) { + return; } - switch (config.type) { - case "TEXT": - // 文本 - el = document.createElement("div"); - el.className = "text"; - el.textContent = config.value as string; - popup.appendChild(el); - break; - case "IMG": - // 图片 - el = document.createElement("img"); - (el as HTMLImageElement).src = config.value as string; - popup.appendChild(el); - break; - case "HTML": - // HTML 字符串 - popup.appendChild(Toolkit.toDOM(config.value as string)); - break; - case "ELEMENT": - // 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 元素"); - } + if (config.beforeShow) { + await config.beforeShow(config.type, config.value); } - 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"; - - // 隐藏后事件 + config = state.config; + if (config.canShow?.() && popupElements.popup && refreshPopupContent(config)) { + movePopup(popupElements.popup, e.x, e.y); + showPopup(popupElements.popup); + state.isShowing = true; + } + }, + onMouseMove: e => { + const popupElements = getPopupElements(); + if (state.config.canShow?.() && state.isShowing && popupElements.popup) { + movePopup(popupElements.popup, e.x, e.y); + } + }, + onMouseLeave: async () => { + const config = state.config; + hidePopupContent(state); if (config.afterHidden && config.value) { await config.afterHidden(config.type, config.value); } } - }, false); + }; + popupStates.set(el, state); + el.addEventListener("mouseenter", state.onMouseEnter, false); + el.addEventListener("mousemove", state.onMouseMove, false); + el.addEventListener("mouseleave", state.onMouseLeave, false); + }, + + updated(el, binding) { + const state = popupStates.get(el); + if (!state) { + return; + } + state.config = getConfig(binding); + if (!state.isShowing) { + return; + } + if (!state.config.canShow?.() || !refreshPopupContent(state.config)) { + hidePopupContent(state); + } + }, + + unmounted(el) { + const state = popupStates.get(el); + if (!state) { + return; + } + el.removeEventListener("mouseenter", state.onMouseEnter, false); + el.removeEventListener("mousemove", state.onMouseMove, false); + el.removeEventListener("mouseleave", state.onMouseLeave, false); + hidePopupContent(state); + popupStates.delete(el); } }; export default VPopup; +export { PopupType }; +export type { PopupConfig }; diff --git a/src/utils/index.ts b/src/utils/index.ts index 7576e1c..e11f3c4 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,7 +2,6 @@ export { default as Cooker } from "./Cooker"; export { default as Events } from "./Events"; export { default as IOSize } from "./IOSize"; export { default as Markdown } from "./Markdown"; -export { default as Network } from "./Network"; export { default as Prismjs } from "./Prismjs"; export { default as Resizer } from "./Resizer"; export { default as Scroller } from "./Scroller"; @@ -11,15 +10,12 @@ export { default as Text } from "./Text"; export { default as Time } from "./Time"; export { default as Toolkit } from "./Toolkit"; export { default as IconMapper } from "./IconMapper"; -export { default as SettingMapper } from "./SettingMapper"; +export { showTDesignMessage } from "./TDesignMessage"; -export { default as VCan } from "./directives/Access"; export { default as VDraggable } from "./directives/Draggable"; export { default as VPopup } from "./directives/Popup"; export * from "./MethodLocker"; -export * from "./Network"; export * from "./Prismjs"; -export * from "./directives/Access"; export * from "./directives/Draggable"; export * from "./directives/Popup";