This commit is contained in:
imeepos
2026-01-29 15:51:39 +08:00
parent 6279752f23
commit 7e6de68891
16 changed files with 2106 additions and 456 deletions

View File

@@ -0,0 +1,202 @@
import React from 'react'
import { render, fireEvent } from '@testing-library/react-native'
import { AnnouncementBanner, type Announcement } from './AnnouncementBanner'
// Mock expo-linear-gradient
jest.mock('expo-linear-gradient', () => ({
LinearGradient: ({ children, ...props }: any) => {
const { View } = require('react-native')
return <View {...props}>{children}</View>
},
}))
describe('AnnouncementBanner', () => {
const mockAnnouncements: Announcement[] = [
{
id: 'ann-1',
title: '系统维护通知',
content: '系统将于今晚进行维护',
link: 'https://example.com/maintenance',
createdAt: new Date('2026-01-29T10:00:00Z'),
},
{
id: 'ann-2',
title: '新功能上线',
content: '我们推出了全新的AI生成功能',
createdAt: new Date('2026-01-28T10:00:00Z'),
},
{
id: 'ann-3',
title: '活动公告',
content: '春节活动即将开始',
link: 'https://example.com/event',
createdAt: new Date('2026-01-27T10:00:00Z'),
},
]
const mockOnPress = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
})
describe('Empty State', () => {
it('should not render anything when announcements array is empty', () => {
const { toJSON } = render(
<AnnouncementBanner announcements={[]} onPress={mockOnPress} />
)
expect(toJSON()).toBeNull()
})
it('should not render anything when announcements is undefined', () => {
const { toJSON } = render(
<AnnouncementBanner announcements={undefined as any} onPress={mockOnPress} />
)
expect(toJSON()).toBeNull()
})
})
describe('Single Announcement', () => {
it('should render single announcement title', () => {
const singleAnnouncement = [mockAnnouncements[0]]
const { getByText } = render(
<AnnouncementBanner announcements={singleAnnouncement} onPress={mockOnPress} />
)
expect(getByText('系统维护通知')).toBeTruthy()
})
it('should render announcement banner container', () => {
const singleAnnouncement = [mockAnnouncements[0]]
const { getByTestId } = render(
<AnnouncementBanner announcements={singleAnnouncement} onPress={mockOnPress} />
)
expect(getByTestId('announcement-banner')).toBeTruthy()
})
})
describe('Multiple Announcements', () => {
it('should render multiple announcements', () => {
const { getByText } = render(
<AnnouncementBanner announcements={mockAnnouncements} onPress={mockOnPress} />
)
expect(getByText('系统维护通知')).toBeTruthy()
})
it('should render horizontal scroll view for multiple announcements', () => {
const { getByTestId } = render(
<AnnouncementBanner announcements={mockAnnouncements} onPress={mockOnPress} />
)
expect(getByTestId('announcement-scroll-view')).toBeTruthy()
})
it('should render all announcement items', () => {
const { getAllByTestId } = render(
<AnnouncementBanner announcements={mockAnnouncements} onPress={mockOnPress} />
)
const items = getAllByTestId(/^announcement-item-/)
expect(items.length).toBe(3)
})
})
describe('Press Interaction', () => {
it('should call onPress with correct announcement when pressed', () => {
const singleAnnouncement = [mockAnnouncements[0]]
const { getByTestId } = render(
<AnnouncementBanner announcements={singleAnnouncement} onPress={mockOnPress} />
)
fireEvent.press(getByTestId('announcement-item-ann-1'))
expect(mockOnPress).toHaveBeenCalledTimes(1)
expect(mockOnPress).toHaveBeenCalledWith(mockAnnouncements[0])
})
it('should call onPress with correct announcement for each item', () => {
const { getByTestId } = render(
<AnnouncementBanner announcements={mockAnnouncements} onPress={mockOnPress} />
)
fireEvent.press(getByTestId('announcement-item-ann-2'))
expect(mockOnPress).toHaveBeenCalledWith(mockAnnouncements[1])
})
it('should not crash when onPress is not provided', () => {
const singleAnnouncement = [mockAnnouncements[0]]
const { getByTestId } = render(
<AnnouncementBanner announcements={singleAnnouncement} />
)
expect(() => {
fireEvent.press(getByTestId('announcement-item-ann-1'))
}).not.toThrow()
})
})
describe('Gradient Background Style', () => {
it('should render with gradient background', () => {
const singleAnnouncement = [mockAnnouncements[0]]
const { getByTestId } = render(
<AnnouncementBanner announcements={singleAnnouncement} onPress={mockOnPress} />
)
const banner = getByTestId('announcement-banner')
expect(banner).toBeTruthy()
})
it('should apply gradient colors', () => {
const singleAnnouncement = [mockAnnouncements[0]]
const { getByTestId } = render(
<AnnouncementBanner announcements={singleAnnouncement} onPress={mockOnPress} />
)
const gradientContainer = getByTestId('gradient-container')
expect(gradientContainer.props.colors).toEqual(['#FF9966', '#FF6699', '#9966FF'])
})
})
describe('Announcement Icon', () => {
it('should render announcement icon', () => {
const singleAnnouncement = [mockAnnouncements[0]]
const { getByTestId } = render(
<AnnouncementBanner announcements={singleAnnouncement} onPress={mockOnPress} />
)
expect(getByTestId('announcement-icon')).toBeTruthy()
})
})
})
describe('Announcement Type', () => {
it('should have required id field', () => {
const announcement: Announcement = {
id: 'test-id',
title: 'Test Title',
createdAt: new Date(),
}
expect(announcement.id).toBeDefined()
})
it('should have required title field', () => {
const announcement: Announcement = {
id: 'test-id',
title: 'Test Title',
createdAt: new Date(),
}
expect(announcement.title).toBeDefined()
})
it('should have optional content field', () => {
const announcement: Announcement = {
id: 'test-id',
title: 'Test Title',
content: 'Test Content',
createdAt: new Date(),
}
expect(announcement.content).toBe('Test Content')
})
it('should have optional link field', () => {
const announcement: Announcement = {
id: 'test-id',
title: 'Test Title',
link: 'https://example.com',
createdAt: new Date(),
}
expect(announcement.link).toBe('https://example.com')
})
})

