Compare commits
3 Commits
7d5f2a0628
...
731b4c9bac
| Author | SHA1 | Date |
|---|---|---|
|
|
731b4c9bac | 2 months ago |
|
|
6637c0ef8c | 2 months ago |
|
|
0d4ee5b87c | 2 months ago |
68 changed files with 3007 additions and 549 deletions
@ -1,217 +0,0 @@ |
|||||
import { Ionicons } from '@expo/vector-icons'; |
|
||||
import { useRouter } from 'expo-router'; |
|
||||
import { useState } from 'react'; |
|
||||
import { |
|
||||
ActivityIndicator, |
|
||||
Pressable, |
|
||||
ScrollView, |
|
||||
StyleSheet, |
|
||||
Text, |
|
||||
View, |
|
||||
} from 'react-native'; |
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'; |
|
||||
import { useIdentityOptions } from '../src/hooks'; |
|
||||
import { useRole } from '../src/context/RoleContext'; |
|
||||
import { useAuth } from '../src/context/AuthContext'; |
|
||||
import { colors } from '../src/theme'; |
|
||||
import type { IdentityOption } from '../src/types'; |
|
||||
|
|
||||
/* ===================== 子组件:顶部 Logo ===================== */ |
|
||||
|
|
||||
function LogoHeader() { |
|
||||
return ( |
|
||||
<View style={styles.logoHeader}> |
|
||||
<View style={styles.logoCircle}> |
|
||||
<Text style={styles.logoText}>职</Text> |
|
||||
</View> |
|
||||
<Text style={styles.brandName}>职来</Text> |
|
||||
<Text style={styles.brandSlogan}>智能求职 · 企业招聘一站式服务平台</Text> |
|
||||
</View> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
/* ===================== 子组件:身份卡片 ===================== */ |
|
||||
|
|
||||
/** |
|
||||
* 身份卡片:两张卡视觉重量保持一致(统一白底描边图标 + 主色线框), |
|
||||
* 仅通过选中态的主题色底与描边区分,避免蓝/绿双色造成的重量不均衡。 |
|
||||
*/ |
|
||||
function IdentityCard({ |
|
||||
option, |
|
||||
selected, |
|
||||
onSelect, |
|
||||
}: { |
|
||||
option: IdentityOption; |
|
||||
selected: boolean; |
|
||||
onSelect: () => void; |
|
||||
}) { |
|
||||
return ( |
|
||||
<Pressable |
|
||||
style={[styles.card, selected && styles.cardSelected]} |
|
||||
onPress={onSelect} |
|
||||
hitSlop={4} |
|
||||
> |
|
||||
<View |
|
||||
style={[ |
|
||||
styles.iconWrap, |
|
||||
{ backgroundColor: selected ? colors.primaryLight : colors.white }, |
|
||||
selected && { borderColor: colors.primaryBorder }, |
|
||||
]} |
|
||||
> |
|
||||
<Ionicons |
|
||||
name={option.icon as keyof typeof Ionicons.glyphMap} |
|
||||
size={28} |
|
||||
color={colors.primary} |
|
||||
/> |
|
||||
</View> |
|
||||
<Text style={[styles.cardTitle, selected && styles.cardTitleSelected]}>{option.title}</Text> |
|
||||
<Text style={styles.cardDesc}>{option.desc}</Text> |
|
||||
{selected && ( |
|
||||
<View style={styles.check}> |
|
||||
<Ionicons name="checkmark-circle" size={22} color={colors.primary} /> |
|
||||
</View> |
|
||||
)} |
|
||||
</Pressable> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
/* ===================== 子组件:四态 ===================== */ |
|
||||
|
|
||||
function Loading() { |
|
||||
return ( |
|
||||
<View style={styles.center}> |
|
||||
<ActivityIndicator size="large" color={colors.primary} /> |
|
||||
<Text style={styles.tip}>正在加载…</Text> |
|
||||
</View> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
function ErrorState({ onRetry }: { onRetry: () => void }) { |
|
||||
return ( |
|
||||
<View style={styles.center}> |
|
||||
<Ionicons name="swap-horizontal-outline" size={40} color={colors.textHint} /> |
|
||||
<Text style={styles.tip}>加载失败</Text> |
|
||||
<Pressable style={styles.retryBtn} onPress={onRetry} hitSlop={6}> |
|
||||
<Text style={styles.retryText}>点击重试</Text> |
|
||||
</Pressable> |
|
||||
</View> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
/* ===================== 页面 ===================== */ |
|
||||
|
|
||||
export default function IdentitySelectScreen() { |
|
||||
const router = useRouter(); |
|
||||
const { setRole } = useRole(); |
|
||||
const { candidateSetupDone, enterpriseVerified } = useAuth(); |
|
||||
const { options, loading, error, refresh } = useIdentityOptions(); |
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null); |
|
||||
|
|
||||
/** |
|
||||
* 选中身份即进入:先写 role,再进入该身份的下一步(避免点到卡片无反应)。 |
|
||||
* - 求职者:未设置求职意向 → 求职意向页;已设置 → 求职端 |
|
||||
* - 招聘者:未完成企业认证 → 企业认证页;已完成 → 企业端 |
|
||||
* 注意:两个路由组均无 index,故一律用显式落地路由。 |
|
||||
*/ |
|
||||
const handleSelect = (option: IdentityOption) => { |
|
||||
setSelectedId(option.id); |
|
||||
if (option.role === 'candidate') { |
|
||||
setRole('candidate'); |
|
||||
router.replace(candidateSetupDone ? '/(tabs)/jobs' : '/job-preference'); |
|
||||
} else { |
|
||||
setRole('enterprise'); |
|
||||
router.replace( |
|
||||
enterpriseVerified ? '/(enterprise)/job-management' : '/enterprise-certification', |
|
||||
); |
|
||||
} |
|
||||
}; |
|
||||
|
|
||||
return ( |
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}> |
|
||||
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}> |
|
||||
<LogoHeader /> |
|
||||
|
|
||||
{loading ? ( |
|
||||
<Loading /> |
|
||||
) : error ? ( |
|
||||
<ErrorState onRetry={refresh} /> |
|
||||
) : ( |
|
||||
<> |
|
||||
<View style={styles.cards}> |
|
||||
{options.map((opt) => ( |
|
||||
<IdentityCard |
|
||||
key={opt.id} |
|
||||
option={opt} |
|
||||
selected={opt.id === selectedId} |
|
||||
onSelect={() => handleSelect(opt)} |
|
||||
/> |
|
||||
))} |
|
||||
</View> |
|
||||
|
|
||||
<Text style={styles.hint}>点击上方卡片,进入对应身份页面</Text> |
|
||||
<Text style={styles.switchTip}>身份后续可在设置中切换</Text> |
|
||||
</> |
|
||||
)} |
|
||||
</ScrollView> |
|
||||
</SafeAreaView> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
const styles = StyleSheet.create({ |
|
||||
container: { flex: 1, backgroundColor: colors.pageBg }, |
|
||||
body: { flexGrow: 1, padding: 20, paddingTop: 28 }, |
|
||||
logoHeader: { alignItems: 'center', marginBottom: 32 }, |
|
||||
logoCircle: { |
|
||||
width: 72, |
|
||||
height: 72, |
|
||||
borderRadius: 36, |
|
||||
backgroundColor: colors.primary, |
|
||||
alignItems: 'center', |
|
||||
justifyContent: 'center', |
|
||||
marginBottom: 12, |
|
||||
}, |
|
||||
logoText: { fontSize: 32, fontWeight: '800', color: colors.textInverse }, |
|
||||
brandName: { fontSize: 24, fontWeight: '800', color: colors.textPrimary }, |
|
||||
brandSlogan: { fontSize: 13, color: colors.textSecondary, marginTop: 6 }, |
|
||||
cards: { flexDirection: 'row', gap: 16, marginBottom: 20 }, |
|
||||
card: { |
|
||||
flex: 1, |
|
||||
position: 'relative', |
|
||||
backgroundColor: colors.white, |
|
||||
borderRadius: 16, |
|
||||
padding: 16, |
|
||||
borderWidth: 1.5, |
|
||||
borderColor: colors.borderLight, |
|
||||
}, |
|
||||
cardSelected: { borderColor: colors.primary, backgroundColor: colors.primaryLight }, |
|
||||
iconWrap: { |
|
||||
width: 52, |
|
||||
height: 52, |
|
||||
borderRadius: 14, |
|
||||
alignItems: 'center', |
|
||||
justifyContent: 'center', |
|
||||
marginBottom: 12, |
|
||||
borderWidth: 1, |
|
||||
borderColor: colors.borderLight, |
|
||||
}, |
|
||||
cardTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary }, |
|
||||
cardTitleSelected: { color: colors.primaryText }, |
|
||||
cardDesc: { fontSize: 12, color: colors.textSecondary, marginTop: 6, lineHeight: 18 }, |
|
||||
check: { position: 'absolute', top: 12, right: 12 }, |
|
||||
hint: { |
|
||||
marginTop: 8, |
|
||||
fontSize: 13, |
|
||||
color: colors.textHint, |
|
||||
textAlign: 'center', |
|
||||
}, |
|
||||
switchTip: { |
|
||||
marginTop: 6, |
|
||||
fontSize: 12, |
|
||||
color: colors.textHint, |
|
||||
textAlign: 'center', |
|
||||
}, |
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 40, gap: 12 }, |
|
||||
tip: { fontSize: 14, color: colors.textSecondary }, |
|
||||
retryBtn: { paddingHorizontal: 18, paddingVertical: 8, borderRadius: 16, backgroundColor: colors.primaryLight }, |
|
||||
retryText: { fontSize: 13, fontWeight: '600', color: colors.primaryText }, |
|
||||
}); |
|
||||
@ -1,264 +0,0 @@ |
|||||
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 }, |
|
||||
}); |
|
||||
@ -0,0 +1,67 @@ |
|||||
|
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 { P3Identity } from '../components/onboarding/screens/P3Identity'; |
||||
|
import { P4Intent } from '../components/onboarding/screens/P4Intent'; |
||||
|
import { P5City } from '../components/onboarding/screens/P5City'; |
||||
|
import { P6ExpectedPosition } from '../components/onboarding/screens/P6ExpectedPosition'; |
||||
|
import { P7Salary } from '../components/onboarding/screens/P7Salary'; |
||||
|
import { P8Resume1 } from '../components/onboarding/screens/P8Resume1'; |
||||
|
import { P9JobStatus } from '../components/onboarding/screens/P9JobStatus'; |
||||
|
import { P10FirstWork } from '../components/onboarding/screens/P10FirstWork'; |
||||
|
import { P11CurrentPosition } from '../components/onboarding/screens/P11CurrentPosition'; |
||||
|
import { P12Company } from '../components/onboarding/screens/P12Company'; |
||||
|
import { P13JoinDate } from '../components/onboarding/screens/P13JoinDate'; |
||||
|
import { P14Skills } from '../components/onboarding/screens/P14Skills'; |
||||
|
import { P16WorkContent } from '../components/onboarding/screens/P16WorkContent'; |
||||
|
import { P17Reference } from '../components/onboarding/screens/P17Reference'; |
||||
|
import { P18Education } from '../components/onboarding/screens/P18Education'; |
||||
|
import { P20School } from '../components/onboarding/screens/P20School'; |
||||
|
import { P21Avatar } from '../components/onboarding/screens/P21Avatar'; |
||||
|
import { P22Done } from '../components/onboarding/screens/P22Done'; |
||||
|
import { P23Home } from '../components/onboarding/screens/P23Home'; |
||||
|
import { B1Wechat } from '../components/onboarding/screens/B1Wechat'; |
||||
|
import { B2Agreement } from '../components/onboarding/screens/B2Agreement'; |
||||
|
import { B3Recruiter } from '../components/onboarding/screens/B3Recruiter'; |
||||
|
|
||||
|
function FlowRouter() { |
||||
|
const { current } = useOnboarding(); |
||||
|
switch (current) { |
||||
|
case 'P1': return <P1Login />; |
||||
|
case 'P2': return <P2PhoneCode />; |
||||
|
case 'P2Q': return <P2QuickLogin />; |
||||
|
case 'P3': return <P3Identity />; |
||||
|
case 'P4': return <P4Intent />; |
||||
|
case 'P5': return <P5City />; |
||||
|
case 'P6': return <P6ExpectedPosition />; |
||||
|
case 'P7': return <P7Salary />; |
||||
|
case 'P8': return <P8Resume1 />; |
||||
|
case 'P9': return <P9JobStatus />; |
||||
|
case 'P10': return <P10FirstWork />; |
||||
|
case 'P11': return <P11CurrentPosition />; |
||||
|
case 'P12': return <P12Company />; |
||||
|
case 'P13': return <P13JoinDate />; |
||||
|
case 'P14': return <P14Skills />; |
||||
|
case 'P16': return <P16WorkContent />; |
||||
|
case 'P17': return <P17Reference />; |
||||
|
case 'P18': return <P18Education />; |
||||
|
case 'P20': return <P20School />; |
||||
|
case 'P21': return <P21Avatar />; |
||||
|
case 'P22': return <P22Done />; |
||||
|
case 'P23': return <P23Home />; |
||||
|
case 'B1': return <B1Wechat />; |
||||
|
case 'B2': return <B2Agreement />; |
||||
|
case 'B3': return <B3Recruiter />; |
||||
|
default: return <P1Login />; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** 新用户注册全流程:默认注册入口 /register */ |
||||
|
export default function RegisterFlow() { |
||||
|
return ( |
||||
|
<OnboardingProvider> |
||||
|
<FlowRouter /> |
||||
|
</OnboardingProvider> |
||||
|
); |
||||
|
} |
||||
|
After Width: | Height: | Size: 83 KiB |
@ -0,0 +1,44 @@ |
|||||
|
import type { ImageSourcePropType } from 'react-native'; |
||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import type { ComponentProps } from 'react'; |
||||
|
import { colors } from '../../src/theme'; |
||||
|
|
||||
|
/** |
||||
|
* 图片素材占位注册表(色块占位方案) |
||||
|
* ------------------------------------------------------------ |
||||
|
* 【换真实图只改这里】把对应项的 source 改为 require('../../assets/xxx.png') |
||||
|
* 即可启用真实图,Illustration 会自动渲染图片,否则渲染色块占位。 |
||||
|
* 所有页面组件只引用本注册表的 name,不直接持有图片,保证页面纯净。 |
||||
|
*/ |
||||
|
export type AssetName = |
||||
|
| 'logo' |
||||
|
| 'illustCandidate' |
||||
|
| 'illustRecruiter' |
||||
|
| 'illustCelebrate' |
||||
|
| 'avatarDesigner' |
||||
|
| 'wechat' |
||||
|
| 'businessLicense' |
||||
|
| 'defaultAvatar'; |
||||
|
|
||||
|
type IconName = ComponentProps<typeof Ionicons>['name']; |
||||
|
|
||||
|
export interface AssetDef { |
||||
|
/** 【换真实图只改这里】设为 require('../../assets/xxx.png') 即启用真实图,否则渲染色块占位 */ |
||||
|
source: ImageSourcePropType | null; |
||||
|
tint: string; |
||||
|
icon: IconName; |
||||
|
hint: string; |
||||
|
} |
||||
|
|
||||
|
export const ASSET_REGISTRY: Record<AssetName, AssetDef> = { |
||||
|
// RN 静态图片仅支持 require 引用,豁免 no-require-imports 规则
|
||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
|
logo: { source: require('../../assets/brand-logo.jpg'), tint: colors.primary, icon: 'briefcase', hint: '有职 Logo' }, |
||||
|
illustCandidate: { source: null, tint: colors.primaryLight, icon: 'document-text', hint: '手持 OFFER 职场男性插画' }, |
||||
|
illustRecruiter: { source: null, tint: colors.primaryLight, icon: 'person', hint: '手持 RESUME 职场男性插画' }, |
||||
|
illustCelebrate: { source: null, tint: colors.successLight, icon: 'ribbon', hint: '庆祝插画' }, |
||||
|
avatarDesigner: { source: null, tint: colors.infoLight, icon: 'person', hint: '资深 UI 设计师头像' }, |
||||
|
wechat: { source: null, tint: colors.success, icon: 'logo-wechat', hint: '微信图标' }, |
||||
|
businessLicense: { source: null, tint: colors.primaryLight, icon: 'document', hint: '营业执照示例图' }, |
||||
|
defaultAvatar: { source: null, tint: colors.borderLight, icon: 'person', hint: '默认头像剪影' }, |
||||
|
}; |
||||
@ -0,0 +1,41 @@ |
|||||
|
import { StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { Illustration } from '../ui/Illustration'; |
||||
|
import { Avatar } from '../ui/Avatar'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
|
||||
|
export function B1Wechat() { |
||||
|
const { navigate, back, meta } = useOnboarding(); |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} title="微信授权" showBack onBack={back}> |
||||
|
<View style={styles.center}> |
||||
|
<Illustration name="wechat" size={72} radius={18} /> |
||||
|
<View style={styles.preview}> |
||||
|
<Avatar name="defaultAvatar" size={48} /> |
||||
|
<View style={styles.previewText}> |
||||
|
<Text style={styles.nick}>微信用户_8f3a</Text> |
||||
|
<Text style={styles.tip}>将使用以下信息登录有职</Text> |
||||
|
</View> |
||||
|
</View> |
||||
|
<Text style={styles.scope}>· 获得你的昵称、头像</Text> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<PrimaryButton label="同意并继续" active onPress={() => navigate('P3')} /> |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 18, paddingBottom: 40 }, |
||||
|
preview: { flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: colors.white, padding: 14, borderRadius: 14, borderWidth: 1, borderColor: colors.border }, |
||||
|
previewText: { gap: 4 }, |
||||
|
nick: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, |
||||
|
tip: { fontSize: 12, color: colors.textHint }, |
||||
|
scope: { fontSize: 12, color: colors.textHint }, |
||||
|
footer: { marginTop: 20 }, |
||||
|
}); |
||||
@ -0,0 +1,38 @@ |
|||||
|
import { ScrollView, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
|
||||
|
const PARAGRAPHS = [ |
||||
|
'一、总则。欢迎使用有职求职招聘平台。在使用本服务前,请您务必仔细阅读并透彻理解本《用户协议》与《隐私政策》的全部内容。', |
||||
|
'二、账号注册。您确认,在您开始注册程序使用有职服务前,您应当具备中华人民共和国法律规定的与您行为相适应的民事行为能力。', |
||||
|
'三、信息收集。为向您提供职位推荐、企业认证等服务,我们可能收集您的手机号、简历信息、位置信息等。我们承诺严格依照《隐私政策》使用并保护上述信息。', |
||||
|
'四、未成年人保护。本平台服务主要面向年满 18 周岁的用户。如您未满 18 周岁,请在监护人陪同下使用,并确保已取得监护人同意。', |
||||
|
'五、用户行为规范。您承诺不利用有职平台制作、发布、传播任何违反法律法规或公序良俗的信息,不从事任何干扰平台正常运营的行为。', |
||||
|
'六、责任限制。在法律允许的最大范围内,有职对您因使用或无法使用本服务而导致的任何间接、偶然或后果性损害不承担责任。', |
||||
|
'七、协议变更。有职保留在必要时修改本条款的权利,修改后的条款将在平台公示后生效。', |
||||
|
]; |
||||
|
|
||||
|
export function B2Agreement() { |
||||
|
const { back, meta } = useOnboarding(); |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} title="用户协议 / 隐私政策" showBack onBack={back}> |
||||
|
<ScrollView style={styles.body} showsVerticalScrollIndicator={false}> |
||||
|
<Text style={styles.h}>有职用户协议与隐私政策</Text> |
||||
|
{PARAGRAPHS.map((p, i) => ( |
||||
|
<Text key={i} style={styles.p}> |
||||
|
{p} |
||||
|
</Text> |
||||
|
))} |
||||
|
<View style={{ height: 20 }} /> |
||||
|
</ScrollView> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
body: { flex: 1 }, |
||||
|
h: { fontSize: 18, fontWeight: '800', color: colors.textPrimary, marginBottom: 14 }, |
||||
|
p: { fontSize: 14, color: colors.textSecondary, lineHeight: 24, marginBottom: 14 }, |
||||
|
}); |
||||
@ -0,0 +1,84 @@ |
|||||
|
import { useState } from 'react'; |
||||
|
import { StyleSheet, Text, TextInput, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding, useRegisterFinish } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { Illustration } from '../ui/Illustration'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
|
||||
|
export function B3Recruiter() { |
||||
|
const { data, setData, back, meta } = useOnboarding(); |
||||
|
const finish = useRegisterFinish(); |
||||
|
const [submitted, setSubmitted] = useState(false); |
||||
|
|
||||
|
const canSubmit = data.recruiterCompany.trim().length > 0; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} title="企业认证" showBack onBack={back}> |
||||
|
<Text style={styles.tip}>请完成企业认证,开启招聘之旅(与求职者主线分开演示)</Text> |
||||
|
|
||||
|
<Text style={styles.label}>公司名称</Text> |
||||
|
<TextInput |
||||
|
style={styles.input} |
||||
|
value={data.recruiterCompany} |
||||
|
onChangeText={(t) => setData({ recruiterCompany: t })} |
||||
|
placeholder="请输入公司全称" |
||||
|
placeholderTextColor={colors.textHint} |
||||
|
/> |
||||
|
|
||||
|
<Text style={styles.label}>营业执照</Text> |
||||
|
<View style={styles.upload}> |
||||
|
<Illustration name="businessLicense" size={96} radius={12} /> |
||||
|
<Text style={styles.uploadText}>点击上传营业执照</Text> |
||||
|
</View> |
||||
|
|
||||
|
<Text style={styles.label}>联系人</Text> |
||||
|
<TextInput |
||||
|
style={styles.input} |
||||
|
value={data.school} |
||||
|
onChangeText={(t) => setData({ school: t })} |
||||
|
placeholder="请输入联系人姓名" |
||||
|
placeholderTextColor={colors.textHint} |
||||
|
/> |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<PrimaryButton |
||||
|
label={submitted ? '已提交,等待审核' : '提交认证'} |
||||
|
active={canSubmit && !submitted} |
||||
|
onPress={() => { |
||||
|
setSubmitted(true); |
||||
|
finish('enterprise'); |
||||
|
}} |
||||
|
/> |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
tip: { fontSize: 13, color: colors.textHint, marginBottom: 18 }, |
||||
|
label: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginTop: 16, marginBottom: 8 }, |
||||
|
input: { |
||||
|
height: 50, |
||||
|
borderRadius: 12, |
||||
|
borderWidth: 1, |
||||
|
borderColor: colors.border, |
||||
|
backgroundColor: colors.white, |
||||
|
paddingHorizontal: 14, |
||||
|
fontSize: 15, |
||||
|
color: colors.textPrimary, |
||||
|
}, |
||||
|
upload: { |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
gap: 8, |
||||
|
paddingVertical: 18, |
||||
|
borderRadius: 12, |
||||
|
borderWidth: 1, |
||||
|
borderStyle: 'dashed', |
||||
|
borderColor: colors.borderStrong, |
||||
|
backgroundColor: colors.white, |
||||
|
}, |
||||
|
uploadText: { fontSize: 13, color: colors.textHint }, |
||||
|
footer: { marginTop: 28 }, |
||||
|
}); |
||||
@ -0,0 +1,46 @@ |
|||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { YearMonthWheel } from '../ui/WheelPicker'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
|
||||
|
export function P10FirstWork() { |
||||
|
const { data, setData, navigate, back, meta } = useOnboarding(); |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} title="首次参加工作" showBack onBack={back}> |
||||
|
<View style={styles.iconWrap}> |
||||
|
<Ionicons name="calendar" size={34} color={colors.primary} /> |
||||
|
</View> |
||||
|
<Text style={styles.tip}>1 年以内经验</Text> |
||||
|
|
||||
|
<YearMonthWheel |
||||
|
year={data.firstWorkYear} |
||||
|
month={data.firstWorkMonth} |
||||
|
onYearChange={(v) => setData({ firstWorkYear: v })} |
||||
|
onMonthChange={(v) => setData({ firstWorkMonth: v })} |
||||
|
/> |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<PrimaryButton label="下一步" active onPress={() => navigate('P11')} /> |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
iconWrap: { |
||||
|
width: 64, |
||||
|
height: 64, |
||||
|
borderRadius: 18, |
||||
|
backgroundColor: colors.primaryLight, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
alignSelf: 'center', |
||||
|
marginBottom: 12, |
||||
|
}, |
||||
|
tip: { fontSize: 13, color: colors.textHint, textAlign: 'center', marginBottom: 18 }, |
||||
|
footer: { marginTop: 28 }, |
||||
|
}); |
||||
@ -0,0 +1,71 @@ |
|||||
|
import { useState } from 'react'; |
||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { SearchInput } from '../ui/SearchInput'; |
||||
|
import { Tag, TagGroup } from '../ui/Tag'; |
||||
|
|
||||
|
const CATS = ['互联网AI', '设计', '金融', '客服运营', '电商']; |
||||
|
const POS: Record<string, string[]> = { |
||||
|
互联网AI: ['全栈工程师', '鸿蒙开发工程师', '前端开发工程师', 'Java工程师', '测试工程师'], |
||||
|
设计: ['UI设计师', '视觉设计师'], |
||||
|
金融: ['风控专员', '财务分析'], |
||||
|
客服运营: ['客服专员', '用户运营'], |
||||
|
电商: ['电商运营', '内容运营'], |
||||
|
}; |
||||
|
const BLOCKS = ['客服', '内容运营', '电商运营', '用户运营']; |
||||
|
|
||||
|
export function P11CurrentPosition() { |
||||
|
const { data, setData, navigate, back, meta } = useOnboarding(); |
||||
|
const [cat, setCat] = useState('互联网AI'); |
||||
|
|
||||
|
const choose = (p: string) => { |
||||
|
setData({ currentPosition: p }); |
||||
|
navigate('P12'); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} title="当前工作职位" showBack onBack={back}> |
||||
|
<SearchInput value="" onChangeText={() => {}} placeholder="搜索职位" /> |
||||
|
|
||||
|
<View style={styles.body}> |
||||
|
<View style={styles.cats}> |
||||
|
{CATS.map((c) => ( |
||||
|
<Pressable key={c} style={[styles.catItem, c === cat && styles.catItemActive]} onPress={() => setCat(c)}> |
||||
|
<Text style={[styles.catText, c === cat && styles.catTextActive]}>{c}</Text> |
||||
|
</Pressable> |
||||
|
))} |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.col}> |
||||
|
<Text style={styles.colTitle}>猜你做过</Text> |
||||
|
<TagGroup> |
||||
|
{POS[cat].map((p) => ( |
||||
|
<Tag key={p} label={p} selected={data.currentPosition === p} onPress={() => choose(p)} /> |
||||
|
))} |
||||
|
</TagGroup> |
||||
|
</View> |
||||
|
</View> |
||||
|
|
||||
|
<Text style={styles.blockTitle}>或者从这些板块选</Text> |
||||
|
<TagGroup> |
||||
|
{BLOCKS.map((b) => ( |
||||
|
<Tag key={b} label={b} selected={data.currentPosition === b} onPress={() => choose(b)} /> |
||||
|
))} |
||||
|
</TagGroup> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
body: { flexDirection: 'row', gap: 14, marginTop: 8 }, |
||||
|
cats: { width: 92 }, |
||||
|
catItem: { paddingVertical: 12, borderRadius: 10 }, |
||||
|
catItemActive: { backgroundColor: colors.primaryLight }, |
||||
|
catText: { fontSize: 13, color: colors.textSecondary, textAlign: 'center' }, |
||||
|
catTextActive: { color: colors.primaryText, fontWeight: '700' }, |
||||
|
col: { flex: 1 }, |
||||
|
colTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginBottom: 12 }, |
||||
|
blockTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginVertical: 16 }, |
||||
|
}); |
||||
@ -0,0 +1,43 @@ |
|||||
|
import { StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { SearchInput } from '../ui/SearchInput'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
|
||||
|
export function P12Company() { |
||||
|
const { data, setData, navigate, back, meta } = useOnboarding(); |
||||
|
const canNext = data.company.trim().length > 0; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} title="就职公司" showBack onBack={back}> |
||||
|
<SearchInput |
||||
|
value={data.company} |
||||
|
onChangeText={(t) => setData({ company: t })} |
||||
|
placeholder="请输入公司名称" |
||||
|
onClear={() => setData({ company: '' })} |
||||
|
/> |
||||
|
|
||||
|
<View style={styles.notice}> |
||||
|
<Text style={styles.noticeText}> |
||||
|
将自动屏蔽该公司,该公司 Boss 无法看见你的信息 |
||||
|
</Text> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<PrimaryButton label="下一步" active={canNext} onPress={() => navigate('P13')} /> |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
notice: { |
||||
|
marginTop: 16, |
||||
|
padding: 12, |
||||
|
borderRadius: 10, |
||||
|
backgroundColor: colors.primaryLight, |
||||
|
}, |
||||
|
noticeText: { fontSize: 12, color: colors.primaryText, lineHeight: 18 }, |
||||
|
footer: { marginTop: 28 }, |
||||
|
}); |
||||
@ -0,0 +1,46 @@ |
|||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { YearMonthWheel } from '../ui/WheelPicker'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
|
||||
|
export function P13JoinDate() { |
||||
|
const { data, setData, navigate, back, meta } = useOnboarding(); |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} title="入职时间" showBack onBack={back}> |
||||
|
<View style={styles.iconWrap}> |
||||
|
<Ionicons name="calendar" size={34} color={colors.primary} /> |
||||
|
</View> |
||||
|
<Text style={styles.tip}>在什么时候入职 {data.company}</Text> |
||||
|
|
||||
|
<YearMonthWheel |
||||
|
year={data.joinYear} |
||||
|
month={data.joinMonth} |
||||
|
onYearChange={(v) => setData({ joinYear: v })} |
||||
|
onMonthChange={(v) => setData({ joinMonth: v })} |
||||
|
/> |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<PrimaryButton label="下一步" active onPress={() => navigate('P14')} /> |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
iconWrap: { |
||||
|
width: 64, |
||||
|
height: 64, |
||||
|
borderRadius: 18, |
||||
|
backgroundColor: colors.primaryLight, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
alignSelf: 'center', |
||||
|
marginBottom: 12, |
||||
|
}, |
||||
|
tip: { fontSize: 13, color: colors.textHint, textAlign: 'center', marginBottom: 18 }, |
||||
|
footer: { marginTop: 28 }, |
||||
|
}); |
||||
@ -0,0 +1,78 @@ |
|||||
|
import { StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { Tag, TagGroup } from '../ui/Tag'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
|
||||
|
const MAX = 5; |
||||
|
const GROUPS = [ |
||||
|
{ name: '运维工具', items: ['Kubernetes', 'Docker'] }, |
||||
|
{ name: '前端框架', items: ['Vue.js', 'React', 'Angular', 'AngularJS', 'Bootstrap', 'JQuery', 'Vue'] }, |
||||
|
{ name: '软件开发工具', items: ['SVN'] }, |
||||
|
{ |
||||
|
name: '技术类型', |
||||
|
items: ['多进程', '网络协议', 'AJAX', '数据结构', '消息队列', '多线程', '分布式技术', '微服务架构'], |
||||
|
}, |
||||
|
{ name: '后端框架', items: ['Spring', 'Django', 'Express', 'Gin'] }, |
||||
|
]; |
||||
|
|
||||
|
export function P14Skills() { |
||||
|
const { data, setData, navigate, back, meta } = useOnboarding(); |
||||
|
const skills = data.skills; |
||||
|
const full = skills.length >= MAX; |
||||
|
|
||||
|
const toggle = (s: string) => { |
||||
|
if (skills.includes(s)) { |
||||
|
setData({ skills: skills.filter((x) => x !== s) }); |
||||
|
} else if (!full) { |
||||
|
setData({ skills: [...skills, s] }); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} showBack onBack={back}> |
||||
|
<View style={styles.header}> |
||||
|
<Text style={styles.title}>技能选择</Text> |
||||
|
<View style={[styles.counter, skills.length > 0 && styles.counterActive]}> |
||||
|
<Text style={[styles.counterText, skills.length > 0 && styles.counterTextActive]}> |
||||
|
{skills.length}/{MAX} |
||||
|
</Text> |
||||
|
</View> |
||||
|
</View> |
||||
|
|
||||
|
{GROUPS.map((g) => ( |
||||
|
<View key={g.name} style={styles.group}> |
||||
|
<Text style={styles.groupName}>{g.name}</Text> |
||||
|
<TagGroup> |
||||
|
{g.items.map((s) => ( |
||||
|
<Tag |
||||
|
key={s} |
||||
|
label={s} |
||||
|
selected={skills.includes(s)} |
||||
|
disabled={!skills.includes(s) && full} |
||||
|
onPress={() => toggle(s)} |
||||
|
/> |
||||
|
))} |
||||
|
</TagGroup> |
||||
|
</View> |
||||
|
))} |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<PrimaryButton label="下一步" active={skills.length > 0} onPress={() => navigate('P16')} /> |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }, |
||||
|
title: { fontSize: 20, fontWeight: '800', color: colors.textPrimary }, |
||||
|
counter: { backgroundColor: colors.borderLight, paddingHorizontal: 12, paddingVertical: 4, borderRadius: 12 }, |
||||
|
counterActive: { backgroundColor: colors.primaryLight }, |
||||
|
counterText: { fontSize: 13, fontWeight: '700', color: colors.textDisabled }, |
||||
|
counterTextActive: { color: colors.primaryText }, |
||||
|
group: { marginBottom: 18 }, |
||||
|
groupName: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginBottom: 10 }, |
||||
|
footer: { marginTop: 20 }, |
||||
|
}); |
||||
@ -0,0 +1,64 @@ |
|||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding, useRegisterFinish } from '../../../src/hooks'; |
||||
|
import type { AppRole } from '../../../src/types'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
|
||||
|
export function P16WorkContent() { |
||||
|
const { data, setData, navigate, back, meta } = useOnboarding(); |
||||
|
const finish = useRegisterFinish(); |
||||
|
const targetRole: AppRole = data.identity === 'recruiter' ? 'enterprise' : 'candidate'; |
||||
|
const text = data.workContent; |
||||
|
const canNext = text.trim().length >= 5; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} showBack onBack={back}> |
||||
|
<View style={styles.header}> |
||||
|
<Text style={styles.title}>工作内容介绍</Text> |
||||
|
<Pressable onPress={() => finish(targetRole)}> |
||||
|
<Text style={styles.save}>保存</Text> |
||||
|
</Pressable> |
||||
|
</View> |
||||
|
|
||||
|
<Pressable style={styles.ref} onPress={() => navigate('P17')}> |
||||
|
<Ionicons name="eye-outline" size={18} color={colors.primary} /> |
||||
|
<Text style={styles.refText}>看看别人怎么写</Text> |
||||
|
</Pressable> |
||||
|
|
||||
|
<TextInput |
||||
|
style={styles.input} |
||||
|
value={text} |
||||
|
onChangeText={(t) => setData({ workContent: t })} |
||||
|
placeholder="至少输入 5 个字,描述你的主要工作内容" |
||||
|
placeholderTextColor={colors.textHint} |
||||
|
multiline |
||||
|
textAlignVertical="top" |
||||
|
/> |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<PrimaryButton label="下一步" active={canNext} onPress={() => navigate('P18')} /> |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }, |
||||
|
title: { fontSize: 20, fontWeight: '800', color: colors.textPrimary }, |
||||
|
save: { fontSize: 15, fontWeight: '700', color: colors.primary }, |
||||
|
ref: { flexDirection: 'row', alignItems: 'center', gap: 6, alignSelf: 'flex-start', marginBottom: 14 }, |
||||
|
refText: { fontSize: 14, color: colors.primary, fontWeight: '600' }, |
||||
|
input: { |
||||
|
minHeight: 200, |
||||
|
borderRadius: 12, |
||||
|
borderWidth: 1, |
||||
|
borderColor: colors.border, |
||||
|
backgroundColor: colors.white, |
||||
|
padding: 14, |
||||
|
fontSize: 15, |
||||
|
color: colors.textPrimary, |
||||
|
}, |
||||
|
footer: { marginTop: 24 }, |
||||
|
}); |
||||
@ -0,0 +1,91 @@ |
|||||
|
import { useState } from 'react'; |
||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { Avatar } from '../ui/Avatar'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
|
||||
|
const SAMPLES = [ |
||||
|
{ |
||||
|
name: '资深 UI 设计师', |
||||
|
items: [ |
||||
|
'1、负责移动端 Android / iOS 的 UI 设计', |
||||
|
'2、制定 App 界面实现标准', |
||||
|
'3、负责版本迭代与用户体验优化', |
||||
|
], |
||||
|
}, |
||||
|
{ |
||||
|
name: '高级前端工程师', |
||||
|
items: [ |
||||
|
'1、负责核心业务前端架构设计', |
||||
|
'2、推动组件化与工程化落地', |
||||
|
'3、主导性能优化与疑难问题攻关', |
||||
|
], |
||||
|
}, |
||||
|
{ |
||||
|
name: '产品经理', |
||||
|
items: [ |
||||
|
'1、负责求职模块需求规划与原型设计', |
||||
|
'2、推进版本迭代与数据分析', |
||||
|
'3、协调研发、设计、运营达成目标', |
||||
|
], |
||||
|
}, |
||||
|
]; |
||||
|
|
||||
|
export function P17Reference() { |
||||
|
const { setData, back, navigate, meta } = useOnboarding(); |
||||
|
const [idx, setIdx] = useState(0); |
||||
|
const sample = SAMPLES[idx]; |
||||
|
|
||||
|
const useIt = () => { |
||||
|
setData({ workContent: sample.items.join('\n') }); |
||||
|
navigate('P16'); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} title="参考案例" showBack onBack={back}> |
||||
|
<View style={styles.head}> |
||||
|
<Avatar name="avatarDesigner" size={56} /> |
||||
|
<View style={styles.headText}> |
||||
|
<Text style={styles.name}>{sample.name}</Text> |
||||
|
<Pressable style={styles.swap} onPress={() => setIdx((i) => (i + 1) % SAMPLES.length)}> |
||||
|
<Ionicons name="refresh" size={14} color={colors.primary} /> |
||||
|
<Text style={styles.swapText}>换一个</Text> |
||||
|
</Pressable> |
||||
|
</View> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.card}> |
||||
|
{sample.items.map((it) => ( |
||||
|
<Text key={it} style={styles.item}> |
||||
|
{it} |
||||
|
</Text> |
||||
|
))} |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<PrimaryButton label="采用并填写" active onPress={useIt} /> |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
head: { flexDirection: 'row', alignItems: 'center', gap: 14, marginBottom: 18 }, |
||||
|
headText: { gap: 8 }, |
||||
|
name: { fontSize: 16, fontWeight: '700', color: colors.textPrimary }, |
||||
|
swap: { flexDirection: 'row', alignItems: 'center', gap: 4 }, |
||||
|
swapText: { fontSize: 13, color: colors.primary, fontWeight: '600' }, |
||||
|
card: { |
||||
|
backgroundColor: colors.white, |
||||
|
borderRadius: 14, |
||||
|
padding: 18, |
||||
|
gap: 12, |
||||
|
borderWidth: 1, |
||||
|
borderColor: colors.border, |
||||
|
}, |
||||
|
item: { fontSize: 14, color: colors.textSecondary, lineHeight: 22 }, |
||||
|
footer: { marginTop: 24 }, |
||||
|
}); |
||||
@ -0,0 +1,55 @@ |
|||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { RadioRow } from '../ui/RadioRow'; |
||||
|
import { Tag, TagGroup } from '../ui/Tag'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
|
||||
|
const LEVELS = ['初中及以下', '中专中技', '高中', '大专', '本科', '硕士', '博士']; |
||||
|
|
||||
|
export function P18Education() { |
||||
|
const { data, setData, navigate, back, meta } = useOnboarding(); |
||||
|
const canNext = data.education !== null; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} title="最高学历" showBack onBack={back}> |
||||
|
<View style={styles.iconWrap}> |
||||
|
<Ionicons name="document" size={34} color={colors.primary} /> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.list}> |
||||
|
{LEVELS.map((l) => ( |
||||
|
<RadioRow key={l} label={l} selected={data.education === l} onPress={() => setData({ education: l })} /> |
||||
|
))} |
||||
|
</View> |
||||
|
|
||||
|
<Text style={styles.subTitle}>类型</Text> |
||||
|
<TagGroup> |
||||
|
<Tag label="全日制" selected={data.eduType === '全日制'} onPress={() => setData({ eduType: '全日制' })} /> |
||||
|
<Tag label="非全日制" selected={data.eduType === '非全日制'} onPress={() => setData({ eduType: '非全日制' })} /> |
||||
|
</TagGroup> |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<PrimaryButton label="下一步" active={canNext} onPress={() => navigate('P20')} /> |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
iconWrap: { |
||||
|
width: 64, |
||||
|
height: 64, |
||||
|
borderRadius: 18, |
||||
|
backgroundColor: colors.primaryLight, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
alignSelf: 'center', |
||||
|
marginBottom: 18, |
||||
|
}, |
||||
|
list: { gap: 10 }, |
||||
|
subTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, marginTop: 22, marginBottom: 12 }, |
||||
|
footer: { marginTop: 28 }, |
||||
|
}); |
||||
@ -0,0 +1,143 @@ |
|||||
|
import { 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 { LinearGradient } from 'expo-linear-gradient'; |
||||
|
import { SafeAreaView } from 'react-native-safe-area-context'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { Illustration } from '../ui/Illustration'; |
||||
|
import { AgreementCheckbox } from '../ui/AgreementCheckbox'; |
||||
|
import { AgreementModal } from '../ui/AgreementModal'; |
||||
|
|
||||
|
/** P1 登录主页:对齐参考样式 —— 淡渐变顶部 + 居中 Logo + 微信绿实心/灰底一键登录 + 协议弹窗拦截 */ |
||||
|
export function P1Login() { |
||||
|
const { data, setData, navigate } = useOnboarding(); |
||||
|
const agreed = data.agreed; |
||||
|
/** 未勾选协议时被拦截的动作,弹窗「同意」后继续执行 */ |
||||
|
const [pending, setPending] = useState<(() => void) | null>(null); |
||||
|
|
||||
|
/** 协议门卫:已勾选直接执行;未勾选弹协议弹窗 */ |
||||
|
const gate = (action: () => void) => { |
||||
|
if (agreed) { |
||||
|
action(); |
||||
|
} else { |
||||
|
setPending(() => action); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const accept = () => { |
||||
|
setData({ agreed: true }); |
||||
|
const act = pending; |
||||
|
setPending(null); |
||||
|
act?.(); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<SafeAreaView style={styles.screen} edges={['top', 'bottom']}> |
||||
|
<StatusBar style="dark" /> |
||||
|
<LinearGradient colors={[colors.primaryLight, colors.pageBg]} style={styles.topGlow} /> |
||||
|
|
||||
|
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false} bounces={false}> |
||||
|
<View style={styles.brandArea}> |
||||
|
<Illustration name="logo" size={96} radius={24} /> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.flexSpacer} /> |
||||
|
|
||||
|
{/* 登录方式 */} |
||||
|
<Pressable style={styles.wechatBtn} onPress={() => gate(() => navigate('B1'))}> |
||||
|
<Ionicons name="logo-wechat" size={20} color={colors.textInverse} /> |
||||
|
<Text style={styles.wechatText}>微信登录</Text> |
||||
|
</Pressable> |
||||
|
|
||||
|
<Pressable style={styles.quickBtn} onPress={() => gate(() => navigate('P2Q'))}> |
||||
|
<Text style={styles.quickText}>一键登录</Text> |
||||
|
</Pressable> |
||||
|
|
||||
|
<Pressable onPress={() => gate(() => navigate('P2'))} hitSlop={8}> |
||||
|
<Text style={styles.otherLink}>其他方式登录</Text> |
||||
|
</Pressable> |
||||
|
|
||||
|
{/* 协议勾选 */} |
||||
|
<View style={styles.agree}> |
||||
|
<AgreementCheckbox |
||||
|
checked={agreed} |
||||
|
onToggle={() => setData({ agreed: !agreed })} |
||||
|
text={ |
||||
|
<Text style={styles.agreeText}> |
||||
|
已阅读并同意 |
||||
|
<Text style={styles.link} onPress={() => navigate('B2')}>《有职用户协议》</Text> |
||||
|
和 |
||||
|
<Text style={styles.link} onPress={() => navigate('B2')}>《隐私政策》</Text> |
||||
|
,允许有职统一登录本账号信息 |
||||
|
</Text> |
||||
|
} |
||||
|
/> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.flexSpacer} /> |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<Text style={styles.footItem}>服务热线</Text> |
||||
|
<Text style={styles.footItem}>举报监督电话</Text> |
||||
|
<Text style={styles.footItem}>资质证照</Text> |
||||
|
</View> |
||||
|
</ScrollView> |
||||
|
|
||||
|
<AgreementModal |
||||
|
visible={pending !== null} |
||||
|
onAccept={accept} |
||||
|
onDecline={() => setPending(null)} |
||||
|
onOpenAgreement={() => { |
||||
|
setPending(null); |
||||
|
navigate('B2'); |
||||
|
}} |
||||
|
/> |
||||
|
</SafeAreaView> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
screen: { flex: 1, backgroundColor: colors.pageBg }, |
||||
|
topGlow: { position: 'absolute', top: 0, left: 0, right: 0, height: 200 }, |
||||
|
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, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
marginTop: 14, |
||||
|
backgroundColor: colors.borderLight, |
||||
|
}, |
||||
|
quickText: { fontSize: 16, fontWeight: '700', color: colors.textPrimary }, |
||||
|
otherLink: { |
||||
|
textAlign: 'center', |
||||
|
marginTop: 20, |
||||
|
fontSize: 14, |
||||
|
color: colors.textSecondary, |
||||
|
}, |
||||
|
agree: { marginTop: 22 }, |
||||
|
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 }, |
||||
|
link: { color: colors.primary, fontWeight: '600' }, |
||||
|
footer: { |
||||
|
flexDirection: 'row', |
||||
|
justifyContent: 'center', |
||||
|
gap: 18, |
||||
|
paddingTop: 24, |
||||
|
paddingBottom: 8, |
||||
|
}, |
||||
|
footItem: { fontSize: 12, color: colors.textHint }, |
||||
|
}); |
||||
@ -0,0 +1,29 @@ |
|||||
|
import { StyleSheet, View } from 'react-native'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { SearchInput } from '../ui/SearchInput'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
|
||||
|
export function P20School() { |
||||
|
const { data, setData, navigate, back, meta } = useOnboarding(); |
||||
|
const canNext = data.school.trim().length > 0; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} title="毕业学校" showBack onBack={back}> |
||||
|
<SearchInput |
||||
|
value={data.school} |
||||
|
onChangeText={(t) => setData({ school: t })} |
||||
|
placeholder="请输入毕业学校名称" |
||||
|
onClear={() => setData({ school: '' })} |
||||
|
/> |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<PrimaryButton label="下一步" active={canNext} onPress={() => navigate('P21')} /> |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
footer: { marginTop: 28 }, |
||||
|
}); |
||||
@ -0,0 +1,77 @@ |
|||||
|
import { useState } from 'react'; |
||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { Avatar } from '../ui/Avatar'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
|
||||
|
const PRESETS = [0, 1, 2, 3, 4]; |
||||
|
|
||||
|
export function P21Avatar() { |
||||
|
const { setData, navigate, back, meta } = useOnboarding(); |
||||
|
const [mode, setMode] = useState<'none' | 'camera' | 'album' | 'preset'>('none'); |
||||
|
const [picked, setPicked] = useState(-1); |
||||
|
|
||||
|
const chooseCamera = () => { |
||||
|
setMode('camera'); |
||||
|
setData({ avatar: 'avatarDesigner' }); |
||||
|
}; |
||||
|
const chooseAlbum = () => { |
||||
|
setMode('album'); |
||||
|
setData({ avatar: 'avatarDesigner' }); |
||||
|
}; |
||||
|
const choosePreset = (i: number) => { |
||||
|
setMode('preset'); |
||||
|
setPicked(i); |
||||
|
setData({ avatar: 'defaultAvatar' }); |
||||
|
}; |
||||
|
|
||||
|
const previewName = mode === 'camera' || mode === 'album' ? 'avatarDesigner' : 'defaultAvatar'; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} title="头像设置" showBack onBack={back}> |
||||
|
<View style={styles.previewWrap}> |
||||
|
<Avatar name={previewName} size={110} selected={mode !== 'none'} /> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.actions}> |
||||
|
<Pressable style={styles.actionBtn} onPress={chooseCamera}> |
||||
|
<Ionicons name="camera" size={22} color={colors.primary} /> |
||||
|
<Text style={styles.actionText}>拍照上传</Text> |
||||
|
</Pressable> |
||||
|
<Pressable style={styles.actionBtn} onPress={chooseAlbum}> |
||||
|
<Ionicons name="images" size={22} color={colors.primary} /> |
||||
|
<Text style={styles.actionText}>从相册选择</Text> |
||||
|
</Pressable> |
||||
|
</View> |
||||
|
|
||||
|
<Text style={styles.subTitle}>系统默认头像</Text> |
||||
|
<View style={styles.presets}> |
||||
|
{PRESETS.map((i) => ( |
||||
|
<Avatar |
||||
|
key={i} |
||||
|
size={56} |
||||
|
selected={mode === 'preset' && picked === i} |
||||
|
onPress={() => choosePreset(i)} |
||||
|
/> |
||||
|
))} |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<PrimaryButton label="下一步" active={mode !== 'none'} onPress={() => navigate('P22')} /> |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
previewWrap: { alignItems: 'center', marginVertical: 24 }, |
||||
|
actions: { flexDirection: 'row', justifyContent: 'center', gap: 28, marginBottom: 28 }, |
||||
|
actionBtn: { alignItems: 'center', gap: 6 }, |
||||
|
actionText: { fontSize: 12, color: colors.textSecondary }, |
||||
|
subTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginBottom: 14 }, |
||||
|
presets: { flexDirection: 'row', justifyContent: 'space-between', gap: 10 }, |
||||
|
footer: { marginTop: 32 }, |
||||
|
}); |
||||
@ -0,0 +1,44 @@ |
|||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding, useRegisterFinish } from '../../../src/hooks'; |
||||
|
import type { AppRole } from '../../../src/types'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { Illustration } from '../ui/Illustration'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
|
||||
|
export function P22Done() { |
||||
|
const { data, meta } = useOnboarding(); |
||||
|
const finish = useRegisterFinish(); |
||||
|
const targetRole: AppRole = data.identity === 'recruiter' ? 'enterprise' : 'candidate'; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery}> |
||||
|
<View style={styles.center}> |
||||
|
<View style={styles.glow}> |
||||
|
<Ionicons name="checkmark-circle" size={72} color={colors.success} /> |
||||
|
</View> |
||||
|
<Text style={styles.title}>资料完善,开启求职之旅</Text> |
||||
|
<Illustration name="illustCelebrate" size={160} radius={20} /> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<PrimaryButton label="进入 APP" active onPress={() => finish(targetRole)} /> |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 22, paddingBottom: 40 }, |
||||
|
glow: { |
||||
|
width: 120, |
||||
|
height: 120, |
||||
|
borderRadius: 60, |
||||
|
backgroundColor: colors.successLight, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
}, |
||||
|
title: { fontSize: 20, fontWeight: '800', color: colors.textPrimary }, |
||||
|
footer: { marginTop: 20 }, |
||||
|
}); |
||||
@ -0,0 +1,181 @@ |
|||||
|
import { useEffect, useState } from 'react'; |
||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { SafeAreaView } from 'react-native-safe-area-context'; |
||||
|
import { StatusBar } from 'expo-status-bar'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { Illustration } from '../ui/Illustration'; |
||||
|
import { Modal } from '../ui/Modal'; |
||||
|
|
||||
|
type Step = 'guide' | 'location' | 'notify' | null; |
||||
|
|
||||
|
const JOBS = [ |
||||
|
{ company: '巴适伙伴', pos: '前端开发工程师', salary: '12-18K', city: '上海' }, |
||||
|
{ company: '星河科技', pos: '鸿蒙开发工程师', salary: '15-22K', city: '上海' }, |
||||
|
{ company: '云图网络', pos: '全栈工程师', salary: '13-20K', city: '杭州' }, |
||||
|
{ company: '启明软件', pos: '数据分析师', salary: '10-16K', city: '深圳' }, |
||||
|
]; |
||||
|
|
||||
|
const TABS = [ |
||||
|
{ key: 'home', icon: 'home', label: '首页' }, |
||||
|
{ key: 'msg', icon: 'chatbubble', label: '消息' }, |
||||
|
{ key: 'me', icon: 'person', label: '我的' }, |
||||
|
] as const; |
||||
|
|
||||
|
export function P23Home() { |
||||
|
const [tab, setTab] = useState<(typeof TABS)[number]['key']>('home'); |
||||
|
const [step, setStep] = useState<Step>('guide'); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
setStep('guide'); |
||||
|
}, []); |
||||
|
|
||||
|
return ( |
||||
|
<SafeAreaView style={styles.screen} edges={['top']}> |
||||
|
<StatusBar style="dark" /> |
||||
|
|
||||
|
<View style={styles.topBar}> |
||||
|
<Text style={styles.topTitle}>推荐职位</Text> |
||||
|
<Ionicons name="location" size={16} color={colors.primary} /> |
||||
|
<Text style={styles.topCity}>上海</Text> |
||||
|
</View> |
||||
|
|
||||
|
<ScrollView style={styles.list} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false}> |
||||
|
{JOBS.map((j) => ( |
||||
|
<View key={j.company} style={styles.card}> |
||||
|
<Illustration name="logo" size={44} radius={10} /> |
||||
|
<View style={styles.cardBody}> |
||||
|
<Text style={styles.pos}>{j.pos}</Text> |
||||
|
<Text style={styles.company}>{j.company}</Text> |
||||
|
</View> |
||||
|
<View style={styles.cardRight}> |
||||
|
<Text style={styles.salary}>{j.salary}</Text> |
||||
|
<View style={styles.cityTag}> |
||||
|
<Text style={styles.cityText}>{j.city}</Text> |
||||
|
</View> |
||||
|
</View> |
||||
|
</View> |
||||
|
))} |
||||
|
</ScrollView> |
||||
|
|
||||
|
<View style={styles.tabBar}> |
||||
|
{TABS.map((t) => ( |
||||
|
<Pressable key={t.key} style={styles.tab} onPress={() => setTab(t.key)}> |
||||
|
<Ionicons name={t.icon} size={22} color={tab === t.key ? colors.primary : colors.textHint} /> |
||||
|
<Text style={[styles.tabLabel, tab === t.key && styles.tabLabelActive]}>{t.label}</Text> |
||||
|
</Pressable> |
||||
|
))} |
||||
|
</View> |
||||
|
|
||||
|
{step === 'guide' && ( |
||||
|
<View style={styles.guide}> |
||||
|
<View style={styles.guideCard}> |
||||
|
<Ionicons name="arrow-down" size={20} color={colors.primary} /> |
||||
|
<Text style={styles.guideText}>点击查看职位详情</Text> |
||||
|
</View> |
||||
|
<Pressable style={styles.guideBtn} onPress={() => setStep('location')}> |
||||
|
<Text style={styles.guideBtnText}>知道了</Text> |
||||
|
</Pressable> |
||||
|
</View> |
||||
|
)} |
||||
|
|
||||
|
<Modal |
||||
|
visible={step === 'location'} |
||||
|
title="允许获取位置" |
||||
|
onClose={() => setStep('notify')} |
||||
|
footer={ |
||||
|
<View style={styles.modalBtns}> |
||||
|
<Pressable style={[styles.modalBtn, styles.modalBtnGhost]} onPress={() => setStep('notify')}> |
||||
|
<Text style={styles.modalBtnGhostText}>暂不</Text> |
||||
|
</Pressable> |
||||
|
<Pressable style={[styles.modalBtn, styles.modalBtnPrimary]} onPress={() => setStep('notify')}> |
||||
|
<Text style={styles.modalBtnPrimaryText}>允许</Text> |
||||
|
</Pressable> |
||||
|
</View> |
||||
|
} |
||||
|
> |
||||
|
<Text style={styles.modalBody}>开启定位可为你推荐附近的职位与机会。</Text> |
||||
|
</Modal> |
||||
|
|
||||
|
<Modal |
||||
|
visible={step === 'notify'} |
||||
|
title="开启通知" |
||||
|
onClose={() => setStep(null)} |
||||
|
footer={ |
||||
|
<View style={styles.modalBtns}> |
||||
|
<Pressable style={[styles.modalBtn, styles.modalBtnGhost]} onPress={() => setStep(null)}> |
||||
|
<Text style={styles.modalBtnGhostText}>暂不</Text> |
||||
|
</Pressable> |
||||
|
<Pressable style={[styles.modalBtn, styles.modalBtnPrimary]} onPress={() => setStep(null)}> |
||||
|
<Text style={styles.modalBtnPrimaryText}>允许</Text> |
||||
|
</Pressable> |
||||
|
</View> |
||||
|
} |
||||
|
> |
||||
|
<Text style={styles.modalBody}>开启通知可及时接收面试邀请与 Boss 消息。</Text> |
||||
|
</Modal> |
||||
|
</SafeAreaView> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
screen: { flex: 1, backgroundColor: colors.pageBg }, |
||||
|
topBar: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 20, paddingVertical: 12, backgroundColor: colors.white }, |
||||
|
topTitle: { fontSize: 18, fontWeight: '800', color: colors.textPrimary, marginRight: 'auto' }, |
||||
|
topCity: { fontSize: 13, color: colors.primary, fontWeight: '600' }, |
||||
|
list: { flex: 1 }, |
||||
|
listContent: { padding: 16, gap: 12 }, |
||||
|
card: { |
||||
|
flexDirection: 'row', |
||||
|
alignItems: 'center', |
||||
|
gap: 12, |
||||
|
backgroundColor: colors.white, |
||||
|
borderRadius: 14, |
||||
|
padding: 14, |
||||
|
borderWidth: 1, |
||||
|
borderColor: colors.border, |
||||
|
}, |
||||
|
cardBody: { flex: 1, gap: 4 }, |
||||
|
pos: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, |
||||
|
company: { fontSize: 12, color: colors.textSecondary }, |
||||
|
cardRight: { alignItems: 'flex-end', gap: 6 }, |
||||
|
salary: { fontSize: 15, fontWeight: '700', color: colors.info }, |
||||
|
cityTag: { backgroundColor: colors.primaryLight, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 8 }, |
||||
|
cityText: { fontSize: 11, color: colors.primaryText }, |
||||
|
tabBar: { |
||||
|
flexDirection: 'row', |
||||
|
borderTopWidth: 1, |
||||
|
borderColor: colors.borderLight, |
||||
|
backgroundColor: colors.white, |
||||
|
paddingVertical: 8, |
||||
|
}, |
||||
|
tab: { flex: 1, alignItems: 'center', gap: 2 }, |
||||
|
tabLabel: { fontSize: 11, color: colors.textHint }, |
||||
|
tabLabelActive: { color: colors.primary, fontWeight: '700' }, |
||||
|
guide: { |
||||
|
...StyleSheet.absoluteFill, |
||||
|
backgroundColor: colors.scrim, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'flex-start', |
||||
|
paddingTop: 150, |
||||
|
}, |
||||
|
guideCard: { |
||||
|
flexDirection: 'row', |
||||
|
alignItems: 'center', |
||||
|
gap: 6, |
||||
|
backgroundColor: colors.white, |
||||
|
paddingHorizontal: 16, |
||||
|
paddingVertical: 10, |
||||
|
borderRadius: 12, |
||||
|
}, |
||||
|
guideText: { fontSize: 14, fontWeight: '600', color: colors.textPrimary }, |
||||
|
guideBtn: { marginTop: 16, backgroundColor: colors.primary, paddingHorizontal: 28, paddingVertical: 10, borderRadius: 20 }, |
||||
|
guideBtnText: { color: colors.textInverse, fontWeight: '700' }, |
||||
|
modalBtns: { flexDirection: 'row', gap: 12 }, |
||||
|
modalBtn: { flex: 1, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center' }, |
||||
|
modalBtnGhost: { backgroundColor: colors.borderLight }, |
||||
|
modalBtnGhostText: { color: colors.textSecondary, fontWeight: '600' }, |
||||
|
modalBtnPrimary: { backgroundColor: colors.primary }, |
||||
|
modalBtnPrimaryText: { color: colors.textInverse, fontWeight: '700' }, |
||||
|
modalBody: { fontSize: 14, color: colors.textSecondary, lineHeight: 22, textAlign: 'center' }, |
||||
|
}); |
||||
@ -0,0 +1,212 @@ |
|||||
|
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 } from '../../../src/hooks'; |
||||
|
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'; |
||||
|
|
||||
|
const PHONE_RE = /^1\d{10}$/; |
||||
|
|
||||
|
/** 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 agreed = data.agreed; |
||||
|
const phone = data.phone; |
||||
|
const phoneValid = PHONE_RE.test(phone); |
||||
|
|
||||
|
const gate = (action: () => void) => { |
||||
|
if (agreed) { |
||||
|
action(); |
||||
|
} else { |
||||
|
setPending(() => action); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const accept = () => { |
||||
|
setData({ agreed: true }); |
||||
|
const act = pending; |
||||
|
setPending(null); |
||||
|
act?.(); |
||||
|
}; |
||||
|
|
||||
|
/** 阶段1 → 阶段2:发送验证码 */ |
||||
|
const sendCode = () => { |
||||
|
if (!phoneValid) return; |
||||
|
setCodeSent(true); |
||||
|
}; |
||||
|
|
||||
|
const handleLogin = () => { |
||||
|
if (code.length < 4) return; |
||||
|
setData({ phone, code }); |
||||
|
navigate('P3'); |
||||
|
}; |
||||
|
|
||||
|
/** 掩码手机号:138****1234 */ |
||||
|
const maskedPhone = phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : phone; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell showBack onBack={back}> |
||||
|
{!codeSent ? ( |
||||
|
/* ---------- 阶段1:手机号 ---------- */ |
||||
|
<View> |
||||
|
<Text style={styles.h1}>登录/注册</Text> |
||||
|
<Text style={styles.subtitle}>首次验证通过即注册有职账号</Text> |
||||
|
|
||||
|
<View style={styles.phoneRow}> |
||||
|
<Text style={styles.prefix}>+86</Text> |
||||
|
<Ionicons name="caret-down" size={14} color={colors.textSecondary} /> |
||||
|
<View style={styles.divider} /> |
||||
|
<TextInput |
||||
|
style={styles.phoneInput} |
||||
|
value={phone} |
||||
|
onChangeText={(t) => setData({ phone: t.replace(/[^\d]/g, '').slice(0, 11) })} |
||||
|
placeholder="请输入手机号" |
||||
|
placeholderTextColor={colors.textHint} |
||||
|
keyboardType="phone-pad" |
||||
|
maxLength={11} |
||||
|
/> |
||||
|
{phone.length > 0 ? ( |
||||
|
<Pressable onPress={() => setData({ phone: '' })} hitSlop={8}> |
||||
|
<Ionicons name="close-circle" size={18} color={colors.textDisabled} /> |
||||
|
</Pressable> |
||||
|
) : null} |
||||
|
</View> |
||||
|
<View style={styles.underline} /> |
||||
|
|
||||
|
<Text style={styles.smsHelp}>接收不到短信</Text> |
||||
|
|
||||
|
<View style={styles.btnArea}> |
||||
|
<PrimaryButton label="下一步" active={phoneValid} onPress={() => gate(sendCode)} /> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.agree}> |
||||
|
<AgreementCheckbox |
||||
|
checked={agreed} |
||||
|
onToggle={() => setData({ agreed: !agreed })} |
||||
|
text={ |
||||
|
<Text style={styles.agreeText}> |
||||
|
已阅读并同意 |
||||
|
<Text style={styles.link} onPress={() => navigate('B2')}>《有职用户协议》</Text> |
||||
|
和 |
||||
|
<Text style={styles.link} onPress={() => navigate('B2')}>《隐私政策》</Text> |
||||
|
,允许有职统一管理本人账号信息 |
||||
|
</Text> |
||||
|
} |
||||
|
/> |
||||
|
</View> |
||||
|
|
||||
|
{/* 分隔线 + 其他登录方式 */} |
||||
|
<View style={styles.orRow}> |
||||
|
<View style={styles.orLine} /> |
||||
|
<Text style={styles.orText}>或通过以下方式登录</Text> |
||||
|
<View style={styles.orLine} /> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.socialRow}> |
||||
|
<Pressable style={[styles.socialCircle, { backgroundColor: colors.success }]} onPress={() => gate(() => navigate('B1'))}> |
||||
|
<Ionicons name="logo-wechat" size={26} color={colors.textInverse} /> |
||||
|
</Pressable> |
||||
|
<Pressable style={[styles.socialCircle, { backgroundColor: colors.primary }]} onPress={() => gate(sendCode)}> |
||||
|
<Ionicons name="chatbubble-ellipses" size={24} color={colors.textInverse} /> |
||||
|
</Pressable> |
||||
|
<Pressable style={[styles.socialCircle, { backgroundColor: colors.dangerBtn }]} onPress={() => gate(sendCode)}> |
||||
|
<Text style={styles.socialIdText}>{'数字\n身份'}</Text> |
||||
|
</Pressable> |
||||
|
</View> |
||||
|
</View> |
||||
|
) : ( |
||||
|
/* ---------- 阶段2:验证码 ---------- */ |
||||
|
<View> |
||||
|
<Text style={styles.h1}>输入验证码</Text> |
||||
|
<Text style={styles.subtitle}>验证码已发送至 {maskedPhone}</Text> |
||||
|
|
||||
|
<View style={styles.phoneRow}> |
||||
|
<TextInput |
||||
|
style={styles.phoneInput} |
||||
|
value={code} |
||||
|
onChangeText={(t) => setCode(t.replace(/[^\d]/g, '').slice(0, 6))} |
||||
|
placeholder="请输入验证码" |
||||
|
placeholderTextColor={colors.textHint} |
||||
|
keyboardType="number-pad" |
||||
|
maxLength={6} |
||||
|
/> |
||||
|
<CountdownButton disabled={!phoneValid} onSend={() => setCodeSent(true)} /> |
||||
|
</View> |
||||
|
<View style={styles.underline} /> |
||||
|
|
||||
|
<View style={styles.btnArea}> |
||||
|
<PrimaryButton label="登录" active={code.length >= 4} onPress={() => gate(handleLogin)} /> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.agree}> |
||||
|
<AgreementCheckbox |
||||
|
checked={agreed} |
||||
|
onToggle={() => setData({ agreed: !agreed })} |
||||
|
text={ |
||||
|
<Text style={styles.agreeText}> |
||||
|
已阅读并同意 |
||||
|
<Text style={styles.link} onPress={() => navigate('B2')}>《有职用户协议》</Text> |
||||
|
和 |
||||
|
<Text style={styles.link} onPress={() => navigate('B2')}>《隐私政策》</Text> |
||||
|
,允许有职统一管理本人账号信息 |
||||
|
</Text> |
||||
|
} |
||||
|
/> |
||||
|
</View> |
||||
|
</View> |
||||
|
)} |
||||
|
|
||||
|
<AgreementModal |
||||
|
visible={pending !== null} |
||||
|
onAccept={accept} |
||||
|
onDecline={() => setPending(null)} |
||||
|
onOpenAgreement={() => { |
||||
|
setPending(null); |
||||
|
navigate('B2'); |
||||
|
}} |
||||
|
/> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
h1: { fontSize: 26, fontWeight: '800', color: colors.textPrimary, marginTop: 12 }, |
||||
|
subtitle: { fontSize: 14, color: colors.textSecondary, marginTop: 10 }, |
||||
|
phoneRow: { |
||||
|
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 }, |
||||
|
phoneInput: { flex: 1, fontSize: 17, color: colors.textPrimary, padding: 0 }, |
||||
|
underline: { height: 1, backgroundColor: colors.borderLight }, |
||||
|
smsHelp: { fontSize: 13, color: colors.textHint, marginTop: 16 }, |
||||
|
btnArea: { marginTop: 28 }, |
||||
|
agree: { marginTop: 18 }, |
||||
|
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 }, |
||||
|
link: { color: colors.primary, fontWeight: '600' }, |
||||
|
orRow: { flexDirection: 'row', alignItems: 'center', marginTop: 40, 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 }, |
||||
|
}); |
||||
@ -0,0 +1,111 @@ |
|||||
|
import { useState } from 'react'; |
||||
|
import { ScrollView, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { Illustration } from '../ui/Illustration'; |
||||
|
import { AgreementCheckbox } from '../ui/AgreementCheckbox'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
import { AgreementModal } from '../ui/AgreementModal'; |
||||
|
|
||||
|
/** P2Q 一键登录确认:本机号码认证 —— 掩码手机号 + 一键登录 + 运营商服务声明 */ |
||||
|
export function P2QuickLogin() { |
||||
|
const { data, setData, navigate, back } = useOnboarding(); |
||||
|
const [pending, setPending] = useState<(() => void) | null>(null); |
||||
|
|
||||
|
const agreed = data.agreed; |
||||
|
const phone = data.phone; |
||||
|
|
||||
|
const gate = (action: () => void) => { |
||||
|
if (agreed) { |
||||
|
action(); |
||||
|
} else { |
||||
|
setPending(() => action); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const accept = () => { |
||||
|
setData({ agreed: true }); |
||||
|
const act = pending; |
||||
|
setPending(null); |
||||
|
act?.(); |
||||
|
}; |
||||
|
|
||||
|
/** 掩码:150****9367;未输入时用演示号段占位 */ |
||||
|
const masked = phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : '150****9367'; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell showBack onBack={back}> |
||||
|
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}> |
||||
|
<View style={styles.brandArea}> |
||||
|
<Illustration name="logo" size={88} radius={22} /> |
||||
|
</View> |
||||
|
|
||||
|
<Text style={styles.phone}>{masked}</Text> |
||||
|
<Text style={styles.subtitle}>首次验证通过即注册有职账号</Text> |
||||
|
|
||||
|
<View style={styles.btnArea}> |
||||
|
<PrimaryButton label="一键登录" active onPress={() => gate(() => navigate('P3'))} /> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.agree}> |
||||
|
<AgreementCheckbox |
||||
|
checked={agreed} |
||||
|
onToggle={() => setData({ agreed: !agreed })} |
||||
|
text={ |
||||
|
<Text style={styles.agreeText}> |
||||
|
已阅读并同意 |
||||
|
<Text style={styles.link}>《中国移动认证服务条款》</Text> |
||||
|
、 |
||||
|
<Text style={styles.link} onPress={() => navigate('B2')}>《有职用户协议》</Text> |
||||
|
和 |
||||
|
<Text style={styles.link} onPress={() => navigate('B2')}>《隐私政策》</Text> |
||||
|
,允许有职统一管理本人账号信息 |
||||
|
</Text> |
||||
|
} |
||||
|
/> |
||||
|
</View> |
||||
|
</ScrollView> |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<View style={styles.footRow}> |
||||
|
<Text style={styles.footItem}>服务热线</Text> |
||||
|
<Text style={styles.footItem}>举报监督电话</Text> |
||||
|
<Text style={styles.footItem}>资质证照</Text> |
||||
|
</View> |
||||
|
<Text style={styles.carrierNote}>认证服务由中国移动提供</Text> |
||||
|
</View> |
||||
|
|
||||
|
<AgreementModal |
||||
|
visible={pending !== null} |
||||
|
onAccept={accept} |
||||
|
onDecline={() => setPending(null)} |
||||
|
onOpenAgreement={() => { |
||||
|
setPending(null); |
||||
|
navigate('B2'); |
||||
|
}} |
||||
|
/> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
content: { flexGrow: 1 }, |
||||
|
brandArea: { alignItems: 'center', marginTop: '16%' }, |
||||
|
phone: { |
||||
|
textAlign: 'center', |
||||
|
fontSize: 30, |
||||
|
fontWeight: '800', |
||||
|
color: colors.textPrimary, |
||||
|
marginTop: 40, |
||||
|
}, |
||||
|
subtitle: { textAlign: 'center', fontSize: 14, color: colors.textSecondary, marginTop: 12 }, |
||||
|
btnArea: { marginTop: 36 }, |
||||
|
agree: { marginTop: 20 }, |
||||
|
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 }, |
||||
|
link: { color: colors.primary, fontWeight: '600' }, |
||||
|
footer: { paddingTop: 16, paddingBottom: 8 }, |
||||
|
footRow: { flexDirection: 'row', justifyContent: 'center', gap: 18 }, |
||||
|
footItem: { fontSize: 12, color: colors.textHint }, |
||||
|
carrierNote: { textAlign: 'center', fontSize: 12, color: colors.textHint, marginTop: 12 }, |
||||
|
}); |
||||
@ -0,0 +1,67 @@ |
|||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { Illustration } from '../ui/Illustration'; |
||||
|
|
||||
|
export function P3Identity() { |
||||
|
const { setData, navigate, back, reset, meta } = useOnboarding(); |
||||
|
|
||||
|
const chooseCandidate = () => { |
||||
|
setData({ identity: 'candidate' }); |
||||
|
navigate('P4'); |
||||
|
}; |
||||
|
const chooseRecruiter = () => { |
||||
|
setData({ identity: 'recruiter' }); |
||||
|
navigate('B3'); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} showBack onBack={back}> |
||||
|
<Text style={styles.heading}>选择你的身份</Text> |
||||
|
|
||||
|
<Pressable style={styles.card} onPress={chooseCandidate}> |
||||
|
<View style={styles.cardTop}> |
||||
|
<Illustration name="illustCandidate" size={120} radius={16} /> |
||||
|
</View> |
||||
|
<View style={styles.cardBtn}> |
||||
|
<Text style={styles.cardBtnText}>我要找工作</Text> |
||||
|
</View> |
||||
|
</Pressable> |
||||
|
|
||||
|
<Text style={styles.or}>或者</Text> |
||||
|
|
||||
|
<Pressable style={styles.card} onPress={chooseRecruiter}> |
||||
|
<View style={styles.cardTop}> |
||||
|
<Illustration name="illustRecruiter" size={120} radius={16} /> |
||||
|
</View> |
||||
|
<View style={[styles.cardBtn, styles.cardBtnOutline]}> |
||||
|
<Text style={[styles.cardBtnText, { color: colors.primary }]}>我要招人</Text> |
||||
|
</View> |
||||
|
</Pressable> |
||||
|
|
||||
|
<Pressable style={styles.logout} onPress={reset}> |
||||
|
<Text style={styles.logoutText}>退出登录</Text> |
||||
|
</Pressable> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
heading: { fontSize: 22, fontWeight: '800', color: colors.textPrimary, textAlign: 'center', marginVertical: 16 }, |
||||
|
card: { backgroundColor: colors.white, borderRadius: 18, padding: 18, alignItems: 'center', borderWidth: 1, borderColor: colors.border }, |
||||
|
cardTop: { marginBottom: 16 }, |
||||
|
cardBtn: { |
||||
|
width: '100%', |
||||
|
height: 48, |
||||
|
borderRadius: 24, |
||||
|
backgroundColor: colors.primary, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
}, |
||||
|
cardBtnOutline: { backgroundColor: colors.white, borderWidth: 1, borderColor: colors.primary }, |
||||
|
cardBtnText: { fontSize: 16, fontWeight: '700', color: colors.textInverse }, |
||||
|
or: { textAlign: 'center', color: colors.textHint, marginVertical: 14, fontSize: 13 }, |
||||
|
logout: { marginTop: 22, alignItems: 'center' }, |
||||
|
logoutText: { fontSize: 13, color: colors.textHint }, |
||||
|
}); |
||||
@ -0,0 +1,58 @@ |
|||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { Tag, TagGroup } from '../ui/Tag'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
|
||||
|
const HOT = ['Java', '项目经理', '运维', '测试', '前端', '数据分析', '电子', '通信']; |
||||
|
|
||||
|
export function P4Intent() { |
||||
|
const { data, setData, navigate, back, meta } = useOnboarding(); |
||||
|
const isStudent = data.isStudent; |
||||
|
const city = data.city; |
||||
|
const positions = data.expectedPositions; |
||||
|
|
||||
|
const togglePos = (p: string) => |
||||
|
setData({ |
||||
|
expectedPositions: positions.includes(p) ? positions.filter((x) => x !== p) : [...positions, p], |
||||
|
}); |
||||
|
|
||||
|
const canNext = isStudent !== null && positions.length > 0; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} title="求职意向" showBack onBack={back}> |
||||
|
<Text style={styles.section}>你是学生吗</Text> |
||||
|
<TagGroup> |
||||
|
<Tag label="是" selected={isStudent === true} onPress={() => setData({ isStudent: true })} /> |
||||
|
<Tag label="不是" selected={isStudent === false} onPress={() => setData({ isStudent: false })} /> |
||||
|
</TagGroup> |
||||
|
|
||||
|
<Text style={styles.section}>工作地点</Text> |
||||
|
<View style={styles.cityRow}> |
||||
|
<Ionicons name="location" size={18} color={colors.primary} /> |
||||
|
<Tag label={city} selected onPress={() => navigate('P5')} /> |
||||
|
<Text style={styles.cityHint}>点击切换城市</Text> |
||||
|
</View> |
||||
|
|
||||
|
<Text style={styles.section}>猜你想找</Text> |
||||
|
<TagGroup> |
||||
|
{HOT.map((p) => ( |
||||
|
<Tag key={p} label={p} selected={positions.includes(p)} onPress={() => togglePos(p)} /> |
||||
|
))} |
||||
|
</TagGroup> |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<PrimaryButton label="下一步" active={canNext} onPress={() => navigate('P6')} /> |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
section: { fontSize: 16, fontWeight: '700', color: colors.textPrimary, marginTop: 22, marginBottom: 12 }, |
||||
|
cityRow: { flexDirection: 'row', alignItems: 'center', gap: 10 }, |
||||
|
cityHint: { fontSize: 12, color: colors.textHint }, |
||||
|
footer: { marginTop: 32 }, |
||||
|
}); |
||||
@ -0,0 +1,68 @@ |
|||||
|
import { useState } from 'react'; |
||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { SearchInput } from '../ui/SearchInput'; |
||||
|
import { Tag, TagGroup } from '../ui/Tag'; |
||||
|
|
||||
|
const ALL = ['北京', '上海', '广州', '深圳', '杭州', '成都', '南京', '武汉', '西安', '苏州', '天津', '重庆', '青岛', '长沙', '郑州', '东莞']; |
||||
|
const HOT = ['北京', '上海', '广州', '深圳', '杭州', '成都']; |
||||
|
|
||||
|
export function P5City() { |
||||
|
const { data, setData, back, meta } = useOnboarding(); |
||||
|
const [query, setQuery] = useState(''); |
||||
|
|
||||
|
const pick = (city: string) => { |
||||
|
setData({ city }); |
||||
|
back(); |
||||
|
}; |
||||
|
|
||||
|
const list = query ? ALL.filter((c) => c.includes(query)) : ALL; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} title="选择城市" showBack onBack={back}> |
||||
|
<SearchInput value={query} onChangeText={setQuery} placeholder="搜索城市" onClear={() => setQuery('')} /> |
||||
|
|
||||
|
<Pressable style={styles.locate} onPress={() => pick('上海')}> |
||||
|
<Ionicons name="locate" size={18} color={colors.primary} /> |
||||
|
<Text style={styles.locateText}>自动定位:上海</Text> |
||||
|
</Pressable> |
||||
|
|
||||
|
<Text style={styles.section}>热门城市</Text> |
||||
|
<TagGroup> |
||||
|
{HOT.map((c) => ( |
||||
|
<Tag key={c} label={c} selected={data.city === c} onPress={() => pick(c)} /> |
||||
|
))} |
||||
|
</TagGroup> |
||||
|
|
||||
|
<Text style={styles.section}>全部城市</Text> |
||||
|
<View style={styles.list}> |
||||
|
{list.map((c) => ( |
||||
|
<Pressable key={c} style={styles.cityItem} onPress={() => pick(c)}> |
||||
|
<Text style={[styles.cityName, data.city === c && styles.cityNameActive]}>{c}</Text> |
||||
|
{data.city === c ? <Ionicons name="checkmark" size={16} color={colors.primary} /> : null} |
||||
|
</Pressable> |
||||
|
))} |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
locate: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 14, marginTop: 12 }, |
||||
|
locateText: { fontSize: 14, fontWeight: '600', color: colors.primaryText }, |
||||
|
section: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginTop: 22, marginBottom: 12 }, |
||||
|
list: { gap: 2 }, |
||||
|
cityItem: { |
||||
|
flexDirection: 'row', |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'space-between', |
||||
|
paddingVertical: 13, |
||||
|
borderBottomWidth: 1, |
||||
|
borderColor: colors.borderLight, |
||||
|
}, |
||||
|
cityName: { fontSize: 15, color: colors.textPrimary }, |
||||
|
cityNameActive: { color: colors.primary, fontWeight: '700' }, |
||||
|
}); |
||||
@ -0,0 +1,121 @@ |
|||||
|
import { useState } from 'react'; |
||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding, useRegisterFinish } from '../../../src/hooks'; |
||||
|
import type { AppRole } from '../../../src/types'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { SearchInput } from '../ui/SearchInput'; |
||||
|
import { Tag, TagGroup } from '../ui/Tag'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
|
||||
|
const CATEGORIES = ['高级管理', '采购贸易', '生产制造', '互联网AI', '设计', '金融', '客服运营', '咨询翻译法律', '房地产建筑', '医疗健康']; |
||||
|
const POSITIONS: Record<string, string[]> = { |
||||
|
高级管理: ['CEO', 'CTO', '产品经理', '项目负责人'], |
||||
|
采购贸易: ['采购专员', '贸易跟单', '供应链专员'], |
||||
|
生产制造: ['工艺工程师', '质量工程师', '生产主管'], |
||||
|
互联网AI: ['前端开发工程师', '鸿蒙开发工程师', '全栈工程师', 'Java工程师', '测试工程师', '运维工程师', '数据分析师', '算法工程师'], |
||||
|
设计: ['UI设计师', '视觉设计师', '交互设计师'], |
||||
|
金融: ['风控专员', '投资经理', '财务分析'], |
||||
|
客服运营: ['客服专员', '用户运营', '内容运营'], |
||||
|
咨询翻译法律: ['咨询顾问', '翻译', '法务专员'], |
||||
|
房地产建筑: ['建筑设计师', '土木工程师', '房产经纪'], |
||||
|
医疗健康: ['临床医师', '药剂师', '健康管理'], |
||||
|
}; |
||||
|
|
||||
|
export function P6ExpectedPosition() { |
||||
|
const { data, setData, navigate, back, meta } = useOnboarding(); |
||||
|
const finish = useRegisterFinish(); |
||||
|
const targetRole: AppRole = data.identity === 'recruiter' ? 'enterprise' : 'candidate'; |
||||
|
const [cat, setCat] = useState('互联网AI'); |
||||
|
const positions = data.expectedPositions; |
||||
|
|
||||
|
const toggle = (p: string) => |
||||
|
setData({ |
||||
|
expectedPositions: positions.includes(p) ? positions.filter((x) => x !== p) : [...positions, p], |
||||
|
}); |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} showBack onBack={back}> |
||||
|
<View style={styles.header}> |
||||
|
<Text style={styles.title}>期望职位</Text> |
||||
|
<View style={styles.headerRight}> |
||||
|
<View style={styles.progress}> |
||||
|
<Text style={styles.progressText}>3/3</Text> |
||||
|
</View> |
||||
|
<Pressable onPress={() => finish(targetRole)}> |
||||
|
<Text style={styles.save}>保存</Text> |
||||
|
</Pressable> |
||||
|
</View> |
||||
|
</View> |
||||
|
|
||||
|
<SearchInput value="" onChangeText={() => {}} placeholder="搜索职位" /> |
||||
|
|
||||
|
<View style={styles.body}> |
||||
|
<View style={styles.cats}> |
||||
|
{CATEGORIES.map((c) => ( |
||||
|
<Pressable |
||||
|
key={c} |
||||
|
style={[styles.catItem, c === cat && styles.catItemActive]} |
||||
|
onPress={() => setCat(c)} |
||||
|
> |
||||
|
<Text style={[styles.catText, c === cat && styles.catTextActive]}>{c}</Text> |
||||
|
</Pressable> |
||||
|
))} |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.posCol}> |
||||
|
{positions.length > 0 && ( |
||||
|
<View style={styles.chips}> |
||||
|
{positions.map((p) => ( |
||||
|
<View key={p} style={styles.chip}> |
||||
|
<Text style={styles.chipText}>{p}</Text> |
||||
|
<Pressable onPress={() => toggle(p)} hitSlop={6}> |
||||
|
<Ionicons name="close" size={14} color={colors.textInverse} /> |
||||
|
</Pressable> |
||||
|
</View> |
||||
|
))} |
||||
|
</View> |
||||
|
)} |
||||
|
<TagGroup> |
||||
|
{POSITIONS[cat].map((p) => ( |
||||
|
<Tag key={p} label={p} selected={positions.includes(p)} onPress={() => toggle(p)} /> |
||||
|
))} |
||||
|
</TagGroup> |
||||
|
</View> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<PrimaryButton label="下一步" active={positions.length > 0} onPress={() => navigate('P7')} /> |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }, |
||||
|
title: { fontSize: 20, fontWeight: '800', color: colors.textPrimary }, |
||||
|
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 14 }, |
||||
|
progress: { backgroundColor: colors.primaryLight, paddingHorizontal: 10, paddingVertical: 3, borderRadius: 10 }, |
||||
|
progressText: { fontSize: 12, fontWeight: '700', color: colors.primaryText }, |
||||
|
save: { fontSize: 15, fontWeight: '700', color: colors.primary }, |
||||
|
body: { flexDirection: 'row', gap: 14, marginTop: 8 }, |
||||
|
cats: { width: 92 }, |
||||
|
catItem: { paddingVertical: 12, borderRadius: 10 }, |
||||
|
catItemActive: { backgroundColor: colors.primaryLight }, |
||||
|
catText: { fontSize: 13, color: colors.textSecondary, textAlign: 'center' }, |
||||
|
catTextActive: { color: colors.primaryText, fontWeight: '700' }, |
||||
|
posCol: { flex: 1 }, |
||||
|
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 14 }, |
||||
|
chip: { |
||||
|
flexDirection: 'row', |
||||
|
alignItems: 'center', |
||||
|
gap: 6, |
||||
|
backgroundColor: colors.primary, |
||||
|
paddingHorizontal: 10, |
||||
|
paddingVertical: 6, |
||||
|
borderRadius: 14, |
||||
|
}, |
||||
|
chipText: { fontSize: 13, color: colors.textInverse }, |
||||
|
footer: { marginTop: 28 }, |
||||
|
}); |
||||
@ -0,0 +1,70 @@ |
|||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
|
||||
|
const MIN = ['9K', '10K', '11K']; |
||||
|
const MAX = ['11K', '12K', '13K']; |
||||
|
|
||||
|
export function P7Salary() { |
||||
|
const { data, setData, navigate, back, meta } = useOnboarding(); |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} title="期望月薪" showBack onBack={back}> |
||||
|
<Text style={styles.tip}>1K 代表 1000 元</Text> |
||||
|
|
||||
|
<View style={styles.cols}> |
||||
|
<View style={styles.col}> |
||||
|
<Text style={styles.colTitle}>最低</Text> |
||||
|
{MIN.map((v) => ( |
||||
|
<Pressable |
||||
|
key={v} |
||||
|
style={[styles.opt, data.expectedSalaryMin === v && styles.optActive]} |
||||
|
onPress={() => setData({ expectedSalaryMin: v })} |
||||
|
> |
||||
|
<Text style={[styles.optText, data.expectedSalaryMin === v && styles.optTextActive]}>{v}</Text> |
||||
|
</Pressable> |
||||
|
))} |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.col}> |
||||
|
<Text style={styles.colTitle}>最高</Text> |
||||
|
{MAX.map((v) => ( |
||||
|
<Pressable |
||||
|
key={v} |
||||
|
style={[styles.opt, data.expectedSalaryMax === v && styles.optActive]} |
||||
|
onPress={() => setData({ expectedSalaryMax: v })} |
||||
|
> |
||||
|
<Text style={[styles.optText, data.expectedSalaryMax === v && styles.optTextActive]}>{v}</Text> |
||||
|
</Pressable> |
||||
|
))} |
||||
|
</View> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<PrimaryButton label="下一步" active onPress={() => navigate('P8')} /> |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
tip: { fontSize: 13, color: colors.textHint, marginBottom: 20 }, |
||||
|
cols: { flexDirection: 'row', gap: 16 }, |
||||
|
col: { flex: 1, gap: 12 }, |
||||
|
colTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, textAlign: 'center', marginBottom: 4 }, |
||||
|
opt: { |
||||
|
height: 50, |
||||
|
borderRadius: 12, |
||||
|
borderWidth: 1, |
||||
|
borderColor: colors.border, |
||||
|
backgroundColor: colors.white, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
}, |
||||
|
optActive: { borderColor: colors.primary, backgroundColor: colors.primaryLight }, |
||||
|
optText: { fontSize: 16, color: colors.textSecondary }, |
||||
|
optTextActive: { color: colors.primaryText, fontWeight: '700' }, |
||||
|
footer: { marginTop: 32 }, |
||||
|
}); |
||||
@ -0,0 +1,74 @@ |
|||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { StyleSheet, Text, TextInput, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { Tag, TagGroup } from '../ui/Tag'; |
||||
|
import { YearMonthWheel } from '../ui/WheelPicker'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
|
||||
|
export function P8Resume1() { |
||||
|
const { data, setData, navigate, back, meta } = useOnboarding(); |
||||
|
|
||||
|
const canNext = data.resumeName.trim().length > 0; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} title="创建在线简历" showBack onBack={back}> |
||||
|
<View style={styles.iconWrap}> |
||||
|
<Ionicons name="document-text" size={34} color={colors.primary} /> |
||||
|
</View> |
||||
|
|
||||
|
<Text style={styles.label}>姓名</Text> |
||||
|
<TextInput |
||||
|
style={styles.input} |
||||
|
value={data.resumeName} |
||||
|
onChangeText={(t) => setData({ resumeName: t })} |
||||
|
placeholder="请输入姓名" |
||||
|
placeholderTextColor={colors.textHint} |
||||
|
/> |
||||
|
|
||||
|
<Text style={styles.label}>性别</Text> |
||||
|
<TagGroup> |
||||
|
<Tag label="女" selected={data.gender === '女'} onPress={() => setData({ gender: '女' })} /> |
||||
|
<Tag label="男" selected={data.gender === '男'} onPress={() => setData({ gender: '男' })} /> |
||||
|
</TagGroup> |
||||
|
|
||||
|
<Text style={styles.label}>出生年月</Text> |
||||
|
<YearMonthWheel |
||||
|
year={data.birthYear} |
||||
|
month={data.birthMonth} |
||||
|
onYearChange={(v) => setData({ birthYear: v })} |
||||
|
onMonthChange={(v) => setData({ birthMonth: v })} |
||||
|
/> |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<PrimaryButton label="下一步" active={canNext} onPress={() => navigate('P9')} /> |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
iconWrap: { |
||||
|
width: 64, |
||||
|
height: 64, |
||||
|
borderRadius: 18, |
||||
|
backgroundColor: colors.primaryLight, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
alignSelf: 'center', |
||||
|
marginBottom: 18, |
||||
|
}, |
||||
|
label: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, marginTop: 18, marginBottom: 10 }, |
||||
|
input: { |
||||
|
height: 50, |
||||
|
borderRadius: 12, |
||||
|
borderWidth: 1, |
||||
|
borderColor: colors.border, |
||||
|
backgroundColor: colors.white, |
||||
|
paddingHorizontal: 14, |
||||
|
fontSize: 16, |
||||
|
color: colors.textPrimary, |
||||
|
}, |
||||
|
footer: { marginTop: 26 }, |
||||
|
}); |
||||
@ -0,0 +1,65 @@ |
|||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { RadioRow } from '../ui/RadioRow'; |
||||
|
import { AgreementCheckbox } from '../ui/AgreementCheckbox'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
|
||||
|
const OPTIONS = ['离职-随时到岗', '在职-月内到岗', '在职-考虑机会', '在职-暂不考虑']; |
||||
|
|
||||
|
export function P9JobStatus() { |
||||
|
const { data, setData, navigate, back, meta } = useOnboarding(); |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell time={meta.time} battery={meta.battery} title="求职状态" showBack onBack={back}> |
||||
|
<View style={styles.iconWrap}> |
||||
|
<Ionicons name="person" size={34} color={colors.primary} /> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.list}> |
||||
|
{OPTIONS.map((o) => ( |
||||
|
<RadioRow |
||||
|
key={o} |
||||
|
label={o} |
||||
|
selected={data.jobStatus === o} |
||||
|
onPress={() => setData({ jobStatus: o })} |
||||
|
/> |
||||
|
))} |
||||
|
</View> |
||||
|
|
||||
|
<Text style={styles.note}>求职状态会影响到被推荐的频率及 Boss 的决策</Text> |
||||
|
|
||||
|
<View style={styles.agree}> |
||||
|
<AgreementCheckbox |
||||
|
checked={data.disabledJobseeker} |
||||
|
onToggle={() => setData({ disabledJobseeker: !data.disabledJobseeker })} |
||||
|
text={<Text style={styles.agreeText}>残障人士求职(享受专项推荐与保护)</Text>} |
||||
|
/> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.footer}> |
||||
|
<PrimaryButton label="下一步" active={data.jobStatus !== null} onPress={() => navigate('P10')} /> |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
iconWrap: { |
||||
|
width: 64, |
||||
|
height: 64, |
||||
|
borderRadius: 18, |
||||
|
backgroundColor: colors.primaryLight, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
alignSelf: 'center', |
||||
|
marginBottom: 18, |
||||
|
}, |
||||
|
list: { gap: 10 }, |
||||
|
note: { fontSize: 12, color: colors.textHint, marginTop: 14, textAlign: 'center' }, |
||||
|
agree: { marginTop: 18 }, |
||||
|
agreeText: { fontSize: 13, color: colors.textSecondary }, |
||||
|
footer: { marginTop: 28 }, |
||||
|
}); |
||||
@ -0,0 +1,37 @@ |
|||||
|
import type { ReactNode } from 'react'; |
||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { Pressable, StyleSheet, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
|
||||
|
interface Props { |
||||
|
checked: boolean; |
||||
|
onToggle: () => void; |
||||
|
text: ReactNode; |
||||
|
} |
||||
|
|
||||
|
/** 协议勾选框:默认勾选 / 点击切换 */ |
||||
|
export function AgreementCheckbox({ checked, onToggle, text }: Props) { |
||||
|
return ( |
||||
|
<View style={styles.row}> |
||||
|
<Pressable style={[styles.box, checked && styles.boxChecked]} onPress={onToggle} hitSlop={6}> |
||||
|
{checked ? <Ionicons name="checkmark" size={14} color={colors.textInverse} /> : null} |
||||
|
</Pressable> |
||||
|
<View style={styles.textWrap}>{text}</View> |
||||
|
</View> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
row: { flexDirection: 'row', alignItems: 'center', gap: 10 }, |
||||
|
box: { |
||||
|
width: 20, |
||||
|
height: 20, |
||||
|
borderRadius: 10, |
||||
|
borderWidth: 1.5, |
||||
|
borderColor: colors.borderStrong, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
}, |
||||
|
boxChecked: { backgroundColor: colors.primary, borderColor: colors.primary }, |
||||
|
textWrap: { flex: 1, flexShrink: 1 }, |
||||
|
}); |
||||
@ -0,0 +1,53 @@ |
|||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { Modal } from './Modal'; |
||||
|
|
||||
|
interface Props { |
||||
|
visible: boolean; |
||||
|
/** 「同意」= 勾选协议并继续原动作 */ |
||||
|
onAccept: () => void; |
||||
|
/** 「拒绝」= 关闭弹窗,不执行动作 */ |
||||
|
onDecline: () => void; |
||||
|
/** 点击协议名跳转协议详情 */ |
||||
|
onOpenAgreement?: () => void; |
||||
|
} |
||||
|
|
||||
|
/** 协议确认弹窗(对齐参考样式):未勾选协议点登录时拦截,拒绝/同意双按钮 */ |
||||
|
export function AgreementModal({ visible, onAccept, onDecline, onOpenAgreement }: Props) { |
||||
|
return ( |
||||
|
<Modal visible={visible} onClose={onDecline}> |
||||
|
<Text style={styles.message}> |
||||
|
请阅读并同意 |
||||
|
<Text style={styles.link} onPress={onOpenAgreement}>《有职用户协议》</Text> |
||||
|
和 |
||||
|
<Text style={styles.link} onPress={onOpenAgreement}>《隐私政策》</Text> |
||||
|
,允许有职统一管理本人账号信息 |
||||
|
</Text> |
||||
|
<View style={styles.btnRow}> |
||||
|
<Pressable style={[styles.btn, styles.declineBtn]} onPress={onDecline} hitSlop={4}> |
||||
|
<Text style={styles.declineText}>拒绝</Text> |
||||
|
</Pressable> |
||||
|
<Pressable style={[styles.btn, styles.acceptBtn]} onPress={onAccept} hitSlop={4}> |
||||
|
<Text style={styles.acceptText}>同意</Text> |
||||
|
</Pressable> |
||||
|
</View> |
||||
|
</Modal> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
message: { fontSize: 15, lineHeight: 24, color: colors.textPrimary, textAlign: 'center' }, |
||||
|
link: { color: colors.primary }, |
||||
|
btnRow: { flexDirection: 'row', gap: 14, marginTop: 20 }, |
||||
|
btn: { |
||||
|
flex: 1, |
||||
|
height: 46, |
||||
|
borderRadius: 23, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
}, |
||||
|
declineBtn: { backgroundColor: colors.borderLight }, |
||||
|
declineText: { fontSize: 16, fontWeight: '600', color: colors.textPrimary }, |
||||
|
acceptBtn: { backgroundColor: colors.primary }, |
||||
|
acceptText: { fontSize: 16, fontWeight: '700', color: colors.textInverse }, |
||||
|
}); |
||||
@ -0,0 +1,31 @@ |
|||||
|
import { Pressable, StyleSheet, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { Illustration } from './Illustration'; |
||||
|
import type { AssetName } from '../assets'; |
||||
|
|
||||
|
interface Props { |
||||
|
name?: AssetName; |
||||
|
size?: number; |
||||
|
selected?: boolean; |
||||
|
onPress?: () => void; |
||||
|
} |
||||
|
|
||||
|
/** 圆形头像占位:默认头像剪影 / 真实图;选中态加主色描边 */ |
||||
|
export function Avatar({ name = 'defaultAvatar', size = 64, selected = false, onPress }: Props) { |
||||
|
const content = ( |
||||
|
<View |
||||
|
style={[ |
||||
|
styles.wrap, |
||||
|
{ width: size, height: size, borderRadius: size / 2, borderColor: selected ? colors.primary : colors.border }, |
||||
|
]} |
||||
|
> |
||||
|
<Illustration name={name} size={size} circle /> |
||||
|
</View> |
||||
|
); |
||||
|
if (onPress) return <Pressable onPress={onPress} hitSlop={6}>{content}</Pressable>; |
||||
|
return content; |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
wrap: { borderWidth: 2, overflow: 'hidden', backgroundColor: colors.white }, |
||||
|
}); |
||||
@ -0,0 +1,20 @@ |
|||||
|
import { StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { Illustration } from './Illustration'; |
||||
|
|
||||
|
/** 统一品牌头部:Logo + 名称 + 标语 */ |
||||
|
export function BrandHeader({ slogan }: { slogan?: string }) { |
||||
|
return ( |
||||
|
<View style={styles.wrap}> |
||||
|
<Illustration name="logo" size={64} radius={18} /> |
||||
|
<Text style={styles.name}>有职</Text> |
||||
|
{slogan ? <Text style={styles.slogan}>{slogan}</Text> : null} |
||||
|
</View> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
wrap: { alignItems: 'center', gap: 12, paddingVertical: 8 }, |
||||
|
name: { fontSize: 26, fontWeight: '800', color: colors.textPrimary }, |
||||
|
slogan: { fontSize: 13, color: colors.textHint }, |
||||
|
}); |
||||
@ -0,0 +1,54 @@ |
|||||
|
import { useEffect, useRef, useState } from 'react'; |
||||
|
import { Pressable, StyleSheet, Text } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
|
||||
|
interface Props { |
||||
|
disabled?: boolean; |
||||
|
onSend?: () => void; |
||||
|
seconds?: number; |
||||
|
} |
||||
|
|
||||
|
/** 获取验证码按钮:未满足前置条件置灰;点击后倒计时 Ns */ |
||||
|
export function CountdownButton({ disabled = false, onSend, seconds = 60 }: Props) { |
||||
|
const [left, setLeft] = useState(0); |
||||
|
const timer = useRef<ReturnType<typeof setInterval> | null>(null); |
||||
|
|
||||
|
useEffect(() => () => { |
||||
|
if (timer.current) clearInterval(timer.current); |
||||
|
}, []); |
||||
|
|
||||
|
const start = () => { |
||||
|
if (disabled || left > 0) return; |
||||
|
setLeft(seconds); |
||||
|
onSend?.(); |
||||
|
timer.current = setInterval(() => { |
||||
|
setLeft((l) => { |
||||
|
if (l <= 1) { |
||||
|
if (timer.current) clearInterval(timer.current); |
||||
|
return 0; |
||||
|
} |
||||
|
return l - 1; |
||||
|
}); |
||||
|
}, 1000); |
||||
|
}; |
||||
|
|
||||
|
const counting = left > 0; |
||||
|
return ( |
||||
|
<Pressable |
||||
|
style={[styles.btn, (disabled || counting) && styles.btnDisabled]} |
||||
|
onPress={start} |
||||
|
disabled={disabled || counting} |
||||
|
> |
||||
|
<Text style={[styles.text, (disabled || counting) && styles.textDisabled]}> |
||||
|
{counting ? `${left}s 后重新获取` : '获取验证码'} |
||||
|
</Text> |
||||
|
</Pressable> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
btn: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 16, backgroundColor: colors.primaryLight }, |
||||
|
btnDisabled: { backgroundColor: colors.borderLight }, |
||||
|
text: { fontSize: 13, fontWeight: '600', color: colors.primaryText }, |
||||
|
textDisabled: { color: colors.textDisabled }, |
||||
|
}); |
||||
@ -0,0 +1,37 @@ |
|||||
|
import { Image, ImageSourcePropType, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import type { ComponentProps } from 'react'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { ASSET_REGISTRY, AssetName } from '../assets'; |
||||
|
|
||||
|
type IconName = ComponentProps<typeof Ionicons>['name']; |
||||
|
|
||||
|
interface Props { |
||||
|
name: AssetName; |
||||
|
size?: number; |
||||
|
radius?: number; |
||||
|
circle?: boolean; |
||||
|
} |
||||
|
|
||||
|
/** 统一插画/Logo/头像占位:有真实图渲染图,否则渲染色块 + 图标 + 提示文字 */ |
||||
|
export function Illustration({ name, size = 120, radius = 16, circle = false }: Props) { |
||||
|
const def = ASSET_REGISTRY[name]; |
||||
|
const source: ImageSourcePropType | null = def.source; |
||||
|
const r = circle ? size / 2 : radius; |
||||
|
|
||||
|
if (source) { |
||||
|
return <Image source={source} style={{ width: size, height: size, borderRadius: r }} resizeMode="cover" />; |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<View style={[styles.block, { width: size, height: size, borderRadius: r, backgroundColor: def.tint }]}> |
||||
|
<Ionicons name={def.icon as IconName} size={size * 0.32} color={colors.primary} /> |
||||
|
{!circle && <Text style={styles.hint}>{def.hint}</Text>} |
||||
|
</View> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
block: { alignItems: 'center', justifyContent: 'center', gap: 6, overflow: 'hidden' }, |
||||
|
hint: { fontSize: 11, color: colors.textHint, paddingHorizontal: 8, textAlign: 'center' }, |
||||
|
}); |
||||
@ -0,0 +1,41 @@ |
|||||
|
import type { ReactNode } from 'react'; |
||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
|
||||
|
interface Props { |
||||
|
visible: boolean; |
||||
|
onClose?: () => void; |
||||
|
title?: string; |
||||
|
children: ReactNode; |
||||
|
footer?: ReactNode; |
||||
|
} |
||||
|
|
||||
|
/** 浅色底圆角卡片弹窗 + 半透明遮罩 */ |
||||
|
export function Modal({ visible, onClose, title, children, footer }: Props) { |
||||
|
if (!visible) return null; |
||||
|
return ( |
||||
|
<View style={styles.overlay}> |
||||
|
<Pressable style={styles.scrim} onPress={onClose} /> |
||||
|
<View style={styles.card}> |
||||
|
{title ? <Text style={styles.title}>{title}</Text> : null} |
||||
|
<View style={styles.body}>{children}</View> |
||||
|
{footer ? <View style={styles.footer}>{footer}</View> : null} |
||||
|
</View> |
||||
|
</View> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
overlay: { ...StyleSheet.absoluteFill, justifyContent: 'center', alignItems: 'center', zIndex: 100 }, |
||||
|
scrim: { ...StyleSheet.absoluteFill, backgroundColor: colors.scrim }, |
||||
|
card: { |
||||
|
width: '84%', |
||||
|
backgroundColor: colors.white, |
||||
|
borderRadius: 20, |
||||
|
padding: 22, |
||||
|
maxHeight: '80%', |
||||
|
}, |
||||
|
title: { fontSize: 18, fontWeight: '800', color: colors.textPrimary, marginBottom: 12, textAlign: 'center' }, |
||||
|
body: { gap: 10 }, |
||||
|
footer: { marginTop: 18, gap: 10 }, |
||||
|
}); |
||||
@ -0,0 +1,29 @@ |
|||||
|
import { ActivityIndicator, Pressable, StyleSheet, Text } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
|
||||
|
interface Props { |
||||
|
label: string; |
||||
|
active?: boolean; |
||||
|
onPress?: () => void; |
||||
|
loading?: boolean; |
||||
|
} |
||||
|
|
||||
|
/** 主按钮:active=高亮可点;false=置灰(未激活) */ |
||||
|
export function PrimaryButton({ label, active = true, onPress, loading = false }: Props) { |
||||
|
return ( |
||||
|
<Pressable |
||||
|
style={[styles.btn, active ? styles.btnActive : styles.btnDisabled]} |
||||
|
onPress={active && !loading ? onPress : undefined} |
||||
|
disabled={!active || loading} |
||||
|
> |
||||
|
{loading ? <ActivityIndicator color={colors.textInverse} /> : <Text style={styles.btnText}>{label}</Text>} |
||||
|
</Pressable> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
btn: { height: 50, borderRadius: 12, alignItems: 'center', justifyContent: 'center' }, |
||||
|
btnActive: { backgroundColor: colors.primary }, |
||||
|
btnDisabled: { backgroundColor: colors.textDisabled }, |
||||
|
btnText: { fontSize: 17, fontWeight: '700', color: colors.textInverse }, |
||||
|
}); |
||||
@ -0,0 +1,54 @@ |
|||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
|
||||
|
interface Props { |
||||
|
label: string; |
||||
|
selected?: boolean; |
||||
|
onPress?: () => void; |
||||
|
desc?: string; |
||||
|
} |
||||
|
|
||||
|
/** 单选行:选中态填充高亮 + 勾选标记 */ |
||||
|
export function RadioRow({ label, selected = false, onPress, desc }: Props) { |
||||
|
return ( |
||||
|
<Pressable style={[styles.row, selected && styles.rowSelected]} onPress={onPress}> |
||||
|
<View style={styles.textCol}> |
||||
|
<Text style={[styles.label, selected && styles.labelSelected]}>{label}</Text> |
||||
|
{desc ? <Text style={styles.desc}>{desc}</Text> : null} |
||||
|
</View> |
||||
|
<View style={[styles.dot, selected && styles.dotSelected]}> |
||||
|
{selected ? <Ionicons name="checkmark" size={14} color={colors.textInverse} /> : null} |
||||
|
</View> |
||||
|
</Pressable> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
row: { |
||||
|
flexDirection: 'row', |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'space-between', |
||||
|
paddingVertical: 14, |
||||
|
paddingHorizontal: 16, |
||||
|
borderRadius: 12, |
||||
|
borderWidth: 1, |
||||
|
borderColor: colors.border, |
||||
|
backgroundColor: colors.white, |
||||
|
}, |
||||
|
rowSelected: { borderColor: colors.primary, backgroundColor: colors.primaryLight }, |
||||
|
textCol: { flex: 1, paddingRight: 12 }, |
||||
|
label: { fontSize: 15, fontWeight: '600', color: colors.textPrimary }, |
||||
|
labelSelected: { color: colors.primaryText }, |
||||
|
desc: { fontSize: 12, color: colors.textHint, marginTop: 4 }, |
||||
|
dot: { |
||||
|
width: 22, |
||||
|
height: 22, |
||||
|
borderRadius: 11, |
||||
|
borderWidth: 1.5, |
||||
|
borderColor: colors.borderStrong, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
}, |
||||
|
dotSelected: { borderColor: colors.primary, backgroundColor: colors.primary }, |
||||
|
}); |
||||
@ -0,0 +1,53 @@ |
|||||
|
import type { ReactNode } from 'react'; |
||||
|
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { StatusBar } from 'expo-status-bar'; |
||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { SafeAreaView } from 'react-native-safe-area-context'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
|
||||
|
interface Props { |
||||
|
title?: string; |
||||
|
showBack?: boolean; |
||||
|
onBack?: () => void; |
||||
|
children: ReactNode; |
||||
|
footer?: ReactNode; |
||||
|
scroll?: boolean; |
||||
|
/** 兼容旧调用保留,已不再渲染(统一使用系统真实状态栏) */ |
||||
|
time?: string; |
||||
|
battery?: number; |
||||
|
} |
||||
|
|
||||
|
/** 每屏统一外壳:系统状态栏 + 可选返回箭头 + 标题 + 滚动内容 + 底部固定区 */ |
||||
|
export function ScreenShell({ title, showBack, onBack, children, footer, scroll = true, time: _time, battery: _battery }: Props) { |
||||
|
return ( |
||||
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> |
||||
|
<StatusBar style="dark" /> |
||||
|
{showBack ? ( |
||||
|
<Pressable style={styles.backRow} onPress={onBack} hitSlop={10}> |
||||
|
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} /> |
||||
|
{title ? <Text style={styles.titleInline}>{title}</Text> : null} |
||||
|
</Pressable> |
||||
|
) : title ? ( |
||||
|
<Text style={styles.titleCenter}>{title}</Text> |
||||
|
) : null} |
||||
|
{scroll ? ( |
||||
|
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}> |
||||
|
{children} |
||||
|
</ScrollView> |
||||
|
) : ( |
||||
|
<View style={styles.scrollContent}>{children}</View> |
||||
|
)} |
||||
|
{footer ? <View style={styles.footer}>{footer}</View> : null} |
||||
|
</SafeAreaView> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
container: { flex: 1, backgroundColor: colors.pageBg }, |
||||
|
backRow: { flexDirection: 'row', alignItems: 'center', gap: 2, paddingHorizontal: 16, paddingVertical: 10 }, |
||||
|
titleInline: { fontSize: 17, fontWeight: '700', color: colors.textPrimary }, |
||||
|
titleCenter: { fontSize: 18, fontWeight: '800', color: colors.textPrimary, textAlign: 'center', paddingVertical: 12 }, |
||||
|
scroll: { flex: 1 }, |
||||
|
scrollContent: { flexGrow: 1, padding: 20, paddingBottom: 28 }, |
||||
|
footer: { padding: 16, paddingBottom: 22, backgroundColor: colors.pageBg }, |
||||
|
}); |
||||
@ -0,0 +1,46 @@ |
|||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { Pressable, StyleSheet, TextInput, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
|
||||
|
interface Props { |
||||
|
value: string; |
||||
|
onChangeText: (t: string) => void; |
||||
|
placeholder?: string; |
||||
|
onClear?: () => void; |
||||
|
} |
||||
|
|
||||
|
/** 带搜索图标与清除按钮的输入框 */ |
||||
|
export function SearchInput({ value, onChangeText, placeholder = '搜索', onClear }: Props) { |
||||
|
return ( |
||||
|
<View style={styles.box}> |
||||
|
<Ionicons name="search" size={18} color={colors.textHint} /> |
||||
|
<TextInput |
||||
|
style={styles.input} |
||||
|
value={value} |
||||
|
onChangeText={onChangeText} |
||||
|
placeholder={placeholder} |
||||
|
placeholderTextColor={colors.textHint} |
||||
|
/> |
||||
|
{value.length > 0 ? ( |
||||
|
<Pressable onPress={onClear} hitSlop={8}> |
||||
|
<Ionicons name="close-circle" size={18} color={colors.textHint} /> |
||||
|
</Pressable> |
||||
|
) : null} |
||||
|
</View> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
box: { |
||||
|
flexDirection: 'row', |
||||
|
alignItems: 'center', |
||||
|
gap: 8, |
||||
|
height: 44, |
||||
|
paddingHorizontal: 14, |
||||
|
borderRadius: 12, |
||||
|
borderWidth: 1, |
||||
|
borderColor: colors.border, |
||||
|
backgroundColor: colors.white, |
||||
|
}, |
||||
|
input: { flex: 1, fontSize: 15, color: colors.textPrimary }, |
||||
|
}); |
||||
@ -0,0 +1,39 @@ |
|||||
|
import type { ReactNode } from 'react'; |
||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
|
||||
|
interface TagProps { |
||||
|
label: string; |
||||
|
selected?: boolean; |
||||
|
onPress?: () => void; |
||||
|
disabled?: boolean; |
||||
|
} |
||||
|
|
||||
|
/** 可选标签:选中=填充高亮+描边高亮;未选=常规样式 */ |
||||
|
export function Tag({ label, selected = false, onPress, disabled = false }: TagProps) { |
||||
|
return ( |
||||
|
<Pressable |
||||
|
style={[styles.tag, selected ? styles.tagSelected : styles.tagNormal, disabled && styles.tagDisabled]} |
||||
|
onPress={disabled ? undefined : onPress} |
||||
|
disabled={disabled} |
||||
|
> |
||||
|
<Text style={[styles.tagText, selected ? styles.tagTextSelected : styles.tagTextNormal]}>{label}</Text> |
||||
|
</Pressable> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/** 标签容器:自动换行布局 */ |
||||
|
export function TagGroup({ children }: { children: ReactNode }) { |
||||
|
return <View style={styles.group}>{children}</View>; |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
group: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 }, |
||||
|
tag: { paddingHorizontal: 16, paddingVertical: 9, borderRadius: 18, borderWidth: 1 }, |
||||
|
tagNormal: { backgroundColor: colors.white, borderColor: colors.border }, |
||||
|
tagSelected: { backgroundColor: colors.primary, borderColor: colors.primary }, |
||||
|
tagDisabled: { opacity: 0.5 }, |
||||
|
tagText: { fontSize: 14 }, |
||||
|
tagTextNormal: { color: colors.textSecondary }, |
||||
|
tagTextSelected: { color: colors.textInverse, fontWeight: '600' }, |
||||
|
}); |
||||
@ -0,0 +1,87 @@ |
|||||
|
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; |
||||
|
import { ScrollView, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
|
||||
|
const ITEM_H = 44; |
||||
|
|
||||
|
function Column({ |
||||
|
items, |
||||
|
value, |
||||
|
onValueChange, |
||||
|
width, |
||||
|
}: { |
||||
|
items: string[]; |
||||
|
value: string; |
||||
|
onValueChange: (v: string) => void; |
||||
|
width: number; |
||||
|
}) { |
||||
|
const idx = Math.max(0, items.indexOf(value)); |
||||
|
const handleEnd = (e: NativeSyntheticEvent<NativeScrollEvent>) => { |
||||
|
const y = e.nativeEvent.contentOffset.y; |
||||
|
const i = Math.min(items.length - 1, Math.max(0, Math.round(y / ITEM_H))); |
||||
|
const v = items[i]; |
||||
|
if (v !== value) onValueChange(v); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<View style={[styles.col, { width }]}> |
||||
|
<View style={styles.highlight} pointerEvents="none" /> |
||||
|
<ScrollView |
||||
|
style={styles.scroll} |
||||
|
showsVerticalScrollIndicator={false} |
||||
|
snapToInterval={ITEM_H} |
||||
|
snapToAlignment="center" |
||||
|
decelerationRate="fast" |
||||
|
contentOffset={{ x: 0, y: idx * ITEM_H }} |
||||
|
contentContainerStyle={{ paddingVertical: ITEM_H }} |
||||
|
onMomentumScrollEnd={handleEnd} |
||||
|
> |
||||
|
{items.map((it) => ( |
||||
|
<View key={it} style={styles.item}> |
||||
|
<Text style={[styles.itemText, it === value && styles.itemTextActive]}>{it}</Text> |
||||
|
</View> |
||||
|
))} |
||||
|
</ScrollView> |
||||
|
</View> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
interface DualProps { |
||||
|
year: string; |
||||
|
month: string; |
||||
|
onYearChange: (v: string) => void; |
||||
|
onMonthChange: (v: string) => void; |
||||
|
} |
||||
|
|
||||
|
/** 年月双滚轮:用于出生年月 / 首次参加工作 / 入职时间 */ |
||||
|
export function YearMonthWheel({ year, month, onYearChange, onMonthChange }: DualProps) { |
||||
|
const years = Array.from({ length: 40 }, (_, i) => `${2026 - i}年`); |
||||
|
const months = Array.from({ length: 12 }, (_, i) => `${i + 1}月`); |
||||
|
return ( |
||||
|
<View style={styles.dual}> |
||||
|
<Column items={years} value={year} onValueChange={onYearChange} width={130} /> |
||||
|
<Column items={months} value={month} onValueChange={onMonthChange} width={110} /> |
||||
|
</View> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
col: { height: ITEM_H * 3, position: 'relative' }, |
||||
|
scroll: { flex: 1 }, |
||||
|
highlight: { |
||||
|
position: 'absolute', |
||||
|
top: ITEM_H, |
||||
|
left: 0, |
||||
|
right: 0, |
||||
|
height: ITEM_H, |
||||
|
borderTopWidth: 1, |
||||
|
borderBottomWidth: 1, |
||||
|
borderColor: colors.border, |
||||
|
backgroundColor: colors.primaryLight, |
||||
|
opacity: 0.45, |
||||
|
}, |
||||
|
item: { height: ITEM_H, alignItems: 'center', justifyContent: 'center' }, |
||||
|
itemText: { fontSize: 18, color: colors.textHint }, |
||||
|
itemTextActive: { fontSize: 20, fontWeight: '700', color: colors.textPrimary }, |
||||
|
dual: { flexDirection: 'row', justifyContent: 'center', gap: 14 }, |
||||
|
}); |
||||
@ -0,0 +1,133 @@ |
|||||
|
import type { ReactNode } from 'react'; |
||||
|
import { createContext, useContext, useMemo, useState } from 'react'; |
||||
|
import type { AssetName } from '../../components/onboarding/assets'; |
||||
|
|
||||
|
/** 注册全流程收集的数据模型 */ |
||||
|
export interface OnboardingData { |
||||
|
phone: string; |
||||
|
code: string; |
||||
|
agreed: boolean; |
||||
|
identity: 'candidate' | 'recruiter' | null; |
||||
|
isStudent: boolean | null; |
||||
|
city: string; |
||||
|
expectedPositions: string[]; |
||||
|
expectedSalaryMin: string; |
||||
|
expectedSalaryMax: string; |
||||
|
resumeName: string; |
||||
|
gender: '女' | '男' | null; |
||||
|
birthYear: string; |
||||
|
birthMonth: string; |
||||
|
jobStatus: string | null; |
||||
|
disabledJobseeker: boolean; |
||||
|
firstWorkYear: string; |
||||
|
firstWorkMonth: string; |
||||
|
currentPosition: string | null; |
||||
|
company: string; |
||||
|
joinYear: string; |
||||
|
joinMonth: string; |
||||
|
skills: string[]; |
||||
|
workContent: string; |
||||
|
education: string | null; |
||||
|
eduType: '全日制' | '非全日制' | null; |
||||
|
school: string; |
||||
|
avatar: AssetName | null; |
||||
|
recruiterCompany: string; |
||||
|
} |
||||
|
|
||||
|
const INITIAL: OnboardingData = { |
||||
|
phone: '', |
||||
|
code: '', |
||||
|
agreed: true, |
||||
|
identity: null, |
||||
|
isStudent: null, |
||||
|
city: '上海', |
||||
|
expectedPositions: ['前端开发工程师', '鸿蒙开发工程师', '全栈工程师'], |
||||
|
expectedSalaryMin: '10K', |
||||
|
expectedSalaryMax: '12K', |
||||
|
resumeName: '杨幸幸', |
||||
|
gender: '女', |
||||
|
birthYear: '2004年', |
||||
|
birthMonth: '12月', |
||||
|
jobStatus: '在职-月内到岗', |
||||
|
disabledJobseeker: false, |
||||
|
firstWorkYear: '2026年', |
||||
|
firstWorkMonth: '4月', |
||||
|
currentPosition: null, |
||||
|
company: '巴适伙伴(上海)科技有限公司', |
||||
|
joinYear: '2026年', |
||||
|
joinMonth: '7月', |
||||
|
skills: [], |
||||
|
workContent: '', |
||||
|
education: '本科', |
||||
|
eduType: '全日制', |
||||
|
school: '', |
||||
|
avatar: null, |
||||
|
recruiterCompany: '', |
||||
|
}; |
||||
|
|
||||
|
interface Ctx { |
||||
|
data: OnboardingData; |
||||
|
setData: (partial: Partial<OnboardingData>) => void; |
||||
|
current: string; |
||||
|
navigate: (screen: string) => void; |
||||
|
back: () => void; |
||||
|
canGoBack: boolean; |
||||
|
/** 两个保存出口:收集当前信息后直接进 P23,剩余资料后续补充 */ |
||||
|
saveAndEnter: () => void; |
||||
|
reset: () => void; |
||||
|
meta: { time: string; battery: number }; |
||||
|
} |
||||
|
|
||||
|
const OnboardingContext = createContext<Ctx | null>(null); |
||||
|
|
||||
|
export function OnboardingProvider({ children }: { children: ReactNode }) { |
||||
|
const [data, setDataState] = useState<OnboardingData>(INITIAL); |
||||
|
const [stack, setStack] = useState<string[]>([]); |
||||
|
const [current, setCurrent] = useState<string>('P1'); |
||||
|
|
||||
|
const value = useMemo<Ctx>(() => { |
||||
|
const setData = (partial: Partial<OnboardingData>) => |
||||
|
setDataState((prev) => ({ ...prev, ...partial })); |
||||
|
const navigate = (screen: string) => { |
||||
|
setStack((s) => [...s, current]); |
||||
|
setCurrent(screen); |
||||
|
}; |
||||
|
const back = () => { |
||||
|
setStack((s) => { |
||||
|
if (s.length === 0) return s; |
||||
|
const prev = s[s.length - 1]; |
||||
|
setCurrent(prev); |
||||
|
return s.slice(0, -1); |
||||
|
}); |
||||
|
}; |
||||
|
const saveAndEnter = () => { |
||||
|
setStack([]); |
||||
|
setCurrent('P23'); |
||||
|
}; |
||||
|
const reset = () => { |
||||
|
setDataState(INITIAL); |
||||
|
setStack([]); |
||||
|
setCurrent('P1'); |
||||
|
}; |
||||
|
return { |
||||
|
data, |
||||
|
setData, |
||||
|
current, |
||||
|
navigate, |
||||
|
back, |
||||
|
canGoBack: stack.length > 0, |
||||
|
saveAndEnter, |
||||
|
reset, |
||||
|
// 已停用:原假状态栏时间线演示数据,现统一使用系统真实状态栏,保留字段仅为兼容旧调用
|
||||
|
meta: { time: '', battery: 0 }, |
||||
|
}; |
||||
|
}, [data, stack, current]); |
||||
|
|
||||
|
return <OnboardingContext.Provider value={value}>{children}</OnboardingContext.Provider>; |
||||
|
} |
||||
|
|
||||
|
export function useOnboarding(): Ctx { |
||||
|
const ctx = useContext(OnboardingContext); |
||||
|
if (!ctx) throw new Error('useOnboarding 必须在 OnboardingProvider 内使用'); |
||||
|
return ctx; |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
import { useRouter } from 'expo-router'; |
||||
|
import { useAuth } from '../context/AuthContext'; |
||||
|
import { useRole } from '../context/RoleContext'; |
||||
|
import type { AppRole } from '../types'; |
||||
|
|
||||
|
/** |
||||
|
* 注册完成后的真实落地:写入登录态与身份,跳转对应端主界面。 |
||||
|
* 真实后端就绪后,login 的 token 替换为接口返回; |
||||
|
* setRole 用于让 index.tsx 漏斗放行,避免注册完成又被弹回注册。 |
||||
|
*/ |
||||
|
export function useRegisterFinish() { |
||||
|
const router = useRouter(); |
||||
|
const { login } = useAuth(); |
||||
|
const { setRole } = useRole(); |
||||
|
|
||||
|
return (role: AppRole) => { |
||||
|
setRole(role); |
||||
|
login(`mock-token-${Date.now()}`); |
||||
|
router.replace(role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs'); |
||||
|
}; |
||||
|
} |
||||
Loading…
Reference in new issue