updated
This commit is contained in:
@@ -48,7 +48,6 @@ import {
|
||||
FaSearch
|
||||
} from 'react-icons/fa';
|
||||
import * as echarts from 'echarts';
|
||||
import StockChartModal from '../../../components/StockChart/StockChartModal';
|
||||
|
||||
import { eventService, stockService } from '../../../services/eventService';
|
||||
|
||||
@@ -71,12 +70,22 @@ const RelatedStocks = ({
|
||||
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();
|
||||
@@ -132,6 +141,41 @@ const RelatedStocks = ({
|
||||
});
|
||||
};
|
||||
|
||||
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} 吗?`)) {
|
||||
@@ -288,6 +332,14 @@ const RelatedStocks = ({
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
leftIcon={<FaPlus />}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
onClick={onAddModalOpen}
|
||||
>
|
||||
添加股票
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
@@ -329,6 +381,7 @@ const RelatedStocks = ({
|
||||
}}>
|
||||
涨跌幅 {sortField === 'change' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||
</Th>
|
||||
<Th textAlign="center">分时图</Th>
|
||||
<Th textAlign="center" cursor="pointer" onClick={() => {
|
||||
if (sortField === 'correlation') {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
@@ -414,6 +467,16 @@ const RelatedStocks = ({
|
||||
</Badge>
|
||||
</Td>
|
||||
|
||||
{/* 分时图 */}
|
||||
<Td>
|
||||
<Box width="120px" height="40px">
|
||||
<MiniChart
|
||||
stockCode={stock.stock_code}
|
||||
eventTime={eventTime}
|
||||
onChartClick={() => handleShowChart(stock)}
|
||||
/>
|
||||
</Box>
|
||||
</Td>
|
||||
|
||||
{/* 相关度 */}
|
||||
<Td textAlign="center">
|
||||
@@ -438,20 +501,6 @@ const RelatedStocks = ({
|
||||
{/* 操作 */}
|
||||
<Td>
|
||||
<HStack spacing={1}>
|
||||
<Tooltip label="股票详情">
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
onClick={() => {
|
||||
const stockCode = stock.stock_code.split('.')[0];
|
||||
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
|
||||
}}
|
||||
>
|
||||
股票详情
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="查看K线图">
|
||||
<IconButton
|
||||
icon={<FaChartLine />}
|
||||
@@ -482,6 +531,14 @@ const RelatedStocks = ({
|
||||
</TableContainer>
|
||||
</Box>
|
||||
|
||||
{/* 添加股票模态框 */}
|
||||
<AddStockModal
|
||||
isOpen={isAddModalOpen}
|
||||
onClose={onAddModalClose}
|
||||
formData={addStockForm}
|
||||
setFormData={setAddStockForm}
|
||||
onSubmit={handleAddStock}
|
||||
/>
|
||||
|
||||
{/* 股票图表模态框 */}
|
||||
<StockChartModal
|
||||
@@ -496,8 +553,401 @@ const RelatedStocks = ({
|
||||
|
||||
// ==================== 子组件 ====================
|
||||
|
||||
// 迷你分时图组件
|
||||
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]);
|
||||
|
||||
// 现在使用统一的StockChartModal组件,无需重复代码
|
||||
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;
|
||||
Reference in New Issue
Block a user