2082 lines
85 KiB
JavaScript
2082 lines
85 KiB
JavaScript
// src/views/Market/MarketDataPro.jsx
|
||
import React, { useState, useEffect, useMemo } from 'react';
|
||
import { logger } from '../../utils/logger';
|
||
import { getApiBase } from '../../utils/apiConfig';
|
||
import {
|
||
Box,
|
||
Container,
|
||
Tabs,
|
||
TabList,
|
||
TabPanels,
|
||
Tab,
|
||
TabPanel,
|
||
Heading,
|
||
Text,
|
||
Table,
|
||
Thead,
|
||
Tbody,
|
||
Tr,
|
||
Th,
|
||
Td,
|
||
TableContainer,
|
||
Stat,
|
||
StatLabel,
|
||
StatNumber,
|
||
StatHelpText,
|
||
StatArrow,
|
||
SimpleGrid,
|
||
Card,
|
||
CardBody,
|
||
CardHeader,
|
||
Spinner,
|
||
Center,
|
||
Alert,
|
||
AlertIcon,
|
||
Badge,
|
||
VStack,
|
||
HStack,
|
||
Divider,
|
||
useColorModeValue,
|
||
Select,
|
||
Button,
|
||
Tooltip,
|
||
Progress,
|
||
Modal,
|
||
ModalOverlay,
|
||
ModalContent,
|
||
ModalHeader,
|
||
ModalBody,
|
||
ModalCloseButton,
|
||
useDisclosure,
|
||
Input,
|
||
Flex,
|
||
Tag,
|
||
TagLabel,
|
||
IconButton,
|
||
useToast,
|
||
Skeleton,
|
||
SkeletonText,
|
||
Grid,
|
||
GridItem,
|
||
ButtonGroup,
|
||
Stack,
|
||
useColorMode,
|
||
Icon,
|
||
InputGroup,
|
||
InputLeftElement,
|
||
Spacer,
|
||
CircularProgress,
|
||
CircularProgressLabel,
|
||
chakra,
|
||
} from '@chakra-ui/react';
|
||
import {
|
||
ChevronDownIcon,
|
||
ChevronUpIcon,
|
||
InfoIcon,
|
||
DownloadIcon,
|
||
RepeatIcon,
|
||
SearchIcon,
|
||
ViewIcon,
|
||
TimeIcon,
|
||
ArrowUpIcon,
|
||
ArrowDownIcon,
|
||
StarIcon,
|
||
WarningIcon,
|
||
LockIcon,
|
||
UnlockIcon,
|
||
BellIcon,
|
||
CalendarIcon,
|
||
ExternalLinkIcon,
|
||
AddIcon,
|
||
MinusIcon,
|
||
CheckCircleIcon,
|
||
SmallCloseIcon,
|
||
MoonIcon,
|
||
SunIcon,
|
||
} from '@chakra-ui/icons';
|
||
import ReactECharts from 'echarts-for-react';
|
||
import ReactMarkdown from 'react-markdown';
|
||
|
||
// API服务配置
|
||
const API_BASE_URL = getApiBase();
|
||
|
||
// 主题配置
|
||
const themes = {
|
||
light: {
|
||
// 日间模式 - 白+蓝
|
||
primary: '#2B6CB0',
|
||
primaryDark: '#1E4E8C',
|
||
secondary: '#FFFFFF',
|
||
secondaryDark: '#F7FAFC',
|
||
success: '#FF4444', // 涨 - 红色
|
||
danger: '#00C851', // 跌 - 绿色
|
||
warning: '#FF9800',
|
||
info: '#00BCD4',
|
||
bgMain: '#F7FAFC',
|
||
bgCard: '#FFFFFF',
|
||
bgDark: '#EDF2F7',
|
||
textPrimary: '#2D3748',
|
||
textSecondary: '#4A5568',
|
||
textMuted: '#718096',
|
||
border: '#CBD5E0',
|
||
chartBg: '#FFFFFF',
|
||
},
|
||
dark: {
|
||
// 夜间模式 - 黑+金
|
||
primary: '#FFD700',
|
||
primaryDark: '#FFA500',
|
||
secondary: '#1A1A1A',
|
||
secondaryDark: '#000000',
|
||
success: '#FF4444', // 涨 - 红色
|
||
danger: '#00C851', // 跌 - 绿色
|
||
warning: '#FFA500',
|
||
info: '#00BFFF',
|
||
bgMain: '#0A0A0A',
|
||
bgCard: '#141414',
|
||
bgDark: '#000000',
|
||
textPrimary: '#FFFFFF',
|
||
textSecondary: '#FFD700',
|
||
textMuted: '#999999',
|
||
border: '#333333',
|
||
chartBg: '#141414',
|
||
}
|
||
};
|
||
|
||
// API服务
|
||
const marketService = {
|
||
async apiRequest(url) {
|
||
try {
|
||
const response = await fetch(`${API_BASE_URL}${url}`);
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
return response.json();
|
||
} catch (error) {
|
||
logger.error('marketService', 'apiRequest', error, { url });
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async getTradeData(stockCode, days = 60) {
|
||
return this.apiRequest(`/api/market/trade/${stockCode}?days=${days}`);
|
||
},
|
||
|
||
async getFundingData(stockCode, days = 30) {
|
||
return this.apiRequest(`/api/market/funding/${stockCode}?days=${days}`);
|
||
},
|
||
|
||
async getBigDealData(stockCode, days = 30) {
|
||
return this.apiRequest(`/api/market/bigdeal/${stockCode}?days=${days}`);
|
||
},
|
||
|
||
async getUnusualData(stockCode, days = 30) {
|
||
return this.apiRequest(`/api/market/unusual/${stockCode}?days=${days}`);
|
||
},
|
||
|
||
async getPledgeData(stockCode) {
|
||
return this.apiRequest(`/api/market/pledge/${stockCode}`);
|
||
},
|
||
|
||
async getMarketSummary(stockCode) {
|
||
return this.apiRequest(`/api/market/summary/${stockCode}`);
|
||
},
|
||
|
||
async getRiseAnalysis(stockCode, startDate, endDate) {
|
||
let url = `/api/market/rise-analysis/${stockCode}`;
|
||
if (startDate && endDate) {
|
||
url += `?start_date=${startDate}&end_date=${endDate}`;
|
||
}
|
||
return this.apiRequest(url);
|
||
}
|
||
};
|
||
|
||
// 格式化工具
|
||
const formatUtils = {
|
||
formatNumber(value, decimals = 2) {
|
||
if (!value && value !== 0) return '-';
|
||
const num = parseFloat(value);
|
||
if (Math.abs(num) >= 100000000) {
|
||
return (num / 100000000).toFixed(decimals) + '亿';
|
||
} else if (Math.abs(num) >= 10000) {
|
||
return (num / 10000).toFixed(decimals) + '万';
|
||
}
|
||
return num.toFixed(decimals);
|
||
},
|
||
|
||
formatPercent(value) {
|
||
if (!value && value !== 0) return '-';
|
||
return value.toFixed(2) + '%';
|
||
},
|
||
|
||
formatDate(dateStr) {
|
||
if (!dateStr) return '-';
|
||
return dateStr.substring(0, 10);
|
||
}
|
||
};
|
||
|
||
// 主题化卡片组件
|
||
const ThemedCard = ({ children, theme, ...props }) => {
|
||
return (
|
||
<Card
|
||
bg={theme.bgCard}
|
||
border="1px solid"
|
||
borderColor={theme.border}
|
||
borderRadius="xl"
|
||
boxShadow="lg"
|
||
transition="all 0.3s ease"
|
||
_hover={{
|
||
transform: 'translateY(-2px)',
|
||
boxShadow: 'xl'
|
||
}}
|
||
{...props}
|
||
>
|
||
{children}
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
// Markdown渲染组件
|
||
const MarkdownRenderer = ({ children, theme, colorMode }) => {
|
||
return (
|
||
<Box
|
||
color={theme.textPrimary}
|
||
sx={{
|
||
'& h1, & h2, & h3, & h4, & h5, & h6': {
|
||
color: theme.primary,
|
||
marginTop: 4,
|
||
marginBottom: 2,
|
||
fontWeight: 'bold'
|
||
},
|
||
'& h1': { fontSize: '1.5em' },
|
||
'& h2': { fontSize: '1.3em' },
|
||
'& h3': { fontSize: '1.1em' },
|
||
'& p': {
|
||
marginBottom: 3,
|
||
lineHeight: 1.6
|
||
},
|
||
'& ul, & ol': {
|
||
paddingLeft: 4,
|
||
marginBottom: 3
|
||
},
|
||
'& li': {
|
||
marginBottom: 1
|
||
},
|
||
'& strong': {
|
||
fontWeight: 'bold',
|
||
color: theme.textSecondary
|
||
},
|
||
'& em': {
|
||
fontStyle: 'italic'
|
||
},
|
||
'& code': {
|
||
backgroundColor: colorMode === 'light' ? 'rgba(0,0,0,0.05)' : 'rgba(255,255,255,0.1)',
|
||
padding: '2px 4px',
|
||
borderRadius: '4px',
|
||
fontSize: '0.9em'
|
||
},
|
||
'& blockquote': {
|
||
borderLeft: `3px solid ${theme.primary}`,
|
||
paddingLeft: 4,
|
||
marginLeft: 2,
|
||
fontStyle: 'italic',
|
||
opacity: 0.9
|
||
}
|
||
}}
|
||
>
|
||
<ReactMarkdown>{children}</ReactMarkdown>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
// 主组件
|
||
const MarketDataView = ({ stockCode: propStockCode }) => {
|
||
const { colorMode } = useColorMode();
|
||
const toast = useToast();
|
||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||
const [modalContent, setModalContent] = useState(null);
|
||
|
||
// 获取当前主题
|
||
const theme = colorMode === 'light' ? themes.light : themes.dark;
|
||
|
||
// 状态管理
|
||
const [stockCode, setStockCode] = useState(propStockCode || '600000');
|
||
const [loading, setLoading] = useState(false);
|
||
const [activeTab, setActiveTab] = useState(0);
|
||
const [selectedPeriod, setSelectedPeriod] = useState(60);
|
||
|
||
// 数据状态
|
||
const [summary, setSummary] = useState(null);
|
||
const [tradeData, setTradeData] = useState([]);
|
||
const [fundingData, setFundingData] = useState([]);
|
||
const [bigDealData, setBigDealData] = useState({ data: [], daily_stats: [] });
|
||
const [unusualData, setUnusualData] = useState({ data: [], grouped_data: [] });
|
||
const [pledgeData, setPledgeData] = useState([]);
|
||
const [riseAnalysisData, setRiseAnalysisData] = useState([]);
|
||
const [analysisMap, setAnalysisMap] = useState({});
|
||
const [minuteData, setMinuteData] = useState([]);
|
||
const [minuteLoading, setMinuteLoading] = useState(false);
|
||
|
||
// 加载数据
|
||
const loadMarketData = async () => {
|
||
logger.debug('MarketDataView', '开始加载市场数据', { stockCode, selectedPeriod });
|
||
setLoading(true);
|
||
try {
|
||
const [summaryRes, tradeRes, fundingRes, bigDealRes, unusualRes, pledgeRes, riseAnalysisRes] = await Promise.all([
|
||
marketService.getMarketSummary(stockCode),
|
||
marketService.getTradeData(stockCode, selectedPeriod),
|
||
marketService.getFundingData(stockCode, 30),
|
||
marketService.getBigDealData(stockCode, 30),
|
||
marketService.getUnusualData(stockCode, 30),
|
||
marketService.getPledgeData(stockCode),
|
||
marketService.getRiseAnalysis(stockCode)
|
||
]);
|
||
|
||
if (summaryRes.success) setSummary(summaryRes.data);
|
||
if (tradeRes.success) setTradeData(tradeRes.data);
|
||
if (fundingRes.success) setFundingData(fundingRes.data);
|
||
if (bigDealRes.success) setBigDealData(bigDealRes); // 设置整个响应对象,包含daily_stats
|
||
if (unusualRes.success) setUnusualData(unusualRes); // 设置整个响应对象,包含grouped_data
|
||
if (pledgeRes.success) setPledgeData(pledgeRes.data);
|
||
if (riseAnalysisRes.success) {
|
||
setRiseAnalysisData(riseAnalysisRes.data);
|
||
|
||
// 创建分析数据映射
|
||
const tempAnalysisMap = {};
|
||
if (tradeRes.success && tradeRes.data && riseAnalysisRes.data) {
|
||
riseAnalysisRes.data.forEach(analysis => {
|
||
const dateIndex = tradeRes.data.findIndex(item =>
|
||
item.date.substring(0, 10) === analysis.trade_date
|
||
);
|
||
if (dateIndex !== -1) {
|
||
tempAnalysisMap[dateIndex] = analysis;
|
||
}
|
||
});
|
||
}
|
||
setAnalysisMap(tempAnalysisMap);
|
||
}
|
||
|
||
// ❌ 移除数据加载成功toast
|
||
logger.info('MarketDataView', '市场数据加载成功', { stockCode });
|
||
} catch (error) {
|
||
logger.error('MarketDataView', 'loadMarketData', error, { stockCode, selectedPeriod });
|
||
|
||
// ❌ 移除数据加载失败toast
|
||
// toast({ title: '数据加载失败', description: error.message, status: 'error', duration: 5000, isClosable: true });
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 获取分钟频数据
|
||
const loadMinuteData = async () => {
|
||
logger.debug('MarketDataView', '开始加载分钟频数据', { stockCode });
|
||
setMinuteLoading(true);
|
||
try {
|
||
const response = await fetch(
|
||
`${API_BASE_URL}/api/stock/${stockCode}/latest-minute`,
|
||
{
|
||
method: 'GET',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
}
|
||
}
|
||
);
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch minute data');
|
||
}
|
||
|
||
const data = await response.json();
|
||
if (data.data && Array.isArray(data.data)) {
|
||
setMinuteData(data);
|
||
logger.info('MarketDataView', '分钟频数据加载成功', { stockCode, dataPoints: data.data.length });
|
||
} else {
|
||
setMinuteData({ data: [], code: stockCode, name: '', trade_date: '', type: 'minute' });
|
||
logger.warn('MarketDataView', '分钟频数据为空', { stockCode });
|
||
}
|
||
|
||
} catch (error) {
|
||
logger.error('MarketDataView', 'loadMinuteData', error, { stockCode });
|
||
|
||
// ❌ 移除分钟数据加载失败toast
|
||
// toast({ title: '分钟数据加载失败', description: error.message, status: 'error', duration: 3000, isClosable: true });
|
||
setMinuteData({ data: [], code: stockCode, name: '', trade_date: '', type: 'minute' });
|
||
} finally {
|
||
setMinuteLoading(false);
|
||
}
|
||
};
|
||
|
||
// 监听props中的stockCode变化
|
||
useEffect(() => {
|
||
if (propStockCode && propStockCode !== stockCode) {
|
||
setStockCode(propStockCode);
|
||
}
|
||
}, [propStockCode, stockCode]);
|
||
|
||
useEffect(() => {
|
||
if (stockCode) {
|
||
loadMarketData();
|
||
// 自动加载分钟频数据
|
||
loadMinuteData();
|
||
}
|
||
}, [stockCode, selectedPeriod]);
|
||
|
||
// K线图配置
|
||
const getKLineOption = () => {
|
||
if (!tradeData || tradeData.length === 0) return {};
|
||
|
||
const dates = tradeData.map(item => item.date.substring(5, 10));
|
||
const kData = tradeData.map(item => [item.open, item.close, item.low, item.high]);
|
||
const volumes = tradeData.map(item => item.volume);
|
||
const ma5 = calculateMA(tradeData.map(item => item.close), 5);
|
||
const ma10 = calculateMA(tradeData.map(item => item.close), 10);
|
||
const ma20 = calculateMA(tradeData.map(item => item.close), 20);
|
||
|
||
// 创建涨幅分析标记点
|
||
const scatterData = [];
|
||
|
||
// 使用组件级别的 analysisMap
|
||
Object.keys(analysisMap).forEach(dateIndex => {
|
||
const idx = parseInt(dateIndex);
|
||
if (tradeData[idx]) {
|
||
const value = tradeData[idx].high * 1.02; // 在最高价上方显示
|
||
scatterData.push([idx, value]);
|
||
}
|
||
});
|
||
|
||
return {
|
||
backgroundColor: theme.chartBg,
|
||
animation: true,
|
||
legend: {
|
||
data: ['K线', 'MA5', 'MA10', 'MA20'],
|
||
top: 10,
|
||
textStyle: {
|
||
color: theme.textPrimary
|
||
}
|
||
},
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
axisPointer: {
|
||
type: 'cross',
|
||
lineStyle: {
|
||
color: theme.primary,
|
||
width: 1,
|
||
opacity: 0.8
|
||
}
|
||
},
|
||
backgroundColor: colorMode === 'light' ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.8)',
|
||
borderColor: theme.primary,
|
||
borderWidth: 1,
|
||
textStyle: {
|
||
color: theme.textPrimary
|
||
},
|
||
formatter: function(params) {
|
||
const dataIndex = params[0]?.dataIndex;
|
||
let result = `${params[0]?.name || ''}<br/>`;
|
||
|
||
params.forEach(param => {
|
||
if (param.seriesName === '涨幅分析' && analysisMap[dataIndex]) {
|
||
const analysis = analysisMap[dataIndex];
|
||
result = `<div style="min-width: 300px;">
|
||
<strong>${analysis.stock_name} (${analysis.stock_code})</strong><br/>
|
||
日期: ${analysis.trade_date}<br/>
|
||
涨幅: <span style="color: #FF4444; font-weight: bold;">${analysis.rise_rate}%</span><br/>
|
||
收盘价: ${analysis.close_price}<br/>
|
||
<br/>
|
||
<strong>涨幅原因:</strong><br/>
|
||
<div style="color: #666; max-width: 400px;">${analysis.rise_reason_brief || '暂无分析'}</div>
|
||
<br/>
|
||
<div style="color: #999; font-size: 12px;">点击查看详细分析</div>
|
||
</div>`;
|
||
} else if (param.seriesName === 'K线') {
|
||
const [open, close, low, high] = param.data;
|
||
result += `${param.marker} ${param.seriesName}<br/>`;
|
||
result += `开盘: ${open}<br/>`;
|
||
result += `收盘: ${close}<br/>`;
|
||
result += `最低: ${low}<br/>`;
|
||
result += `最高: ${high}<br/>`;
|
||
} else if (param.value != null) {
|
||
result += `${param.marker} ${param.seriesName}: ${param.value}<br/>`;
|
||
}
|
||
});
|
||
|
||
return result;
|
||
}
|
||
},
|
||
xAxis: [
|
||
{
|
||
type: 'category',
|
||
data: dates,
|
||
boundaryGap: false,
|
||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||
axisLabel: { color: theme.textMuted }
|
||
},
|
||
{
|
||
type: 'category',
|
||
gridIndex: 1,
|
||
data: dates,
|
||
boundaryGap: false,
|
||
axisLine: { onZero: false, lineStyle: { color: theme.textMuted } },
|
||
axisTick: { show: false },
|
||
splitLine: { show: false },
|
||
axisLabel: { show: false }
|
||
}
|
||
],
|
||
yAxis: [
|
||
{
|
||
scale: true,
|
||
splitLine: {
|
||
show: true,
|
||
lineStyle: {
|
||
color: theme.border
|
||
}
|
||
},
|
||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||
axisLabel: { color: theme.textMuted }
|
||
},
|
||
{
|
||
scale: true,
|
||
gridIndex: 1,
|
||
splitNumber: 2,
|
||
axisLabel: { show: false },
|
||
axisLine: { show: false },
|
||
axisTick: { show: false },
|
||
splitLine: { show: false }
|
||
}
|
||
],
|
||
grid: [
|
||
{
|
||
left: '10%',
|
||
right: '10%',
|
||
height: '50%'
|
||
},
|
||
{
|
||
left: '10%',
|
||
right: '10%',
|
||
top: '65%',
|
||
height: '20%'
|
||
}
|
||
],
|
||
series: [
|
||
{
|
||
name: 'K线',
|
||
type: 'candlestick',
|
||
data: kData,
|
||
itemStyle: {
|
||
color: theme.success, // 涨 - 红色
|
||
color0: theme.danger, // 跌 - 绿色
|
||
borderColor: theme.success,
|
||
borderColor0: theme.danger
|
||
}
|
||
},
|
||
{
|
||
name: 'MA5',
|
||
type: 'line',
|
||
data: ma5,
|
||
smooth: true,
|
||
lineStyle: {
|
||
color: theme.primary,
|
||
width: 1
|
||
},
|
||
itemStyle: {
|
||
color: theme.primary
|
||
}
|
||
},
|
||
{
|
||
name: 'MA10',
|
||
type: 'line',
|
||
data: ma10,
|
||
smooth: true,
|
||
lineStyle: {
|
||
color: theme.info,
|
||
width: 1
|
||
},
|
||
itemStyle: {
|
||
color: theme.info
|
||
}
|
||
},
|
||
{
|
||
name: 'MA20',
|
||
type: 'line',
|
||
data: ma20,
|
||
smooth: true,
|
||
lineStyle: {
|
||
color: theme.warning,
|
||
width: 1
|
||
},
|
||
itemStyle: {
|
||
color: theme.warning
|
||
}
|
||
},
|
||
{
|
||
name: '涨幅分析',
|
||
type: 'scatter',
|
||
data: scatterData,
|
||
symbolSize: 30,
|
||
symbol: 'pin',
|
||
itemStyle: {
|
||
color: '#FFD700',
|
||
shadowBlur: 10,
|
||
shadowColor: 'rgba(255, 215, 0, 0.5)'
|
||
},
|
||
label: {
|
||
show: true,
|
||
formatter: '★',
|
||
fontSize: 20,
|
||
position: 'inside',
|
||
color: '#FF6B6B'
|
||
},
|
||
emphasis: {
|
||
scale: 1.5,
|
||
itemStyle: {
|
||
color: '#FFA500'
|
||
}
|
||
},
|
||
z: 100, // 确保显示在最上层
|
||
cursor: 'pointer' // 显示为可点击
|
||
},
|
||
{
|
||
name: '成交量',
|
||
type: 'bar',
|
||
xAxisIndex: 1,
|
||
yAxisIndex: 1,
|
||
data: volumes,
|
||
itemStyle: {
|
||
color: (params) => {
|
||
const item = tradeData[params.dataIndex];
|
||
return item.change_percent >= 0 ?
|
||
'rgba(255, 68, 68, 0.6)' : 'rgba(0, 200, 81, 0.6)';
|
||
}
|
||
}
|
||
}
|
||
]
|
||
};
|
||
};
|
||
|
||
// 分钟频K线图配置
|
||
const getMinuteKLineOption = () => {
|
||
if (!minuteData || !minuteData.data || minuteData.data.length === 0) return {};
|
||
|
||
const times = minuteData.data.map(item => item.time);
|
||
const kData = minuteData.data.map(item => [item.open, item.close, item.low, item.high]);
|
||
const volumes = minuteData.data.map(item => item.volume);
|
||
const avgPrice = calculateMA(minuteData.data.map(item => item.close), 5); // 5分钟均价
|
||
|
||
// 计算开盘价基准线(用于涨跌判断)
|
||
const openPrice = minuteData.data.length > 0 ? minuteData.data[0].open : 0;
|
||
|
||
return {
|
||
backgroundColor: theme.chartBg,
|
||
title: {
|
||
text: `${minuteData.name} 分钟K线 (${minuteData.trade_date})`,
|
||
left: 'center',
|
||
textStyle: {
|
||
color: theme.textPrimary,
|
||
fontSize: 16,
|
||
fontWeight: 'bold'
|
||
},
|
||
subtextStyle: {
|
||
color: theme.textMuted
|
||
}
|
||
},
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
axisPointer: { type: 'cross' },
|
||
backgroundColor: colorMode === 'light' ? 'rgba(255,255,255,0.95)' : 'rgba(0,0,0,0.85)',
|
||
borderColor: theme.primary,
|
||
borderWidth: 1,
|
||
textStyle: {
|
||
color: theme.textPrimary,
|
||
fontSize: 12
|
||
},
|
||
formatter: (params) => {
|
||
let result = params[0].name + '<br/>';
|
||
params.forEach(param => {
|
||
if (param.seriesName === '分钟K线') {
|
||
const [open, close, low, high] = param.data;
|
||
const changePercent = openPrice > 0 ? ((close - openPrice) / openPrice * 100).toFixed(2) : '0.00';
|
||
result += `${param.marker} ${param.seriesName}<br/>`;
|
||
result += `开盘: <span style="font-weight: bold">${open.toFixed(2)}</span><br/>`;
|
||
result += `收盘: <span style="font-weight: bold; color: ${close >= open ? theme.success : theme.danger}">${close.toFixed(2)}</span><br/>`;
|
||
result += `最高: <span style="font-weight: bold">${high.toFixed(2)}</span><br/>`;
|
||
result += `最低: <span style="font-weight: bold">${low.toFixed(2)}</span><br/>`;
|
||
result += `涨跌: <span style="font-weight: bold; color: ${close >= openPrice ? theme.success : theme.danger}">${changePercent}%</span><br/>`;
|
||
} else if (param.seriesName === '均价线') {
|
||
result += `${param.marker} ${param.seriesName}: <span style="font-weight: bold">${param.value.toFixed(2)}</span><br/>`;
|
||
} else if (param.seriesName === '成交量') {
|
||
result += `${param.marker} ${param.seriesName}: <span style="font-weight: bold">${formatUtils.formatNumber(param.value, 0)}</span><br/>`;
|
||
}
|
||
});
|
||
return result;
|
||
}
|
||
},
|
||
legend: {
|
||
data: ['分钟K线', '均价线', '成交量'],
|
||
top: 35,
|
||
textStyle: {
|
||
color: theme.textPrimary,
|
||
fontSize: 12
|
||
},
|
||
itemWidth: 25,
|
||
itemHeight: 14
|
||
},
|
||
grid: [
|
||
{
|
||
left: '8%',
|
||
right: '8%',
|
||
top: '20%',
|
||
height: '60%'
|
||
},
|
||
{
|
||
left: '8%',
|
||
right: '8%',
|
||
top: '83%',
|
||
height: '12%'
|
||
}
|
||
],
|
||
xAxis: [
|
||
{
|
||
type: 'category',
|
||
data: times,
|
||
scale: true,
|
||
boundaryGap: false,
|
||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||
axisLabel: {
|
||
color: theme.textMuted,
|
||
fontSize: 10,
|
||
interval: 'auto'
|
||
},
|
||
splitLine: { show: false }
|
||
},
|
||
{
|
||
type: 'category',
|
||
gridIndex: 1,
|
||
data: times,
|
||
scale: true,
|
||
boundaryGap: false,
|
||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||
axisLabel: {
|
||
color: theme.textMuted,
|
||
fontSize: 10
|
||
},
|
||
splitLine: { show: false }
|
||
}
|
||
],
|
||
yAxis: [
|
||
{
|
||
scale: true,
|
||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||
axisLabel: { color: theme.textMuted, fontSize: 10 },
|
||
splitLine: {
|
||
lineStyle: {
|
||
color: theme.border,
|
||
type: 'dashed'
|
||
}
|
||
}
|
||
},
|
||
{
|
||
gridIndex: 1,
|
||
scale: true,
|
||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||
axisLabel: { color: theme.textMuted, fontSize: 10 },
|
||
splitLine: { show: false }
|
||
}
|
||
],
|
||
dataZoom: [
|
||
{
|
||
type: 'inside',
|
||
xAxisIndex: [0, 1],
|
||
start: 70,
|
||
end: 100,
|
||
minValueSpan: 20
|
||
},
|
||
{
|
||
show: true,
|
||
xAxisIndex: [0, 1],
|
||
type: 'slider',
|
||
top: '95%',
|
||
start: 70,
|
||
end: 100,
|
||
height: 20,
|
||
handleSize: '100%',
|
||
handleStyle: {
|
||
color: theme.primary
|
||
},
|
||
textStyle: {
|
||
color: theme.textMuted
|
||
}
|
||
}
|
||
],
|
||
series: [
|
||
{
|
||
name: '分钟K线',
|
||
type: 'candlestick',
|
||
data: kData,
|
||
itemStyle: {
|
||
color: theme.success,
|
||
color0: theme.danger,
|
||
borderColor: theme.success,
|
||
borderColor0: theme.danger,
|
||
borderWidth: 1
|
||
},
|
||
barWidth: '60%'
|
||
},
|
||
{
|
||
name: '均价线',
|
||
type: 'line',
|
||
data: avgPrice,
|
||
smooth: true,
|
||
symbol: 'none',
|
||
lineStyle: {
|
||
color: theme.info,
|
||
width: 2,
|
||
opacity: 0.8
|
||
}
|
||
},
|
||
{
|
||
name: '成交量',
|
||
type: 'bar',
|
||
xAxisIndex: 1,
|
||
yAxisIndex: 1,
|
||
data: volumes,
|
||
barWidth: '50%',
|
||
itemStyle: {
|
||
color: (params) => {
|
||
const item = minuteData.data[params.dataIndex];
|
||
return item.close >= item.open ?
|
||
'rgba(255, 68, 68, 0.6)' : 'rgba(0, 200, 81, 0.6)';
|
||
}
|
||
}
|
||
}
|
||
]
|
||
};
|
||
};
|
||
|
||
// 计算移动平均线
|
||
const calculateMA = (data, period) => {
|
||
const result = [];
|
||
for (let i = 0; i < data.length; i++) {
|
||
if (i < period - 1) {
|
||
result.push(null);
|
||
continue;
|
||
}
|
||
let sum = 0;
|
||
for (let j = 0; j < period; j++) {
|
||
sum += data[i - j];
|
||
}
|
||
result.push(sum / period);
|
||
}
|
||
return result;
|
||
};
|
||
|
||
// 融资融券图表配置
|
||
const getFundingOption = () => {
|
||
if (!fundingData || fundingData.length === 0) return {};
|
||
|
||
const dates = fundingData.map(item => item.date.substring(5, 10));
|
||
const financing = fundingData.map(item => item.financing.balance / 100000000);
|
||
const securities = fundingData.map(item => item.securities.balance_amount / 100000000);
|
||
|
||
return {
|
||
backgroundColor: theme.chartBg,
|
||
title: {
|
||
text: '融资融券余额走势',
|
||
left: 'center',
|
||
textStyle: {
|
||
color: theme.textPrimary,
|
||
fontSize: 16
|
||
}
|
||
},
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
backgroundColor: colorMode === 'light' ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.8)',
|
||
borderColor: theme.primary,
|
||
borderWidth: 1,
|
||
textStyle: {
|
||
color: theme.textPrimary
|
||
},
|
||
formatter: (params) => {
|
||
let result = params[0].name + '<br/>';
|
||
params.forEach(param => {
|
||
result += `${param.marker} ${param.seriesName}: ${param.value.toFixed(2)}亿<br/>`;
|
||
});
|
||
return result;
|
||
}
|
||
},
|
||
legend: {
|
||
data: ['融资余额', '融券余额'],
|
||
bottom: 10,
|
||
textStyle: {
|
||
color: theme.textPrimary
|
||
}
|
||
},
|
||
grid: {
|
||
left: '3%',
|
||
right: '4%',
|
||
bottom: '15%',
|
||
containLabel: true
|
||
},
|
||
xAxis: {
|
||
type: 'category',
|
||
boundaryGap: false,
|
||
data: dates,
|
||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||
axisLabel: { color: theme.textMuted }
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
name: '金额(亿)',
|
||
nameTextStyle: { color: theme.textMuted },
|
||
splitLine: {
|
||
lineStyle: {
|
||
color: theme.border
|
||
}
|
||
},
|
||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||
axisLabel: { color: theme.textMuted }
|
||
},
|
||
series: [
|
||
{
|
||
name: '融资余额',
|
||
type: 'line',
|
||
smooth: true,
|
||
symbol: 'circle',
|
||
symbolSize: 8,
|
||
areaStyle: {
|
||
color: {
|
||
type: 'linear',
|
||
x: 0,
|
||
y: 0,
|
||
x2: 0,
|
||
y2: 1,
|
||
colorStops: [{
|
||
offset: 0, color: 'rgba(255, 68, 68, 0.3)'
|
||
}, {
|
||
offset: 1, color: 'rgba(255, 68, 68, 0.05)'
|
||
}]
|
||
}
|
||
},
|
||
lineStyle: {
|
||
color: theme.success,
|
||
width: 2
|
||
},
|
||
itemStyle: {
|
||
color: theme.success,
|
||
borderColor: theme.success,
|
||
borderWidth: 2
|
||
},
|
||
data: financing
|
||
},
|
||
{
|
||
name: '融券余额',
|
||
type: 'line',
|
||
smooth: true,
|
||
symbol: 'diamond',
|
||
symbolSize: 8,
|
||
areaStyle: {
|
||
color: {
|
||
type: 'linear',
|
||
x: 0,
|
||
y: 0,
|
||
x2: 0,
|
||
y2: 1,
|
||
colorStops: [{
|
||
offset: 0, color: 'rgba(0, 200, 81, 0.3)'
|
||
}, {
|
||
offset: 1, color: 'rgba(0, 200, 81, 0.05)'
|
||
}]
|
||
}
|
||
},
|
||
lineStyle: {
|
||
color: theme.danger,
|
||
width: 2
|
||
},
|
||
itemStyle: {
|
||
color: theme.danger,
|
||
borderColor: theme.danger,
|
||
borderWidth: 2
|
||
},
|
||
data: securities
|
||
}
|
||
]
|
||
};
|
||
};
|
||
|
||
// 股权质押图表配置
|
||
const getPledgeOption = () => {
|
||
if (!pledgeData || pledgeData.length === 0) return {};
|
||
|
||
const dates = pledgeData.map(item => item.end_date.substring(5, 10));
|
||
const ratios = pledgeData.map(item => item.pledge_ratio);
|
||
const counts = pledgeData.map(item => item.pledge_count);
|
||
|
||
return {
|
||
backgroundColor: theme.chartBg,
|
||
title: {
|
||
text: '股权质押趋势',
|
||
left: 'center',
|
||
textStyle: {
|
||
color: theme.textPrimary,
|
||
fontSize: 16
|
||
}
|
||
},
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
backgroundColor: colorMode === 'light' ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.8)',
|
||
borderColor: theme.primary,
|
||
borderWidth: 1,
|
||
textStyle: {
|
||
color: theme.textPrimary
|
||
}
|
||
},
|
||
legend: {
|
||
data: ['质押比例', '质押笔数'],
|
||
bottom: 10,
|
||
textStyle: {
|
||
color: theme.textPrimary
|
||
}
|
||
},
|
||
grid: {
|
||
left: '3%',
|
||
right: '4%',
|
||
bottom: '15%',
|
||
containLabel: true
|
||
},
|
||
xAxis: {
|
||
type: 'category',
|
||
data: dates,
|
||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||
axisLabel: { color: theme.textMuted }
|
||
},
|
||
yAxis: [
|
||
{
|
||
type: 'value',
|
||
name: '质押比例(%)',
|
||
nameTextStyle: { color: theme.textMuted },
|
||
splitLine: {
|
||
lineStyle: {
|
||
color: theme.border
|
||
}
|
||
},
|
||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||
axisLabel: { color: theme.textMuted }
|
||
},
|
||
{
|
||
type: 'value',
|
||
name: '质押笔数',
|
||
nameTextStyle: { color: theme.textMuted },
|
||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||
axisLabel: { color: theme.textMuted }
|
||
}
|
||
],
|
||
series: [
|
||
{
|
||
name: '质押比例',
|
||
type: 'line',
|
||
smooth: true,
|
||
symbol: 'circle',
|
||
symbolSize: 8,
|
||
lineStyle: {
|
||
color: theme.warning,
|
||
width: 2,
|
||
shadowBlur: 10,
|
||
shadowColor: theme.warning
|
||
},
|
||
itemStyle: {
|
||
color: theme.warning,
|
||
borderColor: theme.bgCard,
|
||
borderWidth: 2
|
||
},
|
||
data: ratios
|
||
},
|
||
{
|
||
name: '质押笔数',
|
||
type: 'bar',
|
||
yAxisIndex: 1,
|
||
barWidth: '50%',
|
||
itemStyle: {
|
||
color: {
|
||
type: 'linear',
|
||
x: 0,
|
||
y: 0,
|
||
x2: 0,
|
||
y2: 1,
|
||
colorStops: [{
|
||
offset: 0, color: theme.primary
|
||
}, {
|
||
offset: 1, color: theme.primaryDark
|
||
}]
|
||
},
|
||
barBorderRadius: [5, 5, 0, 0]
|
||
},
|
||
data: counts
|
||
}
|
||
]
|
||
};
|
||
};
|
||
|
||
return (
|
||
<Box bg={theme.bgMain} minH="100vh" color={theme.textPrimary}>
|
||
<Container maxW="container.xl" py={6}>
|
||
<VStack spacing={6} align="stretch">
|
||
{/* 股票概览 */}
|
||
{summary && (
|
||
<ThemedCard theme={theme}>
|
||
<CardBody>
|
||
<Grid templateColumns="repeat(12, 1fr)" gap={6}>
|
||
<GridItem colSpan={{ base: 12, md: 4 }}>
|
||
<VStack align="start" spacing={2}>
|
||
<HStack>
|
||
<Heading size="xl" color={theme.textSecondary}>
|
||
{summary.stock_name}
|
||
</Heading>
|
||
<Badge colorScheme={colorMode === 'light' ? 'blue' : 'yellow'} fontSize="lg">
|
||
{summary.stock_code}
|
||
</Badge>
|
||
</HStack>
|
||
{summary.latest_trade && (
|
||
<HStack spacing={4}>
|
||
<Stat>
|
||
<StatNumber fontSize="4xl" color={theme.textPrimary}>
|
||
{summary.latest_trade.close}
|
||
</StatNumber>
|
||
<StatHelpText fontSize="lg">
|
||
<StatArrow
|
||
type={summary.latest_trade.change_percent >= 0 ? 'increase' : 'decrease'}
|
||
color={summary.latest_trade.change_percent >= 0 ? theme.success : theme.danger}
|
||
/>
|
||
{Math.abs(summary.latest_trade.change_percent).toFixed(2)}%
|
||
</StatHelpText>
|
||
</Stat>
|
||
</HStack>
|
||
)}
|
||
</VStack>
|
||
</GridItem>
|
||
|
||
<GridItem colSpan={{ base: 12, md: 8 }}>
|
||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
|
||
{summary.latest_trade && (
|
||
<>
|
||
<Stat>
|
||
<StatLabel color={theme.textMuted}>成交量</StatLabel>
|
||
<StatNumber color={theme.textSecondary}>
|
||
{formatUtils.formatNumber(summary.latest_trade.volume, 0)}
|
||
</StatNumber>
|
||
</Stat>
|
||
<Stat>
|
||
<StatLabel color={theme.textMuted}>成交额</StatLabel>
|
||
<StatNumber color={theme.textSecondary}>
|
||
{formatUtils.formatNumber(summary.latest_trade.amount)}
|
||
</StatNumber>
|
||
</Stat>
|
||
<Stat>
|
||
<StatLabel color={theme.textMuted}>换手率</StatLabel>
|
||
<StatNumber color={theme.textSecondary}>
|
||
{formatUtils.formatPercent(summary.latest_trade.turnover_rate)}
|
||
</StatNumber>
|
||
</Stat>
|
||
<Stat>
|
||
<StatLabel color={theme.textMuted}>市盈率</StatLabel>
|
||
<StatNumber color={theme.textSecondary}>
|
||
{summary.latest_trade.pe_ratio || '-'}
|
||
</StatNumber>
|
||
</Stat>
|
||
</>
|
||
)}
|
||
</SimpleGrid>
|
||
|
||
{summary.latest_funding && (
|
||
<SimpleGrid columns={{ base: 2, md: 3 }} spacing={4} mt={4}>
|
||
<Stat>
|
||
<StatLabel color={theme.textMuted}>融资余额</StatLabel>
|
||
<StatNumber color={theme.success} fontSize="lg">
|
||
{formatUtils.formatNumber(summary.latest_funding.financing_balance)}
|
||
</StatNumber>
|
||
</Stat>
|
||
<Stat>
|
||
<StatLabel color={theme.textMuted}>融券余额</StatLabel>
|
||
<StatNumber color={theme.danger} fontSize="lg">
|
||
{formatUtils.formatNumber(summary.latest_funding.securities_balance)}
|
||
</StatNumber>
|
||
</Stat>
|
||
{summary.latest_pledge && (
|
||
<Stat>
|
||
<StatLabel color={theme.textMuted}>质押比例</StatLabel>
|
||
<StatNumber color={theme.warning} fontSize="lg">
|
||
{formatUtils.formatPercent(summary.latest_pledge.pledge_ratio)}
|
||
</StatNumber>
|
||
</Stat>
|
||
)}
|
||
</SimpleGrid>
|
||
)}
|
||
</GridItem>
|
||
</Grid>
|
||
</CardBody>
|
||
</ThemedCard>
|
||
)}
|
||
|
||
{/* 主要内容区域 */}
|
||
{loading ? (
|
||
<ThemedCard theme={theme}>
|
||
<CardBody>
|
||
<Center h="400px">
|
||
<VStack spacing={4}>
|
||
<Spinner
|
||
thickness="4px"
|
||
speed="0.65s"
|
||
emptyColor={theme.bgDark}
|
||
color={theme.primary}
|
||
size="xl"
|
||
/>
|
||
<Text color={theme.textSecondary}>数据加载中...</Text>
|
||
</VStack>
|
||
</Center>
|
||
</CardBody>
|
||
</ThemedCard>
|
||
) : (
|
||
<Tabs
|
||
variant="soft-rounded"
|
||
colorScheme={colorMode === 'light' ? 'blue' : 'yellow'}
|
||
index={activeTab}
|
||
onChange={setActiveTab}
|
||
>
|
||
<Box
|
||
bg={theme.bgCard}
|
||
p={4}
|
||
borderRadius="xl"
|
||
border="1px solid"
|
||
borderColor={theme.border}
|
||
>
|
||
<HStack justify="space-between" align="center" spacing={4}>
|
||
<TabList overflowX="auto" border="none" flex="1">
|
||
<Tab color={theme.textMuted} _selected={{ color: colorMode === 'light' ? 'white' : theme.secondary, bg: theme.primary }} fontSize="sm" px={3}>
|
||
<HStack spacing={1}>
|
||
<Icon as={ChevronUpIcon} boxSize={4} />
|
||
<Text>交易数据</Text>
|
||
</HStack>
|
||
</Tab>
|
||
<Tab color={theme.textMuted} _selected={{ color: colorMode === 'light' ? 'white' : theme.secondary, bg: theme.primary }} fontSize="sm" px={3}>
|
||
<HStack spacing={1}>
|
||
<Icon as={UnlockIcon} boxSize={4} />
|
||
<Text>融资融券</Text>
|
||
</HStack>
|
||
</Tab>
|
||
<Tab color={theme.textMuted} _selected={{ color: colorMode === 'light' ? 'white' : theme.secondary, bg: theme.primary }} fontSize="sm" px={3}>
|
||
<HStack spacing={1}>
|
||
<Icon as={ArrowUpIcon} boxSize={4} />
|
||
<Text>大宗交易</Text>
|
||
</HStack>
|
||
</Tab>
|
||
<Tab color={theme.textMuted} _selected={{ color: colorMode === 'light' ? 'white' : theme.secondary, bg: theme.primary }} fontSize="sm" px={3}>
|
||
<HStack spacing={1}>
|
||
<Icon as={StarIcon} boxSize={4} />
|
||
<Text>龙虎榜</Text>
|
||
</HStack>
|
||
</Tab>
|
||
<Tab color={theme.textMuted} _selected={{ color: colorMode === 'light' ? 'white' : theme.secondary, bg: theme.primary }} fontSize="sm" px={3}>
|
||
<HStack spacing={1}>
|
||
<Icon as={LockIcon} boxSize={4} />
|
||
<Text>股权质押</Text>
|
||
</HStack>
|
||
</Tab>
|
||
<Tab isDisabled />
|
||
</TabList>
|
||
|
||
<HStack spacing={2} flexShrink={0} ml="auto">
|
||
<Text color={theme.textPrimary} whiteSpace="nowrap" fontSize="sm">时间范围:</Text>
|
||
<Select
|
||
size="sm"
|
||
value={selectedPeriod}
|
||
onChange={(e) => setSelectedPeriod(Number(e.target.value))}
|
||
bg={theme.bgDark}
|
||
borderColor={theme.border}
|
||
color={theme.textPrimary}
|
||
maxW="120px"
|
||
>
|
||
<option value={30} style={{ background: theme.bgDark }}>30天</option>
|
||
<option value={60} style={{ background: theme.bgDark }}>60天</option>
|
||
<option value={120} style={{ background: theme.bgDark }}>120天</option>
|
||
<option value={250} style={{ background: theme.bgDark }}>250天</option>
|
||
</Select>
|
||
<Button
|
||
leftIcon={<RepeatIcon />}
|
||
variant="outline"
|
||
colorScheme={colorMode === 'light' ? 'blue' : 'yellow'}
|
||
onClick={loadMarketData}
|
||
isLoading={loading}
|
||
size="sm"
|
||
>
|
||
刷新
|
||
</Button>
|
||
</HStack>
|
||
</HStack>
|
||
</Box>
|
||
|
||
<TabPanels>
|
||
{/* 交易数据 */}
|
||
<TabPanel px={0}>
|
||
<VStack spacing={6} align="stretch">
|
||
<ThemedCard theme={theme}>
|
||
<CardBody>
|
||
{tradeData.length > 0 && (
|
||
<Box h="600px">
|
||
<ReactECharts
|
||
option={getKLineOption()}
|
||
style={{ height: '100%', width: '100%' }}
|
||
theme={colorMode === 'light' ? 'light' : 'dark'}
|
||
onEvents={{
|
||
'click': (params) => {
|
||
if (params.seriesName === '涨幅分析' && params.data) {
|
||
const dataIndex = params.data[0]; // scatter数据格式是[x, y]
|
||
const analysis = analysisMap[dataIndex];
|
||
|
||
if (analysis) {
|
||
setModalContent(
|
||
<VStack align="stretch" spacing={4}>
|
||
<Box>
|
||
<Heading size="md" mb={2}>{analysis.stock_name} ({analysis.stock_code})</Heading>
|
||
<HStack spacing={4} mb={4}>
|
||
<Tag colorScheme="blue">日期: {analysis.trade_date}</Tag>
|
||
<Tag colorScheme="red">涨幅: {analysis.rise_rate}%</Tag>
|
||
<Tag colorScheme="green">收盘价: {analysis.close_price}</Tag>
|
||
</HStack>
|
||
</Box>
|
||
|
||
{analysis.main_business && (
|
||
<Box p={4} bg={colorMode === 'light' ? 'gray.50' : 'gray.900'} borderRadius="md">
|
||
<Heading size="sm" mb={2} color={theme.primary}>主营业务</Heading>
|
||
<Text color={theme.textPrimary}>{analysis.main_business}</Text>
|
||
</Box>
|
||
)}
|
||
|
||
|
||
{analysis.rise_reason_detail && (
|
||
<Box p={4} bg={colorMode === 'light' ? 'purple.50' : 'purple.900'} borderRadius="md">
|
||
<Heading size="sm" mb={2} color={theme.primary}>详细分析</Heading>
|
||
<MarkdownRenderer theme={theme} colorMode={colorMode}>
|
||
{analysis.rise_reason_detail}
|
||
</MarkdownRenderer>
|
||
</Box>
|
||
)}
|
||
|
||
{analysis.announcements && analysis.announcements !== '[]' && (
|
||
<Box p={4} bg={colorMode === 'light' ? 'orange.50' : 'orange.900'} borderRadius="md">
|
||
<Heading size="sm" mb={2} color={theme.primary}>相关公告</Heading>
|
||
<MarkdownRenderer theme={theme} colorMode={colorMode}>
|
||
{analysis.announcements}
|
||
</MarkdownRenderer>
|
||
</Box>
|
||
)}
|
||
|
||
{/* 研报引用展示 */}
|
||
{analysis.verification_reports && analysis.verification_reports.length > 0 && (
|
||
<Box p={4} bg={colorMode === 'light' ? 'blue.50' : 'blue.900'} borderRadius="md">
|
||
<Heading size="sm" mb={3} color={theme.primary}>
|
||
<HStack spacing={2}>
|
||
<Icon as={ExternalLinkIcon} />
|
||
<Text>研报引用 ({analysis.verification_reports.length})</Text>
|
||
</HStack>
|
||
</Heading>
|
||
<VStack spacing={3} align="stretch">
|
||
{analysis.verification_reports.map((report, reportIdx) => (
|
||
<Box
|
||
key={reportIdx}
|
||
p={3}
|
||
bg={colorMode === 'light' ? 'white' : 'gray.800'}
|
||
borderRadius="md"
|
||
border="1px solid"
|
||
borderColor={theme.border}
|
||
>
|
||
<HStack justify="space-between" mb={2}>
|
||
<HStack spacing={2}>
|
||
<Badge colorScheme="blue" fontSize="xs">
|
||
{report.publisher || '未知机构'}
|
||
</Badge>
|
||
{report.match_score && (
|
||
<Badge
|
||
colorScheme={report.match_score === '好' ? 'green' : report.match_score === '中' ? 'yellow' : 'gray'}
|
||
fontSize="xs"
|
||
>
|
||
匹配度: {report.match_score}
|
||
</Badge>
|
||
)}
|
||
{report.match_ratio != null && report.match_ratio > 0 && (
|
||
<Badge colorScheme="purple" fontSize="xs">
|
||
{(report.match_ratio * 100).toFixed(0)}%
|
||
</Badge>
|
||
)}
|
||
</HStack>
|
||
{report.declare_date && (
|
||
<Text fontSize="xs" color={theme.textMuted}>
|
||
{report.declare_date.substring(0, 10)}
|
||
</Text>
|
||
)}
|
||
</HStack>
|
||
|
||
{report.report_title && (
|
||
<Text fontWeight="bold" fontSize="sm" color={theme.textPrimary} mb={1}>
|
||
《{report.report_title}》
|
||
</Text>
|
||
)}
|
||
|
||
{report.author && (
|
||
<Text fontSize="xs" color={theme.textMuted} mb={2}>
|
||
分析师: {report.author}
|
||
</Text>
|
||
)}
|
||
|
||
{report.verification_item && (
|
||
<Box
|
||
p={2}
|
||
bg={colorMode === 'light' ? 'yellow.50' : 'yellow.900'}
|
||
borderRadius="sm"
|
||
mb={2}
|
||
>
|
||
<Text fontSize="xs" color={theme.textMuted}>
|
||
<strong>验证项:</strong> {report.verification_item}
|
||
</Text>
|
||
</Box>
|
||
)}
|
||
|
||
{report.content && (
|
||
<Text fontSize="sm" color={theme.textSecondary} noOfLines={4}>
|
||
{report.content}
|
||
</Text>
|
||
)}
|
||
</Box>
|
||
))}
|
||
</VStack>
|
||
</Box>
|
||
)}
|
||
|
||
<Box mt={4}>
|
||
<Text fontSize="sm" color={theme.textMuted}>
|
||
成交量: {formatUtils.formatNumber(analysis.volume)} |
|
||
成交额: {formatUtils.formatNumber(analysis.amount)} |
|
||
更新时间: {analysis.update_time || analysis.create_time || '-'}
|
||
</Text>
|
||
</Box>
|
||
</VStack>
|
||
);
|
||
onOpen();
|
||
}
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
</Box>
|
||
)}
|
||
</CardBody>
|
||
</ThemedCard>
|
||
|
||
{/* 当日分钟频数据 */}
|
||
<ThemedCard theme={theme}>
|
||
<CardHeader>
|
||
<HStack justify="space-between" align="center">
|
||
<HStack spacing={3}>
|
||
<Icon as={TimeIcon} color={theme.primary} boxSize={5} />
|
||
<Heading size="md" color={theme.textSecondary}>
|
||
当日分钟频数据
|
||
</Heading>
|
||
{minuteData && minuteData.trade_date && (
|
||
<Badge colorScheme={colorMode === 'light' ? 'blue' : 'yellow'} fontSize="xs">
|
||
{minuteData.trade_date}
|
||
</Badge>
|
||
)}
|
||
</HStack>
|
||
<Button
|
||
leftIcon={<RepeatIcon />}
|
||
size="sm"
|
||
variant="outline"
|
||
colorScheme={colorMode === 'light' ? 'blue' : 'yellow'}
|
||
onClick={loadMinuteData}
|
||
isLoading={minuteLoading}
|
||
loadingText="获取中"
|
||
>
|
||
获取分钟数据
|
||
</Button>
|
||
</HStack>
|
||
</CardHeader>
|
||
<CardBody>
|
||
{minuteLoading ? (
|
||
<Center h="400px">
|
||
<VStack spacing={4}>
|
||
<Spinner
|
||
thickness="4px"
|
||
speed="0.65s"
|
||
emptyColor={theme.bgDark}
|
||
color={theme.primary}
|
||
size="lg"
|
||
/>
|
||
<Text color={theme.textMuted} fontSize="sm">
|
||
加载分钟频数据中...
|
||
</Text>
|
||
</VStack>
|
||
</Center>
|
||
) : minuteData && minuteData.data && minuteData.data.length > 0 ? (
|
||
<VStack spacing={6} align="stretch">
|
||
{/* 分钟K线图 */}
|
||
<Box h="500px">
|
||
<ReactECharts
|
||
option={getMinuteKLineOption()}
|
||
style={{ height: '100%', width: '100%' }}
|
||
theme={colorMode === 'light' ? 'light' : 'dark'}
|
||
/>
|
||
</Box>
|
||
|
||
{/* 分钟数据统计 */}
|
||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
|
||
<Stat>
|
||
<StatLabel color={theme.textMuted} fontSize="sm">
|
||
<HStack spacing={1}>
|
||
<Icon as={ArrowUpIcon} boxSize={3} />
|
||
<Text>开盘价</Text>
|
||
</HStack>
|
||
</StatLabel>
|
||
<StatNumber color={theme.textPrimary} fontSize="lg">
|
||
{minuteData.data[0]?.open != null ? minuteData.data[0].open.toFixed(2) : '-'}
|
||
</StatNumber>
|
||
</Stat>
|
||
<Stat>
|
||
<StatLabel color={theme.textMuted} fontSize="sm">
|
||
<HStack spacing={1}>
|
||
<Icon as={ArrowDownIcon} boxSize={3} />
|
||
<Text>当前价</Text>
|
||
</HStack>
|
||
</StatLabel>
|
||
<StatNumber
|
||
color={minuteData.data[minuteData.data.length - 1]?.close >= minuteData.data[0]?.open ? theme.success : theme.danger}
|
||
fontSize="lg"
|
||
>
|
||
{minuteData.data[minuteData.data.length - 1]?.close != null ? minuteData.data[minuteData.data.length - 1].close.toFixed(2) : '-'}
|
||
</StatNumber>
|
||
<StatHelpText fontSize="xs">
|
||
<StatArrow
|
||
type={minuteData.data[minuteData.data.length - 1]?.close >= minuteData.data[0]?.open ? 'increase' : 'decrease'}
|
||
/>
|
||
{(minuteData.data[minuteData.data.length - 1]?.close != null && minuteData.data[0]?.open != null)
|
||
? Math.abs(((minuteData.data[minuteData.data.length - 1].close - minuteData.data[0].open) / minuteData.data[0].open * 100)).toFixed(2)
|
||
: '0.00'}%
|
||
</StatHelpText>
|
||
</Stat>
|
||
<Stat>
|
||
<StatLabel color={theme.textMuted} fontSize="sm">
|
||
<HStack spacing={1}>
|
||
<Icon as={ChevronUpIcon} boxSize={3} />
|
||
<Text>最高价</Text>
|
||
</HStack>
|
||
</StatLabel>
|
||
<StatNumber color={theme.success} fontSize="lg">
|
||
{(() => {
|
||
const highs = minuteData.data.map(item => item.high).filter(h => h != null);
|
||
return highs.length > 0 ? Math.max(...highs).toFixed(2) : '-';
|
||
})()}
|
||
</StatNumber>
|
||
</Stat>
|
||
<Stat>
|
||
<StatLabel color={theme.textMuted} fontSize="sm">
|
||
<HStack spacing={1}>
|
||
<Icon as={ChevronDownIcon} boxSize={3} />
|
||
<Text>最低价</Text>
|
||
</HStack>
|
||
</StatLabel>
|
||
<StatNumber color={theme.danger} fontSize="lg">
|
||
{(() => {
|
||
const lows = minuteData.data.map(item => item.low).filter(l => l != null);
|
||
return lows.length > 0 ? Math.min(...lows).toFixed(2) : '-';
|
||
})()}
|
||
</StatNumber>
|
||
</Stat>
|
||
</SimpleGrid>
|
||
|
||
{/* 成交量分析 */}
|
||
<Box
|
||
p={4}
|
||
bg={colorMode === 'light' ? theme.bgDark : 'rgba(255, 215, 0, 0.05)'}
|
||
borderRadius="lg"
|
||
border="1px solid"
|
||
borderColor={theme.border}
|
||
>
|
||
<HStack justify="space-between" mb={4}>
|
||
<Text fontWeight="bold" color={theme.textSecondary}>
|
||
成交数据分析
|
||
</Text>
|
||
<HStack spacing={2}>
|
||
<Badge colorScheme="purple" fontSize="xs">
|
||
总成交量: {formatUtils.formatNumber(minuteData.data.reduce((sum, item) => sum + item.volume, 0), 0)}
|
||
</Badge>
|
||
<Badge colorScheme="orange" fontSize="xs">
|
||
总成交额: {formatUtils.formatNumber(minuteData.data.reduce((sum, item) => sum + item.amount, 0))}
|
||
</Badge>
|
||
</HStack>
|
||
</HStack>
|
||
|
||
<Grid templateColumns="repeat(3, 1fr)" gap={4}>
|
||
<Box>
|
||
<Text fontSize="sm" color={theme.textMuted} mb={2}>
|
||
活跃时段
|
||
</Text>
|
||
<Text fontSize="sm" color={theme.textPrimary}>
|
||
{(() => {
|
||
const maxVolume = Math.max(...minuteData.data.map(item => item.volume));
|
||
const activeTime = minuteData.data.find(item => item.volume === maxVolume);
|
||
return activeTime ? `${activeTime.time} (${formatUtils.formatNumber(maxVolume, 0)})` : '-';
|
||
})()}
|
||
</Text>
|
||
</Box>
|
||
<Box>
|
||
<Text fontSize="sm" color={theme.textMuted} mb={2}>
|
||
平均价格
|
||
</Text>
|
||
<Text fontSize="sm" color={theme.textPrimary}>
|
||
{(() => {
|
||
const closes = minuteData.data.map(item => item.close).filter(c => c != null);
|
||
return closes.length > 0 ? (closes.reduce((sum, c) => sum + c, 0) / closes.length).toFixed(2) : '-';
|
||
})()}
|
||
</Text>
|
||
</Box>
|
||
<Box>
|
||
<Text fontSize="sm" color={theme.textMuted} mb={2}>
|
||
数据点数
|
||
</Text>
|
||
<Text fontSize="sm" color={theme.textPrimary}>
|
||
{minuteData.data.length} 个分钟
|
||
</Text>
|
||
</Box>
|
||
</Grid>
|
||
</Box>
|
||
</VStack>
|
||
) : (
|
||
<Center h="300px">
|
||
<VStack spacing={4}>
|
||
<Icon as={InfoIcon} color={theme.textMuted} boxSize={12} />
|
||
<VStack spacing={2}>
|
||
<Text color={theme.textMuted} fontSize="lg">
|
||
暂无分钟频数据
|
||
</Text>
|
||
<Text color={theme.textMuted} fontSize="sm" textAlign="center">
|
||
点击"获取分钟数据"按钮加载最新的交易日分钟频数据
|
||
</Text>
|
||
</VStack>
|
||
</VStack>
|
||
</Center>
|
||
)}
|
||
</CardBody>
|
||
</ThemedCard>
|
||
|
||
<ThemedCard theme={theme}>
|
||
<CardHeader>
|
||
<Heading size="md" color={theme.textSecondary}>
|
||
交易明细
|
||
</Heading>
|
||
</CardHeader>
|
||
<CardBody>
|
||
<TableContainer>
|
||
<Table variant="simple" size="sm">
|
||
<Thead>
|
||
<Tr>
|
||
<Th color={theme.textSecondary}>日期</Th>
|
||
<Th isNumeric color={theme.textSecondary}>开盘</Th>
|
||
<Th isNumeric color={theme.textSecondary}>最高</Th>
|
||
<Th isNumeric color={theme.textSecondary}>最低</Th>
|
||
<Th isNumeric color={theme.textSecondary}>收盘</Th>
|
||
<Th isNumeric color={theme.textSecondary}>涨跌幅</Th>
|
||
<Th isNumeric color={theme.textSecondary}>成交量</Th>
|
||
<Th isNumeric color={theme.textSecondary}>成交额</Th>
|
||
</Tr>
|
||
</Thead>
|
||
<Tbody>
|
||
{tradeData.slice(-10).reverse().map((item, idx) => (
|
||
<Tr key={idx} _hover={{ bg: colorMode === 'light' ? theme.bgDark : 'rgba(255, 215, 0, 0.1)' }}>
|
||
<Td color={theme.textPrimary}>{item.date}</Td>
|
||
<Td isNumeric color={theme.textPrimary}>{item.open}</Td>
|
||
<Td isNumeric color={theme.textPrimary}>{item.high}</Td>
|
||
<Td isNumeric color={theme.textPrimary}>{item.low}</Td>
|
||
<Td isNumeric color={theme.textPrimary} fontWeight="bold">{item.close}</Td>
|
||
<Td isNumeric color={item.change_percent >= 0 ? theme.success : theme.danger} fontWeight="bold">
|
||
{item.change_percent >= 0 ? '+' : ''}{formatUtils.formatPercent(item.change_percent)}
|
||
</Td>
|
||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.volume, 0)}</Td>
|
||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.amount)}</Td>
|
||
</Tr>
|
||
))}
|
||
</Tbody>
|
||
</Table>
|
||
</TableContainer>
|
||
</CardBody>
|
||
</ThemedCard>
|
||
</VStack>
|
||
</TabPanel>
|
||
|
||
{/* 融资融券 */}
|
||
<TabPanel px={0}>
|
||
<VStack spacing={6} align="stretch">
|
||
<ThemedCard theme={theme}>
|
||
<CardBody>
|
||
{fundingData.length > 0 && (
|
||
<Box h="400px">
|
||
<ReactECharts
|
||
option={getFundingOption()}
|
||
style={{ height: '100%', width: '100%' }}
|
||
theme={colorMode === 'light' ? 'light' : 'dark'}
|
||
/>
|
||
</Box>
|
||
)}
|
||
</CardBody>
|
||
</ThemedCard>
|
||
|
||
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
|
||
<ThemedCard theme={theme}>
|
||
<CardHeader>
|
||
<Heading size="md" color={theme.success}>
|
||
融资数据
|
||
</Heading>
|
||
</CardHeader>
|
||
<CardBody>
|
||
<VStack spacing={3} align="stretch">
|
||
{fundingData.slice(-5).reverse().map((item, idx) => (
|
||
<Box key={idx} p={3} bg={colorMode === 'light' ? 'rgba(255, 68, 68, 0.05)' : 'rgba(255, 68, 68, 0.1)'} borderRadius="md">
|
||
<HStack justify="space-between">
|
||
<Text color={theme.textMuted}>{item.date}</Text>
|
||
<VStack align="end" spacing={0}>
|
||
<Text color={theme.textPrimary} fontWeight="bold">
|
||
{formatUtils.formatNumber(item.financing.balance)}
|
||
</Text>
|
||
<Text fontSize="xs" color={theme.textMuted}>
|
||
买入{formatUtils.formatNumber(item.financing.buy)} / 偿还{formatUtils.formatNumber(item.financing.repay)}
|
||
</Text>
|
||
</VStack>
|
||
</HStack>
|
||
</Box>
|
||
))}
|
||
</VStack>
|
||
</CardBody>
|
||
</ThemedCard>
|
||
|
||
<ThemedCard theme={theme}>
|
||
<CardHeader>
|
||
<Heading size="md" color={theme.danger}>
|
||
融券数据
|
||
</Heading>
|
||
</CardHeader>
|
||
<CardBody>
|
||
<VStack spacing={3} align="stretch">
|
||
{fundingData.slice(-5).reverse().map((item, idx) => (
|
||
<Box key={idx} p={3} bg={colorMode === 'light' ? 'rgba(0, 200, 81, 0.05)' : 'rgba(0, 200, 81, 0.1)'} borderRadius="md">
|
||
<HStack justify="space-between">
|
||
<Text color={theme.textMuted}>{item.date}</Text>
|
||
<VStack align="end" spacing={0}>
|
||
<Text color={theme.textPrimary} fontWeight="bold">
|
||
{formatUtils.formatNumber(item.securities.balance)}
|
||
</Text>
|
||
<Text fontSize="xs" color={theme.textMuted}>
|
||
卖出{formatUtils.formatNumber(item.securities.sell)} / 偿还{formatUtils.formatNumber(item.securities.repay)}
|
||
</Text>
|
||
</VStack>
|
||
</HStack>
|
||
</Box>
|
||
))}
|
||
</VStack>
|
||
</CardBody>
|
||
</ThemedCard>
|
||
</Grid>
|
||
</VStack>
|
||
</TabPanel>
|
||
|
||
{/* 大宗交易 */}
|
||
<TabPanel px={0}>
|
||
<ThemedCard theme={theme}>
|
||
<CardHeader>
|
||
<Heading size="md" color={theme.textSecondary}>
|
||
大宗交易记录
|
||
</Heading>
|
||
</CardHeader>
|
||
<CardBody>
|
||
{bigDealData && bigDealData.daily_stats && bigDealData.daily_stats.length > 0 ? (
|
||
<VStack spacing={6} align="stretch">
|
||
{bigDealData.daily_stats.map((dayStats, idx) => (
|
||
<Box
|
||
key={idx}
|
||
p={4}
|
||
bg={colorMode === 'light' ? theme.bgDark : 'rgba(255, 215, 0, 0.05)'}
|
||
borderRadius="lg"
|
||
border="1px solid"
|
||
borderColor={theme.border}
|
||
>
|
||
<HStack justify="space-between" mb={4}>
|
||
<Text fontSize="lg" fontWeight="bold" color={theme.textSecondary}>
|
||
{dayStats.date}
|
||
</Text>
|
||
<HStack spacing={4}>
|
||
<Badge colorScheme={colorMode === 'light' ? 'blue' : 'yellow'} fontSize="md">
|
||
交易笔数: {dayStats.count}
|
||
</Badge>
|
||
<Badge colorScheme="green" fontSize="md">
|
||
成交量: {formatUtils.formatNumber(dayStats.total_volume)}万股
|
||
</Badge>
|
||
<Badge colorScheme="orange" fontSize="md">
|
||
成交额: {formatUtils.formatNumber(dayStats.total_amount)}万元
|
||
</Badge>
|
||
<Badge colorScheme="purple" fontSize="md">
|
||
均价: {dayStats.avg_price != null ? dayStats.avg_price.toFixed(2) : '-'}元
|
||
</Badge>
|
||
</HStack>
|
||
</HStack>
|
||
|
||
{/* 显示当日交易明细 */}
|
||
{dayStats.deals && dayStats.deals.length > 0 && (
|
||
<TableContainer>
|
||
<Table variant="simple" size="sm">
|
||
<Thead>
|
||
<Tr>
|
||
<Th color={theme.textSecondary}>买方营业部</Th>
|
||
<Th color={theme.textSecondary}>卖方营业部</Th>
|
||
<Th isNumeric color={theme.textSecondary}>成交价</Th>
|
||
<Th isNumeric color={theme.textSecondary}>成交量(万股)</Th>
|
||
<Th isNumeric color={theme.textSecondary}>成交额(万元)</Th>
|
||
</Tr>
|
||
</Thead>
|
||
<Tbody>
|
||
{dayStats.deals.map((deal, i) => (
|
||
<Tr key={i} _hover={{ bg: colorMode === 'light' ? 'rgba(43, 108, 176, 0.05)' : 'rgba(255, 215, 0, 0.1)' }}>
|
||
<Td color={theme.textPrimary} fontSize="xs" maxW="200px" isTruncated>
|
||
<Tooltip label={deal.buyer_dept || '-'} placement="top">
|
||
<Text>{deal.buyer_dept || '-'}</Text>
|
||
</Tooltip>
|
||
</Td>
|
||
<Td color={theme.textPrimary} fontSize="xs" maxW="200px" isTruncated>
|
||
<Tooltip label={deal.seller_dept || '-'} placement="top">
|
||
<Text>{deal.seller_dept || '-'}</Text>
|
||
</Tooltip>
|
||
</Td>
|
||
<Td isNumeric color={theme.textPrimary} fontWeight="bold">
|
||
{deal.price != null ? deal.price.toFixed(2) : '-'}
|
||
</Td>
|
||
<Td isNumeric color={theme.textPrimary}>
|
||
{deal.volume != null ? deal.volume.toFixed(2) : '-'}
|
||
</Td>
|
||
<Td isNumeric color={theme.textSecondary} fontWeight="bold">
|
||
{deal.amount != null ? deal.amount.toFixed(2) : '-'}
|
||
</Td>
|
||
</Tr>
|
||
))}
|
||
</Tbody>
|
||
</Table>
|
||
</TableContainer>
|
||
)}
|
||
</Box>
|
||
))}
|
||
</VStack>
|
||
) : (
|
||
<Center h="200px">
|
||
<Text color={theme.textMuted}>暂无大宗交易数据</Text>
|
||
</Center>
|
||
)}
|
||
</CardBody>
|
||
</ThemedCard>
|
||
</TabPanel>
|
||
|
||
{/* 龙虎榜 */}
|
||
<TabPanel px={0}>
|
||
<ThemedCard theme={theme}>
|
||
<CardHeader>
|
||
<Heading size="md" color={theme.textSecondary}>
|
||
龙虎榜数据
|
||
</Heading>
|
||
</CardHeader>
|
||
<CardBody>
|
||
{unusualData && unusualData.grouped_data && unusualData.grouped_data.length > 0 ? (
|
||
<VStack spacing={6} align="stretch">
|
||
{unusualData.grouped_data.map((dayData, idx) => (
|
||
<Box
|
||
key={idx}
|
||
p={4}
|
||
bg={colorMode === 'light' ? theme.bgDark : 'rgba(255, 215, 0, 0.05)'}
|
||
borderRadius="lg"
|
||
border="1px solid"
|
||
borderColor={theme.border}
|
||
>
|
||
<HStack justify="space-between" mb={4}>
|
||
<Text fontSize="lg" fontWeight="bold" color={theme.textSecondary}>
|
||
{dayData.date}
|
||
</Text>
|
||
<HStack spacing={4}>
|
||
<Badge colorScheme="red" fontSize="md">
|
||
买入: {formatUtils.formatNumber(dayData.total_buy)}
|
||
</Badge>
|
||
<Badge colorScheme="green" fontSize="md">
|
||
卖出: {formatUtils.formatNumber(dayData.total_sell)}
|
||
</Badge>
|
||
<Badge colorScheme={dayData.net_amount > 0 ? 'red' : 'green'} fontSize="md">
|
||
净额: {formatUtils.formatNumber(dayData.net_amount)}
|
||
</Badge>
|
||
</HStack>
|
||
</HStack>
|
||
|
||
<Grid templateColumns="repeat(2, 1fr)" gap={4}>
|
||
<Box>
|
||
<Text fontWeight="bold" color={theme.success} mb={2}>
|
||
买入前五
|
||
</Text>
|
||
<VStack spacing={1} align="stretch">
|
||
{dayData.buyers && dayData.buyers.length > 0 ? (
|
||
dayData.buyers.slice(0, 5).map((buyer, i) => (
|
||
<HStack
|
||
key={i}
|
||
justify="space-between"
|
||
p={2}
|
||
bg={colorMode === 'light' ? 'rgba(255, 68, 68, 0.05)' : 'rgba(255, 68, 68, 0.1)'}
|
||
borderRadius="md"
|
||
>
|
||
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
|
||
{buyer.dept_name}
|
||
</Text>
|
||
<Text fontSize="sm" color={theme.success} fontWeight="bold">
|
||
{formatUtils.formatNumber(buyer.buy_amount)}
|
||
</Text>
|
||
</HStack>
|
||
))
|
||
) : (
|
||
<Text fontSize="sm" color={theme.textMuted}>暂无数据</Text>
|
||
)}
|
||
</VStack>
|
||
</Box>
|
||
|
||
<Box>
|
||
<Text fontWeight="bold" color={theme.danger} mb={2}>
|
||
卖出前五
|
||
</Text>
|
||
<VStack spacing={1} align="stretch">
|
||
{dayData.sellers && dayData.sellers.length > 0 ? (
|
||
dayData.sellers.slice(0, 5).map((seller, i) => (
|
||
<HStack
|
||
key={i}
|
||
justify="space-between"
|
||
p={2}
|
||
bg={colorMode === 'light' ? 'rgba(0, 200, 81, 0.05)' : 'rgba(0, 200, 81, 0.1)'}
|
||
borderRadius="md"
|
||
>
|
||
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
|
||
{seller.dept_name}
|
||
</Text>
|
||
<Text fontSize="sm" color={theme.danger} fontWeight="bold">
|
||
{formatUtils.formatNumber(seller.sell_amount)}
|
||
</Text>
|
||
</HStack>
|
||
))
|
||
) : (
|
||
<Text fontSize="sm" color={theme.textMuted}>暂无数据</Text>
|
||
)}
|
||
</VStack>
|
||
</Box>
|
||
</Grid>
|
||
|
||
{/* 信息类型标签 */}
|
||
<HStack mt={3} spacing={2}>
|
||
<Text fontSize="sm" color={theme.textMuted}>类型:</Text>
|
||
{dayData.info_types && dayData.info_types.map((type, i) => (
|
||
<Badge key={i} colorScheme={colorMode === 'light' ? 'blue' : 'yellow'} fontSize="xs">
|
||
{type}
|
||
</Badge>
|
||
))}
|
||
</HStack>
|
||
</Box>
|
||
))}
|
||
</VStack>
|
||
) : (
|
||
<Center h="200px">
|
||
<Text color={theme.textMuted}>暂无龙虎榜数据</Text>
|
||
</Center>
|
||
)}
|
||
</CardBody>
|
||
</ThemedCard>
|
||
</TabPanel>
|
||
|
||
{/* 股权质押 */}
|
||
<TabPanel px={0}>
|
||
<VStack spacing={6} align="stretch">
|
||
<ThemedCard theme={theme}>
|
||
<CardBody>
|
||
{pledgeData.length > 0 && (
|
||
<Box h="400px">
|
||
<ReactECharts
|
||
option={getPledgeOption()}
|
||
style={{ height: '100%', width: '100%' }}
|
||
theme={colorMode === 'light' ? 'light' : 'dark'}
|
||
/>
|
||
</Box>
|
||
)}
|
||
</CardBody>
|
||
</ThemedCard>
|
||
|
||
<ThemedCard theme={theme}>
|
||
<CardHeader>
|
||
<Heading size="md" color={theme.textSecondary}>
|
||
质押明细
|
||
</Heading>
|
||
</CardHeader>
|
||
<CardBody>
|
||
<TableContainer>
|
||
<Table variant="simple" size="sm">
|
||
<Thead>
|
||
<Tr>
|
||
<Th color={theme.textSecondary}>日期</Th>
|
||
<Th isNumeric color={theme.textSecondary}>无限售质押(万股)</Th>
|
||
<Th isNumeric color={theme.textSecondary}>限售质押(万股)</Th>
|
||
<Th isNumeric color={theme.textSecondary}>质押总量(万股)</Th>
|
||
<Th isNumeric color={theme.textSecondary}>总股本(万股)</Th>
|
||
<Th isNumeric color={theme.textSecondary}>质押比例</Th>
|
||
<Th isNumeric color={theme.textSecondary}>质押笔数</Th>
|
||
</Tr>
|
||
</Thead>
|
||
<Tbody>
|
||
{Array.isArray(pledgeData) && pledgeData.length > 0 ? (
|
||
pledgeData.map((item, idx) => (
|
||
<Tr key={idx} _hover={{ bg: colorMode === 'light' ? theme.bgDark : 'rgba(255, 215, 0, 0.1)' }}>
|
||
<Td color={theme.textPrimary}>{item.end_date}</Td>
|
||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.unrestricted_pledge, 0)}</Td>
|
||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.restricted_pledge, 0)}</Td>
|
||
<Td isNumeric color={theme.textPrimary} fontWeight="bold">{formatUtils.formatNumber(item.total_pledge, 0)}</Td>
|
||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.total_shares, 0)}</Td>
|
||
<Td isNumeric color={theme.warning} fontWeight="bold">
|
||
{formatUtils.formatPercent(item.pledge_ratio)}
|
||
</Td>
|
||
<Td isNumeric color={theme.textPrimary}>{item.pledge_count}</Td>
|
||
</Tr>
|
||
))
|
||
) : (
|
||
<Tr>
|
||
<Td colSpan={7} textAlign="center" py={8}>
|
||
<Text fontSize="sm" color={theme.textMuted}>暂无数据</Text>
|
||
</Td>
|
||
</Tr>
|
||
)}
|
||
</Tbody>
|
||
</Table>
|
||
</TableContainer>
|
||
</CardBody>
|
||
</ThemedCard>
|
||
</VStack>
|
||
</TabPanel>
|
||
</TabPanels>
|
||
</Tabs>
|
||
)}
|
||
</VStack>
|
||
</Container>
|
||
|
||
{/* 模态框 */}
|
||
<Modal isOpen={isOpen} onClose={onClose} size="6xl">
|
||
<ModalOverlay bg="rgba(0,0,0,0.8)" />
|
||
<ModalContent bg={theme.bgCard} borderColor={theme.primary} borderWidth="1px">
|
||
<ModalHeader color={theme.textSecondary}>详细信息</ModalHeader>
|
||
<ModalCloseButton color={theme.textSecondary} />
|
||
<ModalBody pb={6}>
|
||
{modalContent}
|
||
</ModalBody>
|
||
</ModalContent>
|
||
</Modal>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export default MarketDataView; |