/**
* 企业端 · 投递详情
*
* 这是企业侧投递链路的第二层:在这一页完成两件**会改变数据**的事 ——
* ① 推进投递状态(后端只接受正向流转)
* ② 写 / 改 / 删候选人反馈(一条投递最多一条反馈)
*
* 页面本身不做业务判断:下一步能做什么由 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,
},
});