434 lines
13 KiB
TypeScript
434 lines
13 KiB
TypeScript
/**
|
||
* 预测市场频道组件 - 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;
|