refactor(ForecastReport): 迁移至 TypeScript

This commit is contained in:
zdl
2025-12-16 20:28:58 +08:00
parent 2f69f83d16
commit ba99f55b16
11 changed files with 722 additions and 161 deletions

View File

@@ -0,0 +1,37 @@
/**
* 通用图表卡片组件 - 黑金主题
*/
import React from 'react';
import { Box, Heading } from '@chakra-ui/react';
import { THEME } from '../constants';
import type { ChartCardProps } from '../types';
const ChartCard: React.FC<ChartCardProps> = ({ title, children }) => {
return (
<Box
bg={THEME.bgDark}
border="1px solid"
borderColor={THEME.goldBorder}
borderRadius="md"
overflow="hidden"
>
<Box
px={4}
py={3}
borderBottom="1px solid"
borderColor={THEME.goldBorder}
bg={THEME.goldLight}
>
<Heading size="sm" color={THEME.gold}>
{title}
</Heading>
</Box>
<Box p={4}>
{children}
</Box>
</Box>
);
};
export default ChartCard;

View File

@@ -0,0 +1,148 @@
/**
* 详细数据表格 - 纯 Ant Design 黑金主题
*/
import React, { useMemo } from 'react';
import { Table, ConfigProvider, Tag, theme as antTheme } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { DetailTableProps, DetailTableRow } from '../types';
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
algorithm: antTheme.darkAlgorithm,
token: {
colorPrimary: '#D4AF37',
colorBgContainer: '#1A202C',
colorBgElevated: '#1a1a2e',
colorBorder: 'rgba(212, 175, 55, 0.3)',
colorText: '#e0e0e0',
colorTextSecondary: '#a0a0a0',
borderRadius: 4,
fontSize: 13,
},
components: {
Table: {
headerBg: 'rgba(212, 175, 55, 0.1)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.05)',
borderColor: 'rgba(212, 175, 55, 0.2)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 表格样式
const tableStyles = `
.forecast-detail-table {
background: #1A202C;
border: 1px solid rgba(212, 175, 55, 0.3);
border-radius: 6px;
overflow: hidden;
}
.forecast-detail-table .table-header {
padding: 12px 16px;
border-bottom: 1px solid rgba(212, 175, 55, 0.3);
background: rgba(212, 175, 55, 0.1);
}
.forecast-detail-table .table-header h4 {
margin: 0;
color: #D4AF37;
font-size: 14px;
font-weight: 600;
}
.forecast-detail-table .table-body {
padding: 16px;
}
.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;
}
.forecast-detail-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.forecast-detail-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-left {
background: #242d3d !important;
}
.forecast-detail-table .ant-table-tbody > tr > td {
background: #1A202C !important;
}
.forecast-detail-table .metric-tag {
background: rgba(212, 175, 55, 0.15);
border-color: rgba(212, 175, 55, 0.3);
color: #D4AF37;
}
`;
interface TableRowData extends DetailTableRow {
key: string;
}
const DetailTable: React.FC<DetailTableProps> = ({ data }) => {
const { years, rows } = data;
// 构建列配置
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: '关键指标',
dataIndex: '指标',
key: '指标',
fixed: 'left',
width: 160,
render: (value: string) => (
<Tag className="metric-tag">{value}</Tag>
),
},
];
// 添加年份列
years.forEach((year) => {
cols.push({
title: year,
dataIndex: year,
key: year,
align: 'right',
width: 100,
render: (value: string | number | null) => value ?? '-',
});
});
return cols;
}, [years]);
// 构建数据源
const dataSource: TableRowData[] = useMemo(() => {
return rows.map((row, idx) => ({
...row,
key: `row-${idx}`,
}));
}, [rows]);
return (
<div className="forecast-detail-table">
<style>{tableStyles}</style>
<div className="table-header">
<h4></h4>
</div>
<div className="table-body">
<ConfigProvider theme={BLACK_GOLD_THEME}>
<Table<TableRowData>
columns={columns}
dataSource={dataSource}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
bordered
/>
</ConfigProvider>
</div>
</div>
);
};
export default DetailTable;

View File

