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:
parent
6c17d720ca
commit
4868ff8660
|
|
@ -6,87 +6,198 @@ import {
|
|||
ScrollView,
|
||||
StatusBar as RNStatusBar,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
} from 'react-native'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
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'
|
||||
|
||||
// 消息卡片数据
|
||||
interface MessageCard {
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string
|
||||
body: string
|
||||
time: string
|
||||
type: 'notice' | 'other'
|
||||
isNew?: boolean // 是否为新消息
|
||||
const getMessageTypeByTab = (tab: 'all' | 'notice' | 'other') => {
|
||||
if (tab === 'all') return undefined
|
||||
if (tab === 'notice') return ['SYSTEM', 'ACTIVITY']
|
||||
return ['BILLING', 'MARKETING']
|
||||
}
|
||||
|
||||
const messageCards: MessageCard[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: '恭喜你,获得双12新用户专享福利',
|
||||
subtitle: '打开推送的内容,限时优惠,多种玩法,快来体验~',
|
||||
body: '图片占位。',
|
||||
time: '2023-12-29 18:32:21',
|
||||
type: 'notice',
|
||||
isNew: true, // 新消息
|
||||
},
|
||||
{
|
||||
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, // 非新消息
|
||||
},
|
||||
]
|
||||
const formatTime = (date: Date) => {
|
||||
const d = new Date(date)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(d.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
export default function MessageScreen() {
|
||||
const { t } = useTranslation()
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'notice' | 'other'>('all')
|
||||
const { markRead } = useMessageActions()
|
||||
|
||||
// 根据选中的标签过滤卡片
|
||||
const filteredCards = messageCards.filter((card) => {
|
||||
if (activeTab === 'all') return true
|
||||
return card.type === activeTab
|
||||
const messageType = getMessageTypeByTab(activeTab)
|
||||
const { messages, loading, loadingMore, error, refetch, loadMore, hasMore, execute } = useMessages({
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
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 (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<StatusBar style="light" />
|
||||
<RNStatusBar barStyle="light-content" />
|
||||
|
||||
{/* 固定在顶部的标签选择器 */}
|
||||
<View style={styles.segment}>
|
||||
<Pressable onPress={() => setActiveTab('all')}>
|
||||
{activeTab === 'all' ? (
|
||||
|
|
@ -156,45 +267,57 @@ export default function MessageScreen() {
|
|||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* 可滚动的消息卡片列表 */}
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={400}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={loading && messages.length > 0}
|
||||
onRefresh={refetch}
|
||||
tintColor="#FFFFFF"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* 消息卡片列表 */}
|
||||
{filteredCards.length > 0 ? (
|
||||
filteredCards.map((card) => (
|
||||
<View key={card.id} style={styles.cardContainer}>
|
||||
{/* 新消息绿色指示点 */}
|
||||
{card.isNew && (
|
||||
{messages.length > 0 ? (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<Pressable
|
||||
key={message.id}
|
||||
onPress={() => handleMessagePress(message)}
|
||||
>
|
||||
<View style={styles.cardContainer}>
|
||||
{!message.isRead && (
|
||||
<View style={styles.newMessageDotContainer}>
|
||||
<View style={styles.newMessageDot} />
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.cardTitle}>
|
||||
{card.title}
|
||||
{message.title}
|
||||
</Text>
|
||||
<Text style={styles.cardSubtitle} numberOfLines={2}>
|
||||
{card.subtitle}
|
||||
{message.content}
|
||||
</Text>
|
||||
|
||||
<View style={styles.cardBody}>
|
||||
<Text style={styles.cardBodyText}>
|
||||
{/* TODO:这里是图片 */}
|
||||
{card.body}
|
||||
{message.data || ''}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.cardTime}>
|
||||
{card.time}
|
||||
{formatTime(message.createdAt)}
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
</Pressable>
|
||||
))}
|
||||
{loadingMore && <PaginationLoader color="#FFFFFF" />}
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
{/* <NoNewsIcon /> */}
|
||||
💭
|
||||
<Text style={styles.emptyIcon}>💭</Text>
|
||||
<Text style={styles.emptyText}>{t('message.noMessages')}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -216,12 +339,6 @@ const styles = StyleSheet.create({
|
|||
paddingHorizontal: 4,
|
||||
paddingTop: 12,
|
||||
},
|
||||
appMiniIcon: {
|
||||
width: 28,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#FF66AA',
|
||||
},
|
||||
segment: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 8,
|
||||
|
|
@ -230,7 +347,6 @@ const styles = StyleSheet.create({
|
|||
backgroundColor: '#090A0B',
|
||||
gap: 16,
|
||||
},
|
||||
|
||||
segmentText: {
|
||||
fontSize: 14,
|
||||
color: '#FFFFFF',
|
||||
|
|
@ -252,20 +368,13 @@ const styles = StyleSheet.create({
|
|||
segmentTextActiveText: {
|
||||
zIndex: 1,
|
||||
},
|
||||
statusDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#00FF66',
|
||||
marginRight: 4,
|
||||
},
|
||||
cardContainer: {
|
||||
backgroundColor: '#16181B',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
marginHorizontal: 4,
|
||||
position: 'relative', // 用于定位新消息指示点
|
||||
position: 'relative',
|
||||
},
|
||||
newMessageDotContainer: {
|
||||
position: 'absolute',
|
||||
|
|
@ -283,7 +392,7 @@ const styles = StyleSheet.create({
|
|||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#00FF66', // 绿色
|
||||
backgroundColor: '#00FF66',
|
||||
zIndex: 1,
|
||||
},
|
||||
cardTitle: {
|
||||
|
|
@ -302,6 +411,7 @@ const styles = StyleSheet.create({
|
|||
borderRadius: 12,
|
||||
height: 100,
|
||||
marginBottom: 12,
|
||||
padding: 8,
|
||||
},
|
||||
cardBodyText: {
|
||||
color: '#FFFFFF',
|
||||
|
|
@ -318,6 +428,9 @@ const styles = StyleSheet.create({
|
|||
justifyContent: 'center',
|
||||
paddingTop: 200,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 48,
|
||||
},
|
||||
emptyText: {
|
||||
color: '#8A8A8A',
|
||||
fontSize: 12,
|
||||
|
|
|
|||
|
|
@ -9,10 +9,14 @@ import { handleError } from './use-error'
|
|||
interface ListMessagesParams {
|
||||
page?: number
|
||||
limit?: number
|
||||
type?: ('SYSTEM' | 'ACTIVITY' | 'BILLING' | 'MARKETING')[]
|
||||
isRead?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_PARAMS = {
|
||||
limit: 20,
|
||||
orderBy: 'createdAt',
|
||||
order: 'desc',
|
||||
}
|
||||
|
||||
export const useMessages = (initialParams?: ListMessagesParams) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue