更新ios

This commit is contained in:
2026-01-17 18:27:32 +08:00
parent 4840557c8b
commit 5ed4eaf048
27 changed files with 5292 additions and 241 deletions

View File

@@ -166,6 +166,7 @@ function CustomDrawerContent({
{ title: "市场热点", navigateTo: "MarketDrawer", icon: "flame", gradient: ["#F59E0B", "#FBBF24"] }, { title: "市场热点", navigateTo: "MarketDrawer", icon: "flame", gradient: ["#F59E0B", "#FBBF24"] },
{ title: "概念中心", navigateTo: "ConceptsDrawer", icon: "bulb", gradient: ["#06B6D4", "#22D3EE"] }, { title: "概念中心", navigateTo: "ConceptsDrawer", icon: "bulb", gradient: ["#06B6D4", "#22D3EE"] },
{ title: "我的自选", navigateTo: "WatchlistDrawer", icon: "star", gradient: ["#EC4899", "#F472B6"] }, { title: "我的自选", navigateTo: "WatchlistDrawer", icon: "star", gradient: ["#EC4899", "#F472B6"] },
{ title: "社区论坛", navigateTo: "CommunityDrawer", icon: "chatbubbles", gradient: ["#10B981", "#34D399"] },
{ title: "个人中心", navigateTo: "ProfileDrawerNew", icon: "person", gradient: ["#8B5CF6", "#A78BFA"] }, { title: "个人中心", navigateTo: "ProfileDrawerNew", icon: "person", gradient: ["#8B5CF6", "#A78BFA"] },
]; ];
return ( return (

View File

@@ -50,6 +50,15 @@ import WatchlistScreen from "../src/screens/Watchlist/WatchlistScreen";
// 新股票详情页面 // 新股票详情页面
import { StockDetailScreen } from "../src/screens/StockDetail"; 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"; import { ProfileScreen as NewProfileScreen } from "../src/screens/Profile";
@@ -398,6 +407,68 @@ function WatchlistStack(props) {
); );
} }
// 社区导航栈
function CommunityStack(props) {
return (
<Stack.Navigator
screenOptions={{
mode: "card",
headerShown: false,
}}
>
<Stack.Screen
name="CommunityHome"
component={CommunityHome}
options={{
cardStyle: { backgroundColor: "#0F172A" },
}}
/>
<Stack.Screen
name="ChannelDetail"
component={ChannelDetail}
options={{
cardStyle: { backgroundColor: "#0F172A" },
}}
/>
<Stack.Screen
name="ForumChannel"
component={ForumChannel}
options={{
cardStyle: { backgroundColor: "#0F172A" },
}}
/>
<Stack.Screen
name="PostDetail"
component={PostDetail}
options={{
cardStyle: { backgroundColor: "#0F172A" },
}}
/>
<Stack.Screen
name="CreatePost"
component={CreatePost}
options={{
cardStyle: { backgroundColor: "#0F172A" },
}}
/>
<Stack.Screen
name="MemberList"
component={MemberList}
options={{
cardStyle: { backgroundColor: "#0F172A" },
}}
/>
<Stack.Screen
name="CreateChannel"
component={CreateChannel}
options={{
cardStyle: { backgroundColor: "#0F172A" },
}}
/>
</Stack.Navigator>
);
}
// 新个人中心导航栈 // 新个人中心导航栈
function NewProfileStack(props) { function NewProfileStack(props) {
return ( return (
@@ -705,6 +776,13 @@ function AppStack(props) {
headerShown: false, headerShown: false,
}} }}
/> />
<Drawer.Screen
name="CommunityDrawer"
component={CommunityStack}
options={{
headerShown: false,
}}
/>
<Drawer.Screen <Drawer.Screen
name="ProfileDrawerNew" name="ProfileDrawerNew"
component={NewProfileStack} component={NewProfileStack}

View File

@@ -46,9 +46,10 @@
"react-native-safe-area-context": "4.10.5", "react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1", "react-native-screens": "3.31.1",
"react-native-svg": "15.2.0", "react-native-svg": "15.2.0",
"react-native-wagmi-charts": "^2.3.0",
"react-native-webview": "13.8.6", "react-native-webview": "13.8.6",
"react-redux": "^9.1.0", "react-redux": "^9.1.0",
"react-native-wagmi-charts": "^2.3.0" "socket.io-client": "^4.8.3"
}, },
"devDependencies": { "devDependencies": {
"babel-preset-expo": "~11.0.0" "babel-preset-expo": "~11.0.0"

View File

@@ -1,33 +1,38 @@
/** /**
* 价值前沿 - 启动页 * 价值前沿 - 启动加载
* 黑金主题设计 * 黑金主题设计,自动跳转到主页
*/ */
import React from "react"; import React, { useEffect } from "react";
import { import {
View, View,
Image, Image,
StyleSheet, StyleSheet,
StatusBar, StatusBar,
Dimensions, Dimensions,
Platform, ActivityIndicator,
Pressable,
} from "react-native"; } from "react-native";
import { Box, VStack, HStack, Text, Center } from "native-base"; import { Box, VStack, Text, Center } from "native-base";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
const { height, width } = Dimensions.get("screen"); const { height, width } = Dimensions.get("screen");
// 金色渐变 // 金色主题
const GOLD_GRADIENT = ['#D4AF37', '#F5D77A', '#D4AF37'];
const GOLD_DARK = '#B8962E';
const GOLD_LIGHT = '#F5D77A';
const GOLD_PRIMARY = '#D4AF37'; const GOLD_PRIMARY = '#D4AF37';
const Pro = ({ navigation }) => { const Pro = ({ navigation }) => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
// 自动跳转到主页
useEffect(() => {
const timer = setTimeout(() => {
navigation.replace("App");
}, 1500); // 1.5秒后跳转
return () => clearTimeout(timer);
}, [navigation]);
return ( return (
<Box flex={1} bg="#000000"> <Box flex={1} bg="#000000">
<StatusBar barStyle="light-content" backgroundColor="#000000" /> <StatusBar barStyle="light-content" backgroundColor="#000000" />
@@ -51,12 +56,8 @@ const Pro = ({ navigation }) => {
</Box> </Box>
{/* 主内容 */} {/* 主内容 */}
<VStack flex={1} justifyContent="space-between" style={{ paddingTop: insets.top }}> <VStack flex={1} justifyContent="center" alignItems="center" style={{ paddingTop: insets.top }}>
{/* 上部空间 */}
<Box flex={1} />
{/* Logo 区域 */} {/* Logo 区域 */}
<Center flex={2}>
<Box <Box
bg="rgba(212, 175, 55, 0.08)" bg="rgba(212, 175, 55, 0.08)"
borderWidth={1} borderWidth={1}
@@ -90,49 +91,29 @@ const Pro = ({ navigation }) => {
VALUE FRONTIER VALUE FRONTIER
</Text> </Text>
</VStack> </VStack>
</Center>
{/* 特性描述 */} {/* 特性描述 */}
<Center flex={1} px={8}> <VStack alignItems="center" mt={10} space={1}>
<Text <Text
fontSize="md" fontSize="sm"
color="rgba(255, 255, 255, 0.6)" color="rgba(255, 255, 255, 0.5)"
textAlign="center"
>
智能投资决策平台
</Text>
<Text
fontSize="sm"
color="rgba(255, 255, 255, 0.5)"
textAlign="center" textAlign="center"
lineHeight="xl"
> >
智能投资决策平台{"\n"}
发现市场热点把握投资机会 发现市场热点把握投资机会
</Text> </Text>
</Center>
{/* 底部按钮区域 */}
<VStack space={4} px={6} style={{ paddingBottom: insets.bottom + 30 }}>
{/* 开始使用按钮 */}
<Pressable onPress={() => navigation.navigate("App")}>
{({ isPressed }) => (
<LinearGradient
colors={isPressed ? [GOLD_DARK, GOLD_PRIMARY] : GOLD_GRADIENT}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[
styles.primaryButton,
{ opacity: isPressed ? 0.9 : 1, transform: [{ scale: isPressed ? 0.98 : 1 }] }
]}
>
<Text fontSize="lg" fontWeight="bold" color="#000000">
开始使用
</Text>
</LinearGradient>
)}
</Pressable>
{/* 版本信息 */}
<Center>
<Text fontSize="xs" color="rgba(255, 255, 255, 0.3)">
Version 1.0.0
</Text>
</Center>
</VStack> </VStack>
{/* 加载指示器 */}
<Box mt={12}>
<ActivityIndicator size="small" color={GOLD_PRIMARY} />
</Box>
</VStack> </VStack>
</Box> </Box>
); );
@@ -156,17 +137,6 @@ const styles = StyleSheet.create({
textShadowOffset: { width: 0, height: 0 }, textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 20, 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; export default Pro;

View File

@@ -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;

View File

@@ -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: () => (
<HStack space={3} mr={4}>
<Pressable onPress={() => navigation.navigate('MemberList', { channel })}>
<Icon as={Ionicons} name="people-outline" size="md" color="gray.400" />
</Pressable>
<Pressable onPress={() => {}}>
<Icon as={Ionicons} name="ellipsis-vertical" size="md" color="gray.400" />
</Pressable>
</HStack>
),
});
}, [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) => (
<HStack alignItems="center" justifyContent="center" my={4}>
<Box flex={1} h="1px" bg="rgba(255, 255, 255, 0.1)" />
<Text fontSize="xs" color="gray.500" mx={3}>
{formatDateDivider(date)}
</Text>
<Box flex={1} h="1px" bg="rgba(255, 255, 255, 0.1)" />
</HStack>
);
// 渲染单条消息
const renderMessage = ({ item: message, index }) => {
const showAvatar = shouldShowAvatar(message, index, channelMessages);
const { text, images } = parseMessageContent(message.content);
return (
<HStack px={4} py={showAvatar ? 2 : 0.5} alignItems="flex-start">
{/* 头像区域 */}
<Box w={10}>
{showAvatar ? (
<Avatar
size="sm"
bg="primary.600"
source={message.authorAvatar ? { uri: message.authorAvatar } : null}
>
{message.authorName?.[0]?.toUpperCase() || '?'}
</Avatar>
) : (
<Text fontSize="2xs" color="gray.600" textAlign="center" mt={1}>
{formatTime(message.createdAt)}
</Text>
)}
</Box>
{/* 消息内容 */}
<VStack flex={1} ml={3}>
{showAvatar ? (
<HStack alignItems="center" space={2} mb={0.5}>
<Text fontSize="sm" fontWeight="bold" color="white">
{message.authorName || '匿名用户'}
</Text>
<Text fontSize="2xs" color="gray.500">
{formatTime(message.createdAt)}
</Text>
</HStack>
) : null}
{/* 文本内容 */}
{text ? (
<Text fontSize="sm" color="gray.300" lineHeight="lg">
{text}
</Text>
) : null}
{/* Base64 图片 */}
{images.length > 0 ? (
<VStack space={2} mt={text ? 2 : 0}>
{images.map((imgUri, imgIndex) => (
<Pressable
key={`img-${imgIndex}`}
onPress={() => {
// TODO: 可以添加图片预览功能
}}
>
<Image
source={{ uri: imgUri }}
style={{
width: Math.min(screenWidth - 100, 250),
height: Math.min(screenWidth - 100, 250),
borderRadius: 12,
}}
resizeMode="cover"
/>
</Pressable>
))}
</VStack>
) : null}
{/* 表情反应 */}
{message.reactions && Object.keys(message.reactions).length > 0 ? (
<HStack mt={1} space={1} flexWrap="wrap">
{Object.entries(message.reactions).map(([emoji, users]) => (
<Pressable key={emoji}>
<HStack
bg="rgba(255, 255, 255, 0.05)"
rounded="full"
px={2}
py={0.5}
alignItems="center"
space={1}
>
<Text fontSize="xs">{emoji}</Text>
<Text fontSize="xs" color="gray.400">
{users.length}
</Text>
</HStack>
</Pressable>
))}
</HStack>
) : null}
</VStack>
</HStack>
);
};
// 渲染消息列表
const renderMessageList = () => {
if (loading.messages && channelMessages.length === 0) {
return (
<Center flex={1}>
<Spinner size="lg" color="primary.500" />
<Text fontSize="sm" color="gray.500" mt={4}>
加载消息...
</Text>
</Center>
);
}
if (channelMessages.length === 0) {
return (
<Center flex={1}>
<Icon as={Ionicons} name="chatbubble-outline" size="4xl" color="gray.600" mb={4} />
<Text fontSize="md" color="gray.400">
还没有消息
</Text>
<Text fontSize="sm" color="gray.600" mt={1}>
成为第一个发言的人吧
</Text>
</Center>
);
}
return (
<FlatList
ref={flatListRef}
data={channelMessages}
renderItem={renderMessage}
keyExtractor={(item) => item.id}
inverted={false}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.1}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.messageList}
ListHeaderComponent={
hasMore && loading.messages ? (
<Center py={4}>
<Spinner size="sm" color="primary.500" />
</Center>
) : null
}
/>
);
};
// 渲染输入中提示
const renderTypingIndicator = () => {
if (channelTypingUsers.length === 0) return null;
const names = channelTypingUsers.map((u) => u.username).join('、');
return (
<HStack px={4} py={1} alignItems="center" space={2}>
<HStack space={1}>
<Box w={1.5} h={1.5} rounded="full" bg="gray.500" />
<Box w={1.5} h={1.5} rounded="full" bg="gray.500" />
<Box w={1.5} h={1.5} rounded="full" bg="gray.500" />
</HStack>
<Text fontSize="xs" color="gray.500">
{names} 正在输入...
</Text>
</HStack>
);
};
// 渲染输入框
const renderInput = () => (
<Box
px={4}
py={3}
bg="#0F172A"
borderTopWidth={1}
borderTopColor="rgba(255, 255, 255, 0.1)"
pb={insets.bottom + 12}
>
{renderTypingIndicator()}
<HStack space={2} alignItems="flex-end">
{/* 附件按钮 */}
<Pressable p={2}>
<Icon as={Ionicons} name="add-circle-outline" size="md" color="gray.500" />
</Pressable>
{/* 输入框 */}
<Box
flex={1}
bg="rgba(255, 255, 255, 0.05)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="xl"
px={3}
py={Platform.OS === 'ios' ? 2 : 0}
>
<TextInput
placeholder={`发送消息到 #${channel?.name || '频道'}`}
value={inputText}
onChangeText={handleTextChange}
style={styles.textInput}
placeholderTextColor="#6B7280"
multiline
maxLength={2000}
textAlignVertical="center"
returnKeyType="default"
blurOnSubmit={false}
autoCapitalize="none"
autoCorrect={false}
editable={true}
keyboardType="default"
contextMenuHidden={false}
/>
</Box>
{/* 发送按钮 */}
<Pressable
onPress={handleSend}
p={2}
opacity={inputText.trim() ? 1 : 0.5}
disabled={!inputText.trim() || isSending}
>
{isSending ? (
<Spinner size="sm" color="primary.500" />
) : (
<Icon
as={Ionicons}
name="send"
size="md"
color={inputText.trim() ? 'primary.500' : 'gray.500'}
/>
)}
</Pressable>
</HStack>
</Box>
);
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
>
<Box flex={1} bg="#0F172A">
{/* 频道话题 */}
{channel?.topic ? (
<Box px={4} py={2} bg="rgba(255, 255, 255, 0.02)">
<HStack alignItems="center" space={2}>
<Icon as={Ionicons} name="information-circle-outline" size="xs" color="gray.500" />
<Text fontSize="xs" color="gray.500" numberOfLines={1} flex={1}>
{channel.topic}
</Text>
</HStack>
</Box>
) : null}
{/* 消息列表 */}
{renderMessageList()}
{/* 输入框 */}
{renderInput()}
</Box>
</KeyboardAvoidingView>
);
};
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;

