2026-03-09 21:29:03 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 帖子详情页 PostDetailScreen - QQ频道风格(响应式版本)
|
|
|
|
|
|
* 胡萝卜BBS - 显示完整帖子内容和评论
|
|
|
|
|
|
* 桌面端使用双栏布局,移动端使用单栏布局
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
|
|
|
|
import {
|
|
|
|
|
|
View,
|
|
|
|
|
|
FlatList,
|
|
|
|
|
|
StyleSheet,
|
|
|
|
|
|
RefreshControl,
|
|
|
|
|
|
TouchableOpacity,
|
|
|
|
|
|
TextInput,
|
|
|
|
|
|
Keyboard,
|
|
|
|
|
|
Platform,
|
|
|
|
|
|
Modal,
|
|
|
|
|
|
KeyboardEvent,
|
|
|
|
|
|
KeyboardAvoidingView,
|
|
|
|
|
|
Alert,
|
|
|
|
|
|
ScrollView,
|
|
|
|
|
|
Image,
|
2026-03-10 12:58:23 +08:00
|
|
|
|
Clipboard,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
} from 'react-native';
|
|
|
|
|
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
|
|
|
|
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
|
|
|
|
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
|
|
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
|
|
|
|
import * as ImagePicker from 'expo-image-picker';
|
|
|
|
|
|
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
|
|
|
|
|
import { Post, Comment, VoteResultDTO } from '../../types';
|
|
|
|
|
|
import { useUserStore } from '../../stores';
|
|
|
|
|
|
import { useCurrentUser } from '../../stores/authStore';
|
|
|
|
|
|
import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services';
|
|
|
|
|
|
import { CommentItem, VoteCard } from '../../components/business';
|
|
|
|
|
|
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout } from '../../components/common';
|
2026-03-10 12:58:23 +08:00
|
|
|
|
import { RootStackParamList } from '../../navigation/types';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive';
|
|
|
|
|
|
|
2026-03-10 12:58:23 +08:00
|
|
|
|
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'PostDetail'>;
|
|
|
|
|
|
type PostDetailRouteProp = RouteProp<RootStackParamList, 'PostDetail'>;
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
export const PostDetailScreen: React.FC = () => {
|
|
|
|
|
|
const navigation = useNavigation<NavigationProp>();
|
|
|
|
|
|
const route = useRoute<PostDetailRouteProp>();
|
|
|
|
|
|
const insets = useSafeAreaInsets();
|
|
|
|
|
|
const postId = route.params?.postId || '';
|
|
|
|
|
|
const shouldScrollToComments = route.params?.scrollToComments || false;
|
|
|
|
|
|
|
|
|
|
|
|
// 使用响应式 hook
|
|
|
|
|
|
const {
|
|
|
|
|
|
width,
|
|
|
|
|
|
isMobile,
|
|
|
|
|
|
isTablet,
|
|
|
|
|
|
isDesktop,
|
|
|
|
|
|
isWideScreen,
|
|
|
|
|
|
isLandscape
|
|
|
|
|
|
} = useResponsive();
|
|
|
|
|
|
|
|
|
|
|
|
// 响应式间距
|
|
|
|
|
|
const responsivePadding = useResponsiveSpacing({ xs: 12, sm: 14, md: 16, lg: 20, xl: 24, '2xl': 28, '3xl': 32, '4xl': 40 });
|
|
|
|
|
|
const responsiveGap = useResponsiveSpacing({ xs: 8, sm: 10, md: 12, lg: 16, xl: 20, '2xl': 24, '3xl': 28, '4xl': 32 });
|
|
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
|
likePost,
|
|
|
|
|
|
unlikePost,
|
|
|
|
|
|
favoritePost,
|
|
|
|
|
|
unfavoritePost,
|
|
|
|
|
|
likeComment,
|
|
|
|
|
|
unlikeComment
|
|
|
|
|
|
} = useUserStore();
|
|
|
|
|
|
|
|
|
|
|
|
const currentUser = useCurrentUser();
|
|
|
|
|
|
|
|
|
|
|
|
const [post, setPost] = useState<Post | null>(null);
|
|
|
|
|
|
const [comments, setComments] = useState<Comment[]>([]);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
|
|
const [commentText, setCommentText] = useState('');
|
|
|
|
|
|
const [showImageModal, setShowImageModal] = useState(false);
|
|
|
|
|
|
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
|
|
|
|
|
const [allImages, setAllImages] = useState<ImageGridItem[]>([]);
|
|
|
|
|
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
|
|
|
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
|
|
// 评论图片相关状态
|
|
|
|
|
|
const [commentImages, setCommentImages] = useState<{ uri: string; uploading?: boolean }[]>([]);
|
|
|
|
|
|
const [isFollowing, setIsFollowing] = useState(false);
|
|
|
|
|
|
const [isFollowingMe, setIsFollowingMe] = useState(false);
|
|
|
|
|
|
const [isFollowLoading, setIsFollowLoading] = useState(false);
|
|
|
|
|
|
const flatListRef = useRef<FlatList>(null);
|
|
|
|
|
|
const hasRecordedView = useRef(false); // 是否已记录浏览量
|
|
|
|
|
|
|
|
|
|
|
|
// 投票相关状态
|
|
|
|
|
|
const [voteResult, setVoteResult] = useState<VoteResultDTO | null>(null);
|
|
|
|
|
|
const [isVoteLoading, setIsVoteLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
// 桌面端侧边栏宽度
|
|
|
|
|
|
const sidebarWidth = useResponsiveValue({
|
|
|
|
|
|
xs: 0,
|
|
|
|
|
|
sm: 0,
|
|
|
|
|
|
md: 280,
|
|
|
|
|
|
lg: 320,
|
|
|
|
|
|
xl: 360,
|
|
|
|
|
|
'2xl': 400,
|
|
|
|
|
|
'3xl': 440,
|
|
|
|
|
|
'4xl': 480,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 是否使用双栏布局(桌面端且横屏)
|
|
|
|
|
|
const useDualColumnLayout = useMemo(() => {
|
|
|
|
|
|
return (isDesktop || isWideScreen) && width >= 1024;
|
|
|
|
|
|
}, [isDesktop, isWideScreen, width]);
|
|
|
|
|
|
|
|
|
|
|
|
// 加载帖子详情(可选择是否记录浏览量)
|
|
|
|
|
|
const loadPostDetail = useCallback(async (recordView: boolean = false) => {
|
|
|
|
|
|
if (!postId) {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 从API获取帖子详情
|
|
|
|
|
|
const postData = await postService.getPost(postId);
|
|
|
|
|
|
if (postData) {
|
|
|
|
|
|
setPost(postData);
|
|
|
|
|
|
// 初始化关注状态
|
|
|
|
|
|
if (postData.author) {
|
|
|
|
|
|
setIsFollowing(postData.author.is_following || false);
|
|
|
|
|
|
setIsFollowingMe(postData.author.is_following_me || false);
|
|
|
|
|
|
}
|
|
|
|
|
|
// 只在首次加载时记录浏览量
|
|
|
|
|
|
if (recordView && !hasRecordedView.current) {
|
|
|
|
|
|
hasRecordedView.current = true;
|
|
|
|
|
|
// 异步记录浏览量,不阻塞加载
|
|
|
|
|
|
postService.recordView(postId).catch(err => {
|
|
|
|
|
|
console.error('记录浏览量失败:', err);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果是投票帖子,立即加载投票数据
|
|
|
|
|
|
if (postData.is_vote) {
|
|
|
|
|
|
setIsVoteLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const voteData = await voteService.getVoteResult(postId);
|
|
|
|
|
|
if (voteData) {
|
|
|
|
|
|
setVoteResult(voteData);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (voteErr) {
|
|
|
|
|
|
console.error('加载投票数据失败:', voteErr);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsVoteLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 如果API返回空,尝试从store中获取
|
|
|
|
|
|
const storePosts = useUserStore.getState().posts;
|
|
|
|
|
|
const updatedPost = storePosts.find(p => p.id === postId);
|
|
|
|
|
|
if (updatedPost) {
|
|
|
|
|
|
setPost(updatedPost);
|
|
|
|
|
|
// 初始化关注状态
|
|
|
|
|
|
if (updatedPost.author) {
|
|
|
|
|
|
setIsFollowing(updatedPost.author.is_following || false);
|
|
|
|
|
|
setIsFollowingMe(updatedPost.author.is_following_me || false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 加载评论
|
|
|
|
|
|
const commentsData = await commentService.getPostComments(postId);
|
|
|
|
|
|
setComments(commentsData.list);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('加载帖子详情失败:', error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [postId]);
|
|
|
|
|
|
|
|
|
|
|
|
// 首次加载时记录浏览量
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadPostDetail(true);
|
|
|
|
|
|
}, [loadPostDetail]);
|
|
|
|
|
|
|
2026-03-10 12:58:23 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const unsubscribe = navigation.addListener('focus', () => {
|
|
|
|
|
|
loadPostDetail(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
return unsubscribe;
|
|
|
|
|
|
}, [navigation, loadPostDetail]);
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
// 如果是从评论按钮跳转过来的,加载完成后滚动到评论区
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (shouldScrollToComments && !loading && comments.length > 0) {
|
|
|
|
|
|
// 延迟确保列表已完成渲染和布局
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
flatListRef.current?.scrollToIndex({ index: 0, animated: true, viewPosition: 0 });
|
|
|
|
|
|
}, 500);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [shouldScrollToComments, loading, comments.length]);
|
|
|
|
|
|
|
|
|
|
|
|
// 动态设置导航头部
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!post?.author) return;
|
|
|
|
|
|
|
|
|
|
|
|
const author = post.author;
|
|
|
|
|
|
|
|
|
|
|
|
navigation.setOptions({
|
|
|
|
|
|
headerTitle: () => (
|
|
|
|
|
|
<View style={styles.headerContainer}>
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
onPress={() => handleUserPress(author.id)}
|
|
|
|
|
|
style={styles.headerAvatarWrapper}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Avatar
|
|
|
|
|
|
source={author.avatar}
|
|
|
|
|
|
size={32}
|
|
|
|
|
|
name={author.nickname}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
<View style={styles.headerUserInfo}>
|
|
|
|
|
|
<TouchableOpacity onPress={() => handleUserPress(author.id)}>
|
|
|
|
|
|
<Text style={styles.headerNickname} numberOfLines={1}>
|
|
|
|
|
|
{author.nickname}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
),
|
|
|
|
|
|
headerRight: () => (
|
|
|
|
|
|
<View style={styles.headerRightContainer}>
|
|
|
|
|
|
{renderFollowButton()}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
),
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [post?.author, isFollowing, isFollowingMe, isFollowLoading, navigation]);
|
|
|
|
|
|
|
|
|
|
|
|
// 监听键盘事件
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const keyboardWillShow = (e: KeyboardEvent) => {
|
|
|
|
|
|
setKeyboardHeight(e.endCoordinates.height);
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
flatListRef.current?.scrollToEnd({ animated: true });
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const keyboardWillHide = () => {
|
|
|
|
|
|
setKeyboardHeight(0);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const showSubscription = Keyboard.addListener(
|
|
|
|
|
|
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
|
|
|
|
|
|
keyboardWillShow
|
|
|
|
|
|
);
|
|
|
|
|
|
const hideSubscription = Keyboard.addListener(
|
|
|
|
|
|
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
|
|
|
|
|
|
keyboardWillHide
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
showSubscription.remove();
|
|
|
|
|
|
hideSubscription.remove();
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// 下拉刷新
|
|
|
|
|
|
const onRefresh = useCallback(() => {
|
|
|
|
|
|
setRefreshing(true);
|
|
|
|
|
|
loadPostDetail();
|
|
|
|
|
|
setRefreshing(false);
|
|
|
|
|
|
}, [loadPostDetail]);
|
|
|
|
|
|
|
2026-03-10 12:58:23 +08:00
|
|
|
|
const formatDateTime = (dateString?: string | null): string => {
|
|
|
|
|
|
if (!dateString) return '';
|
|
|
|
|
|
const date = new Date(dateString);
|
|
|
|
|
|
if (Number.isNaN(date.getTime())) return '';
|
|
|
|
|
|
const pad = (num: number) => String(num).padStart(2, '0');
|
|
|
|
|
|
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const formatRelativeTime = (dateString?: string | null): string => {
|
|
|
|
|
|
if (!dateString) return '';
|
|
|
|
|
|
const date = new Date(dateString);
|
|
|
|
|
|
if (Number.isNaN(date.getTime())) return '';
|
|
|
|
|
|
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
const diffMs = now - date.getTime();
|
|
|
|
|
|
if (diffMs < 0) return formatDateTime(dateString);
|
|
|
|
|
|
|
|
|
|
|
|
const minuteMs = 60 * 1000;
|
|
|
|
|
|
const hourMs = 60 * minuteMs;
|
|
|
|
|
|
const dayMs = 24 * hourMs;
|
|
|
|
|
|
|
|
|
|
|
|
if (diffMs < minuteMs) return '刚刚';
|
|
|
|
|
|
if (diffMs < hourMs) return `${Math.floor(diffMs / minuteMs)}分钟前`;
|
|
|
|
|
|
if (diffMs < dayMs) return `${Math.floor(diffMs / hourMs)}小时前`;
|
|
|
|
|
|
if (diffMs < 2 * dayMs) {
|
|
|
|
|
|
const pad = (num: number) => String(num).padStart(2, '0');
|
|
|
|
|
|
return `昨天 ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
2026-03-10 12:58:23 +08:00
|
|
|
|
|
|
|
|
|
|
return formatDateTime(dateString);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const isPostEdited = (createdAt?: string, updatedAt?: string): boolean => {
|
|
|
|
|
|
if (!createdAt || !updatedAt) return false;
|
|
|
|
|
|
const created = new Date(createdAt).getTime();
|
|
|
|
|
|
const updated = new Date(updatedAt).getTime();
|
|
|
|
|
|
if (Number.isNaN(created) || Number.isNaN(updated)) return false;
|
|
|
|
|
|
return updated-created > 1000;
|
2026-03-09 21:29:03 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化数字
|
|
|
|
|
|
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 handleLike = useCallback(async () => {
|
|
|
|
|
|
if (!post) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 先保存旧状态用于回滚
|
|
|
|
|
|
const oldIsLiked = post.is_liked;
|
|
|
|
|
|
const oldLikesCount = post.likes_count;
|
|
|
|
|
|
|
|
|
|
|
|
// 乐观更新本地状态
|
|
|
|
|
|
setPost(prev => prev ? {
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
is_liked: !prev.is_liked,
|
|
|
|
|
|
likes_count: prev.is_liked ? prev.likes_count - 1 : prev.likes_count + 1
|
|
|
|
|
|
} : null);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (oldIsLiked) {
|
|
|
|
|
|
await unlikePost(post.id);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await likePost(post.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('点赞操作失败:', error);
|
|
|
|
|
|
// 失败时回滚状态
|
|
|
|
|
|
setPost(prev => prev ? {
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
is_liked: oldIsLiked,
|
|
|
|
|
|
likes_count: oldLikesCount
|
|
|
|
|
|
} : null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [post, likePost, unlikePost]);
|
|
|
|
|
|
|
|
|
|
|
|
// 收藏帖子
|
|
|
|
|
|
const handleFavorite = useCallback(async () => {
|
|
|
|
|
|
if (!post) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 先保存旧状态用于回滚
|
|
|
|
|
|
const oldIsFavorited = post.is_favorited;
|
|
|
|
|
|
const oldFavoritesCount = post.favorites_count;
|
|
|
|
|
|
|
|
|
|
|
|
// 乐观更新本地状态
|
|
|
|
|
|
setPost(prev => prev ? {
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
is_favorited: !prev.is_favorited,
|
|
|
|
|
|
favorites_count: prev.is_favorited ? prev.favorites_count - 1 : prev.favorites_count + 1
|
|
|
|
|
|
} : null);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (oldIsFavorited) {
|
|
|
|
|
|
await unfavoritePost(post.id);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await favoritePost(post.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('收藏操作失败:', error);
|
|
|
|
|
|
// 失败时回滚状态
|
|
|
|
|
|
setPost(prev => prev ? {
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
is_favorited: oldIsFavorited,
|
|
|
|
|
|
favorites_count: oldFavoritesCount
|
|
|
|
|
|
} : null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [post, favoritePost, unfavoritePost]);
|
|
|
|
|
|
|
|
|
|
|
|
// 分享帖子
|
2026-03-10 12:58:23 +08:00
|
|
|
|
const handleShare = useCallback(async () => {
|
|
|
|
|
|
if (!post?.id) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await postService.sharePost(post.id);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('上报分享次数失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`;
|
|
|
|
|
|
Clipboard.setString(postUrl);
|
|
|
|
|
|
Alert.alert('已复制', '帖子链接已复制到剪贴板');
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}, [post?.id]);
|
|
|
|
|
|
|
|
|
|
|
|
// 投票处理函数
|
|
|
|
|
|
const handleVote = useCallback(async (optionId: string) => {
|
|
|
|
|
|
if (!post || !voteResult || isVoteLoading) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 保存旧状态用于回滚
|
|
|
|
|
|
const oldVoteResult = { ...voteResult };
|
|
|
|
|
|
|
|
|
|
|
|
// 乐观更新
|
|
|
|
|
|
setVoteResult(prev => {
|
|
|
|
|
|
if (!prev) return null;
|
|
|
|
|
|
return {
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
has_voted: true,
|
|
|
|
|
|
voted_option_id: optionId,
|
|
|
|
|
|
total_votes: prev.total_votes + 1,
|
|
|
|
|
|
options: prev.options.map(opt =>
|
|
|
|
|
|
opt.id === optionId
|
|
|
|
|
|
? { ...opt, votes_count: opt.votes_count + 1 }
|
|
|
|
|
|
: opt
|
|
|
|
|
|
),
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
setIsVoteLoading(true);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const success = await voteService.vote(post.id, optionId);
|
|
|
|
|
|
if (!success) {
|
|
|
|
|
|
// 失败时回滚
|
|
|
|
|
|
setVoteResult(oldVoteResult);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('投票失败:', error);
|
|
|
|
|
|
// 失败时回滚
|
|
|
|
|
|
setVoteResult(oldVoteResult);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsVoteLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [post, voteResult, isVoteLoading]);
|
|
|
|
|
|
|
|
|
|
|
|
// 取消投票处理函数
|
|
|
|
|
|
const handleUnvote = useCallback(async () => {
|
|
|
|
|
|
if (!post || !voteResult || isVoteLoading || !voteResult.voted_option_id) return;
|
|
|
|
|
|
|
|
|
|
|
|
const votedOptionId = voteResult.voted_option_id;
|
|
|
|
|
|
|
|
|
|
|
|
// 保存旧状态用于回滚
|
|
|
|
|
|
const oldVoteResult = { ...voteResult };
|
|
|
|
|
|
|
|
|
|
|
|
// 乐观更新
|
|
|
|
|
|
setVoteResult(prev => {
|
|
|
|
|
|
if (!prev) return null;
|
|
|
|
|
|
return {
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
has_voted: false,
|
|
|
|
|
|
voted_option_id: undefined,
|
|
|
|
|
|
total_votes: Math.max(0, prev.total_votes - 1),
|
|
|
|
|
|
options: prev.options.map(opt =>
|
|
|
|
|
|
opt.id === votedOptionId
|
|
|
|
|
|
? { ...opt, votes_count: Math.max(0, opt.votes_count - 1) }
|
|
|
|
|
|
: opt
|
|
|
|
|
|
),
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
setIsVoteLoading(true);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const success = await voteService.unvote(post.id);
|
|
|
|
|
|
if (!success) {
|
|
|
|
|
|
// 失败时回滚
|
|
|
|
|
|
setVoteResult(oldVoteResult);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('取消投票失败:', error);
|
|
|
|
|
|
// 失败时回滚
|
|
|
|
|
|
setVoteResult(oldVoteResult);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsVoteLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [post, voteResult, isVoteLoading]);
|
|
|
|
|
|
|
|
|
|
|
|
// 删除帖子
|
|
|
|
|
|
const handleDeletePost = useCallback(() => {
|
|
|
|
|
|
if (!post || isDeleting) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否为帖子作者
|
|
|
|
|
|
if (currentUser?.id !== post.author?.id) {
|
|
|
|
|
|
Alert.alert('无法删除', '只能删除自己的帖子');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Alert.alert(
|
|
|
|
|
|
'删除帖子',
|
|
|
|
|
|
'确定要删除这篇帖子吗?删除后将无法恢复。',
|
|
|
|
|
|
[
|
|
|
|
|
|
{
|
|
|
|
|
|
text: '取消',
|
|
|
|
|
|
style: 'cancel',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
text: '删除',
|
|
|
|
|
|
style: 'destructive',
|
|
|
|
|
|
onPress: async () => {
|
|
|
|
|
|
setIsDeleting(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const success = await postService.deletePost(post.id);
|
|
|
|
|
|
if (success) {
|
|
|
|
|
|
Alert.alert('删除成功', '帖子已删除', [
|
|
|
|
|
|
{
|
|
|
|
|
|
text: '确定',
|
|
|
|
|
|
onPress: () => navigation.goBack(),
|
|
|
|
|
|
},
|
|
|
|
|
|
]);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Alert.alert('删除失败', '删除帖子时发生错误,请稍后重试');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('删除帖子失败:', error);
|
|
|
|
|
|
Alert.alert('删除失败', '删除帖子时发生错误,请稍后重试');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsDeleting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}, [post, isDeleting, currentUser?.id, navigation]);
|
|
|
|
|
|
|
2026-03-10 12:58:23 +08:00
|
|
|
|
const handleEditPost = useCallback(() => {
|
|
|
|
|
|
if (!post) return;
|
|
|
|
|
|
navigation.navigate('CreatePost', {
|
|
|
|
|
|
mode: 'edit',
|
|
|
|
|
|
postId: post.id,
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [navigation, post]);
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
// 点击图片查看大图
|
|
|
|
|
|
const handleImagePress = useCallback((images: ImageGridItem[], index: number) => {
|
|
|
|
|
|
setAllImages(images);
|
|
|
|
|
|
setSelectedImageIndex(index);
|
|
|
|
|
|
setShowImageModal(true);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// 选择评论图片
|
|
|
|
|
|
const handlePickCommentImage = async () => {
|
|
|
|
|
|
if (commentImages.length >= 3) {
|
|
|
|
|
|
Alert.alert('提示', '评论最多只能添加3张图片');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
|
|
|
|
|
|
|
|
|
|
if (!permissionResult.granted) {
|
|
|
|
|
|
Alert.alert('权限不足', '需要访问相册权限来选择图片');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const result = await ImagePicker.launchImageLibraryAsync({
|
|
|
|
|
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
|
|
|
|
allowsMultipleSelection: true,
|
|
|
|
|
|
selectionLimit: 3 - commentImages.length,
|
|
|
|
|
|
quality: 0.8,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!result.canceled && result.assets) {
|
|
|
|
|
|
const newImages = result.assets.map(asset => ({ uri: asset.uri, uploading: true }));
|
|
|
|
|
|
setCommentImages(prev => [...prev, ...newImages]);
|
|
|
|
|
|
|
|
|
|
|
|
// 上传图片
|
|
|
|
|
|
for (let i = 0; i < newImages.length; i++) {
|
|
|
|
|
|
const asset = result.assets[i];
|
|
|
|
|
|
const uploadResult = await uploadService.uploadImage({
|
|
|
|
|
|
uri: asset.uri,
|
|
|
|
|
|
type: asset.mimeType || 'image/jpeg',
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (uploadResult) {
|
|
|
|
|
|
setCommentImages(prev => {
|
|
|
|
|
|
const updated = [...prev];
|
|
|
|
|
|
const index = prev.findIndex(img => img.uri === asset.uri);
|
|
|
|
|
|
if (index !== -1) {
|
|
|
|
|
|
updated[index] = { uri: uploadResult.url, uploading: false };
|
|
|
|
|
|
}
|
|
|
|
|
|
return updated;
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Alert.alert('上传失败', '图片上传失败,请重试');
|
|
|
|
|
|
setCommentImages(prev => prev.filter(img => img.uri !== asset.uri));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 删除评论图片
|
|
|
|
|
|
const handleRemoveCommentImage = (index: number) => {
|
|
|
|
|
|
setCommentImages(prev => prev.filter((_, i) => i !== index));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 发送评论
|
|
|
|
|
|
const handleSendComment = async () => {
|
|
|
|
|
|
// 检查是否有内容(文字或图片)
|
|
|
|
|
|
const hasContent = commentText.trim() || commentImages.length > 0;
|
|
|
|
|
|
if (!hasContent || !post) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否有图片正在上传
|
|
|
|
|
|
const uploadingImages = commentImages.filter(img => img.uploading);
|
|
|
|
|
|
if (uploadingImages.length > 0) {
|
|
|
|
|
|
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const tempId = `new-${Date.now()}`;
|
|
|
|
|
|
const userId = currentUser?.id || '';
|
|
|
|
|
|
const isReply = !!replyingTo;
|
|
|
|
|
|
const parentId = isReply ? replyingTo.id : null;
|
|
|
|
|
|
// 获取根评论 ID:如果被回复评论有 root_id,则使用 root_id;否则使用 parent_id(即被回复的是顶级评论)
|
|
|
|
|
|
const rootId = isReply ? (replyingTo.root_id || replyingTo.id) : null;
|
|
|
|
|
|
|
|
|
|
|
|
// 获取已上传图片的URL列表
|
|
|
|
|
|
const uploadedImageUrls = commentImages.filter(img => !img.uploading).map(img => img.uri);
|
|
|
|
|
|
|
|
|
|
|
|
// 先创建临时评论显示在列表中
|
|
|
|
|
|
const tempComment: Comment = {
|
|
|
|
|
|
id: tempId,
|
|
|
|
|
|
post_id: post.id,
|
|
|
|
|
|
user_id: userId,
|
|
|
|
|
|
parent_id: parentId,
|
|
|
|
|
|
root_id: rootId,
|
|
|
|
|
|
target_id: parentId,
|
|
|
|
|
|
content: commentText.trim(),
|
|
|
|
|
|
images: uploadedImageUrls.map(url => ({ url })),
|
|
|
|
|
|
likes_count: 0,
|
|
|
|
|
|
replies_count: 0,
|
|
|
|
|
|
is_liked: false,
|
|
|
|
|
|
author: currentUser || {
|
|
|
|
|
|
id: '',
|
|
|
|
|
|
username: 'guest',
|
|
|
|
|
|
nickname: '游客',
|
|
|
|
|
|
avatar: null,
|
|
|
|
|
|
cover_url: null,
|
|
|
|
|
|
bio: null,
|
|
|
|
|
|
website: null,
|
|
|
|
|
|
location: null,
|
|
|
|
|
|
phone: null,
|
|
|
|
|
|
email: null,
|
|
|
|
|
|
posts_count: 0,
|
|
|
|
|
|
followers_count: 0,
|
|
|
|
|
|
following_count: 0,
|
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
|
},
|
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 如果是回复,添加到根评论的 replies 中;否则添加到顶级评论列表
|
|
|
|
|
|
if (isReply && rootId) {
|
|
|
|
|
|
// 回复评论:更新根评论的 replies 和 replies_count
|
|
|
|
|
|
setComments(prev => prev.map(c => {
|
|
|
|
|
|
if (c.id === rootId) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...c,
|
|
|
|
|
|
replies: [...(c.replies || []), tempComment],
|
|
|
|
|
|
replies_count: (c.replies_count || 0) + 1
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return c;
|
|
|
|
|
|
}));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 顶级评论:添加到评论列表开头
|
|
|
|
|
|
setComments(prev => [tempComment, ...prev]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setCommentText('');
|
|
|
|
|
|
setCommentImages([]); // 清除图片
|
|
|
|
|
|
setReplyingTo(null); // 清除回复状态
|
|
|
|
|
|
|
|
|
|
|
|
// 更新帖子评论数
|
|
|
|
|
|
setPost(prev => prev ? {
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
comments_count: prev.comments_count + 1
|
|
|
|
|
|
} : null);
|
|
|
|
|
|
|
|
|
|
|
|
// 调用API创建评论
|
|
|
|
|
|
try {
|
|
|
|
|
|
const createdComment = await commentService.createComment({
|
|
|
|
|
|
postId: post.id,
|
|
|
|
|
|
content: commentText.trim(),
|
|
|
|
|
|
parentId: parentId || undefined,
|
|
|
|
|
|
images: uploadedImageUrls,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (createdComment) {
|
|
|
|
|
|
// 用服务器返回的真实评论替换临时评论
|
|
|
|
|
|
if (isReply && rootId) {
|
|
|
|
|
|
// 回复评论:更新根评论的 replies
|
|
|
|
|
|
setComments(prev => prev.map(c => {
|
|
|
|
|
|
if (c.id === rootId) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...c,
|
|
|
|
|
|
replies: (c.replies || []).map(r => r.id === tempId ? createdComment : r),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return c;
|
|
|
|
|
|
}));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 顶级评论:替换列表中的临时评论
|
|
|
|
|
|
setComments(prev =>
|
|
|
|
|
|
prev.map(c => c.id === tempId ? createdComment : c)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
showPrompt({
|
|
|
|
|
|
type: 'info',
|
|
|
|
|
|
title: '审核中',
|
|
|
|
|
|
message: isReply ? '回复已提交,内容审核中,稍后展示' : '评论已提交,内容审核中,稍后展示',
|
|
|
|
|
|
duration: 2400,
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 创建失败,移除临时评论
|
|
|
|
|
|
if (isReply && rootId) {
|
|
|
|
|
|
// 回复评论:从根评论的 replies 中移除
|
|
|
|
|
|
setComments(prev => prev.map(c => {
|
|
|
|
|
|
if (c.id === rootId) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...c,
|
|
|
|
|
|
replies: (c.replies || []).filter(r => r.id !== tempId),
|
|
|
|
|
|
replies_count: Math.max(0, (c.replies_count || 0) - 1)
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return c;
|
|
|
|
|
|
}));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 顶级评论:从列表中移除
|
|
|
|
|
|
setComments(prev => prev.filter(c => c.id !== tempId));
|
|
|
|
|
|
}
|
|
|
|
|
|
setPost(prev => prev ? {
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
comments_count: Math.max(0, prev.comments_count - 1)
|
|
|
|
|
|
} : null);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('创建评论失败:', error);
|
|
|
|
|
|
// 创建失败,移除临时评论
|
|
|
|
|
|
if (isReply && rootId) {
|
|
|
|
|
|
// 回复评论:从根评论的 replies 中移除
|
|
|
|
|
|
setComments(prev => prev.map(c => {
|
|
|
|
|
|
if (c.id === rootId) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...c,
|
|
|
|
|
|
replies: (c.replies || []).filter(r => r.id !== tempId),
|
|
|
|
|
|
replies_count: Math.max(0, (c.replies_count || 0) - 1)
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return c;
|
|
|
|
|
|
}));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 顶级评论:从列表中移除
|
|
|
|
|
|
setComments(prev => prev.filter(c => c.id !== tempId));
|
|
|
|
|
|
}
|
|
|
|
|
|
setPost(prev => prev ? {
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
comments_count: Math.max(0, prev.comments_count - 1)
|
|
|
|
|
|
} : null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 点赞评论(包括回复)
|
|
|
|
|
|
const handleLikeComment = async (comment: Comment) => {
|
|
|
|
|
|
// 先保存旧状态用于回滚
|
|
|
|
|
|
const oldIsLiked = comment.is_liked;
|
|
|
|
|
|
const oldLikesCount = comment.likes_count;
|
|
|
|
|
|
|
|
|
|
|
|
// 乐观更新本地状态 - 辅助函数用于更新评论或回复
|
|
|
|
|
|
const updateCommentLike = (c: Comment): Comment => {
|
|
|
|
|
|
// 如果是目标评论/回复
|
|
|
|
|
|
if (c.id === comment.id) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...c,
|
|
|
|
|
|
is_liked: !c.is_liked,
|
|
|
|
|
|
likes_count: c.is_liked ? c.likes_count - 1 : c.likes_count + 1
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
// 如果有回复,递归查找
|
|
|
|
|
|
if (c.replies && c.replies.length > 0) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...c,
|
|
|
|
|
|
replies: c.replies.map(r => updateCommentLike(r))
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return c;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 更新本地状态
|
|
|
|
|
|
setComments(prev => prev.map(c => updateCommentLike(c)));
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (oldIsLiked) {
|
|
|
|
|
|
await unlikeComment(comment.id);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await likeComment(comment.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('评论点赞操作失败:', error);
|
|
|
|
|
|
// 失败时回滚状态
|
|
|
|
|
|
const rollbackCommentLike = (c: Comment): Comment => {
|
|
|
|
|
|
if (c.id === comment.id) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...c,
|
|
|
|
|
|
is_liked: oldIsLiked,
|
|
|
|
|
|
likes_count: oldLikesCount
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (c.replies && c.replies.length > 0) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...c,
|
|
|
|
|
|
replies: c.replies.map(r => rollbackCommentLike(r))
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return c;
|
|
|
|
|
|
};
|
|
|
|
|
|
setComments(prev => prev.map(c => rollbackCommentLike(c)));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 删除评论
|
|
|
|
|
|
const handleDeleteComment = async (comment: Comment) => {
|
|
|
|
|
|
if (!post) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否为评论作者
|
|
|
|
|
|
if (currentUser?.id !== comment.author?.id) {
|
|
|
|
|
|
Alert.alert('无法删除', '只能删除自己的评论');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const success = await commentService.deleteComment(comment.id);
|
|
|
|
|
|
if (success) {
|
|
|
|
|
|
// 判断是顶级评论还是回复
|
|
|
|
|
|
if (comment.root_id) {
|
|
|
|
|
|
// 这是一个回复,从根评论的 replies 中移除
|
|
|
|
|
|
setComments(prev => prev.map(c => {
|
|
|
|
|
|
if (c.id === comment.root_id) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...c,
|
|
|
|
|
|
replies: (c.replies || []).filter(r => r.id !== comment.id),
|
|
|
|
|
|
replies_count: Math.max(0, (c.replies_count || 0) - 1)
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return c;
|
|
|
|
|
|
}));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 这是一个顶级评论,从列表中移除
|
|
|
|
|
|
setComments(prev => prev.filter(c => c.id !== comment.id));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新帖子评论数
|
|
|
|
|
|
setPost(prev => prev ? {
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
comments_count: Math.max(0, prev.comments_count - 1)
|
|
|
|
|
|
} : null);
|
|
|
|
|
|
|
|
|
|
|
|
Alert.alert('删除成功', '评论已删除');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Alert.alert('删除失败', '删除评论时发生错误,请稍后重试');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('删除评论失败:', error);
|
|
|
|
|
|
Alert.alert('删除失败', '删除评论时发生错误,请稍后重试');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 跳转到用户主页
|
|
|
|
|
|
const handleUserPress = (userId: string) => {
|
|
|
|
|
|
navigation.navigate('UserProfile', { userId });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染身份标识
|
|
|
|
|
|
const renderBadges = () => {
|
|
|
|
|
|
const badges = [];
|
|
|
|
|
|
|
|
|
|
|
|
if (post?.author?.id === post?.user_id) {
|
|
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 获取关注按钮配置(类似B站/小红书的互关逻辑)
|
|
|
|
|
|
const getFollowButtonConfig = (): { title: string; style: 'primary' | 'outline' | 'mutual' } => {
|
|
|
|
|
|
if (isFollowing && isFollowingMe) {
|
|
|
|
|
|
// 已互关
|
|
|
|
|
|
return { title: '互相关注', style: 'mutual' };
|
|
|
|
|
|
} else if (isFollowing) {
|
|
|
|
|
|
// 已关注但对方未回关
|
|
|
|
|
|
return { title: '已关注', style: 'outline' };
|
|
|
|
|
|
} else if (isFollowingMe) {
|
|
|
|
|
|
// 对方关注了我,但我没关注对方 - 显示回关
|
|
|
|
|
|
return { title: '回关', style: 'primary' };
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 互不关注
|
|
|
|
|
|
return { title: '+ 关注', style: 'primary' };
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理关注/取消关注
|
|
|
|
|
|
const handleFollow = async () => {
|
|
|
|
|
|
if (!post?.author?.id || isFollowLoading) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 不能关注自己
|
|
|
|
|
|
if (currentUser?.id === post.author.id) return;
|
|
|
|
|
|
|
|
|
|
|
|
setIsFollowLoading(true);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (isFollowing) {
|
|
|
|
|
|
// 取消关注
|
|
|
|
|
|
const success = await authService.unfollowUser(post.author.id);
|
|
|
|
|
|
if (success) {
|
|
|
|
|
|
setIsFollowing(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 关注
|
|
|
|
|
|
const success = await authService.followUser(post.author.id);
|
|
|
|
|
|
if (success) {
|
|
|
|
|
|
setIsFollowing(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('关注操作失败:', error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsFollowLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染关注按钮
|
|
|
|
|
|
const renderFollowButton = () => {
|
|
|
|
|
|
if (!post?.author?.id || currentUser?.id === post.author.id) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const config = getFollowButtonConfig();
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.followButton,
|
|
|
|
|
|
config.style === 'primary' && styles.followButtonPrimary,
|
|
|
|
|
|
config.style === 'outline' && styles.followButtonOutline,
|
|
|
|
|
|
config.style === 'mutual' && styles.followButtonMutual,
|
|
|
|
|
|
isFollowLoading && styles.followButtonLoading,
|
|
|
|
|
|
]}
|
|
|
|
|
|
onPress={handleFollow}
|
|
|
|
|
|
disabled={isFollowLoading}
|
|
|
|
|
|
activeOpacity={0.8}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Text
|
|
|
|
|
|
variant="caption"
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.followButtonText,
|
|
|
|
|
|
...(config.style === 'primary' ? [styles.followButtonTextPrimary] : []),
|
|
|
|
|
|
...((config.style === 'outline' || config.style === 'mutual') ? [styles.followButtonTextOutline] : []),
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
{config.title}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 使用 useMemo 缓存图片数据,避免输入时重新创建数组导致图片重新加载
|
|
|
|
|
|
const postImages = useMemo(() => {
|
|
|
|
|
|
if (!post?.images) return [];
|
|
|
|
|
|
return post.images.map(img => ({
|
|
|
|
|
|
id: img.id,
|
|
|
|
|
|
url: img.url,
|
|
|
|
|
|
thumbnail_url: img.thumbnail_url,
|
|
|
|
|
|
width: img.width,
|
|
|
|
|
|
height: img.height,
|
|
|
|
|
|
}));
|
|
|
|
|
|
}, [post?.images]);
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染帖子头部 - 小红书/微博风格
|
|
|
|
|
|
const renderPostHeader = useCallback(() => {
|
|
|
|
|
|
if (!post) return null;
|
|
|
|
|
|
|
|
|
|
|
|
// 响应式图片间距和圆角
|
|
|
|
|
|
const imageGap = isDesktop ? 12 : isTablet ? 10 : 8;
|
|
|
|
|
|
const imageBorderRadius = isDesktop ? borderRadius.xl : borderRadius.lg;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<View style={[styles.postContainer, { paddingHorizontal: responsivePadding, paddingTop: responsivePadding }]}>
|
|
|
|
|
|
{/* 标题 */}
|
|
|
|
|
|
{post.title && (
|
|
|
|
|
|
<View style={styles.titleContainer}>
|
|
|
|
|
|
<Text
|
|
|
|
|
|
variant="h2"
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.title,
|
|
|
|
|
|
{
|
|
|
|
|
|
fontSize: isDesktop ? fontSizes['3xl'] : isTablet ? fontSizes['2xl'] : fontSizes['2xl'],
|
|
|
|
|
|
lineHeight: isDesktop ? 40 : isTablet ? 36 : 32
|
|
|
|
|
|
}
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
{post.title}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 内容 - 增大字体 */}
|
|
|
|
|
|
<View style={styles.contentContainer}>
|
|
|
|
|
|
<Text
|
|
|
|
|
|
variant="body"
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.content,
|
|
|
|
|
|
{
|
|
|
|
|
|
fontSize: isDesktop ? fontSizes.xl : isTablet ? fontSizes.lg : fontSizes.lg,
|
|
|
|
|
|
lineHeight: isDesktop ? 32 : isTablet ? 30 : 28
|
|
|
|
|
|
}
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
{post.content}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 图片 - 详情页显示所有图片,不限制数量 */}
|
|
|
|
|
|
{postImages.length > 0 && (
|
|
|
|
|
|
<ImageGrid
|
|
|
|
|
|
images={postImages}
|
|
|
|
|
|
mode="auto"
|
|
|
|
|
|
gap={imageGap}
|
|
|
|
|
|
borderRadius={imageBorderRadius}
|
|
|
|
|
|
maxDisplayCount={isWideScreen ? 20 : 100}
|
|
|
|
|
|
showMoreOverlay={false}
|
|
|
|
|
|
onImagePress={handleImagePress}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 投票卡片 */}
|
|
|
|
|
|
{post.is_vote && voteResult && (
|
|
|
|
|
|
<View style={styles.voteContainer}>
|
|
|
|
|
|
<VoteCard
|
|
|
|
|
|
postId={post.id}
|
|
|
|
|
|
options={voteResult.options}
|
|
|
|
|
|
totalVotes={voteResult.total_votes}
|
|
|
|
|
|
hasVoted={voteResult.has_voted}
|
|
|
|
|
|
votedOptionId={voteResult.voted_option_id}
|
|
|
|
|
|
onVote={handleVote}
|
|
|
|
|
|
onUnvote={handleUnvote}
|
|
|
|
|
|
isLoading={isVoteLoading}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 发帖时间和浏览量 - 放在图片下方 */}
|
|
|
|
|
|
<View style={[styles.postMetaInfo, { marginTop: responsiveGap }]}>
|
2026-03-10 12:58:23 +08:00
|
|
|
|
<View style={styles.metaInfoMain}>
|
|
|
|
|
|
<Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}>
|
|
|
|
|
|
发布 {formatRelativeTime(post.created_at)}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
{isPostEdited(post.created_at, post.updated_at) && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Text style={styles.metaInfoDot}>·</Text>
|
|
|
|
|
|
<Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}>
|
|
|
|
|
|
修改 {formatRelativeTime(post.updated_at)}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{post.views_count !== undefined && post.views_count > 0 && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Text style={styles.metaInfoDot}>·</Text>
|
|
|
|
|
|
<Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}>
|
|
|
|
|
|
{formatNumber(post.views_count)} 浏览
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
{currentUser?.id === post.author?.id && (
|
2026-03-10 12:58:23 +08:00
|
|
|
|
<View style={styles.metaActions}>
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={styles.editButtonInline}
|
|
|
|
|
|
onPress={handleEditPost}
|
|
|
|
|
|
>
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name="pencil-outline"
|
|
|
|
|
|
size={14}
|
|
|
|
|
|
color={colors.text.hint}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Text variant="caption" color={colors.text.hint} style={styles.editButtonText}>
|
|
|
|
|
|
编辑
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
{/* 删除按钮 - 只对帖子作者显示 */}
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={styles.deleteButtonInline}
|
|
|
|
|
|
onPress={handleDeletePost}
|
|
|
|
|
|
disabled={isDeleting}
|
|
|
|
|
|
>
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name={isDeleting ? 'loading' : 'delete-outline'}
|
|
|
|
|
|
size={14}
|
|
|
|
|
|
color={colors.text.hint}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Text variant="caption" color={colors.text.hint} style={styles.deleteButtonText}>
|
|
|
|
|
|
删除
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
</View>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 底部操作栏 - QQ频道风格 */}
|
|
|
|
|
|
<View style={[styles.actionBar, { marginTop: responsiveGap }]}>
|
|
|
|
|
|
<TouchableOpacity style={styles.actionButton} onPress={handleLike}>
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name={post.is_liked ? 'heart' : 'heart-outline'}
|
|
|
|
|
|
size={isDesktop ? 24 : 20}
|
|
|
|
|
|
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}>
|
|
|
|
|
|
{post.likes_count > 0 ? formatNumber(post.likes_count) : '点赞'}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
|
|
|
|
|
|
<TouchableOpacity style={styles.actionButton}>
|
|
|
|
|
|
<MaterialCommunityIcons name="comment-outline" size={isDesktop ? 24 : 20} color={colors.text.secondary} />
|
|
|
|
|
|
<Text variant="caption" color={colors.text.secondary} style={styles.actionText}>
|
|
|
|
|
|
{post.comments_count > 0 ? formatNumber(post.comments_count) : '评论'}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
|
|
|
|
|
|
<TouchableOpacity style={styles.actionButton} onPress={handleShare}>
|
|
|
|
|
|
<MaterialCommunityIcons name="share-outline" size={isDesktop ? 24 : 20} color={colors.text.secondary} />
|
|
|
|
|
|
<Text variant="caption" color={colors.text.secondary} style={styles.actionText}>
|
|
|
|
|
|
{post.shares_count > 0 ? formatNumber(post.shares_count) : '分享'}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
|
|
|
|
|
|
<TouchableOpacity style={styles.actionButton} onPress={handleFavorite}>
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name={post.is_favorited ? 'bookmark' : 'bookmark-outline'}
|
|
|
|
|
|
size={isDesktop ? 24 : 20}
|
|
|
|
|
|
color={post.is_favorited ? colors.warning.main : colors.text.secondary}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Text variant="caption" color={post.is_favorited ? colors.warning.main : colors.text.secondary} style={styles.actionText}>
|
|
|
|
|
|
{post.is_favorited ? '已收藏' : '收藏'}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 评论标题 - 现代化设计 */}
|
|
|
|
|
|
<View style={[styles.commentTitle, { paddingVertical: responsiveGap }]}>
|
|
|
|
|
|
<View style={styles.commentTitleLeft}>
|
|
|
|
|
|
<View style={[styles.commentTitleIconContainer, { width: isDesktop ? 32 : 28, height: isDesktop ? 32 : 28 }]}>
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name="chat-processing-outline"
|
|
|
|
|
|
size={isDesktop ? 18 : 16}
|
|
|
|
|
|
color={colors.primary.contrast}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
<Text
|
|
|
|
|
|
variant="body"
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.commentTitleText,
|
|
|
|
|
|
{ fontSize: isDesktop ? fontSizes.xl : fontSizes.lg }
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
评论
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
<View style={styles.commentCountBadge}>
|
|
|
|
|
|
<Text variant="caption" style={styles.commentCountText}>{post.comments_count}</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
2026-03-10 12:58:23 +08:00
|
|
|
|
}, [post, postImages, currentUser?.id, isDeleting, handleLike, handleShare, handleFavorite, handleDeletePost, handleEditPost, handleImagePress, voteResult, isVoteLoading, handleVote, handleUnvote, isDesktop, isTablet, isWideScreen, responsivePadding, responsiveGap]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 回复评论
|
|
|
|
|
|
const [replyingTo, setReplyingTo] = useState<Comment | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const handleReply = (comment: Comment) => {
|
|
|
|
|
|
setReplyingTo(comment);
|
|
|
|
|
|
// 可以在这里添加聚焦输入框的逻辑
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCancelReply = () => {
|
|
|
|
|
|
setReplyingTo(null);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 加载更多回复
|
|
|
|
|
|
const handleLoadMoreReplies = async (commentId: string) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 找到当前评论
|
|
|
|
|
|
const comment = comments.find(c => c.id === commentId);
|
|
|
|
|
|
if (!comment) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 计算下一页的起始位置
|
|
|
|
|
|
const currentRepliesCount = comment.replies?.length || 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 调用API获取更多回复
|
|
|
|
|
|
const response = await commentService.getFlatReplies(commentId, 1, currentRepliesCount + 10);
|
|
|
|
|
|
|
|
|
|
|
|
// 更新评论列表
|
|
|
|
|
|
setComments(prev => prev.map(c => {
|
|
|
|
|
|
if (c.id === commentId) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...c,
|
|
|
|
|
|
replies: response.list,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return c;
|
|
|
|
|
|
}));
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('加载更多回复失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染评论 - 带楼层号
|
|
|
|
|
|
const renderComment = ({ item, index }: { item: Comment; index: number }) => {
|
|
|
|
|
|
const authorId = item.author?.id || '';
|
|
|
|
|
|
// 收集当前评论的所有回复,用于根据 target_id 查找被回复用户
|
|
|
|
|
|
const allReplies = item.replies || [];
|
|
|
|
|
|
// 判断当前用户是否为评论作者
|
|
|
|
|
|
const isCommentAuthor = currentUser?.id === authorId;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<CommentItem
|
|
|
|
|
|
comment={item}
|
|
|
|
|
|
onLike={() => handleLikeComment(item)}
|
|
|
|
|
|
onReply={() => handleReply(item)}
|
|
|
|
|
|
onUserPress={() => authorId && handleUserPress(authorId)}
|
|
|
|
|
|
floorNumber={index + 1}
|
|
|
|
|
|
isAuthor={authorId === post?.user_id}
|
|
|
|
|
|
onReplyPress={handleReply}
|
|
|
|
|
|
allReplies={allReplies}
|
|
|
|
|
|
onLoadMoreReplies={handleLoadMoreReplies}
|
|
|
|
|
|
isCommentAuthor={isCommentAuthor}
|
|
|
|
|
|
onDelete={handleDeleteComment}
|
|
|
|
|
|
onImagePress={handleImagePress}
|
|
|
|
|
|
currentUserId={currentUser?.id}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染空评论 - 现代化设计
|
|
|
|
|
|
const renderEmptyComments = () => (
|
|
|
|
|
|
<View style={styles.emptyCommentsContainer}>
|
|
|
|
|
|
<View style={styles.emptyCommentsIconWrapper}>
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name="message-text-outline"
|
|
|
|
|
|
size={isDesktop ? 48 : 40}
|
|
|
|
|
|
color={colors.primary.main}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
<Text variant="body" style={styles.emptyCommentsTitle}>
|
|
|
|
|
|
还没有评论
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
<Text variant="caption" color={colors.text.secondary} style={styles.emptyCommentsSubtitle}>
|
|
|
|
|
|
成为第一个评论的人吧~
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染评论输入框
|
|
|
|
|
|
const renderCommentInput = () => (
|
|
|
|
|
|
<View style={[
|
|
|
|
|
|
styles.inputContainer,
|
|
|
|
|
|
{
|
|
|
|
|
|
paddingHorizontal: responsivePadding,
|
|
|
|
|
|
paddingVertical: responsiveGap,
|
|
|
|
|
|
paddingBottom: keyboardHeight + responsiveGap + insets.bottom
|
|
|
|
|
|
}
|
|
|
|
|
|
]}>
|
|
|
|
|
|
{/* 回复提示 */}
|
|
|
|
|
|
{replyingTo && (
|
|
|
|
|
|
<View style={styles.replyingToContainer}>
|
|
|
|
|
|
<Text variant="caption" color={colors.text.secondary}>
|
|
|
|
|
|
回复 <Text style={styles.replyingToName}>{replyingTo.author.nickname}</Text>
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
<TouchableOpacity onPress={handleCancelReply} style={styles.cancelReplyButton}>
|
|
|
|
|
|
<MaterialCommunityIcons name="close" size={16} color={colors.text.hint} />
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 已选图片预览 */}
|
|
|
|
|
|
{commentImages.length > 0 && (
|
|
|
|
|
|
<ScrollView horizontal style={styles.commentImagesPreview} showsHorizontalScrollIndicator={false}>
|
|
|
|
|
|
{commentImages.map((image, index) => (
|
|
|
|
|
|
<View key={index} style={styles.commentImageWrapper}>
|
|
|
|
|
|
<Image source={{ uri: image.uri }} style={styles.commentImageThumbnail} resizeMode="cover" />
|
|
|
|
|
|
{image.uploading && (
|
|
|
|
|
|
<View style={styles.uploadingOverlay}>
|
|
|
|
|
|
<Loading size="sm" />
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={styles.removeImageButton}
|
|
|
|
|
|
onPress={() => handleRemoveCommentImage(index)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<MaterialCommunityIcons name="close-circle" size={18} color={colors.text.primary} />
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ScrollView>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<View style={styles.inputWrapper}>
|
|
|
|
|
|
{/* 图片选择按钮 */}
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={styles.imagePickerButton}
|
|
|
|
|
|
onPress={handlePickCommentImage}
|
|
|
|
|
|
disabled={commentImages.length >= 3}
|
|
|
|
|
|
>
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name="image-outline"
|
|
|
|
|
|
size={isDesktop ? 24 : 22}
|
|
|
|
|
|
color={commentImages.length >= 3 ? colors.text.disabled : colors.text.secondary}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
|
|
|
|
|
|
<TextInput
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.input,
|
|
|
|
|
|
{ fontSize: isDesktop ? fontSizes.lg : fontSizes.md }
|
|
|
|
|
|
]}
|
|
|
|
|
|
placeholder={replyingTo ? `回复 ${replyingTo.author.nickname}...` : "说点什么..."}
|
|
|
|
|
|
placeholderTextColor={colors.text.hint}
|
|
|
|
|
|
value={commentText}
|
|
|
|
|
|
onChangeText={setCommentText}
|
|
|
|
|
|
multiline
|
|
|
|
|
|
maxLength={500}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.sendButton,
|
|
|
|
|
|
!(commentText.trim() || commentImages.length > 0) && styles.sendButtonDisabled
|
|
|
|
|
|
]}
|
|
|
|
|
|
onPress={handleSendComment}
|
|
|
|
|
|
disabled={!(commentText.trim() || commentImages.length > 0)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name="send"
|
|
|
|
|
|
size={isDesktop ? 24 : 20}
|
|
|
|
|
|
color={(commentText.trim() || commentImages.length > 0) ? colors.primary.main : colors.text.disabled}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染评论列表
|
|
|
|
|
|
const renderCommentsList = () => (
|
|
|
|
|
|
<FlatList
|
|
|
|
|
|
ref={flatListRef}
|
|
|
|
|
|
data={comments}
|
|
|
|
|
|
renderItem={renderComment}
|
|
|
|
|
|
keyExtractor={item => item.id}
|
|
|
|
|
|
ListEmptyComponent={renderEmptyComments}
|
|
|
|
|
|
contentContainerStyle={{ paddingBottom: responsiveGap }}
|
|
|
|
|
|
showsVerticalScrollIndicator={false}
|
|
|
|
|
|
onScrollToIndexFailed={() => {
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
flatListRef.current?.scrollToEnd({ animated: true });
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
}}
|
|
|
|
|
|
refreshControl={
|
|
|
|
|
|
<RefreshControl
|
|
|
|
|
|
refreshing={refreshing}
|
|
|
|
|
|
onRefresh={onRefresh}
|
|
|
|
|
|
colors={[colors.primary.main]}
|
|
|
|
|
|
tintColor={colors.primary.main}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染右侧边栏(桌面端)
|
|
|
|
|
|
const renderSidebar = () => (
|
|
|
|
|
|
<View style={[styles.sidebar, { width: sidebarWidth, padding: responsiveGap }]}>
|
|
|
|
|
|
<View style={styles.sidebarContent}>
|
|
|
|
|
|
<Text variant="h3" style={styles.sidebarTitle}>热门帖子</Text>
|
|
|
|
|
|
{/* 这里可以添加热门帖子列表 */}
|
|
|
|
|
|
<View style={styles.sidebarPlaceholder}>
|
|
|
|
|
|
<MaterialCommunityIcons name="fire" size={48} color={colors.primary.light} />
|
|
|
|
|
|
<Text variant="caption" color={colors.text.secondary} style={styles.sidebarPlaceholderText}>
|
|
|
|
|
|
热门内容即将推出
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
|
return <Loading fullScreen />;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!post) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<SafeAreaView style={styles.container}>
|
|
|
|
|
|
<EmptyState
|
|
|
|
|
|
title="帖子不存在"
|
|
|
|
|
|
description="该帖子可能已被删除"
|
|
|
|
|
|
icon="alert-circle-outline"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SafeAreaView>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 桌面端双栏布局
|
|
|
|
|
|
if (useDualColumnLayout) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<SafeAreaView style={styles.container} edges={['bottom']}>
|
|
|
|
|
|
<AdaptiveLayout
|
|
|
|
|
|
sidebar={renderSidebar()}
|
|
|
|
|
|
sidebarWidth={sidebarWidth}
|
|
|
|
|
|
showSidebarBreakpoint="lg"
|
|
|
|
|
|
sidebarPosition="right"
|
|
|
|
|
|
>
|
|
|
|
|
|
<View style={styles.flex}>
|
|
|
|
|
|
<ScrollView
|
|
|
|
|
|
style={styles.flex}
|
|
|
|
|
|
contentContainerStyle={{ paddingBottom: responsivePadding }}
|
|
|
|
|
|
showsVerticalScrollIndicator={false}
|
|
|
|
|
|
refreshControl={
|
|
|
|
|
|
<RefreshControl
|
|
|
|
|
|
refreshing={refreshing}
|
|
|
|
|
|
onRefresh={onRefresh}
|
|
|
|
|
|
colors={[colors.primary.main]}
|
|
|
|
|
|
tintColor={colors.primary.main}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{renderPostHeader()}
|
|
|
|
|
|
{renderCommentsList()}
|
|
|
|
|
|
</ScrollView>
|
|
|
|
|
|
{renderCommentInput()}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</AdaptiveLayout>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 图片预览 ImageGallery */}
|
|
|
|
|
|
<ImageGallery
|
|
|
|
|
|
visible={showImageModal}
|
|
|
|
|
|
images={allImages.map(img => ({
|
|
|
|
|
|
id: img.id || img.url || String(Math.random()),
|
|
|
|
|
|
url: img.url || img.uri || ''
|
|
|
|
|
|
}))}
|
|
|
|
|
|
initialIndex={selectedImageIndex}
|
|
|
|
|
|
onClose={() => setShowImageModal(false)}
|
|
|
|
|
|
enableSave
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SafeAreaView>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 移动端单栏布局
|
|
|
|
|
|
return (
|
|
|
|
|
|
<SafeAreaView style={styles.container} edges={['bottom']}>
|
|
|
|
|
|
<KeyboardAvoidingView
|
|
|
|
|
|
style={styles.flex}
|
|
|
|
|
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
|
|
|
|
keyboardVerticalOffset={88}
|
|
|
|
|
|
>
|
|
|
|
|
|
<FlatList
|
|
|
|
|
|
ref={flatListRef}
|
|
|
|
|
|
data={comments}
|
|
|
|
|
|
renderItem={renderComment}
|
|
|
|
|
|
keyExtractor={item => item.id}
|
|
|
|
|
|
ListHeaderComponent={renderPostHeader}
|
|
|
|
|
|
ListEmptyComponent={renderEmptyComments}
|
|
|
|
|
|
contentContainerStyle={{ paddingBottom: responsiveGap }}
|
|
|
|
|
|
showsVerticalScrollIndicator={false}
|
|
|
|
|
|
onScrollToIndexFailed={() => {
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
flatListRef.current?.scrollToEnd({ animated: true });
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
}}
|
|
|
|
|
|
refreshControl={
|
|
|
|
|
|
<RefreshControl
|
|
|
|
|
|
refreshing={refreshing}
|
|
|
|
|
|
onRefresh={onRefresh}
|
|
|
|
|
|
colors={[colors.primary.main]}
|
|
|
|
|
|
tintColor={colors.primary.main}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 评论输入框 - 跟随键盘 */}
|
|
|
|
|
|
{renderCommentInput()}
|
|
|
|
|
|
</KeyboardAvoidingView>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 图片预览 ImageGallery */}
|
|
|
|
|
|
<ImageGallery
|
|
|
|
|
|
visible={showImageModal}
|
|
|
|
|
|
images={allImages.map(img => ({
|
|
|
|
|
|
id: img.id || img.url || String(Math.random()),
|
|
|
|
|
|
url: img.url || img.uri || ''
|
|
|
|
|
|
}))}
|
|
|
|
|
|
initialIndex={selectedImageIndex}
|
|
|
|
|
|
onClose={() => setShowImageModal(false)}
|
|
|
|
|
|
enableSave
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SafeAreaView>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const styles = StyleSheet.create({
|
|
|
|
|
|
flex: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
container: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
backgroundColor: colors.background.default,
|
|
|
|
|
|
},
|
|
|
|
|
|
// Header 样式
|
|
|
|
|
|
headerContainer: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
marginLeft: -8,
|
|
|
|
|
|
},
|
|
|
|
|
|
headerAvatarWrapper: {
|
|
|
|
|
|
marginRight: spacing.sm,
|
|
|
|
|
|
},
|
|
|
|
|
|
headerUserInfo: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
headerNickname: {
|
|
|
|
|
|
fontSize: fontSizes.md,
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
color: colors.text.primary,
|
|
|
|
|
|
},
|
|
|
|
|
|
headerRightContainer: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
marginRight: spacing.sm,
|
|
|
|
|
|
},
|
|
|
|
|
|
// 帖子容器
|
|
|
|
|
|
postContainer: {
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
paddingBottom: spacing.md,
|
|
|
|
|
|
},
|
|
|
|
|
|
// 关注按钮样式
|
|
|
|
|
|
followButton: {
|
|
|
|
|
|
paddingHorizontal: spacing.md,
|
|
|
|
|
|
paddingVertical: 4,
|
|
|
|
|
|
borderRadius: borderRadius.lg,
|
|
|
|
|
|
minWidth: 64,
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
followButtonPrimary: {
|
|
|
|
|
|
backgroundColor: colors.primary.main,
|
|
|
|
|
|
},
|
|
|
|
|
|
followButtonOutline: {
|
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
|
borderWidth: 1,
|
|
|
|
|
|
borderColor: colors.divider,
|
|
|
|
|
|
},
|
|
|
|
|
|
followButtonMutual: {
|
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
|
borderWidth: 1,
|
|
|
|
|
|
borderColor: colors.divider,
|
|
|
|
|
|
},
|
|
|
|
|
|
followButtonLoading: {
|
|
|
|
|
|
opacity: 0.7,
|
|
|
|
|
|
},
|
|
|
|
|
|
followButtonText: {
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
},
|
|
|
|
|
|
followButtonTextPrimary: {
|
|
|
|
|
|
color: colors.text.inverse,
|
|
|
|
|
|
},
|
|
|
|
|
|
followButtonTextOutline: {
|
|
|
|
|
|
color: colors.text.secondary,
|
|
|
|
|
|
},
|
|
|
|
|
|
// 徽章样式
|
|
|
|
|
|
badge: {
|
|
|
|
|
|
paddingHorizontal: 6,
|
|
|
|
|
|
paddingVertical: 2,
|
|
|
|
|
|
borderRadius: borderRadius.sm,
|
|
|
|
|
|
marginRight: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
authorBadge: {
|
|
|
|
|
|
backgroundColor: colors.primary.main,
|
|
|
|
|
|
},
|
|
|
|
|
|
adminBadge: {
|
|
|
|
|
|
backgroundColor: colors.error.main,
|
|
|
|
|
|
},
|
|
|
|
|
|
badgeText: {
|
|
|
|
|
|
color: colors.text.inverse,
|
|
|
|
|
|
fontSize: fontSizes.xs,
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
},
|
|
|
|
|
|
// 标题和内容
|
|
|
|
|
|
titleContainer: {
|
|
|
|
|
|
marginBottom: spacing.lg,
|
|
|
|
|
|
},
|
|
|
|
|
|
title: {
|
|
|
|
|
|
fontWeight: '800',
|
|
|
|
|
|
color: colors.text.primary,
|
|
|
|
|
|
letterSpacing: -0.3,
|
|
|
|
|
|
},
|
|
|
|
|
|
contentContainer: {
|
|
|
|
|
|
marginBottom: spacing.lg,
|
|
|
|
|
|
},
|
|
|
|
|
|
content: {
|
|
|
|
|
|
color: colors.text.primary,
|
|
|
|
|
|
letterSpacing: 0.2,
|
|
|
|
|
|
},
|
|
|
|
|
|
// 发帖时间和浏览量
|
|
|
|
|
|
postMetaInfo: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
2026-03-10 12:58:23 +08:00
|
|
|
|
justifyContent: 'space-between',
|
2026-03-09 21:29:03 +08:00
|
|
|
|
marginBottom: spacing.sm,
|
|
|
|
|
|
},
|
2026-03-10 12:58:23 +08:00
|
|
|
|
metaInfoMain: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
flexWrap: 'wrap',
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
minWidth: 0,
|
|
|
|
|
|
},
|
2026-03-09 21:29:03 +08:00
|
|
|
|
metaInfoText: {
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
color: colors.text.hint,
|
|
|
|
|
|
},
|
|
|
|
|
|
metaInfoDot: {
|
|
|
|
|
|
marginHorizontal: spacing.sm,
|
|
|
|
|
|
color: colors.text.hint,
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
fontWeight: '700',
|
|
|
|
|
|
},
|
|
|
|
|
|
deleteButtonInline: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
2026-03-10 12:58:23 +08:00
|
|
|
|
marginLeft: spacing.sm,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
padding: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
deleteButtonText: {
|
|
|
|
|
|
marginLeft: 2,
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
},
|
2026-03-10 12:58:23 +08:00
|
|
|
|
metaActions: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
marginLeft: spacing.sm,
|
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
editButtonInline: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
padding: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
editButtonText: {
|
|
|
|
|
|
marginLeft: 2,
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
},
|
2026-03-09 21:29:03 +08:00
|
|
|
|
imagesContainer: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
flexWrap: 'wrap',
|
|
|
|
|
|
marginTop: spacing.sm,
|
|
|
|
|
|
marginHorizontal: -spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
imageWrapper: {
|
|
|
|
|
|
width: 100, // 动态计算
|
|
|
|
|
|
height: 100, // 动态计算
|
|
|
|
|
|
margin: spacing.xs,
|
|
|
|
|
|
borderRadius: borderRadius.md,
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
},
|
|
|
|
|
|
postImage: {
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
},
|
|
|
|
|
|
voteContainer: {
|
|
|
|
|
|
marginVertical: spacing.md,
|
|
|
|
|
|
},
|
|
|
|
|
|
voteLoadingPlaceholder: {
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
borderRadius: borderRadius.lg,
|
|
|
|
|
|
padding: spacing.xl,
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
borderWidth: 1,
|
|
|
|
|
|
borderColor: colors.divider,
|
|
|
|
|
|
},
|
|
|
|
|
|
voteLoadingText: {
|
|
|
|
|
|
marginTop: spacing.sm,
|
|
|
|
|
|
},
|
|
|
|
|
|
actionBar: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
justifyContent: 'space-around',
|
|
|
|
|
|
paddingVertical: spacing.md,
|
|
|
|
|
|
borderTopWidth: StyleSheet.hairlineWidth,
|
|
|
|
|
|
borderTopColor: colors.divider,
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
},
|
|
|
|
|
|
actionButton: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
paddingHorizontal: spacing.md,
|
|
|
|
|
|
paddingVertical: spacing.xs,
|
|
|
|
|
|
borderRadius: borderRadius.lg,
|
|
|
|
|
|
},
|
|
|
|
|
|
actionText: {
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
marginLeft: 6,
|
|
|
|
|
|
fontWeight: '500',
|
|
|
|
|
|
},
|
|
|
|
|
|
commentTitle: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
commentTitleLeft: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
commentTitleIconContainer: {
|
|
|
|
|
|
borderRadius: borderRadius.md,
|
|
|
|
|
|
backgroundColor: colors.primary.main,
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
marginRight: spacing.sm,
|
|
|
|
|
|
},
|
|
|
|
|
|
commentTitleText: {
|
|
|
|
|
|
fontWeight: '700',
|
|
|
|
|
|
color: colors.text.primary,
|
|
|
|
|
|
},
|
|
|
|
|
|
commentCountBadge: {
|
|
|
|
|
|
backgroundColor: colors.primary.light + '25',
|
|
|
|
|
|
paddingHorizontal: spacing.sm,
|
|
|
|
|
|
paddingVertical: 2,
|
|
|
|
|
|
borderRadius: borderRadius.md,
|
|
|
|
|
|
marginLeft: spacing.sm,
|
|
|
|
|
|
minWidth: 24,
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
commentCountText: {
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
fontWeight: '700',
|
|
|
|
|
|
color: colors.primary.main,
|
|
|
|
|
|
},
|
|
|
|
|
|
inputContainer: {
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
borderTopWidth: StyleSheet.hairlineWidth,
|
|
|
|
|
|
borderTopColor: colors.divider,
|
|
|
|
|
|
},
|
|
|
|
|
|
replyingToContainer: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
marginBottom: spacing.xs,
|
|
|
|
|
|
paddingHorizontal: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
replyingToName: {
|
|
|
|
|
|
color: colors.primary.main,
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
},
|
|
|
|
|
|
cancelReplyButton: {
|
|
|
|
|
|
marginLeft: spacing.sm,
|
|
|
|
|
|
padding: 2,
|
|
|
|
|
|
},
|
|
|
|
|
|
inputWrapper: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
backgroundColor: colors.background.default,
|
|
|
|
|
|
borderRadius: 20,
|
|
|
|
|
|
paddingHorizontal: spacing.md,
|
|
|
|
|
|
},
|
|
|
|
|
|
input: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
minHeight: 40,
|
|
|
|
|
|
maxHeight: 100,
|
|
|
|
|
|
color: colors.text.primary,
|
|
|
|
|
|
paddingVertical: spacing.sm,
|
|
|
|
|
|
},
|
|
|
|
|
|
sendButton: {
|
|
|
|
|
|
padding: spacing.sm,
|
|
|
|
|
|
},
|
|
|
|
|
|
sendButtonDisabled: {
|
|
|
|
|
|
opacity: 0.5,
|
|
|
|
|
|
},
|
|
|
|
|
|
imagePickerButton: {
|
|
|
|
|
|
padding: spacing.sm,
|
|
|
|
|
|
marginRight: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
commentImagesPreview: {
|
|
|
|
|
|
maxHeight: 80,
|
|
|
|
|
|
marginBottom: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
commentImageWrapper: {
|
|
|
|
|
|
width: 60,
|
|
|
|
|
|
height: 60,
|
|
|
|
|
|
marginRight: spacing.sm,
|
|
|
|
|
|
borderRadius: borderRadius.sm,
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
position: 'relative',
|
|
|
|
|
|
},
|
|
|
|
|
|
commentImageThumbnail: {
|
|
|
|
|
|
width: 60,
|
|
|
|
|
|
height: 60,
|
|
|
|
|
|
borderRadius: borderRadius.sm,
|
|
|
|
|
|
},
|
|
|
|
|
|
uploadingOverlay: {
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
top: 0,
|
|
|
|
|
|
left: 0,
|
|
|
|
|
|
right: 0,
|
|
|
|
|
|
bottom: 0,
|
|
|
|
|
|
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
borderRadius: borderRadius.sm,
|
|
|
|
|
|
},
|
|
|
|
|
|
removeImageButton: {
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
top: -6,
|
|
|
|
|
|
right: -6,
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
borderRadius: 9,
|
|
|
|
|
|
},
|
|
|
|
|
|
modalContainer: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
closeButton: {
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
top: 40,
|
|
|
|
|
|
right: 20,
|
|
|
|
|
|
zIndex: 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
fullImage: {
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
},
|
|
|
|
|
|
imageCounter: {
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
bottom: 40,
|
|
|
|
|
|
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
|
|
|
|
|
paddingHorizontal: spacing.md,
|
|
|
|
|
|
paddingVertical: spacing.xs,
|
|
|
|
|
|
borderRadius: borderRadius.md,
|
|
|
|
|
|
},
|
|
|
|
|
|
// 空评论状态样式
|
|
|
|
|
|
emptyCommentsContainer: {
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
paddingVertical: spacing.xl * 2,
|
|
|
|
|
|
paddingHorizontal: spacing.lg,
|
|
|
|
|
|
},
|
|
|
|
|
|
emptyCommentsIconWrapper: {
|
|
|
|
|
|
width: 72,
|
|
|
|
|
|
height: 72,
|
|
|
|
|
|
borderRadius: 36,
|
|
|
|
|
|
backgroundColor: colors.primary.light + '18',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
marginBottom: spacing.lg,
|
|
|
|
|
|
},
|
|
|
|
|
|
emptyCommentsTitle: {
|
|
|
|
|
|
fontSize: fontSizes.md,
|
|
|
|
|
|
fontWeight: '500',
|
|
|
|
|
|
color: colors.text.primary,
|
|
|
|
|
|
marginBottom: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
emptyCommentsSubtitle: {
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
// 侧边栏样式
|
|
|
|
|
|
sidebar: {
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
borderLeftWidth: StyleSheet.hairlineWidth,
|
|
|
|
|
|
borderLeftColor: colors.divider,
|
|
|
|
|
|
},
|
|
|
|
|
|
sidebarContent: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
sidebarTitle: {
|
|
|
|
|
|
fontWeight: '700',
|
|
|
|
|
|
color: colors.text.primary,
|
|
|
|
|
|
marginBottom: spacing.md,
|
|
|
|
|
|
},
|
|
|
|
|
|
sidebarPlaceholder: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
paddingVertical: spacing.xl,
|
|
|
|
|
|
},
|
|
|
|
|
|
sidebarPlaceholderText: {
|
|
|
|
|
|
marginTop: spacing.md,
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|