- Introduced new methods for merging posts and comments efficiently, improving performance during data updates. - Updated loading state management in hooks to include initial loading and loading more states for better user feedback. - Refactored HomeScreen and PostDetailScreen to utilize the new loading states and merging logic, enhancing user experience during data fetching. - Improved pagination handling in useCursorPagination and useDifferentialPosts to ensure consistent data management across components.
2102 lines
64 KiB
TypeScript
2102 lines
64 KiB
TypeScript
/**
|
||
* 帖子详情页 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,
|
||
Clipboard,
|
||
} 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 { processPostUseCase } from '../../core/usecases/ProcessPostUseCase';
|
||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||
import { CommentItem, VoteCard } from '../../components/business';
|
||
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout } from '../../components/common';
|
||
import { RootStackParamList } from '../../navigation/types';
|
||
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive';
|
||
|
||
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'PostDetail'>;
|
||
type PostDetailRouteProp = RouteProp<RootStackParamList, 'PostDetail'>;
|
||
|
||
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 currentUser = useCurrentUser();
|
||
|
||
const [post, setPost] = useState<Post | null>(null);
|
||
const [comments, setComments] = useState<Comment[]>([]);
|
||
const [isPostInitialLoading, setIsPostInitialLoading] = useState(true);
|
||
const [isPostRefreshing, setIsPostRefreshing] = useState(false);
|
||
|
||
// 使用游标分页 Hook 管理评论列表
|
||
const {
|
||
list: paginatedComments,
|
||
isLoading: isCommentsLoading,
|
||
isInitialLoading: isCommentsInitialLoading,
|
||
isLoadingMore: isCommentsLoadingMore,
|
||
isRefreshing: isCommentsRefreshing,
|
||
hasMore: hasMoreComments,
|
||
loadMore: loadMoreComments,
|
||
refresh: refreshComments,
|
||
error: commentsError,
|
||
} = useCursorPagination(
|
||
async ({ cursor, pageSize }) => {
|
||
return await commentService.getPostCommentsCursor(postId, {
|
||
cursor,
|
||
page_size: pageSize,
|
||
});
|
||
},
|
||
{ pageSize: 20 }
|
||
);
|
||
|
||
const getCommentId = useCallback((comment: Comment) => String(comment.id), []);
|
||
|
||
const mergeCommentsById = useCallback((previousInput: Comment[] | null | undefined, incomingInput: Comment[] | null | undefined): Comment[] => {
|
||
const previous = Array.isArray(previousInput) ? previousInput : [];
|
||
const incoming = Array.isArray(incomingInput) ? incomingInput : [];
|
||
if (incoming.length === 0) return previous;
|
||
if (previous.length === 0) return incoming;
|
||
|
||
const previousSet = new Set(previous.map(getCommentId));
|
||
const incomingFirstId = getCommentId(incoming[0]);
|
||
if (!previousSet.has(incomingFirstId)) {
|
||
return [...previous, ...incoming];
|
||
}
|
||
|
||
const merged = [...previous];
|
||
const indexById = new Map<string, number>();
|
||
for (let i = 0; i < merged.length; i += 1) {
|
||
indexById.set(getCommentId(merged[i]), i);
|
||
}
|
||
|
||
for (const comment of incoming) {
|
||
const id = getCommentId(comment);
|
||
const existingIndex = indexById.get(id);
|
||
if (existingIndex === undefined) {
|
||
indexById.set(id, merged.length);
|
||
merged.push(comment);
|
||
} else {
|
||
merged[existingIndex] = comment;
|
||
}
|
||
}
|
||
return merged;
|
||
}, [getCommentId]);
|
||
|
||
const mergeCommentsRefreshWindow = useCallback((previousInput: Comment[] | null | undefined, latestInput: Comment[] | null | undefined): Comment[] => {
|
||
const previous = Array.isArray(previousInput) ? previousInput : [];
|
||
const latest = Array.isArray(latestInput) ? latestInput : [];
|
||
if (latest.length === 0) return previous;
|
||
if (previous.length === 0) return latest;
|
||
|
||
const latestSet = new Set(latest.map(getCommentId));
|
||
const tail = previous.filter((item) => !latestSet.has(getCommentId(item)));
|
||
return [...latest, ...tail];
|
||
}, [getCommentId]);
|
||
|
||
// 切换帖子时,先清空上一帖评论,避免跨帖残留。
|
||
useEffect(() => {
|
||
setComments([]);
|
||
}, [postId]);
|
||
|
||
// 同步分页评论到本地状态(增量合并,避免全量替换)
|
||
useEffect(() => {
|
||
setComments((prev) => {
|
||
if (isCommentsRefreshing) {
|
||
return mergeCommentsRefreshWindow(prev, paginatedComments);
|
||
}
|
||
return mergeCommentsById(prev, paginatedComments);
|
||
});
|
||
}, [isCommentsRefreshing, mergeCommentsById, mergeCommentsRefreshWindow, paginatedComments]);
|
||
|
||
useEffect(() => {
|
||
if (commentsError) {
|
||
console.warn('[PostPerf] comments request error', { postId, error: commentsError });
|
||
}
|
||
}, [commentsError, postId]);
|
||
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) {
|
||
setIsPostInitialLoading(false);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 使用 ProcessPostUseCase 获取帖子详情
|
||
const postData = await processPostUseCase.fetchPostById(postId);
|
||
if (postData) {
|
||
// 类型转换:将 core/entities/Post 转换为 PostDTO 以保持兼容性
|
||
setPost(postData as unknown as Post);
|
||
// 初始化关注状态
|
||
if (postData.author) {
|
||
setIsFollowing((postData.author as any).is_following || false);
|
||
setIsFollowingMe((postData.author as any).is_following_me || false);
|
||
}
|
||
// 只在首次加载时记录浏览量
|
||
if (recordView && !hasRecordedView.current) {
|
||
hasRecordedView.current = true;
|
||
// 异步记录浏览量,不阻塞加载
|
||
postService.recordView(postId).catch(err => {
|
||
console.error('记录浏览量失败:', err);
|
||
});
|
||
}
|
||
|
||
// 如果是投票帖子,立即加载投票数据
|
||
if ((postData as any).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 as any).is_following || false);
|
||
setIsFollowingMe((updatedPost.author as any).is_following_me || false);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 加载评论(使用游标分页刷新)
|
||
await refreshComments();
|
||
} catch (error) {
|
||
console.error('加载帖子详情失败:', error);
|
||
} finally {
|
||
setIsPostInitialLoading(false);
|
||
}
|
||
}, [postId]);
|
||
|
||
// 首次加载时记录浏览量
|
||
useEffect(() => {
|
||
loadPostDetail(true);
|
||
}, [loadPostDetail]);
|
||
|
||
useEffect(() => {
|
||
const unsubscribe = navigation.addListener('focus', () => {
|
||
loadPostDetail(false);
|
||
});
|
||
return unsubscribe;
|
||
}, [navigation, loadPostDetail]);
|
||
|
||
// 如果是从评论按钮跳转过来的,加载完成后滚动到评论区
|
||
useEffect(() => {
|
||
if (shouldScrollToComments && !isPostInitialLoading && (comments?.length ?? 0) > 0) {
|
||
// 延迟确保列表已完成渲染和布局
|
||
setTimeout(() => {
|
||
flatListRef.current?.scrollToIndex({ index: 0, animated: true, viewPosition: 0 });
|
||
}, 500);
|
||
}
|
||
}, [shouldScrollToComments, isPostInitialLoading, 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(async () => {
|
||
const startedAt = Date.now();
|
||
setIsPostRefreshing(true);
|
||
await loadPostDetail();
|
||
console.log('[PostPerf] detail refresh', {
|
||
postId,
|
||
costMs: Date.now() - startedAt,
|
||
comments: comments.length,
|
||
});
|
||
setIsPostRefreshing(false);
|
||
}, [comments.length, loadPostDetail, postId, refreshComments]);
|
||
|
||
const handleLoadMoreComments = useCallback(async () => {
|
||
const startedAt = Date.now();
|
||
await loadMoreComments();
|
||
console.log('[PostPerf] detail loadMoreComments', {
|
||
postId,
|
||
costMs: Date.now() - startedAt,
|
||
totalItems: comments.length,
|
||
});
|
||
}, [comments.length, loadMoreComments, postId]);
|
||
|
||
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())}`;
|
||
}
|
||
|
||
return formatDateTime(dateString);
|
||
};
|
||
|
||
const getPostEditedDisplayAt = (
|
||
createdAt?: string | null,
|
||
contentEditedAt?: string | null,
|
||
updatedAt?: string | null,
|
||
): string | null => {
|
||
if (contentEditedAt) {
|
||
const c = new Date(createdAt || '').getTime();
|
||
const e = new Date(contentEditedAt).getTime();
|
||
if (!Number.isNaN(c) && !Number.isNaN(e) && e - c > 1000) return contentEditedAt;
|
||
return null;
|
||
}
|
||
if (createdAt && updatedAt) {
|
||
const c = new Date(createdAt).getTime();
|
||
const u = new Date(updatedAt).getTime();
|
||
if (!Number.isNaN(c) && !Number.isNaN(u) && u - c > 1000) return updatedAt;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// 格式化数字
|
||
const 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 processPostUseCase.unlikePost(post.id);
|
||
} else {
|
||
await processPostUseCase.likePost(post.id);
|
||
}
|
||
// UseCase 会更新 store,本地状态已经是乐观更新的,无需再次更新
|
||
} catch (error) {
|
||
console.error('点赞操作失败:', error);
|
||
// 失败时回滚状态
|
||
setPost(prev => prev ? {
|
||
...prev,
|
||
is_liked: oldIsLiked,
|
||
likes_count: oldLikesCount
|
||
} : null);
|
||
}
|
||
}, [post]);
|
||
|
||
// 收藏帖子
|
||
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 processPostUseCase.unfavoritePost(post.id);
|
||
} else {
|
||
await processPostUseCase.favoritePost(post.id);
|
||
}
|
||
// UseCase 会更新 store,本地状态已经是乐观更新的,无需再次更新
|
||
} catch (error) {
|
||
console.error('收藏操作失败:', error);
|
||
// 失败时回滚状态
|
||
setPost(prev => prev ? {
|
||
...prev,
|
||
is_favorited: oldIsFavorited,
|
||
favorites_count: oldFavoritesCount
|
||
} : null);
|
||
}
|
||
}, [post]);
|
||
|
||
// 分享帖子
|
||
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('已复制', '帖子链接已复制到剪贴板');
|
||
}, [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 {
|
||
await processPostUseCase.deletePost(post.id);
|
||
Alert.alert('删除成功', '帖子已删除', [
|
||
{
|
||
text: '确定',
|
||
onPress: () => navigation.goBack(),
|
||
},
|
||
]);
|
||
} catch (error) {
|
||
console.error('删除帖子失败:', error);
|
||
Alert.alert('删除失败', '删除帖子时发生错误,请稍后重试');
|
||
} finally {
|
||
setIsDeleting(false);
|
||
}
|
||
},
|
||
},
|
||
],
|
||
);
|
||
}, [post, isDeleting, currentUser?.id, navigation]);
|
||
|
||
const handleEditPost = useCallback(() => {
|
||
if (!post) return;
|
||
navigation.navigate('CreatePost', {
|
||
mode: 'edit',
|
||
postId: post.id,
|
||
});
|
||
}, [navigation, post]);
|
||
|
||
// 点击图片查看大图
|
||
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: '',
|
||
cover_url: '',
|
||
bio: '',
|
||
website: '',
|
||
location: '',
|
||
phone: undefined,
|
||
email: undefined,
|
||
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 commentService.unlikeComment(comment.id);
|
||
} else {
|
||
await commentService.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 }]}>
|
||
<View style={styles.metaInfoMain}>
|
||
<Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}>
|
||
发布 {formatRelativeTime(post.created_at)}
|
||
</Text>
|
||
{(() => {
|
||
const editedAt = getPostEditedDisplayAt(
|
||
post.created_at,
|
||
post.content_edited_at,
|
||
post.updated_at,
|
||
);
|
||
return editedAt ? (
|
||
<>
|
||
<Text style={styles.metaInfoDot}>·</Text>
|
||
<Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}>
|
||
修改 {formatRelativeTime(editedAt)}
|
||
</Text>
|
||
</>
|
||
) : null;
|
||
})()}
|
||
{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>
|
||
|
||
{currentUser?.id === post.author?.id && (
|
||
<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>
|
||
)}
|
||
</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>
|
||
);
|
||
}, [post, postImages, currentUser?.id, isDeleting, handleLike, handleShare, handleFavorite, handleDeletePost, handleEditPost, handleImagePress, voteResult, isVoteLoading, handleVote, handleUnvote, isDesktop, isTablet, isWideScreen, responsivePadding, responsiveGap]);
|
||
|
||
// 回复评论
|
||
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 commentKeyExtractor = useCallback((item: Comment) => item.id, []);
|
||
|
||
const renderComment = useCallback(({ 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}
|
||
/>
|
||
);
|
||
}, [currentUser?.id, handleDeleteComment, handleImagePress, handleLikeComment, handleLoadMoreReplies, post?.user_id]);
|
||
|
||
// 渲染空评论 - 现代化设计
|
||
const renderEmptyComments = useCallback(() => (
|
||
<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>
|
||
), [isDesktop]);
|
||
|
||
// 渲染评论输入框
|
||
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={commentKeyExtractor}
|
||
ListEmptyComponent={renderEmptyComments}
|
||
contentContainerStyle={{ paddingBottom: responsiveGap }}
|
||
showsVerticalScrollIndicator={false}
|
||
onScrollToIndexFailed={() => {
|
||
setTimeout(() => {
|
||
flatListRef.current?.scrollToEnd({ animated: true });
|
||
}, 100);
|
||
}}
|
||
refreshControl={
|
||
<RefreshControl
|
||
refreshing={isPostRefreshing || isCommentsRefreshing}
|
||
onRefresh={onRefresh}
|
||
colors={[colors.primary.main]}
|
||
tintColor={colors.primary.main}
|
||
/>
|
||
}
|
||
onEndReached={handleLoadMoreComments}
|
||
onEndReachedThreshold={0.3}
|
||
initialNumToRender={10}
|
||
maxToRenderPerBatch={10}
|
||
updateCellsBatchingPeriod={60}
|
||
windowSize={7}
|
||
removeClippedSubviews
|
||
ListFooterComponent={
|
||
isCommentsInitialLoading ? (
|
||
<View style={styles.commentsLoadingFooter}>
|
||
<Loading size="sm" />
|
||
</View>
|
||
) : isCommentsLoadingMore ? (
|
||
<View style={styles.commentsLoadingFooter}>
|
||
<Loading size="sm" />
|
||
</View>
|
||
) : hasMoreComments ? (
|
||
<TouchableOpacity
|
||
style={styles.loadMoreButton}
|
||
onPress={handleLoadMoreComments}
|
||
>
|
||
<Text variant="caption" color={colors.primary.main}>
|
||
加载更多评论
|
||
</Text>
|
||
</TouchableOpacity>
|
||
) : (comments?.length ?? 0) > 0 ? (
|
||
<Text variant="caption" color={colors.text.hint} style={styles.noMoreComments}>
|
||
没有更多评论了
|
||
</Text>
|
||
) : null
|
||
}
|
||
/>
|
||
);
|
||
|
||
// 渲染右侧边栏(桌面端)
|
||
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 (isPostInitialLoading) {
|
||
return <Loading fullScreen />;
|
||
}
|
||
|
||
if (!post) {
|
||
return (
|
||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||
<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={isPostRefreshing || isCommentsRefreshing}
|
||
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={commentKeyExtractor}
|
||
ListHeaderComponent={renderPostHeader}
|
||
ListEmptyComponent={renderEmptyComments}
|
||
contentContainerStyle={{ paddingBottom: responsiveGap }}
|
||
showsVerticalScrollIndicator={false}
|
||
onScrollToIndexFailed={() => {
|
||
setTimeout(() => {
|
||
flatListRef.current?.scrollToEnd({ animated: true });
|
||
}, 100);
|
||
}}
|
||
refreshControl={
|
||
<RefreshControl
|
||
refreshing={isPostRefreshing || isCommentsRefreshing}
|
||
onRefresh={onRefresh}
|
||
colors={[colors.primary.main]}
|
||
tintColor={colors.primary.main}
|
||
/>
|
||
}
|
||
onEndReached={handleLoadMoreComments}
|
||
onEndReachedThreshold={0.3}
|
||
initialNumToRender={10}
|
||
maxToRenderPerBatch={10}
|
||
updateCellsBatchingPeriod={60}
|
||
windowSize={7}
|
||
removeClippedSubviews
|
||
ListFooterComponent={
|
||
isCommentsInitialLoading ? (
|
||
<View style={styles.commentsLoadingFooter}>
|
||
<Loading size="sm" />
|
||
</View>
|
||
) : isCommentsLoadingMore ? (
|
||
<View style={styles.commentsLoadingFooter}>
|
||
<Loading size="sm" />
|
||
</View>
|
||
) : hasMoreComments ? (
|
||
<TouchableOpacity
|
||
style={styles.loadMoreButton}
|
||
onPress={handleLoadMoreComments}
|
||
>
|
||
<Text variant="caption" color={colors.primary.main}>
|
||
加载更多评论
|
||
</Text>
|
||
</TouchableOpacity>
|
||
) : (comments?.length ?? 0) > 0 ? (
|
||
<Text variant="caption" color={colors.text.hint} style={styles.noMoreComments}>
|
||
没有更多评论了
|
||
</Text>
|
||
) : null
|
||
}
|
||
/>
|
||
|
||
{/* 评论输入框 - 跟随键盘 */}
|
||
{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',
|
||
justifyContent: 'space-between',
|
||
marginBottom: spacing.sm,
|
||
},
|
||
metaInfoMain: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
flexWrap: 'wrap',
|
||
flex: 1,
|
||
minWidth: 0,
|
||
},
|
||
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',
|
||
marginLeft: spacing.sm,
|
||
padding: spacing.xs,
|
||
},
|
||
deleteButtonText: {
|
||
marginLeft: 2,
|
||
fontSize: fontSizes.sm,
|
||
},
|
||
metaActions: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
marginLeft: spacing.sm,
|
||
flexShrink: 0,
|
||
},
|
||
editButtonInline: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
padding: spacing.xs,
|
||
},
|
||
editButtonText: {
|
||
marginLeft: 2,
|
||
fontSize: fontSizes.sm,
|
||
},
|
||
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',
|
||
},
|
||
// 评论加载更多样式
|
||
commentsLoadingFooter: {
|
||
paddingVertical: spacing.md,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
loadMoreButton: {
|
||
paddingVertical: spacing.md,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: colors.background.paper,
|
||
borderTopWidth: StyleSheet.hairlineWidth,
|
||
borderTopColor: colors.divider,
|
||
},
|
||
noMoreComments: {
|
||
textAlign: 'center',
|
||
paddingVertical: spacing.md,
|
||
},
|
||
});
|