Compare commits

...

4 Commits

Author SHA1 Message Date
doublex001 f6d42023be feat: 薪资计算器城市选择 + 文书模板预览(占位审计断点收口) 2 months ago
doublex001 cbf38e7745 feat: 法律服务付费闭环、会员权益兑现与岗位评价发布(2026-09-10) 2 months ago
doublex001 1db789397c chore: 移除陈旧的 package-lock.json(yarn 工程,避免 EAS 构建包管理器歧义) 2 months ago
doublex001 b8d19d29cc chore: 有职 App 多模块推进与配置同步(2026-09-10) 2 months ago
  1. 3
      .gitignore
  2. 29
      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. 37
      app/jobs/[id].tsx
  12. 105
      app/legal.tsx
  13. 395
      app/legal/checkout.tsx
  14. 430
      app/legal/service.tsx
  15. 145
      app/legal/template.tsx
  16. 6
      app/legal/templates.tsx
  17. 82
      app/legal/upgrade.tsx
  18. 2
      app/register.tsx
  19. 17
      app/settings.tsx
  20. 2
      app/settings/account-cancel.tsx
  21. 131
      app/tools/salary-calculator.tsx
  22. BIN
      assets/captcha-bg-1.jpg
  23. BIN
      assets/captcha-bg-2.jpg
  24. BIN
      assets/captcha-bg-3.jpg
  25. BIN
      assets/captcha-bg-4.jpg
  26. BIN
      assets/captcha-bg-5.jpg
  27. 14
      components/onboarding/assets.ts
  28. 85
      components/onboarding/screens/P1Login.tsx
  29. 49
      components/onboarding/screens/P2PhoneCode.tsx
  30. 134
      components/onboarding/screens/P2WechatCode.tsx
  31. 49
      components/onboarding/ui/DeletedAccountModal.tsx
  32. 6
      components/onboarding/ui/ScreenShell.tsx
  33. 306
      components/onboarding/ui/SliderCaptcha.tsx
  34. 21
      eas.json
  35. 2
      eslint.config.js
  36. 7677
      package-lock.json
  37. 3
      package.json
  38. 102
      scripts/check-routes.mjs
  39. 599
      src/api/api.ts
  40. 11
      src/api/index.ts
  41. 32
      src/constants/index.ts
  42. 58
      src/context/AuthContext.tsx
  43. 20
      src/context/RoleContext.tsx
  44. 4
      src/hooks/index.ts
  45. 12
      src/hooks/useJobDetail.ts
  46. 92
      src/hooks/useLegalMembership.ts
  47. 58
      src/hooks/useLegalServiceRequests.ts
  48. 11
      src/hooks/useOfferCompareData.ts
  49. 8
      src/hooks/useOnboardingFlow.tsx
  50. 46
      src/hooks/useWechatBindPhone.ts
  51. 131
      src/hooks/useWechatLogin.ts
  52. 6
      src/hooks/useWeightSettings.ts
  53. 35
      src/lib/wechat-sdk.ts
  54. 15
      src/types/index.ts
  55. 65
      src/types/legal.ts
  56. 16
      src/types/tools.ts
  57. 98
      src/types/wechat-auth.ts
  58. 100
      yarn.lock
  59. 151
      微信登录接入方案.md
  60. 113
      项目开发状态.md

3
.gitignore

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

29
AGENTS.md

@ -554,4 +554,31 @@ benefits: ['五险一金', '年终奖金', '弹性工作'],
industry: '互联网/IT',
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',
icon: './assets/icon.png',
userInterfaceStyle: 'light',
// EAS 项目归属(Expo 账号名)。运行 `eas login` 后执行 `eas init` 会自动写入/校正。
owner: 'REPLACE_WITH_EAS_OWNER',
// OTA 运行时版本策略:随 app 版本号推进,保证原生层与 JS 层兼容。
runtimeVersion: {
policy: 'appVersion',
},
// EAS Update(OTA 热更新)通道与端点。
// url 中的 PROJECT_ID 在 `eas init` 后由 EAS 注入(见 extra.eas.projectId)。
updates: {
url: 'https://u.expo.dev/REPLACE_WITH_PROJECT_ID',
channel: 'production',
},
ios: {
supportsTablet: true,
// iOS Bundle Identifier:上架 App Store 的唯一标识,按你的开发者账号命名。
bundleIdentifier: 'com.youzhi.app',
},
android: {
// Android Package:上架 Google Play 的唯一标识,通常与 iOS 保持一致。
package: 'com.youzhi.app',
adaptiveIcon: {
backgroundColor: '#E6F4FE',
foregroundImage: './assets/android-icon-foreground.png',
@ -28,12 +44,13 @@ export default {
monochromeImage: './assets/android-icon-monochrome.png',
},
},
web: {
favicon: './assets/favicon.png',
},
plugins: ['expo-router', 'expo-font'],
extra: {
apiBaseUrl: process.env.API_BASE_URL || 'https://dev.api.com',
// EAS 项目 ID:`eas init` 后会自动注入此处(与 updates.url 中的 PROJECT_ID 对应)。
eas: {
projectId: 'REPLACE_WITH_PROJECT_ID',
},
},
},
};

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

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

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

