From b9ea08e601f4c901e999b9a09eaf363ecac878d2 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 12 Dec 2025 15:01:47 +0800 Subject: [PATCH] =?UTF-8?q?refactor(FinancialPanorama):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A09=E4=B8=AA=E5=AD=90=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 财务报表组件: - BalanceSheetTable: 资产负债表(可折叠分类) - IncomeStatementTable: 利润表(支持负向指标反色) - CashflowTable: 现金流量表 - FinancialMetricsTable: 财务指标(7分类切换 + 关键指标速览) 业务分析组件: - StockInfoHeader: 股票信息头部 - MainBusinessAnalysis: 主营业务分析(饼图 + 表格) - IndustryRankingView: 行业排名展示 - StockComparison: 股票对比(多维度) - ComparisonAnalysis: 综合对比分析(双轴图) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../components/BalanceSheetTable.tsx | 254 ++++++++++++++++ .../components/CashflowTable.tsx | 157 ++++++++++ .../components/ComparisonAnalysis.tsx | 40 +++ .../components/FinancialMetricsTable.tsx | 279 ++++++++++++++++++ .../components/IncomeStatementTable.tsx | 229 ++++++++++++++ .../components/IndustryRankingView.tsx | 114 +++++++ .../components/MainBusinessAnalysis.tsx | 183 ++++++++++++ .../components/StockComparison.tsx | 259 ++++++++++++++++ .../components/StockInfoHeader.tsx | 111 +++++++ .../FinancialPanorama/components/index.ts | 13 + 10 files changed, 1639 insertions(+) create mode 100644 src/views/Company/components/FinancialPanorama/components/BalanceSheetTable.tsx create mode 100644 src/views/Company/components/FinancialPanorama/components/CashflowTable.tsx create mode 100644 src/views/Company/components/FinancialPanorama/components/ComparisonAnalysis.tsx create mode 100644 src/views/Company/components/FinancialPanorama/components/FinancialMetricsTable.tsx create mode 100644 src/views/Company/components/FinancialPanorama/components/IncomeStatementTable.tsx create mode 100644 src/views/Company/components/FinancialPanorama/components/IndustryRankingView.tsx create mode 100644 src/views/Company/components/FinancialPanorama/components/MainBusinessAnalysis.tsx create mode 100644 src/views/Company/components/FinancialPanorama/components/StockComparison.tsx create mode 100644 src/views/Company/components/FinancialPanorama/components/StockInfoHeader.tsx create mode 100644 src/views/Company/components/FinancialPanorama/components/index.ts diff --git a/src/views/Company/components/FinancialPanorama/components/BalanceSheetTable.tsx b/src/views/Company/components/FinancialPanorama/components/BalanceSheetTable.tsx new file mode 100644 index 00000000..2f70e7b7 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/BalanceSheetTable.tsx @@ -0,0 +1,254 @@ +/** + * 资产负债表组件 + */ + +import React, { useState } from 'react'; +import { + Table, + Thead, + Tbody, + Tr, + Th, + Td, + TableContainer, + Text, + VStack, + HStack, + Box, + Badge, + Tooltip, + IconButton, + Alert, + AlertIcon, +} from '@chakra-ui/react'; +import { ChevronDownIcon, ChevronUpIcon, ViewIcon } from '@chakra-ui/icons'; +import { formatUtils } from '@services/financialService'; +import { + CURRENT_ASSETS_METRICS, + NON_CURRENT_ASSETS_METRICS, + TOTAL_ASSETS_METRICS, + CURRENT_LIABILITIES_METRICS, + NON_CURRENT_LIABILITIES_METRICS, + TOTAL_LIABILITIES_METRICS, + EQUITY_METRICS, +} from '../constants'; +import { getValueByPath } from '../utils'; +import type { BalanceSheetTableProps, MetricSectionConfig } from '../types'; + +export const BalanceSheetTable: React.FC = ({ + data, + showMetricChart, + calculateYoYChange, + getCellBackground, + positiveColor, + negativeColor, + bgColor, + hoverBg, +}) => { + const [expandedSections, setExpandedSections] = useState>({ + currentAssets: true, + nonCurrentAssets: true, + currentLiabilities: true, + nonCurrentLiabilities: true, + equity: true, + }); + + const toggleSection = (section: string) => { + setExpandedSections((prev) => ({ + ...prev, + [section]: !prev[section], + })); + }; + + // 资产部分配置 + const assetSections: MetricSectionConfig[] = [ + CURRENT_ASSETS_METRICS, + NON_CURRENT_ASSETS_METRICS, + TOTAL_ASSETS_METRICS, + ]; + + // 负债部分配置 + const liabilitySections: MetricSectionConfig[] = [ + CURRENT_LIABILITIES_METRICS, + NON_CURRENT_LIABILITIES_METRICS, + TOTAL_LIABILITIES_METRICS, + ]; + + // 权益部分配置 + const equitySections: MetricSectionConfig[] = [EQUITY_METRICS]; + + // 数组安全检查 + if (!Array.isArray(data) || data.length === 0) { + return ( + + + 暂无资产负债表数据 + + ); + } + + const maxColumns = Math.min(data.length, 6); + const displayData = data.slice(0, maxColumns); + + const renderSection = (sections: MetricSectionConfig[]) => ( + <> + {sections.map((section) => ( + + {section.title !== '资产总计' && + section.title !== '负债合计' && ( + toggleSection(section.key)} + > + + + {expandedSections[section.key] ? ( + + ) : ( + + )} + {section.title} + + + + )} + {(expandedSections[section.key] || + section.title === '资产总计' || + section.title === '负债合计' || + section.title === '股东权益合计') && + section.metrics.map((metric) => { + const rowData = data.map((item) => + getValueByPath(item, metric.path) + ); + + return ( + + showMetricChart(metric.name, metric.key, data, metric.path) + } + bg={metric.isTotal ? 'blue.50' : 'transparent'} + > + + + {!metric.isTotal && } + + {metric.name} + + {metric.isCore && ( + + 核心 + + )} + + + {displayData.map((item, idx) => { + const value = rowData[idx]; + const { change, intensity } = calculateYoYChange( + value ?? 0, + item.period, + data, + metric.path + ); + + return ( + + + + 数值: {formatUtils.formatLargeNumber(value)} + + 同比: {change.toFixed(2)}% + + } + placement="top" + > + + {formatUtils.formatLargeNumber(value, 0)} + + + {Math.abs(change) > 30 && !metric.isTotal && ( + 0 ? positiveColor : negativeColor} + fontWeight="bold" + > + {change > 0 ? '↑' : '↓'} + {Math.abs(change).toFixed(0)}% + + )} + + ); + })} + + } + variant="ghost" + colorScheme="blue" + aria-label="查看图表" + onClick={(e) => { + e.stopPropagation(); + showMetricChart(metric.name, metric.key, data, metric.path); + }} + /> + + + ); + })} + + ))} + + ); + + return ( + + + + + + {displayData.map((item) => ( + + ))} + + + + + {renderSection(assetSections)} + + {renderSection(liabilitySections)} + + {renderSection(equitySections)} + +
+ 项目 + + + {formatUtils.getReportType(item.period)} + + {item.period.substring(0, 10)} + + + 操作
+
+ ); +}; + +export default BalanceSheetTable; diff --git a/src/views/Company/components/FinancialPanorama/components/CashflowTable.tsx b/src/views/Company/components/FinancialPanorama/components/CashflowTable.tsx new file mode 100644 index 00000000..ed193273 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/CashflowTable.tsx @@ -0,0 +1,157 @@ +/** + * 现金流量表组件 + */ + +import React from 'react'; +import { + Table, + Thead, + Tbody, + Tr, + Th, + Td, + TableContainer, + Text, + VStack, + HStack, + Badge, + Tooltip, + IconButton, + Alert, + AlertIcon, +} from '@chakra-ui/react'; +import { ViewIcon } from '@chakra-ui/icons'; +import { formatUtils } from '@services/financialService'; +import { CASHFLOW_METRICS } from '../constants'; +import { getValueByPath } from '../utils'; +import type { CashflowTableProps } from '../types'; + +export const CashflowTable: React.FC = ({ + data, + showMetricChart, + calculateYoYChange, + getCellBackground, + positiveColor, + negativeColor, + bgColor, + hoverBg, +}) => { + // 数组安全检查 + if (!Array.isArray(data) || data.length === 0) { + return ( + + + 暂无现金流量表数据 + + ); + } + + const maxColumns = Math.min(data.length, 8); + const displayData = data.slice(0, maxColumns); + + return ( + + + + + + {displayData.map((item) => ( + + ))} + + + + + {CASHFLOW_METRICS.map((metric) => { + const rowData = data.map((item) => getValueByPath(item, metric.path)); + + return ( + showMetricChart(metric.name, metric.key, data, metric.path)} + > + + {displayData.map((item, idx) => { + const value = rowData[idx]; + const isNegative = value !== undefined && value < 0; + const { change, intensity } = calculateYoYChange( + value ?? 0, + item.period, + data, + metric.path + ); + + return ( + + ); + })} + + + ); + })} + +
+ 项目 + + + {formatUtils.getReportType(item.period)} + + {item.period.substring(0, 10)} + + + 趋势
+ + {metric.name} + {['operating_net', 'free_cash_flow'].includes(metric.key) && ( + 核心 + )} + + + + 数值: {formatUtils.formatLargeNumber(value)} + 同比: {change.toFixed(2)}% + + } + placement="top" + > + + {formatUtils.formatLargeNumber(value, 1)} + + + {Math.abs(change) > 50 && ( + 0 ? positiveColor : negativeColor} + fontWeight="bold" + > + {change > 0 ? '↑' : '↓'} + + )} + + } + variant="ghost" + colorScheme="blue" + aria-label="查看趋势" + /> +
+
+ ); +}; + +export default CashflowTable; diff --git a/src/views/Company/components/FinancialPanorama/components/ComparisonAnalysis.tsx b/src/views/Company/components/FinancialPanorama/components/ComparisonAnalysis.tsx new file mode 100644 index 00000000..f8e2c002 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/ComparisonAnalysis.tsx @@ -0,0 +1,40 @@ +/** + * 综合对比分析组件 + */ + +import React from 'react'; +import { Card, CardBody } from '@chakra-ui/react'; +import ReactECharts from 'echarts-for-react'; +import { formatUtils } from '@services/financialService'; +import { getComparisonChartOption } from '../utils'; +import type { ComparisonAnalysisProps } from '../types'; + +export const ComparisonAnalysis: React.FC = ({ comparison }) => { + if (!Array.isArray(comparison) || comparison.length === 0) return null; + + const revenueData = comparison + .map((item) => ({ + period: formatUtils.getReportType(item.period), + value: item.performance.revenue / 100000000, // 转换为亿 + })) + .reverse(); + + const profitData = comparison + .map((item) => ({ + period: formatUtils.getReportType(item.period), + value: item.performance.net_profit / 100000000, // 转换为亿 + })) + .reverse(); + + const chartOption = getComparisonChartOption(revenueData, profitData); + + return ( + + + + + + ); +}; + +export default ComparisonAnalysis; diff --git a/src/views/Company/components/FinancialPanorama/components/FinancialMetricsTable.tsx b/src/views/Company/components/FinancialPanorama/components/FinancialMetricsTable.tsx new file mode 100644 index 00000000..05ac1d57 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/FinancialMetricsTable.tsx @@ -0,0 +1,279 @@ +/** + * 财务指标表格组件 + */ + +import React, { useState } from 'react'; +import { + VStack, + HStack, + Button, + Table, + Thead, + Tbody, + Tr, + Th, + Td, + TableContainer, + Text, + Badge, + Tooltip, + IconButton, + Alert, + AlertIcon, + Card, + CardBody, + CardHeader, + Heading, + SimpleGrid, + Box, +} from '@chakra-ui/react'; +import { ViewIcon } from '@chakra-ui/icons'; +import { formatUtils } from '@services/financialService'; +import { FINANCIAL_METRICS_CATEGORIES } from '../constants'; +import { getValueByPath, isNegativeIndicator } from '../utils'; +import type { FinancialMetricsTableProps } from '../types'; + +type CategoryKey = keyof typeof FINANCIAL_METRICS_CATEGORIES; + +export const FinancialMetricsTable: React.FC = ({ + data, + showMetricChart, + calculateYoYChange, + getCellBackground, + positiveColor, + negativeColor, + bgColor, + hoverBg, +}) => { + const [selectedCategory, setSelectedCategory] = useState('profitability'); + + // 数组安全检查 + if (!Array.isArray(data) || data.length === 0) { + return ( + + + 暂无财务指标数据 + + ); + } + + const maxColumns = Math.min(data.length, 6); + const displayData = data.slice(0, maxColumns); + const currentCategory = FINANCIAL_METRICS_CATEGORIES[selectedCategory]; + + return ( + + {/* 分类选择器 */} + + {(Object.entries(FINANCIAL_METRICS_CATEGORIES) as [CategoryKey, typeof currentCategory][]).map( + ([key, category]) => ( + + ) + )} + + + {/* 指标表格 */} + + + + + + {displayData.map((item) => ( + + ))} + + + + + {currentCategory.metrics.map((metric) => { + const rowData = data.map((item) => + getValueByPath(item, metric.path) + ); + + return ( + + showMetricChart(metric.name, metric.key, data, metric.path) + } + > + + {displayData.map((item, idx) => { + const value = rowData[idx]; + const { change, intensity } = calculateYoYChange( + value ?? 0, + item.period, + data, + metric.path + ); + + // 判断指标性质 + const isNegative = isNegativeIndicator(metric.key); + + // 对于负向指标,增加是坏事(绿色),减少是好事(红色) + const displayColor = isNegative + ? change > 0 + ? negativeColor + : positiveColor + : change > 0 + ? positiveColor + : negativeColor; + + return ( + + ); + })} + + + ); + })} + +
+ {currentCategory.title} + + + {formatUtils.getReportType(item.period)} + + {item.period.substring(0, 10)} + + + 趋势
+ + + {metric.name} + + {metric.isCore && ( + + 核心 + + )} + + + + + {metric.name}: {value?.toFixed(2) || '-'} + + 同比: {change.toFixed(2)}% + + } + placement="top" + > + 0 + ? positiveColor + : value !== undefined && value < 0 + ? negativeColor + : 'gray.500' + : 'inherit' + } + > + {value?.toFixed(2) || '-'} + + + {Math.abs(change) > 20 && + value !== undefined && + Math.abs(value) > 0.01 && ( + + {change > 0 ? '↑' : '↓'} + + )} + + } + variant="ghost" + colorScheme="blue" + aria-label="查看趋势" + onClick={(e) => { + e.stopPropagation(); + showMetricChart(metric.name, metric.key, data, metric.path); + }} + /> +
+
+ + {/* 关键指标快速对比 */} + + + 关键指标速览 + + + + {data[0] && + [ + { + label: 'ROE', + value: getValueByPath(data[0], 'profitability.roe'), + format: 'percent', + }, + { + label: '毛利率', + value: getValueByPath(data[0], 'profitability.gross_margin'), + format: 'percent', + }, + { + label: '净利率', + value: getValueByPath(data[0], 'profitability.net_profit_margin'), + format: 'percent', + }, + { + label: '流动比率', + value: getValueByPath(data[0], 'solvency.current_ratio'), + format: 'decimal', + }, + { + label: '资产负债率', + value: getValueByPath(data[0], 'solvency.asset_liability_ratio'), + format: 'percent', + }, + { + label: '研发费用率', + value: getValueByPath(data[0], 'expense_ratios.rd_expense_ratio'), + format: 'percent', + }, + ].map((item, idx) => ( + + + {item.label} + + + {item.format === 'percent' + ? formatUtils.formatPercent(item.value) + : item.value?.toFixed(2) || '-'} + + + ))} + + + +
+ ); +}; + +export default FinancialMetricsTable; diff --git a/src/views/Company/components/FinancialPanorama/components/IncomeStatementTable.tsx b/src/views/Company/components/FinancialPanorama/components/IncomeStatementTable.tsx new file mode 100644 index 00000000..a9d1dbee --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/IncomeStatementTable.tsx @@ -0,0 +1,229 @@ +/** + * 利润表组件 + */ + +import React, { useState } from 'react'; +import { + Table, + Thead, + Tbody, + Tr, + Th, + Td, + TableContainer, + Text, + VStack, + HStack, + Box, + Badge, + Tooltip, + IconButton, + Alert, + AlertIcon, +} from '@chakra-ui/react'; +import { ChevronDownIcon, ChevronUpIcon, ViewIcon } from '@chakra-ui/icons'; +import { formatUtils } from '@services/financialService'; +import { INCOME_STATEMENT_SECTIONS } from '../constants'; +import { getValueByPath, isNegativeIndicator } from '../utils'; +import type { IncomeStatementTableProps } from '../types'; + +export const IncomeStatementTable: React.FC = ({ + data, + showMetricChart, + calculateYoYChange, + getCellBackground, + positiveColor, + negativeColor, + bgColor, + hoverBg, +}) => { + const [expandedSections, setExpandedSections] = useState>({ + revenue: true, + costs: true, + otherGains: true, + profits: true, + eps: true, + comprehensive: true, + }); + + const toggleSection = (section: string) => { + setExpandedSections((prev) => ({ + ...prev, + [section]: !prev[section], + })); + }; + + // 数组安全检查 + if (!Array.isArray(data) || data.length === 0) { + return ( + + + 暂无利润表数据 + + ); + } + + const maxColumns = Math.min(data.length, 6); + const displayData = data.slice(0, maxColumns); + + const renderSection = (section: (typeof INCOME_STATEMENT_SECTIONS)[0]) => ( + + toggleSection(section.key)} + > + + + {expandedSections[section.key] ? : } + {section.title} + + + + {expandedSections[section.key] && + section.metrics.map((metric) => { + const rowData = data.map((item) => getValueByPath(item, metric.path)); + + return ( + showMetricChart(metric.name, metric.key, data, metric.path)} + bg={ + metric.isTotal + ? 'blue.50' + : metric.isSubtotal + ? 'orange.50' + : 'transparent' + } + > + + + {!metric.isTotal && + !metric.isSubtotal && ( + + )} + + {metric.name} + + {metric.isCore && ( + + 核心 + + )} + + + {displayData.map((item, idx) => { + const value = rowData[idx]; + const { change, intensity } = calculateYoYChange( + value ?? 0, + item.period, + data, + metric.path + ); + + // 特殊处理:成本费用类负向指标,增长用绿色,减少用红色 + const isCostItem = isNegativeIndicator(metric.key); + const displayColor = isCostItem + ? change > 0 + ? negativeColor + : positiveColor + : change > 0 + ? positiveColor + : negativeColor; + + return ( + + + + 数值:{' '} + {metric.key.includes('eps') + ? value?.toFixed(3) + : formatUtils.formatLargeNumber(value)} + + 同比: {change.toFixed(2)}% + + } + placement="top" + > + + {metric.key.includes('eps') + ? value?.toFixed(3) + : formatUtils.formatLargeNumber(value, 0)} + + + {Math.abs(change) > 30 && !metric.isTotal && ( + + {change > 0 ? '↑' : '↓'} + {Math.abs(change).toFixed(0)}% + + )} + + ); + })} + + } + variant="ghost" + colorScheme="blue" + aria-label="查看图表" + /> + + + ); + })} + + ); + + return ( + + + + + + {displayData.map((item) => ( + + ))} + + + + + {INCOME_STATEMENT_SECTIONS.map((section) => renderSection(section))} + +
+ 项目 + + + {formatUtils.getReportType(item.period)} + + {item.period.substring(0, 10)} + + + 操作
+
+ ); +}; + +export default IncomeStatementTable; diff --git a/src/views/Company/components/FinancialPanorama/components/IndustryRankingView.tsx b/src/views/Company/components/FinancialPanorama/components/IndustryRankingView.tsx new file mode 100644 index 00000000..7115c59d --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/IndustryRankingView.tsx @@ -0,0 +1,114 @@ +/** + * 行业排名组件 + */ + +import React from 'react'; +import { + VStack, + Card, + CardBody, + CardHeader, + Heading, + Text, + Box, + HStack, + Badge, + SimpleGrid, +} from '@chakra-ui/react'; +import { formatUtils } from '@services/financialService'; +import { RANKING_METRICS } from '../constants'; +import type { IndustryRankingViewProps } from '../types'; + +export const IndustryRankingView: React.FC = ({ + industryRank, + bgColor, + borderColor, +}) => { + if (!industryRank || industryRank.length === 0) { + return ( + + + + 暂无行业排名数据 + + + + ); + } + + return ( + + {industryRank.map((periodData, periodIdx) => ( + + + + {periodData.report_type} 行业排名 + {periodData.period} + + + + {periodData.rankings?.map((ranking, idx) => ( + + + {ranking.industry_name} ({ranking.level_description}) + + + {RANKING_METRICS.map((metric) => { + const metricData = ranking.metrics?.[metric.key as keyof typeof ranking.metrics]; + if (!metricData) return null; + + const isGood = metricData.rank && metricData.rank <= 10; + const isBad = metricData.rank && metricData.rank > 30; + + const isPercentMetric = + metric.key.includes('growth') || + metric.key.includes('margin') || + metric.key === 'roe'; + + return ( + + + {metric.name} + + + + {isPercentMetric + ? formatUtils.formatPercent(metricData.value) + : metricData.value?.toFixed(2) || '-'} + + {metricData.rank && ( + + #{metricData.rank} + + )} + + + 行业均值:{' '} + {isPercentMetric + ? formatUtils.formatPercent(metricData.industry_avg) + : metricData.industry_avg?.toFixed(2) || '-'} + + + ); + })} + + + ))} + + + ))} + + ); +}; + +export default IndustryRankingView; diff --git a/src/views/Company/components/FinancialPanorama/components/MainBusinessAnalysis.tsx b/src/views/Company/components/FinancialPanorama/components/MainBusinessAnalysis.tsx new file mode 100644 index 00000000..3bb8067b --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/MainBusinessAnalysis.tsx @@ -0,0 +1,183 @@ +/** + * 主营业务分析组件 + */ + +import React from 'react'; +import { + VStack, + Grid, + GridItem, + Card, + CardBody, + CardHeader, + Heading, + Table, + Thead, + Tbody, + Tr, + Th, + Td, + TableContainer, + Alert, + AlertIcon, +} from '@chakra-ui/react'; +import ReactECharts from 'echarts-for-react'; +import { formatUtils } from '@services/financialService'; +import { getMainBusinessPieOption } from '../utils'; +import type { + MainBusinessAnalysisProps, + BusinessItem, + ProductClassification, + IndustryClassification, +} from '../types'; + +export const MainBusinessAnalysis: React.FC = ({ + mainBusiness, +}) => { + // 优先使用product_classification,如果为空则使用industry_classification + const hasProductData = + mainBusiness?.product_classification && mainBusiness.product_classification.length > 0; + const hasIndustryData = + mainBusiness?.industry_classification && mainBusiness.industry_classification.length > 0; + + if (!hasProductData && !hasIndustryData) { + return ( + + + 暂无主营业务数据 + + ); + } + + // 选择数据源 + const dataSource = hasProductData ? 'product' : 'industry'; + + // 获取最新期间数据 + const latestPeriod = hasProductData + ? (mainBusiness!.product_classification![0] as ProductClassification) + : (mainBusiness!.industry_classification![0] as IndustryClassification); + + // 获取业务项目 + const businessItems: BusinessItem[] = hasProductData + ? (latestPeriod as ProductClassification).products + : (latestPeriod as IndustryClassification).industries; + + // 过滤掉"合计"项,准备饼图数据 + const pieData = businessItems + .filter((item: BusinessItem) => item.content !== '合计') + .map((item: BusinessItem) => ({ + name: item.content, + value: item.revenue || 0, + })); + + const pieOption = getMainBusinessPieOption( + `主营业务构成 - ${latestPeriod.report_type}`, + dataSource === 'industry' ? '按行业分类' : '按产品分类', + pieData + ); + + // 历史对比数据 + const historicalData = hasProductData + ? (mainBusiness!.product_classification! as ProductClassification[]) + : (mainBusiness!.industry_classification! as IndustryClassification[]); + + return ( + + + + + + + + + + + + + 业务明细 - {latestPeriod.report_type} + + + + + + + + + + + + + + {businessItems + .filter((item: BusinessItem) => item.content !== '合计') + .map((item: BusinessItem, idx: number) => ( + + + + + + + ))} + +
业务营收毛利率(%)利润
{item.content}{formatUtils.formatLargeNumber(item.revenue)} + {formatUtils.formatPercent(item.gross_margin || item.profit_margin)} + {formatUtils.formatLargeNumber(item.profit)}
+
+
+
+
+
+ + {/* 历史对比 */} + {historicalData.length > 1 && ( + + + 主营业务历史对比 + + + + + + + + {historicalData.slice(0, 3).map((period) => ( + + ))} + + + + {businessItems + .filter((item: BusinessItem) => item.content !== '合计') + .map((item: BusinessItem, idx: number) => ( + + + {historicalData.slice(0, 3).map((period) => { + const periodItems: BusinessItem[] = hasProductData + ? (period as ProductClassification).products + : (period as IndustryClassification).industries; + const matchItem = periodItems.find( + (p: BusinessItem) => p.content === item.content + ); + return ( + + ); + })} + + ))} + +
业务/期间 + {period.report_type} +
{item.content} + {matchItem + ? formatUtils.formatLargeNumber(matchItem.revenue) + : '-'} +
+
+
+
+ )} +
+ ); +}; + +export default MainBusinessAnalysis; diff --git a/src/views/Company/components/FinancialPanorama/components/StockComparison.tsx b/src/views/Company/components/FinancialPanorama/components/StockComparison.tsx new file mode 100644 index 00000000..ab9e867a --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/StockComparison.tsx @@ -0,0 +1,259 @@ +/** + * 股票对比组件 + */ + +import React, { useState } from 'react'; +import { + VStack, + Card, + CardBody, + CardHeader, + Heading, + HStack, + Input, + Button, + Table, + Thead, + Tbody, + Tr, + Th, + Td, + TableContainer, + Text, + Grid, + GridItem, +} from '@chakra-ui/react'; +import { ArrowUpIcon, ArrowDownIcon } from '@chakra-ui/icons'; +import { useToast } from '@chakra-ui/react'; +import ReactECharts from 'echarts-for-react'; +import { logger } from '@utils/logger'; +import { financialService, formatUtils } from '@services/financialService'; +import { COMPARE_METRICS } from '../constants'; +import { getValueByPath, getCompareBarChartOption } from '../utils'; +import type { StockComparisonProps, StockInfo } from '../types'; + +interface CompareData { + stockInfo: StockInfo; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + metrics: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + comparison: any[]; +} + +export const StockComparison: React.FC = ({ + currentStock, + stockInfo, + positiveColor, + negativeColor, +}) => { + const [compareStock, setCompareStock] = useState(''); + const [compareData, setCompareData] = useState(null); + const [compareLoading, setCompareLoading] = useState(false); + const toast = useToast(); + + const loadCompareData = async () => { + if (!compareStock || compareStock.length !== 6) { + logger.warn('StockComparison', '无效的对比股票代码', { compareStock }); + toast({ + title: '请输入有效的6位股票代码', + status: 'warning', + duration: 3000, + }); + return; + } + + logger.debug('StockComparison', '开始加载对比数据', { currentStock, compareStock }); + setCompareLoading(true); + try { + const [stockInfoRes, metricsRes, comparisonRes] = await Promise.all([ + financialService.getStockInfo(compareStock), + financialService.getFinancialMetrics(compareStock, 4), + financialService.getPeriodComparison(compareStock, 4), + ]); + + setCompareData({ + stockInfo: stockInfoRes.data, + metrics: metricsRes.data, + comparison: comparisonRes.data, + }); + + logger.info('StockComparison', '对比数据加载成功', { currentStock, compareStock }); + } catch (error) { + logger.error('StockComparison', 'loadCompareData', error, { + currentStock, + compareStock, + }); + } finally { + setCompareLoading(false); + } + }; + + return ( + + + + + setCompareStock(e.target.value)} + maxLength={6} + /> + + + + + + {compareData && ( + + + + {stockInfo?.stock_name} ({currentStock}) VS{' '} + {compareData.stockInfo?.stock_name} ({compareStock}) + + + + + + + + + + + + + + + {COMPARE_METRICS.map((metric) => { + const value1 = getValueByPath(stockInfo, metric.path); + const value2 = getValueByPath( + compareData.stockInfo, + metric.path + ); + + let diff: number | null = null; + let diffColor = 'gray.500'; + + if (value1 !== undefined && value2 !== undefined) { + if (metric.format === 'percent') { + diff = value1 - value2; + diffColor = diff > 0 ? positiveColor : negativeColor; + } else { + diff = ((value1 - value2) / value2) * 100; + diffColor = diff > 0 ? positiveColor : negativeColor; + } + } + + return ( + + + + + + + ); + })} + +
指标{stockInfo?.stock_name}{compareData.stockInfo?.stock_name}差异
{metric.label} + {metric.format === 'percent' + ? formatUtils.formatPercent(value1) + : formatUtils.formatLargeNumber(value1)} + + {metric.format === 'percent' + ? formatUtils.formatPercent(value2) + : formatUtils.formatLargeNumber(value2)} + + {diff !== null ? ( + + {diff > 0 && } + {diff < 0 && } + + {metric.format === 'percent' + ? `${Math.abs(diff).toFixed(2)}pp` + : `${Math.abs(diff).toFixed(2)}%`} + + + ) : ( + '-' + )} +
+
+ + {/* 对比图表 */} + + + + + 盈利能力对比 + + + + + + + + + + + 成长能力对比 + + + + + + + +
+
+ )} +
+ ); +}; + +export default StockComparison; diff --git a/src/views/Company/components/FinancialPanorama/components/StockInfoHeader.tsx b/src/views/Company/components/FinancialPanorama/components/StockInfoHeader.tsx new file mode 100644 index 00000000..07d634e4 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/StockInfoHeader.tsx @@ -0,0 +1,111 @@ +/** + * 股票信息头部组件 + */ + +import React from 'react'; +import { + Card, + CardBody, + Grid, + GridItem, + VStack, + HStack, + Text, + Heading, + Badge, + Stat, + StatLabel, + StatNumber, + Alert, + AlertIcon, + Box, +} from '@chakra-ui/react'; +import { formatUtils } from '@services/financialService'; +import type { StockInfoHeaderProps } from '../types'; + +export const StockInfoHeader: React.FC = ({ + stockInfo, + positiveColor, + negativeColor, +}) => { + if (!stockInfo) return null; + + return ( + + + + + + + 股票名称 + + + {stockInfo.stock_name} + {stockInfo.stock_code} + + + + + + 最新EPS + + {stockInfo.key_metrics?.eps?.toFixed(3) || '-'} + + + + + + ROE + + {formatUtils.formatPercent(stockInfo.key_metrics?.roe)} + + + + + + 营收增长 + 0 + ? positiveColor + : negativeColor + : 'gray.500' + } + > + {formatUtils.formatPercent(stockInfo.growth_rates?.revenue_growth)} + + + + + + 利润增长 + 0 + ? positiveColor + : negativeColor + : 'gray.500' + } + > + {formatUtils.formatPercent(stockInfo.growth_rates?.profit_growth)} + + + + + {stockInfo.latest_forecast && ( + + + + {stockInfo.latest_forecast.forecast_type} + {stockInfo.latest_forecast.content} + + + )} + + + ); +}; + +export default StockInfoHeader; diff --git a/src/views/Company/components/FinancialPanorama/components/index.ts b/src/views/Company/components/FinancialPanorama/components/index.ts new file mode 100644 index 00000000..3463c0ca --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/index.ts @@ -0,0 +1,13 @@ +/** + * 组件统一导出 + */ + +export { StockInfoHeader } from './StockInfoHeader'; +export { BalanceSheetTable } from './BalanceSheetTable'; +export { IncomeStatementTable } from './IncomeStatementTable'; +export { CashflowTable } from './CashflowTable'; +export { FinancialMetricsTable } from './FinancialMetricsTable'; +export { MainBusinessAnalysis } from './MainBusinessAnalysis'; +export { IndustryRankingView } from './IndustryRankingView'; +export { StockComparison } from './StockComparison'; +export { ComparisonAnalysis } from './ComparisonAnalysis';