Introduce a new `BlockEditor` component and upgrade the post/message rendering system to support rich text segments (images, @mentions, votes, and post references). - Implement `BlockEditor` for long-form post creation with image embedding support. - Upgrade `PostContentRenderer` and `SegmentRenderer` to handle complex segment types including inline images and block elements. - Refactor `PostCard` to use segment-based content and image signatures for optimized memoization. - Centralize segment partitioning logic in `segmentUtils.ts` to ensure consistent rendering across chat and post modules. - Update `PostRepository` and `Post` entity to support the new `segments` data structure.
2401 lines
75 KiB
TypeScript
2401 lines
75 KiB
TypeScript
/**
|
||
* 帖子详情页 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, uploadService, 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 { 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<{ uri: string; uploading?: boolean }[]>([]);
|
||
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 => ({ 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(),
|
||
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]);
|
||
|
||
// 渲染帖子头部 - 小红书/微博风格
|
||
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={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>
|
||
)}
|
||
|
||
{/* 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={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={['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={allImages.map(img => ({
|
||
id: img.id || img.url || String(Math.random()),
|
||
url: img.url || img.uri || ''
|
||
}))}
|
||
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,
|
||
},
|
||
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,
|
||
},
|
||
// 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,
|
||
},
|
||
});
|
||
}
|