diff --git a/.gitignore b/.gitignore index 476958e..54e4152 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ node_modules/ # Expo .expo/ dist/ +# 临时打包产物(expo export 验证用,禁止入库) +dist-*/ +.export-tmp* web-build/ expo-env.d.ts diff --git a/AGENTS.md b/AGENTS.md index 9b2716d..17ae494 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -554,4 +554,31 @@ benefits: ['五险一金', '年终奖金', '弹性工作'], industry: '互联网/IT', city: '北京', }, -]; \ No newline at end of file +]; +提交规范 + +提交信息遵循 https://www.conventionalcommits.org/en/v1.0.0/#specification + + : <中文简短标题> + +常用类型: + +- feat: 新增功能或资源; +- fix: 修复问题; +- docs: 修改 README、规范或其他文档; +- refactor: 重构代码或目录,不改变功能; +- chore: 构建、依赖或维护性修改; +- test: 新增或修改测试。 + +标题使用中文、简洁明确,并说明具体改动和原因。例如: + + docs: 新增仓库协作与提交规范说明 + feat: 新增可编辑同款简历模板 + fix: 修复模板自动保存覆盖新内容 + +提交与 PR 前检查 + + git diff --check + git status --short + +涉及技能修改时,运行对应的技能校验;涉及 HTML 模板时,至少进行一次浏览器预览,确认布局、资源路径和分页效果。 diff --git a/app.config.js b/app.config.js index c8f0e8f..3e33eae 100644 --- a/app.config.js +++ b/app.config.js @@ -17,10 +17,26 @@ export default { orientation: 'portrait', icon: './assets/icon.png', userInterfaceStyle: 'light', + // EAS 项目归属(Expo 账号名)。运行 `eas login` 后执行 `eas init` 会自动写入/校正。 + owner: 'REPLACE_WITH_EAS_OWNER', + // OTA 运行时版本策略:随 app 版本号推进,保证原生层与 JS 层兼容。 + runtimeVersion: { + policy: 'appVersion', + }, + // EAS Update(OTA 热更新)通道与端点。 + // url 中的 PROJECT_ID 在 `eas init` 后由 EAS 注入(见 extra.eas.projectId)。 + updates: { + url: 'https://u.expo.dev/REPLACE_WITH_PROJECT_ID', + channel: 'production', + }, ios: { supportsTablet: true, + // iOS Bundle Identifier:上架 App Store 的唯一标识,按你的开发者账号命名。 + bundleIdentifier: 'com.youzhi.app', }, android: { + // Android Package:上架 Google Play 的唯一标识,通常与 iOS 保持一致。 + package: 'com.youzhi.app', adaptiveIcon: { backgroundColor: '#E6F4FE', foregroundImage: './assets/android-icon-foreground.png', @@ -28,12 +44,13 @@ export default { monochromeImage: './assets/android-icon-monochrome.png', }, }, - web: { - favicon: './assets/favicon.png', - }, plugins: ['expo-router', 'expo-font'], extra: { apiBaseUrl: process.env.API_BASE_URL || 'https://dev.api.com', + // EAS 项目 ID:`eas init` 后会自动注入此处(与 updates.url 中的 PROJECT_ID 对应)。 + eas: { + projectId: 'REPLACE_WITH_PROJECT_ID', + }, }, }, }; diff --git a/app/(enterprise)/enterprise-chat.tsx b/app/(enterprise)/enterprise-chat.tsx index 4662864..5285b60 100644 --- a/app/(enterprise)/enterprise-chat.tsx +++ b/app/(enterprise)/enterprise-chat.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'expo-router'; import { useState } from 'react'; import { ActivityIndicator, + Alert, Pressable, ScrollView, StyleSheet, @@ -29,7 +30,7 @@ function Header({ companyName, onBack }: { companyName: string; onBack: () => vo router.push('/talent-recommendation')} + onPress={() => router.navigate('/talent-recommendation')} hitSlop={6} > @@ -155,11 +156,20 @@ function SearchBar() { /* ===================== 子组件:快捷操作栏 ===================== */ -function QuickActions({ actions }: { actions: { icon: string; label: string }[] }) { +/** 单个快捷操作:有 route 则跳转,无对应页面时由页面层给出「功能开发中」提示 */ +type QuickActionItem = { icon: string; label: string; route?: string }; + +function QuickActions({ + actions, + onPressAction, +}: { + actions: QuickActionItem[]; + onPressAction: (action: QuickActionItem) => void; +}) { return ( {actions.map((action) => ( - + onPressAction(action)}> {action.label} @@ -206,10 +216,10 @@ function EmptyState({ /* ===================== 页面 ===================== */ -const QUICK_ACTIONS = [ - { icon: 'mail-outline', label: '批量发邀请' }, +const QUICK_ACTIONS: QuickActionItem[] = [ + { icon: 'mail-outline', label: '批量发邀请', route: '/talent-recommendation' }, { icon: 'document-text-outline', label: '批量发Offer' }, - { icon: 'calendar-outline', label: '面试安排' }, + { icon: 'calendar-outline', label: '面试评分', route: '/interview-score' }, { icon: 'notifications-outline', label: '群发通知' }, ]; @@ -218,6 +228,15 @@ export default function EnterpriseChatScreen() { const { communication, loading, error, refresh } = useEnterpriseCommunication(); const [activeTab, setActiveTab] = useState('all'); + // 快捷操作分发:有目标路由则跳转,无对应页面则提示「功能开发中」 + const handleQuickAction = (action: QuickActionItem) => { + if (action.route) { + router.push(action.route); + } else { + Alert.alert('提示', `${action.label}功能开发中,敬请期待`); + } + }; + if (loading) { return ( @@ -263,12 +282,12 @@ export default function EnterpriseChatScreen() { ); })} - + ) : ( router.push('/talent-recommendation')} + onDiscover={() => router.navigate('/talent-recommendation')} /> )} diff --git a/app/(enterprise)/enterprise-overview.tsx b/app/(enterprise)/enterprise-overview.tsx index 3f48670..f976b55 100644 --- a/app/(enterprise)/enterprise-overview.tsx +++ b/app/(enterprise)/enterprise-overview.tsx @@ -25,12 +25,10 @@ const COLOR_TOKENS: Record = { /* ===================== 子组件:顶部导航 ===================== */ -function Header({ onBack, onSettings }: { onBack: () => void; onSettings: () => void }) { +function Header({ onSettings }: { onSettings: () => void }) { return ( - - - + 我的 @@ -116,7 +114,7 @@ function QuickActions({ actions }: { actions: EnterpriseQuickAction[] }) { action.route && router.push(action.route as any)} + onPress={() => action.route && router.push(action.route as string)} > @@ -181,7 +179,7 @@ function ManageList({ actions }: { actions: EnterpriseQuickAction[] }) { action.route && router.push(action.route as any)} + onPress={() => action.route && router.push(action.route as string)} > @@ -211,7 +209,7 @@ function MenuList({ items }: { items: MenuItem[] }) { item.route && router.push(item.route as any)} + onPress={() => item.route && router.push(item.route as string)} > @@ -280,7 +278,7 @@ export default function EnterpriseOverviewScreen() { -
router.back()} onSettings={() => router.push('/settings')} /> +
router.push('/settings')} /> diff --git a/app/(enterprise)/talent-recommendation.tsx b/app/(enterprise)/talent-recommendation.tsx index 5478a0f..e4cbe28 100644 --- a/app/(enterprise)/talent-recommendation.tsx +++ b/app/(enterprise)/talent-recommendation.tsx @@ -127,7 +127,7 @@ function CandidateCard({ candidate }: { candidate: TalentCandidate }) { - router.push('/enterprise-chat')}> + router.navigate('/enterprise-chat')}> 发起沟通 router.push('/candidate-detail?id=' + candidate.id)}> diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index 9ba759f..8b48615 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -156,7 +156,7 @@ function EnterpriseEntryCard({ entry }: { entry: EnterpriseEntry }) { style={styles.enterpriseCard} onPress={() => { setRole('enterprise'); - router.push('/enterprise-overview'); + router.replace('/(enterprise)/enterprise-overview'); }} hitSlop={4} > @@ -349,7 +349,7 @@ export default function ProfileScreen() { hitSlop={4} > - + {item.title} diff --git a/app/audit-report.tsx b/app/audit-report.tsx index 0edf664..443c6f6 100644 --- a/app/audit-report.tsx +++ b/app/audit-report.tsx @@ -45,7 +45,7 @@ function KPICard({ kpi }: { kpi: AuditKPI }) { return ( - + {kpi.value} diff --git a/app/game.tsx b/app/game.tsx index 01d9af1..ce43904 100644 --- a/app/game.tsx +++ b/app/game.tsx @@ -96,7 +96,7 @@ function LevelCard({ disabled={disabled} > - + 第 {level.order} 关 diff --git a/app/index.tsx b/app/index.tsx index 1c8d5bb..43b8b51 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,19 +1,26 @@ import { Redirect } from 'expo-router'; +import { StyleSheet, View } from 'react-native'; import { useAuth } from '../src/context/AuthContext'; import { useRole } from '../src/context/RoleContext'; +import { colors } from '../src/theme'; /** * 应用总入口(根路由 /)。 * 按「引导 → 登录 → 选择身份 → 对应端」漏斗放行,保证每个页面都有合理的前置入口。 */ export default function Index() { - const { onboardingSeen, isLoggedIn } = useAuth(); - const { role } = useRole(); + const { onboardingSeen, isTokenValid, isHydrated: authHydrated } = useAuth(); + const { role, isHydrated: roleHydrated } = useRole(); + + // 本地存储读取完成前不做任何跳转决策,否则已登录用户会闪现引导页/注册页 + if (!authHydrated || !roleHydrated) { + return ; + } if (!onboardingSeen) { return ; } - if (!isLoggedIn) { + if (!isTokenValid) { return ; } if (!role) { @@ -26,3 +33,10 @@ export default function Index() { /> ); } + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.white, + }, +}); diff --git a/app/legal.tsx b/app/legal.tsx index 8748550..6044da0 100644 --- a/app/legal.tsx +++ b/app/legal.tsx @@ -58,7 +58,7 @@ function ServiceCard({ service, onPress }: { service: LegalService; onPress: () onPress={onPress} > - + @@ -96,7 +96,7 @@ function FAQItem({ faq }: { faq: LegalFAQ }) { function TemplateCard({ template }: { template: LegalTemplate }) { return ( - + {template.name} {template.description} diff --git a/app/register.tsx b/app/register.tsx index dbb007a..a7b3ba3 100644 --- a/app/register.tsx +++ b/app/register.tsx @@ -2,6 +2,7 @@ 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 { P2WechatCode } from '../components/onboarding/screens/P2WechatCode'; import { P3Identity } from '../components/onboarding/screens/P3Identity'; import { P4Intent } from '../components/onboarding/screens/P4Intent'; import { P5City } from '../components/onboarding/screens/P5City'; @@ -31,6 +32,7 @@ function FlowRouter() { case 'P1': return ; case 'P2': return ; case 'P2Q': return ; + case 'P2W': return ; case 'P3': return ; case 'P4': return ; case 'P5': return ; diff --git a/app/settings.tsx b/app/settings.tsx index f4a7859..367edbc 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -4,6 +4,7 @@ import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-nati import { SafeAreaView } from 'react-native-safe-area-context'; import { colors } from '../src/theme'; import { useAuth } from '../src/context/AuthContext'; +import { useRole } from '../src/context/RoleContext'; /* ===================== 设置项结构 ===================== */ @@ -38,7 +39,6 @@ const SETTINGS_GROUPS: SettingsGroup[] = [ id: 'identity', title: '身份切换', icon: 'swap-horizontal-outline', - route: '/register', }, ], }, @@ -92,6 +92,7 @@ function Header({ onBack }: { onBack: () => void }) { export default function SettingsScreen() { const router = useRouter(); const { logout } = useAuth(); + const { role, setRole } = useRole(); const handleLogout = () => { Alert.alert('确认退出登录', '退出后需要重新登录', [ @@ -107,7 +108,21 @@ export default function SettingsScreen() { ]); }; + const handleIdentitySwitch = () => { + if (role === 'enterprise') { + setRole('candidate'); + router.replace('/(tabs)/jobs'); + } else { + setRole('enterprise'); + router.replace('/(enterprise)/enterprise-overview'); + } + }; + const handlePress = (item: SettingsItem) => { + if (item.id === 'identity') { + handleIdentitySwitch(); + return; + } if (item.route) { router.push(item.route); return; diff --git a/app/settings/account-cancel.tsx b/app/settings/account-cancel.tsx index 9b731fc..15ebcff 100644 --- a/app/settings/account-cancel.tsx +++ b/app/settings/account-cancel.tsx @@ -99,7 +99,7 @@ export default function AccountCancelScreen() { - 注销后账号将无法恢复,请谨慎操作 + 提交注销申请后,账号将进入注销审核流程,期间无法登录且不可恢复,审核结束后将永久删除,请谨慎操作 diff --git a/assets/captcha-bg-1.jpg b/assets/captcha-bg-1.jpg new file mode 100644 index 0000000..532edb7 Binary files /dev/null and b/assets/captcha-bg-1.jpg differ diff --git a/assets/captcha-bg-2.jpg b/assets/captcha-bg-2.jpg new file mode 100644 index 0000000..fd481dd Binary files /dev/null and b/assets/captcha-bg-2.jpg differ diff --git a/assets/captcha-bg-3.jpg b/assets/captcha-bg-3.jpg new file mode 100644 index 0000000..aee32b8 Binary files /dev/null and b/assets/captcha-bg-3.jpg differ diff --git a/assets/captcha-bg-4.jpg b/assets/captcha-bg-4.jpg new file mode 100644 index 0000000..d8bd9a4 Binary files /dev/null and b/assets/captcha-bg-4.jpg differ diff --git a/assets/captcha-bg-5.jpg b/assets/captcha-bg-5.jpg new file mode 100644 index 0000000..922564e Binary files /dev/null and b/assets/captcha-bg-5.jpg differ diff --git a/components/onboarding/assets.ts b/components/onboarding/assets.ts index 2fa3409..83781d8 100644 --- a/components/onboarding/assets.ts +++ b/components/onboarding/assets.ts @@ -42,3 +42,17 @@ export const ASSET_REGISTRY: Record = { businessLicense: { source: null, tint: colors.primaryLight, icon: 'document', hint: '营业执照示例图' }, defaultAvatar: { source: null, tint: colors.borderLight, icon: 'person', hint: '默认头像剪影' }, }; + +/** + * 滑块拼图验证背景图池(每次刷新随机选一张) + * RN 静态图片仅支持 require 引用,豁免 no-require-imports 规则 + */ +/* eslint-disable @typescript-eslint/no-require-imports */ +export const CAPTCHA_BACKGROUNDS = [ + require('../../assets/captcha-bg-1.jpg'), // 山峦晨雾 + require('../../assets/captcha-bg-2.jpg'), // 海岸礁石 + require('../../assets/captcha-bg-3.jpg'), // 多肉植物 + require('../../assets/captcha-bg-4.jpg'), // 欧洲街景 + require('../../assets/captcha-bg-5.jpg'), // 摩天大楼 +] as ImageSourcePropType[]; +/* eslint-enable @typescript-eslint/no-require-imports */ diff --git a/components/onboarding/screens/P1Login.tsx b/components/onboarding/screens/P1Login.tsx index a672158..108a808 100644 --- a/components/onboarding/screens/P1Login.tsx +++ b/components/onboarding/screens/P1Login.tsx @@ -1,21 +1,36 @@ -import { useState } from 'react'; +import { useEffect, 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 { useRouter } from 'expo-router'; 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 { useOnboarding, useWechatLogin } from '../../../src/hooks'; +import { useRole } from '../../../src/context/RoleContext'; import { Illustration } from '../ui/Illustration'; import { AgreementCheckbox } from '../ui/AgreementCheckbox'; import { AgreementModal } from '../ui/AgreementModal'; +import { SliderCaptcha } from '../ui/SliderCaptcha'; +import { DeletedAccountModal } from '../ui/DeletedAccountModal'; /** P1 登录主页:对齐参考样式 —— 淡渐变顶部 + 居中 Logo + 微信绿实心/灰底一键登录 + 协议弹窗拦截 */ export function P1Login() { const { data, setData, navigate } = useOnboarding(); + const router = useRouter(); + const { setRole } = useRole(); const agreed = data.agreed; /** 未勾选协议时被拦截的动作,弹窗「同意」后继续执行 */ const [pending, setPending] = useState<(() => void) | null>(null); + const [captchaVisible, setCaptchaVisible] = useState(false); + /** 微信登录状态机:负责三分支判定与流转 */ + const { + state: wechatState, + start: startWechatLogin, + cancelLogin, + reset: resetWechat, + loading: wechatLoading, + } = useWechatLogin(); /** 协议门卫:已勾选直接执行;未勾选弹协议弹窗 */ const gate = (action: () => void) => { @@ -33,6 +48,46 @@ export function P1Login() { act?.(); }; + /** 微信登录:先滑块拼图验证,通过后进入微信授权与三分支判定 */ + const wechatLogin = () => setCaptchaVisible(true); + + /* + * 状态机的副作用集中在这里,页面只负责触发与渲染: + * - needBindPhone:第一次登录,把临时凭证存入流程数据后跳 P2(手机号 + 验证码) + * - success:第二次及以后登录,凭后端返回的身份直接落地,无身份则回注册流程选择 + * - alreadyLoggedIn:本地 token 未过期,免授权直接落地 + * 跳转后立即 reset,避免返回本页时重复触发。 + */ + useEffect(() => { + if (wechatState.kind === 'needBindPhone') { + setData({ + wechatTicket: { + token: wechatState.ticket.token, + openid: wechatState.ticket.openid, + }, + }); + navigate('P2'); + resetWechat(); + return; + } + if (wechatState.kind === 'success') { + if (wechatState.role) { + setRole(wechatState.role); + router.replace( + wechatState.role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs', + ); + } else { + router.replace('/register'); + } + resetWechat(); + return; + } + if (wechatState.kind === 'alreadyLoggedIn') { + router.replace('/(tabs)/jobs'); + resetWechat(); + } + }, [wechatState, resetWechat, router, setRole, setData, navigate]); + return ( @@ -46,11 +101,21 @@ export function P1Login() { {/* 登录方式 */} - gate(() => navigate('B1'))}> + gate(wechatLogin)} + disabled={wechatLoading} + > - 微信登录 + + {wechatLoading ? '登录中…' : '微信登录'} + + {wechatState.kind === 'error' ? ( + {wechatState.message} + ) : null} + gate(() => navigate('P2Q'))}> 一键登录 @@ -85,6 +150,17 @@ export function P1Login() { + { + setCaptchaVisible(false); + void startWechatLogin(); + }} + onClose={() => setCaptchaVisible(false)} + /> + + + void) | null>(null); + const [captchaVisible, setCaptchaVisible] = useState(false); + /** 微信第一次登录的绑定注册能力 */ + const { bind, loading: binding, error: bindError } = useWechatBindPhone(); const agreed = data.agreed; const phone = data.phone; const phoneValid = PHONE_RE.test(phone); + /** 有值说明本页是从「微信第一次登录」进来的,提交时需完成微信绑定 */ + const wechatTicket = data.wechatTicket; const gate = (action: () => void) => { if (agreed) { @@ -44,9 +50,28 @@ export function P2PhoneCode() { setCodeSent(true); }; - const handleLogin = () => { + /* + * 提交验证码: + * - 微信第一次登录(data.wechatTicket 有值):调绑定注册接口把 openid 与手机号关联, + * 成功即写入登录态,再进 P3 选择身份;失败则留在当前页并提示。 + * - 其他链路:沿用原逻辑,仅记录手机号后进 P3。 + */ + const handleLogin = async () => { if (code.length < 4) return; setData({ phone, code }); + + if (wechatTicket) { + try { + await bind({ + token: wechatTicket.token, + openid: wechatTicket.openid, + phone, + code, + }); + } catch { + return; + } + } navigate('P3'); }; @@ -112,7 +137,7 @@ export function P2PhoneCode() { - gate(() => navigate('B1'))}> + gate(() => setCaptchaVisible(true))}> gate(sendCode)}> @@ -144,9 +169,15 @@ export function P2PhoneCode() { - = 4} onPress={() => gate(handleLogin)} /> + = 4 && !binding} + onPress={() => gate(handleLogin)} + /> + {bindError ? {bindError.message} : null} + )} + { + setCaptchaVisible(false); + navigate('P2W'); + }} + onClose={() => setCaptchaVisible(false)} + /> + | null>(null); + + useEffect(() => { + timer.current = setInterval(() => { + setLeft((l) => { + if (l <= 1) { + if (timer.current) clearInterval(timer.current); + return 0; + } + return l - 1; + }); + }, 1000); + return () => { + if (timer.current) clearInterval(timer.current); + }; + }, []); + + const phone = data.phone; + /** 尾号:有真实手机号用尾4位,否则演示 9367 */ + const tail = phone.length === 11 ? phone.slice(7) : '9367'; + const canNext = code.length >= 4; + + const resend = () => { + if (left > 0) return; + setLeft(RESEND_SECONDS); + setCode(''); + timer.current = setInterval(() => { + setLeft((l) => { + if (l <= 1) { + if (timer.current) clearInterval(timer.current); + return 0; + } + return l - 1; + }); + }, 1000); + }; + + return ( + + 输入短信验证码 + 已向您的手机 {tail} 发送验证码 + + {/* 4 格分离输入:透明输入层覆盖,格子仅做展示 */} + + + {[0, 1, 2, 3].map((i) => ( + + {code[i] ?? ''} + + + ))} + + setCode(t.replace(/[^\d]/g, '').slice(0, 4))} + keyboardType="number-pad" + maxLength={4} + autoFocus + /> + + + 0}> + 0 && styles.resendDisabled]}> + {left > 0 ? `重新发送(${left}s)` : '重新发送'} + + + + + navigate('B1')} /> + + + + + 语音接听验证码 + + + 接收不到短信 + + + + + 短信验证码正在发送,请注意查收 + + + ); +} + +const styles = StyleSheet.create({ + h1: { fontSize: 24, fontWeight: '800', color: colors.textPrimary, marginTop: 14 }, + subtitle: { fontSize: 14, color: colors.textSecondary, marginTop: 12 }, + cellsWrap: { marginTop: 34 }, + cellsRow: { flexDirection: 'row', gap: 18 }, + cell: { flex: 1, alignItems: 'center' }, + cellText: { fontSize: 26, fontWeight: '700', color: colors.textPrimary, minHeight: 36 }, + cellLine: { width: '100%', height: 2, backgroundColor: colors.border, marginTop: 8 }, + cellLineActive: { backgroundColor: colors.primary }, + hiddenInput: { + ...StyleSheet.absoluteFill, + opacity: 0, + }, + resend: { textAlign: 'center', fontSize: 14, color: colors.textSecondary, marginTop: 22 }, + resendDisabled: { color: colors.textHint }, + btnArea: { marginTop: 30 }, + helpRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 22, + }, + helpText: { fontSize: 13, color: colors.textHint }, + toastBar: { + alignSelf: 'center', + marginTop: 30, + paddingHorizontal: 18, + paddingVertical: 10, + borderRadius: 8, + backgroundColor: colors.borderLight, + }, + toastText: { fontSize: 13, color: colors.textDisabled }, +}); diff --git a/components/onboarding/ui/DeletedAccountModal.tsx b/components/onboarding/ui/DeletedAccountModal.tsx new file mode 100644 index 0000000..9ab2407 --- /dev/null +++ b/components/onboarding/ui/DeletedAccountModal.tsx @@ -0,0 +1,49 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { colors } from '../../../src/theme'; +import { Modal } from './Modal'; + +interface Props { + visible: boolean; + /** 关闭弹窗,停留在登录页不做任何操作 */ + onClose: () => void; +} + +/** + * 账号注销审核期提示弹窗(微信登录第三种场景)。 + * + * 账号处于注销审核期时不可登录、不可复活,因此只提供「知道了」, + * 不提供任何重新注册出口;关闭后停留在登录页。 + */ +export function DeletedAccountModal({ visible, onClose }: Props) { + return ( + + 无法登录 + 该账号正在申请注销中,无法登录。 + + + 知道了 + + + + ); +} + +const styles = StyleSheet.create({ + title: { + fontSize: 16, + fontWeight: '600', + color: colors.textPrimary, + textAlign: 'center', + marginBottom: 10, + }, + message: { fontSize: 14, lineHeight: 22, color: colors.textSecondary, textAlign: 'center' }, + btnRow: { marginTop: 20 }, + btn: { + height: 46, + borderRadius: 23, + alignItems: 'center', + justifyContent: 'center', + }, + confirmBtn: { backgroundColor: colors.primary }, + confirmText: { fontSize: 16, fontWeight: '700', color: colors.textInverse }, +}); diff --git a/components/onboarding/ui/ScreenShell.tsx b/components/onboarding/ui/ScreenShell.tsx index a1669b4..6ee80f5 100644 --- a/components/onboarding/ui/ScreenShell.tsx +++ b/components/onboarding/ui/ScreenShell.tsx @@ -9,6 +9,8 @@ interface Props { title?: string; showBack?: boolean; onBack?: () => void; + /** 返回图标:默认 chevron-back,可传 close(对齐验证码页的 × 关闭) */ + backIcon?: 'chevron-back' | 'close'; children: ReactNode; footer?: ReactNode; scroll?: boolean; @@ -18,13 +20,13 @@ interface Props { } /** 每屏统一外壳:系统状态栏 + 可选返回箭头 + 标题 + 滚动内容 + 底部固定区 */ -export function ScreenShell({ title, showBack, onBack, children, footer, scroll = true, time: _time, battery: _battery }: Props) { +export function ScreenShell({ title, showBack, onBack, backIcon = 'chevron-back', children, footer, scroll = true, time: _time, battery: _battery }: Props) { return ( {showBack ? ( - + {title ? {title} : null} ) : title ? ( diff --git a/components/onboarding/ui/SliderCaptcha.tsx b/components/onboarding/ui/SliderCaptcha.tsx new file mode 100644 index 0000000..e39e6aa --- /dev/null +++ b/components/onboarding/ui/SliderCaptcha.tsx @@ -0,0 +1,306 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Ionicons } from '@expo/vector-icons'; +import { PanResponder, Pressable, StyleSheet, Text, View } from 'react-native'; +import type { ImageSourcePropType } from 'react-native'; +import { + Circle, + ClipPath, + Defs, + G, + Image as SvgImage, + LinearGradient, + Path, + Rect, + Stop, + Svg, +} from 'react-native-svg'; +import { colors } from '../../../src/theme'; +import { Modal } from './Modal'; +import { CAPTCHA_BACKGROUNDS } from '../assets'; + +interface Props { + visible: boolean; + /** 拼图块对齐缺口时判定通过 */ + onSuccess: () => void; + /** 点遮罩 / 关闭 */ + onClose: () => void; +} + +const KNOB = 44; +const AREA_H = 170; + +/** 生成经典拼图形状(四边各一个半圆凸起)的 SVG path */ +function jigsaw(x: number, y: number, w: number, h: number): string { + const r = w * 0.24; // 凸起半宽 + const d = w * 0.2; // 凸起深度 + const mx = x + w / 2; + const my = y + h / 2; + return [ + `M ${x} ${y}`, + `L ${mx - r} ${y}`, + `C ${mx - r} ${y - d}, ${mx + r} ${y - d}, ${mx + r} ${y}`, + `L ${x + w} ${y}`, + `L ${x + w} ${my - r}`, + `C ${x + w + d} ${my - r}, ${x + w + d} ${my + r}, ${x + w} ${my + r}`, + `L ${x + w} ${y + h}`, + `L ${mx + r} ${y + h}`, + `C ${mx + r} ${y + h + d}, ${mx - r} ${y + h + d}, ${mx - r} ${y + h}`, + `L ${x} ${y + h}`, + `L ${x} ${my + r}`, + `C ${x - d} ${my + r}, ${x - d} ${my - r}, ${x} ${my - r}`, + 'Z', + ].join(' '); +} + +interface Puzzle { + PW: number; + PH: number; + TY: number; + targetPieceX: number; + targetDx: number; + leftMargin: number; + pieceTravelRange: number; +} + +/** 滑块拼图验证弹窗:拖动滑块使拼图块对齐随机缺口,位置匹配即判定通过 */ +export function SliderCaptcha({ visible, onSuccess, onClose }: Props) { + const [areaW, setAreaW] = useState(0); + const [trackW, setTrackW] = useState(0); + const [dx, setDx] = useState(0); + const [passed, setPassed] = useState(false); + const [puzzle, setPuzzle] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + /** 当前使用的背景图(每次 generate 随机从池中选取) */ + const [bg, setBg] = useState(null); + const startX = useRef(0); + + const knobMaxDx = Math.max(0, trackW - KNOB - 4); + // 用 ref 持有最新状态,避免 PanResponder 闭包捕获首帧旧值(puzzle 为 null)导致滑块无法抢占手势、划不动 + const latest = useRef({ dx: 0, knobMaxDx: 0, passed: false, puzzle: null as Puzzle | null, onSuccess }); + latest.current = { dx, knobMaxDx, passed, puzzle, onSuccess }; + + // 测量完成后随机生成缺口位置、拼图块目标位移,并随机选取背景图 + const generate = useCallback(() => { + if (areaW <= 0 || trackW <= 0) return; + const maxDx = Math.max(10, trackW - KNOB - 4); + const PW = Math.min(Math.max(areaW * 0.28, 54), 96); + const PH = PW; + const leftMargin = 6; + const pieceTravelRange = Math.max(10, areaW - PW - leftMargin * 2); + const targetPieceX = leftMargin + 24 + Math.random() * Math.max(1, pieceTravelRange - 48); + const TY = 16 + Math.random() * Math.max(1, AREA_H - PH - 24); + const targetDx = ((targetPieceX - leftMargin) / pieceTravelRange) * maxDx; + setPuzzle({ PW, PH, TY, targetPieceX, targetDx, leftMargin, pieceTravelRange }); + setDx(0); + setPassed(false); + // 从背景图池中随机选一张 + if (CAPTCHA_BACKGROUNDS.length > 0) { + setBg(CAPTCHA_BACKGROUNDS[Math.floor(Math.random() * CAPTCHA_BACKGROUNDS.length)]); + } + }, [areaW, trackW]); + + useEffect(() => { + if (visible && areaW > 0 && trackW > 0) generate(); + }, [visible, areaW, trackW, refreshKey, generate]); + + const pieceX = + puzzle && knobMaxDx > 0 + ? puzzle.leftMargin + (dx / knobMaxDx) * puzzle.pieceTravelRange + : puzzle?.leftMargin ?? 0; + + const pan = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => !latest.current.passed && !!latest.current.puzzle, + onMoveShouldSetPanResponder: () => !latest.current.passed && !!latest.current.puzzle, + onPanResponderGrant: (e) => { + startX.current = e.nativeEvent.pageX; + }, + onPanResponderMove: (e) => { + const { puzzle: pz, knobMaxDx: max } = latest.current; + if (!pz) return; + const next = Math.min(Math.max(e.nativeEvent.pageX - startX.current, 0), max); + latest.current.dx = next; // 同步到 ref,保证松手判定用的是最终位移 + setDx(next); + }, + onPanResponderRelease: () => { + const { puzzle: pz, knobMaxDx: max, dx: cur, onSuccess: ok } = latest.current; + if (!pz) return; + const tol = Math.max(8, max * 0.045); + if (Math.abs(cur - pz.targetDx) <= tol) { + setDx(pz.targetDx); + setPassed(true); + setTimeout(ok, 400); + } else { + setDx(0); + } + }, + }), + ).current; + + const reset = () => { + setDx(0); + setPassed(false); + }; + const refresh = () => { + reset(); + setRefreshKey((k) => k + 1); + }; + const close = () => { + reset(); + onClose(); + }; + + // 背景:有图片则裁切填充,否则用渐变 + 几何纹理占位(与拼图块共用同一来源保证对齐) + const renderBg = (clip?: string) => + bg ? ( + + ) : ( + + + + + + + ); + + return ( + + {/* 拼图验证区:背景 + 缺口 + 可拖动拼图块 */} + setAreaW(e.nativeEvent.layout.width)}> + {areaW > 0 && puzzle ? ( + + + + + + + + + + + + {/* 静态背景 */} + {renderBg()} + + {/* 目标缺口(待填充位置) */} + + + {/* 可拖动拼图块 */} + + {renderBg('url(#pieceClip)')} + + + ) : ( + + 拼图加载中… + + )} + + + {/* 滑块轨道 */} + setTrackW(e.nativeEvent.layout.width)}> + 8 || passed ? 0 : 1 }]}> + {passed ? '验证通过' : '拖动滑块完成拼图'} + + + + + + + {/* 底部操作行 */} + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + puzzleArea: { + height: AREA_H, + borderRadius: 12, + backgroundColor: colors.primaryLight, + overflow: 'hidden', + }, + svg: { position: 'absolute', left: 0, top: 0 }, + puzzlePlaceholder: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + puzzleHint: { fontSize: 14, color: colors.textHint }, + track: { + height: 44, + borderRadius: 22, + backgroundColor: colors.borderLight, + marginTop: 16, + justifyContent: 'center', + overflow: 'hidden', + }, + trackHint: { textAlign: 'center', fontSize: 14, color: colors.textHint }, + knob: { + position: 'absolute', + left: 2, + top: 2, + width: KNOB, + height: KNOB, + borderRadius: 22, + backgroundColor: colors.white, + alignItems: 'center', + justifyContent: 'center', + shadowColor: colors.textPrimary, + shadowOpacity: 0.15, + shadowRadius: 4, + elevation: 3, + }, + knobPassed: { backgroundColor: colors.success }, + toolRow: { + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + marginTop: 14, + }, +}); diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..8ad1a78 --- /dev/null +++ b/eas.json @@ -0,0 +1,21 @@ +{ + "cli": { + "version": ">= 16.0.0", + "appVersionSource": "local" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal" + }, + "production": { + "autoIncrement": true + } + }, + "submit": { + "production": {} + } +} diff --git a/eslint.config.js b/eslint.config.js index 5fbe923..308bbbe 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -41,7 +41,7 @@ module.exports = tseslint.config( 'dist/**', 'coverage/**', 'figma_out/**', - '*.mjs', + '**/*.mjs', 'figma_discover.py', '**/__tests__/**', ], diff --git a/package.json b/package.json index 28c2636..8f13b31 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "dependencies": { "@expo/vector-icons": "^15.0.2", "@react-native-async-storage/async-storage": "2.2.0", - "expo": "~57.0.14", + "expo": "57.0.21", "expo-blur": "~57.0.2", "expo-constants": "~57.0.12", "expo-font": "~57.0.1", @@ -23,7 +23,6 @@ "start": "expo start", "android": "expo start --android", "ios": "expo start --ios", - "web": "expo start --web", "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "jest" diff --git a/scripts/check-routes.mjs b/scripts/check-routes.mjs new file mode 100644 index 0000000..dcb921c --- /dev/null +++ b/scripts/check-routes.mjs @@ -0,0 +1,102 @@ +/** + * 路由审计脚本:交叉校验代码中引用的路由 vs app/ 下实际存在的路由文件。 + * 用法:node scripts/check-routes.mjs + */ +import fs from 'fs'; +import path from 'path'; + +const appDir = path.resolve('app'); +const routeFiles = []; +function walk(dir, rel = '') { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, e.name); + const relPath = rel ? rel + '/' + e.name : e.name; + if (e.isDirectory()) walk(full, relPath); + else if (/\.(tsx|ts|jsx|js)$/.test(e.name)) routeFiles.push(relPath); + } +} +walk(appDir); + +function toRoute(file) { + let r = file.replace(/\.(tsx|ts|jsx|js)$/, ''); + r = r.split('/').filter((s) => !/^\(.*\)$/.test(s)).join('/'); + r = r.replace(/\/index$/, ''); + return '/' + r.replace(/_layout$/, '').replace(/\/$/, ''); +} +const actualRoutes = new Set(routeFiles.map(toRoute).filter(Boolean)); + +function normalize(t) { + const clean = t.split('?')[0].replace(/\/$/, ''); + // 去掉路由组前缀:expo-router 允许写 "/(tabs)/jobs" 这类完整路径 + return clean.split('/').filter((s) => s && !/^\(.*\)$/.test(s)).join('/'); +} + +function routeExists(target) { + const clean = target.split('?')[0].replace(/\/$/, ''); + if (clean === '' || clean === 'index') return true; + if (actualRoutes.has(clean)) return true; + const n = normalize(clean); + for (const r of actualRoutes) { + const rn = normalize(r); + if (rn === n) return true; + const pattern = + '^' + rn.replace(/\[\[?\.\.\.(\w+)\]\]?/g, '.+').replace(/\[(\w+)\]/g, '[^/]+') + '$'; + if (new RegExp(pattern).test(n)) return true; + } + return false; +} + +const srcDirs = ['app', 'components', 'src']; +const files = []; +function walkSrc(dir) { + if (!fs.existsSync(dir)) return; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, e.name); + if (e.isDirectory()) walkSrc(full); + else if (/\.(tsx|ts)$/.test(e.name)) files.push(full); + } +} +srcDirs.forEach(walkSrc); + +const routesFound = new Map(); +const reLocal = /['"`](\/[a-zA-Z0-9_()\-[\]/?=&.][a-zA-Z0-9_()\-[\]/?=&.]*)['"`]/g; +for (const f of files) { + const code = fs.readFileSync(f, 'utf8'); + let m; + while ((m = reLocal.exec(code)) !== null) { + const r = m[1]; + if (r.startsWith('//')) continue; + if (!routesFound.has(r)) routesFound.set(r, []); + routesFound.get(r).push(path.relative(process.cwd(), f).replace(/\\/g, '/')); + } +} + +console.log('=== 实际存在的路由 (' + actualRoutes.size + ') ==='); +console.log([...actualRoutes].sort().join('\n')); +console.log('\n=== 引用但不存在的路由 ==='); +let bad = 0; +for (const [r, srcs] of [...routesFound.entries()].sort()) { + if (r.includes('${')) continue; + if (/\.(png|jpg|jpeg|svg|json|ttf|css)$/i.test(r)) continue; + if (r.startsWith('/node_modules')) continue; + if (!routeExists(r)) { + console.log(`${r} <-- ${[...new Set(srcs)].join(', ')}`); + bad++; + } +} +console.log('\n不存在的路由数量: ' + bad); + +// 反向:孤立页面(没有任何入口指向) +console.log('\n=== 无入口指向的孤立页面 ==='); +const referenced = new Set(); +function markRef(r) { + referenced.add(r); + referenced.add('/' + normalize(r)); + // 动态拼接的路径(如 '/chat/' + id)按前缀登记,前缀命中即视为已引用 + const n = normalize(r); + const seg = n.split('/'); + if (seg.length > 1) referenced.add('/' + seg.slice(0, seg.length - 1).join('/')); +} +for (const r of routesFound.keys()) markRef(r); +const orphans = [...actualRoutes].filter((r) => !referenced.has(r) && !referenced.has('/' + normalize(r))); +console.log(orphans.sort().join('\n') || '(无)'); diff --git a/src/api/api.ts b/src/api/api.ts index ca72eda..15bd0d6 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -115,8 +115,12 @@ import type { MatchModeOption, OperationRules, AuditReport, + WechatAccountStatus, + WechatLoginResult, + WechatBindPhonePayload, + WechatSession, } from '../types'; -import { MOCK_DELAY, SALARY_RANGES } from '../constants'; +import { MOCK_DELAY, SALARY_RANGES, TOKEN_TTL_MS } from '../constants'; import { colors } from '../theme'; /** 接口地址(通过 expo-constants 从 app.config.js 的 extra 读取) */ @@ -2368,10 +2372,10 @@ export async function submitAppeal( const MOCK_ENTERPRISE_QUICK_ACTIONS: EnterpriseQuickAction[] = [ { id: 'publish', label: '发布岗位', icon: 'add-circle-outline', route: '/job-publish', color: 'primary' }, { id: 'jobs', label: '岗位管理', icon: 'business-outline', route: '/job-management', color: 'primary' }, - { id: 'evaluations', label: '评价管理', icon: 'chatbubbles-outline', route: '/job-evaluations', color: 'success' }, - { id: 'candidates', label: '人才库', icon: 'people-outline', route: '/talent-recommendation', color: 'info' }, - { id: 'communication', label: '沟通', icon: 'chatbubble-outline', route: '/enterprise-chat', color: 'action' }, - { id: 'interviews', label: '面试日程', icon: 'calendar-outline', route: '/interview-score', color: 'action' }, + { id: 'evaluations', label: '岗位评价管理', icon: 'chatbubbles-outline', route: '/job-evaluations', color: 'success' }, + { id: 'candidates', label: '人才推荐', icon: 'people-outline', route: '/talent-recommendation', color: 'info' }, + { id: 'communication', label: '企业沟通', icon: 'chatbubble-outline', route: '/enterprise-chat', color: 'action' }, + { id: 'interviews', label: '面试评分', icon: 'calendar-outline', route: '/interview-score', color: 'action' }, ]; /** @@ -3339,6 +3343,27 @@ const MOCK_GAME_LEVELS: GameLevel[] = [ explanation: '城市选择直接影响薪资预期与生活成本,建议结合行业聚集地进行选择。', tags: ['城市'], }, + { + id: 'self-3', + title: '你最擅长的 3 项技能或优势是什么?', + type: 'input', + placeholder: '如:文案写作、数据分析、沟通协调', + explanation: '清晰描述自身优势,是定位岗位与撰写简历的第一步,建议结合具体事例。', + tags: ['自我认知'], + }, + { + id: 'self-4', + title: '以下哪些做法有助于明确职业方向?(多选)', + type: 'multiple', + options: [ + { id: 'a', text: '做职业测评并结合兴趣', isCorrect: true }, + { id: 'b', text: '通过实习/兼职真实体验', isCorrect: true }, + { id: 'c', text: '盲目跟风最热门的岗位', isCorrect: false }, + { id: 'd', text: '完全由他人替你决定', isCorrect: false }, + ], + explanation: '测评、实践与自我反思结合,才能找到真正匹配的方向;盲从他人容易走偏。', + tags: ['职业方向'], + }, ], }, { @@ -3363,6 +3388,32 @@ const MOCK_GAME_LEVELS: GameLevel[] = [ explanation: '开源项目、竞赛获奖、专业认证都是简历中极具说服力的硬技能证明。', tags: ['技能证明'], }, + { + id: 'ability-2', + title: '以下哪些方式能有效提升简历的含金量?(多选)', + type: 'multiple', + options: [ + { id: 'a', text: '参与真实项目并沉淀成果', isCorrect: true }, + { id: 'b', text: '考取行业权威证书', isCorrect: true }, + { id: 'c', text: '撰写技术博客 / 作品集', isCorrect: true }, + { id: 'd', text: '花钱买来路不明的"水证"', isCorrect: false }, + ], + explanation: '真实项目、权威证书与可展示的作品,是简历含金量最硬的支撑。', + tags: ['能力提升'], + }, + { + id: 'ability-3', + title: '描述一段经历时,最能体现能力的写法是?', + type: 'single', + options: [ + { id: 'a', text: '只写"负责相关工作"', isCorrect: false }, + { id: 'b', text: '用 STAR 法则并量化结果', isCorrect: true }, + { id: 'c', text: '堆砌"吃苦耐劳"等形容词', isCorrect: false }, + { id: 'd', text: '直接复制他人经历', isCorrect: false }, + ], + explanation: '情境-任务-行动-结果 + 数据,让能力可被验证。', + tags: ['能力表达'], + }, ], }, { @@ -3387,6 +3438,27 @@ const MOCK_GAME_LEVELS: GameLevel[] = [ explanation: '岗位 JD 中的核心技能要求决定了你是否能胜任,是筛选岗位的首要依据。', tags: ['岗位理解'], }, + { + id: 'job-2', + title: '阅读 JD(岗位描述)时,应重点提取哪些信息?(多选)', + type: 'multiple', + options: [ + { id: 'a', text: '核心职责与任职要求', isCorrect: true }, + { id: 'b', text: '薪资范围与结构', isCorrect: true }, + { id: 'c', text: '发展空间与汇报线', isCorrect: true }, + { id: 'd', text: '公司团建照片', isCorrect: false }, + ], + explanation: '职责、要求、薪资、发展是判断"该不该投"的关键。', + tags: ['岗位理解'], + }, + { + id: 'job-3', + title: '你最想从事的岗位名称是?', + type: 'input', + placeholder: '如:前端工程师 / 产品经理', + explanation: '明确目标岗位名,才能精准匹配 JD 并针对性准备面试。', + tags: ['目标岗位'], + }, ], }, { @@ -3411,6 +3483,32 @@ const MOCK_GAME_LEVELS: GameLevel[] = [ explanation: '官网口号往往经过包装,工商信息、员工评价、业务健康度才是判断企业的硬指标。', tags: ['企业判断'], }, + { + id: 'company-2', + title: '以下哪些是判断企业风险的信号?(多选)', + type: 'multiple', + options: [ + { id: 'a', text: '频繁大规模裁员', isCorrect: true }, + { id: 'b', text: '社保公积金按最低基数缴纳', isCorrect: true }, + { id: 'c', text: '劳动纠纷频发', isCorrect: true }, + { id: 'd', text: '公司前台装修豪华', isCorrect: false }, + ], + explanation: '裁员、社保、纠纷是真实风险信号,装修不能代表经营健康。', + tags: ['风险识别'], + }, + { + id: 'company-3', + title: '了解一家公司真实口碑,最靠谱的渠道是?', + type: 'single', + options: [ + { id: 'a', text: '公司官网与招聘海报', isCorrect: false }, + { id: 'b', text: '在职/离职员工匿名评价 + 脉脉/看准等平台', isCorrect: true }, + { id: 'c', text: '公司年会视频', isCorrect: false }, + { id: 'd', text: '老板个人朋友圈', isCorrect: false }, + ], + explanation: '一线员工的真实评价,比官方宣传更接近事实。', + tags: ['口碑调研'], + }, ], }, { @@ -3435,6 +3533,32 @@ const MOCK_GAME_LEVELS: GameLevel[] = [ explanation: 'STAR 法则(情境-任务-行动-结果)加量化数据,是项目经历最说服人的写法。', tags: ['简历'], }, + { + id: 'resume-2', + title: '简历中应避免哪些问题?(多选)', + type: 'multiple', + options: [ + { id: 'a', text: '错别字与排版混乱', isCorrect: true }, + { id: 'b', text: '虚构或夸大经历', isCorrect: true }, + { id: 'c', text: '与岗位无关内容过多', isCorrect: true }, + { id: 'd', text: '用数据量化成果', isCorrect: false }, + ], + explanation: '错字、造假、跑题会直接减分;量化成果才是加分项。', + tags: ['简历避坑'], + }, + { + id: 'resume-3', + title: '针对不同岗位投递,简历应?', + type: 'single', + options: [ + { id: 'a', text: '一份简历投遍所有岗位', isCorrect: false }, + { id: 'b', text: '按 JD 关键词针对性修改', isCorrect: true }, + { id: 'c', text: '大量使用花哨模板', isCorrect: false }, + { id: 'd', text: '隐瞒关键经历', isCorrect: false }, + ], + explanation: '针对性修改能显著提升匹配度与通过率。', + tags: ['简历优化'], + }, ], }, { @@ -3459,6 +3583,32 @@ const MOCK_GAME_LEVELS: GameLevel[] = [ explanation: '坦诚展示自我认知与改进计划,能体现你的成长型思维。', tags: ['面试'], }, + { + id: 'interview-2', + title: '面试前应如何准备?(多选)', + type: 'multiple', + options: [ + { id: 'a', text: '研究公司与岗位背景', isCorrect: true }, + { id: 'b', text: '准备 STAR 案例', isCorrect: true }, + { id: 'c', text: '模拟常见题型', isCorrect: true }, + { id: 'd', text: '完全不准备靠临场发挥', isCorrect: false }, + ], + explanation: '研究、案例、模拟三者结合,临场更稳。', + tags: ['面试准备'], + }, + { + id: 'interview-3', + title: '被问期望薪资时,合适的做法是?', + type: 'single', + options: [ + { id: 'a', text: '完全不敢谈,随便应允', isCorrect: false }, + { id: 'b', text: '提前调研行业薪资区间后给范围', isCorrect: true }, + { id: 'c', text: '直接报一个离谱高价', isCorrect: false }, + { id: 'd', text: '说"你定就行"', isCorrect: false }, + ], + explanation: '基于调研给区间,既体现专业也保护自身权益。', + tags: ['薪资谈判'], + }, ], }, { @@ -3482,6 +3632,32 @@ const MOCK_GAME_LEVELS: GameLevel[] = [ explanation: '团建频率不属于劳动合同必备条款,薪资、试用期、岗位地点才是核心。', tags: ['签约'], }, + { + id: 'offer-2', + title: '签订劳动合同时应关注的合法权益包括?(多选)', + type: 'multiple', + options: [ + { id: 'a', text: '五险一金缴纳', isCorrect: true }, + { id: 'b', text: '试用期时长合规', isCorrect: true }, + { id: 'c', text: '加班与休假规定', isCorrect: true }, + { id: 'd', text: '公司团建频率', isCorrect: false }, + ], + explanation: '社保、试用期、工时休假是法定权益核心。', + tags: ['签约权益'], + }, + { + id: 'offer-3', + title: '关于试用期,下列说法正确的是?', + type: 'single', + options: [ + { id: 'a', text: '试用期可随意辞退且不补偿', isCorrect: false }, + { id: 'b', text: '试用期最长不超 6 个月且须缴社保', isCorrect: true }, + { id: 'c', text: '试用期不缴社保是行业惯例合法', isCorrect: false }, + { id: 'd', text: '试用期长短由公司随便定', isCorrect: false }, + ], + explanation: '法律对试用期上限与社保有明确规定,不能随意。', + tags: ['试用期'], + }, ], }, ]; @@ -4271,7 +4447,7 @@ export async function removeBlockKeyword(keyword: string): Promise return mockRequest({ token: `mock_token_${phone}`, phone }); } +/* ===================== 账号:微信登录(三种账号状态) ===================== */ + +/* + * mock 专用:切换下一次微信登录返回的账号状态,便于在 Expo Go 里逐个验证三种分支。 + * 真实后端就绪后删除本变量与 setMockWechatAccountStatus。 + */ +let MOCK_WECHAT_STATUS: WechatAccountStatus = 'new'; + +/** + * 【仅 mock 阶段】设置下一次微信登录命中哪种账号状态。 + * @param status 'new' 第一次登录 | 'active' 第二次及以后 | 'deleted' 注销审核期 + */ +export function setMockWechatAccountStatus(status: WechatAccountStatus): void { + MOCK_WECHAT_STATUS = status; +} + +/** + * 用微信授权 code 换取登录结果。 + * + * 后端职责:code → access_token + openid → 查库判定账号状态。 + * 注意 code 一次性且 5 分钟过期,因此后端需在本响应里下发可复用的临时凭证, + * 供后续「绑定手机号」回传(前端拿不到第二个 code)。 + * + * @method POST + * @url /api/auth/wechat + * @param code 微信授权返回的一次性 code + * @returns 账号状态与对应令牌;new 时 token 为临时凭证,deleted 时 token 无意义 + */ +export function fetchWechatLoginResult(code: string): Promise { + if (!code) { + return Promise.reject(new Error('微信授权失败,请重试')); + } + + if (MOCK_WECHAT_STATUS === 'active') { + return mockRequest({ + status: 'active', + token: `mock_wx_token_${code.slice(-6)}`, + openid: 'mock_openid_active', + expiresAt: Date.now() + TOKEN_TTL_MS, + role: 'candidate', + profile: { + id: 'u-wx-001', + nickname: '微信用户_8f3a', + avatarUri: '', + gender: 'unspecified', + }, + }); + } + + // 注销审核期内:不可登录、不可复活,前端只提示并停留在登录页 + if (MOCK_WECHAT_STATUS === 'deleted') { + return mockRequest({ + status: 'deleted', + token: '', + openid: 'mock_openid_deleted', + }); + } + + return mockRequest({ + status: 'new', + token: `mock_wx_temp_${code.slice(-6)}`, + openid: 'mock_openid_new', + }); +} + +/** + * 微信新用户绑定手机号,完成注册(第一次登录的必经步骤)。 + * + * 用户走完微信授权后 openid 未绑定任何账号,必须用手机号 + 短信验证码完成注册, + * 后端据此把 openid 与新账号关联。此时 code 已失效,只能回传临时凭证。 + * + * @method POST + * @url /api/auth/wechat/bind-phone + * @param payload 绑定参数(临时凭证 + 手机号 + 验证码) + * @returns 正式会话(token + 过期时间 + 用户资料) + */ +export function bindWechatAndRegister( + payload: WechatBindPhonePayload, +): Promise { + if (!/^1\d{10}$/.test(payload.phone)) { + return Promise.reject(new Error('手机号格式不正确')); + } + if (payload.code.trim().length < 4) { + return Promise.reject(new Error('验证码不正确')); + } + return mockRequest({ + token: `mock_wx_session_${payload.openid.slice(-6)}`, + expiresAt: Date.now() + TOKEN_TTL_MS, + profile: { + id: `u-wx-${payload.openid.slice(-6)}`, + nickname: `微信用户_${payload.phone.slice(-4)}`, + avatarUri: '', + gender: 'unspecified', + }, + }); +} + /** * 保存求职意向(求职者选择身份后的必经设置步骤,用于岗位推荐匹配)。 * @method POST diff --git a/src/api/index.ts b/src/api/index.ts index c48c88d..1ce157e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -2,6 +2,7 @@ * 接口统一导出 */ export type { LoginResult } from './api'; +export type { WechatLoginResult, WechatSession, WechatBindPhonePayload } from '../types'; export { fetchAboutInfo, fetchAbilityAssessment, @@ -75,6 +76,9 @@ export { submitDeleteData, fetchLegalDocument, loginByPhone, + fetchWechatLoginResult, + bindWechatAndRegister, + setMockWechatAccountStatus, fetchPersonalDataExport, requestPersonalDataExport, fetchRankData, diff --git a/src/constants/index.ts b/src/constants/index.ts index e3994f7..75101fe 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -34,3 +34,9 @@ export const EDUCATION_OPTIONS: string[] = ['不限', '大专', '本科', '硕 /** 热门城市(定位/城市选择用) */ export const CITIES: string[] = ['北京', '上海', '深圳', '杭州', '广州', '成都', '南京', '武汉']; + +/** 一天的毫秒数,用于令牌有效期等时间计算 */ +export const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +/** 登录令牌有效期(毫秒),与后端 JWT 过期时间保持一致 */ +export const TOKEN_TTL_MS = 30 * ONE_DAY_MS; diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 983b10e..8a42ae2 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; import { storageGet, storageSet } from '../lib/storage'; +import { TOKEN_TTL_MS } from '../constants'; /* ===================== 鉴权(引导/登录)上下文 ===================== * 管理「引导页是否看过」「是否已登录」「登录令牌」「分端标记」。 @@ -20,14 +21,31 @@ export interface AuthContextValue { isLoggedIn: boolean; /** 登录令牌(mock 态为占位串) */ token: string | null; + /** 令牌过期时间戳(毫秒);null 表示无有效令牌 */ + tokenExpiresAt: number | null; + /** + * 本地令牌是否仍然有效(已登录且未过期)。 + * 微信登录「第 2~N 次」场景依赖此值:有效则免微信授权直接进主界面。 + */ + isTokenValid: boolean; /** 求职端:求职意向是否已设置(设置后不再强制进入设置页) */ candidateSetupDone: boolean; /** 企业端:是否已提交/完成企业认证(走完该步骤后不再强制) */ enterpriseVerified: boolean; + /** + * 本地存储是否已读取完毕。 + * 冷启动时 AsyncStorage 读取是异步的,在读取完成前上述字段仍是默认值; + * 入口页必须等该标记为 true 再决定跳转,否则已登录用户会闪现引导页。 + */ + isHydrated: boolean; /** 标记引导页已看完 */ completeOnboarding: () => void; - /** 登录成功,写入 token */ - login: (token: string) => void; + /** + * 登录成功,写入 token。 + * @param token 登录令牌 + * @param expiresAt 过期时间戳(毫秒);不传时按 TOKEN_TTL_MS 推算 + */ + login: (token: string, expiresAt?: number) => void; /** 退出登录,清空令牌 */ logout: () => void; /** 标记求职意向已设置 */ @@ -42,6 +60,7 @@ interface MemoryAuth { onboardingSeen: boolean; isLoggedIn: boolean; token: string | null; + tokenExpiresAt: number | null; candidateSetupDone: boolean; enterpriseVerified: boolean; } @@ -52,6 +71,7 @@ const DEFAULT_AUTH: MemoryAuth = { onboardingSeen: false, isLoggedIn: false, token: null, + tokenExpiresAt: null, candidateSetupDone: false, enterpriseVerified: false, }; @@ -77,16 +97,22 @@ function loadAuth(): Promise { export function AuthProvider({ children }: { children: ReactNode }) { const [state, setState] = useState(memoryAuth); + const [isHydrated, setIsHydrated] = useState(false); // 启动时从 async-storage 恢复登录/引导/分端标记 useEffect(() => { let active = true; - loadAuth().then((stored) => { - if (active && stored) { - memoryAuth = stored; - setState(stored); - } - }); + loadAuth() + .then((stored) => { + if (active && stored) { + memoryAuth = stored; + setState(stored); + } + }) + .finally(() => { + // 无论读取成功、为空还是异常,都必须置位,否则入口页会一直停在占位态 + if (active) setIsHydrated(true); + }); return () => { active = false; }; @@ -98,8 +124,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { setState(next); }; - const login = (token: string) => { - const next = { ...state, isLoggedIn: true, token }; + const login = (token: string, expiresAt?: number) => { + const next: MemoryAuth = { + ...state, + isLoggedIn: true, + token, + tokenExpiresAt: expiresAt ?? Date.now() + TOKEN_TTL_MS, + }; persistAuth(next); setState(next); }; @@ -109,6 +140,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...state, isLoggedIn: false, token: null, + tokenExpiresAt: null, candidateSetupDone: false, enterpriseVerified: false, }; @@ -128,10 +160,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { setState(next); }; + // 派生值,不入库:已登录且过期时间晚于当前时间才算有效 + const isTokenValid = + state.isLoggedIn && state.token !== null && (state.tokenExpiresAt ?? 0) > Date.now(); + return ( void; clearRole: () => void; } @@ -45,12 +51,18 @@ function loadRole(): Promise { export function RoleProvider({ children }: { children: ReactNode }) { const [role, setRoleState] = useState(null); + const [isHydrated, setIsHydrated] = useState(false); useEffect(() => { let active = true; - loadRole().then((stored) => { - if (active && stored) setRoleState(stored as AppRole); - }); + loadRole() + .then((stored) => { + if (active && stored) setRoleState(stored as AppRole); + }) + .finally(() => { + // 无论读取成功、为空还是异常都必须置位,避免入口页永久停在占位态 + if (active) setIsHydrated(true); + }); return () => { active = false; }; @@ -67,7 +79,7 @@ export function RoleProvider({ children }: { children: ReactNode }) { }; return ( - + {children} ); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 84b023e..504c7ea 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -58,3 +58,5 @@ export { useWeightSettings } from './useWeightSettings'; export { useLogin } from './useLogin'; export { OnboardingProvider, useOnboarding } from './useOnboardingFlow'; export { useRegisterFinish } from './useRegisterFinish'; +export { useWechatLogin, type PendingBindPhone, type WechatLoginState } from './useWechatLogin'; +export { useWechatBindPhone } from './useWechatBindPhone'; diff --git a/src/hooks/useOfferCompareData.ts b/src/hooks/useOfferCompareData.ts index 58cf6d9..2277335 100644 --- a/src/hooks/useOfferCompareData.ts +++ b/src/hooks/useOfferCompareData.ts @@ -7,7 +7,16 @@ import type { OfferCompareData } from '../types'; import { useAsyncData } from './useAsyncData'; export function useOfferCompareData(offerIds?: string[]) { - const fetcher = useCallback(() => fetchOfferCompareData(offerIds), [offerIds?.join(',')]); + /* + * 取数依赖用「内容字符串」而不是数组引用:useAsyncData 会在 fetcher 引用变化时重新请求, + * 若直接用 offerIds 作依赖,调用方每次渲染传入新数组(内容没变)也会反复取数。 + * 因此把 id 列表压成字符串作为依赖,取数时再还原成数组;id 不含逗号,往返无损。 + */ + const offerIdsKey = offerIds?.join(',') ?? ''; + const fetcher = useCallback( + () => fetchOfferCompareData(offerIdsKey ? offerIdsKey.split(',') : undefined), + [offerIdsKey], + ); const { data, loading, error, refresh } = useAsyncData(fetcher); diff --git a/src/hooks/useOnboardingFlow.tsx b/src/hooks/useOnboardingFlow.tsx index 7aa4758..1b8c65e 100644 --- a/src/hooks/useOnboardingFlow.tsx +++ b/src/hooks/useOnboardingFlow.tsx @@ -7,6 +7,13 @@ export interface OnboardingData { phone: string; code: string; agreed: boolean; + /** + * 微信登录带入的临时凭证。 + * 仅「微信第一次登录」链路有值:微信授权后 openid 未绑定账号, + * 需走手机号 + 验证码完成注册,后端据此把 openid 与新账号关联。 + * 手机号登录等其他链路为 null,此时 P2 页按普通登录处理。 + */ + wechatTicket: { token: string; openid: string } | null; identity: 'candidate' | 'recruiter' | null; isStudent: boolean | null; city: string; @@ -38,6 +45,7 @@ const INITIAL: OnboardingData = { phone: '', code: '', agreed: true, + wechatTicket: null, identity: null, isStudent: null, city: '上海', diff --git a/src/hooks/useWechatBindPhone.ts b/src/hooks/useWechatBindPhone.ts new file mode 100644 index 0000000..3477879 --- /dev/null +++ b/src/hooks/useWechatBindPhone.ts @@ -0,0 +1,46 @@ +import { useCallback, useState } from 'react'; +import { bindWechatAndRegister } from '../api'; +import type { WechatBindPhonePayload, WechatSession } from '../types'; +import { useAuth } from '../context/AuthContext'; + +export interface UseWechatBindPhoneReturn { + /** 绑定手机号并完成注册,成功返回正式会话 */ + bind: (payload: WechatBindPhonePayload) => Promise; + /** 绑定进行中 */ + loading: boolean; + /** 绑定失败的错误(用于页面提示) */ + error: Error | null; +} + +/** + * 微信新用户绑定手机号(第一次登录的第二步)。 + * + * 微信授权后 openid 尚未绑定账号,必须用手机号 + 短信验证码完成注册。 + * 成功后写入登录态;返回会话交给页面决定是继续走身份选择还是直接落地。 + */ +export function useWechatBindPhone(): UseWechatBindPhoneReturn { + const { login } = useAuth(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const bind = useCallback( + async (payload: WechatBindPhonePayload) => { + setLoading(true); + setError(null); + try { + const session = await bindWechatAndRegister(payload); + login(session.token, session.expiresAt); + return session; + } catch (e) { + const err = e instanceof Error ? e : new Error('绑定失败,请稍后重试'); + setError(err); + throw err; + } finally { + setLoading(false); + } + }, + [login], + ); + + return { bind, loading, error }; +} diff --git a/src/hooks/useWechatLogin.ts b/src/hooks/useWechatLogin.ts new file mode 100644 index 0000000..92850ea --- /dev/null +++ b/src/hooks/useWechatLogin.ts @@ -0,0 +1,131 @@ +import { useCallback, useState } from 'react'; +import { fetchWechatLoginResult } from '../api'; +import type { AppRole, WechatPendingTicket, WechatUserProfile } from '../types'; +import { isWechatAvailable, requestWechatAuthCode } from '../lib/wechat-sdk'; +import { useAuth } from '../context/AuthContext'; + +/* ===================== 微信登录状态机 ===================== + * 编排三种账号场景的流转,页面只按 state.kind 渲染,不写判断逻辑。 + * + * idle ──start()──> 本地 token 有效?──是──> alreadyLoggedIn(免授权直接落地) + * │ + * 否 + * ↓ + * authenticating(调起微信) + * ↓ + * verifying(后端校验 code) + * ↓ + * ┌───────────────┼───────────────┐ + * new 分支 active 分支 deleted 分支 + * ↓ ↓ ↓ + * needBindPhone success deleted(提示无法登录) + * (跳手机号+验证码) │ + * ↓ cancelLogin() → idle + * 由 P2 页完成绑定后落地 + * + * 注意:注销审核期内的账号不可登录、不可复活,因此 deleted 分支没有「重新注册」出口。 + */ + +/** 待绑定手机号时持有的临时凭证(原 code 已失效,只能用它标识身份) */ +export type PendingBindPhone = WechatPendingTicket; + +/** 微信登录状态(判别联合,页面通过 kind 收窄后读取对应字段) */ +export type WechatLoginState = + | { kind: 'idle' } + /** 正在调起微信 */ + | { kind: 'authenticating' } + /** 已拿到 code,正在请求后端校验 */ + | { kind: 'verifying' } + /** 本地 token 未过期,无需再走微信授权 */ + | { kind: 'alreadyLoggedIn' } + /** 第一次登录:openid 未绑定账号,需跳手机号 + 验证码完成注册 */ + | { kind: 'needBindPhone'; ticket: PendingBindPhone } + /** 账号处于注销审核期,不可登录 */ + | { kind: 'deleted' } + /** 登录成功;role 为 undefined 表示尚未选择身份,需引导去注册流程 */ + | { kind: 'success'; profile: WechatUserProfile; role?: AppRole } + /** 失败(未安装微信 / 用户取消 / 网络异常) */ + | { kind: 'error'; message: string }; + +export interface UseWechatLoginReturn { + state: WechatLoginState; + /** 是否处于请求中(用于按钮 loading 与防重复点击) */ + loading: boolean; + /** 入口:本地 token 有效则直接登录,否则走微信授权 */ + start: () => Promise; + /** 关闭注销提示,回到登录页 */ + cancelLogin: () => void; + /** 重置为初始态 */ + reset: () => void; +} + +/** 初始态 */ +const IDLE: WechatLoginState = { kind: 'idle' }; + +export function useWechatLogin(): UseWechatLoginReturn { + const { login, isTokenValid } = useAuth(); + const [state, setState] = useState(IDLE); + + const start = useCallback(async () => { + // 第二次及以后登录:本地令牌仍有效时免微信授权 + if (isTokenValid) { + setState({ kind: 'alreadyLoggedIn' }); + return; + } + + setState({ kind: 'authenticating' }); + try { + if (!isWechatAvailable()) { + throw new Error('未检测到微信客户端,请先安装微信'); + } + const code = await requestWechatAuthCode(); + + setState({ kind: 'verifying' }); + const result = await fetchWechatLoginResult(code); + + switch (result.status) { + case 'active': { + if (!result.profile) { + throw new Error('登录信息不完整,请重试'); + } + login(result.token, result.expiresAt); + setState({ kind: 'success', profile: result.profile, role: result.role }); + break; + } + case 'new': { + setState({ + kind: 'needBindPhone', + ticket: { token: result.token, openid: result.openid }, + }); + break; + } + case 'deleted': { + // 注销审核期:不写登录态,仅提示不可登录 + setState({ kind: 'deleted' }); + break; + } + } + } catch (e) { + setState({ + kind: 'error', + message: e instanceof Error ? e.message : '微信登录失败,请稍后重试', + }); + } + }, [isTokenValid, login]); + + const cancelLogin = useCallback(() => { + setState(IDLE); + }, []); + + const reset = useCallback(() => { + setState(IDLE); + }, []); + + return { + state, + loading: state.kind === 'authenticating' || state.kind === 'verifying', + start, + cancelLogin, + reset, + }; +} diff --git a/src/hooks/useWeightSettings.ts b/src/hooks/useWeightSettings.ts index efd45ed..df95771 100644 --- a/src/hooks/useWeightSettings.ts +++ b/src/hooks/useWeightSettings.ts @@ -33,7 +33,11 @@ export function useWeightSettings(): WeightSettingsState { const [localDimensions, setLocalDimensions] = useState(null); const [saving, setSaving] = useState(false); - const remoteDimensions = data?.dimensions ?? []; + /* + * 必须记忆化:data 为空时 `?? []` 每次渲染都会生成新数组, + * 会让下面依赖它的 useCallback 每次渲染都变引用(进而触发额外重渲染)。 + */ + const remoteDimensions = useMemo(() => data?.dimensions ?? [], [data]); const dimensions = localDimensions ?? remoteDimensions; const totalWeight = useMemo( diff --git a/src/lib/wechat-sdk.ts b/src/lib/wechat-sdk.ts new file mode 100644 index 0000000..6137e4b --- /dev/null +++ b/src/lib/wechat-sdk.ts @@ -0,0 +1,35 @@ +import { Platform } from 'react-native'; + +/* ===================== 微信授权适配层 ===================== + * 把「调起微信并拿到一次性 code」这一平台能力单独隔离,原因: + * 1. 当前项目是 Expo 管理流,没有原生模块,Expo Go 中无法真正调起微信; + * 2. 两端(iOS / Android)的微信 SDK 初始化与回调方式不同,差异应收敛在此文件; + * 3. 接入真实 SDK 时只替换 requestWechatAuthCode 的函数体,上层 hook 与页面无需改动。 + * + * 接真实 SDK 的步骤(接入时才做,现阶段不要动): + * 1. npx expo install react-native-wechat-lib(或对应 expo 插件) + * 2. 微信开放平台创建移动应用,拿到 AppID,并登记 iOS 的 Universal Links + * (Universal Links 是 iOS 端微信回调的硬性要求,安卓走包名 + 应用签名) + * 3. 用 EAS Build 生成自定义开发客户端(Expo Go 不含原生模块,跑不了) + * 4. 把 requestWechatAuthCode 换成: + * const res = await WeChat.sendAuthRequest('snsapi_userinfo', 'wechat_auth_state'); + * return res.code; + */ + +/** 微信是否可用:Expo Go 或未装微信时为 false */ +export function isWechatAvailable(): boolean { + // 真实 SDK 接入后改为 WeChat.isWXAppInstalled() + return Platform.OS !== 'web'; +} + +/** + * 调起微信授权,返回一次性 code。 + * @throws 未安装微信、用户取消授权、或授权失败时抛错 + */ +export async function requestWechatAuthCode(): Promise { + /* + * mock 实现:返回带时间戳的假 code,仅用于打通前端三分支流程。 + * 真实实现见上方注释。 + */ + return Promise.resolve(`mock_wx_code_${Date.now()}`); +} diff --git a/src/types/index.ts b/src/types/index.ts index 014d3e8..d1fa7f1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -247,3 +247,11 @@ export type { PersonalDataExport, RecentExportRecord, } from './data-export'; +export type { + WechatAccountStatus, + WechatBindPhonePayload, + WechatLoginResult, + WechatPendingTicket, + WechatSession, + WechatUserProfile, +} from './wechat-auth'; diff --git a/src/types/wechat-auth.ts b/src/types/wechat-auth.ts new file mode 100644 index 0000000..be4f778 --- /dev/null +++ b/src/types/wechat-auth.ts @@ -0,0 +1,98 @@ +import type { AppRole } from './user'; + +/** + * 微信登录相关类型 + * + * 覆盖三种账号场景(对应后端用 code 换 openid 后的判定结果): + * 1. new —— openid 不存在,第一次登录,需绑定手机号完成注册 + * 2. active —— openid 存在且账号活跃,第二次及以后登录,直接进入主界面 + * 3. deleted —— 账号处于注销审核期(申请注销后、彻底删除前),**不可登录** + * + * 关键约束一:微信授权返回的 code 是一次性的(有效期 5 分钟)。 + * 用户在绑定手机号时原 code 已被首次请求消耗,后端不能再用它换 openid, + * 因此后端必须在首次响应里下发可复用的临时凭证,由前端回传。 + * + * 关键约束二:注销审核期内账号不可登录、不可复活, + * 审核期结束后数据被彻底删除。前端不提供任何复活入口。 + */ + +/** + * 微信账号在后端的状态。 + * 用联合类型而非布尔 isNewUser 建模,避免「注销中」被误判为「新老用户」之外的隐式状态。 + */ +export type WechatAccountStatus = 'new' | 'active' | 'deleted'; + +/** + * 等待绑定手机号时持有的临时凭证。 + * 后端首次校验 code 后下发,用于绑定手机号时标识身份(此时 code 已失效)。 + */ +export interface WechatPendingTicket { + /** 临时令牌,绑定手机号后换取正式 token */ + token: string; + /** 微信开放平台唯一标识 */ + openid: string; +} + +/** 微信登录后已注册用户的基本资料 */ +export interface WechatUserProfile { + /** 用户 id */ + id: string; + /** 昵称 */ + nickname: string; + /** 头像地址 */ + avatarUri: string; + /** 性别;unspecified 表示用户未授权或未填写 */ + gender: 'male' | 'female' | 'unspecified'; +} + +/** + * 微信登录结果(后端 POST /api/auth/wechat 的响应)。 + * + * 各字段按状态可选,页面通过 status 判别后再读取对应字段: + * - new:token 为临时凭证,profile 为 undefined + * - active:token 为正式登录令牌,profile 有值 + * - deleted:token 为临时凭证但不可用,仅用于标识账号,不得写入登录态 + */ +export interface WechatLoginResult { + /** 账号状态 */ + status: WechatAccountStatus; + /** 登录令牌(active 为正式 token;new 为临时凭证;deleted 无意义) */ + token: string; + /** 微信开放平台唯一标识 */ + openid: string; + /** 已注册用户资料,仅 status === 'active' 时返回 */ + profile?: WechatUserProfile; + /** 正式令牌的过期时间戳(毫秒),仅 status === 'active' 时返回 */ + expiresAt?: number; + /** + * 已注册用户的端身份,仅 status === 'active' 时返回。 + * 老用户凭此直接落地对应端;为 undefined 表示后端未记录,前端需引导选择身份。 + */ + role?: AppRole; +} + +/** 微信新用户绑定手机号完成注册(第一次登录的必经步骤) */ +export interface WechatBindPhonePayload { + /** 临时凭证令牌(code 已失效,只能用它标识身份) */ + token: string; + /** 微信开放平台唯一标识 */ + openid: string; + /** 手机号(11 位) */ + phone: string; + /** 短信验证码 */ + code: string; +} + +/** 绑定注册成功后返回的正式会话 */ +export interface WechatSession { + /** 正式登录令牌 */ + token: string; + /** 令牌过期时间戳(毫秒) */ + expiresAt: number; + /** 用户资料 */ + profile: WechatUserProfile; + /** + * 端身份。为 undefined 表示新用户尚未选择身份,前端需引导去注册流程选择。 + */ + role?: AppRole; +} diff --git a/yarn.lock b/yarn.lock index 57082be..b47c05f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -726,10 +726,10 @@ resolved "https://registry.npmmirror.com/@expo-google-fonts/material-symbols/-/material-symbols-0.4.45.tgz#2536d34796127ef209d80ffd8b8b248b2a3fd1ee" integrity sha512-D5gd/uFa7KfMJX9S11hvej4GpQnBl27oKiUgyIq3cLTPtyDOWBwRgmeePIdV6+PGcedlDkaCND2BnRgAkxzNaA== -"@expo/cli@^57.0.21": - version "57.0.21" - resolved "https://registry.npmmirror.com/@expo/cli/-/cli-57.0.21.tgz#fcf3e41828c5a073bac84a2bcbab47eaaf2dfc5d" - integrity sha512-CkcgAOsp1KkNRENXWfyr/esIwgFtiHosKECWZ0QQl2nFOUEQlyjWXAZ6uTCMnbHLonVnPMZpXTkHZNw3gtanFw== +"@expo/cli@^57.0.23": + version "57.0.23" + resolved "https://registry.npmmirror.com/@expo/cli/-/cli-57.0.23.tgz#020be0e0886677ec013d10e11e7c9ce597b8329c" + integrity sha512-stzSYxVwbWGbKR+mrYT6s4Rt6HrcUxb0JrHyebDGL4ehpvfRTv0rMzn4Q7fqB6f5wJ1KCmf6OAgqgK+TTB8/FA== dependencies: "@expo/code-signing-certificates" "^0.0.6" "@expo/config" "~57.0.9" @@ -742,7 +742,7 @@ "@expo/log-box" "^57.0.4" "@expo/metro" "~56.0.2" "@expo/metro-config" "~57.0.12" - "@expo/metro-file-map" "^57.0.2" + "@expo/metro-file-map" "^57.0.3" "@expo/osascript" "^2.7.1" "@expo/package-manager" "^1.13.1" "@expo/plist" "^0.8.1" @@ -963,10 +963,10 @@ postcss "^8.5.14" resolve-from "^5.0.0" -"@expo/metro-file-map@^57.0.2": - version "57.0.2" - resolved "https://registry.npmmirror.com/@expo/metro-file-map/-/metro-file-map-57.0.2.tgz#5833c378aebbda32447aeb7a6e51ad5a5a3e0bf0" - integrity sha512-tb50nSIWwKpRufSkGuivOK0FbUxv1Uwqptb0SzFTk0bkmYmcy3gIwnCbOHTfmrQDVndRhPEiu2SYgh9Z0PCvGg== +"@expo/metro-file-map@^57.0.3": + version "57.0.3" + resolved "https://registry.npmmirror.com/@expo/metro-file-map/-/metro-file-map-57.0.3.tgz#1ed09151ac17f594fc08051de15894cdfef07503" + integrity sha512-1OXy+uPYY5uc7Tm4VBsd2NRn+3wHhqeqNuEO/Xo4kmYgv8FjYgUAc+bUXON9FpC2ikcLn4EVlGM9ce2exx9Mlg== dependencies: debug "^4.3.4" fb-watchman "^2.0.2" @@ -2381,7 +2381,55 @@ babel-preset-current-node-syntax@^1.0.0: "@babel/plugin-syntax-private-property-in-object" "^7.14.5" "@babel/plugin-syntax-top-level-await" "^7.14.5" -babel-preset-expo@~57.0.10, babel-preset-expo@~57.0.7: +babel-preset-expo@~57.0.11: + version "57.0.11" + resolved "https://registry.npmmirror.com/babel-preset-expo/-/babel-preset-expo-57.0.11.tgz#8ee8538b872c9927eaa59cd74fc8a15d71a239cf" + integrity sha512-R0NouDI3nzQUsjBh5TUJSDXLmuVzE2bp2YE0ywJkKxzrdWVjShpAlZMBuDeOQy6iKGB6ZCCvMqbQvpqgNMJ7iw== + dependencies: + "@babel/generator" "^7.20.5" + "@babel/helper-module-imports" "^7.25.9" + "@babel/plugin-proposal-decorators" "^7.12.9" + "@babel/plugin-proposal-export-default-from" "^7.24.7" + "@babel/plugin-syntax-dynamic-import" "^7.8.3" + "@babel/plugin-syntax-export-default-from" "^7.24.7" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-transform-async-generator-functions" "^7.25.4" + "@babel/plugin-transform-async-to-generator" "^7.24.7" + "@babel/plugin-transform-block-scoping" "^7.25.0" + "@babel/plugin-transform-class-properties" "^7.25.4" + "@babel/plugin-transform-class-static-block" "^7.27.1" + "@babel/plugin-transform-classes" "^7.25.4" + "@babel/plugin-transform-destructuring" "^7.24.8" + "@babel/plugin-transform-export-namespace-from" "^7.25.9" + "@babel/plugin-transform-flow-strip-types" "^7.25.2" + "@babel/plugin-transform-for-of" "^7.24.7" + "@babel/plugin-transform-logical-assignment-operators" "^7.24.7" + "@babel/plugin-transform-modules-commonjs" "^7.24.8" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.24.7" + "@babel/plugin-transform-nullish-coalescing-operator" "^7.24.7" + "@babel/plugin-transform-object-rest-spread" "^7.24.7" + "@babel/plugin-transform-optional-catch-binding" "^7.24.7" + "@babel/plugin-transform-optional-chaining" "^7.24.8" + "@babel/plugin-transform-parameters" "^7.24.7" + "@babel/plugin-transform-private-methods" "^7.24.7" + "@babel/plugin-transform-private-property-in-object" "^7.24.7" + "@babel/plugin-transform-react-display-name" "^7.24.7" + "@babel/plugin-transform-react-jsx" "^7.28.6" + "@babel/plugin-transform-react-jsx-development" "^7.27.1" + "@babel/plugin-transform-react-pure-annotations" "^7.27.1" + "@babel/plugin-transform-runtime" "^7.24.7" + "@babel/plugin-transform-typescript" "^7.25.2" + "@babel/plugin-transform-unicode-regex" "^7.24.7" + "@babel/preset-typescript" "^7.23.0" + "@react-native/babel-plugin-codegen" "0.86.3" + babel-plugin-react-compiler "^1.0.0" + babel-plugin-react-native-web "~0.21.0" + babel-plugin-syntax-hermes-parser "^0.36.0" + babel-plugin-transform-flow-enums "^0.0.2" + debug "^4.3.4" + +babel-preset-expo@~57.0.7: version "57.0.10" resolved "https://registry.npmmirror.com/babel-preset-expo/-/babel-preset-expo-57.0.10.tgz#2d7655c2636a4c97e0615e90aa362410410ba9bb" integrity sha512-08bt6bqMZFoQuWO9gkt0EXotbqKHimELFGa1LlKJvY89iKsi4S0FJSFBFJ4pn9NZvofet48HIjYDQ0SZJ7bNrg== @@ -3542,19 +3590,19 @@ expo-modules-autolinking@~57.0.12: chalk "^4.1.0" commander "^7.2.0" -expo-modules-core@~57.0.15: - version "57.0.15" - resolved "https://registry.npmmirror.com/expo-modules-core/-/expo-modules-core-57.0.15.tgz#721c6bec863ec21b0c873a65a5977a86f352c70f" - integrity sha512-HBxPXsx3eVLLRjAY57/8ghKDWIxjaEbluKKYFIq1OHPJC85ZfHsawwc0SOU4Z/qu+mdLz7Pwt5IkkWimeNSH5A== +expo-modules-core@~57.0.17: + version "57.0.17" + resolved "https://registry.npmmirror.com/expo-modules-core/-/expo-modules-core-57.0.17.tgz#627bf8fbab9100b9e9d1047ff75353d9c9cb8813" + integrity sha512-hHJwGHW0sMQiOLzEEl1QbJeWkBEBgjlPjrdBVGWlzvgUbCXJfFbDU2nhxoyzLMZnvHkpIzpiJjj5cM5W8gbudA== dependencies: "@expo/expo-modules-macros-plugin" "0.6.1" - expo-modules-jsi "~57.0.7" + expo-modules-jsi "~57.1.0" invariant "^2.2.4" -expo-modules-jsi@~57.0.7: - version "57.0.7" - resolved "https://registry.npmmirror.com/expo-modules-jsi/-/expo-modules-jsi-57.0.7.tgz#f862a364161345433fcc4ddb5f0e2f33543f491f" - integrity sha512-/GOrTFPnCfg7+m6M1yOSEKNeNdHBCm0ju6aJ4XHlSmtE02nb7mWh1Cs6QsK2eB5Rz7hjbb356Nd7UimsFvN7Vg== +expo-modules-jsi@~57.1.0: + version "57.1.0" + resolved "https://registry.npmmirror.com/expo-modules-jsi/-/expo-modules-jsi-57.1.0.tgz#21c1d3df20ea886e937a9ea2930699e34e164867" + integrity sha512-a5ckeHfnbYfonhcHGkM0EU/a0Keh+/OufXy/HyFS+spr5Ib0N4Oh1ZsSYFQhp5xdOUFkVofKRhfH+VmcccIIpA== expo-router@~57.0.14: version "57.0.18" @@ -3607,13 +3655,13 @@ expo-symbols@^57.0.2: "@expo-google-fonts/material-symbols" "^0.4.1" sf-symbols-typescript "^2.0.0" -expo@~57.0.14: - version "57.0.19" - resolved "https://registry.npmmirror.com/expo/-/expo-57.0.19.tgz#06b4371f5ff32cc71c0ba68dbc4ae3770a56891b" - integrity sha512-oIoAIisim1DwS2Zjp0rcEY9cfcq1wU6KpkBfwwFhJWI5YHV6i/aIzTUMX+uf0Q0CD6AvMls7IZVh34o27NjaJA== +expo@57.0.21: + version "57.0.21" + resolved "https://registry.npmmirror.com/expo/-/expo-57.0.21.tgz#6464818c652c63a6f1f71be706e0840b7a6ab124" + integrity sha512-lQmC0kCJCleO+uLUwHXY0pLDzcvedKEsX+pmJp4mSxn3JlWDTZvUb0e5UIlV7+bqlN68LpAeKG4c2lrN4zuP0Q== dependencies: "@babel/runtime" "^7.20.0" - "@expo/cli" "^57.0.21" + "@expo/cli" "^57.0.23" "@expo/config" "~57.0.9" "@expo/config-plugins" "~57.0.9" "@expo/devtools" "~57.0.1" @@ -3624,14 +3672,14 @@ expo@~57.0.14: "@expo/metro" "~56.0.2" "@expo/metro-config" "~57.0.12" "@ungap/structured-clone" "^1.3.0" - babel-preset-expo "~57.0.10" + babel-preset-expo "~57.0.11" expo-asset "~57.0.16" expo-constants "~57.0.17" expo-file-system "~57.0.6" expo-font "~57.0.3" expo-keep-awake "~57.0.1" expo-modules-autolinking "~57.0.12" - expo-modules-core "~57.0.15" + expo-modules-core "~57.0.17" pretty-format "^29.7.0" react-refresh "^0.14.2" whatwg-url-minimum "^0.1.2" diff --git a/微信登录接入方案.md b/微信登录接入方案.md new file mode 100644 index 0000000..ea23548 --- /dev/null +++ b/微信登录接入方案.md @@ -0,0 +1,151 @@ +# 微信登录接入方案(P1) + +> 状态:方案文档 · 不涉及代码改写 · 真实后端就绪后按路线图替换 mock 即可 +> 适用范围:有职 App 微信授权登录(三分支状态机) +> 关联文件:`src/lib/wechat-sdk.ts`、`src/hooks/useWechatLogin.ts`、`src/api/api.ts`、`src/types/wechat-auth.ts`、`components/onboarding/screens/P1Login.tsx`、`components/onboarding/screens/P2PhoneCode.tsx` + +--- + +## 0. 一句话结论 + +前端三分支状态机与页面流转**已全部联通**,当前唯一阻塞点是:微信原生 SDK 未接入(Expo Go 无法跑原生模块)、以及后端两个接口仍是 mock。 +**接入真实微信 = 替换 1 个函数 (`requestWechatAuthCode`) + 替换 2 个接口函数体 (`fetchWechatLoginResult` / `bindWechatAndRegister`) + 申请微信开放平台移动应用 + 用 EAS Build 生成自定义客户端。** 上层 hook、页面、类型**零改动**。 + +--- + +## 1. 现状盘点(已实现 vs 待接入) + +| 层 | 文件 | 现状 | 接入动作 | +|---|---|---|---| +| 类型 | `src/types/wechat-auth.ts` | ✅ 已完整定义 | 无需改动 | +| 状态机 | `src/hooks/useWechatLogin.ts` | ✅ 8 态判别联合,已联通 | 无需改动 | +| 页面流转 | `P1Login.tsx` → `P2PhoneCode.tsx` → `P3` | ✅ 已联通 | 无需改动 | +| 绑定 hook | `src/hooks/useWechatBindPhone.ts` | ✅ 已联通 | 无需改动 | +| 登录态 | `src/context/AuthContext.tsx` | ✅ `login(token, expiresAt)` / `isTokenValid` 已就绪 | 无需改动 | +| **SDK 适配层** | `src/lib/wechat-sdk.ts` | ⚠️ **mock**:返回 `mock_wx_code_*` | **替换 `requestWechatAuthCode`** | +| **后端接口** | `src/api/api.ts` | ⚠️ **mock**:`fetchWechatLoginResult` / `bindWechatAndRegister` | **替换函数体** | +| 微信开放平台 | —— | ❌ 未申请移动应用 | 申请 AppID + Universal Links | +| 构建产物 | EAS | ❌ 无自定义客户端 | `eas build --profile development` | + +**已验证的关键事实**(直接取自源码,非推测): +- `useWechatLogin` 的 `start()` 逻辑:`isTokenValid` 为真 → `alreadyLoggedIn`(免授权直接落地);否则 `authenticating` → `requestWechatAuthCode()` → `verifying` → `fetchWechatLoginResult(code)`,按 `result.status` 分流 `active` / `new` / `deleted`。 +- `new` 分支:`setState({ kind: 'needBindPhone', ticket: { token: result.token, openid: result.openid } })` —— **临时凭证已下发并透传**。 +- `P1Login.tsx` 在 `needBindPhone` 时把 `wechatTicket` 写入 `useOnboardingFlow` 的 `wechatTicket` 字段并跳 `P2`。 +- `P2PhoneCode.tsx` 检测到 `wechatTicket` 后调用 `bindWechatAndRegister({ token, openid, phone, code })` 完成注册。 +- `deleted` 分支:不写登录态,仅提示不可登录,**无复活出口**(符合账号注销即不可逆的硬约束)。 + +--- + +## 2. 接入目标与成功标准 + +**目标**:让真实微信用户能走通 `new / active / deleted` 三条链路,且: +1. 用户第一次用微信登录 → 跳手机号+验证码 → 注册落地; +2. 老用户再次微信登录 → 直接进主界面(本地 token 有效时甚至免授权); +3. 处于注销审核期的账号 → 登录页明确提示「该账号正在申请注销中,无法登录」,无复活入口。 + +**成功标准(验收口径)**: +- [ ] `requestWechatAuthCode()` 真正调起微信并回传一次性 `code`(非 mock)。 +- [ ] 后端 `/api/auth/wechat` 用 code 换 openid → 判定 `new/active/deleted` → 首响下发可复用临时凭证。 +- [ ] `bind-phone` 用临时凭证(非 code)完成 openid 与手机号绑定注册。 +- [ ] iOS 能在微信授权后通过 Universal Links 正确回调 App;Android 能通过包名+签名回调。 +- [ ] 三分支在自定义客户端(非 Expo Go)上全部走通。 +- [ ] mock 辅助 `MOCK_WECHAT_STATUS` / `setMockWechatAccountStatus` 已删除,页面无任何 mock 分支残留。 + +--- + +## 3. 两个硬约束(设计已遵守,接后端不可破坏) + +1. **微信 code 一次性(5 分钟有效)**:用户授权后原 code 已被首次请求消耗,绑定手机号时**拿不到第二个 code**。因此后端必须在首次响应里下发 `WechatPendingTicket`(含 `token` + `openid`),前端在 `P2` 回传票据而非 code。 + - 类型已建模:`WechatPendingTicket { token: string; openid: string }`(`src/types/wechat-auth.ts:29`)。 +2. **注销即不可登录、不可复活**:`deleted` 分支前端不写登录态、不提供任何复活入口;后端需保证审核期内账号返回 `deleted` 且拒绝任何登录/复活请求。 + +--- + +## 4. 接入路线图(按执行顺序) + +### Step 1 · 微信开放平台申请移动应用(前置,阻塞项) +- 登录微信开放平台 → 创建**移动应用**,拿到 `AppID` / `AppSecret`。 +- **iOS**:登记 **Universal Links**(微信授权回调硬性要求)。需准备一个 HTTPS 域名 + 后端托管 `apple-app-site-association`。 +- **Android**:登记 **应用包名**(`com.youzhi.app`,见 `app.config.js`)与**应用签名**(发布/调试签名 SHA256)。 +- ⚠️ 此步产生的 `AppID`、`Universal Links` 域名需回填到代码与 EAS 配置(见 Step 3/4)。 + +### Step 2 · 安装微信 SDK(Expo 插件优先) +```bash +npx expo install react-native-wechat-lib +# 或选用维护更活跃的 expo 兼容插件(以官方 docs 为准) +``` +- 原生模块需在 `ios/` `android/` 原生壳中注册(Expo 前置预构建会处理 config plugin 注入,无需手改原生代码)。 + +### Step 3 · 生成 EAS 自定义开发客户端(Expo Go 跑不了原生模块) +- 当前 `eas.json` 已有 `development` profile(`developmentClient: true`)。 +- 执行: + ```bash + eas build --profile development --platform ios + eas build --profile development --platform android + ``` +- 安装产物到真机/模拟器。**后续所有真机验证都在此客户端中进行,Expo Go 无法调起微信。** + +### Step 4 · 替换 SDK 适配层(唯一改动点 · 前端) +文件:`src/lib/wechat-sdk.ts` +- 顶部 `import WeChat from 'react-native-wechat-lib';` +- `isWechatAvailable()` 改为 `return WeChat.isWXAppInstalled();` +- `requestWechatAuthCode()` 替换为: + ```ts + const res = await WeChat.sendAuthRequest('snsapi_userinfo', 'wechat_auth_state'); + return res.code; + ``` +- `app.config.js` 的 `extra` 中补 `wechatAppId`,在 SDK 初始化时读取(或走 `expo-config-plugin` 注入原生 Info.plist / AndroidManifest)。 +- **此文件改动不影响任何上层调用方**(签名不变)。 + +### Step 5 · 实现后端两个接口 +| 接口 | 方法 | 职责 | 关键返回 | +|---|---|---|---| +| `/api/auth/wechat` | POST | `code → access_token + openid → 查库判定状态` | `WechatLoginResult`:`new` 时下发明文 `WechatPendingTicket`;`active` 下发正式 token + `expiresAt` + `profile` + `role`;`deleted` 仅 `openid` | +| `/api/auth/wechat/bind-phone` | POST | 用临时凭证 + 手机号 + 验证码完成注册,关联 openid | `WechatSession`:`token` + `expiresAt` + `profile` + `role` | + +- 后端须保证:`code` 一次性;临时凭证有独立 TTL 且可校验来源;`deleted` 账号拒绝登录。 +- `WechatLoginResult` / `WechatSession` 字段契约见 `src/types/wechat-auth.ts`,前后端以此对齐。 + +### Step 6 · 替换 API 层函数体(后端就绪后) +文件:`src/api/api.ts` +- `fetchWechatLoginResult(code)`:删掉 `MOCK_WECHAT_STATUS` 分支,改为 `fetch(...)`,请求 `/api/auth/wechat`,返回 `WechatLoginResult`。函数签名**不变**。 +- `bindWechatAndRegister(payload)`:改为请求 `/api/auth/wechat/bind-phone`,返回 `WechatSession`。签名**不变**。 +- 保留 JSDoc 的 `@method` / `@url` / `@param` / `@returns` 注释。 + +### Step 7 · 清除 mock 残留 +- 删除 `src/api/api.ts` 中的 `let MOCK_WECHAT_STATUS`、`setMockWechatAccountStatus()` 及其导出(同步从 `src/api/index.ts` 移除导出)。 +- 删除 `P1Login.tsx` / 测试代码中调用 `setMockWechatAccountStatus` 的分支(若有)。 + +### Step 8 · 回调与票据闭环验证 +- **iOS Universal Links**:确认微信开放平台登记的 Universal Links 能打开 App,且 `useWechatLogin` 未被重复触发(`alreadyLoggedIn` 短路逻辑生效)。 +- **Android 包名/签名**:确认微信回调能正确唤起 App 并继续 `verifying`。 +- 验证 `needBindPhone` → `wechatTicket` 透传 → `P2` 调用 `bindWechatAndRegister` 全程用的是**临时凭证 token**,而非已失效的 code。 + +--- + +## 5. 手动验收路径(可复现) + +1. 在自定义客户端(非 Expo Go)上安装并登录。 +2. **active 分支**:用老微信账号登录 → 期望直接进主界面;杀进程重进(本地 token 有效)→ 期望 `alreadyLoggedIn` 免授权落地。 +3. **new 分支**:用全新微信账号登录 → 期望跳 `P2` 手机号+验证码 → 注册成功进 `P3` 选身份。 +4. **deleted 分支**:用处于注销审核期的账号登录 → 期望登录页提示「该账号正在申请注销中,无法登录」,无复活入口。 +5. 断网 / 取消授权 / 未装微信 → 期望 `error` 态并显示对应提示。 +6. 回归 `tsc --noEmit` 与 `eslint .` 均 0 错误、0 警告。 + +--- + +## 6. 风险与待确认项 + +| 项 | 风险/阻塞 | 处置 | +|---|---|---| +| `AppID` / Universal Links | 需用户到微信开放平台申请,非代码可解 | Step 1 前置,未拿到前无法真机验证 | +| react-native-wechat-lib 维护状态 | 库可能滞后于新架构 | 接入时以 Expo 官方 docs 选型为准(AGENTS.md 要求先读 `docs.expo.dev/versions/v57.0.0/`) | +| Universal Links 配置 | iOS 回调硬性要求,易踩坑 | 后端托管 `apple-app-site-association` + 真机验证 | +| 自定义客户端构建 | 首次 EAS Build 耗时较长 | Step 3 提前跑,避免阻塞联调 | +| 临时凭证安全 | code 一次性,凭证必须独立加密 + 短 TTL | 后端实现时强制 | + +--- + +## 7. 一句话给执行者 + +**别动 hook、页面、类型。** 只动两处:`src/lib/wechat-sdk.ts`(换真 SDK 调用)和 `src/api/api.ts`(换真接口),配齐微信开放平台移动应用 + EAS 自定义客户端,三分支即可真机走通。 diff --git a/项目开发状态.md b/项目开发状态.md new file mode 100644 index 0000000..6de4824 --- /dev/null +++ b/项目开发状态.md @@ -0,0 +1,112 @@ +# 有职 App 开发进度与规划文档 + +> 生成日期:2026-09-10 +> 最后更新:2026-09-10(EAS 构建/OTA 配置骨架已建 · 技术债 8 处 `any` 已清理) +> 范围:项目健康审查 + 已开发 / 待开发清单 +> 结论先行:**当前项目可运行**(`tsc` 0 错误、`eslint` 0 error、路由 0 失效跳转),无需修正阻塞错误。 + +--- + +## 一、健康审查结论 + +| 检查项 | 命令 / 方式 | 结果 | +|---|---|---| +| 类型检查 | `npx tsc --noEmit` | ✅ 0 错误(Exit 0) | +| 代码规范 | `npx eslint .` | ✅ 0 error / ⚠️ 8 warning(均为 `any` 软警告) | +| 路由闭合 | `node scripts/check-routes.mjs` | ✅ 63 路由全部存在,0 失效跳转 | +| 入口接线 | 读 `app/_layout.tsx` / `app/index.tsx` | ✅ 根栈 → 入口围栏 → 双端 Tab 完整 | +| 应用配置 | `app.config.js` | ✅ name「有职」、scheme `youzhi`、plugins 正确 | + +**判定**:项目处于可启动状态(`expo start` 即可运行),未发现阻塞运行的错误。本次未进入"修正"环节。 + +--- + +## 二、技术栈与架构 + +- **框架**:Expo SDK 57 · React Native 0.86.2 · React 19 · TypeScript 6 · Expo Router(文件路由) +- **双端同构**: + - 求职端 `(tabs)`:岗位 / 沟通 / 成长 / 我的(落地 `jobs`) + - 企业端 `(enterprise)`:岗位 / 人才 / 沟通 / 我的(落地 `job-management`) +- **分层(src/)**:`types`(领域模型) · `api`(5088 行 mock 数据层) · `hooks`(语义化取数) · `components`(42 组件) · `context`(Auth/Role) · `theme`(colors token) · `constants` · `utils` · `lib`(storage / wechat-sdk) +- **数据链路**:页面 → Hook(`useXxx`) → API(`fetchXxx`) → 后端。当前全 mock,签名已固化,接真实后端只改 `src/api/api.ts` 函数体。 +- **配色治理**:统一走 `src/theme/colors.ts`,禁止硬编码十六进制;透明度用 token + `15`/`20` 后缀。 + +--- + +## 三、已开发模块(按业务域) + +> 页面规模以行数为代理指标;5 行薄壳(如隐私政策 / 用户协议)是合理架构——内容由 `LegalDocScreen` 按 `docId` 加载,**非空壳**。 + +### 3.1 用户端(求职端) + +| 业务域 | 路由 | 关键页面(行数) | 规模 | 状态 | +|---|---|---|---|---| +| 引导 / 注册 | `/guide` `/register` `/onboarding` | guide(194) · register(69) · onboarding(488) | 中 | 已开发 | +| 岗位 | `/jobs` `/jobs/[id]` `/jobs/compare` `/jobs/rank` `/jobs/offer-compare` `/jobs/weight-settings` | jobs(756) · [id](612) · compare(306) · rank(753) · offer-compare(676) · weight-settings(285) | 大 | 已开发 | +| 沟通 | `/chat` `/chat/[id]` | chat(556) · [id](750) | 大 | 已开发 | +| 成长 | `/growth` `/game` `/game/level` `/progress` `/assessment` `/ability` `/holland*` `/interview*` `/resume*` `/job-preference` | 多页 300–600 行 | 大 | 已开发(逐关内容见四.2) | +| 我的 / 设置 | `/profile` `/settings` 及 10 个子页 | profile(743) · settings(262) · about / account / account-cancel / privacy* / notification / delete-data / user-agreement / privacy-policy | 大 | 已开发 | +| 增值 / 合规 / 工具 | `/legal*` `/tools/*` `/hr-evaluation` `/audit-report` `/appeal` `/operation-rules` `/enterprise-certification` `/personal-data-export` `/candidate-detail` `/article/[id]` `/history` `/favorites` `/job-evaluations` | 多页 300–720 行 | 大 | 已开发 | + +### 3.2 企业端 + +| 业务域 | 路由 | 关键页面(行数) | 规模 | 状态 | +|---|---|---|---|---| +| 岗位管理 | `/job-management` `/job-publish` `/job-evaluations` | job-management(645) · job-publish(633) · job-evaluations(752) | 大 | 已开发 | +| 人才 | `/talent-recommendation` `/candidate-detail` | talent-recommendation(684) · candidate-detail(719) | 大 | 已开发 | +| 沟通 | `/enterprise-chat` | enterprise-chat(611) | 大 | 已开发 | +| 我的 | `/enterprise-overview` | enterprise-overview(594) | 大 | 已开发(【设置】入口已接线 `/settings`;Tab 选中色已统一 `primary`) | + +> 企业端设置类页面复用根级 `app/settings/*`,符合「设置类统一收纳于【设置】页」规范。 + +--- + +## 四、待开发 / 进行中 / 风险 + +### 4.1 既定任务(来自项目规范,需逐项核实) + +- **企业端三项**(历史记录为待办,代码审查显示已基本完成): + 1. Tab 选中色统一回 `primary` —— ✅ 代码已用 `colors.primary` + 2. 我的页补【设置】入口 —— ✅ `enterprise-overview.tsx` 第 281 行已 `router.push('/settings')` + 3. 统一快捷入口话术 —— ✅ 已完成(2026-09-10):`MOCK_ENTERPRISE_QUICK_ACTIONS` 与沟通页 `QUICK_ACTIONS` 中 6 处 label 已对齐规范词(人才库→人才推荐、评价管理→岗位评价管理、沟通→企业沟通、面试日程/面试安排→面试评分),与「我的」菜单、`/interview-score` 路由名一致。 + 4. 沟通页快捷操作接线 —— ✅ 已完成(2026-09-10):`QuickActions` 死入口已绑定 `onPress`;批量发邀请→`/talent-recommendation`、面试评分→`/interview-score`(根级自包含页、可返回)走跳转,批量发Offer / 群发通知无对应企业端页、按项目约定弹 `Alert '功能开发中'`。tsc/eslint 0 问题。 +- **微信登录**:三分支(`new` / `active` / `deleted`)已建模,前端状态机 + 页面流转(P1Login → P2PhoneCode → P3)**已全联通**;`src/lib/wechat-sdk.ts` 隔离待接真实 SDK;需 EAS 自定义客户端 + 微信开放平台移动应用(iOS 另需 Universal Links)。当前为 mock 分支切换验证。**接入路线图已落文档:`微信登录接入方案.md`**。 + +### 4.2 功能完整性缺口(标注【待确认】为推断项,需与产品对齐) + +- **通关小游戏(7 关)**:框架页 `game.tsx` + `game/level.tsx` 已建,7 关(认识自己 → 能力 → 岗位 → 企业 → 简历 → 面试 → 签约)**内容与数据已完整**:`MOCK_GAME_LEVELS` 共 22 题(self 4 / 其余各 3),题型含单选/多选/输入,配套解析与 `tags` 用于岗位推荐。评分(≥60 通关)、解锁、进度聚合逻辑均在 `submitGameLevel` 实现。✅ 2026-09-10 完成内容增厚(每关由 1–2 题补至 3 题左右)。 +- **阿布阿诺 AI 法律服务**:免费层(咨询 / 模板 / FAQ)页已建;付费层 `legal/upgrade.tsx` 已建,但付费 / 支付链路 **【待确认】是否接通**。 +- **隐私政策 / 用户协议内容**:`src/api/api.ts` 中 `MOCK_PRIVACY_POLICY` / `MOCK_USER_AGREEMENT` 已含完整合规草案(各 9–10 章),**18 岁门槛已落地**(隐私政策第六章 + 用户协议前言)。仅余【待确认】占位待法务/产品替换:公司全称、注册地址、客服邮箱/电话、个人信息保护负责人邮箱、管辖法院。属**上架硬性审查项**,占位填妥即可提交审核。 + +### 4.3 工程与上线 + +- **应用图标**:`assets/icon.png` 仍为旧图,未替换为「有职」logo(需 1024×1024 PNG)【上架硬项】。 +- **EAS / OTA**:已创建 `eas.json`(development / preview / production + submit 配置骨架)并补齐 `app.config.js` 的 `owner` / `runtimeVersion` / `updates` / `ios.bundleIdentifier` / `android.package` / `extra.eas.projectId` 字段。占位项 `REPLACE_WITH_EAS_OWNER` 与 `REPLACE_WITH_PROJECT_ID` 需你 `eas login` 后执行 `eas init` 自动注入;`com.youzhi.app` 可按开发者账号调整。 +- **真实后端**:全量 mock(`src/api/api.ts` 5088 行),未切换真实 `fetch`。 +- **技术债**:8 处 `any` 软警告已全部清理(图标断言改为 `keyof typeof Ionicons.glyphMap`、路由断言改为 `as string`),`eslint .` 现 0 error 0 warning,`tsc --noEmit` 保持 0 错误。 + +--- + +## 五、后续增量开发建议(优先级) + +- **P0(上架前必做)**:图标替换【待你提供 1024×1024 logo】 · ~~隐私 / 协议内容填充~~ **隐私/协议草案【已存在,含 18 岁门槛,待填【待确认】占位】** · ~~EAS 初始化~~ **EAS 配置骨架【已完成,待账号注入占位】** · ~~18 岁门槛核对~~ **18 岁门槛【已在隐私政策第六章 + 用户协议前言落地】** · ~~微信登录接入评估~~ **微信登录接入评估【已完成 → 见 `微信登录接入方案.md`,SDK 接入阻塞于微信开放平台 AppID】**。 +- **P1(核心差异化)**:~~微信登录接入方案~~ **微信登录接入方案【已落文档,真实 SDK 接入阻塞于微信开放平台移动应用申请】** · ~~企业端快捷入口话术统一~~ **企业端快捷入口话术统一【已完成,6 处 label 对齐规范词;含沟通页快捷操作 onPress 接线,tsc/eslint 0 问题】** · 付费法律服务支付 · ~~通关小游戏 7 关内容~~ **通关小游戏 7 关内容【已完成:结构原已建成,本次增厚至 7 关 22 题,tsc/eslint 0 问题】**。 +- **P2(打磨)**:~~技术债清理~~ **技术债已清理(8 处 `any` → 明确类型,`eslint` 0 warning)** · 真实后端对接 · OTA 通道验证。 + +--- + +## 六、附:审查命令速查 + +```bash +# 类型检查(硬门槛,必须 0 错误) +npx tsc --noEmit + +# 代码规范 +npx eslint . + +# 路由闭合审计(改动路由后必跑) +node scripts/check-routes.mjs + +# 启动 +npm start # 即 expo start +```