Browse Source
- 通知:标记已读/全部已读/未读数 api + useNotifications/useUnreadCount hook - 通知收件箱页 app/notifications.tsx + 菜单入口 + 路由注册 - 收藏:收藏/取消收藏 api + 扩展 useFavorites(add/remove/toggle/favoriteIds) - 岗位详情头部接入收藏按钮 - 认证:补 refreshAuthToken / selectIdentity(getCurrentUser/登出/注册已有) - 全量 tsc --noEmit 0 错误master
9 changed files with 704 additions and 37 deletions
@ -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, |
|||
}, |
|||
}); |
|||
@ -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…
Reference in new issue