Browse Source
- 岗位详情:新增 JobDetail/JobOverallScore/SalaryData/EmployeeReview 等类型、fetchJobDetail 等接口、useJobDetail Hook,新增 app/jobs/[id].tsx 页面(含 SVG 雷达图、真实薪资区间、员工评价、评论输入),岗位列表卡片接入详情跳转 - 个人中心:重做我的页 UI,品牌蓝渐变头部、综合评分卡片、八大维度横向评分条、求职偏好、勋章横滑 - 导航栏:底部 Tab 改为悬浮毛玻璃胶囊,选中浅蓝高亮 + 图标弹跳动画,引入 expo-blur 依赖master
15 changed files with 1666 additions and 192 deletions
@ -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', |
||||
}, |
}, |
||||
}); |
}); |
||||
@ -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} ></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, |
||||
|
}, |
||||
|
}); |
||||
@ -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, |
||||
|
}; |
||||
|
} |
||||
Loading…
Reference in new issue