308 lines
8.8 KiB
TypeScript
308 lines
8.8 KiB
TypeScript
import React, { memo, useCallback, useMemo } from 'react'
|
||
import { Pressable, StyleSheet, Text, View, ViewStyle, ImageStyle } from 'react-native'
|
||
import { Image } from 'expo-image'
|
||
import { LinearGradient } from 'expo-linear-gradient'
|
||
import { Ionicons } from '@expo/vector-icons'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { useTemplateSocialStore, useTemplateLiked, useTemplateFavorited, useTemplateLikeCount } from '@/stores/templateSocialStore'
|
||
|
||
export interface TemplateCardProps {
|
||
id?: string
|
||
title: string
|
||
titleEn?: string
|
||
previewUrl?: string
|
||
webpPreviewUrl?: string
|
||
coverImageUrl?: string
|
||
aspectRatio?: string
|
||
cardWidth: number
|
||
onPress: (id: string) => void
|
||
liked?: boolean
|
||
favorited?: boolean
|
||
likeCount?: number
|
||
onLike?: (id: string) => void
|
||
onUnlike?: (id: string) => void
|
||
onFavorite?: (id: string) => void
|
||
onUnfavorite?: (id: string) => void
|
||
testID?: string
|
||
}
|
||
|
||
/**
|
||
* 解析 aspectRatio 字符串为数字
|
||
* 例如: "128:128" -> 1, "16:9" -> 1.777..., "1.5" -> 1.5
|
||
*/
|
||
export function parseAspectRatio(aspectRatioString?: string): number | undefined {
|
||
if (!aspectRatioString) return undefined
|
||
|
||
if (aspectRatioString.includes(':')) {
|
||
const [w, h] = aspectRatioString.split(':').map(Number)
|
||
return w / h
|
||
}
|
||
|
||
return Number(aspectRatioString)
|
||
}
|
||
|
||
/**
|
||
* 获取图片源 URI,按优先级: webpPreviewUrl > previewUrl > coverImageUrl
|
||
*/
|
||
export function getImageUri(
|
||
webpPreviewUrl?: string,
|
||
previewUrl?: string,
|
||
coverImageUrl?: string
|
||
): string | undefined {
|
||
return webpPreviewUrl || previewUrl || coverImageUrl
|
||
}
|
||
|
||
/**
|
||
* 格式化数字显示
|
||
* 1000以下: 显示原数字
|
||
* 1000-9999: 1.2k
|
||
* 10000-99999: 1w
|
||
* 100000以上: 10w
|
||
*/
|
||
export function formatCount(count: number): string {
|
||
if (count < 1000) {
|
||
return count.toString()
|
||
}
|
||
if (count < 10000) {
|
||
return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}k`
|
||
}
|
||
return `${(count / 10000).toFixed(1).replace(/\.0$/, '')}w`
|
||
}
|
||
|
||
// 渐变颜色常量,避免每次渲染创建新数组
|
||
const GRADIENT_COLORS = ['rgba(17, 17, 17, 0)', 'rgba(17, 17, 17, 0.9)'] as const
|
||
const GRADIENT_START = { x: 0, y: 0 }
|
||
const GRADIENT_END = { x: 0, y: 1 }
|
||
|
||
const TemplateCardComponent: React.FC<TemplateCardProps> = ({
|
||
id,
|
||
title,
|
||
titleEn,
|
||
previewUrl,
|
||
webpPreviewUrl,
|
||
coverImageUrl,
|
||
aspectRatio: aspectRatioString,
|
||
cardWidth,
|
||
onPress,
|
||
liked: likedProp,
|
||
favorited: favoritedProp,
|
||
likeCount: likeCountProp,
|
||
onLike,
|
||
onUnlike,
|
||
onFavorite,
|
||
onUnfavorite,
|
||
testID,
|
||
}) => {
|
||
const { i18n } = useTranslation()
|
||
|
||
// 订阅特定模板的点赞/收藏状态和点赞数量
|
||
// 当该模板的状态变化时才会触发重新渲染,不影响其他卡片
|
||
const storeLiked = useTemplateLiked(id)
|
||
const storeFavorited = useTemplateFavorited(id)
|
||
const storeLikeCount = useTemplateLikeCount(id)
|
||
|
||
// 合并 props 状态和 store 状态:store 优先(乐观更新)
|
||
const liked = storeLiked ?? likedProp
|
||
const favorited = storeFavorited ?? favoritedProp
|
||
const likeCount = storeLikeCount ?? likeCountProp
|
||
|
||
const aspectRatio = useMemo(() => parseAspectRatio(aspectRatioString), [aspectRatioString])
|
||
const imageUri = useMemo(() => getImageUri(webpPreviewUrl, previewUrl, coverImageUrl), [webpPreviewUrl, previewUrl, coverImageUrl])
|
||
|
||
// Select display title based on current language
|
||
const displayTitle = useMemo(() => {
|
||
const isEnglish = i18n.language === 'en-US'
|
||
return isEnglish && titleEn ? titleEn : title
|
||
}, [i18n.language, title, titleEn])
|
||
|
||
// 计算图标状态
|
||
const likeIconName = useMemo(() => (liked ? 'heart' : 'heart-outline'), [liked])
|
||
const likeIconColor = useMemo(() => (liked ? '#FF3B30' : 'rgba(142, 142, 147, 0.7)'), [liked])
|
||
const favoriteIconName = useMemo(() => (favorited ? 'star' : 'star-outline'), [favorited])
|
||
const favoriteIconColor = useMemo(() => (favorited ? '#FFD700' : 'rgba(142, 142, 147, 0.7)'), [favorited])
|
||
|
||
// 使用 useCallback 缓存 onPress 回调
|
||
const handlePress = useCallback(() => {
|
||
if (id) {
|
||
onPress(id)
|
||
}
|
||
}, [id, onPress])
|
||
|
||
// 点赞按钮点击处理 - 阻止事件冒泡
|
||
const handleLikePress = useCallback(() => {
|
||
if (id) {
|
||
if (liked && onUnlike) {
|
||
onUnlike(id)
|
||
} else if (!liked && onLike) {
|
||
onLike(id)
|
||
}
|
||
}
|
||
}, [id, liked, onLike, onUnlike])
|
||
|
||
// 收藏按钮点击处理 - 阻止事件冒泡
|
||
const handleFavoritePress = useCallback(() => {
|
||
if (id) {
|
||
if (favorited && onUnfavorite) {
|
||
onUnfavorite(id)
|
||
} else if (!favorited && onFavorite) {
|
||
onFavorite(id)
|
||
}
|
||
}
|
||
}, [id, favorited, onFavorite, onUnfavorite])
|
||
|
||
// 缓存样式计算
|
||
const cardStyle = useMemo(() => [styles.card, { width: cardWidth }], [cardWidth])
|
||
const containerStyle = useMemo(() =>
|
||
[styles.cardImageContainer, aspectRatio ? { aspectRatio } : undefined].filter(Boolean) as ViewStyle[],
|
||
[aspectRatio]
|
||
)
|
||
const imageStyle = useMemo(() =>
|
||
[styles.cardImage, aspectRatio ? { aspectRatio } : undefined].filter(Boolean) as ImageStyle[],
|
||
[aspectRatio]
|
||
)
|
||
|
||
// 如果没有 id,则不渲染卡片
|
||
if (!id) {
|
||
return null
|
||
}
|
||
|
||
// 判断是否有点赞/收藏回调
|
||
const hasSocialActions = !!(onLike || onUnlike || onFavorite || onUnfavorite)
|
||
|
||
return (
|
||
<Pressable
|
||
style={cardStyle}
|
||
onPress={handlePress}
|
||
testID={testID}
|
||
>
|
||
<View style={containerStyle}>
|
||
<Image
|
||
source={{ uri: imageUri }}
|
||
style={imageStyle}
|
||
contentFit="cover"
|
||
cachePolicy="memory-disk"
|
||
recyclingKey={id}
|
||
transition={200}
|
||
/>
|
||
<LinearGradient
|
||
colors={GRADIENT_COLORS}
|
||
start={GRADIENT_START}
|
||
end={GRADIENT_END}
|
||
style={styles.cardImageGradient}
|
||
/>
|
||
{/* 点赞和收藏图标 - 只在有回调时显示为可点击 */}
|
||
{hasSocialActions ? (
|
||
<View style={styles.iconsContainer}>
|
||
<Pressable
|
||
onPress={(e) => {
|
||
e.stopPropagation()
|
||
handleLikePress()
|
||
}}
|
||
style={styles.iconButton}
|
||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||
testID={`${testID}-like-button`}
|
||
>
|
||
<View style={styles.likeButtonContainer}>
|
||
<Ionicons name={likeIconName} size={16} color={likeIconColor} style={styles.icon} />
|
||
{likeCount !== undefined && likeCount > 0 && (
|
||
<Text style={styles.likeCount}>{formatCount(likeCount)}</Text>
|
||
)}
|
||
</View>
|
||
</Pressable>
|
||
<Pressable
|
||
onPress={(e) => {
|
||
e.stopPropagation()
|
||
handleFavoritePress()
|
||
}}
|
||
style={styles.iconButton}
|
||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||
testID={`${testID}-favorite-button`}
|
||
>
|
||
<Ionicons name={favoriteIconName} size={16} color={favoriteIconColor} style={styles.icon} />
|
||
</Pressable>
|
||
</View>
|
||
) : (
|
||
// 没有回调时显示为静态图标
|
||
<View style={styles.iconsContainer}>
|
||
<View style={styles.likeButtonContainer}>
|
||
<Ionicons name={likeIconName} size={16} color={likeIconColor} style={styles.icon} />
|
||
{likeCount !== undefined && likeCount > 0 && (
|
||
<Text style={styles.likeCount}>{formatCount(likeCount)}</Text>
|
||
)}
|
||
</View>
|
||
<Ionicons name={favoriteIconName} size={16} color={favoriteIconColor} style={styles.icon} />
|
||
</View>
|
||
)}
|
||
<Text style={styles.cardTitle} numberOfLines={1}>
|
||
{displayTitle}
|
||
</Text>
|
||
</View>
|
||
</Pressable>
|
||
)
|
||
}
|
||
|
||
export const TemplateCard = memo(TemplateCardComponent)
|
||
|
||
const styles = StyleSheet.create({
|
||
card: {
|
||
marginBottom: 5,
|
||
},
|
||
cardImageContainer: {
|
||
width: '100%',
|
||
borderRadius: 8,
|
||
overflow: 'hidden',
|
||
position: 'relative',
|
||
},
|
||
cardImage: {
|
||
width: '100%',
|
||
borderRadius: 8,
|
||
},
|
||
cardImageGradient: {
|
||
position: 'absolute',
|
||
bottom: 0,
|
||
left: 0,
|
||
right: 0,
|
||
height: '50%',
|
||
},
|
||
iconsContainer: {
|
||
position: 'absolute',
|
||
top: 8,
|
||
right: 8,
|
||
flexDirection: 'row',
|
||
gap: 4,
|
||
},
|
||
likeButtonContainer: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 2,
|
||
},
|
||
iconButton: {
|
||
padding: 4,
|
||
},
|
||
icon: {
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 1 },
|
||
shadowOpacity: 0.3,
|
||
shadowRadius: 2,
|
||
elevation: 2,
|
||
},
|
||
likeCount: {
|
||
fontSize: 10,
|
||
fontWeight: '600',
|
||
color: '#F5F5F5',
|
||
textShadowColor: 'rgba(0, 0, 0, 0.5)',
|
||
textShadowOffset: { width: 0, height: 1 },
|
||
textShadowRadius: 2,
|
||
minWidth: 12,
|
||
},
|
||
cardTitle: {
|
||
position: 'absolute',
|
||
bottom: 8,
|
||
left: 8,
|
||
right: 8,
|
||
color: '#F5F5F5',
|
||
fontSize: 12,
|
||
fontWeight: '500',
|
||
},
|
||
})
|