Compare commits

...

3 Commits

Author SHA1 Message Date
doublex001 731b4c9bac feat(onboarding): 品牌更名「有职」并重构注册引导流程 2 months ago
doublex001 6637c0ef8c fix(ui): 清理页面空交互,接真实逻辑与 Alert 兜底 2 months ago
doublex001 0d4ee5b87c fix(navigation): 修正双端(端口)切换的角色与入口问题 2 months ago
  1. 4
      AGENTS.md
  2. 6
      app.config.js
  3. 13
      app/(enterprise)/_layout.tsx
  4. 73
      app/(enterprise)/enterprise-overview.tsx
  5. 11
      app/(enterprise)/job-management.tsx
  6. 13
      app/(tabs)/_layout.tsx
  7. 17
      app/(tabs)/profile.tsx
  8. 4
      app/_layout.tsx
  9. 13
      app/chat/[id].tsx
  10. 4
      app/guide.tsx
  11. 217
      app/identity-select.tsx
  12. 4
      app/index.tsx
  13. 11
      app/jobs/[id].tsx
  14. 264
      app/login.tsx
  15. 5
      app/personal-data-export.tsx
  16. 67
      app/register.tsx
  17. 22
      app/settings.tsx
  18. 3
      app/settings/account-cancel.tsx
  19. 4
      app/settings/privacy.tsx
  20. 7
      app/tools/salary-calculator.tsx
  21. BIN
      assets/brand-logo.jpg
  22. 44
      components/onboarding/assets.ts
  23. 41
      components/onboarding/screens/B1Wechat.tsx
  24. 38
      components/onboarding/screens/B2Agreement.tsx
  25. 84
      components/onboarding/screens/B3Recruiter.tsx
  26. 46
      components/onboarding/screens/P10FirstWork.tsx
  27. 71
      components/onboarding/screens/P11CurrentPosition.tsx
  28. 43
      components/onboarding/screens/P12Company.tsx
  29. 46
      components/onboarding/screens/P13JoinDate.tsx
  30. 78
      components/onboarding/screens/P14Skills.tsx
  31. 64
      components/onboarding/screens/P16WorkContent.tsx
  32. 91
      components/onboarding/screens/P17Reference.tsx
  33. 55
      components/onboarding/screens/P18Education.tsx
  34. 143
      components/onboarding/screens/P1Login.tsx
  35. 29
      components/onboarding/screens/P20School.tsx
  36. 77
      components/onboarding/screens/P21Avatar.tsx
  37. 44
      components/onboarding/screens/P22Done.tsx
  38. 181
      components/onboarding/screens/P23Home.tsx
  39. 212
      components/onboarding/screens/P2PhoneCode.tsx
  40. 111
      components/onboarding/screens/P2QuickLogin.tsx
  41. 67
      components/onboarding/screens/P3Identity.tsx
  42. 58
      components/onboarding/screens/P4Intent.tsx
  43. 68
      components/onboarding/screens/P5City.tsx
  44. 121
      components/onboarding/screens/P6ExpectedPosition.tsx
  45. 70
      components/onboarding/screens/P7Salary.tsx
  46. 74
      components/onboarding/screens/P8Resume1.tsx
  47. 65
      components/onboarding/screens/P9JobStatus.tsx
  48. 37
      components/onboarding/ui/AgreementCheckbox.tsx
  49. 53
      components/onboarding/ui/AgreementModal.tsx
  50. 31
      components/onboarding/ui/Avatar.tsx
  51. 20
      components/onboarding/ui/BrandHeader.tsx
  52. 54
      components/onboarding/ui/CountdownButton.tsx
  53. 37
      components/onboarding/ui/Illustration.tsx
  54. 41
      components/onboarding/ui/Modal.tsx
  55. 29
      components/onboarding/ui/PrimaryButton.tsx
  56. 54
      components/onboarding/ui/RadioRow.tsx
  57. 53
      components/onboarding/ui/ScreenShell.tsx
  58. 46
      components/onboarding/ui/SearchInput.tsx
  59. 39
      components/onboarding/ui/Tag.tsx
  60. 87
      components/onboarding/ui/WheelPicker.tsx
  61. 4
      screenshots/README.md
  62. 4
      screenshots/capture-manifest.json
  63. 18
      src/api/api.ts
  64. 2
      src/hooks/index.ts
  65. 133
      src/hooks/useOnboardingFlow.tsx
  66. 21
      src/hooks/useRegisterFinish.ts
  67. 4
      src/lib/storage.ts
  68. 6
      src/types/tools.ts

4
AGENTS.md

@ -10,8 +10,8 @@ Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before
【核心原则】 【核心原则】
做任务前,先检查问题有没有错误前提、逻辑跳跃和信息缺失。 不要迎合我,要独立判断,区分事实、预测和主观观点。 涉及数字、人物和结论时尽量核实来源。 不同意就直接指出并给出依据、风险和替代解释。 还要主动提醒我忽略的变量、成本和偏差。 做任务前,先检查问题有没有错误前提、逻辑跳跃和信息缺失。 不要迎合我,要独立判断,区分事实、预测和主观观点。 涉及数字、人物和结论时尽量核实来源。 不同意就直接指出并给出依据、风险和替代解释。 还要主动提醒我忽略的变量、成本和偏差。
请在每周的周一-周五的9:10的时候拉取一次、每天17:50提交一次git仓库,说明今天新增与改动的内容,上班时间是9:00-18:00,有页面就一定要有入口 仓库:https://gitea.baskbuddy.com/baskbuddy/mydreamjob
请在每周的周一-周五的9:20的时候拉取一次远端仓库、每天17:50提交一次git仓库,其他时间可以改动但不需要提交,说明今天新增与改动的内容,上班时间是9:00-18:00,有页面出口就一定要有入口。
业务逻辑分层管理,不是没有,而是被妥善组织: 业务逻辑分层管理,不是没有,而是被妥善组织:
+------------------+--------------------------------------------------+----------------------------------------------------------------------------------------+ +------------------+--------------------------------------------------+----------------------------------------------------------------------------------------+
| 层级 | 职责 | 业务逻辑示例 | | 层级 | 职责 | 业务逻辑示例 |

6
app.config.js

