Files
frontend/src/screens/home/PostDetailScreen.tsx
lan b15e0c0b0b
Some checks failed
Frontend CI / ota-ios (push) Successful in 2m3s
Frontend CI / ota-android (push) Successful in 2m4s
Frontend CI / build-and-push-web (push) Failing after 33m21s
Frontend CI / build-android-apk (push) Successful in 59m9s
feat(editor): implement deferred image uploading and pending state management
Refactor the image handling workflow to decouple image selection from the upload process. This improves user experience by allowing users to continue composing posts or trades while images are queued for upload, and increases reliability by using a centralized pending image utility.

- **Deferred Uploads**: Replaced immediate image uploads in `CreatePostScreen`, `PostDetailScreen`, and `CreateTradeScreen` with a "pending" state. Images are now uploaded in bulk during the final submission phase.
- **BlockEditor Enhancements**:
    - Updated `useBlockEditor` to support asynchronous batch uploading of all pending images via a new `uploadPendingImages` method.
    - Removed inline uploading overlays in favor of a more robust state-driven approach.
    - Improved `BlockEditorHandle` to expose block retrieval and batch upload capabilities.
- **API Layer Improvements**:
    - Migrated native image uploads from standard `FormData` to `expo-file-system`'s multipart upload to resolve compatibility issues with recent React Native versions.
    - Maintained `fetch` + `Blob` logic for web platform compatibility.
- **New Utilities**: Introduced `src/utils/pendingImages.ts` to manage the lifecycle of local (pending) vs. remote (uploaded) images across the application.
2026-06-07 00:40:35 +08:00

