Browse Source

feat(api): 补齐后端有而前端缺的通知/收藏/认证接口与页面

- 通知:标记已读/全部已读/未读数 api + useNotifications/useUnreadCount hook
- 通知收件箱页 app/notifications.tsx + 菜单入口 + 路由注册
- 收藏:收藏/取消收藏 api + 扩展 useFavorites(add/remove/toggle/favoriteIds)
- 岗位详情头部接入收藏按钮
- 认证:补 refreshAuthToken / selectIdentity(getCurrentUser/登出/注册已有)
- 全量 tsc --noEmit 0 错误
master
doublex001 4 weeks ago
parent
commit
550a39da20
  1. 1
      app/_layout.tsx
  2. 54
      app/jobs/[id].tsx
  3. 179
      app/notifications.tsx
  4. 309
      src/api/api.ts
  5. 86
      src/api/auth.ts
  6. 13
      src/api/index.ts
  7. 1
      src/hooks/index.ts
  8. 39
      src/hooks/useFavorites.ts
  9. 59
      src/hooks/useNotifications.ts

1
app/_layout.tsx

@ -22,6 +22,7 @@ export default function RootLayout() {
<Stack.Screen name="settings" options={{ title: '设置' }} /> <Stack.Screen name="settings" options={{ title: '设置' }} />
<Stack.Screen name="resume" options={{ title: '我的简历' }} /> <Stack.Screen name="resume" options={{ title: '我的简历' }} />
<Stack.Screen name="favorites" options={{ title: '我的收藏' }} /> <Stack.Screen name="favorites" options={{ title: '我的收藏' }} />
<Stack.Screen name="notifications" options={{ title: '通知' }} />
<Stack.Screen name="history" options={{ title: '浏览历史' }} /> <Stack.Screen name="history" options={{ title: '浏览历史' }} />
<Stack.Screen name="assessment" options={{ title: '我的测评' }} /> <Stack.Screen name="assessment" options={{ title: '我的测评' }} />
</Stack> </Stack>

54
app/jobs/[id].tsx

@ -13,7 +13,7 @@ import {
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Circle, Line, Polygon, Text as SvgText } from 'react-native-svg'; 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 { buildJobShareText } from '../../src/api';
import { shareText } from '../../src/lib/shareFile'; import { shareText } from '../../src/lib/shareFile';
import {colors, spacing} from '../../src/theme'; import {colors, spacing} from '../../src/theme';
@ -107,21 +107,40 @@ function RadarChart({
/* ===================== 子组件 ===================== */ /* ===================== 子组件 ===================== */
/** 顶部导航:返回 + 标题 + 分享(onShare 缺省时不渲染分享按钮,避免留下点了没反应的入口) */ /** 顶部导航:返回 + 标题 + 收藏 + 分享(onShare 缺省时不渲染分享按钮,避免留下点了没反应的入口) */
function Header({ onBack, onShare }: { onBack: () => void; onShare?: () => void }) { function Header({
onBack,
onShare,
favorited,
onToggleFavorite,
}: {
onBack: () => void;
onShare?: () => void;
favorited?: boolean;
onToggleFavorite?: () => void;
}) {
return ( return (
<View style={styles.header}> <View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}> <Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} /> <Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable> </Pressable>
<Text style={styles.headerTitle}>岗位详情</Text> <Text style={styles.headerTitle}>岗位详情</Text>
{onShare ? ( <View style={{ flexDirection: 'row' }}>
<Pressable style={styles.headerBtnRight} onPress={onShare} hitSlop={8}> {onToggleFavorite ? (
<Ionicons name="share-social-outline" size={22} color={colors.textPrimary} /> <Pressable style={styles.headerBtnRight} onPress={onToggleFavorite} hitSlop={8}>
</Pressable> <Ionicons
) : ( name={favorited ? 'heart' : 'heart-outline'}
<View style={styles.headerBtnRight} /> size={22}
)} color={favorited ? colors.primary : colors.textPrimary}
/>
</Pressable>
) : null}
{onShare ? (
<Pressable style={styles.headerBtnRight} onPress={onShare} hitSlop={8}>
<Ionicons name="share-social-outline" size={22} color={colors.textPrimary} />
</Pressable>
) : null}
</View>
</View> </View>
); );
} }
@ -159,6 +178,10 @@ export default function JobDetailScreen() {
const [comment, setComment] = useState(''); const [comment, setComment] = useState('');
const [publishing, setPublishing] = useState(false); const [publishing, setPublishing] = useState(false);
// 收藏态:用已收藏 id 集合判断是否已收藏(仅 STUDENT 可收藏)
const { favoriteIds, toggle: toggleFavorite } = useFavorites();
const favorited = id ? favoriteIds.has(id) : false;
/** /**
* 分享岗位:把当前岗位拼成文本经系统分享面板发出。 * 分享岗位:把当前岗位拼成文本经系统分享面板发出。
* `ok` 只代表面板已拉起,不能据此提示「分享成功」,只在失败时说明原因(见 lib/shareFile)。 * `ok` 只代表面板已拉起,不能据此提示「分享成功」,只在失败时说明原因(见 lib/shareFile)。
@ -194,7 +217,7 @@ export default function JobDetailScreen() {
if (error) { if (error) {
return ( return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}> <SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} /> <Header onBack={() => router.back()} favorited={false} onToggleFavorite={() => {}} />
<View style={styles.stateWrap}> <View style={styles.stateWrap}>
<Text style={styles.stateText}>加载失败,请重试</Text> <Text style={styles.stateText}>加载失败,请重试</Text>
<Pressable style={styles.retryBtn} onPress={refresh}> <Pressable style={styles.retryBtn} onPress={refresh}>
@ -209,7 +232,7 @@ export default function JobDetailScreen() {
if (loading || !detail || !overallScore || !salaryData) { if (loading || !detail || !overallScore || !salaryData) {
return ( return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}> <SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} /> <Header onBack={() => router.back()} favorited={false} onToggleFavorite={() => {}} />
<View style={styles.stateWrap}> <View style={styles.stateWrap}>
<ActivityIndicator size="large" color={colors.primary} /> <ActivityIndicator size="large" color={colors.primary} />
</View> </View>
@ -219,7 +242,12 @@ export default function JobDetailScreen() {
return ( return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}> <SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} onShare={handleShare} /> <Header
onBack={() => router.back()}
onShare={handleShare}
favorited={favorited}
onToggleFavorite={id ? () => toggleFavorite(id) : undefined}
/>
<ScrollView <ScrollView
style={styles.scroll} style={styles.scroll}

179
app/notifications.tsx

@ -0,0 +1,179 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNotifications } from '../src/hooks';
import { LoadingState, ErrorState, EmptyState } from '../src/components/StateView';
import { colors, spacing } from '../src/theme';
import type { Notification } from '../src/types';
const TYPE_ICON: Record<Notification['type'], keyof typeof Ionicons.glyphMap> = {
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 (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text style={styles.headerTitle}>通知</Text>
{hasUnread ? (
<Pressable style={styles.markAllBtn} onPress={markAllRead} hitSlop={8}>
<Text style={styles.markAllText}>全部已读</Text>
</Pressable>
) : (
<View style={styles.headerBtn} />
)}
</View>
{loading ? (
<LoadingState text="加载通知中…" />
) : error ? (
<ErrorState message="通知加载失败,请重试" onRetry={refresh} />
) : isEmpty ? (
<EmptyState icon="notifications-off-outline" title="暂无通知" subtitle="系统通知会在这里出现" />
) : (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{items.map((n) => (
<Pressable
key={n.id}
style={[styles.card, !n.read && styles.cardUnread]}
onPress={() => markRead(n.id)}
hitSlop={4}
>
<View style={[styles.iconWrap, !n.read && styles.iconWrapUnread]}>
<Ionicons name={TYPE_ICON[n.type]} size={18} color={colors.primary} />
</View>
<View style={styles.cardBody}>
<View style={styles.cardTop}>
<Text style={styles.cardTitle}>{n.title}</Text>
{!n.read && <View style={styles.dot} />}
</View>
<Text style={styles.cardContent} numberOfLines={2}>
{n.content}
</Text>
<Text style={styles.cardTime}>{n.timestamp}</Text>
</View>
</Pressable>
))}
</ScrollView>
)}
</SafeAreaView>
);
}
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,
},
});

309
src/api/api.ts

@ -166,6 +166,11 @@ import type {
CarrierFailureReason, CarrierFailureReason,
CarrierName, CarrierName,
CarrierPhoneResult, CarrierPhoneResult,
ApplicationDetail,
ApplicationItem,
ApplicationStatus,
ApplicationTimelineEvent,
PageResult,
} from '../types'; } from '../types';
import { import {
ALL_CITY_LABEL, ALL_CITY_LABEL,
@ -741,6 +746,7 @@ function buildUserStats(): UserStats {
*/ */
const MOCK_CANDIDATE_MENU_ITEMS: MenuItem[] = [ const MOCK_CANDIDATE_MENU_ITEMS: MenuItem[] = [
{ id: 'resume', title: '我的简历', icon: 'document-text-outline', route: '/resume' }, { 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: 'favorites', title: '我的收藏', icon: 'heart-outline', route: '/favorites' },
{ id: 'history', title: '浏览历史', icon: 'time-outline', route: '/history' }, { id: 'history', title: '浏览历史', icon: 'time-outline', route: '/history' },
{ id: 'compare', title: '岗位对比', icon: 'git-compare-outline', route: '/jobs/compare' }, { id: 'compare', title: '岗位对比', icon: 'git-compare-outline', route: '/jobs/compare' },
@ -1277,6 +1283,30 @@ export async function fetchFavorites(): Promise<Omit<Job, 'level'>[]> {
return res.items ?? []; return res.items ?? [];
} }
/**
* 收藏岗位(仅 STUDENT,幂等)。
* @method POST
* @url /jobs/{jobId}/favorite
* @param jobId 岗位 id
* @returns void
*/
export async function addFavorite(jobId: string): Promise<void> {
if (isMockMode()) return mockRequest<void>(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<void> {
if (isMockMode()) return mockRequest<void>(undefined);
await request(`/jobs/${jobId}/favorite`, { method: 'DELETE', auth: true });
}
/** /**
* 获取浏览历史(职位)列表 * 获取浏览历史(职位)列表
* @method GET * @method GET
@ -1327,6 +1357,46 @@ export async function fetchNotifications(): Promise<Notification[]> {
return res.items ?? []; return res.items ?? [];
} }
/**
* 标记单条通知已读(幂等)。
* @method POST
* @url /notifications/{id}/read
* @param id 通知 id
* @returns void
*/
export async function markNotificationRead(id: string): Promise<void> {
if (isMockMode()) return mockRequest<void>(undefined);
await request(`/notifications/${id}/read`, { method: 'POST', auth: true });
}
/**
* 标记当前用户全部通知已读(幂等)。
* @method POST
* @url /notifications/read-all
* @returns void
*/
export async function markAllNotificationsRead(): Promise<void> {
if (isMockMode()) return mockRequest<void>(undefined);
await request('/notifications/read-all', { method: 'POST', auth: true });
}
/**
* 获取当前用户未读通知数。
* 后端 data 可能为 { unreadCount: number } 或 { count: number } 或裸数字,统一兜底为 number。
* @method GET
* @url /notifications/unread-count
* @returns Promise<number> 未读数
*/
export async function fetchUnreadCount(): Promise<number> {
if (isMockMode()) return mockRequest<number>(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 * @method GET
@ -1459,6 +1529,25 @@ export async function fetchArticles(): Promise<Article[]> {
* @url /api/user/me * @url /api/user/me
* @returns Promise<UserProfile> 用户个人信息 * @returns Promise<UserProfile> 用户个人信息
*/ */
/**
* 后端学生资料 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<UserProfile> { export async function fetchUserProfile(): Promise<UserProfile> {
// @method GET // @method GET
// @url /profile/me 后端学生资料端点(当前返回空对象:用户未填资料) // @url /profile/me 后端学生资料端点(当前返回空对象:用户未填资料)
@ -1479,21 +1568,30 @@ export async function fetchUserProfile(): Promise<UserProfile> {
}); });
} }
// 真实后端:/profile/me 返回学生填写的资料(当前可能为空对象 {}), // 真实后端:/profile/me 返回 StudentProfileResponse
// 用 /auth/me 的 phone+nickname 兜底组合出前端需要的 name/avatar/bio。 // (id, userId, realName, bio, location, education, school, graduationYear, resumeUrl, skills, createdAt)。
// 用户未填资料时后端返回空对象 {},此时回落到下方 /auth/me 兜底组合 name/avatar/bio。
// 注意:StudentProfileResponse 无头像图片字段(resumeUrl 是简历文件,非头像),
// 故真实资料分支 avatarKey 固定为 null,头像展示回落到姓氏首字。
try { try {
const profile = await request<{ nickname?: string; avatarUrl?: string; [k: string]: unknown }>( const profile = await request<StudentProfileResponse>('/profile/me');
'/profile/me', // 关键字段任一存在才认定「用户填过资料」,否则走兜底分支
); if (
// profile 非空(用户填过资料),用它 profile &&
if (profile && Object.keys(profile).length > 0) { (profile.realName || profile.bio || profile.school || profile.education || profile.location)
const name = profile.nickname ?? ''; ) {
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 { return {
id: '', id: String(profile.id ?? profile.userId ?? ''),
name, name,
avatar: name ? name.slice(0, 1) : '', avatar,
bio: '', bio,
avatarKey: profile.avatarUrl ?? null, avatarKey: null,
}; };
} }
} catch (err) { } catch (err) {
@ -1548,6 +1646,162 @@ export async function fetchUserStats(): Promise<UserStats> {
} }
/* ===================== 投递(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<PageResult<ApplicationItem>> 分页投递列表
*/
export async function fetchMyApplications(params?: {
page?: number;
size?: number;
status?: ApplicationStatus;
}): Promise<PageResult<ApplicationItem>> {
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<ApplicationDetail>
*/
export async function fetchApplicationDetail(id: string): Promise<ApplicationDetail> {
if (isMockMode()) {
throw new Error('mock 模式下无投递详情数据');
}
return request<ApplicationDetail>(`/applications/${id}`, { method: 'GET', auth: true });
}
/**
* 获取投递状态时间线
* @method GET
* @url /applications/{id}/timeline
* @param id 投递记录 ID
* @returns Promise<ApplicationTimelineEvent[]> 时间线节点数组
*/
export async function fetchApplicationTimeline(id: string): Promise<ApplicationTimelineEvent[]> {
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<void> {
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<string, unknown>,
});
return { fileName: data.fileName };
}
/**
* 删除已上传的简历文件
* @method DELETE
* @url /profile/me/resume
*/
export async function deleteResume(): Promise<void> {
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<void> {
if (isMockMode()) return;
await request('/auth/password/set', {
method: 'POST',
auth: true,
body: { password },
});
}
/** /**
* 获取个人中心功能菜单列表 * 获取个人中心功能菜单列表
* @method GET * @method GET
@ -1891,7 +2145,9 @@ function matchScore(job: Omit<Job, 'level'>, preference: EffectivePreference): n
*/ */
async function resolveJobPreference(): Promise<JobPreference> { async function resolveJobPreference(): Promise<JobPreference> {
const override = await readJobPreferenceOverride(); 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(); const profile = await fetchOnboardingProfile();
return profile ? deriveJobPreference(profile) : EMPTY_JOB_PREFERENCE; return profile ? deriveJobPreference(profile) : EMPTY_JOB_PREFERENCE;
} }
@ -1908,8 +2164,11 @@ export async function fetchJobPreference(): Promise<JobPreference> {
// 引导采集阶段:偏好从 OnboardingProfile 派生(见 resolveJobPreference),未采集字段留空由页面渲染「未设置」 // 引导采集阶段:偏好从 OnboardingProfile 派生(见 resolveJobPreference),未采集字段留空由页面渲染「未设置」
return mockRequest(await resolveJobPreference()); return mockRequest(await resolveJobPreference());
} }
// 真实后端:/profile/me 旗下求职者偏好(§5 STUDENT 前缀) // 真实后端:/profile/me 旗下求职者偏好(§5 STUDENT 前缀)。
return request<JobPreference>('/profile/me/preferences', { method: 'GET' }); // 与 resolveJobPreference 一致:合并 EMPTY_JOB_PREFERENCE 兜底,避免后端返回缺字段的偏好对象
// (如仅 {city:'上海'})导致 position/salary/workType/status 为 undefined、页面派生值出错。
const res = await request<Partial<JobPreference>>('/profile/me/preferences', { method: 'GET' });
return { ...EMPTY_JOB_PREFERENCE, ...res };
} }
/** /**
@ -1950,8 +2209,10 @@ export async function fetchJobDetail(id: string): Promise<JobDetail> {
}; };
return mockRequest(detail); return mockRequest(detail);
} }
// 真实后端:公开岗位详情(§7) // 真实后端:学生端公开岗位详情走 /jobs/public/{id}(匿名可用)。
return request<JobDetail>(`/jobs/${id}`, { method: 'GET', auth: false }); // /jobs/{id} 是企业端看自己发布的岗位详情(需 COMPANY 鉴权),不能混用。
const detail = await request<JobDetail>(`/jobs/public/${id}`, { method: 'GET', auth: false });
return { ...detail, level: scoreToLevel(detail.score ?? 0) };
} }
/** /**
@ -8621,9 +8882,9 @@ export function resetPassword(payload: PasswordResetPayload): Promise<LoginResul
* 修改登录密码(已登录态,需校验原密码)。 * 修改登录密码(已登录态,需校验原密码)。
* *
* @method POST * @method POST
* @url /api/auth/change-password * @url /auth/password/change 真实后端;原 /api/auth/change-password 已弃用
* @param payload 原密码 + 新密码 + 确认新密码 * @param payload 原密码 + 新密码 + 确认新密码(前端本地校验,后端只收 oldPassword + newPassword)
* @returns 无返回体;失败时 reject * @returns 无返回体;失败时 reject(后端返回业务错误码透传)
*/ */
export function changePassword(payload: ChangePasswordPayload): Promise<void> { export function changePassword(payload: ChangePasswordPayload): Promise<void> {
if (payload.oldPassword.length === 0) { if (payload.oldPassword.length === 0) {
@ -8639,7 +8900,13 @@ export function changePassword(payload: ChangePasswordPayload): Promise<void> {
if (payload.newPassword === payload.oldPassword) { if (payload.newPassword === payload.oldPassword) {
return Promise.reject(new Error(getPasswordRuleMessage('same-as-old'))); return Promise.reject(new Error(getPasswordRuleMessage('same-as-old')));
} }
return mockRequest<void>(undefined); if (isMockMode()) return mockRequest<void>(undefined);
// 真实后端:/auth/password/change 只收 oldPassword + newPassword
return request('/auth/password/change', {
method: 'POST',
auth: true,
body: { oldPassword: payload.oldPassword, newPassword: payload.newPassword },
});
} }
/* ===================== 账号:微信登录(三种账号状态) ===================== */ /* ===================== 账号:微信登录(三种账号状态) ===================== */

86
src/api/auth.ts

@ -271,3 +271,89 @@ export async function registerCompany(payload: CompanyRegisterPayload): Promise<
expiresAt: toExpiresAt(raw.expiresIn), 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<AuthTokens> {
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<IdentitySelectionResult> {
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,
};
}

13
src/api/index.ts

@ -67,6 +67,8 @@ export {
buildJobShareText, buildJobShareText,
fetchJobList, fetchJobList,
fetchFavorites, fetchFavorites,
addFavorite,
removeFavorite,
fetchHistory, fetchHistory,
fetchOfferCompareData, fetchOfferCompareData,
buildOfferCompareText, buildOfferCompareText,
@ -78,6 +80,9 @@ export {
fetchMatchResult, fetchMatchResult,
fetchMenuItems, fetchMenuItems,
fetchNotifications, fetchNotifications,
markNotificationRead,
markAllNotificationsRead,
fetchUnreadCount,
fetchNotificationSettings, fetchNotificationSettings,
fetchOnboardingChecklist, fetchOnboardingChecklist,
addChecklistItem, addChecklistItem,
@ -184,6 +189,14 @@ export {
toggleChecklistItem, toggleChecklistItem,
updateNotificationSetting, updateNotificationSetting,
updatePrivacySetting, updatePrivacySetting,
submitApplication,
fetchMyApplications,
fetchApplicationDetail,
fetchApplicationTimeline,
withdrawApplication,
uploadResume,
deleteResume,
setFirstPassword,
} from './api'; } from './api';
/* ===================== 真实后端:鉴权接口 ===================== */ /* ===================== 真实后端:鉴权接口 ===================== */

1
src/hooks/index.ts

@ -44,6 +44,7 @@ export { useLegalMembership } from './useLegalMembership';
export { useLegalServiceRequests } from './useLegalServiceRequests'; export { useLegalServiceRequests } from './useLegalServiceRequests';
export { useMessageData } from './useMessageData'; export { useMessageData } from './useMessageData';
export { useNotificationSettings } from './useNotificationSettings'; export { useNotificationSettings } from './useNotificationSettings';
export { useNotifications, useUnreadCount } from './useNotifications';
export { useOfferCompareData } from './useOfferCompareData'; export { useOfferCompareData } from './useOfferCompareData';
export { useOnboardingChecklist } from './useOnboardingChecklist'; export { useOnboardingChecklist } from './useOnboardingChecklist';
export { useCarrierPhone } from './useCarrierPhone'; export { useCarrierPhone } from './useCarrierPhone';

39
src/hooks/useFavorites.ts

@ -1,8 +1,9 @@
/** /**
* 我的收藏(职位)数据 hook:复用通用异步取数 hook,统一管理三态。 * 我的收藏(职位)数据 hook:复用通用异步取数 hook,统一管理三态与收藏增删。
*/ */
import { useCallback, useMemo } from 'react';
import type { Job } from '../types'; import type { Job } from '../types';
import { fetchFavorites } from '../api'; import { addFavorite, fetchFavorites, removeFavorite } from '../api';
import { useAsyncData } from './useAsyncData'; import { useAsyncData } from './useAsyncData';
export type FavoriteJob = Omit<Job, 'level'>; export type FavoriteJob = Omit<Job, 'level'>;
@ -13,5 +14,37 @@ export function useFavorites() {
// 空状态:加载完成且无错误、但列表为空 // 空状态:加载完成且无错误、但列表为空
const isEmpty = !loading && !error && items.length === 0; 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 };
} }

59
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 };
}
Loading…
Cancel
Save