From 75cf29263b75a63fef1315383b3cb3a695e0bee0 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Fri, 12 Dec 2025 19:15:49 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=B7=A5=E5=85=B7=E7=B1=BB?= =?UTF-8?q?=E7=9A=84=E8=BF=81=E7=A7=BB=20feat=EF=BC=9Ahooks=20=E7=9A=84?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=EF=BC=88=E5=AD=97=E5=85=B8=E3=80=81=E6=9D=83?= =?UTF-8?q?=E9=99=90=EF=BC=89=20feat=EF=BC=9Astore=20=E7=9A=84=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=EF=BC=88=E5=AD=97=E5=85=B8=E3=80=81=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useAccess.ts | 41 +++++++ src/hooks/useDict.ts | 132 ++++++++++++++++++++ src/store/dict.ts | 86 +++++++++++++ src/store/index.ts | 2 + src/store/token.ts | 85 +++++++++---- src/store/user.ts | 58 ++++++--- src/utils/constants.ts | 3 + src/utils/constants/biz-infra-enum.ts | 26 ++++ src/utils/constants/biz-system-enum.ts | 59 +++++++++ src/utils/constants/dict-enum.ts | 60 +++++++++ src/utils/date.ts | 85 +++++++++++++ src/utils/download.ts | 110 +++++++++++++++++ src/utils/index.ts | 42 ++++++- src/utils/toLoginPage.ts | 4 +- src/utils/tree.ts | 101 +++++++++++++++ src/utils/uploadFile.ts | 163 ++++++++++++++++++------- src/utils/url.ts | 43 +++++++ src/utils/validator.ts | 41 +++++++ 18 files changed, 1057 insertions(+), 84 deletions(-) create mode 100644 src/hooks/useAccess.ts create mode 100644 src/hooks/useDict.ts create mode 100644 src/store/dict.ts create mode 100644 src/utils/constants.ts create mode 100644 src/utils/constants/biz-infra-enum.ts create mode 100644 src/utils/constants/biz-system-enum.ts create mode 100644 src/utils/constants/dict-enum.ts create mode 100644 src/utils/date.ts create mode 100644 src/utils/download.ts create mode 100644 src/utils/tree.ts create mode 100644 src/utils/url.ts create mode 100644 src/utils/validator.ts diff --git a/src/hooks/useAccess.ts b/src/hooks/useAccess.ts new file mode 100644 index 0000000..6173fd6 --- /dev/null +++ b/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 diff --git a/src/hooks/useDict.ts b/src/hooks/useDict.ts new file mode 100644 index 0000000..2d5273f --- /dev/null +++ b/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 +} diff --git a/src/store/dict.ts b/src/store/dict.ts new file mode 100644 index 0000000..5aecb0f --- /dev/null +++ b/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 + +export const useDictStore = defineStore( + 'dict', + () => { + const dictCache = ref({}) // 字典缓存 + 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, + }, +) diff --git a/src/store/index.ts b/src/store/index.ts index fe2ead1..c8aa23e 100644 --- a/src/store/index.ts +++ b/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' diff --git a/src/store/token.ts b/src/store/token.ts index 363dc2b..eb4a2dd 100644 --- a/src/store/token.ts +++ b/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 }) diff --git a/src/store/user.ts b/src/store/user.ts index 3f6d693..651d080 100644 --- a/src/store/user.ts +++ b/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({ ...userInfoState }) - // 设置用户信息 - const setUserInfo = (val: IUserInfoRes) => { - console.log('设置用户信息', val) + const tenantId = ref(null) // 租户编号 + const roles = ref([]) // 角色标识列表 + const permissions = ref([]) // 权限标识列表 + const favoriteMenus = ref([]) // 常用菜单 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, } }, { diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000..42648fd --- /dev/null +++ b/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' diff --git a/src/utils/constants/biz-infra-enum.ts b/src/utils/constants/biz-infra-enum.ts new file mode 100644 index 0000000..32d4d94 --- /dev/null +++ b/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, // 已忽略 +} diff --git a/src/utils/constants/biz-system-enum.ts b/src/utils/constants/biz-system-enum.ts new file mode 100644 index 0000000..b40373e --- /dev/null +++ b/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', + }, +} diff --git a/src/utils/constants/dict-enum.ts b/src/utils/constants/dict-enum.ts new file mode 100644 index 0000000..4ac5127 --- /dev/null +++ b/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 diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 0000000..1e876bc --- /dev/null +++ b/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 '刚刚' +} diff --git a/src/utils/download.ts b/src/utils/download.ts new file mode 100644 index 0000000..71119d6 --- /dev/null +++ b/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 { + 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 { + 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 + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 89d14c7..e70b9c0 100644 --- a/src/utils/index.ts +++ b/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 & { $page: Page.PageInstance & { 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() + } +} diff --git a/src/utils/toLoginPage.ts b/src/utils/toLoginPage.ts index 4393115..f979e2e 100644 --- a/src/utils/toLoginPage.ts +++ b/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' - /** * 跳转到登录页, 带防抖处理 * diff --git a/src/utils/tree.ts b/src/utils/tree.ts new file mode 100644 index 0000000..67b467d --- /dev/null +++ b/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( + data: T[], + id = 'id', + parentId = 'parentId', + children = 'children', +): T[] { + if (!Array.isArray(data)) { + console.warn('data must be an array') + return [] + } + + const nodeMap: Record = {} + const childrenListMap: Record = {} + 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( + 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 [] +} diff --git a/src/utils/uploadFile.ts b/src/utils/uploadFile.ts index 71848a2..7393989 100644 --- a/src/utils/uploadFile.ts +++ b/src/utils/uploadFile.ts @@ -1,45 +1,128 @@ /** - * 文件上传钩子函数使用示例 - * @example - * const { loading, error, data, progress, run } = useUpload( - * 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 配置 */ -/** - * 上传文件的URL配置 - */ -export const uploadFileUrl = { - /** 用户头像上传地址 */ - USER_AVATAR: `${import.meta.env.VITE_SERVER_BASEURL}/user/avatar`, +import * as FileApi from '@/api/infra/file' + +/** 上传类型 */ +const UPLOAD_TYPE = { + /** 客户端直接上传(只支持S3服务) */ + CLIENT: 'client', + /** 客户端发送到后端上传 */ + SERVER: 'server', } /** - * 通用文件上传函数(支持直接传入文件路径) - * @param url 上传地址 - * @param filePath 本地文件路径 - * @param formData 额外表单数据 - * @param options 上传选项 + * 读取文件二进制内容 + * @param uniFile 文件对象 */ -export function useFileUpload(url: string, filePath: string, formData: Record = {}, options: Omit = {}) { - return useUpload( - url, - formData, - { - ...options, - sourceType: ['album'], - sizeType: ['original'], - }, - filePath, - ) +async function readFile(uniFile: { path: string, arrayBuffer?: () => Promise }): Promise { + // 微信小程序 + 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 presignedInfo 预签名信息 + * @param file 文件信息 + */ +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 { + 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 = { + 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 { } /** - * 执行文件上传 + * 执行文件上传(带 formData) * @template T 上传成功后返回的数据类型 * @param options 上传选项 */ @@ -288,8 +371,7 @@ function uploadFile({ // 上传成功 data.value = _data as T onSuccess?.(_data) - } - catch (err) { + } catch (err) { // 响应解析错误 console.error('解析上传响应失败:', err) error.value = true @@ -314,8 +396,7 @@ function uploadFile({ progress.value = res.progress onProgress?.(res.progress) }) - } - catch (err) { + } catch (err) { // 创建上传任务失败 console.error('创建上传任务失败:', err) error.value = true diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 0000000..59ae04b --- /dev/null +++ b/src/utils/url.ts @@ -0,0 +1,43 @@ +/** + * 解析 URL 查询参数 + * @param url URL 字符串 + * @returns { path: 路径, query: 参数对象 } + */ +export function parseUrl(url: string): { path: string, query: Record } { + const [path, queryString] = url.split('?') + const query: Record = {} + 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) { + const app = getApp() + if (app) { + app.globalData = app.globalData || {} + app.globalData.tabParams = params + } +} + +/** + * 获取并清除 tabBar 页面跳转参数 + * @returns 参数对象,如果没有则返回 undefined + */ +export function getAndClearTabParams(): Record | undefined { + const app = getApp() + const tabParams = app?.globalData?.tabParams + if (tabParams) { + delete app.globalData.tabParams + } + return tabParams +} \ No newline at end of file diff --git a/src/utils/validator.ts b/src/utils/validator.ts new file mode 100644 index 0000000..1a35beb --- /dev/null +++ b/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) +}