This commit is contained in:
2025-10-11 12:10:00 +08:00
parent 8107dee8d3
commit 4d0dc109bc
109 changed files with 152150 additions and 8037 deletions

View File

@@ -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;