Compare commits

...

3 Commits

Author SHA1 Message Date
zdl
ac7e627b2d refactor(Company): 统一所有 Tab 的 loading 状态组件
- 创建共享的 LoadingState 组件(黑金主题)
- DeepAnalysisTab: 使用统一 LoadingState 替换蓝色 Spinner
- FinancialPanorama: 使用 LoadingState 替换 Skeleton
- MarketDataView: 使用 LoadingState 替换自定义 Spinner
- ForecastReport: 使用 LoadingState 替换 Skeleton 骨架屏

所有一级 Tab 现在使用一致的金色 Spinner + 加载提示文案

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 10:31:38 +08:00
zdl
21e83ac1bc style(ForecastReport): 详细数据表格 UI 优化
- 斑马纹(奇数行浅色背景)
- 等宽字体(SF Mono/Monaco/Menlo)
- 重要指标行高亮(归母净利润、ROE、EPS、营业总收入)
- 预测列区分样式(斜体+浅金背景+分隔线)
- 负数红色/正增长绿色显示

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 10:27:38 +08:00
zdl
e2dd9e2648 style(ForecastReport): 盈利预测图表优化
- 预测年份 X 轴金色高亮,预测区域添加背景标记
- Y 轴颜色与对应数据系列匹配
- PEG 改用青色点划线+菱形符号,增加 PEG=1 参考线
- EPS 图添加行业平均参考线
- Tooltip 显示预测标签,智能避让

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 10:27:30 +08:00
10 changed files with 445 additions and 88 deletions

View File

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

View File

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

View File

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

View File

@@ -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 趋势">

View File

@@ -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="营收与利润趋势 · 增长率">

View File

@@ -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 分析">

View File

@@ -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: {

View File

@@ -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列布局 */}

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

View File

@@ -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>
) : (