初始化
This commit is contained in:
4
src/utils/constants.ts
Normal file
4
src/utils/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './constants/biz-bpm-enum'
|
||||
export * from './constants/biz-infra-enum'
|
||||
export * from './constants/biz-system-enum'
|
||||
export * from './constants/dict-enum'
|
||||
306
src/utils/constants/biz-bpm-enum.ts
Normal file
306
src/utils/constants/biz-bpm-enum.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
// 候选人策略枚举 ( 用于审批节点。抄送节点 )
|
||||
export enum BpmCandidateStrategyEnum {
|
||||
/**
|
||||
* 审批人自选
|
||||
*/
|
||||
APPROVE_USER_SELECT = 34,
|
||||
/**
|
||||
* 部门的负责人
|
||||
*/
|
||||
DEPT_LEADER = 21,
|
||||
/**
|
||||
* 部门成员
|
||||
*/
|
||||
DEPT_MEMBER = 20,
|
||||
/**
|
||||
* 流程表达式
|
||||
*/
|
||||
EXPRESSION = 60,
|
||||
/**
|
||||
* 表单内部门负责人
|
||||
*/
|
||||
FORM_DEPT_LEADER = 51,
|
||||
/**
|
||||
* 表单内用户字段
|
||||
*/
|
||||
FORM_USER = 50,
|
||||
/**
|
||||
* 连续多级部门的负责人
|
||||
*/
|
||||
MULTI_LEVEL_DEPT_LEADER = 23,
|
||||
/**
|
||||
* 指定岗位
|
||||
*/
|
||||
POST = 22,
|
||||
/**
|
||||
* 指定角色
|
||||
*/
|
||||
ROLE = 10,
|
||||
/**
|
||||
* 发起人自己
|
||||
*/
|
||||
START_USER = 36,
|
||||
/**
|
||||
* 发起人部门负责人
|
||||
*/
|
||||
START_USER_DEPT_LEADER = 37,
|
||||
/**
|
||||
* 发起人连续多级部门的负责人
|
||||
*/
|
||||
START_USER_MULTI_LEVEL_DEPT_LEADER = 38,
|
||||
/**
|
||||
* 发起人自选
|
||||
*/
|
||||
START_USER_SELECT = 35,
|
||||
/**
|
||||
* 指定用户
|
||||
*/
|
||||
USER = 30,
|
||||
/**
|
||||
* 指定用户组
|
||||
*/
|
||||
USER_GROUP = 40,
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点类型
|
||||
*/
|
||||
export enum BpmNodeTypeEnum {
|
||||
/**
|
||||
* 子流程节点
|
||||
*/
|
||||
CHILD_PROCESS_NODE = 20,
|
||||
/**
|
||||
* 条件分支节点 (对应排他网关)
|
||||
*/
|
||||
CONDITION_BRANCH_NODE = 51,
|
||||
/**
|
||||
* 条件节点
|
||||
*/
|
||||
CONDITION_NODE = 50,
|
||||
|
||||
/**
|
||||
* 抄送人节点
|
||||
*/
|
||||
COPY_TASK_NODE = 12,
|
||||
|
||||
/**
|
||||
* 延迟器节点
|
||||
*/
|
||||
DELAY_TIMER_NODE = 14,
|
||||
|
||||
/**
|
||||
* 结束节点
|
||||
*/
|
||||
END_EVENT_NODE = 1,
|
||||
|
||||
/**
|
||||
* 包容分支节点 (对应包容网关)
|
||||
*/
|
||||
INCLUSIVE_BRANCH_NODE = 53,
|
||||
|
||||
/**
|
||||
* 并行分支节点 (对应并行网关)
|
||||
*/
|
||||
PARALLEL_BRANCH_NODE = 52,
|
||||
|
||||
/**
|
||||
* 路由分支节点
|
||||
*/
|
||||
ROUTER_BRANCH_NODE = 54,
|
||||
/**
|
||||
* 发起人节点
|
||||
*/
|
||||
START_USER_NODE = 10,
|
||||
/**
|
||||
* 办理人节点
|
||||
*/
|
||||
TRANSACTOR_NODE = 13,
|
||||
|
||||
/**
|
||||
* 触发器节点
|
||||
*/
|
||||
TRIGGER_NODE = 15,
|
||||
/**
|
||||
* 审批人节点
|
||||
*/
|
||||
USER_TASK_NODE = 11,
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程任务操作按钮
|
||||
*/
|
||||
export enum BpmTaskOperationButtonTypeEnum {
|
||||
/**
|
||||
* 加签
|
||||
*/
|
||||
ADD_SIGN = 5,
|
||||
/**
|
||||
* 通过
|
||||
*/
|
||||
APPROVE = 1,
|
||||
/**
|
||||
* 抄送
|
||||
*/
|
||||
COPY = 7,
|
||||
/**
|
||||
* 委派
|
||||
*/
|
||||
DELEGATE = 4,
|
||||
/**
|
||||
* 拒绝
|
||||
*/
|
||||
REJECT = 2,
|
||||
/**
|
||||
* 退回
|
||||
*/
|
||||
RETURN = 6,
|
||||
/**
|
||||
* 转办
|
||||
*/
|
||||
TRANSFER = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务状态枚举
|
||||
*/
|
||||
export enum BpmTaskStatusEnum {
|
||||
/**
|
||||
* 审批通过
|
||||
*/
|
||||
APPROVE = 2,
|
||||
/**
|
||||
* 审批通过中
|
||||
*/
|
||||
APPROVING = 7,
|
||||
|
||||
/**
|
||||
* 已取消
|
||||
*/
|
||||
CANCEL = 4,
|
||||
/**
|
||||
* 未开始
|
||||
*/
|
||||
NOT_START = -1,
|
||||
/**
|
||||
* 审批不通过
|
||||
*/
|
||||
REJECT = 3,
|
||||
|
||||
/**
|
||||
* 已退回
|
||||
*/
|
||||
RETURN = 5,
|
||||
|
||||
/**
|
||||
* 审批中
|
||||
*/
|
||||
RUNNING = 1,
|
||||
/**
|
||||
* 跳过
|
||||
*/
|
||||
SKIP = -2,
|
||||
/**
|
||||
* 待审批
|
||||
*/
|
||||
WAIT = 0,
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点 Id 枚举
|
||||
*/
|
||||
export enum BpmNodeIdEnum {
|
||||
/**
|
||||
* 发起人节点 Id
|
||||
*/
|
||||
END_EVENT_NODE_ID = 'EndEvent',
|
||||
|
||||
/**
|
||||
* 发起人节点 Id
|
||||
*/
|
||||
START_USER_NODE_ID = 'StartUserNode',
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单权限的枚举
|
||||
*/
|
||||
export enum BpmFieldPermissionType {
|
||||
/**
|
||||
* 隐藏
|
||||
*/
|
||||
NONE = '3',
|
||||
/**
|
||||
* 只读
|
||||
*/
|
||||
READ = '1',
|
||||
/**
|
||||
* 编辑
|
||||
*/
|
||||
WRITE = '2',
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程模型类型
|
||||
*/
|
||||
export const BpmModelType = {
|
||||
BPMN: 10, // BPMN 设计器
|
||||
SIMPLE: 20, // 简易设计器
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程模型表单类型
|
||||
*/
|
||||
export const BpmModelFormType = {
|
||||
NORMAL: 10, // 流程表单
|
||||
CUSTOM: 20, // 业务表单
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程实例状态
|
||||
*/
|
||||
export const BpmProcessInstanceStatus = {
|
||||
NOT_START: -1, // 未开始
|
||||
RUNNING: 1, // 审批中
|
||||
APPROVE: 2, // 审批通过
|
||||
REJECT: 3, // 审批不通过
|
||||
CANCEL: 4, // 已取消
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动审批类型
|
||||
*/
|
||||
export const BpmAutoApproveType = {
|
||||
NONE: 0, // 不自动通过
|
||||
APPROVE_ALL: 1, // 仅审批一次,后续重复的审批节点均自动通过
|
||||
APPROVE_SEQUENT: 2, // 仅针对连续审批的节点自动通过
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批操作按钮名称
|
||||
*/
|
||||
export const OPERATION_BUTTON_NAME = new Map<number, string>()
|
||||
OPERATION_BUTTON_NAME.set(BpmTaskOperationButtonTypeEnum.APPROVE, '通过')
|
||||
OPERATION_BUTTON_NAME.set(BpmTaskOperationButtonTypeEnum.REJECT, '拒绝')
|
||||
OPERATION_BUTTON_NAME.set(BpmTaskOperationButtonTypeEnum.TRANSFER, '转办')
|
||||
OPERATION_BUTTON_NAME.set(BpmTaskOperationButtonTypeEnum.DELEGATE, '委派')
|
||||
OPERATION_BUTTON_NAME.set(BpmTaskOperationButtonTypeEnum.ADD_SIGN, '加签')
|
||||
OPERATION_BUTTON_NAME.set(BpmTaskOperationButtonTypeEnum.RETURN, '退回')
|
||||
OPERATION_BUTTON_NAME.set(BpmTaskOperationButtonTypeEnum.COPY, '抄送')
|
||||
|
||||
/**
|
||||
* 流程实例的变量枚举
|
||||
*/
|
||||
export enum ProcessVariableEnum {
|
||||
/**
|
||||
* 流程定义名称
|
||||
*/
|
||||
PROCESS_DEFINITION_NAME = 'PROCESS_DEFINITION_NAME',
|
||||
/**
|
||||
* 发起时间
|
||||
*/
|
||||
START_TIME = 'PROCESS_START_TIME',
|
||||
/**
|
||||
* 发起用户 ID
|
||||
*/
|
||||
START_USER_ID = 'PROCESS_START_USER_ID',
|
||||
}
|
||||
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',
|
||||
},
|
||||
}
|
||||
67
src/utils/constants/dict-enum.ts
Normal file
67
src/utils/constants/dict-enum.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/** ========== 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',
|
||||
SYSTEM_DICT_COLOR_TYPE: 'system_dict_color_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
|
||||
|
||||
/** ========== CAR - 车辆模块 ========== */
|
||||
const CAR_DICT = {
|
||||
CAR_RENEWAL_PRODUCT_TYPE: 'car_renewal_product_type', // 续保产品类别
|
||||
} as const
|
||||
|
||||
/** 字典类型枚举 - 统一导出 */
|
||||
export const DICT_TYPE = {
|
||||
...BPM_DICT,
|
||||
...INFRA_DICT,
|
||||
...SYSTEM_DICT,
|
||||
...COMMON_DICT,
|
||||
...CAR_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 '刚刚'
|
||||
}
|
||||
166
src/utils/debounce.ts
Normal file
166
src/utils/debounce.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
// fork from https://github.com/toss/es-toolkit/blob/main/src/function/debounce.ts
|
||||
// 文档可查看:https://es-toolkit.dev/reference/function/debounce.html
|
||||
// 如需要 throttle 功能,可 copy https://github.com/toss/es-toolkit/blob/main/src/function/throttle.ts
|
||||
|
||||
interface DebounceOptions {
|
||||
/**
|
||||
* An optional AbortSignal to cancel the debounced function.
|
||||
*/
|
||||
signal?: AbortSignal
|
||||
|
||||
/**
|
||||
* An optional array specifying whether the function should be invoked on the leading edge, trailing edge, or both.
|
||||
* If `edges` includes "leading", the function will be invoked at the start of the delay period.
|
||||
* If `edges` includes "trailing", the function will be invoked at the end of the delay period.
|
||||
* If both "leading" and "trailing" are included, the function will be invoked at both the start and end of the delay period.
|
||||
* @default ["trailing"]
|
||||
*/
|
||||
edges?: Array<'leading' | 'trailing'>
|
||||
}
|
||||
|
||||
export interface DebouncedFunction<F extends (...args: any[]) => void> {
|
||||
(...args: Parameters<F>): void
|
||||
|
||||
/**
|
||||
* Schedules the execution of the debounced function after the specified debounce delay.
|
||||
* This method resets any existing timer, ensuring that the function is only invoked
|
||||
* after the delay has elapsed since the last call to the debounced function.
|
||||
* It is typically called internally whenever the debounced function is invoked.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
schedule: () => void
|
||||
|
||||
/**
|
||||
* Cancels any pending execution of the debounced function.
|
||||
* This method clears the active timer and resets any stored context or arguments.
|
||||
*/
|
||||
cancel: () => void
|
||||
|
||||
/**
|
||||
* Immediately invokes the debounced function if there is a pending execution.
|
||||
* This method executes the function right away if there is a pending execution.
|
||||
*/
|
||||
flush: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a debounced function that delays invoking the provided function until after `debounceMs` milliseconds
|
||||
* have elapsed since the last time the debounced function was invoked. The debounced function also has a `cancel`
|
||||
* method to cancel any pending execution.
|
||||
*
|
||||
* @template F - The type of function.
|
||||
* @param {F} func - The function to debounce.
|
||||
* @param {number} debounceMs - The number of milliseconds to delay.
|
||||
* @param {DebounceOptions} options - The options object
|
||||
* @param {AbortSignal} options.signal - An optional AbortSignal to cancel the debounced function.
|
||||
* @returns A new debounced function with a `cancel` method.
|
||||
*
|
||||
* @example
|
||||
* const debouncedFunction = debounce(() => {
|
||||
* console.log('Function executed');
|
||||
* }, 1000);
|
||||
*
|
||||
* // Will log 'Function executed' after 1 second if not called again in that time
|
||||
* debouncedFunction();
|
||||
*
|
||||
* // Will not log anything as the previous call is canceled
|
||||
* debouncedFunction.cancel();
|
||||
*
|
||||
* // With AbortSignal
|
||||
* const controller = new AbortController();
|
||||
* const signal = controller.signal;
|
||||
* const debouncedWithSignal = debounce(() => {
|
||||
* console.log('Function executed');
|
||||
* }, 1000, { signal });
|
||||
*
|
||||
* debouncedWithSignal();
|
||||
*
|
||||
* // Will cancel the debounced function call
|
||||
* controller.abort();
|
||||
*/
|
||||
export function debounce<F extends (...args: any[]) => void>(
|
||||
func: F,
|
||||
debounceMs: number,
|
||||
{ signal, edges }: DebounceOptions = {},
|
||||
): DebouncedFunction<F> {
|
||||
let pendingThis: any
|
||||
let pendingArgs: Parameters<F> | null = null
|
||||
|
||||
const leading = edges != null && edges.includes('leading')
|
||||
const trailing = edges == null || edges.includes('trailing')
|
||||
|
||||
const invoke = () => {
|
||||
if (pendingArgs !== null) {
|
||||
func.apply(pendingThis, pendingArgs)
|
||||
pendingThis = undefined
|
||||
pendingArgs = null
|
||||
}
|
||||
}
|
||||
|
||||
const onTimerEnd = () => {
|
||||
if (trailing) {
|
||||
invoke()
|
||||
}
|
||||
|
||||
// eslint-disable-next-line ts/no-use-before-define
|
||||
cancel()
|
||||
}
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const schedule = () => {
|
||||
if (timeoutId != null) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = null
|
||||
|
||||
onTimerEnd()
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
const cancelTimer = () => {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
cancelTimer()
|
||||
pendingThis = undefined
|
||||
pendingArgs = null
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
invoke()
|
||||
}
|
||||
|
||||
const debounced = function (this: any, ...args: Parameters<F>) {
|
||||
if (signal?.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
// eslint-disable-next-line ts/no-this-alias
|
||||
pendingThis = this
|
||||
pendingArgs = args
|
||||
|
||||
const isFirstCall = timeoutId == null
|
||||
|
||||
schedule()
|
||||
|
||||
if (leading && isFirstCall) {
|
||||
invoke()
|
||||
}
|
||||
}
|
||||
|
||||
debounced.schedule = schedule
|
||||
debounced.cancel = cancel
|
||||
debounced.flush = flush
|
||||
|
||||
signal?.addEventListener('abort', cancel, { once: true })
|
||||
|
||||
return debounced
|
||||
}
|
||||
137
src/utils/download.ts
Normal file
137
src/utils/download.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 下载工具类 - 支持多端(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
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
export function formatFileSize(size?: number): string {
|
||||
if (!size) {
|
||||
return '-'
|
||||
}
|
||||
if (size < 1024) {
|
||||
return `${size} B`
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(2)} KB`
|
||||
}
|
||||
if (size < 1024 * 1024 * 1024) {
|
||||
return `${(size / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取静态资源完整 URL 地址
|
||||
* @param path 资源路径
|
||||
* @returns 完整的静态资源 URL 地址
|
||||
*/
|
||||
export function staticUrl(path: string): string {
|
||||
const baseUrl = import.meta.env.VITE_STATIC_BASEURL || ''
|
||||
// 确保 path 以 / 开头
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
return `${baseUrl}${normalizedPath}`
|
||||
}
|
||||
231
src/utils/encrypt.ts
Normal file
231
src/utils/encrypt.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { JSEncrypt } from 'jsencrypt'
|
||||
|
||||
/**
|
||||
* API 加解密工具类
|
||||
* 支持 AES 和 RSA 加密算法
|
||||
*/
|
||||
|
||||
// 从环境变量获取配置
|
||||
const API_ENCRYPT_ENABLE = import.meta.env.VITE_APP_API_ENCRYPT_ENABLE === 'true'
|
||||
const API_ENCRYPT_HEADER = import.meta.env.VITE_APP_API_ENCRYPT_HEADER || 'X-Api-Encrypt'
|
||||
const API_ENCRYPT_ALGORITHM = import.meta.env.VITE_APP_API_ENCRYPT_ALGORITHM || 'AES'
|
||||
const API_ENCRYPT_REQUEST_KEY = import.meta.env.VITE_APP_API_ENCRYPT_REQUEST_KEY || '' // AES密钥 或 RSA公钥
|
||||
const API_ENCRYPT_RESPONSE_KEY = import.meta.env.VITE_APP_API_ENCRYPT_RESPONSE_KEY || '' // AES密钥 或 RSA私钥
|
||||
|
||||
/**
|
||||
* AES 加密工具类
|
||||
*/
|
||||
export class AES {
|
||||
/**
|
||||
* AES 加密
|
||||
* @param data 要加密的数据
|
||||
* @param key 加密密钥
|
||||
* @returns 加密后的字符串
|
||||
*/
|
||||
static encrypt(data: string, key: string): string {
|
||||
try {
|
||||
if (!key) {
|
||||
throw new Error('AES 加密密钥不能为空')
|
||||
}
|
||||
if (key.length !== 32) {
|
||||
throw new Error(`AES 加密密钥长度必须为 32 位,当前长度: ${key.length}`)
|
||||
}
|
||||
|
||||
const keyUtf8 = CryptoJS.enc.Utf8.parse(key)
|
||||
const encrypted = CryptoJS.AES.encrypt(data, keyUtf8, {
|
||||
mode: CryptoJS.mode.ECB,
|
||||
padding: CryptoJS.pad.Pkcs7,
|
||||
})
|
||||
return encrypted.toString()
|
||||
} catch (error) {
|
||||
console.error('AES 加密失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AES 解密
|
||||
* @param encryptedData 加密的数据
|
||||
* @param key 解密密钥
|
||||
* @returns 解密后的字符串
|
||||
*/
|
||||
static decrypt(encryptedData: string, key: string): string {
|
||||
try {
|
||||
if (!key) {
|
||||
throw new Error('AES 解密密钥不能为空')
|
||||
}
|
||||
if (key.length !== 32) {
|
||||
throw new Error(`AES 解密密钥长度必须为 32 位,当前长度: ${key.length}`)
|
||||
}
|
||||
if (!encryptedData) {
|
||||
throw new Error('AES 解密数据不能为空')
|
||||
}
|
||||
|
||||
const keyUtf8 = CryptoJS.enc.Utf8.parse(key)
|
||||
const decrypted = CryptoJS.AES.decrypt(encryptedData, keyUtf8, {
|
||||
mode: CryptoJS.mode.ECB,
|
||||
padding: CryptoJS.pad.Pkcs7,
|
||||
})
|
||||
const result = decrypted.toString(CryptoJS.enc.Utf8)
|
||||
if (!result) {
|
||||
throw new Error('AES 解密结果为空,可能是密钥错误或数据损坏')
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('AES 解密失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA 加密工具类
|
||||
*/
|
||||
export class RSA {
|
||||
/**
|
||||
* RSA 加密
|
||||
* @param data 要加密的数据
|
||||
* @param publicKey 公钥(必需)
|
||||
* @returns 加密后的字符串
|
||||
*/
|
||||
static encrypt(data: string, publicKey: string): string | false {
|
||||
try {
|
||||
if (!publicKey) {
|
||||
throw new Error('RSA 公钥不能为空')
|
||||
}
|
||||
|
||||
const encryptor = new JSEncrypt()
|
||||
encryptor.setPublicKey(publicKey)
|
||||
const result = encryptor.encrypt(data)
|
||||
if (result === false) {
|
||||
throw new Error('RSA 加密失败,可能是公钥格式错误或数据过长')
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('RSA 加密失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA 解密
|
||||
* @param encryptedData 加密的数据
|
||||
* @param privateKey 私钥(必需)
|
||||
* @returns 解密后的字符串
|
||||
*/
|
||||
static decrypt(encryptedData: string, privateKey: string): string | false {
|
||||
try {
|
||||
if (!privateKey) {
|
||||
throw new Error('RSA 私钥不能为空')
|
||||
}
|
||||
if (!encryptedData) {
|
||||
throw new Error('RSA 解密数据不能为空')
|
||||
}
|
||||
|
||||
const encryptor = new JSEncrypt()
|
||||
encryptor.setPrivateKey(privateKey)
|
||||
const result = encryptor.decrypt(encryptedData)
|
||||
if (result === false) {
|
||||
throw new Error('RSA 解密失败,可能是私钥错误或数据损坏')
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('RSA 解密失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 加解密主类
|
||||
*/
|
||||
export class ApiEncrypt {
|
||||
/**
|
||||
* 获取加密头名称
|
||||
*/
|
||||
static getEncryptHeader(): string {
|
||||
return API_ENCRYPT_HEADER
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密请求数据
|
||||
* @param data 要加密的数据
|
||||
* @returns 加密后的数据
|
||||
*/
|
||||
static encryptRequest(data: any): string {
|
||||
if (!API_ENCRYPT_ENABLE) {
|
||||
return data
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonData = typeof data === 'string' ? data : JSON.stringify(data)
|
||||
|
||||
if (API_ENCRYPT_ALGORITHM.toUpperCase() === 'AES') {
|
||||
if (!API_ENCRYPT_REQUEST_KEY) {
|
||||
throw new Error('AES 请求加密密钥未配置')
|
||||
}
|
||||
return AES.encrypt(jsonData, API_ENCRYPT_REQUEST_KEY)
|
||||
} else if (API_ENCRYPT_ALGORITHM.toUpperCase() === 'RSA') {
|
||||
if (!API_ENCRYPT_REQUEST_KEY) {
|
||||
throw new Error('RSA 公钥未配置')
|
||||
}
|
||||
const result = RSA.encrypt(jsonData, API_ENCRYPT_REQUEST_KEY)
|
||||
if (result === false) {
|
||||
throw new Error('RSA 加密失败')
|
||||
}
|
||||
return result
|
||||
} else {
|
||||
throw new Error(`不支持的加密算法: ${API_ENCRYPT_ALGORITHM}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('请求数据加密失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密响应数据
|
||||
* @param encryptedData 加密的响应数据
|
||||
* @returns 解密后的数据
|
||||
*/
|
||||
static decryptResponse(encryptedData: string): any {
|
||||
if (!API_ENCRYPT_ENABLE) {
|
||||
return encryptedData
|
||||
}
|
||||
|
||||
try {
|
||||
let decryptedData: string | false = ''
|
||||
if (API_ENCRYPT_ALGORITHM.toUpperCase() === 'AES') {
|
||||
if (!API_ENCRYPT_RESPONSE_KEY) {
|
||||
throw new Error('AES 响应解密密钥未配置')
|
||||
}
|
||||
decryptedData = AES.decrypt(encryptedData, API_ENCRYPT_RESPONSE_KEY)
|
||||
} else if (API_ENCRYPT_ALGORITHM.toUpperCase() === 'RSA') {
|
||||
if (!API_ENCRYPT_RESPONSE_KEY) {
|
||||
throw new Error('RSA 私钥未配置')
|
||||
}
|
||||
decryptedData = RSA.decrypt(encryptedData, API_ENCRYPT_RESPONSE_KEY)
|
||||
if (decryptedData === false) {
|
||||
throw new Error('RSA 解密失败')
|
||||
}
|
||||
} else {
|
||||
throw new Error(`不支持的解密算法: ${API_ENCRYPT_ALGORITHM}`)
|
||||
}
|
||||
|
||||
if (!decryptedData) {
|
||||
throw new Error('解密结果为空')
|
||||
}
|
||||
|
||||
// 尝试解析为 JSON,如果失败则返回原字符串
|
||||
try {
|
||||
return JSON.parse(decryptedData)
|
||||
} catch {
|
||||
return decryptedData
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('响应数据解密失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
243
src/utils/index.ts
Normal file
243
src/utils/index.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
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 { isPageTabbar } from '@/tabbar/store'
|
||||
|
||||
export type PageInstance = Page.PageInstance<AnyObject, object> & { $page: Page.PageInstance<AnyObject, object> & { fullPath: string } }
|
||||
|
||||
export function getLastPage() {
|
||||
// getCurrentPages() 至少有1个元素,所以不再额外判断
|
||||
// const lastPage = getCurrentPages().at(-1)
|
||||
// 上面那个在低版本安卓中打包会报错,所以改用下面这个【虽然我加了 src/interceptions/prototype.ts,但依然报错】
|
||||
const pages = getCurrentPages()
|
||||
return pages[pages.length - 1] as PageInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前页面路由的 path 路径和 redirectPath 路径
|
||||
* path 如 '/pages/login/login'
|
||||
* redirectPath 如 '/pages/demo/base/route-interceptor'
|
||||
*/
|
||||
export function currRoute() {
|
||||
const lastPage = getLastPage() as PageInstance
|
||||
if (!lastPage) {
|
||||
return {
|
||||
path: '',
|
||||
query: {},
|
||||
}
|
||||
}
|
||||
const currRoute = lastPage.$page
|
||||
// console.log('lastPage.$page:', currRoute)
|
||||
// console.log('lastPage.$page.fullpath:', currRoute.fullPath)
|
||||
// console.log('lastPage.$page.options:', currRoute.options)
|
||||
// console.log('lastPage.options:', (lastPage as any).options)
|
||||
// 经过多端测试,只有 fullPath 靠谱,其他都不靠谱
|
||||
const { fullPath } = currRoute
|
||||
// console.log(fullPath)
|
||||
// eg: /pages/login/login?redirect=%2Fpages%2Fdemo%2Fbase%2Froute-interceptor (小程序)
|
||||
// eg: /pages/login/login?redirect=%2Fpages%2Froute-interceptor%2Findex%3Fname%3Dfeige%26age%3D30(h5)
|
||||
return parseUrlToObj(fullPath)
|
||||
}
|
||||
|
||||
export function ensureDecodeURIComponent(url: string) {
|
||||
if (url.startsWith('%')) {
|
||||
return ensureDecodeURIComponent(decodeURIComponent(url))
|
||||
}
|
||||
return url
|
||||
}
|
||||
/**
|
||||
* 解析 url 得到 path 和 query
|
||||
* 比如输入url: /pages/login/login?redirect=%2Fpages%2Fdemo%2Fbase%2Froute-interceptor
|
||||
* 输出: {path: /pages/login/login, query: {redirect: /pages/demo/base/route-interceptor}}
|
||||
*/
|
||||
export function parseUrlToObj(url: string) {
|
||||
const [path, queryStr] = url.split('?')
|
||||
// console.log(path, queryStr)
|
||||
|
||||
if (!queryStr) {
|
||||
return {
|
||||
path,
|
||||
query: {},
|
||||
}
|
||||
}
|
||||
const query: Record<string, string> = {}
|
||||
queryStr.split('&').forEach((item) => {
|
||||
const [key, value] = item.split('=')
|
||||
// console.log(key, value)
|
||||
query[key] = ensureDecodeURIComponent(value) // 这里需要统一 decodeURIComponent 一下,可以兼容h5和微信y
|
||||
})
|
||||
return { path, query }
|
||||
}
|
||||
/**
|
||||
* 得到所有的需要登录的 pages,包括主包和分包的
|
||||
* 这里设计得通用一点,可以传递 key 作为判断依据,默认是 excludeLoginPath, 与 route-block 配对使用
|
||||
* 如果没有传 key,则表示所有的 pages,如果传递了 key, 则表示通过 key 过滤
|
||||
*/
|
||||
export function getAllPages(key?: string) {
|
||||
// 这里处理主包
|
||||
const mainPages = (pages as PageMetaDatum[])
|
||||
.filter(page => !key || page[key])
|
||||
.map(page => ({
|
||||
...page,
|
||||
path: `/${page.path}`,
|
||||
}))
|
||||
|
||||
// 这里处理分包
|
||||
const subPages: PageMetaDatum[] = []
|
||||
;(subPackages as SubPackages).forEach((subPageObj) => {
|
||||
// console.log(subPageObj)
|
||||
const { root } = subPageObj
|
||||
|
||||
subPageObj.pages
|
||||
.filter(page => !key || page[key])
|
||||
.forEach((page) => {
|
||||
subPages.push({
|
||||
...page,
|
||||
path: `/${root}/${page.path}`,
|
||||
})
|
||||
})
|
||||
})
|
||||
const result = [...mainPages, ...subPages]
|
||||
// console.log(`getAllPages by ${key} result: `, result)
|
||||
return result
|
||||
}
|
||||
|
||||
export function getCurrentPageI18nKey() {
|
||||
const routeObj = currRoute()
|
||||
const currPage = (pages as PageMetaDatum[]).find(page => `/${page.path}` === routeObj.path)
|
||||
if (!currPage) {
|
||||
console.warn('路由不正确')
|
||||
return ''
|
||||
}
|
||||
console.log(currPage)
|
||||
console.log(currPage.style.navigationBarTitleText)
|
||||
return currPage.style?.navigationBarTitleText || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据微信小程序当前环境,判断应该获取的 baseUrl
|
||||
*/
|
||||
export function getEnvBaseUrl() {
|
||||
// 请求基准地址
|
||||
let baseUrl = import.meta.env.VITE_SERVER_BASEURL
|
||||
|
||||
// # 有些同学可能需要在微信小程序里面根据 develop、trial、release 分别设置上传地址,参考代码如下。
|
||||
// TODO @芋艿:这个后续也要调整。
|
||||
const VITE_SERVER_BASEURL__WEIXIN_DEVELOP = 'http://1.14.158.154:48080/admin-api'
|
||||
const VITE_SERVER_BASEURL__WEIXIN_TRIAL = 'http://1.14.158.154:48080/admin-api'
|
||||
const VITE_SERVER_BASEURL__WEIXIN_RELEASE = 'http://1.14.158.154:48080/admin-api'
|
||||
|
||||
// 微信小程序端环境区分
|
||||
if (isMpWeixin) {
|
||||
const {
|
||||
miniProgram: { envVersion },
|
||||
} = uni.getAccountInfoSync()
|
||||
|
||||
switch (envVersion) {
|
||||
case 'develop':
|
||||
baseUrl = VITE_SERVER_BASEURL__WEIXIN_DEVELOP || baseUrl
|
||||
break
|
||||
case 'trial':
|
||||
baseUrl = VITE_SERVER_BASEURL__WEIXIN_TRIAL || baseUrl
|
||||
break
|
||||
case 'release':
|
||||
baseUrl = VITE_SERVER_BASEURL__WEIXIN_RELEASE || baseUrl
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据环境变量,获取基础路径的根路径,比如 http://1.14.158.154:48080
|
||||
*
|
||||
* add by 芋艿:用户类似 websocket 这种需要根路径的场景
|
||||
*
|
||||
* @return 根路径
|
||||
*/
|
||||
export function getEnvBaseUrlRoot() {
|
||||
const baseUrl = getEnvBaseUrl()
|
||||
// 提取根路径
|
||||
const urlObj = new URL(baseUrl)
|
||||
return urlObj.origin
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是双token模式
|
||||
*/
|
||||
export const isDoubleTokenMode = import.meta.env.VITE_AUTH_MODE === 'double'
|
||||
|
||||
/**
|
||||
* 首页路径,通过 page.json 里面的 type 为 home 的页面获取,如果没有,则默认是第一个页面
|
||||
* 通常为 /pages/index/index
|
||||
*/
|
||||
export const HOME_PAGE = `/${(pages as PageMetaDatum[]).find(page => page.type === 'home')?.path || (pages as PageMetaDatum[])[0].path}`
|
||||
|
||||
/**
|
||||
* 登录成功后跳转
|
||||
*
|
||||
* @author 芋艿
|
||||
* @param redirectUrl 重定向地址,为空则跳转到默认首页(HOME_PAGE)
|
||||
*/
|
||||
export function redirectAfterLogin(redirectUrl?: string) {
|
||||
let path = redirectUrl || HOME_PAGE
|
||||
if (!path.startsWith('/')) {
|
||||
path = `/${path}`
|
||||
}
|
||||
const { path: _path } = parseUrlToObj(path)
|
||||
if (isPageTabbar(_path)) {
|
||||
uni.switchTab({ url: path })
|
||||
} else {
|
||||
uni.navigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 增强的返回方法
|
||||
* 1. 如果存在上一页,则返回上一页
|
||||
* 2. 如果不存在上一页,则跳转到传入的 fallbackUrl 地址
|
||||
* 3. 如果 fallbackUrl 也不存在,则跳转到首页
|
||||
*
|
||||
* @author 芋艿
|
||||
* @param fallbackUrl 备选跳转地址,当不存在上一页时使用
|
||||
*/
|
||||
export function navigateBackPlus(fallbackUrl?: string) {
|
||||
const pages = getCurrentPages()
|
||||
// 情况一:如果存在上一页(页面栈长度大于 1),则直接返回
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack()
|
||||
return
|
||||
}
|
||||
|
||||
// 情况二 + 三:不存在上一页,尝试跳转到传入的 fallbackUrl
|
||||
let targetUrl = fallbackUrl || HOME_PAGE
|
||||
// 确保路径以 / 开头
|
||||
if (!targetUrl.startsWith('/')) {
|
||||
targetUrl = `/${targetUrl}`
|
||||
}
|
||||
// 解析路径,判断是否是 tabbar 页面
|
||||
const { path } = parseUrlToObj(targetUrl)
|
||||
if (isPageTabbar(path)) {
|
||||
uni.switchTab({ url: targetUrl })
|
||||
} else {
|
||||
uni.reLaunch({ url: targetUrl })
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取 wd-navbar 导航栏高度 */
|
||||
export function getNavbarHeight() {
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
const statusBarHeight = systemInfo.statusBarHeight || 0
|
||||
// #ifdef MP-WEIXIN
|
||||
// 小程序:根据胶囊按钮位置计算导航栏高度,确保内容与胶囊垂直居中
|
||||
const menuButtonInfo = uni.getMenuButtonBoundingClientRect()
|
||||
// 导航栏高度 = (胶囊顶部到状态栏底部的距离) * 2 + 胶囊高度
|
||||
const navBarHeight = (menuButtonInfo.top - statusBarHeight) * 2 + menuButtonInfo.height
|
||||
return statusBarHeight + navBarHeight
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
// H5/App:状态栏高度 + 导航栏高度(44px)
|
||||
return statusBarHeight + 44
|
||||
// #endif
|
||||
}
|
||||
38
src/utils/systemInfo.ts
Normal file
38
src/utils/systemInfo.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable import/no-mutable-exports */
|
||||
// 获取屏幕边界到安全区域距离
|
||||
let systemInfo
|
||||
let safeAreaInsets
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序使用新的API
|
||||
systemInfo = uni.getWindowInfo()
|
||||
safeAreaInsets = systemInfo.safeArea
|
||||
? {
|
||||
top: systemInfo.safeArea.top,
|
||||
right: systemInfo.windowWidth - systemInfo.safeArea.right,
|
||||
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||
left: systemInfo.safeArea.left,
|
||||
}
|
||||
: null
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
// 其他平台继续使用uni API
|
||||
systemInfo = uni.getSystemInfoSync()
|
||||
safeAreaInsets = systemInfo.safeAreaInsets
|
||||
// #endif
|
||||
|
||||
console.log('systemInfo', systemInfo)
|
||||
// 微信里面打印
|
||||
// pixelRatio: 3
|
||||
// safeArea: {top: 47, left: 0, right: 390, bottom: 810, width: 390, …}
|
||||
// safeAreaInsets: {top: 47, left: 0, right: 0, bottom: 34}
|
||||
// screenHeight: 844
|
||||
// screenTop: 91
|
||||
// screenWidth: 390
|
||||
// statusBarHeight: 47
|
||||
// windowBottom: 0
|
||||
// windowHeight: 753
|
||||
// windowTop: 0
|
||||
// windowWidth: 390
|
||||
export { safeAreaInsets, systemInfo }
|
||||
48
src/utils/toLoginPage.ts
Normal file
48
src/utils/toLoginPage.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/* eslint-disable brace-style */ // 原因:unibest 官方维护的代码,尽量不要大概,避免难以合并
|
||||
import { LOGIN_PAGE } from '@/router/config'
|
||||
import { getLastPage } from '@/utils'
|
||||
import { debounce } from '@/utils/debounce'
|
||||
|
||||
interface ToLoginPageOptions {
|
||||
/**
|
||||
* 跳转模式, uni.navigateTo | uni.reLaunch
|
||||
* @default 'navigateTo'
|
||||
*/
|
||||
mode?: 'navigateTo' | 'reLaunch'
|
||||
/**
|
||||
* 查询参数
|
||||
* @example '?redirect=/pages/home/index'
|
||||
*/
|
||||
queryString?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到登录页, 带防抖处理
|
||||
*
|
||||
* 如果要立即跳转,不做延时,可以使用 `toLoginPage.flush()` 方法
|
||||
*/
|
||||
export const toLoginPage = debounce((options: ToLoginPageOptions = {}) => {
|
||||
let { mode = 'navigateTo', queryString = '' } = options
|
||||
// add by 芋艿:如果有查询参数,强制使用 reLaunch 模式。
|
||||
// 原因:携带 redirect 参数,登录成功后可以跳回去。避免使用 navigateTo 导致页面数据不会刷新
|
||||
if (queryString) {
|
||||
mode = 'reLaunch'
|
||||
}
|
||||
|
||||
const url = `${LOGIN_PAGE}${queryString}`
|
||||
|
||||
// 获取当前页面路径
|
||||
const currentPage = getLastPage()
|
||||
const currentPath = `/${currentPage.route}`
|
||||
// 如果已经在登录页,则不跳转
|
||||
if (currentPath === LOGIN_PAGE) {
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'navigateTo') {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
else {
|
||||
uni.reLaunch({ url })
|
||||
}
|
||||
}, 500)
|
||||
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 []
|
||||
}
|
||||
29
src/utils/updateManager.wx.ts
Normal file
29
src/utils/updateManager.wx.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export default () => {
|
||||
if (!wx.canIUse('getUpdateManager')) {
|
||||
return
|
||||
}
|
||||
|
||||
const updateManager = wx.getUpdateManager()
|
||||
|
||||
updateManager.onCheckForUpdate((res) => {
|
||||
// 请求完新版本信息的回调
|
||||
console.log('版本信息', res)
|
||||
})
|
||||
|
||||
updateManager.onUpdateReady(() => {
|
||||
wx.showModal({
|
||||
title: '更新提示',
|
||||
content: '新版本已经准备好,是否重启应用?',
|
||||
success(res) {
|
||||
if (res.confirm) {
|
||||
// 新的版本已经下载好,调用 applyUpdate 应用新版本并重启
|
||||
updateManager.applyUpdate()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
updateManager.onUpdateFailed(() => {
|
||||
// 新版本下载失败
|
||||
})
|
||||
}
|
||||
410
src/utils/uploadFile.ts
Normal file
410
src/utils/uploadFile.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* 文件上传工具
|
||||
*
|
||||
* 支持两种上传模式:
|
||||
* - server: 后端上传(默认)
|
||||
* - client: 前端直连上传(仅支持 S3 服务)
|
||||
*
|
||||
* 通过环境变量 VITE_UPLOAD_TYPE 配置
|
||||
*/
|
||||
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import * as FileApi from '@/api/infra/file'
|
||||
|
||||
/** 上传类型 */
|
||||
const UPLOAD_TYPE = {
|
||||
/** 客户端直接上传(只支持S3服务) */
|
||||
CLIENT: 'client',
|
||||
/** 客户端发送到后端上传 */
|
||||
SERVER: 'server',
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件二进制内容
|
||||
* @param uniFile 文件对象
|
||||
*/
|
||||
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 {
|
||||
/** 最大可选择的图片数量,默认为1 */
|
||||
count?: number
|
||||
/** 所选的图片的尺寸,original-原图,compressed-压缩图 */
|
||||
sizeType?: Array<'original' | 'compressed'>
|
||||
/** 选择图片的来源,album-相册,camera-相机 */
|
||||
sourceType?: Array<'album' | 'camera'>
|
||||
/** 文件大小限制,单位:MB */
|
||||
maxSize?: number //
|
||||
/** 上传进度回调函数 */
|
||||
onProgress?: (progress: number) => void
|
||||
/** 上传成功回调函数 */
|
||||
onSuccess?: (res: Record<string, any>) => void
|
||||
/** 上传失败回调函数 */
|
||||
onError?: (err: Error | UniApp.GeneralCallbackResult) => void
|
||||
/** 上传完成回调函数(无论成功失败) */
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传钩子函数(带 formData)
|
||||
* @template T 上传成功后返回的数据类型
|
||||
* @param url 上传地址
|
||||
* @param formData 额外的表单数据
|
||||
* @param options 上传选项
|
||||
* @returns 上传状态和控制对象
|
||||
*/
|
||||
export function useUpload<T = string>(url: string, formData: Record<string, any> = {}, options: UploadOptions = {},
|
||||
/** 直接传入文件路径,跳过选择器 */
|
||||
directFilePath?: string) {
|
||||
/** 上传中状态 */
|
||||
const loading = ref(false)
|
||||
/** 上传错误状态 */
|
||||
const error = ref(false)
|
||||
/** 上传成功后的响应数据 */
|
||||
const data = ref<T>()
|
||||
/** 上传进度(0-100) */
|
||||
const progress = ref(0)
|
||||
const toast = useToast()
|
||||
|
||||
/** 解构上传选项,设置默认值 */
|
||||
const {
|
||||
/** 最大可选择的图片数量 */
|
||||
count = 1,
|
||||
/** 所选的图片的尺寸 */
|
||||
sizeType = ['original', 'compressed'],
|
||||
/** 选择图片的来源 */
|
||||
sourceType = ['album', 'camera'],
|
||||
/** 文件大小限制(MB) */
|
||||
maxSize = 10,
|
||||
/** 进度回调 */
|
||||
onProgress,
|
||||
/** 成功回调 */
|
||||
onSuccess,
|
||||
/** 失败回调 */
|
||||
onError,
|
||||
/** 完成回调 */
|
||||
onComplete,
|
||||
} = options
|
||||
|
||||
/**
|
||||
* 检查文件大小是否超过限制
|
||||
* @param size 文件大小(字节)
|
||||
* @returns 是否通过检查
|
||||
*/
|
||||
const checkFileSize = (size: number) => {
|
||||
const sizeInMB = size / 1024 / 1024
|
||||
if (sizeInMB > maxSize) {
|
||||
// 注释 by 芋艿:使用 wd-toast 替代
|
||||
// uni.showToast({
|
||||
// title: `文件大小不能超过${maxSize}MB`,
|
||||
// icon: 'none',
|
||||
// })
|
||||
toast.show(`文件大小不能超过${maxSize}MB`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
/**
|
||||
* 触发文件选择和上传
|
||||
* 根据平台使用不同的选择器:
|
||||
* - 微信小程序使用 chooseMedia
|
||||
* - 其他平台使用 chooseImage
|
||||
*/
|
||||
const run = () => {
|
||||
if (directFilePath) {
|
||||
// 直接使用传入的文件路径
|
||||
loading.value = true
|
||||
progress.value = 0
|
||||
uploadFile<T>({
|
||||
url,
|
||||
tempFilePath: directFilePath,
|
||||
formData,
|
||||
data,
|
||||
error,
|
||||
loading,
|
||||
progress,
|
||||
onProgress,
|
||||
onSuccess,
|
||||
onError,
|
||||
onComplete,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序环境下使用 chooseMedia API
|
||||
uni.chooseMedia({
|
||||
count,
|
||||
mediaType: ['image'], // 仅支持图片类型
|
||||
sourceType,
|
||||
success: (res) => {
|
||||
const file = res.tempFiles[0]
|
||||
// 检查文件大小是否符合限制
|
||||
if (!checkFileSize(file.size))
|
||||
return
|
||||
|
||||
// 开始上传
|
||||
loading.value = true
|
||||
progress.value = 0
|
||||
uploadFile<T>({
|
||||
url,
|
||||
tempFilePath: file.tempFilePath,
|
||||
formData,
|
||||
data,
|
||||
error,
|
||||
loading,
|
||||
progress,
|
||||
onProgress,
|
||||
onSuccess,
|
||||
onError,
|
||||
onComplete,
|
||||
})
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('选择媒体文件失败:', err)
|
||||
error.value = true
|
||||
onError?.(err)
|
||||
},
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
// 非微信小程序环境下使用 chooseImage API
|
||||
uni.chooseImage({
|
||||
count,
|
||||
sizeType,
|
||||
sourceType,
|
||||
success: (res) => {
|
||||
console.log('选择图片成功:', res)
|
||||
|
||||
// 开始上传
|
||||
loading.value = true
|
||||
progress.value = 0
|
||||
uploadFile<T>({
|
||||
url,
|
||||
tempFilePath: res.tempFilePaths[0],
|
||||
formData,
|
||||
data,
|
||||
error,
|
||||
loading,
|
||||
progress,
|
||||
onProgress,
|
||||
onSuccess,
|
||||
onError,
|
||||
onComplete,
|
||||
})
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('选择图片失败:', err)
|
||||
error.value = true
|
||||
onError?.(err)
|
||||
},
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
|
||||
return { loading, error, data, progress, run }
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传选项接口
|
||||
* @template T 上传成功后返回的数据类型
|
||||
*/
|
||||
interface UploadFileOptions<T> {
|
||||
/** 上传地址 */
|
||||
url: string
|
||||
/** 临时文件路径 */
|
||||
tempFilePath: string
|
||||
/** 额外的表单数据 */
|
||||
formData: Record<string, any>
|
||||
/** 上传成功后的响应数据 */
|
||||
data: Ref<T | undefined>
|
||||
/** 上传错误状态 */
|
||||
error: Ref<boolean>
|
||||
/** 上传中状态 */
|
||||
loading: Ref<boolean>
|
||||
/** 上传进度(0-100) */
|
||||
progress: Ref<number>
|
||||
/** 上传进度回调 */
|
||||
onProgress?: (progress: number) => void
|
||||
/** 上传成功回调 */
|
||||
onSuccess?: (res: Record<string, any>) => void
|
||||
/** 上传失败回调 */
|
||||
onError?: (err: Error | UniApp.GeneralCallbackResult) => void
|
||||
/** 上传完成回调 */
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行文件上传(带 formData)
|
||||
* @template T 上传成功后返回的数据类型
|
||||
* @param options 上传选项
|
||||
*/
|
||||
function uploadFile<T>({
|
||||
url,
|
||||
tempFilePath,
|
||||
formData,
|
||||
data,
|
||||
error,
|
||||
loading,
|
||||
progress,
|
||||
onProgress,
|
||||
onSuccess,
|
||||
onError,
|
||||
onComplete,
|
||||
}: UploadFileOptions<T>) {
|
||||
try {
|
||||
// 创建上传任务
|
||||
const uploadTask = uni.uploadFile({
|
||||
url,
|
||||
filePath: tempFilePath,
|
||||
name: 'file', // 文件对应的 key
|
||||
formData,
|
||||
header: {
|
||||
// H5环境下不需要手动设置Content-Type,让浏览器自动处理multipart格式
|
||||
// #ifndef H5
|
||||
'Content-Type': 'multipart/form-data',
|
||||
// #endif
|
||||
},
|
||||
// 确保文件名称合法
|
||||
success: (uploadFileRes) => {
|
||||
console.log('上传文件成功:', uploadFileRes)
|
||||
try {
|
||||
// 解析响应数据
|
||||
const { data: _data } = JSON.parse(uploadFileRes.data)
|
||||
// 上传成功
|
||||
data.value = _data as T
|
||||
onSuccess?.(_data)
|
||||
} catch (err) {
|
||||
// 响应解析错误
|
||||
console.error('解析上传响应失败:', err)
|
||||
error.value = true
|
||||
onError?.(new Error('上传响应解析失败'))
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
// 上传请求失败
|
||||
console.error('上传文件失败:', err)
|
||||
error.value = true
|
||||
onError?.(err)
|
||||
},
|
||||
complete: () => {
|
||||
// 无论成功失败都执行
|
||||
loading.value = false
|
||||
onComplete?.()
|
||||
},
|
||||
})
|
||||
|
||||
// 监听上传进度
|
||||
uploadTask.onProgressUpdate((res) => {
|
||||
progress.value = res.progress
|
||||
onProgress?.(res.progress)
|
||||
})
|
||||
} catch (err) {
|
||||
// 创建上传任务失败
|
||||
console.error('创建上传任务失败:', err)
|
||||
error.value = true
|
||||
loading.value = false
|
||||
onError?.(new Error('创建上传任务失败'))
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
58
src/utils/validator.ts
Normal file
58
src/utils/validator.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/** 手机号正则表达式(中国) */
|
||||
const MOBILE_REGEX = /^1[3-9]\d{9}$/
|
||||
|
||||
/** 邮箱正则表达式 */
|
||||
const EMAIL_REGEX = /^[\w-]+(?:\.[\w-]+)*@[\w-]+(?:\.[\w-]+)+$/
|
||||
|
||||
/** IP 地址正则表达式(IPv4) */
|
||||
// eslint-disable-next-line regexp/no-unused-capturing-group
|
||||
const IP_REGEX = /^(\d{1,3}\.){3}\d{1,3}$/
|
||||
|
||||
/**
|
||||
* 判断字符串是否为空白(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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证是否为 IP 地址(IPv4)
|
||||
*
|
||||
* @param value 值
|
||||
* @returns 是否为 IP 地址
|
||||
*/
|
||||
export function isIp(value?: null | string): boolean {
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
return IP_REGEX.test(value)
|
||||
}
|
||||
Reference in New Issue
Block a user