Compare commits

..

4 Commits

Author SHA1 Message Date
f5023d9ce6 update pay ui 2025-12-17 16:51:42 +08:00
c589516633 update pay ui 2025-12-17 16:46:06 +08:00
c88f13db89 update pay ui 2025-12-17 16:20:27 +08:00
5804aa27c4 update pay ui 2025-12-17 16:15:14 +08:00
186 changed files with 7748 additions and 24847 deletions

View File

@@ -1030,3 +1030,51 @@ async def get_stock_intraday_statistics(
except Exception as e:
logger.error(f"[ClickHouse] 日内统计失败: {e}", exc_info=True)
return {"success": False, "error": str(e)}
async def get_stock_code_by_name(stock_name: str) -> Dict[str, Any]:
"""
根据股票名称查询股票代码
Args:
stock_name: 股票名称(支持模糊匹配)
Returns:
匹配的股票列表,包含代码和名称
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# 使用 LIKE 进行模糊匹配
query = """
SELECT DISTINCT
SECCODE as code,
SECNAME as name,
F030V as industry
FROM ea_baseinfo
WHERE SECNAME LIKE %s
OR SECNAME = %s
ORDER BY
CASE WHEN SECNAME = %s THEN 0 ELSE 1 END,
SECCODE
LIMIT 10
"""
# 精确匹配和模糊匹配
like_pattern = f"%{stock_name}%"
await cursor.execute(query, (like_pattern, stock_name, stock_name))
results = await cursor.fetchall()
if not results:
return {
"success": False,
"error": f"未找到名称包含 '{stock_name}' 的股票"
}
return {
"success": True,
"data": results,
"count": len(results)
}

View File

@@ -495,6 +495,20 @@ TOOLS: List[ToolDefinition] = [
"required": ["query"]
}
),
ToolDefinition(
name="get_stock_code_by_name",
description="根据股票名称查询股票代码,支持模糊匹配。当只知道股票名称不知道代码时使用。",
parameters={
"type": "object",
"properties": {
"stock_name": {
"type": "string",
"description": "股票名称,例如:'贵州茅台''舒泰神''比亚迪'"
}
},
"required": ["stock_name"]
}
),
ToolDefinition(
name="get_stock_basic_info",
description="获取股票基本信息,包括公司名称、行业、地址、主营业务、高管等基础数据。",
@@ -1494,7 +1508,11 @@ async def handle_get_concept_details(args: Dict[str, Any]) -> Any:
async def handle_get_stock_concepts(args: Dict[str, Any]) -> Any:
"""处理股票概念获取"""
stock_code = args["stock_code"]
# 兼容不同的参数名: stock_code, seccode, code
stock_code = args.get("stock_code") or args.get("seccode") or args.get("code")
if not stock_code:
raise ValueError("缺少股票代码参数 (stock_code/seccode/code)")
params = {
"size": args.get("size", 50),
"sort_by": args.get("sort_by", "stock_count"),
@@ -1503,6 +1521,7 @@ async def handle_get_stock_concepts(args: Dict[str, Any]) -> Any:
if args.get("trade_date"):
params["trade_date"] = args["trade_date"]
logger.info(f"[get_stock_concepts] 查询股票 {stock_code} 的概念")
response = await HTTP_CLIENT.get(
f"{ServiceEndpoints.CONCEPT_API}/stock/{stock_code}/concepts",
params=params
@@ -1573,9 +1592,24 @@ async def handle_search_research_reports(args: Dict[str, Any]) -> Any:
response.raise_for_status()
return response.json()
async def handle_get_stock_code_by_name(args: Dict[str, Any]) -> Any:
"""根据股票名称查询股票代码"""
# 兼容不同的参数名: stock_name, name
stock_name = args.get("stock_name") or args.get("name")
if not stock_name:
return {"success": False, "error": "缺少股票名称参数 (stock_name/name)"}
logger.info(f"[get_stock_code_by_name] 查询股票名称: {stock_name}")
result = await db.get_stock_code_by_name(stock_name)
return result
async def handle_get_stock_basic_info(args: Dict[str, Any]) -> Any:
"""处理股票基本信息查询"""
seccode = args["seccode"]
# 兼容不同的参数名: seccode, stock_code, code
seccode = args.get("seccode") or args.get("stock_code") or args.get("code")
if not seccode:
return {"success": False, "error": "缺少股票代码参数 (seccode/stock_code/code)"}
result = await db.get_stock_basic_info(seccode)
if result:
return {"success": True, "data": result}
@@ -1803,6 +1837,7 @@ TOOL_HANDLERS = {
"search_limit_up_stocks": handle_search_limit_up_stocks,
"get_daily_stock_analysis": handle_get_daily_stock_analysis,
"search_research_reports": handle_search_research_reports,
"get_stock_code_by_name": handle_get_stock_code_by_name,
"get_stock_basic_info": handle_get_stock_basic_info,
"get_stock_financial_index": handle_get_stock_financial_index,
"get_stock_trade_data": handle_get_stock_trade_data,
@@ -2549,8 +2584,15 @@ A股交易时间: 上午 9:30-11:30下午 13:00-15:00
# 如果没有原生工具调用,尝试从文本内容中解析
if not native_tool_calls and assistant_message.content:
content = assistant_message.content
# 检查是否包含工具调用标记
if '<tool_call>' in content or '```tool_call' in content or '"tool":' in content:
# 检查是否包含工具调用标记(包括 DSML 格式)
has_tool_markers = (
'<tool_call>' in content or
'```tool_call' in content or
'"tool":' in content or
'DSML' in content or # DeepSeek DSML 格式
'DSML' in content # 全角竖线版本
)
if has_tool_markers:
logger.info(f"[Agent Stream] 尝试从文本内容解析工具调用")
logger.info(f"[Agent Stream] 内容预览: {content[:500]}")
text_tool_calls = self._parse_text_tool_calls(content)
@@ -2947,6 +2989,7 @@ A股交易时间: 上午 9:30-11:30下午 13:00-15:00
支持的格式:
1. <tool_call> <function=xxx> <parameter=yyy> value </parameter> </function> </tool_call>
2. ```tool_call\n{"name": "xxx", "arguments": {...}}\n```
3. DeepSeek DSML 格式: <DSMLfunction_calls> <DSMLinvoke name="xxx"> <DSMLparameter name="yyy" string="true">value</DSMLparameter> </DSMLinvoke> </DSMLfunction_calls>
返回: [{"name": "tool_name", "arguments": {...}}, ...]
"""
@@ -3006,6 +3049,47 @@ A股交易时间: 上午 9:30-11:30下午 13:00-15:00
except:
pass
# 格式4: DeepSeek DSML 格式(使用全角竖线
# <DSMLfunction_calls> <DSMLinvoke name="search_research_reports"> <DSMLparameter name="query" string="true">AI概念股</DSMLparameter> </DSMLinvoke> </DSMLfunction_calls>
# 注意:| 是全角字符
dsml_pattern = r'<[\|]DSML[\|]function_calls>(.*?)</[\|]DSML[\|]function_calls>'
dsml_matches = re.findall(dsml_pattern, content, re.DOTALL)
for dsml_content in dsml_matches:
# 解析 invoke 标签
invoke_pattern = r'<[\|]DSML[\|]invoke\s+name="(\w+)">(.*?)</[\|]DSML[\|]invoke>'
invoke_matches = re.findall(invoke_pattern, dsml_content, re.DOTALL)
for func_name, params_str in invoke_matches:
arguments = {}
# 解析参数: <DSMLparameter name="xxx" string="true/false">value</DSMLparameter>
param_pattern = r'<[\|]DSML[\|]parameter\s+name="(\w+)"\s+string="(true|false)">(.*?)</[\|]DSML[\|]parameter>'
param_matches = re.findall(param_pattern, params_str, re.DOTALL)
for param_name, is_string, param_value in param_matches:
param_value = param_value.strip()
if is_string == "false":
# 不是字符串,尝试解析为数字或 JSON
try:
arguments[param_name] = json.loads(param_value)
except:
# 尝试转为整数或浮点数
try:
arguments[param_name] = int(param_value)
except:
try:
arguments[param_name] = float(param_value)
except:
arguments[param_name] = param_value
else:
# 是字符串
arguments[param_name] = param_value
tool_calls.append({
"name": func_name,
"arguments": arguments
})
logger.info(f"[Text Tool Call] 解析到 {len(tool_calls)} 个工具调用: {tool_calls}")
return tool_calls

View File

@@ -1,84 +0,0 @@
/**
* 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<FavoriteButtonProps> = ({
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 = (
<IconButton
aria-label={label}
icon={
isLoading ? (
<Spinner size="sm" color={currentColor} />
) : (
<Star
size={size === 'sm' ? 18 : size === 'md' ? 20 : 24}
fill={isFavorite ? currentColor : 'none'}
stroke={currentColor}
/>
)
}
variant="ghost"
color={currentColor}
size={size}
onClick={onClick}
isDisabled={isLoading}
_hover={{ bg: colors.hoverBg }}
/>
);
if (showTooltip) {
return (
<Tooltip label={label} placement="top">
{iconButton}
</Tooltip>
);
}
return iconButton;
};
export default FavoriteButton;

View File

@@ -545,13 +545,19 @@ const InvestmentCalendar = () => {
render: (concepts) => (
<Space wrap>
{concepts && concepts.length > 0 ? (
concepts.slice(0, 3).map((concept, index) => (
<Tag key={index} icon={<TagsOutlined />}>
{typeof concept === 'string'
? concept
: (concept?.concept || concept?.name || '未知')}
</Tag>
))
concepts.slice(0, 3).map((concept, index) => {
// 兼容多种数据格式:字符串、数组、对象
const conceptName = typeof concept === 'string'
? concept
: Array.isArray(concept)
? concept[0]
: concept?.concept || concept?.name || '';
return (
<Tag key={index} icon={<TagsOutlined />}>
{conceptName}
</Tag>
);
})
) : (
<Text type="secondary"></Text>
)}
@@ -943,7 +949,7 @@ const InvestmentCalendar = () => {
<Table
dataSource={selectedStocks}
columns={stockColumns}
rowKey={(record) => record.code}
rowKey={(record) => record[0]}
size="middle"
pagination={false}
/>

View File

@@ -1,232 +0,0 @@
/**
* SubTabContainer - 二级导航容器组件
*
* 用于模块内的子功能切换(如公司档案下的股权结构、管理团队等)
* 与 TabContainer一级导航区分无 Card 包裹,直接融入父容器
*
* @example
* ```tsx
* <SubTabContainer
* tabs={[
* { key: 'tab1', name: 'Tab 1', icon: FaHome, component: Tab1 },
* { key: 'tab2', name: 'Tab 2', icon: FaUser, component: Tab2 },
* ]}
* componentProps={{ stockCode: '000001' }}
* onTabChange={(index, key) => 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<any>;
}
/**
* 主题配置
*/
export interface SubTabTheme {
bg: string;
borderColor: string;
tabSelectedBg: string;
tabSelectedColor: string;
tabUnselectedColor: string;
tabHoverBg: string;
}
/**
* 预设主题
*/
const THEME_PRESETS: Record<string, SubTabTheme> = {
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<string, any>;
/** 默认选中的 Tab 索引 */
defaultIndex?: number;
/** 受控模式下的当前索引 */
index?: number;
/** Tab 变更回调 */
onTabChange?: (index: number, tabKey: string) => void;
/** 主题预设 */
themePreset?: 'blackGold' | 'default';
/** 自定义主题(优先级高于预设) */
theme?: Partial<SubTabTheme>;
/** 内容区内边距 */
contentPadding?: number;
/** 是否懒加载 */
isLazy?: boolean;
/** TabList 右侧自定义内容 */
rightElement?: React.ReactNode;
}
const SubTabContainer: React.FC<SubTabContainerProps> = 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<Set<number>>(
() => 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 (
<Box>
<Tabs
isLazy={isLazy}
variant="unstyled"
index={currentIndex}
onChange={handleTabChange}
>
<TabList
bg={theme.bg}
borderBottom="1px solid"
borderColor={theme.borderColor}
pl={0}
pr={2}
py={1.5}
flexWrap="nowrap"
gap={1}
alignItems="center"
overflowX="auto"
css={{
'&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
color={theme.tabUnselectedColor}
borderRadius="full"
px={2.5}
py={1.5}
fontSize="xs"
whiteSpace="nowrap"
flexShrink={0}
_selected={{
bg: theme.tabSelectedBg,
color: theme.tabSelectedColor,
fontWeight: 'bold',
}}
_hover={{
bg: theme.tabHoverBg,
}}
>
<HStack spacing={1}>
{tab.icon && <Icon as={tab.icon} boxSize={3} />}
<Text>{tab.name}</Text>
</HStack>
</Tab>
))}
{rightElement && (
<>
<Spacer />
<Box flexShrink={0}>{rightElement}</Box>
</>
)}
</TabList>
<TabPanels p={contentPadding}>
{tabs.map((tab, idx) => {
const Component = tab.component;
// 懒加载:只渲染已访问过的 Tab
const shouldRender = !isLazy || visitedTabs.has(idx);
return (
<TabPanel key={tab.key} p={0}>
{shouldRender && Component ? (
<Component {...componentProps} />
) : null}
</TabPanel>
);
})}
</TabPanels>
</Tabs>
</Box>
);
});
SubTabContainer.displayName = 'SubTabContainer';
export default SubTabContainer;

