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