Browse Source

feat(auth): 补齐密码登录通道并修复登录态从未写入的缺口

【严重缺口修复】P2(验证码)/ P2Q(一键登录)此前只 navigate('P3'),从不写登录态——
全项目唯一写 token 的地方是微信链路。微信入口一旦摘除,isTokenValid 恒为 false,
每次冷启动都会把用户打回登录页。现三条通道均接入 useAuth().login()。

【新增通道】P1 主通道改为「验证码登录」,「一键登录」为次,新增「密码登录」(P2P):
- P2P 手机号 + 密码登录;P2R 设置 / 重置密码(同页两阶段:验证手机号 → 设新密码,
  成功后直接下发登录态,用户无需再登录一次)
- 设置页新增 /settings/password 修改密码,改完强制重登

【密码规则】8–20 位且需含字母和数字,规则计算统一放在 api.ts 的 validatePassword /
validatePasswordConfirm,错误码 → 文案映射放在 constants 的 PASSWORD_RULE_LABEL,
按联合类型 PasswordRuleError 建模,新增成员漏配文案会被测试拦下。

【微信入口摘除】按产品决定(丁总 2026-09-14)前端不做 QQ / 微信登录:
P1Login 移除微信按钮、滑块验证与注销弹窗;P2W / B1 页面、useWechatLogin 状态机、
wechat-sdk 及三个接口函数全部保留待启用,接入路线见 微信登录接入方案.md。

【去重公共件】新增 PasswordField(密码输入 + 明文切换)、useAgreementGate(协议门卫,
此前在 3 个页面被逐字复制)、utils/validators(手机号 / 验证码规则的单一来源)。

【测试】新增 src/__tests__/password.test.ts(24 例)。全套 6 套 45 例通过,
tsc --noEmit 0 错误,eslint . 0 问题,check-routes 67 路由 0 失效跳转。
master
doublex001 1 month ago
parent
commit
5fbb7222f0
  1. 5
      app/register.tsx
  2. 211
      app/settings/password.tsx
  3. 139
      components/onboarding/screens/P1Login.tsx
  4. 139
      components/onboarding/screens/P2PasswordLogin.tsx
  5. 182
      components/onboarding/screens/P2PasswordReset.tsx
  6. 142
      components/onboarding/screens/P2PhoneCode.tsx
  7. 50
      components/onboarding/screens/P2QuickLogin.tsx
  8. 177
      src/__tests__/password.test.ts
  9. 182
      src/api/api.ts
  10. 13
      src/api/index.ts
  11. 72
      src/components/PasswordField.tsx
  12. 23
      src/constants/index.ts
  13. 5
      src/hooks/index.ts
  14. 73
      src/hooks/useAgreementGate.ts
  15. 69
      src/hooks/useChangePassword.ts
  16. 29
      src/hooks/useLogin.ts
  17. 36
      src/hooks/usePasswordLogin.ts
  18. 62
      src/hooks/usePasswordReset.ts
  19. 36
      src/hooks/useSmsCode.ts
  20. 38
      src/types/auth.ts
  21. 5
      src/types/index.ts
  22. 10
      src/utils/index.ts
  23. 37
      src/utils/validators.ts

5
app/register.tsx