View File

@@ -1,56 +0,0 @@
/**
* 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<TabNavigationProps> = ({
tabs,
themeColors,
borderRadius = 'lg',
}) => {
return (
<TabList
bg={themeColors.bg}
borderBottom="1px solid"
borderColor={themeColors.dividerColor}
borderTopLeftRadius={borderRadius}
borderTopRightRadius={borderRadius}
pl={0}
pr={4}
py={2}
flexWrap="wrap"
gap={2}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
color={themeColors.unselectedText}
borderRadius="full"
px={4}
py={2}
fontSize="sm"
_selected={{
bg: themeColors.selectedBg,
color: themeColors.selectedText,
fontWeight: 'bold',
}}
_hover={{
bg: 'whiteAlpha.100',
}}
>
<HStack spacing={2}>
{tab.icon && <Icon as={tab.icon} boxSize={4} />}
<Text>{tab.name}</Text>
</HStack>
</Tab>
))}
</TabList>
);
};
export default TabNavigation;

View File

@@ -1,55 +0,0 @@
/**
* TabContainer 常量和主题预设
*/
import type { ThemeColors, ThemePreset } from './types';
/**
* 主题预设配置
*/
export const THEME_PRESETS: Record<ThemePreset, Required<ThemeColors>> = {
// 黑金主题(原 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,
};

