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

945 lines
26 KiB

/**
* 企业端 · 投递详情
*
* 这是企业侧投递链路的第二层:在这一页完成两件**会改变数据**的事 ——
* ① 推进投递状态(后端只接受正向流转)
* ② 写 / 改 / 删候选人反馈(一条投递最多一条反馈)
*
* 页面本身不做业务判断:下一步能做什么由 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 (
<View style={[styles.badge, { backgroundColor: tone.bg }]}>
<Text style={[styles.badgeText, { color: tone.color }]}>
{APPLICATION_STATUS_LABELS[status]}
</Text>
</View>
);
}
function SectionTitle({ title }: { title: string }) {
return <Text style={styles.sectionTitle}>{title}</Text>;
}
/** 候选人 + 岗位信息 */
function ProfileCard({ detail }: { detail: CompanyCandidateDetail }) {
const meta = [
detail.candidateSchool,
detail.candidateEducation,
detail.candidateGraduationYear ? `${detail.candidateGraduationYear} 届` : undefined,
detail.candidateLocation,
]
.filter(Boolean)
.join(' · ');
return (
<View style={styles.card}>
<View style={styles.profileTop}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{detail.candidateName.slice(0, 1)}</Text>
</View>
<View style={styles.profileMain}>
<View style={styles.profileNameRow}>
<Text style={styles.name}>{detail.candidateName}</Text>
<StatusBadge status={detail.status} />
</View>
<Text style={styles.meta}>{meta || '学历信息未公开'}</Text>
</View>
</View>
{detail.candidateBio ? (
<Text style={styles.bio}>{detail.candidateBio}</Text>
) : null}
{detail.candidateSkills && detail.candidateSkills.length > 0 ? (
<View style={styles.chips}>
{detail.candidateSkills.map((skill) => (
<View key={skill} style={styles.chip}>
<Text style={styles.chipText}>{skill}</Text>
</View>
))}
</View>
) : null}
<View style={styles.divider} />
<View style={styles.kvRow}>
<Text style={styles.kvLabel}>应聘岗位</Text>
<Text style={styles.kvValue}>{detail.jobTitle}</Text>
</View>
<View style={styles.kvRow}>
<Text style={styles.kvLabel}>用工类型</Text>
<Text style={styles.kvValue}>
{[detail.jobEmploymentType, detail.jobCity].filter(Boolean).join(' · ') || '—'}
</Text>
</View>
<View style={styles.kvRow}>
<Text style={styles.kvLabel}>投递时间</Text>
<Text style={styles.kvValue}>{formatDateTime(detail.createdAt)}</Text>
</View>
<View style={styles.kvRow}>
<Text style={styles.kvLabel}>最近更新</Text>
<Text style={styles.kvValue}>{formatDateTime(detail.updatedAt)}</Text>
</View>
</View>
);
}
/** 状态流转:只渲染允许的下一步,终态给出明确说明而不是留白 */
function StatusActionCard({
status,
updating,
onChange,
}: {
status: ApplicationStatus;
updating: boolean;
onChange: (next: ApplicationStatus) => void;
}) {
const nextOptions = APPLICATION_STATUS_TRANSITIONS[status];
return (
<View style={styles.card}>
<View style={styles.statusRow}>
<Text style={styles.statusLabel}>当前状态</Text>
<StatusBadge status={status} />
</View>
{nextOptions.length === 0 ? (
<Text style={styles.terminalHint}>
{status === 'hired'
? '该候选人已录用,投递流程结束。'
: status === 'rejected'
? '该投递已拒绝,流程结束。'
: '该投递已结束,无法再变更状态。'}
</Text>
) : (
<>
<Text style={styles.actionHint}>推进到:</Text>
<View style={styles.actionRow}>
{nextOptions.map((next) => {
const tone = applicationStatusColors[next];
const isReject = next === 'rejected';
return (
<Pressable
key={next}
style={[
styles.actionBtn,
{ backgroundColor: tone.bg },
updating && styles.actionBtnDisabled,
]}
disabled={updating}
onPress={() => onChange(next)}
>
{updating ? (
<ActivityIndicator size="small" color={tone.color} />
) : (
<Text
style={[
styles.actionText,
{ color: tone.color },
isReject && styles.actionTextDanger,
]}
>
{APPLICATION_STATUS_LABELS[next]}
</Text>
)}
</Pressable>
);
})}
</View>
</>
)}
</View>
);
}
/** 候选人反馈区:已有反馈可改可删,没有则引导去写 */
function FeedbackCard({
feedback,
onEdit,
onDelete,
busy,
}: {
feedback: CandidateFeedback | null;
onEdit: () => void;
onDelete: () => void;
busy: boolean;
}) {
if (!feedback) {
return (
<View style={styles.card}>
<View style={styles.feedbackEmpty}>
<Ionicons name="chatbox-ellipses-outline" size={28} color={colors.textHint} />
<Text style={styles.feedbackEmptyTitle}>还没有写反馈</Text>
<Text style={styles.feedbackEmptyHint}>
记录这位候选人的面试表现,后续 Offer 决策时可回溯
</Text>
<Pressable style={styles.primaryBtn} onPress={onEdit}>
<Text style={styles.primaryBtnText}>写反馈</Text>
</Pressable>
</View>
</View>
);
}
return (
<View style={styles.card}>
<View style={styles.feedbackTop}>
<RatingStars value={feedback.rating} size={15} />
<Text style={styles.feedbackRating}>{feedback.rating}.0</Text>
</View>
<Text style={styles.feedbackContent}>{feedback.content}</Text>
{feedback.tags.length > 0 ? (
<View style={styles.chips}>
{feedback.tags.map((tag) => (
<View key={tag} style={styles.chip}>
<Text style={styles.chipText}>{tag}</Text>
</View>
))}
</View>
) : null}
<Text style={styles.feedbackTime}>最后更新 {formatDateTime(feedback.updatedAt)}</Text>
<View style={styles.feedbackActions}>
<Pressable style={styles.linkBtn} onPress={onEdit} disabled={busy}>
<Ionicons name="create-outline" size={14} color={colors.primaryText} />
<Text style={styles.linkText}>修改</Text>
</Pressable>
<Pressable style={styles.linkBtn} onPress={onDelete} disabled={busy}>
{busy ? (
<ActivityIndicator size="small" color={colors.dangerText} />
) : (
<>
<Ionicons name="trash-outline" size={14} color={colors.dangerText} />
<Text style={[styles.linkText, styles.linkTextDanger]}>删除</Text>
</>
)}
</Pressable>
</View>
</View>
);
}
/** 反馈编辑器(新建 / 修改共用) */
function FeedbackEditorModal({
visible,
initial,
submitting,
onClose,
onSubmit,
}: {
visible: boolean;
initial: CandidateFeedback | null;
submitting: boolean;
onClose: () => void;
onSubmit: (input: CandidateFeedbackInput) => void;
}) {
const [draft, setDraft] = useState<CandidateFeedbackInput>(EMPTY_FEEDBACK);
const [seededFor, setSeededFor] = useState<string | null>(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 (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<KeyboardAvoidingView
style={styles.modalMask}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>{initial ? '修改反馈' : '写反馈'}</Text>
<Pressable onPress={onClose} hitSlop={8} accessibilityLabel="关闭">
<Ionicons name="close" size={22} color={colors.textSecondary} />
</Pressable>
</View>
<ScrollView style={styles.modalBody} keyboardShouldPersistTaps="handled">
<Text style={styles.fieldLabel}>评分</Text>
<View style={styles.ratingRow}>
<RatingStars
value={draft.rating}
size={26}
onChange={(next) => setDraft((prev) => ({ ...prev, rating: next }))}
/>
<Text style={styles.ratingHint}>
{draft.rating > 0 ? `${draft.rating} 分` : '点击星星评分'}
</Text>
</View>
<Text style={styles.fieldLabel}>标签(选填,可多选)</Text>
<View style={styles.tagPicker}>
{CANDIDATE_FEEDBACK_TAG_SUGGESTIONS.map((tag) => {
const active = draft.tags.includes(tag);
return (
<Pressable
key={tag}
style={[styles.tagChip, active && styles.tagChipActive]}
onPress={() => toggleTag(tag)}
>
<Text style={[styles.tagChipText, active && styles.tagChipTextActive]}>
{tag}
</Text>
</Pressable>
);
})}
</View>
<Text style={styles.fieldLabel}>反馈内容</Text>
<TextInput
style={styles.modalInput}
placeholder="记录面试表现、亮点与顾虑,供后续决策回溯..."
placeholderTextColor={colors.textHint}
value={draft.content}
onChangeText={(text) => setDraft((prev) => ({ ...prev, content: text }))}
multiline
maxLength={500}
/>
<Text style={styles.counterText}>{draft.content.length}/500</Text>
</ScrollView>
<View style={styles.modalFooter}>
<Pressable
style={[styles.submitBtn, submitting && styles.submitBtnDisabled]}
onPress={handleSubmit}
disabled={submitting}
>
{submitting ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<Text style={styles.submitText}>{initial ? '保存修改' : '提交反馈'}</Text>
)}
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
}
/* ===================== 页面 ===================== */
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 (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text style={styles.headerTitle}>投递详情</Text>
<View style={styles.headerBtn} />
</View>
{loading ? (
<LoadingState text="正在加载投递详情…" />
) : error || !detail ? (
<ErrorState message="投递详情加载失败" onRetry={refresh} />
) : (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<ProfileCard detail={detail} />
<SectionTitle title="投递状态" />
<StatusActionCard
status={detail.status}
updating={updating}
onChange={handleChangeStatus}
/>
{detail.coverLetter ? (
<>
<SectionTitle title="求职信" />
<View style={styles.card}>
<Text style={styles.coverLetter}>{detail.coverLetter}</Text>
</View>
</>
) : null}
<SectionTitle title="状态时间线" />
{timeline.length === 0 ? (
<View style={styles.card}>
<Text style={styles.timelineEmpty}>暂无状态变更记录</Text>
</View>
) : (
<View style={styles.card}>
{timeline.map((event, index) => (
<View key={`${event.status}-${index}`} style={styles.timelineRow}>
<View style={styles.timelineDot} />
<View style={styles.timelineMain}>
<Text style={styles.timelineStatus}>
{APPLICATION_STATUS_LABELS[event.status] ?? event.status}
</Text>
{event.note ? (
<Text style={styles.timelineNote}>{event.note}</Text>
) : null}
</View>
<Text style={styles.timelineTime}>{formatDateTime(event.time)}</Text>
</View>
))}
</View>
)}
<SectionTitle title="候选人反馈" />
<FeedbackCard
feedback={feedback}
busy={savingFeedback}
onEdit={() => setEditorOpen(true)}
onDelete={handleDeleteFeedback}
/>
</ScrollView>
)}
<FeedbackEditorModal
visible={editorOpen}
initial={feedback}
submitting={savingFeedback}
onClose={() => setEditorOpen(false)}
onSubmit={handleSubmitFeedback}
/>
</SafeAreaView>
);
}
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,
},
});