轻松聘
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

698 lines
19 KiB

import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useEnterpriseCommunication } from '../../src/hooks';
import { ENTERPRISE_DISPLAY_NAME } from '../../src/constants';
import { colors , spacing } from '../../src/theme';
import type { EnterpriseChatItem, EnterpriseCommunicationTab, PendingTask } from '../../src/types';
/* ===================== 子组件:顶部导航 ===================== */
function Header({ companyName, onBack }: { companyName: string; onBack: () => void }) {
const router = useRouter();
return (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<View style={styles.headerCenter}>
<Text style={styles.headerCompany}>{companyName}</Text>
<Text style={styles.headerTitle}>沟通</Text>
</View>
<Pressable
style={styles.startBtn}
onPress={() => router.navigate('/talent-recommendation')}
hitSlop={6}
>
<Ionicons name="add" size={16} color={colors.white} />
<Text style={styles.startBtnText}>发起沟通</Text>
</Pressable>
</View>
);
}
/* ===================== 子组件:Tab 栏 ===================== */
function TabBar({
tabs,
active,
onChange,
}: {
tabs: EnterpriseCommunicationTab[];
active: string;
onChange: (key: string) => void;
}) {
return (
<View style={styles.tabBar}>
{tabs.map((tab) => {
const isActive = tab.key === active;
return (
<Pressable key={tab.key} style={styles.tabItem} onPress={() => onChange(tab.key)}>
<Text style={[styles.tabLabel, isActive && styles.tabLabelActive]}>
{tab.label}
<Text style={[styles.tabCount, isActive && styles.tabCountActive]}>({tab.count})</Text>
</Text>
<View style={[styles.tabIndicator, isActive && styles.tabIndicatorActive]} />
</Pressable>
);
})}
</View>
);
}
/* ===================== 子组件:待处理任务 ===================== */
function PendingTasks({
tasks,
onPressTask,
}: {
tasks: PendingTask[];
onPressTask: (task: PendingTask) => void;
}) {
return (
<View style={styles.pendingCard}>
<Text style={styles.pendingTitle}>待处理</Text>
<View style={styles.pendingGrid}>
{tasks.map((task) => {
const token =
task.color === 'action'
? colors.action
: task.color === 'compareOrange'
? colors.compareOrange
: colors.primary;
return (
<Pressable
key={task.id}
style={styles.pendingItem}
onPress={() => onPressTask(task)}
>
<View style={[styles.pendingIconWrap, { backgroundColor: token + '15' }]}>
<Ionicons name={task.icon as keyof typeof Ionicons.glyphMap} size={22} color={token} />
</View>
<View style={styles.pendingBody}>
<View style={styles.pendingLabelRow}>
<Text style={styles.pendingLabel}>{task.label}</Text>
<View style={styles.pendingBadge}>
<Text style={styles.pendingBadgeText}>{task.count}人</Text>
</View>
</View>
{task.hint ? <Text style={styles.pendingHint}>{task.hint}</Text> : null}
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textHint} />
</Pressable>
);
})}
</View>
</View>
);
}
/* ===================== 子组件:聊天列表项 ===================== */
function ChatRow({ chat }: { chat: EnterpriseChatItem }) {
const router = useRouter();
return (
<Pressable style={styles.chatRow} onPress={() => router.push('/chat/' + chat.id)}>
<View style={styles.chatAvatar}>
<Text style={styles.chatAvatarText}>{chat.avatar}</Text>
</View>
<View style={styles.chatBody}>
<View style={styles.chatHeader}>
<View style={styles.chatNameWrap}>
<Text style={styles.chatName}>{chat.name}</Text>
{chat.labels.length > 0 ? (
<View style={styles.chatLabelTag}>
<Text style={styles.chatLabelTagText}>{chat.labels[0]}</Text>
</View>
) : null}
</View>
<Text style={styles.chatTime}>{chat.time}</Text>
</View>
<Text style={styles.chatTitle}>{chat.title}</Text>
<View style={styles.chatFooter}>
<Text style={styles.chatLastMessage} numberOfLines={1}>
{chat.lastMessage}
</Text>
{chat.unreadCount > 0 ? (
<View style={styles.unreadBadge}>
<Text style={styles.unreadBadgeText}>{chat.unreadCount}</Text>
</View>
) : null}
</View>
</View>
</Pressable>
);
}
/* ===================== 子组件:搜索栏 ===================== */
function SearchBar({
value,
onChange,
onClear,
}: {
value: string;
onChange: (text: string) => void;
onClear: () => void;
}) {
return (
<View style={styles.searchBar}>
<Ionicons name="search-outline" size={18} color={colors.textHint} />
<TextInput
style={styles.searchInput}
value={value}
onChangeText={onChange}
placeholder="搜索候选人姓名 / 职位 / 消息"
placeholderTextColor={colors.textHint}
returnKeyType="search"
/>
{value !== '' && (
<Pressable onPress={onClear} hitSlop={8}>
<Ionicons name="close-circle" size={16} color={colors.textHint} />
</Pressable>
)}
</View>
);
}
/* ===================== 子组件:快捷操作栏 ===================== */
/** 单个快捷操作。`route` 必填 —— 每个入口都必须有落点,没有目标页的宁可不放 */
type QuickActionItem = { icon: string; label: string; route: string };
function QuickActions({
actions,
onPressAction,
}: {
actions: QuickActionItem[];
onPressAction: (action: QuickActionItem) => void;
}) {
return (
<View style={styles.quickActionsCard}>
{actions.map((action) => (
<Pressable key={action.label} style={styles.quickAction} onPress={() => onPressAction(action)}>
<Ionicons name={action.icon as keyof typeof Ionicons.glyphMap} size={20} color={colors.textSecondary} />
<Text style={styles.quickActionText}>{action.label}</Text>
</Pressable>
))}
</View>
);
}
/* ===================== 子组件:空状态 ===================== */
function EmptyState({
dataReference,
onDiscover,
}: {
dataReference: { label: string; value: string }[];
onDiscover: () => void;
}) {
return (
<View style={styles.emptyCard}>
<View style={styles.emptyIconWrap}>
<Ionicons name="chatbubbles-outline" size={48} color={colors.primary} />
</View>
<Text style={styles.emptyTitle}>还没有沟通记录</Text>
<Text style={styles.emptySubtitle}>从人才推荐中发现合适的候选人,主动发起沟通吧</Text>
<Pressable style={styles.emptyBtn} onPress={onDiscover} hitSlop={6}>
<Text style={styles.emptyBtnText}>去发现人才</Text>
</Pressable>
<View style={styles.dataCard}>
<Text style={styles.dataTitle}>数据参考</Text>
{dataReference.map((item, index) => (
<View key={index} style={styles.dataRow}>
<Ionicons name="trending-up-outline" size={16} color={colors.success} />
<Text style={styles.dataText}>
{item.label}
<Text style={styles.dataValue}> {item.value}</Text>
</Text>
</View>
))}
</View>
</View>
);
}
/* ===================== 页面 ===================== */
/*
* 「批量发Offer」「群发通知」原先在这里,但两者都没有对应页面,点了只能弹「功能开发中」。
* 按「不留假交互」的口径摘掉(与 talent-recommendation.tsx 的 POOL_ACTIONS 同一处理)。
*/
const QUICK_ACTIONS: QuickActionItem[] = [
{ icon: 'mail-outline', label: '批量发邀请', route: '/talent-recommendation' },
{ icon: 'calendar-outline', label: '面试评分', route: '/interview-score' },
];
export default function EnterpriseChatScreen() {
const router = useRouter();
const {
communication,
activeTab,
setActiveTab,
keyword,
setKeyword,
chats,
isEmptySearch,
loading,
error,
refresh,
} = useEnterpriseCommunication();
/** 快捷操作:每个入口都由数据保证有落点,页面不再需要「没页面就弹提示」的分支 */
const handleQuickAction = (action: QuickActionItem) => {
router.push(action.route);
};
// 待办卡片:跳页面或切 Tab,目标由接口层给,页面不写死映射。
// PendingTask 是联合类型,两者必居其一,故不需要「都没有」的兜底分支
const handlePressTask = (task: PendingTask) => {
if (task.tabKey) {
setActiveTab(task.tabKey);
setKeyword('');
return;
}
router.push(task.route as never);
};
if (loading) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header companyName={ENTERPRISE_DISPLAY_NAME} onBack={() => router.back()} />
<View style={styles.center}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
if (error || !communication) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header companyName={ENTERPRISE_DISPLAY_NAME} onBack={() => router.back()} />
<View style={styles.center}>
<Ionicons name="alert-circle-outline" size={40} color={colors.danger} />
<Text style={styles.tip}>加载失败</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<Header companyName={communication.companyName} onBack={() => router.back()} />
<TabBar tabs={communication.tabs} active={activeTab} onChange={setActiveTab} />
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
{communication.hasData ? (
<View>
<PendingTasks tasks={communication.pendingTasks} onPressTask={handlePressTask} />
<SearchBar value={keyword} onChange={setKeyword} onClear={() => setKeyword('')} />
{chats.length === 0 ? (
<View style={styles.emptyList}>
<Ionicons name="chatbubbles-outline" size={32} color={colors.textHint} />
<Text style={styles.emptyListText}>
{isEmptySearch ? `没有匹配「${keyword.trim()}」的会话` : '这个分类下暂时没有会话'}
</Text>
</View>
) : (
<View style={styles.chatListCard}>
{chats.map((chat, index) => {
const isLast = index === chats.length - 1;
return (
<View key={chat.id} style={isLast ? undefined : styles.chatBorder}>
<ChatRow chat={chat} />
</View>
);
})}
</View>
)}
<QuickActions actions={QUICK_ACTIONS} onPressAction={handleQuickAction} />
</View>
) : (
<EmptyState
dataReference={communication.dataReference}
onDiscover={() => router.navigate('/talent-recommendation')}
/>
)}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.pageBg },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
paddingVertical: 12,
backgroundColor: colors.white,
},
headerBtn: { width: 40, alignItems: 'center' },
headerCenter: { alignItems: 'center' },
headerCompany: { fontSize: 12, color: colors.textSecondary },
headerTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary, marginTop: 2 },
startBtn: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.primaryBtn,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 16,
},
startBtnText: {
color: colors.white,
fontSize: 12,
fontWeight: '600',
marginLeft: 2,
},
tabBar: {
flexDirection: 'row',
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
tabItem: {
flex: 1,
alignItems: 'center',
paddingTop: 8,
},
tabLabel: {
fontSize: 13,
color: colors.textSecondary,
paddingBottom: 8,
},
tabLabelActive: {
color: colors.textPrimary,
fontWeight: '600',
},
tabCount: {
fontSize: 12,
color: colors.textHint,
marginLeft: 2,
},
tabCountActive: {
color: colors.primary,
},
tabIndicator: {
height: 2,
width: 20,
borderRadius: 1,
backgroundColor: 'transparent',
marginBottom: -1,
},
tabIndicatorActive: {
backgroundColor: colors.primary,
},
body: {
padding: spacing.card,
paddingBottom: 24,
},
center: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
tip: { fontSize: 14, color: colors.textSecondary },
retryBtn: {
marginTop: 8,
paddingHorizontal: 24,
paddingVertical: spacing.card,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: { color: colors.white, fontSize: 13, fontWeight: '600' },
pendingCard: {
backgroundColor: colors.white,
borderRadius: spacing.card,
padding: spacing.card,
marginBottom: 8,
},
pendingTitle: {
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
marginBottom: 12,
},
pendingGrid: {
gap: 8,
},
pendingItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.pageBg,
borderRadius: spacing.card,
padding: spacing.card,
},
pendingIconWrap: {
width: 40,
height: 40,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
pendingBody: {
flex: 1,
},
pendingLabelRow: {
flexDirection: 'row',
alignItems: 'center',
},
pendingLabel: {
fontSize: 14,
fontWeight: '600',
color: colors.textPrimary,
},
pendingBadge: {
backgroundColor: colors.actionLight,
borderRadius: 10,
paddingHorizontal: spacing.card,
paddingVertical: 2,
marginLeft: 8,
},
pendingBadgeText: {
fontSize: 12,
fontWeight: '700',
color: colors.actionText,
},
pendingHint: {
fontSize: 12,
color: colors.textSecondary,
marginTop: 2,
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.white,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
marginBottom: 12,
},
searchInput: {
flex: 1,
marginLeft: 8,
fontSize: 14,
color: colors.textPrimary,
padding: 0,
},
emptyList: {
alignItems: 'center',
gap: 10,
padding: spacing.card,
backgroundColor: colors.white,
borderRadius: spacing.card,
},
emptyListText: { fontSize: 13, color: colors.textSecondary },
chatListCard: {
backgroundColor: colors.white,
borderRadius: spacing.card,
overflow: 'hidden',
marginBottom: 8,
},
chatBorder: {
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
chatRow: {
flexDirection: 'row',
alignItems: 'center',
padding: 14,
},
chatAvatar: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: colors.primaryLight,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
chatAvatarText: {
fontSize: 18,
fontWeight: '700',
color: colors.primary,
},
chatBody: {
flex: 1,
},
chatHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
chatNameWrap: {
flexDirection: 'row',
alignItems: 'center',
},
chatName: {
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
},
chatLabelTag: {
backgroundColor: colors.compareOrange + '20',
borderRadius: spacing.card,
paddingHorizontal: 6,
paddingVertical: 2,
marginLeft: 8,
},
chatLabelTagText: {
fontSize: 11,
color: colors.compareOrange,
fontWeight: '600',
},
chatTime: {
fontSize: 12,
color: colors.textHint,
},
chatTitle: {
fontSize: 13,
color: colors.textSecondary,
marginTop: 2,
},
chatFooter: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 4,
},
chatLastMessage: {
flex: 1,
fontSize: 13,
color: colors.textHint,
},
unreadBadge: {
minWidth: 18,
height: 18,
borderRadius: 9,
backgroundColor: colors.danger,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 8,
},
unreadBadgeText: {
fontSize: 11,
color: colors.white,
fontWeight: '700',
},
quickActionsCard: {
flexDirection: 'row',
backgroundColor: colors.white,
borderRadius: spacing.card,
paddingVertical: spacing.card,
},
quickAction: {
flex: 1,
alignItems: 'center',
},
quickActionText: {
fontSize: 11,
color: colors.textSecondary,
marginTop: 4,
},
emptyCard: {
backgroundColor: colors.white,
borderRadius: spacing.card,
padding: spacing.card,
alignItems: 'center',
},
emptyIconWrap: {
width: 88,
height: 88,
borderRadius: 44,
backgroundColor: colors.primaryLight,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
emptyTitle: {
fontSize: 18,
fontWeight: '700',
color: colors.textPrimary,
marginBottom: 8,
},
emptySubtitle: {
fontSize: 13,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: 20,
},
emptyBtn: {
backgroundColor: colors.primaryBtn,
paddingHorizontal: 32,
paddingVertical: 12,
borderRadius: 24,
marginBottom: 24,
},
emptyBtnText: {
color: colors.white,
fontSize: 15,
fontWeight: '600',
},
dataCard: {
width: '100%',
backgroundColor: colors.primaryLight,
borderRadius: spacing.card,
padding: spacing.card,
alignItems: 'flex-start',
},
dataTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.primaryText,
marginBottom: 12,
},
dataRow: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 10,
},
dataText: {
flex: 1,
fontSize: 13,
color: colors.textPrimary,
marginLeft: 10,
lineHeight: 18,
},
dataValue: {
color: colors.success,
fontWeight: '700',
},
});