View File

@@ -1,134 +0,0 @@
/**
* TabContainer 通用 Tab 容器组件
*
* 功能:
* - 管理 Tab 切换状态(支持受控/非受控模式)
* - 动态渲染 Tab 导航和内容
* - 支持多种主题预设(黑金、默认、深色、浅色)
* - 支持自定义主题颜色
* - 支持懒加载
*
* @example
* // 基础用法(传入 components
* <TabContainer
* tabs={[
* { key: 'tab1', name: 'Tab 1', icon: FaHome, component: Tab1Content },
* { key: 'tab2', name: 'Tab 2', icon: FaUser, component: Tab2Content },
* ]}
* componentProps={{ userId: '123' }}
* onTabChange={(index, key) => console.log('切换到', key)}
* />
*
* @example
* // 自定义渲染用法(使用 children
* <TabContainer tabs={tabs} themePreset="dark">
* <TabPanel>自定义内容 1</TabPanel>
* <TabPanel>自定义内容 2</TabPanel>
* </TabContainer>
*/
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<TabContainerProps> = ({
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<ThemeColors> = 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 (
<TabPanel key={tab.key} px={panelPadding} py={panelPadding}>
{Component ? <Component {...componentProps} /> : null}
</TabPanel>
);
});
};
return (
<Card shadow={shadow} bg={themeColors.bg} borderRadius={borderRadius}>
<CardBody p={0}>
<Tabs
isLazy={isLazy}
variant="unstyled"
size={size}
index={currentIndex}
onChange={handleTabChange}
>
{/* Tab 导航 */}
<TabNavigation
tabs={tabs}
themeColors={themeColors}
borderRadius={borderRadius}
/>
{/* Tab 内容面板 */}
<TabPanels>{renderTabPanels()}</TabPanels>
</Tabs>
</CardBody>
</Card>
);
};
export default TabContainer;