@ -2,6 +2,8 @@ import { OnboardingProvider, useOnboarding } from '../src/hooks';
import { P1Login } from '../components/onboarding/screens/P1Login'; import { P1Login } from '../components/onboarding/screens/P1Login';
import { P2PhoneCode } from '../components/onboarding/screens/P2PhoneCode'; import { P2PhoneCode } from '../components/onboarding/screens/P2PhoneCode';
import { P2QuickLogin } from '../components/onboarding/screens/P2QuickLogin'; 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 { P2WechatCode } from '../components/onboarding/screens/P2WechatCode';
import { P3Identity } from '../components/onboarding/screens/P3Identity'; import { P3Identity } from '../components/onboarding/screens/P3Identity';
import { P4Intent } from '../components/onboarding/screens/P4Intent'; import { P4Intent } from '../components/onboarding/screens/P4Intent';
@ -32,6 +34,9 @@ function FlowRouter() {
case 'P1': return <P1Login />; case 'P1': return <P1Login />;
case 'P2': return <P2PhoneCode />; case 'P2': return <P2PhoneCode />;
case 'P2Q': return <P2QuickLogin />; case 'P2Q': return <P2QuickLogin />;
case 'P2P': return <P2PasswordLogin />;
case 'P2R': return <P2PasswordReset />;
// P2W / B1 为微信登录页:2026-09-15 按产品决定摘除入口,代码保留待启用(见 微信登录接入方案.md)
case 'P2W': return <P2WechatCode />; case 'P2W': return <P2WechatCode />;
case 'P3': return <P3Identity />; case 'P3': return <P3Identity />;
case 'P4': return <P4Intent />; case 'P4': return <P4Intent />;

211
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 (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text style={styles.headerTitle}>修改登录密码</Text>
<View style={styles.headerBtn} />
</View>
);
}
/* ===================== 页面 ===================== */
/**
* 账号与安全 → 修改登录密码。
*
* 入口由 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 (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} />
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<Text style={styles.tip}>
为保障账号安全,修改密码后需要使用新密码重新登录。
</Text>
<View style={styles.card}>
<PasswordField
value={oldPassword}
onChangeText={setOldPassword}
placeholder="请输入原密码"
/>
<View style={styles.gap} />
<PasswordField
value={newPassword}
onChangeText={setNewPassword}
placeholder="请输入新密码"
/>
<View style={styles.gap} />
<PasswordField
value={confirmPassword}
onChangeText={setConfirmPassword}
placeholder="请再次输入新密码"
/>
</View>
{hint ? (
<Text style={styles.hintDanger}>{hint}</Text>
) : (
<Text style={styles.hint}>8–20 位,需同时包含字母和数字</Text>
)}
{error ? <Text style={styles.formError}>{error.message}</Text> : null}
<Pressable
style={[styles.submitBtn, !canSubmit && styles.submitDisabled]}
disabled={!canSubmit}
onPress={handleSubmit}
>
<Text style={styles.submitText}>{loading ? '提交中…' : '确认修改'}</Text>
</Pressable>
</ScrollView>
</SafeAreaView>
);
}
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,
},
});

139
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 { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { useRouter } from 'expo-router';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { colors } from '../../../src/theme'; import { colors } from '../../../src/theme';
import { useOnboarding, useWechatLogin } from '../../../src/hooks'; import { useAgreementGate, useOnboarding } from '../../../src/hooks';
import { useRole } from '../../../src/context/RoleContext';
import { Illustration } from '../ui/Illustration'; import { Illustration } from '../ui/Illustration';
import { AgreementCheckbox } from '../ui/AgreementCheckbox'; import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { AgreementModal } from '../ui/AgreementModal'; import { AgreementModal } from '../ui/AgreementModal';
import { SliderCaptcha } from '../ui/SliderCaptcha'; import { PrimaryButton } from '../ui/PrimaryButton';
import { DeletedAccountModal } from '../ui/DeletedAccountModal';
/** P1 登录主页:对齐参考样式 —— 淡渐变顶部 + 居中 Logo + 微信绿实心/灰底一键登录 + 协议弹窗拦截 */ /**
* P1 登录主页:淡渐变顶部 + 居中 Logo + 三种登录方式。
*
* 2026-09-15 按产品决定摘除微信登录入口(代码与页面保留,见 微信登录接入方案.md),
* 现为:验证码登录(主)/ 一键登录(次)/ 密码登录(弱)。
*/
export function P1Login() { export function P1Login() {
const { data, setData, navigate } = useOnboarding(); const { data, setData, navigate } = useOnboarding();
const router = useRouter(); const { gate, modalProps } = useAgreementGate();
const { setRole } = useRole();
const agreed = data.agreed; 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 ( return (
<SafeAreaView style={styles.screen} edges={['top', 'bottom']}> <SafeAreaView style={styles.screen} edges={['top', 'bottom']}>
@ -100,28 +32,15 @@ export function P1Login() {
<View style={styles.flexSpacer} /> <View style={styles.flexSpacer} />
{/* 登录方式 */} {/* 登录方式:主通道为验证码登录,其余两种降级展示 */}
<Pressable <PrimaryButton label="验证码登录" onPress={() => gate(() => navigate('P2'))} />
style={styles.wechatBtn}
onPress={() => gate(wechatLogin)}
disabled={wechatLoading}
>
<Ionicons name="logo-wechat" size={20} color={colors.textInverse} />
<Text style={styles.wechatText}>
{wechatLoading ? '登录中…' : '微信登录'}
</Text>
</Pressable>
{wechatState.kind === 'error' ? (
<Text style={styles.loginError}>{wechatState.message}</Text>
) : null}
<Pressable style={styles.quickBtn} onPress={() => gate(() => navigate('P2Q'))}> <Pressable style={styles.quickBtn} onPress={() => gate(() => navigate('P2Q'))}>
<Text style={styles.quickText}>一键登录</Text> <Text style={styles.quickText}>一键登录</Text>
</Pressable> </Pressable>
<Pressable onPress={() => gate(() => navigate('P2'))} hitSlop={8}> <Pressable onPress={() => gate(() => navigate('P2P'))} hitSlop={8}>
<Text style={styles.otherLink}>其他方式登录</Text> <Text style={styles.otherLink}>密码登录</Text>
</Pressable> </Pressable>
{/* 协议勾选 */} {/* 协议勾选 */}
@ -150,26 +69,7 @@ export function P1Login() {
</View> </View>
</ScrollView> </ScrollView>
<SliderCaptcha <AgreementModal {...modalProps} />
visible={captchaVisible}
onSuccess={() => {
setCaptchaVisible(false);
void startWechatLogin();
}}
onClose={() => setCaptchaVisible(false)}
/>
<DeletedAccountModal visible={wechatState.kind === 'deleted'} onClose={cancelLogin} />
<AgreementModal
visible={pending !== null}
onAccept={accept}
onDecline={() => setPending(null)}
onOpenAgreement={() => {
setPending(null);
navigate('B2');
}}
/>
</SafeAreaView> </SafeAreaView>
); );
} }
@ -180,16 +80,6 @@ const styles = StyleSheet.create({
content: { flexGrow: 1, paddingHorizontal: 24, paddingBottom: 12 }, content: { flexGrow: 1, paddingHorizontal: 24, paddingBottom: 12 },
brandArea: { alignItems: 'center', marginTop: '22%' }, brandArea: { alignItems: 'center', marginTop: '22%' },
flexSpacer: { flex: 1, minHeight: 28 }, 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: { quickBtn: {
height: 50, height: 50,
borderRadius: 12, borderRadius: 12,
@ -205,7 +95,6 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
color: colors.textSecondary, color: colors.textSecondary,
}, },
loginError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger },
agree: { marginTop: 22 }, agree: { marginTop: 22 },
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 }, agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
link: { color: colors.primary, fontWeight: '600' }, link: { color: colors.primary, fontWeight: '600' },

139
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 (
<ScreenShell showBack onBack={back}>
<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.passwordArea}>
<PasswordField value={password} onChangeText={setPassword} placeholder="请输入登录密码" />
</View>
<Pressable onPress={() => gate(() => navigate('P2R'))} hitSlop={8}>
<Text style={styles.forgotLink}>忘记密码?</Text>
</Pressable>
<View style={styles.btnArea}>
<PrimaryButton label="登录" active={canSubmit} loading={loading} onPress={() => gate(handleLogin)} />
</View>
{error ? <Text style={styles.formError}>{error.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>
<Pressable onPress={() => gate(() => navigate('P2'))} hitSlop={8}>
<Text style={styles.switchLink}>验证码登录</Text>
</Pressable>
<AgreementModal {...modalProps} />
</ScreenShell>
);
}
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 },
});

