You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
254 lines
10 KiB
254 lines
10 KiB
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<Stage>('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 (
|
|
<ScreenShell showBack onBack={stage === 'code' ? () => setStage('phone') : back}>
|
|
{stage === 'phone' ? (
|
|
/* ---------- 阶段1:手机号 ---------- */
|
|
<View>
|
|
<Text style={styles.h1}>登录/注册</Text>
|
|
<Text style={styles.subtitle}>首次验证通过即注册轻松聘账号</Text>
|
|
|
|
<View style={styles.phoneRow}>
|
|
<Text style={styles.prefix}>+86</Text>
|
|
<Ionicons name="caret-down" size={14} color={colors.textSecondary} />
|
|
<View style={styles.divider} />
|
|
<TextInput
|
|
style={styles.phoneInput}
|
|
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} />
|
|
|
|
<Pressable onPress={showSmsHelp} hitSlop={6} style={styles.smsHelpRow}>
|
|
<Text style={styles.smsHelp}>接收不到短信?</Text>
|
|
</Pressable>
|
|
|
|
<View style={styles.btnArea}>
|
|
<PrimaryButton label="下一步" active={phoneValid} onPress={() => gate(requestCode)} />
|
|
</View>
|
|
|
|
{smsError ? <Text style={styles.formError}>{smsError.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>
|
|
|
|
{/* 其他登录方式:密码登录(与 P2P 互跳,保证两条通道都有入口) */}
|
|
<View style={styles.orRow}>
|
|
<View style={styles.orLine} />
|
|
<Text style={styles.orText}>或</Text>
|
|
<View style={styles.orLine} />
|
|
</View>
|
|
|
|
<Pressable onPress={() => gate(() => navigate('P2P'))} hitSlop={8}>
|
|
<Text style={styles.switchLink}>密码登录</Text>
|
|
</Pressable>
|
|
</View>
|
|
) : (
|
|
/* ---------- 阶段2:验证码 ---------- */
|
|
<View>
|
|
<Text style={styles.h1}>输入验证码</Text>
|
|
<Text style={styles.subtitle}>验证码已发送至 {maskedPhone}</Text>
|
|
|
|
<View style={styles.phoneRow}>
|
|
<TextInput
|
|
style={styles.phoneInput}
|
|
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={resendCode} />
|
|
</View>
|
|
<View style={styles.underline} />
|
|
|
|
<View style={styles.btnArea}>
|
|
<PrimaryButton
|
|
label={binding ? '绑定中…' : '登录'}
|
|
active={code.length >= SMS_CODE_MIN_LENGTH && !binding && !logging}
|
|
onPress={() => gate(handleLogin)}
|
|
/>
|
|
</View>
|
|
|
|
{bindError ? <Text style={styles.formError}>{bindError.message}</Text> : null}
|
|
{loginError ? <Text style={styles.formError}>{loginError.message}</Text> : null}
|
|
{smsError ? <Text style={styles.formError}>{smsError.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>
|
|
</View>
|
|
)}
|
|
|
|
<AgreementModal {...modalProps} />
|
|
</ScreenShell>
|
|
);
|
|
}
|
|
|
|
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' },
|
|
});
|
|
|