个股论坛重做

This commit is contained in:
2026-01-06 12:09:28 +08:00
parent 526337847b
commit 4912105a8d
8 changed files with 656 additions and 535 deletions

View File

@@ -1,16 +0,0 @@
-- 添加预测市场频道到社区
-- 在 MySQL 中执行
-- 首先检查是否已存在预测市场分类
INSERT IGNORE INTO community_categories (id, name, icon, position, is_collapsible, is_system, created_at, updated_at)
VALUES ('cat_prediction', '预测市场', '', 2, 1, 1, NOW(), NOW());
-- 添加预测市场频道
INSERT IGNORE INTO community_channels
(id, category_id, name, type, topic, position, slow_mode, is_readonly, is_visible, is_system, subscriber_count, message_count, created_at, updated_at)
VALUES
('ch_prediction', 'cat_prediction', '预测大厅', 'prediction', 'Polymarket 风格预测市场,用积分参与预测,赢取奖池', 1, 0, 0, 1, 1, 0, 0, NOW(), NOW());
-- 查看结果
SELECT * FROM community_categories WHERE id = 'cat_prediction';
SELECT * FROM community_channels WHERE type = 'prediction';

View File

@@ -48,7 +48,7 @@ import {
Star, Star,
Settings, Settings,
Users, Users,
Zap, Flame,
} from 'lucide-react'; } from 'lucide-react';
import { Channel, ChannelCategory, ChannelType } from '../../types'; import { Channel, ChannelCategory, ChannelType } from '../../types';
@@ -67,7 +67,7 @@ const channelIcons: Record<ChannelType, any> = {
text: MessageSquare, text: MessageSquare,
forum: FileText, forum: FileText,
announcement: Megaphone, announcement: Megaphone,
prediction: Zap, voice: Users,
}; };
// 动画配置 // 动画配置
@@ -281,7 +281,7 @@ const ChannelSidebar: React.FC<ChannelSidebarProps> = ({
{/* 热门标记 */} {/* 热门标记 */}
{channel.isHot && ( {channel.isHot && (
<Icon as={Zap} boxSize={4} color="orange.400" mr={1} /> <Icon as={Flame} boxSize={4} color="orange.400" mr={1} />
)} )}
{/* 概念频道股票数 */} {/* 概念频道股票数 */}

View File

@@ -1,433 +0,0 @@
/**
* 预测市场频道组件 - HeroUI 深色风格
* Polymarket 风格的预测市场
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Flex,
Text,
Button,
Icon,
IconButton,
HStack,
VStack,
Select,
Spinner,
useDisclosure,
Badge,
Tooltip,
SimpleGrid,
} from '@chakra-ui/react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Plus,
TrendingUp,
Filter,
Zap,
Trophy,
HelpCircle,
Coins,
RefreshCw,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Channel } from '../../../types';
import { GLASS_BLUR } from '@/constants/glassConfig';
import { useAuth } from '@/contexts/AuthContext';
import { getTopics, getUserAccount } from '@services/predictionMarketService.api';
// 导入原有组件
import CreatePredictionModal from '@views/ValueForum/components/CreatePredictionModal';
import PredictionGuideModal from '@views/ValueForum/components/PredictionGuideModal';
import PredictionTopicCardDark from './PredictionTopicCardDark';
interface PredictionChannelProps {
channel: Channel;
}
type SortOption = 'latest' | 'hot' | 'ending_soon' | 'highest_pool';
type FilterOption = 'all' | 'active' | 'settled';
const PredictionChannel: React.FC<PredictionChannelProps> = ({ channel }) => {
const navigate = useNavigate();
const { user, isAuthenticated } = useAuth();
const [topics, setTopics] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [sortBy, setSortBy] = useState<SortOption>('latest');
const [filterBy, setFilterBy] = useState<FilterOption>('active');
const [userAccount, setUserAccount] = useState<any>(null);
const {
isOpen: isCreateOpen,
onOpen: onCreateOpen,
onClose: onCreateClose,
} = useDisclosure();
const {
isOpen: isGuideOpen,
onOpen: onGuideOpen,
onClose: onGuideClose,
} = useDisclosure();
// 加载话题列表
const loadTopics = useCallback(async (showRefreshing = false) => {
try {
if (showRefreshing) {
setRefreshing(true);
} else {
setLoading(true);
}
const response = await getTopics({
status: filterBy === 'all' ? undefined : filterBy,
sort: sortBy,
page: 1,
pageSize: 20,
});
if (response.success) {
setTopics(response.data.items || []);
}
} catch (error) {
console.error('加载预测话题失败:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [sortBy, filterBy]);
// 加载用户账户
const loadUserAccount = useCallback(async () => {
if (!isAuthenticated) return;
try {
const response = await getUserAccount();
if (response.success) {
setUserAccount(response.data);
}
} catch (error) {
console.error('加载用户账户失败:', error);
}
}, [isAuthenticated]);
useEffect(() => {
loadTopics();
}, [loadTopics]);
useEffect(() => {
loadUserAccount();
}, [loadUserAccount]);
// 创建成功回调
const handleTopicCreated = (newTopic: any) => {
setTopics(prev => [newTopic, ...prev]);
onCreateClose();
loadUserAccount(); // 刷新余额
};
// 点击话题卡片
const handleTopicClick = (topicId: string) => {
navigate(`/value-forum/prediction/${topicId}`);
};
return (
<Flex direction="column" h="full">
{/* 频道头部 */}
<Box
px={4}
py={3}
borderBottom="1px solid"
borderColor="rgba(255, 255, 255, 0.08)"
bg="rgba(17, 24, 39, 0.6)"
backdropFilter={GLASS_BLUR.sm}
>
<Flex justify="space-between" align="center">
<HStack spacing={3}>
<Box
p={2}
bg="linear-gradient(135deg, rgba(251, 191, 36, 0.2), rgba(245, 158, 11, 0.2))"
borderRadius="lg"
>
<Icon as={Zap} boxSize={5} color="yellow.400" />
</Box>
<Box>
<HStack spacing={2}>
<Text fontWeight="semibold" color="white" fontSize="md">
{channel.name}
</Text>
<Badge
bg="linear-gradient(135deg, rgba(251, 191, 36, 0.3), rgba(245, 158, 11, 0.3))"
color="yellow.300"
fontSize="xs"
px={2}
borderRadius="full"
>
</Badge>
</HStack>
<Text fontSize="xs" color="gray.500" mt={0.5}>
Polymarket
</Text>
</Box>
</HStack>
<HStack spacing={2}>
{/* 用户积分 */}
{isAuthenticated && userAccount && (
<Tooltip label="我的积分" placement="bottom">
<HStack
px={3}
py={1.5}
bg="rgba(251, 191, 36, 0.1)"
border="1px solid"
borderColor="rgba(251, 191, 36, 0.3)"
borderRadius="lg"
spacing={1}
>
<Icon as={Coins} boxSize={4} color="yellow.400" />
<Text fontSize="sm" fontWeight="bold" color="yellow.300">
{userAccount.balance?.toLocaleString()}
</Text>
</HStack>
</Tooltip>
)}
{/* 玩法说明 */}
<Tooltip label="玩法说明" placement="bottom">
<IconButton
aria-label="玩法说明"
icon={<HelpCircle className="w-4 h-4" />}
variant="ghost"
size="sm"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
onClick={onGuideOpen}
/>
</Tooltip>
{/* 排行榜入口 */}
<Tooltip label="积分排行榜" placement="bottom">
<IconButton
aria-label="排行榜"
icon={<Trophy className="w-4 h-4" />}
variant="ghost"
size="sm"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'yellow.400' }}
onClick={() => navigate('/value-forum/my-points')}
/>
</Tooltip>
</HStack>
</Flex>
</Box>
{/* 工具栏 */}
<Flex
align="center"
justify="space-between"
px={4}
py={3}
borderBottom="1px solid"
borderColor="rgba(255, 255, 255, 0.08)"
bg="rgba(17, 24, 39, 0.4)"
>
<HStack spacing={3}>
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
leftIcon={<Plus className="w-4 h-4" />}
size="sm"
bg="linear-gradient(135deg, #F59E0B, #D97706)"
color="white"
_hover={{
bg: 'linear-gradient(135deg, #D97706, #B45309)',
boxShadow: '0 0 20px rgba(245, 158, 11, 0.4)',
}}
onClick={onCreateOpen}
>
</Button>
</motion.div>
{/* 刷新按钮 */}
<IconButton
aria-label="刷新"
icon={<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />}
variant="ghost"
size="sm"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
onClick={() => loadTopics(true)}
isDisabled={refreshing}
/>
</HStack>
<HStack spacing={3}>
{/* 状态筛选 */}
<Select
size="sm"
w="100px"
value={filterBy}
onChange={(e) => setFilterBy(e.target.value as FilterOption)}
bg="rgba(255, 255, 255, 0.05)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
color="white"
_focus={{
borderColor: 'yellow.400',
boxShadow: '0 0 0 1px var(--chakra-colors-yellow-400)',
}}
sx={{
option: {
bg: '#1f2937',
color: 'white',
},
}}
>
<option value="all"></option>
<option value="active"></option>
<option value="settled"></option>
</Select>
{/* 排序 */}
<HStack spacing={2}>
<Icon as={Filter} boxSize={4} color="gray.500" />
<Select
size="sm"
w="120px"
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
bg="rgba(255, 255, 255, 0.05)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
color="white"
_focus={{
borderColor: 'yellow.400',
boxShadow: '0 0 0 1px var(--chakra-colors-yellow-400)',
}}
sx={{
option: {
bg: '#1f2937',
color: 'white',
},
}}
>
<option value="latest"></option>
<option value="hot"></option>
<option value="ending_soon"></option>
<option value="highest_pool"></option>
</Select>
</HStack>
</HStack>
</Flex>
{/* 话题列表 */}
<Box
flex={1}
overflowY="auto"
px={4}
py={4}
css={{
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: 'rgba(255, 255, 255, 0.02)',
},
'&::-webkit-scrollbar-thumb': {
background: 'rgba(245, 158, 11, 0.2)',
borderRadius: '4px',
'&:hover': {
background: 'rgba(245, 158, 11, 0.4)',
},
},
}}
>
{loading ? (
<Flex justify="center" align="center" h="200px">
<VStack spacing={3}>
<Spinner size="lg" color="yellow.400" thickness="3px" />
<Text color="gray.500" fontSize="sm">...</Text>
</VStack>
</Flex>
) : topics.length === 0 ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Flex
direction="column"
align="center"
justify="center"
py={12}
>
<Box
p={4}
bg="linear-gradient(135deg, rgba(251, 191, 36, 0.2), rgba(245, 158, 11, 0.2))"
borderRadius="full"
mb={4}
>
<Icon as={TrendingUp} boxSize={8} color="yellow.400" />
</Box>
<Text color="gray.400" mb={2}></Text>
<Text color="gray.500" fontSize="sm" mb={4}>
</Text>
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
size="sm"
bg="linear-gradient(135deg, #F59E0B, #D97706)"
color="white"
leftIcon={<Plus className="w-4 h-4" />}
_hover={{
bg: 'linear-gradient(135deg, #D97706, #B45309)',
boxShadow: '0 0 20px rgba(245, 158, 11, 0.4)',
}}
onClick={onCreateOpen}
>
</Button>
</motion.div>
</Flex>
</motion.div>
) : (
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={4}>
<AnimatePresence>
{topics.map((topic, index) => (
<motion.div
key={topic.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ delay: index * 0.05 }}
>
<PredictionTopicCardDark
topic={topic}
onClick={() => handleTopicClick(topic.id)}
/>
</motion.div>
))}
</AnimatePresence>
</SimpleGrid>
)}
</Box>
{/* 创建话题弹窗 */}
<CreatePredictionModal
isOpen={isCreateOpen}
onClose={onCreateClose}
onTopicCreated={handleTopicCreated}
/>
{/* 玩法说明弹窗 */}
<PredictionGuideModal
isOpen={isGuideOpen}
onClose={onGuideClose}
/>
</Flex>
);
};
export default PredictionChannel;