@ -127,7 +127,7 @@ function CandidateCard({ candidate }: { candidate: TalentCandidate }) {
</View>
<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>
</Pressable>
<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}
onPress={() => {
setRole('enterprise');
router.push('/enterprise-overview');
router.replace('/(enterprise)/enterprise-overview');
}}
hitSlop={4}
>
@ -349,7 +349,7 @@ export default function ProfileScreen() {
hitSlop={4}
>
<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>
<Text style={styles.menuTitle}>{item.title}</Text>
<Ionicons name="chevron-forward" size={16} color={colors.textHint} />

2
app/audit-report.tsx

@ -45,7 +45,7 @@ function KPICard({ kpi }: { kpi: AuditKPI }) {
return (
<View style={styles.kpiCard}>
<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>
<Text style={styles.kpiValue}>
{kpi.value}

2
app/game.tsx

@ -96,7 +96,7 @@ function LevelCard({
disabled={disabled}
>
<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 style={styles.levelBody}>
<Text style={[styles.levelOrder, { color: theme.text }]}>第 {level.order} 关</Text>

20
app/index.tsx

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

37
app/jobs/[id].tsx

@ -148,8 +148,28 @@ function SectionTitle({
export default function JobDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { detail, overallScore, salaryData, reviews, loading, error, refresh } = useJobDetail(id);
const { detail, overallScore, salaryData, reviews, loading, error, refresh, publishReview } =
useJobDetail(id);
const [comment, setComment] = useState('');
const [publishing, setPublishing] = useState(false);
// 发布评价:空内容拦截,成功后清空输入框(列表由 hook 自动刷新)
const handlePublish = async () => {
const text = comment.trim();
if (!text) {
Alert.alert('提示', '请先输入你的评价内容');
return;
}
setPublishing(true);
try {
await publishReview(text);
setComment('');
} catch (e) {
Alert.alert('发布失败', (e as Error).message || '请稍后重试');
} finally {
setPublishing(false);
}
};
// 错误态
if (error) {
@ -285,8 +305,16 @@ export default function JobDetailScreen() {
value={comment}
onChangeText={setComment}
/>
<Pressable style={styles.publishBtn} onPress={() => Alert.alert('发布评论', '评论发布功能开发中,敬请期待')}>
<Text style={styles.publishText}>发布</Text>
<Pressable
style={[styles.publishBtn, publishing && styles.publishBtnDisabled]}
onPress={handlePublish}
disabled={publishing}
>
{publishing ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<Text style={styles.publishText}>发布</Text>
)}
</Pressable>
</View>
</SafeAreaView>
@ -604,6 +632,9 @@ const styles = StyleSheet.create({
justifyContent: 'center',
backgroundColor: colors.primaryBtn,
},
publishBtnDisabled: {
opacity: 0.6,
},
publishText: {
fontSize: 14,
fontWeight: '600',

105
app/legal.tsx

@ -1,6 +1,6 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { useFocusEffect, useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import {
ActivityIndicator,
Pressable,
@ -10,7 +10,7 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLegalData } from '../src/hooks';
import { useLegalData, useLegalMembership } from '../src/hooks';
import { colors } from '../src/theme';
import type { LegalFAQ, LegalPlan, LegalService, LegalTemplate } from '../src/types';
@ -47,7 +47,16 @@ function BrandCard({ name, company, slogan, logo }: { name: string; company: str
/* ===================== 子组件:服务入口 ===================== */
function ServiceCard({ service, onPress }: { service: LegalService; onPress: () => void }) {
function ServiceCard({
service,
isUnlocked,
onPress,
}: {
service: LegalService;
/** 会员服务是否已解锁(已开通会员) */
isUnlocked: boolean;
onPress: () => void;
}) {
const isMember = service.type === 'member';
return (
<Pressable
@ -58,14 +67,14 @@ function ServiceCard({ service, onPress }: { service: LegalService; onPress: ()
onPress={onPress}
>
<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 style={styles.serviceBody}>
<View style={styles.serviceTitleRow}>
<Text style={styles.serviceTitle}>{service.title}</Text>
{isMember && (
<View style={styles.memberTag}>
<Text style={styles.memberTagText}>会员</Text>
<Text style={styles.memberTagText}>{isUnlocked ? '已开通' : '会员'}</Text>
</View>
)}
</View>
@ -96,7 +105,7 @@ function FAQItem({ faq }: { faq: LegalFAQ }) {
function TemplateCard({ template }: { template: LegalTemplate }) {
return (
<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}>
<Text style={styles.templateName}>{template.name}</Text>
<Text style={styles.templateDesc}>{template.description}</Text>
@ -107,9 +116,17 @@ function TemplateCard({ template }: { template: LegalTemplate }) {
/* ===================== 子组件:会员套餐 ===================== */
function PlanCard({ plan }: { plan: LegalPlan }) {
function PlanCard({ plan, onPress }: { plan: LegalPlan; onPress?: () => void }) {
return (
<View style={[styles.planCard, plan.recommended && styles.planCardRecommended]}>
<Pressable
style={({ pressed }) => [
styles.planCard,
plan.recommended && styles.planCardRecommended,
pressed && styles.planPressed,
]}
onPress={onPress}
disabled={!onPress}
>
{plan.recommended && (
<View style={styles.recommendedTag}>
<Text style={styles.recommendedText}>推荐</Text>
@ -129,7 +146,11 @@ function PlanCard({ plan }: { plan: LegalPlan }) {
</View>
))}
</View>
</View>
<View style={styles.planCtaRow}>
<Text style={styles.planCtaText}>立即开通</Text>
<Ionicons name="chevron-forward" size={16} color={colors.action} />
</View>
</Pressable>
);
}
@ -138,6 +159,14 @@ function PlanCard({ plan }: { plan: LegalPlan }) {
export default function LegalScreen() {
const router = useRouter();
const { brand, services, faqs, templates, plans, loading, error, refresh } = useLegalData();
const { membership, refresh: refreshMembership } = useLegalMembership();
// 返回本页时刷新会员态(支付成功后需即时反映)
useFocusEffect(
useCallback(() => {
refreshMembership();
}, [refreshMembership]),
);
const freeServices = services.filter((s) => s.type === 'free');
const memberServices = services.filter((s) => s.type === 'member');
@ -150,7 +179,12 @@ export default function LegalScreen() {
} else if (service.action === 'faq') {
router.push('/legal/faq');
} else if (service.action === 'upgrade') {
router.push('/legal/upgrade');
// 已开通会员 → 进入服务使用页(权益兑现);未开通 → 引导开通
if (membership) {
router.push(`/legal/service?id=${service.id}`);
} else {
router.push('/legal/upgrade');
}
}
};
@ -190,6 +224,7 @@ export default function LegalScreen() {
<ServiceCard
key={service.id}
service={service}
isUnlocked={false}
onPress={() => handleServicePress(service)}
/>
))}
@ -201,6 +236,7 @@ export default function LegalScreen() {
<ServiceCard
key={service.id}
service={service}
isUnlocked={Boolean(membership)}
onPress={() => handleServicePress(service)}
/>
))}
@ -231,9 +267,21 @@ export default function LegalScreen() {
{plans.length > 0 && (
<>
<SectionTitle title="会员套餐" />
{membership && (
<View style={styles.memberBanner}>
<Ionicons name="shield-checkmark" size={16} color={colors.action} />
<Text style={styles.memberBannerText}>
您已开通{membership.planName},可畅享会员专属服务
</Text>
</View>
)}
<View style={styles.planGrid}>
{plans.map((plan) => (
<PlanCard key={plan.id} plan={plan} />
<PlanCard
key={plan.id}
plan={plan}
onPress={() => router.push(`/legal/checkout?planId=${plan.id}`)}
/>
))}
</View>
</>
@ -474,6 +522,39 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: colors.border,
},
planPressed: {
opacity: 0.85,
},
planCtaRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
marginTop: 12,
gap: 2,
},
planCtaText: {
fontSize: 13,
fontWeight: '700',
color: colors.action,
},
memberBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 8,
backgroundColor: colors.actionLight,
borderWidth: 1,
borderColor: colors.action,
marginBottom: 12,
},
memberBannerText: {
flex: 1,
fontSize: 13,
fontWeight: '600',
color: colors.actionText,
},
planCardRecommended: {
borderColor: colors.action,
backgroundColor: colors.actionLight,

395
app/legal/checkout.tsx

@ -0,0 +1,395 @@
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import { ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLegalData, useLegalMembership } from '../../src/hooks';
import { LoadingState, EmptyState } from '../../src/components/StateView';
import { colors } from '../../src/theme';
import type { LegalPaymentChannel } from '../../src/types';
/* ===================== 付款方式 ===================== */
const CHANNELS: { id: LegalPaymentChannel; label: string; icon: keyof typeof Ionicons.glyphMap; color: string }[] = [
{ id: 'wechat', label: '微信支付', icon: 'logo-wechat', color: colors.success },
{ id: 'alipay', label: '支付宝', icon: 'wallet-outline', color: colors.primary },
];
/* ===================== 顶部导航 ===================== */
function Header({ onBack }: { onBack: () => void }) {
return (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text style={styles.headerTitle}>确认支付</Text>
<View style={styles.headerBtn} />
</View>
);
}
/* ===================== 页面 ===================== */
export default function LegalCheckoutScreen() {
const router = useRouter();
const { planId } = useLocalSearchParams();
const id = Array.isArray(planId) ? planId[0] : planId;
const { plans, loading } = useLegalData();
const { createOrder, payOrder, processing } = useLegalMembership();
const [channel, setChannel] = useState<LegalPaymentChannel>('wechat');
const [paid, setPaid] = useState(false);
const plan = plans.find((p) => p.id === id);
const handlePay = useCallback(async () => {
if (!plan) return;
try {
const order = await createOrder(plan.id, channel);
const result = await payOrder(order.id);
if (result.status === 'paid') {
setPaid(true);
} else {
Alert.alert('支付失败', '请稍后重试');
}
} catch (e) {
Alert.alert('支付失败', (e as Error).message || '请稍后重试');
}
}, [plan, channel, createOrder, payOrder]);
// 加载套餐中
if (loading) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<LoadingState text="加载套餐中…" />
</SafeAreaView>
);
}
// 套餐不存在(异常入口)
if (!plan) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<EmptyState icon="alert-circle-outline" title="套餐不存在" subtitle="请从会员套餐页重新进入" />
</SafeAreaView>
);
}
// 支付成功
if (paid) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.replace('/legal')} />
<View style={styles.successBox}>
<View style={styles.successIconWrap}>
<Ionicons name="checkmark-circle" size={56} color={colors.success} />
</View>
<Text style={styles.successTitle}>支付成功</Text>
<Text style={styles.successDesc}>
已开通{plan.name},可畅享会员专属法律服务
</Text>
<Pressable style={styles.successBtn} onPress={() => router.replace('/legal')}>
<Text style={styles.successBtnText}>返回法律服务</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{/* 套餐摘要 */}
<View style={styles.summaryCard}>
<Text style={styles.summaryName}>{plan.name}</Text>
<View style={styles.summaryPriceRow}>
<Text style={styles.summaryCurrency}>¥</Text>
<Text style={styles.summaryPrice}>{plan.price}</Text>
<Text style={styles.summaryPeriod}>{plan.period}</Text>
</View>
<View style={styles.benefits}>
{plan.benefits.map((benefit, idx) => (
<View key={idx} style={styles.benefitRow}>
<Ionicons name="checkmark-circle" size={14} color={colors.success} />
<Text style={styles.benefitText}>{benefit}</Text>
</View>
))}
</View>
</View>
{/* 支付方式 */}
<Text style={styles.sectionTitle}>选择支付方式</Text>
<View style={styles.channelGroup}>
{CHANNELS.map((c) => {
const active = c.id === channel;
return (
<Pressable
key={c.id}
style={({ pressed }) => [
styles.channelRow,
active && styles.channelRowActive,
pressed && styles.channelPressed,
]}
onPress={() => setChannel(c.id)}
>
<Ionicons name={c.icon} size={24} color={c.color} />
<Text style={styles.channelLabel}>{c.label}</Text>
<View style={[styles.radio, active && styles.radioActive]}>
{active && <View style={styles.radioDot} />}
</View>
</Pressable>
);
})}
</View>
{/* 实付金额 */}
<View style={styles.amountRow}>
<Text style={styles.amountLabel}>实付金额</Text>
<View style={styles.amountValue}>
<Text style={styles.amountCurrency}>¥</Text>
<Text style={styles.amountPrice}>{plan.price}</Text>
</View>
</View>
</ScrollView>
{/* 确认支付(底部固定) */}
<View style={styles.footer}>
<Pressable
style={({ pressed }) => [styles.payBtn, pressed && styles.payBtnPressed, processing && styles.payBtnDisabled]}
onPress={handlePay}
disabled={processing}
>
{processing ? (
<ActivityIndicator color={colors.white} />
) : (
<Text style={styles.payBtnText}>确认支付 ¥{plan.price}</Text>
)}
</Pressable>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
height: 48,
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
headerBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
scroll: {
flex: 1,
},
content: {
padding: 12,
paddingBottom: 24,
},
summaryCard: {
padding: 16,
borderRadius: 8,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
},
summaryName: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
summaryPriceRow: {
flexDirection: 'row',
alignItems: 'baseline',
marginTop: 8,
marginBottom: 12,
},
summaryCurrency: {
fontSize: 15,
fontWeight: '700',
color: colors.actionText,
},
summaryPrice: {
fontSize: 30,
fontWeight: '700',
color: colors.actionText,
},
summaryPeriod: {
fontSize: 13,
color: colors.textSecondary,
marginLeft: 2,
},
benefits: {
gap: 6,
},
benefitRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
benefitText: {
fontSize: 12,
color: colors.textSecondary,
},
sectionTitle: {
marginTop: 20,
marginBottom: 10,
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
},
channelGroup: {
gap: 8,
},
channelRow: {
flexDirection: 'row',
alignItems: 'center',
padding: 14,
borderRadius: 8,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
gap: 12,
},
channelRowActive: {
borderColor: colors.action,
backgroundColor: colors.actionLight,
},
channelPressed: {
opacity: 0.85,
},
channelLabel: {
flex: 1,
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
},
radio: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
},
radioActive: {
borderColor: colors.action,
},
radioDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.action,
},
amountRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 20,
paddingTop: 16,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
amountLabel: {
fontSize: 14,
color: colors.textSecondary,
},
amountValue: {
flexDirection: 'row',
alignItems: 'baseline',
},
amountCurrency: {
fontSize: 15,
fontWeight: '700',
color: colors.actionText,
},
amountPrice: {
fontSize: 22,
fontWeight: '700',
color: colors.actionText,
},
footer: {
padding: 12,
backgroundColor: colors.white,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
payBtn: {
height: 48,
borderRadius: 8,
backgroundColor: colors.primaryBtn,
alignItems: 'center',
justifyContent: 'center',
},
payBtnPressed: {
opacity: 0.9,
},
payBtnDisabled: {
opacity: 0.6,
},
payBtnText: {
fontSize: 16,
fontWeight: '700',
color: colors.white,
},
successBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
successIconWrap: {
marginBottom: 16,
},
successTitle: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary,
},
successDesc: {
marginTop: 8,
fontSize: 14,
color: colors.textSecondary,
textAlign: 'center',
},
successBtn: {
marginTop: 28,
width: 200,
height: 46,
borderRadius: 23,
backgroundColor: colors.primaryBtn,
alignItems: 'center',
justifyContent: 'center',
},
successBtnText: {
fontSize: 15,
fontWeight: '700',
color: colors.white,
},
});

430
app/legal/service.tsx

@ -0,0 +1,430 @@
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLegalData, useLegalMembership, useLegalServiceRequests } from '../../src/hooks';
import { LoadingState, EmptyState } from '../../src/components/StateView';
import { colors } from '../../src/theme';
/* ===================== 顶部导航 ===================== */
function Header({ onBack }: { onBack: () => void }) {
return (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text style={styles.headerTitle}>会员服务</Text>
<View style={styles.headerBtn} />
</View>
);
}
/* ===================== 页面 ===================== */
export default function LegalServiceScreen() {
const router = useRouter();
const { id } = useLocalSearchParams();
const serviceId = Array.isArray(id) ? id[0] : id;
const { services, loading } = useLegalData();
const { membership, refresh: refreshMembership } = useLegalMembership();
const {
requests,
loading: requestsLoading,
refresh: refreshRequests,
submit,
submitting,
} = useLegalServiceRequests();
const [content, setContent] = useState('');
// 返回本页时刷新会员态与申请记录
useFocusEffect(
useCallback(() => {
refreshMembership();
refreshRequests();
}, [refreshMembership, refreshRequests]),
);
const service = services.find((s) => s.id === serviceId);
const handleSubmit = useCallback(async () => {
if (!service) return;
const text = content.trim();
if (!text) {
Alert.alert('提示', '请先填写你的需求描述');
return;
}
try {
await submit(service.id, text);
setContent('');
Alert.alert('提交成功', '我们已收到你的申请,将尽快安排专人跟进。');
} catch (e) {
Alert.alert('提交失败', (e as Error).message || '请稍后重试');
}
}, [service, content, submit]);
if (loading) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<LoadingState text="加载服务中…" />
</SafeAreaView>
);
}
if (!service) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<EmptyState icon="alert-circle-outline" title="服务不存在" subtitle="请从法律服务页重新进入" />
</SafeAreaView>
);
}
// 未开通会员:拦截并引导开通(付费前不给权益)
if (!membership) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<View style={styles.lockedBox}>
<View style={styles.lockedIconWrap}>
<Ionicons name="lock-closed-outline" size={44} color={colors.action} />
</View>
<Text style={styles.lockedTitle}>{service.title}</Text>
<Text style={styles.lockedDesc}>{service.description}</Text>
<Text style={styles.lockedTip}>该服务为会员专享,开通后即可使用</Text>
<Pressable style={styles.lockedBtn} onPress={() => router.push('/legal/upgrade')}>
<Text style={styles.lockedBtnText}>开通会员</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
const myRequests = requests.filter((r) => r.serviceId === service.id);
const canSubmit = content.trim().length > 0 && !submitting;
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{/* 服务说明 */}
<View style={styles.card}>
<View style={styles.serviceHead}>
<View style={styles.serviceIconWrap}>
<Ionicons
name={service.icon as keyof typeof Ionicons.glyphMap}
size={22}
color={colors.action}
/>
</View>
<View style={styles.serviceHeadBody}>
<Text style={styles.serviceName}>{service.title}</Text>
<Text style={styles.serviceDesc}>{service.description}</Text>
</View>
</View>
{service.requestHint && <Text style={styles.hint}>{service.requestHint}</Text>}
</View>
{/* 需求填写 */}
<Text style={styles.sectionTitle}>填写你的需求</Text>
<TextInput
style={styles.input}
value={content}
onChangeText={setContent}
placeholder="请描述你的具体情况与希望解决的问题…"
placeholderTextColor={colors.textHint}
multiline
maxLength={500}
textAlignVertical="top"
/>
<Text style={styles.counter}>{content.length}/500</Text>
<Pressable
style={({ pressed }) => [
styles.submitBtn,
!canSubmit && styles.submitBtnDisabled,
pressed && canSubmit && styles.submitBtnPressed,
]}
onPress={handleSubmit}
disabled={!canSubmit}
>
{submitting ? (
<ActivityIndicator color={colors.white} />
) : (
<Text style={styles.submitBtnText}>提交申请</Text>
)}
</Pressable>
{/* 我的申请记录 */}
<Text style={styles.sectionTitle}>我的申请记录</Text>
{requestsLoading ? (
<ActivityIndicator color={colors.primary} style={styles.requestsLoading} />
) : myRequests.length === 0 ? (
<View style={styles.emptyCard}>
<Text style={styles.emptyText}>暂无申请记录</Text>
</View>
) : (
<View style={styles.requestList}>
{myRequests.map((r) => (
<View key={r.id} style={styles.requestCard}>
<View style={styles.requestHead}>
<Text style={styles.requestService}>{r.serviceName}</Text>
<View style={styles.statusTag}>
<Text style={styles.statusText}>
{r.status === 'submitted' ? '已提交' : r.status === 'processing' ? '处理中' : '已完成'}
</Text>
</View>
</View>
<Text style={styles.requestContent}>{r.content}</Text>
<Text style={styles.requestTime}>
{new Date(r.createdAt).toLocaleString('zh-CN')}
</Text>
</View>
))}
</View>
)}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
height: 48,
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
headerBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
scroll: {
flex: 1,
},
content: {
padding: 12,
paddingBottom: 24,
},
card: {
padding: 14,
borderRadius: 8,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
},
serviceHead: {
flexDirection: 'row',
alignItems: 'center',
},
serviceIconWrap: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.actionLight,
alignItems: 'center',
justifyContent: 'center',
},
serviceHeadBody: {
flex: 1,
marginLeft: 12,
},
serviceName: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
serviceDesc: {
marginTop: 2,
fontSize: 12,
color: colors.textSecondary,
lineHeight: 17,
},
hint: {
marginTop: 12,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary,
},
sectionTitle: {
marginTop: 20,
marginBottom: 10,
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
},
input: {
minHeight: 120,
padding: 12,
borderRadius: 8,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
fontSize: 14,
lineHeight: 20,
color: colors.textPrimary,
},
counter: {
marginTop: 6,
textAlign: 'right',
fontSize: 12,
color: colors.textHint,
},
submitBtn: {
marginTop: 16,
height: 48,
borderRadius: 8,
backgroundColor: colors.primaryBtn,
alignItems: 'center',
justifyContent: 'center',
},
submitBtnPressed: {
opacity: 0.9,
},
submitBtnDisabled: {
opacity: 0.5,
},
submitBtnText: {
fontSize: 16,
fontWeight: '700',
color: colors.white,
},
requestsLoading: {
marginTop: 16,
},
emptyCard: {
padding: 20,
borderRadius: 8,
backgroundColor: colors.white,
alignItems: 'center',
},
emptyText: {
fontSize: 13,
color: colors.textSecondary,
},
requestList: {
gap: 8,
},
requestCard: {
padding: 12,
borderRadius: 8,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
},
requestHead: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
requestService: {
flex: 1,
fontSize: 14,
fontWeight: '700',
color: colors.textPrimary,
marginRight: 8,
},
statusTag: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
backgroundColor: colors.primaryLight,
},
statusText: {
fontSize: 11,
fontWeight: '700',
color: colors.primaryText,
},
requestContent: {
marginTop: 8,
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary,
},
requestTime: {
marginTop: 8,
fontSize: 11,
color: colors.textHint,
},
lockedBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
lockedIconWrap: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: colors.actionLight,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
lockedTitle: {
fontSize: 18,
fontWeight: '700',
color: colors.textPrimary,
},
lockedDesc: {
marginTop: 8,
fontSize: 14,
lineHeight: 20,
color: colors.textSecondary,
textAlign: 'center',
},
lockedTip: {
marginTop: 12,
fontSize: 13,
fontWeight: '600',
color: colors.actionText,
},
lockedBtn: {
marginTop: 28,
width: 200,
height: 46,
borderRadius: 23,
backgroundColor: colors.primaryBtn,
alignItems: 'center',
justifyContent: 'center',
},
lockedBtnText: {
fontSize: 15,
fontWeight: '700',
color: colors.white,
},
});

145
app/legal/template.tsx

@ -0,0 +1,145 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLegalData } from '../../src/hooks';
import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView';
import { colors } from '../../src/theme';
/* ===================== 顶部导航 ===================== */
function Header({ onBack }: { onBack: () => void }) {
return (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text style={styles.headerTitle}>文书预览</Text>
<View style={styles.headerBtn} />
</View>
);
}
/* ===================== 页面 ===================== */
export default function LegalTemplateScreen() {
const router = useRouter();
const { id } = useLocalSearchParams();
const { templates, loading, error, refresh } = useLegalData();
const templateId = Array.isArray(id) ? id[0] : id;
const template = templateId ? templates.find((t) => t.id === templateId) : undefined;
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
{loading ? (
<LoadingState text="加载模板中…" />
) : error ? (
<ErrorState message="加载失败,请重试" onRetry={refresh} />
) : !template ? (
<EmptyState icon="document-text-outline" title="未找到该模板" subtitle="请返回重新选择" />
) : (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<View style={styles.headCard}>
<Ionicons name={template.icon as never} size={28} color={colors.primary} />
<View style={styles.headBody}>
<Text style={styles.name}>{template.name}</Text>
<Text style={styles.desc}>{template.description}</Text>
</View>
</View>
<View style={styles.contentCard}>
{/* selectable:长按正文即可复制,无需额外原生模块(mock 阶段) */}
<Text style={styles.contentText} selectable>
{template.content}
</Text>
</View>
<Text style={styles.tip}>
长按正文即可复制;正式下载 / 导出为 Word·PDF 需后端文件支持,当前为内容预览。
</Text>
</ScrollView>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
height: 48,
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
headerBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
scroll: {
flex: 1,
},
content: {
padding: 12,
paddingBottom: 24,
},
headCard: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderRadius: 12,
backgroundColor: colors.white,
marginBottom: 12,
},
headBody: {
flex: 1,
marginLeft: 12,
},
name: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
desc: {
marginTop: 2,
fontSize: 12,
color: colors.textSecondary,
},
contentCard: {
padding: 16,
borderRadius: 12,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.borderLight,
},
contentText: {
fontSize: 14,
lineHeight: 22,
color: colors.textPrimary,
},
tip: {
marginTop: 12,
fontSize: 12,
color: colors.textHint,
lineHeight: 18,
},
});

6
app/legal/templates.tsx

@ -1,6 +1,6 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLegalData } from '../../src/hooks';
import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView';
@ -62,9 +62,7 @@ export default function LegalTemplatesScreen() {
<TemplateCard
key={template.id}
template={template}
onPress={() =>
Alert.alert('文书预览', `「${template.name}」预览与下载功能即将上线`)
}
onPress={() => router.push(`/legal/template?id=${template.id}`)}
/>
))}
</View>

