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
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 },
|
|
});
|
|
|