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.
1119 lines
30 KiB
1119 lines
30 KiB
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 (
|
|
<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.publishBtn}
|
|
onPress={() => router.push('/job-publish')}
|
|
hitSlop={6}
|
|
>
|
|
<Ionicons name="add" size={16} color={colors.white} />
|
|
<Text style={styles.publishBtnText}>发布</Text>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:Tab 栏 ===================== */
|
|
|
|
function TabBar({
|
|
tabs,
|
|
active,
|
|
onChange,
|
|
}: {
|
|
tabs: JobManagementTab[];
|
|
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 StatsOverview({ stats }: { stats: JobManagement['stats'] }) {
|
|
return (
|
|
<View style={styles.statsGrid}>
|
|
{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 (
|
|
<View key={stat.id} style={styles.statCard}>
|
|
<View style={[styles.statIconWrap, { backgroundColor: token + '15' }]}>
|
|
<Ionicons name={stat.icon as keyof typeof Ionicons.glyphMap} size={22} color={token} />
|
|
</View>
|
|
<View style={styles.statBody}>
|
|
<Text style={styles.statValue}>{stat.value}</Text>
|
|
<Text style={styles.statLabel}>{stat.label}</Text>
|
|
</View>
|
|
<View style={styles.statChangeWrap}>
|
|
<Text style={[styles.statChange, { color: changeColor }]}>{stat.change}</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:搜索与筛选 ===================== */
|
|
|
|
function SearchFilterBar({
|
|
keyword,
|
|
onChangeKeyword,
|
|
sortLabel,
|
|
onOpenSort,
|
|
onOpenFilter,
|
|
activeFilterCount,
|
|
}: {
|
|
keyword: string;
|
|
onChangeKeyword: (value: string) => void;
|
|
sortLabel: string;
|
|
onOpenSort: () => void;
|
|
onOpenFilter: () => void;
|
|
activeFilterCount: number;
|
|
}) {
|
|
return (
|
|
<View style={styles.searchBar}>
|
|
<View style={styles.searchInputWrap}>
|
|
<Ionicons name="search-outline" size={18} color={colors.textHint} />
|
|
<TextInput
|
|
style={styles.searchInput}
|
|
placeholder="搜索岗位名称..."
|
|
placeholderTextColor={colors.textHint}
|
|
value={keyword}
|
|
onChangeText={onChangeKeyword}
|
|
returnKeyType="search"
|
|
/>
|
|
{keyword.length > 0 && (
|
|
<Pressable onPress={() => onChangeKeyword('')} hitSlop={8}>
|
|
<Ionicons name="close-circle" size={16} color={colors.textHint} />
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
<Pressable style={styles.filterBtn} onPress={onOpenFilter}>
|
|
<Ionicons name="funnel-outline" size={18} color={colors.textPrimary} />
|
|
<Text style={styles.filterBtnText}>筛选</Text>
|
|
{activeFilterCount > 0 && (
|
|
<View style={styles.filterBadge}>
|
|
<Text style={styles.filterBadgeText}>{activeFilterCount}</Text>
|
|
</View>
|
|
)}
|
|
</Pressable>
|
|
<Pressable style={styles.sortBtn} onPress={onOpenSort}>
|
|
<Text style={styles.sortBtnText}>{sortLabel}</Text>
|
|
<Ionicons name="chevron-down" size={14} color={colors.textHint} />
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:底部弹层 ===================== */
|
|
|
|
function Sheet({
|
|
visible,
|
|
title,
|
|
onClose,
|
|
children,
|
|
footer,
|
|
}: {
|
|
visible: boolean;
|
|
title: string;
|
|
onClose: () => void;
|
|
children: ReactNode;
|
|
footer?: ReactNode;
|
|
}) {
|
|
return (
|
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
|
<Pressable style={styles.modalOverlay} onPress={onClose}>
|
|
{/* affordance-ok:吞冒泡,点弹层内容区不关闭 */}
|
|
<Pressable style={styles.sheetPanel} onPress={() => {}}>
|
|
<View style={styles.sheetHeader}>
|
|
<Text style={styles.sheetTitle}>{title}</Text>
|
|
<Pressable onPress={onClose} hitSlop={8}>
|
|
<Ionicons name="close" size={22} color={colors.textSecondary} />
|
|
</Pressable>
|
|
</View>
|
|
<ScrollView showsVerticalScrollIndicator={false} style={styles.sheetBody}>
|
|
{children}
|
|
</ScrollView>
|
|
{footer ? <View style={styles.sheetFooter}>{footer}</View> : null}
|
|
</Pressable>
|
|
</Pressable>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
/** 弹层里的一组多选筛选(「不限」= 清空该组) */
|
|
function FilterGroup({
|
|
title,
|
|
options,
|
|
selected,
|
|
onToggle,
|
|
onClear,
|
|
}: {
|
|
title: string;
|
|
options: string[];
|
|
selected: string[];
|
|
onToggle: (value: string) => void;
|
|
onClear: () => void;
|
|
}) {
|
|
return (
|
|
<View style={styles.filterGroup}>
|
|
<Text style={styles.filterGroupTitle}>{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>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:岗位卡片 ===================== */
|
|
|
|
/** 状态角标:只有草稿与已下线才需要标出来,招聘中靠绿点即可。文案取自接口层 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 (
|
|
<View style={styles.jobCard}>
|
|
<View style={styles.jobHeader}>
|
|
<View style={styles.jobTitleWrap}>
|
|
<Text style={styles.jobTitle}>{job.title}</Text>
|
|
{isActive ? <View style={styles.activeDot} /> : null}
|
|
</View>
|
|
{!isActive && (
|
|
<View style={styles.statusTag}>
|
|
<Text style={styles.statusTagText}>{getJobStatusLabel(job.status)}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
<Text style={styles.jobSalary}>
|
|
{job.salary} · {job.salaryMonths}薪
|
|
</Text>
|
|
<Text style={styles.jobMeta}>{job.city} · {job.experience} · {job.education}</Text>
|
|
|
|
<View style={styles.jobScoreRow}>
|
|
<Text style={styles.jobScoreLabel}>综合评分</Text>
|
|
<Text style={styles.jobScoreValue}>{job.overallScore}</Text>
|
|
<View style={styles.rankTag}>
|
|
<Text style={styles.rankTagText}>{job.rankText}</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.jobTags}>
|
|
{job.tags.map((tag) => (
|
|
<View key={tag} style={styles.jobTag}>
|
|
<Text style={styles.jobTagText}>{tag}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
|
|
<View style={styles.jobStatsRow}>
|
|
<View style={styles.jobStat}>
|
|
<Text style={styles.jobStatValue}>{job.applicants}</Text>
|
|
<Text style={styles.jobStatLabel}>投递</Text>
|
|
</View>
|
|
<View style={styles.jobStat}>
|
|
<Text style={styles.jobStatValue}>{formatCompact(job.views)}</Text>
|
|
<Text style={styles.jobStatLabel}>查看</Text>
|
|
</View>
|
|
<View style={styles.jobStat}>
|
|
<Text style={styles.jobStatValue}>{job.matches}</Text>
|
|
<Text style={styles.jobStatLabel}>匹配</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.jobActions}>
|
|
{isActive && (
|
|
<Pressable
|
|
style={[styles.jobActionBtn, refreshing && styles.jobActionBtnDisabled]}
|
|
onPress={() => onRefresh(job)}
|
|
disabled={refreshing}
|
|
>
|
|
<Ionicons name="refresh-outline" size={16} color={colors.primary} />
|
|
<Text style={styles.jobActionText}>
|
|
{job.refreshedAt ? '再次刷新' : '刷新'}
|
|
</Text>
|
|
</Pressable>
|
|
)}
|
|
<Pressable
|
|
style={[styles.jobActionBtn, refreshing && styles.jobActionBtnDisabled]}
|
|
onPress={() => onToggleStatus(job)}
|
|
disabled={refreshing}
|
|
>
|
|
{/* 图标与文案同出一处(接口层的 buildJobStatusAction),不各写一半 */}
|
|
<Ionicons
|
|
name={statusAction.icon as keyof typeof Ionicons.glyphMap}
|
|
size={16}
|
|
color={statusAction.destructive ? colors.action : colors.primary}
|
|
/>
|
|
<Text style={[styles.jobActionText, statusAction.destructive && styles.jobActionTextAction]}>
|
|
{statusAction.label}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
<Text style={styles.jobTimeHint}>
|
|
{job.refreshedAt
|
|
? `最近刷新:${formatDateTime(job.refreshedAt)}`
|
|
: `发布:${formatDateTime(job.publishedAt)}`}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:空状态 ===================== */
|
|
|
|
function EmptyState({ benefits, onPublish }: { benefits: string[]; onPublish: () => void }) {
|
|
return (
|
|
<View style={styles.emptyCard}>
|
|
<View style={styles.emptyIconWrap}>
|
|
<Ionicons name="business-outline" size={48} color={colors.primary} />
|
|
</View>
|
|
<Text style={styles.emptyTitle}>还没有发布岗位</Text>
|
|
<Text style={styles.emptySubtitle}>发布你的第一个岗位,开始招聘优秀人才</Text>
|
|
<Pressable style={styles.emptyBtn} onPress={onPublish} hitSlop={6}>
|
|
<Text style={styles.emptyBtnText}>发布岗位</Text>
|
|
</Pressable>
|
|
|
|
<View style={styles.benefitCard}>
|
|
<Text style={styles.benefitTitle}>发布岗位后你可以</Text>
|
|
{benefits.map((benefit, index) => (
|
|
<View key={index} style={styles.benefitRow}>
|
|
<Ionicons name="checkmark-circle" size={18} color={colors.success} />
|
|
<Text style={styles.benefitText}>{benefit}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 页面 ===================== */
|
|
|
|
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 (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<Header companyName={ENTERPRISE_DISPLAY_NAME} onBack={() => router.back()} />
|
|
<LoadingState />
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
if (error || !management) {
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<Header companyName={ENTERPRISE_DISPLAY_NAME} onBack={() => router.back()} />
|
|
<ErrorState onRetry={refresh} />
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
|
<Header companyName={management.companyName} onBack={() => router.back()} />
|
|
<TabBar tabs={management.tabs} active={activeTab} onChange={setActiveTab} />
|
|
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
|
|
{management.hasData ? (
|
|
<View>
|
|
<StatsOverview stats={management.stats} />
|
|
<SearchFilterBar
|
|
keyword={keyword}
|
|
onChangeKeyword={setKeyword}
|
|
sortLabel={sortLabel}
|
|
onOpenSort={() => setSortVisible(true)}
|
|
onOpenFilter={openFilter}
|
|
activeFilterCount={activeFilterCount}
|
|
/>
|
|
{jobs.length > 0 ? (
|
|
jobs.map((job) => (
|
|
<JobCard
|
|
key={job.id}
|
|
job={job}
|
|
refreshing={mutatingId === job.id}
|
|
onRefresh={handleRefreshJob}
|
|
onToggleStatus={handleToggleStatus}
|
|
/>
|
|
))
|
|
) : (
|
|
/* 列表为空有两种原因,说清楚是哪一种,并给出对应的下一步 */
|
|
<View style={styles.emptyResult}>
|
|
<Ionicons name="search-outline" size={40} color={colors.textHint} />
|
|
<Text style={styles.emptyResultText}>
|
|
{isFiltered ? '没有符合条件的岗位' : '这个分类下还没有岗位'}
|
|
</Text>
|
|
{isFiltered && (
|
|
<Pressable
|
|
style={styles.emptyResultBtn}
|
|
onPress={() => {
|
|
setKeyword('');
|
|
resetFilters();
|
|
}}
|
|
>
|
|
<Text style={styles.emptyResultBtnText}>清空筛选条件</Text>
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
) : (
|
|
<EmptyState
|
|
benefits={management.benefits}
|
|
onPublish={() => router.push('/job-publish')}
|
|
/>
|
|
)}
|
|
</ScrollView>
|
|
|
|
{/* 排序弹层 */}
|
|
<Sheet
|
|
visible={sortVisible}
|
|
title="排序方式"
|
|
onClose={() => setSortVisible(false)}
|
|
>
|
|
{JOB_MANAGEMENT_SORTS.map((option) => (
|
|
<Pressable
|
|
key={option.key}
|
|
style={styles.sortOption}
|
|
onPress={() => {
|
|
setSortKey(option.key);
|
|
setSortVisible(false);
|
|
}}
|
|
>
|
|
<Text
|
|
style={[styles.sortOptionText, sortKey === option.key && styles.sortOptionTextActive]}
|
|
>
|
|
{option.label}
|
|
</Text>
|
|
{sortKey === option.key && (
|
|
<Ionicons name="checkmark" size={18} color={colors.primary} />
|
|
)}
|
|
</Pressable>
|
|
))}
|
|
</Sheet>
|
|
|
|
{/* 筛选弹层。选项来自真实岗位数据,不写死 */}
|
|
<Sheet
|
|
visible={filterVisible}
|
|
title="筛选岗位"
|
|
onClose={() => setFilterVisible(false)}
|
|
footer={
|
|
<View style={styles.sheetActions}>
|
|
<Pressable style={styles.resetBtn} onPress={() => setDraft({ cities: [], departments: [] })}>
|
|
<Text style={styles.resetBtnText}>重置</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.confirmBtn}
|
|
onPress={() => {
|
|
applyFilters(draft);
|
|
setFilterVisible(false);
|
|
}}
|
|
>
|
|
<Text style={styles.confirmBtnText}>确定</Text>
|
|
</Pressable>
|
|
</View>
|
|
}
|
|
>
|
|
<FilterGroup
|
|
title="工作城市"
|
|
options={filterOptions.cities}
|
|
selected={draft.cities}
|
|
onToggle={(value) => toggleDraft('cities', value)}
|
|
onClear={() => setDraft((prev) => ({ ...prev, cities: [] }))}
|
|
/>
|
|
<FilterGroup
|
|
title="所属部门"
|
|
options={filterOptions.departments}
|
|
selected={draft.departments}
|
|
onToggle={(value) => toggleDraft('departments', value)}
|
|
onClear={() => setDraft((prev) => ({ ...prev, departments: [] }))}
|
|
/>
|
|
</Sheet>
|
|
</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 },
|
|
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,
|
|
},
|
|
});
|
|
|