Initial frontend repository commit.

Include app source and update .gitignore to exclude local release artifacts and signing files.

Made-with: Cursor
This commit is contained in:
2026-03-09 21:29:03 +08:00
commit 3968660048
129 changed files with 55599 additions and 0 deletions

View File

@@ -0,0 +1,612 @@
/**
* CommentItem 评论项组件 - QQ频道风格
* 支持嵌套回复显示、楼层号、身份标识、删除评论、图片显示
*/
import React, { useState } from 'react';
import { View, TouchableOpacity, StyleSheet, Alert, Dimensions } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { colors, spacing, borderRadius, fontSizes } from '../../theme';
import { Comment, CommentImage } from '../../types';
import Text from '../common/Text';
import Avatar from '../common/Avatar';
import { CompactImageGrid, ImageGridItem } from '../common';
const { width: screenWidth } = Dimensions.get('window');
interface CommentItemProps {
comment: Comment;
onUserPress: () => void;
onReply: () => void;
onLike: () => void;
floorNumber?: number; // 楼层号
isAuthor?: boolean; // 是否是楼主
replyToUser?: string; // 回复给哪位用户
onReplyPress?: (comment: Comment) => void; // 点击评论的评论
allReplies?: Comment[]; // 所有回复列表,用于根据 target_id 查找被回复用户
onLoadMoreReplies?: (commentId: string) => void; // 加载更多回复的回调
isCommentAuthor?: boolean; // 当前用户是否为评论作者
onDelete?: (comment: Comment) => void; // 删除评论的回调
onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图
currentUserId?: string; // 当前用户ID用于判断子评论作者
}
const CommentItem: React.FC<CommentItemProps> = ({
comment,
onUserPress,
onReply,
onLike,
floorNumber,
isAuthor = false,
replyToUser,
onReplyPress,
allReplies,
onLoadMoreReplies,
isCommentAuthor = false,
onDelete,
onImagePress,
currentUserId,
}) => {
const [isDeleting, setIsDeleting] = useState(false);
// 格式化时间
const formatTime = (dateString: string): string => {
try {
return formatDistanceToNow(new Date(dateString), {
addSuffix: true,
locale: zhCN,
});
} catch {
return '';
}
};
// 格式化数字
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 handleImagePress = (index: number) => {
if (onImagePress && comment.images && comment.images.length > 0) {
const images: ImageGridItem[] = comment.images.map(img => ({
id: img.url,
url: img.url,
}));
onImagePress(images, index);
}
};
// 渲染评论图片 - 使用 CompactImageGrid
const renderCommentImages = () => {
if (!comment.images || comment.images.length === 0) {
return null;
}
const gridImages: ImageGridItem[] = comment.images.map(img => ({
id: img.url,
url: img.url,
}));
return (
<CompactImageGrid
images={gridImages}
maxDisplayCount={6}
gap={4}
borderRadius={borderRadius.sm}
showMoreOverlay
onImagePress={onImagePress}
/>
);
};
// 处理删除评论
const handleDelete = () => {
if (!onDelete || isDeleting) return;
Alert.alert(
'删除评论',
'确定要删除这条评论吗?删除后将无法恢复。',
[
{
text: '取消',
style: 'cancel',
},
{
text: '删除',
style: 'destructive',
onPress: async () => {
setIsDeleting(true);
try {
await onDelete(comment);
} catch (error) {
console.error('删除评论失败:', error);
Alert.alert('删除失败', '删除评论时发生错误,请稍后重试');
} finally {
setIsDeleting(false);
}
},
},
],
);
};
// 渲染身份标识
const renderBadges = () => {
const badges = [];
const authorId = comment.author?.id || '';
if (isAuthor) {
badges.push(
<View key="author" style={[styles.badge, styles.authorBadge]}>
<Text variant="caption" style={styles.badgeText}></Text>
</View>
);
}
if (authorId === '1') { // 管理员
badges.push(
<View key="admin" style={[styles.badge, styles.adminBadge]}>
<Text variant="caption" style={styles.badgeText}></Text>
</View>
);
}
return badges;
};
// 渲染楼层号
const renderFloorNumber = () => {
if (!floorNumber) return null;
// 获取楼层显示文本
const getFloorText = (floor: number): string => {
switch (floor) {
case 1:
return '沙发';
case 2:
return '板凳';
case 3:
return '地板';
default:
return `${floor}`;
}
};
return (
<View style={styles.floorTag}>
<Text variant="caption" color={colors.text.hint} style={styles.floorText}>
{getFloorText(floorNumber)}
</Text>
</View>
);
};
// 根据 target_id 查找被回复用户的昵称
const getTargetUserNickname = (targetId: string): string => {
// 首先在顶级评论中查找
if (comment.id === targetId) {
return comment.author?.nickname || '用户';
}
// 然后在回复列表中查找
if (allReplies) {
const targetComment = allReplies.find(r => r.id === targetId);
if (targetComment) {
return targetComment.author?.nickname || '用户';
}
}
// 最后在当前评论的 replies 中查找
if (comment.replies) {
const targetComment = comment.replies.find(r => r.id === targetId);
if (targetComment) {
return targetComment.author?.nickname || '用户';
}
}
return '用户';
};
// 渲染子评论图片
const renderSubReplyImages = (reply: Comment) => {
if (!reply.images || reply.images.length === 0) {
return null;
}
const gridImages: ImageGridItem[] = reply.images.map(img => ({
id: img.url,
url: img.url,
}));
return (
<CompactImageGrid
images={gridImages}
maxDisplayCount={3}
gap={4}
borderRadius={borderRadius.sm}
showMoreOverlay
onImagePress={onImagePress}
/>
);
};
// 处理子评论删除
const handleSubReplyDelete = (reply: Comment) => {
if (!onDelete || isDeleting) return;
Alert.alert(
'删除回复',
'确定要删除这条回复吗?删除后将无法恢复。',
[
{
text: '取消',
style: 'cancel',
},
{
text: '删除',
style: 'destructive',
onPress: async () => {
setIsDeleting(true);
try {
await onDelete(reply);
} catch (error) {
console.error('删除回复失败:', error);
Alert.alert('删除失败', '删除回复时发生错误,请稍后重试');
} finally {
setIsDeleting(false);
}
},
},
],
);
};
// 渲染评论的评论(子评论)- 抖音/b站风格平铺展示
const renderSubReplies = () => {
if (!comment.replies || comment.replies.length === 0) {
return null;
}
const commentAuthorId = comment.author?.id || '';
return (
<View style={styles.subRepliesContainer}>
{comment.replies.map((reply) => {
const replyAuthorId = reply.author?.id || '';
// 根据 target_id 获取被回复的用户昵称
const targetId = reply.target_id;
const targetNickname = targetId ? getTargetUserNickname(targetId) : null;
// 判断当前用户是否为子评论作者
const isSubReplyAuthor = currentUserId === replyAuthorId;
return (
<TouchableOpacity
key={reply.id}
style={styles.subReplyItem}
onPress={() => onReplyPress?.(reply)}
activeOpacity={0.7}
>
<Avatar
source={reply.author?.avatar}
size={24}
name={reply.author?.nickname || '用户'}
/>
<View style={styles.subReplyContent}>
<View style={styles.subReplyHeader}>
<Text variant="caption" style={styles.subReplyAuthor}>
{reply.author?.nickname || '用户'}
</Text>
{replyAuthorId === commentAuthorId && (
<View style={[styles.badge, styles.authorBadge, styles.smallBadge]}>
<Text variant="caption" style={styles.smallBadgeText}></Text>
</View>
)}
{/* 显示回复引用aaa 回复 bbb */}
{targetNickname && (
<>
<Text variant="caption" color={colors.text.hint} style={styles.replyToText}>
{' '}
</Text>
<Text variant="caption" color={colors.primary.main} style={styles.replyToName}>
{targetNickname}
</Text>
</>
)}
</View>
{/* 显示回复内容(如果有文字) */}
{reply.content ? (
<Text variant="caption" color={colors.text.secondary} numberOfLines={2}>
{reply.content}
</Text>
) : null}
{/* 显示回复图片(如果有图片) */}
{renderSubReplyImages(reply)}
{/* 子评论操作按钮 */}
<View style={styles.subReplyActions}>
<TouchableOpacity
style={styles.actionButton}
onPress={() => onReplyPress?.(reply)}
>
<MaterialCommunityIcons name="reply" size={12} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
</Text>
</TouchableOpacity>
{/* 删除按钮 - 子评论作者可见 */}
{isSubReplyAuthor && (
<TouchableOpacity
style={styles.actionButton}
onPress={() => handleSubReplyDelete(reply)}
disabled={isDeleting}
>
<MaterialCommunityIcons
name={isDeleting ? 'loading' : 'delete-outline'}
size={12}
color={colors.text.hint}
/>
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
{isDeleting ? '删除中' : '删除'}
</Text>
</TouchableOpacity>
)}
</View>
</View>
</TouchableOpacity>
);
})}
{comment.replies_count > (comment.replies?.length || 0) && (
<TouchableOpacity
style={styles.moreRepliesButton}
onPress={() => onLoadMoreReplies?.(comment.id)}
>
<Text variant="caption" color={colors.primary.main}>
{comment.replies_count - (comment.replies?.length || 0)}
</Text>
</TouchableOpacity>
)}
</View>
);
};
return (
<View style={styles.container}>
{/* 用户头像 */}
<TouchableOpacity onPress={onUserPress}>
<Avatar
source={comment.author?.avatar}
size={36}
name={comment.author?.nickname}
/>
</TouchableOpacity>
{/* 评论内容 */}
<View style={styles.content}>
{/* 用户信息行 - QQ频道风格 */}
<View style={styles.header}>
<View style={styles.userInfo}>
<TouchableOpacity onPress={onUserPress}>
<Text variant="body" style={styles.username}>
{comment.author?.nickname}
</Text>
</TouchableOpacity>
{renderBadges()}
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
{formatTime(comment.created_at || '')}
</Text>
</View>
{renderFloorNumber()}
</View>
{/* 回复引用 */}
{replyToUser && (
<View style={styles.replyReference}>
<Text variant="caption" color={colors.primary.main}>
@{replyToUser}:
</Text>
</View>
)}
{/* 评论文本 - 非气泡样式 */}
<View style={styles.commentContent}>
<Text variant="body" color={colors.text.primary} style={styles.text}>
{comment.content}
</Text>
</View>
{/* 评论图片 */}
{renderCommentImages()}
{/* 操作按钮 - 更紧凑 */}
<View style={styles.actions}>
{/* 点赞 */}
<TouchableOpacity style={styles.actionButton} onPress={onLike}>
<MaterialCommunityIcons
name={comment.is_liked ? 'heart' : 'heart-outline'}
size={14}
color={comment.is_liked ? colors.error.main : colors.text.hint}
/>
<Text
variant="caption"
color={comment.is_liked ? colors.error.main : colors.text.hint}
style={styles.actionText}
>
{comment.likes_count > 0 ? formatNumber(comment.likes_count) : '赞'}
</Text>
</TouchableOpacity>
{/* 回复 */}
<TouchableOpacity style={styles.actionButton} onPress={onReply}>
<MaterialCommunityIcons name="reply" size={14} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
</Text>
</TouchableOpacity>
{/* 删除按钮 - 只对评论作者显示 */}
{isCommentAuthor && (
<TouchableOpacity
style={styles.actionButton}
onPress={handleDelete}
disabled={isDeleting}
>
<MaterialCommunityIcons
name={isDeleting ? 'loading' : 'delete-outline'}
size={14}
color={colors.text.hint}
/>
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
{isDeleting ? '删除中' : '删除'}
</Text>
</TouchableOpacity>
)}
</View>
{/* 评论的评论(子评论)- 抖音/b站风格平铺展示不开新层级 */}
{renderSubReplies()}
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.lg,
backgroundColor: colors.background.paper,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
},
content: {
flex: 1,
marginLeft: spacing.sm,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: spacing.xs,
},
userInfo: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
flex: 1,
},
username: {
fontWeight: '600',
fontSize: fontSizes.sm,
color: colors.text.primary,
marginRight: spacing.xs,
},
badge: {
paddingHorizontal: 4,
paddingVertical: 1,
borderRadius: 2,
marginRight: spacing.xs,
},
smallBadge: {
paddingHorizontal: 2,
paddingVertical: 0,
},
authorBadge: {
backgroundColor: colors.primary.main,
},
adminBadge: {
backgroundColor: colors.error.main,
},
badgeText: {
color: colors.text.inverse,
fontSize: fontSizes.xs,
fontWeight: '600',
},
smallBadgeText: {
color: colors.text.inverse,
fontSize: 9,
fontWeight: '600',
},
timeText: {
fontSize: fontSizes.xs,
},
floorTag: {
backgroundColor: colors.background.default,
paddingHorizontal: spacing.xs,
paddingVertical: 2,
borderRadius: borderRadius.sm,
},
floorText: {
fontSize: fontSizes.xs,
},
replyReference: {
marginBottom: spacing.xs,
},
commentContent: {
marginBottom: spacing.xs,
},
text: {
lineHeight: 20,
fontSize: fontSizes.md,
},
actions: {
flexDirection: 'row',
alignItems: 'center',
},
actionButton: {
flexDirection: 'row',
alignItems: 'center',
marginRight: spacing.md,
paddingVertical: spacing.xs,
},
actionText: {
marginLeft: 2,
fontSize: fontSizes.xs,
},
subRepliesContainer: {
marginTop: spacing.sm,
backgroundColor: colors.background.default,
borderRadius: borderRadius.md,
padding: spacing.sm,
},
subReplyItem: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: spacing.sm,
},
subReplyContent: {
flex: 1,
marginLeft: spacing.xs,
},
subReplyHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 2,
},
subReplyAuthor: {
fontWeight: '600',
color: colors.text.primary,
marginRight: spacing.xs,
},
moreRepliesButton: {
marginTop: spacing.xs,
paddingVertical: spacing.xs,
},
replyToText: {
fontSize: fontSizes.xs,
},
replyToName: {
fontSize: fontSizes.xs,
fontWeight: '500',
},
subReplyActions: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.xs,
},
});
export default CommentItem;

View File

@@ -0,0 +1,147 @@
/**
* NotificationItem 通知项组件
* 根据通知类型显示不同图标和内容
*
* @deprecated 请使用 SystemMessageItem 组件代替
* 该组件使用旧的 Notification 类型,新功能请使用 SystemMessageItem
*/
import React from 'react';
import { View, TouchableOpacity, StyleSheet } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { colors, spacing, borderRadius } from '../../theme';
import { Notification, NotificationType } from '../../types';
import Text from '../common/Text';
import Avatar from '../common/Avatar';
interface NotificationItemProps {
notification: Notification;
onPress: () => void;
}
// 通知类型到图标和颜色的映射
const getNotificationIcon = (type: NotificationType): { icon: string; color: string } => {
switch (type) {
case 'like_post':
return { icon: 'heart', color: colors.error.main };
case 'like_comment':
return { icon: 'heart', color: colors.error.main };
case 'comment':
return { icon: 'comment', color: colors.info.main };
case 'reply':
return { icon: 'reply', color: colors.info.main };
case 'follow':
return { icon: 'account-plus', color: colors.primary.main };
case 'mention':
return { icon: 'at', color: colors.warning.main };
case 'system':
return { icon: 'information', color: colors.text.secondary };
default:
return { icon: 'bell', color: colors.text.secondary };
}
};
const NotificationItem: React.FC<NotificationItemProps> = ({
notification,
onPress,
}) => {
// 格式化时间
const formatTime = (dateString: string): string => {
try {
return formatDistanceToNow(new Date(dateString), {
addSuffix: true,
locale: zhCN,
});
} catch {
return '';
}
};
const { icon, color } = getNotificationIcon(notification.type);
return (
<TouchableOpacity
style={[styles.container, !notification.isRead && styles.unread]}
onPress={onPress}
activeOpacity={0.7}
>
{/* 通知图标/头像 */}
<View style={styles.iconContainer}>
{notification.type === 'follow' && notification.data.userId ? (
<Avatar
source={null}
size={40}
name={notification.title}
/>
) : (
<View style={[styles.iconWrapper, { backgroundColor: color + '20' }]}>
<MaterialCommunityIcons name={icon as any} size={20} color={color} />
</View>
)}
</View>
{/* 通知内容 */}
<View style={styles.content}>
<View style={styles.textContainer}>
<Text
variant="body"
numberOfLines={2}
style={!notification.isRead ? styles.unreadText : undefined}
>
{notification.content}
</Text>
</View>
<Text variant="caption" color={colors.text.secondary}>
{formatTime(notification.createdAt)}
</Text>
</View>
{/* 未读标记 */}
{!notification.isRead && <View style={styles.unreadDot} />}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.lg,
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
unread: {
backgroundColor: colors.primary.light + '10',
},
iconContainer: {
marginRight: spacing.md,
},
iconWrapper: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
},
content: {
flex: 1,
},
textContainer: {
marginBottom: spacing.xs,
},
unreadText: {
fontWeight: '600',
},
unreadDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.primary.main,
marginLeft: spacing.sm,
},
});
export default NotificationItem;

View File

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

View File