@@ -0,0 +1,51 @@
/**
* EPS 趋势图
*/
import React, { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import ChartCard from './ChartCard';
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
import type { EpsChartProps } from '../types';
const EpsChart: React.FC<EpsChartProps> = ({ data }) => {
const option = useMemo(() => ({
...BASE_CHART_CONFIG,
color: [CHART_COLORS.eps],
tooltip: {
...BASE_CHART_CONFIG.tooltip,
trigger: 'axis',
},
xAxis: {
...BASE_CHART_CONFIG.xAxis,
type: 'category',
data: data.years,
},
yAxis: {
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: '元/股',
nameTextStyle: { color: THEME.textSecondary },
},
series: [
{
name: 'EPS(稀释)',
type: 'line',
data: data.eps,
smooth: true,
lineStyle: { width: 2 },
areaStyle: { opacity: 0.15 },
symbol: 'circle',
symbolSize: 6,
},
],
}), [data]);
return (
<ChartCard title="EPS 趋势">
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
</ChartCard>
);
};
export default EpsChart;

View File

@@ -0,0 +1,59 @@
/**
* 增长率分析图
*/
import React, { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import ChartCard from './ChartCard';
import { BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
import type { GrowthChartProps } from '../types';
const GrowthChart: React.FC<GrowthChartProps> = ({ data }) => {
const option = useMemo(() => ({
...BASE_CHART_CONFIG,
tooltip: {
...BASE_CHART_CONFIG.tooltip,
trigger: 'axis',
},
xAxis: {
...BASE_CHART_CONFIG.xAxis,
type: 'category',
data: data.years,
},
yAxis: {
...BASE_CHART_CONFIG.yAxis,
type: 'value',
axisLabel: {
...BASE_CHART_CONFIG.yAxis.axisLabel,
formatter: '{value}%',
},
},
series: [
{
name: '营收增长率(%)',
type: 'bar',
data: data.revenue_growth_pct,
itemStyle: {
color: (params: { value: number }) =>
params.value >= 0 ? THEME.positive : THEME.negative,
},
label: {
show: true,
position: 'top',
color: THEME.textSecondary,
fontSize: 10,
formatter: (params: { value: number }) =>
params.value ? `${params.value.toFixed(1)}%` : '',
},
},
],
}), [data]);
return (
<ChartCard title="增长率分析">
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
</ChartCard>
);
};
export default GrowthChart;

View File

@@ -0,0 +1,69 @@
/**
* 营业收入与净利润趋势图
*/
import React, { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import ChartCard from './ChartCard';
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
import type { IncomeProfitChartProps } from '../types';
const IncomeProfitChart: React.FC<IncomeProfitChartProps> = ({ data }) => {
const option = useMemo(() => ({
...BASE_CHART_CONFIG,
color: [CHART_COLORS.income, CHART_COLORS.profit],
tooltip: {
...BASE_CHART_CONFIG.tooltip,
trigger: 'axis',
},
legend: {
...BASE_CHART_CONFIG.legend,
data: ['营业总收入(百万元)', '归母净利润(百万元)'],
},
xAxis: {
...BASE_CHART_CONFIG.xAxis,
type: 'category',
data: data.years,
},
yAxis: [
{
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: '收入(百万元)',
nameTextStyle: { color: THEME.textSecondary },
},
{
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: '利润(百万元)',
nameTextStyle: { color: THEME.textSecondary },
},
],
series: [
{
name: '营业总收入(百万元)',
type: 'line',
data: data.income,
smooth: true,
lineStyle: { width: 2 },
areaStyle: { opacity: 0.1 },
},
{
name: '归母净利润(百万元)',
type: 'line',
yAxisIndex: 1,
data: data.profit,
smooth: true,
lineStyle: { width: 2 },
},
],
}), [data]);
return (
<ChartCard title="营业收入与净利润趋势">
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
</ChartCard>
);
};
export default IncomeProfitChart;

View File

@@ -0,0 +1,68 @@
/**
* PE 与 PEG 分析图
*/
import React, { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import ChartCard from './ChartCard';
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
import type { PePegChartProps } from '../types';
const PePegChart: React.FC<PePegChartProps> = ({ data }) => {
const option = useMemo(() => ({
...BASE_CHART_CONFIG,
color: [CHART_COLORS.pe, CHART_COLORS.peg],
tooltip: {
...BASE_CHART_CONFIG.tooltip,
trigger: 'axis',
},
legend: {
...BASE_CHART_CONFIG.legend,
data: ['PE', 'PEG'],
},
xAxis: {
...BASE_CHART_CONFIG.xAxis,
type: 'category',
data: data.years,
},
yAxis: [
{
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: 'PE(倍)',
nameTextStyle: { color: THEME.textSecondary },
},
{
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: 'PEG',
nameTextStyle: { color: THEME.textSecondary },
},
],
series: [
{
name: 'PE',
type: 'line',
data: data.pe,
smooth: true,
lineStyle: { width: 2 },
},
{
name: 'PEG',
type: 'line',
yAxisIndex: 1,
data: data.peg,
smooth: true,
lineStyle: { width: 2 },
},
],
}), [data]);
return (
<ChartCard title="PE 与 PEG 分析">
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
</ChartCard>
);
};
export default PePegChart;

View File

@@ -0,0 +1,10 @@
/**
* ForecastReport 子组件导出
*/
export { default as ChartCard } from './ChartCard';
export { default as IncomeProfitChart } from './IncomeProfitChart';
export { default as GrowthChart } from './GrowthChart';
export { default as EpsChart } from './EpsChart';
export { default as PePegChart } from './PePegChart';
export { default as DetailTable } from './DetailTable';

View File

@@ -0,0 +1,84 @@
/**
* 盈利预测报表常量和图表配置
*/
// 黑金主题配色
export const THEME = {
gold: '#D4AF37',
goldLight: 'rgba(212, 175, 55, 0.1)',
goldBorder: 'rgba(212, 175, 55, 0.3)',
bgDark: '#1A202C',
text: '#E2E8F0',
textSecondary: '#A0AEC0',
positive: '#E53E3E',
negative: '#10B981',
};
// 图表配色方案
export const CHART_COLORS = {
income: '#D4AF37', // 收入 - 金色
profit: '#F6AD55', // 利润 - 橙金色
growth: '#B8860B', // 增长 - 深金色
eps: '#DAA520', // EPS - 金菊色
pe: '#D4AF37', // PE - 金色
peg: '#CD853F', // PEG - 秘鲁色
};
// ECharts 基础配置(黑金主题)
export const BASE_CHART_CONFIG = {
backgroundColor: 'transparent',
textStyle: {
color: THEME.text,
},
tooltip: {
backgroundColor: 'rgba(26, 32, 44, 0.95)',
borderColor: THEME.goldBorder,
textStyle: {
color: THEME.text,
},
},
legend: {
textStyle: {
color: THEME.textSecondary,
},
},
grid: {
left: 50,
right: 20,
bottom: 40,
top: 40,
containLabel: false,
},
xAxis: {
axisLine: {
lineStyle: {
color: THEME.goldBorder,
},
},
axisLabel: {
color: THEME.textSecondary,
rotate: 30,
},
splitLine: {
show: false,
},
},
yAxis: {
axisLine: {
lineStyle: {
color: THEME.goldBorder,
},
},
axisLabel: {
color: THEME.textSecondary,
},
splitLine: {
lineStyle: {
color: 'rgba(212, 175, 55, 0.1)',
},
},
},
};
// 图表高度
export const CHART_HEIGHT = 280;

View File

@@ -1,161 +0,0 @@
// 简易版公司盈利预测报表视图
import React, { useState, useEffect } from 'react';
import { Box, Flex, Input, Button, SimpleGrid, HStack, Text, Skeleton, VStack } from '@chakra-ui/react';
import { Card, CardHeader, CardBody, Heading, Table, Thead, Tr, Th, Tbody, Td, Tag } from '@chakra-ui/react';
import { RepeatIcon } from '@chakra-ui/icons';
import ReactECharts from 'echarts-for-react';
import { stockService } from '@services/eventService';
const ForecastReport = ({ stockCode: propStockCode }) => {
const [code, setCode] = useState(propStockCode || '600000');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const load = async () => {
if (!code) return;
setLoading(true);
try {
const resp = await stockService.getForecastReport(code);
if (resp && resp.success) setData(resp.data);
} finally {
setLoading(false);
}
};
// 监听props中的stockCode变化
useEffect(() => {
if (propStockCode && propStockCode !== code) {
setCode(propStockCode);
}
}, [propStockCode, code]);
// 加载数据
useEffect(() => {
if (code) {
load();
}
}, [code]);
const years = data?.detail_table?.years || [];
const colors = ['#805AD5', '#38B2AC', '#F6AD55', '#63B3ED', '#E53E3E', '#10B981'];
const incomeProfitOption = data ? {
color: [colors[0], colors[4]],
tooltip: { trigger: 'axis' },
legend: { data: ['营业总收入(百万元)', '归母净利润(百万元)'] },
grid: { left: 40, right: 20, bottom: 40, top: 30 },
xAxis: { type: 'category', data: data.income_profit_trend.years, axisLabel: { rotate: 30 } },
yAxis: [
{ type: 'value', name: '收入(百万元)' },
{ type: 'value', name: '利润(百万元)' }
],
series: [
{ name: '营业总收入(百万元)', type: 'line', data: data.income_profit_trend.income, smooth: true, lineStyle: { width: 2 }, areaStyle: { opacity: 0.08 } },
{ name: '归母净利润(百万元)', type: 'line', yAxisIndex: 1, data: data.income_profit_trend.profit, smooth: true, lineStyle: { width: 2 } }
]
} : {};
const growthOption = data ? {
color: [colors[2]],
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, bottom: 40, top: 30 },
xAxis: { type: 'category', data: data.growth_bars.years, axisLabel: { rotate: 30 } },
yAxis: { type: 'value', axisLabel: { formatter: '{value}%' } },
series: [ {
name: '营收增长率(%)',
type: 'bar',
data: data.growth_bars.revenue_growth_pct,
itemStyle: { color: (params) => params.value >= 0 ? '#E53E3E' : '#10B981' }
} ]
} : {};
const epsOption = data ? {
color: [colors[3]],
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, bottom: 40, top: 30 },
xAxis: { type: 'category', data: data.eps_trend.years, axisLabel: { rotate: 30 } },
yAxis: { type: 'value', name: '元/股' },
series: [ { name: 'EPS(稀释)', type: 'line', data: data.eps_trend.eps, smooth: true, areaStyle: { opacity: 0.1 }, lineStyle: { width: 2 } } ]
} : {};
const pePegOption = data ? {
color: [colors[0], colors[1]],
tooltip: { trigger: 'axis' },
legend: { data: ['PE', 'PEG'] },
grid: { left: 40, right: 40, bottom: 40, top: 30 },
xAxis: { type: 'category', data: data.pe_peg_axes.years, axisLabel: { rotate: 30 } },
yAxis: [ { type: 'value', name: 'PE(倍)' }, { type: 'value', name: 'PEG' } ],
series: [
{ name: 'PE', type: 'line', data: data.pe_peg_axes.pe, smooth: true },
{ name: 'PEG', type: 'line', yAxisIndex: 1, data: data.pe_peg_axes.peg, smooth: true }
]
} : {};
return (
<Box p={4}>
<HStack align="center" justify="space-between" mb={4}>
<Heading size="md">盈利预测报表</Heading>
<Button
leftIcon={<RepeatIcon />}
size="sm"
variant="outline"
onClick={load}
isLoading={loading}
>
刷新数据
</Button>
</HStack>
{loading && !data && (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{[1,2,3,4].map(i => (
<Card key={i}>
<CardHeader><Skeleton height="18px" width="140px" /></CardHeader>
<CardBody>
<Skeleton height="320px" />
</CardBody>
</Card>
))}
</SimpleGrid>
)}
{data && (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<Card><CardHeader><Heading size="sm">营业收入与净利润趋势</Heading></CardHeader><CardBody><ReactECharts option={incomeProfitOption} style={{ height: 320 }} /></CardBody></Card>
<Card><CardHeader><Heading size="sm">增长率分析</Heading></CardHeader><CardBody><ReactECharts option={growthOption} style={{ height: 320 }} /></CardBody></Card>
<Card><CardHeader><Heading size="sm">EPS 趋势</Heading></CardHeader><CardBody><ReactECharts option={epsOption} style={{ height: 320 }} /></CardBody></Card>
<Card><CardHeader><Heading size="sm">PE PEG 分析</Heading></CardHeader><CardBody><ReactECharts option={pePegOption} style={{ height: 320 }} /></CardBody></Card>
</SimpleGrid>
)}
{data && (
<Card mt={4}>
<CardHeader><Heading size="sm">详细数据表格</Heading></CardHeader>
<CardBody>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th>关键指标</Th>
{years.map(y => <Th key={y}>{y}</Th>)}
</Tr>
</Thead>
<Tbody>
{data.detail_table.rows.map((row, idx) => (
<Tr key={idx}>
<Td><Tag>{row['指标']}</Tag></Td>
{years.map(y => <Td key={y}>{row[y] ?? '-'}</Td>)}
</Tr>
))}
</Tbody>
</Table>
</CardBody>
</Card>
)}
</Box>
);
};
export default ForecastReport;

View File

@@ -0,0 +1,115 @@
/**
* 盈利预测报表视图 - 黑金主题
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, SimpleGrid, HStack, Heading, Skeleton, IconButton } from '@chakra-ui/react';
import { RefreshCw } from 'lucide-react';
import { stockService } from '@services/eventService';
import {
IncomeProfitChart,
GrowthChart,
EpsChart,
PePegChart,
DetailTable,
ChartCard,
} from './components';
import { THEME, CHART_HEIGHT } from './constants';
import type { ForecastReportProps, ForecastData } from './types';
const ForecastReport: React.FC<ForecastReportProps> = ({ stockCode: propStockCode }) => {
const [code, setCode] = useState(propStockCode || '600000');
const [data, setData] = useState<ForecastData | null>(null);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
if (!code) return;
setLoading(true);
try {
const resp = await stockService.getForecastReport(code);
if (resp && resp.success) {
setData(resp.data);
}
} finally {
setLoading(false);
}
}, [code]);
// 监听 props 中的 stockCode 变化
useEffect(() => {
if (propStockCode && propStockCode !== code) {
setCode(propStockCode);
}
}, [propStockCode, code]);
// 加载数据
useEffect(() => {
if (code) {
load();
}
}, [code, load]);
return (
<Box>
{/* 标题栏 */}
<HStack align="center" justify="space-between" mb={4}>
<Heading size="md" color={THEME.gold}>
</Heading>
<IconButton
icon={<RefreshCw size={14} className={loading ? 'spin' : ''} />}
onClick={load}
isLoading={loading}
variant="outline"
size="sm"
aria-label="刷新数据"
borderColor={THEME.goldBorder}
color={THEME.gold}
_hover={{
bg: THEME.goldLight,
borderColor: THEME.gold,
}}
/>
<style>{`
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</HStack>
{/* 加载骨架屏 */}
{loading && !data && (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{[1, 2, 3, 4].map((i) => (
<ChartCard key={i} title="加载中...">
<Skeleton height={`${CHART_HEIGHT}px`} />
</ChartCard>
))}
</SimpleGrid>
)}
{/* 图表区域 */}
{data && (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<IncomeProfitChart data={data.income_profit_trend} />
<GrowthChart data={data.growth_bars} />
<EpsChart data={data.eps_trend} />
<PePegChart data={data.pe_peg_axes} />
</SimpleGrid>
)}
{/* 详细数据表格 */}
{data && (
<Box mt={4}>
<DetailTable data={data.detail_table} />
</Box>
)}
</Box>
);
};
export default ForecastReport;

View File

@@ -0,0 +1,81 @@
/**
* 盈利预测报表类型定义
*/
// 收入利润趋势数据
export interface IncomeProfitTrend {
years: string[];
income: number[];
profit: number[];
}
// 增长率数据
export interface GrowthBars {
years: string[];
revenue_growth_pct: number[];
}
// EPS 趋势数据
export interface EpsTrend {
years: string[];
eps: number[];
}
// PE/PEG 数据
export interface PePegAxes {
years: string[];
pe: number[];
peg: number[];
}
// 详细表格行数据
export interface DetailTableRow {
指标: string;
[year: string]: string | number | null;
}
// 详细表格数据
export interface DetailTable {
years: string[];
rows: DetailTableRow[];
}
// 完整的预测报表数据
export interface ForecastData {
income_profit_trend: IncomeProfitTrend;
growth_bars: GrowthBars;
eps_trend: EpsTrend;
pe_peg_axes: PePegAxes;
detail_table: DetailTable;
}
// 组件 Props
export interface ForecastReportProps {
stockCode?: string;
}
export interface ChartCardProps {
title: string;
children: React.ReactNode;
height?: number;
}
export interface IncomeProfitChartProps {
data: IncomeProfitTrend;
}
export interface GrowthChartProps {
data: GrowthBars;
}
export interface EpsChartProps {
data: EpsTrend;
}
export interface PePegChartProps {
data: PePegAxes;
}
export interface DetailTableProps {
data: DetailTable;
}