182
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<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: 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 },
});

142
components/onboarding/screens/P2PhoneCode.tsx

@ -2,72 +2,75 @@ import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { colors } from '../../../src/theme'; 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 { ScreenShell } from '../ui/ScreenShell';
import { CountdownButton } from '../ui/CountdownButton'; import { CountdownButton } from '../ui/CountdownButton';
import { AgreementCheckbox } from '../ui/AgreementCheckbox'; import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { PrimaryButton } from '../ui/PrimaryButton'; import { PrimaryButton } from '../ui/PrimaryButton';
import { AgreementModal } from '../ui/AgreementModal'; import { AgreementModal } from '../ui/AgreementModal';
import { SliderCaptcha } from '../ui/SliderCaptcha';
const PHONE_RE = /^1\d{10}$/; /** 登录阶段:手机号 → 验证码 */
type Stage = 'phone' | 'code';
/** P2 登录/注册:阶段1 手机号(对齐参考样式)→ 阶段2 验证码 + 60s 倒计时 */ /** P2 登录/注册:阶段1 手机号(对齐参考样式)→ 阶段2 验证码 + 60s 倒计时 */
export function P2PhoneCode() { export function P2PhoneCode() {
const { data, setData, navigate, back } = useOnboarding(); const { data, setData, navigate, back } = useOnboarding();
const [code, setCode] = useState(''); const { gate, modalProps } = useAgreementGate();
const [codeSent, setCodeSent] = useState(false); const { login, loading: logging, error: loginError } = useLogin();
/** 未勾选协议时被拦截的动作 */ const { send: sendCode, error: smsError } = useSmsCode();
const [pending, setPending] = useState<(() => void) | null>(null); /** 登录态写入:此前本页只跳 P3 不写 token,导致登录态始终为空 */
const [captchaVisible, setCaptchaVisible] = useState(false); const { login: writeAuth } = useAuth();
/** 微信第一次登录的绑定注册能力 */
const { bind, loading: binding, error: bindError } = useWechatBindPhone(); const { bind, loading: binding, error: bindError } = useWechatBindPhone();
const [code, setCode] = useState('');
const [stage, setStage] = useState<Stage>('phone');
const agreed = data.agreed; const agreed = data.agreed;
const phone = data.phone; const phone = data.phone;
const phoneValid = PHONE_RE.test(phone); const phoneValid = isValidPhone(phone);
/** 有值说明本页是从「微信第一次登录」进来的,提交时需完成微信绑定 */ /** 有值说明本页是从「微信第一次登录」进来的,提交时需完成微信绑定 */
const wechatTicket = data.wechatTicket; const wechatTicket = data.wechatTicket;
const gate = (action: () => void) => { /** 阶段1 → 阶段2:发送验证码;发送失败则停在阶段1,不误导用户去填验证码 */
if (agreed) { const requestCode = async () => {
action(); if (!phoneValid) return;
} else { try {
setPending(() => action); await sendCode(phone);
setStage('code');
} catch {
// 失败原因由 useSmsCode 记录,页面下方统一展示
} }
}; };
const accept = () => { /** 阶段2 重新获取(不改变阶段) */
setData({ agreed: true }); const resendCode = () => {
const act = pending; void sendCode(phone).catch(() => {
setPending(null); // 同上,错误已记录
act?.(); });
};
/** 阶段1 → 阶段2:发送验证码 */
const sendCode = () => {
if (!phoneValid) return;
setCodeSent(true);
}; };
/* /*
* 提交验证码: * 提交验证码:
* - 微信第一次登录(data.wechatTicket 有值):调绑定注册接口把 openid 与手机号关联, * - 微信第一次登录(data.wechatTicket 有值):调绑定注册接口把 openid 与手机号关联,
* 成功即写入登录态,再进 P3 选择身份;失败则留在当前页并提示。 * hook 内部已写入登录态,再进 P3 选择身份;失败则留在当前页并提示。
* - 其他链路:沿用原逻辑,仅记录手机号后进 P3。 * - 手机号登录:换取令牌并写入登录态(本次修复的缺口),成功后才进 P3。
*/ */
const handleLogin = async () => { const handleLogin = async () => {
if (code.length < 4) return; if (code.length < SMS_CODE_MIN_LENGTH) return;
setData({ phone, code }); setData({ phone, code });
if (wechatTicket) { if (wechatTicket) {
try { try {
await bind({ await bind({ token: wechatTicket.token, openid: wechatTicket.openid, phone, code });
token: wechatTicket.token, } catch {
openid: wechatTicket.openid, return;
phone, }
code, } else {
}); try {
const result = await login(phone, code);
writeAuth(result.token);
} catch { } catch {
return; return;
} }
@ -79,8 +82,8 @@ export function P2PhoneCode() {
const maskedPhone = phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : phone; const maskedPhone = phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : phone;
return ( return (
<ScreenShell showBack onBack={back}> <ScreenShell showBack onBack={stage === 'code' ? () => setStage('phone') : back}>
{!codeSent ? ( {stage === 'phone' ? (
/* ---------- 阶段1:手机号 ---------- */ /* ---------- 阶段1:手机号 ---------- */
<View> <View>
<Text style={styles.h1}>登录/注册</Text> <Text style={styles.h1}>登录/注册</Text>
@ -110,9 +113,11 @@ export function P2PhoneCode() {
<Text style={styles.smsHelp}>接收不到短信</Text> <Text style={styles.smsHelp}>接收不到短信</Text>
<View style={styles.btnArea}> <View style={styles.btnArea}>
<PrimaryButton label="下一步" active={phoneValid} onPress={() => gate(sendCode)} /> <PrimaryButton label="下一步" active={phoneValid} onPress={() => gate(requestCode)} />
</View> </View>
{smsError ? <Text style={styles.formError}>{smsError.message}</Text> : null}
<View style={styles.agree}> <View style={styles.agree}>
<AgreementCheckbox <AgreementCheckbox
checked={agreed} checked={agreed}
@ -129,24 +134,16 @@ export function P2PhoneCode() {
/> />
</View> </View>
{/* 分隔线 + 其他登录方式 */} {/* 其他登录方式:密码登录(与 P2P 互跳,保证两条通道都有入口) */}
<View style={styles.orRow}> <View style={styles.orRow}>
<View style={styles.orLine} /> <View style={styles.orLine} />
<Text style={styles.orText}>或通过以下方式登录</Text> <Text style={styles.orText}>或</Text>
<View style={styles.orLine} /> <View style={styles.orLine} />
</View> </View>
<View style={styles.socialRow}> <Pressable onPress={() => gate(() => navigate('P2P'))} hitSlop={8}>
<Pressable style={[styles.socialCircle, { backgroundColor: colors.success }]} onPress={() => gate(() => setCaptchaVisible(true))}> <Text style={styles.switchLink}>密码登录</Text>
<Ionicons name="logo-wechat" size={26} color={colors.textInverse} />
</Pressable> </Pressable>
<Pressable style={[styles.socialCircle, { backgroundColor: colors.primary }]} onPress={() => gate(sendCode)}>
<Ionicons name="chatbubble-ellipses" size={24} color={colors.textInverse} />
</Pressable>
<Pressable style={[styles.socialCircle, { backgroundColor: colors.dangerBtn }]} onPress={() => gate(sendCode)}>
<Text style={styles.socialIdText}>{'数字\n身份'}</Text>
</Pressable>
</View>
</View> </View>
) : ( ) : (
/* ---------- 阶段2:验证码 ---------- */ /* ---------- 阶段2:验证码 ---------- */
@ -158,25 +155,27 @@ export function P2PhoneCode() {
<TextInput <TextInput
style={styles.phoneInput} style={styles.phoneInput}
value={code} value={code}
onChangeText={(t) => setCode(t.replace(/[^\d]/g, '').slice(0, 6))} onChangeText={(t) => setCode(t.replace(/[^\d]/g, '').slice(0, SMS_CODE_MAX_LENGTH))}
placeholder="请输入验证码" placeholder="请输入验证码"
placeholderTextColor={colors.textHint} placeholderTextColor={colors.textHint}
keyboardType="number-pad" keyboardType="number-pad"
maxLength={6} maxLength={SMS_CODE_MAX_LENGTH}
/> />
<CountdownButton disabled={!phoneValid} onSend={() => setCodeSent(true)} /> <CountdownButton disabled={!phoneValid} onSend={resendCode} />
</View> </View>
<View style={styles.underline} /> <View style={styles.underline} />
<View style={styles.btnArea}> <View style={styles.btnArea}>
<PrimaryButton <PrimaryButton
label={binding ? '绑定中…' : '登录'} label={binding ? '绑定中…' : '登录'}
active={code.length >= 4 && !binding} active={code.length >= SMS_CODE_MIN_LENGTH && !binding && !logging}
onPress={() => gate(handleLogin)} onPress={() => gate(handleLogin)}
/> />
</View> </View>
{bindError ? <Text style={styles.bindError}>{bindError.message}</Text> : null} {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}> <View style={styles.agree}>
<AgreementCheckbox <AgreementCheckbox
@ -196,24 +195,7 @@ export function P2PhoneCode() {
</View> </View>
)} )}
<SliderCaptcha <AgreementModal {...modalProps} />
visible={captchaVisible}
onSuccess={() => {
setCaptchaVisible(false);
navigate('P2W');
}}
onClose={() => setCaptchaVisible(false)}
/>
<AgreementModal
visible={pending !== null}
onAccept={accept}
onDecline={() => setPending(null)}
onOpenAgreement={() => {
setPending(null);
navigate('B2');
}}
/>
</ScreenShell> </ScreenShell>
); );
} }
@ -235,19 +217,11 @@ const styles = StyleSheet.create({
smsHelp: { fontSize: 13, color: colors.textHint, marginTop: 16 }, smsHelp: { fontSize: 13, color: colors.textHint, marginTop: 16 },
btnArea: { marginTop: 28 }, btnArea: { marginTop: 28 },
agree: { marginTop: 18 }, 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 }, agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
link: { color: colors.primary, fontWeight: '600' }, 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 }, orLine: { flex: 1, height: 1, backgroundColor: colors.borderLight },
orText: { fontSize: 12, color: colors.textHint }, orText: { fontSize: 12, color: colors.textHint },
socialRow: { flexDirection: 'row', justifyContent: 'center', gap: 28, marginTop: 26 }, switchLink: { marginTop: 20, textAlign: 'center', fontSize: 14, color: colors.primaryText, fontWeight: '600' },
socialCircle: {
width: 56,
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
},
socialIdText: { fontSize: 12, fontWeight: '700', color: colors.textInverse, textAlign: 'center', lineHeight: 15 },
}); });

50
components/onboarding/screens/P2QuickLogin.tsx

@ -1,7 +1,7 @@
import { useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native'; import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme'; 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 { ScreenShell } from '../ui/ScreenShell';
import { Illustration } from '../ui/Illustration'; import { Illustration } from '../ui/Illustration';
import { AgreementCheckbox } from '../ui/AgreementCheckbox'; import { AgreementCheckbox } from '../ui/AgreementCheckbox';
@ -11,24 +11,24 @@ import { AgreementModal } from '../ui/AgreementModal';
/** P2Q 一键登录确认:本机号码认证 —— 掩码手机号 + 一键登录 + 运营商服务声明 */ /** P2Q 一键登录确认:本机号码认证 —— 掩码手机号 + 一键登录 + 运营商服务声明 */
export function P2QuickLogin() { export function P2QuickLogin() {
const { data, setData, navigate, back } = useOnboarding(); 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 agreed = data.agreed;
const phone = data.phone; const phone = data.phone;
const gate = (action: () => void) => { /** 一键登录:运营商返回本机号码并换取令牌,成功后才进 P3 */
if (agreed) { const handleLogin = async () => {
action(); try {
} else { const result = await loginByCarrier();
setPending(() => action); writeAuth(result.token);
} catch {
// 失败原因由 useLogin 记录,页面下方展示
return;
} }
}; navigate('P3');
const accept = () => {
setData({ agreed: true });
const act = pending;
setPending(null);
act?.();
}; };
/** 掩码:150****9367;未输入时用演示号段占位 */ /** 掩码:150****9367;未输入时用演示号段占位 */
@ -45,9 +45,16 @@ export function P2QuickLogin() {
<Text style={styles.subtitle}>首次验证通过即注册有职账号</Text> <Text style={styles.subtitle}>首次验证通过即注册有职账号</Text>
<View style={styles.btnArea}> <View style={styles.btnArea}>
<PrimaryButton label="一键登录" active onPress={() => gate(() => navigate('P3'))} /> <PrimaryButton
label="一键登录"
active={!loading}
loading={loading}
onPress={() => gate(handleLogin)}
/>
</View> </View>
{error ? <Text style={styles.formError}>{error.message}</Text> : null}
<View style={styles.agree}> <View style={styles.agree}>
<AgreementCheckbox <AgreementCheckbox
checked={agreed} checked={agreed}
@ -76,15 +83,7 @@ export function P2QuickLogin() {
<Text style={styles.carrierNote}>认证服务由中国移动提供</Text> <Text style={styles.carrierNote}>认证服务由中国移动提供</Text>
</View> </View>
<AgreementModal <AgreementModal {...modalProps} />
visible={pending !== null}
onAccept={accept}
onDecline={() => setPending(null)}
onOpenAgreement={() => {
setPending(null);
navigate('B2');
}}
/>
</ScreenShell> </ScreenShell>
); );
} }
@ -101,6 +100,7 @@ const styles = StyleSheet.create({
}, },
subtitle: { textAlign: 'center', fontSize: 14, color: colors.textSecondary, marginTop: 12 }, subtitle: { textAlign: 'center', fontSize: 14, color: colors.textSecondary, marginTop: 12 },
btnArea: { marginTop: 36 }, btnArea: { marginTop: 36 },
formError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger },
agree: { marginTop: 20 }, agree: { marginTop: 20 },
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 }, agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
link: { color: colors.primary, fontWeight: '600' }, link: { color: colors.primary, fontWeight: '600' },

177
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();
});
});