@@ -0,0 +1,134 @@
/**
* SearchBar 搜索栏组件
* 用于搜索内容
*/
import React, { useState } from 'react';
import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
interface SearchBarProps {
value: string;
onChangeText: (text: string) => void;
onSubmit: () => void;
placeholder?: string;
onFocus?: () => void;
onBlur?: () => void;
autoFocus?: boolean;
}
const SearchBar: React.FC<SearchBarProps> = ({
value,
onChangeText,
onSubmit,
placeholder = '搜索...',
onFocus,
onBlur,
autoFocus = false,
}) => {
const [isFocused, setIsFocused] = useState(false);
const handleFocus = () => {
setIsFocused(true);
onFocus?.();
};
const handleBlur = () => {
setIsFocused(false);
onBlur?.();
};
return (
<View style={[styles.container, isFocused && styles.containerFocused]}>
<View style={[styles.searchIconWrap, isFocused && styles.searchIconWrapFocused]}>
<MaterialCommunityIcons
name="magnify"
size={18}
color={isFocused ? colors.primary.main : colors.text.secondary}
/>
</View>
<TextInput
style={styles.input}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={colors.text.hint}
returnKeyType="search"
onSubmitEditing={onSubmit}
onFocus={handleFocus}
onBlur={handleBlur}
autoFocus={autoFocus}
/>
{value.length > 0 && (
<TouchableOpacity
onPress={() => onChangeText('')}
style={styles.clearButton}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name="close"
size={14}
color={colors.text.secondary}
/>
</TouchableOpacity>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderRadius: borderRadius.full,
paddingHorizontal: spacing.xs,
height: 46,
borderWidth: 1,
borderColor: '#E7E7E7',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.09,
shadowRadius: 6,
elevation: 2,
},
containerFocused: {
borderColor: `${colors.primary.main}66`,
shadowColor: colors.primary.main,
shadowOpacity: 0.18,
shadowRadius: 10,
elevation: 4,
},
searchIconWrap: {
width: 30,
height: 30,
marginLeft: spacing.xs,
marginRight: spacing.xs,
borderRadius: borderRadius.full,
backgroundColor: `${colors.text.secondary}12`,
alignItems: 'center',
justifyContent: 'center',
},
searchIconWrapFocused: {
backgroundColor: `${colors.primary.main}1A`,
},
input: {
flex: 1,
fontSize: fontSizes.md,
color: colors.text.primary,
paddingVertical: spacing.sm + 1,
paddingHorizontal: spacing.xs,
},
clearButton: {
width: 24,
height: 24,
marginHorizontal: spacing.xs,
borderRadius: borderRadius.full,
backgroundColor: `${colors.text.secondary}14`,
alignItems: 'center',
justifyContent: 'center',
},
});
export default SearchBar;

View File

@@ -0,0 +1,369 @@
/**
* SystemMessageItem 系统消息项组件
* 根据系统消息类型显示不同图标和内容
* 参考 QQ 10000 系统消息和微信服务通知样式
*/
import React from 'react';
import { View, TouchableOpacity, StyleSheet } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { colors, spacing, borderRadius } from '../../theme';
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
import Text from '../common/Text';
import Avatar from '../common/Avatar';
interface SystemMessageItemProps {
message: SystemMessageResponse;
onPress?: () => void;
onAvatarPress?: () => void; // 头像点击回调
onRequestAction?: (approve: boolean) => void;
requestActionLoading?: boolean;
}
// 系统消息类型到图标和颜色的映射
const getSystemMessageIcon = (
systemType: SystemMessageType
): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string } => {
switch (systemType) {
case 'like_post':
case 'like_comment':
case 'like_reply':
case 'favorite_post':
return {
icon: 'heart',
color: colors.error.main,
bgColor: colors.error.light + '20',
};
case 'comment':
return {
icon: 'comment',
color: colors.info.main,
bgColor: colors.info.light + '20',
};
case 'reply':
return {
icon: 'reply',
color: colors.success.main,
bgColor: colors.success.light + '20',
};
case 'follow':
return {
icon: 'account-plus',
color: '#9C27B0', // 紫色
bgColor: '#9C27B020',
};
case 'mention':
return {
icon: 'at',
color: colors.warning.main,
bgColor: colors.warning.light + '20',
};
case 'system':
return {
icon: 'cog',
color: colors.text.secondary,
bgColor: colors.background.disabled,
};
case 'announcement':
case 'announce':
return {
icon: 'bullhorn',
color: colors.primary.main,
bgColor: colors.primary.light + '20',
};
case 'group_invite':
return {
icon: 'account-multiple-plus',
color: colors.primary.main,
bgColor: colors.primary.light + '20',
};
case 'group_join_apply':
return {
icon: 'account-clock',
color: colors.warning.main,
bgColor: colors.warning.light + '20',
};
case 'group_join_approved':
return {
icon: 'check-circle',
color: colors.success.main,
bgColor: colors.success.light + '20',
};
case 'group_join_rejected':
return {
icon: 'close-circle',
color: colors.error.main,
bgColor: colors.error.light + '20',
};
default:
return {
icon: 'bell',
color: colors.text.secondary,
bgColor: colors.background.disabled,
};
}
};
// 获取系统消息标题
const getSystemMessageTitle = (message: SystemMessageResponse): string => {
const { system_type, extra_data } = message;
// 兼容后端返回的 actor_name 和 operator_name
const operatorName = extra_data?.actor_name || extra_data?.operator_name;
const groupName = extra_data?.group_name || extra_data?.target_title;
switch (system_type) {
case 'like_post':
return operatorName ? `${operatorName} 赞了你的帖子` : '有人赞了你的帖子';
case 'like_comment':
return operatorName ? `${operatorName} 赞了你的评论` : '有人赞了你的评论';
case 'like_reply':
return operatorName ? `${operatorName} 赞了你的回复` : '有人赞了你的回复';
case 'favorite_post':
return operatorName ? `${operatorName} 收藏了你的帖子` : '有人收藏了你的帖子';
case 'comment':
return operatorName ? `${operatorName} 评论了你的帖子` : '有人评论了你的帖子';
case 'reply':
return operatorName ? `${operatorName} 回复了你` : '有人回复了你';
case 'follow':
return operatorName ? `${operatorName} 关注了你` : '有人关注了你';
case 'mention':
return operatorName ? `${operatorName} @提到了你` : '有人@提到了你';
case 'system':
return '系统通知';
case 'announcement':
return '公告';
case 'group_invite':
return operatorName
? `${operatorName} 邀请加入群聊 ${groupName || ''}`.trim()
: `收到群邀请${groupName ? `${groupName}` : ''}`;
case 'group_join_apply':
if (extra_data?.request_status === 'accepted') {
return operatorName ? `${operatorName} 已同意` : '该请求已同意';
}
if (extra_data?.request_status === 'rejected') {
return operatorName ? `${operatorName} 已拒绝` : '该请求已拒绝';
}
return operatorName
? `${operatorName} 申请加入群聊 ${groupName || ''}`.trim()
: `收到加群申请${groupName ? `${groupName}` : ''}`;
case 'group_join_approved':
return '加群申请已通过';
case 'group_join_rejected':
return '加群申请被拒绝';
default:
return '通知';
}
};
const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
message,
onPress,
onAvatarPress,
onRequestAction,
requestActionLoading = false,
}) => {
// 格式化时间
const formatTime = (dateString: string): string => {
try {
return formatDistanceToNow(new Date(dateString), {
addSuffix: true,
locale: zhCN,
});
} catch {
return '';
}
};
const { icon, color, bgColor } = getSystemMessageIcon(message.system_type);
const title = getSystemMessageTitle(message);
const { extra_data } = message;
// 兼容后端返回的 actor_name 和 operator_name
const operatorName = extra_data?.actor_name || extra_data?.operator_name;
const operatorAvatar = extra_data?.avatar_url || extra_data?.operator_avatar;
const groupAvatar = extra_data?.group_avatar;
const requestStatus = extra_data?.request_status;
const isActionable =
(message.system_type === 'group_invite' || message.system_type === 'group_join_apply') &&
requestStatus === 'pending' &&
!!onRequestAction;
// 判断是否显示操作者头像
const showOperatorAvatar = ['follow', 'like_post', 'like_comment', 'like_reply', 'favorite_post', 'comment', 'reply', 'mention', 'group_join_apply'].includes(
message.system_type
);
const showGroupAvatar = message.system_type === 'group_invite';
return (
<TouchableOpacity
style={styles.container}
onPress={onPress}
activeOpacity={onPress ? 0.7 : 1}
disabled={!onPress}
>
{/* 图标/头像区域 */}
<View style={styles.iconContainer}>
{showGroupAvatar ? (
<Avatar
source={groupAvatar || ''}
size={44}
name={extra_data?.group_name || '群聊'}
/>
) : showOperatorAvatar ? (
<Avatar
source={operatorAvatar || ''}
size={44}
name={operatorName}
onPress={onAvatarPress}
/>
) : (
<View style={[styles.iconWrapper, { backgroundColor: bgColor }]}>
<MaterialCommunityIcons name={icon} size={22} color={color} />
</View>
)}
</View>
{/* 内容区域 */}
<View style={styles.content}>
{/* 标题行 */}
<View style={styles.titleRow}>
<Text variant="body" style={styles.title} numberOfLines={1}>
{title}
</Text>
<Text variant="caption" color={colors.text.secondary}>
{formatTime(message.created_at)}
</Text>
</View>
{/* 消息内容 */}
<Text variant="caption" color={colors.text.secondary} numberOfLines={2} style={styles.messageContent}>
{message.content}
</Text>
{/* 附加信息 - 优先显示 target_title图标根据 target_type 区分 */}
{(extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview) &&
!['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected'].includes(message.system_type) &&
(() => {
const isCommentType = ['comment', 'reply'].includes(extra_data?.target_type ?? '');
const previewText = extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview;
const iconName = isCommentType ? 'comment-outline' : 'file-document-outline';
return (
<View style={styles.extraInfo}>
<MaterialCommunityIcons name={iconName} size={12} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} numberOfLines={1} style={styles.extraText}>
{previewText}
</Text>
</View>
);
})()}
{isActionable && (
<View style={styles.actionsRow}>
<TouchableOpacity
style={[styles.actionBtn, styles.rejectBtn]}
onPress={() => onRequestAction?.(false)}
disabled={requestActionLoading}
>
<Text variant="caption" color={colors.error.main}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, styles.approveBtn]}
onPress={() => onRequestAction?.(true)}
disabled={requestActionLoading}
>
<Text variant="caption" color={colors.success.main}></Text>
</TouchableOpacity>
</View>
)}
</View>
{/* 右侧箭头(如果有跳转) */}
{onPress && (
<View style={styles.arrowContainer}>
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
</View>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: spacing.lg,
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
iconContainer: {
marginRight: spacing.md,
},
iconWrapper: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
content: {
flex: 1,
justifyContent: 'center',
},
titleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.xs,
},
title: {
fontWeight: '600',
flex: 1,
marginRight: spacing.sm,
},
messageContent: {
lineHeight: 18,
},
extraInfo: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.xs,
backgroundColor: colors.background.default,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: borderRadius.sm,
alignSelf: 'flex-start',
},
extraText: {
marginLeft: spacing.xs,
flex: 1,
},
actionsRow: {
flexDirection: 'row',
marginTop: spacing.sm,
},
actionBtn: {
paddingVertical: spacing.xs,
paddingHorizontal: spacing.md,
borderRadius: borderRadius.sm,
borderWidth: 1,
marginRight: spacing.sm,
},
rejectBtn: {
borderColor: colors.error.light,
backgroundColor: colors.error.light + '18',
},
approveBtn: {
borderColor: colors.success.light,
backgroundColor: colors.success.light + '18',
},
arrowContainer: {
marginLeft: spacing.sm,
justifyContent: 'center',
alignItems: 'center',
height: 20,
},
});
export default SystemMessageItem;

View File

@@ -0,0 +1,347 @@
/**
* TabBar 标签栏组件 - 美化版
* 用于切换不同标签页,支持多种样式变体
* 新增胶囊式、分段式等现代设计风格
*/
import React, { ReactNode } from 'react';
import { View, TouchableOpacity, StyleSheet, ScrollView, Animated } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import Text from '../common/Text';
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern';
interface TabBarProps {
tabs: string[];
activeIndex: number;
onTabChange: (index: number) => void;
scrollable?: boolean;
rightContent?: ReactNode;
variant?: TabBarVariant;
icons?: string[];
}
const TabBar: React.FC<TabBarProps> = ({
tabs,
activeIndex,
onTabChange,
scrollable = false,
rightContent,
variant = 'default',
icons,
}) => {
const renderTabs = () => {
return tabs.map((tab, index) => {
const isActive = index === activeIndex;
const icon = icons?.[index];
if (variant === 'modern') {
return (
<TouchableOpacity
key={index}
style={[styles.modernTab, isActive && styles.modernTabActive]}
onPress={() => onTabChange(index)}
activeOpacity={0.85}
>
<View style={styles.modernTabContent}>
{icon && (
<MaterialCommunityIcons
name={icon as any}
size={18}
color={isActive ? colors.primary.main : colors.text.secondary}
style={styles.modernTabIcon}
/>
)}
<Text
variant="body"
color={isActive ? colors.primary.main : colors.text.secondary}
style={isActive ? [styles.modernTabText, styles.modernTabTextActive] : styles.modernTabText}
>
{tab}
</Text>
</View>
{isActive && <View style={styles.modernTabIndicator} />}
</TouchableOpacity>
);
}
if (variant === 'pill') {
return (
<TouchableOpacity
key={index}
style={[styles.pillTab, isActive && styles.pillTabActive]}
onPress={() => onTabChange(index)}
activeOpacity={0.8}
>
<Text
variant="body"
color={isActive ? colors.text.inverse : colors.text.secondary}
style={styles.pillTabText}
>
{tab}
</Text>
</TouchableOpacity>
);
}
if (variant === 'segmented') {
const isFirst = index === 0;
const isLast = index === tabs.length - 1;
return (
<TouchableOpacity
key={index}
style={[
styles.segmentedTab,
isActive && styles.segmentedTabActive,
isFirst && styles.segmentedTabFirst,
isLast && styles.segmentedTabLast,
]}
onPress={() => onTabChange(index)}
activeOpacity={0.8}
>
<View style={styles.segmentedTabContent}>
{icon && (
<MaterialCommunityIcons
name={icon as any}
size={16}
color={isActive ? colors.primary.main : colors.text.secondary}
style={styles.segmentedTabIcon}
/>
)}
<Text
variant="body"
color={isActive ? colors.primary.main : colors.text.secondary}
style={styles.segmentedTabText}
>
{tab}
</Text>
</View>
</TouchableOpacity>
);
}
// default variant
return (
<TouchableOpacity
key={index}
style={[styles.tab, isActive && styles.activeTab]}
onPress={() => onTabChange(index)}
activeOpacity={0.7}
>
<Text
variant="body"
color={isActive ? colors.primary.main : colors.text.secondary}
style={isActive ? [styles.tabText, styles.activeTabText] : styles.tabText}
>
{tab}
</Text>
{isActive && <View style={styles.activeIndicator} />}
</TouchableOpacity>
);
});
};
const getContainerStyle = () => {
switch (variant) {
case 'pill':
return styles.pillContainer;
case 'segmented':
return styles.segmentedContainer;
case 'modern':
return styles.modernContainer;
default:
return styles.container;
}
};
if (scrollable) {
return (
<View style={getContainerStyle()}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scrollableContainer}
>
{renderTabs()}
</ScrollView>
{rightContent && <View style={styles.rightContent}>{rightContent}</View>}
</View>
);
}
return (
<View style={getContainerStyle()}>
{renderTabs()}
{rightContent && <View style={styles.rightContent}>{rightContent}</View>}
</View>
);
};
const styles = StyleSheet.create({
// Default variant
container: {
flexDirection: 'row',
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
alignItems: 'center',
paddingRight: spacing.xs,
},
scrollableContainer: {
flexDirection: 'row',
paddingHorizontal: spacing.md,
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
flex: 1,
},
tab: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.md,
position: 'relative',
},
activeTab: {
// 激活状态样式
},
tabText: {
fontWeight: '500',
},
activeTabText: {
fontWeight: '600',
},
activeIndicator: {
position: 'absolute',
bottom: 0,
left: '25%',
right: '25%',
height: 3,
backgroundColor: colors.primary.main,
borderTopLeftRadius: borderRadius.sm,
borderTopRightRadius: borderRadius.sm,
},
rightContent: {
paddingLeft: spacing.sm,
},
// Pill variant
pillContainer: {
flexDirection: 'row',
backgroundColor: colors.background.default,
padding: spacing.sm,
gap: spacing.sm,
},
pillTab: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: borderRadius.lg,
backgroundColor: colors.background.paper,
},
pillTabActive: {
backgroundColor: colors.primary.main,
},
pillTabText: {
fontWeight: '600',
},
// Segmented variant
segmentedContainer: {
flexDirection: 'row',
backgroundColor: colors.background.default,
padding: spacing.xs,
marginHorizontal: spacing.md,
marginVertical: spacing.sm,
borderRadius: borderRadius.lg,
},
segmentedTab: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.sm,
backgroundColor: 'transparent',
},
segmentedTabActive: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.md,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
segmentedTabFirst: {
borderTopLeftRadius: borderRadius.md,
borderBottomLeftRadius: borderRadius.md,
},
segmentedTabLast: {
borderTopRightRadius: borderRadius.md,
borderBottomRightRadius: borderRadius.md,
},
segmentedTabText: {
fontWeight: '600',
},
segmentedTabContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
segmentedTabIcon: {
marginRight: spacing.xs,
},
// Modern variant - 现代化标签栏
modernContainer: {
flexDirection: 'row',
backgroundColor: colors.background.paper,
borderRadius: borderRadius.xl,
marginHorizontal: spacing.lg,
marginVertical: spacing.md,
padding: spacing.xs,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 3,
},
modernTab: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.sm,
borderRadius: borderRadius.lg,
position: 'relative',
},
modernTabActive: {
backgroundColor: colors.primary.main + '15', // 10% opacity
},
modernTabContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
modernTabIcon: {
marginRight: spacing.xs,
},
modernTabText: {
fontWeight: '500',
fontSize: fontSizes.md,
},
modernTabTextActive: {
fontWeight: '700',
},
modernTabIndicator: {
position: 'absolute',
bottom: 4,
width: 20,
height: 3,
backgroundColor: colors.primary.main,
borderRadius: borderRadius.full,
},
});
export default TabBar;

View File

