diff --git a/project.private.config.json b/project.private.config.json index 3844ea3..165bb9d 100644 --- a/project.private.config.json +++ b/project.private.config.json @@ -95,5 +95,5 @@ ] } }, - "libVersion": "3.7.7" + "libVersion": "3.11.3" } \ No newline at end of file diff --git a/src/app.json b/src/app.json index 711dd87..7e7b616 100644 --- a/src/app.json +++ b/src/app.json @@ -56,5 +56,11 @@ "componentFramework": "glass-easel", "sitemapLocation": "sitemap.json", "lazyCodeLoading": "requiredComponents", - "requiredBackgroundModes": ["audio"] + "requiredBackgroundModes": ["audio"], + "plugins": { + "AliyunCaptcha": { + "version": "3.0.0", + "provider": "wxbe275ff84246f1a4" + } + } } diff --git a/src/app.ts b/src/app.ts index 5bf3813..f51d448 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,8 +1,12 @@ +/* eslint-disable perfectionist/sort-imports */ import component from '@/utils/component'; + import relativeTime from '@/utils/dayjs/relativeTime.js'; import page from '@/utils/page'; import { request } from '@/utils/request'; +import { initNetworkMonitor } from './utils/network'; import { parseScene } from './utils/util'; + const dayjs = require('dayjs'); const licia = require('miniprogram-licia'); @@ -28,12 +32,15 @@ App({ loginState: '', isLogin: 0, isAnswer: '0', + sceneId: '', scene: null, backPath: '', }, onLaunch() { + // 初始化网络状态监听 + initNetworkMonitor(); Page = page as WechatMiniprogram.Page.Constructor; Component = component as WechatMiniprogram.Component.Constructor; @@ -50,6 +57,7 @@ App({ }).then((res) => { this.globalData.loginState = res.loginState; this.globalData.isLogin = res.isLogin || 999; + this.globalData.sceneId = res.sceneId; }); }, }); diff --git a/src/images/defaultBook.png b/src/images/defaultBook.png new file mode 100644 index 0000000..6aee585 Binary files /dev/null and b/src/images/defaultBook.png differ diff --git a/src/pages/login/index.json b/src/pages/login/index.json index b3238b0..1e7f66e 100644 --- a/src/pages/login/index.json +++ b/src/pages/login/index.json @@ -2,6 +2,8 @@ "usingComponents": { "van-icon": "@vant/weapp/icon/index", "van-nav-bar": "@vant/weapp/nav-bar/index", - "van-popup": "@vant/weapp/popup/index" + "van-popup": "@vant/weapp/popup/index", + "aliyun-captcha": "plugin://AliyunCaptcha/captcha" + } } diff --git a/src/pages/login/index.ts b/src/pages/login/index.ts index 35ddd78..85d6a4c 100644 --- a/src/pages/login/index.ts +++ b/src/pages/login/index.ts @@ -1,5 +1,6 @@ +import { destroyCaptcha, initCaptcha, showCaptcha } from '@/utils/captcha'; + const app = getApp(); -let timer: number | null = 0; Page({ data: { @@ -13,17 +14,34 @@ Page({ type: '', show: false, + + loadCaptcha: false, // 是否加载验证码 + pluginProps: {} as any, // 验证码插件配置 }, onLoad(options) { this.setData({ back: options.back === '1', }); + app.waitLogin(true).then(() => { + // 初始化验证码插件 + const captchaConfig = initCaptcha(this, { + sceneId: app.globalData.sceneId, + sendCodeConfig: { + url: '?r=shizhong/login/send-verify-code', + mobileField: 'mobile', + }, + }); + this.setData(captchaConfig); + }); }, onShow() { wx.hideShareMenu(); }, + onUnload() { + // 页面卸载时清理验证码资源 + destroyCaptcha(); + }, getCode() { - if (timer) return; const mobile = this.data.mobile; if (!mobile) { wx.showToast({ @@ -33,39 +51,14 @@ Page({ return; } // 验证手机号 - if (!/^1[3-9,]\d{9}$/.test(mobile)) { + if (!/^1[3-9]\d{9}$/.test(mobile)) { wx.showToast({ title: '手机号格式不正确', icon: 'none', }); return; } - wx.ajax({ - method: 'POST', - url: '?r=shizhong/login/send-verify-code', - data: { - mobile, - }, - }).then(() => { - wx.showToast({ - icon: 'none', - title: '验证码已发送~', - }); - let time = 60; - timer = setInterval(() => { - time--; - this.setData({ - codeText: `${time}s后重新发送`, - }); - if (time <= 0) { - clearInterval(timer as number); - timer = null; - this.setData({ - codeText: '发送验证码', - }); - } - }, 1000); - }); + showCaptcha(); }, handleSubmit() { const { show, mobile, code, protool } = this.data; diff --git a/src/pages/login/index.wxml b/src/pages/login/index.wxml index 4b96c45..ddbb3df 100644 --- a/src/pages/login/index.wxml +++ b/src/pages/login/index.wxml @@ -37,6 +37,7 @@ model:value="{{code}}" type="number" /> + {{codeText}} diff --git a/src/utils/captcha.ts b/src/utils/captcha.ts new file mode 100644 index 0000000..fce48d5 --- /dev/null +++ b/src/utils/captcha.ts @@ -0,0 +1,350 @@ +/** + * 阿里云验证码2.0工具模块 + * 用于小程序发送验证码前的行为验证 + */ + +// 验证码插件实例 +let AliyunCaptchaPluginInterface: any = null; + +// 计时器 +let timer: number | null = null; + +// 页面实例引用 +let pageInstance: any = null; + +// 是否在发送中(防止重复请求) +let isSending: boolean = false; + +// 倒计时剩余秒数 +let remainingSeconds: number = 0; + +// 发送验证码的接口配置 +interface SendCodeConfig { + url: string; + mobileField?: string; + extraData?: Record; +} + +// 验证码配置选项 +interface CaptchaOptions { + sceneId: string; + sendCodeConfig: SendCodeConfig; + onSendSuccess?: () => void; + onSendFail?: (err: any) => void; + countdown?: number; +} + +// 当前配置 +let currentOptions: CaptchaOptions | null = null; + +/** + * 初始化验证码插件 + */ +function initPlugin() { + if (!AliyunCaptchaPluginInterface) { + AliyunCaptchaPluginInterface = requirePlugin('AliyunCaptcha'); + } +} + +/** + * 验证通过回调函数 + * @param captchaVerifyParam 验证码验证参数 + */ +async function successCallback(captchaVerifyParam: string) { + if (!pageInstance || !currentOptions) return; + if (isSending) { + wx.showToast({ + title: '发送中,请稍候', + icon: 'none', + }); + return; + } + + const { sendCodeConfig, onSendSuccess, onSendFail, countdown = 60 } = currentOptions; + const mobileField = sendCodeConfig.mobileField || 'mobile'; + const mobile = pageInstance.data[mobileField]; + + if (!mobile) { + wx.showToast({ + title: '请输入手机号', + icon: 'none', + }); + return; + } + + isSending = true; + updateCountdownText('发送中...'); + + try { + const res = await wx.ajax({ + method: 'POST', + url: sendCodeConfig.url, + data: { + [mobileField]: mobile, + captchaVerifyParam, + ...sendCodeConfig.extraData, + }, + }); + + wx.showToast({ + icon: 'none', + title: '验证码已发送~', + }); + + // 开始倒计时 + startCountdown(countdown); + + // 执行成功回调 + onSendSuccess?.(); + + return res; + } catch (err: any) { + // 发送失败,重置状态(包括阿里云插件,以便下次重新验证) + resetCaptchaState(true); + + const errorMsg = err.data?.msg || err.message || '发送失败,请重试'; + wx.showToast({ + title: errorMsg, + icon: 'none', + duration: 2000, + }); + onSendFail?.(err); + throw err; + } finally { + isSending = false; + } +} + +/** + * 验证失败回调函数 + * @param error 错误信息 + */ +function failCallback(error: any) { + console.error('阿里云验证码验证失败:', error); + wx.showToast({ + title: '验证失败,请重试', + icon: 'none', + }); +} + +/** + * 开始倒计时 + * @param seconds 倒计时秒数 + */ +function startCountdown(seconds: number) { + // 清除之前的计时器 + if (timer) { + clearInterval(timer); + timer = null; + } + + remainingSeconds = seconds; + updateCountdownText(`${remainingSeconds}s后重新发送`); + + timer = setInterval(() => { + remainingSeconds--; + if (remainingSeconds <= 0) { + // 倒计时结束,重置状态(包括阿里云插件,以便下次重新验证) + resetCaptchaState(true); + } else { + updateCountdownText(`${remainingSeconds}s后重新发送`); + } + }, 1000) as unknown as number; +} + +/** + * 重置验证码状态 + * 在倒计时结束、发送失败或需要重置时使用 + */ +function resetCaptchaState(resetPluginToo: boolean = false) { + if (timer) { + clearInterval(timer); + timer = null; + } + remainingSeconds = 0; + isSending = false; + updateCountdownText('发送验证码'); + + // 如果需要,同时重置阿里云插件状态(接口失败时使用) + if (resetPluginToo) { + refreshCaptchaComponent(); + } +} + +/** + * 重置阿里云验证码插件 + * 用于验证失败后需要重新验证的情况 + */ +function resetAliyunPlugin(): void { + // 销毁插件实例,下次会自动重新初始化 + AliyunCaptchaPluginInterface = null; +} + +/** + * 强制刷新验证码组件 + * 通过卸载并重新加载组件来重置阿里云插件状态 + */ +export function refreshCaptchaComponent(callback?: () => void): void { + if (!pageInstance) return; + + // 卸载组件 + pageInstance.setData({ + loadCaptcha: false, + }); + + // 重置插件实例 + resetAliyunPlugin(); + + // 延迟重新加载组件,确保卸载完成 + setTimeout(() => { + if (!pageInstance || !currentOptions) return; + + // 重新初始化配置 + const pluginProps = { + SceneId: currentOptions.sceneId, + mode: 'popup', + success: successCallback.bind(pageInstance), + fail: failCallback.bind(pageInstance), + slideStyle: { + width: 540, + height: 60, + }, + language: 'cn', + region: 'cn', + }; + + // 重新加载组件 + pageInstance.setData({ + loadCaptcha: true, + pluginProps, + }); + if (callback) callback(); + }, 100); +} + +/** + * 更新倒计时文本 + * @param text 显示的文本 + */ +function updateCountdownText(text: string) { + if (pageInstance) { + pageInstance.setData({ codeText: text }); + } +} + +/** + * 初始化验证码功能 + * @param page 页面实例 + * @param options 配置选项 + * @returns 插件配置对象 + */ +export function initCaptcha(page: any, options: CaptchaOptions) { + initPlugin(); + pageInstance = page; + currentOptions = options; + + const pluginProps = { + SceneId: options.sceneId, + mode: 'popup', + success: successCallback.bind(page), + fail: failCallback.bind(page), + slideStyle: { + width: 540, + height: 60, + }, + language: 'cn', + region: 'cn', + }; + + return { + loadCaptcha: true, + pluginProps, + }; +} + +/** + * 显示验证码弹窗 + * @returns 是否成功触发 + */ +export function showCaptcha(): boolean { + if (!AliyunCaptchaPluginInterface) { + initPlugin(); + } + + if (isCountingDown()) { + return false; + } + + wx.getNetworkType({ + success: (res) => { + if (res.networkType === 'none') { + wx.showToast({ + title: '网络连接不可用,请检查网络设置', + icon: 'none', + }); + return; + } + AliyunCaptchaPluginInterface.show(); + }, + fail: () => { + AliyunCaptchaPluginInterface.show(); + }, + }); + + return true; +} + +/** + * 显示验证码弹窗(带重置) + * 在接口报错后再次点击时使用,会重置插件状态 + * @returns 是否成功触发 + */ +export function showCaptchaWithReset(): boolean { + // 重置插件状态,清除已验证的状态 + resetAliyunPlugin(); + + if (isCountingDown()) { + return false; + } + + // 重新初始化后显示 + initPlugin(); + AliyunCaptchaPluginInterface.show(); + return true; +} + +/** + * 检查是否正在倒计时 + * @returns 是否正在倒计时 + */ +export function isCountingDown(): boolean { + return timer !== null; +} + +/** + * 清除倒计时 + * 完全重置所有状态,可在页面卸载或需要完全重置时调用 + */ +export function clearCountdown() { + resetCaptchaState(); +} + +/** + * 获取验证码插件接口 + * @returns 插件接口 + */ +export function getCaptchaPlugin() { + if (!AliyunCaptchaPluginInterface) { + initPlugin(); + } + return AliyunCaptchaPluginInterface; +} + +/** + * 页面卸载时清理资源 + */ +export function destroyCaptcha() { + clearCountdown(); + pageInstance = null; + currentOptions = null; +} diff --git a/src/utils/network.ts b/src/utils/network.ts new file mode 100644 index 0000000..b6b16b4 --- /dev/null +++ b/src/utils/network.ts @@ -0,0 +1,63 @@ +/** + * 网络状态管理工具 + * 监听网络变化,断网时提示,恢复时引导刷新 + */ + +interface NetworkStatus { + isConnected: boolean; + networkType: string; +} + +let networkListener: WechatMiniprogram.OnNetworkStatusChangeListener | null = null; +let isOfflineShown = false; +let isRecoveryShown = false; + +function showOfflineTip() { + if (isOfflineShown) return; + isOfflineShown = true; + wx.showModal({ + title: '网络连接失败', + content: '当前网络不可用,请检查网络设置', + showCancel: false, + confirmText: '知道了', + }); +} + +function showRecoveryTip() { + if (isRecoveryShown) return; + isRecoveryShown = true; + wx.showModal({ + title: '网络已恢复', + content: '网络连接已恢复,是否刷新小程序?', + confirmText: '立即刷新', + cancelText: '暂不刷新', + success: (res) => { + if (res.confirm) { + wx.reLaunch({ url: '/pages/home/index' }); + } + setTimeout(() => { + isRecoveryShown = false; + }, 3000); + }, + }); +} + +export function initNetworkMonitor(): void { + wx.getNetworkType({ + success: (res) => { + if (res.networkType === 'none') showOfflineTip(); + }, + }); + + networkListener = (res: NetworkStatus) => { + if (res.isConnected) { + isOfflineShown = false; + showRecoveryTip(); + } else { + isRecoveryShown = false; + showOfflineTip(); + } + }; + + wx.onNetworkStatusChange(networkListener); +} diff --git a/typings/index.d.ts b/typings/index.d.ts index fb8efe7..6857ccb 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -7,6 +7,7 @@ interface IAppOption { loginState: string; isLogin: 0 | 1 | 999; isAnswer: '0' | '1'; + sceneId: string; scene: null | { [key: string]: any }; backPath: string; };