diff --git a/app/_layout.tsx b/app/_layout.tsx
index e11576f..e4c775f 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -22,6 +22,7 @@ export default function RootLayout() {
+
diff --git a/app/jobs/[id].tsx b/app/jobs/[id].tsx
index 6effdad..8bcfecd 100644
--- a/app/jobs/[id].tsx
+++ b/app/jobs/[id].tsx
@@ -13,7 +13,7 @@ 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 { useJobDetail } from '../../src/hooks';
+import { useFavorites, useJobDetail } from '../../src/hooks';
import { buildJobShareText } from '../../src/api';
import { shareText } from '../../src/lib/shareFile';
import {colors, spacing} from '../../src/theme';
@@ -107,21 +107,40 @@ function RadarChart({
/* ===================== 子组件 ===================== */
-/** 顶部导航:返回 + 标题 + 分享(onShare 缺省时不渲染分享按钮,避免留下点了没反应的入口) */
-function Header({ onBack, onShare }: { onBack: () => void; onShare?: () => void }) {
+/** 顶部导航:返回 + 标题 + 收藏 + 分享(onShare 缺省时不渲染分享按钮,避免留下点了没反应的入口) */
+function Header({
+ onBack,
+ onShare,
+ favorited,
+ onToggleFavorite,
+}: {
+ onBack: () => void;
+ onShare?: () => void;
+ favorited?: boolean;
+ onToggleFavorite?: () => void;
+}) {
return (
岗位详情
- {onShare ? (
-
-
-
- ) : (
-
- )}
+
+ {onToggleFavorite ? (
+
+
+
+ ) : null}
+ {onShare ? (
+
+
+
+ ) : null}
+
);
}
@@ -159,6 +178,10 @@ export default function JobDetailScreen() {
const [comment, setComment] = useState('');
const [publishing, setPublishing] = useState(false);
+ // 收藏态:用已收藏 id 集合判断是否已收藏(仅 STUDENT 可收藏)
+ const { favoriteIds, toggle: toggleFavorite } = useFavorites();
+ const favorited = id ? favoriteIds.has(id) : false;
+
/**
* 分享岗位:把当前岗位拼成文本经系统分享面板发出。
* `ok` 只代表面板已拉起,不能据此提示「分享成功」,只在失败时说明原因(见 lib/shareFile)。
@@ -194,7 +217,7 @@ export default function JobDetailScreen() {
if (error) {
return (
- router.back()} />
+ router.back()} favorited={false} onToggleFavorite={() => {}} />
加载失败,请重试
@@ -209,7 +232,7 @@ export default function JobDetailScreen() {
if (loading || !detail || !overallScore || !salaryData) {
return (
- router.back()} />
+ router.back()} favorited={false} onToggleFavorite={() => {}} />
@@ -219,7 +242,12 @@ export default function JobDetailScreen() {
return (
- router.back()} onShare={handleShare} />
+ router.back()}
+ onShare={handleShare}
+ favorited={favorited}
+ onToggleFavorite={id ? () => toggleFavorite(id) : undefined}
+ />
= {
+ offer: 'gift-outline',
+ system: 'information-circle-outline',
+ promotion: 'megaphone-outline',
+};
+
+/**
+ * 通知收件箱:列表 + 标记已读 + 全部已读。
+ * 来自后端 /notifications(STUDENT 旗下);点击单条标记已读。
+ */
+export default function NotificationsScreen() {
+ const router = useRouter();
+ const { items, loading, error, refresh, isEmpty, markRead, markAllRead } = useNotifications();
+
+ const hasUnread = items.some((n) => !n.read);
+
+ return (
+
+
+ router.back()} hitSlop={8}>
+
+
+ 通知
+ {hasUnread ? (
+
+ 全部已读
+
+ ) : (
+
+ )}
+
+
+ {loading ? (
+
+ ) : error ? (
+
+ ) : isEmpty ? (
+
+ ) : (
+
+ {items.map((n) => (
+ markRead(n.id)}
+ hitSlop={4}
+ >
+
+
+
+
+
+ {n.title}
+ {!n.read && }
+
+
+ {n.content}
+
+ {n.timestamp}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: colors.pageBg,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: spacing.card,
+ height: 48,
+ backgroundColor: colors.white,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.borderLight,
+ },
+ headerBtn: {
+ width: 40,
+ height: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ headerTitle: {
+ fontSize: 17,
+ fontWeight: '700',
+ color: colors.textPrimary,
+ },
+ markAllBtn: {
+ paddingHorizontal: 8,
+ height: 32,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ markAllText: {
+ fontSize: 13,
+ fontWeight: '600',
+ color: colors.primary,
+ },
+ scroll: {
+ flex: 1,
+ },
+ content: {
+ paddingHorizontal: spacing.card,
+ paddingTop: 12,
+ paddingBottom: 24,
+ },
+ card: {
+ flexDirection: 'row',
+ backgroundColor: colors.white,
+ borderRadius: 8,
+ padding: spacing.card,
+ marginBottom: spacing.card,
+ },
+ cardUnread: {
+ backgroundColor: colors.primaryLight,
+ },
+ iconWrap: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ backgroundColor: colors.pageBg,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: spacing.card,
+ },
+ iconWrapUnread: {
+ backgroundColor: colors.white,
+ },
+ cardBody: {
+ flex: 1,
+ },
+ cardTop: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ cardTitle: {
+ fontSize: 15,
+ fontWeight: '600',
+ color: colors.textPrimary,
+ },
+ dot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ backgroundColor: colors.primary,
+ },
+ cardContent: {
+ fontSize: 13,
+ color: colors.textSecondary,
+ marginTop: 4,
+ lineHeight: 18,
+ },
+ cardTime: {
+ fontSize: 12,
+ color: colors.textHint,
+ marginTop: 6,
+ },
+});
diff --git a/src/api/api.ts b/src/api/api.ts
index 5e469c3..060c6b6 100644
--- a/src/api/api.ts
+++ b/src/api/api.ts
@@ -166,6 +166,11 @@ import type {
CarrierFailureReason,
CarrierName,
CarrierPhoneResult,
+ ApplicationDetail,
+ ApplicationItem,
+ ApplicationStatus,
+ ApplicationTimelineEvent,
+ PageResult,
} from '../types';
import {
ALL_CITY_LABEL,
@@ -741,6 +746,7 @@ function buildUserStats(): UserStats {
*/
const MOCK_CANDIDATE_MENU_ITEMS: MenuItem[] = [
{ id: 'resume', title: '我的简历', icon: 'document-text-outline', route: '/resume' },
+ { id: 'notifications', title: '通知', icon: 'notifications-outline', route: '/notifications' },
{ id: 'favorites', title: '我的收藏', icon: 'heart-outline', route: '/favorites' },
{ id: 'history', title: '浏览历史', icon: 'time-outline', route: '/history' },
{ id: 'compare', title: '岗位对比', icon: 'git-compare-outline', route: '/jobs/compare' },
@@ -1277,6 +1283,30 @@ export async function fetchFavorites(): Promise[]> {
return res.items ?? [];
}
+/**
+ * 收藏岗位(仅 STUDENT,幂等)。
+ * @method POST
+ * @url /jobs/{jobId}/favorite
+ * @param jobId 岗位 id
+ * @returns void
+ */
+export async function addFavorite(jobId: string): Promise {
+ if (isMockMode()) return mockRequest(undefined);
+ await request(`/jobs/${jobId}/favorite`, { method: 'POST', auth: true });
+}
+
+/**
+ * 取消收藏岗位(仅 STUDENT,幂等,未收藏也返回 200)。
+ * @method DELETE
+ * @url /jobs/{jobId}/favorite
+ * @param jobId 岗位 id
+ * @returns void
+ */
+export async function removeFavorite(jobId: string): Promise {
+ if (isMockMode()) return mockRequest(undefined);
+ await request(`/jobs/${jobId}/favorite`, { method: 'DELETE', auth: true });
+}
+
/**
* 获取浏览历史(职位)列表
* @method GET
@@ -1327,6 +1357,46 @@ export async function fetchNotifications(): Promise {
return res.items ?? [];
}
+/**
+ * 标记单条通知已读(幂等)。
+ * @method POST
+ * @url /notifications/{id}/read
+ * @param id 通知 id
+ * @returns void
+ */
+export async function markNotificationRead(id: string): Promise {
+ if (isMockMode()) return mockRequest(undefined);
+ await request(`/notifications/${id}/read`, { method: 'POST', auth: true });
+}
+
+/**
+ * 标记当前用户全部通知已读(幂等)。
+ * @method POST
+ * @url /notifications/read-all
+ * @returns void
+ */
+export async function markAllNotificationsRead(): Promise {
+ if (isMockMode()) return mockRequest(undefined);
+ await request('/notifications/read-all', { method: 'POST', auth: true });
+}
+
+/**
+ * 获取当前用户未读通知数。
+ * 后端 data 可能为 { unreadCount: number } 或 { count: number } 或裸数字,统一兜底为 number。
+ * @method GET
+ * @url /notifications/unread-count
+ * @returns Promise 未读数
+ */
+export async function fetchUnreadCount(): Promise {
+ if (isMockMode()) return mockRequest(2);
+ const res = await request<{ unreadCount?: number; count?: number } | number>(
+ '/notifications/unread-count',
+ { method: 'GET', auth: true },
+ );
+ if (typeof res === 'number') return res;
+ return res.unreadCount ?? res.count ?? 0;
+}
+
/**
* 获取面试日程列表
* @method GET
@@ -1459,6 +1529,25 @@ export async function fetchArticles(): Promise {
* @url /api/user/me
* @returns Promise 用户个人信息
*/
+/**
+ * 后端学生资料 DTO(/profile/me 真实返回)。
+ * 字段名以后端契约(Apifox StudentProfileResponse)为准,前端不可臆造。
+ * 注意:无头像图片字段(resumeUrl 是简历文件,非头像),故真实资料分支 avatarKey 恒为 null。
+ */
+interface StudentProfileResponse {
+ id?: number;
+ userId?: number;
+ realName?: string;
+ bio?: string;
+ location?: string;
+ education?: string;
+ school?: string;
+ graduationYear?: number;
+ resumeUrl?: string;
+ skills?: string[];
+ createdAt?: string;
+}
+
export async function fetchUserProfile(): Promise {
// @method GET
// @url /profile/me 后端学生资料端点(当前返回空对象:用户未填资料)
@@ -1479,21 +1568,30 @@ export async function fetchUserProfile(): Promise {
});
}
- // 真实后端:/profile/me 返回学生填写的资料(当前可能为空对象 {}),
- // 用 /auth/me 的 phone+nickname 兜底组合出前端需要的 name/avatar/bio。
+ // 真实后端:/profile/me 返回 StudentProfileResponse
+ // (id, userId, realName, bio, location, education, school, graduationYear, resumeUrl, skills, createdAt)。
+ // 用户未填资料时后端返回空对象 {},此时回落到下方 /auth/me 兜底组合 name/avatar/bio。
+ // 注意:StudentProfileResponse 无头像图片字段(resumeUrl 是简历文件,非头像),
+ // 故真实资料分支 avatarKey 固定为 null,头像展示回落到姓氏首字。
try {
- const profile = await request<{ nickname?: string; avatarUrl?: string; [k: string]: unknown }>(
- '/profile/me',
- );
- // profile 非空(用户填过资料),用它
- if (profile && Object.keys(profile).length > 0) {
- const name = profile.nickname ?? '';
+ const profile = await request('/profile/me');
+ // 关键字段任一存在才认定「用户填过资料」,否则走兜底分支
+ if (
+ profile &&
+ (profile.realName || profile.bio || profile.school || profile.education || profile.location)
+ ) {
+ const name = profile.realName ?? '';
+ const avatar = name ? name.slice(0, 1) : '';
+ // bio 直接取后端填写的简介;为空时仅用真实采集字段(学校/学历/城市)组合,不拿 mock 顶替
+ const bio =
+ profile.bio ||
+ [profile.school, profile.education, profile.location].filter(Boolean).join('·');
return {
- id: '',
+ id: String(profile.id ?? profile.userId ?? ''),
name,
- avatar: name ? name.slice(0, 1) : '',
- bio: '',
- avatarKey: profile.avatarUrl ?? null,
+ avatar,
+ bio,
+ avatarKey: null,
};
}
} catch (err) {
@@ -1548,6 +1646,162 @@ export async function fetchUserStats(): Promise {
}
+/* ===================== 投递(Application) ===================== */
+
+/**
+ * 提交投递(学生端)
+ * @method POST
+ * @url /applications
+ * @param payload.jobId 目标岗位 ID
+ * @param payload.coverLetter 可选求职信
+ * @returns Promise<{ id: string }> 投递记录 ID
+ */
+export async function submitApplication(payload: {
+ jobId: string;
+ coverLetter?: string;
+}): Promise<{ id: string }> {
+ if (isMockMode()) {
+ // mock 直接返回一个生成的 id,不写 storage(求职进度页独立维护)
+ return mockRequest({ id: `mock-app-${Date.now()}` });
+ }
+ return request<{ id: number }>('/applications', {
+ method: 'POST',
+ auth: true,
+ body: { jobId: Number(payload.jobId), coverLetter: payload.coverLetter },
+ }).then((r) => ({ id: String(r.id) }));
+}
+
+/**
+ * 获取我的投递列表(学生端)
+ * @method GET
+ * @url /applications/me
+ * @param params.page 页码(后端 0/1 都收,前端从 1 开始)
+ * @param params.size 每页数量
+ * @param params.status 可选状态筛选
+ * @returns Promise> 分页投递列表
+ */
+export async function fetchMyApplications(params?: {
+ page?: number;
+ size?: number;
+ status?: ApplicationStatus;
+}): Promise> {
+ if (isMockMode()) {
+ // 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 } : {}),
+ },
+ },
+ );
+ return {
+ items: data.items.map((item) => ({ ...item, id: String(item.id) })),
+ total: data.total,
+ size: data.size,
+ page: data.page,
+ totalPages: data.totalPages,
+ };
+}
+
+/**
+ * 获取投递详情
+ * @method GET
+ * @url /applications/{id}
+ * @param id 投递记录 ID
+ * @returns Promise
+ */
+export async function fetchApplicationDetail(id: string): Promise {
+ if (isMockMode()) {
+ throw new Error('mock 模式下无投递详情数据');
+ }
+ return request(`/applications/${id}`, { method: 'GET', auth: true });
+}
+
+/**
+ * 获取投递状态时间线
+ * @method GET
+ * @url /applications/{id}/timeline
+ * @param id 投递记录 ID
+ * @returns Promise 时间线节点数组
+ */
+export async function fetchApplicationTimeline(id: string): Promise {
+ if (isMockMode()) {
+ return [];
+ }
+ const data = await request<{ timeline: ApplicationTimelineEvent[] } | ApplicationTimelineEvent[]>(
+ `/applications/${id}/timeline`,
+ { method: 'GET', auth: true },
+ );
+ return Array.isArray(data) ? data : data.timeline ?? [];
+}
+
+/**
+ * 撤回投递
+ * @method POST
+ * @url /applications/{id}/withdraw
+ * @param id 投递记录 ID
+ */
+export async function withdrawApplication(id: string): Promise {
+ if (isMockMode()) return;
+ await request(`/applications/${id}/withdraw`, { method: 'POST', auth: true });
+}
+
+/* ===================== Resume 上传/删除(后端有,前端之前没接) ===================== */
+
+/**
+ * 上传简历文件(PDF/DOCX)
+ * @method POST
+ * @url /profile/me/resume
+ * @param file 简历文件(FormData,含 file 字段)
+ */
+export async function uploadResume(file: { uri: string; name: string; type?: string }): Promise<{ fileName: string }> {
+ if (isMockMode()) {
+ return { fileName: file.name };
+ }
+ const formData = new FormData();
+ formData.append('file', file as unknown as Blob);
+ const data = await request<{ fileName: string; url?: string }>('/profile/me/resume', {
+ method: 'POST',
+ auth: true,
+ body: formData as unknown as Record,
+ });
+ return { fileName: data.fileName };
+}
+
+/**
+ * 删除已上传的简历文件
+ * @method DELETE
+ * @url /profile/me/resume
+ */
+export async function deleteResume(): Promise {
+ if (isMockMode()) return;
+ await request('/profile/me/resume', { method: 'DELETE', auth: true });
+}
+
+/* ===================== Password set/change(后端有,前端之前没接) ===================== */
+
+/**
+ * 首次设置登录密码(已注册但从未设置过密码的学生)
+ * @method POST
+ * @url /auth/password/set
+ * @param password 新密码
+ */
+export async function setFirstPassword(password: string): Promise {
+ if (isMockMode()) return;
+ await request('/auth/password/set', {
+ method: 'POST',
+ auth: true,
+ body: { password },
+ });
+}
+
/**
* 获取个人中心功能菜单列表
* @method GET
@@ -1891,7 +2145,9 @@ function matchScore(job: Omit, preference: EffectivePreference): n
*/
async function resolveJobPreference(): Promise {
const override = await readJobPreferenceOverride();
- if (override) return override;
+ // override 是设备存储的 JSON.parse 结果,可能是旧数据/被截断对象,缺字段;
+ // 合并到 EMPTY_JOB_PREFERENCE 兜底,保证所有字段均为 string,避免页面 .trim() 崩溃。
+ if (override) return { ...EMPTY_JOB_PREFERENCE, ...override };
const profile = await fetchOnboardingProfile();
return profile ? deriveJobPreference(profile) : EMPTY_JOB_PREFERENCE;
}
@@ -1908,8 +2164,11 @@ export async function fetchJobPreference(): Promise {
// 引导采集阶段:偏好从 OnboardingProfile 派生(见 resolveJobPreference),未采集字段留空由页面渲染「未设置」
return mockRequest(await resolveJobPreference());
}
- // 真实后端:/profile/me 旗下求职者偏好(§5 STUDENT 前缀)
- return request('/profile/me/preferences', { method: 'GET' });
+ // 真实后端:/profile/me 旗下求职者偏好(§5 STUDENT 前缀)。
+ // 与 resolveJobPreference 一致:合并 EMPTY_JOB_PREFERENCE 兜底,避免后端返回缺字段的偏好对象
+ // (如仅 {city:'上海'})导致 position/salary/workType/status 为 undefined、页面派生值出错。
+ const res = await request>('/profile/me/preferences', { method: 'GET' });
+ return { ...EMPTY_JOB_PREFERENCE, ...res };
}
/**
@@ -1950,8 +2209,10 @@ export async function fetchJobDetail(id: string): Promise {
};
return mockRequest(detail);
}
- // 真实后端:公开岗位详情(§7)
- return request(`/jobs/${id}`, { method: 'GET', auth: false });
+ // 真实后端:学生端公开岗位详情走 /jobs/public/{id}(匿名可用)。
+ // /jobs/{id} 是企业端看自己发布的岗位详情(需 COMPANY 鉴权),不能混用。
+ const detail = await request(`/jobs/public/${id}`, { method: 'GET', auth: false });
+ return { ...detail, level: scoreToLevel(detail.score ?? 0) };
}
/**
@@ -8621,9 +8882,9 @@ export function resetPassword(payload: PasswordResetPayload): Promise {
if (payload.oldPassword.length === 0) {
@@ -8639,7 +8900,13 @@ export function changePassword(payload: ChangePasswordPayload): Promise {
if (payload.newPassword === payload.oldPassword) {
return Promise.reject(new Error(getPasswordRuleMessage('same-as-old')));
}
- return mockRequest(undefined);
+ if (isMockMode()) return mockRequest(undefined);
+ // 真实后端:/auth/password/change 只收 oldPassword + newPassword
+ return request('/auth/password/change', {
+ method: 'POST',
+ auth: true,
+ body: { oldPassword: payload.oldPassword, newPassword: payload.newPassword },
+ });
}
/* ===================== 账号:微信登录(三种账号状态) ===================== */
diff --git a/src/api/auth.ts b/src/api/auth.ts
index 7785f51..3afc1a9 100644
--- a/src/api/auth.ts
+++ b/src/api/auth.ts
@@ -271,3 +271,89 @@ export async function registerCompany(payload: CompanyRegisterPayload): Promise<
expiresAt: toExpiresAt(raw.expiresIn),
};
}
+
+/** 刷新 Token 参数(§4 步骤4):用 Refresh Token 换新的 Access/Refresh 对 */
+export interface RefreshTokenPayload {
+ refreshToken: string;
+}
+
+/** 用 Refresh Token 换取新凭证(旧 Refresh 立即失效) */
+export async function refreshAuthToken(payload: RefreshTokenPayload): Promise {
+ if (isMockMode()) return mockAuthTokens('refreshed');
+ const raw = await request<{
+ accessToken: string;
+ refreshToken: string;
+ expiresIn?: number;
+ }>('/auth/refresh', { method: 'POST', auth: false, body: payload });
+ return {
+ accessToken: raw.accessToken,
+ refreshToken: raw.refreshToken,
+ expiresAt: toExpiresAt(raw.expiresIn),
+ };
+}
+
+/** 首次选择账号身份参数(§5):COMPANY 必须带 company 资料 */
+export interface IdentitySelectionPayload {
+ identity: 'STUDENT' | 'COMPANY';
+ company?: {
+ name: string;
+ industry: string;
+ description: string;
+ website: string;
+ address: string;
+ };
+}
+
+/** 首次选择身份后后端返回(在登录凭证基础上补充选身份相关字段) */
+export interface IdentitySelectionResult extends AuthLoginData {
+ /** 是否仍需选择身份(同身份重复请求只返回状态) */
+ identitySelectionRequired: boolean;
+ /** 企业 id(选 COMPANY 时下发) */
+ companyId?: number;
+ /** 入驻状态 */
+ onboardingStatus?: string;
+ /** 是否需要重新鉴权 */
+ reauthenticationRequired?: boolean;
+}
+
+/**
+ * 统一账号一次性选择 STUDENT / COMPANY(§5)。
+ * 首次成功撤销全部旧会话并返回新凭证;同身份重复请求只返回状态,不重复颁发凭证。
+ */
+export async function selectIdentity(payload: IdentitySelectionPayload): Promise {
+ if (isMockMode()) {
+ const mock = mockAuthTokens('identity');
+ return {
+ ...mock,
+ userId: 0,
+ phone: '',
+ nickname: 'Mock 用户',
+ role: payload.identity,
+ identitySelectionRequired: false,
+ };
+ }
+ const raw = await request<{
+ userId: number;
+ role: BackendRole;
+ identitySelectionRequired: boolean;
+ companyId?: number;
+ onboardingStatus?: string;
+ accessToken: string;
+ refreshToken: string;
+ expiresIn?: number;
+ reauthenticationRequired?: boolean;
+ }>('/auth/identity/select', { method: 'POST', auth: true, body: payload });
+ return {
+ userId: raw.userId,
+ phone: '',
+ nickname: 'Mock 用户',
+ role: raw.role,
+ accessToken: raw.accessToken,
+ refreshToken: raw.refreshToken,
+ expiresAt: toExpiresAt(raw.expiresIn),
+ identitySelectionRequired: raw.identitySelectionRequired,
+ companyId: raw.companyId,
+ onboardingStatus: raw.onboardingStatus,
+ reauthenticationRequired: raw.reauthenticationRequired,
+ };
+}
diff --git a/src/api/index.ts b/src/api/index.ts
index 69b386b..0d593c5 100644
--- a/src/api/index.ts
+++ b/src/api/index.ts
@@ -67,6 +67,8 @@ export {
buildJobShareText,
fetchJobList,
fetchFavorites,
+ addFavorite,
+ removeFavorite,
fetchHistory,
fetchOfferCompareData,
buildOfferCompareText,
@@ -78,6 +80,9 @@ export {
fetchMatchResult,
fetchMenuItems,
fetchNotifications,
+ markNotificationRead,
+ markAllNotificationsRead,
+ fetchUnreadCount,
fetchNotificationSettings,
fetchOnboardingChecklist,
addChecklistItem,
@@ -184,6 +189,14 @@ export {
toggleChecklistItem,
updateNotificationSetting,
updatePrivacySetting,
+ submitApplication,
+ fetchMyApplications,
+ fetchApplicationDetail,
+ fetchApplicationTimeline,
+ withdrawApplication,
+ uploadResume,
+ deleteResume,
+ setFirstPassword,
} from './api';
/* ===================== 真实后端:鉴权接口 ===================== */
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 1f13b43..8913191 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -44,6 +44,7 @@ export { useLegalMembership } from './useLegalMembership';
export { useLegalServiceRequests } from './useLegalServiceRequests';
export { useMessageData } from './useMessageData';
export { useNotificationSettings } from './useNotificationSettings';
+export { useNotifications, useUnreadCount } from './useNotifications';
export { useOfferCompareData } from './useOfferCompareData';
export { useOnboardingChecklist } from './useOnboardingChecklist';
export { useCarrierPhone } from './useCarrierPhone';
diff --git a/src/hooks/useFavorites.ts b/src/hooks/useFavorites.ts
index 161b71f..0bc682b 100644
--- a/src/hooks/useFavorites.ts
+++ b/src/hooks/useFavorites.ts
@@ -1,8 +1,9 @@
/**
- * 我的收藏(职位)数据 hook:复用通用异步取数 hook,统一管理三态。
+ * 我的收藏(职位)数据 hook:复用通用异步取数 hook,统一管理三态与收藏增删。
*/
+import { useCallback, useMemo } from 'react';
import type { Job } from '../types';
-import { fetchFavorites } from '../api';
+import { addFavorite, fetchFavorites, removeFavorite } from '../api';
import { useAsyncData } from './useAsyncData';
export type FavoriteJob = Omit;
@@ -13,5 +14,37 @@ export function useFavorites() {
// 空状态:加载完成且无错误、但列表为空
const isEmpty = !loading && !error && items.length === 0;
- return { items, loading, error, refresh, isEmpty };
+ // 已收藏的岗位 id 集合,供岗位详情等页快速判断是否已收藏
+ const favoriteIds = useMemo(() => new Set(items.map((job) => job.id)), [items]);
+
+ const add = useCallback(
+ async (jobId: string) => {
+ await addFavorite(jobId);
+ refresh();
+ },
+ [refresh],
+ );
+
+ const remove = useCallback(
+ async (jobId: string) => {
+ await removeFavorite(jobId);
+ refresh();
+ },
+ [refresh],
+ );
+
+ /** 切换收藏状态:已收藏则取消,未收藏则添加 */
+ const toggle = useCallback(
+ async (jobId: string) => {
+ if (favoriteIds.has(jobId)) {
+ await removeFavorite(jobId);
+ } else {
+ await addFavorite(jobId);
+ }
+ refresh();
+ },
+ [favoriteIds, refresh],
+ );
+
+ return { items, loading, error, refresh, isEmpty, favoriteIds, add, remove, toggle };
}
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
new file mode 100644
index 0000000..b470fca
--- /dev/null
+++ b/src/hooks/useNotifications.ts
@@ -0,0 +1,59 @@
+/**
+ * 通知中心数据 hook:未读数 + 列表 + 标记已读/全部已读。
+ * 未读数单独取(用于 Tab/入口红点),不随列表刷新而丢失。
+ */
+import { useCallback, useEffect, useState } from 'react';
+import {
+ fetchNotifications,
+ fetchUnreadCount,
+ markAllNotificationsRead,
+ markNotificationRead,
+} from '../api';
+import { useAsyncData } from './useAsyncData';
+
+/** 通知收件箱:列表 + 标记已读 + 全部已读 */
+export function useNotifications() {
+ const { data, loading, error, refresh } = useAsyncData(fetchNotifications);
+ const items = data ?? [];
+ const isEmpty = !loading && !error && items.length === 0;
+
+ const markRead = useCallback(
+ async (id: string) => {
+ await markNotificationRead(id);
+ refresh();
+ },
+ [refresh],
+ );
+
+ const markAllRead = useCallback(async () => {
+ await markAllNotificationsRead();
+ refresh();
+ }, [refresh]);
+
+ return { items, loading, error, refresh, isEmpty, markRead, markAllRead };
+}
+
+/** 未读通知数(红点),独立刷新,供「我的」入口与 Tab 使用 */
+export function useUnreadCount() {
+ const [count, setCount] = useState(0);
+ const [loading, setLoading] = useState(false);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ try {
+ const n = await fetchUnreadCount();
+ setCount(n);
+ } catch {
+ // 红点失败静默降级为 0,不阻断主流程
+ setCount(0);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ return { count, loading, refresh };
+}