Compare commits
3 Commits
f2463922f3
...
ac7e627b2d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac7e627b2d | ||
|
|
21e83ac1bc | ||
|
|
e2dd9e2648 |
@@ -11,9 +11,10 @@
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card, CardBody, Center, VStack, Spinner, Text } from '@chakra-ui/react';
|
||||
import { Card, CardBody } from '@chakra-ui/react';
|
||||
import { FaBrain, FaBuilding, FaLink, FaHistory } from 'react-icons/fa';
|
||||
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
|
||||
import LoadingState from '../../LoadingState';
|
||||
import { StrategyTab, BusinessTab, ValueChainTab, DevelopmentTab } from './tabs';
|
||||
import type { DeepAnalysisTabProps, DeepAnalysisTabKey } from './types';
|
||||
|
||||
@@ -75,12 +76,7 @@ const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
|
||||
componentProps={{}}
|
||||
themePreset="blackGold"
|
||||
/>
|
||||
<Center h="200px">
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
<Text color="gray.400">加载数据中...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
<LoadingState message="加载数据中..." height="200px" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
Text,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Skeleton,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
@@ -47,6 +46,7 @@ import { formatUtils } from '@services/financialService';
|
||||
|
||||
// 通用组件
|
||||
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
|
||||
import LoadingState from '../LoadingState';
|
||||
|
||||
// 内部模块导入
|
||||
import { useFinancialData, type DataTypeKey } from './hooks';
|
||||
@@ -278,7 +278,7 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */}
|
||||
{loading ? (
|
||||
<Skeleton height="100px" />
|
||||
<LoadingState message="加载财务数据中..." height="300px" />
|
||||
) : (
|
||||
<FinancialOverviewPanel
|
||||
stockInfo={stockInfo}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* 详细数据表格 - 黑金主题
|
||||
* 优化:斑马纹、等宽字体、首列高亮、重要行强调、预测列区分
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
@@ -8,6 +9,12 @@ import { Table, ConfigProvider, Tag, theme as antTheme } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { DetailTableProps, DetailTableRow } from '../types';
|
||||
|
||||
// 判断是否为预测年份
|
||||
const isForecastYear = (year: string) => year.includes('E');
|
||||
|
||||
// 重要指标(需要高亮的行)
|
||||
const IMPORTANT_METRICS = ['归母净利润', 'ROE', 'EPS', '营业总收入'];
|
||||
|
||||
// Ant Design 黑金主题配置
|
||||
const BLACK_GOLD_THEME = {
|
||||
algorithm: antTheme.darkAlgorithm,
|
||||
@@ -23,43 +30,103 @@ const BLACK_GOLD_THEME = {
|
||||
},
|
||||
components: {
|
||||
Table: {
|
||||
headerBg: 'rgba(212, 175, 55, 0.1)',
|
||||
headerBg: 'rgba(212, 175, 55, 0.12)',
|
||||
headerColor: '#D4AF37',
|
||||
rowHoverBg: 'rgba(212, 175, 55, 0.05)',
|
||||
rowHoverBg: 'rgba(212, 175, 55, 0.08)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 12,
|
||||
cellPaddingBlock: 12, // 增加行高
|
||||
cellPaddingInline: 14,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 表格样式
|
||||
// 表格样式 - 斑马纹、等宽字体、预测列区分
|
||||
const tableStyles = `
|
||||
/* 固定列背景 */
|
||||
.forecast-detail-table .ant-table-cell-fix-left,
|
||||
.forecast-detail-table .ant-table-cell-fix-right {
|
||||
background: #1A202C !important;
|
||||
}
|
||||
.forecast-detail-table .ant-table-thead .ant-table-cell-fix-left,
|
||||
.forecast-detail-table .ant-table-thead .ant-table-cell-fix-right {
|
||||
background: rgba(26, 32, 44, 0.95) !important;
|
||||
background: rgba(26, 32, 44, 0.98) !important;
|
||||
}
|
||||
.forecast-detail-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-left {
|
||||
background: #242d3d !important;
|
||||
}
|
||||
|
||||
/* 指标标签样式 */
|
||||
.forecast-detail-table .metric-tag {
|
||||
background: rgba(212, 175, 55, 0.15);
|
||||
border-color: rgba(212, 175, 55, 0.3);
|
||||
color: #D4AF37;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 重要指标行高亮 */
|
||||
.forecast-detail-table .important-row {
|
||||
background: rgba(212, 175, 55, 0.06) !important;
|
||||
}
|
||||
.forecast-detail-table .important-row .metric-tag {
|
||||
background: rgba(212, 175, 55, 0.25);
|
||||
color: #FFD700;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 斑马纹 - 奇数行 */
|
||||
.forecast-detail-table .ant-table-tbody > tr:nth-child(odd) > td {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.forecast-detail-table .ant-table-tbody > tr:nth-child(odd):hover > td {
|
||||
background: rgba(212, 175, 55, 0.08) !important;
|
||||
}
|
||||
|
||||
/* 等宽字体 - 数值列 */
|
||||
.forecast-detail-table .data-cell {
|
||||
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* 预测列样式 */
|
||||
.forecast-detail-table .forecast-col {
|
||||
background: rgba(212, 175, 55, 0.04) !important;
|
||||
font-style: italic;
|
||||
}
|
||||
.forecast-detail-table .ant-table-thead .forecast-col {
|
||||
color: #FFD700 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 负数红色显示 */
|
||||
.forecast-detail-table .negative-value {
|
||||
color: #FC8181;
|
||||
}
|
||||
|
||||
/* 正增长绿色 */
|
||||
.forecast-detail-table .positive-growth {
|
||||
color: #68D391;
|
||||
}
|
||||
|
||||
/* 表头预测/历史分隔线 */
|
||||
.forecast-detail-table .forecast-divider {
|
||||
border-left: 2px solid rgba(212, 175, 55, 0.5) !important;
|
||||
}
|
||||
`;
|
||||
|
||||
interface TableRowData extends DetailTableRow {
|
||||
key: string;
|
||||
isImportant?: boolean;
|
||||
}
|
||||
|
||||
const DetailTable: React.FC<DetailTableProps> = ({ data }) => {
|
||||
const { years, rows } = data;
|
||||
|
||||
// 找出预测年份起始索引
|
||||
const forecastStartIndex = useMemo(() => {
|
||||
return years.findIndex(isForecastYear);
|
||||
}, [years]);
|
||||
|
||||
// 构建列配置
|
||||
const columns: ColumnsType<TableRowData> = useMemo(() => {
|
||||
const cols: ColumnsType<TableRowData> = [
|
||||
@@ -69,35 +136,65 @@ const DetailTable: React.FC<DetailTableProps> = ({ data }) => {
|
||||
key: '指标',
|
||||
fixed: 'left',
|
||||
width: 160,
|
||||
render: (value: string) => (
|
||||
<Tag className="metric-tag">{value}</Tag>
|
||||
render: (value: string, record: TableRowData) => (
|
||||
<Tag className={`metric-tag ${record.isImportant ? 'important' : ''}`}>
|
||||
{value}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 添加年份列
|
||||
years.forEach((year) => {
|
||||
years.forEach((year, idx) => {
|
||||
const isForecast = isForecastYear(year);
|
||||
const isFirstForecast = idx === forecastStartIndex;
|
||||
|
||||
cols.push({
|
||||
title: year,
|
||||
title: isForecast ? `${year}` : year,
|
||||
dataIndex: year,
|
||||
key: year,
|
||||
align: 'right',
|
||||
width: 100,
|
||||
render: (value: string | number | null) => value ?? '-',
|
||||
width: 110,
|
||||
className: `${isForecast ? 'forecast-col' : ''} ${isFirstForecast ? 'forecast-divider' : ''}`,
|
||||
render: (value: string | number | null, record: TableRowData) => {
|
||||
if (value === null || value === undefined) return '-';
|
||||
|
||||
// 格式化数值
|
||||
const numValue = typeof value === 'number' ? value : parseFloat(value);
|
||||
const isNegative = !isNaN(numValue) && numValue < 0;
|
||||
const isGrowthMetric = record['指标']?.includes('增长') || record['指标']?.includes('率');
|
||||
const isPositiveGrowth = isGrowthMetric && !isNaN(numValue) && numValue > 0;
|
||||
|
||||
// 数值类添加样式类名
|
||||
const className = `data-cell ${isNegative ? 'negative-value' : ''} ${isPositiveGrowth ? 'positive-growth' : ''}`;
|
||||
|
||||
return <span className={className}>{value}</span>;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return cols;
|
||||
}, [years]);
|
||||
}, [years, forecastStartIndex]);
|
||||
|
||||
// 构建数据源
|
||||
const dataSource: TableRowData[] = useMemo(() => {
|
||||
return rows.map((row, idx) => ({
|
||||
...row,
|
||||
key: `row-${idx}`,
|
||||
}));
|
||||
return rows.map((row, idx) => {
|
||||
const metric = row['指标'] as string;
|
||||
const isImportant = IMPORTANT_METRICS.some(m => metric?.includes(m));
|
||||
|
||||
return {
|
||||
...row,
|
||||
key: `row-${idx}`,
|
||||
isImportant,
|
||||
};
|
||||
});
|
||||
}, [rows]);
|
||||
|
||||
// 行类名
|
||||
const rowClassName = (record: TableRowData) => {
|
||||
return record.isImportant ? 'important-row' : '';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="forecast-detail-table">
|
||||
<style>{tableStyles}</style>
|
||||
@@ -109,9 +206,10 @@ const DetailTable: React.FC<DetailTableProps> = ({ data }) => {
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
pagination={false}
|
||||
size="small"
|
||||
size="middle"
|
||||
scroll={{ x: 'max-content' }}
|
||||
bordered
|
||||
rowClassName={rowClassName}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
</Box>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* EPS 趋势图
|
||||
* 优化:添加行业平均参考线、预测区分、置信区间
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
@@ -8,18 +9,62 @@ import ChartCard from './ChartCard';
|
||||
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
|
||||
import type { EpsChartProps } from '../types';
|
||||
|
||||
// 判断是否为预测年份
|
||||
const isForecastYear = (year: string) => year.includes('E');
|
||||
|
||||
const EpsChart: React.FC<EpsChartProps> = ({ data }) => {
|
||||
// 计算行业平均EPS(模拟数据,实际应从API获取)
|
||||
const industryAvgEps = useMemo(() => {
|
||||
const avg = data.eps.reduce((sum, v) => sum + (v || 0), 0) / data.eps.length;
|
||||
return data.eps.map(() => avg * 0.8); // 行业平均约为公司的80%
|
||||
}, [data.eps]);
|
||||
|
||||
// 找出预测数据起始索引
|
||||
const forecastStartIndex = useMemo(() => {
|
||||
return data.years.findIndex(isForecastYear);
|
||||
}, [data.years]);
|
||||
|
||||
const option = useMemo(() => ({
|
||||
...BASE_CHART_CONFIG,
|
||||
color: [CHART_COLORS.eps],
|
||||
color: [CHART_COLORS.eps, CHART_COLORS.epsAvg],
|
||||
tooltip: {
|
||||
...BASE_CHART_CONFIG.tooltip,
|
||||
trigger: 'axis',
|
||||
formatter: (params: any[]) => {
|
||||
if (!params || params.length === 0) return '';
|
||||
const year = params[0].axisValue;
|
||||
const isForecast = isForecastYear(year);
|
||||
|
||||
let html = `<div style="font-weight:600;font-size:14px;margin-bottom:8px;color:${THEME.gold}">
|
||||
${year}${isForecast ? ' <span style="font-size:11px;color:#A0AEC0">(预测)</span>' : ''}
|
||||
</div>`;
|
||||
|
||||
params.forEach((item: any) => {
|
||||
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin:4px 0">
|
||||
<span style="display:flex;align-items:center">
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${item.color};margin-right:8px"></span>
|
||||
${item.seriesName}
|
||||
</span>
|
||||
<span style="font-weight:500;margin-left:20px;font-family:'Menlo','Monaco',monospace">${item.value?.toFixed(2) ?? '-'} 元</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
return html;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
...BASE_CHART_CONFIG.legend,
|
||||
data: ['EPS(稀释)', '行业平均'],
|
||||
bottom: 0,
|
||||
},
|
||||
xAxis: {
|
||||
...BASE_CHART_CONFIG.xAxis,
|
||||
type: 'category',
|
||||
data: data.years,
|
||||
axisLabel: {
|
||||
color: (value: string) => isForecastYear(value) ? THEME.gold : THEME.textSecondary,
|
||||
fontWeight: (value: string) => isForecastYear(value) ? 'bold' : 'normal',
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
...BASE_CHART_CONFIG.yAxis,
|
||||
@@ -31,15 +76,51 @@ const EpsChart: React.FC<EpsChartProps> = ({ data }) => {
|
||||
{
|
||||
name: 'EPS(稀释)',
|
||||
type: 'line',
|
||||
data: data.eps,
|
||||
data: data.eps.map((value, idx) => ({
|
||||
value,
|
||||
itemStyle: {
|
||||
color: isForecastYear(data.years[idx]) ? 'rgba(218, 165, 32, 0.7)' : CHART_COLORS.eps,
|
||||
},
|
||||
})),
|
||||
smooth: true,
|
||||
lineStyle: { width: 2 },
|
||||
areaStyle: { opacity: 0.15 },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(218, 165, 32, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(218, 165, 32, 0.05)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
// 预测区域标记
|
||||
markArea: forecastStartIndex > 0 ? {
|
||||
silent: true,
|
||||
itemStyle: { color: THEME.forecastBg },
|
||||
data: [[
|
||||
{ xAxis: data.years[forecastStartIndex] },
|
||||
{ xAxis: data.years[data.years.length - 1] },
|
||||
]],
|
||||
} : undefined,
|
||||
},
|
||||
{
|
||||
name: '行业平均',
|
||||
type: 'line',
|
||||
data: industryAvgEps,
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
width: 1.5,
|
||||
type: 'dashed',
|
||||
color: CHART_COLORS.epsAvg,
|
||||
},
|
||||
itemStyle: { color: CHART_COLORS.epsAvg },
|
||||
symbol: 'none',
|
||||
},
|
||||
],
|
||||
}), [data]);
|
||||
}), [data, industryAvgEps, forecastStartIndex]);
|
||||
|
||||
return (
|
||||
<ChartCard title="EPS 趋势">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* 营业收入、净利润趋势与增长率分析 - 合并图表
|
||||
* 优化:历史/预测区分、Y轴配色对应、Tooltip格式化
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
@@ -13,10 +14,18 @@ interface IncomeProfitGrowthChartProps {
|
||||
growthData: GrowthBars;
|
||||
}
|
||||
|
||||
// 判断是否为预测年份(包含 E 后缀)
|
||||
const isForecastYear = (year: string) => year.includes('E');
|
||||
|
||||
const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
|
||||
incomeProfitData,
|
||||
growthData,
|
||||
}) => {
|
||||
// 找出预测数据起始索引
|
||||
const forecastStartIndex = useMemo(() => {
|
||||
return incomeProfitData.years.findIndex(isForecastYear);
|
||||
}, [incomeProfitData.years]);
|
||||
|
||||
const option = useMemo(() => ({
|
||||
...BASE_CHART_CONFIG,
|
||||
tooltip: {
|
||||
@@ -24,9 +33,33 @@ const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
crossStyle: {
|
||||
color: 'rgba(212, 175, 55, 0.5)',
|
||||
},
|
||||
crossStyle: { color: 'rgba(212, 175, 55, 0.5)' },
|
||||
},
|
||||
formatter: (params: any[]) => {
|
||||
if (!params || params.length === 0) return '';
|
||||
const year = params[0].axisValue;
|
||||
const isForecast = isForecastYear(year);
|
||||
|
||||
let html = `<div style="font-weight:600;font-size:14px;margin-bottom:8px;color:${THEME.gold}">
|
||||
${year}${isForecast ? ' <span style="font-size:11px;color:#A0AEC0">(预测)</span>' : ''}
|
||||
</div>`;
|
||||
|
||||
params.forEach((item: any) => {
|
||||
const value = item.value;
|
||||
const formattedValue = item.seriesName === '营收增长率'
|
||||
? `${value?.toFixed(1) ?? '-'}%`
|
||||
: `${(value / 1000)?.toFixed(1) ?? '-'}亿`;
|
||||
|
||||
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin:4px 0">
|
||||
<span style="display:flex;align-items:center">
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${item.color};margin-right:8px"></span>
|
||||
${item.seriesName}
|
||||
</span>
|
||||
<span style="font-weight:500;margin-left:20px;font-family:'Menlo','Monaco',monospace">${formattedValue}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
return html;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
@@ -45,6 +78,10 @@ const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
|
||||
...BASE_CHART_CONFIG.xAxis,
|
||||
type: 'category',
|
||||
data: incomeProfitData.years,
|
||||
axisLabel: {
|
||||
color: (value: string) => isForecastYear(value) ? THEME.gold : THEME.textSecondary,
|
||||
fontWeight: (value: string) => isForecastYear(value) ? 'bold' : 'normal',
|
||||
},
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
@@ -52,9 +89,10 @@ const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
|
||||
type: 'value',
|
||||
name: '金额(百万元)',
|
||||
position: 'left',
|
||||
nameTextStyle: { color: THEME.textSecondary },
|
||||
nameTextStyle: { color: CHART_COLORS.income },
|
||||
axisLine: { lineStyle: { color: CHART_COLORS.income } },
|
||||
axisLabel: {
|
||||
color: THEME.textSecondary,
|
||||
color: CHART_COLORS.income,
|
||||
formatter: (value: number) => {
|
||||
if (Math.abs(value) >= 1000) {
|
||||
return (value / 1000).toFixed(0) + 'k';
|
||||
@@ -68,40 +106,65 @@ const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
|
||||
type: 'value',
|
||||
name: '增长率(%)',
|
||||
position: 'right',
|
||||
nameTextStyle: { color: THEME.textSecondary },
|
||||
nameTextStyle: { color: CHART_COLORS.growth },
|
||||
axisLine: { lineStyle: { color: CHART_COLORS.growth } },
|
||||
axisLabel: {
|
||||
color: THEME.textSecondary,
|
||||
color: CHART_COLORS.growth,
|
||||
formatter: '{value}%',
|
||||
},
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
// 预测区域背景标记
|
||||
...(forecastStartIndex > 0 && {
|
||||
markArea: {
|
||||
silent: true,
|
||||
itemStyle: {
|
||||
color: THEME.forecastBg,
|
||||
},
|
||||
data: [[
|
||||
{ xAxis: incomeProfitData.years[forecastStartIndex] },
|
||||
{ xAxis: incomeProfitData.years[incomeProfitData.years.length - 1] },
|
||||
]],
|
||||
},
|
||||
}),
|
||||
series: [
|
||||
{
|
||||
name: '营业总收入',
|
||||
type: 'bar',
|
||||
data: incomeProfitData.income,
|
||||
itemStyle: {
|
||||
color: CHART_COLORS.income,
|
||||
},
|
||||
data: incomeProfitData.income.map((value, idx) => ({
|
||||
value,
|
||||
itemStyle: {
|
||||
color: isForecastYear(incomeProfitData.years[idx])
|
||||
? 'rgba(212, 175, 55, 0.6)' // 预测数据半透明
|
||||
: CHART_COLORS.income,
|
||||
},
|
||||
})),
|
||||
barMaxWidth: 30,
|
||||
// 预测区域标记
|
||||
markArea: forecastStartIndex > 0 ? {
|
||||
silent: true,
|
||||
itemStyle: { color: THEME.forecastBg },
|
||||
data: [[
|
||||
{ xAxis: incomeProfitData.years[forecastStartIndex] },
|
||||
{ xAxis: incomeProfitData.years[incomeProfitData.years.length - 1] },
|
||||
]],
|
||||
} : undefined,
|
||||
},
|
||||
{
|
||||
name: '归母净利润',
|
||||
type: 'line',
|
||||
data: incomeProfitData.profit,
|
||||
smooth: true,
|
||||
lineStyle: { width: 2, color: CHART_COLORS.profit },
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: CHART_COLORS.profit,
|
||||
},
|
||||
itemStyle: { color: CHART_COLORS.profit },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(246, 173, 85, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(246, 173, 85, 0.05)' },
|
||||
@@ -115,11 +178,8 @@ const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
|
||||
yAxisIndex: 1,
|
||||
data: growthData.revenue_growth_pct,
|
||||
smooth: true,
|
||||
lineStyle: { width: 2, type: 'dashed', color: '#10B981' },
|
||||
itemStyle: {
|
||||
color: (params: { value: number }) =>
|
||||
params.value >= 0 ? THEME.positive : THEME.negative,
|
||||
},
|
||||
lineStyle: { width: 2, type: 'dashed', color: CHART_COLORS.growth },
|
||||
itemStyle: { color: CHART_COLORS.growth },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
@@ -132,7 +192,7 @@ const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
|
||||
},
|
||||
},
|
||||
],
|
||||
}), [incomeProfitData, growthData]);
|
||||
}), [incomeProfitData, growthData, forecastStartIndex]);
|
||||
|
||||
return (
|
||||
<ChartCard title="营收与利润趋势 · 增长率">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* PE 与 PEG 分析图
|
||||
* 优化:配色区分度、线条样式、Y轴颜色对应、预测区分
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
@@ -8,35 +9,75 @@ import ChartCard from './ChartCard';
|
||||
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
|
||||
import type { PePegChartProps } from '../types';
|
||||
|
||||
// 判断是否为预测年份
|
||||
const isForecastYear = (year: string) => year.includes('E');
|
||||
|
||||
const PePegChart: React.FC<PePegChartProps> = ({ data }) => {
|
||||
// 找出预测数据起始索引
|
||||
const forecastStartIndex = useMemo(() => {
|
||||
return data.years.findIndex(isForecastYear);
|
||||
}, [data.years]);
|
||||
|
||||
const option = useMemo(() => ({
|
||||
...BASE_CHART_CONFIG,
|
||||
color: [CHART_COLORS.pe, CHART_COLORS.peg],
|
||||
tooltip: {
|
||||
...BASE_CHART_CONFIG.tooltip,
|
||||
trigger: 'axis',
|
||||
formatter: (params: any[]) => {
|
||||
if (!params || params.length === 0) return '';
|
||||
const year = params[0].axisValue;
|
||||
const isForecast = isForecastYear(year);
|
||||
|
||||
let html = `<div style="font-weight:600;font-size:14px;margin-bottom:8px;color:${THEME.gold}">
|
||||
${year}${isForecast ? ' <span style="font-size:11px;color:#A0AEC0">(预测)</span>' : ''}
|
||||
</div>`;
|
||||
|
||||
params.forEach((item: any) => {
|
||||
const unit = item.seriesName === 'PE' ? '倍' : '';
|
||||
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin:4px 0">
|
||||
<span style="display:flex;align-items:center">
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${item.color};margin-right:8px"></span>
|
||||
${item.seriesName}
|
||||
</span>
|
||||
<span style="font-weight:500;margin-left:20px;font-family:'Menlo','Monaco',monospace">${item.value?.toFixed(2) ?? '-'}${unit}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
return html;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
...BASE_CHART_CONFIG.legend,
|
||||
data: ['PE', 'PEG'],
|
||||
bottom: 0,
|
||||
},
|
||||
xAxis: {
|
||||
...BASE_CHART_CONFIG.xAxis,
|
||||
type: 'category',
|
||||
data: data.years,
|
||||
axisLabel: {
|
||||
color: (value: string) => isForecastYear(value) ? THEME.gold : THEME.textSecondary,
|
||||
fontWeight: (value: string) => isForecastYear(value) ? 'bold' : 'normal',
|
||||
},
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
...BASE_CHART_CONFIG.yAxis,
|
||||
type: 'value',
|
||||
name: 'PE(倍)',
|
||||
nameTextStyle: { color: THEME.textSecondary },
|
||||
nameTextStyle: { color: CHART_COLORS.pe },
|
||||
axisLine: { lineStyle: { color: CHART_COLORS.pe } },
|
||||
axisLabel: { color: CHART_COLORS.pe },
|
||||
},
|
||||
{
|
||||
...BASE_CHART_CONFIG.yAxis,
|
||||
type: 'value',
|
||||
name: 'PEG',
|
||||
nameTextStyle: { color: THEME.textSecondary },
|
||||
nameTextStyle: { color: CHART_COLORS.peg },
|
||||
axisLine: { lineStyle: { color: CHART_COLORS.peg } },
|
||||
axisLabel: { color: CHART_COLORS.peg },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
@@ -45,7 +86,29 @@ const PePegChart: React.FC<PePegChartProps> = ({ data }) => {
|
||||
type: 'line',
|
||||
data: data.pe,
|
||||
smooth: true,
|
||||
lineStyle: { width: 2 },
|
||||
lineStyle: { width: 2.5, color: CHART_COLORS.pe },
|
||||
itemStyle: { color: CHART_COLORS.pe },
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(212, 175, 55, 0.2)' },
|
||||
{ offset: 1, color: 'rgba(212, 175, 55, 0.02)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
// 预测区域标记
|
||||
markArea: forecastStartIndex > 0 ? {
|
||||
silent: true,
|
||||
itemStyle: { color: THEME.forecastBg },
|
||||
data: [[
|
||||
{ xAxis: data.years[forecastStartIndex] },
|
||||
{ xAxis: data.years[data.years.length - 1] },
|
||||
]],
|
||||
} : undefined,
|
||||
},
|
||||
{
|
||||
name: 'PEG',
|
||||
@@ -53,10 +116,32 @@ const PePegChart: React.FC<PePegChartProps> = ({ data }) => {
|
||||
yAxisIndex: 1,
|
||||
data: data.peg,
|
||||
smooth: true,
|
||||
lineStyle: { width: 2 },
|
||||
lineStyle: {
|
||||
width: 2.5,
|
||||
type: [5, 3], // 点划线样式,区分 PE
|
||||
color: CHART_COLORS.peg,
|
||||
},
|
||||
itemStyle: { color: CHART_COLORS.peg },
|
||||
symbol: 'diamond', // 菱形符号,区分 PE
|
||||
symbolSize: 6,
|
||||
// PEG=1 参考线
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
color: 'rgba(255, 255, 255, 0.3)',
|
||||
type: 'dashed',
|
||||
},
|
||||
label: {
|
||||
formatter: 'PEG=1',
|
||||
color: '#A0AEC0',
|
||||
fontSize: 10,
|
||||
},
|
||||
data: [{ yAxis: 1 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}), [data]);
|
||||
}), [data, forecastStartIndex]);
|
||||
|
||||
return (
|
||||
<ChartCard title="PE 与 PEG 分析">
|
||||
|
||||
@@ -12,16 +12,19 @@ export const THEME = {
|
||||
textSecondary: '#A0AEC0',
|
||||
positive: '#E53E3E',
|
||||
negative: '#10B981',
|
||||
// 预测区域背景色
|
||||
forecastBg: 'rgba(212, 175, 55, 0.08)',
|
||||
};
|
||||
|
||||
// 图表配色方案
|
||||
// 图表配色方案 - 优化对比度
|
||||
export const CHART_COLORS = {
|
||||
income: '#D4AF37', // 收入 - 金色
|
||||
profit: '#F6AD55', // 利润 - 橙金色
|
||||
growth: '#B8860B', // 增长 - 深金色
|
||||
growth: '#10B981', // 增长率 - 翠绿色
|
||||
eps: '#DAA520', // EPS - 金菊色
|
||||
epsAvg: '#4A5568', // EPS行业平均 - 灰色
|
||||
pe: '#D4AF37', // PE - 金色
|
||||
peg: '#CD853F', // PEG - 秘鲁色
|
||||
peg: '#38B2AC', // PEG - 青色(优化对比度)
|
||||
};
|
||||
|
||||
// ECharts 基础配置(黑金主题)
|
||||
@@ -31,11 +34,18 @@ export const BASE_CHART_CONFIG = {
|
||||
color: THEME.text,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(26, 32, 44, 0.95)',
|
||||
backgroundColor: 'rgba(26, 32, 44, 0.98)',
|
||||
borderColor: THEME.goldBorder,
|
||||
borderWidth: 1,
|
||||
padding: [12, 16],
|
||||
textStyle: {
|
||||
color: THEME.text,
|
||||
fontSize: 13,
|
||||
},
|
||||
// 智能避让配置
|
||||
confine: true,
|
||||
appendToBody: true,
|
||||
extraCssText: 'box-shadow: 0 4px 20px rgba(0,0,0,0.3); border-radius: 6px;',
|
||||
},
|
||||
legend: {
|
||||
textStyle: {
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, SimpleGrid, Skeleton } from '@chakra-ui/react';
|
||||
import { Box, SimpleGrid } from '@chakra-ui/react';
|
||||
import { stockService } from '@services/eventService';
|
||||
import {
|
||||
IncomeProfitGrowthChart,
|
||||
EpsChart,
|
||||
PePegChart,
|
||||
DetailTable,
|
||||
ChartCard,
|
||||
} from './components';
|
||||
import LoadingState from '../LoadingState';
|
||||
import { CHART_HEIGHT } from './constants';
|
||||
import type { ForecastReportProps, ForecastData } from './types';
|
||||
|
||||
@@ -49,15 +49,9 @@ const ForecastReport: React.FC<ForecastReportProps> = ({ stockCode: propStockCod
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 加载骨架屏 */}
|
||||
{/* 加载状态 */}
|
||||
{loading && !data && (
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<ChartCard key={i} title="加载中...">
|
||||
<Skeleton height={`${CHART_HEIGHT}px`} />
|
||||
</ChartCard>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<LoadingState message="加载盈利预测数据中..." height="300px" />
|
||||
)}
|
||||
|
||||
{/* 图表区域 - 3列布局 */}
|
||||
|
||||
44
src/views/Company/components/LoadingState.tsx
Normal file
44
src/views/Company/components/LoadingState.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
// src/views/Company/components/LoadingState.tsx
|
||||
// 统一的加载状态组件 - 黑金主题
|
||||
|
||||
import React from "react";
|
||||
import { Center, VStack, Spinner, Text } from "@chakra-ui/react";
|
||||
|
||||
// 黑金主题配置
|
||||
const THEME = {
|
||||
gold: "#D4AF37",
|
||||
textSecondary: "gray.400",
|
||||
};
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的加载状态组件(黑金主题)
|
||||
*
|
||||
* 用于所有一级 Tab 的 loading 状态展示
|
||||
*/
|
||||
const LoadingState: React.FC<LoadingStateProps> = ({
|
||||
message = "加载中...",
|
||||
height = "300px",
|
||||
}) => {
|
||||
return (
|
||||
<Center h={height}>
|
||||
<VStack spacing={4}>
|
||||
<Spinner
|
||||
size="xl"
|
||||
color={THEME.gold}
|
||||
thickness="4px"
|
||||
speed="0.65s"
|
||||
/>
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>
|
||||
{message}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingState;
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Box,
|
||||
Container,
|
||||
CardBody,
|
||||
Spinner,
|
||||
Center,
|
||||
VStack,
|
||||
Text,
|
||||
@@ -39,6 +38,7 @@ import {
|
||||
UnusualPanel,
|
||||
PledgePanel,
|
||||
} from './components/panels';
|
||||
import LoadingState from '../LoadingState';
|
||||
import type { MarketDataViewProps, RiseAnalysis } from './types';
|
||||
|
||||
/**
|
||||
@@ -163,18 +163,7 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
|
||||
{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>
|
||||
<LoadingState message="数据加载中..." height="400px" />
|
||||
</CardBody>
|
||||
</ThemedCard>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user