feat(user): 添加关于页隐藏开发者登录功能
- 在底部版权信息连续点击11次时弹出开发者登录弹窗 - 登录方式采用手机号加短信验证码,复用现有登录API - 点击计数器使用useRef实现,3秒无操作自动重置 - 第4次及以后点击显示剩余步数提示,前3次触发轻震动反馈 - 登录成功后同步React登录态,关闭弹窗并重置表单 - 弹窗中使用NutUI组件,实现验证码发送和倒计时功能
This commit is contained in:
9
.workbuddy/memory/2025-07-15.md
Normal file
9
.workbuddy/memory/2025-07-15.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# 2025-07-15 工作日志
|
||||||
|
|
||||||
|
## 关于页面隐藏开发者登录
|
||||||
|
- 在 `src/pages/user/about/index.tsx` 底部版权信息上添加了点击 11 次弹出隐藏登录弹窗的功能
|
||||||
|
- 登录方式:手机号 + 短信验证码,复用 `loginBySms` / `sendSmsCaptcha` API(from `@/api/passport/login`)
|
||||||
|
- 点击计数使用 `useRef` 保证快速点击时的同步性,超过 3 秒未点击自动重置
|
||||||
|
- 第 4 次点击起显示步数提示(类似 Android 开发者模式),前 3 次仅轻震动反馈
|
||||||
|
- 登录成功后调用 `useUserContext().syncFromStorage()` 同步 React 登录态(`loginBySms` 内部已写入 storage 但不返回数据)
|
||||||
|
- 弹窗 UI 使用 NutUI `Input` + `Button`,带 60 秒倒计时
|
||||||
@@ -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 { 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({
|
definePageConfig({
|
||||||
navigationBarTitleText: '关于我们',
|
navigationBarTitleText: '关于我们',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 触发隐藏登录的点击次数 */
|
||||||
|
const DEV_LOGIN_CLICK_COUNT = 11
|
||||||
|
|
||||||
const AboutPage: React.FC = () => {
|
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 (
|
return (
|
||||||
<ScrollView scrollY className='min-h-screen bg-gray-50'>
|
<ScrollView scrollY className='min-h-screen bg-gray-50'>
|
||||||
{/* 头部品牌区 */}
|
{/* 头部品牌区 */}
|
||||||
@@ -74,10 +198,81 @@ const AboutPage: React.FC = () => {
|
|||||||
</View>
|
</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'>Copyright © 2024 鑫龙商贸电器</Text>
|
||||||
<Text className='text-xs text-gray-400 mt-1'>All Rights Reserved</Text>
|
<Text className='text-xs text-gray-400 mt-1'>All Rights Reserved</Text>
|
||||||
</View>
|
</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>
|
</ScrollView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
79
src/utils/privacy.ts
Normal file
79
src/utils/privacy.ts
Normal 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 }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user