View File

@@ -1,85 +0,0 @@
/**
* 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<any>;
}
/**
* 主题颜色配置
*/
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<string, any>;
/** 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<ThemeColors>;
/** 容器圆角 */
borderRadius?: string;
}

View File

@@ -1,100 +0,0 @@
/**
* TabPanelContainer - Tab 面板通用容器组件
*
* 提供统一的:
* - Loading 状态处理
* - VStack 布局
* - 免责声明(可选)
*
* @example
* ```tsx
* <TabPanelContainer loading={loading} showDisclaimer>
* <YourContent />
* </TabPanelContainer>
* ```
*/
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,
}) => (
<Center h={height}>
<VStack spacing={3}>
<Spinner size="lg" color="#D4AF37" thickness="3px" />
<Text fontSize="sm" color="gray.500">
{message}
</Text>
</VStack>
</Center>
);
/**
* 免责声明组件
*/
const DisclaimerText: React.FC<{ text: string }> = ({ text }) => (
<Text mt={4} color="gray.500" fontSize="12px" lineHeight="1.5">
{text}
</Text>
);
/**
* Tab 面板通用容器
*/
const TabPanelContainer: React.FC<TabPanelContainerProps> = memo(
({
loading = false,
loadingMessage = '加载中...',
loadingHeight = '200px',
spacing = 6,
padding = 4,
showDisclaimer = false,
disclaimerText = DEFAULT_DISCLAIMER,
children,
}) => {
if (loading) {
return <LoadingState message={loadingMessage} height={loadingHeight} />;
}
return (
<Box p={padding}>
<VStack spacing={spacing} align="stretch">
{children}
</VStack>
{showDisclaimer && <DisclaimerText text={disclaimerText} />}
</Box>
);
}
);
TabPanelContainer.displayName = 'TabPanelContainer';
export default TabPanelContainer;