82
app/legal/upgrade.tsx

@ -1,8 +1,9 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useFocusEffect, useRouter } from 'expo-router';
import { useCallback } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLegalData } from '../../src/hooks';
import { useLegalData, useLegalMembership } from '../../src/hooks';
import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView';
import { colors } from '../../src/theme';
import type { LegalPlan } from '../../src/types';
@ -23,9 +24,17 @@ function Header({ onBack }: { onBack: () => void }) {
/* ===================== 套餐卡片 ===================== */
function PlanCard({ plan }: { plan: LegalPlan }) {
function PlanCard({ plan, onPress }: { plan: LegalPlan; onPress?: () => void }) {
return (
<View style={[styles.planCard, plan.recommended && styles.planCardRecommended]}>
<Pressable
style={({ pressed }) => [
styles.planCard,
plan.recommended && styles.planCardRecommended,
pressed && styles.planPressed,
]}
onPress={onPress}
disabled={!onPress}
>
{plan.recommended && (
<View style={styles.recommendedTag}>
<Text style={styles.recommendedText}>推荐</Text>
@ -45,7 +54,11 @@ function PlanCard({ plan }: { plan: LegalPlan }) {
</View>
))}
</View>
</View>
<View style={styles.planCtaRow}>
<Text style={styles.planCtaText}>立即开通</Text>
<Ionicons name="chevron-forward" size={16} color={colors.action} />
</View>
</Pressable>
);
}
@ -54,6 +67,15 @@ function PlanCard({ plan }: { plan: LegalPlan }) {
export default function LegalUpgradeScreen() {
const router = useRouter();
const { plans, loading, error, refresh } = useLegalData();
const { membership, refresh: refreshMembership } = useLegalMembership();
useFocusEffect(
useCallback(() => {
refreshMembership();
}, [refreshMembership]),
);
const formatDate = (iso: string) => new Date(iso).toLocaleDateString('zh-CN');
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
@ -70,10 +92,23 @@ export default function LegalUpgradeScreen() {
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<Text style={styles.tip}>开通会员后可享受律师咨询、合同审核等专属法律服务</Text>
{membership ? (
<View style={styles.memberBanner}>
<Ionicons name="shield-checkmark" size={16} color={colors.action} />
<Text style={styles.memberBannerText}>
您已开通{membership.planName},有效期至 {formatDate(membership.expiresAt)}
</Text>
</View>
) : (
<Text style={styles.tip}>开通会员后可享受律师咨询、合同审核等专属法律服务</Text>
)}
<View style={styles.planGrid}>
{plans.map((plan) => (
<PlanCard key={plan.id} plan={plan} />
<PlanCard
key={plan.id}
plan={plan}
onPress={() => router.push(`/legal/checkout?planId=${plan.id}`)}
/>
))}
</View>
</ScrollView>
@ -130,6 +165,39 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: colors.border,
},
planPressed: {
opacity: 0.85,
},
planCtaRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
marginTop: 12,
gap: 2,
},
planCtaText: {
fontSize: 13,
fontWeight: '700',
color: colors.action,
},
memberBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 8,
backgroundColor: colors.actionLight,
borderWidth: 1,
borderColor: colors.action,
marginBottom: 12,
},
memberBannerText: {
flex: 1,
fontSize: 13,
fontWeight: '600',
color: colors.actionText,
},
planCardRecommended: {
borderColor: colors.action,
backgroundColor: colors.actionLight,

2
app/register.tsx

@ -2,6 +2,7 @@ import { OnboardingProvider, useOnboarding } from '../src/hooks';
import { P1Login } from '../components/onboarding/screens/P1Login';
import { P2PhoneCode } from '../components/onboarding/screens/P2PhoneCode';
import { P2QuickLogin } from '../components/onboarding/screens/P2QuickLogin';
import { P2WechatCode } from '../components/onboarding/screens/P2WechatCode';
import { P3Identity } from '../components/onboarding/screens/P3Identity';
import { P4Intent } from '../components/onboarding/screens/P4Intent';
import { P5City } from '../components/onboarding/screens/P5City';
@ -31,6 +32,7 @@ function FlowRouter() {
case 'P1': return <P1Login />;
case 'P2': return <P2PhoneCode />;
case 'P2Q': return <P2QuickLogin />;
case 'P2W': return <P2WechatCode />;
case 'P3': return <P3Identity />;
case 'P4': return <P4Intent />;
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 { colors } from '../src/theme';
import { useAuth } from '../src/context/AuthContext';
import { useRole } from '../src/context/RoleContext';
/* ===================== 设置项结构 ===================== */
@ -38,7 +39,6 @@ const SETTINGS_GROUPS: SettingsGroup[] = [
id: 'identity',
title: '身份切换',
icon: 'swap-horizontal-outline',
route: '/register',
},
],
},
@ -92,6 +92,7 @@ function Header({ onBack }: { onBack: () => void }) {
export default function SettingsScreen() {
const router = useRouter();
const { logout } = useAuth();
const { role, setRole } = useRole();
const handleLogout = () => {
Alert.alert('确认退出登录', '退出后需要重新登录', [
@ -107,7 +108,21 @@ export default function SettingsScreen() {
]);
};
const handleIdentitySwitch = () => {
if (role === 'enterprise') {
setRole('candidate');
router.replace('/(tabs)/jobs');
} else {
setRole('enterprise');
router.replace('/(enterprise)/enterprise-overview');
}
};
const handlePress = (item: SettingsItem) => {
if (item.id === 'identity') {
handleIdentitySwitch();
return;
}
if (item.route) {
router.push(item.route);
return;

2
app/settings/account-cancel.tsx

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

131
app/tools/salary-calculator.tsx

@ -4,6 +4,7 @@ import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
Alert,
Modal,
Pressable,
ScrollView,
StyleSheet,
@ -14,6 +15,11 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context';
import { useSalaryCalculator } from '../../src/hooks';
import { colors, headerGradientShort } from '../../src/theme';
import {
CITIES,
DEFAULT_SOCIAL_PROFILE,
SOCIAL_SECURITY_CITY_PROFILES,
} from '../../src/constants';
import type { SalaryCalcInput, SalaryToolLink } from '../../src/types';
import { formatCurrency } from '../../src/utils';
@ -226,11 +232,32 @@ function ToolLink({ tool }: { tool: SalaryToolLink }) {
export default function SalaryCalculatorScreen() {
const router = useRouter();
const { inputs, setInputs, result } = useSalaryCalculator();
const [cityPickerVisible, setCityPickerVisible] = useState(false);
const cityProfile =
SOCIAL_SECURITY_CITY_PROFILES[inputs.city] ?? DEFAULT_SOCIAL_PROFILE;
const update = <K extends keyof SalaryCalcInput>(key: K, value: SalaryCalcInput[K]) => {
setInputs({ ...inputs, [key]: value });
};
/** 将社保基数 clamp 到当前城市上下限内 */
const clampSocialBase = (v: number) => {
const profile = SOCIAL_SECURITY_CITY_PROFILES[inputs.city] ?? DEFAULT_SOCIAL_PROFILE;
return Math.min(profile.socialBaseUpper, Math.max(profile.socialBaseLower, v));
};
/** 选城市:切换城市并把社保基数 clamp 到新城市上下限 */
const handleSelectCity = (city: string) => {
const profile = SOCIAL_SECURITY_CITY_PROFILES[city] ?? DEFAULT_SOCIAL_PROFILE;
const clamped = Math.min(
profile.socialBaseUpper,
Math.max(profile.socialBaseLower, inputs.socialBase),
);
setInputs({ ...inputs, city, socialBase: clamped });
setCityPickerVisible(false);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onHistory={() => Alert.alert('历史记录', '计算历史功能开发中,敬请期待')} />
@ -244,13 +271,17 @@ export default function SalaryCalculatorScreen() {
suffix="元"
onChange={(v) => update('monthlyGross', Math.max(0, v))}
/>
<SelectRow label="所在城市" value={inputs.city} onPress={() => Alert.alert('选择城市', '城市选择功能开发中,敬请期待')} />
<SelectRow label="所在城市" value={inputs.city} onPress={() => setCityPickerVisible(true)} />
<NumberInput
label="社保基数"
value={inputs.socialBase}
suffix="元"
onChange={(v) => update('socialBase', Math.max(0, v))}
onChange={(v) => update('socialBase', clampSocialBase(Math.max(0, v)))}
/>
<Text style={styles.baseHint}>
基数范围 ¥{formatCurrency(cityProfile.socialBaseLower, 0)} – ¥
{formatCurrency(cityProfile.socialBaseUpper, 0)}({inputs.city})
</Text>
<NumberInput
label="公积金比例"
value={inputs.providentFundRate * 100}
@ -320,10 +351,59 @@ export default function SalaryCalculatorScreen() {
<Text style={styles.saveBtnText}>保存计算记录</Text>
</Pressable>
</ScrollView>
<CityPicker
visible={cityPickerVisible}
current={inputs.city}
onClose={() => setCityPickerVisible(false)}
onSelect={handleSelectCity}
/>
</SafeAreaView>
);
}
/* ===================== 子组件:城市选择底部弹层 ===================== */
function CityPicker({
visible,
current,
onClose,
onSelect,
}: {
visible: boolean;
current: string;
onClose: () => void;
onSelect: (city: string) => void;
}) {
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={styles.sheetBackdrop} onPress={onClose}>
<View style={styles.sheet}>
<View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}>选择所在城市</Text>
<ScrollView style={styles.sheetList} showsVerticalScrollIndicator={false}>
{CITIES.map((city) => {
const active = city === current;
return (
<Pressable
key={city}
style={[styles.sheetItem, active && styles.sheetItemActive]}
onPress={() => onSelect(city)}
>
<Text style={[styles.sheetItemText, active && styles.sheetItemTextActive]}>
{city}
</Text>
{active && <Ionicons name="checkmark" size={18} color={colors.primary} />}
</Pressable>
);
})}
</ScrollView>
</View>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.pageBg },
header: {
@ -462,4 +542,51 @@ const styles = StyleSheet.create({
marginTop: 8,
},
saveBtnText: { fontSize: 16, fontWeight: '700', color: colors.white },
baseHint: {
fontSize: 12,
color: colors.textHint,
marginTop: 6,
paddingHorizontal: 2,
},
sheetBackdrop: {
flex: 1,
backgroundColor: colors.scrim,
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: colors.white,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingTop: 10,
paddingBottom: 24,
maxHeight: '72%',
},
sheetHandle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: colors.borderLight,
alignSelf: 'center',
marginBottom: 10,
},
sheetTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
textAlign: 'center',
marginBottom: 12,
},
sheetList: { maxHeight: 360 },
sheetItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: 20,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
sheetItemActive: { backgroundColor: colors.primaryLight },
sheetItemText: { fontSize: 15, color: colors.textPrimary },
sheetItemTextActive: { color: colors.primary, fontWeight: '700' },
});

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: '营业执照示例图' },
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 { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { useRouter } from 'expo-router';
import { LinearGradient } from 'expo-linear-gradient';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors } from '../../../src/theme';
import { useOnboarding } from '../../../src/hooks';
import { useOnboarding, useWechatLogin } from '../../../src/hooks';
import { useRole } from '../../../src/context/RoleContext';
import { Illustration } from '../ui/Illustration';
import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { AgreementModal } from '../ui/AgreementModal';
import { SliderCaptcha } from '../ui/SliderCaptcha';
import { DeletedAccountModal } from '../ui/DeletedAccountModal';
/** P1 登录主页:对齐参考样式 —— 淡渐变顶部 + 居中 Logo + 微信绿实心/灰底一键登录 + 协议弹窗拦截 */
export function P1Login() {
const { data, setData, navigate } = useOnboarding();
const router = useRouter();
const { setRole } = useRole();
const agreed = data.agreed;
/** 未勾选协议时被拦截的动作,弹窗「同意」后继续执行 */
const [pending, setPending] = useState<(() => void) | null>(null);
const [captchaVisible, setCaptchaVisible] = useState(false);
/** 微信登录状态机:负责三分支判定与流转 */
const {
state: wechatState,
start: startWechatLogin,
cancelLogin,
reset: resetWechat,
loading: wechatLoading,
} = useWechatLogin();
/** 协议门卫:已勾选直接执行;未勾选弹协议弹窗 */
const gate = (action: () => void) => {
@ -33,6 +48,46 @@ export function P1Login() {
act?.();
};
/** 微信登录:先滑块拼图验证,通过后进入微信授权与三分支判定 */
const wechatLogin = () => setCaptchaVisible(true);
/*
* 状态机的副作用集中在这里,页面只负责触发与渲染:
* - needBindPhone:第一次登录,把临时凭证存入流程数据后跳 P2(手机号 + 验证码)
* - success:第二次及以后登录,凭后端返回的身份直接落地,无身份则回注册流程选择
* - alreadyLoggedIn:本地 token 未过期,免授权直接落地
* 跳转后立即 reset,避免返回本页时重复触发。
*/
useEffect(() => {
if (wechatState.kind === 'needBindPhone') {
setData({
wechatTicket: {
token: wechatState.ticket.token,
openid: wechatState.ticket.openid,
},
});
navigate('P2');
resetWechat();
return;
}
if (wechatState.kind === 'success') {
if (wechatState.role) {
setRole(wechatState.role);
router.replace(
wechatState.role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs',
);
} else {
router.replace('/register');
}
resetWechat();
return;
}
if (wechatState.kind === 'alreadyLoggedIn') {
router.replace('/(tabs)/jobs');
resetWechat();
}
}, [wechatState, resetWechat, router, setRole, setData, navigate]);
return (
<SafeAreaView style={styles.screen} edges={['top', 'bottom']}>
<StatusBar style="dark" />
@ -46,11 +101,21 @@ export function P1Login() {
<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} />
<Text style={styles.wechatText}>微信登录</Text>
<Text style={styles.wechatText}>
{wechatLoading ? '登录中…' : '微信登录'}
</Text>
</Pressable>
{wechatState.kind === 'error' ? (
<Text style={styles.loginError}>{wechatState.message}</Text>
) : null}
<Pressable style={styles.quickBtn} onPress={() => gate(() => navigate('P2Q'))}>
<Text style={styles.quickText}>一键登录</Text>
</Pressable>
@ -85,6 +150,17 @@ export function P1Login() {
</View>
</ScrollView>
<SliderCaptcha
visible={captchaVisible}
onSuccess={() => {
setCaptchaVisible(false);
void startWechatLogin();
}}
onClose={() => setCaptchaVisible(false)}
/>
<DeletedAccountModal visible={wechatState.kind === 'deleted'} onClose={cancelLogin} />
<AgreementModal
visible={pending !== null}
onAccept={accept}
@ -129,6 +205,7 @@ const styles = StyleSheet.create({
fontSize: 14,
color: colors.textSecondary,
},
loginError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger },
agree: { marginTop: 22 },
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
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 { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { colors } from '../../../src/theme';
import { useOnboarding } from '../../../src/hooks';
import { useOnboarding, useWechatBindPhone } from '../../../src/hooks';
import { ScreenShell } from '../ui/ScreenShell';
import { CountdownButton } from '../ui/CountdownButton';
import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { PrimaryButton } from '../ui/PrimaryButton';
import { AgreementModal } from '../ui/AgreementModal';
import { SliderCaptcha } from '../ui/SliderCaptcha';
const PHONE_RE = /^1\d{10}$/;
@ -18,10 +19,15 @@ export function P2PhoneCode() {
const [codeSent, setCodeSent] = useState(false);
/** 未勾选协议时被拦截的动作 */
const [pending, setPending] = useState<(() => void) | null>(null);
const [captchaVisible, setCaptchaVisible] = useState(false);
/** 微信第一次登录的绑定注册能力 */
const { bind, loading: binding, error: bindError } = useWechatBindPhone();
const agreed = data.agreed;
const phone = data.phone;
const phoneValid = PHONE_RE.test(phone);
/** 有值说明本页是从「微信第一次登录」进来的,提交时需完成微信绑定 */
const wechatTicket = data.wechatTicket;
const gate = (action: () => void) => {
if (agreed) {
@ -44,9 +50,28 @@ export function P2PhoneCode() {
setCodeSent(true);
};
const handleLogin = () => {
/*
* 提交验证码:
* - 微信第一次登录(data.wechatTicket 有值):调绑定注册接口把 openid 与手机号关联,
* 成功即写入登录态,再进 P3 选择身份;失败则留在当前页并提示。
* - 其他链路:沿用原逻辑,仅记录手机号后进 P3。
*/
const handleLogin = async () => {
if (code.length < 4) return;
setData({ phone, code });
if (wechatTicket) {
try {
await bind({
token: wechatTicket.token,
openid: wechatTicket.openid,
phone,
code,
});
} catch {
return;
}
}
navigate('P3');
};
@ -112,7 +137,7 @@ export function P2PhoneCode() {
</View>
<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} />
</Pressable>
<Pressable style={[styles.socialCircle, { backgroundColor: colors.primary }]} onPress={() => gate(sendCode)}>
@ -144,9 +169,15 @@ export function P2PhoneCode() {
<View style={styles.underline} />
<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>
{bindError ? <Text style={styles.bindError}>{bindError.message}</Text> : null}
<View style={styles.agree}>
<AgreementCheckbox
checked={agreed}
@ -165,6 +196,15 @@ export function P2PhoneCode() {
</View>
)}
<SliderCaptcha
visible={captchaVisible}
onSuccess={() => {
setCaptchaVisible(false);
navigate('P2W');
}}
onClose={() => setCaptchaVisible(false)}
/>
<AgreementModal
visible={pending !== null}
onAccept={accept}
@ -195,6 +235,7 @@ const styles = StyleSheet.create({
smsHelp: { fontSize: 13, color: colors.textHint, marginTop: 16 },
btnArea: { marginTop: 28 },
agree: { marginTop: 18 },
bindError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger },
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
link: { color: colors.primary, fontWeight: '600' },
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;
showBack?: boolean;
onBack?: () => void;
/** 返回图标:默认 chevron-back,可传 close(对齐验证码页的 × 关闭) */
backIcon?: 'chevron-back' | 'close';
children: ReactNode;
footer?: ReactNode;
scroll?: boolean;
@ -18,13 +20,13 @@ interface Props {
}
/** 每屏统一外壳:系统状态栏 + 可选返回箭头 + 标题 + 滚动内容 + 底部固定区 */
export function ScreenShell({ title, showBack, onBack, children, footer, scroll = true, time: _time, battery: _battery }: Props) {
export function ScreenShell({ title, showBack, onBack, backIcon = 'chevron-back', children, footer, scroll = true, time: _time, battery: _battery }: Props) {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<StatusBar style="dark" />
{showBack ? (
<Pressable style={styles.backRow} onPress={onBack} hitSlop={10}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
<Ionicons name={backIcon} size={backIcon === 'close' ? 26 : 24} color={colors.textPrimary} />
{title ? <Text style={styles.titleInline}>{title}</Text> : null}
</Pressable>
) : 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/**',
'coverage/**',
'figma_out/**',
'*.mjs',
'**/*.mjs',
'figma_discover.py',
'**/__tests__/**',
],

7677
package-lock.json

File diff suppressed because it is too large

3
package.json

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

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

599
src/api/api.ts

@ -74,8 +74,12 @@ import type {
LegalConversation,
LegalFAQ,
LegalMessage,
LegalMembership,
LegalOrder,
LegalPaymentChannel,
LegalPlan,
LegalService,
LegalServiceRequest,
LegalTemplate,
ToggleSetting,
ActionSetting,
@ -115,9 +119,14 @@ import type {
MatchModeOption,
OperationRules,
AuditReport,
WechatAccountStatus,
WechatLoginResult,
WechatBindPhonePayload,
WechatSession,
} from '../types';
import { MOCK_DELAY, SALARY_RANGES } from '../constants';
import { DEFAULT_SOCIAL_PROFILE, MOCK_DELAY, SALARY_RANGES, SOCIAL_SECURITY_CITY_PROFILES, TOKEN_TTL_MS } from '../constants';
import { colors } from '../theme';
import { storageGet, storageSet, storageRemove } from '../lib/storage';
/** 接口地址(通过 expo-constants 从 app.config.js 的 extra 读取) */
export const API_BASE_URL: string =
@ -1236,8 +1245,50 @@ export async function fetchSalaryData(id: string): Promise<SalaryData> {
*/
export async function fetchEmployeeReviews(id: string): Promise<EmployeeReview[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/${id}/reviews`, { method: 'GET' }); return res.json();
void id;
return mockRequest(MOCK_EMPLOYEE_REVIEWS);
// mock 阶段:内置评价 + 该岗位下本人已发布的评价(本人发布的最新在前)
const mine = (await readJobReviews()).filter((r) => r.jobId === id);
return mockRequest([...mine, ...MOCK_EMPLOYEE_REVIEWS]);
}
/* ===================== 岗位评价发布(mock 阶段持久化到 AsyncStorage) ===================== */
const JOB_REVIEWS_KEY = 'job:reviews';
/** 本人发布评价的存储结构:在 EmployeeReview 上补充归属岗位 */
interface StoredJobReview extends EmployeeReview {
jobId: string;
}
/** 读取本人发布的全部评价 */
async function readJobReviews(): Promise<StoredJobReview[]> {
const raw = await storageGet(JOB_REVIEWS_KEY);
return raw ? (JSON.parse(raw) as StoredJobReview[]) : [];
}
/**
* 发布岗位评价
* @method POST
* @url /api/jobs/:id/reviews
* @param id 岗位 id
* @param content 评价内容
* @returns Promise<EmployeeReview> 新建的评价
* @remark 真实后端下需做敏感词校验与重复提交限制,此处不做
*/
export async function createJobReview(id: string, content: string): Promise<EmployeeReview> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/${id}/reviews`, { method: 'POST', body: JSON.stringify({ content }) }); return res.json();
const review: StoredJobReview = {
id: `my-review-${Date.now()}`,
jobId: id,
userName: '我',
userRole: '我的评价',
time: '刚刚',
content,
tags: [],
};
const all = await readJobReviews();
all.unshift(review);
await storageSet(JOB_REVIEWS_KEY, JSON.stringify(all));
return mockRequest(review);
}
/**
@ -1696,11 +1747,18 @@ export function computeSalaryResult(input: SalaryCalcInput): SalaryCalcResult {
// 年终奖摊入月度,得到月度税前合计
const monthlyTotal = monthlyGross * (1 + input.bonusMonths / 12);
// 按所在城市 clamp 社保基数到该城市上下限(养老/医疗/失业比例全国一致,城市差异在基数上下限)
const socialProfile = SOCIAL_SECURITY_CITY_PROFILES[input.city] ?? DEFAULT_SOCIAL_PROFILE;
const socialBase = Math.min(
socialProfile.socialBaseUpper,
Math.max(socialProfile.socialBaseLower, input.socialBase),
);
// 社保个人缴纳比例:养老 8% + 医疗 2% + 失业 0.5%
const pension = input.socialBase * 0.08;
const medical = input.socialBase * 0.02;
const unemployment = input.socialBase * 0.005;
const providentFund = input.socialBase * input.providentFundRate;
const pension = socialBase * 0.08;
const medical = socialBase * 0.02;
const unemployment = socialBase * 0.005;
const providentFund = socialBase * input.providentFundRate;
const monthlySocial = pension + medical + unemployment + providentFund;
// 个税:月度应纳税所得额 = 税前 - 五险一金 - 5000 起征点 - 专项附加扣除
@ -2368,10 +2426,10 @@ export async function submitAppeal(
const MOCK_ENTERPRISE_QUICK_ACTIONS: EnterpriseQuickAction[] = [
{ id: 'publish', label: '发布岗位', icon: 'add-circle-outline', route: '/job-publish', color: 'primary' },
{ id: 'jobs', label: '岗位管理', icon: 'business-outline', route: '/job-management', color: 'primary' },
{ id: 'evaluations', label: '评价管理', icon: 'chatbubbles-outline', route: '/job-evaluations', color: 'success' },
{ id: 'candidates', label: '人才库', icon: 'people-outline', route: '/talent-recommendation', color: 'info' },
{ id: 'communication', label: '沟通', icon: 'chatbubble-outline', route: '/enterprise-chat', color: 'action' },
{ id: 'interviews', label: '面试日程', icon: 'calendar-outline', route: '/interview-score', color: 'action' },
{ id: 'evaluations', label: '岗位评价管理', icon: 'chatbubbles-outline', route: '/job-evaluations', color: 'success' },
{ id: 'candidates', label: '人才推荐', icon: 'people-outline', route: '/talent-recommendation', color: 'info' },
{ id: 'communication', label: '企业沟通', icon: 'chatbubble-outline', route: '/enterprise-chat', color: 'action' },
{ id: 'interviews', label: '面试评分', icon: 'calendar-outline', route: '/interview-score', color: 'action' },
];
/**
@ -3339,6 +3397,27 @@ const MOCK_GAME_LEVELS: GameLevel[] = [
explanation: '城市选择直接影响薪资预期与生活成本,建议结合行业聚集地进行选择。',
tags: ['城市'],
},
{
id: 'self-3',
title: '你最擅长的 3 项技能或优势是什么?',
type: 'input',
placeholder: '如:文案写作、数据分析、沟通协调',
explanation: '清晰描述自身优势,是定位岗位与撰写简历的第一步,建议结合具体事例。',
tags: ['自我认知'],
},
{
id: 'self-4',
title: '以下哪些做法有助于明确职业方向?(多选)',
type: 'multiple',
options: [
{ id: 'a', text: '做职业测评并结合兴趣', isCorrect: true },
{ id: 'b', text: '通过实习/兼职真实体验', isCorrect: true },
{ id: 'c', text: '盲目跟风最热门的岗位', isCorrect: false },
{ id: 'd', text: '完全由他人替你决定', isCorrect: false },
],
explanation: '测评、实践与自我反思结合,才能找到真正匹配的方向;盲从他人容易走偏。',
tags: ['职业方向'],
},
],
},
{
@ -3363,6 +3442,32 @@ const MOCK_GAME_LEVELS: GameLevel[] = [
explanation: '开源项目、竞赛获奖、专业认证都是简历中极具说服力的硬技能证明。',
tags: ['技能证明'],
},
{
id: 'ability-2',
title: '以下哪些方式能有效提升简历的含金量?(多选)',
type: 'multiple',
options: [
{ id: 'a', text: '参与真实项目并沉淀成果', isCorrect: true },
{ id: 'b', text: '考取行业权威证书', isCorrect: true },
{ id: 'c', text: '撰写技术博客 / 作品集', isCorrect: true },
{ id: 'd', text: '花钱买来路不明的"水证"', isCorrect: false },
],
explanation: '真实项目、权威证书与可展示的作品,是简历含金量最硬的支撑。',
tags: ['能力提升'],
},
{
id: 'ability-3',
title: '描述一段经历时,最能体现能力的写法是?',
type: 'single',
options: [
{ id: 'a', text: '只写"负责相关工作"', isCorrect: false },
{ id: 'b', text: '用 STAR 法则并量化结果', isCorrect: true },
{ id: 'c', text: '堆砌"吃苦耐劳"等形容词', isCorrect: false },
{ id: 'd', text: '直接复制他人经历', isCorrect: false },
],
explanation: '情境-任务-行动-结果 + 数据,让能力可被验证。',
tags: ['能力表达'],
},
],
},
{
@ -3387,6 +3492,27 @@ const MOCK_GAME_LEVELS: GameLevel[] = [
explanation: '岗位 JD 中的核心技能要求决定了你是否能胜任,是筛选岗位的首要依据。',
tags: ['岗位理解'],
},
{
id: 'job-2',
title: '阅读 JD(岗位描述)时,应重点提取哪些信息?(多选)',
type: 'multiple',
options: [
{ id: 'a', text: '核心职责与任职要求', isCorrect: true },
{ id: 'b', text: '薪资范围与结构', isCorrect: true },
{ id: 'c', text: '发展空间与汇报线', isCorrect: true },
{ id: 'd', text: '公司团建照片', isCorrect: false },
],
explanation: '职责、要求、薪资、发展是判断"该不该投"的关键。',
tags: ['岗位理解'],
},
{
id: 'job-3',
title: '你最想从事的岗位名称是?',
type: 'input',
placeholder: '如:前端工程师 / 产品经理',
explanation: '明确目标岗位名,才能精准匹配 JD 并针对性准备面试。',
tags: ['目标岗位'],
},
],
},
{
@ -3411,6 +3537,32 @@ const MOCK_GAME_LEVELS: GameLevel[] = [
explanation: '官网口号往往经过包装,工商信息、员工评价、业务健康度才是判断企业的硬指标。',
tags: ['企业判断'],
},
{
id: 'company-2',
title: '以下哪些是判断企业风险的信号?(多选)',
type: 'multiple',
options: [
{ id: 'a', text: '频繁大规模裁员', isCorrect: true },
{ id: 'b', text: '社保公积金按最低基数缴纳', isCorrect: true },
{ id: 'c', text: '劳动纠纷频发', isCorrect: true },
{ id: 'd', text: '公司前台装修豪华', isCorrect: false },
],
explanation: '裁员、社保、纠纷是真实风险信号,装修不能代表经营健康。',
tags: ['风险识别'],
},
{
id: 'company-3',
title: '了解一家公司真实口碑,最靠谱的渠道是?',
type: 'single',
options: [
{ id: 'a', text: '公司官网与招聘海报', isCorrect: false },
{ id: 'b', text: '在职/离职员工匿名评价 + 脉脉/看准等平台', isCorrect: true },
{ id: 'c', text: '公司年会视频', isCorrect: false },
{ id: 'd', text: '老板个人朋友圈', isCorrect: false },
],
explanation: '一线员工的真实评价,比官方宣传更接近事实。',
tags: ['口碑调研'],
},
],
},
{
@ -3435,6 +3587,32 @@ const MOCK_GAME_LEVELS: GameLevel[] = [
explanation: 'STAR 法则(情境-任务-行动-结果)加量化数据,是项目经历最说服人的写法。',
tags: ['简历'],
},
{
id: 'resume-2',
title: '简历中应避免哪些问题?(多选)',
type: 'multiple',
options: [
{ id: 'a', text: '错别字与排版混乱', isCorrect: true },
{ id: 'b', text: '虚构或夸大经历', isCorrect: true },
{ id: 'c', text: '与岗位无关内容过多', isCorrect: true },
{ id: 'd', text: '用数据量化成果', isCorrect: false },
],
explanation: '错字、造假、跑题会直接减分;量化成果才是加分项。',
tags: ['简历避坑'],
},
{
id: 'resume-3',
title: '针对不同岗位投递,简历应?',
type: 'single',
options: [
{ id: 'a', text: '一份简历投遍所有岗位', isCorrect: false },
{ id: 'b', text: '按 JD 关键词针对性修改', isCorrect: true },
{ id: 'c', text: '大量使用花哨模板', isCorrect: false },
{ id: 'd', text: '隐瞒关键经历', isCorrect: false },
],
explanation: '针对性修改能显著提升匹配度与通过率。',
tags: ['简历优化'],
},
],
},
{
@ -3459,6 +3637,32 @@ const MOCK_GAME_LEVELS: GameLevel[] = [
explanation: '坦诚展示自我认知与改进计划,能体现你的成长型思维。',
tags: ['面试'],
},
{
id: 'interview-2',
title: '面试前应如何准备?(多选)',
type: 'multiple',
options: [
{ id: 'a', text: '研究公司与岗位背景', isCorrect: true },
{ id: 'b', text: '准备 STAR 案例', isCorrect: true },
{ id: 'c', text: '模拟常见题型', isCorrect: true },
{ id: 'd', text: '完全不准备靠临场发挥', isCorrect: false },
],
explanation: '研究、案例、模拟三者结合,临场更稳。',
tags: ['面试准备'],
},
{
id: 'interview-3',
title: '被问期望薪资时,合适的做法是?',
type: 'single',
options: [
{ id: 'a', text: '完全不敢谈,随便应允', isCorrect: false },
{ id: 'b', text: '提前调研行业薪资区间后给范围', isCorrect: true },
{ id: 'c', text: '直接报一个离谱高价', isCorrect: false },
{ id: 'd', text: '说"你定就行"', isCorrect: false },
],
explanation: '基于调研给区间,既体现专业也保护自身权益。',
tags: ['薪资谈判'],
},
],
},
{
@ -3482,6 +3686,32 @@ const MOCK_GAME_LEVELS: GameLevel[] = [
explanation: '团建频率不属于劳动合同必备条款,薪资、试用期、岗位地点才是核心。',
tags: ['签约'],
},
{
id: 'offer-2',
title: '签订劳动合同时应关注的合法权益包括?(多选)',
type: 'multiple',
options: [
{ id: 'a', text: '五险一金缴纳', isCorrect: true },
{ id: 'b', text: '试用期时长合规', isCorrect: true },
{ id: 'c', text: '加班与休假规定', isCorrect: true },
{ id: 'd', text: '公司团建频率', isCorrect: false },
],
explanation: '社保、试用期、工时休假是法定权益核心。',
tags: ['签约权益'],
},
{
id: 'offer-3',
title: '关于试用期,下列说法正确的是?',
type: 'single',
options: [
{ id: 'a', text: '试用期可随意辞退且不补偿', isCorrect: false },
{ id: 'b', text: '试用期最长不超 6 个月且须缴社保', isCorrect: true },
{ id: 'c', text: '试用期不缴社保是行业惯例合法', isCorrect: false },
{ id: 'd', text: '试用期长短由公司随便定', isCorrect: false },
],
explanation: '法律对试用期上限与社保有明确规定,不能随意。',
tags: ['试用期'],
},
],
},
];
@ -3637,6 +3867,7 @@ const MOCK_LEGAL_SERVICES: LegalService[] = [
icon: 'people-outline',
type: 'member',
action: 'upgrade',
requestHint: '请简要描述你遇到的劳动法律问题,例如试用期被辞退、拖欠工资、未签劳动合同等,律师将在 24 小时内响应。',
},
{
id: 'member-review',
@ -3645,6 +3876,7 @@ const MOCK_LEGAL_SERVICES: LegalService[] = [
icon: 'reader-outline',
type: 'member',
action: 'upgrade',
requestHint: '请说明需要审核的文书类型与你重点关注的风险点,例如劳动合同中的违约金条款、竞业限制范围等。',
},
{
id: 'member-advisor',
@ -3653,6 +3885,7 @@ const MOCK_LEGAL_SERVICES: LegalService[] = [
icon: 'shield-checkmark-outline',
type: 'member',
action: 'upgrade',
requestHint: '请描述你希望顾问长期跟进的事项,例如在职期间的权益保障、跳槽风险把控等。',
},
];
@ -3692,6 +3925,20 @@ const MOCK_LEGAL_TEMPLATES: LegalTemplate[] = [
description: '入职前必查 12 项关键条款',
icon: 'checkbox-outline',
url: 'https://dev.api.com/legal/templates/contract-checklist',
content: `劳动合同审查清单(入职前必查 12 项)
1. 用人单位名称、法定代表人、注册地址是否与营业执照一致
2. 合同期限(固定/无固定/以完成一定工作任务为期限)与试用期约定是否合法(试用期不超合同期 1/12,且最长 6 个月)
3. 工作地点、岗位名称、工作内容是否明确,有无「公司可随意调岗」等霸王条款
4. 工时制度(标准/综合计算/不定时)及加班工资计算基数与倍数
5. 劳动报酬:基本工资、绩效、补贴构成,发放时间与方式
6. 社会保险与住房公积金的缴纳基数、比例、起始时间
7. 试用期工资是否不低于转正工资 80% 且不低于当地最低工资
8. 竞业限制范围、期限(不超 2 年)、补偿标准是否明确
9. 培训服务期约定及违约金上限(不超培训费用)
10. 解除劳动合同的条件与经济补偿(N / N+1 / 2N)是否合法
11. 违约金条款是否超出法定范围(仅培训、竞业可约定违约金)
12. 争议解决方式(仲裁/诉讼)与管辖地是否合理`,
},
{
id: 'tmpl-2',
@ -3699,6 +3946,22 @@ const MOCK_LEGAL_TEMPLATES: LegalTemplate[] = [
description: '规范离职通知,保留证据',
icon: 'exit-outline',
url: 'https://dev.api.com/legal/templates/resignation-letter',
content: `离职申请书
致:____(公司名称)____ 人力资源部
本人 ____(姓名)____,身份证号 ____,于 ____(入职日期)____ 入职,现任 ____(部门 / 岗位)____。
现因 ____(离职原因,如:个人职业发展)____,根据《劳动合同法》第三十七条,本人提前三十日以书面形式通知公司,拟于 ____(最后工作日期)____ 正式解除劳动关系。
请公司按约定办理工作交接、出具离职证明,并结清工资、加班费及未休年假工资;社会保险与住房公积金请缴存至 ____(年 / 月)____。
此致
敬礼
申请人:____(签名)____
联系电话:____
日期:____ 年 ____ 月 ____ 日`,
},
{
id: 'tmpl-3',
@ -3706,6 +3969,27 @@ const MOCK_LEGAL_TEMPLATES: LegalTemplate[] = [
description: '劳动争议维权起步文书',
icon: 'gavel-outline',
url: 'https://dev.api.com/legal/templates/arbitration-application',
content: `劳动仲裁申请书
申请人:____(姓名)____,性别 ____,身份证号 ____,联系电话 ____,住址 ____
被申请人:____(公司全称)____,统一社会信用代码 ____,住所地 ____,法定代表人 ____
仲裁请求:
1. 请求裁决被申请人支付违法解除劳动合同赔偿金 ____ 元;
2. 请求裁决被申请人支付拖欠工资 ____ 元及加班费 ____ 元;
3. 请求裁决被申请人补缴 ____ 年 ____ 月至 ____ 年 ____ 月的社会保险与住房公积金。
事实与理由:
____(简述入职时间、岗位、工资标准、争议发生经过及证据,如劳动合同、工资条、考勤记录、聊天记录等)____
此致
____(用人单位所在地或合同履行地)____ 劳动人事争议仲裁委员会
申请人:____(签名)____
日期:____ 年 ____ 月 ____ 日
附:1. 申请人身份证复印件;2. 证据清单及材料 ____ 份`,
},
];
@ -3797,6 +4081,198 @@ export async function fetchLegalPlans(): Promise<LegalPlan[]> {
return mockRequest(MOCK_LEGAL_PLANS);
}
/* ===================== 付费 / 订单 / 会员态(mock 阶段持久化到 AsyncStorage) ===================== */
const LEGAL_MEMBERSHIP_KEY = 'legal:membership';
const LEGAL_ORDERS_KEY = 'legal:orders';
/** 读取会员态(storage 为唯一真相源,避免内存态过期) */
async function readLegalMembership(): Promise<LegalMembership | null> {
const raw = await storageGet(LEGAL_MEMBERSHIP_KEY);
return raw ? (JSON.parse(raw) as LegalMembership) : null;
}
/** 写入会员态(null 表示清除) */
async function writeLegalMembership(m: LegalMembership | null): Promise<void> {
if (m) {
await storageSet(LEGAL_MEMBERSHIP_KEY, JSON.stringify(m));
} else {
await storageRemove(LEGAL_MEMBERSHIP_KEY);
}
}
/** 读取订单列表 */
async function readLegalOrders(): Promise<LegalOrder[]> {
const raw = await storageGet(LEGAL_ORDERS_KEY);
return raw ? (JSON.parse(raw) as LegalOrder[]) : [];
}
/** 写入订单列表 */
async function writeLegalOrders(orders: LegalOrder[]): Promise<void> {
await storageSet(LEGAL_ORDERS_KEY, JSON.stringify(orders));
}
/**
* 获取当前会员态
* @method GET
* @url /api/legal/membership
* @returns Promise<LegalMembership | null> 未开通则返回 null
*/
export async function fetchLegalMembership(): Promise<LegalMembership | null> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/membership`, { method: 'GET' }); return res.json();
return mockRequest(await readLegalMembership());
}
/**
* 获取付费订单列表(按创建时间倒序)
* @method GET
* @url /api/legal/orders
* @returns Promise<LegalOrder[]> 订单列表
*/
export async function fetchLegalOrders(): Promise<LegalOrder[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/orders`, { method: 'GET' }); return res.json();
return mockRequest(await readLegalOrders());
}
/**
* 创建会员订单(mock 阶段仅本地落库,不接真实支付)
* @method POST
* @url /api/legal/orders
* @param planId 套餐 id
* @param channel 支付方式
* @returns Promise<LegalOrder> 新建的待支付订单
*/
export async function createLegalOrder(
planId: string,
channel: LegalPaymentChannel,
): Promise<LegalOrder> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/orders`, { method: 'POST', body: JSON.stringify({ planId, channel }) }); return res.json();
const plans = await fetchLegalPlans();
const plan = plans.find((p) => p.id === planId);
if (!plan) {
throw new Error('套餐不存在');
}
const order: LegalOrder = {
id: `order-${Date.now()}`,
planId: plan.id,
planName: plan.name,
price: plan.price,
channel,
status: 'pending',
createdAt: new Date().toISOString(),
};
const orders = await readLegalOrders();
orders.unshift(order);
await writeLegalOrders(orders);
return mockRequest(order);
}
/**
* 支付订单(mock 阶段模拟支付处理并激活会员态)
* @method POST
* @url /api/legal/orders/:id/pay
* @param orderId 订单 id
* @returns Promise<LegalOrder> 支付后的订单(status=paid)
* @remark 真实接入时,应由后端支付回调确认支付结果后再激活会员态,前端不自行判定成功
*/
export async function payLegalOrder(orderId: string): Promise<LegalOrder> {
// TODO: 调用真实支付 SDK → 后端回调确认 → 返回订单
const orders = await readLegalOrders();
const idx = orders.findIndex((o) => o.id === orderId);
if (idx === -1) {
throw new Error('订单不存在');
}
const paid: LegalOrder = {
...orders[idx],
status: 'paid',
paidAt: new Date().toISOString(),
};
orders[idx] = paid;
await writeLegalOrders(orders);
// 根据套餐周期计算会员有效期并激活会员态
const plans = await fetchLegalPlans();
const plan = plans.find((p) => p.id === paid.planId);
if (plan) {
const now = new Date();
const expires = new Date(now.getTime());
// period: '/年' 或 '/月'
if (plan.period.includes('年')) {
expires.setFullYear(now.getFullYear() + 1);
} else {
expires.setMonth(now.getMonth() + 1);
}
await writeLegalMembership({
planId: plan.id,
planName: plan.name,
startedAt: now.toISOString(),
expiresAt: expires.toISOString(),
});
}
// 模拟支付处理延时
await new Promise((resolve) => setTimeout(resolve, 800));
return mockRequest(paid);
}
/* ===================== 会员服务使用申请(付费后权益兑现) ===================== */
const LEGAL_REQUESTS_KEY = 'legal:requests';
/** 读取服务申请列表 */
async function readLegalRequests(): Promise<LegalServiceRequest[]> {
const raw = await storageGet(LEGAL_REQUESTS_KEY);
return raw ? (JSON.parse(raw) as LegalServiceRequest[]) : [];
}
/** 写入服务申请列表 */
async function writeLegalRequests(requests: LegalServiceRequest[]): Promise<void> {
await storageSet(LEGAL_REQUESTS_KEY, JSON.stringify(requests));
}
/**
* 获取会员服务使用申请列表(按提交时间倒序)
* @method GET
* @url /api/legal/service-requests
* @returns Promise<LegalServiceRequest[]> 申请列表
*/
export async function fetchLegalServiceRequests(): Promise<LegalServiceRequest[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/service-requests`, { method: 'GET' }); return res.json();
return mockRequest(await readLegalRequests());
}
/**
* 提交会员服务使用申请(仅已开通会员可提交,权限由后端校验)
* @method POST
* @url /api/legal/service-requests
* @param serviceId 服务 id
* @param content 用户填写的需求描述
* @returns Promise<LegalServiceRequest> 新建的申请
*/
export async function createLegalServiceRequest(
serviceId: string,
content: string,
): Promise<LegalServiceRequest> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/service-requests`, { method: 'POST', body: JSON.stringify({ serviceId, content }) }); return res.json();
const services = await fetchLegalServices();
const service = services.find((s) => s.id === serviceId);
if (!service) {
throw new Error('服务不存在');
}
const request: LegalServiceRequest = {
id: `request-${Date.now()}`,
serviceId: service.id,
serviceName: service.title,
content,
status: 'submitted',
createdAt: new Date().toISOString(),
};
const requests = await readLegalRequests();
requests.unshift(request);
await writeLegalRequests(requests);
return mockRequest(request);
}
/**
* 获取默认法律咨询服务会话
* @method GET
@ -4271,7 +4747,7 @@ export async function removeBlockKeyword(keyword: string): Promise<KeywordBlockS
const MOCK_ACCOUNT_CANCEL: AccountCancelData = {
title: '账号注销',
consequences: [
'账号将无法登录,平台内所有数据将被永久删除且无法恢复',
'账号将无法登录且不可恢复,平台内所有数据将被清空且永久删除',
'正在进行的投递、面试邀约与沟通记录将全部终止',
'已获得的会员权益、优惠券、成长值与勋章将失效且不予退还',
'若你已完成企业认证,认证信息与关联招聘流程将一并注销',
@ -4786,7 +5262,7 @@ const MOCK_ACCOUNT_SECURITY: ActionSetting[] = [
{
id: 'deactivate',
title: '注销账号',
description: '注销后账号数据将被永久删除,不可恢复',
description: '注销后账号不可恢复,且注销审核期间无法登录',
variant: 'danger',
},
];
@ -4975,6 +5451,103 @@ export function loginByPhone(phone: string, code: string): Promise<LoginResult>
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

11
src/api/index.ts

@ -2,6 +2,7 @@
* 接口统一导出
*/
export type { LoginResult } from './api';
export type { WechatLoginResult, WechatSession, WechatBindPhonePayload } from '../types';
export {
fetchAboutInfo,
fetchAbilityAssessment,
@ -37,6 +38,7 @@ export {
fetchLegalServices,
fetchLegalTemplates,
fetchEmployeeReviews,
createJobReview,
fetchEnterpriseEntry,
fetchHollandAssessment,
fetchHollandResult,
@ -74,7 +76,16 @@ export {
fetchDeleteAllData,
submitDeleteData,
fetchLegalDocument,
fetchLegalMembership,
fetchLegalOrders,
createLegalOrder,
payLegalOrder,
fetchLegalServiceRequests,
createLegalServiceRequest,
loginByPhone,
fetchWechatLoginResult,
bindWechatAndRegister,
setMockWechatAccountStatus,
fetchPersonalDataExport,
requestPersonalDataExport,
fetchRankData,

32
src/constants/index.ts

@ -1,7 +1,7 @@
/**
* 常量统一导出(枚举、配置项等)
*/
import type { JobLevel } from '../types';
import type { CitySocialProfile, JobLevel } from '../types';
/** 请求失败自动重试次数 */
export const MAX_RETRY_COUNT = 3;
@ -34,3 +34,33 @@ export const EDUCATION_OPTIONS: string[] = ['不限', '大专', '本科', '硕
/** 热门城市(定位/城市选择用) */
export const CITIES: string[] = ['北京', '上海', '深圳', '杭州', '广州', '成都', '南京', '武汉'];
/**
* 城市社保/公积金缴费基数上下限(mock 数值,真实接入后端按城市下发)。
* 养老 8% / 医疗 2% / 失业 0.5% 全国一致,城市差异体现在基数上下限,故按城市 clamp 基数即可反映差异。
*/
export const SOCIAL_SECURITY_CITY_PROFILES: Record<string, CitySocialProfile> = {
北京: { city: '北京', socialBaseUpper: 33891, socialBaseLower: 6821, providentBaseUpper: 33891, providentBaseLower: 2420 },
上海: { city: '上海', socialBaseUpper: 36549, socialBaseLower: 7384, providentBaseUpper: 36549, providentBaseLower: 2690 },
深圳: { city: '深圳', socialBaseUpper: 43659, socialBaseLower: 4492, providentBaseUpper: 43659, providentBaseLower: 2360 },
广州: { city: '广州', socialBaseUpper: 27501, socialBaseLower: 5500, providentBaseUpper: 39579, providentBaseLower: 2300 },
杭州: { city: '杭州', socialBaseUpper: 24060, socialBaseLower: 4812, providentBaseUpper: 36675, providentBaseLower: 2490 },
成都: { city: '成都', socialBaseUpper: 22555, socialBaseLower: 4511, providentBaseUpper: 29331, providentBaseLower: 2100 },
南京: { city: '南京', socialBaseUpper: 24396, socialBaseLower: 4878, providentBaseUpper: 39900, providentBaseLower: 2280 },
武汉: { city: '武汉', socialBaseUpper: 22467, socialBaseLower: 4494, providentBaseUpper: 29330, providentBaseLower: 2210 },
};
/** 未匹配城市时的兜底上下限(参考一线城市,mock 阶段使用) */
export const DEFAULT_SOCIAL_PROFILE: CitySocialProfile = {
city: '全国',
socialBaseUpper: 33891,
socialBaseLower: 6821,
providentBaseUpper: 33891,
providentBaseLower: 2420,
};
/** 一天的毫秒数,用于令牌有效期等时间计算 */
export const ONE_DAY_MS = 24 * 60 * 60 * 1000;
/** 登录令牌有效期(毫秒),与后端 JWT 过期时间保持一致 */
export const TOKEN_TTL_MS = 30 * ONE_DAY_MS;

58
src/context/AuthContext.tsx

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

20
src/context/RoleContext.tsx

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

4
src/hooks/index.ts

@ -37,6 +37,8 @@ export { useJobPublish } from './useJobPublish';
export { useKeywordBlock } from './useKeywordBlock';
export { useLegalData } from './useLegalData';
export { useLegalDocument } from './useLegalDocument';
export { useLegalMembership } from './useLegalMembership';
export { useLegalServiceRequests } from './useLegalServiceRequests';
export { useMessageData } from './useMessageData';
export { useNotificationSettings } from './useNotificationSettings';
export { useOfferCompareData } from './useOfferCompareData';
@ -58,3 +60,5 @@ export { useWeightSettings } from './useWeightSettings';
export { useLogin } from './useLogin';
export { OnboardingProvider, useOnboarding } from './useOnboardingFlow';
export { useRegisterFinish } from './useRegisterFinish';
export { useWechatLogin, type PendingBindPhone, type WechatLoginState } from './useWechatLogin';
export { useWechatBindPhone } from './useWechatBindPhone';

12
src/hooks/useJobDetail.ts

@ -4,6 +4,7 @@
*/
import { useCallback } from 'react';
import {
createJobReview,
fetchEmployeeReviews,
fetchJobDetail,
fetchJobOverallScore,
@ -38,6 +39,16 @@ export function useJobDetail(id: string) {
const { data, loading, error, refresh } = useAsyncData(fetcher);
/** 发布岗位评价,成功后自动刷新评价列表 */
const publishReview = useCallback(
async (content: string) => {
const created = await createJobReview(id, content);
refresh();
return created;
},
[id, refresh],
);
return {
detail: data?.detail ?? null,
overallScore: data?.overallScore ?? null,
@ -46,5 +57,6 @@ export function useJobDetail(id: string) {
loading,
error,
refresh,
publishReview,
};
}

92
src/hooks/useLegalMembership.ts

@ -0,0 +1,92 @@
/**
* 法律服务会员态 hook:拉取当前会员态与付费订单,并提供
* 创建订单 / 模拟支付 的能力。mock 阶段数据持久化在 AsyncStorage。
*/
import { useCallback, useEffect, useState } from 'react';
import {
createLegalOrder,
fetchLegalMembership,
fetchLegalOrders,
payLegalOrder,
} from '../api';
import type { LegalMembership, LegalOrder, LegalPaymentChannel } from '../types';
export interface LegalMembershipState {
/** 当前会员态,未开通为 null */
membership: LegalMembership | null;
/** 付费订单列表(倒序) */
orders: LegalOrder[];
/** 首次加载中 */
loading: boolean;
/** 错误对象 */
error: Error | null;
/** 支付处理中(创建订单 + 模拟支付) */
processing: boolean;
/** 刷新会员态与订单 */
refresh: () => Promise<void>;
/** 创建订单,返回新建的待支付订单 */
createOrder: (planId: string, channel: LegalPaymentChannel) => Promise<LegalOrder>;
/** 支付订单,成功后同步会员态 */
payOrder: (orderId: string) => Promise<LegalOrder>;
}
export function useLegalMembership(): LegalMembershipState {
const [membership, setMembership] = useState<LegalMembership | null>(null);
const [orders, setOrders] = useState<LegalOrder[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [processing, setProcessing] = useState(false);
const refresh = useCallback(async () => {
try {
setLoading(true);
setError(null);
const [m, o] = await Promise.all([fetchLegalMembership(), fetchLegalOrders()]);
setMembership(m);
setOrders(o);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const createOrder = useCallback(
async (planId: string, channel: LegalPaymentChannel) => {
const order = await createLegalOrder(planId, channel);
setOrders((prev) => [order, ...prev]);
return order;
},
[],
);
const payOrder = useCallback(async (orderId: string) => {
setProcessing(true);
try {
const order = await payLegalOrder(orderId);
setOrders((prev) => prev.map((o) => (o.id === orderId ? order : o)));
if (order.status === 'paid') {
const m = await fetchLegalMembership();
setMembership(m);
}
return order;
} finally {
setProcessing(false);
}
}, []);
return {
membership,
orders,
loading,
error,
processing,
refresh,
createOrder,
payOrder,
};
}

58
src/hooks/useLegalServiceRequests.ts

@ -0,0 +1,58 @@
/**
* 会员服务使用申请 hook:拉取本人的服务申请记录,并提供提交申请的能力。
* 权益兑现载体——已开通会员提交后由律师/顾问跟进。
*/
import { useCallback, useEffect, useState } from 'react';
import { createLegalServiceRequest, fetchLegalServiceRequests } from '../api';
import type { LegalServiceRequest } from '../types';
export interface LegalServiceRequestsState {
/** 申请记录(倒序) */
requests: LegalServiceRequest[];
/** 首次加载中 */
loading: boolean;
/** 错误对象 */
error: Error | null;
/** 提交中 */
submitting: boolean;
/** 刷新申请记录 */
refresh: () => Promise<void>;
/** 提交申请 */
submit: (serviceId: string, content: string) => Promise<LegalServiceRequest>;
}
export function useLegalServiceRequests(): LegalServiceRequestsState {
const [requests, setRequests] = useState<LegalServiceRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [submitting, setSubmitting] = useState(false);
const refresh = useCallback(async () => {
try {
setLoading(true);
setError(null);
setRequests(await fetchLegalServiceRequests());
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const submit = useCallback(async (serviceId: string, content: string) => {
setSubmitting(true);
try {
const created = await createLegalServiceRequest(serviceId, content);
setRequests((prev) => [created, ...prev]);
return created;
} finally {
setSubmitting(false);
}
}, []);
return { requests, loading, error, submitting, refresh, submit };
}

11
src/hooks/useOfferCompareData.ts

@ -7,7 +7,16 @@ import type { OfferCompareData } from '../types';
import { useAsyncData } from './useAsyncData';
export function useOfferCompareData(offerIds?: string[]) {
const fetcher = useCallback(() => fetchOfferCompareData(offerIds), [offerIds?.join(',')]);
/*
* 取数依赖用「内容字符串」而不是数组引用:useAsyncData 会在 fetcher 引用变化时重新请求,
* 若直接用 offerIds 作依赖,调用方每次渲染传入新数组(内容没变)也会反复取数。
* 因此把 id 列表压成字符串作为依赖,取数时再还原成数组;id 不含逗号,往返无损。
*/
const offerIdsKey = offerIds?.join(',') ?? '';
const fetcher = useCallback(
() => fetchOfferCompareData(offerIdsKey ? offerIdsKey.split(',') : undefined),
[offerIdsKey],
);
const { data, loading, error, refresh } = useAsyncData<OfferCompareData>(fetcher);

8
src/hooks/useOnboardingFlow.tsx

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

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 [saving, setSaving] = useState(false);
const remoteDimensions = data?.dimensions ?? [];
/*
* 必须记忆化:data 为空时 `?? []` 每次渲染都会生成新数组,
* 会让下面依赖它的 useCallback 每次渲染都变引用(进而触发额外重渲染)。
*/
const remoteDimensions = useMemo(() => data?.dimensions ?? [], [data]);
const dimensions = localDimensions ?? remoteDimensions;
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()}`);
}

15
src/types/index.ts

@ -160,6 +160,7 @@ export type {
ProgressStatus,
ProgressStep,
SalaryBracket,
CitySocialProfile,
SalaryCalcInput,
SalaryCalcResult,
SalaryInsuranceItem,
@ -192,6 +193,12 @@ export type {
LegalService,
LegalServiceType,
LegalTemplate,
LegalPaymentChannel,
LegalOrderStatus,
LegalOrder,
LegalMembership,
LegalServiceRequest,
LegalServiceRequestStatus,
} from './legal';
export type {
AboutInfo,
@ -247,3 +254,11 @@ export type {
PersonalDataExport,
RecentExportRecord,
} from './data-export';
export type {
WechatAccountStatus,
WechatBindPhonePayload,
WechatLoginResult,
WechatPendingTicket,
WechatSession,
WechatUserProfile,
} from './wechat-auth';

65
src/types/legal.ts

@ -20,6 +20,8 @@ export interface LegalService {
type: LegalServiceType;
/** 跳转动作:chat=法律咨询对话 / templates=文书模板 / faq=常见问题 / upgrade=开通会员 */
action: 'chat' | 'templates' | 'faq' | 'upgrade';
/** 会员服务使用页的引导提示语(仅 type=member 时配置) */
requestHint?: string;
}
/** 法律常见问题 */
@ -44,8 +46,10 @@ export interface LegalTemplate {
description: string;
/** 图标名(Ionicons) */
icon: string;
/** 下载/预览地址(mock 阶段为占位) */
/** 下载/预览地址(mock 阶段为占位,真实下载由后端文件提供) */
url: string;
/** 模板正文(mock 阶段提供可预览/复制的内容;____ 表示需填写项) */
content: string;
}
/** 会员套餐 */
@ -101,3 +105,62 @@ export interface LegalBrand {
/** 品牌 Logo(mock 阶段用 emoji/图标占位) */
logo: string;
}
/* ===================== 付费 / 订单 / 会员态 ===================== */
/** 支付方式(mock 阶段仅作选择,真实支付由后端凭证决定) */
export type LegalPaymentChannel = 'wechat' | 'alipay';
/** 订单状态:待支付 / 已支付 / 失败 */
export type LegalOrderStatus = 'pending' | 'paid' | 'failed';
/** 会员订单 */
export interface LegalOrder {
/** 订单 id */
id: string;
/** 套餐 id */
planId: string;
/** 套餐名称(下单时快照) */
planName: string;
/** 实付金额(元) */
price: number;
/** 支付方式 */
channel: LegalPaymentChannel;
/** 订单状态 */
status: LegalOrderStatus;
/** 创建时间 ISO */
createdAt: string;
/** 支付时间 ISO(status=paid 时存在) */
paidAt?: string;
}
/** 会员态 */
export interface LegalMembership {
/** 套餐 id */
planId: string;
/** 套餐名称 */
planName: string;
/** 开通时间 ISO */
startedAt: string;
/** 到期时间 ISO */
expiresAt: string;
}
/** 会员服务申请状态:已提交 / 处理中 / 已完成 */
export type LegalServiceRequestStatus = 'submitted' | 'processing' | 'done';
/** 会员服务使用申请(付费后权益兑现的载体) */
export interface LegalServiceRequest {
/** 申请 id */
id: string;
/** 服务 id */
serviceId: string;
/** 服务名称(提交时快照) */
serviceName: string;
/** 用户填写的需求描述 */
content: string;
/** 申请状态 */
status: LegalServiceRequestStatus;
/** 提交时间 ISO */
createdAt: string;
}

16
src/types/tools.ts

@ -139,7 +139,7 @@ export interface SkillCloudData {
export interface SalaryCalcInput {
/** 税前月薪(元) */
monthlyGross: number;
/** 所在城市(决定社保公积金上下限,mock 阶段仅展示) */
/** 所在城市(决定社保/公积金缴费基数上下限,选择后实际参与计算) */
city: string;
/** 社保缴费基数(元) */
socialBase: number;
@ -151,6 +151,20 @@ export interface SalaryCalcInput {
bonusMonths: number;
}
/** 城市社保/公积金缴费基数上下限(mock 阶段内置,真实后端按城市下发) */
export interface CitySocialProfile {
/** 城市名(与 CITIES 常量对应) */
city: string;
/** 社保缴费基数上限(元/月) */
socialBaseUpper: number;
/** 社保缴费基数下限(元/月) */
socialBaseLower: number;
/** 公积金缴存基数上限(元/月) */
providentBaseUpper: number;
/** 公积金缴存基数下限(元/月) */
providentBaseLower: number;
}
/** 五险一金单项明细 */
export interface SalaryInsuranceItem {
/** 险种名称 */

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"
integrity sha512-D5gd/uFa7KfMJX9S11hvej4GpQnBl27oKiUgyIq3cLTPtyDOWBwRgmeePIdV6+PGcedlDkaCND2BnRgAkxzNaA==
"@expo/cli@^57.0.21":
version "57.0.21"
resolved "https://registry.npmmirror.com/@expo/cli/-/cli-57.0.21.tgz#fcf3e41828c5a073bac84a2bcbab47eaaf2dfc5d"
integrity sha512-CkcgAOsp1KkNRENXWfyr/esIwgFtiHosKECWZ0QQl2nFOUEQlyjWXAZ6uTCMnbHLonVnPMZpXTkHZNw3gtanFw==
"@expo/cli@^57.0.23":
version "57.0.23"
resolved "https://registry.npmmirror.com/@expo/cli/-/cli-57.0.23.tgz#020be0e0886677ec013d10e11e7c9ce597b8329c"
integrity sha512-stzSYxVwbWGbKR+mrYT6s4Rt6HrcUxb0JrHyebDGL4ehpvfRTv0rMzn4Q7fqB6f5wJ1KCmf6OAgqgK+TTB8/FA==
dependencies:
"@expo/code-signing-certificates" "^0.0.6"
"@expo/config" "~57.0.9"
@ -742,7 +742,7 @@
"@expo/log-box" "^57.0.4"
"@expo/metro" "~56.0.2"
"@expo/metro-config" "~57.0.12"
"@expo/metro-file-map" "^57.0.2"
"@expo/metro-file-map" "^57.0.3"
"@expo/osascript" "^2.7.1"
"@expo/package-manager" "^1.13.1"
"@expo/plist" "^0.8.1"
@ -963,10 +963,10 @@
postcss "^8.5.14"
resolve-from "^5.0.0"
"@expo/metro-file-map@^57.0.2":
version "57.0.2"
resolved "https://registry.npmmirror.com/@expo/metro-file-map/-/metro-file-map-57.0.2.tgz#5833c378aebbda32447aeb7a6e51ad5a5a3e0bf0"
integrity sha512-tb50nSIWwKpRufSkGuivOK0FbUxv1Uwqptb0SzFTk0bkmYmcy3gIwnCbOHTfmrQDVndRhPEiu2SYgh9Z0PCvGg==
"@expo/metro-file-map@^57.0.3":
version "57.0.3"
resolved "https://registry.npmmirror.com/@expo/metro-file-map/-/metro-file-map-57.0.3.tgz#1ed09151ac17f594fc08051de15894cdfef07503"
integrity sha512-1OXy+uPYY5uc7Tm4VBsd2NRn+3wHhqeqNuEO/Xo4kmYgv8FjYgUAc+bUXON9FpC2ikcLn4EVlGM9ce2exx9Mlg==
dependencies:
debug "^4.3.4"
fb-watchman "^2.0.2"
@ -2381,7 +2381,55 @@ babel-preset-current-node-syntax@^1.0.0:
"@babel/plugin-syntax-private-property-in-object" "^7.14.5"
"@babel/plugin-syntax-top-level-await" "^7.14.5"
babel-preset-expo@~57.0.10, babel-preset-expo@~57.0.7:
babel-preset-expo@~57.0.11:
version "57.0.11"
resolved "https://registry.npmmirror.com/babel-preset-expo/-/babel-preset-expo-57.0.11.tgz#8ee8538b872c9927eaa59cd74fc8a15d71a239cf"
integrity sha512-R0NouDI3nzQUsjBh5TUJSDXLmuVzE2bp2YE0ywJkKxzrdWVjShpAlZMBuDeOQy6iKGB6ZCCvMqbQvpqgNMJ7iw==
dependencies:
"@babel/generator" "^7.20.5"
"@babel/helper-module-imports" "^7.25.9"
"@babel/plugin-proposal-decorators" "^7.12.9"
"@babel/plugin-proposal-export-default-from" "^7.24.7"
"@babel/plugin-syntax-dynamic-import" "^7.8.3"
"@babel/plugin-syntax-export-default-from" "^7.24.7"
"@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3"
"@babel/plugin-syntax-optional-chaining" "^7.8.3"
"@babel/plugin-transform-async-generator-functions" "^7.25.4"
"@babel/plugin-transform-async-to-generator" "^7.24.7"
"@babel/plugin-transform-block-scoping" "^7.25.0"
"@babel/plugin-transform-class-properties" "^7.25.4"
"@babel/plugin-transform-class-static-block" "^7.27.1"
"@babel/plugin-transform-classes" "^7.25.4"
"@babel/plugin-transform-destructuring" "^7.24.8"
"@babel/plugin-transform-export-namespace-from" "^7.25.9"
"@babel/plugin-transform-flow-strip-types" "^7.25.2"
"@babel/plugin-transform-for-of" "^7.24.7"
"@babel/plugin-transform-logical-assignment-operators" "^7.24.7"
"@babel/plugin-transform-modules-commonjs" "^7.24.8"
"@babel/plugin-transform-named-capturing-groups-regex" "^7.24.7"
"@babel/plugin-transform-nullish-coalescing-operator" "^7.24.7"
"@babel/plugin-transform-object-rest-spread" "^7.24.7"
"@babel/plugin-transform-optional-catch-binding" "^7.24.7"
"@babel/plugin-transform-optional-chaining" "^7.24.8"
"@babel/plugin-transform-parameters" "^7.24.7"
"@babel/plugin-transform-private-methods" "^7.24.7"
"@babel/plugin-transform-private-property-in-object" "^7.24.7"
"@babel/plugin-transform-react-display-name" "^7.24.7"
"@babel/plugin-transform-react-jsx" "^7.28.6"
"@babel/plugin-transform-react-jsx-development" "^7.27.1"
"@babel/plugin-transform-react-pure-annotations" "^7.27.1"
"@babel/plugin-transform-runtime" "^7.24.7"
"@babel/plugin-transform-typescript" "^7.25.2"
"@babel/plugin-transform-unicode-regex" "^7.24.7"
"@babel/preset-typescript" "^7.23.0"
"@react-native/babel-plugin-codegen" "0.86.3"
babel-plugin-react-compiler "^1.0.0"
babel-plugin-react-native-web "~0.21.0"
babel-plugin-syntax-hermes-parser "^0.36.0"
babel-plugin-transform-flow-enums "^0.0.2"
debug "^4.3.4"
babel-preset-expo@~57.0.7:
version "57.0.10"
resolved "https://registry.npmmirror.com/babel-preset-expo/-/babel-preset-expo-57.0.10.tgz#2d7655c2636a4c97e0615e90aa362410410ba9bb"
integrity sha512-08bt6bqMZFoQuWO9gkt0EXotbqKHimELFGa1LlKJvY89iKsi4S0FJSFBFJ4pn9NZvofet48HIjYDQ0SZJ7bNrg==
@ -3542,19 +3590,19 @@ expo-modules-autolinking@~57.0.12:
chalk "^4.1.0"
commander "^7.2.0"
expo-modules-core@~57.0.15:
version "57.0.15"
resolved "https://registry.npmmirror.com/expo-modules-core/-/expo-modules-core-57.0.15.tgz#721c6bec863ec21b0c873a65a5977a86f352c70f"
integrity sha512-HBxPXsx3eVLLRjAY57/8ghKDWIxjaEbluKKYFIq1OHPJC85ZfHsawwc0SOU4Z/qu+mdLz7Pwt5IkkWimeNSH5A==
expo-modules-core@~57.0.17:
version "57.0.17"
resolved "https://registry.npmmirror.com/expo-modules-core/-/expo-modules-core-57.0.17.tgz#627bf8fbab9100b9e9d1047ff75353d9c9cb8813"
integrity sha512-hHJwGHW0sMQiOLzEEl1QbJeWkBEBgjlPjrdBVGWlzvgUbCXJfFbDU2nhxoyzLMZnvHkpIzpiJjj5cM5W8gbudA==
dependencies:
"@expo/expo-modules-macros-plugin" "0.6.1"
expo-modules-jsi "~57.0.7"
expo-modules-jsi "~57.1.0"
invariant "^2.2.4"
expo-modules-jsi@~57.0.7:
version "57.0.7"
resolved "https://registry.npmmirror.com/expo-modules-jsi/-/expo-modules-jsi-57.0.7.tgz#f862a364161345433fcc4ddb5f0e2f33543f491f"
integrity sha512-/GOrTFPnCfg7+m6M1yOSEKNeNdHBCm0ju6aJ4XHlSmtE02nb7mWh1Cs6QsK2eB5Rz7hjbb356Nd7UimsFvN7Vg==
expo-modules-jsi@~57.1.0:
version "57.1.0"
resolved "https://registry.npmmirror.com/expo-modules-jsi/-/expo-modules-jsi-57.1.0.tgz#21c1d3df20ea886e937a9ea2930699e34e164867"
integrity sha512-a5ckeHfnbYfonhcHGkM0EU/a0Keh+/OufXy/HyFS+spr5Ib0N4Oh1ZsSYFQhp5xdOUFkVofKRhfH+VmcccIIpA==
expo-router@~57.0.14:
version "57.0.18"
@ -3607,13 +3655,13 @@ expo-symbols@^57.0.2:
"@expo-google-fonts/material-symbols" "^0.4.1"
sf-symbols-typescript "^2.0.0"
expo@~57.0.14:
version "57.0.19"
resolved "https://registry.npmmirror.com/expo/-/expo-57.0.19.tgz#06b4371f5ff32cc71c0ba68dbc4ae3770a56891b"
integrity sha512-oIoAIisim1DwS2Zjp0rcEY9cfcq1wU6KpkBfwwFhJWI5YHV6i/aIzTUMX+uf0Q0CD6AvMls7IZVh34o27NjaJA==
expo@57.0.21:
version "57.0.21"
resolved "https://registry.npmmirror.com/expo/-/expo-57.0.21.tgz#6464818c652c63a6f1f71be706e0840b7a6ab124"
integrity sha512-lQmC0kCJCleO+uLUwHXY0pLDzcvedKEsX+pmJp4mSxn3JlWDTZvUb0e5UIlV7+bqlN68LpAeKG4c2lrN4zuP0Q==
dependencies:
"@babel/runtime" "^7.20.0"
"@expo/cli" "^57.0.21"
"@expo/cli" "^57.0.23"
"@expo/config" "~57.0.9"
"@expo/config-plugins" "~57.0.9"
"@expo/devtools" "~57.0.1"
@ -3624,14 +3672,14 @@ expo@~57.0.14:
"@expo/metro" "~56.0.2"
"@expo/metro-config" "~57.0.12"
"@ungap/structured-clone" "^1.3.0"
babel-preset-expo "~57.0.10"
babel-preset-expo "~57.0.11"
expo-asset "~57.0.16"
expo-constants "~57.0.17"
expo-file-system "~57.0.6"
expo-font "~57.0.3"
expo-keep-awake "~57.0.1"
expo-modules-autolinking "~57.0.12"
expo-modules-core "~57.0.15"
expo-modules-core "~57.0.17"
pretty-format "^29.7.0"
react-refresh "^0.14.2"
whatwg-url-minimum "^0.1.2"

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 自定义客户端,三分支即可真机走通。

113
项目开发状态.md

@ -0,0 +1,113 @@
# 有职 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` 已建,**付费 / 支付链路已接通(mock 支付闭环)**——`legal/checkout.tsx` 选套餐→选支付方式→模拟支付→会员态激活并 AsyncStorage 持久化;`legal.tsx` / `upgrade.tsx` 套餐卡可点击进入结算,会员态在返回时即时刷新。真实支付 SDK 后续仅换 `api.ts` 的 `createLegalOrder` / `payLegalOrder` 函数体。**付费后权益已兑现**:新增 `legal/service.tsx` 会员服务使用页——未开通点会员服务仍引导开通,已开通则进入使用页(服务说明 + 引导语 + 需求输入框 + 提交申请 + 申请记录持久化),`legal.tsx` 会员卡标签按态显示「会员 / 已开通」。
- **岗位评价发布(双向透明评价生态核心)**:✅ 2026-09-10 接通。此前 `jobs/[id].tsx` 底部发布按钮为「功能开发中」占位——产品核心定位即评价体系却无法发布。现已新增 `createJobReview`(AsyncStorage 键 `job:reviews`,按 jobId 归属),`fetchEmployeeReviews` 合并「本人发布(置顶)+ 内置评价」,`useJobDetail` 暴露 `publishReview`,页面接线含空输入拦截、发布中 loading、成功后清空输入框并自动刷新列表。
- **隐私政策 / 用户协议内容**:`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 问题】** · ~~付费法律服务支付~~ **付费法律服务支付【已完成:mock 支付闭环,选套餐→结算→模拟支付→会员激活持久化,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