/** * PostCard 主组件 * 帖子卡片组件(新实现) * * @example * // 新 API 使用方式 * handleAction(post, action)} * variant="list" * features="full" * /> */ import React, { useMemo, useState, memo, useEffect } from 'react'; import { View, TouchableOpacity, StyleSheet, Alert, TextStyle } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { PostCardProps, PostCardAction } from './types'; import { ImageGridItem, SmartImage } from '../../common'; import Text from '../../common/Text'; import Avatar from '../../common/Avatar'; import { colors, spacing, borderRadius, fontSizes } from '../../../theme'; import { getPreviewImageUrl } from '../../../utils/imageHelper'; import PostImages from './components/PostImages'; import { useResponsive } from '../../../hooks/useResponsive'; import { formatPostCreatedAtString } from '../../../core/entities/Post'; /** 列表相对时间:独立定时刷新,避免外层 PostCard.memo 挡住「分钟前」更新 */ const PostCardRelativeTime: React.FC<{ createdAt?: string | null; style?: TextStyle | TextStyle[] }> = ({ createdAt, style: timeStyle, }) => { const [label, setLabel] = useState(() => formatPostCreatedAtString(createdAt)); useEffect(() => { setLabel(formatPostCreatedAtString(createdAt)); const tick = () => setLabel(formatPostCreatedAtString(createdAt)); const id = setInterval(tick, 60_000); return () => clearInterval(id); }, [createdAt]); return ( {label} ); }; function imagesSignature( images: PostCardProps['post']['images'] | undefined ): string { if (!images?.length) return ''; return images.map((i) => i.id).join('\u001f'); } function authorSignature(author: PostCardProps['post']['author']): string { if (!author) return ''; return [author.id, author.nickname ?? '', author.avatar ?? ''].join('\u001f'); } function topCommentSignature(tc: PostCardProps['post']['top_comment']): string { if (!tc) return ''; return [tc.id, tc.content ?? '', tc.author?.id ?? ''].join('\u001f'); } function channelSignature(ch: PostCardProps['post']['channel']): string { if (!ch) return ''; return [ch.id, ch.name ?? ''].join('\u001f'); } function featuresComparable(f: PostCardProps['features']): string { if (f == null) return ''; if (typeof f === 'string') return f; return JSON.stringify(f); } /** * 自定义比较:忽略 onAction 引用(父组件应用 postId + ref 解析最新 post,避免 Tab 切换时全列表因回调重建而重绘)。 * index 未参与 UI,不比较以免列表重排时多余更新。 */ function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolean { if (prev.variant !== next.variant) return false; if (prev.isPostAuthor !== next.isPostAuthor) return false; if (prev.isAuthor !== next.isAuthor) return false; if (prev.style !== next.style) return false; if (featuresComparable(prev.features) !== featuresComparable(next.features)) return false; const a = prev.post; const b = next.post; if (a === b) return true; if (a.id !== b.id) return false; if (a.title !== b.title) return false; if (a.content !== b.content) return false; if ((a.likes_count ?? 0) !== (b.likes_count ?? 0)) return false; if ((a.comments_count ?? 0) !== (b.comments_count ?? 0)) return false; if ((a.favorites_count ?? 0) !== (b.favorites_count ?? 0)) return false; if ((a.shares_count ?? 0) !== (b.shares_count ?? 0)) return false; if ((a.views_count ?? 0) !== (b.views_count ?? 0)) return false; if (!!a.is_pinned !== !!b.is_pinned) return false; if (!!a.is_locked !== !!b.is_locked) return false; if (!!a.is_vote !== !!b.is_vote) return false; if (a.created_at !== b.created_at) return false; if (a.updated_at !== b.updated_at) return false; if (a.content_edited_at !== b.content_edited_at) return false; if (!!a.is_liked !== !!b.is_liked) return false; if (!!a.is_favorited !== !!b.is_favorited) return false; if (authorSignature(a.author) !== authorSignature(b.author)) return false; if (imagesSignature(a.images) !== imagesSignature(b.images)) return false; if (topCommentSignature(a.top_comment) !== topCommentSignature(b.top_comment)) return false; if (channelSignature(a.channel) !== channelSignature(b.channel)) return false; return true; } /** * PostCard 主组件 * 仅支持新 API */ const PostCardInner: React.FC = (normalizedProps) => { const { post, onAction, variant = 'list', features, isPostAuthor = false, isAuthor = false, index, style, } = normalizedProps; const [isExpanded, setIsExpanded] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const isCompact = features === 'compact' || (typeof features === 'object' && features?.showContent === false); const emit = (action: PostCardAction) => { onAction(action); }; const handleImagePress = (images: ImageGridItem[], imageIndex: number) => { emit({ type: 'imagePress', payload: { images, imageIndex } }); }; const showGrid = variant === 'grid'; const author = post.author; const rawContent = post.content ?? ''; const { isDesktop, isTablet, isWideScreen, isLandscape } = useResponsive(); const handleCardPress = () => emit({ type: 'press' }); const handleUserPress = () => emit({ type: 'userPress' }); const handleLike = () => emit({ type: post.is_liked ? 'unlike' : 'like' }); const handleComment = () => emit({ type: 'comment' }); const handleBookmark = () => emit({ type: post.is_favorited ? 'unbookmark' : 'bookmark' }); const handleShare = () => emit({ type: 'share' }); const handleDelete = () => { if (isDeleting) return; Alert.alert( '删除帖子', '确定要删除这篇帖子吗?删除后将无法恢复。', [ { text: '取消', style: 'cancel' }, { text: '删除', style: 'destructive', onPress: async () => { setIsDeleting(true); try { emit({ type: 'delete' }); } finally { setIsDeleting(false); } }, }, ], ); }; const images: ImageGridItem[] = Array.isArray(post.images) ? post.images.map((img) => ({ id: img.id, url: img.url, thumbnail_url: img.thumbnail_url, preview_url: img.preview_url, preview_url_large: img.preview_url_large, width: img.width, height: img.height, })) : []; const formatNumber = (num: number): string => { if (num >= 10000) return `${(num / 10000).toFixed(1)}w`; if (num >= 1000) return `${(num / 1000).toFixed(1)}k`; return String(num); }; const getResponsiveMaxLength = () => { if (isWideScreen) return 300; if (isDesktop) return 250; if (isTablet) return 200; return 100; }; const contentNumberOfLines = useMemo(() => { if (isWideScreen) return 8; if (isDesktop) return 6; if (isTablet) return 5; return 3; }, [isWideScreen, isDesktop, isTablet]); const getTruncatedContent = (value: string): string => { const maxLength = getResponsiveMaxLength(); if (value.length <= maxLength || isExpanded) return value; return `${value.substring(0, maxLength)}...`; }; const shouldTruncate = !showGrid && !isCompact && rawContent.length > getResponsiveMaxLength(); const content = useMemo( () => (showGrid || isCompact ? rawContent : getTruncatedContent(rawContent)), [rawContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet] ); const renderImages = () => { if (images.length === 0) return null; if (showGrid) { const cover = images[0]; const coverUrl = getPreviewImageUrl(cover as any, 'grid'); return ( handleImagePress(images, 0)}> ); } return ( ); }; const renderTopComment = () => { if (showGrid || !post.top_comment) return null; return ( {post.top_comment.author?.nickname || '匿名用户'}: {post.top_comment.content} ); }; const renderGridCard = () => { const cover = images[0]; const hasImage = !!cover; const coverUrl = hasImage ? getPreviewImageUrl(cover as any, 'grid') : ''; const contentPreview = rawContent .replace(/[#*_~`\[\]()]/g, '') .replace(/\n+/g, ' ') .trim(); return ( {hasImage ? ( handleImagePress(images, 0)}> ) : ( {contentPreview} )} {post.is_vote && ( 投票 )} {!!post.title && ( {post.title} )} {!!post.channel?.name && ( {post.channel.name} )} {author?.nickname || '匿名用户'} {formatNumber(post.likes_count || 0)} ); }; return ( showGrid ? renderGridCard() : ( {author?.nickname || '匿名用户'} {!showGrid && ( {post.is_pinned && ( 置顶 )} {isAuthor && 楼主} )} {isPostAuthor && ( )} {!!post.title && ( {post.title} )} {!isCompact && !!content && ( <> {content} {shouldTruncate && ( setIsExpanded((prev) => !prev)} style={styles.expandBtn}> {isExpanded ? '收起' : '展开全文'} )} )} {renderImages()} {renderTopComment()} {post.views_count || 0} {!!post.channel?.name && ( {post.channel.name} )} {post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'} {post.comments_count > 0 ? formatNumber(post.comments_count) : '评论'} ) ); }; PostCardInner.displayName = 'PostCard'; const PostCard = memo(PostCardInner, arePostCardPropsEqual); const styles = StyleSheet.create({ card: { backgroundColor: colors.background.paper, borderRadius: borderRadius.lg, padding: spacing.md, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.divider, }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: spacing.sm, }, authorWrap: { flexDirection: 'row', alignItems: 'center', flex: 1, minWidth: 0, }, authorMeta: { marginLeft: spacing.sm, flex: 1, minWidth: 0, }, authorName: { color: colors.text.primary, fontWeight: '600', fontSize: fontSizes.md, }, timeText: { color: colors.text.hint, fontSize: fontSizes.xs, marginTop: 2, }, metaRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 2, flexWrap: 'wrap', }, channelTag: { flexDirection: 'row', alignItems: 'center', maxWidth: 140, paddingHorizontal: 6, paddingVertical: 2, borderRadius: borderRadius.sm, backgroundColor: `${colors.primary.main}12`, gap: 4, }, channelTagText: { fontSize: fontSizes.xs, color: colors.primary.main, fontWeight: '600', flexShrink: 1, }, pinnedTag: { flexDirection: 'row', alignItems: 'center', backgroundColor: `${colors.warning.light}22`, borderRadius: borderRadius.sm, paddingHorizontal: 4, paddingVertical: 1, gap: 2, }, pinnedText: { color: colors.warning.main, fontSize: fontSizes.xs, }, badgeText: { color: colors.primary.main, fontSize: fontSizes.xs, fontWeight: '600', }, deleteButton: { padding: spacing.xs, marginLeft: spacing.sm, }, title: { color: colors.text.primary, fontSize: fontSizes.lg, fontWeight: '600', marginBottom: spacing.xs, }, content: { color: colors.text.secondary, fontSize: fontSizes.md, marginBottom: spacing.xs, lineHeight: fontSizes.md * 1.45, }, expandBtn: { marginBottom: spacing.sm, }, expandText: { color: colors.primary.main, fontSize: fontSizes.sm, }, topCommentBox: { backgroundColor: colors.background.default, borderRadius: borderRadius.sm, paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, marginBottom: spacing.sm, flexDirection: 'row', alignItems: 'center', }, topCommentAuthor: { color: colors.text.secondary, fontSize: fontSizes.sm, fontWeight: '600', marginRight: 4, }, topCommentText: { color: colors.text.secondary, fontSize: fontSizes.sm, flex: 1, }, gridCover: { width: '100%', aspectRatio: 0.78, backgroundColor: colors.background.default, }, actions: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, actionsLeading: { flexDirection: 'row', alignItems: 'center', flex: 1, minWidth: 0, marginRight: spacing.sm, gap: spacing.sm, }, channelTagAfterViews: { maxWidth: 130, flexShrink: 1, }, viewsWrap: { flexDirection: 'row', alignItems: 'center', flexShrink: 0, }, viewsText: { color: colors.text.hint, fontSize: fontSizes.sm, marginLeft: 4, }, actionButtons: { flexDirection: 'row', alignItems: 'center', }, actionBtn: { flexDirection: 'row', alignItems: 'center', marginLeft: spacing.md, }, actionText: { color: colors.text.secondary, fontSize: fontSizes.sm, marginLeft: 4, }, activeActionText: { color: colors.error.main, }, activeActionTextMerged: { color: colors.error.main, fontSize: fontSizes.sm, marginLeft: 4, }, gridRootCard: { backgroundColor: colors.background.paper, borderRadius: borderRadius.lg, overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth, borderColor: colors.divider, }, gridNoImagePreview: { backgroundColor: colors.background.default, margin: 6, borderRadius: borderRadius.md, minHeight: 120, padding: 8, }, gridNoImageText: { color: colors.text.secondary, fontSize: fontSizes.sm, lineHeight: 20, }, gridVoteTag: { position: 'absolute', top: 8, right: 8, backgroundColor: `${colors.primary.main}CC`, borderRadius: borderRadius.full, paddingHorizontal: 8, paddingVertical: 4, flexDirection: 'row', alignItems: 'center', gap: 4, }, gridVoteTagText: { color: colors.primary.contrast, fontSize: 10, fontWeight: '600', }, gridTitleMain: { color: colors.text.primary, fontSize: fontSizes.md, lineHeight: 20, paddingHorizontal: 8, paddingTop: 8, minHeight: 44, }, gridChannelRow: { flexDirection: 'row', alignItems: 'center', gap: 4, paddingHorizontal: 8, paddingTop: 4, paddingBottom: 2, }, gridChannelText: { fontSize: fontSizes.xs, color: colors.text.secondary, fontWeight: '600', flex: 1, minWidth: 0, }, gridFooter: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 8, paddingVertical: 10, }, gridUserArea: { flexDirection: 'row', alignItems: 'center', flex: 1, minWidth: 0, }, gridUsername: { color: colors.text.secondary, fontSize: fontSizes.sm, marginLeft: 6, flex: 1, }, gridLikeArea: { flexDirection: 'row', alignItems: 'center', }, gridLikeCount: { color: colors.text.secondary, fontSize: fontSizes.sm, marginLeft: 4, }, }); export default PostCard;