个股论坛重做

This commit is contained in:
2026-01-06 11:30:15 +08:00
parent 7c65b1e066
commit 526337847b
8 changed files with 786 additions and 3 deletions

View File

@@ -0,0 +1,16 @@
-- 添加预测市场频道到社区
-- 在 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

@@ -229,7 +229,7 @@ def create_channel():
if len(name) > 50:
return api_error('频道名称不能超过50个字符')
if channel_type not in ['text', 'forum', 'voice', 'announcement']:
if channel_type not in ['text', 'forum', 'voice', 'announcement', 'prediction']:
return api_error('无效的频道类型')
channel_id = f"ch_{generate_id()}"

View File

@@ -24,7 +24,7 @@ const ModeToggleButtons = React.memo(({ mode, onModeChange }) => {
colorScheme="blue"
variant={mode === 'mainline' ? 'solid' : 'outline'}
>
主线
题材
</Button>
</ButtonGroup>
);

View File

@@ -67,6 +67,7 @@ const channelIcons: Record<ChannelType, any> = {
text: MessageSquare,
forum: FileText,
announcement: Megaphone,
prediction: Zap,
};
// 动画配置
@@ -607,6 +608,7 @@ const ChannelSidebar: React.FC<ChannelSidebarProps> = ({
>
<option value="text" style={{ background: '#1f2937' }}>💬 </option>
<option value="forum" style={{ background: '#1f2937' }}>📝 </option>
<option value="prediction" style={{ background: '#1f2937' }}> </option>
</Select>
</FormControl>

View File

@@ -0,0 +1,324 @@
/**
* 预测话题卡片组件 - HeroUI 深色风格
* 展示预测市场的话题概览
*/
import React from 'react';
import {
Box,
Text,
HStack,
VStack,
Badge,
Progress,
Flex,
Avatar,
Icon,
} from '@chakra-ui/react';
import { motion } from 'framer-motion';
import {
TrendingUp,
TrendingDown,
Crown,
Users,
Clock,
Coins,
Zap,
} from 'lucide-react';
const MotionBox = motion(Box);
interface PredictionTopicCardDarkProps {
topic: any;
onClick: () => void;
}
const PredictionTopicCardDark: React.FC<PredictionTopicCardDarkProps> = ({ topic, onClick }) => {
// 格式化数字
const formatNumber = (num: number) => {
if (num >= 10000) return `${(num / 10000).toFixed(1)}`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num;
};
// 格式化时间
const formatTime = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diff = date.getTime() - now.getTime();
const days = Math.floor(diff / 86400000);
const hours = Math.floor(diff / 3600000);
if (diff < 0) return '已截止';
if (days > 0) return `${days}天后`;
if (hours > 0) return `${hours}小时后`;
return '即将截止';
};
// 获取选项数据
const yesData = topic.positions?.yes || { total_shares: 0, current_price: 500, lord_id: null };
const noData = topic.positions?.no || { total_shares: 0, current_price: 500, lord_id: null };
// 计算总份额
const totalShares = yesData.total_shares + noData.total_shares;
// 计算百分比
const yesPercent = totalShares > 0 ? (yesData.total_shares / totalShares) * 100 : 50;
const noPercent = totalShares > 0 ? (noData.total_shares / totalShares) * 100 : 50;
// 状态配置
const statusConfig: Record<string, { bg: string; color: string; label: string }> = {
active: { bg: 'rgba(74, 222, 128, 0.2)', color: 'green.300', label: '交易中' },
trading_closed: { bg: 'rgba(251, 191, 36, 0.2)', color: 'yellow.300', label: '已截止' },
settled: { bg: 'rgba(148, 163, 184, 0.2)', color: 'gray.400', label: '已结算' },
};
const status = statusConfig[topic.status] || statusConfig.active;
return (
<MotionBox
bg="rgba(255, 255, 255, 0.03)"
borderRadius="xl"
overflow="hidden"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.08)"
cursor="pointer"
onClick={onClick}
whileHover={{ y: -4, scale: 1.01 }}
transition={{ duration: 0.2 }}
_hover={{
borderColor: 'rgba(251, 191, 36, 0.4)',
boxShadow: '0 8px 30px rgba(251, 191, 36, 0.15)',
bg: 'rgba(255, 255, 255, 0.05)',
}}
>
{/* 头部:状态标识 */}
<Box
bg="linear-gradient(135deg, rgba(251, 191, 36, 0.1) 0%, rgba(245, 158, 11, 0.05) 100%)"
px={4}
py={2}
borderBottom="1px solid"
borderColor="rgba(255, 255, 255, 0.08)"
>
<Flex justify="space-between" align="center">
<HStack spacing={2}>
<Icon as={Zap} boxSize={4} color="yellow.400" />
<Text fontSize="xs" fontWeight="bold" color="yellow.400">
</Text>
</HStack>
<Badge
bg={status.bg}
color={status.color}
px={3}
py={1}
borderRadius="full"
fontSize="xs"
fontWeight="bold"
>
{status.label}
</Badge>
</Flex>
</Box>
{/* 内容区域 */}
<VStack align="stretch" p={4} spacing={3}>
{/* 话题标题 */}
<Text
fontSize="md"
fontWeight="700"
color="white"
noOfLines={2}
lineHeight="1.4"
>
{topic.title}
</Text>
{/* 描述 */}
{topic.description && (
<Text
fontSize="sm"
color="gray.400"
noOfLines={2}
lineHeight="1.5"
>
{topic.description}
</Text>
)}
{/* 双向价格卡片 */}
<HStack spacing={3} w="full">
{/* Yes 方 */}
<Box
flex={1}
bg="linear-gradient(135deg, rgba(74, 222, 128, 0.1) 0%, rgba(74, 222, 128, 0.03) 100%)"
border="1px solid"
borderColor="rgba(74, 222, 128, 0.3)"
borderRadius="lg"
p={3}
position="relative"
overflow="hidden"
>
{/* 领主徽章 */}
{yesData.lord_id && (
<Icon
as={Crown}
position="absolute"
top={2}
right={2}
boxSize={4}
color="yellow.400"
/>
)}
<VStack spacing={1} align="start">
<HStack spacing={1}>
<Icon as={TrendingUp} boxSize={3.5} color="green.400" />
<Text fontSize="xs" fontWeight="600" color="green.400">
/ Yes
</Text>
</HStack>
<Text fontSize="xl" fontWeight="bold" color="green.400">
{Math.round(yesData.current_price)}
<Text as="span" fontSize="xs" ml={1} color="green.500">
</Text>
</Text>
<Text fontSize="xs" color="gray.500">
{yesData.total_shares} · {yesPercent.toFixed(0)}%
</Text>
</VStack>
</Box>
{/* No 方 */}
<Box
flex={1}
bg="linear-gradient(135deg, rgba(248, 113, 113, 0.1) 0%, rgba(248, 113, 113, 0.03) 100%)"
border="1px solid"
borderColor="rgba(248, 113, 113, 0.3)"
borderRadius="lg"
p={3}
position="relative"
overflow="hidden"
>
{/* 领主徽章 */}
{noData.lord_id && (
<Icon
as={Crown}
position="absolute"
top={2}
right={2}
boxSize={4}
color="yellow.400"
/>
)}
<VStack spacing={1} align="start">
<HStack spacing={1}>
<Icon as={TrendingDown} boxSize={3.5} color="red.400" />
<Text fontSize="xs" fontWeight="600" color="red.400">
/ No
</Text>
</HStack>
<Text fontSize="xl" fontWeight="bold" color="red.400">
{Math.round(noData.current_price)}
<Text as="span" fontSize="xs" ml={1} color="red.500">
</Text>
</Text>
<Text fontSize="xs" color="gray.500">
{noData.total_shares} · {noPercent.toFixed(0)}%
</Text>
</VStack>
</Box>
</HStack>
{/* 市场情绪进度条 */}
<Box>
<Flex justify="space-between" mb={1}>
<Text fontSize="xs" color="gray.500">
</Text>
<Text fontSize="xs" color="gray.500">
{yesPercent.toFixed(0)}% vs {noPercent.toFixed(0)}%
</Text>
</Flex>
<Progress
value={yesPercent}
size="sm"
borderRadius="full"
bg="rgba(248, 113, 113, 0.3)"
sx={{
'& > div': {
bg: 'linear-gradient(90deg, #4ADE80 0%, #22C55E 100%)',
},
}}
/>
</Box>
{/* 奖池和数据 */}
<HStack spacing={4} fontSize="sm" color="gray.500">
<HStack spacing={1}>
<Icon as={Coins} boxSize={4} color="yellow.400" />
<Text fontWeight="600" color="yellow.400">
{formatNumber(topic.total_pool || 0)}
</Text>
<Text fontSize="xs"></Text>
</HStack>
<HStack spacing={1}>
<Icon as={Users} boxSize={4} />
<Text>{topic.stats?.unique_traders?.size || topic.participants_count || 0}</Text>
</HStack>
<HStack spacing={1}>
<Icon as={Clock} boxSize={4} />
<Text>{formatTime(topic.deadline)}</Text>
</HStack>
</HStack>
{/* 底部:作者信息 */}
<Flex
justify="space-between"
align="center"
pt={2}
borderTop="1px solid"
borderColor="rgba(255, 255, 255, 0.08)"
>
<HStack spacing={2}>
<Avatar
size="xs"
name={topic.author_name || topic.creator_name}
src={topic.author_avatar}
bg="linear-gradient(135deg, rgba(251, 191, 36, 0.6), rgba(245, 158, 11, 0.6))"
/>
<Text fontSize="xs" color="gray.500">
{topic.author_name || topic.creator_name}
</Text>
</HStack>
{/* 分类标签 */}
{topic.category && (
<Badge
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
px={2}
py={1}
borderRadius="md"
fontSize="xs"
>
{topic.category}
</Badge>
)}
</Flex>
</VStack>
</MotionBox>
);
};
export default PredictionTopicCardDark;

View File

@@ -0,0 +1,433 @@
/**
* 预测市场频道组件 - 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,6 +17,7 @@ import { MessageSquare, Hash, Users } from 'lucide-react';
import { Channel } from '../../types';
import TextChannel from './TextChannel';
import ForumChannel from './ForumChannel';
import PredictionChannel from './PredictionChannel';
import { GLASS_BLUR } from '@/constants/glassConfig';
interface MessageAreaProps {
@@ -153,6 +154,13 @@ const MessageArea: React.FC<MessageAreaProps> = ({
/>
);
case 'prediction':
return (
<PredictionChannel
channel={channel}
/>
);
case 'announcement':
return (
<TextChannel

View File

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