Browse Source

feat: 新增岗位详情页,重构个人中心与底部导航

- 岗位详情:新增 JobDetail/JobOverallScore/SalaryData/EmployeeReview 等类型、fetchJobDetail 等接口、useJobDetail Hook,新增 app/jobs/[id].tsx 页面(含 SVG 雷达图、真实薪资区间、员工评价、评论输入),岗位列表卡片接入详情跳转
- 个人中心:重做我的页 UI,品牌蓝渐变头部、综合评分卡片、八大维度横向评分条、求职偏好、勋章横滑
- 导航栏:底部 Tab 改为悬浮毛玻璃胶囊,选中浅蓝高亮 + 图标弹跳动画,引入 expo-blur 依赖
master
doublex001 2 months ago
parent
commit
a6301a8570
  1. 4
      AGENTS.md
  2. 182
      app/(tabs)/_layout.tsx
  3. 20
      app/(tabs)/jobs.tsx
  4. 346
      app/(tabs)/profile.tsx
  5. 1
      app/_layout.tsx
  6. 611
      app/jobs/[id].tsx
  7. 385
      package-lock.json
  8. 1
      package.json
  9. 151
      src/api/api.ts
  10. 4
      src/api/index.ts
  11. 1
      src/hooks/index.ts
  12. 50
      src/hooks/useJobDetail.ts
  13. 6
      src/types/index.ts
  14. 68
      src/types/job.ts
  15. 6
      src/types/user.ts

4
AGENTS.md

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

182
app/(tabs)/_layout.tsx

@ -1,62 +1,178 @@
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import type { ComponentProps } from 'react'; import { BlurView } from 'expo-blur';
import { StyleSheet, Text } from 'react-native'; import { Tabs } from 'expo-router/js-tabs';
import type { ColorValue } from 'react-native'; import type { BottomTabBarProps } from 'expo-router/js-tabs';
import { useEffect, useRef } from 'react';
import type { ComponentProps, ReactNode } from 'react';
import { Animated, Pressable, StyleSheet, Text, View, type ColorValue } from 'react-native';
import { colors } from '../../src/theme'; import { colors } from '../../src/theme';
type IconName = ComponentProps<typeof Ionicons>['name']; type IconName = ComponentProps<typeof Ionicons>['name'];
type TabIconProps = { focused: boolean; color: ColorValue; size: number };
/** 内容底部预留高度:悬浮 Tab 栏高度 + 悬浮间距 + 余量 */
const TAB_BAR_FLOAT_SPACE = 84;
/** /**
* 根据选中状态返回实心/描边图标 * 选中/未选中切换实心与描边图标
*/ */
function tabIcon(filled: IconName, outline: IconName) { function tabIcon(filled: IconName, outline: IconName) {
return ({ color, size, focused }: { color: ColorValue; size: number; focused: boolean }) => ( return ({ color, size, focused }: TabIconProps) => (
<Ionicons name={focused ? filled : outline} size={size} color={color} /> <Ionicons name={focused ? filled : outline} size={size} color={color} />
); );
} }
/** 单个 Tab 项:选中时图标轻弹 + 浅蓝胶囊高亮 */
function TabItem({
focused,
label,
icon,
onPress,
onLongPress,
}: {
focused: boolean;
label: string;
icon?: (props: TabIconProps) => ReactNode;
onPress: () => void;
onLongPress: () => void;
}) {
// 图标弹跳动画:选中 1.15,未选中回到 1
const scale = useRef(new Animated.Value(1)).current;
useEffect(() => {
Animated.spring(scale, {
toValue: focused ? 1.15 : 1,
useNativeDriver: true,
friction: 6,
tension: 200,
}).start();
}, [focused, scale]);
const color = focused ? colors.primary : colors.textSecondary;
return (
<Pressable
style={styles.tabItem}
onPress={onPress}
onLongPress={onLongPress}
accessibilityRole="button"
accessibilityState={{ selected: focused }}
>
<View style={[styles.tabItemContent, focused && styles.tabItemContentActive]}>
<Animated.View style={{ transform: [{ scale }] }}>
{icon?.({ focused, color, size: 22 })}
</Animated.View>
<Text style={[styles.tabLabel, { color }]}>{label}</Text>
</View>
</Pressable>
);
}
/** 悬浮毛玻璃 Tab 栏 */
function FloatingTabBar({ state, descriptors, navigation, insets }: BottomTabBarProps) {
return (
<View style={styles.tabBarWrap} pointerEvents="box-none">
<View style={[styles.tabBarShadow, { marginBottom: insets.bottom + 12 }]}>
<BlurView intensity={80} tint="light" blurMethod="dimezisBlurViewSdk31Plus" style={styles.tabBar}>
{state.routes.map((route, index) => {
const { options } = descriptors[route.key];
const focused = state.index === index;
const label =
typeof options.tabBarLabel === 'string' ? options.tabBarLabel : options.title ?? '';
const onPress = () => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (!focused && !event.defaultPrevented) {
navigation.navigate(route.name);
}
};
const onLongPress = () =>
navigation.emit({ type: 'tabLongPress', target: route.key });
return (
<TabItem
key={route.key}
focused={focused}
label={label}
icon={options.tabBarIcon}
onPress={onPress}
onLongPress={onLongPress}
/>
);
})}
</BlurView>
</View>
</View>
);
}
/** /**
* 底部 Tab 导航:首页默认选中,蓝色高亮 * 底部 Tab 导航:悬浮毛玻璃胶囊 + 选中高亮 + 图标弹跳
*/ */
export default function TabLayout() { export default function TabLayout() {
return ( return (
<Tabs <Tabs
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
tabBarActiveTintColor: colors.primary, // 悬浮 Tab 栏不占布局空间,给每个页面内容底部预留空间,避免被遮挡
tabBarInactiveTintColor: colors.textSecondary, sceneStyle: { paddingBottom: TAB_BAR_FLOAT_SPACE },
tabBarLabel: ({ color, children }) => (
<Text style={[styles.labelText, { color }]}>{children}</Text>
),
}} }}
tabBar={(props) => <FloatingTabBar {...props} />}
> >
<Tabs.Screen <Tabs.Screen name="index" options={{ title: '首页', tabBarIcon: tabIcon('home', 'home-outline') }} />
name="index" <Tabs.Screen name="jobs" options={{ title: '岗位', tabBarIcon: tabIcon('briefcase', 'briefcase-outline') }} />
options={{ title: '首页', tabBarIcon: tabIcon('home', 'home-outline') }} <Tabs.Screen name="chat" options={{ title: '沟通', tabBarIcon: tabIcon('chatbubbles', 'chatbubbles-outline') }} />
/> <Tabs.Screen name="growth" options={{ title: '成长', tabBarIcon: tabIcon('trending-up', 'trending-up-outline') }} />
<Tabs.Screen <Tabs.Screen name="profile" options={{ title: '我的', tabBarIcon: tabIcon('person', 'person-outline') }} />
name="jobs"
options={{ title: '岗位', tabBarIcon: tabIcon('briefcase', 'briefcase-outline') }}
/>
<Tabs.Screen
name="chat"
options={{ title: '沟通', tabBarIcon: tabIcon('chatbubbles', 'chatbubbles-outline') }}
/>
<Tabs.Screen
name="growth"
options={{ title: '成长', tabBarIcon: tabIcon('trending-up', 'trending-up-outline') }}
/>
<Tabs.Screen
name="profile"
options={{ title: '我的', tabBarIcon: tabIcon('person', 'person-outline') }}
/>
</Tabs> </Tabs>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
labelText: { tabBarWrap: {
position: 'absolute',
left: 16,
right: 16,
bottom: 0,
},
// 外层负责 iOS 阴影(不裁剪,阴影在圆角外正常显示)
tabBarShadow: {
borderRadius: 24,
shadowColor: colors.textPrimary,
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.15,
shadowRadius: 16,
},
// 内层 BlurView:真毛玻璃(iOS)/ 半透明(Android 31 以下降级)+ Android 阴影
tabBar: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 24,
paddingHorizontal: 6,
paddingVertical: 8,
overflow: 'hidden',
elevation: 10,
},
tabItem: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
tabItemContent: {
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 18,
},
tabItemContentActive: {
backgroundColor: colors.primaryLight,
},
tabLabel: {
marginTop: 4,
fontSize: 11, fontSize: 11,
fontWeight: '600',
}, },
}); });

