refactor(PostCard, PostCard.legacy): remove legacy PostCard component and update exports
- Deleted the legacy PostCard component to streamline the codebase and improve maintainability. - Updated exports in PostCard and index files to remove references to the legacy component. - Adjusted PostCardProps to eliminate legacy properties, ensuring only the new API is supported. - Enhanced the PostCard component to focus on modern implementation and features.
This commit is contained in:
@@ -1,996 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { 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';
|
|
||||||
import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper';
|
|
||||||
|
|
||||||
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 formatDateTime = (dateString?: string | null): string => {
|
|
||||||
if (!dateString) return '';
|
|
||||||
const date = new Date(dateString);
|
|
||||||
if (Number.isNaN(date.getTime())) return '';
|
|
||||||
const pad = (num: number) => String(num).padStart(2, '0');
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 返回应展示的「最后编辑」时间字符串;优先 content_edited_at(与点赞/评论等统计更新解耦) */
|
|
||||||
const getPostEditedDisplayAt = (
|
|
||||||
createdAt?: string | null,
|
|
||||||
contentEditedAt?: string | null,
|
|
||||||
updatedAt?: string | null,
|
|
||||||
): string | null => {
|
|
||||||
if (contentEditedAt) {
|
|
||||||
const c = new Date(createdAt || '').getTime();
|
|
||||||
const e = new Date(contentEditedAt).getTime();
|
|
||||||
if (!Number.isNaN(c) && !Number.isNaN(e) && e - c > 1000) return contentEditedAt;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (createdAt && updatedAt) {
|
|
||||||
const c = new Date(createdAt).getTime();
|
|
||||||
const u = new Date(updatedAt).getTime();
|
|
||||||
if (!Number.isNaN(c) && !Number.isNaN(u) && u - c > 1000) return updatedAt;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
preview_url: img.preview_url,
|
|
||||||
preview_url_large: img.preview_url_large,
|
|
||||||
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}
|
|
||||||
displayMode={variant === 'grid' ? 'grid' : 'list'}
|
|
||||||
usePreview={true}
|
|
||||||
/>
|
|
||||||
</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];
|
|
||||||
|
|
||||||
// 获取封面图预览 URL
|
|
||||||
const coverUrl = hasImage
|
|
||||||
? getPreviewImageUrl(coverImage, 'grid')
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// 获取正文预览(无图时显示)
|
|
||||||
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;
|
|
||||||
|
|
||||||
const handleContainerPress = () => {
|
|
||||||
onPress();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImagePress = (e: any) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onImagePress?.(post.images || [], 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUserPress = (e: any) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onUserPress();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.gridContainer,
|
|
||||||
!hasImage && styles.gridContainerNoImage,
|
|
||||||
isDesktop && styles.gridContainerDesktop
|
|
||||||
]}
|
|
||||||
onPress={handleContainerPress}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
>
|
|
||||||
{/* 封面图 - 只有有图片时才渲染,无图时不显示占位区域 */}
|
|
||||||
{hasImage && (
|
|
||||||
<TouchableOpacity
|
|
||||||
activeOpacity={0.8}
|
|
||||||
onPress={handleImagePress}
|
|
||||||
>
|
|
||||||
<SmartImage
|
|
||||||
source={{ uri: coverUrl }}
|
|
||||||
style={[styles.gridCoverImage, { aspectRatio }]}
|
|
||||||
resizeMode="cover"
|
|
||||||
usePreview={true}
|
|
||||||
/>
|
|
||||||
</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={handleUserPress}>
|
|
||||||
<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]);
|
|
||||||
|
|
||||||
const handleContainerPress = () => {
|
|
||||||
onPress();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUserPress = (e: any) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onUserPress();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLikePress = (e: any) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onLike();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCommentPress = (e: any) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onComment();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSharePress = (e: any) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onShare();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBookmarkPress = (e: any) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onBookmark();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeletePress = (e: any) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDelete();
|
|
||||||
};
|
|
||||||
|
|
||||||
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={handleContainerPress}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
>
|
|
||||||
{/* 用户信息 */}
|
|
||||||
<View style={styles.userSection}>
|
|
||||||
<TouchableOpacity onPress={handleUserPress}>
|
|
||||||
<Avatar
|
|
||||||
source={author.avatar}
|
|
||||||
size={avatarSize}
|
|
||||||
name={author.nickname}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={styles.userInfo}>
|
|
||||||
<View style={styles.userNameRow}>
|
|
||||||
<TouchableOpacity onPress={handleUserPress}>
|
|
||||||
<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}>
|
|
||||||
发布 {formatDateTime(post.created_at)}
|
|
||||||
</Text>
|
|
||||||
{(() => {
|
|
||||||
const editedAt = getPostEditedDisplayAt(
|
|
||||||
post.created_at,
|
|
||||||
post.content_edited_at,
|
|
||||||
post.updated_at,
|
|
||||||
);
|
|
||||||
return editedAt ? (
|
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
|
|
||||||
{' · 修改 '}{formatDateTime(editedAt)}
|
|
||||||
</Text>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
</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={handleDeletePress}
|
|
||||||
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={handleLikePress}>
|
|
||||||
<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={handleCommentPress}>
|
|
||||||
<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={handleSharePress}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="share-outline"
|
|
||||||
size={isDesktop ? 22 : 19}
|
|
||||||
color={colors.text.secondary}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity style={[styles.actionButton, isDesktop && styles.actionButtonWide]} onPress={handleBookmarkPress}>
|
|
||||||
<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;
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* PostCard 主组件
|
* PostCard 主组件
|
||||||
* 帖子卡片组件 - 支持列表模式和网格模式
|
* 帖子卡片组件(新实现)
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // 新 API 使用方式
|
* // 新 API 使用方式
|
||||||
@@ -12,63 +12,543 @@
|
|||||||
* />
|
* />
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { PostCardProps, LegacyPostCardProps } from './types';
|
import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native';
|
||||||
import PostCardList from './PostCardList';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import PostCardGrid from './PostCardGrid';
|
import { PostCardProps, PostCardAction } from './types';
|
||||||
import { usePostCardFeatures } from './hooks/usePostCardFeatures';
|
import { ImageGridItem, SmartImage } from '../../common';
|
||||||
import { createLegacyAdapter, isLegacyProps } from './legacyAdapter';
|
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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PostCard 主组件
|
* PostCard 主组件
|
||||||
* 支持新旧两种 API
|
* 仅支持新 API
|
||||||
*/
|
*/
|
||||||
const PostCard: React.FC<PostCardProps | LegacyPostCardProps> = (props) => {
|
const PostCard: React.FC<PostCardProps> = (normalizedProps) => {
|
||||||
// 检测是否使用旧 API
|
|
||||||
const isLegacy = isLegacyProps(props as LegacyPostCardProps);
|
|
||||||
|
|
||||||
// 兼容层:转换旧 Props 到新 Props
|
|
||||||
const normalizedProps: PostCardProps = isLegacy
|
|
||||||
? createLegacyAdapter(props as LegacyPostCardProps)
|
|
||||||
: (props as PostCardProps);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
post,
|
post,
|
||||||
onAction,
|
onAction,
|
||||||
variant = 'list',
|
variant = 'list',
|
||||||
features: featuresConfig,
|
features,
|
||||||
isPostAuthor = false,
|
isPostAuthor = false,
|
||||||
isAuthor = false,
|
isAuthor = false,
|
||||||
index,
|
index,
|
||||||
style,
|
style,
|
||||||
} = normalizedProps;
|
} = normalizedProps;
|
||||||
|
|
||||||
// 解析 Features 配置
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const resolvedFeatures = usePostCardFeatures(featuresConfig);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
// 根据 variant 选择渲染模式
|
const isCompact =
|
||||||
if (variant === 'grid') {
|
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 (
|
return (
|
||||||
<PostCardGrid
|
<TouchableOpacity activeOpacity={0.9} onPress={() => handleImagePress(images, 0)}>
|
||||||
post={post}
|
<SmartImage
|
||||||
onAction={onAction}
|
source={{ uri: coverUrl || cover.url || '' }}
|
||||||
features={resolvedFeatures}
|
style={styles.gridCover}
|
||||||
style={style}
|
resizeMode="cover"
|
||||||
|
usePreview={true}
|
||||||
/>
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PostCardList
|
<PostImages
|
||||||
post={post}
|
images={post.images || []}
|
||||||
onAction={onAction}
|
displayMode="list"
|
||||||
features={resolvedFeatures}
|
onImagePress={handleImagePress}
|
||||||
isPostAuthor={isPostAuthor}
|
|
||||||
isAuthor={isAuthor}
|
|
||||||
index={index}
|
|
||||||
style={style}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderTopComment = () => {
|
||||||
|
if (showGrid || !post.top_comment) return null;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={styles.topCommentBox} activeOpacity={0.85} onPress={handleComment}>
|
||||||
|
<Text style={styles.topCommentAuthor} numberOfLines={1}>
|
||||||
|
{post.top_comment.author?.nickname || '匿名用户'}:
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.topCommentText} numberOfLines={1}>
|
||||||
|
{post.top_comment.content}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={handleCardPress}
|
||||||
|
style={[styles.gridRootCard, style]}
|
||||||
|
>
|
||||||
|
{hasImage ? (
|
||||||
|
<TouchableOpacity activeOpacity={0.9} onPress={() => handleImagePress(images, 0)}>
|
||||||
|
<SmartImage
|
||||||
|
source={{ uri: coverUrl || cover.url || '' }}
|
||||||
|
style={styles.gridCover}
|
||||||
|
resizeMode="cover"
|
||||||
|
usePreview={true}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<View style={styles.gridNoImagePreview}>
|
||||||
|
<Text style={styles.gridNoImageText} numberOfLines={6}>
|
||||||
|
{contentPreview}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{post.is_vote && (
|
||||||
|
<View style={styles.gridVoteTag}>
|
||||||
|
<MaterialCommunityIcons name="vote" size={11} color={colors.primary.contrast} />
|
||||||
|
<Text style={styles.gridVoteTagText}>投票</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!post.title && (
|
||||||
|
<Text style={styles.gridTitleMain} numberOfLines={hasImage ? 2 : 3}>
|
||||||
|
{post.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.gridFooter}>
|
||||||
|
<TouchableOpacity onPress={handleUserPress} style={styles.gridUserArea}>
|
||||||
|
<Avatar source={author?.avatar} size={20} name={author?.nickname || '匿名用户'} />
|
||||||
|
<Text style={styles.gridUsername} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={styles.gridLikeArea}>
|
||||||
|
<MaterialCommunityIcons name="heart-outline" size={14} color={colors.text.secondary} />
|
||||||
|
<Text style={styles.gridLikeCount}>{formatNumber(post.likes_count || 0)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
showGrid ? renderGridCard() : (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={handleCardPress}
|
||||||
|
style={[styles.card, style]}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={handleUserPress} activeOpacity={0.8} style={styles.authorWrap}>
|
||||||
|
<Avatar source={author?.avatar} size={showGrid ? 22 : 36} name={author?.nickname || '匿名用户'} />
|
||||||
|
<View style={styles.authorMeta}>
|
||||||
|
<Text style={styles.authorName} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
|
||||||
|
{!showGrid && (
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
<Text style={styles.timeText} numberOfLines={1}>
|
||||||
|
{post.created_at ? new Date(post.created_at).toLocaleDateString() : ''}
|
||||||
|
</Text>
|
||||||
|
{post.is_pinned && (
|
||||||
|
<View style={styles.pinnedTag}>
|
||||||
|
<MaterialCommunityIcons name="pin" size={10} color={colors.warning.main} />
|
||||||
|
<Text style={styles.pinnedText}>置顶</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{isAuthor && <Text style={styles.badgeText}>楼主</Text>}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{isPostAuthor && (
|
||||||
|
<TouchableOpacity onPress={handleDelete} hitSlop={8} style={styles.deleteButton} disabled={isDeleting}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={isDeleting ? 'loading' : 'delete-outline'}
|
||||||
|
size={isDesktop ? 20 : 18}
|
||||||
|
color={colors.text.hint}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!!post.title && (
|
||||||
|
<Text style={styles.title} numberOfLines={isCompact ? 2 : undefined}>
|
||||||
|
{post.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isCompact && !!content && (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
style={styles.content}
|
||||||
|
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Text>
|
||||||
|
{shouldTruncate && (
|
||||||
|
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
|
||||||
|
<Text style={styles.expandText}>{isExpanded ? '收起' : '展开全文'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderImages()}
|
||||||
|
{renderTopComment()}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<View style={styles.viewsWrap}>
|
||||||
|
<MaterialCommunityIcons name="eye-outline" size={16} color={colors.text.hint} />
|
||||||
|
<Text style={styles.viewsText}>{post.views_count || 0}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.actionButtons}>
|
||||||
|
<TouchableOpacity style={styles.actionBtn} onPress={handleLike}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={post.is_liked ? 'heart' : 'heart-outline'}
|
||||||
|
size={18}
|
||||||
|
color={post.is_liked ? colors.error.main : colors.text.secondary}
|
||||||
|
/>
|
||||||
|
<Text style={post.is_liked ? styles.activeActionTextMerged : styles.actionText}>
|
||||||
|
{post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.actionBtn} onPress={handleComment}>
|
||||||
|
<MaterialCommunityIcons name="comment-outline" size={18} color={colors.text.secondary} />
|
||||||
|
<Text style={styles.actionText}>{post.comments_count > 0 ? formatNumber(post.comments_count) : '评论'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.actionBtn} onPress={handleShare}>
|
||||||
|
<MaterialCommunityIcons name="share-outline" size={18} color={colors.text.secondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.actionBtn} onPress={handleBookmark}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={post.is_favorited ? 'bookmark' : 'bookmark-outline'}
|
||||||
|
size={18}
|
||||||
|
color={post.is_favorited ? colors.warning.main : colors.text.secondary}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
viewsWrap: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
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;
|
export default PostCard;
|
||||||
@@ -26,33 +26,5 @@ export type {
|
|||||||
PostGridInfoProps,
|
PostGridInfoProps,
|
||||||
PostCardListProps,
|
PostCardListProps,
|
||||||
PostCardGridProps,
|
PostCardGridProps,
|
||||||
LegacyPostCardProps,
|
|
||||||
Badge,
|
Badge,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// 预设配置导出
|
|
||||||
export { PostCardPresets, defaultFeatures, resolveFeatures } from './presets';
|
|
||||||
|
|
||||||
// Hooks 导出
|
|
||||||
export {
|
|
||||||
usePostCardActions,
|
|
||||||
usePostCardFeatures,
|
|
||||||
usePostCardStyles,
|
|
||||||
usePostGridStyles,
|
|
||||||
} from './hooks';
|
|
||||||
export type { PostCardStylesConfig, PostGridStylesConfig } from './hooks';
|
|
||||||
|
|
||||||
// 子组件导出
|
|
||||||
export {
|
|
||||||
PostHeader,
|
|
||||||
PostTitle,
|
|
||||||
PostContent,
|
|
||||||
PostImages,
|
|
||||||
PostActions,
|
|
||||||
PostTopComment,
|
|
||||||
PostGridCover,
|
|
||||||
PostGridInfo,
|
|
||||||
} from './components';
|
|
||||||
|
|
||||||
// 兼容层导出(用于迁移帮助)
|
|
||||||
export { isLegacyProps, createLegacyAdapter } from './legacyAdapter';
|
|
||||||
@@ -251,29 +251,6 @@ export interface PostCardGridProps {
|
|||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 旧版 Props(兼容层使用) ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 旧版 PostCard Props(用于兼容层)
|
|
||||||
* @deprecated 请迁移到新的 PostCardProps
|
|
||||||
*/
|
|
||||||
export interface LegacyPostCardProps {
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 工具类型 ====================
|
// ==================== 工具类型 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ export type {
|
|||||||
PostCardFeaturesPreset,
|
PostCardFeaturesPreset,
|
||||||
PostCardFeaturesConfig,
|
PostCardFeaturesConfig,
|
||||||
PostCardProps,
|
PostCardProps,
|
||||||
LegacyPostCardProps,
|
|
||||||
} from './PostCard';
|
} from './PostCard';
|
||||||
export { PostCardPresets, resolveFeatures } from './PostCard';
|
|
||||||
export { usePostCardActions, usePostCardFeatures } from './PostCard';
|
|
||||||
export { default as CommentItem } from './CommentItem';
|
export { default as CommentItem } from './CommentItem';
|
||||||
export { default as UserProfileHeader } from './UserProfileHeader';
|
export { default as UserProfileHeader } from './UserProfileHeader';
|
||||||
export { default as SystemMessageItem } from './SystemMessageItem';
|
export { default as SystemMessageItem } from './SystemMessageItem';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* 使用 expo-image,原生支持 GIF/WebP 动图
|
* 使用 expo-image,原生支持 GIF/WebP 动图
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
View,
|
View,
|
||||||
@@ -84,6 +84,8 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
onSave,
|
onSave,
|
||||||
backgroundOpacity = 1,
|
backgroundOpacity = 1,
|
||||||
}) => {
|
}) => {
|
||||||
|
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const isClosingRef = useRef(false);
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
const [showControls, setShowControls] = useState(true);
|
const [showControls, setShowControls] = useState(true);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -136,6 +138,20 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
}
|
}
|
||||||
}, [visible, initialIndex, resetZoom]);
|
}, [visible, initialIndex, resetZoom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
isClosingRef.current = false;
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (closeTimerRef.current) {
|
||||||
|
clearTimeout(closeTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 图片变化时重置加载状态和缩放
|
// 图片变化时重置加载状态和缩放
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -156,6 +172,18 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
setShowControls(prev => !prev);
|
setShowControls(prev => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const requestClose = useCallback(() => {
|
||||||
|
if (isClosingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isClosingRef.current = true;
|
||||||
|
// 延迟一个短时间窗口,避免关闭同一触摸触发到底层组件
|
||||||
|
closeTimerRef.current = setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 120);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
const goToPrev = useCallback(() => {
|
const goToPrev = useCallback(() => {
|
||||||
if (currentIndex > 0) {
|
if (currentIndex > 0) {
|
||||||
updateIndex(currentIndex - 1);
|
updateIndex(currentIndex - 1);
|
||||||
@@ -297,7 +325,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
.numberOfTaps(1)
|
.numberOfTaps(1)
|
||||||
.maxDistance(10)
|
.maxDistance(10)
|
||||||
.onEnd(() => {
|
.onEnd(() => {
|
||||||
runOnJS(onClose)();
|
runOnJS(requestClose)();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 组合手势:
|
// 组合手势:
|
||||||
@@ -326,7 +354,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
visible={visible}
|
visible={visible}
|
||||||
transparent
|
transparent
|
||||||
animationType="fade"
|
animationType="fade"
|
||||||
onRequestClose={onClose}
|
onRequestClose={requestClose}
|
||||||
statusBarTranslucent
|
statusBarTranslucent
|
||||||
>
|
>
|
||||||
<GestureHandlerRootView style={styles.root}>
|
<GestureHandlerRootView style={styles.root}>
|
||||||
@@ -334,7 +362,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
{/* 顶部控制栏 */}
|
{/* 顶部控制栏 */}
|
||||||
{showControls && (
|
{showControls && (
|
||||||
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
|
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
|
||||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
<TouchableOpacity style={styles.closeButton} onPress={requestClose}>
|
||||||
<MaterialCommunityIcons name="close" size={24} color="#FFF" />
|
<MaterialCommunityIcons name="close" size={24} color="#FFF" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface PostAuthor {
|
|||||||
username: string;
|
username: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
is_following?: boolean;
|
||||||
|
is_following_me?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,6 +135,8 @@ export const createPostAuthor = (data: Partial<PostAuthor>): PostAuthor => ({
|
|||||||
username: data.username || '',
|
username: data.username || '',
|
||||||
nickname: data.nickname,
|
nickname: data.nickname,
|
||||||
avatar: data.avatar,
|
avatar: data.avatar,
|
||||||
|
is_following: data.is_following,
|
||||||
|
is_following_me: data.is_following_me,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ interface PostApiResponse {
|
|||||||
username: string;
|
username: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
is_following?: boolean;
|
||||||
|
is_following_me?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +104,9 @@ export class PostRepository implements IPostRepository {
|
|||||||
username: model.author.username,
|
username: model.author.username,
|
||||||
nickname: model.author.nickname,
|
nickname: model.author.nickname,
|
||||||
avatar: model.author.avatar,
|
avatar: model.author.avatar,
|
||||||
|
// 关注关系直接透传 API 字段,供详情页关注按钮状态使用
|
||||||
|
is_following: response.author?.is_following,
|
||||||
|
is_following_me: response.author?.is_following_me,
|
||||||
} : undefined,
|
} : undefined,
|
||||||
title: model.title,
|
title: model.title,
|
||||||
content: model.content,
|
content: model.content,
|
||||||
@@ -286,20 +291,7 @@ export class PostRepository implements IPostRepository {
|
|||||||
*/
|
*/
|
||||||
async getPostById(id: string): Promise<Post | null> {
|
async getPostById(id: string): Promise<Post | null> {
|
||||||
try {
|
try {
|
||||||
// 1. 先查内存缓存
|
// 详情页优先取最新服务端数据,避免旧缓存导致关注态等关系字段过期
|
||||||
const memoryCached = this.getFromMemoryCache(id);
|
|
||||||
if (memoryCached) {
|
|
||||||
return memoryCached;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 查本地数据库缓存
|
|
||||||
const localCached = await this.getFromLocalCache(id);
|
|
||||||
if (localCached) {
|
|
||||||
this.saveToMemoryCache(localCached);
|
|
||||||
return localCached;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 从API获取
|
|
||||||
const response = await this.api.get<PostApiResponse>(`/posts/${id}`);
|
const response = await this.api.get<PostApiResponse>(`/posts/${id}`);
|
||||||
const post = this.mapToPost(response);
|
const post = this.mapToPost(response);
|
||||||
|
|
||||||
@@ -309,10 +301,16 @@ export class PostRepository implements IPostRepository {
|
|||||||
|
|
||||||
return post;
|
return post;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 如果API请求失败,尝试返回本地缓存
|
// API失败时再回退缓存,保证离线/弱网可用
|
||||||
|
const memoryCached = this.getFromMemoryCache(id);
|
||||||
|
if (memoryCached) {
|
||||||
|
console.warn('[PostRepository] API获取失败,使用内存缓存:', error);
|
||||||
|
return memoryCached;
|
||||||
|
}
|
||||||
const localCached = await this.getFromLocalCache(id);
|
const localCached = await this.getFromLocalCache(id);
|
||||||
if (localCached) {
|
if (localCached) {
|
||||||
console.warn('[PostRepository] API获取失败,使用本地缓存:', error);
|
console.warn('[PostRepository] API获取失败,使用本地缓存:', error);
|
||||||
|
this.saveToMemoryCache(localCached);
|
||||||
return localCached;
|
return localCached;
|
||||||
}
|
}
|
||||||
this.handleError(error, '获取帖子详情');
|
this.handleError(error, '获取帖子详情');
|
||||||
|
|||||||
@@ -152,7 +152,10 @@ function prefetchConversations(): void {
|
|||||||
key: 'conversations:list',
|
key: 'conversations:list',
|
||||||
executor: async () => {
|
executor: async () => {
|
||||||
await messageManager.initialize();
|
await messageManager.initialize();
|
||||||
await messageManager.fetchConversations(true);
|
await messageManager.requestConversationListRefresh('prefetch', {
|
||||||
|
force: true,
|
||||||
|
allowDefer: true,
|
||||||
|
});
|
||||||
return messageManager.getConversations();
|
return messageManager.getConversations();
|
||||||
},
|
},
|
||||||
priority: Priority.HIGH,
|
priority: Priority.HIGH,
|
||||||
|
|||||||
@@ -512,14 +512,7 @@ export const HomeScreen: React.FC = () => {
|
|||||||
<PostCard
|
<PostCard
|
||||||
post={post}
|
post={post}
|
||||||
variant="grid"
|
variant="grid"
|
||||||
onPress={() => handlePostPress(post.id)}
|
onAction={(action) => handlePostAction(post, action)}
|
||||||
onUserPress={() => authorId && handleUserPress(authorId)}
|
|
||||||
onLike={() => handleLike(post)}
|
|
||||||
onComment={() => handlePostPress(post.id, true)}
|
|
||||||
onBookmark={() => handleBookmark(post)}
|
|
||||||
onShare={() => handleShare(post)}
|
|
||||||
onImagePress={(images, index) => handleImagePress(images, index)}
|
|
||||||
onDelete={() => handleDeletePost(post.id)}
|
|
||||||
isPostAuthor={isPostAuthor}
|
isPostAuthor={isPostAuthor}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -579,15 +572,8 @@ export const HomeScreen: React.FC = () => {
|
|||||||
<PostCard
|
<PostCard
|
||||||
key={post.id}
|
key={post.id}
|
||||||
post={post}
|
post={post}
|
||||||
variant={viewMode === 'grid' ? 'grid' : 'default'}
|
variant={viewMode === 'grid' ? 'grid' : 'list'}
|
||||||
onPress={() => handlePostPress(post.id)}
|
onAction={(action) => handlePostAction(post, action)}
|
||||||
onUserPress={() => authorId && handleUserPress(authorId)}
|
|
||||||
onLike={() => handleLike(post)}
|
|
||||||
onComment={() => handlePostPress(post.id, true)}
|
|
||||||
onBookmark={() => handleBookmark(post)}
|
|
||||||
onShare={() => handleShare(post)}
|
|
||||||
onImagePress={(images, index) => handleImagePress(images, index)}
|
|
||||||
onDelete={() => handleDeletePost(post.id)}
|
|
||||||
isPostAuthor={isPostAuthor}
|
isPostAuthor={isPostAuthor}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -281,8 +281,25 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
if (!post?.author) return;
|
if (!post?.author) return;
|
||||||
|
|
||||||
const author = post.author;
|
const author = post.author;
|
||||||
|
const handleBackPress = () => {
|
||||||
|
if (navigation.canGoBack()) {
|
||||||
|
navigation.goBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigation.navigate('Main', {
|
||||||
|
screen: 'HomeTab',
|
||||||
|
params: { screen: 'Home', params: undefined },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
|
headerBackVisible: false,
|
||||||
|
headerTitleAlign: 'left',
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity onPress={handleBackPress} style={styles.headerBackButton} hitSlop={8}>
|
||||||
|
<MaterialCommunityIcons name="arrow-left" size={24} color={colors.text.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
headerTitle: () => (
|
headerTitle: () => (
|
||||||
<View style={styles.headerContainer}>
|
<View style={styles.headerContainer}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -298,7 +315,9 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
<View style={styles.headerUserInfo}>
|
<View style={styles.headerUserInfo}>
|
||||||
<TouchableOpacity onPress={() => handleUserPress(author.id)}>
|
<TouchableOpacity onPress={() => handleUserPress(author.id)}>
|
||||||
<Text style={styles.headerNickname} numberOfLines={1}>
|
<Text style={styles.headerNickname} numberOfLines={1}>
|
||||||
{author.nickname}
|
{author.nickname && author.nickname.length > 10
|
||||||
|
? `${author.nickname.slice(0, 10)}...`
|
||||||
|
: author.nickname}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -1701,7 +1720,7 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginLeft: -8,
|
marginLeft: spacing.sm,
|
||||||
},
|
},
|
||||||
headerAvatarWrapper: {
|
headerAvatarWrapper: {
|
||||||
marginRight: spacing.sm,
|
marginRight: spacing.sm,
|
||||||
@@ -1720,6 +1739,12 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginRight: spacing.sm,
|
marginRight: spacing.sm,
|
||||||
},
|
},
|
||||||
|
headerBackButton: {
|
||||||
|
paddingHorizontal: spacing.xs,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
marginRight: spacing.sm,
|
||||||
|
},
|
||||||
// 帖子容器
|
// 帖子容器
|
||||||
postContainer: {
|
postContainer: {
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.paper,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Post, User } from '../../types';
|
|||||||
import { useUserStore } from '../../stores';
|
import { useUserStore } from '../../stores';
|
||||||
import { postService, authService } from '../../services';
|
import { postService, authService } from '../../services';
|
||||||
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
||||||
|
import type { PostCardAction } from '../../components/business/PostCard';
|
||||||
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
|
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
|
||||||
import { HomeStackParamList } from '../../navigation/types';
|
import { HomeStackParamList } from '../../navigation/types';
|
||||||
import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks/useResponsive';
|
import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks/useResponsive';
|
||||||
@@ -175,6 +176,41 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
|
|||||||
navigation.navigate('UserProfile', { userId });
|
navigation.navigate('UserProfile', { userId });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 统一处理 PostCard 的操作(搜索页不支持删除)
|
||||||
|
const handlePostAction = (post: Post, action: PostCardAction) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'press':
|
||||||
|
handlePostPress(post.id);
|
||||||
|
break;
|
||||||
|
case 'userPress':
|
||||||
|
if (post.author?.id) {
|
||||||
|
handleUserPress(post.author.id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'like':
|
||||||
|
postService.likePost(post.id);
|
||||||
|
break;
|
||||||
|
case 'unlike':
|
||||||
|
postService.unlikePost(post.id);
|
||||||
|
break;
|
||||||
|
case 'comment':
|
||||||
|
handlePostPress(post.id, true);
|
||||||
|
break;
|
||||||
|
case 'bookmark':
|
||||||
|
postService.favoritePost(post.id);
|
||||||
|
break;
|
||||||
|
case 'unbookmark':
|
||||||
|
postService.unfavoritePost(post.id);
|
||||||
|
break;
|
||||||
|
case 'share':
|
||||||
|
// 搜索页暂不处理分享
|
||||||
|
break;
|
||||||
|
case 'imagePress':
|
||||||
|
case 'delete':
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 获取当前搜索类型
|
// 获取当前搜索类型
|
||||||
const getSearchType = (): SearchType => {
|
const getSearchType = (): SearchType => {
|
||||||
switch (activeIndex) {
|
switch (activeIndex) {
|
||||||
@@ -222,13 +258,9 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
|
|||||||
<PostCard
|
<PostCard
|
||||||
key={post.id}
|
key={post.id}
|
||||||
post={post}
|
post={post}
|
||||||
onPress={() => handlePostPress(post.id)}
|
onAction={(action) => handlePostAction(post, action)}
|
||||||
onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}}
|
variant="list"
|
||||||
onLike={() => {}}
|
features={isMobile ? 'compact' : 'full'}
|
||||||
onComment={() => handlePostPress(post.id, true)}
|
|
||||||
onBookmark={() => {}}
|
|
||||||
onShare={() => {}}
|
|
||||||
compact={isMobile}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ResponsiveGrid>
|
</ResponsiveGrid>
|
||||||
@@ -248,13 +280,9 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
|
|||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<PostCard
|
<PostCard
|
||||||
post={item}
|
post={item}
|
||||||
onPress={() => handlePostPress(item.id)}
|
onAction={(action) => handlePostAction(item, action)}
|
||||||
onUserPress={() => item.author ? handleUserPress(item.author.id) : () => {}}
|
variant="list"
|
||||||
onLike={() => {}}
|
features="compact"
|
||||||
onComment={() => handlePostPress(item.id, true)}
|
|
||||||
onBookmark={() => {}}
|
|
||||||
onShare={() => {}}
|
|
||||||
compact
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
keyExtractor={item => item.id}
|
keyExtractor={item => item.id}
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export const ChatScreen: React.FC = () => {
|
|||||||
// 输入框区域高度(用于定位浮动 mention 面板)
|
// 输入框区域高度(用于定位浮动 mention 面板)
|
||||||
const [inputWrapperHeight, setInputWrapperHeight] = useState(60);
|
const [inputWrapperHeight, setInputWrapperHeight] = useState(60);
|
||||||
const [showEdgeLoadingIndicator, setShowEdgeLoadingIndicator] = useState(false);
|
const [showEdgeLoadingIndicator, setShowEdgeLoadingIndicator] = useState(false);
|
||||||
|
const replyTargetMessageIdRef = useRef<string | null>(null);
|
||||||
|
const replyHighlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const isPreloadingRef = useRef(false);
|
const isPreloadingRef = useRef(false);
|
||||||
const lastScrollYRef = useRef(0);
|
const lastScrollYRef = useRef(0);
|
||||||
const isUserDraggingRef = useRef(false);
|
const isUserDraggingRef = useRef(false);
|
||||||
@@ -151,6 +153,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
longPressMenuVisible,
|
longPressMenuVisible,
|
||||||
selectedMessage,
|
selectedMessage,
|
||||||
selectedMessageId,
|
selectedMessageId,
|
||||||
|
setSelectedMessageId,
|
||||||
menuPosition,
|
menuPosition,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
groupInfo,
|
groupInfo,
|
||||||
@@ -206,6 +209,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
handleReachLatestEdge,
|
handleReachLatestEdge,
|
||||||
setBrowsingHistory,
|
setBrowsingHistory,
|
||||||
} = useChatScreen();
|
} = useChatScreen();
|
||||||
|
const displayMessages = useMemo(() => [...messages].reverse(), [messages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loadingMore && showEdgeLoadingIndicator) {
|
if (!loadingMore && showEdgeLoadingIndicator) {
|
||||||
@@ -218,14 +222,46 @@ export const ChatScreen: React.FC = () => {
|
|||||||
if (preloadCooldownTimerRef.current) {
|
if (preloadCooldownTimerRef.current) {
|
||||||
clearTimeout(preloadCooldownTimerRef.current);
|
clearTimeout(preloadCooldownTimerRef.current);
|
||||||
}
|
}
|
||||||
|
if (replyHighlightTimerRef.current) {
|
||||||
|
clearTimeout(replyHighlightTimerRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const highlightMessageTemporarily = useCallback((messageId: string, duration = 1500) => {
|
||||||
|
if (replyHighlightTimerRef.current) {
|
||||||
|
clearTimeout(replyHighlightTimerRef.current);
|
||||||
|
}
|
||||||
|
setSelectedMessageId(messageId);
|
||||||
|
replyHighlightTimerRef.current = setTimeout(() => {
|
||||||
|
setSelectedMessageId(prev => (prev === messageId ? null : prev));
|
||||||
|
}, duration);
|
||||||
|
}, [setSelectedMessageId]);
|
||||||
|
|
||||||
|
const handleReplyPreviewPress = useCallback((messageId: string) => {
|
||||||
|
const targetId = String(messageId);
|
||||||
|
const targetIndex = displayMessages.findIndex(msg => String(msg.id) === targetId);
|
||||||
|
if (targetIndex < 0) return;
|
||||||
|
|
||||||
|
replyTargetMessageIdRef.current = targetId;
|
||||||
|
setBrowsingHistory(true);
|
||||||
|
highlightMessageTemporarily(targetId);
|
||||||
|
|
||||||
|
flatListRef.current?.scrollToIndex({
|
||||||
|
index: targetIndex,
|
||||||
|
animated: true,
|
||||||
|
viewPosition: 0.5,
|
||||||
|
});
|
||||||
|
}, [displayMessages, flatListRef, highlightMessageTemporarily, setBrowsingHistory]);
|
||||||
|
|
||||||
// 监听返回事件,刷新会话列表
|
// 监听返回事件,刷新会话列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = navigation.addListener('beforeRemove', () => {
|
const unsubscribe = navigation.addListener('beforeRemove', () => {
|
||||||
// 刷新会话列表,确保已读状态正确显示
|
// 刷新会话列表,确保已读状态正确显示
|
||||||
messageManager.fetchConversations(true);
|
messageManager.requestConversationListRefresh('chat-before-remove', {
|
||||||
|
force: true,
|
||||||
|
allowDefer: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
@@ -261,6 +297,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
shouldShowTime={shouldShowTime}
|
shouldShowTime={shouldShowTime}
|
||||||
onImagePress={handleImagePress}
|
onImagePress={handleImagePress}
|
||||||
onReply={handleReplyMessage}
|
onReply={handleReplyMessage}
|
||||||
|
onReplyPress={handleReplyPreviewPress}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
@@ -280,14 +317,13 @@ export const ChatScreen: React.FC = () => {
|
|||||||
shouldShowTime,
|
shouldShowTime,
|
||||||
handleImagePress,
|
handleImagePress,
|
||||||
handleReplyMessage,
|
handleReplyMessage,
|
||||||
|
handleReplyPreviewPress,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: any) => String(item.id), []);
|
const keyExtractor = useCallback((item: any) => String(item.id), []);
|
||||||
|
|
||||||
// 获取正在输入提示
|
// 获取正在输入提示
|
||||||
const typingHint = getTypingHint();
|
const typingHint = getTypingHint();
|
||||||
const displayMessages = useMemo(() => [...messages].reverse(), [messages]);
|
|
||||||
|
|
||||||
const handleMessageListScroll = useCallback((event: any) => {
|
const handleMessageListScroll = useCallback((event: any) => {
|
||||||
const { contentSize, contentOffset, layoutMeasurement } = event.nativeEvent;
|
const { contentSize, contentOffset, layoutMeasurement } = event.nativeEvent;
|
||||||
scrollPositionRef.current = {
|
scrollPositionRef.current = {
|
||||||
@@ -410,6 +446,26 @@ export const ChatScreen: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
onContentSizeChange={handleContentSizeChange}
|
onContentSizeChange={handleContentSizeChange}
|
||||||
|
onScrollToIndexFailed={(info) => {
|
||||||
|
const targetId = replyTargetMessageIdRef.current;
|
||||||
|
if (!targetId || !flatListRef.current) return;
|
||||||
|
|
||||||
|
flatListRef.current.scrollToOffset({
|
||||||
|
offset: Math.max(0, info.averageItemLength * info.index),
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const retryIndex = displayMessages.findIndex(msg => String(msg.id) === targetId);
|
||||||
|
if (retryIndex >= 0) {
|
||||||
|
flatListRef.current?.scrollToIndex({
|
||||||
|
index: retryIndex,
|
||||||
|
animated: true,
|
||||||
|
viewPosition: 0.5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 120);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showEdgeLoadingIndicator && (
|
{showEdgeLoadingIndicator && (
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
shouldShowTime,
|
shouldShowTime,
|
||||||
onImagePress,
|
onImagePress,
|
||||||
onReply,
|
onReply,
|
||||||
|
onReplyPress,
|
||||||
}) => {
|
}) => {
|
||||||
const bubbleRef = useRef<View>(null);
|
const bubbleRef = useRef<View>(null);
|
||||||
const pressPositionRef = useRef({ x: 0, y: 0 });
|
const pressPositionRef = useRef({ x: 0, y: 0 });
|
||||||
@@ -306,6 +307,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
isMe ? styles.myBubble : styles.theirBubble,
|
isMe ? styles.myBubble : styles.theirBubble,
|
||||||
hasReply && segmentStyles.replyBubble,
|
hasReply && segmentStyles.replyBubble,
|
||||||
isPureImageMessage && segmentStyles.pureImageBubble,
|
isPureImageMessage && segmentStyles.pureImageBubble,
|
||||||
|
isSelected && (isMe ? styles.mySelectedBubble : styles.theirSelectedBubble),
|
||||||
]}>
|
]}>
|
||||||
<MessageSegmentsRenderer
|
<MessageSegmentsRenderer
|
||||||
segments={segments}
|
segments={segments}
|
||||||
@@ -315,7 +317,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
replyMessage={getReplyMessage()}
|
replyMessage={getReplyMessage()}
|
||||||
getSenderInfo={getSenderInfo}
|
getSenderInfo={getSenderInfo}
|
||||||
onAtPress={() => undefined}
|
onAtPress={() => undefined}
|
||||||
onReplyPress={() => undefined}
|
onReplyPress={onReplyPress}
|
||||||
onImagePress={(url) => {
|
onImagePress={(url) => {
|
||||||
// 查找点击的图片索引
|
// 查找点击的图片索引
|
||||||
const clickIndex = imageSegments.findIndex(img => img.url === url);
|
const clickIndex = imageSegments.findIndex(img => img.url === url);
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export interface MessageBubbleProps {
|
|||||||
onImagePress?: (images: { id: string; url: string; thumbnail_url?: string; width?: number; height?: number }[], index: number) => void;
|
onImagePress?: (images: { id: string; url: string; thumbnail_url?: string; width?: number; height?: number }[], index: number) => void;
|
||||||
// 滑动回复回调
|
// 滑动回复回调
|
||||||
onReply?: (message: GroupMessage) => void;
|
onReply?: (message: GroupMessage) => void;
|
||||||
|
// 点击回复预览回调(定位到原消息)
|
||||||
|
onReplyPress?: (messageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 输入框 Props
|
// 输入框 Props
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ export const useChatScreen = () => {
|
|||||||
const isProgrammaticScrollRef = useRef(false);
|
const isProgrammaticScrollRef = useRef(false);
|
||||||
const suppressAutoFollowRef = useRef(false);
|
const suppressAutoFollowRef = useRef(false);
|
||||||
const isBrowsingHistoryRef = useRef(false);
|
const isBrowsingHistoryRef = useRef(false);
|
||||||
|
const hasShownMessageListRef = useRef(false);
|
||||||
|
const historyLoadingLockUntilRef = useRef(0);
|
||||||
|
|
||||||
// 回复消息状态
|
// 回复消息状态
|
||||||
const [replyingTo, setReplyingTo] = useState<GroupMessage | null>(null);
|
const [replyingTo, setReplyingTo] = useState<GroupMessage | null>(null);
|
||||||
@@ -205,9 +207,16 @@ export const useChatScreen = () => {
|
|||||||
// 加载态语义修正:
|
// 加载态语义修正:
|
||||||
// isLoadingMessages 在分页加载历史时也会短暂为 true。
|
// isLoadingMessages 在分页加载历史时也会短暂为 true。
|
||||||
// 若直接驱动 ChatScreen 的 loading,会导致消息列表被卸载重挂载,触发“回到底部”。
|
// 若直接驱动 ChatScreen 的 loading,会导致消息列表被卸载重挂载,触发“回到底部”。
|
||||||
// 这里只在“首屏且尚无消息”时展示 loading 占位。
|
// 这里只在“首屏且尚未展示过消息列表”时展示 loading 占位。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(isLoadingMessages && messageManagerMessages.length === 0);
|
if (messageManagerMessages.length > 0) {
|
||||||
|
hasShownMessageListRef.current = true;
|
||||||
|
}
|
||||||
|
const shouldShowInitialLoading =
|
||||||
|
!hasShownMessageListRef.current &&
|
||||||
|
isLoadingMessages &&
|
||||||
|
messageManagerMessages.length === 0;
|
||||||
|
setLoading(shouldShowInitialLoading);
|
||||||
}, [isLoadingMessages, messageManagerMessages.length]);
|
}, [isLoadingMessages, messageManagerMessages.length]);
|
||||||
|
|
||||||
// 【改造】同步 hasMore 状态
|
// 【改造】同步 hasMore 状态
|
||||||
@@ -281,10 +290,16 @@ export const useChatScreen = () => {
|
|||||||
enterMarkedKeyRef.current = '';
|
enterMarkedKeyRef.current = '';
|
||||||
suppressAutoFollowRef.current = false;
|
suppressAutoFollowRef.current = false;
|
||||||
isBrowsingHistoryRef.current = false;
|
isBrowsingHistoryRef.current = false;
|
||||||
|
hasShownMessageListRef.current = false;
|
||||||
|
historyLoadingLockUntilRef.current = 0;
|
||||||
}, [conversationId]);
|
}, [conversationId]);
|
||||||
|
|
||||||
|
const isHistoryLoadingLocked = useCallback(() => {
|
||||||
|
return Date.now() < historyLoadingLockUntilRef.current;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => {
|
const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => {
|
||||||
if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current)) return;
|
if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked())) return;
|
||||||
// inverted 列表下,最新消息端对应 offset=0
|
// inverted 列表下,最新消息端对应 offset=0
|
||||||
isProgrammaticScrollRef.current = true;
|
isProgrammaticScrollRef.current = true;
|
||||||
flatListRef.current?.scrollToOffset({
|
flatListRef.current?.scrollToOffset({
|
||||||
@@ -294,7 +309,7 @@ export const useChatScreen = () => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isProgrammaticScrollRef.current = false;
|
isProgrammaticScrollRef.current = false;
|
||||||
}, animated ? 220 : 32);
|
}, animated ? 220 : 32);
|
||||||
}, [flatListRef, scrollPositionRef, messages.length]);
|
}, [flatListRef, isHistoryLoadingLocked]);
|
||||||
|
|
||||||
const isNearBottom = useCallback(() => {
|
const isNearBottom = useCallback(() => {
|
||||||
const scrollY = scrollPositionRef.current.scrollY || 0;
|
const scrollY = scrollPositionRef.current.scrollY || 0;
|
||||||
@@ -443,6 +458,8 @@ export const useChatScreen = () => {
|
|||||||
|
|
||||||
// 历史加载期间禁止“新消息自动跟随到底”
|
// 历史加载期间禁止“新消息自动跟随到底”
|
||||||
suppressAutoFollowRef.current = true;
|
suppressAutoFollowRef.current = true;
|
||||||
|
isBrowsingHistoryRef.current = true;
|
||||||
|
historyLoadingLockUntilRef.current = Date.now() + 3000;
|
||||||
|
|
||||||
// 保存加载前的滚动位置和内容高度
|
// 保存加载前的滚动位置和内容高度
|
||||||
const scrollYBefore = scrollPositionRef.current.scrollY;
|
const scrollYBefore = scrollPositionRef.current.scrollY;
|
||||||
@@ -464,6 +481,8 @@ export const useChatScreen = () => {
|
|||||||
console.error('加载历史消息失败:', error);
|
console.error('加载历史消息失败:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
|
// 加载结束后继续保留短暂锁窗,避免布局结算阶段误触自动回底
|
||||||
|
historyLoadingLockUntilRef.current = Date.now() + 800;
|
||||||
}
|
}
|
||||||
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]);
|
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]);
|
||||||
|
|
||||||
@@ -473,9 +492,10 @@ export const useChatScreen = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleReachLatestEdge = useCallback(() => {
|
const handleReachLatestEdge = useCallback(() => {
|
||||||
|
if (isHistoryLoadingLocked()) return;
|
||||||
suppressAutoFollowRef.current = false;
|
suppressAutoFollowRef.current = false;
|
||||||
isBrowsingHistoryRef.current = false;
|
isBrowsingHistoryRef.current = false;
|
||||||
}, []);
|
}, [isHistoryLoadingLocked]);
|
||||||
|
|
||||||
const setBrowsingHistory = useCallback((browsing: boolean) => {
|
const setBrowsingHistory = useCallback((browsing: boolean) => {
|
||||||
isBrowsingHistoryRef.current = browsing;
|
isBrowsingHistoryRef.current = browsing;
|
||||||
@@ -1221,6 +1241,7 @@ export const useChatScreen = () => {
|
|||||||
longPressMenuVisible,
|
longPressMenuVisible,
|
||||||
selectedMessage,
|
selectedMessage,
|
||||||
selectedMessageId,
|
selectedMessageId,
|
||||||
|
setSelectedMessageId,
|
||||||
menuPosition,
|
menuPosition,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
groupInfo,
|
groupInfo,
|
||||||
|
|||||||
@@ -1,523 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* 个人主页 ProfileScreen - 美化版(响应式适配)
|
* 个人主页 ProfileScreen
|
||||||
* 胡萝卜BBS - 当前用户个人主页
|
* 胡萝卜BBS - 当前用户个人主页
|
||||||
* 采用现代卡片式设计,优化视觉层次和交互体验
|
* 使用统一的 UserProfileScreen 组件,mode='self'
|
||||||
* 支持桌面端双栏布局
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback, useMemo } from 'react';
|
import React from 'react';
|
||||||
import {
|
|
||||||
View,
|
|
||||||
StyleSheet,
|
|
||||||
RefreshControl,
|
|
||||||
Animated,
|
|
||||||
ScrollView,
|
|
||||||
} from 'react-native';
|
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
import UserProfileScreen from './UserProfileScreen';
|
||||||
import { colors, spacing } from '../../theme';
|
import { ProfileStackParamList } from '../../navigation/types';
|
||||||
import { Post } from '../../types';
|
|
||||||
import { useAuthStore, useUserStore } from '../../stores';
|
|
||||||
import { postService } from '../../services';
|
|
||||||
import { UserProfileHeader, PostCard, TabBar } from '../../components/business';
|
|
||||||
import { PostCardAction } from '../../components/business/PostCard';
|
|
||||||
import { Loading, EmptyState, Text } from '../../components/common';
|
|
||||||
import { ResponsiveContainer } from '../../components/common';
|
|
||||||
import { useResponsive } from '../../hooks';
|
|
||||||
import { ProfileStackParamList, HomeStackParamList, RootStackParamList } from '../../navigation/types';
|
|
||||||
|
|
||||||
type NavigationProp = NativeStackNavigationProp<ProfileStackParamList, 'Profile'>;
|
type ProfileNavigationProp = NativeStackNavigationProp<ProfileStackParamList, 'Profile'>;
|
||||||
type HomeNavigationProp = NativeStackNavigationProp<HomeStackParamList>;
|
|
||||||
type RootNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
|
||||||
|
|
||||||
const TABS = ['帖子', '收藏'];
|
|
||||||
const TAB_ICONS = ['file-document-outline', 'bookmark-outline'];
|
|
||||||
|
|
||||||
export const ProfileScreen: React.FC = () => {
|
export const ProfileScreen: React.FC = () => {
|
||||||
const navigation = useNavigation<NavigationProp>();
|
const profileNavigation = useNavigation<ProfileNavigationProp>();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
// 在大屏幕模式下不使用 Bottom Tab Navigator,所以 try-catch 处理
|
|
||||||
let tabBarHeight = 0;
|
|
||||||
try {
|
|
||||||
tabBarHeight = useBottomTabBarHeight();
|
|
||||||
} catch (e) {
|
|
||||||
// 大屏幕模式下不在 Bottom Tab Navigator 中,会抛出错误,忽略即可
|
|
||||||
tabBarHeight = 0;
|
|
||||||
}
|
|
||||||
const homeNavigation = useNavigation<HomeNavigationProp>();
|
|
||||||
// 使用 any 类型来访问根导航
|
|
||||||
const rootNavigation = useNavigation<RootNavigationProp>();
|
|
||||||
const { currentUser, updateUser, fetchCurrentUser } = useAuthStore();
|
|
||||||
const { followUser, unfollowUser, posts: storePosts } = useUserStore();
|
|
||||||
|
|
||||||
// 响应式布局
|
return <UserProfileScreen mode="self" profileNavigation={profileNavigation} />;
|
||||||
const { isDesktop, isTablet, width } = useResponsive();
|
|
||||||
|
|
||||||
// 页面滚动底部安全间距,避免内容被底部 TabBar 遮挡
|
|
||||||
// TabBar 悬浮:高度 64 + 浮动间距 12*2 = 88
|
|
||||||
const scrollBottomInset = useMemo(() => {
|
|
||||||
if (isDesktop) return spacing.lg;
|
|
||||||
const FLOATING_TAB_BAR_HEIGHT = 64 + 24; // TabBar高度 + 上下浮动间距
|
|
||||||
return FLOATING_TAB_BAR_HEIGHT + insets.bottom + spacing.md;
|
|
||||||
}, [isDesktop, insets.bottom]);
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState(0);
|
|
||||||
const [posts, setPosts] = useState<Post[]>([]);
|
|
||||||
const [favorites, setFavorites] = useState<Post[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const scrollY = new Animated.Value(0);
|
|
||||||
|
|
||||||
// 跳转到关注列表
|
|
||||||
const handleFollowingPress = useCallback(() => {
|
|
||||||
if (currentUser) {
|
|
||||||
(rootNavigation as any).navigate('FollowList', { userId: currentUser.id, type: 'following' });
|
|
||||||
}
|
|
||||||
}, [currentUser, rootNavigation]);
|
|
||||||
|
|
||||||
// 跳转到粉丝列表
|
|
||||||
const handleFollowersPress = useCallback(() => {
|
|
||||||
if (currentUser) {
|
|
||||||
(rootNavigation as any).navigate('FollowList', { userId: currentUser.id, type: 'followers' });
|
|
||||||
}
|
|
||||||
}, [currentUser, rootNavigation]);
|
|
||||||
|
|
||||||
// 加载用户帖子
|
|
||||||
const loadUserPosts = useCallback(async () => {
|
|
||||||
if (currentUser) {
|
|
||||||
try {
|
|
||||||
const response = await postService.getUserPosts(currentUser.id);
|
|
||||||
setPosts(response.list);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取用户帖子失败:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}, [currentUser]);
|
|
||||||
|
|
||||||
// 加载用户收藏
|
|
||||||
const loadUserFavorites = useCallback(async () => {
|
|
||||||
if (currentUser) {
|
|
||||||
try {
|
|
||||||
const response = await postService.getUserFavorites(currentUser.id);
|
|
||||||
setFavorites(response.list);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取用户收藏失败:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}, [currentUser]);
|
|
||||||
|
|
||||||
// 监听 tab 切换,只在数据为空时加载对应数据
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (activeTab === 0 && posts.length === 0) {
|
|
||||||
loadUserPosts();
|
|
||||||
} else if (activeTab === 1 && favorites.length === 0) {
|
|
||||||
loadUserFavorites();
|
|
||||||
}
|
|
||||||
}, [activeTab, loadUserPosts, loadUserFavorites, posts.length, favorites.length]);
|
|
||||||
|
|
||||||
// 初始加载
|
|
||||||
React.useEffect(() => {
|
|
||||||
loadUserPosts();
|
|
||||||
}, [loadUserPosts]);
|
|
||||||
|
|
||||||
// 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新)
|
|
||||||
React.useEffect(() => {
|
|
||||||
// 同步帖子列表状态
|
|
||||||
if (posts.length > 0) {
|
|
||||||
let hasChanges = false;
|
|
||||||
const updatedPosts = posts.map(localPost => {
|
|
||||||
const storePost = storePosts.find(sp => sp.id === localPost.id);
|
|
||||||
if (storePost && (
|
|
||||||
storePost.is_liked !== localPost.is_liked ||
|
|
||||||
storePost.is_favorited !== localPost.is_favorited ||
|
|
||||||
storePost.likes_count !== localPost.likes_count ||
|
|
||||||
storePost.favorites_count !== localPost.favorites_count
|
|
||||||
)) {
|
|
||||||
hasChanges = true;
|
|
||||||
return {
|
|
||||||
...localPost,
|
|
||||||
is_liked: storePost.is_liked,
|
|
||||||
is_favorited: storePost.is_favorited,
|
|
||||||
likes_count: storePost.likes_count,
|
|
||||||
favorites_count: storePost.favorites_count,
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
return localPost;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasChanges) {
|
|
||||||
setPosts(updatedPosts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同步收藏列表状态
|
|
||||||
if (favorites.length > 0) {
|
|
||||||
let hasChanges = false;
|
|
||||||
const updatedFavorites = favorites.map(localPost => {
|
|
||||||
const storePost = storePosts.find(sp => sp.id === localPost.id);
|
|
||||||
if (storePost && (
|
|
||||||
storePost.is_liked !== localPost.is_liked ||
|
|
||||||
storePost.is_favorited !== localPost.is_favorited ||
|
|
||||||
storePost.likes_count !== localPost.likes_count ||
|
|
||||||
storePost.favorites_count !== localPost.favorites_count
|
|
||||||
)) {
|
|
||||||
hasChanges = true;
|
|
||||||
return {
|
|
||||||
...localPost,
|
|
||||||
is_liked: storePost.is_liked,
|
|
||||||
is_favorited: storePost.is_favorited,
|
|
||||||
likes_count: storePost.likes_count,
|
|
||||||
favorites_count: storePost.favorites_count,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return localPost;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasChanges) {
|
|
||||||
setFavorites(updatedFavorites);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [storePosts]);
|
|
||||||
|
|
||||||
// 下拉刷新
|
|
||||||
const onRefresh = useCallback(async () => {
|
|
||||||
setRefreshing(true);
|
|
||||||
try {
|
|
||||||
// 刷新用户信息
|
|
||||||
await fetchCurrentUser();
|
|
||||||
// 刷新帖子列表
|
|
||||||
await loadUserPosts();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('刷新失败:', error);
|
|
||||||
} finally {
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
}, [fetchCurrentUser, loadUserPosts]);
|
|
||||||
|
|
||||||
// 跳转到设置页
|
|
||||||
const handleSettings = useCallback(() => {
|
|
||||||
navigation.navigate('Settings');
|
|
||||||
}, [navigation]);
|
|
||||||
|
|
||||||
// 跳转到编辑资料页
|
|
||||||
const handleEditProfile = useCallback(() => {
|
|
||||||
navigation.navigate('EditProfile');
|
|
||||||
}, [navigation]);
|
|
||||||
|
|
||||||
// 关注/取消关注
|
|
||||||
const handleFollow = useCallback(() => {
|
|
||||||
if (!currentUser) return;
|
|
||||||
if (currentUser.is_following) {
|
|
||||||
unfollowUser(currentUser.id);
|
|
||||||
} else {
|
|
||||||
followUser(currentUser.id);
|
|
||||||
}
|
|
||||||
}, [currentUser, unfollowUser, followUser]);
|
|
||||||
|
|
||||||
// 跳转到帖子详情
|
|
||||||
const handlePostPress = useCallback((postId: string, scrollToComments: boolean = false) => {
|
|
||||||
homeNavigation.navigate('PostDetail', { postId, scrollToComments });
|
|
||||||
}, [homeNavigation]);
|
|
||||||
|
|
||||||
// 跳转到用户主页(当前用户)
|
|
||||||
const handleUserPress = useCallback((userId: string) => {
|
|
||||||
// 个人主页点击自己的头像不跳转
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 删除帖子
|
|
||||||
const handleDeletePost = useCallback(async (postId: string) => {
|
|
||||||
try {
|
|
||||||
const success = await postService.deletePost(postId);
|
|
||||||
if (success) {
|
|
||||||
// 从帖子列表中移除
|
|
||||||
setPosts(prev => prev.filter(p => p.id !== postId));
|
|
||||||
// 也从收藏列表中移除(如果存在)
|
|
||||||
setFavorites(prev => prev.filter(p => p.id !== postId));
|
|
||||||
} else {
|
|
||||||
console.error('删除帖子失败');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除帖子失败:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 统一的 PostCard Action 处理
|
|
||||||
const handlePostAction = useCallback((post: Post, action: PostCardAction) => {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'press':
|
|
||||||
handlePostPress(post.id);
|
|
||||||
break;
|
|
||||||
case 'userPress':
|
|
||||||
if (post.author) {
|
|
||||||
handleUserPress(post.author.id);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'like':
|
|
||||||
case 'unlike':
|
|
||||||
post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id);
|
|
||||||
break;
|
|
||||||
case 'comment':
|
|
||||||
handlePostPress(post.id, true);
|
|
||||||
break;
|
|
||||||
case 'bookmark':
|
|
||||||
case 'unbookmark':
|
|
||||||
post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id);
|
|
||||||
break;
|
|
||||||
case 'share':
|
|
||||||
// 暂不处理分享
|
|
||||||
break;
|
|
||||||
case 'delete':
|
|
||||||
handleDeletePost(post.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}, [handlePostPress, handleUserPress, handleDeletePost]);
|
|
||||||
|
|
||||||
// 渲染内容
|
|
||||||
const renderContent = useCallback(() => {
|
|
||||||
if (loading) return <Loading />;
|
|
||||||
|
|
||||||
if (activeTab === 0) {
|
|
||||||
// 帖子
|
|
||||||
if (posts.length === 0) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
title="还没有帖子"
|
|
||||||
description="分享你的想法,发布第一条帖子吧"
|
|
||||||
icon="file-document-edit-outline"
|
|
||||||
variant="modern"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.postsContainer}>
|
|
||||||
{posts.map((post, index) => {
|
|
||||||
const isPostAuthor = currentUser?.id === post.author?.id;
|
|
||||||
return (
|
|
||||||
<View key={post.id} style={[
|
|
||||||
styles.postWrapper,
|
|
||||||
index === posts.length - 1 && styles.lastPost,
|
|
||||||
]}>
|
|
||||||
<PostCard
|
|
||||||
post={post}
|
|
||||||
onAction={(action) => handlePostAction(post, action)}
|
|
||||||
isPostAuthor={isPostAuthor}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeTab === 1) {
|
|
||||||
// 收藏
|
|
||||||
if (favorites.length === 0) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
title="还没有收藏"
|
|
||||||
description="发现喜欢的内容,点击收藏按钮保存"
|
|
||||||
icon="bookmark-heart-outline"
|
|
||||||
variant="modern"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.postsContainer}>
|
|
||||||
{favorites.map((post, index) => {
|
|
||||||
const isPostAuthor = currentUser?.id === post.author?.id;
|
|
||||||
return (
|
|
||||||
<View key={post.id} style={[
|
|
||||||
styles.postWrapper,
|
|
||||||
index === favorites.length - 1 && styles.lastPost,
|
|
||||||
]}>
|
|
||||||
<PostCard
|
|
||||||
post={post}
|
|
||||||
onPress={() => handlePostPress(post.id)}
|
|
||||||
onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}}
|
|
||||||
onLike={() => post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id)}
|
|
||||||
onComment={() => handlePostPress(post.id, true)}
|
|
||||||
onBookmark={() => post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id)}
|
|
||||||
onShare={() => {}}
|
|
||||||
onDelete={() => handleDeletePost(post.id)}
|
|
||||||
isPostAuthor={isPostAuthor}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [loading, activeTab, posts, favorites, currentUser?.id, handlePostPress, handleUserPress, handleDeletePost]);
|
|
||||||
|
|
||||||
// 渲染用户信息头部 - 不随 tab 变化,使用 useMemo 缓存
|
|
||||||
const renderUserHeader = useMemo(() => (
|
|
||||||
<UserProfileHeader
|
|
||||||
user={currentUser!}
|
|
||||||
isCurrentUser={true}
|
|
||||||
onFollow={handleFollow}
|
|
||||||
onSettings={handleSettings}
|
|
||||||
onEditProfile={handleEditProfile}
|
|
||||||
onFollowingPress={handleFollowingPress}
|
|
||||||
onFollowersPress={handleFollowersPress}
|
|
||||||
/>
|
|
||||||
), [currentUser, handleFollow, handleSettings, handleEditProfile, handleFollowingPress, handleFollowersPress]);
|
|
||||||
|
|
||||||
// 渲染 TabBar 和内容
|
|
||||||
const renderTabBarAndContent = useMemo(() => (
|
|
||||||
<>
|
|
||||||
<View style={styles.tabBarContainer}>
|
|
||||||
<TabBar
|
|
||||||
tabs={TABS}
|
|
||||||
activeIndex={activeTab}
|
|
||||||
onTabChange={setActiveTab}
|
|
||||||
variant="modern"
|
|
||||||
icons={TAB_ICONS}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={styles.contentContainer}>
|
|
||||||
{renderContent()}
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
), [activeTab, renderContent]);
|
|
||||||
|
|
||||||
if (!currentUser) {
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
|
||||||
<EmptyState
|
|
||||||
title="未登录"
|
|
||||||
description="请先登录"
|
|
||||||
icon="account-off-outline"
|
|
||||||
/>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 桌面端使用双栏布局
|
|
||||||
if (isDesktop || isTablet) {
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
|
||||||
<ResponsiveContainer maxWidth={1400}>
|
|
||||||
<View style={styles.desktopContainer}>
|
|
||||||
{/* 左侧:用户信息 */}
|
|
||||||
<View style={styles.desktopSidebar}>
|
|
||||||
<ScrollView
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
contentContainerStyle={{ paddingBottom: scrollBottomInset }}
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl
|
|
||||||
refreshing={refreshing}
|
|
||||||
onRefresh={onRefresh}
|
|
||||||
colors={[colors.primary.main]}
|
|
||||||
tintColor={colors.primary.main}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{renderUserHeader}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 右侧:帖子列表 */}
|
|
||||||
<View style={styles.desktopContent}>
|
|
||||||
<ScrollView
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
contentContainerStyle={[styles.desktopScrollContent, { paddingBottom: scrollBottomInset }]}
|
|
||||||
>
|
|
||||||
{renderTabBarAndContent}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移动端使用单栏布局
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
|
||||||
<ScrollView
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl
|
|
||||||
refreshing={refreshing}
|
|
||||||
onRefresh={onRefresh}
|
|
||||||
colors={[colors.primary.main]}
|
|
||||||
tintColor={colors.primary.main}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
|
|
||||||
>
|
|
||||||
{/* 用户信息头部 - 固定在顶部,不受 tab 切换影响 */}
|
|
||||||
{renderUserHeader}
|
|
||||||
|
|
||||||
{/* TabBar - 分离出来,切换 tab 不会影响上面的用户信息 */}
|
|
||||||
{renderTabBarAndContent}
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
|
||||||
flexGrow: 1,
|
|
||||||
},
|
|
||||||
// 桌面端双栏布局
|
|
||||||
desktopContainer: {
|
|
||||||
flex: 1,
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: spacing.lg,
|
|
||||||
padding: spacing.lg,
|
|
||||||
},
|
|
||||||
desktopSidebar: {
|
|
||||||
width: 380,
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
desktopContent: {
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
},
|
|
||||||
desktopScrollContent: {
|
|
||||||
flexGrow: 1,
|
|
||||||
},
|
|
||||||
tabBarContainer: {
|
|
||||||
marginTop: spacing.xs,
|
|
||||||
marginBottom: 2,
|
|
||||||
},
|
|
||||||
contentContainer: {
|
|
||||||
flex: 1,
|
|
||||||
minHeight: 350,
|
|
||||||
paddingTop: spacing.xs,
|
|
||||||
},
|
|
||||||
postsContainer: {
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
paddingTop: spacing.sm,
|
|
||||||
},
|
|
||||||
postWrapper: {
|
|
||||||
marginBottom: spacing.md,
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
borderRadius: 16,
|
|
||||||
overflow: 'hidden',
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.06,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
lastPost: {
|
|
||||||
marginBottom: spacing['2xl'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default ProfileScreen;
|
export default ProfileScreen;
|
||||||
235
src/screens/profile/UserProfileScreen.tsx
Normal file
235
src/screens/profile/UserProfileScreen.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* 统一的用户主页组件
|
||||||
|
* 胡萝卜BBS - 支持两种模式:self(当前用户)和 other(其他用户)
|
||||||
|
* 采用现代卡片式设计,优化视觉层次和交互体验
|
||||||
|
* 支持桌面端双栏布局
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { Post } from '../../types';
|
||||||
|
import { PostCard, TabBar, UserProfileHeader } from '../../components/business';
|
||||||
|
import { Loading, EmptyState, ResponsiveContainer } from '../../components/common';
|
||||||
|
import { useResponsive } from '../../hooks';
|
||||||
|
import { ProfileStackParamList } from '../../navigation/types';
|
||||||
|
import { useUserProfile, ProfileMode, TABS, TAB_ICONS, sharedStyles } from './useUserProfile';
|
||||||
|
|
||||||
|
interface UserProfileScreenProps {
|
||||||
|
mode: ProfileMode;
|
||||||
|
userId?: string; // 仅 other 模式需要
|
||||||
|
profileNavigation?: NativeStackNavigationProp<ProfileStackParamList>; // 仅 self 模式需要
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, userId, profileNavigation }) => {
|
||||||
|
const { isDesktop, isTablet } = useResponsive();
|
||||||
|
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
posts,
|
||||||
|
favorites,
|
||||||
|
loading,
|
||||||
|
refreshing,
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
scrollBottomInset,
|
||||||
|
onRefresh,
|
||||||
|
handleFollow,
|
||||||
|
handlePostAction,
|
||||||
|
handleFollowingPress,
|
||||||
|
handleFollowersPress,
|
||||||
|
handleMessage,
|
||||||
|
handleMore,
|
||||||
|
handleSettings,
|
||||||
|
handleEditProfile,
|
||||||
|
isCurrentUser,
|
||||||
|
currentUser,
|
||||||
|
} = useUserProfile({ mode, userId, isDesktop, isTablet, profileNavigation });
|
||||||
|
|
||||||
|
// 渲染帖子列表
|
||||||
|
const renderPostList = useCallback((postList: Post[], emptyTitle: string, emptyDesc: string) => {
|
||||||
|
if (postList.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title={emptyTitle}
|
||||||
|
description={emptyDesc}
|
||||||
|
icon={activeTab === 0 ? 'file-document-edit-outline' : 'bookmark-heart-outline'}
|
||||||
|
variant="modern"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={sharedStyles.postsContainer}>
|
||||||
|
{postList.map((post, index) => {
|
||||||
|
const isPostAuthor = currentUser?.id === post.author?.id;
|
||||||
|
return (
|
||||||
|
<View key={post.id} style={[
|
||||||
|
sharedStyles.postWrapper,
|
||||||
|
index === postList.length - 1 && sharedStyles.lastPost,
|
||||||
|
]}>
|
||||||
|
<PostCard
|
||||||
|
post={post}
|
||||||
|
onAction={(action) => handlePostAction(post, action)}
|
||||||
|
isPostAuthor={isPostAuthor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [currentUser?.id, handlePostAction, activeTab]);
|
||||||
|
|
||||||
|
// 渲染内容
|
||||||
|
const renderContent = useCallback(() => {
|
||||||
|
if (loading) return <Loading />;
|
||||||
|
|
||||||
|
if (activeTab === 0) {
|
||||||
|
return renderPostList(
|
||||||
|
posts,
|
||||||
|
mode === 'self' ? '还没有帖子' : '这个用户还没有发布任何帖子',
|
||||||
|
mode === 'self' ? '分享你的想法,发布第一条帖子吧' : ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab === 1) {
|
||||||
|
return renderPostList(
|
||||||
|
favorites,
|
||||||
|
mode === 'self' ? '还没有收藏' : '这个用户还没有收藏任何帖子',
|
||||||
|
mode === 'self' ? '发现喜欢的内容,点击收藏按钮保存' : ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [loading, activeTab, posts, favorites, mode, renderPostList]);
|
||||||
|
|
||||||
|
// 渲染用户信息头部
|
||||||
|
const renderUserHeader = useMemo(() => {
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserProfileHeader
|
||||||
|
user={user}
|
||||||
|
isCurrentUser={isCurrentUser}
|
||||||
|
onFollow={handleFollow}
|
||||||
|
onSettings={handleSettings}
|
||||||
|
onEditProfile={handleEditProfile}
|
||||||
|
onMessage={handleMessage}
|
||||||
|
onMore={handleMore}
|
||||||
|
onFollowingPress={handleFollowingPress}
|
||||||
|
onFollowersPress={handleFollowersPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [user, isCurrentUser, handleFollow, handleSettings, handleEditProfile, handleMessage, handleMore, handleFollowingPress, handleFollowersPress]);
|
||||||
|
|
||||||
|
// 渲染 TabBar 和内容
|
||||||
|
const renderTabBarAndContent = useMemo(() => (
|
||||||
|
<>
|
||||||
|
<View style={sharedStyles.tabBarContainer}>
|
||||||
|
<TabBar
|
||||||
|
tabs={TABS}
|
||||||
|
activeIndex={activeTab}
|
||||||
|
onTabChange={setActiveTab}
|
||||||
|
variant="modern"
|
||||||
|
icons={TAB_ICONS}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={sharedStyles.contentContainer}>
|
||||||
|
{renderContent()}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
), [activeTab, renderContent]);
|
||||||
|
|
||||||
|
// 未登录/用户不存在状态
|
||||||
|
if (mode === 'self' && !currentUser) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={sharedStyles.container} edges={['top', 'bottom']}>
|
||||||
|
<EmptyState
|
||||||
|
title="未登录"
|
||||||
|
description="请先登录"
|
||||||
|
icon="account-off-outline"
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'other' && !user && !loading) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={sharedStyles.container} edges={['bottom']}>
|
||||||
|
<EmptyState
|
||||||
|
title="用户不存在"
|
||||||
|
description="该用户可能已被删除"
|
||||||
|
icon="account-off-outline"
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 桌面端使用双栏布局
|
||||||
|
if (isDesktop || isTablet) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={sharedStyles.container} edges={mode === 'self' ? ['top', 'bottom'] : ['bottom']}>
|
||||||
|
<ResponsiveContainer maxWidth={1400}>
|
||||||
|
<View style={sharedStyles.desktopContainer}>
|
||||||
|
{/* 左侧:用户信息 */}
|
||||||
|
<View style={sharedStyles.desktopSidebar}>
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingBottom: scrollBottomInset }}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
colors={[colors.primary.main]}
|
||||||
|
tintColor={colors.primary.main}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{renderUserHeader}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 右侧:帖子列表 */}
|
||||||
|
<View style={sharedStyles.desktopContent}>
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={[sharedStyles.desktopScrollContent, { paddingBottom: scrollBottomInset }]}
|
||||||
|
>
|
||||||
|
{renderTabBarAndContent}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端使用单栏布局
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={sharedStyles.container} edges={mode === 'self' ? ['top', 'bottom'] : ['bottom']}>
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
colors={[colors.primary.main]}
|
||||||
|
tintColor={colors.primary.main}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
contentContainerStyle={[sharedStyles.scrollContent, { paddingBottom: scrollBottomInset }]}
|
||||||
|
>
|
||||||
|
{renderUserHeader}
|
||||||
|
{renderTabBarAndContent}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserProfileScreen;
|
||||||
@@ -1,578 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* 用户主页 UserScreen(响应式适配)
|
* 用户主页 UserScreen
|
||||||
* 胡萝卜BBS - 查看其他用户资料
|
* 胡萝卜BBS - 查看其他用户资料
|
||||||
* 支持桌面端双栏布局
|
* 使用统一的 UserProfileScreen 组件,mode='other'
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React from 'react';
|
||||||
import {
|
import { useRoute, RouteProp } from '@react-navigation/native';
|
||||||
View,
|
import UserProfileScreen from './UserProfileScreen';
|
||||||
FlatList,
|
import { HomeStackParamList } from '../../navigation/types';
|
||||||
StyleSheet,
|
|
||||||
RefreshControl,
|
|
||||||
ScrollView,
|
|
||||||
Alert,
|
|
||||||
} from 'react-native';
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import { colors, spacing } from '../../theme';
|
|
||||||
import { Post, User } from '../../types';
|
|
||||||
import { useUserStore } from '../../stores';
|
|
||||||
import { useCurrentUser } from '../../stores/authStore';
|
import { useCurrentUser } from '../../stores/authStore';
|
||||||
import { authService, postService, messageService } from '../../services';
|
|
||||||
import { userManager } from '../../stores/userManager';
|
|
||||||
import { UserProfileHeader, PostCard, TabBar } from '../../components/business';
|
|
||||||
import { PostCardAction } from '../../components/business/PostCard';
|
|
||||||
import { Loading, EmptyState, ResponsiveContainer } from '../../components/common';
|
|
||||||
import { useResponsive } from '../../hooks';
|
|
||||||
import { HomeStackParamList, RootStackParamList } from '../../navigation/types';
|
|
||||||
|
|
||||||
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'UserProfile'>;
|
|
||||||
type UserRouteProp = RouteProp<HomeStackParamList, 'UserProfile'>;
|
type UserRouteProp = RouteProp<HomeStackParamList, 'UserProfile'>;
|
||||||
|
|
||||||
const TABS = ['帖子', '收藏'];
|
|
||||||
const TAB_ICONS = ['file-document-outline', 'bookmark-outline'];
|
|
||||||
|
|
||||||
export const UserScreen: React.FC = () => {
|
export const UserScreen: React.FC = () => {
|
||||||
const navigation = useNavigation<NavigationProp>();
|
|
||||||
const route = useRoute<UserRouteProp>();
|
const route = useRoute<UserRouteProp>();
|
||||||
const userId = route.params?.userId || '';
|
const userId = route.params?.userId || '';
|
||||||
|
|
||||||
// 使用 any 类型来访问根导航
|
|
||||||
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
|
||||||
|
|
||||||
const { followUser, unfollowUser, posts: storePosts } = useUserStore();
|
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
|
const isSelfProfile = !!currentUser?.id && currentUser.id === userId;
|
||||||
// 响应式布局
|
|
||||||
const { isDesktop, isTablet } = useResponsive();
|
|
||||||
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
const [posts, setPosts] = useState<Post[]>([]);
|
|
||||||
const [favorites, setFavorites] = useState<Post[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [activeTab, setActiveTab] = useState(0);
|
|
||||||
const [isBlocked, setIsBlocked] = useState(false);
|
|
||||||
|
|
||||||
// 加载用户信息
|
|
||||||
const loadUserData = useCallback(async (forceRefresh = false) => {
|
|
||||||
if (!userId) {
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 强制从服务器获取最新数据,确保关注状态是最新的
|
|
||||||
const userData = await userManager.getUserById(userId, forceRefresh);
|
|
||||||
setUser(userData || null);
|
|
||||||
const blockStatus = await authService.getBlockStatus(userId);
|
|
||||||
setIsBlocked(blockStatus);
|
|
||||||
|
|
||||||
const response = await postService.getUserPosts(userId);
|
|
||||||
setPosts(response.list);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载用户数据失败:', error);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}, [userId]);
|
|
||||||
|
|
||||||
// 加载用户收藏
|
|
||||||
const loadUserFavorites = useCallback(async () => {
|
|
||||||
if (!userId) return;
|
|
||||||
try {
|
|
||||||
const response = await postService.getUserFavorites(userId);
|
|
||||||
setFavorites(response.list);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取用户收藏失败:', error);
|
|
||||||
}
|
|
||||||
}, [userId]);
|
|
||||||
|
|
||||||
// 监听 tab 切换
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === 1) {
|
|
||||||
loadUserFavorites();
|
|
||||||
}
|
|
||||||
}, [activeTab, loadUserFavorites]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 首次加载时强制刷新,确保关注状态是最新的
|
|
||||||
loadUserData(true);
|
|
||||||
}, [loadUserData]);
|
|
||||||
|
|
||||||
// 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新)
|
|
||||||
useEffect(() => {
|
|
||||||
// 同步帖子列表状态
|
|
||||||
if (posts.length > 0) {
|
|
||||||
let hasChanges = false;
|
|
||||||
const updatedPosts = posts.map(localPost => {
|
|
||||||
const storePost = storePosts.find(sp => sp.id === localPost.id);
|
|
||||||
if (storePost && (
|
|
||||||
storePost.is_liked !== localPost.is_liked ||
|
|
||||||
storePost.is_favorited !== localPost.is_favorited ||
|
|
||||||
storePost.likes_count !== localPost.likes_count ||
|
|
||||||
storePost.favorites_count !== localPost.favorites_count
|
|
||||||
)) {
|
|
||||||
hasChanges = true;
|
|
||||||
return {
|
|
||||||
...localPost,
|
|
||||||
is_liked: storePost.is_liked,
|
|
||||||
is_favorited: storePost.is_favorited,
|
|
||||||
likes_count: storePost.likes_count,
|
|
||||||
favorites_count: storePost.favorites_count,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return localPost;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasChanges) {
|
|
||||||
setPosts(updatedPosts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同步收藏列表状态
|
|
||||||
if (favorites.length > 0) {
|
|
||||||
let hasChanges = false;
|
|
||||||
const updatedFavorites = favorites.map(localPost => {
|
|
||||||
const storePost = storePosts.find(sp => sp.id === localPost.id);
|
|
||||||
if (storePost && (
|
|
||||||
storePost.is_liked !== localPost.is_liked ||
|
|
||||||
storePost.is_favorited !== localPost.is_favorited ||
|
|
||||||
storePost.likes_count !== localPost.likes_count ||
|
|
||||||
storePost.favorites_count !== localPost.favorites_count
|
|
||||||
)) {
|
|
||||||
hasChanges = true;
|
|
||||||
return {
|
|
||||||
...localPost,
|
|
||||||
is_liked: storePost.is_liked,
|
|
||||||
is_favorited: storePost.is_favorited,
|
|
||||||
likes_count: storePost.likes_count,
|
|
||||||
favorites_count: storePost.favorites_count,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return localPost;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasChanges) {
|
|
||||||
setFavorites(updatedFavorites);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [storePosts]);
|
|
||||||
|
|
||||||
// 下拉刷新
|
|
||||||
const onRefresh = useCallback(() => {
|
|
||||||
setRefreshing(true);
|
|
||||||
loadUserData(true);
|
|
||||||
setRefreshing(false);
|
|
||||||
}, [loadUserData]);
|
|
||||||
|
|
||||||
// 关注/取消关注
|
|
||||||
const handleFollow = () => {
|
|
||||||
if (!user) return;
|
|
||||||
if (user.is_following) {
|
|
||||||
unfollowUser(user.id);
|
|
||||||
setUser({ ...user, is_following: false, followers_count: user.followers_count - 1 });
|
|
||||||
} else {
|
|
||||||
followUser(user.id);
|
|
||||||
setUser({ ...user, is_following: true, followers_count: user.followers_count + 1 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 跳转到关注列表
|
|
||||||
const handleFollowingPress = () => {
|
|
||||||
(rootNavigation as any).navigate('FollowList', { userId, type: 'following' });
|
|
||||||
};
|
|
||||||
|
|
||||||
// 跳转到粉丝列表
|
|
||||||
const handleFollowersPress = () => {
|
|
||||||
(rootNavigation as any).navigate('FollowList', { userId, type: 'followers' });
|
|
||||||
};
|
|
||||||
|
|
||||||
// 跳转到帖子详情
|
|
||||||
const handlePostPress = (postId: string, scrollToComments: boolean = false) => {
|
|
||||||
navigation.navigate('PostDetail', { postId, scrollToComments });
|
|
||||||
};
|
|
||||||
|
|
||||||
// 跳转到用户主页(这里不做处理)
|
|
||||||
const handleUserPress = (postUserId: string) => {
|
|
||||||
if (postUserId !== userId) {
|
|
||||||
navigation.push('UserProfile', { userId: postUserId });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 删除帖子
|
|
||||||
const handleDeletePost = async (postId: string) => {
|
|
||||||
try {
|
|
||||||
const success = await postService.deletePost(postId);
|
|
||||||
if (success) {
|
|
||||||
// 从帖子列表中移除
|
|
||||||
setPosts(prev => prev.filter(p => p.id !== postId));
|
|
||||||
// 也从收藏列表中移除(如果存在)
|
|
||||||
setFavorites(prev => prev.filter(p => p.id !== postId));
|
|
||||||
} else {
|
|
||||||
console.error('删除帖子失败');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除帖子失败:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 统一处理 PostCard 的所有操作
|
|
||||||
const handlePostAction = (post: Post, action: PostCardAction) => {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'press':
|
|
||||||
handlePostPress(post.id);
|
|
||||||
break;
|
|
||||||
case 'userPress':
|
|
||||||
if (post.author) handleUserPress(post.author.id);
|
|
||||||
break;
|
|
||||||
case 'like':
|
|
||||||
postService.likePost(post.id);
|
|
||||||
break;
|
|
||||||
case 'unlike':
|
|
||||||
postService.unlikePost(post.id);
|
|
||||||
break;
|
|
||||||
case 'comment':
|
|
||||||
handlePostPress(post.id, true);
|
|
||||||
break;
|
|
||||||
case 'bookmark':
|
|
||||||
postService.favoritePost(post.id);
|
|
||||||
break;
|
|
||||||
case 'unbookmark':
|
|
||||||
postService.unfavoritePost(post.id);
|
|
||||||
break;
|
|
||||||
case 'share':
|
|
||||||
// 暂不处理
|
|
||||||
break;
|
|
||||||
case 'delete':
|
|
||||||
handleDeletePost(post.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 跳转到聊天界面
|
|
||||||
const handleMessage = async () => {
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 前端只提供对方的用户ID,会话ID由后端生成
|
|
||||||
const conversation = await messageService.createConversation(user.id);
|
|
||||||
if (conversation) {
|
|
||||||
// 跳转到聊天界面 - 使用 rootNavigation 确保在正确的导航栈中
|
|
||||||
(rootNavigation as any).navigate('Chat', {
|
|
||||||
conversationId: conversation.id.toString(),
|
|
||||||
userId: user.id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('创建会话失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理更多按钮点击
|
|
||||||
const handleMore = () => {
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
Alert.alert(
|
|
||||||
'更多操作',
|
|
||||||
undefined,
|
|
||||||
[
|
|
||||||
{ text: '取消', style: 'cancel' },
|
|
||||||
{
|
|
||||||
text: isBlocked ? '取消拉黑' : '拉黑用户',
|
|
||||||
style: 'destructive',
|
|
||||||
onPress: () => {
|
|
||||||
Alert.alert(
|
|
||||||
isBlocked ? '确认取消拉黑' : '确认拉黑',
|
|
||||||
isBlocked
|
|
||||||
? '取消拉黑后,对方可以重新与你建立关系。'
|
|
||||||
: '拉黑后,对方将无法给你发送私聊消息,且会互相移除关注关系。',
|
|
||||||
[
|
|
||||||
{ text: '取消', style: 'cancel' },
|
|
||||||
{
|
|
||||||
text: '确定',
|
|
||||||
style: 'destructive',
|
|
||||||
onPress: async () => {
|
|
||||||
const ok = isBlocked
|
|
||||||
? await authService.unblockUser(user.id)
|
|
||||||
: await authService.blockUser(user.id);
|
|
||||||
if (!ok) {
|
|
||||||
Alert.alert('失败', isBlocked ? '取消拉黑失败,请稍后重试' : '拉黑失败,请稍后重试');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUser(prev =>
|
|
||||||
prev
|
|
||||||
? {
|
|
||||||
...prev,
|
|
||||||
is_following: false,
|
|
||||||
is_following_me: false,
|
|
||||||
}
|
|
||||||
: prev
|
|
||||||
);
|
|
||||||
setIsBlocked(!isBlocked);
|
|
||||||
Alert.alert('成功', isBlocked ? '已取消拉黑' : '已拉黑该用户');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 渲染内容
|
|
||||||
const renderContent = () => {
|
|
||||||
if (loading) return <Loading />;
|
|
||||||
|
|
||||||
if (activeTab === 0) {
|
|
||||||
// 帖子
|
|
||||||
if (posts.length === 0) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
title="还没有帖子"
|
|
||||||
description="这个用户还没有发布任何帖子"
|
|
||||||
icon="file-document-edit-outline"
|
|
||||||
variant="modern"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.postsContainer}>
|
<UserProfileScreen
|
||||||
{posts.map((post, index) => {
|
mode={isSelfProfile ? 'self' : 'other'}
|
||||||
const isPostAuthor = currentUser?.id === post.author?.id;
|
userId={isSelfProfile ? undefined : userId}
|
||||||
return (
|
|
||||||
<View key={post.id} style={[
|
|
||||||
styles.postWrapper,
|
|
||||||
index === posts.length - 1 && styles.lastPost,
|
|
||||||
]}>
|
|
||||||
<PostCard
|
|
||||||
post={post}
|
|
||||||
onAction={(action) => handlePostAction(post, action)}
|
|
||||||
isPostAuthor={isPostAuthor}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeTab === 1) {
|
|
||||||
// 收藏
|
|
||||||
if (favorites.length === 0) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
title="还没有收藏"
|
|
||||||
description="这个用户还没有收藏任何帖子"
|
|
||||||
icon="bookmark-heart-outline"
|
|
||||||
variant="modern"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.postsContainer}>
|
|
||||||
{favorites.map((post, index) => {
|
|
||||||
const isPostAuthor = currentUser?.id === post.author?.id;
|
|
||||||
return (
|
|
||||||
<View key={post.id} style={[
|
|
||||||
styles.postWrapper,
|
|
||||||
index === favorites.length - 1 && styles.lastPost,
|
|
||||||
]}>
|
|
||||||
<PostCard
|
|
||||||
post={post}
|
|
||||||
onAction={(action) => handlePostAction(post, action)}
|
|
||||||
isPostAuthor={isPostAuthor}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 渲染用户信息头部
|
|
||||||
const renderUserHeader = () => {
|
|
||||||
if (!user) return null;
|
|
||||||
return (
|
|
||||||
<UserProfileHeader
|
|
||||||
user={user}
|
|
||||||
isCurrentUser={false}
|
|
||||||
onFollow={handleFollow}
|
|
||||||
onMessage={handleMessage}
|
|
||||||
onMore={handleMore}
|
|
||||||
onFollowingPress={handleFollowingPress}
|
|
||||||
onFollowersPress={handleFollowersPress}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染 TabBar 和内容
|
|
||||||
const renderTabBarAndContent = () => (
|
|
||||||
<>
|
|
||||||
<View style={styles.tabBarContainer}>
|
|
||||||
<TabBar
|
|
||||||
tabs={TABS}
|
|
||||||
activeIndex={activeTab}
|
|
||||||
onTabChange={setActiveTab}
|
|
||||||
variant="modern"
|
|
||||||
icons={TAB_ICONS}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={styles.contentContainer}>
|
|
||||||
{renderContent()}
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <Loading fullScreen />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
|
||||||
<EmptyState
|
|
||||||
title="用户不存在"
|
|
||||||
description="该用户可能已被删除"
|
|
||||||
icon="account-off-outline"
|
|
||||||
/>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 桌面端使用双栏布局
|
|
||||||
if (isDesktop || isTablet) {
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
|
||||||
<ResponsiveContainer maxWidth={1400}>
|
|
||||||
<View style={styles.desktopContainer}>
|
|
||||||
{/* 左侧:用户信息 */}
|
|
||||||
<View style={styles.desktopSidebar}>
|
|
||||||
<ScrollView
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl
|
|
||||||
refreshing={refreshing}
|
|
||||||
onRefresh={onRefresh}
|
|
||||||
colors={[colors.primary.main]}
|
|
||||||
tintColor={colors.primary.main}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{renderUserHeader()}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 右侧:帖子列表 */}
|
|
||||||
<View style={styles.desktopContent}>
|
|
||||||
<ScrollView
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
contentContainerStyle={styles.desktopScrollContent}
|
|
||||||
>
|
|
||||||
{renderTabBarAndContent()}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移动端使用单栏布局
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
|
||||||
<FlatList
|
|
||||||
data={[{ key: 'header' }]}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<View>
|
|
||||||
{renderUserHeader()}
|
|
||||||
<View style={styles.tabBarContainer}>
|
|
||||||
<TabBar
|
|
||||||
tabs={TABS}
|
|
||||||
activeIndex={activeTab}
|
|
||||||
onTabChange={setActiveTab}
|
|
||||||
variant="modern"
|
|
||||||
icons={TAB_ICONS}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={styles.contentContainer}>
|
|
||||||
{renderContent()}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
keyExtractor={item => item.key}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl
|
|
||||||
refreshing={refreshing}
|
|
||||||
onRefresh={onRefresh}
|
|
||||||
colors={[colors.primary.main]}
|
|
||||||
tintColor={colors.primary.main}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
},
|
|
||||||
// 桌面端双栏布局
|
|
||||||
desktopContainer: {
|
|
||||||
flex: 1,
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: spacing.lg,
|
|
||||||
padding: spacing.lg,
|
|
||||||
},
|
|
||||||
desktopSidebar: {
|
|
||||||
width: 380,
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
desktopContent: {
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
},
|
|
||||||
desktopScrollContent: {
|
|
||||||
flexGrow: 1,
|
|
||||||
},
|
|
||||||
tabBarContainer: {
|
|
||||||
marginTop: spacing.sm,
|
|
||||||
marginBottom: spacing.xs,
|
|
||||||
},
|
|
||||||
contentContainer: {
|
|
||||||
flex: 1,
|
|
||||||
minHeight: 350,
|
|
||||||
paddingTop: spacing.sm,
|
|
||||||
},
|
|
||||||
postsContainer: {
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
paddingTop: spacing.sm,
|
|
||||||
},
|
|
||||||
postWrapper: {
|
|
||||||
marginBottom: spacing.md,
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
borderRadius: 16,
|
|
||||||
overflow: 'hidden',
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.06,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
lastPost: {
|
|
||||||
marginBottom: spacing['2xl'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default UserScreen;
|
export default UserScreen;
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
* 个人中心模块导出
|
* 个人中心模块导出
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// 统一的用户主页组件
|
||||||
|
export { UserProfileScreen } from './UserProfileScreen';
|
||||||
|
|
||||||
|
// 兼容旧组件(内部使用 UserProfileScreen)
|
||||||
export { ProfileScreen } from './ProfileScreen';
|
export { ProfileScreen } from './ProfileScreen';
|
||||||
export { SettingsScreen } from './SettingsScreen';
|
export { SettingsScreen } from './SettingsScreen';
|
||||||
export { EditProfileScreen } from './EditProfileScreen';
|
export { EditProfileScreen } from './EditProfileScreen';
|
||||||
@@ -10,3 +14,7 @@ export { default as FollowListScreen } from './FollowListScreen';
|
|||||||
export { NotificationSettingsScreen } from './NotificationSettingsScreen';
|
export { NotificationSettingsScreen } from './NotificationSettingsScreen';
|
||||||
export { BlockedUsersScreen } from './BlockedUsersScreen';
|
export { BlockedUsersScreen } from './BlockedUsersScreen';
|
||||||
export { AccountSecurityScreen } from './AccountSecurityScreen';
|
export { AccountSecurityScreen } from './AccountSecurityScreen';
|
||||||
|
|
||||||
|
// 导出 Hook 供需要自定义的场景使用
|
||||||
|
export { useUserProfile, sharedStyles, TABS, TAB_ICONS } from './useUserProfile';
|
||||||
|
export type { ProfileMode, UseUserProfileOptions, UseUserProfileReturn } from './useUserProfile';
|
||||||
|
|||||||
504
src/screens/profile/useUserProfile.ts
Normal file
504
src/screens/profile/useUserProfile.ts
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
/**
|
||||||
|
* 用户主页共享逻辑 Hook
|
||||||
|
* 提取 ProfileScreen 和 UserScreen 的共同逻辑
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { colors, spacing } from '../../theme';
|
||||||
|
import { Post, User } from '../../types';
|
||||||
|
import { useUserStore } from '../../stores';
|
||||||
|
import { useCurrentUser } from '../../stores/authStore';
|
||||||
|
import { postService, authService, messageService } from '../../services';
|
||||||
|
import { userManager } from '../../stores/userManager';
|
||||||
|
import { HomeStackParamList, RootStackParamList, ProfileStackParamList } from '../../navigation/types';
|
||||||
|
import { PostCardAction } from '../../components/business/PostCard';
|
||||||
|
|
||||||
|
export type ProfileMode = 'self' | 'other';
|
||||||
|
|
||||||
|
export interface UseUserProfileOptions {
|
||||||
|
mode: ProfileMode;
|
||||||
|
userId?: string; // 仅 other 模式需要
|
||||||
|
isDesktop?: boolean;
|
||||||
|
isTablet?: boolean;
|
||||||
|
profileNavigation?: NativeStackNavigationProp<ProfileStackParamList>; // 仅 self 模式需要
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseUserProfileReturn {
|
||||||
|
// 用户数据
|
||||||
|
user: User | null;
|
||||||
|
posts: Post[];
|
||||||
|
favorites: Post[];
|
||||||
|
loading: boolean;
|
||||||
|
refreshing: boolean;
|
||||||
|
isBlocked: boolean;
|
||||||
|
|
||||||
|
// Tab 相关
|
||||||
|
activeTab: number;
|
||||||
|
setActiveTab: (tab: number) => void;
|
||||||
|
|
||||||
|
// 布局相关
|
||||||
|
scrollBottomInset: number;
|
||||||
|
|
||||||
|
// 操作
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
handleFollow: () => void;
|
||||||
|
handlePostPress: (postId: string, scrollToComments?: boolean) => void;
|
||||||
|
handleUserPress: (postUserId: string) => void;
|
||||||
|
handleDeletePost: (postId: string) => Promise<void>;
|
||||||
|
handleFollowingPress: () => void;
|
||||||
|
handleFollowersPress: () => void;
|
||||||
|
handlePostAction: (post: Post, action: PostCardAction) => void;
|
||||||
|
|
||||||
|
// 仅 other 模式
|
||||||
|
handleMessage?: () => Promise<void>;
|
||||||
|
handleMore?: () => void;
|
||||||
|
|
||||||
|
// 仅 self 模式
|
||||||
|
handleSettings?: () => void;
|
||||||
|
handleEditProfile?: () => void;
|
||||||
|
|
||||||
|
// 判断
|
||||||
|
isCurrentUser: boolean;
|
||||||
|
currentUser: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUserProfile = (options: UseUserProfileOptions): UseUserProfileReturn => {
|
||||||
|
const { mode, userId, isDesktop = false, profileNavigation } = options;
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const homeNavigation = useNavigation<NativeStackNavigationProp<HomeStackParamList>>();
|
||||||
|
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
|
|
||||||
|
const { followUser, unfollowUser, posts: storePosts } = useUserStore();
|
||||||
|
const currentUser = useCurrentUser();
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
|
const [favorites, setFavorites] = useState<Post[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
const [isBlocked, setIsBlocked] = useState(false);
|
||||||
|
|
||||||
|
// 获取其他用户数据(other 模式)
|
||||||
|
const loadOtherUserData = useCallback(async (forceRefresh = false) => {
|
||||||
|
if (mode !== 'other' || !userId) {
|
||||||
|
setLoading(false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userData = await userManager.getUserById(userId, forceRefresh);
|
||||||
|
setUser(userData || null);
|
||||||
|
const blockStatus = await authService.getBlockStatus(userId);
|
||||||
|
setIsBlocked(blockStatus);
|
||||||
|
|
||||||
|
const response = await postService.getUserPosts(userId);
|
||||||
|
setPosts(response.list);
|
||||||
|
|
||||||
|
return userData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载用户数据失败:', error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [mode, userId]);
|
||||||
|
|
||||||
|
// 加载当前用户的帖子(self 模式)
|
||||||
|
const loadSelfPosts = useCallback(async () => {
|
||||||
|
if (mode !== 'self' || !currentUser) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await postService.getUserPosts(currentUser.id);
|
||||||
|
setPosts(response.list);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取用户帖子失败:', error);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [mode, currentUser]);
|
||||||
|
|
||||||
|
// 加载收藏
|
||||||
|
const loadFavorites = useCallback(async () => {
|
||||||
|
const targetUserId = mode === 'self' ? currentUser?.id : userId;
|
||||||
|
if (!targetUserId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await postService.getUserFavorites(targetUserId);
|
||||||
|
setFavorites(response.list);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取用户收藏失败:', error);
|
||||||
|
}
|
||||||
|
}, [mode, currentUser?.id, userId]);
|
||||||
|
|
||||||
|
// 初始化加载
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'self') {
|
||||||
|
loadSelfPosts();
|
||||||
|
} else {
|
||||||
|
loadOtherUserData(true);
|
||||||
|
}
|
||||||
|
}, [mode, loadSelfPosts, loadOtherUserData]);
|
||||||
|
|
||||||
|
// Tab 切换加载收藏
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 1 && favorites.length === 0) {
|
||||||
|
loadFavorites();
|
||||||
|
}
|
||||||
|
}, [activeTab, favorites.length, loadFavorites]);
|
||||||
|
|
||||||
|
// 同步 store 中的帖子状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (posts.length > 0) {
|
||||||
|
let hasChanges = false;
|
||||||
|
const updatedPosts = posts.map(localPost => {
|
||||||
|
const storePost = storePosts.find(sp => sp.id === localPost.id);
|
||||||
|
if (storePost && (
|
||||||
|
storePost.is_liked !== localPost.is_liked ||
|
||||||
|
storePost.is_favorited !== localPost.is_favorited ||
|
||||||
|
storePost.likes_count !== localPost.likes_count ||
|
||||||
|
storePost.favorites_count !== localPost.favorites_count
|
||||||
|
)) {
|
||||||
|
hasChanges = true;
|
||||||
|
return {
|
||||||
|
...localPost,
|
||||||
|
is_liked: storePost.is_liked,
|
||||||
|
is_favorited: storePost.is_favorited,
|
||||||
|
likes_count: storePost.likes_count,
|
||||||
|
favorites_count: storePost.favorites_count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return localPost;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
setPosts(updatedPosts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (favorites.length > 0) {
|
||||||
|
let hasChanges = false;
|
||||||
|
const updatedFavorites = favorites.map(localPost => {
|
||||||
|
const storePost = storePosts.find(sp => sp.id === localPost.id);
|
||||||
|
if (storePost && (
|
||||||
|
storePost.is_liked !== localPost.is_liked ||
|
||||||
|
storePost.is_favorited !== localPost.is_favorited ||
|
||||||
|
storePost.likes_count !== localPost.likes_count ||
|
||||||
|
storePost.favorites_count !== localPost.favorites_count
|
||||||
|
)) {
|
||||||
|
hasChanges = true;
|
||||||
|
return {
|
||||||
|
...localPost,
|
||||||
|
is_liked: storePost.is_liked,
|
||||||
|
is_favorited: storePost.is_favorited,
|
||||||
|
likes_count: storePost.likes_count,
|
||||||
|
favorites_count: storePost.favorites_count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return localPost;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
setFavorites(updatedFavorites);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [storePosts]);
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
const onRefresh = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
if (mode === 'self') {
|
||||||
|
const { useAuthStore } = await import('../../stores');
|
||||||
|
const { fetchCurrentUser } = useAuthStore.getState();
|
||||||
|
await fetchCurrentUser();
|
||||||
|
await loadSelfPosts();
|
||||||
|
} else {
|
||||||
|
await loadOtherUserData(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刷新失败:', error);
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [mode, loadSelfPosts, loadOtherUserData]);
|
||||||
|
|
||||||
|
// 关注/取消关注
|
||||||
|
const handleFollow = useCallback(() => {
|
||||||
|
const targetUser = mode === 'self' ? currentUser : user;
|
||||||
|
if (!targetUser) return;
|
||||||
|
|
||||||
|
if (targetUser.is_following) {
|
||||||
|
unfollowUser(targetUser.id);
|
||||||
|
if (mode === 'other') {
|
||||||
|
setUser({ ...targetUser, is_following: false, followers_count: targetUser.followers_count - 1 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
followUser(targetUser.id);
|
||||||
|
if (mode === 'other') {
|
||||||
|
setUser({ ...targetUser, is_following: true, followers_count: targetUser.followers_count + 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [mode, currentUser, user, unfollowUser, followUser]);
|
||||||
|
|
||||||
|
// 跳转到关注列表
|
||||||
|
const handleFollowingPress = useCallback(() => {
|
||||||
|
const targetUserId = mode === 'self' ? currentUser?.id : userId;
|
||||||
|
if (targetUserId) {
|
||||||
|
(rootNavigation as any).navigate('FollowList', { userId: targetUserId, type: 'following' });
|
||||||
|
}
|
||||||
|
}, [mode, currentUser?.id, userId, rootNavigation]);
|
||||||
|
|
||||||
|
// 跳转到粉丝列表
|
||||||
|
const handleFollowersPress = useCallback(() => {
|
||||||
|
const targetUserId = mode === 'self' ? currentUser?.id : userId;
|
||||||
|
if (targetUserId) {
|
||||||
|
(rootNavigation as any).navigate('FollowList', { userId: targetUserId, type: 'followers' });
|
||||||
|
}
|
||||||
|
}, [mode, currentUser?.id, userId, rootNavigation]);
|
||||||
|
|
||||||
|
// 跳转到帖子详情
|
||||||
|
const handlePostPress = useCallback((postId: string, scrollToComments: boolean = false) => {
|
||||||
|
homeNavigation.navigate('PostDetail', { postId, scrollToComments });
|
||||||
|
}, [homeNavigation]);
|
||||||
|
|
||||||
|
// 跳转到用户主页
|
||||||
|
const handleUserPress = useCallback((postUserId: string) => {
|
||||||
|
if (mode === 'self') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (postUserId !== userId) {
|
||||||
|
homeNavigation.push('UserProfile', { userId: postUserId });
|
||||||
|
}
|
||||||
|
}, [mode, userId, homeNavigation]);
|
||||||
|
|
||||||
|
// 删除帖子
|
||||||
|
const handleDeletePost = useCallback(async (postId: string) => {
|
||||||
|
try {
|
||||||
|
const success = await postService.deletePost(postId);
|
||||||
|
if (success) {
|
||||||
|
setPosts(prev => prev.filter(p => p.id !== postId));
|
||||||
|
setFavorites(prev => prev.filter(p => p.id !== postId));
|
||||||
|
} else {
|
||||||
|
console.error('删除帖子失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除帖子失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 统一的 PostCard Action 处理
|
||||||
|
const handlePostAction = useCallback((post: Post, action: PostCardAction) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'press':
|
||||||
|
handlePostPress(post.id);
|
||||||
|
break;
|
||||||
|
case 'userPress':
|
||||||
|
if (post.author) {
|
||||||
|
handleUserPress(post.author.id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'like':
|
||||||
|
case 'unlike':
|
||||||
|
post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id);
|
||||||
|
break;
|
||||||
|
case 'comment':
|
||||||
|
handlePostPress(post.id, true);
|
||||||
|
break;
|
||||||
|
case 'bookmark':
|
||||||
|
case 'unbookmark':
|
||||||
|
post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id);
|
||||||
|
break;
|
||||||
|
case 'share':
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
handleDeletePost(post.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [handlePostPress, handleUserPress, handleDeletePost]);
|
||||||
|
|
||||||
|
// 发消息(仅 other 模式)
|
||||||
|
const handleMessage = useCallback(async () => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const conversation = await messageService.createConversation(user.id);
|
||||||
|
if (conversation) {
|
||||||
|
(rootNavigation as any).navigate('Chat', {
|
||||||
|
conversationId: conversation.id.toString(),
|
||||||
|
userId: user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建会话失败:', error);
|
||||||
|
}
|
||||||
|
}, [user, rootNavigation]);
|
||||||
|
|
||||||
|
// 更多操作(仅 other 模式)
|
||||||
|
const handleMore = useCallback(() => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const { Alert } = require('react-native');
|
||||||
|
Alert.alert(
|
||||||
|
'更多操作',
|
||||||
|
undefined,
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: isBlocked ? '取消拉黑' : '拉黑用户',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
Alert.alert(
|
||||||
|
isBlocked ? '确认取消拉黑' : '确认拉黑',
|
||||||
|
isBlocked
|
||||||
|
? '取消拉黑后,对方可以重新与你建立关系。'
|
||||||
|
: '拉黑后,对方将无法给你发送私聊消息,且会互相移除关注关系。',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '确定',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
const ok = isBlocked
|
||||||
|
? await authService.unblockUser(user.id)
|
||||||
|
: await authService.blockUser(user.id);
|
||||||
|
if (!ok) {
|
||||||
|
Alert.alert('失败', isBlocked ? '取消拉黑失败,请稍后重试' : '拉黑失败,请稍后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUser(prev =>
|
||||||
|
prev
|
||||||
|
? { ...prev, is_following: false, is_following_me: false }
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
setIsBlocked(!isBlocked);
|
||||||
|
Alert.alert('成功', isBlocked ? '已取消拉黑' : '已拉黑该用户');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}, [user, isBlocked]);
|
||||||
|
|
||||||
|
// 设置页跳转(仅 self 模式)
|
||||||
|
const handleSettings = useCallback(() => {
|
||||||
|
if (profileNavigation) {
|
||||||
|
profileNavigation.navigate('Settings');
|
||||||
|
}
|
||||||
|
}, [profileNavigation]);
|
||||||
|
|
||||||
|
// 编辑资料页跳转(仅 self 模式)
|
||||||
|
const handleEditProfile = useCallback(() => {
|
||||||
|
if (profileNavigation) {
|
||||||
|
profileNavigation.navigate('EditProfile');
|
||||||
|
}
|
||||||
|
}, [profileNavigation]);
|
||||||
|
|
||||||
|
// 计算滚动底部间距
|
||||||
|
const scrollBottomInset = useMemo(() => {
|
||||||
|
if (isDesktop) return spacing.lg;
|
||||||
|
const FLOATING_TAB_BAR_HEIGHT = 64 + 24;
|
||||||
|
return FLOATING_TAB_BAR_HEIGHT + insets.bottom + spacing.md;
|
||||||
|
}, [isDesktop, insets.bottom]);
|
||||||
|
|
||||||
|
// 获取显示的用户
|
||||||
|
const displayUser = mode === 'self' ? currentUser : user;
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: displayUser,
|
||||||
|
posts,
|
||||||
|
favorites,
|
||||||
|
loading,
|
||||||
|
refreshing,
|
||||||
|
isBlocked,
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
scrollBottomInset,
|
||||||
|
onRefresh,
|
||||||
|
handleFollow,
|
||||||
|
handlePostPress,
|
||||||
|
handleUserPress,
|
||||||
|
handleDeletePost,
|
||||||
|
handleFollowingPress,
|
||||||
|
handleFollowersPress,
|
||||||
|
handlePostAction,
|
||||||
|
handleMessage: mode === 'other' ? handleMessage : undefined,
|
||||||
|
handleMore: mode === 'other' ? handleMore : undefined,
|
||||||
|
handleSettings: mode === 'self' && profileNavigation ? handleSettings : undefined,
|
||||||
|
handleEditProfile: mode === 'self' && profileNavigation ? handleEditProfile : undefined,
|
||||||
|
isCurrentUser: mode === 'self',
|
||||||
|
currentUser,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 共享的样式
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
export const sharedStyles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background.default,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
desktopContainer: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: spacing.lg,
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
desktopSidebar: {
|
||||||
|
width: 380,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
desktopContent: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
desktopScrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
tabBarContainer: {
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 350,
|
||||||
|
paddingTop: spacing.xs,
|
||||||
|
},
|
||||||
|
postsContainer: {
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingTop: spacing.sm,
|
||||||
|
},
|
||||||
|
postWrapper: {
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
lastPost: {
|
||||||
|
marginBottom: spacing['2xl'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tab 配置
|
||||||
|
export const TABS = ['帖子', '收藏'];
|
||||||
|
export const TAB_ICONS = ['file-document-outline', 'bookmark-outline'];
|
||||||
@@ -172,22 +172,28 @@ interface SSEEnvelope {
|
|||||||
class SSEService {
|
class SSEService {
|
||||||
private source: EventSource | null = null;
|
private source: EventSource | null = null;
|
||||||
private isConnecting = false;
|
private isConnecting = false;
|
||||||
|
private connected = false;
|
||||||
private reconnectAttempts = 0;
|
private reconnectAttempts = 0;
|
||||||
private maxReconnectAttempts = 20;
|
private maxReconnectAttempts = 20;
|
||||||
private reconnectDelay = 3000;
|
private reconnectDelay = 3000;
|
||||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||||
|
private connectionWatchdogTimer: NodeJS.Timeout | null = null;
|
||||||
|
private readonly CONNECTION_IDLE_TIMEOUT_MS = 70000;
|
||||||
private messageHandlers: Map<WSMessageType, MessageHandler[]> = new Map();
|
private messageHandlers: Map<WSMessageType, MessageHandler[]> = new Map();
|
||||||
private connectionHandlers: ConnectionHandler[] = [];
|
private connectionHandlers: ConnectionHandler[] = [];
|
||||||
private disconnectionHandlers: ConnectionHandler[] = [];
|
private disconnectionHandlers: ConnectionHandler[] = [];
|
||||||
private appStateSubscription: any = null;
|
private appStateSubscription: any = null;
|
||||||
private lastAppState: AppStateStatus = 'active';
|
private lastAppState: AppStateStatus = 'active';
|
||||||
private lastEventId = '';
|
private lastEventId = '';
|
||||||
|
private lastActivityAt = 0;
|
||||||
|
private shouldRun = false;
|
||||||
|
|
||||||
private toSSEUrl(): string {
|
private toSSEUrl(): string {
|
||||||
return `${SSE_URL}?last_event_id=${encodeURIComponent(this.lastEventId)}`;
|
return `${SSE_URL}?last_event_id=${encodeURIComponent(this.lastEventId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<boolean> {
|
async connect(): Promise<boolean> {
|
||||||
|
this.shouldRun = true;
|
||||||
if (this.isConnecting || this.isConnected()) return true;
|
if (this.isConnecting || this.isConnected()) return true;
|
||||||
this.isConnecting = true;
|
this.isConnecting = true;
|
||||||
try {
|
try {
|
||||||
@@ -201,18 +207,31 @@ class SSEService {
|
|||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
|
// Use one reconnect strategy only (manual), avoid library auto-reconnect fighting with us.
|
||||||
|
pollingInterval: 0,
|
||||||
|
// Be explicit to prevent platform-level short idle timeout behavior.
|
||||||
|
timeout: this.CONNECTION_IDLE_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.source.addEventListener('open', () => {
|
this.source.addEventListener('open', () => {
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
|
this.connected = true;
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
|
this.markActivity();
|
||||||
|
this.startConnectionWatchdog();
|
||||||
this.connectionHandlers.forEach(h => h());
|
this.connectionHandlers.forEach(h => h());
|
||||||
});
|
});
|
||||||
|
|
||||||
this.source.addEventListener('error', () => {
|
this.source.addEventListener('error', () => {
|
||||||
this.isConnecting = false;
|
this.handleDisconnected();
|
||||||
this.disconnectionHandlers.forEach(h => h());
|
});
|
||||||
this.scheduleReconnect();
|
|
||||||
|
this.source.addEventListener('done' as any, () => {
|
||||||
|
this.handleDisconnected();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.source.addEventListener('close' as any, () => {
|
||||||
|
this.handleDisconnected();
|
||||||
});
|
});
|
||||||
|
|
||||||
const events = ['chat_message', 'message_read', 'typing', 'system_notification', 'group_notice', 'message_recall', 'heartbeat'];
|
const events = ['chat_message', 'message_read', 'typing', 'system_notification', 'group_notice', 'message_recall', 'heartbeat'];
|
||||||
@@ -229,6 +248,7 @@ class SSEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleIncoming(eventName: string, evt: any): void {
|
private handleIncoming(eventName: string, evt: any): void {
|
||||||
|
this.markActivity();
|
||||||
const rawData = typeof evt?.data === 'string' ? evt.data : '{}';
|
const rawData = typeof evt?.data === 'string' ? evt.data : '{}';
|
||||||
const lastEventId = evt?.lastEventId;
|
const lastEventId = evt?.lastEventId;
|
||||||
if (lastEventId) {
|
if (lastEventId) {
|
||||||
@@ -375,10 +395,14 @@ class SSEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
|
this.shouldRun = false;
|
||||||
|
this.connected = false;
|
||||||
|
this.isConnecting = false;
|
||||||
if (this.source) {
|
if (this.source) {
|
||||||
this.source.close();
|
this.source.close();
|
||||||
this.source = null;
|
this.source = null;
|
||||||
}
|
}
|
||||||
|
this.stopConnectionWatchdog();
|
||||||
this.stopReconnect();
|
this.stopReconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,7 +423,7 @@ class SSEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
return this.source != null;
|
return this.connected;
|
||||||
}
|
}
|
||||||
|
|
||||||
on<T extends WSMessageType>(type: T, handler: MessageHandler<Extract<WSMessage, { type: T }>>): () => void {
|
on<T extends WSMessageType>(type: T, handler: MessageHandler<Extract<WSMessage, { type: T }>>): () => void {
|
||||||
@@ -453,6 +477,45 @@ class SSEService {
|
|||||||
}
|
}
|
||||||
this.disconnect();
|
this.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private markActivity(): void {
|
||||||
|
this.lastActivityAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startConnectionWatchdog(): void {
|
||||||
|
this.stopConnectionWatchdog();
|
||||||
|
this.connectionWatchdogTimer = setInterval(() => {
|
||||||
|
// During active app usage, if no SSE activity for too long, reconnect proactively.
|
||||||
|
if (!this.connected || this.lastActivityAt <= 0) return;
|
||||||
|
if (Date.now() - this.lastActivityAt > this.CONNECTION_IDLE_TIMEOUT_MS) {
|
||||||
|
this.handleDisconnected();
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopConnectionWatchdog(): void {
|
||||||
|
if (this.connectionWatchdogTimer) {
|
||||||
|
clearInterval(this.connectionWatchdogTimer);
|
||||||
|
this.connectionWatchdogTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDisconnected(): void {
|
||||||
|
const wasActive = this.connected || this.source != null || this.isConnecting;
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.connected = false;
|
||||||
|
if (this.source) {
|
||||||
|
this.source.close();
|
||||||
|
this.source = null;
|
||||||
|
}
|
||||||
|
this.stopConnectionWatchdog();
|
||||||
|
if (wasActive) {
|
||||||
|
this.disconnectionHandlers.forEach(h => h());
|
||||||
|
}
|
||||||
|
if (this.shouldRun) {
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sseService = new SSEService();
|
export const sseService = new SSEService();
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ class MessageManager {
|
|||||||
|
|
||||||
/** 正在加载会话列表下一页 */
|
/** 正在加载会话列表下一页 */
|
||||||
private loadingMoreConversations = false;
|
private loadingMoreConversations = false;
|
||||||
|
/** 聊天页活跃期间延迟的会话列表刷新 */
|
||||||
|
private deferredConversationRefresh = false;
|
||||||
|
|
||||||
/** 远端会话列表(游标或页码,由注入/remoteListKind 决定) */
|
/** 远端会话列表(游标或页码,由注入/remoteListKind 决定) */
|
||||||
private readonly remoteConversationListSource: IConversationListPagedSource;
|
private readonly remoteConversationListSource: IConversationListPagedSource;
|
||||||
@@ -274,6 +276,32 @@ class MessageManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldDeferConversationRefresh(source: string, forceRefresh: boolean): boolean {
|
||||||
|
if (!this.state.currentConversationId) return false;
|
||||||
|
if (!forceRefresh) return false;
|
||||||
|
// 列表域后台刷新来源:聊天活跃期间一律延后执行
|
||||||
|
return source === 'sse-reconnect' || source === 'prefetch' || source === 'hooks-initial-refresh';
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestConversationListRefresh(
|
||||||
|
source: string,
|
||||||
|
options?: { force?: boolean; allowDefer?: boolean }
|
||||||
|
): Promise<void> {
|
||||||
|
const forceRefresh = options?.force ?? true;
|
||||||
|
const allowDefer = options?.allowDefer ?? true;
|
||||||
|
if (allowDefer && this.shouldDeferConversationRefresh(source, forceRefresh)) {
|
||||||
|
this.deferredConversationRefresh = true;
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('[MessageManager] defer conversation refresh request', {
|
||||||
|
source,
|
||||||
|
activeConversationId: this.state.currentConversationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.fetchConversations(forceRefresh, source);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按 message_id 合并消息并按 seq 排序。
|
* 按 message_id 合并消息并按 seq 排序。
|
||||||
* 后者覆盖前者,可用来吸收更完整的同 ID 消息体(例如服务端回包/SSE)。
|
* 后者覆盖前者,可用来吸收更完整的同 ID 消息体(例如服务端回包/SSE)。
|
||||||
@@ -430,7 +458,7 @@ class MessageManager {
|
|||||||
this.initSSEListeners();
|
this.initSSEListeners();
|
||||||
|
|
||||||
// 3. 加载会话列表
|
// 3. 加载会话列表
|
||||||
await this.fetchConversations();
|
await this.fetchConversations(false, 'initialize');
|
||||||
|
|
||||||
// 4. 加载未读数
|
// 4. 加载未读数
|
||||||
await this.fetchUnreadCount();
|
await this.fetchUnreadCount();
|
||||||
@@ -539,9 +567,16 @@ class MessageManager {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - this.lastReconnectSyncAt > 1500) {
|
if (now - this.lastReconnectSyncAt > 1500) {
|
||||||
this.lastReconnectSyncAt = now;
|
this.lastReconnectSyncAt = now;
|
||||||
this.fetchConversations(true).catch(error => {
|
// 聊天页活跃时不强刷会话列表,避免触发不必要的 UI 抖动/重算
|
||||||
|
if (!this.state.currentConversationId) {
|
||||||
|
this.requestConversationListRefresh('sse-reconnect', { force: true, allowDefer: true }).catch(error => {
|
||||||
console.error('[MessageManager] 连接后同步会话失败:', error);
|
console.error('[MessageManager] 连接后同步会话失败:', error);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
this.fetchUnreadCount().catch(error => {
|
||||||
|
console.error('[MessageManager] 连接后同步未读数失败:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
if (this.state.currentConversationId) {
|
if (this.state.currentConversationId) {
|
||||||
this.fetchMessages(this.state.currentConversationId).catch(error => {
|
this.fetchMessages(this.state.currentConversationId).catch(error => {
|
||||||
console.error('[MessageManager] 连接后同步当前会话消息失败:', error);
|
console.error('[MessageManager] 连接后同步当前会话消息失败:', error);
|
||||||
@@ -1146,11 +1181,30 @@ class MessageManager {
|
|||||||
/**
|
/**
|
||||||
* 获取会话列表(首屏/下拉刷新:优先网络游标;可回退 SQLite,对 UI 透明)
|
* 获取会话列表(首屏/下拉刷新:优先网络游标;可回退 SQLite,对 UI 透明)
|
||||||
*/
|
*/
|
||||||
async fetchConversations(forceRefresh = false): Promise<void> {
|
async fetchConversations(forceRefresh = false, source: string = 'unknown'): Promise<void> {
|
||||||
if (this.state.isLoadingConversations && !forceRefresh) {
|
if (this.state.isLoadingConversations && !forceRefresh) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.shouldDeferConversationRefresh(source, forceRefresh)) {
|
||||||
|
this.deferredConversationRefresh = true;
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('[MessageManager] defer fetchConversations', {
|
||||||
|
source,
|
||||||
|
activeConversationId: this.state.currentConversationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('[MessageManager] fetchConversations', {
|
||||||
|
source,
|
||||||
|
forceRefresh,
|
||||||
|
activeConversationId: this.state.currentConversationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 非强制刷新且内存为空:先用本地源暖机,便于离线/弱网先展示列表
|
// 非强制刷新且内存为空:先用本地源暖机,便于离线/弱网先展示列表
|
||||||
if (!forceRefresh && this.state.conversations.size === 0) {
|
if (!forceRefresh && this.state.conversations.size === 0) {
|
||||||
const warmed = await this.hydrateConversationsFromLocalSource();
|
const warmed = await this.hydrateConversationsFromLocalSource();
|
||||||
@@ -1304,21 +1358,29 @@ class MessageManager {
|
|||||||
const mergeMessages = (base: MessageResponse[], incoming: MessageResponse[]): MessageResponse[] => {
|
const mergeMessages = (base: MessageResponse[], incoming: MessageResponse[]): MessageResponse[] => {
|
||||||
return this.mergeMessagesById(base, incoming);
|
return this.mergeMessagesById(base, incoming);
|
||||||
};
|
};
|
||||||
|
const existingMessagesAtStart = this.state.messagesMap.get(conversationId) || [];
|
||||||
|
const hasInMemoryMessages = existingMessagesAtStart.length > 0;
|
||||||
|
let baselineMaxSeq = hasInMemoryMessages
|
||||||
|
? existingMessagesAtStart.reduce((max, m) => Math.max(max, m.seq || 0), 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
// 1. 先从本地数据库加载(确保立即有数据展示)
|
// 1. 先从本地数据库加载(确保立即有数据展示)
|
||||||
if (!afterSeq) {
|
if (!afterSeq) {
|
||||||
let localMessages: any[] = [];
|
let localMessages: any[] = [];
|
||||||
let localMaxSeq = 0;
|
let localMaxSeq = 0;
|
||||||
|
if (!hasInMemoryMessages) {
|
||||||
try {
|
try {
|
||||||
localMessages = await getMessagesByConversation(conversationId, 20);
|
localMessages = await getMessagesByConversation(conversationId, 20);
|
||||||
localMaxSeq = await getMaxSeq(conversationId);
|
localMaxSeq = await getMaxSeq(conversationId);
|
||||||
|
baselineMaxSeq = localMaxSeq;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 架构原则:本地存储异常不阻断在线同步
|
// 架构原则:本地存储异常不阻断在线同步
|
||||||
console.warn('[MessageManager] 读取本地消息失败,回退到服务端同步:', error);
|
console.warn('[MessageManager] 读取本地消息失败,回退到服务端同步:', error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (localMessages.length > 0) {
|
if (!hasInMemoryMessages && localMessages.length > 0) {
|
||||||
// 转换格式
|
// 转换格式
|
||||||
const formattedMessages: MessageResponse[] = localMessages.map(m => ({
|
const formattedMessages: MessageResponse[] = localMessages.map(m => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
@@ -1343,7 +1405,7 @@ class MessageManager {
|
|||||||
});
|
});
|
||||||
// 异步填充 sender 信息
|
// 异步填充 sender 信息
|
||||||
this.enrichMessagesWithSenderInfo(conversationId, formattedMessages);
|
this.enrichMessagesWithSenderInfo(conversationId, formattedMessages);
|
||||||
} else {
|
} else if (!hasInMemoryMessages) {
|
||||||
// 冷启动兜底:先下发空列表事件,避免 ChatScreen 长时间停留在 loading
|
// 冷启动兜底:先下发空列表事件,避免 ChatScreen 长时间停留在 loading
|
||||||
this.state.messagesMap.set(conversationId, []);
|
this.state.messagesMap.set(conversationId, []);
|
||||||
this.notifySubscribers({
|
this.notifySubscribers({
|
||||||
@@ -1434,8 +1496,8 @@ class MessageManager {
|
|||||||
|
|
||||||
// 2.2 再基于 localMaxSeq 做增量补齐(防止快照窗口不足导致漏更老的新消息)
|
// 2.2 再基于 localMaxSeq 做增量补齐(防止快照窗口不足导致漏更老的新消息)
|
||||||
const snapshotMaxSeq = snapshotMessages.reduce((max, m) => Math.max(max, m.seq || 0), 0);
|
const snapshotMaxSeq = snapshotMessages.reduce((max, m) => Math.max(max, m.seq || 0), 0);
|
||||||
if (snapshotMaxSeq > localMaxSeq) {
|
if (snapshotMaxSeq > baselineMaxSeq) {
|
||||||
const incrementalResp = await messageService.getMessages(conversationId, localMaxSeq);
|
const incrementalResp = await messageService.getMessages(conversationId, baselineMaxSeq);
|
||||||
const newMessages = incrementalResp?.messages || [];
|
const newMessages = incrementalResp?.messages || [];
|
||||||
if (newMessages.length > 0) {
|
if (newMessages.length > 0) {
|
||||||
const existingMessages = this.state.messagesMap.get(conversationId) || [];
|
const existingMessages = this.state.messagesMap.get(conversationId) || [];
|
||||||
@@ -2226,6 +2288,12 @@ class MessageManager {
|
|||||||
setActiveConversation(conversationId: string | null): void {
|
setActiveConversation(conversationId: string | null): void {
|
||||||
const normalizedConversationId = conversationId ? this.normalizeConversationId(conversationId) : null;
|
const normalizedConversationId = conversationId ? this.normalizeConversationId(conversationId) : null;
|
||||||
this.state.currentConversationId = normalizedConversationId;
|
this.state.currentConversationId = normalizedConversationId;
|
||||||
|
if (!normalizedConversationId && this.deferredConversationRefresh) {
|
||||||
|
this.deferredConversationRefresh = false;
|
||||||
|
this.requestConversationListRefresh('deferred-after-chat', { force: true, allowDefer: false }).catch(error => {
|
||||||
|
console.error('[MessageManager] 延迟会话刷新失败:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ export function useConversations(): UseConversationsReturn {
|
|||||||
|
|
||||||
// 冷启动兜底:延迟做一次强制刷新,避免首次因时序问题拿到空列表
|
// 冷启动兜底:延迟做一次强制刷新,避免首次因时序问题拿到空列表
|
||||||
const coldStartSyncTimer = setTimeout(() => {
|
const coldStartSyncTimer = setTimeout(() => {
|
||||||
messageManager.fetchConversations(true).catch(error => {
|
messageManager.requestConversationListRefresh('hooks-initial-refresh', {
|
||||||
|
force: true,
|
||||||
|
allowDefer: true,
|
||||||
|
}).catch(error => {
|
||||||
console.error('[useConversations] 冷启动强制刷新失败:', error);
|
console.error('[useConversations] 冷启动强制刷新失败:', error);
|
||||||
});
|
});
|
||||||
}, 1200);
|
}, 1200);
|
||||||
@@ -67,7 +70,10 @@ export function useConversations(): UseConversationsReturn {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
await messageManager.fetchConversations(true);
|
await messageManager.requestConversationListRefresh('hooks-manual-refresh', {
|
||||||
|
force: true,
|
||||||
|
allowDefer: false,
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
@@ -530,7 +536,10 @@ export function useMessageListRefresh(): UseMessageListRefreshReturn {
|
|||||||
|
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
try {
|
try {
|
||||||
await messageManager.fetchConversations(true);
|
await messageManager.requestConversationListRefresh('hooks-load-more-fallback', {
|
||||||
|
force: true,
|
||||||
|
allowDefer: false,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[useMessageListRefresh] 刷新失败:', error);
|
console.error('[useMessageListRefresh] 刷新失败:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user