File diff suppressed because it is too large Load Diff

View File

@@ -874,20 +874,8 @@ 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)) ||
// 搜索 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))
e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query))
);
// 如果搜索结果为空,返回所有事件(宽松模式)
if (filteredEvents.length === 0) {
filteredEvents = allEvents;
}
}
// 行业筛选
@@ -1054,7 +1042,7 @@ function generateTransmissionChain(industry, index) {
let nodeName;
if (nodeType === 'company' && industryStock) {
nodeName = industryStock.stock_name;
nodeName = industryStock.name;
} else if (nodeType === 'industry') {
nodeName = `${industry}产业`;
} else if (nodeType === 'policy') {
@@ -1145,7 +1133,7 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
const stock = industryStocks[j % industryStocks.length];
relatedStocks.push({
stock_code: stock.stock_code,
stock_name: stock.stock_name,
stock_name: stock.name,
relation_desc: relationDescriptions[j % relationDescriptions.length]
});
}
@@ -1157,7 +1145,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.stock_name,
stock_name: randomStock.name,
relation_desc: relationDescriptions[relatedStocks.length % relationDescriptions.length]
});
}

View File

@@ -10,323 +10,73 @@ export const generateFinancialData = (stockCode) => {
// 股票基本信息
stockInfo: {
stock_code: stockCode,
stock_name: stockCode === '000001' ? '平安银行' : '示例公司',
code: stockCode,
name: stockCode === '000001' ? '平安银行' : '示例公司',
industry: stockCode === '000001' ? '银行' : '制造业',
list_date: '1991-04-03',
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%'
}
market: 'SZ'
},
// 资产负债表 - 嵌套结构
// 资产负债表
balanceSheet: periods.map((period, i) => ({
period,
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
}
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
})),
// 利润表 - 嵌套结构
// 利润表
incomeStatement: periods.map((period, i) => ({
period,
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
}
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
})),
// 现金流量表 - 嵌套结构
// 现金流量表
cashflow: periods.map((period, i) => ({
period,
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
}
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
})),
// 财务指标 - 嵌套结构
// 财务指标
financialMetrics: periods.map((period, i) => ({
period,
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
}
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 // 银行特殊
})),
// 主营业务 - 按产品/业务分类
// 主营业务
mainBusiness: {
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_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 }
],
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 },
]
},
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 }
]
},
@@ -342,74 +92,48 @@ export const generateFinancialData = (stockCode) => {
publish_date: '2024-10-15'
},
// 行业排名(数组格式,符合 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 }
}
}
]
}
],
// 行业排名
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 }
]
},
// 期间对比 - 营收与利润趋势数据
periodComparison: [
{
period: '2024-09-30',
performance: {
revenue: 41500000000, // 415亿
net_profit: 13420000000 // 134.2亿
// 期间对比
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]
}
},
{
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亿
}
}
]
]
}
};
};

