548 lines
20 KiB
JavaScript
548 lines
20 KiB
JavaScript
/**
|
||
* 交易模态框组件
|
||
* 用于买入/卖出预测市场席位
|
||
*/
|
||
|
||
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;
|