feat: 提交模板代码

This commit is contained in:
熊熊熊子路
2026-01-09 09:35:44 +08:00
parent 1ba1c0de42
commit fbab15dd2c
47 changed files with 66336 additions and 0 deletions
+233
View File
@@ -0,0 +1,233 @@
// 小程序中使用命名空间导入 crypto-jsCommonJS 模块)
// 注意:crypto-js 是 CommonJS 模块,在小程序中需要使用 import * as 方式导入
import * as CryptoJS from 'crypto-js'
/**
* AES 加密/解密错误类
*/
export class AESError extends Error {
constructor(
message: string,
public readonly code?: string,
) {
super(message)
this.name = 'AESError'
}
}
// KEY 和 IV
const DEFAULT_KEY: string = 'J9w8x7z6y7A7B3C2D1E0F9G8H7I6J5K4'
const DEFAULT_IV: string = 'Q1w2Q3T4T5y6U7i8'
/**
* 统一解析 Key 和 IV
* 强制校验长度,确保触发 AES-256
* @throws {AESError} 当 KEY 或 IV 长度不符合要求时抛出错误
*/
const parseKeyIv = (keyStr: string, ivStr: string) => {
try {
// 验证 CryptoJS 是否正确加载
if (!CryptoJS || !CryptoJS.enc || !CryptoJS.enc.Utf8) {
throw new AESError('CryptoJS 模块未正确加载,请检查 crypto-js 依赖', 'MODULE_NOT_LOADED')
}
const key = CryptoJS.enc.Utf8.parse(keyStr)
const iv = CryptoJS.enc.Utf8.parse(ivStr)
// IV 必须是 16 字节 (128 bit),否则 AES 算法本身会出错或行为异常
if (iv.sigBytes !== 16) {
throw new AESError(
`AES IV 错误: 期望 16 字节, 但实际为 ${iv.sigBytes} 字节.`,
'INVALID_IV_LENGTH',
)
}
// Key 必须是 32 字节 (256 bit) 才能触发 AES-256
// 如果是 16 字节会降级为 AES-128,如果是 24 字节是 AES-192
// 为了强制 AES-256,这里必须报错
if (key.sigBytes !== 32) {
throw new AESError(
`AES Key 错误: 期望 32 字节 (用于 AES-256), 但实际为 ${key.sigBytes} 字节.`,
'INVALID_KEY_LENGTH',
)
}
return { key, iv }
} catch (error) {
// 如果是 AESError,直接抛出
if (error instanceof AESError) {
throw error
}
// 其他错误包装为 AESError
throw new AESError(
`解析 Key 或 IV 时发生错误: ${error instanceof Error ? error.message : String(error)}`,
'PARSE_ERROR',
)
}
}
/**
* 标准 AES 加密 (输出标准 Base64)
* @param word 要加密的字符串
* @param keyStr 密钥,可选,默认使用 DEFAULT_KEY
* @param ivStr 初始化向量,可选,默认使用 DEFAULT_IV
* @returns 加密后的字符串
* @throws {AESError} 当加密失败时抛出错误
*/
export const Encrypt = (
word: string,
keyStr: string = DEFAULT_KEY,
ivStr: string = DEFAULT_IV,
): string => {
try {
// 验证输入参数
if (typeof word !== 'string') {
throw new AESError('要加密的内容必须是字符串类型', 'INVALID_INPUT')
}
const { key, iv } = parseKeyIv(keyStr, ivStr)
const src = CryptoJS.enc.Utf8.parse(word)
const encrypted = CryptoJS.AES.encrypt(src, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
})
const result = encrypted.toString()
if (!result) {
throw new AESError('加密结果为空,加密可能失败', 'ENCRYPT_FAILED')
}
return result
} catch (error) {
// 如果是 AESError,直接抛出
if (error instanceof AESError) {
throw error
}
// 其他错误包装为 AESError
throw new AESError(
`加密失败: ${error instanceof Error ? error.message : String(error)}`,
'ENCRYPT_ERROR',
)
}
}
/**
* 标准 AES 解密 (输入标准 Base64)
* @param word 要解密的字符串
* @param keyStr 密钥,可选,默认使用 DEFAULT_KEY
* @param ivStr 初始化向量,可选,默认使用 DEFAULT_IV
* @returns 解密后的字符串,解密失败
* @throws {AESError} 当解密过程中发生真正的错误时抛出
*/
export const Decrypt = (
word: string,
keyStr: string = DEFAULT_KEY,
ivStr: string = DEFAULT_IV,
): string => {
try {
// 验证输入参数
if (typeof word !== 'string' || !word.trim()) {
throw new AESError('要解密的内容必须是非空字符串', 'INVALID_INPUT')
}
const { key, iv } = parseKeyIv(keyStr, ivStr)
const decrypt = CryptoJS.AES.decrypt(word, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
})
const result = decrypt.toString(CryptoJS.enc.Utf8)
// 如果解密结果为空,可能是密钥不匹配或密文被篡改(这是业务逻辑,不是错误)
if (!result) {
throw new AESError('解密结果为空,可能是密钥不匹配或密文被篡改', 'DECRYPT_FAILED')
}
return result
} catch (error) {
// 如果是 AESError,直接抛出(这些是真正的错误,不应该静默处理)
if (error instanceof AESError) {
throw error
}
// 其他错误(如参数错误、模块未加载等)包装为 AESError
throw new AESError(
`解密失败: ${error instanceof Error ? error.message : String(error)}`,
'DECRYPT_ERROR',
)
}
}
/**
* URL Safe Base64 加密
* @param word 要加密的字符串
* @param keyStr 密钥,可选,默认使用 DEFAULT_KEY
* @param ivStr 初始化向量,可选,默认使用 DEFAULT_IV
* @returns URL Safe Base64 编码的加密字符串
* @throws {AESError} 当加密失败时抛出错误
*/
export const BASE64Encrypt = (
word: string,
keyStr: string = DEFAULT_KEY,
ivStr: string = DEFAULT_IV,
): string => {
try {
const encrypted = Encrypt(word, keyStr, ivStr)
return encrypted.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
} catch (error) {
// 如果是 AESError,直接抛出
if (error instanceof AESError) {
throw error
}
// 其他错误包装为 AESError
throw new AESError(
`URL Safe Base64 加密失败: ${error instanceof Error ? error.message : String(error)}`,
'BASE64_ENCRYPT_ERROR',
)
}
}
/**
* URL Safe Base64 解密
* @param word 要解密的字符串
* @param keyStr 密钥,可选,默认使用 DEFAULT_KEY
* @param ivStr 初始化向量,可选,默认使用 DEFAULT_IV
* @returns URL Safe Base64 解密后的字符串
* @throws {AESError} 当解密过程中发生真正的错误时抛出
*/
export const BASE64Decrypt = (
word: string,
keyStr: string = DEFAULT_KEY,
ivStr: string = DEFAULT_IV,
): string => {
try {
// 验证输入参数
if (typeof word !== 'string' || !word.trim()) {
throw new AESError('要解密的内容必须是非空字符串', 'INVALID_INPUT')
}
// 还原 Base64 格式
let base64Str = word.replace(/-/g, '+').replace(/_/g, '/')
// 补全末尾的 = (Base64长度必须是4的倍数)
const mod4 = base64Str.length % 4
if (mod4 > 0) {
base64Str += '='.repeat(4 - mod4)
}
return Decrypt(base64Str, keyStr, ivStr)
} catch (error) {
// 如果是 AESError,直接抛出
if (error instanceof AESError) {
throw error
}
// 其他错误包装为 AESError
throw new AESError(
`URL Safe Base64 解密失败: ${error instanceof Error ? error.message : String(error)}`,
'BASE64_DECRYPT_ERROR',
)
}
}
+238
View File
@@ -0,0 +1,238 @@
/**
* 网络请求封装 - 基于 axios-miniprogram
*
* 功能特性:
* - 自动添加 Token
* - 统一错误处理
* - 智能加载动画管理
* - 完整的 TypeScript 类型支持
* - 文件上传功能
*/
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios-miniprogram'
import type { StandardResponse } from '../api/types'
// ==================== 类型定义 ====================
/**
* 自定义请求配置接口
* 扩展 axios 的请求配置,添加自定义参数
*/
interface CustomRequestConfig extends AxiosRequestConfig {
loading?: boolean // 是否显示加载提示,默认 true
upload?: boolean // 是否为上传请求
}
/**
* 文件上传配置接口
*/
interface UploadOptions {
apiUrl: string // 接口路径
filePath: string // 文件本地路径
name?: string // 文件对应的 key,默认 'file'
formData?: Record<string, unknown> // 额外参数
loading?: boolean // 是否显示加载提示,默认 true
onUploadProgress?: (event: unknown) => void // 上传进度回调
}
// ==================== 基础配置 ====================
// 配置 baseURL
axios.defaults.baseURL = '你的后端域名'
// 配置超时时间(30秒)
axios.defaults.timeout = 30000
// 配置默认请求头
axios.defaults.headers.common['Content-Type'] = 'application/json'
// ==================== 请求白名单 ====================
/**
* 请求白名单
* 白名单内的接口不需要添加 Authorization 请求头
* 例如:登录接口、注册接口等
*/
const requestWhiteList: string[] = []
// ==================== 加载动画管理 ====================
/**
* 网络请求计数器
* 用于管理多个并发请求的加载状态
*/
let netWorkNum = 0
/**
* 开启加载动画
* 使用计数器机制,防止多个请求导致的 loading 闪烁
* @param title 加载提示文字
*/
function startLoading(title: string) {
if (netWorkNum === 0) {
wx.showLoading({
title: title,
mask: true,
})
}
netWorkNum++
}
/**
* 关闭加载动画
* 延迟 500ms 关闭,确保用户体验
*/
function endLoading() {
netWorkNum--
setTimeout(() => {
if (netWorkNum === 0) {
wx.hideLoading()
wx.stopPullDownRefresh() // 停止当前页面下拉刷新
}
}, 500)
}
// ==================== 请求拦截器 ====================
axios.interceptors.request.use(
(config: CustomRequestConfig) => {
// 加载提示
if (config.loading ?? true) {
startLoading('请稍等...')
}
// Content-Type 设置
// 注意:上传文件时 axios 会自动设置正确的 Content-Type
if (config.headers && !config.headers['Content-Type']) {
config.headers['Content-Type'] = 'application/json'
}
// Token 自动注入(非白名单接口)
if (!requestWhiteList.includes(config.url || '')) {
const app = getApp<IAppOption>()
if (app.globalData.token && config.headers) {
config.headers['Authorization'] = app.globalData.token
}
}
// 详细日志记录
/* eslint-disable no-console */
console.log('============发送请求 start============')
console.log(`[${new Date().toISOString()}] [INFO] 请求地址: ${config.baseURL}${config.url}`)
console.log(`[${new Date().toISOString()}] [INFO] 请求方式: ${config.method?.toUpperCase()}`)
console.log(`[${new Date().toISOString()}] [INFO] 上传模式: ${config.upload ? '是' : '否'}`)
console.log(`[${new Date().toISOString()}] [INFO] params参数: ${JSON.stringify(config.params)}`)
console.log(`[${new Date().toISOString()}] [INFO] data参数: ${JSON.stringify(config.data)}`)
console.log('============发送请求 end============')
/* eslint-enable no-console */
return config
},
(error) => {
// 请求错误处理
endLoading()
return Promise.reject(error)
},
)
// ==================== 响应拦截器 ====================
axios.interceptors.response.use(
(response: AxiosResponse) => {
// 关闭加载动画
const config = response.config as CustomRequestConfig
if (config.loading ?? true) {
endLoading()
}
// 标准格式:{ code, data, message }
const result = response.data as StandardResponse
if (result.code === 200) {
// 只返回 data 字段(修改 response.data 为提取后的 data
return { ...response, data: result.data } as unknown as AxiosResponse
} else {
// 业务错误处理
wx.showToast({
title: result.message || '请求失败',
icon: 'none',
})
return Promise.reject(new Error(result.message))
}
},
(error: unknown) => {
// 关闭加载动画
const axiosError = error as {
config?: CustomRequestConfig
response?: AxiosResponse
message?: string
}
const config = axiosError.config
if (config?.loading ?? true) {
endLoading()
}
// 401 未授权,跳转登录页
if (axiosError.response?.status === 401) {
wx.showToast({
title: '未授权,请重新登录',
icon: 'none',
})
wx.reLaunch({
url: '/pages/login/login',
})
return Promise.reject(error)
}
// 统一错误提示
const errorData = axiosError.response?.data as StandardResponse | undefined
const message = errorData?.message || errorData?.data || axiosError.message || '网络请求失败'
wx.showToast({
title: message as string,
icon: 'none',
})
return Promise.reject(error)
},
)
// ==================== 文件上传封装 ====================
/**
* 文件上传函数
* 基于 axios-miniprogram 内置的 upload 功能
* @param options 上传配置
* @returns Promise<T> 返回 data 字段的类型
*/
function upload<T = unknown>(options: UploadOptions): Promise<T> {
const {
apiUrl,
filePath,
name = 'file',
formData = {},
loading = true,
onUploadProgress,
} = options
return axios({
url: apiUrl,
method: 'POST',
upload: true, // 启用上传模式
data: {
name, // 文件对应的 key
filePath, // 要上传文件资源的路径
...formData, // 额外的 formData 属性
},
loading, // 自定义 loading 参数(会被拦截器识别)
onUploadProgress, // 上传进度回调
} as CustomRequestConfig).then((res) => res.data as T)
}
// ==================== 导出接口 ====================
// 默认导出 axios 实例(支持完整的泛型类型推导)
export default axios
// 命名导出 upload 函数(支持泛型)
export { upload }
+126
View File
@@ -0,0 +1,126 @@
/**
* 格式化数字为两位数,不足两位前面补0
* @param n 需要格式化的数字
* @returns 格式化后的字符串(两位数)
*/
const formatNumber = (n: number) => {
const s = n.toString()
return s[1] ? s : '0' + s
}
/**
* 格式化日期时间为 YYYY/MM/DD HH:mm:ss 格式
* @param date 需要格式化的日期对象
* @returns 格式化后的日期时间字符串,格式为 YYYY/MM/DD HH:mm:ss
*/
export const formatTime = (date: Date) => {
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const hour = date.getHours()
const minute = date.getMinutes()
const second = date.getSeconds()
return (
[year, month, day].map(formatNumber).join('/') +
' ' +
[hour, minute, second].map(formatNumber).join(':')
)
}
/**
* 格式化日期时间
* @param {Date|Number|String} date 日期对象、时间戳或日期字符串
* @param {String} format 格式化模板,支持以下占位符:
* - YYYY: 4位年份
* - YY: 2位年份
* - MM: 2位月份
* - M: 1位月份
* - DD: 2位日期
* - D: 1位日期
* - HH: 2位小时(24小时制)
* - H: 1位小时(24小时制)
* - hh: 2位小时(12小时制)
* - h: 1位小时(12小时制)
* - mm: 2位分钟
* - m: 1位分钟
* - ss: 2位秒
* - s: 1位秒
* - SSS: 3位毫秒
* - A: 上午/下午
* - a: 上午/下午(小写)
* @return {String} 格式化后的日期时间字符串
*/
export const formatDateTime = (
date: Date | number | string | undefined,
format: string = 'YYYY/MM/DD HH:mm:ss',
): string => {
if (!date && date !== 0) return ''
let d: Date
// 1. 处理字符串类型的特殊清洗逻辑 (兼容原本 getDate 的脏数据处理能力)
if (typeof date === 'string') {
let cleanStr = date.trim()
// 替换中文符号和特殊符号为标准分隔符
if (['年', '月', '日'].some((x) => cleanStr.includes(x))) {
cleanStr = cleanStr.replace(/[年月日]/g, '/')
}
// 补全简写日期 (例如 "2020" -> "2020/01/01") 以便 new Date 解析
const parts = cleanStr.split(/[-/ :]/).filter(Boolean)
if (parts.length === 1)
cleanStr += '/01/01' // 只有年
else if (parts.length === 2) cleanStr += '/01' // 只有年月
// 解决 iOS/Safari 不支持 "-" 连接符的问题
cleanStr = cleanStr.replace(/-/g, '/')
d = new Date(cleanStr)
} else if (typeof date === 'number') {
d = new Date(date)
} else {
d = date
}
// 有效性检查
if (!(d instanceof Date) || isNaN(d.getTime())) {
return ''
}
// 提取时间分量
const year = d.getFullYear()
const month = d.getMonth() + 1
const day = d.getDate()
const hours = d.getHours()
const minutes = d.getMinutes()
const seconds = d.getSeconds()
const milliseconds = d.getMilliseconds()
// 定义替换映射表
const matches: Record<string, string | number> = {
YYYY: year,
YY: String(year).slice(-2),
MM: String(month).padStart(2, '0'),
M: month,
DD: String(day).padStart(2, '0'),
D: day,
HH: String(hours).padStart(2, '0'),
H: hours,
hh: String(hours % 12 || 12).padStart(2, '0'),
h: hours % 12 || 12,
mm: String(minutes).padStart(2, '0'),
m: minutes,
ss: String(seconds).padStart(2, '0'),
s: seconds,
SSS: String(milliseconds).padStart(3, '0'),
A: hours < 12 ? '上午' : '下午',
a: hours < 12 ? 'am' : 'pm',
}
// 5. 生成结果
return format.replace(/YYYY|YY|MM|M|DD|D|HH|H|hh|h|mm|m|ss|s|SSS|A|a/g, (match) =>
String(matches[match]),
)
}