Merge branch 'feature_2025/251209_stock_pref' into feature_bugfix/251217_stock

* feature_2025/251209_stock_pref: (133 commits)
  chore(StockQuoteCard): 删除未使用的 mockData.ts
  refactor(marketService): 移除 apiRequest 包装函数,统一使用 axios.get
  docs(Company): 添加 API 接口清单到 STRUCTURE.md
  refactor(Company): 提取共享的 useStockSearch Hook
  fix(hooks): 添加 AbortController 解决竞态条件问题
  fix(SubTabContainer): 修复 Tab 懒加载失效问题
  chore(CompanyOverview): 移除未使用的 CompanyOverviewData 类型定义
  fix(CompanyOverview): 修复 useBasicInfo 重复调用问题
  refactor(Company): fetch 请求迁移至 axios
  docs(Company): 更新 STRUCTURE.md 添加数据下沉优化记录
  refactor(StockQuoteCard): 数据下沉优化,Props 从 11 个精简为 4 个
  feat(StockQuoteCard): 新增内部数据获取 hooks
  fix(MarketDataView): 添加缺失的 VStack 导入
  fix(MarketDataView): loading 背景色改为深色与整体一致
  refactor(Company): 统一所有 Tab 的 loading 状态组件
  style(ForecastReport): 详细数据表格 UI 优化
  style(ForecastReport): 盈利预测图表优化
  fix(ValueChainCard): 视图切换按钮始终靠右显示
  refactor(CompanyOverview): 优化多个面板显示逻辑
  style(DetailTable): 简化布局,标题+表格无嵌套
  ...
This commit is contained in:
zdl
2025-12-17 16:06:43 +08:00
182 changed files with 24855 additions and 7624 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
// src/views/Company/components/CompanyHeader/SearchBar.js
// 股票搜索栏组件 - 金色主题 + 模糊搜索下拉
import React, { useState, useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import {
Box,
HStack,
Input,
InputGroup,
InputLeftElement,
Text,
VStack,
} from '@chakra-ui/react';
import { SearchIcon } from '@chakra-ui/icons';
import { useStockSearch } from '../../hooks/useStockSearch';
/**
* 股票搜索栏组件(带模糊搜索下拉)
*
* @param {Object} props
* @param {string} props.inputCode - 输入框当前值
* @param {Function} props.onInputChange - 输入变化回调
* @param {Function} props.onSearch - 搜索按钮点击回调
* @param {Function} props.onKeyDown - 键盘事件回调
*/
const SearchBar = ({
inputCode,
onInputChange,
onSearch,
onKeyDown,
}) => {
// 下拉状态
const [showDropdown, setShowDropdown] = useState(false);
const containerRef = useRef(null);
// 从 Redux 获取全部股票列表
const allStocks = useSelector(state => state.stock.allStocks);
// 使用共享的搜索 Hook
const filteredStocks = useStockSearch(allStocks, inputCode, { limit: 10 });
// 根据搜索结果更新下拉显示状态
useEffect(() => {
setShowDropdown(filteredStocks.length > 0 && !!inputCode?.trim());
}, [filteredStocks, inputCode]);
// 点击外部关闭下拉
useEffect(() => {
const handleClickOutside = (event) => {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 选择股票 - 直接触发搜索跳转
const handleSelectStock = (stock) => {
onInputChange(stock.code);
setShowDropdown(false);
onSearch(stock.code);
};
// 处理键盘事件
const handleKeyDownWrapper = (e) => {
if (e.key === 'Enter') {
setShowDropdown(false);
}
onKeyDown?.(e);
};
return (
<Box ref={containerRef} position="relative" w="300px">
<InputGroup size="lg">
<InputLeftElement pointerEvents="none">
<SearchIcon color="#C9A961" />
</InputLeftElement>
<Input
placeholder="输入股票代码或名称"
value={inputCode}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={handleKeyDownWrapper}
onFocus={() => inputCode && filteredStocks.length > 0 && setShowDropdown(true)}
borderRadius="md"
color="white"
borderColor="#C9A961"
_placeholder={{ color: '#C9A961' }}
_focus={{
borderColor: '#F4D03F',
boxShadow: '0 0 0 1px #F4D03F',
}}
_hover={{
borderColor: '#F4D03F',
}}
/>
</InputGroup>
{/* 模糊搜索下拉列表 */}
{showDropdown && (
<Box
position="absolute"
top="100%"
left={0}
mt={1}
w="100%"
bg="#1A202C"
border="1px solid #C9A961"
borderRadius="md"
maxH="300px"
overflowY="auto"
zIndex={1000}
boxShadow="0 4px 12px rgba(0, 0, 0, 0.3)"
>
<VStack align="stretch" spacing={0}>
{filteredStocks.map((stock) => (
<Box
key={stock.code}
px={4}
py={2}
cursor="pointer"
_hover={{ bg: 'whiteAlpha.100' }}
onClick={() => handleSelectStock(stock)}
borderBottom="1px solid"
borderColor="whiteAlpha.100"
_last={{ borderBottom: 'none' }}
>
<HStack justify="space-between">
<Text color="#F4D03F" fontWeight="bold" fontSize="sm">
{stock.code}
</Text>
<Text color="#C9A961" fontSize="sm" noOfLines={1} maxW="180px">
{stock.name}
</Text>
</HStack>
</Box>
))}
</VStack>
</Box>
)}
</Box>
);
};
export default SearchBar;

View File

@@ -0,0 +1,62 @@
// src/views/Company/components/CompanyHeader/index.js
// 公司详情页面头部区域组件
import React from 'react';
import {
Card,
CardBody,
HStack,
VStack,
Heading,
Text,
} from '@chakra-ui/react';
import SearchBar from './SearchBar';
/**
* 公司详情页面头部区域组件
*
* 包含:
* - 页面标题和描述(金色主题)
* - 股票搜索栏(支持模糊搜索)
*
* @param {Object} props
* @param {string} props.inputCode - 搜索输入框值
* @param {Function} props.onInputChange - 输入变化回调
* @param {Function} props.onSearch - 搜索回调
* @param {Function} props.onKeyDown - 键盘事件回调
* @param {string} props.bgColor - 背景颜色
*/
const CompanyHeader = ({
inputCode,
onInputChange,
onSearch,
onKeyDown,
bgColor,
}) => {
return (
<Card bg={bgColor} shadow="md">
<CardBody>
<HStack justify="space-between" align="center">
{/* 标题区域 - 金色主题 */}
<VStack align="start" spacing={1}>
<Heading size="lg" color="#F4D03F">个股详情</Heading>
<Text color="#C9A961" fontSize="sm">
查看股票实时行情财务数据和盈利预测
</Text>
</VStack>
{/* 搜索栏 */}
<SearchBar
inputCode={inputCode}
onInputChange={onInputChange}
onSearch={onSearch}
onKeyDown={onKeyDown}
/>
</HStack>
</CardBody>
</Card>
);
};
export default CompanyHeader;

View File

@@ -0,0 +1,157 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/AnnouncementsPanel.tsx
// 公司公告 Tab Panel
import React, { useState } from "react";
import {
Box,
VStack,
HStack,
Text,
Badge,
Card,
CardBody,
IconButton,
Button,
Tag,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
useDisclosure,
} from "@chakra-ui/react";
import { ExternalLinkIcon } from "@chakra-ui/icons";
import { useAnnouncementsData } from "../../hooks/useAnnouncementsData";
import { THEME } from "../config";
import { formatDate } from "../utils";
import LoadingState from "./LoadingState";
interface AnnouncementsPanelProps {
stockCode: string;
}
const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode }) => {
const { announcements, loading } = useAnnouncementsData(stockCode);
const { isOpen, onOpen, onClose } = useDisclosure();
const [selectedAnnouncement, setSelectedAnnouncement] = useState<any>(null);
const handleAnnouncementClick = (announcement: any) => {
setSelectedAnnouncement(announcement);
onOpen();
};
if (loading) {
return <LoadingState message="加载公告数据..." />;
}
return (
<>
<VStack spacing={4} align="stretch">
{/* 最新公告 */}
<Box>
<VStack spacing={2} align="stretch">
{announcements.map((announcement: any, idx: number) => (
<Card
key={idx}
bg={THEME.tableBg}
border="1px solid"
borderColor={THEME.border}
size="sm"
cursor="pointer"
onClick={() => handleAnnouncementClick(announcement)}
_hover={{ bg: THEME.tableHoverBg }}
>
<CardBody p={3}>
<HStack justify="space-between">
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Badge size="sm" bg={THEME.gold} color="gray.900">
{announcement.info_type || "公告"}
</Badge>
<Text fontSize="xs" color={THEME.textSecondary}>
{formatDate(announcement.announce_date)}
</Text>
</HStack>
<Text fontSize="sm" fontWeight="medium" noOfLines={1} color={THEME.textPrimary}>
{announcement.title}
</Text>
</VStack>
<HStack>
{announcement.format && (
<Tag size="sm" bg={THEME.tableHoverBg} color={THEME.textSecondary}>
{announcement.format}
</Tag>
)}
<IconButton
size="sm"
icon={<ExternalLinkIcon />}
variant="ghost"
color={THEME.goldLight}
aria-label="查看原文"
onClick={(e) => {
e.stopPropagation();
window.open(announcement.url, "_blank");
}}
/>
</HStack>
</HStack>
</CardBody>
</Card>
))}
</VStack>
</Box>
</VStack>
{/* 公告详情模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent bg={THEME.cardBg}>
<ModalHeader color={THEME.textPrimary}>
<VStack align="start" spacing={1}>
<Text>{selectedAnnouncement?.title}</Text>
<HStack>
<Badge bg={THEME.gold} color="gray.900">
{selectedAnnouncement?.info_type || "公告"}
</Badge>
<Text fontSize="sm" color={THEME.textSecondary}>
{formatDate(selectedAnnouncement?.announce_date)}
</Text>
</HStack>
</VStack>
</ModalHeader>
<ModalCloseButton color={THEME.textPrimary} />
<ModalBody>
<VStack align="start" spacing={3}>
<Text fontSize="sm" color={THEME.textSecondary}>
{selectedAnnouncement?.format || "-"}
</Text>
<Text fontSize="sm" color={THEME.textSecondary}>
{selectedAnnouncement?.file_size || "-"} KB
</Text>
</VStack>
</ModalBody>
<ModalFooter>
<Button
bg={THEME.gold}
color="gray.900"
mr={3}
_hover={{ bg: THEME.goldLight }}
onClick={() => window.open(selectedAnnouncement?.url, "_blank")}
>
</Button>
<Button variant="ghost" color={THEME.textSecondary} onClick={onClose}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
};
export default AnnouncementsPanel;

View File

@@ -0,0 +1,168 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/BranchesPanel.tsx
// 分支机构 Tab Panel - 黑金风格
import React from "react";
import {
Box,
VStack,
HStack,
Text,
Icon,
SimpleGrid,
Center,
} from "@chakra-ui/react";
import { FaSitemap, FaBuilding, FaCheckCircle, FaTimesCircle } from "react-icons/fa";
import { useBranchesData } from "../../hooks/useBranchesData";
import { THEME } from "../config";
import { formatDate } from "../utils";
import LoadingState from "./LoadingState";
interface BranchesPanelProps {
stockCode: string;
}
// 黑金卡片样式
const cardStyles = {
bg: "linear-gradient(145deg, rgba(30, 30, 35, 0.95), rgba(20, 20, 25, 0.98))",
border: "1px solid",
borderColor: "rgba(212, 175, 55, 0.3)",
borderRadius: "12px",
overflow: "hidden",
transition: "all 0.3s ease",
_hover: {
borderColor: "rgba(212, 175, 55, 0.6)",
boxShadow: "0 4px 20px rgba(212, 175, 55, 0.15), inset 0 1px 0 rgba(212, 175, 55, 0.1)",
transform: "translateY(-2px)",
},
};
// 状态徽章样式
const getStatusBadgeStyles = (isActive: boolean) => ({
display: "inline-flex",
alignItems: "center",
gap: "4px",
px: 2,
py: 0.5,
borderRadius: "full",
fontSize: "xs",
fontWeight: "medium",
bg: isActive ? "rgba(212, 175, 55, 0.15)" : "rgba(255, 100, 100, 0.15)",
color: isActive ? THEME.gold : "#ff6b6b",
border: "1px solid",
borderColor: isActive ? "rgba(212, 175, 55, 0.3)" : "rgba(255, 100, 100, 0.3)",
});
// 信息项组件
const InfoItem: React.FC<{ label: string; value: string | number }> = ({ label, value }) => (
<VStack align="start" spacing={0.5}>
<Text fontSize="xs" color={THEME.textSecondary} letterSpacing="0.5px">
{label}
</Text>
<Text fontSize="sm" fontWeight="semibold" color={THEME.textPrimary}>
{value || "-"}
</Text>
</VStack>
);
const BranchesPanel: React.FC<BranchesPanelProps> = ({ stockCode }) => {
const { branches, loading } = useBranchesData(stockCode);
if (loading) {
return <LoadingState message="加载分支机构数据..." />;
}
if (branches.length === 0) {
return (
<Center h="200px">
<VStack spacing={3}>
<Box
p={4}
borderRadius="full"
bg="rgba(212, 175, 55, 0.1)"
border="1px solid"
borderColor="rgba(212, 175, 55, 0.2)"
>
<Icon as={FaSitemap} boxSize={10} color={THEME.gold} opacity={0.6} />
</Box>
<Text color={THEME.textSecondary} fontSize="sm">
</Text>
</VStack>
</Center>
);
}
return (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{branches.map((branch: any, idx: number) => {
const isActive = branch.business_status === "存续";
return (
<Box key={idx} sx={cardStyles}>
{/* 顶部金色装饰线 */}
<Box
h="2px"
bgGradient="linear(to-r, transparent, rgba(212, 175, 55, 0.6), transparent)"
/>
<Box p={4}>
<VStack align="start" spacing={4}>
{/* 标题行 */}
<HStack justify="space-between" w="full" align="flex-start">
<HStack spacing={2} flex={1}>
<Box
p={1.5}
borderRadius="md"
bg="rgba(212, 175, 55, 0.1)"
>
<Icon as={FaBuilding} boxSize={3.5} color={THEME.gold} />
</Box>
<Text
fontWeight="bold"
color={THEME.textPrimary}
fontSize="sm"
noOfLines={2}
lineHeight="tall"
>
{branch.branch_name}
</Text>
</HStack>
{/* 状态徽章 */}
<Box sx={getStatusBadgeStyles(isActive)}>
<Icon
as={isActive ? FaCheckCircle : FaTimesCircle}
boxSize={3}
/>
<Text>{branch.business_status}</Text>
</Box>
</HStack>
{/* 分隔线 */}
<Box
w="full"
h="1px"
bgGradient="linear(to-r, rgba(212, 175, 55, 0.3), transparent)"
/>
{/* 信息网格 */}
<SimpleGrid columns={2} spacing={3} w="full">
<InfoItem label="注册资本" value={branch.register_capital} />
<InfoItem label="法人代表" value={branch.legal_person} />
<InfoItem label="成立日期" value={formatDate(branch.register_date)} />
<InfoItem
label="关联企业"
value={`${branch.related_company_count || 0}`}
/>
</SimpleGrid>
</VStack>
</Box>
</Box>
);
})}
</SimpleGrid>
);
};
export default BranchesPanel;

View File

@@ -0,0 +1,121 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/BusinessInfoPanel.tsx
// 工商信息 Tab Panel
import React from "react";
import {
Box,
VStack,
HStack,
Text,
Heading,
SimpleGrid,
Divider,
Center,
Code,
Spinner,
} from "@chakra-ui/react";
import { THEME } from "../config";
import { useBasicInfo } from "../../hooks/useBasicInfo";
interface BusinessInfoPanelProps {
stockCode: string;
}
const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ stockCode }) => {
const { basicInfo, loading } = useBasicInfo(stockCode);
if (loading) {
return (
<Center h="200px">
<Spinner size="lg" color={THEME.gold} />
</Center>
);
}
if (!basicInfo) {
return (
<Center h="200px">
<Text color={THEME.textSecondary}></Text>
</Center>
);
}
return (
<VStack spacing={4} align="stretch">
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
<Box>
<Heading size="sm" mb={3} color={THEME.textPrimary}></Heading>
<VStack align="start" spacing={2}>
<HStack w="full">
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
</Text>
<Code fontSize="xs" bg={THEME.tableHoverBg} color={THEME.goldLight}>
{basicInfo.credit_code}
</Code>
</HStack>
<HStack w="full">
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
</Text>
<Text fontSize="sm" color={THEME.textPrimary}>{basicInfo.company_size}</Text>
</HStack>
<HStack w="full" align="start">
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
</Text>
<Text fontSize="sm" noOfLines={2} color={THEME.textPrimary}>
{basicInfo.reg_address}
</Text>
</HStack>
<HStack w="full" align="start">
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
</Text>
<Text fontSize="sm" noOfLines={2} color={THEME.textPrimary}>
{basicInfo.office_address}
</Text>
</HStack>
</VStack>
</Box>
<Box>
<Heading size="sm" mb={3} color={THEME.textPrimary}></Heading>
<VStack align="start" spacing={2}>
<Box>
<Text fontSize="sm" color={THEME.textSecondary}></Text>
<Text fontSize="sm" fontWeight="medium" color={THEME.textPrimary}>
{basicInfo.accounting_firm}
</Text>
</Box>
<Box>
<Text fontSize="sm" color={THEME.textSecondary}></Text>
<Text fontSize="sm" fontWeight="medium" color={THEME.textPrimary}>
{basicInfo.law_firm}
</Text>
</Box>
</VStack>
</Box>
</SimpleGrid>
<Divider borderColor={THEME.border} />
<Box>
<Heading size="sm" mb={3} color={THEME.textPrimary}></Heading>
<Text fontSize="sm" lineHeight="tall" color={THEME.textSecondary}>
{basicInfo.main_business}
</Text>
</Box>
<Box>
<Heading size="sm" mb={3} color={THEME.textPrimary}></Heading>
<Text fontSize="sm" lineHeight="tall" color={THEME.textSecondary}>
{basicInfo.business_scope}
</Text>
</Box>
</VStack>
);
};
export default BusinessInfoPanel;

View File

@@ -0,0 +1,76 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel.tsx
// 财报披露日程 Tab Panel
import React from "react";
import {
Box,
VStack,
Text,
Badge,
Card,
CardBody,
SimpleGrid,
} from "@chakra-ui/react";
import { useDisclosureData } from "../../hooks/useDisclosureData";
import { THEME } from "../config";
import { formatDate } from "../utils";
import LoadingState from "./LoadingState";
interface DisclosureSchedulePanelProps {
stockCode: string;
}
const DisclosureSchedulePanel: React.FC<DisclosureSchedulePanelProps> = ({ stockCode }) => {
const { disclosureSchedule, loading } = useDisclosureData(stockCode);
if (loading) {
return <LoadingState message="加载披露日程..." />;
}
if (disclosureSchedule.length === 0) {
return (
<Box textAlign="center" py={8}>
<Text color={THEME.textSecondary}></Text>
</Box>
);
}
return (
<VStack spacing={4} align="stretch">
<Box>
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
{disclosureSchedule.map((schedule: any, idx: number) => (
<Card
key={idx}
bg={schedule.is_disclosed ? "green.900" : "orange.900"}
border="1px solid"
borderColor={schedule.is_disclosed ? "green.600" : "orange.600"}
size="sm"
>
<CardBody p={3}>
<VStack spacing={1}>
<Badge colorScheme={schedule.is_disclosed ? "green" : "orange"}>
{schedule.report_name}
</Badge>
<Text fontSize="sm" fontWeight="bold" color={THEME.textPrimary}>
{schedule.is_disclosed ? "已披露" : "预计"}
</Text>
<Text fontSize="xs" color={THEME.textSecondary}>
{formatDate(
schedule.is_disclosed
? schedule.actual_date
: schedule.latest_scheduled_date
)}
</Text>
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
</Box>
</VStack>
);
};
export default DisclosureSchedulePanel;

View File

@@ -0,0 +1,32 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx
// 复用的加载状态组件
import React from "react";
import { Center, VStack, Spinner, Text } from "@chakra-ui/react";
import { THEME } from "../config";
interface LoadingStateProps {
message?: string;
height?: string;
}
/**
* 加载状态组件(黑金主题)
*/
const LoadingState: React.FC<LoadingStateProps> = ({
message = "加载中...",
height = "200px",
}) => {
return (
<Center h={height}>
<VStack>
<Spinner size="lg" color={THEME.gold} thickness="3px" />
<Text fontSize="sm" color={THEME.textSecondary}>
{message}
</Text>
</VStack>
</Center>
);
};
export default LoadingState;

View File

@@ -0,0 +1,60 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/ShareholderPanel.tsx
// 股权结构 Tab Panel - 使用拆分后的子组件
import React from "react";
import { SimpleGrid, Box } from "@chakra-ui/react";
import { useShareholderData } from "../../hooks/useShareholderData";
import {
ActualControlCard,
ConcentrationCard,
ShareholdersTable,
} from "../../components/shareholder";
import TabPanelContainer from "@components/TabPanelContainer";
interface ShareholderPanelProps {
stockCode: string;
}
/**
* 股权结构面板
* 使用拆分后的子组件:
* - ActualControlCard: 实际控制人卡片
* - ConcentrationCard: 股权集中度卡片
* - ShareholdersTable: 股东表格(合并版,支持十大股东和十大流通股东)
*/
const ShareholderPanel: React.FC<ShareholderPanelProps> = ({ stockCode }) => {
const {
actualControl,
concentration,
topShareholders,
topCirculationShareholders,
loading,
} = useShareholderData(stockCode);
return (
<TabPanelContainer loading={loading} loadingMessage="加载股权结构数据...">
{/* 实际控制人 + 股权集中度 左右分布 */}
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
<Box>
<ActualControlCard actualControl={actualControl} />
</Box>
<Box>
<ConcentrationCard concentration={concentration} />
</Box>
</SimpleGrid>
{/* 十大股东 + 十大流通股东 左右分布 */}
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
<Box>
<ShareholdersTable type="top" shareholders={topShareholders} />
</Box>
<Box>
<ShareholdersTable type="circulation" shareholders={topCirculationShareholders} />
</Box>
</SimpleGrid>
</TabPanelContainer>
);
};
export default ShareholderPanel;

View File

@@ -0,0 +1,11 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/index.ts
// 组件导出
export { default as LoadingState } from "./LoadingState";
// TabPanelContainer 已提升为通用组件,从 @components/TabPanelContainer 导入
export { default as TabPanelContainer } from "@components/TabPanelContainer";
export { default as ShareholderPanel } from "./ShareholderPanel";
export { ManagementPanel } from "./management";
export { default as AnnouncementsPanel } from "./AnnouncementsPanel";
export { default as BranchesPanel } from "./BranchesPanel";
export { default as BusinessInfoPanel } from "./BusinessInfoPanel";

View File

@@ -0,0 +1,63 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/CategorySection.tsx
// 管理层分类区块组件
import React, { memo } from "react";
import {
Box,
HStack,
Heading,
Badge,
Icon,
SimpleGrid,
} from "@chakra-ui/react";
import type { IconType } from "react-icons";
import { THEME } from "../../config";
import ManagementCard from "./ManagementCard";
import type { ManagementPerson, ManagementCategory } from "./types";
interface CategorySectionProps {
category: ManagementCategory;
people: ManagementPerson[];
icon: IconType;
color: string;
}
const CategorySection: React.FC<CategorySectionProps> = ({
category,
people,
icon,
color,
}) => {
if (people.length === 0) {
return null;
}
return (
<Box>
{/* 分类标题 */}
<HStack mb={4}>
<Icon as={icon} color={color} boxSize={5} />
<Heading size="sm" color={THEME.textPrimary}>
{category}
</Heading>
<Badge bg={THEME.gold} color="gray.900">
{people.length}
</Badge>
</HStack>
{/* 人员卡片网格 */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{people.map((person, idx) => (
<ManagementCard
key={`${person.name}-${idx}`}
person={person}
categoryColor={color}
/>
))}
</SimpleGrid>
</Box>
);
};
export default memo(CategorySection);

View File

@@ -0,0 +1,100 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/ManagementCard.tsx
// 管理人员卡片组件
import React, { memo } from "react";
import {
HStack,
VStack,
Text,
Icon,
Card,
CardBody,
Avatar,
Tag,
} from "@chakra-ui/react";
import {
FaVenusMars,
FaGraduationCap,
FaPassport,
} from "react-icons/fa";
import { THEME } from "../../config";
import { formatDate } from "../../utils";
import type { ManagementPerson } from "./types";
interface ManagementCardProps {
person: ManagementPerson;
categoryColor: string;
}
const ManagementCard: React.FC<ManagementCardProps> = ({ person, categoryColor }) => {
const currentYear = new Date().getFullYear();
const age = person.birth_year ? currentYear - parseInt(person.birth_year, 10) : null;
return (
<Card
bg={THEME.tableBg}
border="1px solid"
borderColor={THEME.border}
size="sm"
>
<CardBody>
<HStack spacing={3} align="start">
<Avatar
name={person.name}
size="md"
bg={categoryColor}
/>
<VStack align="start" spacing={1} flex={1}>
{/* 姓名和性别 */}
<HStack>
<Text fontWeight="bold" color={THEME.textPrimary}>
{person.name}
</Text>
{person.gender && (
<Icon
as={FaVenusMars}
color={person.gender === "男" ? "blue.400" : "pink.400"}
boxSize={3}
/>
)}
</HStack>
{/* 职位 */}
<Text fontSize="sm" color={THEME.goldLight}>
{person.position_name}
</Text>
{/* 标签:学历、年龄、国籍 */}
<HStack spacing={2} flexWrap="wrap">
{person.education && (
<Tag size="sm" bg={THEME.tableHoverBg} color={THEME.textSecondary}>
<Icon as={FaGraduationCap} mr={1} boxSize={3} />
{person.education}
</Tag>
)}
{age && (
<Tag size="sm" bg={THEME.tableHoverBg} color={THEME.textSecondary}>
{age}
</Tag>
)}
{person.nationality && person.nationality !== "中国" && (
<Tag size="sm" bg="orange.600" color="white">
<Icon as={FaPassport} mr={1} boxSize={3} />
{person.nationality}
</Tag>
)}
</HStack>
{/* 任职日期 */}
<Text fontSize="xs" color={THEME.textSecondary}>
{formatDate(person.start_date)}
</Text>
</VStack>
</HStack>
</CardBody>
</Card>
);
};
export default memo(ManagementCard);

View File

@@ -0,0 +1,100 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/ManagementPanel.tsx
// 管理团队 Tab Panel重构版
import React, { useMemo } from "react";
import {
FaUserTie,
FaCrown,
FaEye,
FaUsers,
} from "react-icons/fa";
import { useManagementData } from "../../../hooks/useManagementData";
import { THEME } from "../../config";
import TabPanelContainer from "@components/TabPanelContainer";
import CategorySection from "./CategorySection";
import type {
ManagementPerson,
ManagementCategory,
CategorizedManagement,
CategoryConfig,
} from "./types";
interface ManagementPanelProps {
stockCode: string;
}
/**
* 分类配置映射
*/
const CATEGORY_CONFIG: Record<ManagementCategory, CategoryConfig> = {
: { icon: FaUserTie, color: THEME.gold },
: { icon: FaCrown, color: THEME.goldLight },
: { icon: FaEye, color: "green.400" },
: { icon: FaUsers, color: THEME.textSecondary },
};
/**
* 分类顺序
*/
const CATEGORY_ORDER: ManagementCategory[] = ["高管", "董事", "监事", "其他"];
/**
* 根据职位信息对管理人员进行分类
*/
const categorizeManagement = (management: ManagementPerson[]): CategorizedManagement => {
const categories: CategorizedManagement = {
: [],
: [],
: [],
: [],
};
management.forEach((person) => {
const positionCategory = person.position_category;
const positionName = person.position_name || "";
if (positionCategory === "高管" || positionName.includes("总")) {
categories["高管"].push(person);
} else if (positionCategory === "董事" || positionName.includes("董事")) {
categories["董事"].push(person);
} else if (positionCategory === "监事" || positionName.includes("监事")) {
categories["监事"].push(person);
} else {
categories["其他"].push(person);
}
});
return categories;
};
const ManagementPanel: React.FC<ManagementPanelProps> = ({ stockCode }) => {
const { management, loading } = useManagementData(stockCode);
// 使用 useMemo 缓存分类计算结果
const categorizedManagement = useMemo(
() => categorizeManagement(management as ManagementPerson[]),
[management]
);
return (
<TabPanelContainer loading={loading} loadingMessage="加载管理团队数据...">
{CATEGORY_ORDER.map((category) => {
const config = CATEGORY_CONFIG[category];
const people = categorizedManagement[category];
return (
<CategorySection
key={category}
category={category}
people={people}
icon={config.icon}
color={config.color}
/>
);
})}
</TabPanelContainer>
);
};
export default ManagementPanel;

View File

@@ -0,0 +1,7 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/index.ts
// 管理团队组件导出
export { default as ManagementPanel } from "./ManagementPanel";
export { default as ManagementCard } from "./ManagementCard";
export { default as CategorySection } from "./CategorySection";
export * from "./types";

View File

@@ -0,0 +1,36 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/types.ts
// 管理团队相关类型定义
import type { IconType } from "react-icons";
/**
* 管理人员信息
*/
export interface ManagementPerson {
name: string;
position_name?: string;
position_category?: string;
gender?: "男" | "女";
education?: string;
birth_year?: string;
nationality?: string;
start_date?: string;
}
/**
* 管理层分类
*/
export type ManagementCategory = "高管" | "董事" | "监事" | "其他";
/**
* 分类后的管理层数据
*/
export type CategorizedManagement = Record<ManagementCategory, ManagementPerson[]>;
/**
* 分类配置项
*/
export interface CategoryConfig {
icon: IconType;
color: string;
}

View File

@@ -0,0 +1,96 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/config.ts
// Tab 配置 + 黑金主题配置
import { IconType } from "react-icons";
import {
FaShareAlt,
FaUserTie,
FaSitemap,
FaInfoCircle,
} from "react-icons/fa";
// 主题类型定义
export interface Theme {
bg: string;
cardBg: string;
tableBg: string;
tableHoverBg: string;
gold: string;
goldLight: string;
textPrimary: string;
textSecondary: string;
border: string;
tabSelected: {
bg: string;
color: string;
};
tabUnselected: {
color: string;
};
}
// 黑金主题配置
export const THEME: Theme = {
bg: "gray.900",
cardBg: "gray.800",
tableBg: "gray.700",
tableHoverBg: "gray.600",
gold: "#D4AF37",
goldLight: "#F0D78C",
textPrimary: "white",
textSecondary: "gray.400",
border: "rgba(212, 175, 55, 0.3)",
tabSelected: {
bg: "#D4AF37",
color: "gray.900",
},
tabUnselected: {
color: "#D4AF37",
},
};
// Tab 配置类型
export interface TabConfig {
key: string;
name: string;
icon: IconType;
enabled: boolean;
}
// Tab 配置
export const TAB_CONFIG: TabConfig[] = [
{
key: "shareholder",
name: "股权结构",
icon: FaShareAlt,
enabled: true,
},
{
key: "management",
name: "管理团队",
icon: FaUserTie,
enabled: true,
},
{
key: "branches",
name: "分支机构",
icon: FaSitemap,
enabled: true,
},
{
key: "business",
name: "工商信息",
icon: FaInfoCircle,
enabled: true,
},
];
// 获取启用的 Tab 列表
export const getEnabledTabs = (enabledKeys?: string[]): TabConfig[] => {
if (!enabledKeys || enabledKeys.length === 0) {
return TAB_CONFIG.filter((tab) => tab.enabled);
}
return TAB_CONFIG.filter(
(tab) => tab.enabled && enabledKeys.includes(tab.key)
);
};

View File

@@ -0,0 +1,87 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/index.tsx
// 基本信息 Tab 组件 - 使用 SubTabContainer 通用组件
import React, { useMemo } from "react";
import { Card, CardBody } from "@chakra-ui/react";
import SubTabContainer, { type SubTabConfig } from "@components/SubTabContainer";
import { THEME, TAB_CONFIG, getEnabledTabs } from "./config";
import {
ShareholderPanel,
ManagementPanel,
AnnouncementsPanel,
BranchesPanel,
BusinessInfoPanel,
} from "./components";
// Props 类型定义
export interface BasicInfoTabProps {
stockCode: string;
// 可配置项
enabledTabs?: string[]; // 指定显示哪些 Tab通过 key
defaultTabIndex?: number; // 默认选中 Tab
onTabChange?: (index: number, tabKey: string) => void;
}
// Tab 组件映射
const TAB_COMPONENTS: Record<string, React.FC<any>> = {
shareholder: ShareholderPanel,
management: ManagementPanel,
announcements: AnnouncementsPanel,
branches: BranchesPanel,
business: BusinessInfoPanel,
};
/**
* 构建 SubTabContainer 所需的 tabs 配置
*/
const buildTabsConfig = (enabledKeys?: string[]): SubTabConfig[] => {
const enabledTabs = getEnabledTabs(enabledKeys);
return enabledTabs.map((tab) => ({
key: tab.key,
name: tab.name,
icon: tab.icon,
component: TAB_COMPONENTS[tab.key],
}));
};
/**
* 基本信息 Tab 组件
*
* 特性:
* - 使用 SubTabContainer 通用组件
* - 可配置显示哪些 TabenabledTabs
* - 黑金主题
* - 懒加载
* - 支持 Tab 变更回调
*/
const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
stockCode,
enabledTabs,
defaultTabIndex = 0,
onTabChange,
}) => {
// 构建 tabs 配置(缓存避免重复计算)
const tabs = useMemo(() => buildTabsConfig(enabledTabs), [enabledTabs]);
return (
<Card bg={THEME.cardBg} shadow="md" border="1px solid" borderColor={THEME.border}>
<CardBody p={0}>
<SubTabContainer
tabs={tabs}
componentProps={{ stockCode }}
defaultIndex={defaultTabIndex}
onTabChange={onTabChange}
themePreset="blackGold"
/>
</CardBody>
</Card>
);
};
export default BasicInfoTab;
// 导出配置和工具,供外部使用
export { THEME, TAB_CONFIG, getEnabledTabs } from "./config";
export * from "./utils";

View File

@@ -0,0 +1,52 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/utils.ts
// 格式化工具函数
/**
* 格式化百分比
*/
export const formatPercentage = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
return `${(value * 100).toFixed(2)}%`;
};
/**
* 格式化数字(自动转换亿/万)
*/
export const formatNumber = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
if (value >= 100000000) {
return `${(value / 100000000).toFixed(2)}亿`;
} else if (value >= 10000) {
return `${(value / 10000).toFixed(2)}`;
}
return value.toLocaleString();
};
/**
* 格式化股数(自动转换亿股/万股)
*/
export const formatShares = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
if (value >= 100000000) {
return `${(value / 100000000).toFixed(2)}亿股`;
} else if (value >= 10000) {
return `${(value / 10000).toFixed(2)}万股`;
}
return `${value.toLocaleString()}`;
};
/**
* 格式化日期(去掉时间部分)
*/
export const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return "-";
return dateStr.split("T")[0];
};
// 导出工具对象(兼容旧代码)
export const formatUtils = {
formatPercentage,
formatNumber,
formatShares,
formatDate,
};

View File

@@ -0,0 +1,94 @@
/**
* 业务结构树形项组件
*
* 递归显示业务结构层级
* 使用位置:业务结构分析卡片
* 黑金主题风格
*/
import React from 'react';
import { Box, HStack, VStack, Text, Badge, Tag, TagLabel } from '@chakra-ui/react';
import { formatPercentage, formatBusinessRevenue } from '@utils/priceFormatters';
import type { BusinessTreeItemProps } from '../types';
// 黑金主题配置
const THEME = {
bg: 'gray.700',
gold: '#D4AF37',
goldLight: '#F0D78C',
textPrimary: '#D4AF37',
textSecondary: 'gray.400',
border: 'rgba(212, 175, 55, 0.5)',
};
const BusinessTreeItem: React.FC<BusinessTreeItemProps> = ({ business, depth = 0 }) => {
// 获取营收显示
const getRevenueDisplay = (): string => {
const revenue = business.revenue || business.financial_metrics?.revenue;
const unit = business.revenue_unit;
if (revenue !== undefined && revenue !== null) {
return formatBusinessRevenue(revenue, unit);
}
return '-';
};
return (
<Box
ml={depth * 6}
p={3}
bg={THEME.bg}
borderLeft={depth > 0 ? '4px solid' : 'none'}
borderLeftColor={THEME.gold}
borderRadius="md"
mb={2}
_hover={{ shadow: 'md', bg: 'gray.600' }}
transition="all 0.2s"
>
<HStack justify="space-between">
<VStack align="start" spacing={1}>
<HStack>
<Text fontWeight="bold" fontSize={depth === 0 ? 'md' : 'sm'} color={THEME.textPrimary}>
{business.business_name}
</Text>
{business.financial_metrics?.revenue_ratio &&
business.financial_metrics.revenue_ratio > 30 && (
<Badge bg={THEME.gold} color="gray.900" size="sm">
</Badge>
)}
</HStack>
<HStack spacing={4} flexWrap="wrap">
<Tag size="sm" bg="gray.600" color={THEME.textPrimary}>
: {formatPercentage(business.financial_metrics?.revenue_ratio)}
</Tag>
<Tag size="sm" bg="gray.600" color={THEME.textPrimary}>
: {formatPercentage(business.financial_metrics?.gross_margin)}
</Tag>
{business.growth_metrics?.revenue_growth !== undefined && (
<Tag
size="sm"
bg={business.growth_metrics.revenue_growth > 0 ? 'red.600' : 'green.600'}
color="white"
>
<TagLabel>
: {business.growth_metrics.revenue_growth > 0 ? '+' : ''}
{formatPercentage(business.growth_metrics.revenue_growth)}
</TagLabel>
</Tag>
)}
</HStack>
</VStack>
<VStack align="end" spacing={0}>
<Text fontSize="lg" fontWeight="bold" color={THEME.gold}>
{getRevenueDisplay()}
</Text>
<Text fontSize="xs" color={THEME.textSecondary}>
</Text>
</VStack>
</HStack>
</Box>
);
};
export default BusinessTreeItem;