View File

@@ -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 = () => (
<Box px={4} py={3}>
<Input
placeholder="搜索频道..."
value={searchText}
onChangeText={setSearchText}
bg="rgba(255, 255, 255, 0.05)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="xl"
py={2}
px={3}
color="white"
placeholderTextColor="gray.500"
InputLeftElement={
<Icon
as={Ionicons}
name="search"
size="sm"
color="gray.500"
ml={3}
/>
}
_focus={{
borderColor: 'primary.500',
bg: 'rgba(255, 255, 255, 0.08)',
}}
/>
</Box>
);
// 渲染分类标题
const renderSectionHeader = ({ section }) => {
if (!section) return null;
return (
<Pressable onPress={() => section.id && toggleCategory(section.id)}>
<HStack
px={4}
py={2}
alignItems="center"
justifyContent="space-between"
bg="rgba(255, 255, 255, 0.02)"
>
<HStack alignItems="center" space={2}>
<Icon
as={Ionicons}
name={CATEGORY_ICONS[section.id] || 'folder'}
size="sm"
color={section.id === 'hot' ? '#F97316' : 'gray.400'}
/>
<Text
fontSize="xs"
fontWeight="bold"
color="gray.400"
textTransform="uppercase"
letterSpacing="lg"
>
{section.name || '未命名分类'}
</Text>
</HStack>
<Icon
as={Ionicons}
name={collapsedCategories.has(section.id) ? 'chevron-forward' : 'chevron-down'}
size="xs"
color="gray.500"
/>
</HStack>
</Pressable>
);
};
// 渲染频道项
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 (
<Pressable onPress={() => handleChannelPress(channel)}>
{({ isPressed }) => (
<HStack
mx={2}
my={0.5}
px={3}
py={2.5}
rounded="lg"
alignItems="center"
bg={isPressed ? 'rgba(255, 255, 255, 0.08)' : 'transparent'}
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
>
{/* 频道图标 */}
<Box
w={8}
h={8}
rounded="lg"
alignItems="center"
justifyContent="center"
bg={unreadCount > 0 ? 'rgba(124, 58, 237, 0.2)' : 'rgba(255, 255, 255, 0.05)'}
>
<Icon
as={Ionicons}
name={CHANNEL_ICONS[channelType] || 'chatbubble'}
size="sm"
color={unreadCount > 0 ? '#A78BFA' : 'gray.500'}
/>
</Box>
{/* 频道信息 */}
<VStack flex={1} ml={3}>
<HStack alignItems="center">
<Text
fontSize="sm"
fontWeight={unreadCount > 0 ? 'bold' : 'medium'}
color={unreadCount > 0 ? 'white' : 'gray.300'}
numberOfLines={1}
>
{channelName}
</Text>
{channelType === CHANNEL_TYPES.ANNOUNCEMENT && (
<Icon
as={Ionicons}
name="megaphone"
size="2xs"
color="warning.400"
ml={1}
/>
)}
</HStack>
{channel.topic ? (
<Text fontSize="2xs" color="gray.500" numberOfLines={1}>
{channel.topic}
</Text>
) : null}
</VStack>
{/* 未读标记 */}
{unreadCount > 0 ? (
<Box bg="primary.500" rounded="full" px={2} py={0.5}>
<Text fontSize="2xs" fontWeight="bold" color="white">
{unreadCount > 99 ? '99+' : String(unreadCount)}
</Text>
</Box>
) : null}
{/* 成员数 */}
{!unreadCount && channel.subscriberCount ? (
<HStack alignItems="center" space={1}>
<Icon as={Ionicons} name="people-outline" size="2xs" color="gray.600" />
<Text fontSize="2xs" color="gray.600">
{formatNumber(channel.subscriberCount)}
</Text>
</HStack>
) : null}
</HStack>
)}
</Pressable>
);
};
// 渲染空状态
const renderEmpty = () => {
if (loading.channels) {
return (
<Center flex={1} py={20}>
<Spinner size="lg" color="primary.500" />
<Text fontSize="sm" color="gray.500" mt={4}>
加载频道列表...
</Text>
</Center>
);
}
if (searchText) {
return (
<Center flex={1} py={20}>
<Icon as={Ionicons} name="search-outline" size="4xl" color="gray.600" mb={4} />
<Text fontSize="md" color="gray.400">
未找到匹配的频道
</Text>
<Text fontSize="sm" color="gray.600" mt={1}>
尝试其他搜索词
</Text>
</Center>
);
}
return (
<Center flex={1} py={20}>
<Icon as={Ionicons} name="chatbubbles-outline" size="4xl" color="gray.600" mb={4} />
<Text fontSize="md" color="gray.400">
暂无频道
</Text>
</Center>
);
};
// 渲染底部
const renderFooter = () => (
<Box px={4} py={6}>
<Pressable
onPress={() => {
navigation.navigate('CreateChannel');
}}
>
<HStack
alignItems="center"
justifyContent="center"
py={3}
rounded="xl"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
borderStyle="dashed"
>
<Icon as={Ionicons} name="add-circle-outline" size="sm" color="gray.500" mr={2} />
<Text fontSize="sm" color="gray.500">
创建频道
</Text>
</HStack>
</Pressable>
</Box>
);
return (
<Box flex={1} bg="#0F172A">
<SectionList
sections={filteredCategories}
keyExtractor={(item, index) => item?.id || `item-${index}`}
renderItem={renderChannelItem}
renderSectionHeader={renderSectionHeader}
ListHeaderComponent={renderHeader}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
stickySectionHeadersEnabled={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor="#7C3AED"
colors={['#7C3AED']}
/>
}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContent}
/>
</Box>
);
};
// 格式化数字
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;

View File

@@ -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: () => (
<Pressable
onPress={handleSubmit}
mr={4}
disabled={isSubmitting || !name.trim()}
opacity={isSubmitting || !name.trim() ? 0.5 : 1}
>
{isSubmitting ? (
<Spinner size="sm" color="primary.500" />
) : (
<Text fontSize="sm" fontWeight="bold" color="primary.400">
创建
</Text>
)}
</Pressable>
),
});
}, [navigation, handleSubmit, isSubmitting, name]);
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={[
styles.content,
{ paddingBottom: insets.bottom + 20 },
]}
>
{/* 频道名称 */}
<VStack mx={4} mt={4}>
<HStack alignItems="center" justifyContent="space-between" mb={2}>
<Text fontSize="sm" fontWeight="bold" color="gray.400">
频道名称 *
</Text>
<Text fontSize="xs" color={name.length > NAME_MAX ? 'red.500' : 'gray.500'}>
{name.length}/{NAME_MAX}
</Text>
</HStack>
<Box
bg="rgba(255, 255, 255, 0.05)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="xl"
px={4}
py={Platform.OS === 'ios' ? 3 : 0}
>
<TextInput
placeholder="例如:新能源汽车讨论"
value={name}
onChangeText={setName}
style={styles.textInput}
placeholderTextColor="#6B7280"
maxLength={NAME_MAX + 10}
editable={true}
keyboardType="default"
/>
</Box>
</VStack>
{/* 频道类型 */}
<VStack mx={4} mt={4}>
<Text fontSize="sm" fontWeight="bold" color="gray.400" mb={2}>
频道类型
</Text>
<HStack space={3}>
{CHANNEL_TYPE_OPTIONS.map((option) => (
<Pressable
key={option.value}
flex={1}
onPress={() => setChannelType(option.value)}
>
<Box
p={3}
rounded="xl"
borderWidth={2}
borderColor={channelType === option.value ? 'primary.500' : 'rgba(255, 255, 255, 0.1)'}
bg={channelType === option.value ? 'rgba(124, 58, 237, 0.1)' : 'rgba(255, 255, 255, 0.03)'}
>
<VStack alignItems="center" space={2}>
<Icon
as={Ionicons}
name={option.icon}
size="lg"
color={channelType === option.value ? 'primary.400' : 'gray.500'}
/>
<Text
fontSize="sm"
fontWeight="bold"
color={channelType === option.value ? 'primary.400' : 'gray.400'}
>
{option.label}
</Text>
<Text fontSize="2xs" color="gray.500" textAlign="center">
{option.description}
</Text>
</VStack>
</Box>
</Pressable>
))}
</HStack>
</VStack>
{/* 所属分类 */}
<VStack mx={4} mt={4}>
<Text fontSize="sm" fontWeight="bold" color="gray.400" mb={2}>
所属分类
</Text>
{loadingCategories ? (
<Center py={4}>
<Spinner size="sm" color="primary.500" />
<Text fontSize="xs" color="gray.500" mt={2}>加载分类...</Text>
</Center>
) : categories.length === 0 ? (
<Text fontSize="sm" color="gray.500">暂无可用分类</Text>
) : (
<HStack flexWrap="wrap" space={2}>
{categories.map((cat) => (
<Pressable
key={cat.id}
onPress={() => setCategory(cat.id)}
mb={2}
>
<Box
px={4}
py={2}
rounded="full"
borderWidth={1}
borderColor={category === cat.id ? 'primary.500' : 'rgba(255, 255, 255, 0.1)'}
bg={category === cat.id ? 'rgba(124, 58, 237, 0.2)' : 'rgba(255, 255, 255, 0.05)'}
>
<Text
fontSize="sm"
color={category === cat.id ? 'primary.400' : 'gray.400'}
>
{cat.name || cat.id}
</Text>
</Box>
</Pressable>
))}
</HStack>
)}
</VStack>
{/* 频道话题 */}
<VStack mx={4} mt={4}>
<HStack alignItems="center" justifyContent="space-between" mb={2}>
<Text fontSize="sm" fontWeight="bold" color="gray.400">
频道话题可选
</Text>
<Text fontSize="xs" color={topic.length > TOPIC_MAX ? 'red.500' : 'gray.500'}>
{topic.length}/{TOPIC_MAX}
</Text>
</HStack>
<Box
bg="rgba(255, 255, 255, 0.05)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="xl"
px={4}
py={Platform.OS === 'ios' ? 3 : 0}
>
<TextInput
placeholder="简单描述频道讨论的主题..."
value={topic}
onChangeText={setTopic}
style={[styles.textInput, styles.textArea]}
placeholderTextColor="#6B7280"
multiline
textAlignVertical="top"
maxLength={TOPIC_MAX + 10}
editable={true}
keyboardType="default"
/>
</Box>
</VStack>
{/* 提示 */}
<Box mx={4} mt={6} p={3} rounded="lg" bg="rgba(124, 58, 237, 0.1)">
<HStack alignItems="flex-start" space={2}>
<Icon as={Ionicons} name="information-circle" size="sm" color="primary.400" mt={0.5} />
<VStack flex={1}>
<Text fontSize="xs" color="primary.400" fontWeight="bold">
创建须知
</Text>
<Text fontSize="xs" color="gray.400" mt={1}>
{`• 频道名称应与讨论主题相关\n• 创建后您将成为频道管理员\n• 请遵守社区规范,文明交流`}
</Text>
</VStack>
</HStack>
</Box>
</ScrollView>
</KeyboardAvoidingView>
);
};
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;

View File

@@ -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: () => (
<Pressable
onPress={handleSubmit}
mr={4}
disabled={isSubmitting || !title.trim() || !content.trim()}
opacity={isSubmitting || !title.trim() || !content.trim() ? 0.5 : 1}
>
{isSubmitting ? (
<Spinner size="sm" color="primary.500" />
) : (
<Text fontSize="sm" fontWeight="bold" color="primary.400">
发布
</Text>
)}
</Pressable>
),
});
}, [navigation, handleSubmit, isSubmitting, title, content]);
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={[
styles.content,
{ paddingBottom: insets.bottom + 20 },
]}
>
{/* 频道信息 */}
<Box
mx={4}
mt={4}
px={3}
py={2}
rounded="lg"
bg="rgba(124, 58, 237, 0.1)"
borderWidth={1}
borderColor="rgba(124, 58, 237, 0.2)"
>
<HStack alignItems="center" space={2}>
<Icon as={Ionicons} name="document-text" size="sm" color="primary.400" />
<Text fontSize="sm" color="primary.400">
发布到: {channel.name}
</Text>
</HStack>
</Box>
{/* 标题输入 */}
<VStack mx={4} mt={4}>
<HStack alignItems="center" justifyContent="space-between" mb={2}>
<Text fontSize="sm" fontWeight="bold" color="gray.400">
标题
</Text>
<Text fontSize="xs" color={title.length > TITLE_MAX ? 'red.500' : 'gray.500'}>
{title.length}/{TITLE_MAX}
</Text>
</HStack>
<Input
placeholder="请输入标题"
value={title}
onChangeText={setTitle}
bg="rgba(255, 255, 255, 0.05)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="xl"
py={3}
px={4}
color="white"
fontSize="md"
placeholderTextColor="gray.500"
_focus={{
borderColor: 'primary.500',
bg: 'rgba(255, 255, 255, 0.08)',
}}
/>
</VStack>
{/* 内容输入 */}
<VStack mx={4} mt={4}>
<HStack alignItems="center" justifyContent="space-between" mb={2}>
<Text fontSize="sm" fontWeight="bold" color="gray.400">
内容
</Text>
<Text fontSize="xs" color={content.length > CONTENT_MAX ? 'red.500' : 'gray.500'}>
{content.length}/{CONTENT_MAX}
</Text>
</HStack>
<TextArea
placeholder="分享你的观点..."
value={content}
onChangeText={setContent}
bg="rgba(255, 255, 255, 0.05)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="xl"
py={3}
px={4}
color="white"
fontSize="sm"
placeholderTextColor="gray.500"
minH={200}
_focus={{
borderColor: 'primary.500',
bg: 'rgba(255, 255, 255, 0.08)',
}}
/>
</VStack>
{/* 标签选择 */}
<VStack mx={4} mt={4}>
<HStack alignItems="center" justifyContent="space-between" mb={2}>
<Text fontSize="sm" fontWeight="bold" color="gray.400">
标签
</Text>
<Text fontSize="xs" color="gray.500">
{tags.length}/{TAGS_MAX}
</Text>
</HStack>
{/* 已选标签 */}
{tags.length > 0 && (
<HStack space={2} mb={3} flexWrap="wrap">
{tags.map((tag) => (
<Pressable key={tag} onPress={() => handleRemoveTag(tag)}>
<HStack
alignItems="center"
space={1}
bg="rgba(124, 58, 237, 0.2)"
rounded="full"
px={3}
py={1}
mb={1}
>
<Text fontSize="xs" color="primary.300">
#{tag}
</Text>
<Icon as={Ionicons} name="close" size="2xs" color="primary.300" />
</HStack>
</Pressable>
))}
</HStack>
)}
{/* 自定义标签输入 */}
<HStack space={2} mb={3}>
<Input
flex={1}
placeholder="添加自定义标签"
value={customTag}
onChangeText={setCustomTag}
onSubmitEditing={handleAddCustomTag}
bg="rgba(255, 255, 255, 0.05)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="lg"
py={2}
px={3}
color="white"
fontSize="sm"
placeholderTextColor="gray.500"
_focus={{
borderColor: 'primary.500',
}}
/>
<Pressable
onPress={handleAddCustomTag}
bg="primary.500"
rounded="lg"
px={4}
justifyContent="center"
_pressed={{ bg: 'primary.600' }}
>
<Text fontSize="sm" fontWeight="bold" color="white">
添加
</Text>
</Pressable>
</HStack>
{/* 预设标签 */}
<HStack flexWrap="wrap" space={2}>
{PRESET_TAGS.map((tag) => (
<Pressable
key={tag}
onPress={() => handleAddTag(tag)}
disabled={tags.includes(tag)}
mb={2}
>
<Box
bg={tags.includes(tag) ? 'rgba(124, 58, 237, 0.2)' : 'rgba(255, 255, 255, 0.05)'}
borderWidth={1}
borderColor={tags.includes(tag) ? 'primary.500' : 'rgba(255, 255, 255, 0.1)'}
rounded="full"
px={3}
py={1}
>
<Text
fontSize="xs"
color={tags.includes(tag) ? 'primary.400' : 'gray.400'}
>
#{tag}
</Text>
</Box>
</Pressable>
))}
</HStack>
</VStack>
{/* 提示 */}
<Box mx={4} mt={6} p={3} rounded="lg" bg="rgba(251, 146, 60, 0.1)">
<HStack alignItems="flex-start" space={2}>
<Icon as={Ionicons} name="information-circle" size="sm" color="warning.400" mt={0.5} />
<VStack flex={1}>
<Text fontSize="xs" color="warning.400" fontWeight="bold">
发帖须知
</Text>
<Text fontSize="xs" color="gray.400" mt={1}>
请遵守社区规范文明发言{'\n'}
禁止发布违法低俗广告内容{'\n'}
投资有风险观点仅供参考
</Text>
</VStack>
</HStack>
</Box>
</ScrollView>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0F172A',
},
content: {
flexGrow: 1,
},
});
export default CreatePost;

