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

@@ -21,7 +21,7 @@ import {
Image,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
@@ -31,6 +31,7 @@ import { ApiError } from '../../services/api';
import { uploadService } from '../../services/uploadService';
import VoteEditor from '../../components/business/VoteEditor';
import { useResponsive, useResponsiveValue } from '../../hooks';
import { RootStackParamList } from '../../navigation/types';
const MAX_TITLE_LENGTH = 100;
const MAX_CONTENT_LENGTH = 2000;
@@ -57,9 +58,9 @@ const EMOJIS = [
'👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌',
'👐', '🤲', '🤝', '🙏', '✍️', '💪', '🦾', '🦵',
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍',
'🤎', '💔', '❤️\u200d🔥', '❤️\u200d🩹', '💕', '💞', '💓', '💗',
'💖', '💘', '💝', '🎉', '🎊', '🎁', '🎈', '✨',
'🔥', '💯', '💢', '💥', '💫', '💦', '💨', '🕳️',
'🤎', '💔', '🩹', '💕', '💞', '💓', '💗', '💖',
'💘', '💝', '🎉', '🎊', '🎁', '🎈', '✨', '🔥',
'💯', '💢', '💥', '💫', '💦', '💨', '🕳️',
];
// 动画值
@@ -74,6 +75,9 @@ const getPublishErrorMessage = (error: unknown): string => {
export const CreatePostScreen: React.FC = () => {
const navigation = useNavigation();
const route = useRoute<RouteProp<RootStackParamList, 'CreatePost'>>();
const isEditMode = route.params?.mode === 'edit' && !!route.params?.postId;
const editPostID = route.params?.postId || '';
// 响应式布局
const { isWideScreen, width } = useResponsive();
@@ -86,6 +90,7 @@ export const CreatePostScreen: React.FC = () => {
const [showTagInput, setShowTagInput] = useState(false);
const [showEmojiPanel, setShowEmojiPanel] = useState(false);
const [posting, setPosting] = useState(false);
const [loadingPost, setLoadingPost] = useState(false);
// 投票相关状态
const [isVotePost, setIsVotePost] = useState(false);
@@ -122,6 +127,41 @@ export const CreatePostScreen: React.FC = () => {
]).start();
}, []);
React.useLayoutEffect(() => {
navigation.setOptions({
title: isEditMode ? '编辑帖子' : '发布帖子',
});
}, [navigation, isEditMode]);
React.useEffect(() => {
if (!isEditMode || !editPostID) {
return;
}
const loadPostForEdit = async () => {
setLoadingPost(true);
try {
const existingPost = await postService.getPost(editPostID);
if (!existingPost) {
Alert.alert('提示', '帖子不存在或已被删除');
navigation.goBack();
return;
}
setTitle(existingPost.title || '');
setContent(existingPost.content || '');
setImages((existingPost.images || []).map((img) => ({ uri: img.url, uploading: false })));
} catch (error) {
console.error('加载待编辑帖子失败:', error);
Alert.alert('错误', '加载帖子失败,请稍后重试');
navigation.goBack();
} finally {
setLoadingPost(false);
}
};
loadPostForEdit();
}, [isEditMode, editPostID, navigation]);
// 选择图片
const handlePickImage = async () => {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
@@ -323,19 +363,37 @@ export const CreatePostScreen: React.FC = () => {
});
navigation.goBack();
} else {
// 创建普通帖子
await postService.createPost({
title: title.trim() || '无标题',
content: content.trim(),
images: imageUrls,
});
showPrompt({
type: 'info',
title: '审核中',
message: '帖子已提交,内容审核中,稍后展示',
duration: 2600,
});
navigation.goBack();
if (isEditMode && editPostID) {
const updated = await postService.updatePost(editPostID, {
title: title.trim() || '无标题',
content: content.trim(),
images: imageUrls,
});
if (!updated) {
throw new Error('更新帖子失败');
}
showPrompt({
type: 'success',
title: '修改成功',
message: '帖子内容已更新',
duration: 2200,
});
navigation.goBack();
} else {
// 创建普通帖子
await postService.createPost({
title: title.trim() || '无标题',
content: content.trim(),
images: imageUrls,
});
showPrompt({
type: 'info',
title: '审核中',
message: '帖子已提交,内容审核中,稍后展示',
duration: 2600,
});
navigation.goBack();
}
}
} catch (error) {
console.error('发布帖子失败:', error);
@@ -631,7 +689,7 @@ export const CreatePostScreen: React.FC = () => {
<MaterialCommunityIcons name="loading" size={18} color={colors.primary.contrast} />
) : (
<Text variant="body" color={colors.primary.contrast} style={styles.postButtonText}>
{isEditMode ? '保存' : '发布'}
</Text>
)}
</TouchableOpacity>
@@ -677,10 +735,24 @@ export const CreatePostScreen: React.FC = () => {
>
{isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
{renderMainContent()}
{loadingPost ? (
<View style={styles.loadingContainer}>
<MaterialCommunityIcons name="loading" size={28} color={colors.primary.main} />
<Text variant="body" color={colors.text.secondary} style={styles.loadingText}>...</Text>
</View>
) : (
renderMainContent()
)}
</ResponsiveContainer>
) : (
renderMainContent()
loadingPost ? (
<View style={styles.loadingContainer}>
<MaterialCommunityIcons name="loading" size={28} color={colors.primary.main} />
<Text variant="body" color={colors.text.secondary} style={styles.loadingText}>...</Text>
</View>
) : (
renderMainContent()
)
)}
</KeyboardAvoidingView>
</SafeAreaView>
@@ -934,6 +1006,15 @@ const styles = StyleSheet.create({
emojiText: {
fontSize: 24,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
gap: spacing.md,
},
loadingText: {
fontSize: fontSizes.md,
},
});
export default CreatePostScreen;

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',