2375 lines
74 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 帖子详情页 PostDetailScreen - QQ频道风格响应式版本
* 威友 - 显示完整帖子内容和评论
* 桌面端使用双栏布局,移动端使用单栏布局
*/
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
View,
StyleSheet,
RefreshControl,
TouchableOpacity,
TextInput,
Keyboard,
Platform,
Modal,
KeyboardEvent,
KeyboardAvoidingView,
Alert,
ScrollView,
Image,
Clipboard,
Dimensions,
} from 'react-native';
import { FlashList, ListRenderItem, FlashListRef } from '@shopify/flash-list';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useRouter, useLocalSearchParams } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import {
spacing,
fontSizes,
borderRadius,
useAppColors,
type AppColors,
} from '../../theme';
import { Post, Comment, VoteResultDTO, VoteOptionDTO, MessageSegment } from '../../types';
import { useUserStore } from '../../stores';
import { useCurrentUser } from '../../stores/auth';
import { postService, commentService, authService, showPrompt, voteService } from '../../services';
import { postSyncService } from '@/services/post';
import { useCursorPagination } from '../../hooks/useCursorPagination';
import { CommentItem, VoteCard, ReportDialog, ShareSheet, PostContentRenderer, PostMentionInput } from '../../components/business';
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks';
import {
type PendingOrRemoteImage,
makePendingImageFromAsset,
getImageDisplayUri,
uploadAllPendingImages,
} from '../../utils/pendingImages';
import { handleError } from '@/services/core';
import { formatDateTime as formatDateTimeUtil, formatRelativeTime as formatRelativeTimeUtil } from '@/utils/formatTime';
import * as hrefs from '../../navigation/hrefs';
const EMOJIS = [
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂',
'🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩',
'😘', '😗', '😚', '😙', '😋', '😛', '😜', '🤪',
'😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐', '🤨',
'😐', '😑', '😶', '😏', '😒', '🙄', '😬', '🤥',
'😌', '😔', '😪', '🤤', '😴', '😷', '🤒', '🤕',
'🤢', '🤮', '🤧', '🥵', '🥶', '🥴', '😵', '🤯',
'🤠', '🥳', '🥸', '😎', '🤓', '🧐', '😕', '😟',
'🙁', '😮', '😯', '😲', '😳', '🥺', '😦', '😧',
'😨', '😰', '😥', '😢', '😭', '😱', '😖', '😣',
'😞', '😓', '😩', '😫', '🥱', '😤', '😡', '😠',
'🤬', '😈', '👿', '💀', '👋', '🤚', '🖐️', '✋',
'🖖', '👌', '🤏', '✌️', '🤞', '🤟', '🤘', '🤙',
'👈', '👉', '👆', '👍', '👎', '✊', '👊', '🤛',
'🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '💪',
'🦾', '🦵', '❤️', '🧡', '💛', '💚', '💙', '💜',
'🖤', '🤍', '🤎', '💕', '💞', '💓', '💗', '💖',
'💘', '💝', '🎉', '🎊', '🎁', '🎈', '✨', '🔥',
'💯', '💢', '💥', '💫', '💦', '💨', '🐶', '🐱',
'🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯',
'🦁', '🐮', '🐷', '🐸', '🐵', '🐔', '🐧', '🐦',
'🐤', '🦆', '🦅', '🦉', '🦇', '🐺', '🐗', '🐴',
'🦄', '🐝', '🐛', '🦋', '🐌', '🐞', '🐜', '🍎',
'🍏', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓',
'🍈', '🍒', '🍑', '🥭', '🍍', '🥥', '🥝', '🍅',
'🍆', '🥑', '🌽', '🥕', '🍞', '🥐', '🍔', '🍟',
'🍕', '🌮', '🌯', '🍜', '🍣', '🍱', '🍰', '🎂',
'🍩', '🍪', '☕', '🍵', '🍺', '🍷', '⚽', '🏀',
'🏈', '⚾', '🎾', '🏐', '🎮', '🎲', '🚗', '🚕',
'✈️', '🚀', '⛵', '🌟', '⭐', '🌈', '☀️', '🌙',
];
// 每行显示的 emoji 数量
const EMOJIS_PER_ROW = 8;
const EMOJI_ROW_HEIGHT = 52;
// 将 emoji 分组成行,用于 FlatList 虚拟化
const EMOJI_ROWS = (() => {
const rows: { id: string; emojis: string[] }[] = [];
for (let i = 0; i < EMOJIS.length; i += EMOJIS_PER_ROW) {
rows.push({
id: `emoji-row-${i}`,
emojis: EMOJIS.slice(i, i + EMOJIS_PER_ROW),
});
}
return rows;
})();
function togglePostField(
post: Post,
field: 'is_liked' | 'is_favorited',
countField: 'likes_count' | 'favorites_count',
): Post {
const oldValue = post[field];
const oldCount = post[countField];
return {
...post,
[field]: !oldValue,
[countField]: oldValue ? Math.max(0, oldCount - 1) : oldCount + 1,
};
}
function toggleCommentLikeInTree(comments: Comment[], commentId: string, isLiked: boolean, likesCount: number): Comment[] {
return comments.map(c => {
if (c.id === commentId) {
return { ...c, is_liked: isLiked, likes_count: likesCount };
}
if (c.replies?.length) {
return { ...c, replies: toggleCommentLikeInTree(c.replies, commentId, isLiked, likesCount) };
}
return c;
});
}
export const PostDetailScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createPostDetailStyles(colors), [colors]);
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 [commentSegments, setCommentSegments] = useState<MessageSegment[]>([]);
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<PendingOrRemoteImage[]>([]);
const [showEmojiPanel, setShowEmojiPanel] = useState(false);
const [isFollowing, setIsFollowing] = useState(false);
const [isFollowingMe, setIsFollowingMe] = useState(false);
const [isFollowLoading, setIsFollowLoading] = useState(false);
const flatListRef = useRef<FlashListRef<Comment> | null>(null);
const hasRecordedView = useRef(false); // 是否已记录浏览量
// 举报相关状态
const [reportDialogVisible, setReportDialogVisible] = useState(false);
const [reportTarget, setReportTarget] = useState<{ type: 'post' | 'comment'; id: string } | null>(null);
// 分享面板状态
const [shareSheetVisible, setShareSheetVisible] = useState(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 postSyncService.fetchPostById(postId);
if (postData) {
// 类型转换:将 core/entities/Post 转换为 PostDTO 以保持兼容性
setPost(postData as unknown as Post);
// 初始化关注状态
if (postData.author) {
setIsFollowing(postData.author.is_following || false);
setIsFollowingMe(postData.author.is_following_me || false);
}
if (!hasRecordedView.current) {
hasRecordedView.current = true;
postService.recordView(postId).catch(err => {
console.error('记录浏览量失败:', err);
});
}
if (postData.is_vote) {
setIsVoteLoading(true);
try {
const voteData = await voteService.getVoteResult(postId);
if (voteData) {
setVoteResult(voteData);
}
} catch (voteErr) {
console.error('加载投票数据失败:', voteErr);
} finally {
setIsVoteLoading(false);
}
}
} else {
// 如果API返回空尝试从store中获取
const storePosts = useUserStore.getState().posts;
const updatedPost = storePosts.find(p => p.id === postId);
if (updatedPost) {
setPost(updatedPost);
// 初始化关注状态
if (updatedPost.author) {
setIsFollowing(updatedPost.author.is_following || false);
setIsFollowingMe(updatedPost.author.is_following_me || false);
}
}
}
// 加载评论(使用游标分页刷新)
await refreshComments();
} catch (error) {
handleError(error, { context: '加载帖子详情' });
} 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]);
const handleBackPress = useCallback(() => {
if (router.canGoBack()) {
router.back();
return;
}
router.replace(hrefs.hrefHome());
}, [router]);
const renderCustomHeader = () => (
<View style={styles.customHeader}>
<AppBackButton onPress={handleBackPress} style={styles.headerBackButton} />
{post?.author ? (
<View style={styles.headerContainer}>
<TouchableOpacity
onPress={() => handleUserPress(post.author!.id)}
style={styles.headerAvatarWrapper}
>
<Avatar source={post.author.avatar} size={32} name={post.author.nickname} />
</TouchableOpacity>
<View style={styles.headerUserInfo}>
<TouchableOpacity onPress={() => handleUserPress(post.author!.id)}>
<Text style={styles.headerNickname} numberOfLines={1}>
{post.author.nickname && post.author.nickname.length > 10
? `${post.author.nickname.slice(0, 10)}...`
: post.author.nickname}
</Text>
</TouchableOpacity>
</View>
</View>
) : (
<View style={styles.headerPlaceholder} />
)}
<View style={styles.headerRightContainer}>
{renderFollowButton()}
</View>
</View>
);
// 监听键盘事件
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 => formatDateTimeUtil(dateString);
const formatRelativeTime = (dateString?: string | null): string => formatRelativeTimeUtil(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 originalPost = post;
setPost(prev => prev ? togglePostField(prev, 'is_liked', 'likes_count') : null);
try {
if (originalPost.is_liked) {
await postSyncService.unlikePost(post.id);
} else {
await postSyncService.likePost(post.id);
}
} catch (error) {
handleError(error, { context: '点赞' });
setPost(originalPost);
}
}, [post]);
const handleFavorite = useCallback(async () => {
if (!post) return;
const originalPost = post;
setPost(prev => prev ? togglePostField(prev, 'is_favorited', 'favorites_count') : null);
try {
if (originalPost.is_favorited) {
await postSyncService.unfavoritePost(post.id);
} else {
await postSyncService.favoritePost(post.id);
}
} catch (error) {
handleError(error, { context: '收藏' });
setPost(originalPost);
}
}, [post]);
// 分享帖子 - 打开分享面板
const handleShare = useCallback(async () => {
if (!post?.id) return;
setShareSheetVisible(true);
}, [post?.id]);
// 投票处理函数
const handleVote = useCallback(async (optionId: string) => {
if (!post || !voteResult || isVoteLoading) return;
// 保存旧状态用于回滚
const oldVoteResult = { ...voteResult };
// 乐观更新
setVoteResult((prev: VoteResultDTO | null) => {
if (!prev) return null;
return {
...prev,
has_voted: true,
voted_option_id: optionId,
total_votes: prev.total_votes + 1,
options: prev.options.map((opt: VoteOptionDTO) =>
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) {
handleError(error, { context: '投票' });
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: VoteResultDTO | null) => {
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: VoteOptionDTO) =>
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) {
handleError(error, { context: '取消投票' });
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 postSyncService.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 => makePendingImageFromAsset(asset, 'ci'));
setCommentImages(prev => [...prev, ...newImages]);
}
};
// 删除评论图片
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;
// 上传所有 pending 图片
let uploadedImageUrls: string[] = [];
if (commentImages.length > 0) {
const uploadResult = await uploadAllPendingImages(commentImages, (id, url) => {
setCommentImages(prev => prev.map(i => i.id === id ? { id, kind: 'remote' as const, url } : i));
});
if (!uploadResult.success) {
Alert.alert('上传失败', '部分图片上传失败,请重试');
return;
}
uploadedImageUrls = uploadResult.urls;
}
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;
// 先创建临时评论显示在列表中
const tempComment: Comment = {
id: tempId,
post_id: post.id,
user_id: userId,
parent_id: parentId,
root_id: rootId,
target_id: parentId,
content: commentText.trim(),
segments: commentSegments.length > 0 ? commentSegments : undefined,
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);
setIsComposerVisible(false);
// 更新帖子评论数
setPost(prev => prev ? {
...prev,
comments_count: prev.comments_count + 1
} : null);
// 调用API创建评论
try {
const createdComment = await commentService.createComment({
postId: post.id,
content: commentText.trim(),
segments: commentSegments.length > 0 ? commentSegments : undefined,
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 { id, is_liked, likes_count } = comment;
const oldIsLiked = is_liked ?? false;
const newIsLiked = !oldIsLiked;
const newLikesCount = newIsLiked ? likes_count + 1 : Math.max(0, likes_count - 1);
const originalComments = comments;
setComments(prev => toggleCommentLikeInTree(prev, id, newIsLiked, newLikesCount));
try {
const success = newIsLiked
? await commentService.likeComment(id)
: await commentService.unlikeComment(id);
if (!success) {
throw new Error('点赞操作失败');
}
} catch (error) {
handleError(error, { context: '评论点赞' });
setComments(toggleCommentLikeInTree(originalComments, id, oldIsLiked, likes_count));
}
};
// 删除评论
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);
}
};
// 渲染关注按钮(始终返回同尺寸占位,避免 header 右侧宽度跳动)
const renderFollowButton = useCallback(() => {
const shouldShow = post?.author?.id && currentUser?.id !== post.author.id;
if (!shouldShow) {
return <View style={styles.followButtonPlaceholder} />;
}
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>
);
}, [post?.author?.id, currentUser?.id, isFollowing, isFollowingMe, isFollowLoading, styles]);
// 使用 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]);
// 为 ImageGallery 提供稳定的图片列表,避免 Math.random() 在每次渲染时生成新 ID
const stableGalleryImages = useMemo(() => allImages.map((img, i) => ({
id: img.id || img.url || `img-${i}`,
url: img.url || img.uri || ''
})), [allImages]);
// 渲染帖子头部 - 小红书/微博风格
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"
selectable
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}>
<PostContentRenderer
content={post.content}
segments={post.segments}
textStyle={[
styles.content,
{
fontSize: isDesktop ? fontSizes.xl : isTablet ? fontSizes.lg : fontSizes.lg,
lineHeight: isDesktop ? 32 : isTablet ? 30 : 28
}
]}
onImagePress={(url) => handleImagePress([{ uri: url }], 0)}
/>
</View>
{/* 仅当 segments 中不含 image 时才渲染独立的图片区域 */}
{!post.segments?.some(s => s.type === 'image') && 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>
)}
{/* 所属板块 */}
{!!post.channel?.name && (
<View style={[styles.channelTagContainer, { marginTop: responsiveGap }]}>
<View style={styles.channelTag}>
<MaterialCommunityIcons name="tag-outline" size={11} color={colors.primary.main} />
<Text style={styles.channelTagText} numberOfLines={1}>
{post.channel.name}
</Text>
</View>
</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 && 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>
)}
{/* 举报按钮 - 只对已登录且非帖子作者显示 */}
{!!currentUser && currentUser.id !== post.author?.id && (
<TouchableOpacity
style={styles.reportButtonInline}
onPress={() => {
setReportTarget({ type: 'post', id: post.id });
setReportDialogVisible(true);
}}
>
<MaterialCommunityIcons
name="flag-outline"
size={14}
color={colors.text.hint}
/>
<Text variant="caption" color={colors.text.hint} style={styles.reportButtonText}>
</Text>
</TouchableOpacity>
)}
</View>
{/* 评论标题 - 类似图片中的样式 */}
<View style={[styles.commentSectionHeader, { marginTop: responsiveGap * 1.5, marginBottom: responsiveGap }]}>
<View style={styles.commentSectionTitleRow}>
<Text
variant="body"
style={[
styles.commentSectionTitle,
{ fontSize: isDesktop ? fontSizes.lg : fontSizes.md }
]}
>
</Text>
<Text variant="caption" color={colors.text.hint} style={styles.commentSectionCount}>
{post.comments_count || 0}
</Text>
</View>
<View style={styles.commentSectionUnderline} />
</View>
</View>
);
}, [post, postImages, currentUser?.id, isDeleting, handleDeletePost, handleEditPost, handleImagePress, voteResult, isVoteLoading, handleVote, handleUnvote, isDesktop, isTablet, isWideScreen, responsivePadding, responsiveGap]);
// 回复评论
const [replyingTo, setReplyingTo] = useState<Comment | null>(null);
const [isComposerVisible, setIsComposerVisible] = useState(false);
const handleReply = (comment: Comment) => {
setReplyingTo(comment);
setIsComposerVisible(true);
};
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 handleReportComment = useCallback((comment: Comment) => {
setReportTarget({ type: 'comment', id: comment.id });
setReportDialogVisible(true);
}, []);
// 渲染评论 - 带楼层号
const commentKeyExtractor = useCallback((item: Comment) => item.id, []);
const renderComment = useCallback<ListRenderItem<Comment>>(({ item, index }) => {
const authorId = item.author?.id || '';
// 收集当前评论的所有回复,用于根据 target_id 查找被回复用户
const allReplies = item.replies || [];
// 判断当前用户是否为评论作者
const isCommentAuthor = currentUser?.id === authorId;
return (
<CommentItem
comment={item}
onLike={handleLikeComment}
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}
onReport={handleReportComment}
/>
);
}, [currentUser?.id, handleDeleteComment, handleImagePress, handleLikeComment, handleLoadMoreReplies, post?.user_id, handleReportComment]);
// 渲染空评论 - 类似图片中的样式
const renderEmptyComments = useCallback(() => (
<View style={styles.emptyCommentsContainer}>
{/* 四个方块图标 */}
<View style={styles.emptyCommentsGrid}>
<View style={styles.emptyCommentsGridItem} />
<View style={styles.emptyCommentsGridItem} />
<View style={styles.emptyCommentsGridItem} />
<View style={[styles.emptyCommentsGridItem, styles.emptyCommentsGridItemActive]} />
</View>
<Text variant="caption" color={colors.text.hint} style={styles.emptyCommentsSubtitle}>
</Text>
</View>
), []);
const openComposer = () => {
setIsComposerVisible(true);
};
const closeComposer = () => {
setIsComposerVisible(false);
setShowEmojiPanel(false);
Keyboard.dismiss();
};
const handleAtMention = () => {
setCommentText(prev => prev + '@');
setShowEmojiPanel(false);
};
const handleToggleEmojiPanel = () => {
const willShow = !showEmojiPanel;
if (willShow) {
Keyboard.dismiss();
}
setShowEmojiPanel(willShow);
};
const handleInsertEmoji = (emoji: string) => {
setCommentText(prev => prev + emoji);
};
const renderCommentInput = () => (
<View style={[
styles.inputContainer,
{
paddingHorizontal: responsivePadding,
paddingVertical: responsiveGap,
paddingBottom: (Platform.OS === 'android' ? keyboardHeight : 0) + responsiveGap + insets.bottom,
}
]}>
{/* 回复提示 */}
{replyingTo && !isComposerVisible && (
<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>
)}
{!isComposerVisible ? (
/* Collapsed state - simple input trigger */
<View style={styles.bottomComposerRow}>
<TouchableOpacity
style={styles.inputTrigger}
onPress={openComposer}
activeOpacity={0.7}
>
<Text
style={styles.inputTriggerText}
numberOfLines={1}
>
{replyingTo ? `回复 ${replyingTo.author.nickname}...` : '来说点什么吧!'}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.bottomActionItem} onPress={handleLike}>
<MaterialCommunityIcons
name={post?.is_liked ? 'thumb-up' : 'thumb-up-outline'}
size={isDesktop ? 26 : 24}
color={post?.is_liked ? colors.primary.main : colors.text.secondary}
/>
<Text variant="caption" style={styles.bottomActionCount}>
{formatNumber(post?.likes_count || 0)}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.bottomActionItem} onPress={handleFavorite}>
<MaterialCommunityIcons
name={post?.is_favorited ? 'star' : 'star-outline'}
size={isDesktop ? 26 : 24}
color={post?.is_favorited ? colors.warning.main : colors.text.secondary}
/>
<Text variant="caption" style={styles.bottomActionCount}>
{formatNumber(post?.favorites_count || 0)}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.bottomActionItem} onPress={handleShare}>
<MaterialCommunityIcons name="share-outline" size={isDesktop ? 26 : 24} color={colors.text.secondary} />
<Text variant="caption" style={styles.bottomActionCount}>
{formatNumber(post?.shares_count || 0)}
</Text>
</TouchableOpacity>
</View>
) : (
/* Expanded state - full composer */
<View style={styles.expandedComposer}>
{/* Reply hint */}
{replyingTo && (
<View style={styles.expandedReplyHint}>
<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>
)}
{/* Text Input —— 小红书风格:无边框、自动扩展 */}
<PostMentionInput
value={commentText}
onChangeText={setCommentText}
onSegmentsChange={setCommentSegments}
placeholder={replyingTo ? `回复 ${replyingTo.author.nickname}...` : '来说点什么吧!'}
maxLength={500}
minHeight={60}
autoExpand
style={styles.expandedTextInput}
returnKeyType="send"
onSubmitEditing={handleSendComment}
/>
{/* Image preview */}
{commentImages.length > 0 && (
<ScrollView horizontal style={styles.commentImagesPreview} showsHorizontalScrollIndicator={false}>
{commentImages.map((image, index) => (
<View key={image.id} style={styles.commentImageWrapper}>
<Image
source={{ uri: getImageDisplayUri(image) }}
style={styles.commentImageThumbnail}
resizeMode="cover"
/>
<TouchableOpacity
style={styles.removeImageButton}
onPress={() => handleRemoveCommentImage(index)}
>
<MaterialCommunityIcons name="close-circle" size={18} color={colors.text.primary} />
</TouchableOpacity>
</View>
))}
</ScrollView>
)}
{/* Toolbar */}
<View style={styles.expandedToolbar}>
<View style={styles.expandedToolbarLeft}>
<TouchableOpacity
style={styles.expandedToolbarItem}
onPress={handlePickCommentImage}
disabled={commentImages.length >= 3}
>
<MaterialCommunityIcons
name="image-outline"
size={22}
color={commentImages.length >= 3 ? colors.text.disabled : colors.text.secondary}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.expandedToolbarItem}
onPress={handleAtMention}
>
<MaterialCommunityIcons
name="at"
size={22}
color={colors.text.secondary}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.expandedToolbarItem}
onPress={handleToggleEmojiPanel}
>
<MaterialCommunityIcons
name={showEmojiPanel ? "emoticon-happy" : "emoticon-happy-outline"}
size={22}
color={showEmojiPanel ? colors.primary.main : colors.text.secondary}
/>
</TouchableOpacity>
<Text variant="caption" color={colors.text.hint} style={styles.expandedCharCount}>
{commentText.length}/500
</Text>
</View>
<TouchableOpacity
style={[
styles.expandedSendButton,
!(commentText.trim() || commentImages.length > 0) && styles.expandedSendButtonDisabled
]}
onPress={handleSendComment}
disabled={!(commentText.trim() || commentImages.length > 0)}
>
<Text style={styles.expandedSendButtonText}></Text>
</TouchableOpacity>
</View>
{/* Emoji Panel —— 使用 FlashList 虚拟化渲染,解决显示不全和卡顿 */}
{showEmojiPanel && (
<View style={styles.emojiPanel}>
<FlashList
data={EMOJI_ROWS}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.emojiRow}>
{item.emojis.map((emoji, index) => (
<TouchableOpacity
key={`${item.id}-${index}`}
style={styles.emojiItem}
onPress={() => handleInsertEmoji(emoji)}
activeOpacity={0.7}
>
<Text style={styles.emojiText}>{emoji}</Text>
</TouchableOpacity>
))}
</View>
)}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
drawDistance={100}
/>
</View>
)}
</View>
)}
</View>
);
// 渲染评论列表
const renderCommentsList = () => (
<FlashList
ref={flatListRef}
data={comments}
renderItem={renderComment}
keyExtractor={commentKeyExtractor}
ListEmptyComponent={renderEmptyComments}
contentContainerStyle={{ paddingBottom: responsiveGap }}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isPostRefreshing || isCommentsRefreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={handleLoadMoreComments}
onEndReachedThreshold={0.3}
drawDistance={250}
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 (
<View style={styles.container}>
<EmptyState
title="帖子不存在"
description="该帖子可能已被删除"
icon="alert-circle-outline"
/>
</View>
);
}
// 桌面端双栏布局
if (useDualColumnLayout) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="auto" />
{renderCustomHeader()}
<AdaptiveLayout
sidebar={renderSidebar()}
sidebarWidth={sidebarWidth}
showSidebarBreakpoint="lg"
sidebarPosition="right"
>
<View style={styles.flex}>
<FlashList
ref={flatListRef}
data={comments}
renderItem={renderComment}
keyExtractor={commentKeyExtractor}
ListHeaderComponent={renderPostHeader}
ListEmptyComponent={renderEmptyComments}
contentContainerStyle={{ paddingBottom: responsivePadding }}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isPostRefreshing || isCommentsRefreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={handleLoadMoreComments}
onEndReachedThreshold={0.3}
drawDistance={250}
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()}
</View>
</AdaptiveLayout>
{/* 图片预览 ImageGallery */}
<ImageGallery
visible={showImageModal}
images={stableGalleryImages}
initialIndex={selectedImageIndex}
onClose={() => setShowImageModal(false)}
enableSave
/>
</SafeAreaView>
);
}
// 移动端单栏布局
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="auto" />
{renderCustomHeader()}
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : undefined}
>
<FlashList
ref={flatListRef}
data={comments}
renderItem={renderComment}
keyExtractor={commentKeyExtractor}
ListHeaderComponent={renderPostHeader}
ListEmptyComponent={renderEmptyComments}
contentContainerStyle={{ paddingBottom: responsiveGap }}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isPostRefreshing || isCommentsRefreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={handleLoadMoreComments}
onEndReachedThreshold={0.3}
drawDistance={250}
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={stableGalleryImages}
initialIndex={selectedImageIndex}
onClose={() => setShowImageModal(false)}
enableSave
/>
{/* 举报对话框 */}
<ReportDialog
visible={reportDialogVisible}
targetType={reportTarget?.type || 'post'}
targetId={reportTarget?.id || ''}
onClose={() => {
setReportDialogVisible(false);
setReportTarget(null);
}}
onSuccess={() => {
setReportTarget(null);
}}
/>
{/* 分享面板 */}
<ShareSheet
visible={shareSheetVisible}
postUrl={post ? `https://browser.littlelan.cn/post/${encodeURIComponent(post.id)}` : undefined}
postTitle={post?.title}
onClose={() => {
setShareSheetVisible(false);
}}
onShareAction={(actionKey) => {
if (post) {
(async () => {
try {
const res = await postService.sharePost(post.id, { channel: actionKey });
if (res.ok && res.shares_count != null) {
setPost((prev) =>
prev
? { ...prev, shares_count: res.shares_count!, sharesCount: res.shares_count! }
: null
);
postSyncService.applyShareCountUpdate(post.id, res.shares_count!);
}
} catch (error) {
console.error('上报分享次数失败:', error);
}
})();
}
}}
/>
</SafeAreaView>
);
};
function createPostDetailStyles(colors: AppColors) {
return StyleSheet.create({
flex: {
flex: 1,
},
container: {
flex: 1,
backgroundColor: colors.background.default,
},
customHeader: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
height: 52,
paddingRight: spacing.sm,
},
headerPlaceholder: {
flex: 1,
},
// 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,
backgroundColor: 'transparent',
},
headerBackButton: {
marginLeft: 0,
marginRight: 0,
backgroundColor: 'transparent',
},
// 帖子容器
postContainer: {
backgroundColor: colors.background.paper,
paddingBottom: spacing.md,
},
// 关注按钮样式
followButton: {
paddingHorizontal: spacing.md,
paddingVertical: 4,
borderRadius: borderRadius.lg,
minWidth: 64,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
},
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,
},
followButtonPlaceholder: {
minWidth: 64,
paddingHorizontal: spacing.md,
paddingVertical: 4,
backgroundColor: 'transparent',
},
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,
},
reportButtonInline: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: spacing.sm,
padding: spacing.xs,
},
reportButtonText: {
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,
},
commentSectionHeader: {
flexDirection: 'column',
alignItems: 'flex-start',
paddingTop: spacing.lg,
},
commentSectionTitleRow: {
flexDirection: 'row',
alignItems: 'center',
},
commentSectionTitle: {
fontWeight: '600',
color: colors.text.primary,
letterSpacing: 0,
},
commentSectionCount: {
marginLeft: spacing.xs,
fontSize: fontSizes.sm,
color: colors.text.hint,
},
commentSectionUnderline: {
width: 28,
height: 3,
backgroundColor: colors.text.primary,
marginTop: spacing.xs,
borderRadius: borderRadius.sm,
},
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,
},
inputTrigger: {
flex: 1,
backgroundColor: colors.background.default,
borderRadius: 20,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
minHeight: 44,
justifyContent: 'center',
},
inputTriggerText: {
fontSize: fontSizes.sm,
color: colors.text.hint,
},
bottomComposerRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
bottomActionItem: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 2,
paddingHorizontal: spacing.sm,
marginLeft: 0,
},
bottomActionCount: {
marginTop: 1,
fontSize: fontSizes.xs,
color: colors.text.secondary,
lineHeight: fontSizes.xs + 2,
fontWeight: '500',
},
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,
},
removeImageButton: {
position: 'absolute',
top: -6,
right: -6,
backgroundColor: colors.background.paper,
borderRadius: 9,
},
// Expanded composer styles
expandedComposer: {
backgroundColor: colors.background.paper,
},
expandedReplyHint: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
},
expandedTextInput: {
flexGrow: 1,
fontSize: fontSizes.md,
color: colors.text.primary,
paddingVertical: spacing.sm,
textAlignVertical: 'top',
lineHeight: 24,
},
expandedToolbar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.divider,
},
expandedToolbarLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
expandedToolbarItem: {
padding: spacing.xs,
marginRight: spacing.sm,
},
expandedCharCount: {
marginLeft: 'auto',
marginRight: spacing.sm,
},
expandedSendButton: {
backgroundColor: colors.primary.main,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.md,
minWidth: 60,
alignItems: 'center',
},
expandedSendButtonDisabled: {
backgroundColor: colors.text.disabled,
},
expandedSendButtonText: {
color: colors.text.inverse,
fontSize: fontSizes.sm,
fontWeight: '600',
},
emojiPanel: {
height: 220,
backgroundColor: colors.background.default,
borderRadius: borderRadius.md,
marginTop: spacing.sm,
},
emojiRow: {
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center',
height: EMOJI_ROW_HEIGHT,
paddingHorizontal: spacing.sm,
},
emojiItem: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
emojiText: {
fontSize: 26,
lineHeight: 32,
},
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.5,
},
emptyCommentsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
width: 48,
height: 48,
marginBottom: spacing.md,
},
emptyCommentsGridItem: {
width: 20,
height: 20,
backgroundColor: colors.background.disabled,
margin: 2,
borderRadius: borderRadius.sm,
},
emptyCommentsGridItemActive: {
backgroundColor: colors.text.hint,
},
emptyCommentsSubtitle: {
fontSize: fontSizes.sm,
textAlign: 'center',
color: colors.text.hint,
},
// 侧边栏样式
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',
},
// 所属板块标签
channelTagContainer: {
flexDirection: 'row',
alignItems: 'center',
},
channelTag: {
flexDirection: 'row',
alignItems: 'center',
maxWidth: 140,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: borderRadius.sm,
backgroundColor: `${colors.primary.main}12`,
gap: 4,
},
channelTagText: {
fontSize: fontSizes.xs,
color: colors.primary.main,
fontWeight: '600',
flexShrink: 1,
},
// 评论加载更多样式
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,
},
});
}