View File

@@ -24,9 +24,8 @@ 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: 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
turnover_rate: (Math.random() * 2 + 0.5).toFixed(2), // 0.5-2.5%
change_pct: (Math.random() * 6 - 3).toFixed(2) // -3% to +3%
};
})
},
@@ -79,45 +78,36 @@ export const generateMarketData = (stockCode) => {
}))
},
// 股权质押 - 匹配 PledgeData[] 类型
// 股权质押
pledgeData: {
success: true,
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
};
})
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'
}
},
// 市场摘要 - 匹配 MarketSummary 类型
// 市场摘要
summaryData: {
success: true,
data: {
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
}
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
}
},
@@ -141,57 +131,26 @@ export const generateMarketData = (stockCode) => {
})
},
// 最新分时数据 - 匹配 MinuteData 类型
// 最新分时数据
latestMinuteData: {
success: true,
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;
})(),
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)
};
}),
code: stockCode,
name: stockCode === '000001' ? '平安银行' : '示例股票',
trade_date: new Date().toISOString().split('T')[0],
type: '1min'
type: 'minute'
}
};
};

View File

@@ -43,10 +43,12 @@ export const companyHandlers = [
const { stockCode } = params;
const data = getCompanyData(stockCode);
// 直接返回 keyFactorsTimeline 对象(包含 key_factors 和 development_timeline
return HttpResponse.json({
success: true,
data: data.keyFactorsTimeline
data: {
timeline: data.keyFactorsTimeline,
total: data.keyFactorsTimeline.length
}
});
}),
@@ -67,19 +69,10 @@ 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: formatted
data: data.actualControl
});
}),
@@ -88,19 +81,10 @@ 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: formatted
data: data.concentration
});
}),

View File

