Files
vf_react/src/views/ValueForum/components/TradeModal.js
2025-12-11 17:23:53 +08:00

548 lines
20 KiB
JavaScript
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.

/**
* 交易模态框组件
* 用于买入/卖出预测市场席位
*/
import React, { useState, useEffect } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
Button,
VStack,
HStack,
Text,
Box,
Icon,
Slider,
SliderTrack,
SliderFilledTrack,
SliderThumb,
RadioGroup,
Radio,
Stack,
Flex,
useToast,
Badge,
} from '@chakra-ui/react';
import { TrendingUp, TrendingDown, Coins, AlertCircle, Zap } from 'lucide-react';
import { motion } from 'framer-motion';
import { forumColors } from '@theme/forumTheme';
import {
buyShares,
getUserAccount,
calculateBuyCost,
calculateTax,
MARKET_CONFIG,
} from '@services/predictionMarketService.api';
import { CREDIT_CONFIG } from '@services/creditSystemService';
import { useAuth } from '@contexts/AuthContext';
const MotionBox = motion(Box);
const TradeModal = ({ isOpen, onClose, topic, mode = 'buy', onTradeSuccess }) => {
const toast = useToast();
const { user } = useAuth();
// 状态
const [selectedOption, setSelectedOption] = useState('yes');
const [shares, setShares] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [userAccount, setUserAccount] = useState(null);
// 异步获取用户账户
useEffect(() => {
const fetchAccount = async () => {
if (!user || !isOpen) return;
try {
const response = await getUserAccount();
if (response.success) {
setUserAccount(response.data);
}
} catch (error) {
console.error('获取账户失败:', error);
}
};
fetchAccount();
}, [user, isOpen]);
// 重置状态
useEffect(() => {
if (isOpen) {
setSelectedOption('yes');
setShares(1);
}
}, [isOpen]);
if (!topic || !userAccount) return null;
// 构建市场数据(兼容后端字段)
const positions = {
yes: {
total_shares: topic.yes_total_shares || 0,
current_price: topic.yes_price || 500,
},
no: {
total_shares: topic.no_total_shares || 0,
current_price: topic.no_price || 500,
},
};
// 获取市场数据
const selectedSide = positions[selectedOption];
const otherOption = selectedOption === 'yes' ? 'no' : 'yes';
const otherSide = positions[otherOption];
// 计算交易数据
let cost = 0;
let tax = 0;
let totalCost = 0;
let avgPrice = 0;
if (mode === 'buy') {
const costData = calculateBuyCost(
selectedOption === 'yes' ? selectedSide.total_shares : otherSide.total_shares,
selectedOption === 'yes' ? otherSide.total_shares : selectedSide.total_shares,
shares
);
cost = costData.amount;
tax = costData.tax;
totalCost = costData.total;
avgPrice = costData.avgPrice;
} else {
// 卖出功能暂未实现,使用简化计算
const currentPrice = selectedSide.current_price || MARKET_CONFIG.BASE_PRICE;
cost = currentPrice * shares;
tax = calculateTax(cost);
totalCost = cost - tax;
avgPrice = currentPrice;
}
// 获取用户在该方向的持仓
const userPosition = userAccount.active_positions?.find(
(p) => p.topic_id === topic.id && p.option_id === selectedOption
);
const maxShares = mode === 'buy' ? 10 : userPosition?.shares || 0;
// 检查是否可以交易
const canTrade = () => {
if (mode === 'buy') {
// 检查余额
if (userAccount.balance < totalCost) {
return { ok: false, reason: '积分不足' };
}
// 检查单次上限
if (totalCost > CREDIT_CONFIG.MAX_SINGLE_BET) {
return { ok: false, reason: `单次购买上限${CREDIT_CONFIG.MAX_SINGLE_BET}积分` };
}
return { ok: true };
} else {
// 检查持仓
if (!userPosition || userPosition.shares < shares) {
return { ok: false, reason: '持仓不足' };
}
return { ok: true };
}
};
const tradeCheck = canTrade();
// 处理交易
const handleTrade = async () => {
try {
setIsSubmitting(true);
if (mode === 'buy') {
// 调用买入 API
const response = await buyShares({
topic_id: topic.id,
direction: selectedOption,
shares,
});
if (response.success) {
toast({
title: '购买成功!',
description: `花费${totalCost}积分,剩余 ${response.data.new_balance} 积分`,
status: 'success',
duration: 3000,
});
// 刷新账户数据
const accountResponse = await getUserAccount();
if (accountResponse.success) {
setUserAccount(accountResponse.data);
}
// 通知父组件刷新
if (onTradeSuccess) {
onTradeSuccess(response.data);
}
onClose();
}
} else {
// 卖出功能暂未实现
toast({
title: '功能暂未开放',
description: '卖出功能正在开发中,敬请期待',
status: 'warning',
duration: 3000,
});
}
} catch (error) {
console.error('交易失败:', error);
toast({
title: '交易失败',
description: error.response?.data?.message || error.message,
status: 'error',
duration: 3000,
});
} finally {
setIsSubmitting(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} size={{ base: "full", sm: "lg" }} isCentered>
<ModalOverlay backdropFilter="blur(4px)" />
<ModalContent
bg={forumColors.background.card}
borderRadius={{ base: "0", sm: "xl" }}
border="1px solid"
borderColor={forumColors.border.default}
maxH={{ base: "100vh", sm: "90vh" }}
m={{ base: "0", sm: "4" }}
>
<ModalHeader
bg={forumColors.gradients.goldSubtle}
borderTopRadius={{ base: "0", sm: "xl" }}
borderBottom="1px solid"
borderColor={forumColors.border.default}
py={{ base: "4", sm: "3" }}
>
<HStack spacing="2">
<Icon
as={mode === 'buy' ? Zap : Coins}
boxSize={{ base: "18px", sm: "20px" }}
color={forumColors.primary[500]}
/>
<Text color={forumColors.text.primary} fontSize={{ base: "md", sm: "lg" }}>
{mode === 'buy' ? '购买席位' : '卖出席位'}
</Text>
</HStack>
</ModalHeader>
<ModalCloseButton color={forumColors.text.primary} />
<ModalBody py={{ base: "4", sm: "6" }} px={{ base: "4", sm: "6" }}>
<VStack spacing="5" align="stretch">
{/* 话题标题 */}
<Box
bg={forumColors.background.hover}
borderRadius="lg"
p={{ base: "3", sm: "3" }}
border="1px solid"
borderColor={forumColors.border.default}
>
<Text fontSize={{ base: "xs", sm: "sm" }} fontWeight="600" color={forumColors.text.primary} lineHeight="1.5">
{topic.title}
</Text>
</Box>
{/* 选择方向 */}
<Box>
<Text fontSize={{ base: "sm", sm: "sm" }} fontWeight="600" color={forumColors.text.primary} mb={{ base: "2", sm: "3" }}>
选择方向
</Text>
<RadioGroup value={selectedOption} onChange={setSelectedOption}>
<Stack spacing="3">
{/* Yes 选项 */}
<MotionBox
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Box
bg={
selectedOption === 'yes'
? 'linear-gradient(135deg, rgba(72, 187, 120, 0.2) 0%, rgba(72, 187, 120, 0.1) 100%)'
: forumColors.background.hover
}
border="2px solid"
borderColor={selectedOption === 'yes' ? 'green.400' : forumColors.border.default}
borderRadius="lg"
p={{ base: "3", sm: "4" }}
cursor="pointer"
onClick={() => setSelectedOption('yes')}
minH={{ base: "auto", sm: "auto" }}
>
<Flex justify="space-between" align="center">
<HStack spacing={{ base: "2", sm: "3" }}>
<Radio value="yes" colorScheme="green" size={{ base: "md", sm: "lg" }} />
<VStack align="start" spacing="0">
<HStack spacing="2">
<Icon as={TrendingUp} boxSize={{ base: "14px", sm: "16px" }} color="green.500" />
<Text fontWeight="600" color="green.600" fontSize={{ base: "sm", sm: "md" }}>
看涨 / Yes
</Text>
</HStack>
<Text fontSize={{ base: "2xs", sm: "xs" }} color={forumColors.text.secondary}>
{positions.yes.total_shares}份持仓
</Text>
</VStack>
</HStack>
<VStack align="end" spacing="0">
<Text fontSize={{ base: "lg", sm: "xl" }} fontWeight="bold" color="green.600">
{Math.round(positions.yes.current_price)}
</Text>
<Text fontSize={{ base: "2xs", sm: "xs" }} color={forumColors.text.secondary}>
积分/
</Text>
</VStack>
</Flex>
</Box>
</MotionBox>
{/* No 选项 */}
<MotionBox
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Box
bg={
selectedOption === 'no'
? 'linear-gradient(135deg, rgba(245, 101, 101, 0.2) 0%, rgba(245, 101, 101, 0.1) 100%)'
: forumColors.background.hover
}
border="2px solid"
borderColor={selectedOption === 'no' ? 'red.400' : forumColors.border.default}
borderRadius="lg"
p={{ base: "3", sm: "4" }}
cursor="pointer"
onClick={() => setSelectedOption('no')}
minH={{ base: "auto", sm: "auto" }}
>
<Flex justify="space-between" align="center">
<HStack spacing={{ base: "2", sm: "3" }}>
<Radio value="no" colorScheme="red" size={{ base: "md", sm: "lg" }} />
<VStack align="start" spacing="0">
<HStack spacing="2">
<Icon as={TrendingDown} boxSize={{ base: "14px", sm: "16px" }} color="red.500" />
<Text fontWeight="600" color="red.600" fontSize={{ base: "sm", sm: "md" }}>
看跌 / No
</Text>
</HStack>
<Text fontSize={{ base: "2xs", sm: "xs" }} color={forumColors.text.secondary}>
{positions.no.total_shares}份持仓
</Text>
</VStack>
</HStack>
<VStack align="end" spacing="0">
<Text fontSize={{ base: "lg", sm: "xl" }} fontWeight="bold" color="red.600">
{Math.round(positions.no.current_price)}
</Text>
<Text fontSize={{ base: "2xs", sm: "xs" }} color={forumColors.text.secondary}>
积分/
</Text>
</VStack>
</Flex>
</Box>
</MotionBox>
</Stack>
</RadioGroup>
</Box>
{/* 购买份额 */}
<Box>
<Flex justify="space-between" mb={{ base: "2", sm: "3" }}>
<Text fontSize={{ base: "sm", sm: "sm" }} fontWeight="600" color={forumColors.text.primary}>
{mode === 'buy' ? '购买份额' : '卖出份额'}
</Text>
<Text fontSize={{ base: "sm", sm: "sm" }} color={forumColors.text.secondary}>
{shares}
</Text>
</Flex>
<Slider
value={shares}
onChange={setShares}
min={1}
max={maxShares}
step={1}
focusThumbOnChange={false}
>
<SliderTrack bg={forumColors.background.hover} h={{ base: "2", sm: "1.5" }}>
<SliderFilledTrack bg={forumColors.gradients.goldPrimary} />
</SliderTrack>
<SliderThumb boxSize={{ base: "7", sm: "6" }} bg={forumColors.primary[500]}>
<Box as={Icon} as={Coins} boxSize={{ base: "14px", sm: "12px" }} color="white" />
</SliderThumb>
</Slider>
<HStack justify="space-between" mt="2" fontSize={{ base: "2xs", sm: "xs" }} color={forumColors.text.tertiary}>
<Text>1</Text>
<Text>{maxShares} (最大)</Text>
</HStack>
{mode === 'sell' && userPosition && (
<Text fontSize={{ base: "2xs", sm: "xs" }} color={forumColors.text.secondary} mt="2">
你的持仓{userPosition.shares} · 平均成本{Math.round(userPosition.avg_cost)}积分/
</Text>
)}
</Box>
{/* 费用明细 */}
<Box
bg={forumColors.gradients.goldSubtle}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="lg"
p={{ base: "3", sm: "4" }}
>
<VStack spacing={{ base: "1.5", sm: "2" }} align="stretch">
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>
{mode === 'buy' ? '购买成本' : '卖出收益'}
</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{Math.round(cost)} 积分
</Text>
</Flex>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>平均价格</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{Math.round(avgPrice)} 积分/
</Text>
</Flex>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>交易税 (2%)</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{Math.round(tax)} 积分
</Text>
</Flex>
<Box borderTop="1px solid" borderColor={forumColors.border.default} pt={{ base: "1.5", sm: "2" }} mt="1">
<Flex justify="space-between">
<Text fontWeight="bold" color={forumColors.text.primary} fontSize={{ base: "sm", sm: "md" }}>
{mode === 'buy' ? '总计' : '净收益'}
</Text>
<HStack spacing="1">
<Icon as={DollarSign} boxSize={{ base: "16px", sm: "20px" }} color={forumColors.primary[500]} />
<Text fontSize={{ base: "xl", sm: "2xl" }} fontWeight="bold" color={forumColors.primary[500]}>
{Math.round(totalCost)}
</Text>
<Text fontSize={{ base: "xs", sm: "sm" }} color={forumColors.text.secondary}>
积分
</Text>
</HStack>
</Flex>
</Box>
{/* 余额提示 */}
<Box borderTop="1px solid" borderColor={forumColors.border.default} pt={{ base: "1.5", sm: "2" }}>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>你的余额</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{userAccount.balance} 积分
</Text>
</Flex>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }} mt="1">
<Text color={forumColors.text.secondary}>
{mode === 'buy' ? '交易后:' : '交易后:'}
</Text>
<Text
fontWeight="600"
color={
mode === 'buy'
? userAccount.balance >= totalCost
? forumColors.success[500]
: forumColors.error[500]
: forumColors.success[500]
}
>
{mode === 'buy'
? userAccount.balance - totalCost
: userAccount.balance + totalCost}{' '}
积分
</Text>
</Flex>
</Box>
</VStack>
</Box>
{/* 警告提示 */}
{!tradeCheck.ok && (
<Box
bg="red.50"
border="1px solid"
borderColor="red.200"
borderRadius="lg"
p={{ base: "2.5", sm: "3" }}
>
<HStack spacing="2">
<Icon as={AlertCircle} boxSize={{ base: "14px", sm: "16px" }} color="red.500" />
<Text fontSize={{ base: "xs", sm: "sm" }} color="red.600" fontWeight="600">
{tradeCheck.reason}
</Text>
</HStack>
</Box>
)}
</VStack>
</ModalBody>
<ModalFooter
borderTop="1px solid"
borderColor={forumColors.border.default}
py={{ base: "3", sm: "4" }}
px={{ base: "4", sm: "6" }}
>
<HStack spacing={{ base: "2", sm: "3" }} w="full" justify="flex-end">
<Button
variant="ghost"
onClick={onClose}
color={forumColors.text.secondary}
_hover={{ bg: forumColors.background.hover }}
h={{ base: "10", sm: "auto" }}
fontSize={{ base: "sm", sm: "md" }}
>
取消
</Button>
<Button
bg={mode === 'buy' ? forumColors.gradients.goldPrimary : 'red.500'}
color="white"
fontWeight="bold"
onClick={handleTrade}
isLoading={isSubmitting}
loadingText={mode === 'buy' ? '购买中...' : '卖出中...'}
isDisabled={!tradeCheck.ok}
_hover={{
opacity: 0.9,
transform: 'translateY(-2px)',
}}
_active={{ transform: 'translateY(0)' }}
h={{ base: "11", sm: "auto" }}
fontSize={{ base: "sm", sm: "md" }}
px={{ base: "6", sm: "4" }}
>
{mode === 'buy' ? `购买 ${shares}` : `卖出 ${shares}`}
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default TradeModal;