@@ -0,0 +1,541 @@
/**
* UserProfileHeader 用户资料头部组件 - 美化版(响应式适配)
* 显示用户封面、头像、昵称、简介、关注/粉丝数
* 采用现代卡片式设计,渐变封面,悬浮头像
* 支持互关状态显示
* 在宽屏下显示更大的头像和封面
*/
import React from 'react';
import {
View,
Image,
TouchableOpacity,
StyleSheet,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
import { User } from '../../types';
import Text from '../common/Text';
import Button from '../common/Button';
import Avatar from '../common/Avatar';
import { useResponsive } from '../../hooks';
interface UserProfileHeaderProps {
user: User;
isCurrentUser?: boolean;
onFollow: () => void;
onSettings?: () => void;
onEditProfile?: () => void;
onMessage?: () => void;
onMore?: () => void; // 点击更多按钮
onPostsPress?: () => void; // 点击帖子数(可选)
onFollowingPress?: () => void; // 点击关注数
onFollowersPress?: () => void; // 点击粉丝数
onAvatarPress?: () => void; // 点击头像编辑按钮
}
const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
user,
isCurrentUser = false,
onFollow,
onSettings,
onEditProfile,
onMessage,
onMore,
onPostsPress,
onFollowingPress,
onFollowersPress,
onAvatarPress,
}) => {
// 响应式布局
const { isWideScreen, isDesktop, width } = useResponsive();
// 格式化数字
const formatCount = (count: number | undefined): string => {
if (count === undefined || count === null) {
return '0';
}
if (count >= 10000) {
return `${(count / 10000).toFixed(1)}w`;
}
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}k`;
}
return count.toString();
};
// 获取帖子数量
const getPostsCount = (): number => {
return user.posts_count ?? 0;
};
// 获取粉丝数量
const getFollowersCount = (): number => {
return user.followers_count ?? 0;
};
// 获取关注数量
const getFollowingCount = (): number => {
return user.following_count ?? 0;
};
// 检查是否关注
const getIsFollowing = (): boolean => {
return user.is_following ?? false;
};
// 检查对方是否关注了我
const getIsFollowingMe = (): boolean => {
return user.is_following_me ?? false;
};
// 获取按钮配置类似B站的互关逻辑
const getButtonConfig = (): { title: string; variant: 'primary' | 'outline'; icon?: string } => {
const isFollowing = getIsFollowing();
const isFollowingMe = getIsFollowingMe();
if (isFollowing && isFollowingMe) {
// 已互关
return { title: '互相关注', variant: 'outline', icon: 'account-check' };
} else if (isFollowing) {
// 已关注但对方未回关
return { title: '已关注', variant: 'outline', icon: 'check' };
} else if (isFollowingMe) {
// 对方关注了我,但我没关注对方 - 显示回关
return { title: '回关', variant: 'primary', icon: 'plus' };
} else {
// 互不关注
return { title: '关注', variant: 'primary', icon: 'plus' };
}
};
// 根据屏幕尺寸计算封面高度
const coverHeight = isDesktop ? 240 : isWideScreen ? 200 : (width * 9) / 16;
// 根据屏幕尺寸计算头像大小
const avatarSize = isDesktop ? 120 : isWideScreen ? 100 : 90;
const renderStatItem = ({
value,
label,
onPress,
}: {
value: string;
label: string;
onPress?: () => void;
}) => {
const content = (
<View style={styles.statContent}>
<Text variant="h3" style={styles.statNumber}>{value}</Text>
<Text variant="caption" color={colors.text.secondary} style={styles.statLabel}>
{label}
</Text>
</View>
);
if (onPress) {
return (
<TouchableOpacity
style={[styles.statItem, styles.statItemTouchable]}
onPress={onPress}
activeOpacity={0.75}
>
{content}
</TouchableOpacity>
);
}
return <View style={styles.statItem}>{content}</View>;
};
return (
<View style={styles.container}>
{/* 渐变封面背景 */}
<View style={[styles.coverContainer, { height: coverHeight }]}>
<View style={styles.coverTouchable}>
{user.cover_url ? (
<Image
source={{ uri: user.cover_url }}
style={styles.coverImage}
resizeMode="cover"
/>
) : (
<LinearGradient
colors={['#FF8F66', '#FF6B35', '#E5521D']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.gradient}
/>
)}
</View>
{/* 设置按钮 */}
{isCurrentUser && onSettings && (
<TouchableOpacity style={styles.settingsButton} onPress={onSettings}>
<MaterialCommunityIcons name="cog-outline" size={22} color={colors.text.inverse} />
</TouchableOpacity>
)}
{/* 装饰性波浪 */}
<View style={styles.waveDecoration}>
<View style={styles.wave} />
</View>
</View>
{/* 用户信息卡片 */}
<View style={[
styles.profileCard,
isWideScreen && styles.profileCardWide,
]}>
{/* 悬浮头像 */}
<View style={[
styles.avatarWrapper,
isWideScreen && styles.avatarWrapperWide,
]}>
<View style={styles.avatarContainer}>
<Avatar
source={user.avatar}
size={avatarSize}
name={user.nickname}
/>
{isCurrentUser && onAvatarPress && (
<TouchableOpacity style={styles.editAvatarButton} onPress={onAvatarPress}>
<MaterialCommunityIcons name="camera" size={14} color={colors.text.inverse} />
</TouchableOpacity>
)}
</View>
</View>
{/* 用户名和简介 */}
<View style={styles.userInfo}>
<Text variant="h2" style={[
styles.nickname,
isWideScreen ? styles.nicknameWide : {},
]}>
{user.nickname}
</Text>
<Text variant="caption" color={colors.text.secondary} style={styles.username}>
@{user.username}
</Text>
{user.bio ? (
<Text variant="body" color={colors.text.secondary} style={[
styles.bio,
isWideScreen ? styles.bioWide : {},
]}>
{user.bio}
</Text>
) : (
<Text variant="body" color={colors.text.hint} style={styles.bioPlaceholder}>
~
</Text>
)}
</View>
{/* 个人信息标签 */}
<View style={styles.metaInfo}>
{user.location && (
<View style={styles.metaTag}>
<MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.primary.main} />
<Text variant="caption" color={colors.primary.main} style={styles.metaTagText}>
{user.location}
</Text>
</View>
)}
{user.website && (
<View style={styles.metaTag}>
<MaterialCommunityIcons name="link-variant" size={12} color={colors.info.main} />
<Text variant="caption" color={colors.info.main} style={styles.metaTagText}>
{user.website.replace(/^https?:\/\//, '')}
</Text>
</View>
)}
<View style={styles.metaTag}>
<MaterialCommunityIcons name="calendar-outline" size={12} color={colors.text.secondary} />
<Text variant="caption" color={colors.text.secondary} style={styles.metaTagText}>
{new Date(user.created_at || Date.now()).getFullYear()}
</Text>
</View>
</View>
{/* 统计数据 - 卡片式 */}
<View style={[
styles.statsCard,
isWideScreen && styles.statsCardWide,
]}>
{renderStatItem({
value: formatCount(getPostsCount()),
label: '帖子',
onPress: onPostsPress,
})}
<View style={styles.statDivider} />
{renderStatItem({
value: formatCount(getFollowingCount()),
label: '关注',
onPress: onFollowingPress,
})}
<View style={styles.statDivider} />
{renderStatItem({
value: formatCount(getFollowersCount()),
label: '粉丝',
onPress: onFollowersPress,
})}
</View>
{/* 操作按钮 */}
<View style={styles.actionButtons}>
{isCurrentUser ? (
<View style={styles.buttonRow} />
) : (
<View style={StyleSheet.flatten([
styles.buttonRow,
isWideScreen && styles.buttonRowWide,
])}>
<Button
title={getButtonConfig().title}
onPress={onFollow}
variant={getButtonConfig().variant}
style={StyleSheet.flatten([
styles.followButton,
isWideScreen && styles.followButtonWide,
])}
icon={getButtonConfig().icon}
/>
<TouchableOpacity style={styles.messageButton} onPress={onMessage}>
<MaterialCommunityIcons name="message-text-outline" size={20} color={colors.primary.main} />
</TouchableOpacity>
<TouchableOpacity style={styles.moreButton} onPress={onMore}>
<MaterialCommunityIcons name="dots-horizontal" size={24} color={colors.text.secondary} />
</TouchableOpacity>
</View>
)}
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: colors.background.default,
},
coverContainer: {
position: 'relative',
overflow: 'hidden',
},
coverTouchable: {
width: '100%',
height: '100%',
},
coverImage: {
width: '100%',
height: '100%',
},
gradient: {
width: '100%',
height: '100%',
},
settingsButton: {
position: 'absolute',
top: spacing.lg,
right: spacing.lg,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
justifyContent: 'center',
alignItems: 'center',
},
waveDecoration: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 40,
},
wave: {
width: '100%',
height: '100%',
backgroundColor: colors.background.default,
borderTopLeftRadius: 30,
borderTopRightRadius: 30,
},
profileCard: {
backgroundColor: colors.background.paper,
marginHorizontal: spacing.md,
marginTop: -50,
borderRadius: borderRadius.xl,
padding: spacing.lg,
...shadows.md,
},
profileCardWide: {
marginHorizontal: spacing.lg,
marginTop: -60,
padding: spacing.xl,
},
avatarWrapper: {
alignItems: 'center',
marginTop: -60,
marginBottom: spacing.md,
},
avatarWrapperWide: {
marginTop: -80,
marginBottom: spacing.lg,
},
avatarContainer: {
position: 'relative',
padding: 4,
backgroundColor: colors.background.paper,
borderRadius: 50,
},
editAvatarButton: {
position: 'absolute',
bottom: 0,
right: 0,
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.primary.main,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.background.paper,
},
userInfo: {
alignItems: 'center',
marginBottom: spacing.md,
},
nickname: {
marginBottom: spacing.xs,
fontWeight: '700',
},
nicknameWide: {
fontSize: fontSizes['3xl'],
},
username: {
marginBottom: spacing.sm,
},
bio: {
textAlign: 'center',
marginTop: spacing.sm,
lineHeight: 20,
},
bioWide: {
fontSize: fontSizes.md,
lineHeight: 24,
maxWidth: 600,
},
bioPlaceholder: {
textAlign: 'center',
marginTop: spacing.sm,
fontStyle: 'italic',
},
metaInfo: {
flexDirection: 'row',
justifyContent: 'center',
flexWrap: 'wrap',
marginBottom: spacing.md,
gap: spacing.sm,
},
metaTag: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: borderRadius.md,
},
metaTagText: {
marginLeft: spacing.xs,
fontSize: fontSizes.xs,
},
statsCard: {
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center',
backgroundColor: 'transparent',
paddingHorizontal: spacing.xs,
paddingVertical: spacing.xs,
marginBottom: spacing.md,
},
statsCardWide: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
marginBottom: spacing.lg,
},
statItem: {
flex: 1,
minHeight: 58,
justifyContent: 'center',
},
statItemTouchable: {
borderRadius: borderRadius.md,
},
statContent: {
alignItems: 'center',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.xs,
},
statNumber: {
fontWeight: '600',
marginBottom: 0,
},
statLabel: {
fontSize: fontSizes.xs,
marginTop: 2,
},
statDivider: {
width: 1,
height: 24,
backgroundColor: colors.divider + '55',
},
actionButtons: {
marginTop: spacing.sm,
},
buttonRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
buttonRowWide: {
justifyContent: 'center',
gap: spacing.md,
},
editButton: {
flex: 1,
},
followButton: {
flex: 1,
},
followButtonWide: {
flex: 0,
minWidth: 120,
},
messageButton: {
width: 44,
height: 44,
borderRadius: borderRadius.md,
backgroundColor: colors.primary.light + '20',
justifyContent: 'center',
alignItems: 'center',
},
moreButton: {
width: 44,
height: 44,
borderRadius: borderRadius.md,
backgroundColor: colors.background.default,
justifyContent: 'center',
alignItems: 'center',
},
settingsButtonOnly: {
alignSelf: 'center',
padding: spacing.sm,
},
});
// 使用 React.memo 避免不必要的重新渲染
const MemoizedUserProfileHeader = React.memo(UserProfileHeader);
export default MemoizedUserProfileHeader;

View File

@@ -0,0 +1,370 @@
/**
* VoteCard 投票卡片组件
* 显示投票选项列表,支持投票和取消投票
* 风格与现代整体UI保持一致
*/
import React, { useCallback } from 'react';
import {
View,
TouchableOpacity,
StyleSheet,
Animated,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { VoteOptionDTO } from '../../types';
import Text from '../common/Text';
interface VoteCardProps {
postId?: string;
options: VoteOptionDTO[];
totalVotes: number;
hasVoted: boolean;
votedOptionId?: string;
onVote: (optionId: string) => void;
onUnvote: () => void;
isLoading?: boolean;
compact?: boolean;
}
const VoteCard: React.FC<VoteCardProps> = ({
options,
totalVotes,
hasVoted,
votedOptionId,
onVote,
onUnvote,
isLoading = false,
compact = false,
}) => {
// 动画值
const progressAnim = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
Animated.timing(progressAnim, {
toValue: 1,
duration: 400,
useNativeDriver: false,
}).start();
}, [hasVoted, totalVotes]);
// 计算百分比
const calculatePercentage = useCallback((votes: number): number => {
if (totalVotes === 0) return 0;
return Math.round((votes / totalVotes) * 100);
}, [totalVotes]);
// 格式化票数
const formatVoteCount = useCallback((count: number): string => {
if (count >= 10000) {
return (count / 10000).toFixed(1) + '万';
}
if (count >= 1000) {
return (count / 1000).toFixed(1) + 'k';
}
return count.toString();
}, []);
// 处理投票
const handleVote = useCallback((optionId: string) => {
if (isLoading || hasVoted) return;
onVote(optionId);
}, [isLoading, hasVoted, onVote]);
// 处理取消投票
const handleUnvote = useCallback(() => {
if (isLoading || !hasVoted) return;
onUnvote();
}, [isLoading, hasVoted, onUnvote]);
// 渲染投票选项
const renderOption = useCallback((option: VoteOptionDTO, index: number) => {
const isVotedOption = votedOptionId === option.id;
const percentage = calculatePercentage(option.votes_count);
const showResults = hasVoted;
return (
<View key={option.id} style={styles.optionContainer}>
{/* 进度条背景 */}
{showResults && (
<Animated.View
style={[
styles.progressBar,
{
width: progressAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0%', `${percentage}%`],
}),
backgroundColor: isVotedOption
? colors.primary.light + '40'
: colors.background.disabled,
},
]}
/>
)}
{/* 选项按钮 */}
<TouchableOpacity
style={[
styles.optionButton,
isVotedOption && styles.optionButtonVoted,
]}
onPress={() => handleVote(option.id)}
disabled={isLoading || hasVoted}
activeOpacity={hasVoted ? 1 : 0.8}
>
{/* 选择指示器 */}
<View style={[
styles.optionIndicator,
isVotedOption && styles.optionIndicatorVoted,
]}>
{isVotedOption && (
<MaterialCommunityIcons
name="check"
size={12}
color={colors.primary.contrast}
/>
)}
</View>
{/* 选项内容 */}
<Text
variant={compact ? 'caption' : 'body'}
style={compact ? [styles.optionText, styles.optionTextCompact] : styles.optionText}
numberOfLines={compact ? 1 : 2}
>
{option.content}
</Text>
{/* 投票结果 */}
{showResults && (
<View style={styles.resultContainer}>
<Text
variant="caption"
style={isVotedOption ? [styles.percentage, styles.percentageVoted] : styles.percentage}
>
{percentage}%
</Text>
</View>
)}
</TouchableOpacity>
</View>
);
}, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact]);
// 排序后的选项(已投票的排在前面)
const sortedOptions = React.useMemo(() => {
if (!hasVoted) return options;
return [...options].sort((a, b) => {
if (a.id === votedOptionId) return -1;
if (b.id === votedOptionId) return 1;
return b.votes_count - a.votes_count;
});
}, [options, hasVoted, votedOptionId]);
return (
<View style={[styles.container, compact && styles.containerCompact]}>
{/* 投票图标和标题 */}
<View style={styles.header}>
<View style={styles.headerIcon}>
<MaterialCommunityIcons
name="vote"
size={compact ? 14 : 16}
color={colors.primary.main}
/>
</View>
<Text variant={compact ? 'caption' : 'body'} style={styles.headerTitle}>
</Text>
</View>
{/* 投票选项列表 */}
<View style={styles.optionsList}>
{sortedOptions.map((option, index) => renderOption(option, index))}
</View>
{/* 底部信息栏 */}
<View style={styles.footer}>
<View style={styles.footerLeft}>
<MaterialCommunityIcons
name="account-group-outline"
size={14}
color={colors.text.hint}
/>
<Text variant="caption" color={colors.text.hint} style={styles.footerText}>
{formatVoteCount(totalVotes)}
</Text>
</View>
{hasVoted && (
<TouchableOpacity
style={styles.unvoteButton}
onPress={handleUnvote}
disabled={isLoading}
>
<MaterialCommunityIcons
name="refresh"
size={14}
color={colors.text.hint}
/>
<Text variant="caption" color={colors.text.hint} style={styles.unvoteText}>
</Text>
</TouchableOpacity>
)}
</View>
{/* 加载遮罩 */}
{isLoading && (
<View style={styles.loadingOverlay}>
<MaterialCommunityIcons
name="loading"
size={24}
color={colors.primary.main}
/>
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.md,
marginVertical: spacing.sm,
borderWidth: 1,
borderColor: colors.divider,
},
containerCompact: {
padding: spacing.sm,
marginVertical: spacing.xs,
},
header: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
},
headerIcon: {
width: 24,
height: 24,
borderRadius: borderRadius.sm,
backgroundColor: colors.primary.light + '20',
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.sm,
},
headerTitle: {
fontWeight: '600',
color: colors.text.primary,
},
optionsList: {
gap: spacing.sm,
},
optionContainer: {
position: 'relative',
borderRadius: borderRadius.md,
overflow: 'hidden',
},
progressBar: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
borderRadius: borderRadius.md,
},
optionButton: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: borderRadius.md,
backgroundColor: colors.background.default,
minHeight: 44,
borderWidth: 1,
borderColor: 'transparent',
},
optionButtonVoted: {
borderColor: colors.primary.main,
backgroundColor: 'transparent',
},
optionIndicator: {
width: 18,
height: 18,
borderRadius: borderRadius.full,
borderWidth: 2,
borderColor: colors.divider,
marginRight: spacing.sm,
justifyContent: 'center',
alignItems: 'center',
},
optionIndicatorVoted: {
backgroundColor: colors.primary.main,
borderColor: colors.primary.main,
},
optionText: {
flex: 1,
fontSize: fontSizes.md,
color: colors.text.primary,
lineHeight: 20,
},
optionTextCompact: {
fontSize: fontSizes.sm,
lineHeight: 18,
},
resultContainer: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: spacing.sm,
minWidth: 40,
justifyContent: 'flex-end',
},
percentage: {
color: colors.text.secondary,
fontWeight: '500',
},
percentageVoted: {
color: colors.primary.main,
fontWeight: '700',
},
footer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: spacing.md,
paddingTop: spacing.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.divider,
},
footerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
footerText: {
fontSize: fontSizes.sm,
},
unvoteButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
borderRadius: borderRadius.sm,
backgroundColor: colors.background.default,
},
unvoteText: {
fontSize: fontSizes.sm,
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: colors.background.paper + 'CC',
justifyContent: 'center',
alignItems: 'center',
borderRadius: borderRadius.lg,
},
});
export default VoteCard;

View File

@@ -0,0 +1,203 @@
/**
* VoteEditor 投票编辑器组件
* 用于创建帖子时编辑投票选项
*/
import React, { useCallback } from 'react';
import {
View,
TouchableOpacity,
StyleSheet,
TextInput,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import Text from '../common/Text';
interface VoteEditorProps {
options: string[];
onAddOption: () => void;
onRemoveOption: (index: number) => void;
onUpdateOption: (index: number, value: string) => void;
maxOptions?: number;
minOptions?: number;
maxLength?: number;
}
const VoteEditor: React.FC<VoteEditorProps> = ({
options,
onAddOption,
onRemoveOption,
onUpdateOption,
maxOptions = 10,
minOptions = 2,
maxLength = 50,
}) => {
const validOptionsCount = options.filter(opt => opt.trim() !== '').length;
const canAddOption = options.length < maxOptions;
const canRemoveOption = options.length > minOptions;
return (
<View style={styles.container}>
{/* 标题栏 */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<MaterialCommunityIcons
name="vote"
size={18}
color={colors.primary.main}
/>
<Text variant="body" style={styles.headerTitle}>
</Text>
</View>
<Text variant="caption" color={colors.text.hint}>
{validOptionsCount}/{maxOptions}
</Text>
</View>
{/* 选项列表 */}
<View style={styles.optionsContainer}>
{options.map((option, index) => (
<View key={index} style={styles.optionRow}>
<View style={styles.optionIndex}>
<Text variant="caption" color={colors.text.hint}>
{index + 1}
</Text>
</View>
<TextInput
style={styles.optionInput}
value={option}
onChangeText={(text) => onUpdateOption(index, text)}
placeholder={`输入选项 ${index + 1}`}
placeholderTextColor={colors.text.hint}
maxLength={maxLength}
returnKeyType="done"
/>
{canRemoveOption && (
<TouchableOpacity
style={styles.removeButton}
onPress={() => onRemoveOption(index)}
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
>
<MaterialCommunityIcons
name="close-circle"
size={20}
color={colors.text.hint}
/>
</TouchableOpacity>
)}
{!canRemoveOption && options.length <= minOptions && (
<View style={styles.removeButtonPlaceholder} />
)}
</View>
))}
{/* 添加选项按钮 */}
{canAddOption && (
<TouchableOpacity
style={styles.addOptionButton}
onPress={onAddOption}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name="plus-circle-outline"
size={20}
color={colors.primary.main}
/>
<Text variant="body" color={colors.primary.main} style={styles.addOptionText}>
</Text>
</TouchableOpacity>
)}
</View>
{/* 提示信息 */}
<View style={styles.hintContainer}>
<Text variant="caption" color={colors.text.hint}>
{minOptions}
</Text>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: colors.background.default,
borderRadius: borderRadius.lg,
marginHorizontal: spacing.lg,
marginTop: spacing.md,
marginBottom: spacing.md,
padding: spacing.md,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.md,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
headerTitle: {
fontWeight: '600',
color: colors.text.primary,
},
optionsContainer: {
gap: spacing.sm,
},
optionRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
optionIndex: {
width: 20,
height: 20,
borderRadius: borderRadius.full,
backgroundColor: colors.background.disabled,
justifyContent: 'center',
alignItems: 'center',
},
optionInput: {
flex: 1,
fontSize: fontSizes.md,
color: colors.text.primary,
backgroundColor: colors.background.paper,
borderRadius: borderRadius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderWidth: 1,
borderColor: colors.divider,
height: 44,
},
removeButton: {
padding: spacing.xs,
},
removeButtonPlaceholder: {
width: 28,
},
addOptionButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
marginTop: spacing.xs,
},
addOptionText: {
fontWeight: '500',
},
hintContainer: {
marginTop: spacing.md,
paddingTop: spacing.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.divider,
alignItems: 'center',
},
});
export default VoteEditor;

View File

@@ -0,0 +1,109 @@
/**
* VotePreview 投票预览组件
* 用于帖子列表中显示投票预览,类似微博风格
*/
import React from 'react';
import {
View,
TouchableOpacity,
StyleSheet,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import Text from '../common/Text';
interface VotePreviewProps {
totalVotes?: number;
optionsCount?: number;
onPress?: () => void;
}
const VotePreview: React.FC<VotePreviewProps> = ({
totalVotes = 0,
optionsCount = 0,
onPress,
}) => {
// 格式化票数
const formatVoteCount = (count: number): string => {
if (count >= 10000) {
return (count / 10000).toFixed(1) + '万';
}
if (count >= 1000) {
return (count / 1000).toFixed(1) + 'k';
}
return count.toString();
};
// 判断是否有真实数据
const hasData = totalVotes > 0 || optionsCount > 0;
return (
<TouchableOpacity
style={styles.container}
onPress={onPress}
activeOpacity={0.8}
>
<View style={styles.iconContainer}>
<MaterialCommunityIcons
name="vote"
size={18}
color={colors.primary.main}
/>
</View>
<View style={styles.content}>
<Text style={styles.title}>
</Text>
<Text style={styles.subtitle}>
{hasData
? `${optionsCount} 个选项 · ${formatVoteCount(totalVotes)} 人参与`
: '点击查看详情'
}
</Text>
</View>
<MaterialCommunityIcons
name="chevron-right"
size={20}
color={colors.text.hint}
/>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.primary.light + '08',
borderRadius: borderRadius.md,
padding: spacing.md,
marginTop: spacing.sm,
borderWidth: 1,
borderColor: colors.primary.light + '30',
},
iconContainer: {
width: 36,
height: 36,
borderRadius: borderRadius.md,
backgroundColor: colors.primary.light + '20',
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.sm,
},
content: {
flex: 1,
},
title: {
fontSize: fontSizes.md,
fontWeight: '600',
color: colors.text.primary,
marginBottom: 2,
},
subtitle: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
},
});
export default VotePreview;

View File

@@ -0,0 +1,14 @@
/**
* 业务组件导出
*/
export { default as PostCard } from './PostCard';
export { default as CommentItem } from './CommentItem';
export { default as UserProfileHeader } from './UserProfileHeader';
export { default as NotificationItem } from './NotificationItem';
export { default as SystemMessageItem } from './SystemMessageItem';
export { default as SearchBar } from './SearchBar';
export { default as TabBar } from './TabBar';
export { default as VoteCard } from './VoteCard';
export { default as VoteEditor } from './VoteEditor';
export { default as VotePreview } from './VotePreview';

View File

@@ -0,0 +1,402 @@
/**
* 自适应布局组件
* 支持主内容区 + 侧边栏布局,根据屏幕宽度自动调整侧边栏显示/隐藏
* 支持移动端抽屉式侧边栏
*/
import React, { useState, useCallback, useEffect } from 'react';
import {
View,
StyleProp,
ViewStyle,
StyleSheet,
Animated,
TouchableOpacity,
Modal,
Pressable,
Dimensions,
} from 'react-native';
import {
useResponsive,
useBreakpointGTE,
FineBreakpointKey,
} from '../../hooks/useResponsive';
export interface AdaptiveLayoutProps {
/** 主内容区 */
children: React.ReactNode;
/** 侧边栏内容 */
sidebar?: React.ReactNode;
/** 自定义头部 */
header?: React.ReactNode;
/** 自定义底部 */
footer?: React.ReactNode;
/** 布局样式 */
style?: StyleProp<ViewStyle>;
/** 主内容区样式 */
contentStyle?: StyleProp<ViewStyle>;
/** 侧边栏样式 */
sidebarStyle?: StyleProp<ViewStyle>;
/** 侧边栏宽度(桌面端) */
sidebarWidth?: number;
/** 移动端抽屉宽度 */
drawerWidth?: number;
/** 显示侧边栏的断点 */
showSidebarBreakpoint?: FineBreakpointKey;
/** 侧边栏位置 */
sidebarPosition?: 'left' | 'right';
/** 是否强制显示侧边栏(覆盖响应式逻辑) */
forceShowSidebar?: boolean;
/** 是否强制隐藏侧边栏(覆盖响应式逻辑) */
forceHideSidebar?: boolean;
/** 移动端抽屉是否打开(受控模式) */
drawerOpen?: boolean;
/** 移动端抽屉状态变化回调 */
onDrawerOpenChange?: (open: boolean) => void;
/** 渲染移动端抽屉触发按钮 */
renderDrawerTrigger?: (props: { onPress: () => void; isOpen: boolean }) => React.ReactNode;
/** 抽屉遮罩层颜色 */
overlayColor?: string;
/** 抽屉动画时长(毫秒) */
animationDuration?: number;
}
/**
* 自适应布局组件
*
* 支持主内容区 + 侧边栏布局,根据屏幕宽度自动调整:
* - 桌面端:并排显示侧边栏
* - 移动端:侧边栏变为抽屉式,通过按钮触发
*
* @example
* // 基础用法
* <AdaptiveLayout
* sidebar={<SidebarContent />}
* sidebarWidth={280}
* >
* <MainContent />
* </AdaptiveLayout>
*
* @example
* // 自定义断点和抽屉宽度
* <AdaptiveLayout
* sidebar={<SidebarContent />}
* showSidebarBreakpoint="xl"
* sidebarWidth={320}
* drawerWidth={280}
* sidebarPosition="right"
* >
* <MainContent />
* </AdaptiveLayout>
*
* @example
* // 受控模式
* const [drawerOpen, setDrawerOpen] = useState(false);
*
* <AdaptiveLayout
* sidebar={<SidebarContent />}
* drawerOpen={drawerOpen}
* onDrawerOpenChange={setDrawerOpen}
* renderDrawerTrigger={({ onPress, isOpen }) => (
* <Button onPress={onPress}>
* {isOpen ? '关闭' : '菜单'}
* </Button>
* )}
* >
* <MainContent />
* </AdaptiveLayout>
*/
export function AdaptiveLayout({
children,
sidebar,
header,
footer,
style,
contentStyle,
sidebarStyle,
sidebarWidth = 280,
drawerWidth = 280,
showSidebarBreakpoint = 'lg',
sidebarPosition = 'left',
forceShowSidebar,
forceHideSidebar,
drawerOpen: controlledDrawerOpen,
onDrawerOpenChange,
renderDrawerTrigger,
overlayColor = 'rgba(0, 0, 0, 0.5)',
animationDuration = 300,
}: AdaptiveLayoutProps) {
const { width, isMobile } = useResponsive();
const shouldShowSidebar = useBreakpointGTE(showSidebarBreakpoint);
// 内部抽屉状态(非受控模式)
const [internalDrawerOpen, setInternalDrawerOpen] = useState(false);
// 动画值
const [slideAnim] = useState(new Animated.Value(0));
const [fadeAnim] = useState(new Animated.Value(0));
// 确定最终抽屉状态
const isDrawerOpen = controlledDrawerOpen ?? internalDrawerOpen;
const setDrawerOpen = useCallback((open: boolean) => {
if (onDrawerOpenChange) {
onDrawerOpenChange(open);
} else {
setInternalDrawerOpen(open);
}
}, [onDrawerOpenChange]);
// 切换抽屉状态
const toggleDrawer = useCallback(() => {
setDrawerOpen(!isDrawerOpen);
}, [isDrawerOpen, setDrawerOpen]);
// 关闭抽屉
const closeDrawer = useCallback(() => {
setDrawerOpen(false);
}, [setDrawerOpen]);
// 抽屉动画
useEffect(() => {
if (isDrawerOpen) {
Animated.parallel([
Animated.timing(slideAnim, {
toValue: 1,
duration: animationDuration,
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: 1,
duration: animationDuration,
useNativeDriver: true,
}),
]).start();
} else {
Animated.parallel([
Animated.timing(slideAnim, {
toValue: 0,
duration: animationDuration,
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: 0,
duration: animationDuration,
useNativeDriver: true,
}),
]).start();
}
}, [isDrawerOpen, slideAnim, fadeAnim, animationDuration]);
// 计算侧边栏是否应该显示
const isSidebarVisible = forceShowSidebar ?? (shouldShowSidebar && !forceHideSidebar);
const isDrawerMode = !isSidebarVisible && !!sidebar;
// 抽屉滑动动画
const drawerTranslateX = slideAnim.interpolate({
inputRange: [0, 1],
outputRange: [
sidebarPosition === 'left' ? -drawerWidth : drawerWidth,
0,
],
});
// 渲染桌面端侧边栏
const renderDesktopSidebar = () => {
if (!sidebar || !isSidebarVisible) return null;
return (
<View
style={[
styles.sidebar,
{
width: sidebarWidth,
[sidebarPosition === 'left' ? 'marginRight' : 'marginLeft']: 16,
},
sidebarStyle,
]}
>
{sidebar}
</View>
);
};
// 渲染移动端抽屉
const renderMobileDrawer = () => {
if (!sidebar || !isDrawerMode) return null;
return (
<Modal
visible={isDrawerOpen}
transparent
animationType="none"
onRequestClose={closeDrawer}
>
<View style={styles.modalContainer}>
{/* 遮罩层 */}
<TouchableOpacity
style={styles.overlayTouchable}
activeOpacity={1}
onPress={closeDrawer}
>
<Animated.View
style={[
styles.overlay,
{ backgroundColor: overlayColor, opacity: fadeAnim },
]}
/>
</TouchableOpacity>
{/* 抽屉内容 */}
<Animated.View
style={[
styles.drawer,
{
width: drawerWidth,
[sidebarPosition]: 0,
transform: [{ translateX: drawerTranslateX }],
},
sidebarStyle,
]}
>
{sidebar}
</Animated.View>
</View>
</Modal>
);
};
// 渲染抽屉触发按钮
const renderTrigger = () => {
if (!isDrawerMode || !renderDrawerTrigger) return null;
return renderDrawerTrigger({
onPress: toggleDrawer,
isOpen: isDrawerOpen,
});
};
return (
<View style={[styles.container, style]}>
{/* 头部 */}
{header && (
<View style={styles.header}>
{header}
</View>
)}
{/* 主布局区域 */}
<View style={styles.main}>
{/* 左侧布局 */}
{sidebarPosition === 'left' && renderDesktopSidebar()}
{/* 主内容区 */}
<View style={[styles.content, contentStyle]}>
{/* 抽屉触发按钮(仅在移动端抽屉模式显示) */}
{renderTrigger()}
{children}
</View>
{/* 右侧布局 */}
{sidebarPosition === 'right' && renderDesktopSidebar()}
</View>
{/* 移动端抽屉 */}
{renderMobileDrawer()}
{/* 底部 */}
{footer && (
<View style={styles.footer}>
{footer}
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
width: '100%',
},
header: {
width: '100%',
},
main: {
flex: 1,
flexDirection: 'row',
width: '100%',
},
content: {
flex: 1,
minWidth: 0, // 防止 flex item 溢出
},
sidebar: {
flexShrink: 0,
},
footer: {
width: '100%',
},
modalContainer: {
flex: 1,
flexDirection: 'row',
},
overlayTouchable: {
...StyleSheet.absoluteFillObject,
},
overlay: {
flex: 1,
},
drawer: {
position: 'absolute',
top: 0,
bottom: 0,
backgroundColor: '#fff',
shadowColor: '#000',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 16,
},
});
/**
* 简化的侧边栏布局组件
* 仅包含主内容和侧边栏,无头部底部
*/
export interface SidebarLayoutProps {
children: React.ReactNode;
sidebar: React.ReactNode;
style?: StyleProp<ViewStyle>;
contentStyle?: StyleProp<ViewStyle>;
sidebarStyle?: StyleProp<ViewStyle>;
sidebarWidth?: number;
showSidebarBreakpoint?: FineBreakpointKey;
sidebarPosition?: 'left' | 'right';
}
export function SidebarLayout({
children,
sidebar,
style,
contentStyle,
sidebarStyle,
sidebarWidth = 280,
showSidebarBreakpoint = 'lg',
sidebarPosition = 'left',
}: SidebarLayoutProps) {
return (
<AdaptiveLayout
sidebar={sidebar}
style={style}
contentStyle={contentStyle}
sidebarStyle={sidebarStyle}
sidebarWidth={sidebarWidth}
showSidebarBreakpoint={showSidebarBreakpoint}
sidebarPosition={sidebarPosition}
>
{children}
</AdaptiveLayout>
);
}
export default AdaptiveLayout;

View File

@@ -0,0 +1,188 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import type { AlertButton } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { bindDialogListener, DialogPayload } from '../../services/dialogService';
import { borderRadius, colors, shadows, spacing } from '../../theme';
const AppDialogHost: React.FC = () => {
const [dialog, setDialog] = useState<DialogPayload | null>(null);
useEffect(() => {
const unbind = bindDialogListener((payload) => {
setDialog(payload);
});
return unbind;
}, []);
const actions = useMemo<AlertButton[]>(() => {
if (!dialog?.actions?.length) return [{ text: '确定' }];
return dialog.actions.slice(0, 3);
}, [dialog]);
const onClose = () => {
const cancelAction = actions.find((action) => action.style === 'cancel');
if (cancelAction?.onPress) {
cancelAction.onPress();
}
setDialog(null);
};
const onActionPress = (action: AlertButton) => {
action.onPress?.();
setDialog(null);
};
return (
<Modal
visible={!!dialog}
transparent
animationType="fade"
onRequestClose={onClose}
statusBarTranslucent
>
<Pressable
style={styles.backdrop}
onPress={() => {
if (dialog?.options?.cancelable ?? true) {
onClose();
}
}}
>
<Pressable style={styles.container}>
<View style={styles.iconHeader}>
<LinearGradient
colors={['#FF6B35', '#FF8F66']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.iconBadge}
>
<MaterialCommunityIcons name="carrot" size={20} color="#FFFFFF" />
</LinearGradient>
</View>
<Text style={styles.title}>{dialog?.title || '提示'}</Text>
{!!dialog?.message && <Text style={styles.message}>{dialog.message}</Text>}
<View style={styles.actions}>
{actions.map((action, index) => {
const isDestructive = action.style === 'destructive';
const isCancel = action.style === 'cancel';
return (
<TouchableOpacity
key={`${action.text || 'action'}-${index}`}
style={[
styles.actionButton,
isCancel && styles.cancelButton,
!isCancel && !isDestructive && styles.primaryButton,
isDestructive && styles.destructiveButton,
]}
onPress={() => onActionPress(action)}
activeOpacity={0.8}
>
<Text
style={[
styles.actionText,
!isCancel && !isDestructive && styles.primaryText,
isCancel && styles.cancelText,
isDestructive && styles.destructiveText,
]}
>
{action.text || '确定'}
</Text>
</TouchableOpacity>
);
})}
</View>
</Pressable>
</Pressable>
</Modal>
);
};
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.36)',
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: spacing.xl,
},
container: {
width: '100%',
maxWidth: 380,
backgroundColor: colors.background.paper,
borderRadius: borderRadius['2xl'],
paddingHorizontal: spacing.xl,
paddingTop: spacing.lg,
paddingBottom: spacing.lg,
...shadows.lg,
},
iconHeader: {
alignItems: 'center',
marginBottom: spacing.md,
},
iconBadge: {
width: 42,
height: 42,
borderRadius: borderRadius.full,
justifyContent: 'center',
alignItems: 'center',
},
title: {
color: colors.text.primary,
fontSize: 18,
fontWeight: '700',
textAlign: 'center',
},
message: {
marginTop: spacing.md,
color: colors.text.secondary,
fontSize: 14,
lineHeight: 21,
textAlign: 'center',
},
actions: {
marginTop: spacing.xl,
gap: spacing.sm,
},
actionButton: {
height: 46,
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: `${colors.primary.main}28`,
backgroundColor: '#FFFFFF',
justifyContent: 'center',
alignItems: 'center',
},
primaryButton: {
backgroundColor: colors.primary.main,
borderColor: colors.primary.main,
},
cancelButton: {
backgroundColor: colors.background.paper,
borderColor: colors.divider,
},
destructiveButton: {
backgroundColor: '#FEECEC',
borderColor: '#FCD4D1',
},
actionText: {
color: colors.text.primary,
fontSize: 15,
fontWeight: '700',
},
primaryText: {
color: '#FFFFFF',
},
cancelText: {
color: colors.text.secondary,
fontWeight: '600',
},
destructiveText: {
color: colors.error.main,
},
});
export default AppDialogHost;

View File

@@ -0,0 +1,185 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { bindPromptListener, PromptPayload, PromptType } from '../../services/promptService';
import { borderRadius, colors, shadows, spacing } from '../../theme';
interface PromptState extends PromptPayload {
id: number;
}
const DEFAULT_DURATION = 2200;
const styleMap: Record<PromptType, { backgroundColor: string; icon: React.ComponentProps<typeof MaterialCommunityIcons>['name'] }> = {
info: { backgroundColor: '#FFFFFF', icon: 'information-outline' },
success: { backgroundColor: '#FFFFFF', icon: 'check-circle-outline' },
warning: { backgroundColor: '#FFFFFF', icon: 'alert-outline' },
error: { backgroundColor: '#FFFFFF', icon: 'alert-circle-outline' },
};
const iconColorMap: Record<PromptType, string> = {
info: colors.primary.main,
success: colors.success.main,
warning: colors.warning.dark,
error: colors.error.main,
};
const accentColorMap: Record<PromptType, string> = {
info: colors.primary.main,
success: colors.success.main,
warning: colors.warning.main,
error: colors.error.main,
};
const AppPromptBar: React.FC = () => {
const insets = useSafeAreaInsets();
const [prompt, setPrompt] = useState<PromptState | null>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const animation = useRef(new Animated.Value(0)).current;
const hidePrompt = useCallback(() => {
Animated.timing(animation, {
toValue: 0,
duration: 180,
useNativeDriver: true,
}).start(({ finished }) => {
if (finished) {
setPrompt(null);
}
});
}, [animation]);
useEffect(() => {
const unbind = bindPromptListener((payload) => {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
}
setPrompt({
...payload,
id: Date.now(),
type: payload.type ?? 'info',
});
});
return () => {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
}
unbind();
};
}, []);
useEffect(() => {
if (!prompt) return;
animation.setValue(0);
Animated.timing(animation, {
toValue: 1,
duration: 220,
useNativeDriver: true,
}).start();
hideTimerRef.current = setTimeout(() => {
hidePrompt();
}, prompt.duration ?? DEFAULT_DURATION);
}, [animation, hidePrompt, prompt]);
if (!prompt) return null;
const promptType = prompt.type ?? 'info';
const promptStyle = styleMap[promptType];
const iconColor = iconColorMap[promptType];
const accentColor = accentColorMap[promptType];
return (
<Animated.View
pointerEvents="box-none"
style={[
styles.wrapper,
{
top: insets.top + spacing.sm,
opacity: animation,
transform: [
{
translateY: animation.interpolate({
inputRange: [0, 1],
outputRange: [-20, 0],
}),
},
],
},
]}
>
<TouchableOpacity
activeOpacity={0.95}
onPress={hidePrompt}
style={[styles.card, { backgroundColor: promptStyle.backgroundColor }]}
>
<View style={[styles.accentBar, { backgroundColor: accentColor }]} />
<View style={styles.iconWrap}>
<MaterialCommunityIcons name={promptStyle.icon} size={20} color={iconColor} />
</View>
<View style={styles.textWrap}>
{!!prompt.title && <Text style={styles.title}>{prompt.title}</Text>}
<Text style={styles.message}>{prompt.message}</Text>
</View>
</TouchableOpacity>
</Animated.View>
);
};
const styles = StyleSheet.create({
wrapper: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
zIndex: 9999,
},
card: {
borderRadius: borderRadius.xl,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
borderWidth: 1,
borderColor: `${colors.primary.main}22`,
flexDirection: 'row',
alignItems: 'center',
overflow: 'hidden',
...shadows.lg,
},
accentBar: {
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: 4,
},
iconWrap: {
width: 32,
height: 32,
borderRadius: borderRadius.full,
backgroundColor: `${colors.primary.main}12`,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.md,
},
textWrap: {
flex: 1,
paddingRight: spacing.sm,
},
title: {
color: colors.text.primary,
fontWeight: '700',
fontSize: 14,
marginBottom: 2,
},
message: {
color: colors.text.primary,
fontSize: 13,
lineHeight: 18,
},
});
export default AppPromptBar;

View File

@@ -0,0 +1,146 @@
/**
* Avatar 头像组件
* 支持图片URL、本地图片、首字母显示、在线状态徽章
* 使用 expo-image 实现内存+磁盘双级缓存,头像秒加载
*/
import React from 'react';
import {
View,
TouchableOpacity,
StyleSheet,
ViewStyle,
} from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { colors, borderRadius } from '../../theme';
import Text from './Text';
type AvatarSource = string | { uri: string } | number | null;
interface AvatarProps {
source?: AvatarSource;
size?: number; // 默认40
name?: string; // 用于显示首字母
onPress?: () => void;
showBadge?: boolean;
badgeColor?: string;
style?: ViewStyle;
}
const Avatar: React.FC<AvatarProps> = ({
source,
size = 40,
name,
onPress,
showBadge = false,
badgeColor = colors.success.main,
style,
}) => {
// 获取首字母
const getInitial = (): string => {
if (!name) return '?';
const firstChar = name.charAt(0).toUpperCase();
// 中文字符
if (/[\u4e00-\u9fa5]/.test(firstChar)) {
return firstChar;
}
return firstChar;
};
// 渲染头像内容
const renderAvatarContent = () => {
// 如果有图片源
if (source) {
const imageSource =
typeof source === 'string' ? { uri: source } : source;
return (
<ExpoImage
source={imageSource}
style={[
styles.image,
{
width: size,
height: size,
borderRadius: size / 2,
},
]}
contentFit="cover"
cachePolicy="memory"
transition={150}
/>
);
}
// 显示首字母
const fontSize = size / 2;
return (
<View
style={[
styles.placeholder,
{
width: size,
height: size,
borderRadius: size / 2,
},
]}
>
<Text color={colors.text.inverse} style={{ fontSize, lineHeight: fontSize * 1.2 }}>
{getInitial()}
</Text>
</View>
);
};
const avatarContainer = (
<View style={[styles.container, style]}>
{renderAvatarContent()}
{showBadge && (
<View
style={[
styles.badge,
{
backgroundColor: badgeColor,
width: size * 0.3,
height: size * 0.3,
borderRadius: (size * 0.3) / 2,
right: 0,
bottom: 0,
},
]}
/>
)}
</View>
);
if (onPress) {
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
{avatarContainer}
</TouchableOpacity>
);
}
return avatarContainer;
};
const styles = StyleSheet.create({
container: {
position: 'relative',
},
image: {
backgroundColor: colors.background.disabled,
},
placeholder: {
backgroundColor: colors.primary.main,
justifyContent: 'center',
alignItems: 'center',
},
badge: {
position: 'absolute',
borderWidth: 2,
borderColor: colors.background.paper,
},
});
export default Avatar;

View File

@@ -0,0 +1,260 @@
/**
* Button 按钮组件
* 支持多种变体、尺寸、加载状态和图标
*/
import React from 'react';
import {
TouchableOpacity,
ActivityIndicator,
StyleSheet,
View,
ViewStyle,
TextStyle,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, borderRadius, spacing, fontSizes, shadows } from '../../theme';
import Text from './Text';
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'text' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
type IconPosition = 'left' | 'right';
interface ButtonProps {
title: string;
onPress: () => void;
variant?: ButtonVariant;
size?: ButtonSize;
disabled?: boolean;
loading?: boolean;
icon?: string; // MaterialCommunityIcons name
iconPosition?: IconPosition;
fullWidth?: boolean;
style?: ViewStyle;
}
const Button: React.FC<ButtonProps> = ({
title,
onPress,
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
icon,
iconPosition = 'left',
fullWidth = false,
style,
}) => {
// 获取按钮样式
const getButtonStyle = (): ViewStyle[] => {
const baseStyle: ViewStyle[] = [styles.base, styles[`size_${size}`]];
// 变体样式
switch (variant) {
case 'primary':
baseStyle.push(styles.primary);
break;
case 'secondary':
baseStyle.push(styles.secondary);
break;
case 'outline':
baseStyle.push(styles.outline);
break;
case 'text':
baseStyle.push(styles.text);
break;
case 'danger':
baseStyle.push(styles.danger);
break;
}
// 禁用状态
if (disabled || loading) {
baseStyle.push(styles.disabled);
}
// 全宽度
if (fullWidth) {
baseStyle.push(styles.fullWidth);
}
return baseStyle;
};
// 获取文本样式
const getTextStyle = (): TextStyle => {
const baseStyle: TextStyle = {
...styles.textBase,
...styles[`textSize_${size}`],
};
switch (variant) {
case 'primary':
case 'danger':
baseStyle.color = colors.text.inverse;
break;
case 'secondary':
baseStyle.color = colors.text.inverse;
break;
case 'outline':
case 'text':
baseStyle.color = colors.primary.main;
break;
}
if (disabled || loading) {
baseStyle.color = colors.text.disabled;
}
return baseStyle;
};
// 获取图标大小
const getIconSize = (): number => {
switch (size) {
case 'sm':
return 16;
case 'md':
return 20;
case 'lg':
return 24;
}
};
// 获取图标颜色
const getIconColor = (): string => {
if (disabled || loading) {
return colors.text.disabled;
}
switch (variant) {
case 'primary':
case 'danger':
return colors.text.inverse;
case 'secondary':
return colors.text.inverse;
case 'outline':
case 'text':
return colors.primary.main;
}
};
const isDisabled = disabled || loading;
return (
<TouchableOpacity
style={[getButtonStyle(), style]}
onPress={onPress}
disabled={isDisabled}
activeOpacity={0.9}
>
{loading ? (
<ActivityIndicator
size="small"
color={variant === 'outline' || variant === 'text'
? colors.primary.main
: colors.text.inverse}
/>
) : (
<View style={styles.content}>
{icon && iconPosition === 'left' && (
<MaterialCommunityIcons
name={icon as any}
size={getIconSize()}
color={getIconColor()}
style={styles.iconLeft}
/>
)}
<Text style={getTextStyle()}>{title}</Text>
{icon && iconPosition === 'right' && (
<MaterialCommunityIcons
name={icon as any}
size={getIconSize()}
color={getIconColor()}
style={styles.iconRight}
/>
)}
</View>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
base: {
justifyContent: 'center',
alignItems: 'center',
borderRadius: borderRadius.md,
},
content: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
// 尺寸样式
size_sm: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
minHeight: 32,
},
size_md: {
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
minHeight: 40,
},
size_lg: {
paddingVertical: spacing.lg,
paddingHorizontal: spacing.xl,
minHeight: 48,
},
// 变体样式
primary: {
backgroundColor: colors.primary.main,
},
secondary: {
backgroundColor: colors.secondary.main,
},
outline: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: colors.primary.main,
},
text: {
backgroundColor: 'transparent',
shadowColor: 'transparent',
elevation: 0,
},
danger: {
backgroundColor: colors.error.main,
},
// 禁用状态
disabled: {
backgroundColor: colors.background.disabled,
borderColor: colors.background.disabled,
},
// 全宽度
fullWidth: {
width: '100%',
},
// 文本样式
textBase: {
fontWeight: '600',
},
textSize_sm: {
fontSize: fontSizes.sm,
},
textSize_md: {
fontSize: fontSizes.md,
},
textSize_lg: {
fontSize: fontSizes.lg,
},
// 图标样式
iconLeft: {
marginRight: spacing.sm,
},
iconRight: {
marginLeft: spacing.sm,
},
});
export default Button;

View File

@@ -0,0 +1,58 @@
/**
* Card 卡片组件
* 白色背景、圆角、阴影,支持点击
*/
import React from 'react';
import { View, TouchableOpacity, StyleSheet, ViewStyle } from 'react-native';
import { colors, borderRadius, spacing, shadows } from '../../theme';
type ShadowSize = 'sm' | 'md' | 'lg';
interface CardProps {
children: React.ReactNode;
onPress?: () => void;
padding?: number;
shadow?: ShadowSize;
style?: ViewStyle;
}
const Card: React.FC<CardProps> = ({
children,
onPress,
padding = spacing.lg,
shadow = 'none',
style,
}) => {
const shadowStyle = shadow !== 'none' ? shadows[shadow as keyof typeof shadows] : undefined;
const cardStyle = [
styles.card,
{ padding },
shadowStyle,
].filter(Boolean);
if (onPress) {
return (
<TouchableOpacity
style={[...cardStyle, style]}
onPress={onPress}
activeOpacity={0.95}
>
{children}
</TouchableOpacity>
);
}
return <View style={[...cardStyle, style]}>{children}</View>;
};
const styles = StyleSheet.create({
card: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
overflow: 'hidden',
},
});
export default Card;

View File

@@ -0,0 +1,39 @@
/**
* Divider 分割线组件
* 用于分隔内容
*/
import React from 'react';
import { View, StyleSheet, ViewStyle } from 'react-native';
import { colors, spacing } from '../../theme';
interface DividerProps {
margin?: number;
color?: string;
style?: ViewStyle;
}
const Divider: React.FC<DividerProps> = ({
margin = spacing.lg,
color = colors.divider,
style,
}) => {
return (
<View
style={[
styles.divider,
{ marginVertical: margin, backgroundColor: color },
style,
]}
/>
);
};
const styles = StyleSheet.create({
divider: {
height: 1,
width: '100%',
},
});
export default Divider;

View File

@@ -0,0 +1,242 @@
/**
* EmptyState 空状态组件 - 美化版
* 显示空数据时的占位界面,采用现代插图风格设计
*/
import React from 'react';
import { View, StyleSheet, ViewStyle, Dimensions } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import Text from './Text';
import Button from './Button';
interface EmptyStateProps {
icon?: string;
title: string;
description?: string;
actionLabel?: string;
onAction?: () => void;
style?: ViewStyle;
variant?: 'default' | 'modern' | 'compact';
}
const EmptyState: React.FC<EmptyStateProps> = ({
icon = 'folder-open-outline',
title,
description,
actionLabel,
onAction,
style,
variant = 'modern',
}) => {
// 现代风格空状态
if (variant === 'modern') {
return (
<View style={[styles.modernContainer, style]}>
<View style={styles.illustrationContainer}>
<View style={styles.iconBackground}>
<MaterialCommunityIcons
name={icon as any}
size={48}
color={colors.primary.main}
style={styles.modernIcon}
/>
</View>
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
</View>
<Text
variant="h3"
color={colors.text.primary}
style={styles.modernTitle}
>
{title}
</Text>
{description && (
<Text
variant="body"
color={colors.text.secondary}
style={styles.modernDescription}
>
{description}
</Text>
)}
{actionLabel && onAction && (
<Button
title={actionLabel}
onPress={onAction}
variant="primary"
size="md"
style={styles.modernButton}
/>
)}
</View>
);
}
// 紧凑风格
if (variant === 'compact') {
return (
<View style={[styles.compactContainer, style]}>
<MaterialCommunityIcons
name={icon as any}
size={40}
color={colors.text.disabled}
style={styles.compactIcon}
/>
<Text
variant="body"
color={colors.text.secondary}
style={styles.compactTitle}
>
{title}
</Text>
</View>
);
}
// 默认风格
return (
<View style={[styles.container, style]}>
<MaterialCommunityIcons
name={icon as any}
size={64}
color={colors.text.disabled}
style={styles.icon}
/>
<Text
variant="h3"
color={colors.text.secondary}
style={styles.title}
>
{title}
</Text>
{description && (
<Text
variant="body"
color={colors.text.secondary}
style={styles.description}
>
{description}
</Text>
)}
{actionLabel && onAction && (
<Button
title={actionLabel}
onPress={onAction}
variant="outline"
size="md"
style={styles.button}
/>
)}
</View>
);
};
const styles = StyleSheet.create({
// 默认风格
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
},
icon: {
marginBottom: spacing.lg,
},
title: {
textAlign: 'center',
marginBottom: spacing.sm,
},
description: {
textAlign: 'center',
marginBottom: spacing.lg,
},
button: {
minWidth: 120,
},
// 现代风格
modernContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
minHeight: 280,
},
illustrationContainer: {
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.xl,
width: 120,
height: 120,
},
iconBackground: {
width: 100,
height: 100,
borderRadius: borderRadius['2xl'],
backgroundColor: colors.primary.main + '15',
alignItems: 'center',
justifyContent: 'center',
shadowColor: colors.primary.main,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 12,
elevation: 4,
},
modernIcon: {
opacity: 0.9,
},
decorativeCircle1: {
position: 'absolute',
width: 24,
height: 24,
borderRadius: borderRadius.full,
backgroundColor: colors.primary.light + '30',
top: 5,
right: 10,
},
decorativeCircle2: {
position: 'absolute',
width: 16,
height: 16,
borderRadius: borderRadius.full,
backgroundColor: colors.primary.main + '20',
bottom: 15,
left: 5,
},
modernTitle: {
textAlign: 'center',
marginBottom: spacing.sm,
fontWeight: '700',
fontSize: fontSizes.xl,
},
modernDescription: {
textAlign: 'center',
marginBottom: spacing.lg,
fontSize: fontSizes.md,
lineHeight: 22,
maxWidth: 280,
},
modernButton: {
minWidth: 140,
borderRadius: borderRadius.lg,
},
// 紧凑风格
compactContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: spacing.lg,
},
compactIcon: {
marginRight: spacing.sm,
},
compactTitle: {
fontSize: fontSizes.md,
},
});
export default EmptyState;

View File

@@ -0,0 +1,609 @@
/**
* ImageGallery 图片画廊组件
* 支持手势滑动切换图片、双指放大、点击关闭
* 使用 expo-image原生支持 GIF/WebP 动图
*/
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import {
Modal,
View,
StyleSheet,
Dimensions,
TouchableOpacity,
Text,
StatusBar,
ActivityIndicator,
Alert,
} from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import {
Gesture,
GestureDetector,
GestureHandlerRootView,
} from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
useDerivedValue,
runOnJS,
withTiming,
withSpring,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as MediaLibrary from 'expo-media-library';
import { File, Paths } from 'expo-file-system';
import { colors, spacing, borderRadius, fontSizes } from '../../theme';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
// 图片项类型
export interface GalleryImageItem {
id: string;
url: string;
thumbnailUrl?: string;
width?: number;
height?: number;
description?: string;
}
// 组件 Props
export interface ImageGalleryProps {
/** 是否可见 */
visible: boolean;
/** 图片列表 */
images: GalleryImageItem[];
/** 初始索引 */
initialIndex: number;
/** 关闭回调 */
onClose: () => void;
/** 索引变化回调 */
onIndexChange?: (index: number) => void;
/** 是否显示指示器 */
showIndicator?: boolean;
/** 是否允许保存图片 */
enableSave?: boolean;
/** 保存图片成功回调 */
onSave?: (url: string) => void;
/** 背景透明度 */
backgroundOpacity?: number;
}
/**
* 图片画廊主组件
*/
export const ImageGallery: React.FC<ImageGalleryProps> = ({
visible,
images,
initialIndex,
onClose,
onIndexChange,
showIndicator = true,
enableSave = false,
onSave,
backgroundOpacity = 1,
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [showControls, setShowControls] = useState(true);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [saving, setSaving] = useState(false);
const [saveToast, setSaveToast] = useState<'success' | 'error' | null>(null);
const insets = useSafeAreaInsets();
// 缩放相关状态
const scale = useSharedValue(1);
const savedScale = useSharedValue(1);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const savedTranslateX = useSharedValue(0);
const savedTranslateY = useSharedValue(0);
const validImages = useMemo(() => {
return images.filter(img => img.url);
}, [images]);
const currentImage = useMemo(() => {
if (currentIndex < 0 || currentIndex >= validImages.length) {
return null;
}
return validImages[currentIndex];
}, [validImages, currentIndex]);
// 重置缩放状态
const resetZoom = useCallback(() => {
scale.value = withTiming(1, { duration: 200 });
savedScale.value = 1;
translateX.value = withTiming(0, { duration: 200 });
translateY.value = withTiming(0, { duration: 200 });
savedTranslateX.value = 0;
savedTranslateY.value = 0;
}, [scale, savedScale, translateX, translateY, savedTranslateX, savedTranslateY]);
// 打开/关闭时重置状态
useEffect(() => {
if (visible) {
setCurrentIndex(initialIndex);
setShowControls(true);
setLoading(true);
setError(false);
resetZoom();
StatusBar.setHidden(true, 'fade');
} else {
StatusBar.setHidden(false, 'fade');
}
}, [visible, initialIndex, resetZoom]);
// 图片变化时重置加载状态和缩放
useEffect(() => {
setLoading(true);
setError(false);
resetZoom();
}, [currentImage?.id, resetZoom]);
const updateIndex = useCallback(
(newIndex: number) => {
const clampedIndex = Math.max(0, Math.min(validImages.length - 1, newIndex));
setCurrentIndex(clampedIndex);
onIndexChange?.(clampedIndex);
},
[validImages.length, onIndexChange]
);
const toggleControls = useCallback(() => {
setShowControls(prev => !prev);
}, []);
const goToPrev = useCallback(() => {
if (currentIndex > 0) {
updateIndex(currentIndex - 1);
}
}, [currentIndex, updateIndex]);
const goToNext = useCallback(() => {
if (currentIndex < validImages.length - 1) {
updateIndex(currentIndex + 1);
}
}, [currentIndex, validImages.length, updateIndex]);
// 显示短暂提示
const showToast = useCallback((type: 'success' | 'error') => {
setSaveToast(type);
setTimeout(() => setSaveToast(null), 2500);
}, []);
// 保存图片到本地相册
const handleSaveImage = useCallback(async () => {
if (!currentImage || saving) return;
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
Alert.alert('无法保存', '请在系统设置中允许访问相册权限后重试。');
return;
}
setSaving(true);
try {
const urlPath = currentImage.url.split('?')[0];
const ext = urlPath.split('.').pop()?.toLowerCase() ?? 'jpg';
const allowedExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
const fileExt = allowedExts.includes(ext) ? ext : 'jpg';
const fileName = `carrot_${Date.now()}.${fileExt}`;
const destination = new File(Paths.cache, fileName);
// File.downloadFileAsync 是新版 expo-file-system/next 的静态方法
const downloaded = await File.downloadFileAsync(currentImage.url, destination);
await MediaLibrary.saveToLibraryAsync(downloaded.uri);
// 清理缓存文件
downloaded.delete();
onSave?.(currentImage.url);
showToast('success');
} catch (err) {
console.error('[ImageGallery] 保存图片失败:', err);
showToast('error');
} finally {
setSaving(false);
}
}, [currentImage, saving, onSave, showToast]);
// 双指缩放手势
const pinchGesture = Gesture.Pinch()
.onUpdate((e) => {
const newScale = savedScale.value * e.scale;
// 限制缩放范围
scale.value = Math.max(0.5, Math.min(newScale, 4));
})
.onEnd(() => {
// 如果缩放小于1回弹到1
if (scale.value < 1) {
scale.value = withTiming(1, { duration: 200 });
savedScale.value = 1;
translateX.value = withTiming(0, { duration: 200 });
translateY.value = withTiming(0, { duration: 200 });
savedTranslateX.value = 0;
savedTranslateY.value = 0;
} else {
savedScale.value = scale.value;
}
});
// 滑动切换图片相关状态
const swipeTranslateX = useSharedValue(0);
// 统一的滑动手势:放大时拖动,未放大时切换图片
const panGesture = Gesture.Pan()
.activeOffsetX([-10, 10]) // 水平方向需要移动10pt才激活避免与点击冲突
.activeOffsetY([-10, 10]) // 垂直方向也需要一定偏移才激活
.onBegin(() => {
swipeTranslateX.value = 0;
})
.onUpdate((e) => {
if (scale.value > 1) {
// 放大状态下:拖动图片
translateX.value = savedTranslateX.value + e.translationX;
translateY.value = savedTranslateY.value + e.translationY;
} else if (validImages.length > 1) {
// 未放大且有多张图片:切换图片的跟随效果
const isFirst = currentIndex === 0 && e.translationX > 0;
const isLast = currentIndex === validImages.length - 1 && e.translationX < 0;
if (isFirst || isLast) {
// 边界阻力效果
swipeTranslateX.value = e.translationX * 0.3;
} else {
swipeTranslateX.value = e.translationX;
}
}
})
.onEnd((e) => {
if (scale.value > 1) {
// 放大状态下:保存拖动位置
savedTranslateX.value = translateX.value;
savedTranslateY.value = translateY.value;
} else if (validImages.length > 1) {
// 未放大状态下:判断是否切换图片
const threshold = SCREEN_WIDTH * 0.2;
const velocity = e.velocityX;
const shouldGoNext = e.translationX < -threshold || velocity < -500;
const shouldGoPrev = e.translationX > threshold || velocity > 500;
if (shouldGoNext && currentIndex < validImages.length - 1) {
// 向左滑动,显示下一张
swipeTranslateX.value = withTiming(-SCREEN_WIDTH, { duration: 200 }, () => {
runOnJS(updateIndex)(currentIndex + 1);
swipeTranslateX.value = 0;
});
} else if (shouldGoPrev && currentIndex > 0) {
// 向右滑动,显示上一张
swipeTranslateX.value = withTiming(SCREEN_WIDTH, { duration: 200 }, () => {
runOnJS(updateIndex)(currentIndex - 1);
swipeTranslateX.value = 0;
});
} else {
// 回弹到原位
swipeTranslateX.value = withTiming(0, { duration: 200 });
}
}
});
// 点击手势(关闭 gallery
const tapGesture = Gesture.Tap()
.numberOfTaps(1)
.maxDistance(10)
.onEnd(() => {
runOnJS(onClose)();
});
// 组合手势:
// - pinchGesture 和 (panGesture / tapGesture) 可以同时识别
// - panGesture 和 tapGesture 互斥Race短按是点击长按/滑动是拖动
const composedGesture = Gesture.Simultaneous(
pinchGesture,
Gesture.Race(panGesture, tapGesture)
);
// 动画样式
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value + swipeTranslateX.value },
{ translateY: translateY.value },
{ scale: scale.value },
] as const,
}));
if (!visible || validImages.length === 0) {
return null;
}
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
statusBarTranslucent
>
<GestureHandlerRootView style={styles.root}>
<View style={[styles.container, { backgroundColor: `rgba(0, 0, 0, ${backgroundOpacity})` }]}>
{/* 顶部控制栏 */}
{showControls && (
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<MaterialCommunityIcons name="close" size={24} color="#FFF" />
</TouchableOpacity>
<View style={styles.headerCenter}>
<Text style={styles.pageIndicator}>
{currentIndex + 1} / {validImages.length}
</Text>
</View>
{enableSave && (
<TouchableOpacity
style={styles.saveButton}
onPress={handleSaveImage}
disabled={saving}
>
{saving ? (
<ActivityIndicator size="small" color="#FFF" />
) : (
<MaterialCommunityIcons name="download" size={24} color="#FFF" />
)}
</TouchableOpacity>
)}
</View>
)}
{/* 图片显示区域 */}
<GestureDetector gesture={composedGesture}>
<View style={styles.imageContainer}>
{loading && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#FFF" />
</View>
)}
{error && (
<View style={styles.errorContainer}>
<MaterialCommunityIcons name="image-off" size={48} color="#999" />
<Text style={styles.errorText}></Text>
</View>
)}
{currentImage && (
<Animated.View style={[styles.imageWrapper, animatedStyle]}>
<ExpoImage
source={{ uri: currentImage.url }}
style={styles.image}
contentFit="contain"
cachePolicy="disk"
priority="high"
recyclingKey={currentImage.id}
allowDownscaling
onLoadStart={() => setLoading(true)}
onLoad={() => {
setLoading(false);
setError(false);
}}
onError={() => {
setLoading(false);
setError(true);
}}
/>
</Animated.View>
)}
</View>
</GestureDetector>
{/* 左右切换按钮 */}
{showControls && validImages.length > 1 && (
<>
{currentIndex > 0 && (
<TouchableOpacity
style={[styles.navButton, styles.navButtonLeft]}
onPress={goToPrev}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="chevron-left" size={36} color="#FFF" />
</TouchableOpacity>
)}
{currentIndex < validImages.length - 1 && (
<TouchableOpacity
style={[styles.navButton, styles.navButtonRight]}
onPress={goToNext}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="chevron-right" size={36} color="#FFF" />
</TouchableOpacity>
)}
</>
)}
{/* 底部指示器 */}
{showControls && showIndicator && validImages.length > 1 && (
<View style={[styles.footer, { paddingBottom: insets.bottom + spacing.lg }]}>
<View style={styles.dotsContainer}>
{validImages.map((_, index) => (
<View
key={index}
style={[
styles.dot,
index === currentIndex && styles.activeDot,
]}
/>
))}
</View>
</View>
)}
{/* 保存结果 Toast */}
{saveToast !== null && (
<View style={[styles.toast, saveToast === 'success' ? styles.toastSuccess : styles.toastError]}>
<MaterialCommunityIcons
name={saveToast === 'success' ? 'check-circle-outline' : 'alert-circle-outline'}
size={18}
color="#FFF"
/>
<Text style={styles.toastText}>
{saveToast === 'success' ? '已保存到相册' : '保存失败,请重试'}
</Text>
</View>
)}
</View>
</GestureHandlerRootView>
</Modal>
);
};
const styles = StyleSheet.create({
root: {
flex: 1,
},
container: {
flex: 1,
backgroundColor: '#000',
},
header: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingBottom: spacing.md,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
zIndex: 10,
},
closeButton: {
width: 40,
height: 40,
borderRadius: borderRadius.full,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
},
headerCenter: {
flex: 1,
alignItems: 'center',
},
pageIndicator: {
color: '#FFF',
fontSize: fontSizes.md,
fontWeight: '500',
},
saveButton: {
width: 40,
height: 40,
borderRadius: borderRadius.full,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
},
imageContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
imageWrapper: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
justifyContent: 'center',
alignItems: 'center',
},
image: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT * 0.8,
},
loadingContainer: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
zIndex: 5,
},
errorContainer: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
color: '#999',
fontSize: fontSizes.md,
marginTop: spacing.sm,
},
navButton: {
position: 'absolute',
top: '50%',
marginTop: -25,
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
navButtonLeft: {
left: spacing.sm,
},
navButtonRight: {
right: spacing.sm,
},
footer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
paddingVertical: spacing.lg,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
dotsContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 8,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: 'rgba(255, 255, 255, 0.4)',
},
activeDot: {
width: 20,
borderRadius: 4,
backgroundColor: '#FFF',
},
toast: {
position: 'absolute',
bottom: 100,
alignSelf: 'center',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: borderRadius.full,
},
toastSuccess: {
backgroundColor: 'rgba(34, 197, 94, 0.9)',
},
toastError: {
backgroundColor: 'rgba(239, 68, 68, 0.9)',
},
toastText: {
color: '#FFF',
fontSize: fontSizes.sm,
fontWeight: '500',
},
});
export default ImageGallery;

View File

@@ -0,0 +1,598 @@
/**
* ImageGrid 图片网格组件
* 支持 1-9 张图片的智能布局
* 根据图片数量自动选择最佳展示方式
*/
import React, { useMemo, useCallback, useState, useEffect } from 'react';
import {
View,
StyleSheet,
Dimensions,
ViewStyle,
Pressable,
Text,
Image,
} from 'react-native';
import { SmartImage, ImageSource } from './SmartImage';
import { colors, spacing, borderRadius } from '../../theme';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
// 默认容器内边距(用于计算可用宽度)
const DEFAULT_CONTAINER_PADDING = spacing.lg * 2; // 左右各 spacing.lg
// 单张图片的默认宽高比
const SINGLE_IMAGE_DEFAULT_ASPECT_RATIO = 4 / 3; // 默认4:3比例
// 单张图片的最大高度
const SINGLE_IMAGE_MAX_HEIGHT = 400;
// 单张图片的最小高度
const SINGLE_IMAGE_MIN_HEIGHT = 150;
// 图片项类型 - 兼容 PostImageDTO 和 CommentImage
export interface ImageGridItem {
id?: string;
uri?: string;
url?: string;
thumbnailUrl?: string;
thumbnail_url?: string;
width?: number;
height?: number;
}
// 布局模式
export type GridLayoutMode = 'auto' | 'single' | 'horizontal' | 'grid' | 'masonry';
// 组件 Props
export interface ImageGridProps {
/** 图片列表 */
images: ImageGridItem[];
/** 容器样式 */
style?: ViewStyle;
/** 最大显示数量 */
maxDisplayCount?: number;
/** 布局模式 */
mode?: GridLayoutMode;
/** 图片间距 */
gap?: number;
/** 圆角大小 */
borderRadius?: number;
/** 是否显示更多遮罩 */
showMoreOverlay?: boolean;
/** 单张图片最大高度 */
singleImageMaxHeight?: number;
/** 网格列数(2或3) */
gridColumns?: 2 | 3;
/** 图片点击回调 */
onImagePress?: (images: ImageGridItem[], index: number) => void;
/** 测试ID */
testID?: string;
}
/**
* 计算图片网格尺寸
*/
const calculateGridDimensions = (
count: number,
containerWidth: number,
gap: number,
columns: number
) => {
const totalGap = (columns - 1) * gap;
const itemSize = (containerWidth - totalGap) / columns;
// 计算行数
const rows = Math.ceil(count / columns);
return {
itemSize,
rows,
containerHeight: rows * itemSize + (rows - 1) * gap,
};
};
// ─── 单张图片子组件 ───────────────────────────────────────────────────────────
// 独立成组件,方便用 useState 管理异步加载到的实际尺寸
interface SingleImageItemProps {
image: ImageGridItem;
containerWidth: number;
maxHeight: number;
borderRadiusValue: number;
onPress: () => void;
}
const SingleImageItem: React.FC<SingleImageItemProps> = ({
image,
containerWidth,
maxHeight,
borderRadiusValue,
onPress,
}) => {
const [aspectRatio, setAspectRatio] = useState<number | null>(null);
const uri = image.uri || image.url || '';
useEffect(() => {
if (!uri) return;
let cancelled = false;
// 添加超时处理,防止高分辨率图片加载卡住
const timeoutId = setTimeout(() => {
if (!cancelled && aspectRatio === null) {
setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO);
}
}, 3000);
Image.getSize(
uri,
(w, h) => {
if (!cancelled) {
clearTimeout(timeoutId);
setAspectRatio(w / h);
}
},
() => {
if (!cancelled) {
clearTimeout(timeoutId);
setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO);
}
},
);
return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [uri]);
const effectiveContainerWidth = containerWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING;
// 尺寸还没拿到时先占位,避免闪烁
if (aspectRatio === null) {
return (
<View
style={[
styles.singleContainer,
{
width: effectiveContainerWidth,
height: SINGLE_IMAGE_MIN_HEIGHT,
borderRadius: borderRadiusValue,
},
]}
/>
);
}
// 适配最大边界框,保证宽高比不变
let width: number;
let height: number;
if (aspectRatio > effectiveContainerWidth / maxHeight) {
// 宽图:宽度撑满容器
width = effectiveContainerWidth;
height = effectiveContainerWidth / aspectRatio;
} else {
// 高图:高度触及上限
height = maxHeight;
width = maxHeight * aspectRatio;
}
// 最小高度兜底
if (height < SINGLE_IMAGE_MIN_HEIGHT) {
height = SINGLE_IMAGE_MIN_HEIGHT;
width = Math.min(SINGLE_IMAGE_MIN_HEIGHT * aspectRatio, effectiveContainerWidth);
}
return (
<Pressable
onPress={onPress}
style={[styles.singleContainer, { width, height, borderRadius: borderRadiusValue }]}
>
<SmartImage
source={{ uri }}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
</Pressable>
);
};
/**
* 图片网格组件
* 智能布局:根据图片数量自动选择最佳展示方式
*/
export const ImageGrid: React.FC<ImageGridProps> = ({
images,
style,
maxDisplayCount = 9,
mode = 'auto',
gap = 4,
borderRadius: borderRadiusValue = borderRadius.md,
showMoreOverlay = true,
singleImageMaxHeight = 300,
gridColumns = 3,
onImagePress,
testID,
}) => {
// 通过 onLayout 拿到容器实际宽度
const [containerWidth, setContainerWidth] = useState(0);
// 过滤有效图片 - 支持 uri 或 url 字段
const validImages = useMemo(() => {
const filtered = images.filter(img => img.uri || img.url || typeof img === 'string');
return filtered;
}, [images]);
// 实际显示的图片
const displayImages = useMemo(() => {
return validImages.slice(0, maxDisplayCount);
}, [validImages, maxDisplayCount]);
// 剩余图片数量
const remainingCount = useMemo(() => {
return Math.max(0, validImages.length - maxDisplayCount);
}, [validImages, maxDisplayCount]);
// 处理图片点击
const handleImagePress = useCallback(
(index: number) => {
onImagePress?.(validImages, index);
},
[onImagePress, validImages]
);
// 确定布局模式
const layoutMode = useMemo(() => {
if (mode !== 'auto') return mode;
const count = displayImages.length;
if (count === 1) return 'single';
if (count === 2) return 'horizontal';
return 'grid';
}, [mode, displayImages.length]);
// 渲染单张图片
const renderSingleImage = () => {
const image = displayImages[0];
if (!image) return null;
return (
<SingleImageItem
image={image}
containerWidth={containerWidth}
maxHeight={singleImageMaxHeight}
borderRadiusValue={borderRadiusValue}
onPress={() => handleImagePress(0)}
/>
);
};
// 渲染横向双图
const renderHorizontal = () => {
return (
<View style={[styles.horizontalContainer, { gap }]}>
{displayImages.map((image, index) => (
<Pressable
key={image.id || index}
onPress={() => handleImagePress(index)}
style={[
styles.horizontalItem,
{
flex: 1,
aspectRatio: 1,
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
</Pressable>
))}
</View>
);
};
// 渲染网格布局
const renderGrid = () => {
return (
<View style={[styles.gridContainer, { gap }]}>
{displayImages.map((image, index) => {
const isLastVisible = index === displayImages.length - 1;
const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay;
return (
<Pressable
key={image.id || index}
onPress={() => handleImagePress(index)}
style={[
styles.gridItem,
gridColumns === 3 ? styles.gridItem3 : styles.gridItem2,
{
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
{showOverlay && (
<View style={styles.moreOverlay}>
<Text style={styles.moreText}>+{remainingCount}</Text>
</View>
)}
</Pressable>
);
})}
</View>
);
};
// 渲染瀑布流布局
const renderMasonry = () => {
const containerWidth = SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING;
const columns = 2;
const itemWidth = (containerWidth - gap) / columns;
// 将图片分配到两列
const leftColumn: ImageGridItem[] = [];
const rightColumn: ImageGridItem[] = [];
displayImages.forEach((image, index) => {
if (index % 2 === 0) {
leftColumn.push(image);
} else {
rightColumn.push(image);
}
});
const renderColumn = (columnImages: ImageGridItem[], columnIndex: number) => {
return (
<View style={[styles.masonryColumn, { gap }]}>
{columnImages.map((image, index) => {
const actualIndex = columnIndex + index * 2;
const aspectRatio = image.width && image.height
? image.width / image.height
: 1;
const height = itemWidth / aspectRatio;
return (
<Pressable
key={image.id || actualIndex}
onPress={() => handleImagePress(actualIndex)}
style={[
styles.masonryItem,
{
width: itemWidth,
height: Math.max(height, itemWidth * 0.7),
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
</Pressable>
);
})}
</View>
);
};
return (
<View style={[styles.masonryContainer, { gap }]}>
{renderColumn(leftColumn, 0)}
{renderColumn(rightColumn, 1)}
</View>
);
};
// 根据布局模式渲染
const renderContent = () => {
switch (layoutMode) {
case 'single':
return renderSingleImage();
case 'horizontal':
return renderHorizontal();
case 'masonry':
return renderMasonry();
case 'grid':
default:
return renderGrid();
}
};
// 如果没有图片返回null
if (displayImages.length === 0) {
return null;
}
return (
<View
style={[styles.container, style]}
testID={testID}
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}
>
{renderContent()}
</View>
);
};
// 紧凑模式 - 用于评论等小空间场景
export interface CompactImageGridProps extends Omit<ImageGridProps, 'mode' | 'gridColumns'> {
/** 最大尺寸限制 */
maxWidth?: number;
}
/**
* 紧凑图片网格
* 适用于评论等空间有限的场景
*/
export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
maxWidth,
gap = 4,
borderRadius: borderRadiusValue = borderRadius.sm,
...props
}) => {
const containerWidth = maxWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING - 36 - spacing.sm; // 36是头像宽度
const renderCompactGrid = () => {
const { images } = props;
const count = images.length;
if (count === 0) return null;
if (count === 1) {
const image = images[0];
const size = Math.min(containerWidth * 0.6, 150);
return (
<Pressable
onPress={() => props.onImagePress?.(images, 0)}
style={[
styles.compactItem,
{
width: size,
height: size,
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
</Pressable>
);
}
// 多张图片使用小网格
const columns = count <= 4 ? 2 : 3;
const { itemSize } = calculateGridDimensions(count, containerWidth, gap, columns);
return (
<View style={[styles.compactGrid, { gap }]}>
{images.slice(0, 6).map((image, index) => (
<Pressable
key={image.id || index}
onPress={() => props.onImagePress?.(images, index)}
style={[
styles.compactItem,
{
width: itemSize,
height: itemSize,
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
{index === 5 && images.length > 6 && (
<View style={styles.moreOverlay}>
<Text style={styles.moreText}>+{images.length - 6}</Text>
</View>
)}
</Pressable>
))}
</View>
);
};
return <View style={styles.compactContainer}>{renderCompactGrid()}</View>;
};
const styles = StyleSheet.create({
container: {
marginTop: spacing.sm,
},
fullSize: {
flex: 1,
width: '100%',
height: '100%',
},
// 单图样式
singleContainer: {
overflow: 'hidden',
backgroundColor: colors.background.disabled,
},
// 横向布局样式
horizontalContainer: {
flexDirection: 'row',
},
horizontalItem: {
overflow: 'hidden',
backgroundColor: colors.background.disabled,
},
// 网格布局样式
gridContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
},
gridItem: {
overflow: 'hidden',
backgroundColor: colors.background.disabled,
aspectRatio: 1,
},
gridItem2: {
width: '48%', // 2列布局每列约48%宽度,留有余量避免换行
},
gridItem3: {
width: '31%', // 3列布局每列约31%宽度,留有余量避免换行
},
// 瀑布流样式
masonryContainer: {
flexDirection: 'row',
},
masonryColumn: {
flex: 1,
},
masonryItem: {
overflow: 'hidden',
backgroundColor: colors.background.disabled,
},
// 更多遮罩 - 类似微博的灰色蒙版
moreOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
justifyContent: 'center',
alignItems: 'center',
borderRadius: borderRadius.md,
},
moreText: {
color: colors.text.inverse,
fontSize: fontSizes.xl,
fontWeight: '500',
},
// 紧凑模式样式
compactContainer: {
marginTop: spacing.xs,
},
compactGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
},
compactItem: {
overflow: 'hidden',
backgroundColor: colors.background.disabled,
},
});
// 导入字体大小
import { fontSizes } from '../../theme';
export default ImageGrid;

View File

@@ -0,0 +1,169 @@
/**
* Input 输入框组件
* 支持标签、错误提示、图标、多行输入等
*/
import React, { useState } from 'react';
import {
View,
TextInput,
TouchableOpacity,
StyleSheet,
ViewStyle,
TextStyle,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, borderRadius, spacing, fontSizes } from '../../theme';
import Text from './Text';
interface InputProps {
value: string;
onChangeText: (text: string) => void;
placeholder?: string;
label?: string;
error?: string;
secureTextEntry?: boolean;
multiline?: boolean;
numberOfLines?: number;
leftIcon?: string;
rightIcon?: string;
onRightIconPress?: () => void;
editable?: boolean;
style?: ViewStyle;
inputStyle?: TextStyle;
autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters';
keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad';
autoCorrect?: boolean;
}
const Input: React.FC<InputProps> = ({
value,
onChangeText,
placeholder,
label,
error,
secureTextEntry = false,
multiline = false,
numberOfLines = 1,
leftIcon,
rightIcon,
onRightIconPress,
editable = true,
style,
inputStyle,
autoCapitalize = 'sentences',
keyboardType = 'default',
autoCorrect = true,
}) => {
const [isFocused, setIsFocused] = useState(false);
const getBorderColor = () => {
if (error) return colors.error.main;
if (isFocused) return colors.primary.main;
return colors.divider;
};
const containerStyle = [
styles.container,
{ borderColor: getBorderColor() },
multiline && { minHeight: 100 },
style,
];
return (
<View style={styles.wrapper}>
{label && (
<Text variant="label" color={colors.text.secondary} style={styles.label}>
{label}
</Text>
)}
<View style={containerStyle}>
{leftIcon && (
<MaterialCommunityIcons
name={leftIcon as any}
size={20}
color={colors.text.secondary}
style={styles.leftIcon}
/>
)}
<TextInput
style={[
styles.input,
multiline && styles.multilineInput,
inputStyle,
]}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={colors.text.hint}
secureTextEntry={secureTextEntry}
multiline={multiline}
numberOfLines={numberOfLines}
editable={editable}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
autoCapitalize={autoCapitalize}
keyboardType={keyboardType}
autoCorrect={autoCorrect}
/>
{rightIcon && (
<TouchableOpacity
onPress={onRightIconPress}
disabled={!onRightIconPress}
>
<MaterialCommunityIcons
name={rightIcon as any}
size={20}
color={colors.text.secondary}
style={styles.rightIcon}
/>
</TouchableOpacity>
)}
</View>
{error && (
<Text variant="caption" color={colors.error.main} style={styles.error}>
{error}
</Text>
)}
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
width: '100%',
},
label: {
marginBottom: spacing.xs,
},
container: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderWidth: 1,
borderRadius: borderRadius.md,
paddingHorizontal: spacing.md,
},
input: {
flex: 1,
fontSize: fontSizes.md,
color: colors.text.primary,
paddingVertical: spacing.md,
minHeight: 44,
},
multilineInput: {
textAlignVertical: 'top',
minHeight: 100,
},
leftIcon: {
marginRight: spacing.sm,
},
rightIcon: {
marginLeft: spacing.sm,
},
error: {
marginTop: spacing.xs,
},
});
export default Input;

View File

@@ -0,0 +1,65 @@
/**
* Loading 加载组件
* 支持不同尺寸、全屏模式
*/
import React from 'react';
import { View, ActivityIndicator, StyleSheet, ViewStyle } from 'react-native';
import { colors } from '../../theme';
type LoadingSize = 'sm' | 'md' | 'lg';
interface LoadingProps {
size?: LoadingSize;
color?: string;
fullScreen?: boolean;
style?: ViewStyle;
}
const Loading: React.FC<LoadingProps> = ({
size = 'md',
color = colors.primary.main,
fullScreen = false,
style,
}) => {
const getSize = (): 'small' | 'large' | undefined => {
switch (size) {
case 'sm':
return 'small';
case 'lg':
return 'large';
default:
return undefined;
}
};
if (fullScreen) {
return (
<View style={[styles.fullScreen, style]}>
<ActivityIndicator size={getSize()} color={color} />
</View>
);
}
return (
<View style={[styles.container, style]}>
<ActivityIndicator size={getSize()} color={color} />
</View>
);
};
const styles = StyleSheet.create({
container: {
padding: 20,
alignItems: 'center',
justifyContent: 'center',
},
fullScreen: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background.default,
},
});
export default Loading;

View File

@@ -0,0 +1,62 @@
/**
* 响应式容器组件
* 在宽屏时居中显示并限制最大宽度,在移动端占满宽度
*/
import React from 'react';
import { View, StyleProp, ViewStyle } from 'react-native';
import { useResponsive } from '../../hooks/useResponsive';
export interface ResponsiveContainerProps {
children: React.ReactNode;
maxWidth?: number; // 默认 1200
style?: StyleProp<ViewStyle>;
}
/**
* 响应式容器组件
*
* 根据屏幕尺寸自动调整布局:
* - 移动端:占满宽度
* - 平板及以上:居中显示,限制最大宽度
*
* @param props - 组件属性
* @param props.children - 子元素
* @param props.maxWidth - 最大宽度,默认 1200
* @param props.style - 自定义样式
*
* @example
* <ResponsiveContainer>
* <YourContent />
* </ResponsiveContainer>
*/
export function ResponsiveContainer({
children,
maxWidth = 1200,
style,
}: ResponsiveContainerProps) {
const { isWideScreen, width } = useResponsive();
// 在宽屏时限制最大宽度
const containerWidth = isWideScreen ? Math.min(width, maxWidth) : width;
// 计算水平 padding
const horizontalPadding = isWideScreen ? (width - containerWidth) / 2 : 0;
return (
<View
style={[
{
width: '100%',
maxWidth: isWideScreen ? maxWidth : undefined,
paddingHorizontal: isWideScreen ? Math.max(horizontalPadding, 16) : 16,
},
style,
]}
>
{children}
</View>
);
}
export default ResponsiveContainer;

View File

@@ -0,0 +1,168 @@
/**
* 响应式网格布局组件
* 根据断点自动调整列数,支持间距配置
*/
import React, { useMemo } from 'react';
import {
View,
StyleProp,
ViewStyle,
StyleSheet,
Dimensions,
} from 'react-native';
import {
useResponsive,
useColumnCount,
useResponsiveSpacing,
FineBreakpointKey,
} from '../../hooks/useResponsive';
export interface ResponsiveGridProps {
/** 子元素 */
children: React.ReactNode[];
/** 自定义样式 */
style?: StyleProp<ViewStyle>;
/** 容器样式 */
containerStyle?: StyleProp<ViewStyle>;
/** 项目样式 */
itemStyle?: StyleProp<ViewStyle>;
/** 列数配置,根据断点设置 */
columns?: Partial<Record<FineBreakpointKey, number>>;
/** 间距配置 */
gap?: Partial<Record<FineBreakpointKey, number>>;
/** 行间距(默认等于 gap */
rowGap?: Partial<Record<FineBreakpointKey, number>>;
/** 列间距(默认等于 gap */
columnGap?: Partial<Record<FineBreakpointKey, number>>;
/** 是否启用等宽列 */
equalColumns?: boolean;
/** 自定义列宽计算函数 */
getColumnWidth?: (containerWidth: number, columns: number, gap: number) => number;
/** 渲染空状态 */
renderEmpty?: () => React.ReactNode;
/** key 提取函数 */
keyExtractor?: (item: React.ReactNode, index: number) => string;
}
/**
* 响应式网格布局组件
*
* 根据屏幕宽度自动调整列数,支持平板/桌面端的多列布局
*
* @example
* <ResponsiveGrid
* columns={{ xs: 1, sm: 2, md: 3, lg: 4 }}
* gap={{ xs: 8, md: 16 }}
* >
* {items.map(item => <Card key={item.id} {...item} />)}
* </ResponsiveGrid>
*/
export function ResponsiveGrid({
children,
style,
containerStyle,
itemStyle,
columns: columnsConfig,
gap: gapConfig,
rowGap: rowGapConfig,
columnGap: columnGapConfig,
equalColumns = true,
getColumnWidth,
renderEmpty,
keyExtractor,
}: ResponsiveGridProps) {
const { width } = useResponsive();
const columns = useColumnCount(columnsConfig);
const defaultGap = useResponsiveSpacing(gapConfig);
const rowGap = useResponsiveSpacing(rowGapConfig ?? gapConfig);
const columnGap = useResponsiveSpacing(columnGapConfig ?? gapConfig);
// 计算列宽
const columnWidth = useMemo(() => {
if (getColumnWidth) {
return getColumnWidth(width, columns, columnGap);
}
if (equalColumns) {
// 等宽列计算:(总宽度 - (列数 - 1) * 列间距) / 列数
return (width - (columns - 1) * columnGap) / columns;
}
return undefined;
}, [width, columns, columnGap, equalColumns, getColumnWidth]);
// 将子元素分组为行
const rows = useMemo(() => {
const items = React.Children.toArray(children);
if (items.length === 0) return [];
const result: React.ReactNode[][] = [];
for (let i = 0; i < items.length; i += columns) {
result.push(items.slice(i, i + columns));
}
return result;
}, [children, columns]);
// 空状态处理
if (rows.length === 0 && renderEmpty) {
return (
<View style={[styles.container, containerStyle]}>
{renderEmpty()}
</View>
);
}
return (
<View style={[styles.container, containerStyle]}>
{rows.map((row, rowIndex) => (
<View
key={`row-${rowIndex}`}
style={[
styles.row,
{
marginBottom: rowIndex < rows.length - 1 ? rowGap : 0,
},
style,
]}
>
{row.map((child, colIndex) => {
const index = rowIndex * columns + colIndex;
const key = keyExtractor?.(child, index) ?? `grid-item-${index}`;
return (
<View
key={key}
style={[
styles.item,
{
width: columnWidth,
marginRight: colIndex < row.length - 1 ? columnGap : 0,
},
itemStyle,
]}
>
{child}
</View>
);
})}
</View>
))}
</View>
);
}
const styles = StyleSheet.create({
container: {
width: '100%',
},
row: {
flexDirection: 'row',
alignItems: 'flex-start',
},
item: {
flexShrink: 0,
},
});
export default ResponsiveGrid;

View File

@@ -0,0 +1,212 @@
/**
* 响应式堆叠布局组件
* 移动端垂直堆叠,平板/桌面端水平排列
*/
import React, { useMemo } from 'react';
import {
View,
StyleProp,
ViewStyle,
StyleSheet,
FlexAlignType,
} from 'react-native';
import {
useResponsive,
useResponsiveSpacing,
FineBreakpointKey,
useBreakpointGTE,
} from '../../hooks/useResponsive';
export type StackDirection = 'horizontal' | 'vertical' | 'responsive';
export type StackAlignment = 'start' | 'center' | 'end' | 'stretch' | 'baseline';
export type StackJustify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
export interface ResponsiveStackProps {
/** 子元素 */
children: React.ReactNode;
/** 布局方向 */
direction?: StackDirection;
/** 切换为水平布局的断点(仅在 direction='responsive' 时有效) */
horizontalBreakpoint?: FineBreakpointKey;
/** 间距 */
gap?: Partial<Record<FineBreakpointKey, number>> | number;
/** 是否允许换行 */
wrap?: boolean;
/** 对齐方式(交叉轴) */
align?: StackAlignment;
/** 分布方式(主轴) */
justify?: StackJustify;
/** 自定义样式 */
style?: StyleProp<ViewStyle>;
/** 子元素样式 */
itemStyle?: StyleProp<ViewStyle>;
/** 是否反转顺序 */
reverse?: boolean;
/** 是否等分空间 */
equalItem?: boolean;
}
const alignMap: Record<StackAlignment, FlexAlignType> = {
start: 'flex-start',
center: 'center',
end: 'flex-end',
stretch: 'stretch',
baseline: 'baseline',
};
const justifyMap: Record<StackJustify, 'flex-start' | 'center' | 'flex-end' | 'space-between' | 'space-around' | 'space-evenly'> = {
start: 'flex-start',
center: 'center',
end: 'flex-end',
between: 'space-between',
around: 'space-around',
evenly: 'space-evenly',
};
/**
* 响应式堆叠布局组件
*
* - 移动端垂直堆叠
* - 平板/桌面端水平排列
* - 支持间距和换行配置
*
* @example
* // 基础用法 - 自动响应式
* <ResponsiveStack>
* <Item1 />
* <Item2 />
* <Item3 />
* </ResponsiveStack>
*
* @example
* // 自定义断点和间距
* <ResponsiveStack
* direction="responsive"
* horizontalBreakpoint="md"
* gap={{ xs: 8, md: 16, lg: 24 }}
* align="center"
* justify="between"
* >
* <Item1 />
* <Item2 />
* </ResponsiveStack>
*
* @example
* // 固定水平方向
* <ResponsiveStack direction="horizontal" wrap gap={16}>
* {items.map(item => <Tag key={item.id} {...item} />)}
* </ResponsiveStack>
*/
export function ResponsiveStack({
children,
direction = 'responsive',
horizontalBreakpoint = 'lg',
gap: gapConfig,
wrap = false,
align = 'stretch',
justify = 'start',
style,
itemStyle,
reverse = false,
equalItem = false,
}: ResponsiveStackProps) {
const { isMobile, isTablet } = useResponsive();
const isHorizontalBreakpoint = useBreakpointGTE(horizontalBreakpoint);
// 计算间距
const gap = useMemo(() => {
if (typeof gapConfig === 'number') {
return gapConfig;
}
// 使用 hook 获取响应式间距
return gapConfig;
}, [gapConfig]);
const responsiveGap = useResponsiveSpacing(typeof gap === 'number' ? undefined : gap);
const finalGap = typeof gap === 'number' ? gap : responsiveGap;
// 确定布局方向
const isHorizontal = useMemo(() => {
if (direction === 'horizontal') return true;
if (direction === 'vertical') return false;
// direction === 'responsive'
return isHorizontalBreakpoint;
}, [direction, isHorizontalBreakpoint]);
// 构建容器样式
const containerStyle = useMemo((): ViewStyle => {
const flexDirection = isHorizontal
? (reverse ? 'row-reverse' : 'row')
: (reverse ? 'column-reverse' : 'column');
return {
flexDirection,
flexWrap: wrap ? 'wrap' : 'nowrap',
alignItems: alignMap[align],
justifyContent: justifyMap[justify],
gap: finalGap,
};
}, [isHorizontal, reverse, wrap, align, justify, finalGap]);
// 处理子元素
const processedChildren = useMemo(() => {
const childrenArray = React.Children.toArray(children);
return childrenArray.map((child, index) => {
if (!React.isValidElement(child)) {
return child;
}
const childStyle: ViewStyle = {};
if (equalItem) {
childStyle.flex = 1;
}
// 如果不是最后一个元素,添加间距
// 注意:使用 gap 后不需要手动添加 margin
return (
<View
key={child.key ?? `stack-item-${index}`}
style={[equalItem && styles.equalItem, itemStyle, childStyle]}
>
{child}
</View>
);
});
}, [children, equalItem, itemStyle]);
return (
<View style={[styles.container, containerStyle, style]}>
{processedChildren}
</View>
);
}
const styles = StyleSheet.create({
container: {
width: '100%',
},
equalItem: {
flex: 1,
minWidth: 0, // 防止 flex item 溢出
},
});
/**
* 水平堆叠组件(快捷方式)
*/
export function HStack(props: Omit<ResponsiveStackProps, 'direction'>) {
return <ResponsiveStack {...props} direction="horizontal" />;
}
/**
* 垂直堆叠组件(快捷方式)
*/
export function VStack(props: Omit<ResponsiveStackProps, 'direction'>) {
return <ResponsiveStack {...props} direction="vertical" />;
}
export default ResponsiveStack;

View File

@@ -0,0 +1,277 @@
/**
* SmartImage 智能图片组件
* 支持加载状态、错误处理、自适应尺寸
* 基于 expo-image 封装,原生支持 GIF/WebP 动图
*/
import React, { useState, useCallback, useRef } from 'react';
import {
View,
StyleSheet,
ViewStyle,
ImageStyle,
ActivityIndicator,
Pressable,
StyleProp,
} from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, borderRadius } from '../../theme';
// 图片加载状态
export type ImageLoadState = 'loading' | 'success' | 'error';
// 图片源类型 - 兼容多种数据格式
export interface ImageSource {
uri?: string;
url?: string;
width?: number;
height?: number;
}
// SmartImage Props
export interface SmartImageProps {
/** 图片源 */
source: ImageSource | string;
/** 容器样式 */
style?: StyleProp<ViewStyle>;
/** 图片样式 */
imageStyle?: StyleProp<ImageStyle>;
/** 图片填充模式 */
resizeMode?: 'cover' | 'contain' | 'stretch' | 'repeat' | 'center';
/** 是否显示加载指示器 */
showLoading?: boolean;
/** 是否显示错误占位图 */
showError?: boolean;
/** 圆角大小 */
borderRadius?: number;
/** 点击回调 */
onPress?: () => void;
/** 长按回调 */
onLongPress?: () => void;
/** 加载完成回调 */
onLoad?: () => void;
/** 加载失败回调 */
onError?: (error: any) => void;
/** 测试ID */
testID?: string;
}
/**
* 智能图片组件
* 自动处理加载状态、错误状态
*/
export const SmartImage: React.FC<SmartImageProps> = ({
source,
style,
imageStyle,
resizeMode = 'cover',
showLoading = true,
showError = true,
borderRadius: borderRadiusValue = 0,
onPress,
onLongPress,
onLoad,
onError,
testID,
}) => {
const [loadState, setLoadState] = useState<ImageLoadState>('loading');
// 解析图片源 - 支持 uri 或 url 字段
const imageUri = typeof source === 'string'
? source
: (source.uri || source.url || '');
// 处理加载开始
const handleLoadStart = useCallback(() => {
setLoadState('loading');
}, []);
// 处理加载完成
const handleLoad = useCallback(() => {
setLoadState('success');
onLoad?.();
}, [onLoad]);
// 处理加载错误
const handleError = useCallback(
(error: any) => {
setLoadState('error');
onError?.(error);
},
[onError]
);
// 重试加载
const handleRetry = useCallback(() => {
setLoadState('loading');
}, []);
// 渲染加载指示器
const renderLoading = () => {
if (!showLoading || loadState !== 'loading') return null;
return (
<View style={styles.overlay}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={colors.primary.main} />
</View>
</View>
);
};
// 渲染错误占位图
const renderError = () => {
if (!showError || loadState !== 'error') return null;
return (
<Pressable style={styles.overlay} onPress={handleRetry}>
<View style={styles.errorContainer}>
<MaterialCommunityIcons
name="image-off-outline"
size={24}
color={colors.text.hint}
/>
</View>
</Pressable>
);
};
// 容器样式
const containerStyle: ViewStyle = {
borderRadius: borderRadiusValue,
overflow: 'hidden',
};
// 图片源配置
const imageSource = imageUri && imageUri.trim() !== '' ? { uri: imageUri } : undefined;
// 如果没有有效的图片源,显示错误占位
if (!imageSource) {
return (
<Pressable
style={[containerStyle, style as ViewStyle, { backgroundColor: colors.background.disabled }]}
testID={testID}
>
<View style={styles.errorContainer}>
<MaterialCommunityIcons
name="image-off-outline"
size={24}
color={colors.text.hint}
/>
</View>
</Pressable>
);
}
return (
<Pressable
style={[containerStyle, style as ViewStyle]}
onPress={onPress}
onLongPress={onLongPress}
disabled={!onPress && !onLongPress}
testID={testID}
>
<ExpoImage
source={imageSource}
style={[styles.image, imageStyle as ImageStyle]}
contentFit={
resizeMode === 'stretch' ? 'fill' :
resizeMode === 'repeat' ? 'cover' :
resizeMode === 'center' ? 'scale-down' :
resizeMode
}
cachePolicy="memory-disk"
onLoadStart={handleLoadStart}
onLoad={handleLoad}
onError={handleError}
/>
{renderLoading()}
{renderError()}
</Pressable>
);
};
// 预定义尺寸变体
export interface ImageVariantProps extends Omit<SmartImageProps, 'style'> {
/** 尺寸变体 */
variant?: 'thumbnail' | 'small' | 'medium' | 'large' | 'full';
/** 自定义尺寸 */
size?: number;
/** 宽高比 */
aspectRatio?: number;
}
/**
* 变体图片组件
* 提供预定义的尺寸变体
*/
export const VariantImage: React.FC<ImageVariantProps> = ({
variant = 'medium',
size,
aspectRatio,
imageStyle,
...props
}) => {
const getVariantStyle = (): ViewStyle => {
switch (variant) {
case 'thumbnail':
return { width: size || 40, height: size || 40 };
case 'small':
return { width: size || 80, height: size || 80 };
case 'medium':
return { width: size || 120, height: size || 120 };
case 'large':
return { width: size || 200, height: size || 200 };
case 'full':
return { flex: 1 };
default:
return { width: size || 120, height: size || 120 };
}
};
const variantStyle = getVariantStyle();
// 如果有宽高比,调整高度
const finalStyle: ViewStyle = { ...variantStyle };
if (aspectRatio && finalStyle.width && !size) {
finalStyle.height = (finalStyle.width as number) / aspectRatio;
}
return (
<SmartImage
{...props}
style={finalStyle}
imageStyle={[styles.variantImage, imageStyle]}
/>
);
};
const styles = StyleSheet.create({
image: {
flex: 1,
width: '100%',
height: '100%',
},
variantImage: {
flex: 1,
},
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.disabled,
},
loadingContainer: {
padding: 8,
borderRadius: borderRadius.md,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
},
errorContainer: {
padding: 12,
borderRadius: borderRadius.md,
backgroundColor: 'rgba(0, 0, 0, 0.05)',
},
});
export default SmartImage;

View File

@@ -0,0 +1,96 @@
/**
* Text 文本组件
* 提供统一的文本样式
*/
import React from 'react';
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native';
import { colors, fontSizes } from '../../theme';
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
interface CustomTextProps extends Omit<TextProps, 'style'> {
children: React.ReactNode;
variant?: TextVariant;
color?: string;
numberOfLines?: number;
onPress?: () => void;
style?: TextStyle | TextStyle[];
}
const variantStyles: Record<TextVariant, object> = {
h1: {
fontSize: fontSizes['4xl'],
fontWeight: '700',
lineHeight: fontSizes['4xl'] * 1.4,
},
h2: {
fontSize: fontSizes['3xl'],
fontWeight: '600',
lineHeight: fontSizes['3xl'] * 1.4,
},
h3: {
fontSize: fontSizes['2xl'],
fontWeight: '600',
lineHeight: fontSizes['2xl'] * 1.3,
},
body: {
fontSize: fontSizes.md,
fontWeight: '400',
lineHeight: fontSizes.md * 1.5,
},
caption: {
fontSize: fontSizes.sm,
fontWeight: '400',
lineHeight: fontSizes.sm * 1.4,
},
label: {
fontSize: fontSizes.xs,
fontWeight: '500',
lineHeight: fontSizes.xs * 1.4,
},
};
const Text: React.FC<CustomTextProps> = ({
children,
variant = 'body',
color,
numberOfLines,
onPress,
style,
...props
}) => {
const textStyle = [
styles.base,
variantStyles[variant],
color ? { color } : { color: colors.text.primary },
style,
];
if (onPress) {
return (
<RNText
style={textStyle}
numberOfLines={numberOfLines}
onPress={onPress}
{...props}
>
{children}
</RNText>
);
}
return (
<RNText style={textStyle} numberOfLines={numberOfLines} {...props}>
{children}
</RNText>
);
};
const styles = StyleSheet.create({
base: {
fontFamily: undefined, // 使用系统默认字体
},
});
export default Text;

View File

@@ -0,0 +1,145 @@
/**
* VideoPlayerModal 视频播放弹窗组件
* 全屏模态播放视频,基于 expo-video
*/
import React, { useCallback } from 'react';
import {
Modal,
View,
StyleSheet,
Dimensions,
TouchableOpacity,
Text,
StatusBar,
} from 'react-native';
import { VideoView, useVideoPlayer } from 'expo-video';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { spacing, fontSizes, borderRadius } from '../../theme';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
export interface VideoPlayerModalProps {
/** 是否可见 */
visible: boolean;
/** 视频 URL */
url: string;
/** 关闭回调 */
onClose: () => void;
}
/**
* 视频播放器内容 - 独立组件,仅在弹窗可见时挂载,避免预加载
*/
const VideoPlayerContent: React.FC<{ url: string; onClose: () => void }> = ({ url, onClose }) => {
const insets = useSafeAreaInsets();
const player = useVideoPlayer(url, (p) => {
p.loop = false;
p.play();
});
const handleClose = useCallback(() => {
player.pause();
onClose();
}, [player, onClose]);
return (
<View style={styles.container}>
<StatusBar hidden />
{/* 顶部关闭按钮 */}
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
<TouchableOpacity style={styles.closeButton} onPress={handleClose}>
<MaterialCommunityIcons name="close" size={24} color="#FFF" />
</TouchableOpacity>
<Text style={styles.title}></Text>
<View style={styles.placeholder} />
</View>
{/* 视频播放区域 */}
<View style={styles.videoContainer}>
<VideoView
player={player}
style={styles.video}
contentFit="contain"
nativeControls
/>
</View>
</View>
);
};
/**
* 视频播放弹窗 - 仅在可见时渲染播放器,避免提前加载视频资源
*/
export const VideoPlayerModal: React.FC<VideoPlayerModalProps> = ({
visible,
url,
onClose,
}) => {
return (
<Modal
visible={visible}
transparent={false}
animationType="fade"
onRequestClose={onClose}
statusBarTranslucent
supportedOrientations={['portrait', 'landscape']}
>
{visible && url ? (
<VideoPlayerContent url={url} onClose={onClose} />
) : (
<View style={styles.container} />
)}
</Modal>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingBottom: spacing.md,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
},
closeButton: {
width: 40,
height: 40,
borderRadius: borderRadius.full,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
},
title: {
color: '#FFF',
fontSize: fontSizes.md,
fontWeight: '600',
},
placeholder: {
width: 40,
},
videoContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
video: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
},
});
export default VideoPlayerModal;

View File

@@ -0,0 +1,33 @@
/**
* 通用组件导出
*/
export { default as Avatar } from './Avatar';
export { default as Button } from './Button';
export { default as Card } from './Card';
export { default as Input } from './Input';
export { default as Loading } from './Loading';
export { default as Text } from './Text';
export { default as EmptyState } from './EmptyState';
export { default as Divider } from './Divider';
export { default as ResponsiveContainer } from './ResponsiveContainer';
// 响应式布局组件
export { default as ResponsiveGrid } from './ResponsiveGrid';
export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack';
export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout';
// 图片相关组件
export { default as SmartImage } from './SmartImage';
export { default as ImageGrid, CompactImageGrid } from './ImageGrid';
export { default as ImageGallery } from './ImageGallery';
// 类型导出
export type { SmartImageProps, ImageLoadState, ImageSource } from './SmartImage';
export type { ImageGridProps, ImageGridItem, GridLayoutMode, CompactImageGridProps } from './ImageGrid';
export type { ImageGalleryProps, GalleryImageItem } from './ImageGallery';
// 响应式组件类型导出
export type { ResponsiveGridProps } from './ResponsiveGrid';
export type { ResponsiveStackProps, StackDirection, StackAlignment, StackJustify } from './ResponsiveStack';
export type { AdaptiveLayoutProps, SidebarLayoutProps } from './AdaptiveLayout';