feat: integrate real message data with SDK in message page

Replace mock data with useMessages hook and useMessageActions for real-time message management. Add support for tab-based filtering (all/notice/other), pull-to-refresh, infinite scroll pagination, and mark-as-read functionality. Integrate LoadingState, ErrorState, and PaginationLoader components for better UX.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
imeepos
2026-01-21 14:58:32 +08:00
parent 6c17d720ca
commit 4868ff8660
2 changed files with 226 additions and 109 deletions

View File

@@ -6,87 +6,198 @@ import {
ScrollView, ScrollView,
StatusBar as RNStatusBar, StatusBar as RNStatusBar,
Pressable, Pressable,
RefreshControl,
NativeScrollEvent,
NativeSyntheticEvent,
} from 'react-native' } from 'react-native'
import { LinearGradient } from 'expo-linear-gradient' import { LinearGradient } from 'expo-linear-gradient'
import { StatusBar } from 'expo-status-bar' import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context' import { SafeAreaView } from 'react-native-safe-area-context'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useMessages, type Message } from '@/hooks/use-messages'
import { useMessageActions } from '@/hooks/use-message-actions'
import LoadingState from '@/components/LoadingState'
import ErrorState from '@/components/ErrorState'
import PaginationLoader from '@/components/PaginationLoader'
// 消息卡片数据 const getMessageTypeByTab = (tab: 'all' | 'notice' | 'other') => {
interface MessageCard { if (tab === 'all') return undefined
id: number if (tab === 'notice') return ['SYSTEM', 'ACTIVITY']
title: string return ['BILLING', 'MARKETING']
subtitle: string
body: string
time: string
type: 'notice' | 'other'
isNew?: boolean // 是否为新消息
} }
const messageCards: MessageCard[] = [ const formatTime = (date: Date) => {
{ const d = new Date(date)
id: 1, const year = d.getFullYear()
title: '恭喜你获得双12新用户专享福利', const month = String(d.getMonth() + 1).padStart(2, '0')
subtitle: '打开推送的内容,限时优惠,多种玩法,快来体验~', const day = String(d.getDate()).padStart(2, '0')
body: '图片占位。', const hours = String(d.getHours()).padStart(2, '0')
time: '2023-12-29 18:32:21', const minutes = String(d.getMinutes()).padStart(2, '0')
type: 'notice', const seconds = String(d.getSeconds()).padStart(2, '0')
isNew: true, // 新消息 return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}, }
{
id: 2,
title: '新功能上线AI 智能生成',
subtitle: '全新 AI 功能已上线,快来体验吧!',
body: '图片占位。',
time: '2023-12-28 15:20:10',
type: 'notice',
isNew: true, // 新消息
},
{
id: 3,
title: '系统维护通知',
subtitle: '系统将于今晚进行维护升级',
body: '图片占位。',
time: '2023-12-27 10:15:30',
type: 'other',
isNew: false, // 非新消息
},
{
id: 4,
title: '限时活动:分享有礼',
subtitle: '分享你的作品,赢取丰厚奖励',
body: '图片占位。',
time: '2023-12-26 14:05:22',
type: 'notice',
isNew: false, // 非新消息
},
{
id: 5,
title: '版本更新提醒',
subtitle: '新版本已发布,建议及时更新',
body: '图片占位。',
time: '2023-12-25 09:30:45',
type: 'other',
isNew: false, // 非新消息
},
]
export default function MessageScreen() { export default function MessageScreen() {
const { t } = useTranslation() const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<'all' | 'notice' | 'other'>('all') const [activeTab, setActiveTab] = useState<'all' | 'notice' | 'other'>('all')
const { markRead } = useMessageActions()
// 根据选中的标签过滤卡片 const messageType = getMessageTypeByTab(activeTab)
const filteredCards = messageCards.filter((card) => { const { messages, loading, loadingMore, error, refetch, loadMore, hasMore, execute } = useMessages({
if (activeTab === 'all') return true limit: 20,
return card.type === activeTab
}) })
useEffect(() => {
execute({ type: messageType as any })
}, [activeTab])
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - 50
if (isCloseToBottom && hasMore && !loadingMore && !loading) {
loadMore()
}
}
const handleMessagePress = async (message: Message) => {
if (!message.isRead) {
await markRead(message.id)
refetch()
}
}
if (loading && messages.length === 0) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
<View style={styles.segment}>
<Pressable onPress={() => setActiveTab('all')}>
{activeTab === 'all' ? (
<View style={styles.segmentTextActiveWrapper}>
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.segmentTextActiveBg}
/>
<Text style={[styles.segmentText, styles.segmentTextActiveText]}>
{t('message.all')}
</Text>
</View>
) : (
<Text style={styles.segmentText}>{t('message.all')}</Text>
)}
</Pressable>
<Pressable onPress={() => setActiveTab('notice')}>
{activeTab === 'notice' ? (
<View style={styles.segmentTextActiveWrapper}>
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.segmentTextActiveBg}
/>
<Text style={[styles.segmentText, styles.segmentTextActiveText]}>
{t('message.notice')}
</Text>
</View>
) : (
<Text style={styles.segmentText}>{t('message.notice')}</Text>
)}
</Pressable>
<Pressable onPress={() => setActiveTab('other')}>
{activeTab === 'other' ? (
<View style={styles.segmentTextActiveWrapper}>
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.segmentTextActiveBg}
/>
<Text style={[styles.segmentText, styles.segmentTextActiveText]}>
{t('message.other')}
</Text>
</View>
) : (
<Text style={styles.segmentText}>{t('message.other')}</Text>
)}
</Pressable>
</View>
<LoadingState color="#FFFFFF" />
</SafeAreaView>
)
}
if (error && messages.length === 0) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
<View style={styles.segment}>
<Pressable onPress={() => setActiveTab('all')}>
{activeTab === 'all' ? (
<View style={styles.segmentTextActiveWrapper}>
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.segmentTextActiveBg}
/>
<Text style={[styles.segmentText, styles.segmentTextActiveText]}>
{t('message.all')}
</Text>
</View>
) : (
<Text style={styles.segmentText}>{t('message.all')}</Text>
)}
</Pressable>
<Pressable onPress={() => setActiveTab('notice')}>
{activeTab === 'notice' ? (
<View style={styles.segmentTextActiveWrapper}>
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.segmentTextActiveBg}
/>
<Text style={[styles.segmentText, styles.segmentTextActiveText]}>
{t('message.notice')}
</Text>
</View>
) : (
<Text style={styles.segmentText}>{t('message.notice')}</Text>
)}
</Pressable>
<Pressable onPress={() => setActiveTab('other')}>
{activeTab === 'other' ? (
<View style={styles.segmentTextActiveWrapper}>
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.segmentTextActiveBg}
/>
<Text style={[styles.segmentText, styles.segmentTextActiveText]}>
{t('message.other')}
</Text>
</View>
) : (
<Text style={styles.segmentText}>{t('message.other')}</Text>
)}
</Pressable>
</View>
<ErrorState message={error.message} onRetry={refetch} />
</SafeAreaView>
)
}
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" /> <StatusBar style="light" />
<RNStatusBar barStyle="light-content" /> <RNStatusBar barStyle="light-content" />
{/* 固定在顶部的标签选择器 */}
<View style={styles.segment}> <View style={styles.segment}>
<Pressable onPress={() => setActiveTab('all')}> <Pressable onPress={() => setActiveTab('all')}>
{activeTab === 'all' ? ( {activeTab === 'all' ? (
@@ -156,45 +267,57 @@ export default function MessageScreen() {
</Pressable> </Pressable>
</View> </View>
{/* 可滚动的消息卡片列表 */}
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={400}
refreshControl={
<RefreshControl
refreshing={loading && messages.length > 0}
onRefresh={refetch}
tintColor="#FFFFFF"
/>
}
> >
{/* 消息卡片列表 */} {messages.length > 0 ? (
{filteredCards.length > 0 ? ( <>
filteredCards.map((card) => ( {messages.map((message) => (
<View key={card.id} style={styles.cardContainer}> <Pressable
{/* 新消息绿色指示点 */} key={message.id}
{card.isNew && ( onPress={() => handleMessagePress(message)}
>
<View style={styles.cardContainer}>
{!message.isRead && (
<View style={styles.newMessageDotContainer}> <View style={styles.newMessageDotContainer}>
<View style={styles.newMessageDot} /> <View style={styles.newMessageDot} />
</View> </View>
)} )}
<Text style={styles.cardTitle}> <Text style={styles.cardTitle}>
{card.title} {message.title}
</Text> </Text>
<Text style={styles.cardSubtitle} numberOfLines={2}> <Text style={styles.cardSubtitle} numberOfLines={2}>
{card.subtitle} {message.content}
</Text> </Text>
<View style={styles.cardBody}> <View style={styles.cardBody}>
<Text style={styles.cardBodyText}> <Text style={styles.cardBodyText}>
{/* TODO:这里是图片 */} {message.data || ''}
{card.body}
</Text> </Text>
</View> </View>
<Text style={styles.cardTime}> <Text style={styles.cardTime}>
{card.time} {formatTime(message.createdAt)}
</Text> </Text>
</View> </View>
)) </Pressable>
))}
{loadingMore && <PaginationLoader color="#FFFFFF" />}
</>
) : ( ) : (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
{/* <NoNewsIcon /> */} <Text style={styles.emptyIcon}>💭</Text>
💭
<Text style={styles.emptyText}>{t('message.noMessages')}</Text> <Text style={styles.emptyText}>{t('message.noMessages')}</Text>
</View> </View>
)} )}
@@ -216,12 +339,6 @@ const styles = StyleSheet.create({
paddingHorizontal: 4, paddingHorizontal: 4,
paddingTop: 12, paddingTop: 12,
}, },
appMiniIcon: {
width: 28,
height: 18,
borderRadius: 4,
backgroundColor: '#FF66AA',
},
segment: { segment: {
flexDirection: 'row', flexDirection: 'row',
paddingHorizontal: 8, paddingHorizontal: 8,
@@ -230,7 +347,6 @@ const styles = StyleSheet.create({
backgroundColor: '#090A0B', backgroundColor: '#090A0B',
gap: 16, gap: 16,
}, },
segmentText: { segmentText: {
fontSize: 14, fontSize: 14,
color: '#FFFFFF', color: '#FFFFFF',
@@ -252,20 +368,13 @@ const styles = StyleSheet.create({
segmentTextActiveText: { segmentTextActiveText: {
zIndex: 1, zIndex: 1,
}, },
statusDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#00FF66',
marginRight: 4,
},
cardContainer: { cardContainer: {
backgroundColor: '#16181B', backgroundColor: '#16181B',
borderRadius: 16, borderRadius: 16,
padding: 16, padding: 16,
marginBottom: 12, marginBottom: 12,
marginHorizontal: 4, marginHorizontal: 4,
position: 'relative', // 用于定位新消息指示点 position: 'relative',
}, },
newMessageDotContainer: { newMessageDotContainer: {
position: 'absolute', position: 'absolute',
@@ -283,7 +392,7 @@ const styles = StyleSheet.create({
width: 6, width: 6,
height: 6, height: 6,
borderRadius: 4, borderRadius: 4,
backgroundColor: '#00FF66', // 绿色 backgroundColor: '#00FF66',
zIndex: 1, zIndex: 1,
}, },
cardTitle: { cardTitle: {
@@ -302,6 +411,7 @@ const styles = StyleSheet.create({
borderRadius: 12, borderRadius: 12,
height: 100, height: 100,
marginBottom: 12, marginBottom: 12,
padding: 8,
}, },
cardBodyText: { cardBodyText: {
color: '#FFFFFF', color: '#FFFFFF',
@@ -318,6 +428,9 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
paddingTop: 200, paddingTop: 200,
}, },
emptyIcon: {
fontSize: 48,
},
emptyText: { emptyText: {
color: '#8A8A8A', color: '#8A8A8A',
fontSize: 12, fontSize: 12,

View File

@@ -9,10 +9,14 @@ import { handleError } from './use-error'
interface ListMessagesParams { interface ListMessagesParams {
page?: number page?: number
limit?: number limit?: number
type?: ('SYSTEM' | 'ACTIVITY' | 'BILLING' | 'MARKETING')[]
isRead?: boolean
} }
const DEFAULT_PARAMS = { const DEFAULT_PARAMS = {
limit: 20, limit: 20,
orderBy: 'createdAt',
order: 'desc',
} }
export const useMessages = (initialParams?: ListMessagesParams) => { export const useMessages = (initialParams?: ListMessagesParams) => {