View File

@@ -117,8 +117,8 @@ const GroupInfoScreen: React.FC = () => {
// 并行加载群组信息和成员列表
const [groupData, membersData] = await Promise.all([
groupManager.getGroup(groupId),
groupManager.getMembers(groupId, 1, 100),
groupManager.getGroup(groupId, true),
groupManager.getMembers(groupId, 1, 100, true),
]);
setGroup(groupData);

View File

@@ -92,11 +92,16 @@ const GroupMembersScreen: React.FC = () => {
const isAdmin = currentMember?.role === 'admin' || isOwner;
// 加载成员列表
const loadMembers = useCallback(async (pageNum: number = 1, refresh: boolean = false) => {
const loadMembers = useCallback(
async (
pageNum: number = 1,
refresh: boolean = false,
forceRefresh: boolean = false
) => {
if (!hasMore && !refresh) return;
try {
const response = await groupManager.getMembers(groupId, pageNum, 50);
const response = await groupManager.getMembers(groupId, pageNum, 50, forceRefresh);
if (refresh) {
setMembers(response.list);
@@ -121,14 +126,14 @@ const GroupMembersScreen: React.FC = () => {
// 初始加载
useEffect(() => {
loadMembers(1, true);
loadMembers(1, true, true);
}, [groupId]);
// 下拉刷新
const onRefresh = useCallback(() => {
setRefreshing(true);
setHasMore(true);
loadMembers(1, true);
loadMembers(1, true, true);
}, [loadMembers]);
// 加载更多
@@ -246,6 +251,8 @@ const GroupMembersScreen: React.FC = () => {
}
return m;
}));
// 强制刷新远端状态,避免命中旧缓存导致解禁后仍显示禁言
await loadMembers(1, true, true);
setActionModalVisible(false);
Alert.alert('成功', `${actionText}`);

View File

@@ -72,9 +72,10 @@ const truncateDisplayName = (name: string, maxLength: number = MAX_CONVERSATION_
*/
const AsyncMessagePreview: React.FC<{
segments?: MessageSegment[];
status?: string;
isGroupChat?: boolean;
senderName?: string;
}> = ({ segments, isGroupChat, senderName }) => {
}> = ({ segments, status, isGroupChat, senderName }) => {
const [displayText, setDisplayText] = useState<string>('');
const isMountedRef = useRef(true);
@@ -82,6 +83,13 @@ const AsyncMessagePreview: React.FC<{
isMountedRef.current = true;
const loadPreview = async () => {
if (status === 'recalled') {
if (isMountedRef.current) {
setDisplayText('消息已撤回');
}
return;
}
const initialText = extractTextFromSegments(segments);
if (isMountedRef.current) {
setDisplayText(initialText);
@@ -112,7 +120,7 @@ const AsyncMessagePreview: React.FC<{
return () => {
isMountedRef.current = false;
};
}, [segments]);
}, [segments, status]);
if (!displayText) return null;
@@ -556,6 +564,7 @@ export const MessageListScreen: React.FC = () => {
) : (
<AsyncMessagePreview
segments={item.last_message?.segments}
status={item.last_message?.status}
isGroupChat={isGroupChat}
senderName={getSenderName()}
/>

View File

@@ -27,9 +27,6 @@ import { uploadService } from '../../../../services/uploadService';
import { ApiError } from '../../../../services/api';
// 【新架构】使用 MessageManager
import { useChat, useGroupTyping, useGroupMuted, messageManager } from '../../../../stores';
import {
websocketService,
} from '../../../../services/websocketService';
import { groupService } from '../../../../services/groupService';
import { userManager } from '../../../../stores/userManager';
import { groupManager } from '../../../../stores/groupManager';
@@ -728,14 +725,7 @@ export const useChatScreen = () => {
const segments = buildTextSegments(trimmedText, replyingTo);
if (isGroupChat && routeGroupId) {
// 群聊消息发送
websocketService.sendGroupChatMessage({
conversationId: conversationId,
groupId: routeGroupId,
segments: segments,
mentionUsers: selectedMentions.length > 0 ? selectedMentions : undefined,
mentionAll: mentionAll || undefined,
});
await messageService.sendMessageByAction('group', conversationId, segments);
setInputText('');
setSelectedMentions([]);
@@ -790,12 +780,7 @@ export const useChatScreen = () => {
const segments = buildImageSegments(uploadResult.url, uploadResult.url, undefined, undefined, replyingTo);
if (isGroupChat && routeGroupId) {
websocketService.sendGroupChatMessage({
conversationId: conversationId,
groupId: routeGroupId,
mediaUrl: uploadResult.url,
segments: segments,
});
await messageService.sendMessageByAction('group', conversationId, segments);
setReplyingTo(null);
} else {
// 【新架构】私聊图片通过 MessageManager 发送
@@ -922,12 +907,7 @@ export const useChatScreen = () => {
const segments = buildImageSegments(stickerUrl, stickerUrl, undefined, undefined, replyingTo);
if (isGroupChat && routeGroupId) {
websocketService.sendGroupChatMessage({
conversationId: conversationId,
groupId: routeGroupId,
mediaUrl: stickerUrl,
segments: segments,
});
await messageService.sendMessageByAction('group', conversationId, segments);
setReplyingTo(null);
} else {
// 【新架构】私聊表情通过 MessageManager 发送
@@ -983,7 +963,7 @@ export const useChatScreen = () => {
const handleRecall = useCallback(async (messageId: string) => {
try {
if (isGroupChat && routeGroupId) {
websocketService.sendGroupRecall(routeGroupId, conversationId!, messageId);
await messageService.recallMessage(messageId);
} else {
await messageService.recallMessage(messageId);
}

View File

@@ -145,7 +145,7 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
<Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text>
)}
<Text style={[styles.messageText, isMe ? styles.messageTextMe : styles.messageTextOther]}>
{extractTextFromSegments(item.segments)}
{item.status === 'recalled' ? '消息已撤回' : extractTextFromSegments(item.segments)}
</Text>
</View>
{isMe && (