@ -10,9 +10,9 @@
*/ */
export default { export default {
expo: { expo: {
name: 'mydreamjob', name: '有职',
slug: 'mydreamjob', slug: 'youzhi',
scheme: 'mydreamjob', scheme: 'youzhi',
version: '1.0.0', version: '1.0.0',
orientation: 'portrait', orientation: 'portrait',
icon: './assets/icon.png', icon: './assets/icon.png',

13
app/(enterprise)/_layout.tsx

@ -1,7 +1,8 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Tabs } from 'expo-router/js-tabs'; import { Tabs } from 'expo-router/js-tabs';
import type { BottomTabBarProps } from 'expo-router/js-tabs'; import type { BottomTabBarProps } from 'expo-router/js-tabs';
import { useEffect, useRef } from 'react'; import { useFocusEffect } from 'expo-router';
import { useCallback, useEffect, useRef } from 'react';
import { Animated, Easing, Pressable, StyleSheet, View } from 'react-native'; import { Animated, Easing, Pressable, StyleSheet, View } from 'react-native';
import { colors } from '../../src/theme'; import { colors } from '../../src/theme';
import { useRole } from '../../src/context/RoleContext'; import { useRole } from '../../src/context/RoleContext';
@ -142,9 +143,13 @@ function TabBar({ state, descriptors, navigation, insets }: BottomTabBarProps) {
export default function EnterpriseTabLayout() { export default function EnterpriseTabLayout() {
const { setRole } = useRole(); const { setRole } = useRole();
useEffect(() => { // 用 useFocusEffect:进入/返回该端时随焦点写入角色,
setRole('enterprise'); // 避免「切换到企业端」后返回时 layout 未重新挂载导致 role 残留 candidate。
}, [setRole]); useFocusEffect(
useCallback(() => {
setRole('enterprise');
}, [setRole]),
);
return ( return (
<Tabs <Tabs

73
app/(enterprise)/enterprise-overview.tsx

@ -11,6 +11,7 @@ import {
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useEnterpriseOverview } from '../../src/hooks'; import { useEnterpriseOverview } from '../../src/hooks';
import { useRole } from '../../src/context/RoleContext';
import { colors, headerGradient } from '../../src/theme'; import { colors, headerGradient } from '../../src/theme';
import type { EnterpriseOverview, EnterpriseQuickAction, EnterpriseStat, JobEvaluationSummary, MenuItem } from '../../src/types'; import type { EnterpriseOverview, EnterpriseQuickAction, EnterpriseStat, JobEvaluationSummary, MenuItem } from '../../src/types';
@ -223,6 +224,36 @@ function MenuList({ items }: { items: MenuItem[] }) {
); );
} }
/* ===================== 子组件:切换到求职端卡片 ===================== */
/**
* 企业端「我的」页回切求职端,与求职端「切换到企业端」卡片对称,
* 避免经「设置→身份切换」进入企业端后返回栈为空、回不去求职端。
*/
function CandidateEntryCard() {
const router = useRouter();
const { setRole } = useRole();
return (
<Pressable
style={styles.candidateCard}
onPress={() => {
setRole('candidate');
router.replace('/(tabs)/jobs');
}}
hitSlop={4}
>
<View style={styles.candidateIconWrap}>
<Ionicons name="person-outline" size={22} color={colors.primary} />
</View>
<View style={styles.candidateText}>
<Text style={styles.candidateTitle}>切换到求职端</Text>
<Text style={styles.candidateDesc}>返回求职者端,浏览岗位与你的竞争力数据</Text>
</View>
<Text style={styles.candidateAction}>去求职端 →</Text>
</Pressable>
);
}
/* ===================== 页面主组件 ===================== */ /* ===================== 页面主组件 ===================== */
export default function EnterpriseOverviewScreen() { export default function EnterpriseOverviewScreen() {
@ -254,6 +285,7 @@ export default function EnterpriseOverviewScreen() {
</SafeAreaView> </SafeAreaView>
</LinearGradient> </LinearGradient>
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}> <ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
<CandidateEntryCard />
<StatGrid stats={overview.stats} /> <StatGrid stats={overview.stats} />
<QuickActions actions={overview.quickActions} /> <QuickActions actions={overview.quickActions} />
<JobEvaluationList jobs={overview.jobEvaluations} /> <JobEvaluationList jobs={overview.jobEvaluations} />
@ -520,4 +552,45 @@ const styles = StyleSheet.create({
fontWeight: '500', fontWeight: '500',
color: colors.textPrimary, color: colors.textPrimary,
}, },
candidateCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.white,
borderRadius: 16,
padding: 16,
marginBottom: 16,
shadowColor: colors.shadow,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.04,
shadowRadius: 6,
elevation: 2,
},
candidateIconWrap: {
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: colors.primaryLight,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
candidateText: {
flex: 1,
},
candidateTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
marginBottom: 2,
},
candidateDesc: {
fontSize: 12,
color: colors.textSecondary,
},
candidateAction: {
fontSize: 13,
fontWeight: '600',
color: colors.primary,
marginLeft: 8,
},
}); });

11
app/(enterprise)/job-management.tsx

@ -3,6 +3,7 @@ import { useRouter } from 'expo-router';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@ -117,11 +118,11 @@ function SearchFilterBar() {
placeholderTextColor={colors.textHint} placeholderTextColor={colors.textHint}
/> />
</View> </View>
<Pressable style={styles.filterBtn} onPress={() => {}}> <Pressable style={styles.filterBtn} onPress={() => Alert.alert('筛选', '岗位筛选功能开发中,敬请期待')}>
<Ionicons name="funnel-outline" size={18} color={colors.textPrimary} /> <Ionicons name="funnel-outline" size={18} color={colors.textPrimary} />
<Text style={styles.filterBtnText}>筛选</Text> <Text style={styles.filterBtnText}>筛选</Text>
</Pressable> </Pressable>
<Pressable style={styles.sortBtn} onPress={() => {}}> <Pressable style={styles.sortBtn} onPress={() => Alert.alert('排序', '岗位排序功能开发中,敬请期待')}>
<Text style={styles.sortBtnText}>最新发布</Text> <Text style={styles.sortBtnText}>最新发布</Text>
<Ionicons name="chevron-down" size={14} color={colors.textHint} /> <Ionicons name="chevron-down" size={14} color={colors.textHint} />
</Pressable> </Pressable>
@ -178,15 +179,15 @@ function JobCard({ job }: { job: JobManagementJob }) {
</View> </View>
<View style={styles.jobActions}> <View style={styles.jobActions}>
<Pressable style={styles.jobActionBtn} onPress={() => {}}> <Pressable style={styles.jobActionBtn} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}>
<Ionicons name="create-outline" size={16} color={colors.primary} /> <Ionicons name="create-outline" size={16} color={colors.primary} />
<Text style={styles.jobActionText}>编辑</Text> <Text style={styles.jobActionText}>编辑</Text>
</Pressable> </Pressable>
<Pressable style={styles.jobActionBtn} onPress={() => {}}> <Pressable style={styles.jobActionBtn} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}>
<Ionicons name="refresh-outline" size={16} color={colors.primary} /> <Ionicons name="refresh-outline" size={16} color={colors.primary} />
<Text style={styles.jobActionText}>刷新</Text> <Text style={styles.jobActionText}>刷新</Text>
</Pressable> </Pressable>
<Pressable style={styles.jobActionBtn} onPress={() => {}}> <Pressable style={styles.jobActionBtn} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}>
<Ionicons name="download-outline" size={16} color={colors.primary} /> <Ionicons name="download-outline" size={16} color={colors.primary} />
<Text style={styles.jobActionText}>下架</Text> <Text style={styles.jobActionText}>下架</Text>
</Pressable> </Pressable>

13
app/(tabs)/_layout.tsx

@ -1,7 +1,8 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Tabs } from 'expo-router/js-tabs'; import { Tabs } from 'expo-router/js-tabs';
import type { BottomTabBarProps } from 'expo-router/js-tabs'; import type { BottomTabBarProps } from 'expo-router/js-tabs';
import { useEffect, useRef } from 'react'; import { useFocusEffect } from 'expo-router';
import { useCallback, useEffect, useRef } from 'react';
import { Animated, Easing, Pressable, StyleSheet, View } from 'react-native'; import { Animated, Easing, Pressable, StyleSheet, View } from 'react-native';
import { colors } from '../../src/theme'; import { colors } from '../../src/theme';
import { useRole } from '../../src/context/RoleContext'; import { useRole } from '../../src/context/RoleContext';
@ -164,9 +165,13 @@ function TabBar({ state, descriptors, navigation, insets }: BottomTabBarProps) {
export default function TabLayout() { export default function TabLayout() {
const { setRole } = useRole(); const { setRole } = useRole();
useEffect(() => { // 用 useFocusEffect:进入/返回该端时随焦点写入角色,
setRole('candidate'); // 避免「从企业端返回求职端」时 layout 未重新挂载导致 role 残留 enterprise。
}, [setRole]); useFocusEffect(
useCallback(() => {
setRole('candidate');
}, [setRole]),
);
return ( return (
<Tabs <Tabs

17
app/(tabs)/profile.tsx

@ -3,6 +3,7 @@ import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@ -149,8 +150,16 @@ function StatsRow({ stats }: { stats: UserStats }) {
/** 切换到企业端卡片 */ /** 切换到企业端卡片 */
function EnterpriseEntryCard({ entry }: { entry: EnterpriseEntry }) { function EnterpriseEntryCard({ entry }: { entry: EnterpriseEntry }) {
const router = useRouter(); const router = useRouter();
const { setRole } = useRole();
return ( return (
<Pressable style={styles.enterpriseCard} onPress={() => router.push('/enterprise-overview')}> <Pressable
style={styles.enterpriseCard}
onPress={() => {
setRole('enterprise');
router.push('/enterprise-overview');
}}
hitSlop={4}
>
<View style={styles.enterpriseIconWrap}> <View style={styles.enterpriseIconWrap}>
<Ionicons name="business-outline" size={22} color={colors.primary} /> <Ionicons name="business-outline" size={22} color={colors.primary} />
</View> </View>
@ -263,7 +272,7 @@ export default function ProfileScreen() {
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<View style={styles.sectionBar} /> <View style={styles.sectionBar} />
<Text style={[styles.sectionTitle, styles.sectionTitleGrow]}>八大维度评分</Text> <Text style={[styles.sectionTitle, styles.sectionTitleGrow]}>八大维度评分</Text>
<Pressable onPress={() => {}} hitSlop={6}> <Pressable onPress={() => Alert.alert('评分申诉', '评分申诉功能开发中,敬请期待')} hitSlop={6}>
<Text style={styles.sectionLink}>评分申诉</Text> <Text style={styles.sectionLink}>评分申诉</Text>
</Pressable> </Pressable>
</View> </View>
@ -277,7 +286,7 @@ export default function ProfileScreen() {
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<View style={styles.sectionBar} /> <View style={styles.sectionBar} />
<Text style={[styles.sectionTitle, styles.sectionTitleGrow]}>求职偏好</Text> <Text style={[styles.sectionTitle, styles.sectionTitleGrow]}>求职偏好</Text>
<Pressable onPress={() => {}} hitSlop={6}> <Pressable onPress={() => router.push('/job-preference')} hitSlop={6}>
<Text style={styles.sectionLink}>编辑</Text> <Text style={styles.sectionLink}>编辑</Text>
</Pressable> </Pressable>
</View> </View>
@ -290,7 +299,7 @@ export default function ProfileScreen() {
<Pressable <Pressable
key={key} key={key}
style={[styles.prefRow, isLast && styles.prefRowLast]} style={[styles.prefRow, isLast && styles.prefRowLast]}
onPress={() => {}} onPress={() => router.push('/job-preference')}
> >
<Text style={styles.prefLabel}>{label}</Text> <Text style={styles.prefLabel}>{label}</Text>
{isStatus ? ( {isStatus ? (

4
app/_layout.tsx

@ -14,8 +14,8 @@ export default function RootLayout() {
<Stack> <Stack>
<Stack.Screen name="index" options={{ headerShown: false }} /> <Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="guide" options={{ headerShown: false }} /> <Stack.Screen name="guide" options={{ headerShown: false }} />
<Stack.Screen name="login" options={{ headerShown: false }} /> <Stack.Screen name="register" options={{ headerShown: false }} />
<Stack.Screen name="identity-select" options={{ title: '选择身份' }} /> <Stack.Screen name="onboarding" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="(enterprise)" options={{ headerShown: false }} /> <Stack.Screen name="(enterprise)" options={{ headerShown: false }} />
<Stack.Screen name="jobs/[id]" options={{ headerShown: false }} /> <Stack.Screen name="jobs/[id]" options={{ headerShown: false }} />

13
app/chat/[id].tsx

@ -3,6 +3,7 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
Pressable, Pressable,
@ -148,7 +149,7 @@ function InterviewCard({ card }: { card: ChatCard }) {
</View> </View>
))} ))}
</View> </View>
<Pressable style={styles.cardAction} onPress={() => {}}> <Pressable style={styles.cardAction} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}>
<Text style={styles.cardActionText}>{card.actionText}</Text> <Text style={styles.cardActionText}>{card.actionText}</Text>
<Ionicons name="chevron-forward" size={14} color={colors.primary} /> <Ionicons name="chevron-forward" size={14} color={colors.primary} />
</Pressable> </Pressable>
@ -166,7 +167,7 @@ function SalaryCard({ card }: { card: ChatCard }) {
{card.subtitle && ( {card.subtitle && (
<Text style={styles.cardSubtitle}>{card.subtitle}</Text> <Text style={styles.cardSubtitle}>{card.subtitle}</Text>
)} )}
<Pressable style={styles.cardAction} onPress={() => {}}> <Pressable style={styles.cardAction} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}>
<Text style={styles.cardActionText}>{card.actionText}</Text> <Text style={styles.cardActionText}>{card.actionText}</Text>
<Ionicons name="chevron-forward" size={14} color={colors.primary} /> <Ionicons name="chevron-forward" size={14} color={colors.primary} />
</Pressable> </Pressable>
@ -266,13 +267,13 @@ function InputBar({
return ( return (
<View style={styles.inputBar}> <View style={styles.inputBar}>
<View style={styles.inputLeftActions}> <View style={styles.inputLeftActions}>
<Pressable style={styles.inputIconBtn} onPress={() => {}}> <Pressable style={styles.inputIconBtn} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}>
<Ionicons name="happy-outline" size={24} color={colors.textSecondary} /> <Ionicons name="happy-outline" size={24} color={colors.textSecondary} />
</Pressable> </Pressable>
<Pressable style={styles.inputIconBtn} onPress={() => {}}> <Pressable style={styles.inputIconBtn} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}>
<Ionicons name="image-outline" size={24} color={colors.textSecondary} /> <Ionicons name="image-outline" size={24} color={colors.textSecondary} />
</Pressable> </Pressable>
<Pressable style={styles.inputIconBtn} onPress={() => {}}> <Pressable style={styles.inputIconBtn} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}>
<Ionicons name="folder-outline" size={24} color={colors.textSecondary} /> <Ionicons name="folder-outline" size={24} color={colors.textSecondary} />
</Pressable> </Pressable>
</View> </View>
@ -288,7 +289,7 @@ function InputBar({
maxLength={500} maxLength={500}
editable={!sending} editable={!sending}
/> />
<Pressable style={styles.plusBtn} onPress={() => {}}> <Pressable style={styles.plusBtn} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}>
<Ionicons name="add" size={22} color={colors.textSecondary} /> <Ionicons name="add" size={22} color={colors.textSecondary} />
</Pressable> </Pressable>
</View> </View>

4
app/guide.tsx

@ -20,7 +20,7 @@ interface GuideSlide {
desc: string; desc: string;
} }
/** 引导页内容:聚焦职来四大差异化能力 */ /** 引导页内容:聚焦有职四大差异化能力 */
const SLIDES: GuideSlide[] = [ const SLIDES: GuideSlide[] = [
{ {
icon: 'compass-outline', icon: 'compass-outline',
@ -55,7 +55,7 @@ export default function GuideScreen() {
const finish = () => { const finish = () => {
completeOnboarding(); completeOnboarding();
router.replace('/login'); router.replace('/register');
}; };
const goNext = () => { const goNext = () => {

217
app/identity-select.tsx

@ -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 },
});

4
app/index.tsx

@ -14,10 +14,10 @@ export default function Index() {
return <Redirect href="/guide" />; return <Redirect href="/guide" />;
} }
if (!isLoggedIn) { if (!isLoggedIn) {
return <Redirect href="/login" />; return <Redirect href="/register" />;
} }
if (!role) { if (!role) {
return <Redirect href="/identity-select" />; return <Redirect href="/register" />;
} }
// 两个路由组均无 index,必须用显式落地路由,否则落地页空白 // 两个路由组均无 index,必须用显式落地路由,否则落地页空白
return ( return (

11
app/jobs/[id].tsx

@ -3,6 +3,7 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@ -112,7 +113,7 @@ function Header({ onBack }: { onBack: () => void }) {
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} /> <Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable> </Pressable>
<Text style={styles.headerTitle}>岗位详情</Text> <Text style={styles.headerTitle}>岗位详情</Text>
<Pressable style={styles.headerBtn} onPress={() => {}} hitSlop={8}> <Pressable style={styles.headerBtn} onPress={() => Alert.alert('分享', '分享功能开发中,敬请期待')} hitSlop={8}>
<Ionicons name="share-social-outline" size={22} color={colors.textPrimary} /> <Ionicons name="share-social-outline" size={22} color={colors.textPrimary} />
</Pressable> </Pressable>
</View> </View>
@ -216,7 +217,7 @@ export default function JobDetailScreen() {
</View> </View>
{/* 2. 岗位综合评分 */} {/* 2. 岗位综合评分 */}
<SectionTitle title="岗位综合评分" link="评分说明" onLink={() => {}} /> <SectionTitle title="岗位综合评分" link="评分说明" onLink={() => Alert.alert('评分说明', '评分规则说明开发中,敬请期待')} />
<View style={styles.card}> <View style={styles.card}>
<View style={styles.scoreRow}> <View style={styles.scoreRow}>
<RadarChart dimensions={overallScore.dimensions} /> <RadarChart dimensions={overallScore.dimensions} />
@ -231,14 +232,14 @@ export default function JobDetailScreen() {
<Text style={styles.scoreRank}>{overallScore.rank}</Text> <Text style={styles.scoreRank}>{overallScore.rank}</Text>
</View> </View>
</View> </View>
<Pressable style={styles.compareBtn} onPress={() => {}} hitSlop={6}> <Pressable style={styles.compareBtn} onPress={() => router.push('/jobs/compare')} hitSlop={6}>
<Ionicons name="add-circle-outline" size={16} color={colors.primary} /> <Ionicons name="add-circle-outline" size={16} color={colors.primary} />
<Text style={styles.compareText}>加入对比</Text> <Text style={styles.compareText}>加入对比</Text>
</Pressable> </Pressable>
</View> </View>
{/* 3. 真实薪资区间 */} {/* 3. 真实薪资区间 */}
<SectionTitle title="真实薪资区间" link="全部" onLink={() => {}} /> <SectionTitle title="真实薪资区间" link="全部" onLink={() => Alert.alert('薪资详情', '完整薪资数据开发中,敬请期待')} />
<View style={styles.card}> <View style={styles.card}>
{salaryData.ranges.map((range, index) => ( {salaryData.ranges.map((range, index) => (
<View <View
@ -284,7 +285,7 @@ export default function JobDetailScreen() {
value={comment} value={comment}
onChangeText={setComment} onChangeText={setComment}
/> />
<Pressable style={styles.publishBtn} onPress={() => {}}> <Pressable style={styles.publishBtn} onPress={() => Alert.alert('发布评论', '评论发布功能开发中,敬请期待')}>
<Text style={styles.publishText}>发布</Text> <Text style={styles.publishText}>发布</Text>
</Pressable> </Pressable>
</View> </View>

264
app/login.tsx

@ -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 },
});

5
app/personal-data-export.tsx

@ -2,6 +2,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { import {
Alert,
ActivityIndicator, ActivityIndicator,
Pressable, Pressable,
ScrollView, ScrollView,
@ -346,11 +347,11 @@ export default function PersonalDataExportScreen() {
const isLast = index === exportData.recentExports.length - 1; const isLast = index === exportData.recentExports.length - 1;
return ( return (
<View key={record.id} style={isLast ? undefined : styles.rowBorder}> <View key={record.id} style={isLast ? undefined : styles.rowBorder}>
<RecentExportRow record={record} onDownload={() => {}} /> <RecentExportRow record={record} onDownload={() => Alert.alert('下载', '导出文件下载功能开发中,敬请期待')} />
</View> </View>
); );
})} })}
<Pressable style={styles.viewMoreRow} onPress={() => {}}> <Pressable style={styles.viewMoreRow} onPress={() => Alert.alert('提示', '历史导出功能开发中,敬请期待')}>
<Text style={styles.viewMoreText}>查看更多历史导出</Text> <Text style={styles.viewMoreText}>查看更多历史导出</Text>
<Ionicons name="chevron-forward" size={16} color={colors.textHint} /> <Ionicons name="chevron-forward" size={16} color={colors.textHint} />
</Pressable> </Pressable>

67
app/register.tsx

@ -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>
);
}

22
app/settings.tsx

@ -1,8 +1,9 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { colors } from '../src/theme'; import { colors } from '../src/theme';
import { useAuth } from '../src/context/AuthContext';
/* ===================== 设置项结构 ===================== */ /* ===================== 设置项结构 ===================== */
@ -37,7 +38,7 @@ const SETTINGS_GROUPS: SettingsGroup[] = [
id: 'identity', id: 'identity',
title: '身份切换', title: '身份切换',
icon: 'swap-horizontal-outline', icon: 'swap-horizontal-outline',
route: '/identity-select', route: '/register',
}, },
], ],
}, },
@ -90,6 +91,21 @@ function Header({ onBack }: { onBack: () => void }) {
export default function SettingsScreen() { export default function SettingsScreen() {
const router = useRouter(); const router = useRouter();
const { logout } = useAuth();
const handleLogout = () => {
Alert.alert('确认退出登录', '退出后需要重新登录', [
{ text: '取消', style: 'cancel' },
{
text: '退出',
style: 'destructive',
onPress: () => {
logout();
router.replace('/register');
},
},
]);
};
const handlePress = (item: SettingsItem) => { const handlePress = (item: SettingsItem) => {
if (item.route) { if (item.route) {
@ -134,7 +150,7 @@ export default function SettingsScreen() {
))} ))}
{/* 退出登录 */} {/* 退出登录 */}
<Pressable style={styles.logoutBtn} onPress={() => {}}> <Pressable style={styles.logoutBtn} onPress={handleLogout}>
<Text style={styles.logoutText}>退出登录</Text> <Text style={styles.logoutText}>退出登录</Text>
</Pressable> </Pressable>
</ScrollView> </ScrollView>

3
app/settings/account-cancel.tsx

@ -2,6 +2,7 @@ import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { import {
Alert,
ActivityIndicator, ActivityIndicator,
Pressable, Pressable,
ScrollView, ScrollView,
@ -128,7 +129,7 @@ export default function AccountCancelScreen() {
<View style={styles.rowBody}> <View style={styles.rowBody}>
<Text style={styles.rowTitle}>{item.label}</Text> <Text style={styles.rowTitle}>{item.label}</Text>
{item.actionText ? ( {item.actionText ? (
<Pressable onPress={() => {}}> <Pressable onPress={() => Alert.alert('提示', '该待办处理功能开发中,敬请期待')}>
<Text style={styles.actionText}>{item.actionText}</Text> <Text style={styles.actionText}>{item.actionText}</Text>
</Pressable> </Pressable>
) : null} ) : null}

4
app/settings/privacy.tsx

@ -1,6 +1,6 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native'; import { Alert, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { usePrivacyCenter } from '../../src/hooks'; import { usePrivacyCenter } from '../../src/hooks';
import { ErrorState, LoadingState } from '../../src/components/StateView'; import { ErrorState, LoadingState } from '../../src/components/StateView';
@ -41,7 +41,7 @@ function PrivacyLevelCard({ level }: { level: PrivacyLevelInfo }) {
<Text style={styles.levelDesc}>{level.description}</Text> <Text style={styles.levelDesc}>{level.description}</Text>
</View> </View>
</View> </View>
<Pressable style={styles.levelBtn} onPress={() => {}}> <Pressable style={styles.levelBtn} onPress={() => Alert.alert('隐私报告', '隐私报告功能开发中,敬请期待')}>
<Text style={[styles.levelBtnText, { color: theme.text }]}>查看隐私报告</Text> <Text style={[styles.levelBtnText, { color: theme.text }]}>查看隐私报告</Text>
<Ionicons name="chevron-forward" size={14} color={theme.text} /> <Ionicons name="chevron-forward" size={14} color={theme.text} />
</Pressable> </Pressable>

7
app/tools/salary-calculator.tsx

@ -3,6 +3,7 @@ import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { import {
Alert,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@ -232,7 +233,7 @@ export default function SalaryCalculatorScreen() {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onHistory={() => {}} /> <Header onBack={() => router.back()} onHistory={() => Alert.alert('历史记录', '计算历史功能开发中,敬请期待')} />
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
{/* 输入卡片 */} {/* 输入卡片 */}
<View style={styles.card}> <View style={styles.card}>
@ -243,7 +244,7 @@ export default function SalaryCalculatorScreen() {
suffix="元" suffix="元"
onChange={(v) => update('monthlyGross', Math.max(0, v))} onChange={(v) => update('monthlyGross', Math.max(0, v))}
/> />
<SelectRow label="所在城市" value={inputs.city} onPress={() => {}} /> <SelectRow label="所在城市" value={inputs.city} onPress={() => Alert.alert('选择城市', '城市选择功能开发中,敬请期待')} />
<NumberInput <NumberInput
label="社保基数" label="社保基数"
value={inputs.socialBase} value={inputs.socialBase}
@ -315,7 +316,7 @@ export default function SalaryCalculatorScreen() {
))} ))}
</View> </View>
<Pressable style={styles.saveBtn} onPress={() => {}}> <Pressable style={styles.saveBtn} onPress={() => Alert.alert('保存计算记录', '保存功能开发中,敬请期待')}>
<Text style={styles.saveBtnText}>保存计算记录</Text> <Text style={styles.saveBtnText}>保存计算记录</Text>
</Pressable> </Pressable>
</ScrollView> </ScrollView>

BIN
assets/brand-logo.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

44
components/onboarding/assets.ts

@ -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: '默认头像剪影' },
};

41
components/onboarding/screens/B1Wechat.tsx

@ -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 },
});

38
components/onboarding/screens/B2Agreement.tsx

@ -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 },
});

84
components/onboarding/screens/B3Recruiter.tsx

@ -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 },
});

46
components/onboarding/screens/P10FirstWork.tsx

@ -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 },
});

71
components/onboarding/screens/P11CurrentPosition.tsx

@ -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 },
});

43
components/onboarding/screens/P12Company.tsx

@ -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 },
});

