diff --git a/app/register.tsx b/app/register.tsx index a7b3ba3..300f60a 100644 --- a/app/register.tsx +++ b/app/register.tsx @@ -2,6 +2,8 @@ import { OnboardingProvider, useOnboarding } from '../src/hooks'; import { P1Login } from '../components/onboarding/screens/P1Login'; import { P2PhoneCode } from '../components/onboarding/screens/P2PhoneCode'; import { P2QuickLogin } from '../components/onboarding/screens/P2QuickLogin'; +import { P2PasswordLogin } from '../components/onboarding/screens/P2PasswordLogin'; +import { P2PasswordReset } from '../components/onboarding/screens/P2PasswordReset'; import { P2WechatCode } from '../components/onboarding/screens/P2WechatCode'; import { P3Identity } from '../components/onboarding/screens/P3Identity'; import { P4Intent } from '../components/onboarding/screens/P4Intent'; @@ -32,6 +34,9 @@ function FlowRouter() { case 'P1': return ; case 'P2': return ; case 'P2Q': return ; + case 'P2P': return ; + case 'P2R': return ; + // P2W / B1 为微信登录页:2026-09-15 按产品决定摘除入口,代码保留待启用(见 微信登录接入方案.md) case 'P2W': return ; case 'P3': return ; case 'P4': return ; diff --git a/app/settings/password.tsx b/app/settings/password.tsx new file mode 100644 index 0000000..c9f8475 --- /dev/null +++ b/app/settings/password.tsx @@ -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 ( + + + + + 修改登录密码 + + + ); +} + +/* ===================== 页面 ===================== */ + +/** + * 账号与安全 → 修改登录密码。 + * + * 入口由 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 ( + +
router.back()} /> + + + + 为保障账号安全,修改密码后需要使用新密码重新登录。 + + + + + + + + + + + {hint ? ( + {hint} + ) : ( + 8–20 位,需同时包含字母和数字 + )} + + {error ? {error.message} : null} + + + {loading ? '提交中…' : '确认修改'} + + + + ); +} + +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, + }, +}); diff --git a/components/onboarding/screens/P1Login.tsx b/components/onboarding/screens/P1Login.tsx index 108a808..fd7c61f 100644 --- a/components/onboarding/screens/P1Login.tsx +++ b/components/onboarding/screens/P1Login.tsx @@ -1,92 +1,24 @@ -import { useEffect, useState } from 'react'; -import { Ionicons } from '@expo/vector-icons'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { StatusBar } from 'expo-status-bar'; -import { useRouter } from 'expo-router'; import { LinearGradient } from 'expo-linear-gradient'; import { SafeAreaView } from 'react-native-safe-area-context'; import { colors } from '../../../src/theme'; -import { useOnboarding, useWechatLogin } from '../../../src/hooks'; -import { useRole } from '../../../src/context/RoleContext'; +import { useAgreementGate, useOnboarding } from '../../../src/hooks'; import { Illustration } from '../ui/Illustration'; import { AgreementCheckbox } from '../ui/AgreementCheckbox'; import { AgreementModal } from '../ui/AgreementModal'; -import { SliderCaptcha } from '../ui/SliderCaptcha'; -import { DeletedAccountModal } from '../ui/DeletedAccountModal'; - -/** P1 登录主页:对齐参考样式 —— 淡渐变顶部 + 居中 Logo + 微信绿实心/灰底一键登录 + 协议弹窗拦截 */ +import { PrimaryButton } from '../ui/PrimaryButton'; + +/** + * P1 登录主页:淡渐变顶部 + 居中 Logo + 三种登录方式。 + * + * 2026-09-15 按产品决定摘除微信登录入口(代码与页面保留,见 微信登录接入方案.md), + * 现为:验证码登录(主)/ 一键登录(次)/ 密码登录(弱)。 + */ export function P1Login() { const { data, setData, navigate } = useOnboarding(); - const router = useRouter(); - const { setRole } = useRole(); + const { gate, modalProps } = useAgreementGate(); const agreed = data.agreed; - /** 未勾选协议时被拦截的动作,弹窗「同意」后继续执行 */ - const [pending, setPending] = useState<(() => void) | null>(null); - const [captchaVisible, setCaptchaVisible] = useState(false); - /** 微信登录状态机:负责三分支判定与流转 */ - const { - state: wechatState, - start: startWechatLogin, - cancelLogin, - reset: resetWechat, - loading: wechatLoading, - } = useWechatLogin(); - - /** 协议门卫:已勾选直接执行;未勾选弹协议弹窗 */ - const gate = (action: () => void) => { - if (agreed) { - action(); - } else { - setPending(() => action); - } - }; - - const accept = () => { - setData({ agreed: true }); - const act = pending; - setPending(null); - act?.(); - }; - - /** 微信登录:先滑块拼图验证,通过后进入微信授权与三分支判定 */ - const wechatLogin = () => setCaptchaVisible(true); - - /* - * 状态机的副作用集中在这里,页面只负责触发与渲染: - * - needBindPhone:第一次登录,把临时凭证存入流程数据后跳 P2(手机号 + 验证码) - * - success:第二次及以后登录,凭后端返回的身份直接落地,无身份则回注册流程选择 - * - alreadyLoggedIn:本地 token 未过期,免授权直接落地 - * 跳转后立即 reset,避免返回本页时重复触发。 - */ - useEffect(() => { - if (wechatState.kind === 'needBindPhone') { - setData({ - wechatTicket: { - token: wechatState.ticket.token, - openid: wechatState.ticket.openid, - }, - }); - navigate('P2'); - resetWechat(); - return; - } - if (wechatState.kind === 'success') { - if (wechatState.role) { - setRole(wechatState.role); - router.replace( - wechatState.role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs', - ); - } else { - router.replace('/register'); - } - resetWechat(); - return; - } - if (wechatState.kind === 'alreadyLoggedIn') { - router.replace('/(tabs)/jobs'); - resetWechat(); - } - }, [wechatState, resetWechat, router, setRole, setData, navigate]); return ( @@ -100,28 +32,15 @@ export function P1Login() { - {/* 登录方式 */} - gate(wechatLogin)} - disabled={wechatLoading} - > - - - {wechatLoading ? '登录中…' : '微信登录'} - - - - {wechatState.kind === 'error' ? ( - {wechatState.message} - ) : null} + {/* 登录方式:主通道为验证码登录,其余两种降级展示 */} + gate(() => navigate('P2'))} /> gate(() => navigate('P2Q'))}> 一键登录 - gate(() => navigate('P2'))} hitSlop={8}> - 其他方式登录 + gate(() => navigate('P2P'))} hitSlop={8}> + 密码登录 {/* 协议勾选 */} @@ -150,26 +69,7 @@ export function P1Login() { - { - setCaptchaVisible(false); - void startWechatLogin(); - }} - onClose={() => setCaptchaVisible(false)} - /> - - - - setPending(null)} - onOpenAgreement={() => { - setPending(null); - navigate('B2'); - }} - /> + ); } @@ -180,16 +80,6 @@ const styles = StyleSheet.create({ content: { flexGrow: 1, paddingHorizontal: 24, paddingBottom: 12 }, brandArea: { alignItems: 'center', marginTop: '22%' }, flexSpacer: { flex: 1, minHeight: 28 }, - wechatBtn: { - height: 50, - borderRadius: 12, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - backgroundColor: colors.success, - }, - wechatText: { fontSize: 16, fontWeight: '700', color: colors.textInverse }, quickBtn: { height: 50, borderRadius: 12, @@ -205,7 +95,6 @@ const styles = StyleSheet.create({ fontSize: 14, color: colors.textSecondary, }, - loginError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger }, agree: { marginTop: 22 }, agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 }, link: { color: colors.primary, fontWeight: '600' }, diff --git a/components/onboarding/screens/P2PasswordLogin.tsx b/components/onboarding/screens/P2PasswordLogin.tsx new file mode 100644 index 0000000..d4ca13d --- /dev/null +++ b/components/onboarding/screens/P2PasswordLogin.tsx @@ -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 ( + + 密码登录 + 使用手机号与登录密码进入有职 + + {/* 手机号 */} + + +86 + + + setData({ phone: t.replace(/[^\d]/g, '').slice(0, 11) })} + placeholder="请输入手机号" + placeholderTextColor={colors.textHint} + keyboardType="phone-pad" + maxLength={11} + /> + {phone.length > 0 ? ( + setData({ phone: '' })} hitSlop={8}> + + + ) : null} + + + + {/* 密码 */} + + + + + gate(() => navigate('P2R'))} hitSlop={8}> + 忘记密码? + + + + gate(handleLogin)} /> + + + {error ? {error.message} : null} + + + setData({ agreed: !agreed })} + text={ + + 已阅读并同意 + navigate('B2')}>《有职用户协议》 + 和 + navigate('B2')}>《隐私政策》 + ,允许有职统一管理本人账号信息 + + } + /> + + + gate(() => navigate('P2'))} hitSlop={8}> + 验证码登录 + + + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P2PasswordReset.tsx b/components/onboarding/screens/P2PasswordReset.tsx new file mode 100644 index 0000000..c5f426c --- /dev/null +++ b/components/onboarding/screens/P2PasswordReset.tsx @@ -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('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 ( + setStage('verify') : back}> + {stage === 'verify' ? ( + /* ---------- 阶段1:验证手机号 ---------- */ + + 验证手机号 + 验证通过后即可设置新的登录密码 + + + +86 + + + setData({ phone: t.replace(/[^\d]/g, '').slice(0, 11) })} + placeholder="请输入手机号" + placeholderTextColor={colors.textHint} + keyboardType="phone-pad" + maxLength={11} + /> + {phone.length > 0 ? ( + setData({ phone: '' })} hitSlop={8}> + + + ) : null} + + + + + setCode(t.replace(/[^\d]/g, '').slice(0, SMS_CODE_MAX_LENGTH))} + placeholder="请输入验证码" + placeholderTextColor={colors.textHint} + keyboardType="number-pad" + maxLength={SMS_CODE_MAX_LENGTH} + /> + void sendCodeNow()} /> + + + + + gate(() => setStage('password'))} + /> + + + {smsError ? {smsError.message} : null} + + ) : ( + /* ---------- 阶段2:设置新密码 ---------- */ + + 设置登录密码 + 设置后可用手机号 + 密码登录 + + + + + + + + + + {hint ? ( + {hint} + ) : ( + 8–20 位,需同时包含字母和数字 + )} + + + gate(handleSubmit)} /> + + + {error ? {error.message} : null} + + )} + + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P2PhoneCode.tsx b/components/onboarding/screens/P2PhoneCode.tsx index 761051c..4317da7 100644 --- a/components/onboarding/screens/P2PhoneCode.tsx +++ b/components/onboarding/screens/P2PhoneCode.tsx @@ -2,72 +2,75 @@ import { useState } from 'react'; import { Ionicons } from '@expo/vector-icons'; import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { colors } from '../../../src/theme'; -import { useOnboarding, useWechatBindPhone } from '../../../src/hooks'; +import { useAgreementGate, useLogin, useOnboarding, useSmsCode, useWechatBindPhone } from '../../../src/hooks'; +import { isValidPhone, SMS_CODE_MAX_LENGTH, SMS_CODE_MIN_LENGTH } from '../../../src/utils'; +import { useAuth } from '../../../src/context/AuthContext'; import { ScreenShell } from '../ui/ScreenShell'; import { CountdownButton } from '../ui/CountdownButton'; import { AgreementCheckbox } from '../ui/AgreementCheckbox'; import { PrimaryButton } from '../ui/PrimaryButton'; import { AgreementModal } from '../ui/AgreementModal'; -import { SliderCaptcha } from '../ui/SliderCaptcha'; -const PHONE_RE = /^1\d{10}$/; +/** 登录阶段:手机号 → 验证码 */ +type Stage = 'phone' | 'code'; /** P2 登录/注册:阶段1 手机号(对齐参考样式)→ 阶段2 验证码 + 60s 倒计时 */ export function P2PhoneCode() { const { data, setData, navigate, back } = useOnboarding(); - const [code, setCode] = useState(''); - const [codeSent, setCodeSent] = useState(false); - /** 未勾选协议时被拦截的动作 */ - const [pending, setPending] = useState<(() => void) | null>(null); - const [captchaVisible, setCaptchaVisible] = useState(false); - /** 微信第一次登录的绑定注册能力 */ + const { gate, modalProps } = useAgreementGate(); + const { login, loading: logging, error: loginError } = useLogin(); + const { send: sendCode, error: smsError } = useSmsCode(); + /** 登录态写入:此前本页只跳 P3 不写 token,导致登录态始终为空 */ + const { login: writeAuth } = useAuth(); const { bind, loading: binding, error: bindError } = useWechatBindPhone(); + const [code, setCode] = useState(''); + const [stage, setStage] = useState('phone'); + const agreed = data.agreed; const phone = data.phone; - const phoneValid = PHONE_RE.test(phone); + const phoneValid = isValidPhone(phone); /** 有值说明本页是从「微信第一次登录」进来的,提交时需完成微信绑定 */ const wechatTicket = data.wechatTicket; - const gate = (action: () => void) => { - if (agreed) { - action(); - } else { - setPending(() => action); + /** 阶段1 → 阶段2:发送验证码;发送失败则停在阶段1,不误导用户去填验证码 */ + const requestCode = async () => { + if (!phoneValid) return; + try { + await sendCode(phone); + setStage('code'); + } catch { + // 失败原因由 useSmsCode 记录,页面下方统一展示 } }; - const accept = () => { - setData({ agreed: true }); - const act = pending; - setPending(null); - act?.(); - }; - - /** 阶段1 → 阶段2:发送验证码 */ - const sendCode = () => { - if (!phoneValid) return; - setCodeSent(true); + /** 阶段2 重新获取(不改变阶段) */ + const resendCode = () => { + void sendCode(phone).catch(() => { + // 同上,错误已记录 + }); }; /* * 提交验证码: * - 微信第一次登录(data.wechatTicket 有值):调绑定注册接口把 openid 与手机号关联, - * 成功即写入登录态,再进 P3 选择身份;失败则留在当前页并提示。 - * - 其他链路:沿用原逻辑,仅记录手机号后进 P3。 + * hook 内部已写入登录态,再进 P3 选择身份;失败则留在当前页并提示。 + * - 手机号登录:换取令牌并写入登录态(本次修复的缺口),成功后才进 P3。 */ const handleLogin = async () => { - if (code.length < 4) return; + if (code.length < SMS_CODE_MIN_LENGTH) return; setData({ phone, code }); if (wechatTicket) { try { - await bind({ - token: wechatTicket.token, - openid: wechatTicket.openid, - phone, - code, - }); + await bind({ token: wechatTicket.token, openid: wechatTicket.openid, phone, code }); + } catch { + return; + } + } else { + try { + const result = await login(phone, code); + writeAuth(result.token); } catch { return; } @@ -79,8 +82,8 @@ export function P2PhoneCode() { const maskedPhone = phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : phone; return ( - - {!codeSent ? ( + setStage('phone') : back}> + {stage === 'phone' ? ( /* ---------- 阶段1:手机号 ---------- */ 登录/注册 @@ -110,9 +113,11 @@ export function P2PhoneCode() { 接收不到短信 - gate(sendCode)} /> + gate(requestCode)} /> + {smsError ? {smsError.message} : null} + - {/* 分隔线 + 其他登录方式 */} + {/* 其他登录方式:密码登录(与 P2P 互跳,保证两条通道都有入口) */} - 或通过以下方式登录 + 或 - - gate(() => setCaptchaVisible(true))}> - - - gate(sendCode)}> - - - gate(sendCode)}> - {'数字\n身份'} - - + gate(() => navigate('P2P'))} hitSlop={8}> + 密码登录 + ) : ( /* ---------- 阶段2:验证码 ---------- */ @@ -158,25 +155,27 @@ export function P2PhoneCode() { setCode(t.replace(/[^\d]/g, '').slice(0, 6))} + onChangeText={(t) => setCode(t.replace(/[^\d]/g, '').slice(0, SMS_CODE_MAX_LENGTH))} placeholder="请输入验证码" placeholderTextColor={colors.textHint} keyboardType="number-pad" - maxLength={6} + maxLength={SMS_CODE_MAX_LENGTH} /> - setCodeSent(true)} /> + = 4 && !binding} + active={code.length >= SMS_CODE_MIN_LENGTH && !binding && !logging} onPress={() => gate(handleLogin)} /> - {bindError ? {bindError.message} : null} + {bindError ? {bindError.message} : null} + {loginError ? {loginError.message} : null} + {smsError ? {smsError.message} : null} )} - { - setCaptchaVisible(false); - navigate('P2W'); - }} - onClose={() => setCaptchaVisible(false)} - /> - - setPending(null)} - onOpenAgreement={() => { - setPending(null); - navigate('B2'); - }} - /> + ); } @@ -235,19 +217,11 @@ const styles = StyleSheet.create({ smsHelp: { fontSize: 13, color: colors.textHint, marginTop: 16 }, btnArea: { marginTop: 28 }, agree: { marginTop: 18 }, - bindError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger }, + formError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger }, agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 }, link: { color: colors.primary, fontWeight: '600' }, - orRow: { flexDirection: 'row', alignItems: 'center', marginTop: 40, gap: 12 }, + orRow: { flexDirection: 'row', alignItems: 'center', marginTop: 36, gap: 12 }, orLine: { flex: 1, height: 1, backgroundColor: colors.borderLight }, orText: { fontSize: 12, color: colors.textHint }, - socialRow: { flexDirection: 'row', justifyContent: 'center', gap: 28, marginTop: 26 }, - socialCircle: { - width: 56, - height: 56, - borderRadius: 28, - alignItems: 'center', - justifyContent: 'center', - }, - socialIdText: { fontSize: 12, fontWeight: '700', color: colors.textInverse, textAlign: 'center', lineHeight: 15 }, + switchLink: { marginTop: 20, textAlign: 'center', fontSize: 14, color: colors.primaryText, fontWeight: '600' }, }); diff --git a/components/onboarding/screens/P2QuickLogin.tsx b/components/onboarding/screens/P2QuickLogin.tsx index 05af439..50bc112 100644 --- a/components/onboarding/screens/P2QuickLogin.tsx +++ b/components/onboarding/screens/P2QuickLogin.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react'; import { ScrollView, StyleSheet, Text, View } from 'react-native'; import { colors } from '../../../src/theme'; -import { useOnboarding } from '../../../src/hooks'; +import { useAgreementGate, useLogin, useOnboarding } from '../../../src/hooks'; +import { useAuth } from '../../../src/context/AuthContext'; import { ScreenShell } from '../ui/ScreenShell'; import { Illustration } from '../ui/Illustration'; import { AgreementCheckbox } from '../ui/AgreementCheckbox'; @@ -11,24 +11,24 @@ import { AgreementModal } from '../ui/AgreementModal'; /** P2Q 一键登录确认:本机号码认证 —— 掩码手机号 + 一键登录 + 运营商服务声明 */ export function P2QuickLogin() { const { data, setData, navigate, back } = useOnboarding(); - const [pending, setPending] = useState<(() => void) | null>(null); + const { gate, modalProps } = useAgreementGate(); + const { loginByCarrier, loading, error } = useLogin(); + /** 登录态写入:此前本页只跳 P3 不写 token */ + const { login: writeAuth } = useAuth(); const agreed = data.agreed; const phone = data.phone; - const gate = (action: () => void) => { - if (agreed) { - action(); - } else { - setPending(() => action); + /** 一键登录:运营商返回本机号码并换取令牌,成功后才进 P3 */ + const handleLogin = async () => { + try { + const result = await loginByCarrier(); + writeAuth(result.token); + } catch { + // 失败原因由 useLogin 记录,页面下方展示 + return; } - }; - - const accept = () => { - setData({ agreed: true }); - const act = pending; - setPending(null); - act?.(); + navigate('P3'); }; /** 掩码:150****9367;未输入时用演示号段占位 */ @@ -45,9 +45,16 @@ export function P2QuickLogin() { 首次验证通过即注册有职账号 - gate(() => navigate('P3'))} /> + gate(handleLogin)} + /> + {error ? {error.message} : null} + 认证服务由中国移动提供 - setPending(null)} - onOpenAgreement={() => { - setPending(null); - navigate('B2'); - }} - /> + ); } @@ -101,6 +100,7 @@ const styles = StyleSheet.create({ }, subtitle: { textAlign: 'center', fontSize: 14, color: colors.textSecondary, marginTop: 12 }, btnArea: { marginTop: 36 }, + formError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger }, agree: { marginTop: 20 }, agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 }, link: { color: colors.primary, fontWeight: '600' }, diff --git a/src/__tests__/password.test.ts b/src/__tests__/password.test.ts new file mode 100644 index 0000000..247d0f1 --- /dev/null +++ b/src/__tests__/password.test.ts @@ -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(); + }); +}); diff --git a/src/api/api.ts b/src/api/api.ts index 8dfcac6..c7a58b1 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -124,8 +124,21 @@ import type { WechatLoginResult, WechatBindPhonePayload, WechatSession, + ChangePasswordPayload, + PasswordResetPayload, + PasswordRuleError, } from '../types'; -import { DEFAULT_SOCIAL_PROFILE, MOCK_DELAY, SALARY_RANGES, SOCIAL_SECURITY_CITY_PROFILES, TOKEN_TTL_MS } from '../constants'; +import { + DEFAULT_SOCIAL_PROFILE, + MOCK_DELAY, + PASSWORD_MAX_LENGTH, + PASSWORD_MIN_LENGTH, + PASSWORD_RULE_LABEL, + SALARY_RANGES, + SOCIAL_SECURITY_CITY_PROFILES, + TOKEN_TTL_MS, +} from '../constants'; +import { isValidPhone, isValidSmsCode } from '../utils'; import { colors } from '../theme'; import { storageGet, storageSet, storageRemove } from '../lib/storage'; @@ -5312,18 +5325,12 @@ const MOCK_ACCOUNT_SECURITY: ActionSetting[] = [ value: '138****8888', variant: 'normal', }, - { - id: 'wechat', - title: '微信账号', - description: '绑定后可使用微信快捷登录', - value: '未绑定', - variant: 'normal', - }, { id: 'password', title: '修改登录密码', description: '建议定期更新密码以保护账号安全', variant: 'normal', + route: '/settings/password', }, { id: 'devices', @@ -5517,12 +5524,165 @@ export interface LoginResult { * @returns 登录结果(含 token) */ export function loginByPhone(phone: string, code: string): Promise { - if (code.trim().length < 4) { + if (!isValidPhone(phone)) { + return Promise.reject(new Error('手机号格式不正确')); + } + if (!isValidSmsCode(code)) { return Promise.reject(new Error('验证码不正确')); } return mockRequest({ token: `mock_token_${phone}`, phone }); } +/** + * 运营商一键登录(本机号码认证)。 + * + * 真实场景由运营商 SDK 返回本机号码与鉴权 token,前端直接换取登录态, + * 无需用户输入。此处 mock 返回与 P2Q 展示掩码(150****9367)一致的号码。 + * + * @method POST + * @url /api/auth/login-by-carrier + * @returns 登录结果(含 token) + */ +export function loginByCarrier(): Promise { + return mockRequest({ token: 'mock_token_carrier', phone: '15000009367' }); +} + +/* ===================== 账号:密码规则(登录/重置/改密三处共用) ===================== */ + +/** + * 校验登录密码强度。 + * + * 规则:8–20 位,且同时包含字母和数字。 + * 放在接口层的原因:注册、重置、修改三处共用同一份规则,页面只渲染文案不写判断。 + * + * @param password 待校验密码 + * @returns 校验失败的错误码;null 表示通过 + */ +export function validatePassword(password: string): PasswordRuleError | null { + if (password.length === 0) return 'empty'; + if (password.length < PASSWORD_MIN_LENGTH) return 'too-short'; + if (password.length > PASSWORD_MAX_LENGTH) return 'too-long'; + if (!/[a-zA-Z]/.test(password) || !/\d/.test(password)) return 'need-letter-digit'; + return null; +} + +/** + * 校验两次输入的密码是否一致。 + * @param password 新密码 + * @param confirm 确认密码 + * @returns 'mismatch';一致返回 null + */ +export function validatePasswordConfirm( + password: string, + confirm: string, +): PasswordRuleError | null { + return password === confirm ? null : 'mismatch'; +} + +/** + * 错误码 → 中文提示文案(枚举映射集中在此,页面不写死文案)。 + * @param error 密码规则错误码 + * @returns 可直接展示的中文提示 + */ +export function getPasswordRuleMessage(error: PasswordRuleError): string { + return PASSWORD_RULE_LABEL[error]; +} + +/* ===================== 账号:手机号 + 密码登录 ===================== */ + +/** + * 手机号 + 密码登录。 + * + * mock 阶段判定:密码本身不合规即视为「密码错误」,用于在 Expo Go 里验证失败分支。 + * + * @method POST + * @url /api/auth/login-by-password + * @param phone 手机号(11 位) + * @param password 登录密码 + * @returns 登录结果(含 token) + */ +export function loginByPassword(phone: string, password: string): Promise { + if (!isValidPhone(phone)) { + return Promise.reject(new Error('手机号格式不正确')); + } + const ruleError = validatePassword(password); + if (ruleError) { + // 真实后端应统一返回「手机号或密码错误」,此处不区分,避免账号枚举 + return Promise.reject(new Error('手机号或密码错误')); + } + return mockRequest({ token: `mock_token_${phone}`, phone }); +} + +/** + * 发送短信验证码。 + * + * 此前页面「获取验证码」只做前端倒计时、无任何接口调用,本函数补齐链路。 + * + * @method POST + * @url /api/auth/sms-code + * @param phone 手机号(11 位) + * @returns 无返回体;失败时 reject + */ +export function sendSmsCode(phone: string): Promise { + if (!isValidPhone(phone)) { + return Promise.reject(new Error('手机号格式不正确')); + } + return mockRequest(undefined); +} + +/** + * 重置 / 首次设置登录密码(忘记密码与首次设密码共用同一入口)。 + * + * 「登录即注册」下用户天生没有密码,因此首次获得密码走的就是本接口(忘记密码)。 + * 重置成功后直接下发登录态,避免用户重置完再登录一次。 + * + * @method POST + * @url /api/auth/reset-password + * @param payload 手机号 + 短信验证码 + 新密码 + * @returns 登录结果(含 token) + */ +export function resetPassword(payload: PasswordResetPayload): Promise { + if (!isValidPhone(payload.phone)) { + return Promise.reject(new Error('手机号格式不正确')); + } + if (!isValidSmsCode(payload.code)) { + return Promise.reject(new Error('验证码不正确')); + } + const ruleError = validatePassword(payload.password); + if (ruleError) { + return Promise.reject(new Error(getPasswordRuleMessage(ruleError))); + } + return mockRequest({ + token: `mock_token_${payload.phone}`, + phone: payload.phone, + }); +} + +/** + * 修改登录密码(已登录态,需校验原密码)。 + * + * @method POST + * @url /api/auth/change-password + * @param payload 原密码 + 新密码 + 确认新密码 + * @returns 无返回体;失败时 reject + */ +export function changePassword(payload: ChangePasswordPayload): Promise { + if (payload.oldPassword.length === 0) { + return Promise.reject(new Error('请输入原密码')); + } + const ruleError = validatePassword(payload.newPassword); + if (ruleError) { + return Promise.reject(new Error(getPasswordRuleMessage(ruleError))); + } + if (payload.newPassword !== payload.confirmPassword) { + return Promise.reject(new Error(getPasswordRuleMessage('mismatch'))); + } + if (payload.newPassword === payload.oldPassword) { + return Promise.reject(new Error(getPasswordRuleMessage('same-as-old'))); + } + return mockRequest(undefined); +} + /* ===================== 账号:微信登录(三种账号状态) ===================== */ /* @@ -5602,10 +5762,10 @@ export function fetchWechatLoginResult(code: string): Promise export function bindWechatAndRegister( payload: WechatBindPhonePayload, ): Promise { - if (!/^1\d{10}$/.test(payload.phone)) { + if (!isValidPhone(payload.phone)) { return Promise.reject(new Error('手机号格式不正确')); } - if (payload.code.trim().length < 4) { + if (!isValidSmsCode(payload.code)) { return Promise.reject(new Error('验证码不正确')); } return mockRequest({ diff --git a/src/api/index.ts b/src/api/index.ts index b2b8c06..485af63 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -3,6 +3,11 @@ */ export type { LoginResult } from './api'; export type { WechatLoginResult, WechatSession, WechatBindPhonePayload } from '../types'; +export type { + ChangePasswordPayload, + PasswordResetPayload, + PasswordRuleError, +} from '../types'; export { fetchAboutInfo, fetchAbilityAssessment, @@ -83,6 +88,14 @@ export { fetchLegalServiceRequests, createLegalServiceRequest, loginByPhone, + loginByPassword, + loginByCarrier, + sendSmsCode, + resetPassword, + changePassword, + validatePassword, + validatePasswordConfirm, + getPasswordRuleMessage, fetchWechatLoginResult, bindWechatAndRegister, setMockWechatAccountStatus, diff --git a/src/components/PasswordField.tsx b/src/components/PasswordField.tsx new file mode 100644 index 0000000..7940c00 --- /dev/null +++ b/src/components/PasswordField.tsx @@ -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 ( + <> + + + {value.length > 0 ? ( + onChangeText('')} hitSlop={8}> + + + ) : null} + setVisible((v) => !v)} hitSlop={8}> + + + + + + ); +} + +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 }, +}); diff --git a/src/constants/index.ts b/src/constants/index.ts index 3a15791..de016cc 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,7 +1,7 @@ /** * 常量统一导出(枚举、配置项等) */ -import type { CitySocialProfile, JobLevel } from '../types'; +import type { CitySocialProfile, JobLevel, PasswordRuleError } from '../types'; /** 请求失败自动重试次数 */ export const MAX_RETRY_COUNT = 3; @@ -64,3 +64,24 @@ export const ONE_DAY_MS = 24 * 60 * 60 * 1000; /** 登录令牌有效期(毫秒),与后端 JWT 过期时间保持一致 */ export const TOKEN_TTL_MS = 30 * ONE_DAY_MS; + +/* ===================== 登录密码规则 ===================== */ + +/** 登录密码最小长度(含) */ +export const PASSWORD_MIN_LENGTH = 8; + +/** 登录密码最大长度(含) */ +export const PASSWORD_MAX_LENGTH = 20; + +/** + * 密码规则错误码 → 中文提示(展示用枚举映射)。 + * 页面只读该映射渲染提示,不在组件内写 if 判断规则。 + */ +export const PASSWORD_RULE_LABEL: Record = { + empty: '请输入密码', + 'too-short': `密码至少 ${PASSWORD_MIN_LENGTH} 位`, + 'too-long': `密码最多 ${PASSWORD_MAX_LENGTH} 位`, + 'need-letter-digit': '密码需同时包含字母和数字', + mismatch: '两次输入的密码不一致', + 'same-as-old': '新密码不能与原密码相同', +}; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index f550117..c0dbc1f 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -5,12 +5,14 @@ export { useAbilityAssessment } from './useAbilityAssessment'; export { useAboutInfo } from './useAboutInfo'; export { useAccountCancel } from './useAccountCancel'; export { useAccountSecurity } from './useAccountSecurity'; +export { useAgreementGate, type AgreementGate } from './useAgreementGate'; export { useAIInterview } from './useAIInterview'; export { useAppealForm } from './useAppealForm'; export { useAsyncData } from './useAsyncData'; export { useAuditReport } from './useAuditReport'; export { useCandidateDetail } from './useCandidateDetail'; export { useCompareData } from './useCompareData'; +export { useChangePassword } from './useChangePassword'; export { useConversation } from './useConversation'; export { useDeleteAllData } from './useDeleteAllData'; export { useDndSetting } from './useDndSetting'; @@ -44,6 +46,9 @@ export { useNotificationSettings } from './useNotificationSettings'; export { useOfferCompareData } from './useOfferCompareData'; export { useOnboardingChecklist } from './useOnboardingChecklist'; export { useOperationRules } from './useOperationRules'; +export { usePasswordLogin } from './usePasswordLogin'; +export { usePasswordReset } from './usePasswordReset'; +export { useSmsCode } from './useSmsCode'; export { usePrivacyCenter } from './usePrivacyCenter'; export { usePrivacyDetailSetting } from './usePrivacyDetailSetting'; export { usePrivacySettings } from './usePrivacySettings'; diff --git a/src/hooks/useAgreementGate.ts b/src/hooks/useAgreementGate.ts new file mode 100644 index 0000000..8c00dcc --- /dev/null +++ b/src/hooks/useAgreementGate.ts @@ -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(); + * gate(() => navigate('P3'))} /> + * + * ``` + */ + +export interface AgreementGate { + /** 门卫:已勾选协议直接执行动作;未勾选则弹协议弹窗,点「同意」后继续执行 */ + gate: (action: () => void) => void; + /** 直接展开给 */ + 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, + }, + }; +} diff --git a/src/hooks/useChangePassword.ts b/src/hooks/useChangePassword.ts new file mode 100644 index 0000000..554220c --- /dev/null +++ b/src/hooks/useChangePassword.ts @@ -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; + /** 提交进行中 */ + 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(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 }; +} diff --git a/src/hooks/useLogin.ts b/src/hooks/useLogin.ts index 764b556..a207530 100644 --- a/src/hooks/useLogin.ts +++ b/src/hooks/useLogin.ts @@ -1,9 +1,11 @@ import { useCallback, useState } from 'react'; -import { loginByPhone, type LoginResult } from '../api'; +import { loginByCarrier, loginByPhone, type LoginResult } from '../api'; export interface UseLoginReturn { /** 手机号 + 验证码登录,成功返回 LoginResult */ login: (phone: string, code: string) => Promise; + /** 运营商一键登录(本机号码认证),成功返回 LoginResult */ + loginByCarrier: () => Promise; /** 登录进行中 */ loading: boolean; /** 登录失败的错误(用于页面提示) */ @@ -11,21 +13,22 @@ export interface UseLoginReturn { } /** - * 登录(一次性变更,非取数)。 - * 包装 api.loginByPhone,向上暴露 loading / error 供页面控制按钮与提示。 + * 手机号登录(一次性变更,非取数)。 + * 覆盖两种无需密码的通道:短信验证码、运营商一键登录。 + * 向上暴露 loading / error 供页面控制按钮与提示;写登录态由调用页面配合 useAuth().login 完成。 */ export function useLogin(): UseLoginReturn { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const login = useCallback(async (phone: string, code: string) => { + /** 统一收口 loading / error 的重复样板 */ + const run = useCallback(async (action: () => Promise, fallback: string): Promise => { setLoading(true); setError(null); try { - const result = await loginByPhone(phone, code); - return result; + return await action(); } catch (e) { - const err = e instanceof Error ? e : new Error('登录失败,请稍后重试'); + const err = e instanceof Error ? e : new Error(fallback); setError(err); throw err; } finally { @@ -33,5 +36,15 @@ export function useLogin(): UseLoginReturn { } }, []); - return { login, loading, error }; + const login = useCallback( + (phone: string, code: string) => run(() => loginByPhone(phone, code), '登录失败,请稍后重试'), + [run], + ); + + const carrierLogin = useCallback( + () => run(() => loginByCarrier(), '一键登录失败,请稍后重试'), + [run], + ); + + return { login, loginByCarrier: carrierLogin, loading, error }; } diff --git a/src/hooks/usePasswordLogin.ts b/src/hooks/usePasswordLogin.ts new file mode 100644 index 0000000..1711d62 --- /dev/null +++ b/src/hooks/usePasswordLogin.ts @@ -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; + /** 登录进行中 */ + loading: boolean; + /** 登录失败的错误(用于页面提示) */ + error: Error | null; +} + +/** + * 密码登录(一次性变更,非取数)。 + * 包装 api.loginByPassword,向上暴露 loading / error 供页面控制按钮与提示。 + */ +export function usePasswordLogin(): UsePasswordLoginReturn { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 }; +} diff --git a/src/hooks/usePasswordReset.ts b/src/hooks/usePasswordReset.ts new file mode 100644 index 0000000..992348b --- /dev/null +++ b/src/hooks/usePasswordReset.ts @@ -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; + /** 提交进行中 */ + 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(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 }; +} diff --git a/src/hooks/useSmsCode.ts b/src/hooks/useSmsCode.ts new file mode 100644 index 0000000..981585e --- /dev/null +++ b/src/hooks/useSmsCode.ts @@ -0,0 +1,36 @@ +import { useCallback, useState } from 'react'; +import { sendSmsCode } from '../api'; + +export interface UseSmsCodeReturn { + /** 发送短信验证码(失败抛出,由页面提示) */ + send: (phone: string) => Promise; + /** 发送进行中(用于防重复点击) */ + loading: boolean; + /** 发送失败的错误 */ + error: Error | null; +} + +/** + * 发送短信验证码。 + * 验证码登录(P2)与重置密码(P2R)共用,倒计时由 CountdownButton 组件自管。 + */ +export function useSmsCode(): UseSmsCodeReturn { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 }; +} diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 0000000..9a4b085 --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,38 @@ +/** + * 账号鉴权领域模型:手机号 + 验证码 / 手机号 + 密码两条登录通道。 + * 密码规则的计算与错误码映射放在 src/api 与 src/constants, + * 页面只消费联合类型与文案,不写死判断逻辑。 + */ + +/** + * 密码规则校验失败原因;null 表示通过校验。 + * 用联合类型建模派生状态,便于 Record 反向约束文案配置完整性。 + */ +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; +} diff --git a/src/types/index.ts b/src/types/index.ts index aea8fe1..eab6d6a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -263,3 +263,8 @@ export type { WechatSession, WechatUserProfile, } from './wechat-auth'; +export type { + ChangePasswordPayload, + PasswordResetPayload, + PasswordRuleError, +} from './auth'; diff --git a/src/utils/index.ts b/src/utils/index.ts index be555dd..7450f2d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,6 +2,16 @@ * 纯工具函数(字符串 / 数字格式化等)。 */ +// 表单校验规则(手机号 / 验证码):页面与 API 层共用,避免规则被复制后分叉 +export { + PHONE_RE, + SMS_CODE_MAX_LENGTH, + SMS_CODE_MIN_LENGTH, + SMS_CODE_RE, + isValidPhone, + isValidSmsCode, +} from './validators'; + /** * 千分位格式化:12846 → "12,846" */ diff --git a/src/utils/validators.ts b/src/utils/validators.ts new file mode 100644 index 0000000..7ae40ae --- /dev/null +++ b/src/utils/validators.ts @@ -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()); +}