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';