Browse Source

chore: 有职 App 多模块推进与配置同步(2026-09-10)

按每日 17:50 提交纪律,精确分阶段 git add(未用 -A / 未用 git rm),
tsc --noEmit 0 错误。涵盖本日及近期累积改动。

| 模块 | 主要文件 | 变更要点 | 状态 |
|---|---|---|---|
| 构建/发布 | eas.json, app.config.js | EAS 构建/OTA 骨架(runtimeVersion/updates/channel、iOS·Android 包名) | 完成·占位待注入 |
| 依赖治理 | package.json, yarn.lock, package-lock.json(删) | 移 web 脚本、统一 yarn、删陈旧 lock | 完成 |
| 微信登录 | wechat-sdk.ts, useWechatLogin.ts, useWechatBindPhone.ts, wechat-auth.ts, P2WechatCode.tsx, DeletedAccountModal.tsx | 三分支状态机(new/active/deleted)+注销不可复活 | mock 完成·真实SDK 待开放平台申请 |
| 企业端 | enterprise-chat.tsx, enterprise-overview.tsx, talent-recommendation.tsx | 快捷入口话术统一 + 沟通页快捷操作 onPress 接线 | 完成 |
| 通关小游戏 | src/api/api.ts | 7 关内容增厚至 22 题(单选/多选/输入混排) | 完成 |
| UI 技术债 | profile/audit-report/game/legal.tsx | as any → Ionicons 类型化、路由类型化 | 完成 |
| 滑块验证/注销 | SliderCaptcha.tsx, ScreenShell.tsx, settings/account-cancel.tsx, index.tsx | 滑块验证 + 账号注销流程接线 | 完成 |
| 注册链路 | register.tsx, P1Login.tsx, P2PhoneCode.tsx, AuthContext/RoleContext | 微信分支 + 手机号验证整合 | 完成 |
| 路由审计 | scripts/check-routes.mjs | 死链/失效跳转自检工具 | 完成 |
| 文档 | 项目开发状态.md, 微信登录接入方案.md | 状态盘点 + 接入路线图 | 完成 |
master
doublex001 2 months ago
parent
commit
b8d19d29cc
  1. 3
      .gitignore
  2. 27
      AGENTS.md
  3. 23
      app.config.js
  4. 35
      app/(enterprise)/enterprise-chat.tsx
  5. 14
      app/(enterprise)/enterprise-overview.tsx
  6. 2
      app/(enterprise)/talent-recommendation.tsx
  7. 4
      app/(tabs)/profile.tsx
  8. 2
      app/audit-report.tsx
  9. 2
      app/game.tsx
  10. 20
      app/index.tsx
  11. 4
      app/legal.tsx
  12. 2
      app/register.tsx
  13. 17
      app/settings.tsx
  14. 2
      app/settings/account-cancel.tsx
  15. BIN
      assets/captcha-bg-1.jpg
  16. BIN
      assets/captcha-bg-2.jpg
  17. BIN
      assets/captcha-bg-3.jpg
  18. BIN
      assets/captcha-bg-4.jpg
  19. BIN
      assets/captcha-bg-5.jpg
  20. 14
      components/onboarding/assets.ts
  21. 85
      components/onboarding/screens/P1Login.tsx
  22. 49
      components/onboarding/screens/P2PhoneCode.tsx
  23. 134
      components/onboarding/screens/P2WechatCode.tsx
  24. 49
      components/onboarding/ui/DeletedAccountModal.tsx
  25. 6
      components/onboarding/ui/ScreenShell.tsx
  26. 306
      components/onboarding/ui/SliderCaptcha.tsx
  27. 21
      eas.json
  28. 2
      eslint.config.js
  29. 3
      package.json
  30. 102
      scripts/check-routes.mjs
  31. 287
      src/api/api.ts
  32. 4
      src/api/index.ts
  33. 6
      src/constants/index.ts
  34. 48
      src/context/AuthContext.tsx
  35. 16
      src/context/RoleContext.tsx
  36. 2
      src/hooks/index.ts
  37. 11
      src/hooks/useOfferCompareData.ts
  38. 8
      src/hooks/useOnboardingFlow.tsx
  39. 46
      src/hooks/useWechatBindPhone.ts
  40. 131
      src/hooks/useWechatLogin.ts
  41. 6
      src/hooks/useWeightSettings.ts
  42. 35
      src/lib/wechat-sdk.ts
  43. 8
      src/types/index.ts
  44. 98
      src/types/wechat-auth.ts
  45. 100
      yarn.lock
  46. 151
      微信登录接入方案.md
  47. 112
      项目开发状态.md

3
.gitignore

@ -6,6 +6,9 @@ node_modules/
# Expo # Expo
.expo/ .expo/
dist/ dist/
# 临时打包产物(expo export 验证用,禁止入库)
dist-*/
.export-tmp*
web-build/ web-build/
expo-env.d.ts expo-env.d.ts

27
AGENTS.md

@ -555,3 +555,30 @@ industry: '互联网/IT',
city: '北京', city: '北京',
}, },
]; ];
提交规范
提交信息遵循 https://www.conventionalcommits.org/en/v1.0.0/#specification
<type>: <中文简短标题>
常用类型:
- feat: 新增功能或资源;
- fix: 修复问题;
- docs: 修改 README、规范或其他文档;
- refactor: 重构代码或目录,不改变功能;
- chore: 构建、依赖或维护性修改;
- test: 新增或修改测试。
标题使用中文、简洁明确,并说明具体改动和原因。例如:
docs: 新增仓库协作与提交规范说明
feat: 新增可编辑同款简历模板
fix: 修复模板自动保存覆盖新内容
提交与 PR 前检查
git diff --check
git status --short
涉及技能修改时,运行对应的技能校验;涉及 HTML 模板时,至少进行一次浏览器预览,确认布局、资源路径和分页效果。

23
app.config.js

@ -17,10 +17,26 @@ export default {
orientation: 'portrait', orientation: 'portrait',
icon: './assets/icon.png', icon: './assets/icon.png',
userInterfaceStyle: 'light', 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: { ios: {
supportsTablet: true, supportsTablet: true,
// iOS Bundle Identifier:上架 App Store 的唯一标识,按你的开发者账号命名。
bundleIdentifier: 'com.youzhi.app',
}, },
android: { android: {
// Android Package:上架 Google Play 的唯一标识,通常与 iOS 保持一致。
package: 'com.youzhi.app',
adaptiveIcon: { adaptiveIcon: {
backgroundColor: '#E6F4FE', backgroundColor: '#E6F4FE',
foregroundImage: './assets/android-icon-foreground.png', foregroundImage: './assets/android-icon-foreground.png',
@ -28,12 +44,13 @@ export default {
monochromeImage: './assets/android-icon-monochrome.png', monochromeImage: './assets/android-icon-monochrome.png',
}, },
}, },
web: {
favicon: './assets/favicon.png',
},
plugins: ['expo-router', 'expo-font'], plugins: ['expo-router', 'expo-font'],
extra: { extra: {
apiBaseUrl: process.env.API_BASE_URL || 'https://dev.api.com', apiBaseUrl: process.env.API_BASE_URL || 'https://dev.api.com',
// EAS 项目 ID:`eas init` 后会自动注入此处(与 updates.url 中的 PROJECT_ID 对应)。
eas: {
projectId: 'REPLACE_WITH_PROJECT_ID',
},
}, },
}, },
}; };

35
app/(enterprise)/enterprise-chat.tsx

