Просмотр исходного кода

feat:工具类的迁移
feat:hooks 的迁移(字典、权限)
feat:store 的迁移(字典、用户信息)

YunaiV 4 месяцев назад
Родитель
Сommit
75cf29263b

+ 41 - 0
src/hooks/useAccess.ts

@@ -0,0 +1,41 @@
+import { useUserStore } from '@/store/user'
+
+/**
+ * 权限控制 Hook
+ * @description 提供基于角色和权限码的权限判断方法
+ */
+function useAccess() {
+  const userStore = useUserStore()
+
+  /**
+   * 基于角色判断是否有权限
+   * @description 通过用户的角色列表判断是否具有指定角色
+   * @param roles 需要判断的角色列表
+   * @returns 是否具有指定角色中的任意一个
+   */
+  function hasAccessByRoles(roles: string[]): boolean {
+    const userRoleSet = new Set(userStore.roles)
+    const intersection = roles.filter(item => userRoleSet.has(item))
+    return intersection.length > 0
+  }
+
+  /**
+   * 基于权限码判断是否有权限
+   * @description 通过用户的权限码列表判断是否具有指定权限
+   * @param codes 需要判断的权限码列表
+   * @returns 是否具有指定权限码中的任意一个
+   */
+  function hasAccessByCodes(codes: string[]): boolean {
+    const userCodesSet = new Set(userStore.permissions)
+    const intersection = codes.filter(item => userCodesSet.has(item))
+    return intersection.length > 0
+  }
+
+  return {
+    hasAccessByCodes,
+    hasAccessByRoles,
+  }
+}
+
+export { useAccess }
+export default useAccess

+ 132 - 0
src/hooks/useDict.ts

@@ -0,0 +1,132 @@
+import type { DictItem } from '@/store/dict'
+import { useDictStore } from '@/store/dict'
+
+type ColorType = 'error' | 'info' | 'primary' | 'success' | 'warning'
+
+export interface DictDataType {
+  dictType?: string
+  label: string
+  value: boolean | number | string
+  colorType?: string
+  cssClass?: string
+}
+
+export interface NumberDictDataType extends DictDataType {
+  value: number
+}
+
+export interface StringDictDataType extends DictDataType {
+  value: string
+}
+
+/**
+ * 获取字典标签
+ *
+ * @param dictType 字典类型
+ * @param value 字典值
+ * @returns 字典标签
+ */
+export function getDictLabel(dictType: string, value: any): string {
+  const dictStore = useDictStore()
+  const dictObj = dictStore.getDictData(dictType, value)
+  return dictObj ? dictObj.label : ''
+}
+
+/**
+ * 获取字典对象
+ *
+ * @param dictType 字典类型
+ * @param value 字典值
+ * @returns 字典对象
+ */
+export function getDictObj(dictType: string, value: any): DictItem | null {
+  const dictStore = useDictStore()
+  const dictObj = dictStore.getDictData(dictType, value)
+  return dictObj || null
+}
+
+export function getIntDictOptions(dictType: string): NumberDictDataType[] {
+  // 获得通用的 DictDataType 列表
+  const dictOptions: DictDataType[] = getDictOptions(dictType)
+  // 转换成 number 类型的 NumberDictDataType 类型
+  // why 需要特殊转换:避免 IDEA 在 v-for="dict in getIntDictOptions(...)" 时,el-option 的 key 会告警
+  const dictOption: NumberDictDataType[] = []
+  dictOptions.forEach((dict: DictDataType) => {
+    dictOption.push({
+      ...dict,
+      value: Number.parseInt(`${dict.value}`),
+    })
+  })
+  return dictOption
+}
+
+export function getStrDictOptions(dictType: string) {
+  // 获得通用的 DictDataType 列表
+  const dictOptions: DictDataType[] = getDictOptions(dictType)
+  // 转换成 string 类型的 StringDictDataType 类型
+  // why 需要特殊转换:避免 IDEA 在 v-for="dict in getStrDictOptions(...)" 时,el-option 的 key 会告警
+  const dictOption: StringDictDataType[] = []
+  dictOptions.forEach((dict: DictDataType) => {
+    dictOption.push({
+      ...dict,
+      value: `${dict.value}`,
+    })
+  })
+  return dictOption
+}
+
+export function getBoolDictOptions(dictType: string) {
+  const dictOption: DictDataType[] = []
+  const dictOptions: DictDataType[] = getDictOptions(dictType)
+  dictOptions.forEach((dict: DictDataType) => {
+    dictOption.push({
+      ...dict,
+      value: `${dict.value}` === 'true',
+    })
+  })
+  return dictOption
+}
+
+/**
+ * 获取字典数组,用于 picker、radio 等
+ *
+ * @param dictType 字典类型
+ * @param valueType 字典值类型,默认 string 类型
+ * @returns 字典数组
+ */
+export function getDictOptions(
+  dictType: string,
+  valueType: 'boolean' | 'number' | 'string' = 'string',
+): DictDataType[] {
+  const dictStore = useDictStore()
+  const dictOpts = dictStore.getDictOptions(dictType)
+  const dictOptions: DictDataType[] = []
+
+  if (dictOpts.length > 0) {
+    let dictValue: boolean | number | string = ''
+    dictOpts.forEach((dict) => {
+      switch (valueType) {
+        case 'boolean': {
+          dictValue = `${dict.value}` === 'true'
+          break
+        }
+        case 'number': {
+          dictValue = Number.parseInt(`${dict.value}`)
+          break
+        }
+        case 'string': {
+          dictValue = `${dict.value}`
+          break
+        }
+      }
+      dictOptions.push({
+        value: dictValue,
+        label: dict.label,
+        colorType: dict.colorType as ColorType,
+        cssClass: dict.cssClass,
+      })
+    })
+  }
+
+  return dictOptions
+}

+ 86 - 0
src/store/dict.ts

