Browse Source

feat(enterprise): 补齐企业端收到的投递与候选人反馈(P0-7)

后端有、前端完全没有的整条企业投递链路(含 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
doublex001 4 weeks ago
parent
commit
51f5a9b2d2
  1. 3
      app/_layout.tsx
  2. 945
      app/application-detail.tsx
  3. 389
      app/enterprise-applications.tsx
  4. 74
      app/jobs/[id].tsx
  5. 78
      components/RatingStars.tsx
  6. 230
      src/__tests__/enterprise-applications.test.ts
  7. 429
      src/api/api.ts
  8. 7
      src/api/http.ts
  9. 8
      src/api/index.ts
  10. 67
      src/constants/index.ts
  11. 5
      src/hooks/index.ts
  12. 170
      src/hooks/useCompanyApplications.ts
  13. 32
      src/theme/colors.ts
  14. 1
      src/theme/index.ts
  15. 87
      src/types/application.ts
  16. 5
      src/types/index.ts

3
app/_layout.tsx

@ -26,6 +26,9 @@ export default function RootLayout() {
<Stack.Screen name="notifications" options={{ title: '通知' }} />
<Stack.Screen name="history" options={{ title: '浏览历史' }} />
<Stack.Screen name="assessment" options={{ title: '我的测评' }} />
{/* 企业端投递链路:两页都自带 header,关掉 Stack 默认 header 免得叠两条 */}
<Stack.Screen name="enterprise-applications" options={{ headerShown: false }} />
<Stack.Screen name="application-detail" options={{ headerShown: false }} />
</Stack>
</RoleProvider>
</AuthProvider>

945
app/application-detail.tsx

@ -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,
},
});

389
app/enterprise-applications.tsx

@ -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,
},
});

74
app/jobs/[id].tsx

@ -16,11 +16,11 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Circle, Line, Polygon, Text as SvgText } from 'react-native-svg';
import { RatingStars, STAR_COLOR } from '../../components/RatingStars';
import { useFavorites, useJobDetail } from '../../src/hooks';
import { buildJobShareText } from '../../src/api';
import {
REVIEW_DIMENSIONS,
REVIEW_RATING_MAX,
REVIEW_TAG_SUGGESTIONS,
} from '../../src/constants';
import { shareText } from '../../src/lib/shareFile';
@ -33,9 +33,6 @@ import type {
JobReviewSummary,
} from '../../src/types';
/** 星级色:星级图标在候选人详情、岗位评价页均复用 compareOrange,此处沿用同一语义色 */
const STAR_COLOR = colors.compareOrange;
/* ===================== 雷达图(6 维度,SVG 绘制) ===================== */
/** 六边形雷达图:同心网格 + 轴线 + 数据多边形 + 维度标签 */
@ -184,58 +181,7 @@ function SectionTitle({
);
}
/* ===================== 评价:星级 / 汇总 / 卡片 / 编辑器 ===================== */
/**
* 星级组件。
* 传 `onChange` 时可点击打分(编辑器内用),不传则只读(列表展示用)。
* 只读态用实心/描边星区分,避免仅靠颜色传达分数(WCAG 1.4.1)。
*/
function Stars({
value,
size = 14,
onChange,
}: {
value: number;
size?: number;
onChange?: (next: number) => void;
}) {
const readonly = !onChange;
return (
<View style={styles.starsRow}>
{Array.from({ length: REVIEW_RATING_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.starCell}>
{star}
</View>
);
}
return (
<Pressable
key={starValue}
style={styles.starCell}
hitSlop={6}
onPress={() => onChange(starValue)}
accessibilityRole="button"
accessibilityLabel={`打 ${starValue} 分`}
>
{star}
</Pressable>
);
})}
</View>
);
}
/* ===================== 评价:汇总 / 卡片 / 编辑器 ===================== */
/**
* 评价汇总卡:平均分 + 星级分布 + 热门标签。
@ -249,7 +195,7 @@ function ReviewSummaryCard({ summary }: { summary: JobReviewSummary }) {
<View style={styles.summaryTop}>
<View style={styles.summaryScoreCol}>
<Text style={styles.summaryScore}>{summary.averageRating.toFixed(1)}</Text>
<Stars value={Math.round(summary.averageRating)} size={13} />
<RatingStars value={Math.round(summary.averageRating)} size={13} />
<Text style={styles.summaryTotal}>{summary.total} 条真实评价</Text>
</View>
<View style={styles.summaryBars}>
@ -308,7 +254,7 @@ function ReviewCard({
</View>
{typeof review.rating === 'number' && review.rating > 0 ? (
<View style={styles.reviewRatingRow}>
<Stars value={review.rating} size={13} />
<RatingStars value={review.rating} size={13} />
<Text style={styles.reviewRatingText}>{review.rating}.0</Text>
</View>
) : null}
@ -447,7 +393,7 @@ function ReviewEditorModal({
<ScrollView style={styles.modalBody} keyboardShouldPersistTaps="handled">
<Text style={styles.fieldLabel}>综合评分</Text>
<View style={styles.ratingRow}>
<Stars
<RatingStars
value={draft.rating}
size={26}
onChange={(next) => setDraft((prev) => ({ ...prev, rating: next }))}
@ -462,7 +408,7 @@ function ReviewEditorModal({
{REVIEW_DIMENSIONS.map((dim) => (
<View key={dim.key} style={styles.dimEditorRow}>
<Text style={styles.dimEditorLabel}>{dim.label}</Text>
<Stars
<RatingStars
value={draft.dimensionScores[dim.key] ?? 0}
size={16}
onChange={(next) => setDimension(dim.key, next)}
@ -1084,14 +1030,6 @@ const styles = StyleSheet.create({
fontSize: 12,
color: colors.textSecondary,
},
/* ---- 星级 ---- */
starsRow: {
flexDirection: 'row',
alignItems: 'center',
},
starCell: {
paddingHorizontal: 1,
},
/* ---- 评价汇总 ---- */
summaryTop: {
flexDirection: 'row',

78
components/RatingStars.tsx

@ -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,
},
});

