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 { colors } from '../../src/theme';
import { 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}
投递
{job.views >= 1000 ? `${(job.views / 1000).toFixed(1)}k` : 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 (
);
}
if (error || !management) {
return (
);
}
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,
},
});