View File

@@ -0,0 +1,421 @@
/**
* 论坛频道页面
* 显示帖子列表,支持发帖
*/
import React, { useEffect, useState, useCallback } from 'react';
import {
FlatList,
RefreshControl,
StyleSheet,
} from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Icon,
Pressable,
Spinner,
Center,
Avatar,
Fab,
Menu,
} 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, useSelector } from 'react-redux';
import { fetchPosts } from '../../store/slices/communitySlice';
import { gradients } from '../../theme';
// 排序选项
const SORT_OPTIONS = [
{ key: 'latest', label: '最新发布', icon: 'time-outline' },
{ key: 'hot', label: '最热门', icon: 'flame-outline' },
{ key: 'most_replies', label: '最多回复', icon: 'chatbubbles-outline' },
];
// 格式化相对时间
const formatRelativeTime = (dateStr) => {
const date = new Date(dateStr);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 30) return `${days}天前`;
const month = date.getMonth() + 1;
const day = date.getDate();
return `${month}${day}`;
};
// 格式化数字
const formatNumber = (num) => {
if (num >= 10000) return (num / 10000).toFixed(1) + 'w';
if (num >= 1000) return (num / 1000).toFixed(1) + 'k';
return num?.toString() || '0';
};
const ForumChannel = ({ route, navigation }) => {
const { channel } = route.params;
const dispatch = useDispatch();
const insets = useSafeAreaInsets();
const { posts, postsHasMore, loading } = useSelector((state) => state.community);
const channelPosts = posts[channel.id] || [];
const hasMore = postsHasMore[channel.id] ?? true;
const [sortBy, setSortBy] = useState('latest');
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
// 设置导航标题
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,
},
});
}, [navigation, channel]);
// 加载帖子
useEffect(() => {
dispatch(fetchPosts({ channelId: channel.id, options: { sortBy, page: 1 } }));
setPage(1);
}, [dispatch, channel.id, sortBy]);
// 下拉刷新
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await dispatch(fetchPosts({ channelId: channel.id, options: { sortBy, page: 1 } }));
setPage(1);
setRefreshing(false);
}, [dispatch, channel.id, sortBy]);
// 加载更多
const handleLoadMore = useCallback(() => {
if (loading.posts || !hasMore) return;
const nextPage = page + 1;
dispatch(fetchPosts({ channelId: channel.id, options: { sortBy, page: nextPage } }));
setPage(nextPage);
}, [dispatch, channel.id, sortBy, page, loading.posts, hasMore]);
// 点击帖子
const handlePostPress = useCallback((post) => {
navigation.navigate('PostDetail', { post, channel });
}, [navigation, channel]);
// 发帖
const handleCreatePost = useCallback(() => {
navigation.navigate('CreatePost', { channel });
}, [navigation, channel]);
// 渲染排序栏
const renderSortBar = () => (
<HStack px={4} py={3} alignItems="center" justifyContent="space-between">
<HStack space={2}>
{SORT_OPTIONS.map((option) => (
<Pressable
key={option.key}
onPress={() => setSortBy(option.key)}
>
<HStack
alignItems="center"
space={1}
px={3}
py={1.5}
rounded="full"
bg={sortBy === option.key ? 'rgba(124, 58, 237, 0.2)' : 'transparent'}
borderWidth={1}
borderColor={sortBy === option.key ? 'primary.500' : 'transparent'}
>
<Icon
as={Ionicons}
name={option.icon}
size="xs"
color={sortBy === option.key ? 'primary.400' : 'gray.500'}
/>
<Text
fontSize="xs"
fontWeight={sortBy === option.key ? 'bold' : 'medium'}
color={sortBy === option.key ? 'primary.400' : 'gray.500'}
>
{option.label}
</Text>
</HStack>
</Pressable>
))}
</HStack>
<Text fontSize="xs" color="gray.600">
{channelPosts.length} 篇帖子
</Text>
</HStack>
);
// 渲染帖子卡片
const renderPostCard = ({ item: post }) => (
<Pressable onPress={() => handlePostPress(post)} mx={4} mb={3}>
{({ isPressed }) => (
<Box
bg={isPressed ? 'rgba(255, 255, 255, 0.08)' : 'rgba(30, 41, 59, 0.6)'}
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.05)"
rounded="2xl"
p={4}
>
{/* 作者信息 */}
<HStack alignItems="center" mb={3}>
<Avatar
size="sm"
bg="primary.600"
source={post.authorAvatar ? { uri: post.authorAvatar } : null}
>
{post.authorName?.[0]?.toUpperCase() || '?'}
</Avatar>
<VStack ml={2} flex={1}>
<Text fontSize="sm" fontWeight="bold" color="white">
{post.authorName || '匿名用户'}
</Text>
<Text fontSize="2xs" color="gray.500">
{formatRelativeTime(post.createdAt)}
</Text>
</VStack>
{post.isPinned && (
<Box bg="warning.500" rounded="full" px={2} py={0.5}>
<HStack alignItems="center" space={1}>
<Icon as={Ionicons} name="pin" size="2xs" color="white" />
<Text fontSize="2xs" color="white" fontWeight="bold">
置顶
</Text>
</HStack>
</Box>
)}
</HStack>
{/* 标题 */}
<Text
fontSize="md"
fontWeight="bold"
color="white"
numberOfLines={2}
mb={2}
>
{post.title}
</Text>
{/* 内容预览 */}
{post.content ? (
<Text
fontSize="sm"
color="gray.400"
numberOfLines={3}
mb={3}
>
{post.content
.replace(/!\[[^\]]*\]\([^)]+\)/g, '[图片]') // 移除 Markdown 图片
.replace(/<[^>]*>/g, '') // 移除 HTML 标签
.replace(/data:image\/[a-zA-Z]+;base64,[A-Za-z0-9+/=]+/g, '[图片]') // 移除 base64 图片
.slice(0, 150)}
</Text>
) : null}
{/* 标签 */}
{post.tags && post.tags.length > 0 && (
<HStack space={1} mb={3} flexWrap="wrap">
{post.tags.slice(0, 3).map((tag, index) => (
<Box
key={index}
bg="rgba(124, 58, 237, 0.15)"
rounded="full"
px={2}
py={0.5}
>
<Text fontSize="2xs" color="primary.300">
#{tag}
</Text>
</Box>
))}
{post.tags.length > 3 && (
<Text fontSize="2xs" color="gray.500">
+{post.tags.length - 3}
</Text>
)}
</HStack>
)}
{/* 统计数据 */}
<HStack alignItems="center" space={4}>
<HStack alignItems="center" space={1}>
<Icon as={Ionicons} name="heart-outline" size="xs" color="gray.500" />
<Text fontSize="xs" color="gray.500">
{formatNumber(post.likeCount)}
</Text>
</HStack>
<HStack alignItems="center" space={1}>
<Icon as={Ionicons} name="chatbubble-outline" size="xs" color="gray.500" />
<Text fontSize="xs" color="gray.500">
{formatNumber(post.replyCount)}
</Text>
</HStack>
<HStack alignItems="center" space={1}>
<Icon as={Ionicons} name="eye-outline" size="xs" color="gray.500" />
<Text fontSize="xs" color="gray.500">
{formatNumber(post.viewCount)}
</Text>
</HStack>
</HStack>
</Box>
)}
</Pressable>
);
// 渲染空状态
const renderEmpty = () => {
if (loading.posts) {
return (
<Center flex={1} py={20}>
<Spinner size="lg" color="primary.500" />
<Text fontSize="sm" color="gray.500" mt={4}>
加载帖子列表...
</Text>
</Center>
);
}
return (
<Center flex={1} py={20}>
<Icon as={Ionicons} name="document-text-outline" size="4xl" color="gray.600" mb={4} />
<Text fontSize="md" color="gray.400">
还没有帖子
</Text>
<Text fontSize="sm" color="gray.600" mt={1}>
成为第一个发帖的人吧
</Text>
<Pressable
onPress={handleCreatePost}
mt={4}
>
<LinearGradient
colors={gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.createButton}
>
<Icon as={Ionicons} name="add" size="sm" color="white" mr={1} />
<Text fontSize="sm" fontWeight="bold" color="white">
发布帖子
</Text>
</LinearGradient>
</Pressable>
</Center>
);
};
// 渲染加载更多
const renderFooter = () => {
if (!hasMore || channelPosts.length === 0) return null;
if (loading.posts) {
return (
<Center py={4}>
<Spinner size="sm" color="primary.500" />
</Center>
);
}
return <Box h={20} />;
};
return (
<Box flex={1} bg="#0F172A">
{/* 频道话题 */}
{channel.topic && (
<Box px={4} py={2} bg="rgba(255, 255, 255, 0.02)">
<HStack alignItems="center" space={2}>
<Icon as={Ionicons} name="information-circle-outline" size="xs" color="gray.500" />
<Text fontSize="xs" color="gray.500" numberOfLines={1}>
{channel.topic}
</Text>
</HStack>
</Box>
)}
{/* 排序栏 */}
{renderSortBar()}
{/* 帖子列表 */}
<FlatList
data={channelPosts}
renderItem={renderPostCard}
keyExtractor={(item) => item.id}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.2}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor="#7C3AED"
colors={['#7C3AED']}
/>
}
showsVerticalScrollIndicator={false}
contentContainerStyle={[
styles.listContent,
{ paddingBottom: insets.bottom + 80 },
]}
/>
{/* 发帖按钮 */}
<Fab
renderInPortal={false}
shadow={2}
size="lg"
icon={<Icon as={Ionicons} name="add" size="md" color="white" />}
onPress={handleCreatePost}
position="absolute"
bottom={insets.bottom + 16}
right={4}
bg="primary.500"
_pressed={{ bg: 'primary.600' }}
/>
</Box>
);
};
const styles = StyleSheet.create({
listContent: {
flexGrow: 1,
},
createButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 24,
},
});
export default ForumChannel;

View File

@@ -0,0 +1,299 @@
/**
* 成员列表页面
* 显示频道成员,区分在线/离线
*/
import React, { useEffect, useState, useCallback } from 'react';
import {
SectionList,
RefreshControl,
StyleSheet,
} from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Icon,
Pressable,
Spinner,
Center,
Avatar,
Input,
} from 'native-base';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useDispatch, useSelector } from 'react-redux';
import { fetchMembers } from '../../store/slices/communitySlice';
// 角色配置
const ROLE_CONFIG = {
owner: { label: '群主', color: '#D4AF37', icon: 'star' },
admin: { label: '管理员', color: '#7C3AED', icon: 'shield' },
moderator: { label: '版主', color: '#10B981', icon: 'ribbon' },
};
const MemberList = ({ route, navigation }) => {
const { channel } = route.params;
const dispatch = useDispatch();
const insets = useSafeAreaInsets();
const { members, loading } = useSelector((state) => state.community);
const channelMembers = members[channel.id] || [];
const [searchText, setSearchText] = useState('');
const [refreshing, setRefreshing] = useState(false);
// 设置导航
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,
},
});
}, [navigation]);
// 加载成员
useEffect(() => {
dispatch(fetchMembers({ channelId: channel.id }));
}, [dispatch, channel.id]);
// 下拉刷新
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await dispatch(fetchMembers({ channelId: channel.id }));
setRefreshing(false);
}, [dispatch, channel.id]);
// 过滤和分组成员
const filteredMembers = channelMembers.filter((member) =>
member.username?.toLowerCase().includes(searchText.toLowerCase())
);
const onlineMembers = filteredMembers.filter((m) => m.isOnline);
const offlineMembers = filteredMembers.filter((m) => !m.isOnline);
const sections = [
{ title: '在线', data: onlineMembers, count: onlineMembers.length },
{ title: '离线', data: offlineMembers, count: offlineMembers.length },
].filter((section) => section.data.length > 0);
// 渲染搜索栏
const renderHeader = () => (
<Box px={4} py={3}>
<Input
placeholder="搜索成员..."
value={searchText}
onChangeText={setSearchText}
bg="rgba(255, 255, 255, 0.05)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="xl"
py={2}
px={3}
color="white"
placeholderTextColor="gray.500"
InputLeftElement={
<Icon as={Ionicons} name="search" size="sm" color="gray.500" ml={3} />
}
_focus={{
borderColor: 'primary.500',
bg: 'rgba(255, 255, 255, 0.08)',
}}
/>
<Text fontSize="xs" color="gray.500" mt={2} textAlign="center">
{channelMembers.length} 位成员 · {onlineMembers.length} 人在线
</Text>
</Box>
);
// 渲染分组标题
const renderSectionHeader = ({ section }) => (
<HStack
px={4}
py={2}
alignItems="center"
bg="rgba(255, 255, 255, 0.02)"
>
<Box
w={2}
h={2}
rounded="full"
bg={section.title === '在线' ? 'green.500' : 'gray.500'}
mr={2}
/>
<Text fontSize="xs" fontWeight="bold" color="gray.400" textTransform="uppercase">
{section.title} {section.count}
</Text>
</HStack>
);
// 渲染成员项
const renderMemberItem = ({ item: member }) => {
const role = ROLE_CONFIG[member.role];
return (
<Pressable onPress={() => {}}>
{({ isPressed }) => (
<HStack
mx={2}
my={0.5}
px={3}
py={2.5}
rounded="lg"
alignItems="center"
bg={isPressed ? 'rgba(255, 255, 255, 0.08)' : 'transparent'}
>
{/* 头像 */}
<Box position="relative">
<Avatar
size="sm"
bg="primary.600"
source={member.avatar ? { uri: member.avatar } : null}
>
{member.username?.[0]?.toUpperCase() || '?'}
</Avatar>
{/* 在线状态 */}
<Box
position="absolute"
bottom={0}
right={0}
w={3}
h={3}
rounded="full"
bg={member.isOnline ? 'green.500' : 'gray.500'}
borderWidth={2}
borderColor="#0F172A"
/>
</Box>
{/* 用户信息 */}
<VStack flex={1} ml={3}>
<HStack alignItems="center" space={1}>
<Text
fontSize="sm"
fontWeight="bold"
color="white"
numberOfLines={1}
>
{member.username || '匿名用户'}
</Text>
{role && (
<Box
bg={`${role.color}20`}
rounded="full"
px={1.5}
py={0.5}
>
<HStack alignItems="center" space={0.5}>
<Icon
as={Ionicons}
name={role.icon}
size="2xs"
color={role.color}
/>
<Text fontSize="2xs" color={role.color} fontWeight="bold">
{role.label}
</Text>
</HStack>
</Box>
)}
</HStack>
{member.bio && (
<Text fontSize="2xs" color="gray.500" numberOfLines={1}>
{member.bio}
</Text>
)}
</VStack>
{/* 操作按钮 */}
<Pressable p={2}>
<Icon as={Ionicons} name="ellipsis-horizontal" size="sm" color="gray.500" />
</Pressable>
</HStack>
)}
</Pressable>
);
};
// 渲染空状态
const renderEmpty = () => {
if (loading.members) {
return (
<Center flex={1} py={20}>
<Spinner size="lg" color="primary.500" />
<Text fontSize="sm" color="gray.500" mt={4}>
加载成员列表...
</Text>
</Center>
);
}
if (searchText) {
return (
<Center flex={1} py={20}>
<Icon as={Ionicons} name="search-outline" size="4xl" color="gray.600" mb={4} />
<Text fontSize="md" color="gray.400">
未找到匹配的成员
</Text>
</Center>
);
}
return (
<Center flex={1} py={20}>
<Icon as={Ionicons} name="people-outline" size="4xl" color="gray.600" mb={4} />
<Text fontSize="md" color="gray.400">
暂无成员
</Text>
</Center>
);
};
return (
<Box flex={1} bg="#0F172A">
<SectionList
sections={sections}
keyExtractor={(item) => item.userId}
renderItem={renderMemberItem}
renderSectionHeader={renderSectionHeader}
ListHeaderComponent={renderHeader}
ListEmptyComponent={renderEmpty}
stickySectionHeadersEnabled={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor="#7C3AED"
colors={['#7C3AED']}
/>
}
showsVerticalScrollIndicator={false}
contentContainerStyle={[
styles.listContent,
{ paddingBottom: insets.bottom + 20 },
]}
/>
</Box>
);
};
const styles = StyleSheet.create({
listContent: {
flexGrow: 1,
},
});
export default MemberList;

