dev #5

Merged
lan merged 38 commits from dev into master 2026-03-25 20:54:10 +08:00
13 changed files with 728 additions and 32 deletions
Showing only changes of commit 82e99d24d8 - Show all commits

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "carrot_bbs",
"version": "1.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "carrot_bbs",
"version": "1.0.0",
"version": "1.0.1",
"dependencies": {
"@expo/vector-icons": "^15.1.1",
"@react-native-async-storage/async-storage": "^2.2.0",

View File

@@ -0,0 +1,74 @@
/**
* PostCard 主组件
* 帖子卡片组件 - 支持列表模式和网格模式
*
* @example
* // 新 API 使用方式
* <PostCard
* post={post}
* onAction={(action) => handleAction(post, action)}
* variant="list"
* features="full"
* />
*/
import React from 'react';
import { PostCardProps, LegacyPostCardProps } from './types';
import PostCardList from './PostCardList';
import PostCardGrid from './PostCardGrid';
import { usePostCardFeatures } from './hooks/usePostCardFeatures';
import { createLegacyAdapter, isLegacyProps } from './legacyAdapter';
/**
* PostCard 主组件
* 支持新旧两种 API
*/
const PostCard: React.FC<PostCardProps | LegacyPostCardProps> = (props) => {
// 检测是否使用旧 API
const isLegacy = isLegacyProps(props as LegacyPostCardProps);
// 兼容层:转换旧 Props 到新 Props
const normalizedProps: PostCardProps = isLegacy
? createLegacyAdapter(props as LegacyPostCardProps)
: (props as PostCardProps);
const {
post,
onAction,
variant = 'list',
features: featuresConfig,
isPostAuthor = false,
isAuthor = false,
index,
style,
} = normalizedProps;
// 解析 Features 配置
const resolvedFeatures = usePostCardFeatures(featuresConfig);
// 根据 variant 选择渲染模式
if (variant === 'grid') {
return (
<PostCardGrid
post={post}
onAction={onAction}
features={resolvedFeatures}
style={style}
/>
);
}
return (
<PostCardList
post={post}
onAction={onAction}
features={resolvedFeatures}
isPostAuthor={isPostAuthor}
isAuthor={isAuthor}
index={index}
style={style}
/>
);
};
export default PostCard;

View File

@@ -0,0 +1,115 @@
/**
* PostCardGrid 容器组件
* 网格模式(小红书风格)的帖子卡片容器
*/
import React from 'react';
import { TouchableOpacity, StyleSheet, StyleProp, ViewStyle, GestureResponderEvent } from 'react-native';
import { colors, borderRadius } from '../../../theme';
import { useResponsive } from '../../../hooks/useResponsive';
import { PostCardGridProps } from './types';
import { usePostCardActions } from './hooks/usePostCardActions';
import { usePostGridStyles } from './hooks/usePostCardStyles';
import { PostGridCover, PostGridInfo } from './components';
import { convertToImageGridItems, getConsistentAspectRatio } from './utils';
const PostCardGrid: React.FC<PostCardGridProps> = ({
post,
onAction,
features,
style,
}) => {
const { isDesktop } = useResponsive();
const gridStyles = usePostGridStyles();
const {
handlePress,
handleUserPress,
handleImagePress,
} = usePostCardActions(onAction);
// 防御性检查:确保 author 存在
const author = post.author ?? {
id: '',
nickname: '匿名用户',
avatar: '',
username: '',
cover_url: '',
bio: '',
website: '',
location: '',
posts_count: 0,
followers_count: 0,
following_count: 0,
created_at: '',
};
// 获取封面图(第一张图)
const coverImage = post.images && post.images.length > 0 ? post.images[0] : null;
// 根据帖子 ID 生成一致的宽高比
const aspectRatio = getConsistentAspectRatio(post.id);
// 样式计算
const containerStyle: StyleProp<ViewStyle> = [
styles.container,
!coverImage && styles.containerNoImage,
isDesktop && styles.containerDesktop,
];
const handleContainerPress = () => {
handlePress();
};
const handleImagePressLocal = () => {
if (post.images && post.images.length > 0) {
handleImagePress(convertToImageGridItems(post.images), 0);
}
};
const handleUserPressLocal = () => {
handleUserPress();
};
return (
<TouchableOpacity
style={[containerStyle, style]}
onPress={handleContainerPress}
activeOpacity={0.9}
>
{/* 封面 */}
<PostGridCover
image={coverImage}
content={post.content}
isVote={post.is_vote}
aspectRatio={aspectRatio}
onImagePress={handleImagePressLocal}
/>
{/* 标题和信息 */}
<PostGridInfo
title={post.title}
author={author}
likesCount={post.likes_count}
onUserPress={handleUserPressLocal}
/>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: '#FFF',
overflow: 'hidden',
borderRadius: 8,
},
containerDesktop: {
borderRadius: 12,
},
containerNoImage: {
minHeight: 200,
justifyContent: 'space-between',
},
});
export default PostCardGrid;

