diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..df8324e --- /dev/null +++ b/src/App.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/common/AppLoading.vue b/src/components/common/AppLoading.vue new file mode 100644 index 0000000..0eb1176 --- /dev/null +++ b/src/components/common/AppLoading.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/src/components/common/CoiDialog.vue b/src/components/common/CoiDialog.vue new file mode 100644 index 0000000..1249eb3 --- /dev/null +++ b/src/components/common/CoiDialog.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/src/components/common/CoiEmpty.vue b/src/components/common/CoiEmpty.vue new file mode 100644 index 0000000..ec134d5 --- /dev/null +++ b/src/components/common/CoiEmpty.vue @@ -0,0 +1,505 @@ + + + + + diff --git a/src/components/common/CoiIcon.vue b/src/components/common/CoiIcon.vue new file mode 100644 index 0000000..f317d07 --- /dev/null +++ b/src/components/common/CoiIcon.vue @@ -0,0 +1,46 @@ + + + diff --git a/src/components/common/CoiImageViewer.vue b/src/components/common/CoiImageViewer.vue new file mode 100644 index 0000000..9d3074a --- /dev/null +++ b/src/components/common/CoiImageViewer.vue @@ -0,0 +1,395 @@ + + + + + diff --git a/src/components/common/CoiPagination.vue b/src/components/common/CoiPagination.vue new file mode 100644 index 0000000..53d3a1a --- /dev/null +++ b/src/components/common/CoiPagination.vue @@ -0,0 +1,474 @@ + + + + + diff --git a/src/components/common/CommonWrapper.vue b/src/components/common/CommonWrapper.vue new file mode 100644 index 0000000..3e28ec5 --- /dev/null +++ b/src/components/common/CommonWrapper.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/src/components/common/DarkModeSwitch.vue b/src/components/common/DarkModeSwitch.vue new file mode 100644 index 0000000..07ba43b --- /dev/null +++ b/src/components/common/DarkModeSwitch.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/src/components/common/DictTag.vue b/src/components/common/DictTag.vue new file mode 100644 index 0000000..7df1ae5 --- /dev/null +++ b/src/components/common/DictTag.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/src/components/common/ErrorTip.vue b/src/components/common/ErrorTip.vue new file mode 100644 index 0000000..ace0736 --- /dev/null +++ b/src/components/common/ErrorTip.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/src/components/common/HelpInfo.vue b/src/components/common/HelpInfo.vue new file mode 100644 index 0000000..eb05c84 --- /dev/null +++ b/src/components/common/HelpInfo.vue @@ -0,0 +1,16 @@ + + + diff --git a/src/components/common/IconSelect.vue b/src/components/common/IconSelect.vue new file mode 100644 index 0000000..b3279b8 --- /dev/null +++ b/src/components/common/IconSelect.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/src/components/common/LangsSwitch.vue b/src/components/common/LangsSwitch.vue new file mode 100644 index 0000000..ca086f9 --- /dev/null +++ b/src/components/common/LangsSwitch.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/src/components/common/NaiveProvider.vue b/src/components/common/NaiveProvider.vue new file mode 100644 index 0000000..752df39 --- /dev/null +++ b/src/components/common/NaiveProvider.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/src/components/common/UserCenter.vue b/src/components/common/UserCenter.vue new file mode 100644 index 0000000..1f5bfdd --- /dev/null +++ b/src/components/common/UserCenter.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/src/constants/Regex.ts b/src/constants/Regex.ts new file mode 100644 index 0000000..d675af7 --- /dev/null +++ b/src/constants/Regex.ts @@ -0,0 +1,12 @@ +/** + * @description Some common rules + * @link https://any-rule.vercel.app/ + */ + +export enum Regex { + Url = '^(((ht|f)tps?):\\\/\\\/)?([^!@#$%^&*?.\\s-]([^!@#$%^&*?.\\s]{0,63}[^!@#$%^&*?.\\s])?\\.)+[a-z]{2,6}\\\/?', + + Email = '^(([^<>()[\\]\\\\.,;:\\s@"]+(\\.[^<>()[\\]\\\\.,;:\\s@"]+)*)|(".+"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$', + + RouteName = '^[\\w_!@#$%^&*~-]+$', +} diff --git a/src/constants/User.ts b/src/constants/User.ts new file mode 100644 index 0000000..a8104ea --- /dev/null +++ b/src/constants/User.ts @@ -0,0 +1,5 @@ +/** Gender */ +export enum Gender { + male, + female, +} diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..a2b4ead --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,2 @@ +export * from './Regex' +export * from './User' diff --git a/src/constants/permissions.ts b/src/constants/permissions.ts new file mode 100644 index 0000000..30d1cd7 --- /dev/null +++ b/src/constants/permissions.ts @@ -0,0 +1,138 @@ +/** + * 权限常量映射 + * 根据后端接口权限标识定义前端权限常量 + */ + +// 系统管理模块权限 +export const PERMISSIONS = { + // 用户管理权限 + USER: { + LIST: 'system:user:list', + ADD: 'system:user:add', + UPDATE: 'system:user:update', + DELETE: 'system:user:delete', + RESET_PWD: 'system:user:resetPwd', + EXPORT: 'system:user:export', + IMPORT: 'system:user:import', + ROLE: 'system:user:role', // 分配角色权限 + }, + + // 角色管理权限 + ROLE: { + LIST: 'system:role:list', + ADD: 'system:role:add', + UPDATE: 'system:role:update', + DELETE: 'system:role:delete', + MENU: 'system:role:menu', // 分配菜单权限 + }, + + // 菜单管理权限 + MENU: { + LIST: 'system:menu:list', + ADD: 'system:menu:add', + UPDATE: 'system:menu:update', + DELETE: 'system:menu:delete', + }, + + // 登录日志权限 + LOGIN_LOG: { + LIST: 'system:loginlog:list', + ADD: 'system:loginlog:add', + UPDATE: 'system:loginlog:update', + DELETE: 'system:loginlog:delete', + }, + + // 操作日志权限 + OPER_LOG: { + SEARCH: 'system:operlog:search', + DELETE: 'system:operlog:delete', + }, + + // 文件管理权限 + FILE: { + LIST: 'system:file:list', + ADD: 'system:file:add', + UPDATE: 'system:file:update', + DELETE: 'system:file:delete', + UPLOAD: 'system:file:upload', + }, + + // 图库管理权限 + PICTURE: { + LIST: 'system:picture:list', + ADD: 'system:sysPicture:add', + UPDATE: 'system:sysPicture:update', + DELETE: 'system:sysPicture:delete', + }, + + // 在线用户监控权限 + ONLINE: { + LIST: 'monitor:online:list', + LOGOUT: 'monitor:online:logout', + }, +} as const + +// 权限类型推断 +export type PermissionType = typeof PERMISSIONS[keyof typeof PERMISSIONS][keyof typeof PERMISSIONS[keyof typeof PERMISSIONS]] + +// 常用权限组合 +export const PERMISSION_GROUPS = { + // 用户管理相关权限 + USER_MANAGEMENT: [ + PERMISSIONS.USER.LIST, + PERMISSIONS.USER.ADD, + PERMISSIONS.USER.UPDATE, + PERMISSIONS.USER.DELETE, + ], + + // 角色管理相关权限 + ROLE_MANAGEMENT: [ + PERMISSIONS.ROLE.LIST, + PERMISSIONS.ROLE.ADD, + PERMISSIONS.ROLE.UPDATE, + PERMISSIONS.ROLE.DELETE, + ], + + // 菜单管理相关权限 + MENU_MANAGEMENT: [ + PERMISSIONS.MENU.LIST, + PERMISSIONS.MENU.ADD, + PERMISSIONS.MENU.UPDATE, + PERMISSIONS.MENU.DELETE, + ], + + // 文件管理相关权限 + FILE_MANAGEMENT: [ + PERMISSIONS.FILE.LIST, + PERMISSIONS.FILE.ADD, + PERMISSIONS.FILE.UPDATE, + PERMISSIONS.FILE.DELETE, + PERMISSIONS.FILE.UPLOAD, + ], + + // 图库管理相关权限 + PICTURE_MANAGEMENT: [ + PERMISSIONS.PICTURE.LIST, + PERMISSIONS.PICTURE.ADD, + PERMISSIONS.PICTURE.UPDATE, + PERMISSIONS.PICTURE.DELETE, + ], + + // 在线用户监控相关权限 + ONLINE_MANAGEMENT: [ + PERMISSIONS.ONLINE.LIST, + PERMISSIONS.ONLINE.LOGOUT, + ], + + // 系统管理员权限(包含所有权限) + SYSTEM_ADMIN: [ + ...Object.values(PERMISSIONS.USER), + ...Object.values(PERMISSIONS.ROLE), + ...Object.values(PERMISSIONS.MENU), + ...Object.values(PERMISSIONS.LOGIN_LOG), + ...Object.values(PERMISSIONS.OPER_LOG), + ...Object.values(PERMISSIONS.FILE), + ...Object.values(PERMISSIONS.PICTURE), + ...Object.values(PERMISSIONS.ONLINE), + ], +} as const diff --git a/src/directives/copy.ts b/src/directives/copy.ts new file mode 100644 index 0000000..783435b --- /dev/null +++ b/src/directives/copy.ts @@ -0,0 +1,50 @@ +import type { App, Directive } from 'vue' +import { $t } from '@/utils' +import { coiMsgError, coiMsgSuccess } from '@/utils/coi' + +interface CopyHTMLElement extends HTMLElement { + _copyText: string +} + +export function install(app: App) { + const { isSupported, copy } = useClipboard() + const permissionWrite = usePermission('clipboard-write') + + function clipboardEnable() { + if (!isSupported.value) { + coiMsgError($t('components.copyText.unsupportedError')) + return false + } + + if (permissionWrite.value === 'denied') { + coiMsgError($t('components.copyText.unpermittedError')) + return false + } + return true + } + + function copyHandler(this: any) { + if (!clipboardEnable()) + return + copy(this._copyText) + coiMsgSuccess($t('components.copyText.message')) + } + + function updataClipboard(el: CopyHTMLElement, text: string) { + el._copyText = text + el.addEventListener('click', copyHandler) + } + + const copyDirective: Directive = { + mounted(el, binding) { + updataClipboard(el, binding.value) + }, + updated(el, binding) { + updataClipboard(el, binding.value) + }, + unmounted(el) { + el.removeEventListener('click', copyHandler) + }, + } + app.directive('copy', copyDirective) +} diff --git a/src/directives/permission.ts b/src/directives/permission.ts new file mode 100644 index 0000000..08b7309 --- /dev/null +++ b/src/directives/permission.ts @@ -0,0 +1,89 @@ +import type { App, Directive } from 'vue' +import { usePermission } from '@/hooks' + +export function install(app: App) { + // 角色权限指令 + function updateRolePermission(el: HTMLElement, permission: Entity.RoleType | Entity.RoleType[]) { + if (!permission) + throw new Error('v-role Directive with no explicit role attached') + + // 每次检查时重新获取权限函数,确保使用最新的用户状态 + const { hasRole } = usePermission() + + // 使用显示/隐藏而不是删除元素 + if (hasRole(permission)) { + el.style.display = '' + } + else { + el.style.display = 'none' + } + } + + // 按钮权限指令 + function updateButtonPermission(el: HTMLElement, permission: string | string[]) { + if (!permission) + throw new Error('v-button Directive with no explicit permission attached') + + // 每次检查时重新获取权限函数,确保使用最新的用户状态 + const { hasButton } = usePermission() + + // 使用显示/隐藏而不是删除元素 + if (hasButton(permission)) { + el.style.display = '' + } + else { + el.style.display = 'none' + } + } + + // 通用权限指令(向后兼容) + function updatePermission(el: HTMLElement, permission: Entity.RoleType | Entity.RoleType[]) { + if (!permission) + throw new Error('v-permission Directive with no explicit role attached') + + // 每次检查时重新获取权限函数,确保使用最新的用户状态 + const { hasPermission } = usePermission() + + // 使用显示/隐藏而不是删除元素 + if (hasPermission(permission)) { + el.style.display = '' + } + else { + el.style.display = 'none' + } + } + + // 角色权限指令 + const roleDirective: Directive = { + mounted(el, binding) { + updateRolePermission(el, binding.value) + }, + updated(el, binding) { + updateRolePermission(el, binding.value) + }, + } + + // 按钮权限指令 + const buttonDirective: Directive = { + mounted(el, binding) { + updateButtonPermission(el, binding.value) + }, + updated(el, binding) { + updateButtonPermission(el, binding.value) + }, + } + + // 通用权限指令(向后兼容) + const permissionDirective: Directive = { + mounted(el, binding) { + updatePermission(el, binding.value) + }, + updated(el, binding) { + updatePermission(el, binding.value) + }, + } + + app.directive('permission', permissionDirective) + app.directive('role', roleDirective) + app.directive('button', buttonDirective) +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..efec91d --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './useBoolean' +export * from './usePermission' +export * from './useDict' diff --git a/src/hooks/useBoolean.ts b/src/hooks/useBoolean.ts new file mode 100644 index 0000000..6e2bdd6 --- /dev/null +++ b/src/hooks/useBoolean.ts @@ -0,0 +1,28 @@ +/** + * boolean组合式函数 + * @param initValue 初始值 + */ +export function useBoolean(initValue = false) { + const bool = ref(initValue) + + function setBool(value: boolean) { + bool.value = value + } + function setTrue() { + setBool(true) + } + function setFalse() { + setBool(false) + } + function toggle() { + setBool(!bool.value) + } + + return { + bool, + setBool, + setTrue, + setFalse, + toggle, + } +} diff --git a/src/hooks/useDict.ts b/src/hooks/useDict.ts new file mode 100644 index 0000000..2a39d2e --- /dev/null +++ b/src/hooks/useDict.ts @@ -0,0 +1,75 @@ +import type { MaybeRef } from 'vue' +import { computed, unref, watch } from 'vue' +import { toSelectOptions } from '@/utils/dict' +import { useDictStore } from '@/store' +import type { DictDataOption } from '@/service/api/system/dict' + +interface UseDictOptions { + /** 是否在创建时立即加载,默认为 true */ + immediate?: boolean + /** 监听类型变化时是否强制刷新 */ + force?: boolean +} + +export function useDict(dictTypes: MaybeRef, options: UseDictOptions = {}) { + const dictStore = useDictStore() + + const normalizedTypes = computed(() => { + const value = unref(dictTypes) ?? [] + return value.filter((item): item is string => Boolean(item)) + }) + + const load = async (force = false) => { + const types = normalizedTypes.value + if (!types.length) + return + + await dictStore.fetchDicts(types, force) + } + + watch( + normalizedTypes, + (types) => { + if (!types.length) + return + void dictStore.fetchDicts(types, options.force ?? false) + }, + { immediate: options.immediate ?? true }, + ) + + const dictOptions = computed>(() => { + const result: Record = {} + normalizedTypes.value.forEach((type) => { + result[type] = dictStore.getDictOptions(type) + }) + return result + }) + + const isLoading = computed(() => normalizedTypes.value.some(type => dictStore.isLoading(type))) + + const getDictLabel = (dictType: string, value: unknown, fallback?: string) => + dictStore.getDictLabel(dictType, value, fallback) + + const getDictOption = (dictType: string, value: unknown) => + dictStore.getDictOption(dictType, value) + + const getSelectOptions = (dictType: string) => toSelectOptions(dictStore.getDictOptions(dictType)) + + const reload = async (targetTypes?: string[]) => { + const types = targetTypes && targetTypes.length ? targetTypes : normalizedTypes.value + if (!types.length) + return + + await dictStore.fetchDicts(types, true) + } + + return { + dictOptions, + isLoading, + load, + reload, + getDictLabel, + getDictOption, + getSelectOptions, + } +} diff --git a/src/hooks/usePermission.ts b/src/hooks/usePermission.ts new file mode 100644 index 0000000..6a9a4ee --- /dev/null +++ b/src/hooks/usePermission.ts @@ -0,0 +1,102 @@ +import { useAuthStore } from '@/store' +import { isArray, isString } from 'radash' + +/** 权限判断 */ +export function usePermission() { + const authStore = useAuthStore() + + /** + * 检查是否为超级管理员 + */ + function isSuperAdmin() { + if (!authStore.userInfo) + return false + + const { role } = authStore.userInfo + + // 支持多种超级管理员标识 + const superAdminIdentifiers = ['super', 'admin', '超级管理员', 'ADMIN', 'coder_ADMIN'] + return superAdminIdentifiers.some(identifier => role.includes(identifier)) + } + + /** + * 检查角色权限 + * @param permission 角色类型或角色类型数组 + */ + function hasRole( + permission?: Entity.RoleType | Entity.RoleType[], + ) { + if (!permission) + return true + + if (!authStore.userInfo) + return false + const { role } = authStore.userInfo + + // 超级管理员可直接通过 + if (isSuperAdmin()) + return true + + let has = false + if (isArray(permission)) + // 角色为数组, 判断是否有交集 + has = permission.some(i => role.includes(i)) + + if (isString(permission)) + // 角色为字符串, 判断是否包含 + has = role.includes(permission) + + return has + } + + /** + * 检查按钮权限 + * @param permission 权限标识或权限标识数组 + */ + function hasButton( + permission?: string | string[], + ) { + if (!permission) + return true + + if (!authStore.userInfo) + return false + + // 超级管理员拥有所有权限 + if (isSuperAdmin()) + return true + + const { buttons } = authStore.userInfo + + // 检查具体权限标识 + if (!buttons || buttons.length === 0) + return false + + if (isArray(permission)) + // 权限为数组, 判断是否有交集 + return permission.some(i => buttons.includes(i)) + + if (isString(permission)) + // 权限为字符串, 判断是否包含 + return buttons.includes(permission) + + return false + } + + /** + * 通用权限检查 (向后兼容) + * @param permission 角色类型或角色类型数组 + */ + function hasPermission( + permission?: Entity.RoleType | Entity.RoleType[], + ) { + return hasRole(permission) + } + + return { + hasPermission, + hasRole, + hasButton, + isSuperAdmin, + } +} diff --git a/src/hooks/useTabScroll.ts b/src/hooks/useTabScroll.ts new file mode 100644 index 0000000..f40e364 --- /dev/null +++ b/src/hooks/useTabScroll.ts @@ -0,0 +1,66 @@ +import type { NScrollbar } from 'naive-ui' +import { ref, watchEffect } from 'vue' +import type { Ref } from 'vue' +import { throttle } from 'radash' + +export function useTabScroll(currentTabPath: Ref) { + const scrollbar = ref>() + const safeArea = ref(150) + + const handleTabSwitch = (distance: number) => { + scrollbar.value?.scrollTo({ + left: distance, + behavior: 'smooth', + }) + } + + const scrollToCurrentTab = () => { + nextTick(() => { + const currentTabElement = document.querySelector(`[data-tab-path="${currentTabPath.value}"]`) as HTMLElement + const tabBarScrollWrapper = document.querySelector('.tab-bar-scroller-wrapper .n-scrollbar-container') + const tabBarScrollContent = document.querySelector('.tab-bar-scroller-content') + + if (currentTabElement && tabBarScrollContent && tabBarScrollWrapper) { + const tabLeft = currentTabElement.offsetLeft + const tabBarLeft = tabBarScrollWrapper.scrollLeft + const wrapperWidth = tabBarScrollWrapper.getBoundingClientRect().width + const tabWidth = currentTabElement.getBoundingClientRect().width + const containerPR = Number.parseFloat(window.getComputedStyle(tabBarScrollContent).paddingRight) + + if (tabLeft + tabWidth + safeArea.value + containerPR > wrapperWidth + tabBarLeft) { + handleTabSwitch(tabLeft + tabWidth + containerPR - wrapperWidth + safeArea.value) + } + else if (tabLeft - safeArea.value < tabBarLeft) { + handleTabSwitch(tabLeft - safeArea.value) + } + } + }) + } + + const handleScroll = throttle({ interval: 120 }, (step: number) => { + scrollbar.value?.scrollBy({ + left: step * 400, + behavior: 'smooth', + }) + }) + + const onWheel = (e: WheelEvent) => { + e.preventDefault() + if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { + handleScroll(e.deltaY > 0 ? 1 : -1) + } + } + + watchEffect(() => { + if (currentTabPath.value) { + scrollToCurrentTab() + } + }) + + return { + scrollbar, + onWheel, + safeArea, + handleTabSwitch, + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..d4083cb --- /dev/null +++ b/src/main.ts @@ -0,0 +1,35 @@ +import type { App } from 'vue' +import { installRouter } from '@/router' +import { installPinia } from '@/store' +import AppVue from './App.vue' +import AppLoading from './components/common/AppLoading.vue' + +async function setupApp() { + // 载入全局loading加载状态 + const appLoading = createApp(AppLoading) + appLoading.mount('#appLoading') + + // 创建vue实例 + const app = createApp(AppVue) + + // 注册模块Pinia + await installPinia(app) + + // 注册模块 Vue-router + await installRouter(app) + + /* 注册模块 指令/静态资源 */ + Object.values( + import.meta.glob<{ install: (app: App) => void }>('./modules/*.ts', { + eager: true, + }), + ).map(i => app.use(i)) + + // 卸载载入动画 + appLoading.unmount() + + // 挂载 + app.mount('#app') +} + +setupApp() diff --git a/src/typings/api/login.d.ts b/src/typings/api/login.d.ts new file mode 100644 index 0000000..bd3a55d --- /dev/null +++ b/src/typings/api/login.d.ts @@ -0,0 +1,33 @@ +/// + +namespace Api { + namespace Login { + /* 登录返回的用户字段, 该数据是根据用户表扩展而来, 部分字段可能需要覆盖,例如id */ + interface Info extends Entity.User { + /** 用户id */ + id: number + /** 用户角色类型 */ + role: Entity.RoleType[] + /** 用户权限按钮列表 */ + buttons: string[] + /** 访问token */ + accessToken: string + /** 访问token */ + refreshToken: string + } + + /* 获取登录用户信息接口返回的数据结构 */ + interface UserInfoResponse { + /** 登录用户基本信息 */ + loginUser: { + userId: number + userName: string + avatar?: string + } + /** 用户角色列表 */ + roles: string[] + /** 用户权限按钮列表 */ + buttons: string[] + } + } +} diff --git a/src/typings/entities/dict.d.ts b/src/typings/entities/dict.d.ts new file mode 100644 index 0000000..23d5709 --- /dev/null +++ b/src/typings/entities/dict.d.ts @@ -0,0 +1,13 @@ +/// + +/* 字典数据库表字段 */ +namespace Entity { + + interface Dict { + id?: number + isRoot?: 0 | 1 + code: string + label: string + value?: number + } +} diff --git a/src/typings/entities/message.d.ts b/src/typings/entities/message.d.ts new file mode 100644 index 0000000..bd5d0b5 --- /dev/null +++ b/src/typings/entities/message.d.ts @@ -0,0 +1,16 @@ +/// + +/* 角色数据库表字段 */ +namespace Entity { + interface Message { + id: number + type: 0 | 1 | 2 + title: string + icon: string + tagTitle?: string + tagType?: 'error' | 'info' | 'success' | 'warning' + description?: string + isRead?: boolean + date: string + } +} diff --git a/src/typings/entities/role.d.ts b/src/typings/entities/role.d.ts new file mode 100644 index 0000000..7d2223a --- /dev/null +++ b/src/typings/entities/role.d.ts @@ -0,0 +1,13 @@ +/// + +/* 角色数据库表字段 */ +namespace Entity { + type RoleType = 'super' | 'admin' | 'user' + + interface Role { + /** 用户id */ + id?: number + /** 用户名 */ + role?: RoleType + } +} diff --git a/src/typings/entities/user.d.ts b/src/typings/entities/user.d.ts new file mode 100644 index 0000000..a4705b4 --- /dev/null +++ b/src/typings/entities/user.d.ts @@ -0,0 +1,30 @@ +/// + +/** 用户数据库表字段 */ +namespace Entity { + interface User { + /** 用户id */ + id?: number + /** 用户id (后端字段) */ + userId?: number + /** 用户名 */ + userName?: string + /* 用户头像 */ + avatar?: string + /* 用户性别 */ + gender?: 0 | 1 + /* 用户邮箱 */ + email?: string + /* 用户昵称 */ + nickname?: string + /* 用户电话 */ + tel?: string + /** 用户角色类型 */ + role?: Entity.RoleType[] + /** 用户状态 */ + status?: 0 | 1 + /** 备注 */ + remark?: string + } + +} diff --git a/src/typings/env.d.ts b/src/typings/env.d.ts new file mode 100644 index 0000000..6694d16 --- /dev/null +++ b/src/typings/env.d.ts @@ -0,0 +1,42 @@ +/** + *后台服务的环境类型 + * - dev: 后台开发环境 + * - test: 后台测试环境 + * - prod: 后台生产环境 + */ +type ServiceEnvType = 'dev' | 'test' | 'prod' + +interface ImportMetaEnv { + /** 项目基本地址 */ + readonly VITE_BASE_URL: string + /** 项目标题 */ + readonly VITE_APP_NAME: string + /** 开启请求代理 */ + readonly VITE_HTTP_PROXY?: 'Y' | 'N' + /** 是否开启打包压缩 */ + readonly VITE_BUILD_COMPRESS?: 'Y' | 'N' + /** 压缩算法类型 */ + readonly VITE_COMPRESS_TYPE?: + | 'gzip' + | 'brotliCompress' + | 'deflate' + | 'deflateRaw' + /** 路由模式 */ + readonly VITE_ROUTE_MODE?: 'hash' | 'web' + /** 路由加载模式 */ + readonly VITE_ROUTE_LOAD_MODE: 'static' | 'dynamic' + /** 首次加载页面 */ + readonly VITE_HOME_PATH: string + /** 版权信息 */ + readonly VITE_COPYRIGHT_INFO: string + /** 是否自动刷新token */ + readonly VITE_AUTO_REFRESH_TOKEN: 'Y' | 'N' + /** 默认语言 */ + readonly VITE_DEFAULT_LANG: App.lang + /** 后端服务的环境类型 */ + readonly MODE: ServiceEnvType +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/src/typings/global.d.ts b/src/typings/global.d.ts new file mode 100644 index 0000000..d90956b --- /dev/null +++ b/src/typings/global.d.ts @@ -0,0 +1,56 @@ +/* 存放数据库实体表类型, 具体内容在 ./entities */ +declare namespace Entity { +} + +/* 各类接口返回的数据类型, 具体内容在 ./api */ +declare namespace Api { + +} + +interface Window { + $loadingBar: import('naive-ui').LoadingBarApi + $dialog: import('naive-ui').DialogApi + $message: import('naive-ui').MessageApi + $notification: import('naive-ui').NotificationApi +} + +declare const AMap: any +declare const BMap: any + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + + const component: DefineComponent + export default component +} + +declare namespace NaiveUI { + type ThemeColor = 'default' | 'error' | 'primary' | 'info' | 'success' | 'warning' +} + +declare namespace Storage { + interface Session { + dict: DictMap + } + + interface Local { + /* 存储用户信息 */ + userInfo: Api.Login.Info + /* 存储访问token */ + accessToken: string + /* 存储刷新token */ + refreshToken: string + /* 存储登录账号 */ + loginAccount: any + /* 存储当前语言 */ + lang: App.lang + } +} + +declare namespace App { + type lang = 'zhCN' | 'enUS' +} + +interface DictMap { + [key: string]: Entity.Dict[] +} diff --git a/src/typings/route.d.ts b/src/typings/route.d.ts new file mode 100644 index 0000000..f4a9ec5 --- /dev/null +++ b/src/typings/route.d.ts @@ -0,0 +1,105 @@ +declare namespace AppRoute { + + type MenuType = '1' | '2' | '3' // 1-目录 2-菜单 3-按钮 + + /** 单个路由所携带的meta标识 */ + interface RouteMeta { + /* 页面标题,通常必选。 */ + title: string + /* 图标,一般配合菜单使用 */ + icon?: string + /* 是否需要登录权限。 */ + requiresAuth?: boolean + /* 可以访问的角色 */ + roles?: Entity.RoleType[] + /* 权限标识,用于按钮权限验证 */ + auth?: string + /* 是否开启页面缓存 */ + keepAlive?: boolean + /* 有些路由我们并不想在菜单中显示,比如某些编辑页面。 */ + hide?: boolean + /* 菜单排序。 */ + order?: number + /* 嵌套外链 */ + href?: string + /** 当前路由不在左侧菜单显示,但需要高亮某个菜单的情况 */ + activeMenu?: string + /** 当前路由是否会被添加到Tab中 */ + withoutTab?: boolean + /** 当前路由是否会被固定在Tab中,用于一些常驻页面 */ + pinTab?: boolean + /** 当前路由在左侧菜单是目录还是页面,不设置默认为page */ + menuType?: MenuType + } + + type MetaKeys = keyof RouteMeta + + // 后端返回的菜单数据结构 + interface BackendRoute { + /** 菜单ID */ + menuId: number + /** 菜单名称 */ + menuName: string + /** 英文名称 */ + enName?: string + /** 父菜单ID */ + parentId: number + /** 菜单类型 1-目录 2-菜单 3-按钮 */ + menuType: string + /** 路由名称 */ + name: string + /** 路由路径 */ + path: string + /** 组件路径 */ + component?: string + /** 菜单图标 */ + icon?: string + /** 权限标识 */ + auth?: string + /** 是否隐藏 0-隐藏 1-显示 */ + isHide: string + /** 是否外链 */ + isLink?: string + /** 是否缓存 0-是 1-否 */ + isKeepAlive: string + /** 是否全屏 0-是 1-否 */ + isFull: string + /** 是否固定 0-是 1-否 */ + isAffix: string + /** 重定向地址 */ + redirect?: string | null + /** 选中路由 */ + activeMenu?: string | null + } + + interface baseRoute { + /** 路由名称(路由唯一标识) */ + name: string + /** 路由路径 */ + path: string + /** 路由重定向 */ + redirect?: string + /* 页面组件地址 */ + componentPath?: string | null + /* 路由id */ + id: number + /* 父级路由id,顶级页面为null */ + pid: number | null + } + + /** 单个路由的类型结构(动态路由模式:后端返回此类型结构的路由) */ + type RowRoute = RouteMeta & baseRoute + + /** + * 挂载到项目上的真实路由结构 + */ + interface Route extends baseRoute { + /** 子路由 */ + children?: Route[] + /* 页面组件 */ + component: any + /** 路由描述 */ + meta: RouteMeta + } + +} diff --git a/src/typings/router.d.ts b/src/typings/router.d.ts new file mode 100644 index 0000000..d89cf7a --- /dev/null +++ b/src/typings/router.d.ts @@ -0,0 +1,5 @@ +import 'vue-router' + +declare module 'vue-router' { + interface RouteMeta extends AppRoute.RouteMeta {} +} diff --git a/src/typings/service.d.ts b/src/typings/service.d.ts new file mode 100644 index 0000000..d13ce3e --- /dev/null +++ b/src/typings/service.d.ts @@ -0,0 +1,65 @@ +/** 请求的相关类型 */ +declare namespace Service { + import type { Method } from 'alova' + + interface AlovaConfig { + baseURL: string + timeout?: number + beforeRequest?: (method: Method>) => void + } + + /** 后端接口返回的数据结构配置 */ + interface BackendConfig { + /** 表示后端请求状态码的属性字段 */ + codeKey?: string + /** 表示后端请求数据的属性字段 */ + dataKey?: string + /** 表示后端消息的属性字段 */ + msgKey?: string + /** 后端业务上定义的成功请求的状态 */ + successCode?: number | string + } + + type RequestErrorType = 'Response Error' | 'Business Error' | null + type RequestCode = string | number + + interface RequestError { + /** 请求服务的错误类型 */ + errorType: RequestErrorType + /** 错误码 */ + code: RequestCode + /** 错误信息 */ + message: string + /** 返回的数据 */ + data?: any + } + + interface ResponseResult extends RequestError { + /** 请求服务是否成功 */ + isSuccess: boolean + /** 请求服务的错误类型 */ + errorType: RequestErrorType + /** 错误码 */ + code: RequestCode + /** 错误信息 */ + message: string + /** 返回的数据 */ + data: T + /** 错误信息别名 */ + msg?: string + } + + /** 分页结果类型 */ + interface PageResult { + /** 当前页数据 */ + records: T[] + /** 总记录数 */ + total: number + /** 当前页 */ + current: number + /** 每页大小 */ + size: number + /** 总页数 */ + pages: number + } +} diff --git a/src/utils/array.ts b/src/utils/array.ts new file mode 100644 index 0000000..1594cfc --- /dev/null +++ b/src/utils/array.ts @@ -0,0 +1,37 @@ +/** + * 将给定的数组转换为树形结构。 + * @param arr - 原始数组,其中每个元素包含id和pid属性,pid表示父级id。 + * @returns 返回转换后的树形结构数组。 + */ +export function arrayToTree(arr: any[]) { + // 初始化结果数组 + const res: any = [] + // 使用Map存储数组元素,以id为键,元素本身为值 + const map = new Map() + + // 遍历数组,将每个元素以id为键存储到Map中 + arr.forEach((item) => { + map.set(item.id, item) + }) + + // 再次遍历数组,根据pid将元素组织成树形结构 + arr.forEach((item) => { + // 获取当前元素的父级元素 + const parent = item.pid && map.get(item.pid) + // 如果有父级元素 + if (parent) { + // 如果父级元素已有子元素,则将当前元素追加到子元素数组中 + if (parent?.children) + parent.children.push(item) + // 如果父级元素没有子元素,则创建子元素数组,并将当前元素作为第一个元素 + else + parent.children = [item] + } + // 如果没有父级元素,则将当前元素直接添加到结果数组中 + else { + res.push(item) + } + }) + // 返回组织好的树形结构数组 + return res +} diff --git a/src/utils/coi.ts b/src/utils/coi.ts new file mode 100755 index 0000000..9875959 --- /dev/null +++ b/src/utils/coi.ts @@ -0,0 +1,395 @@ +// 工具类提示信息 +import { createDiscreteApi, darkTheme, lightTheme } from 'naive-ui' + +type MessageType = 'info' | 'success' | 'error' | 'warning' + +// 缓存 API 实例 +let naiveApiCache: any = null +let currentTheme: 'light' | 'dark' | null = null + +// 检测当前主题 +function isDark(): boolean { + return document.documentElement.classList.contains('dark') || document.documentElement.getAttribute('data-theme') === 'dark' +} + +// 获取当前主题 +function getCurrentTheme(): 'light' | 'dark' { + return isDark() ? 'dark' : 'light' +} + +// 创建或获取 Naive UI API 实例 +function getNaiveApi() { + const theme = getCurrentTheme() + + // 如果主题没有变化且已有缓存,直接返回 + if (naiveApiCache && currentTheme === theme) { + return naiveApiCache + } + + try { + // 创建完整的 API 实例,包括 message, notification, dialog + const api = createDiscreteApi(['message', 'notification', 'dialog'], { + configProviderProps: { + theme: theme === 'dark' ? darkTheme : lightTheme, + }, + }) + + naiveApiCache = api + currentTheme = theme + return api + } + catch (error) { + console.warn('Failed to create Naive UI API:', error) + // 返回 fallback 实现,避免应用崩溃 + return { + message: { + info: console.info, + success: console.log, + warning: console.warn, + error: console.error, + loading: console.log, + destroyAll: () => {}, + }, + notification: { + info: console.info, + success: console.log, + warning: console.warn, + error: console.error, + destroyAll: () => {}, + }, + dialog: { + info: (_options: any) => Promise.resolve(true), + success: (_options: any) => Promise.resolve(true), + warning: (_options: any) => Promise.resolve(true), + error: (_options: any) => Promise.resolve(true), + destroyAll: () => {}, + }, + } + } +} + +// 安全的 API 调用函数 +function safeApiCall(apiType: 'message' | 'notification' | 'dialog', method: string, ...args: any[]) { + // 延迟执行函数 + const executeCall = () => { + try { + const api = getNaiveApi() + return api[apiType][method](...args) + } + catch (error) { + console.warn(`Failed to call ${apiType}.${method}:`, error) + // fallback 到 console + if (apiType === 'message' || apiType === 'notification') { + const logMethod = method === 'success' ? 'log' : method + if (logMethod === 'info' || logMethod === 'log' || logMethod === 'warn' || logMethod === 'error') { + console[logMethod](args[0]) + } + } + return apiType === 'dialog' ? Promise.resolve(false) : null + } + } + + // 如果 document 还未准备好,延迟执行 + if (typeof document === 'undefined' || document.readyState === 'loading') { + if (apiType === 'dialog') { + return new Promise((resolve) => { + setTimeout(() => resolve(executeCall()), 100) + }) + } + setTimeout(executeCall, 100) + return null + } + + return executeCall() +} + +/** 封装任意提示类型通知,默认info */ +export function coiNotice(message: any, title = '温馨提示', duration = 2000, type: MessageType = 'info', parseHtml = false) { + const api = getNaiveApi() + api.notification.destroyAll() + + return safeApiCall('notification', type, { + title, + content: message, + duration, + closable: true, + // Naive UI 不支持 dangerouslyUseHTMLString,需要处理 HTML + ...(parseHtml + && { + // 如果需要解析HTML,可以考虑使用 render 函数或其他方式 + }), + }) +} + +/** 封装提示通知,默认success */ +export function coiNoticeSuccess( + message: any, + title = '温馨提示', + duration = 2000, + type: MessageType = 'success', + parseHtml = false, +) { + return coiNotice(message, title, duration, type, parseHtml) +} + +/** 封装提示通知,默认error */ +export function coiNoticeError( + message: any, + title = '温馨提示', + duration = 2000, + type: MessageType = 'error', + parseHtml = false, +) { + return coiNotice(message, title, duration, type, parseHtml) +} + +/** 封装提示通知,默认warning */ +export function coiNoticeWarning( + message: any, + title = '温馨提示', + duration = 2000, + type: MessageType = 'warning', + parseHtml = false, +) { + return coiNotice(message, title, duration, type, parseHtml) +} + +/** 封装提示通知,默认info */ +export function coiNoticeInfo(message: any, title = '温馨提示', duration = 2000, type: MessageType = 'info', parseHtml = false) { + return coiNotice(message, title, duration, type, parseHtml) +} + +/** 封装提示信息,默认info */ +export function coiMsg(message: any, _plain = false, duration = 2000, type: MessageType = 'info', _parseHtml = false) { + const api = getNaiveApi() + api.message.destroyAll() + + return safeApiCall('message', type, message, { + duration, + closable: true, + }) +} + +/** 封装提示信息,默认success */ +export function coiMsgSuccess(message: any, _plain = false, duration = 2000, _type: MessageType = 'success', _parseHtml = false) { + const api = getNaiveApi() + api.message.destroyAll() + + return safeApiCall('message', 'success', message, { + duration, + closable: true, + }) +} + +/** 封装提示信息,默认error */ +export function coiMsgError(message: any, _plain = false, duration = 2000, _type: MessageType = 'error', _parseHtml = false) { + const api = getNaiveApi() + api.message.destroyAll() + + return safeApiCall('message', 'error', message, { + duration, + closable: true, + }) +} + +/** 封装提示信息,默认warning */ +export function coiMsgWarning(message: any, _plain = false, duration = 2000, _type: MessageType = 'warning', _parseHtml = false) { + const api = getNaiveApi() + api.message.destroyAll() + + return safeApiCall('message', 'warning', message, { + duration, + closable: true, + }) +} + +/** 封装提示信息,默认info */ +export function coiMsgInfo(message: any, _plain = false, duration = 2000, _type: MessageType = 'info', _parseHtml = false) { + const api = getNaiveApi() + api.message.destroyAll() + + return safeApiCall('message', 'info', message, { + duration, + closable: true, + }) +} + +/** 封装确认信息,默认warning */ +export function coiMsgBox( + message: any = '您确定进行关闭么?', + title: string = '温馨提示:', + confirmButtonText: string = '确定', + cancelButtonText: string = '取消', + type: string = 'warning', +): Promise { + return new Promise((resolve, reject) => { + const executeDialog = () => { + try { + const api = getNaiveApi() + api.dialog[type as keyof typeof api.dialog]({ + title, + content: message, + positiveText: confirmButtonText, + negativeText: cancelButtonText, + onPositiveClick: () => { + resolve(true) + }, + onNegativeClick: () => { + reject(false) + }, + onClose: () => { + reject(false) + }, + }) + } + catch (error) { + console.warn('Failed to show dialog:', error) + reject(false) + } + } + + if (typeof document === 'undefined' || document.readyState === 'loading') { + setTimeout(executeDialog, 100) + } + else { + executeDialog() + } + }) +} + +/** 封装确认信息,默认warning - HTML 版本 */ +export function coiMsgBoxHtml( + message: any = `