View File

@@ -0,0 +1,112 @@
import React from 'react'
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
} from 'react-native'
import { LinearGradient } from 'expo-linear-gradient'
export interface Announcement {
id: string
title: string
content?: string
link?: string
createdAt: Date
}
export interface AnnouncementBannerProps {
announcements: Announcement[]
onPress?: (announcement: Announcement) => void
}
const GRADIENT_COLORS = ['#FF9966', '#FF6699', '#9966FF'] as const
export const AnnouncementBanner: React.FC<AnnouncementBannerProps> = ({
announcements,
onPress,
}) => {
// 无公告时不渲染
if (!announcements || announcements.length === 0) {
return null
}
const handlePress = (announcement: Announcement) => {
onPress?.(announcement)
}
return (
<View testID="announcement-banner" style={styles.container}>
<LinearGradient
testID="gradient-container"
colors={[...GRADIENT_COLORS]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.gradientContainer}
>
<View testID="announcement-icon" style={styles.iconContainer}>
<Text style={styles.icon}>📢</Text>
</View>
<ScrollView
testID="announcement-scroll-view"
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{announcements.map((announcement) => (
<TouchableOpacity
key={announcement.id}
testID={`announcement-item-${announcement.id}`}
style={styles.announcementItem}
onPress={() => handlePress(announcement)}
activeOpacity={0.7}
>
<Text style={styles.title} numberOfLines={1}>
{announcement.title}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</LinearGradient>
</View>
)
}
const styles = StyleSheet.create({
container: {
marginHorizontal: 16,
marginVertical: 8,
borderRadius: 12,
overflow: 'hidden',
},
gradientContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 12,
},
iconContainer: {
marginRight: 8,
},
icon: {
fontSize: 18,
},
scrollView: {
flex: 1,
},
scrollContent: {
alignItems: 'center',
},
announcementItem: {
marginRight: 16,
},
title: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '500',
},
})
export default AnnouncementBanner

View File

@@ -223,10 +223,16 @@ describe('MessageCard Component', () => {
})
it('should render message icon based on subType', () => {
// Use a message without avatar to test icon display
const billingMessage = {
...mockMessage,
type: 'BILLING' as const,
data: { subType: 'CREDITS_RECHARGED', credits: 1000 },
}
const { getByText } = render(
<MessageCard message={mockMessage} onPress={mockOnPress} />
<MessageCard message={billingMessage} onPress={mockOnPress} />
)
expect(getByText('👍')).toBeTruthy()
expect(getByText('💰')).toBeTruthy()
})
describe('Avatar Display', () => {

View File

@@ -0,0 +1,104 @@
import React from 'react'
import { render } from '@testing-library/react-native'
import { Text } from 'react-native'
import { MessageEmptyState } from './MessageEmptyState'
describe('MessageEmptyState Component', () => {
describe('Default Rendering', () => {
it('should render default message "暂无消息"', () => {
const { getByText } = render(<MessageEmptyState />)
expect(getByText('暂无消息')).toBeTruthy()
})
it('should render default icon', () => {
const { getByTestId } = render(<MessageEmptyState />)
expect(getByTestId('empty-state-icon')).toBeTruthy()
})
it('should render container with testID', () => {
const { getByTestId } = render(<MessageEmptyState />)
expect(getByTestId('message-empty-state')).toBeTruthy()
})
})
describe('Custom Props', () => {
it('should render custom message when provided', () => {
const customMessage = '没有互动消息'
const { getByText, queryByText } = render(
<MessageEmptyState message={customMessage} />
)
expect(getByText(customMessage)).toBeTruthy()
expect(queryByText('暂无消息')).toBeNull()
})
it('should render custom icon when provided', () => {
const CustomIcon = <Text testID="custom-icon">🎨</Text>
const { getByTestId } = render(<MessageEmptyState icon={CustomIcon} />)
expect(getByTestId('custom-icon')).toBeTruthy()
})
it('should not render default icon when custom icon is provided', () => {
const CustomIcon = <Text testID="custom-icon">🎨</Text>
const { queryByTestId } = render(<MessageEmptyState icon={CustomIcon} />)
// Custom icon container should exist, but default icon should be replaced
expect(queryByTestId('custom-icon')).toBeTruthy()
})
})
describe('Styling', () => {
it('should have centered content', () => {
const { getByTestId } = render(<MessageEmptyState />)
const container = getByTestId('message-empty-state')
// Check that container has centered alignment styles
expect(container.props.style).toEqual(
expect.objectContaining({
alignItems: 'center',
justifyContent: 'center',
})
)
})
it('should have transparent background', () => {
const { getByTestId } = render(<MessageEmptyState />)
const container = getByTestId('message-empty-state')
expect(container.props.style).toEqual(
expect.objectContaining({
backgroundColor: 'transparent',
})
)
})
it('should have correct text color #8A8A8A', () => {
const { getByTestId } = render(<MessageEmptyState />)
const messageText = getByTestId('empty-state-message')
expect(messageText.props.style).toEqual(
expect.objectContaining({
color: '#8A8A8A',
})
)
})
it('should have flex: 1 to fill available space', () => {
const { getByTestId } = render(<MessageEmptyState />)
const container = getByTestId('message-empty-state')
expect(container.props.style).toEqual(
expect.objectContaining({
flex: 1,
})
)
})
})
describe('Accessibility', () => {
it('should have accessible message text', () => {
const { getByTestId } = render(<MessageEmptyState />)
const messageText = getByTestId('empty-state-message')
expect(messageText).toBeTruthy()
})
})
})

View File

@@ -0,0 +1,42 @@
import React from 'react'
import { View, Text, StyleSheet } from 'react-native'
export interface MessageEmptyStateProps {
message?: string
icon?: React.ReactNode
}
export function MessageEmptyState({
message = '暂无消息',
icon,
}: MessageEmptyStateProps) {
return (
<View testID="message-empty-state" style={styles.container}>
<View testID="empty-state-icon" style={styles.iconContainer}>
{icon || <Text style={styles.defaultIcon}>📭</Text>}
</View>
<Text testID="empty-state-message" style={styles.message}>
{message}
</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
},
iconContainer: {
marginBottom: 12,
},
defaultIcon: {
fontSize: 48,
},
message: {
fontSize: 14,
color: '#8A8A8A',
},
})

View File

@@ -0,0 +1,283 @@
import React from 'react'
import { render, fireEvent } from '@testing-library/react-native'
import { MessageTabBar, TAB_ITEMS } from './MessageTabBar'
import type { MessageType } from './MessageTabBar'
describe('MessageTabBar Constants', () => {
describe('TAB_ITEMS', () => {
it('should have 4 tabs', () => {
expect(TAB_ITEMS).toHaveLength(4)
})
it('should have correct tab keys', () => {
const keys = TAB_ITEMS.map((tab) => tab.key)
expect(keys).toEqual(['all', 'activity', 'system', 'billing'])
})
it('should have correct tab labels', () => {
const labels = TAB_ITEMS.map((tab) => tab.label)
expect(labels).toEqual(['全部', '互动', '系统', '账单'])
})
it('should have correct tab types', () => {
const types = TAB_ITEMS.map((tab) => tab.type)
expect(types).toEqual([undefined, 'ACTIVITY', 'SYSTEM', 'BILLING'])
})
})
})
describe('MessageTabBar Component', () => {
const mockOnTabChange = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render all 4 tabs', () => {
const { getByText } = render(
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
)
expect(getByText('全部')).toBeTruthy()
expect(getByText('互动')).toBeTruthy()
expect(getByText('系统')).toBeTruthy()
expect(getByText('账单')).toBeTruthy()
})
it('should render with testID for each tab', () => {
const { getByTestId } = render(
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
)
expect(getByTestId('tab-all')).toBeTruthy()
expect(getByTestId('tab-activity')).toBeTruthy()
expect(getByTestId('tab-system')).toBeTruthy()
expect(getByTestId('tab-billing')).toBeTruthy()
})
})
describe('Active Tab Highlighting', () => {
it('should highlight "全部" tab when activeTab is undefined', () => {
const { getByTestId } = render(
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
)
const allTab = getByTestId('tab-all')
const activityTab = getByTestId('tab-activity')
// Check that all tab has active indicator
expect(getByTestId('tab-all-active-indicator')).toBeTruthy()
// Check that activity tab does not have active indicator
expect(() => getByTestId('tab-activity-active-indicator')).toThrow()
})
it('should highlight "互动" tab when activeTab is ACTIVITY', () => {
const { getByTestId } = render(
<MessageTabBar activeTab="ACTIVITY" onTabChange={mockOnTabChange} />
)
expect(getByTestId('tab-activity-active-indicator')).toBeTruthy()
expect(() => getByTestId('tab-all-active-indicator')).toThrow()
})
it('should highlight "系统" tab when activeTab is SYSTEM', () => {
const { getByTestId } = render(
<MessageTabBar activeTab="SYSTEM" onTabChange={mockOnTabChange} />
)
expect(getByTestId('tab-system-active-indicator')).toBeTruthy()
expect(() => getByTestId('tab-all-active-indicator')).toThrow()
})
it('should highlight "账单" tab when activeTab is BILLING', () => {
const { getByTestId } = render(
<MessageTabBar activeTab="BILLING" onTabChange={mockOnTabChange} />
)
expect(getByTestId('tab-billing-active-indicator')).toBeTruthy()
expect(() => getByTestId('tab-all-active-indicator')).toThrow()
})
})
describe('Tab Press Interaction', () => {
it('should call onTabChange with undefined when "全部" tab is pressed', () => {
const { getByTestId } = render(
<MessageTabBar activeTab="ACTIVITY" onTabChange={mockOnTabChange} />
)
fireEvent.press(getByTestId('tab-all'))
expect(mockOnTabChange).toHaveBeenCalledWith(undefined)
})
it('should call onTabChange with ACTIVITY when "互动" tab is pressed', () => {
const { getByTestId } = render(
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
)
fireEvent.press(getByTestId('tab-activity'))
expect(mockOnTabChange).toHaveBeenCalledWith('ACTIVITY')
})
it('should call onTabChange with SYSTEM when "系统" tab is pressed', () => {
const { getByTestId } = render(
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
)
fireEvent.press(getByTestId('tab-system'))
expect(mockOnTabChange).toHaveBeenCalledWith('SYSTEM')
})
it('should call onTabChange with BILLING when "账单" tab is pressed', () => {
const { getByTestId } = render(
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
)
fireEvent.press(getByTestId('tab-billing'))
expect(mockOnTabChange).toHaveBeenCalledWith('BILLING')
})
it('should call onTabChange even when pressing the already active tab', () => {
const { getByTestId } = render(
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
)
fireEvent.press(getByTestId('tab-all'))
expect(mockOnTabChange).toHaveBeenCalledWith(undefined)
})
})
describe('Unread Badge Display', () => {
it('should show unread badge on "全部" tab when all count > 0', () => {
const { getByTestId, getByText } = render(
<MessageTabBar
activeTab={undefined}
onTabChange={mockOnTabChange}
unreadCounts={{ all: 5 }}
/>
)
expect(getByTestId('tab-all-badge')).toBeTruthy()
expect(getByText('5')).toBeTruthy()
})
it('should show unread badge on "互动" tab when activity count > 0', () => {
const { getByTestId, getByText } = render(
<MessageTabBar
activeTab={undefined}
onTabChange={mockOnTabChange}
unreadCounts={{ activity: 3 }}
/>
)
expect(getByTestId('tab-activity-badge')).toBeTruthy()
expect(getByText('3')).toBeTruthy()
})
it('should show unread badge on "系统" tab when system count > 0', () => {
const { getByTestId, getByText } = render(
<MessageTabBar
activeTab={undefined}
onTabChange={mockOnTabChange}
unreadCounts={{ system: 10 }}
/>
)
expect(getByTestId('tab-system-badge')).toBeTruthy()
expect(getByText('10')).toBeTruthy()
})
it('should show unread badge on "账单" tab when billing count > 0', () => {
const { getByTestId, getByText } = render(
<MessageTabBar
activeTab={undefined}
onTabChange={mockOnTabChange}
unreadCounts={{ billing: 2 }}
/>
)
expect(getByTestId('tab-billing-badge')).toBeTruthy()
expect(getByText('2')).toBeTruthy()
})
it('should show multiple badges when multiple counts > 0', () => {
const { getByTestId } = render(
<MessageTabBar
activeTab={undefined}
onTabChange={mockOnTabChange}
unreadCounts={{ all: 15, activity: 5, system: 8, billing: 2 }}
/>
)
expect(getByTestId('tab-all-badge')).toBeTruthy()
expect(getByTestId('tab-activity-badge')).toBeTruthy()
expect(getByTestId('tab-system-badge')).toBeTruthy()
expect(getByTestId('tab-billing-badge')).toBeTruthy()
})
it('should show "99+" when count exceeds 99', () => {
const { getByText } = render(
<MessageTabBar
activeTab={undefined}
onTabChange={mockOnTabChange}
unreadCounts={{ all: 150 }}
/>
)
expect(getByText('99+')).toBeTruthy()
})
})
describe('No Badge Display', () => {
it('should not show badge when unreadCounts is not provided', () => {
const { queryByTestId } = render(
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
)
expect(queryByTestId('tab-all-badge')).toBeNull()
expect(queryByTestId('tab-activity-badge')).toBeNull()
expect(queryByTestId('tab-system-badge')).toBeNull()
expect(queryByTestId('tab-billing-badge')).toBeNull()
})
it('should not show badge when count is 0', () => {
const { queryByTestId } = render(
<MessageTabBar
activeTab={undefined}
onTabChange={mockOnTabChange}
unreadCounts={{ all: 0, activity: 0, system: 0, billing: 0 }}
/>
)
expect(queryByTestId('tab-all-badge')).toBeNull()
expect(queryByTestId('tab-activity-badge')).toBeNull()
expect(queryByTestId('tab-system-badge')).toBeNull()
expect(queryByTestId('tab-billing-badge')).toBeNull()
})
it('should not show badge when count is undefined', () => {
const { queryByTestId } = render(
<MessageTabBar
activeTab={undefined}
onTabChange={mockOnTabChange}
unreadCounts={{}}
/>
)
expect(queryByTestId('tab-all-badge')).toBeNull()
expect(queryByTestId('tab-activity-badge')).toBeNull()
expect(queryByTestId('tab-system-badge')).toBeNull()
expect(queryByTestId('tab-billing-badge')).toBeNull()
})
})
})
describe('MessageTabBar Types', () => {
it('should accept valid MessageType values', () => {
const types: MessageType[] = [undefined, 'ACTIVITY', 'SYSTEM', 'BILLING']
types.forEach((type) => {
expect([undefined, 'ACTIVITY', 'SYSTEM', 'BILLING']).toContain(type)
})
})
})