View File

@@ -0,0 +1,24 @@
/**
* 免责声明组件
*
* 显示 AI 分析内容的免责声明提示
* 使用位置:深度分析各 Card 底部(共 6 处)
*/
import React from 'react';
import { Text } from '@chakra-ui/react';
const DisclaimerBox: React.FC = () => {
return (
<Text
mb={4}
color="gray.500"
fontSize="12px"
lineHeight="1.5"
>
AI模型基于新闻
</Text>
);
};
export default DisclaimerBox;

View File

@@ -0,0 +1,128 @@
/**
* 关键因素卡片组件
*
* 显示单个关键因素的详细信息
* 使用位置:关键因素 Accordion 内
* 黑金主题设计
*/
import React from 'react';
import {
Card,
CardBody,
VStack,
HStack,
Text,
Badge,
Tag,
Icon,
} from '@chakra-ui/react';
import { FaArrowUp, FaArrowDown } from 'react-icons/fa';
import type { KeyFactorCardProps, ImpactDirection } from '../types';
// 黑金主题样式常量
const THEME = {
cardBg: '#252D3A',
textColor: '#E2E8F0',
subtextColor: '#A0AEC0',
} as const;
/**
* 获取影响方向对应的颜色
*/
const getImpactColor = (direction?: ImpactDirection): string => {
const colorMap: Record<ImpactDirection, string> = {
positive: 'red',
negative: 'green',
neutral: 'gray',
mixed: 'yellow',
};
return colorMap[direction || 'neutral'] || 'gray';
};
/**
* 获取影响方向的中文标签
*/
const getImpactLabel = (direction?: ImpactDirection): string => {
const labelMap: Record<ImpactDirection, string> = {
positive: '正面',
negative: '负面',
neutral: '中性',
mixed: '混合',
};
return labelMap[direction || 'neutral'] || '中性';
};
const KeyFactorCard: React.FC<KeyFactorCardProps> = ({ factor }) => {
const impactColor = getImpactColor(factor.impact_direction);
return (
<Card
bg={THEME.cardBg}
border="1px solid"
borderColor="whiteAlpha.100"
size="sm"
>
<CardBody p={3}>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Text fontWeight="medium" fontSize="sm" color={THEME.textColor}>
{factor.factor_name}
</Text>
<Badge
bg="transparent"
border="1px solid"
borderColor={`${impactColor}.400`}
color={`${impactColor}.400`}
size="sm"
>
{getImpactLabel(factor.impact_direction)}
</Badge>
</HStack>
<HStack spacing={2}>
<Text fontSize="lg" fontWeight="bold" color={`${impactColor}.400`}>
{factor.factor_value}
{factor.factor_unit && ` ${factor.factor_unit}`}
</Text>
{factor.year_on_year !== undefined && (
<Tag
size="sm"
bg="transparent"
border="1px solid"
borderColor={factor.year_on_year > 0 ? 'red.400' : 'green.400'}
color={factor.year_on_year > 0 ? 'red.400' : 'green.400'}
>
<Icon
as={factor.year_on_year > 0 ? FaArrowUp : FaArrowDown}
mr={1}
boxSize={3}
/>
{Math.abs(factor.year_on_year)}%
</Tag>
)}
</HStack>
{factor.factor_desc && (
<Text fontSize="xs" color={THEME.subtextColor} noOfLines={2}>
{factor.factor_desc}
</Text>
)}
<HStack justify="space-between">
<Text fontSize="xs" color={THEME.subtextColor}>
: {factor.impact_weight}
</Text>
{factor.report_period && (
<Text fontSize="xs" color={THEME.subtextColor}>
{factor.report_period}
</Text>
)}
</HStack>
</VStack>
</CardBody>
</Card>
);
};
export default KeyFactorCard;

View File

@@ -0,0 +1,170 @@
/**
* 产业链流程式导航组件
*
* 显示上游 → 核心 → 下游的流程式导航
* 带图标箭头连接符
*/
import React, { memo } from 'react';
import { HStack, VStack, Box, Text, Icon, Badge } from '@chakra-ui/react';
import { FaArrowRight } from 'react-icons/fa';
// 黑金主题配置
const THEME = {
gold: '#D4AF37',
textSecondary: 'gray.400',
upstream: {
active: 'orange.500',
activeBg: 'orange.900',
inactive: 'white',
inactiveBg: 'gray.700',
},
core: {
active: 'blue.500',
activeBg: 'blue.900',
inactive: 'white',
inactiveBg: 'gray.700',
},
downstream: {
active: 'green.500',
activeBg: 'green.900',
inactive: 'white',
inactiveBg: 'gray.700',
},
};
export type TabType = 'upstream' | 'core' | 'downstream';
interface ProcessNavigationProps {
activeTab: TabType;
onTabChange: (tab: TabType) => void;
upstreamCount: number;
coreCount: number;
downstreamCount: number;
}
interface NavItemProps {
label: string;
subtitle: string;
count: number;
isActive: boolean;
colorKey: 'upstream' | 'core' | 'downstream';
onClick: () => void;
}
const NavItem: React.FC<NavItemProps> = memo(({
label,
subtitle,
count,
isActive,
colorKey,
onClick,
}) => {
const colors = THEME[colorKey];
return (
<Box
px={4}
py={2}
borderRadius="lg"
cursor="pointer"
bg={isActive ? colors.activeBg : colors.inactiveBg}
borderWidth={2}
borderColor={isActive ? colors.active : 'gray.600'}
onClick={onClick}
transition="all 0.2s"
_hover={{
borderColor: colors.active,
transform: 'translateY(-2px)',
}}
>
<VStack spacing={1} align="center">
<HStack spacing={2}>
<Text
fontWeight={isActive ? 'bold' : 'medium'}
color={isActive ? colors.active : colors.inactive}
fontSize="sm"
>
{label}
</Text>
<Badge
bg={isActive ? colors.active : 'gray.600'}
color="white"
borderRadius="full"
px={2}
fontSize="xs"
>
{count}
</Badge>
</HStack>
<Text
fontSize="xs"
color={THEME.textSecondary}
>
{subtitle}
</Text>
</VStack>
</Box>
);
});
NavItem.displayName = 'NavItem';
const ProcessNavigation: React.FC<ProcessNavigationProps> = memo(({
activeTab,
onTabChange,
upstreamCount,
coreCount,
downstreamCount,
}) => {
return (
<HStack
spacing={2}
flexWrap="wrap"
gap={2}
>
<NavItem
label="上游供应链"
subtitle="原材料与供应商"
count={upstreamCount}
isActive={activeTab === 'upstream'}
colorKey="upstream"
onClick={() => onTabChange('upstream')}
/>
<Icon
as={FaArrowRight}
color={THEME.textSecondary}
boxSize={4}
/>
<NavItem
label="核心企业"
subtitle="公司主体与产品"
count={coreCount}
isActive={activeTab === 'core'}
colorKey="core"
onClick={() => onTabChange('core')}
/>
<Icon
as={FaArrowRight}
color={THEME.textSecondary}
boxSize={4}
/>
<NavItem
label="下游客户"
subtitle="客户与终端市场"
count={downstreamCount}
isActive={activeTab === 'downstream'}
colorKey="downstream"
onClick={() => onTabChange('downstream')}
/>
</HStack>
);
});
ProcessNavigation.displayName = 'ProcessNavigation';
export default ProcessNavigation;

View File

@@ -0,0 +1,51 @@
/**
* 评分进度条组件
*
* 显示带图标的评分进度条
* 使用位置:竞争力分析区域(共 8 处)
*/
import React from 'react';
import { Box, HStack, Text, Badge, Progress, Icon } from '@chakra-ui/react';
import type { ScoreBarProps } from '../types';
/**
* 根据分数百分比获取颜色方案
*/
const getColorScheme = (percentage: number): string => {
if (percentage >= 80) return 'purple';
if (percentage >= 60) return 'blue';
if (percentage >= 40) return 'yellow';
return 'orange';
};
const ScoreBar: React.FC<ScoreBarProps> = ({ label, score, icon }) => {
const percentage = ((score || 0) / 100) * 100;
const colorScheme = getColorScheme(percentage);
return (
<Box>
<HStack justify="space-between" mb={1}>
<HStack>
{icon && (
<Icon as={icon} boxSize={4} color={`${colorScheme}.500`} />
)}
<Text fontSize="sm" fontWeight="medium">
{label}
</Text>
</HStack>
<Badge colorScheme={colorScheme}>{score || 0}</Badge>
</HStack>
<Progress
value={percentage}
size="sm"
colorScheme={colorScheme}
borderRadius="full"
hasStripe
isAnimated
/>
</Box>
);
};
export default ScoreBar;

View File

@@ -0,0 +1,151 @@
/**
* 产业链筛选栏组件
*
* 提供类型筛选、重要度筛选和视图切换功能
*/
import React, { memo } from 'react';
import {
HStack,
Select,
Tabs,
TabList,
Tab,
} from '@chakra-ui/react';
// 黑金主题配置
const THEME = {
gold: '#D4AF37',
textPrimary: '#D4AF37',
textSecondary: 'gray.400',
inputBg: 'gray.700',
inputBorder: 'gray.600',
};
export type ViewMode = 'hierarchy' | 'flow';
// 节点类型选项
const TYPE_OPTIONS = [
{ value: 'all', label: '全部类型' },
{ value: 'company', label: '公司' },
{ value: 'supplier', label: '供应商' },
{ value: 'customer', label: '客户' },
{ value: 'regulator', label: '监管机构' },
{ value: 'product', label: '产品' },
{ value: 'service', label: '服务' },
{ value: 'channel', label: '渠道' },
{ value: 'raw_material', label: '原材料' },
{ value: 'end_user', label: '终端用户' },
];
// 重要度选项
const IMPORTANCE_OPTIONS = [
{ value: 'all', label: '全部重要度' },
{ value: 'high', label: '高 (≥80)' },
{ value: 'medium', label: '中 (50-79)' },
{ value: 'low', label: '低 (<50)' },
];
interface ValueChainFilterBarProps {
typeFilter: string;
onTypeChange: (value: string) => void;
importanceFilter: string;
onImportanceChange: (value: string) => void;
viewMode: ViewMode;
onViewModeChange: (value: ViewMode) => void;
}
const ValueChainFilterBar: React.FC<ValueChainFilterBarProps> = memo(({
typeFilter,
onTypeChange,
importanceFilter,
onImportanceChange,
viewMode,
onViewModeChange,
}) => {
return (
<HStack
spacing={3}
flexWrap="wrap"
gap={3}
>
{/* 左侧筛选区 */}
{/* <HStack spacing={3}>
<Select
value={typeFilter}
onChange={(e) => onTypeChange(e.target.value)}
size="sm"
w="140px"
bg={THEME.inputBg}
borderColor={THEME.inputBorder}
color={THEME.textPrimary}
_hover={{ borderColor: THEME.gold }}
_focus={{ borderColor: THEME.gold, boxShadow: 'none' }}
>
{TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value} style={{ background: '#2D3748' }}>
{opt.label}
</option>
))}
</Select>
<Select
value={importanceFilter}
onChange={(e) => onImportanceChange(e.target.value)}
size="sm"
w="140px"
bg={THEME.inputBg}
borderColor={THEME.inputBorder}
color={THEME.textPrimary}
_hover={{ borderColor: THEME.gold }}
_focus={{ borderColor: THEME.gold, boxShadow: 'none' }}
>
{IMPORTANCE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value} style={{ background: '#2D3748' }}>
{opt.label}
</option>
))}
</Select>
</HStack> */}
{/* 右侧视图切换 */}
<Tabs
index={viewMode === 'hierarchy' ? 0 : 1}
onChange={(index) => onViewModeChange(index === 0 ? 'hierarchy' : 'flow')}
variant="soft-rounded"
size="sm"
>
<TabList>
<Tab
color={THEME.textSecondary}
_selected={{
bg: THEME.gold,
color: 'gray.900',
}}
_hover={{
bg: 'gray.600',
}}
>
</Tab>
<Tab
color={THEME.textSecondary}
_selected={{
bg: THEME.gold,
color: 'gray.900',
}}
_hover={{
bg: 'gray.600',
}}
>
</Tab>
</TabList>
</Tabs>
</HStack>
);
});
ValueChainFilterBar.displayName = 'ValueChainFilterBar';
export default ValueChainFilterBar;

View File

@@ -0,0 +1,14 @@
/**
* 原子组件导出
*
* DeepAnalysisTab 内部使用的基础 UI 组件
*/
export { default as DisclaimerBox } from './DisclaimerBox';
export { default as ScoreBar } from './ScoreBar';
export { default as BusinessTreeItem } from './BusinessTreeItem';
export { default as KeyFactorCard } from './KeyFactorCard';
export { default as ProcessNavigation } from './ProcessNavigation';
export { default as ValueChainFilterBar } from './ValueChainFilterBar';
export type { TabType } from './ProcessNavigation';
export type { ViewMode } from './ValueChainFilterBar';

View File

@@ -0,0 +1,169 @@
/**
* 业务板块详情卡片
*
* 显示公司各业务板块的详细信息
* 黑金主题风格
*/
import React from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Text,
Heading,
Badge,
Box,
Icon,
SimpleGrid,
Button,
} from '@chakra-ui/react';
import { FaIndustry, FaExpandAlt, FaCompressAlt } from 'react-icons/fa';
import type { BusinessSegment } from '../types';
// 黑金主题配置
const THEME = {
cardBg: 'gray.800',
innerCardBg: 'gray.700',
gold: '#D4AF37',
goldLight: '#F0D78C',
textPrimary: '#D4AF37',
textSecondary: 'gray.400',
border: 'rgba(212, 175, 55, 0.3)',
};
interface BusinessSegmentsCardProps {
businessSegments: BusinessSegment[];
expandedSegments: Record<number, boolean>;
onToggleSegment: (index: number) => void;
cardBg?: string;
}
const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
businessSegments,
expandedSegments,
onToggleSegment,
}) => {
if (!businessSegments || businessSegments.length === 0) return null;
return (
<Card bg={THEME.cardBg} shadow="md">
<CardHeader>
<HStack>
<Icon as={FaIndustry} color={THEME.gold} />
<Heading size="sm" color={THEME.textPrimary}></Heading>
<Badge bg={THEME.gold} color="gray.900">{businessSegments.length} </Badge>
</HStack>
</CardHeader>
<CardBody px={2}>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{businessSegments.map((segment, idx) => {
const isExpanded = expandedSegments[idx];
return (
<Card key={idx} bg={THEME.innerCardBg}>
<CardBody px={2}>
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text fontWeight="bold" fontSize="md" color={THEME.textPrimary}>
{segment.segment_name}
</Text>
<Button
size="sm"
variant="ghost"
leftIcon={
<Icon as={isExpanded ? FaCompressAlt : FaExpandAlt} />
}
onClick={() => onToggleSegment(idx)}
color={THEME.gold}
_hover={{ bg: 'gray.600' }}
>
{isExpanded ? '折叠' : '展开'}
</Button>
</HStack>
<Box>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Text
fontSize="sm"
color={THEME.textPrimary}
noOfLines={isExpanded ? undefined : 3}
>
{segment.segment_description || '暂无描述'}
</Text>
</Box>
<Box>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Text
fontSize="sm"
color={THEME.textPrimary}
noOfLines={isExpanded ? undefined : 2}
>
{segment.competitive_position || '暂无数据'}
</Text>
</Box>
<Box>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Text
fontSize="sm"
noOfLines={isExpanded ? undefined : 2}
color={THEME.goldLight}
>
{segment.future_potential || '暂无数据'}
</Text>
</Box>
{isExpanded && segment.key_products && (
<Box>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Text fontSize="sm" color="green.300">
{segment.key_products}
</Text>
</Box>
)}
{isExpanded && segment.market_share !== undefined && (
<Box>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Badge bg="purple.600" color="white" fontSize="sm">
{segment.market_share}%
</Badge>
</Box>
)}
{isExpanded && segment.revenue_contribution !== undefined && (
<Box>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Badge bg={THEME.gold} color="gray.900" fontSize="sm">
{segment.revenue_contribution}%
</Badge>
</Box>
)}
</VStack>
</CardBody>
</Card>
);
})}
</SimpleGrid>
</CardBody>
</Card>
);
};
export default BusinessSegmentsCard;

View File

@@ -0,0 +1,65 @@
/**
* 业务结构分析卡片
*
* 显示公司业务结构树形图
* 黑金主题风格
*/
import React from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Heading,
Badge,
Icon,
} from '@chakra-ui/react';
import { FaChartPie } from 'react-icons/fa';
import { BusinessTreeItem } from '../atoms';
import type { BusinessStructure } from '../types';
// 黑金主题配置
const THEME = {
cardBg: 'gray.800',
gold: '#D4AF37',
textPrimary: '#D4AF37',
border: 'rgba(212, 175, 55, 0.3)',
};
interface BusinessStructureCardProps {
businessStructure: BusinessStructure[];
cardBg?: string;
}
const BusinessStructureCard: React.FC<BusinessStructureCardProps> = ({
businessStructure,
}) => {
if (!businessStructure || businessStructure.length === 0) return null;
return (
<Card bg={THEME.cardBg} shadow="md">
<CardHeader>
<HStack>
<Icon as={FaChartPie} color={THEME.gold} />
<Heading size="sm" color={THEME.textPrimary}></Heading>
<Badge bg={THEME.gold} color="gray.900">{businessStructure[0]?.report_period}</Badge>
</HStack>
</CardHeader>
<CardBody px={0}>
<VStack spacing={3} align="stretch">
{businessStructure.map((business, idx) => (
<BusinessTreeItem
key={idx}
business={business}
depth={business.business_level - 1}
/>
))}
</VStack>
</CardBody>
</Card>
);
};
export default BusinessStructureCard;

View File

@@ -0,0 +1,295 @@
/**
* 竞争地位分析卡片
*
* 显示竞争力评分、雷达图和竞争分析
* 包含行业排名弹窗功能
*/
import React, { memo, useMemo } from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Text,
Heading,
Badge,
Tag,
TagLabel,
Grid,
GridItem,
Box,
Icon,
Divider,
SimpleGrid,
Button,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure,
} from '@chakra-ui/react';
import {
FaTrophy,
FaCog,
FaStar,
FaChartLine,
FaDollarSign,
FaFlask,
FaShieldAlt,
FaRocket,
FaUsers,
FaExternalLinkAlt,
} from 'react-icons/fa';
import ReactECharts from 'echarts-for-react';
import { ScoreBar } from '../atoms';
import { getRadarChartOption } from '../utils/chartOptions';
import { IndustryRankingView } from '../../../FinancialPanorama/components';
import type { ComprehensiveData, CompetitivePosition, IndustryRankData } from '../types';
// 黑金主题弹窗样式
const MODAL_STYLES = {
content: {
bg: 'gray.900',
borderColor: 'rgba(212, 175, 55, 0.3)',
borderWidth: '1px',
maxW: '900px',
},
header: {
color: 'yellow.500',
borderBottomColor: 'rgba(212, 175, 55, 0.2)',
borderBottomWidth: '1px',
},
closeButton: {
color: 'yellow.500',
_hover: { bg: 'rgba(212, 175, 55, 0.1)' },
},
} as const;
// 样式常量 - 避免每次渲染创建新对象
const CARD_STYLES = {
bg: 'transparent',
shadow: 'md',
} as const;
const CONTENT_BOX_STYLES = {
p: 4,
border: '1px solid',
borderColor: 'yellow.600',
borderRadius: 'md',
} as const;
const GRID_COLSPAN = { base: 2, lg: 1 } as const;
const CHART_STYLE = { height: '320px' } as const;
interface CompetitiveAnalysisCardProps {
comprehensiveData: ComprehensiveData;
industryRankData?: IndustryRankData[];
}
// 竞争对手标签组件
interface CompetitorTagsProps {
competitors: string[];
}
const CompetitorTags = memo<CompetitorTagsProps>(({ competitors }) => (
<Box mb={4}>
<Text fontWeight="bold" fontSize="sm" mb={2} color="yellow.500">
</Text>
<HStack spacing={2} flexWrap="wrap">
{competitors.map((competitor, idx) => (
<Tag
key={idx}
size="md"
variant="outline"
borderColor="yellow.600"
color="yellow.500"
borderRadius="full"
>
<Icon as={FaUsers} mr={1} />
<TagLabel>{competitor}</TagLabel>
</Tag>
))}
</HStack>
</Box>
));
CompetitorTags.displayName = 'CompetitorTags';
// 评分区域组件
interface ScoreSectionProps {
scores: CompetitivePosition['scores'];
}
const ScoreSection = memo<ScoreSectionProps>(({ scores }) => (
<VStack spacing={4} align="stretch">
<ScoreBar label="市场地位" score={scores?.market_position} icon={FaTrophy} />
<ScoreBar label="技术实力" score={scores?.technology} icon={FaCog} />
<ScoreBar label="品牌价值" score={scores?.brand} icon={FaStar} />
<ScoreBar label="运营效率" score={scores?.operation} icon={FaChartLine} />
<ScoreBar label="财务健康" score={scores?.finance} icon={FaDollarSign} />
<ScoreBar label="创新能力" score={scores?.innovation} icon={FaFlask} />
<ScoreBar label="风险控制" score={scores?.risk} icon={FaShieldAlt} />
<ScoreBar label="成长潜力" score={scores?.growth} icon={FaRocket} />
</VStack>
));
ScoreSection.displayName = 'ScoreSection';
// 竞争优劣势组件
interface AdvantagesSectionProps {
advantages?: string;
disadvantages?: string;
}
const AdvantagesSection = memo<AdvantagesSectionProps>(
({ advantages, disadvantages }) => (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<Box {...CONTENT_BOX_STYLES}>
<Text fontWeight="bold" fontSize="sm" mb={2} color="green.400">
</Text>
<Text fontSize="sm" color="white">
{advantages || '暂无数据'}
</Text>
</Box>
<Box {...CONTENT_BOX_STYLES}>
<Text fontWeight="bold" fontSize="sm" mb={2} color="red.400">
</Text>
<Text fontSize="sm" color="white">
{disadvantages || '暂无数据'}
</Text>
</Box>
</SimpleGrid>
)
);
AdvantagesSection.displayName = 'AdvantagesSection';
const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
({ comprehensiveData, industryRankData }) => {
const competitivePosition = comprehensiveData.competitive_position;
const { isOpen, onOpen, onClose } = useDisclosure();
if (!competitivePosition) return null;
// 缓存雷达图配置
const radarOption = useMemo(
() => getRadarChartOption(comprehensiveData),
[comprehensiveData]
);
// 缓存竞争对手列表
const competitors = useMemo(
() =>
competitivePosition.analysis?.main_competitors
?.split(',')
.map((c) => c.trim()) || [],
[competitivePosition.analysis?.main_competitors]
);
// 判断是否有行业排名数据可展示
const hasIndustryRankData = industryRankData && industryRankData.length > 0;
return (
<>
<Card {...CARD_STYLES}>
<CardHeader>
<HStack>
<Icon as={FaTrophy} color="yellow.500" />
<Heading size="sm" color="yellow.500"></Heading>
{competitivePosition.ranking && (
<Badge
ml={2}
bg="transparent"
border="1px solid"
borderColor="yellow.600"
color="yellow.500"
cursor={hasIndustryRankData ? 'pointer' : 'default'}
onClick={hasIndustryRankData ? onOpen : undefined}
_hover={hasIndustryRankData ? { bg: 'rgba(212, 175, 55, 0.1)' } : undefined}
>
{competitivePosition.ranking.industry_rank}/
{competitivePosition.ranking.total_companies}
</Badge>
)}
{hasIndustryRankData && (
<Button
size="xs"
variant="ghost"
color="yellow.500"
rightIcon={<Icon as={FaExternalLinkAlt} boxSize={3} />}
onClick={onOpen}
_hover={{ bg: 'rgba(212, 175, 55, 0.1)' }}
>
</Button>
)}
</HStack>
</CardHeader>
<CardBody>
{/* 主要竞争对手 */}
{competitors.length > 0 && <CompetitorTags competitors={competitors} />}
{/* 评分和雷达图 */}
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
<GridItem colSpan={GRID_COLSPAN}>
<ScoreSection scores={competitivePosition.scores} />
</GridItem>
<GridItem colSpan={GRID_COLSPAN}>
{radarOption && (
<ReactECharts
option={radarOption}
style={CHART_STYLE}
theme="dark"
/>
)}
</GridItem>
</Grid>
<Divider my={4} borderColor="yellow.600" />
{/* 竞争优势和劣势 */}
<AdvantagesSection
advantages={competitivePosition.analysis?.competitive_advantages}
disadvantages={competitivePosition.analysis?.competitive_disadvantages}
/>
</CardBody>
</Card>
{/* 行业排名弹窗 - 黑金主题 */}
<Modal isOpen={isOpen} onClose={onClose} size="4xl" scrollBehavior="inside">
<ModalOverlay bg="blackAlpha.700" />
<ModalContent {...MODAL_STYLES.content}>
<ModalHeader {...MODAL_STYLES.header}>
<HStack>
<Icon as={FaTrophy} color="yellow.500" />
<Text></Text>
</HStack>
</ModalHeader>
<ModalCloseButton {...MODAL_STYLES.closeButton} />
<ModalBody py={4}>
{hasIndustryRankData && (
<IndustryRankingView
industryRank={industryRankData}
bgColor="gray.800"
borderColor="rgba(212, 175, 55, 0.3)"
/>
)}
</ModalBody>
</ModalContent>
</Modal>
</>
);
}
);
CompetitiveAnalysisCard.displayName = 'CompetitiveAnalysisCard';
export default CompetitiveAnalysisCard;

View File

@@ -0,0 +1,54 @@
/**
* 投资亮点卡片组件
*/
import React, { memo } from 'react';
import { Box, HStack, VStack, Icon, Text } from '@chakra-ui/react';
import { FaUsers } from 'react-icons/fa';
import { THEME, ICON_MAP, HIGHLIGHT_HOVER_STYLES } from '../theme';
import type { InvestmentHighlightItem } from '../../../types';
interface HighlightCardProps {
highlight: InvestmentHighlightItem;
}
export const HighlightCard = memo<HighlightCardProps>(({ highlight }) => {
const IconComponent = ICON_MAP[highlight.icon] || FaUsers;
return (
<Box
p={4}
bg={THEME.light.cardBg}
borderRadius="lg"
border="1px solid"
borderColor="whiteAlpha.100"
{...HIGHLIGHT_HOVER_STYLES}
transition="border-color 0.2s"
>
<HStack spacing={3} align="flex-start">
<Box
p={2}
bg="whiteAlpha.100"
borderRadius="md"
color={THEME.light.titleColor}
>
<Icon as={IconComponent} boxSize={4} />
</Box>
<VStack align="start" spacing={1} flex={1}>
<Text fontWeight="bold" color={THEME.light.textColor} fontSize="sm">
{highlight.title}
</Text>
<Text
fontSize="xs"
color={THEME.light.subtextColor}
lineHeight="tall"
>
{highlight.description}
</Text>
</VStack>
</HStack>
</Box>
);
});
HighlightCard.displayName = 'HighlightCard';

View File

@@ -0,0 +1,47 @@
/**
* 商业模式板块组件
*/
import React, { memo } from 'react';
import { Box, VStack, HStack, Text, Tag, Divider } from '@chakra-ui/react';
import { THEME } from '../theme';
import type { BusinessModelSection } from '../../../types';
interface ModelBlockProps {
section: BusinessModelSection;
isLast?: boolean;
}
export const ModelBlock = memo<ModelBlockProps>(({ section, isLast }) => (
<Box>
<VStack align="start" spacing={2}>
<Text fontWeight="bold" color={THEME.light.textColor} fontSize="sm">
{section.title}
</Text>
<Text fontSize="xs" color={THEME.light.subtextColor} lineHeight="tall">
{section.description}
</Text>
{section.tags && section.tags.length > 0 && (
<HStack spacing={2} flexWrap="wrap" mt={1}>
{section.tags.map((tag, idx) => (
<Tag
key={idx}
size="sm"
bg={THEME.light.tagBg}
color={THEME.light.tagColor}
borderRadius="full"
px={3}
py={1}
fontSize="xs"
>
{tag}
</Tag>
))}
</HStack>
)}
</VStack>
{!isLast && <Divider my={4} borderColor="whiteAlpha.100" />}
</Box>
));
ModelBlock.displayName = 'ModelBlock';

View File

@@ -0,0 +1,27 @@
/**
* 区域标题组件
*/
import React, { memo } from 'react';
import { HStack, Icon, Text } from '@chakra-ui/react';
import type { IconType } from 'react-icons';
import { THEME } from '../theme';
interface SectionHeaderProps {
icon: IconType;
title: string;
color?: string;
}
export const SectionHeader = memo<SectionHeaderProps>(
({ icon, title, color = THEME.dark.titleColor }) => (
<HStack spacing={2} mb={4}>
<Icon as={icon} color={color} boxSize={4} />
<Text fontWeight="bold" color={color} fontSize="md">
{title}
</Text>
</HStack>
)
);
SectionHeader.displayName = 'SectionHeader';

View File

@@ -0,0 +1,7 @@
/**
* CorePositioningCard 原子组件统一导出
*/
export { SectionHeader } from './SectionHeader';
export { HighlightCard } from './HighlightCard';
export { ModelBlock } from './ModelBlock';

View File

@@ -0,0 +1,204 @@
/**
* 核心定位卡片
*
* 显示公司的核心定位、投资亮点和商业模式
* 黑金主题设计
*/
import React, { memo, useMemo } from 'react';
import {
Card,
CardBody,
VStack,
Text,
Box,
Grid,
GridItem,
} from '@chakra-ui/react';
import { FaCrown, FaStar, FaBriefcase } from 'react-icons/fa';
import type {
QualitativeAnalysis,
InvestmentHighlightItem,
} from '../../types';
import {
THEME,
CARD_STYLES,
GRID_COLUMNS,
BORDER_RIGHT_RESPONSIVE,
} from './theme';
import { SectionHeader, HighlightCard, ModelBlock } from './atoms';
// ==================== 主组件 ====================
interface CorePositioningCardProps {
qualitativeAnalysis: QualitativeAnalysis;
cardBg?: string;
}
const CorePositioningCard: React.FC<CorePositioningCardProps> = memo(
({ qualitativeAnalysis }) => {
const corePositioning = qualitativeAnalysis.core_positioning;
// 判断是否有结构化数据
const hasStructuredData = useMemo(
() =>
!!(
corePositioning?.features?.length ||
(Array.isArray(corePositioning?.investment_highlights) &&
corePositioning.investment_highlights.length > 0) ||
corePositioning?.business_model_sections?.length
),
[corePositioning]
);
// 如果没有结构化数据,使用旧的文本格式渲染
if (!hasStructuredData) {
return (
<Card {...CARD_STYLES}>
<CardBody>
<VStack spacing={4} align="stretch">
<SectionHeader icon={FaCrown} title="核心定位" />
{corePositioning?.one_line_intro && (
<Box
p={4}
bg={THEME.dark.cardBg}
borderRadius="lg"
borderLeft="4px solid"
borderColor={THEME.dark.border}
>
<Text color={THEME.dark.textColor} fontWeight="medium">
{corePositioning.one_line_intro}
</Text>
</Box>
)}
<Grid templateColumns={GRID_COLUMNS.twoColumnMd} gap={4}>
<GridItem>
<Box p={4} bg={THEME.light.cardBg} borderRadius="lg">
<SectionHeader icon={FaStar} title="投资亮点" />
<Text
fontSize="sm"
color={THEME.light.subtextColor}
whiteSpace="pre-wrap"
>
{corePositioning?.investment_highlights_text ||
(typeof corePositioning?.investment_highlights === 'string'
? corePositioning.investment_highlights
: '暂无数据')}
</Text>
</Box>
</GridItem>
<GridItem>
<Box p={4} bg={THEME.light.cardBg} borderRadius="lg">
<SectionHeader icon={FaBriefcase} title="商业模式" />
<Text
fontSize="sm"
color={THEME.light.subtextColor}
whiteSpace="pre-wrap"
>
{corePositioning?.business_model_desc || '暂无数据'}
</Text>
</Box>
</GridItem>
</Grid>
</VStack>
</CardBody>
</Card>
);
}
// 结构化数据渲染 - 缓存数组计算
const highlights = useMemo(
() =>
(Array.isArray(corePositioning?.investment_highlights)
? corePositioning.investment_highlights
: []) as InvestmentHighlightItem[],
[corePositioning?.investment_highlights]
);
const businessSections = useMemo(
() => corePositioning?.business_model_sections || [],
[corePositioning?.business_model_sections]
);
return (
<Card {...CARD_STYLES}>
<CardBody p={0}>
<VStack spacing={0} align="stretch">
{/* 核心定位区域(深色背景) */}
<Box p={6} bg={THEME.dark.bg}>
<SectionHeader icon={FaCrown} title="核心定位" />
{/* 一句话介绍 */}
{corePositioning?.one_line_intro && (
<Box
p={4}
bg={THEME.dark.cardBg}
borderRadius="lg"
borderLeft="4px solid"
borderColor={THEME.dark.border}
>
<Text color={THEME.dark.textColor} fontWeight="medium">
{corePositioning.one_line_intro}
</Text>
</Box>
)}
</Box>
{/* 投资亮点 + 商业模式区域 */}
<Grid templateColumns={GRID_COLUMNS.twoColumn} bg={THEME.light.bg}>
{/* 投资亮点区域 */}
<GridItem
p={6}
borderRight={BORDER_RIGHT_RESPONSIVE}
borderColor="whiteAlpha.100"
>
<SectionHeader icon={FaStar} title="投资亮点" />
<VStack spacing={3} align="stretch">
{highlights.length > 0 ? (
highlights.map((highlight, idx) => (
<HighlightCard key={idx} highlight={highlight} />
))
) : (
<Text fontSize="sm" color={THEME.light.subtextColor}>
</Text>
)}
</VStack>
</GridItem>
{/* 商业模式区域 */}
<GridItem p={6}>
<SectionHeader icon={FaBriefcase} title="商业模式" />
<Box
p={4}
bg={THEME.light.cardBg}
borderRadius="lg"
border="1px solid"
borderColor="whiteAlpha.100"
>
{businessSections.length > 0 ? (
businessSections.map((section, idx) => (
<ModelBlock
key={idx}
section={section}
isLast={idx === businessSections.length - 1}
/>
))
) : (
<Text fontSize="sm" color={THEME.light.subtextColor}>
</Text>
)}
</Box>
</GridItem>
</Grid>
</VStack>
</CardBody>
</Card>
);
}
);
CorePositioningCard.displayName = 'CorePositioningCard';
export default CorePositioningCard;

View File

@@ -0,0 +1,83 @@
/**
* CorePositioningCard 主题和样式常量
*/
import {
FaUniversity,
FaFire,
FaUsers,
FaChartLine,
FaMicrochip,
FaShieldAlt,
} from 'react-icons/fa';
import type { IconType } from 'react-icons';
// ==================== 主题常量 ====================
export const THEME = {
// 深色背景区域(核心定位)
dark: {
bg: '#1A202C',
cardBg: '#252D3A',
border: '#C9A961',
borderGradient: 'linear-gradient(90deg, #C9A961, #8B7355)',
titleColor: '#C9A961',
textColor: '#E2E8F0',
subtextColor: '#A0AEC0',
},
// 浅色背景区域(投资亮点/商业模式)
light: {
bg: '#1E2530',
cardBg: '#252D3A',
titleColor: '#C9A961',
textColor: '#E2E8F0',
subtextColor: '#A0AEC0',
tagBg: 'rgba(201, 169, 97, 0.15)',
tagColor: '#C9A961',
},
} as const;
// ==================== 图标映射 ====================
export const ICON_MAP: Record<string, IconType> = {
bank: FaUniversity,
fire: FaFire,
users: FaUsers,
'trending-up': FaChartLine,
cpu: FaMicrochip,
'shield-check': FaShieldAlt,
};
// ==================== 样式常量 ====================
// 卡片通用样式(含顶部金色边框)
export const CARD_STYLES = {
bg: THEME.dark.bg,
shadow: 'lg',
border: '1px solid',
borderColor: 'whiteAlpha.100',
overflow: 'hidden',
position: 'relative',
_before: {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: THEME.dark.borderGradient,
},
} as const;
// HighlightCard hover 样式
export const HIGHLIGHT_HOVER_STYLES = {
_hover: { borderColor: 'whiteAlpha.200' },
} as const;
// 响应式布局常量
export const GRID_COLUMNS = {
twoColumn: { base: '1fr', lg: 'repeat(2, 1fr)' },
twoColumnMd: { base: '1fr', md: 'repeat(2, 1fr)' },
} as const;
export const BORDER_RIGHT_RESPONSIVE = { lg: '1px solid' } as const;

View File

