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

483 lines
15 KiB

import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import React, { useState } from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useOperationRules } from '../src/hooks';
import { colors } from '../src/theme';
import type { MatchMode, OperationRules } from '../src/types';
/* ===================== 子组件:顶部导航 ===================== */
function Header({ onBack }: { onBack: () => void }) {
return (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text style={styles.headerTitle}>运营规则配置</Text>
<View style={styles.headerBtn} />
</View>
);
}
/* ===================== 子组件:匹配模式卡片 ===================== */
function MatchModeCard({
options,
value,
onChange,
}: {
options: OperationRules['matchModeOptions'];
value: MatchMode;
onChange: (mode: MatchMode) => void;
}) {
return (
<View style={styles.card}>
{options.map((opt) => {
const selected = opt.id === value;
return (
<Pressable
key={opt.id}
style={[styles.modeRow, selected && styles.modeRowSelected]}
onPress={() => onChange(opt.id)}
>
<View style={styles.modeTextWrap}>
<View style={styles.modeTitleRow}>
<Text style={[styles.modeName, selected && styles.modeNameSelected]}>
{opt.name}
</Text>
{opt.recommended && (
<View style={styles.recommendedTag}>
<Text style={styles.recommendedText}>推荐</Text>
</View>
)}
</View>
<Text style={styles.modeDesc}>{opt.desc}</Text>
</View>
<View style={[styles.radio, selected && styles.radioSelected]}>
{selected && <Ionicons name="checkmark" size={14} color={colors.textInverse} />}
</View>
</Pressable>
);
})}
</View>
);
}
/* ===================== 子组件:阈值滑块 ===================== */
function ThresholdRow({
label,
value,
suffix,
onChange,
}: {
label: string;
value: number;
suffix: string;
onChange: (val: number) => void;
}) {
const trackRef = React.useRef<View>(null);
const handlePress = (evt: { nativeEvent: { locationX: number } }) => {
trackRef.current?.measure((_x, _y, width) => {
const ratio = Math.max(0, Math.min(1, evt.nativeEvent.locationX / width));
onChange(Math.round(ratio * 100));
});
};
return (
<View style={styles.thresholdRow}>
<View style={styles.thresholdHeader}>
<Text style={styles.thresholdLabel}>{label}</Text>
<Text style={styles.thresholdValue}>
{value}
{suffix}
</Text>
</View>
<Pressable ref={trackRef} style={styles.sliderTrack} onPress={handlePress}>
<View style={[styles.sliderFill, { width: `${value}%` }]} />
<View style={[styles.sliderThumb, { left: `${value}%` }]} />
</Pressable>
<View style={styles.sliderLabels}>
<Text style={styles.sliderLabel}>0{suffix}</Text>
<Text style={styles.sliderLabel}>50{suffix}</Text>
<Text style={styles.sliderLabel}>100{suffix}</Text>
</View>
</View>
);
}
/* ===================== 子组件:开关行 ===================== */
function ToggleRow({
label,
desc,
value,
onChange,
}: {
label: string;
desc?: string;
value: boolean;
onChange: (val: boolean) => void;
}) {
return (
<View style={styles.toggleRow}>
<View style={styles.toggleText}>
<Text style={styles.toggleLabel}>{label}</Text>
{desc ? <Text style={styles.toggleDesc}>{desc}</Text> : null}
</View>
<Switch
value={value}
onValueChange={onChange}
trackColor={{ false: colors.border, true: colors.primaryLight }}
thumbColor={value ? colors.primary : colors.white}
ios_backgroundColor={colors.border}
/>
</View>
);
}
/* ===================== 页面 ===================== */
export default function OperationRulesScreen() {
const router = useRouter();
const { rules, loading, error, refresh, saving, updateRules } = useOperationRules();
const [localLimit, setLocalLimit] = useState<string>('');
if (loading || !rules) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<View style={styles.center}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
if (error) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<View style={styles.center}>
<Text style={styles.tip}>加载失败</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
const handleModeChange = (mode: MatchMode) => {
updateRules((prev) => ({ ...prev, matchMode: mode }));
};
const handleThresholdChange = (field: 'matchThreshold' | 'visibilityThreshold', val: number) => {
updateRules((prev) => ({ ...prev, [field]: val }));
};
const handleFilterToggle = (id: string, enabled: boolean) => {
updateRules((prev) => ({
...prev,
autoFilters: prev.autoFilters.map((f) => (f.id === id ? { ...f, enabled } : f)),
}));
};
const handleLimitSubmit = () => {
const num = parseInt(localLimit, 10);
if (!Number.isNaN(num) && num > 0) {
updateRules((prev) => ({ ...prev, dailyRecommendLimit: num }));
setLocalLimit('');
}
};
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
{/* 匹配模式 */}
<SectionTitle title="匹配模式" />
<MatchModeCard
options={rules.matchModeOptions}
value={rules.matchMode}
onChange={handleModeChange}
/>
{/* 双向匹配配置 */}
<SectionTitle title="双向匹配配置" />
<View style={styles.card}>
<ThresholdRow
label="双向匹配阈值"
value={rules.matchThreshold}
suffix="%"
onChange={(val) => handleThresholdChange('matchThreshold', val)}
/>
<View style={styles.divider} />
<ThresholdRow
label="候选人可见度阈值"
value={rules.visibilityThreshold}
suffix="%"
onChange={(val) => handleThresholdChange('visibilityThreshold', val)}
/>
<View style={styles.divider} />
<View style={styles.limitRow}>
<Text style={styles.limitLabel}>每日推荐数量上限</Text>
<View style={styles.limitInputWrap}>
<TextInput
style={styles.limitInput}
keyboardType="number-pad"
placeholder={`${rules.dailyRecommendLimit}`}
placeholderTextColor={colors.textHint}
value={localLimit}
onChangeText={setLocalLimit}
onBlur={handleLimitSubmit}
/>
<Text style={styles.limitUnit}>人/天</Text>
</View>
</View>
</View>
{/* 自动筛选规则 */}
<SectionTitle title="自动筛选规则" />
<View style={styles.card}>
{rules.autoFilters.map((filter, idx) => {
const isLast = idx === rules.autoFilters.length - 1;
return (
<View key={filter.id} style={[styles.filterRow, isLast && styles.filterRowLast]}>
<View style={styles.filterText}>
<Text style={styles.filterName}>{filter.name}</Text>
<Text style={styles.filterDesc}>{filter.description}</Text>
</View>
<Switch
value={filter.enabled}
onValueChange={(v) => handleFilterToggle(filter.id, v)}
trackColor={{ false: colors.border, true: colors.primaryLight }}
thumbColor={filter.enabled ? colors.primary : colors.white}
ios_backgroundColor={colors.border}
/>
</View>
);
})}
</View>
{/* 推荐规则 */}
<SectionTitle title="推荐规则" />
<View style={styles.card}>
<ToggleRow
label="急招岗位优先推荐"
value={rules.urgentPriority}
onChange={(v) => updateRules((prev) => ({ ...prev, urgentPriority: v }))}
/>
<View style={styles.divider} />
<ToggleRow
label="校园岗位优先推荐"
value={rules.campusPriority}
onChange={(v) => updateRules((prev) => ({ ...prev, campusPriority: v }))}
/>
</View>
{/* 通知设置 */}
<SectionTitle title="通知设置" />
<View style={styles.card}>
<ToggleRow
label="新候选人投递通知"
value={rules.notifyNewDelivery}
onChange={(v) => updateRules((prev) => ({ ...prev, notifyNewDelivery: v }))}
/>
</View>
<View style={styles.bottomPad} />
</ScrollView>
{saving && (
<View style={styles.savingToast}>
<ActivityIndicator size="small" color={colors.textInverse} />
<Text style={styles.savingText}>保存中…</Text>
</View>
)}
</SafeAreaView>
);
}
function SectionTitle({ title }: { title: string }) {
return (
<View style={styles.sectionHeader}>
<View style={styles.sectionBar} />
<Text style={styles.sectionTitle}>{title}</Text>
</View>
);
}
const styles = StyleSheet.create({
safeArea: { flex: 1, backgroundColor: colors.pageBg },
header: {
height: 44,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
backgroundColor: colors.white,
},
headerBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
headerTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary },
scroll: { flex: 1 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
tip: { fontSize: 14, color: colors.textSecondary },
retryBtn: {
marginTop: 12,
paddingHorizontal: 18,
paddingVertical: 8,
borderRadius: 16,
backgroundColor: colors.primaryLight,
},
retryText: { fontSize: 13, fontWeight: '600', color: colors.primaryText },
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 20,
marginBottom: 10,
paddingHorizontal: 12,
},
sectionBar: { width: 4, height: 16, borderRadius: 2, backgroundColor: colors.primary, marginRight: 8 },
sectionTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary },
card: {
marginHorizontal: 12,
backgroundColor: colors.white,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 8,
},
modeRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderLight,
},
modeRowSelected: { backgroundColor: colors.primaryLight },
modeTextWrap: { flex: 1, marginRight: 12 },
modeTitleRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 4 },
modeName: { fontSize: 15, fontWeight: '600', color: colors.textPrimary },
modeNameSelected: { color: colors.primaryText },
recommendedTag: {
marginLeft: 8,
backgroundColor: colors.successLight,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
},
recommendedText: { fontSize: 10, fontWeight: '600', color: colors.successText },
modeDesc: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
radio: {
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
borderColor: colors.borderStrong,
alignItems: 'center',
justifyContent: 'center',
},
radioSelected: { backgroundColor: colors.primary, borderColor: colors.primary },
thresholdRow: { paddingVertical: 14 },
thresholdHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
thresholdLabel: { fontSize: 14, color: colors.textPrimary },
thresholdValue: { fontSize: 15, fontWeight: '700', color: colors.primary },
sliderTrack: {
height: 6,
borderRadius: 3,
backgroundColor: colors.border,
marginTop: 12,
overflow: 'hidden',
},
sliderFill: {
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
backgroundColor: colors.primary,
borderRadius: 3,
},
sliderThumb: {
position: 'absolute',
top: -5,
marginLeft: -8,
width: 16,
height: 16,
borderRadius: 8,
backgroundColor: colors.white,
borderWidth: 2,
borderColor: colors.primary,
},
sliderLabels: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 8,
},
sliderLabel: { fontSize: 11, color: colors.textHint },
divider: { height: StyleSheet.hairlineWidth, backgroundColor: colors.borderLight },
limitRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
},
limitLabel: { fontSize: 14, color: colors.textPrimary },
limitInputWrap: { flexDirection: 'row', alignItems: 'center' },
limitInput: {
width: 56,
height: 32,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 8,
textAlign: 'center',
fontSize: 14,
color: colors.textPrimary,
padding: 0,
},
limitUnit: { marginLeft: 6, fontSize: 13, color: colors.textSecondary },
filterRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderLight,
},
filterRowLast: { borderBottomWidth: 0 },
filterText: { flex: 1, marginRight: 12 },
filterName: { fontSize: 14, fontWeight: '500', color: colors.textPrimary },
filterDesc: { fontSize: 12, color: colors.textSecondary, marginTop: 2 },
toggleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
},
toggleText: { flex: 1, marginRight: 12 },
toggleLabel: { fontSize: 14, fontWeight: '500', color: colors.textPrimary },
toggleDesc: { fontSize: 12, color: colors.textSecondary, marginTop: 2 },
bottomPad: { height: 24 },
savingToast: {
position: 'absolute',
bottom: 24,
alignSelf: 'center',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.textPrimary,
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 18,
},
savingText: { marginLeft: 8, fontSize: 13, color: colors.textInverse },
});