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.4 KiB
182 lines
7.4 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);
|
|
} 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 },
|
|
});
|
|
|