From 51f5a9b2d29a074719fb163332e37d37ec815317 Mon Sep 17 00:00:00 2001 From: doublex001 <16157008+doublex001@user.noreply.gitee.com> Date: Tue, 29 Sep 2026 17:21:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(enterprise):=20=E8=A1=A5=E9=BD=90=E4=BC=81?= =?UTF-8?q?=E4=B8=9A=E7=AB=AF=E6=94=B6=E5=88=B0=E7=9A=84=E6=8A=95=E9=80=92?= =?UTF-8?q?=E4=B8=8E=E5=80=99=E9=80=89=E4=BA=BA=E5=8F=8D=E9=A6=88=EF=BC=88?= =?UTF-8?q?P0-7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端有、前端完全没有的整条企业投递链路(含 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 例通过 --- app/_layout.tsx | 3 + app/application-detail.tsx | 945 ++++++++++++++++++ app/enterprise-applications.tsx | 389 +++++++ app/jobs/[id].tsx | 74 +- components/RatingStars.tsx | 78 ++ src/__tests__/enterprise-applications.test.ts | 230 +++++ src/api/api.ts | 429 +++++++- src/api/http.ts | 7 +- src/api/index.ts | 8 + src/constants/index.ts | 67 ++ src/hooks/index.ts | 5 + src/hooks/useCompanyApplications.ts | 170 ++++ src/theme/colors.ts | 32 + src/theme/index.ts | 1 + src/types/application.ts | 87 +- src/types/index.ts | 5 + 16 files changed, 2448 insertions(+), 82 deletions(-) create mode 100644 app/application-detail.tsx create mode 100644 app/enterprise-applications.tsx create mode 100644 components/RatingStars.tsx create mode 100644 src/__tests__/enterprise-applications.test.ts create mode 100644 src/hooks/useCompanyApplications.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index 46a9640..5f27fe2 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -26,6 +26,9 @@ export default function RootLayout() { + {/* 企业端投递链路:两页都自带 header,关掉 Stack 默认 header 免得叠两条 */} + + diff --git a/app/application-detail.tsx b/app/application-detail.tsx new file mode 100644 index 0000000..03e1101 --- /dev/null +++ b/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 ( + + + {APPLICATION_STATUS_LABELS[status]} + + + ); +} + +function SectionTitle({ title }: { title: string }) { + return {title}; +} + +/** 候选人 + 岗位信息 */ +function ProfileCard({ detail }: { detail: CompanyCandidateDetail }) { + const meta = [ + detail.candidateSchool, + detail.candidateEducation, + detail.candidateGraduationYear ? `${detail.candidateGraduationYear} 届` : undefined, + detail.candidateLocation, + ] + .filter(Boolean) + .join(' · '); + + return ( + + + + {detail.candidateName.slice(0, 1)} + + + + {detail.candidateName} + + + {meta || '学历信息未公开'} + + + + {detail.candidateBio ? ( + {detail.candidateBio} + ) : null} + + {detail.candidateSkills && detail.candidateSkills.length > 0 ? ( + + {detail.candidateSkills.map((skill) => ( + + {skill} + + ))} + + ) : null} + + + + + 应聘岗位 + {detail.jobTitle} + + + 用工类型 + + {[detail.jobEmploymentType, detail.jobCity].filter(Boolean).join(' · ') || '—'} + + + + 投递时间 + {formatDateTime(detail.createdAt)} + + + 最近更新 + {formatDateTime(detail.updatedAt)} + + + ); +} + +/** 状态流转:只渲染允许的下一步,终态给出明确说明而不是留白 */ +function StatusActionCard({ + status, + updating, + onChange, +}: { + status: ApplicationStatus; + updating: boolean; + onChange: (next: ApplicationStatus) => void; +}) { + const nextOptions = APPLICATION_STATUS_TRANSITIONS[status]; + return ( + + + 当前状态 + + + {nextOptions.length === 0 ? ( + + {status === 'hired' + ? '该候选人已录用,投递流程结束。' + : status === 'rejected' + ? '该投递已拒绝,流程结束。' + : '该投递已结束,无法再变更状态。'} + + ) : ( + <> + 推进到: + + {nextOptions.map((next) => { + const tone = applicationStatusColors[next]; + const isReject = next === 'rejected'; + return ( + onChange(next)} + > + {updating ? ( + + ) : ( + + {APPLICATION_STATUS_LABELS[next]} + + )} + + ); + })} + + + )} + + ); +} + +/** 候选人反馈区:已有反馈可改可删,没有则引导去写 */ +function FeedbackCard({ + feedback, + onEdit, + onDelete, + busy, +}: { + feedback: CandidateFeedback | null; + onEdit: () => void; + onDelete: () => void; + busy: boolean; +}) { + if (!feedback) { + return ( + + + + 还没有写反馈 + + 记录这位候选人的面试表现,后续 Offer 决策时可回溯 + + + 写反馈 + + + + ); + } + + return ( + + + + {feedback.rating}.0 + + {feedback.content} + {feedback.tags.length > 0 ? ( + + {feedback.tags.map((tag) => ( + + {tag} + + ))} + + ) : null} + 最后更新 {formatDateTime(feedback.updatedAt)} + + + + 修改 + + + {busy ? ( + + ) : ( + <> + + 删除 + + )} + + + + ); +} + +/** 反馈编辑器(新建 / 修改共用) */ +function FeedbackEditorModal({ + visible, + initial, + submitting, + onClose, + onSubmit, +}: { + visible: boolean; + initial: CandidateFeedback | null; + submitting: boolean; + onClose: () => void; + onSubmit: (input: CandidateFeedbackInput) => void; +}) { + const [draft, setDraft] = useState(EMPTY_FEEDBACK); + const [seededFor, setSeededFor] = useState(null); + + // 打开时按 initial 播种一次:用「是否已播种过这条反馈」判断,避免每次父组件重渲染都覆盖用户正在输入的内容 + const seedKey = visible ? (initial?.id ?? 'new') : null; + if (seedKey !== seededFor) { + setSeededFor(seedKey); + setDraft( + initial + ? { rating: initial.rating, content: initial.content, tags: [...initial.tags] } + : EMPTY_FEEDBACK, + ); + } + + const toggleTag = (tag: string) => { + setDraft((prev) => ({ + ...prev, + tags: prev.tags.includes(tag) ? prev.tags.filter((t) => t !== tag) : [...prev.tags, tag], + })); + }; + + const handleSubmit = () => { + if (draft.rating <= 0) { + Alert.alert('提示', '请先给候选人一个评分'); + return; + } + if (!draft.content.trim()) { + Alert.alert('提示', '请填写反馈内容'); + return; + } + onSubmit({ ...draft, content: draft.content.trim() }); + }; + + return ( + + + + + {initial ? '修改反馈' : '写反馈'} + + + + + + 评分 + + setDraft((prev) => ({ ...prev, rating: next }))} + /> + + {draft.rating > 0 ? `${draft.rating} 分` : '点击星星评分'} + + + + 标签(选填,可多选) + + {CANDIDATE_FEEDBACK_TAG_SUGGESTIONS.map((tag) => { + const active = draft.tags.includes(tag); + return ( + toggleTag(tag)} + > + + {tag} + + + ); + })} + + + 反馈内容 + setDraft((prev) => ({ ...prev, content: text }))} + multiline + maxLength={500} + /> + {draft.content.length}/500 + + + + + {submitting ? ( + + ) : ( + {initial ? '保存修改' : '提交反馈'} + )} + + + + + + ); +} + +/* ===================== 页面 ===================== */ + +export default function ApplicationDetailScreen() { + const router = useRouter(); + const { id } = useLocalSearchParams<{ id: string }>(); + const applicationId = id ?? ''; + + const { + detail, + timeline, + feedback, + loading, + error, + refresh, + updating, + savingFeedback, + changeStatus, + saveFeedback, + removeFeedback, + } = useCompanyApplicationDetail(applicationId); + + const [editorOpen, setEditorOpen] = useState(false); + + const handleChangeStatus = (next: ApplicationStatus) => { + Alert.alert( + '确认变更状态', + `将投递状态改为「${APPLICATION_STATUS_LABELS[next]}」?该操作会同步给学生端。`, + [ + { text: '取消', style: 'cancel' }, + { + text: '确认变更', + style: next === 'rejected' ? 'destructive' : 'default', + onPress: async () => { + try { + await changeStatus(next); + } catch (e) { + Alert.alert('变更失败', (e as Error).message || '请稍后重试'); + } + }, + }, + ], + ); + }; + + const handleSubmitFeedback = async (input: CandidateFeedbackInput) => { + try { + await saveFeedback(input, feedback); + setEditorOpen(false); + } catch (e) { + Alert.alert('保存失败', (e as Error).message || '请稍后重试'); + } + }; + + const handleDeleteFeedback = () => { + Alert.alert('删除反馈', '删除后无法恢复,确定要删除这条反馈吗?', [ + { text: '取消', style: 'cancel' }, + { + text: '删除', + style: 'destructive', + onPress: async () => { + try { + await removeFeedback(); + } catch (e) { + Alert.alert('删除失败', (e as Error).message || '请稍后重试'); + } + }, + }, + ]); + }; + + return ( + + + router.back()} hitSlop={8}> + + + 投递详情 + + + + {loading ? ( + + ) : error || !detail ? ( + + ) : ( + + + + + + + {detail.coverLetter ? ( + <> + + + {detail.coverLetter} + + + ) : null} + + + {timeline.length === 0 ? ( + + 暂无状态变更记录 + + ) : ( + + {timeline.map((event, index) => ( + + + + + {APPLICATION_STATUS_LABELS[event.status] ?? event.status} + + {event.note ? ( + {event.note} + ) : null} + + {formatDateTime(event.time)} + + ))} + + )} + + + setEditorOpen(true)} + onDelete={handleDeleteFeedback} + /> + + )} + + setEditorOpen(false)} + onSubmit={handleSubmitFeedback} + /> + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: colors.pageBg, + }, + header: { + height: 48, + flexDirection: 'row', + alignItems: 'center', + }, + headerBtn: { + width: 48, + height: 48, + alignItems: 'center', + justifyContent: 'center', + }, + headerTitle: { + flex: 1, + textAlign: 'center', + fontSize: 17, + fontWeight: '700', + color: colors.textPrimary, + }, + scroll: { + flex: 1, + }, + content: { + paddingHorizontal: spacing.framework, + paddingBottom: 24, + }, + sectionTitle: { + marginTop: spacing.card, + marginBottom: spacing.card, + fontSize: 15, + fontWeight: '700', + color: colors.textPrimary, + }, + /* ---- 卡片 ---- */ + card: { + backgroundColor: colors.white, + borderRadius: spacing.card, + padding: spacing.card + 4, + marginBottom: spacing.card, + }, + badge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: spacing.card, + }, + badgeText: { + fontSize: 11, + fontWeight: '600', + }, + /* ---- 候选人 / 岗位 ---- */ + profileTop: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.card, + }, + avatar: { + width: 48, + height: 48, + borderRadius: 24, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.primaryLight, + }, + avatarText: { + fontSize: 20, + fontWeight: '700', + color: colors.primaryText, + }, + profileMain: { + flex: 1, + }, + profileNameRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.card, + }, + name: { + fontSize: 17, + fontWeight: '700', + color: colors.textPrimary, + }, + meta: { + marginTop: 4, + fontSize: 12, + color: colors.textSecondary, + }, + bio: { + marginTop: spacing.card + 4, + fontSize: 13, + lineHeight: 20, + color: colors.textPrimary, + }, + chips: { + flexDirection: 'row', + flexWrap: 'wrap', + marginTop: spacing.card + 2, + gap: spacing.card, + }, + chip: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: spacing.card, + backgroundColor: colors.pageBg, + }, + chipText: { + fontSize: 11, + color: colors.textSecondary, + }, + divider: { + height: 1, + marginVertical: spacing.card + 4, + backgroundColor: colors.borderLight, + }, + kvRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 4, + }, + kvLabel: { + fontSize: 12, + color: colors.textHint, + }, + kvValue: { + flexShrink: 1, + textAlign: 'right', + fontSize: 12, + color: colors.textPrimary, + }, + /* ---- 状态流转 ---- */ + statusRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + statusLabel: { + fontSize: 13, + color: colors.textSecondary, + }, + terminalHint: { + marginTop: spacing.card + 4, + fontSize: 12, + color: colors.textHint, + }, + actionHint: { + marginTop: spacing.card + 4, + marginBottom: spacing.card, + fontSize: 12, + color: colors.textHint, + }, + actionRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.card, + }, + actionBtn: { + minWidth: 84, + height: 34, + paddingHorizontal: 12, + borderRadius: spacing.card, + alignItems: 'center', + justifyContent: 'center', + }, + actionBtnDisabled: { + opacity: 0.6, + }, + actionText: { + fontSize: 13, + fontWeight: '600', + }, + actionTextDanger: { + fontWeight: '700', + }, + /* ---- 求职信 / 时间线 ---- */ + coverLetter: { + fontSize: 13, + lineHeight: 20, + color: colors.textPrimary, + }, + timelineEmpty: { + fontSize: 12, + color: colors.textHint, + }, + timelineRow: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingVertical: 5, + }, + timelineDot: { + width: 8, + height: 8, + borderRadius: 4, + marginTop: 5, + marginRight: spacing.card, + backgroundColor: colors.primary, + }, + timelineMain: { + flex: 1, + }, + timelineStatus: { + fontSize: 13, + fontWeight: '600', + color: colors.textPrimary, + }, + timelineNote: { + marginTop: 2, + fontSize: 11, + color: colors.textSecondary, + }, + timelineTime: { + fontSize: 11, + color: colors.textHint, + }, + /* ---- 反馈 ---- */ + feedbackEmpty: { + alignItems: 'center', + gap: 6, + paddingVertical: spacing.card, + }, + feedbackEmptyTitle: { + fontSize: 14, + fontWeight: '600', + color: colors.textSecondary, + }, + feedbackEmptyHint: { + fontSize: 12, + color: colors.textHint, + textAlign: 'center', + }, + primaryBtn: { + marginTop: spacing.card, + paddingHorizontal: 24, + height: 36, + borderRadius: spacing.card, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.primaryBtn, + }, + primaryBtnText: { + fontSize: 13, + fontWeight: '600', + color: colors.white, + }, + feedbackTop: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + feedbackRating: { + fontSize: 13, + fontWeight: '700', + color: STAR_COLOR, + }, + feedbackContent: { + marginTop: spacing.card, + fontSize: 13, + lineHeight: 20, + color: colors.textPrimary, + }, + feedbackTime: { + marginTop: spacing.card, + fontSize: 11, + color: colors.textHint, + }, + feedbackActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: spacing.card, + paddingTop: spacing.card, + borderTopWidth: 1, + borderTopColor: colors.borderLight, + gap: 16, + }, + linkBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + linkText: { + fontSize: 12, + fontWeight: '600', + color: colors.primaryText, + }, + linkTextDanger: { + color: colors.dangerText, + }, + /* ---- 编辑器弹窗 ---- */ + modalMask: { + flex: 1, + backgroundColor: colors.scrim, + justifyContent: 'flex-end', + }, + modalSheet: { + maxHeight: '86%', + backgroundColor: colors.white, + borderTopLeftRadius: spacing.framework, + borderTopRightRadius: spacing.framework, + }, + modalHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.framework, + paddingVertical: 14, + borderBottomWidth: 1, + borderBottomColor: colors.borderLight, + }, + modalTitle: { + fontSize: 16, + fontWeight: '700', + color: colors.textPrimary, + }, + modalBody: { + paddingHorizontal: spacing.framework, + paddingTop: 12, + }, + fieldLabel: { + marginTop: 14, + marginBottom: 8, + fontSize: 13, + fontWeight: '600', + color: colors.textPrimary, + }, + ratingRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + ratingHint: { + fontSize: 13, + color: colors.textSecondary, + }, + tagPicker: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.card, + }, + tagChip: { + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: spacing.card, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.white, + }, + tagChipActive: { + borderColor: colors.primary, + backgroundColor: colors.primaryLight, + }, + tagChipText: { + fontSize: 12, + color: colors.textSecondary, + }, + tagChipTextActive: { + color: colors.primaryText, + fontWeight: '600', + }, + modalInput: { + minHeight: 96, + borderRadius: spacing.card, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.pageBg, + paddingHorizontal: spacing.card, + paddingVertical: spacing.card, + fontSize: 13, + lineHeight: 20, + color: colors.textPrimary, + textAlignVertical: 'top', + }, + counterText: { + marginTop: 6, + textAlign: 'right', + fontSize: 11, + color: colors.textHint, + }, + modalFooter: { + paddingHorizontal: spacing.framework, + paddingTop: 10, + paddingBottom: 24, + }, + submitBtn: { + height: 44, + borderRadius: 22, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.primaryBtn, + }, + submitBtnDisabled: { + opacity: 0.6, + }, + submitText: { + fontSize: 15, + fontWeight: '700', + color: colors.white, + }, +}); diff --git a/app/enterprise-applications.tsx b/app/enterprise-applications.tsx new file mode 100644 index 0000000..ff6c8b7 --- /dev/null +++ b/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 ( + + + {APPLICATION_STATUS_LABELS[status]} + + + ); +} + +/** 单行投递。整行可点 → 进详情做状态流转与反馈 */ +function ApplicationRow({ + item, + onPress, +}: { + item: CompanyApplicationItem; + onPress: () => void; +}) { + return ( + + + {item.candidateName.slice(0, 1)} + + + + + {item.candidateName} + + + + + {[item.candidateSchool, item.candidateEducation, item.candidateGraduationYear + ? `${item.candidateGraduationYear} 届` + : undefined] + .filter(Boolean) + .join(' · ') || '学历信息未公开'} + + + + + + {item.jobTitle} + + + {formatAppliedAt(item.createdAt)} + + + + + ); +} + +/* ===================== 页面 ===================== */ + +export default function EnterpriseApplicationsScreen() { + const router = useRouter(); + const [status, setStatus] = useState(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 ( + + {/* 顶部导航 */} + + router.back()} hitSlop={8}> + + + + {ENTERPRISE_DISPLAY_NAME} + 收到的投递 + + + + + {/* 关键词搜索 */} + + + + {keyword.length > 0 ? ( + setKeyword('')} hitSlop={8} accessibilityLabel="清空搜索"> + + + ) : null} + + + {/* 状态筛选:计数来自独立计数口,不随当前筛选变化 */} + + {COMPANY_APPLICATION_FILTERS.map((filter) => { + const active = filter.value === status; + const count = filter.value === null ? allCount : counts[filter.value] ?? 0; + return ( + setStatus(filter.value)} + > + + {filter.label} {count} + + + ); + })} + + + {/* 列表区:四态齐全 */} + {loading && applications.length === 0 ? ( + + ) : error ? ( + + ) : isEmpty ? ( + { + setKeyword(''); + setStatus(null); + } + : undefined + } + /> + ) : ( + + + 共 {total} 条投递 + {status ? ` · 已按「${APPLICATION_STATUS_LABELS[status]}」筛选` : ''} + + {applications.map((item) => ( + router.push(`/application-detail?id=${item.applicationId}`)} + /> + ))} + + )} + + ); +} + +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, + }, +}); diff --git a/app/jobs/[id].tsx b/app/jobs/[id].tsx index 1d34ece..45b74cf 100644 --- a/app/jobs/[id].tsx +++ b/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 ( - - {Array.from({ length: REVIEW_RATING_MAX }, (_, i) => { - const starValue = i + 1; - const filled = starValue <= value; - const star = ( - - ); - if (readonly) { - return ( - - {star} - - ); - } - return ( - onChange(starValue)} - accessibilityRole="button" - accessibilityLabel={`打 ${starValue} 分`} - > - {star} - - ); - })} - - ); -} +/* ===================== 评价:汇总 / 卡片 / 编辑器 ===================== */ /** * 评价汇总卡:平均分 + 星级分布 + 热门标签。 @@ -249,7 +195,7 @@ function ReviewSummaryCard({ summary }: { summary: JobReviewSummary }) { {summary.averageRating.toFixed(1)} - + {summary.total} 条真实评价 @@ -308,7 +254,7 @@ function ReviewCard({ {typeof review.rating === 'number' && review.rating > 0 ? ( - + {review.rating}.0 ) : null} @@ -447,7 +393,7 @@ function ReviewEditorModal({ 综合评分 - setDraft((prev) => ({ ...prev, rating: next }))} @@ -462,7 +408,7 @@ function ReviewEditorModal({ {REVIEW_DIMENSIONS.map((dim) => ( {dim.label} - 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', diff --git a/components/RatingStars.tsx b/components/RatingStars.tsx new file mode 100644 index 0000000..9c920a7 --- /dev/null +++ b/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 ( + + {Array.from({ length: max }, (_, i) => { + const starValue = i + 1; + const filled = starValue <= value; + const star = ( + + ); + if (readonly) { + return ( + + {star} + + ); + } + return ( + onChange(starValue)} + accessibilityRole="button" + accessibilityLabel={`打 ${starValue} 分`} + > + {star} + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + }, + cell: { + paddingHorizontal: 1, + }, +}); diff --git a/src/__tests__/enterprise-applications.test.ts b/src/__tests__/enterprise-applications.test.ts new file mode 100644 index 0000000..31032d6 --- /dev/null +++ b/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(); + 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'); + }); +}); diff --git a/src/api/api.ts b/src/api/api.ts index 9c6a53e..0be246f 100644 --- a/src/api/api.ts +++ b/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 & { + 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 { 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 = { + 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 */ +const CANDIDATE_FEEDBACK_KEY = 'enterprise:candidate-feedback'; + +/** 读取企业手动改过的状态覆盖表 */ +async function readApplicationStatusOverrides(): Promise> { + const raw = await storageGet(COMPANY_APPLICATION_STATUS_KEY); + return raw ? (JSON.parse(raw) as Record) : {}; +} + +/** 读取候选人反馈表(key 为 applicationId) */ +async function readCandidateFeedbackMap(): Promise> { + const raw = await storageGet(CANDIDATE_FEEDBACK_KEY); + return raw ? (JSON.parse(raw) as Record) : {}; +} + +/** 合并种子数据与状态覆盖,得到当前企业视角的投递列表 */ +async function readCompanyApplications(): Promise { + 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> 分页列表 + */ +export async function fetchCompanyApplications( + query?: CompanyApplicationQuery, +): Promise> { + 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 & { + 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 + */ +export async function fetchCompanyApplicationDetail( + applicationId: string, +): Promise { + 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({ + ...found, + jobCity: '上海', + jobEmploymentType: '全职', + coverLetter: '贵司的岗位与我的经历高度匹配,期待有机会进一步沟通。', + candidateBio: `${found.candidateGraduationYear ?? ''} 年毕业,${found.candidateEducation ?? '本科'},熟悉岗位核心技能,有完整项目落地经验。`, + candidateLocation: '上海', + candidateSkills: ['React', 'TypeScript', 'Node.js', '数据可视化'], + }); + } + const data = await request & { + 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 { + 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 尚未反馈时返回 null(不是错误分支) + * @remark 「还没反馈」是正常状态,页面据此显示「写反馈」而不是错误提示,故不抛异常 + */ +export async function fetchCandidateFeedback( + applicationId: string, +): Promise { + if (isMockMode()) { + const map = await readCandidateFeedbackMap(); + return mockRequest(map[applicationId] ?? null); + } + const data = await request( + `/applications/${applicationId}/feedback`, + { method: 'GET', auth: true }, + ); + return data ?? null; +} + +/** + * 创建候选人反馈 + * @method POST + * @url /applications/{applicationId}/feedback + * @param applicationId 投递 id + * @param input 评分 / 内容 / 标签 + * @returns Promise + */ +export async function createCandidateFeedback( + applicationId: string, + input: CandidateFeedbackInput, +): Promise { + 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(`/applications/${applicationId}/feedback`, { + method: 'POST', + auth: true, + body: input, + }); +} + +/** + * 修改候选人反馈 + * @method PUT + * @url /applications/{applicationId}/feedback + * @returns Promise + */ +export async function updateCandidateFeedback( + applicationId: string, + input: CandidateFeedbackInput, +): Promise { + 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(`/applications/${applicationId}/feedback`, { + method: 'PUT', + auth: true, + body: input, + }); +} + +/** + * 删除候选人反馈 + * @method DELETE + * @url /applications/{applicationId}/feedback + */ +export async function deleteCandidateFeedback(applicationId: string): Promise { + 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(`/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' }, diff --git a/src/api/http.ts b/src/api/http.ts index ad82738..60d9fdd 100644 --- a/src/api/http.ts +++ b/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; diff --git a/src/api/index.ts b/src/api/index.ts index 1dc2009..59d4383 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -210,6 +210,14 @@ export { fetchApplicationDetail, fetchApplicationTimeline, withdrawApplication, + toBackendApplicationStatus, + fetchCompanyApplications, + fetchCompanyApplicationDetail, + updateApplicationStatus, + fetchCandidateFeedback, + createCandidateFeedback, + updateCandidateFeedback, + deleteCandidateFeedback, uploadResume, deleteResume, setFirstPassword, diff --git a/src/constants/index.ts b/src/constants/index.ts index 62af110..a7da4a2 100644 --- a/src/constants/index.ts +++ b/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` 而不是 `Record`: + * 后端新增状态时这里会编译报错,而不是在界面上显示一个没有文案的空徽标。 + */ +export const APPLICATION_STATUS_LABELS: Record = { + 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 = { + 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[] = [ + '沟通顺畅', + '专业扎实', + '经历匹配', + '表达清晰', + '项目经验丰富', + '稳定性好', + '期望薪资偏高', + '经验不足', + '沟通待改进', + '岗位匹配度低', +]; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 95d48a2..597618a 100644 --- a/src/hooks/index.ts +++ b/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'; diff --git a/src/hooks/useCompanyApplications.ts b/src/hooks/useCompanyApplications.ts new file mode 100644 index 0000000..773355c --- /dev/null +++ b/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; + counts: Record; +} + +export function useCompanyApplications(filters: CompanyApplicationFilters) { + const { jobId, status, keyword } = filters; + + const fetcher = useCallback(async (): Promise => { + // 计数口不带 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 = {}; + all.items.forEach((item) => { + counts[item.status] = (counts[item.status] ?? 0) + 1; + }); + return { page, counts }; + }, [jobId, status, keyword]); + + const { data, loading, error, refresh } = useAsyncData(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 => { + const [detail, timeline, feedback] = await Promise.all([ + fetchCompanyApplicationDetail(applicationId), + fetchApplicationTimeline(applicationId), + fetchCandidateFeedback(applicationId), + ]); + return { detail, timeline, feedback }; + }, [applicationId]); + + const { data, loading, error, refresh } = useAsyncData(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, + }; +} diff --git a/src/theme/colors.ts b/src/theme/colors.ts index 073ea17..244acf1 100644 --- a/src/theme/colors.ts +++ b/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 = { diff --git a/src/theme/index.ts b/src/theme/index.ts index 4ba67da..1f1a725 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -19,4 +19,5 @@ export { levelLabelTextColors, hollandTypeColors, articleThumbnailColors, + applicationStatusColors, } from './colors'; diff --git a/src/types/application.ts b/src/types/application.ts index 6dec4ed..081c7a5 100644 --- a/src/types/application.ts +++ b/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 { 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[]; +} diff --git a/src/types/index.ts b/src/types/index.ts index 3552c58..43abdac 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -311,5 +311,10 @@ export type { ApplicationItem, ApplicationStatus, ApplicationTimelineEvent, + CandidateFeedback, + CandidateFeedbackInput, + CompanyApplicationItem, + CompanyApplicationQuery, + CompanyCandidateDetail, PageResult, } from './application';