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