Browse Source

feat: 区分企业端与求职端导航

- 新增 RoleContext(会话级角色持久化),identity-select 进入时写入 role
- 新增 app/(enterprise) 路由组 + 企业端底部 Tab 栏(概览/岗位/人才/沟通,success 绿)
- 将 10 个企业端页面迁移至 (enterprise) 组(路由路径不变),修正相对引用
- (tabs) 求职端挂载时 setRole('candidate'),两端体感对称
master
doublex001 2 months ago
parent
commit
87ad5e629c
  1. 208
      app/(enterprise)/_layout.tsx
  2. 6
      app/(enterprise)/audit-report.tsx
  3. 6
      app/(enterprise)/candidate-detail.tsx
  4. 6
      app/(enterprise)/enterprise-certification.tsx
  5. 6
      app/(enterprise)/enterprise-chat.tsx
  6. 6
      app/(enterprise)/enterprise-overview.tsx
  7. 6
      app/(enterprise)/hr-evaluation.tsx
  8. 16
      app/(enterprise)/job-management.tsx
  9. 6
      app/(enterprise)/job-publish.tsx
  10. 6
      app/(enterprise)/operation-rules.tsx
  11. 10
      app/(enterprise)/talent-recommendation.tsx
  12. 7
      app/(tabs)/_layout.tsx
  13. 6
      app/_layout.tsx
  14. 6
      app/identity-select.tsx
  15. 77
      src/context/RoleContext.tsx

208
app/(enterprise)/_layout.tsx

@ -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,
},
});

6
app/audit-report.tsx → app/(enterprise)/audit-report.tsx

@ -3,9 +3,9 @@ import { useRouter } from 'expo-router';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Line, Path, Rect, Text as SvgText } from 'react-native-svg';
import { useAuditReport } from '../src/hooks';
import { colors } from '../src/theme';
import type { AuditKPI, AuditTrendPoint, RiskDistributionItem } from '../src/types';
import { useAuditReport } from '../../src/hooks';
import { colors } from '../../src/theme';
import type { AuditKPI, AuditTrendPoint, RiskDistributionItem } from '../../src/types';
/* ===================== 子组件:顶部导航 ===================== */

6
app/candidate-detail.tsx → app/(enterprise)/candidate-detail.tsx

@ -10,9 +10,9 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Line, Path, Polygon, Text as SvgText } from 'react-native-svg';
import { useCandidateDetail } from '../src/hooks';
import { colors } from '../src/theme';
import type { CandidateAbility, CandidateReview } from '../src/types';
import { useCandidateDetail } from '../../src/hooks';
import { colors } from '../../src/theme';
import type { CandidateAbility, CandidateReview } from '../../src/types';
/* ===================== 子组件:顶部导航 ===================== */

6
app/enterprise-certification.tsx → app/(enterprise)/enterprise-certification.tsx

@ -11,9 +11,9 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useEnterpriseCertification } from '../src/hooks';
import { colors } from '../src/theme';
import type { EnterpriseCertificationForm, EnterpriseCertificationStatus } from '../src/types';
import { useEnterpriseCertification } from '../../src/hooks';
import { colors } from '../../src/theme';
import type { EnterpriseCertificationForm, EnterpriseCertificationStatus } from '../../src/types';
/* ===================== 子组件:顶部导航 ===================== */

6
app/enterprise-chat.tsx → app/(enterprise)/enterprise-chat.tsx

@ -10,9 +10,9 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useEnterpriseCommunication } from '../src/hooks';
import { colors } from '../src/theme';
import type { EnterpriseChatItem, EnterpriseCommunicationTab, PendingTask } from '../src/types';
import { useEnterpriseCommunication } from '../../src/hooks';
import { colors } from '../../src/theme';
import type { EnterpriseChatItem, EnterpriseCommunicationTab, PendingTask } from '../../src/types';
/* ===================== 子组件:顶部导航 ===================== */

6
app/enterprise-overview.tsx → app/(enterprise)/enterprise-overview.tsx