182
src/api/api.ts

@ -124,8 +124,21 @@ import type {
WechatLoginResult, WechatLoginResult,
WechatBindPhonePayload, WechatBindPhonePayload,
WechatSession, WechatSession,
ChangePasswordPayload,
PasswordResetPayload,
PasswordRuleError,
} from '../types'; } 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 { colors } from '../theme';
import { storageGet, storageSet, storageRemove } from '../lib/storage'; import { storageGet, storageSet, storageRemove } from '../lib/storage';
@ -5312,18 +5325,12 @@ const MOCK_ACCOUNT_SECURITY: ActionSetting[] = [
value: '138****8888', value: '138****8888',
variant: 'normal', variant: 'normal',
}, },
{
id: 'wechat',
title: '微信账号',
description: '绑定后可使用微信快捷登录',
value: '未绑定',
variant: 'normal',
},
{ {
id: 'password', id: 'password',
title: '修改登录密码', title: '修改登录密码',
description: '建议定期更新密码以保护账号安全', description: '建议定期更新密码以保护账号安全',
variant: 'normal', variant: 'normal',
route: '/settings/password',
}, },
{ {
id: 'devices', id: 'devices',
@ -5517,12 +5524,165 @@ export interface LoginResult {
* @returns 登录结果(含 token) * @returns 登录结果(含 token)
*/ */
export function loginByPhone(phone: string, code: string): Promise<LoginResult> { export function loginByPhone(phone: string, code: string): Promise<LoginResult> {
if (code.trim().length < 4) { if (!isValidPhone(phone)) {
return Promise.reject(new Error('手机号格式不正确'));
}
if (!isValidSmsCode(code)) {
return Promise.reject(new Error('验证码不正确')); return Promise.reject(new Error('验证码不正确'));
} }
return mockRequest<LoginResult>({ token: `mock_token_${phone}`, phone }); return mockRequest<LoginResult>({ 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<LoginResult> {
return mockRequest<LoginResult>({ 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<LoginResult> {
if (!isValidPhone(phone)) {
return Promise.reject(new Error('手机号格式不正确'));
}
const ruleError = validatePassword(password);
if (ruleError) {
// 真实后端应统一返回「手机号或密码错误」,此处不区分,避免账号枚举
return Promise.reject(new Error('手机号或密码错误'));
}
return mockRequest<LoginResult>({ token: `mock_token_${phone}`, phone });
}
/**
* 发送短信验证码。
*
* 此前页面「获取验证码」只做前端倒计时、无任何接口调用,本函数补齐链路。
*
* @method POST
* @url /api/auth/sms-code
* @param phone 手机号(11 位)
* @returns 无返回体;失败时 reject
*/
export function sendSmsCode(phone: string): Promise<void> {
if (!isValidPhone(phone)) {
return Promise.reject(new Error('手机号格式不正确'));
}
return mockRequest<void>(undefined);
}
/**
* 重置 / 首次设置登录密码(忘记密码与首次设密码共用同一入口)。
*
* 「登录即注册」下用户天生没有密码,因此首次获得密码走的就是本接口(忘记密码)。
* 重置成功后直接下发登录态,避免用户重置完再登录一次。
*
* @method POST
* @url /api/auth/reset-password
* @param payload 手机号 + 短信验证码 + 新密码
* @returns 登录结果(含 token)
*/
export function resetPassword(payload: PasswordResetPayload): Promise<LoginResult> {
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<LoginResult>({
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<void> {
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<void>(undefined);
}
/* ===================== 账号:微信登录(三种账号状态) ===================== */ /* ===================== 账号:微信登录(三种账号状态) ===================== */
/* /*
@ -5602,10 +5762,10 @@ export function fetchWechatLoginResult(code: string): Promise<WechatLoginResult>
export function bindWechatAndRegister( export function bindWechatAndRegister(
payload: WechatBindPhonePayload, payload: WechatBindPhonePayload,
): Promise<WechatSession> { ): Promise<WechatSession> {
if (!/^1\d{10}$/.test(payload.phone)) { if (!isValidPhone(payload.phone)) {
return Promise.reject(new Error('手机号格式不正确')); return Promise.reject(new Error('手机号格式不正确'));
} }
if (payload.code.trim().length < 4) { if (!isValidSmsCode(payload.code)) {
return Promise.reject(new Error('验证码不正确')); return Promise.reject(new Error('验证码不正确'));
} }
return mockRequest<WechatSession>({ return mockRequest<WechatSession>({

13
src/api/index.ts

@ -3,6 +3,11 @@
*/ */
export type { LoginResult } from './api'; export type { LoginResult } from './api';
export type { WechatLoginResult, WechatSession, WechatBindPhonePayload } from '../types'; export type { WechatLoginResult, WechatSession, WechatBindPhonePayload } from '../types';
export type {
ChangePasswordPayload,
PasswordResetPayload,
PasswordRuleError,
} from '../types';
export { export {
fetchAboutInfo, fetchAboutInfo,
fetchAbilityAssessment, fetchAbilityAssessment,
@ -83,6 +88,14 @@ export {
fetchLegalServiceRequests, fetchLegalServiceRequests,
createLegalServiceRequest, createLegalServiceRequest,
loginByPhone, loginByPhone,
loginByPassword,
loginByCarrier,
sendSmsCode,
resetPassword,
changePassword,
validatePassword,
validatePasswordConfirm,
getPasswordRuleMessage,
fetchWechatLoginResult, fetchWechatLoginResult,
bindWechatAndRegister, bindWechatAndRegister,
setMockWechatAccountStatus, setMockWechatAccountStatus,

72
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 (
<>
<View style={styles.row}>
<TextInput
style={styles.input}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={colors.textHint}
secureTextEntry={!visible}
autoCapitalize="none"
autoCorrect={false}
maxLength={maxLength}
onBlur={onBlur}
/>
{value.length > 0 ? (
<Pressable onPress={() => onChangeText('')} hitSlop={8}>
<Ionicons name="close-circle" size={18} color={colors.textDisabled} />
</Pressable>
) : null}
<Pressable onPress={() => setVisible((v) => !v)} hitSlop={8}>
<Ionicons
name={visible ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={colors.textHint}
/>
</Pressable>
</View>
<View style={styles.underline} />
</>
);
}
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 },
});

23
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; export const MAX_RETRY_COUNT = 3;
@ -64,3 +64,24 @@ export const ONE_DAY_MS = 24 * 60 * 60 * 1000;
/** 登录令牌有效期(毫秒),与后端 JWT 过期时间保持一致 */ /** 登录令牌有效期(毫秒),与后端 JWT 过期时间保持一致 */
export const TOKEN_TTL_MS = 30 * ONE_DAY_MS; 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<PasswordRuleError, string> = {
empty: '请输入密码',
'too-short': `密码至少 ${PASSWORD_MIN_LENGTH} 位`,
'too-long': `密码最多 ${PASSWORD_MAX_LENGTH} 位`,
'need-letter-digit': '密码需同时包含字母和数字',
mismatch: '两次输入的密码不一致',
'same-as-old': '新密码不能与原密码相同',
};

5
src/hooks/index.ts

@ -5,12 +5,14 @@ export { useAbilityAssessment } from './useAbilityAssessment';
export { useAboutInfo } from './useAboutInfo'; export { useAboutInfo } from './useAboutInfo';
export { useAccountCancel } from './useAccountCancel'; export { useAccountCancel } from './useAccountCancel';
export { useAccountSecurity } from './useAccountSecurity'; export { useAccountSecurity } from './useAccountSecurity';
export { useAgreementGate, type AgreementGate } from './useAgreementGate';
export { useAIInterview } from './useAIInterview'; export { useAIInterview } from './useAIInterview';
export { useAppealForm } from './useAppealForm'; export { useAppealForm } from './useAppealForm';
export { useAsyncData } from './useAsyncData'; export { useAsyncData } from './useAsyncData';
export { useAuditReport } from './useAuditReport'; export { useAuditReport } from './useAuditReport';
export { useCandidateDetail } from './useCandidateDetail'; export { useCandidateDetail } from './useCandidateDetail';
export { useCompareData } from './useCompareData'; export { useCompareData } from './useCompareData';
export { useChangePassword } from './useChangePassword';
export { useConversation } from './useConversation'; export { useConversation } from './useConversation';
export { useDeleteAllData } from './useDeleteAllData'; export { useDeleteAllData } from './useDeleteAllData';
export { useDndSetting } from './useDndSetting'; export { useDndSetting } from './useDndSetting';
@ -44,6 +46,9 @@ export { useNotificationSettings } from './useNotificationSettings';
export { useOfferCompareData } from './useOfferCompareData'; export { useOfferCompareData } from './useOfferCompareData';
export { useOnboardingChecklist } from './useOnboardingChecklist'; export { useOnboardingChecklist } from './useOnboardingChecklist';
export { useOperationRules } from './useOperationRules'; export { useOperationRules } from './useOperationRules';
export { usePasswordLogin } from './usePasswordLogin';
export { usePasswordReset } from './usePasswordReset';
export { useSmsCode } from './useSmsCode';
export { usePrivacyCenter } from './usePrivacyCenter'; export { usePrivacyCenter } from './usePrivacyCenter';
export { usePrivacyDetailSetting } from './usePrivacyDetailSetting'; export { usePrivacyDetailSetting } from './usePrivacyDetailSetting';
export { usePrivacySettings } from './usePrivacySettings'; export { usePrivacySettings } from './usePrivacySettings';

73
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();
* <Pressable onPress={() => gate(() => navigate('P3'))} />
* <AgreementModal {...modalProps} />
* ```
*/
export interface AgreementGate {
/** 门卫:已勾选协议直接执行动作;未勾选则弹协议弹窗,点「同意」后继续执行 */
gate: (action: () => void) => void;
/** 直接展开给 <AgreementModal {...modalProps} /> */
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,
},
};
}

69
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<void>;
/** 提交进行中 */
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<Error | null>(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 };
}

29
src/hooks/useLogin.ts

@ -1,9 +1,11 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { loginByPhone, type LoginResult } from '../api'; import { loginByCarrier, loginByPhone, type LoginResult } from '../api';
export interface UseLoginReturn { export interface UseLoginReturn {
/** 手机号 + 验证码登录,成功返回 LoginResult */ /** 手机号 + 验证码登录,成功返回 LoginResult */
login: (phone: string, code: string) => Promise<LoginResult>; login: (phone: string, code: string) => Promise<LoginResult>;
/** 运营商一键登录(本机号码认证),成功返回 LoginResult */
loginByCarrier: () => Promise<LoginResult>;
/** 登录进行中 */ /** 登录进行中 */
loading: boolean; loading: boolean;
/** 登录失败的错误(用于页面提示) */ /** 登录失败的错误(用于页面提示) */
@ -11,21 +13,22 @@ export interface UseLoginReturn {
} }
/** /**
* 登录(一次性变更,非取数)。 * 手机号登录(一次性变更,非取数)。
* 包装 api.loginByPhone,向上暴露 loading / error 供页面控制按钮与提示。 * 覆盖两种无需密码的通道:短信验证码、运营商一键登录。
* 向上暴露 loading / error 供页面控制按钮与提示;写登录态由调用页面配合 useAuth().login 完成。
*/ */
export function useLogin(): UseLoginReturn { export function useLogin(): UseLoginReturn {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const login = useCallback(async (phone: string, code: string) => { /** 统一收口 loading / error 的重复样板 */
const run = useCallback(async <T>(action: () => Promise<T>, fallback: string): Promise<T> => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const result = await loginByPhone(phone, code); return await action();
return result;
} catch (e) { } catch (e) {
const err = e instanceof Error ? e : new Error('登录失败,请稍后重试'); const err = e instanceof Error ? e : new Error(fallback);
setError(err); setError(err);
throw err; throw err;
} finally { } 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 };
} }

36
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<LoginResult>;
/** 登录进行中 */
loading: boolean;
/** 登录失败的错误(用于页面提示) */
error: Error | null;
}
/**
* 密码登录(一次性变更,非取数)。
* 包装 api.loginByPassword,向上暴露 loading / error 供页面控制按钮与提示。
*/
export function usePasswordLogin(): UsePasswordLoginReturn {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(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 };
}

62
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<LoginResult>;
/** 提交进行中 */
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<Error | null>(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 };
}

36
src/hooks/useSmsCode.ts

@ -0,0 +1,36 @@
import { useCallback, useState } from 'react';
import { sendSmsCode } from '../api';
export interface UseSmsCodeReturn {
/** 发送短信验证码(失败抛出,由页面提示) */
send: (phone: string) => Promise<void>;
/** 发送进行中(用于防重复点击) */
loading: boolean;
/** 发送失败的错误 */
error: Error | null;
}
/**
* 发送短信验证码。
* 验证码登录(P2)与重置密码(P2R)共用,倒计时由 CountdownButton 组件自管。
*/
export function useSmsCode(): UseSmsCodeReturn {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(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 };
}

38
src/types/auth.ts

@ -0,0 +1,38 @@
/**
* 账号鉴权领域模型:手机号 + 验证码 / 手机号 + 密码两条登录通道。
* 密码规则的计算与错误码映射放在 src/api 与 src/constants,
* 页面只消费联合类型与文案,不写死判断逻辑。
*/
/**
* 密码规则校验失败原因;null 表示通过校验。
* 用联合类型建模派生状态,便于 Record<PasswordRuleError, string> 反向约束文案配置完整性。
*/
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;
}

5
src/types/index.ts

@ -263,3 +263,8 @@ export type {
WechatSession, WechatSession,
WechatUserProfile, WechatUserProfile,
} from './wechat-auth'; } from './wechat-auth';
export type {
ChangePasswordPayload,
PasswordResetPayload,
PasswordRuleError,
} from './auth';

10
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" * 千分位格式化:12846 → "12,846"
*/ */

37
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());
}
Loading…
Cancel
Save