Browse Source
- 新增 RoleContext(会话级角色持久化),identity-select 进入时写入 role
- 新增 app/(enterprise) 路由组 + 企业端底部 Tab 栏(概览/岗位/人才/沟通,success 绿)
- 将 10 个企业端页面迁移至 (enterprise) 组(路由路径不变),修正相对引用
- (tabs) 求职端挂载时 setRole('candidate'),两端体感对称
master
15 changed files with 338 additions and 40 deletions
@ -0,0 +1,208 @@ |
|||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { Tabs } from 'expo-router/js-tabs'; |
||||
|
import type { BottomTabBarProps } from 'expo-router/js-tabs'; |
||||
|
import { useEffect, useRef } from 'react'; |
||||
|
import { Animated, Easing, Pressable, StyleSheet, View } from 'react-native'; |
||||
|
import { colors } from '../../src/theme'; |
||||
|
import { useRole } from '../../src/context/RoleContext'; |
||||
|
|
||||
|
/** 企业端默认落地「概览」Tab */ |
||||
|
export const unstable_settings = { |
||||
|
initialRouteName: 'enterprise-overview', |
||||
|
}; |
||||
|
|
||||
|
/** 企业端用 success(绿) 区分求职端 primary(蓝);未选中 -outline,选中填充实心 */ |
||||
|
const ICON_MAP = { |
||||
|
'enterprise-overview': { outline: 'grid-outline', filled: 'grid' }, |
||||
|
'job-management': { outline: 'folder-outline', filled: 'folder' }, |
||||
|
'talent-recommendation': { outline: 'people-outline', filled: 'people' }, |
||||
|
'enterprise-chat': { outline: 'chatbubble-outline', filled: 'chatbubble' }, |
||||
|
} as const; |
||||
|
|
||||
|
type TabRoute = keyof typeof ICON_MAP; |
||||
|
|
||||
|
function TabItem({ |
||||
|
focused, |
||||
|
route, |
||||
|
label, |
||||
|
onPress, |
||||
|
onLongPress, |
||||
|
}: { |
||||
|
focused: boolean; |
||||
|
route: TabRoute; |
||||
|
label: string; |
||||
|
onPress: () => void; |
||||
|
onLongPress: () => void; |
||||
|
}) { |
||||
|
const { outline, filled } = ICON_MAP[route]; |
||||
|
const translateY = useRef(new Animated.Value(focused ? -2 : 0)).current; |
||||
|
const progress = useRef(new Animated.Value(focused ? 1 : 0)).current; |
||||
|
|
||||
|
useEffect(() => { |
||||
|
const translateAnim = Animated.timing(translateY, { |
||||
|
toValue: focused ? -2 : 0, |
||||
|
duration: 150, |
||||
|
easing: Easing.out(Easing.ease), |
||||
|
useNativeDriver: true, |
||||
|
}); |
||||
|
const progressAnim = Animated.timing(progress, { |
||||
|
toValue: focused ? 1 : 0, |
||||
|
duration: 200, |
||||
|
easing: Easing.inOut(Easing.ease), |
||||
|
useNativeDriver: true, |
||||
|
}); |
||||
|
translateAnim.start(); |
||||
|
progressAnim.start(); |
||||
|
return () => { |
||||
|
translateAnim.stop(); |
||||
|
progressAnim.stop(); |
||||
|
}; |
||||
|
}, [focused, translateY, progress]); |
||||
|
|
||||
|
const inactiveOpacity = progress.interpolate({ |
||||
|
inputRange: [0, 1], |
||||
|
outputRange: [1, 0], |
||||
|
}); |
||||
|
|
||||
|
return ( |
||||
|
<Pressable |
||||
|
style={({ pressed }) => [styles.tabItem, pressed && styles.tabItemPressed]} |
||||
|
onPress={onPress} |
||||
|
onLongPress={onLongPress} |
||||
|
accessibilityRole="button" |
||||
|
accessibilityState={{ selected: focused }} |
||||
|
> |
||||
|
<Animated.View style={{ transform: [{ translateY }] }}> |
||||
|
<View style={styles.iconBox}> |
||||
|
<Animated.View |
||||
|
style={[StyleSheet.absoluteFill, styles.center, { opacity: inactiveOpacity }]} |
||||
|
> |
||||
|
<Ionicons name={outline} size={22} color={colors.textHint} /> |
||||
|
</Animated.View> |
||||
|
<Animated.View style={[StyleSheet.absoluteFill, styles.center, { opacity: progress }]}> |
||||
|
<Ionicons name={filled} size={22} color={colors.success} /> |
||||
|
</Animated.View> |
||||
|
</View> |
||||
|
</Animated.View> |
||||
|
|
||||
|
<View style={styles.labelBox}> |
||||
|
<Animated.Text style={[styles.tabLabel, styles.labelLayer, { opacity: inactiveOpacity }]}> |
||||
|
{label} |
||||
|
</Animated.Text> |
||||
|
<Animated.Text |
||||
|
style={[styles.tabLabel, styles.tabLabelActive, styles.labelLayer, { opacity: progress }]} |
||||
|
> |
||||
|
{label} |
||||
|
</Animated.Text> |
||||
|
</View> |
||||
|
</Pressable> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
function TabBar({ state, descriptors, navigation, insets }: BottomTabBarProps) { |
||||
|
return ( |
||||
|
<View style={[styles.tabBar, { paddingBottom: insets.bottom + 4 }]}> |
||||
|
{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} |
||||
|
route={route.name as TabRoute} |
||||
|
focused={focused} |
||||
|
label={label} |
||||
|
onPress={onPress} |
||||
|
onLongPress={onLongPress} |
||||
|
/> |
||||
|
); |
||||
|
})} |
||||
|
</View> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 企业端底部 Tab 导航:概览 / 岗位 / 人才 / 沟通 |
||||
|
* 绿色 success 强调,与求职端蓝色 primary 形成视觉区分。 |
||||
|
*/ |
||||
|
export default function EnterpriseTabLayout() { |
||||
|
const { setRole } = useRole(); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
setRole('enterprise'); |
||||
|
}, [setRole]); |
||||
|
|
||||
|
return ( |
||||
|
<Tabs |
||||
|
screenOptions={{ headerShown: false }} |
||||
|
tabBar={(props) => <TabBar {...props} />} |
||||
|
> |
||||
|
<Tabs.Screen name="enterprise-overview" options={{ title: '概览' }} /> |
||||
|
<Tabs.Screen name="job-management" options={{ title: '岗位' }} /> |
||||
|
<Tabs.Screen name="talent-recommendation" options={{ title: '人才' }} /> |
||||
|
<Tabs.Screen name="enterprise-chat" options={{ title: '沟通' }} /> |
||||
|
</Tabs> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
tabBar: { |
||||
|
flexDirection: 'row', |
||||
|
alignItems: 'center', |
||||
|
backgroundColor: colors.white, |
||||
|
paddingTop: 4, |
||||
|
}, |
||||
|
tabItem: { |
||||
|
flex: 1, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
paddingVertical: 2, |
||||
|
}, |
||||
|
tabItemPressed: { |
||||
|
transform: [{ scale: 0.92 }], |
||||
|
}, |
||||
|
iconBox: { |
||||
|
width: 22, |
||||
|
height: 22, |
||||
|
}, |
||||
|
center: { |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
}, |
||||
|
labelBox: { |
||||
|
height: 14, |
||||
|
marginTop: 1, |
||||
|
alignSelf: 'stretch', |
||||
|
}, |
||||
|
labelLayer: { |
||||
|
position: 'absolute', |
||||
|
top: 0, |
||||
|
left: 0, |
||||
|
right: 0, |
||||
|
}, |
||||
|
tabLabel: { |
||||
|
fontSize: 11, |
||||
|
lineHeight: 14, |
||||
|
fontWeight: '400', |
||||
|
textAlign: 'center', |
||||
|
color: colors.textHint, |
||||
|
}, |
||||
|
tabLabelActive: { |
||||
|
fontWeight: '600', |
||||
|
color: colors.success, |
||||
|
}, |
||||
|
}); |
||||
@ -0,0 +1,77 @@ |
|||||
|
import { |
||||
|
createContext, |
||||
|
useContext, |
||||
|
useEffect, |
||||
|
useState, |
||||
|
type ReactNode, |
||||
|
} from 'react'; |
||||
|
|
||||
|
/* ===================== 角色(端)上下文 ===================== |
||||
|
* 区分「求职端 candidate」与「企业端 enterprise」两套管路。 |
||||
|
* - 进入 (tabs) 视为求职端,进入 (enterprise) 视为企业端; |
||||
|
* - identity-select 选定身份时写入 role,两端布局挂载时也会兜底校正。 |
||||
|
* |
||||
|
* 持久化:当前为会话内(内存)实现,App 重启后回到 null(重新选择身份)。 |
||||
|
* 若已安装 @react-native-async-storage/async-storage,可将文件底部 |
||||
|
* persistRole / loadRole 替换为真实读写,即可跨重启记住当前端。 |
||||
|
*/ |
||||
|
|
||||
|
export type AppRole = 'candidate' | 'enterprise'; |
||||
|
|
||||
|
interface RoleContextValue { |
||||
|
role: AppRole | null; |
||||
|
setRole: (role: AppRole) => void; |
||||
|
clearRole: () => void; |
||||
|
} |
||||
|
|
||||
|
const RoleContext = createContext<RoleContextValue | undefined>(undefined); |
||||
|
|
||||
|
let memoryRole: AppRole | null = null; |
||||
|
|
||||
|
function persistRole(role: AppRole | null): void { |
||||
|
memoryRole = role; |
||||
|
// TODO(持久化): 安装 async-storage 后改为 AsyncStorage.setItem('app_role', role ?? '')
|
||||
|
} |
||||
|
|
||||
|
function loadRole(): Promise<AppRole | null> { |
||||
|
return Promise.resolve(memoryRole); |
||||
|
// TODO(持久化): 安装 async-storage 后改为 AsyncStorage.getItem('app_role')
|
||||
|
} |
||||
|
|
||||
|
export function RoleProvider({ children }: { children: ReactNode }) { |
||||
|
const [role, setRoleState] = useState<AppRole | null>(null); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
let active = true; |
||||
|
loadRole().then((stored) => { |
||||
|
if (active && stored) setRoleState(stored); |
||||
|
}); |
||||
|
return () => { |
||||
|
active = false; |
||||
|
}; |
||||
|
}, []); |
||||
|
|
||||
|
const setRole = (next: AppRole) => { |
||||
|
setRoleState(next); |
||||
|
persistRole(next); |
||||
|
}; |
||||
|
|
||||
|
const clearRole = () => { |
||||
|
setRoleState(null); |
||||
|
persistRole(null); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<RoleContext.Provider value={{ role, setRole, clearRole }}> |
||||
|
{children} |
||||
|
</RoleContext.Provider> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
export function useRole(): RoleContextValue { |
||||
|
const ctx = useContext(RoleContext); |
||||
|
if (!ctx) { |
||||
|
throw new Error('useRole 必须在 RoleProvider 内部使用'); |
||||
|
} |
||||
|
return ctx; |
||||
|
} |
||||
Loading…
Reference in new issue