Browse Source

feat: 新增引导页/登录页并接通入口漏斗

- app/index.tsx 总闸门:引导→登录→选择身份→对应端(Redirect 漏斗)
- app/guide.tsx 引导页(4 屏职来核心能力,分页+跳过+立即体验)
- app/login.tsx 登录页(手机号+验证码倒计时、协议勾选、其他登录、游客浏览)
- _layout 包 AuthProvider 并注册 guide/login/index 屏
- 避开 onboarding.tsx(入职清单功能页),引导页命名为 guide.tsx
master
doublex001 2 months ago
parent
commit
a2fa2bad44
  1. 6
      app/_layout.tsx
  2. 194
      app/guide.tsx
  3. 23
      app/index.tsx
  4. 264
      app/login.tsx

6
app/_layout.tsx

@ -1,15 +1,20 @@
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { RoleProvider } from '../src/context/RoleContext';
import { AuthProvider } from '../src/context/AuthContext';
/**
* 根布局:用 Stack 包裹整个应用,Tab 组不显示顶部 header
*/
export default function RootLayout() {
return (
<AuthProvider>
<RoleProvider>
<StatusBar style="dark" />
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="guide" options={{ headerShown: false }} />
<Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="(enterprise)" options={{ headerShown: false }} />
<Stack.Screen name="jobs/[id]" options={{ headerShown: false }} />
@ -20,5 +25,6 @@ export default function RootLayout() {
<Stack.Screen name="assessment" options={{ title: '我的测评' }} />
</Stack>
</RoleProvider>
</AuthProvider>
);
}

194
app/guide.tsx

@ -0,0 +1,194 @@
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import { useRef, useState } from 'react';
import {
Pressable,
ScrollView,
StyleSheet,
Text,
useWindowDimensions,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors, headerGradientShort } from '../src/theme';
import { useAuth } from '../src/context/AuthContext';
interface GuideSlide {
icon: string;
title: string;
desc: string;
}
/** 引导页内容:聚焦职来四大差异化能力 */
const SLIDES: GuideSlide[] = [
{
icon: 'compass-outline',
title: '双向透明求职生态',
desc: '看真实公司评价与行业声誉,让信息差不再成为求职的障碍。',
},
{
icon: 'options-outline',
title: '自定义评价维度',
desc: '按你的关注点设置权重,实时算出综合得分,找到最匹配的公司。',
},
{
icon: 'game-controller-outline',
title: '通关小游戏',
desc: '7 关闯关引导你走完「认识自己 → 签约」的完整求职链路。',
},
{
icon: 'shield-checkmark-outline',
title: '阿布阿诺 AI 法律服务',
desc: '免费咨询与模板,付费律师与合同审核,求职权益有保障。',
},
];
export default function GuideScreen() {
const router = useRouter();
const { completeOnboarding } = useAuth();
const { width } = useWindowDimensions();
const [page, setPage] = useState(0);
const scrollRef = useRef<ScrollView>(null);
const isLast = page === SLIDES.length - 1;
const finish = () => {
completeOnboarding();
router.replace('/login');
};
const goNext = () => {
if (isLast) {
finish();
return;
}
const next = page + 1;
setPage(next);
scrollRef.current?.scrollTo({ x: next * width, animated: true });
};
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.topBar}>
<View style={styles.topSpacer} />
<Pressable onPress={finish} hitSlop={8}>
<Text style={styles.skipText}>跳过</Text>
</Pressable>
</View>
<ScrollView
ref={scrollRef}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={(e) => {
const index = Math.round(e.nativeEvent.contentOffset.x / width);
setPage(index);
}}
style={styles.pager}
>
{SLIDES.map((slide) => (
<View key={slide.title} style={[styles.slide, { width }]}>
<LinearGradient colors={[...headerGradientShort]} style={styles.illus}>
<Ionicons
name={slide.icon as keyof typeof Ionicons.glyphMap}
size={56}
color={colors.textInverse}
/>
</LinearGradient>
<Text style={styles.slideTitle}>{slide.title}</Text>
<Text style={styles.slideDesc}>{slide.desc}</Text>
</View>
))}
</ScrollView>
<View style={styles.bottom}>
<View style={styles.dots}>
{SLIDES.map((slide, i) => (
<View key={slide.title} style={[styles.dot, i === page && styles.dotActive]} />
))}
</View>
<Pressable style={styles.nextBtn} onPress={goNext} hitSlop={6}>
<LinearGradient colors={[...headerGradientShort]} style={styles.nextGradient}>
<Text style={styles.nextText}>{isLast ? '立即体验' : '下一步'}</Text>
</LinearGradient>
</Pressable>
<Text style={styles.pageHint}>
{page + 1} / {SLIDES.length}
</Text>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.white },
topBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 10,
},
topSpacer: { width: 40 },
skipText: { fontSize: 14, color: colors.textSecondary, paddingHorizontal: 4 },
pager: { flex: 1 },
slide: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 36,
},
illus: {
width: 132,
height: 132,
borderRadius: 36,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 36,
},
slideTitle: {
fontSize: 22,
fontWeight: '800',
color: colors.textPrimary,
textAlign: 'center',
marginBottom: 14,
},
slideDesc: {
fontSize: 15,
color: colors.textSecondary,
textAlign: 'center',
lineHeight: 24,
},
bottom: {
alignItems: 'center',
paddingHorizontal: 36,
paddingBottom: 28,
},
dots: { flexDirection: 'row', gap: 8, marginBottom: 24 },
dot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.border,
},
dotActive: { width: 22, backgroundColor: colors.primary },
nextBtn: {
width: '100%',
height: 50,
borderRadius: 25,
overflow: 'hidden',
},
nextGradient: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
nextText: { fontSize: 16, fontWeight: '700', color: colors.textInverse },
pageHint: {
marginTop: 16,
fontSize: 13,
color: colors.textHint,
},
});