230
src/__tests__/enterprise-applications.test.ts

@ -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');
});
});

429
src/api/api.ts

@ -181,6 +181,11 @@ import type {
ApplicationItem,
ApplicationStatus,
ApplicationTimelineEvent,
CandidateFeedback,
CandidateFeedbackInput,
CompanyApplicationItem,
CompanyApplicationQuery,
CompanyCandidateDetail,
PageResult,
} from '../types';
import {
@ -785,6 +790,7 @@ const MOCK_CANDIDATE_MENU_ITEMS: MenuItem[] = [
const MOCK_ENTERPRISE_MENU_ITEMS: MenuItem[] = [
{ id: 'job-publish', title: '发布岗位', icon: 'add-circle-outline', route: '/job-publish' },
{ id: 'job-management', title: '岗位管理', icon: 'business-outline', route: '/job-management' },
{ id: 'enterprise-applications', title: '收到的投递', icon: 'file-tray-full-outline', route: '/enterprise-applications' },
{ id: 'job-evaluations', title: '岗位评价管理', icon: 'chatbubbles-outline', route: '/job-evaluations' },
{ id: 'talent-recommendation', title: '人才推荐', icon: 'people-outline', route: '/talent-recommendation' },
{ id: 'interview-score', title: '面试评分', icon: 'create-outline', route: '/interview-score' },
@ -1742,20 +1748,33 @@ export async function fetchMyApplications(params?: {
// mock 返回空壳,真实数据源走求职进度页 MOCK_JOB_PROGRESS 派生
return mockRequest({ items: [], total: 0, size: params?.size ?? 20, page: params?.page ?? 1, totalPages: 0 });
}
const data = await request<{ items: ApplicationItem[]; total: number; size: number; totalPages: number; page: number }>(
'/applications/me',
{
method: 'GET',
auth: true,
query: {
page: params?.page ?? 1,
size: params?.size ?? 20,
...(params?.status ? { status: params.status } : {}),
},
const data = await request<{
items: (Omit<ApplicationItem, 'id' | 'jobId' | 'status'> & {
id: number | string;
jobId: number | string;
status: string;
})[];
total: number;
size: number;
totalPages: number;
page: number;
}>('/applications/me', {
method: 'GET',
auth: true,
query: {
page: params?.page ?? 1,
size: params?.size ?? 20,
// 前端小写联合类型 → 后端大写 token;直接发小写后端筛不出任何结果
...(params?.status ? { status: toBackendApplicationStatus(params.status) } : {}),
},
);
});
return {
items: data.items.map((item) => ({ ...item, id: String(item.id) })),
items: data.items.map((item) => ({
...item,
id: String(item.id),
jobId: String(item.jobId),
status: toApplicationStatus(item.status),
})),
total: data.total,
size: data.size,
page: data.page,
@ -1806,6 +1825,391 @@ export async function withdrawApplication(id: string): Promise<void> {
await request(`/applications/${id}/withdraw`, { method: 'POST', auth: true });
}
/* ===================== 企业端 · 收到的投递 / 候选人反馈 ===================== */
/**
* 后端投递状态 token(大写)→ 前端联合类型。
*
* 后端流转是 `SUBMITTED → VIEWED → INTERVIEW → REJECTED / HIRED`,前端统一用小写联合类型。
* 跨这层边界必须转换,否则页面拿到的是 'SUBMITTED',而 constants 的
* `APPLICATION_STATUS_LABELS` 键是小写 —— 徽标会显示成一团空白。
* 未识别的 token 落到 'submitted':显示成「待查看」让企业还能操作,好过渲染空状态。
*/
const APPLICATION_STATUS_FROM_BACKEND: Record<string, ApplicationStatus> = {
SUBMITTED: 'submitted',
VIEWED: 'viewed',
SHORTLISTED: 'shortlisted',
INTERVIEW: 'interview',
OFFER: 'offer',
ACCEPTED: 'accepted',
HIRED: 'hired',
REJECTED: 'rejected',
WITHDRAWN: 'withdrawn',
};
/** 后端状态 token → 前端状态;空值/未知值兜底为「待查看」 */
function toApplicationStatus(raw: string | null | undefined): ApplicationStatus {
if (!raw) return 'submitted';
return APPLICATION_STATUS_FROM_BACKEND[raw.toUpperCase()] ?? 'submitted';
}
/** 前端状态 → 后端大写 token(所有状态变更请求都必须经此函数) */
export function toBackendApplicationStatus(status: ApplicationStatus): string {
return status.toUpperCase();
}
/** mock:企业收到的投递种子数据(真实来源是 GET /company/candidates) */
const MOCK_COMPANY_APPLICATIONS: CompanyApplicationItem[] = [
{
applicationId: 'app-1001',
jobId: '1',
jobTitle: '高级前端工程师',
status: 'submitted',
candidateName: '张明远',
candidateSchool: '北京邮电大学',
candidateEducation: '本科',
candidateGraduationYear: 2022,
createdAt: '2026-09-28T10:12:00Z',
updatedAt: '2026-09-28T10:12:00Z',
},
{
applicationId: 'app-1002',
jobId: '1',
jobTitle: '高级前端工程师',
status: 'viewed',
candidateName: '李思彤',
candidateSchool: '浙江大学',
candidateEducation: '硕士',
candidateGraduationYear: 2024,
createdAt: '2026-09-27T15:40:00Z',
updatedAt: '2026-09-28T09:05:00Z',
},
{
applicationId: 'app-1003',
jobId: '2',
jobTitle: '产品经理',
status: 'interview',
candidateName: '王嘉宁',
candidateSchool: '复旦大学',
candidateEducation: '硕士',
candidateGraduationYear: 2023,
createdAt: '2026-09-24T09:30:00Z',
updatedAt: '2026-09-29T11:20:00Z',
},
{
applicationId: 'app-1004',
jobId: '3',
jobTitle: 'UI 设计师',
status: 'offer',
candidateName: '陈雨薇',
candidateSchool: '中国美术学院',
candidateEducation: '本科',
candidateGraduationYear: 2021,
createdAt: '2026-09-20T14:00:00Z',
updatedAt: '2026-09-26T16:45:00Z',
},
{
applicationId: 'app-1005',
jobId: '2',
jobTitle: '产品经理',
status: 'hired',
candidateName: '赵子豪',
candidateSchool: '上海交通大学',
candidateEducation: '硕士',
candidateGraduationYear: 2020,
createdAt: '2026-09-10T11:00:00Z',
updatedAt: '2026-09-22T18:00:00Z',
},
{
applicationId: 'app-1006',
jobId: '3',
jobTitle: 'UI 设计师',
status: 'rejected',
candidateName: '孙一鸣',
candidateSchool: '江南大学',
candidateEducation: '本科',
candidateGraduationYear: 2025,
createdAt: '2026-09-18T08:20:00Z',
updatedAt: '2026-09-19T10:10:00Z',
},
];
/** 状态变更的持久化键(企业改过的状态必须跨刷新保留,否则「标记已查看」形同虚设) */
const COMPANY_APPLICATION_STATUS_KEY = 'enterprise:application-status';
/** 候选人反馈的持久化键:Record<applicationId, CandidateFeedback> */
const CANDIDATE_FEEDBACK_KEY = 'enterprise:candidate-feedback';
/** 读取企业手动改过的状态覆盖表 */
async function readApplicationStatusOverrides(): Promise<Record<string, ApplicationStatus>> {
const raw = await storageGet(COMPANY_APPLICATION_STATUS_KEY);
return raw ? (JSON.parse(raw) as Record<string, ApplicationStatus>) : {};
}
/** 读取候选人反馈表(key 为 applicationId) */
async function readCandidateFeedbackMap(): Promise<Record<string, CandidateFeedback>> {
const raw = await storageGet(CANDIDATE_FEEDBACK_KEY);
return raw ? (JSON.parse(raw) as Record<string, CandidateFeedback>) : {};
}
/** 合并种子数据与状态覆盖,得到当前企业视角的投递列表 */
async function readCompanyApplications(): Promise<CompanyApplicationItem[]> {
const overrides = await readApplicationStatusOverrides();
return MOCK_COMPANY_APPLICATIONS.map((item) => ({
...item,
status: overrides[item.applicationId] ?? item.status,
}));
}
/**
* 企业端投递/候选人列表(跨岗位)
* @method GET
* @url /company/candidates
* @param query.jobId 按岗位筛选
* @param query.status 按状态筛选
* @param query.keyword 姓名 / 学校关键词
* @param query.page 页码(从 1 开始)
* @param query.size 每页数量
* @returns Promise<PageResult<CompanyApplicationItem>> 分页列表
*/
export async function fetchCompanyApplications(
query?: CompanyApplicationQuery,
): Promise<PageResult<CompanyApplicationItem>> {
const page = query?.page ?? 1;
const size = query?.size ?? 20;
if (isMockMode()) {
const all = await readCompanyApplications();
const keyword = query?.keyword?.trim();
const filtered = all.filter((item) => {
if (query?.jobId && item.jobId !== query.jobId) return false;
if (query?.status && item.status !== query.status) return false;
if (keyword) {
const hay = `${item.candidateName}${item.candidateSchool ?? ''}${item.jobTitle}`;
if (!hay.includes(keyword)) return false;
}
return true;
});
const start = (page - 1) * size;
return mockRequest({
items: filtered.slice(start, start + size),
total: filtered.length,
size,
page,
totalPages: Math.ceil(filtered.length / size),
});
}
const data = await request<{
items: (Omit<CompanyApplicationItem, 'applicationId' | 'jobId' | 'status'> & {
applicationId: number | string;
jobId: number | string;
status: string;
})[];
total: number;
size: number;
page: number;
totalPages: number;
}>('/company/candidates', {
method: 'GET',
auth: true,
query: {
page,
size,
...(query?.jobId ? { jobId: query.jobId } : {}),
...(query?.status ? { status: toBackendApplicationStatus(query.status) } : {}),
...(query?.keyword ? { keyword: query.keyword } : {}),
...(query?.sort ? { sort: query.sort } : {}),
...(query?.order ? { order: query.order } : {}),
},
});
return {
items: data.items.map((item) => ({
...item,
applicationId: String(item.applicationId),
jobId: String(item.jobId),
status: toApplicationStatus(item.status),
})),
total: data.total,
size: data.size,
page: data.page,
totalPages: data.totalPages,
};
}
/**
* 企业端候选人详情(按投递 id)
* @method GET
* @url /company/candidates/{applicationId}
* @param applicationId 投递 id(注意不是候选人 id —— 后端路径参数语义如此)
* @returns Promise<CompanyCandidateDetail>
*/
export async function fetchCompanyApplicationDetail(
applicationId: string,
): Promise<CompanyCandidateDetail> {
if (isMockMode()) {
const all = await readCompanyApplications();
const found = all.find((item) => item.applicationId === applicationId);
if (!found) throw new ApiError({ httpStatus: 404, code: 404, message: '投递不存在或无权查看' });
// 详情在列表字段上补岗位与候选人概况;mock 不另存一份,避免与列表分叉
return mockRequest<CompanyCandidateDetail>({
...found,
jobCity: '上海',
jobEmploymentType: '全职',
coverLetter: '贵司的岗位与我的经历高度匹配,期待有机会进一步沟通。',
candidateBio: `${found.candidateGraduationYear ?? ''} 年毕业,${found.candidateEducation ?? '本科'},熟悉岗位核心技能,有完整项目落地经验。`,
candidateLocation: '上海',
candidateSkills: ['React', 'TypeScript', 'Node.js', '数据可视化'],
});
}
const data = await request<Omit<CompanyCandidateDetail, 'applicationId' | 'jobId' | 'status'> & {
applicationId: number | string;
jobId: number | string;
status: string;
}>(`/company/candidates/${applicationId}`, { method: 'GET', auth: true });
return {
...data,
applicationId: String(data.applicationId),
jobId: String(data.jobId),
status: toApplicationStatus(data.status),
};
}
/**
* 更新投递状态(企业)
* @method PATCH
* @url /applications/{id}/status
* @param id 投递 id
* @param status 目标状态
* @remark 后端只接受正向流转(SUBMITTED→VIEWED→INTERVIEW→REJECTED/HIRED),
* 非法流转会返回错误;页面按钮由 APPLICATION_STATUS_TRANSITIONS 约束,不靠后端兜底
*/
export async function updateApplicationStatus(
id: string,
status: ApplicationStatus,
): Promise<void> {
if (isMockMode()) {
const overrides = await readApplicationStatusOverrides();
overrides[id] = status;
await storageSet(COMPANY_APPLICATION_STATUS_KEY, JSON.stringify(overrides));
return mockRequest(undefined);
}
await request(`/applications/${id}/status`, {
method: 'PATCH',
auth: true,
body: { status: toBackendApplicationStatus(status) },
});
}
/**
* 查询候选人反馈
* @method GET
* @url /applications/{applicationId}/feedback
* @param applicationId 投递 id
* @returns Promise<CandidateFeedback | null> 尚未反馈时返回 null(不是错误分支)
* @remark 「还没反馈」是正常状态,页面据此显示「写反馈」而不是错误提示,故不抛异常
*/
export async function fetchCandidateFeedback(
applicationId: string,
): Promise<CandidateFeedback | null> {
if (isMockMode()) {
const map = await readCandidateFeedbackMap();
return mockRequest(map[applicationId] ?? null);
}
const data = await request<CandidateFeedback | null>(
`/applications/${applicationId}/feedback`,
{ method: 'GET', auth: true },
);
return data ?? null;
}
/**
* 创建候选人反馈
* @method POST
* @url /applications/{applicationId}/feedback
* @param applicationId 投递 id
* @param input 评分 / 内容 / 标签
* @returns Promise<CandidateFeedback>
*/
export async function createCandidateFeedback(
applicationId: string,
input: CandidateFeedbackInput,
): Promise<CandidateFeedback> {
if (isMockMode()) {
const map = await readCandidateFeedbackMap();
if (map[applicationId]) {
throw new ApiError({ httpStatus: 409, code: 409, message: '该投递已反馈过,请直接修改' });
}
const now = new Date().toISOString();
const created: CandidateFeedback = {
id: `fb-${Date.now()}`,
applicationId,
rating: input.rating,
content: input.content,
tags: input.tags,
createdAt: now,
updatedAt: now,
};
map[applicationId] = created;
await storageSet(CANDIDATE_FEEDBACK_KEY, JSON.stringify(map));
return mockRequest(created);
}
return request<CandidateFeedback>(`/applications/${applicationId}/feedback`, {
method: 'POST',
auth: true,
body: input,
});
}
/**
* 修改候选人反馈
* @method PUT
* @url /applications/{applicationId}/feedback
* @returns Promise<CandidateFeedback>
*/
export async function updateCandidateFeedback(
applicationId: string,
input: CandidateFeedbackInput,
): Promise<CandidateFeedback> {
if (isMockMode()) {
const map = await readCandidateFeedbackMap();
const existing = map[applicationId];
if (!existing) {
throw new ApiError({ httpStatus: 404, code: 404, message: '反馈不存在或无权修改' });
}
const updated: CandidateFeedback = {
...existing,
rating: input.rating,
content: input.content,
tags: input.tags,
updatedAt: new Date().toISOString(),
};
map[applicationId] = updated;
await storageSet(CANDIDATE_FEEDBACK_KEY, JSON.stringify(map));
return mockRequest(updated);
}
return request<CandidateFeedback>(`/applications/${applicationId}/feedback`, {
method: 'PUT',
auth: true,
body: input,
});
}
/**
* 删除候选人反馈
* @method DELETE
* @url /applications/{applicationId}/feedback
*/
export async function deleteCandidateFeedback(applicationId: string): Promise<void> {
if (isMockMode()) {
const map = await readCandidateFeedbackMap();
if (!map[applicationId]) {
throw new ApiError({ httpStatus: 404, code: 404, message: '反馈不存在或无权删除' });
}
delete map[applicationId];
await storageSet(CANDIDATE_FEEDBACK_KEY, JSON.stringify(map));
return mockRequest(undefined);
}
await request<void>(`/applications/${applicationId}/feedback`, { method: 'DELETE', auth: true });
}
/* ===================== Resume 上传/删除(后端有,前端之前没接) ===================== */
/**
@ -4870,6 +5274,7 @@ export async function submitAppeal(
const MOCK_ENTERPRISE_QUICK_ACTIONS: EnterpriseQuickAction[] = [
{ id: 'publish', label: '发布岗位', icon: 'add-circle-outline', route: '/job-publish', color: 'primary' },
{ id: 'jobs', label: '岗位管理', icon: 'business-outline', route: '/job-management', color: 'primary' },
{ id: 'applications', label: '收到的投递', icon: 'file-tray-full-outline', route: '/enterprise-applications', color: 'info' },
{ id: 'evaluations', label: '岗位评价管理', icon: 'chatbubbles-outline', route: '/job-evaluations', color: 'success' },
{ id: 'candidates', label: '人才推荐', icon: 'people-outline', route: '/talent-recommendation', color: 'info' },
{ id: 'communication', label: '企业沟通', icon: 'chatbubble-outline', route: '/enterprise-chat', color: 'action' },

7
src/api/http.ts

@ -72,7 +72,12 @@ export class ApiError extends Error {
}
}
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
/**
* 支持的 HTTP 方法。
* `PATCH` 必须在内:后端契约里投递状态流转(PATCH /applications/{id}/status)、
* 匹配意愿分、HR 适合度分等都用 PATCH,缺了它这些接口只能绕开统一请求层自己拼 fetch。
*/
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export interface RequestOptions {
method?: HttpMethod;

8
src/api/index.ts

@ -210,6 +210,14 @@ export {
fetchApplicationDetail,
fetchApplicationTimeline,
withdrawApplication,
toBackendApplicationStatus,
fetchCompanyApplications,
fetchCompanyApplicationDetail,
updateApplicationStatus,
fetchCandidateFeedback,
createCandidateFeedback,
updateCandidateFeedback,
deleteCandidateFeedback,
uploadResume,
deleteResume,
setFirstPassword,

67
src/constants/index.ts

@ -2,6 +2,7 @@
* 常量统一导出(枚举、配置项等)
*/
import type {
ApplicationStatus,
CarrierFailureReason,
CarrierName,
CitySocialProfile,
@ -381,3 +382,69 @@ export {
REVIEW_TAG_SUGGESTIONS,
emptyDimensionScores,
} from './review';
/* ===================== 企业端 · 收到的投递 ===================== */
/**
* 投递状态展示文案(唯一真相源)。
* 声明为 `Record<ApplicationStatus, string>` 而不是 `Record<string, string>`:
* 后端新增状态时这里会编译报错,而不是在界面上显示一个没有文案的空徽标。
*/
export const APPLICATION_STATUS_LABELS: Record<ApplicationStatus, string> = {
submitted: '待查看',
viewed: '已查看',
shortlisted: '已进入候选',
interview: '面试中',
offer: '已发 Offer',
accepted: '已接受',
hired: '已录用',
rejected: '已拒绝',
withdrawn: '已撤回',
};
/** 企业端投递列表的筛选标签(顺序即展示顺序);`value` 为 null 表示「全部」 */
export const COMPANY_APPLICATION_FILTERS: ReadonlyArray<{
value: ApplicationStatus | null;
label: string;
}> = [
{ value: null, label: '全部' },
{ value: 'submitted', label: '待查看' },
{ value: 'viewed', label: '已查看' },
{ value: 'interview', label: '面试中' },
{ value: 'hired', label: '已录用' },
{ value: 'rejected', label: '已拒绝' },
];
/**
* 企业侧可执行的下一步状态。
*
* 对齐后端流转 `SUBMITTED → VIEWED → INTERVIEW → REJECTED / HIRED`:
* 页面只渲染这张表里列出的按钮,不让企业把投递「倒着改」回去 —— 状态倒退会让
* 学生端的时间线出现前后矛盾,且后端也会拒绝。
* 空数组 = 终态,界面上不再提供任何状态按钮。
*/
export const APPLICATION_STATUS_TRANSITIONS: Record<ApplicationStatus, ApplicationStatus[]> = {
submitted: ['viewed', 'rejected'],
viewed: ['interview', 'rejected'],
shortlisted: ['interview', 'rejected'],
interview: ['offer', 'hired', 'rejected'],
offer: ['hired', 'rejected'],
accepted: ['hired'],
hired: [],
rejected: [],
withdrawn: [],
};
/** 候选人反馈的标签候选(企业给候选人打标,一键多选) */
export const CANDIDATE_FEEDBACK_TAG_SUGGESTIONS: string[] = [
'沟通顺畅',
'专业扎实',
'经历匹配',
'表达清晰',
'项目经验丰富',
'稳定性好',
'期望薪资偏高',
'经验不足',
'沟通待改进',
'岗位匹配度低',
];

5
src/hooks/index.ts

@ -12,6 +12,11 @@ export { useAppealForm } from './useAppealForm';
export { useAsyncData } from './useAsyncData';
export { useAuditReport } from './useAuditReport';
export { useCandidateDetail } from './useCandidateDetail';
export {
useCompanyApplications,
useCompanyApplicationDetail,
} from './useCompanyApplications';
export type { CompanyApplicationFilters } from './useCompanyApplications';
export { useCompareData } from './useCompareData';
export { useChangePassword } from './useChangePassword';
export { useConversation } from './useConversation';

170
src/hooks/useCompanyApplications.ts

@ -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,
};
}

32
src/theme/colors.ts

@ -228,6 +228,38 @@ export const articleThumbnailColors: Record<
green: { bg: colors.successLight, color: colors.success },
};
/**
* 投递状态 → 徽标底色 / 文字色(企业端「收到的投递」用)。
*
* 九态各自可辨,不让用户靠猜:未读/已读是中性递进,进入候选是信息蓝紫,
* 面试中是「等你处理」的橙,Offer / 接受 / 录用走绿并逐级加深,拒绝走红,撤回与已读同灰。
* 键写成字面量联合(保持本文件零 import),取值方拿到的是 ApplicationStatus,
* 将来后端新增状态时会在此处编译报错,而不是静默显示成没有颜色的灰块。
*/
export const applicationStatusColors: Record<
| 'submitted'
| 'viewed'
| 'shortlisted'
| 'interview'
| 'offer'
| 'accepted'
| 'hired'
| 'rejected'
| 'withdrawn',
{ bg: string; color: string }
> = {
submitted: { bg: colors.primaryLight, color: colors.primaryText },
viewed: { bg: colors.borderLight, color: colors.textSecondary },
shortlisted: { bg: colors.infoLight, color: colors.infoText },
interview: { bg: colors.actionLight, color: colors.actionText },
offer: { bg: colors.compareOrange + '20', color: colors.compareOrange },
accepted: { bg: colors.successLight, color: colors.successText },
/** 录用是终态里最需要被一眼认出的,直接反白 */
hired: { bg: colors.success, color: colors.white },
rejected: { bg: colors.dangerLight, color: colors.dangerText },
withdrawn: { bg: colors.borderLight, color: colors.textHint },
};
/* ===================== 暗色模式(亮 → 暗映射) ===================== */
export const darkColors = {

1
src/theme/index.ts

@ -19,4 +19,5 @@ export {
levelLabelTextColors,
hollandTypeColors,
articleThumbnailColors,
applicationStatusColors,
} from './colors';

87
src/types/application.ts

@ -4,7 +4,14 @@
* 待后端补全字段名后再收敛。
*/
/** 投递状态(后端 timeline 端点暗示的状态枚举) */
/**
* 投递状态。
*
* 后端的流转序列是 `SUBMITTED → VIEWED → INTERVIEW → REJECTED / HIRED`(大写 token),
* 前端统一用小写联合类型,跨边界时由 api 层 `toBackendApplicationStatus` /
* `toApplicationStatus` 转换 —— 页面层不允许直接拼大写字符串。
* 「已录用(hired)」与「已接受 Offer(accepted)」是两个不同状态,早期版本漏了前者。
*/
export type ApplicationStatus =
| 'submitted' // 已投递
| 'viewed' // 已查看
@ -12,6 +19,7 @@ export type ApplicationStatus =
| 'interview' // 面试中
| 'offer' // 已发 Offer
| 'accepted' // 已接受 Offer
| 'hired' // 已录用(企业侧终态)
| 'rejected' // 已拒绝
| 'withdrawn'; // 已撤回
@ -64,3 +72,80 @@ export interface PageResult<T> {
page: number;
totalPages: number;
}
/* ===================== 企业端 · 收到的投递 ===================== */
/**
* 企业候选人列表行(对齐 GET /company/candidates 的 items)。
* 字段名取自 OpenAPI 契约,不做重命名 —— 这层是与后端对齐的边界,改名就失去了可比性。
*/
export interface CompanyApplicationItem {
/** 投递 id(后续所有投递级操作都用它,不是候选人 id) */
applicationId: string;
jobId: string;
/** 岗位名称 */
jobTitle: string;
/** 投递状态(api 层已由后端大写 token 转换为前端联合类型) */
status: ApplicationStatus;
candidateName: string;
candidateSchool?: string;
candidateEducation?: string;
/** 毕业年份,如 2026 */
candidateGraduationYear?: number;
candidateAvatarUrl?: string;
createdAt?: string;
updatedAt?: string;
}
/** 企业候选人详情(对齐 GET /company/candidates/{applicationId}) */
export interface CompanyCandidateDetail extends CompanyApplicationItem {
/** 岗位所在城市 */
jobCity?: string;
/** 用工类型,如「全职」 */
jobEmploymentType?: string;
/** 求职信 */
coverLetter?: string;
/** 候选人自我介绍 */
candidateBio?: string;
/** 候选人所在城市 */
candidateLocation?: string;
/** 技能标签 */
candidateSkills?: string[];
}
/** 企业端投递列表查询参数(对齐 GET /company/candidates 的 query) */
export interface CompanyApplicationQuery {
jobId?: string;
status?: ApplicationStatus;
keyword?: string;
/** 排序字段,如 createdAt */
sort?: string;
order?: 'asc' | 'desc';
page?: number;
size?: number;
}
/* ===================== 候选人反馈 ===================== */
/**
* 候选人反馈:企业对该投递的评价,一条投递最多一条。
* 与服务端 `CandidateFeedbackResponse` 一一对应(含 createdAt / updatedAt)。
*/
export interface CandidateFeedback {
id: string;
applicationId: string;
/** 评分 1-5 */
rating: number;
content: string;
tags: string[];
createdAt?: string;
updatedAt?: string;
}
/** 创建 / 修改候选人反馈的请求体(对齐CandidateFeedbackRequest) */
export interface CandidateFeedbackInput {
/** 评分 1-5 */
rating: number;
content: string;
tags: string[];
}

5
src/types/index.ts

@ -311,5 +311,10 @@ export type {
ApplicationItem,
ApplicationStatus,
ApplicationTimelineEvent,
CandidateFeedback,
CandidateFeedbackInput,
CompanyApplicationItem,
CompanyApplicationQuery,
CompanyCandidateDetail,
PageResult,
} from './application';

Loading…
Cancel
Save