@@ -0,0 +1,124 @@
/**
* 关键因素卡片
*
* 显示影响公司的关键因素列表
* 黑金主题设计
*/
import React from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Text,
Heading,
Badge,
Box,
Icon,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
} from '@chakra-ui/react';
import { FaBalanceScale } from 'react-icons/fa';
import { KeyFactorCard } from '../atoms';
import type { KeyFactors } from '../types';
// 黑金主题样式常量
const THEME = {
bg: '#1A202C',
cardBg: '#252D3A',
border: '#C9A961',
borderGradient: 'linear-gradient(90deg, #C9A961, #8B7355)',
titleColor: '#C9A961',
textColor: '#E2E8F0',
subtextColor: '#A0AEC0',
} as const;
const CARD_STYLES = {
bg: THEME.bg,
shadow: 'lg',
border: '1px solid',
borderColor: 'whiteAlpha.100',
overflow: 'hidden',
position: 'relative',
_before: {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: THEME.borderGradient,
},
} as const;
interface KeyFactorsCardProps {
keyFactors: KeyFactors;
cardBg?: string;
}
const KeyFactorsCard: React.FC<KeyFactorsCardProps> = ({ keyFactors }) => {
return (
<Card {...CARD_STYLES} h="full">
<CardHeader>
<HStack>
<Icon as={FaBalanceScale} color="yellow.500" />
<Heading size="sm" color={THEME.titleColor}>
</Heading>
<Badge
bg="transparent"
border="1px solid"
borderColor="yellow.600"
color="yellow.500"
>
{keyFactors.total_factors}
</Badge>
</HStack>
</CardHeader>
<CardBody>
<Accordion allowMultiple>
{keyFactors.categories.map((category, idx) => (
<AccordionItem key={idx} border="none">
<AccordionButton
bg={THEME.cardBg}
borderRadius="md"
mb={2}
_hover={{ bg: 'whiteAlpha.100' }}
>
<Box flex="1" textAlign="left">
<HStack>
<Text fontWeight="medium" color={THEME.textColor}>
{category.category_name}
</Text>
<Badge
bg="whiteAlpha.100"
color={THEME.subtextColor}
size="sm"
>
{category.factors.length}
</Badge>
</HStack>
</Box>
<AccordionIcon color={THEME.subtextColor} />
</AccordionButton>
<AccordionPanel pb={4}>
<VStack spacing={3} align="stretch">
{category.factors.map((factor, fidx) => (
<KeyFactorCard key={fidx} factor={factor} />
))}
</VStack>
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
</CardBody>
</Card>
);
};
export default KeyFactorsCard;

View File

@@ -0,0 +1,133 @@
/**
* 战略分析卡片
*
* 显示公司战略方向和战略举措
*/
import React, { memo, useMemo } from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Text,
Heading,
Box,
Icon,
Grid,
GridItem,
Center,
} from '@chakra-ui/react';
import { FaRocket, FaChartBar } from 'react-icons/fa';
import type { Strategy } from '../types';
// 样式常量 - 避免每次渲染创建新对象
const CARD_STYLES = {
bg: 'transparent',
shadow: 'md',
} as const;
const CONTENT_BOX_STYLES = {
p: 4,
border: '1px solid',
borderColor: 'yellow.600',
borderRadius: 'md',
} as const;
const EMPTY_BOX_STYLES = {
border: '1px dashed',
borderColor: 'yellow.600',
borderRadius: 'md',
py: 12,
} as const;
const GRID_RESPONSIVE_COLSPAN = { base: 2, md: 1 } as const;
interface StrategyAnalysisCardProps {
strategy: Strategy;
cardBg?: string;
}
// 空状态组件 - 独立 memo 避免重复渲染
const EmptyState = memo(() => (
<Box {...EMPTY_BOX_STYLES}>
<Center>
<VStack spacing={3}>
<Icon as={FaChartBar} boxSize={10} color="yellow.600" />
<Text fontWeight="medium"></Text>
<Text fontSize="sm" color="gray.500">
</Text>
</VStack>
</Center>
</Box>
));
EmptyState.displayName = 'StrategyEmptyState';
// 内容项组件 - 复用结构
interface ContentItemProps {
title: string;
content: string;
}
const ContentItem = memo<ContentItemProps>(({ title, content }) => (
<VStack align="stretch" spacing={2}>
<Text fontWeight="bold" fontSize="sm" color="yellow.500">
{title}
</Text>
<Text fontSize="sm" color="white">
{content}
</Text>
</VStack>
));
ContentItem.displayName = 'StrategyContentItem';
const StrategyAnalysisCard: React.FC<StrategyAnalysisCardProps> = memo(
({ strategy }) => {
// 缓存数据检测结果
const hasData = useMemo(
() => !!(strategy?.strategy_description || strategy?.strategic_initiatives),
[strategy?.strategy_description, strategy?.strategic_initiatives]
);
return (
<Card {...CARD_STYLES}>
<CardHeader>
<HStack>
<Icon as={FaRocket} color="yellow.500" />
<Heading size="sm" color="yellow.500"></Heading>
</HStack>
</CardHeader>
<CardBody>
{!hasData ? (
<EmptyState />
) : (
<Box {...CONTENT_BOX_STYLES}>
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
<GridItem colSpan={GRID_RESPONSIVE_COLSPAN}>
<ContentItem
title="战略方向"
content={strategy.strategy_description || '暂无数据'}
/>
</GridItem>
<GridItem colSpan={GRID_RESPONSIVE_COLSPAN}>
<ContentItem
title="战略举措"
content={strategy.strategic_initiatives || '暂无数据'}
/>
</GridItem>
</Grid>
</Box>
)}
</CardBody>
</Card>
);
}
);
StrategyAnalysisCard.displayName = 'StrategyAnalysisCard';
export default StrategyAnalysisCard;

View File

@@ -0,0 +1,95 @@
/**
* 发展时间线卡片
*
* 显示公司发展历程时间线
* 黑金主题设计
*/
import React from 'react';
import {
Card,
CardBody,
CardHeader,
HStack,
Heading,
Badge,
Box,
Icon,
} from '@chakra-ui/react';
import { FaHistory } from 'react-icons/fa';
import TimelineComponent from '../organisms/TimelineComponent';
import type { DevelopmentTimeline } from '../types';
// 黑金主题样式常量
const THEME = {
bg: '#1A202C',
cardBg: '#252D3A',
border: '#C9A961',
borderGradient: 'linear-gradient(90deg, #C9A961, #8B7355)',
titleColor: '#C9A961',
textColor: '#E2E8F0',
subtextColor: '#A0AEC0',
} as const;
const CARD_STYLES = {
bg: THEME.bg,
shadow: 'lg',
border: '1px solid',
borderColor: 'whiteAlpha.100',
overflow: 'hidden',
position: 'relative',
_before: {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: THEME.borderGradient,
},
} as const;
interface TimelineCardProps {
developmentTimeline: DevelopmentTimeline;
cardBg?: string;
}
const TimelineCard: React.FC<TimelineCardProps> = ({ developmentTimeline }) => {
return (
<Card {...CARD_STYLES} h="full">
<CardHeader>
<HStack>
<Icon as={FaHistory} color="yellow.500" />
<Heading size="sm" color={THEME.titleColor}>
线
</Heading>
<HStack spacing={1}>
<Badge
bg="transparent"
border="1px solid"
borderColor="red.400"
color="red.400"
>
{developmentTimeline.statistics?.positive_events || 0}
</Badge>
<Badge
bg="transparent"
border="1px solid"
borderColor="green.400"
color="green.400"
>
{developmentTimeline.statistics?.negative_events || 0}
</Badge>
</HStack>
</HStack>
</CardHeader>
<CardBody>
<Box maxH="600px" overflowY="auto" pr={2}>
<TimelineComponent events={developmentTimeline.events} />
</Box>
</CardBody>
</Card>
);
};
export default TimelineCard;

View File

@@ -0,0 +1,220 @@
/**
* 产业链分析卡片
*
* 显示产业链层级视图和流向关系
* 黑金主题风格 + 流程式导航
*/
import React, { useState, useMemo, memo } from 'react';
import {
Card,
CardBody,
CardHeader,
HStack,
Text,
Heading,
Badge,
Icon,
SimpleGrid,
Center,
Box,
Flex,
} from '@chakra-ui/react';
import { FaNetworkWired } from 'react-icons/fa';
import ReactECharts from 'echarts-for-react';
import {
ProcessNavigation,
ValueChainFilterBar,
} from '../atoms';
import type { TabType, ViewMode } from '../atoms';
import ValueChainNodeCard from '../organisms/ValueChainNodeCard';
import { getSankeyChartOption } from '../utils/chartOptions';
import type { ValueChainData, ValueChainNode } from '../types';
// 黑金主题配置
const THEME = {
cardBg: 'gray.800',
gold: '#D4AF37',
goldLight: '#F0D78C',
textPrimary: '#D4AF37',
textSecondary: 'gray.400',
};
interface ValueChainCardProps {
valueChainData: ValueChainData;
companyName?: string;
cardBg?: string;
}
const ValueChainCard: React.FC<ValueChainCardProps> = memo(({
valueChainData,
companyName = '目标公司',
}) => {
// 状态管理
const [activeTab, setActiveTab] = useState<TabType>('upstream');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [importanceFilter, setImportanceFilter] = useState<string>('all');
const [viewMode, setViewMode] = useState<ViewMode>('hierarchy');
// 解析节点数据
const nodesByLevel = valueChainData.value_chain_structure?.nodes_by_level;
// 获取上游节点
const upstreamNodes = useMemo(() => [
...(nodesByLevel?.['level_-2'] || []),
...(nodesByLevel?.['level_-1'] || []),
], [nodesByLevel]);
// 获取核心节点
const coreNodes = useMemo(() =>
nodesByLevel?.['level_0'] || [],
[nodesByLevel]);
// 获取下游节点
const downstreamNodes = useMemo(() => [
...(nodesByLevel?.['level_1'] || []),
...(nodesByLevel?.['level_2'] || []),
], [nodesByLevel]);
// 计算总节点数
const totalNodes = valueChainData.analysis_summary?.total_nodes ||
(upstreamNodes.length + coreNodes.length + downstreamNodes.length);
// 根据 activeTab 获取当前节点
const currentNodes = useMemo(() => {
switch (activeTab) {
case 'upstream':
return upstreamNodes;
case 'core':
return coreNodes;
case 'downstream':
return downstreamNodes;
default:
return [];
}
}, [activeTab, upstreamNodes, coreNodes, downstreamNodes]);
// 筛选节点
const filteredNodes = useMemo(() => {
let nodes = [...currentNodes];
// 类型筛选
if (typeFilter !== 'all') {
nodes = nodes.filter((n: ValueChainNode) => n.node_type === typeFilter);
}
// 重要度筛选
if (importanceFilter !== 'all') {
nodes = nodes.filter((n: ValueChainNode) => {
const score = n.importance_score || 0;
switch (importanceFilter) {
case 'high':
return score >= 80;
case 'medium':
return score >= 50 && score < 80;
case 'low':
return score < 50;
default:
return true;
}
});
}
return nodes;
}, [currentNodes, typeFilter, importanceFilter]);
// Sankey 图配置
const sankeyOption = useMemo(() =>
getSankeyChartOption(valueChainData),
[valueChainData]);
return (
<Card bg={THEME.cardBg} shadow="md">
{/* 头部区域 */}
<CardHeader py={0}>
<HStack flexWrap="wrap" gap={0}>
<Icon as={FaNetworkWired} color={THEME.gold} />
<Heading size="sm" color={THEME.textPrimary}>
</Heading>
<Text color={THEME.textSecondary} fontSize="sm">
| {companyName}
</Text>
<Badge bg={THEME.gold} color="gray.900">
{totalNodes}
</Badge>
</HStack>
</CardHeader>
<CardBody px={2}>
{/* 工具栏:左侧流程导航 + 右侧筛选 */}
<Flex
borderBottom="1px solid"
borderColor="gray.700"
justify="space-between"
align="center"
flexWrap="wrap"
>
{/* 左侧:流程式导航 - 仅在层级视图显示 */}
{viewMode === 'hierarchy' && (
<ProcessNavigation
activeTab={activeTab}
onTabChange={setActiveTab}
upstreamCount={upstreamNodes.length}
coreCount={coreNodes.length}
downstreamCount={downstreamNodes.length}
/>
)}
{/* 右侧:筛选与视图切换 - 始终靠右 */}
<Box ml="auto">
<ValueChainFilterBar
typeFilter={typeFilter}
onTypeChange={setTypeFilter}
importanceFilter={importanceFilter}
onImportanceChange={setImportanceFilter}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
</Box>
</Flex>
{/* 内容区域 */}
<Box px={0} pt={4}>
{viewMode === 'hierarchy' ? (
filteredNodes.length > 0 ? (
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
{filteredNodes.map((node, idx) => (
<ValueChainNodeCard
key={idx}
node={node}
isCompany={node.node_type === 'company'}
level={node.node_level}
/>
))}
</SimpleGrid>
) : (
<Center h="200px">
<Text color={THEME.textSecondary}></Text>
</Center>
)
) : sankeyOption ? (
<ReactECharts
option={sankeyOption}
style={{ height: '500px' }}
theme="dark"
/>
) : (
<Center h="200px">
<Text color={THEME.textSecondary}></Text>
</Center>
)}
</Box>
</CardBody>
</Card>
);
});
ValueChainCard.displayName = 'ValueChainCard';
export default ValueChainCard;

View File

@@ -0,0 +1,14 @@
/**
* Card 子组件导出
*
* DeepAnalysisTab 的各个区块组件
*/
export { default as CorePositioningCard } from './CorePositioningCard';
export { default as CompetitiveAnalysisCard } from './CompetitiveAnalysisCard';
export { default as BusinessStructureCard } from './BusinessStructureCard';
export { default as ValueChainCard } from './ValueChainCard';
export { default as KeyFactorsCard } from './KeyFactorsCard';
export { default as TimelineCard } from './TimelineCard';
export { default as BusinessSegmentsCard } from './BusinessSegmentsCard';
export { default as StrategyAnalysisCard } from './StrategyAnalysisCard';

View File

@@ -0,0 +1,108 @@
/**
* 深度分析 Tab 主组件
*
* 使用 SubTabContainer 二级导航组件,分为 4 个子 Tab
* 1. 战略分析 - 核心定位 + 战略分析 + 竞争地位
* 2. 业务结构 - 业务结构树 + 业务板块详情
* 3. 产业链 - 产业链分析(独立,含 Sankey 图)
* 4. 发展历程 - 关键因素 + 时间线
*
* 支持懒加载:通过 activeTab 和 onTabChange 实现按需加载数据
*/
import React, { useMemo } from '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';
// 主题配置(与 BasicInfoTab 保持一致)
const THEME = {
cardBg: 'gray.900',
border: 'rgba(212, 175, 55, 0.3)',
};
/**
* Tab 配置
*/
const DEEP_ANALYSIS_TABS: SubTabConfig[] = [
{ key: 'strategy', name: '战略分析', icon: FaBrain, component: StrategyTab },
{ key: 'business', name: '业务结构', icon: FaBuilding, component: BusinessTab },
{ key: 'valueChain', name: '产业链', icon: FaLink, component: ValueChainTab },
{ key: 'development', name: '发展历程', icon: FaHistory, component: DevelopmentTab },
];
/**
* Tab key 到 index 的映射
*/
const TAB_KEY_TO_INDEX: Record<DeepAnalysisTabKey, number> = {
strategy: 0,
business: 1,
valueChain: 2,
development: 3,
};
const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
comprehensiveData,
valueChainData,
keyFactorsData,
industryRankData,
loading,
cardBg,
expandedSegments,
onToggleSegment,
activeTab,
onTabChange,
}) => {
// 计算当前 Tab 索引(受控模式)
const currentIndex = useMemo(() => {
if (activeTab) {
return TAB_KEY_TO_INDEX[activeTab] ?? 0;
}
return undefined; // 非受控模式
}, [activeTab]);
// 加载状态
if (loading) {
return (
<Card bg={THEME.cardBg} shadow="md" border="1px solid" borderColor={THEME.border}>
<CardBody p={0}>
<SubTabContainer
tabs={DEEP_ANALYSIS_TABS}
index={currentIndex}
onTabChange={onTabChange}
componentProps={{}}
themePreset="blackGold"
/>
<LoadingState message="加载数据中..." height="200px" />
</CardBody>
</Card>
);
}
return (
<Card bg={THEME.cardBg} shadow="md" border="1px solid" borderColor={THEME.border}>
<CardBody p={0}>
<SubTabContainer
tabs={DEEP_ANALYSIS_TABS}
index={currentIndex}
onTabChange={onTabChange}
componentProps={{
comprehensiveData,
valueChainData,
keyFactorsData,
industryRankData,
cardBg,
expandedSegments,
onToggleSegment,
}}
themePreset="blackGold"
/>
</CardBody>
</Card>
);
};
export default DeepAnalysisTab;

View File