View File

@@ -0,0 +1,53 @@
/**
* PostImages 子组件
* 显示帖子图片网格
*/
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { colors, spacing, borderRadius } from '../../../../theme';
import { useResponsive } from '../../../../hooks/useResponsive';
import { ImageGrid, ImageGridItem } from '../../../common';
import { PostImagesProps } from '../types';
const PostImages: React.FC<PostImagesProps> = ({ images, displayMode, onImagePress }) => {
const { isDesktop, isWideScreen } = useResponsive();
if (!images || images.length === 0) return null;
// 计算图片间距和圆角
const imageGap = isDesktop ? 12 : 8;
const imageBorderRadius = isDesktop ? borderRadius.xl : borderRadius.md;
return (
<View style={styles.container}>
<ImageGrid
images={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,
}))}
maxDisplayCount={isWideScreen ? 12 : 9}
mode="auto"
gap={imageGap}
borderRadius={imageBorderRadius}
showMoreOverlay={true}
onImagePress={onImagePress}
displayMode={displayMode}
usePreview={true}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: spacing.md,
},
});
export default PostImages;

View File

@@ -0,0 +1,12 @@
/**
* PostCard 子组件导出
*/
export { default as PostHeader } from './PostHeader';
export { default as PostTitle } from './PostTitle';
export { default as PostContent } from './PostContent';
export { default as PostImages } from './PostImages';
export { default as PostActions } from './PostActions';
export { default as PostTopComment } from './PostTopComment';
export { default as PostGridCover } from './PostGridCover';
export { default as PostGridInfo } from './PostGridInfo';

View File

@@ -0,0 +1,8 @@
/**
* PostCard Hooks 导出
*/
export { usePostCardActions } from './usePostCardActions';
export { usePostCardFeatures } from './usePostCardFeatures';
export { usePostCardStyles, usePostGridStyles } from './usePostCardStyles';
export type { PostCardStylesConfig, PostGridStylesConfig } from './usePostCardStyles';

View File

@@ -0,0 +1,58 @@
/**
* PostCard 组件模块导出
*/
// 主组件(默认导出)
export { default } from './PostCard';
// 类型导出
export type {
PostCardActionType,
PostCardActionPayload,
PostCardAction,
PostCardOnAction,
PostCardFeatures,
PostCardFeaturesPreset,
PostCardFeaturesConfig,
PostCardProps,
PostHeaderProps,
PostTitleProps,
PostContentProps,
PostImagesProps,
PostActionsProps,
PostVotePreviewProps,
PostTopCommentProps,
PostGridCoverProps,
PostGridInfoProps,
PostCardListProps,
PostCardGridProps,
LegacyPostCardProps,
Badge,
} 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';

View File

