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.
636 lines
19 KiB
636 lines
19 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 { LoadingState, ErrorState } from '../src/components/StateView';
|
|
import { colors } from '../src/theme';
|
|
import { EDUCATION_OPTIONS, WORK_TYPE_LABELS } from '../src/constants';
|
|
import type { JobPublishFormData } from '../src/types';
|
|
|
|
/** 工作类型选项(标签与编码的对照表来自 constants,此处只是换个形状消费) */
|
|
const WORK_TYPE_OPTIONS = WORK_TYPE_LABELS;
|
|
|
|
/**
|
|
* 经验要求选项 —— **刻意不从 constants 的 `EXPERIENCE_OPTIONS` 取**。
|
|
*
|
|
* 两张表不是同一个事实,名字像而已:
|
|
* · constants 的 `EXPERIENCE_OPTIONS` 是**筛选项**,含「不限」(表示不加限制),
|
|
* 且不含「1年以内」;
|
|
* · 本表是**企业发布岗位时的必填要求**,没有「不限」(必须明确要求),
|
|
* 且含「1年以内」—— 与 `JobExperienceRequirement` 的成员逐一对应。
|
|
* 强行合并会让发布页出现「不限」这个不该有的选项。
|
|
*
|
|
* 已知遗留(非本批处理):`JobExperienceRequirement` 的「1年以内」在 mock 岗位数据里
|
|
* **一次都没用到**,而筛选项表里也没有它 —— 即这个取值目前既筛不到、也没有样本。
|
|
* 属数据/类型层面的取舍,需产品定夺,已记入《项目开发状态.md》4.16。
|
|
*/
|
|
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,
|
|
}: {
|
|
/** 只读:选项表现在有一份来自 constants 的共享常量(`WORK_TYPE_LABELS`),不该被就地改写 */
|
|
options: readonly T[] | ReadonlyArray<{ 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,
|
|
refresh,
|
|
} = useJobPublish();
|
|
|
|
if (loading) {
|
|
return (
|
|
<SafeAreaView style={styles.loadingContainer} edges={['top']}>
|
|
<LoadingState text="加载中…" />
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
if (error || !form) {
|
|
return (
|
|
<SafeAreaView style={styles.loadingContainer} edges={['top']}>
|
|
<ErrorState onRetry={refresh} />
|
|
</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,
|
|
},
|
|
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: 8,
|
|
paddingBottom: 100,
|
|
},
|
|
card: {
|
|
backgroundColor: colors.white,
|
|
borderRadius: 8,
|
|
padding: 8,
|
|
marginBottom: 8,
|
|
},
|
|
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,
|
|
},
|
|
});
|
|
|