feat(user): 添加关于页隐藏开发者登录功能

- 在底部版权信息连续点击11次时弹出开发者登录弹窗
- 登录方式采用手机号加短信验证码,复用现有登录API
- 点击计数器使用useRef实现,3秒无操作自动重置
- 第4次及以后点击显示剩余步数提示,前3次触发轻震动反馈
- 登录成功后同步React登录态,关闭弹窗并重置表单
- 弹窗中使用NutUI组件,实现验证码发送和倒计时功能
This commit is contained in:
2026-07-15 01:25:04 +08:00
parent 1e4549095a
commit 19ca568d60
3 changed files with 285 additions and 2 deletions

View File

@@ -1,11 +1,135 @@
import React from 'react'
import React, { useState, useRef, useEffect } from 'react'
import Taro from '@tarojs/taro'
import { View, Text, ScrollView } from '@tarojs/components'
import { Input, Button } from '@nutui/nutui-react-taro'
import { loginBySms, sendSmsCaptcha } from '@/api/passport/login'
import type { LoginParam } from '@/api/passport/login/model'
import { useUserContext } from '@/contexts/UserContext'
definePageConfig({
navigationBarTitleText: '关于我们',
})
/** 触发隐藏登录的点击次数 */
const DEV_LOGIN_CLICK_COUNT = 11
const AboutPage: React.FC = () => {
const { syncFromStorage } = useUserContext()
// ---- 隐藏开发者登录相关状态 ----
const clickCountRef = useRef(0)
const lastClickTime = useRef(0)
const [showLogin, setShowLogin] = useState(false)
const [loading, setLoading] = useState(false)
const [sendingCode, setSendingCode] = useState(false)
const [countdown, setCountdown] = useState(0)
const [formData, setFormData] = useState<LoginParam>({ phone: '', code: '' })
// 倒计时
useEffect(() => {
if (countdown <= 0) return
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
return () => clearTimeout(timer)
}, [countdown])
/** 点击版权信息 —— 累计 11 次弹出隐藏登录 */
const handleCopyrightClick = () => {
const now = Date.now()
// 超过 3 秒未点击则重新计数
if (now - lastClickTime.current > 3000) {
clickCountRef.current = 1
} else {
clickCountRef.current += 1
}
lastClickTime.current = now
const current = clickCountRef.current
const remaining = DEV_LOGIN_CLICK_COUNT - current
if (current >= DEV_LOGIN_CLICK_COUNT) {
clickCountRef.current = 0
setShowLogin(true)
return
}
// 从第 4 次开始给步数提示
if (current >= 4) {
Taro.showToast({
title: `再点击 ${remaining} 次开启开发者登录`,
icon: 'none',
duration: 1200,
})
} else {
// 前 3 次给极轻的震动反馈,避免用户误触无感
Taro.vibrateShort({ type: 'light' })
}
}
/** 校验手机号 */
const validatePhone = (phone: string): boolean => /^1[3-9]\d{9}$/.test(phone)
/** 发送短信验证码 */
const handleSendCode = async () => {
if (!formData.phone) {
Taro.showToast({ title: '请输入手机号码', icon: 'none' })
return
}
if (!validatePhone(formData.phone)) {
Taro.showToast({ title: '请输入正确的手机号码', icon: 'none' })
return
}
if (sendingCode || countdown > 0) return
try {
setSendingCode(true)
await sendSmsCaptcha({ phone: formData.phone })
Taro.showToast({ title: '验证码已发送', icon: 'success' })
setCountdown(60)
} catch (error: any) {
Taro.showToast({ title: error.message || '发送失败', icon: 'none' })
} finally {
setSendingCode(false)
}
}
/** 登录提交 */
const handleLogin = async () => {
if (loading) return
if (!formData.phone || !validatePhone(formData.phone)) {
Taro.showToast({ title: '请输入正确的手机号码', icon: 'none' })
return
}
if (!formData.code || formData.code.length !== 6) {
Taro.showToast({ title: '请输入6位验证码', icon: 'none' })
return
}
try {
setLoading(true)
await loginBySms({ phone: formData.phone, code: formData.code })
// loginBySms 内部已写入 storage这里同步 React 登录态
syncFromStorage()
Taro.showToast({ title: '登录成功', icon: 'success' })
// 重置表单并关闭弹窗
setFormData({ phone: '', code: '' })
setCountdown(0)
setTimeout(() => setShowLogin(false), 800)
} catch (error: any) {
Taro.showToast({ title: error.message || '登录失败', icon: 'none' })
} finally {
setLoading(false)
}
}
/** 关闭弹窗时重置状态 */
const handleCloseLogin = () => {
if (loading) return
setShowLogin(false)
setFormData({ phone: '', code: '' })
setCountdown(0)
}
return (
<ScrollView scrollY className='min-h-screen bg-gray-50'>
{/* 头部品牌区 */}
@@ -74,10 +198,81 @@ const AboutPage: React.FC = () => {
</View>
{/* 底部版权 */}
<View className='flex flex-col items-center py-8'>
<View className='flex flex-col items-center py-8' onClick={handleCopyrightClick}>
<Text className='text-xs text-gray-400'>Copyright © 2024 </Text>
<Text className='text-xs text-gray-400 mt-1'>All Rights Reserved</Text>
</View>
{/* 隐藏的开发者登录弹窗 */}
{showLogin && (
<View
className='fixed inset-0 z-[9999] flex items-center justify-center'
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
onClick={handleCloseLogin}
catchMove
>
<View
className='mx-8 w-[300px] rounded-2xl bg-white p-5'
onClick={(e) => e.stopPropagation()}
>
<View className='mb-4 flex items-center justify-between'>
<Text className='text-base font-semibold text-gray-800'></Text>
<Text
className='text-sm text-gray-400 px-2'
onClick={handleCloseLogin}
>
</Text>
</View>
<Text className='mb-4 block text-xs text-gray-400'>
使+
</Text>
<Input
type='number'
placeholder='请输入手机号码'
maxLength={11}
value={formData.phone || ''}
onChange={(val) => setFormData({ ...formData, phone: val })}
style={{ backgroundColor: '#f5f5f5', borderRadius: '8px' }}
/>
<View className='mt-3 flex items-center gap-2'>
<View className='flex-1'>
<Input
type='number'
placeholder='请输入6位验证码'
maxLength={6}
value={formData.code || ''}
onChange={(val) => setFormData({ ...formData, code: val })}
style={{ backgroundColor: '#f5f5f5', borderRadius: '8px' }}
/>
</View>
<Button
size='small'
type={countdown > 0 ? 'default' : 'primary'}
loading={sendingCode}
disabled={sendingCode || countdown > 0}
onClick={handleSendCode}
>
{countdown > 0 ? `${countdown}s` : sendingCode ? '发送中' : '获取验证码'}
</Button>
</View>
<Button
type='primary'
size='large'
block
className='mt-5 rounded-lg'
loading={loading}
disabled={loading}
onClick={handleLogin}
>
{loading ? '登录中...' : '登录'}
</Button>
</View>
</View>
)}
</ScrollView>
)
}

79
src/utils/privacy.ts Normal file
View File

@@ -0,0 +1,79 @@
import Taro from '@tarojs/taro'
/**
* 在调用隐私相关 API / 组件前,确保用户已完成微信隐私协议授权。
* 基础库 3.16.1+ 强制要求,未授权会抛 errno:112 / buttonId is wrong 等错误。
* 旧基础库或不支持隐私协议的版本直接放行。
*/
export const ensurePrivacyAuthorized = (): Promise<void> => {
return new Promise((resolve) => {
const wxAny: any = Taro
if (typeof wxAny.requirePrivacyAuthorize !== 'function') {
resolve()
return
}
if (typeof wxAny.getPrivacySetting === 'function') {
wxAny.getPrivacySetting({
success: (res: any) => {
if (res && res.needAuthorization) {
wxAny.requirePrivacyAuthorize({
success: () => resolve(),
fail: () => resolve(),
})
} else {
resolve()
}
},
fail: () => resolve(),
})
} else {
wxAny.requirePrivacyAuthorize({
success: () => resolve(),
fail: () => resolve(),
})
}
})
}
/**
* 打开官方隐私协议页面(半屏协议)。
* 返回是否成功打开。
*/
export const openPrivacyContract = (): Promise<boolean> => {
return new Promise((resolve) => {
const wxAny: any = Taro
if (typeof wxAny.openPrivacyContract !== 'function') {
resolve(false)
return
}
wxAny.openPrivacyContract({
success: () => resolve(true),
fail: () => resolve(false),
})
})
}
/**
* 检查当前用户是否需要同意隐私协议。
*/
export const checkPrivacyAuthorization = (): Promise<{
needAuthorization: boolean
isAuthorization: boolean
}> => {
return new Promise((resolve) => {
const wxAny: any = Taro
if (typeof wxAny.getPrivacySetting !== 'function') {
resolve({ needAuthorization: false, isAuthorization: true })
return
}
wxAny.getPrivacySetting({
success: (res: any) => {
resolve({
needAuthorization: !!res?.needAuthorization,
isAuthorization: !!res?.isAuthorization,
})
},
fail: () => resolve({ needAuthorization: false, isAuthorization: true }),
})
})
}