View File

@@ -0,0 +1,337 @@
/**
* 通知页面
* 显示社区通知列表
*/
import React, { useEffect, useState, useCallback } from 'react';
import {
FlatList,
RefreshControl,
StyleSheet,
} 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 {
fetchNotifications,
markNotificationRead,
markAllNotificationsRead,
} from '../../store/slices/communitySlice';
// 通知类型配置
const NOTIFICATION_CONFIG = {
reply: {
icon: 'chatbubble',
color: '#7C3AED',
label: '回复了你的帖子',
},
mention: {
icon: 'at',
color: '#10B981',
label: '提到了你',
},
like: {
icon: 'heart',
color: '#F43F5E',
label: '赞了你的内容',
},
follow: {
icon: 'person-add',
color: '#3B82F6',
label: '关注了你',
},
system: {
icon: 'megaphone',
color: '#D4AF37',
label: '系统通知',
},
};
// 格式化相对时间
const formatRelativeTime = (dateStr) => {
const date = new Date(dateStr);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
const month = date.getMonth() + 1;
const day = date.getDate();
return `${month}${day}`;
};
const Notifications = ({ navigation }) => {
const dispatch = useDispatch();
const insets = useSafeAreaInsets();
const communityState = useSelector((state) => state.community);
const notifications = communityState?.notifications || [];
const notificationsHasMore = communityState?.notificationsHasMore ?? true;
const loading = communityState?.loading || {};
const unreadCount = communityState?.unreadCount || 0;
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
// 加载通知
useEffect(() => {
dispatch(fetchNotifications({ page: 1 }));
setPage(1);
}, [dispatch]);
// 下拉刷新
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await dispatch(fetchNotifications({ page: 1 }));
setPage(1);
setRefreshing(false);
}, [dispatch]);
// 加载更多
const handleLoadMore = useCallback(() => {
if (loading.notifications || !notificationsHasMore) return;
const nextPage = page + 1;
dispatch(fetchNotifications({ page: nextPage }));
setPage(nextPage);
}, [dispatch, page, loading.notifications, notificationsHasMore]);
// 标记已读
const handleMarkRead = useCallback((notification) => {
if (!notification.isRead) {
dispatch(markNotificationRead(notification.id));
}
// 根据通知类型跳转
if (notification.targetType === 'post' && notification.targetId) {
navigation.navigate('PostDetail', {
post: { id: notification.targetId },
channel: {},
});
}
}, [dispatch, navigation]);
// 标记全部已读
const handleMarkAllRead = useCallback(() => {
dispatch(markAllNotificationsRead());
}, [dispatch]);
// 渲染头部
const renderHeader = () => (
<HStack px={4} py={3} alignItems="center" justifyContent="space-between">
<HStack alignItems="center" space={2}>
<Text fontSize="md" fontWeight="bold" color="white">
通知
</Text>
{unreadCount > 0 ? (
<Box bg="red.500" rounded="full" px={2} py={0.5}>
<Text fontSize="2xs" fontWeight="bold" color="white">
{unreadCount > 99 ? '99+' : String(unreadCount)}
</Text>
</Box>
) : null}
</HStack>
{unreadCount > 0 ? (
<Pressable onPress={handleMarkAllRead}>
<Text fontSize="xs" color="primary.400">
全部已读
</Text>
</Pressable>
) : null}
</HStack>
);
// 渲染通知项
const renderNotificationItem = ({ item: notification }) => {
if (!notification) return null;
const config = NOTIFICATION_CONFIG[notification.type] || NOTIFICATION_CONFIG.system;
return (
<Pressable onPress={() => handleMarkRead(notification)}>
{({ isPressed }) => (
<HStack
mx={4}
my={1}
px={3}
py={3}
rounded="xl"
alignItems="flex-start"
bg={
isPressed
? 'rgba(255, 255, 255, 0.08)'
: notification.isRead
? 'transparent'
: 'rgba(124, 58, 237, 0.05)'
}
borderWidth={notification.isRead ? 0 : 1}
borderColor="rgba(124, 58, 237, 0.1)"
>
{/* 图标/头像 */}
<Box position="relative">
{notification.senderAvatar ? (
<Avatar
size="sm"
source={{ uri: notification.senderAvatar }}
bg="primary.600"
>
{notification.senderName?.[0]?.toUpperCase() || '?'}
</Avatar>
) : (
<Box
w={10}
h={10}
rounded="full"
bg={`${config.color}20`}
alignItems="center"
justifyContent="center"
>
<Icon as={Ionicons} name={config.icon} size="sm" color={config.color} />
</Box>
)}
{/* 未读标记 */}
{!notification.isRead && (
<Box
position="absolute"
top={-2}
right={-2}
w={3}
h={3}
rounded="full"
bg="primary.500"
/>
)}
</Box>
{/* 内容 */}
<VStack flex={1} ml={3}>
<HStack alignItems="center" flexWrap="wrap">
{notification.senderName && (
<Text fontSize="sm" fontWeight="bold" color="white" mr={1}>
{notification.senderName}
</Text>
)}
<Text fontSize="sm" color="gray.400">
{config.label}
</Text>
</HStack>
{notification.content && (
<Text
fontSize="sm"
color="gray.300"
numberOfLines={2}
mt={1}
>
{notification.content}
</Text>
)}
<Text fontSize="2xs" color="gray.500" mt={1}>
{formatRelativeTime(notification.createdAt)}
</Text>
</VStack>
{/* 箭头 */}
<Icon as={Ionicons} name="chevron-forward" size="sm" color="gray.600" ml={2} mt={2} />
</HStack>
)}
</Pressable>
);
};
// 渲染空状态
const renderEmpty = () => {
if (loading.notifications) {
return (
<Center flex={1} py={20}>
<Spinner size="lg" color="primary.500" />
<Text fontSize="sm" color="gray.500" mt={4}>
加载通知...
</Text>
</Center>
);
}
return (
<Center flex={1} py={20}>
<Icon as={Ionicons} name="notifications-outline" size="4xl" color="gray.600" mb={4} />
<Text fontSize="md" color="gray.400">
暂无通知
</Text>
<Text fontSize="sm" color="gray.600" mt={1}>
当有人回复或提及你时会收到通知
</Text>
</Center>
);
};
// 渲染加载更多
const renderFooter = () => {
if (!notificationsHasMore || notifications.length === 0) return null;
if (loading.notifications) {
return (
<Center py={4}>
<Spinner size="sm" color="primary.500" />
</Center>
);
}
return <Box h={20} />;
};
return (
<Box flex={1} bg="#0F172A">
<FlatList
data={notifications || []}
renderItem={renderNotificationItem}
keyExtractor={(item, index) => item?.id || `notification-${index}`}
ListHeaderComponent={renderHeader}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.2}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor="#7C3AED"
colors={['#7C3AED']}
/>
}
showsVerticalScrollIndicator={false}
contentContainerStyle={[
styles.listContent,
{ paddingBottom: insets.bottom + 20 },
]}
/>
</Box>
);
};
const styles = StyleSheet.create({
listContent: {
flexGrow: 1,
},
});
export default Notifications;

View File