@@ -0,0 +1,86 @@
+import type { DictData } from '@/api/system/dict/data'
+import { defineStore } from 'pinia'
+
+import { computed, ref } from 'vue'
+import { getSimpleDictDataList } from '@/api/system/dict/data'
+
+/** 字典项 */
+export interface DictItem {
+  label: string
+  value: string
+  colorType?: string
+  cssClass?: string
+}
+
+/** 字典缓存类型 */
+export type DictCache = Record<string, DictItem[]>
+
+export const useDictStore = defineStore(
+  'dict',
+  () => {
+    const dictCache = ref<DictCache>({}) // 字典缓存
+    const isLoaded = computed(() => Object.keys(dictCache.value).length > 0) // 是否已加载(基于 dictCache 非空判断)
+
+    /** 设置字典缓存 */
+    const setDictCache = (dicts: DictCache) => {
+      dictCache.value = dicts
+    }
+
+    /** 通过 API 加载字典数据 */
+    const loadDictCache = async () => {
+      if (isLoaded.value) {
+        return
+      }
+      try {
+        const dicts = await getSimpleDictDataList()
+        const dictCacheData: DictCache = {}
+        dicts.forEach((dict: DictData) => {
+          if (!dictCacheData[dict.dictType]) {
+            dictCacheData[dict.dictType] = []
+          }
+          dictCacheData[dict.dictType].push({
+            label: dict.label,
+            value: dict.value,
+            colorType: dict.colorType,
+            cssClass: dict.cssClass,
+          })
+        })
+        setDictCache(dictCacheData)
+      } catch (error) {
+        console.error('加载字典数据失败', error)
+      }
+    }
+
+    /** 获取字典选项列表 */
+    const getDictOptions = (dictType: string): DictItem[] => {
+      return dictCache.value[dictType] || []
+    }
+
+    /** 获取字典数据对象 */
+    const getDictData = (dictType: string, value: any): DictItem | undefined => {
+      const dict = dictCache.value[dictType]
+      if (!dict) {
+        return undefined
+      }
+      return dict.find(d => d.value === value || d.value === String(value))
+    }
+
+    /** 清空字典缓存 */
+    const clearDictCache = () => {
+      dictCache.value = {}
+    }
+
+    return {
+      dictCache,
+      isLoaded,
+      setDictCache,
+      loadDictCache,
+      getDictOptions,
+      getDictData,
+      clearDictCache,
+    }
+  },
+  {
+    persist: true,
+  },
+)

+ 2 - 0
src/store/index.ts

@@ -16,5 +16,7 @@ setActivePinia(store)
 export default store
 
 // 模块统一导出
+export * from './dict'
+export * from './theme'
 export * from './token'
 export * from './user'

+ 64 - 21
src/store/token.ts

@@ -1,4 +1,8 @@
+/* eslint-disable brace-style */ // 原因:unibest 官方维护的代码,尽量不要大概,避免难以合并
 import type {
+  AuthLoginReqVO,
+  AuthRegisterReqVO,
+  AuthSmsLoginReqVO,
   ILoginForm,
 } from '@/api/login'
 import type { IAuthLoginRes } from '@/api/types/login'
@@ -10,18 +14,22 @@ import {
   refreshToken as _refreshToken,
   wxLogin as _wxLogin,
   getWxCode,
+  register,
+  smsLogin,
 } from '@/api/login'
 import { isDoubleTokenRes, isSingleTokenRes } from '@/api/types/login'
 import { isDoubleTokenMode } from '@/utils'
+import { useDictStore } from './dict'
 import { useUserStore } from './user'
 
 // 初始化状态
 const tokenInfoState = isDoubleTokenMode
   ? {
       accessToken: '',
-      accessExpiresIn: 0,
+      // accessExpiresIn: 0,
       refreshToken: '',
-      refreshExpiresIn: 0,
+      // refreshExpiresIn: 0,
+      expiresTime: 0,
     }
   : {
       token: '',
@@ -46,10 +54,11 @@ export const useTokenStore = defineStore(
       }
       else if (isDoubleTokenRes(val)) {
         // 双token模式
-        const accessExpireTime = now + val.accessExpiresIn * 1000
-        const refreshExpireTime = now + val.refreshExpiresIn * 1000
+        const accessExpireTime = val.expiresTime
+        // const refreshExpireTime = now + val.refreshExpiresIn * 1000
         uni.setStorageSync('accessTokenExpireTime', accessExpireTime)
-        uni.setStorageSync('refreshTokenExpireTime', refreshExpireTime)
+        // uni.setStorageSync('refreshTokenExpireTime', refreshExpireTime)
+        // add by 芋艿:目前后端没有返回 refreshToken 的过期时间,所以这里暂时不存储 refreshToken 过期时间
       }
     }
 