您确定进行关闭么?

`, + title: string = '温馨提示:', + confirmButtonText: string = '确定', + cancelButtonText: string = '取消', + type: string = 'warning', +): Promise { + // Naive UI 的 dialog 可以通过 render 函数支持 HTML + // 这里先使用纯文本,如果需要 HTML 可以进一步优化 + const textMessage = message.replace(/<[^>]*>/g, '') // 简单去除HTML标签 + return coiMsgBox(textMessage, title, confirmButtonText, cancelButtonText, type) +} + +/** Prompt 类型的消息框 */ +export function coiMsgBoxPrompt( + message: any = '请输入需要修改的数据?', + _title: string = '温馨提示:', + _confirmButtonText: string = '确定', + _cancelButtonText: string = '取消', + _type: string = 'info', + _inputPattern: string = '', + _inputErrorMessage: string = '无效输入', +): Promise { + return new Promise((resolve, reject) => { + const executeDialog = () => { + try { + // Naive UI 没有直接的 prompt,需要使用自定义 dialog + // 这里先简化实现,返回一个输入的结果 + const userInput = prompt(message) // 使用原生 prompt 作为临时方案 + if (userInput !== null) { + resolve({ value: userInput }) + } + else { + reject(false) + } + } + catch (error) { + console.warn('Failed to show prompt dialog:', error) + reject(false) + } + } + + if (typeof document === 'undefined' || document.readyState === 'loading') { + setTimeout(executeDialog, 100) + } + else { + executeDialog() + } + }) +} + +/** Alert 类型的消息框 */ +export function coiMsgBoxAlert( + message: any = '请输入需要修改的数据?', + title: string = '温馨提示:', + confirmButtonText: string = '确定', + type: string = 'info', +): Promise { + return new Promise((resolve, reject) => { + const executeDialog = () => { + try { + const api = getNaiveApi() + api.dialog[type as keyof typeof api.dialog]({ + title, + content: message, + positiveText: confirmButtonText, + onPositiveClick: () => { + resolve(true) + }, + onClose: () => { + resolve(true) + }, + }) + } + catch (error) { + console.warn('Failed to show alert dialog:', error) + reject(false) + } + } + + if (typeof document === 'undefined' || document.readyState === 'loading') { + setTimeout(executeDialog, 100) + } + else { + executeDialog() + } + }) +} + +// 导出 naiveMessage 对象,保持向后兼容 +export const naiveMessage = { + info: (content: string, options?: any) => { + return safeApiCall('message', 'info', content, { + duration: 3000, + closable: true, + ...options, + }) + }, + success: (content: string, options?: any) => { + return safeApiCall('message', 'success', content, { + duration: 3000, + closable: true, + ...options, + }) + }, + warning: (content: string, options?: any) => { + return safeApiCall('message', 'warning', content, { + duration: 3000, + closable: true, + ...options, + }) + }, + error: (content: string, options?: any) => { + return safeApiCall('message', 'error', content, { + duration: 3000, + closable: true, + ...options, + }) + }, + loading: (content: string, options?: any) => { + return safeApiCall('message', 'loading', content, { + duration: 0, // loading 默认不自动关闭 + ...options, + }) + }, + destroyAll: () => { + try { + const api = getNaiveApi() + api.message.destroyAll() + } + catch (error) { + console.warn('Failed to destroy all messages:', error) + } + }, +} diff --git a/src/utils/component-guard.ts b/src/utils/component-guard.ts new file mode 100644 index 0000000..4eaccfa --- /dev/null +++ b/src/utils/component-guard.ts @@ -0,0 +1,109 @@ +import type { AsyncComponentLoader, Component } from 'vue' +import { defineAsyncComponent } from 'vue' + +/** + * 安全的异步组件加载器 + * 防止在组件卸载时继续加载导致的内存泄漏和错误 + */ +export function safeAsyncComponent( + loader: AsyncComponentLoader, + options?: { + loadingComponent?: Component + errorComponent?: Component + delay?: number + timeout?: number + suspensible?: boolean + onError?: (error: Error, retry: () => void, fail: () => void, attempts: number) => any + }, +) { + const safeLoader: AsyncComponentLoader = () => { + return loader().catch((error) => { + console.error('异步组件加载失败:', error) + + // 如果是网络错误或者加载错误,返回一个空的组件 + if (error.name === 'ChunkLoadError' || error.message?.includes('Loading chunk')) { + console.warn('检测到代码分割加载错误,尝试重新加载页面') + // 延迟重新加载页面,避免无限循环 + setTimeout(() => { + window.location.reload() + }, 1000) + } + + // 返回一个错误组件 + return Promise.resolve({ + template: '
组件加载失败
', + }) + }) + } + + return defineAsyncComponent({ + loader: safeLoader, + loadingComponent: options?.loadingComponent, + errorComponent: options?.errorComponent, + delay: options?.delay ?? 200, + timeout: options?.timeout ?? 30000, + suspensible: options?.suspensible ?? false, + onError: options?.onError || ((error, retry, fail, attempts) => { + console.error(`异步组件加载错误 (第${attempts}次尝试):`, error) + if (attempts <= 3) { + retry() + } + else { + fail() + } + }), + }) +} + +/** + * 创建路由组件的安全加载器 + */ +export function createSafeRouteComponent(componentPath: string) { + return safeAsyncComponent( + () => import(/* @vite-ignore */ `/src/views${componentPath}.vue`), + { + delay: 100, + timeout: 10000, + onError: (error, retry, fail, attempts) => { + console.error(`路由组件加载失败: ${componentPath}`, error) + + // 对于路由组件,最多重试2次 + if (attempts <= 2) { + console.warn(`重试加载组件: ${componentPath} (第${attempts}次)`) + retry() + } + else { + console.error(`组件加载最终失败: ${componentPath}`) + fail() + } + }, + }, + ) +} + +/** + * 清理组件缓存,用于解决热更新时的问题 + */ +export function clearComponentCache() { + // 在开发环境下清理模块缓存 + if (import.meta.hot) { + import.meta.hot.invalidate() + } +} + +/** + * 组件安全性检查 + */ +export function validateComponent(component: any): boolean { + if (!component) { + console.error('组件为空或未定义') + return false + } + + if (typeof component !== 'object' && typeof component !== 'function') { + console.error('组件类型不正确:', typeof component) + return false + } + + return true +} diff --git a/src/utils/dict.ts b/src/utils/dict.ts new file mode 100644 index 0000000..33f373e --- /dev/null +++ b/src/utils/dict.ts @@ -0,0 +1,65 @@ +import type { DictDataOption } from '@/service/api/system/dict' + +export type DictValue = string | number | boolean | null | undefined + +/** + * 将字典选项转换为 Naive UI Select 所需结构 + */ +export function toSelectOptions(dictOptions: DictDataOption[] = []) { + return dictOptions.map(option => ({ + label: option.dictLabel, + value: option.dictValue, + })) +} + +/** + * 通过字典值获取对应的完整选项对象 + */ +export function findDictOption(dictOptions: DictDataOption[] = [], value: DictValue) { + if (value === undefined || value === null) + return undefined + + const target = String(value) + return dictOptions.find(option => option.dictValue === target) +} + +/** + * 获取字典标签,找不到时返回默认值或原始值 + */ +export function findDictLabel(dictOptions: DictDataOption[] = [], value: DictValue, fallback?: string) { + const target = findDictOption(dictOptions, value) + if (target) + return target.dictLabel + + if (fallback !== undefined) + return fallback + + if (value === undefined || value === null || value === '') + return '' + + return String(value) +} + +/** + * 获取字典颜色配置 + */ +export function findDictColor(dictOptions: DictDataOption[] = [], value: DictValue) { + const target = findDictOption(dictOptions, value) + if (!target) + return undefined + + return { + tag: target.dictTag, + color: target.dictColor, + label: target.dictLabel, + } +} + +/** + * 将字典数组转为值 -> 选项的 Map,方便重复查询 + */ +export function createDictMap(dictOptions: DictDataOption[] = []) { + const map = new Map() + dictOptions.forEach(option => map.set(option.dictValue, option)) + return map +} diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts new file mode 100644 index 0000000..2768710 --- /dev/null +++ b/src/utils/i18n.ts @@ -0,0 +1,20 @@ +import type { NDateLocale, NLocale } from 'naive-ui' +import { i18n } from '@/modules/i18n' +import { dateZhCN, zhCN } from 'naive-ui' + +export function setLocale(locale: App.lang) { + i18n.global.locale.value = locale +} + +export const $t = i18n.global.t + +export const naiveI18nOptions: Record = { + zhCN: { + locale: zhCN, + dateLocale: dateZhCN, + }, + enUS: { + locale: null, + dateLocale: null, + }, +} diff --git a/src/utils/icon.ts b/src/utils/icon.ts new file mode 100644 index 0000000..cf376eb --- /dev/null +++ b/src/utils/icon.ts @@ -0,0 +1,15 @@ +import CoiIcon from '@/components/common/CoiIcon.vue' + +export function renderIcon(icon?: string, size?: number) { + if (!icon) + return + + return () => createIcon(icon, size) +} + +export function createIcon(icon?: string, size: number = 18) { + if (!icon) + return + + return h(CoiIcon, { icon, size }) +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..30b48af --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,5 @@ +export * from './storage' +export * from './array' +export * from './i18n' +export * from './icon' +export * from './normalize' diff --git a/src/utils/navigation-guard.ts b/src/utils/navigation-guard.ts new file mode 100644 index 0000000..e5978c4 --- /dev/null +++ b/src/utils/navigation-guard.ts @@ -0,0 +1,147 @@ +import type { RouteLocationNormalized, Router } from 'vue-router' + +/** + * 导航防护类,用于防止快速路由切换导致的问题 + */ +export class NavigationGuard { + private router: Router + private isNavigating = false + private pendingNavigation: string | null = null + private navigationTimer: NodeJS.Timeout | null = null + private readonly NAVIGATION_DEBOUNCE = 100 // 100ms防抖 + private lastNavigationTime = 0 + + constructor(router: Router) { + this.router = router + this.setupGuards() + } + + private setupGuards() { + // 在路由开始时设置导航状态 + this.router.beforeEach((to, from, next) => { + const targetPath = to.fullPath + const currentTime = Date.now() + + // 检查是否是页面刷新或首次加载(from.name为null或undefined) + const isPageRefresh = !from.name || from.fullPath === '/' + + // 如果是页面刷新,直接重置状态并允许导航 + if (isPageRefresh) { + this.resetNavigationState() + this.lastNavigationTime = currentTime + next() + return + } + + // 检查是否是快速重复点击(时间间隔小于防抖时间且目标路径相同) + const timeSinceLastNavigation = currentTime - this.lastNavigationTime + const isQuickDuplicate = timeSinceLastNavigation < this.NAVIGATION_DEBOUNCE + && this.pendingNavigation === targetPath + + if (isQuickDuplicate) { + console.warn('快速重复导航被阻止:', targetPath) + return next(false) + } + + // 更新导航状态 + this.isNavigating = true + this.pendingNavigation = targetPath + this.lastNavigationTime = currentTime + + // 清除之前的定时器 + if (this.navigationTimer) { + clearTimeout(this.navigationTimer) + } + + // 设置导航完成的定时器 + this.navigationTimer = setTimeout(() => { + this.isNavigating = false + this.pendingNavigation = null + }, this.NAVIGATION_DEBOUNCE) + + next() + }) + + // 在路由完成或取消时重置状态 + this.router.afterEach(() => { + this.resetNavigationState() + }) + + // 监听导航错误 + this.router.onError((error) => { + console.error('Navigation error:', error) + this.resetNavigationState() + }) + } + + private resetNavigationState() { + if (this.navigationTimer) { + clearTimeout(this.navigationTimer) + this.navigationTimer = null + } + this.isNavigating = false + this.pendingNavigation = null + // 注意:不重置 lastNavigationTime,保持防抖效果 + } + + /** + * 安全的路由跳转 + */ + async safePush(to: string | RouteLocationNormalized): Promise { + try { + // 如果正在导航到相同路由,则忽略 + if (this.pendingNavigation === (typeof to === 'string' ? to : to.fullPath)) { + return true + } + + await this.router.push(to) + return true + } + catch (error: any) { + // 忽略重复导航错误 + if (error.name === 'NavigationDuplicated') { + return true + } + console.error('Navigation failed:', error) + return false + } + } + + /** + * 安全的路由替换 + */ + async safeReplace(to: string | RouteLocationNormalized): Promise { + try { + await this.router.replace(to) + return true + } + catch (error: any) { + if (error.name === 'NavigationDuplicated') { + return true + } + console.error('Navigation replace failed:', error) + return false + } + } + + /** + * 检查是否正在导航 + */ + isNavigatingTo(path: string): boolean { + return this.isNavigating && this.pendingNavigation === path + } + + /** + * 清理资源 + */ + destroy() { + this.resetNavigationState() + } +} + +/** + * 创建导航防护实例 + */ +export function createNavigationGuard(router: Router): NavigationGuard { + return new NavigationGuard(router) +} diff --git a/src/utils/normalize.ts b/src/utils/normalize.ts new file mode 100644 index 0000000..170c36d --- /dev/null +++ b/src/utils/normalize.ts @@ -0,0 +1,22 @@ +/** + * 统一化存储单位,字节转化为英文缩写`bytes`, `KB`, `MB`, `GB` + * + * @param {number} bytes 需要转换的字节大小 + * @returns {string} 转化后的字节字符串 + * @example + * ``` + * // Output: '1 MB' + * normalizeSizeUnits(1048576) + * ``` + */ +export function normalizeSizeUnits(bytes: number): string { + if (bytes === 0) + return '0 bytes' + + const units = ['bytes', 'KB', 'MB', 'GB'] + const index = Math.floor(Math.log(bytes) / Math.log(1024)) + const size = +(bytes / 1024 ** index).toFixed(2) + const unit = units[index] + + return `${size} ${unit}` +} diff --git a/src/utils/router-safety.ts b/src/utils/router-safety.ts new file mode 100644 index 0000000..0c8be74 --- /dev/null +++ b/src/utils/router-safety.ts @@ -0,0 +1,100 @@ +import type { Router } from 'vue-router' + +/** + * 路由安全包装器,用于处理路由操作中的错误 + */ +export class RouterSafetyWrapper { + private router: Router + + constructor(router: Router) { + this.router = router + } + + /** + * 安全的路由跳转 + */ + async safePush(to: string | object): Promise { + try { + await this.router.push(to) + return true + } + catch (error) { + console.error('路由跳转失败:', error) + return false + } + } + + /** + * 安全的路由替换 + */ + async safeReplace(to: string | object): Promise { + try { + await this.router.replace(to) + return true + } + catch (error) { + console.error('路由替换失败:', error) + return false + } + } + + /** + * 安全的路由回退 + */ + async safeBack(): Promise { + try { + this.router.back() + return true + } + catch (error) { + console.error('路由回退失败:', error) + return false + } + } + + /** + * 安全的路由前进 + */ + async safeForward(): Promise { + try { + this.router.forward() + return true + } + catch (error) { + console.error('路由前进失败:', error) + return false + } + } +} + +/** + * 创建路由安全包装器实例 + */ +export function createRouterSafety(router: Router): RouterSafetyWrapper { + return new RouterSafetyWrapper(router) +} + +/** + * 全局错误处理函数 + */ +export function handleRouterError(error: any, operation: string = '路由操作') { + console.error(`${operation}发生错误:`, error) + + // 如果是导航被阻止的错误,不需要特殊处理 + if (error.name === 'NavigationDuplicated' || error.message?.includes('redundant navigation')) { + return + } + + // 其他路由错误的处理 + if (error.name === 'NavigationAborted') { + console.warn('导航被中止') + return + } + + // 未知错误,记录详细信息 + console.error('未知路由错误:', { + name: error.name, + message: error.message, + stack: error.stack, + }) +} diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 0000000..4c79ff5 --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,86 @@ +const STORAGE_PREFIX = import.meta.env.VITE_STORAGE_PREFIX + +interface StorageData { + value: T + expire: number | null +} +/** + * LocalStorage部分操作 + */ +function createLocalStorage() { + // 默认缓存期限为7天 + + function set(key: K, value: T[K], expire: number = 60 * 60 * 24 * 7) { + const storageData: StorageData = { + value, + expire: new Date().getTime() + expire * 1000, + } + const json = JSON.stringify(storageData) + window.localStorage.setItem(`${STORAGE_PREFIX}${String(key)}`, json) + } + + function get(key: K) { + const json = window.localStorage.getItem(`${STORAGE_PREFIX}${String(key)}`) + if (!json) + return null + + const storageData: StorageData | null = JSON.parse(json) + + if (storageData) { + const { value, expire } = storageData + if (expire === null || expire >= Date.now()) + return value + } + remove(key) + return null + } + + function remove(key: keyof T) { + window.localStorage.removeItem(`${STORAGE_PREFIX}${String(key)}`) + } + + const clear = window.localStorage.clear + + return { + set, + get, + remove, + clear, + } +} +/** + * sessionStorage部分操作 + */ + +function createSessionStorage() { + function set(key: K, value: T[K]) { + const json = JSON.stringify(value) + window.sessionStorage.setItem(`${STORAGE_PREFIX}${String(key)}`, json) + } + function get(key: K) { + const json = sessionStorage.getItem(`${STORAGE_PREFIX}${String(key)}`) + if (!json) + return null + + const storageData: T[K] | null = JSON.parse(json) + + if (storageData) + return storageData + + return null + } + function remove(key: keyof T) { + window.sessionStorage.removeItem(`${STORAGE_PREFIX}${String(key)}`) + } + const clear = window.sessionStorage.clear + + return { + set, + get, + remove, + clear, + } +} + +export const local = createLocalStorage() +export const session = createSessionStorage()