Browse Source
【严重缺口修复】P2(验证码)/ P2Q(一键登录)此前只 navigate('P3'),从不写登录态——
全项目唯一写 token 的地方是微信链路。微信入口一旦摘除,isTokenValid 恒为 false,
每次冷启动都会把用户打回登录页。现三条通道均接入 useAuth().login()。
【新增通道】P1 主通道改为「验证码登录」,「一键登录」为次,新增「密码登录」(P2P):
- P2P 手机号 + 密码登录;P2R 设置 / 重置密码(同页两阶段:验证手机号 → 设新密码,
成功后直接下发登录态,用户无需再登录一次)
- 设置页新增 /settings/password 修改密码,改完强制重登
【密码规则】8–20 位且需含字母和数字,规则计算统一放在 api.ts 的 validatePassword /
validatePasswordConfirm,错误码 → 文案映射放在 constants 的 PASSWORD_RULE_LABEL,
按联合类型 PasswordRuleError 建模,新增成员漏配文案会被测试拦下。
【微信入口摘除】按产品决定(丁总 2026-09-14)前端不做 QQ / 微信登录:
P1Login 移除微信按钮、滑块验证与注销弹窗;P2W / B1 页面、useWechatLogin 状态机、
wechat-sdk 及三个接口函数全部保留待启用,接入路线见 微信登录接入方案.md。
【去重公共件】新增 PasswordField(密码输入 + 明文切换)、useAgreementGate(协议门卫,
此前在 3 个页面被逐字复制)、utils/validators(手机号 / 验证码规则的单一来源)。
【测试】新增 src/__tests__/password.test.ts(24 例)。全套 6 套 45 例通过,
tsc --noEmit 0 错误,eslint . 0 问题,check-routes 67 路由 0 失效跳转。
master
23 changed files with 1483 additions and 256 deletions
@ -0,0 +1,211 @@ |
|||||
|
import { useState } from 'react'; |
||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { useRouter } from 'expo-router'; |
||||
|
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { SafeAreaView } from 'react-native-safe-area-context'; |
||||
|
import { useChangePassword } from '../../src/hooks'; |
||||
|
import { useAuth } from '../../src/context/AuthContext'; |
||||
|
import { PasswordField } from '../../src/components/PasswordField'; |
||||
|
import { colors } from '../../src/theme'; |
||||
|
|
||||
|
/* ===================== 子组件:顶部导航 ===================== */ |
||||
|
|
||||
|
function Header({ onBack }: { onBack: () => void }) { |
||||
|
return ( |
||||
|
<View style={styles.header}> |
||||
|
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}> |
||||
|
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} /> |
||||
|
</Pressable> |
||||
|
<Text style={styles.headerTitle}>修改登录密码</Text> |
||||
|
<View style={styles.headerBtn} /> |
||||
|
</View> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/* ===================== 页面 ===================== */ |
||||
|
|
||||
|
/** |
||||
|
* 账号与安全 → 修改登录密码。 |
||||
|
* |
||||
|
* 入口由 MOCK_ACCOUNT_SECURITY 的 route 字段驱动(见 src/api/api.ts), |
||||
|
* 此前该菜单项没有 route,点击无任何反应。 |
||||
|
* 改密成功后强制重新登录,避免旧会话继续持有已失效的密码凭据。 |
||||
|
*/ |
||||
|
export default function ChangePasswordScreen() { |
||||
|
const router = useRouter(); |
||||
|
const { logout } = useAuth(); |
||||
|
const { change, loading, error, checkNewPassword } = useChangePassword(); |
||||
|
|
||||
|
const [oldPassword, setOldPassword] = useState(''); |
||||
|
const [newPassword, setNewPassword] = useState(''); |
||||
|
const [confirmPassword, setConfirmPassword] = useState(''); |
||||
|
|
||||
|
/** 规则与一致性校验在 API 层计算,这里只渲染文案 */ |
||||
|
const hint = |
||||
|
newPassword.length > 0 ? checkNewPassword(oldPassword, newPassword, confirmPassword) : null; |
||||
|
const canSubmit = |
||||
|
oldPassword.length > 0 && |
||||
|
newPassword.length > 0 && |
||||
|
confirmPassword.length > 0 && |
||||
|
hint === null && |
||||
|
!loading; |
||||
|
|
||||
|
const handleSubmit = async () => { |
||||
|
if (!canSubmit) return; |
||||
|
try { |
||||
|
await change({ oldPassword, newPassword, confirmPassword }); |
||||
|
} catch { |
||||
|
// 失败原因由 useChangePassword 记录,页面下方展示
|
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
Alert.alert('密码已修改', '请使用新密码重新登录', [ |
||||
|
{ |
||||
|
text: '重新登录', |
||||
|
onPress: () => { |
||||
|
logout(); |
||||
|
router.replace('/register'); |
||||
|
}, |
||||
|
}, |
||||
|
]); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}> |
||||
|
<Header onBack={() => router.back()} /> |
||||
|
|
||||
|
<ScrollView |
||||
|
style={styles.scroll} |
||||
|
contentContainerStyle={styles.content} |
||||
|
showsVerticalScrollIndicator={false} |
||||
|
> |
||||
|
<Text style={styles.tip}> |
||||
|
为保障账号安全,修改密码后需要使用新密码重新登录。 |
||||
|
</Text> |
||||
|
|
||||
|
<View style={styles.card}> |
||||
|
<PasswordField |
||||
|
value={oldPassword} |
||||
|
onChangeText={setOldPassword} |
||||
|
placeholder="请输入原密码" |
||||
|
/> |
||||
|
<View style={styles.gap} /> |
||||
|
<PasswordField |
||||
|
value={newPassword} |
||||
|
onChangeText={setNewPassword} |
||||
|
placeholder="请输入新密码" |
||||
|
/> |
||||
|
<View style={styles.gap} /> |
||||
|
<PasswordField |
||||
|
value={confirmPassword} |
||||
|
onChangeText={setConfirmPassword} |
||||
|
placeholder="请再次输入新密码" |
||||
|
/> |
||||
|
</View> |
||||
|
|
||||
|
{hint ? ( |
||||
|
<Text style={styles.hintDanger}>{hint}</Text> |
||||
|
) : ( |
||||
|
<Text style={styles.hint}>8–20 位,需同时包含字母和数字</Text> |
||||
|
)} |
||||
|
|
||||
|
{error ? <Text style={styles.formError}>{error.message}</Text> : null} |
||||
|
|
||||
|
<Pressable |
||||
|
style={[styles.submitBtn, !canSubmit && styles.submitDisabled]} |
||||
|
disabled={!canSubmit} |
||||
|
onPress={handleSubmit} |
||||
|
> |
||||
|
<Text style={styles.submitText}>{loading ? '提交中…' : '确认修改'}</Text> |
||||
|
</Pressable> |
||||
|
</ScrollView> |
||||
|
</SafeAreaView> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
safeArea: { |
||||
|
flex: 1, |
||||
|
backgroundColor: colors.pageBg, |
||||
|
}, |
||||
|
header: { |
||||
|
flexDirection: 'row', |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'space-between', |
||||
|
paddingHorizontal: 8, |
||||
|
height: 48, |
||||
|
backgroundColor: colors.white, |
||||
|
borderBottomWidth: 1, |
||||
|
borderBottomColor: colors.borderLight, |
||||
|
}, |
||||
|
headerBtn: { |
||||
|
width: 40, |
||||
|
height: 40, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
}, |
||||
|
headerTitle: { |
||||
|
flex: 1, |
||||
|
textAlign: 'center', |
||||
|
fontSize: 17, |
||||
|
fontWeight: '700', |
||||
|
color: colors.textPrimary, |
||||
|
}, |
||||
|
scroll: { |
||||
|
flex: 1, |
||||
|
}, |
||||
|
content: { |
||||
|
padding: 12, |
||||
|
paddingBottom: 24, |
||||
|
}, |
||||
|
tip: { |
||||
|
fontSize: 12, |
||||
|
lineHeight: 17, |
||||
|
color: colors.textSecondary, |
||||
|
marginBottom: 10, |
||||
|
paddingHorizontal: 4, |
||||
|
}, |
||||
|
card: { |
||||
|
backgroundColor: colors.white, |
||||
|
borderRadius: 8, |
||||
|
paddingHorizontal: 14, |
||||
|
paddingVertical: 6, |
||||
|
}, |
||||
|
gap: { |
||||
|
height: 6, |
||||
|
}, |
||||
|
hint: { |
||||
|
marginTop: 12, |
||||
|
paddingHorizontal: 4, |
||||
|
fontSize: 12, |
||||
|
color: colors.textHint, |
||||
|
}, |
||||
|
hintDanger: { |
||||
|
marginTop: 12, |
||||
|
paddingHorizontal: 4, |
||||
|
fontSize: 12, |
||||
|
color: colors.danger, |
||||
|
}, |
||||
|
formError: { |
||||
|
marginTop: 10, |
||||
|
paddingHorizontal: 4, |
||||
|
fontSize: 12, |
||||
|
color: colors.danger, |
||||
|
}, |
||||
|
submitBtn: { |
||||
|
marginTop: 24, |
||||
|
height: 50, |
||||
|
borderRadius: 12, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
backgroundColor: colors.primaryBtn, |
||||
|
}, |
||||
|
submitDisabled: { |
||||
|
backgroundColor: colors.textDisabled, |
||||
|
}, |
||||
|
submitText: { |
||||
|
fontSize: 16, |
||||
|
fontWeight: '700', |
||||
|
color: colors.white, |
||||
|
}, |
||||
|
}); |
||||
@ -0,0 +1,139 @@ |
|||||
|
import { useState } from 'react'; |
||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { useRouter } from 'expo-router'; |
||||
|
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useAgreementGate, useOnboarding, usePasswordLogin } from '../../../src/hooks'; |
||||
|
import { isValidPhone } from '../../../src/utils'; |
||||
|
import { useAuth } from '../../../src/context/AuthContext'; |
||||
|
import { useRole } from '../../../src/context/RoleContext'; |
||||
|
import { PasswordField } from '../../../src/components/PasswordField'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { AgreementCheckbox } from '../ui/AgreementCheckbox'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
import { AgreementModal } from '../ui/AgreementModal'; |
||||
|
|
||||
|
/** |
||||
|
* P2P 手机号 + 密码登录。 |
||||
|
* |
||||
|
* 与 P2(验证码)、P2R(设置密码)互相可达:本页底部可切回验证码登录,忘记密码进 P2R。 |
||||
|
* 登录成功后按是否已选过身份决定落地页,与 P1 微信分支的原逻辑一致。 |
||||
|
*/ |
||||
|
export function P2PasswordLogin() { |
||||
|
const { data, setData, navigate, back } = useOnboarding(); |
||||
|
const { gate, modalProps } = useAgreementGate(); |
||||
|
const { login, loading, error } = usePasswordLogin(); |
||||
|
/** 登录态写入:换取令牌后必须落库,否则冷启动会被打回登录页 */ |
||||
|
const { login: writeAuth } = useAuth(); |
||||
|
const { role } = useRole(); |
||||
|
const router = useRouter(); |
||||
|
|
||||
|
const [password, setPassword] = useState(''); |
||||
|
|
||||
|
const agreed = data.agreed; |
||||
|
const phone = data.phone; |
||||
|
const phoneValid = isValidPhone(phone); |
||||
|
const canSubmit = phoneValid && password.length > 0; |
||||
|
|
||||
|
const handleLogin = async () => { |
||||
|
if (!canSubmit || loading) return; |
||||
|
try { |
||||
|
const result = await login(phone, password); |
||||
|
writeAuth(result.token); |
||||
|
} catch { |
||||
|
// 失败原因由 usePasswordLogin 记录,页面下方展示
|
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 已有身份的老用户直接进对应主界面,新用户才去选身份
|
||||
|
if (role) { |
||||
|
router.replace(role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs'); |
||||
|
return; |
||||
|
} |
||||
|
navigate('P3'); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell showBack onBack={back}> |
||||
|
<Text style={styles.h1}>密码登录</Text> |
||||
|
<Text style={styles.subtitle}>使用手机号与登录密码进入有职</Text> |
||||
|
|
||||
|
{/* 手机号 */} |
||||
|
<View style={styles.fieldRow}> |
||||
|
<Text style={styles.prefix}>+86</Text> |
||||
|
<Ionicons name="caret-down" size={14} color={colors.textSecondary} /> |
||||
|
<View style={styles.divider} /> |
||||
|
<TextInput |
||||
|
style={styles.input} |
||||
|
value={phone} |
||||
|
onChangeText={(t) => setData({ phone: t.replace(/[^\d]/g, '').slice(0, 11) })} |
||||
|
placeholder="请输入手机号" |
||||
|
placeholderTextColor={colors.textHint} |
||||
|
keyboardType="phone-pad" |
||||
|
maxLength={11} |
||||
|
/> |
||||
|
{phone.length > 0 ? ( |
||||
|
<Pressable onPress={() => setData({ phone: '' })} hitSlop={8}> |
||||
|
<Ionicons name="close-circle" size={18} color={colors.textDisabled} /> |
||||
|
</Pressable> |
||||
|
) : null} |
||||
|
</View> |
||||
|
<View style={styles.underline} /> |
||||
|
|
||||
|
{/* 密码 */} |
||||
|
<View style={styles.passwordArea}> |
||||
|
<PasswordField value={password} onChangeText={setPassword} placeholder="请输入登录密码" /> |
||||
|
</View> |
||||
|
|
||||
|
<Pressable onPress={() => gate(() => navigate('P2R'))} hitSlop={8}> |
||||
|
<Text style={styles.forgotLink}>忘记密码?</Text> |
||||
|
</Pressable> |
||||
|
|
||||
|
<View style={styles.btnArea}> |
||||
|
<PrimaryButton label="登录" active={canSubmit} loading={loading} onPress={() => gate(handleLogin)} /> |
||||
|
</View> |
||||
|
|
||||
|
{error ? <Text style={styles.formError}>{error.message}</Text> : null} |
||||
|
|
||||
|
<View style={styles.agree}> |
||||
|
<AgreementCheckbox |
||||
|
checked={agreed} |
||||
|
onToggle={() => setData({ agreed: !agreed })} |
||||
|
text={ |
||||
|
<Text style={styles.agreeText}> |
||||
|
已阅读并同意 |
||||
|
<Text style={styles.link} onPress={() => navigate('B2')}>《有职用户协议》</Text> |
||||
|
和 |
||||
|
<Text style={styles.link} onPress={() => navigate('B2')}>《隐私政策》</Text> |
||||
|
,允许有职统一管理本人账号信息 |
||||
|
</Text> |
||||
|
} |
||||
|
/> |
||||
|
</View> |
||||
|
|
||||
|
<Pressable onPress={() => gate(() => navigate('P2'))} hitSlop={8}> |
||||
|
<Text style={styles.switchLink}>验证码登录</Text> |
||||
|
</Pressable> |
||||
|
|
||||
|
<AgreementModal {...modalProps} /> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
h1: { fontSize: 26, fontWeight: '800', color: colors.textPrimary, marginTop: 12 }, |
||||
|
subtitle: { fontSize: 14, color: colors.textSecondary, marginTop: 10 }, |
||||
|
fieldRow: { flexDirection: 'row', alignItems: 'center', height: 56, marginTop: 28, gap: 6 }, |
||||
|
prefix: { fontSize: 17, fontWeight: '700', color: colors.textPrimary }, |
||||
|
divider: { width: 1, height: 20, backgroundColor: colors.border, marginHorizontal: 10 }, |
||||
|
input: { flex: 1, fontSize: 17, color: colors.textPrimary, padding: 0 }, |
||||
|
underline: { height: 1, backgroundColor: colors.borderLight }, |
||||
|
passwordArea: { marginTop: 22 }, |
||||
|
forgotLink: { marginTop: 14, alignSelf: 'flex-end', fontSize: 13, color: colors.primaryText, fontWeight: '600' }, |
||||
|
btnArea: { marginTop: 26 }, |
||||
|
formError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger }, |
||||
|
agree: { marginTop: 18 }, |
||||
|
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 }, |
||||
|
link: { color: colors.primary, fontWeight: '600' }, |
||||
|
switchLink: { marginTop: 26, textAlign: 'center', fontSize: 14, color: colors.textSecondary }, |
||||
|
}); |
||||
@ -0,0 +1,182 @@ |
|||||
|
import { useState } from 'react'; |
||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { useRouter } from 'expo-router'; |
||||
|
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useAgreementGate, useOnboarding, usePasswordReset, useSmsCode } from '../../../src/hooks'; |
||||
|
import { isValidPhone, isValidSmsCode, SMS_CODE_MAX_LENGTH } from '../../../src/utils'; |
||||
|
import { useAuth } from '../../../src/context/AuthContext'; |
||||
|
import { useRole } from '../../../src/context/RoleContext'; |
||||
|
import { PasswordField } from '../../../src/components/PasswordField'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { CountdownButton } from '../ui/CountdownButton'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
import { AgreementModal } from '../ui/AgreementModal'; |
||||
|
|
||||
|
/** 重置阶段:验证手机号 → 设置新密码 */ |
||||
|
type Stage = 'verify' | 'password'; |
||||
|
|
||||
|
/** |
||||
|
* P2R 设置 / 重置登录密码(同页两阶段)。 |
||||
|
* |
||||
|
* 一个页面同时承担两件事: |
||||
|
* 1. 忘记密码 —— 老用户忘记原密码,验证手机号后重设; |
||||
|
* 2. 首次设置密码 —— 「登录即注册」的用户天生没有密码,走同一入口首次获得密码。 |
||||
|
* 因此文案对两者都成立,不区分来源,避免出现「有密码登录入口但没人拿得到密码」的死结。 |
||||
|
*/ |
||||
|
export function P2PasswordReset() { |
||||
|
const { data, setData, navigate, back } = useOnboarding(); |
||||
|
const { gate, modalProps } = useAgreementGate(); |
||||
|
const { reset, loading, error, checkPassword } = usePasswordReset(); |
||||
|
const { send: sendCode, error: smsError } = useSmsCode(); |
||||
|
/** 重置成功即写入登录态,用户不必再用新密码登录一次 */ |
||||
|
const { login: writeAuth } = useAuth(); |
||||
|
const { role } = useRole(); |
||||
|
const router = useRouter(); |
||||
|
|
||||
|
const [stage, setStage] = useState<Stage>('verify'); |
||||
|
const [code, setCode] = useState(''); |
||||
|
const [password, setPassword] = useState(''); |
||||
|
const [confirm, setConfirm] = useState(''); |
||||
|
|
||||
|
const phone = data.phone; |
||||
|
const phoneValid = isValidPhone(phone); |
||||
|
const codeValid = isValidSmsCode(code); |
||||
|
|
||||
|
/** 密码规则提示(规则在 API 层计算,这里只渲染文案) */ |
||||
|
const hint = password.length > 0 ? checkPassword(password, confirm) : null; |
||||
|
const canSubmit = codeValid && password.length > 0 && confirm.length > 0 && hint === null && !loading; |
||||
|
|
||||
|
/** |
||||
|
* 发送验证码(只发送,不切阶段)。 |
||||
|
* 验证码是否正确由最终提交时的后端校验统一判定,本步不做本地校验, |
||||
|
* 避免「前端说对了、后端说错了」的不一致。 |
||||
|
*/ |
||||
|
const sendCodeNow = async () => { |
||||
|
if (!phoneValid) return; |
||||
|
try { |
||||
|
await sendCode(phone); |
||||
|
} catch { |
||||
|
// 失败原因由 useSmsCode 记录,页面下方展示
|
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const handleSubmit = async () => { |
||||
|
if (!canSubmit) return; |
||||
|
let result; |
||||
|
try { |
||||
|
result = await reset({ phone, code, password }); |
||||
|
writeAuth(result.token); |
||||
|
} catch { |
||||
|
// 失败原因由 usePasswordReset 记录,页面下方展示
|
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 已有身份的老用户直接进对应主界面,新用户才去选身份
|
||||
|
if (role) { |
||||
|
router.replace(role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs'); |
||||
|
return; |
||||
|
} |
||||
|
navigate('P3'); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell showBack onBack={stage === 'password' ? () => setStage('verify') : back}> |
||||
|
{stage === 'verify' ? ( |
||||
|
/* ---------- 阶段1:验证手机号 ---------- */ |
||||
|
<View> |
||||
|
<Text style={styles.h1}>验证手机号</Text> |
||||
|
<Text style={styles.subtitle}>验证通过后即可设置新的登录密码</Text> |
||||
|
|
||||
|
<View style={styles.fieldRow}> |
||||
|
<Text style={styles.prefix}>+86</Text> |
||||
|
<Ionicons name="caret-down" size={14} color={colors.textSecondary} /> |
||||
|
<View style={styles.divider} /> |
||||
|
<TextInput |
||||
|
style={styles.input} |
||||
|
value={phone} |
||||
|
onChangeText={(t) => setData({ phone: t.replace(/[^\d]/g, '').slice(0, 11) })} |
||||
|
placeholder="请输入手机号" |
||||
|
placeholderTextColor={colors.textHint} |
||||
|
keyboardType="phone-pad" |
||||
|
maxLength={11} |
||||
|
/> |
||||
|
{phone.length > 0 ? ( |
||||
|
<Pressable onPress={() => setData({ phone: '' })} hitSlop={8}> |
||||
|
<Ionicons name="close-circle" size={18} color={colors.textDisabled} /> |
||||
|
</Pressable> |
||||
|
) : null} |
||||
|
</View> |
||||
|
<View style={styles.underline} /> |
||||
|
|
||||
|
<View style={styles.fieldRow}> |
||||
|
<TextInput |
||||
|
style={styles.input} |
||||
|
value={code} |
||||
|
onChangeText={(t) => setCode(t.replace(/[^\d]/g, '').slice(0, SMS_CODE_MAX_LENGTH))} |
||||
|
placeholder="请输入验证码" |
||||
|
placeholderTextColor={colors.textHint} |
||||
|
keyboardType="number-pad" |
||||
|
maxLength={SMS_CODE_MAX_LENGTH} |
||||
|
/> |
||||
|
<CountdownButton disabled={!phoneValid} onSend={() => void sendCodeNow()} /> |
||||
|
</View> |
||||
|
<View style={styles.underline} /> |
||||
|
|
||||
|
<View style={styles.btnArea}> |
||||
|
<PrimaryButton |
||||
|
label="下一步" |
||||
|
active={phoneValid && codeValid} |
||||
|
onPress={() => gate(() => setStage('password'))} |
||||
|
/> |
||||
|
</View> |
||||
|
|
||||
|
{smsError ? <Text style={styles.formError}>{smsError.message}</Text> : null} |
||||
|
</View> |
||||
|
) : ( |
||||
|
/* ---------- 阶段2:设置新密码 ---------- */ |
||||
|
<View> |
||||
|
<Text style={styles.h1}>设置登录密码</Text> |
||||
|
<Text style={styles.subtitle}>设置后可用手机号 + 密码登录</Text> |
||||
|
|
||||
|
<View style={styles.passwordArea}> |
||||
|
<PasswordField value={password} onChangeText={setPassword} placeholder="请输入新密码" /> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.passwordArea}> |
||||
|
<PasswordField value={confirm} onChangeText={setConfirm} placeholder="请再次输入新密码" /> |
||||
|
</View> |
||||
|
|
||||
|
{hint ? ( |
||||
|
<Text style={styles.hintDanger}>{hint}</Text> |
||||
|
) : ( |
||||
|
<Text style={styles.hint}>8–20 位,需同时包含字母和数字</Text> |
||||
|
)} |
||||
|
|
||||
|
<View style={styles.btnArea}> |
||||
|
<PrimaryButton label="完成" active={canSubmit} loading={loading} onPress={() => gate(handleSubmit)} /> |
||||
|
</View> |
||||
|
|
||||
|
{error ? <Text style={styles.formError}>{error.message}</Text> : null} |
||||
|
</View> |
||||
|
)} |
||||
|
|
||||
|
<AgreementModal {...modalProps} /> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
h1: { fontSize: 26, fontWeight: '800', color: colors.textPrimary, marginTop: 12 }, |
||||
|
subtitle: { fontSize: 14, color: colors.textSecondary, marginTop: 10 }, |
||||
|
fieldRow: { flexDirection: 'row', alignItems: 'center', height: 56, marginTop: 26, gap: 6 }, |
||||
|
prefix: { fontSize: 17, fontWeight: '700', color: colors.textPrimary }, |
||||
|
divider: { width: 1, height: 20, backgroundColor: colors.border, marginHorizontal: 10 }, |
||||
|
input: { flex: 1, fontSize: 17, color: colors.textPrimary, padding: 0 }, |
||||
|
underline: { height: 1, backgroundColor: colors.borderLight }, |
||||
|
passwordArea: { marginTop: 22 }, |
||||
|
hint: { marginTop: 14, fontSize: 12, color: colors.textHint }, |
||||
|
hintDanger: { marginTop: 14, fontSize: 12, color: colors.danger }, |
||||
|
btnArea: { marginTop: 26 }, |
||||
|
formError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger }, |
||||
|
}); |
||||
@ -0,0 +1,177 @@ |
|||||
|
import { |
||||
|
changePassword, |
||||
|
getPasswordRuleMessage, |
||||
|
loginByPassword, |
||||
|
resetPassword, |
||||
|
sendSmsCode, |
||||
|
validatePassword, |
||||
|
validatePasswordConfirm, |
||||
|
} from '../api/api'; |
||||
|
import { PASSWORD_RULE_LABEL } from '../constants'; |
||||
|
import type { PasswordRuleError } from '../types'; |
||||
|
import { isValidPhone, isValidSmsCode } from '../utils'; |
||||
|
|
||||
|
// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage,需内存版 mock(与 salary.test.ts 同)
|
||||
|
jest.mock('../lib/storage', () => ({ |
||||
|
storageGet: jest.fn(async () => null), |
||||
|
storageSet: jest.fn(async () => undefined), |
||||
|
storageRemove: jest.fn(async () => undefined), |
||||
|
})); |
||||
|
|
||||
|
/** 联合类型的全部成员:新增成员而漏配文案时,下面的完整性用例会失败 */ |
||||
|
const ALL_RULE_ERRORS: PasswordRuleError[] = [ |
||||
|
'empty', |
||||
|
'too-short', |
||||
|
'too-long', |
||||
|
'need-letter-digit', |
||||
|
'mismatch', |
||||
|
'same-as-old', |
||||
|
]; |
||||
|
|
||||
|
describe('validatePassword(密码强度业务规则)', () => { |
||||
|
it('空密码返回 empty', () => { |
||||
|
expect(validatePassword('')).toBe('empty'); |
||||
|
}); |
||||
|
|
||||
|
it('少于 8 位返回 too-short', () => { |
||||
|
expect(validatePassword('ab12')).toBe('too-short'); |
||||
|
expect(validatePassword('abc1234')).toBe('too-short'); |
||||
|
}); |
||||
|
|
||||
|
it('超过 20 位返回 too-long', () => { |
||||
|
expect(validatePassword('abc12345678901234567890')).toBe('too-long'); |
||||
|
}); |
||||
|
|
||||
|
it('纯数字返回 need-letter-digit', () => { |
||||
|
expect(validatePassword('12345678')).toBe('need-letter-digit'); |
||||
|
}); |
||||
|
|
||||
|
it('纯字母返回 need-letter-digit', () => { |
||||
|
expect(validatePassword('abcdefgh')).toBe('need-letter-digit'); |
||||
|
}); |
||||
|
|
||||
|
it('8–20 位且含字母和数字时通过', () => { |
||||
|
expect(validatePassword('abc12345')).toBeNull(); |
||||
|
expect(validatePassword('a1b2c3d4e5f6g7h8i9j0')).toBeNull(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('validatePasswordConfirm(两次输入一致性)', () => { |
||||
|
it('一致时返回 null', () => { |
||||
|
expect(validatePasswordConfirm('abc12345', 'abc12345')).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
it('不一致时返回 mismatch', () => { |
||||
|
expect(validatePasswordConfirm('abc12345', 'abc12346')).toBe('mismatch'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('PASSWORD_RULE_LABEL(错误码 → 文案映射完整性)', () => { |
||||
|
it('每个错误码都有非空中文文案', () => { |
||||
|
ALL_RULE_ERRORS.forEach((code) => { |
||||
|
const label = PASSWORD_RULE_LABEL[code]; |
||||
|
expect(typeof label).toBe('string'); |
||||
|
expect(label.length).toBeGreaterThan(0); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('getPasswordRuleMessage 与映射表一致', () => { |
||||
|
ALL_RULE_ERRORS.forEach((code) => { |
||||
|
expect(getPasswordRuleMessage(code)).toBe(PASSWORD_RULE_LABEL[code]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('validators(手机号 / 验证码格式)', () => { |
||||
|
it('手机号必须是 1 开头共 11 位', () => { |
||||
|
expect(isValidPhone('13800138000')).toBe(true); |
||||
|
expect(isValidPhone('1380013800')).toBe(false); |
||||
|
expect(isValidPhone('23800138000')).toBe(false); |
||||
|
expect(isValidPhone('138001380001')).toBe(false); |
||||
|
expect(isValidPhone('')).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('验证码为 4–6 位数字', () => { |
||||
|
expect(isValidSmsCode('1234')).toBe(true); |
||||
|
expect(isValidSmsCode('123456')).toBe(true); |
||||
|
expect(isValidSmsCode('123')).toBe(false); |
||||
|
expect(isValidSmsCode('1234567')).toBe(false); |
||||
|
expect(isValidSmsCode('12a4')).toBe(false); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('loginByPassword(手机号 + 密码登录)', () => { |
||||
|
it('手机号非法时拒绝', async () => { |
||||
|
await expect(loginByPassword('138', 'abc12345')).rejects.toThrow('手机号格式不正确'); |
||||
|
}); |
||||
|
|
||||
|
it('密码不合规时统一提示账号或密码错误(不泄露账号是否存在)', async () => { |
||||
|
await expect(loginByPassword('13800138000', 'abc')).rejects.toThrow('手机号或密码错误'); |
||||
|
}); |
||||
|
|
||||
|
it('参数合法时返回令牌', async () => { |
||||
|
const result = await loginByPassword('13800138000', 'abc12345'); |
||||
|
expect(result.phone).toBe('13800138000'); |
||||
|
expect(result.token).toContain('13800138000'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('sendSmsCode(发送验证码)', () => { |
||||
|
it('手机号非法时拒绝', async () => { |
||||
|
await expect(sendSmsCode('138')).rejects.toThrow('手机号格式不正确'); |
||||
|
}); |
||||
|
|
||||
|
it('手机号合法时成功', async () => { |
||||
|
await expect(sendSmsCode('13800138000')).resolves.toBeUndefined(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('resetPassword(重置 / 首次设置密码)', () => { |
||||
|
it('验证码不合法时拒绝', async () => { |
||||
|
await expect( |
||||
|
resetPassword({ phone: '13800138000', code: '12', password: 'abc12345' }), |
||||
|
).rejects.toThrow('验证码不正确'); |
||||
|
}); |
||||
|
|
||||
|
it('新密码不合规时返回对应文案', async () => { |
||||
|
await expect( |
||||
|
resetPassword({ phone: '13800138000', code: '1234', password: '12345678' }), |
||||
|
).rejects.toThrow(PASSWORD_RULE_LABEL['need-letter-digit']); |
||||
|
}); |
||||
|
|
||||
|
it('参数合法时直接下发登录态,用户无需再登录一次', async () => { |
||||
|
const result = await resetPassword({ |
||||
|
phone: '13800138000', |
||||
|
code: '1234', |
||||
|
password: 'abc12345', |
||||
|
}); |
||||
|
expect(result.phone).toBe('13800138000'); |
||||
|
expect(result.token).toContain('13800138000'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('changePassword(已登录态改密)', () => { |
||||
|
it('未填原密码时拒绝', async () => { |
||||
|
await expect( |
||||
|
changePassword({ oldPassword: '', newPassword: 'abc12345', confirmPassword: 'abc12345' }), |
||||
|
).rejects.toThrow('请输入原密码'); |
||||
|
}); |
||||
|
|
||||
|
it('两次新密码不一致时拒绝', async () => { |
||||
|
await expect( |
||||
|
changePassword({ oldPassword: 'old12345', newPassword: 'abc12345', confirmPassword: 'abc12346' }), |
||||
|
).rejects.toThrow(PASSWORD_RULE_LABEL.mismatch); |
||||
|
}); |
||||
|
|
||||
|
it('新密码与原密码相同时拒绝', async () => { |
||||
|
await expect( |
||||
|
changePassword({ oldPassword: 'abc12345', newPassword: 'abc12345', confirmPassword: 'abc12345' }), |
||||
|
).rejects.toThrow(PASSWORD_RULE_LABEL['same-as-old']); |
||||
|
}); |
||||
|
|
||||
|
it('参数合法时成功', async () => { |
||||
|
await expect( |
||||
|
changePassword({ oldPassword: 'old12345', newPassword: 'abc12345', confirmPassword: 'abc12345' }), |
||||
|
).resolves.toBeUndefined(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,72 @@ |
|||||
|
import { useState } from 'react'; |
||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { Pressable, StyleSheet, TextInput, View } from 'react-native'; |
||||
|
import { PASSWORD_MAX_LENGTH } from '../constants'; |
||||
|
import { colors } from '../theme'; |
||||
|
|
||||
|
interface Props { |
||||
|
value: string; |
||||
|
onChangeText: (t: string) => void; |
||||
|
placeholder?: string; |
||||
|
/** 最大长度,默认取密码规则上限 */ |
||||
|
maxLength?: number; |
||||
|
/** 失焦回调,供页面触发规则校验提示 */ |
||||
|
onBlur?: () => void; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 密码输入行:下划线样式 + 明文切换(眼睛图标)+ 一键清空。 |
||||
|
* 登录页、重置密码页、账号安全改密页共用,避免三处各写一份输入样式。 |
||||
|
*/ |
||||
|
export function PasswordField({ |
||||
|
value, |
||||
|
onChangeText, |
||||
|
placeholder = '请输入密码', |
||||
|
maxLength = PASSWORD_MAX_LENGTH, |
||||
|
onBlur, |
||||
|
}: Props) { |
||||
|
const [visible, setVisible] = useState(false); |
||||
|
|
||||
|
return ( |
||||
|
<> |
||||
|
<View style={styles.row}> |
||||
|
<TextInput |
||||
|
style={styles.input} |
||||
|
value={value} |
||||
|
onChangeText={onChangeText} |
||||
|
placeholder={placeholder} |
||||
|
placeholderTextColor={colors.textHint} |
||||
|
secureTextEntry={!visible} |
||||
|
autoCapitalize="none" |
||||
|
autoCorrect={false} |
||||
|
maxLength={maxLength} |
||||
|
onBlur={onBlur} |
||||
|
/> |
||||
|
{value.length > 0 ? ( |
||||
|
<Pressable onPress={() => onChangeText('')} hitSlop={8}> |
||||
|
<Ionicons name="close-circle" size={18} color={colors.textDisabled} /> |
||||
|
</Pressable> |
||||
|
) : null} |
||||
|
<Pressable onPress={() => setVisible((v) => !v)} hitSlop={8}> |
||||
|
<Ionicons |
||||
|
name={visible ? 'eye-off-outline' : 'eye-outline'} |
||||
|
size={20} |
||||
|
color={colors.textHint} |
||||
|
/> |
||||
|
</Pressable> |
||||
|
</View> |
||||
|
<View style={styles.underline} /> |
||||
|
</> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
row: { |
||||
|
flexDirection: 'row', |
||||
|
alignItems: 'center', |
||||
|
height: 56, |
||||
|
gap: 10, |
||||
|
}, |
||||
|
input: { flex: 1, fontSize: 17, color: colors.textPrimary, padding: 0 }, |
||||
|
underline: { height: 1, backgroundColor: colors.borderLight }, |
||||
|
}); |
||||
@ -0,0 +1,73 @@ |
|||||
|
import { useCallback, useState } from 'react'; |
||||
|
import { useOnboarding } from './useOnboardingFlow'; |
||||
|
|
||||
|
/** |
||||
|
* 协议门卫(登录类页面共用)。 |
||||
|
* |
||||
|
* 此前 gate / pending / accept 三段逻辑被逐字复制在 P1Login、P2PhoneCode、P2QuickLogin |
||||
|
* 三个页面里,新增密码登录页会变成第 4、5 份,故抽到此处统一维护。 |
||||
|
* |
||||
|
* 用法: |
||||
|
* ```tsx
|
||||
|
* const { gate, modalProps } = useAgreementGate(); |
||||
|
* <Pressable onPress={() => gate(() => navigate('P3'))} /> |
||||
|
* <AgreementModal {...modalProps} /> |
||||
|
* ``` |
||||
|
*/ |
||||
|
|
||||
|
export interface AgreementGate { |
||||
|
/** 门卫:已勾选协议直接执行动作;未勾选则弹协议弹窗,点「同意」后继续执行 */ |
||||
|
gate: (action: () => void) => void; |
||||
|
/** 直接展开给 <AgreementModal {...modalProps} /> */ |
||||
|
modalProps: { |
||||
|
visible: boolean; |
||||
|
onAccept: () => void; |
||||
|
onDecline: () => void; |
||||
|
onOpenAgreement: () => void; |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
export function useAgreementGate(): AgreementGate { |
||||
|
const { data, setData, navigate } = useOnboarding(); |
||||
|
/** 未勾选协议时被拦截的动作,弹窗「同意」后继续执行 */ |
||||
|
const [pending, setPending] = useState<(() => void) | null>(null); |
||||
|
|
||||
|
const gate = useCallback( |
||||
|
(action: () => void) => { |
||||
|
if (data.agreed) { |
||||
|
action(); |
||||
|
return; |
||||
|
} |
||||
|
// 动作是函数,必须包一层,否则 setState 会把它当成更新函数执行
|
||||
|
setPending(() => action); |
||||
|
}, |
||||
|
[data.agreed], |
||||
|
); |
||||
|
|
||||
|
const onAccept = useCallback(() => { |
||||
|
setData({ agreed: true }); |
||||
|
// 先取出待执行动作再清空:避免在 setState 更新函数里做副作用(StrictMode 下会重复执行)
|
||||
|
const act = pending; |
||||
|
setPending(null); |
||||
|
act?.(); |
||||
|
}, [pending, setData]); |
||||
|
|
||||
|
const onDecline = useCallback(() => { |
||||
|
setPending(null); |
||||
|
}, []); |
||||
|
|
||||
|
const onOpenAgreement = useCallback(() => { |
||||
|
setPending(null); |
||||
|
navigate('B2'); |
||||
|
}, [navigate]); |
||||
|
|
||||
|
return { |
||||
|
gate, |
||||
|
modalProps: { |
||||
|
visible: pending !== null, |
||||
|
onAccept, |
||||
|
onDecline, |
||||
|
onOpenAgreement, |
||||
|
}, |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,69 @@ |
|||||
|
import { useCallback, useState } from 'react'; |
||||
|
import { |
||||
|
changePassword, |
||||
|
getPasswordRuleMessage, |
||||
|
validatePassword, |
||||
|
validatePasswordConfirm, |
||||
|
} from '../api'; |
||||
|
import type { ChangePasswordPayload } from '../types'; |
||||
|
|
||||
|
export interface UseChangePasswordReturn { |
||||
|
/** 修改登录密码(已登录态,需校验原密码) */ |
||||
|
change: (payload: ChangePasswordPayload) => Promise<void>; |
||||
|
/** 提交进行中 */ |
||||
|
loading: boolean; |
||||
|
/** 失败原因(用于页面提示) */ |
||||
|
error: Error | null; |
||||
|
/** 修改成功标记(页面据此提示并强制重新登录) */ |
||||
|
success: boolean; |
||||
|
/** |
||||
|
* 新密码规则校验(含与原密码比对):返回中文提示,null 表示通过。 |
||||
|
*/ |
||||
|
checkNewPassword: ( |
||||
|
oldPassword: string, |
||||
|
newPassword: string, |
||||
|
confirmPassword: string, |
||||
|
) => string | null; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 修改登录密码(账号与安全 → 修改登录密码)。 |
||||
|
* 改密成功后需强制重新登录,因此只回传 success 标记,由页面决定后续跳转。 |
||||
|
*/ |
||||
|
export function useChangePassword(): UseChangePasswordReturn { |
||||
|
const [loading, setLoading] = useState(false); |
||||
|
const [error, setError] = useState<Error | null>(null); |
||||
|
const [success, setSuccess] = useState(false); |
||||
|
|
||||
|
const change = useCallback(async (payload: ChangePasswordPayload) => { |
||||
|
setLoading(true); |
||||
|
setError(null); |
||||
|
try { |
||||
|
await changePassword(payload); |
||||
|
setSuccess(true); |
||||
|
} catch (e) { |
||||
|
const err = e instanceof Error ? e : new Error('密码修改失败,请稍后重试'); |
||||
|
setError(err); |
||||
|
throw err; |
||||
|
} finally { |
||||
|
setLoading(false); |
||||
|
} |
||||
|
}, []); |
||||
|
|
||||
|
const checkNewPassword = useCallback( |
||||
|
(oldPassword: string, newPassword: string, confirmPassword: string) => { |
||||
|
const ruleError = validatePassword(newPassword); |
||||
|
if (ruleError) return getPasswordRuleMessage(ruleError); |
||||
|
// 确认框还没开始输入时不提示不一致
|
||||
|
if (confirmPassword.length > 0) { |
||||
|
const confirmError = validatePasswordConfirm(newPassword, confirmPassword); |
||||
|
if (confirmError) return getPasswordRuleMessage(confirmError); |
||||
|
} |
||||
|
if (newPassword === oldPassword) return getPasswordRuleMessage('same-as-old'); |
||||
|
return null; |
||||
|
}, |
||||
|
[], |
||||
|
); |
||||
|
|
||||
|
return { change, loading, error, success, checkNewPassword }; |
||||
|
} |
||||
@ -0,0 +1,36 @@ |
|||||
|
import { useCallback, useState } from 'react'; |
||||
|
import { loginByPassword, type LoginResult } from '../api'; |
||||
|
|
||||
|
export interface UsePasswordLoginReturn { |
||||
|
/** 手机号 + 密码登录,成功返回 LoginResult */ |
||||
|
login: (phone: string, password: string) => Promise<LoginResult>; |
||||
|
/** 登录进行中 */ |
||||
|
loading: boolean; |
||||
|
/** 登录失败的错误(用于页面提示) */ |
||||
|
error: Error | null; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 密码登录(一次性变更,非取数)。 |
||||
|
* 包装 api.loginByPassword,向上暴露 loading / error 供页面控制按钮与提示。 |
||||
|
*/ |
||||
|
export function usePasswordLogin(): UsePasswordLoginReturn { |
||||
|
const [loading, setLoading] = useState(false); |
||||
|
const [error, setError] = useState<Error | null>(null); |
||||
|
|
||||
|
const login = useCallback(async (phone: string, password: string) => { |
||||
|
setLoading(true); |
||||
|
setError(null); |
||||
|
try { |
||||
|
return await loginByPassword(phone, password); |
||||
|
} catch (e) { |
||||
|
const err = e instanceof Error ? e : new Error('登录失败,请稍后重试'); |
||||
|
setError(err); |
||||
|
throw err; |
||||
|
} finally { |
||||
|
setLoading(false); |
||||
|
} |
||||
|
}, []); |
||||
|
|
||||
|
return { login, loading, error }; |
||||
|
} |
||||
@ -0,0 +1,62 @@ |
|||||
|
import { useCallback, useState } from 'react'; |
||||
|
import { |
||||
|
getPasswordRuleMessage, |
||||
|
resetPassword, |
||||
|
validatePassword, |
||||
|
validatePasswordConfirm, |
||||
|
type LoginResult, |
||||
|
} from '../api'; |
||||
|
import type { PasswordResetPayload } from '../types'; |
||||
|
|
||||
|
export interface UsePasswordResetReturn { |
||||
|
/** 重置 / 首次设置密码,成功返回登录结果(后端直接下发登录态) */ |
||||
|
reset: (payload: PasswordResetPayload) => Promise<LoginResult>; |
||||
|
/** 提交进行中 */ |
||||
|
loading: boolean; |
||||
|
/** 失败原因(用于页面提示) */ |
||||
|
error: Error | null; |
||||
|
/** |
||||
|
* 新密码规则校验:返回中文提示,null 表示通过。 |
||||
|
* 规则本身在 API 层计算,页面只渲染结果,不写判断逻辑。 |
||||
|
*/ |
||||
|
checkPassword: (password: string, confirm: string) => string | null; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 重置 / 首次设置登录密码。 |
||||
|
* |
||||
|
* 覆盖两个场景:忘记密码(老用户改密)、首次设置密码(登录即注册的用户天生无密码)。 |
||||
|
* 两条路径共用同一流程与接口,页面按来源只改文案。 |
||||
|
* 发送验证码走独立的 useSmsCode。 |
||||
|
*/ |
||||
|
export function usePasswordReset(): UsePasswordResetReturn { |
||||
|
const [loading, setLoading] = useState(false); |
||||
|
const [error, setError] = useState<Error | null>(null); |
||||
|
|
||||
|
const reset = useCallback(async (payload: PasswordResetPayload) => { |
||||
|
setLoading(true); |
||||
|
setError(null); |
||||
|
try { |
||||
|
return await resetPassword(payload); |
||||
|
} catch (e) { |
||||
|
const err = e instanceof Error ? e : new Error('密码设置失败,请稍后重试'); |
||||
|
setError(err); |
||||
|
throw err; |
||||
|
} finally { |
||||
|
setLoading(false); |
||||
|
} |
||||
|
}, []); |
||||
|
|
||||
|
const checkPassword = useCallback((password: string, confirm: string) => { |
||||
|
const ruleError = validatePassword(password); |
||||
|
if (ruleError) return getPasswordRuleMessage(ruleError); |
||||
|
// 确认框还没开始输入时不提示不一致,避免用户刚敲第一个字符就被报错
|
||||
|
if (confirm.length > 0) { |
||||
|
const confirmError = validatePasswordConfirm(password, confirm); |
||||
|
if (confirmError) return getPasswordRuleMessage(confirmError); |
||||
|
} |
||||
|
return null; |
||||
|
}, []); |
||||
|
|
||||
|
return { reset, loading, error, checkPassword }; |
||||
|
} |
||||
@ -0,0 +1,36 @@ |
|||||
|
import { useCallback, useState } from 'react'; |
||||
|
import { sendSmsCode } from '../api'; |
||||
|
|
||||
|
export interface UseSmsCodeReturn { |
||||
|
/** 发送短信验证码(失败抛出,由页面提示) */ |
||||
|
send: (phone: string) => Promise<void>; |
||||
|
/** 发送进行中(用于防重复点击) */ |
||||
|
loading: boolean; |
||||
|
/** 发送失败的错误 */ |
||||
|
error: Error | null; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 发送短信验证码。 |
||||
|
* 验证码登录(P2)与重置密码(P2R)共用,倒计时由 CountdownButton 组件自管。 |
||||
|
*/ |
||||
|
export function useSmsCode(): UseSmsCodeReturn { |
||||
|
const [loading, setLoading] = useState(false); |
||||
|
const [error, setError] = useState<Error | null>(null); |
||||
|
|
||||
|
const send = useCallback(async (phone: string) => { |
||||
|
setLoading(true); |
||||
|
setError(null); |
||||
|
try { |
||||
|
await sendSmsCode(phone); |
||||
|
} catch (e) { |
||||
|
const err = e instanceof Error ? e : new Error('验证码发送失败,请稍后重试'); |
||||
|
setError(err); |
||||
|
throw err; |
||||
|
} finally { |
||||
|
setLoading(false); |
||||
|
} |
||||
|
}, []); |
||||
|
|
||||
|
return { send, loading, error }; |
||||
|
} |
||||
@ -0,0 +1,38 @@ |
|||||
|
/** |
||||
|
* 账号鉴权领域模型:手机号 + 验证码 / 手机号 + 密码两条登录通道。 |
||||
|
* 密码规则的计算与错误码映射放在 src/api 与 src/constants, |
||||
|
* 页面只消费联合类型与文案,不写死判断逻辑。 |
||||
|
*/ |
||||
|
|
||||
|
/** |
||||
|
* 密码规则校验失败原因;null 表示通过校验。 |
||||
|
* 用联合类型建模派生状态,便于 Record<PasswordRuleError, string> 反向约束文案配置完整性。 |
||||
|
*/ |
||||
|
export type PasswordRuleError = |
||||
|
/** 未填写 */ |
||||
|
| 'empty' |
||||
|
/** 少于最小长度 */ |
||||
|
| 'too-short' |
||||
|
/** 超过最大长度 */ |
||||
|
| 'too-long' |
||||
|
/** 未同时包含字母和数字 */ |
||||
|
| 'need-letter-digit' |
||||
|
/** 两次输入不一致 */ |
||||
|
| 'mismatch' |
||||
|
/** 新密码与原密码相同 */ |
||||
|
| 'same-as-old'; |
||||
|
|
||||
|
/** 重置 / 首次设置密码参数(忘记密码与首次设密码共用) */ |
||||
|
export interface PasswordResetPayload { |
||||
|
phone: string; |
||||
|
code: string; |
||||
|
password: string; |
||||
|
} |
||||
|
|
||||
|
/** 已登录态修改密码参数 */ |
||||
|
export interface ChangePasswordPayload { |
||||
|
oldPassword: string; |
||||
|
newPassword: string; |
||||
|
/** 确认新密码(前端二次输入,后端可忽略) */ |
||||
|
confirmPassword: string; |
||||
|
} |
||||
@ -0,0 +1,37 @@ |
|||||
|
/** |
||||
|
* 表单校验纯函数:手机号 / 短信验证码。 |
||||
|
* |
||||
|
* 单一来源:页面与 API 层都从这里取规则,避免同一正则被复制到多处后各自分叉 |
||||
|
* (此前 `/^1\d{10}$/` 在页面与接口层各有一份,验证码长度规则散落 4 处且不一致)。 |
||||
|
*/ |
||||
|
|
||||
|
/** 短信验证码最小长度 */ |
||||
|
export const SMS_CODE_MIN_LENGTH = 4; |
||||
|
|
||||
|
/** 短信验证码最大长度(部分通道下发 6 位) */ |
||||
|
export const SMS_CODE_MAX_LENGTH = 6; |
||||
|
|
||||
|
/** 中国大陆手机号:1 开头共 11 位数字 */ |
||||
|
export const PHONE_RE = /^1\d{10}$/; |
||||
|
|
||||
|
/** 短信验证码:4–6 位数字 */ |
||||
|
export const SMS_CODE_RE = /^\d{4,6}$/; |
||||
|
|
||||
|
/** |
||||
|
* 校验手机号是否为合法的大陆号码。 |
||||
|
* @param phone 手机号 |
||||
|
* @returns 合法返回 true |
||||
|
*/ |
||||
|
export function isValidPhone(phone: string): boolean { |
||||
|
return PHONE_RE.test(phone); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 校验短信验证码格式(纯数字、4–6 位)。 |
||||
|
* 只做格式校验,正确性由后端判定。 |
||||
|
* @param code 验证码 |
||||
|
* @returns 合法返回 true |
||||
|
*/ |
||||
|
export function isValidSmsCode(code: string): boolean { |
||||
|
return SMS_CODE_RE.test(code.trim()); |
||||
|
} |
||||
Loading…
Reference in new issue