diff --git a/app/register.tsx b/app/register.tsx
index a7b3ba3..300f60a 100644
--- a/app/register.tsx
+++ b/app/register.tsx
@@ -2,6 +2,8 @@ import { OnboardingProvider, useOnboarding } from '../src/hooks';
import { P1Login } from '../components/onboarding/screens/P1Login';
import { P2PhoneCode } from '../components/onboarding/screens/P2PhoneCode';
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 { P3Identity } from '../components/onboarding/screens/P3Identity';
import { P4Intent } from '../components/onboarding/screens/P4Intent';
@@ -32,6 +34,9 @@ function FlowRouter() {
case 'P1': return ;
case 'P2': return ;
case 'P2Q': return ;
+ case 'P2P': return ;
+ case 'P2R': return ;
+ // P2W / B1 为微信登录页:2026-09-15 按产品决定摘除入口,代码保留待启用(见 微信登录接入方案.md)
case 'P2W': return ;
case 'P3': return ;
case 'P4': return ;
diff --git a/app/settings/password.tsx b/app/settings/password.tsx
new file mode 100644
index 0000000..c9f8475
--- /dev/null
+++ b/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 (
+
+
+
+
+ 修改登录密码
+
+
+ );
+}
+
+/* ===================== 页面 ===================== */
+
+/**
+ * 账号与安全 → 修改登录密码。
+ *
+ * 入口由 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 (
+
+ router.back()} />
+
+
+
+ 为保障账号安全,修改密码后需要使用新密码重新登录。
+
+
+
+
+
+
+
+
+
+
+ {hint ? (
+ {hint}
+ ) : (
+ 8–20 位,需同时包含字母和数字
+ )}
+
+ {error ? {error.message} : null}
+
+
+ {loading ? '提交中…' : '确认修改'}
+
+
+
+ );
+}
+
+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,
+ },
+});
diff --git a/components/onboarding/screens/P1Login.tsx b/components/onboarding/screens/P1Login.tsx
index 108a808..fd7c61f 100644
--- a/components/onboarding/screens/P1Login.tsx
+++ b/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 { StatusBar } from 'expo-status-bar';
-import { useRouter } from 'expo-router';
import { LinearGradient } from 'expo-linear-gradient';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors } from '../../../src/theme';
-import { useOnboarding, useWechatLogin } from '../../../src/hooks';
-import { useRole } from '../../../src/context/RoleContext';
+import { useAgreementGate, useOnboarding } from '../../../src/hooks';
import { Illustration } from '../ui/Illustration';
import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { AgreementModal } from '../ui/AgreementModal';
-import { SliderCaptcha } from '../ui/SliderCaptcha';
-import { DeletedAccountModal } from '../ui/DeletedAccountModal';
-
-/** P1 登录主页:对齐参考样式 —— 淡渐变顶部 + 居中 Logo + 微信绿实心/灰底一键登录 + 协议弹窗拦截 */
+import { PrimaryButton } from '../ui/PrimaryButton';
+
+/**
+ * P1 登录主页:淡渐变顶部 + 居中 Logo + 三种登录方式。
+ *
+ * 2026-09-15 按产品决定摘除微信登录入口(代码与页面保留,见 微信登录接入方案.md),
+ * 现为:验证码登录(主)/ 一键登录(次)/ 密码登录(弱)。
+ */
export function P1Login() {
const { data, setData, navigate } = useOnboarding();
- const router = useRouter();
- const { setRole } = useRole();
+ const { gate, modalProps } = useAgreementGate();
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 (
@@ -100,28 +32,15 @@ export function P1Login() {
- {/* 登录方式 */}
- gate(wechatLogin)}
- disabled={wechatLoading}
- >
-
-
- {wechatLoading ? '登录中…' : '微信登录'}
-
-
-
- {wechatState.kind === 'error' ? (
- {wechatState.message}
- ) : null}
+ {/* 登录方式:主通道为验证码登录,其余两种降级展示 */}
+ gate(() => navigate('P2'))} />
gate(() => navigate('P2Q'))}>
一键登录
- gate(() => navigate('P2'))} hitSlop={8}>
- 其他方式登录
+ gate(() => navigate('P2P'))} hitSlop={8}>
+ 密码登录
{/* 协议勾选 */}
@@ -150,26 +69,7 @@ export function P1Login() {
- {
- setCaptchaVisible(false);
- void startWechatLogin();
- }}
- onClose={() => setCaptchaVisible(false)}
- />
-
-
-
- setPending(null)}
- onOpenAgreement={() => {
- setPending(null);
- navigate('B2');
- }}
- />
+
);
}
@@ -180,16 +80,6 @@ const styles = StyleSheet.create({
content: { flexGrow: 1, paddingHorizontal: 24, paddingBottom: 12 },
brandArea: { alignItems: 'center', marginTop: '22%' },
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: {
height: 50,
borderRadius: 12,
@@ -205,7 +95,6 @@ const styles = StyleSheet.create({
fontSize: 14,
color: colors.textSecondary,
},
- loginError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger },
agree: { marginTop: 22 },
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
link: { color: colors.primary, fontWeight: '600' },
diff --git a/components/onboarding/screens/P2PasswordLogin.tsx b/components/onboarding/screens/P2PasswordLogin.tsx
new file mode 100644
index 0000000..d4ca13d
--- /dev/null
+++ b/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 (
+
+ 密码登录
+ 使用手机号与登录密码进入有职
+
+ {/* 手机号 */}
+
+ +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(() => navigate('P2R'))} hitSlop={8}>
+ 忘记密码?
+
+
+
+ gate(handleLogin)} />
+
+
+ {error ? {error.message} : null}
+
+
+ setData({ agreed: !agreed })}
+ text={
+
+ 已阅读并同意
+ navigate('B2')}>《有职用户协议》
+ 和
+ navigate('B2')}>《隐私政策》
+ ,允许有职统一管理本人账号信息
+
+ }
+ />
+
+
+ gate(() => navigate('P2'))} hitSlop={8}>
+ 验证码登录
+
+
+
+
+ );
+}
+
+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 },
+});
diff --git a/components/onboarding/screens/P2PasswordReset.tsx b/components/onboarding/screens/P2PasswordReset.tsx
new file mode 100644
index 0000000..c5f426c
--- /dev/null
+++ b/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('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 (
+ setStage('verify') : back}>
+ {stage === 'verify' ? (
+ /* ---------- 阶段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}
+
+
+
+
+ setCode(t.replace(/[^\d]/g, '').slice(0, SMS_CODE_MAX_LENGTH))}
+ placeholder="请输入验证码"
+ placeholderTextColor={colors.textHint}
+ keyboardType="number-pad"
+ maxLength={SMS_CODE_MAX_LENGTH}
+ />
+ void sendCodeNow()} />
+
+
+
+
+ gate(() => setStage('password'))}
+ />
+
+
+ {smsError ? {smsError.message} : null}
+
+ ) : (
+ /* ---------- 阶段2:设置新密码 ---------- */
+
+ 设置登录密码
+ 设置后可用手机号 + 密码登录
+
+
+
+
+
+
+
+
+
+ {hint ? (
+ {hint}
+ ) : (
+ 8–20 位,需同时包含字母和数字
+ )}
+
+
+ gate(handleSubmit)} />
+
+
+ {error ? {error.message} : null}
+
+ )}
+
+
+
+ );
+}
+
+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 },
+});
diff --git a/components/onboarding/screens/P2PhoneCode.tsx b/components/onboarding/screens/P2PhoneCode.tsx
index 761051c..4317da7 100644
--- a/components/onboarding/screens/P2PhoneCode.tsx
+++ b/components/onboarding/screens/P2PhoneCode.tsx
@@ -2,72 +2,75 @@ import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
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 { CountdownButton } from '../ui/CountdownButton';
import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { PrimaryButton } from '../ui/PrimaryButton';
import { AgreementModal } from '../ui/AgreementModal';
-import { SliderCaptcha } from '../ui/SliderCaptcha';
-const PHONE_RE = /^1\d{10}$/;
+/** 登录阶段:手机号 → 验证码 */
+type Stage = 'phone' | 'code';
/** P2 登录/注册:阶段1 手机号(对齐参考样式)→ 阶段2 验证码 + 60s 倒计时 */
export function P2PhoneCode() {
const { data, setData, navigate, back } = useOnboarding();
- const [code, setCode] = useState('');
- const [codeSent, setCodeSent] = useState(false);
- /** 未勾选协议时被拦截的动作 */
- const [pending, setPending] = useState<(() => void) | null>(null);
- const [captchaVisible, setCaptchaVisible] = useState(false);
- /** 微信第一次登录的绑定注册能力 */
+ const { gate, modalProps } = useAgreementGate();
+ const { login, loading: logging, error: loginError } = useLogin();
+ const { send: sendCode, error: smsError } = useSmsCode();
+ /** 登录态写入:此前本页只跳 P3 不写 token,导致登录态始终为空 */
+ const { login: writeAuth } = useAuth();
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 = PHONE_RE.test(phone);
+ const phoneValid = isValidPhone(phone);
/** 有值说明本页是从「微信第一次登录」进来的,提交时需完成微信绑定 */
const wechatTicket = data.wechatTicket;
- const gate = (action: () => void) => {
- if (agreed) {
- action();
- } else {
- setPending(() => action);
+ /** 阶段1 → 阶段2:发送验证码;发送失败则停在阶段1,不误导用户去填验证码 */
+ const requestCode = async () => {
+ if (!phoneValid) return;
+ try {
+ await sendCode(phone);
+ setStage('code');
+ } catch {
+ // 失败原因由 useSmsCode 记录,页面下方统一展示
}
};
- const accept = () => {
- setData({ agreed: true });
- const act = pending;
- setPending(null);
- act?.();
- };
-
- /** 阶段1 → 阶段2:发送验证码 */
- const sendCode = () => {
- if (!phoneValid) return;
- setCodeSent(true);
+ /** 阶段2 重新获取(不改变阶段) */
+ const resendCode = () => {
+ void sendCode(phone).catch(() => {
+ // 同上,错误已记录
+ });
};
/*
* 提交验证码:
* - 微信第一次登录(data.wechatTicket 有值):调绑定注册接口把 openid 与手机号关联,
- * 成功即写入登录态,再进 P3 选择身份;失败则留在当前页并提示。
- * - 其他链路:沿用原逻辑,仅记录手机号后进 P3。
+ * hook 内部已写入登录态,再进 P3 选择身份;失败则留在当前页并提示。
+ * - 手机号登录:换取令牌并写入登录态(本次修复的缺口),成功后才进 P3。
*/
const handleLogin = async () => {
- if (code.length < 4) return;
+ if (code.length < SMS_CODE_MIN_LENGTH) return;
setData({ phone, code });
if (wechatTicket) {
try {
- await bind({
- token: wechatTicket.token,
- openid: wechatTicket.openid,
- phone,
- code,
- });
+ await bind({ token: wechatTicket.token, openid: wechatTicket.openid, phone, code });
+ } catch {
+ return;
+ }
+ } else {
+ try {
+ const result = await login(phone, code);
+ writeAuth(result.token);
} catch {
return;
}
@@ -79,8 +82,8 @@ export function P2PhoneCode() {
const maskedPhone = phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : phone;
return (
-
- {!codeSent ? (
+ setStage('phone') : back}>
+ {stage === 'phone' ? (
/* ---------- 阶段1:手机号 ---------- */
登录/注册
@@ -110,9 +113,11 @@ export function P2PhoneCode() {
接收不到短信
- gate(sendCode)} />
+ gate(requestCode)} />
+ {smsError ? {smsError.message} : null}
+
- {/* 分隔线 + 其他登录方式 */}
+ {/* 其他登录方式:密码登录(与 P2P 互跳,保证两条通道都有入口) */}
- 或通过以下方式登录
+ 或
-
- gate(() => setCaptchaVisible(true))}>
-
-
- gate(sendCode)}>
-
-
- gate(sendCode)}>
- {'数字\n身份'}
-
-
+ gate(() => navigate('P2P'))} hitSlop={8}>
+ 密码登录
+
) : (
/* ---------- 阶段2:验证码 ---------- */
@@ -158,25 +155,27 @@ export function P2PhoneCode() {
setCode(t.replace(/[^\d]/g, '').slice(0, 6))}
+ onChangeText={(t) => setCode(t.replace(/[^\d]/g, '').slice(0, SMS_CODE_MAX_LENGTH))}
placeholder="请输入验证码"
placeholderTextColor={colors.textHint}
keyboardType="number-pad"
- maxLength={6}
+ maxLength={SMS_CODE_MAX_LENGTH}
/>
- setCodeSent(true)} />
+
= 4 && !binding}
+ active={code.length >= SMS_CODE_MIN_LENGTH && !binding && !logging}
onPress={() => gate(handleLogin)}
/>
- {bindError ? {bindError.message} : null}
+ {bindError ? {bindError.message} : null}
+ {loginError ? {loginError.message} : null}
+ {smsError ? {smsError.message} : null}
)}
- {
- setCaptchaVisible(false);
- navigate('P2W');
- }}
- onClose={() => setCaptchaVisible(false)}
- />
-
- setPending(null)}
- onOpenAgreement={() => {
- setPending(null);
- navigate('B2');
- }}
- />
+
);
}
@@ -235,19 +217,11 @@ const styles = StyleSheet.create({
smsHelp: { fontSize: 13, color: colors.textHint, marginTop: 16 },
btnArea: { marginTop: 28 },
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 },
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 },
orText: { fontSize: 12, color: colors.textHint },
- socialRow: { flexDirection: 'row', justifyContent: 'center', gap: 28, marginTop: 26 },
- socialCircle: {
- width: 56,
- height: 56,
- borderRadius: 28,
- alignItems: 'center',
- justifyContent: 'center',
- },
- socialIdText: { fontSize: 12, fontWeight: '700', color: colors.textInverse, textAlign: 'center', lineHeight: 15 },
+ switchLink: { marginTop: 20, textAlign: 'center', fontSize: 14, color: colors.primaryText, fontWeight: '600' },
});
diff --git a/components/onboarding/screens/P2QuickLogin.tsx b/components/onboarding/screens/P2QuickLogin.tsx
index 05af439..50bc112 100644
--- a/components/onboarding/screens/P2QuickLogin.tsx
+++ b/components/onboarding/screens/P2QuickLogin.tsx
@@ -1,7 +1,7 @@
-import { useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
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 { Illustration } from '../ui/Illustration';
import { AgreementCheckbox } from '../ui/AgreementCheckbox';
@@ -11,24 +11,24 @@ import { AgreementModal } from '../ui/AgreementModal';
/** P2Q 一键登录确认:本机号码认证 —— 掩码手机号 + 一键登录 + 运营商服务声明 */
export function P2QuickLogin() {
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 phone = data.phone;
- const gate = (action: () => void) => {
- if (agreed) {
- action();
- } else {
- setPending(() => action);
+ /** 一键登录:运营商返回本机号码并换取令牌,成功后才进 P3 */
+ const handleLogin = async () => {
+ try {
+ const result = await loginByCarrier();
+ writeAuth(result.token);
+ } catch {
+ // 失败原因由 useLogin 记录,页面下方展示
+ return;
}
- };
-
- const accept = () => {
- setData({ agreed: true });
- const act = pending;
- setPending(null);
- act?.();
+ navigate('P3');
};
/** 掩码:150****9367;未输入时用演示号段占位 */
@@ -45,9 +45,16 @@ export function P2QuickLogin() {
首次验证通过即注册有职账号
- gate(() => navigate('P3'))} />
+ gate(handleLogin)}
+ />
+ {error ? {error.message} : null}
+
认证服务由中国移动提供
- setPending(null)}
- onOpenAgreement={() => {
- setPending(null);
- navigate('B2');
- }}
- />
+
);
}
@@ -101,6 +100,7 @@ const styles = StyleSheet.create({
},
subtitle: { textAlign: 'center', fontSize: 14, color: colors.textSecondary, marginTop: 12 },
btnArea: { marginTop: 36 },
+ formError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger },
agree: { marginTop: 20 },
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
link: { color: colors.primary, fontWeight: '600' },
diff --git a/src/__tests__/password.test.ts b/src/__tests__/password.test.ts
new file mode 100644
index 0000000..247d0f1
--- /dev/null
+++ b/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();
+ });
+});
diff --git a/src/api/api.ts b/src/api/api.ts
index 8dfcac6..c7a58b1 100644
--- a/src/api/api.ts
+++ b/src/api/api.ts
@@ -124,8 +124,21 @@ import type {
WechatLoginResult,
WechatBindPhonePayload,
WechatSession,
+ ChangePasswordPayload,
+ PasswordResetPayload,
+ PasswordRuleError,
} 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 { storageGet, storageSet, storageRemove } from '../lib/storage';
@@ -5312,18 +5325,12 @@ const MOCK_ACCOUNT_SECURITY: ActionSetting[] = [
value: '138****8888',
variant: 'normal',
},
- {
- id: 'wechat',
- title: '微信账号',
- description: '绑定后可使用微信快捷登录',
- value: '未绑定',
- variant: 'normal',
- },
{
id: 'password',
title: '修改登录密码',
description: '建议定期更新密码以保护账号安全',
variant: 'normal',
+ route: '/settings/password',
},
{
id: 'devices',
@@ -5517,12 +5524,165 @@ export interface LoginResult {
* @returns 登录结果(含 token)
*/
export function loginByPhone(phone: string, code: string): Promise {
- if (code.trim().length < 4) {
+ if (!isValidPhone(phone)) {
+ return Promise.reject(new Error('手机号格式不正确'));
+ }
+ if (!isValidSmsCode(code)) {
return Promise.reject(new Error('验证码不正确'));
}
return mockRequest({ 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 {
+ return mockRequest({ 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 {
+ if (!isValidPhone(phone)) {
+ return Promise.reject(new Error('手机号格式不正确'));
+ }
+ const ruleError = validatePassword(password);
+ if (ruleError) {
+ // 真实后端应统一返回「手机号或密码错误」,此处不区分,避免账号枚举
+ return Promise.reject(new Error('手机号或密码错误'));
+ }
+ return mockRequest({ token: `mock_token_${phone}`, phone });
+}
+
+/**
+ * 发送短信验证码。
+ *
+ * 此前页面「获取验证码」只做前端倒计时、无任何接口调用,本函数补齐链路。
+ *
+ * @method POST
+ * @url /api/auth/sms-code
+ * @param phone 手机号(11 位)
+ * @returns 无返回体;失败时 reject
+ */
+export function sendSmsCode(phone: string): Promise {
+ if (!isValidPhone(phone)) {
+ return Promise.reject(new Error('手机号格式不正确'));
+ }
+ return mockRequest(undefined);
+}
+
+/**
+ * 重置 / 首次设置登录密码(忘记密码与首次设密码共用同一入口)。
+ *
+ * 「登录即注册」下用户天生没有密码,因此首次获得密码走的就是本接口(忘记密码)。
+ * 重置成功后直接下发登录态,避免用户重置完再登录一次。
+ *
+ * @method POST
+ * @url /api/auth/reset-password
+ * @param payload 手机号 + 短信验证码 + 新密码
+ * @returns 登录结果(含 token)
+ */
+export function resetPassword(payload: PasswordResetPayload): Promise {
+ 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({
+ 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 {
+ 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(undefined);
+}
+
/* ===================== 账号:微信登录(三种账号状态) ===================== */
/*
@@ -5602,10 +5762,10 @@ export function fetchWechatLoginResult(code: string): Promise
export function bindWechatAndRegister(
payload: WechatBindPhonePayload,
): Promise {
- if (!/^1\d{10}$/.test(payload.phone)) {
+ if (!isValidPhone(payload.phone)) {
return Promise.reject(new Error('手机号格式不正确'));
}
- if (payload.code.trim().length < 4) {
+ if (!isValidSmsCode(payload.code)) {
return Promise.reject(new Error('验证码不正确'));
}
return mockRequest({
diff --git a/src/api/index.ts b/src/api/index.ts
index b2b8c06..485af63 100644
--- a/src/api/index.ts
+++ b/src/api/index.ts
@@ -3,6 +3,11 @@
*/
export type { LoginResult } from './api';
export type { WechatLoginResult, WechatSession, WechatBindPhonePayload } from '../types';
+export type {
+ ChangePasswordPayload,
+ PasswordResetPayload,
+ PasswordRuleError,
+} from '../types';
export {
fetchAboutInfo,
fetchAbilityAssessment,
@@ -83,6 +88,14 @@ export {
fetchLegalServiceRequests,
createLegalServiceRequest,
loginByPhone,
+ loginByPassword,
+ loginByCarrier,
+ sendSmsCode,
+ resetPassword,
+ changePassword,
+ validatePassword,
+ validatePasswordConfirm,
+ getPasswordRuleMessage,
fetchWechatLoginResult,
bindWechatAndRegister,
setMockWechatAccountStatus,
diff --git a/src/components/PasswordField.tsx b/src/components/PasswordField.tsx
new file mode 100644
index 0000000..7940c00
--- /dev/null
+++ b/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 (
+ <>
+
+
+ {value.length > 0 ? (
+ onChangeText('')} hitSlop={8}>
+
+
+ ) : null}
+ setVisible((v) => !v)} hitSlop={8}>
+
+
+
+
+ >
+ );
+}
+
+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 },
+});
diff --git a/src/constants/index.ts b/src/constants/index.ts
index 3a15791..de016cc 100644
--- a/src/constants/index.ts
+++ b/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;
@@ -64,3 +64,24 @@ export const ONE_DAY_MS = 24 * 60 * 60 * 1000;
/** 登录令牌有效期(毫秒),与后端 JWT 过期时间保持一致 */
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 = {
+ empty: '请输入密码',
+ 'too-short': `密码至少 ${PASSWORD_MIN_LENGTH} 位`,
+ 'too-long': `密码最多 ${PASSWORD_MAX_LENGTH} 位`,
+ 'need-letter-digit': '密码需同时包含字母和数字',
+ mismatch: '两次输入的密码不一致',
+ 'same-as-old': '新密码不能与原密码相同',
+};
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index f550117..c0dbc1f 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -5,12 +5,14 @@ export { useAbilityAssessment } from './useAbilityAssessment';
export { useAboutInfo } from './useAboutInfo';
export { useAccountCancel } from './useAccountCancel';
export { useAccountSecurity } from './useAccountSecurity';
+export { useAgreementGate, type AgreementGate } from './useAgreementGate';
export { useAIInterview } from './useAIInterview';
export { useAppealForm } from './useAppealForm';
export { useAsyncData } from './useAsyncData';
export { useAuditReport } from './useAuditReport';
export { useCandidateDetail } from './useCandidateDetail';
export { useCompareData } from './useCompareData';
+export { useChangePassword } from './useChangePassword';
export { useConversation } from './useConversation';
export { useDeleteAllData } from './useDeleteAllData';
export { useDndSetting } from './useDndSetting';
@@ -44,6 +46,9 @@ export { useNotificationSettings } from './useNotificationSettings';
export { useOfferCompareData } from './useOfferCompareData';
export { useOnboardingChecklist } from './useOnboardingChecklist';
export { useOperationRules } from './useOperationRules';
+export { usePasswordLogin } from './usePasswordLogin';
+export { usePasswordReset } from './usePasswordReset';
+export { useSmsCode } from './useSmsCode';
export { usePrivacyCenter } from './usePrivacyCenter';
export { usePrivacyDetailSetting } from './usePrivacyDetailSetting';
export { usePrivacySettings } from './usePrivacySettings';
diff --git a/src/hooks/useAgreementGate.ts b/src/hooks/useAgreementGate.ts
new file mode 100644
index 0000000..8c00dcc
--- /dev/null
+++ b/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();
+ * gate(() => navigate('P3'))} />
+ *
+ * ```
+ */
+
+export interface AgreementGate {
+ /** 门卫:已勾选协议直接执行动作;未勾选则弹协议弹窗,点「同意」后继续执行 */
+ gate: (action: () => void) => void;
+ /** 直接展开给 */
+ 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,
+ },
+ };
+}
diff --git a/src/hooks/useChangePassword.ts b/src/hooks/useChangePassword.ts
new file mode 100644
index 0000000..554220c
--- /dev/null
+++ b/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;
+ /** 提交进行中 */
+ 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(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 };
+}
diff --git a/src/hooks/useLogin.ts b/src/hooks/useLogin.ts
index 764b556..a207530 100644
--- a/src/hooks/useLogin.ts
+++ b/src/hooks/useLogin.ts
@@ -1,9 +1,11 @@
import { useCallback, useState } from 'react';
-import { loginByPhone, type LoginResult } from '../api';
+import { loginByCarrier, loginByPhone, type LoginResult } from '../api';
export interface UseLoginReturn {
/** 手机号 + 验证码登录,成功返回 LoginResult */
login: (phone: string, code: string) => Promise;
+ /** 运营商一键登录(本机号码认证),成功返回 LoginResult */
+ loginByCarrier: () => Promise;
/** 登录进行中 */
loading: boolean;
/** 登录失败的错误(用于页面提示) */
@@ -11,21 +13,22 @@ export interface UseLoginReturn {
}
/**
- * 登录(一次性变更,非取数)。
- * 包装 api.loginByPhone,向上暴露 loading / error 供页面控制按钮与提示。
+ * 手机号登录(一次性变更,非取数)。
+ * 覆盖两种无需密码的通道:短信验证码、运营商一键登录。
+ * 向上暴露 loading / error 供页面控制按钮与提示;写登录态由调用页面配合 useAuth().login 完成。
*/
export function useLogin(): UseLoginReturn {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
- const login = useCallback(async (phone: string, code: string) => {
+ /** 统一收口 loading / error 的重复样板 */
+ const run = useCallback(async (action: () => Promise, fallback: string): Promise => {
setLoading(true);
setError(null);
try {
- const result = await loginByPhone(phone, code);
- return result;
+ return await action();
} catch (e) {
- const err = e instanceof Error ? e : new Error('登录失败,请稍后重试');
+ const err = e instanceof Error ? e : new Error(fallback);
setError(err);
throw err;
} 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 };
}
diff --git a/src/hooks/usePasswordLogin.ts b/src/hooks/usePasswordLogin.ts
new file mode 100644
index 0000000..1711d62
--- /dev/null
+++ b/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;
+ /** 登录进行中 */
+ loading: boolean;
+ /** 登录失败的错误(用于页面提示) */
+ error: Error | null;
+}
+
+/**
+ * 密码登录(一次性变更,非取数)。
+ * 包装 api.loginByPassword,向上暴露 loading / error 供页面控制按钮与提示。
+ */
+export function usePasswordLogin(): UsePasswordLoginReturn {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(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 };
+}
diff --git a/src/hooks/usePasswordReset.ts b/src/hooks/usePasswordReset.ts
new file mode 100644
index 0000000..992348b
--- /dev/null
+++ b/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;
+ /** 提交进行中 */
+ 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(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 };
+}
diff --git a/src/hooks/useSmsCode.ts b/src/hooks/useSmsCode.ts
new file mode 100644
index 0000000..981585e
--- /dev/null
+++ b/src/hooks/useSmsCode.ts
@@ -0,0 +1,36 @@
+import { useCallback, useState } from 'react';
+import { sendSmsCode } from '../api';
+
+export interface UseSmsCodeReturn {
+ /** 发送短信验证码(失败抛出,由页面提示) */
+ send: (phone: string) => Promise;
+ /** 发送进行中(用于防重复点击) */
+ loading: boolean;
+ /** 发送失败的错误 */
+ error: Error | null;
+}
+
+/**
+ * 发送短信验证码。
+ * 验证码登录(P2)与重置密码(P2R)共用,倒计时由 CountdownButton 组件自管。
+ */
+export function useSmsCode(): UseSmsCodeReturn {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(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 };
+}
diff --git a/src/types/auth.ts b/src/types/auth.ts
new file mode 100644
index 0000000..9a4b085
--- /dev/null
+++ b/src/types/auth.ts
@@ -0,0 +1,38 @@
+/**
+ * 账号鉴权领域模型:手机号 + 验证码 / 手机号 + 密码两条登录通道。
+ * 密码规则的计算与错误码映射放在 src/api 与 src/constants,
+ * 页面只消费联合类型与文案,不写死判断逻辑。
+ */
+
+/**
+ * 密码规则校验失败原因;null 表示通过校验。
+ * 用联合类型建模派生状态,便于 Record 反向约束文案配置完整性。
+ */
+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;
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index aea8fe1..eab6d6a 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -263,3 +263,8 @@ export type {
WechatSession,
WechatUserProfile,
} from './wechat-auth';
+export type {
+ ChangePasswordPayload,
+ PasswordResetPayload,
+ PasswordRuleError,
+} from './auth';
diff --git a/src/utils/index.ts b/src/utils/index.ts
index be555dd..7450f2d 100644
--- a/src/utils/index.ts
+++ b/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"
*/
diff --git a/src/utils/validators.ts b/src/utils/validators.ts
new file mode 100644
index 0000000..7ae40ae
--- /dev/null
+++ b/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());
+}