View File

@@ -0,0 +1,161 @@
import React from 'react'
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
import { LinearGradient } from 'expo-linear-gradient'
// Types
export type MessageType = 'ACTIVITY' | 'SYSTEM' | 'BILLING' | undefined
export interface TabItem {
key: string
label: string
type: MessageType
}
export interface MessageTabBarProps {
activeTab: MessageType
onTabChange: (type: MessageType) => void
unreadCounts?: {
all?: number
activity?: number
system?: number
billing?: number
}
}
// Constants
export const TAB_ITEMS: TabItem[] = [
{ key: 'all', label: '全部', type: undefined },
{ key: 'activity', label: '互动', type: 'ACTIVITY' },
{ key: 'system', label: '系统', type: 'SYSTEM' },
{ key: 'billing', label: '账单', type: 'BILLING' },
]
// Helper function to format badge count
const formatBadgeCount = (count: number): string => {
if (count > 99) {
return '99+'
}
return String(count)
}
// Helper function to get unread count for a tab
const getUnreadCount = (
key: string,
unreadCounts?: MessageTabBarProps['unreadCounts']
): number | undefined => {
if (!unreadCounts) return undefined
switch (key) {
case 'all':
return unreadCounts.all
case 'activity':
return unreadCounts.activity
case 'system':
return unreadCounts.system
case 'billing':
return unreadCounts.billing
default:
return undefined
}
}
export const MessageTabBar: React.FC<MessageTabBarProps> = ({
activeTab,
onTabChange,
unreadCounts,
}) => {
return (
<View style={styles.container}>
{TAB_ITEMS.map((tab) => {
const isActive = activeTab === tab.type
const unreadCount = getUnreadCount(tab.key, unreadCounts)
const showBadge = unreadCount !== undefined && unreadCount > 0
return (
<TouchableOpacity
key={tab.key}
testID={`tab-${tab.key}`}
style={styles.tabItem}
onPress={() => onTabChange(tab.type)}
activeOpacity={0.7}
>
<View style={styles.tabContent}>
<Text
style={[
styles.tabLabel,
isActive ? styles.tabLabelActive : styles.tabLabelInactive,
]}
>
{tab.label}
</Text>
{showBadge && (
<View testID={`tab-${tab.key}-badge`} style={styles.badge}>
<Text style={styles.badgeText}>
{formatBadgeCount(unreadCount)}
</Text>
</View>
)}
</View>
{isActive && (
<LinearGradient
testID={`tab-${tab.key}-active-indicator`}
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.activeIndicator}
/>
)}
</TouchableOpacity>
)
})}
</View>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
backgroundColor: '#090A0B',
paddingHorizontal: 16,
paddingTop: 12,
},
tabItem: {
marginRight: 24,
paddingBottom: 8,
alignItems: 'center',
},
tabContent: {
flexDirection: 'row',
alignItems: 'center',
},
tabLabel: {
fontSize: 16,
fontWeight: '500',
},
tabLabelActive: {
color: '#FFFFFF',
},
tabLabelInactive: {
color: '#8A8A8A',
},
activeIndicator: {
height: 2,
width: '100%',
marginTop: 6,
borderRadius: 1,
},
badge: {
backgroundColor: '#FF3B30',
borderRadius: 10,
minWidth: 18,
height: 18,
paddingHorizontal: 5,
marginLeft: 4,
justifyContent: 'center',
alignItems: 'center',
},
badgeText: {
color: '#FFFFFF',
fontSize: 11,
fontWeight: '600',
},
})

