953 lines
39 KiB
JavaScript
953 lines
39 KiB
JavaScript
// src/views/EventDetail/components/RelatedStocks.js - 完整修改版本
|
||
|
||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||
import {
|
||
Box,
|
||
HStack,
|
||
VStack,
|
||
Text,
|
||
Button,
|
||
Badge,
|
||
Modal,
|
||
ModalOverlay,
|
||
ModalContent,
|
||
ModalHeader,
|
||
ModalCloseButton,
|
||
ModalBody,
|
||
ModalFooter,
|
||
useDisclosure,
|
||
Skeleton,
|
||
Alert,
|
||
AlertIcon,
|
||
Table,
|
||
Thead,
|
||
Tbody,
|
||
Tr,
|
||
Th,
|
||
Td,
|
||
TableContainer,
|
||
Input,
|
||
Textarea,
|
||
FormControl,
|
||
FormLabel,
|
||
ButtonGroup,
|
||
IconButton,
|
||
Tooltip,
|
||
useColorModeValue,
|
||
CircularProgress,
|
||
CircularProgressLabel,
|
||
Flex,
|
||
Spacer,
|
||
useToast
|
||
} from '@chakra-ui/react';
|
||
import {
|
||
FaPlus,
|
||
FaTrash,
|
||
FaChartLine,
|
||
FaRedo,
|
||
FaSearch
|
||
} from 'react-icons/fa';
|
||
import * as echarts from 'echarts';
|
||
|
||
import { eventService, stockService } from '../../../services/eventService';
|
||
|
||
const RelatedStocks = ({
|
||
eventId,
|
||
eventTime, // 新增:从父组件传递事件时间
|
||
stocks = [],
|
||
loading = false,
|
||
error = null,
|
||
onStockAdded = () => {},
|
||
onStockDeleted = () => {}
|
||
}) => {
|
||
// ==================== 状态管理 ====================
|
||
const [stocksData, setStocksData] = useState(stocks);
|
||
const [quotes, setQuotes] = useState({});
|
||
const [quotesLoading, setQuotesLoading] = useState(false);
|
||
const [selectedStock, setSelectedStock] = useState(null);
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [sortField, setSortField] = useState('correlation');
|
||
const [sortOrder, setSortOrder] = useState('desc');
|
||
|
||
// 模态框状态
|
||
const {
|
||
isOpen: isAddModalOpen,
|
||
onOpen: onAddModalOpen,
|
||
onClose: onAddModalClose
|
||
} = useDisclosure();
|
||
const {
|
||
isOpen: isChartModalOpen,
|
||
onOpen: onChartModalOpen,
|
||
onClose: onChartModalClose
|
||
} = useDisclosure();
|
||
|
||
// 添加股票表单状态
|
||
const [addStockForm, setAddStockForm] = useState({
|
||
stock_code: '',
|
||
relation_desc: ''
|
||
});
|
||
|
||
// 主题和工具
|
||
const toast = useToast();
|
||
const tableBg = useColorModeValue('white', 'gray.800');
|
||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||
|
||
// ==================== 副作用处理 ====================
|
||
useEffect(() => {
|
||
setStocksData(stocks);
|
||
}, [stocks]);
|
||
|
||
useEffect(() => {
|
||
if (stocksData.length > 0) {
|
||
fetchQuotes();
|
||
}
|
||
}, [stocksData, eventTime]); // 添加 eventTime 依赖
|
||
|
||
// ==================== API 调用函数 ====================
|
||
const fetchQuotes = useCallback(async () => {
|
||
if (stocksData.length === 0) return;
|
||
|
||
try {
|
||
setQuotesLoading(true);
|
||
const codes = stocksData.map(stock => stock.stock_code);
|
||
|
||
console.log('获取股票报价,代码:', codes, '事件时间:', eventTime);
|
||
|
||
const response = await stockService.getQuotes(codes, eventTime);
|
||
console.log('股票报价响应:', response);
|
||
|
||
setQuotes(response || {});
|
||
} catch (err) {
|
||
console.error('获取股票报价失败:', err);
|
||
toast({
|
||
title: '获取股票报价失败',
|
||
description: err.message,
|
||
status: 'error',
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
} finally {
|
||
setQuotesLoading(false);
|
||
}
|
||
}, [stocksData, eventTime, toast]);
|
||
|
||
const handleRefreshQuotes = () => {
|
||
fetchQuotes();
|
||
toast({
|
||
title: '正在刷新报价...',
|
||
status: 'info',
|
||
duration: 2000,
|
||
isClosable: true,
|
||
});
|
||
};
|
||
|
||
const handleAddStock = async () => {
|
||
if (!addStockForm.stock_code.trim() || !addStockForm.relation_desc.trim()) {
|
||
toast({
|
||
title: '请填写完整信息',
|
||
status: 'warning',
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await eventService.addRelatedStock(eventId, addStockForm);
|
||
|
||
toast({
|
||
title: '添加成功',
|
||
status: 'success',
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
|
||
// 重置表单
|
||
setAddStockForm({ stock_code: '', relation_desc: '' });
|
||
onAddModalClose();
|
||
onStockAdded();
|
||
} catch (err) {
|
||
toast({
|
||
title: '添加失败',
|
||
description: err.message,
|
||
status: 'error',
|
||
duration: 5000,
|
||
isClosable: true,
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleDeleteStock = async (stockId, stockCode) => {
|
||
if (!window.confirm(`确定要删除股票 ${stockCode} 吗?`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await eventService.deleteRelatedStock(stockId);
|
||
|
||
toast({
|
||
title: '删除成功',
|
||
status: 'success',
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
|
||
onStockDeleted();
|
||
} catch (err) {
|
||
toast({
|
||
title: '删除失败',
|
||
description: err.message,
|
||
status: 'error',
|
||
duration: 5000,
|
||
isClosable: true,
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleShowChart = (stock) => {
|
||
setSelectedStock(stock);
|
||
onChartModalOpen();
|
||
};
|
||
|
||
// ==================== 辅助函数 ====================
|
||
const getCorrelationColor = (correlation) => {
|
||
const value = (correlation || 0) * 100;
|
||
if (value >= 80) return 'red';
|
||
if (value >= 60) return 'orange';
|
||
return 'green';
|
||
};
|
||
|
||
const formatPriceChange = (change) => {
|
||
if (!change && change !== 0) return '--';
|
||
const prefix = change > 0 ? '+' : '';
|
||
return `${prefix}${change.toFixed(2)}%`;
|
||
};
|
||
|
||
const getStockName = (stock) => {
|
||
// 优先使用API返回的名称
|
||
const quote = quotes[stock.stock_code];
|
||
if (quote && quote.name) {
|
||
return quote.name;
|
||
}
|
||
|
||
// 其次使用数据库中的名称
|
||
if (stock.stock_name) {
|
||
return stock.stock_name;
|
||
}
|
||
|
||
// 最后使用默认格式
|
||
return `股票${stock.stock_code.split('.')[0]}`;
|
||
};
|
||
|
||
// ==================== 数据处理 ====================
|
||
const filteredAndSortedStocks = stocksData
|
||
.filter(stock => {
|
||
const stockName = getStockName(stock);
|
||
return stock.stock_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
stockName.toLowerCase().includes(searchTerm.toLowerCase());
|
||
})
|
||
.sort((a, b) => {
|
||
let aValue, bValue;
|
||
|
||
switch (sortField) {
|
||
case 'correlation':
|
||
aValue = a.correlation || 0;
|
||
bValue = b.correlation || 0;
|
||
break;
|
||
case 'change':
|
||
aValue = quotes[a.stock_code]?.change || 0;
|
||
bValue = quotes[b.stock_code]?.change || 0;
|
||
break;
|
||
case 'price':
|
||
aValue = quotes[a.stock_code]?.price || 0;
|
||
bValue = quotes[b.stock_code]?.price || 0;
|
||
break;
|
||
default:
|
||
aValue = a[sortField] || '';
|
||
bValue = b[sortField] || '';
|
||
}
|
||
|
||
if (sortOrder === 'asc') {
|
||
return aValue > bValue ? 1 : -1;
|
||
} else {
|
||
return aValue < bValue ? 1 : -1;
|
||
}
|
||
});
|
||
|
||
// ==================== 渲染状态 ====================
|
||
if (loading) {
|
||
return (
|
||
<VStack spacing={4}>
|
||
{[1, 2, 3].map((i) => (
|
||
<HStack key={i} w="100%" spacing={4}>
|
||
<Skeleton height="20px" width="100px" />
|
||
<Skeleton height="20px" width="150px" />
|
||
<Skeleton height="20px" width="80px" />
|
||
<Skeleton height="20px" width="100px" />
|
||
<Skeleton height="20px" width="60px" />
|
||
<Skeleton height="20px" width="120px" />
|
||
</HStack>
|
||
))}
|
||
</VStack>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Alert status="error" borderRadius="lg">
|
||
<AlertIcon />
|
||
加载相关股票失败: {error}
|
||
</Alert>
|
||
);
|
||
}
|
||
|
||
// ==================== 主要渲染 ====================
|
||
return (
|
||
<>
|
||
{/* 工具栏 */}
|
||
<Flex mb={4} align="center" wrap="wrap" gap={4}>
|
||
{/* 搜索框 */}
|
||
<HStack>
|
||
<FaSearch />
|
||
<Input
|
||
placeholder="搜索股票代码或名称..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
size="sm"
|
||
maxW="200px"
|
||
/>
|
||
</HStack>
|
||
|
||
<Spacer />
|
||
|
||
{/* 操作按钮 */}
|
||
<HStack>
|
||
<Tooltip label="刷新报价">
|
||
<IconButton
|
||
icon={<FaRedo />}
|
||
size="sm"
|
||
onClick={handleRefreshQuotes}
|
||
isLoading={quotesLoading}
|
||
aria-label="刷新报价"
|
||
/>
|
||
</Tooltip>
|
||
|
||
<Button
|
||
leftIcon={<FaPlus />}
|
||
size="sm"
|
||
colorScheme="blue"
|
||
onClick={onAddModalOpen}
|
||
>
|
||
添加股票
|
||
</Button>
|
||
</HStack>
|
||
</Flex>
|
||
|
||
{/* 股票表格 */}
|
||
<Box bg={tableBg} borderRadius="lg" overflow="hidden" border="1px solid" borderColor={borderColor}>
|
||
<TableContainer>
|
||
<Table size="sm">
|
||
<Thead>
|
||
<Tr>
|
||
<Th cursor="pointer" onClick={() => {
|
||
if (sortField === 'stock_code') {
|
||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||
} else {
|
||
setSortField('stock_code');
|
||
setSortOrder('asc');
|
||
}
|
||
}}>
|
||
股票代码 {sortField === 'stock_code' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||
</Th>
|
||
<Th>股票名称</Th>
|
||
<Th>产业链</Th>
|
||
<Th isNumeric cursor="pointer" onClick={() => {
|
||
if (sortField === 'price') {
|
||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||
} else {
|
||
setSortField('price');
|
||
setSortOrder('desc');
|
||
}
|
||
}}>
|
||
最新价 {sortField === 'price' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||
</Th>
|
||
<Th isNumeric cursor="pointer" onClick={() => {
|
||
if (sortField === 'change') {
|
||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||
} else {
|
||
setSortField('change');
|
||
setSortOrder('desc');
|
||
}
|
||
}}>
|
||
涨跌幅 {sortField === 'change' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||
</Th>
|
||
<Th textAlign="center">分时图</Th>
|
||
<Th textAlign="center" cursor="pointer" onClick={() => {
|
||
if (sortField === 'correlation') {
|
||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||
} else {
|
||
setSortField('correlation');
|
||
setSortOrder('desc');
|
||
}
|
||
}}>
|
||
相关度 {sortField === 'correlation' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||
</Th>
|
||
<Th>关联描述</Th>
|
||
<Th>操作</Th>
|
||
</Tr>
|
||
</Thead>
|
||
<Tbody>
|
||
{filteredAndSortedStocks.length === 0 ? (
|
||
<Tr>
|
||
<Td colSpan={9} textAlign="center" py={8} color="gray.500">
|
||
{searchTerm ? '未找到匹配的股票' : '暂无相关股票数据'}
|
||
</Td>
|
||
</Tr>
|
||
) : (
|
||
filteredAndSortedStocks.map((stock) => {
|
||
const quote = quotes[stock.stock_code] || {};
|
||
const correlation = (stock.correlation || 0) * 100;
|
||
const stockName = getStockName(stock);
|
||
|
||
return (
|
||
<Tr key={stock.id} _hover={{ bg: useColorModeValue('gray.50', 'gray.700') }}>
|
||
{/* 股票代码 */}
|
||
<Td>
|
||
<Button
|
||
variant="link"
|
||
color="blue.500"
|
||
fontFamily="mono"
|
||
fontWeight="bold"
|
||
p={0}
|
||
onClick={() => handleShowChart(stock)}
|
||
>
|
||
{stock.stock_code}
|
||
</Button>
|
||
</Td>
|
||
|
||
{/* 股票名称 */}
|
||
<Td>
|
||
<VStack align="flex-start" spacing={0}>
|
||
<Text fontSize="sm" fontWeight="medium">
|
||
{stockName}
|
||
</Text>
|
||
{quotesLoading && (
|
||
<Text fontSize="xs" color="gray.500">
|
||
加载中...
|
||
</Text>
|
||
)}
|
||
</VStack>
|
||
</Td>
|
||
|
||
{/* 产业链 */}
|
||
<Td>
|
||
<Badge size="sm" variant="outline">
|
||
{stock.sector || '未知'}
|
||
</Badge>
|
||
</Td>
|
||
|
||
{/* 最新价 */}
|
||
<Td isNumeric>
|
||
<Text fontSize="sm" fontWeight="medium">
|
||
{quote.price ? `¥${quote.price.toFixed(2)}` : '--'}
|
||
</Text>
|
||
</Td>
|
||
|
||
{/* 涨跌幅 */}
|
||
<Td isNumeric>
|
||
<Badge
|
||
colorScheme={
|
||
!quote.change ? 'gray' :
|
||
quote.change > 0 ? 'red' :
|
||
quote.change < 0 ? 'green' : 'gray'
|
||
}
|
||
size="sm"
|
||
>
|
||
{formatPriceChange(quote.change)}
|
||
</Badge>
|
||
</Td>
|
||
|
||
{/* 分时图 */}
|
||
<Td>
|
||
<Box width="120px" height="40px">
|
||
<MiniChart
|
||
stockCode={stock.stock_code}
|
||
eventTime={eventTime}
|
||
onChartClick={() => handleShowChart(stock)}
|
||
/>
|
||
</Box>
|
||
</Td>
|
||
|
||
{/* 相关度 */}
|
||
<Td textAlign="center">
|
||
<CircularProgress
|
||
value={correlation}
|
||
color={getCorrelationColor(stock.correlation)}
|
||
size="50px"
|
||
>
|
||
<CircularProgressLabel fontSize="xs">
|
||
{Math.round(correlation)}%
|
||
</CircularProgressLabel>
|
||
</CircularProgress>
|
||
</Td>
|
||
|
||
{/* 关联描述 */}
|
||
<Td maxW="200px">
|
||
<Text fontSize="xs" noOfLines={2}>
|
||
{stock.relation_desc || '--'}
|
||
</Text>
|
||
</Td>
|
||
|
||
{/* 操作 */}
|
||
<Td>
|
||
<HStack spacing={1}>
|
||
<Tooltip label="查看K线图">
|
||
<IconButton
|
||
icon={<FaChartLine />}
|
||
size="xs"
|
||
onClick={() => handleShowChart(stock)}
|
||
aria-label="查看K线图"
|
||
/>
|
||
</Tooltip>
|
||
|
||
<Tooltip label="删除">
|
||
<IconButton
|
||
icon={<FaTrash />}
|
||
size="xs"
|
||
colorScheme="red"
|
||
variant="ghost"
|
||
onClick={() => handleDeleteStock(stock.id, stock.stock_code)}
|
||
aria-label="删除股票"
|
||
/>
|
||
</Tooltip>
|
||
</HStack>
|
||
</Td>
|
||
</Tr>
|
||
);
|
||
})
|
||
)}
|
||
</Tbody>
|
||
</Table>
|
||
</TableContainer>
|
||
</Box>
|
||
|
||
{/* 添加股票模态框 */}
|
||
<AddStockModal
|
||
isOpen={isAddModalOpen}
|
||
onClose={onAddModalClose}
|
||
formData={addStockForm}
|
||
setFormData={setAddStockForm}
|
||
onSubmit={handleAddStock}
|
||
/>
|
||
|
||
{/* 股票图表模态框 */}
|
||
<StockChartModal
|
||
isOpen={isChartModalOpen}
|
||
onClose={onChartModalClose}
|
||
stock={selectedStock}
|
||
eventTime={eventTime}
|
||
/>
|
||
</>
|
||
);
|
||
};
|
||
|
||
// ==================== 子组件 ====================
|
||
|
||
// 迷你分时图组件
|
||
const MiniChart = ({ stockCode, eventTime, onChartClick }) => {
|
||
const chartRef = useRef(null);
|
||
const chartInstanceRef = useRef(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
|
||
useEffect(() => {
|
||
if (chartRef.current && stockCode) {
|
||
loadChartData();
|
||
}
|
||
return () => {
|
||
if (chartInstanceRef.current) {
|
||
chartInstanceRef.current.dispose();
|
||
chartInstanceRef.current = null;
|
||
}
|
||
};
|
||
}, [stockCode, eventTime]);
|
||
|
||
const loadChartData = async () => {
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
const response = await stockService.getKlineData(stockCode, 'timeline', eventTime);
|
||
|
||
if (!response.data || response.data.length === 0) {
|
||
setError('无数据');
|
||
return;
|
||
}
|
||
|
||
// 初始化图表
|
||
if (!chartInstanceRef.current && chartRef.current) {
|
||
chartInstanceRef.current = echarts.init(chartRef.current);
|
||
}
|
||
|
||
const option = generateMiniChartOption(response.data);
|
||
chartInstanceRef.current.setOption(option, true);
|
||
} catch (err) {
|
||
console.error('加载迷你图表失败:', err);
|
||
setError('加载失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const generateMiniChartOption = (data) => {
|
||
const prices = data.map(item => item.close);
|
||
const times = data.map(item => item.time);
|
||
|
||
// 计算最高最低价格
|
||
const minPrice = Math.min(...prices);
|
||
const maxPrice = Math.max(...prices);
|
||
|
||
// 判断是上涨还是下跌
|
||
const isUp = prices[prices.length - 1] >= prices[0];
|
||
const lineColor = isUp ? '#ef5350' : '#26a69a';
|
||
|
||
return {
|
||
grid: {
|
||
left: 2,
|
||
right: 2,
|
||
top: 2,
|
||
bottom: 2,
|
||
containLabel: false
|
||
},
|
||
xAxis: {
|
||
type: 'category',
|
||
data: times,
|
||
show: false,
|
||
boundaryGap: false
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
show: false,
|
||
min: minPrice * 0.995,
|
||
max: maxPrice * 1.005
|
||
},
|
||
series: [{
|
||
data: prices,
|
||
type: 'line',
|
||
smooth: true,
|
||
symbol: 'none',
|
||
lineStyle: {
|
||
color: lineColor,
|
||
width: 2
|
||
},
|
||
areaStyle: {
|
||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||
{
|
||
offset: 0,
|
||
color: lineColor === '#ef5350' ? 'rgba(239, 83, 80, 0.3)' : 'rgba(38, 166, 154, 0.3)'
|
||
},
|
||
{
|
||
offset: 1,
|
||
color: lineColor === '#ef5350' ? 'rgba(239, 83, 80, 0.05)' : 'rgba(38, 166, 154, 0.05)'
|
||
}
|
||
])
|
||
},
|
||
markLine: {
|
||
silent: true,
|
||
symbol: 'none',
|
||
label: { show: false },
|
||
lineStyle: {
|
||
color: '#aaa',
|
||
type: 'dashed',
|
||
width: 1
|
||
},
|
||
data: [{
|
||
yAxis: prices[0] // 参考价
|
||
}]
|
||
}
|
||
}],
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
formatter: function(params) {
|
||
if (!params || params.length === 0) return '';
|
||
const price = params[0].value.toFixed(2);
|
||
const time = params[0].axisValue;
|
||
const percentChange = ((price - prices[0]) / prices[0] * 100).toFixed(2);
|
||
const sign = percentChange >= 0 ? '+' : '';
|
||
return `${time}<br/>价格: ${price}<br/>变动: ${sign}${percentChange}%`;
|
||
},
|
||
position: function (pos, params, el, elRect, size) {
|
||
const obj = { top: 10 };
|
||
obj[['left', 'right'][+(pos[0] < size.viewSize[0] / 2)]] = 30;
|
||
return obj;
|
||
}
|
||
},
|
||
animation: false
|
||
};
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<Flex align="center" justify="center" h="100%" w="100%">
|
||
<Text fontSize="xs" color="gray.500">加载中...</Text>
|
||
</Flex>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Flex align="center" justify="center" h="100%" w="100%">
|
||
<Text fontSize="xs" color="gray.500">{error}</Text>
|
||
</Flex>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Box
|
||
ref={chartRef}
|
||
w="100%"
|
||
h="100%"
|
||
cursor="pointer"
|
||
onClick={onChartClick}
|
||
_hover={{ opacity: 0.8 }}
|
||
/>
|
||
);
|
||
};
|
||
|
||
// 添加股票模态框组件
|
||
const AddStockModal = ({ isOpen, onClose, formData, setFormData, onSubmit }) => {
|
||
return (
|
||
<Modal isOpen={isOpen} onClose={onClose}>
|
||
<ModalOverlay />
|
||
<ModalContent>
|
||
<ModalHeader>添加相关股票</ModalHeader>
|
||
<ModalCloseButton />
|
||
|
||
<ModalBody>
|
||
<VStack spacing={4}>
|
||
<FormControl isRequired>
|
||
<FormLabel>股票代码</FormLabel>
|
||
<Input
|
||
placeholder="请输入股票代码,如:000001.SZ"
|
||
value={formData.stock_code}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
stock_code: e.target.value.toUpperCase()
|
||
}))}
|
||
/>
|
||
</FormControl>
|
||
|
||
<FormControl isRequired>
|
||
<FormLabel>关联描述</FormLabel>
|
||
<Textarea
|
||
placeholder="请描述该股票与事件的关联原因..."
|
||
value={formData.relation_desc}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
relation_desc: e.target.value
|
||
}))}
|
||
rows={4}
|
||
/>
|
||
</FormControl>
|
||
</VStack>
|
||
</ModalBody>
|
||
|
||
<ModalFooter>
|
||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||
取消
|
||
</Button>
|
||
<Button colorScheme="blue" onClick={onSubmit}>
|
||
确定
|
||
</Button>
|
||
</ModalFooter>
|
||
</ModalContent>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
// 股票图表模态框组件
|
||
const StockChartModal = ({ isOpen, onClose, stock, eventTime }) => {
|
||
const chartRef = useRef(null);
|
||
const chartInstanceRef = useRef(null);
|
||
const [chartType, setChartType] = useState('timeline');
|
||
const [loading, setLoading] = useState(false);
|
||
const [chartData, setChartData] = useState(null);
|
||
const toast = useToast();
|
||
|
||
useEffect(() => {
|
||
if (isOpen && chartRef.current) {
|
||
loadChartData(chartType);
|
||
}
|
||
return () => {
|
||
if (chartInstanceRef.current) {
|
||
window.removeEventListener('resize', chartInstanceRef.current.resizeHandler);
|
||
chartInstanceRef.current.dispose();
|
||
chartInstanceRef.current = null;
|
||
}
|
||
};
|
||
}, [isOpen]);
|
||
|
||
useEffect(() => {
|
||
if (isOpen && chartRef.current) {
|
||
loadChartData(chartType);
|
||
}
|
||
}, [chartType, stock, eventTime]);
|
||
|
||
const loadChartData = async (type) => {
|
||
if (!stock || !chartRef.current) return;
|
||
try {
|
||
setLoading(true);
|
||
if (chartInstanceRef.current) {
|
||
chartInstanceRef.current.showLoading();
|
||
}
|
||
const response = await stockService.getKlineData(stock.stock_code, type, eventTime);
|
||
setChartData(response);
|
||
if (!chartRef.current) return;
|
||
if (!chartInstanceRef.current) {
|
||
const chart = echarts.init(chartRef.current);
|
||
chart.resizeHandler = () => chart.resize();
|
||
window.addEventListener('resize', chart.resizeHandler);
|
||
chartInstanceRef.current = chart;
|
||
}
|
||
chartInstanceRef.current.hideLoading();
|
||
const option = generateChartOption(response, type);
|
||
chartInstanceRef.current.setOption(option, true);
|
||
} catch (err) {
|
||
console.error('加载图表数据失败:', err);
|
||
toast({ title: '加载图表数据失败', description: err.message, status: 'error', duration: 3000, isClosable: true, });
|
||
if (chartInstanceRef.current) {
|
||
chartInstanceRef.current.hideLoading();
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const generateChartOption = (data, type) => {
|
||
if (!data || !data.data || data.data.length === 0) {
|
||
return { title: { text: '暂无数据', left: 'center', top: 'center', textStyle: { color: '#999', fontSize: 16 } } };
|
||
}
|
||
const stockData = data.data;
|
||
|
||
if (type === 'timeline' || type === 'minute') {
|
||
const times = stockData.map(item => item.time);
|
||
const prices = stockData.map(item => item.close);
|
||
const isUp = prices[prices.length - 1] >= prices[0];
|
||
const lineColor = isUp ? '#ef5350' : '#26a69a';
|
||
return {
|
||
title: { text: `${data.name} (${data.code}) - ${type === 'timeline' ? '分时图' : '分钟线'}`, left: 'center', textStyle: { fontSize: 16, fontWeight: 'bold' } },
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
formatter: function(params) {
|
||
if (!params || params.length === 0) {
|
||
return '';
|
||
}
|
||
const point = params[0];
|
||
if (!point) return '';
|
||
return `时间: ${point.axisValue}<br/>价格: ¥${point.value.toFixed(2)}`;
|
||
}
|
||
},
|
||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||
xAxis: { type: 'category', data: times, boundaryGap: false },
|
||
yAxis: { type: 'value', scale: true },
|
||
series: [{ data: prices, type: 'line', smooth: true, symbol: 'none', lineStyle: { color: lineColor, width: 2 }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: isUp ? 'rgba(239, 83, 80, 0.3)' : 'rgba(38, 166, 154, 0.3)' }, { offset: 1, color: isUp ? 'rgba(239, 83, 80, 0.1)' : 'rgba(38, 166, 154, 0.1)' }]) } }]
|
||
};
|
||
}
|
||
|
||
if (type === 'daily') {
|
||
const dates = stockData.map(item => item.time);
|
||
const klineData = stockData.map(item => [item.open, item.close, item.low, item.high]);
|
||
const volumes = stockData.map(item => item.volume);
|
||
return {
|
||
title: { text: `${data.name} (${data.code}) - 日K线`, left: 'center', textStyle: { fontSize: 16, fontWeight: 'bold' } },
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
axisPointer: { type: 'cross' },
|
||
formatter: function(params) {
|
||
if (!params || !Array.isArray(params) || params.length === 0) {
|
||
return '';
|
||
}
|
||
|
||
const kline = params[0];
|
||
const volume = params[1];
|
||
|
||
if (!kline || !kline.data) {
|
||
return '';
|
||
}
|
||
|
||
let tooltipHtml = `日期: ${kline.axisValue}<br/>
|
||
开盘: ${kline.data[0]}<br/>
|
||
收盘: ${kline.data[1]}<br/>
|
||
最低: ${kline.data[2]}<br/>
|
||
最高: ${kline.data[3]}`;
|
||
|
||
if (volume && typeof volume.value !== 'undefined') {
|
||
tooltipHtml += `<br/>成交量: ${volume.value}`;
|
||
}
|
||
|
||
return tooltipHtml;
|
||
}
|
||
},
|
||
grid: [{ left: '10%', right: '10%', height: '60%' }, { left: '10%', right: '10%', top: '70%', height: '16%' }],
|
||
xAxis: [{ type: 'category', data: dates, scale: true, boundaryGap: false, axisLine: { onZero: false }, splitLine: { show: false }, min: 'dataMin', max: 'dataMax' }, { type: 'category', gridIndex: 1, data: dates, scale: true, boundaryGap: false, axisLine: { onZero: false }, axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false }, min: 'dataMin', max: 'dataMax' }],
|
||
yAxis: [{ scale: true, splitArea: { show: true } }, { scale: true, gridIndex: 1, splitNumber: 2, axisLabel: { show: false }, axisLine: { show: false }, axisTick: { show: false }, splitLine: { show: false } }],
|
||
dataZoom: [{ type: 'inside', xAxisIndex: [0, 1], start: 80, end: 100 }, { show: true, xAxisIndex: [0, 1], type: 'slider', bottom: 10, start: 80, end: 100 }],
|
||
series: [{ name: 'K线', type: 'candlestick', data: klineData, itemStyle: { color: '#ef5350', color0: '#26a69a', borderColor: '#ef5350', borderColor0: '#26a69a' } }, { name: '成交量', type: 'bar', xAxisIndex: 1, yAxisIndex: 1, data: volumes, itemStyle: { color: function(params) { const dataIndex = params.dataIndex; if (dataIndex === 0) return '#ef5350'; return stockData[dataIndex].close >= stockData[dataIndex - 1].close ? '#ef5350' : '#26a69a'; } } }]
|
||
};
|
||
}
|
||
return {};
|
||
};
|
||
|
||
if (!stock) return null;
|
||
|
||
return (
|
||
<Modal isOpen={isOpen} onClose={onClose} size="6xl">
|
||
<ModalOverlay />
|
||
<ModalContent maxW="90vw" maxH="90vh">
|
||
<ModalHeader>
|
||
<VStack align="flex-start" spacing={2}>
|
||
<HStack>
|
||
<Text fontSize="lg" fontWeight="bold">{stock.stock_code} - 股票详情</Text>
|
||
{chartData && (<Badge colorScheme="blue">{chartData.trade_date}</Badge>)}
|
||
</HStack>
|
||
<ButtonGroup size="sm">
|
||
<Button variant={chartType === 'timeline' ? 'solid' : 'outline'} onClick={() => setChartType('timeline')} colorScheme="blue">分时图</Button>
|
||
<Button variant={chartType === 'minute' ? 'solid' : 'outline'} onClick={() => setChartType('minute')} colorScheme="blue">分钟线</Button>
|
||
<Button variant={chartType === 'daily' ? 'solid' : 'outline'} onClick={() => setChartType('daily')} colorScheme="blue">日K线</Button>
|
||
</ButtonGroup>
|
||
</VStack>
|
||
</ModalHeader>
|
||
<ModalCloseButton />
|
||
<ModalBody p={0}>
|
||
<Box h="500px" w="100%" position="relative">
|
||
{loading && (
|
||
<Flex position="absolute" top="0" left="0" right="0" bottom="0" bg="rgba(255, 255, 255, 0.7)" zIndex="10" alignItems="center" justifyContent="center" >
|
||
<VStack spacing={4}>
|
||
<CircularProgress isIndeterminate color="blue.300" />
|
||
<Text>加载图表数据...</Text>
|
||
</VStack>
|
||
</Flex>
|
||
)}
|
||
<div ref={chartRef} style={{ height: '100%', width: '100%', minHeight: '500px' }}/>
|
||
</Box>
|
||
{stock?.relation_desc && (
|
||
<Box p={4} borderTop="1px solid" borderTopColor="gray.200">
|
||
<Text fontSize="sm" fontWeight="bold" mb={2}>关联描述:</Text>
|
||
<Text fontSize="sm" color="gray.600">{stock.relation_desc}</Text>
|
||
</Box>
|
||
)}
|
||
{process.env.NODE_ENV === 'development' && chartData && (
|
||
<Box p={4} bg="gray.50" fontSize="xs" color="gray.600">
|
||
<Text fontWeight="bold">调试信息:</Text>
|
||
<Text>数据条数: {chartData.data ? chartData.data.length : 0}</Text>
|
||
<Text>交易日期: {chartData.trade_date}</Text>
|
||
<Text>图表类型: {chartData.type}</Text>
|
||
</Box>
|
||
)}
|
||
</ModalBody>
|
||
</ModalContent>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
export default RelatedStocks; |