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:
612
src/components/business/CommentItem.tsx
Normal file
612
src/components/business/CommentItem.tsx
Normal 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;
|
||||
147
src/components/business/NotificationItem.tsx
Normal file
147
src/components/business/NotificationItem.tsx
Normal 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;
|
||||
911
src/components/business/PostCard.tsx
Normal file
911
src/components/business/PostCard.tsx
Normal 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;
|
||||
134
src/components/business/SearchBar.tsx
Normal file
134
src/components/business/SearchBar.tsx
Normal 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;
|
||||
369
src/components/business/SystemMessageItem.tsx
Normal file
369
src/components/business/SystemMessageItem.tsx
Normal 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;
|
||||
347
src/components/business/TabBar.tsx
Normal file
347
src/components/business/TabBar.tsx
Normal 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;
|
||||
541
src/components/business/UserProfileHeader.tsx
Normal file
541
src/components/business/UserProfileHeader.tsx
Normal 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;
|
||||
370
src/components/business/VoteCard.tsx
Normal file
370
src/components/business/VoteCard.tsx
Normal 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;
|
||||
203
src/components/business/VoteEditor.tsx
Normal file
203
src/components/business/VoteEditor.tsx
Normal 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;
|
||||
109
src/components/business/VotePreview.tsx
Normal file
109
src/components/business/VotePreview.tsx
Normal 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;
|
||||
14
src/components/business/index.ts
Normal file
14
src/components/business/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user