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 }; +}