View File

@@ -0,0 +1,75 @@
import React from 'react'
import {
View,
Text,
TouchableOpacity,
StyleSheet,
ViewStyle,
StyleProp,
} from 'react-native'
import { Swipeable } from 'react-native-gesture-handler'
export interface SwipeToDeleteProps {
children: React.ReactNode
onDelete: () => void
enabled?: boolean
disabled?: boolean
deleteText?: string
style?: StyleProp<ViewStyle>
}
export function SwipeToDelete({
children,
onDelete,
enabled = true,
disabled = false,
deleteText = '删除',
style,
}: SwipeToDeleteProps) {
const isDisabled = disabled || !enabled
const renderRightActions = () => {
return (
<TouchableOpacity
testID="swipe-delete-button"
accessibilityRole="button"
accessibilityLabel="删除"
style={styles.deleteButton}
onPress={() => {
if (!isDisabled) {
onDelete()
}
}}
activeOpacity={0.8}
>
<Text style={styles.deleteText}>{deleteText}</Text>
</TouchableOpacity>
)
}
return (
<Swipeable
testID="swipe-container"
renderRightActions={renderRightActions}
enabled={!isDisabled}
overshootRight={false}
friction={2}
>
<View style={style}>{children}</View>
</Swipeable>
)
}
const styles = StyleSheet.create({
deleteButton: {
backgroundColor: '#FF3B30',
width: 80,
justifyContent: 'center',
alignItems: 'center',
},
deleteText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
})

View File

@@ -0,0 +1,14 @@
export { MessageCard, getMessageIcon, getMessageConfig, formatRelativeTime } from './MessageCard'
export type { Message, MessageData } from './MessageCard'
export { AnnouncementBanner } from './AnnouncementBanner'
export type { Announcement, AnnouncementBannerProps } from './AnnouncementBanner'
export { SwipeToDelete } from './SwipeToDelete'
export type { SwipeToDeleteProps } from './SwipeToDelete'
export { MessageTabBar, TAB_ITEMS } from './MessageTabBar'
export type { MessageType, TabItem, MessageTabBarProps } from './MessageTabBar'
export { MessageEmptyState } from './MessageEmptyState'
export type { MessageEmptyStateProps } from './MessageEmptyState'