46
components/onboarding/screens/P13JoinDate.tsx

@ -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 },
});

78
components/onboarding/screens/P14Skills.tsx

@ -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 },
});

64
components/onboarding/screens/P16WorkContent.tsx

@ -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 },
});

91
components/onboarding/screens/P17Reference.tsx

@ -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 },
});

55
components/onboarding/screens/P18Education.tsx

@ -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 },
});

143
components/onboarding/screens/P1Login.tsx

@ -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 },
});

29
components/onboarding/screens/P20School.tsx

@ -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 },
});

77
components/onboarding/screens/P21Avatar.tsx

@ -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 },
});

44
components/onboarding/screens/P22Done.tsx

@ -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 },
});

181
components/onboarding/screens/P23Home.tsx

@ -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' },
});

212
components/onboarding/screens/P2PhoneCode.tsx

@ -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 },
});

111
components/onboarding/screens/P2QuickLogin.tsx

@ -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 },
});

67
components/onboarding/screens/P3Identity.tsx

@ -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 },
});

58
components/onboarding/screens/P4Intent.tsx

@ -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 },
});

68
components/onboarding/screens/P5City.tsx

@ -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' },
});

121
components/onboarding/screens/P6ExpectedPosition.tsx

@ -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 },
});

70
components/onboarding/screens/P7Salary.tsx