@@ -0,0 +1,285 @@
/**
* PostCard 组件类型定义
* 统一的帖子卡片组件类型系统
*/
import { StyleProp, ViewStyle } from 'react-native';
import { Post, PostImageDTO, UserDTO, CommentDTO } from '../../../types';
import { ImageGridItem } from '../../common';
// ==================== Action 类型定义 ====================
/**
* PostCard 支持的操作类型
*/
export type PostCardActionType =
| 'press' // 点击帖子主体
| 'userPress' // 点击用户头像/名称
| 'like' // 点赞
| 'unlike' // 取消点赞
| 'comment' // 评论
| 'bookmark' // 收藏
| 'unbookmark' // 取消收藏
| 'share' // 分享
| 'imagePress' // 点击图片
| 'delete'; // 删除
/**
* Action payload 类型
*/
export interface PostCardActionPayload {
images?: ImageGridItem[];
imageIndex?: number;
[key: string]: unknown;
}
/**
* 统一的 Action 对象
*/
export interface PostCardAction {
type: PostCardActionType;
payload?: PostCardActionPayload;
}
/**
* 统一的回调函数类型
*/
export type PostCardOnAction = (action: PostCardAction) => void;
// ==================== Features 配置类型 ====================
/**
* 显示特性配置
*/
export interface PostCardFeatures {
/** 显示用户信息 */
showAuthor?: boolean;
/** 显示标题 */
showTitle?: boolean;
/** 显示内容 */
showContent?: boolean;
/** 显示图片 */
showImages?: boolean;
/** 显示投票预览 */
showVote?: boolean;
/** 显示热门评论 */
showTopComment?: boolean;
/** 显示操作栏 */
showActions?: boolean;
/** 显示浏览数 */
showViews?: boolean;
/** 显示分享按钮 */
showShare?: boolean;
/** 显示收藏按钮 */
showBookmark?: boolean;
/** 显示删除按钮(需配合权限判断) */
showDelete?: boolean;
/** 显示置顶标签 */
showPinned?: boolean;
/** 显示徽章(楼主/管理员) */
showBadges?: boolean;
}
/**
* Features 预设名称
*/
export type PostCardFeaturesPreset = 'full' | 'compact' | 'grid';
/**
* Features 配置(可以是预设名称或自定义对象)
*/
export type PostCardFeaturesConfig = PostCardFeaturesPreset | PostCardFeatures;
// ==================== 主组件 Props ====================
/**
* PostCard 主组件 Props
*/
export interface PostCardProps {
/** 帖子数据 */
post: Post;
/** 统一事件回调 */
onAction: PostCardOnAction;
/** 显示模式:列表模式 | 网格模式 */
variant?: 'list' | 'grid';
/** 预设配置名称,或自定义配置对象 */
features?: PostCardFeaturesConfig;
/** 当前用户是否为帖子作者(用于显示删除按钮) */
isPostAuthor?: boolean;
/** 当前用户是否为楼主(在帖子详情页显示"楼主"徽章) */
isAuthor?: boolean;
/** 索引(用于虚拟化列表优化) */
index?: number;
/** 自定义样式 */
style?: StyleProp<ViewStyle>;
}
// ==================== 子组件 Props ====================
/**
* PostHeader 子组件 Props
*/
export interface PostHeaderProps {
author: UserDTO;
createdAt: string;
editedAt?: string;
isPinned?: boolean;
badges?: Array<{ type: 'author' | 'admin'; label: string }>;
onDelete?: () => void;
onUserPress: () => void;
showDelete?: boolean;
showPinned?: boolean;
showBadges?: boolean;
isDeleting?: boolean;
}
/**
* PostTitle 子组件 Props
*/
export interface PostTitleProps {
title?: string;
numberOfLines?: number;
}
/**
* PostContent 子组件 Props
*/
export interface PostContentProps {
content?: string;
compact?: boolean;
maxLines?: number;
isExpanded?: boolean;
onToggleExpand?: () => void;
}
/**
* PostImages 子组件 Props
*/
export interface PostImagesProps {
images: PostImageDTO[];
displayMode: 'list' | 'grid';
onImagePress: (images: ImageGridItem[], index: number) => void;
}
/**
* PostActions 子组件 Props
*/
export interface PostActionsProps {
viewsCount: number;
likesCount: number;
commentsCount: number;
isLiked: boolean;
isFavorited: boolean;
onLike: () => void;
onComment: () => void;
onShare: () => void;
onBookmark: () => void;
showViews?: boolean;
showShare?: boolean;
showBookmark?: boolean;
}
/**
* PostVotePreview 子组件 Props
*/
export interface PostVotePreviewProps {
isVote: boolean;
totalVotes?: number;
optionsCount?: number;
onPress: () => void;
}
/**
* PostTopComment 子组件 Props
*/
export interface PostTopCommentProps {
comment: CommentDTO | null;
totalComments: number;
onPress: () => void;
}
/**
* PostGridCover 子组件 Props
*/
export interface PostGridCoverProps {
image: PostImageDTO | null;
content?: string;
isVote?: boolean;
aspectRatio: number;
onImagePress: () => void;
}
/**
* PostGridInfo 子组件 Props
*/
export interface PostGridInfoProps {
title?: string;
author: UserDTO;
likesCount: number;
onUserPress: () => void;
}
// ==================== 容器组件 Props ====================
/**
* PostCardList 容器组件 Props
*/
export interface PostCardListProps {
post: Post;
onAction: PostCardOnAction;
features: PostCardFeatures;
isPostAuthor?: boolean;
isAuthor?: boolean;
index?: number;
style?: StyleProp<ViewStyle>;
}
/**
* PostCardGrid 容器组件 Props
*/
export interface PostCardGridProps {
post: Post;
onAction: PostCardOnAction;
features: PostCardFeatures;
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';
}
// ==================== 工具类型 ====================
/**
* 徽章类型
*/
export interface Badge {
type: 'author' | 'admin';
label: string;
}

