轻松聘
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.
 
 
 

691 lines
18 KiB

import { Ionicons } from '@expo/vector-icons';
import { useState } from 'react';
import {
ActivityIndicator,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useJobList } from '../../src/hooks';
import {
CITIES,
EDUCATION_OPTIONS,
EXPERIENCE_OPTIONS,
JOB_LEVEL_LABEL,
SALARY_RANGES,
} from '../../src/constants';
import type { Job, SortType } from '../../src/types';
import { colors } from '../../src/theme';
/** 筛选项(下拉占位) */
const FILTERS = ['全部行业', '薪资', '经验', '更多筛选'];
/** 排序选项 */
const SORTS: { key: SortType; label: string }[] = [
{ key: 'comprehensive', label: '综合排序' },
{ key: 'latest', label: '最新发布' },
{ key: 'salary', label: '薪资最高' },
];
/** 等级 → 文字颜色 */
const LEVEL_COLOR = {
excellent: colors.primaryDark,
good: colors.primary,
medium: colors.textSecondary,
} as const;
/** 更多筛选的三组字段 */
type FilterKey = 'salary' | 'experience' | 'education';
/** 切换某个筛选值(多选:已在则移除,否则加入) */
function toggleValue(
draft: Record<FilterKey, string[]>,
key: FilterKey,
value: string,
): Record<FilterKey, string[]> {
const current = draft[key];
const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
return { ...draft, [key]: next };
}
/** 清空某一组筛选 */
function clearGroup(draft: Record<FilterKey, string[]>, key: FilterKey): Record<FilterKey, string[]> {
return { ...draft, [key]: [] };
}
/** 筛选面板中的一组选项(「不限」为清空态) */
function FilterGroup({
title,
options,
selected,
onToggle,
onClear,
}: {
title: string;
options: string[];
selected: string[];
onToggle: (value: string) => void;
onClear: () => void;
}) {
return (
<>
<Text style={styles.filterLabel}>{title}</Text>
<View style={styles.optionRow}>
{options.map((option) => {
const isNone = option === '不限';
const active = isNone ? selected.length === 0 : selected.includes(option);
return (
<Pressable
key={option}
style={[styles.optionChip, active && styles.optionChipActive]}
onPress={() => (isNone ? onClear() : onToggle(option))}
>
<Text style={[styles.optionText, active && styles.optionTextActive]}>{option}</Text>
</Pressable>
);
})}
</View>
</>
);
}
/** 单张岗位卡片 */
function JobCard({ job }: { job: Job }) {
return (
<View style={styles.card}>
{/* 公司名 + 地点 */}
<View style={styles.cardHeader}>
<Text style={styles.companyName}>{job.companyName}</Text>
<Text style={styles.companyLocation}>{job.companyLocation}</Text>
</View>
{/* 岗位名 + 薪资 */}
<View style={styles.positionRow}>
<Text style={styles.positionName}>{job.positionName}</Text>
<Text style={styles.salary}>
{job.salaryMin}-{job.salaryMax}K
</Text>
</View>
{/* 评分标签 + 查看详情 */}
<View style={styles.scoreRow}>
<Text style={[styles.scoreText, { color: LEVEL_COLOR[job.level] }]}>
{job.score}分 · {JOB_LEVEL_LABEL[job.level]}
</Text>
<Pressable
onPress={() => {
console.log('查看详情', job.id, job.positionName);
}}
>
<Text style={styles.detailLink}>查看详情 &gt;</Text>
</Pressable>
</View>
{/* 福利标签 */}
<View style={styles.benefits}>
{[job.experience, job.education, ...job.benefits].map((item) => (
<View key={item} style={styles.benefitTag}>
<Text style={styles.benefitText}>{item}</Text>
</View>
))}
</View>
</View>
);
}
/**
* 岗位列表页
*/
export default function JobsScreen() {
// 定位城市(点击可切换)
const [city, setCity] = useState('北京');
const [cityVisible, setCityVisible] = useState(false);
const [keyword, setKeyword] = useState('');
const [sort, setSort] = useState<SortType>('comprehensive');
// 更多筛选:已应用的筛选条件(多选,空数组 = 不限)
const [filters, setFilters] = useState<Record<FilterKey, string[]>>({
salary: [],
experience: [],
education: [],
});
// 筛选面板内的临时选择(点「确定」后才生效)
const [draft, setDraft] = useState<Record<FilterKey, string[]>>(filters);
const [filterVisible, setFilterVisible] = useState(false);
const { jobs, total, loading, error, refresh, isEmpty } = useJobList({
sortBy: sort,
city,
salary: filters.salary,
experience: filters.experience,
education: filters.education,
});
// 已勾选的筛选项总数(用于「更多筛选」角标)
const activeCount =
filters.salary.length + filters.experience.length + filters.education.length;
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
{/* 顶部导航:定位城市 + 标题 */}
<View style={styles.nav}>
<View style={styles.navSide}>
<Pressable style={styles.locationBtn} onPress={() => setCityVisible(true)}>
<Ionicons name="location-outline" size={18} color={colors.textPrimary} />
<Text style={styles.locationText}>{city}</Text>
<Ionicons name="chevron-down" size={14} color={colors.textSecondary} />
</Pressable>
</View>
<Text style={styles.navTitle}>找工作</Text>
<View style={styles.navSide} />
</View>
{/* 搜索框 */}
<View style={styles.searchBox}>
<Ionicons name="search" size={18} color={colors.textSecondary} />
<TextInput
style={styles.searchInput}
placeholder="搜索职位、公司、关键词"
placeholderTextColor={colors.textSecondary}
value={keyword}
onChangeText={setKeyword}
/>
</View>
{/* 筛选条件栏(横向滚动) */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.filterBar}
contentContainerStyle={styles.filterContent}
>
{FILTERS.map((filter) => (
<Pressable
key={filter}
style={styles.filterChip}
onPress={() => {
if (filter === '更多筛选') {
setDraft(filters);
setFilterVisible(true);
}
/* 其余筛选(行业/城市/薪资/经验)留待后续接入 */
}}
>
<Text style={styles.filterText}>{filter}</Text>
<Ionicons
name={filter === '更多筛选' ? 'options-outline' : 'chevron-down'}
size={14}
color={colors.textSecondary}
/>
{filter === '更多筛选' && activeCount > 0 && (
<View style={styles.filterBadge}>
<Text style={styles.filterBadgeText}>{activeCount}</Text>
</View>
)}
</Pressable>
))}
</ScrollView>
{/* 结果统计 + 排序 */}
<View style={styles.resultRow}>
<Text style={styles.resultCount}>共找到 {total} 个岗位</Text>
<View style={styles.sortRow}>
{SORTS.map((option) => (
<Pressable key={option.key} onPress={() => setSort(option.key)}>
<Text style={[styles.sortText, sort === option.key && styles.sortTextActive]}>
{option.label}
</Text>
</Pressable>
))}
</View>
</View>
{/* 岗位列表:统一处理加载 / 错误 / 空 / 成功四态 */}
{loading ? (
<View style={styles.stateBox}>
<ActivityIndicator size="large" color={colors.textSecondary} />
</View>
) : error ? (
<View style={styles.stateBox}>
<Text style={styles.stateText}>加载失败,请重试</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
) : isEmpty ? (
<View style={styles.stateBox}>
<Ionicons name="file-tray-outline" size={48} color={colors.textSecondary} />
<Text style={styles.stateText}>暂无相关岗位</Text>
</View>
) : (
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
>
{jobs.map((job) => (
<JobCard key={job.id} job={job} />
))}
</ScrollView>
)}
{/* 更多筛选面板(底部弹层) */}
<Modal
visible={filterVisible}
transparent
animationType="slide"
onRequestClose={() => setFilterVisible(false)}
>
<Pressable style={styles.modalOverlay} onPress={() => setFilterVisible(false)}>
<Pressable style={styles.filterPanel} onPress={() => {}}>
<View style={styles.panelHeader}>
<Text style={styles.panelTitle}>更多筛选</Text>
<Pressable onPress={() => setFilterVisible(false)}>
<Ionicons name="close" size={22} color={colors.textSecondary} />
</Pressable>
</View>
{/* 薪资范围 */}
<FilterGroup
title="薪资范围"
options={SALARY_RANGES.map((range) => range.label)}
selected={draft.salary}
onToggle={(value) => setDraft((d) => toggleValue(d, 'salary', value))}
onClear={() => setDraft((d) => clearGroup(d, 'salary'))}
/>
{/* 经验要求 */}
<FilterGroup
title="经验要求"
options={EXPERIENCE_OPTIONS}
selected={draft.experience}
onToggle={(value) => setDraft((d) => toggleValue(d, 'experience', value))}
onClear={() => setDraft((d) => clearGroup(d, 'experience'))}
/>
{/* 学历要求 */}
<FilterGroup
title="学历要求"
options={EDUCATION_OPTIONS}
selected={draft.education}
onToggle={(value) => setDraft((d) => toggleValue(d, 'education', value))}
onClear={() => setDraft((d) => clearGroup(d, 'education'))}
/>
{/* 底部操作 */}
<View style={styles.panelActions}>
<Pressable
style={styles.resetBtn}
onPress={() => setDraft({ salary: [], experience: [], education: [] })}
>
<Text style={styles.resetText}>重置</Text>
</Pressable>
<Pressable
style={styles.confirmBtn}
onPress={() => {
setFilters(draft);
setFilterVisible(false);
}}
>
<Text style={styles.confirmText}>确定</Text>
</Pressable>
</View>
</Pressable>
</Pressable>
</Modal>
{/* 城市选择弹层(底部弹层) */}
<Modal
visible={cityVisible}
transparent
animationType="slide"
onRequestClose={() => setCityVisible(false)}
>
<Pressable style={styles.modalOverlay} onPress={() => setCityVisible(false)}>
<Pressable style={styles.filterPanel} onPress={() => {}}>
<View style={styles.panelHeader}>
<Text style={styles.panelTitle}>选择城市</Text>
<Pressable onPress={() => setCityVisible(false)}>
<Ionicons name="close" size={22} color={colors.textSecondary} />
</Pressable>
</View>
<View style={styles.cityGrid}>
{CITIES.map((item) => (
<Pressable
key={item}
style={[styles.optionChip, city === item && styles.optionChipActive]}
onPress={() => {
setCity(item);
setCityVisible(false);
}}
>
<Text style={[styles.optionText, city === item && styles.optionTextActive]}>
{item}
</Text>
</Pressable>
))}
</View>
</Pressable>
</Pressable>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
nav: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 20,
paddingTop: 8,
paddingBottom: 8,
},
navSide: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
},
locationBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
locationText: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
},
navTitle: {
fontSize: 18,
fontWeight: '700',
color: colors.textPrimary,
},
searchBox: {
flexDirection: 'row',
alignItems: 'center',
height: 48,
borderRadius: 12,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 12,
marginHorizontal: 20,
marginTop: 4,
},
searchInput: {
flex: 1,
marginLeft: 8,
fontSize: 14,
color: colors.textPrimary,
},
filterBar: {
flexGrow: 0,
height: 32,
marginTop: 12,
},
filterContent: {
paddingHorizontal: 20,
gap: 12,
},
filterChip: {
flexDirection: 'row',
alignItems: 'center',
height: 32,
paddingHorizontal: 12,
borderRadius: 16,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
},
filterText: {
marginRight: 4,
fontSize: 13,
color: colors.textPrimary,
},
filterBadge: {
marginLeft: 4,
minWidth: 16,
height: 16,
borderRadius: 8,
paddingHorizontal: 4,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.textPrimary,
},
filterBadgeText: {
fontSize: 10,
fontWeight: '700',
color: colors.white,
},
resultRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
marginTop: 16,
marginBottom: 12,
},
resultCount: {
fontSize: 12,
color: colors.textSecondary,
},
sortRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
sortText: {
fontSize: 13,
color: colors.textSecondary,
},
sortTextActive: {
color: colors.textPrimary,
fontWeight: '700',
},
stateBox: {
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,
},
list: {
flex: 1,
},
listContent: {
paddingHorizontal: 20,
paddingBottom: 24,
},
card: {
backgroundColor: colors.white,
borderRadius: 16,
padding: 16,
marginBottom: 16,
shadowColor: colors.textPrimary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 3,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
companyName: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
companyLocation: {
fontSize: 12,
color: colors.textSecondary,
},
positionRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 8,
},
positionName: {
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
},
salary: {
fontSize: 15,
fontWeight: '700',
color: colors.primary,
},
scoreRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 10,
},
scoreText: {
fontSize: 13,
fontWeight: '600',
},
detailLink: {
fontSize: 13,
color: colors.primary,
},
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,
},
/* ---- 更多筛选面板 ---- */
modalOverlay: {
flex: 1,
backgroundColor: colors.scrim,
justifyContent: 'flex-end',
},
filterPanel: {
backgroundColor: colors.white,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingHorizontal: 20,
paddingTop: 16,
paddingBottom: 28,
},
panelHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 4,
},
panelTitle: {
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
filterLabel: {
marginTop: 16,
marginBottom: 10,
fontSize: 14,
fontWeight: '600',
color: colors.textPrimary,
},
optionRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
cityGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
marginTop: 16,
},
optionChip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: colors.pageBg,
borderWidth: 1,
borderColor: colors.border,
},
optionChipActive: {
backgroundColor: colors.surface,
borderColor: colors.borderStrong,
},
optionText: {
fontSize: 13,
color: colors.textPrimary,
},
optionTextActive: {
color: colors.textPrimary,
fontWeight: '700',
},
panelActions: {
flexDirection: 'row',
gap: 12,
marginTop: 24,
},
resetBtn: {
flex: 1,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.pageBg,
borderWidth: 1,
borderColor: colors.border,
},
resetText: {
fontSize: 15,
fontWeight: '600',
color: colors.textSecondary,
},
confirmBtn: {
flex: 1,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.primaryBtn,
},
confirmText: {
fontSize: 15,
fontWeight: '600',
color: colors.white,
},
});