@@ -0,0 +1,510 @@
/**
* 帖子详情页面
* 显示帖子内容和回复列表
*/
import React, { useEffect, useState, useCallback } from 'react';
import {
ScrollView,
KeyboardAvoidingView,
Platform,
StyleSheet,
Keyboard,
TextInput,
Image,
Dimensions,
} from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Icon,
Pressable,
Spinner,
Center,
Avatar,
Divider,
} from 'native-base';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useDispatch, useSelector } from 'react-redux';
import {
fetchPostDetail,
fetchReplies,
createReply,
clearCurrentPost,
} from '../../store/slices/communitySlice';
import { postService } from '../../services/communityService';
// 格式化相对时间
const formatRelativeTime = (dateStr) => {
const date = new Date(dateStr);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 30) return `${days}天前`;
const month = date.getMonth() + 1;
const day = date.getDate();
return `${month}${day}`;
};
// 获取屏幕宽度
const screenWidth = Dimensions.get('window').width;
// 解析 Markdown 内容,提取文本和图片
const parseMarkdownContent = (content) => {
if (!content) return [];
const parts = [];
let lastIndex = 0;
// 匹配 Markdown 图片格式: ![alt](url) 或 ![alt](data:image/xxx;base64,xxx)
const markdownImgRegex = /!\[([^\]]*)\]\((data:image\/[a-zA-Z]+;base64,[A-Za-z0-9+/=]+|https?:\/\/[^\s)]+)\)/g;
let match;
while ((match = markdownImgRegex.exec(content)) !== null) {
// 添加图片前的文本
if (match.index > lastIndex) {
const textBefore = content.slice(lastIndex, match.index).trim();
if (textBefore) {
parts.push({ type: 'text', content: textBefore });
}
}
// 添加图片
parts.push({
type: 'image',
uri: match[2],
alt: match[1] || '图片',
});
lastIndex = match.index + match[0].length;
}
// 添加最后的文本
if (lastIndex < content.length) {
const textAfter = content.slice(lastIndex).trim();
if (textAfter) {
parts.push({ type: 'text', content: textAfter });
}
}
// 如果没有匹配到任何图片,返回原始文本
if (parts.length === 0 && content.trim()) {
parts.push({ type: 'text', content: content });
}
return parts;
};
const PostDetail = ({ route, navigation }) => {
const { post: initialPost, channel } = route.params;
const dispatch = useDispatch();
const insets = useSafeAreaInsets();
const { currentPost, replies, loading } = useSelector((state) => state.community);
const postReplies = replies[initialPost.id] || [];
const [replyText, setReplyText] = useState('');
const [isSending, setIsSending] = useState(false);
const [isLiked, setIsLiked] = useState(false);
const [likeCount, setLikeCount] = useState(initialPost.likeCount || 0);
const post = currentPost || initialPost;
// 设置导航
useEffect(() => {
navigation.setOptions({
headerShown: true,
headerTitle: '',
headerStyle: {
backgroundColor: '#0F172A',
elevation: 0,
shadowOpacity: 0,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
},
headerTintColor: '#FFFFFF',
});
}, [navigation]);
// 加载帖子详情和回复
useEffect(() => {
dispatch(fetchPostDetail(initialPost.id));
dispatch(fetchReplies({ postId: initialPost.id }));
return () => {
dispatch(clearCurrentPost());
};
}, [dispatch, initialPost.id]);
// 点赞
const handleLike = useCallback(async () => {
if (isLiked) return;
setIsLiked(true);
setLikeCount((prev) => prev + 1);
try {
await postService.likePost(post.id);
} catch (error) {
// 回滚
setIsLiked(false);
setLikeCount((prev) => prev - 1);
console.error('点赞失败:', error);
}
}, [post.id, isLiked]);
// 发送回复
const handleSendReply = useCallback(async () => {
const content = replyText.trim();
if (!content || isSending) return;
setIsSending(true);
Keyboard.dismiss();
try {
await dispatch(
createReply({
postId: post.id,
data: { content },
})
).unwrap();
setReplyText('');
} catch (error) {
console.error('回复失败:', error);
} finally {
setIsSending(false);
}
}, [dispatch, post.id, replyText, isSending]);
// 渲染帖子主体
const renderPostContent = () => (
<Box px={4} py={4}>
{/* 作者信息 */}
<HStack alignItems="center" mb={4}>
<Avatar
size="md"
bg="primary.600"
source={post.authorAvatar ? { uri: post.authorAvatar } : null}
>
{post.authorName?.[0]?.toUpperCase() || '?'}
</Avatar>
<VStack ml={3} flex={1}>
<Text fontSize="md" fontWeight="bold" color="white">
{post.authorName || '匿名用户'}
</Text>
<Text fontSize="xs" color="gray.500">
{formatRelativeTime(post.createdAt)}
</Text>
</VStack>
<Pressable p={2}>
<Icon as={Ionicons} name="ellipsis-horizontal" size="sm" color="gray.500" />
</Pressable>
</HStack>
{/* 标题 */}
<Text fontSize="xl" fontWeight="bold" color="white" mb={3}>
{post.title}
</Text>
{/* 标签 */}
{post.tags && post.tags.length > 0 && (
<HStack space={2} mb={4} flexWrap="wrap">
{post.tags.map((tag, index) => (
<Box
key={index}
bg="rgba(124, 58, 237, 0.15)"
rounded="full"
px={3}
py={1}
>
<Text fontSize="xs" color="primary.300">
#{tag}
</Text>
</Box>
))}
</HStack>
)}
{/* 内容(支持 Markdown 图片) */}
<VStack space={3} mb={4}>
{parseMarkdownContent(post.content).map((part, index) => {
if (part.type === 'text') {
return (
<Text key={`text-${index}`} fontSize="sm" color="gray.300" lineHeight="xl">
{part.content}
</Text>
);
}
if (part.type === 'image') {
return (
<Pressable key={`img-${index}`}>
<Image
source={{ uri: part.uri }}
style={{
width: screenWidth - 32,
height: (screenWidth - 32) * 0.75,
borderRadius: 12,
}}
resizeMode="contain"
/>
</Pressable>
);
}
return null;
})}
</VStack>
{/* 额外的图片(如果有单独的 images 字段) */}
{post.images && post.images.length > 0 ? (
<HStack space={2} mb={4} flexWrap="wrap">
{post.images.map((image, index) => (
<Pressable key={index}>
<Image
source={{ uri: image }}
style={{
width: 96,
height: 96,
borderRadius: 8,
}}
resizeMode="cover"
/>
</Pressable>
))}
</HStack>
) : null}
{/* 互动栏 */}
<HStack
alignItems="center"
justifyContent="space-between"
pt={4}
borderTopWidth={1}
borderTopColor="rgba(255, 255, 255, 0.05)"
>
<HStack space={6}>
{/* 点赞 */}
<Pressable onPress={handleLike}>
<HStack alignItems="center" space={1}>
<Icon
as={Ionicons}
name={isLiked ? 'heart' : 'heart-outline'}
size="sm"
color={isLiked ? 'red.500' : 'gray.500'}
/>
<Text fontSize="sm" color={isLiked ? 'red.500' : 'gray.500'}>
{likeCount}
</Text>
</HStack>
</Pressable>
{/* 回复数 */}
<HStack alignItems="center" space={1}>
<Icon as={Ionicons} name="chatbubble-outline" size="sm" color="gray.500" />
<Text fontSize="sm" color="gray.500">
{post.replyCount || postReplies.length}
</Text>
</HStack>
{/* 浏览数 */}
<HStack alignItems="center" space={1}>
<Icon as={Ionicons} name="eye-outline" size="sm" color="gray.500" />
<Text fontSize="sm" color="gray.500">
{post.viewCount || 0}
</Text>
</HStack>
</HStack>
{/* 分享 */}
<Pressable>
<Icon as={Ionicons} name="share-outline" size="sm" color="gray.500" />
</Pressable>
</HStack>
</Box>
);
// 渲染回复列表
const renderReplies = () => (
<VStack>
{/* 回复标题 */}
<HStack px={4} py={3} alignItems="center" bg="rgba(255, 255, 255, 0.02)">
<Icon as={Ionicons} name="chatbubbles-outline" size="sm" color="gray.400" mr={2} />
<Text fontSize="sm" fontWeight="bold" color="gray.400">
回复 ({postReplies.length})
</Text>
</HStack>
{/* 回复列表 */}
{loading.replies ? (
<Center py={8}>
<Spinner size="sm" color="primary.500" />
</Center>
) : postReplies.length === 0 ? (
<Center py={8}>
<Text fontSize="sm" color="gray.500">
暂无回复来说两句吧
</Text>
</Center>
) : (
postReplies.map((reply, index) => (
<Box key={reply.id} px={4} py={3}>
<HStack alignItems="flex-start">
<Avatar
size="sm"
bg="primary.600"
source={reply.authorAvatar ? { uri: reply.authorAvatar } : null}
>
{reply.authorName?.[0]?.toUpperCase() || '?'}
</Avatar>
<VStack flex={1} ml={3}>
<HStack alignItems="center" space={2}>
<Text fontSize="sm" fontWeight="bold" color="white">
{reply.authorName || '匿名用户'}
</Text>
<Text fontSize="2xs" color="gray.500">
{formatRelativeTime(reply.createdAt)}
</Text>
</HStack>
<Text fontSize="sm" color="gray.300" mt={1}>
{reply.content}
</Text>
<HStack alignItems="center" space={4} mt={2}>
<Pressable>
<HStack alignItems="center" space={1}>
<Icon as={Ionicons} name="heart-outline" size="xs" color="gray.500" />
<Text fontSize="xs" color="gray.500">
{reply.likeCount || 0}
</Text>
</HStack>
</Pressable>
<Pressable>
<Text fontSize="xs" color="gray.500">
回复
</Text>
</Pressable>
</HStack>
</VStack>
</HStack>
{index < postReplies.length - 1 && (
<Divider mt={3} bg="rgba(255, 255, 255, 0.05)" />
)}
</Box>
))
)}
</VStack>
);
// 渲染回复输入框
const renderReplyInput = () => (
<Box
px={4}
py={3}
bg="#0F172A"
borderTopWidth={1}
borderTopColor="rgba(255, 255, 255, 0.1)"
pb={insets.bottom + 12}
>
<HStack space={2} alignItems="flex-end">
<Box
flex={1}
bg="rgba(255, 255, 255, 0.05)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="xl"
px={3}
py={Platform.OS === 'ios' ? 2 : 0}
>
<TextInput
placeholder="写下你的回复..."
value={replyText}
onChangeText={setReplyText}
style={styles.textInput}
placeholderTextColor="#6B7280"
multiline
maxLength={2000}
textAlignVertical="center"
returnKeyType="default"
blurOnSubmit={false}
autoCapitalize="none"
autoCorrect={false}
editable={true}
keyboardType="default"
contextMenuHidden={false}
/>
</Box>
<Pressable
onPress={handleSendReply}
p={2}
opacity={replyText.trim() ? 1 : 0.5}
disabled={!replyText.trim() || isSending}
>
{isSending ? (
<Spinner size="sm" color="primary.500" />
) : (
<Icon
as={Ionicons}
name="send"
size="md"
color={replyText.trim() ? 'primary.500' : 'gray.500'}
/>
)}
</Pressable>
</HStack>
</Box>
);
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
>
<Box flex={1} bg="#0F172A">
<ScrollView showsVerticalScrollIndicator={false}>
{/* 帖子内容 */}
{renderPostContent()}
<Divider bg="rgba(255, 255, 255, 0.05)" />
{/* 回复列表 */}
{renderReplies()}
<Box h={20} />
</ScrollView>
{/* 回复输入框 */}
{renderReplyInput()}
</Box>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0F172A',
},
textInput: {
flex: 1,
color: '#FFFFFF',
fontSize: 14,
maxHeight: 100,
minHeight: 36,
paddingVertical: Platform.OS === 'ios' ? 8 : 10,
paddingHorizontal: 0,
},
});
export default PostDetail;

View File

@@ -0,0 +1,109 @@
/**
* 社区主入口
* Discord 风格的社区论坛
*/
import React, { useState } from 'react';
import { Box, HStack, Pressable, Text } from 'native-base';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { StatusBar } from 'react-native';
import { useSelector } from 'react-redux';
import ChannelList from './ChannelList';
import Notifications from './Notifications';
// 自定义 Tab Bar
const CustomTabBar = ({ activeTab, onTabChange, unreadCount }) => {
const tabs = [
{ key: 'channels', label: '频道' },
{ key: 'notifications', label: '通知', badge: unreadCount },
];
return (
<HStack
bg="#0F172A"
borderBottomWidth={1}
borderBottomColor="rgba(255, 255, 255, 0.1)"
>
{tabs.map((tab) => (
<Pressable
key={tab.key}
flex={1}
py={3}
onPress={() => onTabChange(tab.key)}
>
<Box alignItems="center">
<HStack alignItems="center" space={1}>
<Text
fontSize="sm"
fontWeight={activeTab === tab.key ? 'bold' : 'normal'}
color={activeTab === tab.key ? 'white' : 'gray.500'}
>
{tab.label}
</Text>
{tab.badge > 0 ? (
<Box bg="red.500" rounded="full" px={1.5} minW={5} alignItems="center">
<Text fontSize="2xs" fontWeight="bold" color="white">
{tab.badge > 99 ? '99+' : String(tab.badge)}
</Text>
</Box>
) : null}
</HStack>
{activeTab === tab.key && (
<Box
position="absolute"
bottom={-12}
w="50%"
h={0.5}
bg="#7C3AED"
borderRadius={1}
/>
)}
</Box>
</Pressable>
))}
</HStack>
);
};
const CommunityHome = ({ navigation }) => {
const insets = useSafeAreaInsets();
const [activeTab, setActiveTab] = useState('channels');
const communityState = useSelector((state) => state.community);
const unreadCount = communityState?.unreadCount || 0;
return (
<Box flex={1} bg="#0F172A" pt={insets.top}>
<StatusBar barStyle="light-content" />
{/* 自定义 Tab Bar */}
<CustomTabBar
activeTab={activeTab}
onTabChange={setActiveTab}
unreadCount={unreadCount}
/>
{/* Tab 内容 */}
<Box flex={1}>
{activeTab === 'channels' ? (
<ChannelList navigation={navigation} />
) : (
<Notifications navigation={navigation} />
)}
</Box>
</Box>
);
};
export default CommunityHome;
// 导出所有社区相关页面
export { default as ChannelList } from './ChannelList';
export { default as ChannelDetail } from './ChannelDetail';
export { default as ForumChannel } from './ForumChannel';
export { default as PostDetail } from './PostDetail';
export { default as CreatePost } from './CreatePost';
export { default as CreateChannel } from './CreateChannel';
export { default as MemberList } from './MemberList';
export { default as Notifications } from './Notifications';

View File

