From 133067f567e127083b60f0ffc9edc7531abdc507 Mon Sep 17 00:00:00 2001 From: kola-web Date: Thu, 30 Apr 2026 11:11:12 +0800 Subject: [PATCH] =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=A0=812.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- project.private.config.json | 2 +- src/app.json | 8 +- src/app.ts | 8 + src/images/defaultBook.png | Bin 0 -> 4777 bytes src/pages/login/index.json | 4 +- src/pages/login/index.ts | 51 +++---- src/pages/login/index.wxml | 1 + src/utils/captcha.ts | 350 ++++++++++++++++++++++++++++++++++++++++++++ src/utils/network.ts | 63 ++++++++ typings/index.d.ts | 1 + 10 files changed, 456 insertions(+), 32 deletions(-) create mode 100644 src/images/defaultBook.png create mode 100644 src/utils/captcha.ts create mode 100644 src/utils/network.ts 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 0000000000000000000000000000000000000000..6aee58591ad3d7d2e580a4ab03ebb03c20572b22 GIT binary patch literal 4777 zcmeHL{Xf(D-`_54rm)fJi{*q>D2YaL&lsWH6%)djk>xgOl-TwiCmaVkAyh2NWSrbi zZsNo-oeiyWH!}`V?&WrFHohO{dR*84a6PW?5AVn4{d&G`-mll|^LpQ=x;R~wIfy(6 z0)b@g?XXusAaLD&gFykrOaGHC@R7P@=Xo0hI&*2ifnS>Cz5+n-?JF0pK)>}W&j17@ z7~_ZmfvPhPiTn?MK=S7HSd80U@PZ)JgKyXLz#nWtSH7{mx5s)*KN|mE)-^s(dM1zT zspo3k)~A~W0xAo2@?~Y3d|o!ZsNLyY36%t;n=FA;DKszw!v&?N51iEqhbem8g}AF#7dp<3iWdw#^UyDO=4iMFl{QT?Yw2A!~5L1b<+8m(?Ovt8((dQ zQceuXo`}oC2tl+-QsYSF!3IIH&(H||_)oNVzqig) zqdlnTzsl!>4d7!?Cv&oBsON^lk9EBl^%Pxr0ixAK(puhIO-4OgARx~5SW8ycS|$E# z9v?#{udPj-(#A2wZG#IvM9In;t3+_0a;?G?$+z*fWTm{mVYS$-wzGH^PrUVa^DND)d zE*uch^p0V$5?yS!IL8}lM*urjyiQtuC99~~q2weLYNo}gol?h(-YSC{97+Jb89&L4 z*l{`?2Scuft>zIk<|=g*f2szkvX(r(@ljspv0aj1>3IpZzr|la*WQ*>kcU9d_oCL@}zZmS8KYE7<(I9Xitx0Z26hYFPq>yWq zr$p2xDN&yjm!>h}OxGb>IR8bz0eG>6{#Tm#huw_2O8|#C`OoWAsPg=rf|ma5Nx;JD zs>qLCyILz7a+2`-yWS6w^tgRv0SC48F(m=DvdAke7Dw+u(JpCba*MY`IvN%eb#A+J zLlmG;_gAdH?|f^Qiyo##3eFF27!BJvTJ`y zb2Y-07zYJ0XJ!Q6Qy)AlZ~~XJI^TSX8}mMbFeRs_mR*;8^2KEpeN5+$UsOh~``BbK z{rvohE+zn%JSvE#yD*O>ziDs}Fc`?5dNmaB;t19Dya7B@(oyeNiKvm$qiel>!DG

oghi>v%Iv&N>TI-NsrERTUeUL^ zDY|E2b1e~Fwc^?hQv+mb@0A4*Hv-DQSqim08{r4%B3p8S-MxOtE#VtEJu4V8(YW*T z+89GRMcORfvsA@`EUMyDTYikM$KiseNTVuyn=P4IFTTUVq4gDkjOB|#!!>|x0;0(@ zwWOjC*Bt&~Ws!OH^$(mq4+jVU-2*`3?uT@i!f&ZBBEa;vj*COXcB zOcnYT`DTmAdURe{hkd>x;>~v~H8)xeo;=#|KZwspu zOt%Rb9;>D4Y3$?GNI%^JRF-Op(r2*RyGgPuj~kmxdv6p&a>-zj8I zU)<$iR63U2X$Ns$Z;m;=DCF)7>H@*X#XUh1>nx=5hb zFkC|~UO~YGFaaiPHY2L1Spk+wLV5iR=5TnkBayg)nn9Uvgzzb_rIQ5ZmleGO?~=>l zId|lK^WPsth|tfNc(k^6=?&~LT%*2%DU)*R4CYG@3#nY@$x#@OWGuwVrX59$U!$^~ zq#GH!Frwv1@gp(X<;%WhV7Uyy=|8i(*8DzC*t{9I{~U@nd_Y@Pzj_QPODS=A^*7T2 zWf=)nOAaln>Z(P88AfXNv2E)`ZBv(F3M^8Mpd466f32Yzc{k#npUlXi^Q!IMFr=i? z?97~$mm=a+79q&w15j=9`o8CLVxO(Q+DN-~NoWQ5sEPUFepWrwW0hi;_NW{fHi@?E zJv9+bjCqQd_2X0QIPh!()%H+G`WU*D+*0F?<5S+3j!QZKp`53(bd0cK(iG}CC(V*G z8^+s|cqxG3Zt5`aY+v|FvGdIDk5+lDqunN;e3sMSO|kUxs zX7_)Z$d0=S*ESt{9b^qpIpJK++>DWM0(2X43zr7V)pXjg=DCH>EI^+Y(v~AxYsu&O zDDU0W-2oS^xIzc_}0%xs^Pez#tcI=_pQb(?O zSab7t&u8h35#h{joAc}Y7Cx&J4jc4Wz8cOx;;tfr@)fS8gR^Gj>r$ha8Jyx|xI(WS z32zzL1PL#`E}8`vFwt)%hZ#Bc{Yy%Se09tdRfRU=9Pl$dlf9AoCawy#l?ABF)ul!) zGdxl~8~0XmT@Q4h{V&G%9`pICJif2|56lyULYqNLkmVmn?PGeXXl<&28f%SzYnqaB zJ1O~`D*8vNf!tLx6&otcLvDblZUI4Hxa#eBByWdqQp2-p<#E0uA|Hx*0s`#hgm?ML zq-;`FVxZU+DX%x;yw)_oS`yRYcE)$Z|Au-N6^1%r`vfuOcfeWV7qcaphj=B#pX!8n zY@NmE&wD#e#Q`CZ1J*VPij9`?(hbf=w@9ZLUL1l;pc*0C*=c^cH4B@A;^Gn&C>AZ{ zRc5iYXi}3cF}T)2u~Ww!3cRf18*F(ZlXd`I3I`(i)rQ+{SOQ7b+wW0*Le{lonVnk#&f1XjqDbn*CB6bMoINC_VsZF=1Z#UuHV0 zq*LmwxyT8``Cow8jzTKsf>g$4rCI)#FJ!0#DJz^nBJ|5S)hia5P|K~8UM5(X67=5` z#k$W$QUXOhT*o#uGXwA=fRqk~F(L@~2wvCyyPtP1q3jVj%UH^*^OqUxXTa8C&plo5 zRax0f3xHy0j}wjzTR850RE6~cS@MBYS_NGO#-0tfd~ueFQ(7`kWGW(FCDYtlY9WL) za8?}5%ZAPFo|PU3qLroM4W;T3f4EG^#o>-!3_*)>xb|y}eQ+f$sTMyExH) zGRCfzf4u2!(tTWfvEgr6ZQoR=<#6tG8D7U|Ez8$yUHJ96 z(JHTE=gaChL4sae8{W9t?~GYk1v(QrWqU=ouzo!4VTD880KJfP61z`Fn~#yrEXXs3 zg+&|wT5u!ny`zfGJo|h1G_81!%dqCAYtO-mcU_16bM^oK^yIN=@c`S{vmv(Ke`W&N M+c;r=v-&6X-|RUfod5s; literal 0 HcmV?d00001 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; };