refactor(FinancialPanorama): 重构主组件为模块化结构

从 2,150 行单文件重构为模块化 TypeScript 组件:
- 使用 useFinancialData Hook 管理数据加载
- 组合9个子组件渲染9个Tab面板
- 保留指标图表弹窗功能
- 保留期数选择器功能
- 删除旧的 index.js(2,150行)
- 新增 index.tsx(454行,精简79%)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-12 15:02:05 +08:00
parent b9ea08e601
commit 35e3b66684
2 changed files with 453 additions and 2151 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,453 @@
/**
* 财务全景组件
* 重构后的主组件,使用模块化结构
*/
import React, { useState, ReactNode } from 'react';
import {
Box,
Container,
VStack,
HStack,
Card,
CardBody,
CardHeader,
Heading,
Text,
Badge,
Select,
IconButton,
Alert,
AlertIcon,
Skeleton,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Divider,
Tooltip,
} from '@chakra-ui/react';
import { RepeatIcon } from '@chakra-ui/icons';
import ReactECharts from 'echarts-for-react';
import { formatUtils } from '@services/financialService';
// 内部模块导入
import { useFinancialData } from './hooks';
import { COLORS } from './constants';
import { calculateYoYChange, getCellBackground, getMetricChartOption } from './utils';
import {
StockInfoHeader,
BalanceSheetTable,
IncomeStatementTable,
CashflowTable,
FinancialMetricsTable,
MainBusinessAnalysis,
IndustryRankingView,
StockComparison,
ComparisonAnalysis,
} from './components';
import type { FinancialPanoramaProps } from './types';
/**
* 财务全景主组件
*/
const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propStockCode }) => {
// 使用数据加载 Hook
const {
stockInfo,
balanceSheet,
incomeStatement,
cashflow,
financialMetrics,
mainBusiness,
forecast,
industryRank,
comparison,
loading,
error,
refetch,
currentStockCode,
selectedPeriods,
setSelectedPeriods,
} = useFinancialData({ stockCode: propStockCode });
// UI 状态
const [activeTab, setActiveTab] = useState(0);
const { isOpen, onOpen, onClose } = useDisclosure();
const [modalContent, setModalContent] = useState<ReactNode>(null);
// 颜色配置
const { bgColor, hoverBg, positiveColor, negativeColor, borderColor } = COLORS;
// 点击指标行显示图表
const showMetricChart = (
metricName: string,
metricKey: string,
data: Array<{ period: string; [key: string]: unknown }>,
dataPath: string
) => {
const chartData = data
.map((item) => {
const value = dataPath.split('.').reduce((obj: unknown, key: string) => {
if (obj && typeof obj === 'object') {
return (obj as Record<string, unknown>)[key];
}
return undefined;
}, item) as number | undefined;
return {
period: formatUtils.getReportType(item.period),
date: item.period,
value: value ?? 0,
};
})
.reverse();
const option = getMetricChartOption(metricName, chartData);
setModalContent(
<Box>
<ReactECharts option={option} style={{ height: '400px', width: '100%' }} />
<Divider my={4} />
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th></Th>
<Th isNumeric></Th>
<Th isNumeric></Th>
<Th isNumeric></Th>
</Tr>
</Thead>
<Tbody>
{chartData.map((item, idx) => {
// 计算环比
const qoq =
idx > 0
? ((item.value - chartData[idx - 1].value) /
Math.abs(chartData[idx - 1].value)) *
100
: null;
// 计算同比
const currentDate = new Date(item.date);
const lastYearItem = chartData.find((d) => {
const date = new Date(d.date);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
const yoy = lastYearItem
? ((item.value - lastYearItem.value) / Math.abs(lastYearItem.value)) * 100
: null;
return (
<Tr key={idx}>
<Td>{item.period}</Td>
<Td isNumeric>{formatUtils.formatLargeNumber(item.value)}</Td>
<Td
isNumeric
color={
yoy !== null && yoy > 0
? positiveColor
: yoy !== null && yoy < 0
? negativeColor
: 'gray.500'
}
>
{yoy !== null ? `${yoy.toFixed(2)}%` : '-'}
</Td>
<Td
isNumeric
color={
qoq !== null && qoq > 0
? positiveColor
: qoq !== null && qoq < 0
? negativeColor
: 'gray.500'
}
>
{qoq !== null ? `${qoq.toFixed(2)}%` : '-'}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</TableContainer>
</Box>
);
onOpen();
};
// 通用表格属性
const tableProps = {
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
};
return (
<Container maxW="container.xl" py={5}>
<VStack spacing={6} align="stretch">
{/* 时间选择器 */}
<Card>
<CardBody>
<HStack justify="space-between">
<HStack>
<Text fontSize="sm" color="gray.600">
</Text>
<Select
value={selectedPeriods}
onChange={(e) => setSelectedPeriods(Number(e.target.value))}
w="150px"
size="sm"
>
<option value={4}>4</option>
<option value={8}>8</option>
<option value={12}>12</option>
<option value={16}>16</option>
</Select>
</HStack>
<IconButton
icon={<RepeatIcon />}
onClick={refetch}
isLoading={loading}
variant="outline"
size="sm"
aria-label="刷新数据"
/>
</HStack>
</CardBody>
</Card>
{/* 股票信息头部 */}
{loading ? (
<Skeleton height="150px" />
) : (
<StockInfoHeader
stockInfo={stockInfo}
positiveColor={positiveColor}
negativeColor={negativeColor}
/>
)}
{/* 主要内容区域 */}
{!loading && stockInfo && (
<Tabs
index={activeTab}
onChange={setActiveTab}
variant="enclosed"
colorScheme="blue"
>
<TabList>
<Tab></Tab>
<Tab></Tab>
<Tab></Tab>
<Tab></Tab>
<Tab></Tab>
<Tab></Tab>
<Tab></Tab>
<Tab></Tab>
<Tab></Tab>
</TabList>
<TabPanels>
{/* 财务概览 */}
<TabPanel>
<VStack spacing={4} align="stretch">
<ComparisonAnalysis comparison={comparison} />
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
</VStack>
</TabPanel>
{/* 资产负债表 */}
<TabPanel>
<Card>
<CardHeader>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Heading size="md"></Heading>
<HStack spacing={2}>
<Badge colorScheme="blue">
{Math.min(balanceSheet.length, 8)}
</Badge>
<Text fontSize="sm" color="gray.500">
绿 |
</Text>
</HStack>
</HStack>
<Text fontSize="xs" color="gray.500">
</Text>
</VStack>
</CardHeader>
<CardBody>
<BalanceSheetTable data={balanceSheet} {...tableProps} />
</CardBody>
</Card>
</TabPanel>
{/* 利润表 */}
<TabPanel>
<Card>
<CardHeader>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Heading size="md"></Heading>
<HStack spacing={2}>
<Badge colorScheme="blue">
{Math.min(incomeStatement.length, 8)}
</Badge>
<Text fontSize="sm" color="gray.500">
绿 |
</Text>
</HStack>
</HStack>
<Text fontSize="xs" color="gray.500">
Q1Q3
</Text>
</VStack>
</CardHeader>
<CardBody>
<IncomeStatementTable data={incomeStatement} {...tableProps} />
</CardBody>
</Card>
</TabPanel>
{/* 现金流量表 */}
<TabPanel>
<Card>
<CardHeader>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Heading size="md"></Heading>
<HStack spacing={2}>
<Badge colorScheme="blue">
{Math.min(cashflow.length, 8)}
</Badge>
<Text fontSize="sm" color="gray.500">
绿 |
</Text>
</HStack>
</HStack>
<Text fontSize="xs" color="gray.500">
绿
</Text>
</VStack>
</CardHeader>
<CardBody>
<CashflowTable data={cashflow} {...tableProps} />
</CardBody>
</Card>
</TabPanel>
{/* 财务指标 */}
<TabPanel>
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
</TabPanel>
{/* 主营业务 */}
<TabPanel>
<MainBusinessAnalysis mainBusiness={mainBusiness} />
</TabPanel>
{/* 行业排名 */}
<TabPanel>
<IndustryRankingView
industryRank={industryRank}
bgColor={bgColor}
borderColor={borderColor}
/>
</TabPanel>
{/* 业绩预告 */}
<TabPanel>
{forecast && (
<VStack spacing={4} align="stretch">
{forecast.forecasts?.map((item, idx) => (
<Card key={idx}>
<CardBody>
<HStack justify="space-between" mb={2}>
<Badge colorScheme="blue">{item.forecast_type}</Badge>
<Text fontSize="sm" color="gray.500">
: {item.report_date}
</Text>
</HStack>
<Text mb={2}>{item.content}</Text>
{item.reason && (
<Text fontSize="sm" color="gray.600">
{item.reason}
</Text>
)}
{item.change_range?.lower && (
<HStack mt={2}>
<Text fontSize="sm">:</Text>
<Badge colorScheme="green">
{item.change_range.lower}% ~ {item.change_range.upper}%
</Badge>
</HStack>
)}
</CardBody>
</Card>
))}
</VStack>
)}
</TabPanel>
{/* 股票对比 */}
<TabPanel>
<StockComparison
currentStock={currentStockCode}
stockInfo={stockInfo}
positiveColor={positiveColor}
negativeColor={negativeColor}
/>
</TabPanel>
</TabPanels>
</Tabs>
)}
{/* 错误提示 */}
{error && (
<Alert status="error">
<AlertIcon />
{error}
</Alert>
)}
{/* 弹出模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent maxW="900px">
<ModalHeader></ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>{modalContent}</ModalBody>
</ModalContent>
</Modal>
</VStack>
</Container>
);
};
export default FinancialPanorama;