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