@@ -120,12 +120,9 @@ export const eventHandlers = [
try {
const result = generateMockEvents(params);
// 返回格式兼容 NewsPanel 期望的结构
// NewsPanel 期望: { success, data: [], pagination: {} }
return HttpResponse.json({
success: true,
data: result.events, // 事件数组
pagination: result.pagination, // 分页信息
data: result,
message: '获取成功'
});
} catch (error) {
@@ -139,14 +136,16 @@ export const eventHandlers = [
{
success: false,
error: '获取事件列表失败',
data: [],
pagination: {
page: 1,
per_page: 10,
total: 0,
pages: 0,
has_prev: false,
has_next: false
data: {
events: [],
pagination: {
page: 1,
per_page: 10,
total: 0,
pages: 0, // ← 对齐后端字段名
has_prev: false, // ← 对齐后端
has_next: false // ← 对齐后端
}
}
},
{ status: 500 }

View File

@@ -387,68 +387,6 @@ 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);
@@ -476,25 +414,6 @@ 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 => {
@@ -507,11 +426,6 @@ 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}`,
@@ -525,23 +439,7 @@ 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(),
// 行业和指数标签
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))
update_time: new Date().toISOString()
};
});

View File

@@ -35,9 +35,9 @@ export const lazyComponents = {
// 公司相关模块
CompanyIndex: React.lazy(() => import('@views/Company')),
ForecastReport: React.lazy(() => import('@views/Company/components/ForecastReport')),
FinancialPanorama: React.lazy(() => import('@views/Company/components/FinancialPanorama')),
MarketDataView: React.lazy(() => import('@views/Company/components/MarketDataView')),
ForecastReport: React.lazy(() => import('@views/Company/ForecastReport')),
FinancialPanorama: React.lazy(() => import('@views/Company/FinancialPanorama')),
MarketDataView: React.lazy(() => import('@views/Company/MarketDataView')),
// Agent模块
AgentChat: React.lazy(() => import('@views/AgentChat')),

View File

@@ -4,56 +4,6 @@ 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 ====================
/**
@@ -203,28 +153,13 @@ export const fetchExpectationScore = createAsyncThunk(
/**
* 加载用户自选股列表(包含完整信息)
* 缓存策略Redux 内存缓存 → localStorage 持久缓存7天 → API 请求
*/
export const loadWatchlist = createAsyncThunk(
'stock/loadWatchlist',
async (_, { getState }) => {
async () => {
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'
@@ -237,10 +172,6 @@ export const loadWatchlist = createAsyncThunk(
stock_code: item.stock_code,
stock_name: item.stock_name,
}));
// 保存到 localStorage 缓存
saveWatchlistToCache(watchlistData);
logger.debug('stockSlice', '自选股列表加载成功', {
count: watchlistData.length
});
@@ -409,26 +340,6 @@ 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) => {
@@ -559,10 +470,9 @@ const stockSlice = createSlice({
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
}
})
// fulfilled: 同步更新 localStorage 缓存
.addCase(toggleWatchlist.fulfilled, (state) => {
// 状态已在 pending 时更新,这里同步到 localStorage
saveWatchlistToCache(state.watchlist);
// fulfilled: 乐观更新模式下状态已在 pending 更新,这里无需操作
.addCase(toggleWatchlist.fulfilled, () => {
// 状态已在 pending 时更新
});
}
});
@@ -571,9 +481,7 @@ export const {
updateQuote,
updateQuotes,
clearQuotes,
clearEventCache,
optimisticAddWatchlist,
optimisticRemoveWatchlist
clearEventCache
} = stockSlice.actions;
export default stockSlice.reducer;

View File

@@ -103,71 +103,3 @@ export const PriceArrow = ({ value }) => {
return <Icon color={color} boxSize="16px" />;
};
// ==================== 货币/数值格式化 ====================
/**
* 格式化货币金额(自动选择单位:亿元/万元/元)
* @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) + '%';
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,50 +0,0 @@
/**
* 综合对比分析组件 - 黑金主题
*/
import React from 'react';
import { Box } from '@chakra-ui/react';
import ReactECharts from 'echarts-for-react';
import { formatUtils } from '@services/financialService';
import { getComparisonChartOption } from '../utils';
import type { ComparisonAnalysisProps } from '../types';
// 黑金主题样式
const THEME = {
cardBg: 'transparent',
border: 'rgba(212, 175, 55, 0.2)',
};
export const ComparisonAnalysis: React.FC<ComparisonAnalysisProps> = ({ comparison }) => {
if (!Array.isArray(comparison) || comparison.length === 0) return null;
const revenueData = comparison
.map((item) => ({
period: formatUtils.getReportType(item.period),
value: item.performance.revenue / 100000000, // 转换为亿
}))
.reverse();
const profitData = comparison
.map((item) => ({
period: formatUtils.getReportType(item.period),
value: item.performance.net_profit / 100000000, // 转换为亿
}))
.reverse();
const chartOption = getComparisonChartOption(revenueData, profitData);
return (
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
p={4}
>
<ReactECharts option={chartOption} style={{ height: '350px' }} />
</Box>
);
};
export default ComparisonAnalysis;

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