20
app/(tabs)/jobs.tsx

@ -1,4 +1,5 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
@ -94,10 +95,14 @@ function FilterGroup({
); );
} }
/** 单张岗位卡片 */ /** 单张岗位卡片(整卡可点击进入详情) */
function JobCard({ job }: { job: Job }) { function JobCard({ job }: { job: Job }) {
const router = useRouter();
return ( return (
<View style={styles.card}> <Pressable
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
onPress={() => router.push(`/jobs/${job.id}`)}
>
{/* 公司名 + 地点 */} {/* 公司名 + 地点 */}
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<Text style={styles.companyName}>{job.companyName}</Text> <Text style={styles.companyName}>{job.companyName}</Text>
@ -117,13 +122,7 @@ function JobCard({ job }: { job: Job }) {
<Text style={[styles.scoreText, { color: LEVEL_COLOR[job.level] }]}> <Text style={[styles.scoreText, { color: LEVEL_COLOR[job.level] }]}>
{job.score}分 · {JOB_LEVEL_LABEL[job.level]} {job.score}分 · {JOB_LEVEL_LABEL[job.level]}
</Text> </Text>
<Pressable
onPress={() => {
console.log('查看详情', job.id, job.positionName);
}}
>
<Text style={styles.detailLink}>查看详情 &gt;</Text> <Text style={styles.detailLink}>查看详情 &gt;</Text>
</Pressable>
</View> </View>
{/* 福利标签 */} {/* 福利标签 */}
@ -134,7 +133,7 @@ function JobCard({ job }: { job: Job }) {
</View> </View>
))} ))}
</View> </View>
</View> </Pressable>
); );
} }
@ -529,6 +528,9 @@ const styles = StyleSheet.create({
shadowRadius: 8, shadowRadius: 8,
elevation: 3, elevation: 3,
}, },
cardPressed: {
opacity: 0.85,
},
cardHeader: { cardHeader: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

346
app/(tabs)/profile.tsx

@ -1,4 +1,5 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { import {
ActivityIndicator, ActivityIndicator,
@ -15,13 +16,13 @@ import type { Badge, DimensionScore, JobPreference } from '../../src/types';
/* ===================== 展示层映射(全部使用主题令牌) ===================== */ /* ===================== 展示层映射(全部使用主题令牌) ===================== */
/** 维度等级 → 展示配置(标签文案 + 配色) */ /** 维度等级 → 展示配置(优秀=品牌蓝,良好=灰色) */
const DIMENSION_LEVEL: Record< const DIMENSION_LEVEL: Record<
DimensionScore['level'], DimensionScore['level'],
{ label: string; bg: string; text: string } { label: string; bar: string; bg: string; text: string }
> = { > = {
excellent: { label: '优秀', bg: colors.successLight, text: colors.successText }, excellent: { label: '优秀', bar: colors.primary, bg: colors.primaryLight, text: colors.primaryDark },
good: { label: '良好', bg: colors.primaryLight, text: colors.primaryText }, good: { label: '良好', bar: colors.textHint, bg: colors.pageBg, text: colors.textSecondary },
}; };
/** 求职偏好字段 → 展示标签 */ /** 求职偏好字段 → 展示标签 */
@ -35,29 +36,53 @@ const PREF_ITEMS: { key: keyof JobPreference; label: string }[] = [
/* ===================== 子组件 ===================== */ /* ===================== 子组件 ===================== */
/** 单个维度评分项 */ /** 单个维度评分行(横向评分条) */
function DimensionItem({ dimension }: { dimension: DimensionScore }) { function DimensionRow({ dimension }: { dimension: DimensionScore }) {
const theme = DIMENSION_LEVEL[dimension.level]; const theme = DIMENSION_LEVEL[dimension.level];
return ( return (
<View style={styles.dimItem}> <View style={styles.dimRow}>
<Text style={styles.dimName}>{dimension.name}</Text> <Text style={styles.dimName}>{dimension.name}</Text>
<View style={styles.dimScoreRow}> <View style={styles.dimTrack}>
<View style={[styles.dimFill, { width: `${dimension.score}%`, backgroundColor: theme.bar }]} />
</View>
<Text style={styles.dimScore}>{dimension.score}</Text> <Text style={styles.dimScore}>{dimension.score}</Text>
<View style={[styles.levelTag, { backgroundColor: theme.bg }]}> <View style={[styles.levelTag, { backgroundColor: theme.bg }]}>
<Text style={[styles.levelTagText, { color: theme.text }]}>{theme.label}</Text> <Text style={[styles.levelTagText, { color: theme.text }]}>{theme.label}</Text>
</View> </View>
</View> </View>
</View>
); );
} }
/** 单个勋章卡片 */ /** 单个勋章卡片(已获得=彩色,未获得=灰色+进度条) */
function BadgeCard({ badge }: { badge: Badge }) { function BadgeCard({ badge }: { badge: Badge }) {
return ( return (
<View style={styles.badgeCard}> <View style={styles.badgeCard}>
<Text style={styles.badgeIcon}>{badge.icon}</Text> <Text style={[styles.badgeIcon, !badge.obtained && styles.badgeIconLocked]}>
<Text style={styles.badgeTitle}>{badge.title}</Text> {badge.icon}
</Text>
<Text style={[styles.badgeTitle, !badge.obtained && styles.badgeTitleLocked]}>
{badge.title}
</Text>
<Text style={styles.badgeDesc}>{badge.description}</Text> <Text style={styles.badgeDesc}>{badge.description}</Text>
{badge.obtained ? (
<View style={styles.badgeObtainedTag}>
<Text style={styles.badgeObtainedText}>已获得</Text>
</View>
) : (
<>
<View style={styles.badgeProgressTrack}>
<View
style={[
styles.badgeProgressFill,
{ width: `${((badge.progress ?? 0) / (badge.target ?? 1)) * 100}%` },
]}
/>
</View>
<Text style={styles.badgeProgressText}>
{badge.progress}/{badge.target}
</Text>
</>
)}
</View> </View>
); );
} }
@ -99,29 +124,36 @@ export default function ProfileScreen() {
<SafeAreaView style={styles.safeArea} edges={['top']}> <SafeAreaView style={styles.safeArea} edges={['top']}>
<ScrollView <ScrollView
style={styles.scroll} style={styles.scroll}
contentContainerStyle={styles.content} contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{/* 1. 顶部导航 */} {/* 顶部渐变头部:标题 + 用户信息(随页面滚动) */}
<View style={styles.header}> <LinearGradient
colors={[colors.primary, colors.primaryBright]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.headerGradient}
>
<View style={styles.headerRow}>
<Text style={styles.headerTitle}>我的</Text> <Text style={styles.headerTitle}>我的</Text>
<Pressable style={styles.headerAction} onPress={() => router.push('/settings')}> <Pressable onPress={() => router.push('/settings')} hitSlop={8}>
<Ionicons name="settings-outline" size={22} color={colors.textPrimary} /> <Ionicons name="settings-outline" size={22} color={colors.white} />
</Pressable> </Pressable>
</View> </View>
<View style={styles.profileRow}>
{/* 2. 用户信息 */}
<View style={styles.profileCard}>
<View style={styles.profileTop}>
<View style={styles.avatar}> <View style={styles.avatar}>
<Text style={styles.avatarText}>{profile.avatar}</Text> <Text style={styles.avatarText}>{profile.avatar}</Text>
</View> </View>
<View style={styles.profileInfo}>
<Text style={styles.userName}>{profile.name}</Text> <Text style={styles.userName}>{profile.name}</Text>
</View>
<Text style={styles.bio}>{profile.bio}</Text> <Text style={styles.bio}>{profile.bio}</Text>
</View> </View>
</View>
</LinearGradient>
{/* 3. 综合评分卡片 */} {/* 内容区 */}
<View style={styles.content}>
{/* 综合评分卡片 */}
<View style={styles.scoreCard}> <View style={styles.scoreCard}>
<Text style={styles.scoreValue}> <Text style={styles.scoreValue}>
{overallScore.score} {overallScore.score}
@ -130,49 +162,60 @@ export default function ProfileScreen() {
<View style={styles.scoreTag}> <View style={styles.scoreTag}>
<Text style={styles.scoreTagText}>{overallScore.level}</Text> <Text style={styles.scoreTagText}>{overallScore.level}</Text>
</View> </View>
<View style={styles.scoreRight}>
<Text style={styles.scoreRank}>{overallScore.rank}</Text> <Text style={styles.scoreRank}>{overallScore.rank}</Text>
<Pressable onPress={() => console.log('评分申诉')}>
<Text style={styles.scoreAppeal}>评分申诉</Text>
</Pressable>
</View>
</View> </View>
{/* 4. 八大维度评分 */} {/* 八大维度评分 */}
<Text style={[styles.sectionTitle, styles.sectionGap]}>八大维度评分</Text> <View style={styles.sectionHeader}>
<View style={styles.dimGrid}> <View style={styles.sectionBar} />
<Text style={[styles.sectionTitle, styles.sectionTitleGrow]}>八大维度评分</Text>
<Pressable onPress={() => console.log('评分申诉')} hitSlop={6}>
<Text style={styles.sectionLink}>评分申诉</Text>
</Pressable>
</View>
<View style={styles.dimCard}>
{dimensions.map((dimension) => ( {dimensions.map((dimension) => (
<DimensionItem key={dimension.id} dimension={dimension} /> <DimensionRow key={dimension.id} dimension={dimension} />
))} ))}
</View> </View>
{/* 5. 求职偏好 */} {/* 求职偏好 */}
<Text style={[styles.sectionTitle, styles.sectionGap]}>求职偏好</Text> <View style={styles.sectionHeader}>
<View style={styles.sectionBar} />
<Text style={[styles.sectionTitle, styles.sectionTitleGrow]}>求职偏好</Text>
<Pressable onPress={() => console.log('编辑求职偏好')} hitSlop={6}>
<Text style={styles.sectionLink}>编辑</Text>
</Pressable>
</View>
<View style={styles.prefCard}> <View style={styles.prefCard}>
{PREF_ITEMS.map(({ key, label }, index) => { {PREF_ITEMS.map(({ key, label }, index) => {
const value = preference[key]; const value = preference[key];
const isLast = index === PREF_ITEMS.length - 1; const isLast = index === PREF_ITEMS.length - 1;
const isStatus = key === 'status';
return ( return (
<View key={key} style={[styles.prefRow, isLast && styles.prefRowLast]}> <Pressable
key={key}
style={[styles.prefRow, isLast && styles.prefRowLast]}
onPress={() => console.log(`编辑${label}`)}
>
<Text style={styles.prefLabel}>{label}</Text> <Text style={styles.prefLabel}>{label}</Text>
{key === 'status' ? ( {isStatus ? (
<View style={styles.prefStatusTag}> <View style={styles.prefStatus}>
<View style={styles.prefStatusDot} />
<Text style={styles.prefStatusText}>{value}</Text> <Text style={styles.prefStatusText}>{value}</Text>
</View> </View>
) : ( ) : (
<Text style={styles.prefValue}>{value}</Text> <Text style={styles.prefValue}>{value}</Text>
)} )}
</View> </Pressable>
); );
})} })}
</View> </View>
{/* 6. 全部勋章 */} {/* 全部勋章 */}
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>全部勋章</Text> <View style={styles.sectionBar} />
<Pressable onPress={() => console.log('查看全部勋章')}> <Text style={[styles.sectionTitle, styles.sectionTitleGrow]}>全部勋章</Text>
<Text style={styles.sectionMore}>&gt;</Text>
</Pressable>
</View> </View>
<ScrollView <ScrollView
horizontal horizontal
@ -184,6 +227,7 @@ export default function ProfileScreen() {
<BadgeCard key={badge.id} badge={badge} /> <BadgeCard key={badge.id} badge={badge} />
))} ))}
</ScrollView> </ScrollView>
</View>
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
); );
@ -225,92 +269,91 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
color: colors.white, color: colors.white,
}, },
scroll: { /* ---- 顶部渐变头部 ---- */
flex: 1, headerGradient: {
},
content: {
paddingHorizontal: 20, paddingHorizontal: 20,
paddingTop: 8,
paddingBottom: 24, paddingBottom: 24,
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
}, },
/* ---- 顶部导航 ---- */ headerRow: {
header: { height: 40,
height: 48,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'space-between',
}, },
headerTitle: { headerTitle: {
fontSize: 17, fontSize: 17,
fontWeight: '700', fontWeight: '700',
color: colors.textPrimary, color: colors.white,
},
headerAction: {
position: 'absolute',
right: 0,
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
/* ---- 用户信息 ---- */
profileCard: {
marginTop: 8,
backgroundColor: colors.white,
borderRadius: 16,
padding: 20,
...shadow,
}, },
profileTop: { profileRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginTop: 8,
}, },
avatar: { avatar: {
width: 56, width: 60,
height: 56, height: 60,
borderRadius: 28, borderRadius: 30,
backgroundColor: colors.primary, backgroundColor: colors.white,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
avatarText: { avatarText: {
fontSize: 24, fontSize: 26,
fontWeight: '700', fontWeight: '700',
color: colors.white, color: colors.primary,
}, },
userName: { profileInfo: {
marginLeft: 14, marginLeft: 14,
},
userName: {
fontSize: 20, fontSize: 20,
fontWeight: '700', fontWeight: '700',
color: colors.textPrimary, color: colors.white,
}, },
bio: { bio: {
marginTop: 14, marginTop: 6,
fontSize: 13, fontSize: 13,
color: colors.textSecondary, color: colors.white,
opacity: 0.85,
},
/* ---- 内容区 ---- */
scroll: {
flex: 1,
},
scrollContent: {
paddingBottom: 24,
},
content: {
paddingHorizontal: 20,
paddingTop: 16,
}, },
/* ---- 综合评分卡片 ---- */ /* ---- 综合评分卡片 ---- */
scoreCard: { scoreCard: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginTop: 12,
backgroundColor: colors.white, backgroundColor: colors.white,
borderRadius: 16, borderRadius: 16,
padding: 20, paddingHorizontal: 20,
paddingVertical: 20,
...shadow, ...shadow,
}, },
scoreValue: { scoreValue: {
fontSize: 40, fontSize: 40,
fontWeight: '700', fontWeight: '700',
color: colors.primary, color: colors.primaryDark,
}, },
scoreUnit: { scoreUnit: {
fontSize: 16, fontSize: 16,
fontWeight: '600', fontWeight: '600',
color: colors.primary, color: colors.primaryDark,
}, },
scoreTag: { scoreTag: {
marginLeft: 12, marginLeft: 12,
backgroundColor: colors.successLight, backgroundColor: colors.primaryLight,
paddingHorizontal: 10, paddingHorizontal: 10,
paddingVertical: 4, paddingVertical: 4,
borderRadius: 12, borderRadius: 12,
@ -318,80 +361,85 @@ const styles = StyleSheet.create({
scoreTagText: { scoreTagText: {
fontSize: 13, fontSize: 13,
fontWeight: '600', fontWeight: '600',
color: colors.successText, color: colors.primaryDark,
}, },
scoreRight: { scoreRank: {
flex: 1, flex: 1,
alignItems: 'flex-end', textAlign: 'right',
marginLeft: 12, marginLeft: 12,
},
scoreRank: {
fontSize: 13, fontSize: 13,
color: colors.textSecondary, color: colors.textSecondary,
}, },
scoreAppeal: {
marginTop: 8,
fontSize: 13,
color: colors.primary,
},
/* ---- 分区标题 ---- */ /* ---- 分区标题 ---- */
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: colors.textPrimary,
},
sectionGap: {
marginTop: 24,
marginBottom: 12,
},
sectionHeader: { sectionHeader: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between',
marginTop: 24, marginTop: 24,
marginBottom: 12, marginBottom: 12,
}, },
sectionMore: { sectionBar: {
width: 4,
height: 16,
borderRadius: 2,
backgroundColor: colors.primary,
marginRight: 8,
},
sectionTitle: {
fontSize: 18, fontSize: 18,
fontWeight: '700', fontWeight: '700',
color: colors.textSecondary, color: colors.textPrimary,
}, },
/* ---- 八大维度 ---- */ sectionTitleGrow: {
dimGrid: { flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
}, },
dimItem: { sectionLink: {
width: '48.5%', fontSize: 13,
color: colors.primary,
},
/* ---- 八大维度(横向评分条) ---- */
dimCard: {
backgroundColor: colors.white, backgroundColor: colors.white,
borderRadius: 16, borderRadius: 16,
padding: 16, paddingHorizontal: 16,
marginBottom: 12, paddingVertical: 6,
...shadow, ...shadow,
}, },
dimRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
},
dimName: { dimName: {
width: 78,
fontSize: 13, fontSize: 13,
color: colors.textSecondary, color: colors.textSecondary,
}, },
dimScoreRow: { dimTrack: {
flexDirection: 'row', flex: 1,
alignItems: 'center', height: 6,
marginTop: 8, borderRadius: 3,
backgroundColor: colors.border,
marginHorizontal: 12,
},
dimFill: {
height: 6,
borderRadius: 3,
}, },
dimScore: { dimScore: {
fontSize: 24, width: 28,
textAlign: 'right',
fontSize: 14,
fontWeight: '700', fontWeight: '700',
color: colors.textPrimary, color: colors.textPrimary,
}, },
levelTag: { levelTag: {
marginLeft: 8, marginLeft: 10,
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 3, paddingVertical: 2,
borderRadius: 10, borderRadius: 10,
}, },
levelTagText: { levelTagText: {
fontSize: 12, fontSize: 11,
fontWeight: '600', fontWeight: '600',
}, },
/* ---- 求职偏好 ---- */ /* ---- 求职偏好 ---- */
@ -422,14 +470,19 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
color: colors.textPrimary, color: colors.textPrimary,
}, },
prefStatusTag: { prefStatus: {
backgroundColor: colors.successLight, flexDirection: 'row',
paddingHorizontal: 10, alignItems: 'center',
paddingVertical: 4, },
borderRadius: 12, prefStatusDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.success,
marginRight: 6,
}, },
prefStatusText: { prefStatusText: {
fontSize: 13, fontSize: 14,
fontWeight: '600', fontWeight: '600',
color: colors.successText, color: colors.successText,
}, },
@ -453,16 +506,51 @@ const styles = StyleSheet.create({
badgeIcon: { badgeIcon: {
fontSize: 32, fontSize: 32,
}, },
badgeIconLocked: {
opacity: 0.35,
},
badgeTitle: { badgeTitle: {
marginTop: 10, marginTop: 10,
fontSize: 14, fontSize: 14,
fontWeight: '700', fontWeight: '700',
color: colors.textPrimary, color: colors.textPrimary,
}, },
badgeTitleLocked: {
color: colors.textHint,
},
badgeDesc: { badgeDesc: {
marginTop: 4, marginTop: 4,
fontSize: 12, fontSize: 12,
color: colors.textSecondary, color: colors.textSecondary,
textAlign: 'center', textAlign: 'center',
}, },
badgeObtainedTag: {
marginTop: 10,
backgroundColor: colors.successLight,
paddingHorizontal: 10,
paddingVertical: 3,
borderRadius: 10,
},
badgeObtainedText: {
fontSize: 11,
fontWeight: '600',
color: colors.successText,
},
badgeProgressTrack: {
width: '100%',
height: 4,
borderRadius: 2,
backgroundColor: colors.border,
marginTop: 12,
},
badgeProgressFill: {
height: 4,
borderRadius: 2,
backgroundColor: colors.primary,
},
badgeProgressText: {
marginTop: 6,
fontSize: 11,
color: colors.textHint,
},
}); });