View File

@@ -2,7 +2,21 @@
* 业务组件导出
*/
// PostCard 新架构(从 PostCard 目录导出)
export { default as PostCard } from './PostCard';
// 导出 PostCard 相关类型和工具
export type {
PostCardActionType,
PostCardAction,
PostCardOnAction,
PostCardFeatures,
PostCardFeaturesPreset,
PostCardFeaturesConfig,
PostCardProps,
LegacyPostCardProps,
} from './PostCard';
export { PostCardPresets, resolveFeatures } from './PostCard';
export { usePostCardActions, usePostCardFeatures } from './PostCard';
export { default as CommentItem } from './CommentItem';
export { default as UserProfileHeader } from './UserProfileHeader';
export { default as SystemMessageItem } from './SystemMessageItem';

View File

@@ -29,6 +29,7 @@ import { useUserStore } from '../../stores';
import { useCurrentUser } from '../../stores/authStore';
import { postService } from '../../services';
import { PostCard, TabBar, SearchBar } from '../../components/business';
import type { PostCardAction } from '../../components/business/PostCard';
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common';
import { HomeStackParamList, RootStackParamList } from '../../navigation/types';
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
@@ -368,6 +369,41 @@ export const HomeScreen: React.FC = () => {
setShowImageViewer(true);
};
// 统一处理 PostCard 的所有操作
const handlePostAction = (post: Post, action: PostCardAction) => {
switch (action.type) {
case 'press':
handlePostPress(post.id);
break;
case 'userPress':
const authorId = post.author?.id;
if (authorId) handleUserPress(authorId);
break;
case 'like':
case 'unlike':
handleLike(post);
break;
case 'comment':
handlePostPress(post.id, true);
break;
case 'bookmark':
case 'unbookmark':
handleBookmark(post);
break;
case 'share':
handleShare(post);
break;
case 'imagePress':
if (action.payload?.images && action.payload?.imageIndex !== undefined) {
handleImagePress(action.payload.images, action.payload.imageIndex);
}
break;
case 'delete':
handleDeletePost(post.id);
break;
}
};
// 跳转到发帖页面(使用 Modal 方式)
const handleCreatePost = () => {
setShowCreatePost(true);
@@ -392,19 +428,12 @@ export const HomeScreen: React.FC = () => {
]}>
<PostCard
post={item}
onPress={() => handlePostPress(item.id)}
onUserPress={() => authorId && handleUserPress(authorId)}
onLike={() => handleLike(item)}
onComment={() => handlePostPress(item.id, true)}
onBookmark={() => handleBookmark(item)}
onShare={() => handleShare(item)}
onImagePress={(images, index) => handleImagePress(images, index)}
onDelete={() => handleDeletePost(item.id)}
onAction={(action) => handlePostAction(item, action)}
isPostAuthor={isPostAuthor}
/>
</View>
);
}, [currentUser?.id, handleBookmark, handleDeletePost, handleImagePress, handleLike, handlePostPress, handleShare, handleUserPress, isMobile, listItemWidth, responsiveGap]);
}, [currentUser?.id, handlePostAction, isMobile, listItemWidth, responsiveGap]);
// 估算帖子在瀑布流中的高度(用于均匀分配)
const estimatePostHeight = (post: Post, columnWidth: number): number => {

View File

@@ -22,6 +22,7 @@ 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';
@@ -245,6 +246,37 @@ export const ProfileScreen: React.FC = () => {
}
}, []);
// 统一的 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 />;
@@ -273,13 +305,7 @@ export const ProfileScreen: React.FC = () => {
]}>
<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)}
onAction={(action) => handlePostAction(post, action)}
isPostAuthor={isPostAuthor}
/>
</View>

View File

@@ -23,6 +23,7 @@ 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';
@@ -218,6 +219,39 @@ export const UserScreen: React.FC = () => {
}
};
// 统一处理 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;
@@ -317,13 +351,7 @@ export const UserScreen: React.FC = () => {
]}>
<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)}
onAction={(action) => handlePostAction(post, action)}
isPostAuthor={isPostAuthor}
/>
</View>
@@ -357,13 +385,7 @@ export const UserScreen: React.FC = () => {
]}>
<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)}
onAction={(action) => handlePostAction(post, action)}
isPostAuthor={isPostAuthor}
/>
</View>