Files
vf_react/src/views/StockCommunity/components/MessageArea/PredictionChannel/index.tsx
2026-01-06 11:30:15 +08:00

434 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 预测市场频道组件 - 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;