From 5ed4eaf0482976e10db8a18c9b2767015c7146e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BB=B7=E5=B0=8F=E5=89=8D?= Date: Sat, 17 Jan 2026 18:27:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0ios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MeAgent/navigation/Menu.js | 1 + MeAgent/navigation/Screens.js | 78 +++ MeAgent/package.json | 3 +- MeAgent/screens/Pro.js | 160 ++--- MeAgent/src/hooks/useCommunitySocket.js | 301 +++++++++ .../src/screens/Community/ChannelDetail.js | 585 ++++++++++++++++ MeAgent/src/screens/Community/ChannelList.js | 371 ++++++++++ .../src/screens/Community/CreateChannel.js | 378 +++++++++++ MeAgent/src/screens/Community/CreatePost.js | 396 +++++++++++ MeAgent/src/screens/Community/ForumChannel.js | 421 ++++++++++++ MeAgent/src/screens/Community/MemberList.js | 299 ++++++++ .../src/screens/Community/Notifications.js | 337 +++++++++ MeAgent/src/screens/Community/PostDetail.js | 510 ++++++++++++++ MeAgent/src/screens/Community/index.js | 109 +++ MeAgent/src/screens/Market/MarketHot.js | 2 +- MeAgent/src/screens/Market/SectorDetail.js | 8 +- MeAgent/src/services/communityService.js | 637 ++++++++++++++++++ MeAgent/src/store/index.js | 2 + MeAgent/src/store/slices/communitySlice.js | 513 ++++++++++++++ mcp_server.py | 90 ++- .../components/ChatArea/MessageRenderer.js | 150 ++++- .../AgentChat/components/ChatArea/index.js | 9 +- .../components/RightSidebar/index.js | 98 --- src/views/AgentChat/constants/messageTypes.ts | 7 + src/views/AgentChat/constants/models.ts | 25 +- src/views/AgentChat/hooks/useAgentChat.ts | 40 ++ src/views/AgentChat/index.js | 3 - 27 files changed, 5292 insertions(+), 241 deletions(-) create mode 100644 MeAgent/src/hooks/useCommunitySocket.js create mode 100644 MeAgent/src/screens/Community/ChannelDetail.js create mode 100644 MeAgent/src/screens/Community/ChannelList.js create mode 100644 MeAgent/src/screens/Community/CreateChannel.js create mode 100644 MeAgent/src/screens/Community/CreatePost.js create mode 100644 MeAgent/src/screens/Community/ForumChannel.js create mode 100644 MeAgent/src/screens/Community/MemberList.js create mode 100644 MeAgent/src/screens/Community/Notifications.js create mode 100644 MeAgent/src/screens/Community/PostDetail.js create mode 100644 MeAgent/src/screens/Community/index.js create mode 100644 MeAgent/src/services/communityService.js create mode 100644 MeAgent/src/store/slices/communitySlice.js diff --git a/MeAgent/navigation/Menu.js b/MeAgent/navigation/Menu.js index 83297f54..87d6a8c4 100644 --- a/MeAgent/navigation/Menu.js +++ b/MeAgent/navigation/Menu.js @@ -166,6 +166,7 @@ function CustomDrawerContent({ { title: "市场热点", navigateTo: "MarketDrawer", icon: "flame", gradient: ["#F59E0B", "#FBBF24"] }, { title: "概念中心", navigateTo: "ConceptsDrawer", icon: "bulb", gradient: ["#06B6D4", "#22D3EE"] }, { title: "我的自选", navigateTo: "WatchlistDrawer", icon: "star", gradient: ["#EC4899", "#F472B6"] }, + { title: "社区论坛", navigateTo: "CommunityDrawer", icon: "chatbubbles", gradient: ["#10B981", "#34D399"] }, { title: "个人中心", navigateTo: "ProfileDrawerNew", icon: "person", gradient: ["#8B5CF6", "#A78BFA"] }, ]; return ( diff --git a/MeAgent/navigation/Screens.js b/MeAgent/navigation/Screens.js index 811f8f89..b5ddcc77 100644 --- a/MeAgent/navigation/Screens.js +++ b/MeAgent/navigation/Screens.js @@ -50,6 +50,15 @@ import WatchlistScreen from "../src/screens/Watchlist/WatchlistScreen"; // 新股票详情页面 import { StockDetailScreen } from "../src/screens/StockDetail"; +// 社区页面 +import CommunityHome from "../src/screens/Community"; +import ChannelDetail from "../src/screens/Community/ChannelDetail"; +import ForumChannel from "../src/screens/Community/ForumChannel"; +import PostDetail from "../src/screens/Community/PostDetail"; +import CreatePost from "../src/screens/Community/CreatePost"; +import CreateChannel from "../src/screens/Community/CreateChannel"; +import MemberList from "../src/screens/Community/MemberList"; + // 新个人中心页面 import { ProfileScreen as NewProfileScreen } from "../src/screens/Profile"; @@ -398,6 +407,68 @@ function WatchlistStack(props) { ); } +// 社区导航栈 +function CommunityStack(props) { + return ( + + + + + + + + + + ); +} + // 新个人中心导航栈 function NewProfileStack(props) { return ( @@ -705,6 +776,13 @@ function AppStack(props) { headerShown: false, }} /> + { const insets = useSafeAreaInsets(); + // 自动跳转到主页 + useEffect(() => { + const timer = setTimeout(() => { + navigation.replace("App"); + }, 1500); // 1.5秒后跳转 + + return () => clearTimeout(timer); + }, [navigation]); + return ( @@ -51,88 +56,64 @@ const Pro = ({ navigation }) => { {/* 主内容 */} - - {/* 上部空间 */} - - + {/* Logo 区域 */} -
- + + + + {/* 品牌名称 */} + + - - - - {/* 品牌名称 */} - - - 价值前沿 - - - VALUE FRONTIER - - -
- - {/* 特性描述 */} -
+ 价值前沿 + + VALUE FRONTIER + + + + {/* 特性描述 */} + + + 智能投资决策平台 + + - 智能投资决策平台{"\n"} 发现市场热点,把握投资机会 -
- - {/* 底部按钮区域 */} - - {/* 开始使用按钮 */} - navigation.navigate("App")}> - {({ isPressed }) => ( - - - 开始使用 - - - )} - - - {/* 版本信息 */} -
- - Version 1.0.0 - -
+ + {/* 加载指示器 */} + + +
); @@ -156,17 +137,6 @@ const styles = StyleSheet.create({ textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 20, }, - primaryButton: { - height: 56, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - shadowColor: '#D4AF37', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.4, - shadowRadius: 12, - elevation: 8, - }, }); export default Pro; diff --git a/MeAgent/src/hooks/useCommunitySocket.js b/MeAgent/src/hooks/useCommunitySocket.js new file mode 100644 index 00000000..b9214555 --- /dev/null +++ b/MeAgent/src/hooks/useCommunitySocket.js @@ -0,0 +1,301 @@ +/** + * 社区 WebSocket Hook + * 管理社区实时通信连接 + * 与 Web 端保持一致的事件名称和行为 + */ + +import { useEffect, useRef, useCallback, useState, useContext } from 'react'; +import { useDispatch } from 'react-redux'; +import { API_BASE_URL } from '../services/api'; +import AuthContext from '../contexts/AuthContext'; +import { + addMessage, + removeMessage, + updateMessageReactions, + addTypingUser, + removeTypingUser, + incrementUnreadCount, +} from '../store/slices/communitySlice'; + +// 服务器事件类型 - 与 Web 端保持一致(大写) +export const SERVER_EVENTS = { + MESSAGE_CREATE: 'MESSAGE_CREATE', + MESSAGE_UPDATE: 'MESSAGE_UPDATE', + MESSAGE_DELETE: 'MESSAGE_DELETE', + REACTION_ADD: 'REACTION_ADD', + REACTION_REMOVE: 'REACTION_REMOVE', + TYPING_START: 'TYPING_START', + TYPING_STOP: 'TYPING_STOP', + MEMBER_JOIN: 'MEMBER_JOIN', + MEMBER_LEAVE: 'MEMBER_LEAVE', + MEMBER_ONLINE: 'MEMBER_ONLINE', + MEMBER_OFFLINE: 'MEMBER_OFFLINE', + CHANNEL_UPDATE: 'CHANNEL_UPDATE', +}; + +// 客户端事件类型 - 与 Web 端保持一致(大写) +export const CLIENT_EVENTS = { + SUBSCRIBE_CHANNEL: 'SUBSCRIBE_CHANNEL', + UNSUBSCRIBE_CHANNEL: 'UNSUBSCRIBE_CHANNEL', + SEND_MESSAGE: 'SEND_MESSAGE', + START_TYPING: 'START_TYPING', + STOP_TYPING: 'STOP_TYPING', +}; + +/** + * 社区 WebSocket Hook + * @returns {object} WebSocket 操作方法 + */ +export const useCommunitySocket = () => { + const dispatch = useDispatch(); + // 安全获取 auth context,避免在 AuthProvider 外部使用时报错 + const authContext = useContext(AuthContext); + const isLoggedIn = authContext?.isLoggedIn ?? false; + const socketRef = useRef(null); + const [isConnected, setIsConnected] = useState(false); + const [connectionError, setConnectionError] = useState(null); + const subscribedChannelsRef = useRef(new Set()); + const typingTimeoutRef = useRef(null); + const eventHandlersRef = useRef(new Map()); + + // 初始化 Socket 连接 - 只有登录后才连接 + useEffect(() => { + if (!isLoggedIn) { + // 未登录时断开连接 + if (socketRef.current) { + socketRef.current.disconnect(); + socketRef.current = null; + setIsConnected(false); + } + return; + } + + // 动态导入 socket.io-client + const initSocket = async () => { + try { + const { io } = await import('socket.io-client'); + const socketUrl = API_BASE_URL; + + const socket = io(`${socketUrl}/community`, { + transports: ['websocket', 'polling'], + autoConnect: true, + reconnection: true, + reconnectionAttempts: 3, + reconnectionDelay: 2000, + reconnectionDelayMax: 10000, + timeout: 10000, + }); + + // 连接成功 + socket.on('connect', () => { + console.log('[CommunitySocket] 连接成功'); + setIsConnected(true); + setConnectionError(null); + + // 重新订阅之前的频道 + subscribedChannelsRef.current.forEach((channelId) => { + socket.emit(CLIENT_EVENTS.SUBSCRIBE_CHANNEL, { channelId }); + }); + }); + + // 连接断开 + socket.on('disconnect', (reason) => { + console.log('[CommunitySocket] 连接断开:', reason); + setIsConnected(false); + }); + + // 连接错误 - 只是警告,不阻断页面 + socket.on('connect_error', (error) => { + console.warn('[CommunitySocket] WebSocket 暂不可用,使用离线模式'); + setConnectionError('WebSocket 暂不可用'); + }); + + // 监听服务器事件 + const serverEvents = [ + SERVER_EVENTS.MESSAGE_CREATE, + SERVER_EVENTS.MESSAGE_UPDATE, + SERVER_EVENTS.MESSAGE_DELETE, + SERVER_EVENTS.REACTION_ADD, + SERVER_EVENTS.REACTION_REMOVE, + SERVER_EVENTS.TYPING_START, + SERVER_EVENTS.TYPING_STOP, + SERVER_EVENTS.MEMBER_JOIN, + SERVER_EVENTS.MEMBER_LEAVE, + SERVER_EVENTS.MEMBER_ONLINE, + SERVER_EVENTS.MEMBER_OFFLINE, + SERVER_EVENTS.CHANNEL_UPDATE, + ]; + + serverEvents.forEach((event) => { + socket.on(event, (data) => { + // 调用自定义事件处理器 + const listeners = eventHandlersRef.current.get(event); + if (listeners) { + listeners.forEach((callback) => callback(data)); + } + + // 内置的 Redux dispatch 处理 + switch (event) { + case SERVER_EVENTS.MESSAGE_CREATE: + dispatch( + addMessage({ + channelId: data.channelId, + message: data.message, + }) + ); + break; + case SERVER_EVENTS.MESSAGE_DELETE: + dispatch( + removeMessage({ + channelId: data.channelId, + messageId: data.messageId, + }) + ); + break; + case SERVER_EVENTS.REACTION_ADD: + case SERVER_EVENTS.REACTION_REMOVE: + dispatch( + updateMessageReactions({ + channelId: data.channelId, + messageId: data.messageId, + reactions: data.reactions, + }) + ); + break; + case SERVER_EVENTS.TYPING_START: + dispatch( + addTypingUser({ + channelId: data.channelId, + user: { userId: data.userId, username: data.userName || data.username }, + }) + ); + break; + case SERVER_EVENTS.TYPING_STOP: + dispatch( + removeTypingUser({ + channelId: data.channelId, + userId: data.userId, + }) + ); + break; + default: + break; + } + }); + }); + + socketRef.current = socket; + } catch (error) { + console.warn('[CommunitySocket] Socket.IO 初始化失败,使用离线模式'); + setConnectionError('初始化失败'); + } + }; + + initSocket(); + + return () => { + if (socketRef.current) { + socketRef.current.disconnect(); + socketRef.current = null; + } + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + }; + }, [isLoggedIn, dispatch]); + + // 订阅频道 + const subscribe = useCallback((channelId) => { + if (!channelId) return; + + subscribedChannelsRef.current.add(channelId); + + if (socketRef.current?.connected) { + socketRef.current.emit(CLIENT_EVENTS.SUBSCRIBE_CHANNEL, { channelId }); + console.log('[CommunitySocket] 已订阅频道:', channelId); + } + }, []); + + // 取消订阅频道 + const unsubscribe = useCallback((channelId) => { + if (!channelId) return; + + subscribedChannelsRef.current.delete(channelId); + + if (socketRef.current?.connected) { + socketRef.current.emit(CLIENT_EVENTS.UNSUBSCRIBE_CHANNEL, { channelId }); + console.log('[CommunitySocket] 已取消订阅频道:', channelId); + } + }, []); + + // 发送消息 + const sendMessage = useCallback((channelId, content, options = {}) => { + if (!socketRef.current?.connected) { + return false; + } + + socketRef.current.emit(CLIENT_EVENTS.SEND_MESSAGE, { + channelId, + content, + ...options, + }); + + return true; + }, []); + + // 开始输入 + const startTyping = useCallback((channelId) => { + if (!socketRef.current?.connected || !channelId) return; + + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + + socketRef.current.emit(CLIENT_EVENTS.START_TYPING, { channelId }); + + typingTimeoutRef.current = setTimeout(() => { + stopTyping(channelId); + }, 3000); + }, []); + + // 停止输入 + const stopTyping = useCallback((channelId) => { + if (!socketRef.current?.connected || !channelId) return; + + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + typingTimeoutRef.current = null; + } + + socketRef.current.emit(CLIENT_EVENTS.STOP_TYPING, { channelId }); + }, []); + + // 注册事件监听 + const onMessage = useCallback((event, callback) => { + if (!eventHandlersRef.current.has(event)) { + eventHandlersRef.current.set(event, new Set()); + } + eventHandlersRef.current.get(event).add(callback); + }, []); + + // 移除事件监听 + const offMessage = useCallback((event, callback) => { + if (eventHandlersRef.current.has(event)) { + eventHandlersRef.current.get(event).delete(callback); + } + }, []); + + return { + isConnected, + connectionError, + subscribe, + unsubscribe, + sendMessage, + startTyping, + stopTyping, + onMessage, + offMessage, + }; +}; + +export default useCommunitySocket; diff --git a/MeAgent/src/screens/Community/ChannelDetail.js b/MeAgent/src/screens/Community/ChannelDetail.js new file mode 100644 index 00000000..110e7c83 --- /dev/null +++ b/MeAgent/src/screens/Community/ChannelDetail.js @@ -0,0 +1,585 @@ +/** + * 频道详情页面 + * 显示消息列表和输入框 + */ + +import React, { useEffect, useState, useCallback, useRef } from 'react'; +import { + FlatList, + KeyboardAvoidingView, + Platform, + StyleSheet, + Keyboard, + TextInput, + Image, + Dimensions, +} from 'react-native'; +import { + Box, + VStack, + HStack, + Text, + Icon, + Pressable, + Spinner, + Center, + Avatar, +} from 'native-base'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useDispatch, useSelector } from 'react-redux'; + +import { + fetchMessages, + sendMessage, + addMessage, +} from '../../store/slices/communitySlice'; +import { useCommunitySocket } from '../../hooks/useCommunitySocket'; + +// 消息分组:按日期 +const groupMessagesByDate = (messages) => { + const groups = []; + let currentDate = null; + let currentGroup = null; + + messages.forEach((message) => { + const messageDate = new Date(message.createdAt).toDateString(); + + if (messageDate !== currentDate) { + currentDate = messageDate; + currentGroup = { + date: message.createdAt, + messages: [message], + }; + groups.push(currentGroup); + } else { + currentGroup.messages.push(message); + } + }); + + return groups; +}; + +// 格式化日期分隔符 +const formatDateDivider = (dateStr) => { + const date = new Date(dateStr); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + if (date.toDateString() === today.toDateString()) { + return '今天'; + } + if (date.toDateString() === yesterday.toDateString()) { + return '昨天'; + } + + const month = date.getMonth() + 1; + const day = date.getDate(); + const year = date.getFullYear(); + + if (year === today.getFullYear()) { + return `${month}月${day}日`; + } + return `${year}年${month}月${day}日`; +}; + +// 格式化时间 +const formatTime = (dateStr) => { + const date = new Date(dateStr); + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + return `${hours}:${minutes}`; +}; + +// 解析消息内容,提取文本和图片 +const parseMessageContent = (content) => { + if (!content) return { text: '', images: [] }; + + const images = []; + let text = content; + + // 1. 匹配 Markdown 图片格式: ![alt](data:image/xxx;base64,xxx) 或 ![alt](url) + const markdownImgRegex = /!\[([^\]]*)\]\((data:image\/[a-zA-Z]+;base64,[A-Za-z0-9+/=]+|https?:\/\/[^\s)]+)\)/g; + let mdMatch; + while ((mdMatch = markdownImgRegex.exec(content)) !== null) { + if (!images.includes(mdMatch[2])) { + images.push(mdMatch[2]); + } + text = text.replace(mdMatch[0], '').trim(); + } + + // 2. 匹配直接的 base64 图片格式: data:image/xxx;base64,xxxxx + const base64Regex = /(data:image\/[a-zA-Z]+;base64,[A-Za-z0-9+/=]+)/g; + const matches = text.match(base64Regex); + + if (matches) { + matches.forEach((match) => { + if (!images.includes(match)) { + images.push(match); + } + text = text.replace(match, '').trim(); + }); + } + + // 3. 匹配 [图片] 标记后跟 base64 的情况 + const imgTagRegex = /\[图片\]\s*(data:image\/[a-zA-Z]+;base64,[A-Za-z0-9+/=]+)/g; + let imgMatch; + while ((imgMatch = imgTagRegex.exec(content)) !== null) { + if (!images.includes(imgMatch[1])) { + images.push(imgMatch[1]); + } + text = text.replace(imgMatch[0], '').trim(); + } + + // 移除多余的 [图片] 标记 + text = text.replace(/\[图片\]/g, '').trim(); + + return { text, images }; +}; + +// 获取屏幕宽度用于图片尺寸计算 +const screenWidth = Dimensions.get('window').width; + +// 判断是否显示头像(同用户5分钟内的消息合并) +const shouldShowAvatar = (message, index, messages) => { + if (index === 0) return true; + + const prevMessage = messages[index - 1]; + if (prevMessage.authorId !== message.authorId) return true; + + const timeDiff = new Date(message.createdAt) - new Date(prevMessage.createdAt); + return timeDiff > 5 * 60 * 1000; // 5分钟 +}; + +const ChannelDetail = ({ route, navigation }) => { + const { channel } = route.params; + const dispatch = useDispatch(); + const insets = useSafeAreaInsets(); + const flatListRef = useRef(null); + + const communityState = useSelector((state) => state.community); + const messages = communityState?.messages || {}; + const messagesHasMore = communityState?.messagesHasMore || {}; + const loading = communityState?.loading || {}; + const typingUsers = communityState?.typingUsers || {}; + + const channelMessages = messages[channel?.id] || []; + const hasMore = messagesHasMore[channel?.id] ?? true; + const channelTypingUsers = typingUsers[channel?.id] || []; + + const [inputText, setInputText] = useState(''); + const [isSending, setIsSending] = useState(false); + + // WebSocket 连接 + const { + isConnected, + subscribe, + unsubscribe, + startTyping, + stopTyping, + } = useCommunitySocket(); + + // 订阅频道 WebSocket + useEffect(() => { + if (isConnected && channel?.id) { + subscribe(channel.id); + } + + return () => { + if (channel?.id) { + unsubscribe(channel.id); + stopTyping(channel.id); + } + }; + }, [isConnected, channel?.id, subscribe, unsubscribe, stopTyping]); + + // 监听新消息滚动到底部 + useEffect(() => { + if (channelMessages.length > 0) { + setTimeout(() => { + flatListRef.current?.scrollToEnd({ animated: true }); + }, 100); + } + }, [channelMessages.length]); + + // 输入时发送输入状态 + const handleTextChange = useCallback((text) => { + setInputText(text); + if (text.trim() && isConnected && channel?.id) { + startTyping(channel.id); + } + }, [isConnected, channel?.id, startTyping]); + + // 设置导航标题 + useEffect(() => { + navigation.setOptions({ + headerShown: true, + headerTitle: channel?.name || '频道', + headerStyle: { + backgroundColor: '#0F172A', + elevation: 0, + shadowOpacity: 0, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255, 255, 255, 0.1)', + }, + headerTintColor: '#FFFFFF', + headerTitleStyle: { + fontWeight: '600', + fontSize: 16, + }, + headerRight: () => ( + + navigation.navigate('MemberList', { channel })}> + + + {}}> + + + + ), + }); + }, [navigation, channel]); + + // 加载消息 + useEffect(() => { + if (channel?.id) { + dispatch(fetchMessages({ channelId: channel.id })); + } + }, [dispatch, channel?.id]); + + // 加载更多消息 + const handleLoadMore = useCallback(() => { + if (loading.messages || !hasMore || channelMessages.length === 0 || !channel?.id) return; + + const oldestMessage = channelMessages[0]; + dispatch( + fetchMessages({ + channelId: channel.id, + options: { before: oldestMessage.createdAt }, + }) + ); + }, [dispatch, channel?.id, channelMessages, loading.messages, hasMore]); + + // 发送消息 + const handleSend = useCallback(async () => { + const content = inputText.trim(); + if (!content || isSending || !channel?.id) return; + + setIsSending(true); + Keyboard.dismiss(); + + // 停止输入状态 + stopTyping(channel.id); + + try { + await dispatch( + sendMessage({ + channelId: channel.id, + data: { content }, + }) + ).unwrap(); + + setInputText(''); + // 滚动到底部 + setTimeout(() => { + flatListRef.current?.scrollToEnd({ animated: true }); + }, 100); + } catch (error) { + console.error('发送消息失败:', error); + } finally { + setIsSending(false); + } + }, [dispatch, channel?.id, inputText, isSending, stopTyping]); + + // 渲染日期分隔符 + const renderDateDivider = (date) => ( + + + + {formatDateDivider(date)} + + + + ); + + // 渲染单条消息 + const renderMessage = ({ item: message, index }) => { + const showAvatar = shouldShowAvatar(message, index, channelMessages); + const { text, images } = parseMessageContent(message.content); + + return ( + + {/* 头像区域 */} + + {showAvatar ? ( + + {message.authorName?.[0]?.toUpperCase() || '?'} + + ) : ( + + {formatTime(message.createdAt)} + + )} + + + {/* 消息内容 */} + + {showAvatar ? ( + + + {message.authorName || '匿名用户'} + + + {formatTime(message.createdAt)} + + + ) : null} + + {/* 文本内容 */} + {text ? ( + + {text} + + ) : null} + + {/* Base64 图片 */} + {images.length > 0 ? ( + + {images.map((imgUri, imgIndex) => ( + { + // TODO: 可以添加图片预览功能 + }} + > + + + ))} + + ) : null} + + {/* 表情反应 */} + {message.reactions && Object.keys(message.reactions).length > 0 ? ( + + {Object.entries(message.reactions).map(([emoji, users]) => ( + + + {emoji} + + {users.length} + + + + ))} + + ) : null} + + + ); + }; + + // 渲染消息列表 + const renderMessageList = () => { + if (loading.messages && channelMessages.length === 0) { + return ( +
+ + + 加载消息... + +
+ ); + } + + if (channelMessages.length === 0) { + return ( +
+ + + 还没有消息 + + + 成为第一个发言的人吧! + +
+ ); + } + + return ( + item.id} + inverted={false} + onEndReached={handleLoadMore} + onEndReachedThreshold={0.1} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.messageList} + ListHeaderComponent={ + hasMore && loading.messages ? ( +
+ +
+ ) : null + } + /> + ); + }; + + // 渲染输入中提示 + const renderTypingIndicator = () => { + if (channelTypingUsers.length === 0) return null; + + const names = channelTypingUsers.map((u) => u.username).join('、'); + return ( + + + + + + + + {names} 正在输入... + + + ); + }; + + // 渲染输入框 + const renderInput = () => ( + + {renderTypingIndicator()} + + {/* 附件按钮 */} + + + + + {/* 输入框 */} + + + + + {/* 发送按钮 */} + + {isSending ? ( + + ) : ( + + )} + + + + ); + + return ( + + + {/* 频道话题 */} + {channel?.topic ? ( + + + + + {channel.topic} + + + + ) : null} + + {/* 消息列表 */} + {renderMessageList()} + + {/* 输入框 */} + {renderInput()} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0F172A', + }, + messageList: { + paddingVertical: 8, + }, + textInput: { + flex: 1, + color: '#FFFFFF', + fontSize: 14, + maxHeight: 100, + minHeight: 36, + paddingVertical: Platform.OS === 'ios' ? 8 : 10, + paddingHorizontal: 0, + }, +}); + +export default ChannelDetail; diff --git a/MeAgent/src/screens/Community/ChannelList.js b/MeAgent/src/screens/Community/ChannelList.js new file mode 100644 index 00000000..62a4c389 --- /dev/null +++ b/MeAgent/src/screens/Community/ChannelList.js @@ -0,0 +1,371 @@ +/** + * 频道列表页面 + * Discord 风格的频道分类展示 + */ + +import React, { useEffect, useState, useCallback } from 'react'; +import { + SectionList, + RefreshControl, + StyleSheet, + Pressable as RNPressable, +} from 'react-native'; +import { + Box, + VStack, + HStack, + Text, + Icon, + Input, + Pressable, + Spinner, + Center, +} from 'native-base'; +import { Ionicons } from '@expo/vector-icons'; +import { useDispatch, useSelector } from 'react-redux'; + +import { fetchChannels, setCurrentChannel } from '../../store/slices/communitySlice'; +import { CHANNEL_TYPES } from '../../services/communityService'; + +// 频道图标映射 +const CHANNEL_ICONS = { + [CHANNEL_TYPES.TEXT]: 'chatbubble', + [CHANNEL_TYPES.FORUM]: 'document-text', + [CHANNEL_TYPES.ANNOUNCEMENT]: 'megaphone', +}; + +// 分类图标映射 +const CATEGORY_ICONS = { + general: 'chatbubbles', + hot: 'flame', + announcement: 'megaphone', +}; + +const ChannelList = ({ navigation }) => { + const dispatch = useDispatch(); + const communityState = useSelector((state) => state.community); + const categories = communityState?.categories || []; + const loading = communityState?.loading || {}; + + const [searchText, setSearchText] = useState(''); + const [collapsedCategories, setCollapsedCategories] = useState(new Set()); + const [refreshing, setRefreshing] = useState(false); + + // 加载频道列表 + useEffect(() => { + dispatch(fetchChannels()); + }, [dispatch]); + + // 下拉刷新 + const handleRefresh = useCallback(async () => { + setRefreshing(true); + await dispatch(fetchChannels()); + setRefreshing(false); + }, [dispatch]); + + // 切换分类折叠状态 + const toggleCategory = useCallback((categoryId) => { + setCollapsedCategories((prev) => { + const newSet = new Set(prev); + if (newSet.has(categoryId)) { + newSet.delete(categoryId); + } else { + newSet.add(categoryId); + } + return newSet; + }); + }, []); + + // 点击频道 + const handleChannelPress = useCallback((channel) => { + dispatch(setCurrentChannel(channel)); + + // 根据频道类型导航到不同页面 + if (channel.type === CHANNEL_TYPES.FORUM) { + navigation.navigate('ForumChannel', { channel }); + } else { + navigation.navigate('ChannelDetail', { channel }); + } + }, [dispatch, navigation]); + + // 过滤频道(安全处理 undefined) + const filteredCategories = (categories || []).map((category) => ({ + ...category, + data: collapsedCategories.has(category.id) + ? [] + : (category.channels || []).filter((channel) => + channel.name?.toLowerCase().includes(searchText.toLowerCase()) + ), + })); + + // 渲染搜索栏 + const renderHeader = () => ( + + + } + _focus={{ + borderColor: 'primary.500', + bg: 'rgba(255, 255, 255, 0.08)', + }} + /> + + ); + + // 渲染分类标题 + const renderSectionHeader = ({ section }) => { + if (!section) return null; + + return ( + section.id && toggleCategory(section.id)}> + + + + + {section.name || '未命名分类'} + + + + + + ); + }; + + // 渲染频道项 + const renderChannelItem = ({ item: channel }) => { + if (!channel) return null; + + const unreadCount = channel.unreadCount || 0; + const channelName = channel.name || '未命名频道'; + const channelType = channel.type || CHANNEL_TYPES.TEXT; + + return ( + handleChannelPress(channel)}> + {({ isPressed }) => ( + + {/* 频道图标 */} + 0 ? 'rgba(124, 58, 237, 0.2)' : 'rgba(255, 255, 255, 0.05)'} + > + 0 ? '#A78BFA' : 'gray.500'} + /> + + + {/* 频道信息 */} + + + 0 ? 'bold' : 'medium'} + color={unreadCount > 0 ? 'white' : 'gray.300'} + numberOfLines={1} + > + {channelName} + + {channelType === CHANNEL_TYPES.ANNOUNCEMENT && ( + + )} + + {channel.topic ? ( + + {channel.topic} + + ) : null} + + + {/* 未读标记 */} + {unreadCount > 0 ? ( + + + {unreadCount > 99 ? '99+' : String(unreadCount)} + + + ) : null} + + {/* 成员数 */} + {!unreadCount && channel.subscriberCount ? ( + + + + {formatNumber(channel.subscriberCount)} + + + ) : null} + + )} + + ); + }; + + // 渲染空状态 + const renderEmpty = () => { + if (loading.channels) { + return ( +
+ + + 加载频道列表... + +
+ ); + } + + if (searchText) { + return ( +
+ + + 未找到匹配的频道 + + + 尝试其他搜索词 + +
+ ); + } + + return ( +
+ + + 暂无频道 + +
+ ); + }; + + // 渲染底部 + const renderFooter = () => ( + + { + navigation.navigate('CreateChannel'); + }} + > + + + + 创建频道 + + + + + ); + + return ( + + item?.id || `item-${index}`} + renderItem={renderChannelItem} + renderSectionHeader={renderSectionHeader} + ListHeaderComponent={renderHeader} + ListEmptyComponent={renderEmpty} + ListFooterComponent={renderFooter} + stickySectionHeadersEnabled={false} + refreshControl={ + + } + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.listContent} + /> + + ); +}; + +// 格式化数字 +const formatNumber = (num) => { + if (num == null || isNaN(num)) return '0'; + if (num >= 10000) { + return (num / 10000).toFixed(1) + 'w'; + } + if (num >= 1000) { + return (num / 1000).toFixed(1) + 'k'; + } + return String(num); +}; + +const styles = StyleSheet.create({ + listContent: { + flexGrow: 1, + }, +}); + +export default ChannelList; diff --git a/MeAgent/src/screens/Community/CreateChannel.js b/MeAgent/src/screens/Community/CreateChannel.js new file mode 100644 index 00000000..44e25adc --- /dev/null +++ b/MeAgent/src/screens/Community/CreateChannel.js @@ -0,0 +1,378 @@ +/** + * 创建频道页面 + */ + +import React, { useState, useCallback, useEffect } from 'react'; +import { + ScrollView, + KeyboardAvoidingView, + Platform, + StyleSheet, + Keyboard, + Alert, + TextInput, +} from 'react-native'; +import { + Box, + VStack, + HStack, + Text, + Icon, + Pressable, + Spinner, + Center, +} from 'native-base'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useDispatch, useSelector } from 'react-redux'; + +import { channelService, CHANNEL_TYPES } from '../../services/communityService'; +import { fetchChannels } from '../../store/slices/communitySlice'; + +// 频道类型选项 +const CHANNEL_TYPE_OPTIONS = [ + { + value: CHANNEL_TYPES.TEXT, + label: '文字频道', + icon: 'chatbubble', + description: '实时消息聊天', + }, + { + value: CHANNEL_TYPES.FORUM, + label: '论坛频道', + icon: 'document-text', + description: '发帖讨论,支持长文', + }, +]; + +const CreateChannel = ({ navigation }) => { + const dispatch = useDispatch(); + const insets = useSafeAreaInsets(); + + // 从 Redux 获取真实的 categories 列表 + const communityState = useSelector((state) => state.community); + const categories = communityState?.categories || []; + const loadingCategories = communityState?.loading?.channels || false; + + const [name, setName] = useState(''); + const [topic, setTopic] = useState(''); + const [channelType, setChannelType] = useState(CHANNEL_TYPES.TEXT); + const [category, setCategory] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + // 加载 categories(如果还没有加载) + useEffect(() => { + if (categories.length === 0) { + dispatch(fetchChannels()); + } else if (!category && categories.length > 0) { + // 设置默认选中第一个分类 + setCategory(categories[0].id); + } + }, [dispatch, categories, category]); + + const NAME_MAX = 50; + const TOPIC_MAX = 200; + + // 验证表单 + const validateForm = useCallback(() => { + if (!name.trim()) { + Alert.alert('提示', '请输入频道名称'); + return false; + } + if (name.length > NAME_MAX) { + Alert.alert('提示', `频道名称不能超过${NAME_MAX}个字符`); + return false; + } + if (!category) { + Alert.alert('提示', '请选择所属分类'); + return false; + } + if (topic.length > TOPIC_MAX) { + Alert.alert('提示', `频道话题不能超过${TOPIC_MAX}个字符`); + return false; + } + return true; + }, [name, topic, category]); + + // 创建频道 + const handleSubmit = useCallback(async () => { + if (!validateForm() || isSubmitting) return; + + setIsSubmitting(true); + Keyboard.dismiss(); + + try { + const newChannel = await channelService.createChannel({ + name: name.trim(), + type: channelType, + topic: topic.trim() || undefined, + categoryId: category, + }); + + // 刷新频道列表 + dispatch(fetchChannels()); + + Alert.alert('创建成功', `已创建频道 #${newChannel.name || name}`, [ + { + text: '进入频道', + onPress: () => { + navigation.goBack(); + // 如果需要直接进入新频道,可以导航到对应页面 + if (newChannel) { + if (channelType === CHANNEL_TYPES.FORUM) { + navigation.navigate('ForumChannel', { channel: newChannel }); + } else { + navigation.navigate('ChannelDetail', { channel: newChannel }); + } + } + }, + }, + { + text: '返回列表', + onPress: () => navigation.goBack(), + }, + ]); + } catch (error) { + Alert.alert('创建失败', error.message || '请稍后重试'); + } finally { + setIsSubmitting(false); + } + }, [dispatch, name, topic, channelType, category, validateForm, isSubmitting, navigation]); + + // 设置导航 + React.useEffect(() => { + navigation.setOptions({ + headerShown: true, + headerTitle: '创建频道', + headerStyle: { + backgroundColor: '#0F172A', + elevation: 0, + shadowOpacity: 0, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255, 255, 255, 0.1)', + }, + headerTintColor: '#FFFFFF', + headerTitleStyle: { + fontWeight: '600', + fontSize: 16, + }, + headerRight: () => ( + + {isSubmitting ? ( + + ) : ( + + 创建 + + )} + + ), + }); + }, [navigation, handleSubmit, isSubmitting, name]); + + return ( + + + {/* 频道名称 */} + + + + 频道名称 * + + NAME_MAX ? 'red.500' : 'gray.500'}> + {name.length}/{NAME_MAX} + + + + + + + + {/* 频道类型 */} + + + 频道类型 + + + {CHANNEL_TYPE_OPTIONS.map((option) => ( + setChannelType(option.value)} + > + + + + + {option.label} + + + {option.description} + + + + + ))} + + + + {/* 所属分类 */} + + + 所属分类 + + {loadingCategories ? ( +
+ + 加载分类... +
+ ) : categories.length === 0 ? ( + 暂无可用分类 + ) : ( + + {categories.map((cat) => ( + setCategory(cat.id)} + mb={2} + > + + + {cat.name || cat.id} + + + + ))} + + )} +
+ + {/* 频道话题 */} + + + + 频道话题(可选) + + TOPIC_MAX ? 'red.500' : 'gray.500'}> + {topic.length}/{TOPIC_MAX} + + + + + + + + {/* 提示 */} + + + + + + 创建须知 + + + {`• 频道名称应与讨论主题相关\n• 创建后您将成为频道管理员\n• 请遵守社区规范,文明交流`} + + + + +
+
+ ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0F172A', + }, + content: { + flexGrow: 1, + }, + textInput: { + color: '#FFFFFF', + fontSize: 14, + minHeight: 40, + paddingVertical: Platform.OS === 'ios' ? 0 : 12, + }, + textArea: { + minHeight: 80, + paddingTop: Platform.OS === 'ios' ? 0 : 12, + }, +}); + +export default CreateChannel; diff --git a/MeAgent/src/screens/Community/CreatePost.js b/MeAgent/src/screens/Community/CreatePost.js new file mode 100644 index 00000000..e4f4db30 --- /dev/null +++ b/MeAgent/src/screens/Community/CreatePost.js @@ -0,0 +1,396 @@ +/** + * 发帖页面 + * 创建新的论坛帖子 + */ + +import React, { useState, useCallback } from 'react'; +import { + ScrollView, + KeyboardAvoidingView, + Platform, + StyleSheet, + Keyboard, + Alert, +} from 'react-native'; +import { + Box, + VStack, + HStack, + Text, + Icon, + Pressable, + Input, + TextArea, + Spinner, +} from 'native-base'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useDispatch } from 'react-redux'; + +import { createPost } from '../../store/slices/communitySlice'; +import { gradients } from '../../theme'; + +// 预设标签 +const PRESET_TAGS = [ + '技术分析', '基本面', '短线', '中长线', '新手', + '价值投资', '趋势', '消息面', '情绪', '复盘', +]; + +const CreatePost = ({ route, navigation }) => { + const { channel } = route.params; + const dispatch = useDispatch(); + const insets = useSafeAreaInsets(); + + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [tags, setTags] = useState([]); + const [customTag, setCustomTag] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const TITLE_MAX = 100; + const CONTENT_MAX = 5000; + const TAGS_MAX = 5; + + // 添加标签 + const handleAddTag = useCallback((tag) => { + if (tags.length >= TAGS_MAX) { + Alert.alert('提示', `最多添加${TAGS_MAX}个标签`); + return; + } + if (tags.includes(tag)) { + return; + } + setTags([...tags, tag]); + }, [tags]); + + // 移除标签 + const handleRemoveTag = useCallback((tag) => { + setTags(tags.filter((t) => t !== tag)); + }, [tags]); + + // 添加自定义标签 + const handleAddCustomTag = useCallback(() => { + const tag = customTag.trim(); + if (!tag) return; + if (tag.length > 20) { + Alert.alert('提示', '标签长度不能超过20个字符'); + return; + } + handleAddTag(tag); + setCustomTag(''); + }, [customTag, handleAddTag]); + + // 验证表单 + const validateForm = useCallback(() => { + if (!title.trim()) { + Alert.alert('提示', '请输入标题'); + return false; + } + if (title.length > TITLE_MAX) { + Alert.alert('提示', `标题不能超过${TITLE_MAX}个字符`); + return false; + } + if (!content.trim()) { + Alert.alert('提示', '请输入内容'); + return false; + } + if (content.length > CONTENT_MAX) { + Alert.alert('提示', `内容不能超过${CONTENT_MAX}个字符`); + return false; + } + return true; + }, [title, content]); + + // 提交帖子 + const handleSubmit = useCallback(async () => { + if (!validateForm() || isSubmitting) return; + + setIsSubmitting(true); + Keyboard.dismiss(); + + try { + await dispatch( + createPost({ + channelId: channel.id, + data: { + title: title.trim(), + content: content.trim(), + tags, + }, + }) + ).unwrap(); + + Alert.alert('成功', '帖子发布成功', [ + { + text: '确定', + onPress: () => navigation.goBack(), + }, + ]); + } catch (error) { + Alert.alert('发布失败', error.message || '请稍后重试'); + } finally { + setIsSubmitting(false); + } + }, [dispatch, channel.id, title, content, tags, validateForm, isSubmitting, navigation]); + + // 设置导航 + React.useEffect(() => { + navigation.setOptions({ + headerShown: true, + headerTitle: '发布帖子', + headerStyle: { + backgroundColor: '#0F172A', + elevation: 0, + shadowOpacity: 0, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255, 255, 255, 0.1)', + }, + headerTintColor: '#FFFFFF', + headerTitleStyle: { + fontWeight: '600', + fontSize: 16, + }, + headerRight: () => ( + + {isSubmitting ? ( + + ) : ( + + 发布 + + )} + + ), + }); + }, [navigation, handleSubmit, isSubmitting, title, content]); + + return ( + + + {/* 频道信息 */} + + + + + 发布到: {channel.name} + + + + + {/* 标题输入 */} + + + + 标题 + + TITLE_MAX ? 'red.500' : 'gray.500'}> + {title.length}/{TITLE_MAX} + + + + + + {/* 内容输入 */} + + + + 内容 + + CONTENT_MAX ? 'red.500' : 'gray.500'}> + {content.length}/{CONTENT_MAX} + + +