Files
frontend/src/screens/home/PostDetailScreen.tsx

2128 lines
65 KiB
TypeScript
Raw Normal View History

/**
* 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, useRouter, useLocalSearchParams } from 'expo-router';
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, AppBackButton } from '../../components/common';
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive';
import * as hrefs from '../../navigation/hrefs';
export const PostDetailScreen: React.FC = () => {
const navigation = useNavigation();
const router = useRouter();
const insets = useSafeAreaInsets();
const { postId: postIdParam, scrollToComments: scrollParam } = useLocalSearchParams<{
postId?: string;
scrollToComments?: string;
}>();
const postId = postIdParam || '';
const shouldScrollToComments = scrollParam === '1' || scrollParam === 'true';
// 使用响应式 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;
const handleBackPress = () => {
if (router.canGoBack()) {
router.back();
return;
}
router.replace(hrefs.hrefHome());
};
navigation.setOptions({
headerBackVisible: false,
headerTitleAlign: 'left',
headerLeft: () => (
<AppBackButton onPress={handleBackPress} style={styles.headerBackButton} />
),
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 && author.nickname.length > 10
? `${author.nickname.slice(0, 10)}...`
: 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 {
const res = await postService.sharePost(post.id, { channel: 'copy_link' });
if (res.ok && res.shares_count != null) {
setPost((prev) =>
prev
? { ...prev, shares_count: res.shares_count!, sharesCount: res.shares_count! }
: null
);
processPostUseCase.applyShareCountUpdate(post.id, res.shares_count);
}
} 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: () => router.back(),
},
]);
} catch (error) {
console.error('删除帖子失败:', error);
Alert.alert('删除失败', '删除帖子时发生错误,请稍后重试');
} finally {
setIsDeleting(false);
}
},
},
],
);
}, [post, isDeleting, currentUser?.id, router]);
const handleEditPost = useCallback(() => {
if (!post) return;
router.push(hrefs.hrefCreatePost('edit', post.id));
}, [router, 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) => {
router.push(hrefs.hrefUserProfile(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.commentSectionHeader, { marginTop: responsiveGap, marginBottom: responsiveGap }]}>
<View style={styles.commentSectionTitleBlock}>
<View style={styles.commentSectionTitleRow}>
<Text
variant="body"
style={[
styles.commentSectionTitle,
{ 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>
</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-reply-text-outline"
size={isDesktop ? 24 : 22}
color={colors.text.secondary}
/>
</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: spacing.sm,
},
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,
},
headerBackButton: {
paddingHorizontal: spacing.xs,
paddingVertical: spacing.xs,
marginLeft: spacing.xs,
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: 'flex-start',
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',
},
commentSectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.divider,
paddingTop: spacing.lg,
},
commentSectionTitleBlock: {
flex: 1,
minWidth: 0,
},
commentSectionTitleRow: {
flexDirection: 'row',
alignItems: 'center',
},
commentSectionTitle: {
fontWeight: '800',
color: colors.text.primary,
letterSpacing: -0.2,
},
commentCountBadge: {
backgroundColor: colors.background.default,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.divider,
paddingHorizontal: spacing.sm + 2,
paddingVertical: 3,
borderRadius: 999,
marginLeft: spacing.sm,
minWidth: 24,
alignItems: 'center',
},
commentCountText: {
fontSize: fontSizes.xs,
fontWeight: '700',
color: colors.text.secondary,
},
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',
marginHorizontal: spacing.md,
marginTop: spacing.md,
marginBottom: spacing.lg,
paddingVertical: spacing.xl,
paddingHorizontal: spacing.md,
},
emptyCommentsIconWrapper: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm,
opacity: 0.85,
},
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,
},
});