@@ -368,7 +368,7 @@ const MarketHot = ({ navigation }) => {
<ScrollView <ScrollView
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16 }} contentContainerStyle={{ paddingLeft: 16, paddingRight: 32 }}
> >
<HStack space={3}> <HStack space={3}>
{hotSectors.map((sector, index) => { {hotSectors.map((sector, index) => {

View File

@@ -93,8 +93,14 @@ const SectorDetail = ({ route, navigation }) => {
// 点击事件 // 点击事件
const handleEventPress = useCallback((event) => { const handleEventPress = useCallback((event) => {
// 兼容不同的事件ID字段名: id, event_id
const eventId = event.id || event.event_id;
if (!eventId) {
console.warn('事件ID不存在:', event);
return;
}
navigation.navigate('EventDetail', { navigation.navigate('EventDetail', {
eventId: event.id, eventId,
title: event.title, title: event.title,
}); });
}, [navigation]); }, [navigation]);

View File

@@ -0,0 +1,637 @@
/**
* 社区服务层
* 处理频道、消息、帖子、成员等 API 调用
* 与 Web 端保持一致,使用 ElasticSearch API 读取数据
*/
import { apiRequest, API_BASE_URL } from './api';
// ES API 基础路径
const ES_API_BASE = `${API_BASE_URL}/api/community/es`;
// 频道类型
export const CHANNEL_TYPES = {
TEXT: 'text',
FORUM: 'forum',
ANNOUNCEMENT: 'announcement',
};
// 频道分类
export const CHANNEL_CATEGORIES = [
{ id: 'general', name: '综合讨论', icon: 'chatbubbles' },
{ id: 'hot', name: '热门板块', icon: 'flame' },
{ id: 'announcement', name: '公告频道', icon: 'megaphone' },
];
/**
* 频道服务
*/
export const channelService = {
/**
* 获取频道列表
* @returns {Promise<object>} 频道列表(按分类分组)
*/
getChannels: async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/community/channels`);
if (!response.ok) {
console.warn('[CommunityService] 获取频道失败,使用默认数据');
return getDefaultChannels();
}
const data = await response.json();
return { categories: data.data || [] };
} catch (error) {
console.warn('[CommunityService] getChannels: 使用默认数据');
return getDefaultChannels();
}
},
/**
* 获取单个频道详情
*/
getChannelDetail: async (channelId) => {
try {
const response = await fetch(`${API_BASE_URL}/api/community/channels/${channelId}`);
if (!response.ok) throw new Error('获取频道详情失败');
const data = await response.json();
return data;
} catch (error) {
console.warn('[CommunityService] getChannelDetail 错误:', error);
throw error;
}
},
/**
* 订阅频道
*/
subscribeChannel: async (channelId) => {
try {
const response = await fetch(`${API_BASE_URL}/api/community/channels/${channelId}/subscribe`, {
method: 'POST',
credentials: 'include',
});
if (!response.ok) throw new Error('订阅频道失败');
} catch (error) {
console.warn('[CommunityService] subscribeChannel 错误:', error);
throw error;
}
},
/**
* 取消订阅频道
*/
unsubscribeChannel: async (channelId) => {
try {
const response = await fetch(`${API_BASE_URL}/api/community/channels/${channelId}/unsubscribe`, {
method: 'POST',
credentials: 'include',
});
if (!response.ok) throw new Error('取消订阅失败');
} catch (error) {
console.warn('[CommunityService] unsubscribeChannel 错误:', error);
throw error;
}
},
/**
* 创建新频道
*/
createChannel: async (data) => {
try {
const response = await fetch(`${API_BASE_URL}/api/community/channels`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || '创建频道失败');
}
const result = await response.json();
return result.data;
} catch (error) {
console.warn('[CommunityService] createChannel 错误:', error);
throw error;
}
},
};
/**
* 消息服务 - 使用 ElasticSearch API
*/
export const messageService = {
/**
* 获取频道消息
*/
getMessages: async (channelId, options = {}) => {
try {
const { before, after, limit = 50 } = options;
// 构建 ES 查询
const mustClauses = [
{ match: { channel_id: channelId } },
];
if (before) {
mustClauses.push({ range: { created_at: { lt: before } } });
}
if (after) {
mustClauses.push({ range: { created_at: { gt: after } } });
}
const esBody = {
query: {
bool: {
must: mustClauses,
must_not: [{ match: { is_deleted: true } }],
},
},
sort: [{ created_at: 'desc' }],
size: limit,
};
const response = await fetch(`${ES_API_BASE}/community_messages/_search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(esBody),
});
if (!response.ok) {
console.warn('[CommunityService] getMessages 失败');
return { data: [], hasMore: false };
}
const data = await response.json();
// 转换 ES 返回的 snake_case 字段为 camelCase
const items = data.hits.hits.map((hit) => {
const source = hit._source;
return {
id: hit._id,
channelId: source.channel_id,
authorId: source.author_id,
authorName: source.author_name,
authorAvatar: source.author_avatar,
content: source.content,
contentHtml: source.content_html,
type: source.type,
replyTo: source.reply_to,
reactions: source.reactions || {},
isPinned: source.is_pinned,
isEdited: source.is_edited,
createdAt: source.created_at,
};
});
// 反转顺序(最旧的在前)
items.reverse();
return {
data: items,
hasMore: data.hits.total.value > items.length,
};
} catch (error) {
console.warn('[CommunityService] getMessages 错误:', error);
return { data: [], hasMore: false };
}
},
/**
* 发送消息
*/
sendMessage: async (channelId, data) => {
try {
const response = await fetch(`${API_BASE_URL}/api/community/channels/${channelId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('发送消息失败');
const result = await response.json();
return { data: result.data };
} catch (error) {
console.warn('[CommunityService] sendMessage 错误:', error);
throw error;
}
},
/**
* 删除消息
*/
deleteMessage: async (messageId) => {
try {
const response = await fetch(`${API_BASE_URL}/api/community/messages/${messageId}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error('删除消息失败');
} catch (error) {
console.warn('[CommunityService] deleteMessage 错误:', error);
throw error;
}
},
/**
* 添加表情反应
*/
addReaction: async (messageId, emoji) => {
try {
const response = await fetch(`${API_BASE_URL}/api/community/messages/${messageId}/reactions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ emoji }),
});
if (!response.ok) throw new Error('添加表情失败');
} catch (error) {
console.warn('[CommunityService] addReaction 错误:', error);
throw error;
}
},
};
/**
* 帖子服务 - 使用 ElasticSearch API
*/
export const postService = {
/**
* 获取帖子列表
*/
getPosts: async (channelId, options = {}) => {
try {
const { page = 1, limit = 20, sortBy = 'latest' } = options;
// 排序映射
const sortMap = {
latest: { created_at: 'desc' },
hot: { view_count: 'desc' },
most_replies: { reply_count: 'desc' },
};
const esBody = {
query: {
bool: {
must: [
{ term: { channel_id: channelId } },
{ term: { is_deleted: false } },
],
},
},
sort: [
{ is_pinned: 'desc' },
sortMap[sortBy] || sortMap.latest,
],
from: (page - 1) * limit,
size: limit,
};
const response = await fetch(`${ES_API_BASE}/community_forum_posts/_search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(esBody),
});
if (!response.ok) {
console.warn('[CommunityService] getPosts 失败');
return { data: [], hasMore: false };
}
const data = await response.json();
const items = data.hits.hits.map((hit) => {
const source = hit._source;
return {
id: hit._id,
channelId: source.channel_id,
authorId: source.author_id,
authorName: source.author_name,
authorAvatar: source.author_avatar,
title: source.title,
content: source.content,
contentHtml: source.content_html,
tags: source.tags || [],
isPinned: source.is_pinned,
isLocked: source.is_locked,
replyCount: source.reply_count || 0,
viewCount: source.view_count || 0,
likeCount: source.like_count || 0,
lastReplyAt: source.last_reply_at,
createdAt: source.created_at,
};
});
return {
data: items,
hasMore: page * limit < data.hits.total.value,
};
} catch (error) {
console.warn('[CommunityService] getPosts 错误:', error);
return { data: [], hasMore: false };
}
},
/**
* 获取帖子详情
*/
getPostDetail: async (postId) => {
try {
const response = await fetch(`${API_BASE_URL}/api/community/posts/${postId}`);
if (!response.ok) throw new Error('获取帖子详情失败');
const result = await response.json();
return { data: result.data };
} catch (error) {
console.warn('[CommunityService] getPostDetail 错误:', error);
throw error;
}
},
/**
* 创建帖子
*/
createPost: async (channelId, data) => {
try {
const response = await fetch(`${API_BASE_URL}/api/community/channels/${channelId}/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('发布帖子失败');
const result = await response.json();
return { data: result.data };
} catch (error) {
console.warn('[CommunityService] createPost 错误:', error);
throw error;
}
},
/**
* 获取帖子回复
*/
getReplies: async (postId, options = {}) => {
try {
const { page = 1, limit = 20 } = options;
const esBody = {
query: {
bool: {
must: [
{ term: { post_id: postId } },
{ term: { is_deleted: false } },
],
},
},
sort: [{ created_at: 'asc' }],
from: (page - 1) * limit,
size: limit,
};
const response = await fetch(`${ES_API_BASE}/community_forum_replies/_search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(esBody),
});
if (!response.ok) {
console.warn('[CommunityService] getReplies 失败');
return { data: [], hasMore: false };
}
const data = await response.json();
const items = data.hits.hits.map((hit) => {
const source = hit._source;
return {
id: hit._id,
postId: source.post_id,
channelId: source.channel_id,
authorId: source.author_id,
authorName: source.author_name,
authorAvatar: source.author_avatar,
content: source.content,
contentHtml: source.content_html,
replyTo: source.reply_to,
reactions: source.reactions || {},
likeCount: source.like_count || 0,
createdAt: source.created_at,
};
});
return {
data: items,
hasMore: page * limit < data.hits.total.value,
};
} catch (error) {
console.warn('[CommunityService] getReplies 错误:', error);
return { data: [], hasMore: false };
}
},
/**
* 创建回复
*/
createReply: async (postId, data) => {
try {
const response = await fetch(`${API_BASE_URL}/api/community/posts/${postId}/replies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('回复失败');
const result = await response.json();
return { data: result.data };
} catch (error) {
console.warn('[CommunityService] createReply 错误:', error);
throw error;
}
},
/**
* 点赞帖子
*/
likePost: async (postId) => {
try {
const response = await fetch(`${API_BASE_URL}/api/community/posts/${postId}/like`, {
method: 'POST',
credentials: 'include',
});
if (!response.ok) throw new Error('点赞失败');
} catch (error) {
console.warn('[CommunityService] likePost 错误:', error);
throw error;
}
},
/**
* 增加帖子浏览量
*/
incrementView: async (postId) => {
try {
await fetch(`${API_BASE_URL}/api/community/posts/${postId}/view`, {
method: 'POST',
});
} catch (error) {
// 静默失败
}
},
};
/**
* 成员服务
*/
export const memberService = {
/**
* 获取在线成员列表
*/
getMembers: async (channelId, options = {}) => {
try {
const response = await fetch(`${API_BASE_URL}/api/community/members/online`, {
credentials: 'include',
});
if (!response.ok) {
return { data: [] };
}
const data = await response.json();
return { data: data.data || [] };
} catch (error) {
console.warn('[CommunityService] getMembers 错误:', error);
return { data: [] };
}
},
};
/**
* 通知服务
*/
export const notificationService = {
/**
* 获取通知列表
*/
getNotifications: async (options = {}) => {
// TODO: 实现通知 API
return { data: [], hasMore: false };
},
/**
* 标记通知为已读
*/
markAsRead: async (notificationId) => {
// TODO: 实现标记已读 API
},
/**
* 标记所有通知为已读
*/
markAllAsRead: async () => {
// TODO: 实现标记全部已读 API
},
/**
* 获取未读通知数量
*/
getUnreadCount: async () => {
return 0;
},
};
/**
* 获取默认频道列表(用于 API 失败时的回退)
*/
function getDefaultChannels() {
return {
categories: [
{
id: 'general',
name: '综合讨论',
icon: 'chatbubbles',
channels: [
{
id: 'general-chat',
name: '综合交流',
type: CHANNEL_TYPES.TEXT,
topic: '自由交流,分享观点',
subscriberCount: 1280,
messageCount: 5620,
unreadCount: 0,
},
{
id: 'general-forum',
name: '投资心得',
type: CHANNEL_TYPES.FORUM,
topic: '分享您的投资经验',
subscriberCount: 856,
postCount: 342,
unreadCount: 0,
},
],
},
{
id: 'hot',
name: '热门板块',
icon: 'flame',
channels: [
{
id: 'ai-chip',
name: 'AI芯片',
type: CHANNEL_TYPES.TEXT,
topic: '人工智能芯片讨论',
subscriberCount: 2340,
messageCount: 8920,
unreadCount: 12,
},
{
id: 'new-energy',
name: '新能源',
type: CHANNEL_TYPES.TEXT,
topic: '新能源行业动态',
subscriberCount: 1890,
messageCount: 6750,
unreadCount: 5,
},
{
id: 'semiconductor',
name: '半导体',
type: CHANNEL_TYPES.TEXT,
topic: '半导体产业链讨论',
subscriberCount: 1560,
messageCount: 4320,
unreadCount: 0,
},
],
},
{
id: 'announcement',
name: '公告频道',
icon: 'megaphone',
channels: [
{
id: 'announcements',
name: '官方公告',
type: CHANNEL_TYPES.ANNOUNCEMENT,
topic: '平台公告与重要通知',
subscriberCount: 5200,
messageCount: 128,
unreadCount: 1,
},
],
},
],
};
}
export default {
channelService,
messageService,
postService,
memberService,
notificationService,
CHANNEL_TYPES,
CHANNEL_CATEGORIES,
};

View File

@@ -6,12 +6,14 @@ import { configureStore } from '@reduxjs/toolkit';
import eventsReducer from './slices/eventsSlice'; import eventsReducer from './slices/eventsSlice';
import watchlistReducer from './slices/watchlistSlice'; import watchlistReducer from './slices/watchlistSlice';
import stockReducer from './slices/stockSlice'; import stockReducer from './slices/stockSlice';
import communityReducer from './slices/communitySlice';
const store = configureStore({ const store = configureStore({
reducer: { reducer: {
events: eventsReducer, events: eventsReducer,
watchlist: watchlistReducer, watchlist: watchlistReducer,
stock: stockReducer, stock: stockReducer,
community: communityReducer,
}, },
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ getDefaultMiddleware({

View File

@@ -0,0 +1,513 @@
/**
* 社区 Redux Slice
* 管理频道、消息、帖子、成员、通知状态
*/
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import {
channelService,
messageService,
postService,
memberService,
notificationService,
} from '../../services/communityService';
// ============ Async Thunks ============
// 获取频道列表
export const fetchChannels = createAsyncThunk(
'community/fetchChannels',
async (_, { rejectWithValue }) => {
try {
const data = await channelService.getChannels();
return data;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// 获取频道消息
export const fetchMessages = createAsyncThunk(
'community/fetchMessages',
async ({ channelId, options = {} }, { rejectWithValue }) => {
try {
const response = await messageService.getMessages(channelId, options);
return { channelId, messages: response.data || [], hasMore: response.hasMore };
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// 发送消息
export const sendMessage = createAsyncThunk(
'community/sendMessage',
async ({ channelId, data }, { rejectWithValue }) => {
try {
const response = await messageService.sendMessage(channelId, data);
return { channelId, message: response.data };
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// 获取帖子列表
export const fetchPosts = createAsyncThunk(
'community/fetchPosts',
async ({ channelId, options = {} }, { rejectWithValue }) => {
try {
const response = await postService.getPosts(channelId, options);
return { channelId, posts: response.data || [], hasMore: response.hasMore };
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// 获取帖子详情
export const fetchPostDetail = createAsyncThunk(
'community/fetchPostDetail',
async (postId, { rejectWithValue }) => {
try {
const response = await postService.getPostDetail(postId);
return response.data;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// 创建帖子
export const createPost = createAsyncThunk(
'community/createPost',
async ({ channelId, data }, { rejectWithValue }) => {
try {
const response = await postService.createPost(channelId, data);
return { channelId, post: response.data };
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// 获取帖子回复
export const fetchReplies = createAsyncThunk(
'community/fetchReplies',
async ({ postId, options = {} }, { rejectWithValue }) => {
try {
const response = await postService.getReplies(postId, options);
return { postId, replies: response.data || [], hasMore: response.hasMore };
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// 创建回复
export const createReply = createAsyncThunk(
'community/createReply',
async ({ postId, data }, { rejectWithValue }) => {
try {
const response = await postService.createReply(postId, data);
return { postId, reply: response.data };
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// 获取成员列表
export const fetchMembers = createAsyncThunk(
'community/fetchMembers',
async ({ channelId, options = {} }, { rejectWithValue }) => {
try {
const response = await memberService.getMembers(channelId, options);
return { channelId, members: response.data || [] };
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// 获取通知列表
export const fetchNotifications = createAsyncThunk(
'community/fetchNotifications',
async (options = {}, { rejectWithValue }) => {
try {
const response = await notificationService.getNotifications(options);
return { notifications: response.data || [], hasMore: response.hasMore };
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// 标记通知已读
export const markNotificationRead = createAsyncThunk(
'community/markNotificationRead',
async (notificationId, { rejectWithValue }) => {
try {
await notificationService.markAsRead(notificationId);
return notificationId;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// 标记所有通知已读
export const markAllNotificationsRead = createAsyncThunk(
'community/markAllNotificationsRead',
async (_, { rejectWithValue }) => {
try {
await notificationService.markAllAsRead();
return true;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// ============ Initial State ============
const initialState = {
// 频道相关
categories: [],
currentChannel: null,
// 消息相关
messages: {}, // { channelId: [messages] }
messagesHasMore: {}, // { channelId: boolean }
// 帖子相关
posts: {}, // { channelId: [posts] }
postsHasMore: {}, // { channelId: boolean }
currentPost: null,
replies: {}, // { postId: [replies] }
repliesHasMore: {}, // { postId: boolean }
// 成员相关
members: {}, // { channelId: [members] }
onlineMembers: {}, // { channelId: [memberIds] }
// 通知相关
notifications: [],
notificationsHasMore: true,
unreadCount: 0,
// 加载状态
loading: {
channels: false,
messages: false,
posts: false,
postDetail: false,
replies: false,
members: false,
notifications: false,
sending: false,
},
// 错误状态
error: null,
// 输入状态
typingUsers: {}, // { channelId: [{ userId, username }] }
};
// ============ Slice ============
const communitySlice = createSlice({
name: 'community',
initialState,
reducers: {
// 设置当前频道
setCurrentChannel: (state, action) => {
state.currentChannel = action.payload;
},
// 清除当前频道
clearCurrentChannel: (state) => {
state.currentChannel = null;
},
// 设置当前帖子
setCurrentPost: (state, action) => {
state.currentPost = action.payload;
},
// 清除当前帖子
clearCurrentPost: (state) => {
state.currentPost = null;
},
// 添加新消息WebSocket 实时消息)
addMessage: (state, action) => {
const { channelId, message } = action.payload;
if (!state.messages[channelId]) {
state.messages[channelId] = [];
}
// 检查消息是否已存在
const exists = state.messages[channelId].some(m => m.id === message.id);
if (!exists) {
state.messages[channelId].push(message);
}
},
// 删除消息
removeMessage: (state, action) => {
const { channelId, messageId } = action.payload;
if (state.messages[channelId]) {
state.messages[channelId] = state.messages[channelId].filter(
m => m.id !== messageId
);
}
},
// 更新消息反应
updateMessageReactions: (state, action) => {
const { channelId, messageId, reactions } = action.payload;
if (state.messages[channelId]) {
const message = state.messages[channelId].find(m => m.id === messageId);
if (message) {
message.reactions = reactions;
}
}
},
// 设置输入状态
setTypingUsers: (state, action) => {
const { channelId, users } = action.payload;
state.typingUsers[channelId] = users;
},
// 添加输入用户
addTypingUser: (state, action) => {
const { channelId, user } = action.payload;
if (!state.typingUsers[channelId]) {
state.typingUsers[channelId] = [];
}
const exists = state.typingUsers[channelId].some(u => u.userId === user.userId);
if (!exists) {
state.typingUsers[channelId].push(user);
}
},
// 移除输入用户
removeTypingUser: (state, action) => {
const { channelId, userId } = action.payload;
if (state.typingUsers[channelId]) {
state.typingUsers[channelId] = state.typingUsers[channelId].filter(
u => u.userId !== userId
);
}
},
// 更新未读数量
setUnreadCount: (state, action) => {
state.unreadCount = action.payload;
},
// 增加未读数量
incrementUnreadCount: (state) => {
state.unreadCount += 1;
},
// 清除错误
clearError: (state) => {
state.error = null;
},
// 重置状态
resetCommunityState: () => initialState,
},
extraReducers: (builder) => {
builder
// 获取频道列表
.addCase(fetchChannels.pending, (state) => {
state.loading.channels = true;
state.error = null;
})
.addCase(fetchChannels.fulfilled, (state, action) => {
state.loading.channels = false;
state.categories = action.payload.categories || [];
})
.addCase(fetchChannels.rejected, (state, action) => {
state.loading.channels = false;
state.error = action.payload;
})
// 获取消息
.addCase(fetchMessages.pending, (state) => {
state.loading.messages = true;
state.error = null;
})
.addCase(fetchMessages.fulfilled, (state, action) => {
state.loading.messages = false;
const { channelId, messages, hasMore } = action.payload;
// 合并消息prepend 旧消息)
const existingMessages = state.messages[channelId] || [];
const newMessageIds = new Set(messages.map(m => m.id));
const filteredExisting = existingMessages.filter(m => !newMessageIds.has(m.id));
state.messages[channelId] = [...messages, ...filteredExisting];
state.messagesHasMore[channelId] = hasMore;
})
.addCase(fetchMessages.rejected, (state, action) => {
state.loading.messages = false;
state.error = action.payload;
})
// 发送消息
.addCase(sendMessage.pending, (state) => {
state.loading.sending = true;
})
.addCase(sendMessage.fulfilled, (state, action) => {
state.loading.sending = false;
const { channelId, message } = action.payload;
if (!state.messages[channelId]) {
state.messages[channelId] = [];
}
state.messages[channelId].push(message);
})
.addCase(sendMessage.rejected, (state, action) => {
state.loading.sending = false;
state.error = action.payload;
})
// 获取帖子列表
.addCase(fetchPosts.pending, (state) => {
state.loading.posts = true;
state.error = null;
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.loading.posts = false;
const { channelId, posts, hasMore } = action.payload;
state.posts[channelId] = posts;
state.postsHasMore[channelId] = hasMore;
})
.addCase(fetchPosts.rejected, (state, action) => {
state.loading.posts = false;
state.error = action.payload;
})
// 获取帖子详情
.addCase(fetchPostDetail.pending, (state) => {
state.loading.postDetail = true;
state.error = null;
})
.addCase(fetchPostDetail.fulfilled, (state, action) => {
state.loading.postDetail = false;
state.currentPost = action.payload;
})
.addCase(fetchPostDetail.rejected, (state, action) => {
state.loading.postDetail = false;
state.error = action.payload;
})
// 创建帖子
.addCase(createPost.fulfilled, (state, action) => {
const { channelId, post } = action.payload;
if (!state.posts[channelId]) {
state.posts[channelId] = [];
}
state.posts[channelId].unshift(post);
})
// 获取回复
.addCase(fetchReplies.pending, (state) => {
state.loading.replies = true;
})
.addCase(fetchReplies.fulfilled, (state, action) => {
state.loading.replies = false;
const { postId, replies, hasMore } = action.payload;
state.replies[postId] = replies;
state.repliesHasMore[postId] = hasMore;
})
.addCase(fetchReplies.rejected, (state, action) => {
state.loading.replies = false;
state.error = action.payload;
})
// 创建回复
.addCase(createReply.fulfilled, (state, action) => {
const { postId, reply } = action.payload;
if (!state.replies[postId]) {
state.replies[postId] = [];
}
state.replies[postId].push(reply);
// 更新帖子回复数
if (state.currentPost && state.currentPost.id === postId) {
state.currentPost.replyCount = (state.currentPost.replyCount || 0) + 1;
}
})
// 获取成员
.addCase(fetchMembers.pending, (state) => {
state.loading.members = true;
})
.addCase(fetchMembers.fulfilled, (state, action) => {
state.loading.members = false;
const { channelId, members } = action.payload;
state.members[channelId] = members;
})
.addCase(fetchMembers.rejected, (state, action) => {
state.loading.members = false;
state.error = action.payload;
})
// 获取通知
.addCase(fetchNotifications.pending, (state) => {
state.loading.notifications = true;
})
.addCase(fetchNotifications.fulfilled, (state, action) => {
state.loading.notifications = false;
const { notifications, hasMore } = action.payload;
state.notifications = notifications;
state.notificationsHasMore = hasMore;
})
.addCase(fetchNotifications.rejected, (state, action) => {
state.loading.notifications = false;
state.error = action.payload;
})
// 标记通知已读
.addCase(markNotificationRead.fulfilled, (state, action) => {
const notificationId = action.payload;
const notification = state.notifications.find(n => n.id === notificationId);
if (notification && !notification.isRead) {
notification.isRead = true;
state.unreadCount = Math.max(0, state.unreadCount - 1);
}
})
// 标记所有通知已读
.addCase(markAllNotificationsRead.fulfilled, (state) => {
state.notifications.forEach(n => {
n.isRead = true;
});
state.unreadCount = 0;
});
},
});
// 导出 actions
export const {
setCurrentChannel,
clearCurrentChannel,
setCurrentPost,
clearCurrentPost,
addMessage,
removeMessage,
updateMessageReactions,
setTypingUsers,
addTypingUser,
removeTypingUser,
setUnreadCount,
incrementUnreadCount,
clearError,
resetCommunityState,
} = communitySlice.actions;
// 导出 reducer
export default communitySlice.reducer;

View File

@@ -89,7 +89,8 @@ MODEL_CONFIGS = {
"api_key": "", # 空值 "api_key": "", # 空值
"base_url": "http://111.62.35.50:8000/v1", "base_url": "http://111.62.35.50:8000/v1",
"model": "deepmoney", "model": "deepmoney",
"max_tokens": 32768, # DeepMoney 本地托管,上下文 65536,输出限制 32768 "max_tokens": 32768, # DeepMoney (MiniMax-M2.1 via vLLM),上下文 84k,输出限制 32768
"context_length": 84000, # 最大上下文长度
}, },
"gemini-3": { "gemini-3": {
"api_key": "", # 需要配置Google API密钥 "api_key": "", # 需要配置Google API密钥
@@ -2890,18 +2891,84 @@ A股交易时间: 上午 9:30-11:30下午 13:00-15:00
stream=True, # 启用流式输出 stream=True, # 启用流式输出
) )
# 状态机处理 <think>...</think> 标签
# 状态: "normal" - 正常内容, "thinking" - 思考内容
parse_state = "normal"
buffer = ""
thinking_content = ""
thinking_sent = False # 标记是否已发送过 thinking 事件
# 逐块发送总结内容 # 逐块发送总结内容
for chunk in summary_stream: for chunk in summary_stream:
if chunk.choices and chunk.choices[0].delta.content: if chunk.choices and chunk.choices[0].delta.content:
content_chunk = chunk.choices[0].delta.content content_chunk = chunk.choices[0].delta.content
final_summary += content_chunk buffer += content_chunk
# 发送总结片段 # 处理 buffer 中的内容
yield self._format_sse("summary_chunk", { while buffer:
"content": content_chunk if parse_state == "normal":
}) # 查找 <think> 开始标签
think_start = buffer.find("<think>")
if think_start != -1:
# 发送 <think> 之前的内容
if think_start > 0:
normal_content = buffer[:think_start]
final_summary += normal_content
yield self._format_sse("summary_chunk", {"content": normal_content})
# 切换到思考状态
parse_state = "thinking"
buffer = buffer[think_start + 7:] # 跳过 <think>
if not thinking_sent:
yield self._format_sse("thinking_start", {"message": "正在深度思考..."})
thinking_sent = True
else:
# 没有 <think> 标签,检查是否可能是不完整的标签
if buffer.endswith("<") or buffer.endswith("<t") or buffer.endswith("<th") or \
buffer.endswith("<thi") or buffer.endswith("<thin") or buffer.endswith("<think"):
# 保留可能不完整的标签,等待更多内容
break
else:
# 发送全部内容
final_summary += buffer
yield self._format_sse("summary_chunk", {"content": buffer})
buffer = ""
logger.info("[Summary] 流式总结完成") elif parse_state == "thinking":
# 查找 </think> 结束标签
think_end = buffer.find("</think>")
if think_end != -1:
# 发送思考内容
if think_end > 0:
think_chunk = buffer[:think_end]
thinking_content += think_chunk
yield self._format_sse("thinking_chunk", {"content": think_chunk})
# 切换回正常状态
parse_state = "normal"
buffer = buffer[think_end + 8:] # 跳过 </think>
yield self._format_sse("thinking_end", {"content": thinking_content})
else:
# 没有 </think> 标签,检查是否可能是不完整的标签
if buffer.endswith("<") or buffer.endswith("</") or buffer.endswith("</t") or \
buffer.endswith("</th") or buffer.endswith("</thi") or buffer.endswith("</thin") or buffer.endswith("</think"):
# 保留可能不完整的标签,等待更多内容
break
else:
# 发送全部思考内容
thinking_content += buffer
yield self._format_sse("thinking_chunk", {"content": buffer})
buffer = ""
# 处理剩余的 buffer
if buffer:
if parse_state == "thinking":
thinking_content += buffer
yield self._format_sse("thinking_chunk", {"content": buffer})
yield self._format_sse("thinking_end", {"content": thinking_content})
else:
final_summary += buffer
yield self._format_sse("summary_chunk", {"content": buffer})
logger.info(f"[Summary] 流式总结完成,思考内容长度: {len(thinking_content)}")
except Exception as llm_error: except Exception as llm_error:
logger.error(f"[Summary] 流式总结失败: {llm_error}") logger.error(f"[Summary] 流式总结失败: {llm_error}")
@@ -3375,10 +3442,11 @@ async def agent_chat_stream(chat_request: AgentChatRequest, request: Request):
tools = all_tools tools = all_tools
logger.info(f"[工具过滤] 使用全部 {len(tools)} 个工具") logger.info(f"[工具过滤] 使用全部 {len(tools)} 个工具")
# ==================== 动态模型选择 ==================== # ==================== 模型配置 ====================
selected_model = chat_request.model or "deepseek" # 强制使用 deepmoney 模型MiniMax-M2.1 via vLLM
model_config = MODEL_CONFIGS.get(selected_model, MODEL_CONFIGS["deepseek"]) selected_model = "deepmoney"
logger.info(f"[模型选择] 使用模型: {selected_model} ({model_config['model']})") model_config = MODEL_CONFIGS["deepmoney"]
logger.info(f"[模型配置] 使用 DeepMoney 模型 (MiniMax-M2.1, 84k context)")
# 返回流式响应 # 返回流式响应
return StreamingResponse( return StreamingResponse(

View File

@@ -1,7 +1,7 @@
// src/views/AgentChat/components/ChatArea/MessageRenderer.js // src/views/AgentChat/components/ChatArea/MessageRenderer.js
// 消息渲染器组件 // 消息渲染器组件
import React from 'react'; import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
Card, Card,
@@ -17,13 +17,156 @@ import {
Text, Text,
Box, Box,
Progress, Progress,
Collapse,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { Cpu, User, Copy, ThumbsUp, ThumbsDown, File, ListChecks, Play, CheckCircle, XCircle, Clock } from 'lucide-react'; import { Cpu, User, Copy, ThumbsUp, ThumbsDown, File, ListChecks, Play, CheckCircle, XCircle, Clock, Brain, ChevronDown, ChevronUp, Sparkles } from 'lucide-react';
import { MessageTypes } from '../../constants/messageTypes'; import { MessageTypes } from '../../constants/messageTypes';
import ExecutionStepsDisplay from './ExecutionStepsDisplay'; import ExecutionStepsDisplay from './ExecutionStepsDisplay';
import { MarkdownWithCharts } from '@components/ChatBot/MarkdownWithCharts'; import { MarkdownWithCharts } from '@components/ChatBot/MarkdownWithCharts';
import { GLASS_BLUR } from '@/constants/glassConfig'; import { GLASS_BLUR } from '@/constants/glassConfig';
/**
* DeepThinkingCard - 可折叠的深度思考过程卡片
* 类似于 Gemini/ChatGPT 的思考过程展示
*/
const DeepThinkingCard = ({ message }) => {
const [isExpanded, setIsExpanded] = useState(false);
const isThinking = message.isThinking;
const thinkingContent = message.thinkingContent || '';
return (
<Flex justify="flex-start" w="100%">
<HStack align="start" spacing={3} maxW={{ base: '95%', md: '85%', lg: '80%' }} w="100%">
<Avatar
src="/images/agent/基金经理.png"
icon={<Brain className="w-4 h-4" />}
size="sm"
bgGradient="linear(to-br, purple.500, pink.500)"
boxShadow="0 0 12px rgba(236, 72, 153, 0.4)"
flexShrink={0}
/>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
style={{ flex: 1, minWidth: 0 }}
>
<Card
bg="rgba(139, 92, 246, 0.08)"
backdropFilter={`${GLASS_BLUR.md} saturate(180%)`}
border="1px solid"
borderColor={isThinking ? 'purple.400' : 'rgba(139, 92, 246, 0.3)'}
boxShadow={isThinking ? '0 0 20px rgba(139, 92, 246, 0.3)' : '0 8px 32px 0 rgba(139, 92, 246, 0.15)'}
w="100%"
transition="all 0.3s"
>
<CardBody px={4} py={3}>
{/* 标题栏 - 可点击展开/折叠 */}
<HStack
spacing={2}
cursor="pointer"
onClick={() => !isThinking && setIsExpanded(!isExpanded)}
_hover={{ opacity: isThinking ? 1 : 0.8 }}
transition="opacity 0.2s"
>
{isThinking ? (
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
>
<Sparkles className="w-4 h-4" color="#C084FC" />
</motion.div>
) : (
<Brain className="w-4 h-4" color="#C084FC" />
)}
<Text fontSize="sm" fontWeight="medium" color="purple.300">
{isThinking ? '正在深度思考...' : '深度思考'}
</Text>
{isThinking && (
<Spinner
size="xs"
color="purple.400"
thickness="2px"
speed="0.8s"
/>
)}
{!isThinking && thinkingContent && (
<>
<Badge
bg="rgba(139, 92, 246, 0.2)"
color="purple.300"
fontSize="xs"
>
{thinkingContent.length}
</Badge>
<Box ml="auto">
{isExpanded ? (
<ChevronUp className="w-4 h-4" color="#A78BFA" />
) : (
<ChevronDown className="w-4 h-4" color="#A78BFA" />
)}
</Box>
</>
)}
</HStack>
{/* 思考内容 - 正在思考时直接显示,思考完成后可折叠 */}
{isThinking ? (
<Box
mt={3}
maxH="300px"
overflowY="auto"
sx={{
'&::-webkit-scrollbar': { width: '4px' },
'&::-webkit-scrollbar-track': { bg: 'rgba(255,255,255,0.05)' },
'&::-webkit-scrollbar-thumb': { bg: 'rgba(139, 92, 246, 0.3)', borderRadius: 'full' },
}}
>
<Text
fontSize="xs"
color="gray.400"
whiteSpace="pre-wrap"
lineHeight="tall"
fontStyle="italic"
>
{thinkingContent || '...'}
</Text>
</Box>
) : (
<Collapse in={isExpanded} animateOpacity>
<Box
mt={3}
pt={3}
borderTop="1px solid"
borderColor="rgba(139, 92, 246, 0.2)"
maxH="400px"
overflowY="auto"
sx={{
'&::-webkit-scrollbar': { width: '4px' },
'&::-webkit-scrollbar-track': { bg: 'rgba(255,255,255,0.05)' },
'&::-webkit-scrollbar-thumb': { bg: 'rgba(139, 92, 246, 0.3)', borderRadius: 'full' },
}}
>
<Text
fontSize="xs"
color="gray.400"
whiteSpace="pre-wrap"
lineHeight="tall"
fontStyle="italic"
>
{thinkingContent}
</Text>
</Box>
</Collapse>
)}
</CardBody>
</Card>
</motion.div>
</HStack>
</Flex>
);
};
/** /**
* MessageRenderer - 消息渲染器组件 * MessageRenderer - 消息渲染器组件
* *
@@ -121,6 +264,9 @@ const MessageRenderer = ({ message, userAvatar }) => {
</Flex> </Flex>
); );
case MessageTypes.AGENT_DEEP_THINKING:
return <DeepThinkingCard message={message} />;
case MessageTypes.AGENT_RESPONSE: case MessageTypes.AGENT_RESPONSE:
return ( return (
<Flex justify="flex-start" w="100%"> <Flex justify="flex-start" w="100%">

View File

@@ -29,7 +29,6 @@ import {
Paperclip, Paperclip,
Image as ImageIcon, Image as ImageIcon,
} from 'lucide-react'; } from 'lucide-react';
import { AVAILABLE_MODELS } from '../../constants/models';
import { animations } from '../../constants/animations'; import { animations } from '../../constants/animations';
import { GLASS_BLUR } from '@/constants/glassConfig'; import { GLASS_BLUR } from '@/constants/glassConfig';
import MessageRenderer from './MessageRenderer'; import MessageRenderer from './MessageRenderer';
@@ -48,7 +47,6 @@ import WelcomeScreen from './WelcomeScreen';
* @param {Array} props.uploadedFiles - 已上传文件列表 * @param {Array} props.uploadedFiles - 已上传文件列表
* @param {Function} props.onFileSelect - 文件选择回调 * @param {Function} props.onFileSelect - 文件选择回调
* @param {Function} props.onFileRemove - 文件删除回调 * @param {Function} props.onFileRemove - 文件删除回调
* @param {string} props.selectedModel - 当前选中的模型 ID
* @param {boolean} props.isLeftSidebarOpen - 左侧栏是否展开 * @param {boolean} props.isLeftSidebarOpen - 左侧栏是否展开
* @param {boolean} props.isRightSidebarOpen - 右侧栏是否展开 * @param {boolean} props.isRightSidebarOpen - 右侧栏是否展开
* @param {Function} props.onToggleLeftSidebar - 切换左侧栏回调 * @param {Function} props.onToggleLeftSidebar - 切换左侧栏回调
@@ -69,7 +67,6 @@ const ChatArea = ({
uploadedFiles, uploadedFiles,
onFileSelect, onFileSelect,
onFileRemove, onFileRemove,
selectedModel,
isLeftSidebarOpen, isLeftSidebarOpen,
isRightSidebarOpen, isRightSidebarOpen,
onToggleLeftSidebar, onToggleLeftSidebar,
@@ -157,14 +154,14 @@ const ChatArea = ({
智能分析 智能分析
</Badge> </Badge>
<Badge <Badge
bgGradient="linear(to-r, purple.500, pink.500)" bgGradient="linear(to-r, green.500, teal.500)"
color="white" color="white"
px={2} px={2}
py={1} py={1}
borderRadius="md" borderRadius="md"
boxShadow="0 2px 8px rgba(139, 92, 246, 0.3)" boxShadow="0 2px 8px rgba(16, 185, 129, 0.3)"
> >
{AVAILABLE_MODELS.find((m) => m.id === selectedModel)?.name} DeepMoney
</Badge> </Badge>
</HStack> </HStack>
</Box> </Box>

View File

@@ -31,15 +31,12 @@ import {
import { import {
Settings, Settings,
ChevronRight, ChevronRight,
Cpu,
Code, Code,
BarChart3, BarChart3,
Check,
MessageSquare, MessageSquare,
Activity, Activity,
} from 'lucide-react'; } from 'lucide-react';
import { animations } from '../../constants/animations'; import { animations } from '../../constants/animations';
import { AVAILABLE_MODELS } from '../../constants/models';
import { MCP_TOOLS, TOOL_CATEGORIES } from '../../constants/tools'; import { MCP_TOOLS, TOOL_CATEGORIES } from '../../constants/tools';
import { GLASS_BLUR } from '@/constants/glassConfig'; import { GLASS_BLUR } from '@/constants/glassConfig';
@@ -49,8 +46,6 @@ import { GLASS_BLUR } from '@/constants/glassConfig';
* @param {Object} props * @param {Object} props
* @param {boolean} props.isOpen - 侧边栏是否展开 * @param {boolean} props.isOpen - 侧边栏是否展开
* @param {Function} props.onClose - 关闭侧边栏回调 * @param {Function} props.onClose - 关闭侧边栏回调
* @param {string} props.selectedModel - 当前选中的模型 ID
* @param {Function} props.onModelChange - 模型切换回调
* @param {Array} props.selectedTools - 已选工具 ID 列表 * @param {Array} props.selectedTools - 已选工具 ID 列表
* @param {Function} props.onToolsChange - 工具选择变化回调 * @param {Function} props.onToolsChange - 工具选择变化回调
* @param {number} props.sessionsCount - 会话总数 * @param {number} props.sessionsCount - 会话总数
@@ -60,8 +55,6 @@ import { GLASS_BLUR } from '@/constants/glassConfig';
const RightSidebar = ({ const RightSidebar = ({
isOpen, isOpen,
onClose, onClose,
selectedModel,
onModelChange,
selectedTools, selectedTools,
onToolsChange, onToolsChange,
sessionsCount, sessionsCount,
@@ -129,19 +122,6 @@ const RightSidebar = ({
<Box flex={1} overflowY="auto"> <Box flex={1} overflowY="auto">
<Tabs colorScheme="purple" variant="line"> <Tabs colorScheme="purple" variant="line">
<TabList px={4} borderBottom="1px solid" borderColor="rgba(255, 255, 255, 0.1)"> <TabList px={4} borderBottom="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
<Tab
color="gray.400"
_selected={{
color: 'purple.400',
borderColor: 'purple.500',
boxShadow: '0 2px 8px rgba(139, 92, 246, 0.3)',
}}
>
<HStack spacing={2}>
<Cpu className="w-4 h-4" />
<Text>模型</Text>
</HStack>
</Tab>
<Tab <Tab
color="gray.400" color="gray.400"
_selected={{ _selected={{
@@ -184,84 +164,6 @@ const RightSidebar = ({
</TabList> </TabList>
<TabPanels> <TabPanels>
{/* 模型选择 */}
<TabPanel p={4}>
<VStack spacing={3} align="stretch">
{AVAILABLE_MODELS.map((model, idx) => (
<motion.div
key={model.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.1 }}
whileHover={{ scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }}
>
<Card
cursor="pointer"
onClick={() => onModelChange(model.id)}
bg={
selectedModel === model.id
? 'rgba(139, 92, 246, 0.15)'
: 'rgba(255, 255, 255, 0.05)'
}
backdropFilter={GLASS_BLUR.md}
borderWidth={2}
borderColor={
selectedModel === model.id ? 'purple.400' : 'rgba(255, 255, 255, 0.1)'
}
_hover={{
borderColor:
selectedModel === model.id ? 'purple.400' : 'rgba(255, 255, 255, 0.2)',
boxShadow:
selectedModel === model.id
? '0 8px 20px rgba(139, 92, 246, 0.4)'
: '0 4px 12px rgba(0, 0, 0, 0.3)',
}}
transition="all 0.3s"
>
<CardBody p={3}>
<HStack align="start" spacing={3}>
<Box
p={2}
borderRadius="lg"
bgGradient={
selectedModel === model.id
? 'linear(to-br, purple.500, pink.500)'
: 'linear(to-br, rgba(139, 92, 246, 0.2), rgba(236, 72, 153, 0.2))'
}
boxShadow={
selectedModel === model.id
? '0 4px 12px rgba(139, 92, 246, 0.4)'
: 'none'
}
>
{model.icon}
</Box>
<Box flex={1}>
<Text fontWeight="semibold" fontSize="sm" color="gray.100">
{model.name}
</Text>
<Text fontSize="xs" color="gray.400" mt={1}>
{model.description}
</Text>
</Box>
{selectedModel === model.id && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
>
<Check className="w-5 h-5" color="#A78BFA" />
</motion.div>
)}
</HStack>
</CardBody>
</Card>
</motion.div>
))}
</VStack>
</TabPanel>
{/* 工具选择 */} {/* 工具选择 */}
<TabPanel p={4}> <TabPanel p={4}>
<Accordion allowMultiple> <Accordion allowMultiple>

View File

@@ -10,6 +10,8 @@ export enum MessageTypes {
USER = 'user', USER = 'user',
/** Agent 思考中状态 */ /** Agent 思考中状态 */
AGENT_THINKING = 'agent_thinking', AGENT_THINKING = 'agent_thinking',
/** Agent 深度思考过程(可折叠展示) */
AGENT_DEEP_THINKING = 'agent_deep_thinking',
/** Agent 执行计划 */ /** Agent 执行计划 */
AGENT_PLAN = 'agent_plan', AGENT_PLAN = 'agent_plan',
/** Agent 执行步骤中 */ /** Agent 执行步骤中 */
@@ -64,6 +66,7 @@ export interface AgentMessage extends BaseMessage {
type: type:
| MessageTypes.AGENT_RESPONSE | MessageTypes.AGENT_RESPONSE
| MessageTypes.AGENT_THINKING | MessageTypes.AGENT_THINKING
| MessageTypes.AGENT_DEEP_THINKING
| MessageTypes.AGENT_PLAN | MessageTypes.AGENT_PLAN
| MessageTypes.AGENT_EXECUTING; | MessageTypes.AGENT_EXECUTING;
/** 执行计划JSON 对象)*/ /** 执行计划JSON 对象)*/
@@ -76,6 +79,10 @@ export interface AgentMessage extends BaseMessage {
execution_time?: number; execution_time?: number;
error?: string; error?: string;
}>; }>;
/** 深度思考内容(用于 AGENT_DEEP_THINKING 类型)*/
thinkingContent?: string;
/** 是否正在思考中 */
isThinking?: boolean;
/** 额外元数据 */ /** 额外元数据 */
metadata?: any; metadata?: any;
} }

View File

@@ -22,34 +22,13 @@ export interface ModelConfig {
/** /**
* 可用模型配置列表 * 可用模型配置列表
* 包含所有可供用户选择的 AI 模型 * 当前仅支持 DeepMoney 模型(基于 MiniMax-M2.1
*/ */
export const AVAILABLE_MODELS: ModelConfig[] = [ export const AVAILABLE_MODELS: ModelConfig[] = [
{
id: 'deepseek',
name: 'DeepSeek',
description: '高性能对话模型,响应迅速',
icon: React.createElement(Zap, { className: 'w-5 h-5' }),
color: 'blue',
},
{
id: 'kimi-k2-thinking',
name: 'Kimi K2 Thinking',
description: '深度思考模型,适合复杂分析',
icon: React.createElement(Brain, { className: 'w-5 h-5' }),
color: 'purple',
},
{
id: 'kimi-k2',
name: 'Kimi K2',
description: '快速响应模型,适合简单查询',
icon: React.createElement(Zap, { className: 'w-5 h-5' }),
color: 'cyan',
},
{ {
id: 'deepmoney', id: 'deepmoney',
name: 'DeepMoney', name: 'DeepMoney',
description: '金融专业模型,65K 上下文', description: '金融专业模型,84K 上下文,支持深度思考',
icon: React.createElement(TrendingUp, { className: 'w-5 h-5' }), icon: React.createElement(TrendingUp, { className: 'w-5 h-5' }),
color: 'green', color: 'green',
}, },

View File

@@ -122,6 +122,8 @@ export const useAgentChat = ({
const streamStateRef = useRef<{ const streamStateRef = useRef<{
thinkingContent: string; thinkingContent: string;
summaryContent: string; summaryContent: string;
deepThinkingContent: string; // 深度思考内容(来自 <think> 标签)
isDeepThinking: boolean; // 是否正在进行深度思考
plan: any; plan: any;
stepResults: any[]; stepResults: any[];
sessionId: string | null; sessionId: string | null;
@@ -129,6 +131,8 @@ export const useAgentChat = ({
}>({ }>({
thinkingContent: '', thinkingContent: '',
summaryContent: '', summaryContent: '',
deepThinkingContent: '',
isDeepThinking: false,
plan: null, plan: null,
stepResults: [], stepResults: [],
sessionId: null, sessionId: null,
@@ -188,6 +192,8 @@ export const useAgentChat = ({
streamStateRef.current = { streamStateRef.current = {
thinkingContent: '', thinkingContent: '',
summaryContent: '', summaryContent: '',
deepThinkingContent: '',
isDeepThinking: false,
plan: null, plan: null,
stepResults: [], stepResults: [],
sessionId: null, sessionId: null,
@@ -427,6 +433,40 @@ export const useAgentChat = ({
} }
break; break;
case 'thinking_start':
// 深度思考开始
streamStateRef.current.isDeepThinking = true;
streamStateRef.current.deepThinkingContent = '';
// 移除执行中消息
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING));
// 添加深度思考消息
addMessage({
type: MessageTypes.AGENT_DEEP_THINKING,
content: data?.message || '正在深度思考...',
thinkingContent: '',
isThinking: true,
});
break;
case 'thinking_chunk':
// 深度思考内容流式输出
streamStateRef.current.deepThinkingContent += data?.content || '';
updateLastMessage(MessageTypes.AGENT_DEEP_THINKING, {
thinkingContent: streamStateRef.current.deepThinkingContent,
isThinking: true,
});
break;
case 'thinking_end':
// 深度思考结束
streamStateRef.current.isDeepThinking = false;
updateLastMessage(MessageTypes.AGENT_DEEP_THINKING, {
thinkingContent: streamStateRef.current.deepThinkingContent,
isThinking: false,
content: '思考完成',
});
break;
case 'summary_chunk': case 'summary_chunk':
// 总结内容流式输出 // 总结内容流式输出
// 如果还在执行中,先切换到思考状态 // 如果还在执行中,先切换到思考状态

View File

@@ -333,7 +333,6 @@ const AgentChat = () => {
uploadedFiles={uploadedFiles} uploadedFiles={uploadedFiles}
onFileSelect={handleFileSelect} onFileSelect={handleFileSelect}
onFileRemove={removeFile} onFileRemove={removeFile}
selectedModel={selectedModel}
isLeftSidebarOpen={isLeftSidebarOpen} isLeftSidebarOpen={isLeftSidebarOpen}
isRightSidebarOpen={isRightSidebarOpen} isRightSidebarOpen={isRightSidebarOpen}
onToggleLeftSidebar={() => setIsLeftSidebarOpen(true)} onToggleLeftSidebar={() => setIsLeftSidebarOpen(true)}
@@ -348,8 +347,6 @@ const AgentChat = () => {
<RightSidebar <RightSidebar
isOpen={isRightSidebarOpen} isOpen={isRightSidebarOpen}
onClose={() => setIsRightSidebarOpen(false)} onClose={() => setIsRightSidebarOpen(false)}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
selectedTools={selectedTools} selectedTools={selectedTools}
onToolsChange={setSelectedTools} onToolsChange={setSelectedTools}
sessionsCount={sessions.length} sessionsCount={sessions.length}