轻松聘
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.
 
 
 

182 lines
7.5 KiB

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, spacing } 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, 'reset-password');
} 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: spacing.block },
subtitle: { fontSize: 14, color: colors.textSecondary, marginTop: spacing.sm },
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: spacing.section },
hint: { marginTop: spacing.block, fontSize: 12, color: colors.textHint },
hintDanger: { marginTop: spacing.block, fontSize: 12, color: colors.danger },
btnArea: { marginTop: 26 },
formError: { marginTop: spacing.sm, textAlign: 'center', fontSize: 12, color: colors.danger },
});