diff --git a/AGENTS.md b/AGENTS.md index 5e96a56..9b2716d 100644 --- a/AGENTS.md +++ b/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,有页面出口就一定要有入口。 业务逻辑分层管理,不是没有,而是被妥善组织: +------------------+--------------------------------------------------+----------------------------------------------------------------------------------------+ | 层级 | 职责 | 业务逻辑示例 | diff --git a/app.config.js b/app.config.js index ea24793..c8f0e8f 100644 --- a/app.config.js +++ b/app.config.js @@ -10,9 +10,9 @@ */ export default { expo: { - name: 'mydreamjob', - slug: 'mydreamjob', - scheme: 'mydreamjob', + name: '有职', + slug: 'youzhi', + scheme: 'youzhi', version: '1.0.0', orientation: 'portrait', icon: './assets/icon.png', diff --git a/app/_layout.tsx b/app/_layout.tsx index 0f8ecd6..e11576f 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -14,8 +14,8 @@ export default function RootLayout() { - - + + diff --git a/app/guide.tsx b/app/guide.tsx index 316e98e..ffd93f5 100644 --- a/app/guide.tsx +++ b/app/guide.tsx @@ -20,7 +20,7 @@ interface GuideSlide { desc: string; } -/** 引导页内容:聚焦职来四大差异化能力 */ +/** 引导页内容:聚焦有职四大差异化能力 */ const SLIDES: GuideSlide[] = [ { icon: 'compass-outline', @@ -55,7 +55,7 @@ export default function GuideScreen() { const finish = () => { completeOnboarding(); - router.replace('/login'); + router.replace('/register'); }; const goNext = () => { diff --git a/app/identity-select.tsx b/app/identity-select.tsx deleted file mode 100644 index b972346..0000000 --- a/app/identity-select.tsx +++ /dev/null @@ -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 ( - - - 职 - - 职来 - 智能求职 · 企业招聘一站式服务平台 - - ); -} - -/* ===================== 子组件:身份卡片 ===================== */ - -/** - * 身份卡片:两张卡视觉重量保持一致(统一白底描边图标 + 主色线框), - * 仅通过选中态的主题色底与描边区分,避免蓝/绿双色造成的重量不均衡。 - */ -function IdentityCard({ - option, - selected, - onSelect, -}: { - option: IdentityOption; - selected: boolean; - onSelect: () => void; -}) { - return ( - - - - - {option.title} - {option.desc} - {selected && ( - - - - )} - - ); -} - -/* ===================== 子组件:四态 ===================== */ - -function Loading() { - return ( - - - 正在加载… - - ); -} - -function ErrorState({ onRetry }: { onRetry: () => void }) { - return ( - - - 加载失败 - - 点击重试 - - - ); -} - -/* ===================== 页面 ===================== */ - -export default function IdentitySelectScreen() { - const router = useRouter(); - const { setRole } = useRole(); - const { candidateSetupDone, enterpriseVerified } = useAuth(); - const { options, loading, error, refresh } = useIdentityOptions(); - const [selectedId, setSelectedId] = useState(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 ( - - - - - {loading ? ( - - ) : error ? ( - - ) : ( - <> - - {options.map((opt) => ( - handleSelect(opt)} - /> - ))} - - - 点击上方卡片,进入对应身份页面 - 身份后续可在设置中切换 - - )} - - - ); -} - -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 }, -}); diff --git a/app/index.tsx b/app/index.tsx index 242b6cf..1c8d5bb 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -14,10 +14,10 @@ export default function Index() { return ; } if (!isLoggedIn) { - return ; + return ; } if (!role) { - return ; + return ; } // 两个路由组均无 index,必须用显式落地路由,否则落地页空白 return ( diff --git a/app/login.tsx b/app/login.tsx deleted file mode 100644 index f424b33..0000000 --- a/app/login.tsx +++ /dev/null @@ -1,265 +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 { - Alert, - 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 | 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 ( - - - - 职 - - 登录职来 - 智能求职 · 企业招聘一站式服务平台 - - - - - 手机号 - - - - - 验证码 - - - 0 || !phoneValid) && styles.codeBtnDisabled]} - onPress={startCountdown} - disabled={countdown > 0 || !phoneValid} - > - - {countdown > 0 ? `${countdown}s 后重发` : '获取验证码'} - - - - - - {error && {error.message}} - - - {loading ? '登录中…' : '登录 / 注册'} - - - - setAgreed((v) => !v)} - hitSlop={6} - > - {agreed && } - - - 已阅读并同意 - router.push('/settings/privacy-policy')}> - 《隐私政策》 - - 和 - router.push('/settings/user-agreement')}> - 《用户协议》 - - - - - - 其他登录方式 - - Alert.alert('提示', '该功能开发中,敬请期待')} hitSlop={8}> - - - Alert.alert('提示', '该功能开发中,敬请期待')} hitSlop={8}> - - - - - - - - 暂不登录,先逛逛 - - - ); -} - -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 }, -}); diff --git a/app/register.tsx b/app/register.tsx new file mode 100644 index 0000000..dbb007a --- /dev/null +++ b/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 ; + case 'P2': return ; + case 'P2Q': return ; + case 'P3': return ; + case 'P4': return ; + case 'P5': return ; + case 'P6': return ; + case 'P7': return ; + case 'P8': return ; + case 'P9': return ; + case 'P10': return ; + case 'P11': return ; + case 'P12': return ; + case 'P13': return ; + case 'P14': return ; + case 'P16': return ; + case 'P17': return ; + case 'P18': return ; + case 'P20': return ; + case 'P21': return ; + case 'P22': return ; + case 'P23': return ; + case 'B1': return ; + case 'B2': return ; + case 'B3': return ; + default: return ; + } +} + +/** 新用户注册全流程:默认注册入口 /register */ +export default function RegisterFlow() { + return ( + + + + ); +} diff --git a/app/settings.tsx b/app/settings.tsx index aa84553..f4a7859 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -38,7 +38,7 @@ const SETTINGS_GROUPS: SettingsGroup[] = [ id: 'identity', title: '身份切换', icon: 'swap-horizontal-outline', - route: '/identity-select', + route: '/register', }, ], }, @@ -101,7 +101,7 @@ export default function SettingsScreen() { style: 'destructive', onPress: () => { logout(); - router.replace('/login'); + router.replace('/register'); }, }, ]); diff --git a/assets/brand-logo.jpg b/assets/brand-logo.jpg new file mode 100644 index 0000000..629a0cb Binary files /dev/null and b/assets/brand-logo.jpg differ diff --git a/components/onboarding/assets.ts b/components/onboarding/assets.ts new file mode 100644 index 0000000..2fa3409 --- /dev/null +++ b/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['name']; + +export interface AssetDef { + /** 【换真实图只改这里】设为 require('../../assets/xxx.png') 即启用真实图,否则渲染色块占位 */ + source: ImageSourcePropType | null; + tint: string; + icon: IconName; + hint: string; +} + +export const ASSET_REGISTRY: Record = { + // 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: '默认头像剪影' }, +}; diff --git a/components/onboarding/screens/B1Wechat.tsx b/components/onboarding/screens/B1Wechat.tsx new file mode 100644 index 0000000..64a66d2 --- /dev/null +++ b/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 ( + + + + + + + 微信用户_8f3a + 将使用以下信息登录有职 + + + · 获得你的昵称、头像 + + + + navigate('P3')} /> + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/B2Agreement.tsx b/components/onboarding/screens/B2Agreement.tsx new file mode 100644 index 0000000..c987d93 --- /dev/null +++ b/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 ( + + + 有职用户协议与隐私政策 + {PARAGRAPHS.map((p, i) => ( + + {p} + + ))} + + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/B3Recruiter.tsx b/components/onboarding/screens/B3Recruiter.tsx new file mode 100644 index 0000000..8b2b24f --- /dev/null +++ b/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 ( + + 请完成企业认证,开启招聘之旅(与求职者主线分开演示) + + 公司名称 + setData({ recruiterCompany: t })} + placeholder="请输入公司全称" + placeholderTextColor={colors.textHint} + /> + + 营业执照 + + + 点击上传营业执照 + + + 联系人 + setData({ school: t })} + placeholder="请输入联系人姓名" + placeholderTextColor={colors.textHint} + /> + + + { + setSubmitted(true); + finish('enterprise'); + }} + /> + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P10FirstWork.tsx b/components/onboarding/screens/P10FirstWork.tsx new file mode 100644 index 0000000..ccda465 --- /dev/null +++ b/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 ( + + + + + 1 年以内经验 + + setData({ firstWorkYear: v })} + onMonthChange={(v) => setData({ firstWorkMonth: v })} + /> + + + navigate('P11')} /> + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P11CurrentPosition.tsx b/components/onboarding/screens/P11CurrentPosition.tsx new file mode 100644 index 0000000..3fb1c45 --- /dev/null +++ b/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 = { + 互联网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 ( + + {}} placeholder="搜索职位" /> + + + + {CATS.map((c) => ( + setCat(c)}> + {c} + + ))} + + + + 猜你做过 + + {POS[cat].map((p) => ( + choose(p)} /> + ))} + + + + + 或者从这些板块选 + + {BLOCKS.map((b) => ( + choose(b)} /> + ))} + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P12Company.tsx b/components/onboarding/screens/P12Company.tsx new file mode 100644 index 0000000..f9ad5f8 --- /dev/null +++ b/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 ( + + setData({ company: t })} + placeholder="请输入公司名称" + onClear={() => setData({ company: '' })} + /> + + + + 将自动屏蔽该公司,该公司 Boss 无法看见你的信息 + + + + + navigate('P13')} /> + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P13JoinDate.tsx b/components/onboarding/screens/P13JoinDate.tsx new file mode 100644 index 0000000..e997e32 --- /dev/null +++ b/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 ( + + + + + 在什么时候入职 {data.company} + + setData({ joinYear: v })} + onMonthChange={(v) => setData({ joinMonth: v })} + /> + + + navigate('P14')} /> + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P14Skills.tsx b/components/onboarding/screens/P14Skills.tsx new file mode 100644 index 0000000..dfbddff --- /dev/null +++ b/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 ( + + + 技能选择 + 0 && styles.counterActive]}> + 0 && styles.counterTextActive]}> + {skills.length}/{MAX} + + + + + {GROUPS.map((g) => ( + + {g.name} + + {g.items.map((s) => ( + toggle(s)} + /> + ))} + + + ))} + + + 0} onPress={() => navigate('P16')} /> + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P16WorkContent.tsx b/components/onboarding/screens/P16WorkContent.tsx new file mode 100644 index 0000000..12e2129 --- /dev/null +++ b/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 ( + + + 工作内容介绍 + finish(targetRole)}> + 保存 + + + + navigate('P17')}> + + 看看别人怎么写 + + + setData({ workContent: t })} + placeholder="至少输入 5 个字,描述你的主要工作内容" + placeholderTextColor={colors.textHint} + multiline + textAlignVertical="top" + /> + + + navigate('P18')} /> + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P17Reference.tsx b/components/onboarding/screens/P17Reference.tsx new file mode 100644 index 0000000..57f8c68 --- /dev/null +++ b/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 ( + + + + + {sample.name} + setIdx((i) => (i + 1) % SAMPLES.length)}> + + 换一个 + + + + + + {sample.items.map((it) => ( + + {it} + + ))} + + + + + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P18Education.tsx b/components/onboarding/screens/P18Education.tsx new file mode 100644 index 0000000..80e0e03 --- /dev/null +++ b/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 ( + + + + + + + {LEVELS.map((l) => ( + setData({ education: l })} /> + ))} + + + 类型 + + setData({ eduType: '全日制' })} /> + setData({ eduType: '非全日制' })} /> + + + + navigate('P20')} /> + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P1Login.tsx b/components/onboarding/screens/P1Login.tsx new file mode 100644 index 0000000..a672158 --- /dev/null +++ b/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 ( + + + + + + + + + + + + {/* 登录方式 */} + gate(() => navigate('B1'))}> + + 微信登录 + + + gate(() => navigate('P2Q'))}> + 一键登录 + + + gate(() => navigate('P2'))} hitSlop={8}> + 其他方式登录 + + + {/* 协议勾选 */} + + setData({ agreed: !agreed })} + text={ + + 已阅读并同意 + navigate('B2')}>《有职用户协议》 + 和 + navigate('B2')}>《隐私政策》 + ,允许有职统一登录本账号信息 + + } + /> + + + + + + 服务热线 + 举报监督电话 + 资质证照 + + + + setPending(null)} + onOpenAgreement={() => { + setPending(null); + navigate('B2'); + }} + /> + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P20School.tsx b/components/onboarding/screens/P20School.tsx new file mode 100644 index 0000000..b54ffe0 --- /dev/null +++ b/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 ( + + setData({ school: t })} + placeholder="请输入毕业学校名称" + onClear={() => setData({ school: '' })} + /> + + + navigate('P21')} /> + + + ); +} + +const styles = StyleSheet.create({ + footer: { marginTop: 28 }, +}); diff --git a/components/onboarding/screens/P21Avatar.tsx b/components/onboarding/screens/P21Avatar.tsx new file mode 100644 index 0000000..593c338 --- /dev/null +++ b/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 ( + + + + + + + + + 拍照上传 + + + + 从相册选择 + + + + 系统默认头像 + + {PRESETS.map((i) => ( + choosePreset(i)} + /> + ))} + + + + navigate('P22')} /> + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P22Done.tsx b/components/onboarding/screens/P22Done.tsx new file mode 100644 index 0000000..c6cfbea --- /dev/null +++ b/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 ( + + + + + + 资料完善,开启求职之旅 + + + + + finish(targetRole)} /> + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P23Home.tsx b/components/onboarding/screens/P23Home.tsx new file mode 100644 index 0000000..059e752 --- /dev/null +++ b/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('guide'); + + useEffect(() => { + setStep('guide'); + }, []); + + return ( + + + + + 推荐职位 + + 上海 + + + + {JOBS.map((j) => ( + + + + {j.pos} + {j.company} + + + {j.salary} + + {j.city} + + + + ))} + + + + {TABS.map((t) => ( + setTab(t.key)}> + + {t.label} + + ))} + + + {step === 'guide' && ( + + + + 点击查看职位详情 + + setStep('location')}> + 知道了 + + + )} + + setStep('notify')} + footer={ + + setStep('notify')}> + 暂不 + + setStep('notify')}> + 允许 + + + } + > + 开启定位可为你推荐附近的职位与机会。 + + + setStep(null)} + footer={ + + setStep(null)}> + 暂不 + + setStep(null)}> + 允许 + + + } + > + 开启通知可及时接收面试邀请与 Boss 消息。 + + + ); +} + +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' }, +}); diff --git a/components/onboarding/screens/P2PhoneCode.tsx b/components/onboarding/screens/P2PhoneCode.tsx new file mode 100644 index 0000000..c017246 --- /dev/null +++ b/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 ( + + {!codeSent ? ( + /* ---------- 阶段1:手机号 ---------- */ + + 登录/注册 + 首次验证通过即注册有职账号 + + + +86 + + + setData({ phone: t.replace(/[^\d]/g, '').slice(0, 11) })} + placeholder="请输入手机号" + placeholderTextColor={colors.textHint} + keyboardType="phone-pad" + maxLength={11} + /> + {phone.length > 0 ? ( + setData({ phone: '' })} hitSlop={8}> + + + ) : null} + + + + 接收不到短信 + + + gate(sendCode)} /> + + + + setData({ agreed: !agreed })} + text={ + + 已阅读并同意 + navigate('B2')}>《有职用户协议》 + 和 + navigate('B2')}>《隐私政策》 + ,允许有职统一管理本人账号信息 + + } + /> + + + {/* 分隔线 + 其他登录方式 */} + + + 或通过以下方式登录 + + + + + gate(() => navigate('B1'))}> + + + gate(sendCode)}> + + + gate(sendCode)}> + {'数字\n身份'} + + + + ) : ( + /* ---------- 阶段2:验证码 ---------- */ + + 输入验证码 + 验证码已发送至 {maskedPhone} + + + setCode(t.replace(/[^\d]/g, '').slice(0, 6))} + placeholder="请输入验证码" + placeholderTextColor={colors.textHint} + keyboardType="number-pad" + maxLength={6} + /> + setCodeSent(true)} /> + + + + + = 4} onPress={() => gate(handleLogin)} /> + + + + setData({ agreed: !agreed })} + text={ + + 已阅读并同意 + navigate('B2')}>《有职用户协议》 + 和 + navigate('B2')}>《隐私政策》 + ,允许有职统一管理本人账号信息 + + } + /> + + + )} + + setPending(null)} + onOpenAgreement={() => { + setPending(null); + navigate('B2'); + }} + /> + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P2QuickLogin.tsx b/components/onboarding/screens/P2QuickLogin.tsx new file mode 100644 index 0000000..05af439 --- /dev/null +++ b/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 ( + + + + + + + {masked} + 首次验证通过即注册有职账号 + + + gate(() => navigate('P3'))} /> + + + + setData({ agreed: !agreed })} + text={ + + 已阅读并同意 + 《中国移动认证服务条款》 + 、 + navigate('B2')}>《有职用户协议》 + 和 + navigate('B2')}>《隐私政策》 + ,允许有职统一管理本人账号信息 + + } + /> + + + + + + 服务热线 + 举报监督电话 + 资质证照 + + 认证服务由中国移动提供 + + + setPending(null)} + onOpenAgreement={() => { + setPending(null); + navigate('B2'); + }} + /> + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P3Identity.tsx b/components/onboarding/screens/P3Identity.tsx new file mode 100644 index 0000000..3a9c5ce --- /dev/null +++ b/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 ( + + 选择你的身份 + + + + + + + 我要找工作 + + + + 或者 + + + + + + + 我要招人 + + + + + 退出登录 + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P4Intent.tsx b/components/onboarding/screens/P4Intent.tsx new file mode 100644 index 0000000..2cd9d39 --- /dev/null +++ b/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 ( + + 你是学生吗 + + setData({ isStudent: true })} /> + setData({ isStudent: false })} /> + + + 工作地点 + + + navigate('P5')} /> + 点击切换城市 + + + 猜你想找 + + {HOT.map((p) => ( + togglePos(p)} /> + ))} + + + + navigate('P6')} /> + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P5City.tsx b/components/onboarding/screens/P5City.tsx new file mode 100644 index 0000000..96f0802 --- /dev/null +++ b/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 ( + + setQuery('')} /> + + pick('上海')}> + + 自动定位:上海 + + + 热门城市 + + {HOT.map((c) => ( + pick(c)} /> + ))} + + + 全部城市 + + {list.map((c) => ( + pick(c)}> + {c} + {data.city === c ? : null} + + ))} + + + ); +} + +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' }, +}); diff --git a/components/onboarding/screens/P6ExpectedPosition.tsx b/components/onboarding/screens/P6ExpectedPosition.tsx new file mode 100644 index 0000000..ee51713 --- /dev/null +++ b/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 = { + 高级管理: ['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 ( + + + 期望职位 + + + 3/3 + + finish(targetRole)}> + 保存 + + + + + {}} placeholder="搜索职位" /> + + + + {CATEGORIES.map((c) => ( + setCat(c)} + > + {c} + + ))} + + + + {positions.length > 0 && ( + + {positions.map((p) => ( + + {p} + toggle(p)} hitSlop={6}> + + + + ))} + + )} + + {POSITIONS[cat].map((p) => ( + toggle(p)} /> + ))} + + + + + + 0} onPress={() => navigate('P7')} /> + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P7Salary.tsx b/components/onboarding/screens/P7Salary.tsx new file mode 100644 index 0000000..6b83a30 --- /dev/null +++ b/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 ( + + 1K 代表 1000 元 + + + + 最低 + {MIN.map((v) => ( + setData({ expectedSalaryMin: v })} + > + {v} + + ))} + + + + 最高 + {MAX.map((v) => ( + setData({ expectedSalaryMax: v })} + > + {v} + + ))} + + + + + navigate('P8')} /> + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P8Resume1.tsx b/components/onboarding/screens/P8Resume1.tsx new file mode 100644 index 0000000..19380c4 --- /dev/null +++ b/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 ( + + + + + + 姓名 + setData({ resumeName: t })} + placeholder="请输入姓名" + placeholderTextColor={colors.textHint} + /> + + 性别 + + setData({ gender: '女' })} /> + setData({ gender: '男' })} /> + + + 出生年月 + setData({ birthYear: v })} + onMonthChange={(v) => setData({ birthMonth: v })} + /> + + + navigate('P9')} /> + + + ); +} + +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 }, +}); diff --git a/components/onboarding/screens/P9JobStatus.tsx b/components/onboarding/screens/P9JobStatus.tsx new file mode 100644 index 0000000..c35a189 --- /dev/null +++ b/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 ( + + + + + + + {OPTIONS.map((o) => ( + setData({ jobStatus: o })} + /> + ))} + + + 求职状态会影响到被推荐的频率及 Boss 的决策 + + + setData({ disabledJobseeker: !data.disabledJobseeker })} + text={残障人士求职(享受专项推荐与保护)} + /> + + + + navigate('P10')} /> + + + ); +} + +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 }, +}); diff --git a/components/onboarding/ui/AgreementCheckbox.tsx b/components/onboarding/ui/AgreementCheckbox.tsx new file mode 100644 index 0000000..7d6280c --- /dev/null +++ b/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 ( + + + {checked ? : null} + + {text} + + ); +} + +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 }, +}); diff --git a/components/onboarding/ui/AgreementModal.tsx b/components/onboarding/ui/AgreementModal.tsx new file mode 100644 index 0000000..5c6916f --- /dev/null +++ b/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 ( + + + 请阅读并同意 + 《有职用户协议》 + 和 + 《隐私政策》 + ,允许有职统一管理本人账号信息 + + + + 拒绝 + + + 同意 + + + + ); +} + +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 }, +}); diff --git a/components/onboarding/ui/Avatar.tsx b/components/onboarding/ui/Avatar.tsx new file mode 100644 index 0000000..f02bc9d --- /dev/null +++ b/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 = ( + + + + ); + if (onPress) return {content}; + return content; +} + +const styles = StyleSheet.create({ + wrap: { borderWidth: 2, overflow: 'hidden', backgroundColor: colors.white }, +}); diff --git a/components/onboarding/ui/BrandHeader.tsx b/components/onboarding/ui/BrandHeader.tsx new file mode 100644 index 0000000..6a73087 --- /dev/null +++ b/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 ( + + + 有职 + {slogan ? {slogan} : null} + + ); +} + +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 }, +}); diff --git a/components/onboarding/ui/CountdownButton.tsx b/components/onboarding/ui/CountdownButton.tsx new file mode 100644 index 0000000..e2533c9 --- /dev/null +++ b/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 | 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 ( + + + {counting ? `${left}s 后重新获取` : '获取验证码'} + + + ); +} + +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 }, +}); diff --git a/components/onboarding/ui/Illustration.tsx b/components/onboarding/ui/Illustration.tsx new file mode 100644 index 0000000..650ea85 --- /dev/null +++ b/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['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 ; + } + + return ( + + + {!circle && {def.hint}} + + ); +} + +const styles = StyleSheet.create({ + block: { alignItems: 'center', justifyContent: 'center', gap: 6, overflow: 'hidden' }, + hint: { fontSize: 11, color: colors.textHint, paddingHorizontal: 8, textAlign: 'center' }, +}); diff --git a/components/onboarding/ui/Modal.tsx b/components/onboarding/ui/Modal.tsx new file mode 100644 index 0000000..6762861 --- /dev/null +++ b/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 ( + + + + {title ? {title} : null} + {children} + {footer ? {footer} : null} + + + ); +} + +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 }, +}); diff --git a/components/onboarding/ui/PrimaryButton.tsx b/components/onboarding/ui/PrimaryButton.tsx new file mode 100644 index 0000000..50fc3f1 --- /dev/null +++ b/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 ( + + {loading ? : {label}} + + ); +} + +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 }, +}); diff --git a/components/onboarding/ui/RadioRow.tsx b/components/onboarding/ui/RadioRow.tsx new file mode 100644 index 0000000..fb772b2 --- /dev/null +++ b/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 ( + + + {label} + {desc ? {desc} : null} + + + {selected ? : null} + + + ); +} + +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 }, +}); diff --git a/components/onboarding/ui/ScreenShell.tsx b/components/onboarding/ui/ScreenShell.tsx new file mode 100644 index 0000000..a1669b4 --- /dev/null +++ b/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 ( + + + {showBack ? ( + + + {title ? {title} : null} + + ) : title ? ( + {title} + ) : null} + {scroll ? ( + + {children} + + ) : ( + {children} + )} + {footer ? {footer} : null} + + ); +} + +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 }, +}); diff --git a/components/onboarding/ui/SearchInput.tsx b/components/onboarding/ui/SearchInput.tsx new file mode 100644 index 0000000..ddf413a --- /dev/null +++ b/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 ( + + + + {value.length > 0 ? ( + + + + ) : null} + + ); +} + +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 }, +}); diff --git a/components/onboarding/ui/Tag.tsx b/components/onboarding/ui/Tag.tsx new file mode 100644 index 0000000..97e87de --- /dev/null +++ b/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 ( + + {label} + + ); +} + +/** 标签容器:自动换行布局 */ +export function TagGroup({ children }: { children: ReactNode }) { + return {children}; +} + +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' }, +}); diff --git a/components/onboarding/ui/WheelPicker.tsx b/components/onboarding/ui/WheelPicker.tsx new file mode 100644 index 0000000..2780275 --- /dev/null +++ b/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) => { + 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 ( + + + + {items.map((it) => ( + + {it} + + ))} + + + ); +} + +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 ( + + + + + ); +} + +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 }, +}); diff --git a/screenshots/README.md b/screenshots/README.md index 5ce1417..64036e1 100644 --- a/screenshots/README.md +++ b/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) 技能的目录与命名约定。 ## 目录结构(约定) diff --git a/screenshots/capture-manifest.json b/screenshots/capture-manifest.json index 221b094..4e27178 100644 --- a/screenshots/capture-manifest.json +++ b/screenshots/capture-manifest.json @@ -1,6 +1,6 @@ { - "project": "职来 (mydreamjob)", - "scheme": "mydreamjob", + "project": "有职 (youzhi)", + "scheme": "youzhi", "locale": "zh-CN", "devices": ["iphone", "ipad", "android-phone"], "targetSizes": { diff --git a/src/api/api.ts b/src/api/api.ts index fd758e0..ca72eda 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -575,7 +575,7 @@ const MOCK_ARTICLES: Article[] = [ views: 23000, thumbnailColor: 'violet', 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', @@ -584,7 +584,7 @@ const MOCK_ARTICLES: Article[] = [ views: 18000, thumbnailColor: 'orange', icon: 'help-circle-outline', - content: '面试不是考试,而是一次双向匹配。最容易翻车的 10 个问题集中在:自我介绍、离职原因、期望薪资、职业规划、自身缺点、压力情境、对公司的了解、对前东家的评价、到岗时间,以及最后的「你还有什么想问的」。\n\n高频雷区有三个:把离职原因说成吐槽前公司、在薪资上先亮底牌、用「我没什么缺点」来回避成长话题。正确姿势是用「事实加反思」替代情绪,把每个问题都变成展示你方法论的机会。\n\n最后一题「你有什么想问的」最容易被忽略,其实它是你反向评估公司是否靠谱的关键窗口——问团队节奏、问岗位成功标准、问业务下一步,比问福利更有价值。职来 AI 模拟面试可针对你的目标岗位做专项陪练。', + content: '面试不是考试,而是一次双向匹配。最容易翻车的 10 个问题集中在:自我介绍、离职原因、期望薪资、职业规划、自身缺点、压力情境、对公司的了解、对前东家的评价、到岗时间,以及最后的「你还有什么想问的」。\n\n高频雷区有三个:把离职原因说成吐槽前公司、在薪资上先亮底牌、用「我没什么缺点」来回避成长话题。正确姿势是用「事实加反思」替代情绪,把每个问题都变成展示你方法论的机会。\n\n最后一题「你有什么想问的」最容易被忽略,其实它是你反向评估公司是否靠谱的关键窗口——问团队节奏、问岗位成功标准、问业务下一步,比问福利更有价值。有职 AI 模拟面试可针对你的目标岗位做专项陪练。', }, { id: 'art-3', @@ -593,7 +593,7 @@ const MOCK_ARTICLES: Article[] = [ views: 35000, thumbnailColor: 'green', icon: 'stats-chart-outline', - content: '薪资报告的价值不在数字本身,而在帮你建立谈薪的「锚点」与「区间」。2024 年互联网行业整体回归理性:中厂与垂直领域岗位增长稳定,纯补贴型高薪收缩,复合能力(业务加技术加沟通)溢价明显。\n\n看报告要学会三层拆解:一是按城市与年限看基线,二是按岗位序列看带宽(P25、P50、P75),三是评估自己的「可替代性」——越稀缺的能力,越能突破带宽上限。\n\n谈薪时不要只报一个数,给一个区间并说明依据(市场分位加你的独特价值),更容易落到上限。职来薪资计算器已内置城市与序列系数,可一键估算你的合理区间。', + content: '薪资报告的价值不在数字本身,而在帮你建立谈薪的「锚点」与「区间」。2024 年互联网行业整体回归理性:中厂与垂直领域岗位增长稳定,纯补贴型高薪收缩,复合能力(业务加技术加沟通)溢价明显。\n\n看报告要学会三层拆解:一是按城市与年限看基线,二是按岗位序列看带宽(P25、P50、P75),三是评估自己的「可替代性」——越稀缺的能力,越能突破带宽上限。\n\n谈薪时不要只报一个数,给一个区间并说明依据(市场分位加你的独特价值),更容易落到上限。有职薪资计算器已内置城市与序列系数,可一键估算你的合理区间。', }, ]; @@ -4377,7 +4377,7 @@ const MOCK_PRIVACY_POLICY: LegalDocument = { updatedAt: '2026-08-31', effectiveAt: '2026-08-31', preamble: - '职来(以下简称「我们」)深知个人信息对你的重要性,并会尽全力保护你的个人信息安全可靠。我们致力于维持你对我们的信任,恪守以下原则保护你的个人信息:权责一致原则、目的明确原则、选择同意原则、最少够用原则、确保安全原则、主体参与原则、公开透明原则。本政策适用于你通过职来平台(含移动客户端、网站及关联服务)所进行的全部活动。', + '有职(以下简称「我们」)深知个人信息对你的重要性,并会尽全力保护你的个人信息安全可靠。我们致力于维持你对我们的信任,恪守以下原则保护你的个人信息:权责一致原则、目的明确原则、选择同意原则、最少够用原则、确保安全原则、主体参与原则、公开透明原则。本政策适用于你通过有职平台(含移动客户端、网站及关联服务)所进行的全部活动。', sections: [ { heading: '一、我们收集的信息', @@ -4462,12 +4462,12 @@ const MOCK_USER_AGREEMENT: LegalDocument = { updatedAt: '2026-08-31', effectiveAt: '2026-08-31', preamble: - '本协议是你与职来平台运营方【待确认:公司全称】(以下简称「平台」)之间就使用职来平台服务所订立的协议。在使用平台各项服务前,请你务必审慎阅读、充分理解本协议各条款内容,特别是以粗体或下划线标注的免责条款、责任限制条款与争议解决条款。如你未满 18 周岁,请在法定监护人的陪同下阅读并决定是否同意本协议。', + '本协议是你与有职平台运营方【待确认:公司全称】(以下简称「平台」)之间就使用有职平台服务所订立的协议。在使用平台各项服务前,请你务必审慎阅读、充分理解本协议各条款内容,特别是以粗体或下划线标注的免责条款、责任限制条款与争议解决条款。如你未满 18 周岁,请在法定监护人的陪同下阅读并决定是否同意本协议。', sections: [ { heading: '一、协议的范围与接受', paragraphs: [ - '本协议适用于你使用职来平台提供的全部服务,包括但不限于职位浏览与搜索、简历创建与投递、在线沟通、能力测评、竞争力排名、AI 模拟面试、求职进度管理以及企业端的职位发布、人才搜索与招聘管理功能。', + '本协议适用于你使用有职平台提供的全部服务,包括但不限于职位浏览与搜索、简历创建与投递、在线沟通、能力测评、竞争力排名、AI 模拟面试、求职进度管理以及企业端的职位发布、人才搜索与招聘管理功能。', '你通过点击「同意」「注册」或以其他任何方式实际使用平台服务,即视为你已阅读并同意受本协议约束。若你不同意本协议,请立即停止使用平台服务。', '平台有权在必要时修订本协议并在平台上公布。修订后的协议自公布之日起生效。若你继续使用平台服务,即视为你已接受修订后的协议。', ], @@ -4806,11 +4806,11 @@ export async function fetchAccountSecurity(): Promise { /** 关于我们信息 */ const MOCK_ABOUT_INFO: AboutInfo = { - appName: '职来', + appName: '有职', version: 'v1.0.0', company: '阿布阿诺人工智能科技有限公司', description: - '职来是面向全社会青年求职者的双向透明求职生态平台,提供公司调研、真实评价、行业声誉与岗位评价分级,帮助用户找到更适合自己的工作。', + '有职是面向全社会青年求职者的双向透明求职生态平台,提供公司调研、真实评价、行业声誉与岗位评价分级,帮助用户找到更适合自己的工作。', links: [ { id: 'privacy-policy', title: '隐私政策' }, { id: 'terms', title: '用户服务协议' }, diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 687d876..84b023e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -56,3 +56,5 @@ export { useSkillCloud } from './useSkillCloud'; export { useTalentRecommendation } from './useTalentRecommendation'; export { useWeightSettings } from './useWeightSettings'; export { useLogin } from './useLogin'; +export { OnboardingProvider, useOnboarding } from './useOnboardingFlow'; +export { useRegisterFinish } from './useRegisterFinish'; diff --git a/src/hooks/useOnboardingFlow.tsx b/src/hooks/useOnboardingFlow.tsx new file mode 100644 index 0000000..7aa4758 --- /dev/null +++ b/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) => void; + current: string; + navigate: (screen: string) => void; + back: () => void; + canGoBack: boolean; + /** 两个保存出口:收集当前信息后直接进 P23,剩余资料后续补充 */ + saveAndEnter: () => void; + reset: () => void; + meta: { time: string; battery: number }; +} + +const OnboardingContext = createContext(null); + +export function OnboardingProvider({ children }: { children: ReactNode }) { + const [data, setDataState] = useState(INITIAL); + const [stack, setStack] = useState([]); + const [current, setCurrent] = useState('P1'); + + const value = useMemo(() => { + const setData = (partial: Partial) => + 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 {children}; +} + +export function useOnboarding(): Ctx { + const ctx = useContext(OnboardingContext); + if (!ctx) throw new Error('useOnboarding 必须在 OnboardingProvider 内使用'); + return ctx; +} diff --git a/src/hooks/useRegisterFinish.ts b/src/hooks/useRegisterFinish.ts new file mode 100644 index 0000000..3f4cd37 --- /dev/null +++ b/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'); + }; +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 82dbead..0c5dcc7 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -4,10 +4,10 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; * 统一封装 @react-native-async-storage/async-storage, * 业务侧只调用 get/set/remove,不感知底层实现; * 所有读写包 try/catch,存储不可用(如 web 未链接原生模块)时静默降级, - * 不影响内存态功能。键统一加 'mydreamjob:' 前缀避免与其他 App 冲突。 + * 不影响内存态功能。键统一加 'youzhi:' 前缀避免与其他 App 冲突。 */ -const KEY_PREFIX = 'mydreamjob:'; +const KEY_PREFIX = 'youzhi:'; /** 读取字符串值,缺失/异常时返回 null */ export async function storageGet(key: string): Promise { diff --git a/src/types/tools.ts b/src/types/tools.ts index 79cb95d..be5e78f 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -1,6 +1,6 @@ /** * 工具类页面领域模型(行业技能词云 / 薪资计算器 / 求职进度 / 选择身份 / 入职准备清单)。 - * 与职来设计稿中「行业技能词云」「薪资计算器」「求职进度」「选择身份」「入职准备清单」五屏对应。 + * 与有职设计稿中「行业技能词云」「薪资计算器」「求职进度」「选择身份」「入职准备清单」五屏对应。 */ /** 入职准备清单分类 */