View File

@@ -17,7 +17,6 @@ import { MessageSquare, Hash, Users } from 'lucide-react';
import { Channel } from '../../types'; import { Channel } from '../../types';
import TextChannel from './TextChannel'; import TextChannel from './TextChannel';
import ForumChannel from './ForumChannel'; import ForumChannel from './ForumChannel';
import PredictionChannel from './PredictionChannel';
import { GLASS_BLUR } from '@/constants/glassConfig'; import { GLASS_BLUR } from '@/constants/glassConfig';
interface MessageAreaProps { interface MessageAreaProps {
@@ -154,13 +153,6 @@ const MessageArea: React.FC<MessageAreaProps> = ({
/> />
); );
case 'prediction':
return (
<PredictionChannel
channel={channel}
/>
);
case 'announcement': case 'announcement':
return ( return (
<TextChannel <TextChannel

View File

@@ -0,0 +1,474 @@
/**
* 预测市场独立板块 - HeroUI 深色风格
* Polymarket 风格的预测市场,作为独立 Tab 页面
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Flex,
Text,
Button,
Icon,
IconButton,
HStack,
VStack,
Select,
Spinner,
useDisclosure,
Badge,
Tooltip,
SimpleGrid,
Input,
InputGroup,
InputLeftElement,
Heading,
} from '@chakra-ui/react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Plus,
TrendingUp,
Filter,
Zap,
Trophy,
HelpCircle,
Coins,
RefreshCw,
Search,
Clock,
Flame,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { GLASS_BLUR } from '@/constants/glassConfig';
import { useAuth } from '@/contexts/AuthContext';
import { getTopics, getUserAccount } from '@services/predictionMarketService.api';
// 导入原有组件
import CreatePredictionModal from '@views/ValueForum/components/CreatePredictionModal';
import PredictionGuideModal from '@views/ValueForum/components/PredictionGuideModal';
import PredictionTopicCardDark from './PredictionTopicCardDark';
type SortOption = 'latest' | 'hot' | 'ending_soon' | 'highest_pool';
type FilterOption = 'all' | 'active' | 'settled';
const PredictionMarket: React.FC = () => {
const navigate = useNavigate();
const { user, isAuthenticated } = useAuth();
const [topics, setTopics] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [sortBy, setSortBy] = useState<SortOption>('latest');
const [filterBy, setFilterBy] = useState<FilterOption>('active');
const [searchKeyword, setSearchKeyword] = useState('');
const [userAccount, setUserAccount] = useState<any>(null);
const {
isOpen: isCreateOpen,
onOpen: onCreateOpen,
onClose: onCreateClose,
} = useDisclosure();
const {
isOpen: isGuideOpen,
onOpen: onGuideOpen,
onClose: onGuideClose,
} = useDisclosure();
// 加载话题列表
const loadTopics = useCallback(async (showRefreshing = false) => {
try {
if (showRefreshing) {
setRefreshing(true);
} else {
setLoading(true);
}
const response = await getTopics({
status: filterBy === 'all' ? undefined : filterBy,
sort: sortBy,
page: 1,
pageSize: 30,
});
if (response.success) {
let items = response.data.items || response.data || [];
// 前端搜索过滤
if (searchKeyword.trim()) {
const keyword = searchKeyword.toLowerCase();
items = items.filter((topic: any) =>
topic.title?.toLowerCase().includes(keyword) ||
topic.description?.toLowerCase().includes(keyword)
);
}
setTopics(items);
}
} catch (error) {
console.error('加载预测话题失败:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [sortBy, filterBy, searchKeyword]);
// 加载用户账户
const loadUserAccount = useCallback(async () => {
if (!isAuthenticated) return;
try {
const response = await getUserAccount();
if (response.success) {
setUserAccount(response.data);
}
} catch (error) {
console.error('加载用户账户失败:', error);
}
}, [isAuthenticated]);
useEffect(() => {
loadTopics();
}, [loadTopics]);
useEffect(() => {
loadUserAccount();
}, [loadUserAccount]);
// 搜索处理
const handleSearch = () => {
loadTopics();
};
// 创建成功回调
const handleTopicCreated = (newTopic: any) => {
setTopics(prev => [newTopic, ...prev]);
onCreateClose();
loadUserAccount(); // 刷新余额
};
// 点击话题卡片
const handleTopicClick = (topicId: string) => {
navigate(`/value-forum/prediction/${topicId}`);
};
// 排序选项配置
const sortOptions = [
{ value: 'latest', label: '最新发布', icon: Clock },
{ value: 'hot', label: '最热门', icon: Flame },
{ value: 'ending_soon', label: '即将截止', icon: Clock },
{ value: 'highest_pool', label: '奖池最高', icon: Coins },
];
return (
<Box h="full" overflow="auto" bg="transparent">
{/* 顶部区域 */}
<Box
px={6}
py={6}
borderBottom="1px solid"
borderColor="rgba(255, 255, 255, 0.08)"
bg="rgba(17, 24, 39, 0.4)"
backdropFilter={GLASS_BLUR.sm}
>
{/* 标题和操作栏 */}
<Flex justify="space-between" align="center" mb={6}>
<VStack align="start" spacing={1}>
<HStack spacing={3}>
<Box
p={2}
bg="linear-gradient(135deg, rgba(251, 191, 36, 0.2), rgba(245, 158, 11, 0.2))"
borderRadius="lg"
>
<Icon as={Zap} boxSize={6} color="yellow.400" />
</Box>
<Heading
fontSize="2xl"
fontWeight="bold"
bgGradient="linear(to-r, yellow.300, orange.300)"
bgClip="text"
>
</Heading>
<Badge
bg="linear-gradient(135deg, rgba(239, 68, 68, 0.3), rgba(220, 38, 38, 0.3))"
color="red.300"
fontSize="xs"
px={2}
borderRadius="full"
>
BETA
</Badge>
</HStack>
<Text fontSize="sm" color="gray.500">
Polymarket
</Text>
</VStack>
<HStack spacing={3}>
{/* 用户积分 */}
{isAuthenticated && userAccount && (
<Tooltip label="我的积分余额" placement="bottom">
<HStack
px={4}
py={2}
bg="rgba(251, 191, 36, 0.1)"
border="1px solid"
borderColor="rgba(251, 191, 36, 0.3)"
borderRadius="xl"
spacing={2}
cursor="pointer"
_hover={{
bg: 'rgba(251, 191, 36, 0.15)',
borderColor: 'rgba(251, 191, 36, 0.5)',
}}
onClick={() => navigate('/value-forum/my-points')}
>
<Icon as={Coins} boxSize={5} color="yellow.400" />
<Text fontSize="md" fontWeight="bold" color="yellow.300">
{userAccount.balance?.toLocaleString()}
</Text>
</HStack>
</Tooltip>
)}
{/* 玩法说明 */}
<Tooltip label="玩法说明" placement="bottom">
<IconButton
aria-label="玩法说明"
icon={<HelpCircle className="w-5 h-5" />}
variant="outline"
borderColor="rgba(255, 255, 255, 0.2)"
color="gray.300"
_hover={{ bg: 'whiteAlpha.100', color: 'white', borderColor: 'yellow.400' }}
onClick={onGuideOpen}
/>
</Tooltip>
{/* 排行榜 */}
<Tooltip label="积分排行榜" placement="bottom">
<IconButton
aria-label="排行榜"
icon={<Trophy className="w-5 h-5" />}
variant="outline"
borderColor="rgba(255, 255, 255, 0.2)"
color="gray.300"
_hover={{ bg: 'whiteAlpha.100', color: 'yellow.400', borderColor: 'yellow.400' }}
onClick={() => navigate('/value-forum/my-points')}
/>
</Tooltip>
{/* 发起预测按钮 */}
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
leftIcon={<Plus className="w-5 h-5" />}
size="md"
bg="linear-gradient(135deg, #F59E0B, #D97706)"
color="white"
fontWeight="bold"
_hover={{
bg: 'linear-gradient(135deg, #D97706, #B45309)',
boxShadow: '0 0 30px rgba(245, 158, 11, 0.4)',
}}
onClick={onCreateOpen}
>
</Button>
</motion.div>
</HStack>
</Flex>
{/* 搜索和筛选栏 */}
<Flex gap={4} align="center" flexWrap="wrap">
{/* 搜索框 */}
<InputGroup maxW="400px" flex={1}>
<InputLeftElement pointerEvents="none">
<Search className="w-4 h-4" color="#6B7280" />
</InputLeftElement>
<Input
placeholder="搜索预测话题..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
bg="rgba(255, 255, 255, 0.05)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
color="white"
_placeholder={{ color: 'gray.500' }}
_hover={{ borderColor: 'rgba(255, 255, 255, 0.2)' }}
_focus={{
borderColor: 'yellow.400',
boxShadow: '0 0 0 1px var(--chakra-colors-yellow-400)',
}}
/>
</InputGroup>
{/* 状态筛选 */}
<Select
w="120px"
value={filterBy}
onChange={(e) => setFilterBy(e.target.value as FilterOption)}
bg="rgba(255, 255, 255, 0.05)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
color="white"
_focus={{
borderColor: 'yellow.400',
boxShadow: '0 0 0 1px var(--chakra-colors-yellow-400)',
}}
sx={{
option: { bg: '#1f2937', color: 'white' },
}}
>
<option value="all"></option>
<option value="active"></option>
<option value="settled"></option>
</Select>
{/* 排序选项按钮组 */}
<HStack spacing={2}>
{sortOptions.map((option) => {
const IconComp = option.icon;
const isActive = sortBy === option.value;
return (
<Button
key={option.value}
leftIcon={<IconComp className="w-4 h-4" />}
size="sm"
variant={isActive ? 'solid' : 'outline'}
bg={isActive ? 'linear-gradient(135deg, #F59E0B, #D97706)' : 'transparent'}
color={isActive ? 'white' : 'gray.400'}
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: isActive ? 'linear-gradient(135deg, #D97706, #B45309)' : 'whiteAlpha.100',
borderColor: 'yellow.400',
color: isActive ? 'white' : 'yellow.300',
}}
onClick={() => setSortBy(option.value as SortOption)}
>
{option.label}
</Button>
);
})}
</HStack>
{/* 刷新按钮 */}
<IconButton
aria-label="刷新"
icon={<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />}
variant="ghost"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
onClick={() => loadTopics(true)}
isDisabled={refreshing}
/>
</Flex>
</Box>
{/* 话题列表 */}
<Box
px={6}
py={6}
css={{
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: 'rgba(255, 255, 255, 0.02)',
},
'&::-webkit-scrollbar-thumb': {
background: 'rgba(245, 158, 11, 0.2)',
borderRadius: '4px',
'&:hover': {
background: 'rgba(245, 158, 11, 0.4)',
},
},
}}
>
{loading ? (
<Flex justify="center" align="center" h="300px">
<VStack spacing={4}>
<Spinner size="xl" color="yellow.400" thickness="4px" />
<Text color="gray.500">...</Text>
</VStack>
</Flex>
) : topics.length === 0 ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Flex direction="column" align="center" justify="center" py={16}>
<Box
p={6}
bg="linear-gradient(135deg, rgba(251, 191, 36, 0.15), rgba(245, 158, 11, 0.15))"
borderRadius="full"
mb={6}
>
<Icon as={TrendingUp} boxSize={12} color="yellow.400" />
</Box>
<Text fontSize="xl" color="gray.300" mb={2} fontWeight="medium">
{searchKeyword ? '未找到相关预测话题' : '暂无预测话题'}
</Text>
<Text color="gray.500" mb={6}>
{searchKeyword ? '换个关键词试试?' : '成为第一个发起预测的人!'}
</Text>
{!searchKeyword && (
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
size="lg"
bg="linear-gradient(135deg, #F59E0B, #D97706)"
color="white"
leftIcon={<Zap className="w-5 h-5" />}
fontWeight="bold"
_hover={{
bg: 'linear-gradient(135deg, #D97706, #B45309)',
boxShadow: '0 0 30px rgba(245, 158, 11, 0.4)',
}}
onClick={onCreateOpen}
>
</Button>
</motion.div>
)}
</Flex>
</motion.div>
) : (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing={5}>
<AnimatePresence>
{topics.map((topic, index) => (
<motion.div
key={topic.id}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ delay: index * 0.03, duration: 0.3 }}
>
<PredictionTopicCardDark
topic={topic}
onClick={() => handleTopicClick(topic.id)}
/>
</motion.div>
))}
</AnimatePresence>
</SimpleGrid>
)}
</Box>
{/* 创建话题弹窗 */}
<CreatePredictionModal
isOpen={isCreateOpen}
onClose={onCreateClose}
onTopicCreated={handleTopicCreated}
/>
{/* 玩法说明弹窗 */}
<PredictionGuideModal
isOpen={isGuideOpen}
onClose={onGuideClose}
/>
</Box>
);
};
export default PredictionMarket;

