feat:工具类的迁移
feat:hooks 的迁移(字典、权限) feat:store 的迁移(字典、用户信息)
This commit is contained in:
41
src/hooks/useAccess.ts
Normal file
41
src/hooks/useAccess.ts
Normal file
@@ -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
src/hooks/useDict.ts
Normal file
132
src/hooks/useDict.ts
Normal file
@@ -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
src/store/dict.ts
Normal file
86
src/store/dict.ts
Normal file
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -16,5 +16,7 @@ setActivePinia(store)
|
||||
export default store
|
||||
|
||||
// 模块统一导出
|
||||
export * from './dict'
|
||||
export * from './theme'
|
||||
export * from './token'
|
||||
export * from './user'
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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
src/utils/constants.ts
Normal file
3
src/utils/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './constants/biz-infra-enum'
|
||||
export * from './constants/biz-system-enum'
|
||||
export * from './constants/dict-enum'
|
||||
26
src/utils/constants/biz-infra-enum.ts
Normal file
26
src/utils/constants/biz-infra-enum.ts
Normal file
@@ -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
src/utils/constants/biz-system-enum.ts
Normal file
59
src/utils/constants/biz-system-enum.ts
Normal file
@@ -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
src/utils/constants/dict-enum.ts
Normal file
60
src/utils/constants/dict-enum.ts
Normal file
@@ -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
src/utils/date.ts
Normal file
85
src/utils/date.ts
Normal file
@@ -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
src/utils/download.ts
Normal file
110
src/utils/download.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 +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
src/utils/tree.ts
Normal file
101
src/utils/tree.ts
Normal file
@@ -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 []
|
||||
}
|
||||
@@ -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 配置
|
||||
*/
|
||||
|
||||
/**
|
||||
* 上传文件的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<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,
|
||||
)
|
||||
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 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<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
src/utils/url.ts
Normal file
43
src/utils/url.ts
Normal file
@@ -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
src/utils/validator.ts
Normal file
41
src/utils/validator.ts
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user