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