import { useState } from 'react'; import { Ionicons } from '@expo/vector-icons'; import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { colors, spacing } from '../../../src/theme'; import { maskPhone } from '../../../src/api'; import { SMS_HELP_BODY, SMS_HELP_TITLE } from '../../../src/constants'; import { useAgreementGate, useLogin, useLoginLanding, useOnboarding, useSmsCode, useWechatBindPhone, } from '../../../src/hooks'; import { isValidPhone, SMS_CODE_MAX_LENGTH, SMS_CODE_MIN_LENGTH } from '../../../src/utils'; 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'; /** 登录阶段:手机号 → 验证码 */ type Stage = 'phone' | 'code'; /** P2 登录/注册:阶段1 手机号(对齐参考样式)→ 阶段2 验证码 + 60s 倒计时 */ export function P2PhoneCode() { const { data, setData, navigate, back } = useOnboarding(); const { gate, modalProps } = useAgreementGate(); const { login, loading: logging, error: loginError } = useLogin(); const { send: sendCode, error: smsError } = useSmsCode(); /** 登录成功后的会话写入与落地判定(双 Token + 服务器角色) */ const { land, go } = useLoginLanding(); 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 = isValidPhone(phone); /** 有值说明本页是从「微信第一次登录」进来的,提交时需完成微信绑定 */ const wechatTicket = data.wechatTicket; /** 阶段1 → 阶段2:发送验证码;发送失败则停在阶段1,不误导用户去填验证码 */ const requestCode = async () => { if (!phoneValid) return; try { await sendCode(phone, 'login'); setStage('code'); } catch { // 失败原因由 useSmsCode 记录,页面下方统一展示 } }; /** 阶段2 重新获取(不改变阶段) */ const resendCode = () => { void sendCode(phone, 'login').catch(() => { // 同上,错误已记录 }); }; /* * 提交验证码: * - 微信第一次登录(data.wechatTicket 有值):调绑定注册接口把 openid 与手机号关联, * hook 内部已写入登录态,再进 P3 选择身份;失败则留在当前页并提示。 * - 手机号登录:换取令牌并写入登录态(本次修复的缺口),成功后才进 P3。 */ const handleLogin = async () => { if (code.length < SMS_CODE_MIN_LENGTH) return; setData({ phone, code }); if (wechatTicket) { try { await bind({ token: wechatTicket.token, openid: wechatTicket.openid, phone, code }); } catch { return; } // 微信首次绑定:身份尚未确定,去选身份 navigate('P3'); return; } let result; try { // 真实 /auth/login:验证码错误 / 过期由后端拒绝并抛出,不会走到下面的跳转 result = await login(phone, code); } catch { return; } // 服务器给了明确角色就直达对应端;UNASSIGNED 才去选身份 if (!go(land(result))) navigate('P3'); }; /** * 掩码手机号:138****1234。 * * 走接口层的 `maskPhone` 而不是本页自己切字符串 —— 页面原先内联的那段与之逐字节相同, * 但「脱敏规则」只有一处说了算:`carrier-login.test.ts` 锁的就是这条规则, * 而它测的是接口层那份;页面自持一份等于让测试锁不住真实渲染出来的值。 * 《运营商一键登录接入方案.md》也写明「页面不做脱敏」。 */ const maskedPhone = maskPhone(phone); /** 「接收不到短信」:原先是点了没反应的纯文本,改为给出可执行的下一步 */ const showSmsHelp = () => Alert.alert(SMS_HELP_TITLE, SMS_HELP_BODY); return ( setStage('phone') : back}> {stage === 'phone' ? ( /* ---------- 阶段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} 接收不到短信? gate(requestCode)} /> {smsError ? {smsError.message} : null} setData({ agreed: !agreed })} text={ 已阅读并同意 navigate('B2')}>《轻松聘用户协议》 和 navigate('B2')}>《隐私政策》 ,允许轻松聘统一管理本人账号信息 } /> {/* 其他登录方式:密码登录(与 P2P 互跳,保证两条通道都有入口) */} 或 gate(() => navigate('P2P'))} hitSlop={8}> 密码登录 ) : ( /* ---------- 阶段2:验证码 ---------- */ 输入验证码 验证码已发送至 {maskedPhone} setCode(t.replace(/[^\d]/g, '').slice(0, SMS_CODE_MAX_LENGTH))} placeholder="请输入验证码" placeholderTextColor={colors.textHint} keyboardType="number-pad" maxLength={SMS_CODE_MAX_LENGTH} /> = SMS_CODE_MIN_LENGTH && !binding && !logging} onPress={() => gate(handleLogin)} /> {bindError ? {bindError.message} : null} {loginError ? {loginError.message} : null} {smsError ? {smsError.message} : null} setData({ agreed: !agreed })} text={ 已阅读并同意 navigate('B2')}>《轻松聘用户协议》 和 navigate('B2')}>《隐私政策》 ,允许轻松聘统一管理本人账号信息 } /> )} ); } const styles = StyleSheet.create({ h1: { fontSize: 26, fontWeight: '800', color: colors.textPrimary, marginTop: spacing.block }, subtitle: { fontSize: 14, color: colors.textSecondary, marginTop: spacing.sm }, phoneRow: { 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 }, phoneInput: { flex: 1, fontSize: 17, color: colors.textPrimary, padding: 0 }, underline: { height: 1, backgroundColor: colors.borderLight }, // 已是可点入口(原来是无反馈纯文本),故用可读对比度的 primaryText 而非 textHint smsHelpRow: { alignSelf: 'flex-start', marginTop: 16 }, smsHelp: { fontSize: 13, color: colors.primaryText }, btnArea: { marginTop: 28 }, agree: { marginTop: spacing.block }, formError: { marginTop: spacing.sm, 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: 36, gap: spacing.block }, orLine: { flex: 1, height: 1, backgroundColor: colors.borderLight }, orText: { fontSize: 12, color: colors.textHint }, switchLink: { marginTop: 20, textAlign: 'center', fontSize: 14, color: colors.primaryText, fontWeight: '600' }, });