@@ -76,12 +85,14 @@ export const useTokenStore = defineStore(
       if (!isDoubleTokenMode)
         return true
 
-      const now = Date.now()
-      const refreshExpireTime = uni.getStorageSync('refreshTokenExpireTime')
-
-      if (!refreshExpireTime)
-        return true
-      return now >= refreshExpireTime
+      // const now = Date.now()
+      // const refreshExpireTime = uni.getStorageSync('refreshTokenExpireTime')
+      //
+      // if (!refreshExpireTime)
+      //   return true
+      // return now >= refreshExpireTime
+      // add by 芋艿:目前后端没有返回 refreshToken 的过期时间,所以这里暂时不做过期判断,先全部返回 false 非过期
+      return false
     })
 
     /**
@@ -89,35 +100,58 @@ export const useTokenStore = defineStore(
      * @param tokenInfo 登录返回的token信息
      */
     async function _postLogin(tokenInfo: IAuthLoginRes) {
+      // 设置认证信息
       setTokenInfo(tokenInfo)
+      // 获取用户信息
       const userStore = useUserStore()
       await userStore.fetchUserInfo()
+      // add by 芋艿:加载字典数据(异步)
+      const dictStore = useDictStore()
+      dictStore.loadDictCache().then()
     }
 
     /**
-     * 用户登录
+     * 用户登录:账号登录、注册登录、短信登录、三方登录等
      * 有的时候后端会用一个接口返回token和用户信息,有的时候会分开2个接口,一个获取token,一个获取用户信息
      * (各有利弊,看业务场景和系统复杂度),这里使用2个接口返回的来模拟
      * @param loginForm 登录参数
      * @returns 登录结果
      */
     const login = async (loginForm: ILoginForm) => {
+      let typeName = ''
       try {
-        const res = await _login(loginForm)
-        console.log('普通登录-res: ', res)
+        let res: IAuthLoginRes
+        switch (loginForm.type) {
+          case 'register': {
+            res = await register(loginForm as AuthRegisterReqVO)
+            typeName = '注册'
+            break
+          }
+          case 'sms': {
+            res = await smsLogin(loginForm as AuthSmsLoginReqVO)
+            typeName = '注册'
+            break
+          }
+          default: {
+            res = await _login(loginForm as AuthLoginReqVO)
+            typeName = '登录'
+          }
+        }
+        // console.log('普通登录-res: ', res)
         await _postLogin(res)
         uni.showToast({
-          title: '登录成功',
+          title: `${typeName}成功`,
           icon: 'success',
         })
         return res
       }
       catch (error) {
-        console.error('登录失败:', error)
-        uni.showToast({
-          title: '登录失败,请重试',
-          icon: 'error',
-        })
+        console.error(`${typeName}失败:`, error)
+        // 注释 by 芋艿:避免覆盖 http.ts 中的错误提示
+        // uni.showToast({
+        //   title: `${typeName}失败,请重试`,
+        //   icon: 'error',
+        // })
         throw error
       }
     }
@@ -167,12 +201,15 @@ export const useTokenStore = defineStore(
         // 无论成功失败,都需要清除本地token信息
         // 清除存储的过期时间
         uni.removeStorageSync('accessTokenExpireTime')
-        uni.removeStorageSync('refreshTokenExpireTime')
+        // uni.removeStorageSync('refreshTokenExpireTime')
         console.log('退出登录-清除用户信息')
         tokenInfo.value = { ...tokenInfoState }
         uni.removeStorageSync('token')
         const userStore = useUserStore()
         userStore.clearUserInfo()
+        // add by 芋艿:清空字典缓存
+        const dictStore = useDictStore()
+        dictStore.clearDictCache()
       }
     }
 