1
app/_layout.tsx

@ -10,6 +10,7 @@ export default function RootLayout() {
<StatusBar style="dark" /> <StatusBar style="dark" />
<Stack> <Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="jobs/[id]" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ title: '设置' }} /> <Stack.Screen name="settings" options={{ title: '设置' }} />
<Stack.Screen name="resume" options={{ title: '我的简历' }} /> <Stack.Screen name="resume" options={{ title: '我的简历' }} />
<Stack.Screen name="favorites" options={{ title: '我的收藏' }} /> <Stack.Screen name="favorites" options={{ title: '我的收藏' }} />

611
app/jobs/[id].tsx

@ -0,0 +1,611 @@
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Circle, Line, Polygon, Text as SvgText } from 'react-native-svg';
import { useJobDetail } from '../../src/hooks';
import { colors } from '../../src/theme';
import { formatThousands } from '../../src/utils';
import type { JobDimensionScore } from '../../src/types';
/* ===================== 雷达图(6 维度,SVG 绘制) ===================== */
/** 六边形雷达图:同心网格 + 轴线 + 数据多边形 + 维度标签 */
function RadarChart({
dimensions,
size = 160,
}: {
dimensions: JobDimensionScore[];
size?: number;
}) {
const cx = size / 2;
const cy = size / 2;
const count = dimensions.length;
// 数据/网格最大半径(预留标签空间)
const R = size / 2 - 36;
const labelR = R + 16;
const levels = [0.25, 0.5, 0.75, 1];
const angleFor = (i: number) => (-90 + i * (360 / count)) * (Math.PI / 180);
const pointFor = (i: number, ratio: number) => ({
x: cx + R * ratio * Math.cos(angleFor(i)),
y: cy + R * ratio * Math.sin(angleFor(i)),
});
const polygonPoints = (ratio: number) =>
dimensions
.map((_, i) => {
const p = pointFor(i, ratio);
return `${p.x},${p.y}`;
})
.join(' ');
const dataPoints = dimensions.map((d, i) => pointFor(i, d.value / 100));
return (
<Svg width={size} height={size}>
{/* 同心网格 */}
{levels.map((ratio) => (
<Polygon
key={ratio}
points={polygonPoints(ratio)}
fill="none"
stroke={colors.border}
strokeWidth={1}
/>
))}
{/* 轴线 */}
{dimensions.map((_, i) => {
const p = pointFor(i, 1);
return (
<Line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y} stroke={colors.border} strokeWidth={1} />
);
})}
{/* 数据多边形 */}
<Polygon
points={dataPoints.map((p) => `${p.x},${p.y}`).join(' ')}
fill={colors.primary}
fillOpacity={0.15}
stroke={colors.primary}
strokeWidth={2}
/>
{/* 数据点 */}
{dataPoints.map((p, i) => (
<Circle key={i} cx={p.x} cy={p.y} r={3} fill={colors.primary} />
))}
{/* 维度标签 */}
{dimensions.map((d, i) => {
const lx = cx + labelR * Math.cos(angleFor(i));
const ly = cy + labelR * Math.sin(angleFor(i));
return (
<SvgText
key={d.id}
x={lx}
y={ly}
fontSize={10}
fill={colors.textSecondary}
textAnchor="middle"
alignmentBaseline="middle"
>
{d.name}
</SvgText>
);
})}
</Svg>
);
}
/* ===================== 子组件 ===================== */
/** 顶部导航:返回 + 标题 + 分享 */
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>
<Pressable style={styles.headerBtn} onPress={() => console.log('分享')} hitSlop={8}>
<Ionicons name="share-social-outline" size={22} color={colors.textPrimary} />
</Pressable>
</View>
);
}
/** 分区标题:竖条装饰 + 标题 + 可选右侧链接 */
function SectionTitle({
title,
link,
onLink,
}: {
title: string;
link?: string;
onLink?: () => void;
}) {
return (
<View style={styles.sectionHeader}>
<View style={styles.sectionBar} />
<Text style={styles.sectionTitle}>{title}</Text>
{link ? (
<Pressable style={styles.sectionLinkBtn} onPress={onLink} hitSlop={8}>
<Text style={styles.sectionLink}>{link} &gt;</Text>
</Pressable>
) : null}
</View>
);
}
/* ===================== 页面 ===================== */
export default function JobDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { detail, overallScore, salaryData, reviews, loading, error, refresh } = useJobDetail(id);
const [comment, setComment] = useState('');
// 错误态
if (error) {
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} />
<View style={styles.stateWrap}>
<Text style={styles.stateText}>加载失败,请重试</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
// 加载态
if (loading || !detail || !overallScore || !salaryData) {
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} />
<View style={styles.stateWrap}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} />
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{/* 1. 岗位基本信息 */}
<View style={styles.card}>
<View style={styles.basicTop}>
<Text style={styles.positionName}>{detail.positionName}</Text>
<View style={styles.statusCol}>
<View style={styles.statusTag}>
<Text style={styles.statusText}>{detail.status}</Text>
</View>
<Text style={styles.viewsText}>浏览{formatThousands(detail.views)}</Text>
</View>
</View>
<Text style={styles.salary}>
{detail.salaryMin}-{detail.salaryMax}K · {detail.salaryMonths}薪
</Text>
<Text style={styles.metaText}>
{detail.companyName} · {detail.industry} · {detail.companySize}
</Text>
<Text style={styles.metaText}>
{detail.companyLocation} · {detail.experience} · {detail.education}
</Text>
<View style={styles.benefits}>
{detail.benefits.map((item) => (
<View key={item} style={styles.benefitTag}>
<Text style={styles.benefitText}>{item}</Text>
</View>
))}
</View>
</View>
{/* 2. 岗位综合评分 */}
<SectionTitle title="岗位综合评分" link="评分说明" onLink={() => console.log('评分说明')} />
<View style={styles.card}>
<View style={styles.scoreRow}>
<RadarChart dimensions={overallScore.dimensions} />
<View style={styles.scoreInfo}>
<View style={styles.scoreNumberRow}>
<Text style={styles.scoreNumber}>{overallScore.totalScore}</Text>
<Text style={styles.scoreUnit}>分</Text>
</View>
<View style={styles.levelTag}>
<Text style={styles.levelTagText}>{overallScore.level}</Text>
</View>
<Text style={styles.scoreRank}>{overallScore.rank}</Text>
</View>
</View>
<Pressable style={styles.compareBtn} onPress={() => console.log('加入对比')} hitSlop={6}>
<Ionicons name="add-circle-outline" size={16} color={colors.primary} />
<Text style={styles.compareText}>加入对比</Text>
</Pressable>
</View>
{/* 3. 真实薪资区间 */}
<SectionTitle title="真实薪资区间" link="全部" onLink={() => console.log('全部')} />
<View style={styles.card}>
{salaryData.ranges.map((range, index) => (
<View
key={range.experience}
style={[styles.salaryRow, index === salaryData.ranges.length - 1 && styles.salaryRowLast]}
>
<Text style={styles.salaryExp}>{range.experience}</Text>
<Text style={styles.salaryRange}>
{range.min}-{range.max}K
</Text>
</View>
))}
<Text style={styles.salarySource}>{salaryData.dataSource}</Text>
</View>
{/* 4. 员工评价 */}
<SectionTitle title="员工评价" />
{reviews.map((review) => (
<View key={review.id} style={styles.card}>
<View style={styles.reviewHeader}>
<Text style={styles.reviewName}>{review.userName}</Text>
<Text style={styles.reviewRole}>{review.userRole}</Text>
<Text style={styles.reviewTime}>{review.time}</Text>
</View>
<Text style={styles.reviewContent}>{review.content}</Text>
<View style={styles.reviewTags}>
{review.tags.map((tag) => (
<View key={tag} style={styles.reviewTag}>
<Text style={styles.reviewTagText}>{tag}</Text>
</View>
))}
</View>
</View>
))}
</ScrollView>
{/* 5. 评论输入框(固定底部) */}
<View style={styles.commentBar}>
<TextInput
style={styles.commentInput}
placeholder="说说你对这个岗位的看法..."
placeholderTextColor={colors.textHint}
value={comment}
onChangeText={setComment}
/>
<Pressable style={styles.publishBtn} onPress={() => console.log('发布', comment)}>
<Text style={styles.publishText}>发布</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
// 跨平台阴影
const shadow = {
shadowColor: colors.textPrimary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 3,
};
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
stateWrap: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
stateText: {
marginTop: 12,
fontSize: 14,
color: colors.textSecondary,
},
retryBtn: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
/* ---- 顶部导航 ---- */
header: {
height: 48,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
headerBtn: {
position: 'absolute',
top: 0,
width: 48,
height: 48,
alignItems: 'center',
justifyContent: 'center',
},
scroll: {
flex: 1,
},
content: {
paddingHorizontal: 20,
paddingBottom: 24,
},
card: {
backgroundColor: colors.white,
borderRadius: 16,
padding: 16,
marginBottom: 16,
...shadow,
},
/* ---- 基本信息 ---- */
basicTop: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
},
positionName: {
flex: 1,
fontSize: 22,
fontWeight: '700',
color: colors.textPrimary,
},
statusCol: {
alignItems: 'flex-end',
marginLeft: 12,
},
statusTag: {
backgroundColor: colors.successLight,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
},
statusText: {
fontSize: 12,
fontWeight: '600',
color: colors.successText,
},
viewsText: {
marginTop: 6,
fontSize: 12,
color: colors.textHint,
},
salary: {
marginTop: 14,
fontSize: 20,
fontWeight: '700',
color: colors.primary,
},
metaText: {
marginTop: 8,
fontSize: 13,
color: colors.textSecondary,
},
benefits: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 12,
},
benefitTag: {
backgroundColor: colors.primaryLight,
borderWidth: 1,
borderColor: colors.primaryBorder,
borderRadius: 12,
paddingHorizontal: 10,
paddingVertical: 4,
marginRight: 8,
marginBottom: 8,
},
benefitText: {
fontSize: 12,
color: colors.primary,
},
/* ---- 分区标题 ---- */
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
sectionBar: {
width: 4,
height: 16,
borderRadius: 2,
backgroundColor: colors.primary,
marginRight: 8,
},
sectionTitle: {
flex: 1,
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
sectionLinkBtn: {
paddingLeft: 8,
},
sectionLink: {
fontSize: 13,
color: colors.primary,
},
/* ---- 综合评分 ---- */
scoreRow: {
flexDirection: 'row',
alignItems: 'center',
},
scoreInfo: {
flex: 1,
alignItems: 'flex-start',
marginLeft: 8,
},
scoreNumberRow: {
flexDirection: 'row',
alignItems: 'baseline',
},
scoreNumber: {
fontSize: 40,
fontWeight: '700',
color: colors.primary,
},
scoreUnit: {
fontSize: 14,
fontWeight: '600',
color: colors.primary,
},
levelTag: {
marginTop: 4,
backgroundColor: colors.successLight,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
},
levelTagText: {
fontSize: 13,
fontWeight: '600',
color: colors.successText,
},
scoreRank: {
marginTop: 8,
fontSize: 12,
color: colors.textSecondary,
},
compareBtn: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',
marginTop: 16,
},
compareText: {
marginLeft: 4,
fontSize: 14,
fontWeight: '600',
color: colors.primary,
},
/* ---- 薪资区间 ---- */
salaryRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
salaryRowLast: {
borderBottomWidth: 0,
},
salaryExp: {
fontSize: 14,
color: colors.textSecondary,
},
salaryRange: {
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
},
salarySource: {
marginTop: 12,
fontSize: 12,
color: colors.textHint,
},
/* ---- 员工评价 ---- */
reviewHeader: {
flexDirection: 'row',
alignItems: 'center',
},
reviewName: {
fontSize: 14,
fontWeight: '700',
color: colors.textPrimary,
},
reviewRole: {
marginLeft: 8,
fontSize: 12,
color: colors.textSecondary,
},
reviewTime: {
flex: 1,
textAlign: 'right',
fontSize: 12,
color: colors.textHint,
},
reviewContent: {
marginTop: 10,
fontSize: 13,
lineHeight: 20,
color: colors.textPrimary,
},
reviewTags: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 10,
},
reviewTag: {
backgroundColor: colors.pageBg,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
paddingHorizontal: 10,
paddingVertical: 4,
marginRight: 8,
marginBottom: 8,
},
reviewTagText: {
fontSize: 12,
color: colors.textSecondary,
},
/* ---- 评论输入框 ---- */
commentBar: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: colors.white,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
commentInput: {
flex: 1,
height: 40,
borderRadius: 20,
backgroundColor: colors.pageBg,
paddingHorizontal: 16,
fontSize: 14,
color: colors.textPrimary,
},
publishBtn: {
marginLeft: 12,
paddingHorizontal: 18,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.primaryBtn,
},
publishText: {
fontSize: 14,
fontWeight: '600',
color: colors.white,
},
});

