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

633 lines
18 KiB

import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useJobPublish } from '../../src/hooks';
import { colors } from '../../src/theme';
import type { JobPublishFormData } from '../../src/types';
const WORK_TYPE_OPTIONS: { label: string; value: JobPublishFormData['workType'] }[] = [
{ label: '全职', value: 'full-time' },
{ label: '兼职', value: 'part-time' },
{ label: '实习', value: 'intern' },
{ label: '远程', value: 'remote' },
];
const EDUCATION_OPTIONS: JobPublishFormData['education'][] = ['不限', '大专', '本科', '硕士', '博士'];
const EXPERIENCE_OPTIONS: JobPublishFormData['experience'][] = [
'应届生',
'1年以内',
'1-3年',
'3-5年',
'5-10年',
'10年以上',
];
const BENEFIT_OPTIONS = ['五险一金', '年终奖金', '股票期权', '带薪年假', '弹性工作', '餐补', '交通补助', '定期体检'];
const SKILL_OPTIONS = ['React', 'Vue', 'TypeScript', 'Node.js', 'Webpack', 'Git', 'Python', 'Java', 'Go', 'SQL'];
/* ===================== 子组件:顶部导航 ===================== */
function Header({ onBack, onSave }: { onBack: () => void; onSave: () => 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>
<Pressable style={styles.headerSaveBtn} onPress={onSave} hitSlop={8}>
<Text style={styles.headerSaveText}>保存</Text>
</Pressable>
</View>
);
}
/* ===================== 子组件:表单项 ===================== */
function Field({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) {
return (
<View style={styles.field}>
<Text style={styles.fieldLabel}>
{label}
{required && <Text style={styles.required}> *</Text>}
</Text>
{children}
</View>
);
}
function Input({ value, onChangeText, placeholder, multiline }: {
value: string;
onChangeText: (text: string) => void;
placeholder?: string;
multiline?: boolean;
}) {
return (
<TextInput
style={[styles.input, multiline && styles.textarea]}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={colors.textHint}
multiline={multiline}
textAlignVertical={multiline ? 'top' : 'center'}
/>
);
}
function NumberInput({ value, onChangeText, placeholder }: {
value: number;
onChangeText: (text: string) => void;
placeholder?: string;
}) {
return (
<TextInput
style={styles.input}
value={value ? String(value) : ''}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={colors.textHint}
keyboardType="numeric"
/>
);
}
/* ===================== 子组件:单选标签组 ===================== */
function RadioGroup<T extends string>({
options,
value,
onChange,
}: {
options: T[] | { label: string; value: T }[];
value: T;
onChange: (value: T) => void;
}) {
return (
<View style={styles.tagRow}>
{options.map((opt) => {
const optionValue = typeof opt === 'string' ? opt : opt.value;
const optionLabel = typeof opt === 'string' ? opt : opt.label;
const selected = optionValue === value;
return (
<Pressable
key={optionValue}
style={[styles.radioTag, selected && styles.radioTagActive]}
onPress={() => onChange(optionValue)}
>
<Text style={[styles.radioText, selected && styles.radioTextActive]}>{optionLabel}</Text>
</Pressable>
);
})}
</View>
);
}
/* ===================== 子组件:多选标签组 ===================== */
function CheckboxGroup({ options, values, onToggle }: { options: string[]; values: string[]; onToggle: (v: string) => void }) {
return (
<View style={styles.tagRow}>
{options.map((opt) => {
const selected = values.includes(opt);
return (
<Pressable
key={opt}
style={[styles.checkboxTag, selected && styles.checkboxTagActive]}
onPress={() => onToggle(opt)}
>
{selected && <Ionicons name="checkmark" size={12} color={colors.white} />}
<Text style={[styles.checkboxText, selected && styles.checkboxTextActive]}>{opt}</Text>
</Pressable>
);
})}
</View>
);
}
/* ===================== 子组件:开关行 ===================== */
function SwitchRow({ label, value, onValueChange }: { label: string; value: boolean; onValueChange: (v: boolean) => void }) {
return (
<View style={styles.switchRow}>
<Text style={styles.switchLabel}>{label}</Text>
<Switch value={value} onValueChange={onValueChange} trackColor={{ false: colors.border, true: colors.primary }} thumbColor={colors.white} />
</View>
);
}
/* ===================== 子组件:质量预估卡 ===================== */
function QualityEstimateCard({ score, level, suggestions, estimating }: {
score: number;
level: string;
suggestions: string[];
estimating: boolean;
}) {
return (
<View style={[styles.card, styles.qualityCard]}>
<View style={styles.qualityHeader}>
<View>
<Text style={styles.cardTitle}>岗位质量预估</Text>
<Text style={styles.qualitySub}>AI 实时评估岗位信息完整度</Text>
</View>
<View style={styles.qualityScoreBox}>
{estimating ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<>
<Text style={styles.qualityScore}>{score}</Text>
<Text style={styles.qualityLevel}>{level}</Text>
</>
)}
</View>
</View>
{suggestions.length > 0 && (
<View style={styles.suggestionList}>
{suggestions.map((s, i) => (
<View key={i} style={styles.suggestionItem}>
<Ionicons name="bulb-outline" size={14} color={colors.info} />
<Text style={styles.suggestionText}>{s}</Text>
</View>
))}
</View>
)}
</View>
);
}
/* ===================== 页面主组件 ===================== */
export default function JobPublishScreen() {
const router = useRouter();
const {
form,
loading,
error,
updateStringField,
updateNumberField,
updateField,
toggleBenefit,
toggleSkill,
estimate,
estimating,
runEstimate,
publishing,
publish,
} = useJobPublish();
if (loading) {
return (
<SafeAreaView style={styles.loadingContainer} edges={['top']}>
<ActivityIndicator size="large" color={colors.primary} />
</SafeAreaView>
);
}
if (error || !form) {
return (
<SafeAreaView style={styles.loadingContainer} edges={['top']}>
<Text style={styles.errorText}>加载失败,请稍后重试</Text>
</SafeAreaView>
);
}
return (
<View style={styles.container}>
<Header onBack={() => router.back()} onSave={() => {}} />
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{/* 基本信息 */}
<View style={styles.card}>
<Text style={styles.cardTitle}>基本信息</Text>
<Field label="岗位名称" required>
<Input value={form.positionName} onChangeText={(v) => updateStringField('positionName', v)} placeholder="请输入岗位名称" />
</Field>
<Field label="所属部门" required>
<Input value={form.department} onChangeText={(v) => updateStringField('department', v)} placeholder="请输入所属部门" />
</Field>
<Field label="招聘人数" required>
<NumberInput value={form.headcount} onChangeText={(v) => updateNumberField('headcount', v)} placeholder="请输入招聘人数" />
</Field>
<Field label="工作城市" required>
<Input value={form.city} onChangeText={(v) => updateStringField('city', v)} placeholder="请输入工作城市" />
</Field>
<Field label="详细地址">
<Input value={form.address} onChangeText={(v) => updateStringField('address', v)} placeholder="请输入详细地址" />
</Field>
<Field label="工作类型" required>
<RadioGroup<JobPublishFormData['workType']>
options={WORK_TYPE_OPTIONS}
value={form.workType}
onChange={(v) => updateField('workType', v)}
/>
</Field>
</View>
{/* 岗位要求 */}
<View style={styles.card}>
<Text style={styles.cardTitle}>岗位要求</Text>
<Field label="学历要求">
<RadioGroup<JobPublishFormData['education']>
options={EDUCATION_OPTIONS}
value={form.education}
onChange={(v) => updateField('education', v)}
/>
</Field>
<Field label="经验要求">
<RadioGroup<JobPublishFormData['experience']>
options={EXPERIENCE_OPTIONS}
value={form.experience}
onChange={(v) => updateField('experience', v)}
/>
</Field>
<Field label="技能要求">
<CheckboxGroup options={SKILL_OPTIONS} values={form.skills} onToggle={toggleSkill} />
</Field>
</View>
{/* 薪资福利 */}
<View style={styles.card}>
<Text style={styles.cardTitle}>薪资福利</Text>
<View style={styles.salaryRow}>
<Field label="最低薪资(K)">
<NumberInput value={form.salaryMin} onChangeText={(v) => updateNumberField('salaryMin', v)} placeholder="最低" />
</Field>
<Text style={styles.salarySeparator}>-</Text>
<Field label="最高薪资(K)">
<NumberInput value={form.salaryMax} onChangeText={(v) => updateNumberField('salaryMax', v)} placeholder="最高" />
</Field>
<Field label="薪资月数">
<NumberInput value={form.salaryMonths} onChangeText={(v) => updateNumberField('salaryMonths', v)} placeholder="如 14" />
</Field>
</View>
<Field label="福利标签">
<CheckboxGroup options={BENEFIT_OPTIONS} values={form.benefits} onToggle={toggleBenefit} />
</Field>
</View>
{/* 岗位描述 */}
<View style={styles.card}>
<Text style={styles.cardTitle}>岗位描述</Text>
<Field label="岗位职责" required>
<Input value={form.responsibilities} onChangeText={(v) => updateStringField('responsibilities', v)} placeholder="请输入岗位职责" multiline />
</Field>
<Field label="岗位要求" required>
<Input value={form.requirements} onChangeText={(v) => updateStringField('requirements', v)} placeholder="请输入岗位要求" multiline />
</Field>
</View>
{/* 岗位设置 */}
<View style={styles.card}>
<Text style={styles.cardTitle}>岗位设置</Text>
<Field label="岗位可见范围">
<RadioGroup<JobPublishFormData['visibility']>
options={[
{ label: '全部可见', value: 'all' as const },
{ label: '内部推荐', value: 'internal' as const },
{ label: '校园招聘', value: 'campus' as const },
]}
value={form.visibility}
onChange={(v) => updateField('visibility', v)}
/>
</Field>
<SwitchRow label="急招岗位" value={form.urgent} onValueChange={(v) => updateField('urgent', v)} />
<SwitchRow label="接受应届生" value={form.acceptFresh} onValueChange={(v) => updateField('acceptFresh', v)} />
<Field label="截止日期">
<Input value={form.deadline} onChangeText={(v) => updateStringField('deadline', v)} placeholder="YYYY-MM-DD" />
</Field>
</View>
{/* 质量预估 */}
<QualityEstimateCard
score={estimate?.score ?? 0}
level={estimate?.level ?? '评估中'}
suggestions={estimate?.suggestions ?? []}
estimating={estimating}
/>
</ScrollView>
<View style={styles.footer}>
<Pressable style={styles.draftBtn} onPress={runEstimate}>
<Text style={styles.draftText}>质量预估</Text>
</Pressable>
<Pressable
style={[styles.publishBtn, publishing && styles.publishBtnDisabled]}
onPress={publish}
disabled={publishing}
>
<Text style={styles.publishText}>{publishing ? '发布中...' : '发布岗位'}</Text>
</Pressable>
</View>
</View>
);
}
/* ===================== 样式 ===================== */
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.pageBg,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.pageBg,
},
errorText: {
color: colors.textSecondary,
fontSize: 14,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
headerBtn: {
width: 36,
height: 36,
justifyContent: 'center',
alignItems: 'center',
},
headerTitle: {
fontSize: 17,
fontWeight: '600',
color: colors.textPrimary,
},
headerSaveBtn: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: colors.primaryLight,
borderRadius: 16,
},
headerSaveText: {
fontSize: 13,
fontWeight: '600',
color: colors.primary,
},
scroll: {
flex: 1,
},
scrollContent: {
padding: 16,
paddingBottom: 100,
},
card: {
backgroundColor: colors.white,
borderRadius: 16,
padding: 16,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.04,
shadowRadius: 6,
elevation: 2,
},
cardTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
marginBottom: 16,
},
field: {
marginBottom: 16,
},
fieldLabel: {
fontSize: 14,
fontWeight: '500',
color: colors.textPrimary,
marginBottom: 8,
},
required: {
color: colors.danger,
},
input: {
height: 44,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 10,
paddingHorizontal: 12,
fontSize: 14,
color: colors.textPrimary,
backgroundColor: colors.pageBg,
},
textarea: {
height: 100,
paddingTop: 12,
paddingBottom: 12,
},
tagRow: {
flexDirection: 'row',
flexWrap: 'wrap',
marginHorizontal: -4,
},
radioTag: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 18,
borderWidth: 1,
borderColor: colors.border,
margin: 4,
backgroundColor: colors.white,
},
radioTagActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
radioText: {
fontSize: 13,
color: colors.textPrimary,
},
radioTextActive: {
color: colors.white,
fontWeight: '500',
},
checkboxTag: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 7,
borderRadius: 18,
borderWidth: 1,
borderColor: colors.border,
margin: 4,
backgroundColor: colors.white,
},
checkboxTagActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
checkboxText: {
fontSize: 13,
color: colors.textPrimary,
marginLeft: 4,
},
checkboxTextActive: {
color: colors.white,
fontWeight: '500',
},
switchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
switchLabel: {
fontSize: 14,
color: colors.textPrimary,
},
salaryRow: {
flexDirection: 'row',
alignItems: 'flex-end',
},
salarySeparator: {
marginHorizontal: 8,
marginBottom: 16,
fontSize: 16,
color: colors.textSecondary,
},
qualityCard: {
backgroundColor: colors.primaryLight,
borderWidth: 1,
borderColor: colors.primaryBorder,
},
qualityHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
qualitySub: {
fontSize: 12,
color: colors.textSecondary,
marginTop: 4,
},
qualityScoreBox: {
alignItems: 'center',
},
qualityScore: {
fontSize: 32,
fontWeight: '700',
color: colors.primary,
},
qualityLevel: {
fontSize: 13,
color: colors.primaryText,
fontWeight: '500',
},
suggestionList: {
marginTop: 16,
paddingTop: 16,
borderTopWidth: 1,
borderTopColor: colors.primaryBorder,
},
suggestionItem: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 8,
},
suggestionText: {
flex: 1,
fontSize: 13,
color: colors.textSecondary,
marginLeft: 8,
lineHeight: 18,
},
footer: {
flexDirection: 'row',
padding: 16,
paddingBottom: 24,
backgroundColor: colors.white,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
draftBtn: {
flex: 1,
height: 46,
borderRadius: 24,
borderWidth: 1,
borderColor: colors.primary,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
draftText: {
fontSize: 15,
fontWeight: '600',
color: colors.primary,
},
publishBtn: {
flex: 2,
height: 46,
borderRadius: 24,
backgroundColor: colors.primaryBtn,
justifyContent: 'center',
alignItems: 'center',
},
publishBtnDisabled: {
opacity: 0.6,
},
publishText: {
fontSize: 15,
fontWeight: '600',
color: colors.white,
},
});