@@ -243,6 +280,12 @@ export const useTokenStore = defineStore(
      */
     const hasValidLogin = computed(() => {
       console.log('hasValidLogin', hasLoginInfo.value, !isTokenExpired.value)
+      if (isDoubleTokenMode) {
+        // add by 芋艿:双令牌场景下,以刷新令牌过期为准。而刷新令牌是否过期,通过请求时返回 401 来判断(由于后端 refreshToken 不返回过期时间)
+        // 即相比下面的判断方式,去掉了“!isTokenExpired.value”
+        // 如果不这么做:访问令牌过期时(刷新令牌没过期),会导致刷新界面时,直接认为是令牌过期,导致跳转到登录界面
+        return hasLoginInfo.value
+      }
       return hasLoginInfo.value && !isTokenExpired.value
     })
 

+ 42 - 16
src/store/user.ts

@@ -1,8 +1,9 @@
-import type { IUserInfoRes } from '@/api/types/login'
+import type { AuthPermissionInfo, IUserInfoRes } from '@/api/types/login'
 import { defineStore } from 'pinia'
 import { ref } from 'vue'
 import {
-  getUserInfo,
+  // getUserInfo,
+  getAuthPermissionInfo,
 } from '@/api/login'
 
 // 初始化状态
@@ -10,7 +11,7 @@ const userInfoState: IUserInfoRes = {
   userId: -1,
   username: '',
   nickname: '',
-  avatar: '/static/images/default-avatar.png',
+  avatar: '/static/images/default-avatar.png', // TODO @芋艿:CDN 化
 }
 
 export const useUserStore = defineStore(
@@ -18,41 +19,66 @@ export const useUserStore = defineStore(
   () => {
     // 定义用户信息
     const userInfo = ref<IUserInfoRes>({ ...userInfoState })
-    // 设置用户信息
-    const setUserInfo = (val: IUserInfoRes) => {
-      console.log('设置用户信息', val)
+    const tenantId = ref<number | null>(null) // 租户编号
+    const roles = ref<string[]>([]) // 角色标识列表
+    const permissions = ref<string[]>([]) // 权限标识列表
+    const favoriteMenus = ref<string[]>([]) // 常用菜单 key 列表
+
+    /** 设置用户信息 */
+    const setUserInfo = (val: AuthPermissionInfo) => {
+      // console.log('设置用户信息', val)
       // 若头像为空 则使用默认头像
-      if (!val.avatar) {
-        val.avatar = userInfoState.avatar
+      if (!val.user) {
+        val.user.avatar = userInfoState.avatar
       }
-      userInfo.value = val
+      userInfo.value = val.user
+      roles.value = val.roles
+      permissions.value = val.permissions
     }
+
     const setUserAvatar = (avatar: string) => {
       userInfo.value.avatar = avatar
-      console.log('设置用户头像', avatar)
-      console.log('userInfo', userInfo.value)
+      // console.log('设置用户头像', avatar)
+      // console.log('userInfo', userInfo.value)
     }
-    // 删除用户信息
+
+    /** 删除用户信息 */
     const clearUserInfo = () => {
       userInfo.value = { ...userInfoState }
+      roles.value = []
+      permissions.value = []
       uni.removeStorageSync('user')
     }
 
-    /**
-     * 获取用户信息
-     */
+    /** 设置租户编号 */
+    const setTenantId = (id: number) => {
+      tenantId.value = id
+    }
+
+    /** 设置常用菜单 */
+    const setFavoriteMenus = (keys: string[]) => {
+      favoriteMenus.value = keys
+    }
+
+    /** 获取用户信息 */
     const fetchUserInfo = async () => {
-      const res = await getUserInfo()
+      const res = await getAuthPermissionInfo()
       setUserInfo(res)
       return res
     }
 
     return {
       userInfo,
+      tenantId,
+      roles,
+      permissions,
+      favoriteMenus,
       clearUserInfo,
       fetchUserInfo,
       setUserInfo,
       setUserAvatar,
+      setTenantId,
+      setFavoriteMenus,
     }
   },
   {

+ 3 - 0
src/utils/constants.ts

@@ -0,0 +1,3 @@
+export * from './constants/biz-infra-enum'
+export * from './constants/biz-system-enum'
+export * from './constants/dict-enum'

+ 26 - 0
src/utils/constants/biz-infra-enum.ts

@@ -0,0 +1,26 @@
+/**
+ * 代码生成模板类型
+ */
+export const InfraCodegenTemplateTypeEnum = {
+  CRUD: 1, // 基础 CRUD
+  TREE: 2, // 树形 CRUD
+  SUB: 15, // 主子表 CRUD
+}
+
+/**
+ * 任务状态的枚举
+ */
+export const InfraJobStatusEnum = {
+  INIT: 0, // 初始化中
+  NORMAL: 1, // 运行中
+  STOP: 2, // 暂停运行
+}
+
+/**
+ * API 异常数据的处理状态
+ */
+export const InfraApiErrorLogProcessStatusEnum = {
+  INIT: 0, // 未处理
+  DONE: 1, // 已处理
+  IGNORE: 2, // 已忽略
+}

+ 59 - 0
src/utils/constants/biz-system-enum.ts

@@ -0,0 +1,59 @@
+// ========== COMMON 模块 ==========
+// 全局通用状态枚举
+export const CommonStatusEnum = {
+  ENABLE: 0, // 开启
+  DISABLE: 1, // 禁用
+}
+
+// 全局用户类型枚举
+export const UserTypeEnum = {
+  MEMBER: 1, // 会员
+  ADMIN: 2, // 管理员
+}
+
+// ========== SYSTEM 模块 ==========
+/**
+ * 菜单的类型枚举
+ */
+export const SystemMenuTypeEnum = {
+  DIR: 1, // 目录
+  MENU: 2, // 菜单
+  BUTTON: 3, // 按钮
+}
+
+/**
+ * 角色的类型枚举
+ */
+export const SystemRoleTypeEnum = {
+  SYSTEM: 1, // 内置角色
+  CUSTOM: 2, // 自定义角色
+}
+
+/**
+ * 数据权限的范围枚举
+ */
+export const SystemDataScopeEnum = {
+  ALL: 1, // 全部数据权限
+  DEPT_CUSTOM: 2, // 指定部门数据权限
+  DEPT_ONLY: 3, // 部门数据权限
+  DEPT_AND_CHILD: 4, // 部门及以下数据权限
+  DEPT_SELF: 5, // 仅本人数据权限
+}
+
+/**
+ * 用户的社交平台的类型枚举
+ */
+export const SystemUserSocialTypeEnum = {
+  DINGTALK: {
+    title: '钉钉',
+    type: 20,
+    source: 'dingtalk',
+    img: 'https://s1.ax1x.com/2022/05/22/OzMDRs.png',
+  },
+  WECHAT_ENTERPRISE: {
+    title: '企业微信',
+    type: 30,
+    source: 'wechat_enterprise',
+    img: 'https://s1.ax1x.com/2022/05/22/OzMrzn.png',
+  },
+}

+ 60 - 0
src/utils/constants/dict-enum.ts

@@ -0,0 +1,60 @@
+/** ========== COMMON - 通用模块 ========== */
+const COMMON_DICT = {
+  USER_TYPE: 'user_type',
+  COMMON_STATUS: 'common_status',
+  TERMINAL: 'terminal', // 终端
+  DATE_INTERVAL: 'date_interval', // 数据间隔
+} as const
+
+/** ========== SYSTEM - 系统模块 ========== */
+const SYSTEM_DICT = {
+  SYSTEM_USER_SEX: 'system_user_sex',
+  SYSTEM_MENU_TYPE: 'system_menu_type',
+  SYSTEM_ROLE_TYPE: 'system_role_type',
+  SYSTEM_DATA_SCOPE: 'system_data_scope',
+  SYSTEM_NOTICE_TYPE: 'system_notice_type',
+  SYSTEM_LOGIN_TYPE: 'system_login_type',
+  SYSTEM_LOGIN_RESULT: 'system_login_result',
+  SYSTEM_SMS_CHANNEL_CODE: 'system_sms_channel_code',
+  SYSTEM_SMS_TEMPLATE_TYPE: 'system_sms_template_type',
+  SYSTEM_SMS_SEND_STATUS: 'system_sms_send_status',
+  SYSTEM_SMS_RECEIVE_STATUS: 'system_sms_receive_status',
+  SYSTEM_OAUTH2_GRANT_TYPE: 'system_oauth2_grant_type',
+  SYSTEM_MAIL_SEND_STATUS: 'system_mail_send_status',
+  SYSTEM_NOTIFY_TEMPLATE_TYPE: 'system_notify_template_type',
+  SYSTEM_SOCIAL_TYPE: 'system_social_type',
+} as const
+
+/** ========== INFRA - 基础设施模块 ========== */
+const INFRA_DICT = {
+  INFRA_BOOLEAN_STRING: 'infra_boolean_string',
+  INFRA_JOB_STATUS: 'infra_job_status',
+  INFRA_JOB_LOG_STATUS: 'infra_job_log_status',
+  INFRA_API_ERROR_LOG_PROCESS_STATUS: 'infra_api_error_log_process_status',
+  INFRA_CONFIG_TYPE: 'infra_config_type',
+  INFRA_CODEGEN_TEMPLATE_TYPE: 'infra_codegen_template_type',
+  INFRA_CODEGEN_FRONT_TYPE: 'infra_codegen_front_type',
+  INFRA_CODEGEN_SCENE: 'infra_codegen_scene',
+  INFRA_FILE_STORAGE: 'infra_file_storage',
+  INFRA_OPERATE_TYPE: 'infra_operate_type',
+} as const
+
+/** ========== BPM - 工作流模块 ========== */
+const BPM_DICT = {
+  BPM_MODEL_FORM_TYPE: 'bpm_model_form_type', // BPM 模型表单类型
+  BPM_MODEL_TYPE: 'bpm_model_type', // BPM 模型类型
+  BPM_OA_LEAVE_TYPE: 'bpm_oa_leave_type', // BPM OA 请假类型
+  BPM_PROCESS_INSTANCE_STATUS: 'bpm_process_instance_status', // BPM 流程实例状态
+  BPM_PROCESS_LISTENER_TYPE: 'bpm_process_listener_type', // BPM 流程监听器类型
+  BPM_PROCESS_LISTENER_VALUE_TYPE: 'bpm_process_listener_value_type', // BPM 流程监听器值类型
+  BPM_TASK_CANDIDATE_STRATEGY: 'bpm_task_candidate_strategy', // BPM 任务候选人策略
+  BPM_TASK_STATUS: 'bpm_task_status', // BPM 任务状态
+} as const
+
+/** 字典类型枚举 - 统一导出 */
+export const DICT_TYPE = {
+  ...BPM_DICT,
+  ...INFRA_DICT,
+  ...SYSTEM_DICT,
+  ...COMMON_DICT,
+} as const

+ 85 - 0
src/utils/date.ts

@@ -0,0 +1,85 @@
+import dayjs from 'dayjs'
+
+type FormatDate = Date | dayjs.Dayjs | number | string
+
+type Format
+  = | 'HH'
+    | 'HH:mm'
+    | 'HH:mm:ss'
+    | 'YYYY'
+    | 'YYYY-MM'
+    | 'YYYY-MM-DD'
+    | 'YYYY-MM-DD HH'
+    | 'YYYY-MM-DD HH:mm'
+    | 'YYYY-MM-DD HH:mm:ss'
+    | (string & {})
+
+/** 格式化日期 */
+export function formatDate(time?: FormatDate, format: Format = 'YYYY-MM-DD') {
+  if (!time) {
+    return ''
+  }
+  try {
+    const date = dayjs.isDayjs(time) ? time : dayjs(time)
+    if (!date.isValid()) {
+      throw new Error('Invalid date')
+    }
+    return date.format(format)
+  } catch (error) {
+    console.error(`Error formatting date: ${error}`)
+    return String(time ?? '')
+  }
+}
+
+/** 格式化日期时间 */
+export function formatDateTime(time?: FormatDate) {
+  return formatDate(time, 'YYYY-MM-DD HH:mm:ss')
+}
+
+/** 计算开始结束时间 */
+export function formatDateRange(dateRange?: [any, any]) {
+  if (!dateRange || !dateRange[0] || !dateRange[1]) {
+    return undefined
+  }
+  const startDate = new Date(dateRange[0])
+  startDate.setHours(0, 0, 0, 0)
+  const endDate = new Date(dateRange[1])
+  endDate.setHours(23, 59, 59, 999)
+  return [formatDateTime(startDate), formatDateTime(endDate)]
+}
+
+/** 格式化过去时间(如:3分钟前、2小时前、1天前) */
+export function formatPast(time?: FormatDate): string {
+  if (!time) {
+    return ''
+  }
+  const now = Date.now()
+  const date = dayjs.isDayjs(time) ? time : dayjs(time)
+  if (!date.isValid()) {
+    return ''
+  }
+  const diff = now - date.valueOf()
+  const seconds = Math.floor(diff / 1000)
+  const minutes = Math.floor(seconds / 60)
+  const hours = Math.floor(minutes / 60)
+  const days = Math.floor(hours / 24)
+  const months = Math.floor(days / 30)
+  const years = Math.floor(days / 365)
+
+  if (years > 0) {
+    return `${years}年前`
+  }
+  if (months > 0) {
+    return `${months}个月前`
+  }
+  if (days > 0) {
+    return `${days}天前`
+  }
+  if (hours > 0) {
+    return `${hours}小时前`
+  }
+  if (minutes > 0) {
+    return `${minutes}分钟前`
+  }
+  return '刚刚'
+}

+ 110 - 0
src/utils/download.ts

@@ -0,0 +1,110 @@
+/**
+ * 下载工具类 - 支持多端(H5、小程序、APP)
+ */
+
+import { isH5, isMpWeixin } from '@uni-helper/uni-env'
+
+/** 保存图片到相册 */
+export async function saveImageToAlbum(url: string, fileName?: string): Promise<void> {
+  if (isH5) {
+    await downloadFileH5(url, fileName)
+    return
+  }
+  // 小程序和 APP 端保存图片到相册
+  return new Promise((resolve, reject) => {
+    // 如果是网络图片,先下载
+    if (url.startsWith('http')) {
+      uni.downloadFile({
+        url,
+        success: (downloadResult) => {
+          if (downloadResult.statusCode === 200) {
+            saveToAlbum(downloadResult.tempFilePath, resolve, reject)
+          } else {
+            uni.showToast({ icon: 'none', title: '下载失败' })
+            reject(new Error('Download failed'))
+          }
+        },
+        fail: (err) => {
+          uni.showToast({ icon: 'none', title: '下载失败' })
+          reject(err)
+        },
+      })
+    } else {
+      // 本地图片直接保存
+      saveToAlbum(url, resolve, reject)
+    }
+  })
+}
+
+/** 保存图片到相册(内部方法) */
+function saveToAlbum(
+  filePath: string,
+  resolve: () => void,
+  reject: (err: unknown) => void,
+): void {
+  uni.saveImageToPhotosAlbum({
+    filePath,
+    success: () => {
+      uni.showToast({
+        icon: 'success',
+        title: '已保存到相册',
+      })
+      resolve()
+    },
+    fail: (err) => {
+      // 微信小程序需要授权
+      if (isMpWeixin && err.errMsg?.includes('auth deny')) {
+        uni.showModal({
+          title: '提示',
+          content: '需要您授权保存相册权限',
+          success: (res) => {
+            if (res.confirm) {
+              uni.openSetting({
+                success: (settingRes) => {
+                  if (settingRes.authSetting['scope.writePhotosAlbum']) {
+                    // 重新尝试保存
+                    saveToAlbum(filePath, resolve, reject)
+                  }
+                  else {
+                    reject(new Error('User denied'))
+                  }
+                },
+              })
+            }
+            else {
+              reject(new Error('User cancelled'))
+            }
+          },
+        })
+      } else {
+        uni.showToast({
+          icon: 'none',
+          title: '保存失败',
+        })
+        reject(err)
+      }
+    },
+  })
+}
+
+/** H5 端下载文件 */
+async function downloadFileH5(url: string, fileName?: string): Promise<void> {
+  const link = document.createElement('a')
+  link.href = url
+  link.download = fileName || resolveFileName(url)
+  link.style.display = 'none'
+  document.body.appendChild(link)
+  link.click()
+  document.body.removeChild(link)
+}
+
+/** 从 URL 中解析文件名 */
+function resolveFileName(url: string): string {
+  const defaultName = 'downloaded_file'
+  try {
+    const pathname = new URL(url).pathname
+    return pathname.slice(pathname.lastIndexOf('/') + 1) || defaultName
+  } catch {
+    return url.slice(url.lastIndexOf('/') + 1) || defaultName
+  }
+}

+ 39 - 3
src/utils/index.ts

@@ -1,6 +1,8 @@
 import type { PageMetaDatum, SubPackages } from '@uni-helper/vite-plugin-uni-pages'
 import { isMpWeixin } from '@uni-helper/uni-env'
 import { pages, subPackages } from '@/pages.json'
+import { tabbarList } from '@/tabbar/config'
+import { isPageTabbar } from '@/tabbar/store'
 
 export type PageInstance = Page.PageInstance<AnyObject, object> & { $page: Page.PageInstance<AnyObject, object> & { fullPath: string } }
 
@@ -121,9 +123,10 @@ export function getEnvBaseUrl() {
   let baseUrl = import.meta.env.VITE_SERVER_BASEURL
 
   // # 有些同学可能需要在微信小程序里面根据 develop、trial、release 分别设置上传地址,参考代码如下。
-  const VITE_SERVER_BASEURL__WEIXIN_DEVELOP = 'https://ukw0y1.laf.run'
-  const VITE_SERVER_BASEURL__WEIXIN_TRIAL = 'https://ukw0y1.laf.run'
-  const VITE_SERVER_BASEURL__WEIXIN_RELEASE = 'https://ukw0y1.laf.run'
+  // TODO @芋艿:这个后续也要调整。
+  const VITE_SERVER_BASEURL__WEIXIN_DEVELOP = 'http://localhost:48080/admin-api'
+  const VITE_SERVER_BASEURL__WEIXIN_TRIAL = 'http://localhost:48080/admin-api'
+  const VITE_SERVER_BASEURL__WEIXIN_RELEASE = 'http://localhost:48080/admin-api'
 
   // 微信小程序端环境区分
   if (isMpWeixin) {
@@ -147,6 +150,20 @@ export function getEnvBaseUrl() {
   return baseUrl
 }
 
+/**
+ * 根据环境变量,获取基础路径的根路径,比如 http://localhost:48080
+ *
+ * add by 芋艿:用户类似 websocket 这种需要根路径的场景
+ *
+ * @return 根路径
+ */
+export function getEnvBaseUrlRoot() {
+  const baseUrl = getEnvBaseUrl()
+  // 提取根路径
+  const urlObj = new URL(baseUrl)
+  return urlObj.origin
+}
+
 /**
  * 是否是双token模式
  */
@@ -157,3 +174,22 @@ export const isDoubleTokenMode = import.meta.env.VITE_AUTH_MODE === 'double'
  * 通常为 /pages/index/index
  */
 export const HOME_PAGE = `/${(pages as PageMetaDatum[]).find(page => page.type === 'home')?.path || (pages as PageMetaDatum[])[0].path}`
+
+// TODO @芋艿:这里要不要换成 HOME_PAGE?
+/**
+ * 登录成功后跳转
+ * @param redirectUrl 重定向地址,为空则跳转到默认首页(tabbar 第一个页面)
+ */
+export function redirectAfterLogin(redirectUrl?: string) {
+  let path = redirectUrl || tabbarList[0].pagePath
+  if (!path.startsWith('/')) {
+    path = `/${path}`
+  }
+  const { path: _path } = parseUrlToObj(path)
+  if (isPageTabbar(_path)) {
+    uni.switchTab({ url: path })
+  }
+  else {
+    uni.navigateBack()
+  }
+}

+ 1 - 3
src/utils/toLoginPage.ts

@@ -1,3 +1,4 @@
+import { LOGIN_PAGE } from '@/router/config'
 import { getLastPage } from '@/utils'
 import { debounce } from '@/utils/debounce'
 
@@ -14,9 +15,6 @@ interface ToLoginPageOptions {
   queryString?: string
 }
 
-// TODO: 自己增加登录页
-const LOGIN_PAGE = '/pages/login/index'
-
 /**
  * 跳转到登录页, 带防抖处理
  *

+ 101 - 0
src/utils/tree.ts

@@ -0,0 +1,101 @@
+/**
+ * 树形结构工具函数
+ */
+
+interface TreeNode {
+  id?: number
+  parentId?: number
+  children?: TreeNode[]
+  [key: string]: any
+}
+
+/**
+ * 构造树型结构数据
+ * @param data 数据源
+ * @param id id 字段,默认 'id'
+ * @param parentId 父节点字段,默认 'parentId'
+ * @param children 孩子节点字段,默认 'children'
+ */
+export function handleTree<T extends TreeNode>(
+  data: T[],
+  id = 'id',
+  parentId = 'parentId',
+  children = 'children',
+): T[] {
+  if (!Array.isArray(data)) {
+    console.warn('data must be an array')
+    return []
+  }
+
+  const nodeMap: Record<number, T> = {}
+  const childrenListMap: Record<number, T[]> = {}
+  const tree: T[] = []
+
+  // 构建节点映射和子节点列表
+  for (const node of data) {
+    const nodeId = node[id] as number
+    const nodeParentId = node[parentId] as number
+
+    nodeMap[nodeId] = { ...node, [children]: [] } as T
+
+    if (!childrenListMap[nodeParentId]) {
+      childrenListMap[nodeParentId] = []
+    }
+    childrenListMap[nodeParentId].push(nodeMap[nodeId])
+  }
+
+  // 构建树形结构
+  for (const node of data) {
+    const nodeParentId = node[parentId] as number
+    // 父节点不存在于 nodeMap 中,说明是根节点
+    if (!nodeMap[nodeParentId]) {
+      tree.push(nodeMap[node[id] as number])
+    }
+  }
+
+  // 递归设置子节点
+  function setChildren(node: T) {
+    const nodeId = node[id] as number
+    const nodeChildren = childrenListMap[nodeId]
+    if (nodeChildren && nodeChildren.length > 0) {
+      ;(node as any)[children] = nodeChildren
+      for (const child of nodeChildren) {
+        setChildren(child)
+      }
+    }
+  }
+
+  for (const node of tree) {
+    setChildren(node)
+  }
+
+  return tree
+}
+
+/**
+ * 在树中查找节点的子节点列表
+ * @param tree 树形数据
+ * @param parentId 父节点 ID
+ * @param id id 字段,默认 'id'
+ * @param children 孩子节点字段,默认 'children'
+ */
+export function findChildren<T extends TreeNode>(
+  tree: T[],
+  parentId: number,
+  id = 'id',
+  children = 'children',
+): T[] {
+  for (const node of tree) {
+    if (node[id] === parentId) {
+      return (node[children] as T[]) || []
+    }
+    const nodeChildren = node[children] as T[] | undefined
+    if (nodeChildren && nodeChildren.length > 0) {
+      const found = findChildren(nodeChildren, parentId, id, children)
+      if (found.length > 0) {
+        return found
+      }
+    }
+  }
+  return []
+}

+ 120 - 39
src/utils/uploadFile.ts

@@ -1,45 +1,128 @@
 /**
- * 文件上传钩子函数使用示例
- * @example
- * const { loading, error, data, progress, run } = useUpload<IUploadResult>(
- *   uploadUrl,
- *   {},
- *   {
- *     maxSize: 5, // 最大5MB
- *     sourceType: ['album'], // 仅支持从相册选择
- *     onProgress: (p) => console.log(`上传进度:${p}%`),
- *     onSuccess: (res) => console.log('上传成功', res),
- *     onError: (err) => console.error('上传失败', err),
- *   },
- * )
+ * 文件上传工具
+ *
+ * 支持两种上传模式:
+ * - server: 后端上传(默认)
+ * - client: 前端直连上传(仅支持 S3 服务)
+ *
+ * 通过环境变量 VITE_UPLOAD_TYPE 配置
  */
 
+import * as FileApi from '@/api/infra/file'
+
+/** 上传类型 */
+const UPLOAD_TYPE = {
+  /** 客户端直接上传(只支持S3服务) */
+  CLIENT: 'client',
+  /** 客户端发送到后端上传 */
+  SERVER: 'server',
+}
+
 /**
- * 上传文件的URL配置
+ * 读取文件二进制内容
+ * @param uniFile 文件对象
  */
-export const uploadFileUrl = {
-  /** 用户头像上传地址 */
-  USER_AVATAR: `${import.meta.env.VITE_SERVER_BASEURL}/user/avatar`,
+async function readFile(uniFile: { path: string, arrayBuffer?: () => Promise<ArrayBuffer> }): Promise<ArrayBuffer | string> {
+  // 微信小程序
+  if (uni.getFileSystemManager) {
+    const fs = uni.getFileSystemManager()
+    return fs.readFileSync(uniFile.path) as ArrayBuffer
+  }
+  // H5 等
+  if (uniFile.arrayBuffer) {
+    return uniFile.arrayBuffer()
+  }
+  throw new Error('不支持的文件读取方式')
 }
 
 /**
- * 通用文件上传函数(支持直接传入文件路径)
- * @param url 上传地址
- * @param filePath 本地文件路径
- * @param formData 额外表单数据
- * @param options 上传选项
+ * 创建文件记录(异步)
+ * @param presignedInfo 预签名信息
+ * @param file 文件信息
  */
-export function useFileUpload<T = string>(url: string, filePath: string, formData: Record<string, any> = {}, options: Omit<UploadOptions, 'sourceType' | 'sizeType' | 'count'> = {}) {
-  return useUpload<T>(
-    url,
-    formData,
-    {
-      ...options,
-      sourceType: ['album'],
-      sizeType: ['original'],
-    },
-    filePath,
-  )
+function createFileRecord(presignedInfo: FileApi.FilePresignedUrlRespVO, file: { name: string, type?: string, size?: number }) {
+  const fileVo: FileApi.FileCreateReqVO = {
+    configId: presignedInfo.configId,
+    url: presignedInfo.url,
+    path: presignedInfo.path,
+    name: file.name,
+    type: file.type,
+    size: file.size,
+  }
+  FileApi.createFile(fileVo).catch((err) => {
+    console.error('创建文件记录失败:', err, fileVo)
+  })
+}
+
+/**
+ * 从文件路径上传文件(纯文件上传)
+ * @param filePath 文件路径
+ * @param directory 目录(可选)
+ * @returns 文件访问 URL
+ */
+export async function uploadFileFromPath(filePath: string, directory?: string, fileType?: string): Promise<string> {
+  const fileName = filePath.includes('/') ? filePath.substring(filePath.lastIndexOf('/') + 1) : filePath
+  const uploadType = import.meta.env.VITE_UPLOAD_TYPE || UPLOAD_TYPE.SERVER
+  // 根据文件后缀推断 MIME 类型
+  const mimeType = fileType || getMimeType(fileName)
+
+  // 情况一:前端直连上传
+  if (uploadType === UPLOAD_TYPE.CLIENT) {
+    // 1.1 获取文件预签名地址
+    const presignedInfo = await FileApi.getFilePresignedUrl(fileName, directory)
+
+    // 1.2 获取二进制文件对象
+    const fileBuffer = await readFile({ path: filePath })
+
+    // 返回上传的 Promise
+    return new Promise((resolve, reject) => {
+      // 1.3 上传到 S3
+      uni.request({
+        url: presignedInfo.uploadUrl,
+        method: 'PUT',
+        header: {
+          'Content-Type': mimeType,
+        },
+        data: fileBuffer,
+        success: () => {
+          // 1.4. 记录文件信息到后端(异步)
+          createFileRecord(presignedInfo, { name: fileName, type: mimeType })
+          // 1.5 返回文件访问 URL
+          resolve(presignedInfo.url)
+        },
+        fail: (err) => {
+          console.error('上传到S3失败:', err, presignedInfo)
+          reject(err)
+        },
+      })
+    })
+  } else {
+    // 情况二:后端上传
+    return FileApi.uploadFile(filePath, directory)
+  }
+}
+
+/** 根据文件名获取 MIME 类型 */
+function getMimeType(fileName: string): string {
+  const ext = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase()
+  const mimeTypes: Record<string, string> = {
+    jpg: 'image/jpeg',
+    jpeg: 'image/jpeg',
+    png: 'image/png',
+    gif: 'image/gif',
+    webp: 'image/webp',
+    bmp: 'image/bmp',
+    svg: 'image/svg+xml',
+    mp4: 'video/mp4',
+    mov: 'video/quicktime',
+    avi: 'video/x-msvideo',
+    pdf: 'application/pdf',
+    doc: 'application/msword',
+    docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+    xls: 'application/vnd.ms-excel',
+    xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+  }
+  return mimeTypes[ext] || 'application/octet-stream'
 }
 
 export interface UploadOptions {
@@ -62,7 +145,7 @@ export interface UploadOptions {
 }
 
 /**
- * 文件上传钩子函数
+ * 文件上传钩子函数(带 formData)
  * @template T 上传成功后返回的数据类型
  * @param url 上传地址
  * @param formData 额外的表单数据
@@ -249,7 +332,7 @@ interface UploadFileOptions<T> {
 }
 
 /**
- * 执行文件上传
+ * 执行文件上传(带 formData)
  * @template T 上传成功后返回的数据类型
  * @param options 上传选项
  */
@@ -288,8 +371,7 @@ function uploadFile<T>({
           // 上传成功
           data.value = _data as T
           onSuccess?.(_data)
-        }
-        catch (err) {
+        } catch (err) {
           // 响应解析错误
           console.error('解析上传响应失败:', err)
           error.value = true
@@ -314,8 +396,7 @@ function uploadFile<T>({
       progress.value = res.progress
       onProgress?.(res.progress)
     })
-  }
-  catch (err) {
+  } catch (err) {
     // 创建上传任务失败
     console.error('创建上传任务失败:', err)
     error.value = true

+ 43 - 0
src/utils/url.ts

@@ -0,0 +1,43 @@
+/**
+ * 解析 URL 查询参数
+ * @param url URL 字符串
+ * @returns { path: 路径, query: 参数对象 }
+ */
+export function parseUrl(url: string): { path: string, query: Record<string, string> } {
+  const [path, queryString] = url.split('?')
+  const query: Record<string, string> = {}
+  if (queryString) {
+    queryString.split('&').forEach((param) => {
+      const [key, value] = param.split('=')
+      if (key) {
+        query[key] = decodeURIComponent(value || '')
+      }
+    })
+  }
+  return { path, query }
+}
+
+/**
+ * 设置 tabBar 页面跳转参数(通过 globalData 传递)
+ * @param params 参数对象
+ */
+export function setTabParams(params: Record<string, string>) {
+  const app = getApp()
+  if (app) {
+    app.globalData = app.globalData || {}
+    app.globalData.tabParams = params
+  }
+}
+
+/**
+ * 获取并清除 tabBar 页面跳转参数
+ * @returns 参数对象,如果没有则返回 undefined
+ */
+export function getAndClearTabParams(): Record<string, string> | undefined {
+  const app = getApp()
+  const tabParams = app?.globalData?.tabParams
+  if (tabParams) {
+    delete app.globalData.tabParams
+  }
+  return tabParams
+}

+ 41 - 0
src/utils/validator.ts

@@ -0,0 +1,41 @@
+/** 手机号正则表达式(中国) */
+const MOBILE_REGEX = /^1[3-9]\d{9}$/
+
+/** 邮箱正则表达式 */
+const EMAIL_REGEX = /^[\w-]+(?:\.[\w-]+)*@[\w-]+(?:\.[\w-]+)+$/
+
+/**
+ * 判断字符串是否为空白(null、undefined、空字符串或仅包含空白字符)
+ *
+ * @param value 值
+ * @returns 是否为空白
+ */
+export function isBlank(value?: null | string): boolean {
+  return !value || value.trim().length === 0
+}
+
+/**
+ * 验证是否为手机号码(中国)
+ *
+ * @param value 值
+ * @returns 是否为手机号码(中国)
+ */
+export function isMobile(value?: null | string): boolean {
+  if (!value) {
+    return false
+  }
+  return MOBILE_REGEX.test(value)
+}
+
+/**
+ * 验证是否为邮箱
+ *
+ * @param value 值
+ * @returns 是否为邮箱
+ */
+export function isEmail(value?: null | string): boolean {
+  if (!value) {
+    return false
+  }
+  return EMAIL_REGEX.test(value)
+}