Files
vf_react/src/views/Company/MarketDataView.js
2025-12-02 18:55:59 +08:00

2082 lines
85 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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