@@ -0,0 +1,136 @@
/**
* 事件详情模态框组件
*
* 显示时间线事件的详细信息
*/
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
VStack,
HStack,
Text,
Badge,
Box,
Progress,
Icon,
Button,
} from '@chakra-ui/react';
import { FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
import type { TimelineEvent } from '../../types';
interface EventDetailModalProps {
isOpen: boolean;
onClose: () => void;
event: TimelineEvent | null;
}
const EventDetailModal: React.FC<EventDetailModalProps> = ({
isOpen,
onClose,
event,
}) => {
if (!event) return null;
const isPositive = event.impact_metrics?.is_positive;
const impactScore = event.impact_metrics?.impact_score || 0;
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>
<HStack>
<Icon
as={isPositive ? FaCheckCircle : FaExclamationCircle}
color={isPositive ? 'red.500' : 'green.500'}
boxSize={6}
/>
<VStack align="start" spacing={0}>
<Text>{event.event_title}</Text>
<HStack>
<Badge colorScheme={isPositive ? 'red' : 'green'}>
{event.event_type}
</Badge>
<Text fontSize="sm" color="gray.500">
{event.event_date}
</Text>
</HStack>
</VStack>
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
<Box>
<Text fontWeight="bold" mb={2} color="gray.600">
</Text>
<Text fontSize="sm" lineHeight="1.6">
{event.event_desc}
</Text>
</Box>
{event.related_info?.financial_impact && (
<Box>
<Text fontWeight="bold" mb={2} color="gray.600">
</Text>
<Text fontSize="sm" lineHeight="1.6" color="blue.600">
{event.related_info.financial_impact}
</Text>
</Box>
)}
<Box>
<Text fontWeight="bold" mb={2} color="gray.600">
</Text>
<HStack spacing={4}>
<VStack spacing={1}>
<Text fontSize="xs" color="gray.500">
</Text>
<Progress
value={impactScore}
size="lg"
width="120px"
colorScheme={impactScore > 70 ? 'red' : 'orange'}
hasStripe
isAnimated
/>
<Text fontSize="sm" fontWeight="bold">
{impactScore}/100
</Text>
</VStack>
<VStack>
<Badge
size="lg"
colorScheme={isPositive ? 'red' : 'green'}
px={3}
py={1}
>
{isPositive ? '正面影响' : '负面影响'}
</Badge>
</VStack>
</HStack>
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<Button colorScheme="blue" onClick={onClose}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default EventDetailModal;

View File

@@ -0,0 +1,178 @@
/**
* 时间线组件
*
* 显示公司发展事件时间线
*/
import React, { useState } from 'react';
import {
Box,
VStack,
HStack,
Text,
Badge,
Card,
CardBody,
Icon,
Progress,
Circle,
Fade,
useDisclosure,
} from '@chakra-ui/react';
import {
FaCalendarAlt,
FaArrowUp,
FaArrowDown,
} from 'react-icons/fa';
import EventDetailModal from './EventDetailModal';
import type { TimelineComponentProps, TimelineEvent } from '../../types';
const TimelineComponent: React.FC<TimelineComponentProps> = ({ events }) => {
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
// 背景颜色
const positiveBgColor = 'red.50';
const negativeBgColor = 'green.50';
const handleEventClick = (event: TimelineEvent) => {
setSelectedEvent(event);
onOpen();
};
return (
<>
<Box position="relative" pl={8}>
{/* 时间线轴 */}
<Box
position="absolute"
left="15px"
top="20px"
bottom="20px"
width="2px"
bg="gray.300"
/>
<VStack align="stretch" spacing={6}>
{events.map((event, idx) => {
const isPositive = event.impact_metrics?.is_positive;
const iconColor = isPositive ? 'red.500' : 'green.500';
const bgColor = isPositive ? positiveBgColor : negativeBgColor;
return (
<Fade in={true} key={idx}>
<Box position="relative">
{/* 时间点圆圈 */}
<Circle
size="30px"
bg={iconColor}
position="absolute"
left="-15px"
top="20px"
zIndex={2}
border="3px solid white"
shadow="md"
>
<Icon
as={isPositive ? FaArrowUp : FaArrowDown}
color="white"
boxSize={3}
/>
</Circle>
{/* 连接线 */}
<Box
position="absolute"
left="15px"
top="35px"
width="20px"
height="2px"
bg="gray.300"
/>
{/* 事件卡片 */}
<Card
ml={10}
bg={bgColor}
cursor="pointer"
onClick={() => handleEventClick(event)}
_hover={{ shadow: 'lg', transform: 'translateX(4px)' }}
transition="all 0.3s ease"
>
<CardBody p={4}>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<VStack align="start" spacing={0}>
<Text fontWeight="bold" fontSize="sm">
{event.event_title}
</Text>
<HStack spacing={2}>
<Icon
as={FaCalendarAlt}
boxSize={3}
color="gray.500"
/>
<Text
fontSize="xs"
color="gray.500"
fontWeight="medium"
>
{event.event_date}
</Text>
</HStack>
</VStack>
<Badge
colorScheme={isPositive ? 'red' : 'green'}
size="sm"
>
{event.event_type}
</Badge>
</HStack>
<Text fontSize="sm" color="gray.600" noOfLines={2}>
{event.event_desc}
</Text>
<HStack>
<Text fontSize="xs" color="gray.500">
:
</Text>
<Progress
value={event.impact_metrics?.impact_score}
size="xs"
width="60px"
colorScheme={
(event.impact_metrics?.impact_score || 0) > 70
? 'red'
: 'orange'
}
borderRadius="full"
/>
<Text
fontSize="xs"
color="gray.500"
fontWeight="bold"
>
{event.impact_metrics?.impact_score || 0}
</Text>
</HStack>
</VStack>
</CardBody>
</Card>
</Box>
</Fade>
);
})}
</VStack>
</Box>
<EventDetailModal
isOpen={isOpen}
onClose={onClose}
event={selectedEvent}
/>
</>
);
};
export default TimelineComponent;

View File

@@ -0,0 +1,346 @@
/**
* 相关公司模态框组件
*
* 显示产业链节点的相关上市公司列表
*/
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
VStack,
HStack,
Text,
Badge,
Card,
CardBody,
Icon,
IconButton,
Center,
Spinner,
Divider,
SimpleGrid,
Box,
Stat,
StatLabel,
StatNumber,
StatHelpText,
Progress,
Tooltip,
Button,
} from '@chakra-ui/react';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import {
FaBuilding,
FaHandshake,
FaUserTie,
FaIndustry,
FaCog,
FaNetworkWired,
FaFlask,
FaStar,
FaArrowRight,
FaArrowLeft,
} from 'react-icons/fa';
import type { ValueChainNode, RelatedCompany } from '../../types';
interface RelatedCompaniesModalProps {
isOpen: boolean;
onClose: () => void;
node: ValueChainNode;
isCompany: boolean;
colorScheme: string;
relatedCompanies: RelatedCompany[];
loadingRelated: boolean;
}
/**
* 获取节点类型对应的图标
*/
const getNodeTypeIcon = (type: string) => {
const icons: Record<string, React.ComponentType> = {
company: FaBuilding,
supplier: FaHandshake,
customer: FaUserTie,
product: FaIndustry,
service: FaCog,
channel: FaNetworkWired,
raw_material: FaFlask,
};
return icons[type] || FaBuilding;
};
/**
* 获取重要度对应的颜色
*/
const getImportanceColor = (score?: number): string => {
if (!score) return 'green';
if (score >= 80) return 'red';
if (score >= 60) return 'orange';
if (score >= 40) return 'yellow';
return 'green';
};
/**
* 获取层级标签
*/
const getLevelLabel = (level?: number): { text: string; color: string } => {
if (level === undefined) return { text: '未知', color: 'gray' };
if (level < 0) return { text: '上游', color: 'orange' };
if (level === 0) return { text: '核心', color: 'blue' };
return { text: '下游', color: 'green' };
};
const RelatedCompaniesModal: React.FC<RelatedCompaniesModalProps> = ({
isOpen,
onClose,
node,
isCompany,
colorScheme,
relatedCompanies,
loadingRelated,
}) => {
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>
<HStack>
<Icon
as={getNodeTypeIcon(node.node_type)}
color={`${colorScheme}.500`}
boxSize={6}
/>
<VStack align="start" spacing={0}>
<Text>{node.node_name}</Text>
<HStack>
<Badge colorScheme={colorScheme}>{node.node_type}</Badge>
{isCompany && (
<Badge colorScheme="blue" variant="solid">
</Badge>
)}
</HStack>
</VStack>
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
{node.node_description && (
<Box>
<Text fontWeight="bold" mb={2} color="gray.600">
</Text>
<Text fontSize="sm" lineHeight="1.6">
{node.node_description}
</Text>
</Box>
)}
<SimpleGrid columns={3} spacing={4}>
<Stat>
<StatLabel fontSize="xs"></StatLabel>
<StatNumber fontSize="lg">
{node.importance_score || 0}
</StatNumber>
<StatHelpText>
<Progress
value={node.importance_score}
size="sm"
colorScheme={getImportanceColor(node.importance_score)}
borderRadius="full"
/>
</StatHelpText>
</Stat>
{node.market_share !== undefined && (
<Stat>
<StatLabel fontSize="xs"></StatLabel>
<StatNumber fontSize="lg">{node.market_share}%</StatNumber>
</Stat>
)}
{node.dependency_degree !== undefined && (
<Stat>
<StatLabel fontSize="xs"></StatLabel>
<StatNumber fontSize="lg">
{node.dependency_degree}%
</StatNumber>
<StatHelpText>
<Progress
value={node.dependency_degree}
size="sm"
colorScheme={
node.dependency_degree > 50 ? 'orange' : 'green'
}
borderRadius="full"
/>
</StatHelpText>
</Stat>
)}
</SimpleGrid>
<Divider />
<Box>
<HStack mb={3} justify="space-between">
<Text fontWeight="bold" color="gray.600">
</Text>
{loadingRelated && <Spinner size="sm" />}
</HStack>
{loadingRelated ? (
<Center py={4}>
<Spinner size="md" />
</Center>
) : relatedCompanies.length > 0 ? (
<VStack
align="stretch"
spacing={3}
maxH="400px"
overflowY="auto"
>
{relatedCompanies.map((company, idx) => {
const levelInfo = getLevelLabel(company.node_info?.node_level);
return (
<Card key={idx} variant="outline" size="sm">
<CardBody p={3}>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<VStack align="start" spacing={1} flex={1}>
<HStack flexWrap="wrap">
<Text fontSize="sm" fontWeight="bold">
{company.stock_name}
</Text>
<Badge size="sm" colorScheme="blue">
{company.stock_code}
</Badge>
<Badge
size="sm"
colorScheme={levelInfo.color}
variant="solid"
>
{levelInfo.text}
</Badge>
</HStack>
{company.company_name && (
<Text
fontSize="xs"
color="gray.500"
noOfLines={1}
>
{company.company_name}
</Text>
)}
</VStack>
<IconButton
size="sm"
icon={<ExternalLinkIcon />}
variant="ghost"
colorScheme="blue"
onClick={() => {
window.location.href = `/company?stock_code=${company.stock_code}`;
}}
aria-label="查看公司详情"
/>
</HStack>
{company.node_info?.node_description && (
<Text
fontSize="xs"
color="gray.600"
noOfLines={2}
>
{company.node_info.node_description}
</Text>
)}
{company.relationships &&
company.relationships.length > 0 && (
<Box
pt={2}
borderTop="1px"
borderColor="gray.100"
>
<Text
fontSize="xs"
fontWeight="bold"
color="gray.600"
mb={1}
>
:
</Text>
<VStack align="stretch" spacing={1}>
{company.relationships.map((rel, ridx) => (
<HStack
key={ridx}
fontSize="xs"
spacing={2}
>
<Icon
as={
rel.role === 'source'
? FaArrowRight
: FaArrowLeft
}
color={
rel.role === 'source'
? 'green.500'
: 'orange.500'
}
boxSize={3}
/>
<Text color="gray.700" noOfLines={1}>
{rel.role === 'source'
? '流向'
: '来自'}
<Text
as="span"
fontWeight="medium"
mx={1}
>
{rel.connected_node}
</Text>
</Text>
</HStack>
))}
</VStack>
</Box>
)}
</VStack>
</CardBody>
</Card>
);
})}
</VStack>
) : (
<Center py={4}>
<VStack spacing={2}>
<Icon as={FaBuilding} boxSize={8} color="gray.300" />
<Text fontSize="sm" color="gray.500">
</Text>
</VStack>
</Center>
)}
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<Button colorScheme="blue" onClick={onClose}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default RelatedCompaniesModal;

View File

@@ -0,0 +1,264 @@
/**
* 产业链节点卡片组件
*
* 显示产业链中的单个节点,点击可展开查看相关公司
* 黑金主题风格
*/
import React, { useState, memo } from 'react';
import {
Card,
CardBody,
VStack,
HStack,
Text,
Badge,
Icon,
Progress,
Box,
Tooltip,
useDisclosure,
useToast,
ScaleFade,
} from '@chakra-ui/react';
import {
FaBuilding,
FaHandshake,
FaUserTie,
FaIndustry,
FaCog,
FaNetworkWired,
FaFlask,
FaStar,
} from 'react-icons/fa';
import { logger } from '@utils/logger';
import axios from '@utils/axiosConfig';
import RelatedCompaniesModal from './RelatedCompaniesModal';
import type { ValueChainNodeCardProps, RelatedCompany } from '../../types';
// 黑金主题配置
const THEME = {
cardBg: 'gray.700',
gold: '#D4AF37',
goldLight: '#F0D78C',
textPrimary: 'white',
textSecondary: 'gray.400',
// 上游颜色
upstream: {
bg: 'rgba(237, 137, 54, 0.1)',
border: 'orange.600',
badge: 'orange',
icon: 'orange.400',
},
// 核心企业颜色
core: {
bg: 'rgba(66, 153, 225, 0.15)',
border: 'blue.500',
badge: 'blue',
icon: 'blue.400',
},
// 下游颜色
downstream: {
bg: 'rgba(72, 187, 120, 0.1)',
border: 'green.600',
badge: 'green',
icon: 'green.400',
},
};
/**
* 获取节点类型对应的图标
*/
const getNodeTypeIcon = (type: string) => {
const icons: Record<string, React.ComponentType> = {
company: FaBuilding,
supplier: FaHandshake,
customer: FaUserTie,
product: FaIndustry,
service: FaCog,
channel: FaNetworkWired,
raw_material: FaFlask,
regulator: FaBuilding,
end_user: FaUserTie,
};
return icons[type] || FaBuilding;
};
/**
* 获取重要度对应的颜色
*/
const getImportanceColor = (score?: number): string => {
if (!score) return 'green';
if (score >= 80) return 'red';
if (score >= 60) return 'orange';
if (score >= 40) return 'yellow';
return 'green';
};
const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = memo(({
node,
isCompany = false,
level = 0,
}) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const [relatedCompanies, setRelatedCompanies] = useState<RelatedCompany[]>([]);
const [loadingRelated, setLoadingRelated] = useState(false);
const toast = useToast();
// 根据层级确定颜色方案
const getColorConfig = () => {
if (isCompany || level === 0) return THEME.core;
if (level < 0) return THEME.upstream;
return THEME.downstream;
};
const colorConfig = getColorConfig();
// 获取相关公司数据
const fetchRelatedCompanies = async () => {
setLoadingRelated(true);
try {
const { data } = await axios.get(
`/api/company/value-chain/related-companies?node_name=${encodeURIComponent(
node.node_name
)}`
);
if (data.success) {
setRelatedCompanies(data.data || []);
} else {
toast({
title: '获取相关公司失败',
description: data.message,
status: 'error',
duration: 3000,
isClosable: true,
});
}
} catch (error) {
logger.error('ValueChainNodeCard', 'fetchRelatedCompanies', error, {
node_name: node.node_name,
});
toast({
title: '获取相关公司失败',
description: error instanceof Error ? error.message : '未知错误',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setLoadingRelated(false);
}
};
// 点击卡片打开模态框
const handleCardClick = () => {
onOpen();
if (relatedCompanies.length === 0) {
fetchRelatedCompanies();
}
};
return (
<>
<ScaleFade in={true} initialScale={0.9}>
<Card
bg={colorConfig.bg}
borderColor={colorConfig.border}
borderWidth={isCompany ? 2 : 1}
shadow={isCompany ? 'lg' : 'sm'}
cursor="pointer"
onClick={handleCardClick}
_hover={{
shadow: 'xl',
transform: 'translateY(-4px)',
borderColor: THEME.gold,
}}
transition="all 0.3s ease"
minH="140px"
>
<CardBody p={4}>
<VStack spacing={3} align="stretch">
<HStack justify="space-between">
<HStack spacing={2}>
<Icon
as={getNodeTypeIcon(node.node_type)}
color={colorConfig.icon}
boxSize={5}
/>
{isCompany && (
<Badge colorScheme={colorConfig.badge} variant="solid">
</Badge>
)}
</HStack>
{node.importance_score !== undefined &&
node.importance_score >= 70 && (
<Tooltip label="重要节点">
<span>
<Icon as={FaStar} color={THEME.gold} boxSize={4} />
</span>
</Tooltip>
)}
</HStack>
<Text fontWeight="bold" fontSize="sm" color={THEME.textPrimary} noOfLines={2}>
{node.node_name}
</Text>
{node.node_description && (
<Text fontSize="xs" color={THEME.textSecondary} noOfLines={2}>
{node.node_description}
</Text>
)}
<HStack spacing={2} flexWrap="wrap">
<Badge variant="subtle" size="sm" colorScheme={colorConfig.badge}>
{node.node_type}
</Badge>
{node.market_share !== undefined && (
<Badge variant="outline" size="sm" color={THEME.goldLight}>
{node.market_share}%
</Badge>
)}
</HStack>
{node.importance_score !== undefined && (
<Box>
<HStack justify="space-between" mb={1}>
<Text fontSize="xs" color={THEME.textSecondary}>
</Text>
<Text fontSize="xs" fontWeight="bold" color={THEME.goldLight}>
{node.importance_score}
</Text>
</HStack>
<Progress
value={node.importance_score}
size="xs"
colorScheme={getImportanceColor(node.importance_score)}
borderRadius="full"
bg="gray.600"
/>
</Box>
)}
</VStack>
</CardBody>
</Card>
</ScaleFade>
<RelatedCompaniesModal
isOpen={isOpen}
onClose={onClose}
node={node}
isCompany={isCompany}
colorScheme={colorConfig.badge}
relatedCompanies={relatedCompanies}
loadingRelated={loadingRelated}
/>
</>
);
});
ValueChainNodeCard.displayName = 'ValueChainNodeCard';
export default ValueChainNodeCard;

View File

@@ -0,0 +1,52 @@
/**
* 业务结构 Tab
*
* 包含:业务结构分析 + 业务板块详情
*/
import React, { memo } from 'react';
import TabPanelContainer from '@components/TabPanelContainer';
import { BusinessStructureCard, BusinessSegmentsCard } from '../components';
import type { ComprehensiveData } from '../types';
export interface BusinessTabProps {
comprehensiveData?: ComprehensiveData;
cardBg?: string;
expandedSegments: Record<number, boolean>;
onToggleSegment: (index: number) => void;
}
const BusinessTab: React.FC<BusinessTabProps> = memo(({
comprehensiveData,
cardBg,
expandedSegments,
onToggleSegment,
}) => {
return (
<TabPanelContainer showDisclaimer>
{/* 业务结构分析 */}
{comprehensiveData?.business_structure &&
comprehensiveData.business_structure.length > 0 && (
<BusinessStructureCard
businessStructure={comprehensiveData.business_structure}
cardBg={cardBg}
/>
)}
{/* 业务板块详情 */}
{comprehensiveData?.business_segments &&
comprehensiveData.business_segments.length > 0 && (
<BusinessSegmentsCard
businessSegments={comprehensiveData.business_segments}
expandedSegments={expandedSegments}
onToggleSegment={onToggleSegment}
cardBg={cardBg}
/>
)}
</TabPanelContainer>
);
});
BusinessTab.displayName = 'BusinessTab';
export default BusinessTab;

View File

@@ -0,0 +1,49 @@
/**
* 发展历程 Tab
*
* 包含:关键因素 + 发展时间线Grid 布局)
*/
import React, { memo } from 'react';
import { Grid, GridItem } from '@chakra-ui/react';
import TabPanelContainer from '@components/TabPanelContainer';
import { KeyFactorsCard, TimelineCard } from '../components';
import type { KeyFactorsData } from '../types';
export interface DevelopmentTabProps {
keyFactorsData?: KeyFactorsData;
cardBg?: string;
}
const DevelopmentTab: React.FC<DevelopmentTabProps> = memo(({
keyFactorsData,
cardBg,
}) => {
return (
<TabPanelContainer showDisclaimer>
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
<GridItem colSpan={{ base: 2, lg: 1 }}>
{keyFactorsData?.key_factors && (
<KeyFactorsCard
keyFactors={keyFactorsData.key_factors}
cardBg={cardBg}
/>
)}
</GridItem>
<GridItem colSpan={{ base: 2, lg: 1 }}>
{keyFactorsData?.development_timeline && (
<TimelineCard
developmentTimeline={keyFactorsData.development_timeline}
cardBg={cardBg}
/>
)}
</GridItem>
</Grid>
</TabPanelContainer>
);
});
DevelopmentTab.displayName = 'DevelopmentTab';
export default DevelopmentTab;

View File

@@ -0,0 +1,58 @@
/**
* 战略分析 Tab
*
* 包含:核心定位 + 战略分析 + 竞争地位分析(含行业排名弹窗)
*/
import React, { memo } from 'react';
import TabPanelContainer from '@components/TabPanelContainer';
import {
CorePositioningCard,
StrategyAnalysisCard,
CompetitiveAnalysisCard,
} from '../components';
import type { ComprehensiveData, IndustryRankData } from '../types';
export interface StrategyTabProps {
comprehensiveData?: ComprehensiveData;
industryRankData?: IndustryRankData[];
cardBg?: string;
}
const StrategyTab: React.FC<StrategyTabProps> = memo(({
comprehensiveData,
industryRankData,
cardBg,
}) => {
return (
<TabPanelContainer showDisclaimer>
{/* 核心定位卡片 */}
{comprehensiveData?.qualitative_analysis && (
<CorePositioningCard
qualitativeAnalysis={comprehensiveData.qualitative_analysis}
cardBg={cardBg}
/>
)}
{/* 战略分析 */}
{comprehensiveData?.qualitative_analysis?.strategy && (
<StrategyAnalysisCard
strategy={comprehensiveData.qualitative_analysis.strategy}
cardBg={cardBg}
/>
)}
{/* 竞争地位分析(包含行业排名弹窗) */}
{comprehensiveData?.competitive_position && (
<CompetitiveAnalysisCard
comprehensiveData={comprehensiveData}
industryRankData={industryRankData}
/>
)}
</TabPanelContainer>
);
});
StrategyTab.displayName = 'StrategyTab';
export default StrategyTab;

View File

@@ -0,0 +1,32 @@
/**
* 产业链 Tab
*
* 包含:产业链分析(层级视图 + Sankey 流向图)
*/
import React, { memo } from 'react';
import TabPanelContainer from '@components/TabPanelContainer';
import { ValueChainCard } from '../components';
import type { ValueChainData } from '../types';
export interface ValueChainTabProps {
valueChainData?: ValueChainData;
cardBg?: string;
}
const ValueChainTab: React.FC<ValueChainTabProps> = memo(({
valueChainData,
cardBg,
}) => {
return (
<TabPanelContainer showDisclaimer>
{valueChainData && (
<ValueChainCard valueChainData={valueChainData} cardBg={cardBg} />
)}
</TabPanelContainer>
);
});
ValueChainTab.displayName = 'ValueChainTab';
export default ValueChainTab;

View File

@@ -0,0 +1,14 @@
/**
* DeepAnalysisTab - Tab 组件导出
*/
export { default as StrategyTab } from './StrategyTab';
export { default as BusinessTab } from './BusinessTab';
export { default as ValueChainTab } from './ValueChainTab';
export { default as DevelopmentTab } from './DevelopmentTab';
// 导出类型
export type { StrategyTabProps } from './StrategyTab';
export type { BusinessTabProps } from './BusinessTab';
export type { ValueChainTabProps } from './ValueChainTab';
export type { DevelopmentTabProps } from './DevelopmentTab';

View File

@@ -0,0 +1,403 @@
/**
* DeepAnalysisTab 组件类型定义
*
* 深度分析 Tab 所需的所有数据接口类型
*/
// ==================== 格式化工具类型 ====================
export interface FormatUtils {
formatCurrency: (value: number | null | undefined) => string;
formatBusinessRevenue: (value: number | null | undefined, unit?: string) => string;
formatPercentage: (value: number | null | undefined) => string;
}
// ==================== 竞争力评分类型 ====================
export interface CompetitiveScores {
market_position?: number;
technology?: number;
brand?: number;
operation?: number;
finance?: number;
innovation?: number;
risk?: number;
growth?: number;
}
export interface CompetitiveRanking {
industry_rank: number;
total_companies: number;
}
export interface CompetitiveAnalysis {
main_competitors?: string;
competitive_advantages?: string;
competitive_disadvantages?: string;
}
export interface CompetitivePosition {
scores?: CompetitiveScores;
ranking?: CompetitiveRanking;
analysis?: CompetitiveAnalysis;
}
// ==================== 核心定位类型 ====================
/** 特性项(用于核心定位下方的两个区块:零售业务/综合金融) */
export interface FeatureItem {
/** 图标名称,如 'bank', 'fire' */
icon: string;
/** 标题,如 '零售业务' */
title: string;
/** 描述文字 */
description: string;
}
/** 投资亮点项(结构化) */
export interface InvestmentHighlightItem {
/** 图标名称,如 'users', 'trending-up' */
icon: string;
/** 标题,如 '综合金融优势' */
title: string;
/** 描述文字 */
description: string;
}
/** 商业模式板块 */
export interface BusinessModelSection {
/** 标题,如 '零售银行核心驱动' */
title: string;
/** 描述文字 */
description: string;
/** 可选的标签,如 ['AI应用深化', '大数据分析'] */
tags?: string[];
}
export interface CorePositioning {
/** 一句话介绍 */
one_line_intro?: string;
/** 核心特性2个显示在核心定位区域下方 */
features?: FeatureItem[];
/** 投资亮点 - 支持结构化数组(新格式)或字符串(旧格式) */
investment_highlights?: InvestmentHighlightItem[] | string;
/** 结构化商业模式数组 */
business_model_sections?: BusinessModelSection[];
/** 原 investment_highlights 文本格式(兼容旧数据,优先级低于 investment_highlights */
investment_highlights_text?: string;
/** 商业模式描述(兼容旧数据) */
business_model_desc?: string;
}
export interface Strategy {
strategy_description?: string;
strategic_initiatives?: string;
}
export interface QualitativeAnalysis {
core_positioning?: CorePositioning;
strategy?: Strategy;
}
// ==================== 业务结构类型 ====================
export interface FinancialMetrics {
revenue?: number;
revenue_ratio?: number;
gross_margin?: number;
}
export interface GrowthMetrics {
revenue_growth?: number;
}
export interface BusinessStructure {
business_name: string;
business_level: number;
revenue?: number;
revenue_unit?: string;
financial_metrics?: FinancialMetrics;
growth_metrics?: GrowthMetrics;
report_period?: string;
}
// ==================== 业务板块类型 ====================
export interface BusinessSegment {
segment_name: string;
segment_description?: string;
competitive_position?: string;
future_potential?: string;
key_products?: string;
market_share?: number;
revenue_contribution?: number;
}
// ==================== 综合数据类型 ====================
export interface ComprehensiveData {
qualitative_analysis?: QualitativeAnalysis;
competitive_position?: CompetitivePosition;
business_structure?: BusinessStructure[];
business_segments?: BusinessSegment[];
}
// ==================== 产业链类型 ====================
export interface ValueChainNode {
node_name: string;
node_type: string;
node_description?: string;
node_level?: number;
importance_score?: number;
market_share?: number;
dependency_degree?: number;
}
export interface ValueChainFlow {
source?: { node_name: string };
target?: { node_name: string };
flow_metrics?: {
flow_ratio?: string;
};
}
export interface NodesByLevel {
[key: string]: ValueChainNode[];
}
export interface ValueChainStructure {
nodes_by_level?: NodesByLevel;
}
export interface AnalysisSummary {
upstream_nodes?: number;
company_nodes?: number;
downstream_nodes?: number;
total_nodes?: number;
}
export interface ValueChainData {
value_chain_flows?: ValueChainFlow[];
value_chain_structure?: ValueChainStructure;
analysis_summary?: AnalysisSummary;
}
// ==================== 相关公司类型 ====================
export interface RelatedCompanyRelationship {
role: 'source' | 'target';
connected_node: string;
}
export interface RelatedCompanyNodeInfo {
node_level?: number;
node_description?: string;
}
export interface RelatedCompany {
stock_code: string;
stock_name: string;
company_name?: string;
node_info?: RelatedCompanyNodeInfo;
relationships?: RelatedCompanyRelationship[];
}
// ==================== 关键因素类型 ====================
export type ImpactDirection = 'positive' | 'negative' | 'neutral' | 'mixed';
export interface KeyFactor {
factor_name: string;
factor_value: string | number;
factor_unit?: string;
factor_desc?: string;
impact_direction?: ImpactDirection;
impact_weight?: number;
year_on_year?: number;
report_period?: string;
}
export interface FactorCategory {
category_name: string;
factors: KeyFactor[];
}
export interface KeyFactors {
total_factors?: number;
categories: FactorCategory[];
}
// ==================== 时间线事件类型 ====================
export interface ImpactMetrics {
is_positive?: boolean;
impact_score?: number;
}
export interface RelatedInfo {
financial_impact?: string;
}
export interface TimelineEvent {
event_title: string;
event_date: string;
event_type: string;
event_desc: string;
impact_metrics?: ImpactMetrics;
related_info?: RelatedInfo;
}
export interface TimelineStatistics {
positive_events?: number;
negative_events?: number;
}
export interface DevelopmentTimeline {
events: TimelineEvent[];
statistics?: TimelineStatistics;
}
// ==================== 关键因素数据类型 ====================
export interface KeyFactorsData {
key_factors?: KeyFactors;
development_timeline?: DevelopmentTimeline;
}
// ==================== 行业排名类型 ====================
/** 行业排名指标 */
export interface RankingMetric {
value?: number;
rank?: number;
industry_avg?: number;
}
/** 行业排名数据 */
export interface IndustryRankData {
period: string;
report_type: string;
rankings?: {
industry_name: string;
level_description: string;
metrics?: {
eps?: RankingMetric;
bvps?: RankingMetric;
roe?: RankingMetric;
revenue_growth?: RankingMetric;
profit_growth?: RankingMetric;
operating_margin?: RankingMetric;
debt_ratio?: RankingMetric;
receivable_turnover?: RankingMetric;
};
}[];
}
// ==================== 主组件 Props 类型 ====================
/** Tab 类型 */
export type DeepAnalysisTabKey = 'strategy' | 'business' | 'valueChain' | 'development';
export interface DeepAnalysisTabProps {
comprehensiveData?: ComprehensiveData;
valueChainData?: ValueChainData;
keyFactorsData?: KeyFactorsData;
industryRankData?: IndustryRankData[];
loading?: boolean;
cardBg?: string;
expandedSegments: Record<number, boolean>;
onToggleSegment: (index: number) => void;
/** 当前激活的 Tab受控模式 */
activeTab?: DeepAnalysisTabKey;
/** Tab 切换回调(懒加载触发) */
onTabChange?: (index: number, tabKey: string) => void;
}
// ==================== 子组件 Props 类型 ====================
export interface DisclaimerBoxProps {
// 无需 props
}
export interface ScoreBarProps {
label: string;
score?: number;
icon?: React.ComponentType;
}
export interface BusinessTreeItemProps {
business: BusinessStructure;
depth?: number;
}
export interface KeyFactorCardProps {
factor: KeyFactor;
}
export interface ValueChainNodeCardProps {
node: ValueChainNode;
isCompany?: boolean;
level?: number;
}
export interface TimelineComponentProps {
events: TimelineEvent[];
}
// ==================== 图表配置类型 ====================
export interface RadarIndicator {
name: string;
max: number;
}
export interface RadarChartOption {
tooltip: { trigger: string };
radar: {
indicator: RadarIndicator[];
shape: string;
splitNumber: number;
name: { textStyle: { color: string; fontSize: number } };
splitLine: { lineStyle: { color: string[] } };
splitArea: { show: boolean; areaStyle: { color: string[] } };
axisLine: { lineStyle: { color: string } };
};
series: Array<{
name: string;
type: string;
data: Array<{
value: number[];
name: string;
symbol: string;
symbolSize: number;
lineStyle: { width: number; color: string };
areaStyle: { color: string };
label: { show: boolean; formatter: (params: { value: number }) => number; color: string; fontSize: number };
}>;
}>;
}
export interface SankeyNode {
name: string;
}
export interface SankeyLink {
source: string;
target: string;
value: number;
lineStyle: { color: string; opacity: number };
}
export interface SankeyChartOption {
tooltip: { trigger: string; triggerOn: string };
series: Array<{
type: string;
layout: string;
emphasis: { focus: string };
data: SankeyNode[];
links: SankeyLink[];
lineStyle: { color: string; curveness: number };
label: { color: string; fontSize: number };
}>;
}

View File

@@ -0,0 +1,139 @@
/**
* DeepAnalysisTab 图表配置工具
*
* 生成雷达图和桑基图的 ECharts 配置
*/
import type {
ComprehensiveData,
ValueChainData,
RadarChartOption,
SankeyChartOption,
} from '../types';
/**
* 生成竞争力雷达图配置
* @param comprehensiveData - 综合分析数据
* @returns ECharts 雷达图配置,或 null数据不足时
*/
export const getRadarChartOption = (
comprehensiveData?: ComprehensiveData
): RadarChartOption | null => {
if (!comprehensiveData?.competitive_position?.scores) return null;
const scores = comprehensiveData.competitive_position.scores;
const indicators = [
{ name: '市场地位', max: 100 },
{ name: '技术实力', max: 100 },
{ name: '品牌价值', max: 100 },
{ name: '运营效率', max: 100 },
{ name: '财务健康', max: 100 },
{ name: '创新能力', max: 100 },
{ name: '风险控制', max: 100 },
{ name: '成长潜力', max: 100 },
];
const data = [
scores.market_position || 0,
scores.technology || 0,
scores.brand || 0,
scores.operation || 0,
scores.finance || 0,
scores.innovation || 0,
scores.risk || 0,
scores.growth || 0,
];
return {
tooltip: { trigger: 'item' },
radar: {
indicator: indicators,
shape: 'polygon',
splitNumber: 4,
name: { textStyle: { color: '#666', fontSize: 12 } },
splitLine: {
lineStyle: { color: ['#e8e8e8', '#e0e0e0', '#d0d0d0', '#c0c0c0'] },
},
splitArea: {
show: true,
areaStyle: {
color: ['rgba(250,250,250,0.3)', 'rgba(200,200,200,0.3)'],
},
},
axisLine: { lineStyle: { color: '#ddd' } },
},
series: [
{
name: '竞争力评分',
type: 'radar',
data: [
{
value: data,
name: '当前评分',
symbol: 'circle',
symbolSize: 5,
lineStyle: { width: 2, color: '#3182ce' },
areaStyle: { color: 'rgba(49, 130, 206, 0.3)' },
label: {
show: true,
formatter: (params: { value: number }) => params.value,
color: '#3182ce',
fontSize: 10,
},
},
],
},
],
};
};
/**
* 生成产业链桑基图配置
* @param valueChainData - 产业链数据
* @returns ECharts 桑基图配置,或 null数据不足时
*/
export const getSankeyChartOption = (
valueChainData?: ValueChainData
): SankeyChartOption | null => {
if (
!valueChainData?.value_chain_flows ||
valueChainData.value_chain_flows.length === 0
) {
return null;
}
const nodes = new Set<string>();
const links: Array<{
source: string;
target: string;
value: number;
lineStyle: { color: string; opacity: number };
}> = [];
valueChainData.value_chain_flows.forEach((flow) => {
if (!flow?.source?.node_name || !flow?.target?.node_name) return;
nodes.add(flow.source.node_name);
nodes.add(flow.target.node_name);
links.push({
source: flow.source.node_name,
target: flow.target.node_name,
value: parseFloat(flow.flow_metrics?.flow_ratio || '1') || 1,
lineStyle: { color: 'source', opacity: 0.6 },
});
});
return {
tooltip: { trigger: 'item', triggerOn: 'mousemove' },
series: [
{
type: 'sankey',
layout: 'none',
emphasis: { focus: 'adjacency' },
data: Array.from(nodes).map((name) => ({ name })),
links: links,
lineStyle: { color: 'gradient', curveness: 0.5 },
label: { color: '#333', fontSize: 10 },
},
],
};
};

View File

@@ -0,0 +1,650 @@
// src/views/Company/components/CompanyOverview/NewsEventsTab.js
// 新闻动态 Tab - 相关新闻事件列表 + 分页
import React from "react";
import {
Box,
VStack,
HStack,
Text,
Badge,
Icon,
Card,
CardBody,
Button,
Input,
InputGroup,
InputLeftElement,
Tag,
Center,
Spinner,
} from "@chakra-ui/react";
import { SearchIcon } from "@chakra-ui/icons";
import {
FaNewspaper,
FaBullhorn,
FaGavel,
FaFlask,
FaDollarSign,
FaShieldAlt,
FaFileAlt,
FaIndustry,
FaEye,
FaFire,
FaChartLine,
FaChevronLeft,
FaChevronRight,
} from "react-icons/fa";
// 黑金主题配色
const THEME_PRESETS = {
blackGold: {
bg: "#0A0E17",
cardBg: "#1A1F2E",
cardHoverBg: "#212633",
cardBorder: "rgba(212, 175, 55, 0.2)",
cardHoverBorder: "#D4AF37",
textPrimary: "#E8E9ED",
textSecondary: "#A0A4B8",
textMuted: "#6B7280",
gold: "#D4AF37",
goldLight: "#FFD54F",
inputBg: "#151922",
inputBorder: "#2D3748",
buttonBg: "#D4AF37",
buttonText: "#0A0E17",
buttonHoverBg: "#FFD54F",
badgeS: { bg: "rgba(255, 195, 0, 0.2)", color: "#FFD54F" },
badgeA: { bg: "rgba(249, 115, 22, 0.2)", color: "#FB923C" },
badgeB: { bg: "rgba(59, 130, 246, 0.2)", color: "#60A5FA" },
badgeC: { bg: "rgba(107, 114, 128, 0.2)", color: "#9CA3AF" },
tagBg: "rgba(212, 175, 55, 0.15)",
tagColor: "#D4AF37",
spinnerColor: "#D4AF37",
},
default: {
bg: "white",
cardBg: "white",
cardHoverBg: "gray.50",
cardBorder: "gray.200",
cardHoverBorder: "blue.300",
textPrimary: "gray.800",
textSecondary: "gray.600",
textMuted: "gray.500",
gold: "blue.500",
goldLight: "blue.400",
inputBg: "white",
inputBorder: "gray.200",
buttonBg: "blue.500",
buttonText: "white",
buttonHoverBg: "blue.600",
badgeS: { bg: "red.100", color: "red.600" },
badgeA: { bg: "orange.100", color: "orange.600" },
badgeB: { bg: "yellow.100", color: "yellow.600" },
badgeC: { bg: "green.100", color: "green.600" },
tagBg: "cyan.50",
tagColor: "cyan.600",
spinnerColor: "blue.500",
},
};
/**
* 新闻动态 Tab 组件
*
* Props:
* - newsEvents: 新闻事件列表数组
* - newsLoading: 加载状态
* - newsPagination: 分页信息 { page, per_page, total, pages, has_next, has_prev }
* - searchQuery: 搜索关键词
* - onSearchChange: 搜索输入回调 (value) => void
* - onSearch: 搜索提交回调 () => void
* - onPageChange: 分页回调 (page) => void
* - cardBg: 卡片背景色
* - themePreset: 主题预设 'blackGold' | 'default'
*/
const NewsEventsTab = ({
newsEvents = [],
newsLoading = false,
newsPagination = {
page: 1,
per_page: 10,
total: 0,
pages: 0,
has_next: false,
has_prev: false,
},
searchQuery = "",
onSearchChange,
onSearch,
onPageChange,
cardBg,
themePreset = "default",
}) => {
// 获取主题配色
const theme = THEME_PRESETS[themePreset] || THEME_PRESETS.default;
const isBlackGold = themePreset === "blackGold";
// 事件类型图标映射
const getEventTypeIcon = (eventType) => {
const iconMap = {
企业公告: FaBullhorn,
政策: FaGavel,
技术突破: FaFlask,
企业融资: FaDollarSign,
政策监管: FaShieldAlt,
政策动态: FaFileAlt,
行业事件: FaIndustry,
};
return iconMap[eventType] || FaNewspaper;
};
// 重要性颜色映射 - 根据主题返回不同配色
const getImportanceBadgeStyle = (importance) => {
if (isBlackGold) {
const styles = {
S: theme.badgeS,
A: theme.badgeA,
B: theme.badgeB,
C: theme.badgeC,
};
return styles[importance] || { bg: "rgba(107, 114, 128, 0.2)", color: "#9CA3AF" };
}
// 默认主题使用 colorScheme
const colorMap = {
S: "red",
A: "orange",
B: "yellow",
C: "green",
};
return { colorScheme: colorMap[importance] || "gray" };
};
// 处理搜索输入
const handleInputChange = (e) => {
onSearchChange?.(e.target.value);
};
// 处理搜索提交
const handleSearchSubmit = () => {
onSearch?.();
};
// 处理键盘事件
const handleKeyPress = (e) => {
if (e.key === "Enter") {
handleSearchSubmit();
}
};
// 处理分页
const handlePageChange = (page) => {
onPageChange?.(page);
// 滚动到列表顶部
document
.getElementById("news-list-top")
?.scrollIntoView({ behavior: "smooth" });
};
// 渲染分页按钮
const renderPaginationButtons = () => {
const { page: currentPage, pages: totalPages } = newsPagination;
const pageButtons = [];
// 显示当前页及前后各2页
let startPage = Math.max(1, currentPage - 2);
let endPage = Math.min(totalPages, currentPage + 2);
// 如果开始页大于1显示省略号
if (startPage > 1) {
pageButtons.push(
<Text key="start-ellipsis" fontSize="sm" color={theme.textMuted}>
...
</Text>
);
}
for (let i = startPage; i <= endPage; i++) {
const isActive = i === currentPage;
pageButtons.push(
<Button
key={i}
size="sm"
bg={isActive ? theme.buttonBg : (isBlackGold ? theme.inputBg : undefined)}
color={isActive ? theme.buttonText : theme.textSecondary}
borderColor={isActive ? theme.gold : theme.cardBorder}
borderWidth="1px"
_hover={{
bg: isActive ? theme.buttonHoverBg : theme.cardHoverBg,
borderColor: theme.gold
}}
onClick={() => handlePageChange(i)}
isDisabled={newsLoading}
>
{i}
</Button>
);
}
// 如果结束页小于总页数,显示省略号
if (endPage < totalPages) {
pageButtons.push(
<Text key="end-ellipsis" fontSize="sm" color={theme.textMuted}>
...
</Text>
);
}
return pageButtons;
};
return (
<VStack spacing={4} align="stretch">
<Card bg={cardBg || theme.cardBg} shadow="md" borderColor={theme.cardBorder} borderWidth={isBlackGold ? "1px" : "0"}>
<CardBody>
<VStack spacing={4} align="stretch">
{/* 搜索框和统计信息 */}
<HStack justify="space-between" flexWrap="wrap">
<HStack flex={1} minW="300px">
<InputGroup>
<InputLeftElement pointerEvents="none">
<SearchIcon color={theme.textMuted} />
</InputLeftElement>
<Input
placeholder="搜索相关新闻..."
value={searchQuery}
onChange={handleInputChange}
onKeyPress={handleKeyPress}
bg={theme.inputBg}
borderColor={theme.inputBorder}
color={theme.textPrimary}
_placeholder={{ color: theme.textMuted }}
_hover={{ borderColor: theme.gold }}
_focus={{ borderColor: theme.gold, boxShadow: `0 0 0 1px ${theme.gold}` }}
/>
</InputGroup>
<Button
bg={theme.buttonBg}
color={theme.buttonText}
_hover={{ bg: theme.buttonHoverBg }}
onClick={handleSearchSubmit}
isLoading={newsLoading}
minW="80px"
>
搜索
</Button>
</HStack>
{newsPagination.total > 0 && (
<HStack spacing={2}>
<Icon as={FaNewspaper} color={theme.gold} />
<Text fontSize="sm" color={theme.textSecondary}>
共找到{" "}
<Text as="span" fontWeight="bold" color={theme.gold}>
{newsPagination.total}
</Text>{" "}
条新闻
</Text>
</HStack>
)}
</HStack>
<div id="news-list-top" />
{/* 新闻列表 */}
{newsLoading ? (
<Center h="400px">
<VStack spacing={3}>
<Spinner size="xl" color={theme.spinnerColor} thickness="4px" />
<Text color={theme.textSecondary}>正在加载新闻...</Text>
</VStack>
</Center>
) : newsEvents.length > 0 ? (
<>
<VStack spacing={3} align="stretch">
{newsEvents.map((event, idx) => {
const importanceBadgeStyle = getImportanceBadgeStyle(
event.importance
);
const eventTypeIcon = getEventTypeIcon(event.event_type);
return (
<Card
key={event.id || idx}
variant="outline"
bg={theme.cardBg}
borderColor={theme.cardBorder}
_hover={{
bg: theme.cardHoverBg,
shadow: "md",
borderColor: theme.cardHoverBorder,
}}
transition="all 0.2s"
>
<CardBody p={4}>
<VStack align="stretch" spacing={3}>
{/* 标题栏 */}
<HStack justify="space-between" align="start">
<VStack align="start" spacing={2} flex={1}>
<HStack>
<Icon
as={eventTypeIcon}
color={theme.gold}
boxSize={5}
/>
<Text
fontWeight="bold"
fontSize="lg"
lineHeight="1.3"
color={theme.textPrimary}
>
{event.title}
</Text>
</HStack>
{/* 标签栏 */}
<HStack spacing={2} flexWrap="wrap">
{event.importance && (
<Badge
{...(isBlackGold ? {} : { colorScheme: importanceBadgeStyle.colorScheme, variant: "solid" })}
bg={isBlackGold ? importanceBadgeStyle.bg : undefined}
color={isBlackGold ? importanceBadgeStyle.color : undefined}
px={2}
>
{event.importance}
</Badge>
)}
{event.event_type && (
<Badge
{...(isBlackGold ? {} : { colorScheme: "blue", variant: "outline" })}
bg={isBlackGold ? "rgba(59, 130, 246, 0.2)" : undefined}
color={isBlackGold ? "#60A5FA" : undefined}
borderColor={isBlackGold ? "rgba(59, 130, 246, 0.3)" : undefined}
>
{event.event_type}
</Badge>
)}
{event.invest_score && (
<Badge
{...(isBlackGold ? {} : { colorScheme: "purple", variant: "subtle" })}
bg={isBlackGold ? "rgba(139, 92, 246, 0.2)" : undefined}
color={isBlackGold ? "#A78BFA" : undefined}
>
投资分: {event.invest_score}
</Badge>
)}
{event.keywords && event.keywords.length > 0 && (
<>
{event.keywords
.slice(0, 4)
.map((keyword, kidx) => (
<Tag
key={kidx}
size="sm"
{...(isBlackGold ? {} : { colorScheme: "cyan", variant: "subtle" })}
bg={isBlackGold ? theme.tagBg : undefined}
color={isBlackGold ? theme.tagColor : undefined}
>
{typeof keyword === "string"
? keyword
: keyword?.concept ||
keyword?.name ||
"未知"}
</Tag>
))}
</>
)}
</HStack>
</VStack>
{/* 右侧信息栏 */}
<VStack align="end" spacing={1} minW="100px">
<Text fontSize="xs" color={theme.textMuted}>
{event.created_at
? new Date(
event.created_at
).toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
: ""}
</Text>
<HStack spacing={3}>
{event.view_count !== undefined && (
<HStack spacing={1}>
<Icon
as={FaEye}
boxSize={3}
color={theme.textMuted}
/>
<Text fontSize="xs" color={theme.textMuted}>
{event.view_count}
</Text>
</HStack>
)}
{event.hot_score !== undefined && (
<HStack spacing={1}>
<Icon
as={FaFire}
boxSize={3}
color={theme.goldLight}
/>
<Text fontSize="xs" color={theme.textMuted}>
{event.hot_score.toFixed(1)}
</Text>
</HStack>
)}
</HStack>
{event.creator && (
<Text fontSize="xs" color={theme.textMuted}>
@{event.creator.username}
</Text>
)}
</VStack>
</HStack>
{/* 描述 */}
{event.description && (
<Text
fontSize="sm"
color={theme.textSecondary}
lineHeight="1.6"
>
{event.description}
</Text>
)}
{/* 收益率数据 */}
{(event.related_avg_chg !== null ||
event.related_max_chg !== null ||
event.related_week_chg !== null) && (
<Box
pt={2}
borderTop="1px"
borderColor={theme.cardBorder}
>
<HStack spacing={6} flexWrap="wrap">
<HStack spacing={1}>
<Icon
as={FaChartLine}
boxSize={3}
color={theme.textMuted}
/>
<Text
fontSize="xs"
color={theme.textMuted}
fontWeight="medium"
>
相关涨跌:
</Text>
</HStack>
{event.related_avg_chg !== null &&
event.related_avg_chg !== undefined && (
<HStack spacing={1}>
<Text fontSize="xs" color={theme.textMuted}>
平均
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={
event.related_avg_chg > 0
? "#EF4444"
: "#10B981"
}
>
{event.related_avg_chg > 0 ? "+" : ""}
{event.related_avg_chg.toFixed(2)}%
</Text>
</HStack>
)}
{event.related_max_chg !== null &&
event.related_max_chg !== undefined && (
<HStack spacing={1}>
<Text fontSize="xs" color={theme.textMuted}>
最大
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={
event.related_max_chg > 0
? "#EF4444"
: "#10B981"
}
>
{event.related_max_chg > 0 ? "+" : ""}
{event.related_max_chg.toFixed(2)}%
</Text>
</HStack>
)}
{event.related_week_chg !== null &&
event.related_week_chg !== undefined && (
<HStack spacing={1}>
<Text fontSize="xs" color={theme.textMuted}>
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={
event.related_week_chg > 0
? "#EF4444"
: "#10B981"
}
>
{event.related_week_chg > 0
? "+"
: ""}
{event.related_week_chg.toFixed(2)}%
</Text>
</HStack>
)}
</HStack>
</Box>
)}
</VStack>
</CardBody>
</Card>
);
})}
</VStack>
{/* 分页控件 */}
{newsPagination.pages > 1 && (
<Box pt={4}>
<HStack
justify="space-between"
align="center"
flexWrap="wrap"
>
{/* 分页信息 */}
<Text fontSize="sm" color={theme.textSecondary}>
{newsPagination.page} / {newsPagination.pages}
</Text>
{/* 分页按钮 */}
<HStack spacing={2}>
<Button
size="sm"
bg={isBlackGold ? theme.inputBg : undefined}
color={theme.textSecondary}
borderColor={theme.cardBorder}
borderWidth="1px"
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
onClick={() => handlePageChange(1)}
isDisabled={!newsPagination.has_prev || newsLoading}
leftIcon={<Icon as={FaChevronLeft} />}
>
首页
</Button>
<Button
size="sm"
bg={isBlackGold ? theme.inputBg : undefined}
color={theme.textSecondary}
borderColor={theme.cardBorder}
borderWidth="1px"
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
onClick={() =>
handlePageChange(newsPagination.page - 1)
}
isDisabled={!newsPagination.has_prev || newsLoading}
>
上一页
</Button>
{/* 页码按钮 */}
{renderPaginationButtons()}
<Button
size="sm"
bg={isBlackGold ? theme.inputBg : undefined}
color={theme.textSecondary}
borderColor={theme.cardBorder}
borderWidth="1px"
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
onClick={() =>
handlePageChange(newsPagination.page + 1)
}
isDisabled={!newsPagination.has_next || newsLoading}
>
下一页
</Button>
<Button
size="sm"
bg={isBlackGold ? theme.inputBg : undefined}
color={theme.textSecondary}
borderColor={theme.cardBorder}
borderWidth="1px"
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
onClick={() => handlePageChange(newsPagination.pages)}
isDisabled={!newsPagination.has_next || newsLoading}
rightIcon={<Icon as={FaChevronRight} />}
>
末页
</Button>
</HStack>
</HStack>
</Box>
)}
</>
) : (
<Center h="400px">
<VStack spacing={3}>
<Icon as={FaNewspaper} boxSize={16} color={isBlackGold ? theme.gold : "gray.300"} opacity={0.5} />
<Text color={theme.textSecondary} fontSize="lg" fontWeight="medium">
暂无相关新闻
</Text>
<Text fontSize="sm" color={theme.textMuted}>
{searchQuery ? "尝试修改搜索关键词" : "该公司暂无新闻动态"}
</Text>
</VStack>
</Center>
)}
</VStack>
</CardBody>
</Card>
</VStack>
);
};
export default NewsEventsTab;

View File

@@ -0,0 +1,96 @@
// src/views/Company/components/CompanyOverview/components/shareholder/ActualControlCard.tsx
// 实际控制人卡片组件
import React from "react";
import {
Box,
VStack,
HStack,
Text,
Heading,
Badge,
Icon,
Card,
CardBody,
Stat,
StatLabel,
StatNumber,
StatHelpText,
} from "@chakra-ui/react";
import { FaCrown } from "react-icons/fa";
import type { ActualControl } from "../../types";
import { THEME } from "../../BasicInfoTab/config";
// 格式化工具函数
const formatPercentage = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
return `${(value * 100).toFixed(2)}%`;
};
const formatShares = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
if (value >= 100000000) {
return `${(value / 100000000).toFixed(2)}亿股`;
} else if (value >= 10000) {
return `${(value / 10000).toFixed(2)}万股`;
}
return `${value.toLocaleString()}`;
};
const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return "-";
return dateStr.split("T")[0];
};
interface ActualControlCardProps {
actualControl: ActualControl[];
}
/**
* 实际控制人卡片
*/
const ActualControlCard: React.FC<ActualControlCardProps> = ({ actualControl = [] }) => {
if (!actualControl.length) return null;
const data = actualControl[0];
return (
<Box>
<HStack mb={4}>
<Icon as={FaCrown} color={THEME.gold} boxSize={5} />
<Heading size="sm" color={THEME.gold}></Heading>
</HStack>
<Card bg={THEME.cardBg} borderColor={THEME.border} borderWidth="1px">
<CardBody>
<HStack
justify="space-between"
flexDir={{ base: "column", md: "row" }}
align={{ base: "stretch", md: "center" }}
gap={4}
>
<VStack align={{ base: "center", md: "start" }}>
<Text fontWeight="bold" fontSize="lg" color={THEME.textPrimary}>
{data.actual_controller_name}
</Text>
<HStack>
<Badge colorScheme="purple">{data.control_type}</Badge>
<Text fontSize="sm" color={THEME.textSecondary}>
{formatDate(data.end_date)}
</Text>
</HStack>
</VStack>
<Stat textAlign={{ base: "center", md: "right" }}>
<StatLabel color={THEME.textSecondary}></StatLabel>
<StatNumber color={THEME.goldLight}>
{formatPercentage(data.holding_ratio)}
</StatNumber>
<StatHelpText color={THEME.textSecondary}>{formatShares(data.holding_shares)}</StatHelpText>
</Stat>
</HStack>
</CardBody>
</Card>
</Box>
);
};
export default ActualControlCard;

View File

