Browse Source
后端有、前端完全没有的整条企业投递链路(含 4 个候选人反馈端点):
- api: fetchCompanyApplications(GET /company/candidates,筛岗位/状态/关键词/分页)、
fetchCompanyApplicationDetail(GET /company/candidates/{applicationId})、
updateApplicationStatus(PATCH /applications/{id}/status)、
fetchCandidateFeedback / createCandidateFeedback / updateCandidateFeedback / deleteCandidateFeedback
- 状态 token 跨边界转换:后端大写 SUBMITTED/VIEWED/INTERVIEW/HIRED… ↔ 前端小写联合类型,
双向都过 toApplicationStatus / toBackendApplicationStatus,页面不拼大写串
- http: HttpMethod 补 PATCH(契约里投递状态、意愿分、适合度分都是 PATCH,原先只能绕开统一请求层)
- types: CompanyApplicationItem / CompanyCandidateDetail / CompanyApplicationQuery /
CandidateFeedback / CandidateFeedbackInput;ApplicationStatus 补 'hired'(此前漏了「已录用」)
- constants: APPLICATION_STATUS_LABELS / COMPANY_APPLICATION_FILTERS /
APPLICATION_STATUS_TRANSITIONS(禁止状态倒退)/ CANDIDATE_FEEDBACK_TAG_SUGGESTIONS
- theme: applicationStatusColors 九态徽标色板,全部走 token
- 页面: app/enterprise-applications.tsx 投递工作台(筛选条计数独立于筛选、关键词、四态、返回即刷新);
app/application-detail.tsx 详情(状态流转 + 二次确认、时间线、反馈读/写/改/删)
- 入口: 企业端「我的」快捷入口与菜单各加「收到的投递」;两页路由注册(headerShown:false,避免叠 header)
- 组件: 抽出 components/RatingStars.tsx,岗位评价页与本页共用一份星级,删掉页面内的重复实现
- 测试: 新增 enterprise-applications.test.ts 14 例(token 双向、筛选、持久化、反馈单条约束、真实路径);
全量 514 例通过
master
16 changed files with 2448 additions and 82 deletions
@ -0,0 +1,945 @@ |
|||
/** |
|||
* 企业端 · 投递详情 |
|||
* |
|||
* 这是企业侧投递链路的第二层:在这一页完成两件**会改变数据**的事 —— |
|||
* ① 推进投递状态(后端只接受正向流转) |
|||
* ② 写 / 改 / 删候选人反馈(一条投递最多一条反馈) |
|||
* |
|||
* 页面本身不做业务判断:下一步能做什么由 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, |
|||
}, |
|||
}); |
|||
@ -0,0 +1,389 @@ |
|||
/** |
|||
* 企业端 · 收到的投递(工作台列表) |
|||
* |
|||
* 这是企业侧投递链路的第一层:一行一个**投递**(不是候选人), |
|||
* 展示状态、岗位、时间与下一步动作。状态变更与候选人反馈都从详情页发起。 |
|||
* |
|||
* 设计取舍: |
|||
* - 筛选条上的计数来自**独立于筛选的**计数口,切到「面试中」不会让其他状态计数归零 |
|||
* - 用 useFocusEffect 在返回时刷新:详情页改完状态后回到列表,徽标必须已经跟着变, |
|||
* 否则用户会以为刚才那次操作没生效 |
|||
*/ |
|||
import { Ionicons } from '@expo/vector-icons'; |
|||
import { useFocusEffect, useRouter } from 'expo-router'; |
|||
import { useCallback, useRef, useState } from 'react'; |
|||
import { |
|||
Pressable, |
|||
ScrollView, |
|||
StyleSheet, |
|||
Text, |
|||
TextInput, |
|||
View, |
|||
} from 'react-native'; |
|||
import { SafeAreaView } from 'react-native-safe-area-context'; |
|||
import { EmptyState, ErrorState, LoadingState } from '../src/components/StateView'; |
|||
import { |
|||
APPLICATION_STATUS_LABELS, |
|||
COMPANY_APPLICATION_FILTERS, |
|||
ENTERPRISE_DISPLAY_NAME, |
|||
} from '../src/constants'; |
|||
import { useCompanyApplications } from '../src/hooks'; |
|||
import { applicationStatusColors, colors, spacing } from '../src/theme'; |
|||
import type { ApplicationStatus, CompanyApplicationItem } from '../src/types'; |
|||
|
|||
/** 把 ISO 时间压成「9-28 10:12」这种一眼能排序的相对短格式 */ |
|||
function formatAppliedAt(iso?: string): string { |
|||
if (!iso) return ''; |
|||
const date = new Date(iso); |
|||
if (Number.isNaN(date.getTime())) return ''; |
|||
const mm = date.getMonth() + 1; |
|||
const dd = date.getDate(); |
|||
const hh = date.getHours().toString().padStart(2, '0'); |
|||
const mi = date.getMinutes().toString().padStart(2, '0'); |
|||
return `${mm}-${dd} ${hh}:${mi}`; |
|||
} |
|||
|
|||
/* ===================== 子组件 ===================== */ |
|||
|
|||
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 ApplicationRow({ |
|||
item, |
|||
onPress, |
|||
}: { |
|||
item: CompanyApplicationItem; |
|||
onPress: () => void; |
|||
}) { |
|||
return ( |
|||
<Pressable style={styles.row} onPress={onPress}> |
|||
<View style={styles.avatar}> |
|||
<Text style={styles.avatarText}>{item.candidateName.slice(0, 1)}</Text> |
|||
</View> |
|||
<View style={styles.rowMain}> |
|||
<View style={styles.rowTop}> |
|||
<Text style={styles.name} numberOfLines={1}> |
|||
{item.candidateName} |
|||
</Text> |
|||
<StatusBadge status={item.status} /> |
|||
</View> |
|||
<Text style={styles.rowMeta} numberOfLines={1}> |
|||
{[item.candidateSchool, item.candidateEducation, item.candidateGraduationYear |
|||
? `${item.candidateGraduationYear} 届` |
|||
: undefined] |
|||
.filter(Boolean) |
|||
.join(' · ') || '学历信息未公开'} |
|||
</Text> |
|||
<View style={styles.rowBottom}> |
|||
<View style={styles.jobChip}> |
|||
<Ionicons name="briefcase-outline" size={11} color={colors.textSecondary} /> |
|||
<Text style={styles.jobChipText} numberOfLines={1}> |
|||
{item.jobTitle} |
|||
</Text> |
|||
</View> |
|||
<Text style={styles.time}>{formatAppliedAt(item.createdAt)}</Text> |
|||
</View> |
|||
</View> |
|||
<Ionicons name="chevron-forward" size={18} color={colors.textHint} /> |
|||
</Pressable> |
|||
); |
|||
} |
|||
|
|||
/* ===================== 页面 ===================== */ |
|||
|
|||
export default function EnterpriseApplicationsScreen() { |
|||
const router = useRouter(); |
|||
const [status, setStatus] = useState<ApplicationStatus | null>(null); |
|||
const [keyword, setKeyword] = useState(''); |
|||
|
|||
const { applications, total, counts, loading, error, refresh, isEmpty } = |
|||
useCompanyApplications({ status, keyword }); |
|||
|
|||
/** |
|||
* 从详情页返回时刷新:状态改了、反馈写了,列表上的数字与徽标都要跟着变。 |
|||
* |
|||
* refresh 的引用会随筛选条件(keyword)变化,直接写进 useCallback 依赖的话, |
|||
* 每敲一个字都会重跑一次 focus effect。故用 ref 取最新值,effect 依赖留空。 |
|||
*/ |
|||
const refreshRef = useRef(refresh); |
|||
refreshRef.current = refresh; |
|||
useFocusEffect( |
|||
useCallback(() => { |
|||
refreshRef.current(); |
|||
}, []), |
|||
); |
|||
|
|||
const allCount = Object.values(counts).reduce((sum, n) => sum + n, 0); |
|||
|
|||
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> |
|||
<View style={styles.headerCenter}> |
|||
<Text style={styles.headerCompany}>{ENTERPRISE_DISPLAY_NAME}</Text> |
|||
<Text style={styles.headerTitle}>收到的投递</Text> |
|||
</View> |
|||
<View style={styles.headerBtn} /> |
|||
</View> |
|||
|
|||
{/* 关键词搜索 */} |
|||
<View style={styles.searchWrap}> |
|||
<Ionicons name="search-outline" size={16} color={colors.textHint} /> |
|||
<TextInput |
|||
style={styles.searchInput} |
|||
placeholder="搜索候选人姓名 / 学校 / 岗位" |
|||
placeholderTextColor={colors.textHint} |
|||
value={keyword} |
|||
onChangeText={setKeyword} |
|||
returnKeyType="search" |
|||
/> |
|||
{keyword.length > 0 ? ( |
|||
<Pressable onPress={() => setKeyword('')} hitSlop={8} accessibilityLabel="清空搜索"> |
|||
<Ionicons name="close-circle" size={16} color={colors.textHint} /> |
|||
</Pressable> |
|||
) : null} |
|||
</View> |
|||
|
|||
{/* 状态筛选:计数来自独立计数口,不随当前筛选变化 */} |
|||
<ScrollView |
|||
horizontal |
|||
showsHorizontalScrollIndicator={false} |
|||
contentContainerStyle={styles.filterRow} |
|||
> |
|||
{COMPANY_APPLICATION_FILTERS.map((filter) => { |
|||
const active = filter.value === status; |
|||
const count = filter.value === null ? allCount : counts[filter.value] ?? 0; |
|||
return ( |
|||
<Pressable |
|||
key={filter.label} |
|||
style={[styles.filterChip, active && styles.filterChipActive]} |
|||
onPress={() => setStatus(filter.value)} |
|||
> |
|||
<Text style={[styles.filterText, active && styles.filterTextActive]}> |
|||
{filter.label} {count} |
|||
</Text> |
|||
</Pressable> |
|||
); |
|||
})} |
|||
</ScrollView> |
|||
|
|||
{/* 列表区:四态齐全 */} |
|||
{loading && applications.length === 0 ? ( |
|||
<LoadingState text="正在加载投递…" /> |
|||
) : error ? ( |
|||
<ErrorState message="投递列表加载失败" onRetry={refresh} /> |
|||
) : isEmpty ? ( |
|||
<EmptyState |
|||
icon="file-tray-outline" |
|||
title={keyword || status ? '没有符合条件的投递' : '还没有收到投递'} |
|||
subtitle={ |
|||
keyword || status |
|||
? '换个筛选条件或清空搜索词再试' |
|||
: '岗位发布后,收到的投递会在这里汇总' |
|||
} |
|||
actionLabel={keyword || status ? '清空筛选' : undefined} |
|||
onAction={ |
|||
keyword || status |
|||
? () => { |
|||
setKeyword(''); |
|||
setStatus(null); |
|||
} |
|||
: undefined |
|||
} |
|||
/> |
|||
) : ( |
|||
<ScrollView |
|||
style={styles.list} |
|||
contentContainerStyle={styles.listContent} |
|||
showsVerticalScrollIndicator={false} |
|||
> |
|||
<Text style={styles.summary}> |
|||
共 {total} 条投递 |
|||
{status ? ` · 已按「${APPLICATION_STATUS_LABELS[status]}」筛选` : ''} |
|||
</Text> |
|||
{applications.map((item) => ( |
|||
<ApplicationRow |
|||
key={item.applicationId} |
|||
item={item} |
|||
onPress={() => router.push(`/application-detail?id=${item.applicationId}`)} |
|||
/> |
|||
))} |
|||
</ScrollView> |
|||
)} |
|||
</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', |
|||
}, |
|||
headerCenter: { |
|||
flex: 1, |
|||
alignItems: 'center', |
|||
}, |
|||
headerCompany: { |
|||
fontSize: 11, |
|||
color: colors.textHint, |
|||
}, |
|||
headerTitle: { |
|||
fontSize: 17, |
|||
fontWeight: '700', |
|||
color: colors.textPrimary, |
|||
}, |
|||
/* ---- 搜索 ---- */ |
|||
searchWrap: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
gap: 6, |
|||
height: 36, |
|||
marginHorizontal: spacing.framework, |
|||
paddingHorizontal: spacing.card, |
|||
borderRadius: spacing.card, |
|||
backgroundColor: colors.white, |
|||
borderWidth: 1, |
|||
borderColor: colors.border, |
|||
}, |
|||
searchInput: { |
|||
flex: 1, |
|||
fontSize: 13, |
|||
color: colors.textPrimary, |
|||
padding: 0, |
|||
}, |
|||
/* ---- 筛选 ---- */ |
|||
filterRow: { |
|||
paddingHorizontal: spacing.framework, |
|||
paddingVertical: spacing.card, |
|||
gap: spacing.card, |
|||
}, |
|||
filterChip: { |
|||
paddingHorizontal: 12, |
|||
height: 30, |
|||
borderRadius: 15, |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
backgroundColor: colors.white, |
|||
borderWidth: 1, |
|||
borderColor: colors.border, |
|||
}, |
|||
filterChipActive: { |
|||
backgroundColor: colors.primaryLight, |
|||
borderColor: colors.primary, |
|||
}, |
|||
filterText: { |
|||
fontSize: 12, |
|||
color: colors.textSecondary, |
|||
}, |
|||
filterTextActive: { |
|||
color: colors.primaryText, |
|||
fontWeight: '600', |
|||
}, |
|||
/* ---- 列表 ---- */ |
|||
list: { |
|||
flex: 1, |
|||
}, |
|||
listContent: { |
|||
paddingHorizontal: spacing.framework, |
|||
paddingBottom: 24, |
|||
}, |
|||
summary: { |
|||
marginBottom: spacing.card, |
|||
fontSize: 12, |
|||
color: colors.textHint, |
|||
}, |
|||
row: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
gap: spacing.card, |
|||
backgroundColor: colors.white, |
|||
borderRadius: spacing.card, |
|||
padding: spacing.card + 4, |
|||
marginBottom: spacing.card, |
|||
}, |
|||
avatar: { |
|||
width: 40, |
|||
height: 40, |
|||
borderRadius: 20, |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
backgroundColor: colors.primaryLight, |
|||
}, |
|||
avatarText: { |
|||
fontSize: 16, |
|||
fontWeight: '700', |
|||
color: colors.primaryText, |
|||
}, |
|||
rowMain: { |
|||
flex: 1, |
|||
}, |
|||
rowTop: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
gap: spacing.card, |
|||
}, |
|||
name: { |
|||
flexShrink: 1, |
|||
fontSize: 15, |
|||
fontWeight: '700', |
|||
color: colors.textPrimary, |
|||
}, |
|||
badge: { |
|||
paddingHorizontal: 8, |
|||
paddingVertical: 2, |
|||
borderRadius: spacing.card, |
|||
}, |
|||
badgeText: { |
|||
fontSize: 11, |
|||
fontWeight: '600', |
|||
}, |
|||
rowMeta: { |
|||
marginTop: 4, |
|||
fontSize: 12, |
|||
color: colors.textSecondary, |
|||
}, |
|||
rowBottom: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
justifyContent: 'space-between', |
|||
marginTop: 6, |
|||
}, |
|||
jobChip: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
gap: 4, |
|||
flexShrink: 1, |
|||
}, |
|||
jobChipText: { |
|||
fontSize: 11, |
|||
color: colors.textSecondary, |
|||
}, |
|||
time: { |
|||
fontSize: 11, |
|||
color: colors.textHint, |
|||
}, |
|||
}); |
|||
@ -0,0 +1,78 @@ |
|||
/** |
|||
* 星级打分 / 星级展示组件。 |
|||
* |
|||
* 岗位评价(app/jobs/[id].tsx)与候选人反馈(app/application-detail.tsx)都要用, |
|||
* 所以提到 components 层只留一份 —— 两处各写一份的话,改星色或改最大星数时必然漏改一处。 |
|||
* |
|||
* 只读态用「实心 / 描边」区分而不只靠颜色:色觉障碍用户也要能读出分数(WCAG 1.4.1)。 |
|||
*/ |
|||
import { Ionicons } from '@expo/vector-icons'; |
|||
import { Pressable, StyleSheet, View } from 'react-native'; |
|||
import { colors } from '../src/theme'; |
|||
|
|||
/** |
|||
* 星色。候选项详情、岗位评价页此前都用 compareOrange 画星, |
|||
* 这里沿用同一语义色,不新造 token。 |
|||
*/ |
|||
export const STAR_COLOR = colors.compareOrange; |
|||
|
|||
export function RatingStars({ |
|||
value, |
|||
max = 5, |
|||
size = 14, |
|||
onChange, |
|||
}: { |
|||
/** 当前分值,0 表示未评分 */ |
|||
value: number; |
|||
/** 满星数,默认 5 */ |
|||
max?: number; |
|||
size?: number; |
|||
/** 传了就是可点打分(编辑器内用),不传即只读展示 */ |
|||
onChange?: (next: number) => void; |
|||
}) { |
|||
const readonly = !onChange; |
|||
return ( |
|||
<View style={styles.row}> |
|||
{Array.from({ length: max }, (_, i) => { |
|||
const starValue = i + 1; |
|||
const filled = starValue <= value; |
|||
const star = ( |
|||
<Ionicons |
|||
name={filled ? 'star' : 'star-outline'} |
|||
size={size} |
|||
color={filled ? STAR_COLOR : colors.borderStrong} |
|||
/> |
|||
); |
|||
if (readonly) { |
|||
return ( |
|||
<View key={starValue} style={styles.cell}> |
|||
{star} |
|||
</View> |
|||
); |
|||
} |
|||
return ( |
|||
<Pressable |
|||
key={starValue} |
|||
style={styles.cell} |
|||
hitSlop={6} |
|||
onPress={() => onChange(starValue)} |
|||
accessibilityRole="button" |
|||
accessibilityLabel={`打 ${starValue} 分`} |
|||
> |
|||
{star} |
|||
</Pressable> |
|||
); |
|||
})} |
|||
</View> |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
row: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
}, |
|||
cell: { |
|||
paddingHorizontal: 1, |
|||
}, |
|||
}); |
|||
@ -0,0 +1,230 @@ |
|||
/** |
|||
* 企业端投递 / 候选人反馈域测试 |
|||
* |
|||
* 重点覆盖三类容易出错的地方: |
|||
* 1. **状态 token 跨边界**:前端小写联合类型 ↔ 后端大写 token,错一个方向筛选就永远空 |
|||
* 2. **mock 持久化**:企业改过的状态、写过的反馈,刷新后必须还在(否则「标记已查看」是假的) |
|||
* 3. **反馈单条约束**:同一投递重复创建必须被拒(后端语义如此),而不是默默产生第二条 |
|||
*/ |
|||
jest.mock('expo-constants', () => ({ expoConfig: { extra: {} } })); |
|||
|
|||
jest.mock('../lib/token-store', () => { |
|||
let mem: { accessToken: string; refreshToken: string; expiresAt?: number } | null = null; |
|||
return { |
|||
getAccessToken: jest.fn(() => mem?.accessToken ?? null), |
|||
getTokens: jest.fn(() => mem), |
|||
setTokens: jest.fn((a: string, r: string, e?: number) => { |
|||
mem = { accessToken: a, refreshToken: r, expiresAt: e }; |
|||
}), |
|||
clearTokens: jest.fn(() => { |
|||
mem = null; |
|||
}), |
|||
hydrateTokens: jest.fn(async () => undefined), |
|||
}; |
|||
}); |
|||
|
|||
/** 内存存储:跨调用保持,才测得出「刷新后状态还在」 */ |
|||
jest.mock('../lib/storage', () => { |
|||
const m = new Map<string, string>(); |
|||
return { |
|||
storageGet: jest.fn(async (k: string) => m.get(k) ?? null), |
|||
storageSet: jest.fn(async (k: string, v: string) => { |
|||
m.set(k, v); |
|||
}), |
|||
storageRemove: jest.fn(async (k: string) => { |
|||
m.delete(k); |
|||
}), |
|||
__clear: () => m.clear(), |
|||
}; |
|||
}); |
|||
|
|||
import { |
|||
createCandidateFeedback, |
|||
deleteCandidateFeedback, |
|||
fetchCandidateFeedback, |
|||
fetchCompanyApplicationDetail, |
|||
fetchCompanyApplications, |
|||
toBackendApplicationStatus, |
|||
updateApplicationStatus, |
|||
updateCandidateFeedback, |
|||
} from '../api/api'; |
|||
import { setMockMode, API_BASE_URL } from '../api/http'; |
|||
|
|||
interface MockResponseSpec { |
|||
status: number; |
|||
body: unknown; |
|||
} |
|||
type Responder = (url: string, init: RequestInit) => MockResponseSpec; |
|||
|
|||
let responder: Responder | null = null; |
|||
let fetchCalls: Array<{ url: string; init: RequestInit }> = []; |
|||
|
|||
function installFetch(): void { |
|||
(global as unknown as { fetch: jest.Mock }).fetch = jest.fn( |
|||
async (url: string, init: RequestInit) => { |
|||
fetchCalls.push({ url, init }); |
|||
const spec = responder |
|||
? responder(url, init) |
|||
: { status: 200, body: { code: 200, message: 'ok', data: null, requestId: 'r', timestamp: 't' } }; |
|||
return { |
|||
status: spec.status, |
|||
ok: spec.status >= 200 && spec.status < 300, |
|||
headers: { get: () => 'r' }, |
|||
text: async () => JSON.stringify(spec.body), |
|||
} as unknown as Response; |
|||
}, |
|||
); |
|||
} |
|||
|
|||
function okData(data: unknown): MockResponseSpec { |
|||
return { status: 200, body: { code: 200, message: 'ok', data, requestId: 'r', timestamp: 't' } }; |
|||
} |
|||
|
|||
beforeEach(() => { |
|||
fetchCalls = []; |
|||
responder = null; |
|||
installFetch(); |
|||
setMockMode(true); |
|||
}); |
|||
|
|||
describe('投递状态 token 转换', () => { |
|||
it('前端状态映射为后端大写 token', () => { |
|||
expect(toBackendApplicationStatus('submitted')).toBe('SUBMITTED'); |
|||
expect(toBackendApplicationStatus('interview')).toBe('INTERVIEW'); |
|||
expect(toBackendApplicationStatus('hired')).toBe('HIRED'); |
|||
expect(toBackendApplicationStatus('withdrawn')).toBe('WITHDRAWN'); |
|||
}); |
|||
}); |
|||
|
|||
describe('企业端投递列表(mock)', () => { |
|||
it('返回种子投递,状态已由后端 token 转为前端联合类型', async () => { |
|||
const page = await fetchCompanyApplications(); |
|||
expect(fetchCalls.length).toBe(0); |
|||
expect(page.items.length).toBeGreaterThan(0); |
|||
// 若没做转换,这里会是 'SUBMITTED' 之类的大写串,徽标取色会拿到 undefined
|
|||
expect(page.items.every((i) => i.status === i.status.toLowerCase())).toBe(true); |
|||
}); |
|||
|
|||
it('按状态筛选只返回该状态的投递', async () => { |
|||
const page = await fetchCompanyApplications({ status: 'hired' }); |
|||
expect(page.items.length).toBeGreaterThan(0); |
|||
expect(page.items.every((i) => i.status === 'hired')).toBe(true); |
|||
}); |
|||
|
|||
it('关键词可匹配候选人姓名', async () => { |
|||
const page = await fetchCompanyApplications({ keyword: '张明远' }); |
|||
expect(page.items.length).toBe(1); |
|||
expect(page.items[0].candidateName).toBe('张明远'); |
|||
}); |
|||
|
|||
it('改过的状态跨刷新保留(不是只改内存里那一份)', async () => { |
|||
await updateApplicationStatus('app-1002', 'interview'); |
|||
|
|||
const page = await fetchCompanyApplications({ status: 'interview' }); |
|||
const target = page.items.find((i) => i.applicationId === 'app-1002'); |
|||
expect(target).toBeDefined(); |
|||
expect(target?.status).toBe('interview'); |
|||
}); |
|||
|
|||
it('详情缺 id 时抛 404,而不是回落成第一条(避免看错人)', async () => { |
|||
await expect(fetchCompanyApplicationDetail('not-exist')).rejects.toThrow('投递不存在'); |
|||
}); |
|||
}); |
|||
|
|||
describe('候选人反馈(mock)', () => { |
|||
it('未反馈时返回 null,不是错误', async () => { |
|||
expect(await fetchCandidateFeedback('app-1001')).toBeNull(); |
|||
}); |
|||
|
|||
it('创建后能读回;修改后内容与时间都更新', async () => { |
|||
const created = await createCandidateFeedback('app-1001', { |
|||
rating: 4, |
|||
content: '沟通顺畅', |
|||
tags: ['沟通顺畅'], |
|||
}); |
|||
expect(created.applicationId).toBe('app-1001'); |
|||
|
|||
const fetched = await fetchCandidateFeedback('app-1001'); |
|||
expect(fetched?.content).toBe('沟通顺畅'); |
|||
|
|||
const updated = await updateCandidateFeedback('app-1001', { |
|||
rating: 5, |
|||
content: '沟通顺畅,专业扎实', |
|||
tags: ['沟通顺畅', '专业扎实'], |
|||
}); |
|||
expect(updated.rating).toBe(5); |
|||
expect(updated.id).toBe(created.id); |
|||
|
|||
const reread = await fetchCandidateFeedback('app-1001'); |
|||
expect(reread?.content).toBe('沟通顺畅,专业扎实'); |
|||
expect(reread?.tags).toEqual(['沟通顺畅', '专业扎实']); |
|||
}); |
|||
|
|||
it('同一投递重复创建被拒(后端约束一条投递一条反馈)', async () => { |
|||
await createCandidateFeedback('app-1003', { rating: 3, content: '待定', tags: [] }); |
|||
await expect( |
|||
createCandidateFeedback('app-1003', { rating: 4, content: '再来一条', tags: [] }), |
|||
).rejects.toThrow('已反馈过'); |
|||
}); |
|||
|
|||
it('没有反馈时修改抛 404;删除后回到 null', async () => { |
|||
await expect( |
|||
updateCandidateFeedback('app-1004', { rating: 5, content: 'x', tags: [] }), |
|||
).rejects.toThrow('反馈不存在'); |
|||
|
|||
await createCandidateFeedback('app-1004', { rating: 5, content: '可录用', tags: [] }); |
|||
await deleteCandidateFeedback('app-1004'); |
|||
expect(await fetchCandidateFeedback('app-1004')).toBeNull(); |
|||
|
|||
await expect(deleteCandidateFeedback('app-1004')).rejects.toThrow('反馈不存在'); |
|||
}); |
|||
}); |
|||
|
|||
describe('企业端投递 / 反馈(真实模式)', () => { |
|||
it('列表打 /company/candidates,status 以大写 token 下发', async () => { |
|||
setMockMode(false); |
|||
responder = (url, init) => { |
|||
expect(url).toContain('/company/candidates'); |
|||
expect(url).toContain('status=HIRED'); |
|||
expect(init.method).toBe('GET'); |
|||
return okData({ items: [], total: 0, size: 20, page: 1, totalPages: 0 }); |
|||
}; |
|||
await fetchCompanyApplications({ status: 'hired' }); |
|||
}); |
|||
|
|||
it('详情打 /company/candidates/{applicationId} 并回写状态为联合类型', async () => { |
|||
setMockMode(false); |
|||
responder = (url) => { |
|||
expect(url).toBe(`${API_BASE_URL}/company/candidates/app-1001`); |
|||
return okData({ applicationId: 1001, jobId: 1, jobTitle: '前端', status: 'INTERVIEW' }); |
|||
}; |
|||
const detail = await fetchCompanyApplicationDetail('app-1001'); |
|||
expect(detail.applicationId).toBe('1001'); |
|||
expect(detail.status).toBe('interview'); |
|||
}); |
|||
|
|||
it('状态变更走 PATCH /applications/{id}/status', async () => { |
|||
setMockMode(false); |
|||
responder = (url, init) => { |
|||
expect(url).toBe(`${API_BASE_URL}/applications/app-1001/status`); |
|||
expect(init.method).toBe('PATCH'); |
|||
expect(JSON.parse(String(init.body))).toEqual({ status: 'VIEWED' }); |
|||
return okData(null); |
|||
}; |
|||
await updateApplicationStatus('app-1001', 'viewed'); |
|||
}); |
|||
|
|||
it('反馈新建 POST、修改 PUT,路径同一处', async () => { |
|||
setMockMode(false); |
|||
let seenMethod = ''; |
|||
responder = (url, init) => { |
|||
expect(url).toBe(`${API_BASE_URL}/applications/app-1001/feedback`); |
|||
seenMethod = String(init.method); |
|||
return okData({ id: 1, applicationId: 1001, rating: 5, content: '好', tags: [] }); |
|||
}; |
|||
await createCandidateFeedback('app-1001', { rating: 5, content: '好', tags: [] }); |
|||
expect(seenMethod).toBe('POST'); |
|||
await updateCandidateFeedback('app-1001', { rating: 4, content: '改', tags: [] }); |
|||
expect(seenMethod).toBe('PUT'); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,170 @@ |
|||
/** |
|||
* 企业端「收到的投递」hook。 |
|||
* |
|||
* 拆成两个取数口是刻意的: |
|||
* - 列表口跟随筛选条件变化(状态 / 关键词 / 分页) |
|||
* - 计数口**只跟岗位变化**,不受筛选影响 |
|||
* |
|||
* 合成一个的话,筛选到「面试中」时其余状态的计数会一起归零, |
|||
* 筛选条上「全部 42」会跳成「全部 5」——筛选条的数字就不再是各状态的总量了。 |
|||
*/ |
|||
import { useCallback, useMemo, useState } from 'react'; |
|||
import { |
|||
createCandidateFeedback, |
|||
deleteCandidateFeedback, |
|||
fetchCandidateFeedback, |
|||
fetchCompanyApplicationDetail, |
|||
fetchCompanyApplications, |
|||
fetchApplicationTimeline, |
|||
updateApplicationStatus, |
|||
updateCandidateFeedback, |
|||
} from '../api'; |
|||
import type { |
|||
ApplicationStatus, |
|||
ApplicationTimelineEvent, |
|||
CandidateFeedback, |
|||
CandidateFeedbackInput, |
|||
CompanyApplicationItem, |
|||
CompanyCandidateDetail, |
|||
PageResult, |
|||
} from '../types'; |
|||
import { useAsyncData } from './useAsyncData'; |
|||
|
|||
/** 列表筛选项:状态为 null 表示不筛 */ |
|||
export interface CompanyApplicationFilters { |
|||
jobId?: string; |
|||
status: ApplicationStatus | null; |
|||
keyword: string; |
|||
} |
|||
|
|||
/** 列表页聚合数据(列表 + 各状态总量) */ |
|||
interface CompanyApplicationData { |
|||
page: PageResult<CompanyApplicationItem>; |
|||
counts: Record<string, number>; |
|||
} |
|||
|
|||
export function useCompanyApplications(filters: CompanyApplicationFilters) { |
|||
const { jobId, status, keyword } = filters; |
|||
|
|||
const fetcher = useCallback(async (): Promise<CompanyApplicationData> => { |
|||
// 计数口不带 status/keyword:它回答的是「各状态各有多少」,与当前筛选无关
|
|||
const [page, all] = await Promise.all([ |
|||
fetchCompanyApplications({ |
|||
jobId, |
|||
status: status ?? undefined, |
|||
keyword: keyword.trim() || undefined, |
|||
page: 1, |
|||
size: 50, |
|||
}), |
|||
fetchCompanyApplications({ jobId, page: 1, size: 5000 }), |
|||
]); |
|||
const counts: Record<string, number> = {}; |
|||
all.items.forEach((item) => { |
|||
counts[item.status] = (counts[item.status] ?? 0) + 1; |
|||
}); |
|||
return { page, counts }; |
|||
}, [jobId, status, keyword]); |
|||
|
|||
const { data, loading, error, refresh } = useAsyncData<CompanyApplicationData>(fetcher); |
|||
|
|||
const applications = data?.page.items ?? []; |
|||
const total = data?.page.total ?? 0; |
|||
const counts = data?.counts ?? {}; |
|||
|
|||
return { |
|||
applications, |
|||
total, |
|||
counts, |
|||
loading, |
|||
error, |
|||
refresh, |
|||
isEmpty: !loading && !error && applications.length === 0, |
|||
}; |
|||
} |
|||
|
|||
/** 详情页聚合数据 */ |
|||
interface ApplicationDetailData { |
|||
detail: CompanyCandidateDetail; |
|||
timeline: ApplicationTimelineEvent[]; |
|||
feedback: CandidateFeedback | null; |
|||
} |
|||
|
|||
export function useCompanyApplicationDetail(applicationId: string) { |
|||
const fetcher = useCallback(async (): Promise<ApplicationDetailData> => { |
|||
const [detail, timeline, feedback] = await Promise.all([ |
|||
fetchCompanyApplicationDetail(applicationId), |
|||
fetchApplicationTimeline(applicationId), |
|||
fetchCandidateFeedback(applicationId), |
|||
]); |
|||
return { detail, timeline, feedback }; |
|||
}, [applicationId]); |
|||
|
|||
const { data, loading, error, refresh } = useAsyncData<ApplicationDetailData>(fetcher); |
|||
|
|||
/** 状态流转中:按钮据此置灰,避免连点造成状态乱跳 */ |
|||
const [updating, setUpdating] = useState(false); |
|||
/** 反馈保存中 */ |
|||
const [savingFeedback, setSavingFeedback] = useState(false); |
|||
|
|||
const changeStatus = useCallback( |
|||
async (next: ApplicationStatus) => { |
|||
setUpdating(true); |
|||
try { |
|||
await updateApplicationStatus(applicationId, next); |
|||
refresh(); |
|||
return true; |
|||
} finally { |
|||
setUpdating(false); |
|||
} |
|||
}, |
|||
[applicationId, refresh], |
|||
); |
|||
|
|||
/** |
|||
* 保存反馈:**已有反馈走 PUT、没有走 POST**。 |
|||
* 这个分支必须留在 hook 里 —— 页面拿到的是「有没有反馈」这个事实, |
|||
* 不该由 UI 去决定调哪个接口(否则后端把 POST 改成 upsert 时页面要跟着改)。 |
|||
*/ |
|||
const saveFeedback = useCallback( |
|||
async (input: CandidateFeedbackInput, existing: CandidateFeedback | null) => { |
|||
setSavingFeedback(true); |
|||
try { |
|||
const saved = existing |
|||
? await updateCandidateFeedback(applicationId, input) |
|||
: await createCandidateFeedback(applicationId, input); |
|||
refresh(); |
|||
return saved; |
|||
} finally { |
|||
setSavingFeedback(false); |
|||
} |
|||
}, |
|||
[applicationId, refresh], |
|||
); |
|||
|
|||
const removeFeedback = useCallback(async () => { |
|||
setSavingFeedback(true); |
|||
try { |
|||
await deleteCandidateFeedback(applicationId); |
|||
refresh(); |
|||
} finally { |
|||
setSavingFeedback(false); |
|||
} |
|||
}, [applicationId, refresh]); |
|||
|
|||
// 稳定引用:时间线数组每次渲染新建会让下游依赖它的 memo/effect 反复触发
|
|||
const timeline = useMemo(() => data?.timeline ?? [], [data]); |
|||
|
|||
return { |
|||
detail: data?.detail ?? null, |
|||
timeline, |
|||
feedback: data?.feedback ?? null, |
|||
loading, |
|||
error, |
|||
refresh, |
|||
updating, |
|||
savingFeedback, |
|||
changeStatus, |
|||
saveFeedback, |
|||
removeFeedback, |
|||
}; |
|||
} |
|||
Loading…
Reference in new issue