912 lines
24 KiB
TypeScript
912 lines
24 KiB
TypeScript
|
|
/**
|
|||
|
|
* PostCard 帖子卡片组件 - QQ频道风格(响应式版本)
|
|||
|
|
* 简洁的帖子展示,无卡片边框
|
|||
|
|
* 支持响应式布局,宽屏下优化显示
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import React, { useState, useCallback, useMemo } from 'react';
|
|||
|
|
import {
|
|||
|
|
View,
|
|||
|
|
TouchableOpacity,
|
|||
|
|
StyleSheet,
|
|||
|
|
Alert,
|
|||
|
|
useWindowDimensions,
|
|||
|
|
} from 'react-native';
|
|||
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|||
|
|
import { formatDistanceToNow } from 'date-fns';
|
|||
|
|
import { zhCN } from 'date-fns/locale';
|
|||
|
|
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
|||
|
|
import { Post } from '../../types';
|
|||
|
|
import Text from '../common/Text';
|
|||
|
|
import Avatar from '../common/Avatar';
|
|||
|
|
import { ImageGrid, ImageGridItem, SmartImage } from '../common';
|
|||
|
|
import VotePreview from './VotePreview';
|
|||
|
|
import { useResponsive, useResponsiveValue } from '../../hooks/useResponsive';
|
|||
|
|
|
|||
|
|
interface PostCardProps {
|
|||
|
|
post: Post;
|
|||
|
|
onPress: () => void;
|
|||
|
|
onUserPress: () => void;
|
|||
|
|
onLike: () => void;
|
|||
|
|
onComment: () => void;
|
|||
|
|
onBookmark: () => void;
|
|||
|
|
onShare: () => void;
|
|||
|
|
onImagePress?: (images: ImageGridItem[], index: number) => void;
|
|||
|
|
onDelete?: () => void;
|
|||
|
|
compact?: boolean;
|
|||
|
|
index?: number;
|
|||
|
|
isAuthor?: boolean;
|
|||
|
|
isPostAuthor?: boolean; // 当前用户是否为帖子作者
|
|||
|
|
variant?: 'default' | 'grid';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const PostCard: React.FC<PostCardProps> = ({
|
|||
|
|
post,
|
|||
|
|
onPress,
|
|||
|
|
onUserPress,
|
|||
|
|
onLike,
|
|||
|
|
onComment,
|
|||
|
|
onBookmark,
|
|||
|
|
onShare,
|
|||
|
|
onImagePress,
|
|||
|
|
onDelete,
|
|||
|
|
compact = false,
|
|||
|
|
index,
|
|||
|
|
isAuthor = false,
|
|||
|
|
isPostAuthor = false,
|
|||
|
|
variant = 'default',
|
|||
|
|
}) => {
|
|||
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|||
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|||
|
|
|
|||
|
|
// 使用响应式 hook
|
|||
|
|
const {
|
|||
|
|
width,
|
|||
|
|
isMobile,
|
|||
|
|
isTablet,
|
|||
|
|
isDesktop,
|
|||
|
|
isWideScreen,
|
|||
|
|
isLandscape,
|
|||
|
|
orientation
|
|||
|
|
} = useResponsive();
|
|||
|
|
|
|||
|
|
const { width: windowWidth } = useWindowDimensions();
|
|||
|
|
|
|||
|
|
// 响应式字体大小
|
|||
|
|
const responsiveFontSize = useResponsiveValue({
|
|||
|
|
xs: fontSizes.md,
|
|||
|
|
sm: fontSizes.md,
|
|||
|
|
md: fontSizes.lg,
|
|||
|
|
lg: isLandscape ? fontSizes.lg : fontSizes.xl,
|
|||
|
|
xl: fontSizes.xl,
|
|||
|
|
'2xl': fontSizes.xl,
|
|||
|
|
'3xl': fontSizes['2xl'],
|
|||
|
|
'4xl': fontSizes['2xl'],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 响应式标题字体大小
|
|||
|
|
const responsiveTitleFontSize = useResponsiveValue({
|
|||
|
|
xs: fontSizes.lg,
|
|||
|
|
sm: fontSizes.lg,
|
|||
|
|
md: fontSizes.xl,
|
|||
|
|
lg: isLandscape ? fontSizes.xl : fontSizes['2xl'],
|
|||
|
|
xl: fontSizes['2xl'],
|
|||
|
|
'2xl': fontSizes['2xl'],
|
|||
|
|
'3xl': fontSizes['3xl'],
|
|||
|
|
'4xl': fontSizes['3xl'],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 响应式头像大小
|
|||
|
|
const avatarSize = useResponsiveValue({
|
|||
|
|
xs: 36,
|
|||
|
|
sm: 38,
|
|||
|
|
md: 40,
|
|||
|
|
lg: 44,
|
|||
|
|
xl: 48,
|
|||
|
|
'2xl': 48,
|
|||
|
|
'3xl': 52,
|
|||
|
|
'4xl': 56,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 响应式内边距 - 宽屏下增加更多内边距
|
|||
|
|
const responsivePadding = useResponsiveValue({
|
|||
|
|
xs: spacing.md,
|
|||
|
|
sm: spacing.md,
|
|||
|
|
md: spacing.lg,
|
|||
|
|
lg: spacing.xl,
|
|||
|
|
xl: spacing.xl * 1.2,
|
|||
|
|
'2xl': spacing.xl * 1.5,
|
|||
|
|
'3xl': spacing.xl * 2,
|
|||
|
|
'4xl': spacing.xl * 2.5,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 宽屏下额外的卡片内边距
|
|||
|
|
const cardPadding = useMemo(() => {
|
|||
|
|
if (isWideScreen) return spacing.xl;
|
|||
|
|
if (isDesktop) return spacing.lg;
|
|||
|
|
if (isTablet) return spacing.md;
|
|||
|
|
return 0; // 移动端无额外内边距
|
|||
|
|
}, [isWideScreen, isDesktop, isTablet]);
|
|||
|
|
|
|||
|
|
const formatTime = (dateString: string | undefined | null): string => {
|
|||
|
|
if (!dateString) return '';
|
|||
|
|
try {
|
|||
|
|
return formatDistanceToNow(new Date(dateString), {
|
|||
|
|
addSuffix: true,
|
|||
|
|
locale: zhCN,
|
|||
|
|
});
|
|||
|
|
} catch {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getTruncatedContent = (content: string | undefined | null, maxLength: number = 100): string => {
|
|||
|
|
if (!content) return '';
|
|||
|
|
if (content.length <= maxLength || isExpanded) return content;
|
|||
|
|
return content.substring(0, maxLength) + '...';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 宽屏下显示更多内容
|
|||
|
|
const getResponsiveMaxLength = () => {
|
|||
|
|
if (isWideScreen) return 300;
|
|||
|
|
if (isDesktop) return 250;
|
|||
|
|
if (isTablet) return 200;
|
|||
|
|
return 100;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const formatNumber = (num: number): string => {
|
|||
|
|
if (num >= 10000) {
|
|||
|
|
return (num / 10000).toFixed(1) + 'w';
|
|||
|
|
}
|
|||
|
|
if (num >= 1000) {
|
|||
|
|
return (num / 1000).toFixed(1) + 'k';
|
|||
|
|
}
|
|||
|
|
return num.toString();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 处理删除帖子
|
|||
|
|
const handleDelete = () => {
|
|||
|
|
if (!onDelete || isDeleting) return;
|
|||
|
|
|
|||
|
|
Alert.alert(
|
|||
|
|
'删除帖子',
|
|||
|
|
'确定要删除这篇帖子吗?删除后将无法恢复。',
|
|||
|
|
[
|
|||
|
|
{
|
|||
|
|
text: '取消',
|
|||
|
|
style: 'cancel',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
text: '删除',
|
|||
|
|
style: 'destructive',
|
|||
|
|
onPress: async () => {
|
|||
|
|
setIsDeleting(true);
|
|||
|
|
try {
|
|||
|
|
await onDelete();
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('删除帖子失败:', error);
|
|||
|
|
Alert.alert('删除失败', '删除帖子时发生错误,请稍后重试');
|
|||
|
|
} finally {
|
|||
|
|
setIsDeleting(false);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 渲染图片 - 使用新的 ImageGrid 组件
|
|||
|
|
const renderImages = () => {
|
|||
|
|
if (!post.images || !Array.isArray(post.images) || post.images.length === 0) return null;
|
|||
|
|
|
|||
|
|
// 转换图片数据格式
|
|||
|
|
const gridImages: ImageGridItem[] = post.images.map(img => ({
|
|||
|
|
id: img.id,
|
|||
|
|
url: img.url,
|
|||
|
|
thumbnail_url: img.thumbnail_url,
|
|||
|
|
width: img.width,
|
|||
|
|
height: img.height,
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 宽屏下显示更大的图片
|
|||
|
|
const imageGap = isDesktop ? 12 : isTablet ? 8 : 4;
|
|||
|
|
const imageBorderRadius = isDesktop ? borderRadius.xl : borderRadius.md;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<View style={styles.imagesContainer}>
|
|||
|
|
<ImageGrid
|
|||
|
|
images={gridImages}
|
|||
|
|
maxDisplayCount={isWideScreen ? 12 : 9}
|
|||
|
|
mode="auto"
|
|||
|
|
gap={imageGap}
|
|||
|
|
borderRadius={imageBorderRadius}
|
|||
|
|
showMoreOverlay={true}
|
|||
|
|
onImagePress={onImagePress}
|
|||
|
|
/>
|
|||
|
|
</View>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const renderBadges = () => {
|
|||
|
|
const badges = [];
|
|||
|
|
|
|||
|
|
if (isAuthor) {
|
|||
|
|
badges.push(
|
|||
|
|
<View key="author" style={[styles.badge, styles.authorBadge]}>
|
|||
|
|
<Text variant="caption" style={styles.badgeText}>楼主</Text>
|
|||
|
|
</View>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (post.author?.id === '1') {
|
|||
|
|
badges.push(
|
|||
|
|
<View key="admin" style={[styles.badge, styles.adminBadge]}>
|
|||
|
|
<Text variant="caption" style={styles.badgeText}>管理员</Text>
|
|||
|
|
</View>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return badges;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const renderTopComment = () => {
|
|||
|
|
if (!post.top_comment) return null;
|
|||
|
|
|
|||
|
|
const { top_comment } = post;
|
|||
|
|
|
|||
|
|
const topCommentContainerStyles = [
|
|||
|
|
styles.topCommentContainer,
|
|||
|
|
...(isDesktop ? [styles.topCommentContainerWide] : [])
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const topCommentAuthorStyles = [
|
|||
|
|
styles.topCommentAuthor,
|
|||
|
|
...(isDesktop ? [styles.topCommentAuthorWide] : [])
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const topCommentTextStyles = [
|
|||
|
|
styles.topCommentText,
|
|||
|
|
...(isDesktop ? [styles.topCommentTextWide] : [])
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const moreCommentsStyles = [
|
|||
|
|
styles.moreComments,
|
|||
|
|
...(isDesktop ? [styles.moreCommentsWide] : [])
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<View style={topCommentContainerStyles}>
|
|||
|
|
<View style={styles.topCommentContent}>
|
|||
|
|
<Text
|
|||
|
|
variant="caption"
|
|||
|
|
color={colors.text.secondary}
|
|||
|
|
style={topCommentAuthorStyles}
|
|||
|
|
>
|
|||
|
|
{top_comment.author?.nickname || '匿名用户'}:
|
|||
|
|
</Text>
|
|||
|
|
<Text
|
|||
|
|
variant="caption"
|
|||
|
|
color={colors.text.secondary}
|
|||
|
|
style={topCommentTextStyles}
|
|||
|
|
numberOfLines={isDesktop ? 2 : 1}
|
|||
|
|
>
|
|||
|
|
{top_comment.content}
|
|||
|
|
</Text>
|
|||
|
|
</View>
|
|||
|
|
{post.comments_count > 1 && (
|
|||
|
|
<Text
|
|||
|
|
variant="caption"
|
|||
|
|
color={colors.primary.main}
|
|||
|
|
style={moreCommentsStyles}
|
|||
|
|
>
|
|||
|
|
查看全部{formatNumber(post.comments_count)}条评论
|
|||
|
|
</Text>
|
|||
|
|
)}
|
|||
|
|
</View>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 渲染投票预览
|
|||
|
|
const renderVotePreview = () => {
|
|||
|
|
if (!post.is_vote) return null;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<VotePreview
|
|||
|
|
onPress={onPress}
|
|||
|
|
/>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 渲染小红书风格的两栏卡片
|
|||
|
|
const renderGridVariant = () => {
|
|||
|
|
// 获取封面图(第一张图)
|
|||
|
|
const coverImage = post.images && Array.isArray(post.images) && post.images.length > 0 ? post.images[0] : null;
|
|||
|
|
const hasImage = !!coverImage;
|
|||
|
|
|
|||
|
|
// 防御性检查:确保 author 存在
|
|||
|
|
const author = post.author || { id: '', nickname: '匿名用户', avatar: null, username: '' };
|
|||
|
|
|
|||
|
|
// 根据图片实际宽高比或使用随机值创建错落感
|
|||
|
|
// 使用帖子 ID 生成一个伪随机值,确保每次渲染一致但不同帖子有差异
|
|||
|
|
const postId = post.id || '';
|
|||
|
|
const hash = postId.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
|
|||
|
|
const aspectRatios = [0.7, 0.75, 0.8, 0.85, 0.9, 1, 1.1, 1.2];
|
|||
|
|
const aspectRatio = aspectRatios[hash % aspectRatios.length];
|
|||
|
|
|
|||
|
|
// 获取正文预览(无图时显示)
|
|||
|
|
const getContentPreview = (content: string | undefined | null): string => {
|
|||
|
|
if (!content) return '';
|
|||
|
|
// 移除 Markdown 标记和多余空白
|
|||
|
|
return content
|
|||
|
|
.replace(/[#*_~`\[\]()]/g, '')
|
|||
|
|
.replace(/\n+/g, ' ')
|
|||
|
|
.trim();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const contentPreview = getContentPreview(post.content);
|
|||
|
|
|
|||
|
|
// 响应式字体大小(网格模式)
|
|||
|
|
const gridTitleFontSize = isDesktop ? 16 : isTablet ? 15 : 14;
|
|||
|
|
const gridUsernameFontSize = isDesktop ? 14 : 12;
|
|||
|
|
const gridLikeFontSize = isDesktop ? 14 : 12;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<TouchableOpacity
|
|||
|
|
style={[
|
|||
|
|
styles.gridContainer,
|
|||
|
|
!hasImage && styles.gridContainerNoImage,
|
|||
|
|
isDesktop && styles.gridContainerDesktop
|
|||
|
|
]}
|
|||
|
|
onPress={onPress}
|
|||
|
|
activeOpacity={0.9}
|
|||
|
|
>
|
|||
|
|
{/* 封面图 - 只有有图片时才渲染,无图时不显示占位区域 */}
|
|||
|
|
{hasImage && (
|
|||
|
|
<TouchableOpacity
|
|||
|
|
activeOpacity={0.8}
|
|||
|
|
onPress={() => onImagePress?.(post.images || [], 0)}
|
|||
|
|
>
|
|||
|
|
<SmartImage
|
|||
|
|
source={{ uri: coverImage.thumbnail_url || coverImage.url || '' }}
|
|||
|
|
style={[styles.gridCoverImage, { aspectRatio }]}
|
|||
|
|
resizeMode="cover"
|
|||
|
|
/>
|
|||
|
|
</TouchableOpacity>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 无图时的正文预览区域 */}
|
|||
|
|
{!hasImage && contentPreview && (
|
|||
|
|
<View style={[styles.gridContentPreview, isDesktop ? styles.gridContentPreviewDesktop : null]}>
|
|||
|
|
<Text
|
|||
|
|
style={[styles.gridContentText, ...(isDesktop ? [styles.gridContentTextDesktop] : [])]}
|
|||
|
|
numberOfLines={isDesktop ? 8 : 6}
|
|||
|
|
>
|
|||
|
|
{contentPreview}
|
|||
|
|
</Text>
|
|||
|
|
</View>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 投票标识 */}
|
|||
|
|
{post.is_vote && (
|
|||
|
|
<View style={styles.gridVoteBadge}>
|
|||
|
|
<MaterialCommunityIcons name="vote" size={isDesktop ? 14 : 12} color={colors.primary.contrast} />
|
|||
|
|
<Text style={styles.gridVoteBadgeText}>投票</Text>
|
|||
|
|
</View>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 标题 - 无图时显示更多行 */}
|
|||
|
|
{post.title && (
|
|||
|
|
<Text
|
|||
|
|
style={[
|
|||
|
|
styles.gridTitle,
|
|||
|
|
{ fontSize: gridTitleFontSize },
|
|||
|
|
...(hasImage ? [] : [styles.gridTitleNoImage]),
|
|||
|
|
...(isDesktop ? [styles.gridTitleDesktop] : [])
|
|||
|
|
]}
|
|||
|
|
numberOfLines={hasImage ? 2 : 3}
|
|||
|
|
>
|
|||
|
|
{post.title}
|
|||
|
|
</Text>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 底部信息 */}
|
|||
|
|
<View style={[styles.gridFooter, isDesktop && styles.gridFooterDesktop]}>
|
|||
|
|
<TouchableOpacity style={styles.gridUserInfo} onPress={onUserPress}>
|
|||
|
|
<Avatar
|
|||
|
|
source={author.avatar}
|
|||
|
|
size={isDesktop ? 24 : 20}
|
|||
|
|
name={author.nickname}
|
|||
|
|
/>
|
|||
|
|
<Text style={[styles.gridUsername, { fontSize: gridUsernameFontSize }]} numberOfLines={1}>
|
|||
|
|
{author.nickname}
|
|||
|
|
</Text>
|
|||
|
|
</TouchableOpacity>
|
|||
|
|
|
|||
|
|
<View style={styles.gridLikeInfo}>
|
|||
|
|
<MaterialCommunityIcons name="heart" size={isDesktop ? 16 : 14} color="#666" />
|
|||
|
|
<Text style={[styles.gridLikeCount, { fontSize: gridLikeFontSize }]}>
|
|||
|
|
{formatNumber(post.likes_count)}
|
|||
|
|
</Text>
|
|||
|
|
</View>
|
|||
|
|
</View>
|
|||
|
|
</TouchableOpacity>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 根据 variant 渲染不同样式
|
|||
|
|
if (variant === 'grid') {
|
|||
|
|
return renderGridVariant();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 防御性检查:确保 author 存在
|
|||
|
|
const author = post.author || { id: '', nickname: '匿名用户', avatar: null, username: '' };
|
|||
|
|
|
|||
|
|
// 计算响应式内容最大行数
|
|||
|
|
const contentNumberOfLines = useMemo(() => {
|
|||
|
|
if (isWideScreen) return 8;
|
|||
|
|
if (isDesktop) return 6;
|
|||
|
|
if (isTablet) return 5;
|
|||
|
|
return 3;
|
|||
|
|
}, [isWideScreen, isDesktop, isTablet]);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<TouchableOpacity
|
|||
|
|
style={[
|
|||
|
|
styles.container,
|
|||
|
|
{
|
|||
|
|
paddingHorizontal: responsivePadding,
|
|||
|
|
paddingVertical: responsivePadding * 0.75,
|
|||
|
|
...(isDesktop || isWideScreen ? {
|
|||
|
|
borderRadius: borderRadius.lg,
|
|||
|
|
marginHorizontal: cardPadding,
|
|||
|
|
marginVertical: spacing.sm,
|
|||
|
|
shadowColor: '#000',
|
|||
|
|
shadowOffset: { width: 0, height: 2 },
|
|||
|
|
shadowOpacity: 0.08,
|
|||
|
|
shadowRadius: 8,
|
|||
|
|
elevation: 3,
|
|||
|
|
} : {})
|
|||
|
|
}
|
|||
|
|
]}
|
|||
|
|
onPress={onPress}
|
|||
|
|
activeOpacity={0.9}
|
|||
|
|
>
|
|||
|
|
{/* 用户信息 */}
|
|||
|
|
<View style={styles.userSection}>
|
|||
|
|
<TouchableOpacity onPress={onUserPress}>
|
|||
|
|
<Avatar
|
|||
|
|
source={author.avatar}
|
|||
|
|
size={avatarSize}
|
|||
|
|
name={author.nickname}
|
|||
|
|
/>
|
|||
|
|
</TouchableOpacity>
|
|||
|
|
<View style={styles.userInfo}>
|
|||
|
|
<View style={styles.userNameRow}>
|
|||
|
|
<TouchableOpacity onPress={onUserPress}>
|
|||
|
|
<Text
|
|||
|
|
variant="body"
|
|||
|
|
style={[
|
|||
|
|
styles.username,
|
|||
|
|
{ fontSize: isDesktop ? fontSizes.lg : fontSizes.md }
|
|||
|
|
]}
|
|||
|
|
>
|
|||
|
|
{author.nickname}
|
|||
|
|
</Text>
|
|||
|
|
</TouchableOpacity>
|
|||
|
|
{renderBadges()}
|
|||
|
|
</View>
|
|||
|
|
<View style={styles.postMeta}>
|
|||
|
|
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
|
|||
|
|
{formatTime(post.created_at || '')}
|
|||
|
|
</Text>
|
|||
|
|
</View>
|
|||
|
|
</View>
|
|||
|
|
{post.is_pinned && (
|
|||
|
|
<View style={styles.pinnedTag}>
|
|||
|
|
<MaterialCommunityIcons name="pin" size={isDesktop ? 14 : 12} color={colors.warning.main} />
|
|||
|
|
<Text variant="caption" color={colors.warning.main} style={styles.pinnedText}>置顶</Text>
|
|||
|
|
</View>
|
|||
|
|
)}
|
|||
|
|
{/* 删除按钮 - 只对帖子作者显示 */}
|
|||
|
|
{isPostAuthor && onDelete && (
|
|||
|
|
<TouchableOpacity
|
|||
|
|
style={styles.deleteButton}
|
|||
|
|
onPress={handleDelete}
|
|||
|
|
disabled={isDeleting}
|
|||
|
|
>
|
|||
|
|
<MaterialCommunityIcons
|
|||
|
|
name={isDeleting ? 'loading' : 'delete-outline'}
|
|||
|
|
size={isDesktop ? 20 : 18}
|
|||
|
|
color={colors.text.hint}
|
|||
|
|
/>
|
|||
|
|
</TouchableOpacity>
|
|||
|
|
)}
|
|||
|
|
</View>
|
|||
|
|
|
|||
|
|
{/* 标题 */}
|
|||
|
|
{post.title && (
|
|||
|
|
<Text
|
|||
|
|
variant="body"
|
|||
|
|
numberOfLines={compact ? 2 : undefined}
|
|||
|
|
style={[
|
|||
|
|
styles.title,
|
|||
|
|
{ fontSize: responsiveTitleFontSize, lineHeight: responsiveTitleFontSize * 1.4 }
|
|||
|
|
]}
|
|||
|
|
>
|
|||
|
|
{post.title}
|
|||
|
|
</Text>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 内容 */}
|
|||
|
|
{!compact && (
|
|||
|
|
<>
|
|||
|
|
<Text
|
|||
|
|
variant="body"
|
|||
|
|
color={colors.text.secondary}
|
|||
|
|
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
|||
|
|
style={[
|
|||
|
|
styles.content,
|
|||
|
|
{ fontSize: responsiveFontSize, lineHeight: responsiveFontSize * 1.5 }
|
|||
|
|
]}
|
|||
|
|
>
|
|||
|
|
{getTruncatedContent(post.content, getResponsiveMaxLength())}
|
|||
|
|
</Text>
|
|||
|
|
{post.content && post.content.length > getResponsiveMaxLength() && (
|
|||
|
|
<TouchableOpacity
|
|||
|
|
onPress={() => setIsExpanded(!isExpanded)}
|
|||
|
|
style={styles.expandButton}
|
|||
|
|
>
|
|||
|
|
<Text variant="caption" color={colors.primary.main}>
|
|||
|
|
{isExpanded ? '收起' : '展开全文'}
|
|||
|
|
</Text>
|
|||
|
|
</TouchableOpacity>
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 图片 */}
|
|||
|
|
{renderImages()}
|
|||
|
|
|
|||
|
|
{/* 投票预览 */}
|
|||
|
|
{renderVotePreview()}
|
|||
|
|
|
|||
|
|
{/* 热门评论预览 */}
|
|||
|
|
{renderTopComment()}
|
|||
|
|
|
|||
|
|
{/* 交互按钮 */}
|
|||
|
|
<View style={[styles.actionBar, isDesktop ? styles.actionBarWide : null]}>
|
|||
|
|
<View style={styles.viewCount}>
|
|||
|
|
<MaterialCommunityIcons name="eye-outline" size={isDesktop ? 18 : 16} color={colors.text.hint} />
|
|||
|
|
<Text
|
|||
|
|
variant="caption"
|
|||
|
|
color={colors.text.hint}
|
|||
|
|
style={[styles.actionText, ...(isDesktop ? [styles.actionTextWide] : [])]}
|
|||
|
|
>
|
|||
|
|
{formatNumber(post.views_count || 0)}
|
|||
|
|
</Text>
|
|||
|
|
</View>
|
|||
|
|
|
|||
|
|
<View style={styles.actionButtons}>
|
|||
|
|
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={onLike}>
|
|||
|
|
<MaterialCommunityIcons
|
|||
|
|
name={post.is_liked ? 'heart' : 'heart-outline'}
|
|||
|
|
size={isDesktop ? 22 : 19}
|
|||
|
|
color={post.is_liked ? colors.error.main : colors.text.secondary}
|
|||
|
|
/>
|
|||
|
|
<Text
|
|||
|
|
variant="caption"
|
|||
|
|
color={post.is_liked ? colors.error.main : colors.text.secondary}
|
|||
|
|
style={[styles.actionText, ...(isDesktop ? [styles.actionTextWide] : [])]}
|
|||
|
|
>
|
|||
|
|
{post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'}
|
|||
|
|
</Text>
|
|||
|
|
</TouchableOpacity>
|
|||
|
|
|
|||
|
|
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={onComment}>
|
|||
|
|
<MaterialCommunityIcons
|
|||
|
|
name="comment-outline"
|
|||
|
|
size={isDesktop ? 22 : 19}
|
|||
|
|
color={colors.text.secondary}
|
|||
|
|
/>
|
|||
|
|
<Text variant="caption" color={colors.text.secondary} style={[styles.actionText, ...(isDesktop ? [styles.actionTextWide] : [])]}>
|
|||
|
|
{post.comments_count > 0 ? formatNumber(post.comments_count) : '评论'}
|
|||
|
|
</Text>
|
|||
|
|
</TouchableOpacity>
|
|||
|
|
|
|||
|
|
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={onShare}>
|
|||
|
|
<MaterialCommunityIcons
|
|||
|
|
name="share-outline"
|
|||
|
|
size={isDesktop ? 22 : 19}
|
|||
|
|
color={colors.text.secondary}
|
|||
|
|
/>
|
|||
|
|
</TouchableOpacity>
|
|||
|
|
|
|||
|
|
<TouchableOpacity style={[styles.actionButton, isDesktop && styles.actionButtonWide]} onPress={onBookmark}>
|
|||
|
|
<MaterialCommunityIcons
|
|||
|
|
name={post.is_favorited ? 'bookmark' : 'bookmark-outline'}
|
|||
|
|
size={isDesktop ? 22 : 19}
|
|||
|
|
color={post.is_favorited ? colors.warning.main : colors.text.secondary}
|
|||
|
|
/>
|
|||
|
|
</TouchableOpacity>
|
|||
|
|
</View>
|
|||
|
|
</View>
|
|||
|
|
</TouchableOpacity>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const styles = StyleSheet.create({
|
|||
|
|
container: {
|
|||
|
|
backgroundColor: colors.background.paper,
|
|||
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|||
|
|
borderBottomColor: colors.divider,
|
|||
|
|
},
|
|||
|
|
// 宽屏卡片样式
|
|||
|
|
wideCard: {
|
|||
|
|
backgroundColor: colors.background.paper,
|
|||
|
|
borderRadius: borderRadius.lg,
|
|||
|
|
shadowColor: '#000',
|
|||
|
|
shadowOffset: { width: 0, height: 2 },
|
|||
|
|
shadowOpacity: 0.08,
|
|||
|
|
shadowRadius: 8,
|
|||
|
|
elevation: 3,
|
|||
|
|
},
|
|||
|
|
userSection: {
|
|||
|
|
flexDirection: 'row',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
marginBottom: spacing.sm,
|
|||
|
|
},
|
|||
|
|
userInfo: {
|
|||
|
|
flex: 1,
|
|||
|
|
marginLeft: spacing.sm,
|
|||
|
|
},
|
|||
|
|
userNameRow: {
|
|||
|
|
flexDirection: 'row',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
flexWrap: 'wrap',
|
|||
|
|
},
|
|||
|
|
username: {
|
|||
|
|
fontWeight: '600',
|
|||
|
|
color: colors.text.primary,
|
|||
|
|
},
|
|||
|
|
badge: {
|
|||
|
|
paddingHorizontal: 4,
|
|||
|
|
paddingVertical: 1,
|
|||
|
|
borderRadius: 2,
|
|||
|
|
marginLeft: spacing.xs,
|
|||
|
|
},
|
|||
|
|
authorBadge: {
|
|||
|
|
backgroundColor: colors.primary.main,
|
|||
|
|
},
|
|||
|
|
adminBadge: {
|
|||
|
|
backgroundColor: colors.error.main,
|
|||
|
|
},
|
|||
|
|
badgeText: {
|
|||
|
|
color: colors.text.inverse,
|
|||
|
|
fontSize: fontSizes.xs,
|
|||
|
|
fontWeight: '600',
|
|||
|
|
},
|
|||
|
|
postMeta: {
|
|||
|
|
flexDirection: 'row',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
marginTop: 2,
|
|||
|
|
},
|
|||
|
|
timeText: {
|
|||
|
|
fontSize: fontSizes.sm,
|
|||
|
|
color: colors.text.hint,
|
|||
|
|
},
|
|||
|
|
pinnedTag: {
|
|||
|
|
flexDirection: 'row',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
backgroundColor: colors.warning.light + '20',
|
|||
|
|
paddingHorizontal: spacing.xs,
|
|||
|
|
paddingVertical: 2,
|
|||
|
|
borderRadius: borderRadius.sm,
|
|||
|
|
},
|
|||
|
|
pinnedText: {
|
|||
|
|
marginLeft: 2,
|
|||
|
|
fontSize: fontSizes.xs,
|
|||
|
|
fontWeight: '500',
|
|||
|
|
},
|
|||
|
|
deleteButton: {
|
|||
|
|
padding: spacing.xs,
|
|||
|
|
marginLeft: spacing.sm,
|
|||
|
|
},
|
|||
|
|
title: {
|
|||
|
|
marginBottom: spacing.xs,
|
|||
|
|
fontWeight: '600',
|
|||
|
|
color: colors.text.primary,
|
|||
|
|
},
|
|||
|
|
content: {
|
|||
|
|
marginBottom: spacing.xs,
|
|||
|
|
color: colors.text.secondary,
|
|||
|
|
},
|
|||
|
|
expandButton: {
|
|||
|
|
marginBottom: spacing.sm,
|
|||
|
|
},
|
|||
|
|
imagesContainer: {
|
|||
|
|
marginBottom: spacing.md,
|
|||
|
|
},
|
|||
|
|
topCommentContainer: {
|
|||
|
|
backgroundColor: colors.background.default,
|
|||
|
|
borderRadius: borderRadius.sm,
|
|||
|
|
padding: spacing.sm,
|
|||
|
|
marginBottom: spacing.sm,
|
|||
|
|
},
|
|||
|
|
topCommentContainerWide: {
|
|||
|
|
padding: spacing.md,
|
|||
|
|
borderRadius: borderRadius.md,
|
|||
|
|
},
|
|||
|
|
topCommentContent: {
|
|||
|
|
flexDirection: 'row',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
},
|
|||
|
|
topCommentAuthor: {
|
|||
|
|
fontWeight: '600',
|
|||
|
|
marginRight: spacing.xs,
|
|||
|
|
},
|
|||
|
|
topCommentAuthorWide: {
|
|||
|
|
fontSize: fontSizes.sm,
|
|||
|
|
},
|
|||
|
|
topCommentText: {
|
|||
|
|
flex: 1,
|
|||
|
|
},
|
|||
|
|
topCommentTextWide: {
|
|||
|
|
fontSize: fontSizes.sm,
|
|||
|
|
},
|
|||
|
|
moreComments: {
|
|||
|
|
marginTop: spacing.xs,
|
|||
|
|
fontWeight: '500',
|
|||
|
|
},
|
|||
|
|
moreCommentsWide: {
|
|||
|
|
fontSize: fontSizes.sm,
|
|||
|
|
marginTop: spacing.sm,
|
|||
|
|
},
|
|||
|
|
actionBar: {
|
|||
|
|
flexDirection: 'row',
|
|||
|
|
justifyContent: 'space-between',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
},
|
|||
|
|
actionBarWide: {
|
|||
|
|
marginTop: spacing.sm,
|
|||
|
|
paddingTop: spacing.sm,
|
|||
|
|
},
|
|||
|
|
viewCount: {
|
|||
|
|
flexDirection: 'row',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
},
|
|||
|
|
actionButtons: {
|
|||
|
|
flexDirection: 'row',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
},
|
|||
|
|
actionButton: {
|
|||
|
|
flexDirection: 'row',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
marginLeft: spacing.md,
|
|||
|
|
paddingVertical: spacing.xs,
|
|||
|
|
},
|
|||
|
|
actionButtonWide: {
|
|||
|
|
marginLeft: spacing.lg,
|
|||
|
|
paddingVertical: spacing.sm,
|
|||
|
|
},
|
|||
|
|
actionText: {
|
|||
|
|
marginLeft: 6,
|
|||
|
|
},
|
|||
|
|
actionTextWide: {
|
|||
|
|
fontSize: fontSizes.md,
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ========== 小红书风格两栏样式 ==========
|
|||
|
|
gridContainer: {
|
|||
|
|
backgroundColor: '#FFF',
|
|||
|
|
overflow: 'hidden',
|
|||
|
|
borderRadius: 8,
|
|||
|
|
},
|
|||
|
|
gridContainerDesktop: {
|
|||
|
|
borderRadius: 12,
|
|||
|
|
},
|
|||
|
|
gridContainerNoImage: {
|
|||
|
|
minHeight: 200,
|
|||
|
|
justifyContent: 'space-between',
|
|||
|
|
},
|
|||
|
|
gridCoverImage: {
|
|||
|
|
width: '100%',
|
|||
|
|
aspectRatio: 0.7,
|
|||
|
|
backgroundColor: '#F5F5F5',
|
|||
|
|
},
|
|||
|
|
gridCoverPlaceholder: {
|
|||
|
|
width: '100%',
|
|||
|
|
aspectRatio: 0.7,
|
|||
|
|
backgroundColor: '#F5F5F5',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
justifyContent: 'center',
|
|||
|
|
},
|
|||
|
|
// 无图时的正文预览区域
|
|||
|
|
gridContentPreview: {
|
|||
|
|
backgroundColor: '#F8F8F8',
|
|||
|
|
margin: 4,
|
|||
|
|
padding: 8,
|
|||
|
|
borderRadius: 8,
|
|||
|
|
minHeight: 100,
|
|||
|
|
},
|
|||
|
|
gridContentPreviewDesktop: {
|
|||
|
|
margin: 8,
|
|||
|
|
padding: 12,
|
|||
|
|
borderRadius: 12,
|
|||
|
|
minHeight: 150,
|
|||
|
|
},
|
|||
|
|
gridContentText: {
|
|||
|
|
fontSize: 13,
|
|||
|
|
color: '#666',
|
|||
|
|
lineHeight: 20,
|
|||
|
|
},
|
|||
|
|
gridContentTextDesktop: {
|
|||
|
|
fontSize: 15,
|
|||
|
|
lineHeight: 24,
|
|||
|
|
},
|
|||
|
|
gridTitle: {
|
|||
|
|
color: '#333',
|
|||
|
|
lineHeight: 20,
|
|||
|
|
paddingHorizontal: 4,
|
|||
|
|
paddingTop: 8,
|
|||
|
|
minHeight: 44,
|
|||
|
|
},
|
|||
|
|
gridTitleNoImage: {
|
|||
|
|
minHeight: 0,
|
|||
|
|
paddingTop: 4,
|
|||
|
|
},
|
|||
|
|
gridTitleDesktop: {
|
|||
|
|
paddingHorizontal: 8,
|
|||
|
|
paddingTop: 12,
|
|||
|
|
lineHeight: 24,
|
|||
|
|
minHeight: 56,
|
|||
|
|
},
|
|||
|
|
gridFooter: {
|
|||
|
|
flexDirection: 'row',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
justifyContent: 'space-between',
|
|||
|
|
paddingHorizontal: 4,
|
|||
|
|
paddingVertical: 8,
|
|||
|
|
},
|
|||
|
|
gridFooterDesktop: {
|
|||
|
|
paddingHorizontal: 8,
|
|||
|
|
paddingVertical: 12,
|
|||
|
|
},
|
|||
|
|
gridUserInfo: {
|
|||
|
|
flexDirection: 'row',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
flex: 1,
|
|||
|
|
},
|
|||
|
|
gridUsername: {
|
|||
|
|
color: '#666',
|
|||
|
|
marginLeft: 6,
|
|||
|
|
flex: 1,
|
|||
|
|
},
|
|||
|
|
gridLikeInfo: {
|
|||
|
|
flexDirection: 'row',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
},
|
|||
|
|
gridLikeCount: {
|
|||
|
|
color: '#666',
|
|||
|
|
marginLeft: 4,
|
|||
|
|
},
|
|||
|
|
gridVoteBadge: {
|
|||
|
|
position: 'absolute',
|
|||
|
|
top: 8,
|
|||
|
|
right: 8,
|
|||
|
|
flexDirection: 'row',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
backgroundColor: colors.primary.main + 'CC',
|
|||
|
|
paddingHorizontal: 8,
|
|||
|
|
paddingVertical: 4,
|
|||
|
|
borderRadius: borderRadius.full,
|
|||
|
|
gap: 4,
|
|||
|
|
},
|
|||
|
|
gridVoteBadgeText: {
|
|||
|
|
fontSize: 10,
|
|||
|
|
color: colors.primary.contrast,
|
|||
|
|
fontWeight: '600',
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
export default PostCard;
|