import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { useState } from 'react'; import type { ReactNode } from 'react'; import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useJobManagement } from '../../src/hooks'; import { JOB_MANAGEMENT_SORTS, buildJobStatusAction, getJobStatusLabel } from '../../src/api'; import { ErrorState, LoadingState } from '../../src/components/StateView'; import { ENTERPRISE_DISPLAY_NAME } from '../../src/constants'; import { colors } from '../../src/theme'; import { formatCompact, formatDateTime } from '../../src/utils'; import type { JobManagement, JobManagementJob, JobManagementTab } from '../../src/types'; /* ===================== 子组件:顶部导航 ===================== */ function Header({ companyName, onBack }: { companyName: string; onBack: () => void }) { const router = useRouter(); return ( {companyName} 岗位管理 router.push('/job-publish')} hitSlop={6} > 发布 ); } /* ===================== 子组件:Tab 栏 ===================== */ function TabBar({ tabs, active, onChange, }: { tabs: JobManagementTab[]; active: string; onChange: (key: string) => void; }) { return ( {tabs.map((tab) => { const isActive = tab.key === active; return ( onChange(tab.key)}> {tab.label} ({tab.count}) ); })} ); } /* ===================== 子组件:数据概览 ===================== */ function StatsOverview({ stats }: { stats: JobManagement['stats'] }) { return ( {stats.map((stat) => { const token = stat.color === 'success' ? colors.success : stat.color === 'action' ? colors.action : stat.color === 'info' ? colors.info : colors.primary; const isUp = stat.change.startsWith('+'); const isDown = stat.change.startsWith('-'); const changeColor = isUp ? colors.success : isDown ? colors.action : colors.textSecondary; return ( {stat.value} {stat.label} {stat.change} ); })} ); } /* ===================== 子组件:搜索与筛选 ===================== */ function SearchFilterBar({ keyword, onChangeKeyword, sortLabel, onOpenSort, onOpenFilter, activeFilterCount, }: { keyword: string; onChangeKeyword: (value: string) => void; sortLabel: string; onOpenSort: () => void; onOpenFilter: () => void; activeFilterCount: number; }) { return ( {keyword.length > 0 && ( onChangeKeyword('')} hitSlop={8}> )} 筛选 {activeFilterCount > 0 && ( {activeFilterCount} )} {sortLabel} ); } /* ===================== 子组件:底部弹层 ===================== */ function Sheet({ visible, title, onClose, children, footer, }: { visible: boolean; title: string; onClose: () => void; children: ReactNode; footer?: ReactNode; }) { return ( {/* affordance-ok:吞冒泡,点弹层内容区不关闭 */} {}}> {title} {children} {footer ? {footer} : null} ); } /** 弹层里的一组多选筛选(「不限」= 清空该组) */ function FilterGroup({ title, options, selected, onToggle, onClear, }: { title: string; options: string[]; selected: string[]; onToggle: (value: string) => void; onClear: () => void; }) { return ( {title} {['不限', ...options].map((option) => { const isNone = option === '不限'; const active = isNone ? selected.length === 0 : selected.includes(option); return ( (isNone ? onClear() : onToggle(option))} > {option} ); })} ); } /* ===================== 子组件:岗位卡片 ===================== */ /** 状态角标:只有草稿与已下线才需要标出来,招聘中靠绿点即可。文案取自接口层 getJobStatusLabel */ /** 岗位卡片。编辑入口已摘除 —— 编辑态需要给发布页加预填数据,本轮未做,不留点了没反应的按钮 */ function JobCard({ job, refreshing, onRefresh, onToggleStatus, }: { job: JobManagementJob; /** 该岗位是否有写操作进行中 */ refreshing: boolean; onRefresh: (job: JobManagementJob) => void; onToggleStatus: (job: JobManagementJob) => void; }) { const isActive = job.status === 'active'; /** 状态切换动作(按钮图标/文案、确认框文案、目标状态)一律由接口层给出 */ const statusAction = buildJobStatusAction(job); return ( {job.title} {isActive ? : null} {!isActive && ( {getJobStatusLabel(job.status)} )} {job.salary} · {job.salaryMonths}薪 {job.city} · {job.experience} · {job.education} 综合评分 {job.overallScore} {job.rankText} {job.tags.map((tag) => ( {tag} ))} {job.applicants} 投递 {formatCompact(job.views)} 查看 {job.matches} 匹配 {isActive && ( onRefresh(job)} disabled={refreshing} > {job.refreshedAt ? '再次刷新' : '刷新'} )} onToggleStatus(job)} disabled={refreshing} > {/* 图标与文案同出一处(接口层的 buildJobStatusAction),不各写一半 */} {statusAction.label} {job.refreshedAt ? `最近刷新:${formatDateTime(job.refreshedAt)}` : `发布:${formatDateTime(job.publishedAt)}`} ); } /* ===================== 子组件:空状态 ===================== */ function EmptyState({ benefits, onPublish }: { benefits: string[]; onPublish: () => void }) { return ( 还没有发布岗位 发布你的第一个岗位,开始招聘优秀人才 发布岗位 发布岗位后你可以 {benefits.map((benefit, index) => ( {benefit} ))} ); } /* ===================== 页面 ===================== */ export default function JobManagementScreen() { const router = useRouter(); const { management, jobs, filterOptions, activeTab, setActiveTab, keyword, setKeyword, sortKey, setSortKey, filters, applyFilters, resetFilters, activeFilterCount, isFiltered, setStatus, refreshJobItem, mutatingId, loading, error, refresh, } = useJobManagement(); // 弹层内的临时选择(点「确定」后才生效),与 `app/(tabs)/jobs.tsx` 的筛选面板同一套做法 const [filterVisible, setFilterVisible] = useState(false); const [sortVisible, setSortVisible] = useState(false); const [draft, setDraft] = useState(filters); const sortLabel = JOB_MANAGEMENT_SORTS.find((s) => s.key === sortKey)?.label ?? '最新发布'; const openFilter = () => { setDraft(filters); setFilterVisible(true); }; /** 切换草稿里的某个筛选值(多选:已在则移除) */ const toggleDraft = (key: 'cities' | 'departments', value: string) => { setDraft((prev) => { const current = prev[key]; return { ...prev, [key]: current.includes(value) ? current.filter((v) => v !== value) : [...current, value], }; }); }; /** 下架 / 上架 / 重新上架。下架是破坏性操作,先二次确认。文案按岗位状态由接口层给出 */ const handleToggleStatus = (job: JobManagementJob) => { const action = buildJobStatusAction(job); Alert.alert(action.confirmTitle, action.confirmMessage, [ { text: '取消', style: 'cancel' }, { text: action.confirmButton, style: action.destructive ? 'destructive' : 'default', onPress: async () => { try { await setStatus(job.id, action.nextStatus); } catch { // 写入失败要如实告知,不能让用户以为已经生效 Alert.alert('操作失败', '岗位状态未能保存,请稍后重试'); } }, }, ]); }; const handleRefreshJob = async (job: JobManagementJob) => { try { await refreshJobItem(job.id); } catch { Alert.alert('操作失败', '刷新未能保存,请稍后重试'); } }; if (loading) { return (
router.back()} /> ); } if (error || !management) { return (
router.back()} /> ); } return (
router.back()} /> {management.hasData ? ( setSortVisible(true)} onOpenFilter={openFilter} activeFilterCount={activeFilterCount} /> {jobs.length > 0 ? ( jobs.map((job) => ( )) ) : ( /* 列表为空有两种原因,说清楚是哪一种,并给出对应的下一步 */ {isFiltered ? '没有符合条件的岗位' : '这个分类下还没有岗位'} {isFiltered && ( { setKeyword(''); resetFilters(); }} > 清空筛选条件 )} )} ) : ( router.push('/job-publish')} /> )} {/* 排序弹层 */} setSortVisible(false)} > {JOB_MANAGEMENT_SORTS.map((option) => ( { setSortKey(option.key); setSortVisible(false); }} > {option.label} {sortKey === option.key && ( )} ))} {/* 筛选弹层。选项来自真实岗位数据,不写死 */} setFilterVisible(false)} footer={ setDraft({ cities: [], departments: [] })}> 重置 { applyFilters(draft); setFilterVisible(false); }} > 确定 } > toggleDraft('cities', value)} onClear={() => setDraft((prev) => ({ ...prev, cities: [] }))} /> toggleDraft('departments', value)} onClear={() => setDraft((prev) => ({ ...prev, departments: [] }))} /> ); } 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 }, publishBtn: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.primaryBtn, paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16, }, publishBtnText: { color: colors.white, fontSize: 13, fontWeight: '600', marginLeft: 2, }, tabBar: { flexDirection: 'row', backgroundColor: colors.white, borderBottomWidth: 1, borderBottomColor: colors.borderLight, }, tabItem: { flex: 1, alignItems: 'center', paddingTop: 8, }, tabLabel: { fontSize: 14, 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: 24, borderRadius: 1, backgroundColor: 'transparent', marginBottom: -1, }, tabIndicatorActive: { backgroundColor: colors.primary, }, body: { padding: 16, paddingBottom: 24, }, center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12, }, tip: { fontSize: 14, color: colors.textSecondary }, retryBtn: { marginTop: 8, paddingHorizontal: 24, paddingVertical: 8, borderRadius: 18, backgroundColor: colors.primaryBtn, }, retryText: { color: colors.white, fontSize: 13, fontWeight: '600' }, statsGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, marginBottom: 12, }, statCard: { width: '48%', flexDirection: 'row', alignItems: 'center', backgroundColor: colors.white, borderRadius: 12, padding: 12, }, statIconWrap: { width: 40, height: 40, borderRadius: 10, alignItems: 'center', justifyContent: 'center', marginRight: 10, }, statBody: { flex: 1 }, statValue: { fontSize: 18, fontWeight: '800', color: colors.textPrimary, }, statLabel: { fontSize: 12, color: colors.textSecondary, marginTop: 2, }, statChangeWrap: { alignSelf: 'flex-start', }, statChange: { fontSize: 12, fontWeight: '600', }, searchBar: { flexDirection: 'row', alignItems: 'center', marginBottom: 12, gap: 8, }, searchInputWrap: { flex: 1, flexDirection: 'row', alignItems: 'center', backgroundColor: colors.white, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 8, }, searchInput: { flex: 1, marginLeft: 8, fontSize: 14, color: colors.textPrimary, }, filterBtn: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.white, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 8, }, filterBtnText: { fontSize: 13, color: colors.textPrimary, marginLeft: 4, }, sortBtn: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.white, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 8, }, sortBtnText: { fontSize: 13, color: colors.textPrimary, marginRight: 2, }, jobCard: { backgroundColor: colors.white, borderRadius: 12, padding: 16, marginBottom: 12, }, jobHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, jobTitleWrap: { flexDirection: 'row', alignItems: 'center', }, jobTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary, }, activeDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: colors.success, marginLeft: 8, }, jobSalary: { fontSize: 15, fontWeight: '700', color: colors.action, marginTop: 6, }, jobMeta: { fontSize: 13, color: colors.textSecondary, marginTop: 4, }, jobScoreRow: { flexDirection: 'row', alignItems: 'center', marginTop: 10, }, jobScoreLabel: { fontSize: 13, color: colors.textSecondary, }, jobScoreValue: { fontSize: 14, fontWeight: '800', color: colors.primary, marginLeft: 6, }, rankTag: { backgroundColor: colors.primaryLight, borderRadius: 8, paddingHorizontal: 8, paddingVertical: 2, marginLeft: 8, }, rankTagText: { fontSize: 11, color: colors.primaryText, fontWeight: '600', }, jobTags: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 10, }, jobTag: { backgroundColor: colors.pageBg, borderRadius: 6, paddingHorizontal: 8, paddingVertical: 3, }, jobTagText: { fontSize: 11, color: colors.textSecondary, }, jobStatsRow: { flexDirection: 'row', marginTop: 14, paddingTop: 14, borderTopWidth: 1, borderTopColor: colors.borderLight, }, jobStat: { flex: 1, alignItems: 'center', }, jobStatValue: { fontSize: 16, fontWeight: '700', color: colors.textPrimary, }, jobStatLabel: { fontSize: 11, color: colors.textSecondary, marginTop: 2, }, jobActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 12, gap: 12, }, jobActionBtn: { flexDirection: 'row', alignItems: 'center', }, jobActionBtnDisabled: { opacity: 0.4, }, jobActionText: { fontSize: 13, color: colors.primary, marginLeft: 4, }, jobActionTextAction: { color: colors.action, }, jobTimeHint: { marginTop: 10, fontSize: 11, color: colors.textHint, }, statusTag: { backgroundColor: colors.pageBg, borderRadius: 8, paddingHorizontal: 8, paddingVertical: 2, }, statusTagText: { fontSize: 11, color: colors.textSecondary, fontWeight: '600', }, /* ---- 筛选标记 ---- */ filterBadge: { minWidth: 16, height: 16, borderRadius: 8, backgroundColor: colors.primaryBtn, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 4, marginLeft: 4, }, filterBadgeText: { fontSize: 10, color: colors.white, fontWeight: '700', }, /* ---- 筛选结果为空 ---- */ emptyResult: { alignItems: 'center', paddingVertical: 48, gap: 12, }, emptyResultText: { fontSize: 14, color: colors.textSecondary, }, emptyResultBtn: { marginTop: 4, paddingHorizontal: 20, paddingVertical: 8, borderRadius: 18, backgroundColor: colors.primaryLight, }, emptyResultBtnText: { fontSize: 13, fontWeight: '600', color: colors.primaryText, }, /* ---- 底部弹层 ---- */ modalOverlay: { flex: 1, backgroundColor: colors.scrim, justifyContent: 'flex-end', }, sheetPanel: { backgroundColor: colors.white, borderTopLeftRadius: 16, borderTopRightRadius: 16, paddingBottom: 24, maxHeight: '80%', }, sheetHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: colors.borderLight, }, sheetTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary, }, sheetBody: { paddingHorizontal: 16, }, sheetFooter: { paddingHorizontal: 16, paddingTop: 12, }, sheetActions: { flexDirection: 'row', gap: 12, }, resetBtn: { flex: 1, alignItems: 'center', paddingVertical: 12, borderRadius: 22, borderWidth: 1, borderColor: colors.border, }, resetBtnText: { fontSize: 15, color: colors.textSecondary, fontWeight: '600', }, confirmBtn: { flex: 2, alignItems: 'center', paddingVertical: 12, borderRadius: 22, backgroundColor: colors.primaryBtn, }, confirmBtnText: { fontSize: 15, color: colors.white, fontWeight: '700', }, sortOption: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: colors.borderLight, }, sortOptionText: { fontSize: 15, color: colors.textPrimary, }, sortOptionTextActive: { color: colors.primaryText, fontWeight: '700', }, /* ---- 筛选分组 ---- */ filterGroup: { marginTop: 16, }, filterGroupTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginBottom: 10, }, optionRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, }, optionChip: { paddingHorizontal: 14, paddingVertical: 7, borderRadius: 16, backgroundColor: colors.pageBg, borderWidth: 1, borderColor: colors.borderLight, }, optionChipActive: { backgroundColor: colors.primaryLight, borderColor: colors.primary, }, optionText: { fontSize: 13, color: colors.textSecondary, }, optionTextActive: { color: colors.primaryText, fontWeight: '600', }, emptyCard: { backgroundColor: colors.white, borderRadius: 16, padding: 28, 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', }, benefitCard: { width: '100%', backgroundColor: colors.primaryLight, borderRadius: 12, padding: 16, alignItems: 'flex-start', }, benefitTitle: { fontSize: 14, fontWeight: '700', color: colors.primaryText, marginBottom: 12, }, benefitRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 10, }, benefitText: { fontSize: 13, color: colors.textPrimary, marginLeft: 10, }, });