@ -10,9 +10,9 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useEnterpriseOverview } from '../src/hooks';
import { colors, headerGradient } from '../src/theme';
import type { EnterpriseOverview, EnterpriseQuickAction, EnterpriseStat, JobEvaluationSummary } from '../src/types';
import { useEnterpriseOverview } from '../../src/hooks';
import { colors, headerGradient } from '../../src/theme';
import type { EnterpriseOverview, EnterpriseQuickAction, EnterpriseStat, JobEvaluationSummary } from '../../src/types';
const COLOR_TOKENS: Record<string, string> = {
primary: colors.primary,

6
app/hr-evaluation.tsx → app/(enterprise)/hr-evaluation.tsx

@ -10,9 +10,9 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Circle, Line, Path, Text as SvgText } from 'react-native-svg';
import { useHREvaluation } from '../src/hooks';
import { colors } from '../src/theme';
import type { EvaluationDimension, EvaluationReview, EvaluationTrendPoint } from '../src/types';
import { useHREvaluation } from '../../src/hooks';
import { colors } from '../../src/theme';
import type { EvaluationDimension, EvaluationReview, EvaluationTrendPoint } from '../../src/types';
/** 评级 → 文字颜色(优秀=品牌蓝,良好=主色,其他=次要文字) */
const LEVEL_TEXT_COLOR: Record<string, string> = {

16
app/job-management.tsx → app/(enterprise)/job-management.tsx

@ -11,9 +11,9 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useJobManagement } from '../src/hooks';
import { colors } from '../src/theme';
import type { JobManagement, JobManagementJob, JobManagementTab } from '../src/types';
import { useJobManagement } from '../../src/hooks';
import { colors } from '../../src/theme';
import type { JobManagement, JobManagementJob, JobManagementTab } from '../../src/types';
/* ===================== 子组件:顶部导航 ===================== */
@ -117,11 +117,11 @@ function SearchFilterBar() {
placeholderTextColor={colors.textHint}
/>
</View>
<Pressable style={styles.filterBtn} onPress={() => console.log('筛选')}>
<Pressable style={styles.filterBtn} onPress={() => {}}>
<Ionicons name="funnel-outline" size={18} color={colors.textPrimary} />
<Text style={styles.filterBtnText}>筛选</Text>
</Pressable>
<Pressable style={styles.sortBtn} onPress={() => console.log('最新发布')}>
<Pressable style={styles.sortBtn} onPress={() => {}}>
<Text style={styles.sortBtnText}>最新发布</Text>
<Ionicons name="chevron-down" size={14} color={colors.textHint} />
</Pressable>
@ -178,15 +178,15 @@ function JobCard({ job }: { job: JobManagementJob }) {
</View>
<View style={styles.jobActions}>
<Pressable style={styles.jobActionBtn} onPress={() => console.log('编辑', job.id)}>
<Pressable style={styles.jobActionBtn} onPress={() => {}}>
<Ionicons name="create-outline" size={16} color={colors.primary} />
<Text style={styles.jobActionText}>编辑</Text>
</Pressable>
<Pressable style={styles.jobActionBtn} onPress={() => console.log('刷新', job.id)}>
<Pressable style={styles.jobActionBtn} onPress={() => {}}>
<Ionicons name="refresh-outline" size={16} color={colors.primary} />
<Text style={styles.jobActionText}>刷新</Text>
</Pressable>
<Pressable style={styles.jobActionBtn} onPress={() => console.log('下架', job.id)}>
<Pressable style={styles.jobActionBtn} onPress={() => {}}>
<Ionicons name="download-outline" size={16} color={colors.primary} />
<Text style={styles.jobActionText}>下架</Text>
</Pressable>

6
app/job-publish.tsx → app/(enterprise)/job-publish.tsx

@ -11,9 +11,9 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useJobPublish } from '../src/hooks';
import { colors } from '../src/theme';
import type { JobPublishFormData } from '../src/types';
import { useJobPublish } from '../../src/hooks';
import { colors } from '../../src/theme';
import type { JobPublishFormData } from '../../src/types';
const WORK_TYPE_OPTIONS: { label: string; value: JobPublishFormData['workType'] }[] = [
{ label: '全职', value: 'full-time' },

6
app/operation-rules.tsx → app/(enterprise)/operation-rules.tsx

@ -12,9 +12,9 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useOperationRules } from '../src/hooks';
import { colors } from '../src/theme';
import type { MatchMode, OperationRules } from '../src/types';
import { useOperationRules } from '../../src/hooks';
import { colors } from '../../src/theme';
import type { MatchMode, OperationRules } from '../../src/types';
/* ===================== 子组件:顶部导航 ===================== */

10
app/talent-recommendation.tsx → app/(enterprise)/talent-recommendation.tsx

@ -10,9 +10,9 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTalentRecommendation } from '../src/hooks';
import { colors } from '../src/theme';
import type { TalentCandidate, TalentRecommendationTab } from '../src/types';
import { useTalentRecommendation } from '../../src/hooks';
import { colors } from '../../src/theme';
import type { TalentCandidate, TalentRecommendationTab } from '../../src/types';
/* ===================== 子组件:顶部导航 ===================== */
@ -264,7 +264,7 @@ export default function TalentRecommendationScreen() {
<View>
<TodayHeader
count={recommendation.todayCount}
onReport={() => console.log('[TalentRecommendation] 查看人才市场报告')}
onReport={() => {}}
/>
{recommendation.candidates.map((candidate) => (
<CandidateCard key={candidate.id} candidate={candidate} />
@ -275,7 +275,7 @@ export default function TalentRecommendationScreen() {
<EmptyState
actions={recommendation.recommendedActions}
dataReference={recommendation.dataReference}
onFilter={() => console.log('[TalentRecommendation] 调整筛选条件')}
onFilter={() => {}}
/>
)}
</ScrollView>

7
app/(tabs)/_layout.tsx

@ -4,6 +4,7 @@ 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';
/** 无 index 路由,默认进入「岗位」Tab 作为落地页 */
export const unstable_settings = {
@ -161,6 +162,12 @@ function TabBar({ state, descriptors, navigation, insets }: BottomTabBarProps) {
* 底部 Tab 导航:4 个 Tab(岗位/沟通/成长/我的),高级感极简风格
*/
export default function TabLayout() {
const { setRole } = useRole();
useEffect(() => {
setRole('candidate');
}, [setRole]);
return (
<Tabs
screenOptions={{ headerShown: false }}

6
app/_layout.tsx

@ -1,15 +1,17 @@
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { RoleProvider } from '../src/context/RoleContext';
/**
* 根布局:用 Stack 包裹整个应用,Tab 组不显示顶部 header
*/
export default function RootLayout() {
return (
<>
<RoleProvider>
<StatusBar style="dark" />
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="(enterprise)" options={{ headerShown: false }} />
<Stack.Screen name="jobs/[id]" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ title: '设置' }} />
<Stack.Screen name="resume" options={{ title: '我的简历' }} />
@ -17,6 +19,6 @@ export default function RootLayout() {
<Stack.Screen name="history" options={{ title: '浏览历史' }} />
<Stack.Screen name="assessment" options={{ title: '我的测评' }} />
</Stack>
</>
</RoleProvider>
);
}

6
app/identity-select.tsx

@ -11,6 +11,7 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useIdentityOptions } from '../src/hooks';
import { useRole } from '../src/context/RoleContext';
import { colors } from '../src/theme';
import type { IdentityOption } from '../src/types';
@ -97,15 +98,18 @@ function ErrorState({ onRetry }: { onRetry: () => void }) {
export default function IdentitySelectScreen() {
const router = useRouter();
const { setRole } = useRole();
const { options, loading, error, refresh } = useIdentityOptions();
const [selectedId, setSelectedId] = useState<string | null>(null);
const handleCandidatePress = () => {
setRole('candidate');
router.replace('/(tabs)');
};
const handleEnterprisePress = () => {
router.push('/enterprise-overview');
setRole('enterprise');
router.replace('/(enterprise)');
};
return (

77
src/context/RoleContext.tsx

@ -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…
Cancel
Save