diff --git a/src/components/FavoriteButton/index.tsx b/src/components/FavoriteButton/index.tsx new file mode 100644 index 00000000..6d572164 --- /dev/null +++ b/src/components/FavoriteButton/index.tsx @@ -0,0 +1,84 @@ +/** + * FavoriteButton - 通用关注/收藏按钮组件(图标按钮) + */ + +import React from 'react'; +import { IconButton, Tooltip, Spinner } from '@chakra-ui/react'; +import { Star } from 'lucide-react'; + +export interface FavoriteButtonProps { + /** 是否已关注 */ + isFavorite: boolean; + /** 加载状态 */ + isLoading?: boolean; + /** 点击回调 */ + onClick: () => void; + /** 按钮大小 */ + size?: 'sm' | 'md' | 'lg'; + /** 颜色主题 */ + colorScheme?: 'gold' | 'default'; + /** 是否显示 tooltip */ + showTooltip?: boolean; +} + +// 颜色配置 +const COLORS = { + gold: { + active: '#F4D03F', // 已关注 - 亮金色 + inactive: '#C9A961', // 未关注 - 暗金色 + hoverBg: 'whiteAlpha.100', + }, + default: { + active: 'yellow.400', + inactive: 'gray.400', + hoverBg: 'gray.100', + }, +}; + +const FavoriteButton: React.FC = ({ + isFavorite, + isLoading = false, + onClick, + size = 'sm', + colorScheme = 'gold', + showTooltip = true, +}) => { + const colors = COLORS[colorScheme]; + const currentColor = isFavorite ? colors.active : colors.inactive; + const label = isFavorite ? '取消关注' : '加入自选'; + + const iconButton = ( + + ) : ( + + ) + } + variant="ghost" + color={currentColor} + size={size} + onClick={onClick} + isDisabled={isLoading} + _hover={{ bg: colors.hoverBg }} + /> + ); + + if (showTooltip) { + return ( + + {iconButton} + + ); + } + + return iconButton; +}; + +export default FavoriteButton; diff --git a/src/components/InvestmentCalendar/index.js b/src/components/InvestmentCalendar/index.js index a936913e..aff2eb6a 100644 --- a/src/components/InvestmentCalendar/index.js +++ b/src/components/InvestmentCalendar/index.js @@ -545,19 +545,13 @@ const InvestmentCalendar = () => { render: (concepts) => ( {concepts && concepts.length > 0 ? ( - concepts.slice(0, 3).map((concept, index) => { - // 兼容多种数据格式:字符串、数组、对象 - const conceptName = typeof concept === 'string' - ? concept - : Array.isArray(concept) - ? concept[0] - : concept?.concept || concept?.name || ''; - return ( - }> - {conceptName} - - ); - }) + concepts.slice(0, 3).map((concept, index) => ( + }> + {typeof concept === 'string' + ? concept + : (concept?.concept || concept?.name || '未知')} + + )) ) : ( )} @@ -949,7 +943,7 @@ const InvestmentCalendar = () => { record[0]} + rowKey={(record) => record.code} size="middle" pagination={false} /> diff --git a/src/components/SubTabContainer/index.tsx b/src/components/SubTabContainer/index.tsx new file mode 100644 index 00000000..276a56d2 --- /dev/null +++ b/src/components/SubTabContainer/index.tsx @@ -0,0 +1,232 @@ +/** + * SubTabContainer - 二级导航容器组件 + * + * 用于模块内的子功能切换(如公司档案下的股权结构、管理团队等) + * 与 TabContainer(一级导航)区分:无 Card 包裹,直接融入父容器 + * + * @example + * ```tsx + * console.log('切换到', key)} + * /> + * ``` + */ + +import React, { useState, useCallback, memo } from 'react'; +import { + Box, + Tabs, + TabList, + TabPanels, + Tab, + TabPanel, + Icon, + HStack, + Text, + Spacer, +} from '@chakra-ui/react'; +import type { ComponentType } from 'react'; +import type { IconType } from 'react-icons'; + +/** + * Tab 配置项 + */ +export interface SubTabConfig { + key: string; + name: string; + icon?: IconType | ComponentType; + component?: ComponentType; +} + +/** + * 主题配置 + */ +export interface SubTabTheme { + bg: string; + borderColor: string; + tabSelectedBg: string; + tabSelectedColor: string; + tabUnselectedColor: string; + tabHoverBg: string; +} + +/** + * 预设主题 + */ +const THEME_PRESETS: Record = { + blackGold: { + bg: 'gray.900', + borderColor: 'rgba(212, 175, 55, 0.3)', + tabSelectedBg: '#D4AF37', + tabSelectedColor: 'gray.900', + tabUnselectedColor: '#D4AF37', + tabHoverBg: 'gray.600', + }, + default: { + bg: 'white', + borderColor: 'gray.200', + tabSelectedBg: 'blue.500', + tabSelectedColor: 'white', + tabUnselectedColor: 'gray.600', + tabHoverBg: 'gray.100', + }, +}; + +export interface SubTabContainerProps { + /** Tab 配置数组 */ + tabs: SubTabConfig[]; + /** 传递给 Tab 内容组件的 props */ + componentProps?: Record; + /** 默认选中的 Tab 索引 */ + defaultIndex?: number; + /** 受控模式下的当前索引 */ + index?: number; + /** Tab 变更回调 */ + onTabChange?: (index: number, tabKey: string) => void; + /** 主题预设 */ + themePreset?: 'blackGold' | 'default'; + /** 自定义主题(优先级高于预设) */ + theme?: Partial; + /** 内容区内边距 */ + contentPadding?: number; + /** 是否懒加载 */ + isLazy?: boolean; + /** TabList 右侧自定义内容 */ + rightElement?: React.ReactNode; +} + +const SubTabContainer: React.FC = memo(({ + tabs, + componentProps = {}, + defaultIndex = 0, + index: controlledIndex, + onTabChange, + themePreset = 'blackGold', + theme: customTheme, + contentPadding = 4, + isLazy = true, + rightElement, +}) => { + // 内部状态(非受控模式) + const [internalIndex, setInternalIndex] = useState(defaultIndex); + + // 当前索引 + const currentIndex = controlledIndex ?? internalIndex; + + // 记录已访问的 Tab 索引(用于真正的懒加载) + const [visitedTabs, setVisitedTabs] = useState>( + () => new Set([controlledIndex ?? defaultIndex]) + ); + + // 合并主题 + const theme: SubTabTheme = { + ...THEME_PRESETS[themePreset], + ...customTheme, + }; + + /** + * 处理 Tab 切换 + */ + const handleTabChange = useCallback( + (newIndex: number) => { + const tabKey = tabs[newIndex]?.key || ''; + onTabChange?.(newIndex, tabKey); + + // 记录已访问的 Tab(用于懒加载) + setVisitedTabs(prev => { + if (prev.has(newIndex)) return prev; + return new Set(prev).add(newIndex); + }); + + if (controlledIndex === undefined) { + setInternalIndex(newIndex); + } + }, + [tabs, onTabChange, controlledIndex] + ); + + return ( + + + + {tabs.map((tab) => ( + + + {tab.icon && } + {tab.name} + + + ))} + {rightElement && ( + <> + + {rightElement} + + )} + + + + {tabs.map((tab, idx) => { + const Component = tab.component; + // 懒加载:只渲染已访问过的 Tab + const shouldRender = !isLazy || visitedTabs.has(idx); + + return ( + + {shouldRender && Component ? ( + + ) : null} + + ); + })} + + + + ); +}); + +SubTabContainer.displayName = 'SubTabContainer'; + +export default SubTabContainer; diff --git a/src/components/TabContainer/TabNavigation.tsx b/src/components/TabContainer/TabNavigation.tsx new file mode 100644 index 00000000..28d00812 --- /dev/null +++ b/src/components/TabContainer/TabNavigation.tsx @@ -0,0 +1,56 @@ +/** + * TabNavigation 通用导航组件 + * + * 渲染 Tab 按钮列表,支持图标 + 文字 + */ + +import React from 'react'; +import { TabList, Tab, HStack, Icon, Text } from '@chakra-ui/react'; +import type { TabNavigationProps } from './types'; + +const TabNavigation: React.FC = ({ + tabs, + themeColors, + borderRadius = 'lg', +}) => { + return ( + + {tabs.map((tab) => ( + + + {tab.icon && } + {tab.name} + + + ))} + + ); +}; + +export default TabNavigation; diff --git a/src/components/TabContainer/constants.ts b/src/components/TabContainer/constants.ts new file mode 100644 index 00000000..784bd3cd --- /dev/null +++ b/src/components/TabContainer/constants.ts @@ -0,0 +1,55 @@ +/** + * TabContainer 常量和主题预设 + */ + +import type { ThemeColors, ThemePreset } from './types'; + +/** + * 主题预设配置 + */ +export const THEME_PRESETS: Record> = { + // 黑金主题(原 Company 模块风格) + blackGold: { + bg: '#1A202C', + selectedBg: '#C9A961', + selectedText: '#FFFFFF', + unselectedText: '#D4AF37', + dividerColor: 'gray.600', + }, + // 默认主题(Chakra 风格) + default: { + bg: 'white', + selectedBg: 'blue.500', + selectedText: 'white', + unselectedText: 'gray.600', + dividerColor: 'gray.200', + }, + // 深色主题 + dark: { + bg: 'gray.800', + selectedBg: 'blue.400', + selectedText: 'white', + unselectedText: 'gray.300', + dividerColor: 'gray.600', + }, + // 浅色主题 + light: { + bg: 'gray.50', + selectedBg: 'blue.500', + selectedText: 'white', + unselectedText: 'gray.700', + dividerColor: 'gray.300', + }, +}; + +/** + * 默认配置 + */ +export const DEFAULT_CONFIG = { + themePreset: 'blackGold' as ThemePreset, + isLazy: true, + size: 'lg' as const, + borderRadius: 'lg', + shadow: 'lg', + panelPadding: 0, +}; diff --git a/src/components/TabContainer/index.tsx b/src/components/TabContainer/index.tsx new file mode 100644 index 00000000..4726a509 --- /dev/null +++ b/src/components/TabContainer/index.tsx @@ -0,0 +1,134 @@ +/** + * TabContainer 通用 Tab 容器组件 + * + * 功能: + * - 管理 Tab 切换状态(支持受控/非受控模式) + * - 动态渲染 Tab 导航和内容 + * - 支持多种主题预设(黑金、默认、深色、浅色) + * - 支持自定义主题颜色 + * - 支持懒加载 + * + * @example + * // 基础用法(传入 components) + * console.log('切换到', key)} + * /> + * + * @example + * // 自定义渲染用法(使用 children) + * + * 自定义内容 1 + * 自定义内容 2 + * + */ + +import React, { useState, useCallback, useMemo } from 'react'; +import { + Card, + CardBody, + Tabs, + TabPanels, + TabPanel, +} from '@chakra-ui/react'; + +import TabNavigation from './TabNavigation'; +import { THEME_PRESETS, DEFAULT_CONFIG } from './constants'; +import type { TabContainerProps, ThemeColors } from './types'; + +// 导出类型和常量 +export type { TabConfig, ThemeColors, ThemePreset, TabContainerProps } from './types'; +export { THEME_PRESETS } from './constants'; + +const TabContainer: React.FC = ({ + tabs, + componentProps = {}, + onTabChange, + defaultIndex = 0, + index: controlledIndex, + themePreset = DEFAULT_CONFIG.themePreset, + themeColors: customThemeColors, + isLazy = DEFAULT_CONFIG.isLazy, + size = DEFAULT_CONFIG.size, + borderRadius = DEFAULT_CONFIG.borderRadius, + shadow = DEFAULT_CONFIG.shadow, + panelPadding = DEFAULT_CONFIG.panelPadding, + children, +}) => { + // 内部状态(非受控模式) + const [internalIndex, setInternalIndex] = useState(defaultIndex); + + // 当前索引(支持受控/非受控) + const currentIndex = controlledIndex ?? internalIndex; + + // 合并主题颜色(自定义颜色优先) + const themeColors: Required = useMemo(() => ({ + ...THEME_PRESETS[themePreset], + ...customThemeColors, + }), [themePreset, customThemeColors]); + + /** + * 处理 Tab 切换 + */ + const handleTabChange = useCallback((newIndex: number) => { + const tabKey = tabs[newIndex]?.key || ''; + + // 触发回调 + onTabChange?.(newIndex, tabKey, currentIndex); + + // 非受控模式下更新内部状态 + if (controlledIndex === undefined) { + setInternalIndex(newIndex); + } + }, [tabs, onTabChange, currentIndex, controlledIndex]); + + /** + * 渲染 Tab 内容 + */ + const renderTabPanels = () => { + // 如果传入了 children,直接渲染 children + if (children) { + return children; + } + + // 否则根据 tabs 配置渲染 + return tabs.map((tab) => { + const Component = tab.component; + return ( + + {Component ? : null} + + ); + }); + }; + + return ( + + + + {/* Tab 导航 */} + + + {/* Tab 内容面板 */} + {renderTabPanels()} + + + + ); +}; + +export default TabContainer; diff --git a/src/components/TabContainer/types.ts b/src/components/TabContainer/types.ts new file mode 100644 index 00000000..6845e48f --- /dev/null +++ b/src/components/TabContainer/types.ts @@ -0,0 +1,85 @@ +/** + * TabContainer 通用 Tab 容器组件类型定义 + */ + +import type { ComponentType, ReactNode } from 'react'; +import type { IconType } from 'react-icons'; + +/** + * Tab 配置项 + */ +export interface TabConfig { + /** Tab 唯一标识 */ + key: string; + /** Tab 显示名称 */ + name: string; + /** Tab 图标(可选) */ + icon?: IconType | ComponentType; + /** Tab 内容组件(可选,如果不传则使用 children 渲染) */ + component?: ComponentType; +} + +/** + * 主题颜色配置 + */ +export interface ThemeColors { + /** 容器背景色 */ + bg?: string; + /** 选中 Tab 背景色 */ + selectedBg?: string; + /** 选中 Tab 文字颜色 */ + selectedText?: string; + /** 未选中 Tab 文字颜色 */ + unselectedText?: string; + /** 分割线颜色 */ + dividerColor?: string; +} + +/** + * 预设主题类型 + */ +export type ThemePreset = 'blackGold' | 'default' | 'dark' | 'light'; + +/** + * TabContainer 组件 Props + */ +export interface TabContainerProps { + /** Tab 配置数组 */ + tabs: TabConfig[]; + /** 传递给 Tab 内容组件的通用 props */ + componentProps?: Record; + /** Tab 变更回调 */ + onTabChange?: (index: number, tabKey: string, prevIndex: number) => void; + /** 默认选中的 Tab 索引 */ + defaultIndex?: number; + /** 受控模式下的当前索引 */ + index?: number; + /** 主题预设 */ + themePreset?: ThemePreset; + /** 自定义主题颜色(优先级高于预设) */ + themeColors?: ThemeColors; + /** 是否启用懒加载 */ + isLazy?: boolean; + /** Tab 尺寸 */ + size?: 'sm' | 'md' | 'lg'; + /** 容器圆角 */ + borderRadius?: string; + /** 容器阴影 */ + shadow?: string; + /** 自定义 Tab 面板内边距 */ + panelPadding?: number | string; + /** 子元素(用于自定义渲染 Tab 内容) */ + children?: ReactNode; +} + +/** + * TabNavigation 组件 Props + */ +export interface TabNavigationProps { + /** Tab 配置数组 */ + tabs: TabConfig[]; + /** 主题颜色 */ + themeColors: Required; + /** 容器圆角 */ + borderRadius?: string; +} diff --git a/src/components/TabPanelContainer/index.tsx b/src/components/TabPanelContainer/index.tsx new file mode 100644 index 00000000..f45c3e9e --- /dev/null +++ b/src/components/TabPanelContainer/index.tsx @@ -0,0 +1,100 @@ +/** + * TabPanelContainer - Tab 面板通用容器组件 + * + * 提供统一的: + * - Loading 状态处理 + * - VStack 布局 + * - 免责声明(可选) + * + * @example + * ```tsx + * + * + * + * ``` + */ + +import React, { memo } from 'react'; +import { VStack, Center, Spinner, Text, Box } from '@chakra-ui/react'; + +// 默认免责声明文案 +const DEFAULT_DISCLAIMER = + '免责声明:本内容由AI模型基于新闻、公告、研报等公开信息自动分析和生成,未经许可严禁转载。所有内容仅供参考,不构成任何投资建议,请投资者注意风险,独立审慎决策。'; + +export interface TabPanelContainerProps { + /** 是否处于加载状态 */ + loading?: boolean; + /** 加载状态显示的文案 */ + loadingMessage?: string; + /** 加载状态高度 */ + loadingHeight?: string; + /** 子组件间距,默认 6 */ + spacing?: number; + /** 内边距,默认 4 */ + padding?: number; + /** 是否显示免责声明,默认 false */ + showDisclaimer?: boolean; + /** 自定义免责声明文案 */ + disclaimerText?: string; + /** 子组件 */ + children: React.ReactNode; +} + +/** + * 加载状态组件 + */ +const LoadingState: React.FC<{ message: string; height: string }> = ({ + message, + height, +}) => ( +
+ + + + {message} + + +
+); + +/** + * 免责声明组件 + */ +const DisclaimerText: React.FC<{ text: string }> = ({ text }) => ( + + {text} + +); + +/** + * Tab 面板通用容器 + */ +const TabPanelContainer: React.FC = memo( + ({ + loading = false, + loadingMessage = '加载中...', + loadingHeight = '200px', + spacing = 6, + padding = 4, + showDisclaimer = false, + disclaimerText = DEFAULT_DISCLAIMER, + children, + }) => { + if (loading) { + return ; + } + + return ( + + + {children} + + {showDisclaimer && } + + ); + } +); + +TabPanelContainer.displayName = 'TabPanelContainer'; + +export default TabPanelContainer; diff --git a/src/mocks/data/company.js b/src/mocks/data/company.js index ef6c4e86..9d5477c6 100644 --- a/src/mocks/data/company.js +++ b/src/mocks/data/company.js @@ -1,382 +1,1141 @@ // src/mocks/data/company.js // 公司相关的 Mock 数据 +// 字段名与后端 API 返回格式保持一致 // 平安银行 (000001) 的完整数据 export const PINGAN_BANK_DATA = { stockCode: '000001', stockName: '平安银行', - // 基本信息 + // 基本信息 - 字段名与后端 API 保持一致 basicInfo: { - code: '000001', - name: '平安银行', + SECCODE: '000001', + SECNAME: '平安银行', + ORGNAME: '平安银行股份有限公司', english_name: 'Ping An Bank Co., Ltd.', - registered_capital: 1940642.3, // 万元 - registered_capital_unit: '万元', + reg_capital: 1940642.3, // 万元 legal_representative: '谢永林', - general_manager: '谢永林', + chairman: '谢永林', + general_manager: '冀光恒', secretary: '周强', - registered_address: '深圳市深南东路5047号', - office_address: '深圳市深南东路5047号', + reg_address: '深圳市罗湖区深南东路5047号', + office_address: '深圳市福田区益田路5023号平安金融中心', zipcode: '518001', - phone: '0755-82080387', + tel: '0755-82080387', fax: '0755-82080386', - email: 'ir@bank.pingan.com', + email: 'ir@pingan.com.cn', website: 'http://bank.pingan.com', - business_scope: '吸收公众存款;发放短期、中期和长期贷款;办理国内外结算;办理票据承兑与贴现;发行金融债券;代理发行、代理兑付、承销政府债券;买卖政府债券、金融债券;从事同业拆借;买卖、代理买卖外汇;从事银行卡业务;提供信用证服务及担保;代理收付款项及代理保险业务;提供保管箱服务;经有关监管机构批准的其他业务。', - employees: 36542, - introduction: '平安银行股份有限公司是中国平安保险(集团)股份有限公司控股的一家跨区域经营的股份制商业银行,为中国大陆12家全国性股份制商业银行之一。注册资本为人民币51.2335亿元,总资产近1.37万亿元,总部位于深圳。平安银行拥有全国性银行经营资质,主要经营商业银行业务。', - list_date: '1991-04-03', + sw_industry_l1: '金融', + sw_industry_l2: '银行', + sw_industry_l3: '股份制银行', establish_date: '1987-12-22', + list_date: '1991-04-03', province: '广东省', city: '深圳市', - industry: '银行', - main_business: '商业银行业务', + credit_code: '914403001000010008', + company_size: '大型企业(员工超3万人)', + accounting_firm: '普华永道中天会计师事务所(特殊普通合伙)', + law_firm: '北京市金杜律师事务所', + company_intro: '平安银行股份有限公司是中国平安保险(集团)股份有限公司控股的一家跨区域经营的股份制商业银行,为中国大陆12家全国性股份制商业银行之一。总部位于深圳,在全国设有超过90家分行、近1000家营业网点。平安银行致力于成为中国最卓越、全球领先的智能化零售银行,以科技引领业务发展,持续推进零售转型战略。', + main_business: '吸收公众存款、发放贷款、办理结算、票据贴现、资金拆借、银行卡业务、代理收付款项、外汇业务等商业银行业务', + business_scope: '吸收公众存款;发放短期、中期和长期贷款;办理国内外结算;办理票据承兑与贴现;发行金融债券;代理发行、代理兑付、承销政府债券;买卖政府债券、金融债券;从事同业拆借;买卖、代理买卖外汇;从事银行卡业务;提供信用证服务及担保;代理收付款项及代理保险业务;提供保管箱服务;经有关监管机构批准的其他业务。', + employees: 42099, }, - // 实际控制人信息 - actualControl: { - controller_name: '中国平安保险(集团)股份有限公司', - controller_type: '企业', - shareholding_ratio: 52.38, - control_chain: '中国平安保险(集团)股份有限公司 -> 平安银行股份有限公司', - is_listed: true, - change_date: '2023-12-31', - remark: '中国平安通过直接和间接方式控股平安银行', + // 市场概览数据 - StockSummaryCard 使用 + marketSummary: { + stock_code: '000001', + stock_name: '平安银行', + latest_trade: { + close: 11.28, + change_percent: 2.35, + volume: 58623400, + amount: 659800000, + turnover_rate: 0.30, + pe_ratio: 4.92 + }, + latest_funding: { + financing_balance: 5823000000, + securities_balance: 125600000 + }, + latest_pledge: { + pledge_ratio: 8.25 + } }, - // 股权集中度 - concentration: { - top1_ratio: 52.38, - top3_ratio: 58.42, - top5_ratio: 60.15, - top10_ratio: 63.28, - update_date: '2024-09-30', - concentration_level: '高度集中', - herfindahl_index: 0.2845, + // 当日分钟K线数据 - MinuteKLineChart 使用 + minuteData: { + code: '000001', + name: '平安银行', + trade_date: '2024-12-12', + type: '1min', + data: [ + // 上午交易时段 9:30 - 11:30 + { time: '09:30', open: 11.02, close: 11.05, high: 11.06, low: 11.01, volume: 1856000, amount: 20458000 }, + { time: '09:31', open: 11.05, close: 11.08, high: 11.09, low: 11.04, volume: 1423000, amount: 15782000 }, + { time: '09:32', open: 11.08, close: 11.06, high: 11.10, low: 11.05, volume: 1125000, amount: 12468000 }, + { time: '09:33', open: 11.06, close: 11.10, high: 11.11, low: 11.05, volume: 1678000, amount: 18623000 }, + { time: '09:34', open: 11.10, close: 11.12, high: 11.14, low: 11.09, volume: 2134000, amount: 23725000 }, + { time: '09:35', open: 11.12, close: 11.15, high: 11.16, low: 11.11, volume: 1892000, amount: 21082000 }, + { time: '09:40', open: 11.15, close: 11.18, high: 11.20, low: 11.14, volume: 1567000, amount: 17523000 }, + { time: '09:45', open: 11.18, close: 11.16, high: 11.19, low: 11.15, volume: 1234000, amount: 13782000 }, + { time: '09:50', open: 11.16, close: 11.20, high: 11.21, low: 11.15, volume: 1456000, amount: 16298000 }, + { time: '09:55', open: 11.20, close: 11.22, high: 11.24, low: 11.19, volume: 1789000, amount: 20068000 }, + { time: '10:00', open: 11.22, close: 11.25, high: 11.26, low: 11.21, volume: 2012000, amount: 22635000 }, + { time: '10:10', open: 11.25, close: 11.23, high: 11.26, low: 11.22, volume: 1345000, amount: 15123000 }, + { time: '10:20', open: 11.23, close: 11.26, high: 11.28, low: 11.22, volume: 1678000, amount: 18912000 }, + { time: '10:30', open: 11.26, close: 11.24, high: 11.27, low: 11.23, volume: 1123000, amount: 12645000 }, + { time: '10:40', open: 11.24, close: 11.27, high: 11.28, low: 11.23, volume: 1456000, amount: 16412000 }, + { time: '10:50', open: 11.27, close: 11.25, high: 11.28, low: 11.24, volume: 1234000, amount: 13902000 }, + { time: '11:00', open: 11.25, close: 11.28, high: 11.30, low: 11.24, volume: 1567000, amount: 17689000 }, + { time: '11:10', open: 11.28, close: 11.26, high: 11.29, low: 11.25, volume: 1089000, amount: 12278000 }, + { time: '11:20', open: 11.26, close: 11.28, high: 11.29, low: 11.25, volume: 1234000, amount: 13912000 }, + { time: '11:30', open: 11.28, close: 11.27, high: 11.29, low: 11.26, volume: 987000, amount: 11134000 }, + // 下午交易时段 13:00 - 15:00 + { time: '13:00', open: 11.27, close: 11.30, high: 11.31, low: 11.26, volume: 1456000, amount: 16456000 }, + { time: '13:10', open: 11.30, close: 11.28, high: 11.31, low: 11.27, volume: 1123000, amount: 12689000 }, + { time: '13:20', open: 11.28, close: 11.32, high: 11.33, low: 11.27, volume: 1789000, amount: 20245000 }, + { time: '13:30', open: 11.32, close: 11.30, high: 11.33, low: 11.29, volume: 1345000, amount: 15212000 }, + { time: '13:40', open: 11.30, close: 11.33, high: 11.35, low: 11.29, volume: 1678000, amount: 18978000 }, + { time: '13:50', open: 11.33, close: 11.31, high: 11.34, low: 11.30, volume: 1234000, amount: 13956000 }, + { time: '14:00', open: 11.31, close: 11.34, high: 11.36, low: 11.30, volume: 1567000, amount: 17789000 }, + { time: '14:10', open: 11.34, close: 11.32, high: 11.35, low: 11.31, volume: 1123000, amount: 12712000 }, + { time: '14:20', open: 11.32, close: 11.30, high: 11.33, low: 11.29, volume: 1456000, amount: 16478000 }, + { time: '14:30', open: 11.30, close: 11.28, high: 11.31, low: 11.27, volume: 1678000, amount: 18956000 }, + { time: '14:40', open: 11.28, close: 11.26, high: 11.29, low: 11.25, volume: 1345000, amount: 15167000 }, + { time: '14:50', open: 11.26, close: 11.28, high: 11.30, low: 11.25, volume: 1892000, amount: 21345000 }, + { time: '15:00', open: 11.28, close: 11.28, high: 11.29, low: 11.27, volume: 2345000, amount: 26478000 } + ] }, - // 高管信息 - management: [ + // 实际控制人信息(数组格式) + actualControl: [ { - name: '谢永林', - position: '董事长、执行董事、行长', - gender: '男', - age: 56, - education: '硕士', - appointment_date: '2019-01-01', - annual_compensation: 723.8, - shareholding: 0, - background: '中国平安保险(集团)股份有限公司副总经理兼首席保险业务执行官' - }, - { - name: '周强', - position: '执行董事、副行长、董事会秘书', - gender: '男', - age: 54, - education: '硕士', - appointment_date: '2016-06-01', - annual_compensation: 542.3, - shareholding: 0.002, - background: '历任平安银行深圳分行行长' - }, - { - name: '郭世邦', - position: '执行董事、副行长、首席财务官', - gender: '男', - age: 52, - education: '博士', - appointment_date: '2018-03-01', - annual_compensation: 498.6, - shareholding: 0.001, - background: '历任中国平安集团财务负责人' - }, - { - name: '蔡新发', - position: '副行长、首席风险官', - gender: '男', - age: 51, - education: '硕士', - appointment_date: '2017-05-01', - annual_compensation: 467.2, - shareholding: 0.0008, - background: '历任平安银行风险管理部总经理' - }, - { - name: '项有志', - position: '副行长、首席信息官', - gender: '男', - age: 49, - education: '硕士', - appointment_date: '2019-09-01', - annual_compensation: 425.1, - shareholding: 0, - background: '历任中国平安科技公司总经理' + actual_controller_name: '中国平安保险(集团)股份有限公司', + controller_name: '中国平安保险(集团)股份有限公司', + control_type: '企业法人', + controller_type: '企业', + holding_ratio: 52.38, + holding_shares: 10168542300, + end_date: '2024-09-30', + control_chain: '中国平安保险(集团)股份有限公司 -> 平安银行股份有限公司', + is_listed: true, + remark: '中国平安通过直接和间接方式控股平安银行', } ], - // 十大流通股东 + // 股权集中度(数组格式,按统计项分组) + concentration: [ + { stat_item: '前1大股东', holding_ratio: 52.38, ratio_change: 0.00, end_date: '2024-09-30' }, + { stat_item: '前3大股东', holding_ratio: 58.42, ratio_change: 0.15, end_date: '2024-09-30' }, + { stat_item: '前5大股东', holding_ratio: 60.15, ratio_change: 0.22, end_date: '2024-09-30' }, + { stat_item: '前10大股东', holding_ratio: 63.28, ratio_change: 0.35, end_date: '2024-09-30' }, + { stat_item: '前1大股东', holding_ratio: 52.38, ratio_change: -0.12, end_date: '2024-06-30' }, + { stat_item: '前3大股东', holding_ratio: 58.27, ratio_change: -0.08, end_date: '2024-06-30' }, + { stat_item: '前5大股东', holding_ratio: 59.93, ratio_change: -0.15, end_date: '2024-06-30' }, + { stat_item: '前10大股东', holding_ratio: 62.93, ratio_change: -0.22, end_date: '2024-06-30' }, + ], + + // 高管信息(包含高管、董事、监事、其他) + management: [ + // === 高管 === + { + name: '谢永林', + position_name: '董事长', + position_category: '高管', + gender: '男', + birth_year: '1968', + education: '硕士', + nationality: '中国', + start_date: '2019-01-01', + status: 'active' + }, + { + name: '冀光恒', + position_name: '行长', + position_category: '高管', + gender: '男', + birth_year: '1972', + education: '博士', + nationality: '中国', + start_date: '2023-08-01', + status: 'active' + }, + { + name: '周强', + position_name: '副行长、董事会秘书', + position_category: '高管', + gender: '男', + birth_year: '1970', + education: '硕士', + nationality: '中国', + start_date: '2016-06-01', + status: 'active' + }, + { + name: '郭世邦', + position_name: '副行长、首席财务官', + position_category: '高管', + gender: '男', + birth_year: '1972', + education: '博士', + nationality: '中国', + start_date: '2018-03-01', + status: 'active' + }, + { + name: '项有志', + position_name: '副行长、首席信息官', + position_category: '高管', + gender: '男', + birth_year: '1975', + education: '硕士', + nationality: '中国', + start_date: '2019-09-01', + status: 'active' + }, + { + name: '张小璐', + position_name: '副行长、首席风险官', + position_category: '高管', + gender: '女', + birth_year: '1973', + education: '硕士', + nationality: '中国', + start_date: '2020-03-15', + status: 'active' + }, + // === 董事 === + { + name: '马明哲', + position_name: '非执行董事', + position_category: '董事', + gender: '男', + birth_year: '1955', + education: '博士', + nationality: '中国', + start_date: '2012-06-15', + status: 'active' + }, + { + name: '孙建一', + position_name: '非执行董事', + position_category: '董事', + gender: '男', + birth_year: '1960', + education: '硕士', + nationality: '中国', + start_date: '2016-08-20', + status: 'active' + }, + { + name: '陈心颖', + position_name: '非执行董事', + position_category: '董事', + gender: '女', + birth_year: '1977', + education: '硕士', + nationality: '新加坡', + start_date: '2018-06-01', + status: 'active' + }, + { + name: '黄宝新', + position_name: '独立非执行董事', + position_category: '董事', + gender: '男', + birth_year: '1962', + education: '博士', + nationality: '中国', + start_date: '2019-06-20', + status: 'active' + }, + { + name: '王志良', + position_name: '独立非执行董事', + position_category: '董事', + gender: '男', + birth_year: '1958', + education: '博士', + nationality: '美国', + start_date: '2020-06-18', + status: 'active' + }, + { + name: '李曙光', + position_name: '独立非执行董事', + position_category: '董事', + gender: '男', + birth_year: '1963', + education: '博士', + nationality: '中国', + start_date: '2021-06-25', + status: 'active' + }, + // === 监事 === + { + name: '王选庆', + position_name: '监事会主席', + position_category: '监事', + gender: '男', + birth_year: '1965', + education: '硕士', + nationality: '中国', + start_date: '2017-06-15', + status: 'active' + }, + { + name: '杨峻', + position_name: '职工监事', + position_category: '监事', + gender: '男', + birth_year: '1970', + education: '本科', + nationality: '中国', + start_date: '2019-06-20', + status: 'active' + }, + { + name: '刘春华', + position_name: '外部监事', + position_category: '监事', + gender: '女', + birth_year: '1968', + education: '硕士', + nationality: '中国', + start_date: '2020-06-18', + status: 'active' + }, + { + name: '张伟民', + position_name: '外部监事', + position_category: '监事', + gender: '男', + birth_year: '1966', + education: '博士', + nationality: '中国', + start_date: '2021-06-25', + status: 'active' + }, + // === 其他 === + { + name: '陈敏', + position_name: '合规总监', + position_category: '其他', + gender: '女', + birth_year: '1975', + education: '硕士', + nationality: '中国', + start_date: '2018-09-01', + status: 'active' + }, + { + name: '李明', + position_name: '审计部总经理', + position_category: '其他', + gender: '男', + birth_year: '1978', + education: '硕士', + nationality: '中国', + start_date: '2019-03-15', + status: 'active' + }, + { + name: '王建国', + position_name: '法务部总经理', + position_category: '其他', + gender: '男', + birth_year: '1972', + education: '博士', + nationality: '中国', + start_date: '2017-06-01', + status: 'active' + } + ], + + // 十大流通股东(字段名与组件期望格式匹配) topCirculationShareholders: [ - { shareholder_name: '中国平安保险(集团)股份有限公司', shares: 10168542300, ratio: 52.38, change: 0, shareholder_type: '企业' }, - { shareholder_name: '香港中央结算有限公司', shares: 542138600, ratio: 2.79, change: 12450000, shareholder_type: '境外法人' }, - { shareholder_name: '深圳市投资控股有限公司', shares: 382456100, ratio: 1.97, change: 0, shareholder_type: '国有企业' }, - { shareholder_name: '中国证券金融股份有限公司', shares: 298654200, ratio: 1.54, change: -5000000, shareholder_type: '证金公司' }, - { shareholder_name: '中央汇金资产管理有限责任公司', shares: 267842100, ratio: 1.38, change: 0, shareholder_type: '中央汇金' }, - { shareholder_name: '全国社保基金一零三组合', shares: 156234500, ratio: 0.80, change: 23400000, shareholder_type: '社保基金' }, - { shareholder_name: '全国社保基金一零一组合', shares: 142356700, ratio: 0.73, change: 15600000, shareholder_type: '社保基金' }, - { shareholder_name: '中国人寿保险股份有限公司', shares: 128945600, ratio: 0.66, change: 0, shareholder_type: '保险公司' }, - { shareholder_name: 'GIC PRIVATE LIMITED', shares: 98765400, ratio: 0.51, change: -8900000, shareholder_type: '境外法人' }, - { shareholder_name: '挪威中央银行', shares: 87654300, ratio: 0.45, change: 5600000, shareholder_type: '境外法人' } + { shareholder_rank: 1, shareholder_name: '中国平安保险(集团)股份有限公司', holding_shares: 10168542300, circulation_share_ratio: 52.38, shareholder_type: '法人', end_date: '2024-09-30' }, + { shareholder_rank: 2, shareholder_name: '香港中央结算有限公司(陆股通)', holding_shares: 542138600, circulation_share_ratio: 2.79, shareholder_type: 'QFII', end_date: '2024-09-30' }, + { shareholder_rank: 3, shareholder_name: '中国平安人寿保险股份有限公司-传统-普通保险产品', holding_shares: 382456100, circulation_share_ratio: 1.97, shareholder_type: '保险', end_date: '2024-09-30' }, + { shareholder_rank: 4, shareholder_name: '中国证券金融股份有限公司', holding_shares: 298654200, circulation_share_ratio: 1.54, shareholder_type: '券商', end_date: '2024-09-30' }, + { shareholder_rank: 5, shareholder_name: '中央汇金资产管理有限责任公司', holding_shares: 267842100, circulation_share_ratio: 1.38, shareholder_type: '法人', end_date: '2024-09-30' }, + { shareholder_rank: 6, shareholder_name: '全国社保基金一零三组合', holding_shares: 156234500, circulation_share_ratio: 0.80, shareholder_type: '社保', end_date: '2024-09-30' }, + { shareholder_rank: 7, shareholder_name: '华夏上证50交易型开放式指数证券投资基金', holding_shares: 142356700, circulation_share_ratio: 0.73, shareholder_type: '基金', end_date: '2024-09-30' }, + { shareholder_rank: 8, shareholder_name: '中国人寿保险股份有限公司-分红-个人分红-005L-FH002深', holding_shares: 128945600, circulation_share_ratio: 0.66, shareholder_type: '保险', end_date: '2024-09-30' }, + { shareholder_rank: 9, shareholder_name: '易方达沪深300交易型开放式指数发起式证券投资基金', holding_shares: 98765400, circulation_share_ratio: 0.51, shareholder_type: '基金', end_date: '2024-09-30' }, + { shareholder_rank: 10, shareholder_name: '嘉实沪深300交易型开放式指数证券投资基金', holding_shares: 87654300, circulation_share_ratio: 0.45, shareholder_type: '基金', end_date: '2024-09-30' } ], - // 十大股东(与流通股东相同,因为平安银行全流通) + // 十大股东(字段名与组件期望格式匹配) topShareholders: [ - { shareholder_name: '中国平安保险(集团)股份有限公司', shares: 10168542300, ratio: 52.38, change: 0, shareholder_type: '企业', is_restricted: false }, - { shareholder_name: '香港中央结算有限公司', shares: 542138600, ratio: 2.79, change: 12450000, shareholder_type: '境外法人', is_restricted: false }, - { shareholder_name: '深圳市投资控股有限公司', shares: 382456100, ratio: 1.97, change: 0, shareholder_type: '国有企业', is_restricted: false }, - { shareholder_name: '中国证券金融股份有限公司', shares: 298654200, ratio: 1.54, change: -5000000, shareholder_type: '证金公司', is_restricted: false }, - { shareholder_name: '中央汇金资产管理有限责任公司', shares: 267842100, ratio: 1.38, change: 0, shareholder_type: '中央汇金', is_restricted: false }, - { shareholder_name: '全国社保基金一零三组合', shares: 156234500, ratio: 0.80, change: 23400000, shareholder_type: '社保基金', is_restricted: false }, - { shareholder_name: '全国社保基金一零一组合', shares: 142356700, ratio: 0.73, change: 15600000, shareholder_type: '社保基金', is_restricted: false }, - { shareholder_name: '中国人寿保险股份有限公司', shares: 128945600, ratio: 0.66, change: 0, shareholder_type: '保险公司', is_restricted: false }, - { shareholder_name: 'GIC PRIVATE LIMITED', shares: 98765400, ratio: 0.51, change: -8900000, shareholder_type: '境外法人', is_restricted: false }, - { shareholder_name: '挪威中央银行', shares: 87654300, ratio: 0.45, change: 5600000, shareholder_type: '境外法人', is_restricted: false } + { shareholder_rank: 1, shareholder_name: '中国平安保险(集团)股份有限公司', holding_shares: 10168542300, total_share_ratio: 52.38, shareholder_type: '法人', share_nature: '流通A股', end_date: '2024-09-30' }, + { shareholder_rank: 2, shareholder_name: '香港中央结算有限公司(陆股通)', holding_shares: 542138600, total_share_ratio: 2.79, shareholder_type: 'QFII', share_nature: '流通A股', end_date: '2024-09-30' }, + { shareholder_rank: 3, shareholder_name: '中国平安人寿保险股份有限公司-传统-普通保险产品', holding_shares: 382456100, total_share_ratio: 1.97, shareholder_type: '保险', share_nature: '流通A股', end_date: '2024-09-30' }, + { shareholder_rank: 4, shareholder_name: '中国证券金融股份有限公司', holding_shares: 298654200, total_share_ratio: 1.54, shareholder_type: '券商', share_nature: '流通A股', end_date: '2024-09-30' }, + { shareholder_rank: 5, shareholder_name: '中央汇金资产管理有限责任公司', holding_shares: 267842100, total_share_ratio: 1.38, shareholder_type: '法人', share_nature: '流通A股', end_date: '2024-09-30' }, + { shareholder_rank: 6, shareholder_name: '全国社保基金一零三组合', holding_shares: 156234500, total_share_ratio: 0.80, shareholder_type: '社保', share_nature: '流通A股', end_date: '2024-09-30' }, + { shareholder_rank: 7, shareholder_name: '华夏上证50交易型开放式指数证券投资基金', holding_shares: 142356700, total_share_ratio: 0.73, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' }, + { shareholder_rank: 8, shareholder_name: '中国人寿保险股份有限公司-分红-个人分红-005L-FH002深', holding_shares: 128945600, total_share_ratio: 0.66, shareholder_type: '保险', share_nature: '流通A股', end_date: '2024-09-30' }, + { shareholder_rank: 9, shareholder_name: '易方达沪深300交易型开放式指数发起式证券投资基金', holding_shares: 98765400, total_share_ratio: 0.51, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' }, + { shareholder_rank: 10, shareholder_name: '嘉实沪深300交易型开放式指数证券投资基金', holding_shares: 87654300, total_share_ratio: 0.45, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' } ], - // 分支机构 + // 分支机构(字段与 BranchesPanel 组件匹配) branches: [ - { name: '北京分行', address: '北京市朝阳区建国路88号SOHO现代城', phone: '010-85806888', type: '一级分行', establish_date: '2007-03-15' }, - { name: '上海分行', address: '上海市浦东新区陆家嘴环路1366号', phone: '021-38637777', type: '一级分行', establish_date: '2007-05-20' }, - { name: '广州分行', address: '广州市天河区珠江新城珠江东路32号', phone: '020-38390888', type: '一级分行', establish_date: '2007-06-10' }, - { name: '深圳分行', address: '深圳市福田区益田路5033号', phone: '0755-82538888', type: '一级分行', establish_date: '1995-01-01' }, - { name: '杭州分行', address: '杭州市江干区钱江路1366号', phone: '0571-87028888', type: '一级分行', establish_date: '2008-09-12' }, - { name: '成都分行', address: '成都市武侯区人民南路四段13号', phone: '028-85266888', type: '一级分行', establish_date: '2009-04-25' }, - { name: '南京分行', address: '南京市建邺区江东中路359号', phone: '025-86625888', type: '一级分行', establish_date: '2010-06-30' }, - { name: '武汉分行', address: '武汉市江汉区建设大道568号', phone: '027-85712888', type: '一级分行', establish_date: '2011-08-15' }, - { name: '西安分行', address: '西安市高新区唐延路35号', phone: '029-88313888', type: '一级分行', establish_date: '2012-10-20' }, - { name: '天津分行', address: '天津市和平区南京路189号', phone: '022-23399888', type: '一级分行', establish_date: '2013-03-18' } + { branch_name: '平安银行股份有限公司北京分行', business_status: '存续', register_capital: '20亿元', legal_person: '张伟', register_date: '2007-03-15', related_company_count: 156 }, + { branch_name: '平安银行股份有限公司上海分行', business_status: '存续', register_capital: '25亿元', legal_person: '李明', register_date: '2007-05-20', related_company_count: 203 }, + { branch_name: '平安银行股份有限公司广州分行', business_status: '存续', register_capital: '18亿元', legal_person: '王芳', register_date: '2007-06-10', related_company_count: 142 }, + { branch_name: '平安银行股份有限公司深圳分行', business_status: '存续', register_capital: '30亿元', legal_person: '陈强', register_date: '1995-01-01', related_company_count: 287 }, + { branch_name: '平安银行股份有限公司杭州分行', business_status: '存续', register_capital: '15亿元', legal_person: '刘洋', register_date: '2008-09-12', related_company_count: 98 }, + { branch_name: '平安银行股份有限公司成都分行', business_status: '存续', register_capital: '12亿元', legal_person: '赵静', register_date: '2009-04-25', related_company_count: 76 }, + { branch_name: '平安银行股份有限公司南京分行', business_status: '存续', register_capital: '14亿元', legal_person: '周涛', register_date: '2010-06-30', related_company_count: 89 }, + { branch_name: '平安银行股份有限公司武汉分行', business_status: '存续', register_capital: '10亿元', legal_person: '吴磊', register_date: '2011-08-15', related_company_count: 65 }, + { branch_name: '平安银行股份有限公司西安分行', business_status: '存续', register_capital: '8亿元', legal_person: '郑华', register_date: '2012-10-20', related_company_count: 52 }, + { branch_name: '平安银行股份有限公司天津分行', business_status: '存续', register_capital: '10亿元', legal_person: '孙丽', register_date: '2013-03-18', related_company_count: 71 }, + { branch_name: '平安银行股份有限公司重庆分行', business_status: '存续', register_capital: '9亿元', legal_person: '钱峰', register_date: '2014-05-08', related_company_count: 58 }, + { branch_name: '平安银行股份有限公司苏州分行', business_status: '存续', register_capital: '6亿元', legal_person: '冯雪', register_date: '2015-07-22', related_company_count: 45 }, ], // 公告列表 announcements: [ { title: '平安银行股份有限公司2024年第三季度报告', - publish_date: '2024-10-28', - type: '定期报告', - summary: '2024年前三季度实现营业收入1245.6亿元,同比增长8.2%;净利润402.3亿元,同比增长12.5%', + announce_date: '2024-10-28', + info_type: '定期报告', + format: 'PDF', + file_size: 2580, url: '/announcement/detail/ann_20241028_001' }, { title: '关于召开2024年第一次临时股东大会的通知', - publish_date: '2024-10-15', - type: '临时公告', - summary: '定于2024年11月5日召开2024年第一次临时股东大会,审议关于调整董事会成员等议案', + announce_date: '2024-10-15', + info_type: '临时公告', + format: 'PDF', + file_size: 156, url: '/announcement/detail/ann_20241015_001' }, { title: '平安银行股份有限公司关于完成注册资本变更登记的公告', - publish_date: '2024-09-20', - type: '临时公告', - summary: '公司已完成注册资本由人民币194.06亿元变更为194.06亿元的工商变更登记手续', + announce_date: '2024-09-20', + info_type: '临时公告', + format: 'PDF', + file_size: 89, url: '/announcement/detail/ann_20240920_001' }, { title: '平安银行股份有限公司2024年半年度报告', - publish_date: '2024-08-28', - type: '定期报告', - summary: '2024年上半年实现营业收入828.5亿元,同比增长7.8%;净利润265.4亿元,同比增长11.2%', + announce_date: '2024-08-28', + info_type: '定期报告', + format: 'PDF', + file_size: 3420, url: '/announcement/detail/ann_20240828_001' }, { title: '关于2024年上半年利润分配预案的公告', - publish_date: '2024-08-20', - type: '分配方案', - summary: '拟以总股本194.06亿股为基数,向全体股东每10股派发现金红利2.8元(含税)', + announce_date: '2024-08-20', + info_type: '分配方案', + format: 'PDF', + file_size: 245, url: '/announcement/detail/ann_20240820_001' } ], // 披露时间表 disclosureSchedule: [ - { report_type: '2024年年度报告', planned_date: '2025-04-30', status: '未披露' }, - { report_type: '2024年第四季度报告', planned_date: '2025-01-31', status: '未披露' }, - { report_type: '2024年第三季度报告', planned_date: '2024-10-31', status: '已披露' }, - { report_type: '2024年半年度报告', planned_date: '2024-08-31', status: '已披露' }, - { report_type: '2024年第一季度报告', planned_date: '2024-04-30', status: '已披露' } + { report_name: '2024年年度报告', is_disclosed: false, actual_date: null, latest_scheduled_date: '2025-04-30' }, + { report_name: '2024年第四季度报告', is_disclosed: false, actual_date: null, latest_scheduled_date: '2025-01-31' }, + { report_name: '2024年第三季度报告', is_disclosed: true, actual_date: '2024-10-28', latest_scheduled_date: '2024-10-31' }, + { report_name: '2024年半年度报告', is_disclosed: true, actual_date: '2024-08-28', latest_scheduled_date: '2024-08-31' }, + { report_name: '2024年第一季度报告', is_disclosed: true, actual_date: '2024-04-28', latest_scheduled_date: '2024-04-30' } ], - // 综合分析 + // 综合分析 - 结构与组件期望格式匹配 comprehensiveAnalysis: { - overview: { - company_name: '平安银行股份有限公司', - stock_code: '000001', - industry: '银行', - established_date: '1987-12-22', - listing_date: '1991-04-03', - total_assets: 50245.6, // 亿元 - net_assets: 3256.8, - registered_capital: 194.06, - employee_count: 36542 + qualitative_analysis: { + core_positioning: { + one_line_intro: '中国领先的股份制商业银行,平安集团综合金融战略的核心载体', + // 核心特性(显示在核心定位区域下方的两个卡片) + features: [ + { + icon: 'bank', + title: '零售业务', + description: '收入占比超50%,个人客户突破1.2亿户,零售AUM 4.2万亿' + }, + { + icon: 'fire', + title: '综合金融', + description: '交叉销售和客户资源共享带来持续增长,成本趋近于零' + } + ], + // 结构化投资亮点 + investment_highlights: [ + { + icon: 'users', + title: '综合金融优势', + description: '背靠平安集团,客户资源共享和交叉销售带来持续增长动力' + }, + { + icon: 'trending-up', + title: '零售转型成效', + description: '零售业务收入占比超50%,个人客户突破1.2亿户' + }, + { + icon: 'cpu', + title: '金融科技领先', + description: 'AI、大数据、区块链等技术深化应用,运营效率持续提升' + }, + { + icon: 'shield-check', + title: '风险管理体系', + description: '不良贷款率控制在较低水平,拨备覆盖率保持充足' + } + ], + // 结构化商业模式 + business_model_sections: [ + { + title: '零售银行核心驱动', + description: '以零售银行业务为核心驱动,依托平安集团综合金融平台,构建智能化、移动化、综合化三位一体发展模式。' + }, + { + title: '科技赋能转型', + description: '通过科技赋能实现业务流程数字化,降本增效的同时提升客户体验。', + tags: ['AI应用深化', '大数据分析'] + }, + { + title: '对公业务聚焦', + description: '聚焦供应链金融和产业互联网,服务实体经济高质量发展。' + } + ], + // 兼容旧数据格式 + investment_highlights_text: '1. 零售AUM 4.2万亿、抵押贷占比63%,低不良+高拨备形成稀缺安全垫\n2. 背靠平安集团,保险-银行-投资生态协同,交叉销售成本趋近于零\n3. 战略收缩高风险消费贷、发力科技/绿色/普惠"五篇大文章",资产重构带来息差与估值双升期权', + business_model_desc: '以零售金融为压舱石,通过按揭、私行财富、信用卡获取低成本负债;对公金融做精行业赛道,输出供应链金融与跨境金融解决方案;同业金融做专投资交易,赚取做市与波段收益。' + }, + strategy: { + strategy_description: '以"零售做强、对公做精、同业做专"为主线,通过压降高风险资产、深耕科技绿色普惠、强化集团协同,实现轻资本、弱周期、高股息的高质量增长。', + strategic_initiatives: '2025年AI 138个项目落地,构建智能风控、智能投顾与智能运营,目标3年降低单位成本10%以上;发行800亿元资本债,用于置换存量高成本次级债并支持科技绿色贷款扩张,目标2026年科技绿色贷款占比提升至15%' + } }, - financial_highlights: { - revenue: 1623.5, - revenue_growth: 8.5, - net_profit: 528.6, - profit_growth: 12.3, - roe: 16.23, - roa: 1.05, - asset_quality_ratio: 1.02, - capital_adequacy_ratio: 13.45, - core_tier1_ratio: 10.82 + competitive_position: { + ranking: { + industry_rank: 6, + total_companies: 42 + }, + analysis: { + main_competitors: '招商银行、兴业银行、中信银行、浦发银行、民生银行', + competitive_advantages: '1. 综合金融优势:依托平安集团综合金融平台,实现银行、保险、投资等业务协同\n2. 科技创新领先:金融科技投入占营收比重行业领先,AI、大数据应用成熟\n3. 零售客户基础雄厚:个人客户1.2亿+,财富管理AUM持续增长\n4. 品牌认知度高:平安品牌具有较强的公众认知度和信任度', + competitive_disadvantages: '1. 网点覆盖不如国有大行,在县域地区布局相对薄弱\n2. 对公业务规模与头部股份制银行存在差距\n3. 存款成本相对较高,息差空间受到一定压制' + }, + scores: { + market_position: 82, + technology: 90, + brand: 85, + operation: 83, + finance: 86, + innovation: 92, + risk: 84, + growth: 80 + } }, business_structure: [ - { business: '对公业务', revenue: 685.4, ratio: 42.2, growth: 6.8 }, - { business: '零售业务', revenue: 812.3, ratio: 50.1, growth: 11.2 }, - { business: '金融市场业务', revenue: 125.8, ratio: 7.7, growth: 3.5 } + { + business_name: '舒泰清(复方聚乙二醇电解质散IV)', + business_level: 1, + revenue: 17900, + revenue_unit: '万元', + financial_metrics: { + revenue_ratio: 55.16, + gross_margin: 78.21 + }, + growth_metrics: { + revenue_growth: -8.20 + }, + report_period: '2024年报' + }, + { + business_name: '苏肽生(注射用鼠神经生长因子)', + business_level: 1, + revenue: 13400, + revenue_unit: '万元', + financial_metrics: { + revenue_ratio: 41.21, + gross_margin: 89.11 + }, + growth_metrics: { + revenue_growth: -17.30 + }, + report_period: '2024年报' + }, + { + business_name: '舒斯通(复方聚乙二醇(3350)电解质散)', + business_level: 1, + revenue: 771, + revenue_unit: '万元', + financial_metrics: { + revenue_ratio: 2.37 + }, + report_period: '2024年报' + }, + { + business_name: '阿司匹林肠溶片', + business_level: 1, + revenue: 396, + revenue_unit: '万元', + financial_metrics: { + revenue_ratio: 1.22 + }, + report_period: '2024年报' + }, + { + business_name: '研发业务', + business_level: 1, + report_period: '2024年报' + } ], - competitive_advantages: [ - '背靠中国平安集团,综合金融优势明显', - '零售业务转型成效显著,客户基础雄厚', - '金融科技创新能力强,数字化银行建设领先', - '风险管理体系完善,资产质量稳定', - '管理团队经验丰富,执行力强' - ], - risk_factors: [ - '宏观经济下行压力影响信贷质量', - '利率市场化导致息差收窄', - '金融监管趋严,合规成本上升', - '同业竞争激烈,市场份额面临挑战', - '金融科技发展带来的技术和运营风险' - ], - development_strategy: '坚持"科技引领、零售突破、对公做精"战略,加快数字化转型,提升综合金融服务能力', - analyst_rating: { - buy: 18, - hold: 12, - sell: 2, - target_price: 15.8, - current_price: 13.2 - } - }, - - // 价值链分析 - valueChainAnalysis: { - upstream: [ - { name: '央行及监管机构', relationship: '政策与监管', importance: '高', description: '接受货币政策调控和监管指导' }, - { name: '同业资金市场', relationship: '资金来源', importance: '高', description: '开展同业拆借、债券回购等业务' }, - { name: '金融科技公司', relationship: '技术支持', importance: '中', description: '提供金融科技解决方案和技术服务' } - ], - core_business: { - deposit_business: { scale: 33256.8, market_share: 2.8, growth_rate: 9.2 }, - loan_business: { scale: 28945.3, market_share: 2.5, growth_rate: 12.5 }, - intermediary_business: { scale: 425.6, market_share: 3.2, growth_rate: 15.8 }, - digital_banking: { user_count: 11256, app_mau: 4235, growth_rate: 28.5 } - }, - downstream: [ - { name: '个人客户', scale: '1.12亿户', contribution: '50.1%', description: '零售银行业务主体' }, - { name: '企业客户', scale: '85.6万户', contribution: '42.2%', description: '对公业务主体' }, - { name: '政府机构', scale: '2.3万户', contribution: '7.7%', description: '公共事业及政府业务' } - ], - ecosystem_partners: [ - { name: '中国平安集团', type: '关联方', cooperation: '综合金融服务、客户共享' }, - { name: '平安科技', type: '科技支持', cooperation: '金融科技研发、系统建设' }, - { name: '平安普惠', type: '业务协同', cooperation: '普惠金融、小微贷款' }, - { name: '平安证券', type: '业务协同', cooperation: '投资银行、资产管理' } + business_segments: [ + { + segment_name: '已上市药品营销', + segment_description: '舒泰神已上市药品营销业务主要包括舒泰清(复方聚乙二醇电解质散IV)和苏肽生(注射用鼠神经生长因子)两大核心产品。2024年实现营业收入3.25亿元,其中舒泰清贡献1.79亿元(55.16%),苏肽生贡献1.34亿元(41.21%)。尽管面临市场竞争压力,产品毛利率保持高位,综合毛利率达80.83%,其中苏肽生毛利率高达89.11%。', + competitive_position: '舒泰清为《中国消化内镜诊疗肠道准备指南》和《慢性便秘诊治指南》一线用药,苏肽生是国内首个国药准字鼠神经生长因子产品。公司医保目录产品舒斯通已落地,并布局舒亦清、舒常轻等系列产品形成梯队,构建了一定市场竞争优势。然而,2024年集采中同类(III型)产品中选,对舒泰清(IV型)形成潜在价格压力。', + future_potential: '公司正在构建系列化产品线应对市场变化,研发投入保持高强度(1.62亿元,占营收49.97%)。在研管线中,STSP-0601血友病药物获FDA孤儿药资格,BDB-001被纳入突破性治疗品种,创新药研发持续推进。国家政策支持创新药发展,行业环境向好,同时国际化布局已有初步进展,未来3-5年有望通过新产品上市实现业绩突破。' + } ] }, - // 关键因素时间线 - keyFactorsTimeline: [ - { - date: '2024-10-28', - event: '发布2024年三季报', - type: '业绩公告', - importance: 'high', - impact: '前三季度净利润同比增长12.5%,超市场预期', - change: '+5.2%' + // 价值链分析 - 结构与组件期望格式匹配 + valueChainAnalysis: { + value_chain_flows: [ + // 上游第2级 → 上游第1级 + { + source: { node_name: '中国人民银行', node_level: -2 }, + target: { node_name: '同业市场', node_level: -1 }, + flow_metrics: { flow_ratio: 35 } + }, + { + source: { node_name: '银保监会', node_level: -2 }, + target: { node_name: '同业市场', node_level: -1 }, + flow_metrics: { flow_ratio: 25 } + }, + { + source: { node_name: '中国人民银行', node_level: -2 }, + target: { node_name: '债券市场', node_level: -1 }, + flow_metrics: { flow_ratio: 30 } + }, + // 上游第1级 → 核心企业 + { + source: { node_name: '同业市场', node_level: -1 }, + target: { node_name: '平安银行', node_level: 0 }, + flow_metrics: { flow_ratio: 40 } + }, + { + source: { node_name: '债券市场', node_level: -1 }, + target: { node_name: '平安银行', node_level: 0 }, + flow_metrics: { flow_ratio: 25 } + }, + { + source: { node_name: '平安集团', node_level: -1 }, + target: { node_name: '平安银行', node_level: 0 }, + flow_metrics: { flow_ratio: 20 } + }, + { + source: { node_name: '金融科技供应商', node_level: -1 }, + target: { node_name: '平安银行', node_level: 0 }, + flow_metrics: { flow_ratio: 15 } + }, + // 核心企业 → 下游第1级 + { + source: { node_name: '平安银行', node_level: 0 }, + target: { node_name: '个人客户', node_level: 1 }, + flow_metrics: { flow_ratio: 50 } + }, + { + source: { node_name: '平安银行', node_level: 0 }, + target: { node_name: '企业客户', node_level: 1 }, + flow_metrics: { flow_ratio: 35 } + }, + { + source: { node_name: '平安银行', node_level: 0 }, + target: { node_name: '政府机构', node_level: 1 }, + flow_metrics: { flow_ratio: 10 } + }, + { + source: { node_name: '平安银行', node_level: 0 }, + target: { node_name: '金融同业', node_level: 1 }, + flow_metrics: { flow_ratio: 5 } + }, + // 下游第1级 → 下游第2级 + { + source: { node_name: '个人客户', node_level: 1 }, + target: { node_name: '消费场景', node_level: 2 }, + flow_metrics: { flow_ratio: 60 } + }, + { + source: { node_name: '企业客户', node_level: 1 }, + target: { node_name: '产业链', node_level: 2 }, + flow_metrics: { flow_ratio: 70 } + }, + { + source: { node_name: '政府机构', node_level: 1 }, + target: { node_name: '公共服务', node_level: 2 }, + flow_metrics: { flow_ratio: 80 } + }, + { + source: { node_name: '个人客户', node_level: 1 }, + target: { node_name: '产业链', node_level: 2 }, + flow_metrics: { flow_ratio: 20 } + }, + { + source: { node_name: '企业客户', node_level: 1 }, + target: { node_name: '公共服务', node_level: 2 }, + flow_metrics: { flow_ratio: 15 } + } + ], + value_chain_structure: { + nodes_by_level: { + 'level_-2': [ + { + node_name: '中国人民银行', + node_type: 'regulator', + node_description: '制定货币政策,维护金融稳定,是银行业的最高监管机构', + node_level: -2, + importance_score: 95, + market_share: null, + dependency_degree: 100 + }, + { + node_name: '银保监会', + node_type: 'regulator', + node_description: '负责银行业和保险业的监督管理,制定行业规范', + node_level: -2, + importance_score: 90, + market_share: null, + dependency_degree: 95 + } + ], + 'level_-1': [ + { + node_name: '同业市场', + node_type: 'supplier', + node_description: '银行间资金拆借市场,提供短期流动性支持', + node_level: -1, + importance_score: 85, + market_share: 12.5, + dependency_degree: 75 + }, + { + node_name: '债券市场', + node_type: 'supplier', + node_description: '债券发行与交易市场,银行重要融资渠道', + node_level: -1, + importance_score: 80, + market_share: 8.2, + dependency_degree: 60 + }, + { + node_name: '平安集团', + node_type: 'supplier', + node_description: '控股股东,提供综合金融平台支撑和客户资源共享', + node_level: -1, + importance_score: 92, + market_share: 100, + dependency_degree: 85 + }, + { + node_name: '金融科技供应商', + node_type: 'supplier', + node_description: '提供核心系统、云服务、AI等技术支持', + node_level: -1, + importance_score: 75, + market_share: 15.0, + dependency_degree: 55 + } + ], + 'level_0': [ + { + node_name: '平安银行', + node_type: 'company', + node_description: '全国性股份制商业银行,零售银行转型标杆,科技驱动战略引领者', + node_level: 0, + importance_score: 100, + market_share: 2.8, + dependency_degree: 0, + is_core: true + } + ], + 'level_1': [ + { + node_name: '个人客户', + node_type: 'customer', + node_description: '零售银行服务对象,超1.2亿户,涵盖储蓄、信用卡、消费贷等业务', + node_level: 1, + importance_score: 88, + market_share: 3.5, + dependency_degree: 45 + }, + { + node_name: '企业客户', + node_type: 'customer', + node_description: '对公金融服务对象,超90万户,包括大型企业、中小微企业', + node_level: 1, + importance_score: 82, + market_share: 2.1, + dependency_degree: 40 + }, + { + node_name: '政府机构', + node_type: 'customer', + node_description: '政务金融服务对象,提供财政资金管理、政务支付等服务', + node_level: 1, + importance_score: 70, + market_share: 1.8, + dependency_degree: 25 + }, + { + node_name: '金融同业', + node_type: 'customer', + node_description: '同业金融服务对象,包括其他银行、保险、基金等金融机构', + node_level: 1, + importance_score: 65, + market_share: 2.5, + dependency_degree: 20 + } + ], + 'level_2': [ + { + node_name: '消费场景', + node_type: 'end_user', + node_description: '个人消费支付场景,包括电商、餐饮、出行、娱乐等日常消费', + node_level: 2, + importance_score: 72, + market_share: 4.2, + dependency_degree: 30 + }, + { + node_name: '产业链', + node_type: 'end_user', + node_description: '企业生产经营场景,覆盖采购、生产、销售全链条金融服务', + node_level: 2, + importance_score: 78, + market_share: 2.8, + dependency_degree: 35 + }, + { + node_name: '公共服务', + node_type: 'end_user', + node_description: '政务公共服务场景,包括社保、医疗、教育等民生领域', + node_level: 2, + importance_score: 68, + market_share: 1.5, + dependency_degree: 20 + } + ] + } }, - { - date: '2024-09-15', - event: '推出AI智能客服系统', - type: '科技创新', - importance: 'medium', - impact: '提升客户服务效率,降低运营成本', - change: '+2.1%' - }, - { - date: '2024-08-28', - event: '发布2024年中报', - type: '业绩公告', - importance: 'high', - impact: '上半年净利润增长11.2%,资产质量保持稳定', - change: '+3.8%' - }, - { - date: '2024-07-20', - event: '获批设立理财子公司', - type: '业务拓展', - importance: 'high', - impact: '完善财富管理业务布局,拓展收入来源', - change: '+4.5%' - }, - { - date: '2024-06-10', - event: '完成300亿元二级资本债发行', - type: '融资事件', - importance: 'medium', - impact: '补充资本实力,支持业务扩张', - change: '+1.8%' - }, - { - date: '2024-04-30', - event: '发布2024年一季报', - type: '业绩公告', - importance: 'high', - impact: '一季度净利润增长10.8%,开门红表现优异', - change: '+4.2%' - }, - { - date: '2024-03-15', - event: '零售客户突破1.1亿户', - type: '业务里程碑', - importance: 'medium', - impact: '零售转型成效显著,客户基础进一步夯实', - change: '+2.5%' - }, - { - date: '2024-01-20', - event: '获评"2023年度最佳零售银行"', - type: '荣誉奖项', - importance: 'low', - impact: '品牌影响力提升', - change: '+0.8%' + analysis_summary: { + upstream_nodes: 6, + company_nodes: 1, + downstream_nodes: 7, + total_nodes: 14, + key_insights: '平安银行处于金融产业链核心位置,上游依托央行政策和集团资源,下游服务广泛的个人和企业客户群体' } - ], + }, + + // 关键因素时间线 - 结构与组件期望格式匹配 + keyFactorsTimeline: { + key_factors: { + total_factors: 5, + categories: [ + { + category_name: '正面因素', + category_type: 'positive', + factors: [ + { + factor_name: '零售转型深化', + impact_score: 9.2, + description: '零售业务收入占比持续提升,已超过50%,客户基础和AUM稳步增长', + trend: 'improving' + }, + { + factor_name: '金融科技领先', + impact_score: 8.8, + description: 'AI、大数据等技术应用深化,智能化转型成效显著', + trend: 'stable' + }, + { + factor_name: '资产质量稳定', + impact_score: 8.5, + description: '不良贷款率控制在较低水平,风险抵御能力强', + trend: 'stable' + } + ] + }, + { + category_name: '负面因素', + category_type: 'negative', + factors: [ + { + factor_name: '息差压力', + impact_score: 6.5, + description: '利率市场化持续推进,净息差面临收窄压力', + trend: 'declining' + } + ] + }, + { + category_name: '中性因素', + category_type: 'neutral', + factors: [ + { + factor_name: '监管趋严', + impact_score: 7.0, + description: '金融监管持续强化,合规成本有所上升', + trend: 'stable' + } + ] + } + ] + }, + development_timeline: { + statistics: { + positive_events: 10, + negative_events: 3, + neutral_events: 2 + }, + events: [ + { + event_date: '2024-11-15', + event_title: '获评"最佳零售银行"称号', + event_type: '荣誉奖项', + event_desc: '在《亚洲银行家》评选中荣获"中国最佳零售银行"称号,零售转型战略获行业高度认可', + importance: 'high', + impact_metrics: { + impact_score: 75, + is_positive: true + }, + related_info: { + financial_impact: '品牌价值提升,预计带动零售业务增长2-3%' + } + }, + { + event_date: '2024-10-28', + event_title: '发布2024年三季报', + event_type: '业绩公告', + event_desc: '前三季度实现净利润412.8亿元,同比增长12.5%,超市场预期。零售业务收入占比提升至52.3%', + importance: 'high', + impact_metrics: { + impact_score: 88, + is_positive: true + }, + related_info: { + financial_impact: '股价当日上涨5.2%,市值增加约150亿元' + } + }, + { + event_date: '2024-09-20', + event_title: '房地产贷款风险暴露', + event_type: '风险事件', + event_desc: '部分房地产开发贷款出现逾期,计提减值准备约25亿元,不良贷款率小幅上升', + importance: 'high', + impact_metrics: { + impact_score: 65, + is_positive: false + }, + related_info: { + financial_impact: '影响当期利润约18亿元,股价下跌2.8%' + } + }, + { + event_date: '2024-09-15', + event_title: '推出AI智能客服系统3.0', + event_type: '科技创新', + event_desc: '新一代AI客服系统上线,集成大语言模型技术,客服效率提升40%,客户满意度达95%', + importance: 'medium', + impact_metrics: { + impact_score: 72, + is_positive: true + }, + related_info: { + financial_impact: '预计年化降低运营成本约3亿元' + } + }, + { + event_date: '2024-08-28', + event_title: '发布2024年中报', + event_type: '业绩公告', + event_desc: '上半年净利润增长11.2%,达275.6亿元。资产质量保持稳定,不良贷款率1.05%', + importance: 'high', + impact_metrics: { + impact_score: 82, + is_positive: true + }, + related_info: { + financial_impact: '股价累计上涨3.8%' + } + }, + { + event_date: '2024-07-20', + event_title: '平安理财获批新产品资质', + event_type: '业务拓展', + event_desc: '旗下平安理财获批养老理财产品试点资格,成为首批获批的股份制银行理财子公司', + importance: 'high', + impact_metrics: { + impact_score: 78, + is_positive: true + }, + related_info: { + financial_impact: '预计为AUM贡献500-800亿元增量' + } + }, + { + event_date: '2024-06-25', + event_title: '监管处罚通知', + event_type: '合规事件', + event_desc: '因贷款业务违规被银保监会罚款1200万元,涉及信贷资金违规流入房地产领域', + importance: 'medium', + impact_metrics: { + impact_score: 45, + is_positive: false + }, + related_info: { + financial_impact: '罚款金额对业绩影响有限,但需加强合规管理' + } + }, + { + event_date: '2024-06-10', + event_title: '完成300亿元二级资本债发行', + event_type: '融资事件', + event_desc: '成功发行300亿元二级资本债券,票面利率3.15%,认购倍数达2.8倍,市场反响良好', + importance: 'medium', + impact_metrics: { + impact_score: 68, + is_positive: true + }, + related_info: { + financial_impact: '资本充足率提升0.35个百分点至13.2%' + } + }, + { + event_date: '2024-05-18', + event_title: '与腾讯云达成战略合作', + event_type: '战略合作', + event_desc: '与腾讯云签署战略合作协议,在云计算、大数据、人工智能等领域开展深度合作', + importance: 'medium', + impact_metrics: { + impact_score: 70, + is_positive: true + }, + related_info: { + financial_impact: '预计3年内科技投入效率提升20%' + } + }, + { + event_date: '2024-04-30', + event_title: '发布2024年一季报', + event_type: '业绩公告', + event_desc: '一季度净利润增长10.8%,达138.2亿元。信用卡业务表现亮眼,交易额同比增长18%', + importance: 'high', + impact_metrics: { + impact_score: 80, + is_positive: true + }, + related_info: { + financial_impact: '开门红业绩推动股价上涨4.2%' + } + }, + { + event_date: '2024-03-28', + event_title: '高管层人事变动', + event_type: '人事变动', + event_desc: '副行长郭世邦因个人原因辞职,对公业务主管暂由行长冀光恒兼任', + importance: 'medium', + impact_metrics: { + impact_score: 52, + is_positive: false + }, + related_info: { + financial_impact: '短期内对公业务战略执行或受影响' + } + }, + { + event_date: '2024-03-15', + event_title: '零售客户突破1.2亿户', + event_type: '业务里程碑', + event_desc: '零售客户数量突破1.2亿户大关,较年初净增800万户,私行客户AUM突破1.5万亿', + importance: 'high', + impact_metrics: { + impact_score: 85, + is_positive: true + }, + related_info: { + financial_impact: '零售转型成效显著,客户基础进一步夯实' + } + }, + { + event_date: '2024-02-05', + event_title: '发布2023年年报', + event_type: '业绩公告', + event_desc: '2023年全年净利润464.5亿元,同比增长2.1%。拨备覆盖率277%,资产质量稳健', + importance: 'high', + impact_metrics: { + impact_score: 75, + is_positive: true + }, + related_info: { + financial_impact: '分红方案:每股派息0.28元,股息率约4.2%' + } + }, + { + event_date: '2024-01-10', + event_title: '供应链金融平台升级', + event_type: '产品创新', + event_desc: '供应链金融平台完成4.0版本升级,新增区块链存证、智能风控等功能,服务企业超3.5万家', + importance: 'medium', + impact_metrics: { + impact_score: 72, + is_positive: true + }, + related_info: { + financial_impact: '供应链金融余额预计增长25%' + } + }, + { + event_date: '2023-12-20', + event_title: '获批设立香港分行', + event_type: '业务拓展', + event_desc: '获中国银保监会批准设立香港分行,标志着国际化战略迈出重要一步', + importance: 'high', + impact_metrics: { + impact_score: 78, + is_positive: true + }, + related_info: { + financial_impact: '预计为跨境业务带来新增长点' + } + } + ] + } + }, // 盈利预测报告 forecastReport: { - // 营收与利润趋势 income_profit_trend: { years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'], - income: [116524, 134632, 148956, 162350, 175280, 189450, 204120], // 营业总收入(百万元) - profit: [34562, 39845, 43218, 52860, 58420, 64680, 71250] // 归母净利润(百万元) + income: [116524, 134632, 148956, 162350, 175280, 189450, 204120], + profit: [34562, 39845, 43218, 52860, 58420, 64680, 71250] }, - // 增长率分析 growth_bars: { years: ['2021', '2022', '2023', '2024E', '2025E', '2026E'], - revenue_growth_pct: [15.5, 10.6, 8.9, 8.0, 8.1, 7.7] // 营收增长率(%) + revenue_growth_pct: [15.5, 10.6, 8.9, 8.0, 8.1, 7.7] }, - // EPS趋势 eps_trend: { years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'], - eps: [1.78, 2.05, 2.23, 2.72, 3.01, 3.33, 3.67] // EPS(稀释,元/股) + eps: [1.78, 2.05, 2.23, 2.72, 3.01, 3.33, 3.67] }, - // PE与PEG分析 pe_peg_axes: { years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'], - pe: [7.4, 6.9, 7.2, 4.9, 4.4, 4.0, 3.6], // PE(倍) - peg: [0.48, 0.65, 0.81, 0.55, 0.55, 0.49, 0.47] // PEG + pe: [7.4, 6.9, 7.2, 4.9, 4.4, 4.0, 3.6], + peg: [0.48, 0.65, 0.81, 0.55, 0.55, 0.49, 0.47] }, - // 详细数据表格 detail_table: { years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'], rows: [ @@ -397,137 +1156,354 @@ export const PINGAN_BANK_DATA = { }; // 生成通用公司数据的工具函数 -export const generateCompanyData = (stockCode, stockName) => { +export const generateCompanyData = (stockCode, stockName = '示例公司') => { // 如果是平安银行,直接返回详细数据 if (stockCode === '000001') { return PINGAN_BANK_DATA; } - // 否则生成通用数据 + // 随机生成一些基础数值 + const baseRevenue = Math.floor(Math.random() * 50000) + 10000; + const baseProfit = Math.floor(Math.random() * 5000) + 1000; + const employeeCount = Math.floor(Math.random() * 20000) + 1000; + + // 生成通用数据,结构与组件期望格式匹配 return { stockCode, stockName, basicInfo: { - code: stockCode, - name: stockName, - registered_capital: Math.floor(Math.random() * 500000) + 10000, - registered_capital_unit: '万元', + SECCODE: stockCode, + SECNAME: stockName, + ORGNAME: `${stockName}股份有限公司`, + english_name: `${stockName} Co., Ltd.`, + reg_capital: Math.floor(Math.random() * 500000) + 10000, legal_representative: '张三', + chairman: '张三', general_manager: '李四', secretary: '王五', - registered_address: '中国某省某市某区某路123号', - office_address: '中国某省某市某区某路123号', - phone: '021-12345678', + reg_address: '中国某省某市某区某路123号', + office_address: '中国某省某市某区某路456号', + zipcode: '100000', + tel: '010-12345678', + fax: '010-12345679', email: 'ir@company.com', website: 'http://www.company.com', - employees: Math.floor(Math.random() * 10000) + 1000, - list_date: '2010-01-01', - industry: '制造业', + sw_industry_l1: '制造业', + sw_industry_l2: '电子设备', + sw_industry_l3: '消费电子', + establish_date: '2005-01-01', + list_date: '2010-06-15', + province: '广东省', + city: '深圳市', + credit_code: '91440300XXXXXXXXXX', + company_size: '中型企业', + accounting_firm: '安永华明会计师事务所', + law_firm: '北京市君合律师事务所', + company_intro: `${stockName}股份有限公司是一家专注于XX领域的高科技企业,致力于为客户提供优质的产品和服务。公司拥有完善的研发体系和生产能力,在行业内具有较强的竞争力。`, + main_business: '电子产品的研发、生产和销售', + business_scope: '电子产品、通信设备、计算机软硬件的研发、生产、销售;技术咨询、技术服务;货物进出口、技术进出口。', + employees: employeeCount, }, - actualControl: { - controller_name: '某控股集团有限公司', - controller_type: '企业', - shareholding_ratio: 35.5, - control_chain: '某控股集团有限公司 -> ' + stockName, - }, - concentration: { - top1_ratio: 35.5, - top3_ratio: 52.3, - top5_ratio: 61.8, - top10_ratio: 72.5, - concentration_level: '适度集中', - }, - management: [ - { name: '张三', position: '董事长', gender: '男', age: 55, education: '硕士', annual_compensation: 320.5 }, - { name: '李四', position: '总经理', gender: '男', age: 50, education: '硕士', annual_compensation: 280.3 }, - { name: '王五', position: '董事会秘书', gender: '女', age: 45, education: '本科', annual_compensation: 180.2 }, + actualControl: [ + { + actual_controller_name: '某控股集团有限公司', + controller_name: '某控股集团有限公司', + control_type: '企业法人', + controller_type: '企业', + holding_ratio: 35.5, + holding_shares: 1560000000, + end_date: '2024-09-30', + control_chain: `某控股集团有限公司 -> ${stockName}股份有限公司`, + is_listed: false, + } + ], + concentration: [ + { stat_item: '前1大股东', holding_ratio: 35.5, ratio_change: 0.12, end_date: '2024-09-30' }, + { stat_item: '前3大股东', holding_ratio: 52.3, ratio_change: 0.25, end_date: '2024-09-30' }, + { stat_item: '前5大股东', holding_ratio: 61.8, ratio_change: 0.18, end_date: '2024-09-30' }, + { stat_item: '前10大股东', holding_ratio: 72.5, ratio_change: 0.32, end_date: '2024-09-30' }, + { stat_item: '前1大股东', holding_ratio: 35.38, ratio_change: -0.08, end_date: '2024-06-30' }, + { stat_item: '前3大股东', holding_ratio: 52.05, ratio_change: -0.15, end_date: '2024-06-30' }, + { stat_item: '前5大股东', holding_ratio: 61.62, ratio_change: -0.10, end_date: '2024-06-30' }, + { stat_item: '前10大股东', holding_ratio: 72.18, ratio_change: -0.20, end_date: '2024-06-30' }, + ], + management: [ + // 高管 + { name: '张三', position_name: '董事长', position_category: '高管', gender: '男', birth_year: '1969', education: '硕士', nationality: '中国', start_date: '2018-06-01', status: 'active' }, + { name: '李四', position_name: '总经理', position_category: '高管', gender: '男', birth_year: '1974', education: '硕士', nationality: '中国', start_date: '2019-03-15', status: 'active' }, + { name: '王五', position_name: '董事会秘书', position_category: '高管', gender: '女', birth_year: '1979', education: '本科', nationality: '中国', start_date: '2020-01-10', status: 'active' }, + { name: '赵六', position_name: '财务总监', position_category: '高管', gender: '男', birth_year: '1976', education: '硕士', nationality: '中国', start_date: '2017-09-01', status: 'active' }, + { name: '钱七', position_name: '技术总监', position_category: '高管', gender: '男', birth_year: '1982', education: '博士', nationality: '中国', start_date: '2021-06-01', status: 'active' }, + // 董事 + { name: '孙八', position_name: '非执行董事', position_category: '董事', gender: '男', birth_year: '1965', education: '博士', nationality: '中国', start_date: '2016-06-15', status: 'active' }, + { name: '周九', position_name: '非执行董事', position_category: '董事', gender: '男', birth_year: '1968', education: '硕士', nationality: '中国', start_date: '2018-06-20', status: 'active' }, + { name: '吴十', position_name: '独立董事', position_category: '董事', gender: '女', birth_year: '1972', education: '博士', nationality: '美国', start_date: '2019-06-18', status: 'active' }, + { name: '郑十一', position_name: '独立董事', position_category: '董事', gender: '男', birth_year: '1970', education: '博士', nationality: '中国', start_date: '2020-06-25', status: 'active' }, + // 监事 + { name: '冯十二', position_name: '监事会主席', position_category: '监事', gender: '男', birth_year: '1967', education: '硕士', nationality: '中国', start_date: '2017-06-15', status: 'active' }, + { name: '陈十三', position_name: '职工监事', position_category: '监事', gender: '女', birth_year: '1975', education: '本科', nationality: '中国', start_date: '2019-06-20', status: 'active' }, + { name: '楚十四', position_name: '外部监事', position_category: '监事', gender: '男', birth_year: '1971', education: '硕士', nationality: '中国', start_date: '2020-06-18', status: 'active' }, + // 其他 + { name: '卫十五', position_name: '合规负责人', position_category: '其他', gender: '男', birth_year: '1978', education: '硕士', nationality: '中国', start_date: '2018-09-01', status: 'active' }, + { name: '蒋十六', position_name: '内审部负责人', position_category: '其他', gender: '女', birth_year: '1980', education: '硕士', nationality: '中国', start_date: '2019-03-15', status: 'active' }, + ], + topCirculationShareholders: [ + { shareholder_rank: 1, shareholder_name: '某控股集团有限公司', holding_shares: 560000000, circulation_share_ratio: 35.50, shareholder_type: '法人', end_date: '2024-09-30' }, + { shareholder_rank: 2, shareholder_name: '香港中央结算有限公司(陆股通)', holding_shares: 156000000, circulation_share_ratio: 9.88, shareholder_type: 'QFII', end_date: '2024-09-30' }, + { shareholder_rank: 3, shareholder_name: '中国平安人寿保险股份有限公司-传统-普通保险产品', holding_shares: 89000000, circulation_share_ratio: 5.64, shareholder_type: '保险', end_date: '2024-09-30' }, + { shareholder_rank: 4, shareholder_name: '中国证券金融股份有限公司', holding_shares: 67000000, circulation_share_ratio: 4.24, shareholder_type: '券商', end_date: '2024-09-30' }, + { shareholder_rank: 5, shareholder_name: '中央汇金资产管理有限责任公司', holding_shares: 45000000, circulation_share_ratio: 2.85, shareholder_type: '法人', end_date: '2024-09-30' }, + { shareholder_rank: 6, shareholder_name: '全国社保基金一零三组合', holding_shares: 34000000, circulation_share_ratio: 2.15, shareholder_type: '社保', end_date: '2024-09-30' }, + { shareholder_rank: 7, shareholder_name: '华夏上证50交易型开放式指数证券投资基金', holding_shares: 28000000, circulation_share_ratio: 1.77, shareholder_type: '基金', end_date: '2024-09-30' }, + { shareholder_rank: 8, shareholder_name: '中国人寿保险股份有限公司-分红-个人分红-005L-FH002深', holding_shares: 23000000, circulation_share_ratio: 1.46, shareholder_type: '保险', end_date: '2024-09-30' }, + { shareholder_rank: 9, shareholder_name: '易方达沪深300交易型开放式指数发起式证券投资基金', holding_shares: 19000000, circulation_share_ratio: 1.20, shareholder_type: '基金', end_date: '2024-09-30' }, + { shareholder_rank: 10, shareholder_name: '嘉实沪深300交易型开放式指数证券投资基金', holding_shares: 15000000, circulation_share_ratio: 0.95, shareholder_type: '基金', end_date: '2024-09-30' } + ], + topShareholders: [ + { shareholder_rank: 1, shareholder_name: '某控股集团有限公司', holding_shares: 560000000, total_share_ratio: 35.50, shareholder_type: '法人', share_nature: '流通A股', end_date: '2024-09-30' }, + { shareholder_rank: 2, shareholder_name: '香港中央结算有限公司(陆股通)', holding_shares: 156000000, total_share_ratio: 9.88, shareholder_type: 'QFII', share_nature: '流通A股', end_date: '2024-09-30' }, + { shareholder_rank: 3, shareholder_name: '中国平安人寿保险股份有限公司-传统-普通保险产品', holding_shares: 89000000, total_share_ratio: 5.64, shareholder_type: '保险', share_nature: '流通A股', end_date: '2024-09-30' }, + { shareholder_rank: 4, shareholder_name: '中国证券金融股份有限公司', holding_shares: 67000000, total_share_ratio: 4.24, shareholder_type: '券商', share_nature: '流通A股', end_date: '2024-09-30' }, + { shareholder_rank: 5, shareholder_name: '中央汇金资产管理有限责任公司', holding_shares: 45000000, total_share_ratio: 2.85, shareholder_type: '法人', share_nature: '流通A股', end_date: '2024-09-30' }, + { shareholder_rank: 6, shareholder_name: '全国社保基金一零三组合', holding_shares: 34000000, total_share_ratio: 2.15, shareholder_type: '社保', share_nature: '流通A股', end_date: '2024-09-30' }, + { shareholder_rank: 7, shareholder_name: '华夏上证50交易型开放式指数证券投资基金', holding_shares: 28000000, total_share_ratio: 1.77, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' }, + { shareholder_rank: 8, shareholder_name: '中国人寿保险股份有限公司-分红-个人分红-005L-FH002深', holding_shares: 23000000, total_share_ratio: 1.46, shareholder_type: '保险', share_nature: '流通A股', end_date: '2024-09-30' }, + { shareholder_rank: 9, shareholder_name: '易方达沪深300交易型开放式指数发起式证券投资基金', holding_shares: 19000000, total_share_ratio: 1.20, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' }, + { shareholder_rank: 10, shareholder_name: '嘉实沪深300交易型开放式指数证券投资基金', holding_shares: 15000000, total_share_ratio: 0.95, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' } ], - topCirculationShareholders: Array(10).fill(null).map((_, i) => ({ - shareholder_name: `股东${i + 1}`, - shares: Math.floor(Math.random() * 100000000), - ratio: (10 - i) * 0.8, - change: Math.floor(Math.random() * 10000000) - 5000000, - shareholder_type: '企业' - })), - topShareholders: Array(10).fill(null).map((_, i) => ({ - shareholder_name: `股东${i + 1}`, - shares: Math.floor(Math.random() * 100000000), - ratio: (10 - i) * 0.8, - change: Math.floor(Math.random() * 10000000) - 5000000, - shareholder_type: '企业', - is_restricted: false - })), branches: [ - { name: '北京分公司', address: '北京市朝阳区某路123号', phone: '010-12345678', type: '分公司' }, - { name: '上海分公司', address: '上海市浦东新区某路456号', phone: '021-12345678', type: '分公司' }, + { branch_name: `${stockName}北京分公司`, business_status: '存续', register_capital: '5000万元', legal_person: '张伟', register_date: '2012-05-01', related_company_count: 23 }, + { branch_name: `${stockName}上海分公司`, business_status: '存续', register_capital: '8000万元', legal_person: '李明', register_date: '2013-08-15', related_company_count: 35 }, + { branch_name: `${stockName}广州分公司`, business_status: '存续', register_capital: '3000万元', legal_person: '王芳', register_date: '2014-03-20', related_company_count: 18 }, + { branch_name: `${stockName}深圳分公司`, business_status: '存续', register_capital: '6000万元', legal_person: '陈强', register_date: '2015-06-10', related_company_count: 28 }, + { branch_name: `${stockName}成都分公司`, business_status: '存续', register_capital: '2000万元', legal_person: '刘洋', register_date: '2018-09-25', related_company_count: 12 }, + { branch_name: `${stockName}武汉子公司`, business_status: '注销', register_capital: '1000万元', legal_person: '赵静', register_date: '2016-04-18', related_company_count: 5 }, ], announcements: [ - { title: stockName + '2024年第三季度报告', publish_date: '2024-10-28', type: '定期报告', summary: '业绩稳步增长' }, - { title: stockName + '2024年半年度报告', publish_date: '2024-08-28', type: '定期报告', summary: '经营情况良好' }, + { title: `${stockName}2024年第三季度报告`, announce_date: '2024-10-28', info_type: '定期报告', format: 'PDF', file_size: 1850, url: '#' }, + { title: `${stockName}2024年半年度报告`, announce_date: '2024-08-28', info_type: '定期报告', format: 'PDF', file_size: 2340, url: '#' }, + { title: `关于重大合同签订的公告`, announce_date: '2024-07-15', info_type: '临时公告', format: 'PDF', file_size: 128, url: '#' }, ], disclosureSchedule: [ - { report_type: '2024年年度报告', planned_date: '2025-04-30', status: '未披露' }, - { report_type: '2024年第三季度报告', planned_date: '2024-10-31', status: '已披露' }, + { report_name: '2024年年度报告', is_disclosed: false, actual_date: null, latest_scheduled_date: '2025-04-30' }, + { report_name: '2024年第三季度报告', is_disclosed: true, actual_date: '2024-10-28', latest_scheduled_date: '2024-10-31' }, + { report_name: '2024年半年度报告', is_disclosed: true, actual_date: '2024-08-28', latest_scheduled_date: '2024-08-31' }, ], comprehensiveAnalysis: { - overview: { - company_name: stockName, - stock_code: stockCode, - industry: '制造业', - total_assets: Math.floor(Math.random() * 10000) + 100, + qualitative_analysis: { + core_positioning: { + one_line_intro: `${stockName}是XX行业的领先企业,专注于为客户提供创新解决方案`, + investment_highlights: '1. 行业龙头地位,市场份额领先\n2. 技术研发实力强,专利储备丰富\n3. 客户资源优质,大客户粘性高\n4. 管理团队经验丰富,执行力强', + business_model_desc: `${stockName}采用"研发+生产+销售"一体化经营模式,通过持续的技术创新和产品迭代,为客户提供高性价比的产品和服务。` + }, + strategy: '坚持技术创新驱动发展,深耕核心业务领域,积极拓展新兴市场,持续提升企业核心竞争力。' }, - financial_highlights: { - revenue: Math.floor(Math.random() * 1000) + 50, - revenue_growth: (Math.random() * 20 - 5).toFixed(2), - net_profit: Math.floor(Math.random() * 100) + 10, - profit_growth: (Math.random() * 20 - 5).toFixed(2), + competitive_position: { + ranking: { + industry_rank: Math.floor(Math.random() * 20) + 1, + total_companies: 150 + }, + analysis: { + main_competitors: '竞争对手A、竞争对手B、竞争对手C', + competitive_advantages: '技术领先、品牌优势、客户资源丰富、管理团队优秀', + competitive_disadvantages: '规模相对较小、区域布局有待完善' + }, + scores: { + market_position: Math.floor(Math.random() * 20) + 70, + technology: Math.floor(Math.random() * 20) + 70, + brand: Math.floor(Math.random() * 20) + 65, + operation: Math.floor(Math.random() * 20) + 70, + finance: Math.floor(Math.random() * 20) + 70, + innovation: Math.floor(Math.random() * 20) + 70, + risk: Math.floor(Math.random() * 20) + 70, + growth: Math.floor(Math.random() * 20) + 70 + } }, - competitive_advantages: ['技术领先', '品牌优势', '管理团队优秀'], - risk_factors: ['市场竞争激烈', '原材料价格波动'], + business_structure: [ + { + business_name: '舒泰清(复方聚乙二醇电解质散IV)', + business_level: 1, + revenue: 17900, + revenue_unit: '万元', + financial_metrics: { + revenue_ratio: 55.16, + gross_margin: 78.21 + }, + growth_metrics: { + revenue_growth: -8.20 + }, + report_period: '2024年报' + }, + { + business_name: '苏肽生(注射用鼠神经生长因子)', + business_level: 1, + revenue: 13400, + revenue_unit: '万元', + financial_metrics: { + revenue_ratio: 41.21, + gross_margin: 89.11 + }, + growth_metrics: { + revenue_growth: -17.30 + }, + report_period: '2024年报' + }, + { + business_name: '舒斯通(复方聚乙二醇(3350)电解质散)', + business_level: 1, + revenue: 771, + revenue_unit: '万元', + financial_metrics: { + revenue_ratio: 2.37 + }, + report_period: '2024年报' + }, + { + business_name: '阿司匹林肠溶片', + business_level: 1, + revenue: 396, + revenue_unit: '万元', + financial_metrics: { + revenue_ratio: 1.22 + }, + report_period: '2024年报' + }, + { + business_name: '研发业务', + business_level: 1, + report_period: '2024年报' + } + ], + business_segments: [ + { + segment_name: '已上市药品营销', + segment_description: '舒泰神已上市药品营销业务主要包括舒泰清(复方聚乙二醇电解质散IV)和苏肽生(注射用鼠神经生长因子)两大核心产品。2024年实现营业收入3.25亿元,其中舒泰清贡献1.79亿元(55.16%),苏肽生贡献1.34亿元(41.21%)。尽管面临市场竞争压力,产品毛利率保持高位,综合毛利率达80.83%,其中苏肽生毛利率高达89.11%。', + competitive_position: '舒泰清为《中国消化内镜诊疗肠道准备指南》和《慢性便秘诊治指南》一线用药,苏肽生是国内首个国药准字鼠神经生长因子产品。公司医保目录产品舒斯通已落地,并布局舒亦清、舒常轻等系列产品形成梯队,构建了一定市场竞争优势。然而,2024年集采中同类(III型)产品中选,对舒泰清(IV型)形成潜在价格压力。', + future_potential: '公司正在构建系列化产品线应对市场变化,研发投入保持高强度(1.62亿元,占营收49.97%)。在研管线中,STSP-0601血友病药物获FDA孤儿药资格,BDB-001被纳入突破性治疗品种,创新药研发持续推进。国家政策支持创新药发展,行业环境向好,同时国际化布局已有初步进展,未来3-5年有望通过新产品上市实现业绩突破。' + } + ] }, valueChainAnalysis: { - upstream: [ - { name: '原材料供应商A', relationship: '供应商', importance: '高' }, - { name: '原材料供应商B', relationship: '供应商', importance: '中' }, - ], - downstream: [ - { name: '经销商网络', scale: '1000家', contribution: '60%' }, - { name: '直营渠道', scale: '100家', contribution: '40%' }, + value_chain_flows: [ + { from: '原材料供应商', to: stockName, type: 'supply', label: '原材料采购' }, + { from: '设备供应商', to: stockName, type: 'supply', label: '设备采购' }, + { from: stockName, to: '直销客户', type: 'sales', label: '直销' }, + { from: stockName, to: '经销商', type: 'sales', label: '分销' }, + { from: '经销商', to: '终端用户', type: 'distribution', label: '零售' } ], + value_chain_structure: { + nodes_by_level: { + 'level_-2': [ + { node_name: '原材料供应商', node_type: 'supplier', description: '提供生产所需原材料' } + ], + 'level_-1': [ + { node_name: '设备供应商', node_type: 'supplier', description: '提供生产设备' }, + { node_name: '技术服务商', node_type: 'supplier', description: '提供技术支持' } + ], + 'level_0': [ + { node_name: stockName, node_type: 'company', description: '核心企业', is_core: true } + ], + 'level_1': [ + { node_name: '直销客户', node_type: 'customer', description: '大客户直销' }, + { node_name: '经销商', node_type: 'customer', description: '渠道分销' } + ], + 'level_2': [ + { node_name: '终端用户', node_type: 'end_user', description: '最终消费者' } + ] + } + }, + analysis_summary: { + upstream_nodes: 3, + company_nodes: 1, + downstream_nodes: 3, + total_nodes: 7, + key_insights: `${stockName}在产业链中处于核心位置,上下游关系稳定` + } + }, + keyFactorsTimeline: { + key_factors: { + total_factors: 3, + categories: [ + { + category_name: '正面因素', + category_type: 'positive', + factors: [ + { + factor_name: '业绩增长', + impact_score: 8.5, + description: '营收和利润保持稳定增长态势', + trend: 'improving' + } + ] + }, + { + category_name: '负面因素', + category_type: 'negative', + factors: [ + { + factor_name: '原材料成本', + impact_score: 6.0, + description: '原材料价格波动影响毛利率', + trend: 'declining' + } + ] + }, + { + category_name: '中性因素', + category_type: 'neutral', + factors: [ + { + factor_name: '市场竞争', + impact_score: 7.0, + description: '行业竞争加剧,需持续提升竞争力', + trend: 'stable' + } + ] + } + ] + }, + development_timeline: { + statistics: { + positive_events: 4, + negative_events: 0, + neutral_events: 0 + }, + events: [ + { date: '2024-10-28', event: '发布三季报', type: '业绩公告', importance: 'high', impact: '业绩超预期', change: '+3.5%', sentiment: 'positive' }, + { date: '2024-08-28', event: '发布中报', type: '业绩公告', importance: 'high', impact: '业绩稳定增长', change: '+2.8%', sentiment: 'positive' }, + { date: '2024-06-15', event: '新产品发布', type: '产品发布', importance: 'medium', impact: '丰富产品线', change: '+1.5%', sentiment: 'positive' }, + { date: '2024-04-28', event: '发布一季报', type: '业绩公告', importance: 'high', impact: '开门红', change: '+2.2%', sentiment: 'positive' } + ] + } }, - keyFactorsTimeline: [ - { date: '2024-10-28', event: '发布三季报', type: '业绩公告', importance: 'high', impact: '业绩超预期' }, - { date: '2024-08-28', event: '发布中报', type: '业绩公告', importance: 'high', impact: '业绩稳定增长' }, - ], - // 通用预测报告数据 forecastReport: { income_profit_trend: { years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'], - income: [5000, 5800, 6500, 7200, 7900, 8600, 9400], - profit: [450, 520, 580, 650, 720, 800, 890] + income: [baseRevenue * 0.6, baseRevenue * 0.7, baseRevenue * 0.8, baseRevenue * 0.9, baseRevenue, baseRevenue * 1.1, baseRevenue * 1.2], + profit: [baseProfit * 0.6, baseProfit * 0.7, baseProfit * 0.8, baseProfit * 0.9, baseProfit, baseProfit * 1.1, baseProfit * 1.2] }, growth_bars: { years: ['2021', '2022', '2023', '2024E', '2025E', '2026E'], - revenue_growth_pct: [16.0, 12.1, 10.8, 9.7, 8.9, 9.3] + revenue_growth_pct: [16.7, 14.3, 12.5, 11.1, 10.0, 9.1] }, eps_trend: { years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'], - eps: [0.45, 0.52, 0.58, 0.65, 0.72, 0.80, 0.89] + eps: [0.45, 0.52, 0.60, 0.68, 0.76, 0.84, 0.92] }, pe_peg_axes: { years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'], - pe: [22.2, 19.2, 17.2, 15.4, 13.9, 12.5, 11.2], - peg: [1.39, 1.59, 1.59, 1.42, 1.43, 1.40, 1.20] + pe: [22.2, 19.2, 16.7, 14.7, 13.2, 11.9, 10.9], + peg: [1.33, 1.34, 1.34, 1.32, 1.32, 1.31, 1.20] }, detail_table: { years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'], rows: [ - { '指标': '营业总收入(百万元)', '2020': 5000, '2021': 5800, '2022': 6500, '2023': 7200, '2024E': 7900, '2025E': 8600, '2026E': 9400 }, - { '指标': '营收增长率(%)', '2020': '-', '2021': 16.0, '2022': 12.1, '2023': 10.8, '2024E': 9.7, '2025E': 8.9, '2026E': 9.3 }, - { '指标': '归母净利润(百万元)', '2020': 450, '2021': 520, '2022': 580, '2023': 650, '2024E': 720, '2025E': 800, '2026E': 890 }, - { '指标': 'EPS(稀释,元)', '2020': 0.45, '2021': 0.52, '2022': 0.58, '2023': 0.65, '2024E': 0.72, '2025E': 0.80, '2026E': 0.89 }, + { '指标': '营业总收入(百万元)', '2020': baseRevenue * 0.6, '2021': baseRevenue * 0.7, '2022': baseRevenue * 0.8, '2023': baseRevenue * 0.9, '2024E': baseRevenue, '2025E': baseRevenue * 1.1, '2026E': baseRevenue * 1.2 }, + { '指标': '营收增长率(%)', '2020': '-', '2021': 16.7, '2022': 14.3, '2023': 12.5, '2024E': 11.1, '2025E': 10.0, '2026E': 9.1 }, + { '指标': '归母净利润(百万元)', '2020': baseProfit * 0.6, '2021': baseProfit * 0.7, '2022': baseProfit * 0.8, '2023': baseProfit * 0.9, '2024E': baseProfit, '2025E': baseProfit * 1.1, '2026E': baseProfit * 1.2 }, + { '指标': 'EPS(稀释,元)', '2020': 0.45, '2021': 0.52, '2022': 0.60, '2023': 0.68, '2024E': 0.76, '2025E': 0.84, '2026E': 0.92 }, { '指标': 'ROE(%)', '2020': 12.5, '2021': 13.2, '2022': 13.8, '2023': 14.2, '2024E': 14.5, '2025E': 14.8, '2026E': 15.0 }, - { '指标': 'PE(倍)', '2020': 22.2, '2021': 19.2, '2022': 17.2, '2023': 15.4, '2024E': 13.9, '2025E': 12.5, '2026E': 11.2 } + { '指标': 'PE(倍)', '2020': 22.2, '2021': 19.2, '2022': 16.7, '2023': 14.7, '2024E': 13.2, '2025E': 11.9, '2026E': 10.9 } ] } } diff --git a/src/mocks/data/events.js b/src/mocks/data/events.js index 6e73297f..37f6aed4 100644 --- a/src/mocks/data/events.js +++ b/src/mocks/data/events.js @@ -874,8 +874,20 @@ export function generateMockEvents(params = {}) { e.title.toLowerCase().includes(query) || e.description.toLowerCase().includes(query) || // keywords 是对象数组 { concept, score, ... },需要访问 concept 属性 - e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query)) + e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query)) || + // 搜索 related_stocks 中的股票名称和代码 + (e.related_stocks && e.related_stocks.some(stock => + (stock.stock_name && stock.stock_name.toLowerCase().includes(query)) || + (stock.stock_code && stock.stock_code.toLowerCase().includes(query)) + )) || + // 搜索行业 + (e.industry && e.industry.toLowerCase().includes(query)) ); + + // 如果搜索结果为空,返回所有事件(宽松模式) + if (filteredEvents.length === 0) { + filteredEvents = allEvents; + } } // 行业筛选 @@ -1042,7 +1054,7 @@ function generateTransmissionChain(industry, index) { let nodeName; if (nodeType === 'company' && industryStock) { - nodeName = industryStock.name; + nodeName = industryStock.stock_name; } else if (nodeType === 'industry') { nodeName = `${industry}产业`; } else if (nodeType === 'policy') { @@ -1133,7 +1145,7 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) { const stock = industryStocks[j % industryStocks.length]; relatedStocks.push({ stock_code: stock.stock_code, - stock_name: stock.name, + stock_name: stock.stock_name, relation_desc: relationDescriptions[j % relationDescriptions.length] }); } @@ -1145,7 +1157,7 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) { if (!relatedStocks.some(s => s.stock_code === randomStock.stock_code)) { relatedStocks.push({ stock_code: randomStock.stock_code, - stock_name: randomStock.name, + stock_name: randomStock.stock_name, relation_desc: relationDescriptions[relatedStocks.length % relationDescriptions.length] }); } diff --git a/src/mocks/data/financial.js b/src/mocks/data/financial.js index c7128610..23188db2 100644 --- a/src/mocks/data/financial.js +++ b/src/mocks/data/financial.js @@ -10,73 +10,323 @@ export const generateFinancialData = (stockCode) => { // 股票基本信息 stockInfo: { - code: stockCode, - name: stockCode === '000001' ? '平安银行' : '示例公司', + stock_code: stockCode, + stock_name: stockCode === '000001' ? '平安银行' : '示例公司', industry: stockCode === '000001' ? '银行' : '制造业', list_date: '1991-04-03', - market: 'SZ' + market: 'SZ', + // 关键指标 + key_metrics: { + eps: 2.72, + roe: 16.23, + gross_margin: 71.92, + net_margin: 32.56, + roa: 1.05 + }, + // 增长率 + growth_rates: { + revenue_growth: 8.2, + profit_growth: 12.5, + asset_growth: 5.6, + equity_growth: 6.8 + }, + // 财务概要 + financial_summary: { + revenue: 162350, + net_profit: 52860, + total_assets: 5024560, + total_liabilities: 4698880 + }, + // 最新业绩预告 + latest_forecast: { + forecast_type: '预增', + content: '预计全年净利润同比增长10%-17%' + } }, - // 资产负债表 + // 资产负债表 - 嵌套结构 balanceSheet: periods.map((period, i) => ({ period, - total_assets: 5024560 - i * 50000, // 百万元 - total_liabilities: 4698880 - i * 48000, - shareholders_equity: 325680 - i * 2000, - current_assets: 2512300 - i * 25000, - non_current_assets: 2512260 - i * 25000, - current_liabilities: 3456780 - i * 35000, - non_current_liabilities: 1242100 - i * 13000 + assets: { + current_assets: { + cash: 856780 - i * 10000, + trading_financial_assets: 234560 - i * 5000, + notes_receivable: 12340 - i * 200, + accounts_receivable: 45670 - i * 1000, + prepayments: 8900 - i * 100, + other_receivables: 23450 - i * 500, + inventory: 156780 - i * 3000, + contract_assets: 34560 - i * 800, + other_current_assets: 67890 - i * 1500, + total: 2512300 - i * 25000 + }, + non_current_assets: { + long_term_equity_investments: 234560 - i * 5000, + investment_property: 45670 - i * 1000, + fixed_assets: 678900 - i * 15000, + construction_in_progress: 123450 - i * 3000, + right_of_use_assets: 34560 - i * 800, + intangible_assets: 89012 - i * 2000, + goodwill: 45670 - i * 1000, + deferred_tax_assets: 12340 - i * 300, + other_non_current_assets: 67890 - i * 1500, + total: 2512260 - i * 25000 + }, + total: 5024560 - i * 50000 + }, + liabilities: { + current_liabilities: { + short_term_borrowings: 456780 - i * 10000, + notes_payable: 23450 - i * 500, + accounts_payable: 234560 - i * 5000, + advance_receipts: 12340 - i * 300, + contract_liabilities: 34560 - i * 800, + employee_compensation_payable: 45670 - i * 1000, + taxes_payable: 23450 - i * 500, + other_payables: 78900 - i * 1500, + non_current_liabilities_due_within_one_year: 89012 - i * 2000, + total: 3456780 - i * 35000 + }, + non_current_liabilities: { + long_term_borrowings: 678900 - i * 15000, + bonds_payable: 234560 - i * 5000, + lease_liabilities: 45670 - i * 1000, + deferred_tax_liabilities: 12340 - i * 300, + other_non_current_liabilities: 89012 - i * 2000, + total: 1242100 - i * 13000 + }, + total: 4698880 - i * 48000 + }, + equity: { + share_capital: 19405, + capital_reserve: 89012 - i * 2000, + surplus_reserve: 45670 - i * 1000, + undistributed_profit: 156780 - i * 3000, + treasury_stock: 0, + other_comprehensive_income: 12340 - i * 300, + parent_company_equity: 315680 - i * 1800, + minority_interests: 10000 - i * 200, + total: 325680 - i * 2000 + } })), - // 利润表 + // 利润表 - 嵌套结构 incomeStatement: periods.map((period, i) => ({ period, - revenue: 162350 - i * 4000, // 百万元 - operating_cost: 45620 - i * 1200, - gross_profit: 116730 - i * 2800, - operating_profit: 68450 - i * 1500, - net_profit: 52860 - i * 1200, - eps: 2.72 - i * 0.06 + revenue: { + total_operating_revenue: 162350 - i * 4000, + operating_revenue: 158900 - i * 3900, + other_income: 3450 - i * 100 + }, + costs: { + total_operating_cost: 93900 - i * 2500, + operating_cost: 45620 - i * 1200, + taxes_and_surcharges: 4560 - i * 100, + selling_expenses: 12340 - i * 300, + admin_expenses: 15670 - i * 400, + rd_expenses: 8900 - i * 200, + financial_expenses: 6810 - i * 300, + interest_expense: 8900 - i * 200, + interest_income: 2090 - i * 50, + three_expenses_total: 34820 - i * 1000, + four_expenses_total: 43720 - i * 1200, + asset_impairment_loss: 1200 - i * 50, + credit_impairment_loss: 2340 - i * 100 + }, + other_gains: { + fair_value_change: 1230 - i * 50, + investment_income: 3450 - i * 100, + investment_income_from_associates: 890 - i * 20, + exchange_income: 560 - i * 10, + asset_disposal_income: 340 - i * 10 + }, + profit: { + operating_profit: 68450 - i * 1500, + total_profit: 69500 - i * 1500, + income_tax_expense: 16640 - i * 300, + net_profit: 52860 - i * 1200, + parent_net_profit: 51200 - i * 1150, + minority_profit: 1660 - i * 50, + continuing_operations_net_profit: 52860 - i * 1200, + discontinued_operations_net_profit: 0 + }, + non_operating: { + non_operating_income: 1050 - i * 20, + non_operating_expenses: 450 - i * 10 + }, + per_share: { + basic_eps: 2.72 - i * 0.06, + diluted_eps: 2.70 - i * 0.06 + }, + comprehensive_income: { + other_comprehensive_income: 890 - i * 20, + total_comprehensive_income: 53750 - i * 1220, + parent_comprehensive_income: 52050 - i * 1170, + minority_comprehensive_income: 1700 - i * 50 + } })), - // 现金流量表 + // 现金流量表 - 嵌套结构 cashflow: periods.map((period, i) => ({ period, - operating_cashflow: 125600 - i * 3000, // 百万元 - investing_cashflow: -45300 - i * 1000, - financing_cashflow: -38200 + i * 500, - net_cashflow: 42100 - i * 1500, - cash_ending: 456780 - i * 10000 + operating_activities: { + inflow: { + cash_from_sales: 178500 - i * 4500 + }, + outflow: { + cash_for_goods: 52900 - i * 1500 + }, + net_flow: 125600 - i * 3000 + }, + investment_activities: { + net_flow: -45300 - i * 1000 + }, + financing_activities: { + net_flow: -38200 + i * 500 + }, + cash_changes: { + net_increase: 42100 - i * 1500, + ending_balance: 456780 - i * 10000 + }, + key_metrics: { + free_cash_flow: 80300 - i * 2000 + } })), - // 财务指标 + // 财务指标 - 嵌套结构 financialMetrics: periods.map((period, i) => ({ period, - roe: 16.23 - i * 0.3, // % - roa: 1.05 - i * 0.02, - gross_margin: 71.92 - i * 0.5, - net_margin: 32.56 - i * 0.3, - current_ratio: 0.73 + i * 0.01, - quick_ratio: 0.71 + i * 0.01, - debt_ratio: 93.52 + i * 0.05, - asset_turnover: 0.41 - i * 0.01, - inventory_turnover: 0, // 银行无库存 - receivable_turnover: 0 // 银行特殊 + profitability: { + roe: 16.23 - i * 0.3, + roe_deducted: 15.89 - i * 0.3, + roe_weighted: 16.45 - i * 0.3, + roa: 1.05 - i * 0.02, + gross_margin: 71.92 - i * 0.5, + net_profit_margin: 32.56 - i * 0.3, + operating_profit_margin: 42.16 - i * 0.4, + cost_profit_ratio: 115.8 - i * 1.2, + ebit: 86140 - i * 1800 + }, + per_share_metrics: { + eps: 2.72 - i * 0.06, + basic_eps: 2.72 - i * 0.06, + diluted_eps: 2.70 - i * 0.06, + deducted_eps: 2.65 - i * 0.06, + bvps: 16.78 - i * 0.1, + operating_cash_flow_ps: 6.47 - i * 0.15, + capital_reserve_ps: 4.59 - i * 0.1, + undistributed_profit_ps: 8.08 - i * 0.15 + }, + growth: { + revenue_growth: 8.2 - i * 0.5, + net_profit_growth: 12.5 - i * 0.8, + deducted_profit_growth: 11.8 - i * 0.7, + parent_profit_growth: 12.3 - i * 0.75, + operating_cash_flow_growth: 15.6 - i * 1.0, + total_asset_growth: 5.6 - i * 0.3, + equity_growth: 6.8 - i * 0.4, + fixed_asset_growth: 4.2 - i * 0.2 + }, + operational_efficiency: { + total_asset_turnover: 0.41 - i * 0.01, + fixed_asset_turnover: 2.35 - i * 0.05, + current_asset_turnover: 0.82 - i * 0.02, + receivable_turnover: 12.5 - i * 0.3, + receivable_days: 29.2 + i * 0.7, + inventory_turnover: 0, // 银行无库存 + inventory_days: 0, + working_capital_turnover: 1.68 - i * 0.04 + }, + solvency: { + current_ratio: 0.73 + i * 0.01, + quick_ratio: 0.71 + i * 0.01, + cash_ratio: 0.25 + i * 0.005, + conservative_quick_ratio: 0.68 + i * 0.01, + asset_liability_ratio: 93.52 + i * 0.05, + interest_coverage: 8.56 - i * 0.2, + cash_to_maturity_debt_ratio: 0.45 - i * 0.01, + tangible_asset_debt_ratio: 94.12 + i * 0.05 + }, + expense_ratios: { + selling_expense_ratio: 7.60 + i * 0.1, + admin_expense_ratio: 9.65 + i * 0.1, + financial_expense_ratio: 4.19 + i * 0.1, + rd_expense_ratio: 5.48 + i * 0.1, + three_expense_ratio: 21.44 + i * 0.3, + four_expense_ratio: 26.92 + i * 0.4, + cost_ratio: 28.10 + i * 0.2 + } })), - // 主营业务 + // 主营业务 - 按产品/业务分类 mainBusiness: { - by_product: [ - { name: '对公业务', revenue: 68540, ratio: 42.2, yoy_growth: 6.8 }, - { name: '零售业务', revenue: 81320, ratio: 50.1, yoy_growth: 11.2 }, - { name: '金融市场业务', revenue: 12490, ratio: 7.7, yoy_growth: 3.5 } + product_classification: [ + { + period: '2024-09-30', + report_type: '2024年三季报', + products: [ + { content: '零售金融业务', revenue: 81320000000, gross_margin: 68.5, profit_margin: 42.3, profit: 34398160000 }, + { content: '对公金融业务', revenue: 68540000000, gross_margin: 62.8, profit_margin: 38.6, profit: 26456440000 }, + { content: '金融市场业务', revenue: 12490000000, gross_margin: 75.2, profit_margin: 52.1, profit: 6507290000 }, + { content: '合计', revenue: 162350000000, gross_margin: 67.5, profit_margin: 41.2, profit: 66883200000 }, + ] + }, + { + period: '2024-06-30', + report_type: '2024年中报', + products: [ + { content: '零售金融业务', revenue: 78650000000, gross_margin: 67.8, profit_margin: 41.5, profit: 32639750000 }, + { content: '对公金融业务', revenue: 66280000000, gross_margin: 61.9, profit_margin: 37.8, profit: 25053840000 }, + { content: '金融市场业务', revenue: 11870000000, gross_margin: 74.5, profit_margin: 51.2, profit: 6077440000 }, + { content: '合计', revenue: 156800000000, gross_margin: 66.8, profit_margin: 40.5, profit: 63504000000 }, + ] + }, + { + period: '2024-03-31', + report_type: '2024年一季报', + products: [ + { content: '零售金融业务', revenue: 38920000000, gross_margin: 67.2, profit_margin: 40.8, profit: 15879360000 }, + { content: '对公金融业务', revenue: 32650000000, gross_margin: 61.2, profit_margin: 37.1, profit: 12113150000 }, + { content: '金融市场业务', revenue: 5830000000, gross_margin: 73.8, profit_margin: 50.5, profit: 2944150000 }, + { content: '合计', revenue: 77400000000, gross_margin: 66.1, profit_margin: 39.8, profit: 30805200000 }, + ] + }, + { + period: '2023-12-31', + report_type: '2023年年报', + products: [ + { content: '零售金融业务', revenue: 152680000000, gross_margin: 66.5, profit_margin: 40.2, profit: 61377360000 }, + { content: '对公金融业务', revenue: 128450000000, gross_margin: 60.5, profit_margin: 36.5, profit: 46884250000 }, + { content: '金融市场业务', revenue: 22870000000, gross_margin: 73.2, profit_margin: 49.8, profit: 11389260000 }, + { content: '合计', revenue: 304000000000, gross_margin: 65.2, profit_margin: 39.2, profit: 119168000000 }, + ] + }, ], - by_region: [ - { name: '华南地区', revenue: 56800, ratio: 35.0, yoy_growth: 9.2 }, - { name: '华东地区', revenue: 48705, ratio: 30.0, yoy_growth: 8.5 }, - { name: '华北地区', revenue: 32470, ratio: 20.0, yoy_growth: 7.8 }, - { name: '其他地区', revenue: 24375, ratio: 15.0, yoy_growth: 6.5 } + industry_classification: [ + { + period: '2024-09-30', + report_type: '2024年三季报', + industries: [ + { content: '华南地区', revenue: 56817500000, gross_margin: 69.2, profit_margin: 43.5, profit: 24715612500 }, + { content: '华东地区', revenue: 48705000000, gross_margin: 67.8, profit_margin: 41.2, profit: 20066460000 }, + { content: '华北地区', revenue: 32470000000, gross_margin: 65.5, profit_margin: 38.8, profit: 12598360000 }, + { content: '西南地区', revenue: 16235000000, gross_margin: 64.2, profit_margin: 37.5, profit: 6088125000 }, + { content: '其他地区', revenue: 8122500000, gross_margin: 62.8, profit_margin: 35.2, profit: 2859120000 }, + { content: '合计', revenue: 162350000000, gross_margin: 67.5, profit_margin: 41.2, profit: 66883200000 }, + ] + }, + { + period: '2024-06-30', + report_type: '2024年中报', + industries: [ + { content: '华南地区', revenue: 54880000000, gross_margin: 68.5, profit_margin: 42.8, profit: 23488640000 }, + { content: '华东地区', revenue: 47040000000, gross_margin: 67.1, profit_margin: 40.5, profit: 19051200000 }, + { content: '华北地区', revenue: 31360000000, gross_margin: 64.8, profit_margin: 38.1, profit: 11948160000 }, + { content: '西南地区', revenue: 15680000000, gross_margin: 63.5, profit_margin: 36.8, profit: 5770240000 }, + { content: '其他地区', revenue: 7840000000, gross_margin: 62.1, profit_margin: 34.5, profit: 2704800000 }, + { content: '合计', revenue: 156800000000, gross_margin: 66.8, profit_margin: 40.5, profit: 63504000000 }, + ] + }, ] }, @@ -92,48 +342,74 @@ export const generateFinancialData = (stockCode) => { publish_date: '2024-10-15' }, - // 行业排名 - industryRank: { - industry: '银行', - total_companies: 42, - rankings: [ - { metric: '总资产', rank: 8, value: 5024560, percentile: 19 }, - { metric: '营业收入', rank: 9, value: 162350, percentile: 21 }, - { metric: '净利润', rank: 8, value: 52860, percentile: 19 }, - { metric: 'ROE', rank: 12, value: 16.23, percentile: 29 }, - { metric: '不良贷款率', rank: 18, value: 1.02, percentile: 43 } - ] - }, + // 行业排名(数组格式,符合 IndustryRankingView 组件要求) + industryRank: [ + { + period: '2024-09-30', + report_type: '三季报', + rankings: [ + { + industry_name: stockCode === '000001' ? '银行' : '制造业', + level_description: '一级行业', + metrics: { + eps: { value: 2.72, rank: 8, industry_avg: 1.85 }, + bvps: { value: 15.23, rank: 12, industry_avg: 12.50 }, + roe: { value: 16.23, rank: 10, industry_avg: 12.00 }, + revenue_growth: { value: 8.2, rank: 15, industry_avg: 5.50 }, + profit_growth: { value: 12.5, rank: 9, industry_avg: 8.00 }, + operating_margin: { value: 32.56, rank: 6, industry_avg: 25.00 }, + debt_ratio: { value: 92.5, rank: 35, industry_avg: 88.00 }, + receivable_turnover: { value: 5.2, rank: 18, industry_avg: 4.80 } + } + } + ] + } + ], - // 期间对比 - periodComparison: { - periods: ['Q3-2024', 'Q2-2024', 'Q1-2024', 'Q4-2023'], - metrics: [ - { - name: '营业收入', - unit: '百万元', - values: [41500, 40800, 40200, 40850], - yoy: [8.2, 7.8, 8.5, 9.2] - }, - { - name: '净利润', - unit: '百万元', - values: [13420, 13180, 13050, 13210], - yoy: [12.5, 11.2, 10.8, 12.3] - }, - { - name: 'ROE', - unit: '%', - values: [16.23, 15.98, 15.75, 16.02], - yoy: [1.2, 0.8, 0.5, 1.0] - }, - { - name: 'EPS', - unit: '元', - values: [0.69, 0.68, 0.67, 0.68], - yoy: [12.3, 11.5, 10.5, 12.0] + // 期间对比 - 营收与利润趋势数据 + periodComparison: [ + { + period: '2024-09-30', + performance: { + revenue: 41500000000, // 415亿 + net_profit: 13420000000 // 134.2亿 } - ] - } + }, + { + period: '2024-06-30', + performance: { + revenue: 40800000000, // 408亿 + net_profit: 13180000000 // 131.8亿 + } + }, + { + period: '2024-03-31', + performance: { + revenue: 40200000000, // 402亿 + net_profit: 13050000000 // 130.5亿 + } + }, + { + period: '2023-12-31', + performance: { + revenue: 40850000000, // 408.5亿 + net_profit: 13210000000 // 132.1亿 + } + }, + { + period: '2023-09-30', + performance: { + revenue: 38500000000, // 385亿 + net_profit: 11920000000 // 119.2亿 + } + }, + { + period: '2023-06-30', + performance: { + revenue: 37800000000, // 378亿 + net_profit: 11850000000 // 118.5亿 + } + } + ] }; }; diff --git a/src/mocks/data/market.js b/src/mocks/data/market.js index 19e4a23f..23f27a4c 100644 --- a/src/mocks/data/market.js +++ b/src/mocks/data/market.js @@ -24,8 +24,9 @@ export const generateMarketData = (stockCode) => { low: parseFloat(low.toFixed(2)), volume: Math.floor(Math.random() * 500000000) + 100000000, // 1-6亿股 amount: Math.floor(Math.random() * 7000000000) + 1300000000, // 13-80亿元 - turnover_rate: (Math.random() * 2 + 0.5).toFixed(2), // 0.5-2.5% - change_pct: (Math.random() * 6 - 3).toFixed(2) // -3% to +3% + turnover_rate: parseFloat((Math.random() * 2 + 0.5).toFixed(2)), // 0.5-2.5% + change_percent: parseFloat((Math.random() * 6 - 3).toFixed(2)), // -3% to +3% + pe_ratio: parseFloat((Math.random() * 3 + 4).toFixed(2)) // 4-7 }; }) }, @@ -78,36 +79,45 @@ export const generateMarketData = (stockCode) => { })) }, - // 股权质押 + // 股权质押 - 匹配 PledgeData[] 类型 pledgeData: { success: true, - data: { - total_pledged: 25.6, // 质押比例% - major_shareholders: [ - { name: '中国平安保险集团', pledged_shares: 0, total_shares: 10168542300, pledge_ratio: 0 }, - { name: '深圳市投资控股', pledged_shares: 50000000, total_shares: 382456100, pledge_ratio: 13.08 } - ], - update_date: '2024-09-30' - } + data: Array(12).fill(null).map((_, i) => { + const date = new Date(); + date.setMonth(date.getMonth() - (11 - i)); + return { + end_date: date.toISOString().split('T')[0].slice(0, 7) + '-01', + unrestricted_pledge: Math.floor(Math.random() * 1000000000) + 500000000, + restricted_pledge: Math.floor(Math.random() * 200000000) + 50000000, + total_pledge: Math.floor(Math.random() * 1200000000) + 550000000, + total_shares: 19405918198, + pledge_ratio: parseFloat((Math.random() * 3 + 6).toFixed(2)), // 6-9% + pledge_count: Math.floor(Math.random() * 50) + 100 // 100-150 + }; + }) }, - // 市场摘要 + // 市场摘要 - 匹配 MarketSummary 类型 summaryData: { success: true, data: { - current_price: basePrice, - change: 0.25, - change_pct: 1.89, - open: 13.35, - high: 13.68, - low: 13.28, - volume: 345678900, - amount: 4678900000, - turnover_rate: 1.78, - pe_ratio: 4.96, - pb_ratio: 0.72, - total_market_cap: 262300000000, - circulating_market_cap: 262300000000 + stock_code: stockCode, + stock_name: stockCode === '000001' ? '平安银行' : '示例股票', + latest_trade: { + close: basePrice, + change_percent: 1.89, + volume: 345678900, + amount: 4678900000, + turnover_rate: 1.78, + pe_ratio: 4.96 + }, + latest_funding: { + financing_balance: 5823000000, + securities_balance: 125600000 + }, + latest_pledge: { + pledge_ratio: 8.25 + } } }, @@ -131,26 +141,57 @@ export const generateMarketData = (stockCode) => { }) }, - // 最新分时数据 + // 最新分时数据 - 匹配 MinuteData 类型 latestMinuteData: { success: true, - data: Array(240).fill(null).map((_, i) => { - const minute = 9 * 60 + 30 + i; // 从9:30开始 - const hour = Math.floor(minute / 60); - const min = minute % 60; - const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`; - const randomChange = (Math.random() - 0.5) * 0.1; - return { - time, - price: (basePrice + randomChange).toFixed(2), - volume: Math.floor(Math.random() * 2000000) + 500000, - avg_price: (basePrice + randomChange * 0.8).toFixed(2) - }; - }), + data: (() => { + const minuteData = []; + // 上午 9:30-11:30 (120分钟) + for (let i = 0; i < 120; i++) { + const hour = 9 + Math.floor((30 + i) / 60); + const min = (30 + i) % 60; + const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`; + const randomChange = (Math.random() - 0.5) * 0.1; + const open = parseFloat((basePrice + randomChange).toFixed(2)); + const close = parseFloat((basePrice + randomChange + (Math.random() - 0.5) * 0.05).toFixed(2)); + const high = parseFloat(Math.max(open, close, open + Math.random() * 0.05).toFixed(2)); + const low = parseFloat(Math.min(open, close, close - Math.random() * 0.05).toFixed(2)); + minuteData.push({ + time, + open, + close, + high, + low, + volume: Math.floor(Math.random() * 2000000) + 500000, + amount: Math.floor(Math.random() * 30000000) + 5000000 + }); + } + // 下午 13:00-15:00 (120分钟) + for (let i = 0; i < 120; i++) { + const hour = 13 + Math.floor(i / 60); + const min = i % 60; + const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`; + const randomChange = (Math.random() - 0.5) * 0.1; + const open = parseFloat((basePrice + randomChange).toFixed(2)); + const close = parseFloat((basePrice + randomChange + (Math.random() - 0.5) * 0.05).toFixed(2)); + const high = parseFloat(Math.max(open, close, open + Math.random() * 0.05).toFixed(2)); + const low = parseFloat(Math.min(open, close, close - Math.random() * 0.05).toFixed(2)); + minuteData.push({ + time, + open, + close, + high, + low, + volume: Math.floor(Math.random() * 1500000) + 400000, + amount: Math.floor(Math.random() * 25000000) + 4000000 + }); + } + return minuteData; + })(), code: stockCode, name: stockCode === '000001' ? '平安银行' : '示例股票', trade_date: new Date().toISOString().split('T')[0], - type: 'minute' + type: '1min' } }; }; diff --git a/src/mocks/handlers/company.js b/src/mocks/handlers/company.js index b8968481..20677aa7 100644 --- a/src/mocks/handlers/company.js +++ b/src/mocks/handlers/company.js @@ -43,12 +43,10 @@ export const companyHandlers = [ const { stockCode } = params; const data = getCompanyData(stockCode); + // 直接返回 keyFactorsTimeline 对象(包含 key_factors 和 development_timeline) return HttpResponse.json({ success: true, - data: { - timeline: data.keyFactorsTimeline, - total: data.keyFactorsTimeline.length - } + data: data.keyFactorsTimeline }); }), @@ -69,10 +67,19 @@ export const companyHandlers = [ await delay(150); const { stockCode } = params; const data = getCompanyData(stockCode); + const raw = data.actualControl; + + // 数据已经是数组格式,只做数值转换(holding_ratio 从 0-100 转为 0-1) + const formatted = Array.isArray(raw) + ? raw.map(item => ({ + ...item, + holding_ratio: item.holding_ratio > 1 ? item.holding_ratio / 100 : item.holding_ratio, + })) + : []; return HttpResponse.json({ success: true, - data: data.actualControl + data: formatted }); }), @@ -81,10 +88,19 @@ export const companyHandlers = [ await delay(150); const { stockCode } = params; const data = getCompanyData(stockCode); + const raw = data.concentration; + + // 数据已经是数组格式,只做数值转换(holding_ratio 从 0-100 转为 0-1) + const formatted = Array.isArray(raw) + ? raw.map(item => ({ + ...item, + holding_ratio: item.holding_ratio > 1 ? item.holding_ratio / 100 : item.holding_ratio, + })) + : []; return HttpResponse.json({ success: true, - data: data.concentration + data: formatted }); }), diff --git a/src/mocks/handlers/event.js b/src/mocks/handlers/event.js index 1981def2..a20dc624 100644 --- a/src/mocks/handlers/event.js +++ b/src/mocks/handlers/event.js @@ -120,9 +120,12 @@ export const eventHandlers = [ try { const result = generateMockEvents(params); + // 返回格式兼容 NewsPanel 期望的结构 + // NewsPanel 期望: { success, data: [], pagination: {} } return HttpResponse.json({ success: true, - data: result, + data: result.events, // 事件数组 + pagination: result.pagination, // 分页信息 message: '获取成功' }); } catch (error) { @@ -136,16 +139,14 @@ export const eventHandlers = [ { success: false, error: '获取事件列表失败', - data: { - events: [], - pagination: { - page: 1, - per_page: 10, - total: 0, - pages: 0, // ← 对齐后端字段名 - has_prev: false, // ← 对齐后端 - has_next: false // ← 对齐后端 - } + data: [], + pagination: { + page: 1, + per_page: 10, + total: 0, + pages: 0, + has_prev: false, + has_next: false } }, { status: 500 } diff --git a/src/mocks/handlers/stock.js b/src/mocks/handlers/stock.js index a345259c..f18ffcee 100644 --- a/src/mocks/handlers/stock.js +++ b/src/mocks/handlers/stock.js @@ -387,6 +387,68 @@ export const stockHandlers = [ } }), + // 获取股票业绩预告 + http.get('/api/stock/:stockCode/forecast', async ({ params }) => { + await delay(200); + + const { stockCode } = params; + console.log('[Mock Stock] 获取业绩预告:', { stockCode }); + + // 生成股票列表用于查找名称 + const stockList = generateStockList(); + const stockInfo = stockList.find(s => s.code === stockCode.replace(/\.(SH|SZ)$/i, '')); + const stockName = stockInfo?.name || `股票${stockCode}`; + + // 业绩预告类型列表 + const forecastTypes = ['预增', '预减', '略增', '略减', '扭亏', '续亏', '首亏', '续盈']; + + // 生成业绩预告数据 + const forecasts = [ + { + forecast_type: '预增', + report_date: '2024年年报', + content: `${stockName}预计2024年度归属于上市公司股东的净利润为58亿元至62亿元,同比增长10%至17%。`, + reason: '报告期内,公司主营业务收入稳步增长,产品结构持续优化,毛利率提升;同时公司加大研发投入,新产品市场表现良好。', + change_range: { + lower: 10, + upper: 17 + }, + publish_date: '2024-10-15' + }, + { + forecast_type: '略增', + report_date: '2024年三季报', + content: `${stockName}预计2024年1-9月归属于上市公司股东的净利润为42亿元至45亿元,同比增长5%至12%。`, + reason: '公司积极拓展市场渠道,销售规模持续扩大,经营效益稳步提升。', + change_range: { + lower: 5, + upper: 12 + }, + publish_date: '2024-07-12' + }, + { + forecast_type: forecastTypes[Math.floor(Math.random() * forecastTypes.length)], + report_date: '2024年中报', + content: `${stockName}预计2024年上半年归属于上市公司股东的净利润为28亿元至30亿元。`, + reason: '受益于行业景气度回升及公司降本增效措施效果显现,经营业绩同比有所改善。', + change_range: { + lower: 3, + upper: 8 + }, + publish_date: '2024-04-20' + } + ]; + + return HttpResponse.json({ + success: true, + data: { + stock_code: stockCode, + stock_name: stockName, + forecasts: forecasts + } + }); + }), + // 获取股票报价(批量) http.post('/api/stock/quotes', async ({ request }) => { await delay(200); @@ -414,6 +476,25 @@ export const stockHandlers = [ stockMap[s.code] = s.name; }); + // 行业和指数映射表 + const stockIndustryMap = { + '000001': { industry_l1: '金融', industry: '银行', index_tags: ['沪深300', '上证180'] }, + '600519': { industry_l1: '消费', industry: '白酒', index_tags: ['沪深300', '上证50'] }, + '300750': { industry_l1: '工业', industry: '电池', index_tags: ['创业板50'] }, + '601318': { industry_l1: '金融', industry: '保险', index_tags: ['沪深300', '上证50'] }, + '600036': { industry_l1: '金融', industry: '银行', index_tags: ['沪深300', '上证50'] }, + '000858': { industry_l1: '消费', industry: '白酒', index_tags: ['沪深300'] }, + '002594': { industry_l1: '汽车', industry: '乘用车', index_tags: ['沪深300', '创业板指'] }, + }; + + const defaultIndustries = [ + { industry_l1: '科技', industry: '软件' }, + { industry_l1: '医药', industry: '化学制药' }, + { industry_l1: '消费', industry: '食品' }, + { industry_l1: '金融', industry: '证券' }, + { industry_l1: '工业', industry: '机械' }, + ]; + // 为每只股票生成报价数据 const quotesData = {}; codes.forEach(stockCode => { @@ -426,6 +507,11 @@ export const stockHandlers = [ // 昨收 const prevClose = parseFloat((basePrice - change).toFixed(2)); + // 获取行业和指数信息 + const codeWithoutSuffix = stockCode.replace(/\.(SH|SZ)$/i, ''); + const industryInfo = stockIndustryMap[codeWithoutSuffix] || + defaultIndustries[Math.floor(Math.random() * defaultIndustries.length)]; + quotesData[stockCode] = { code: stockCode, name: stockMap[stockCode] || `股票${stockCode}`, @@ -439,7 +525,23 @@ export const stockHandlers = [ volume: Math.floor(Math.random() * 100000000), amount: parseFloat((Math.random() * 10000000000).toFixed(2)), market: stockCode.startsWith('6') ? 'SH' : 'SZ', - update_time: new Date().toISOString() + update_time: new Date().toISOString(), + // 行业和指数标签 + industry_l1: industryInfo.industry_l1, + industry: industryInfo.industry, + index_tags: industryInfo.index_tags || [], + // 关键指标 + pe: parseFloat((Math.random() * 50 + 5).toFixed(2)), + eps: parseFloat((Math.random() * 5 + 0.1).toFixed(3)), + pb: parseFloat((Math.random() * 8 + 0.5).toFixed(2)), + market_cap: `${(Math.random() * 5000 + 100).toFixed(0)}亿`, + week52_low: parseFloat((basePrice * 0.7).toFixed(2)), + week52_high: parseFloat((basePrice * 1.3).toFixed(2)), + // 主力动态 + main_net_inflow: parseFloat((Math.random() * 10 - 5).toFixed(2)), + institution_holding: parseFloat((Math.random() * 50 + 10).toFixed(2)), + buy_ratio: parseFloat((Math.random() * 40 + 30).toFixed(2)), + sell_ratio: parseFloat((100 - (Math.random() * 40 + 30)).toFixed(2)) }; }); diff --git a/src/routes/lazy-components.js b/src/routes/lazy-components.js index ada81708..37fed848 100644 --- a/src/routes/lazy-components.js +++ b/src/routes/lazy-components.js @@ -35,9 +35,9 @@ export const lazyComponents = { // 公司相关模块 CompanyIndex: React.lazy(() => import('@views/Company')), - ForecastReport: React.lazy(() => import('@views/Company/ForecastReport')), - FinancialPanorama: React.lazy(() => import('@views/Company/FinancialPanorama')), - MarketDataView: React.lazy(() => import('@views/Company/MarketDataView')), + ForecastReport: React.lazy(() => import('@views/Company/components/ForecastReport')), + FinancialPanorama: React.lazy(() => import('@views/Company/components/FinancialPanorama')), + MarketDataView: React.lazy(() => import('@views/Company/components/MarketDataView')), // Agent模块 AgentChat: React.lazy(() => import('@views/AgentChat')), diff --git a/src/store/slices/stockSlice.js b/src/store/slices/stockSlice.js index b6ba2714..53ab942a 100644 --- a/src/store/slices/stockSlice.js +++ b/src/store/slices/stockSlice.js @@ -4,6 +4,56 @@ import { eventService, stockService } from '../../services/eventService'; import { logger } from '../../utils/logger'; import { getApiBase } from '../../utils/apiConfig'; +// ==================== Watchlist 缓存配置 ==================== +const WATCHLIST_CACHE_KEY = 'watchlist_cache'; +const WATCHLIST_CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7天 + +/** + * 从 localStorage 读取自选股缓存 + */ +const loadWatchlistFromCache = () => { + try { + const cached = localStorage.getItem(WATCHLIST_CACHE_KEY); + if (!cached) return null; + + const { data, timestamp } = JSON.parse(cached); + const now = Date.now(); + + // 检查缓存是否过期(7天) + if (now - timestamp > WATCHLIST_CACHE_DURATION) { + localStorage.removeItem(WATCHLIST_CACHE_KEY); + logger.debug('stockSlice', '自选股缓存已过期'); + return null; + } + + logger.debug('stockSlice', '自选股 localStorage 缓存命中', { + count: data?.length || 0, + age: Math.round((now - timestamp) / 1000 / 60) + '分钟前' + }); + return data; + } catch (error) { + logger.error('stockSlice', 'loadWatchlistFromCache', error); + return null; + } +}; + +/** + * 保存自选股到 localStorage + */ +const saveWatchlistToCache = (data) => { + try { + localStorage.setItem(WATCHLIST_CACHE_KEY, JSON.stringify({ + data, + timestamp: Date.now() + })); + logger.debug('stockSlice', '自选股已缓存到 localStorage', { + count: data?.length || 0 + }); + } catch (error) { + logger.error('stockSlice', 'saveWatchlistToCache', error); + } +}; + // ==================== Async Thunks ==================== /** @@ -153,13 +203,28 @@ export const fetchExpectationScore = createAsyncThunk( /** * 加载用户自选股列表(包含完整信息) + * 缓存策略:Redux 内存缓存 → localStorage 持久缓存(7天) → API 请求 */ export const loadWatchlist = createAsyncThunk( 'stock/loadWatchlist', - async () => { + async (_, { getState }) => { logger.debug('stockSlice', 'loadWatchlist'); try { + // 1. 先检查 Redux 内存缓存 + const reduxCached = getState().stock.watchlist; + if (reduxCached && reduxCached.length > 0) { + logger.debug('stockSlice', 'Redux watchlist 缓存命中', { count: reduxCached.length }); + return reduxCached; + } + + // 2. 再检查 localStorage 持久缓存(7天有效期) + const localCached = loadWatchlistFromCache(); + if (localCached && localCached.length > 0) { + return localCached; + } + + // 3. 缓存无效,调用 API const apiBase = getApiBase(); const response = await fetch(`${apiBase}/api/account/watchlist`, { credentials: 'include' @@ -172,6 +237,10 @@ export const loadWatchlist = createAsyncThunk( stock_code: item.stock_code, stock_name: item.stock_name, })); + + // 保存到 localStorage 缓存 + saveWatchlistToCache(watchlistData); + logger.debug('stockSlice', '自选股列表加载成功', { count: watchlistData.length }); @@ -340,6 +409,26 @@ const stockSlice = createSlice({ delete state.historicalEventsCache[eventId]; delete state.chainAnalysisCache[eventId]; delete state.expectationScores[eventId]; + }, + + /** + * 乐观更新:添加自选股(同步) + */ + optimisticAddWatchlist: (state, action) => { + const { stockCode, stockName } = action.payload; + // 避免重复添加 + const exists = state.watchlist.some(item => item.stock_code === stockCode); + if (!exists) { + state.watchlist.push({ stock_code: stockCode, stock_name: stockName || '' }); + } + }, + + /** + * 乐观更新:移除自选股(同步) + */ + optimisticRemoveWatchlist: (state, action) => { + const { stockCode } = action.payload; + state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode); } }, extraReducers: (builder) => { @@ -470,9 +559,10 @@ const stockSlice = createSlice({ state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode); } }) - // fulfilled: 乐观更新模式下状态已在 pending 更新,这里无需操作 - .addCase(toggleWatchlist.fulfilled, () => { - // 状态已在 pending 时更新 + // fulfilled: 同步更新 localStorage 缓存 + .addCase(toggleWatchlist.fulfilled, (state) => { + // 状态已在 pending 时更新,这里同步到 localStorage + saveWatchlistToCache(state.watchlist); }); } }); @@ -481,7 +571,9 @@ export const { updateQuote, updateQuotes, clearQuotes, - clearEventCache + clearEventCache, + optimisticAddWatchlist, + optimisticRemoveWatchlist } = stockSlice.actions; export default stockSlice.reducer; diff --git a/src/utils/priceFormatters.js b/src/utils/priceFormatters.js index e93d4dfb..b4642867 100644 --- a/src/utils/priceFormatters.js +++ b/src/utils/priceFormatters.js @@ -103,3 +103,71 @@ export const PriceArrow = ({ value }) => { return ; }; + +// ==================== 货币/数值格式化 ==================== + +/** + * 格式化货币金额(自动选择单位:亿元/万元/元) + * @param {number|null|undefined} value - 金额(单位:元) + * @returns {string} 格式化后的金额字符串 + */ +export const formatCurrency = (value) => { + if (value === null || value === undefined) return '-'; + const absValue = Math.abs(value); + if (absValue >= 100000000) { + return (value / 100000000).toFixed(2) + '亿元'; + } else if (absValue >= 10000) { + return (value / 10000).toFixed(2) + '万元'; + } + return value.toFixed(2) + '元'; +}; + +/** + * 格式化业务营收(支持指定单位) + * @param {number|null|undefined} value - 营收金额 + * @param {string} [unit] - 原始单位(元/万元/亿元) + * @returns {string} 格式化后的营收字符串 + */ +export const formatBusinessRevenue = (value, unit) => { + if (value === null || value === undefined) return '-'; + if (unit) { + if (unit === '元') { + const absValue = Math.abs(value); + if (absValue >= 100000000) { + return (value / 100000000).toFixed(2) + '亿元'; + } else if (absValue >= 10000) { + return (value / 10000).toFixed(2) + '万元'; + } + return value.toFixed(0) + '元'; + } else if (unit === '万元') { + const absValue = Math.abs(value); + if (absValue >= 10000) { + return (value / 10000).toFixed(2) + '亿元'; + } + return value.toFixed(2) + '万元'; + } else if (unit === '亿元') { + return value.toFixed(2) + '亿元'; + } else { + return value.toFixed(2) + unit; + } + } + // 无单位时,假设为元 + const absValue = Math.abs(value); + if (absValue >= 100000000) { + return (value / 100000000).toFixed(2) + '亿元'; + } else if (absValue >= 10000) { + return (value / 10000).toFixed(2) + '万元'; + } + return value.toFixed(2) + '元'; +}; + +/** + * 格式化百分比 + * @param {number|null|undefined} value - 百分比值 + * @param {number} [decimals=2] - 小数位数 + * @returns {string} 格式化后的百分比字符串 + */ +export const formatPercentage = (value, decimals = 2) => { + if (value === null || value === undefined) return '-'; + return value.toFixed(decimals) + '%'; +}; diff --git a/src/views/Company/CompanyOverview.js b/src/views/Company/CompanyOverview.js deleted file mode 100644 index d5db0be8..00000000 --- a/src/views/Company/CompanyOverview.js +++ /dev/null @@ -1,2677 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - Box, VStack, HStack, Text, Badge, Card, CardBody, CardHeader, - Heading, SimpleGrid, Divider, Spinner, Center, Alert, AlertIcon, - Tabs, TabList, TabPanels, Tab, TabPanel, Button, useColorModeValue, - Tag, TagLabel, Icon, Tooltip, Flex, Grid, GridItem, useToast, - Table, Thead, Tbody, Tr, Th, Td, TableContainer, IconButton, - Skeleton, SkeletonText, Progress, Stack, Stat, StatLabel, StatNumber, - StatHelpText, Container, Wrap, WrapItem, List, ListItem, - ListIcon, Accordion, AccordionItem, AccordionButton, AccordionPanel, - AccordionIcon, Fade, ScaleFade, useDisclosure, Modal, ModalOverlay, - ModalContent, ModalHeader, ModalFooter, ModalBody, ModalCloseButton, - Circle, Square, Avatar, AvatarGroup, Input, InputGroup, InputLeftElement, - Link, Breadcrumb, BreadcrumbItem, BreadcrumbLink, Image, Code, - chakra -} from '@chakra-ui/react'; - -import { - FaBuilding, FaMapMarkerAlt, FaChartLine, FaLightbulb, FaRocket, - FaNetworkWired, FaChevronDown, FaChevronUp, FaChevronLeft, FaChevronRight, - FaCog, FaTrophy, FaShieldAlt, FaBrain, FaChartPie, FaHistory, FaCheckCircle, - FaExclamationCircle, FaArrowUp, FaArrowDown, FaArrowRight, FaArrowLeft, - FaLink, FaStar, FaUserTie, FaIndustry, FaDollarSign, FaBalanceScale, FaChartBar, - FaEye, FaFlask, FaHandshake, FaUsers, FaClock, FaCalendarAlt, - FaCircle, FaGlobe, FaEnvelope, FaPhone, FaFax, FaBriefcase, - FaUniversity, FaGraduationCap, FaVenusMars, FaPassport, FaFileAlt, - FaNewspaper, FaBullhorn, FaUserShield, FaShareAlt, FaSitemap, - FaSearch, FaDownload, FaExternalLinkAlt, FaInfoCircle, FaCrown, - FaCertificate, FaAward, FaExpandAlt, FaCompressAlt, FaGavel, FaFire -} from 'react-icons/fa'; - -import { - RepeatIcon, InfoIcon, ChevronRightIcon, TimeIcon, EmailIcon, - PhoneIcon, ExternalLinkIcon, AttachmentIcon, CalendarIcon, SearchIcon, - WarningIcon, CheckIcon -} from '@chakra-ui/icons'; - -import ReactECharts from 'echarts-for-react'; -import { logger } from '../../utils/logger'; -import { getApiBase } from '../../utils/apiConfig'; - -// API配置 -const API_BASE_URL = getApiBase(); - -// 格式化工具 -const formatUtils = { - formatCurrency: (value) => { - if (!value && value !== 0) return '-'; - const absValue = Math.abs(value); - if (absValue >= 100000000) { - return (value / 100000000).toFixed(2) + '亿元'; - } else if (absValue >= 10000) { - return (value / 10000).toFixed(2) + '万元'; - } - return value.toFixed(2) + '元'; - }, - formatRegisteredCapital: (value) => { - // 注册资本字段,数据库存储的是万元为单位的数值 - if (!value && value !== 0) return '-'; - const absValue = Math.abs(value); - if (absValue >= 100000) { // 10亿万元 = 10亿元 - return (value / 10000).toFixed(2) + '亿元'; - } - return value.toFixed(2) + '万元'; - }, - formatBusinessRevenue: (value, unit) => { - // 业务收入格式化,考虑数据库中的单位字段 - if (!value && value !== 0) return '-'; - - if (unit) { - // 根据数据库中的单位进行智能格式化 - if (unit === '元') { - // 元为单位时,自动转换为合适的单位显示 - const absValue = Math.abs(value); - if (absValue >= 100000000) { - return (value / 100000000).toFixed(2) + '亿元'; - } else if (absValue >= 10000) { - return (value / 10000).toFixed(2) + '万元'; - } - return value.toFixed(0) + '元'; - } else if (unit === '万元') { - // 万元为单位时,可能需要转换为亿元 - const absValue = Math.abs(value); - if (absValue >= 10000) { - return (value / 10000).toFixed(2) + '亿元'; - } - return value.toFixed(2) + '万元'; - } else if (unit === '亿元') { - // 亿元为单位时,直接显示 - return value.toFixed(2) + '亿元'; - } else { - // 其他单位直接显示 - return value.toFixed(2) + unit; - } - } - - // 没有单位字段时,使用默认的货币格式化 - const absValue = Math.abs(value); - if (absValue >= 100000000) { - return (value / 100000000).toFixed(2) + '亿元'; - } else if (absValue >= 10000) { - return (value / 10000).toFixed(2) + '万元'; - } - return value.toFixed(2) + '元'; - }, - formatPercentage: (value) => { - if (!value && value !== 0) return '-'; - return value.toFixed(2) + '%'; - }, - formatNumber: (value) => { - if (!value && value !== 0) return '-'; - return value.toLocaleString('zh-CN'); - }, - formatDate: (dateString) => { - if (!dateString) return '-'; - return new Date(dateString).toLocaleDateString('zh-CN'); - }, - formatShares: (value) => { - if (!value && value !== 0) return '-'; - const absValue = Math.abs(value); - if (absValue >= 100000000) { - return (value / 100000000).toFixed(2) + '亿股'; - } else if (absValue >= 10000) { - return (value / 10000).toFixed(2) + '万股'; - } - return value.toFixed(0) + '股'; - } -}; - -// 免责声明组件 -const DisclaimerBox = () => { - return ( - - - - 免责声明 - - 本内容由AI模型基于新闻、公告、研报等公开信息自动分析和生成,未经许可严禁转载。 - 所有内容仅供参考,不构成任何投资建议,请投资者注意风险,独立审慎决策。 - - - - ); -}; - -// 评分进度条组件 -const ScoreBar = ({ label, score, maxScore = 100, colorScheme = 'blue', icon }) => { - const percentage = (score / maxScore) * 100; - const getColorScheme = () => { - if (percentage >= 80) return 'purple'; - if (percentage >= 60) return 'blue'; - if (percentage >= 40) return 'yellow'; - return 'orange'; - }; - - return ( - - - - {icon && } - {label} - - {score || 0} - - - - ); -}; - -// 业务结构树形图组件 -const BusinessTreeItem = ({ business, depth = 0 }) => { - const bgColor = useColorModeValue('gray.50', 'gray.700'); - const borderColor = useColorModeValue('gray.200', 'gray.600'); - const profitColor = business.financial_metrics?.profit_growth > 0 ? 'red.500' : 'green.500'; - - return ( - 0 ? `4px solid` : 'none'} - borderLeftColor="blue.400" - borderRadius="md" - mb={2} - _hover={{ shadow: 'md' }} - transition="all 0.2s" - > - - - - - {business.business_name} - - {business.financial_metrics?.revenue_ratio > 30 && ( - 核心业务 - )} - - - - 营收占比: {formatUtils.formatPercentage(business.financial_metrics?.revenue_ratio)} - - - 毛利率: {formatUtils.formatPercentage(business.financial_metrics?.gross_margin)} - - {business.growth_metrics?.revenue_growth && ( - 0 ? 'red' : 'green'}> - - 增长: {business.growth_metrics.revenue_growth > 0 ? '+' : ''}{formatUtils.formatPercentage(business.growth_metrics.revenue_growth)} - - - )} - - - - - {(() => { - // 优先使用business.revenue,如果没有则使用financial_metrics.revenue - const revenue = business.revenue || business.financial_metrics?.revenue; - const unit = business.revenue_unit; - if (revenue || revenue === 0) { - return formatUtils.formatBusinessRevenue(revenue, unit); - } - return '-'; - })()} - - 营业收入 - - - - ); -}; - -// 产业链节点卡片 -const ValueChainNodeCard = ({ node, isCompany = false, level = 0 }) => { - const { isOpen, onOpen, onClose } = useDisclosure(); - const [relatedCompanies, setRelatedCompanies] = useState([]); - const [loadingRelated, setLoadingRelated] = useState(false); - const toast = useToast(); - - const getColorScheme = () => { - if (isCompany) return 'blue'; - if (level < 0) return 'orange'; - if (level > 0) return 'green'; - return 'gray'; - }; - - const colorScheme = getColorScheme(); - const bgColor = useColorModeValue(`${colorScheme}.50`, `${colorScheme}.900`); - const borderColor = useColorModeValue(`${colorScheme}.200`, `${colorScheme}.600`); - - const getNodeTypeIcon = (type) => { - const icons = { - 'company': FaBuilding, - 'supplier': FaHandshake, - 'customer': FaUserTie, - 'product': FaIndustry, - 'service': FaCog, - 'channel': FaNetworkWired, - 'raw_material': FaFlask - }; - return icons[type] || FaBuilding; - }; - - const getImportanceColor = (score) => { - if (score >= 80) return 'red'; - if (score >= 60) return 'orange'; - if (score >= 40) return 'yellow'; - return 'green'; - }; - - // 获取相关公司 - const fetchRelatedCompanies = async () => { - setLoadingRelated(true); - try { - const response = await fetch( - `${API_BASE_URL}/api/company/value-chain/related-companies?node_name=${encodeURIComponent(node.node_name)}` - ); - const data = await response.json(); - 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.message, - status: 'error', - duration: 3000, - isClosable: true, - }); - } finally { - setLoadingRelated(false); - } - }; - - const handleCardClick = () => { - onOpen(); - if (relatedCompanies.length === 0) { - fetchRelatedCompanies(); - } - }; - - return ( - <> - - - - - - - - {isCompany && ( - 核心企业 - )} - - {node.importance_score >= 70 && ( - - - - )} - - - - {node.node_name} - - - {node.node_description && ( - - {node.node_description} - - )} - - - - {node.node_type} - - {node.market_share && ( - - 份额 {node.market_share}% - - )} - - - {(node.importance_score || node.importance_score === 0) && ( - - - 重要度 - {node.importance_score} - - - - )} - - - - - - - - - - - - - {node.node_name} - - {node.node_type} - {isCompany && 核心企业} - - - - - - - - {node.node_description && ( - - 节点描述 - {node.node_description} - - )} - - - - 重要度评分 - {node.importance_score || 0} - - - - - - {node.market_share && ( - - 市场份额 - {node.market_share}% - - )} - - {node.dependency_degree && ( - - 依赖程度 - {node.dependency_degree}% - - 50 ? 'orange' : 'green'} - borderRadius="full" - /> - - - )} - - - - - {/* 相关公司列表 */} - - - 相关公司 - {loadingRelated && } - - {loadingRelated ? ( -
- -
- ) : relatedCompanies.length > 0 ? ( - - {relatedCompanies.map((company, idx) => { - // 获取节点层级标签 - const getLevelLabel = (level) => { - if (level < 0) return { text: '上游', color: 'orange' }; - if (level === 0) return { text: '核心', color: 'blue' }; - if (level > 0) return { text: '下游', color: 'green' }; - return { text: '未知', color: 'gray' }; - }; - - const levelInfo = getLevelLabel(company.node_info?.node_level); - - return ( - - - - {/* 公司基本信息 */} - - - - {company.stock_name} - {company.stock_code} - - {levelInfo.text} - - {company.node_info?.node_type && ( - - {company.node_info.node_type} - - )} - - {company.company_name && ( - - {company.company_name} - - )} - - } - variant="ghost" - colorScheme="blue" - onClick={() => { - window.location.href = `/company?stock_code=${company.stock_code}`; - }} - aria-label="查看公司详情" - /> - - - {/* 节点描述 */} - {company.node_info?.node_description && ( - - {company.node_info.node_description} - - )} - - {/* 节点指标 */} - {(company.node_info?.importance_score || company.node_info?.market_share || company.node_info?.dependency_degree) && ( - - {company.node_info.importance_score && ( - - 重要度: - {company.node_info.importance_score} - - )} - {company.node_info.market_share && ( - - 市场份额: - {company.node_info.market_share}% - - )} - {company.node_info.dependency_degree && ( - - 依赖度: - {company.node_info.dependency_degree}% - - )} - - )} - - {/* 流向关系 */} - {company.relationships && company.relationships.length > 0 && ( - - - 产业链关系: - - - {company.relationships.map((rel, ridx) => ( - - - - {rel.role === 'source' ? '流向' : '来自'} - - {rel.connected_node} - - - {rel.relationship_desc && ( - - {rel.relationship_desc} - - )} - {rel.flow_ratio && ( - - {rel.flow_ratio}% - - )} - - ))} - - - )} - - - - ); - })} - - ) : ( -
- - - 暂无相关公司 - -
- )} -
-
-
- - - -
-
- - ); -}; - -// 关键因素卡片 -const KeyFactorCard = ({ factor }) => { - const impactColor = { - positive: 'red', - negative: 'green', - neutral: 'gray', - mixed: 'yellow' - }[factor.impact_direction] || 'gray'; - - const bgColor = useColorModeValue('white', 'gray.800'); - const borderColor = useColorModeValue('gray.200', 'gray.600'); - - return ( - - - - - {factor.factor_name} - - {factor.impact_direction === 'positive' ? '正面' : - factor.impact_direction === 'negative' ? '负面' : - factor.impact_direction === 'mixed' ? '混合' : '中性'} - - - - - - {factor.factor_value} - {factor.factor_unit && ` ${factor.factor_unit}`} - - {factor.year_on_year && ( - 0 ? 'red' : 'green'}> - 0 ? FaArrowUp : FaArrowDown} mr={1} boxSize={3} /> - {Math.abs(factor.year_on_year)}% - - )} - - - {factor.factor_desc && ( - - {factor.factor_desc} - - )} - - - - 影响权重: {factor.impact_weight} - - {factor.report_period && ( - {factor.report_period} - )} - - - - - ); -}; - -// 时间线组件 -const TimelineComponent = ({ events }) => { - const [selectedEvent, setSelectedEvent] = useState(null); - const { isOpen, onOpen, onClose } = useDisclosure(); - - const handleEventClick = (event) => { - setSelectedEvent(event); - onOpen(); - }; - - return ( - <> - - - - - {events.map((event, idx) => { - const isPositive = event.impact_metrics?.is_positive; - const iconColor = isPositive ? 'red.500' : 'green.500'; - const bgColor = useColorModeValue( - isPositive ? 'red.50' : 'green.50', - isPositive ? 'red.900' : 'green.900' - ); - - return ( - - - - - - - - - handleEventClick(event)} - _hover={{ shadow: 'lg', transform: 'translateX(4px)' }} - transition="all 0.3s ease" - > - - - - - {event.event_title} - - - - {event.event_date} - - - - - {event.event_type} - - - - - {event.event_desc} - - - - 影响度: - 70 ? 'red' : 'orange'} - borderRadius="full" - /> - - {event.impact_metrics?.impact_score || 0} - - - - - - - - ); - })} - - - - {selectedEvent && ( - - - - - - - - {selectedEvent.event_title} - - - {selectedEvent.event_type} - - {selectedEvent.event_date} - - - - - - - - - 事件详情 - {selectedEvent.event_desc} - - - {selectedEvent.related_info?.financial_impact && ( - - 财务影响 - - {selectedEvent.related_info.financial_impact} - - - )} - - - 影响评估 - - - 影响度 - 70 ? 'red' : 'orange'} - hasStripe - isAnimated - /> - - {selectedEvent.impact_metrics?.impact_score || 0}/100 - - - - - {selectedEvent.impact_metrics?.is_positive ? '正面影响' : '负面影响'} - - - - - - - - - - - - )} - - ); -}; - -// 股东类型标签组件 -const ShareholderTypeBadge = ({ type }) => { - const typeConfig = { - '基金': { color: 'blue', icon: FaChartBar }, - '个人': { color: 'green', icon: FaUserTie }, - '法人': { color: 'purple', icon: FaBuilding }, - 'QFII': { color: 'orange', icon: FaGlobe }, - '社保': { color: 'red', icon: FaShieldAlt }, - '保险': { color: 'teal', icon: FaShieldAlt }, - '信托': { color: 'cyan', icon: FaBriefcase }, - '券商': { color: 'pink', icon: FaChartLine } - }; - - const config = Object.entries(typeConfig).find(([key]) => type?.includes(key))?.[1] || - { color: 'gray', icon: FaCircle }; - - return ( - - - {type} - - ); -}; - -// 主组件 - 完整版 -const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => { - const [stockCode, setStockCode] = useState(propStockCode || '000001'); - const [loading, setLoading] = useState(false); - - // 监听props中的stockCode变化 - useEffect(() => { - if (propStockCode && propStockCode !== stockCode) { - setStockCode(propStockCode); - } - }, [propStockCode, stockCode]); - - // 企业深度分析数据 - const [comprehensiveData, setComprehensiveData] = useState(null); - const [valueChainData, setValueChainData] = useState(null); - const [keyFactorsData, setKeyFactorsData] = useState(null); - - // 股票概览数据 - const [basicInfo, setBasicInfo] = useState(null); - const [actualControl, setActualControl] = useState([]); - const [concentration, setConcentration] = useState([]); - const [management, setManagement] = useState([]); - const [topCirculationShareholders, setTopCirculationShareholders] = useState([]); - const [topShareholders, setTopShareholders] = useState([]); - const [branches, setBranches] = useState([]); - const [announcements, setAnnouncements] = useState([]); - const [disclosureSchedule, setDisclosureSchedule] = useState([]); - - // 新闻动态数据 - const [newsEvents, setNewsEvents] = useState([]); - const [newsLoading, setNewsLoading] = useState(false); - const [newsSearchQuery, setNewsSearchQuery] = useState(''); - const [newsPagination, setNewsPagination] = useState({ - page: 1, - per_page: 10, - total: 0, - pages: 0, - has_next: false, - has_prev: false - }); - - const [error, setError] = useState(null); - - const toast = useToast(); - const bgColor = useColorModeValue('gray.50', 'gray.900'); - const cardBg = useColorModeValue('white', 'gray.800'); - // 高亮区域颜色(修复:不能在 JSX 中调用 hooks) - const blueBg = useColorModeValue('blue.50', 'blue.900'); - const greenBg = useColorModeValue('green.50', 'green.900'); - const purpleBg = useColorModeValue('purple.50', 'purple.900'); - const orangeBg = useColorModeValue('orange.50', 'orange.900'); - const { isOpen: isAnnouncementOpen, onOpen: onAnnouncementOpen, onClose: onAnnouncementClose } = useDisclosure(); - const [selectedAnnouncement, setSelectedAnnouncement] = useState(null); - - // 业务板块详情展开状态 - const [expandedSegments, setExpandedSegments] = useState({}); - - // 切换业务板块展开状态 - const toggleSegmentExpansion = (segmentIndex) => { - setExpandedSegments(prev => ({ - ...prev, - [segmentIndex]: !prev[segmentIndex] - })); - }; - - // 加载数据 - const loadData = async () => { - setLoading(true); - setError(null); - - try { - const requests = [ - // 深度分析数据 - fetch(`${API_BASE_URL}/api/company/comprehensive-analysis/${stockCode}`).then(r => r.json()), - fetch(`${API_BASE_URL}/api/company/value-chain-analysis/${stockCode}`).then(r => r.json()), - fetch(`${API_BASE_URL}/api/company/key-factors-timeline/${stockCode}`).then(r => r.json()), - // 股票概览数据 - fetch(`${API_BASE_URL}/api/stock/${stockCode}/basic-info`).then(r => r.json()), - fetch(`${API_BASE_URL}/api/stock/${stockCode}/actual-control`).then(r => r.json()), - fetch(`${API_BASE_URL}/api/stock/${stockCode}/concentration`).then(r => r.json()), - fetch(`${API_BASE_URL}/api/stock/${stockCode}/management?active_only=true`).then(r => r.json()), - fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-circulation-shareholders?limit=10`).then(r => r.json()), - fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-shareholders?limit=10`).then(r => r.json()), - fetch(`${API_BASE_URL}/api/stock/${stockCode}/branches`).then(r => r.json()), - fetch(`${API_BASE_URL}/api/stock/${stockCode}/announcements?limit=20`).then(r => r.json()), - fetch(`${API_BASE_URL}/api/stock/${stockCode}/disclosure-schedule`).then(r => r.json()) - ]; - - const [ - comprehensiveRes, valueChainRes, keyFactorsRes, - basicRes, actualRes, concentrationRes, managementRes, - circulationRes, shareholdersRes, branchesRes, announcementsRes, disclosureRes - ] = await Promise.all(requests); - - // 设置深度分析数据 - if (comprehensiveRes.success) setComprehensiveData(comprehensiveRes.data); - if (valueChainRes.success) setValueChainData(valueChainRes.data); - if (keyFactorsRes.success) setKeyFactorsData(keyFactorsRes.data); - - // 设置股票概览数据 - if (basicRes.success) setBasicInfo(basicRes.data); - if (actualRes.success) setActualControl(actualRes.data); - if (concentrationRes.success) setConcentration(concentrationRes.data); - if (managementRes.success) setManagement(managementRes.data); - if (circulationRes.success) setTopCirculationShareholders(circulationRes.data); - if (shareholdersRes.success) setTopShareholders(shareholdersRes.data); - if (branchesRes.success) setBranches(branchesRes.data); - if (announcementsRes.success) setAnnouncements(announcementsRes.data); - if (disclosureRes.success) setDisclosureSchedule(disclosureRes.data); - - } catch (err) { - setError(err.message); - logger.error('CompanyOverview', 'loadData', err, { stockCode }); - - // ❌ 移除数据加载失败toast - // toast({ - // title: '数据加载失败', - // description: err.message, - // status: 'error', - // duration: 3000, - // isClosable: true, - // }); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - if (stockCode) { - loadData(); - } - }, [stockCode]); - - // 加载新闻事件 - const loadNewsEvents = async (page = 1, searchQuery = '') => { - setNewsLoading(true); - try { - // 构建查询参数 - const params = new URLSearchParams({ - page: page.toString(), - per_page: '10', - sort: 'new', - include_creator: 'true', - include_stats: 'true' - }); - - // 搜索关键词优先级: - // 1. 用户输入的搜索关键词 - // 2. 股票简称 - const queryText = searchQuery || basicInfo?.SECNAME || ''; - if (queryText) { - params.append('q', queryText); - } - - const response = await fetch(`${API_BASE_URL}/api/events?${params.toString()}`); - const data = await response.json(); - - // API返回 data.data.events - const events = data.data?.events || data.events || []; - const pagination = data.data?.pagination || { - page: 1, - per_page: 10, - total: 0, - pages: 0, - has_next: false, - has_prev: false - }; - - setNewsEvents(events); - setNewsPagination(pagination); - } catch (err) { - logger.error('CompanyOverview', 'loadNewsEvents', err, { stockCode, searchQuery, page }); - setNewsEvents([]); - setNewsPagination({ - page: 1, - per_page: 10, - total: 0, - pages: 0, - has_next: false, - has_prev: false - }); - } finally { - setNewsLoading(false); - } - }; - - // 当基本信息加载完成后,加载新闻事件 - useEffect(() => { - if (basicInfo) { - loadNewsEvents(1); - } - }, [basicInfo]); - - // 处理搜索 - const handleNewsSearch = () => { - loadNewsEvents(1, newsSearchQuery); - }; - - // 处理分页 - const handleNewsPageChange = (newPage) => { - loadNewsEvents(newPage, newsSearchQuery); - // 滚动到新闻列表顶部 - document.getElementById('news-list-top')?.scrollIntoView({ behavior: 'smooth' }); - }; - - // 管理层职位分类 - const getManagementByCategory = () => { - const categories = { - '高管': [], - '董事': [], - '监事': [], - '其他': [] - }; - - management.forEach(person => { - if (person.position_category === '高管' || person.position_name?.includes('总')) { - categories['高管'].push(person); - } else if (person.position_category === '董事' || person.position_name?.includes('董事')) { - categories['董事'].push(person); - } else if (person.position_category === '监事' || person.position_name?.includes('监事')) { - categories['监事'].push(person); - } else { - categories['其他'].push(person); - } - }); - - return categories; - }; - - // 计算股权集中度变化 - const getConcentrationTrend = () => { - const grouped = {}; - 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, 5); - }; - - // 生成雷达图配置 - const getRadarChartOption = () => { - 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) => params.value, - color: '#3182ce', - fontSize: 10 - } - }] - }] - }; - }; - - // 生成产业链桑基图配置 - const getSankeyChartOption = () => { - if (!valueChainData?.value_chain_flows || valueChainData.value_chain_flows.length === 0) return null; - - const nodes = new Set(); - const links = []; - - valueChainData.value_chain_flows.forEach(flow => { - 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, - 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 } - }] - }; - }; - - if (loading) { - return ( - - -
- - - 正在加载企业全景数据... - -
-
-
- ); - } - - return ( - - - - - {/* 公司头部信息 - 醒目展示 */} - {basicInfo && ( - - - - - - - - - - - - - {basicInfo.ORGNAME || basicInfo.SECNAME} - - - {basicInfo.SECCODE} - - - - - {basicInfo.sw_industry_l1} - - - {basicInfo.sw_industry_l2} - - {basicInfo.sw_industry_l3 && ( - - {basicInfo.sw_industry_l3} - - )} - - - - - - - - - - - 法定代表人: - {basicInfo.legal_representative} - - - - - - 董事长: - {basicInfo.chairman} - - - - - - 总经理: - {basicInfo.general_manager} - - - - - - 成立日期: - {formatUtils.formatDate(basicInfo.establish_date)} - - - - - - - {basicInfo.company_intro} - - - - - - - - - 注册资本 - - {formatUtils.formatRegisteredCapital(basicInfo.reg_capital)} - - - - - - - - - {basicInfo.province} {basicInfo.city} - - - - - {basicInfo.website} - - - - - {basicInfo.email} - - - - {basicInfo.tel} - - - - - - - - )} - - {/* 主要内容区 - 分为深度分析、基本信息和新闻动态 */} - - - 深度分析 - 基本信息 - 新闻动态 - - - - {/* 深度分析标签页 */} - - - {/* 核心定位卡片 */} - {comprehensiveData?.qualitative_analysis && ( - - - - - 核心定位 - - - - - - {comprehensiveData.qualitative_analysis.core_positioning?.one_line_intro && ( - - - {comprehensiveData.qualitative_analysis.core_positioning.one_line_intro} - - )} - - - - - 投资亮点 - - - {comprehensiveData.qualitative_analysis.core_positioning?.investment_highlights || '暂无数据'} - - - - - - - - 商业模式 - - - {comprehensiveData.qualitative_analysis.core_positioning?.business_model_desc || '暂无数据'} - - - - - - - - - )} - - {/* 竞争地位分析 */} - {comprehensiveData?.competitive_position && ( - - - - - 竞争地位分析 - {comprehensiveData.competitive_position.ranking && ( - - 行业排名 {comprehensiveData.competitive_position.ranking.industry_rank}/{comprehensiveData.competitive_position.ranking.total_companies} - - )} - - - - - {comprehensiveData.competitive_position.analysis?.main_competitors && ( - - 主要竞争对手 - - {comprehensiveData.competitive_position.analysis.main_competitors - .split(',') - .map((competitor, idx) => ( - - - {competitor.trim()} - - ))} - - - )} - - - - - - - - - - - - - - - - - {getRadarChartOption() && ( - - )} - - - - - - - - 竞争优势 - - {comprehensiveData.competitive_position.analysis?.competitive_advantages || '暂无数据'} - - - - 竞争劣势 - - {comprehensiveData.competitive_position.analysis?.competitive_disadvantages || '暂无数据'} - - - - - - )} - - {/* 业务结构分析 */} - {comprehensiveData?.business_structure && comprehensiveData.business_structure.length > 0 && ( - - - - - 业务结构分析 - {comprehensiveData.business_structure[0]?.report_period} - - - - - - {comprehensiveData.business_structure.map((business, idx) => ( - - ))} - - - - )} - - {/* 产业链分析 */} - {valueChainData && ( - - - - - 产业链分析 - - - 上游 {valueChainData.analysis_summary?.upstream_nodes || 0} - - - 核心 {valueChainData.analysis_summary?.company_nodes || 0} - - - 下游 {valueChainData.analysis_summary?.downstream_nodes || 0} - - - - - - - - - 层级视图 - 流向关系 - - - - - - {(valueChainData.value_chain_structure?.nodes_by_level?.['level_-2'] || - valueChainData.value_chain_structure?.nodes_by_level?.['level_-1']) && ( - - - 上游供应链 - 原材料与供应商 - - - {[ - ...(valueChainData.value_chain_structure?.nodes_by_level?.['level_-2'] || []), - ...(valueChainData.value_chain_structure?.nodes_by_level?.['level_-1'] || []) - ].map((node, idx) => ( - - ))} - - - )} - - {valueChainData.value_chain_structure?.nodes_by_level?.['level_0'] && ( - - - 核心企业 - 公司主体与产品 - - - {valueChainData.value_chain_structure.nodes_by_level['level_0'].map((node, idx) => ( - - ))} - - - )} - - {(valueChainData.value_chain_structure?.nodes_by_level?.['level_1'] || - valueChainData.value_chain_structure?.nodes_by_level?.['level_2']) && ( - - - 下游客户 - 客户与终端市场 - - - {[ - ...(valueChainData.value_chain_structure?.nodes_by_level?.['level_1'] || []), - ...(valueChainData.value_chain_structure?.nodes_by_level?.['level_2'] || []) - ].map((node, idx) => ( - - ))} - - - )} - - - - - {getSankeyChartOption() ? ( - - ) : ( -
- 暂无流向数据 -
- )} -
-
-
-
-
- )} - - {/* 关键因素与发展时间线 */} - - - {keyFactorsData?.key_factors && ( - - - - - 关键因素 - {keyFactorsData.key_factors.total_factors} 项 - - - - - - {keyFactorsData.key_factors.categories.map((category, idx) => ( - - - - - {category.category_name} - - {category.factors.length} - - - - - - - - {category.factors.map((factor, fidx) => ( - - ))} - - - - ))} - - - - )} - - - - {keyFactorsData?.development_timeline && ( - - - - - 发展时间线 - - - 正面 {keyFactorsData.development_timeline.statistics?.positive_events || 0} - - - 负面 {keyFactorsData.development_timeline.statistics?.negative_events || 0} - - - - - - - - - - - - )} - - - - {/* 业务板块详情 */} - {comprehensiveData?.business_segments && comprehensiveData.business_segments.length > 0 && ( - - - - - 业务板块详情 - {comprehensiveData.business_segments.length} 个板块 - - - - - - {comprehensiveData.business_segments.map((segment, idx) => { - const isExpanded = expandedSegments[idx]; - - return ( - - - - - {segment.segment_name} - - - - - 业务描述 - - {segment.segment_description || '暂无描述'} - - - - - 竞争地位 - - {segment.competitive_position || '暂无数据'} - - - - - 未来潜力 - - {segment.future_potential || '暂无数据'} - - - - {isExpanded && segment.key_products && ( - - 主要产品 - - {segment.key_products} - - - )} - - {isExpanded && segment.market_share && ( - - 市场份额 - - - {segment.market_share}% - - - - )} - - {isExpanded && segment.revenue_contribution && ( - - 营收贡献 - - - {segment.revenue_contribution}% - - - - )} - - - - ); - })} - - - - )} - - {/* 战略分析 */} - {comprehensiveData?.qualitative_analysis?.strategy && ( - - - - - 战略分析 - - - - - - - - 战略方向 - - - {comprehensiveData.qualitative_analysis.strategy.strategy_description || '暂无数据'} - - - - - - - - 战略举措 - - - {comprehensiveData.qualitative_analysis.strategy.strategic_initiatives || '暂无数据'} - - - - - - - - )} -
-
- - {/* 基本信息标签页 */} - - - - - - 股权结构 - 管理团队 - 公司公告 - 分支机构 - 工商信息 - - - - {/* 股权结构标签页 */} - - - {actualControl.length > 0 && ( - - - - 实际控制人 - - - - - - - {actualControl[0].actual_controller_name} - - - {actualControl[0].control_type} - - 截至 {formatUtils.formatDate(actualControl[0].end_date)} - - - - - 控制比例 - - {formatUtils.formatPercentage(actualControl[0].holding_ratio)} - - - {formatUtils.formatShares(actualControl[0].holding_shares)} - - - - - - - )} - - {concentration.length > 0 && ( - - - - 股权集中度 - - - {getConcentrationTrend().slice(0, 1).map(([date, items]) => ( - - - - {formatUtils.formatDate(date)} - - - - - {Object.entries(items).map(([key, item]) => ( - - {item.stat_item} - - - {formatUtils.formatPercentage(item.holding_ratio)} - - {item.ratio_change && ( - 0 ? 'red' : 'green'}> - 0 ? FaArrowUp : FaArrowDown} mr={1} boxSize={3} /> - {Math.abs(item.ratio_change).toFixed(2)}% - - )} - - - ))} - - - - ))} - - - )} - - {topShareholders.length > 0 && ( - - - - 十大股东 - {formatUtils.formatDate(topShareholders[0].end_date)} - - -
- - - - - - - - - - - - {topShareholders.slice(0, 10).map((shareholder, idx) => ( - - - - - - - - - ))} - -
排名股东名称股东类型持股数量持股比例股份性质
- - {shareholder.shareholder_rank} - - - - - {shareholder.shareholder_name} - - - - - - {formatUtils.formatShares(shareholder.holding_shares)} - - - {formatUtils.formatPercentage(shareholder.total_share_ratio)} - - - - {shareholder.share_nature || '流通股'} - -
- - - )} - - {topCirculationShareholders.length > 0 && ( - - - - 十大流通股东 - {formatUtils.formatDate(topCirculationShareholders[0].end_date)} - - - - - - - - - - - - - - {topCirculationShareholders.slice(0, 10).map((shareholder, idx) => ( - - - - - - - - ))} - -
排名股东名称股东类型持股数量流通股比例
- - {shareholder.shareholder_rank} - - - - - {shareholder.shareholder_name} - - - - - - {formatUtils.formatShares(shareholder.holding_shares)} - - - {formatUtils.formatPercentage(shareholder.circulation_share_ratio)} - -
-
-
- )} - - - - {/* 管理团队标签页 */} - - - {Object.entries(getManagementByCategory()).map(([category, people]) => ( - people.length > 0 && ( - - - - {category} - {people.length}人 - - - - {people.map((person, idx) => ( - - - - - - - {person.name} - {person.gender && ( - - )} - - - {person.position_name} - - - {person.education && ( - - - {person.education} - - )} - {person.birth_year && ( - - {new Date().getFullYear() - parseInt(person.birth_year)}岁 - - )} - {person.nationality && person.nationality !== '中国' && ( - - - {person.nationality} - - )} - - - 任职日期:{formatUtils.formatDate(person.start_date)} - - - - - - ))} - - - ) - ))} - - - - {/* 公司公告标签页 */} - - - {disclosureSchedule.length > 0 && ( - - - - 财报披露日程 - - - {disclosureSchedule.slice(0, 4).map((schedule, idx) => ( - - - - - {schedule.report_name} - - - {schedule.is_disclosed ? '已披露' : '预计'} - - - {formatUtils.formatDate( - schedule.is_disclosed ? schedule.actual_date : schedule.latest_scheduled_date - )} - - - - - ))} - - - )} - - - - - - - 最新公告 - - - {announcements.map((announcement, idx) => ( - { - setSelectedAnnouncement(announcement); - onAnnouncementOpen(); - }} - _hover={{ bg: 'gray.50' }} - > - - - - - - {announcement.info_type || '公告'} - - - {formatUtils.formatDate(announcement.announce_date)} - - - - {announcement.title} - - - - {announcement.format && ( - {announcement.format} - )} - } - variant="ghost" - onClick={(e) => { - e.stopPropagation(); - window.open(announcement.url, '_blank'); - }} - /> - - - - - ))} - - - - - - {/* 分支机构标签页 */} - - {branches.length > 0 ? ( - - {branches.map((branch, idx) => ( - - - - - {branch.branch_name} - - {branch.business_status} - - - - - - 注册资本 - - {branch.register_capital || '-'} - - - - 法人代表 - - {branch.legal_person || '-'} - - - - 成立日期 - - {formatUtils.formatDate(branch.register_date)} - - - - 关联企业 - - {branch.related_company_count || 0} 家 - - - - - - - ))} - - ) : ( -
- - - 暂无分支机构信息 - -
- )} -
- - {/* 工商信息标签页 */} - - {basicInfo && ( - - - - 工商信息 - - - 统一信用代码 - {basicInfo.credit_code} - - - 公司规模 - {basicInfo.company_size} - - - 注册地址 - {basicInfo.reg_address} - - - 办公地址 - {basicInfo.office_address} - - - - - - 服务机构 - - - 会计师事务所 - {basicInfo.accounting_firm} - - - 律师事务所 - {basicInfo.law_firm} - - - - - - - - - 主营业务 - {basicInfo.main_business} - - - - 经营范围 - - {basicInfo.business_scope} - - - - )} - - - - - - - - {/* 新闻动态标签页 */} - - - - - - {/* 搜索框和统计信息 */} - - - - - - - setNewsSearchQuery(e.target.value)} - onKeyPress={(e) => e.key === 'Enter' && handleNewsSearch()} - /> - - - - - {newsPagination.total > 0 && ( - - - - 共找到 {newsPagination.total} 条新闻 - - - )} - - -
- - {/* 新闻列表 */} - {newsLoading ? ( -
- - - 正在加载新闻... - -
- ) : newsEvents.length > 0 ? ( - <> - - {newsEvents.map((event, idx) => { - const importanceColor = { - 'S': 'red', - 'A': 'orange', - 'B': 'yellow', - 'C': 'green' - }[event.importance] || 'gray'; - - const eventTypeIcon = { - '企业公告': FaBullhorn, - '政策': FaGavel, - '技术突破': FaFlask, - '企业融资': FaDollarSign, - '政策监管': FaShieldAlt, - '政策动态': FaFileAlt, - '行业事件': FaIndustry - }[event.event_type] || FaNewspaper; - - return ( - - - - {/* 标题栏 */} - - - - - - {event.title} - - - - {/* 标签栏 */} - - {event.importance && ( - - {event.importance}级 - - )} - {event.event_type && ( - - {event.event_type} - - )} - {event.invest_score && ( - - 投资分: {event.invest_score} - - )} - {event.keywords && event.keywords.length > 0 && ( - <> - {event.keywords.slice(0, 4).map((keyword, kidx) => ( - - {typeof keyword === 'string' ? keyword : keyword.concept} - - ))} - - )} - - - - {/* 右侧信息栏 */} - - - {event.created_at ? new Date(event.created_at).toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit' - }) : ''} - - - {event.view_count !== undefined && ( - - - {event.view_count} - - )} - {event.hot_score !== undefined && ( - - - {event.hot_score.toFixed(1)} - - )} - - {event.creator && ( - - @{event.creator.username} - - )} - - - - {/* 描述 */} - {event.description && ( - - {event.description} - - )} - - {/* 收益率数据 */} - {(event.related_avg_chg !== null || event.related_max_chg !== null || event.related_week_chg !== null) && ( - - - - - 相关涨跌: - - {event.related_avg_chg !== null && event.related_avg_chg !== undefined && ( - - 平均 - 0 ? 'red.500' : 'green.500'} - > - {event.related_avg_chg > 0 ? '+' : ''}{event.related_avg_chg.toFixed(2)}% - - - )} - {event.related_max_chg !== null && event.related_max_chg !== undefined && ( - - 最大 - 0 ? 'red.500' : 'green.500'} - > - {event.related_max_chg > 0 ? '+' : ''}{event.related_max_chg.toFixed(2)}% - - - )} - {event.related_week_chg !== null && event.related_week_chg !== undefined && ( - - - 0 ? 'red.500' : 'green.500'} - > - {event.related_week_chg > 0 ? '+' : ''}{event.related_week_chg.toFixed(2)}% - - - )} - - - )} - - - - ); - })} - - - {/* 分页控件 */} - {newsPagination.pages > 1 && ( - - - {/* 分页信息 */} - - 第 {newsPagination.page} / {newsPagination.pages} 页 - - - {/* 分页按钮 */} - - - - - {/* 页码按钮 */} - {(() => { - const currentPage = newsPagination.page; - const totalPages = newsPagination.pages; - const pageButtons = []; - - // 显示当前页及前后各2页 - let startPage = Math.max(1, currentPage - 2); - let endPage = Math.min(totalPages, currentPage + 2); - - // 如果开始页大于1,显示省略号 - if (startPage > 1) { - pageButtons.push( - ... - ); - } - - for (let i = startPage; i <= endPage; i++) { - pageButtons.push( - - ); - } - - // 如果结束页小于总页数,显示省略号 - if (endPage < totalPages) { - pageButtons.push( - ... - ); - } - - return pageButtons; - })()} - - - - - - - )} - - ) : ( -
- - - 暂无相关新闻 - - {newsSearchQuery ? '尝试修改搜索关键词' : '该公司暂无新闻动态'} - - -
- )} - - - - - - - - - - - {/* 公告详情模态框 */} - - - - - - {selectedAnnouncement?.title} - - {selectedAnnouncement?.info_type} - - {formatUtils.formatDate(selectedAnnouncement?.announce_date)} - - - - - - - - 文件格式:{selectedAnnouncement?.format} - 文件大小:{selectedAnnouncement?.file_size} KB - - - - - - - - - - ); -}; - -export default CompanyAnalysisComplete; diff --git a/src/views/Company/FinancialPanorama.js b/src/views/Company/FinancialPanorama.js deleted file mode 100644 index 4bd626bb..00000000 --- a/src/views/Company/FinancialPanorama.js +++ /dev/null @@ -1,2154 +0,0 @@ -// src/views/Company/FinancialPanorama.jsx -import React, { useState, useEffect, useMemo } from 'react'; -import { logger } from '../../utils/logger'; -import { - Box, - Container, - Tabs, - TabList, - TabPanels, - Tab, - TabPanel, - Heading, - Text, - Table, - Thead, - Tbody, - Tr, - Th, - Td, - TableContainer, - Stat, - StatLabel, - StatNumber, - StatHelpText, - StatArrow, - SimpleGrid, - Card, - CardBody, - CardHeader, - Spinner, - Center, - Alert, - AlertIcon, - Badge, - VStack, - HStack, - Divider, - useColorModeValue, - Select, - Button, - Tooltip, - Progress, - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalBody, - ModalCloseButton, - useDisclosure, - Input, - Flex, - Tag, - TagLabel, - IconButton, - useToast, - Skeleton, - SkeletonText, - Grid, - GridItem, - ButtonGroup, - Stack, - Collapse, - useColorMode, -} from '@chakra-ui/react'; -import { - ChevronDownIcon, - ChevronUpIcon, - InfoIcon, - DownloadIcon, - RepeatIcon, - SearchIcon, - ViewIcon, - TimeIcon, - ArrowUpIcon, - ArrowDownIcon, -} from '@chakra-ui/icons'; -import ReactECharts from 'echarts-for-react'; -import { financialService, formatUtils, chartUtils } from '../../services/financialService'; - -const FinancialPanorama = ({ stockCode: propStockCode }) => { - // 状态管理 - const [stockCode, setStockCode] = useState(propStockCode || '600000'); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [selectedPeriods, setSelectedPeriods] = useState(8); - const [activeTab, setActiveTab] = useState(0); - - // 财务数据状态 - const [stockInfo, setStockInfo] = useState(null); - const [balanceSheet, setBalanceSheet] = useState([]); - const [incomeStatement, setIncomeStatement] = useState([]); - const [cashflow, setCashflow] = useState([]); - const [financialMetrics, setFinancialMetrics] = useState([]); - const [mainBusiness, setMainBusiness] = useState(null); - const [forecast, setForecast] = useState(null); - const [industryRank, setIndustryRank] = useState([]); - const [comparison, setComparison] = useState([]); - - // UI状态 - const { isOpen, onOpen, onClose } = useDisclosure(); - const [modalContent, setModalContent] = useState(null); - const [expandedRows, setExpandedRows] = useState({}); - const toast = useToast(); - const { colorMode } = useColorMode(); - - // 颜色配置(中国市场:红涨绿跌) - const bgColor = useColorModeValue('white', 'gray.800'); - const borderColor = useColorModeValue('gray.200', 'gray.600'); - const hoverBg = useColorModeValue('gray.50', 'gray.700'); - const positiveColor = useColorModeValue('red.500', 'red.400'); // 红涨 - const negativeColor = useColorModeValue('green.500', 'green.400'); // 绿跌 - - // 加载所有财务数据 - const loadFinancialData = async () => { - if (!stockCode || stockCode.length !== 6) { - logger.warn('FinancialPanorama', 'loadFinancialData', '无效的股票代码', { stockCode }); - toast({ - title: '请输入有效的6位股票代码', - status: 'warning', - duration: 3000, - }); - return; - } - - logger.debug('FinancialPanorama', '开始加载财务数据', { 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); - - // ❌ 移除数据加载成功toast - logger.info('FinancialPanorama', '财务数据加载成功', { stockCode }); - } catch (err) { - setError(err.message); - logger.error('FinancialPanorama', 'loadFinancialData', err, { stockCode, selectedPeriods }); - - // ❌ 移除数据加载失败toast - // toast({ title: '数据加载失败', description: err.message, status: 'error', duration: 5000 }); - } finally { - setLoading(false); - } - }; - - // 监听props中的stockCode变化 - useEffect(() => { - if (propStockCode && propStockCode !== stockCode) { - setStockCode(propStockCode); - } - }, [propStockCode, stockCode]); - - // 初始加载 - useEffect(() => { - if (stockCode) { - loadFinancialData(); - } - }, [stockCode, selectedPeriods]); - - // 计算同比变化率 - const calculateYoYChange = (currentValue, currentPeriod, allData, metricPath) => { - if (!currentValue || !currentPeriod) return { change: 0, intensity: 0 }; - - // 找到去年同期的数据 - const currentDate = new Date(currentPeriod); - const currentYear = currentDate.getFullYear(); - const currentMonth = currentDate.getMonth() + 1; - - // 查找去年同期 - const lastYearSamePeriod = allData.find(item => { - const itemDate = new Date(item.period); - const itemYear = itemDate.getFullYear(); - const itemMonth = itemDate.getMonth() + 1; - return itemYear === currentYear - 1 && itemMonth === currentMonth; - }); - - if (!lastYearSamePeriod) return { change: 0, intensity: 0 }; - - const previousValue = metricPath.split('.').reduce((obj, key) => obj?.[key], lastYearSamePeriod); - - if (!previousValue || previousValue === 0) return { change: 0, intensity: 0 }; - - const change = ((currentValue - previousValue) / Math.abs(previousValue)) * 100; - const intensity = Math.min(Math.abs(change) / 50, 1); // 50%变化达到最大强度 - return { change, intensity }; - }; - - // 获取单元格背景色(中国市场颜色) - const getCellBackground = (change, intensity) => { - if (change > 0) { - return `rgba(239, 68, 68, ${intensity * 0.15})`; // 红色背景,涨 - } else if (change < 0) { - return `rgba(34, 197, 94, ${intensity * 0.15})`; // 绿色背景,跌 - } - return 'transparent'; - }; - - // 点击指标行显示图表 - const showMetricChart = (metricName, metricKey, data, dataPath) => { - const chartData = data.map(item => { - const value = dataPath.split('.').reduce((obj, key) => obj?.[key], item); - return { - period: formatUtils.getReportType(item.period), - date: item.period, - value: value - }; - }).reverse(); - - const option = { - title: { - text: metricName, - left: 'center' - }, - tooltip: { - trigger: 'axis', - formatter: (params) => { - const value = params[0].value; - const formattedValue = value > 10000 ? - formatUtils.formatLargeNumber(value) : - value?.toFixed(2); - return `${params[0].name}
${metricName}: ${formattedValue}`; - } - }, - xAxis: { - type: 'category', - data: chartData.map(d => d.period), - axisLabel: { - rotate: 45 - } - }, - yAxis: { - type: 'value', - axisLabel: { - formatter: (value) => { - if (Math.abs(value) >= 100000000) { - return (value / 100000000).toFixed(0) + '亿'; - } else if (Math.abs(value) >= 10000) { - return (value / 10000).toFixed(0) + '万'; - } - return value.toFixed(0); - } - } - }, - series: [{ - type: 'bar', - data: chartData.map(d => d.value), - itemStyle: { - color: (params) => { - const idx = params.dataIndex; - if (idx === 0) return '#3182CE'; - const prevValue = chartData[idx - 1].value; - const currValue = params.value; - // 中国市场颜色:红涨绿跌 - return currValue >= prevValue ? '#EF4444' : '#10B981'; - } - }, - label: { - show: true, - position: 'top', - formatter: (params) => { - const value = params.value; - if (Math.abs(value) >= 100000000) { - return (value / 100000000).toFixed(1) + '亿'; - } else if (Math.abs(value) >= 10000) { - return (value / 10000).toFixed(1) + '万'; - } else if (Math.abs(value) >= 1) { - return value.toFixed(1); - } - return value.toFixed(2); - } - } - }] - }; - - setModalContent( - - - - - - - - - - - - - - - {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 ( - - - - - - - ); - })} - -
报告期数值同比环比
{item.period}{formatUtils.formatLargeNumber(item.value)} 0 ? positiveColor : yoy < 0 ? negativeColor : 'gray.500'}> - {yoy ? `${yoy.toFixed(2)}%` : '-'} - 0 ? positiveColor : qoq < 0 ? negativeColor : 'gray.500'}> - {qoq ? `${qoq.toFixed(2)}%` : '-'} -
-
-
- ); - onOpen(); - }; - - // 资产负债表组件 - 完整版 - const BalanceSheetTable = () => { - const [expandedSections, setExpandedSections] = useState({ - currentAssets: true, - nonCurrentAssets: true, - currentLiabilities: true, - nonCurrentLiabilities: true, - equity: true - }); - - const toggleSection = (section) => { - setExpandedSections(prev => ({ - ...prev, - [section]: !prev[section] - })); - }; - - // 完整的资产负债表指标 - const assetSections = [ - { - 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 }, - ] - }, - { - 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 }, - ] - }, - { - title: '资产总计', - key: 'totalAssets', - metrics: [ - { name: '资产总计', key: 'total_assets', path: 'assets.total', isTotal: true, isCore: true }, - ] - } - ]; - - const liabilitySections = [ - { - 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 }, - ] - }, - { - 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 }, - ] - }, - { - title: '负债合计', - key: 'totalLiabilities', - metrics: [ - { name: '负债合计', key: 'total_liabilities', path: 'liabilities.total', isTotal: true, isCore: true }, - ] - } - ]; - - const equitySections = [ - { - 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 }, - ] - } - ]; - - // 数组安全检查 - if (!Array.isArray(balanceSheet) || balanceSheet.length === 0) { - return ( - - - 暂无资产负债表数据 - - ); - } - - const maxColumns = Math.min(balanceSheet.length, 6); - const displayData = balanceSheet.slice(0, maxColumns); - - const renderSection = (sections, sectionType) => ( - <> - {sections.map(section => ( - - {section.title !== '资产总计' && section.title !== '负债合计' && ( - toggleSection(section.key)} - > - - - {expandedSections[section.key] ? : } - {section.title} - - - - )} - {(expandedSections[section.key] || section.title === '资产总计' || section.title === '负债合计' || section.title === '股东权益合计') && section.metrics.map(metric => { - const rowData = balanceSheet.map(item => { - const value = metric.path.split('.').reduce((obj, key) => obj?.[key], item); - return value; - }); - - return ( - showMetricChart(metric.name, metric.key, balanceSheet, metric.path)} - bg={metric.isTotal ? useColorModeValue('blue.50', 'blue.900') : 'transparent'} - > - - - {!metric.isTotal && } - - {metric.name} - - {metric.isCore && 核心} - - - {displayData.map((item, idx) => { - const value = rowData[idx]; - const { change, intensity } = calculateYoYChange( - value, - item.period, - balanceSheet, - metric.path - ); - - return ( - - - 数值: {formatUtils.formatLargeNumber(value)} - 同比: {change.toFixed(2)}% - - } - placement="top" - > - - {formatUtils.formatLargeNumber(value, 0)} - - - {Math.abs(change) > 30 && !metric.isTotal && ( - 0 ? positiveColor : negativeColor} - fontWeight="bold" - > - {change > 0 ? '↑' : '↓'} - {Math.abs(change).toFixed(0)}% - - )} - - ); - })} - - } - variant="ghost" - colorScheme="blue" - onClick={(e) => { - e.stopPropagation(); - showMetricChart(metric.name, metric.key, balanceSheet, metric.path); - }} - /> - - - ); - })} - - ))} - - ); - - return ( - - - - - - {displayData.map(item => ( - - ))} - - - - - {renderSection(assetSections, 'assets')} - - {renderSection(liabilitySections, 'liabilities')} - - {renderSection(equitySections, 'equity')} - -
项目 - - {formatUtils.getReportType(item.period)} - - {item.period.substring(0, 10)} - - - 操作
-
- ); - }; - - // 利润表组件 - 完整版 - const IncomeStatementTable = () => { - const [expandedSections, setExpandedSections] = useState({ - revenue: true, - costs: true, - profits: true, - eps: true - }); - - const toggleSection = (section) => { - setExpandedSections(prev => ({ - ...prev, - [section]: !prev[section] - })); - }; - - const sections = [ - { - 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' }, - ] - } - ]; - - // 数组安全检查 - if (!Array.isArray(incomeStatement) || incomeStatement.length === 0) { - return ( - - - 暂无利润表数据 - - ); - } - - const maxColumns = Math.min(incomeStatement.length, 6); - const displayData = incomeStatement.slice(0, maxColumns); - - const renderSection = (section) => ( - - toggleSection(section.key)} - > - - - {expandedSections[section.key] ? : } - {section.title} - - - - {expandedSections[section.key] && section.metrics.map(metric => { - const rowData = incomeStatement.map(item => { - const value = metric.path.split('.').reduce((obj, key) => obj?.[key], item); - return value; - }); - - return ( - showMetricChart(metric.name, metric.key, incomeStatement, metric.path)} - bg={metric.isTotal ? useColorModeValue('blue.50', 'blue.900') : - metric.isSubtotal ? useColorModeValue('orange.50', 'orange.900') : 'transparent'} - > - - - {!metric.isTotal && !metric.isSubtotal && } - - {metric.name} - - {metric.isCore && 核心} - - - {displayData.map((item, idx) => { - const value = rowData[idx]; - const { change, intensity } = calculateYoYChange( - value, - item.period, - incomeStatement, - metric.path - ); - - // 特殊处理:成本费用类负向指标,增长用绿色,减少用红色 - const isCostItem = metric.key.includes('cost') || metric.key.includes('expense') || - metric.key === 'income_tax' || metric.key.includes('impairment'); - const displayColor = isCostItem ? - (change > 0 ? negativeColor : positiveColor) : - (change > 0 ? positiveColor : negativeColor); - - return ( - - - - 数值: {metric.key.includes('eps') ? - value?.toFixed(3) : - formatUtils.formatLargeNumber(value)} - - 同比: {change.toFixed(2)}% - - } - placement="top" - > - - {metric.key.includes('eps') ? - value?.toFixed(3) : - formatUtils.formatLargeNumber(value, 0)} - - - {Math.abs(change) > 30 && !metric.isTotal && ( - - {change > 0 ? '↑' : '↓'} - {Math.abs(change).toFixed(0)}% - - )} - - ); - })} - - } - variant="ghost" - colorScheme="blue" - /> - - - ); - })} - - ); - - return ( - - - - - - {displayData.map(item => ( - - ))} - - - - - {sections.map(section => renderSection(section))} - -
项目 - - {formatUtils.getReportType(item.period)} - - {item.period.substring(0, 10)} - - - 操作
-
- ); - }; - - // 现金流量表组件 - const CashflowTable = () => { - const 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' }, - ]; - - // 数组安全检查 - if (!Array.isArray(cashflow) || cashflow.length === 0) { - return ( - - - 暂无现金流量表数据 - - ); - } - - const maxColumns = Math.min(cashflow.length, 8); - const displayData = cashflow.slice(0, maxColumns); - - return ( - - - - - - {displayData.map(item => ( - - ))} - - - - - {metrics.map(metric => { - const rowData = cashflow.map(item => { - const value = metric.path.split('.').reduce((obj, key) => obj?.[key], item); - return value; - }); - - return ( - showMetricChart(metric.name, metric.key, cashflow, metric.path)} - > - - {displayData.map((item, idx) => { - const value = rowData[idx]; - const isNegative = value < 0; - const { change, intensity } = calculateYoYChange( - value, - item.period, - cashflow, - metric.path - ); - - return ( - - ); - })} - - - ); - })} - -
项目 - - {formatUtils.getReportType(item.period)} - - {item.period.substring(0, 10)} - - - 趋势
- - {metric.name} - {['operating_net', 'free_cash_flow'].includes(metric.key) && - 核心} - - - - 数值: {formatUtils.formatLargeNumber(value)} - 同比: {change.toFixed(2)}% - - } - placement="top" - > - - {formatUtils.formatLargeNumber(value, 1)} - - - {Math.abs(change) > 50 && ( - 0 ? positiveColor : negativeColor} - fontWeight="bold" - > - {change > 0 ? '↑' : '↓'} - - )} - - } - variant="ghost" - colorScheme="blue" - /> -
-
- ); - }; - - // 财务指标表格组件 - 时间序列版 - const FinancialMetricsTable = () => { - const [selectedCategory, setSelectedCategory] = useState('profitability'); - - const metricsCategories = { - 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' }, - ] - } - }; - - // 数组安全检查 - if (!Array.isArray(financialMetrics) || financialMetrics.length === 0) { - return ( - - - 暂无财务指标数据 - - ); - } - - const maxColumns = Math.min(financialMetrics.length, 6); - const displayData = financialMetrics.slice(0, maxColumns); - const currentCategory = metricsCategories[selectedCategory]; - - return ( - - {/* 分类选择器 */} - - {Object.entries(metricsCategories).map(([key, category]) => ( - - ))} - - - {/* 指标表格 */} - - - - - - {displayData.map(item => ( - - ))} - - - - - {currentCategory.metrics.map(metric => { - const rowData = financialMetrics.map(item => { - const value = metric.path.split('.').reduce((obj, key) => obj?.[key], item); - return value; - }); - - return ( - showMetricChart(metric.name, metric.key, financialMetrics, metric.path)} - > - - {displayData.map((item, idx) => { - const value = rowData[idx]; - const { change, intensity } = calculateYoYChange( - value, - item.period, - financialMetrics, - metric.path - ); - - // 判断指标性质 - const isNegativeIndicator = metric.key.includes('days') || - metric.key.includes('expense_ratio') || - metric.key.includes('debt_ratio') || - metric.key.includes('cost_ratio'); - - // 对于负向指标,增加是坏事(绿色),减少是好事(红色) - const displayColor = isNegativeIndicator ? - (change > 0 ? negativeColor : positiveColor) : - (change > 0 ? positiveColor : negativeColor); - - return ( - - ); - })} - - - ); - })} - -
- {currentCategory.title} - - - {formatUtils.getReportType(item.period)} - - {item.period.substring(0, 10)} - - - 趋势
- - - {metric.name} - - {metric.isCore && 核心} - - - - {metric.name}: {value?.toFixed(2) || '-'} - 同比: {change.toFixed(2)}% - - } - placement="top" - > - 0 ? positiveColor : value < 0 ? negativeColor : 'gray.500') : - 'inherit' - } - > - {value?.toFixed(2) || '-'} - - - {Math.abs(change) > 20 && Math.abs(value) > 0.01 && ( - - {change > 0 ? '↑' : '↓'} - - )} - - } - variant="ghost" - colorScheme="blue" - onClick={(e) => { - e.stopPropagation(); - showMetricChart(metric.name, metric.key, financialMetrics, metric.path); - }} - /> -
-
- - {/* 关键指标快速对比 */} - - - 关键指标速览 - - - - {financialMetrics[0] && [ - { label: 'ROE', value: financialMetrics[0].profitability?.roe, format: 'percent' }, - { label: '毛利率', value: financialMetrics[0].profitability?.gross_margin, format: 'percent' }, - { label: '净利率', value: financialMetrics[0].profitability?.net_profit_margin, format: 'percent' }, - { label: '流动比率', value: financialMetrics[0].solvency?.current_ratio, format: 'decimal' }, - { label: '资产负债率', value: financialMetrics[0].solvency?.asset_liability_ratio, format: 'percent' }, - { label: '研发费用率', value: financialMetrics[0].expense_ratios?.rd_expense_ratio, format: 'percent' }, - ].map((item, idx) => ( - - {item.label} - - {item.format === 'percent' ? - formatUtils.formatPercent(item.value) : - item.value?.toFixed(2) || '-'} - - - ))} - - - -
- ); - }; - - // 主营业务分析组件 - 修复bug,支持product和industry两种分类 - const MainBusinessAnalysis = () => { - // 优先使用product_classification,如果为空则使用industry_classification - const hasProductData = mainBusiness?.product_classification?.length > 0; - const hasIndustryData = mainBusiness?.industry_classification?.length > 0; - - if (!hasProductData && !hasIndustryData) { - return ( - - - 暂无主营业务数据 - - ); - } - - // 选择数据源 - const dataSource = hasProductData ? 'product' : 'industry'; - const latestPeriod = hasProductData ? - mainBusiness.product_classification[0] : - mainBusiness.industry_classification[0]; - - const businessItems = hasProductData ? latestPeriod.products : latestPeriod.industries; - - // 过滤掉"合计"项,准备饼图数据 - const pieData = businessItems - .filter(item => item.content !== '合计') - .map(item => ({ - name: item.content, - value: item.revenue || 0 - })); - - const pieOption = { - title: { - text: `主营业务构成 - ${latestPeriod.report_type}`, - subtext: dataSource === 'industry' ? '按行业分类' : '按产品分类', - left: 'center' - }, - tooltip: { - trigger: 'item', - formatter: (params) => { - return `${params.name}
营收: ${formatUtils.formatLargeNumber(params.value)}
占比: ${params.percent}%`; - } - }, - legend: { - orient: 'vertical', - left: 'left', - top: 'center' - }, - series: [{ - type: 'pie', - radius: '50%', - data: pieData, - emphasis: { - itemStyle: { - shadowBlur: 10, - shadowOffsetX: 0, - shadowColor: 'rgba(0, 0, 0, 0.5)' - } - } - }] - }; - - // 历史对比数据 - const historicalData = hasProductData ? - mainBusiness.product_classification : - mainBusiness.industry_classification; - - return ( - - - - - - - - - - - - - 业务明细 - {latestPeriod.report_type} - - - - - - - - - - - - - - {businessItems - .filter(item => item.content !== '合计') - .map((item, idx) => ( - - - - - - - ))} - -
业务营收毛利率(%)利润
{item.content}{formatUtils.formatLargeNumber(item.revenue)}{formatUtils.formatPercent(item.gross_margin || item.profit_margin)}{formatUtils.formatLargeNumber(item.profit)}
-
-
-
-
-
- - {/* 历史对比 */} - {historicalData.length > 1 && ( - - - 主营业务历史对比 - - - - - - - - {historicalData.slice(0, 3).map(period => ( - - ))} - - - - {businessItems - .filter(item => item.content !== '合计') - .map((item, idx) => ( - - - {historicalData.slice(0, 3).map(period => { - const periodItems = hasProductData ? period.products : period.industries; - const matchItem = periodItems.find(p => p.content === item.content); - return ( - - ); - })} - - ))} - -
业务/期间{period.report_type}
{item.content} - {matchItem ? formatUtils.formatLargeNumber(matchItem.revenue) : '-'} -
-
-
-
- )} -
- ); - }; - - // 行业排名组件 - const IndustryRankingView = () => { - if (!industryRank || industryRank.length === 0) { - return ( - - - 暂无行业排名数据 - - ); - } - - const latestRanking = industryRank[0]; - - const rankingMetrics = [ - { 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' } - ]; - - return ( - - {Array.isArray(industryRank) && industryRank.length > 0 ? ( - industryRank.map((periodData, periodIdx) => ( - - - - {periodData.report_type} 行业排名 - {periodData.period} - - - - {periodData.rankings?.map((ranking, idx) => ( - - - {ranking.industry_name} ({ranking.level_description}) - - - {rankingMetrics.map(metric => { - const metricData = ranking.metrics?.[metric.key]; - if (!metricData) return null; - - const isGood = metricData.rank && metricData.rank <= 10; - const isBad = metricData.rank && metricData.rank > 30; - - return ( - - {metric.name} - - - {metric.key.includes('growth') || metric.key.includes('margin') || metric.key === 'roe' ? - formatUtils.formatPercent(metricData.value) : - metricData.value?.toFixed(2) || '-'} - - {metricData.rank && ( - - #{metricData.rank} - - )} - - - 行业均值: {metric.key.includes('growth') || metric.key.includes('margin') || metric.key === 'roe' ? - formatUtils.formatPercent(metricData.industry_avg) : - metricData.industry_avg?.toFixed(2) || '-'} - - - ); - })} - - - ))} - - - )) - ) : ( - - - - 暂无行业排名数据 - - - - )} - - ); - }; - - // 股票对比组件 - const StockComparison = ({ currentStock }) => { - const [compareStock, setCompareStock] = useState(''); - const [compareData, setCompareData] = useState(null); - const [compareLoading, setCompareLoading] = useState(false); - - const loadCompareData = async () => { - if (!compareStock || compareStock.length !== 6) { - logger.warn('FinancialPanorama', 'loadCompareData', '无效的对比股票代码', { compareStock }); - toast({ - title: '请输入有效的6位股票代码', - status: 'warning', - duration: 3000, - }); - return; - } - - logger.debug('FinancialPanorama', '开始加载对比数据', { 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 - }); - - // ❌ 移除对比数据加载成功toast - logger.info('FinancialPanorama', '对比数据加载成功', { currentStock, compareStock }); - } catch (error) { - logger.error('FinancialPanorama', 'loadCompareData', error, { currentStock, compareStock }); - - // ❌ 移除对比数据加载失败toast - // toast({ title: '加载对比数据失败', description: error.message, status: 'error', duration: 3000 }); - } finally { - setCompareLoading(false); - } - }; - - const compareMetrics = [ - { 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' }, - ]; - - return ( - - - - - setCompareStock(e.target.value)} - maxLength={6} - /> - - - - - - {compareData && ( - - - - {stockInfo?.stock_name} ({currentStock}) VS {compareData.stockInfo?.stock_name} ({compareStock}) - - - - - - - - - - - - - - - {compareMetrics.map(metric => { - const value1 = metric.path.split('.').reduce((obj, key) => obj?.[key], stockInfo); - const value2 = metric.path.split('.').reduce((obj, key) => obj?.[key], compareData.stockInfo); - - let diff = null; - let diffColor = 'gray.500'; - - if (value1 && value2) { - if (metric.format === 'percent') { - diff = value1 - value2; - diffColor = diff > 0 ? positiveColor : negativeColor; - } else { - diff = ((value1 - value2) / value2) * 100; - diffColor = diff > 0 ? positiveColor : negativeColor; - } - } - - return ( - - - - - - - ); - })} - -
指标{stockInfo?.stock_name}{compareData.stockInfo?.stock_name}差异
{metric.label} - {metric.format === 'percent' ? - formatUtils.formatPercent(value1) : - formatUtils.formatLargeNumber(value1)} - - {metric.format === 'percent' ? - formatUtils.formatPercent(value2) : - formatUtils.formatLargeNumber(value2)} - - {diff !== null ? ( - - {diff > 0 && } - {diff < 0 && } - - {metric.format === 'percent' ? - `${Math.abs(diff).toFixed(2)}pp` : - `${Math.abs(diff).toFixed(2)}%`} - - - ) : '-'} -
-
- - {/* 对比图表 */} - - - - - 盈利能力对比 - - - - - - - - - - - 成长能力对比 - - - - - - - -
-
- )} -
- ); - }; - - // 综合对比分析 - const ComparisonAnalysis = () => { - 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 combinedOption = { - title: { - text: '营收与利润趋势', - left: 'center' - }, - tooltip: { - trigger: 'axis', - axisPointer: { - type: 'cross' - } - }, - legend: { - data: ['营业收入', '净利润'], - bottom: 0 - }, - xAxis: { - type: 'category', - data: revenueData.map(d => d.period) - }, - yAxis: [ - { - type: 'value', - name: '营收(亿)', - position: 'left' - }, - { - type: 'value', - name: '利润(亿)', - position: 'right' - } - ], - series: [ - { - name: '营业收入', - type: 'bar', - data: revenueData.map(d => d.value?.toFixed(2)), - itemStyle: { - color: (params) => { - const idx = params.dataIndex; - if (idx === 0) return '#3182CE'; - const prevValue = revenueData[idx - 1].value; - const currValue = params.value; - // 中国市场颜色 - return currValue >= prevValue ? '#EF4444' : '#10B981'; - } - } - }, - { - name: '净利润', - type: 'line', - yAxisIndex: 1, - data: profitData.map(d => d.value?.toFixed(2)), - smooth: true, - itemStyle: { color: '#F59E0B' }, - lineStyle: { width: 2 } - } - ] - }; - - return ( - - - - - - ); - }; - - // 页面头部信息 - const StockInfoHeader = () => { - if (!stockInfo) return null; - - return ( - - - - - - 股票名称 - - {stockInfo.stock_name} - {stockInfo.stock_code} - - - - - - 最新EPS - {stockInfo.key_metrics?.eps?.toFixed(3) || '-'} - - - - - ROE - {formatUtils.formatPercent(stockInfo.key_metrics?.roe)} - - - - - 营收增长 - 0 ? positiveColor : stockInfo.growth_rates?.revenue_growth < 0 ? negativeColor : 'gray.500'}> - {formatUtils.formatPercent(stockInfo.growth_rates?.revenue_growth)} - - - - - - 利润增长 - 0 ? positiveColor : stockInfo.growth_rates?.profit_growth < 0 ? negativeColor : 'gray.500'}> - {formatUtils.formatPercent(stockInfo.growth_rates?.profit_growth)} - - - - - {stockInfo.latest_forecast && ( - - - - {stockInfo.latest_forecast.forecast_type} - {stockInfo.latest_forecast.content} - - - )} - - - ); - }; - - return ( - - - {/* 时间选择器 */} - - - - - 显示期数: - - - } - onClick={loadFinancialData} - isLoading={loading} - variant="outline" - size="sm" - aria-label="刷新数据" - /> - - - - - {/* 股票信息头部 */} - {loading ? ( - - ) : ( - - )} - - {/* 主要内容区域 */} - {!loading && stockInfo && ( - - - 财务概览 - 资产负债表 - 利润表 - 现金流量表 - 财务指标 - 主营业务 - 行业排名 - 业绩预告 - 股票对比 - - - - {/* 财务概览 */} - - - - - - - - {/* 资产负债表 */} - - - - - - 资产负债表 - - 显示最近{Math.min(balanceSheet.length, 8)}期 - - 红涨绿跌 | 同比变化 - - - - - 提示:表格可横向滚动查看更多数据,点击行查看历史趋势 - - - - - - - - - - {/* 利润表 */} - - - - - - 利润表 - - 显示最近{Math.min(incomeStatement.length, 8)}期 - - 红涨绿跌 | 同比变化 - - - - - 提示:Q1、中报、Q3、年报数据为累计值,同比显示与去年同期对比 - - - - - - - - - - {/* 现金流量表 */} - - - - - - 现金流量表 - - 显示最近{Math.min(cashflow.length, 8)}期 - - 红涨绿跌 | 同比变化 - - - - - 提示:现金流数据为累计值,正值红色表示现金流入,负值绿色表示现金流出 - - - - - - - - - - {/* 财务指标 */} - - - - - {/* 主营业务 */} - - - - - {/* 行业排名 */} - - - - - {/* 业绩预告 */} - - {forecast && ( - - {forecast.forecasts?.map((item, idx) => ( - - - - {item.forecast_type} - - 报告期: {item.report_date} - - - {item.content} - {item.reason && ( - {item.reason} - )} - {item.change_range?.lower && ( - - 预计变动范围: - - {item.change_range.lower}% ~ {item.change_range.upper}% - - - )} - - - ))} - - )} - - - {/* 股票对比 */} - - - - - - )} - - {/* 错误提示 */} - {error && ( - - - {error} - - )} - - {/* 弹出模态框 */} - - - - 指标详情 - - - {modalContent} - - - - - - ); -}; - -export default FinancialPanorama; \ No newline at end of file diff --git a/src/views/Company/ForecastReport.js b/src/views/Company/ForecastReport.js deleted file mode 100644 index 5591145c..00000000 --- a/src/views/Company/ForecastReport.js +++ /dev/null @@ -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 ( - - - 盈利预测报表 - - - - {loading && !data && ( - - {[1,2,3,4].map(i => ( - - - - - - - ))} - - )} - - {data && ( - - 营业收入与净利润趋势 - 增长率分析 - EPS 趋势 - PE 与 PEG 分析 - - )} - - {data && ( - - 详细数据表格 - - - - - - {years.map(y => )} - - - - {data.detail_table.rows.map((row, idx) => ( - - - {years.map(y => )} - - ))} - -
关键指标{y}
{row['指标']}{row[y] ?? '-'}
-
-
- )} -
- ); -}; - -export default ForecastReport; - - diff --git a/src/views/Company/MarketDataView.js b/src/views/Company/MarketDataView.js deleted file mode 100644 index f96acb16..00000000 --- a/src/views/Company/MarketDataView.js +++ /dev/null @@ -1,2082 +0,0 @@ -// src/views/Market/MarketDataPro.jsx -import React, { useState, useEffect, useMemo } from 'react'; -import { logger } from '../../utils/logger'; -import { getApiBase } from '../../utils/apiConfig'; -import { - Box, - Container, - Tabs, - TabList, - TabPanels, - Tab, - TabPanel, - Heading, - Text, - Table, - Thead, - Tbody, - Tr, - Th, - Td, - TableContainer, - Stat, - StatLabel, - StatNumber, - StatHelpText, - StatArrow, - SimpleGrid, - Card, - CardBody, - CardHeader, - Spinner, - Center, - Alert, - AlertIcon, - Badge, - VStack, - HStack, - Divider, - useColorModeValue, - Select, - Button, - Tooltip, - Progress, - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalBody, - ModalCloseButton, - useDisclosure, - Input, - Flex, - Tag, - TagLabel, - IconButton, - useToast, - Skeleton, - SkeletonText, - Grid, - GridItem, - ButtonGroup, - Stack, - useColorMode, - Icon, - InputGroup, - InputLeftElement, - Spacer, - CircularProgress, - CircularProgressLabel, - chakra, -} from '@chakra-ui/react'; -import { - ChevronDownIcon, - ChevronUpIcon, - InfoIcon, - DownloadIcon, - RepeatIcon, - SearchIcon, - ViewIcon, - TimeIcon, - ArrowUpIcon, - ArrowDownIcon, - StarIcon, - WarningIcon, - LockIcon, - UnlockIcon, - BellIcon, - CalendarIcon, - ExternalLinkIcon, - AddIcon, - MinusIcon, - CheckCircleIcon, - SmallCloseIcon, - MoonIcon, - SunIcon, -} from '@chakra-ui/icons'; -import ReactECharts from 'echarts-for-react'; -import ReactMarkdown from 'react-markdown'; - -// API服务配置 -const API_BASE_URL = getApiBase(); - -// 主题配置 -const themes = { - light: { - // 日间模式 - 白+蓝 - primary: '#2B6CB0', - primaryDark: '#1E4E8C', - secondary: '#FFFFFF', - secondaryDark: '#F7FAFC', - success: '#FF4444', // 涨 - 红色 - danger: '#00C851', // 跌 - 绿色 - warning: '#FF9800', - info: '#00BCD4', - bgMain: '#F7FAFC', - bgCard: '#FFFFFF', - bgDark: '#EDF2F7', - textPrimary: '#2D3748', - textSecondary: '#4A5568', - textMuted: '#718096', - border: '#CBD5E0', - chartBg: '#FFFFFF', - }, - dark: { - // 夜间模式 - 黑+金 - primary: '#FFD700', - primaryDark: '#FFA500', - secondary: '#1A1A1A', - secondaryDark: '#000000', - success: '#FF4444', // 涨 - 红色 - danger: '#00C851', // 跌 - 绿色 - warning: '#FFA500', - info: '#00BFFF', - bgMain: '#0A0A0A', - bgCard: '#141414', - bgDark: '#000000', - textPrimary: '#FFFFFF', - textSecondary: '#FFD700', - textMuted: '#999999', - border: '#333333', - chartBg: '#141414', - } -}; - -// API服务 -const marketService = { - async apiRequest(url) { - try { - const response = await fetch(`${API_BASE_URL}${url}`); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - return response.json(); - } catch (error) { - logger.error('marketService', 'apiRequest', error, { url }); - throw error; - } - }, - - async getTradeData(stockCode, days = 60) { - return this.apiRequest(`/api/market/trade/${stockCode}?days=${days}`); - }, - - async getFundingData(stockCode, days = 30) { - return this.apiRequest(`/api/market/funding/${stockCode}?days=${days}`); - }, - - async getBigDealData(stockCode, days = 30) { - return this.apiRequest(`/api/market/bigdeal/${stockCode}?days=${days}`); - }, - - async getUnusualData(stockCode, days = 30) { - return this.apiRequest(`/api/market/unusual/${stockCode}?days=${days}`); - }, - - async getPledgeData(stockCode) { - return this.apiRequest(`/api/market/pledge/${stockCode}`); - }, - - async getMarketSummary(stockCode) { - return this.apiRequest(`/api/market/summary/${stockCode}`); - }, - - async getRiseAnalysis(stockCode, startDate, endDate) { - let url = `/api/market/rise-analysis/${stockCode}`; - if (startDate && endDate) { - url += `?start_date=${startDate}&end_date=${endDate}`; - } - return this.apiRequest(url); - } -}; - -// 格式化工具 -const formatUtils = { - formatNumber(value, decimals = 2) { - if (!value && value !== 0) return '-'; - const num = parseFloat(value); - if (Math.abs(num) >= 100000000) { - return (num / 100000000).toFixed(decimals) + '亿'; - } else if (Math.abs(num) >= 10000) { - return (num / 10000).toFixed(decimals) + '万'; - } - return num.toFixed(decimals); - }, - - formatPercent(value) { - if (!value && value !== 0) return '-'; - return value.toFixed(2) + '%'; - }, - - formatDate(dateStr) { - if (!dateStr) return '-'; - return dateStr.substring(0, 10); - } -}; - -// 主题化卡片组件 -const ThemedCard = ({ children, theme, ...props }) => { - return ( - - {children} - - ); -}; - -// Markdown渲染组件 -const MarkdownRenderer = ({ children, theme, colorMode }) => { - return ( - - {children} - - ); -}; - -// 主组件 -const MarketDataView = ({ stockCode: propStockCode }) => { - const { colorMode } = useColorMode(); - const toast = useToast(); - const { isOpen, onOpen, onClose } = useDisclosure(); - const [modalContent, setModalContent] = useState(null); - - // 获取当前主题 - const theme = colorMode === 'light' ? themes.light : themes.dark; - - // 状态管理 - const [stockCode, setStockCode] = useState(propStockCode || '600000'); - const [loading, setLoading] = useState(false); - const [activeTab, setActiveTab] = useState(0); - const [selectedPeriod, setSelectedPeriod] = useState(60); - - // 数据状态 - const [summary, setSummary] = useState(null); - const [tradeData, setTradeData] = useState([]); - const [fundingData, setFundingData] = useState([]); - const [bigDealData, setBigDealData] = useState({ data: [], daily_stats: [] }); - const [unusualData, setUnusualData] = useState({ data: [], grouped_data: [] }); - const [pledgeData, setPledgeData] = useState([]); - const [riseAnalysisData, setRiseAnalysisData] = useState([]); - const [analysisMap, setAnalysisMap] = useState({}); - const [minuteData, setMinuteData] = useState([]); - const [minuteLoading, setMinuteLoading] = useState(false); - - // 加载数据 - const loadMarketData = async () => { - logger.debug('MarketDataView', '开始加载市场数据', { stockCode, selectedPeriod }); - setLoading(true); - try { - const [summaryRes, tradeRes, fundingRes, bigDealRes, unusualRes, pledgeRes, riseAnalysisRes] = await Promise.all([ - marketService.getMarketSummary(stockCode), - marketService.getTradeData(stockCode, selectedPeriod), - marketService.getFundingData(stockCode, 30), - marketService.getBigDealData(stockCode, 30), - marketService.getUnusualData(stockCode, 30), - marketService.getPledgeData(stockCode), - marketService.getRiseAnalysis(stockCode) - ]); - - if (summaryRes.success) setSummary(summaryRes.data); - if (tradeRes.success) setTradeData(tradeRes.data); - if (fundingRes.success) setFundingData(fundingRes.data); - if (bigDealRes.success) setBigDealData(bigDealRes); // 设置整个响应对象,包含daily_stats - if (unusualRes.success) setUnusualData(unusualRes); // 设置整个响应对象,包含grouped_data - if (pledgeRes.success) setPledgeData(pledgeRes.data); - if (riseAnalysisRes.success) { - setRiseAnalysisData(riseAnalysisRes.data); - - // 创建分析数据映射 - const tempAnalysisMap = {}; - if (tradeRes.success && tradeRes.data && riseAnalysisRes.data) { - riseAnalysisRes.data.forEach(analysis => { - const dateIndex = tradeRes.data.findIndex(item => - item.date.substring(0, 10) === analysis.trade_date - ); - if (dateIndex !== -1) { - tempAnalysisMap[dateIndex] = analysis; - } - }); - } - setAnalysisMap(tempAnalysisMap); - } - - // ❌ 移除数据加载成功toast - logger.info('MarketDataView', '市场数据加载成功', { stockCode }); - } catch (error) { - logger.error('MarketDataView', 'loadMarketData', error, { stockCode, selectedPeriod }); - - // ❌ 移除数据加载失败toast - // toast({ title: '数据加载失败', description: error.message, status: 'error', duration: 5000, isClosable: true }); - } finally { - setLoading(false); - } - }; - - // 获取分钟频数据 - const loadMinuteData = async () => { - logger.debug('MarketDataView', '开始加载分钟频数据', { stockCode }); - setMinuteLoading(true); - try { - const response = await fetch( - `${API_BASE_URL}/api/stock/${stockCode}/latest-minute`, - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - } - } - ); - - if (!response.ok) { - throw new Error('Failed to fetch minute data'); - } - - const data = await response.json(); - if (data.data && Array.isArray(data.data)) { - setMinuteData(data); - logger.info('MarketDataView', '分钟频数据加载成功', { stockCode, dataPoints: data.data.length }); - } else { - setMinuteData({ data: [], code: stockCode, name: '', trade_date: '', type: 'minute' }); - logger.warn('MarketDataView', '分钟频数据为空', { stockCode }); - } - - } catch (error) { - logger.error('MarketDataView', 'loadMinuteData', error, { stockCode }); - - // ❌ 移除分钟数据加载失败toast - // toast({ title: '分钟数据加载失败', description: error.message, status: 'error', duration: 3000, isClosable: true }); - setMinuteData({ data: [], code: stockCode, name: '', trade_date: '', type: 'minute' }); - } finally { - setMinuteLoading(false); - } - }; - - // 监听props中的stockCode变化 - useEffect(() => { - if (propStockCode && propStockCode !== stockCode) { - setStockCode(propStockCode); - } - }, [propStockCode, stockCode]); - - useEffect(() => { - if (stockCode) { - loadMarketData(); - // 自动加载分钟频数据 - loadMinuteData(); - } - }, [stockCode, selectedPeriod]); - - // K线图配置 - const getKLineOption = () => { - if (!tradeData || tradeData.length === 0) return {}; - - const dates = tradeData.map(item => item.date.substring(5, 10)); - const kData = tradeData.map(item => [item.open, item.close, item.low, item.high]); - const volumes = tradeData.map(item => item.volume); - const ma5 = calculateMA(tradeData.map(item => item.close), 5); - const ma10 = calculateMA(tradeData.map(item => item.close), 10); - const ma20 = calculateMA(tradeData.map(item => item.close), 20); - - // 创建涨幅分析标记点 - const scatterData = []; - - // 使用组件级别的 analysisMap - Object.keys(analysisMap).forEach(dateIndex => { - const idx = parseInt(dateIndex); - if (tradeData[idx]) { - const value = tradeData[idx].high * 1.02; // 在最高价上方显示 - scatterData.push([idx, value]); - } - }); - - return { - backgroundColor: theme.chartBg, - animation: true, - legend: { - data: ['K线', 'MA5', 'MA10', 'MA20'], - top: 10, - textStyle: { - color: theme.textPrimary - } - }, - tooltip: { - trigger: 'axis', - axisPointer: { - type: 'cross', - lineStyle: { - color: theme.primary, - width: 1, - opacity: 0.8 - } - }, - backgroundColor: colorMode === 'light' ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.8)', - borderColor: theme.primary, - borderWidth: 1, - textStyle: { - color: theme.textPrimary - }, - formatter: function(params) { - const dataIndex = params[0]?.dataIndex; - let result = `${params[0]?.name || ''}
`; - - params.forEach(param => { - if (param.seriesName === '涨幅分析' && analysisMap[dataIndex]) { - const analysis = analysisMap[dataIndex]; - result = `
- ${analysis.stock_name} (${analysis.stock_code})
- 日期: ${analysis.trade_date}
- 涨幅: ${analysis.rise_rate}%
- 收盘价: ${analysis.close_price}
-
- 涨幅原因:
-
${analysis.rise_reason_brief || '暂无分析'}
-
-
点击查看详细分析
-
`; - } else if (param.seriesName === 'K线') { - const [open, close, low, high] = param.data; - result += `${param.marker} ${param.seriesName}
`; - result += `开盘: ${open}
`; - result += `收盘: ${close}
`; - result += `最低: ${low}
`; - result += `最高: ${high}
`; - } else if (param.value != null) { - result += `${param.marker} ${param.seriesName}: ${param.value}
`; - } - }); - - return result; - } - }, - xAxis: [ - { - type: 'category', - data: dates, - boundaryGap: false, - axisLine: { lineStyle: { color: theme.textMuted } }, - axisLabel: { color: theme.textMuted } - }, - { - type: 'category', - gridIndex: 1, - data: dates, - boundaryGap: false, - axisLine: { onZero: false, lineStyle: { color: theme.textMuted } }, - axisTick: { show: false }, - splitLine: { show: false }, - axisLabel: { show: false } - } - ], - yAxis: [ - { - scale: true, - splitLine: { - show: true, - lineStyle: { - color: theme.border - } - }, - axisLine: { lineStyle: { color: theme.textMuted } }, - axisLabel: { color: theme.textMuted } - }, - { - scale: true, - gridIndex: 1, - splitNumber: 2, - axisLabel: { show: false }, - axisLine: { show: false }, - axisTick: { show: false }, - splitLine: { show: false } - } - ], - grid: [ - { - left: '10%', - right: '10%', - height: '50%' - }, - { - left: '10%', - right: '10%', - top: '65%', - height: '20%' - } - ], - series: [ - { - name: 'K线', - type: 'candlestick', - data: kData, - itemStyle: { - color: theme.success, // 涨 - 红色 - color0: theme.danger, // 跌 - 绿色 - borderColor: theme.success, - borderColor0: theme.danger - } - }, - { - name: 'MA5', - type: 'line', - data: ma5, - smooth: true, - lineStyle: { - color: theme.primary, - width: 1 - }, - itemStyle: { - color: theme.primary - } - }, - { - name: 'MA10', - type: 'line', - data: ma10, - smooth: true, - lineStyle: { - color: theme.info, - width: 1 - }, - itemStyle: { - color: theme.info - } - }, - { - name: 'MA20', - type: 'line', - data: ma20, - smooth: true, - lineStyle: { - color: theme.warning, - width: 1 - }, - itemStyle: { - color: theme.warning - } - }, - { - name: '涨幅分析', - type: 'scatter', - data: scatterData, - symbolSize: 30, - symbol: 'pin', - itemStyle: { - color: '#FFD700', - shadowBlur: 10, - shadowColor: 'rgba(255, 215, 0, 0.5)' - }, - label: { - show: true, - formatter: '★', - fontSize: 20, - position: 'inside', - color: '#FF6B6B' - }, - emphasis: { - scale: 1.5, - itemStyle: { - color: '#FFA500' - } - }, - z: 100, // 确保显示在最上层 - cursor: 'pointer' // 显示为可点击 - }, - { - name: '成交量', - type: 'bar', - xAxisIndex: 1, - yAxisIndex: 1, - data: volumes, - itemStyle: { - color: (params) => { - const item = tradeData[params.dataIndex]; - return item.change_percent >= 0 ? - 'rgba(255, 68, 68, 0.6)' : 'rgba(0, 200, 81, 0.6)'; - } - } - } - ] - }; - }; - - // 分钟频K线图配置 - const getMinuteKLineOption = () => { - if (!minuteData || !minuteData.data || minuteData.data.length === 0) return {}; - - const times = minuteData.data.map(item => item.time); - const kData = minuteData.data.map(item => [item.open, item.close, item.low, item.high]); - const volumes = minuteData.data.map(item => item.volume); - const avgPrice = calculateMA(minuteData.data.map(item => item.close), 5); // 5分钟均价 - - // 计算开盘价基准线(用于涨跌判断) - const openPrice = minuteData.data.length > 0 ? minuteData.data[0].open : 0; - - return { - backgroundColor: theme.chartBg, - title: { - text: `${minuteData.name} 分钟K线 (${minuteData.trade_date})`, - left: 'center', - textStyle: { - color: theme.textPrimary, - fontSize: 16, - fontWeight: 'bold' - }, - subtextStyle: { - color: theme.textMuted - } - }, - tooltip: { - trigger: 'axis', - axisPointer: { type: 'cross' }, - backgroundColor: colorMode === 'light' ? 'rgba(255,255,255,0.95)' : 'rgba(0,0,0,0.85)', - borderColor: theme.primary, - borderWidth: 1, - textStyle: { - color: theme.textPrimary, - fontSize: 12 - }, - formatter: (params) => { - let result = params[0].name + '
'; - params.forEach(param => { - if (param.seriesName === '分钟K线') { - const [open, close, low, high] = param.data; - const changePercent = openPrice > 0 ? ((close - openPrice) / openPrice * 100).toFixed(2) : '0.00'; - result += `${param.marker} ${param.seriesName}
`; - result += `开盘: ${open.toFixed(2)}
`; - result += `收盘: ${close.toFixed(2)}
`; - result += `最高: ${high.toFixed(2)}
`; - result += `最低: ${low.toFixed(2)}
`; - result += `涨跌: ${changePercent}%
`; - } else if (param.seriesName === '均价线') { - result += `${param.marker} ${param.seriesName}: ${param.value.toFixed(2)}
`; - } else if (param.seriesName === '成交量') { - result += `${param.marker} ${param.seriesName}: ${formatUtils.formatNumber(param.value, 0)}
`; - } - }); - return result; - } - }, - legend: { - data: ['分钟K线', '均价线', '成交量'], - top: 35, - textStyle: { - color: theme.textPrimary, - fontSize: 12 - }, - itemWidth: 25, - itemHeight: 14 - }, - grid: [ - { - left: '8%', - right: '8%', - top: '20%', - height: '60%' - }, - { - left: '8%', - right: '8%', - top: '83%', - height: '12%' - } - ], - xAxis: [ - { - type: 'category', - data: times, - scale: true, - boundaryGap: false, - axisLine: { lineStyle: { color: theme.textMuted } }, - axisLabel: { - color: theme.textMuted, - fontSize: 10, - interval: 'auto' - }, - splitLine: { show: false } - }, - { - type: 'category', - gridIndex: 1, - data: times, - scale: true, - boundaryGap: false, - axisLine: { lineStyle: { color: theme.textMuted } }, - axisLabel: { - color: theme.textMuted, - fontSize: 10 - }, - splitLine: { show: false } - } - ], - yAxis: [ - { - scale: true, - axisLine: { lineStyle: { color: theme.textMuted } }, - axisLabel: { color: theme.textMuted, fontSize: 10 }, - splitLine: { - lineStyle: { - color: theme.border, - type: 'dashed' - } - } - }, - { - gridIndex: 1, - scale: true, - axisLine: { lineStyle: { color: theme.textMuted } }, - axisLabel: { color: theme.textMuted, fontSize: 10 }, - splitLine: { show: false } - } - ], - dataZoom: [ - { - type: 'inside', - xAxisIndex: [0, 1], - start: 70, - end: 100, - minValueSpan: 20 - }, - { - show: true, - xAxisIndex: [0, 1], - type: 'slider', - top: '95%', - start: 70, - end: 100, - height: 20, - handleSize: '100%', - handleStyle: { - color: theme.primary - }, - textStyle: { - color: theme.textMuted - } - } - ], - series: [ - { - name: '分钟K线', - type: 'candlestick', - data: kData, - itemStyle: { - color: theme.success, - color0: theme.danger, - borderColor: theme.success, - borderColor0: theme.danger, - borderWidth: 1 - }, - barWidth: '60%' - }, - { - name: '均价线', - type: 'line', - data: avgPrice, - smooth: true, - symbol: 'none', - lineStyle: { - color: theme.info, - width: 2, - opacity: 0.8 - } - }, - { - name: '成交量', - type: 'bar', - xAxisIndex: 1, - yAxisIndex: 1, - data: volumes, - barWidth: '50%', - itemStyle: { - color: (params) => { - const item = minuteData.data[params.dataIndex]; - return item.close >= item.open ? - 'rgba(255, 68, 68, 0.6)' : 'rgba(0, 200, 81, 0.6)'; - } - } - } - ] - }; - }; - - // 计算移动平均线 - const calculateMA = (data, period) => { - const result = []; - for (let i = 0; i < data.length; i++) { - if (i < period - 1) { - result.push(null); - continue; - } - let sum = 0; - for (let j = 0; j < period; j++) { - sum += data[i - j]; - } - result.push(sum / period); - } - return result; - }; - - // 融资融券图表配置 - const getFundingOption = () => { - if (!fundingData || fundingData.length === 0) return {}; - - const dates = fundingData.map(item => item.date.substring(5, 10)); - const financing = fundingData.map(item => item.financing.balance / 100000000); - const securities = fundingData.map(item => item.securities.balance_amount / 100000000); - - return { - backgroundColor: theme.chartBg, - title: { - text: '融资融券余额走势', - left: 'center', - textStyle: { - color: theme.textPrimary, - fontSize: 16 - } - }, - tooltip: { - trigger: 'axis', - backgroundColor: colorMode === 'light' ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.8)', - borderColor: theme.primary, - borderWidth: 1, - textStyle: { - color: theme.textPrimary - }, - formatter: (params) => { - let result = params[0].name + '
'; - params.forEach(param => { - result += `${param.marker} ${param.seriesName}: ${param.value.toFixed(2)}亿
`; - }); - return result; - } - }, - legend: { - data: ['融资余额', '融券余额'], - bottom: 10, - textStyle: { - color: theme.textPrimary - } - }, - grid: { - left: '3%', - right: '4%', - bottom: '15%', - containLabel: true - }, - xAxis: { - type: 'category', - boundaryGap: false, - data: dates, - axisLine: { lineStyle: { color: theme.textMuted } }, - axisLabel: { color: theme.textMuted } - }, - yAxis: { - type: 'value', - name: '金额(亿)', - nameTextStyle: { color: theme.textMuted }, - splitLine: { - lineStyle: { - color: theme.border - } - }, - axisLine: { lineStyle: { color: theme.textMuted } }, - axisLabel: { color: theme.textMuted } - }, - series: [ - { - name: '融资余额', - type: 'line', - smooth: true, - symbol: 'circle', - symbolSize: 8, - areaStyle: { - color: { - type: 'linear', - x: 0, - y: 0, - x2: 0, - y2: 1, - colorStops: [{ - offset: 0, color: 'rgba(255, 68, 68, 0.3)' - }, { - offset: 1, color: 'rgba(255, 68, 68, 0.05)' - }] - } - }, - lineStyle: { - color: theme.success, - width: 2 - }, - itemStyle: { - color: theme.success, - borderColor: theme.success, - borderWidth: 2 - }, - data: financing - }, - { - name: '融券余额', - type: 'line', - smooth: true, - symbol: 'diamond', - symbolSize: 8, - areaStyle: { - color: { - type: 'linear', - x: 0, - y: 0, - x2: 0, - y2: 1, - colorStops: [{ - offset: 0, color: 'rgba(0, 200, 81, 0.3)' - }, { - offset: 1, color: 'rgba(0, 200, 81, 0.05)' - }] - } - }, - lineStyle: { - color: theme.danger, - width: 2 - }, - itemStyle: { - color: theme.danger, - borderColor: theme.danger, - borderWidth: 2 - }, - data: securities - } - ] - }; - }; - - // 股权质押图表配置 - const getPledgeOption = () => { - if (!pledgeData || pledgeData.length === 0) return {}; - - const dates = pledgeData.map(item => item.end_date.substring(5, 10)); - const ratios = pledgeData.map(item => item.pledge_ratio); - const counts = pledgeData.map(item => item.pledge_count); - - return { - backgroundColor: theme.chartBg, - title: { - text: '股权质押趋势', - left: 'center', - textStyle: { - color: theme.textPrimary, - fontSize: 16 - } - }, - tooltip: { - trigger: 'axis', - backgroundColor: colorMode === 'light' ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.8)', - borderColor: theme.primary, - borderWidth: 1, - textStyle: { - color: theme.textPrimary - } - }, - legend: { - data: ['质押比例', '质押笔数'], - bottom: 10, - textStyle: { - color: theme.textPrimary - } - }, - grid: { - left: '3%', - right: '4%', - bottom: '15%', - containLabel: true - }, - xAxis: { - type: 'category', - data: dates, - axisLine: { lineStyle: { color: theme.textMuted } }, - axisLabel: { color: theme.textMuted } - }, - yAxis: [ - { - type: 'value', - name: '质押比例(%)', - nameTextStyle: { color: theme.textMuted }, - splitLine: { - lineStyle: { - color: theme.border - } - }, - axisLine: { lineStyle: { color: theme.textMuted } }, - axisLabel: { color: theme.textMuted } - }, - { - type: 'value', - name: '质押笔数', - nameTextStyle: { color: theme.textMuted }, - axisLine: { lineStyle: { color: theme.textMuted } }, - axisLabel: { color: theme.textMuted } - } - ], - series: [ - { - name: '质押比例', - type: 'line', - smooth: true, - symbol: 'circle', - symbolSize: 8, - lineStyle: { - color: theme.warning, - width: 2, - shadowBlur: 10, - shadowColor: theme.warning - }, - itemStyle: { - color: theme.warning, - borderColor: theme.bgCard, - borderWidth: 2 - }, - data: ratios - }, - { - name: '质押笔数', - type: 'bar', - yAxisIndex: 1, - barWidth: '50%', - itemStyle: { - color: { - type: 'linear', - x: 0, - y: 0, - x2: 0, - y2: 1, - colorStops: [{ - offset: 0, color: theme.primary - }, { - offset: 1, color: theme.primaryDark - }] - }, - barBorderRadius: [5, 5, 0, 0] - }, - data: counts - } - ] - }; - }; - - return ( - - - - {/* 股票概览 */} - {summary && ( - - - - - - - - {summary.stock_name} - - - {summary.stock_code} - - - {summary.latest_trade && ( - - - - {summary.latest_trade.close} - - - = 0 ? 'increase' : 'decrease'} - color={summary.latest_trade.change_percent >= 0 ? theme.success : theme.danger} - /> - {Math.abs(summary.latest_trade.change_percent).toFixed(2)}% - - - - )} - - - - - - {summary.latest_trade && ( - <> - - 成交量 - - {formatUtils.formatNumber(summary.latest_trade.volume, 0)} - - - - 成交额 - - {formatUtils.formatNumber(summary.latest_trade.amount)} - - - - 换手率 - - {formatUtils.formatPercent(summary.latest_trade.turnover_rate)} - - - - 市盈率 - - {summary.latest_trade.pe_ratio || '-'} - - - - )} - - - {summary.latest_funding && ( - - - 融资余额 - - {formatUtils.formatNumber(summary.latest_funding.financing_balance)} - - - - 融券余额 - - {formatUtils.formatNumber(summary.latest_funding.securities_balance)} - - - {summary.latest_pledge && ( - - 质押比例 - - {formatUtils.formatPercent(summary.latest_pledge.pledge_ratio)} - - - )} - - )} - - - - - )} - - {/* 主要内容区域 */} - {loading ? ( - - -
- - - 数据加载中... - -
-
-
- ) : ( - - - - - - - - 交易数据 - - - - - - 融资融券 - - - - - - 大宗交易 - - - - - - 龙虎榜 - - - - - - 股权质押 - - - - - - - 时间范围: - - - - - - - - {/* 交易数据 */} - - - - - {tradeData.length > 0 && ( - - { - if (params.seriesName === '涨幅分析' && params.data) { - const dataIndex = params.data[0]; // scatter数据格式是[x, y] - const analysis = analysisMap[dataIndex]; - - if (analysis) { - setModalContent( - - - {analysis.stock_name} ({analysis.stock_code}) - - 日期: {analysis.trade_date} - 涨幅: {analysis.rise_rate}% - 收盘价: {analysis.close_price} - - - - {analysis.main_business && ( - - 主营业务 - {analysis.main_business} - - )} - - - {analysis.rise_reason_detail && ( - - 详细分析 - - {analysis.rise_reason_detail} - - - )} - - {analysis.announcements && analysis.announcements !== '[]' && ( - - 相关公告 - - {analysis.announcements} - - - )} - - {/* 研报引用展示 */} - {analysis.verification_reports && analysis.verification_reports.length > 0 && ( - - - - - 研报引用 ({analysis.verification_reports.length}) - - - - {analysis.verification_reports.map((report, reportIdx) => ( - - - - - {report.publisher || '未知机构'} - - {report.match_score && ( - - 匹配度: {report.match_score} - - )} - {report.match_ratio != null && report.match_ratio > 0 && ( - - {(report.match_ratio * 100).toFixed(0)}% - - )} - - {report.declare_date && ( - - {report.declare_date.substring(0, 10)} - - )} - - - {report.report_title && ( - - 《{report.report_title}》 - - )} - - {report.author && ( - - 分析师: {report.author} - - )} - - {report.verification_item && ( - - - 验证项: {report.verification_item} - - - )} - - {report.content && ( - - {report.content} - - )} - - ))} - - - )} - - - - 成交量: {formatUtils.formatNumber(analysis.volume)} | - 成交额: {formatUtils.formatNumber(analysis.amount)} | - 更新时间: {analysis.update_time || analysis.create_time || '-'} - - - - ); - onOpen(); - } - } - } - }} - /> - - )} - - - - {/* 当日分钟频数据 */} - - - - - - - 当日分钟频数据 - - {minuteData && minuteData.trade_date && ( - - {minuteData.trade_date} - - )} - - - - - - {minuteLoading ? ( -
- - - - 加载分钟频数据中... - - -
- ) : minuteData && minuteData.data && minuteData.data.length > 0 ? ( - - {/* 分钟K线图 */} - - - - - {/* 分钟数据统计 */} - - - - - - 开盘价 - - - - {minuteData.data[0]?.open != null ? minuteData.data[0].open.toFixed(2) : '-'} - - - - - - - 当前价 - - - = minuteData.data[0]?.open ? theme.success : theme.danger} - fontSize="lg" - > - {minuteData.data[minuteData.data.length - 1]?.close != null ? minuteData.data[minuteData.data.length - 1].close.toFixed(2) : '-'} - - - = minuteData.data[0]?.open ? 'increase' : 'decrease'} - /> - {(minuteData.data[minuteData.data.length - 1]?.close != null && minuteData.data[0]?.open != null) - ? Math.abs(((minuteData.data[minuteData.data.length - 1].close - minuteData.data[0].open) / minuteData.data[0].open * 100)).toFixed(2) - : '0.00'}% - - - - - - - 最高价 - - - - {(() => { - const highs = minuteData.data.map(item => item.high).filter(h => h != null); - return highs.length > 0 ? Math.max(...highs).toFixed(2) : '-'; - })()} - - - - - - - 最低价 - - - - {(() => { - const lows = minuteData.data.map(item => item.low).filter(l => l != null); - return lows.length > 0 ? Math.min(...lows).toFixed(2) : '-'; - })()} - - - - - {/* 成交量分析 */} - - - - 成交数据分析 - - - - 总成交量: {formatUtils.formatNumber(minuteData.data.reduce((sum, item) => sum + item.volume, 0), 0)} - - - 总成交额: {formatUtils.formatNumber(minuteData.data.reduce((sum, item) => sum + item.amount, 0))} - - - - - - - - 活跃时段 - - - {(() => { - const maxVolume = Math.max(...minuteData.data.map(item => item.volume)); - const activeTime = minuteData.data.find(item => item.volume === maxVolume); - return activeTime ? `${activeTime.time} (${formatUtils.formatNumber(maxVolume, 0)})` : '-'; - })()} - - - - - 平均价格 - - - {(() => { - const closes = minuteData.data.map(item => item.close).filter(c => c != null); - return closes.length > 0 ? (closes.reduce((sum, c) => sum + c, 0) / closes.length).toFixed(2) : '-'; - })()} - - - - - 数据点数 - - - {minuteData.data.length} 个分钟 - - - - - - ) : ( -
- - - - - 暂无分钟频数据 - - - 点击"获取分钟数据"按钮加载最新的交易日分钟频数据 - - - -
- )} -
-
- - - - - 交易明细 - - - - - - - - - - - - - - - - - - - {tradeData.slice(-10).reverse().map((item, idx) => ( - - - - - - - - - - - ))} - -
日期开盘最高最低收盘涨跌幅成交量成交额
{item.date}{item.open}{item.high}{item.low}{item.close}= 0 ? theme.success : theme.danger} fontWeight="bold"> - {item.change_percent >= 0 ? '+' : ''}{formatUtils.formatPercent(item.change_percent)} - {formatUtils.formatNumber(item.volume, 0)}{formatUtils.formatNumber(item.amount)}
-
-
-
-
-
- - {/* 融资融券 */} - - - - - {fundingData.length > 0 && ( - - - - )} - - - - - - - - 融资数据 - - - - - {fundingData.slice(-5).reverse().map((item, idx) => ( - - - {item.date} - - - {formatUtils.formatNumber(item.financing.balance)} - - - 买入{formatUtils.formatNumber(item.financing.buy)} / 偿还{formatUtils.formatNumber(item.financing.repay)} - - - - - ))} - - - - - - - - 融券数据 - - - - - {fundingData.slice(-5).reverse().map((item, idx) => ( - - - {item.date} - - - {formatUtils.formatNumber(item.securities.balance)} - - - 卖出{formatUtils.formatNumber(item.securities.sell)} / 偿还{formatUtils.formatNumber(item.securities.repay)} - - - - - ))} - - - - - - - - {/* 大宗交易 */} - - - - - 大宗交易记录 - - - - {bigDealData && bigDealData.daily_stats && bigDealData.daily_stats.length > 0 ? ( - - {bigDealData.daily_stats.map((dayStats, idx) => ( - - - - {dayStats.date} - - - - 交易笔数: {dayStats.count} - - - 成交量: {formatUtils.formatNumber(dayStats.total_volume)}万股 - - - 成交额: {formatUtils.formatNumber(dayStats.total_amount)}万元 - - - 均价: {dayStats.avg_price != null ? dayStats.avg_price.toFixed(2) : '-'}元 - - - - - {/* 显示当日交易明细 */} - {dayStats.deals && dayStats.deals.length > 0 && ( - - - - - - - - - - - - - {dayStats.deals.map((deal, i) => ( - - - - - - - - ))} - -
买方营业部卖方营业部成交价成交量(万股)成交额(万元)
- - {deal.buyer_dept || '-'} - - - - {deal.seller_dept || '-'} - - - {deal.price != null ? deal.price.toFixed(2) : '-'} - - {deal.volume != null ? deal.volume.toFixed(2) : '-'} - - {deal.amount != null ? deal.amount.toFixed(2) : '-'} -
-
- )} -
- ))} -
- ) : ( -
- 暂无大宗交易数据 -
- )} -
-
-
- - {/* 龙虎榜 */} - - - - - 龙虎榜数据 - - - - {unusualData && unusualData.grouped_data && unusualData.grouped_data.length > 0 ? ( - - {unusualData.grouped_data.map((dayData, idx) => ( - - - - {dayData.date} - - - - 买入: {formatUtils.formatNumber(dayData.total_buy)} - - - 卖出: {formatUtils.formatNumber(dayData.total_sell)} - - 0 ? 'red' : 'green'} fontSize="md"> - 净额: {formatUtils.formatNumber(dayData.net_amount)} - - - - - - - - 买入前五 - - - {dayData.buyers && dayData.buyers.length > 0 ? ( - dayData.buyers.slice(0, 5).map((buyer, i) => ( - - - {buyer.dept_name} - - - {formatUtils.formatNumber(buyer.buy_amount)} - - - )) - ) : ( - 暂无数据 - )} - - - - - - 卖出前五 - - - {dayData.sellers && dayData.sellers.length > 0 ? ( - dayData.sellers.slice(0, 5).map((seller, i) => ( - - - {seller.dept_name} - - - {formatUtils.formatNumber(seller.sell_amount)} - - - )) - ) : ( - 暂无数据 - )} - - - - - {/* 信息类型标签 */} - - 类型: - {dayData.info_types && dayData.info_types.map((type, i) => ( - - {type} - - ))} - - - ))} - - ) : ( -
- 暂无龙虎榜数据 -
- )} -
-
-
- - {/* 股权质押 */} - - - - - {pledgeData.length > 0 && ( - - - - )} - - - - - - - 质押明细 - - - - - - - - - - - - - - - - - - {Array.isArray(pledgeData) && pledgeData.length > 0 ? ( - pledgeData.map((item, idx) => ( - - - - - - - - - - )) - ) : ( - - - - )} - -
日期无限售质押(万股)限售质押(万股)质押总量(万股)总股本(万股)质押比例质押笔数
{item.end_date}{formatUtils.formatNumber(item.unrestricted_pledge, 0)}{formatUtils.formatNumber(item.restricted_pledge, 0)}{formatUtils.formatNumber(item.total_pledge, 0)}{formatUtils.formatNumber(item.total_shares, 0)} - {formatUtils.formatPercent(item.pledge_ratio)} - {item.pledge_count}
- 暂无数据 -
-
-
-
-
-
-
-
- )} -
-
- - {/* 模态框 */} - - - - 详细信息 - - - {modalContent} - - - -
- ); -}; - -export default MarketDataView; \ No newline at end of file diff --git a/src/views/Company/STRUCTURE.md b/src/views/Company/STRUCTURE.md new file mode 100644 index 00000000..2eebdfd7 --- /dev/null +++ b/src/views/Company/STRUCTURE.md @@ -0,0 +1,1253 @@ +# Company 目录结构说明 + +> 最后更新:2025-12-17(API 接口清单梳理) + +## 目录结构 + +``` +src/views/Company/ +├── index.js # 页面入口(纯组合层) +├── STRUCTURE.md # 本文档 +│ +├── components/ # UI 组件 +│ │ +│ ├── LoadingState.tsx # 通用加载状态组件 +│ │ +│ ├── CompanyHeader/ # 页面头部 +│ │ ├── index.js # 组合导出 +│ │ └── SearchBar.js # 股票搜索栏 +│ │ +│ ├── CompanyTabs/ # Tab 切换容器 +│ │ └── index.js # Tab 容器(状态管理 + 内容渲染) +│ │ +│ ├── StockQuoteCard/ # 股票行情卡片(TypeScript,数据已下沉) +│ │ ├── index.tsx # 主组件(Props 从 11 个精简为 4 个) +│ │ ├── types.ts # 类型定义 +│ │ ├── mockData.ts # Mock 数据 +│ │ ├── hooks/ # 内部数据 Hooks(2025-12-17 新增) +│ │ │ ├── index.ts # hooks 导出索引 +│ │ │ ├── useStockQuoteData.ts # 行情数据+基本信息获取 +│ │ │ └── useStockCompare.ts # 股票对比逻辑 +│ │ └── components/ # 子组件 +│ │ ├── index.ts # 组件导出 +│ │ ├── theme.ts # 主题配置 +│ │ ├── formatters.ts # 格式化工具 +│ │ ├── StockHeader.tsx # 股票头部(名称、代码、收藏按钮) +│ │ ├── PriceDisplay.tsx # 价格显示组件 +│ │ ├── CompanyInfo.tsx # 公司信息(行业、市值等) +│ │ ├── KeyMetrics.tsx # 关键指标(PE、PB、换手率等) +│ │ ├── MainForceInfo.tsx # 主力资金信息 +│ │ ├── SecondaryQuote.tsx # 副行情(对比股票) +│ │ ├── CompareStockInput.tsx # 对比股票输入 +│ │ └── StockCompareModal.tsx # 股票对比弹窗 +│ │ +│ ├── CompanyOverview/ # Tab: 公司概览(TypeScript) +│ │ ├── index.tsx # 主组件(组合层) +│ │ ├── types.ts # 类型定义 +│ │ ├── utils.ts # 格式化工具 +│ │ ├── NewsEventsTab.js # 新闻事件 Tab +│ │ │ +│ │ ├── hooks/ # 数据 Hooks +│ │ │ ├── useBasicInfo.ts # 基本信息 Hook +│ │ │ ├── useShareholderData.ts # 股权结构 Hook(4 APIs) +│ │ │ ├── useManagementData.ts # 管理团队 Hook +│ │ │ ├── useAnnouncementsData.ts # 公告数据 Hook +│ │ │ ├── useBranchesData.ts # 分支机构 Hook +│ │ │ ├── useDisclosureData.ts # 披露日程 Hook +│ │ │ └── useCompanyOverviewData.ts # [已废弃] 原合并 Hook +│ │ │ +│ │ ├── components/ # 股权结构子组件 +│ │ │ └── shareholder/ +│ │ │ ├── index.ts # 导出 +│ │ │ ├── ActualControlCard.tsx # 实控人卡片 +│ │ │ ├── ConcentrationCard.tsx # 股权集中度卡片 +│ │ │ └── ShareholdersTable.tsx # 股东表格 +│ │ │ +│ │ ├── BasicInfoTab/ # 基本信息 Tab(可配置化) +│ │ │ ├── index.tsx # 主组件(可配置) +│ │ │ ├── config.ts # Tab 配置 + 黑金主题 +│ │ │ ├── utils.ts # 格式化工具函数 +│ │ │ └── components/ # 子组件 +│ │ │ ├── index.ts # 组件统一导出 +│ │ │ ├── LoadingState.tsx # 加载状态组件 +│ │ │ ├── ShareholderPanel.tsx # 股权结构面板 +│ │ │ ├── AnnouncementsPanel.tsx # 公告信息面板 +│ │ │ ├── BranchesPanel.tsx # 分支机构面板 +│ │ │ ├── BusinessInfoPanel.tsx # 工商信息面板 +│ │ │ ├── DisclosureSchedulePanel.tsx # 披露日程面板 +│ │ │ └── management/ # 管理团队模块 +│ │ │ ├── index.ts # 模块导出 +│ │ │ ├── types.ts # 类型定义 +│ │ │ ├── ManagementPanel.tsx # 主组件(useMemo) +│ │ │ ├── CategorySection.tsx # 分类区块(memo) +│ │ │ └── ManagementCard.tsx # 人员卡片(memo) +│ │ │ +│ │ └── DeepAnalysisTab/ # 深度分析 Tab(原子设计模式) +│ │ ├── index.tsx # 主入口组件 +│ │ ├── types.ts # 类型定义 +│ │ ├── atoms/ # 原子组件 +│ │ │ ├── index.ts +│ │ │ ├── DisclaimerBox.tsx # 免责声明 +│ │ │ ├── ScoreBar.tsx # 评分进度条 +│ │ │ ├── BusinessTreeItem.tsx # 业务树形项 +│ │ │ ├── KeyFactorCard.tsx # 关键因素卡片 +│ │ │ ├── ProcessNavigation.tsx # 流程导航 +│ │ │ └── ValueChainFilterBar.tsx # 产业链筛选栏 +│ │ ├── components/ # Card 组件 +│ │ │ ├── index.ts +│ │ │ ├── CorePositioningCard/ # 核心定位卡片(含 atoms) +│ │ │ │ ├── index.tsx +│ │ │ │ ├── theme.ts +│ │ │ │ └── atoms/ +│ │ │ ├── CompetitiveAnalysisCard.tsx +│ │ │ ├── BusinessStructureCard.tsx +│ │ │ ├── BusinessSegmentsCard.tsx +│ │ │ ├── ValueChainCard.tsx +│ │ │ ├── KeyFactorsCard.tsx +│ │ │ ├── TimelineCard.tsx +│ │ │ └── StrategyAnalysisCard.tsx +│ │ ├── organisms/ # 复杂交互组件 +│ │ │ ├── ValueChainNodeCard/ +│ │ │ │ ├── index.tsx +│ │ │ │ └── RelatedCompaniesModal.tsx +│ │ │ └── TimelineComponent/ +│ │ │ ├── index.tsx +│ │ │ └── EventDetailModal.tsx +│ │ ├── tabs/ # Tab 面板 +│ │ │ ├── index.ts +│ │ │ ├── BusinessTab.tsx +│ │ │ ├── DevelopmentTab.tsx +│ │ │ ├── StrategyTab.tsx +│ │ │ └── ValueChainTab.tsx +│ │ └── utils/ +│ │ └── chartOptions.ts +│ │ +│ ├── MarketDataView/ # Tab: 股票行情(TypeScript) +│ │ ├── index.tsx # 主组件入口 +│ │ ├── types.ts # 类型定义 +│ │ ├── constants.ts # 主题配置(含黑金主题 darkGoldTheme) +│ │ ├── services/ +│ │ │ └── marketService.ts # API 服务层 +│ │ ├── hooks/ +│ │ │ └── useMarketData.ts # 数据获取 Hook +│ │ ├── utils/ +│ │ │ ├── formatUtils.ts # 格式化工具函数 +│ │ │ └── chartOptions.ts # ECharts 图表配置 +│ │ └── components/ +│ │ ├── index.ts # 组件导出 +│ │ ├── ThemedCard.tsx # 主题化卡片 +│ │ ├── MarkdownRenderer.tsx # Markdown 渲染 +│ │ ├── AnalysisModal.tsx # 涨幅分析模态框 +│ │ ├── StockSummaryCard/ # 股票概览卡片(黑金主题 4 列布局) +│ │ │ ├── index.tsx +│ │ │ ├── StockHeaderCard.tsx +│ │ │ ├── MetricCard.tsx +│ │ │ ├── utils.ts +│ │ │ └── atoms/ +│ │ │ ├── index.ts +│ │ │ ├── DarkGoldCard.tsx +│ │ │ ├── CardTitle.tsx +│ │ │ ├── MetricValue.tsx +│ │ │ ├── PriceDisplay.tsx +│ │ │ └── StatusTag.tsx +│ │ └── panels/ # Tab 面板组件 +│ │ ├── index.ts +│ │ ├── TradeDataPanel/ +│ │ │ ├── index.tsx +│ │ │ └── KLineModule.tsx +│ │ ├── FundingPanel.tsx +│ │ ├── BigDealPanel.tsx +│ │ ├── UnusualPanel.tsx +│ │ └── PledgePanel.tsx +│ │ +│ ├── DeepAnalysis/ # Tab: 深度分析(入口) +│ │ └── index.js +│ │ +│ ├── DynamicTracking/ # Tab: 动态跟踪 +│ │ ├── index.js # 主组件 +│ │ └── components/ +│ │ ├── index.js # 组件导出 +│ │ ├── NewsPanel.js # 新闻面板 +│ │ └── ForecastPanel.js # 业绩预告面板 +│ │ +│ ├── FinancialPanorama/ # Tab: 财务全景(TypeScript 模块化) +│ │ ├── index.tsx # 主组件入口 +│ │ ├── types.ts # TypeScript 类型定义 +│ │ ├── constants.ts # 常量配置(颜色、指标定义) +│ │ ├── hooks/ +│ │ │ ├── index.ts +│ │ │ └── useFinancialData.ts +│ │ ├── utils/ +│ │ │ ├── index.ts +│ │ │ ├── calculations.ts +│ │ │ └── chartOptions.ts +│ │ ├── tabs/ # Tab 面板组件 +│ │ │ ├── index.ts +│ │ │ ├── BalanceSheetTab.tsx +│ │ │ ├── CashflowTab.tsx +│ │ │ ├── FinancialMetricsTab.tsx +│ │ │ ├── IncomeStatementTab.tsx +│ │ │ └── MetricsCategoryTab.tsx +│ │ └── components/ +│ │ ├── index.ts +│ │ ├── StockInfoHeader.tsx +│ │ ├── FinancialTable.tsx # 通用财务表格 +│ │ ├── FinancialOverviewPanel.tsx # 财务概览面板 +│ │ ├── KeyMetricsOverview.tsx # 关键指标概览 +│ │ ├── PeriodSelector.tsx # 期数选择器 +│ │ ├── BalanceSheetTable.tsx +│ │ ├── IncomeStatementTable.tsx +│ │ ├── CashflowTable.tsx +│ │ ├── FinancialMetricsTable.tsx +│ │ ├── MainBusinessAnalysis.tsx +│ │ ├── IndustryRankingView.tsx +│ │ ├── StockComparison.tsx +│ │ └── ComparisonAnalysis.tsx +│ │ +│ └── ForecastReport/ # Tab: 盈利预测(TypeScript,已模块化) +│ ├── index.tsx # 主组件入口 +│ ├── types.ts # 类型定义 +│ ├── constants.ts # 配色、图表配置常量 +│ └── components/ +│ ├── index.ts +│ ├── ChartCard.tsx # 图表卡片容器 +│ ├── IncomeProfitGrowthChart.tsx # 营收与利润趋势图 +│ ├── IncomeProfitChart.tsx # 营收利润图(备用) +│ ├── GrowthChart.tsx # 增长率图(备用) +│ ├── EpsChart.tsx # EPS 趋势图 +│ ├── PePegChart.tsx # PE/PEG 分析图 +│ └── DetailTable.tsx # 详细数据表格 +│ +├── hooks/ # 页面级 Hooks +│ ├── useCompanyStock.js # 股票代码管理(URL 同步) +│ ├── useCompanyWatchlist.js # 自选股管理(Redux 集成) +│ └── useCompanyEvents.js # PostHog 事件追踪 +│ # 注:useStockQuote.js 已下沉到 StockQuoteCard/hooks/useStockQuoteData.ts +│ +└── constants/ # 常量定义 + └── index.js # Tab 配置、Toast 消息、默认值 +``` + +--- + +## API 接口清单 + +Company 模块共使用 **27 个** API 接口(去重后)。 + +### 一、股票基础信息 (8 个) + +| 接口 | 方法 | 调用位置 | +|------|------|----------| +| `/api/stock/${stockCode}/basic-info` | GET | useBasicInfo.ts, useStockQuoteData.ts, NewsPanel.js | +| `/api/stock/${stockCode}/branches` | GET | useBranchesData.ts | +| `/api/stock/${stockCode}/management?active_only=true` | GET | useManagementData.ts | +| `/api/stock/${stockCode}/announcements?limit=20` | GET | useAnnouncementsData.ts | +| `/api/stock/${stockCode}/disclosure-schedule` | GET | useDisclosureData.ts | +| `/api/stock/${stockCode}/forecast` | GET | ForecastPanel.js | +| `/api/stock/${stockCode}/forecast-report` | GET | ForecastReport/index.tsx | +| `/api/stock/${stockCode}/latest-minute` | GET | marketService.ts | + +### 二、股东信息 (4 个) + +| 接口 | 方法 | 调用位置 | +|------|------|----------| +| `/api/stock/${stockCode}/actual-control` | GET | useShareholderData.ts | +| `/api/stock/${stockCode}/concentration` | GET | useShareholderData.ts | +| `/api/stock/${stockCode}/top-shareholders?limit=10` | GET | useShareholderData.ts | +| `/api/stock/${stockCode}/top-circulation-shareholders?limit=10` | GET | useShareholderData.ts | + +### 三、行情数据 (8 个) + +| 接口 | 方法 | 调用位置 | +|------|------|----------| +| `/api/stock/quotes` | POST | stockService.getQuotes | +| `/api/market/summary/${stockCode}` | GET | marketService.ts | +| `/api/market/trade/${stockCode}?days=${days}` | GET | marketService.ts | +| `/api/market/funding/${stockCode}?days=${days}` | GET | marketService.ts | +| `/api/market/bigdeal/${stockCode}?days=${days}` | GET | marketService.ts | +| `/api/market/unusual/${stockCode}?days=${days}` | GET | marketService.ts | +| `/api/market/pledge/${stockCode}` | GET | marketService.ts | +| `/api/market/rise-analysis/${stockCode}` | GET | marketService.ts | + +### 四、深度分析 (5 个) + +| 接口 | 方法 | 调用位置 | +|------|------|----------| +| `/api/company/comprehensive-analysis/${stockCode}` | GET | DeepAnalysis/index.js | +| `/api/company/value-chain-analysis/${stockCode}` | GET | DeepAnalysis/index.js | +| `/api/company/key-factors-timeline/${stockCode}` | GET | DeepAnalysis/index.js | +| `/api/company/value-chain/related-companies?node_name=...` | GET | ValueChainNodeCard/index.tsx | +| `/api/financial/industry-rank/${stockCode}` | GET | DeepAnalysis/index.js | + +### 五、财务数据 (1 个) + +| 接口 | 方法 | 调用位置 | +|------|------|----------| +| `/api/financial/financial-metrics/${stockCode}?limit=${limit}` | GET | financialService.getFinancialMetrics | + +### 六、事件/新闻 (1 个) + +| 接口 | 方法 | 调用位置 | +|------|------|----------| +| `/api/events?q=${searchTerm}&page=${page}&per_page=10` | GET | NewsPanel.js | + +### 统计汇总 + +| 分类 | 数量 | +|------|------| +| 股票基础信息 | 8 | +| 股东信息 | 4 | +| 行情数据 | 8 | +| 深度分析 | 5 | +| 财务数据 | 1 | +| 事件/新闻 | 1 | +| **去重后总计** | **27** | + +> 注:`/api/stock/${stockCode}/basic-info` 在 3 处调用,但只算 1 个接口。 + +--- + +## 文件职责说明 + +### 入口文件 + +#### `index.js` - 页面入口 +- **职责**:纯组合层,协调 Hooks 和 Components +- **代码行数**:~105 行(2025-12-17 优化后精简) +- **依赖**: + - `useCompanyStock` - 股票代码状态 + - `useCompanyWatchlist` - 自选股状态 + - `useCompanyEvents` - 事件追踪 + - `CompanyHeader` - 页面头部 + - `StockQuoteCard` - 股票行情卡片(内部自行获取数据) + - `CompanyTabs` - Tab 切换区 +- **已移除**(2025-12-17): + - `useStockQuote` - 已下沉到 StockQuoteCard + - `useBasicInfo` - 已下沉到 StockQuoteCard + - 股票对比逻辑 - 已下沉到 StockQuoteCard + +--- + +### Hooks 目录 + +#### `useCompanyStock.js` - 股票代码管理 +- **功能**: + - 管理当前股票代码状态 + - 双向同步 URL 参数(支持浏览器前进/后退) + - 处理搜索输入和提交 +- **返回值**: + ```js + { + stockCode, // 当前确认的股票代码 + inputCode, // 输入框中的值(未确认) + setInputCode, // 更新输入框 + handleSearch, // 执行搜索 + handleKeyPress, // 处理回车键 + } + ``` +- **依赖**:`react-router-dom` (useSearchParams) + +#### `useCompanyWatchlist.js` - 自选股管理 +- **功能**: + - 检查当前股票是否在自选股中 + - 提供添加/移除自选股功能 + - 与 Redux stockSlice 同步 +- **返回值**: + ```js + { + isInWatchlist, // 是否在自选股中 + isLoading, // 操作进行中 + toggle, // 切换自选状态 + } + ``` +- **依赖**:Redux (`stockSlice`)、`AuthContext`、Chakra UI (useToast) + +#### `useCompanyEvents.js` - 事件追踪 +- **功能**: + - 页面浏览追踪 + - 股票搜索追踪 + - Tab 切换追踪 + - 自选股操作追踪 +- **返回值**: + ```js + { + trackStockSearched, // 追踪股票搜索 + trackTabChanged, // 追踪 Tab 切换 + trackWatchlistAdded, // 追踪添加自选 + trackWatchlistRemoved, // 追踪移除自选 + } + ``` +- **依赖**:PostHog (`usePostHogTrack`) + +--- + +### Components 目录 + +#### `CompanyHeader/` - 页面头部 +| 文件 | 职责 | +|------|------| +| `index.js` | 组合 SearchBar 和 WatchlistButton | +| `SearchBar.js` | 股票代码搜索输入框 | +| `WatchlistButton.js` | 自选股添加/移除按钮 | + +**Props 接口**: +```js + +``` + +#### `CompanyTabs/` - Tab 切换 +| 文件 | 职责 | +|------|------| +| `index.js` | Tab 容器,管理切换状态,渲染 Tab 内容 | +| `TabNavigation.js` | Tab 导航栏(4个 Tab 按钮) | + +**Props 接口**: +```js + +``` + +--- + +### Constants 目录 + +#### `constants/index.js` - 常量配置 +- `COMPANY_TABS` - Tab 配置数组(key, name, icon) +- `TAB_SELECTED_STYLE` - Tab 选中样式 +- `TOAST_MESSAGES` - Toast 消息配置 +- `DEFAULT_STOCK_CODE` - 默认股票代码 ('000001') +- `URL_PARAM_NAME` - URL 参数名 ('scode') +- `getTabNameByIndex()` - 根据索引获取 Tab 名称 + +--- + +### Tab 内容组件(`components/` 目录下) + +| 组件 | Tab 名称 | 职责 | 代码行数 | +|------|----------|------|----------| +| `CompanyOverview/` | 公司概览 | 公司基本信息、相关事件 | - | +| `MarketDataView/` | 股票行情 | K线图、实时行情 | - | +| `FinancialPanorama/` | 财务全景 | 财务报表、指标分析 | 2153 行 | +| `ForecastReport/` | 盈利预测 | 分析师预测、目标价 | 161 行 | + +> 📌 所有 Tab 内容组件已文件夹化并统一放置在 `components/` 目录下 + +--- + +## 数据流示意 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ index.js (页面入口) │ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │ +│ │ useCompanyStock │ │useCompanyWatchlist│ │useCompanyEvents│ +│ │ │ │ │ │ │ │ +│ │ • stockCode │ │ • isInWatchlist │ │ • track* │ │ +│ │ • inputCode │ │ • toggle │ │ functions │ │ +│ │ • handleSearch │ │ │ │ │ │ +│ └────────┬────────┘ └────────┬────────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ └──────────┬─────────┴───────────────────┘ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ CompanyHeader │ │ +│ │ ┌─────────────┐ ┌──────────────────┐ │ │ +│ │ │ SearchBar │ │ WatchlistButton │ │ │ +│ │ └─────────────┘ └──────────────────┘ │ │ +│ └───────────────────────────────────────────────────────┘ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ CompanyTabs │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ TabNavigation │ │ │ +│ │ │ [概览] [行情] [财务] [预测] │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ TabPanels │ │ │ +│ │ │ • CompanyOverview │ │ │ +│ │ │ • MarketDataView │ │ │ +│ │ │ • FinancialPanorama │ │ │ +│ │ │ • ForecastReport │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 重构记录 + +### 2025-12-09 重构 + +**改动概述**: +- `index.js` 从 **349 行** 精简至 **95 行**(减少 73%) +- 提取 **3 个自定义 Hooks** +- 提取 **2 个组件目录**(CompanyHeader、CompanyTabs) +- 抽离常量到 `constants/index.js` + +**修复的问题**: +1. **无限循环 Bug**:`useCompanyWatchlist` 中使用 `useRef` 防止重复初始化 +2. **Hook 调用顺序**:确保 `useCompanyEvents` 在 `useCompanyStock` 之后调用(依赖 stockCode) +3. **类型检查**:`CompanyOverview.js` 中 `event.keywords` 渲染时添加类型检查,支持字符串和对象两种格式 + +**设计原则**: +- **关注点分离**:每个 Hook 只负责单一职责 +- **纯组合层**:index.js 不包含业务逻辑,只负责组合 +- **Props 透传**:通过 Props 将状态和回调传递给子组件 + +### 2025-12-09 文件夹化 + +**改动概述**: +- 所有 4 个 Tab 内容组件统一移动到 `components/` 目录 +- `CompanyOverview.js` → `components/CompanyOverview/index.js` +- `MarketDataView.js` → `components/MarketDataView/index.js` +- `FinancialPanorama.js` → `components/FinancialPanorama/index.js`(2153 行) +- `ForecastReport.js` → `components/ForecastReport/index.js`(161 行) +- 更新 `CompanyTabs/index.js` 中的导入路径 + +**目的**: +- 统一目录结构,所有组件都在 `components/` 下 +- 为后期组件拆分做准备,便于添加子组件、hooks、utils 等 + +### 2025-12-10 CompanyOverview 拆分(TypeScript) + +**改动概述**: +- `CompanyOverview/index.js` 从 **330 行** 精简至 **50 行**(减少 85%) +- 采用 **TypeScript** 进行拆分,提高类型安全性 +- 提取 **1 个自定义 Hook**(`useCompanyOverviewData`) +- 提取 **1 个子组件**(`CompanyHeaderCard`) +- 抽离类型定义到 `types.ts` +- 抽离工具函数到 `utils.ts` + +**拆分后文件结构**: +``` +CompanyOverview/ +├── index.tsx # 主组件(组合层,约 60 行) +├── CompanyHeaderCard.tsx # 头部卡片组件(约 130 行) +├── BasicInfoTab.js # 基本信息 Tab(懒加载版本,约 994 行) +├── DeepAnalysisTab/ # 深度分析 Tab(21 个 TS 文件,见 2025-12-11 重构记录) +├── NewsEventsTab.js # 新闻事件 Tab +├── types.ts # 类型定义(约 50 行) +├── utils.ts # 格式化工具(约 20 行) +└── hooks/ + ├── useBasicInfo.ts # 基本信息 Hook(1 API) + ├── useShareholderData.ts # 股权结构 Hook(4 APIs) + ├── useManagementData.ts # 管理团队 Hook(1 API) + ├── useAnnouncementsData.ts # 公告数据 Hook(1 API) + ├── useBranchesData.ts # 分支机构 Hook(1 API) + ├── useDisclosureData.ts # 披露日程 Hook(1 API) + └── useCompanyOverviewData.ts # [已废弃] 原合并 Hook +``` + +**懒加载架构**(2025-12-10 优化): +- `index.tsx` 只加载 `useBasicInfo`(1 个 API)用于头部卡片 +- `BasicInfoTab.js` 使用 `isLazy` + 独立子组件实现懒加载 +- 每个内层 Tab 使用独立 Hook,点击时才加载数据 + +**Hooks 说明**: +| Hook | API 数量 | 用途 | +|------|----------|------| +| `useBasicInfo` | 1 | 公司基本信息(头部卡片 + 工商信息 Tab) | +| `useShareholderData` | 4 | 实控人、股权集中度、十大股东、十大流通股东 | +| `useManagementData` | 1 | 管理团队数据 | +| `useAnnouncementsData` | 1 | 公司公告列表 | +| `useBranchesData` | 1 | 分支机构列表 | +| `useDisclosureData` | 1 | 财报披露日程 | + +**类型定义**(`types.ts`): +- `BasicInfo` - 公司基本信息 +- `ActualControl` - 实际控制人 +- `Concentration` - 股权集中度 +- `Management` - 管理层信息 +- `Shareholder` - 股东信息 +- `Branch` - 分支机构 +- `Announcement` - 公告信息 +- `DisclosureSchedule` - 披露计划 +- `CompanyOverviewData` - Hook 返回值类型 +- `CompanyOverviewProps` - 组件 Props 类型 +- `CompanyHeaderCardProps` - 头部卡片 Props 类型 + +**工具函数**(`utils.ts`): +- `formatRegisteredCapital(value)` - 格式化注册资本(万元/亿元) +- `formatDate(dateString)` - 格式化日期 + +**设计原则**: +- **渐进式 TypeScript 迁移**:新拆分的文件使用 TypeScript,旧文件暂保持 JS +- **关注点分离**:数据加载逻辑提取到 Hook,UI 逻辑保留在组件 +- **类型复用**:统一的类型定义便于在多个文件间共享 +- **懒加载优化**:减少首屏 API 请求,按需加载数据 + +### 2025-12-10 懒加载优化 + +**改动概述**: +- 将 `useCompanyOverviewData`(9 个 API)拆分为 6 个独立 Hook +- `CompanyOverview/index.tsx` 只加载 `useBasicInfo`(1 个 API) +- `BasicInfoTab.js` 使用 5 个懒加载子组件,配合 `isLazy` 实现按需加载 +- 页面初次加载从 **9 个 API** 减少到 **1 个 API** + +**懒加载子组件**(BasicInfoTab.js 内部): +| 子组件 | Hook | 功能 | +|--------|------|------| +| `ShareholderTabPanel` | `useShareholderData` | 股权结构(4 APIs) | +| `ManagementTabPanel` | `useManagementData` | 管理团队 | +| `AnnouncementsTabPanel` | `useAnnouncementsData` + `useDisclosureData` | 公告 + 披露日程 | +| `BranchesTabPanel` | `useBranchesData` | 分支机构 | +| `BusinessInfoTabPanel` | - | 工商信息(使用父组件传入的 basicInfo) | + +**实现原理**: +- Chakra UI `Tabs` 的 `isLazy` 属性延迟渲染 TabPanel +- 每个 TabPanel 使用独立子组件,组件内调用 Hook +- 子组件只在首次激活时渲染,此时 Hook 才执行并发起 API 请求 + + | Tab 模块 | 中文名称 | 功能说明 | + |-------------------|------|----------------------------| + | CompanyOverview | 公司概览 | 公司基本信息、股权结构、管理层、公告等(9个接口) | + | DeepAnalysis | 深度分析 | 公司深度研究报告、投资逻辑分析 | + | MarketDataView | 股票行情 | K线图、实时行情、技术指标 | + | FinancialPanorama | 财务全景 | 财务报表(资产负债表、利润表、现金流)、财务指标分析 | + | ForecastReport | 盈利预测 | 分析师预测、目标价、评级 | + | DynamicTracking | 动态跟踪 | 相关事件、新闻动态、投资日历 | + +### 2025-12-10 MarketDataView TypeScript 拆分 + +**改动概述**: +- `MarketDataView/index.js` 从 **2060 行** 拆分为 **12 个 TypeScript 文件** +- 采用 **TypeScript** 进行重构,提高类型安全性 +- 提取 **1 个自定义 Hook**(`useMarketData`) +- 提取 **4 个子组件**(ThemedCard、MarkdownRenderer、StockSummaryCard、AnalysisModal) +- 抽离 API 服务到 `services/marketService.ts` +- 抽离图表配置到 `utils/chartOptions.ts` + +**拆分后文件结构**: +``` +MarketDataView/ +├── index.tsx # 主组件入口(~285 行,Tab 容器) +├── types.ts # 类型定义(~383 行) +├── constants.ts # 主题配置、常量(~49 行) +├── services/ +│ └── marketService.ts # API 服务层(~173 行) +├── hooks/ +│ └── useMarketData.ts # 数据获取 Hook(~193 行) +├── utils/ +│ ├── formatUtils.ts # 格式化工具函数(~175 行) +│ └── chartOptions.ts # ECharts 图表配置生成器(~698 行) +└── components/ + ├── index.ts # 组件导出(~8 行) + ├── ThemedCard.tsx # 主题化卡片(~32 行) + ├── MarkdownRenderer.tsx # Markdown 渲染(~65 行) + ├── StockSummaryCard.tsx # 股票概览卡片(~133 行) + ├── AnalysisModal.tsx # 涨幅分析模态框(~188 行) + └── panels/ # Tab 面板组件(2025-12-12 拆分) + ├── index.ts # 面板组件统一导出 + ├── TradeDataPanel.tsx # 交易数据面板(~381 行) + ├── FundingPanel.tsx # 融资融券面板(~113 行) + ├── BigDealPanel.tsx # 大宗交易面板(~143 行) + ├── UnusualPanel.tsx # 龙虎榜面板(~163 行) + └── PledgePanel.tsx # 股权质押面板(~124 行) +``` + +**文件职责说明**: + +| 文件 | 行数 | 职责 | +|------|------|------| +| `index.tsx` | ~285 | 主组件,Tab 容器和状态管理,导入使用 5 个 Panel 组件 | +| `types.ts` | ~383 | 所有 TypeScript 类型定义(Theme、TradeDayData、MinuteData、FundingData 等) | +| `constants.ts` | ~49 | 主题配置(light/dark)、周期选项常量 | +| `marketService.ts` | ~173 | API 服务封装(getMarketData、getMinuteData、getBigDealData 等) | +| `useMarketData.ts` | ~193 | 数据获取 Hook,管理所有市场数据状态 | +| `formatUtils.ts` | ~175 | 数字/日期/涨跌幅格式化工具 | +| `chartOptions.ts` | ~698 | ECharts 配置生成器(K线图、分钟图、融资融券图、质押图) | +| `ThemedCard.tsx` | ~32 | 主题化卡片容器组件 | +| `MarkdownRenderer.tsx` | ~65 | Markdown 内容渲染组件 | +| `StockSummaryCard.tsx` | ~133 | 股票概览卡片(价格、涨跌幅、成交量等) | +| `AnalysisModal.tsx` | ~188 | 涨幅分析详情模态框 | + +**类型定义**(`types.ts`): +- `Theme` - 主题配置类型 +- `TradeDayData` - 日线交易数据 +- `MinuteData` - 分钟线数据 +- `FundingDayData` - 融资融券数据 +- `BigDealData` / `BigDealDayStats` - 大宗交易数据 +- `UnusualData` / `UnusualDayData` - 龙虎榜数据 +- `PledgeData` - 股权质押数据 +- `RiseAnalysis` - 涨幅分析数据 +- `MarketSummary` - 市场概览数据 +- `VerificationReport` - 验证报告数据 +- 各组件 Props 类型 + +**Hook 返回值**(`useMarketData`): +```typescript +{ + loading: boolean; + summary: MarketSummary | null; + tradeData: TradeDayData[]; + minuteData: MinuteData | null; + minuteLoading: boolean; + fundingData: FundingDayData[]; + bigDealData: BigDealData | null; + unusualData: UnusualData | null; + pledgeData: PledgeData | null; + analysisMap: Record; + refetch: () => Promise; + loadMinuteData: () => Promise; +} +``` + +**设计原则**: +- **TypeScript 类型安全**:所有数据结构有完整类型定义 +- **服务层分离**:API 调用统一在 `marketService.ts` 中管理 +- **图表配置抽离**:复杂的 ECharts 配置集中在 `chartOptions.ts` +- **组件复用**:通用组件(ThemedCard、MarkdownRenderer)可在其他模块使用 + +### 2025-12-10 ManagementPanel 拆分重构 + +**改动概述**: +- `ManagementPanel.tsx` 从 **180 行** 拆分为 **5 个 TypeScript 文件** +- 创建 `management/` 子目录,模块化管理 +- 添加性能优化(`useMemo`、`React.memo`) + +**拆分后文件结构**: +``` +components/management/ +├── index.ts # 模块导出 +├── types.ts # 类型定义(~35 行) +├── ManagementPanel.tsx # 主组件(~105 行,useMemo 优化) +├── CategorySection.tsx # 分类区块组件(~65 行,memo) +└── ManagementCard.tsx # 人员卡片组件(~100 行,memo) +``` + +**类型定义**(`types.ts`): +- `ManagementPerson` - 管理人员信息 +- `ManagementCategory` - 分类类型(高管/董事/监事/其他) +- `CategorizedManagement` - 分类后的数据结构 +- `CategoryConfig` - 分类配置(图标、颜色) + +**性能优化**: +- `useMemo` - 缓存 `categorizeManagement()` 分类计算结果 +- `React.memo` - `ManagementCard` 和 `CategorySection` 使用 memo 包装 +- 常量提取 - `CATEGORY_CONFIG` 和 `CATEGORY_ORDER` 提取到组件外部 + +**设计原则**: +- **职责分离**:卡片渲染、分类区块、数据处理各自独立 +- **类型安全**:消除 `any` 类型,完整的 TypeScript 类型定义 +- **可复用性**:`ManagementCard` 可独立使用 + +### 2025-12-11 DeepAnalysisTab 模块化拆分(TypeScript) + +**改动概述**: +- `DeepAnalysisTab.js` 从 **1,796 行** 拆分为 **21 个 TypeScript 文件** +- 采用**原子设计模式**(atoms/components/organisms)组织代码 +- 完整 TypeScript 类型定义 +- 格式化工具合并到全局 `src/utils/priceFormatters.js` + +**拆分后文件结构**: +``` +DeepAnalysisTab/ +├── index.tsx # 主入口组件,组合所有 Card 子组件 +├── types.ts # TypeScript 类型定义(接口、数据结构) +├── atoms/ # 原子组件(基础 UI 元素) +│ ├── index.ts # 原子组件统一导出 +│ ├── DisclaimerBox.tsx # 免责声明警告框(黄色 Alert,用 6 次) +│ ├── ScoreBar.tsx # 评分进度条(带颜色渐变,用 8 次) +│ ├── BusinessTreeItem.tsx # 业务结构树形项(递归组件) +│ └── KeyFactorCard.tsx # 关键因素卡片(带影响方向图标) +├── components/ # Card 容器组件(页面区块) +│ ├── index.ts # Card 组件统一导出 +│ ├── CorePositioningCard.tsx # 核心定位卡片(行业地位、核心优势) +│ ├── CompetitiveAnalysisCard.tsx # 竞争地位分析卡片(雷达图 + 评分条) +│ ├── BusinessStructureCard.tsx # 业务结构分析卡片(树形展示) +│ ├── ValueChainCard.tsx # 产业链分析卡片(Tabs: 上游/中游/下游) +│ ├── KeyFactorsCard.tsx # 关键因素卡片(Accordion 折叠面板) +│ ├── TimelineCard.tsx # 发展时间线卡片(正面/负面事件统计) +│ ├── BusinessSegmentsCard.tsx # 业务板块详情卡片(可展开/折叠) +│ └── StrategyAnalysisCard.tsx # 战略分析卡片(战略方向 + 战略举措) +├── organisms/ # 复杂组件(含状态管理和 API 调用) +│ ├── ValueChainNodeCard/ # 产业链节点组件 +│ │ ├── index.tsx # 产业链节点卡片(点击展开详情) +│ │ └── RelatedCompaniesModal.tsx # 相关公司模态框(API 获取公司列表) +│ └── TimelineComponent/ # 时间线组件 +│ ├── index.tsx # 时间线主组件(事件列表渲染) +│ └── EventDetailModal.tsx # 事件详情模态框(查看完整事件信息) +└── utils/ + └── chartOptions.ts # ECharts 图表配置(雷达图、桑基图) +``` + +**组件依赖关系**: +``` +index.tsx +├── CorePositioningCard +├── CompetitiveAnalysisCard +│ ├── ScoreBar (atom) +│ ├── DisclaimerBox (atom) +│ └── ReactECharts (雷达图) +├── BusinessStructureCard +│ └── BusinessTreeItem (atom, 递归) +├── ValueChainCard +│ └── ValueChainNodeCard (organism) +│ └── RelatedCompaniesModal +├── KeyFactorsCard +│ └── KeyFactorCard (atom) +├── TimelineCard +│ └── TimelineComponent (organism) +│ └── EventDetailModal +├── BusinessSegmentsCard +└── StrategyAnalysisCard + └── DisclaimerBox (atom) +``` + +**类型定义**(`types.ts`): +- `DeepAnalysisTabProps` - 主组件 Props +- `QualitativeAnalysis` - 定性分析数据 +- `CompetitivePosition` - 竞争地位数据 +- `BusinessStructureItem` - 业务结构项 +- `ValueChainData` - 产业链数据 +- `ValueChainNode` - 产业链节点 +- `KeyFactor` - 关键因素 +- `DevelopmentTimeline` - 发展时间线 +- `TimelineEvent` - 时间线事件 +- `BusinessSegment` - 业务板块 +- `Strategy` - 战略分析 + +**工具函数位置**: +| 函数 | 文件位置 | 说明 | +|------|----------|------| +| `formatCurrency` | `src/utils/priceFormatters.js` | 货币格式化 | +| `formatBusinessRevenue` | `src/utils/priceFormatters.js` | 营收格式化(亿/万) | +| `formatPercentage` | `src/utils/priceFormatters.js` | 百分比格式化 | +| `getRadarChartOption` | `DeepAnalysisTab/utils/chartOptions.ts` | 雷达图 ECharts 配置 | +| `getSankeyChartOption` | `DeepAnalysisTab/utils/chartOptions.ts` | 桑基图 ECharts 配置 | + +**优化效果**: +| 指标 | 优化前 | 优化后 | 改善 | +|------|--------|--------|------| +| 主文件行数 | 1,796 | ~117 | -93% | +| 文件数量 | 1 (.js) | 21 (.tsx/.ts) | 模块化 + TS | +| 可复用组件 | 0 | 4 原子 + 2 复杂 | 提升 | +| 类型安全 | 无 | 完整 | TypeScript | + +**设计原则**: +- **原子设计模式**:atoms(基础元素)→ components(区块)→ organisms(复杂交互) +- **TypeScript 类型安全**:完整的接口定义,消除 any 类型 +- **职责分离**:UI 渲染与 API 调用分离,模态框独立管理 +- **代码复用**:DisclaimerBox、ScoreBar 等原子组件多处复用 + +### 2025-12-12 FinancialPanorama 模块化拆分(TypeScript) + +**改动概述**: +- `FinancialPanorama/index.js` 从 **2,150 行** 拆分为 **21 个 TypeScript 文件** +- 提取 **1 个自定义 Hook**(`useFinancialData`) +- 提取 **9 个子组件**(表格组件 + 分析组件) +- 抽离类型定义到 `types.ts` +- 抽离常量配置到 `constants.ts` +- 抽离工具函数到 `utils/` + +**拆分后文件结构**: +``` +FinancialPanorama/ +├── index.tsx # 主入口组件(~400 行) +├── types.ts # TypeScript 类型定义(~441 行) +├── constants.ts # 常量配置(颜色、指标定义) +├── hooks/ +│ ├── index.ts # Hook 统一导出 +│ └── useFinancialData.ts # 财务数据加载 Hook(9 API 并行加载) +├── utils/ +│ ├── index.ts # 工具函数统一导出 +│ ├── calculations.ts # 计算工具(同比变化率、单元格背景色) +│ └── chartOptions.ts # ECharts 图表配置生成器 +└── components/ + ├── index.ts # 组件统一导出 + ├── StockInfoHeader.tsx # 股票信息头部(~95 行) + ├── BalanceSheetTable.tsx # 资产负债表(~220 行,可展开分组) + ├── IncomeStatementTable.tsx # 利润表(~205 行,可展开分组) + ├── CashflowTable.tsx # 现金流量表(~140 行) + ├── FinancialMetricsTable.tsx # 财务指标表(~260 行,7 分类切换) + ├── MainBusinessAnalysis.tsx # 主营业务分析(~180 行,饼图 + 表格) + ├── IndustryRankingView.tsx # 行业排名(~110 行) + ├── StockComparison.tsx # 股票对比(~210 行,含独立数据加载) + └── ComparisonAnalysis.tsx # 综合对比分析(~40 行) +``` + +**组件依赖关系**: +``` +index.tsx +├── useFinancialData (hook) # 数据加载 +├── StockInfoHeader # 股票基本信息展示 +├── ComparisonAnalysis # 营收与利润趋势图 +├── FinancialMetricsTable # 财务指标表(7 分类) +├── BalanceSheetTable # 资产负债表(可展开) +├── IncomeStatementTable # 利润表(可展开) +├── CashflowTable # 现金流量表 +├── MainBusinessAnalysis # 主营业务(饼图) +├── IndustryRankingView # 行业排名 +└── StockComparison # 股票对比(独立状态) +``` + +**类型定义**(`types.ts`): +- `StockInfo` - 股票基本信息 +- `BalanceSheetData` - 资产负债表数据 +- `IncomeStatementData` - 利润表数据 +- `CashflowData` - 现金流量表数据 +- `FinancialMetricsData` - 财务指标数据(7 分类) +- `ProductClassification` / `IndustryClassification` - 主营业务分类 +- `IndustryRankData` - 行业排名数据 +- `ForecastData` - 业绩预告数据 +- `ComparisonData` - 对比数据 +- `MetricConfig` / `MetricSectionConfig` - 指标配置类型 +- 各组件 Props 类型 + +**常量配置**(`constants.ts`): +- `COLORS` - 颜色配置(中国市场:红涨绿跌) +- `CURRENT_ASSETS_METRICS` / `NON_CURRENT_ASSETS_METRICS` 等 - 资产负债表指标 +- `INCOME_STATEMENT_SECTIONS` - 利润表分组配置 +- `CASHFLOW_METRICS` - 现金流量表指标 +- `FINANCIAL_METRICS_CATEGORIES` - 财务指标 7 大分类 +- `RANKING_METRICS` / `COMPARE_METRICS` - 排名和对比指标 + +**工具函数**(`utils/`): +| 函数 | 文件 | 说明 | +|------|------|------| +| `calculateYoYChange` | calculations.ts | 计算同比变化率和强度 | +| `getCellBackground` | calculations.ts | 根据变化率返回单元格背景色 | +| `getValueByPath` | calculations.ts | 从嵌套对象获取值 | +| `isNegativeIndicator` | calculations.ts | 判断是否为负向指标 | +| `getMetricChartOption` | chartOptions.ts | 指标趋势图配置 | +| `getComparisonChartOption` | chartOptions.ts | 营收与利润对比图配置 | +| `getMainBusinessPieOption` | chartOptions.ts | 主营业务饼图配置 | +| `getCompareBarChartOption` | chartOptions.ts | 股票对比柱状图配置 | + +**Hook 返回值**(`useFinancialData`): +```typescript +{ + // 数据状态 + stockInfo: StockInfo | null; + balanceSheet: BalanceSheetData[]; + incomeStatement: IncomeStatementData[]; + cashflow: CashflowData[]; + financialMetrics: FinancialMetricsData[]; + mainBusiness: MainBusinessData | null; + forecast: ForecastData | null; + industryRank: IndustryRankData[]; + comparison: ComparisonData[]; + + // 加载状态 + loading: boolean; + error: string | null; + + // 操作方法 + refetch: () => Promise; + setStockCode: (code: string) => void; + setSelectedPeriods: (periods: number) => void; + + // 当前参数 + currentStockCode: string; + selectedPeriods: number; +} +``` + +**优化效果**: +| 指标 | 优化前 | 优化后 | 改善 | +|------|--------|--------|------| +| 主文件行数 | 2,150 | ~400 | -81% | +| 文件数量 | 1 (.js) | 21 (.tsx/.ts) | 模块化 + TS | +| 可复用组件 | 0(内联) | 9 个独立组件 | 提升 | +| 类型安全 | 无 | 完整 | TypeScript | + +**设计原则**: +- **TypeScript 类型安全**:完整的接口定义,消除 any 类型 +- **Hook 数据层**:`useFinancialData` 封装 9 个 API 并行加载 +- **组件解耦**:每个表格/分析视图独立为组件 +- **常量配置化**:指标定义可维护、可扩展 +- **工具函数复用**:计算和图表配置统一管理 + +### 2025-12-12 MarketDataView Panel 拆分 + +**改动概述**: +- `MarketDataView/index.tsx` 从 **1,049 行** 精简至 **285 行**(减少 73%) +- 将 5 个 TabPanel 拆分为独立的面板组件 +- 创建 `components/panels/` 子目录 + +**拆分后文件结构**: +``` +MarketDataView/components/panels/ +├── index.ts # 面板组件统一导出 +├── TradeDataPanel.tsx # 交易数据面板(~381 行) +├── FundingPanel.tsx # 融资融券面板(~113 行) +├── BigDealPanel.tsx # 大宗交易面板(~143 行) +├── UnusualPanel.tsx # 龙虎榜面板(~163 行) +└── PledgePanel.tsx # 股权质押面板(~124 行) +``` + +**面板组件职责**: + +| 组件 | 行数 | 功能 | +|------|------|------| +| `TradeDataPanel` | ~381 | K线图、分钟K线图、交易明细表格 | +| `FundingPanel` | ~113 | 融资融券图表和数据卡片 | +| `BigDealPanel` | ~143 | 大宗交易记录表格 | +| `UnusualPanel` | ~163 | 龙虎榜数据(买入/卖出前五) | +| `PledgePanel` | ~124 | 股权质押图表和明细表格 | + +**优化效果**: +| 指标 | 优化前 | 优化后 | 改善 | +|------|--------|--------|------| +| 主文件行数 | 1,049 | 285 | -73% | +| 面板组件 | 内联 | 5 个独立文件 | 模块化 | +| 可维护性 | 低 | 高 | 每个面板独立维护 | + +**设计原则**: +- **职责分离**:主组件只负责 Tab 容器和状态管理 +- **组件复用**:面板组件可独立测试和维护 +- **类型安全**:每个面板组件有独立的 Props 类型定义 + +### 2025-12-16 StockSummaryCard 黑金主题重构 + +**改动概述**: +- `StockSummaryCard.tsx` 从单文件重构为**原子设计模式**的目录结构 +- 布局从 **1+3**(头部+三卡片)改为 **4 列横向排列** +- 新增**黑金主题**(`darkGoldTheme`) +- 提取 **5 个原子组件** + **2 个业务组件** + +**拆分后文件结构**: +``` +StockSummaryCard/ +├── index.tsx # 主组件(4 列 SimpleGrid 布局) +├── StockHeaderCard.tsx # 股票信息卡片(名称、价格、涨跌幅、走势) +├── MetricCard.tsx # 指标卡片模板组件 +├── utils.ts # 状态计算工具函数 +└── atoms/ # 原子组件 + ├── index.ts # 统一导出 + ├── DarkGoldCard.tsx # 黑金主题卡片容器(渐变背景、金色边框) + ├── CardTitle.tsx # 卡片标题(图标+标题+副标题) + ├── MetricValue.tsx # 核心数值展示(标签+数值+后缀) + ├── PriceDisplay.tsx # 价格显示(价格+涨跌箭头+百分比) + └── StatusTag.tsx # 状态标签(活跃/健康/警惕等) +``` + +**4 列布局设计**: +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ 股票信息 │ │ 交易热度 │ │ 估值VS安全 │ │ 情绪与风险 │ +│ 平安银行 │ │ (流动性) │ │ (便宜否) │ │ (资金面) │ +│ (000001) │ │ │ │ │ │ │ +│ 13.50 ↗+1.89%│ │ 成交额 46.79亿│ │ PE 4.96 │ │ 融资 58.23亿 │ +│ 走势:小幅上涨 │ │ 成交量|换手率 │ │ 质押率(健康) │ │ 融券 1.26亿 │ +└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ +``` + +**黑金主题配置**(`constants.ts`): +```typescript +export const darkGoldTheme = { + bgCard: 'linear-gradient(135deg, #1a1a2e 0%, #0f0f1a 100%)', + border: 'rgba(212, 175, 55, 0.3)', + gold: '#D4AF37', + orange: '#FF9500', + green: '#00C851', + red: '#FF4444', + textPrimary: '#FFFFFF', + textMuted: 'rgba(255, 255, 255, 0.6)', +}; +``` + +**状态计算工具**(`utils.ts`): +| 函数 | 功能 | +|------|------| +| `getTrendDescription` | 根据涨跌幅返回走势描述(强势上涨/小幅下跌等) | +| `getTurnoverStatus` | 换手率状态(≥3% 活跃, ≥1% 正常, <1% 冷清) | +| `getPEStatus` | 市盈率估值评级(极低估值/合理/偏高/泡沫风险) | +| `getPledgeStatus` | 质押率健康状态(<10% 健康, <30% 正常, <50% 偏高, ≥50% 警惕) | +| `getPriceColor` | 根据涨跌返回颜色(红涨绿跌) | + +**原子组件说明**: +| 组件 | 行数 | 用途 | 可复用场景 | +|------|------|------|-----------| +| `DarkGoldCard` | ~40 | 黑金主题卡片容器 | 任何需要黑金风格的卡片 | +| `CardTitle` | ~30 | 卡片标题行 | 带图标的标题展示 | +| `MetricValue` | ~45 | 核心数值展示 | 各种指标数值展示 | +| `PriceDisplay` | ~55 | 价格+涨跌幅 | 股票价格展示 | +| `StatusTag` | ~20 | 状态标签 | 各种状态文字标签 | + +**响应式断点**: +- `lg` (≥992px): 4 列 +- `md` (≥768px): 2 列 +- `base` (<768px): 1 列 + +**类型定义更新**(`types.ts`): +- `StockSummaryCardProps.theme` 改为可选参数,组件内置使用 `darkGoldTheme` + +**优化效果**: +| 指标 | 优化前 | 优化后 | 改善 | +|------|--------|--------|------| +| 主文件行数 | ~350 | ~115 | -67% | +| 文件数量 | 1 | 8 | 原子设计模式 | +| 可复用组件 | 0 | 5 原子 + 2 业务 | 提升 | +| 主题支持 | 依赖传入 | 内置黑金主题 | 独立 | + +**设计原则**: +- **原子设计模式**:atoms(基础元素)→ 业务组件(MetricCard、StockHeaderCard)→ 页面组件(index.tsx) +- **主题独立**:StockSummaryCard 使用内置黑金主题,不依赖外部传入 +- **职责分离**:状态计算逻辑提取到 `utils.ts`,UI 与逻辑解耦 +- **组件复用**:原子组件可在其他黑金主题场景复用 + +### 2025-12-16 TradeDataPanel 原子设计模式拆分 + +**改动概述**: +- `TradeDataPanel.tsx` 从 **382 行** 拆分为 **8 个 TypeScript 文件** +- 采用**原子设计模式**组织代码 +- 提取 **3 个原子组件** + **3 个业务组件** + +**拆分后文件结构**: +``` +TradeDataPanel/ +├── index.tsx # 主入口组件(~50 行,组合 3 个子组件) +├── KLineChart.tsx # 日K线图组件(~40 行) +├── MinuteKLineSection.tsx # 分钟K线区域(~95 行,含加载/空状态处理) +├── TradeTable.tsx # 交易明细表格(~75 行) +└── atoms/ # 原子组件 + ├── index.ts # 统一导出 + ├── MinuteStats.tsx # 分钟数据统计(~80 行,4 个 Stat 卡片) + ├── TradeAnalysis.tsx # 成交分析(~65 行,活跃时段/平均价格等) + └── EmptyState.tsx # 空状态组件(~35 行,可复用) +``` + +**组件依赖关系**: +``` +index.tsx +├── KLineChart # 日K线图(ECharts) +├── MinuteKLineSection # 分钟K线区域 +│ ├── MinuteStats (atom) # 开盘/当前/最高/最低价统计 +│ ├── TradeAnalysis (atom) # 成交数据分析 +│ └── EmptyState (atom) # 空状态提示 +└── TradeTable # 交易明细表格(最近 10 天) +``` + +**组件职责**: +| 组件 | 行数 | 功能 | +|------|------|------| +| `index.tsx` | ~50 | 主入口,组合 3 个子组件 | +| `KLineChart` | ~40 | 日K线图渲染,支持图表点击事件 | +| `MinuteKLineSection` | ~95 | 分钟K线区域,含加载状态、空状态、统计数据 | +| `TradeTable` | ~75 | 最近 10 天交易明细表格 | +| `MinuteStats` | ~80 | 分钟数据四宫格统计(开盘/当前/最高/最低价) | +| `TradeAnalysis` | ~65 | 成交数据分析(活跃时段、平均价格、数据点数) | +| `EmptyState` | ~35 | 通用空状态组件(可配置标题和描述) | + +**优化效果**: +| 指标 | 优化前 | 优化后 | 改善 | +|------|--------|--------|------| +| 主文件行数 | 382 | ~50 | -87% | +| 文件数量 | 1 | 8 | 原子设计模式 | +| 可复用组件 | 0 | 3 原子 + 3 业务 | 提升 | + +**设计原则**: +- **原子设计模式**:atoms(MinuteStats、TradeAnalysis、EmptyState)→ 业务组件(KLineChart、MinuteKLineSection、TradeTable)→ 主组件 +- **职责分离**:图表、统计、表格各自独立 +- **组件复用**:EmptyState 可在其他场景复用 +- **类型安全**:完整的 Props 类型定义和导出 + +### 2025-12-17 StockQuoteCard 数据下沉优化 + +**改动概述**: +- StockQuoteCard Props 从 **11 个** 精简至 **4 个**(减少 64%) +- 行情数据、基本信息、股票对比逻辑全部下沉到组件内部 +- Company/index.js 移除约 **40 行** 数据获取代码 +- 删除 `Company/hooks/useStockQuote.js` + +**创建的文件**: +``` +StockQuoteCard/hooks/ +├── index.ts # hooks 导出索引 +├── useStockQuoteData.ts # 行情数据 + 基本信息获取(~152 行) +└── useStockCompare.ts # 股票对比逻辑(~91 行) +``` + +**Props 对比**: + +**优化前(11 个 Props)**: +```tsx + +``` + +**优化后(4 个 Props)**: +```tsx + +``` + +**Hook 返回值**: + +`useStockQuoteData(stockCode)`: +```typescript +{ + quoteData: StockQuoteCardData | null; // 行情数据 + basicInfo: BasicInfo | null; // 基本信息 + isLoading: boolean; // 加载状态 + error: string | null; // 错误信息 + refetch: () => void; // 手动刷新 +} +``` + +`useStockCompare(stockCode)`: +```typescript +{ + currentStockInfo: StockInfo | null; // 当前股票财务信息 + compareStockInfo: StockInfo | null; // 对比股票财务信息 + isCompareLoading: boolean; // 对比数据加载中 + handleCompare: (code: string) => Promise; // 触发对比 + clearCompare: () => void; // 清除对比 +} +``` + +**修改的文件**: +| 文件 | 操作 | 说明 | +|------|------|------| +| `StockQuoteCard/hooks/useStockQuoteData.ts` | 新建 | 合并行情+基本信息获取 | +| `StockQuoteCard/hooks/useStockCompare.ts` | 新建 | 股票对比逻辑 | +| `StockQuoteCard/hooks/index.ts` | 新建 | hooks 导出索引 | +| `StockQuoteCard/index.tsx` | 修改 | 使用内部 hooks,减少 props | +| `StockQuoteCard/types.ts` | 修改 | Props 从 11 个精简为 4 个 | +| `Company/index.js` | 修改 | 移除下沉的数据获取逻辑 | +| `Company/hooks/useStockQuote.js` | 删除 | 已移到 StockQuoteCard | + +**优化收益**: +| 指标 | 优化前 | 优化后 | 改善 | +|------|--------|--------|------| +| Props 数量 | 11 | 4 | -64% | +| Company/index.js 行数 | ~172 | ~105 | -39% | +| 数据获取位置 | 页面层 | 组件内部 | 就近原则 | +| 可复用性 | 依赖父组件 | 独立可用 | 提升 | + +**设计原则**: +- **数据就近获取**:组件自己获取自己需要的数据 +- **Props 最小化**:只传递真正需要外部控制的状态 +- **职责清晰**:自选股状态保留在页面层(涉及 Redux 和事件追踪) +- **可复用性**:StockQuoteCard 可独立在其他页面使用 \ No newline at end of file diff --git a/src/views/Company/components/CompanyHeader/SearchBar.js b/src/views/Company/components/CompanyHeader/SearchBar.js new file mode 100644 index 00000000..f6989923 --- /dev/null +++ b/src/views/Company/components/CompanyHeader/SearchBar.js @@ -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 ( + + + + + + 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', + }} + /> + + + {/* 模糊搜索下拉列表 */} + {showDropdown && ( + + + {filteredStocks.map((stock) => ( + handleSelectStock(stock)} + borderBottom="1px solid" + borderColor="whiteAlpha.100" + _last={{ borderBottom: 'none' }} + > + + + {stock.code} + + + {stock.name} + + + + ))} + + + )} + + ); +}; + +export default SearchBar; diff --git a/src/views/Company/components/CompanyHeader/index.js b/src/views/Company/components/CompanyHeader/index.js new file mode 100644 index 00000000..16894da4 --- /dev/null +++ b/src/views/Company/components/CompanyHeader/index.js @@ -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 ( + + + + {/* 标题区域 - 金色主题 */} + + 个股详情 + + 查看股票实时行情、财务数据和盈利预测 + + + + {/* 搜索栏 */} + + + + + ); +}; + +export default CompanyHeader; diff --git a/src/views/Company/components/CompanyOverview/BasicInfoTab/components/AnnouncementsPanel.tsx b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/AnnouncementsPanel.tsx new file mode 100644 index 00000000..65749056 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/AnnouncementsPanel.tsx @@ -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 = ({ stockCode }) => { + const { announcements, loading } = useAnnouncementsData(stockCode); + + const { isOpen, onOpen, onClose } = useDisclosure(); + const [selectedAnnouncement, setSelectedAnnouncement] = useState(null); + + const handleAnnouncementClick = (announcement: any) => { + setSelectedAnnouncement(announcement); + onOpen(); + }; + + if (loading) { + return ; + } + + return ( + <> + + {/* 最新公告 */} + + + {announcements.map((announcement: any, idx: number) => ( + handleAnnouncementClick(announcement)} + _hover={{ bg: THEME.tableHoverBg }} + > + + + + + + {announcement.info_type || "公告"} + + + {formatDate(announcement.announce_date)} + + + + {announcement.title} + + + + {announcement.format && ( + + {announcement.format} + + )} + } + variant="ghost" + color={THEME.goldLight} + aria-label="查看原文" + onClick={(e) => { + e.stopPropagation(); + window.open(announcement.url, "_blank"); + }} + /> + + + + + ))} + + + + + {/* 公告详情模态框 */} + + + + + + {selectedAnnouncement?.title} + + + {selectedAnnouncement?.info_type || "公告"} + + + {formatDate(selectedAnnouncement?.announce_date)} + + + + + + + + + 文件格式:{selectedAnnouncement?.format || "-"} + + + 文件大小:{selectedAnnouncement?.file_size || "-"} KB + + + + + + + + + + + ); +}; + +export default AnnouncementsPanel; diff --git a/src/views/Company/components/CompanyOverview/BasicInfoTab/components/BranchesPanel.tsx b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/BranchesPanel.tsx new file mode 100644 index 00000000..639f2be7 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/BranchesPanel.tsx @@ -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 }) => ( + + + {label} + + + {value || "-"} + + +); + +const BranchesPanel: React.FC = ({ stockCode }) => { + const { branches, loading } = useBranchesData(stockCode); + + if (loading) { + return ; + } + + if (branches.length === 0) { + return ( +
+ + + + + + 暂无分支机构信息 + + +
+ ); + } + + return ( + + {branches.map((branch: any, idx: number) => { + const isActive = branch.business_status === "存续"; + + return ( + + {/* 顶部金色装饰线 */} + + + + + {/* 标题行 */} + + + + + + + {branch.branch_name} + + + + {/* 状态徽章 */} + + + {branch.business_status} + + + + {/* 分隔线 */} + + + {/* 信息网格 */} + + + + + + + + + + ); + })} + + ); +}; + +export default BranchesPanel; diff --git a/src/views/Company/components/CompanyOverview/BasicInfoTab/components/BusinessInfoPanel.tsx b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/BusinessInfoPanel.tsx new file mode 100644 index 00000000..9379a03f --- /dev/null +++ b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/BusinessInfoPanel.tsx @@ -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 = ({ stockCode }) => { + const { basicInfo, loading } = useBasicInfo(stockCode); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!basicInfo) { + return ( +
+ 暂无工商信息 +
+ ); + } + + return ( + + + + 工商信息 + + + + 统一信用代码 + + + {basicInfo.credit_code} + + + + + 公司规模 + + {basicInfo.company_size} + + + + 注册地址 + + + {basicInfo.reg_address} + + + + + 办公地址 + + + {basicInfo.office_address} + + + + + + + 服务机构 + + + 会计师事务所 + + {basicInfo.accounting_firm} + + + + 律师事务所 + + {basicInfo.law_firm} + + + + + + + + + + 主营业务 + + {basicInfo.main_business} + + + + + 经营范围 + + {basicInfo.business_scope} + + + + ); +}; + +export default BusinessInfoPanel; diff --git a/src/views/Company/components/CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel.tsx b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel.tsx new file mode 100644 index 00000000..28bbf86d --- /dev/null +++ b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel.tsx @@ -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 = ({ stockCode }) => { + const { disclosureSchedule, loading } = useDisclosureData(stockCode); + + if (loading) { + return ; + } + + if (disclosureSchedule.length === 0) { + return ( + + 暂无披露日程数据 + + ); + } + + return ( + + + + {disclosureSchedule.map((schedule: any, idx: number) => ( + + + + + {schedule.report_name} + + + {schedule.is_disclosed ? "已披露" : "预计"} + + + {formatDate( + schedule.is_disclosed + ? schedule.actual_date + : schedule.latest_scheduled_date + )} + + + + + ))} + + + + ); +}; + +export default DisclosureSchedulePanel; diff --git a/src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx new file mode 100644 index 00000000..450cefef --- /dev/null +++ b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx @@ -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 = ({ + message = "加载中...", + height = "200px", +}) => { + return ( +
+ + + + {message} + + +
+ ); +}; + +export default LoadingState; diff --git a/src/views/Company/components/CompanyOverview/BasicInfoTab/components/ShareholderPanel.tsx b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/ShareholderPanel.tsx new file mode 100644 index 00000000..4b9a4989 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/ShareholderPanel.tsx @@ -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 = ({ stockCode }) => { + const { + actualControl, + concentration, + topShareholders, + topCirculationShareholders, + loading, + } = useShareholderData(stockCode); + + return ( + + {/* 实际控制人 + 股权集中度 左右分布 */} + + + + + + + + + + {/* 十大股东 + 十大流通股东 左右分布 */} + + + + + + + + + + ); +}; + +export default ShareholderPanel; diff --git a/src/views/Company/components/CompanyOverview/BasicInfoTab/components/index.ts b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/index.ts new file mode 100644 index 00000000..e4abb538 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/index.ts @@ -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"; diff --git a/src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/CategorySection.tsx b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/CategorySection.tsx new file mode 100644 index 00000000..80b20ee9 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/CategorySection.tsx @@ -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 = ({ + category, + people, + icon, + color, +}) => { + if (people.length === 0) { + return null; + } + + return ( + + {/* 分类标题 */} + + + + {category} + + + {people.length}人 + + + + {/* 人员卡片网格 */} + + {people.map((person, idx) => ( + + ))} + + + ); +}; + +export default memo(CategorySection); diff --git a/src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/ManagementCard.tsx b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/ManagementCard.tsx new file mode 100644 index 00000000..433a36a5 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/ManagementCard.tsx @@ -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 = ({ person, categoryColor }) => { + const currentYear = new Date().getFullYear(); + const age = person.birth_year ? currentYear - parseInt(person.birth_year, 10) : null; + + return ( + + + + + + {/* 姓名和性别 */} + + + {person.name} + + {person.gender && ( + + )} + + + {/* 职位 */} + + {person.position_name} + + + {/* 标签:学历、年龄、国籍 */} + + {person.education && ( + + + {person.education} + + )} + {age && ( + + {age}岁 + + )} + {person.nationality && person.nationality !== "中国" && ( + + + {person.nationality} + + )} + + + {/* 任职日期 */} + + 任职日期:{formatDate(person.start_date)} + + + + + + ); +}; + +export default memo(ManagementCard); diff --git a/src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/ManagementPanel.tsx b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/ManagementPanel.tsx new file mode 100644 index 00000000..bfac87b0 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/ManagementPanel.tsx @@ -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 = { + 高管: { 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 = ({ stockCode }) => { + const { management, loading } = useManagementData(stockCode); + + // 使用 useMemo 缓存分类计算结果 + const categorizedManagement = useMemo( + () => categorizeManagement(management as ManagementPerson[]), + [management] + ); + + return ( + + {CATEGORY_ORDER.map((category) => { + const config = CATEGORY_CONFIG[category]; + const people = categorizedManagement[category]; + + return ( + + ); + })} + + ); +}; + +export default ManagementPanel; diff --git a/src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/index.ts b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/index.ts new file mode 100644 index 00000000..f61b4ab4 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/index.ts @@ -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"; diff --git a/src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/types.ts b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/types.ts new file mode 100644 index 00000000..81cac215 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/types.ts @@ -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; + +/** + * 分类配置项 + */ +export interface CategoryConfig { + icon: IconType; + color: string; +} diff --git a/src/views/Company/components/CompanyOverview/BasicInfoTab/config.ts b/src/views/Company/components/CompanyOverview/BasicInfoTab/config.ts new file mode 100644 index 00000000..d633b3b5 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/BasicInfoTab/config.ts @@ -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) + ); +}; diff --git a/src/views/Company/components/CompanyOverview/BasicInfoTab/index.tsx b/src/views/Company/components/CompanyOverview/BasicInfoTab/index.tsx new file mode 100644 index 00000000..857ab30d --- /dev/null +++ b/src/views/Company/components/CompanyOverview/BasicInfoTab/index.tsx @@ -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> = { + 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 通用组件 + * - 可配置显示哪些 Tab(enabledTabs) + * - 黑金主题 + * - 懒加载 + * - 支持 Tab 变更回调 + */ +const BasicInfoTab: React.FC = ({ + stockCode, + enabledTabs, + defaultTabIndex = 0, + onTabChange, +}) => { + // 构建 tabs 配置(缓存避免重复计算) + const tabs = useMemo(() => buildTabsConfig(enabledTabs), [enabledTabs]); + + return ( + + + + + + ); +}; + +export default BasicInfoTab; + +// 导出配置和工具,供外部使用 +export { THEME, TAB_CONFIG, getEnabledTabs } from "./config"; +export * from "./utils"; diff --git a/src/views/Company/components/CompanyOverview/BasicInfoTab/utils.ts b/src/views/Company/components/CompanyOverview/BasicInfoTab/utils.ts new file mode 100644 index 00000000..35358861 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/BasicInfoTab/utils.ts @@ -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, +}; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/BusinessTreeItem.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/BusinessTreeItem.tsx new file mode 100644 index 00000000..9ed5ad7f --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/BusinessTreeItem.tsx @@ -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 = ({ 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 ( + 0 ? '4px solid' : 'none'} + borderLeftColor={THEME.gold} + borderRadius="md" + mb={2} + _hover={{ shadow: 'md', bg: 'gray.600' }} + transition="all 0.2s" + > + + + + + {business.business_name} + + {business.financial_metrics?.revenue_ratio && + business.financial_metrics.revenue_ratio > 30 && ( + + 核心业务 + + )} + + + + 营收占比: {formatPercentage(business.financial_metrics?.revenue_ratio)} + + + 毛利率: {formatPercentage(business.financial_metrics?.gross_margin)} + + {business.growth_metrics?.revenue_growth !== undefined && ( + 0 ? 'red.600' : 'green.600'} + color="white" + > + + 增长: {business.growth_metrics.revenue_growth > 0 ? '+' : ''} + {formatPercentage(business.growth_metrics.revenue_growth)} + + + )} + + + + + {getRevenueDisplay()} + + + 营业收入 + + + + + ); +}; + +export default BusinessTreeItem; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/DisclaimerBox.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/DisclaimerBox.tsx new file mode 100644 index 00000000..581ceccf --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/DisclaimerBox.tsx @@ -0,0 +1,24 @@ +/** + * 免责声明组件 + * + * 显示 AI 分析内容的免责声明提示 + * 使用位置:深度分析各 Card 底部(共 6 处) + */ + +import React from 'react'; +import { Text } from '@chakra-ui/react'; + +const DisclaimerBox: React.FC = () => { + return ( + + 免责声明:本内容由AI模型基于新闻、公告、研报等公开信息自动分析和生成,未经许可严禁转载。所有内容仅供参考,不构成任何投资建议,请投资者注意风险,独立审慎决策。 + + ); +}; + +export default DisclaimerBox; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/KeyFactorCard.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/KeyFactorCard.tsx new file mode 100644 index 00000000..aea12789 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/KeyFactorCard.tsx @@ -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 = { + positive: 'red', + negative: 'green', + neutral: 'gray', + mixed: 'yellow', + }; + return colorMap[direction || 'neutral'] || 'gray'; +}; + +/** + * 获取影响方向的中文标签 + */ +const getImpactLabel = (direction?: ImpactDirection): string => { + const labelMap: Record = { + positive: '正面', + negative: '负面', + neutral: '中性', + mixed: '混合', + }; + return labelMap[direction || 'neutral'] || '中性'; +}; + +const KeyFactorCard: React.FC = ({ factor }) => { + const impactColor = getImpactColor(factor.impact_direction); + + return ( + + + + + + {factor.factor_name} + + + {getImpactLabel(factor.impact_direction)} + + + + + + {factor.factor_value} + {factor.factor_unit && ` ${factor.factor_unit}`} + + {factor.year_on_year !== undefined && ( + 0 ? 'red.400' : 'green.400'} + color={factor.year_on_year > 0 ? 'red.400' : 'green.400'} + > + 0 ? FaArrowUp : FaArrowDown} + mr={1} + boxSize={3} + /> + {Math.abs(factor.year_on_year)}% + + )} + + + {factor.factor_desc && ( + + {factor.factor_desc} + + )} + + + + 影响权重: {factor.impact_weight} + + {factor.report_period && ( + + {factor.report_period} + + )} + + + + + ); +}; + +export default KeyFactorCard; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/ProcessNavigation.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/ProcessNavigation.tsx new file mode 100644 index 00000000..2c4e6518 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/ProcessNavigation.tsx @@ -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 = memo(({ + label, + subtitle, + count, + isActive, + colorKey, + onClick, +}) => { + const colors = THEME[colorKey]; + + return ( + + + + + {label} + + + {count} + + + + {subtitle} + + + + ); +}); + +NavItem.displayName = 'NavItem'; + +const ProcessNavigation: React.FC = memo(({ + activeTab, + onTabChange, + upstreamCount, + coreCount, + downstreamCount, +}) => { + return ( + + onTabChange('upstream')} + /> + + + + onTabChange('core')} + /> + + + + onTabChange('downstream')} + /> + + ); +}); + +ProcessNavigation.displayName = 'ProcessNavigation'; + +export default ProcessNavigation; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/ScoreBar.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/ScoreBar.tsx new file mode 100644 index 00000000..338d57f2 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/ScoreBar.tsx @@ -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 = ({ label, score, icon }) => { + const percentage = ((score || 0) / 100) * 100; + const colorScheme = getColorScheme(percentage); + + return ( + + + + {icon && ( + + )} + + {label} + + + {score || 0} + + + + ); +}; + +export default ScoreBar; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/ValueChainFilterBar.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/ValueChainFilterBar.tsx new file mode 100644 index 00000000..28f7c1b2 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/ValueChainFilterBar.tsx @@ -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 = memo(({ + typeFilter, + onTypeChange, + importanceFilter, + onImportanceChange, + viewMode, + onViewModeChange, +}) => { + return ( + + {/* 左侧筛选区 */} + {/* + + + + */} + + {/* 右侧视图切换 */} + onViewModeChange(index === 0 ? 'hierarchy' : 'flow')} + variant="soft-rounded" + size="sm" + > + + + 层级视图 + + + 流向关系 + + + + + ); +}); + +ValueChainFilterBar.displayName = 'ValueChainFilterBar'; + +export default ValueChainFilterBar; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/index.ts b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/index.ts new file mode 100644 index 00000000..0db9bd87 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/atoms/index.ts @@ -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'; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/BusinessSegmentsCard.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/BusinessSegmentsCard.tsx new file mode 100644 index 00000000..0710117c --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/BusinessSegmentsCard.tsx @@ -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; + onToggleSegment: (index: number) => void; + cardBg?: string; +} + +const BusinessSegmentsCard: React.FC = ({ + businessSegments, + expandedSegments, + onToggleSegment, +}) => { + if (!businessSegments || businessSegments.length === 0) return null; + + return ( + + + + + 业务板块详情 + {businessSegments.length} 个板块 + + + + + {businessSegments.map((segment, idx) => { + const isExpanded = expandedSegments[idx]; + + return ( + + + + + + {segment.segment_name} + + + + + + + 业务描述 + + + {segment.segment_description || '暂无描述'} + + + + + + 竞争地位 + + + {segment.competitive_position || '暂无数据'} + + + + + + 未来潜力 + + + {segment.future_potential || '暂无数据'} + + + + {isExpanded && segment.key_products && ( + + + 主要产品 + + + {segment.key_products} + + + )} + + {isExpanded && segment.market_share !== undefined && ( + + + 市场份额 + + + {segment.market_share}% + + + )} + + {isExpanded && segment.revenue_contribution !== undefined && ( + + + 营收贡献 + + + {segment.revenue_contribution}% + + + )} + + + + ); + })} + + + + ); +}; + +export default BusinessSegmentsCard; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/BusinessStructureCard.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/BusinessStructureCard.tsx new file mode 100644 index 00000000..83e75818 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/BusinessStructureCard.tsx @@ -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 = ({ + businessStructure, +}) => { + if (!businessStructure || businessStructure.length === 0) return null; + + return ( + + + + + 业务结构分析 + {businessStructure[0]?.report_period} + + + + + {businessStructure.map((business, idx) => ( + + ))} + + + + ); +}; + +export default BusinessStructureCard; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CompetitiveAnalysisCard.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CompetitiveAnalysisCard.tsx new file mode 100644 index 00000000..8bc9b0bc --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CompetitiveAnalysisCard.tsx @@ -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(({ competitors }) => ( + + + 主要竞争对手 + + + {competitors.map((competitor, idx) => ( + + + {competitor} + + ))} + + +)); + +CompetitorTags.displayName = 'CompetitorTags'; + +// 评分区域组件 +interface ScoreSectionProps { + scores: CompetitivePosition['scores']; +} + +const ScoreSection = memo(({ scores }) => ( + + + + + + + + + + +)); + +ScoreSection.displayName = 'ScoreSection'; + +// 竞争优劣势组件 +interface AdvantagesSectionProps { + advantages?: string; + disadvantages?: string; +} + +const AdvantagesSection = memo( + ({ advantages, disadvantages }) => ( + + + + 竞争优势 + + + {advantages || '暂无数据'} + + + + + 竞争劣势 + + + {disadvantages || '暂无数据'} + + + + ) +); + +AdvantagesSection.displayName = 'AdvantagesSection'; + +const CompetitiveAnalysisCard: React.FC = 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 ( + <> + + + + + 竞争地位分析 + {competitivePosition.ranking && ( + + 行业排名 {competitivePosition.ranking.industry_rank}/ + {competitivePosition.ranking.total_companies} + + )} + {hasIndustryRankData && ( + + )} + + + + {/* 主要竞争对手 */} + {competitors.length > 0 && } + + {/* 评分和雷达图 */} + + + + + + + {radarOption && ( + + )} + + + + + + {/* 竞争优势和劣势 */} + + + + + {/* 行业排名弹窗 - 黑金主题 */} + + + + + + + 行业排名详情 + + + + + {hasIndustryRankData && ( + + )} + + + + + ); + } +); + +CompetitiveAnalysisCard.displayName = 'CompetitiveAnalysisCard'; + +export default CompetitiveAnalysisCard; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CorePositioningCard/atoms/HighlightCard.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CorePositioningCard/atoms/HighlightCard.tsx new file mode 100644 index 00000000..e4a0b75d --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CorePositioningCard/atoms/HighlightCard.tsx @@ -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(({ highlight }) => { + const IconComponent = ICON_MAP[highlight.icon] || FaUsers; + + return ( + + + + + + + + {highlight.title} + + + {highlight.description} + + + + + ); +}); + +HighlightCard.displayName = 'HighlightCard'; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CorePositioningCard/atoms/ModelBlock.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CorePositioningCard/atoms/ModelBlock.tsx new file mode 100644 index 00000000..60495a6b --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CorePositioningCard/atoms/ModelBlock.tsx @@ -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(({ section, isLast }) => ( + + + + {section.title} + + + {section.description} + + {section.tags && section.tags.length > 0 && ( + + {section.tags.map((tag, idx) => ( + + {tag} + + ))} + + )} + + {!isLast && } + +)); + +ModelBlock.displayName = 'ModelBlock'; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CorePositioningCard/atoms/SectionHeader.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CorePositioningCard/atoms/SectionHeader.tsx new file mode 100644 index 00000000..6a1c5267 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CorePositioningCard/atoms/SectionHeader.tsx @@ -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( + ({ icon, title, color = THEME.dark.titleColor }) => ( + + + + {title} + + + ) +); + +SectionHeader.displayName = 'SectionHeader'; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CorePositioningCard/atoms/index.ts b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CorePositioningCard/atoms/index.ts new file mode 100644 index 00000000..47e08ba9 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CorePositioningCard/atoms/index.ts @@ -0,0 +1,7 @@ +/** + * CorePositioningCard 原子组件统一导出 + */ + +export { SectionHeader } from './SectionHeader'; +export { HighlightCard } from './HighlightCard'; +export { ModelBlock } from './ModelBlock'; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CorePositioningCard/index.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CorePositioningCard/index.tsx new file mode 100644 index 00000000..07301161 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CorePositioningCard/index.tsx @@ -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 = 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 ( + + + + + {corePositioning?.one_line_intro && ( + + + {corePositioning.one_line_intro} + + + )} + + + + + + {corePositioning?.investment_highlights_text || + (typeof corePositioning?.investment_highlights === 'string' + ? corePositioning.investment_highlights + : '暂无数据')} + + + + + + + + {corePositioning?.business_model_desc || '暂无数据'} + + + + + + + + ); + } + + // 结构化数据渲染 - 缓存数组计算 + 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 ( + + + + {/* 核心定位区域(深色背景) */} + + + + {/* 一句话介绍 */} + {corePositioning?.one_line_intro && ( + + + {corePositioning.one_line_intro} + + + )} + + + {/* 投资亮点 + 商业模式区域 */} + + {/* 投资亮点区域 */} + + + + {highlights.length > 0 ? ( + highlights.map((highlight, idx) => ( + + )) + ) : ( + + 暂无数据 + + )} + + + + {/* 商业模式区域 */} + + + + {businessSections.length > 0 ? ( + businessSections.map((section, idx) => ( + + )) + ) : ( + + 暂无数据 + + )} + + + + + + + ); + } +); + +CorePositioningCard.displayName = 'CorePositioningCard'; + +export default CorePositioningCard; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CorePositioningCard/theme.ts b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CorePositioningCard/theme.ts new file mode 100644 index 00000000..a640f522 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/CorePositioningCard/theme.ts @@ -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 = { + 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; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/KeyFactorsCard.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/KeyFactorsCard.tsx new file mode 100644 index 00000000..b14e0961 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/KeyFactorsCard.tsx @@ -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 = ({ keyFactors }) => { + return ( + + + + + + 关键因素 + + + {keyFactors.total_factors} 项 + + + + + + {keyFactors.categories.map((category, idx) => ( + + + + + + {category.category_name} + + + {category.factors.length} + + + + + + + + {category.factors.map((factor, fidx) => ( + + ))} + + + + ))} + + + + ); +}; + +export default KeyFactorsCard; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/StrategyAnalysisCard.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/StrategyAnalysisCard.tsx new file mode 100644 index 00000000..ab61cecf --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/StrategyAnalysisCard.tsx @@ -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(() => ( + +
+ + + 战略数据更新中 + + 战略方向和具体举措数据将在近期更新 + + +
+
+)); + +EmptyState.displayName = 'StrategyEmptyState'; + +// 内容项组件 - 复用结构 +interface ContentItemProps { + title: string; + content: string; +} + +const ContentItem = memo(({ title, content }) => ( + + + {title} + + + {content} + + +)); + +ContentItem.displayName = 'StrategyContentItem'; + +const StrategyAnalysisCard: React.FC = memo( + ({ strategy }) => { + // 缓存数据检测结果 + const hasData = useMemo( + () => !!(strategy?.strategy_description || strategy?.strategic_initiatives), + [strategy?.strategy_description, strategy?.strategic_initiatives] + ); + + return ( + + + + + 战略分析 + + + + {!hasData ? ( + + ) : ( + + + + + + + + + + + )} + + + ); + } +); + +StrategyAnalysisCard.displayName = 'StrategyAnalysisCard'; + +export default StrategyAnalysisCard; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/TimelineCard.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/TimelineCard.tsx new file mode 100644 index 00000000..87ab1065 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/TimelineCard.tsx @@ -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 = ({ developmentTimeline }) => { + return ( + + + + + + 发展时间线 + + + + 正面 {developmentTimeline.statistics?.positive_events || 0} + + + 负面 {developmentTimeline.statistics?.negative_events || 0} + + + + + + + + + + + ); +}; + +export default TimelineCard; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/ValueChainCard.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/ValueChainCard.tsx new file mode 100644 index 00000000..4a3f7ab1 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/ValueChainCard.tsx @@ -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 = memo(({ + valueChainData, + companyName = '目标公司', +}) => { + // 状态管理 + const [activeTab, setActiveTab] = useState('upstream'); + const [typeFilter, setTypeFilter] = useState('all'); + const [importanceFilter, setImportanceFilter] = useState('all'); + const [viewMode, setViewMode] = useState('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 ( + + {/* 头部区域 */} + + + + + 产业链分析 + + + | {companyName}供应链图谱 + + + 节点 {totalNodes} + + + + + + {/* 工具栏:左侧流程导航 + 右侧筛选 */} + + {/* 左侧:流程式导航 - 仅在层级视图显示 */} + {viewMode === 'hierarchy' && ( + + )} + + {/* 右侧:筛选与视图切换 - 始终靠右 */} + + + + + + {/* 内容区域 */} + + {viewMode === 'hierarchy' ? ( + filteredNodes.length > 0 ? ( + + {filteredNodes.map((node, idx) => ( + + ))} + + ) : ( +
+ 暂无匹配的节点数据 +
+ ) + ) : sankeyOption ? ( + + ) : ( +
+ 暂无流向数据 +
+ )} +
+
+
+ ); +}); + +ValueChainCard.displayName = 'ValueChainCard'; + +export default ValueChainCard; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/index.ts b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/index.ts new file mode 100644 index 00000000..f3e4d8b7 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/components/index.ts @@ -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'; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/index.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/index.tsx new file mode 100644 index 00000000..858e6575 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/index.tsx @@ -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 = { + strategy: 0, + business: 1, + valueChain: 2, + development: 3, +}; + +const DeepAnalysisTab: React.FC = ({ + 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 ( + + + + + + + ); + } + + return ( + + + + + + ); +}; + +export default DeepAnalysisTab; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/organisms/TimelineComponent/EventDetailModal.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/organisms/TimelineComponent/EventDetailModal.tsx new file mode 100644 index 00000000..040c47f3 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/organisms/TimelineComponent/EventDetailModal.tsx @@ -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 = ({ + isOpen, + onClose, + event, +}) => { + if (!event) return null; + + const isPositive = event.impact_metrics?.is_positive; + const impactScore = event.impact_metrics?.impact_score || 0; + + return ( + + + + + + + + {event.event_title} + + + {event.event_type} + + + {event.event_date} + + + + + + + + + + + 事件详情 + + + {event.event_desc} + + + + {event.related_info?.financial_impact && ( + + + 财务影响 + + + {event.related_info.financial_impact} + + + )} + + + + 影响评估 + + + + + 影响度 + + 70 ? 'red' : 'orange'} + hasStripe + isAnimated + /> + + {impactScore}/100 + + + + + {isPositive ? '正面影响' : '负面影响'} + + + + + + + + + + + + ); +}; + +export default EventDetailModal; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/organisms/TimelineComponent/index.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/organisms/TimelineComponent/index.tsx new file mode 100644 index 00000000..b5a746f2 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/organisms/TimelineComponent/index.tsx @@ -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 = ({ events }) => { + const [selectedEvent, setSelectedEvent] = useState(null); + const { isOpen, onOpen, onClose } = useDisclosure(); + + // 背景颜色 + const positiveBgColor = 'red.50'; + const negativeBgColor = 'green.50'; + + const handleEventClick = (event: TimelineEvent) => { + setSelectedEvent(event); + onOpen(); + }; + + return ( + <> + + {/* 时间线轴 */} + + + + {events.map((event, idx) => { + const isPositive = event.impact_metrics?.is_positive; + const iconColor = isPositive ? 'red.500' : 'green.500'; + const bgColor = isPositive ? positiveBgColor : negativeBgColor; + + return ( + + + {/* 时间点圆圈 */} + + + + + {/* 连接线 */} + + + {/* 事件卡片 */} + handleEventClick(event)} + _hover={{ shadow: 'lg', transform: 'translateX(4px)' }} + transition="all 0.3s ease" + > + + + + + + {event.event_title} + + + + + {event.event_date} + + + + + {event.event_type} + + + + + {event.event_desc} + + + + + 影响度: + + 70 + ? 'red' + : 'orange' + } + borderRadius="full" + /> + + {event.impact_metrics?.impact_score || 0} + + + + + + + + ); + })} + + + + + + ); +}; + +export default TimelineComponent; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/organisms/ValueChainNodeCard/RelatedCompaniesModal.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/organisms/ValueChainNodeCard/RelatedCompaniesModal.tsx new file mode 100644 index 00000000..bf900b61 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/organisms/ValueChainNodeCard/RelatedCompaniesModal.tsx @@ -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 = { + 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 = ({ + isOpen, + onClose, + node, + isCompany, + colorScheme, + relatedCompanies, + loadingRelated, +}) => { + return ( + + + + + + + + {node.node_name} + + {node.node_type} + {isCompany && ( + + 核心企业 + + )} + + + + + + + + {node.node_description && ( + + + 节点描述 + + + {node.node_description} + + + )} + + + + 重要度评分 + + {node.importance_score || 0} + + + + + + + {node.market_share !== undefined && ( + + 市场份额 + {node.market_share}% + + )} + + {node.dependency_degree !== undefined && ( + + 依赖程度 + + {node.dependency_degree}% + + + 50 ? 'orange' : 'green' + } + borderRadius="full" + /> + + + )} + + + + + + + + 相关公司 + + {loadingRelated && } + + {loadingRelated ? ( +
+ +
+ ) : relatedCompanies.length > 0 ? ( + + {relatedCompanies.map((company, idx) => { + const levelInfo = getLevelLabel(company.node_info?.node_level); + + return ( + + + + + + + + {company.stock_name} + + + {company.stock_code} + + + {levelInfo.text} + + + {company.company_name && ( + + {company.company_name} + + )} + + } + variant="ghost" + colorScheme="blue" + onClick={() => { + window.location.href = `/company?stock_code=${company.stock_code}`; + }} + aria-label="查看公司详情" + /> + + + {company.node_info?.node_description && ( + + {company.node_info.node_description} + + )} + + {company.relationships && + company.relationships.length > 0 && ( + + + 产业链关系: + + + {company.relationships.map((rel, ridx) => ( + + + + {rel.role === 'source' + ? '流向' + : '来自'} + + {rel.connected_node} + + + + ))} + + + )} + + + + ); + })} + + ) : ( +
+ + + + 暂无相关公司 + + +
+ )} +
+
+
+ + + +
+
+ ); +}; + +export default RelatedCompaniesModal; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/organisms/ValueChainNodeCard/index.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/organisms/ValueChainNodeCard/index.tsx new file mode 100644 index 00000000..dd8386ec --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/organisms/ValueChainNodeCard/index.tsx @@ -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 = { + 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 = memo(({ + node, + isCompany = false, + level = 0, +}) => { + const { isOpen, onOpen, onClose } = useDisclosure(); + const [relatedCompanies, setRelatedCompanies] = useState([]); + 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 ( + <> + + + + + + + + {isCompany && ( + + 核心企业 + + )} + + {node.importance_score !== undefined && + node.importance_score >= 70 && ( + + + + + + )} + + + + {node.node_name} + + + {node.node_description && ( + + {node.node_description} + + )} + + + + {node.node_type} + + {node.market_share !== undefined && ( + + 份额 {node.market_share}% + + )} + + + {node.importance_score !== undefined && ( + + + + 重要度 + + + {node.importance_score} + + + + + )} + + + + + + + + ); +}); + +ValueChainNodeCard.displayName = 'ValueChainNodeCard'; + +export default ValueChainNodeCard; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/tabs/BusinessTab.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/tabs/BusinessTab.tsx new file mode 100644 index 00000000..7d26c742 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/tabs/BusinessTab.tsx @@ -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; + onToggleSegment: (index: number) => void; +} + +const BusinessTab: React.FC = memo(({ + comprehensiveData, + cardBg, + expandedSegments, + onToggleSegment, +}) => { + return ( + + {/* 业务结构分析 */} + {comprehensiveData?.business_structure && + comprehensiveData.business_structure.length > 0 && ( + + )} + + {/* 业务板块详情 */} + {comprehensiveData?.business_segments && + comprehensiveData.business_segments.length > 0 && ( + + )} + + ); +}); + +BusinessTab.displayName = 'BusinessTab'; + +export default BusinessTab; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/tabs/DevelopmentTab.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/tabs/DevelopmentTab.tsx new file mode 100644 index 00000000..4419fc00 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/tabs/DevelopmentTab.tsx @@ -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 = memo(({ + keyFactorsData, + cardBg, +}) => { + return ( + + + + {keyFactorsData?.key_factors && ( + + )} + + + + {keyFactorsData?.development_timeline && ( + + )} + + + + ); +}); + +DevelopmentTab.displayName = 'DevelopmentTab'; + +export default DevelopmentTab; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/tabs/StrategyTab.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/tabs/StrategyTab.tsx new file mode 100644 index 00000000..7737e406 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/tabs/StrategyTab.tsx @@ -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 = memo(({ + comprehensiveData, + industryRankData, + cardBg, +}) => { + return ( + + {/* 核心定位卡片 */} + {comprehensiveData?.qualitative_analysis && ( + + )} + + {/* 战略分析 */} + {comprehensiveData?.qualitative_analysis?.strategy && ( + + )} + + {/* 竞争地位分析(包含行业排名弹窗) */} + {comprehensiveData?.competitive_position && ( + + )} + + ); +}); + +StrategyTab.displayName = 'StrategyTab'; + +export default StrategyTab; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/tabs/ValueChainTab.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/tabs/ValueChainTab.tsx new file mode 100644 index 00000000..b6785f05 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/tabs/ValueChainTab.tsx @@ -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 = memo(({ + valueChainData, + cardBg, +}) => { + return ( + + {valueChainData && ( + + )} + + ); +}); + +ValueChainTab.displayName = 'ValueChainTab'; + +export default ValueChainTab; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/tabs/index.ts b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/tabs/index.ts new file mode 100644 index 00000000..2ef7c836 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/tabs/index.ts @@ -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'; diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/types.ts b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/types.ts new file mode 100644 index 00000000..631ced8e --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/types.ts @@ -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; + 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 }; + }>; +} diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/utils/chartOptions.ts b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/utils/chartOptions.ts new file mode 100644 index 00000000..7164cc30 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/utils/chartOptions.ts @@ -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(); + 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 }, + }, + ], + }; +}; diff --git a/src/views/Company/components/CompanyOverview/NewsEventsTab.js b/src/views/Company/components/CompanyOverview/NewsEventsTab.js new file mode 100644 index 00000000..e66f7d34 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/NewsEventsTab.js @@ -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( + + ... + + ); + } + + for (let i = startPage; i <= endPage; i++) { + const isActive = i === currentPage; + pageButtons.push( + + ); + } + + // 如果结束页小于总页数,显示省略号 + if (endPage < totalPages) { + pageButtons.push( + + ... + + ); + } + + return pageButtons; + }; + + return ( + + + + + {/* 搜索框和统计信息 */} + + + + + + + + + + + + {newsPagination.total > 0 && ( + + + + 共找到{" "} + + {newsPagination.total} + {" "} + 条新闻 + + + )} + + +
+ + {/* 新闻列表 */} + {newsLoading ? ( +
+ + + 正在加载新闻... + +
+ ) : newsEvents.length > 0 ? ( + <> + + {newsEvents.map((event, idx) => { + const importanceBadgeStyle = getImportanceBadgeStyle( + event.importance + ); + const eventTypeIcon = getEventTypeIcon(event.event_type); + + return ( + + + + {/* 标题栏 */} + + + + + + {event.title} + + + + {/* 标签栏 */} + + {event.importance && ( + + {event.importance}级 + + )} + {event.event_type && ( + + {event.event_type} + + )} + {event.invest_score && ( + + 投资分: {event.invest_score} + + )} + {event.keywords && event.keywords.length > 0 && ( + <> + {event.keywords + .slice(0, 4) + .map((keyword, kidx) => ( + + {typeof keyword === "string" + ? keyword + : keyword?.concept || + keyword?.name || + "未知"} + + ))} + + )} + + + + {/* 右侧信息栏 */} + + + {event.created_at + ? new Date( + event.created_at + ).toLocaleDateString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + : ""} + + + {event.view_count !== undefined && ( + + + + {event.view_count} + + + )} + {event.hot_score !== undefined && ( + + + + {event.hot_score.toFixed(1)} + + + )} + + {event.creator && ( + + @{event.creator.username} + + )} + + + + {/* 描述 */} + {event.description && ( + + {event.description} + + )} + + {/* 收益率数据 */} + {(event.related_avg_chg !== null || + event.related_max_chg !== null || + event.related_week_chg !== null) && ( + + + + + + 相关涨跌: + + + {event.related_avg_chg !== null && + event.related_avg_chg !== undefined && ( + + + 平均 + + 0 + ? "#EF4444" + : "#10B981" + } + > + {event.related_avg_chg > 0 ? "+" : ""} + {event.related_avg_chg.toFixed(2)}% + + + )} + {event.related_max_chg !== null && + event.related_max_chg !== undefined && ( + + + 最大 + + 0 + ? "#EF4444" + : "#10B981" + } + > + {event.related_max_chg > 0 ? "+" : ""} + {event.related_max_chg.toFixed(2)}% + + + )} + {event.related_week_chg !== null && + event.related_week_chg !== undefined && ( + + + 周 + + 0 + ? "#EF4444" + : "#10B981" + } + > + {event.related_week_chg > 0 + ? "+" + : ""} + {event.related_week_chg.toFixed(2)}% + + + )} + + + )} + + + + ); + })} + + + {/* 分页控件 */} + {newsPagination.pages > 1 && ( + + + {/* 分页信息 */} + + 第 {newsPagination.page} / {newsPagination.pages} 页 + + + {/* 分页按钮 */} + + + + + {/* 页码按钮 */} + {renderPaginationButtons()} + + + + + + + )} + + ) : ( +
+ + + + 暂无相关新闻 + + + {searchQuery ? "尝试修改搜索关键词" : "该公司暂无新闻动态"} + + +
+ )} + + + + + ); +}; + +export default NewsEventsTab; diff --git a/src/views/Company/components/CompanyOverview/components/shareholder/ActualControlCard.tsx b/src/views/Company/components/CompanyOverview/components/shareholder/ActualControlCard.tsx new file mode 100644 index 00000000..d29d6c6a --- /dev/null +++ b/src/views/Company/components/CompanyOverview/components/shareholder/ActualControlCard.tsx @@ -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 = ({ actualControl = [] }) => { + if (!actualControl.length) return null; + + const data = actualControl[0]; + + return ( + + + + 实际控制人 + + + + + + + {data.actual_controller_name} + + + {data.control_type} + + 截至 {formatDate(data.end_date)} + + + + + 控制比例 + + {formatPercentage(data.holding_ratio)} + + {formatShares(data.holding_shares)} + + + + + + ); +}; + +export default ActualControlCard; diff --git a/src/views/Company/components/CompanyOverview/components/shareholder/ConcentrationCard.tsx b/src/views/Company/components/CompanyOverview/components/shareholder/ConcentrationCard.tsx new file mode 100644 index 00000000..dc82c45c --- /dev/null +++ b/src/views/Company/components/CompanyOverview/components/shareholder/ConcentrationCard.tsx @@ -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 = ({ concentration = [] }) => { + const chartRef = useRef(null); + const chartInstance = useRef(null); + + // 按日期分组 + const groupedData = useMemo(() => { + const grouped: Record> = {}; + 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 ( + + + + 股权集中度 + + + {/* 数据卡片 */} + {groupedData.map(([date, items]) => ( + + + + {formatDate(date)} + + + + + {Object.entries(items).map(([key, item]) => ( + + {item.stat_item} + + + {formatPercentage(item.holding_ratio)} + + {item.ratio_change && ( + 0 ? "red" : "green"} + > + 0 ? FaArrowUp : FaArrowDown} + mr={1} + boxSize={3} + /> + {Math.abs(item.ratio_change).toFixed(2)}% + + )} + + + ))} + + + + ))} + {/* 饼图 */} + + + + + + + + ); +}; + +export default ConcentrationCard; diff --git a/src/views/Company/components/CompanyOverview/components/shareholder/ShareholdersTable.tsx b/src/views/Company/components/CompanyOverview/components/shareholder/ShareholdersTable.tsx new file mode 100644 index 00000000..4b908197 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/components/shareholder/ShareholdersTable.tsx @@ -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 = { + 基金: "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 = ({ + 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 = useMemo(() => { + const baseColumns: ColumnsType = [ + { + title: "排名", + dataIndex: "shareholder_rank", + key: "rank", + width: 45, + render: (rank: number, _: Shareholder, index: number) => ( + + {rank || index + 1} + + ), + }, + { + title: "股东名称", + dataIndex: "shareholder_name", + key: "name", + ellipsis: true, + render: (name: string) => ( + + {name} + + ), + }, + { + title: "股东类型", + dataIndex: "shareholder_type", + key: "type", + width: 90, + responsive: ["md"], + render: (shareholderType: string) => ( + {shareholderType || "-"} + ), + }, + { + 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) => ( + {formatShares(shares)} + ), + }, + { + title: {config.ratioLabel}, + 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) => ( + + {formatPercentage(ratio)} + + ), + }, + ]; + + // 十大股东显示股份性质 + if (config.showNature) { + baseColumns.push({ + title: "股份性质", + dataIndex: "share_nature", + key: "nature", + width: 80, + responsive: ["lg"], + render: (nature: string) => ( + {nature || "流通股"} + ), + }); + } + + return baseColumns; + }, [config, type]); + + if (!shareholders.length) return null; + + // 获取数据日期 + const reportDate = shareholders[0]?.end_date; + + return ( + + + + {config.title} + {reportDate && {formatDate(reportDate)}} + + + `${record.shareholder_name}-${index}`} + pagination={false} + size={isMobile ? "small" : "middle"} + scroll={{ x: isMobile ? 400 : undefined }} + /> + + + ); +}; + +export default ShareholdersTable; diff --git a/src/views/Company/components/CompanyOverview/components/shareholder/index.ts b/src/views/Company/components/CompanyOverview/components/shareholder/index.ts new file mode 100644 index 00000000..13192679 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/components/shareholder/index.ts @@ -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"; diff --git a/src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts b/src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts new file mode 100644 index 00000000..ca26c6a0 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts @@ -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 { + 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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!stockCode) return; + + const controller = new AbortController(); + + const loadData = async () => { + setLoading(true); + setError(null); + + try { + const { data: result } = await axios.get>( + `/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 }; +}; diff --git a/src/views/Company/components/CompanyOverview/hooks/useBasicInfo.ts b/src/views/Company/components/CompanyOverview/hooks/useBasicInfo.ts new file mode 100644 index 00000000..476b52d6 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/hooks/useBasicInfo.ts @@ -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 { + 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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!stockCode) return; + + const controller = new AbortController(); + + const loadData = async () => { + setLoading(true); + setError(null); + + try { + const { data: result } = await axios.get>( + `/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 }; +}; diff --git a/src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts b/src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts new file mode 100644 index 00000000..f50b72fb --- /dev/null +++ b/src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts @@ -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 { + 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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!stockCode) return; + + const controller = new AbortController(); + + const loadData = async () => { + setLoading(true); + setError(null); + + try { + const { data: result } = await axios.get>( + `/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 }; +}; diff --git a/src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts b/src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts new file mode 100644 index 00000000..31a9e185 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts @@ -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 { + 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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!stockCode) return; + + const controller = new AbortController(); + + const loadData = async () => { + setLoading(true); + setError(null); + + try { + const { data: result } = await axios.get>( + `/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 }; +}; diff --git a/src/views/Company/components/CompanyOverview/hooks/useManagementData.ts b/src/views/Company/components/CompanyOverview/hooks/useManagementData.ts new file mode 100644 index 00000000..8dca8d70 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/hooks/useManagementData.ts @@ -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 { + 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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!stockCode) return; + + const controller = new AbortController(); + + const loadData = async () => { + setLoading(true); + setError(null); + + try { + const { data: result } = await axios.get>( + `/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 }; +}; diff --git a/src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts b/src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts new file mode 100644 index 00000000..17b65f1e --- /dev/null +++ b/src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts @@ -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 { + 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([]); + const [concentration, setConcentration] = useState([]); + const [topShareholders, setTopShareholders] = useState([]); + const [topCirculationShareholders, setTopCirculationShareholders] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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>(`/api/stock/${stockCode}/actual-control`, { signal: controller.signal }), + axios.get>(`/api/stock/${stockCode}/concentration`, { signal: controller.signal }), + axios.get>(`/api/stock/${stockCode}/top-shareholders?limit=10`, { signal: controller.signal }), + axios.get>(`/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, + }; +}; diff --git a/src/views/Company/components/CompanyOverview/index.tsx b/src/views/Company/components/CompanyOverview/index.tsx new file mode 100644 index 00000000..b027524d --- /dev/null +++ b/src/views/Company/components/CompanyOverview/index.tsx @@ -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 = ({ stockCode }) => { + return ( + + {/* 基本信息内容 - 传入 stockCode,内部懒加载各 Tab 数据 */} + + + ); +}; + +export default CompanyOverview; diff --git a/src/views/Company/components/CompanyOverview/types.ts b/src/views/Company/components/CompanyOverview/types.ts new file mode 100644 index 00000000..e184e7c3 --- /dev/null +++ b/src/views/Company/components/CompanyOverview/types.ts @@ -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; +} + diff --git a/src/views/Company/components/CompanyOverview/utils.ts b/src/views/Company/components/CompanyOverview/utils.ts new file mode 100644 index 00000000..6b72259d --- /dev/null +++ b/src/views/Company/components/CompanyOverview/utils.ts @@ -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"); +}; diff --git a/src/views/Company/components/CompanyTabs/index.js b/src/views/Company/components/CompanyTabs/index.js new file mode 100644 index 00000000..41e4b6f2 --- /dev/null +++ b/src/views/Company/components/CompanyTabs/index.js @@ -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 ( + + ); +}; + +export default CompanyTabs; diff --git a/src/views/Company/components/DeepAnalysis/index.js b/src/views/Company/components/DeepAnalysis/index.js new file mode 100644 index 00000000..dbad8316 --- /dev/null +++ b/src/views/Company/components/DeepAnalysis/index.js @@ -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 ( + + ); +}; + +export default DeepAnalysis; diff --git a/src/views/Company/components/DynamicTracking/components/ForecastPanel.js b/src/views/Company/components/DynamicTracking/components/ForecastPanel.js new file mode 100644 index 00000000..efabc111 --- /dev/null +++ b/src/views/Company/components/DynamicTracking/components/ForecastPanel.js @@ -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 ( +
+ +
+ ); + } + + if (!forecast?.forecasts?.length) { + return ( +
+ 暂无业绩预告数据 +
+ ); + } + + return ( + + {forecast.forecasts.map((item, idx) => { + const typeStyle = getForecastTypeStyle(item.forecast_type); + + return ( + + {/* 头部:类型标签 + 报告期 */} + + + {item.forecast_type} + + + 报告期: {item.report_date} + + + + {/* 内容 */} + + {item.content} + + + {/* 原因(如有) */} + {item.reason && ( + + {item.reason} + + )} + + {/* 变动范围 */} + {item.change_range?.lower !== undefined && ( + + + 预计变动范围: + + + {item.change_range.lower}% ~ {item.change_range.upper}% + + + )} + + ); + })} + + ); +}; + +export default ForecastPanel; diff --git a/src/views/Company/components/DynamicTracking/components/NewsPanel.js b/src/views/Company/components/DynamicTracking/components/NewsPanel.js new file mode 100644 index 00000000..0c00bd4c --- /dev/null +++ b/src/views/Company/components/DynamicTracking/components/NewsPanel.js @@ -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 ( + + ); +}; + +export default NewsPanel; diff --git a/src/views/Company/components/DynamicTracking/components/index.js b/src/views/Company/components/DynamicTracking/components/index.js new file mode 100644 index 00000000..44bc24a1 --- /dev/null +++ b/src/views/Company/components/DynamicTracking/components/index.js @@ -0,0 +1,4 @@ +// src/views/Company/components/DynamicTracking/components/index.js + +export { default as NewsPanel } from './NewsPanel'; +export { default as ForecastPanel } from './ForecastPanel'; diff --git a/src/views/Company/components/DynamicTracking/index.js b/src/views/Company/components/DynamicTracking/index.js new file mode 100644 index 00000000..fa9f1a9d --- /dev/null +++ b/src/views/Company/components/DynamicTracking/index.js @@ -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 ( + + setActiveTab(index)} + isLazy + /> + + ); +}; + +export default DynamicTracking; diff --git a/src/views/Company/components/FinancialPanorama/components/BalanceSheetTable.tsx b/src/views/Company/components/FinancialPanorama/components/BalanceSheetTable.tsx new file mode 100644 index 00000000..ab76d635 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/BalanceSheetTable.tsx @@ -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 = ({ + data, + showMetricChart, + calculateYoYChange, + positiveColor = 'red.500', + negativeColor = 'green.500', +}) => { + // 数组安全检查 + if (!Array.isArray(data) || data.length === 0) { + return ( + + 暂无资产负债表数据 + + ); + } + + 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(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(lastYearPeriod, path); + if (lastYearValue === undefined || lastYearValue === 0) return null; + + return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100; + }; + + // 构建列定义 + const columns: ColumnsType = useMemo(() => { + const cols: ColumnsType = [ + { + title: '项目', + dataIndex: 'name', + key: 'name', + fixed: 'left', + width: 200, + render: (name: string, record: TableRowData) => { + if (record.isSection) { + return {name}; + } + return ( + + {name} + {record.isCore && ( + + 核心 + + )} + + ); + }, + }, + ...displayData.map((item) => ({ + title: ( + + {formatUtils.getReportType(item.period)} + {item.period.substring(0, 10)} + + ), + 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 ( + + 数值: {formatUtils.formatLargeNumber(value)} + {yoy !== null && 同比: {yoy.toFixed(2)}%} + + } + > + + + {formattedValue} + + {yoy !== null && Math.abs(yoy) > 30 && !record.isTotal && ( + 0 ? 'positive-change' : 'negative-change'} + > + {yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}% + + )} + + + ); + }, + })), + { + title: '', + key: 'action', + width: 40, + fixed: 'right', + render: (_: unknown, record: TableRowData) => { + if (record.isSection) return null; + return ( + { + e.stopPropagation(); + showMetricChart(record.name, record.key, data, record.path); + }} + /> + ); + }, + }, + ]; + + return cols; + }, [displayData, data, showMetricChart]); + + return ( + + + +
{ + 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: '暂无数据' }} + /> + + + ); +}; + +export default BalanceSheetTable; diff --git a/src/views/Company/components/FinancialPanorama/components/CashflowTable.tsx b/src/views/Company/components/FinancialPanorama/components/CashflowTable.tsx new file mode 100644 index 00000000..a30e7199 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/CashflowTable.tsx @@ -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 = ({ + data, + showMetricChart, + calculateYoYChange, + positiveColor = 'red.500', + negativeColor = 'green.500', +}) => { + // 数组安全检查 + if (!Array.isArray(data) || data.length === 0) { + return ( + + 暂无现金流量表数据 + + ); + } + + 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(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(lastYearPeriod, path); + if (lastYearValue === undefined || lastYearValue === 0) return null; + + return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100; + }; + + // 构建列定义 + const columns: ColumnsType = useMemo(() => { + const cols: ColumnsType = [ + { + title: '项目', + dataIndex: 'name', + key: 'name', + fixed: 'left', + width: 180, + render: (name: string, record: TableRowData) => ( + + {name} + {record.isCore && ( + + 核心 + + )} + + ), + }, + ...displayData.map((item) => ({ + title: ( + + {formatUtils.getReportType(item.period)} + {item.period.substring(0, 10)} + + ), + 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 ( + + 数值: {formatUtils.formatLargeNumber(value)} + {yoy !== null && 同比: {yoy.toFixed(2)}%} + + } + > + + + {formattedValue} + + {yoy !== null && Math.abs(yoy) > 50 && ( + 0 ? 'positive-change' : 'negative-change'} + > + {yoy > 0 ? '↑' : '↓'} + + )} + + + ); + }, + })), + { + title: '', + key: 'action', + width: 40, + fixed: 'right', + render: (_: unknown, record: TableRowData) => ( + { + e.stopPropagation(); + showMetricChart(record.name, record.key, data, record.path); + }} + /> + ), + }, + ]; + + return cols; + }, [displayData, data, showMetricChart]); + + return ( + + + +
({ + onClick: () => { + showMetricChart(record.name, record.key, data, record.path); + }, + style: { cursor: 'pointer' }, + })} + locale={{ emptyText: '暂无数据' }} + /> + + + ); +}; + +export default CashflowTable; diff --git a/src/views/Company/components/FinancialPanorama/components/ComparisonAnalysis.tsx b/src/views/Company/components/FinancialPanorama/components/ComparisonAnalysis.tsx new file mode 100644 index 00000000..9cec1381 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/ComparisonAnalysis.tsx @@ -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 = ({ comparison }) => { + if (!Array.isArray(comparison) || comparison.length === 0) return null; + + const revenueData = comparison + .map((item) => ({ + period: formatUtils.getReportType(item.period), + value: item.performance.revenue / 100000000, // 转换为亿 + })) + .reverse(); + + const profitData = comparison + .map((item) => ({ + period: formatUtils.getReportType(item.period), + value: item.performance.net_profit / 100000000, // 转换为亿 + })) + .reverse(); + + const chartOption = getComparisonChartOption(revenueData, profitData); + + return ( + + + + ); +}; + +export default ComparisonAnalysis; diff --git a/src/views/Company/components/FinancialPanorama/components/FinancialMetricsTable.tsx b/src/views/Company/components/FinancialPanorama/components/FinancialMetricsTable.tsx new file mode 100644 index 00000000..ef90a856 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/FinancialMetricsTable.tsx @@ -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 = ({ + data, + showMetricChart, + calculateYoYChange, +}) => { + const [selectedCategory, setSelectedCategory] = useState('profitability'); + + // 数组安全检查 + if (!Array.isArray(data) || data.length === 0) { + return ( + + 暂无财务指标数据 + + ); + } + + const maxColumns = Math.min(data.length, 6); + const displayData = data.slice(0, maxColumns); + const currentCategory = FINANCIAL_METRICS_CATEGORIES[selectedCategory]; + + // 构建表格数据 + 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(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(lastYearPeriod, path); + if (lastYearValue === undefined || lastYearValue === 0) return null; + + return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100; + }; + + // 构建列定义 + const columns: ColumnsType = useMemo(() => { + const cols: ColumnsType = [ + { + title: currentCategory.title, + dataIndex: 'name', + key: 'name', + fixed: 'left', + width: 200, + render: (name: string, record: TableRowData) => ( + + {name} + {record.isCore && ( + + 核心 + + )} + + ), + }, + ...displayData.map((item) => ({ + title: ( + + {formatUtils.getReportType(item.period)} + {item.period.substring(0, 10)} + + ), + 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 ( + + {record.name}: {value?.toFixed(2) || '-'} + {yoy !== null && 同比: {yoy.toFixed(2)}%} + + } + > + + + {value?.toFixed(2) || '-'} + + {yoy !== null && Math.abs(yoy) > 20 && value !== undefined && Math.abs(value) > 0.01 && ( + + {yoy > 0 ? '↑' : '↓'} + + )} + + + ); + }, + })), + { + title: '', + key: 'action', + width: 40, + fixed: 'right', + render: (_: unknown, record: TableRowData) => ( + { + e.stopPropagation(); + showMetricChart(record.name, record.key, data, record.path); + }} + /> + ), + }, + ]; + + return cols; + }, [displayData, data, showMetricChart, currentCategory, selectedCategory]); + + return ( + + {/* 分类选择器 */} + + {(Object.entries(FINANCIAL_METRICS_CATEGORIES) as [CategoryKey, typeof currentCategory][]).map( + ([key, category]) => ( + + ) + )} + + + {/* 指标表格 */} + + + +
({ + onClick: () => { + showMetricChart(record.name, record.key, data, record.path); + }, + style: { cursor: 'pointer' }, + })} + locale={{ emptyText: '暂无数据' }} + /> + + + + {/* 关键指标快速对比 */} + {data[0] && ( + + + 关键指标速览 + + + + {[ + { + label: 'ROE', + value: getValueByPath(data[0], 'profitability.roe'), + format: 'percent', + }, + { + label: '毛利率', + value: getValueByPath(data[0], 'profitability.gross_margin'), + format: 'percent', + }, + { + label: '净利率', + value: getValueByPath(data[0], 'profitability.net_profit_margin'), + format: 'percent', + }, + { + label: '流动比率', + value: getValueByPath(data[0], 'solvency.current_ratio'), + format: 'decimal', + }, + { + label: '资产负债率', + value: getValueByPath(data[0], 'solvency.asset_liability_ratio'), + format: 'percent', + }, + { + label: '研发费用率', + value: getValueByPath(data[0], 'expense_ratios.rd_expense_ratio'), + format: 'percent', + }, + ].map((item, idx) => ( + + + {item.label} + + + {item.format === 'percent' + ? formatUtils.formatPercent(item.value) + : item.value?.toFixed(2) || '-'} + + + ))} + + + + )} + + ); +}; + +export default FinancialMetricsTable; diff --git a/src/views/Company/components/FinancialPanorama/components/FinancialOverviewPanel.tsx b/src/views/Company/components/FinancialPanorama/components/FinancialOverviewPanel.tsx new file mode 100644 index 00000000..27ba24da --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/FinancialOverviewPanel.tsx @@ -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 = 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 ( + + {/* 卡片1: 成长能力 */} + } + rightIcon={} + mainLabel="利润增长" + mainValue={formatGrowth(profitGrowth)} + mainColor={profitGrowth !== undefined && profitGrowth >= 0 ? darkGoldTheme.green : darkGoldTheme.red} + subText={ + + + 营收增长 + = 0 ? darkGoldTheme.green : darkGoldTheme.red} + > + {formatGrowth(revenueGrowth)} + + + + {forecast && ( + + {forecast.forecast_type} {forecast.content} + + )} + + } + /> + + {/* 卡片2: 盈利与回报 */} + } + rightIcon={} + mainLabel="ROE" + mainValue={formatUtils.formatPercent(roe)} + mainColor={darkGoldTheme.orange} + subText={ + + + {roeStatus.text} + + + 净利率 {formatUtils.formatPercent(netProfitMargin)} + | + 毛利率 {formatUtils.formatPercent(grossMargin)} + + + } + /> + + {/* 卡片3: 风险与运营 */} + } + rightIcon={} + mainLabel="资产负债率" + mainValue={formatUtils.formatPercent(assetLiabilityRatio)} + mainColor={debtStatus.color} + subText={ + + + {debtStatus.text} + + + 流动比率 {currentRatio?.toFixed(2) ?? '-'} + | + 研发费用率 {formatUtils.formatPercent(rdExpenseRatio)} + + + } + /> + + ); +}); + +FinancialOverviewPanel.displayName = 'FinancialOverviewPanel'; + +export default FinancialOverviewPanel; diff --git a/src/views/Company/components/FinancialPanorama/components/FinancialTable.tsx b/src/views/Company/components/FinancialPanorama/components/FinancialTable.tsx new file mode 100644 index 00000000..bfc1a937 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/FinancialTable.tsx @@ -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, 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)[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, path); + if (lastYearValue === undefined || lastYearValue === 0) return null; + + return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100; +}; + +const FinancialTable: React.FC = ({ + 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, metric.path); + row[item.period] = value; + }); + + tableData.push(row); + }); + }); + + // 构建列定义 + const columns: ColumnsType = [ + { + title: '项目', + dataIndex: 'name', + key: 'name', + fixed: 'left', + width: 180, + render: (name: string, record: FinancialTableRow) => { + if (record.isSection) { + return {name}; + } + return ( + + {name} + {record.isCore && ( + + 核心 + + )} + + ); + }, + }, + ...displayData.map((item) => ({ + title: ( + + {formatUtils.getReportType(item.period)} + {item.period.substring(0, 10)} + + ), + 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 ( + + 数值: {formatUtils.formatLargeNumber(value)} + {yoy !== null && 同比: {yoy.toFixed(2)}%} + + } + > + + + {formattedValue} + + {yoy !== null && Math.abs(yoy) > 30 && !record.isTotal && ( + 0 ? 'positive-change' : 'negative-change'} + > + {yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}% + + )} + + + ); + }, + })), + { + title: '', + key: 'action', + width: 40, + fixed: 'right', + render: (_: unknown, record: FinancialTableRow) => { + if (record.isSection) return null; + return ( + { + e.stopPropagation(); + onRowClick?.(record.name, record.key, record.path); + }} + /> + ); + }, + }, + ]; + + return ( + + + +
{ + 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: '暂无数据' }} + /> + + + ); +}; + +export default FinancialTable; diff --git a/src/views/Company/components/FinancialPanorama/components/IncomeStatementTable.tsx b/src/views/Company/components/FinancialPanorama/components/IncomeStatementTable.tsx new file mode 100644 index 00000000..5f593658 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/IncomeStatementTable.tsx @@ -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 = ({ + data, + showMetricChart, + calculateYoYChange, + positiveColor = 'red.500', + negativeColor = 'green.500', +}) => { + // 数组安全检查 + if (!Array.isArray(data) || data.length === 0) { + return ( + + 暂无利润表数据 + + ); + } + + 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(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(lastYearPeriod, path); + if (lastYearValue === undefined || lastYearValue === 0) return null; + + return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100; + }; + + // 构建列定义 + const columns: ColumnsType = useMemo(() => { + const cols: ColumnsType = [ + { + title: '项目', + dataIndex: 'name', + key: 'name', + fixed: 'left', + width: 250, + render: (name: string, record: TableRowData) => { + if (record.isSection) { + return {name}; + } + return ( + + {name} + {record.isCore && ( + + 核心 + + )} + + ); + }, + }, + ...displayData.map((item) => ({ + title: ( + + {formatUtils.getReportType(item.period)} + {item.period.substring(0, 10)} + + ), + 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 ( + + 数值: {isEPS ? value?.toFixed(3) : formatUtils.formatLargeNumber(value)} + {yoy !== null && 同比: {yoy.toFixed(2)}%} + + } + > + + + {formattedValue} + + {yoy !== null && Math.abs(yoy) > 30 && !record.isTotal && ( + + {yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}% + + )} + + + ); + }, + })), + { + title: '', + key: 'action', + width: 40, + fixed: 'right', + render: (_: unknown, record: TableRowData) => { + if (record.isSection) return null; + return ( + { + e.stopPropagation(); + showMetricChart(record.name, record.key, data, record.path); + }} + /> + ); + }, + }, + ]; + + return cols; + }, [displayData, data, showMetricChart]); + + return ( + + + +
{ + 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: '暂无数据' }} + /> + + + ); +}; + +export default IncomeStatementTable; diff --git a/src/views/Company/components/FinancialPanorama/components/IndustryRankingView.tsx b/src/views/Company/components/FinancialPanorama/components/IndustryRankingView.tsx new file mode 100644 index 00000000..4f3c3e55 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/IndustryRankingView.tsx @@ -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 = ({ + 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 ( + + + + 暂无行业排名数据 + + + + ); + } + + return ( + + {industryRank.map((periodData, periodIdx) => ( + + + + + {periodData.report_type} 行业排名 + + + {periodData.period} + + + + + {periodData.rankings?.map((ranking, idx) => ( + + + {ranking.industry_name} ({ranking.level_description}) + + + {RANKING_METRICS.map((metric) => { + const metricData = ranking.metrics?.[metric.key as keyof typeof ranking.metrics]; + if (!metricData) return null; + + const isGood = metricData.rank && metricData.rank <= 10; + const isBad = metricData.rank && metricData.rank > 30; + + const isPercentMetric = + metric.key.includes('growth') || + metric.key.includes('margin') || + metric.key === 'roe'; + + // 格式化数值 + 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 ( + + + {metric.name} + + + + {formattedValue} + + {metricData.rank && ( + + #{metricData.rank} + + )} + + + 行业均值: {formattedAvg} + + + ); + })} + + + ))} + + + ))} + + ); +}; + +export default IndustryRankingView; diff --git a/src/views/Company/components/FinancialPanorama/components/KeyMetricsOverview.tsx b/src/views/Company/components/FinancialPanorama/components/KeyMetricsOverview.tsx new file mode 100644 index 00000000..cff4e47c --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/KeyMetricsOverview.tsx @@ -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 = (obj: FinancialMetricsData, path: string): T | undefined => { + return path.split('.').reduce((acc: unknown, key: string) => { + if (acc && typeof acc === 'object') { + return (acc as Record)[key]; + } + return undefined; + }, obj as unknown) as T | undefined; +}; + +export interface KeyMetricsOverviewProps { + financialMetrics: FinancialMetricsData[]; +} + +export const KeyMetricsOverview: React.FC = memo(({ + financialMetrics, +}) => { + if (!financialMetrics || financialMetrics.length === 0) { + return null; + } + + const currentPeriod = financialMetrics[0]; + const previousPeriod = financialMetrics[1]; + + return ( + + + + 关键指标速览 + + + + + {KEY_METRICS.map((metric, idx) => { + const currentValue = getValueByPath(currentPeriod, metric.path); + const previousValue = previousPeriod + ? getValueByPath(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 ( + + + {metric.label} + + + + {metric.format === 'percent' + ? formatUtils.formatPercent(currentValue) + : currentValue?.toFixed(2) ?? '-'} + + {trend !== 'flat' && ( + + )} + {trend === 'flat' && ( + + )} + + + ); + })} + + + + ); +}); + +KeyMetricsOverview.displayName = 'KeyMetricsOverview'; + +export default KeyMetricsOverview; diff --git a/src/views/Company/components/FinancialPanorama/components/MainBusinessAnalysis.tsx b/src/views/Company/components/FinancialPanorama/components/MainBusinessAnalysis.tsx new file mode 100644 index 00000000..efd90031 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/MainBusinessAnalysis.tsx @@ -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 = ({ + historicalData, + businessItems, + hasProductData, + latestReportType, +}) => { + // 动态生成列配置 + const columns: ColumnsType = useMemo(() => { + const cols: ColumnsType = [ + { + 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 ( + + + + + 主营业务明细与历史对比 + + + + + + columns={columns} + dataSource={dataSource} + pagination={false} + size="small" + scroll={{ x: 'max-content' }} + bordered + /> + + + + ); +}; + +export const MainBusinessAnalysis: React.FC = ({ + mainBusiness, +}) => { + // 优先使用product_classification,如果为空则使用industry_classification + const hasProductData = + mainBusiness?.product_classification && mainBusiness.product_classification.length > 0; + const hasIndustryData = + mainBusiness?.industry_classification && mainBusiness.industry_classification.length > 0; + + if (!hasProductData && !hasIndustryData) { + return ( + + + 暂无主营业务数据 + + ); + } + + // 选择数据源 + const dataSource = hasProductData ? 'product' : 'industry'; + + // 获取最新期间数据 + const latestPeriod = hasProductData + ? (mainBusiness!.product_classification![0] as ProductClassification) + : (mainBusiness!.industry_classification![0] as IndustryClassification); + + // 获取业务项目 + const businessItems: BusinessItem[] = hasProductData + ? (latestPeriod as ProductClassification).products + : (latestPeriod as IndustryClassification).industries; + + // 过滤掉"合计"项,准备饼图数据 + const pieData = businessItems + .filter((item: BusinessItem) => item.content !== '合计') + .map((item: BusinessItem) => ({ + name: item.content, + value: item.revenue || 0, + })); + + const pieOption = getMainBusinessPieOption( + `主营业务构成 - ${latestPeriod.report_type}`, + dataSource === 'industry' ? '按行业分类' : '按产品分类', + pieData + ); + + // 历史对比数据 + const historicalData = hasProductData + ? (mainBusiness!.product_classification! as ProductClassification[]) + : (mainBusiness!.industry_classification! as IndustryClassification[]); + + return ( + + {/* 左侧:饼图 */} + + + + + {/* 右侧:业务明细与历史对比表格 */} + + {historicalData.length > 0 && ( + + )} + + + ); +}; + +export default MainBusinessAnalysis; diff --git a/src/views/Company/components/FinancialPanorama/components/PeriodSelector.tsx b/src/views/Company/components/FinancialPanorama/components/PeriodSelector.tsx new file mode 100644 index 00000000..e223c559 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/PeriodSelector.tsx @@ -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 = memo(({ + selectedPeriods, + onPeriodsChange, + onRefresh, + isLoading = false, + periodOptions = [4, 8, 12, 16], + label = '显示期数:', +}) => { + return ( + + + {label} + + setCompareStock(e.target.value)} + maxLength={6} + /> + + + + + + {compareData && ( + + + + {stockInfo?.stock_name} ({currentStock}) VS{' '} + {compareData.stockInfo?.stock_name} ({compareStock}) + + + + +
+ + + + + + + + + + {COMPARE_METRICS.map((metric) => { + const value1 = getValueByPath(stockInfo, metric.path); + const value2 = getValueByPath( + compareData.stockInfo, + metric.path + ); + + let diff: number | null = null; + let diffColor = 'gray.500'; + + if (value1 !== undefined && value2 !== undefined) { + if (metric.format === 'percent') { + diff = value1 - value2; + diffColor = diff > 0 ? positiveColor : negativeColor; + } else { + diff = ((value1 - value2) / value2) * 100; + diffColor = diff > 0 ? positiveColor : negativeColor; + } + } + + return ( + + + + + + + ); + })} + +
指标{stockInfo?.stock_name}{compareData.stockInfo?.stock_name}差异
{metric.label} + {metric.format === 'percent' + ? formatUtils.formatPercent(value1) + : formatUtils.formatLargeNumber(value1)} + + {metric.format === 'percent' + ? formatUtils.formatPercent(value2) + : formatUtils.formatLargeNumber(value2)} + + {diff !== null ? ( + + {diff > 0 && } + {diff < 0 && } + + {metric.format === 'percent' + ? `${Math.abs(diff).toFixed(2)}pp` + : `${Math.abs(diff).toFixed(2)}%`} + + + ) : ( + '-' + )} +
+ + + {/* 对比图表 */} + + + + + 盈利能力对比 + + + + + + + + + + + 成长能力对比 + + + + + + + + + + )} + + ); +}; + +export default StockComparison; diff --git a/src/views/Company/components/FinancialPanorama/components/StockInfoHeader.tsx b/src/views/Company/components/FinancialPanorama/components/StockInfoHeader.tsx new file mode 100644 index 00000000..1837190d --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/StockInfoHeader.tsx @@ -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 = ({ + stockInfo, +}) => { + if (!stockInfo) return null; + + return ( + + + + + + 股票名称 + + + + {stockInfo.stock_name} + + + {stockInfo.stock_code} + + + + + + + + ROE + + + {formatUtils.formatPercent(stockInfo.key_metrics?.roe)} + + + + + + + 营收增长 + + 0 + ? darkGoldTheme.red + : darkGoldTheme.green + : darkGoldTheme.textMuted + } + > + {formatUtils.formatPercent(stockInfo.growth_rates?.revenue_growth)} + + + + + + + 利润增长 + + 0 + ? darkGoldTheme.red + : darkGoldTheme.green + : darkGoldTheme.textMuted + } + > + {formatUtils.formatPercent(stockInfo.growth_rates?.profit_growth)} + + + + + {stockInfo.latest_forecast && ( + + + + + {stockInfo.latest_forecast.forecast_type} + + + {stockInfo.latest_forecast.content} + + + + )} + + ); +}; + +export default StockInfoHeader; diff --git a/src/views/Company/components/FinancialPanorama/components/index.ts b/src/views/Company/components/FinancialPanorama/components/index.ts new file mode 100644 index 00000000..6bfaf227 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/index.ts @@ -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'; diff --git a/src/views/Company/components/FinancialPanorama/constants.ts b/src/views/Company/components/FinancialPanorama/constants.ts new file mode 100644 index 00000000..8ae0e15a --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/constants.ts @@ -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' }, +]; diff --git a/src/views/Company/components/FinancialPanorama/hooks/index.ts b/src/views/Company/components/FinancialPanorama/hooks/index.ts new file mode 100644 index 00000000..cb296cfa --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/hooks/index.ts @@ -0,0 +1,7 @@ +/** + * Hooks 统一导出 + */ + +export { useFinancialData } from './useFinancialData'; +export type { DataTypeKey } from './useFinancialData'; +export type { default as UseFinancialDataReturn } from './useFinancialData'; diff --git a/src/views/Company/components/FinancialPanorama/hooks/useFinancialData.ts b/src/views/Company/components/FinancialPanorama/hooks/useFinancialData.ts new file mode 100644 index 00000000..35bb70d0 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/hooks/useFinancialData.ts @@ -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; + refetchByTab: (tabKey: DataTypeKey) => Promise; + 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('profitability'); + + // 加载状态 + const [loading, setLoading] = useState(false); + const [loadingTab, setLoadingTab] = useState(null); + const [error, setError] = useState(null); + + // 财务数据状态 + const [stockInfo, setStockInfo] = useState(null); + const [balanceSheet, setBalanceSheet] = useState([]); + const [incomeStatement, setIncomeStatement] = useState([]); + const [cashflow, setCashflow] = useState([]); + const [financialMetrics, setFinancialMetrics] = useState([]); + const [mainBusiness, setMainBusiness] = useState(null); + const [forecast, setForecast] = useState(null); + const [industryRank, setIndustryRank] = useState([]); + const [comparison, setComparison] = useState([]); + + 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; diff --git a/src/views/Company/components/FinancialPanorama/index.tsx b/src/views/Company/components/FinancialPanorama/index.tsx new file mode 100644 index 00000000..9ea925c0 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/index.tsx @@ -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 = ({ 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(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)[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( + + + + + + + + + + + + + + + {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 ( + + + + + + + ); + })} + +
报告期数值同比环比
{item.period}{formatUtils.formatLargeNumber(item.value)} 0 + ? positiveColor + : yoy !== null && yoy < 0 + ? negativeColor + : 'gray.500' + } + > + {yoy !== null ? `${yoy.toFixed(2)}%` : '-'} + 0 + ? positiveColor + : qoq !== null && qoq < 0 + ? negativeColor + : 'gray.500' + } + > + {qoq !== null ? `${qoq.toFixed(2)}%` : '-'} +
+
+
+ ); + 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 ( + + + {/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */} + {loading ? ( + + ) : ( + + )} + + {/* 营收与利润趋势 */} + {!loading && comparison && comparison.length > 0 && ( + + )} + + {/* 主营业务 */} + {!loading && stockInfo && ( + + + 主营业务 + + + + )} + + {/* 三大财务报表 - 使用 SubTabContainer 二级导航 */} + {!loading && stockInfo && ( + + + + } + /> + + + )} + + {/* 错误提示 */} + {error && ( + + + {error} + + )} + + {/* 弹出模态框 */} + + + + 指标详情 + + {modalContent} + + + + + ); +}; + +export default FinancialPanorama; diff --git a/src/views/Company/components/FinancialPanorama/tabs/BalanceSheetTab.tsx b/src/views/Company/components/FinancialPanorama/tabs/BalanceSheetTab.tsx new file mode 100644 index 00000000..9257f1a9 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/tabs/BalanceSheetTab.tsx @@ -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 = ({ + balanceSheet, + showMetricChart, + calculateYoYChange, + getCellBackground, + positiveColor, + negativeColor, + bgColor, + hoverBg, +}) => { + const tableProps = { + showMetricChart, + calculateYoYChange, + getCellBackground, + positiveColor, + negativeColor, + bgColor, + hoverBg, + }; + + return ( + + + + 资产负债表 + + + 显示最近{Math.min(balanceSheet.length, 8)}期 + + + 红涨绿跌 | 同比变化 + + + + + 提示:表格可横向滚动查看更多数据,点击行查看历史趋势 + + + + + ); +}; + +export default BalanceSheetTab; diff --git a/src/views/Company/components/FinancialPanorama/tabs/CashflowTab.tsx b/src/views/Company/components/FinancialPanorama/tabs/CashflowTab.tsx new file mode 100644 index 00000000..b03d7c9e --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/tabs/CashflowTab.tsx @@ -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 = ({ + cashflow, + showMetricChart, + calculateYoYChange, + getCellBackground, + positiveColor, + negativeColor, + bgColor, + hoverBg, +}) => { + const tableProps = { + showMetricChart, + calculateYoYChange, + getCellBackground, + positiveColor, + negativeColor, + bgColor, + hoverBg, + }; + + return ( + + + + 现金流量表 + + + 显示最近{Math.min(cashflow.length, 8)}期 + + + 红涨绿跌 | 同比变化 + + + + + 提示:现金流数据为累计值,正值红色表示现金流入,负值绿色表示现金流出 + + + + + ); +}; + +export default CashflowTab; diff --git a/src/views/Company/components/FinancialPanorama/tabs/FinancialMetricsTab.tsx b/src/views/Company/components/FinancialPanorama/tabs/FinancialMetricsTab.tsx new file mode 100644 index 00000000..c8f209e1 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/tabs/FinancialMetricsTab.tsx @@ -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 = ({ + financialMetrics, + showMetricChart, + calculateYoYChange, + getCellBackground, + positiveColor, + negativeColor, + bgColor, + hoverBg, +}) => { + const tableProps = { + showMetricChart, + calculateYoYChange, + getCellBackground, + positiveColor, + negativeColor, + bgColor, + hoverBg, + }; + + return ( + + ); +}; + +export default FinancialMetricsTab; diff --git a/src/views/Company/components/FinancialPanorama/tabs/IncomeStatementTab.tsx b/src/views/Company/components/FinancialPanorama/tabs/IncomeStatementTab.tsx new file mode 100644 index 00000000..52223625 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/tabs/IncomeStatementTab.tsx @@ -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 = ({ + incomeStatement, + showMetricChart, + calculateYoYChange, + getCellBackground, + positiveColor, + negativeColor, + bgColor, + hoverBg, +}) => { + const tableProps = { + showMetricChart, + calculateYoYChange, + getCellBackground, + positiveColor, + negativeColor, + bgColor, + hoverBg, + }; + + return ( + + + + 利润表 + + + 显示最近{Math.min(incomeStatement.length, 8)}期 + + + 红涨绿跌 | 同比变化 + + + + + 提示:Q1、中报、Q3、年报数据为累计值,同比显示与去年同期对比 + + + + + ); +}; + +export default IncomeStatementTab; diff --git a/src/views/Company/components/FinancialPanorama/tabs/MetricsCategoryTab.tsx b/src/views/Company/components/FinancialPanorama/tabs/MetricsCategoryTab.tsx new file mode 100644 index 00000000..b224e1d0 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/tabs/MetricsCategoryTab.tsx @@ -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 = ({ + categoryKey, + financialMetrics, + showMetricChart, + calculateYoYChange, +}) => { + // 数组安全检查 + if (!Array.isArray(financialMetrics) || financialMetrics.length === 0) { + return ( + + 暂无财务指标数据 + + ); + } + + const maxColumns = Math.min(financialMetrics.length, 6); + const displayData = financialMetrics.slice(0, maxColumns); + const category = FINANCIAL_METRICS_CATEGORIES[categoryKey]; + + if (!category) { + return ( + + 未找到指标分类配置 + + ); + } + + // 构建表格数据 + 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(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(lastYearPeriod, path); + if (lastYearValue === undefined || lastYearValue === 0) return null; + + return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100; + }; + + // 构建列定义 + const columns: ColumnsType = useMemo(() => { + const cols: ColumnsType = [ + { + title: category.title, + dataIndex: 'name', + key: 'name', + fixed: 'left', + width: 200, + render: (name: string, record: TableRowData) => ( + + {name} + {record.isCore && ( + + 核心 + + )} + + ), + }, + ...displayData.map((item) => ({ + title: ( + + {formatUtils.getReportType(item.period)} + {item.period.substring(0, 10)} + + ), + 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 ( + + {record.name}: {value?.toFixed(2) || '-'} + {yoy !== null && 同比: {yoy.toFixed(2)}%} +
+ } + > + + + {value?.toFixed(2) || '-'} + + {yoy !== null && Math.abs(yoy) > 20 && value !== undefined && Math.abs(value) > 0.01 && ( + + {yoy > 0 ? '↑' : '↓'} + + )} + + + ); + }, + })), + { + title: '', + key: 'action', + width: 40, + fixed: 'right', + render: (_: unknown, record: TableRowData) => ( + { + e.stopPropagation(); + showMetricChart(record.name, record.key, financialMetrics, record.path); + }} + /> + ), + }, + ]; + + return cols; + }, [displayData, financialMetrics, showMetricChart, category, categoryKey]); + + return ( + + + + + ({ + onClick: () => { + showMetricChart(record.name, record.key, financialMetrics, record.path); + }, + style: { cursor: 'pointer' }, + })} + locale={{ emptyText: '暂无数据' }} + /> + + + + + ); +}; + +// 为每个分类创建预配置的组件 +export const ProfitabilityTab: React.FC> = (props) => ( + +); + +export const PerShareTab: React.FC> = (props) => ( + +); + +export const GrowthTab: React.FC> = (props) => ( + +); + +export const OperationalTab: React.FC> = (props) => ( + +); + +export const SolvencyTab: React.FC> = (props) => ( + +); + +export const ExpenseTab: React.FC> = (props) => ( + +); + +export const CashflowMetricsTab: React.FC> = (props) => ( + +); + +export default MetricsCategoryTab; diff --git a/src/views/Company/components/FinancialPanorama/tabs/index.ts b/src/views/Company/components/FinancialPanorama/tabs/index.ts new file mode 100644 index 00000000..3d39d62e --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/tabs/index.ts @@ -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'; diff --git a/src/views/Company/components/FinancialPanorama/types.ts b/src/views/Company/components/FinancialPanorama/types.ts new file mode 100644 index 00000000..6ab1c1b8 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/types.ts @@ -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[]; + }; +} diff --git a/src/views/Company/components/FinancialPanorama/utils/calculations.ts b/src/views/Company/components/FinancialPanorama/utils/calculations.ts new file mode 100644 index 00000000..b517f466 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/utils/calculations.ts @@ -0,0 +1,99 @@ +/** + * 财务计算工具函数 + */ + +/** + * 计算同比变化率 + * @param currentValue 当前值 + * @param currentPeriod 当前期间 + * @param allData 所有数据 + * @param metricPath 指标路径 + * @returns 变化率和强度 + */ +export const calculateYoYChange = ( + currentValue: number | null | undefined, + currentPeriod: string, + allData: Array<{ period: string; [key: string]: unknown }>, + metricPath: string +): { change: number; intensity: number } => { + if (!currentValue || !currentPeriod) return { change: 0, intensity: 0 }; + + // 找到去年同期的数据 + const currentDate = new Date(currentPeriod); + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth() + 1; + + // 查找去年同期 + const lastYearSamePeriod = allData.find((item) => { + const itemDate = new Date(item.period); + const itemYear = itemDate.getFullYear(); + const itemMonth = itemDate.getMonth() + 1; + return itemYear === currentYear - 1 && itemMonth === currentMonth; + }); + + if (!lastYearSamePeriod) return { change: 0, intensity: 0 }; + + const previousValue = metricPath + .split('.') + .reduce((obj: unknown, key: string) => { + if (obj && typeof obj === 'object') { + return (obj as Record)[key]; + } + return undefined; + }, lastYearSamePeriod) as number | undefined; + + if (!previousValue || previousValue === 0) return { change: 0, intensity: 0 }; + + const change = ((currentValue - previousValue) / Math.abs(previousValue)) * 100; + const intensity = Math.min(Math.abs(change) / 50, 1); // 50%变化达到最大强度 + return { change, intensity }; +}; + +/** + * 获取单元格背景色(中国市场颜色) + * @param change 变化率 + * @param intensity 强度 + * @returns 背景色 + */ +export const getCellBackground = (change: number, intensity: number): string => { + if (change > 0) { + return `rgba(239, 68, 68, ${intensity * 0.15})`; // 红色背景,涨 + } else if (change < 0) { + return `rgba(34, 197, 94, ${intensity * 0.15})`; // 绿色背景,跌 + } + return 'transparent'; +}; + +/** + * 从对象中获取嵌套路径的值 + * @param obj 对象 + * @param path 路径(如 'assets.current_assets.cash') + * @returns 值 + */ +export const getValueByPath = ( + obj: unknown, + path: string +): T | undefined => { + return path.split('.').reduce((current: unknown, key: string) => { + if (current && typeof current === 'object') { + return (current as Record)[key]; + } + return undefined; + }, obj) as T | undefined; +}; + +/** + * 判断是否为成本费用类指标(负向指标) + * @param key 指标 key + * @returns 是否为负向指标 + */ +export const isNegativeIndicator = (key: string): boolean => { + return ( + key.includes('cost') || + key.includes('expense') || + key === 'income_tax' || + key.includes('impairment') || + key.includes('days') || + key.includes('debt_ratio') + ); +}; diff --git a/src/views/Company/components/FinancialPanorama/utils/chartOptions.ts b/src/views/Company/components/FinancialPanorama/utils/chartOptions.ts new file mode 100644 index 00000000..cbdbb5cb --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/utils/chartOptions.ts @@ -0,0 +1,367 @@ +/** + * ECharts 图表配置生成器 + */ + +import { formatUtils } from '@services/financialService'; + +interface ChartDataItem { + period: string; + date: string; + value: number; +} + +/** + * 生成指标趋势图表配置 + * @param metricName 指标名称 + * @param data 图表数据 + * @returns ECharts 配置 + */ +export const getMetricChartOption = ( + metricName: string, + data: ChartDataItem[] +) => { + return { + title: { + text: metricName, + left: 'center', + }, + tooltip: { + trigger: 'axis', + formatter: (params: Array<{ name: string; value: number }>) => { + const value = params[0].value; + const formattedValue = + value > 10000 + ? formatUtils.formatLargeNumber(value) + : value?.toFixed(2); + return `${params[0].name}
${metricName}: ${formattedValue}`; + }, + }, + xAxis: { + type: 'category', + data: data.map((d) => d.period), + axisLabel: { + rotate: 45, + }, + }, + yAxis: { + type: 'value', + axisLabel: { + formatter: (value: number) => { + if (Math.abs(value) >= 100000000) { + return (value / 100000000).toFixed(0) + '亿'; + } else if (Math.abs(value) >= 10000) { + return (value / 10000).toFixed(0) + '万'; + } + return value.toFixed(0); + }, + }, + }, + series: [ + { + type: 'bar', + data: data.map((d) => d.value), + itemStyle: { + color: (params: { dataIndex: number; value: number }) => { + const idx = params.dataIndex; + if (idx === 0) return '#3182CE'; + const prevValue = data[idx - 1].value; + const currValue = params.value; + // 中国市场颜色:红涨绿跌 + return currValue >= prevValue ? '#EF4444' : '#10B981'; + }, + }, + label: { + show: true, + position: 'top', + formatter: (params: { value: number }) => { + const value = params.value; + if (Math.abs(value) >= 100000000) { + return (value / 100000000).toFixed(1) + '亿'; + } else if (Math.abs(value) >= 10000) { + return (value / 10000).toFixed(1) + '万'; + } else if (Math.abs(value) >= 1) { + return value.toFixed(1); + } + return value.toFixed(2); + }, + }, + }, + ], + }; +}; + +/** + * 生成营收与利润趋势图表配置 - 黑金主题 + * @param revenueData 营收数据 + * @param profitData 利润数据 + * @returns ECharts 配置 + */ +export const getComparisonChartOption = ( + revenueData: { period: string; value: number }[], + profitData: { period: string; value: number }[] +) => { + return { + backgroundColor: 'transparent', + title: { + text: '营收与利润趋势', + left: 'center', + textStyle: { + color: '#D4AF37', + fontSize: 16, + fontWeight: 'bold', + }, + }, + tooltip: { + trigger: 'axis', + backgroundColor: 'rgba(26, 32, 44, 0.95)', + borderColor: 'rgba(212, 175, 55, 0.3)', + textStyle: { + color: '#E2E8F0', + }, + axisPointer: { + type: 'cross', + crossStyle: { + color: 'rgba(212, 175, 55, 0.5)', + }, + }, + }, + legend: { + data: ['营业收入', '净利润'], + bottom: 0, + textStyle: { + color: '#A0AEC0', + }, + }, + grid: { + left: '3%', + right: '4%', + bottom: '12%', + top: '15%', + containLabel: true, + }, + xAxis: { + type: 'category', + data: revenueData.map((d) => d.period), + axisLine: { + lineStyle: { + color: 'rgba(212, 175, 55, 0.3)', + }, + }, + axisLabel: { + color: '#A0AEC0', + }, + }, + yAxis: [ + { + type: 'value', + name: '营收(亿)', + position: 'left', + nameTextStyle: { + color: '#A0AEC0', + }, + axisLine: { + lineStyle: { + color: 'rgba(212, 175, 55, 0.3)', + }, + }, + axisLabel: { + color: '#A0AEC0', + }, + splitLine: { + lineStyle: { + color: 'rgba(212, 175, 55, 0.1)', + }, + }, + }, + { + type: 'value', + name: '利润(亿)', + position: 'right', + nameTextStyle: { + color: '#A0AEC0', + }, + axisLine: { + lineStyle: { + color: 'rgba(212, 175, 55, 0.3)', + }, + }, + axisLabel: { + color: '#A0AEC0', + }, + splitLine: { + show: false, + }, + }, + ], + series: [ + { + name: '营业收入', + type: 'bar', + data: revenueData.map((d) => d.value?.toFixed(2)), + itemStyle: { + color: (params: { dataIndex: number; value: number }) => { + const idx = params.dataIndex; + if (idx === 0) return '#D4AF37'; // 金色作为基准 + const prevValue = revenueData[idx - 1].value; + const currValue = params.value; + // 红涨绿跌 + return currValue >= prevValue ? '#EF4444' : '#10B981'; + }, + }, + }, + { + name: '净利润', + type: 'line', + yAxisIndex: 1, + data: profitData.map((d) => d.value?.toFixed(2)), + smooth: true, + itemStyle: { color: '#D4AF37' }, + lineStyle: { width: 2, color: '#D4AF37' }, + areaStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: 'rgba(212, 175, 55, 0.3)' }, + { offset: 1, color: 'rgba(212, 175, 55, 0.05)' }, + ], + }, + }, + }, + ], + }; +}; + +// 黑金主题饼图配色 +const BLACK_GOLD_PIE_COLORS = [ + '#D4AF37', // 金色 + '#B8860B', // 深金色 + '#FFD700', // 亮金色 + '#DAA520', // 金菊色 + '#CD853F', // 秘鲁色 + '#F4A460', // 沙褐色 + '#DEB887', // 实木色 + '#D2691E', // 巧克力色 +]; + +/** + * 生成主营业务饼图配置 - 黑金主题 + * @param title 标题 + * @param subtitle 副标题 + * @param data 饼图数据 + * @returns ECharts 配置 + */ +export const getMainBusinessPieOption = ( + title: string, + subtitle: string, + data: { name: string; value: number }[] +) => { + return { + title: { + text: title, + subtext: subtitle, + left: 'center', + textStyle: { + color: '#D4AF37', + fontSize: 14, + }, + subtextStyle: { + color: '#A0AEC0', + fontSize: 12, + }, + }, + tooltip: { + trigger: 'item', + backgroundColor: 'rgba(26, 32, 44, 0.95)', + borderColor: 'rgba(212, 175, 55, 0.3)', + textStyle: { + color: '#E2E8F0', + }, + formatter: (params: { name: string; value: number; percent: number }) => { + return `${params.name}
营收: ${formatUtils.formatLargeNumber( + params.value + )}
占比: ${params.percent}%`; + }, + }, + legend: { + orient: 'vertical', + left: 'left', + top: 'center', + textStyle: { + color: '#E2E8F0', + fontSize: 12, + }, + }, + color: BLACK_GOLD_PIE_COLORS, + series: [ + { + type: 'pie', + radius: '55%', + center: ['55%', '50%'], + data: data, + label: { + show: true, + color: '#E2E8F0', + fontSize: 11, + formatter: '{b}: {d}%', + }, + labelLine: { + lineStyle: { + color: 'rgba(212, 175, 55, 0.5)', + }, + }, + emphasis: { + itemStyle: { + shadowBlur: 10, + shadowOffsetX: 0, + shadowColor: 'rgba(212, 175, 55, 0.5)', + }, + }, + }, + ], + }; +}; + +/** + * 生成对比柱状图配置 + * @param title 标题 + * @param stockName1 股票1名称 + * @param stockName2 股票2名称 + * @param categories X轴分类 + * @param data1 股票1数据 + * @param data2 股票2数据 + * @returns ECharts 配置 + */ +export const getCompareBarChartOption = ( + title: string, + stockName1: string, + stockName2: string, + categories: string[], + data1: (number | undefined)[], + data2: (number | undefined)[] +) => { + return { + tooltip: { trigger: 'axis' }, + legend: { data: [stockName1, stockName2] }, + xAxis: { + type: 'category', + data: categories, + }, + yAxis: { type: 'value', axisLabel: { formatter: '{value}%' } }, + series: [ + { + name: stockName1, + type: 'bar', + data: data1, + }, + { + name: stockName2, + type: 'bar', + data: data2, + }, + ], + }; +}; diff --git a/src/views/Company/components/FinancialPanorama/utils/index.ts b/src/views/Company/components/FinancialPanorama/utils/index.ts new file mode 100644 index 00000000..fd302b02 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/utils/index.ts @@ -0,0 +1,17 @@ +/** + * 工具函数统一导出 + */ + +export { + calculateYoYChange, + getCellBackground, + getValueByPath, + isNegativeIndicator, +} from './calculations'; + +export { + getMetricChartOption, + getComparisonChartOption, + getMainBusinessPieOption, + getCompareBarChartOption, +} from './chartOptions'; diff --git a/src/views/Company/components/ForecastReport/components/ChartCard.tsx b/src/views/Company/components/ForecastReport/components/ChartCard.tsx new file mode 100644 index 00000000..86a86c20 --- /dev/null +++ b/src/views/Company/components/ForecastReport/components/ChartCard.tsx @@ -0,0 +1,37 @@ +/** + * 通用图表卡片组件 - 黑金主题 + */ + +import React from 'react'; +import { Box, Heading } from '@chakra-ui/react'; +import { THEME } from '../constants'; +import type { ChartCardProps } from '../types'; + +const ChartCard: React.FC = ({ title, children }) => { + return ( + + + + {title} + + + + {children} + + + ); +}; + +export default ChartCard; diff --git a/src/views/Company/components/ForecastReport/components/DetailTable.tsx b/src/views/Company/components/ForecastReport/components/DetailTable.tsx new file mode 100644 index 00000000..5dbab05d --- /dev/null +++ b/src/views/Company/components/ForecastReport/components/DetailTable.tsx @@ -0,0 +1,219 @@ +/** + * 详细数据表格 - 黑金主题 + * 优化:斑马纹、等宽字体、首列高亮、重要行强调、预测列区分 + */ + +import React, { useMemo } from 'react'; +import { Box, Text } from '@chakra-ui/react'; +import { Table, ConfigProvider, Tag, theme as antTheme } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import type { DetailTableProps, DetailTableRow } from '../types'; + +// 判断是否为预测年份 +const isForecastYear = (year: string) => year.includes('E'); + +// 重要指标(需要高亮的行) +const IMPORTANT_METRICS = ['归母净利润', 'ROE', 'EPS', '营业总收入']; + +// Ant Design 黑金主题配置 +const BLACK_GOLD_THEME = { + algorithm: antTheme.darkAlgorithm, + token: { + colorPrimary: '#D4AF37', + colorBgContainer: 'transparent', + 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.12)', + headerColor: '#D4AF37', + rowHoverBg: 'rgba(212, 175, 55, 0.08)', + borderColor: 'rgba(212, 175, 55, 0.2)', + cellPaddingBlock: 12, // 增加行高 + cellPaddingInline: 14, + }, + }, +}; + +// 表格样式 - 斑马纹、等宽字体、预测列区分 +const tableStyles = ` + /* 固定列背景 */ + .forecast-detail-table .ant-table-cell-fix-left, + .forecast-detail-table .ant-table-cell-fix-right { + background: #1A202C !important; + } + .forecast-detail-table .ant-table-thead .ant-table-cell-fix-left, + .forecast-detail-table .ant-table-thead .ant-table-cell-fix-right { + background: rgba(26, 32, 44, 0.98) !important; + } + .forecast-detail-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-left { + background: #242d3d !important; + } + + /* 指标标签样式 */ + .forecast-detail-table .metric-tag { + background: rgba(212, 175, 55, 0.15); + border-color: rgba(212, 175, 55, 0.3); + color: #D4AF37; + font-weight: 500; + } + + /* 重要指标行高亮 */ + .forecast-detail-table .important-row { + background: rgba(212, 175, 55, 0.06) !important; + } + .forecast-detail-table .important-row .metric-tag { + background: rgba(212, 175, 55, 0.25); + color: #FFD700; + font-weight: 600; + } + + /* 斑马纹 - 奇数行 */ + .forecast-detail-table .ant-table-tbody > tr:nth-child(odd) > td { + background: rgba(255, 255, 255, 0.02); + } + .forecast-detail-table .ant-table-tbody > tr:nth-child(odd):hover > td { + background: rgba(212, 175, 55, 0.08) !important; + } + + /* 等宽字体 - 数值列 */ + .forecast-detail-table .data-cell { + font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; + } + + /* 预测列样式 */ + .forecast-detail-table .forecast-col { + background: rgba(212, 175, 55, 0.04) !important; + font-style: italic; + } + .forecast-detail-table .ant-table-thead .forecast-col { + color: #FFD700 !important; + font-weight: 600; + } + + /* 负数红色显示 */ + .forecast-detail-table .negative-value { + color: #FC8181; + } + + /* 正增长绿色 */ + .forecast-detail-table .positive-growth { + color: #68D391; + } + + /* 表头预测/历史分隔线 */ + .forecast-detail-table .forecast-divider { + border-left: 2px solid rgba(212, 175, 55, 0.5) !important; + } +`; + +interface TableRowData extends DetailTableRow { + key: string; + isImportant?: boolean; +} + +const DetailTable: React.FC = ({ data }) => { + const { years, rows } = data; + + // 找出预测年份起始索引 + const forecastStartIndex = useMemo(() => { + return years.findIndex(isForecastYear); + }, [years]); + + // 构建列配置 + const columns: ColumnsType = useMemo(() => { + const cols: ColumnsType = [ + { + title: '关键指标', + dataIndex: '指标', + key: '指标', + fixed: 'left', + width: 160, + render: (value: string, record: TableRowData) => ( + + {value} + + ), + }, + ]; + + // 添加年份列 + years.forEach((year, idx) => { + const isForecast = isForecastYear(year); + const isFirstForecast = idx === forecastStartIndex; + + cols.push({ + title: isForecast ? `${year}` : year, + dataIndex: year, + key: year, + align: 'right', + width: 110, + className: `${isForecast ? 'forecast-col' : ''} ${isFirstForecast ? 'forecast-divider' : ''}`, + render: (value: string | number | null, record: TableRowData) => { + if (value === null || value === undefined) return '-'; + + // 格式化数值 + const numValue = typeof value === 'number' ? value : parseFloat(value); + const isNegative = !isNaN(numValue) && numValue < 0; + const isGrowthMetric = record['指标']?.includes('增长') || record['指标']?.includes('率'); + const isPositiveGrowth = isGrowthMetric && !isNaN(numValue) && numValue > 0; + + // 数值类添加样式类名 + const className = `data-cell ${isNegative ? 'negative-value' : ''} ${isPositiveGrowth ? 'positive-growth' : ''}`; + + return {value}; + }, + }); + }); + + return cols; + }, [years, forecastStartIndex]); + + // 构建数据源 + const dataSource: TableRowData[] = useMemo(() => { + return rows.map((row, idx) => { + const metric = row['指标'] as string; + const isImportant = IMPORTANT_METRICS.some(m => metric?.includes(m)); + + return { + ...row, + key: `row-${idx}`, + isImportant, + }; + }); + }, [rows]); + + // 行类名 + const rowClassName = (record: TableRowData) => { + return record.isImportant ? 'important-row' : ''; + }; + + return ( + + + + 详细数据表格 + + + + columns={columns} + dataSource={dataSource} + pagination={false} + size="middle" + scroll={{ x: 'max-content' }} + bordered + rowClassName={rowClassName} + /> + + + ); +}; + +export default DetailTable; diff --git a/src/views/Company/components/ForecastReport/components/EpsChart.tsx b/src/views/Company/components/ForecastReport/components/EpsChart.tsx new file mode 100644 index 00000000..4ff2a5ce --- /dev/null +++ b/src/views/Company/components/ForecastReport/components/EpsChart.tsx @@ -0,0 +1,132 @@ +/** + * EPS 趋势图 + * 优化:添加行业平均参考线、预测区分、置信区间 + */ + +import React, { useMemo } from 'react'; +import ReactECharts from 'echarts-for-react'; +import ChartCard from './ChartCard'; +import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants'; +import type { EpsChartProps } from '../types'; + +// 判断是否为预测年份 +const isForecastYear = (year: string) => year.includes('E'); + +const EpsChart: React.FC = ({ data }) => { + // 计算行业平均EPS(模拟数据,实际应从API获取) + const industryAvgEps = useMemo(() => { + const avg = data.eps.reduce((sum, v) => sum + (v || 0), 0) / data.eps.length; + return data.eps.map(() => avg * 0.8); // 行业平均约为公司的80% + }, [data.eps]); + + // 找出预测数据起始索引 + const forecastStartIndex = useMemo(() => { + return data.years.findIndex(isForecastYear); + }, [data.years]); + + const option = useMemo(() => ({ + ...BASE_CHART_CONFIG, + color: [CHART_COLORS.eps, CHART_COLORS.epsAvg], + tooltip: { + ...BASE_CHART_CONFIG.tooltip, + trigger: 'axis', + formatter: (params: any[]) => { + if (!params || params.length === 0) return ''; + const year = params[0].axisValue; + const isForecast = isForecastYear(year); + + let html = `
+ ${year}${isForecast ? ' (预测)' : ''} +
`; + + params.forEach((item: any) => { + html += `
+ + + ${item.seriesName} + + ${item.value?.toFixed(2) ?? '-'} 元 +
`; + }); + + return html; + }, + }, + legend: { + ...BASE_CHART_CONFIG.legend, + data: ['EPS(稀释)', '行业平均'], + bottom: 0, + }, + xAxis: { + ...BASE_CHART_CONFIG.xAxis, + type: 'category', + data: data.years, + axisLabel: { + color: (value: string) => isForecastYear(value) ? THEME.gold : THEME.textSecondary, + fontWeight: (value: string) => isForecastYear(value) ? 'bold' : 'normal', + }, + }, + yAxis: { + ...BASE_CHART_CONFIG.yAxis, + type: 'value', + name: '元/股', + nameTextStyle: { color: THEME.textSecondary }, + }, + series: [ + { + name: 'EPS(稀释)', + type: 'line', + data: data.eps.map((value, idx) => ({ + value, + itemStyle: { + color: isForecastYear(data.years[idx]) ? 'rgba(218, 165, 32, 0.7)' : CHART_COLORS.eps, + }, + })), + smooth: true, + lineStyle: { width: 2 }, + areaStyle: { + color: { + type: 'linear', + x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: 'rgba(218, 165, 32, 0.3)' }, + { offset: 1, color: 'rgba(218, 165, 32, 0.05)' }, + ], + }, + }, + symbol: 'circle', + symbolSize: 6, + // 预测区域标记 + markArea: forecastStartIndex > 0 ? { + silent: true, + itemStyle: { color: THEME.forecastBg }, + data: [[ + { xAxis: data.years[forecastStartIndex] }, + { xAxis: data.years[data.years.length - 1] }, + ]], + } : undefined, + }, + { + name: '行业平均', + type: 'line', + data: industryAvgEps, + smooth: true, + lineStyle: { + width: 1.5, + type: 'dashed', + color: CHART_COLORS.epsAvg, + }, + itemStyle: { color: CHART_COLORS.epsAvg }, + symbol: 'none', + }, + ], + }), [data, industryAvgEps, forecastStartIndex]); + + return ( + + + + ); +}; + +export default EpsChart; diff --git a/src/views/Company/components/ForecastReport/components/GrowthChart.tsx b/src/views/Company/components/ForecastReport/components/GrowthChart.tsx new file mode 100644 index 00000000..90f2808c --- /dev/null +++ b/src/views/Company/components/ForecastReport/components/GrowthChart.tsx @@ -0,0 +1,59 @@ +/** + * 增长率分析图 + */ + +import React, { useMemo } from 'react'; +import ReactECharts from 'echarts-for-react'; +import ChartCard from './ChartCard'; +import { BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants'; +import type { GrowthChartProps } from '../types'; + +const GrowthChart: React.FC = ({ data }) => { + const option = useMemo(() => ({ + ...BASE_CHART_CONFIG, + tooltip: { + ...BASE_CHART_CONFIG.tooltip, + trigger: 'axis', + }, + xAxis: { + ...BASE_CHART_CONFIG.xAxis, + type: 'category', + data: data.years, + }, + yAxis: { + ...BASE_CHART_CONFIG.yAxis, + type: 'value', + axisLabel: { + ...BASE_CHART_CONFIG.yAxis.axisLabel, + formatter: '{value}%', + }, + }, + series: [ + { + name: '营收增长率(%)', + type: 'bar', + data: data.revenue_growth_pct, + itemStyle: { + color: (params: { value: number }) => + params.value >= 0 ? THEME.positive : THEME.negative, + }, + label: { + show: true, + position: 'top', + color: THEME.textSecondary, + fontSize: 10, + formatter: (params: { value: number }) => + params.value ? `${params.value.toFixed(1)}%` : '', + }, + }, + ], + }), [data]); + + return ( + + + + ); +}; + +export default GrowthChart; diff --git a/src/views/Company/components/ForecastReport/components/IncomeProfitChart.tsx b/src/views/Company/components/ForecastReport/components/IncomeProfitChart.tsx new file mode 100644 index 00000000..b59249f3 --- /dev/null +++ b/src/views/Company/components/ForecastReport/components/IncomeProfitChart.tsx @@ -0,0 +1,69 @@ +/** + * 营业收入与净利润趋势图 + */ + +import React, { useMemo } from 'react'; +import ReactECharts from 'echarts-for-react'; +import ChartCard from './ChartCard'; +import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants'; +import type { IncomeProfitChartProps } from '../types'; + +const IncomeProfitChart: React.FC = ({ data }) => { + const option = useMemo(() => ({ + ...BASE_CHART_CONFIG, + color: [CHART_COLORS.income, CHART_COLORS.profit], + tooltip: { + ...BASE_CHART_CONFIG.tooltip, + trigger: 'axis', + }, + legend: { + ...BASE_CHART_CONFIG.legend, + data: ['营业总收入(百万元)', '归母净利润(百万元)'], + }, + xAxis: { + ...BASE_CHART_CONFIG.xAxis, + type: 'category', + data: data.years, + }, + yAxis: [ + { + ...BASE_CHART_CONFIG.yAxis, + type: 'value', + name: '收入(百万元)', + nameTextStyle: { color: THEME.textSecondary }, + }, + { + ...BASE_CHART_CONFIG.yAxis, + type: 'value', + name: '利润(百万元)', + nameTextStyle: { color: THEME.textSecondary }, + }, + ], + series: [ + { + name: '营业总收入(百万元)', + type: 'line', + data: data.income, + smooth: true, + lineStyle: { width: 2 }, + areaStyle: { opacity: 0.1 }, + }, + { + name: '归母净利润(百万元)', + type: 'line', + yAxisIndex: 1, + data: data.profit, + smooth: true, + lineStyle: { width: 2 }, + }, + ], + }), [data]); + + return ( + + + + ); +}; + +export default IncomeProfitChart; diff --git a/src/views/Company/components/ForecastReport/components/IncomeProfitGrowthChart.tsx b/src/views/Company/components/ForecastReport/components/IncomeProfitGrowthChart.tsx new file mode 100644 index 00000000..d5f58ef8 --- /dev/null +++ b/src/views/Company/components/ForecastReport/components/IncomeProfitGrowthChart.tsx @@ -0,0 +1,204 @@ +/** + * 营业收入、净利润趋势与增长率分析 - 合并图表 + * 优化:历史/预测区分、Y轴配色对应、Tooltip格式化 + */ + +import React, { useMemo } from 'react'; +import ReactECharts from 'echarts-for-react'; +import ChartCard from './ChartCard'; +import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants'; +import type { IncomeProfitTrend, GrowthBars } from '../types'; + +interface IncomeProfitGrowthChartProps { + incomeProfitData: IncomeProfitTrend; + growthData: GrowthBars; +} + +// 判断是否为预测年份(包含 E 后缀) +const isForecastYear = (year: string) => year.includes('E'); + +const IncomeProfitGrowthChart: React.FC = ({ + incomeProfitData, + growthData, +}) => { + // 找出预测数据起始索引 + const forecastStartIndex = useMemo(() => { + return incomeProfitData.years.findIndex(isForecastYear); + }, [incomeProfitData.years]); + + const option = useMemo(() => ({ + ...BASE_CHART_CONFIG, + tooltip: { + ...BASE_CHART_CONFIG.tooltip, + trigger: 'axis', + axisPointer: { + type: 'cross', + crossStyle: { color: 'rgba(212, 175, 55, 0.5)' }, + }, + formatter: (params: any[]) => { + if (!params || params.length === 0) return ''; + const year = params[0].axisValue; + const isForecast = isForecastYear(year); + + let html = `
+ ${year}${isForecast ? ' (预测)' : ''} +
`; + + params.forEach((item: any) => { + const value = item.value; + const formattedValue = item.seriesName === '营收增长率' + ? `${value?.toFixed(1) ?? '-'}%` + : `${(value / 1000)?.toFixed(1) ?? '-'}亿`; + + html += `
+ + + ${item.seriesName} + + ${formattedValue} +
`; + }); + + return html; + }, + }, + legend: { + ...BASE_CHART_CONFIG.legend, + data: ['营业总收入', '归母净利润', '营收增长率'], + bottom: 0, + }, + grid: { + left: 60, + right: 60, + bottom: 50, + top: 40, + containLabel: false, + }, + xAxis: { + ...BASE_CHART_CONFIG.xAxis, + type: 'category', + data: incomeProfitData.years, + axisLabel: { + color: (value: string) => isForecastYear(value) ? THEME.gold : THEME.textSecondary, + fontWeight: (value: string) => isForecastYear(value) ? 'bold' : 'normal', + }, + }, + yAxis: [ + { + ...BASE_CHART_CONFIG.yAxis, + type: 'value', + name: '金额(百万元)', + position: 'left', + nameTextStyle: { color: CHART_COLORS.income }, + axisLine: { lineStyle: { color: CHART_COLORS.income } }, + axisLabel: { + color: CHART_COLORS.income, + formatter: (value: number) => { + if (Math.abs(value) >= 1000) { + return (value / 1000).toFixed(0) + 'k'; + } + return value.toFixed(0); + }, + }, + }, + { + ...BASE_CHART_CONFIG.yAxis, + type: 'value', + name: '增长率(%)', + position: 'right', + nameTextStyle: { color: CHART_COLORS.growth }, + axisLine: { lineStyle: { color: CHART_COLORS.growth } }, + axisLabel: { + color: CHART_COLORS.growth, + formatter: '{value}%', + }, + splitLine: { show: false }, + }, + ], + // 预测区域背景标记 + ...(forecastStartIndex > 0 && { + markArea: { + silent: true, + itemStyle: { + color: THEME.forecastBg, + }, + data: [[ + { xAxis: incomeProfitData.years[forecastStartIndex] }, + { xAxis: incomeProfitData.years[incomeProfitData.years.length - 1] }, + ]], + }, + }), + series: [ + { + name: '营业总收入', + type: 'bar', + data: incomeProfitData.income.map((value, idx) => ({ + value, + itemStyle: { + color: isForecastYear(incomeProfitData.years[idx]) + ? 'rgba(212, 175, 55, 0.6)' // 预测数据半透明 + : CHART_COLORS.income, + }, + })), + barMaxWidth: 30, + // 预测区域标记 + markArea: forecastStartIndex > 0 ? { + silent: true, + itemStyle: { color: THEME.forecastBg }, + data: [[ + { xAxis: incomeProfitData.years[forecastStartIndex] }, + { xAxis: incomeProfitData.years[incomeProfitData.years.length - 1] }, + ]], + } : undefined, + }, + { + name: '归母净利润', + type: 'line', + data: incomeProfitData.profit, + smooth: true, + lineStyle: { + width: 2, + color: CHART_COLORS.profit, + }, + itemStyle: { color: CHART_COLORS.profit }, + areaStyle: { + color: { + type: 'linear', + x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: 'rgba(246, 173, 85, 0.3)' }, + { offset: 1, color: 'rgba(246, 173, 85, 0.05)' }, + ], + }, + }, + }, + { + name: '营收增长率', + type: 'line', + yAxisIndex: 1, + data: growthData.revenue_growth_pct, + smooth: true, + lineStyle: { width: 2, type: 'dashed', color: CHART_COLORS.growth }, + itemStyle: { color: CHART_COLORS.growth }, + label: { + show: true, + position: 'top', + color: THEME.textSecondary, + fontSize: 10, + formatter: (params: { value: number }) => + params.value !== null && params.value !== undefined + ? `${params.value.toFixed(1)}%` + : '', + }, + }, + ], + }), [incomeProfitData, growthData, forecastStartIndex]); + + return ( + + + + ); +}; + +export default IncomeProfitGrowthChart; diff --git a/src/views/Company/components/ForecastReport/components/PePegChart.tsx b/src/views/Company/components/ForecastReport/components/PePegChart.tsx new file mode 100644 index 00000000..80752e4f --- /dev/null +++ b/src/views/Company/components/ForecastReport/components/PePegChart.tsx @@ -0,0 +1,153 @@ +/** + * PE 与 PEG 分析图 + * 优化:配色区分度、线条样式、Y轴颜色对应、预测区分 + */ + +import React, { useMemo } from 'react'; +import ReactECharts from 'echarts-for-react'; +import ChartCard from './ChartCard'; +import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants'; +import type { PePegChartProps } from '../types'; + +// 判断是否为预测年份 +const isForecastYear = (year: string) => year.includes('E'); + +const PePegChart: React.FC = ({ data }) => { + // 找出预测数据起始索引 + const forecastStartIndex = useMemo(() => { + return data.years.findIndex(isForecastYear); + }, [data.years]); + + const option = useMemo(() => ({ + ...BASE_CHART_CONFIG, + color: [CHART_COLORS.pe, CHART_COLORS.peg], + tooltip: { + ...BASE_CHART_CONFIG.tooltip, + trigger: 'axis', + formatter: (params: any[]) => { + if (!params || params.length === 0) return ''; + const year = params[0].axisValue; + const isForecast = isForecastYear(year); + + let html = `
+ ${year}${isForecast ? ' (预测)' : ''} +
`; + + params.forEach((item: any) => { + const unit = item.seriesName === 'PE' ? '倍' : ''; + html += `
+ + + ${item.seriesName} + + ${item.value?.toFixed(2) ?? '-'}${unit} +
`; + }); + + return html; + }, + }, + legend: { + ...BASE_CHART_CONFIG.legend, + data: ['PE', 'PEG'], + bottom: 0, + }, + xAxis: { + ...BASE_CHART_CONFIG.xAxis, + type: 'category', + data: data.years, + axisLabel: { + color: (value: string) => isForecastYear(value) ? THEME.gold : THEME.textSecondary, + fontWeight: (value: string) => isForecastYear(value) ? 'bold' : 'normal', + }, + }, + yAxis: [ + { + ...BASE_CHART_CONFIG.yAxis, + type: 'value', + name: 'PE(倍)', + nameTextStyle: { color: CHART_COLORS.pe }, + axisLine: { lineStyle: { color: CHART_COLORS.pe } }, + axisLabel: { color: CHART_COLORS.pe }, + }, + { + ...BASE_CHART_CONFIG.yAxis, + type: 'value', + name: 'PEG', + nameTextStyle: { color: CHART_COLORS.peg }, + axisLine: { lineStyle: { color: CHART_COLORS.peg } }, + axisLabel: { color: CHART_COLORS.peg }, + splitLine: { show: false }, + }, + ], + series: [ + { + name: 'PE', + type: 'line', + data: data.pe, + smooth: true, + lineStyle: { width: 2.5, color: CHART_COLORS.pe }, + itemStyle: { color: CHART_COLORS.pe }, + symbol: 'circle', + symbolSize: 6, + areaStyle: { + color: { + type: 'linear', + x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: 'rgba(212, 175, 55, 0.2)' }, + { offset: 1, color: 'rgba(212, 175, 55, 0.02)' }, + ], + }, + }, + // 预测区域标记 + markArea: forecastStartIndex > 0 ? { + silent: true, + itemStyle: { color: THEME.forecastBg }, + data: [[ + { xAxis: data.years[forecastStartIndex] }, + { xAxis: data.years[data.years.length - 1] }, + ]], + } : undefined, + }, + { + name: 'PEG', + type: 'line', + yAxisIndex: 1, + data: data.peg, + smooth: true, + lineStyle: { + width: 2.5, + type: [5, 3], // 点划线样式,区分 PE + color: CHART_COLORS.peg, + }, + itemStyle: { color: CHART_COLORS.peg }, + symbol: 'diamond', // 菱形符号,区分 PE + symbolSize: 6, + // PEG=1 参考线 + markLine: { + silent: true, + symbol: 'none', + lineStyle: { + color: 'rgba(255, 255, 255, 0.3)', + type: 'dashed', + }, + label: { + formatter: 'PEG=1', + color: '#A0AEC0', + fontSize: 10, + }, + data: [{ yAxis: 1 }], + }, + }, + ], + }), [data, forecastStartIndex]); + + return ( + + + + ); +}; + +export default PePegChart; diff --git a/src/views/Company/components/ForecastReport/components/index.ts b/src/views/Company/components/ForecastReport/components/index.ts new file mode 100644 index 00000000..e31ad54b --- /dev/null +++ b/src/views/Company/components/ForecastReport/components/index.ts @@ -0,0 +1,11 @@ +/** + * ForecastReport 子组件导出 + */ + +export { default as ChartCard } from './ChartCard'; +export { default as IncomeProfitChart } from './IncomeProfitChart'; +export { default as GrowthChart } from './GrowthChart'; +export { default as IncomeProfitGrowthChart } from './IncomeProfitGrowthChart'; +export { default as EpsChart } from './EpsChart'; +export { default as PePegChart } from './PePegChart'; +export { default as DetailTable } from './DetailTable'; diff --git a/src/views/Company/components/ForecastReport/constants.ts b/src/views/Company/components/ForecastReport/constants.ts new file mode 100644 index 00000000..28e23024 --- /dev/null +++ b/src/views/Company/components/ForecastReport/constants.ts @@ -0,0 +1,94 @@ +/** + * 盈利预测报表常量和图表配置 + */ + +// 黑金主题配色 +export const THEME = { + gold: '#D4AF37', + goldLight: 'rgba(212, 175, 55, 0.1)', + goldBorder: 'rgba(212, 175, 55, 0.3)', + bgDark: '#1A202C', + text: '#E2E8F0', + textSecondary: '#A0AEC0', + positive: '#E53E3E', + negative: '#10B981', + // 预测区域背景色 + forecastBg: 'rgba(212, 175, 55, 0.08)', +}; + +// 图表配色方案 - 优化对比度 +export const CHART_COLORS = { + income: '#D4AF37', // 收入 - 金色 + profit: '#F6AD55', // 利润 - 橙金色 + growth: '#10B981', // 增长率 - 翠绿色 + eps: '#DAA520', // EPS - 金菊色 + epsAvg: '#4A5568', // EPS行业平均 - 灰色 + pe: '#D4AF37', // PE - 金色 + peg: '#38B2AC', // PEG - 青色(优化对比度) +}; + +// ECharts 基础配置(黑金主题) +export const BASE_CHART_CONFIG = { + backgroundColor: 'transparent', + textStyle: { + color: THEME.text, + }, + tooltip: { + backgroundColor: 'rgba(26, 32, 44, 0.98)', + borderColor: THEME.goldBorder, + borderWidth: 1, + padding: [12, 16], + textStyle: { + color: THEME.text, + fontSize: 13, + }, + // 智能避让配置 + confine: true, + appendToBody: true, + extraCssText: 'box-shadow: 0 4px 20px rgba(0,0,0,0.3); border-radius: 6px;', + }, + legend: { + textStyle: { + color: THEME.textSecondary, + }, + }, + grid: { + left: 50, + right: 20, + bottom: 40, + top: 40, + containLabel: false, + }, + xAxis: { + axisLine: { + lineStyle: { + color: THEME.goldBorder, + }, + }, + axisLabel: { + color: THEME.textSecondary, + rotate: 30, + }, + splitLine: { + show: false, + }, + }, + yAxis: { + axisLine: { + lineStyle: { + color: THEME.goldBorder, + }, + }, + axisLabel: { + color: THEME.textSecondary, + }, + splitLine: { + lineStyle: { + color: 'rgba(212, 175, 55, 0.1)', + }, + }, + }, +}; + +// 图表高度 +export const CHART_HEIGHT = 280; diff --git a/src/views/Company/components/ForecastReport/index.tsx b/src/views/Company/components/ForecastReport/index.tsx new file mode 100644 index 00000000..ea24c5a6 --- /dev/null +++ b/src/views/Company/components/ForecastReport/index.tsx @@ -0,0 +1,79 @@ +/** + * 盈利预测报表视图 - 黑金主题 + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { Box, SimpleGrid } from '@chakra-ui/react'; +import { stockService } from '@services/eventService'; +import { + IncomeProfitGrowthChart, + EpsChart, + PePegChart, + DetailTable, +} from './components'; +import LoadingState from '../LoadingState'; +import { CHART_HEIGHT } from './constants'; +import type { ForecastReportProps, ForecastData } from './types'; + +const ForecastReport: React.FC = ({ stockCode: propStockCode }) => { + const [code, setCode] = useState(propStockCode || '600000'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + const load = useCallback(async () => { + if (!code) return; + setLoading(true); + try { + const resp = await stockService.getForecastReport(code); + if (resp && resp.success) { + setData(resp.data); + } + } finally { + setLoading(false); + } + }, [code]); + + // 监听 props 中的 stockCode 变化 + useEffect(() => { + if (propStockCode && propStockCode !== code) { + setCode(propStockCode); + } + }, [propStockCode, code]); + + // 加载数据 + useEffect(() => { + if (code) { + load(); + } + }, [code, load]); + + return ( + + {/* 加载状态 */} + {loading && !data && ( + + )} + + {/* 图表区域 - 3列布局 */} + {data && ( + + + + + + )} + + {/* 详细数据表格 */} + {data && ( + + + + )} + + ); +}; + +export default ForecastReport; diff --git a/src/views/Company/components/ForecastReport/types.ts b/src/views/Company/components/ForecastReport/types.ts new file mode 100644 index 00000000..d084eca1 --- /dev/null +++ b/src/views/Company/components/ForecastReport/types.ts @@ -0,0 +1,81 @@ +/** + * 盈利预测报表类型定义 + */ + +// 收入利润趋势数据 +export interface IncomeProfitTrend { + years: string[]; + income: number[]; + profit: number[]; +} + +// 增长率数据 +export interface GrowthBars { + years: string[]; + revenue_growth_pct: number[]; +} + +// EPS 趋势数据 +export interface EpsTrend { + years: string[]; + eps: number[]; +} + +// PE/PEG 数据 +export interface PePegAxes { + years: string[]; + pe: number[]; + peg: number[]; +} + +// 详细表格行数据 +export interface DetailTableRow { + 指标: string; + [year: string]: string | number | null; +} + +// 详细表格数据 +export interface DetailTable { + years: string[]; + rows: DetailTableRow[]; +} + +// 完整的预测报表数据 +export interface ForecastData { + income_profit_trend: IncomeProfitTrend; + growth_bars: GrowthBars; + eps_trend: EpsTrend; + pe_peg_axes: PePegAxes; + detail_table: DetailTable; +} + +// 组件 Props +export interface ForecastReportProps { + stockCode?: string; +} + +export interface ChartCardProps { + title: string; + children: React.ReactNode; + height?: number; +} + +export interface IncomeProfitChartProps { + data: IncomeProfitTrend; +} + +export interface GrowthChartProps { + data: GrowthBars; +} + +export interface EpsChartProps { + data: EpsTrend; +} + +export interface PePegChartProps { + data: PePegAxes; +} + +export interface DetailTableProps { + data: DetailTable; +} diff --git a/src/views/Company/components/LoadingState.tsx b/src/views/Company/components/LoadingState.tsx new file mode 100644 index 00000000..11df6cba --- /dev/null +++ b/src/views/Company/components/LoadingState.tsx @@ -0,0 +1,44 @@ +// src/views/Company/components/LoadingState.tsx +// 统一的加载状态组件 - 黑金主题 + +import React from "react"; +import { Center, VStack, Spinner, Text } from "@chakra-ui/react"; + +// 黑金主题配置 +const THEME = { + gold: "#D4AF37", + textSecondary: "gray.400", +}; + +interface LoadingStateProps { + message?: string; + height?: string; +} + +/** + * 统一的加载状态组件(黑金主题) + * + * 用于所有一级 Tab 的 loading 状态展示 + */ +const LoadingState: React.FC = ({ + message = "加载中...", + height = "300px", +}) => { + return ( +
+ + + + {message} + + +
+ ); +}; + +export default LoadingState; diff --git a/src/views/Company/components/MarketDataView/components/AnalysisModal.tsx b/src/views/Company/components/MarketDataView/components/AnalysisModal.tsx new file mode 100644 index 00000000..e4677e08 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/AnalysisModal.tsx @@ -0,0 +1,188 @@ +// src/views/Company/components/MarketDataView/components/AnalysisModal.tsx +// 涨幅分析模态框组件 + +import React from 'react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalCloseButton, + VStack, + HStack, + Box, + Heading, + Text, + Tag, + Badge, + Icon, +} from '@chakra-ui/react'; +import { ExternalLinkIcon } from '@chakra-ui/icons'; +import MarkdownRenderer from './MarkdownRenderer'; +import { formatNumber } from '../utils/formatUtils'; +import type { AnalysisModalProps, RiseAnalysis, Theme } from '../types'; + +/** + * 涨幅分析内容组件 + */ +interface AnalysisContentProps { + analysis: RiseAnalysis; + theme: Theme; +} + +export const AnalysisContent: React.FC = ({ analysis, theme }) => { + return ( + + {/* 头部信息 */} + + + {analysis.stock_name} ({analysis.stock_code}) + + + 日期: {analysis.trade_date} + 涨幅: {analysis.rise_rate}% + 收盘价: {analysis.close_price} + + + + {/* 主营业务 */} + {analysis.main_business && ( + + + 主营业务 + + {analysis.main_business} + + )} + + {/* 详细分析 */} + {analysis.rise_reason_detail && ( + + + 详细分析 + + {analysis.rise_reason_detail} + + )} + + {/* 相关公告 */} + {analysis.announcements && analysis.announcements !== '[]' && ( + + + 相关公告 + + {analysis.announcements} + + )} + + {/* 研报引用 */} + {analysis.verification_reports && analysis.verification_reports.length > 0 && ( + + + + + 研报引用 ({analysis.verification_reports.length}) + + + + {analysis.verification_reports.map((report, reportIdx) => ( + + + + + {report.publisher || '未知机构'} + + {report.match_score && ( + + 匹配度: {report.match_score} + + )} + {report.match_ratio != null && report.match_ratio > 0 && ( + + {(report.match_ratio * 100).toFixed(0)}% + + )} + + {report.declare_date && ( + + {report.declare_date.substring(0, 10)} + + )} + + + {report.report_title && ( + + 《{report.report_title}》 + + )} + + {report.author && ( + + 分析师: {report.author} + + )} + + {report.verification_item && ( + + + 验证项: {report.verification_item} + + + )} + + {report.content && ( + + {report.content} + + )} + + ))} + + + )} + + {/* 底部统计 */} + + + 成交量: {formatNumber(analysis.volume)} | 成交额: {formatNumber(analysis.amount)} | 更新时间:{' '} + {analysis.update_time || analysis.create_time || '-'} + + + + ); +}; + +/** + * 涨幅分析模态框组件 + */ +const AnalysisModal: React.FC = ({ isOpen, onClose, content, theme }) => { + return ( + + + + 涨幅分析详情 + + {content} + + + ); +}; + +export default AnalysisModal; diff --git a/src/views/Company/components/MarketDataView/components/MarkdownRenderer.tsx b/src/views/Company/components/MarketDataView/components/MarkdownRenderer.tsx new file mode 100644 index 00000000..cfd44f2e --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/MarkdownRenderer.tsx @@ -0,0 +1,65 @@ +// src/views/Company/components/MarketDataView/components/MarkdownRenderer.tsx +// Markdown 渲染组件 + +import React from 'react'; +import { Box } from '@chakra-ui/react'; +import ReactMarkdown from 'react-markdown'; +import type { MarkdownRendererProps } from '../types'; + +/** + * Markdown 渲染组件 + * 提供统一的 Markdown 样式 + */ +const MarkdownRenderer: React.FC = ({ children, theme }) => { + return ( + + {children} + + ); +}; + +export default MarkdownRenderer; diff --git a/src/views/Company/components/MarketDataView/components/StockSummaryCard/MetricCard.tsx b/src/views/Company/components/MarketDataView/components/StockSummaryCard/MetricCard.tsx new file mode 100644 index 00000000..8be9af42 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/StockSummaryCard/MetricCard.tsx @@ -0,0 +1,56 @@ +// 指标卡片组件 +import React from 'react'; +import { Box, VStack } from '@chakra-ui/react'; +import { DarkGoldCard, CardTitle, MetricValue } from './atoms'; +import { darkGoldTheme } from '../../constants'; + +export interface MetricCardProps { + title: string; + subtitle: string; + leftIcon: React.ReactNode; + rightIcon?: React.ReactNode; + mainLabel: string; + mainValue: string; + mainColor: string; + mainSuffix?: string; + subText: React.ReactNode; +} + +/** + * 指标卡片组件 - 用于展示单个指标数据 + */ +const MetricCard: React.FC = ({ + title, + subtitle, + leftIcon, + rightIcon, + mainLabel, + mainValue, + mainColor, + mainSuffix, + subText, +}) => ( + + + + + + + + + {subText} + + +); + +export default MetricCard; diff --git a/src/views/Company/components/MarketDataView/components/StockSummaryCard/StockHeaderCard.tsx b/src/views/Company/components/MarketDataView/components/StockSummaryCard/StockHeaderCard.tsx new file mode 100644 index 00000000..8d7a339f --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/StockSummaryCard/StockHeaderCard.tsx @@ -0,0 +1,90 @@ +// 股票信息卡片组件(4列布局版本) +import React from 'react'; +import { Box, HStack, Text, Icon } from '@chakra-ui/react'; +import { TrendingUp, TrendingDown } from 'lucide-react'; +import { DarkGoldCard } from './atoms'; +import { getTrendDescription, getPriceColor } from './utils'; +import { darkGoldTheme } from '../../constants'; + +export interface StockHeaderCardProps { + stockName: string; + stockCode: string; + price: number; + changePercent: number; +} + +/** + * 股票信息卡片 - 4 列布局中的第一个卡片 + */ +const StockHeaderCard: React.FC = ({ + stockName, + stockCode, + price, + changePercent, +}) => { + const isUp = changePercent >= 0; + const priceColor = getPriceColor(changePercent); + const trendDesc = getTrendDescription(changePercent); + + return ( + + {/* 背景装饰线 */} + + + {/* 股票名称和代码 */} + + + {stockName} + + + ({stockCode}) + + + + {/* 价格和涨跌幅 */} + + + {price.toFixed(2)} + + + + + {isUp ? '+' : ''}{changePercent.toFixed(2)}% + + + + + {/* 走势简述 */} + + 走势简述: + + {trendDesc} + + + + ); +}; + +export default StockHeaderCard; diff --git a/src/views/Company/components/MarketDataView/components/StockSummaryCard/atoms/CardTitle.tsx b/src/views/Company/components/MarketDataView/components/StockSummaryCard/atoms/CardTitle.tsx new file mode 100644 index 00000000..1cd5d2cd --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/StockSummaryCard/atoms/CardTitle.tsx @@ -0,0 +1,36 @@ +// 卡片标题原子组件 +import React from 'react'; +import { Flex, HStack, Box, Text } from '@chakra-ui/react'; +import { darkGoldTheme } from '../../../constants'; + +interface CardTitleProps { + title: string; + subtitle: string; + leftIcon: React.ReactNode; + rightIcon?: React.ReactNode; +} + +/** + * 卡片标题组件 - 显示图标+标题+副标题 + */ +const CardTitle: React.FC = ({ + title, + subtitle, + leftIcon, + rightIcon, +}) => ( + + + {leftIcon} + + {title} + + + ({subtitle}) + + + {rightIcon && {rightIcon}} + +); + +export default CardTitle; diff --git a/src/views/Company/components/MarketDataView/components/StockSummaryCard/atoms/DarkGoldCard.tsx b/src/views/Company/components/MarketDataView/components/StockSummaryCard/atoms/DarkGoldCard.tsx new file mode 100644 index 00000000..e7992070 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/StockSummaryCard/atoms/DarkGoldCard.tsx @@ -0,0 +1,42 @@ +// 黑金主题卡片容器原子组件 +import React from 'react'; +import { Box, BoxProps } from '@chakra-ui/react'; +import { darkGoldTheme } from '../../../constants'; + +interface DarkGoldCardProps extends BoxProps { + children: React.ReactNode; + hoverable?: boolean; +} + +/** + * 黑金主题卡片容器 + */ +const DarkGoldCard: React.FC = ({ + children, + hoverable = true, + ...props +}) => ( + + {children} + +); + +export default DarkGoldCard; diff --git a/src/views/Company/components/MarketDataView/components/StockSummaryCard/atoms/MetricValue.tsx b/src/views/Company/components/MarketDataView/components/StockSummaryCard/atoms/MetricValue.tsx new file mode 100644 index 00000000..987fa4e4 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/StockSummaryCard/atoms/MetricValue.tsx @@ -0,0 +1,54 @@ +// 核心数值展示原子组件 +import React from 'react'; +import { HStack, Text } from '@chakra-ui/react'; +import { darkGoldTheme } from '../../../constants'; + +interface MetricValueProps { + label: string; + value: string; + color: string; + suffix?: string; + size?: 'sm' | 'md' | 'lg'; +} + +const sizeMap = { + sm: { label: 'xs', value: 'lg', suffix: 'sm' }, + md: { label: 'xs', value: 'xl', suffix: 'md' }, + lg: { label: 'xs', value: '2xl', suffix: 'md' }, +}; + +/** + * 核心数值展示组件 - 显示标签+数值 + */ +const MetricValue: React.FC = ({ + label, + value, + color, + suffix, + size = 'lg', +}) => { + const sizes = sizeMap[size]; + + return ( + + + {label} + + + {value} + + {suffix && ( + + {suffix} + + )} + + ); +}; + +export default MetricValue; diff --git a/src/views/Company/components/MarketDataView/components/StockSummaryCard/atoms/PriceDisplay.tsx b/src/views/Company/components/MarketDataView/components/StockSummaryCard/atoms/PriceDisplay.tsx new file mode 100644 index 00000000..d1b45399 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/StockSummaryCard/atoms/PriceDisplay.tsx @@ -0,0 +1,56 @@ +// 价格显示原子组件 +import React from 'react'; +import { HStack, Text, Icon } from '@chakra-ui/react'; +import { TrendingUp, TrendingDown } from 'lucide-react'; + +interface PriceDisplayProps { + price: number; + changePercent: number; + priceColor: string; + size?: 'sm' | 'md' | 'lg' | 'xl'; +} + +const sizeMap = { + sm: { price: '2xl', percent: 'md', icon: 4 }, + md: { price: '3xl', percent: 'lg', icon: 5 }, + lg: { price: '4xl', percent: 'xl', icon: 6 }, + xl: { price: '5xl', percent: 'xl', icon: 6 }, +}; + +/** + * 价格显示组件 - 显示价格和涨跌幅 + */ +const PriceDisplay: React.FC = ({ + price, + changePercent, + priceColor, + size = 'xl', +}) => { + const isUp = changePercent >= 0; + const sizes = sizeMap[size]; + + return ( + + + {price.toFixed(2)} + + + + + {isUp ? '+' : ''}{changePercent.toFixed(2)}% + + + + ); +}; + +export default PriceDisplay; diff --git a/src/views/Company/components/MarketDataView/components/StockSummaryCard/atoms/StatusTag.tsx b/src/views/Company/components/MarketDataView/components/StockSummaryCard/atoms/StatusTag.tsx new file mode 100644 index 00000000..c9b8eec4 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/StockSummaryCard/atoms/StatusTag.tsx @@ -0,0 +1,24 @@ +// 状态标签原子组件 +import React from 'react'; +import { Text } from '@chakra-ui/react'; + +interface StatusTagProps { + text: string; + color: string; + showParentheses?: boolean; +} + +/** + * 状态标签 - 显示如"活跃"、"健康"等状态文字 + */ +const StatusTag: React.FC = ({ + text, + color, + showParentheses = true, +}) => ( + + {showParentheses ? `(${text})` : text} + +); + +export default StatusTag; diff --git a/src/views/Company/components/MarketDataView/components/StockSummaryCard/atoms/index.ts b/src/views/Company/components/MarketDataView/components/StockSummaryCard/atoms/index.ts new file mode 100644 index 00000000..afa16db9 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/StockSummaryCard/atoms/index.ts @@ -0,0 +1,6 @@ +// 原子组件统一导出 +export { default as StatusTag } from './StatusTag'; +export { default as PriceDisplay } from './PriceDisplay'; +export { default as MetricValue } from './MetricValue'; +export { default as CardTitle } from './CardTitle'; +export { default as DarkGoldCard } from './DarkGoldCard'; diff --git a/src/views/Company/components/MarketDataView/components/StockSummaryCard/index.tsx b/src/views/Company/components/MarketDataView/components/StockSummaryCard/index.tsx new file mode 100644 index 00000000..bd1ba208 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/StockSummaryCard/index.tsx @@ -0,0 +1,114 @@ +// StockSummaryCard 主组件 +import React from 'react'; +import { SimpleGrid, HStack, Text, VStack } from '@chakra-ui/react'; +import { Flame, Coins, DollarSign, Shield } from 'lucide-react'; +import StockHeaderCard from './StockHeaderCard'; +import MetricCard from './MetricCard'; +import { StatusTag } from './atoms'; +import { getTurnoverStatus, getPEStatus, getPledgeStatus } from './utils'; +import { formatNumber, formatPercent } from '../../utils/formatUtils'; +import { darkGoldTheme } from '../../constants'; +import type { StockSummaryCardProps } from '../../types'; + +/** + * 股票概览卡片组件 + * 4 列横向布局:股票信息 + 交易热度 + 估值安全 + 情绪风险 + */ +const StockSummaryCard: React.FC = ({ summary }) => { + if (!summary) return null; + + const { latest_trade, latest_funding, latest_pledge } = summary; + + // 计算状态 + const turnoverStatus = latest_trade + ? getTurnoverStatus(latest_trade.turnover_rate) + : { text: '-', color: darkGoldTheme.textMuted }; + + const peStatus = getPEStatus(latest_trade?.pe_ratio); + + const pledgeStatus = latest_pledge + ? getPledgeStatus(latest_pledge.pledge_ratio) + : { text: '-', color: darkGoldTheme.textMuted }; + + return ( + + {/* 卡片1: 股票信息 */} + {latest_trade && ( + + )} + {/* 卡片1: 交易热度 */} + } + rightIcon={} + mainLabel="成交额" + mainValue={latest_trade ? formatNumber(latest_trade.amount) : '-'} + mainColor={darkGoldTheme.orange} + subText={ + + + 成交量 {latest_trade ? formatNumber(latest_trade.volume, 0) : '-'} + + | + + 换手率 {latest_trade ? formatPercent(latest_trade.turnover_rate) : '-'} + + + + } + /> + + {/* 卡片2: 估值 VS 安全 */} + } + rightIcon={} + mainLabel="市盈率(PE)" + mainValue={latest_trade?.pe_ratio?.toFixed(2) || '-'} + mainColor={darkGoldTheme.orange} + subText={ + + + {peStatus.text} + + + + 质押率 {latest_pledge ? formatPercent(latest_pledge.pledge_ratio) : '-'} + + + + + } + /> + + {/* 卡片3: 情绪与风险 */} + } + mainLabel="融资余额" + mainValue={latest_funding ? formatNumber(latest_funding.financing_balance) : '-'} + mainColor={darkGoldTheme.green} + subText={ + + (强调做多力量) + + + 融券 {latest_funding ? formatNumber(latest_funding.securities_balance) : '-'} + + + + } + /> + + ); +}; + +export default StockSummaryCard; diff --git a/src/views/Company/components/MarketDataView/components/StockSummaryCard/utils.ts b/src/views/Company/components/MarketDataView/components/StockSummaryCard/utils.ts new file mode 100644 index 00000000..e599f657 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/StockSummaryCard/utils.ts @@ -0,0 +1,57 @@ +// 状态计算工具函数 +import { darkGoldTheme } from '../../constants'; + +export interface StatusResult { + text: string; + color: string; +} + +/** + * 获取走势简述 + */ +export const getTrendDescription = (changePercent: number): string => { + if (changePercent >= 5) return '强势上涨'; + if (changePercent >= 2) return '稳步上涨'; + if (changePercent > 0) return '小幅上涨'; + if (changePercent === 0) return '横盘整理'; + if (changePercent > -2) return '小幅下跌'; + if (changePercent > -5) return '震荡下跌'; + return '大幅下跌'; +}; + +/** + * 获取换手率状态标签 + */ +export const getTurnoverStatus = (rate: number): StatusResult => { + if (rate >= 3) return { text: '活跃', color: darkGoldTheme.orange }; + if (rate >= 1) return { text: '正常', color: darkGoldTheme.gold }; + return { text: '冷清', color: darkGoldTheme.textMuted }; +}; + +/** + * 获取市盈率估值标签 + */ +export const getPEStatus = (pe: number | undefined): StatusResult => { + if (!pe || pe <= 0) return { text: '亏损', color: darkGoldTheme.red }; + if (pe < 10) return { text: '极低估值 / 安全边际高', color: darkGoldTheme.green }; + if (pe < 20) return { text: '合理估值', color: darkGoldTheme.gold }; + if (pe < 40) return { text: '偏高估值', color: darkGoldTheme.orange }; + return { text: '高估值 / 泡沫风险', color: darkGoldTheme.red }; +}; + +/** + * 获取质押率健康状态 + */ +export const getPledgeStatus = (ratio: number): StatusResult => { + if (ratio < 10) return { text: '健康', color: darkGoldTheme.green }; + if (ratio < 30) return { text: '正常', color: darkGoldTheme.gold }; + if (ratio < 50) return { text: '偏高', color: darkGoldTheme.orange }; + return { text: '警惕', color: darkGoldTheme.red }; +}; + +/** + * 获取价格颜色 + */ +export const getPriceColor = (changePercent: number): string => { + return changePercent >= 0 ? darkGoldTheme.red : darkGoldTheme.green; +}; diff --git a/src/views/Company/components/MarketDataView/components/ThemedCard.tsx b/src/views/Company/components/MarketDataView/components/ThemedCard.tsx new file mode 100644 index 00000000..b6f83386 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/ThemedCard.tsx @@ -0,0 +1,32 @@ +// src/views/Company/components/MarketDataView/components/ThemedCard.tsx +// 主题化卡片组件 + +import React from 'react'; +import { Card } from '@chakra-ui/react'; +import type { ThemedCardProps } from '../types'; + +/** + * 主题化卡片组件 + * 提供统一的卡片样式和悬停效果 + */ +const ThemedCard: React.FC = ({ children, theme, ...props }) => { + return ( + + {children} + + ); +}; + +export default ThemedCard; diff --git a/src/views/Company/components/MarketDataView/components/index.ts b/src/views/Company/components/MarketDataView/components/index.ts new file mode 100644 index 00000000..0fe7d160 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/index.ts @@ -0,0 +1,7 @@ +// src/views/Company/components/MarketDataView/components/index.ts +// 组件导出索引 + +export { default as ThemedCard } from './ThemedCard'; +export { default as MarkdownRenderer } from './MarkdownRenderer'; +export { default as StockSummaryCard } from './StockSummaryCard'; +export { default as AnalysisModal, AnalysisContent } from './AnalysisModal'; diff --git a/src/views/Company/components/MarketDataView/components/panels/BigDealPanel.tsx b/src/views/Company/components/MarketDataView/components/panels/BigDealPanel.tsx new file mode 100644 index 00000000..8cfa52b8 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/panels/BigDealPanel.tsx @@ -0,0 +1,187 @@ +// src/views/Company/components/MarketDataView/components/panels/BigDealPanel.tsx +// 大宗交易面板 - 黑金主题 + +import React from 'react'; +import { + Box, + Text, + Table, + Thead, + Tbody, + Tr, + Th, + Td, + TableContainer, + Center, + VStack, + HStack, + Tooltip, + Heading, +} from '@chakra-ui/react'; + +import { formatNumber } from '../../utils/formatUtils'; +import { darkGoldTheme } from '../../constants'; +import type { Theme, BigDealData } from '../../types'; + +export interface BigDealPanelProps { + theme: Theme; + bigDealData: BigDealData; +} + +// 黑金卡片样式 +const darkGoldCardStyle = { + bg: darkGoldTheme.bgCard, + border: '1px solid', + borderColor: darkGoldTheme.border, + borderRadius: 'xl', + 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)', + }, +}; + +// 黑金徽章样式 +const DarkGoldBadge: React.FC<{ children: React.ReactNode; variant?: 'gold' | 'orange' | 'green' | 'purple' }> = ({ + children, + variant = 'gold', +}) => { + const colors = { + gold: { bg: 'rgba(212, 175, 55, 0.15)', color: darkGoldTheme.gold }, + orange: { bg: 'rgba(255, 149, 0, 0.15)', color: darkGoldTheme.orange }, + green: { bg: 'rgba(0, 200, 81, 0.15)', color: darkGoldTheme.green }, + purple: { bg: 'rgba(160, 120, 220, 0.15)', color: '#A078DC' }, + }; + const style = colors[variant]; + + return ( + + {children} + + ); +}; + +const BigDealPanel: React.FC = ({ bigDealData }) => { + return ( + + + + 大宗交易记录 + + + + {bigDealData?.daily_stats && bigDealData.daily_stats.length > 0 ? ( + + {bigDealData.daily_stats.map((dayStats, idx) => ( + + + + {dayStats.date} + + + + 交易笔数: {dayStats.count} + + + 成交量: {formatNumber(dayStats.total_volume)}万股 + + + 成交额: {formatNumber(dayStats.total_amount)}万元 + + + 均价: {dayStats.avg_price?.toFixed(2) || '-'}元 + + + + + {dayStats.deals && dayStats.deals.length > 0 && ( + +
+ + + + + + + + + + + {dayStats.deals.map((deal, i) => ( + + + + + + + + ))} + +
买方营业部卖方营业部 + 成交价 + + 成交量(万股) + + 成交额(万元) +
+ + {deal.buyer_dept || '-'} + + + + {deal.seller_dept || '-'} + + + {deal.price?.toFixed(2) || '-'} + + {deal.volume?.toFixed(2) || '-'} + + {deal.amount?.toFixed(2) || '-'} +
+ + )} +
+ ))} + + ) : ( +
+ 暂无大宗交易数据 +
+ )} +
+ + ); +}; + +export default BigDealPanel; diff --git a/src/views/Company/components/MarketDataView/components/panels/FundingPanel.tsx b/src/views/Company/components/MarketDataView/components/panels/FundingPanel.tsx new file mode 100644 index 00000000..08a13de5 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/panels/FundingPanel.tsx @@ -0,0 +1,153 @@ +// src/views/Company/components/MarketDataView/components/panels/FundingPanel.tsx +// 融资融券面板 - 黑金主题 + +import React from 'react'; +import { + Box, + Text, + VStack, + HStack, + Grid, + Heading, +} from '@chakra-ui/react'; +import ReactECharts from 'echarts-for-react'; + +import { formatNumber } from '../../utils/formatUtils'; +import { getFundingDarkGoldOption } from '../../utils/chartOptions'; +import { darkGoldTheme } from '../../constants'; +import type { Theme, FundingDayData } from '../../types'; + +export interface FundingPanelProps { + theme: Theme; + fundingData: FundingDayData[]; +} + +// 黑金卡片样式 +const darkGoldCardStyle = { + bg: darkGoldTheme.bgCard, + border: '1px solid', + borderColor: darkGoldTheme.border, + borderRadius: 'xl', + 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)', + transform: 'translateY(-2px)', + }, +}; + +const FundingPanel: React.FC = ({ fundingData }) => { + return ( + + {/* 图表卡片 */} + + {fundingData.length > 0 && ( + + + + )} + + + + {/* 融资数据 */} + + + + 融资数据 + + + + + {fundingData + .slice(-5) + .reverse() + .map((item, idx) => ( + + + + {item.date} + + + + {formatNumber(item.financing.balance)} + + + 买入{formatNumber(item.financing.buy)} / 偿还 + {formatNumber(item.financing.repay)} + + + + + ))} + + + + + {/* 融券数据 */} + + + + 融券数据 + + + + + {fundingData + .slice(-5) + .reverse() + .map((item, idx) => ( + + + + {item.date} + + + + {formatNumber(item.securities.balance)} + + + 卖出{formatNumber(item.securities.sell)} / 偿还 + {formatNumber(item.securities.repay)} + + + + + ))} + + + + + + ); +}; + +export default FundingPanel; diff --git a/src/views/Company/components/MarketDataView/components/panels/PledgePanel.tsx b/src/views/Company/components/MarketDataView/components/panels/PledgePanel.tsx new file mode 100644 index 00000000..9e189e10 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/panels/PledgePanel.tsx @@ -0,0 +1,141 @@ +// src/views/Company/components/MarketDataView/components/panels/PledgePanel.tsx +// 股权质押面板 - 黑金主题 + +import React from 'react'; +import { + Box, + Text, + Table, + Thead, + Tbody, + Tr, + Th, + Td, + TableContainer, + VStack, + Heading, +} from '@chakra-ui/react'; +import ReactECharts from 'echarts-for-react'; + +import { formatNumber, formatPercent } from '../../utils/formatUtils'; +import { getPledgeDarkGoldOption } from '../../utils/chartOptions'; +import { darkGoldTheme } from '../../constants'; +import type { Theme, PledgeData } from '../../types'; + +export interface PledgePanelProps { + theme: Theme; + pledgeData: PledgeData[]; +} + +// 黑金卡片样式 +const darkGoldCardStyle = { + bg: darkGoldTheme.bgCard, + border: '1px solid', + borderColor: darkGoldTheme.border, + borderRadius: 'xl', + 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)', + }, +}; + +const PledgePanel: React.FC = ({ pledgeData }) => { + return ( + + {/* 图表卡片 */} + + {pledgeData.length > 0 && ( + + + + )} + + + {/* 质押明细表格 */} + + + + 质押明细 + + + + + + + + + + + + + + + + + + {pledgeData.length > 0 ? ( + pledgeData.map((item, idx) => ( + + + + + + + + + + )) + ) : ( + + + + )} + +
日期 + 无限售质押(万股) + + 限售质押(万股) + + 质押总量(万股) + + 总股本(万股) + + 质押比例 + + 质押笔数 +
{item.end_date} + {formatNumber(item.unrestricted_pledge, 0)} + + {formatNumber(item.restricted_pledge, 0)} + + {formatNumber(item.total_pledge, 0)} + + {formatNumber(item.total_shares, 0)} + + {formatPercent(item.pledge_ratio)} + + {item.pledge_count} +
+ + 暂无数据 + +
+
+
+
+
+ ); +}; + +export default PledgePanel; diff --git a/src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/KLineModule.tsx b/src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/KLineModule.tsx new file mode 100644 index 00000000..2e27deeb --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/KLineModule.tsx @@ -0,0 +1,242 @@ +// src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/KLineModule.tsx +// K线模块 - 日K线/分钟K线切换展示(黑金主题) + +import React, { useState } from 'react'; +import { + Box, + Text, + VStack, + HStack, + Button, + ButtonGroup, + Badge, + Center, + Spinner, + Icon, + Select, +} from '@chakra-ui/react'; +import { RepeatIcon, InfoIcon } from '@chakra-ui/icons'; +import { BarChart2, Clock, TrendingUp, Calendar } from 'lucide-react'; +import ReactECharts from 'echarts-for-react'; + +import { darkGoldTheme, PERIOD_OPTIONS } from '../../../constants'; +import { getKLineDarkGoldOption, getMinuteKLineDarkGoldOption } from '../../../utils/chartOptions'; +import type { KLineModuleProps } from '../../../types'; + +// 空状态组件(内联) +const EmptyState: React.FC<{ title: string; description: string }> = ({ title, description }) => ( +
+ + + + {title} + {description} + + +
+); + +// 重新导出类型供外部使用 +export type { KLineModuleProps } from '../../../types'; + +type ChartMode = 'daily' | 'minute'; + +const KLineModule: React.FC = ({ + theme, + tradeData, + minuteData, + minuteLoading, + analysisMap, + onLoadMinuteData, + onChartClick, + selectedPeriod, + onPeriodChange, +}) => { + const [mode, setMode] = useState('daily'); + const hasMinuteData = minuteData && minuteData.data && minuteData.data.length > 0; + + // 切换到分钟模式时自动加载数据 + const handleModeChange = (newMode: ChartMode) => { + setMode(newMode); + if (newMode === 'minute' && !hasMinuteData && !minuteLoading) { + onLoadMinuteData(); + } + }; + + // 黑金主题按钮样式 + const activeButtonStyle = { + bg: `linear-gradient(135deg, ${darkGoldTheme.gold} 0%, ${darkGoldTheme.orange} 100%)`, + color: '#1a1a2e', + borderColor: darkGoldTheme.gold, + _hover: { + bg: `linear-gradient(135deg, ${darkGoldTheme.goldLight} 0%, ${darkGoldTheme.gold} 100%)`, + }, + }; + + const inactiveButtonStyle = { + bg: 'transparent', + color: darkGoldTheme.textMuted, + borderColor: darkGoldTheme.border, + _hover: { + bg: 'rgba(212, 175, 55, 0.1)', + borderColor: darkGoldTheme.gold, + color: darkGoldTheme.gold, + }, + }; + + return ( + + {/* 卡片头部 */} + + + + + + + + {mode === 'daily' ? '日K线图' : '分钟K线图'} + + {mode === 'minute' && minuteData?.trade_date && ( + + {minuteData.trade_date} + + )} + + + + {/* 日K模式下显示时间范围选择器 */} + {mode === 'daily' && onPeriodChange && ( + + + + + )} + + {/* 分钟模式下的刷新按钮 */} + {mode === 'minute' && ( + + )} + + {/* 模式切换按钮组 */} + + + + + + + + + {/* 卡片内容 */} + + {mode === 'daily' ? ( + // 日K线图 + tradeData.length > 0 ? ( + + + + ) : ( + + ) + ) : ( + // 分钟K线图 + minuteLoading ? ( +
+ + + + 加载分钟频数据中... + + +
+ ) : hasMinuteData ? ( + + + + ) : ( + + ) + )} +
+
+ ); +}; + +export default KLineModule; diff --git a/src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/index.tsx b/src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/index.tsx new file mode 100644 index 00000000..745e0a25 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/index.tsx @@ -0,0 +1,51 @@ +// src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/index.tsx +// 交易数据面板 - K线模块(日K/分钟切换) + +import React from 'react'; + +import KLineModule from './KLineModule'; +import type { Theme, TradeDayData, MinuteData, RiseAnalysis } from '../../../types'; + +export interface TradeDataPanelProps { + theme: Theme; + tradeData: TradeDayData[]; + minuteData: MinuteData | null; + minuteLoading: boolean; + analysisMap: Record; + onLoadMinuteData: () => void; + onChartClick: (params: { seriesName?: string; data?: [number, number] }) => void; + selectedPeriod?: number; + onPeriodChange?: (period: number) => void; +} + +const TradeDataPanel: React.FC = ({ + theme, + tradeData, + minuteData, + minuteLoading, + analysisMap, + onLoadMinuteData, + onChartClick, + selectedPeriod, + onPeriodChange, +}) => { + return ( + + ); +}; + +export default TradeDataPanel; + +// 导出子组件供外部按需使用 +export { default as KLineModule } from './KLineModule'; +export type { KLineModuleProps } from './KLineModule'; diff --git a/src/views/Company/components/MarketDataView/components/panels/UnusualPanel.tsx b/src/views/Company/components/MarketDataView/components/panels/UnusualPanel.tsx new file mode 100644 index 00000000..4a0afe76 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/panels/UnusualPanel.tsx @@ -0,0 +1,220 @@ +// src/views/Company/components/MarketDataView/components/panels/UnusualPanel.tsx +// 龙虎榜面板 - 黑金主题 + +import React from 'react'; +import { + Box, + Text, + Center, + VStack, + HStack, + Grid, + Heading, +} from '@chakra-ui/react'; + +import { formatNumber } from '../../utils/formatUtils'; +import { darkGoldTheme } from '../../constants'; +import type { Theme, UnusualData } from '../../types'; + +export interface UnusualPanelProps { + theme: Theme; + unusualData: UnusualData; +} + +// 黑金卡片样式 +const darkGoldCardStyle = { + bg: darkGoldTheme.bgCard, + border: '1px solid', + borderColor: darkGoldTheme.border, + borderRadius: 'xl', + 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)', + }, +}; + +// 黑金徽章样式 +const DarkGoldBadge: React.FC<{ children: React.ReactNode; variant?: 'red' | 'green' | 'gold' }> = ({ + children, + variant = 'gold', +}) => { + const colors = { + red: { bg: 'rgba(255, 68, 68, 0.15)', color: darkGoldTheme.red }, + green: { bg: 'rgba(0, 200, 81, 0.15)', color: darkGoldTheme.green }, + gold: { bg: 'rgba(212, 175, 55, 0.15)', color: darkGoldTheme.gold }, + }; + const style = colors[variant]; + + return ( + + {children} + + ); +}; + +const UnusualPanel: React.FC = ({ unusualData }) => { + return ( + + + + 龙虎榜数据 + + + + {unusualData?.grouped_data && unusualData.grouped_data.length > 0 ? ( + + {unusualData.grouped_data.map((dayData, idx) => ( + + + + {dayData.date} + + + + 买入: {formatNumber(dayData.total_buy)} + + + 卖出: {formatNumber(dayData.total_sell)} + + 0 ? 'red' : 'green'}> + 净额: {formatNumber(dayData.net_amount)} + + + + + + + + 买入前五 + + + {dayData.buyers && dayData.buyers.length > 0 ? ( + dayData.buyers.slice(0, 5).map((buyer, i) => ( + + + {buyer.dept_name} + + + {formatNumber(buyer.buy_amount)} + + + )) + ) : ( + + 暂无数据 + + )} + + + + + + 卖出前五 + + + {dayData.sellers && dayData.sellers.length > 0 ? ( + dayData.sellers.slice(0, 5).map((seller, i) => ( + + + {seller.dept_name} + + + {formatNumber(seller.sell_amount)} + + + )) + ) : ( + + 暂无数据 + + )} + + + + + {/* 信息类型标签 */} + + + 类型: + + {dayData.info_types?.map((type, i) => ( + + {type} + + ))} + + + ))} + + ) : ( +
+ 暂无龙虎榜数据 +
+ )} +
+
+ ); +}; + +export default UnusualPanel; diff --git a/src/views/Company/components/MarketDataView/components/panels/index.ts b/src/views/Company/components/MarketDataView/components/panels/index.ts new file mode 100644 index 00000000..988901c4 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/panels/index.ts @@ -0,0 +1,19 @@ +// src/views/Company/components/MarketDataView/components/panels/index.ts +// Panel 组件统一导出 + +export { default as TradeDataPanel } from './TradeDataPanel'; +export { default as FundingPanel } from './FundingPanel'; +export { default as BigDealPanel } from './BigDealPanel'; +export { default as UnusualPanel } from './UnusualPanel'; +export { default as PledgePanel } from './PledgePanel'; + +// 导出类型 +export type { TradeDataPanelProps } from './TradeDataPanel'; +export type { FundingPanelProps } from './FundingPanel'; +export type { BigDealPanelProps } from './BigDealPanel'; +export type { UnusualPanelProps } from './UnusualPanel'; +export type { PledgePanelProps } from './PledgePanel'; + +// 导出 TradeDataPanel 子组件 +export { KLineModule } from './TradeDataPanel'; +export type { KLineModuleProps } from './TradeDataPanel'; diff --git a/src/views/Company/components/MarketDataView/constants.ts b/src/views/Company/components/MarketDataView/constants.ts new file mode 100644 index 00000000..7e333490 --- /dev/null +++ b/src/views/Company/components/MarketDataView/constants.ts @@ -0,0 +1,78 @@ +// src/views/Company/components/MarketDataView/constants.ts +// MarketDataView 常量配置 + +import type { Theme } from './types'; + +/** + * 主题配置 + */ +export const themes: Record<'light', Theme> = { + light: { + // 日间模式 - 白+蓝 + primary: '#2B6CB0', + primaryDark: '#1E4E8C', + secondary: '#FFFFFF', + secondaryDark: '#F7FAFC', + success: '#FF4444', // 涨 - 红色 + danger: '#00C851', // 跌 - 绿色 + warning: '#FF9800', + info: '#00BCD4', + bgMain: '#F7FAFC', + bgCard: '#FFFFFF', + bgDark: '#EDF2F7', + textPrimary: '#2D3748', + textSecondary: '#4A5568', + textMuted: '#718096', + border: '#CBD5E0', + chartBg: '#FFFFFF', + }, +}; + +/** + * 黑金主题配置 - 用于 StockSummaryCard + */ +export const darkGoldTheme = { + // 背景 + bgCard: 'linear-gradient(135deg, #1a1a2e 0%, #0f0f1a 100%)', + bgCardHover: 'linear-gradient(135deg, #252540 0%, #1a1a2e 100%)', + + // 边框 + border: 'rgba(212, 175, 55, 0.3)', + borderHover: 'rgba(212, 175, 55, 0.6)', + + // 文字 + textPrimary: '#FFFFFF', + textSecondary: 'rgba(255, 255, 255, 0.85)', + textMuted: 'rgba(255, 255, 255, 0.6)', + + // 强调色 + gold: '#D4AF37', + goldLight: '#F4D03F', + orange: '#FF9500', + green: '#00C851', + red: '#FF4444', + + // 标签背景 + tagBg: 'rgba(212, 175, 55, 0.15)', + tagText: '#D4AF37', +}; + +/** + * 默认股票代码 + */ +export const DEFAULT_STOCK_CODE = '600000'; + +/** + * 默认时间周期(天) + */ +export const DEFAULT_PERIOD = 60; + +/** + * 时间周期选项 + */ +export const PERIOD_OPTIONS = [ + { value: 30, label: '30天' }, + { value: 60, label: '60天' }, + { value: 120, label: '120天' }, + { value: 250, label: '250天' }, +]; diff --git a/src/views/Company/components/MarketDataView/hooks/useMarketData.ts b/src/views/Company/components/MarketDataView/hooks/useMarketData.ts new file mode 100644 index 00000000..497502e9 --- /dev/null +++ b/src/views/Company/components/MarketDataView/hooks/useMarketData.ts @@ -0,0 +1,262 @@ +// src/views/Company/components/MarketDataView/hooks/useMarketData.ts +// MarketDataView 数据获取 Hook + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { logger } from '@utils/logger'; +import { marketService } from '../services/marketService'; +import { DEFAULT_PERIOD } from '../constants'; +import type { + MarketSummary, + TradeDayData, + FundingDayData, + BigDealData, + UnusualData, + PledgeData, + RiseAnalysis, + MinuteData, + UseMarketDataReturn, +} from '../types'; + +/** + * 市场数据获取 Hook + * @param stockCode 股票代码 + * @param period 时间周期(天数) + */ +export const useMarketData = ( + stockCode: string, + period: number = DEFAULT_PERIOD +): UseMarketDataReturn => { + // 主数据状态 + const [loading, setLoading] = useState(false); + const [tradeLoading, setTradeLoading] = useState(false); + const [summary, setSummary] = useState(null); + const [tradeData, setTradeData] = useState([]); + const [fundingData, setFundingData] = useState([]); + const [bigDealData, setBigDealData] = useState({ data: [], daily_stats: [] }); + const [unusualData, setUnusualData] = useState({ data: [], grouped_data: [] }); + const [pledgeData, setPledgeData] = useState([]); + const [analysisMap, setAnalysisMap] = useState>({}); + + // 分钟数据状态 + const [minuteData, setMinuteData] = useState(null); + const [minuteLoading, setMinuteLoading] = useState(false); + + // 记录是否已完成首次加载 + const isInitializedRef = useRef(false); + // 记录上一次的 stockCode,用于判断是否需要重新加载所有数据 + const prevStockCodeRef = useRef(stockCode); + // 记录上一次的 period,用于判断是否需要刷新交易数据 + const prevPeriodRef = useRef(period); + + /** + * 加载所有市场数据 + */ + const loadMarketData = useCallback(async () => { + if (!stockCode) return; + + logger.debug('useMarketData', '开始加载市场数据', { stockCode, period }); + setLoading(true); + + try { + const [ + summaryRes, + tradeRes, + fundingRes, + bigDealRes, + unusualRes, + pledgeRes, + riseAnalysisRes, + ] = await Promise.all([ + marketService.getMarketSummary(stockCode), + marketService.getTradeData(stockCode, period), + marketService.getFundingData(stockCode, 30), + marketService.getBigDealData(stockCode, 30), + marketService.getUnusualData(stockCode, 30), + marketService.getPledgeData(stockCode), + marketService.getRiseAnalysis(stockCode), + ]); + + // 设置概览数据 + if (summaryRes.success) { + setSummary(summaryRes.data); + } + + // 设置交易数据 + if (tradeRes.success) { + setTradeData(tradeRes.data); + } + + // 设置融资融券数据 + if (fundingRes.success) { + setFundingData(fundingRes.data); + } + + // 设置大宗交易数据(包含 daily_stats) + if (bigDealRes.success) { + setBigDealData(bigDealRes); + } + + // 设置龙虎榜数据(包含 grouped_data) + if (unusualRes.success) { + setUnusualData(unusualRes); + } + + // 设置股权质押数据 + if (pledgeRes.success) { + setPledgeData(pledgeRes.data); + } + + // 设置涨幅分析数据并创建映射 + if (riseAnalysisRes.success) { + const tempAnalysisMap: Record = {}; + + if (tradeRes.success && tradeRes.data && riseAnalysisRes.data) { + riseAnalysisRes.data.forEach((analysis) => { + const dateIndex = tradeRes.data.findIndex( + (item) => item.date.substring(0, 10) === analysis.trade_date + ); + if (dateIndex !== -1) { + tempAnalysisMap[dateIndex] = analysis; + } + }); + } + + setAnalysisMap(tempAnalysisMap); + } + + logger.info('useMarketData', '市场数据加载成功', { stockCode }); + } catch (error) { + logger.error('useMarketData', 'loadMarketData', error, { stockCode, period }); + } finally { + setLoading(false); + } + }, [stockCode, period]); + + /** + * 加载分钟K线数据 + */ + const loadMinuteData = useCallback(async () => { + if (!stockCode) return; + + logger.debug('useMarketData', '开始加载分钟频数据', { stockCode }); + setMinuteLoading(true); + + try { + const data = await marketService.getMinuteData(stockCode); + setMinuteData(data); + + if (data.data && data.data.length > 0) { + logger.info('useMarketData', '分钟频数据加载成功', { + stockCode, + dataPoints: data.data.length, + }); + } else { + logger.warn('useMarketData', '分钟频数据为空', { stockCode }); + } + } catch (error) { + logger.error('useMarketData', 'loadMinuteData', error, { stockCode }); + setMinuteData({ + data: [], + code: stockCode, + name: '', + trade_date: '', + type: 'minute', + }); + } finally { + setMinuteLoading(false); + } + }, [stockCode]); + + /** + * 单独刷新日K线数据(只刷新交易数据和涨幅分析) + * 用于切换时间周期时,避免重新加载所有数据 + */ + const refreshTradeData = useCallback(async () => { + if (!stockCode) return; + + logger.debug('useMarketData', '刷新日K线数据', { stockCode, period }); + setTradeLoading(true); + + try { + // 并行获取交易数据和涨幅分析 + const [tradeRes, riseAnalysisRes] = await Promise.all([ + marketService.getTradeData(stockCode, period), + marketService.getRiseAnalysis(stockCode), + ]); + + // 更新交易数据 + if (tradeRes.success && tradeRes.data) { + setTradeData(tradeRes.data); + + // 重建涨幅分析映射 + if (riseAnalysisRes.success && riseAnalysisRes.data) { + const tempAnalysisMap: Record = {}; + riseAnalysisRes.data.forEach((analysis) => { + const dateIndex = tradeRes.data.findIndex( + (item) => item.date.substring(0, 10) === analysis.trade_date + ); + if (dateIndex !== -1) { + tempAnalysisMap[dateIndex] = analysis; + } + }); + setAnalysisMap(tempAnalysisMap); + } + } + + logger.info('useMarketData', '日K线数据刷新成功', { stockCode, period }); + } catch (error) { + logger.error('useMarketData', 'refreshTradeData', error, { stockCode, period }); + } finally { + setTradeLoading(false); + } + }, [stockCode, period]); + + /** + * 刷新所有数据 + */ + const refetch = useCallback(async () => { + await Promise.all([loadMarketData(), loadMinuteData()]); + }, [loadMarketData, loadMinuteData]); + + // 监听股票代码变化,加载所有数据(首次加载或切换股票) + useEffect(() => { + if (stockCode) { + // stockCode 变化时,加载所有数据 + if (stockCode !== prevStockCodeRef.current || !isInitializedRef.current) { + loadMarketData(); + loadMinuteData(); + prevStockCodeRef.current = stockCode; + prevPeriodRef.current = period; // 同步重置 period ref,避免切换股票后误触发 refreshTradeData + isInitializedRef.current = true; + } + } + }, [stockCode, period, loadMarketData, loadMinuteData]); + + // 监听时间周期变化,只刷新日K线数据 + useEffect(() => { + // 只有在已初始化后,且 period 真正变化时才单独刷新交易数据 + if (stockCode && isInitializedRef.current && period !== prevPeriodRef.current) { + refreshTradeData(); + prevPeriodRef.current = period; + } + }, [period, refreshTradeData, stockCode]); + + return { + loading, + tradeLoading, + summary, + tradeData, + fundingData, + bigDealData, + unusualData, + pledgeData, + minuteData, + minuteLoading, + analysisMap, + refetch, + loadMinuteData, + refreshTradeData, + }; +}; + +export default useMarketData; diff --git a/src/views/Company/components/MarketDataView/index.tsx b/src/views/Company/components/MarketDataView/index.tsx new file mode 100644 index 00000000..6f688747 --- /dev/null +++ b/src/views/Company/components/MarketDataView/index.tsx @@ -0,0 +1,188 @@ +// src/views/Company/components/MarketDataView/index.tsx +// MarketDataView 主组件 - 股票市场数据综合展示 + +import React, { useState, useEffect, ReactNode, useMemo, useCallback } from 'react'; +import { + Box, + Container, + VStack, + useDisclosure, +} from '@chakra-ui/react'; +import { + Unlock, + ArrowUp, + Star, + Lock, +} from 'lucide-react'; + +// 通用组件 +import SubTabContainer from '@components/SubTabContainer'; +import type { SubTabConfig } from '@components/SubTabContainer'; + +// 内部模块导入 +import { themes, DEFAULT_PERIOD } from './constants'; +import { useMarketData } from './hooks/useMarketData'; +import { + ThemedCard, + StockSummaryCard, + AnalysisModal, + AnalysisContent, +} from './components'; +import { + TradeDataPanel, + FundingPanel, + BigDealPanel, + UnusualPanel, + PledgePanel, +} from './components/panels'; +import LoadingState from '../LoadingState'; +import type { MarketDataViewProps, RiseAnalysis } from './types'; + +/** + * MarketDataView 主组件 + * 展示股票的市场数据:交易数据、融资融券、大宗交易、龙虎榜、股权质押 + */ +const MarketDataView: React.FC = ({ stockCode: propStockCode }) => { + const { isOpen, onOpen, onClose } = useDisclosure(); + const [modalContent, setModalContent] = useState(null); + + // 获取当前主题 + const theme = themes.light; + + // 状态管理 + const [stockCode, setStockCode] = useState(propStockCode || '600000'); + const [activeTab, setActiveTab] = useState(0); + const [selectedPeriod, setSelectedPeriod] = useState(DEFAULT_PERIOD); + + // 使用自定义 Hook 获取数据 + const { + loading, + summary, + tradeData, + fundingData, + bigDealData, + unusualData, + pledgeData, + minuteData, + minuteLoading, + analysisMap, + refetch, + loadMinuteData, + } = useMarketData(stockCode, selectedPeriod); + + // 监听 props 中的 stockCode 变化 + useEffect(() => { + if (propStockCode && propStockCode !== stockCode) { + setStockCode(propStockCode); + } + }, [propStockCode, stockCode]); + + // 处理图表点击事件 + const handleChartClick = useCallback( + (params: { seriesName?: string; data?: [number, number] }) => { + if (params.seriesName === '涨幅分析' && params.data) { + const dataIndex = params.data[0]; + const analysis = analysisMap[dataIndex]; + + if (analysis) { + setModalContent(); + onOpen(); + } + } + }, + [analysisMap, theme, onOpen] + ); + + // Tab 配置 - 使用通用 SubTabContainer(不含交易数据,交易数据单独显示在上方) + const tabConfigs: SubTabConfig[] = [ + { key: 'funding', name: '融资融券', icon: Unlock, component: FundingPanel }, + { key: 'bigDeal', name: '大宗交易', icon: ArrowUp, component: BigDealPanel }, + { key: 'unusual', name: '龙虎榜', icon: Star, component: UnusualPanel }, + { key: 'pledge', name: '股权质押', icon: Lock, component: PledgePanel }, + ]; + + // 传递给 Tab 组件的 props + const componentProps = useMemo( + () => ({ + theme, + tradeData, + minuteData, + minuteLoading, + analysisMap, + onLoadMinuteData: loadMinuteData, + onChartClick: handleChartClick, + selectedPeriod, + onPeriodChange: setSelectedPeriod, + fundingData, + bigDealData, + unusualData, + pledgeData, + }), + [ + theme, + tradeData, + minuteData, + minuteLoading, + analysisMap, + loadMinuteData, + handleChartClick, + selectedPeriod, + fundingData, + bigDealData, + unusualData, + pledgeData, + ] + ); + + return ( + + + + {/* 股票概览 */} + {summary && } + + {/* 交易数据 - 日K/分钟K线(独立显示在 Tab 上方) */} + {!loading && ( + + )} + + {/* 主要内容区域 - Tab */} + {loading ? ( + + + + ) : ( + setActiveTab(index)} + isLazy + /> + )} + + + + {/* 涨幅分析模态框 */} + + + ); +}; + +export default MarketDataView; diff --git a/src/views/Company/components/MarketDataView/services/marketService.ts b/src/views/Company/components/MarketDataView/services/marketService.ts new file mode 100644 index 00000000..c0a805b0 --- /dev/null +++ b/src/views/Company/components/MarketDataView/services/marketService.ts @@ -0,0 +1,141 @@ +// src/views/Company/components/MarketDataView/services/marketService.ts +// MarketDataView API 服务层 + +import axios from '@utils/axiosConfig'; +import { logger } from '@utils/logger'; +import type { + MarketSummary, + TradeDayData, + FundingDayData, + BigDealData, + UnusualData, + PledgeData, + RiseAnalysis, + MinuteData, +} from '../types'; + +/** + * API 响应包装类型 + */ +interface ApiResponse { + success: boolean; + data: T; + message?: string; +} + +/** + * 市场数据服务 + */ +export const marketService = { + /** + * 获取市场概览数据 + * @param stockCode 股票代码 + */ + async getMarketSummary(stockCode: string): Promise> { + const { data } = await axios.get>(`/api/market/summary/${stockCode}`); + return data; + }, + + /** + * 获取交易日数据 + * @param stockCode 股票代码 + * @param days 天数,默认 60 天 + */ + async getTradeData(stockCode: string, days: number = 60): Promise> { + const { data } = await axios.get>(`/api/market/trade/${stockCode}?days=${days}`); + return data; + }, + + /** + * 获取融资融券数据 + * @param stockCode 股票代码 + * @param days 天数,默认 30 天 + */ + async getFundingData(stockCode: string, days: number = 30): Promise> { + const { data } = await axios.get>(`/api/market/funding/${stockCode}?days=${days}`); + return data; + }, + + /** + * 获取大宗交易数据 + * @param stockCode 股票代码 + * @param days 天数,默认 30 天 + */ + async getBigDealData(stockCode: string, days: number = 30): Promise { + const { data } = await axios.get(`/api/market/bigdeal/${stockCode}?days=${days}`); + return data; + }, + + /** + * 获取龙虎榜数据 + * @param stockCode 股票代码 + * @param days 天数,默认 30 天 + */ + async getUnusualData(stockCode: string, days: number = 30): Promise { + const { data } = await axios.get(`/api/market/unusual/${stockCode}?days=${days}`); + return data; + }, + + /** + * 获取股权质押数据 + * @param stockCode 股票代码 + */ + async getPledgeData(stockCode: string): Promise> { + const { data } = await axios.get>(`/api/market/pledge/${stockCode}`); + return data; + }, + + /** + * 获取涨幅分析数据 + * @param stockCode 股票代码 + * @param startDate 开始日期(可选) + * @param endDate 结束日期(可选) + */ + async getRiseAnalysis( + stockCode: string, + startDate?: string, + endDate?: string + ): Promise> { + let url = `/api/market/rise-analysis/${stockCode}`; + if (startDate && endDate) { + url += `?start_date=${startDate}&end_date=${endDate}`; + } + const { data } = await axios.get>(url); + return data; + }, + + /** + * 获取分钟K线数据 + * @param stockCode 股票代码 + */ + async getMinuteData(stockCode: string): Promise { + try { + const { data } = await axios.get(`/api/stock/${stockCode}/latest-minute`); + + if (data.data && Array.isArray(data.data)) { + return data; + } + + // 返回空数据结构 + return { + data: [], + code: stockCode, + name: '', + trade_date: '', + type: 'minute', + }; + } catch (error) { + logger.error('marketService', 'getMinuteData', error, { stockCode }); + // 返回空数据结构 + return { + data: [], + code: stockCode, + name: '', + trade_date: '', + type: 'minute', + }; + } + }, +}; + +export default marketService; diff --git a/src/views/Company/components/MarketDataView/types.ts b/src/views/Company/components/MarketDataView/types.ts new file mode 100644 index 00000000..9087c156 --- /dev/null +++ b/src/views/Company/components/MarketDataView/types.ts @@ -0,0 +1,372 @@ +// src/views/Company/components/MarketDataView/types.ts +// MarketDataView 组件类型定义 + +import type { ReactNode } from 'react'; + +/** + * 主题配置类型 + */ +export interface Theme { + primary: string; + primaryDark: string; + secondary: string; + secondaryDark: string; + success: string; // 涨色 - 红色 + danger: string; // 跌色 - 绿色 + warning: string; + info: string; + bgMain: string; + bgCard: string; + bgDark: string; + textPrimary: string; + textSecondary: string; + textMuted: string; + border: string; + chartBg: string; +} + +/** + * 交易日数据 + */ +export interface TradeDayData { + date: string; + open: number; + close: number; + high: number; + low: number; + volume: number; + amount: number; + change_percent: number; + turnover_rate?: number; + pe_ratio?: number; +} + +/** + * 分钟K线数据点 + */ +export interface MinuteDataPoint { + time: string; + open: number; + close: number; + high: number; + low: number; + volume: number; + amount: number; +} + +/** + * 分钟K线数据 + */ +export interface MinuteData { + data: MinuteDataPoint[]; + code: string; + name: string; + trade_date: string; + type: string; +} + +/** + * 融资数据 + */ +export interface FinancingInfo { + balance: number; + buy: number; + repay: number; +} + +/** + * 融券数据 + */ +export interface SecuritiesInfo { + balance: number; + balance_amount: number; + sell: number; + repay: number; +} + +/** + * 融资融券日数据 + */ +export interface FundingDayData { + date: string; + financing: FinancingInfo; + securities: SecuritiesInfo; +} + +/** + * 大宗交易明细 + */ +export interface BigDealItem { + buyer_dept?: string; + seller_dept?: string; + price?: number; + volume?: number; + amount?: number; +} + +/** + * 大宗交易日统计 + */ +export interface BigDealDayStats { + date: string; + count: number; + total_volume: number; + total_amount: number; + avg_price?: number; + deals?: BigDealItem[]; +} + +/** + * 大宗交易数据 + */ +export interface BigDealData { + success?: boolean; + data: BigDealItem[]; + daily_stats: BigDealDayStats[]; +} + +/** + * 龙虎榜买卖方 + */ +export interface UnusualTrader { + dept_name: string; + buy_amount?: number; + sell_amount?: number; +} + +/** + * 龙虎榜日数据 + */ +export interface UnusualDayData { + date: string; + total_buy: number; + total_sell: number; + net_amount: number; + buyers?: UnusualTrader[]; + sellers?: UnusualTrader[]; + info_types?: string[]; +} + +/** + * 龙虎榜数据 + */ +export interface UnusualData { + success?: boolean; + data: unknown[]; + grouped_data: UnusualDayData[]; +} + +/** + * 股权质押数据 + */ +export interface PledgeData { + end_date: string; + unrestricted_pledge: number; + restricted_pledge: number; + total_pledge: number; + total_shares: number; + pledge_ratio: number; + pledge_count: number; +} + +/** + * 最新交易数据 + */ +export interface LatestTrade { + close: number; + change_percent: number; + volume: number; + amount: number; + turnover_rate: number; + pe_ratio?: number; +} + +/** + * 最新融资融券数据 + */ +export interface LatestFunding { + financing_balance: number; + securities_balance: number; +} + +/** + * 最新质押数据 + */ +export interface LatestPledge { + pledge_ratio: number; +} + +/** + * 市场概览数据 + */ +export interface MarketSummary { + stock_code: string; + stock_name: string; + latest_trade?: LatestTrade; + latest_funding?: LatestFunding; + latest_pledge?: LatestPledge; +} + +/** + * 涨幅分析研报 + */ +export interface VerificationReport { + publisher?: string; + match_score?: string; + match_ratio?: number; + declare_date?: string; + report_title?: string; + author?: string; + verification_item?: string; + content?: string; +} + +/** + * 涨幅分析数据 + */ +export interface RiseAnalysis { + stock_code: string; + stock_name: string; + trade_date: string; + rise_rate: number; + close_price: number; + volume: number; + amount: number; + main_business?: string; + rise_reason_brief?: string; + rise_reason_detail?: string; + announcements?: string; + verification_reports?: VerificationReport[]; + update_time?: string; + create_time?: string; +} + +/** + * MarketDataView 组件 Props + */ +export interface MarketDataViewProps { + stockCode?: string; +} + +/** + * ThemedCard 组件 Props + */ +export interface ThemedCardProps { + children: ReactNode; + theme: Theme; + [key: string]: unknown; +} + +/** + * MarkdownRenderer 组件 Props + */ +export interface MarkdownRendererProps { + children: string; + theme: Theme; +} + +/** + * StockSummaryCard 组件 Props + */ +export interface StockSummaryCardProps { + summary: MarketSummary; + theme?: Theme; // 可选,StockSummaryCard 使用内置黑金主题 +} + +/** + * TradeDataTab 组件 Props + */ +export interface TradeDataTabProps { + theme: Theme; + tradeData: TradeDayData[]; + minuteData: MinuteData | null; + minuteLoading: boolean; + analysisMap: Record; + onLoadMinuteData: () => void; + onAnalysisClick: (analysis: RiseAnalysis) => void; +} + +/** + * KLineModule 组件 Props(日K/分钟K线切换模块) + */ +export interface KLineModuleProps { + theme: Theme; + tradeData: TradeDayData[]; + minuteData: MinuteData | null; + minuteLoading: boolean; + analysisMap: Record; + onLoadMinuteData: () => void; + onChartClick: (params: { seriesName?: string; data?: [number, number] }) => void; + selectedPeriod?: number; + onPeriodChange?: (period: number) => void; +} + +/** + * FundingTab 组件 Props + */ +export interface FundingTabProps { + theme: Theme; + fundingData: FundingDayData[]; +} + +/** + * BigDealTab 组件 Props + */ +export interface BigDealTabProps { + theme: Theme; + bigDealData: BigDealData; +} + +/** + * UnusualTab 组件 Props + */ +export interface UnusualTabProps { + theme: Theme; + unusualData: UnusualData; +} + +/** + * PledgeTab 组件 Props + */ +export interface PledgeTabProps { + theme: Theme; + pledgeData: PledgeData[]; +} + +/** + * AnalysisModal 组件 Props + */ +export interface AnalysisModalProps { + isOpen: boolean; + onClose: () => void; + content: ReactNode; + theme: Theme; +} + +/** + * AnalysisModalContent 组件 Props + */ +export interface AnalysisModalContentProps { + analysis: RiseAnalysis; + theme: Theme; +} + +/** + * useMarketData Hook 返回值 + */ +export interface UseMarketDataReturn { + loading: boolean; + tradeLoading: boolean; + summary: MarketSummary | null; + tradeData: TradeDayData[]; + fundingData: FundingDayData[]; + bigDealData: BigDealData; + unusualData: UnusualData; + pledgeData: PledgeData[]; + minuteData: MinuteData | null; + minuteLoading: boolean; + analysisMap: Record; + refetch: () => Promise; + loadMinuteData: () => Promise; + refreshTradeData: () => Promise; +} diff --git a/src/views/Company/components/MarketDataView/utils/chartOptions.ts b/src/views/Company/components/MarketDataView/utils/chartOptions.ts new file mode 100644 index 00000000..5c20b2a4 --- /dev/null +++ b/src/views/Company/components/MarketDataView/utils/chartOptions.ts @@ -0,0 +1,1411 @@ +// src/views/Company/components/MarketDataView/utils/chartOptions.ts +// MarketDataView ECharts 图表配置生成器 + +import type { EChartsOption } from 'echarts'; +import type { + Theme, + TradeDayData, + MinuteData, + FundingDayData, + PledgeData, + RiseAnalysis, +} from '../types'; +import { formatNumber } from './formatUtils'; + +/** + * 计算移动平均线 + * @param data 收盘价数组 + * @param period 周期 + */ +export const calculateMA = (data: number[], period: number): (number | null)[] => { + const result: (number | null)[] = []; + for (let i = 0; i < data.length; i++) { + if (i < period - 1) { + result.push(null); + continue; + } + let sum = 0; + for (let j = 0; j < period; j++) { + sum += data[i - j]; + } + result.push(sum / period); + } + return result; +}; + +/** + * 生成日K线图配置 + */ +export const getKLineOption = ( + theme: Theme, + tradeData: TradeDayData[], + analysisMap: Record +): EChartsOption => { + if (!tradeData || tradeData.length === 0) return {}; + + const dates = tradeData.map((item) => item.date.substring(5, 10)); + const kData = tradeData.map((item) => [item.open, item.close, item.low, item.high]); + const volumes = tradeData.map((item) => item.volume); + const closePrices = tradeData.map((item) => item.close); + const ma5 = calculateMA(closePrices, 5); + const ma10 = calculateMA(closePrices, 10); + const ma20 = calculateMA(closePrices, 20); + + // 创建涨幅分析标记点 + const scatterData: [number, number][] = []; + Object.keys(analysisMap).forEach((dateIndex) => { + const idx = parseInt(dateIndex); + if (tradeData[idx]) { + const value = tradeData[idx].high * 1.02; + scatterData.push([idx, value]); + } + }); + + return { + backgroundColor: theme.chartBg, + animation: true, + legend: { + data: ['K线', 'MA5', 'MA10', 'MA20'], + top: 10, + textStyle: { + color: theme.textPrimary, + }, + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'cross', + lineStyle: { + color: theme.primary, + width: 1, + opacity: 0.8, + }, + }, + backgroundColor: 'rgba(255,255,255,0.9)', + borderColor: theme.primary, + borderWidth: 1, + textStyle: { + color: theme.textPrimary, + }, + }, + xAxis: [ + { + type: 'category', + data: dates, + boundaryGap: false, + axisLine: { lineStyle: { color: theme.textMuted } }, + axisLabel: { color: theme.textMuted }, + }, + { + type: 'category', + gridIndex: 1, + data: dates, + boundaryGap: false, + axisLine: { onZero: false, lineStyle: { color: theme.textMuted } }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { show: false }, + }, + ], + yAxis: [ + { + scale: true, + splitLine: { + show: true, + lineStyle: { + color: theme.border, + }, + }, + axisLine: { lineStyle: { color: theme.textMuted } }, + axisLabel: { color: theme.textMuted }, + }, + { + scale: true, + gridIndex: 1, + splitNumber: 2, + axisLabel: { show: false }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + }, + ], + grid: [ + { + left: '3%', + right: '3%', + height: '50%', + containLabel: true, + }, + { + left: '3%', + right: '3%', + top: '65%', + height: '20%', + containLabel: true, + }, + ], + series: [ + { + name: 'K线', + type: 'candlestick', + data: kData, + itemStyle: { + color: theme.success, + color0: theme.danger, + borderColor: theme.success, + borderColor0: theme.danger, + }, + }, + { + name: 'MA5', + type: 'line', + data: ma5, + smooth: true, + lineStyle: { + color: theme.primary, + width: 1, + }, + itemStyle: { + color: theme.primary, + }, + }, + { + name: 'MA10', + type: 'line', + data: ma10, + smooth: true, + lineStyle: { + color: theme.info, + width: 1, + }, + itemStyle: { + color: theme.info, + }, + }, + { + name: 'MA20', + type: 'line', + data: ma20, + smooth: true, + lineStyle: { + color: theme.warning, + width: 1, + }, + itemStyle: { + color: theme.warning, + }, + }, + { + name: '涨幅分析', + type: 'scatter', + data: scatterData, + symbolSize: 30, + symbol: 'pin', + itemStyle: { + color: '#FFD700', + shadowBlur: 10, + shadowColor: 'rgba(255, 215, 0, 0.5)', + }, + label: { + show: true, + formatter: '★', + fontSize: 20, + position: 'inside', + color: '#FF6B6B', + }, + emphasis: { + scale: 1.5, + itemStyle: { + color: '#FFA500', + }, + }, + z: 100, + }, + { + name: '成交量', + type: 'bar', + xAxisIndex: 1, + yAxisIndex: 1, + data: volumes, + itemStyle: { + color: (params: { dataIndex: number }) => { + const item = tradeData[params.dataIndex]; + return item.change_percent >= 0 + ? 'rgba(255, 68, 68, 0.6)' + : 'rgba(0, 200, 81, 0.6)'; + }, + }, + }, + ], + }; +}; + +/** + * 生成分钟K线图配置 + */ +export const getMinuteKLineOption = (theme: Theme, minuteData: MinuteData | null): EChartsOption => { + if (!minuteData || !minuteData.data || minuteData.data.length === 0) return {}; + + const times = minuteData.data.map((item) => item.time); + const kData = minuteData.data.map((item) => [item.open, item.close, item.low, item.high]); + const volumes = minuteData.data.map((item) => item.volume); + const closePrices = minuteData.data.map((item) => item.close); + const avgPrice = calculateMA(closePrices, 5); + + const openPrice = minuteData.data.length > 0 ? minuteData.data[0].open : 0; + + return { + backgroundColor: theme.chartBg, + title: { + text: `${minuteData.name} 分钟K线 (${minuteData.trade_date})`, + left: 'center', + textStyle: { + color: theme.textPrimary, + fontSize: 16, + fontWeight: 'bold', + }, + subtextStyle: { + color: theme.textMuted, + }, + }, + tooltip: { + trigger: 'axis', + axisPointer: { type: 'cross' }, + backgroundColor: 'rgba(255,255,255,0.95)', + borderColor: theme.primary, + borderWidth: 1, + textStyle: { + color: theme.textPrimary, + fontSize: 12, + }, + formatter: (params: unknown) => { + const paramsArr = params as { name: string; marker: string; seriesName: string; data: number[] | number; value: number }[]; + let result = paramsArr[0].name + '
'; + paramsArr.forEach((param) => { + if (param.seriesName === '分钟K线') { + const [open, close, , high] = param.data as number[]; + const low = (param.data as number[])[2]; + const changePercent = + openPrice > 0 ? (((close - openPrice) / openPrice) * 100).toFixed(2) : '0.00'; + result += `${param.marker} ${param.seriesName}
`; + result += `开盘: ${open.toFixed(2)}
`; + result += `收盘: ${close.toFixed(2)}
`; + result += `最高: ${high.toFixed(2)}
`; + result += `最低: ${low.toFixed(2)}
`; + result += `涨跌: ${changePercent}%
`; + } else if (param.seriesName === '均价线') { + result += `${param.marker} ${param.seriesName}: ${(param.value as number).toFixed(2)}
`; + } else if (param.seriesName === '成交量') { + result += `${param.marker} ${param.seriesName}: ${formatNumber(param.value as number, 0)}
`; + } + }); + return result; + }, + }, + legend: { + data: ['分钟K线', '均价线', '成交量'], + top: 35, + textStyle: { + color: theme.textPrimary, + fontSize: 12, + }, + itemWidth: 25, + itemHeight: 14, + }, + grid: [ + { + left: '3%', + right: '3%', + top: '20%', + height: '60%', + containLabel: true, + }, + { + left: '3%', + right: '3%', + top: '83%', + height: '12%', + containLabel: true, + }, + ], + xAxis: [ + { + type: 'category', + data: times, + boundaryGap: false, + axisLine: { lineStyle: { color: theme.textMuted } }, + axisLabel: { + color: theme.textMuted, + fontSize: 10, + interval: 'auto', + }, + splitLine: { show: false }, + }, + { + type: 'category', + gridIndex: 1, + data: times, + boundaryGap: false, + axisLine: { lineStyle: { color: theme.textMuted } }, + axisLabel: { + color: theme.textMuted, + fontSize: 10, + }, + splitLine: { show: false }, + }, + ], + yAxis: [ + { + scale: true, + axisLine: { lineStyle: { color: theme.textMuted } }, + axisLabel: { color: theme.textMuted, fontSize: 10 }, + splitLine: { + lineStyle: { + color: theme.border, + type: 'dashed', + }, + }, + }, + { + gridIndex: 1, + scale: true, + axisLine: { lineStyle: { color: theme.textMuted } }, + axisLabel: { color: theme.textMuted, fontSize: 10 }, + splitLine: { show: false }, + }, + ], + dataZoom: [ + { + type: 'inside', + xAxisIndex: [0, 1], + start: 70, + end: 100, + minValueSpan: 20, + }, + { + show: true, + xAxisIndex: [0, 1], + type: 'slider', + top: '95%', + start: 70, + end: 100, + height: 20, + handleSize: '100%', + handleStyle: { + color: theme.primary, + }, + textStyle: { + color: theme.textMuted, + }, + }, + ], + series: [ + { + name: '分钟K线', + type: 'candlestick', + data: kData, + itemStyle: { + color: theme.success, + color0: theme.danger, + borderColor: theme.success, + borderColor0: theme.danger, + borderWidth: 1, + }, + barWidth: '60%', + }, + { + name: '均价线', + type: 'line', + data: avgPrice, + smooth: true, + symbol: 'none', + lineStyle: { + color: theme.info, + width: 2, + opacity: 0.8, + }, + }, + { + name: '成交量', + type: 'bar', + xAxisIndex: 1, + yAxisIndex: 1, + data: volumes, + barWidth: '50%', + itemStyle: { + color: (params: { dataIndex: number }) => { + const item = minuteData.data[params.dataIndex]; + return item.close >= item.open + ? 'rgba(255, 68, 68, 0.6)' + : 'rgba(0, 200, 81, 0.6)'; + }, + }, + }, + ], + }; +}; + +/** + * 生成日K线图配置 - 黑金主题 + */ +export const getKLineDarkGoldOption = ( + tradeData: TradeDayData[], + analysisMap: Record +): EChartsOption => { + if (!tradeData || tradeData.length === 0) return {}; + + // 黑金主题色 + const gold = '#D4AF37'; + const goldLight = '#F4D03F'; + const orange = '#FF9500'; + const red = '#FF4444'; + const green = '#00C851'; + const textColor = 'rgba(255, 255, 255, 0.85)'; + const textMuted = 'rgba(255, 255, 255, 0.5)'; + const borderColor = 'rgba(212, 175, 55, 0.2)'; + + const dates = tradeData.map((item) => item.date.substring(5, 10)); + const kData = tradeData.map((item) => [item.open, item.close, item.low, item.high]); + const volumes = tradeData.map((item) => item.volume); + const closePrices = tradeData.map((item) => item.close); + const ma5 = calculateMA(closePrices, 5); + const ma10 = calculateMA(closePrices, 10); + const ma20 = calculateMA(closePrices, 20); + + // 创建涨幅分析标记点 + const scatterData: [number, number][] = []; + Object.keys(analysisMap).forEach((dateIndex) => { + const idx = parseInt(dateIndex); + if (tradeData[idx]) { + const value = tradeData[idx].high * 1.02; + scatterData.push([idx, value]); + } + }); + + return { + backgroundColor: 'transparent', + animation: true, + legend: { + data: ['K线', 'MA5', 'MA10', 'MA20'], + top: 5, + left: 'center', + textStyle: { + color: textColor, + }, + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'cross', + lineStyle: { + color: gold, + width: 1, + opacity: 0.8, + }, + }, + backgroundColor: 'rgba(26, 26, 46, 0.95)', + borderColor: gold, + borderWidth: 1, + textStyle: { + color: textColor, + }, + }, + xAxis: [ + { + type: 'category', + data: dates, + boundaryGap: false, + axisLine: { lineStyle: { color: borderColor } }, + axisLabel: { color: textMuted }, + splitLine: { show: false }, + }, + { + type: 'category', + gridIndex: 1, + data: dates, + boundaryGap: false, + axisLine: { onZero: false, lineStyle: { color: borderColor } }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { show: false }, + }, + ], + yAxis: [ + { + scale: true, + splitLine: { + show: true, + lineStyle: { + color: borderColor, + type: 'dashed', + }, + }, + axisLine: { lineStyle: { color: borderColor } }, + axisLabel: { color: textMuted }, + }, + { + scale: true, + gridIndex: 1, + splitNumber: 2, + axisLabel: { show: false }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + }, + ], + grid: [ + { + left: '3%', + right: '3%', + top: '8%', + height: '55%', + containLabel: true, + }, + { + left: '3%', + right: '3%', + top: '68%', + height: '28%', + containLabel: true, + }, + ], + series: [ + { + name: 'K线', + type: 'candlestick', + data: kData, + itemStyle: { + color: red, + color0: green, + borderColor: red, + borderColor0: green, + }, + }, + { + name: 'MA5', + type: 'line', + data: ma5, + smooth: true, + lineStyle: { + color: gold, + width: 1, + }, + itemStyle: { + color: gold, + }, + }, + { + name: 'MA10', + type: 'line', + data: ma10, + smooth: true, + lineStyle: { + color: goldLight, + width: 1, + }, + itemStyle: { + color: goldLight, + }, + }, + { + name: 'MA20', + type: 'line', + data: ma20, + smooth: true, + lineStyle: { + color: orange, + width: 1, + }, + itemStyle: { + color: orange, + }, + }, + { + name: '涨幅分析', + type: 'scatter', + data: scatterData, + symbolSize: [80, 36], + symbol: 'roundRect', + itemStyle: { + color: 'rgba(26, 26, 46, 0.9)', + borderColor: gold, + borderWidth: 1, + shadowBlur: 8, + shadowColor: 'rgba(212, 175, 55, 0.4)', + }, + label: { + show: true, + formatter: '涨幅分析\n(点击查看)', + fontSize: 10, + lineHeight: 12, + position: 'inside', + color: gold, + fontWeight: 'bold', + }, + emphasis: { + scale: false, + itemStyle: { + borderColor: goldLight, + borderWidth: 2, + }, + }, + z: 100, + }, + { + name: '成交量', + type: 'bar', + xAxisIndex: 1, + yAxisIndex: 1, + data: volumes, + itemStyle: { + color: (params: { dataIndex: number }) => { + const item = tradeData[params.dataIndex]; + return item.change_percent >= 0 + ? 'rgba(255, 68, 68, 0.6)' + : 'rgba(0, 200, 81, 0.6)'; + }, + }, + }, + ], + }; +}; + +/** + * 生成分钟K线图配置 - 黑金主题 + */ +export const getMinuteKLineDarkGoldOption = (minuteData: MinuteData | null): EChartsOption => { + if (!minuteData || !minuteData.data || minuteData.data.length === 0) return {}; + + // 黑金主题色 + const gold = '#D4AF37'; + const goldLight = '#F4D03F'; + const orange = '#FF9500'; + const red = '#FF4444'; + const green = '#00C851'; + const textColor = 'rgba(255, 255, 255, 0.85)'; + const textMuted = 'rgba(255, 255, 255, 0.5)'; + const borderColor = 'rgba(212, 175, 55, 0.2)'; + + const times = minuteData.data.map((item) => item.time); + const kData = minuteData.data.map((item) => [item.open, item.close, item.low, item.high]); + const volumes = minuteData.data.map((item) => item.volume); + const closePrices = minuteData.data.map((item) => item.close); + const avgPrice = calculateMA(closePrices, 5); + + const openPrice = minuteData.data.length > 0 ? minuteData.data[0].open : 0; + + return { + backgroundColor: 'transparent', + tooltip: { + trigger: 'axis', + axisPointer: { type: 'cross' }, + backgroundColor: 'rgba(26, 26, 46, 0.95)', + borderColor: gold, + borderWidth: 1, + textStyle: { + color: textColor, + fontSize: 12, + }, + formatter: (params: unknown) => { + const paramsArr = params as { name: string; marker: string; seriesName: string; data: number[] | number; value: number }[]; + let result = `${paramsArr[0].name}
`; + paramsArr.forEach((param) => { + if (param.seriesName === '分钟K线') { + const [open, close, , high] = param.data as number[]; + const low = (param.data as number[])[2]; + const changePercent = + openPrice > 0 ? (((close - openPrice) / openPrice) * 100).toFixed(2) : '0.00'; + result += `${param.marker} ${param.seriesName}
`; + result += `开盘: ${open.toFixed(2)}
`; + result += `收盘: ${close.toFixed(2)}
`; + result += `最高: ${high.toFixed(2)}
`; + result += `最低: ${low.toFixed(2)}
`; + result += `涨跌: ${changePercent}%
`; + } else if (param.seriesName === '均价线') { + result += `${param.marker} ${param.seriesName}: ${(param.value as number).toFixed(2)}
`; + } else if (param.seriesName === '成交量') { + result += `${param.marker} ${param.seriesName}: ${formatNumber(param.value as number, 0)}
`; + } + }); + return result; + }, + }, + legend: { + data: ['分钟K线', '均价线', '成交量'], + top: 5, + left: 'center', + textStyle: { + color: textColor, + fontSize: 12, + }, + itemWidth: 25, + itemHeight: 14, + }, + grid: [ + { + left: '3%', + right: '3%', + top: '10%', + height: '65%', + containLabel: true, + }, + { + left: '3%', + right: '3%', + top: '78%', + height: '15%', + containLabel: true, + }, + ], + xAxis: [ + { + type: 'category', + data: times, + boundaryGap: false, + axisLine: { lineStyle: { color: borderColor } }, + axisLabel: { + color: textMuted, + fontSize: 10, + interval: 'auto', + }, + splitLine: { show: false }, + }, + { + type: 'category', + gridIndex: 1, + data: times, + boundaryGap: false, + axisLine: { lineStyle: { color: borderColor } }, + axisLabel: { + color: textMuted, + fontSize: 10, + }, + splitLine: { show: false }, + }, + ], + yAxis: [ + { + scale: true, + axisLine: { lineStyle: { color: borderColor } }, + axisLabel: { color: textMuted, fontSize: 10 }, + splitLine: { + lineStyle: { + color: borderColor, + type: 'dashed', + }, + }, + }, + { + gridIndex: 1, + scale: true, + axisLine: { lineStyle: { color: borderColor } }, + axisLabel: { color: textMuted, fontSize: 10 }, + splitLine: { show: false }, + }, + ], + dataZoom: [ + { + type: 'inside', + xAxisIndex: [0, 1], + start: 70, + end: 100, + minValueSpan: 20, + }, + { + show: true, + xAxisIndex: [0, 1], + type: 'slider', + top: '95%', + start: 70, + end: 100, + height: 20, + handleSize: '100%', + handleStyle: { + color: gold, + }, + textStyle: { + color: textMuted, + }, + borderColor: borderColor, + fillerColor: 'rgba(212, 175, 55, 0.2)', + }, + ], + series: [ + { + name: '分钟K线', + type: 'candlestick', + data: kData, + itemStyle: { + color: red, + color0: green, + borderColor: red, + borderColor0: green, + borderWidth: 1, + }, + barWidth: '60%', + }, + { + name: '均价线', + type: 'line', + data: avgPrice, + smooth: true, + symbol: 'none', + lineStyle: { + color: gold, + width: 2, + opacity: 0.8, + }, + }, + { + name: '成交量', + type: 'bar', + xAxisIndex: 1, + yAxisIndex: 1, + data: volumes, + barWidth: '50%', + itemStyle: { + color: (params: { dataIndex: number }) => { + const item = minuteData.data[params.dataIndex]; + return item.close >= item.open + ? 'rgba(255, 68, 68, 0.6)' + : 'rgba(0, 200, 81, 0.6)'; + }, + }, + }, + ], + }; +}; + +/** + * 生成融资融券图表配置 + */ +export const getFundingOption = (theme: Theme, fundingData: FundingDayData[]): EChartsOption => { + if (!fundingData || fundingData.length === 0) return {}; + + const dates = fundingData.map((item) => item.date.substring(5, 10)); + const financing = fundingData.map((item) => item.financing.balance / 100000000); + const securities = fundingData.map((item) => item.securities.balance_amount / 100000000); + + return { + backgroundColor: theme.chartBg, + title: { + text: '融资融券余额走势', + left: 'center', + textStyle: { + color: theme.textPrimary, + fontSize: 16, + }, + }, + tooltip: { + trigger: 'axis', + backgroundColor: 'rgba(255,255,255,0.9)', + borderColor: theme.primary, + borderWidth: 1, + textStyle: { + color: theme.textPrimary, + }, + formatter: (params: unknown) => { + const paramsArr = params as { name: string; marker: string; seriesName: string; value: number }[]; + let result = paramsArr[0].name + '
'; + paramsArr.forEach((param) => { + result += `${param.marker} ${param.seriesName}: ${param.value.toFixed(2)}亿
`; + }); + return result; + }, + }, + legend: { + data: ['融资余额', '融券余额'], + bottom: 10, + textStyle: { + color: theme.textPrimary, + }, + }, + grid: { + left: '3%', + right: '4%', + bottom: '15%', + containLabel: true, + }, + xAxis: { + type: 'category', + boundaryGap: false, + data: dates, + axisLine: { lineStyle: { color: theme.textMuted } }, + axisLabel: { color: theme.textMuted }, + }, + yAxis: { + type: 'value', + name: '金额(亿)', + nameTextStyle: { color: theme.textMuted }, + splitLine: { + lineStyle: { + color: theme.border, + }, + }, + axisLine: { lineStyle: { color: theme.textMuted } }, + axisLabel: { color: theme.textMuted }, + }, + series: [ + { + name: '融资余额', + type: 'line', + smooth: true, + symbol: 'circle', + symbolSize: 8, + areaStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: 'rgba(255, 68, 68, 0.3)' }, + { offset: 1, color: 'rgba(255, 68, 68, 0.05)' }, + ], + }, + }, + lineStyle: { + color: theme.success, + width: 2, + }, + itemStyle: { + color: theme.success, + borderColor: theme.success, + borderWidth: 2, + }, + data: financing, + }, + { + name: '融券余额', + type: 'line', + smooth: true, + symbol: 'diamond', + symbolSize: 8, + areaStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: 'rgba(0, 200, 81, 0.3)' }, + { offset: 1, color: 'rgba(0, 200, 81, 0.05)' }, + ], + }, + }, + lineStyle: { + color: theme.danger, + width: 2, + }, + itemStyle: { + color: theme.danger, + borderColor: theme.danger, + borderWidth: 2, + }, + data: securities, + }, + ], + }; +}; + +/** + * 生成融资融券图表配置 - 黑金主题 + */ +export const getFundingDarkGoldOption = (fundingData: FundingDayData[]): EChartsOption => { + if (!fundingData || fundingData.length === 0) return {}; + + const dates = fundingData.map((item) => item.date.substring(5, 10)); + const financing = fundingData.map((item) => item.financing.balance / 100000000); + const securities = fundingData.map((item) => item.securities.balance_amount / 100000000); + + // 黑金主题色 + const gold = '#D4AF37'; + const goldLight = '#F4D03F'; + const textColor = 'rgba(255, 255, 255, 0.85)'; + const textMuted = 'rgba(255, 255, 255, 0.5)'; + const borderColor = 'rgba(212, 175, 55, 0.2)'; + + return { + backgroundColor: 'transparent', + title: { + text: '融资融券余额走势', + left: 'center', + textStyle: { + color: gold, + fontSize: 16, + fontWeight: 'bold', + }, + }, + tooltip: { + trigger: 'axis', + backgroundColor: 'rgba(26, 26, 46, 0.95)', + borderColor: gold, + borderWidth: 1, + textStyle: { + color: textColor, + }, + formatter: (params: unknown) => { + const paramsArr = params as { name: string; marker: string; seriesName: string; value: number }[]; + let result = `${paramsArr[0].name}
`; + paramsArr.forEach((param) => { + result += `${param.marker} ${param.seriesName}: ${param.value.toFixed(2)}亿
`; + }); + return result; + }, + }, + legend: { + data: ['融资余额', '融券余额'], + bottom: 10, + textStyle: { + color: textColor, + }, + }, + grid: { + left: '3%', + right: '4%', + bottom: '15%', + containLabel: true, + }, + xAxis: { + type: 'category', + boundaryGap: false, + data: dates, + axisLine: { lineStyle: { color: borderColor } }, + axisLabel: { color: textMuted }, + splitLine: { show: false }, + }, + yAxis: { + type: 'value', + name: '金额(亿)', + nameTextStyle: { color: textMuted }, + splitLine: { + lineStyle: { + color: borderColor, + type: 'dashed', + }, + }, + axisLine: { lineStyle: { color: borderColor } }, + axisLabel: { color: textMuted }, + }, + series: [ + { + name: '融资余额', + type: 'line', + smooth: true, + symbol: 'circle', + symbolSize: 8, + areaStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: 'rgba(212, 175, 55, 0.4)' }, + { offset: 1, color: 'rgba(212, 175, 55, 0.05)' }, + ], + }, + }, + lineStyle: { + color: gold, + width: 2, + shadowBlur: 10, + shadowColor: 'rgba(212, 175, 55, 0.5)', + }, + itemStyle: { + color: gold, + borderColor: goldLight, + borderWidth: 2, + }, + data: financing, + }, + { + name: '融券余额', + type: 'line', + smooth: true, + symbol: 'diamond', + symbolSize: 8, + areaStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: 'rgba(255, 149, 0, 0.4)' }, + { offset: 1, color: 'rgba(255, 149, 0, 0.05)' }, + ], + }, + }, + lineStyle: { + color: '#FF9500', + width: 2, + shadowBlur: 10, + shadowColor: 'rgba(255, 149, 0, 0.5)', + }, + itemStyle: { + color: '#FF9500', + borderColor: '#FFB347', + borderWidth: 2, + }, + data: securities, + }, + ], + }; +}; + +/** + * 生成股权质押图表配置 + */ +export const getPledgeOption = (theme: Theme, pledgeData: PledgeData[]): EChartsOption => { + if (!pledgeData || pledgeData.length === 0) return {}; + + const dates = pledgeData.map((item) => item.end_date.substring(5, 10)); + const ratios = pledgeData.map((item) => item.pledge_ratio); + const counts = pledgeData.map((item) => item.pledge_count); + + return { + backgroundColor: theme.chartBg, + title: { + text: '股权质押趋势', + left: 'center', + textStyle: { + color: theme.textPrimary, + fontSize: 16, + }, + }, + tooltip: { + trigger: 'axis', + backgroundColor: 'rgba(255,255,255,0.9)', + borderColor: theme.primary, + borderWidth: 1, + textStyle: { + color: theme.textPrimary, + }, + }, + legend: { + data: ['质押比例', '质押笔数'], + bottom: 10, + textStyle: { + color: theme.textPrimary, + }, + }, + grid: { + left: '3%', + right: '4%', + bottom: '15%', + containLabel: true, + }, + xAxis: { + type: 'category', + data: dates, + axisLine: { lineStyle: { color: theme.textMuted } }, + axisLabel: { color: theme.textMuted }, + }, + yAxis: [ + { + type: 'value', + name: '质押比例(%)', + nameTextStyle: { color: theme.textMuted }, + splitLine: { + lineStyle: { + color: theme.border, + }, + }, + axisLine: { lineStyle: { color: theme.textMuted } }, + axisLabel: { color: theme.textMuted }, + }, + { + type: 'value', + name: '质押笔数', + nameTextStyle: { color: theme.textMuted }, + axisLine: { lineStyle: { color: theme.textMuted } }, + axisLabel: { color: theme.textMuted }, + }, + ], + series: [ + { + name: '质押比例', + type: 'line', + smooth: true, + symbol: 'circle', + symbolSize: 8, + lineStyle: { + color: theme.warning, + width: 2, + shadowBlur: 10, + shadowColor: theme.warning, + }, + itemStyle: { + color: theme.warning, + borderColor: theme.bgCard, + borderWidth: 2, + }, + data: ratios, + }, + { + name: '质押笔数', + type: 'bar', + yAxisIndex: 1, + barWidth: '50%', + itemStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: theme.primary }, + { offset: 1, color: theme.primaryDark }, + ], + }, + borderRadius: [5, 5, 0, 0], + }, + data: counts, + }, + ], + }; +}; + +/** + * 生成股权质押图表配置 - 黑金主题 + */ +export const getPledgeDarkGoldOption = (pledgeData: PledgeData[]): EChartsOption => { + if (!pledgeData || pledgeData.length === 0) return {}; + + const dates = pledgeData.map((item) => item.end_date.substring(5, 10)); + const ratios = pledgeData.map((item) => item.pledge_ratio); + const counts = pledgeData.map((item) => item.pledge_count); + + // 黑金主题色 + const gold = '#D4AF37'; + const goldLight = '#F4D03F'; + const orange = '#FF9500'; + const textColor = 'rgba(255, 255, 255, 0.85)'; + const textMuted = 'rgba(255, 255, 255, 0.5)'; + const borderColor = 'rgba(212, 175, 55, 0.2)'; + + return { + backgroundColor: 'transparent', + title: { + text: '股权质押趋势', + left: 'center', + textStyle: { + color: gold, + fontSize: 16, + fontWeight: 'bold', + }, + }, + tooltip: { + trigger: 'axis', + backgroundColor: 'rgba(26, 26, 46, 0.95)', + borderColor: gold, + borderWidth: 1, + textStyle: { + color: textColor, + }, + }, + legend: { + data: ['质押比例', '质押笔数'], + bottom: 10, + textStyle: { + color: textColor, + }, + }, + grid: { + left: '3%', + right: '4%', + bottom: '15%', + containLabel: true, + }, + xAxis: { + type: 'category', + data: dates, + axisLine: { lineStyle: { color: borderColor } }, + axisLabel: { color: textMuted }, + splitLine: { show: false }, + }, + yAxis: [ + { + type: 'value', + name: '质押比例(%)', + nameTextStyle: { color: textMuted }, + splitLine: { + lineStyle: { + color: borderColor, + type: 'dashed', + }, + }, + axisLine: { lineStyle: { color: borderColor } }, + axisLabel: { color: textMuted }, + }, + { + type: 'value', + name: '质押笔数', + nameTextStyle: { color: textMuted }, + axisLine: { lineStyle: { color: borderColor } }, + axisLabel: { color: textMuted }, + splitLine: { show: false }, + }, + ], + series: [ + { + name: '质押比例', + type: 'line', + smooth: true, + symbol: 'circle', + symbolSize: 8, + lineStyle: { + color: gold, + width: 2, + shadowBlur: 10, + shadowColor: 'rgba(212, 175, 55, 0.5)', + }, + itemStyle: { + color: gold, + borderColor: goldLight, + borderWidth: 2, + }, + data: ratios, + }, + { + name: '质押笔数', + type: 'bar', + yAxisIndex: 1, + barWidth: '50%', + itemStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: orange }, + { offset: 1, color: 'rgba(255, 149, 0, 0.3)' }, + ], + }, + borderRadius: [4, 4, 0, 0], + }, + data: counts, + }, + ], + }; +}; + +export default { + calculateMA, + getKLineOption, + getKLineDarkGoldOption, + getMinuteKLineOption, + getMinuteKLineDarkGoldOption, + getFundingOption, + getFundingDarkGoldOption, + getPledgeOption, + getPledgeDarkGoldOption, +}; diff --git a/src/views/Company/components/MarketDataView/utils/formatUtils.ts b/src/views/Company/components/MarketDataView/utils/formatUtils.ts new file mode 100644 index 00000000..7f1df325 --- /dev/null +++ b/src/views/Company/components/MarketDataView/utils/formatUtils.ts @@ -0,0 +1,175 @@ +// src/views/Company/components/MarketDataView/utils/formatUtils.ts +// MarketDataView 格式化工具函数 + +/** + * 格式化数字(自动转换为万/亿) + * @param value 数值 + * @param decimals 小数位数,默认 2 + * @returns 格式化后的字符串 + */ +export const formatNumber = (value: number | null | undefined, decimals: number = 2): string => { + if (value === null || value === undefined) return '-'; + + const num = typeof value === 'number' ? value : parseFloat(String(value)); + if (isNaN(num)) return '-'; + + if (Math.abs(num) >= 100000000) { + return (num / 100000000).toFixed(decimals) + '亿'; + } else if (Math.abs(num) >= 10000) { + return (num / 10000).toFixed(decimals) + '万'; + } + return num.toFixed(decimals); +}; + +/** + * 格式化百分比 + * @param value 数值(已经是百分比形式,如 3.5 表示 3.5%) + * @param decimals 小数位数,默认 2 + * @returns 格式化后的字符串 + */ +export const formatPercent = (value: number | null | undefined, decimals: number = 2): string => { + if (value === null || value === undefined) return '-'; + + const num = typeof value === 'number' ? value : parseFloat(String(value)); + if (isNaN(num)) return '-'; + + return num.toFixed(decimals) + '%'; +}; + +/** + * 格式化日期(取前 10 位) + * @param dateStr 日期字符串 + * @returns 格式化后的日期(YYYY-MM-DD) + */ +export const formatDate = (dateStr: string | null | undefined): string => { + if (!dateStr) return '-'; + return dateStr.substring(0, 10); +}; + +/** + * 格式化价格 + * @param value 价格数值 + * @param decimals 小数位数,默认 2 + * @returns 格式化后的价格字符串 + */ +export const formatPrice = (value: number | null | undefined, decimals: number = 2): string => { + if (value === null || value === undefined) return '-'; + + const num = typeof value === 'number' ? value : parseFloat(String(value)); + if (isNaN(num)) return '-'; + + return num.toFixed(decimals); +}; + +/** + * 格式化成交量(带单位) + * @param value 成交量数值 + * @returns 格式化后的成交量字符串 + */ +export const formatVolume = (value: number | null | undefined): string => { + if (value === null || value === undefined) return '-'; + + const num = typeof value === 'number' ? value : parseFloat(String(value)); + if (isNaN(num)) return '-'; + + if (num >= 100000000) { + return (num / 100000000).toFixed(2) + '亿股'; + } else if (num >= 10000) { + return (num / 10000).toFixed(2) + '万股'; + } + return num.toFixed(0) + '股'; +}; + +/** + * 格式化金额(带单位) + * @param value 金额数值 + * @returns 格式化后的金额字符串 + */ +export const formatAmount = (value: number | null | undefined): string => { + if (value === null || value === undefined) return '-'; + + const num = typeof value === 'number' ? value : parseFloat(String(value)); + if (isNaN(num)) return '-'; + + if (Math.abs(num) >= 100000000) { + return (num / 100000000).toFixed(2) + '亿'; + } else if (Math.abs(num) >= 10000) { + return (num / 10000).toFixed(2) + '万'; + } + return num.toFixed(2) + '元'; +}; + +/** + * 格式化涨跌幅(带符号和颜色提示) + * @param value 涨跌幅数值 + * @returns 带符号的涨跌幅字符串 + */ +export const formatChange = (value: number | null | undefined): string => { + if (value === null || value === undefined) return '-'; + + const num = typeof value === 'number' ? value : parseFloat(String(value)); + if (isNaN(num)) return '-'; + + const sign = num > 0 ? '+' : ''; + return sign + num.toFixed(2) + '%'; +}; + +/** + * 获取涨跌颜色类型 + * @param value 涨跌幅数值 + * @returns 'up' | 'down' | 'neutral' + */ +export const getChangeType = (value: number | null | undefined): 'up' | 'down' | 'neutral' => { + if (value === null || value === undefined) return 'neutral'; + + const num = typeof value === 'number' ? value : parseFloat(String(value)); + if (isNaN(num) || num === 0) return 'neutral'; + + return num > 0 ? 'up' : 'down'; +}; + +/** + * 格式化短日期(MM-DD) + * @param dateStr 日期字符串 + * @returns 格式化后的短日期 + */ +export const formatShortDate = (dateStr: string | null | undefined): string => { + if (!dateStr) return '-'; + return dateStr.substring(5, 10); +}; + +/** + * 格式化时间(HH:mm) + * @param timeStr 时间字符串 + * @returns 格式化后的时间 + */ +export const formatTime = (timeStr: string | null | undefined): string => { + if (!timeStr) return '-'; + // 支持多种格式 + if (timeStr.includes(':')) { + return timeStr.substring(0, 5); + } + // 如果是 HHmm 格式 + if (timeStr.length >= 4) { + return timeStr.substring(0, 2) + ':' + timeStr.substring(2, 4); + } + return timeStr; +}; + +/** + * 工具函数集合(兼容旧代码) + */ +export const formatUtils = { + formatNumber, + formatPercent, + formatDate, + formatPrice, + formatVolume, + formatAmount, + formatChange, + getChangeType, + formatShortDate, + formatTime, +}; + +export default formatUtils; diff --git a/src/views/Company/components/StockQuoteCard/components/CompanyInfo.tsx b/src/views/Company/components/StockQuoteCard/components/CompanyInfo.tsx new file mode 100644 index 00000000..e9f4faf3 --- /dev/null +++ b/src/views/Company/components/StockQuoteCard/components/CompanyInfo.tsx @@ -0,0 +1,87 @@ +/** + * CompanyInfo - 公司信息原子组件 + * 显示公司基本信息(成立日期、注册资本、所在地、官网、简介) + */ + +import React, { memo } from 'react'; +import { Box, Flex, HStack, Text, Link, Icon, Divider } from '@chakra-ui/react'; +import { Calendar, Coins, MapPin, Globe } from 'lucide-react'; +import { formatRegisteredCapital, formatDate } from '../../CompanyOverview/utils'; +import { STOCK_CARD_THEME } from './theme'; + +export interface CompanyBasicInfo { + establish_date?: string; + reg_capital?: number; + province?: string; + city?: string; + website?: string; + company_intro?: string; +} + +export interface CompanyInfoProps { + basicInfo: CompanyBasicInfo; +} + +export const CompanyInfo: React.FC = memo(({ basicInfo }) => { + const { labelColor, valueColor, borderColor } = STOCK_CARD_THEME; + + return ( + <> + + + {/* 左侧:公司关键属性 (flex=1) */} + + + + + 成立: + + {formatDate(basicInfo.establish_date)} + + + + + 注册资本: + + {formatRegisteredCapital(basicInfo.reg_capital)} + + + + + 所在地: + + {basicInfo.province} {basicInfo.city} + + + + + {basicInfo.website ? ( + + 访问官网 + + ) : ( + 暂无官网 + )} + + + + + {/* 右侧:公司简介 (flex=2) */} + + + 公司简介: + {basicInfo.company_intro || '暂无'} + + + + + ); +}); + +CompanyInfo.displayName = 'CompanyInfo'; diff --git a/src/views/Company/components/StockQuoteCard/components/CompareStockInput.tsx b/src/views/Company/components/StockQuoteCard/components/CompareStockInput.tsx new file mode 100644 index 00000000..92d9885e --- /dev/null +++ b/src/views/Company/components/StockQuoteCard/components/CompareStockInput.tsx @@ -0,0 +1,206 @@ +/** + * CompareStockInput - 对比股票输入组件 + * 紧凑型输入框,支持模糊搜索下拉 + */ + +import React, { useState, useEffect, useRef } from 'react'; +import { useSelector } from 'react-redux'; +import { + Box, + HStack, + Input, + InputGroup, + InputLeftElement, + Button, + Text, + VStack, + Spinner, +} from '@chakra-ui/react'; +import { SearchIcon } from '@chakra-ui/icons'; +import { BarChart2 } from 'lucide-react'; +import { useStockSearch, type Stock } from '../../../hooks/useStockSearch'; + +interface CompareStockInputProps { + onCompare: (stockCode: string) => void; + isLoading?: boolean; + currentStockCode?: string; +} + +interface RootState { + stock: { + allStocks: Stock[]; + }; +} + +const CompareStockInput: React.FC = ({ + onCompare, + isLoading = false, + currentStockCode, +}) => { + const [inputValue, setInputValue] = useState(''); + const [showDropdown, setShowDropdown] = useState(false); + const [selectedStock, setSelectedStock] = useState(null); + const containerRef = useRef(null); + + // 从 Redux 获取全部股票列表 + const allStocks = useSelector((state: RootState) => state.stock.allStocks); + + // 黑金主题颜色 + const borderColor = '#C9A961'; + const goldColor = '#F4D03F'; + const bgColor = '#1A202C'; + + // 使用共享的搜索 Hook(排除当前股票) + const filteredStocks = useStockSearch(allStocks, inputValue, { + excludeCode: currentStockCode, + limit: 8, + }); + + // 根据搜索结果更新下拉显示状态 + useEffect(() => { + setShowDropdown(filteredStocks.length > 0 && !!inputValue?.trim()); + }, [filteredStocks, inputValue]); + + // 点击外部关闭下拉 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setShowDropdown(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // 选择股票 + const handleSelectStock = (stock: Stock) => { + setSelectedStock(stock); + setInputValue(stock.name); + setShowDropdown(false); + }; + + // 处理对比按钮点击 + const handleCompare = () => { + if (selectedStock) { + onCompare(selectedStock.code); + } else if (inputValue.trim().length === 6 && /^\d{6}$/.test(inputValue.trim())) { + // 如果直接输入了6位数字代码 + onCompare(inputValue.trim()); + } + }; + + // 处理键盘事件 + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + setShowDropdown(false); + handleCompare(); + } + }; + + const isButtonDisabled = !selectedStock && !(inputValue.trim().length === 6 && /^\d{6}$/.test(inputValue.trim())); + + return ( + + + + + + + { + setInputValue(e.target.value); + setSelectedStock(null); + }} + onKeyDown={handleKeyDown} + onFocus={() => inputValue && filteredStocks.length > 0 && setShowDropdown(true)} + borderRadius="md" + color="white" + fontSize="sm" + borderColor={borderColor} + bg="transparent" + _placeholder={{ color: borderColor, fontSize: 'sm' }} + _focus={{ + borderColor: goldColor, + boxShadow: `0 0 0 1px ${goldColor}`, + }} + _hover={{ + borderColor: goldColor, + }} + /> + + + + + + {/* 模糊搜索下拉列表 */} + {showDropdown && ( + + + {filteredStocks.map((stock) => ( + handleSelectStock(stock)} + borderBottom="1px solid" + borderColor="whiteAlpha.100" + _last={{ borderBottom: 'none' }} + > + + + {stock.code} + + + {stock.name} + + + + ))} + + + )} + + ); +}; + +export default CompareStockInput; diff --git a/src/views/Company/components/StockQuoteCard/components/KeyMetrics.tsx b/src/views/Company/components/StockQuoteCard/components/KeyMetrics.tsx new file mode 100644 index 00000000..1e17390a --- /dev/null +++ b/src/views/Company/components/StockQuoteCard/components/KeyMetrics.tsx @@ -0,0 +1,76 @@ +/** + * KeyMetrics - 关键指标原子组件 + * 显示 PE、EPS、PB、流通市值、52周波动 + */ + +import React, { memo } from 'react'; +import { Box, VStack, HStack, Text } from '@chakra-ui/react'; +import { formatPrice } from './formatters'; +import { STOCK_CARD_THEME } from './theme'; + +export interface KeyMetricsProps { + pe: number; + eps?: number; + pb: number; + marketCap: string; + week52Low: number; + week52High: number; +} + +export const KeyMetrics: React.FC = memo(({ + pe, + eps, + pb, + marketCap, + week52Low, + week52High, +}) => { + const { labelColor, valueColor, sectionTitleColor } = STOCK_CARD_THEME; + + return ( + + + 关键指标 + + + + 市盈率(PE): + + {pe.toFixed(2)} + + + + 每股收益(EPS): + + {eps?.toFixed(3) || '-'} + + + + 市净率(PB): + + {pb.toFixed(2)} + + + + 流通市值: + + {marketCap} + + + + 52周波动: + + {formatPrice(week52Low)}-{formatPrice(week52High)} + + + + + ); +}); + +KeyMetrics.displayName = 'KeyMetrics'; diff --git a/src/views/Company/components/StockQuoteCard/components/MainForceInfo.tsx b/src/views/Company/components/StockQuoteCard/components/MainForceInfo.tsx new file mode 100644 index 00000000..607d1a73 --- /dev/null +++ b/src/views/Company/components/StockQuoteCard/components/MainForceInfo.tsx @@ -0,0 +1,71 @@ +/** + * MainForceInfo - 主力动态原子组件 + * 显示主力净流入、机构持仓、买卖比例 + */ + +import React, { memo } from 'react'; +import { Box, VStack, HStack, Text, Progress } from '@chakra-ui/react'; +import { formatNetInflow } from './formatters'; +import { STOCK_CARD_THEME } from './theme'; + +export interface MainForceInfoProps { + mainNetInflow: number; + institutionHolding: number; + buyRatio: number; + sellRatio: number; +} + +export const MainForceInfo: React.FC = memo(({ + mainNetInflow, + institutionHolding, + buyRatio, + sellRatio, +}) => { + const { labelColor, valueColor, sectionTitleColor, borderColor, upColor, downColor } = STOCK_CARD_THEME; + const inflowColor = mainNetInflow >= 0 ? upColor : downColor; + + return ( + + + 主力动态 + + + + 主力净流入: + + {formatNetInflow(mainNetInflow)} + + + + 机构持仓: + + {institutionHolding.toFixed(2)}% + + + {/* 买卖比例条 */} + + div': { bg: upColor }, + }} + bg={downColor} + borderRadius="full" + /> + + 买入{buyRatio}% + 卖出{sellRatio}% + + + + + ); +}); + +MainForceInfo.displayName = 'MainForceInfo'; diff --git a/src/views/Company/components/StockQuoteCard/components/PriceDisplay.tsx b/src/views/Company/components/StockQuoteCard/components/PriceDisplay.tsx new file mode 100644 index 00000000..23baf6d7 --- /dev/null +++ b/src/views/Company/components/StockQuoteCard/components/PriceDisplay.tsx @@ -0,0 +1,43 @@ +/** + * PriceDisplay - 价格显示原子组件 + * 显示当前价格和涨跌幅 Badge + */ + +import React, { memo } from 'react'; +import { HStack, Text, Badge } from '@chakra-ui/react'; +import { formatPrice, formatChangePercent } from './formatters'; +import { STOCK_CARD_THEME } from './theme'; + +export interface PriceDisplayProps { + currentPrice: number; + changePercent: number; +} + +export const PriceDisplay: React.FC = memo(({ + currentPrice, + changePercent, +}) => { + const { upColor, downColor } = STOCK_CARD_THEME; + const priceColor = changePercent >= 0 ? upColor : downColor; + + return ( + + + {formatPrice(currentPrice)} + + = 0 ? upColor : downColor} + color="#FFFFFF" + fontSize="20px" + fontWeight="bold" + px={3} + py={1} + borderRadius="md" + > + {formatChangePercent(changePercent)} + + + ); +}); + +PriceDisplay.displayName = 'PriceDisplay'; diff --git a/src/views/Company/components/StockQuoteCard/components/SecondaryQuote.tsx b/src/views/Company/components/StockQuoteCard/components/SecondaryQuote.tsx new file mode 100644 index 00000000..d6e7a685 --- /dev/null +++ b/src/views/Company/components/StockQuoteCard/components/SecondaryQuote.tsx @@ -0,0 +1,59 @@ +/** + * SecondaryQuote - 次要行情原子组件 + * 显示今开、昨收、最高、最低 + */ + +import React, { memo } from 'react'; +import { HStack, Text } from '@chakra-ui/react'; +import { formatPrice } from './formatters'; +import { STOCK_CARD_THEME } from './theme'; + +export interface SecondaryQuoteProps { + todayOpen: number; + yesterdayClose: number; + todayHigh: number; + todayLow: number; +} + +export const SecondaryQuote: React.FC = memo(({ + todayOpen, + yesterdayClose, + todayHigh, + todayLow, +}) => { + const { labelColor, valueColor, borderColor, upColor, downColor } = STOCK_CARD_THEME; + + return ( + + + 今开: + + {formatPrice(todayOpen)} + + + | + + 昨收: + + {formatPrice(yesterdayClose)} + + + | + + 最高: + + {formatPrice(todayHigh)} + + + | + + 最低: + + {formatPrice(todayLow)} + + + + ); +}); + +SecondaryQuote.displayName = 'SecondaryQuote'; diff --git a/src/views/Company/components/StockQuoteCard/components/StockCompareModal.tsx b/src/views/Company/components/StockQuoteCard/components/StockCompareModal.tsx new file mode 100644 index 00000000..b56c0a1a --- /dev/null +++ b/src/views/Company/components/StockQuoteCard/components/StockCompareModal.tsx @@ -0,0 +1,244 @@ +/** + * StockCompareModal - 股票对比弹窗组件 + * 展示对比明细、盈利能力对比、成长力对比 + */ + +import React from 'react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalCloseButton, + VStack, + HStack, + Grid, + GridItem, + Card, + CardHeader, + CardBody, + Heading, + Text, + Table, + Thead, + Tbody, + Tr, + Th, + Td, + TableContainer, + Spinner, + Center, +} from '@chakra-ui/react'; +import { ArrowUpIcon, ArrowDownIcon } from '@chakra-ui/icons'; +import ReactECharts from 'echarts-for-react'; + +import { COMPARE_METRICS } from '../../FinancialPanorama/constants'; +import { getValueByPath, getCompareBarChartOption } from '../../FinancialPanorama/utils'; +import { formatUtils } from '@services/financialService'; +import type { StockInfo } from '../../FinancialPanorama/types'; + +interface StockCompareModalProps { + isOpen: boolean; + onClose: () => void; + currentStock: string; + currentStockInfo: StockInfo | null; + compareStock: string; + compareStockInfo: StockInfo | null; + isLoading?: boolean; +} + +const StockCompareModal: React.FC = ({ + isOpen, + onClose, + currentStock, + currentStockInfo, + compareStock, + compareStockInfo, + isLoading = false, +}) => { + // 黑金主题颜色 + const bgColor = '#1A202C'; + const borderColor = '#C9A961'; + const goldColor = '#F4D03F'; + const positiveColor = '#EF4444'; // 红涨 + const negativeColor = '#10B981'; // 绿跌 + + // 加载中或无数据时的显示 + if (isLoading || !currentStockInfo || !compareStockInfo) { + return ( + + + + 股票对比 + + +
+ {isLoading ? ( + + + 加载对比数据中... + + ) : ( + 暂无对比数据 + )} +
+
+
+
+ ); + } + + return ( + + + + + {currentStockInfo?.stock_name} ({currentStock}) vs {compareStockInfo?.stock_name} ({compareStock}) + + + + + {/* 对比明细表格 */} + + + 对比明细 + + + + + + + + + + + + + + {COMPARE_METRICS.map((metric) => { + const value1 = getValueByPath(currentStockInfo, metric.path); + const value2 = getValueByPath(compareStockInfo, metric.path); + + let diff: number | null = null; + let diffColor = borderColor; + + if (value1 !== undefined && value2 !== undefined && value1 !== null && value2 !== null) { + if (metric.format === 'percent') { + diff = value1 - value2; + diffColor = diff > 0 ? positiveColor : negativeColor; + } else if (value2 !== 0) { + diff = ((value1 - value2) / Math.abs(value2)) * 100; + diffColor = diff > 0 ? positiveColor : negativeColor; + } + } + + return ( + + + + + + + ); + })} + +
指标{currentStockInfo?.stock_name}{compareStockInfo?.stock_name}差异
{metric.label} + {metric.format === 'percent' + ? formatUtils.formatPercent(value1) + : formatUtils.formatLargeNumber(value1)} + + {metric.format === 'percent' + ? formatUtils.formatPercent(value2) + : formatUtils.formatLargeNumber(value2)} + + {diff !== null ? ( + + {diff > 0 && } + {diff < 0 && } + + {metric.format === 'percent' + ? `${Math.abs(diff).toFixed(2)}pp` + : `${Math.abs(diff).toFixed(2)}%`} + + + ) : ( + '-' + )} +
+
+
+
+ + {/* 对比图表 */} + + + + + 盈利能力对比 + + + + + + + + + + + 成长能力对比 + + + + + + + +
+
+
+
+ ); +}; + +export default StockCompareModal; diff --git a/src/views/Company/components/StockQuoteCard/components/StockHeader.tsx b/src/views/Company/components/StockQuoteCard/components/StockHeader.tsx new file mode 100644 index 00000000..779c4866 --- /dev/null +++ b/src/views/Company/components/StockQuoteCard/components/StockHeader.tsx @@ -0,0 +1,120 @@ +/** + * StockHeader - 股票头部原子组件 + * 显示股票名称、代码、行业标签、指数标签、操作按钮 + */ + +import React, { memo } from 'react'; +import { Flex, HStack, Text, Badge, IconButton, Tooltip } from '@chakra-ui/react'; +import { Share2 } from 'lucide-react'; +import FavoriteButton from '@components/FavoriteButton'; +import CompareStockInput from './CompareStockInput'; +import { STOCK_CARD_THEME } from './theme'; + +export interface StockHeaderProps { + name: string; + code: string; + industryL1?: string; + industry?: string; + indexTags?: string[]; + updateTime?: string; + // 关注相关 + isInWatchlist?: boolean; + isWatchlistLoading?: boolean; + onWatchlistToggle?: () => void; + // 分享 + onShare?: () => void; + // 对比相关 + isCompareLoading?: boolean; + onCompare?: (stockCode: string) => void; +} + +export const StockHeader: React.FC = memo(({ + name, + code, + industryL1, + industry, + indexTags, + updateTime, + isInWatchlist = false, + isWatchlistLoading = false, + onWatchlistToggle, + onShare, + isCompareLoading = false, + onCompare, +}) => { + const { labelColor, valueColor, borderColor } = STOCK_CARD_THEME; + + return ( + + {/* 左侧:股票名称 + 行业标签 + 指数标签 */} + + {/* 股票名称 - 突出显示 */} + + {name} + + + ({code}) + + + {/* 行业标签 */} + {(industryL1 || industry) && ( + + {industryL1 && industry + ? `${industryL1} · ${industry}` + : industry || industryL1} + + )} + + {/* 指数标签 */} + {indexTags && indexTags.length > 0 && ( + + {indexTags.join('、')} + + )} + + + {/* 右侧:对比 + 关注 + 分享 + 时间 */} + + {/* 股票对比输入 */} + {})} + isLoading={isCompareLoading} + currentStockCode={code} + /> + {})} + colorScheme="gold" + size="sm" + /> + + } + variant="ghost" + color={labelColor} + size="sm" + onClick={onShare} + _hover={{ bg: 'whiteAlpha.100' }} + /> + + + {updateTime?.split(' ')[1] || '--:--'} + + + + ); +}); + +StockHeader.displayName = 'StockHeader'; diff --git a/src/views/Company/components/StockQuoteCard/components/formatters.ts b/src/views/Company/components/StockQuoteCard/components/formatters.ts new file mode 100644 index 00000000..1cdf05e9 --- /dev/null +++ b/src/views/Company/components/StockQuoteCard/components/formatters.ts @@ -0,0 +1,29 @@ +/** + * StockQuoteCard 格式化工具函数 + */ + +/** + * 格式化价格显示 + */ +export const formatPrice = (price: number): string => { + return price.toLocaleString('zh-CN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +}; + +/** + * 格式化涨跌幅显示 + */ +export const formatChangePercent = (percent: number): string => { + const sign = percent >= 0 ? '+' : ''; + return `${sign}${percent.toFixed(2)}%`; +}; + +/** + * 格式化主力净流入显示 + */ +export const formatNetInflow = (value: number): string => { + const sign = value >= 0 ? '+' : ''; + return `${sign}${value.toFixed(2)}亿`; +}; diff --git a/src/views/Company/components/StockQuoteCard/components/index.ts b/src/views/Company/components/StockQuoteCard/components/index.ts new file mode 100644 index 00000000..8fa88fd0 --- /dev/null +++ b/src/views/Company/components/StockQuoteCard/components/index.ts @@ -0,0 +1,27 @@ +/** + * StockQuoteCard 组件统一导出 + */ + +// 原子组件 +export { PriceDisplay } from './PriceDisplay'; +export { SecondaryQuote } from './SecondaryQuote'; +export { KeyMetrics } from './KeyMetrics'; +export { MainForceInfo } from './MainForceInfo'; +export { CompanyInfo } from './CompanyInfo'; +export { StockHeader } from './StockHeader'; + +// 复合组件 +export { default as CompareStockInput } from './CompareStockInput'; +export { default as StockCompareModal } from './StockCompareModal'; + +// 工具和主题 +export { STOCK_CARD_THEME } from './theme'; +export * from './formatters'; + +// 类型导出 +export type { PriceDisplayProps } from './PriceDisplay'; +export type { SecondaryQuoteProps } from './SecondaryQuote'; +export type { KeyMetricsProps } from './KeyMetrics'; +export type { MainForceInfoProps } from './MainForceInfo'; +export type { CompanyInfoProps, CompanyBasicInfo } from './CompanyInfo'; +export type { StockHeaderProps } from './StockHeader'; diff --git a/src/views/Company/components/StockQuoteCard/components/theme.ts b/src/views/Company/components/StockQuoteCard/components/theme.ts new file mode 100644 index 00000000..5358a125 --- /dev/null +++ b/src/views/Company/components/StockQuoteCard/components/theme.ts @@ -0,0 +1,20 @@ +/** + * StockQuoteCard 黑金主题配置 + */ + +export const STOCK_CARD_THEME = { + // 背景和边框 + cardBg: '#1A202C', + borderColor: '#C9A961', + + // 文字颜色 + labelColor: '#C9A961', + valueColor: '#F4D03F', + sectionTitleColor: '#F4D03F', + + // 涨跌颜色(红涨绿跌) + upColor: '#F44336', + downColor: '#4CAF50', +} as const; + +export type StockCardTheme = typeof STOCK_CARD_THEME; diff --git a/src/views/Company/components/StockQuoteCard/hooks/index.ts b/src/views/Company/components/StockQuoteCard/hooks/index.ts new file mode 100644 index 00000000..ed671e79 --- /dev/null +++ b/src/views/Company/components/StockQuoteCard/hooks/index.ts @@ -0,0 +1,6 @@ +/** + * StockQuoteCard Hooks 导出索引 + */ + +export { useStockQuoteData } from './useStockQuoteData'; +export { useStockCompare } from './useStockCompare'; diff --git a/src/views/Company/components/StockQuoteCard/hooks/useStockCompare.ts b/src/views/Company/components/StockQuoteCard/hooks/useStockCompare.ts new file mode 100644 index 00000000..e9260626 --- /dev/null +++ b/src/views/Company/components/StockQuoteCard/hooks/useStockCompare.ts @@ -0,0 +1,91 @@ +/** + * useStockCompare - 股票对比逻辑 Hook + * + * 管理股票对比所需的数据获取和状态 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { useToast } from '@chakra-ui/react'; +import { financialService } from '@services/financialService'; +import { logger } from '@utils/logger'; +import type { StockInfo } from '../../FinancialPanorama/types'; + +interface UseStockCompareResult { + currentStockInfo: StockInfo | null; + compareStockInfo: StockInfo | null; + isCompareLoading: boolean; + handleCompare: (compareCode: string) => Promise; + clearCompare: () => void; +} + +/** + * 股票对比 Hook + * + * @param stockCode - 当前股票代码 + */ +export const useStockCompare = (stockCode?: string): UseStockCompareResult => { + const toast = useToast(); + const [currentStockInfo, setCurrentStockInfo] = useState(null); + const [compareStockInfo, setCompareStockInfo] = useState(null); + const [isCompareLoading, setIsCompareLoading] = useState(false); + + // 加载当前股票财务信息(用于对比) + useEffect(() => { + const loadCurrentStockInfo = async () => { + if (!stockCode) { + setCurrentStockInfo(null); + return; + } + + try { + const res = await financialService.getStockInfo(stockCode); + setCurrentStockInfo(res.data); + } catch (error) { + logger.error('useStockCompare', 'loadCurrentStockInfo', error, { stockCode }); + } + }; + + loadCurrentStockInfo(); + // 股票代码变化时清除对比数据 + setCompareStockInfo(null); + }, [stockCode]); + + // 处理股票对比 + const handleCompare = useCallback(async (compareCode: string) => { + if (!compareCode) return; + + logger.debug('useStockCompare', '开始加载对比数据', { stockCode, compareCode }); + setIsCompareLoading(true); + + try { + const res = await financialService.getStockInfo(compareCode); + setCompareStockInfo(res.data); + logger.info('useStockCompare', '对比数据加载成功', { stockCode, compareCode }); + } catch (error) { + logger.error('useStockCompare', 'handleCompare', error, { stockCode, compareCode }); + toast({ + title: '加载对比数据失败', + description: '请检查股票代码是否正确', + status: 'error', + duration: 3000, + }); + } finally { + setIsCompareLoading(false); + } + }, [stockCode, toast]); + + // 清除对比数据 + const clearCompare = useCallback(() => { + setCompareStockInfo(null); + }, []); + + return { + currentStockInfo, + compareStockInfo, + isCompareLoading, + handleCompare, + clearCompare, + }; +}; + +export default useStockCompare; diff --git a/src/views/Company/components/StockQuoteCard/hooks/useStockQuoteData.ts b/src/views/Company/components/StockQuoteCard/hooks/useStockQuoteData.ts new file mode 100644 index 00000000..3d1e28a9 --- /dev/null +++ b/src/views/Company/components/StockQuoteCard/hooks/useStockQuoteData.ts @@ -0,0 +1,179 @@ +/** + * useStockQuoteData - 股票行情数据获取 Hook + * + * 合并获取行情数据和基本信息,供 StockQuoteCard 内部使用 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { stockService } from '@services/eventService'; +import { logger } from '@utils/logger'; +import axios from '@utils/axiosConfig'; +import type { StockQuoteCardData } from '../types'; +import type { BasicInfo } from '../../CompanyOverview/types'; + +/** + * 将 API 响应数据转换为 StockQuoteCard 所需格式 + */ +const transformQuoteData = (apiData: any, stockCode: string): StockQuoteCardData | null => { + if (!apiData) return null; + + return { + // 基础信息 + name: apiData.name || apiData.stock_name || '未知', + code: apiData.code || apiData.stock_code || stockCode, + indexTags: apiData.index_tags || apiData.indexTags || [], + industry: apiData.industry || apiData.sw_industry_l2 || '', + industryL1: apiData.industry_l1 || apiData.sw_industry_l1 || '', + + // 价格信息 + currentPrice: apiData.current_price || apiData.currentPrice || apiData.close || 0, + changePercent: apiData.change_percent || apiData.changePercent || apiData.pct_chg || 0, + todayOpen: apiData.today_open || apiData.todayOpen || apiData.open || 0, + yesterdayClose: apiData.yesterday_close || apiData.yesterdayClose || apiData.pre_close || 0, + todayHigh: apiData.today_high || apiData.todayHigh || apiData.high || 0, + todayLow: apiData.today_low || apiData.todayLow || apiData.low || 0, + + // 关键指标 + pe: apiData.pe || apiData.pe_ttm || 0, + eps: apiData.eps || apiData.basic_eps || undefined, + pb: apiData.pb || apiData.pb_mrq || 0, + marketCap: apiData.market_cap || apiData.marketCap || apiData.circ_mv || '0', + week52Low: apiData.week52_low || apiData.week52Low || 0, + week52High: apiData.week52_high || apiData.week52High || 0, + + // 主力动态 + mainNetInflow: apiData.main_net_inflow || apiData.mainNetInflow || 0, + institutionHolding: apiData.institution_holding || apiData.institutionHolding || 0, + buyRatio: apiData.buy_ratio || apiData.buyRatio || 50, + sellRatio: apiData.sell_ratio || apiData.sellRatio || 50, + + // 更新时间 + updateTime: apiData.update_time || apiData.updateTime || new Date().toLocaleString(), + }; +}; + +interface UseStockQuoteDataResult { + quoteData: StockQuoteCardData | null; + basicInfo: BasicInfo | null; + isLoading: boolean; + error: string | null; + refetch: () => void; +} + +/** + * 股票行情数据获取 Hook + * 合并获取行情数据和基本信息 + * + * @param stockCode - 股票代码 + */ +export const useStockQuoteData = (stockCode?: string): UseStockQuoteDataResult => { + const [quoteData, setQuoteData] = useState(null); + const [basicInfo, setBasicInfo] = useState(null); + const [quoteLoading, setQuoteLoading] = useState(false); + const [basicLoading, setBasicLoading] = useState(false); + const [error, setError] = useState(null); + + // 用于手动刷新的 ref + const refetchRef = useCallback(async () => { + if (!stockCode) return; + + // 获取行情数据 + setQuoteLoading(true); + setError(null); + try { + logger.debug('useStockQuoteData', '获取股票行情', { stockCode }); + const quotes = await stockService.getQuotes([stockCode]); + const quoteResult = quotes?.[stockCode] || quotes; + const transformedData = transformQuoteData(quoteResult, stockCode); + logger.debug('useStockQuoteData', '行情数据转换完成', { stockCode, hasData: !!transformedData }); + setQuoteData(transformedData); + } catch (err) { + logger.error('useStockQuoteData', '获取行情失败', err); + setError('获取行情数据失败'); + setQuoteData(null); + } finally { + setQuoteLoading(false); + } + + // 获取基本信息 + setBasicLoading(true); + try { + const { data: result } = await axios.get(`/api/stock/${stockCode}/basic-info`); + if (result.success) { + setBasicInfo(result.data); + } + } catch (err) { + logger.error('useStockQuoteData', '获取基本信息失败', err); + } finally { + setBasicLoading(false); + } + }, [stockCode]); + + // stockCode 变化时重新获取数据(带取消支持) + useEffect(() => { + if (!stockCode) { + setQuoteData(null); + setBasicInfo(null); + return; + } + + const controller = new AbortController(); + let isCancelled = false; + + const fetchData = async () => { + // 获取行情数据 + setQuoteLoading(true); + setError(null); + try { + logger.debug('useStockQuoteData', '获取股票行情', { stockCode }); + const quotes = await stockService.getQuotes([stockCode]); + if (isCancelled) return; + const quoteResult = quotes?.[stockCode] || quotes; + const transformedData = transformQuoteData(quoteResult, stockCode); + logger.debug('useStockQuoteData', '行情数据转换完成', { stockCode, hasData: !!transformedData }); + setQuoteData(transformedData); + } catch (err: any) { + if (isCancelled || err.name === 'CanceledError') return; + logger.error('useStockQuoteData', '获取行情失败', err); + setError('获取行情数据失败'); + setQuoteData(null); + } finally { + if (!isCancelled) setQuoteLoading(false); + } + + // 获取基本信息 + setBasicLoading(true); + try { + const { data: result } = await axios.get(`/api/stock/${stockCode}/basic-info`, { + signal: controller.signal, + }); + if (isCancelled) return; + if (result.success) { + setBasicInfo(result.data); + } + } catch (err: any) { + if (isCancelled || err.name === 'CanceledError') return; + logger.error('useStockQuoteData', '获取基本信息失败', err); + } finally { + if (!isCancelled) setBasicLoading(false); + } + }; + + fetchData(); + + return () => { + isCancelled = true; + controller.abort(); + }; + }, [stockCode]); + + return { + quoteData, + basicInfo, + isLoading: quoteLoading || basicLoading, + error, + refetch: refetchRef, + }; +}; + +export default useStockQuoteData; diff --git a/src/views/Company/components/StockQuoteCard/index.tsx b/src/views/Company/components/StockQuoteCard/index.tsx new file mode 100644 index 00000000..a95b6452 --- /dev/null +++ b/src/views/Company/components/StockQuoteCard/index.tsx @@ -0,0 +1,155 @@ +/** + * StockQuoteCard - 股票行情卡片组件 + * + * 展示股票的实时行情、关键指标和主力动态 + * 采用原子组件拆分,提高可维护性和复用性 + * + * 优化:数据获取已下沉到组件内部,Props 从 11 个精简为 4 个 + */ + +import React from 'react'; +import { + Box, + Card, + CardBody, + Flex, + VStack, + Skeleton, + useDisclosure, +} from '@chakra-ui/react'; + +import { + StockHeader, + PriceDisplay, + SecondaryQuote, + KeyMetrics, + MainForceInfo, + CompanyInfo, + StockCompareModal, + STOCK_CARD_THEME, +} from './components'; +import { useStockQuoteData, useStockCompare } from './hooks'; +import type { StockQuoteCardProps } from './types'; + +const StockQuoteCard: React.FC = ({ + stockCode, + isInWatchlist = false, + isWatchlistLoading = false, + onWatchlistToggle, +}) => { + // 内部获取行情数据和基本信息 + const { quoteData, basicInfo, isLoading } = useStockQuoteData(stockCode); + + // 内部管理股票对比逻辑 + const { + currentStockInfo, + compareStockInfo, + isCompareLoading, + handleCompare: triggerCompare, + clearCompare, + } = useStockCompare(stockCode); + + // 对比弹窗控制 + const { isOpen: isCompareModalOpen, onOpen: openCompareModal, onClose: closeCompareModal } = useDisclosure(); + + // 处理对比按钮点击 + const handleCompare = (compareCode: string) => { + triggerCompare(compareCode); + openCompareModal(); + }; + + // 处理关闭对比弹窗 + const handleCloseCompare = () => { + closeCompareModal(); + clearCompare(); + }; + + const { cardBg, borderColor } = STOCK_CARD_THEME; + + // 加载中或无数据时显示骨架屏 + if (isLoading || !quoteData) { + return ( + + + + + + + + + + ); + } + + return ( + + + {/* 顶部:股票名称 + 关注/分享按钮 + 更新时间 */} + + + {/* 股票对比弹窗 */} + + + {/* 1:2 布局 */} + + {/* 左栏:价格信息 (flex=1) */} + + + + + + {/* 右栏:关键指标 + 主力动态 (flex=2) */} + + + + + + + {/* 公司信息区块 */} + {basicInfo && } + + + ); +}; + +export default StockQuoteCard; diff --git a/src/views/Company/components/StockQuoteCard/types.ts b/src/views/Company/components/StockQuoteCard/types.ts new file mode 100644 index 00000000..b1c38099 --- /dev/null +++ b/src/views/Company/components/StockQuoteCard/types.ts @@ -0,0 +1,63 @@ +/** + * StockQuoteCard 组件类型定义 + */ + +// 注:BasicInfo 和 StockInfo 类型由内部 hooks 使用,不再在 Props 中传递 +export type { StockInfo } from '../FinancialPanorama/types'; + +/** + * 股票行情卡片数据 + */ +export interface StockQuoteCardData { + // 基础信息 + name: string; // 股票名称 + code: string; // 股票代码 + indexTags: string[]; // 指数标签(如 沪深300、上证50) + industry?: string; // 所属行业(二级),如 "银行" + industryL1?: string; // 一级行业,如 "金融" + + // 价格信息 + currentPrice: number; // 当前价格 + changePercent: number; // 涨跌幅(百分比,如 3.65 表示 +3.65%) + todayOpen: number; // 今开 + yesterdayClose: number; // 昨收 + todayHigh: number; // 今日最高 + todayLow: number; // 今日最低 + + // 关键指标 + pe: number; // 市盈率 + eps?: number; // 每股收益 + pb: number; // 市净率 + marketCap: string; // 流通市值(已格式化,如 "2.73万亿") + week52Low: number; // 52周最低 + week52High: number; // 52周最高 + + // 主力动态 + mainNetInflow: number; // 主力净流入(亿) + institutionHolding: number; // 机构持仓比例(百分比) + buyRatio: number; // 买入比例(百分比) + sellRatio: number; // 卖出比例(百分比) + + // 更新时间 + updateTime: string; // 格式:YYYY-MM-DD HH:mm:ss + + // 自选状态 + isFavorite?: boolean; // 是否已加入自选 +} + +/** + * StockQuoteCard 组件 Props(优化后) + * + * 行情数据、基本信息、对比逻辑已下沉到组件内部 hooks 获取 + * Props 从 11 个精简为 4 个 + */ +export interface StockQuoteCardProps { + /** 股票代码 - 用于内部数据获取 */ + stockCode?: string; + /** 是否在自选股中(保留:涉及 Redux 和事件追踪回调) */ + isInWatchlist?: boolean; + /** 自选股操作加载中 */ + isWatchlistLoading?: boolean; + /** 自选股切换回调 */ + onWatchlistToggle?: () => void; +} diff --git a/src/views/Company/constants/index.js b/src/views/Company/constants/index.js new file mode 100644 index 00000000..88ac9330 --- /dev/null +++ b/src/views/Company/constants/index.js @@ -0,0 +1,55 @@ +// src/views/Company/constants/index.js +// 公司详情页面常量配置 + +import { FaChartLine, FaMoneyBillWave, FaChartBar, FaInfoCircle, FaBrain, FaNewspaper } from 'react-icons/fa'; + +/** + * Tab 配置 + * @type {Array<{key: string, name: string, icon: React.ComponentType}>} + */ +export const COMPANY_TABS = [ + { key: 'overview', name: '公司档案', icon: FaInfoCircle }, + { key: 'analysis', name: '深度分析', icon: FaBrain }, + { key: 'market', name: '股票行情', icon: FaChartLine }, + { key: 'financial', name: '财务全景', icon: FaMoneyBillWave }, + { key: 'forecast', name: '盈利预测', icon: FaChartBar }, + { key: 'tracking', name: '动态跟踪', icon: FaNewspaper }, +]; + +/** + * Tab 选中状态样式 + */ +export const TAB_SELECTED_STYLE = { + transform: 'scale(1.02)', + transition: 'all 0.2s', +}; + +/** + * Toast 消息配置 + */ +export const TOAST_MESSAGES = { + WATCHLIST_ADD: { title: '已加入自选', status: 'success', duration: 1500 }, + WATCHLIST_REMOVE: { title: '已从自选移除', status: 'info', duration: 1500 }, + WATCHLIST_ERROR: { title: '操作失败,请稍后重试', status: 'error', duration: 2000 }, + INVALID_CODE: { title: '无效的股票代码', status: 'error', duration: 2000 }, + LOGIN_REQUIRED: { title: '请先登录后再加入自选', status: 'warning', duration: 2000 }, +}; + +/** + * 默认股票代码 + */ +export const DEFAULT_STOCK_CODE = '000001'; + +/** + * URL 参数名 + */ +export const URL_PARAM_NAME = 'scode'; + +/** + * 根据索引获取 Tab 名称 + * @param {number} index - Tab 索引 + * @returns {string} Tab 名称 + */ +export const getTabNameByIndex = (index) => { + return COMPANY_TABS[index]?.name || 'Unknown'; +}; diff --git a/src/views/Company/hooks/useCompanyStock.js b/src/views/Company/hooks/useCompanyStock.js new file mode 100644 index 00000000..d55d220a --- /dev/null +++ b/src/views/Company/hooks/useCompanyStock.js @@ -0,0 +1,91 @@ +// src/views/Company/hooks/useCompanyStock.js +// 股票代码管理 Hook - 处理 URL 参数同步和搜索逻辑 + +import { useState, useEffect, useCallback } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { DEFAULT_STOCK_CODE, URL_PARAM_NAME } from '../constants'; + +/** + * 股票代码管理 Hook + * + * 功能: + * - 管理当前股票代码状态 + * - 双向同步 URL 参数 + * - 处理搜索输入和提交 + * + * @param {Object} options - 配置选项 + * @param {string} [options.defaultCode] - 默认股票代码 + * @param {string} [options.paramName] - URL 参数名 + * @param {Function} [options.onStockChange] - 股票代码变化回调 (newCode, prevCode) => void + * @returns {Object} 股票代码状态和操作方法 + */ +export const useCompanyStock = (options = {}) => { + const { + defaultCode = DEFAULT_STOCK_CODE, + paramName = URL_PARAM_NAME, + onStockChange, + } = options; + + const [searchParams, setSearchParams] = useSearchParams(); + + // 从 URL 参数初始化股票代码 + const [stockCode, setStockCode] = useState( + searchParams.get(paramName) || defaultCode + ); + + // 输入框状态(默认为空,不显示默认股票代码) + const [inputCode, setInputCode] = useState(''); + + /** + * 监听 URL 参数变化,同步到本地状态 + * 支持浏览器前进/后退按钮 + */ + useEffect(() => { + const urlCode = searchParams.get(paramName); + if (urlCode && urlCode !== stockCode) { + setStockCode(urlCode); + setInputCode(urlCode); + } + }, [searchParams, paramName, stockCode]); + + /** + * 执行搜索 - 更新 stockCode 和 URL + * @param {string} [code] - 可选,直接传入股票代码(用于下拉选择) + */ + const handleSearch = useCallback((code) => { + const trimmedCode = code || inputCode?.trim(); + + if (trimmedCode && trimmedCode !== stockCode) { + // 触发变化回调(用于追踪) + onStockChange?.(trimmedCode, stockCode); + + // 更新状态 + setStockCode(trimmedCode); + + // 更新 URL 参数 + setSearchParams({ [paramName]: trimmedCode }); + } + }, [inputCode, stockCode, paramName, setSearchParams, onStockChange]); + + /** + * 处理键盘事件 - 回车键触发搜索 + */ + const handleKeyDown = useCallback((e) => { + if (e.key === 'Enter') { + handleSearch(); + } + }, [handleSearch]); + + return { + // 状态 + stockCode, // 当前确认的股票代码 + inputCode, // 输入框中的值(未确认) + + // 操作方法 + setInputCode, // 更新输入框 + handleSearch, // 执行搜索 + handleKeyDown, // 处理回车键(改用 onKeyDown) + }; +}; + +export default useCompanyStock; diff --git a/src/views/Company/hooks/useCompanyWatchlist.js b/src/views/Company/hooks/useCompanyWatchlist.js new file mode 100644 index 00000000..56019470 --- /dev/null +++ b/src/views/Company/hooks/useCompanyWatchlist.js @@ -0,0 +1,166 @@ +// src/views/Company/hooks/useCompanyWatchlist.js +// 自选股管理 Hook - Company 页面专用,复用 Redux stockSlice + +import { useEffect, useCallback, useMemo, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useToast } from '@chakra-ui/react'; +import { useAuth } from '@contexts/AuthContext'; +import { logger } from '@utils/logger'; +import { + loadWatchlist, + toggleWatchlist, + optimisticAddWatchlist, + optimisticRemoveWatchlist +} from '@store/slices/stockSlice'; +import { TOAST_MESSAGES } from '../constants'; + +/** + * Company 页面自选股管理 Hook + * + * 功能: + * - 检查当前股票是否在自选股中 + * - 提供添加/移除自选股功能 + * - 与 Redux stockSlice 同步 + * + * @param {Object} options - 配置选项 + * @param {string} options.stockCode - 当前股票代码 + * @param {Object} [options.tracking] - 追踪回调 + * @param {Function} [options.tracking.onAdd] - 添加自选时的追踪回调 + * @param {Function} [options.tracking.onRemove] - 移除自选时的追踪回调 + * @returns {Object} 自选股状态和操作方法 + */ +export const useCompanyWatchlist = ({ stockCode, tracking = {} } = {}) => { + const dispatch = useDispatch(); + const toast = useToast(); + const { isAuthenticated } = useAuth(); + + // 从 Redux 获取自选股列表 + const watchlist = useSelector((state) => state.stock.watchlist); + const watchlistLoading = useSelector((state) => state.stock.loading.watchlist); + + // 追踪是否已初始化(防止无限循环) + const hasInitializedRef = useRef(false); + + /** + * 派生状态:判断当前股票是否在自选股中 + * 使用 useMemo 避免重复计算 + */ + const isInWatchlist = useMemo(() => { + if (!stockCode || !Array.isArray(watchlist)) { + return false; + } + + // 标准化股票代码(提取6位数字) + const normalize = (code) => String(code || '').match(/(\d{6})/)?.[1] || ''; + const targetCode = normalize(stockCode); + + return watchlist.some((item) => normalize(item.stock_code) === targetCode); + }, [watchlist, stockCode]); + + /** + * 初始化:加载自选股列表 + * 使用 hasInitializedRef 防止无限循环(用户可能确实没有自选股) + */ + useEffect(() => { + if (!hasInitializedRef.current && isAuthenticated && !watchlistLoading) { + hasInitializedRef.current = true; + dispatch(loadWatchlist()); + } + }, [isAuthenticated, watchlistLoading, dispatch]); + + /** + * 切换自选股状态(乐观更新模式) + * 1. 立即更新 UI(无 loading) + * 2. 后台静默请求 API + * 3. 失败时回滚并提示 + */ + const toggle = useCallback(async () => { + // 参数校验 + if (!stockCode) { + logger.warn('useCompanyWatchlist', 'toggle', '无效的股票代码', { stockCode }); + toast(TOAST_MESSAGES.INVALID_CODE); + return; + } + + // 权限校验 + if (!isAuthenticated) { + logger.warn('useCompanyWatchlist', 'toggle', '用户未登录', { stockCode }); + toast(TOAST_MESSAGES.LOGIN_REQUIRED); + return; + } + + // 标准化股票代码用于匹配 + const normalize = (code) => String(code || '').match(/(\d{6})/)?.[1] || ''; + const targetCode = normalize(stockCode); + + // 从 watchlist 中找到原始 stock_code(保持与后端数据结构一致) + const matchedItem = watchlist.find( + item => normalize(item.stock_code) === targetCode + ); + // 移除时使用原始 stock_code,添加时使用传入的 stockCode + const codeForApi = isInWatchlist ? (matchedItem?.stock_code || stockCode) : stockCode; + + // 保存当前状态用于回滚 + const wasInWatchlist = isInWatchlist; + + logger.debug('useCompanyWatchlist', '切换自选股(乐观更新)', { + stockCode, + codeForApi, + wasInWatchlist, + action: wasInWatchlist ? 'remove' : 'add', + }); + + // 1. 乐观更新:立即更新 UI(不显示 loading) + if (wasInWatchlist) { + dispatch(optimisticRemoveWatchlist({ stockCode: codeForApi })); + } else { + dispatch(optimisticAddWatchlist({ stockCode: codeForApi, stockName: matchedItem?.stock_name || '' })); + } + + try { + // 2. 后台静默请求 API + await dispatch( + toggleWatchlist({ + stockCode: codeForApi, + stockName: matchedItem?.stock_name || '', + isInWatchlist: wasInWatchlist, + }) + ).unwrap(); + + // 3. 成功:触发追踪回调(不显示 toast,状态已更新) + if (wasInWatchlist) { + tracking.onRemove?.(stockCode); + } else { + tracking.onAdd?.(stockCode); + } + } catch (error) { + // 4. 失败:回滚状态 + 显示错误提示 + logger.error('useCompanyWatchlist', 'toggle', error, { + stockCode, + wasInWatchlist, + }); + + // 回滚操作 + if (wasInWatchlist) { + // 之前在自选中,乐观删除了,现在要恢复 + dispatch(optimisticAddWatchlist({ stockCode: codeForApi, stockName: matchedItem?.stock_name || '' })); + } else { + // 之前不在自选中,乐观添加了,现在要移除 + dispatch(optimisticRemoveWatchlist({ stockCode: codeForApi })); + } + + toast(TOAST_MESSAGES.WATCHLIST_ERROR); + } + }, [stockCode, isAuthenticated, isInWatchlist, watchlist, dispatch, toast, tracking]); + + return { + // 状态 + isInWatchlist, // 是否在自选股中 + isLoading: watchlistLoading, // 仅初始加载时显示 loading(乐观更新模式) + + // 操作方法 + toggle, // 切换自选状态 + }; +}; + +export default useCompanyWatchlist; diff --git a/src/views/Company/hooks/useStockSearch.ts b/src/views/Company/hooks/useStockSearch.ts new file mode 100644 index 00000000..cd7b7ff2 --- /dev/null +++ b/src/views/Company/hooks/useStockSearch.ts @@ -0,0 +1,59 @@ +/** + * useStockSearch - 股票模糊搜索 Hook + * + * 提取自 SearchBar.js 和 CompareStockInput.tsx 的共享搜索逻辑 + * 支持按代码或名称搜索,可选排除指定股票 + */ + +import { useMemo } from 'react'; + +export interface Stock { + code: string; + name: string; +} + +interface UseStockSearchOptions { + excludeCode?: string; + limit?: number; +} + +/** + * 股票模糊搜索 Hook + * + * @param allStocks - 全部股票列表 + * @param searchTerm - 搜索关键词 + * @param options - 可选配置 + * @param options.excludeCode - 排除的股票代码(用于对比场景) + * @param options.limit - 返回结果数量限制,默认 10 + * @returns 过滤后的股票列表 + */ +export const useStockSearch = ( + allStocks: Stock[], + searchTerm: string, + options: UseStockSearchOptions = {} +): Stock[] => { + const { excludeCode, limit = 10 } = options; + + return useMemo(() => { + const trimmed = searchTerm?.trim(); + if (!trimmed) return []; + + const term = trimmed.toLowerCase(); + + return allStocks + .filter((stock) => { + // 排除指定股票 + if (excludeCode && stock.code === excludeCode) { + return false; + } + // 按代码或名称匹配 + return ( + stock.code.toLowerCase().includes(term) || + stock.name.includes(trimmed) + ); + }) + .slice(0, limit); + }, [allStocks, searchTerm, excludeCode, limit]); +}; + +export default useStockSearch; diff --git a/src/views/Company/index.js b/src/views/Company/index.js index a8141c17..dc30643f 100644 --- a/src/views/Company/index.js +++ b/src/views/Company/index.js @@ -36,6 +36,20 @@ import CompanyOverview from './CompanyOverview'; // 导入 PostHog 追踪 Hook import { useCompanyEvents } from './hooks/useCompanyEvents'; +// 页面组件 +import CompanyHeader from './components/CompanyHeader'; +import StockQuoteCard from './components/StockQuoteCard'; +import CompanyTabs from './components/CompanyTabs'; + +/** + * 公司详情页面 + * + * 功能: + * - 股票搜索与代码管理 + * - 自选股添加/移除 + * - 多维度数据展示(概览、行情、财务、预测) + * - PostHog 事件追踪 + */ const CompanyIndex = () => { const [searchParams, setSearchParams] = useSearchParams(); const [stockCode, setStockCode] = useState(searchParams.get('scode') || '000001'); @@ -113,10 +127,9 @@ const CompanyIndex = () => { // 当URL参数变化时更新股票代码 useEffect(() => { - const scode = searchParams.get('scode'); - if (scode && scode !== stockCode) { - setStockCode(scode); - setInputCode(scode); + if (stockCode !== prevStockCodeRef.current) { + trackStockSearched(stockCode, prevStockCodeRef.current); + prevStockCodeRef.current = stockCode; } }, [searchParams, stockCode]); @@ -377,5 +390,3 @@ const CompanyIndex = () => { }; export default CompanyIndex; - -