385
package-lock.json

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"expo": "~57.0.14", "expo": "~57.0.14",
"expo-blur": "~57.0.2",
"expo-constants": "~57.0.12", "expo-constants": "~57.0.12",
"expo-font": "~57.0.1", "expo-font": "~57.0.1",
"expo-linear-gradient": "~57.0.1", "expo-linear-gradient": "~57.0.1",
@ -552,6 +553,22 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-transform-arrow-functions": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz",
"integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-async-generator-functions": { "node_modules/@babel/plugin-transform-async-generator-functions": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz",
@ -925,6 +942,38 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-transform-react-jsx-self": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
"integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-jsx-source": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
"integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-pure-annotations": { "node_modules/@babel/plugin-transform-react-pure-annotations": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.29.7.tgz", "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.29.7.tgz",
@ -941,6 +990,22 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-transform-regenerator": {
"version": "7.29.8",
"resolved": "https://registry.npmmirror.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz",
"integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-runtime": { "node_modules/@babel/plugin-transform-runtime": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz",
@ -970,6 +1035,38 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/@babel/plugin-transform-shorthand-properties": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz",
"integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-template-literals": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz",
"integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-typescript": { "node_modules/@babel/plugin-transform-typescript": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz",
@ -1985,6 +2082,54 @@
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
} }
}, },
"node_modules/@react-native/babel-preset": {
"version": "0.86.2",
"resolved": "https://registry.npmmirror.com/@react-native/babel-preset/-/babel-preset-0.86.2.tgz",
"integrity": "sha512-4XKEJ6jKW9lXMB1O5o47gBoGgolde1fbX13gLW/erlcn+1ky+MHHo5UjuM3RWGdPJHIvIzekDDumSUHhB9x5iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@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-classes": "^7.25.4",
"@babel/plugin-transform-destructuring": "^7.24.8",
"@babel/plugin-transform-flow-strip-types": "^7.25.2",
"@babel/plugin-transform-for-of": "^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-optional-catch-binding": "^7.24.7",
"@babel/plugin-transform-optional-chaining": "^7.24.8",
"@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.25.2",
"@babel/plugin-transform-react-jsx-self": "^7.24.7",
"@babel/plugin-transform-react-jsx-source": "^7.24.7",
"@babel/plugin-transform-regenerator": "^7.24.7",
"@babel/plugin-transform-runtime": "^7.24.7",
"@babel/plugin-transform-typescript": "^7.25.2",
"@babel/plugin-transform-unicode-regex": "^7.24.7",
"@react-native/babel-plugin-codegen": "0.86.2",
"babel-plugin-syntax-hermes-parser": "0.36.0",
"babel-plugin-transform-flow-enums": "^0.0.2",
"react-refresh": "^0.14.0"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
},
"peerDependencies": {
"@babel/core": "*"
}
},
"node_modules/@react-native/codegen": { "node_modules/@react-native/codegen": {
"version": "0.86.2", "version": "0.86.2",
"resolved": "https://registry.npmmirror.com/@react-native/codegen/-/codegen-0.86.2.tgz", "resolved": "https://registry.npmmirror.com/@react-native/codegen/-/codegen-0.86.2.tgz",
@ -2115,6 +2260,58 @@
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
} }
}, },
"node_modules/@react-native/metro-babel-transformer": {
"version": "0.86.2",
"resolved": "https://registry.npmmirror.com/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.86.2.tgz",
"integrity": "sha512-mX1wgLErdb2hDgXJr9zM9SWLe+ZteZTFTwRWGOQ53yEJwc9DVSnxdTlAtVdMeOj2ntycKh0R8jYdat2Am43cwQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@react-native/babel-preset": "0.86.2",
"hermes-parser": "0.36.0",
"nullthrows": "^1.1.1"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
},
"peerDependencies": {
"@babel/core": "*"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/hermes-estree": {
"version": "0.36.0",
"resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.36.0.tgz",
"integrity": "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==",
"license": "MIT",
"peer": true
},
"node_modules/@react-native/metro-babel-transformer/node_modules/hermes-parser": {
"version": "0.36.0",
"resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.36.0.tgz",
"integrity": "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==",
"license": "MIT",
"peer": true,
"dependencies": {
"hermes-estree": "0.36.0"
}
},
"node_modules/@react-native/metro-config": {
"version": "0.86.2",
"resolved": "https://registry.npmmirror.com/@react-native/metro-config/-/metro-config-0.86.2.tgz",
"integrity": "sha512-hJno256j+MS0b3JD1aD3ouTGZVacKNVBuXL2atMQQ8BZ060vl1ptnZ83y569aDW+/rgFSOcqn6ydKeSz4uUKQQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-native/js-polyfills": "0.86.2",
"@react-native/metro-babel-transformer": "0.86.2",
"metro-config": "^0.84.3",
"metro-runtime": "^0.84.3"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/normalize-colors": { "node_modules/@react-native/normalize-colors": {
"version": "0.86.2", "version": "0.86.2",
"resolved": "https://registry.npmmirror.com/@react-native/normalize-colors/-/normalize-colors-0.86.2.tgz", "resolved": "https://registry.npmmirror.com/@react-native/normalize-colors/-/normalize-colors-0.86.2.tgz",
@ -2150,6 +2347,61 @@
"integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/dom/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@testing-library/dom/node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/@testing-library/dom/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"license": "MIT",
"peer": true
},
"node_modules/@testing-library/jest-dom": { "node_modules/@testing-library/jest-dom": {
"version": "6.10.0", "version": "6.10.0",
"resolved": "https://registry.npmmirror.com/@testing-library/jest-dom/-/jest-dom-6.10.0.tgz", "resolved": "https://registry.npmmirror.com/@testing-library/jest-dom/-/jest-dom-6.10.0.tgz",
@ -2191,6 +2443,13 @@
"@testing-library/dom": ">=7.21.4" "@testing-library/dom": ">=7.21.4"
} }
}, },
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"license": "MIT",
"peer": true
},
"node_modules/@types/istanbul-lib-coverage": { "node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmmirror.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "resolved": "https://registry.npmmirror.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@ -2228,12 +2487,21 @@
"version": "19.2.18", "version": "19.2.18",
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
}, },
"node_modules/@types/react-test-renderer": {
"version": "19.1.0",
"resolved": "https://registry.npmmirror.com/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
"integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/yargs": { "node_modules/@types/yargs": {
"version": "17.0.35", "version": "17.0.35",
"resolved": "https://registry.npmmirror.com/@types/yargs/-/yargs-17.0.35.tgz", "resolved": "https://registry.npmmirror.com/@types/yargs/-/yargs-17.0.35.tgz",
@ -3040,7 +3308,6 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
@ -3139,6 +3406,13 @@
"integrity": "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==", "integrity": "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"license": "MIT",
"peer": true
},
"node_modules/dom-serializer": { "node_modules/dom-serializer": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz", "resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz",
@ -3358,6 +3632,17 @@
} }
} }
}, },
"node_modules/expo-blur": {
"version": "57.0.2",
"resolved": "https://registry.npmmirror.com/expo-blur/-/expo-blur-57.0.2.tgz",
"integrity": "sha512-Aoud8H8lmlNkbRufyvRLefmGFELdBf1n5Te/Xm+Zx8ORINH+aXL+gKb5mbftFSha860+I7pMArz77TBYz8HDVg==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-constants": { "node_modules/expo-constants": {
"version": "57.0.12", "version": "57.0.12",
"resolved": "https://registry.npmmirror.com/expo-constants/-/expo-constants-57.0.12.tgz", "resolved": "https://registry.npmmirror.com/expo-constants/-/expo-constants-57.0.12.tgz",
@ -5024,6 +5309,16 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/makeerror": { "node_modules/makeerror": {
"version": "1.0.12", "version": "1.0.12",
"resolved": "https://registry.npmmirror.com/makeerror/-/makeerror-1.0.12.tgz", "resolved": "https://registry.npmmirror.com/makeerror/-/makeerror-1.0.12.tgz",
@ -5955,6 +6250,19 @@
"ws": "^7" "ws": "^7"
} }
}, },
"node_modules/react-dom": {
"version": "19.2.8",
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.8"
}
},
"node_modules/react-fast-compare": { "node_modules/react-fast-compare": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmmirror.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz", "resolved": "https://registry.npmmirror.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
@ -6054,6 +6362,48 @@
"react-native-reanimated": ">= 2.0.0" "react-native-reanimated": ">= 2.0.0"
} }
}, },
"node_modules/react-native-gesture-handler": {
"version": "3.2.1",
"resolved": "https://registry.npmmirror.com/react-native-gesture-handler/-/react-native-gesture-handler-3.2.1.tgz",
"integrity": "sha512-VEWscxUN29aeccbD2McF0LSXrwOhSdisaSRFiEWg0O/3WrVghNJp/tOtLrCgzb9gfcZ3xdA+K2Sp8F5G3iHx/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/react-test-renderer": "^19.1.0",
"invariant": "^2.2.4"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-is-edge-to-edge": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
"integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-reanimated": {
"version": "4.6.0",
"resolved": "https://registry.npmmirror.com/react-native-reanimated/-/react-native-reanimated-4.6.0.tgz",
"integrity": "sha512-9vbQmok5BPX2J3RLPFt2UnwmK2QQoMDPDi0VB4a9JWIiEc55pdzvwEsdH91V/JXKNErqM6a9fElTBMc/1cbtBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"react-native-is-edge-to-edge": "^1.3.1",
"semver": "^7.7.3"
},
"peerDependencies": {
"react": "*",
"react-native": "0.83 - 0.87",
"react-native-worklets": "0.12.x"
}
},
"node_modules/react-native-safe-area-context": { "node_modules/react-native-safe-area-context": {
"version": "5.7.0", "version": "5.7.0",
"resolved": "https://registry.npmmirror.com/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz", "resolved": "https://registry.npmmirror.com/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz",
@ -6093,6 +6443,35 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-worklets": {
"version": "0.12.1",
"resolved": "https://registry.npmmirror.com/react-native-worklets/-/react-native-worklets-0.12.1.tgz",
"integrity": "sha512-/u7B+Akrox32IQUi6+0OHwWAfBC/A5LVdt000RL8OKB4L1pHTvIDwVyMP6Nw7ybuuoQYS8PLlpmxTE1Ce7TqWg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/generator": "^7.27.1",
"@babel/plugin-transform-arrow-functions": "^7.27.1",
"@babel/plugin-transform-class-properties": "^7.28.6",
"@babel/plugin-transform-classes": "^7.28.6",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
"@babel/plugin-transform-optional-chaining": "^7.28.6",
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
"@babel/plugin-transform-template-literals": "^7.27.1",
"@babel/plugin-transform-unicode-regex": "^7.27.1",
"@babel/preset-typescript": "^7.28.5",
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1",
"convert-source-map": "^2.0.0",
"semver": "^7.7.4"
},
"peerDependencies": {
"@babel/core": "*",
"@react-native/metro-config": "*",
"react": "*",
"react-native": "0.83 - 0.87"
}
},
"node_modules/react-native/node_modules/commander": { "node_modules/react-native/node_modules/commander": {
"version": "12.1.0", "version": "12.1.0",
"resolved": "https://registry.npmmirror.com/commander/-/commander-12.1.0.tgz", "resolved": "https://registry.npmmirror.com/commander/-/commander-12.1.0.tgz",
@ -6893,7 +7272,7 @@
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",

1
package.json

@ -5,6 +5,7 @@
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"expo": "~57.0.14", "expo": "~57.0.14",
"expo-blur": "~57.0.2",
"expo-constants": "~57.0.12", "expo-constants": "~57.0.12",
"expo-font": "~57.0.1", "expo-font": "~57.0.1",
"expo-linear-gradient": "~57.0.1", "expo-linear-gradient": "~57.0.1",

151
src/api/api.ts

@ -16,20 +16,24 @@ import type {
Category, Category,
Conversation, Conversation,
DimensionScore, DimensionScore,
EmployeeReview,
EnterpriseEntry, EnterpriseEntry,
HollandAssessment, HollandAssessment,
HomeAITool, HomeAITool,
HotJob, HotJob,
Interview, Interview,
Job, Job,
JobDetail,
JobLevel, JobLevel,
JobListParams, JobListParams,
JobOverallScore,
JobPreference, JobPreference,
MatchData, MatchData,
MatchResult, MatchResult,
MenuItem, MenuItem,
Notification, Notification,
OverallScore, OverallScore,
SalaryData,
UserInfo, UserInfo,
UserProfile, UserProfile,
UserStats, UserStats,
@ -500,12 +504,82 @@ const MOCK_JOB_PREFERENCE: JobPreference = {
status: '积极求职中', status: '积极求职中',
}; };
/** 勋章列表 */ /** 勋章列表(已获得 + 未获得;未获得带获取进度) */
const MOCK_BADGES: Badge[] = [ const MOCK_BADGES: Badge[] = [
{ id: 'badge-1', title: '求职达人', description: '投递满100次', icon: '🏅' }, { id: 'badge-1', title: '求职达人', description: '投递满100次', icon: '🏅', obtained: true },
{ id: 'badge-2', title: '面试高手', description: '面试10次成功', icon: '🏅' }, { id: 'badge-2', title: '面试高手', description: '面试10次成功', icon: '🎯', obtained: true },
{ id: 'badge-3', title: 'offer收割机', description: '获得5个offer', icon: '🏅' }, { id: 'badge-3', title: '诚信之星', description: '诚信记录满分', icon: '⭐', obtained: true },
{ id: 'badge-4', title: '诚信之星', description: '诚信记录满分', icon: '🏅' }, { id: 'badge-4', title: 'offer收割机', description: '获得5个offer', icon: '🏆', obtained: false, progress: 3, target: 5 },
{ id: 'badge-5', title: '投递达人', description: '投递满200次', icon: '📮', obtained: false, progress: 128, target: 200 },
{ id: 'badge-6', title: '学习之星', description: '完成10次测评', icon: '📚', obtained: false, progress: 4, target: 10 },
];
/** 岗位详情附加/覆盖信息(id → 详情页专属字段;覆盖列表页未细化的字段) */
const MOCK_JOB_DETAILS: Record<string, Partial<JobDetail>> = {
'1': {
companyLocation: '北京 · 朝阳区',
industry: '互联网',
benefits: ['五险一金', '年终奖金', '弹性工作', '免费三餐'],
companySize: '10000人以上',
status: '刚发布',
views: 1286,
salaryMonths: 14,
},
};
/** 岗位综合评分(6 维度,供雷达图) */
const MOCK_JOB_OVERALL_SCORE: JobOverallScore = {
totalScore: 92,
level: '优秀',
rank: '超过91%同行业岗位',
dimensions: [
{ id: 'auth', name: '岗位真实性', value: 95 },
{ id: 'salary', name: '薪资竞争力', value: 88 },
{ id: 'growth', name: '发展前景', value: 92 },
{ id: 'company', name: '公司实力', value: 90 },
{ id: 'env', name: '工作环境', value: 85 },
{ id: 'benefit', name: '福利待遇', value: 82 },
],
};
/** 真实薪资区间 */
const MOCK_SALARY_DATA: SalaryData = {
ranges: [
{ experience: '1-3年', min: 15, max: 25 },
{ experience: '3-5年', min: 25, max: 40 },
{ experience: '5-10年', min: 40, max: 65 },
{ experience: '10年以上', min: 65, max: 100 },
],
dataSource: '基于2,846份真实简历数据',
totalCount: 2846,
};
/** 员工评价列表 */
const MOCK_EMPLOYEE_REVIEWS: EmployeeReview[] = [
{
id: 'r-1',
userName: '张**',
userRole: '在职员工,前端开发',
time: '3天前',
content: '团队氛围很好,技术栈比较新,成长空间大,就是加班有点多...',
tags: ['团队氛围好', '技术先进'],
},
{
id: 'r-2',
userName: '李**',
userRole: '离职员工,高级前端',
time: '2周前',
content: '薪资待遇不错,福利也还行,就是晋升通道不够清晰...',
tags: ['薪资高', '福利好'],
},
{
id: 'r-3',
userName: '王**',
userRole: '在职员工,技术主管',
time: '1个月前',
content: '公司文化很好,同事都很优秀,能学到很多东西...',
tags: ['文化好', '成长快'],
},
]; ];
/* ===================== 业务规则 ===================== */ /* ===================== 业务规则 ===================== */
@ -874,3 +948,70 @@ export async function fetchBadges(): Promise<Badge[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/badges`, { method: 'GET' }); return res.json(); // TODO: const res = await fetch(`${API_BASE_URL}/api/user/badges`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_BADGES); return mockRequest(MOCK_BADGES);
} }
/**
* 获取岗位详情(含详情页专属字段;等级由分数在接口层计算)
* @method GET
* @url /api/jobs/:id
* @param id { string } 岗位 id
* @returns Promise<JobDetail> 岗位详情
*/
export async function fetchJobDetail(id: string): Promise<JobDetail> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/jobs/${id}`, { method: 'GET' });
// return res.json();
const base = MOCK_JOBS.find((job) => job.id === id);
if (!base) throw new Error(`岗位不存在: ${id}`);
// 详情 = 列表基础数据 + 详情页专属字段(无对应覆盖时用兜底值)
const detail: JobDetail = {
...base,
level: scoreToLevel(base.score),
companySize: '1000-9999人',
status: '热招中',
views: 0,
salaryMonths: 13,
...MOCK_JOB_DETAILS[id],
};
return mockRequest(detail);
}
/**
* 获取岗位综合评分(含 6 维度雷达图数据)
* @method GET
* @url /api/jobs/:id/score
* @param id { string } 岗位 id
* @returns Promise<JobOverallScore> 综合评分
*/
export async function fetchJobOverallScore(id: string): Promise<JobOverallScore> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/${id}/score`, { method: 'GET' }); return res.json();
void id;
return mockRequest(MOCK_JOB_OVERALL_SCORE);
}
/**
* 获取岗位真实薪资区间
* @method GET
* @url /api/jobs/:id/salary
* @param id { string } 岗位 id
* @returns Promise<SalaryData> 薪资数据
*/
export async function fetchSalaryData(id: string): Promise<SalaryData> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/${id}/salary`, { method: 'GET' }); return res.json();
void id;
return mockRequest(MOCK_SALARY_DATA);
}
/**
* 获取员工评价列表
* @method GET
* @url /api/jobs/:id/reviews
* @param id { string } 岗位 id
* @returns Promise<EmployeeReview[]> 评价数组
*/
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);
}

