事件中心的涨停原因里面和事件相关

This commit is contained in:
2026-01-11 09:14:48 +08:00
parent 2eb876ebbf
commit d26bec9b23
5 changed files with 1140 additions and 82 deletions

View File

@@ -3,7 +3,7 @@
* 展示预测市场的完整信息、交易、评论等
*/
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
Heading,
@@ -20,17 +20,21 @@ import {
useDisclosure,
useToast,
SimpleGrid,
Alert,
AlertIcon,
} from '@chakra-ui/react';
import {
TrendingUp,
TrendingDown,
Crown,
Users,
Clock,
Coins,
ShoppingCart,
ArrowLeftRight,
CheckCircle2,
Lock,
Gavel,
Trophy,
} from 'lucide-react';
import { useParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
@@ -41,6 +45,8 @@ import { LAYOUT_SIZE } from '@/layouts/config/layoutConfig';
import TradeModal from './components/TradeModal';
import PredictionCommentSection from './components/PredictionCommentSection';
import CommentInvestModal from './components/CommentInvestModal';
import SettleTopicModal from './components/SettleTopicModal';
import CountdownTimer, { useCountdown } from './components/CountdownTimer';
const MotionBox = motion(Box);
@@ -70,6 +76,12 @@ const PredictionTopicDetail = () => {
onClose: onInvestModalClose,
} = useDisclosure();
const {
isOpen: isSettleModalOpen,
onOpen: onSettleModalOpen,
onClose: onSettleModalClose,
} = useDisclosure();
// 加载话题数据
useEffect(() => {
const loadTopic = async () => {
@@ -184,6 +196,47 @@ const PredictionTopicDetail = () => {
}
};
// 使用倒计时 Hook
const timeLeft = useCountdown(topic?.deadline);
// 计算实际状态(基于截止时间自动判断)
const effectiveStatus = useMemo(() => {
if (!topic) return 'active';
// 如果已经是结算状态,保持不变
if (topic.status === 'settled') return 'settled';
// 如果已过截止时间,自动变为已截止
if (timeLeft.expired && topic.status === 'active') return 'trading_closed';
return topic.status;
}, [topic?.status, timeLeft.expired]);
// 判断当前用户是否是作者
const isAuthor = useMemo(() => {
if (!user || !topic) return false;
return user.id === topic.author_id || user.username === topic.author_name;
}, [user, topic]);
// 判断是否可以结算(作者 + 已截止 + 未结算)
const canSettle = useMemo(() => {
return isAuthor && effectiveStatus === 'trading_closed';
}, [isAuthor, effectiveStatus]);
// 判断是否可以交易
const canTrade = useMemo(() => {
return effectiveStatus === 'active' && !isAuthor;
}, [effectiveStatus, isAuthor]);
// 结算成功回调
const handleSettleSuccess = async () => {
try {
const topicResponse = await getTopicDetail(topicId);
if (topicResponse.success) {
setTopic(topicResponse.data);
}
} catch (error) {
console.error('刷新数据失败:', error);
}
};
if (!topic) {
return null;
}
@@ -207,20 +260,19 @@ const PredictionTopicDetail = () => {
const yesPercent = totalShares > 0 ? (yesData.total_shares / totalShares) * 100 : 50;
const noPercent = totalShares > 0 ? (noData.total_shares / totalShares) * 100 : 50;
// 格式化时间
const formatTime = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diff = date - now;
const days = Math.floor(diff / 86400000);
const hours = Math.floor(diff / 3600000);
if (days > 0) return `${days}天后`;
if (hours > 0) return `${hours}小时后`;
return '即将截止';
// 获取结算结果显示
const getResultDisplay = () => {
if (topic.status !== 'settled' || !topic.result) return null;
const resultMap = {
yes: { label: '看涨方获胜', color: 'green', icon: TrendingUp },
no: { label: '看跌方获胜', color: 'red', icon: TrendingDown },
draw: { label: '平局', color: 'gray', icon: null },
};
return resultMap[topic.result];
};
const resultDisplay = getResultDisplay();
return (
<Box minH="100vh" bg={forumColors.background.main} pt={LAYOUT_SIZE.navbarHeight} pb={{ base: "6", md: "20" }}>
{/* 头部:返回按钮 */}
@@ -258,26 +310,73 @@ const PredictionTopicDetail = () => {
borderBottom="1px solid"
borderColor={forumColors.border.default}
>
<HStack justify="space-between">
<Badge
bg={forumColors.primary[500]}
color="white"
px="3"
py="1"
borderRadius="full"
fontSize="sm"
>
{topic.category}
</Badge>
<HStack justify="space-between" flexWrap="wrap" gap="2">
<HStack spacing="2">
<Badge
bg={forumColors.primary[500]}
color="white"
px="3"
py="1"
borderRadius="full"
fontSize="sm"
>
{topic.category}
</Badge>
<HStack spacing="3">
<Icon as={Clock} boxSize="16px" color={forumColors.text.secondary} />
<Text fontSize="sm" color={forumColors.text.secondary}>
{formatTime(topic.deadline)} 截止
</Text>
{/* 状态标识 */}
<Badge
bg={
effectiveStatus === 'active' ? forumColors.success[500] :
effectiveStatus === 'trading_closed' ? forumColors.warning[500] :
forumColors.text.secondary
}
color="white"
px="3"
py="1"
borderRadius="full"
fontSize="sm"
display="flex"
alignItems="center"
gap="1"
>
<Icon
as={
effectiveStatus === 'active' ? ShoppingCart :
effectiveStatus === 'trading_closed' ? Lock :
CheckCircle2
}
boxSize="12px"
/>
{effectiveStatus === 'active' ? '交易中' :
effectiveStatus === 'trading_closed' ? '等待结算' : '已结算'}
</Badge>
</HStack>
{/* 倒计时 */}
<CountdownTimer
deadline={topic.deadline}
status={effectiveStatus}
size="md"
variant="badge"
showIcon={true}
/>
</HStack>
{/* 结算结果展示 */}
{resultDisplay && (
<Alert
status={topic.result === 'yes' ? 'success' : topic.result === 'no' ? 'error' : 'info'}
mt="4"
borderRadius="lg"
bg={`${resultDisplay.color}.50`}
>
<Icon as={Trophy} boxSize="20px" color={`${resultDisplay.color}.500`} mr="2" />
<Text fontWeight="600" color={`${resultDisplay.color}.700`}>
结算结果{resultDisplay.label}
</Text>
</Alert>
)}
<Heading
as="h1"
fontSize={{ base: "lg", md: "2xl" }}
@@ -538,8 +637,8 @@ const PredictionTopicDetail = () => {
</VStack>
</Box>
{/* 交易按钮 */}
{topic.status === 'active' && (
{/* 交易按钮 - 只在可交易时显示 */}
{canTrade && (
<VStack spacing="3">
<Button
leftIcon={<ShoppingCart size={18} />}
@@ -581,6 +680,104 @@ const PredictionTopicDetail = () => {
</VStack>
)}
{/* 作者提示 - 不能参与自己的话题 */}
{isAuthor && effectiveStatus === 'active' && (
<Alert
status="info"
bg={forumColors.background.hover}
borderRadius="lg"
border="1px solid"
borderColor={forumColors.border.default}
>
<AlertIcon color={forumColors.primary[500]} />
<Text fontSize="sm" color={forumColors.text.secondary}>
作为话题创建者你不能参与自己发起的预测
</Text>
</Alert>
)}
{/* 结算按钮 - 作者在截止后可见 */}
{canSettle && (
<VStack spacing="3">
<Alert
status="warning"
bg="rgba(221, 107, 32, 0.1)"
borderRadius="lg"
border="1px solid"
borderColor="orange.300"
>
<AlertIcon color="orange.400" />
<VStack align="start" spacing="1" flex="1">
<Text fontSize="sm" fontWeight="600" color={forumColors.text.primary}>
交易已截止
</Text>
<Text fontSize="xs" color={forumColors.text.secondary}>
请根据实际结果进行结算
</Text>
</VStack>
</Alert>
<Button
leftIcon={<Gavel size={18} />}
bg="linear-gradient(135deg, #DD6B20 0%, #C05621 100%)"
color="white"
size="lg"
w="full"
h={{ base: "12", md: "auto" }}
fontWeight="bold"
fontSize={{ base: "md", md: "lg" }}
onClick={onSettleModalOpen}
_hover={{
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(221, 107, 32, 0.4)',
}}
_active={{ transform: 'translateY(0)' }}
>
结算话题
</Button>
</VStack>
)}
{/* 已结算状态提示 */}
{effectiveStatus === 'settled' && (
<Box
bg={forumColors.background.hover}
borderRadius="lg"
p="4"
border="1px solid"
borderColor={forumColors.border.default}
textAlign="center"
>
<Icon as={CheckCircle2} boxSize="32px" color={forumColors.success[500]} mb="2" />
<Text fontSize="md" fontWeight="600" color={forumColors.text.primary}>
话题已结算
</Text>
<Text fontSize="sm" color={forumColors.text.secondary} mt="1">
奖池已分配给获胜方
</Text>
</Box>
)}
{/* 等待结算状态提示(非作者) */}
{effectiveStatus === 'trading_closed' && !isAuthor && (
<Box
bg="rgba(221, 107, 32, 0.1)"
borderRadius="lg"
p="4"
border="1px solid"
borderColor="orange.300"
textAlign="center"
>
<Icon as={Lock} boxSize="32px" color="orange.400" mb="2" />
<Text fontSize="md" fontWeight="600" color={forumColors.text.primary}>
交易已截止
</Text>
<Text fontSize="sm" color={forumColors.text.secondary} mt="1">
等待话题作者提交结算结果
</Text>
</Box>
)}
{/* 用户余额 */}
{user && userAccount && (
<Box
@@ -643,6 +840,14 @@ const PredictionTopicDetail = () => {
topic={topic}
onInvestSuccess={handleInvestSuccess}
/>
{/* 结算模态框 */}
<SettleTopicModal
isOpen={isSettleModalOpen}
onClose={onSettleModalClose}
topic={topic}
onSettleSuccess={handleSettleSuccess}
/>
</Box>
);
};

View File

@@ -0,0 +1,277 @@
/**
* 倒计时组件
* 实时显示距离截止时间的倒计时,参考 Polymarket 设计
*/
import React, { useState, useEffect, useMemo } from 'react';
import { HStack, Text, Box, Icon, Badge } from '@chakra-ui/react';
import { Clock, AlertTriangle, CheckCircle2, Lock } from 'lucide-react';
import { forumColors } from '@theme/forumTheme';
/**
* 计算时间差
* @param {string|Date} deadline - 截止时间
* @returns {object} 时间差对象
*/
const calculateTimeLeft = (deadline) => {
const now = new Date();
const end = new Date(deadline);
const diff = end - now;
if (diff <= 0) {
return { expired: true, days: 0, hours: 0, minutes: 0, seconds: 0, totalMs: 0 };
}
return {
expired: false,
days: Math.floor(diff / (1000 * 60 * 60 * 24)),
hours: Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)),
minutes: Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)),
seconds: Math.floor((diff % (1000 * 60)) / 1000),
totalMs: diff,
};
};
/**
* 倒计时 Hook
*/
export const useCountdown = (deadline) => {
const [timeLeft, setTimeLeft] = useState(() => calculateTimeLeft(deadline));
useEffect(() => {
// 已过期则不需要更新
if (timeLeft.expired) return;
// 根据剩余时间决定更新频率
const interval = timeLeft.totalMs < 60000 ? 1000 : 60000; // 小于1分钟时秒级更新
const timer = setInterval(() => {
const newTimeLeft = calculateTimeLeft(deadline);
setTimeLeft(newTimeLeft);
if (newTimeLeft.expired) {
clearInterval(timer);
}
}, interval);
return () => clearInterval(timer);
}, [deadline, timeLeft.expired, timeLeft.totalMs]);
return timeLeft;
};
/**
* 格式化倒计时显示
*/
const formatCountdown = (timeLeft) => {
if (timeLeft.expired) {
return '已截止';
}
if (timeLeft.days > 0) {
return `${timeLeft.days}${timeLeft.hours}小时`;
}
if (timeLeft.hours > 0) {
return `${timeLeft.hours}小时 ${timeLeft.minutes}`;
}
if (timeLeft.minutes > 0) {
return `${timeLeft.minutes}${timeLeft.seconds}`;
}
return `${timeLeft.seconds}`;
};
/**
* 获取倒计时状态和颜色
*/
const getCountdownStatus = (timeLeft) => {
if (timeLeft.expired) {
return {
status: 'expired',
color: forumColors.text.secondary,
bgColor: forumColors.background.hover,
icon: Lock,
label: '已截止',
};
}
// 小于1小时
if (timeLeft.days === 0 && timeLeft.hours === 0) {
return {
status: 'critical',
color: '#E53E3E',
bgColor: 'rgba(229, 62, 62, 0.1)',
icon: AlertTriangle,
label: '即将截止',
};
}
// 小于24小时
if (timeLeft.days === 0) {
return {
status: 'warning',
color: '#DD6B20',
bgColor: 'rgba(221, 107, 32, 0.1)',
icon: Clock,
label: '今日截止',
};
}
// 小于3天
if (timeLeft.days < 3) {
return {
status: 'soon',
color: '#D69E2E',
bgColor: 'rgba(214, 158, 46, 0.1)',
icon: Clock,
label: '即将截止',
};
}
return {
status: 'normal',
color: forumColors.text.secondary,
bgColor: 'transparent',
icon: Clock,
label: '交易中',
};
};
/**
* 倒计时显示组件
*/
const CountdownTimer = ({
deadline,
status = 'active', // active, trading_closed, settled
size = 'md', // sm, md, lg
showIcon = true,
showLabel = false,
variant = 'inline', // inline, badge, card
onExpire,
}) => {
const timeLeft = useCountdown(deadline);
const countdownStatus = getCountdownStatus(timeLeft);
// 当倒计时结束时触发回调
useEffect(() => {
if (timeLeft.expired && onExpire) {
onExpire();
}
}, [timeLeft.expired, onExpire]);
// 如果话题已结算,显示结算状态
if (status === 'settled') {
return (
<HStack spacing="1">
<Icon as={CheckCircle2} boxSize={size === 'sm' ? '14px' : '16px'} color={forumColors.success[500]} />
<Text fontSize={size} color={forumColors.success[500]} fontWeight="600">
已结算
</Text>
</HStack>
);
}
// 如果话题已截止(手动设置的状态)
if (status === 'trading_closed') {
return (
<HStack spacing="1">
<Icon as={Lock} boxSize={size === 'sm' ? '14px' : '16px'} color={forumColors.warning[500]} />
<Text fontSize={size} color={forumColors.warning[500]} fontWeight="600">
等待结算
</Text>
</HStack>
);
}
const sizeMap = {
sm: { fontSize: 'xs', iconSize: '12px', px: 2, py: 1 },
md: { fontSize: 'sm', iconSize: '14px', px: 3, py: 1 },
lg: { fontSize: 'md', iconSize: '16px', px: 4, py: 2 },
};
const sizeConfig = sizeMap[size];
// Badge 变体
if (variant === 'badge') {
return (
<Badge
display="flex"
alignItems="center"
gap="1"
bg={countdownStatus.bgColor}
color={countdownStatus.color}
px={sizeConfig.px}
py={sizeConfig.py}
borderRadius="full"
fontSize={sizeConfig.fontSize}
fontWeight="600"
border="1px solid"
borderColor={countdownStatus.color}
>
{showIcon && <Icon as={countdownStatus.icon} boxSize={sizeConfig.iconSize} />}
{showLabel && <Text>{countdownStatus.label}</Text>}
<Text>{formatCountdown(timeLeft)}</Text>
</Badge>
);
}
// Card 变体 - 更详细的显示
if (variant === 'card') {
return (
<Box
bg={countdownStatus.bgColor}
border="1px solid"
borderColor={countdownStatus.color}
borderRadius="lg"
p="3"
>
<HStack justify="space-between" align="center">
<HStack spacing="2">
<Icon as={countdownStatus.icon} boxSize="18px" color={countdownStatus.color} />
<Text fontSize="sm" color={forumColors.text.secondary}>
{timeLeft.expired ? '已截止' : '距离截止'}
</Text>
</HStack>
<Text fontSize="lg" fontWeight="bold" color={countdownStatus.color}>
{formatCountdown(timeLeft)}
</Text>
</HStack>
{!timeLeft.expired && timeLeft.days === 0 && (
<Text fontSize="xs" color={countdownStatus.color} mt="2">
交易即将关闭请尽快完成交易
</Text>
)}
</Box>
);
}
// 默认 inline 变体
return (
<HStack spacing="1">
{showIcon && (
<Icon
as={countdownStatus.icon}
boxSize={sizeConfig.iconSize}
color={countdownStatus.color}
/>
)}
{showLabel && (
<Text fontSize={sizeConfig.fontSize} color={countdownStatus.color}>
{countdownStatus.label}:
</Text>
)}
<Text
fontSize={sizeConfig.fontSize}
color={countdownStatus.color}
fontWeight={timeLeft.days < 1 ? '600' : '400'}
>
{formatCountdown(timeLeft)}
</Text>
</HStack>
);
};
export default CountdownTimer;

View File

@@ -3,7 +3,7 @@
* 用户可以发起新的预测市场话题
*/
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import {
Modal,
ModalOverlay,
@@ -26,8 +26,15 @@ import {
Alert,
AlertIcon,
useToast,
RadioGroup,
Radio,
Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
} from '@chakra-ui/react';
import { Zap, Calendar, DollarSign } from 'lucide-react';
import { Zap, Calendar, DollarSign, Clock, AlertTriangle } from 'lucide-react';
import { forumColors } from '@theme/forumTheme';
import { createTopic, getUserAccount } from '@services/predictionMarketService.api';
import { CREDIT_CONFIG } from '@services/creditSystemService';
@@ -43,7 +50,10 @@ const CreatePredictionModal = ({ isOpen, onClose, onTopicCreated }) => {
title: '',
description: '',
category: 'stock',
deadline_type: 'preset', // 'preset' 或 'custom'
deadline_days: 7,
custom_date: '',
custom_time: '15:00', // 默认下午3点A股收盘时间
});
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -70,6 +80,63 @@ const CreatePredictionModal = ({ isOpen, onClose, onTopicCreated }) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// 计算截止时间
const calculatedDeadline = useMemo(() => {
if (formData.deadline_type === 'preset') {
const deadline = new Date();
deadline.setDate(deadline.getDate() + parseInt(formData.deadline_days));
deadline.setHours(15, 0, 0, 0); // 默认下午3点
return deadline;
} else {
if (!formData.custom_date) return null;
const [hours, minutes] = formData.custom_time.split(':');
const deadline = new Date(formData.custom_date);
deadline.setHours(parseInt(hours), parseInt(minutes), 0, 0);
return deadline;
}
}, [formData.deadline_type, formData.deadline_days, formData.custom_date, formData.custom_time]);
// 格式化截止时间显示
const formatDeadlineDisplay = (date) => {
if (!date) return '请选择日期';
const now = new Date();
const diff = date - now;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const dateStr = date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'short',
});
const timeStr = date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
if (days > 0) {
return `${dateStr} ${timeStr}${days}${hours}小时后)`;
} else if (hours > 0) {
return `${dateStr} ${timeStr}${hours}小时后)`;
}
return `${dateStr} ${timeStr}`;
};
// 获取最小日期(明天)
const getMinDate = () => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
return tomorrow.toISOString().split('T')[0];
};
// 获取最大日期90天后
const getMaxDate = () => {
const maxDate = new Date();
maxDate.setDate(maxDate.getDate() + 90);
return maxDate.toISOString().split('T')[0];
};
// 提交表单
const handleSubmit = async () => {
try {
@@ -105,9 +172,27 @@ const CreatePredictionModal = ({ isOpen, onClose, onTopicCreated }) => {
return;
}
// 计算截止时间
const deadline = new Date();
deadline.setDate(deadline.getDate() + parseInt(formData.deadline_days));
// 验证截止时间
if (!calculatedDeadline) {
toast({
title: '请设置截止时间',
status: 'warning',
duration: 3000,
});
return;
}
// 检查截止时间是否在未来
if (calculatedDeadline <= new Date()) {
toast({
title: '截止时间必须在未来',
status: 'warning',
duration: 3000,
});
return;
}
const deadline = calculatedDeadline;
// 调用 API 创建话题
const response = await createTopic({
@@ -130,7 +215,10 @@ const CreatePredictionModal = ({ isOpen, onClose, onTopicCreated }) => {
title: '',
description: '',
category: 'stock',
deadline_type: 'preset',
deadline_days: 7,
custom_date: '',
custom_time: '15:00',
});
// 通知父组件
@@ -288,27 +376,175 @@ const CreatePredictionModal = ({ isOpen, onClose, onTopicCreated }) => {
<Text>交易截止时间</Text>
</HStack>
</FormLabel>
<Select
value={formData.deadline_days}
onChange={(e) => handleChange('deadline_days', e.target.value)}
<Tabs
variant="soft-rounded"
colorScheme="yellow"
size="sm"
index={formData.deadline_type === 'preset' ? 0 : 1}
onChange={(index) => handleChange('deadline_type', index === 0 ? 'preset' : 'custom')}
mb="3"
>
<TabList bg={forumColors.background.main} borderRadius="lg" p="1">
<Tab
_selected={{ bg: forumColors.primary[500], color: 'white' }}
color={forumColors.text.secondary}
>
快速选择
</Tab>
<Tab
_selected={{ bg: forumColors.primary[500], color: 'white' }}
color={forumColors.text.secondary}
>
自定义日期
</Tab>
</TabList>
<TabPanels>
{/* 快速选择 */}
<TabPanel px="0" py="3">
<RadioGroup
value={String(formData.deadline_days)}
onChange={(value) => handleChange('deadline_days', parseInt(value))}
>
<VStack spacing="2" align="stretch">
{[
{ value: '1', label: '1天后', desc: '短期预测' },
{ value: '3', label: '3天后', desc: '适合事件预测' },
{ value: '7', label: '7天后', desc: '推荐,适合周度预测' },
{ value: '14', label: '14天后', desc: '适合趋势预测' },
{ value: '30', label: '30天后', desc: '长期预测' },
].map((option) => (
<Box
key={option.value}
as="label"
cursor="pointer"
bg={formData.deadline_days === parseInt(option.value)
? forumColors.gradients.goldSubtle
: forumColors.background.main
}
border="1px solid"
borderColor={formData.deadline_days === parseInt(option.value)
? forumColors.border.gold
: forumColors.border.default
}
borderRadius="lg"
p="3"
transition="all 0.2s"
_hover={{
borderColor: forumColors.border.gold,
}}
>
<HStack justify="space-between">
<HStack spacing="3">
<Radio value={option.value} colorScheme="yellow" />
<VStack align="start" spacing="0">
<Text fontSize="sm" fontWeight="600" color={forumColors.text.primary}>
{option.label}
</Text>
<Text fontSize="xs" color={forumColors.text.tertiary}>
{option.desc}
</Text>
</VStack>
</HStack>
{option.value === '7' && (
<Text fontSize="xs" color={forumColors.primary[500]} fontWeight="600">
推荐
</Text>
)}
</HStack>
</Box>
))}
</VStack>
</RadioGroup>
</TabPanel>
{/* 自定义日期时间 */}
<TabPanel px="0" py="3">
<VStack spacing="3" align="stretch">
<HStack spacing="3">
<FormControl flex="2">
<FormLabel fontSize="xs" color={forumColors.text.secondary}>
日期
</FormLabel>
<Input
type="date"
value={formData.custom_date}
onChange={(e) => handleChange('custom_date', e.target.value)}
min={getMinDate()}
max={getMaxDate()}
bg={forumColors.background.main}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_hover={{ borderColor: forumColors.border.light }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
/>
</FormControl>
<FormControl flex="1">
<FormLabel fontSize="xs" color={forumColors.text.secondary}>
时间
</FormLabel>
<Input
type="time"
value={formData.custom_time}
onChange={(e) => handleChange('custom_time', e.target.value)}
bg={forumColors.background.main}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_hover={{ borderColor: forumColors.border.light }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
/>
</FormControl>
</HStack>
<Alert
status="info"
bg={forumColors.background.main}
borderRadius="md"
border="1px solid"
borderColor={forumColors.border.default}
py="2"
>
<Icon as={Clock} boxSize="14px" color={forumColors.primary[500]} mr="2" />
<Text fontSize="xs" color={forumColors.text.secondary}>
建议选择 15:00A股收盘时间作为截止时间
</Text>
</Alert>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
{/* 截止时间预览 */}
<Box
bg={forumColors.background.main}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_hover={{ borderColor: forumColors.border.light }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
borderRadius="lg"
p="3"
>
<option value="1">1天后</option>
<option value="3">3天后</option>
<option value="7">7天后推荐</option>
<option value="14">14天后</option>
<option value="30">30天后</option>
</Select>
<HStack spacing="2">
<Icon as={Clock} boxSize="16px" color={forumColors.primary[500]} />
<Text fontSize="sm" color={forumColors.text.primary} fontWeight="500">
截止时间
</Text>
<Text fontSize="sm" color={forumColors.text.secondary}>
{formatDeadlineDisplay(calculatedDeadline)}
</Text>
</HStack>
</Box>
<Text fontSize="xs" color={forumColors.text.tertiary} mt="2">
截止后次日可提交结果进行结算
截止后交易自动锁定由作者提交结果进行结算
</Text>
</FormControl>

View File

@@ -3,7 +3,7 @@
* 展示预测市场的话题概览
*/
import React from 'react';
import React, { useMemo } from 'react';
import {
Box,
Text,
@@ -14,7 +14,6 @@ import {
Flex,
Avatar,
Icon,
useColorModeValue,
} from '@chakra-ui/react';
import { motion } from 'framer-motion';
import {
@@ -22,18 +21,23 @@ import {
TrendingDown,
Crown,
Users,
Clock,
Coins,
Zap,
Lock,
CheckCircle2,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { forumColors } from '@theme/forumTheme';
import CountdownTimer, { useCountdown } from './CountdownTimer';
const MotionBox = motion(Box);
const PredictionTopicCard = ({ topic }) => {
const navigate = useNavigate();
// 使用倒计时 Hook 获取实时状态
const timeLeft = useCountdown(topic.deadline);
// 处理卡片点击
const handleCardClick = () => {
navigate(`/value-forum/prediction/${topic.id}`);
@@ -46,19 +50,14 @@ const PredictionTopicCard = ({ topic }) => {
return num;
};
// 格式化时间
const formatTime = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diff = date - now;
const days = Math.floor(diff / 86400000);
const hours = Math.floor(diff / 3600000);
if (days > 0) return `${days}天后`;
if (hours > 0) return `${hours}小时后`;
return '即将截止';
};
// 计算实际状态(基于截止时间自动判断)
const effectiveStatus = useMemo(() => {
// 如果已经是结算状态,保持不变
if (topic.status === 'settled') return 'settled';
// 如果已过截止时间,自动变为已截止
if (timeLeft.expired && topic.status === 'active') return 'trading_closed';
return topic.status;
}, [topic.status, timeLeft.expired]);
// 获取选项数据
const yesData = topic.positions?.yes || { total_shares: 0, current_price: 500, lord_id: null };
@@ -71,7 +70,7 @@ const PredictionTopicCard = ({ topic }) => {
const yesPercent = totalShares > 0 ? (yesData.total_shares / totalShares) * 100 : 50;
const noPercent = totalShares > 0 ? (noData.total_shares / totalShares) * 100 : 50;
// 状态颜色
// 状态颜色(使用 effectiveStatus
const statusColorMap = {
active: forumColors.success[500],
trading_closed: forumColors.warning[500],
@@ -80,10 +79,16 @@ const PredictionTopicCard = ({ topic }) => {
const statusLabelMap = {
active: '交易中',
trading_closed: '已截止',
trading_closed: '等待结算',
settled: '已结算',
};
const statusIconMap = {
active: Zap,
trading_closed: Lock,
settled: CheckCircle2,
};
return (
<MotionBox
bg={forumColors.background.card}
@@ -110,14 +115,14 @@ const PredictionTopicCard = ({ topic }) => {
>
<Flex justify="space-between" align="center">
<HStack spacing="2">
<Icon as={Zap} boxSize="16px" color={forumColors.primary[500]} />
<Text fontSize="xs" fontWeight="bold" color={forumColors.primary[500]}>
预测市场
<Icon as={statusIconMap[effectiveStatus]} boxSize="16px" color={statusColorMap[effectiveStatus]} />
<Text fontSize="xs" fontWeight="bold" color={statusColorMap[effectiveStatus]}>
{statusLabelMap[effectiveStatus]}
</Text>
</HStack>
<Badge
bg={statusColorMap[topic.status]}
bg={statusColorMap[effectiveStatus]}
color="white"
px="3"
py="1"
@@ -125,7 +130,7 @@ const PredictionTopicCard = ({ topic }) => {
fontSize="xs"
fontWeight="bold"
>
{statusLabelMap[topic.status]}
{effectiveStatus === 'active' ? '可交易' : effectiveStatus === 'trading_closed' ? '待结算' : '已完成'}
</Badge>
</Flex>
</Box>
@@ -270,7 +275,7 @@ const PredictionTopicCard = ({ topic }) => {
</Box>
{/* 奖池和数据 */}
<HStack spacing="4" fontSize="sm" color={forumColors.text.secondary}>
<HStack spacing="4" fontSize="sm" color={forumColors.text.secondary} flexWrap="wrap">
<HStack spacing="1">
<Icon as={Coins} boxSize="16px" color={forumColors.primary[500]} />
<Text fontWeight="600" color={forumColors.primary[500]}>
@@ -281,13 +286,16 @@ const PredictionTopicCard = ({ topic }) => {
<HStack spacing="1">
<Icon as={Users} boxSize="16px" />
<Text>{topic.stats?.unique_traders?.size || 0}</Text>
<Text>{topic.participants_count || topic.stats?.unique_traders?.size || 0}</Text>
</HStack>
<HStack spacing="1">
<Icon as={Clock} boxSize="16px" />
<Text>{formatTime(topic.deadline)}</Text>
</HStack>
{/* 使用新的倒计时组件 */}
<CountdownTimer
deadline={topic.deadline}
status={effectiveStatus}
size="sm"
showIcon={true}
/>
</HStack>
{/* 底部:作者信息 */}

View File

@@ -0,0 +1,332 @@
/**
* 话题结算模态框
* 允许话题创建者在截止后提交结算结果
*/
import React, { useState } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
Button,
VStack,
HStack,
Text,
Box,
Icon,
Alert,
AlertIcon,
useToast,
RadioGroup,
Radio,
Divider,
} from '@chakra-ui/react';
import { CheckCircle2, TrendingUp, TrendingDown, Scale, AlertTriangle, Coins } from 'lucide-react';
import { forumColors } from '@theme/forumTheme';
import { settleTopic } from '@services/predictionMarketService.api';
import { GLASS_BLUR } from '@/constants/glassConfig';
const SettleTopicModal = ({ isOpen, onClose, topic, onSettleSuccess }) => {
const toast = useToast();
const [selectedResult, setSelectedResult] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
// 获取选项数据
const yesData = {
total_shares: topic?.yes_total_shares || 0,
current_price: topic?.yes_price || 500,
};
const noData = {
total_shares: topic?.no_total_shares || 0,
current_price: topic?.no_price || 500,
};
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 resultOptions = [
{
value: 'yes',
label: '看涨方获胜 (Yes)',
icon: TrendingUp,
color: 'green',
description: `${yesData.total_shares} 份持仓将获得奖池分红`,
poolShare: yesData.total_shares > 0 ? topic?.total_pool : 0,
},
{
value: 'no',
label: '看跌方获胜 (No)',
icon: TrendingDown,
color: 'red',
description: `${noData.total_shares} 份持仓将获得奖池分红`,
poolShare: noData.total_shares > 0 ? topic?.total_pool : 0,
},
{
value: 'draw',
label: '平局 / 无效',
icon: Scale,
color: 'gray',
description: '所有参与者按持仓比例退回积分',
poolShare: 0,
},
];
const handleSubmit = async () => {
if (!selectedResult) {
toast({
title: '请选择结算结果',
status: 'warning',
duration: 3000,
});
return;
}
try {
setIsSubmitting(true);
const response = await settleTopic(topic.id, selectedResult);
if (response.success) {
toast({
title: '结算成功!',
description: `话题已结算,${resultOptions.find(o => o.value === selectedResult)?.label}`,
status: 'success',
duration: 5000,
});
if (onSettleSuccess) {
onSettleSuccess(response.data);
}
onClose();
}
} catch (error) {
console.error('结算失败:', error);
toast({
title: '结算失败',
description: error.message || '请稍后重试',
status: 'error',
duration: 3000,
});
} finally {
setIsSubmitting(false);
}
};
if (!topic) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg" isCentered>
<ModalOverlay backdropFilter={GLASS_BLUR.xs} />
<ModalContent
bg={forumColors.background.card}
borderRadius="xl"
border="1px solid"
borderColor={forumColors.border.default}
>
<ModalHeader
bg={forumColors.gradients.goldSubtle}
borderTopRadius="xl"
borderBottom="1px solid"
borderColor={forumColors.border.default}
>
<HStack spacing="2">
<Icon as={CheckCircle2} boxSize="20px" color={forumColors.primary[500]} />
<Text color={forumColors.text.primary}>结算预测话题</Text>
</HStack>
</ModalHeader>
<ModalCloseButton color={forumColors.text.primary} />
<ModalBody py="6">
<VStack spacing="5" align="stretch">
{/* 话题信息 */}
<Box
bg={forumColors.background.hover}
borderRadius="lg"
p="4"
border="1px solid"
borderColor={forumColors.border.default}
>
<Text fontSize="md" fontWeight="600" color={forumColors.text.primary} mb="2">
{topic.title}
</Text>
<Text fontSize="sm" color={forumColors.text.secondary} noOfLines={2}>
{topic.description}
</Text>
</Box>
{/* 市场数据摘要 */}
<HStack spacing="4" justify="center">
<VStack spacing="1">
<Text fontSize="xs" color={forumColors.text.tertiary}>看涨方</Text>
<Text fontSize="lg" fontWeight="bold" color="green.500">
{yesPercent.toFixed(0)}%
</Text>
<Text fontSize="xs" color={forumColors.text.secondary}>
{yesData.total_shares}
</Text>
</VStack>
<Divider orientation="vertical" h="60px" />
<VStack spacing="1">
<Text fontSize="xs" color={forumColors.text.tertiary}>看跌方</Text>
<Text fontSize="lg" fontWeight="bold" color="red.500">
{noPercent.toFixed(0)}%
</Text>
<Text fontSize="xs" color={forumColors.text.secondary}>
{noData.total_shares}
</Text>
</VStack>
<Divider orientation="vertical" h="60px" />
<VStack spacing="1">
<Text fontSize="xs" color={forumColors.text.tertiary}>奖池</Text>
<HStack spacing="1">
<Icon as={Coins} boxSize="16px" color={forumColors.primary[500]} />
<Text fontSize="lg" fontWeight="bold" color={forumColors.primary[500]}>
{topic.total_pool}
</Text>
</HStack>
<Text fontSize="xs" color={forumColors.text.secondary}>积分</Text>
</VStack>
</HStack>
{/* 警告提示 */}
<Alert
status="warning"
bg="rgba(221, 107, 32, 0.1)"
borderRadius="lg"
border="1px solid"
borderColor="orange.300"
>
<AlertIcon color="orange.400" />
<VStack align="start" spacing="1" flex="1">
<Text fontSize="sm" color={forumColors.text.primary} fontWeight="600">
结算操作不可撤销
</Text>
<Text fontSize="xs" color={forumColors.text.secondary}>
请确认预测结果后再提交结算后将自动分配奖池给获胜方
</Text>
</VStack>
</Alert>
{/* 结算选项 */}
<Box>
<Text fontSize="sm" fontWeight="600" color={forumColors.text.primary} mb="3">
选择结算结果
</Text>
<RadioGroup value={selectedResult} onChange={setSelectedResult}>
<VStack spacing="3" align="stretch">
{resultOptions.map((option) => (
<Box
key={option.value}
as="label"
cursor="pointer"
bg={selectedResult === option.value
? `${option.color}.50`
: forumColors.background.hover
}
border="2px solid"
borderColor={selectedResult === option.value
? `${option.color}.400`
: forumColors.border.default
}
borderRadius="lg"
p="4"
transition="all 0.2s"
_hover={{
borderColor: `${option.color}.300`,
bg: `${option.color}.50`,
}}
>
<HStack justify="space-between" align="start">
<HStack spacing="3" align="start">
<Radio
value={option.value}
colorScheme={option.color}
mt="1"
/>
<VStack align="start" spacing="1">
<HStack spacing="2">
<Icon
as={option.icon}
boxSize="18px"
color={`${option.color}.500`}
/>
<Text
fontSize="md"
fontWeight="600"
color={forumColors.text.primary}
>
{option.label}
</Text>
</HStack>
<Text fontSize="xs" color={forumColors.text.secondary}>
{option.description}
</Text>
</VStack>
</HStack>
{option.poolShare > 0 && (
<VStack align="end" spacing="0">
<Text fontSize="xs" color={forumColors.text.tertiary}>
奖池分配
</Text>
<Text fontSize="md" fontWeight="bold" color={forumColors.primary[500]}>
{option.poolShare} 积分
</Text>
</VStack>
)}
</HStack>
</Box>
))}
</VStack>
</RadioGroup>
</Box>
</VStack>
</ModalBody>
<ModalFooter borderTop="1px solid" borderColor={forumColors.border.default}>
<HStack spacing="3">
<Button
variant="ghost"
onClick={onClose}
color={forumColors.text.secondary}
_hover={{ bg: forumColors.background.hover }}
>
取消
</Button>
<Button
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
fontWeight="bold"
onClick={handleSubmit}
isLoading={isSubmitting}
loadingText="结算中..."
isDisabled={!selectedResult}
leftIcon={<CheckCircle2 size={18} />}
_hover={{
opacity: 0.9,
transform: 'translateY(-2px)',
}}
_active={{ transform: 'translateY(0)' }}
>
确认结算
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default SettleTopicModal;