@@ -0,0 +1,234 @@
// src/views/Company/components/CompanyOverview/components/shareholder/ConcentrationCard.tsx
// 股权集中度卡片组件
import React, { useMemo, useRef, useEffect } from "react";
import {
Box,
VStack,
HStack,
Text,
Heading,
Badge,
Icon,
Card,
CardBody,
CardHeader,
SimpleGrid,
} from "@chakra-ui/react";
import { FaChartPie, FaArrowUp, FaArrowDown } from "react-icons/fa";
import * as echarts from "echarts";
import type { Concentration } from "../../types";
import { THEME } from "../../BasicInfoTab/config";
// 格式化工具函数
const formatPercentage = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
return `${(value * 100).toFixed(2)}%`;
};
const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return "-";
return dateStr.split("T")[0];
};
interface ConcentrationCardProps {
concentration: Concentration[];
}
// 饼图颜色配置(黑金主题)
const PIE_COLORS = [
"#D4AF37", // 金色 - 前1大股东
"#F0D78C", // 浅金色 - 第2-3大股东
"#B8860B", // 暗金色 - 第4-5大股东
"#DAA520", // 金麒麟色 - 第6-10大股东
"#4A5568", // 灰色 - 其他股东
];
/**
* 股权集中度卡片
*/
const ConcentrationCard: React.FC<ConcentrationCardProps> = ({ concentration = [] }) => {
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
// 按日期分组
const groupedData = useMemo(() => {
const grouped: Record<string, Record<string, Concentration>> = {};
concentration.forEach((item) => {
if (!grouped[item.end_date]) {
grouped[item.end_date] = {};
}
grouped[item.end_date][item.stat_item] = item;
});
return Object.entries(grouped)
.sort((a, b) => b[0].localeCompare(a[0]))
.slice(0, 1); // 只取最新一期
}, [concentration]);
// 计算饼图数据
const pieData = useMemo(() => {
if (groupedData.length === 0) return [];
const [, items] = groupedData[0];
const top1 = items["前1大股东"]?.holding_ratio || 0;
const top3 = items["前3大股东"]?.holding_ratio || 0;
const top5 = items["前5大股东"]?.holding_ratio || 0;
const top10 = items["前10大股东"]?.holding_ratio || 0;
return [
{ name: "前1大股东", value: Number((top1 * 100).toFixed(2)) },
{ name: "第2-3大股东", value: Number(((top3 - top1) * 100).toFixed(2)) },
{ name: "第4-5大股东", value: Number(((top5 - top3) * 100).toFixed(2)) },
{ name: "第6-10大股东", value: Number(((top10 - top5) * 100).toFixed(2)) },
{ name: "其他股东", value: Number(((1 - top10) * 100).toFixed(2)) },
].filter(item => item.value > 0);
}, [groupedData]);
// 初始化和更新图表
useEffect(() => {
if (!chartRef.current || pieData.length === 0) return;
// 使用 requestAnimationFrame 确保 DOM 渲染完成后再初始化
const initChart = () => {
if (!chartRef.current) return;
// 初始化图表
if (!chartInstance.current) {
chartInstance.current = echarts.init(chartRef.current);
}
const option: echarts.EChartsOption = {
backgroundColor: "transparent",
tooltip: {
trigger: "item",
formatter: "{b}: {c}%",
backgroundColor: "rgba(0,0,0,0.8)",
borderColor: THEME.gold,
textStyle: { color: "#fff" },
},
legend: {
orient: "vertical",
right: 10,
top: "center",
textStyle: { color: THEME.textSecondary, fontSize: 11 },
itemWidth: 12,
itemHeight: 12,
},
series: [
{
name: "股权集中度",
type: "pie",
radius: ["40%", "70%"],
center: ["35%", "50%"],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 4,
borderColor: THEME.cardBg,
borderWidth: 2,
},
label: {
show: false,
},
emphasis: {
label: {
show: true,
fontSize: 12,
fontWeight: "bold",
color: THEME.textPrimary,
formatter: "{b}\n{c}%",
},
},
labelLine: { show: false },
data: pieData.map((item, index) => ({
...item,
itemStyle: { color: PIE_COLORS[index] },
})),
},
],
};
chartInstance.current.setOption(option);
// 延迟 resize 确保容器尺寸已计算完成
setTimeout(() => {
chartInstance.current?.resize();
}, 100);
};
// 延迟初始化,确保布局完成
const rafId = requestAnimationFrame(initChart);
// 响应式
const handleResize = () => chartInstance.current?.resize();
window.addEventListener("resize", handleResize);
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener("resize", handleResize);
};
}, [pieData]);
// 组件卸载时销毁图表
useEffect(() => {
return () => {
chartInstance.current?.dispose();
};
}, []);
if (!concentration.length) return null;
return (
<Box>
<HStack mb={4}>
<Icon as={FaChartPie} color={THEME.gold} boxSize={5} />
<Heading size="sm" color={THEME.gold}></Heading>
</HStack>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{/* 数据卡片 */}
{groupedData.map(([date, items]) => (
<Card key={date} bg={THEME.cardBg} borderColor={THEME.border} borderWidth="1px">
<CardHeader pb={2}>
<Text fontSize="sm" color={THEME.textSecondary}>
{formatDate(date)}
</Text>
</CardHeader>
<CardBody pt={2}>
<VStack spacing={3} align="stretch">
{Object.entries(items).map(([key, item]) => (
<HStack key={key} justify="space-between">
<Text fontSize="sm" color={THEME.textPrimary}>{item.stat_item}</Text>
<HStack>
<Text fontWeight="bold" color={THEME.goldLight}>
{formatPercentage(item.holding_ratio)}
</Text>
{item.ratio_change && (
<Badge
colorScheme={item.ratio_change > 0 ? "red" : "green"}
>
<Icon
as={item.ratio_change > 0 ? FaArrowUp : FaArrowDown}
mr={1}
boxSize={3}
/>
{Math.abs(item.ratio_change).toFixed(2)}%
</Badge>
)}
</HStack>
</HStack>
))}
</VStack>
</CardBody>
</Card>
))}
{/* 饼图 */}
<Card bg={THEME.cardBg} borderColor={THEME.border} borderWidth="1px">
<CardBody p={2}>
<Box ref={chartRef} h="180px" w="100%" />
</CardBody>
</Card>
</SimpleGrid>
</Box>
);
};
export default ConcentrationCard;

View File

@@ -0,0 +1,226 @@
// src/views/Company/components/CompanyOverview/components/shareholder/ShareholdersTable.tsx
// 股东表格组件(合并版)- 支持十大股东和十大流通股东
import React, { useMemo } from "react";
import { Box, HStack, Heading, Badge, Icon, useBreakpointValue } from "@chakra-ui/react";
import { Table, Tag, Tooltip, ConfigProvider } from "antd";
import type { ColumnsType } from "antd/es/table";
import { FaUsers, FaChartLine } from "react-icons/fa";
import type { Shareholder } from "../../types";
import { THEME } from "../../BasicInfoTab/config";
// antd 表格黑金主题配置
const TABLE_THEME = {
token: {
colorBgContainer: "#2D3748", // gray.700
colorText: "white",
colorTextHeading: "#D4AF37", // 金色
colorBorderSecondary: "rgba(212, 175, 55, 0.3)",
},
components: {
Table: {
headerBg: "#1A202C", // gray.900
headerColor: "#D4AF37", // 金色
rowHoverBg: "rgba(212, 175, 55, 0.15)", // 金色半透明,文字更清晰
borderColor: "rgba(212, 175, 55, 0.2)",
},
},
};
// 格式化工具函数
const formatPercentage = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
return `${(value * 100).toFixed(2)}%`;
};
const formatShares = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
if (value >= 100000000) {
return `${(value / 100000000).toFixed(2)}亿股`;
} else if (value >= 10000) {
return `${(value / 10000).toFixed(2)}万股`;
}
return `${value.toLocaleString()}`;
};
const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return "-";
return dateStr.split("T")[0];
};
// 股东类型颜色映射
const shareholderTypeColors: Record<string, string> = {
: "blue",
: "green",
: "purple",
QFII: "orange",
: "red",
: "cyan",
: "geekblue",
: "magenta",
: "purple",
: "blue",
};
const getShareholderTypeColor = (type: string | undefined): string => {
if (!type) return "default";
for (const [key, color] of Object.entries(shareholderTypeColors)) {
if (type.includes(key)) return color;
}
return "default";
};
interface ShareholdersTableProps {
type?: "top" | "circulation";
shareholders: Shareholder[];
title?: string;
}
/**
* 股东表格组件
* @param type - 表格类型: "top" 十大股东 | "circulation" 十大流通股东
* @param shareholders - 股东数据数组
* @param title - 自定义标题
*/
const ShareholdersTable: React.FC<ShareholdersTableProps> = ({
type = "top",
shareholders = [],
title,
}) => {
const isMobile = useBreakpointValue({ base: true, md: false });
// 配置
const config = useMemo(() => {
if (type === "circulation") {
return {
title: title || "十大流通股东",
icon: FaChartLine,
iconColor: "purple.500",
ratioField: "circulation_share_ratio" as keyof Shareholder,
ratioLabel: "流通股比例",
rankColor: "orange",
showNature: true, // 与十大股东保持一致
};
}
return {
title: title || "十大股东",
icon: FaUsers,
iconColor: "green.500",
ratioField: "total_share_ratio" as keyof Shareholder,
ratioLabel: "持股比例",
rankColor: "red",
showNature: true,
};
}, [type, title]);
// 表格列定义
const columns: ColumnsType<Shareholder> = useMemo(() => {
const baseColumns: ColumnsType<Shareholder> = [
{
title: "排名",
dataIndex: "shareholder_rank",
key: "rank",
width: 45,
render: (rank: number, _: Shareholder, index: number) => (
<Tag color={index < 3 ? config.rankColor : "default"}>
{rank || index + 1}
</Tag>
),
},
{
title: "股东名称",
dataIndex: "shareholder_name",
key: "name",
ellipsis: true,
render: (name: string) => (
<Tooltip title={name}>
<span style={{ fontWeight: 500, color: "#D4AF37" }}>{name}</span>
</Tooltip>
),
},
{
title: "股东类型",
dataIndex: "shareholder_type",
key: "type",
width: 90,
responsive: ["md"],
render: (shareholderType: string) => (
<Tag color={getShareholderTypeColor(shareholderType)}>{shareholderType || "-"}</Tag>
),
},
{
title: "持股数量",
dataIndex: "holding_shares",
key: "shares",
width: 100,
align: "right",
responsive: ["md"],
sorter: (a: Shareholder, b: Shareholder) => (a.holding_shares || 0) - (b.holding_shares || 0),
render: (shares: number) => (
<span style={{ color: "#D4AF37" }}>{formatShares(shares)}</span>
),
},
{
title: <span style={{ whiteSpace: "nowrap" }}>{config.ratioLabel}</span>,
dataIndex: config.ratioField as string,
key: "ratio",
width: 110,
align: "right",
sorter: (a: Shareholder, b: Shareholder) => {
const aVal = (a[config.ratioField] as number) || 0;
const bVal = (b[config.ratioField] as number) || 0;
return aVal - bVal;
},
defaultSortOrder: "descend",
render: (ratio: number) => (
<span style={{ color: type === "circulation" ? "#805AD5" : "#3182CE", fontWeight: "bold" }}>
{formatPercentage(ratio)}
</span>
),
},
];
// 十大股东显示股份性质
if (config.showNature) {
baseColumns.push({
title: "股份性质",
dataIndex: "share_nature",
key: "nature",
width: 80,
responsive: ["lg"],
render: (nature: string) => (
<Tag color="default">{nature || "流通股"}</Tag>
),
});
}
return baseColumns;
}, [config, type]);
if (!shareholders.length) return null;
// 获取数据日期
const reportDate = shareholders[0]?.end_date;
return (
<Box>
<HStack mb={4}>
<Icon as={config.icon} color={THEME.gold} boxSize={5} />
<Heading size="sm" color={THEME.gold}>{config.title}</Heading>
{reportDate && <Badge colorScheme="yellow" variant="subtle">{formatDate(reportDate)}</Badge>}
</HStack>
<ConfigProvider theme={TABLE_THEME}>
<Table
columns={columns}
dataSource={shareholders.slice(0, 10)}
rowKey={(record: Shareholder, index?: number) => `${record.shareholder_name}-${index}`}
pagination={false}
size={isMobile ? "small" : "middle"}
scroll={{ x: isMobile ? 400 : undefined }}
/>
</ConfigProvider>
</Box>
);
};
export default ShareholdersTable;

View File

@@ -0,0 +1,6 @@
// src/views/Company/components/CompanyOverview/components/shareholder/index.ts
// 股权结构子组件汇总导出
export { default as ActualControlCard } from "./ActualControlCard";
export { default as ConcentrationCard } from "./ConcentrationCard";
export { default as ShareholdersTable } from "./ShareholdersTable";

View File

@@ -0,0 +1,63 @@
// src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts
// 公告数据 Hook - 用于公司公告 Tab
import { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig";
import type { Announcement } from "../types";
interface ApiResponse<T> {
success: boolean;
data: T;
}
interface UseAnnouncementsDataResult {
announcements: Announcement[];
loading: boolean;
error: string | null;
}
/**
* 公告数据 Hook
* @param stockCode - 股票代码
*/
export const useAnnouncementsData = (stockCode?: string): UseAnnouncementsDataResult => {
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!stockCode) return;
const controller = new AbortController();
const loadData = async () => {
setLoading(true);
setError(null);
try {
const { data: result } = await axios.get<ApiResponse<Announcement[]>>(
`/api/stock/${stockCode}/announcements?limit=20`,
{ signal: controller.signal }
);
if (result.success) {
setAnnouncements(result.data);
} else {
setError("加载公告数据失败");
}
} catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useAnnouncementsData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
};
loadData();
return () => controller.abort();
}, [stockCode]);
return { announcements, loading, error };
};

View File

@@ -0,0 +1,63 @@
// src/views/Company/components/CompanyOverview/hooks/useBasicInfo.ts
// 公司基本信息 Hook - 用于 CompanyHeaderCard
import { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig";
import type { BasicInfo } from "../types";
interface ApiResponse<T> {
success: boolean;
data: T;
}
interface UseBasicInfoResult {
basicInfo: BasicInfo | null;
loading: boolean;
error: string | null;
}
/**
* 公司基本信息 Hook
* @param stockCode - 股票代码
*/
export const useBasicInfo = (stockCode?: string): UseBasicInfoResult => {
const [basicInfo, setBasicInfo] = useState<BasicInfo | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!stockCode) return;
const controller = new AbortController();
const loadData = async () => {
setLoading(true);
setError(null);
try {
const { data: result } = await axios.get<ApiResponse<BasicInfo>>(
`/api/stock/${stockCode}/basic-info`,
{ signal: controller.signal }
);
if (result.success) {
setBasicInfo(result.data);
} else {
setError("加载基本信息失败");
}
} catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useBasicInfo", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
};
loadData();
return () => controller.abort();
}, [stockCode]);
return { basicInfo, loading, error };
};

View File

@@ -0,0 +1,63 @@
// src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts
// 分支机构数据 Hook - 用于分支机构 Tab
import { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig";
import type { Branch } from "../types";
interface ApiResponse<T> {
success: boolean;
data: T;
}
interface UseBranchesDataResult {
branches: Branch[];
loading: boolean;
error: string | null;
}
/**
* 分支机构数据 Hook
* @param stockCode - 股票代码
*/
export const useBranchesData = (stockCode?: string): UseBranchesDataResult => {
const [branches, setBranches] = useState<Branch[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!stockCode) return;
const controller = new AbortController();
const loadData = async () => {
setLoading(true);
setError(null);
try {
const { data: result } = await axios.get<ApiResponse<Branch[]>>(
`/api/stock/${stockCode}/branches`,
{ signal: controller.signal }
);
if (result.success) {
setBranches(result.data);
} else {
setError("加载分支机构数据失败");
}
} catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useBranchesData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
};
loadData();
return () => controller.abort();
}, [stockCode]);
return { branches, loading, error };
};

View File

@@ -0,0 +1,63 @@
// src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts
// 披露日程数据 Hook - 用于工商信息 Tab
import { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig";
import type { DisclosureSchedule } from "../types";
interface ApiResponse<T> {
success: boolean;
data: T;
}
interface UseDisclosureDataResult {
disclosureSchedule: DisclosureSchedule[];
loading: boolean;
error: string | null;
}
/**
* 披露日程数据 Hook
* @param stockCode - 股票代码
*/
export const useDisclosureData = (stockCode?: string): UseDisclosureDataResult => {
const [disclosureSchedule, setDisclosureSchedule] = useState<DisclosureSchedule[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!stockCode) return;
const controller = new AbortController();
const loadData = async () => {
setLoading(true);
setError(null);
try {
const { data: result } = await axios.get<ApiResponse<DisclosureSchedule[]>>(
`/api/stock/${stockCode}/disclosure-schedule`,
{ signal: controller.signal }
);
if (result.success) {
setDisclosureSchedule(result.data);
} else {
setError("加载披露日程数据失败");
}
} catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useDisclosureData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
};
loadData();
return () => controller.abort();
}, [stockCode]);
return { disclosureSchedule, loading, error };
};

View File

@@ -0,0 +1,63 @@
// src/views/Company/components/CompanyOverview/hooks/useManagementData.ts
// 管理团队数据 Hook - 用于管理团队 Tab
import { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig";
import type { Management } from "../types";
interface ApiResponse<T> {
success: boolean;
data: T;
}
interface UseManagementDataResult {
management: Management[];
loading: boolean;
error: string | null;
}
/**
* 管理团队数据 Hook
* @param stockCode - 股票代码
*/
export const useManagementData = (stockCode?: string): UseManagementDataResult => {
const [management, setManagement] = useState<Management[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!stockCode) return;
const controller = new AbortController();
const loadData = async () => {
setLoading(true);
setError(null);
try {
const { data: result } = await axios.get<ApiResponse<Management[]>>(
`/api/stock/${stockCode}/management?active_only=true`,
{ signal: controller.signal }
);
if (result.success) {
setManagement(result.data);
} else {
setError("加载管理团队数据失败");
}
} catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useManagementData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
};
loadData();
return () => controller.abort();
}, [stockCode]);
return { management, loading, error };
};

View File

@@ -0,0 +1,82 @@
// src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts
// 股权结构数据 Hook - 用于股权结构 Tab
import { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig";
import type { ActualControl, Concentration, Shareholder } from "../types";
interface ApiResponse<T> {
success: boolean;
data: T;
}
interface UseShareholderDataResult {
actualControl: ActualControl[];
concentration: Concentration[];
topShareholders: Shareholder[];
topCirculationShareholders: Shareholder[];
loading: boolean;
error: string | null;
}
/**
* 股权结构数据 Hook
* @param stockCode - 股票代码
*/
export const useShareholderData = (stockCode?: string): UseShareholderDataResult => {
const [actualControl, setActualControl] = useState<ActualControl[]>([]);
const [concentration, setConcentration] = useState<Concentration[]>([]);
const [topShareholders, setTopShareholders] = useState<Shareholder[]>([]);
const [topCirculationShareholders, setTopCirculationShareholders] = useState<Shareholder[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!stockCode) return;
const controller = new AbortController();
const loadData = async () => {
setLoading(true);
setError(null);
try {
const [
{ data: actualRes },
{ data: concentrationRes },
{ data: shareholdersRes },
{ data: circulationRes },
] = await Promise.all([
axios.get<ApiResponse<ActualControl[]>>(`/api/stock/${stockCode}/actual-control`, { signal: controller.signal }),
axios.get<ApiResponse<Concentration[]>>(`/api/stock/${stockCode}/concentration`, { signal: controller.signal }),
axios.get<ApiResponse<Shareholder[]>>(`/api/stock/${stockCode}/top-shareholders?limit=10`, { signal: controller.signal }),
axios.get<ApiResponse<Shareholder[]>>(`/api/stock/${stockCode}/top-circulation-shareholders?limit=10`, { signal: controller.signal }),
]);
if (actualRes.success) setActualControl(actualRes.data);
if (concentrationRes.success) setConcentration(concentrationRes.data);
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
} catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useShareholderData", "loadData", err, { stockCode });
setError("加载股权结构数据失败");
} finally {
setLoading(false);
}
};
loadData();
return () => controller.abort();
}, [stockCode]);
return {
actualControl,
concentration,
topShareholders,
topCirculationShareholders,
loading,
error,
};
};

View File

@@ -0,0 +1,31 @@
// src/views/Company/components/CompanyOverview/index.tsx
// 公司档案 - 主组件(组合层)
import React from "react";
import { VStack } from "@chakra-ui/react";
import type { CompanyOverviewProps } from "./types";
// 子组件
import BasicInfoTab from "./BasicInfoTab";
/**
* 公司档案组件
*
* 功能:
* - 显示基本信息 Tab内部懒加载各子 Tab 数据)
*
* 懒加载策略:
* - BasicInfoTab 内部根据 Tab 切换懒加载数据
* - 各 Panel 组件自行获取所需数据(如 BusinessInfoPanel 调用 useBasicInfo
*/
const CompanyOverview: React.FC<CompanyOverviewProps> = ({ stockCode }) => {
return (
<VStack spacing={6} align="stretch">
{/* 基本信息内容 - 传入 stockCode内部懒加载各 Tab 数据 */}
<BasicInfoTab stockCode={stockCode} />
</VStack>
);
};
export default CompanyOverview;

View File

@@ -0,0 +1,125 @@
// src/views/Company/components/CompanyOverview/types.ts
// 公司概览组件类型定义
/**
* 公司基本信息
*/
export interface BasicInfo {
ORGNAME?: string;
SECNAME?: string;
SECCODE?: string;
sw_industry_l1?: string;
sw_industry_l2?: string;
sw_industry_l3?: string;
legal_representative?: string;
chairman?: string;
general_manager?: string;
establish_date?: string;
reg_capital?: number;
province?: string;
city?: string;
website?: string;
email?: string;
tel?: string;
company_intro?: string;
// 工商信息字段
credit_code?: string;
company_size?: string;
reg_address?: string;
office_address?: string;
accounting_firm?: string;
law_firm?: string;
main_business?: string;
business_scope?: string;
}
/**
* 实际控制人
*/
export interface ActualControl {
actual_controller_name?: string;
controller_name?: string;
control_type?: string;
controller_type?: string;
holding_ratio?: number;
holding_shares?: number;
end_date?: string;
}
/**
* 股权集中度
*/
export interface Concentration {
top1_ratio?: number;
top5_ratio?: number;
top10_ratio?: number;
stat_item?: string;
holding_ratio?: number;
ratio_change?: number;
end_date?: string;
}
/**
* 管理层信息
*/
export interface Management {
name?: string;
position?: string;
position_name?: string;
position_category?: string;
start_date?: string;
end_date?: string;
gender?: string;
education?: string;
birth_year?: string;
nationality?: string;
}
/**
* 股东信息
*/
export interface Shareholder {
shareholder_name?: string;
shareholder_type?: string;
shareholder_rank?: number;
holding_ratio?: number;
holding_amount?: number;
holding_shares?: number;
total_share_ratio?: number;
circulation_share_ratio?: number;
share_nature?: string;
end_date?: string;
}
/**
* 分支机构
*/
export interface Branch {
branch_name?: string;
address?: string;
}
/**
* 公告信息
*/
export interface Announcement {
title?: string;
publish_date?: string;
url?: string;
}
/**
* 披露计划
*/
export interface DisclosureSchedule {
report_type?: string;
disclosure_date?: string;
}
/**
* CompanyOverview 组件 Props
*/
export interface CompanyOverviewProps {
stockCode?: string;
}

View File

@@ -0,0 +1,26 @@
// src/views/Company/components/CompanyOverview/utils.ts
// 公司概览格式化工具函数
/**
* 格式化注册资本
* @param value - 注册资本(万元)
* @returns 格式化后的字符串
*/
export const formatRegisteredCapital = (value: number | null | undefined): string => {
if (!value && value !== 0) return "-";
const absValue = Math.abs(value);
if (absValue >= 100000) {
return (value / 10000).toFixed(2) + "亿元";
}
return value.toFixed(2) + "万元";
};
/**
* 格式化日期
* @param dateString - 日期字符串
* @returns 格式化后的日期字符串
*/
export const formatDate = (dateString: string | null | undefined): string => {
if (!dateString) return "-";
return new Date(dateString).toLocaleDateString("zh-CN");
};

View File

@@ -0,0 +1,75 @@
// src/views/Company/components/CompanyTabs/index.js
// Tab 容器组件 - 使用通用 TabContainer 组件
import React from 'react';
import TabContainer from '@components/TabContainer';
import { COMPANY_TABS, getTabNameByIndex } from '../../constants';
// 子组件导入Tab 内容组件)
import CompanyOverview from '../CompanyOverview';
import DeepAnalysis from '../DeepAnalysis';
import MarketDataView from '../MarketDataView';
import FinancialPanorama from '../FinancialPanorama';
import ForecastReport from '../ForecastReport';
import DynamicTracking from '../DynamicTracking';
/**
* Tab 组件映射
*/
const TAB_COMPONENTS = {
overview: CompanyOverview,
analysis: DeepAnalysis,
market: MarketDataView,
financial: FinancialPanorama,
forecast: ForecastReport,
tracking: DynamicTracking,
};
/**
* 构建 TabContainer 所需的 tabs 配置
* 合并 COMPANY_TABS 和对应的组件
*/
const buildTabsConfig = () => {
return COMPANY_TABS.map((tab) => ({
...tab,
component: TAB_COMPONENTS[tab.key],
}));
};
// 预构建 tabs 配置(避免每次渲染重新计算)
const TABS_CONFIG = buildTabsConfig();
/**
* 公司详情 Tab 容器组件
*
* 功能:
* - 使用通用 TabContainer 组件
* - 保持黑金主题风格
* - 触发 Tab 变更追踪
*
* @param {Object} props
* @param {string} props.stockCode - 当前股票代码
* @param {Function} props.onTabChange - Tab 变更回调 (index, tabName, prevIndex) => void
*/
const CompanyTabs = ({ stockCode, onTabChange }) => {
/**
* 处理 Tab 切换
* 转换 tabKey 为 tabName 以保持原有回调格式
*/
const handleTabChange = (index, tabKey, prevIndex) => {
const tabName = getTabNameByIndex(index);
onTabChange?.(index, tabName, prevIndex);
};
return (
<TabContainer
tabs={TABS_CONFIG}
componentProps={{ stockCode }}
onTabChange={handleTabChange}
themePreset="blackGold"
borderRadius="16px"
/>
);
};
export default CompanyTabs;

View File

@@ -0,0 +1,229 @@
// src/views/Company/components/DeepAnalysis/index.js
// 深度分析 - 独立一级 Tab 组件(懒加载版本)
import React, { useState, useEffect, useCallback, useRef } from "react";
import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig";
// 复用原有的展示组件
import DeepAnalysisTab from "../CompanyOverview/DeepAnalysisTab";
/**
* Tab 与 API 接口映射
* - strategy 和 business 共用 comprehensive 接口
*/
const TAB_API_MAP = {
strategy: "comprehensive",
business: "comprehensive",
valueChain: "valueChain",
development: "keyFactors",
};
/**
* 深度分析组件
*
* 功能:
* - 按 Tab 懒加载数据(默认只加载战略分析)
* - 已加载的数据缓存,切换 Tab 不重复请求
* - 管理展开状态
*
* @param {Object} props
* @param {string} props.stockCode - 股票代码
*/
const DeepAnalysis = ({ stockCode }) => {
// 当前 Tab
const [activeTab, setActiveTab] = useState("strategy");
// 数据状态
const [comprehensiveData, setComprehensiveData] = useState(null);
const [valueChainData, setValueChainData] = useState(null);
const [keyFactorsData, setKeyFactorsData] = useState(null);
const [industryRankData, setIndustryRankData] = useState(null);
// 各接口独立的 loading 状态
const [comprehensiveLoading, setComprehensiveLoading] = useState(false);
const [valueChainLoading, setValueChainLoading] = useState(false);
const [keyFactorsLoading, setKeyFactorsLoading] = useState(false);
const [industryRankLoading, setIndustryRankLoading] = useState(false);
// 已加载的接口记录(用于缓存判断)
const loadedApisRef = useRef({
comprehensive: false,
valueChain: false,
keyFactors: false,
industryRank: false,
});
// 业务板块展开状态
const [expandedSegments, setExpandedSegments] = useState({});
// 用于追踪当前 stockCode避免竞态条件
const currentStockCodeRef = useRef(stockCode);
// 切换业务板块展开状态
const toggleSegmentExpansion = (segmentIndex) => {
setExpandedSegments((prev) => ({
...prev,
[segmentIndex]: !prev[segmentIndex],
}));
};
/**
* 加载指定接口的数据
*/
const loadApiData = useCallback(
async (apiKey) => {
if (!stockCode) return;
// 已加载则跳过
if (loadedApisRef.current[apiKey]) return;
try {
switch (apiKey) {
case "comprehensive":
setComprehensiveLoading(true);
const { data: comprehensiveRes } = await axios.get(
`/api/company/comprehensive-analysis/${stockCode}`
);
// 检查 stockCode 是否已变更(防止竞态)
if (currentStockCodeRef.current === stockCode) {
if (comprehensiveRes.success)
setComprehensiveData(comprehensiveRes.data);
loadedApisRef.current.comprehensive = true;
}
break;
case "valueChain":
setValueChainLoading(true);
const { data: valueChainRes } = await axios.get(
`/api/company/value-chain-analysis/${stockCode}`
);
if (currentStockCodeRef.current === stockCode) {
if (valueChainRes.success) setValueChainData(valueChainRes.data);
loadedApisRef.current.valueChain = true;
}
break;
case "keyFactors":
setKeyFactorsLoading(true);
const { data: keyFactorsRes } = await axios.get(
`/api/company/key-factors-timeline/${stockCode}`
);
if (currentStockCodeRef.current === stockCode) {
if (keyFactorsRes.success) setKeyFactorsData(keyFactorsRes.data);
loadedApisRef.current.keyFactors = true;
}
break;
case "industryRank":
setIndustryRankLoading(true);
const { data: industryRankRes } = await axios.get(
`/api/financial/industry-rank/${stockCode}`
);
if (currentStockCodeRef.current === stockCode) {
if (industryRankRes.success) setIndustryRankData(industryRankRes.data);
loadedApisRef.current.industryRank = true;
}
break;
default:
break;
}
} catch (err) {
logger.error("DeepAnalysis", `loadApiData:${apiKey}`, err, {
stockCode,
});
} finally {
// 清除 loading 状态
if (apiKey === "comprehensive") setComprehensiveLoading(false);
if (apiKey === "valueChain") setValueChainLoading(false);
if (apiKey === "keyFactors") setKeyFactorsLoading(false);
if (apiKey === "industryRank") setIndustryRankLoading(false);
}
},
[stockCode]
);
/**
* 根据 Tab 加载对应的数据
*/
const loadTabData = useCallback(
(tabKey) => {
const apiKey = TAB_API_MAP[tabKey];
if (apiKey) {
loadApiData(apiKey);
}
},
[loadApiData]
);
/**
* Tab 切换回调
*/
const handleTabChange = useCallback(
(index, tabKey) => {
setActiveTab(tabKey);
loadTabData(tabKey);
},
[loadTabData]
);
// stockCode 变更时重置并加载默认 Tab 数据
useEffect(() => {
if (stockCode) {
// 更新 ref
currentStockCodeRef.current = stockCode;
// 重置所有数据和状态
setComprehensiveData(null);
setValueChainData(null);
setKeyFactorsData(null);
setIndustryRankData(null);
setExpandedSegments({});
loadedApisRef.current = {
comprehensive: false,
valueChain: false,
keyFactors: false,
industryRank: false,
};
// 重置为默认 Tab 并加载数据
setActiveTab("strategy");
// 加载默认 Tab 的数据(战略分析需要 comprehensive 和 industryRank
loadApiData("comprehensive");
loadApiData("industryRank");
}
}, [stockCode, loadApiData]);
// 计算当前 Tab 的 loading 状态
const getCurrentLoading = () => {
const apiKey = TAB_API_MAP[activeTab];
switch (apiKey) {
case "comprehensive":
return comprehensiveLoading;
case "valueChain":
return valueChainLoading;
case "keyFactors":
return keyFactorsLoading;
default:
return false;
}
};
return (
<DeepAnalysisTab
comprehensiveData={comprehensiveData}
valueChainData={valueChainData}
keyFactorsData={keyFactorsData}
industryRankData={industryRankData}
loading={getCurrentLoading()}
cardBg="white"
expandedSegments={expandedSegments}
onToggleSegment={toggleSegmentExpansion}
activeTab={activeTab}
onTabChange={handleTabChange}
/>
);
};
export default DeepAnalysis;

View File

@@ -0,0 +1,156 @@
// src/views/Company/components/DynamicTracking/components/ForecastPanel.js
// 业绩预告面板 - 黑金主题
import React, { useState, useEffect, useCallback } from 'react';
import {
VStack,
Box,
Flex,
Text,
Spinner,
Center,
} from '@chakra-ui/react';
import { Tag } from 'antd';
import { logger } from '@utils/logger';
import axios from '@utils/axiosConfig';
// 黑金主题
const THEME = {
gold: '#D4AF37',
goldLight: 'rgba(212, 175, 55, 0.15)',
goldBorder: 'rgba(212, 175, 55, 0.3)',
bgDark: '#1A202C',
cardBg: 'rgba(26, 32, 44, 0.6)',
text: '#E2E8F0',
textSecondary: '#A0AEC0',
positive: '#E53E3E',
negative: '#48BB78',
};
// 预告类型配色
const getForecastTypeStyle = (type) => {
const styles = {
'预增': { color: '#E53E3E', bg: 'rgba(229, 62, 62, 0.15)', border: 'rgba(229, 62, 62, 0.3)' },
'预减': { color: '#48BB78', bg: 'rgba(72, 187, 120, 0.15)', border: 'rgba(72, 187, 120, 0.3)' },
'扭亏': { color: '#D4AF37', bg: 'rgba(212, 175, 55, 0.15)', border: 'rgba(212, 175, 55, 0.3)' },
'首亏': { color: '#48BB78', bg: 'rgba(72, 187, 120, 0.15)', border: 'rgba(72, 187, 120, 0.3)' },
'续亏': { color: '#718096', bg: 'rgba(113, 128, 150, 0.15)', border: 'rgba(113, 128, 150, 0.3)' },
'续盈': { color: '#E53E3E', bg: 'rgba(229, 62, 62, 0.15)', border: 'rgba(229, 62, 62, 0.3)' },
'略增': { color: '#ED8936', bg: 'rgba(237, 137, 54, 0.15)', border: 'rgba(237, 137, 54, 0.3)' },
'略减': { color: '#38B2AC', bg: 'rgba(56, 178, 172, 0.15)', border: 'rgba(56, 178, 172, 0.3)' },
};
return styles[type] || { color: THEME.gold, bg: THEME.goldLight, border: THEME.goldBorder };
};
const ForecastPanel = ({ stockCode }) => {
const [forecast, setForecast] = useState(null);
const [loading, setLoading] = useState(false);
const loadForecast = useCallback(async () => {
if (!stockCode) return;
setLoading(true);
try {
const { data: result } = await axios.get(
`/api/stock/${stockCode}/forecast`
);
if (result.success && result.data) {
setForecast(result.data);
}
} catch (err) {
logger.error('ForecastPanel', 'loadForecast', err, { stockCode });
setForecast(null);
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadForecast();
}, [loadForecast]);
if (loading) {
return (
<Center py={10}>
<Spinner size="lg" color={THEME.gold} />
</Center>
);
}
if (!forecast?.forecasts?.length) {
return (
<Center py={10}>
<Text color={THEME.textSecondary}>暂无业绩预告数据</Text>
</Center>
);
}
return (
<VStack spacing={3} align="stretch">
{forecast.forecasts.map((item, idx) => {
const typeStyle = getForecastTypeStyle(item.forecast_type);
return (
<Box
key={idx}
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.goldBorder}
borderRadius="md"
p={4}
>
{/* 头部:类型标签 + 报告期 */}
<Flex justify="space-between" align="center" mb={3}>
<Tag
style={{
color: typeStyle.color,
background: typeStyle.bg,
border: `1px solid ${typeStyle.border}`,
fontWeight: 500,
}}
>
{item.forecast_type}
</Tag>
<Text fontSize="sm" color={THEME.textSecondary}>
报告期: {item.report_date}
</Text>
</Flex>
{/* 内容 */}
<Text color={THEME.text} fontSize="sm" lineHeight="1.6" mb={3}>
{item.content}
</Text>
{/* 原因(如有) */}
{item.reason && (
<Text fontSize="xs" color={THEME.textSecondary} mb={3}>
{item.reason}
</Text>
)}
{/* 变动范围 */}
{item.change_range?.lower !== undefined && (
<Flex align="center" gap={2}>
<Text fontSize="sm" color={THEME.textSecondary}>
预计变动范围:
</Text>
<Tag
style={{
color: THEME.gold,
background: THEME.goldLight,
border: `1px solid ${THEME.goldBorder}`,
fontWeight: 600,
}}
>
{item.change_range.lower}% ~ {item.change_range.upper}%
</Tag>
</Flex>
)}
</Box>
);
})}
</VStack>
);
};
export default ForecastPanel;

View File