4
src/api/index.ts

@ -10,18 +10,22 @@ export {
fetchCategories, fetchCategories,
fetchConversations, fetchConversations,
fetchDimensionScores, fetchDimensionScores,
fetchEmployeeReviews,
fetchEnterpriseEntry, fetchEnterpriseEntry,
fetchHollandAssessment, fetchHollandAssessment,
fetchHomeAITools, fetchHomeAITools,
fetchHotJobs, fetchHotJobs,
fetchInterviews, fetchInterviews,
fetchJobDetail,
fetchJobList, fetchJobList,
fetchJobOverallScore,
fetchJobPreference, fetchJobPreference,
fetchMatchData, fetchMatchData,
fetchMatchResult, fetchMatchResult,
fetchMenuItems, fetchMenuItems,
fetchNotifications, fetchNotifications,
fetchOverallScore, fetchOverallScore,
fetchSalaryData,
fetchUserInfo, fetchUserInfo,
fetchUserProfile, fetchUserProfile,
fetchUserStats, fetchUserStats,

1
src/hooks/index.ts

@ -5,6 +5,7 @@ export { useAsyncData } from './useAsyncData';
export { useCategoryData } from './useCategoryData'; export { useCategoryData } from './useCategoryData';
export { useGrowthData } from './useGrowthData'; export { useGrowthData } from './useGrowthData';
export { useHomeData } from './useHomeData'; export { useHomeData } from './useHomeData';
export { useJobDetail } from './useJobDetail';
export { useJobList } from './useJobList'; export { useJobList } from './useJobList';
export { useMatchData } from './useMatchData'; export { useMatchData } from './useMatchData';
export { useMessageData } from './useMessageData'; export { useMessageData } from './useMessageData';

50
src/hooks/useJobDetail.ts

@ -0,0 +1,50 @@
/**
* 岗位详情数据 hook:并行组合岗位详情、综合评分、薪资区间、员工评价四类数据,
* 统一管理 loading / error / refresh 三态。
*/
import { useCallback } from 'react';
import {
fetchEmployeeReviews,
fetchJobDetail,
fetchJobOverallScore,
fetchSalaryData,
} from '../api';
import type {
EmployeeReview,
JobDetail,
JobOverallScore,
SalaryData,
} from '../types';
import { useAsyncData } from './useAsyncData';
/** 岗位详情聚合数据 */
interface JobDetailData {
detail: JobDetail;
overallScore: JobOverallScore;
salaryData: SalaryData;
reviews: EmployeeReview[];
}
export function useJobDetail(id: string) {
const fetcher = useCallback(async (): Promise<JobDetailData> => {
const [detail, overallScore, salaryData, reviews] = await Promise.all([
fetchJobDetail(id),
fetchJobOverallScore(id),
fetchSalaryData(id),
fetchEmployeeReviews(id),
]);
return { detail, overallScore, salaryData, reviews };
}, [id]);
const { data, loading, error, refresh } = useAsyncData(fetcher);
return {
detail: data?.detail ?? null,
overallScore: data?.overallScore ?? null,
salaryData: data?.salaryData ?? null,
reviews: data?.reviews ?? [],
loading,
error,
refresh,
};
}

6
src/types/index.ts

@ -3,10 +3,16 @@
*/ */
export type { export type {
Category, Category,
EmployeeReview,
Job, Job,
JobDetail,
JobDimensionScore,
JobLevel, JobLevel,
JobListParams, JobListParams,
JobOverallScore,
MatchData, MatchData,
SalaryData,
SalaryRange,
SortType, SortType,
} from './job'; } from './job';
export type { export type {

68
src/types/job.ts

@ -77,3 +77,71 @@ export interface JobListParams {
pageSize?: number; pageSize?: number;
} }
/** 岗位详情(继承 Job,增加详情页专属字段;industry/benefits 复用 Job 已有定义) */
export interface JobDetail extends Job {
/** 公司规模,如 "10000人以上" */
companySize: string;
/** 发布状态,如 "刚发布" */
status: string;
/** 浏览次数,如 1286 */
views: number;
/** 薪资月数,如 14(展示为 "14薪") */
salaryMonths: number;
}
/** 岗位详情维度评分(雷达图 6 维度,与用户端 DimensionScore 区分命名) */
export interface JobDimensionScore {
id: string;
/** 维度名称,如 "岗位真实性" */
name: string;
/** 分值(0-100) */
value: number;
}
/** 岗位综合评分 */
export interface JobOverallScore {
/** 总分,如 92 */
totalScore: number;
/** 等级,如 "优秀" */
level: string;
/** 排名文案,如 "超过91%同行业岗位" */
rank: string;
/** 6 个维度评分 */
dimensions: JobDimensionScore[];
}
/** 薪资区间(按经验分段) */
export interface SalaryRange {
/** 经验段,如 "1-3年" */
experience: string;
/** 最低薪资(K) */
min: number;
/** 最高薪资(K) */
max: number;
}
/** 薪资数据(真实薪资区间) */
export interface SalaryData {
/** 各经验段薪资区间 */
ranges: SalaryRange[];
/** 数据来源说明,如 "基于2,846份真实简历数据" */
dataSource: string;
/** 样本总数,如 2846 */
totalCount: number;
}
/** 员工评价 */
export interface EmployeeReview {
id: string;
/** 用户名(脱敏),如 "张**" */
userName: string;
/** 身份标签,如 "在职员工,前端开发" */
userRole: string;
/** 时间,如 "3天前" */
time: string;
/** 评价内容 */
content: string;
/** 评价标签 */
tags: string[];
}

6
src/types/user.ts

@ -98,4 +98,10 @@ export interface Badge {
description: string; description: string;
/** 勋章图标(emoji) */ /** 勋章图标(emoji) */
icon: string; icon: string;
/** 是否已获得 */
obtained: boolean;
/** 获取进度(未获得时展示进度条,如 3) */
progress?: number;
/** 进度目标(未获得时的分母,如 5) */
target?: number;
} }

Loading…
Cancel
Save