@ -3,6 +3,7 @@ import { useRouter } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@ -29,7 +30,7 @@ function Header({ companyName, onBack }: { companyName: string; onBack: () => vo
</View> </View>
<Pressable <Pressable
style={styles.startBtn} style={styles.startBtn}
onPress={() => router.push('/talent-recommendation')} onPress={() => router.navigate('/talent-recommendation')}
hitSlop={6} hitSlop={6}
> >
<Ionicons name="add" size={16} color={colors.white} /> <Ionicons name="add" size={16} color={colors.white} />
@ -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 ( return (
<View style={styles.quickActionsCard}> <View style={styles.quickActionsCard}>
{actions.map((action) => ( {actions.map((action) => (
<Pressable key={action.label} style={styles.quickAction}> <Pressable key={action.label} style={styles.quickAction} onPress={() => onPressAction(action)}>
<Ionicons name={action.icon as keyof typeof Ionicons.glyphMap} size={20} color={colors.textSecondary} /> <Ionicons name={action.icon as keyof typeof Ionicons.glyphMap} size={20} color={colors.textSecondary} />
<Text style={styles.quickActionText}>{action.label}</Text> <Text style={styles.quickActionText}>{action.label}</Text>
</Pressable> </Pressable>
@ -206,10 +216,10 @@ function EmptyState({
/* ===================== 页面 ===================== */ /* ===================== 页面 ===================== */
const QUICK_ACTIONS = [ const QUICK_ACTIONS: QuickActionItem[] = [
{ icon: 'mail-outline', label: '批量发邀请' }, { icon: 'mail-outline', label: '批量发邀请', route: '/talent-recommendation' },
{ icon: 'document-text-outline', label: '批量发Offer' }, { icon: 'document-text-outline', label: '批量发Offer' },
{ icon: 'calendar-outline', label: '面试安排' }, { icon: 'calendar-outline', label: '面试评分', route: '/interview-score' },
{ icon: 'notifications-outline', label: '群发通知' }, { icon: 'notifications-outline', label: '群发通知' },
]; ];
@ -218,6 +228,15 @@ export default function EnterpriseChatScreen() {
const { communication, loading, error, refresh } = useEnterpriseCommunication(); const { communication, loading, error, refresh } = useEnterpriseCommunication();
const [activeTab, setActiveTab] = useState('all'); const [activeTab, setActiveTab] = useState('all');
// 快捷操作分发:有目标路由则跳转,无对应页面则提示「功能开发中」
const handleQuickAction = (action: QuickActionItem) => {
if (action.route) {
router.push(action.route);
} else {
Alert.alert('提示', `${action.label}功能开发中,敬请期待`);
}
};
if (loading) { if (loading) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
@ -263,12 +282,12 @@ export default function EnterpriseChatScreen() {
); );
})} })}
</View> </View>
<QuickActions actions={QUICK_ACTIONS} /> <QuickActions actions={QUICK_ACTIONS} onPressAction={handleQuickAction} />
</View> </View>
) : ( ) : (
<EmptyState <EmptyState
dataReference={communication.dataReference} dataReference={communication.dataReference}
onDiscover={() => router.push('/talent-recommendation')} onDiscover={() => router.navigate('/talent-recommendation')}
/> />
)} )}
</ScrollView> </ScrollView>

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

@ -25,12 +25,10 @@ const COLOR_TOKENS: Record<string, string> = {
/* ===================== 子组件:顶部导航 ===================== */ /* ===================== 子组件:顶部导航 ===================== */
function Header({ onBack, onSettings }: { onBack: () => void; onSettings: () => void }) { function Header({ onSettings }: { onSettings: () => void }) {
return ( return (
<View style={styles.header}> <View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}> <View style={styles.headerBtn} />
<Ionicons name="chevron-back" size={24} color={colors.textInverse} />
</Pressable>
<Text style={styles.headerTitle}>我的</Text> <Text style={styles.headerTitle}>我的</Text>
<Pressable style={styles.headerBtn} onPress={onSettings} hitSlop={8}> <Pressable style={styles.headerBtn} onPress={onSettings} hitSlop={8}>
<Ionicons name="settings-outline" size={22} color={colors.textInverse} /> <Ionicons name="settings-outline" size={22} color={colors.textInverse} />
@ -116,7 +114,7 @@ function QuickActions({ actions }: { actions: EnterpriseQuickAction[] }) {
<Pressable <Pressable
key={action.id} key={action.id}
style={styles.quickItem} style={styles.quickItem}
onPress={() => action.route && router.push(action.route as any)} onPress={() => action.route && router.push(action.route as string)}
> >
<View style={[styles.quickIcon, { backgroundColor: color + '15' }]}> <View style={[styles.quickIcon, { backgroundColor: color + '15' }]}>
<Ionicons name={action.icon as keyof typeof Ionicons.glyphMap} size={22} color={color} /> <Ionicons name={action.icon as keyof typeof Ionicons.glyphMap} size={22} color={color} />
@ -181,7 +179,7 @@ function ManageList({ actions }: { actions: EnterpriseQuickAction[] }) {
<Pressable <Pressable
key={action.id} key={action.id}
style={styles.manageRow} style={styles.manageRow}
onPress={() => action.route && router.push(action.route as any)} onPress={() => action.route && router.push(action.route as string)}
> >
<View style={[styles.manageIcon, { backgroundColor: color + '15' }]}> <View style={[styles.manageIcon, { backgroundColor: color + '15' }]}>
<Ionicons name={action.icon as keyof typeof Ionicons.glyphMap} size={20} color={color} /> <Ionicons name={action.icon as keyof typeof Ionicons.glyphMap} size={20} color={color} />
@ -211,7 +209,7 @@ function MenuList({ items }: { items: MenuItem[] }) {
<Pressable <Pressable
key={item.id} key={item.id}
style={styles.manageRow} style={styles.manageRow}
onPress={() => item.route && router.push(item.route as any)} onPress={() => item.route && router.push(item.route as string)}
> >
<View style={[styles.manageIcon, { backgroundColor: colors.primary + '15' }]}> <View style={[styles.manageIcon, { backgroundColor: colors.primary + '15' }]}>
<Ionicons name={item.icon as keyof typeof Ionicons.glyphMap} size={20} color={colors.primary} /> <Ionicons name={item.icon as keyof typeof Ionicons.glyphMap} size={20} color={colors.primary} />
@ -280,7 +278,7 @@ export default function EnterpriseOverviewScreen() {
<View style={styles.container}> <View style={styles.container}>
<LinearGradient colors={headerGradient} style={styles.gradientHeader}> <LinearGradient colors={headerGradient} style={styles.gradientHeader}>
<SafeAreaView edges={['top']}> <SafeAreaView edges={['top']}>
<Header onBack={() => router.back()} onSettings={() => router.push('/settings')} /> <Header onSettings={() => router.push('/settings')} />
<CompanyCard overview={overview} /> <CompanyCard overview={overview} />
</SafeAreaView> </SafeAreaView>
</LinearGradient> </LinearGradient>

2
app/(enterprise)/talent-recommendation.tsx

@ -127,7 +127,7 @@ function CandidateCard({ candidate }: { candidate: TalentCandidate }) {
</View> </View>
<View style={styles.candidateActions}> <View style={styles.candidateActions}>
<Pressable style={styles.chatBtn} onPress={() => router.push('/enterprise-chat')}> <Pressable style={styles.chatBtn} onPress={() => router.navigate('/enterprise-chat')}>
<Text style={styles.chatBtnText}>发起沟通</Text> <Text style={styles.chatBtnText}>发起沟通</Text>
</Pressable> </Pressable>
<Pressable style={styles.detailBtn} onPress={() => router.push('/candidate-detail?id=' + candidate.id)}> <Pressable style={styles.detailBtn} onPress={() => router.push('/candidate-detail?id=' + candidate.id)}>

4
app/(tabs)/profile.tsx

@ -156,7 +156,7 @@ function EnterpriseEntryCard({ entry }: { entry: EnterpriseEntry }) {
style={styles.enterpriseCard} style={styles.enterpriseCard}
onPress={() => { onPress={() => {
setRole('enterprise'); setRole('enterprise');
router.push('/enterprise-overview'); router.replace('/(enterprise)/enterprise-overview');
}} }}
hitSlop={4} hitSlop={4}
> >
@ -349,7 +349,7 @@ export default function ProfileScreen() {
hitSlop={4} hitSlop={4}
> >
<View style={[styles.menuIconWrap, { backgroundColor: getMenuIconBg(item.id) }]}> <View style={[styles.menuIconWrap, { backgroundColor: getMenuIconBg(item.id) }]}>
<Ionicons name={item.icon as any} size={18} color={getMenuIconColor(item.id)} /> <Ionicons name={item.icon as keyof typeof Ionicons.glyphMap} size={18} color={getMenuIconColor(item.id)} />
</View> </View>
<Text style={styles.menuTitle}>{item.title}</Text> <Text style={styles.menuTitle}>{item.title}</Text>
<Ionicons name="chevron-forward" size={16} color={colors.textHint} /> <Ionicons name="chevron-forward" size={16} color={colors.textHint} />

2
app/audit-report.tsx

@ -45,7 +45,7 @@ function KPICard({ kpi }: { kpi: AuditKPI }) {
return ( return (
<View style={styles.kpiCard}> <View style={styles.kpiCard}>
<View style={[styles.kpiIconWrap, { backgroundColor: `${kpi.color}15` }]}> <View style={[styles.kpiIconWrap, { backgroundColor: `${kpi.color}15` }]}>
<Ionicons name={kpi.icon as any} size={20} color={kpi.color} /> <Ionicons name={kpi.icon as keyof typeof Ionicons.glyphMap} size={20} color={kpi.color} />
</View> </View>
<Text style={styles.kpiValue}> <Text style={styles.kpiValue}>
{kpi.value} {kpi.value}

2
app/game.tsx

@ -96,7 +96,7 @@ function LevelCard({
disabled={disabled} disabled={disabled}
> >
<View style={[styles.levelIconWrap, { backgroundColor: colors.white }]}> <View style={[styles.levelIconWrap, { backgroundColor: colors.white }]}>
<Ionicons name={level.icon as any} size={22} color={theme.icon} /> <Ionicons name={level.icon as keyof typeof Ionicons.glyphMap} size={22} color={theme.icon} />
</View> </View>
<View style={styles.levelBody}> <View style={styles.levelBody}>
<Text style={[styles.levelOrder, { color: theme.text }]}>第 {level.order} 关</Text> <Text style={[styles.levelOrder, { color: theme.text }]}>第 {level.order} 关</Text>

20
app/index.tsx

@ -1,19 +1,26 @@
import { Redirect } from 'expo-router'; import { Redirect } from 'expo-router';
import { StyleSheet, View } from 'react-native';
import { useAuth } from '../src/context/AuthContext'; import { useAuth } from '../src/context/AuthContext';
import { useRole } from '../src/context/RoleContext'; import { useRole } from '../src/context/RoleContext';
import { colors } from '../src/theme';
/** /**
* 应用总入口(根路由 /)。 * 应用总入口(根路由 /)。
* 按「引导 → 登录 → 选择身份 → 对应端」漏斗放行,保证每个页面都有合理的前置入口。 * 按「引导 → 登录 → 选择身份 → 对应端」漏斗放行,保证每个页面都有合理的前置入口。
*/ */
export default function Index() { export default function Index() {
const { onboardingSeen, isLoggedIn } = useAuth(); const { onboardingSeen, isTokenValid, isHydrated: authHydrated } = useAuth();
const { role } = useRole(); const { role, isHydrated: roleHydrated } = useRole();
// 本地存储读取完成前不做任何跳转决策,否则已登录用户会闪现引导页/注册页
if (!authHydrated || !roleHydrated) {
return <View style={styles.container} />;
}
if (!onboardingSeen) { if (!onboardingSeen) {
return <Redirect href="/guide" />; return <Redirect href="/guide" />;
} }
if (!isLoggedIn) { if (!isTokenValid) {
return <Redirect href="/register" />; return <Redirect href="/register" />;
} }
if (!role) { if (!role) {
@ -26,3 +33,10 @@ export default function Index() {
/> />
); );
} }
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.white,
},
});

4
app/legal.tsx

@ -58,7 +58,7 @@ function ServiceCard({ service, onPress }: { service: LegalService; onPress: ()
onPress={onPress} onPress={onPress}
> >
<View style={styles.serviceIconWrap}> <View style={styles.serviceIconWrap}>
<Ionicons name={service.icon as any} size={22} color={isMember ? colors.action : colors.primary} /> <Ionicons name={service.icon as keyof typeof Ionicons.glyphMap} size={22} color={isMember ? colors.action : colors.primary} />
</View> </View>
<View style={styles.serviceBody}> <View style={styles.serviceBody}>
<View style={styles.serviceTitleRow}> <View style={styles.serviceTitleRow}>
@ -96,7 +96,7 @@ function FAQItem({ faq }: { faq: LegalFAQ }) {
function TemplateCard({ template }: { template: LegalTemplate }) { function TemplateCard({ template }: { template: LegalTemplate }) {
return ( return (
<View style={styles.templateCard}> <View style={styles.templateCard}>
<Ionicons name={template.icon as any} size={22} color={colors.primary} /> <Ionicons name={template.icon as keyof typeof Ionicons.glyphMap} size={22} color={colors.primary} />
<View style={styles.templateBody}> <View style={styles.templateBody}>
<Text style={styles.templateName}>{template.name}</Text> <Text style={styles.templateName}>{template.name}</Text>
<Text style={styles.templateDesc}>{template.description}</Text> <Text style={styles.templateDesc}>{template.description}</Text>

2
app/register.tsx

@ -2,6 +2,7 @@ import { OnboardingProvider, useOnboarding } from '../src/hooks';
import { P1Login } from '../components/onboarding/screens/P1Login'; import { P1Login } from '../components/onboarding/screens/P1Login';
import { P2PhoneCode } from '../components/onboarding/screens/P2PhoneCode'; import { P2PhoneCode } from '../components/onboarding/screens/P2PhoneCode';
import { P2QuickLogin } from '../components/onboarding/screens/P2QuickLogin'; import { P2QuickLogin } from '../components/onboarding/screens/P2QuickLogin';
import { P2WechatCode } from '../components/onboarding/screens/P2WechatCode';
import { P3Identity } from '../components/onboarding/screens/P3Identity'; import { P3Identity } from '../components/onboarding/screens/P3Identity';
import { P4Intent } from '../components/onboarding/screens/P4Intent'; import { P4Intent } from '../components/onboarding/screens/P4Intent';
import { P5City } from '../components/onboarding/screens/P5City'; import { P5City } from '../components/onboarding/screens/P5City';
@ -31,6 +32,7 @@ function FlowRouter() {
case 'P1': return <P1Login />; case 'P1': return <P1Login />;
case 'P2': return <P2PhoneCode />; case 'P2': return <P2PhoneCode />;
case 'P2Q': return <P2QuickLogin />; case 'P2Q': return <P2QuickLogin />;
case 'P2W': return <P2WechatCode />;
case 'P3': return <P3Identity />; case 'P3': return <P3Identity />;
case 'P4': return <P4Intent />; case 'P4': return <P4Intent />;
case 'P5': return <P5City />; case 'P5': return <P5City />;

17
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 { SafeAreaView } from 'react-native-safe-area-context';
import { colors } from '../src/theme'; import { colors } from '../src/theme';
import { useAuth } from '../src/context/AuthContext'; import { useAuth } from '../src/context/AuthContext';
import { useRole } from '../src/context/RoleContext';
/* ===================== 设置项结构 ===================== */ /* ===================== 设置项结构 ===================== */
@ -38,7 +39,6 @@ const SETTINGS_GROUPS: SettingsGroup[] = [
id: 'identity', id: 'identity',
title: '身份切换', title: '身份切换',
icon: 'swap-horizontal-outline', icon: 'swap-horizontal-outline',
route: '/register',
}, },
], ],
}, },
@ -92,6 +92,7 @@ function Header({ onBack }: { onBack: () => void }) {
export default function SettingsScreen() { export default function SettingsScreen() {
const router = useRouter(); const router = useRouter();
const { logout } = useAuth(); const { logout } = useAuth();
const { role, setRole } = useRole();
const handleLogout = () => { const handleLogout = () => {
Alert.alert('确认退出登录', '退出后需要重新登录', [ 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) => { const handlePress = (item: SettingsItem) => {
if (item.id === 'identity') {
handleIdentitySwitch();
return;
}
if (item.route) { if (item.route) {
router.push(item.route); router.push(item.route);
return; return;

2
app/settings/account-cancel.tsx

@ -99,7 +99,7 @@ export default function AccountCancelScreen() {
<View style={[styles.warnCard, { backgroundColor: colors.dangerLight }]}> <View style={[styles.warnCard, { backgroundColor: colors.dangerLight }]}>
<Ionicons name="warning-outline" size={18} color={colors.dangerText} /> <Ionicons name="warning-outline" size={18} color={colors.dangerText} />
<Text style={[styles.warnText, { color: colors.dangerText }]}> <Text style={[styles.warnText, { color: colors.dangerText }]}>
注销后账号将无法恢复,请谨慎操作 提交注销申请后,账号将进入注销审核流程,期间无法登录且不可恢复,审核结束后将永久删除,请谨慎操作
</Text> </Text>
</View> </View>

BIN
assets/captcha-bg-1.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

BIN
assets/captcha-bg-2.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

BIN
assets/captcha-bg-3.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

BIN
assets/captcha-bg-4.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

BIN
assets/captcha-bg-5.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

14
components/onboarding/assets.ts

@ -42,3 +42,17 @@ export const ASSET_REGISTRY: Record<AssetName, AssetDef> = {
businessLicense: { source: null, tint: colors.primaryLight, icon: 'document', hint: '营业执照示例图' }, businessLicense: { source: null, tint: colors.primaryLight, icon: 'document', hint: '营业执照示例图' },
defaultAvatar: { source: null, tint: colors.borderLight, icon: 'person', 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 */

85
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 { Ionicons } from '@expo/vector-icons';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { useRouter } from 'expo-router';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { colors } from '../../../src/theme'; import { colors } from '../../../src/theme';
import { useOnboarding } from '../../../src/hooks'; import { useOnboarding, useWechatLogin } from '../../../src/hooks';
import { useRole } from '../../../src/context/RoleContext';
import { Illustration } from '../ui/Illustration'; import { Illustration } from '../ui/Illustration';
import { AgreementCheckbox } from '../ui/AgreementCheckbox'; import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { AgreementModal } from '../ui/AgreementModal'; import { AgreementModal } from '../ui/AgreementModal';
import { SliderCaptcha } from '../ui/SliderCaptcha';
import { DeletedAccountModal } from '../ui/DeletedAccountModal';
/** P1 登录主页:对齐参考样式 —— 淡渐变顶部 + 居中 Logo + 微信绿实心/灰底一键登录 + 协议弹窗拦截 */ /** P1 登录主页:对齐参考样式 —— 淡渐变顶部 + 居中 Logo + 微信绿实心/灰底一键登录 + 协议弹窗拦截 */
export function P1Login() { export function P1Login() {
const { data, setData, navigate } = useOnboarding(); const { data, setData, navigate } = useOnboarding();
const router = useRouter();
const { setRole } = useRole();
const agreed = data.agreed; const agreed = data.agreed;
/** 未勾选协议时被拦截的动作,弹窗「同意」后继续执行 */ /** 未勾选协议时被拦截的动作,弹窗「同意」后继续执行 */
const [pending, setPending] = useState<(() => void) | null>(null); 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) => { const gate = (action: () => void) => {
@ -33,6 +48,46 @@ export function P1Login() {
act?.(); 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 ( return (
<SafeAreaView style={styles.screen} edges={['top', 'bottom']}> <SafeAreaView style={styles.screen} edges={['top', 'bottom']}>
<StatusBar style="dark" /> <StatusBar style="dark" />
@ -46,11 +101,21 @@ export function P1Login() {
<View style={styles.flexSpacer} /> <View style={styles.flexSpacer} />
{/* 登录方式 */} {/* 登录方式 */}
<Pressable style={styles.wechatBtn} onPress={() => gate(() => navigate('B1'))}> <Pressable
style={styles.wechatBtn}
onPress={() => gate(wechatLogin)}
disabled={wechatLoading}
>
<Ionicons name="logo-wechat" size={20} color={colors.textInverse} /> <Ionicons name="logo-wechat" size={20} color={colors.textInverse} />
<Text style={styles.wechatText}>微信登录</Text> <Text style={styles.wechatText}>
{wechatLoading ? '登录中…' : '微信登录'}
</Text>
</Pressable> </Pressable>
{wechatState.kind === 'error' ? (
<Text style={styles.loginError}>{wechatState.message}</Text>
) : null}
<Pressable style={styles.quickBtn} onPress={() => gate(() => navigate('P2Q'))}> <Pressable style={styles.quickBtn} onPress={() => gate(() => navigate('P2Q'))}>
<Text style={styles.quickText}>一键登录</Text> <Text style={styles.quickText}>一键登录</Text>
</Pressable> </Pressable>
@ -85,6 +150,17 @@ export function P1Login() {
</View> </View>
</ScrollView> </ScrollView>
<SliderCaptcha
visible={captchaVisible}
onSuccess={() => {
setCaptchaVisible(false);
void startWechatLogin();
}}
onClose={() => setCaptchaVisible(false)}
/>
<DeletedAccountModal visible={wechatState.kind === 'deleted'} onClose={cancelLogin} />
<AgreementModal <AgreementModal
visible={pending !== null} visible={pending !== null}
onAccept={accept} onAccept={accept}
@ -129,6 +205,7 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
color: colors.textSecondary, color: colors.textSecondary,
}, },
loginError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger },
agree: { marginTop: 22 }, agree: { marginTop: 22 },
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 }, agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
link: { color: colors.primary, fontWeight: '600' }, link: { color: colors.primary, fontWeight: '600' },

49
components/onboarding/screens/P2PhoneCode.tsx

@ -2,12 +2,13 @@ import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { colors } from '../../../src/theme'; import { colors } from '../../../src/theme';
import { useOnboarding } from '../../../src/hooks'; import { useOnboarding, useWechatBindPhone } from '../../../src/hooks';
import { ScreenShell } from '../ui/ScreenShell'; import { ScreenShell } from '../ui/ScreenShell';
import { CountdownButton } from '../ui/CountdownButton'; import { CountdownButton } from '../ui/CountdownButton';
import { AgreementCheckbox } from '../ui/AgreementCheckbox'; import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { PrimaryButton } from '../ui/PrimaryButton'; import { PrimaryButton } from '../ui/PrimaryButton';
import { AgreementModal } from '../ui/AgreementModal'; import { AgreementModal } from '../ui/AgreementModal';
import { SliderCaptcha } from '../ui/SliderCaptcha';
const PHONE_RE = /^1\d{10}$/; const PHONE_RE = /^1\d{10}$/;
@ -18,10 +19,15 @@ export function P2PhoneCode() {
const [codeSent, setCodeSent] = useState(false); const [codeSent, setCodeSent] = useState(false);
/** 未勾选协议时被拦截的动作 */ /** 未勾选协议时被拦截的动作 */
const [pending, setPending] = useState<(() => void) | null>(null); const [pending, setPending] = useState<(() => void) | null>(null);
const [captchaVisible, setCaptchaVisible] = useState(false);
/** 微信第一次登录的绑定注册能力 */
const { bind, loading: binding, error: bindError } = useWechatBindPhone();
const agreed = data.agreed; const agreed = data.agreed;
const phone = data.phone; const phone = data.phone;
const phoneValid = PHONE_RE.test(phone); const phoneValid = PHONE_RE.test(phone);
/** 有值说明本页是从「微信第一次登录」进来的,提交时需完成微信绑定 */
const wechatTicket = data.wechatTicket;
const gate = (action: () => void) => { const gate = (action: () => void) => {
if (agreed) { if (agreed) {
@ -44,9 +50,28 @@ export function P2PhoneCode() {
setCodeSent(true); setCodeSent(true);
}; };
const handleLogin = () => { /*
* 提交验证码:
* - 微信第一次登录(data.wechatTicket 有值):调绑定注册接口把 openid 与手机号关联,
* 成功即写入登录态,再进 P3 选择身份;失败则留在当前页并提示。
* - 其他链路:沿用原逻辑,仅记录手机号后进 P3。
*/
const handleLogin = async () => {
if (code.length < 4) return; if (code.length < 4) return;
setData({ phone, code }); setData({ phone, code });
if (wechatTicket) {
try {
await bind({
token: wechatTicket.token,
openid: wechatTicket.openid,
phone,
code,
});
} catch {
return;
}
}
navigate('P3'); navigate('P3');
}; };
@ -112,7 +137,7 @@ export function P2PhoneCode() {
</View> </View>
<View style={styles.socialRow}> <View style={styles.socialRow}>
<Pressable style={[styles.socialCircle, { backgroundColor: colors.success }]} onPress={() => gate(() => navigate('B1'))}> <Pressable style={[styles.socialCircle, { backgroundColor: colors.success }]} onPress={() => gate(() => setCaptchaVisible(true))}>
<Ionicons name="logo-wechat" size={26} color={colors.textInverse} /> <Ionicons name="logo-wechat" size={26} color={colors.textInverse} />
</Pressable> </Pressable>
<Pressable style={[styles.socialCircle, { backgroundColor: colors.primary }]} onPress={() => gate(sendCode)}> <Pressable style={[styles.socialCircle, { backgroundColor: colors.primary }]} onPress={() => gate(sendCode)}>
@ -144,9 +169,15 @@ export function P2PhoneCode() {
<View style={styles.underline} /> <View style={styles.underline} />
<View style={styles.btnArea}> <View style={styles.btnArea}>
<PrimaryButton label="登录" active={code.length >= 4} onPress={() => gate(handleLogin)} /> <PrimaryButton
label={binding ? '绑定中…' : '登录'}
active={code.length >= 4 && !binding}
onPress={() => gate(handleLogin)}
/>
</View> </View>
{bindError ? <Text style={styles.bindError}>{bindError.message}</Text> : null}
<View style={styles.agree}> <View style={styles.agree}>
<AgreementCheckbox <AgreementCheckbox
checked={agreed} checked={agreed}
@ -165,6 +196,15 @@ export function P2PhoneCode() {
</View> </View>
)} )}
<SliderCaptcha
visible={captchaVisible}
onSuccess={() => {
setCaptchaVisible(false);
navigate('P2W');
}}
onClose={() => setCaptchaVisible(false)}
/>
<AgreementModal <AgreementModal
visible={pending !== null} visible={pending !== null}
onAccept={accept} onAccept={accept}
@ -195,6 +235,7 @@ const styles = StyleSheet.create({
smsHelp: { fontSize: 13, color: colors.textHint, marginTop: 16 }, smsHelp: { fontSize: 13, color: colors.textHint, marginTop: 16 },
btnArea: { marginTop: 28 }, btnArea: { marginTop: 28 },
agree: { marginTop: 18 }, agree: { marginTop: 18 },
bindError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger },
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 }, agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
link: { color: colors.primary, fontWeight: '600' }, link: { color: colors.primary, fontWeight: '600' },
orRow: { flexDirection: 'row', alignItems: 'center', marginTop: 40, gap: 12 }, orRow: { flexDirection: 'row', alignItems: 'center', marginTop: 40, gap: 12 },

134
components/onboarding/screens/P2WechatCode.tsx

@ -0,0 +1,134 @@
import { useEffect, useRef, useState } from 'react';
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 { PrimaryButton } from '../ui/PrimaryButton';
const RESEND_SECONDS = 60;
/** P2W 微信登录短信验证码页:×关闭 + 4格验证码 + 重新发送倒计时 + 语音/收不到短信入口 */
export function P2WechatCode() {
const { data, navigate, back } = useOnboarding();
const [code, setCode] = useState('');
const [left, setLeft] = useState(RESEND_SECONDS);
const timer = useRef<ReturnType<typeof setInterval> | 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 (
<ScreenShell showBack onBack={back} backIcon="close">
<Text style={styles.h1}>输入短信验证码</Text>
<Text style={styles.subtitle}>已向您的手机 {tail} 发送验证码</Text>
{/* 4 格分离输入:透明输入层覆盖,格子仅做展示 */}
<Pressable style={styles.cellsWrap}>
<View style={styles.cellsRow}>
{[0, 1, 2, 3].map((i) => (
<View key={i} style={styles.cell}>
<Text style={styles.cellText}>{code[i] ?? ''}</Text>
<View style={[styles.cellLine, code.length === i && styles.cellLineActive]} />
</View>
))}
</View>
<TextInput
style={styles.hiddenInput}
value={code}
onChangeText={(t) => setCode(t.replace(/[^\d]/g, '').slice(0, 4))}
keyboardType="number-pad"
maxLength={4}
autoFocus
/>
</Pressable>
<Pressable onPress={resend} hitSlop={6} disabled={left > 0}>
<Text style={[styles.resend, left > 0 && styles.resendDisabled]}>
{left > 0 ? `重新发送(${left}s)` : '重新发送'}
</Text>
</Pressable>
<View style={styles.btnArea}>
<PrimaryButton label="下一步" active={canNext} onPress={() => navigate('B1')} />
</View>
<View style={styles.helpRow}>
<Pressable hitSlop={6}>
<Text style={styles.helpText}>语音接听验证码</Text>
</Pressable>
<Pressable hitSlop={6}>
<Text style={styles.helpText}>接收不到短信</Text>
</Pressable>
</View>
<View style={styles.toastBar}>
<Text style={styles.toastText}>短信验证码正在发送,请注意查收</Text>
</View>
</ScreenShell>
);
}
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 },
});

49
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 (
<Modal visible={visible} onClose={onClose}>
<Text style={styles.title}>无法登录</Text>
<Text style={styles.message}>该账号正在申请注销中,无法登录。</Text>
<View style={styles.btnRow}>
<Pressable style={[styles.btn, styles.confirmBtn]} onPress={onClose} hitSlop={4}>
<Text style={styles.confirmText}>知道了</Text>
</Pressable>
</View>
</Modal>
);
}
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 },
});

6
components/onboarding/ui/ScreenShell.tsx

@ -9,6 +9,8 @@ interface Props {
title?: string; title?: string;
showBack?: boolean; showBack?: boolean;
onBack?: () => void; onBack?: () => void;
/** 返回图标:默认 chevron-back,可传 close(对齐验证码页的 × 关闭) */
backIcon?: 'chevron-back' | 'close';
children: ReactNode; children: ReactNode;
footer?: ReactNode; footer?: ReactNode;
scroll?: boolean; 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 ( return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<StatusBar style="dark" /> <StatusBar style="dark" />
{showBack ? ( {showBack ? (
<Pressable style={styles.backRow} onPress={onBack} hitSlop={10}> <Pressable style={styles.backRow} onPress={onBack} hitSlop={10}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} /> <Ionicons name={backIcon} size={backIcon === 'close' ? 26 : 24} color={colors.textPrimary} />
{title ? <Text style={styles.titleInline}>{title}</Text> : null} {title ? <Text style={styles.titleInline}>{title}</Text> : null}
</Pressable> </Pressable>
) : title ? ( ) : title ? (

306
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<Puzzle | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
/** 当前使用的背景图(每次 generate 随机从池中选取) */
const [bg, setBg] = useState<ImageSourcePropType | null>(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 ? (
<SvgImage
href={bg as number}
x={0}
y={0}
width={areaW}
height={AREA_H}
preserveAspectRatio="xMidYMid slice"
clipPath={clip}
/>
) : (
<G clipPath={clip}>
<Rect x={0} y={0} width={areaW} height={AREA_H} fill="url(#capBg)" />
<Circle cx={areaW * 0.25} cy={AREA_H * 0.3} r={34} fill={colors.white} opacity={0.12} />
<Circle cx={areaW * 0.7} cy={AREA_H * 0.72} r={46} fill={colors.white} opacity={0.1} />
<Circle cx={areaW * 0.86} cy={AREA_H * 0.2} r={20} fill={colors.white} opacity={0.14} />
</G>
);
return (
<Modal visible={visible} onClose={onClose}>
{/* 拼图验证区:背景 + 缺口 + 可拖动拼图块 */}
<View style={styles.puzzleArea} onLayout={(e) => setAreaW(e.nativeEvent.layout.width)}>
{areaW > 0 && puzzle ? (
<Svg width={areaW} height={AREA_H} style={styles.svg}>
<Defs>
<LinearGradient
id="capBg"
x1={0}
y1={0}
x2={areaW}
y2={AREA_H}
gradientUnits="userSpaceOnUse"
>
<Stop offset={0} stopColor={colors.primary} />
<Stop offset={1} stopColor={colors.success} />
</LinearGradient>
<ClipPath id="pieceClip">
<Path d={jigsaw(pieceX, puzzle.TY, puzzle.PW, puzzle.PH)} />
</ClipPath>
</Defs>
{/* 静态背景 */}
{renderBg()}
{/* 目标缺口(待填充位置) */}
<Path
d={jigsaw(puzzle.targetPieceX, puzzle.TY, puzzle.PW, puzzle.PH)}
fill={colors.scrim}
opacity={0.55}
stroke={colors.white}
strokeWidth={2}
/>
{/* 可拖动拼图块 */}
<Path
d={jigsaw(pieceX + 2, puzzle.TY + 2, puzzle.PW, puzzle.PH)}
fill={colors.textPrimary}
opacity={0.18}
/>
{renderBg('url(#pieceClip)')}
<Path
d={jigsaw(pieceX, puzzle.TY, puzzle.PW, puzzle.PH)}
fill="none"
stroke={colors.primary}
strokeWidth={2.5}
/>
</Svg>
) : (
<View style={styles.puzzlePlaceholder}>
<Text style={styles.puzzleHint}>拼图加载中…</Text>
</View>
)}
</View>
{/* 滑块轨道 */}
<View style={styles.track} onLayout={(e) => setTrackW(e.nativeEvent.layout.width)}>
<Text style={[styles.trackHint, { opacity: dx > 8 || passed ? 0 : 1 }]}>
{passed ? '验证通过' : '拖动滑块完成拼图'}
</Text>
<View
style={[styles.knob, { transform: [{ translateX: dx }] }, passed && styles.knobPassed]}
{...pan.panHandlers}
>
<Ionicons
name={passed ? 'checkmark' : 'reorder-three'}
size={22}
color={passed ? colors.textInverse : colors.primary}
/>
</View>
</View>
{/* 底部操作行 */}
<View style={styles.toolRow}>
<Pressable onPress={close} hitSlop={8}>
<Ionicons name="close-circle-outline" size={22} color={colors.textHint} />
</Pressable>
<Pressable onPress={refresh} hitSlop={8}>
<Ionicons name="refresh" size={22} color={colors.textHint} />
</Pressable>
<Ionicons name="volume-medium-outline" size={22} color={colors.textHint} />
</View>
</Modal>
);
}
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,
},
});

21
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": {}
}
}

2
eslint.config.js

@ -41,7 +41,7 @@ module.exports = tseslint.config(
'dist/**', 'dist/**',
'coverage/**', 'coverage/**',
'figma_out/**', 'figma_out/**',
'*.mjs', '**/*.mjs',
'figma_discover.py', 'figma_discover.py',
'**/__tests__/**', '**/__tests__/**',
], ],

3
package.json

@ -5,7 +5,7 @@
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"expo": "~57.0.14", "expo": "57.0.21",
"expo-blur": "~57.0.2", "expo-blur": "~57.0.2",
"expo-constants": "~57.0.12", "expo-constants": "~57.0.12",
"expo-font": "~57.0.1", "expo-font": "~57.0.1",
@ -23,7 +23,6 @@
"start": "expo start", "start": "expo start",
"android": "expo start --android", "android": "expo start --android",
"ios": "expo start --ios", "ios": "expo start --ios",
"web": "expo start --web",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"test": "jest" "test": "jest"

102
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') || '(无)');

287
src/api/api.ts

@ -115,8 +115,12 @@ import type {
MatchModeOption, MatchModeOption,
OperationRules, OperationRules,
AuditReport, AuditReport,
WechatAccountStatus,
WechatLoginResult,
WechatBindPhonePayload,
WechatSession,
} from '../types'; } from '../types';
import { MOCK_DELAY, SALARY_RANGES } from '../constants'; import { MOCK_DELAY, SALARY_RANGES, TOKEN_TTL_MS } from '../constants';
import { colors } from '../theme'; import { colors } from '../theme';
/** 接口地址(通过 expo-constants 从 app.config.js 的 extra 读取) */ /** 接口地址(通过 expo-constants 从 app.config.js 的 extra 读取) */
@ -2368,10 +2372,10 @@ export async function submitAppeal(
const MOCK_ENTERPRISE_QUICK_ACTIONS: EnterpriseQuickAction[] = [ const MOCK_ENTERPRISE_QUICK_ACTIONS: EnterpriseQuickAction[] = [
{ id: 'publish', label: '发布岗位', icon: 'add-circle-outline', route: '/job-publish', color: 'primary' }, { id: 'publish', label: '发布岗位', icon: 'add-circle-outline', route: '/job-publish', color: 'primary' },
{ id: 'jobs', label: '岗位管理', icon: 'business-outline', route: '/job-management', 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: 'evaluations', label: '岗位评价管理', icon: 'chatbubbles-outline', route: '/job-evaluations', color: 'success' },
{ id: 'candidates', label: '人才库', icon: 'people-outline', route: '/talent-recommendation', color: 'info' }, { id: 'candidates', label: '人才推荐', icon: 'people-outline', route: '/talent-recommendation', color: 'info' },
{ id: 'communication', label: '沟通', icon: 'chatbubble-outline', route: '/enterprise-chat', color: 'action' }, { id: 'communication', label: '企业沟通', icon: 'chatbubble-outline', route: '/enterprise-chat', color: 'action' },
{ id: 'interviews', label: '面试日程', icon: 'calendar-outline', route: '/interview-score', color: 'action' }, { id: 'interviews', label: '面试评分', icon: 'calendar-outline', route: '/interview-score', color: 'action' },
]; ];
/** /**
@ -3339,6 +3343,27 @@ const MOCK_GAME_LEVELS: GameLevel[] = [
explanation: '城市选择直接影响薪资预期与生活成本,建议结合行业聚集地进行选择。', explanation: '城市选择直接影响薪资预期与生活成本,建议结合行业聚集地进行选择。',
tags: ['城市'], 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: '开源项目、竞赛获奖、专业认证都是简历中极具说服力的硬技能证明。', explanation: '开源项目、竞赛获奖、专业认证都是简历中极具说服力的硬技能证明。',
tags: ['技能证明'], 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 中的核心技能要求决定了你是否能胜任,是筛选岗位的首要依据。', explanation: '岗位 JD 中的核心技能要求决定了你是否能胜任,是筛选岗位的首要依据。',
tags: ['岗位理解'], 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: '官网口号往往经过包装,工商信息、员工评价、业务健康度才是判断企业的硬指标。', explanation: '官网口号往往经过包装,工商信息、员工评价、业务健康度才是判断企业的硬指标。',
tags: ['企业判断'], 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 法则(情境-任务-行动-结果)加量化数据,是项目经历最说服人的写法。', explanation: 'STAR 法则(情境-任务-行动-结果)加量化数据,是项目经历最说服人的写法。',
tags: ['简历'], 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: '坦诚展示自我认知与改进计划,能体现你的成长型思维。', explanation: '坦诚展示自我认知与改进计划,能体现你的成长型思维。',
tags: ['面试'], 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: '团建频率不属于劳动合同必备条款,薪资、试用期、岗位地点才是核心。', explanation: '团建频率不属于劳动合同必备条款,薪资、试用期、岗位地点才是核心。',
tags: ['签约'], 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<KeywordBlockS
const MOCK_ACCOUNT_CANCEL: AccountCancelData = { const MOCK_ACCOUNT_CANCEL: AccountCancelData = {
title: '账号注销', title: '账号注销',
consequences: [ consequences: [
'账号将无法登录,平台内所有数据将被永久删除且无法恢复', '账号将无法登录且不可恢复,平台内所有数据将被清空且永久删除',
'正在进行的投递、面试邀约与沟通记录将全部终止', '正在进行的投递、面试邀约与沟通记录将全部终止',
'已获得的会员权益、优惠券、成长值与勋章将失效且不予退还', '已获得的会员权益、优惠券、成长值与勋章将失效且不予退还',
'若你已完成企业认证,认证信息与关联招聘流程将一并注销', '若你已完成企业认证,认证信息与关联招聘流程将一并注销',
@ -4786,7 +4962,7 @@ const MOCK_ACCOUNT_SECURITY: ActionSetting[] = [
{ {
id: 'deactivate', id: 'deactivate',
title: '注销账号', title: '注销账号',
description: '注销后账号数据将被永久删除,不可恢复', description: '注销后账号不可恢复,且注销审核期间无法登录',
variant: 'danger', variant: 'danger',
}, },
]; ];
@ -4975,6 +5151,103 @@ export function loginByPhone(phone: string, code: string): Promise<LoginResult>
return mockRequest<LoginResult>({ token: `mock_token_${phone}`, phone }); return mockRequest<LoginResult>({ 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<WechatLoginResult> {
if (!code) {
return Promise.reject(new Error('微信授权失败,请重试'));
}
if (MOCK_WECHAT_STATUS === 'active') {
return mockRequest<WechatLoginResult>({
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<WechatLoginResult>({
status: 'deleted',
token: '',
openid: 'mock_openid_deleted',
});
}
return mockRequest<WechatLoginResult>({
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<WechatSession> {
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<WechatSession>({
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 * @method POST

4
src/api/index.ts

@ -2,6 +2,7 @@
* 接口统一导出 * 接口统一导出
*/ */
export type { LoginResult } from './api'; export type { LoginResult } from './api';
export type { WechatLoginResult, WechatSession, WechatBindPhonePayload } from '../types';
export { export {
fetchAboutInfo, fetchAboutInfo,
fetchAbilityAssessment, fetchAbilityAssessment,
@ -75,6 +76,9 @@ export {
submitDeleteData, submitDeleteData,
fetchLegalDocument, fetchLegalDocument,
loginByPhone, loginByPhone,
fetchWechatLoginResult,
bindWechatAndRegister,
setMockWechatAccountStatus,
fetchPersonalDataExport, fetchPersonalDataExport,
requestPersonalDataExport, requestPersonalDataExport,
fetchRankData, fetchRankData,

6
src/constants/index.ts

@ -34,3 +34,9 @@ export const EDUCATION_OPTIONS: string[] = ['不限', '大专', '本科', '硕
/** 热门城市(定位/城市选择用) */ /** 热门城市(定位/城市选择用) */
export const CITIES: string[] = ['北京', '上海', '深圳', '杭州', '广州', '成都', '南京', '武汉']; export const CITIES: string[] = ['北京', '上海', '深圳', '杭州', '广州', '成都', '南京', '武汉'];
/** 一天的毫秒数,用于令牌有效期等时间计算 */
export const ONE_DAY_MS = 24 * 60 * 60 * 1000;
/** 登录令牌有效期(毫秒),与后端 JWT 过期时间保持一致 */
export const TOKEN_TTL_MS = 30 * ONE_DAY_MS;

48
src/context/AuthContext.tsx

@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { storageGet, storageSet } from '../lib/storage'; import { storageGet, storageSet } from '../lib/storage';
import { TOKEN_TTL_MS } from '../constants';
/* ===================== 鉴权(引导/登录)上下文 ===================== /* ===================== 鉴权(引导/登录)上下文 =====================
* 管理「引导页是否看过」「是否已登录」「登录令牌」「分端标记」。 * 管理「引导页是否看过」「是否已登录」「登录令牌」「分端标记」。
@ -20,14 +21,31 @@ export interface AuthContextValue {
isLoggedIn: boolean; isLoggedIn: boolean;
/** 登录令牌(mock 态为占位串) */ /** 登录令牌(mock 态为占位串) */
token: string | null; token: string | null;
/** 令牌过期时间戳(毫秒);null 表示无有效令牌 */
tokenExpiresAt: number | null;
/**
* 本地令牌是否仍然有效(已登录且未过期)。
* 微信登录「第 2~N 次」场景依赖此值:有效则免微信授权直接进主界面。
*/
isTokenValid: boolean;
/** 求职端:求职意向是否已设置(设置后不再强制进入设置页) */ /** 求职端:求职意向是否已设置(设置后不再强制进入设置页) */
candidateSetupDone: boolean; candidateSetupDone: boolean;
/** 企业端:是否已提交/完成企业认证(走完该步骤后不再强制) */ /** 企业端:是否已提交/完成企业认证(走完该步骤后不再强制) */
enterpriseVerified: boolean; enterpriseVerified: boolean;
/**
* 本地存储是否已读取完毕。
* 冷启动时 AsyncStorage 读取是异步的,在读取完成前上述字段仍是默认值;
* 入口页必须等该标记为 true 再决定跳转,否则已登录用户会闪现引导页。
*/
isHydrated: boolean;
/** 标记引导页已看完 */ /** 标记引导页已看完 */
completeOnboarding: () => void; completeOnboarding: () => void;
/** 登录成功,写入 token */ /**
login: (token: string) => void; * 登录成功,写入 token。
* @param token 登录令牌
* @param expiresAt 过期时间戳(毫秒);不传时按 TOKEN_TTL_MS 推算
*/
login: (token: string, expiresAt?: number) => void;
/** 退出登录,清空令牌 */ /** 退出登录,清空令牌 */
logout: () => void; logout: () => void;
/** 标记求职意向已设置 */ /** 标记求职意向已设置 */
@ -42,6 +60,7 @@ interface MemoryAuth {
onboardingSeen: boolean; onboardingSeen: boolean;
isLoggedIn: boolean; isLoggedIn: boolean;
token: string | null; token: string | null;
tokenExpiresAt: number | null;
candidateSetupDone: boolean; candidateSetupDone: boolean;
enterpriseVerified: boolean; enterpriseVerified: boolean;
} }
@ -52,6 +71,7 @@ const DEFAULT_AUTH: MemoryAuth = {
onboardingSeen: false, onboardingSeen: false,
isLoggedIn: false, isLoggedIn: false,
token: null, token: null,
tokenExpiresAt: null,
candidateSetupDone: false, candidateSetupDone: false,
enterpriseVerified: false, enterpriseVerified: false,
}; };
@ -77,15 +97,21 @@ function loadAuth(): Promise<MemoryAuth | null> {
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<MemoryAuth>(memoryAuth); const [state, setState] = useState<MemoryAuth>(memoryAuth);
const [isHydrated, setIsHydrated] = useState(false);
// 启动时从 async-storage 恢复登录/引导/分端标记 // 启动时从 async-storage 恢复登录/引导/分端标记
useEffect(() => { useEffect(() => {
let active = true; let active = true;
loadAuth().then((stored) => { loadAuth()
.then((stored) => {
if (active && stored) { if (active && stored) {
memoryAuth = stored; memoryAuth = stored;
setState(stored); setState(stored);
} }
})
.finally(() => {
// 无论读取成功、为空还是异常,都必须置位,否则入口页会一直停在占位态
if (active) setIsHydrated(true);
}); });
return () => { return () => {
active = false; active = false;
@ -98,8 +124,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setState(next); setState(next);
}; };
const login = (token: string) => { const login = (token: string, expiresAt?: number) => {
const next = { ...state, isLoggedIn: true, token }; const next: MemoryAuth = {
...state,
isLoggedIn: true,
token,
tokenExpiresAt: expiresAt ?? Date.now() + TOKEN_TTL_MS,
};
persistAuth(next); persistAuth(next);
setState(next); setState(next);
}; };
@ -109,6 +140,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
...state, ...state,
isLoggedIn: false, isLoggedIn: false,
token: null, token: null,
tokenExpiresAt: null,
candidateSetupDone: false, candidateSetupDone: false,
enterpriseVerified: false, enterpriseVerified: false,
}; };
@ -128,10 +160,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setState(next); setState(next);
}; };
// 派生值,不入库:已登录且过期时间晚于当前时间才算有效
const isTokenValid =
state.isLoggedIn && state.token !== null && (state.tokenExpiresAt ?? 0) > Date.now();
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
...state, ...state,
isHydrated,
isTokenValid,
completeOnboarding, completeOnboarding,
login, login,
logout, logout,

16
src/context/RoleContext.tsx

@ -23,6 +23,12 @@ export type { AppRole };
interface RoleContextValue { interface RoleContextValue {
role: AppRole | null; role: AppRole | null;
/**
* 本地存储中的角色是否已读取完毕。
* 与 AuthContext.isHydrated 配套:入口页需等两者都就绪再决定跳哪一端,
* 否则已登录用户冷启动时会因 role 仍为 null 被错误重定向到注册流程。
*/
isHydrated: boolean;
setRole: (role: AppRole) => void; setRole: (role: AppRole) => void;
clearRole: () => void; clearRole: () => void;
} }
@ -45,11 +51,17 @@ function loadRole(): Promise<AppRole | null> {
export function RoleProvider({ children }: { children: ReactNode }) { export function RoleProvider({ children }: { children: ReactNode }) {
const [role, setRoleState] = useState<AppRole | null>(null); const [role, setRoleState] = useState<AppRole | null>(null);
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
loadRole().then((stored) => { loadRole()
.then((stored) => {
if (active && stored) setRoleState(stored as AppRole); if (active && stored) setRoleState(stored as AppRole);
})
.finally(() => {
// 无论读取成功、为空还是异常都必须置位,避免入口页永久停在占位态
if (active) setIsHydrated(true);
}); });
return () => { return () => {
active = false; active = false;
@ -67,7 +79,7 @@ export function RoleProvider({ children }: { children: ReactNode }) {
}; };
return ( return (
<RoleContext.Provider value={{ role, setRole, clearRole }}> <RoleContext.Provider value={{ role, isHydrated, setRole, clearRole }}>
{children} {children}
</RoleContext.Provider> </RoleContext.Provider>
); );

2
src/hooks/index.ts

@ -58,3 +58,5 @@ export { useWeightSettings } from './useWeightSettings';
export { useLogin } from './useLogin'; export { useLogin } from './useLogin';
export { OnboardingProvider, useOnboarding } from './useOnboardingFlow'; export { OnboardingProvider, useOnboarding } from './useOnboardingFlow';
export { useRegisterFinish } from './useRegisterFinish'; export { useRegisterFinish } from './useRegisterFinish';
export { useWechatLogin, type PendingBindPhone, type WechatLoginState } from './useWechatLogin';
export { useWechatBindPhone } from './useWechatBindPhone';

11
src/hooks/useOfferCompareData.ts

@ -7,7 +7,16 @@ import type { OfferCompareData } from '../types';
import { useAsyncData } from './useAsyncData'; import { useAsyncData } from './useAsyncData';
export function useOfferCompareData(offerIds?: string[]) { 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<OfferCompareData>(fetcher); const { data, loading, error, refresh } = useAsyncData<OfferCompareData>(fetcher);

8
src/hooks/useOnboardingFlow.tsx

@ -7,6 +7,13 @@ export interface OnboardingData {
phone: string; phone: string;
code: string; code: string;
agreed: boolean; agreed: boolean;
/**
* 微信登录带入的临时凭证。
* 仅「微信第一次登录」链路有值:微信授权后 openid 未绑定账号,
* 需走手机号 + 验证码完成注册,后端据此把 openid 与新账号关联。
* 手机号登录等其他链路为 null,此时 P2 页按普通登录处理。
*/
wechatTicket: { token: string; openid: string } | null;
identity: 'candidate' | 'recruiter' | null; identity: 'candidate' | 'recruiter' | null;
isStudent: boolean | null; isStudent: boolean | null;
city: string; city: string;
@ -38,6 +45,7 @@ const INITIAL: OnboardingData = {
phone: '', phone: '',
code: '', code: '',
agreed: true, agreed: true,
wechatTicket: null,
identity: null, identity: null,
isStudent: null, isStudent: null,
city: '上海', city: '上海',

46
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<WechatSession>;
/** 绑定进行中 */
loading: boolean;
/** 绑定失败的错误(用于页面提示) */
error: Error | null;
}
/**
* 微信新用户绑定手机号(第一次登录的第二步)。
*
* 微信授权后 openid 尚未绑定账号,必须用手机号 + 短信验证码完成注册。
* 成功后写入登录态;返回会话交给页面决定是继续走身份选择还是直接落地。
*/
export function useWechatBindPhone(): UseWechatBindPhoneReturn {
const { login } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(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 };
}

131
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<void>;
/** 关闭注销提示,回到登录页 */
cancelLogin: () => void;
/** 重置为初始态 */
reset: () => void;
}
/** 初始态 */
const IDLE: WechatLoginState = { kind: 'idle' };
export function useWechatLogin(): UseWechatLoginReturn {
const { login, isTokenValid } = useAuth();
const [state, setState] = useState<WechatLoginState>(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,
};
}

6
src/hooks/useWeightSettings.ts

@ -33,7 +33,11 @@ export function useWeightSettings(): WeightSettingsState {
const [localDimensions, setLocalDimensions] = useState<WeightDimension[] | null>(null); const [localDimensions, setLocalDimensions] = useState<WeightDimension[] | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const remoteDimensions = data?.dimensions ?? []; /*
* 必须记忆化:data 为空时 `?? []` 每次渲染都会生成新数组,
* 会让下面依赖它的 useCallback 每次渲染都变引用(进而触发额外重渲染)。
*/
const remoteDimensions = useMemo(() => data?.dimensions ?? [], [data]);
const dimensions = localDimensions ?? remoteDimensions; const dimensions = localDimensions ?? remoteDimensions;
const totalWeight = useMemo( const totalWeight = useMemo(

35
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<string> {
/*
* mock 实现:返回带时间戳的假 code,仅用于打通前端三分支流程。
* 真实实现见上方注释。
*/
return Promise.resolve(`mock_wx_code_${Date.now()}`);
}

8
src/types/index.ts

@ -247,3 +247,11 @@ export type {
PersonalDataExport, PersonalDataExport,
RecentExportRecord, RecentExportRecord,
} from './data-export'; } from './data-export';
export type {
WechatAccountStatus,
WechatBindPhonePayload,
WechatLoginResult,
WechatPendingTicket,
WechatSession,
WechatUserProfile,
} from './wechat-auth';

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

100
yarn.lock

@ -726,10 +726,10 @@
resolved "https://registry.npmmirror.com/@expo-google-fonts/material-symbols/-/material-symbols-0.4.45.tgz#2536d34796127ef209d80ffd8b8b248b2a3fd1ee" resolved "https://registry.npmmirror.com/@expo-google-fonts/material-symbols/-/material-symbols-0.4.45.tgz#2536d34796127ef209d80ffd8b8b248b2a3fd1ee"
integrity sha512-D5gd/uFa7KfMJX9S11hvej4GpQnBl27oKiUgyIq3cLTPtyDOWBwRgmeePIdV6+PGcedlDkaCND2BnRgAkxzNaA== integrity sha512-D5gd/uFa7KfMJX9S11hvej4GpQnBl27oKiUgyIq3cLTPtyDOWBwRgmeePIdV6+PGcedlDkaCND2BnRgAkxzNaA==
"@expo/cli@^57.0.21": "@expo/cli@^57.0.23":
version "57.0.21" version "57.0.23"
resolved "https://registry.npmmirror.com/@expo/cli/-/cli-57.0.21.tgz#fcf3e41828c5a073bac84a2bcbab47eaaf2dfc5d" resolved "https://registry.npmmirror.com/@expo/cli/-/cli-57.0.23.tgz#020be0e0886677ec013d10e11e7c9ce597b8329c"
integrity sha512-CkcgAOsp1KkNRENXWfyr/esIwgFtiHosKECWZ0QQl2nFOUEQlyjWXAZ6uTCMnbHLonVnPMZpXTkHZNw3gtanFw== integrity sha512-stzSYxVwbWGbKR+mrYT6s4Rt6HrcUxb0JrHyebDGL4ehpvfRTv0rMzn4Q7fqB6f5wJ1KCmf6OAgqgK+TTB8/FA==
dependencies: dependencies:
"@expo/code-signing-certificates" "^0.0.6" "@expo/code-signing-certificates" "^0.0.6"
"@expo/config" "~57.0.9" "@expo/config" "~57.0.9"
@ -742,7 +742,7 @@
"@expo/log-box" "^57.0.4" "@expo/log-box" "^57.0.4"
"@expo/metro" "~56.0.2" "@expo/metro" "~56.0.2"
"@expo/metro-config" "~57.0.12" "@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/osascript" "^2.7.1"
"@expo/package-manager" "^1.13.1" "@expo/package-manager" "^1.13.1"
"@expo/plist" "^0.8.1" "@expo/plist" "^0.8.1"
@ -963,10 +963,10 @@
postcss "^8.5.14" postcss "^8.5.14"
resolve-from "^5.0.0" resolve-from "^5.0.0"
"@expo/metro-file-map@^57.0.2": "@expo/metro-file-map@^57.0.3":
version "57.0.2" version "57.0.3"
resolved "https://registry.npmmirror.com/@expo/metro-file-map/-/metro-file-map-57.0.2.tgz#5833c378aebbda32447aeb7a6e51ad5a5a3e0bf0" resolved "https://registry.npmmirror.com/@expo/metro-file-map/-/metro-file-map-57.0.3.tgz#1ed09151ac17f594fc08051de15894cdfef07503"
integrity sha512-tb50nSIWwKpRufSkGuivOK0FbUxv1Uwqptb0SzFTk0bkmYmcy3gIwnCbOHTfmrQDVndRhPEiu2SYgh9Z0PCvGg== integrity sha512-1OXy+uPYY5uc7Tm4VBsd2NRn+3wHhqeqNuEO/Xo4kmYgv8FjYgUAc+bUXON9FpC2ikcLn4EVlGM9ce2exx9Mlg==
dependencies: dependencies:
debug "^4.3.4" debug "^4.3.4"
fb-watchman "^2.0.2" 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-private-property-in-object" "^7.14.5"
"@babel/plugin-syntax-top-level-await" "^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" version "57.0.10"
resolved "https://registry.npmmirror.com/babel-preset-expo/-/babel-preset-expo-57.0.10.tgz#2d7655c2636a4c97e0615e90aa362410410ba9bb" resolved "https://registry.npmmirror.com/babel-preset-expo/-/babel-preset-expo-57.0.10.tgz#2d7655c2636a4c97e0615e90aa362410410ba9bb"
integrity sha512-08bt6bqMZFoQuWO9gkt0EXotbqKHimELFGa1LlKJvY89iKsi4S0FJSFBFJ4pn9NZvofet48HIjYDQ0SZJ7bNrg== integrity sha512-08bt6bqMZFoQuWO9gkt0EXotbqKHimELFGa1LlKJvY89iKsi4S0FJSFBFJ4pn9NZvofet48HIjYDQ0SZJ7bNrg==
@ -3542,19 +3590,19 @@ expo-modules-autolinking@~57.0.12:
chalk "^4.1.0" chalk "^4.1.0"
commander "^7.2.0" commander "^7.2.0"
expo-modules-core@~57.0.15: expo-modules-core@~57.0.17:
version "57.0.15" version "57.0.17"
resolved "https://registry.npmmirror.com/expo-modules-core/-/expo-modules-core-57.0.15.tgz#721c6bec863ec21b0c873a65a5977a86f352c70f" resolved "https://registry.npmmirror.com/expo-modules-core/-/expo-modules-core-57.0.17.tgz#627bf8fbab9100b9e9d1047ff75353d9c9cb8813"
integrity sha512-HBxPXsx3eVLLRjAY57/8ghKDWIxjaEbluKKYFIq1OHPJC85ZfHsawwc0SOU4Z/qu+mdLz7Pwt5IkkWimeNSH5A== integrity sha512-hHJwGHW0sMQiOLzEEl1QbJeWkBEBgjlPjrdBVGWlzvgUbCXJfFbDU2nhxoyzLMZnvHkpIzpiJjj5cM5W8gbudA==
dependencies: dependencies:
"@expo/expo-modules-macros-plugin" "0.6.1" "@expo/expo-modules-macros-plugin" "0.6.1"
expo-modules-jsi "~57.0.7" expo-modules-jsi "~57.1.0"
invariant "^2.2.4" invariant "^2.2.4"
expo-modules-jsi@~57.0.7: expo-modules-jsi@~57.1.0:
version "57.0.7" version "57.1.0"
resolved "https://registry.npmmirror.com/expo-modules-jsi/-/expo-modules-jsi-57.0.7.tgz#f862a364161345433fcc4ddb5f0e2f33543f491f" resolved "https://registry.npmmirror.com/expo-modules-jsi/-/expo-modules-jsi-57.1.0.tgz#21c1d3df20ea886e937a9ea2930699e34e164867"
integrity sha512-/GOrTFPnCfg7+m6M1yOSEKNeNdHBCm0ju6aJ4XHlSmtE02nb7mWh1Cs6QsK2eB5Rz7hjbb356Nd7UimsFvN7Vg== integrity sha512-a5ckeHfnbYfonhcHGkM0EU/a0Keh+/OufXy/HyFS+spr5Ib0N4Oh1ZsSYFQhp5xdOUFkVofKRhfH+VmcccIIpA==
expo-router@~57.0.14: expo-router@~57.0.14:
version "57.0.18" version "57.0.18"
@ -3607,13 +3655,13 @@ expo-symbols@^57.0.2:
"@expo-google-fonts/material-symbols" "^0.4.1" "@expo-google-fonts/material-symbols" "^0.4.1"
sf-symbols-typescript "^2.0.0" sf-symbols-typescript "^2.0.0"
expo@~57.0.14: expo@57.0.21:
version "57.0.19" version "57.0.21"
resolved "https://registry.npmmirror.com/expo/-/expo-57.0.19.tgz#06b4371f5ff32cc71c0ba68dbc4ae3770a56891b" resolved "https://registry.npmmirror.com/expo/-/expo-57.0.21.tgz#6464818c652c63a6f1f71be706e0840b7a6ab124"
integrity sha512-oIoAIisim1DwS2Zjp0rcEY9cfcq1wU6KpkBfwwFhJWI5YHV6i/aIzTUMX+uf0Q0CD6AvMls7IZVh34o27NjaJA== integrity sha512-lQmC0kCJCleO+uLUwHXY0pLDzcvedKEsX+pmJp4mSxn3JlWDTZvUb0e5UIlV7+bqlN68LpAeKG4c2lrN4zuP0Q==
dependencies: dependencies:
"@babel/runtime" "^7.20.0" "@babel/runtime" "^7.20.0"
"@expo/cli" "^57.0.21" "@expo/cli" "^57.0.23"
"@expo/config" "~57.0.9" "@expo/config" "~57.0.9"
"@expo/config-plugins" "~57.0.9" "@expo/config-plugins" "~57.0.9"
"@expo/devtools" "~57.0.1" "@expo/devtools" "~57.0.1"
@ -3624,14 +3672,14 @@ expo@~57.0.14:
"@expo/metro" "~56.0.2" "@expo/metro" "~56.0.2"
"@expo/metro-config" "~57.0.12" "@expo/metro-config" "~57.0.12"
"@ungap/structured-clone" "^1.3.0" "@ungap/structured-clone" "^1.3.0"
babel-preset-expo "~57.0.10" babel-preset-expo "~57.0.11"
expo-asset "~57.0.16" expo-asset "~57.0.16"
expo-constants "~57.0.17" expo-constants "~57.0.17"
expo-file-system "~57.0.6" expo-file-system "~57.0.6"
expo-font "~57.0.3" expo-font "~57.0.3"
expo-keep-awake "~57.0.1" expo-keep-awake "~57.0.1"
expo-modules-autolinking "~57.0.12" expo-modules-autolinking "~57.0.12"
expo-modules-core "~57.0.15" expo-modules-core "~57.0.17"
pretty-format "^29.7.0" pretty-format "^29.7.0"
react-refresh "^0.14.2" react-refresh "^0.14.2"
whatwg-url-minimum "^0.1.2" whatwg-url-minimum "^0.1.2"

151
微信登录接入方案.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 自定义客户端,三分支即可真机走通。

112
项目开发状态.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
```
Loading…
Cancel
Save