@ -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 },
});

74
components/onboarding/screens/P8Resume1.tsx

@ -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 },
});

65
components/onboarding/screens/P9JobStatus.tsx

@ -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 },
});

37
components/onboarding/ui/AgreementCheckbox.tsx

@ -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 },
});

53
components/onboarding/ui/AgreementModal.tsx

@ -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 },
});

31
components/onboarding/ui/Avatar.tsx

@ -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 },
});

20
components/onboarding/ui/BrandHeader.tsx

@ -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 },
});

54
components/onboarding/ui/CountdownButton.tsx

@ -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 },
});

37
components/onboarding/ui/Illustration.tsx

@ -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' },
});

41
components/onboarding/ui/Modal.tsx

@ -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 },
});

29
components/onboarding/ui/PrimaryButton.tsx

@ -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 },
});

54
components/onboarding/ui/RadioRow.tsx

@ -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 },
});

53
components/onboarding/ui/ScreenShell.tsx

@ -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 },
});

46
components/onboarding/ui/SearchInput.tsx

@ -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 },
});

39
components/onboarding/ui/Tag.tsx

@ -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' },
});

87
components/onboarding/ui/WheelPicker.tsx

@ -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 },
});

4
screenshots/README.md

@ -1,6 +1,6 @@
# 职来 · App Store / Google Play 截图流水线 # 有职 · App Store / Google Play 截图流水线
本目录为「职来」App 的商店素材(App Store Connect + Google Play Console)准备区,遵循 本目录为「有职」App 的商店素材(App Store Connect + Google Play Console)准备区,遵循
[Expo App Store Screenshots](https://www.npmjs.com/package/expo-app-store-screenshots) 技能的目录与命名约定。 [Expo App Store Screenshots](https://www.npmjs.com/package/expo-app-store-screenshots) 技能的目录与命名约定。
## 目录结构(约定) ## 目录结构(约定)

4
screenshots/capture-manifest.json

@ -1,6 +1,6 @@
{ {
"project": "职来 (mydreamjob)", "project": "有职 (youzhi)",
"scheme": "mydreamjob", "scheme": "youzhi",
"locale": "zh-CN", "locale": "zh-CN",
"devices": ["iphone", "ipad", "android-phone"], "devices": ["iphone", "ipad", "android-phone"],
"targetSizes": { "targetSizes": {

18
src/api/api.ts

@ -575,7 +575,7 @@ const MOCK_ARTICLES: Article[] = [
views: 23000, views: 23000,
thumbnailColor: 'violet', thumbnailColor: 'violet',
icon: 'document-text-outline', icon: 'document-text-outline',
content: '简历往往是你与 HR 之间唯一的第一印象,而对方平均只花 6-10 秒扫读一份简历。核心原则只有三条:一页纸、结果导向、量化成就。\n\n先用「岗位关键词」反向定制:把 JD 里的高频能力词自然融入项目描述,让 ATS 筛选系统更容易命中。再套用 STAR 法则(情境-任务-行动-结果)拆解每段经历,例如「主导 X 项目,通过 Y 动作,带来 Z% 的留存提升」明显比「负责 X 项目」更有说服力。\n\n最后做减法:删掉与岗位无关的兴趣社团,把空间留给能证明你胜任的硬证据。职来「简历诊断」会基于行业评分模型给出逐项改进建议,投递前先自检一遍,能少走很多弯路。', content: '简历往往是你与 HR 之间唯一的第一印象,而对方平均只花 6-10 秒扫读一份简历。核心原则只有三条:一页纸、结果导向、量化成就。\n\n先用「岗位关键词」反向定制:把 JD 里的高频能力词自然融入项目描述,让 ATS 筛选系统更容易命中。再套用 STAR 法则(情境-任务-行动-结果)拆解每段经历,例如「主导 X 项目,通过 Y 动作,带来 Z% 的留存提升」明显比「负责 X 项目」更有说服力。\n\n最后做减法:删掉与岗位无关的兴趣社团,把空间留给能证明你胜任的硬证据。有职「简历诊断」会基于行业评分模型给出逐项改进建议,投递前先自检一遍,能少走很多弯路。',
}, },
{ {
id: 'art-2', id: 'art-2',
@ -584,7 +584,7 @@ const MOCK_ARTICLES: Article[] = [
views: 18000, views: 18000,
thumbnailColor: 'orange', thumbnailColor: 'orange',
icon: 'help-circle-outline', icon: 'help-circle-outline',
content: '面试不是考试,而是一次双向匹配。最容易翻车的 10 个问题集中在:自我介绍、离职原因、期望薪资、职业规划、自身缺点、压力情境、对公司的了解、对前东家的评价、到岗时间,以及最后的「你还有什么想问的」。\n\n高频雷区有三个:把离职原因说成吐槽前公司、在薪资上先亮底牌、用「我没什么缺点」来回避成长话题。正确姿势是用「事实加反思」替代情绪,把每个问题都变成展示你方法论的机会。\n\n最后一题「你有什么想问的」最容易被忽略,其实它是你反向评估公司是否靠谱的关键窗口——问团队节奏、问岗位成功标准、问业务下一步,比问福利更有价值。职来 AI 模拟面试可针对你的目标岗位做专项陪练。', content: '面试不是考试,而是一次双向匹配。最容易翻车的 10 个问题集中在:自我介绍、离职原因、期望薪资、职业规划、自身缺点、压力情境、对公司的了解、对前东家的评价、到岗时间,以及最后的「你还有什么想问的」。\n\n高频雷区有三个:把离职原因说成吐槽前公司、在薪资上先亮底牌、用「我没什么缺点」来回避成长话题。正确姿势是用「事实加反思」替代情绪,把每个问题都变成展示你方法论的机会。\n\n最后一题「你有什么想问的」最容易被忽略,其实它是你反向评估公司是否靠谱的关键窗口——问团队节奏、问岗位成功标准、问业务下一步,比问福利更有价值。有职 AI 模拟面试可针对你的目标岗位做专项陪练。',
}, },
{ {
id: 'art-3', id: 'art-3',
@ -593,7 +593,7 @@ const MOCK_ARTICLES: Article[] = [
views: 35000, views: 35000,
thumbnailColor: 'green', thumbnailColor: 'green',
icon: 'stats-chart-outline', icon: 'stats-chart-outline',
content: '薪资报告的价值不在数字本身,而在帮你建立谈薪的「锚点」与「区间」。2024 年互联网行业整体回归理性:中厂与垂直领域岗位增长稳定,纯补贴型高薪收缩,复合能力(业务加技术加沟通)溢价明显。\n\n看报告要学会三层拆解:一是按城市与年限看基线,二是按岗位序列看带宽(P25、P50、P75),三是评估自己的「可替代性」——越稀缺的能力,越能突破带宽上限。\n\n谈薪时不要只报一个数,给一个区间并说明依据(市场分位加你的独特价值),更容易落到上限。职来薪资计算器已内置城市与序列系数,可一键估算你的合理区间。', content: '薪资报告的价值不在数字本身,而在帮你建立谈薪的「锚点」与「区间」。2024 年互联网行业整体回归理性:中厂与垂直领域岗位增长稳定,纯补贴型高薪收缩,复合能力(业务加技术加沟通)溢价明显。\n\n看报告要学会三层拆解:一是按城市与年限看基线,二是按岗位序列看带宽(P25、P50、P75),三是评估自己的「可替代性」——越稀缺的能力,越能突破带宽上限。\n\n谈薪时不要只报一个数,给一个区间并说明依据(市场分位加你的独特价值),更容易落到上限。有职薪资计算器已内置城市与序列系数,可一键估算你的合理区间。',
}, },
]; ];
@ -3290,7 +3290,7 @@ const MOCK_IDENTITY_OPTIONS: IdentityOption[] = [
title: '我是企业 / HR', title: '我是企业 / HR',
desc: '发布岗位、筛选人才、管理评价、展示企业形象', desc: '发布岗位、筛选人才、管理评价、展示企业形象',
icon: 'briefcase-outline', icon: 'briefcase-outline',
role: 'hr', role: 'enterprise',
}, },
]; ];
@ -4377,7 +4377,7 @@ const MOCK_PRIVACY_POLICY: LegalDocument = {
updatedAt: '2026-08-31', updatedAt: '2026-08-31',
effectiveAt: '2026-08-31', effectiveAt: '2026-08-31',
preamble: preamble:
'职来(以下简称「我们」)深知个人信息对你的重要性,并会尽全力保护你的个人信息安全可靠。我们致力于维持你对我们的信任,恪守以下原则保护你的个人信息:权责一致原则、目的明确原则、选择同意原则、最少够用原则、确保安全原则、主体参与原则、公开透明原则。本政策适用于你通过职来平台(含移动客户端、网站及关联服务)所进行的全部活动。', '有职(以下简称「我们」)深知个人信息对你的重要性,并会尽全力保护你的个人信息安全可靠。我们致力于维持你对我们的信任,恪守以下原则保护你的个人信息:权责一致原则、目的明确原则、选择同意原则、最少够用原则、确保安全原则、主体参与原则、公开透明原则。本政策适用于你通过有职平台(含移动客户端、网站及关联服务)所进行的全部活动。',
sections: [ sections: [
{ {
heading: '一、我们收集的信息', heading: '一、我们收集的信息',
@ -4462,12 +4462,12 @@ const MOCK_USER_AGREEMENT: LegalDocument = {
updatedAt: '2026-08-31', updatedAt: '2026-08-31',
effectiveAt: '2026-08-31', effectiveAt: '2026-08-31',
preamble: preamble:
'本协议是你与职来平台运营方【待确认:公司全称】(以下简称「平台」)之间就使用职来平台服务所订立的协议。在使用平台各项服务前,请你务必审慎阅读、充分理解本协议各条款内容,特别是以粗体或下划线标注的免责条款、责任限制条款与争议解决条款。如你未满 18 周岁,请在法定监护人的陪同下阅读并决定是否同意本协议。', '本协议是你与有职平台运营方【待确认:公司全称】(以下简称「平台」)之间就使用有职平台服务所订立的协议。在使用平台各项服务前,请你务必审慎阅读、充分理解本协议各条款内容,特别是以粗体或下划线标注的免责条款、责任限制条款与争议解决条款。如你未满 18 周岁,请在法定监护人的陪同下阅读并决定是否同意本协议。',
sections: [ sections: [
{ {
heading: '一、协议的范围与接受', heading: '一、协议的范围与接受',
paragraphs: [ paragraphs: [
'本协议适用于你使用职来平台提供的全部服务,包括但不限于职位浏览与搜索、简历创建与投递、在线沟通、能力测评、竞争力排名、AI 模拟面试、求职进度管理以及企业端的职位发布、人才搜索与招聘管理功能。', '本协议适用于你使用有职平台提供的全部服务,包括但不限于职位浏览与搜索、简历创建与投递、在线沟通、能力测评、竞争力排名、AI 模拟面试、求职进度管理以及企业端的职位发布、人才搜索与招聘管理功能。',
'你通过点击「同意」「注册」或以其他任何方式实际使用平台服务,即视为你已阅读并同意受本协议约束。若你不同意本协议,请立即停止使用平台服务。', '你通过点击「同意」「注册」或以其他任何方式实际使用平台服务,即视为你已阅读并同意受本协议约束。若你不同意本协议,请立即停止使用平台服务。',
'平台有权在必要时修订本协议并在平台上公布。修订后的协议自公布之日起生效。若你继续使用平台服务,即视为你已接受修订后的协议。', '平台有权在必要时修订本协议并在平台上公布。修订后的协议自公布之日起生效。若你继续使用平台服务,即视为你已接受修订后的协议。',
], ],
@ -4806,11 +4806,11 @@ export async function fetchAccountSecurity(): Promise<ActionSetting[]> {
/** 关于我们信息 */ /** 关于我们信息 */
const MOCK_ABOUT_INFO: AboutInfo = { const MOCK_ABOUT_INFO: AboutInfo = {
appName: '职来', appName: '有职',
version: 'v1.0.0', version: 'v1.0.0',
company: '阿布阿诺人工智能科技有限公司', company: '阿布阿诺人工智能科技有限公司',
description: description:
'职来是面向全社会青年求职者的双向透明求职生态平台,提供公司调研、真实评价、行业声誉与岗位评价分级,帮助用户找到更适合自己的工作。', '有职是面向全社会青年求职者的双向透明求职生态平台,提供公司调研、真实评价、行业声誉与岗位评价分级,帮助用户找到更适合自己的工作。',
links: [ links: [
{ id: 'privacy-policy', title: '隐私政策' }, { id: 'privacy-policy', title: '隐私政策' },
{ id: 'terms', title: '用户服务协议' }, { id: 'terms', title: '用户服务协议' },

2
src/hooks/index.ts

@ -56,3 +56,5 @@ export { useSkillCloud } from './useSkillCloud';
export { useTalentRecommendation } from './useTalentRecommendation'; export { useTalentRecommendation } from './useTalentRecommendation';
export { useWeightSettings } from './useWeightSettings'; export { useWeightSettings } from './useWeightSettings';
export { useLogin } from './useLogin'; export { useLogin } from './useLogin';
export { OnboardingProvider, useOnboarding } from './useOnboardingFlow';
export { useRegisterFinish } from './useRegisterFinish';

133
src/hooks/useOnboardingFlow.tsx

@ -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;
}

21
src/hooks/useRegisterFinish.ts

@ -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');
};
}

4
src/lib/storage.ts

@ -4,10 +4,10 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
* 统一封装 @react-native-async-storage/async-storage, * 统一封装 @react-native-async-storage/async-storage,
* 业务侧只调用 get/set/remove,不感知底层实现; * 业务侧只调用 get/set/remove,不感知底层实现;
* 所有读写包 try/catch,存储不可用(如 web 未链接原生模块)时静默降级, * 所有读写包 try/catch,存储不可用(如 web 未链接原生模块)时静默降级,
* 不影响内存态功能。键统一加 'mydreamjob:' 前缀避免与其他 App 冲突。 * 不影响内存态功能。键统一加 'youzhi:' 前缀避免与其他 App 冲突。
*/ */
const KEY_PREFIX = 'mydreamjob:'; const KEY_PREFIX = 'youzhi:';
/** 读取字符串值,缺失/异常时返回 null */ /** 读取字符串值,缺失/异常时返回 null */
export async function storageGet(key: string): Promise<string | null> { export async function storageGet(key: string): Promise<string | null> {

6
src/types/tools.ts

@ -1,6 +1,6 @@
/** /**
* 工具类页面领域模型(行业技能词云 / 薪资计算器 / 求职进度 / 选择身份 / 入职准备清单)。 * 工具类页面领域模型(行业技能词云 / 薪资计算器 / 求职进度 / 选择身份 / 入职准备清单)。
* 与职来设计稿中「行业技能词云」「薪资计算器」「求职进度」「选择身份」「入职准备清单」五屏对应。 * 与有职设计稿中「行业技能词云」「薪资计算器」「求职进度」「选择身份」「入职准备清单」五屏对应。
*/ */
/** 入职准备清单分类 */ /** 入职准备清单分类 */
@ -343,6 +343,6 @@ export interface IdentityOption {
desc: string; desc: string;
/** 图标名(Ionicons) */ /** 图标名(Ionicons) */
icon: string; icon: string;
/** 角色:求职者 / HR */ /** 角色:求职者 / 企业(HR) */
role: 'candidate' | 'hr'; role: 'candidate' | 'enterprise';
} }

Loading…
Cancel
Save