@@ -0,0 +1,111 @@
// src/views/Company/components/DynamicTracking/components/NewsPanel.js
// 新闻动态面板(包装 NewsEventsTab
import React, { useState, useEffect, useCallback } from 'react';
import { logger } from '@utils/logger';
import axios from '@utils/axiosConfig';
import NewsEventsTab from '../../CompanyOverview/NewsEventsTab';
const NewsPanel = ({ stockCode }) => {
const [newsEvents, setNewsEvents] = useState([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({
page: 1,
per_page: 10,
total: 0,
pages: 0,
has_next: false,
has_prev: false,
});
const [searchQuery, setSearchQuery] = useState('');
const [stockName, setStockName] = useState('');
// 获取股票名称
const fetchStockName = useCallback(async () => {
try {
const { data: result } = await axios.get(
`/api/stock/${stockCode}/basic-info`
);
if (result.success && result.data) {
const name = result.data.SECNAME || result.data.ORGNAME || stockCode;
setStockName(name);
return name;
}
return stockCode;
} catch (err) {
logger.error('NewsPanel', 'fetchStockName', err, { stockCode });
return stockCode;
}
}, [stockCode]);
// 加载新闻事件
const loadNewsEvents = useCallback(
async (query, page = 1) => {
setLoading(true);
try {
const searchTerm = query || stockName || stockCode;
const { data: result } = await axios.get(
`/api/events?q=${encodeURIComponent(searchTerm)}&page=${page}&per_page=10`
);
if (result.success) {
setNewsEvents(result.data || []);
setPagination({
page: result.pagination?.page || page,
per_page: result.pagination?.per_page || 10,
total: result.pagination?.total || 0,
pages: result.pagination?.pages || 0,
has_next: result.pagination?.has_next || false,
has_prev: result.pagination?.has_prev || false,
});
}
} catch (err) {
logger.error('NewsPanel', 'loadNewsEvents', err, { stockCode });
setNewsEvents([]);
} finally {
setLoading(false);
}
},
[stockCode, stockName]
);
// 首次加载
useEffect(() => {
const initLoad = async () => {
if (stockCode) {
const name = await fetchStockName();
await loadNewsEvents(name, 1);
}
};
initLoad();
}, [stockCode, fetchStockName, loadNewsEvents]);
// 搜索处理
const handleSearchChange = (value) => {
setSearchQuery(value);
};
const handleSearch = () => {
loadNewsEvents(searchQuery || stockName, 1);
};
// 分页处理
const handlePageChange = (page) => {
loadNewsEvents(searchQuery || stockName, page);
};
return (
<NewsEventsTab
newsEvents={newsEvents}
newsLoading={loading}
newsPagination={pagination}
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
onSearch={handleSearch}
onPageChange={handlePageChange}
themePreset="blackGold"
/>
);
};
export default NewsPanel;

View File

@@ -0,0 +1,4 @@
// src/views/Company/components/DynamicTracking/components/index.js
export { default as NewsPanel } from './NewsPanel';
export { default as ForecastPanel } from './ForecastPanel';

View File

@@ -0,0 +1,67 @@
// src/views/Company/components/DynamicTracking/index.js
// 动态跟踪 - 独立一级 Tab 组件(包含新闻动态等二级 Tab
import React, { useState, useEffect, useMemo } from 'react';
import { Box } from '@chakra-ui/react';
import { FaNewspaper, FaBullhorn, FaCalendarAlt, FaChartBar } from 'react-icons/fa';
import SubTabContainer from '@components/SubTabContainer';
import AnnouncementsPanel from '../CompanyOverview/BasicInfoTab/components/AnnouncementsPanel';
import DisclosureSchedulePanel from '../CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel';
import { NewsPanel, ForecastPanel } from './components';
// 二级 Tab 配置
const TRACKING_TABS = [
{ key: 'news', name: '新闻动态', icon: FaNewspaper, component: NewsPanel },
{ key: 'announcements', name: '公司公告', icon: FaBullhorn, component: AnnouncementsPanel },
{ key: 'disclosure', name: '财报披露日程', icon: FaCalendarAlt, component: DisclosureSchedulePanel },
{ key: 'forecast', name: '业绩预告', icon: FaChartBar, component: ForecastPanel },
];
/**
* 动态跟踪组件
*
* 功能:
* - 使用 SubTabContainer 实现二级导航
* - Tab1: 新闻动态
* - Tab2: 公司公告
* - Tab3: 财报披露日程
* - Tab4: 业绩预告
*
* @param {Object} props
* @param {string} props.stockCode - 股票代码
*/
const DynamicTracking = ({ stockCode: propStockCode }) => {
const [stockCode, setStockCode] = useState(propStockCode || '000001');
const [activeTab, setActiveTab] = useState(0);
// 监听 props 中的 stockCode 变化
useEffect(() => {
if (propStockCode && propStockCode !== stockCode) {
setStockCode(propStockCode);
}
}, [propStockCode, stockCode]);
// 传递给子组件的 props
const componentProps = useMemo(
() => ({
stockCode,
}),
[stockCode]
);
return (
<Box>
<SubTabContainer
tabs={TRACKING_TABS}
componentProps={componentProps}
themePreset="blackGold"
index={activeTab}
onTabChange={(index) => setActiveTab(index)}
isLazy
/>
</Box>
);
};
export default DynamicTracking;

View File

@@ -0,0 +1,326 @@
/**
* 资产负债表组件 - Ant Design 黑金主题
*/
import React, { useMemo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
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, MetricConfig } from '../types';
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.balance-sheet-table .ant-table {
background: transparent !important;
}
.balance-sheet-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.balance-sheet-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.balance-sheet-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.balance-sheet-table .ant-table-tbody > tr.total-row > td {
background: rgba(212, 175, 55, 0.15) !important;
font-weight: 600;
}
.balance-sheet-table .ant-table-tbody > tr.section-header > td {
background: rgba(212, 175, 55, 0.08) !important;
font-weight: 600;
color: #D4AF37;
}
.balance-sheet-table .ant-table-cell-fix-left,
.balance-sheet-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.balance-sheet-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.balance-sheet-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.balance-sheet-table .positive-change {
color: #E53E3E;
}
.balance-sheet-table .negative-change {
color: #48BB78;
}
.balance-sheet-table .ant-table-placeholder {
background: transparent !important;
}
.balance-sheet-table .ant-empty-description {
color: #A0AEC0;
}
`;
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
isTotal?: boolean;
isSection?: boolean;
indent?: number;
[period: string]: unknown;
}
export const BalanceSheetTable: React.FC<BalanceSheetTableProps> = ({
data,
showMetricChart,
calculateYoYChange,
positiveColor = 'red.500',
negativeColor = 'green.500',
}) => {
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
const maxColumns = Math.min(data.length, 6);
const displayData = data.slice(0, maxColumns);
// 所有分类配置
const allSections = [
CURRENT_ASSETS_METRICS,
NON_CURRENT_ASSETS_METRICS,
TOTAL_ASSETS_METRICS,
CURRENT_LIABILITIES_METRICS,
NON_CURRENT_LIABILITIES_METRICS,
TOTAL_LIABILITIES_METRICS,
EQUITY_METRICS,
];
// 构建表格数据
const tableData = useMemo(() => {
const rows: TableRowData[] = [];
allSections.forEach((section) => {
// 添加分组标题行(汇总行不显示标题)
if (!['资产总计', '负债合计'].includes(section.title)) {
rows.push({
key: `section-${section.key}`,
name: section.title,
path: '',
isSection: true,
});
}
// 添加指标行
section.metrics.forEach((metric: MetricConfig) => {
const row: TableRowData = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: metric.isCore,
isTotal: metric.isTotal || ['资产总计', '负债合计'].includes(section.title),
indent: metric.isTotal ? 0 : 1,
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath<number>(item, metric.path);
row[item.period] = value;
});
rows.push(row);
});
});
return rows;
}, [data, displayData]);
// 计算同比变化
const calculateYoY = (
currentValue: number | undefined,
currentPeriod: string,
path: string
): number | null => {
if (currentValue === undefined || currentValue === null) return null;
const currentDate = new Date(currentPeriod);
const lastYearPeriod = data.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
};
// 构建列定义
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: '项目',
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 200,
render: (name: string, record: TableRowData) => {
if (record.isSection) {
return <Text fontWeight="bold" color="#D4AF37">{name}</Text>;
}
return (
<HStack spacing={2} pl={record.indent ? 4 : 0}>
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
);
},
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 120,
align: 'right' as const,
render: (value: number | undefined, record: TableRowData) => {
if (record.isSection) return null;
const yoy = calculateYoY(value, item.period, record.path);
const formattedValue = formatUtils.formatLargeNumber(value, 0);
return (
<Tooltip
title={
<Box>
<Text>: {formatUtils.formatLargeNumber(value)}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Box position="relative">
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>
{formattedValue}
</Text>
{yoy !== null && Math.abs(yoy) > 30 && !record.isTotal && (
<Text
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={yoy > 0 ? 'positive-change' : 'negative-change'}
>
{yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}%
</Text>
)}
</Box>
</Tooltip>
);
},
})),
{
title: '',
key: 'action',
width: 40,
fixed: 'right',
render: (_: unknown, record: TableRowData) => {
if (record.isSection) return null;
return (
<Eye
size={14}
color="#D4AF37"
style={{ cursor: 'pointer', opacity: 0.7 }}
onClick={(e) => {
e.stopPropagation();
showMetricChart(record.name, record.key, data, record.path);
}}
/>
);
},
},
];
return cols;
}, [displayData, data, showMetricChart]);
return (
<Box className="balance-sheet-table">
<style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
rowClassName={(record) => {
if (record.isSection) return 'section-header';
if (record.isTotal) return 'total-row';
return '';
}}
onRow={(record) => ({
onClick: () => {
if (!record.isSection) {
showMetricChart(record.name, record.key, data, record.path);
}
},
style: { cursor: record.isSection ? 'default' : 'pointer' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
);
};
export default BalanceSheetTable;

View File

@@ -0,0 +1,269 @@
/**
* 现金流量表组件 - Ant Design 黑金主题
*/
import React, { useMemo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import { CASHFLOW_METRICS } from '../constants';
import { getValueByPath } from '../utils';
import type { CashflowTableProps } from '../types';
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.cashflow-table .ant-table {
background: transparent !important;
}
.cashflow-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.cashflow-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.cashflow-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.cashflow-table .ant-table-cell-fix-left,
.cashflow-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.cashflow-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.cashflow-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.cashflow-table .positive-value {
color: #E53E3E;
}
.cashflow-table .negative-value {
color: #48BB78;
}
.cashflow-table .positive-change {
color: #E53E3E;
}
.cashflow-table .negative-change {
color: #48BB78;
}
.cashflow-table .ant-table-placeholder {
background: transparent !important;
}
.cashflow-table .ant-empty-description {
color: #A0AEC0;
}
`;
// 核心指标
const CORE_METRICS = ['operating_net', 'free_cash_flow'];
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
[period: string]: unknown;
}
export const CashflowTable: React.FC<CashflowTableProps> = ({
data,
showMetricChart,
calculateYoYChange,
positiveColor = 'red.500',
negativeColor = 'green.500',
}) => {
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
const maxColumns = Math.min(data.length, 8);
const displayData = data.slice(0, maxColumns);
// 构建表格数据
const tableData = useMemo(() => {
return CASHFLOW_METRICS.map((metric) => {
const row: TableRowData = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: CORE_METRICS.includes(metric.key),
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath<number>(item, metric.path);
row[item.period] = value;
});
return row;
});
}, [data, displayData]);
// 计算同比变化
const calculateYoY = (
currentValue: number | undefined,
currentPeriod: string,
path: string
): number | null => {
if (currentValue === undefined || currentValue === null) return null;
const currentDate = new Date(currentPeriod);
const lastYearPeriod = data.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
};
// 构建列定义
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: '项目',
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 180,
render: (name: string, record: TableRowData) => (
<HStack spacing={2}>
<Text fontWeight="medium">{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
),
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 110,
align: 'right' as const,
render: (value: number | undefined, record: TableRowData) => {
const yoy = calculateYoY(value, item.period, record.path);
const formattedValue = formatUtils.formatLargeNumber(value, 1);
const isNegative = value !== undefined && value < 0;
return (
<Tooltip
title={
<Box>
<Text>: {formatUtils.formatLargeNumber(value)}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Box position="relative">
<Text className={isNegative ? 'negative-value' : 'positive-value'}>
{formattedValue}
</Text>
{yoy !== null && Math.abs(yoy) > 50 && (
<Text
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={yoy > 0 ? 'positive-change' : 'negative-change'}
>
{yoy > 0 ? '↑' : '↓'}
</Text>
)}
</Box>
</Tooltip>
);
},
})),
{
title: '',
key: 'action',
width: 40,
fixed: 'right',
render: (_: unknown, record: TableRowData) => (
<Eye
size={14}
color="#D4AF37"
style={{ cursor: 'pointer', opacity: 0.7 }}
onClick={(e) => {
e.stopPropagation();
showMetricChart(record.name, record.key, data, record.path);
}}
/>
),
},
];
return cols;
}, [displayData, data, showMetricChart]);
return (
<Box className="cashflow-table">
<style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
onRow={(record) => ({
onClick: () => {
showMetricChart(record.name, record.key, data, record.path);
},
style: { cursor: 'pointer' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
);
};
export default CashflowTable;

View File

@@ -0,0 +1,50 @@
/**
* 综合对比分析组件 - 黑金主题
*/
import React from 'react';
import { Box } from '@chakra-ui/react';
import ReactECharts from 'echarts-for-react';
import { formatUtils } from '@services/financialService';
import { getComparisonChartOption } from '../utils';
import type { ComparisonAnalysisProps } from '../types';
// 黑金主题样式
const THEME = {
cardBg: 'transparent',
border: 'rgba(212, 175, 55, 0.2)',
};
export const ComparisonAnalysis: React.FC<ComparisonAnalysisProps> = ({ 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 (
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
p={4}
>
<ReactECharts option={chartOption} style={{ height: '350px' }} />
</Box>
);
};
export default ComparisonAnalysis;

View File

@@ -0,0 +1,360 @@
/**
* 财务指标表格组件 - Ant Design 黑金主题
*/
import React, { useState, useMemo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge, SimpleGrid, Card, CardBody, CardHeader, Heading, Button } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
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;
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.financial-metrics-table .ant-table {
background: transparent !important;
}
.financial-metrics-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.financial-metrics-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.financial-metrics-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.financial-metrics-table .ant-table-cell-fix-left,
.financial-metrics-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.financial-metrics-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.financial-metrics-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.financial-metrics-table .positive-change {
color: #E53E3E;
}
.financial-metrics-table .negative-change {
color: #48BB78;
}
.financial-metrics-table .positive-value {
color: #E53E3E;
}
.financial-metrics-table .negative-value {
color: #48BB78;
}
.financial-metrics-table .ant-table-placeholder {
background: transparent !important;
}
.financial-metrics-table .ant-empty-description {
color: #A0AEC0;
}
`;
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
[period: string]: unknown;
}
export const FinancialMetricsTable: React.FC<FinancialMetricsTableProps> = ({
data,
showMetricChart,
calculateYoYChange,
}) => {
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('profitability');
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
const maxColumns = Math.min(data.length, 6);
const displayData = data.slice(0, maxColumns);
const currentCategory = FINANCIAL_METRICS_CATEGORIES[selectedCategory];
// 构建表格数据
const tableData = useMemo(() => {
return currentCategory.metrics.map((metric) => {
const row: TableRowData = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: metric.isCore,
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath<number>(item, metric.path);
row[item.period] = value;
});
return row;
});
}, [data, displayData, currentCategory]);
// 计算同比变化
const calculateYoY = (
currentValue: number | undefined,
currentPeriod: string,
path: string
): number | null => {
if (currentValue === undefined || currentValue === null) return null;
const currentDate = new Date(currentPeriod);
const lastYearPeriod = data.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
};
// 构建列定义
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: currentCategory.title,
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 200,
render: (name: string, record: TableRowData) => (
<HStack spacing={2}>
<Text fontWeight="medium" fontSize="xs">{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
),
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 100,
align: 'right' as const,
render: (value: number | undefined, record: TableRowData) => {
const yoy = calculateYoY(value, item.period, record.path);
const isNegative = isNegativeIndicator(record.key);
// 对于负向指标,增加是坏事(绿色),减少是好事(红色)
const changeColor = isNegative
? (yoy && yoy > 0 ? 'negative-change' : 'positive-change')
: (yoy && yoy > 0 ? 'positive-change' : 'negative-change');
// 成长能力指标特殊处理:正值红色,负值绿色
const valueColor = selectedCategory === 'growth'
? (value !== undefined && value > 0 ? 'positive-value' : value !== undefined && value < 0 ? 'negative-value' : '')
: '';
return (
<Tooltip
title={
<Box>
<Text>{record.name}: {value?.toFixed(2) || '-'}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Box position="relative">
<Text fontSize="xs" className={valueColor || undefined}>
{value?.toFixed(2) || '-'}
</Text>
{yoy !== null && Math.abs(yoy) > 20 && value !== undefined && Math.abs(value) > 0.01 && (
<Text
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={changeColor}
>
{yoy > 0 ? '↑' : '↓'}
</Text>
)}
</Box>
</Tooltip>
);
},
})),
{
title: '',
key: 'action',
width: 40,
fixed: 'right',
render: (_: unknown, record: TableRowData) => (
<Eye
size={14}
color="#D4AF37"
style={{ cursor: 'pointer', opacity: 0.7 }}
onClick={(e) => {
e.stopPropagation();
showMetricChart(record.name, record.key, data, record.path);
}}
/>
),
},
];
return cols;
}, [displayData, data, showMetricChart, currentCategory, selectedCategory]);
return (
<Box>
{/* 分类选择器 */}
<HStack spacing={2} mb={4} flexWrap="wrap">
{(Object.entries(FINANCIAL_METRICS_CATEGORIES) as [CategoryKey, typeof currentCategory][]).map(
([key, category]) => (
<Button
key={key}
size="sm"
variant={selectedCategory === key ? 'solid' : 'outline'}
bg={selectedCategory === key ? 'rgba(212, 175, 55, 0.3)' : 'transparent'}
color={selectedCategory === key ? '#D4AF37' : 'gray.400'}
borderColor="rgba(212, 175, 55, 0.3)"
_hover={{
bg: 'rgba(212, 175, 55, 0.2)',
borderColor: 'rgba(212, 175, 55, 0.5)',
}}
onClick={() => setSelectedCategory(key)}
>
{category.title.replace('指标', '')}
</Button>
)
)}
</HStack>
{/* 指标表格 */}
<Box className="financial-metrics-table">
<style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
onRow={(record) => ({
onClick: () => {
showMetricChart(record.name, record.key, data, record.path);
},
style: { cursor: 'pointer' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
{/* 关键指标快速对比 */}
{data[0] && (
<Card mt={4} bg="transparent" border="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
<CardHeader py={3} borderBottom="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
<Heading size="sm" color="#D4AF37"></Heading>
</CardHeader>
<CardBody>
<SimpleGrid columns={{ base: 2, md: 4, lg: 6 }} spacing={4}>
{[
{
label: 'ROE',
value: getValueByPath<number>(data[0], 'profitability.roe'),
format: 'percent',
},
{
label: '毛利率',
value: getValueByPath<number>(data[0], 'profitability.gross_margin'),
format: 'percent',
},
{
label: '净利率',
value: getValueByPath<number>(data[0], 'profitability.net_profit_margin'),
format: 'percent',
},
{
label: '流动比率',
value: getValueByPath<number>(data[0], 'solvency.current_ratio'),
format: 'decimal',
},
{
label: '资产负债率',
value: getValueByPath<number>(data[0], 'solvency.asset_liability_ratio'),
format: 'percent',
},
{
label: '研发费用率',
value: getValueByPath<number>(data[0], 'expense_ratios.rd_expense_ratio'),
format: 'percent',
},
].map((item, idx) => (
<Box key={idx} p={3} borderRadius="md" bg="rgba(212, 175, 55, 0.1)" border="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
<Text fontSize="xs" color="gray.400">
{item.label}
</Text>
<Text fontSize="lg" fontWeight="bold" color="#D4AF37">
{item.format === 'percent'
? formatUtils.formatPercent(item.value)
: item.value?.toFixed(2) || '-'}
</Text>
</Box>
))}
</SimpleGrid>
</CardBody>
</Card>
)}
</Box>
);
};
export default FinancialMetricsTable;

View File

@@ -0,0 +1,188 @@
/**
* 财务全景面板组件 - 三列布局
* 复用 MarketDataView 的 MetricCard 组件
*/
import React, { memo } from 'react';
import { SimpleGrid, HStack, VStack, Text, Badge } from '@chakra-ui/react';
import { TrendingUp, Coins, Shield, TrendingDown, Activity, PieChart } from 'lucide-react';
import { formatUtils } from '@services/financialService';
// 复用 MarketDataView 的组件
import MetricCard from '../../MarketDataView/components/StockSummaryCard/MetricCard';
import { StatusTag } from '../../MarketDataView/components/StockSummaryCard/atoms';
import { darkGoldTheme } from '../../MarketDataView/constants';
import type { StockInfo, FinancialMetricsData } from '../types';
export interface FinancialOverviewPanelProps {
stockInfo: StockInfo | null;
financialMetrics: FinancialMetricsData[];
}
/**
* 获取成长状态
*/
const getGrowthStatus = (value: number | undefined): { text: string; color: string } => {
if (value === undefined || value === null) return { text: '-', color: darkGoldTheme.textMuted };
if (value > 30) return { text: '高速增长', color: darkGoldTheme.green };
if (value > 10) return { text: '稳健增长', color: darkGoldTheme.gold };
if (value > 0) return { text: '低速增长', color: darkGoldTheme.orange };
if (value > -10) return { text: '小幅下滑', color: darkGoldTheme.orange };
return { text: '大幅下滑', color: darkGoldTheme.red };
};
/**
* 获取 ROE 状态
*/
const getROEStatus = (value: number | undefined): { text: string; color: string } => {
if (value === undefined || value === null) return { text: '-', color: darkGoldTheme.textMuted };
if (value > 20) return { text: '优秀', color: darkGoldTheme.green };
if (value > 15) return { text: '良好', color: darkGoldTheme.gold };
if (value > 10) return { text: '一般', color: darkGoldTheme.orange };
return { text: '较低', color: darkGoldTheme.red };
};
/**
* 获取资产负债率状态
*/
const getDebtStatus = (value: number | undefined): { text: string; color: string } => {
if (value === undefined || value === null) return { text: '-', color: darkGoldTheme.textMuted };
if (value < 40) return { text: '安全', color: darkGoldTheme.green };
if (value < 60) return { text: '适中', color: darkGoldTheme.gold };
if (value < 70) return { text: '偏高', color: darkGoldTheme.orange };
return { text: '风险', color: darkGoldTheme.red };
};
/**
* 财务全景面板组件
*/
export const FinancialOverviewPanel: React.FC<FinancialOverviewPanelProps> = memo(({
stockInfo,
financialMetrics,
}) => {
if (!stockInfo && (!financialMetrics || financialMetrics.length === 0)) {
return null;
}
// 获取最新一期财务指标
const latestMetrics = financialMetrics?.[0];
// 成长指标(来自 stockInfo
const revenueGrowth = stockInfo?.growth_rates?.revenue_growth;
const profitGrowth = stockInfo?.growth_rates?.profit_growth;
const forecast = stockInfo?.latest_forecast;
// 盈利指标(来自 financialMetrics
const roe = latestMetrics?.profitability?.roe;
const netProfitMargin = latestMetrics?.profitability?.net_profit_margin;
const grossMargin = latestMetrics?.profitability?.gross_margin;
// 风险与运营指标(来自 financialMetrics
const assetLiabilityRatio = latestMetrics?.solvency?.asset_liability_ratio;
const currentRatio = latestMetrics?.solvency?.current_ratio;
const rdExpenseRatio = latestMetrics?.expense_ratios?.rd_expense_ratio;
// 计算状态
const growthStatus = getGrowthStatus(profitGrowth);
const roeStatus = getROEStatus(roe);
const debtStatus = getDebtStatus(assetLiabilityRatio);
// 格式化涨跌显示
const formatGrowth = (value: number | undefined) => {
if (value === undefined || value === null) return '-';
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(2)}%`;
};
return (
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
{/* 卡片1: 成长能力 */}
<MetricCard
title="成长能力"
subtitle="增长动力"
leftIcon={<TrendingUp size={14} />}
rightIcon={<Activity size={14} />}
mainLabel="利润增长"
mainValue={formatGrowth(profitGrowth)}
mainColor={profitGrowth !== undefined && profitGrowth >= 0 ? darkGoldTheme.green : darkGoldTheme.red}
subText={
<VStack align="start" spacing={1}>
<HStack spacing={1} flexWrap="wrap">
<Text></Text>
<Text
fontWeight="bold"
color={revenueGrowth !== undefined && revenueGrowth >= 0 ? darkGoldTheme.green : darkGoldTheme.red}
>
{formatGrowth(revenueGrowth)}
</Text>
<StatusTag text={growthStatus.text} color={growthStatus.color} />
</HStack>
{forecast && (
<Badge
bg="rgba(212, 175, 55, 0.15)"
color={darkGoldTheme.gold}
fontSize="xs"
px={2}
py={0.5}
borderRadius="md"
>
{forecast.forecast_type} {forecast.content}
</Badge>
)}
</VStack>
}
/>
{/* 卡片2: 盈利与回报 */}
<MetricCard
title="盈利与回报"
subtitle="赚钱能力"
leftIcon={<Coins size={14} />}
rightIcon={<PieChart size={14} />}
mainLabel="ROE"
mainValue={formatUtils.formatPercent(roe)}
mainColor={darkGoldTheme.orange}
subText={
<VStack align="start" spacing={0.5}>
<Text color={roeStatus.color} fontWeight="medium">
{roeStatus.text}
</Text>
<HStack spacing={1} flexWrap="wrap">
<Text> {formatUtils.formatPercent(netProfitMargin)}</Text>
<Text>|</Text>
<Text> {formatUtils.formatPercent(grossMargin)}</Text>
</HStack>
</VStack>
}
/>
{/* 卡片3: 风险与运营 */}
<MetricCard
title="风险与运营"
subtitle="安全边际"
leftIcon={<Shield size={14} />}
rightIcon={<TrendingDown size={14} />}
mainLabel="资产负债率"
mainValue={formatUtils.formatPercent(assetLiabilityRatio)}
mainColor={debtStatus.color}
subText={
<VStack align="start" spacing={0.5}>
<Text color={debtStatus.color} fontWeight="medium">
{debtStatus.text}
</Text>
<HStack spacing={1} flexWrap="wrap">
<Text> {currentRatio?.toFixed(2) ?? '-'}</Text>
<Text>|</Text>
<Text> {formatUtils.formatPercent(rdExpenseRatio)}</Text>
</HStack>
</VStack>
}
/>
</SimpleGrid>
);
});
FinancialOverviewPanel.displayName = 'FinancialOverviewPanel';
export default FinancialOverviewPanel;

View File

@@ -0,0 +1,328 @@
/**
* 通用财务表格组件 - Ant Design 黑金主题
*/
import React from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip, Badge } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
// Ant Design 表格黑金主题配置
export const FINANCIAL_TABLE_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 通用样式
export const tableStyles = `
.financial-table .ant-table {
background: transparent !important;
}
.financial-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.financial-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.financial-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.financial-table .ant-table-tbody > tr.total-row > td {
background: rgba(212, 175, 55, 0.15) !important;
font-weight: 600;
}
.financial-table .ant-table-tbody > tr.section-header > td {
background: rgba(212, 175, 55, 0.08) !important;
font-weight: 600;
color: #D4AF37;
}
.financial-table .ant-table-cell-fix-left,
.financial-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.financial-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.financial-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.financial-table .positive-change {
color: #E53E3E;
}
.financial-table .negative-change {
color: #48BB78;
}
.financial-table .ant-table-placeholder {
background: transparent !important;
}
.financial-table .ant-empty-description {
color: #A0AEC0;
}
`;
// 指标类型
export interface MetricConfig {
name: string;
key: string;
path: string;
isCore?: boolean;
isTotal?: boolean;
isSubtotal?: boolean;
}
export interface MetricSectionConfig {
title: string;
key: string;
metrics: MetricConfig[];
}
// 表格行数据类型
export interface FinancialTableRow {
key: string;
name: string;
path: string;
isCore?: boolean;
isTotal?: boolean;
isSection?: boolean;
indent?: number;
[period: string]: unknown;
}
// 组件 Props
export interface FinancialTableProps {
data: Array<{ period: string; [key: string]: unknown }>;
sections: MetricSectionConfig[];
onRowClick?: (name: string, key: string, path: string) => void;
loading?: boolean;
maxColumns?: number;
}
// 获取嵌套路径的值
const getValueByPath = (obj: Record<string, unknown>, path: string): number | undefined => {
const keys = path.split('.');
let value: unknown = obj;
for (const key of keys) {
if (value && typeof value === 'object') {
value = (value as Record<string, unknown>)[key];
} else {
return undefined;
}
}
return typeof value === 'number' ? value : undefined;
};
// 计算同比变化
const calculateYoY = (
currentValue: number | undefined,
currentPeriod: string,
data: Array<{ period: string; [key: string]: unknown }>,
path: string
): number | null => {
if (currentValue === undefined || currentValue === null) return null;
const currentDate = new Date(currentPeriod);
const lastYearPeriod = data.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath(lastYearPeriod as Record<string, unknown>, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
};
const FinancialTable: React.FC<FinancialTableProps> = ({
data,
sections,
onRowClick,
loading = false,
maxColumns = 6,
}) => {
// 限制显示列数
const displayData = data.slice(0, maxColumns);
// 构建表格数据
const tableData: FinancialTableRow[] = [];
sections.forEach((section) => {
// 添加分组标题行(除了汇总行)
if (!section.title.includes('总计') && !section.title.includes('合计')) {
tableData.push({
key: `section-${section.key}`,
name: section.title,
path: '',
isSection: true,
});
}
// 添加指标行
section.metrics.forEach((metric) => {
const row: FinancialTableRow = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: metric.isCore,
isTotal: metric.isTotal || section.title.includes('总计') || section.title.includes('合计'),
indent: metric.isTotal ? 0 : 1,
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath(item as Record<string, unknown>, metric.path);
row[item.period] = value;
});
tableData.push(row);
});
});
// 构建列定义
const columns: ColumnsType<FinancialTableRow> = [
{
title: '项目',
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 180,
render: (name: string, record: FinancialTableRow) => {
if (record.isSection) {
return <Text fontWeight="bold" color="#D4AF37">{name}</Text>;
}
return (
<HStack spacing={2} pl={record.indent ? 4 : 0}>
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
);
},
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 110,
align: 'right' as const,
render: (value: number | undefined, record: FinancialTableRow) => {
if (record.isSection) return null;
const yoy = calculateYoY(value, item.period, data, record.path);
const formattedValue = formatUtils.formatLargeNumber(value, 0);
return (
<Tooltip
title={
<Box>
<Text>: {formatUtils.formatLargeNumber(value)}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Box position="relative">
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>
{formattedValue}
</Text>
{yoy !== null && Math.abs(yoy) > 30 && !record.isTotal && (
<Text
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={yoy > 0 ? 'positive-change' : 'negative-change'}
>
{yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}%
</Text>
)}
</Box>
</Tooltip>
);
},
})),
{
title: '',
key: 'action',
width: 40,
fixed: 'right',
render: (_: unknown, record: FinancialTableRow) => {
if (record.isSection) return null;
return (
<Eye
size={14}
color="#D4AF37"
style={{ cursor: 'pointer', opacity: 0.7 }}
onClick={(e) => {
e.stopPropagation();
onRowClick?.(record.name, record.key, record.path);
}}
/>
);
},
},
];
return (
<Box className="financial-table">
<style>{tableStyles}</style>
<ConfigProvider theme={FINANCIAL_TABLE_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
loading={loading}
size="small"
scroll={{ x: 'max-content' }}
rowClassName={(record) => {
if (record.isSection) return 'section-header';
if (record.isTotal) return 'total-row';
return '';
}}
onRow={(record) => ({
onClick: () => {
if (!record.isSection && onRowClick) {
onRowClick(record.name, record.key, record.path);
}
},
style: { cursor: record.isSection ? 'default' : 'pointer' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
);
};
export default FinancialTable;

View File

@@ -0,0 +1,326 @@
/**
* 利润表组件 - Ant Design 黑金主题
*/
import React, { useMemo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import { INCOME_STATEMENT_SECTIONS } from '../constants';
import { getValueByPath, isNegativeIndicator } from '../utils';
import type { IncomeStatementTableProps, MetricConfig } from '../types';
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.income-statement-table .ant-table {
background: transparent !important;
}
.income-statement-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.income-statement-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.income-statement-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.income-statement-table .ant-table-tbody > tr.total-row > td {
background: rgba(212, 175, 55, 0.15) !important;
font-weight: 600;
}
.income-statement-table .ant-table-tbody > tr.subtotal-row > td {
background: rgba(212, 175, 55, 0.1) !important;
font-weight: 500;
}
.income-statement-table .ant-table-tbody > tr.section-header > td {
background: rgba(212, 175, 55, 0.08) !important;
font-weight: 600;
color: #D4AF37;
}
.income-statement-table .ant-table-cell-fix-left,
.income-statement-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.income-statement-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.income-statement-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.income-statement-table .positive-change {
color: #E53E3E;
}
.income-statement-table .negative-change {
color: #48BB78;
}
.income-statement-table .negative-value {
color: #E53E3E;
}
.income-statement-table .ant-table-placeholder {
background: transparent !important;
}
.income-statement-table .ant-empty-description {
color: #A0AEC0;
}
`;
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
isTotal?: boolean;
isSubtotal?: boolean;
isSection?: boolean;
indent?: number;
[period: string]: unknown;
}
export const IncomeStatementTable: React.FC<IncomeStatementTableProps> = ({
data,
showMetricChart,
calculateYoYChange,
positiveColor = 'red.500',
negativeColor = 'green.500',
}) => {
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
const maxColumns = Math.min(data.length, 6);
const displayData = data.slice(0, maxColumns);
// 构建表格数据
const tableData = useMemo(() => {
const rows: TableRowData[] = [];
INCOME_STATEMENT_SECTIONS.forEach((section) => {
// 添加分组标题行
rows.push({
key: `section-${section.key}`,
name: section.title,
path: '',
isSection: true,
});
// 添加指标行
section.metrics.forEach((metric: MetricConfig) => {
const row: TableRowData = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: metric.isCore,
isTotal: metric.isTotal,
isSubtotal: metric.isSubtotal,
indent: metric.isTotal || metric.isSubtotal ? 0 : (metric.name.startsWith(' ') ? 2 : 1),
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath<number>(item, metric.path);
row[item.period] = value;
});
rows.push(row);
});
});
return rows;
}, [data, displayData]);
// 计算同比变化
const calculateYoY = (
currentValue: number | undefined,
currentPeriod: string,
path: string
): number | null => {
if (currentValue === undefined || currentValue === null) return null;
const currentDate = new Date(currentPeriod);
const lastYearPeriod = data.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
};
// 构建列定义
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: '项目',
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 250,
render: (name: string, record: TableRowData) => {
if (record.isSection) {
return <Text fontWeight="bold" color="#D4AF37">{name}</Text>;
}
return (
<HStack spacing={2} pl={record.indent ? record.indent * 4 : 0}>
<Text fontWeight={record.isTotal || record.isSubtotal ? 'bold' : 'normal'}>{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
);
},
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 120,
align: 'right' as const,
render: (value: number | undefined, record: TableRowData) => {
if (record.isSection) return null;
const yoy = calculateYoY(value, item.period, record.path);
const isEPS = record.key.includes('eps');
const formattedValue = isEPS ? value?.toFixed(3) : formatUtils.formatLargeNumber(value, 0);
const isNegative = value !== undefined && value < 0;
// 成本费用类负向指标,增长用绿色,减少用红色
const isCostItem = isNegativeIndicator(record.key);
const changeColor = isCostItem
? (yoy && yoy > 0 ? 'negative-change' : 'positive-change')
: (yoy && yoy > 0 ? 'positive-change' : 'negative-change');
return (
<Tooltip
title={
<Box>
<Text>: {isEPS ? value?.toFixed(3) : formatUtils.formatLargeNumber(value)}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Box position="relative">
<Text
fontWeight={record.isTotal || record.isSubtotal ? 'bold' : 'normal'}
className={isNegative ? 'negative-value' : undefined}
>
{formattedValue}
</Text>
{yoy !== null && Math.abs(yoy) > 30 && !record.isTotal && (
<Text
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={changeColor}
>
{yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}%
</Text>
)}
</Box>
</Tooltip>
);
},
})),
{
title: '',
key: 'action',
width: 40,
fixed: 'right',
render: (_: unknown, record: TableRowData) => {
if (record.isSection) return null;
return (
<Eye
size={14}
color="#D4AF37"
style={{ cursor: 'pointer', opacity: 0.7 }}
onClick={(e) => {
e.stopPropagation();
showMetricChart(record.name, record.key, data, record.path);
}}
/>
);
},
},
];
return cols;
}, [displayData, data, showMetricChart]);
return (
<Box className="income-statement-table">
<style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
rowClassName={(record) => {
if (record.isSection) return 'section-header';
if (record.isTotal) return 'total-row';
if (record.isSubtotal) return 'subtotal-row';
return '';
}}
onRow={(record) => ({
onClick: () => {
if (!record.isSection) {
showMetricChart(record.name, record.key, data, record.path);
}
},
style: { cursor: record.isSection ? 'default' : 'pointer' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
);
};
export default IncomeStatementTable;

View File

@@ -0,0 +1,142 @@
/**
* 行业排名组件
*/
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<IndustryRankingViewProps> = ({
industryRank,
bgColor = 'white',
borderColor = 'gray.200',
textColor,
labelColor,
}) => {
// 判断是否为深色主题
const isDarkTheme = bgColor === 'gray.800' || bgColor === 'gray.900';
const resolvedTextColor = textColor || (isDarkTheme ? 'white' : 'gray.800');
const resolvedLabelColor = labelColor || (isDarkTheme ? 'gray.400' : 'gray.500');
const cardBg = isDarkTheme ? 'transparent' : 'white';
const headingColor = isDarkTheme ? 'yellow.500' : 'gray.800';
if (!industryRank || !Array.isArray(industryRank) || industryRank.length === 0) {
return (
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
<CardBody>
<Text textAlign="center" color={resolvedLabelColor} py={8}>
</Text>
</CardBody>
</Card>
);
}
return (
<VStack spacing={4} align="stretch">
{industryRank.map((periodData, periodIdx) => (
<Card
key={periodIdx}
bg={cardBg}
borderColor={borderColor}
borderWidth="1px"
>
<CardHeader pb={2}>
<HStack justify="space-between">
<Heading size="sm" color={headingColor}>
{periodData.report_type}
</Heading>
<Badge
bg={isDarkTheme ? 'transparent' : undefined}
borderWidth={isDarkTheme ? '1px' : 0}
borderColor={isDarkTheme ? 'yellow.600' : undefined}
color={isDarkTheme ? 'yellow.500' : undefined}
colorScheme={isDarkTheme ? undefined : 'purple'}
>
{periodData.period}
</Badge>
</HStack>
</CardHeader>
<CardBody pt={2}>
{periodData.rankings?.map((ranking, idx) => (
<Box key={idx} mb={6}>
<Text fontWeight="bold" mb={3} color={resolvedTextColor}>
{ranking.industry_name} ({ranking.level_description})
</Text>
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} spacing={3}>
{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';
// 格式化数值
const formattedValue = isPercentMetric
? formatUtils.formatPercent(metricData.value)
: metricData.value?.toFixed(2) ?? '-';
const formattedAvg = isPercentMetric
? formatUtils.formatPercent(metricData.industry_avg)
: metricData.industry_avg?.toFixed(2) ?? '-';
return (
<Box
key={metric.key}
p={3}
borderRadius="md"
bg={bgColor}
borderWidth="1px"
borderColor={borderColor}
>
<Text fontSize="xs" color={resolvedLabelColor}>
{metric.name}
</Text>
<HStack mt={1} spacing={2}>
<Text fontWeight="bold" fontSize="lg" color={resolvedTextColor}>
{formattedValue}
</Text>
{metricData.rank && (
<Badge
size="sm"
colorScheme={isGood ? 'red' : isBad ? 'green' : 'gray'}
>
#{metricData.rank}
</Badge>
)}
</HStack>
<Text fontSize="xs" color={resolvedLabelColor} mt={1}>
: {formattedAvg}
</Text>
</Box>
);
})}
</SimpleGrid>
</Box>
))}
</CardBody>
</Card>
))}
</VStack>
);
};
export default IndustryRankingView;

View File

@@ -0,0 +1,138 @@
/**
* 关键指标速览组件 - 黑金主题
* 展示核心财务指标的快速概览
*/
import React, { memo } from 'react';
import { Box, Heading, SimpleGrid, Text, HStack, Icon } from '@chakra-ui/react';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import type { FinancialMetricsData } from '../types';
// 黑金主题样式
const THEME = {
cardBg: 'transparent',
border: 'rgba(212, 175, 55, 0.2)',
headingColor: '#D4AF37',
itemBg: 'rgba(212, 175, 55, 0.05)',
itemBorder: 'rgba(212, 175, 55, 0.15)',
labelColor: 'gray.400',
valueColor: 'white',
positiveColor: '#22c55e',
negativeColor: '#ef4444',
};
// 指标配置
const KEY_METRICS = [
{ label: 'ROE', path: 'profitability.roe', format: 'percent', higherBetter: true },
{ label: '毛利率', path: 'profitability.gross_margin', format: 'percent', higherBetter: true },
{ label: '净利率', path: 'profitability.net_profit_margin', format: 'percent', higherBetter: true },
{ label: '流动比率', path: 'solvency.current_ratio', format: 'decimal', higherBetter: true },
{ label: '资产负债率', path: 'solvency.asset_liability_ratio', format: 'percent', higherBetter: false },
{ label: '研发费用率', path: 'expense_ratios.rd_expense_ratio', format: 'percent', higherBetter: true },
];
// 通过路径获取值
const getValueByPath = <T,>(obj: FinancialMetricsData, path: string): T | undefined => {
return path.split('.').reduce((acc: unknown, key: string) => {
if (acc && typeof acc === 'object') {
return (acc as Record<string, unknown>)[key];
}
return undefined;
}, obj as unknown) as T | undefined;
};
export interface KeyMetricsOverviewProps {
financialMetrics: FinancialMetricsData[];
}
export const KeyMetricsOverview: React.FC<KeyMetricsOverviewProps> = memo(({
financialMetrics,
}) => {
if (!financialMetrics || financialMetrics.length === 0) {
return null;
}
const currentPeriod = financialMetrics[0];
const previousPeriod = financialMetrics[1];
return (
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
overflow="hidden"
>
<Box px={4} py={3} borderBottom="1px solid" borderColor={THEME.border}>
<Heading size="sm" color={THEME.headingColor}>
</Heading>
</Box>
<Box p={4}>
<SimpleGrid columns={{ base: 2, md: 3, lg: 6 }} spacing={3}>
{KEY_METRICS.map((metric, idx) => {
const currentValue = getValueByPath<number>(currentPeriod, metric.path);
const previousValue = previousPeriod
? getValueByPath<number>(previousPeriod, metric.path)
: undefined;
// 计算变化
let change: number | null = null;
let trend: 'up' | 'down' | 'flat' = 'flat';
if (currentValue !== undefined && previousValue !== undefined && previousValue !== 0) {
change = currentValue - previousValue;
if (Math.abs(change) > 0.01) {
trend = change > 0 ? 'up' : 'down';
}
}
// 判断趋势是好是坏
const isPositiveTrend = metric.higherBetter ? trend === 'up' : trend === 'down';
const trendColor = trend === 'flat'
? 'gray.500'
: isPositiveTrend
? THEME.positiveColor
: THEME.negativeColor;
return (
<Box
key={idx}
p={3}
borderRadius="md"
bg={THEME.itemBg}
border="1px solid"
borderColor={THEME.itemBorder}
>
<Text fontSize="xs" color={THEME.labelColor} mb={1}>
{metric.label}
</Text>
<HStack justify="space-between" align="flex-end">
<Text fontSize="lg" fontWeight="bold" color={THEME.valueColor}>
{metric.format === 'percent'
? formatUtils.formatPercent(currentValue)
: currentValue?.toFixed(2) ?? '-'}
</Text>
{trend !== 'flat' && (
<Icon
as={trend === 'up' ? TrendingUp : TrendingDown}
boxSize={4}
color={trendColor}
/>
)}
{trend === 'flat' && (
<Icon as={Minus} boxSize={4} color="gray.500" />
)}
</HStack>
</Box>
);
})}
</SimpleGrid>
</Box>
</Box>
);
});
KeyMetricsOverview.displayName = 'KeyMetricsOverview';
export default KeyMetricsOverview;

View File

@@ -0,0 +1,294 @@
/**
* 主营业务分析组件 - 黑金主题
*/
import React, { useMemo } from 'react';
import {
Flex,
Box,
Heading,
Alert,
AlertIcon,
} from '@chakra-ui/react';
import { Table as AntTable, ConfigProvider, theme as antTheme } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import ReactECharts from 'echarts-for-react';
import { formatUtils } from '@services/financialService';
import { getMainBusinessPieOption } from '../utils';
import type {
MainBusinessAnalysisProps,
BusinessItem,
ProductClassification,
IndustryClassification,
} from '../types';
// 黑金主题样式
const THEME = {
cardBg: 'transparent',
border: 'rgba(212, 175, 55, 0.2)',
headingColor: '#D4AF37',
textColor: 'gray.300',
thColor: 'gray.400',
};
// 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 fixedColumnStyles = `
.main-business-table .ant-table-cell-fix-left,
.main-business-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.main-business-table .ant-table-thead .ant-table-cell-fix-left,
.main-business-table .ant-table-thead .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.main-business-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.main-business-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-left,
.main-business-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-right {
background: #242d3d !important;
}
.main-business-table .ant-table-tbody > tr > td {
background: #1A202C !important;
}
`;
// 历史对比表格数据行类型(包含业务明细)
interface HistoricalRowData {
key: string;
business: string;
grossMargin?: number;
profit?: number;
[period: string]: string | number | undefined;
}
// 历史对比表格组件(整合业务明细)
interface HistoricalComparisonTableProps {
historicalData: (ProductClassification | IndustryClassification)[];
businessItems: BusinessItem[];
hasProductData: boolean;
latestReportType: string;
}
const HistoricalComparisonTable: React.FC<HistoricalComparisonTableProps> = ({
historicalData,
businessItems,
hasProductData,
latestReportType,
}) => {
// 动态生成列配置
const columns: ColumnsType<HistoricalRowData> = useMemo(() => {
const cols: ColumnsType<HistoricalRowData> = [
{
title: '业务',
dataIndex: 'business',
key: 'business',
fixed: 'left',
width: 150,
},
{
title: `毛利率(${latestReportType})`,
dataIndex: 'grossMargin',
key: 'grossMargin',
align: 'right',
width: 120,
render: (value: number | undefined) =>
value !== undefined ? formatUtils.formatPercent(value) : '-',
},
{
title: `利润(${latestReportType})`,
dataIndex: 'profit',
key: 'profit',
align: 'right',
width: 100,
render: (value: number | undefined) =>
value !== undefined ? formatUtils.formatLargeNumber(value) : '-',
},
];
// 添加各期间营收列
historicalData.slice(0, 4).forEach((period) => {
cols.push({
title: `营收(${period.report_type})`,
dataIndex: period.period,
key: period.period,
align: 'right',
width: 120,
render: (value: number | string | undefined) =>
value !== undefined && value !== '-'
? formatUtils.formatLargeNumber(value as number)
: '-',
});
});
return cols;
}, [historicalData, latestReportType]);
// 生成表格数据(包含业务明细)
const dataSource: HistoricalRowData[] = useMemo(() => {
return businessItems
.filter((item: BusinessItem) => item.content !== '合计')
.map((item: BusinessItem, idx: number) => {
const row: HistoricalRowData = {
key: `${idx}`,
business: item.content,
grossMargin: item.gross_margin || item.profit_margin,
profit: item.profit,
};
// 添加各期间营收数据
historicalData.slice(0, 4).forEach((period) => {
const periodItems: BusinessItem[] = hasProductData
? (period as ProductClassification).products
: (period as IndustryClassification).industries;
const matchItem = periodItems.find(
(p: BusinessItem) => p.content === item.content
);
row[period.period] = matchItem?.revenue ?? '-';
});
return row;
});
}, [businessItems, historicalData, hasProductData]);
return (
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
overflow="hidden"
h="100%"
className="main-business-table"
>
<style>{fixedColumnStyles}</style>
<Box px={4} py={3} borderBottom="1px solid" borderColor={THEME.border}>
<Heading size="sm" color={THEME.headingColor}>
</Heading>
</Box>
<Box p={4} overflowX="auto">
<ConfigProvider theme={BLACK_GOLD_THEME}>
<AntTable<HistoricalRowData>
columns={columns}
dataSource={dataSource}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
bordered
/>
</ConfigProvider>
</Box>
</Box>
);
};
export const MainBusinessAnalysis: React.FC<MainBusinessAnalysisProps> = ({
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 (
<Alert status="info" bg="rgba(212, 175, 55, 0.1)" color={THEME.headingColor}>
<AlertIcon color={THEME.headingColor} />
</Alert>
);
}
// 选择数据源
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 (
<Flex
direction={{ base: 'column', lg: 'row' }}
gap={4}
>
{/* 左侧:饼图 */}
<Box
flexShrink={0}
w={{ base: '100%', lg: '340px' }}
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
p={4}
>
<ReactECharts option={pieOption} style={{ height: '280px' }} />
</Box>
{/* 右侧:业务明细与历史对比表格 */}
<Box flex={1} minW={0} overflow="hidden">
{historicalData.length > 0 && (
<HistoricalComparisonTable
historicalData={historicalData}
businessItems={businessItems}
hasProductData={hasProductData}
latestReportType={latestPeriod.report_type}
/>
)}
</Box>
</Flex>
);
};
export default MainBusinessAnalysis;

View File

@@ -0,0 +1,97 @@
/**
* 期数选择器组件 - 黑金主题
* 用于选择显示的财务报表期数,并提供刷新功能
*/
import React, { memo } from 'react';
import { HStack, Text, IconButton } from '@chakra-ui/react';
import { Select } from 'antd';
import { RefreshCw } from 'lucide-react';
export interface PeriodSelectorProps {
/** 当前选中的期数 */
selectedPeriods: number;
/** 期数变更回调 */
onPeriodsChange: (periods: number) => void;
/** 刷新回调 */
onRefresh: () => void;
/** 是否加载中 */
isLoading?: boolean;
/** 可选期数列表,默认 [4, 8, 12, 16] */
periodOptions?: number[];
/** 标签文本 */
label?: string;
}
const PeriodSelector: React.FC<PeriodSelectorProps> = memo(({
selectedPeriods,
onPeriodsChange,
onRefresh,
isLoading = false,
periodOptions = [4, 8, 12, 16],
label = '显示期数:',
}) => {
return (
<HStack spacing={2} align="center" flexWrap="wrap">
<Text fontSize="sm" color="gray.400" whiteSpace="nowrap">
{label}
</Text>
<Select
value={selectedPeriods}
onChange={(value) => onPeriodsChange(value)}
style={{
minWidth: 110,
background: 'transparent',
}}
size="small"
popupClassName="period-selector-dropdown"
options={periodOptions.map((period) => ({
value: period,
label: `最近${period}`,
}))}
dropdownStyle={{
background: '#1A202C',
borderColor: 'rgba(212, 175, 55, 0.3)',
}}
/>
<IconButton
icon={<RefreshCw size={14} className={isLoading ? 'spin' : ''} />}
onClick={onRefresh}
isLoading={isLoading}
variant="outline"
size="sm"
aria-label="刷新数据"
borderColor="rgba(212, 175, 55, 0.3)"
color="#D4AF37"
_hover={{
bg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.5)',
}}
/>
<style>{`
.period-selector-dropdown .ant-select-item {
color: #E2E8F0;
}
.period-selector-dropdown .ant-select-item-option-selected {
background: rgba(212, 175, 55, 0.2) !important;
color: #D4AF37;
}
.period-selector-dropdown .ant-select-item-option-active {
background: rgba(212, 175, 55, 0.1) !important;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</HStack>
);
});
PeriodSelector.displayName = 'PeriodSelector';
export { PeriodSelector };
export default PeriodSelector;

View File

@@ -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<StockComparisonProps> = ({
currentStock,
stockInfo,
positiveColor,
negativeColor,
}) => {
const [compareStock, setCompareStock] = useState('');
const [compareData, setCompareData] = useState<CompareData | null>(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 (
<VStack spacing={4} align="stretch">
<Card>
<CardBody>
<HStack>
<Input
placeholder="输入对比股票代码"
value={compareStock}
onChange={(e) => setCompareStock(e.target.value)}
maxLength={6}
/>
<Button
colorScheme="blue"
onClick={loadCompareData}
isLoading={compareLoading}
>
</Button>
</HStack>
</CardBody>
</Card>
{compareData && (
<Card>
<CardHeader>
<Heading size="md">
{stockInfo?.stock_name} ({currentStock}) VS{' '}
{compareData.stockInfo?.stock_name} ({compareStock})
</Heading>
</CardHeader>
<CardBody>
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th></Th>
<Th isNumeric>{stockInfo?.stock_name}</Th>
<Th isNumeric>{compareData.stockInfo?.stock_name}</Th>
<Th isNumeric></Th>
</Tr>
</Thead>
<Tbody>
{COMPARE_METRICS.map((metric) => {
const value1 = getValueByPath<number>(stockInfo, metric.path);
const value2 = getValueByPath<number>(
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 (
<Tr key={metric.key}>
<Td>{metric.label}</Td>
<Td isNumeric>
{metric.format === 'percent'
? formatUtils.formatPercent(value1)
: formatUtils.formatLargeNumber(value1)}
</Td>
<Td isNumeric>
{metric.format === 'percent'
? formatUtils.formatPercent(value2)
: formatUtils.formatLargeNumber(value2)}
</Td>
<Td isNumeric color={diffColor}>
{diff !== null ? (
<HStack spacing={1} justify="flex-end">
{diff > 0 && <ArrowUpIcon boxSize={3} />}
{diff < 0 && <ArrowDownIcon boxSize={3} />}
<Text>
{metric.format === 'percent'
? `${Math.abs(diff).toFixed(2)}pp`
: `${Math.abs(diff).toFixed(2)}%`}
</Text>
</HStack>
) : (
'-'
)}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</TableContainer>
{/* 对比图表 */}
<Grid templateColumns="repeat(2, 1fr)" gap={4} mt={6}>
<GridItem>
<Card>
<CardHeader>
<Heading size="sm"></Heading>
</CardHeader>
<CardBody>
<ReactECharts
option={getCompareBarChartOption(
'盈利能力对比',
stockInfo?.stock_name || '',
compareData.stockInfo?.stock_name || '',
['ROE', 'ROA', '毛利率', '净利率'],
[
stockInfo?.key_metrics?.roe,
stockInfo?.key_metrics?.roa,
stockInfo?.key_metrics?.gross_margin,
stockInfo?.key_metrics?.net_margin,
],
[
compareData.stockInfo?.key_metrics?.roe,
compareData.stockInfo?.key_metrics?.roa,
compareData.stockInfo?.key_metrics?.gross_margin,
compareData.stockInfo?.key_metrics?.net_margin,
]
)}
style={{ height: '300px' }}
/>
</CardBody>
</Card>
</GridItem>
<GridItem>
<Card>
<CardHeader>
<Heading size="sm"></Heading>
</CardHeader>
<CardBody>
<ReactECharts
option={getCompareBarChartOption(
'成长能力对比',
stockInfo?.stock_name || '',
compareData.stockInfo?.stock_name || '',
['营收增长', '利润增长', '资产增长', '股东权益增长'],
[
stockInfo?.growth_rates?.revenue_growth,
stockInfo?.growth_rates?.profit_growth,
stockInfo?.growth_rates?.asset_growth,
stockInfo?.growth_rates?.equity_growth,
],
[
compareData.stockInfo?.growth_rates?.revenue_growth,
compareData.stockInfo?.growth_rates?.profit_growth,
compareData.stockInfo?.growth_rates?.asset_growth,
compareData.stockInfo?.growth_rates?.equity_growth,
]
)}
style={{ height: '300px' }}
/>
</CardBody>
</Card>
</GridItem>
</Grid>
</CardBody>
</Card>
)}
</VStack>
);
};
export default StockComparison;

View File

@@ -0,0 +1,160 @@
/**
* 股票信息头部组件 - 黑金主题
*/
import React from 'react';
import {
Box,
Grid,
GridItem,
VStack,
HStack,
Text,
Heading,
Badge,
Stat,
StatLabel,
StatNumber,
Alert,
AlertIcon,
} from '@chakra-ui/react';
import { formatUtils } from '@services/financialService';
import type { StockInfoHeaderProps } from '../types';
// 黑金主题配置
const darkGoldTheme = {
bgCard: 'rgba(26, 32, 44, 0.95)',
border: 'rgba(212, 175, 55, 0.3)',
borderHover: 'rgba(212, 175, 55, 0.5)',
gold: '#D4AF37',
goldLight: '#F4D03F',
orange: '#FF9500',
red: '#FF4444',
green: '#00C851',
textPrimary: 'rgba(255, 255, 255, 0.92)',
textSecondary: 'rgba(255, 255, 255, 0.7)',
textMuted: 'rgba(255, 255, 255, 0.5)',
tagBg: 'rgba(212, 175, 55, 0.15)',
};
export const StockInfoHeader: React.FC<StockInfoHeaderProps> = ({
stockInfo,
}) => {
if (!stockInfo) return null;
return (
<Box
mb={4}
bg={darkGoldTheme.bgCard}
border="1px solid"
borderColor={darkGoldTheme.border}
borderRadius="xl"
p={5}
boxShadow="0 4px 20px rgba(0, 0, 0, 0.3)"
transition="all 0.3s ease"
_hover={{
borderColor: darkGoldTheme.borderHover,
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
}}
>
<Grid templateColumns="repeat(5, 1fr)" gap={4} alignItems="center">
<GridItem colSpan={{ base: 5, md: 2 }}>
<VStack align="start" spacing={1}>
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
</Text>
<HStack>
<Heading
size="md"
bgGradient={`linear(to-r, ${darkGoldTheme.gold}, ${darkGoldTheme.orange})`}
bgClip="text"
>
{stockInfo.stock_name}
</Heading>
<Badge
bg={darkGoldTheme.tagBg}
color={darkGoldTheme.gold}
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
{stockInfo.stock_code}
</Badge>
</HStack>
</VStack>
</GridItem>
<GridItem>
<Stat>
<StatLabel color={darkGoldTheme.textMuted} fontSize="xs">
ROE
</StatLabel>
<StatNumber color={darkGoldTheme.goldLight} fontSize="lg">
{formatUtils.formatPercent(stockInfo.key_metrics?.roe)}
</StatNumber>
</Stat>
</GridItem>
<GridItem>
<Stat>
<StatLabel color={darkGoldTheme.textMuted} fontSize="xs">
</StatLabel>
<StatNumber
fontSize="lg"
color={
stockInfo.growth_rates?.revenue_growth
? stockInfo.growth_rates.revenue_growth > 0
? darkGoldTheme.red
: darkGoldTheme.green
: darkGoldTheme.textMuted
}
>
{formatUtils.formatPercent(stockInfo.growth_rates?.revenue_growth)}
</StatNumber>
</Stat>
</GridItem>
<GridItem>
<Stat>
<StatLabel color={darkGoldTheme.textMuted} fontSize="xs">
</StatLabel>
<StatNumber
fontSize="lg"
color={
stockInfo.growth_rates?.profit_growth
? stockInfo.growth_rates.profit_growth > 0
? darkGoldTheme.red
: darkGoldTheme.green
: darkGoldTheme.textMuted
}
>
{formatUtils.formatPercent(stockInfo.growth_rates?.profit_growth)}
</StatNumber>
</Stat>
</GridItem>
</Grid>
{stockInfo.latest_forecast && (
<Alert
status="info"
mt={4}
bg="rgba(212, 175, 55, 0.1)"
borderRadius="lg"
border="1px solid"
borderColor={darkGoldTheme.border}
>
<AlertIcon color={darkGoldTheme.gold} />
<Box>
<Text fontWeight="bold" color={darkGoldTheme.gold}>
{stockInfo.latest_forecast.forecast_type}
</Text>
<Text fontSize="sm" color={darkGoldTheme.textSecondary}>
{stockInfo.latest_forecast.content}
</Text>
</Box>
</Alert>
)}
</Box>
);
};
export default StockInfoHeader;

View File

@@ -0,0 +1,17 @@
/**
* 组件统一导出
*/
export { PeriodSelector } from './PeriodSelector';
export { FinancialOverviewPanel } from './FinancialOverviewPanel';
// 保留旧组件导出(向后兼容)
export { KeyMetricsOverview } from './KeyMetricsOverview';
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';

View File

@@ -0,0 +1,341 @@
/**
* FinancialPanorama 常量配置
*/
import type { MetricSectionConfig, MetricsCategoryMap } from './types';
// ==================== 颜色配置 ====================
/** 中国市场颜色:红涨绿跌 */
export const COLORS = {
positiveColor: 'red.500', // 涨
negativeColor: 'green.500', // 跌
bgColor: 'white',
borderColor: 'gray.200',
hoverBg: 'gray.50',
} as const;
// ==================== 资产负债表指标定义 ====================
/** 流动资产指标 */
export const CURRENT_ASSETS_METRICS: MetricSectionConfig = {
title: '流动资产',
key: 'currentAssets',
metrics: [
{ name: '货币资金', key: 'cash', path: 'assets.current_assets.cash', isCore: true },
{ name: '交易性金融资产', key: 'trading_financial_assets', path: 'assets.current_assets.trading_financial_assets' },
{ name: '应收票据', key: 'notes_receivable', path: 'assets.current_assets.notes_receivable' },
{ name: '应收账款', key: 'accounts_receivable', path: 'assets.current_assets.accounts_receivable', isCore: true },
{ name: '预付款项', key: 'prepayments', path: 'assets.current_assets.prepayments' },
{ name: '其他应收款', key: 'other_receivables', path: 'assets.current_assets.other_receivables' },
{ name: '存货', key: 'inventory', path: 'assets.current_assets.inventory', isCore: true },
{ name: '合同资产', key: 'contract_assets', path: 'assets.current_assets.contract_assets' },
{ name: '其他流动资产', key: 'other_current_assets', path: 'assets.current_assets.other_current_assets' },
{ name: '流动资产合计', key: 'total_current_assets', path: 'assets.current_assets.total', isTotal: true },
],
};
/** 非流动资产指标 */
export const NON_CURRENT_ASSETS_METRICS: MetricSectionConfig = {
title: '非流动资产',
key: 'nonCurrentAssets',
metrics: [
{ name: '长期股权投资', key: 'long_term_equity_investments', path: 'assets.non_current_assets.long_term_equity_investments' },
{ name: '投资性房地产', key: 'investment_property', path: 'assets.non_current_assets.investment_property' },
{ name: '固定资产', key: 'fixed_assets', path: 'assets.non_current_assets.fixed_assets', isCore: true },
{ name: '在建工程', key: 'construction_in_progress', path: 'assets.non_current_assets.construction_in_progress' },
{ name: '使用权资产', key: 'right_of_use_assets', path: 'assets.non_current_assets.right_of_use_assets' },
{ name: '无形资产', key: 'intangible_assets', path: 'assets.non_current_assets.intangible_assets', isCore: true },
{ name: '商誉', key: 'goodwill', path: 'assets.non_current_assets.goodwill', isCore: true },
{ name: '递延所得税资产', key: 'deferred_tax_assets', path: 'assets.non_current_assets.deferred_tax_assets' },
{ name: '其他非流动资产', key: 'other_non_current_assets', path: 'assets.non_current_assets.other_non_current_assets' },
{ name: '非流动资产合计', key: 'total_non_current_assets', path: 'assets.non_current_assets.total', isTotal: true },
],
};
/** 资产总计指标 */
export const TOTAL_ASSETS_METRICS: MetricSectionConfig = {
title: '资产总计',
key: 'totalAssets',
metrics: [
{ name: '资产总计', key: 'total_assets', path: 'assets.total', isTotal: true, isCore: true },
],
};
/** 流动负债指标 */
export const CURRENT_LIABILITIES_METRICS: MetricSectionConfig = {
title: '流动负债',
key: 'currentLiabilities',
metrics: [
{ name: '短期借款', key: 'short_term_borrowings', path: 'liabilities.current_liabilities.short_term_borrowings', isCore: true },
{ name: '应付票据', key: 'notes_payable', path: 'liabilities.current_liabilities.notes_payable' },
{ name: '应付账款', key: 'accounts_payable', path: 'liabilities.current_liabilities.accounts_payable', isCore: true },
{ name: '预收款项', key: 'advance_receipts', path: 'liabilities.current_liabilities.advance_receipts' },
{ name: '合同负债', key: 'contract_liabilities', path: 'liabilities.current_liabilities.contract_liabilities' },
{ name: '应付职工薪酬', key: 'employee_compensation_payable', path: 'liabilities.current_liabilities.employee_compensation_payable' },
{ name: '应交税费', key: 'taxes_payable', path: 'liabilities.current_liabilities.taxes_payable' },
{ name: '其他应付款', key: 'other_payables', path: 'liabilities.current_liabilities.other_payables' },
{ name: '一年内到期的非流动负债', key: 'non_current_due_within_one_year', path: 'liabilities.current_liabilities.non_current_liabilities_due_within_one_year' },
{ name: '流动负债合计', key: 'total_current_liabilities', path: 'liabilities.current_liabilities.total', isTotal: true },
],
};
/** 非流动负债指标 */
export const NON_CURRENT_LIABILITIES_METRICS: MetricSectionConfig = {
title: '非流动负债',
key: 'nonCurrentLiabilities',
metrics: [
{ name: '长期借款', key: 'long_term_borrowings', path: 'liabilities.non_current_liabilities.long_term_borrowings', isCore: true },
{ name: '应付债券', key: 'bonds_payable', path: 'liabilities.non_current_liabilities.bonds_payable' },
{ name: '租赁负债', key: 'lease_liabilities', path: 'liabilities.non_current_liabilities.lease_liabilities' },
{ name: '递延所得税负债', key: 'deferred_tax_liabilities', path: 'liabilities.non_current_liabilities.deferred_tax_liabilities' },
{ name: '其他非流动负债', key: 'other_non_current_liabilities', path: 'liabilities.non_current_liabilities.other_non_current_liabilities' },
{ name: '非流动负债合计', key: 'total_non_current_liabilities', path: 'liabilities.non_current_liabilities.total', isTotal: true },
],
};
/** 负债合计指标 */
export const TOTAL_LIABILITIES_METRICS: MetricSectionConfig = {
title: '负债合计',
key: 'totalLiabilities',
metrics: [
{ name: '负债合计', key: 'total_liabilities', path: 'liabilities.total', isTotal: true, isCore: true },
],
};
/** 股东权益指标 */
export const EQUITY_METRICS: MetricSectionConfig = {
title: '股东权益',
key: 'equity',
metrics: [
{ name: '股本', key: 'share_capital', path: 'equity.share_capital', isCore: true },
{ name: '资本公积', key: 'capital_reserve', path: 'equity.capital_reserve' },
{ name: '盈余公积', key: 'surplus_reserve', path: 'equity.surplus_reserve' },
{ name: '未分配利润', key: 'undistributed_profit', path: 'equity.undistributed_profit', isCore: true },
{ name: '库存股', key: 'treasury_stock', path: 'equity.treasury_stock' },
{ name: '其他综合收益', key: 'other_comprehensive_income', path: 'equity.other_comprehensive_income' },
{ name: '归属母公司股东权益', key: 'parent_company_equity', path: 'equity.parent_company_equity', isCore: true },
{ name: '少数股东权益', key: 'minority_interests', path: 'equity.minority_interests' },
{ name: '股东权益合计', key: 'total_equity', path: 'equity.total', isTotal: true, isCore: true },
],
};
/** 资产负债表所有分类 */
export const BALANCE_SHEET_SECTIONS = {
assets: [CURRENT_ASSETS_METRICS, NON_CURRENT_ASSETS_METRICS, TOTAL_ASSETS_METRICS],
liabilities: [CURRENT_LIABILITIES_METRICS, NON_CURRENT_LIABILITIES_METRICS, TOTAL_LIABILITIES_METRICS],
equity: [EQUITY_METRICS],
};
// ==================== 利润表指标定义 ====================
export const INCOME_STATEMENT_SECTIONS: MetricSectionConfig[] = [
{
title: '营业收入',
key: 'revenue',
metrics: [
{ name: '营业总收入', key: 'total_revenue', path: 'revenue.total_operating_revenue', isCore: true },
{ name: '营业收入', key: 'revenue', path: 'revenue.operating_revenue', isCore: true },
{ name: '其他业务收入', key: 'other_income', path: 'revenue.other_income' },
],
},
{
title: '营业成本与费用',
key: 'costs',
metrics: [
{ name: '营业总成本', key: 'total_cost', path: 'costs.total_operating_cost', isTotal: true },
{ name: '营业成本', key: 'cost', path: 'costs.operating_cost', isCore: true },
{ name: '税金及附加', key: 'taxes_and_surcharges', path: 'costs.taxes_and_surcharges' },
{ name: '销售费用', key: 'selling_expenses', path: 'costs.selling_expenses', isCore: true },
{ name: '管理费用', key: 'admin_expenses', path: 'costs.admin_expenses', isCore: true },
{ name: '研发费用', key: 'rd_expenses', path: 'costs.rd_expenses', isCore: true },
{ name: '财务费用', key: 'financial_expenses', path: 'costs.financial_expenses' },
{ name: ' 其中:利息费用', key: 'interest_expense', path: 'costs.interest_expense' },
{ name: '    利息收入', key: 'interest_income', path: 'costs.interest_income' },
{ name: '三费合计', key: 'three_expenses', path: 'costs.three_expenses_total', isSubtotal: true },
{ name: '四费合计(含研发)', key: 'four_expenses', path: 'costs.four_expenses_total', isSubtotal: true },
{ name: '资产减值损失', key: 'asset_impairment', path: 'costs.asset_impairment_loss' },
{ name: '信用减值损失', key: 'credit_impairment', path: 'costs.credit_impairment_loss' },
],
},
{
title: '其他收益',
key: 'otherGains',
metrics: [
{ name: '公允价值变动收益', key: 'fair_value_change', path: 'other_gains.fair_value_change' },
{ name: '投资收益', key: 'investment_income', path: 'other_gains.investment_income', isCore: true },
{ name: ' 其中:对联营企业和合营企业的投资收益', key: 'investment_income_associates', path: 'other_gains.investment_income_from_associates' },
{ name: '汇兑收益', key: 'exchange_income', path: 'other_gains.exchange_income' },
{ name: '资产处置收益', key: 'asset_disposal_income', path: 'other_gains.asset_disposal_income' },
],
},
{
title: '利润',
key: 'profits',
metrics: [
{ name: '营业利润', key: 'operating_profit', path: 'profit.operating_profit', isCore: true, isTotal: true },
{ name: '加:营业外收入', key: 'non_operating_income', path: 'non_operating.non_operating_income' },
{ name: '减:营业外支出', key: 'non_operating_expenses', path: 'non_operating.non_operating_expenses' },
{ name: '利润总额', key: 'total_profit', path: 'profit.total_profit', isCore: true, isTotal: true },
{ name: '减:所得税费用', key: 'income_tax', path: 'profit.income_tax_expense' },
{ name: '净利润', key: 'net_profit', path: 'profit.net_profit', isCore: true, isTotal: true },
{ name: ' 归属母公司所有者的净利润', key: 'parent_net_profit', path: 'profit.parent_net_profit', isCore: true },
{ name: ' 少数股东损益', key: 'minority_profit', path: 'profit.minority_profit' },
{ name: '持续经营净利润', key: 'continuing_net_profit', path: 'profit.continuing_operations_net_profit' },
{ name: '终止经营净利润', key: 'discontinued_net_profit', path: 'profit.discontinued_operations_net_profit' },
],
},
{
title: '每股收益',
key: 'eps',
metrics: [
{ name: '基本每股收益(元)', key: 'basic_eps', path: 'per_share.basic_eps', isCore: true },
{ name: '稀释每股收益(元)', key: 'diluted_eps', path: 'per_share.diluted_eps' },
],
},
{
title: '综合收益',
key: 'comprehensive',
metrics: [
{ name: '其他综合收益(税后)', key: 'other_comprehensive_income', path: 'comprehensive_income.other_comprehensive_income' },
{ name: '综合收益总额', key: 'total_comprehensive_income', path: 'comprehensive_income.total_comprehensive_income', isTotal: true },
{ name: ' 归属母公司', key: 'parent_comprehensive_income', path: 'comprehensive_income.parent_comprehensive_income' },
{ name: ' 归属少数股东', key: 'minority_comprehensive_income', path: 'comprehensive_income.minority_comprehensive_income' },
],
},
];
// ==================== 现金流量表指标定义 ====================
export const CASHFLOW_METRICS = [
{ name: '经营现金流净额', key: 'operating_net', path: 'operating_activities.net_flow' },
{ name: '销售收现', key: 'cash_from_sales', path: 'operating_activities.inflow.cash_from_sales' },
{ name: '购买支付现金', key: 'cash_for_goods', path: 'operating_activities.outflow.cash_for_goods' },
{ name: '投资现金流净额', key: 'investment_net', path: 'investment_activities.net_flow' },
{ name: '筹资现金流净额', key: 'financing_net', path: 'financing_activities.net_flow' },
{ name: '现金净增加额', key: 'net_increase', path: 'cash_changes.net_increase' },
{ name: '期末现金余额', key: 'ending_balance', path: 'cash_changes.ending_balance' },
{ name: '自由现金流', key: 'free_cash_flow', path: 'key_metrics.free_cash_flow' },
];
// ==================== 财务指标分类定义 ====================
export const FINANCIAL_METRICS_CATEGORIES: MetricsCategoryMap = {
profitability: {
title: '盈利能力指标',
metrics: [
{ name: '净资产收益率(ROE)%', key: 'roe', path: 'profitability.roe', isCore: true },
{ name: '净资产收益率(扣非)%', key: 'roe_deducted', path: 'profitability.roe_deducted' },
{ name: '净资产收益率(加权)%', key: 'roe_weighted', path: 'profitability.roe_weighted', isCore: true },
{ name: '总资产报酬率(ROA)%', key: 'roa', path: 'profitability.roa', isCore: true },
{ name: '毛利率%', key: 'gross_margin', path: 'profitability.gross_margin', isCore: true },
{ name: '净利率%', key: 'net_margin', path: 'profitability.net_profit_margin', isCore: true },
{ name: '营业利润率%', key: 'operating_margin', path: 'profitability.operating_profit_margin' },
{ name: '成本费用利润率%', key: 'cost_profit_ratio', path: 'profitability.cost_profit_ratio' },
{ name: 'EBIT', key: 'ebit', path: 'profitability.ebit' },
],
},
perShare: {
title: '每股指标',
metrics: [
{ name: '每股收益(EPS)', key: 'eps', path: 'per_share_metrics.eps', isCore: true },
{ name: '基本每股收益', key: 'basic_eps', path: 'per_share_metrics.basic_eps', isCore: true },
{ name: '稀释每股收益', key: 'diluted_eps', path: 'per_share_metrics.diluted_eps' },
{ name: '扣非每股收益', key: 'deducted_eps', path: 'per_share_metrics.deducted_eps', isCore: true },
{ name: '每股净资产', key: 'bvps', path: 'per_share_metrics.bvps', isCore: true },
{ name: '每股经营现金流', key: 'operating_cash_flow_ps', path: 'per_share_metrics.operating_cash_flow_ps' },
{ name: '每股资本公积', key: 'capital_reserve_ps', path: 'per_share_metrics.capital_reserve_ps' },
{ name: '每股未分配利润', key: 'undistributed_profit_ps', path: 'per_share_metrics.undistributed_profit_ps' },
],
},
growth: {
title: '成长能力指标',
metrics: [
{ name: '营收增长率%', key: 'revenue_growth', path: 'growth.revenue_growth', isCore: true },
{ name: '净利润增长率%', key: 'profit_growth', path: 'growth.net_profit_growth', isCore: true },
{ name: '扣非净利润增长率%', key: 'deducted_profit_growth', path: 'growth.deducted_profit_growth', isCore: true },
{ name: '归母净利润增长率%', key: 'parent_profit_growth', path: 'growth.parent_profit_growth' },
{ name: '经营现金流增长率%', key: 'operating_cash_flow_growth', path: 'growth.operating_cash_flow_growth' },
{ name: '总资产增长率%', key: 'asset_growth', path: 'growth.total_asset_growth' },
{ name: '净资产增长率%', key: 'equity_growth', path: 'growth.equity_growth' },
{ name: '固定资产增长率%', key: 'fixed_asset_growth', path: 'growth.fixed_asset_growth' },
],
},
operational: {
title: '运营效率指标',
metrics: [
{ name: '总资产周转率', key: 'asset_turnover', path: 'operational_efficiency.total_asset_turnover', isCore: true },
{ name: '固定资产周转率', key: 'fixed_asset_turnover', path: 'operational_efficiency.fixed_asset_turnover' },
{ name: '流动资产周转率', key: 'current_asset_turnover', path: 'operational_efficiency.current_asset_turnover' },
{ name: '应收账款周转率', key: 'receivable_turnover', path: 'operational_efficiency.receivable_turnover', isCore: true },
{ name: '应收账款周转天数', key: 'receivable_days', path: 'operational_efficiency.receivable_days', isCore: true },
{ name: '存货周转率', key: 'inventory_turnover', path: 'operational_efficiency.inventory_turnover', isCore: true },
{ name: '存货周转天数', key: 'inventory_days', path: 'operational_efficiency.inventory_days' },
{ name: '营运资金周转率', key: 'working_capital_turnover', path: 'operational_efficiency.working_capital_turnover' },
],
},
solvency: {
title: '偿债能力指标',
metrics: [
{ name: '流动比率', key: 'current_ratio', path: 'solvency.current_ratio', isCore: true },
{ name: '速动比率', key: 'quick_ratio', path: 'solvency.quick_ratio', isCore: true },
{ name: '现金比率', key: 'cash_ratio', path: 'solvency.cash_ratio' },
{ name: '保守速动比率', key: 'conservative_quick_ratio', path: 'solvency.conservative_quick_ratio' },
{ name: '资产负债率%', key: 'debt_ratio', path: 'solvency.asset_liability_ratio', isCore: true },
{ name: '利息保障倍数', key: 'interest_coverage', path: 'solvency.interest_coverage' },
{ name: '现金到期债务比', key: 'cash_to_maturity_debt', path: 'solvency.cash_to_maturity_debt_ratio' },
{ name: '有形资产净值债务率%', key: 'tangible_asset_debt_ratio', path: 'solvency.tangible_asset_debt_ratio' },
],
},
expense: {
title: '费用率指标',
metrics: [
{ name: '销售费用率%', key: 'selling_expense_ratio', path: 'expense_ratios.selling_expense_ratio', isCore: true },
{ name: '管理费用率%', key: 'admin_expense_ratio', path: 'expense_ratios.admin_expense_ratio', isCore: true },
{ name: '财务费用率%', key: 'financial_expense_ratio', path: 'expense_ratios.financial_expense_ratio' },
{ name: '研发费用率%', key: 'rd_expense_ratio', path: 'expense_ratios.rd_expense_ratio', isCore: true },
{ name: '三费费用率%', key: 'three_expense_ratio', path: 'expense_ratios.three_expense_ratio' },
{ name: '四费费用率%', key: 'four_expense_ratio', path: 'expense_ratios.four_expense_ratio' },
{ name: '营业成本率%', key: 'cost_ratio', path: 'expense_ratios.cost_ratio' },
],
},
cashflow: {
title: '现金流量指标',
metrics: [
{ name: '经营现金流/净利润', key: 'cash_to_profit', path: 'cash_flow_quality.operating_cash_to_profit_ratio', isCore: true },
{ name: '净利含金量', key: 'profit_cash_content', path: 'cash_flow_quality.cash_to_profit_ratio', isCore: true },
{ name: '营收现金含量', key: 'revenue_cash_content', path: 'cash_flow_quality.cash_revenue_ratio' },
{ name: '全部资产现金回收率%', key: 'cash_recovery_rate', path: 'cash_flow_quality.cash_recovery_rate' },
{ name: '经营现金流/短期债务', key: 'cash_to_short_debt', path: 'cash_flow_quality.operating_cash_to_short_debt' },
{ name: '经营现金流/总债务', key: 'cash_to_total_debt', path: 'cash_flow_quality.operating_cash_to_total_debt' },
],
},
};
// ==================== 行业排名指标 ====================
export const RANKING_METRICS = [
{ name: 'EPS', key: 'eps' },
{ name: '每股净资产', key: 'bvps' },
{ name: 'ROE', key: 'roe' },
{ name: '营收增长率', key: 'revenue_growth' },
{ name: '利润增长率', key: 'profit_growth' },
{ name: '营业利润率', key: 'operating_margin' },
{ name: '资产负债率', key: 'debt_ratio' },
{ name: '应收账款周转率', key: 'receivable_turnover' },
];
// ==================== 对比指标 ====================
export const COMPARE_METRICS = [
{ label: '营业收入', key: 'revenue', path: 'financial_summary.revenue' },
{ label: '净利润', key: 'net_profit', path: 'financial_summary.net_profit' },
{ label: 'ROE', key: 'roe', path: 'key_metrics.roe', format: 'percent' },
{ label: 'ROA', key: 'roa', path: 'key_metrics.roa', format: 'percent' },
{ label: '毛利率', key: 'gross_margin', path: 'key_metrics.gross_margin', format: 'percent' },
{ label: '净利率', key: 'net_margin', path: 'key_metrics.net_margin', format: 'percent' },
{ label: '营收增长率', key: 'revenue_growth', path: 'growth_rates.revenue_growth', format: 'percent' },
{ label: '利润增长率', key: 'profit_growth', path: 'growth_rates.profit_growth', format: 'percent' },
{ label: '资产总额', key: 'total_assets', path: 'financial_summary.total_assets' },
{ label: '负债总额', key: 'total_liabilities', path: 'financial_summary.total_liabilities' },
];

View File

@@ -0,0 +1,7 @@
/**
* Hooks 统一导出
*/
export { useFinancialData } from './useFinancialData';
export type { DataTypeKey } from './useFinancialData';
export type { default as UseFinancialDataReturn } from './useFinancialData';

View File

@@ -0,0 +1,297 @@
/**
* 财务数据加载 Hook
* 封装所有财务数据的加载逻辑,支持按 Tab 独立刷新
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useToast } from '@chakra-ui/react';
import { logger } from '@utils/logger';
import { financialService } from '@services/financialService';
import type {
StockInfo,
BalanceSheetData,
IncomeStatementData,
CashflowData,
FinancialMetricsData,
MainBusinessData,
ForecastData,
IndustryRankData,
ComparisonData,
} from '../types';
// Tab key 到数据类型的映射
export type DataTypeKey =
| 'balance'
| 'income'
| 'cashflow'
| 'profitability'
| 'perShare'
| 'growth'
| 'operational'
| 'solvency'
| 'expense'
| 'cashflowMetrics';
interface UseFinancialDataOptions {
stockCode?: string;
periods?: number;
}
interface UseFinancialDataReturn {
// 数据状态
stockInfo: StockInfo | null;
balanceSheet: BalanceSheetData[];
incomeStatement: IncomeStatementData[];
cashflow: CashflowData[];
financialMetrics: FinancialMetricsData[];
mainBusiness: MainBusinessData | null;
forecast: ForecastData | null;
industryRank: IndustryRankData[];
comparison: ComparisonData[];
// 加载状态
loading: boolean;
loadingTab: DataTypeKey | null; // 当前正在加载的 Tab
error: string | null;
// 操作方法
refetch: () => Promise<void>;
refetchByTab: (tabKey: DataTypeKey) => Promise<void>;
setStockCode: (code: string) => void;
setSelectedPeriods: (periods: number) => void;
setActiveTab: (tabKey: DataTypeKey) => void;
// 当前参数
currentStockCode: string;
selectedPeriods: number;
activeTab: DataTypeKey;
}
/**
* 财务数据加载 Hook
* @param options - 配置选项
* @returns 财务数据和操作方法
*/
export const useFinancialData = (
options: UseFinancialDataOptions = {}
): UseFinancialDataReturn => {
const { stockCode: initialStockCode = '600000', periods: initialPeriods = 8 } = options;
// 参数状态
const [stockCode, setStockCode] = useState(initialStockCode);
const [selectedPeriods, setSelectedPeriodsState] = useState(initialPeriods);
const [activeTab, setActiveTab] = useState<DataTypeKey>('profitability');
// 加载状态
const [loading, setLoading] = useState(false);
const [loadingTab, setLoadingTab] = useState<DataTypeKey | null>(null);
const [error, setError] = useState<string | null>(null);
// 财务数据状态
const [stockInfo, setStockInfo] = useState<StockInfo | null>(null);
const [balanceSheet, setBalanceSheet] = useState<BalanceSheetData[]>([]);
const [incomeStatement, setIncomeStatement] = useState<IncomeStatementData[]>([]);
const [cashflow, setCashflow] = useState<CashflowData[]>([]);
const [financialMetrics, setFinancialMetrics] = useState<FinancialMetricsData[]>([]);
const [mainBusiness, setMainBusiness] = useState<MainBusinessData | null>(null);
const [forecast, setForecast] = useState<ForecastData | null>(null);
const [industryRank, setIndustryRank] = useState<IndustryRankData[]>([]);
const [comparison, setComparison] = useState<ComparisonData[]>([]);
const toast = useToast();
const isInitialLoad = useRef(true);
const prevPeriods = useRef(selectedPeriods);
// 判断 Tab key 对应的数据类型
const getDataTypeForTab = (tabKey: DataTypeKey): 'balance' | 'income' | 'cashflow' | 'metrics' => {
switch (tabKey) {
case 'balance':
return 'balance';
case 'income':
return 'income';
case 'cashflow':
return 'cashflow';
default:
// 所有财务指标类 tab 都使用 metrics 数据
return 'metrics';
}
};
// 按数据类型加载数据
const loadDataByType = useCallback(async (
dataType: 'balance' | 'income' | 'cashflow' | 'metrics',
periods: number
) => {
try {
switch (dataType) {
case 'balance': {
const res = await financialService.getBalanceSheet(stockCode, periods);
if (res.success) setBalanceSheet(res.data);
break;
}
case 'income': {
const res = await financialService.getIncomeStatement(stockCode, periods);
if (res.success) setIncomeStatement(res.data);
break;
}
case 'cashflow': {
const res = await financialService.getCashflow(stockCode, periods);
if (res.success) setCashflow(res.data);
break;
}
case 'metrics': {
const res = await financialService.getFinancialMetrics(stockCode, periods);
if (res.success) setFinancialMetrics(res.data);
break;
}
}
} catch (err) {
logger.error('useFinancialData', 'loadDataByType', err, { dataType, periods });
throw err;
}
}, [stockCode]);
// 按 Tab 刷新数据
const refetchByTab = useCallback(async (tabKey: DataTypeKey) => {
if (!stockCode || stockCode.length !== 6) {
return;
}
const dataType = getDataTypeForTab(tabKey);
logger.debug('useFinancialData', '刷新单个 Tab 数据', { tabKey, dataType, selectedPeriods });
setLoadingTab(tabKey);
setError(null);
try {
await loadDataByType(dataType, selectedPeriods);
logger.info('useFinancialData', `${tabKey} 数据刷新成功`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '未知错误';
setError(errorMessage);
} finally {
setLoadingTab(null);
}
}, [stockCode, selectedPeriods, loadDataByType]);
// 设置期数(只刷新当前 Tab
const setSelectedPeriods = useCallback((periods: number) => {
setSelectedPeriodsState(periods);
}, []);
// 加载所有财务数据(初始加载)
const loadAllFinancialData = useCallback(async () => {
if (!stockCode || stockCode.length !== 6) {
logger.warn('useFinancialData', '无效的股票代码', { stockCode });
toast({
title: '请输入有效的6位股票代码',
status: 'warning',
duration: 3000,
});
return;
}
logger.debug('useFinancialData', '开始加载全部财务数据', { stockCode, selectedPeriods });
setLoading(true);
setError(null);
try {
// 并行加载所有数据
const [
stockInfoRes,
balanceRes,
incomeRes,
cashflowRes,
metricsRes,
businessRes,
forecastRes,
rankRes,
comparisonRes,
] = await Promise.all([
financialService.getStockInfo(stockCode),
financialService.getBalanceSheet(stockCode, selectedPeriods),
financialService.getIncomeStatement(stockCode, selectedPeriods),
financialService.getCashflow(stockCode, selectedPeriods),
financialService.getFinancialMetrics(stockCode, selectedPeriods),
financialService.getMainBusiness(stockCode, 4),
financialService.getForecast(stockCode),
financialService.getIndustryRank(stockCode, 4),
financialService.getPeriodComparison(stockCode, selectedPeriods),
]);
// 设置数据
if (stockInfoRes.success) setStockInfo(stockInfoRes.data);
if (balanceRes.success) setBalanceSheet(balanceRes.data);
if (incomeRes.success) setIncomeStatement(incomeRes.data);
if (cashflowRes.success) setCashflow(cashflowRes.data);
if (metricsRes.success) setFinancialMetrics(metricsRes.data);
if (businessRes.success) setMainBusiness(businessRes.data);
if (forecastRes.success) setForecast(forecastRes.data);
if (rankRes.success) setIndustryRank(rankRes.data);
if (comparisonRes.success) setComparison(comparisonRes.data);
logger.info('useFinancialData', '全部财务数据加载成功', { stockCode });
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '未知错误';
setError(errorMessage);
logger.error('useFinancialData', 'loadAllFinancialData', err, { stockCode, selectedPeriods });
} finally {
setLoading(false);
}
}, [stockCode, selectedPeriods, toast]);
// 监听 props 中的 stockCode 变化
useEffect(() => {
if (initialStockCode && initialStockCode !== stockCode) {
setStockCode(initialStockCode);
}
}, [initialStockCode]);
// 初始加载(仅股票代码变化时全量加载)
useEffect(() => {
if (stockCode) {
loadAllFinancialData();
isInitialLoad.current = false;
}
}, [stockCode]); // 注意:这里只依赖 stockCode
// 期数变化时只刷新当前 Tab
useEffect(() => {
if (!isInitialLoad.current && prevPeriods.current !== selectedPeriods) {
prevPeriods.current = selectedPeriods;
refetchByTab(activeTab);
}
}, [selectedPeriods, activeTab, refetchByTab]);
return {
// 数据状态
stockInfo,
balanceSheet,
incomeStatement,
cashflow,
financialMetrics,
mainBusiness,
forecast,
industryRank,
comparison,
// 加载状态
loading,
loadingTab,
error,
// 操作方法
refetch: loadAllFinancialData,
refetchByTab,
setStockCode,
setSelectedPeriods,
setActiveTab,
// 当前参数
currentStockCode: stockCode,
selectedPeriods,
activeTab,
};
};
export default useFinancialData;

View File

@@ -0,0 +1,349 @@
/**
* 财务全景组件
* 重构后的主组件,使用模块化结构和 SubTabContainer 二级导航
*/
import React, { useState, useMemo, useCallback, ReactNode } from 'react';
import {
Box,
Container,
VStack,
Card,
CardBody,
Text,
Alert,
AlertIcon,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Divider,
} from '@chakra-ui/react';
import {
BarChart3,
DollarSign,
TrendingUp,
PieChart,
Percent,
TrendingDown,
Activity,
Shield,
Receipt,
Banknote,
} from 'lucide-react';
import ReactECharts from 'echarts-for-react';
import { formatUtils } from '@services/financialService';
// 通用组件
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
import LoadingState from '../LoadingState';
// 内部模块导入
import { useFinancialData, type DataTypeKey } from './hooks';
import { COLORS } from './constants';
import { calculateYoYChange, getCellBackground, getMetricChartOption } from './utils';
import { PeriodSelector, FinancialOverviewPanel, MainBusinessAnalysis, ComparisonAnalysis } from './components';
import {
BalanceSheetTab,
IncomeStatementTab,
CashflowTab,
ProfitabilityTab,
PerShareTab,
GrowthTab,
OperationalTab,
SolvencyTab,
ExpenseTab,
CashflowMetricsTab,
} from './tabs';
import type { FinancialPanoramaProps } from './types';
/**
* 财务全景主组件
*/
// Tab key 映射表SubTabContainer index -> DataTypeKey
const TAB_KEY_MAP: DataTypeKey[] = [
'profitability',
'perShare',
'growth',
'operational',
'solvency',
'expense',
'cashflowMetrics',
'balance',
'income',
'cashflow',
];
const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propStockCode }) => {
// 使用数据加载 Hook
const {
stockInfo,
balanceSheet,
incomeStatement,
cashflow,
financialMetrics,
mainBusiness,
comparison,
loading,
loadingTab,
error,
refetchByTab,
selectedPeriods,
setSelectedPeriods,
setActiveTab,
activeTab,
} = useFinancialData({ stockCode: propStockCode });
// 处理 Tab 切换
const handleTabChange = useCallback((index: number, tabKey: string) => {
const dataTypeKey = TAB_KEY_MAP[index] || (tabKey as DataTypeKey);
setActiveTab(dataTypeKey);
}, [setActiveTab]);
// 处理刷新 - 只刷新当前 Tab
const handleRefresh = useCallback(() => {
refetchByTab(activeTab);
}, [refetchByTab, activeTab]);
// UI 状态
const { isOpen, onOpen, onClose } = useDisclosure();
const [modalContent, setModalContent] = useState<ReactNode>(null);
// 颜色配置
const { bgColor, hoverBg, positiveColor, negativeColor } = 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();
};
// Tab 配置 - 财务指标分类 + 三大财务报表
const tabConfigs: SubTabConfig[] = useMemo(
() => [
// 财务指标分类7个
{ key: 'profitability', name: '盈利能力', icon: PieChart, component: ProfitabilityTab },
{ key: 'perShare', name: '每股指标', icon: Percent, component: PerShareTab },
{ key: 'growth', name: '成长能力', icon: TrendingUp, component: GrowthTab },
{ key: 'operational', name: '运营效率', icon: Activity, component: OperationalTab },
{ key: 'solvency', name: '偿债能力', icon: Shield, component: SolvencyTab },
{ key: 'expense', name: '费用率', icon: Receipt, component: ExpenseTab },
{ key: 'cashflowMetrics', name: '现金流指标', icon: Banknote, component: CashflowMetricsTab },
// 三大财务报表
{ key: 'balance', name: '资产负债表', icon: BarChart3, component: BalanceSheetTab },
{ key: 'income', name: '利润表', icon: DollarSign, component: IncomeStatementTab },
{ key: 'cashflow', name: '现金流量表', icon: TrendingDown, component: CashflowTab },
],
[]
);
// 传递给 Tab 组件的 props
const componentProps = useMemo(
() => ({
// 数据
balanceSheet,
incomeStatement,
cashflow,
financialMetrics,
// 工具函数
showMetricChart,
calculateYoYChange,
getCellBackground,
// 颜色配置
positiveColor,
negativeColor,
bgColor,
hoverBg,
}),
[
balanceSheet,
incomeStatement,
cashflow,
financialMetrics,
showMetricChart,
positiveColor,
negativeColor,
bgColor,
hoverBg,
]
);
return (
<Container maxW="container.xl" py={5}>
<VStack spacing={6} align="stretch">
{/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */}
{loading ? (
<LoadingState message="加载财务数据中..." height="300px" />
) : (
<FinancialOverviewPanel
stockInfo={stockInfo}
financialMetrics={financialMetrics}
/>
)}
{/* 营收与利润趋势 */}
{!loading && comparison && comparison.length > 0 && (
<ComparisonAnalysis comparison={comparison} />
)}
{/* 主营业务 */}
{!loading && stockInfo && (
<Box>
<Text fontSize="lg" fontWeight="bold" mb={4} color="#D4AF37">
</Text>
<MainBusinessAnalysis mainBusiness={mainBusiness} />
</Box>
)}
{/* 三大财务报表 - 使用 SubTabContainer 二级导航 */}
{!loading && stockInfo && (
<Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
<CardBody p={0}>
<SubTabContainer
tabs={tabConfigs}
componentProps={componentProps}
themePreset="blackGold"
isLazy
onTabChange={handleTabChange}
rightElement={
<PeriodSelector
selectedPeriods={selectedPeriods}
onPeriodsChange={setSelectedPeriods}
onRefresh={handleRefresh}
isLoading={loadingTab !== null}
/>
}
/>
</CardBody>
</Card>
)}
{/* 错误提示 */}
{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;

View File

@@ -0,0 +1,64 @@
/**
* 资产负债表 Tab
*/
import React from 'react';
import { Box, VStack, HStack, Heading, Badge, Text } from '@chakra-ui/react';
import { BalanceSheetTable } from '../components';
import type { BalanceSheetData } from '../types';
export interface BalanceSheetTabProps {
balanceSheet: BalanceSheetData[];
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
const BalanceSheetTab: React.FC<BalanceSheetTabProps> = ({
balanceSheet,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
}) => {
const tableProps = {
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
};
return (
<Box>
<VStack align="stretch" spacing={2} mb={4}>
<HStack justify="space-between">
<Heading size="md" color="#D4AF37"></Heading>
<HStack spacing={2}>
<Badge bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
{Math.min(balanceSheet.length, 8)}
</Badge>
<Text fontSize="sm" color="gray.400">
绿 |
</Text>
</HStack>
</HStack>
<Text fontSize="xs" color="gray.500">
</Text>
</VStack>
<BalanceSheetTable data={balanceSheet} {...tableProps} />
</Box>
);
};
export default BalanceSheetTab;

View File

@@ -0,0 +1,64 @@
/**
* 现金流量表 Tab
*/
import React from 'react';
import { Box, VStack, HStack, Heading, Badge, Text } from '@chakra-ui/react';
import { CashflowTable } from '../components';
import type { CashflowData } from '../types';
export interface CashflowTabProps {
cashflow: CashflowData[];
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
const CashflowTab: React.FC<CashflowTabProps> = ({
cashflow,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
}) => {
const tableProps = {
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
};
return (
<Box>
<VStack align="stretch" spacing={2} mb={4}>
<HStack justify="space-between">
<Heading size="md" color="#D4AF37"></Heading>
<HStack spacing={2}>
<Badge bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
{Math.min(cashflow.length, 8)}
</Badge>
<Text fontSize="sm" color="gray.400">
绿 |
</Text>
</HStack>
</HStack>
<Text fontSize="xs" color="gray.500">
绿
</Text>
</VStack>
<CashflowTable data={cashflow} {...tableProps} />
</Box>
);
};
export default CashflowTab;

View File

@@ -0,0 +1,45 @@
/**
* 财务指标 Tab
*/
import React from 'react';
import { FinancialMetricsTable } from '../components';
import type { FinancialMetricsData } from '../types';
export interface FinancialMetricsTabProps {
financialMetrics: FinancialMetricsData[];
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
const FinancialMetricsTab: React.FC<FinancialMetricsTabProps> = ({
financialMetrics,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
}) => {
const tableProps = {
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
};
return (
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
);
};
export default FinancialMetricsTab;

View File

@@ -0,0 +1,64 @@
/**
* 利润表 Tab
*/
import React from 'react';
import { Box, VStack, HStack, Heading, Badge, Text } from '@chakra-ui/react';
import { IncomeStatementTable } from '../components';
import type { IncomeStatementData } from '../types';
export interface IncomeStatementTabProps {
incomeStatement: IncomeStatementData[];
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
const IncomeStatementTab: React.FC<IncomeStatementTabProps> = ({
incomeStatement,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
}) => {
const tableProps = {
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
};
return (
<Box>
<VStack align="stretch" spacing={2} mb={4}>
<HStack justify="space-between">
<Heading size="md" color="#D4AF37"></Heading>
<HStack spacing={2}>
<Badge bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
{Math.min(incomeStatement.length, 8)}
</Badge>
<Text fontSize="sm" color="gray.400">
绿 |
</Text>
</HStack>
</HStack>
<Text fontSize="xs" color="gray.500">
Q1Q3
</Text>
</VStack>
<IncomeStatementTable data={incomeStatement} {...tableProps} />
</Box>
);
};
export default IncomeStatementTab;

View File

@@ -0,0 +1,330 @@
/**
* 财务指标分类 Tab - Ant Design 黑金主题
* 接受 categoryKey 显示单个分类的指标表格
*/
import React, { useMemo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import { FINANCIAL_METRICS_CATEGORIES } from '../constants';
import { getValueByPath, isNegativeIndicator } from '../utils';
import type { FinancialMetricsData } from '../types';
type CategoryKey = keyof typeof FINANCIAL_METRICS_CATEGORIES;
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.metrics-category-table .ant-table {
background: transparent !important;
}
.metrics-category-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.metrics-category-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.metrics-category-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.metrics-category-table .ant-table-cell-fix-left,
.metrics-category-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.metrics-category-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.metrics-category-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.metrics-category-table .positive-change {
color: #E53E3E;
}
.metrics-category-table .negative-change {
color: #48BB78;
}
.metrics-category-table .positive-value {
color: #E53E3E;
}
.metrics-category-table .negative-value {
color: #48BB78;
}
.metrics-category-table .ant-table-placeholder {
background: transparent !important;
}
.metrics-category-table .ant-empty-description {
color: #A0AEC0;
}
`;
export interface MetricsCategoryTabProps {
categoryKey: CategoryKey;
financialMetrics: FinancialMetricsData[];
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
[period: string]: unknown;
}
const MetricsCategoryTab: React.FC<MetricsCategoryTabProps> = ({
categoryKey,
financialMetrics,
showMetricChart,
calculateYoYChange,
}) => {
// 数组安全检查
if (!Array.isArray(financialMetrics) || financialMetrics.length === 0) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
const maxColumns = Math.min(financialMetrics.length, 6);
const displayData = financialMetrics.slice(0, maxColumns);
const category = FINANCIAL_METRICS_CATEGORIES[categoryKey];
if (!category) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
// 构建表格数据
const tableData = useMemo(() => {
return category.metrics.map((metric) => {
const row: TableRowData = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: metric.isCore,
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath<number>(item, metric.path);
row[item.period] = value;
});
return row;
});
}, [financialMetrics, displayData, category]);
// 计算同比变化
const calculateYoY = (
currentValue: number | undefined,
currentPeriod: string,
path: string
): number | null => {
if (currentValue === undefined || currentValue === null) return null;
const currentDate = new Date(currentPeriod);
const lastYearPeriod = financialMetrics.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
};
// 构建列定义
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: category.title,
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 200,
render: (name: string, record: TableRowData) => (
<HStack spacing={2}>
<Text fontWeight="medium" fontSize="xs">{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
),
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 100,
align: 'right' as const,
render: (value: number | undefined, record: TableRowData) => {
const yoy = calculateYoY(value, item.period, record.path);
const isNegative = isNegativeIndicator(record.key);
// 对于负向指标,增加是坏事(绿色),减少是好事(红色)
const changeColor = isNegative
? (yoy && yoy > 0 ? 'negative-change' : 'positive-change')
: (yoy && yoy > 0 ? 'positive-change' : 'negative-change');
// 成长能力指标特殊处理:正值红色,负值绿色
const valueColor = categoryKey === 'growth'
? (value !== undefined && value > 0 ? 'positive-value' : value !== undefined && value < 0 ? 'negative-value' : '')
: '';
return (
<Tooltip
title={
<Box>
<Text>{record.name}: {value?.toFixed(2) || '-'}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Box position="relative">
<Text fontSize="xs" className={valueColor || undefined}>
{value?.toFixed(2) || '-'}
</Text>
{yoy !== null && Math.abs(yoy) > 20 && value !== undefined && Math.abs(value) > 0.01 && (
<Text
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={changeColor}
>
{yoy > 0 ? '↑' : '↓'}
</Text>
)}
</Box>
</Tooltip>
);
},
})),
{
title: '',
key: 'action',
width: 40,
fixed: 'right',
render: (_: unknown, record: TableRowData) => (
<Eye
size={14}
color="#D4AF37"
style={{ cursor: 'pointer', opacity: 0.7 }}
onClick={(e) => {
e.stopPropagation();
showMetricChart(record.name, record.key, financialMetrics, record.path);
}}
/>
),
},
];
return cols;
}, [displayData, financialMetrics, showMetricChart, category, categoryKey]);
return (
<Box>
<Box className="metrics-category-table">
<style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
onRow={(record) => ({
onClick: () => {
showMetricChart(record.name, record.key, financialMetrics, record.path);
},
style: { cursor: 'pointer' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
</Box>
);
};
// 为每个分类创建预配置的组件
export const ProfitabilityTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="profitability" {...props} />
);
export const PerShareTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="perShare" {...props} />
);
export const GrowthTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="growth" {...props} />
);
export const OperationalTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="operational" {...props} />
);
export const SolvencyTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="solvency" {...props} />
);
export const ExpenseTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="expense" {...props} />
);
export const CashflowMetricsTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="cashflow" {...props} />
);
export default MetricsCategoryTab;

View File

@@ -0,0 +1,28 @@
/**
* Tab 组件统一导出
*/
// 三大财务报表
export { default as BalanceSheetTab } from './BalanceSheetTab';
export { default as IncomeStatementTab } from './IncomeStatementTab';
export { default as CashflowTab } from './CashflowTab';
// 财务指标分类 tabs
export {
ProfitabilityTab,
PerShareTab,
GrowthTab,
OperationalTab,
SolvencyTab,
ExpenseTab,
CashflowMetricsTab,
} from './MetricsCategoryTab';
// 旧的综合财务指标 tab保留兼容
export { default as FinancialMetricsTab } from './FinancialMetricsTab';
export type { BalanceSheetTabProps } from './BalanceSheetTab';
export type { IncomeStatementTabProps } from './IncomeStatementTab';
export type { CashflowTabProps } from './CashflowTab';
export type { FinancialMetricsTabProps } from './FinancialMetricsTab';
export type { MetricsCategoryTabProps } from './MetricsCategoryTab';

View File

@@ -0,0 +1,439 @@
/**
* FinancialPanorama 组件类型定义
*/
// ==================== 基础类型 ====================
/** 股票基本信息 */
export interface StockInfo {
stock_code: string;
stock_name: string;
key_metrics?: {
eps?: number;
roe?: number;
gross_margin?: number;
net_margin?: number;
roa?: number;
};
growth_rates?: {
revenue_growth?: number;
profit_growth?: number;
asset_growth?: number;
equity_growth?: number;
};
financial_summary?: {
revenue?: number;
net_profit?: number;
total_assets?: number;
total_liabilities?: number;
};
latest_forecast?: {
forecast_type: string;
content: string;
};
}
// ==================== 财务报表类型 ====================
/** 资产负债表数据 */
export interface BalanceSheetData {
period: string;
assets: {
current_assets: {
cash?: number;
trading_financial_assets?: number;
notes_receivable?: number;
accounts_receivable?: number;
prepayments?: number;
other_receivables?: number;
inventory?: number;
contract_assets?: number;
other_current_assets?: number;
total?: number;
};
non_current_assets: {
long_term_equity_investments?: number;
investment_property?: number;
fixed_assets?: number;
construction_in_progress?: number;
right_of_use_assets?: number;
intangible_assets?: number;
goodwill?: number;
deferred_tax_assets?: number;
other_non_current_assets?: number;
total?: number;
};
total?: number;
};
liabilities: {
current_liabilities: {
short_term_borrowings?: number;
notes_payable?: number;
accounts_payable?: number;
advance_receipts?: number;
contract_liabilities?: number;
employee_compensation_payable?: number;
taxes_payable?: number;
other_payables?: number;
non_current_liabilities_due_within_one_year?: number;
total?: number;
};
non_current_liabilities: {
long_term_borrowings?: number;
bonds_payable?: number;
lease_liabilities?: number;
deferred_tax_liabilities?: number;
other_non_current_liabilities?: number;
total?: number;
};
total?: number;
};
equity: {
share_capital?: number;
capital_reserve?: number;
surplus_reserve?: number;
undistributed_profit?: number;
treasury_stock?: number;
other_comprehensive_income?: number;
parent_company_equity?: number;
minority_interests?: number;
total?: number;
};
}
/** 利润表数据 */
export interface IncomeStatementData {
period: string;
revenue: {
total_operating_revenue?: number;
operating_revenue?: number;
other_income?: number;
};
costs: {
total_operating_cost?: number;
operating_cost?: number;
taxes_and_surcharges?: number;
selling_expenses?: number;
admin_expenses?: number;
rd_expenses?: number;
financial_expenses?: number;
interest_expense?: number;
interest_income?: number;
three_expenses_total?: number;
four_expenses_total?: number;
asset_impairment_loss?: number;
credit_impairment_loss?: number;
};
other_gains: {
fair_value_change?: number;
investment_income?: number;
investment_income_from_associates?: number;
exchange_income?: number;
asset_disposal_income?: number;
};
profit: {
operating_profit?: number;
total_profit?: number;
income_tax_expense?: number;
net_profit?: number;
parent_net_profit?: number;
minority_profit?: number;
continuing_operations_net_profit?: number;
discontinued_operations_net_profit?: number;
};
non_operating: {
non_operating_income?: number;
non_operating_expenses?: number;
};
per_share: {
basic_eps?: number;
diluted_eps?: number;
};
comprehensive_income: {
other_comprehensive_income?: number;
total_comprehensive_income?: number;
parent_comprehensive_income?: number;
minority_comprehensive_income?: number;
};
}
/** 现金流量表数据 */
export interface CashflowData {
period: string;
operating_activities: {
inflow: {
cash_from_sales?: number;
};
outflow: {
cash_for_goods?: number;
};
net_flow?: number;
};
investment_activities: {
net_flow?: number;
};
financing_activities: {
net_flow?: number;
};
cash_changes: {
net_increase?: number;
ending_balance?: number;
};
key_metrics: {
free_cash_flow?: number;
};
}
/** 财务指标数据 */
export interface FinancialMetricsData {
period: string;
profitability: {
roe?: number;
roe_deducted?: number;
roe_weighted?: number;
roa?: number;
gross_margin?: number;
net_profit_margin?: number;
operating_profit_margin?: number;
cost_profit_ratio?: number;
ebit?: number;
};
per_share_metrics: {
eps?: number;
basic_eps?: number;
diluted_eps?: number;
deducted_eps?: number;
bvps?: number;
operating_cash_flow_ps?: number;
capital_reserve_ps?: number;
undistributed_profit_ps?: number;
};
growth: {
revenue_growth?: number;
net_profit_growth?: number;
deducted_profit_growth?: number;
parent_profit_growth?: number;
operating_cash_flow_growth?: number;
total_asset_growth?: number;
equity_growth?: number;
fixed_asset_growth?: number;
};
operational_efficiency: {
total_asset_turnover?: number;
fixed_asset_turnover?: number;
current_asset_turnover?: number;
receivable_turnover?: number;
receivable_days?: number;
inventory_turnover?: number;
inventory_days?: number;
working_capital_turnover?: number;
};
solvency: {
current_ratio?: number;
quick_ratio?: number;
cash_ratio?: number;
conservative_quick_ratio?: number;
asset_liability_ratio?: number;
interest_coverage?: number;
cash_to_maturity_debt_ratio?: number;
tangible_asset_debt_ratio?: number;
};
expense_ratios: {
selling_expense_ratio?: number;
admin_expense_ratio?: number;
financial_expense_ratio?: number;
rd_expense_ratio?: number;
three_expense_ratio?: number;
four_expense_ratio?: number;
cost_ratio?: number;
};
cash_flow_quality: {
operating_cash_to_profit_ratio?: number;
cash_to_profit_ratio?: number;
cash_revenue_ratio?: number;
cash_recovery_rate?: number;
operating_cash_to_short_debt?: number;
operating_cash_to_total_debt?: number;
};
}
// ==================== 业务分析类型 ====================
/** 业务项目 */
export interface BusinessItem {
content: string;
revenue?: number;
gross_margin?: number;
profit_margin?: number;
profit?: number;
}
/** 主营业务产品分类 */
export interface ProductClassification {
period: string;
report_type: string;
products: BusinessItem[];
}
/** 主营业务行业分类 */
export interface IndustryClassification {
period: string;
report_type: string;
industries: BusinessItem[];
}
/** 主营业务数据 */
export interface MainBusinessData {
product_classification?: ProductClassification[];
industry_classification?: IndustryClassification[];
}
/** 行业排名指标 */
export interface RankingMetric {
value?: number;
rank?: number;
industry_avg?: number;
}
/** 行业排名数据 */
export interface IndustryRankData {
period: string;
report_type: string;
rankings?: {
industry_name: string;
level_description: string;
metrics?: {
eps?: RankingMetric;
bvps?: RankingMetric;
roe?: RankingMetric;
revenue_growth?: RankingMetric;
profit_growth?: RankingMetric;
operating_margin?: RankingMetric;
debt_ratio?: RankingMetric;
receivable_turnover?: RankingMetric;
};
}[];
}
/** 业绩预告数据 */
export interface ForecastData {
forecasts?: {
forecast_type: string;
report_date: string;
content: string;
reason?: string;
change_range?: {
lower?: number;
upper?: number;
};
}[];
}
/** 对比数据 */
export interface ComparisonData {
period: string;
performance: {
revenue?: number;
net_profit?: number;
};
}
// ==================== 组件 Props 类型 ====================
/** 主组件 Props */
export interface FinancialPanoramaProps {
stockCode?: string;
}
/** 股票信息头部 Props */
export interface StockInfoHeaderProps {
stockInfo: StockInfo | null;
positiveColor: string;
negativeColor: string;
}
/** 表格通用 Props */
export interface TableProps {
data: unknown[];
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
/** 资产负债表 Props */
export interface BalanceSheetTableProps extends TableProps {
data: BalanceSheetData[];
}
/** 利润表 Props */
export interface IncomeStatementTableProps extends TableProps {
data: IncomeStatementData[];
}
/** 现金流量表 Props */
export interface CashflowTableProps extends TableProps {
data: CashflowData[];
}
/** 财务指标表 Props */
export interface FinancialMetricsTableProps extends TableProps {
data: FinancialMetricsData[];
}
/** 主营业务分析 Props */
export interface MainBusinessAnalysisProps {
mainBusiness: MainBusinessData | null;
}
/** 行业排名 Props */
export interface IndustryRankingViewProps {
industryRank: IndustryRankData[];
bgColor?: string;
borderColor?: string;
textColor?: string;
labelColor?: string;
}
/** 股票对比 Props */
export interface StockComparisonProps {
currentStock: string;
stockInfo: StockInfo | null;
positiveColor: string;
negativeColor: string;
}
/** 综合对比分析 Props */
export interface ComparisonAnalysisProps {
comparison: ComparisonData[];
}
// ==================== 指标定义类型 ====================
/** 指标配置 */
export interface MetricConfig {
name: string;
key: string;
path: string;
isCore?: boolean;
isTotal?: boolean;
isSubtotal?: boolean;
}
/** 指标分类配置 */
export interface MetricSectionConfig {
title: string;
key: string;
metrics: MetricConfig[];
}
/** 指标分类映射 */
export interface MetricsCategoryMap {
[key: string]: {
title: string;
metrics: MetricConfig[];
};
}

Some files were not shown because too many files have changed in this diff Show More