/** * 企业端 · 投递详情 * * 这是企业侧投递链路的第二层:在这一页完成两件**会改变数据**的事 —— * ① 推进投递状态(后端只接受正向流转) * ② 写 / 改 / 删候选人反馈(一条投递最多一条反馈) * * 页面本身不做业务判断:下一步能做什么由 constants 的 APPLICATION_STATUS_TRANSITIONS 决定, * 「已有反馈该走 PUT 还是 POST」由 hook 决定。页面只负责渲染与二次确认。 */ import { Ionicons } from '@expo/vector-icons'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useState } from 'react'; import { ActivityIndicator, Alert, KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { RatingStars, STAR_COLOR } from '../components/RatingStars'; import { ErrorState, LoadingState } from '../src/components/StateView'; import { APPLICATION_STATUS_LABELS, APPLICATION_STATUS_TRANSITIONS, CANDIDATE_FEEDBACK_TAG_SUGGESTIONS, } from '../src/constants'; import { useCompanyApplicationDetail } from '../src/hooks'; import { applicationStatusColors, colors, spacing } from '../src/theme'; import type { ApplicationStatus, CandidateFeedback, CandidateFeedbackInput, CompanyCandidateDetail, } from '../src/types'; /** 反馈编辑器初值;编辑已有反馈时回填 */ const EMPTY_FEEDBACK: CandidateFeedbackInput = { rating: 0, content: '', tags: [] }; function formatDateTime(iso?: string): string { if (!iso) return '—'; const date = new Date(iso); if (Number.isNaN(date.getTime())) return '—'; const pad = (n: number) => n.toString().padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad( date.getHours(), )}:${pad(date.getMinutes())}`; } /* ===================== 子组件 ===================== */ function StatusBadge({ status }: { status: ApplicationStatus }) { const tone = applicationStatusColors[status]; return ( {APPLICATION_STATUS_LABELS[status]} ); } function SectionTitle({ title }: { title: string }) { return {title}; } /** 候选人 + 岗位信息 */ function ProfileCard({ detail }: { detail: CompanyCandidateDetail }) { const meta = [ detail.candidateSchool, detail.candidateEducation, detail.candidateGraduationYear ? `${detail.candidateGraduationYear} 届` : undefined, detail.candidateLocation, ] .filter(Boolean) .join(' · '); return ( {detail.candidateName.slice(0, 1)} {detail.candidateName} {meta || '学历信息未公开'} {detail.candidateBio ? ( {detail.candidateBio} ) : null} {detail.candidateSkills && detail.candidateSkills.length > 0 ? ( {detail.candidateSkills.map((skill) => ( {skill} ))} ) : null} 应聘岗位 {detail.jobTitle} 用工类型 {[detail.jobEmploymentType, detail.jobCity].filter(Boolean).join(' · ') || '—'} 投递时间 {formatDateTime(detail.createdAt)} 最近更新 {formatDateTime(detail.updatedAt)} ); } /** 状态流转:只渲染允许的下一步,终态给出明确说明而不是留白 */ function StatusActionCard({ status, updating, onChange, }: { status: ApplicationStatus; updating: boolean; onChange: (next: ApplicationStatus) => void; }) { const nextOptions = APPLICATION_STATUS_TRANSITIONS[status]; return ( 当前状态 {nextOptions.length === 0 ? ( {status === 'hired' ? '该候选人已录用,投递流程结束。' : status === 'rejected' ? '该投递已拒绝,流程结束。' : '该投递已结束,无法再变更状态。'} ) : ( <> 推进到: {nextOptions.map((next) => { const tone = applicationStatusColors[next]; const isReject = next === 'rejected'; return ( onChange(next)} > {updating ? ( ) : ( {APPLICATION_STATUS_LABELS[next]} )} ); })} )} ); } /** 候选人反馈区:已有反馈可改可删,没有则引导去写 */ function FeedbackCard({ feedback, onEdit, onDelete, busy, }: { feedback: CandidateFeedback | null; onEdit: () => void; onDelete: () => void; busy: boolean; }) { if (!feedback) { return ( 还没有写反馈 记录这位候选人的面试表现,后续 Offer 决策时可回溯 写反馈 ); } return ( {feedback.rating}.0 {feedback.content} {feedback.tags.length > 0 ? ( {feedback.tags.map((tag) => ( {tag} ))} ) : null} 最后更新 {formatDateTime(feedback.updatedAt)} 修改 {busy ? ( ) : ( <> 删除 )} ); } /** 反馈编辑器(新建 / 修改共用) */ function FeedbackEditorModal({ visible, initial, submitting, onClose, onSubmit, }: { visible: boolean; initial: CandidateFeedback | null; submitting: boolean; onClose: () => void; onSubmit: (input: CandidateFeedbackInput) => void; }) { const [draft, setDraft] = useState(EMPTY_FEEDBACK); const [seededFor, setSeededFor] = useState(null); // 打开时按 initial 播种一次:用「是否已播种过这条反馈」判断,避免每次父组件重渲染都覆盖用户正在输入的内容 const seedKey = visible ? (initial?.id ?? 'new') : null; if (seedKey !== seededFor) { setSeededFor(seedKey); setDraft( initial ? { rating: initial.rating, content: initial.content, tags: [...initial.tags] } : EMPTY_FEEDBACK, ); } const toggleTag = (tag: string) => { setDraft((prev) => ({ ...prev, tags: prev.tags.includes(tag) ? prev.tags.filter((t) => t !== tag) : [...prev.tags, tag], })); }; const handleSubmit = () => { if (draft.rating <= 0) { Alert.alert('提示', '请先给候选人一个评分'); return; } if (!draft.content.trim()) { Alert.alert('提示', '请填写反馈内容'); return; } onSubmit({ ...draft, content: draft.content.trim() }); }; return ( {initial ? '修改反馈' : '写反馈'} 评分 setDraft((prev) => ({ ...prev, rating: next }))} /> {draft.rating > 0 ? `${draft.rating} 分` : '点击星星评分'} 标签(选填,可多选) {CANDIDATE_FEEDBACK_TAG_SUGGESTIONS.map((tag) => { const active = draft.tags.includes(tag); return ( toggleTag(tag)} > {tag} ); })} 反馈内容 setDraft((prev) => ({ ...prev, content: text }))} multiline maxLength={500} /> {draft.content.length}/500 {submitting ? ( ) : ( {initial ? '保存修改' : '提交反馈'} )} ); } /* ===================== 页面 ===================== */ export default function ApplicationDetailScreen() { const router = useRouter(); const { id } = useLocalSearchParams<{ id: string }>(); const applicationId = id ?? ''; const { detail, timeline, feedback, loading, error, refresh, updating, savingFeedback, changeStatus, saveFeedback, removeFeedback, } = useCompanyApplicationDetail(applicationId); const [editorOpen, setEditorOpen] = useState(false); const handleChangeStatus = (next: ApplicationStatus) => { Alert.alert( '确认变更状态', `将投递状态改为「${APPLICATION_STATUS_LABELS[next]}」?该操作会同步给学生端。`, [ { text: '取消', style: 'cancel' }, { text: '确认变更', style: next === 'rejected' ? 'destructive' : 'default', onPress: async () => { try { await changeStatus(next); } catch (e) { Alert.alert('变更失败', (e as Error).message || '请稍后重试'); } }, }, ], ); }; const handleSubmitFeedback = async (input: CandidateFeedbackInput) => { try { await saveFeedback(input, feedback); setEditorOpen(false); } catch (e) { Alert.alert('保存失败', (e as Error).message || '请稍后重试'); } }; const handleDeleteFeedback = () => { Alert.alert('删除反馈', '删除后无法恢复,确定要删除这条反馈吗?', [ { text: '取消', style: 'cancel' }, { text: '删除', style: 'destructive', onPress: async () => { try { await removeFeedback(); } catch (e) { Alert.alert('删除失败', (e as Error).message || '请稍后重试'); } }, }, ]); }; return ( router.back()} hitSlop={8}> 投递详情 {loading ? ( ) : error || !detail ? ( ) : ( {detail.coverLetter ? ( <> {detail.coverLetter} ) : null} {timeline.length === 0 ? ( 暂无状态变更记录 ) : ( {timeline.map((event, index) => ( {APPLICATION_STATUS_LABELS[event.status] ?? event.status} {event.note ? ( {event.note} ) : null} {formatDateTime(event.time)} ))} )} setEditorOpen(true)} onDelete={handleDeleteFeedback} /> )} setEditorOpen(false)} onSubmit={handleSubmitFeedback} /> ); } const styles = StyleSheet.create({ safeArea: { flex: 1, backgroundColor: colors.pageBg, }, header: { height: 48, flexDirection: 'row', alignItems: 'center', }, headerBtn: { width: 48, height: 48, alignItems: 'center', justifyContent: 'center', }, headerTitle: { flex: 1, textAlign: 'center', fontSize: 17, fontWeight: '700', color: colors.textPrimary, }, scroll: { flex: 1, }, content: { paddingHorizontal: spacing.framework, paddingBottom: 24, }, sectionTitle: { marginTop: spacing.card, marginBottom: spacing.card, fontSize: 15, fontWeight: '700', color: colors.textPrimary, }, /* ---- 卡片 ---- */ card: { backgroundColor: colors.white, borderRadius: spacing.card, padding: spacing.card + 4, marginBottom: spacing.card, }, badge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: spacing.card, }, badgeText: { fontSize: 11, fontWeight: '600', }, /* ---- 候选人 / 岗位 ---- */ profileTop: { flexDirection: 'row', alignItems: 'center', gap: spacing.card, }, avatar: { width: 48, height: 48, borderRadius: 24, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.primaryLight, }, avatarText: { fontSize: 20, fontWeight: '700', color: colors.primaryText, }, profileMain: { flex: 1, }, profileNameRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.card, }, name: { fontSize: 17, fontWeight: '700', color: colors.textPrimary, }, meta: { marginTop: 4, fontSize: 12, color: colors.textSecondary, }, bio: { marginTop: spacing.card + 4, fontSize: 13, lineHeight: 20, color: colors.textPrimary, }, chips: { flexDirection: 'row', flexWrap: 'wrap', marginTop: spacing.card + 2, gap: spacing.card, }, chip: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: spacing.card, backgroundColor: colors.pageBg, }, chipText: { fontSize: 11, color: colors.textSecondary, }, divider: { height: 1, marginVertical: spacing.card + 4, backgroundColor: colors.borderLight, }, kvRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4, }, kvLabel: { fontSize: 12, color: colors.textHint, }, kvValue: { flexShrink: 1, textAlign: 'right', fontSize: 12, color: colors.textPrimary, }, /* ---- 状态流转 ---- */ statusRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, statusLabel: { fontSize: 13, color: colors.textSecondary, }, terminalHint: { marginTop: spacing.card + 4, fontSize: 12, color: colors.textHint, }, actionHint: { marginTop: spacing.card + 4, marginBottom: spacing.card, fontSize: 12, color: colors.textHint, }, actionRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.card, }, actionBtn: { minWidth: 84, height: 34, paddingHorizontal: 12, borderRadius: spacing.card, alignItems: 'center', justifyContent: 'center', }, actionBtnDisabled: { opacity: 0.6, }, actionText: { fontSize: 13, fontWeight: '600', }, actionTextDanger: { fontWeight: '700', }, /* ---- 求职信 / 时间线 ---- */ coverLetter: { fontSize: 13, lineHeight: 20, color: colors.textPrimary, }, timelineEmpty: { fontSize: 12, color: colors.textHint, }, timelineRow: { flexDirection: 'row', alignItems: 'flex-start', paddingVertical: 5, }, timelineDot: { width: 8, height: 8, borderRadius: 4, marginTop: 5, marginRight: spacing.card, backgroundColor: colors.primary, }, timelineMain: { flex: 1, }, timelineStatus: { fontSize: 13, fontWeight: '600', color: colors.textPrimary, }, timelineNote: { marginTop: 2, fontSize: 11, color: colors.textSecondary, }, timelineTime: { fontSize: 11, color: colors.textHint, }, /* ---- 反馈 ---- */ feedbackEmpty: { alignItems: 'center', gap: 6, paddingVertical: spacing.card, }, feedbackEmptyTitle: { fontSize: 14, fontWeight: '600', color: colors.textSecondary, }, feedbackEmptyHint: { fontSize: 12, color: colors.textHint, textAlign: 'center', }, primaryBtn: { marginTop: spacing.card, paddingHorizontal: 24, height: 36, borderRadius: spacing.card, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.primaryBtn, }, primaryBtnText: { fontSize: 13, fontWeight: '600', color: colors.white, }, feedbackTop: { flexDirection: 'row', alignItems: 'center', gap: 6, }, feedbackRating: { fontSize: 13, fontWeight: '700', color: STAR_COLOR, }, feedbackContent: { marginTop: spacing.card, fontSize: 13, lineHeight: 20, color: colors.textPrimary, }, feedbackTime: { marginTop: spacing.card, fontSize: 11, color: colors.textHint, }, feedbackActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: spacing.card, paddingTop: spacing.card, borderTopWidth: 1, borderTopColor: colors.borderLight, gap: 16, }, linkBtn: { flexDirection: 'row', alignItems: 'center', gap: 4, }, linkText: { fontSize: 12, fontWeight: '600', color: colors.primaryText, }, linkTextDanger: { color: colors.dangerText, }, /* ---- 编辑器弹窗 ---- */ modalMask: { flex: 1, backgroundColor: colors.scrim, justifyContent: 'flex-end', }, modalSheet: { maxHeight: '86%', backgroundColor: colors.white, borderTopLeftRadius: spacing.framework, borderTopRightRadius: spacing.framework, }, modalHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: spacing.framework, paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: colors.borderLight, }, modalTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary, }, modalBody: { paddingHorizontal: spacing.framework, paddingTop: 12, }, fieldLabel: { marginTop: 14, marginBottom: 8, fontSize: 13, fontWeight: '600', color: colors.textPrimary, }, ratingRow: { flexDirection: 'row', alignItems: 'center', gap: 12, }, ratingHint: { fontSize: 13, color: colors.textSecondary, }, tagPicker: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.card, }, tagChip: { paddingHorizontal: 10, paddingVertical: 5, borderRadius: spacing.card, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.white, }, tagChipActive: { borderColor: colors.primary, backgroundColor: colors.primaryLight, }, tagChipText: { fontSize: 12, color: colors.textSecondary, }, tagChipTextActive: { color: colors.primaryText, fontWeight: '600', }, modalInput: { minHeight: 96, borderRadius: spacing.card, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.pageBg, paddingHorizontal: spacing.card, paddingVertical: spacing.card, fontSize: 13, lineHeight: 20, color: colors.textPrimary, textAlignVertical: 'top', }, counterText: { marginTop: 6, textAlign: 'right', fontSize: 11, color: colors.textHint, }, modalFooter: { paddingHorizontal: spacing.framework, paddingTop: 10, paddingBottom: 24, }, submitBtn: { height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.primaryBtn, }, submitBtnDisabled: { opacity: 0.6, }, submitText: { fontSize: 15, fontWeight: '700', color: colors.white, }, });