View File

@@ -1,6 +1,7 @@
/** /**
* 股票社区主页面 - HeroUI 深色风格 * 股票社区主页面 - HeroUI 深色风格
* Discord 风格三栏布局:频道列表 | 消息区域 | 右侧面板 * Discord 风格三栏布局:频道列表 | 消息区域 | 右侧面板
* 支持 Tab 切换:社区频道 / 预测市场
*/ */
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
@@ -16,14 +17,21 @@ import {
HStack, HStack,
Icon, Icon,
Tooltip, Tooltip,
Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
Badge,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Menu, Users, Hash, Settings, Bell, Search } from 'lucide-react'; import { Menu, Users, Hash, Settings, Bell, Search, MessageSquare, Zap } from 'lucide-react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import ChannelSidebar from './components/ChannelSidebar'; import ChannelSidebar from './components/ChannelSidebar';
import MessageArea from './components/MessageArea'; import MessageArea from './components/MessageArea';
import RightPanel from './components/RightPanel'; import RightPanel from './components/RightPanel';
import PredictionMarket from './components/PredictionMarket';
import { useCommunitySocket } from './hooks/useCommunitySocket'; import { useCommunitySocket } from './hooks/useCommunitySocket';
import { Channel } from './types'; import { Channel } from './types';
import { GLASS_BLUR, GLASS_BG, GLASS_BORDER, GLASS_SHADOW } from '@/constants/glassConfig'; import { GLASS_BLUR, GLASS_BG, GLASS_BORDER, GLASS_SHADOW } from '@/constants/glassConfig';
@@ -39,6 +47,7 @@ const StockCommunity: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [activeChannel, setActiveChannel] = useState<Channel | null>(null); const [activeChannel, setActiveChannel] = useState<Channel | null>(null);
const [rightPanelContent, setRightPanelContent] = useState<'members' | 'thread' | 'info'>('members'); 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: isLeftOpen, onOpen: onLeftOpen, onClose: onLeftClose } = useDisclosure();
@@ -98,82 +107,13 @@ const StockCommunity: React.FC = () => {
/> />
); );
return ( // 渲染社区频道视图(原有的 Discord 风格三栏布局)
const renderCommunityView = () => (
<Flex <Flex
h="calc(100vh - 60px)" h="full"
bg="gray.900"
overflow="hidden" overflow="hidden"
position="relative" position="relative"
> >
{/* 背景装饰 */}
<Box
position="absolute"
top="-10%"
left="-5%"
w="400px"
h="400px"
bg="purple.500"
filter="blur(150px)"
opacity="0.08"
borderRadius="full"
pointerEvents="none"
/>
<Box
position="absolute"
bottom="-10%"
right="-5%"
w="350px"
h="350px"
bg="blue.500"
filter="blur(150px)"
opacity="0.06"
borderRadius="full"
pointerEvents="none"
/>
{/* 移动端顶部操作栏 */}
{isMobile && (
<Box
position="fixed"
top="60px"
left={0}
right={0}
h="56px"
bg="rgba(17, 24, 39, 0.95)"
backdropFilter={GLASS_BLUR.lg}
borderBottom="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={4}
display="flex"
alignItems="center"
justifyContent="space-between"
zIndex={20}
>
<IconButton
aria-label="打开频道列表"
icon={<Menu className="w-5 h-5" />}
variant="ghost"
color="gray.300"
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
onClick={onLeftOpen}
/>
<HStack spacing={2}>
<Icon as={Hash} boxSize={4} color="gray.400" />
<Text fontWeight="semibold" color="white" fontSize="md">
{activeChannel?.name || '股票社区'}
</Text>
</HStack>
<IconButton
aria-label="打开详情面板"
icon={<Users className="w-5 h-5" />}
variant="ghost"
color="gray.300"
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
onClick={onRightOpen}
/>
</Box>
)}
{/* 左侧频道列表 - 桌面端 */} {/* 左侧频道列表 - 桌面端 */}
<AnimatePresence> <AnimatePresence>
{showLeftSidebar && ( {showLeftSidebar && (
@@ -219,7 +159,6 @@ const StockCommunity: React.FC = () => {
<Box <Box
flex={1} flex={1}
h="full" h="full"
mt={isMobile ? '56px' : 0}
overflow="hidden" overflow="hidden"
bg="rgba(15, 23, 42, 0.6)" bg="rgba(15, 23, 42, 0.6)"
position="relative" position="relative"
@@ -270,6 +209,171 @@ const StockCommunity: React.FC = () => {
</Drawer> </Drawer>
</Flex> </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; export default StockCommunity;

View File

@@ -6,7 +6,7 @@
// 频道相关 // 频道相关
// ============================================================ // ============================================================
export type ChannelType = 'text' | 'forum' | 'announcement' | 'prediction'; export type ChannelType = 'text' | 'forum' | 'announcement' | 'voice';
export interface ChannelCategory { export interface ChannelCategory {
id: string; id: string;