23
app/index.tsx

@ -0,0 +1,23 @@
import { Redirect } from 'expo-router';
import { useAuth } from '../src/context/AuthContext';
import { useRole } from '../src/context/RoleContext';
/**
* 应用总入口(根路由 /)。
* 按「引导 → 登录 → 选择身份 → 对应端」漏斗放行,保证每个页面都有合理的前置入口。
*/
export default function Index() {
const { onboardingSeen, isLoggedIn } = useAuth();
const { role } = useRole();
if (!onboardingSeen) {
return <Redirect href="/guide" />;
}
if (!isLoggedIn) {
return <Redirect href="/login" />;
}
if (!role) {
return <Redirect href="/identity-select" />;
}
return <Redirect href={role === 'candidate' ? '/(tabs)' : '/(enterprise)'} />;
}

264
app/login.tsx

@ -0,0 +1,264 @@
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import { useEffect, useRef, useState } from 'react';
import {
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors, headerGradientShort } from '../src/theme';
import { useAuth } from '../src/context/AuthContext';
import { useLogin } from '../src/hooks';
const PHONE_RE = /^1\d{10}$/;
export default function LoginScreen() {
const router = useRouter();
const { login: authLogin } = useAuth();
const { login, loading, error } = useLogin();
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [agreed, setAgreed] = useState(false);
const [countdown, setCountdown] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, []);
const phoneValid = PHONE_RE.test(phone);
const canSubmit = phoneValid && code.length >= 4 && agreed && !loading;
const startCountdown = () => {
if (!phoneValid || countdown > 0) return;
setCountdown(60);
timerRef.current = setInterval(() => {
setCountdown((c) => {
if (c <= 1) {
if (timerRef.current) clearInterval(timerRef.current);
return 0;
}
return c - 1;
});
}, 1000);
};
const handleLogin = async () => {
if (!canSubmit) return;
try {
const res = await login(phone, code);
authLogin(res.token);
router.replace('/identity-select');
} catch {
// 错误已由 error 向上展示
}
};
const handleGuest = () => {
router.replace('/identity-select');
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<LinearGradient colors={[...headerGradientShort]} style={styles.header}>
<View style={styles.logoCircle}>
<Text style={styles.logoText}>职</Text>
</View>
<Text style={styles.headerTitle}>登录职来</Text>
<Text style={styles.headerSub}>智能求职 · 企业招聘一站式服务平台</Text>
</LinearGradient>
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
<View style={styles.field}>
<Text style={styles.label}>手机号</Text>
<TextInput
style={styles.input}
value={phone}
onChangeText={setPhone}
placeholder="请输入手机号"
placeholderTextColor={colors.textHint}
keyboardType="phone-pad"
maxLength={11}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>验证码</Text>
<View style={styles.codeRow}>
<TextInput
style={[styles.input, styles.codeInput]}
value={code}
onChangeText={setCode}
placeholder="请输入验证码"
placeholderTextColor={colors.textHint}
keyboardType="number-pad"
maxLength={6}
/>
<Pressable
style={[styles.codeBtn, (countdown > 0 || !phoneValid) && styles.codeBtnDisabled]}
onPress={startCountdown}
disabled={countdown > 0 || !phoneValid}
>
<Text style={styles.codeBtnText}>
{countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
</Text>
</Pressable>
</View>
</View>
{error && <Text style={styles.errorText}>{error.message}</Text>}
<Pressable
style={[styles.submitBtn, !canSubmit && styles.submitDisabled]}
onPress={handleLogin}
disabled={!canSubmit}
>
<Text style={styles.submitText}>{loading ? '登录中…' : '登录 / 注册'}</Text>
</Pressable>
<View style={styles.agreement}>
<Pressable
style={[styles.checkbox, agreed && styles.checkboxChecked]}
onPress={() => setAgreed((v) => !v)}
hitSlop={6}
>
{agreed && <Ionicons name="checkmark" size={14} color={colors.textInverse} />}
</Pressable>
<Text style={styles.agreementText}>
已阅读并同意
<Text style={styles.link} onPress={() => router.push('/settings/privacy-policy')}>
《隐私政策》
</Text>
和
<Text style={styles.link} onPress={() => router.push('/settings/user-agreement')}>
《用户协议》
</Text>
</Text>
</View>
<View style={styles.others}>
<Text style={styles.othersTitle}>其他登录方式</Text>
<View style={styles.othersRow}>
<Pressable style={styles.otherItem} onPress={() => {}} hitSlop={8}>
<Ionicons name="logo-wechat" size={26} color={colors.success} />
</Pressable>
<Pressable style={styles.otherItem} onPress={() => {}} hitSlop={8}>
<Ionicons name="logo-apple" size={26} color={colors.textPrimary} />
</Pressable>
</View>
</View>
</ScrollView>
<Pressable style={styles.guest} onPress={handleGuest} hitSlop={6}>
<Text style={styles.guestText}>暂不登录,先逛逛</Text>
</Pressable>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.pageBg },
header: {
alignItems: 'center',
paddingTop: 44,
paddingBottom: 36,
},
logoCircle: {
width: 64,
height: 64,
borderRadius: 20,
backgroundColor: colors.white,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 14,
},
logoText: { fontSize: 30, fontWeight: '800', color: colors.primary },
headerTitle: { fontSize: 22, fontWeight: '800', color: colors.textInverse },
headerSub: { fontSize: 13, color: colors.textInverse, opacity: 0.9, marginTop: 6 },
body: { flexGrow: 1, padding: 20, paddingTop: 24 },
field: { marginBottom: 18 },
label: { fontSize: 14, fontWeight: '600', color: colors.textPrimary, marginBottom: 8 },
input: {
height: 50,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.white,
paddingHorizontal: 14,
fontSize: 16,
color: colors.textPrimary,
},
codeRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
codeInput: { flex: 1 },
codeBtn: {
height: 50,
paddingHorizontal: 16,
borderRadius: 12,
backgroundColor: colors.primaryLight,
alignItems: 'center',
justifyContent: 'center',
},
codeBtnDisabled: { backgroundColor: colors.borderLight, opacity: 0.7 },
codeBtnText: { fontSize: 14, fontWeight: '600', color: colors.primaryText },
errorText: {
fontSize: 13,
color: colors.danger,
marginBottom: 12,
},
submitBtn: {
height: 50,
borderRadius: 25,
backgroundColor: colors.primaryBtn,
alignItems: 'center',
justifyContent: 'center',
marginTop: 8,
},
submitDisabled: { backgroundColor: colors.border, opacity: 0.7 },
submitText: { fontSize: 16, fontWeight: '700', color: colors.textInverse },
agreement: {
flexDirection: 'row',
alignItems: 'flex-start',
marginTop: 16,
},
checkbox: {
width: 18,
height: 18,
borderRadius: 5,
borderWidth: 1.5,
borderColor: colors.borderStrong,
alignItems: 'center',
justifyContent: 'center',
marginTop: 2,
marginRight: 8,
},
checkboxChecked: { backgroundColor: colors.primary, borderColor: colors.primary },
agreementText: { flex: 1, fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
link: { color: colors.primaryText, fontWeight: '600' },
others: { alignItems: 'center', marginTop: 32 },
othersTitle: { fontSize: 12, color: colors.textHint, marginBottom: 16 },
othersRow: { flexDirection: 'row', gap: 36 },
otherItem: {
width: 52,
height: 52,
borderRadius: 26,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.borderLight,
alignItems: 'center',
justifyContent: 'center',
},
guest: {
alignItems: 'center',
paddingVertical: 18,
backgroundColor: colors.pageBg,
},
guestText: { fontSize: 14, color: colors.textHint },
});
Loading…
Cancel
Save