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 type { ComponentProps } from 'react'; |
|||
import { StyleSheet, Text } from 'react-native'; |
|||
import type { ColorValue } from 'react-native'; |
|||
import { BlurView } from 'expo-blur'; |
|||
import { Tabs } from 'expo-router/js-tabs'; |
|||
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'; |
|||
|
|||
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) { |
|||
return ({ color, size, focused }: { color: ColorValue; size: number; focused: boolean }) => ( |
|||
return ({ color, size, focused }: TabIconProps) => ( |
|||
<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() { |
|||
return ( |
|||
<Tabs |
|||
screenOptions={{ |
|||
headerShown: false, |
|||
tabBarActiveTintColor: colors.primary, |
|||
tabBarInactiveTintColor: colors.textSecondary, |
|||
tabBarLabel: ({ color, children }) => ( |
|||
<Text style={[styles.labelText, { color }]}>{children}</Text> |
|||
), |
|||
// 悬浮 Tab 栏不占布局空间,给每个页面内容底部预留空间,避免被遮挡
|
|||
sceneStyle: { paddingBottom: TAB_BAR_FLOAT_SPACE }, |
|||
}} |
|||
tabBar={(props) => <FloatingTabBar {...props} />} |
|||
> |
|||
<Tabs.Screen |
|||
name="index" |
|||
options={{ title: '首页', tabBarIcon: tabIcon('home', 'home-outline') }} |
|||
/> |
|||
<Tabs.Screen |
|||
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.Screen name="index" options={{ title: '首页', tabBarIcon: tabIcon('home', 'home-outline') }} /> |
|||
<Tabs.Screen 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> |
|||
); |
|||
} |
|||
|
|||
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, |
|||
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