Migrate frontend realtime messaging to SSE.

Switch service integrations and screen/store consumers from websocket events to SSE, and ignore generated dist-web artifacts.

Made-with: Cursor
This commit is contained in:
2026-03-10 12:58:23 +08:00
parent 63e32b15a3
commit be84c01abd
25 changed files with 974 additions and 1305 deletions

View File

@@ -15,6 +15,8 @@ import {
TouchableOpacity,
NativeScrollEvent,
NativeSyntheticEvent,
Alert,
Clipboard,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
@@ -357,8 +359,16 @@ export const HomeScreen: React.FC = () => {
};
// 分享帖子
const handleShare = (post: Post) => {
void post;
const handleShare = async (post: Post) => {
if (!post?.id) return;
try {
await postService.sharePost(post.id);
} catch (error) {
console.error('上报分享次数失败:', error);
}
const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`;
Clipboard.setString(postUrl);
Alert.alert('已复制', '帖子链接已复制到剪贴板');
};
// 删除帖子

View File

@@ -20,14 +20,13 @@ import {
Alert,
ScrollView,
Image,
Clipboard,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { Post, Comment, VoteResultDTO } from '../../types';
import { useUserStore } from '../../stores';
@@ -35,11 +34,11 @@ import { useCurrentUser } from '../../stores/authStore';
import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services';
import { CommentItem, VoteCard } from '../../components/business';
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout } from '../../components/common';
import { HomeStackParamList } from '../../navigation/types';
import { RootStackParamList } from '../../navigation/types';
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive';
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'PostDetail'>;
type PostDetailRouteProp = RouteProp<HomeStackParamList, 'PostDetail'>;
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'PostDetail'>;
type PostDetailRouteProp = RouteProp<RootStackParamList, 'PostDetail'>;
export const PostDetailScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
@@ -181,6 +180,13 @@ export const PostDetailScreen: React.FC = () => {
loadPostDetail(true);
}, [loadPostDetail]);
useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => {
loadPostDetail(false);
});
return unsubscribe;
}, [navigation, loadPostDetail]);
// 如果是从评论按钮跳转过来的,加载完成后滚动到评论区
useEffect(() => {
if (shouldScrollToComments && !loading && comments.length > 0) {
@@ -262,16 +268,44 @@ export const PostDetailScreen: React.FC = () => {
setRefreshing(false);
}, [loadPostDetail]);
// 格式化时间
const formatTime = (dateString: string): string => {
try {
return formatDistanceToNow(new Date(dateString), {
addSuffix: true,
locale: zhCN,
});
} catch {
return '';
const formatDateTime = (dateString?: string | null): string => {
if (!dateString) return '';
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) return '';
const pad = (num: number) => String(num).padStart(2, '0');
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
const formatRelativeTime = (dateString?: string | null): string => {
if (!dateString) return '';
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) return '';
const now = Date.now();
const diffMs = now - date.getTime();
if (diffMs < 0) return formatDateTime(dateString);
const minuteMs = 60 * 1000;
const hourMs = 60 * minuteMs;
const dayMs = 24 * hourMs;
if (diffMs < minuteMs) return '刚刚';
if (diffMs < hourMs) return `${Math.floor(diffMs / minuteMs)}分钟前`;
if (diffMs < dayMs) return `${Math.floor(diffMs / hourMs)}小时前`;
if (diffMs < 2 * dayMs) {
const pad = (num: number) => String(num).padStart(2, '0');
return `昨天 ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
return formatDateTime(dateString);
};
const isPostEdited = (createdAt?: string, updatedAt?: string): boolean => {
if (!createdAt || !updatedAt) return false;
const created = new Date(createdAt).getTime();
const updated = new Date(updatedAt).getTime();
if (Number.isNaN(created) || Number.isNaN(updated)) return false;
return updated-created > 1000;
};
// 格式化数字
@@ -350,9 +384,16 @@ export const PostDetailScreen: React.FC = () => {
}, [post, favoritePost, unfavoritePost]);
// 分享帖子
const handleShare = useCallback(() => {
// TODO: 实现分享功能
void post;
const handleShare = useCallback(async () => {
if (!post?.id) return;
try {
await postService.sharePost(post.id);
} catch (error) {
console.error('上报分享次数失败:', error);
}
const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`;
Clipboard.setString(postUrl);
Alert.alert('已复制', '帖子链接已复制到剪贴板');
}, [post?.id]);
// 投票处理函数
@@ -484,6 +525,14 @@ export const PostDetailScreen: React.FC = () => {
);
}, [post, isDeleting, currentUser?.id, navigation]);
const handleEditPost = useCallback(() => {
if (!post) return;
navigation.navigate('CreatePost', {
mode: 'edit',
postId: post.id,
});
}, [navigation, post]);
// 点击图片查看大图
const handleImagePress = useCallback((images: ImageGridItem[], index: number) => {
setAllImages(images);
@@ -1012,33 +1061,59 @@ export const PostDetailScreen: React.FC = () => {
{/* 发帖时间和浏览量 - 放在图片下方 */}
<View style={[styles.postMetaInfo, { marginTop: responsiveGap }]}>
<Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}>
{formatTime(post.created_at)}
</Text>
{post.views_count !== undefined && post.views_count > 0 && (
<>
<Text style={styles.metaInfoDot}>·</Text>
<Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}>
{formatNumber(post.views_count)}
</Text>
</>
)}
{/* 删除按钮 - 只对帖子作者显示 */}
<View style={styles.metaInfoMain}>
<Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}>
{formatRelativeTime(post.created_at)}
</Text>
{isPostEdited(post.created_at, post.updated_at) && (
<>
<Text style={styles.metaInfoDot}>·</Text>
<Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}>
{formatRelativeTime(post.updated_at)}
</Text>
</>
)}
{post.views_count !== undefined && post.views_count > 0 && (
<>
<Text style={styles.metaInfoDot}>·</Text>
<Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}>
{formatNumber(post.views_count)}
</Text>
</>
)}
</View>
{currentUser?.id === post.author?.id && (
<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 style={styles.metaActions}>
<TouchableOpacity
style={styles.editButtonInline}
onPress={handleEditPost}
>
<MaterialCommunityIcons
name="pencil-outline"
size={14}
color={colors.text.hint}
/>
<Text variant="caption" color={colors.text.hint} style={styles.editButtonText}>
</Text>
</TouchableOpacity>
{/* 删除按钮 - 只对帖子作者显示 */}
<TouchableOpacity
style={styles.deleteButtonInline}
onPress={handleDeletePost}
disabled={isDeleting}
>
<MaterialCommunityIcons
name={isDeleting ? 'loading' : 'delete-outline'}
size={14}
color={colors.text.hint}
/>
<Text variant="caption" color={colors.text.hint} style={styles.deleteButtonText}>
</Text>
</TouchableOpacity>
</View>
)}
</View>
@@ -1107,7 +1182,7 @@ export const PostDetailScreen: React.FC = () => {
</View>
</View>
);
}, [post, postImages, currentUser?.id, isDeleting, handleLike, handleShare, handleFavorite, handleDeletePost, handleImagePress, voteResult, isVoteLoading, handleVote, handleUnvote, isDesktop, isTablet, isWideScreen, responsivePadding, responsiveGap]);
}, [post, postImages, currentUser?.id, isDeleting, handleLike, handleShare, handleFavorite, handleDeletePost, handleEditPost, handleImagePress, voteResult, isVoteLoading, handleVote, handleUnvote, isDesktop, isTablet, isWideScreen, responsivePadding, responsiveGap]);
// 回复评论
const [replyingTo, setReplyingTo] = useState<Comment | null>(null);
@@ -1548,8 +1623,16 @@ const styles = StyleSheet.create({
postMetaInfo: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.sm,
},
metaInfoMain: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
flex: 1,
minWidth: 0,
},
metaInfoText: {
fontSize: fontSizes.sm,
color: colors.text.hint,
@@ -1563,13 +1646,28 @@ const styles = StyleSheet.create({
deleteButtonInline: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: 'auto',
marginLeft: spacing.sm,
padding: spacing.xs,
},
deleteButtonText: {
marginLeft: 2,
fontSize: fontSizes.sm,
},
metaActions: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: spacing.sm,
flexShrink: 0,
},
editButtonInline: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.xs,
},
editButtonText: {
marginLeft: 2,
fontSize: fontSizes.sm,
},
imagesContainer: {
flexDirection: 'row',
flexWrap: 'wrap',