Files
vf_react/src/views/StockCommunity/index.tsx
2026-01-06 12:09:28 +08:00

380 lines
11 KiB
TypeScript

/**
* 股票社区主页面 - HeroUI 深色风格
* Discord 风格三栏布局:频道列表 | 消息区域 | 右侧面板
* 支持 Tab 切换:社区频道 / 预测市场
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Flex,
IconButton,
Drawer,
DrawerOverlay,
DrawerContent,
useDisclosure,
useBreakpointValue,
Text,
HStack,
Icon,
Tooltip,
Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
Badge,
} from '@chakra-ui/react';
import { motion, AnimatePresence } from 'framer-motion';
import { Menu, Users, Hash, Settings, Bell, Search, MessageSquare, Zap } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import ChannelSidebar from './components/ChannelSidebar';
import MessageArea from './components/MessageArea';
import RightPanel from './components/RightPanel';
import PredictionMarket from './components/PredictionMarket';
import { useCommunitySocket } from './hooks/useCommunitySocket';
import { Channel } from './types';
import { GLASS_BLUR, GLASS_BG, GLASS_BORDER, GLASS_SHADOW } from '@/constants/glassConfig';
// 动画配置
const slideVariants = {
hidden: { opacity: 0, x: -20 },
visible: { opacity: 1, x: 0, transition: { duration: 0.3, ease: 'easeOut' as const } },
exit: { opacity: 0, x: -20, transition: { duration: 0.2 } },
};
const StockCommunity: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [activeChannel, setActiveChannel] = useState<Channel | null>(null);
const [rightPanelContent, setRightPanelContent] = useState<'members' | 'thread' | 'info'>('members');
const [activeTab, setActiveTab] = useState(0); // 0: 社区频道, 1: 预测市场
// 移动端抽屉
const { isOpen: isLeftOpen, onOpen: onLeftOpen, onClose: onLeftClose } = useDisclosure();
const { isOpen: isRightOpen, onOpen: onRightOpen, onClose: onRightClose } = useDisclosure();
// 响应式布局
const isMobile = useBreakpointValue({ base: true, lg: false });
const showLeftSidebar = useBreakpointValue({ base: false, lg: true });
const showRightPanel = useBreakpointValue({ base: false, xl: true });
// WebSocket 连接
const { isConnected, subscribe, unsubscribe } = useCommunitySocket();
// 从 URL 参数获取频道 ID
useEffect(() => {
const channelId = searchParams.get('channel');
if (channelId && !activeChannel) {
// 将在 ChannelSidebar 加载频道后设置
}
}, [searchParams, activeChannel]);
// 切换频道
const handleChannelSelect = useCallback((channel: Channel) => {
// 取消订阅旧频道
if (activeChannel) {
unsubscribe(activeChannel.id);
}
// 订阅新频道
subscribe(channel.id);
setActiveChannel(channel);
// 更新 URL
setSearchParams({ channel: channel.id });
// 移动端关闭抽屉
if (isMobile) {
onLeftClose();
}
}, [activeChannel, subscribe, unsubscribe, setSearchParams, isMobile, onLeftClose]);
// 渲染频道侧边栏
const renderChannelSidebar = () => (
<ChannelSidebar
activeChannelId={activeChannel?.id}
onChannelSelect={handleChannelSelect}
initialChannelId={searchParams.get('channel')}
/>
);
// 渲染右侧面板
const renderRightPanel = () => (
<RightPanel
channel={activeChannel}
contentType={rightPanelContent}
onContentTypeChange={setRightPanelContent}
/>
);
// 渲染社区频道视图(原有的 Discord 风格三栏布局)
const renderCommunityView = () => (
<Flex
h="full"
overflow="hidden"
position="relative"
>
{/* 左侧频道列表 - 桌面端 */}
<AnimatePresence>
{showLeftSidebar && (
<motion.div
initial="hidden"
animate="visible"
exit="exit"
variants={slideVariants}
style={{ display: 'flex', height: '100%' }}
>
<Box
w="280px"
minW="280px"
h="full"
bg="rgba(17, 24, 39, 0.8)"
backdropFilter={`${GLASS_BLUR.lg} saturate(180%)`}
borderRight="1px solid"
borderColor="rgba(255, 255, 255, 0.08)"
boxShadow="4px 0 24px rgba(0, 0, 0, 0.3)"
overflow="hidden"
display="flex"
flexDirection="column"
>
{renderChannelSidebar()}
</Box>
</motion.div>
)}
</AnimatePresence>
{/* 左侧频道列表 - 移动端抽屉 */}
<Drawer isOpen={isLeftOpen} placement="left" onClose={onLeftClose}>
<DrawerOverlay bg="blackAlpha.700" backdropFilter={GLASS_BLUR.sm} />
<DrawerContent
maxW="300px"
bg="rgba(17, 24, 39, 0.98)"
backdropFilter={GLASS_BLUR.lg}
>
{renderChannelSidebar()}
</DrawerContent>
</Drawer>
{/* 中间消息区域 */}
<Box
flex={1}
h="full"
overflow="hidden"
bg="rgba(15, 23, 42, 0.6)"
position="relative"
>
<MessageArea
channel={activeChannel}
onOpenRightPanel={onRightOpen}
onRightPanelContentChange={setRightPanelContent}
/>
</Box>
{/* 右侧面板 - 桌面端 */}
<AnimatePresence>
{showRightPanel && activeChannel && (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0, transition: { duration: 0.3 } }}
exit={{ opacity: 0, x: 20, transition: { duration: 0.2 } }}
style={{ display: 'flex', height: '100%' }}
>
<Box
w="300px"
minW="300px"
h="full"
bg="rgba(17, 24, 39, 0.8)"
backdropFilter={`${GLASS_BLUR.lg} saturate(180%)`}
borderLeft="1px solid"
borderColor="rgba(255, 255, 255, 0.08)"
boxShadow="-4px 0 24px rgba(0, 0, 0, 0.3)"
overflow="hidden"
>
{renderRightPanel()}
</Box>
</motion.div>
)}
</AnimatePresence>
{/* 右侧面板 - 移动端抽屉 */}
<Drawer isOpen={isRightOpen} placement="right" onClose={onRightClose}>
<DrawerOverlay bg="blackAlpha.700" backdropFilter={GLASS_BLUR.sm} />
<DrawerContent
maxW="320px"
bg="rgba(17, 24, 39, 0.98)"
backdropFilter={GLASS_BLUR.lg}
>
{renderRightPanel()}
</DrawerContent>
</Drawer>
</Flex>
);
return (
<Box
h="calc(100vh - 60px)"
bg="gray.900"
overflow="hidden"
position="relative"
>
{/* 背景装饰 */}
<Box
position="absolute"
top="-10%"
left="-5%"
w="400px"
h="400px"
bg={activeTab === 0 ? 'purple.500' : 'yellow.500'}
filter="blur(150px)"
opacity="0.08"
borderRadius="full"
pointerEvents="none"
transition="background 0.5s ease"
/>
<Box
position="absolute"
bottom="-10%"
right="-5%"
w="350px"
h="350px"
bg={activeTab === 0 ? 'blue.500' : 'orange.500'}
filter="blur(150px)"
opacity="0.06"
borderRadius="full"
pointerEvents="none"
transition="background 0.5s ease"
/>
{/* Tabs 容器 */}
<Tabs
index={activeTab}
onChange={(index) => setActiveTab(index)}
isLazy
h="full"
display="flex"
flexDirection="column"
>
{/* Tab 切换栏 */}
<Box
bg="rgba(17, 24, 39, 0.95)"
backdropFilter={GLASS_BLUR.lg}
borderBottom="1px solid"
borderColor="rgba(255, 255, 255, 0.08)"
px={4}
flexShrink={0}
>
<Flex align="center" justify="space-between">
{/* 移动端菜单按钮 */}
{isMobile && activeTab === 0 && (
<IconButton
aria-label="打开频道列表"
icon={<Menu className="w-5 h-5" />}
variant="ghost"
color="gray.300"
size="sm"
mr={2}
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
onClick={onLeftOpen}
/>
)}
<TabList
border="none"
gap={2}
py={2}
flex={1}
>
<Tab
px={4}
py={2}
borderRadius="lg"
fontWeight="semibold"
fontSize="sm"
color="gray.400"
bg="transparent"
border="none"
_selected={{
color: 'white',
bg: 'linear-gradient(135deg, rgba(139, 92, 246, 0.3), rgba(59, 130, 246, 0.3))',
boxShadow: '0 0 20px rgba(139, 92, 246, 0.2)',
}}
_hover={{
color: 'white',
bg: 'whiteAlpha.100',
}}
>
<HStack spacing={2}>
<MessageSquare className="w-4 h-4" />
<Text></Text>
</HStack>
</Tab>
<Tab
px={4}
py={2}
borderRadius="lg"
fontWeight="semibold"
fontSize="sm"
color="gray.400"
bg="transparent"
border="none"
_selected={{
color: 'white',
bg: 'linear-gradient(135deg, rgba(251, 191, 36, 0.3), rgba(245, 158, 11, 0.3))',
boxShadow: '0 0 20px rgba(251, 191, 36, 0.2)',
}}
_hover={{
color: 'white',
bg: 'whiteAlpha.100',
}}
>
<HStack spacing={2}>
<Zap className="w-4 h-4" />
<Text></Text>
<Badge
bg="linear-gradient(135deg, rgba(239, 68, 68, 0.4), rgba(220, 38, 38, 0.4))"
color="red.200"
fontSize="2xs"
px={1.5}
borderRadius="full"
>
BETA
</Badge>
</HStack>
</Tab>
</TabList>
{/* 移动端右侧按钮 */}
{isMobile && activeTab === 0 && (
<IconButton
aria-label="打开详情面板"
icon={<Users className="w-5 h-5" />}
variant="ghost"
color="gray.300"
size="sm"
ml={2}
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
onClick={onRightOpen}
/>
)}
</Flex>
</Box>
{/* Tab 内容区域 */}
<TabPanels flex={1} overflow="hidden">
{/* 社区频道 */}
<TabPanel p={0} h="full">
{renderCommunityView()}
</TabPanel>
{/* 预测市场 */}
<TabPanel p={0} h="full">
<PredictionMarket />
</TabPanel>
</TabPanels>
</Tabs>
</Box>
);
};
export default StockCommunity;