Browse Source
- app/index.tsx 总闸门:引导→登录→选择身份→对应端(Redirect 漏斗) - app/guide.tsx 引导页(4 屏职来核心能力,分页+跳过+立即体验) - app/login.tsx 登录页(手机号+验证码倒计时、协议勾选、其他登录、游客浏览) - _layout 包 AuthProvider 并注册 guide/login/index 屏 - 避开 onboarding.tsx(入职清单功能页),引导页命名为 guide.tsxmaster
4 changed files with 493 additions and 6 deletions
@ -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, |
|||
}, |
|||
}); |
|||
@ -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)'} />; |
|||
} |
|||
@ -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…
Reference in new issue