Compare commits
57 Commits
feature_bu
...
26bc5fece0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26bc5fece0 | ||
|
|
1c35ea24cd | ||
|
|
d76b0d32d6 | ||
|
|
eb093a5189 | ||
|
|
2c0b06e6a0 | ||
|
|
b3fb472c66 | ||
|
|
6797f54b6c | ||
|
|
a47e0feed8 | ||
|
|
13fa91a998 | ||
|
|
fba7a7ee96 | ||
|
|
32a73efb55 | ||
|
|
7819b4f8a2 | ||
|
|
6f74c1c1de | ||
|
|
3fed9d2d65 | ||
|
|
514917c0eb | ||
|
|
6ce913d79b | ||
|
|
6d5594556b | ||
|
|
c32091e83e | ||
|
|
2994de98c2 | ||
|
|
c237a4dc0c | ||
|
|
395dc27fe2 | ||
|
|
3abee6b907 | ||
|
|
d86cef9f79 | ||
|
|
9aaf4400c1 | ||
|
|
1cd8a2d7e9 | ||
|
|
af3cdc24b1 | ||
|
|
bfb6ef63d0 | ||
|
|
722d038b56 | ||
|
|
5f6e4387e5 | ||
|
|
38076534b1 | ||
|
|
a7ab87f7c4 | ||
|
|
9a77bb6f0b | ||
|
|
bf8847698b | ||
|
|
7c83ffe008 | ||
|
|
8786fa7b06 | ||
|
|
0997cd9992 | ||
|
|
c8d704363d | ||
|
|
0de4a1f7af | ||
|
|
3382dd1036 | ||
|
|
9423094af2 | ||
|
|
4f38505a80 | ||
|
|
4274341ed5 | ||
|
|
40f6eaced6 | ||
|
|
2dd7dd755a | ||
|
|
04ce16df56 | ||
|
|
d7759b1da3 | ||
|
|
701f96855e | ||
|
|
cd1a5b743f | ||
|
|
18c83237e2 | ||
|
|
c1e10e6205 | ||
|
|
4954c58525 | ||
|
|
91bd581a5e | ||
|
|
258708fca0 | ||
|
|
90391729bb | ||
|
|
2148d319ad | ||
|
|
c61d58b0e3 | ||
|
|
ed1c7b9fa9 |
2
app.py
2
app.py
@@ -165,7 +165,7 @@ WECHAT_OPEN_APPID = 'wxa8d74c47041b5f87'
|
||||
WECHAT_OPEN_APPSECRET = 'eedef95b11787fd7ca7f1acc6c9061bc'
|
||||
|
||||
# 微信公众号配置(H5 网页授权用)
|
||||
WECHAT_MP_APPID = 'wx4e4b759f8fa9e43a'
|
||||
WECHAT_MP_APPID = 'wx8afd36f7c7b21ba0'
|
||||
WECHAT_MP_APPSECRET = 'ef1ca9064af271bb0405330efbc495aa'
|
||||
|
||||
# 微信回调地址
|
||||
|
||||
84
src/components/FavoriteButton/index.tsx
Normal file
84
src/components/FavoriteButton/index.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* FavoriteButton - 通用关注/收藏按钮组件(图标按钮)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { IconButton, Tooltip, Spinner } from '@chakra-ui/react';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
export interface FavoriteButtonProps {
|
||||
/** 是否已关注 */
|
||||
isFavorite: boolean;
|
||||
/** 加载状态 */
|
||||
isLoading?: boolean;
|
||||
/** 点击回调 */
|
||||
onClick: () => void;
|
||||
/** 按钮大小 */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** 颜色主题 */
|
||||
colorScheme?: 'gold' | 'default';
|
||||
/** 是否显示 tooltip */
|
||||
showTooltip?: boolean;
|
||||
}
|
||||
|
||||
// 颜色配置
|
||||
const COLORS = {
|
||||
gold: {
|
||||
active: '#F4D03F', // 已关注 - 亮金色
|
||||
inactive: '#C9A961', // 未关注 - 暗金色
|
||||
hoverBg: 'whiteAlpha.100',
|
||||
},
|
||||
default: {
|
||||
active: 'yellow.400',
|
||||
inactive: 'gray.400',
|
||||
hoverBg: 'gray.100',
|
||||
},
|
||||
};
|
||||
|
||||
const FavoriteButton: React.FC<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;
|
||||
@@ -544,19 +544,13 @@ const InvestmentCalendar = () => {
|
||||
render: (concepts) => (
|
||||
<Space wrap>
|
||||
{concepts && concepts.length > 0 ? (
|
||||
concepts.slice(0, 3).map((concept, index) => {
|
||||
// 兼容多种数据格式:字符串、数组、对象
|
||||
const conceptName = typeof concept === 'string'
|
||||
? concept
|
||||
: Array.isArray(concept)
|
||||
? concept[0]
|
||||
: concept?.concept || concept?.name || '';
|
||||
return (
|
||||
<Tag key={index} icon={<TagsOutlined />}>
|
||||
{conceptName}
|
||||
</Tag>
|
||||
);
|
||||
})
|
||||
concepts.slice(0, 3).map((concept, index) => (
|
||||
<Tag key={index} icon={<TagsOutlined />}>
|
||||
{typeof concept === 'string'
|
||||
? concept
|
||||
: (concept?.concept || concept?.name || '未知')}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<Text type="secondary">无</Text>
|
||||
)}
|
||||
@@ -948,7 +942,7 @@ const InvestmentCalendar = () => {
|
||||
<Table
|
||||
dataSource={selectedStocks}
|
||||
columns={stockColumns}
|
||||
rowKey={(record) => record[0]}
|
||||
rowKey={(record) => record.code}
|
||||
size="middle"
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
49
src/components/TabContainer/TabNavigation.tsx
Normal file
49
src/components/TabContainer/TabNavigation.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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
|
||||
py={4}
|
||||
bg={themeColors.bg}
|
||||
borderTopLeftRadius={borderRadius}
|
||||
borderTopRightRadius={borderRadius}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
color={themeColors.unselectedText}
|
||||
borderRadius="full"
|
||||
px={4}
|
||||
py={2}
|
||||
_selected={{
|
||||
bg: themeColors.selectedBg,
|
||||
color: themeColors.selectedText,
|
||||
}}
|
||||
_hover={{
|
||||
color: themeColors.selectedText,
|
||||
}}
|
||||
mr={index < tabs.length - 1 ? 2 : 0}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
{tab.icon && <Icon as={tab.icon} boxSize="18px" />}
|
||||
<Text fontSize="15px">{tab.name}</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabNavigation;
|
||||
56
src/components/TabContainer/constants.ts
Normal file
56
src/components/TabContainer/constants.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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,
|
||||
showDivider: true,
|
||||
borderRadius: 'lg',
|
||||
shadow: 'lg',
|
||||
panelPadding: 0,
|
||||
};
|
||||
140
src/components/TabContainer/index.tsx
Normal file
140
src/components/TabContainer/index.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 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,
|
||||
Divider,
|
||||
} 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,
|
||||
showDivider = DEFAULT_CONFIG.showDivider,
|
||||
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="soft-rounded"
|
||||
colorScheme="blue"
|
||||
size={size}
|
||||
index={currentIndex}
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
{/* Tab 导航 */}
|
||||
<TabNavigation
|
||||
tabs={tabs}
|
||||
themeColors={themeColors}
|
||||
borderRadius={borderRadius}
|
||||
/>
|
||||
|
||||
{/* 分割线 */}
|
||||
{showDivider && <Divider borderColor={themeColors.dividerColor} />}
|
||||
|
||||
{/* Tab 内容面板 */}
|
||||
<TabPanels>{renderTabPanels()}</TabPanels>
|
||||
</Tabs>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabContainer;
|
||||
87
src/components/TabContainer/types.ts
Normal file
87
src/components/TabContainer/types.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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';
|
||||
/** 是否显示分割线 */
|
||||
showDivider?: boolean;
|
||||
/** 容器圆角 */
|
||||
borderRadius?: string;
|
||||
/** 容器阴影 */
|
||||
shadow?: string;
|
||||
/** 自定义 Tab 面板内边距 */
|
||||
panelPadding?: number | string;
|
||||
/** 子元素(用于自定义渲染 Tab 内容) */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* TabNavigation 组件 Props
|
||||
*/
|
||||
export interface TabNavigationProps {
|
||||
/** Tab 配置数组 */
|
||||
tabs: TabConfig[];
|
||||
/** 主题颜色 */
|
||||
themeColors: Required<ThemeColors>;
|
||||
/** 容器圆角 */
|
||||
borderRadius?: string;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,12 +43,10 @@ export const companyHandlers = [
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
// 直接返回 keyFactorsTimeline 对象(包含 key_factors 和 development_timeline)
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
timeline: data.keyFactorsTimeline,
|
||||
total: data.keyFactorsTimeline.length
|
||||
}
|
||||
data: data.keyFactorsTimeline
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -69,10 +67,19 @@ export const companyHandlers = [
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
const raw = data.actualControl;
|
||||
|
||||
// 数据已经是数组格式,只做数值转换(holding_ratio 从 0-100 转为 0-1)
|
||||
const formatted = Array.isArray(raw)
|
||||
? raw.map(item => ({
|
||||
...item,
|
||||
holding_ratio: item.holding_ratio > 1 ? item.holding_ratio / 100 : item.holding_ratio,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.actualControl
|
||||
data: formatted
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -81,10 +88,19 @@ export const companyHandlers = [
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
const raw = data.concentration;
|
||||
|
||||
// 数据已经是数组格式,只做数值转换(holding_ratio 从 0-100 转为 0-1)
|
||||
const formatted = Array.isArray(raw)
|
||||
? raw.map(item => ({
|
||||
...item,
|
||||
holding_ratio: item.holding_ratio > 1 ? item.holding_ratio / 100 : item.holding_ratio,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.concentration
|
||||
data: formatted
|
||||
});
|
||||
}),
|
||||
|
||||
|
||||
@@ -368,6 +368,25 @@ export const stockHandlers = [
|
||||
stockMap[s.code] = s.name;
|
||||
});
|
||||
|
||||
// 行业和指数映射表
|
||||
const stockIndustryMap = {
|
||||
'000001': { industry_l1: '金融', industry: '银行', index_tags: ['沪深300', '上证180'] },
|
||||
'600519': { industry_l1: '消费', industry: '白酒', index_tags: ['沪深300', '上证50'] },
|
||||
'300750': { industry_l1: '工业', industry: '电池', index_tags: ['创业板50'] },
|
||||
'601318': { industry_l1: '金融', industry: '保险', index_tags: ['沪深300', '上证50'] },
|
||||
'600036': { industry_l1: '金融', industry: '银行', index_tags: ['沪深300', '上证50'] },
|
||||
'000858': { industry_l1: '消费', industry: '白酒', index_tags: ['沪深300'] },
|
||||
'002594': { industry_l1: '汽车', industry: '乘用车', index_tags: ['沪深300', '创业板指'] },
|
||||
};
|
||||
|
||||
const defaultIndustries = [
|
||||
{ industry_l1: '科技', industry: '软件' },
|
||||
{ industry_l1: '医药', industry: '化学制药' },
|
||||
{ industry_l1: '消费', industry: '食品' },
|
||||
{ industry_l1: '金融', industry: '证券' },
|
||||
{ industry_l1: '工业', industry: '机械' },
|
||||
];
|
||||
|
||||
// 为每只股票生成报价数据
|
||||
const quotesData = {};
|
||||
codes.forEach(stockCode => {
|
||||
@@ -380,6 +399,11 @@ export const stockHandlers = [
|
||||
// 昨收
|
||||
const prevClose = parseFloat((basePrice - change).toFixed(2));
|
||||
|
||||
// 获取行业和指数信息
|
||||
const codeWithoutSuffix = stockCode.replace(/\.(SH|SZ)$/i, '');
|
||||
const industryInfo = stockIndustryMap[codeWithoutSuffix] ||
|
||||
defaultIndustries[Math.floor(Math.random() * defaultIndustries.length)];
|
||||
|
||||
quotesData[stockCode] = {
|
||||
code: stockCode,
|
||||
name: stockMap[stockCode] || `股票${stockCode}`,
|
||||
@@ -393,7 +417,11 @@ export const stockHandlers = [
|
||||
volume: Math.floor(Math.random() * 100000000),
|
||||
amount: parseFloat((Math.random() * 10000000000).toFixed(2)),
|
||||
market: stockCode.startsWith('6') ? 'SH' : 'SZ',
|
||||
update_time: new Date().toISOString()
|
||||
update_time: new Date().toISOString(),
|
||||
// 行业和指数标签
|
||||
industry_l1: industryInfo.industry_l1,
|
||||
industry: industryInfo.industry,
|
||||
index_tags: industryInfo.index_tags || []
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -35,9 +35,9 @@ export const lazyComponents = {
|
||||
|
||||
// 公司相关模块
|
||||
CompanyIndex: React.lazy(() => import('@views/Company')),
|
||||
ForecastReport: React.lazy(() => import('@views/Company/ForecastReport')),
|
||||
FinancialPanorama: React.lazy(() => import('@views/Company/FinancialPanorama')),
|
||||
MarketDataView: React.lazy(() => import('@views/Company/MarketDataView')),
|
||||
ForecastReport: React.lazy(() => import('@views/Company/components/ForecastReport')),
|
||||
FinancialPanorama: React.lazy(() => import('@views/Company/components/FinancialPanorama')),
|
||||
MarketDataView: React.lazy(() => import('@views/Company/components/MarketDataView')),
|
||||
|
||||
// Agent模块
|
||||
AgentChat: React.lazy(() => import('@views/AgentChat')),
|
||||
|
||||
@@ -4,6 +4,56 @@ import { eventService, stockService } from '../../services/eventService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getApiBase } from '../../utils/apiConfig';
|
||||
|
||||
// ==================== Watchlist 缓存配置 ====================
|
||||
const WATCHLIST_CACHE_KEY = 'watchlist_cache';
|
||||
const WATCHLIST_CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7天
|
||||
|
||||
/**
|
||||
* 从 localStorage 读取自选股缓存
|
||||
*/
|
||||
const loadWatchlistFromCache = () => {
|
||||
try {
|
||||
const cached = localStorage.getItem(WATCHLIST_CACHE_KEY);
|
||||
if (!cached) return null;
|
||||
|
||||
const { data, timestamp } = JSON.parse(cached);
|
||||
const now = Date.now();
|
||||
|
||||
// 检查缓存是否过期(7天)
|
||||
if (now - timestamp > WATCHLIST_CACHE_DURATION) {
|
||||
localStorage.removeItem(WATCHLIST_CACHE_KEY);
|
||||
logger.debug('stockSlice', '自选股缓存已过期');
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug('stockSlice', '自选股 localStorage 缓存命中', {
|
||||
count: data?.length || 0,
|
||||
age: Math.round((now - timestamp) / 1000 / 60) + '分钟前'
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('stockSlice', 'loadWatchlistFromCache', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存自选股到 localStorage
|
||||
*/
|
||||
const saveWatchlistToCache = (data) => {
|
||||
try {
|
||||
localStorage.setItem(WATCHLIST_CACHE_KEY, JSON.stringify({
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
logger.debug('stockSlice', '自选股已缓存到 localStorage', {
|
||||
count: data?.length || 0
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('stockSlice', 'saveWatchlistToCache', error);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== Async Thunks ====================
|
||||
|
||||
/**
|
||||
@@ -153,13 +203,28 @@ export const fetchExpectationScore = createAsyncThunk(
|
||||
|
||||
/**
|
||||
* 加载用户自选股列表(包含完整信息)
|
||||
* 缓存策略:Redux 内存缓存 → localStorage 持久缓存(7天) → API 请求
|
||||
*/
|
||||
export const loadWatchlist = createAsyncThunk(
|
||||
'stock/loadWatchlist',
|
||||
async () => {
|
||||
async (_, { getState }) => {
|
||||
logger.debug('stockSlice', 'loadWatchlist');
|
||||
|
||||
try {
|
||||
// 1. 先检查 Redux 内存缓存
|
||||
const reduxCached = getState().stock.watchlist;
|
||||
if (reduxCached && reduxCached.length > 0) {
|
||||
logger.debug('stockSlice', 'Redux watchlist 缓存命中', { count: reduxCached.length });
|
||||
return reduxCached;
|
||||
}
|
||||
|
||||
// 2. 再检查 localStorage 持久缓存(7天有效期)
|
||||
const localCached = loadWatchlistFromCache();
|
||||
if (localCached && localCached.length > 0) {
|
||||
return localCached;
|
||||
}
|
||||
|
||||
// 3. 缓存无效,调用 API
|
||||
const apiBase = getApiBase();
|
||||
const response = await fetch(`${apiBase}/api/account/watchlist`, {
|
||||
credentials: 'include'
|
||||
@@ -172,6 +237,10 @@ export const loadWatchlist = createAsyncThunk(
|
||||
stock_code: item.stock_code,
|
||||
stock_name: item.stock_name,
|
||||
}));
|
||||
|
||||
// 保存到 localStorage 缓存
|
||||
saveWatchlistToCache(watchlistData);
|
||||
|
||||
logger.debug('stockSlice', '自选股列表加载成功', {
|
||||
count: watchlistData.length
|
||||
});
|
||||
@@ -340,6 +409,26 @@ const stockSlice = createSlice({
|
||||
delete state.historicalEventsCache[eventId];
|
||||
delete state.chainAnalysisCache[eventId];
|
||||
delete state.expectationScores[eventId];
|
||||
},
|
||||
|
||||
/**
|
||||
* 乐观更新:添加自选股(同步)
|
||||
*/
|
||||
optimisticAddWatchlist: (state, action) => {
|
||||
const { stockCode, stockName } = action.payload;
|
||||
// 避免重复添加
|
||||
const exists = state.watchlist.some(item => item.stock_code === stockCode);
|
||||
if (!exists) {
|
||||
state.watchlist.push({ stock_code: stockCode, stock_name: stockName || '' });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 乐观更新:移除自选股(同步)
|
||||
*/
|
||||
optimisticRemoveWatchlist: (state, action) => {
|
||||
const { stockCode } = action.payload;
|
||||
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
|
||||
}
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
@@ -470,9 +559,10 @@ const stockSlice = createSlice({
|
||||
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
|
||||
}
|
||||
})
|
||||
// fulfilled: 乐观更新模式下状态已在 pending 更新,这里无需操作
|
||||
.addCase(toggleWatchlist.fulfilled, () => {
|
||||
// 状态已在 pending 时更新
|
||||
// fulfilled: 同步更新 localStorage 缓存
|
||||
.addCase(toggleWatchlist.fulfilled, (state) => {
|
||||
// 状态已在 pending 时更新,这里同步到 localStorage
|
||||
saveWatchlistToCache(state.watchlist);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -481,7 +571,9 @@ export const {
|
||||
updateQuote,
|
||||
updateQuotes,
|
||||
clearQuotes,
|
||||
clearEventCache
|
||||
clearEventCache,
|
||||
optimisticAddWatchlist,
|
||||
optimisticRemoveWatchlist
|
||||
} = stockSlice.actions;
|
||||
|
||||
export default stockSlice.reducer;
|
||||
|
||||
@@ -103,3 +103,71 @@ 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
635
src/views/Company/STRUCTURE.md
Normal file
635
src/views/Company/STRUCTURE.md
Normal file
@@ -0,0 +1,635 @@
|
||||
# Company 目录结构说明
|
||||
|
||||
> 最后更新:2025-12-11
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/views/Company/
|
||||
├── index.js # 页面入口(纯组合层)
|
||||
├── STRUCTURE.md # 本文档
|
||||
│
|
||||
├── components/ # UI 组件
|
||||
│ │
|
||||
│ ├── CompanyHeader/ # 页面头部
|
||||
│ │ ├── index.js # 组合导出
|
||||
│ │ └── SearchBar.js # 股票搜索栏
|
||||
│ │
|
||||
│ ├── CompanyTabs/ # Tab 切换容器
|
||||
│ │ ├── index.js # Tab 容器(状态管理 + 内容渲染)
|
||||
│ │ └── TabNavigation.js # Tab 导航栏
|
||||
│ │
|
||||
│ ├── StockQuoteCard/ # 股票行情卡片(TypeScript)
|
||||
│ │ ├── index.tsx # 主组件
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ └── mockData.ts # Mock 数据
|
||||
│ │
|
||||
│ ├── CompanyOverview/ # Tab: 公司概览(TypeScript)
|
||||
│ │ ├── index.tsx # 主组件(组合层)
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ ├── utils.ts # 格式化工具
|
||||
│ │ ├── DeepAnalysisTab/ # 深度分析 Tab(21 个 TS 文件)
|
||||
│ │ ├── NewsEventsTab.js # 新闻事件 Tab
|
||||
│ │ │
|
||||
│ │ ├── hooks/ # 数据 Hooks
|
||||
│ │ │ ├── useBasicInfo.ts # 基本信息 Hook
|
||||
│ │ │ ├── useShareholderData.ts # 股权结构 Hook(4 APIs)
|
||||
│ │ │ ├── useManagementData.ts # 管理团队 Hook
|
||||
│ │ │ ├── useAnnouncementsData.ts # 公告数据 Hook
|
||||
│ │ │ ├── useBranchesData.ts # 分支机构 Hook
|
||||
│ │ │ ├── useDisclosureData.ts # 披露日程 Hook
|
||||
│ │ │ └── useCompanyOverviewData.ts # [已废弃] 原合并 Hook
|
||||
│ │ │
|
||||
│ │ ├── components/ # 股权结构子组件
|
||||
│ │ │ └── shareholder/
|
||||
│ │ │ ├── index.ts # 导出
|
||||
│ │ │ ├── ActualControlCard.tsx # 实控人卡片
|
||||
│ │ │ ├── ConcentrationCard.tsx # 股权集中度卡片
|
||||
│ │ │ └── ShareholdersTable.tsx # 股东表格
|
||||
│ │ │
|
||||
│ │ └── BasicInfoTab/ # 基本信息 Tab(可配置化)
|
||||
│ │ ├── index.tsx # 主组件(可配置)
|
||||
│ │ ├── config.ts # Tab 配置 + 黑金主题
|
||||
│ │ ├── utils.ts # 格式化工具函数
|
||||
│ │ └── components/ # 子组件
|
||||
│ │ ├── index.ts # 组件统一导出
|
||||
│ │ ├── LoadingState.tsx # 加载状态组件
|
||||
│ │ ├── ShareholderPanel.tsx # 股权结构面板
|
||||
│ │ ├── AnnouncementsPanel.tsx # 公告信息面板
|
||||
│ │ ├── BranchesPanel.tsx # 分支机构面板
|
||||
│ │ ├── BusinessInfoPanel.tsx # 工商信息面板
|
||||
│ │ ├── DisclosureSchedulePanel.tsx # 披露日程面板
|
||||
│ │ └── management/ # 管理团队模块
|
||||
│ │ ├── index.ts # 模块导出
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ ├── ManagementPanel.tsx # 主组件(useMemo)
|
||||
│ │ ├── CategorySection.tsx # 分类区块(memo)
|
||||
│ │ └── ManagementCard.tsx # 人员卡片(memo)
|
||||
│ │
|
||||
│ ├── MarketDataView/ # Tab: 股票行情(TypeScript)
|
||||
│ │ ├── index.tsx # 主组件入口
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ ├── constants.ts # 主题配置、常量
|
||||
│ │ ├── services/
|
||||
│ │ │ └── marketService.ts # API 服务层
|
||||
│ │ ├── hooks/
|
||||
│ │ │ └── useMarketData.ts # 数据获取 Hook
|
||||
│ │ ├── utils/
|
||||
│ │ │ ├── formatUtils.ts # 格式化工具函数
|
||||
│ │ │ └── chartOptions.ts # ECharts 图表配置
|
||||
│ │ └── components/
|
||||
│ │ ├── index.ts # 组件导出
|
||||
│ │ ├── ThemedCard.tsx # 主题化卡片
|
||||
│ │ ├── MarkdownRenderer.tsx # Markdown 渲染
|
||||
│ │ ├── StockSummaryCard.tsx # 股票概览卡片
|
||||
│ │ └── AnalysisModal.tsx # 涨幅分析模态框
|
||||
│ │
|
||||
│ ├── DeepAnalysis/ # Tab: 深度分析
|
||||
│ │ └── index.js
|
||||
│ │
|
||||
│ ├── DynamicTracking/ # Tab: 动态跟踪
|
||||
│ │ └── index.js
|
||||
│ │
|
||||
│ ├── FinancialPanorama/ # Tab: 财务全景(待拆分)
|
||||
│ │ └── index.js
|
||||
│ │
|
||||
│ └── ForecastReport/ # Tab: 盈利预测(待拆分)
|
||||
│ └── index.js
|
||||
│
|
||||
├── hooks/ # 页面级 Hooks
|
||||
│ ├── useCompanyStock.js # 股票代码管理(URL 同步)
|
||||
│ ├── useCompanyWatchlist.js # 自选股管理(Redux 集成)
|
||||
│ ├── useCompanyEvents.js # PostHog 事件追踪
|
||||
│ └── useStockQuote.js # 股票行情数据 Hook
|
||||
│
|
||||
└── constants/ # 常量定义
|
||||
└── index.js # Tab 配置、Toast 消息、默认值
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文件职责说明
|
||||
|
||||
### 入口文件
|
||||
|
||||
#### `index.js` - 页面入口
|
||||
- **职责**:纯组合层,协调 Hooks 和 Components
|
||||
- **代码行数**:95 行
|
||||
- **依赖**:
|
||||
- `useCompanyStock` - 股票代码状态
|
||||
- `useCompanyWatchlist` - 自选股状态
|
||||
- `useCompanyEvents` - 事件追踪
|
||||
- `CompanyHeader` - 页面头部
|
||||
- `CompanyTabs` - Tab 切换区
|
||||
|
||||
---
|
||||
|
||||
### Hooks 目录
|
||||
|
||||
#### `useCompanyStock.js` - 股票代码管理
|
||||
- **功能**:
|
||||
- 管理当前股票代码状态
|
||||
- 双向同步 URL 参数(支持浏览器前进/后退)
|
||||
- 处理搜索输入和提交
|
||||
- **返回值**:
|
||||
```js
|
||||
{
|
||||
stockCode, // 当前确认的股票代码
|
||||
inputCode, // 输入框中的值(未确认)
|
||||
setInputCode, // 更新输入框
|
||||
handleSearch, // 执行搜索
|
||||
handleKeyPress, // 处理回车键
|
||||
}
|
||||
```
|
||||
- **依赖**:`react-router-dom` (useSearchParams)
|
||||
|
||||
#### `useCompanyWatchlist.js` - 自选股管理
|
||||
- **功能**:
|
||||
- 检查当前股票是否在自选股中
|
||||
- 提供添加/移除自选股功能
|
||||
- 与 Redux stockSlice 同步
|
||||
- **返回值**:
|
||||
```js
|
||||
{
|
||||
isInWatchlist, // 是否在自选股中
|
||||
isLoading, // 操作进行中
|
||||
toggle, // 切换自选状态
|
||||
}
|
||||
```
|
||||
- **依赖**:Redux (`stockSlice`)、`AuthContext`、Chakra UI (useToast)
|
||||
|
||||
#### `useCompanyEvents.js` - 事件追踪
|
||||
- **功能**:
|
||||
- 页面浏览追踪
|
||||
- 股票搜索追踪
|
||||
- Tab 切换追踪
|
||||
- 自选股操作追踪
|
||||
- **返回值**:
|
||||
```js
|
||||
{
|
||||
trackStockSearched, // 追踪股票搜索
|
||||
trackTabChanged, // 追踪 Tab 切换
|
||||
trackWatchlistAdded, // 追踪添加自选
|
||||
trackWatchlistRemoved, // 追踪移除自选
|
||||
}
|
||||
```
|
||||
- **依赖**:PostHog (`usePostHogTrack`)
|
||||
|
||||
---
|
||||
|
||||
### Components 目录
|
||||
|
||||
#### `CompanyHeader/` - 页面头部
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `index.js` | 组合 SearchBar 和 WatchlistButton |
|
||||
| `SearchBar.js` | 股票代码搜索输入框 |
|
||||
| `WatchlistButton.js` | 自选股添加/移除按钮 |
|
||||
|
||||
**Props 接口**:
|
||||
```js
|
||||
<CompanyHeader
|
||||
stockCode={string} // 当前股票代码
|
||||
inputCode={string} // 输入框值
|
||||
onInputChange={func} // 输入变化回调
|
||||
onSearch={func} // 搜索回调
|
||||
onKeyPress={func} // 键盘事件回调
|
||||
isInWatchlist={bool} // 是否在自选中
|
||||
isWatchlistLoading={bool} // 自选操作加载中
|
||||
onWatchlistToggle={func} // 自选切换回调
|
||||
bgColor={string} // 背景色
|
||||
/>
|
||||
```
|
||||
|
||||
#### `CompanyTabs/` - Tab 切换
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `index.js` | Tab 容器,管理切换状态,渲染 Tab 内容 |
|
||||
| `TabNavigation.js` | Tab 导航栏(4个 Tab 按钮) |
|
||||
|
||||
**Props 接口**:
|
||||
```js
|
||||
<CompanyTabs
|
||||
stockCode={string} // 当前股票代码
|
||||
onTabChange={func} // Tab 变更回调 (index, tabName, prevIndex)
|
||||
bgColor={string} // 背景色
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Constants 目录
|
||||
|
||||
#### `constants/index.js` - 常量配置
|
||||
- `COMPANY_TABS` - Tab 配置数组(key, name, icon)
|
||||
- `TAB_SELECTED_STYLE` - Tab 选中样式
|
||||
- `TOAST_MESSAGES` - Toast 消息配置
|
||||
- `DEFAULT_STOCK_CODE` - 默认股票代码 ('000001')
|
||||
- `URL_PARAM_NAME` - URL 参数名 ('scode')
|
||||
- `getTabNameByIndex()` - 根据索引获取 Tab 名称
|
||||
|
||||
---
|
||||
|
||||
### Tab 内容组件(`components/` 目录下)
|
||||
|
||||
| 组件 | Tab 名称 | 职责 | 代码行数 |
|
||||
|------|----------|------|----------|
|
||||
| `CompanyOverview/` | 公司概览 | 公司基本信息、相关事件 | - |
|
||||
| `MarketDataView/` | 股票行情 | K线图、实时行情 | - |
|
||||
| `FinancialPanorama/` | 财务全景 | 财务报表、指标分析 | 2153 行 |
|
||||
| `ForecastReport/` | 盈利预测 | 分析师预测、目标价 | 161 行 |
|
||||
|
||||
> 📌 所有 Tab 内容组件已文件夹化并统一放置在 `components/` 目录下
|
||||
|
||||
---
|
||||
|
||||
## 数据流示意
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ index.js (页面入口) │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
|
||||
│ │ useCompanyStock │ │useCompanyWatchlist│ │useCompanyEvents│
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ • stockCode │ │ • isInWatchlist │ │ • track* │ │
|
||||
│ │ • inputCode │ │ • toggle │ │ functions │ │
|
||||
│ │ • handleSearch │ │ │ │ │ │
|
||||
│ └────────┬────────┘ └────────┬────────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ └──────────┬─────────┴───────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ CompanyHeader │ │
|
||||
│ │ ┌─────────────┐ ┌──────────────────┐ │ │
|
||||
│ │ │ SearchBar │ │ WatchlistButton │ │ │
|
||||
│ │ └─────────────┘ └──────────────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ CompanyTabs │ │
|
||||
│ │ ┌─────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ TabNavigation │ │ │
|
||||
│ │ │ [概览] [行情] [财务] [预测] │ │ │
|
||||
│ │ └─────────────────────────────────────────────────┘ │ │
|
||||
│ │ ┌─────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ TabPanels │ │ │
|
||||
│ │ │ • CompanyOverview │ │ │
|
||||
│ │ │ • MarketDataView │ │ │
|
||||
│ │ │ • FinancialPanorama │ │ │
|
||||
│ │ │ • ForecastReport │ │ │
|
||||
│ │ └─────────────────────────────────────────────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 重构记录
|
||||
|
||||
### 2025-12-09 重构
|
||||
|
||||
**改动概述**:
|
||||
- `index.js` 从 **349 行** 精简至 **95 行**(减少 73%)
|
||||
- 提取 **3 个自定义 Hooks**
|
||||
- 提取 **2 个组件目录**(CompanyHeader、CompanyTabs)
|
||||
- 抽离常量到 `constants/index.js`
|
||||
|
||||
**修复的问题**:
|
||||
1. **无限循环 Bug**:`useCompanyWatchlist` 中使用 `useRef` 防止重复初始化
|
||||
2. **Hook 调用顺序**:确保 `useCompanyEvents` 在 `useCompanyStock` 之后调用(依赖 stockCode)
|
||||
3. **类型检查**:`CompanyOverview.js` 中 `event.keywords` 渲染时添加类型检查,支持字符串和对象两种格式
|
||||
|
||||
**设计原则**:
|
||||
- **关注点分离**:每个 Hook 只负责单一职责
|
||||
- **纯组合层**:index.js 不包含业务逻辑,只负责组合
|
||||
- **Props 透传**:通过 Props 将状态和回调传递给子组件
|
||||
|
||||
### 2025-12-09 文件夹化
|
||||
|
||||
**改动概述**:
|
||||
- 所有 4 个 Tab 内容组件统一移动到 `components/` 目录
|
||||
- `CompanyOverview.js` → `components/CompanyOverview/index.js`
|
||||
- `MarketDataView.js` → `components/MarketDataView/index.js`
|
||||
- `FinancialPanorama.js` → `components/FinancialPanorama/index.js`(2153 行)
|
||||
- `ForecastReport.js` → `components/ForecastReport/index.js`(161 行)
|
||||
- 更新 `CompanyTabs/index.js` 中的导入路径
|
||||
|
||||
**目的**:
|
||||
- 统一目录结构,所有组件都在 `components/` 下
|
||||
- 为后期组件拆分做准备,便于添加子组件、hooks、utils 等
|
||||
|
||||
### 2025-12-10 CompanyOverview 拆分(TypeScript)
|
||||
|
||||
**改动概述**:
|
||||
- `CompanyOverview/index.js` 从 **330 行** 精简至 **50 行**(减少 85%)
|
||||
- 采用 **TypeScript** 进行拆分,提高类型安全性
|
||||
- 提取 **1 个自定义 Hook**(`useCompanyOverviewData`)
|
||||
- 提取 **1 个子组件**(`CompanyHeaderCard`)
|
||||
- 抽离类型定义到 `types.ts`
|
||||
- 抽离工具函数到 `utils.ts`
|
||||
|
||||
**拆分后文件结构**:
|
||||
```
|
||||
CompanyOverview/
|
||||
├── index.tsx # 主组件(组合层,约 60 行)
|
||||
├── CompanyHeaderCard.tsx # 头部卡片组件(约 130 行)
|
||||
├── BasicInfoTab.js # 基本信息 Tab(懒加载版本,约 994 行)
|
||||
├── DeepAnalysisTab/ # 深度分析 Tab(21 个 TS 文件,见 2025-12-11 重构记录)
|
||||
├── NewsEventsTab.js # 新闻事件 Tab
|
||||
├── types.ts # 类型定义(约 50 行)
|
||||
├── utils.ts # 格式化工具(约 20 行)
|
||||
└── hooks/
|
||||
├── useBasicInfo.ts # 基本信息 Hook(1 API)
|
||||
├── useShareholderData.ts # 股权结构 Hook(4 APIs)
|
||||
├── useManagementData.ts # 管理团队 Hook(1 API)
|
||||
├── useAnnouncementsData.ts # 公告数据 Hook(1 API)
|
||||
├── useBranchesData.ts # 分支机构 Hook(1 API)
|
||||
├── useDisclosureData.ts # 披露日程 Hook(1 API)
|
||||
└── useCompanyOverviewData.ts # [已废弃] 原合并 Hook
|
||||
```
|
||||
|
||||
**懒加载架构**(2025-12-10 优化):
|
||||
- `index.tsx` 只加载 `useBasicInfo`(1 个 API)用于头部卡片
|
||||
- `BasicInfoTab.js` 使用 `isLazy` + 独立子组件实现懒加载
|
||||
- 每个内层 Tab 使用独立 Hook,点击时才加载数据
|
||||
|
||||
**Hooks 说明**:
|
||||
| Hook | API 数量 | 用途 |
|
||||
|------|----------|------|
|
||||
| `useBasicInfo` | 1 | 公司基本信息(头部卡片 + 工商信息 Tab) |
|
||||
| `useShareholderData` | 4 | 实控人、股权集中度、十大股东、十大流通股东 |
|
||||
| `useManagementData` | 1 | 管理团队数据 |
|
||||
| `useAnnouncementsData` | 1 | 公司公告列表 |
|
||||
| `useBranchesData` | 1 | 分支机构列表 |
|
||||
| `useDisclosureData` | 1 | 财报披露日程 |
|
||||
|
||||
**类型定义**(`types.ts`):
|
||||
- `BasicInfo` - 公司基本信息
|
||||
- `ActualControl` - 实际控制人
|
||||
- `Concentration` - 股权集中度
|
||||
- `Management` - 管理层信息
|
||||
- `Shareholder` - 股东信息
|
||||
- `Branch` - 分支机构
|
||||
- `Announcement` - 公告信息
|
||||
- `DisclosureSchedule` - 披露计划
|
||||
- `CompanyOverviewData` - Hook 返回值类型
|
||||
- `CompanyOverviewProps` - 组件 Props 类型
|
||||
- `CompanyHeaderCardProps` - 头部卡片 Props 类型
|
||||
|
||||
**工具函数**(`utils.ts`):
|
||||
- `formatRegisteredCapital(value)` - 格式化注册资本(万元/亿元)
|
||||
- `formatDate(dateString)` - 格式化日期
|
||||
|
||||
**设计原则**:
|
||||
- **渐进式 TypeScript 迁移**:新拆分的文件使用 TypeScript,旧文件暂保持 JS
|
||||
- **关注点分离**:数据加载逻辑提取到 Hook,UI 逻辑保留在组件
|
||||
- **类型复用**:统一的类型定义便于在多个文件间共享
|
||||
- **懒加载优化**:减少首屏 API 请求,按需加载数据
|
||||
|
||||
### 2025-12-10 懒加载优化
|
||||
|
||||
**改动概述**:
|
||||
- 将 `useCompanyOverviewData`(9 个 API)拆分为 6 个独立 Hook
|
||||
- `CompanyOverview/index.tsx` 只加载 `useBasicInfo`(1 个 API)
|
||||
- `BasicInfoTab.js` 使用 5 个懒加载子组件,配合 `isLazy` 实现按需加载
|
||||
- 页面初次加载从 **9 个 API** 减少到 **1 个 API**
|
||||
|
||||
**懒加载子组件**(BasicInfoTab.js 内部):
|
||||
| 子组件 | Hook | 功能 |
|
||||
|--------|------|------|
|
||||
| `ShareholderTabPanel` | `useShareholderData` | 股权结构(4 APIs) |
|
||||
| `ManagementTabPanel` | `useManagementData` | 管理团队 |
|
||||
| `AnnouncementsTabPanel` | `useAnnouncementsData` + `useDisclosureData` | 公告 + 披露日程 |
|
||||
| `BranchesTabPanel` | `useBranchesData` | 分支机构 |
|
||||
| `BusinessInfoTabPanel` | - | 工商信息(使用父组件传入的 basicInfo) |
|
||||
|
||||
**实现原理**:
|
||||
- Chakra UI `Tabs` 的 `isLazy` 属性延迟渲染 TabPanel
|
||||
- 每个 TabPanel 使用独立子组件,组件内调用 Hook
|
||||
- 子组件只在首次激活时渲染,此时 Hook 才执行并发起 API 请求
|
||||
|
||||
| Tab 模块 | 中文名称 | 功能说明 |
|
||||
|-------------------|------|----------------------------|
|
||||
| CompanyOverview | 公司概览 | 公司基本信息、股权结构、管理层、公告等(9个接口) |
|
||||
| DeepAnalysis | 深度分析 | 公司深度研究报告、投资逻辑分析 |
|
||||
| MarketDataView | 股票行情 | K线图、实时行情、技术指标 |
|
||||
| FinancialPanorama | 财务全景 | 财务报表(资产负债表、利润表、现金流)、财务指标分析 |
|
||||
| ForecastReport | 盈利预测 | 分析师预测、目标价、评级 |
|
||||
| DynamicTracking | 动态跟踪 | 相关事件、新闻动态、投资日历 |
|
||||
|
||||
### 2025-12-10 MarketDataView TypeScript 拆分
|
||||
|
||||
**改动概述**:
|
||||
- `MarketDataView/index.js` 从 **2060 行** 拆分为 **12 个 TypeScript 文件**
|
||||
- 采用 **TypeScript** 进行重构,提高类型安全性
|
||||
- 提取 **1 个自定义 Hook**(`useMarketData`)
|
||||
- 提取 **4 个子组件**(ThemedCard、MarkdownRenderer、StockSummaryCard、AnalysisModal)
|
||||
- 抽离 API 服务到 `services/marketService.ts`
|
||||
- 抽离图表配置到 `utils/chartOptions.ts`
|
||||
|
||||
**拆分后文件结构**:
|
||||
```
|
||||
MarketDataView/
|
||||
├── index.tsx # 主组件入口(~1049 行)
|
||||
├── types.ts # 类型定义(~383 行)
|
||||
├── constants.ts # 主题配置、常量(~49 行)
|
||||
├── services/
|
||||
│ └── marketService.ts # API 服务层(~173 行)
|
||||
├── hooks/
|
||||
│ └── useMarketData.ts # 数据获取 Hook(~193 行)
|
||||
├── utils/
|
||||
│ ├── formatUtils.ts # 格式化工具函数(~175 行)
|
||||
│ └── chartOptions.ts # ECharts 图表配置生成器(~698 行)
|
||||
└── components/
|
||||
├── index.ts # 组件导出(~8 行)
|
||||
├── ThemedCard.tsx # 主题化卡片(~32 行)
|
||||
├── MarkdownRenderer.tsx # Markdown 渲染(~65 行)
|
||||
├── StockSummaryCard.tsx # 股票概览卡片(~133 行)
|
||||
└── AnalysisModal.tsx # 涨幅分析模态框(~188 行)
|
||||
```
|
||||
|
||||
**文件职责说明**:
|
||||
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `index.tsx` | ~1049 | 主组件,包含 5 个 Tab 面板(交易数据、融资融券、大宗交易、龙虎榜、股权质押) |
|
||||
| `types.ts` | ~383 | 所有 TypeScript 类型定义(Theme、TradeDayData、MinuteData、FundingData 等) |
|
||||
| `constants.ts` | ~49 | 主题配置(light/dark)、周期选项常量 |
|
||||
| `marketService.ts` | ~173 | API 服务封装(getMarketData、getMinuteData、getBigDealData 等) |
|
||||
| `useMarketData.ts` | ~193 | 数据获取 Hook,管理所有市场数据状态 |
|
||||
| `formatUtils.ts` | ~175 | 数字/日期/涨跌幅格式化工具 |
|
||||
| `chartOptions.ts` | ~698 | ECharts 配置生成器(K线图、分钟图、融资融券图、质押图) |
|
||||
| `ThemedCard.tsx` | ~32 | 主题化卡片容器组件 |
|
||||
| `MarkdownRenderer.tsx` | ~65 | Markdown 内容渲染组件 |
|
||||
| `StockSummaryCard.tsx` | ~133 | 股票概览卡片(价格、涨跌幅、成交量等) |
|
||||
| `AnalysisModal.tsx` | ~188 | 涨幅分析详情模态框 |
|
||||
|
||||
**类型定义**(`types.ts`):
|
||||
- `Theme` - 主题配置类型
|
||||
- `TradeDayData` - 日线交易数据
|
||||
- `MinuteData` - 分钟线数据
|
||||
- `FundingDayData` - 融资融券数据
|
||||
- `BigDealData` / `BigDealDayStats` - 大宗交易数据
|
||||
- `UnusualData` / `UnusualDayData` - 龙虎榜数据
|
||||
- `PledgeData` - 股权质押数据
|
||||
- `RiseAnalysis` - 涨幅分析数据
|
||||
- `MarketSummary` - 市场概览数据
|
||||
- `VerificationReport` - 验证报告数据
|
||||
- 各组件 Props 类型
|
||||
|
||||
**Hook 返回值**(`useMarketData`):
|
||||
```typescript
|
||||
{
|
||||
loading: boolean;
|
||||
summary: MarketSummary | null;
|
||||
tradeData: TradeDayData[];
|
||||
minuteData: MinuteData | null;
|
||||
minuteLoading: boolean;
|
||||
fundingData: FundingDayData[];
|
||||
bigDealData: BigDealData | null;
|
||||
unusualData: UnusualData | null;
|
||||
pledgeData: PledgeData | null;
|
||||
analysisMap: Record<number, RiseAnalysis>;
|
||||
refetch: () => Promise<void>;
|
||||
loadMinuteData: () => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
**设计原则**:
|
||||
- **TypeScript 类型安全**:所有数据结构有完整类型定义
|
||||
- **服务层分离**:API 调用统一在 `marketService.ts` 中管理
|
||||
- **图表配置抽离**:复杂的 ECharts 配置集中在 `chartOptions.ts`
|
||||
- **组件复用**:通用组件(ThemedCard、MarkdownRenderer)可在其他模块使用
|
||||
|
||||
### 2025-12-10 ManagementPanel 拆分重构
|
||||
|
||||
**改动概述**:
|
||||
- `ManagementPanel.tsx` 从 **180 行** 拆分为 **5 个 TypeScript 文件**
|
||||
- 创建 `management/` 子目录,模块化管理
|
||||
- 添加性能优化(`useMemo`、`React.memo`)
|
||||
|
||||
**拆分后文件结构**:
|
||||
```
|
||||
components/management/
|
||||
├── index.ts # 模块导出
|
||||
├── types.ts # 类型定义(~35 行)
|
||||
├── ManagementPanel.tsx # 主组件(~105 行,useMemo 优化)
|
||||
├── CategorySection.tsx # 分类区块组件(~65 行,memo)
|
||||
└── ManagementCard.tsx # 人员卡片组件(~100 行,memo)
|
||||
```
|
||||
|
||||
**类型定义**(`types.ts`):
|
||||
- `ManagementPerson` - 管理人员信息
|
||||
- `ManagementCategory` - 分类类型(高管/董事/监事/其他)
|
||||
- `CategorizedManagement` - 分类后的数据结构
|
||||
- `CategoryConfig` - 分类配置(图标、颜色)
|
||||
|
||||
**性能优化**:
|
||||
- `useMemo` - 缓存 `categorizeManagement()` 分类计算结果
|
||||
- `React.memo` - `ManagementCard` 和 `CategorySection` 使用 memo 包装
|
||||
- 常量提取 - `CATEGORY_CONFIG` 和 `CATEGORY_ORDER` 提取到组件外部
|
||||
|
||||
**设计原则**:
|
||||
- **职责分离**:卡片渲染、分类区块、数据处理各自独立
|
||||
- **类型安全**:消除 `any` 类型,完整的 TypeScript 类型定义
|
||||
- **可复用性**:`ManagementCard` 可独立使用
|
||||
|
||||
### 2025-12-11 DeepAnalysisTab 模块化拆分(TypeScript)
|
||||
|
||||
**改动概述**:
|
||||
- `DeepAnalysisTab.js` 从 **1,796 行** 拆分为 **21 个 TypeScript 文件**
|
||||
- 采用**原子设计模式**(atoms/components/organisms)组织代码
|
||||
- 完整 TypeScript 类型定义
|
||||
- 格式化工具合并到全局 `src/utils/priceFormatters.js`
|
||||
|
||||
**拆分后文件结构**:
|
||||
```
|
||||
DeepAnalysisTab/
|
||||
├── index.tsx # 主入口组件,组合所有 Card 子组件
|
||||
├── types.ts # TypeScript 类型定义(接口、数据结构)
|
||||
├── atoms/ # 原子组件(基础 UI 元素)
|
||||
│ ├── index.ts # 原子组件统一导出
|
||||
│ ├── DisclaimerBox.tsx # 免责声明警告框(黄色 Alert,用 6 次)
|
||||
│ ├── ScoreBar.tsx # 评分进度条(带颜色渐变,用 8 次)
|
||||
│ ├── BusinessTreeItem.tsx # 业务结构树形项(递归组件)
|
||||
│ └── KeyFactorCard.tsx # 关键因素卡片(带影响方向图标)
|
||||
├── components/ # Card 容器组件(页面区块)
|
||||
│ ├── index.ts # Card 组件统一导出
|
||||
│ ├── CorePositioningCard.tsx # 核心定位卡片(行业地位、核心优势)
|
||||
│ ├── CompetitiveAnalysisCard.tsx # 竞争地位分析卡片(雷达图 + 评分条)
|
||||
│ ├── BusinessStructureCard.tsx # 业务结构分析卡片(树形展示)
|
||||
│ ├── ValueChainCard.tsx # 产业链分析卡片(Tabs: 上游/中游/下游)
|
||||
│ ├── KeyFactorsCard.tsx # 关键因素卡片(Accordion 折叠面板)
|
||||
│ ├── TimelineCard.tsx # 发展时间线卡片(正面/负面事件统计)
|
||||
│ ├── BusinessSegmentsCard.tsx # 业务板块详情卡片(可展开/折叠)
|
||||
│ └── StrategyAnalysisCard.tsx # 战略分析卡片(战略方向 + 战略举措)
|
||||
├── organisms/ # 复杂组件(含状态管理和 API 调用)
|
||||
│ ├── ValueChainNodeCard/ # 产业链节点组件
|
||||
│ │ ├── index.tsx # 产业链节点卡片(点击展开详情)
|
||||
│ │ └── RelatedCompaniesModal.tsx # 相关公司模态框(API 获取公司列表)
|
||||
│ └── TimelineComponent/ # 时间线组件
|
||||
│ ├── index.tsx # 时间线主组件(事件列表渲染)
|
||||
│ └── EventDetailModal.tsx # 事件详情模态框(查看完整事件信息)
|
||||
└── utils/
|
||||
└── chartOptions.ts # ECharts 图表配置(雷达图、桑基图)
|
||||
```
|
||||
|
||||
**组件依赖关系**:
|
||||
```
|
||||
index.tsx
|
||||
├── CorePositioningCard
|
||||
├── CompetitiveAnalysisCard
|
||||
│ ├── ScoreBar (atom)
|
||||
│ ├── DisclaimerBox (atom)
|
||||
│ └── ReactECharts (雷达图)
|
||||
├── BusinessStructureCard
|
||||
│ └── BusinessTreeItem (atom, 递归)
|
||||
├── ValueChainCard
|
||||
│ └── ValueChainNodeCard (organism)
|
||||
│ └── RelatedCompaniesModal
|
||||
├── KeyFactorsCard
|
||||
│ └── KeyFactorCard (atom)
|
||||
├── TimelineCard
|
||||
│ └── TimelineComponent (organism)
|
||||
│ └── EventDetailModal
|
||||
├── BusinessSegmentsCard
|
||||
└── StrategyAnalysisCard
|
||||
└── DisclaimerBox (atom)
|
||||
```
|
||||
|
||||
**类型定义**(`types.ts`):
|
||||
- `DeepAnalysisTabProps` - 主组件 Props
|
||||
- `QualitativeAnalysis` - 定性分析数据
|
||||
- `CompetitivePosition` - 竞争地位数据
|
||||
- `BusinessStructureItem` - 业务结构项
|
||||
- `ValueChainData` - 产业链数据
|
||||
- `ValueChainNode` - 产业链节点
|
||||
- `KeyFactor` - 关键因素
|
||||
- `DevelopmentTimeline` - 发展时间线
|
||||
- `TimelineEvent` - 时间线事件
|
||||
- `BusinessSegment` - 业务板块
|
||||
- `Strategy` - 战略分析
|
||||
|
||||
**工具函数位置**:
|
||||
| 函数 | 文件位置 | 说明 |
|
||||
|------|----------|------|
|
||||
| `formatCurrency` | `src/utils/priceFormatters.js` | 货币格式化 |
|
||||
| `formatBusinessRevenue` | `src/utils/priceFormatters.js` | 营收格式化(亿/万) |
|
||||
| `formatPercentage` | `src/utils/priceFormatters.js` | 百分比格式化 |
|
||||
| `getRadarChartOption` | `DeepAnalysisTab/utils/chartOptions.ts` | 雷达图 ECharts 配置 |
|
||||
| `getSankeyChartOption` | `DeepAnalysisTab/utils/chartOptions.ts` | 桑基图 ECharts 配置 |
|
||||
|
||||
**优化效果**:
|
||||
| 指标 | 优化前 | 优化后 | 改善 |
|
||||
|------|--------|--------|------|
|
||||
| 主文件行数 | 1,796 | ~117 | -93% |
|
||||
| 文件数量 | 1 (.js) | 21 (.tsx/.ts) | 模块化 + TS |
|
||||
| 可复用组件 | 0 | 4 原子 + 2 复杂 | 提升 |
|
||||
| 类型安全 | 无 | 完整 | TypeScript |
|
||||
|
||||
**设计原则**:
|
||||
- **原子设计模式**:atoms(基础元素)→ components(区块)→ organisms(复杂交互)
|
||||
- **TypeScript 类型安全**:完整的接口定义,消除 any 类型
|
||||
- **职责分离**:UI 渲染与 API 调用分离,模态框独立管理
|
||||
- **代码复用**:DisclaimerBox、ScoreBar 等原子组件多处复用
|
||||
155
src/views/Company/components/CompanyHeader/SearchBar.js
Normal file
155
src/views/Company/components/CompanyHeader/SearchBar.js
Normal file
@@ -0,0 +1,155 @@
|
||||
// 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';
|
||||
|
||||
/**
|
||||
* 股票搜索栏组件(带模糊搜索下拉)
|
||||
*
|
||||
* @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 [filteredStocks, setFilteredStocks] = useState([]);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
// 从 Redux 获取全部股票列表
|
||||
const allStocks = useSelector(state => state.stock.allStocks);
|
||||
|
||||
// 模糊搜索过滤
|
||||
useEffect(() => {
|
||||
if (inputCode && inputCode.trim()) {
|
||||
const searchTerm = inputCode.trim().toLowerCase();
|
||||
const filtered = allStocks.filter(stock =>
|
||||
stock.code.toLowerCase().includes(searchTerm) ||
|
||||
stock.name.includes(inputCode.trim())
|
||||
).slice(0, 10); // 限制显示10条
|
||||
setFilteredStocks(filtered);
|
||||
setShowDropdown(filtered.length > 0);
|
||||
} else {
|
||||
setFilteredStocks([]);
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}, [inputCode, allStocks]);
|
||||
|
||||
// 点击外部关闭下拉
|
||||
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;
|
||||
62
src/views/Company/components/CompanyHeader/index.js
Normal file
62
src/views/Company/components/CompanyHeader/index.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// src/views/Company/components/CompanyHeader/index.js
|
||||
// 公司详情页面头部区域组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
HStack,
|
||||
VStack,
|
||||
Heading,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import SearchBar from './SearchBar';
|
||||
|
||||
/**
|
||||
* 公司详情页面头部区域组件
|
||||
*
|
||||
* 包含:
|
||||
* - 页面标题和描述(金色主题)
|
||||
* - 股票搜索栏(支持模糊搜索)
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.inputCode - 搜索输入框值
|
||||
* @param {Function} props.onInputChange - 输入变化回调
|
||||
* @param {Function} props.onSearch - 搜索回调
|
||||
* @param {Function} props.onKeyDown - 键盘事件回调
|
||||
* @param {string} props.bgColor - 背景颜色
|
||||
*/
|
||||
const CompanyHeader = ({
|
||||
inputCode,
|
||||
onInputChange,
|
||||
onSearch,
|
||||
onKeyDown,
|
||||
bgColor,
|
||||
}) => {
|
||||
return (
|
||||
<Card bg={bgColor} shadow="md">
|
||||
<CardBody>
|
||||
<HStack justify="space-between" align="center">
|
||||
{/* 标题区域 - 金色主题 */}
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="lg" color="#F4D03F">个股详情</Heading>
|
||||
<Text color="#C9A961" fontSize="sm">
|
||||
查看股票实时行情、财务数据和盈利预测
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<SearchBar
|
||||
inputCode={inputCode}
|
||||
onInputChange={onInputChange}
|
||||
onSearch={onSearch}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompanyHeader;
|
||||
@@ -0,0 +1,163 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/AnnouncementsPanel.tsx
|
||||
// 公司公告 Tab Panel
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
Card,
|
||||
CardBody,
|
||||
IconButton,
|
||||
Button,
|
||||
Tag,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { FaBullhorn } from "react-icons/fa";
|
||||
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>
|
||||
<HStack mb={3}>
|
||||
<Icon as={FaBullhorn} color={THEME.gold} />
|
||||
<Text fontWeight="bold" color={THEME.textPrimary}>最新公告</Text>
|
||||
</HStack>
|
||||
<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;
|
||||
@@ -0,0 +1,168 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/BranchesPanel.tsx
|
||||
// 分支机构 Tab Panel - 黑金风格
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Icon,
|
||||
SimpleGrid,
|
||||
Center,
|
||||
} from "@chakra-ui/react";
|
||||
import { FaSitemap, FaBuilding, FaCheckCircle, FaTimesCircle } from "react-icons/fa";
|
||||
|
||||
import { useBranchesData } from "../../hooks/useBranchesData";
|
||||
import { THEME } from "../config";
|
||||
import { formatDate } from "../utils";
|
||||
import LoadingState from "./LoadingState";
|
||||
|
||||
interface BranchesPanelProps {
|
||||
stockCode: string;
|
||||
}
|
||||
|
||||
// 黑金卡片样式
|
||||
const cardStyles = {
|
||||
bg: "linear-gradient(145deg, rgba(30, 30, 35, 0.95), rgba(20, 20, 25, 0.98))",
|
||||
border: "1px solid",
|
||||
borderColor: "rgba(212, 175, 55, 0.3)",
|
||||
borderRadius: "12px",
|
||||
overflow: "hidden",
|
||||
transition: "all 0.3s ease",
|
||||
_hover: {
|
||||
borderColor: "rgba(212, 175, 55, 0.6)",
|
||||
boxShadow: "0 4px 20px rgba(212, 175, 55, 0.15), inset 0 1px 0 rgba(212, 175, 55, 0.1)",
|
||||
transform: "translateY(-2px)",
|
||||
},
|
||||
};
|
||||
|
||||
// 状态徽章样式
|
||||
const getStatusBadgeStyles = (isActive: boolean) => ({
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
borderRadius: "full",
|
||||
fontSize: "xs",
|
||||
fontWeight: "medium",
|
||||
bg: isActive ? "rgba(212, 175, 55, 0.15)" : "rgba(255, 100, 100, 0.15)",
|
||||
color: isActive ? THEME.gold : "#ff6b6b",
|
||||
border: "1px solid",
|
||||
borderColor: isActive ? "rgba(212, 175, 55, 0.3)" : "rgba(255, 100, 100, 0.3)",
|
||||
});
|
||||
|
||||
// 信息项组件
|
||||
const InfoItem: React.FC<{ label: string; value: string | number }> = ({ label, value }) => (
|
||||
<VStack align="start" spacing={0.5}>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} letterSpacing="0.5px">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fontSize="sm" fontWeight="semibold" color={THEME.textPrimary}>
|
||||
{value || "-"}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
const BranchesPanel: React.FC<BranchesPanelProps> = ({ stockCode }) => {
|
||||
const { branches, loading } = useBranchesData(stockCode);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="加载分支机构数据..." />;
|
||||
}
|
||||
|
||||
if (branches.length === 0) {
|
||||
return (
|
||||
<Center h="200px">
|
||||
<VStack spacing={3}>
|
||||
<Box
|
||||
p={4}
|
||||
borderRadius="full"
|
||||
bg="rgba(212, 175, 55, 0.1)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.2)"
|
||||
>
|
||||
<Icon as={FaSitemap} boxSize={10} color={THEME.gold} opacity={0.6} />
|
||||
</Box>
|
||||
<Text color={THEME.textSecondary} fontSize="sm">
|
||||
暂无分支机构信息
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{branches.map((branch: any, idx: number) => {
|
||||
const isActive = branch.business_status === "存续";
|
||||
|
||||
return (
|
||||
<Box key={idx} sx={cardStyles}>
|
||||
{/* 顶部金色装饰线 */}
|
||||
<Box
|
||||
h="2px"
|
||||
bgGradient="linear(to-r, transparent, rgba(212, 175, 55, 0.6), transparent)"
|
||||
/>
|
||||
|
||||
<Box p={4}>
|
||||
<VStack align="start" spacing={4}>
|
||||
{/* 标题行 */}
|
||||
<HStack justify="space-between" w="full" align="flex-start">
|
||||
<HStack spacing={2} flex={1}>
|
||||
<Box
|
||||
p={1.5}
|
||||
borderRadius="md"
|
||||
bg="rgba(212, 175, 55, 0.1)"
|
||||
>
|
||||
<Icon as={FaBuilding} boxSize={3.5} color={THEME.gold} />
|
||||
</Box>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={THEME.textPrimary}
|
||||
fontSize="sm"
|
||||
noOfLines={2}
|
||||
lineHeight="tall"
|
||||
>
|
||||
{branch.branch_name}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 状态徽章 */}
|
||||
<Box sx={getStatusBadgeStyles(isActive)}>
|
||||
<Icon
|
||||
as={isActive ? FaCheckCircle : FaTimesCircle}
|
||||
boxSize={3}
|
||||
/>
|
||||
<Text>{branch.business_status}</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<Box
|
||||
w="full"
|
||||
h="1px"
|
||||
bgGradient="linear(to-r, rgba(212, 175, 55, 0.3), transparent)"
|
||||
/>
|
||||
|
||||
{/* 信息网格 */}
|
||||
<SimpleGrid columns={2} spacing={3} w="full">
|
||||
<InfoItem label="注册资本" value={branch.register_capital} />
|
||||
<InfoItem label="法人代表" value={branch.legal_person} />
|
||||
<InfoItem label="成立日期" value={formatDate(branch.register_date)} />
|
||||
<InfoItem
|
||||
label="关联企业"
|
||||
value={`${branch.related_company_count || 0} 家`}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
|
||||
export default BranchesPanel;
|
||||
@@ -0,0 +1,109 @@
|
||||
// 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,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
import { THEME } from "../config";
|
||||
|
||||
interface BusinessInfoPanelProps {
|
||||
basicInfo: any;
|
||||
}
|
||||
|
||||
const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ basicInfo }) => {
|
||||
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;
|
||||
@@ -0,0 +1,83 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel.tsx
|
||||
// 财报披露日程 Tab Panel
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
Card,
|
||||
CardBody,
|
||||
SimpleGrid,
|
||||
} from "@chakra-ui/react";
|
||||
import { FaCalendarAlt } from "react-icons/fa";
|
||||
|
||||
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>
|
||||
<HStack mb={3}>
|
||||
<Icon as={FaCalendarAlt} color={THEME.gold} />
|
||||
<Text fontWeight="bold" color={THEME.textPrimary}>财报披露日程</Text>
|
||||
</HStack>
|
||||
<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;
|
||||
@@ -0,0 +1,32 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx
|
||||
// 复用的加载状态组件
|
||||
|
||||
import React from "react";
|
||||
import { Center, VStack, Spinner, Text } from "@chakra-ui/react";
|
||||
import { THEME } from "../config";
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载状态组件(黑金主题)
|
||||
*/
|
||||
const LoadingState: React.FC<LoadingStateProps> = ({
|
||||
message = "加载中...",
|
||||
height = "200px",
|
||||
}) => {
|
||||
return (
|
||||
<Center h={height}>
|
||||
<VStack>
|
||||
<Spinner size="lg" color={THEME.gold} thickness="3px" />
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>
|
||||
{message}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingState;
|
||||
@@ -0,0 +1,64 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/ShareholderPanel.tsx
|
||||
// 股权结构 Tab Panel - 使用拆分后的子组件
|
||||
|
||||
import React from "react";
|
||||
import { VStack, SimpleGrid, Box } from "@chakra-ui/react";
|
||||
|
||||
import { useShareholderData } from "../../hooks/useShareholderData";
|
||||
import {
|
||||
ActualControlCard,
|
||||
ConcentrationCard,
|
||||
ShareholdersTable,
|
||||
} from "../../components/shareholder";
|
||||
import LoadingState from "./LoadingState";
|
||||
|
||||
interface ShareholderPanelProps {
|
||||
stockCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 股权结构面板
|
||||
* 使用拆分后的子组件:
|
||||
* - ActualControlCard: 实际控制人卡片
|
||||
* - ConcentrationCard: 股权集中度卡片
|
||||
* - ShareholdersTable: 股东表格(合并版,支持十大股东和十大流通股东)
|
||||
*/
|
||||
const ShareholderPanel: React.FC<ShareholderPanelProps> = ({ stockCode }) => {
|
||||
const {
|
||||
actualControl,
|
||||
concentration,
|
||||
topShareholders,
|
||||
topCirculationShareholders,
|
||||
loading,
|
||||
} = useShareholderData(stockCode);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="加载股权结构数据..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 实际控制人 + 股权集中度 左右分布 */}
|
||||
<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>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareholderPanel;
|
||||
@@ -0,0 +1,9 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/index.ts
|
||||
// 组件导出
|
||||
|
||||
export { default as LoadingState } from "./LoadingState";
|
||||
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";
|
||||
@@ -0,0 +1,63 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/CategorySection.tsx
|
||||
// 管理层分类区块组件
|
||||
|
||||
import React, { memo } from "react";
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
Icon,
|
||||
SimpleGrid,
|
||||
} from "@chakra-ui/react";
|
||||
import type { IconType } from "react-icons";
|
||||
|
||||
import { THEME } from "../../config";
|
||||
import ManagementCard from "./ManagementCard";
|
||||
import type { ManagementPerson, ManagementCategory } from "./types";
|
||||
|
||||
interface CategorySectionProps {
|
||||
category: ManagementCategory;
|
||||
people: ManagementPerson[];
|
||||
icon: IconType;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const CategorySection: React.FC<CategorySectionProps> = ({
|
||||
category,
|
||||
people,
|
||||
icon,
|
||||
color,
|
||||
}) => {
|
||||
if (people.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 分类标题 */}
|
||||
<HStack mb={4}>
|
||||
<Icon as={icon} color={color} boxSize={5} />
|
||||
<Heading size="sm" color={THEME.textPrimary}>
|
||||
{category}
|
||||
</Heading>
|
||||
<Badge bg={THEME.gold} color="gray.900">
|
||||
{people.length}人
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
{/* 人员卡片网格 */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||
{people.map((person, idx) => (
|
||||
<ManagementCard
|
||||
key={`${person.name}-${idx}`}
|
||||
person={person}
|
||||
categoryColor={color}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(CategorySection);
|
||||
@@ -0,0 +1,100 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/ManagementCard.tsx
|
||||
// 管理人员卡片组件
|
||||
|
||||
import React, { memo } from "react";
|
||||
import {
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Icon,
|
||||
Card,
|
||||
CardBody,
|
||||
Avatar,
|
||||
Tag,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
FaVenusMars,
|
||||
FaGraduationCap,
|
||||
FaPassport,
|
||||
} from "react-icons/fa";
|
||||
|
||||
import { THEME } from "../../config";
|
||||
import { formatDate } from "../../utils";
|
||||
import type { ManagementPerson } from "./types";
|
||||
|
||||
interface ManagementCardProps {
|
||||
person: ManagementPerson;
|
||||
categoryColor: string;
|
||||
}
|
||||
|
||||
const ManagementCard: React.FC<ManagementCardProps> = ({ person, categoryColor }) => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const age = person.birth_year ? currentYear - parseInt(person.birth_year, 10) : null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
bg={THEME.tableBg}
|
||||
border="1px solid"
|
||||
borderColor={THEME.border}
|
||||
size="sm"
|
||||
>
|
||||
<CardBody>
|
||||
<HStack spacing={3} align="start">
|
||||
<Avatar
|
||||
name={person.name}
|
||||
size="md"
|
||||
bg={categoryColor}
|
||||
/>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
{/* 姓名和性别 */}
|
||||
<HStack>
|
||||
<Text fontWeight="bold" color={THEME.textPrimary}>
|
||||
{person.name}
|
||||
</Text>
|
||||
{person.gender && (
|
||||
<Icon
|
||||
as={FaVenusMars}
|
||||
color={person.gender === "男" ? "blue.400" : "pink.400"}
|
||||
boxSize={3}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 职位 */}
|
||||
<Text fontSize="sm" color={THEME.goldLight}>
|
||||
{person.position_name}
|
||||
</Text>
|
||||
|
||||
{/* 标签:学历、年龄、国籍 */}
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{person.education && (
|
||||
<Tag size="sm" bg={THEME.tableHoverBg} color={THEME.textSecondary}>
|
||||
<Icon as={FaGraduationCap} mr={1} boxSize={3} />
|
||||
{person.education}
|
||||
</Tag>
|
||||
)}
|
||||
{age && (
|
||||
<Tag size="sm" bg={THEME.tableHoverBg} color={THEME.textSecondary}>
|
||||
{age}岁
|
||||
</Tag>
|
||||
)}
|
||||
{person.nationality && person.nationality !== "中国" && (
|
||||
<Tag size="sm" bg="orange.600" color="white">
|
||||
<Icon as={FaPassport} mr={1} boxSize={3} />
|
||||
{person.nationality}
|
||||
</Tag>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 任职日期 */}
|
||||
<Text fontSize="xs" color={THEME.textSecondary}>
|
||||
任职日期:{formatDate(person.start_date)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ManagementCard);
|
||||
@@ -0,0 +1,105 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/ManagementPanel.tsx
|
||||
// 管理团队 Tab Panel(重构版)
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { VStack } from "@chakra-ui/react";
|
||||
import {
|
||||
FaUserTie,
|
||||
FaCrown,
|
||||
FaEye,
|
||||
FaUsers,
|
||||
} from "react-icons/fa";
|
||||
|
||||
import { useManagementData } from "../../../hooks/useManagementData";
|
||||
import { THEME } from "../../config";
|
||||
import LoadingState from "../LoadingState";
|
||||
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]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="加载管理团队数据..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
{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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagementPanel;
|
||||
@@ -0,0 +1,7 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/index.ts
|
||||
// 管理团队组件导出
|
||||
|
||||
export { default as ManagementPanel } from "./ManagementPanel";
|
||||
export { default as ManagementCard } from "./ManagementCard";
|
||||
export { default as CategorySection } from "./CategorySection";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,36 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/types.ts
|
||||
// 管理团队相关类型定义
|
||||
|
||||
import type { IconType } from "react-icons";
|
||||
|
||||
/**
|
||||
* 管理人员信息
|
||||
*/
|
||||
export interface ManagementPerson {
|
||||
name: string;
|
||||
position_name?: string;
|
||||
position_category?: string;
|
||||
gender?: "男" | "女";
|
||||
education?: string;
|
||||
birth_year?: string;
|
||||
nationality?: string;
|
||||
start_date?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理层分类
|
||||
*/
|
||||
export type ManagementCategory = "高管" | "董事" | "监事" | "其他";
|
||||
|
||||
/**
|
||||
* 分类后的管理层数据
|
||||
*/
|
||||
export type CategorizedManagement = Record<ManagementCategory, ManagementPerson[]>;
|
||||
|
||||
/**
|
||||
* 分类配置项
|
||||
*/
|
||||
export interface CategoryConfig {
|
||||
icon: IconType;
|
||||
color: string;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/config.ts
|
||||
// Tab 配置 + 黑金主题配置
|
||||
|
||||
import { IconType } from "react-icons";
|
||||
import {
|
||||
FaShareAlt,
|
||||
FaUserTie,
|
||||
FaSitemap,
|
||||
FaInfoCircle,
|
||||
} from "react-icons/fa";
|
||||
|
||||
// 主题类型定义
|
||||
export interface Theme {
|
||||
bg: string;
|
||||
cardBg: string;
|
||||
tableBg: string;
|
||||
tableHoverBg: string;
|
||||
gold: string;
|
||||
goldLight: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
border: string;
|
||||
tabSelected: {
|
||||
bg: string;
|
||||
color: string;
|
||||
};
|
||||
tabUnselected: {
|
||||
color: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 黑金主题配置
|
||||
export const THEME: Theme = {
|
||||
bg: "gray.900",
|
||||
cardBg: "gray.800",
|
||||
tableBg: "gray.700",
|
||||
tableHoverBg: "gray.600",
|
||||
gold: "#D4AF37",
|
||||
goldLight: "#F0D78C",
|
||||
textPrimary: "white",
|
||||
textSecondary: "gray.400",
|
||||
border: "rgba(212, 175, 55, 0.3)",
|
||||
tabSelected: {
|
||||
bg: "#D4AF37",
|
||||
color: "gray.900",
|
||||
},
|
||||
tabUnselected: {
|
||||
color: "#D4AF37",
|
||||
},
|
||||
};
|
||||
|
||||
// Tab 配置类型
|
||||
export interface TabConfig {
|
||||
key: string;
|
||||
name: string;
|
||||
icon: IconType;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Tab 配置
|
||||
export const TAB_CONFIG: TabConfig[] = [
|
||||
{
|
||||
key: "shareholder",
|
||||
name: "股权结构",
|
||||
icon: FaShareAlt,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: "management",
|
||||
name: "管理团队",
|
||||
icon: FaUserTie,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: "branches",
|
||||
name: "分支机构",
|
||||
icon: FaSitemap,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: "business",
|
||||
name: "工商信息",
|
||||
icon: FaInfoCircle,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
// 获取启用的 Tab 列表
|
||||
export const getEnabledTabs = (enabledKeys?: string[]): TabConfig[] => {
|
||||
if (!enabledKeys || enabledKeys.length === 0) {
|
||||
return TAB_CONFIG.filter((tab) => tab.enabled);
|
||||
}
|
||||
return TAB_CONFIG.filter(
|
||||
(tab) => tab.enabled && enabledKeys.includes(tab.key)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/index.tsx
|
||||
// 基本信息 Tab 组件 - 可配置版本(黑金主题)
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Icon,
|
||||
HStack,
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
import { THEME, TAB_CONFIG, getEnabledTabs, type TabConfig } from "./config";
|
||||
import {
|
||||
ShareholderPanel,
|
||||
ManagementPanel,
|
||||
AnnouncementsPanel,
|
||||
BranchesPanel,
|
||||
BusinessInfoPanel,
|
||||
} from "./components";
|
||||
|
||||
// Props 类型定义
|
||||
export interface BasicInfoTabProps {
|
||||
stockCode: string;
|
||||
basicInfo?: any;
|
||||
|
||||
// 可配置项
|
||||
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,
|
||||
};
|
||||
|
||||
/**
|
||||
* 基本信息 Tab 组件
|
||||
*
|
||||
* 特性:
|
||||
* - 可配置显示哪些 Tab(enabledTabs)
|
||||
* - 黑金主题
|
||||
* - 懒加载(isLazy)
|
||||
* - 支持 Tab 变更回调
|
||||
*/
|
||||
const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
|
||||
stockCode,
|
||||
basicInfo,
|
||||
enabledTabs,
|
||||
defaultTabIndex = 0,
|
||||
onTabChange,
|
||||
}) => {
|
||||
// 获取启用的 Tab 配置
|
||||
const tabs = getEnabledTabs(enabledTabs);
|
||||
|
||||
// 处理 Tab 变更
|
||||
const handleTabChange = (index: number) => {
|
||||
if (onTabChange && tabs[index]) {
|
||||
onTabChange(index, tabs[index].key);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染单个 Tab 内容
|
||||
const renderTabContent = (tab: TabConfig) => {
|
||||
const Component = TAB_COMPONENTS[tab.key];
|
||||
if (!Component) return null;
|
||||
|
||||
// business Tab 需要 basicInfo,其他需要 stockCode
|
||||
if (tab.key === "business") {
|
||||
return <Component basicInfo={basicInfo} />;
|
||||
}
|
||||
return <Component stockCode={stockCode} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card bg={THEME.cardBg} shadow="md" border="1px solid" borderColor={THEME.border}>
|
||||
<CardBody p={0}>
|
||||
<Tabs
|
||||
isLazy
|
||||
variant="unstyled"
|
||||
defaultIndex={defaultTabIndex}
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
<TabList
|
||||
bg={THEME.bg}
|
||||
borderBottom="1px solid"
|
||||
borderColor={THEME.border}
|
||||
px={4}
|
||||
py={2}
|
||||
flexWrap="wrap"
|
||||
gap={2}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
color={THEME.tabUnselected.color}
|
||||
borderRadius="full"
|
||||
px={4}
|
||||
py={2}
|
||||
fontSize="sm"
|
||||
_selected={{
|
||||
bg: THEME.tabSelected.bg,
|
||||
color: THEME.tabSelected.color,
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
_hover={{
|
||||
bg: THEME.tableHoverBg,
|
||||
}}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={tab.icon} boxSize={4} />
|
||||
<Text>{tab.name}</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
|
||||
<TabPanels p={4}>
|
||||
{tabs.map((tab) => (
|
||||
<TabPanel key={tab.key} p={0}>
|
||||
{renderTabContent(tab)}
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicInfoTab;
|
||||
|
||||
// 导出配置和工具,供外部使用
|
||||
export { THEME, TAB_CONFIG, getEnabledTabs } from "./config";
|
||||
export * from "./utils";
|
||||
@@ -0,0 +1,52 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/utils.ts
|
||||
// 格式化工具函数
|
||||
|
||||
/**
|
||||
* 格式化百分比
|
||||
*/
|
||||
export const formatPercentage = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
return `${(value * 100).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化数字(自动转换亿/万)
|
||||
*/
|
||||
export const formatNumber = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
if (value >= 100000000) {
|
||||
return `${(value / 100000000).toFixed(2)}亿`;
|
||||
} else if (value >= 10000) {
|
||||
return `${(value / 10000).toFixed(2)}万`;
|
||||
}
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化股数(自动转换亿股/万股)
|
||||
*/
|
||||
export const formatShares = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
if (value >= 100000000) {
|
||||
return `${(value / 100000000).toFixed(2)}亿股`;
|
||||
} else if (value >= 10000) {
|
||||
return `${(value / 10000).toFixed(2)}万股`;
|
||||
}
|
||||
return `${value.toLocaleString()}股`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化日期(去掉时间部分)
|
||||
*/
|
||||
export const formatDate = (dateStr: string | null | undefined): string => {
|
||||
if (!dateStr) return "-";
|
||||
return dateStr.split("T")[0];
|
||||
};
|
||||
|
||||
// 导出工具对象(兼容旧代码)
|
||||
export const formatUtils = {
|
||||
formatPercentage,
|
||||
formatNumber,
|
||||
formatShares,
|
||||
formatDate,
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 业务结构树形项组件
|
||||
*
|
||||
* 递归显示业务结构层级
|
||||
* 使用位置:业务结构分析卡片
|
||||
*/
|
||||
|
||||
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 BusinessTreeItem: React.FC<BusinessTreeItemProps> = ({ business, depth = 0 }) => {
|
||||
const bgColor = 'gray.50';
|
||||
|
||||
// 获取营收显示
|
||||
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={bgColor}
|
||||
borderLeft={depth > 0 ? '4px solid' : 'none'}
|
||||
borderLeftColor="blue.400"
|
||||
borderRadius="md"
|
||||
mb={2}
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" spacing={1}>
|
||||
<HStack>
|
||||
<Text fontWeight="bold" fontSize={depth === 0 ? 'md' : 'sm'}>
|
||||
{business.business_name}
|
||||
</Text>
|
||||
{business.financial_metrics?.revenue_ratio &&
|
||||
business.financial_metrics.revenue_ratio > 30 && (
|
||||
<Badge colorScheme="purple" size="sm">
|
||||
核心业务
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack spacing={4} flexWrap="wrap">
|
||||
<Tag size="sm" variant="subtle">
|
||||
营收占比: {formatPercentage(business.financial_metrics?.revenue_ratio)}
|
||||
</Tag>
|
||||
<Tag size="sm" variant="subtle">
|
||||
毛利率: {formatPercentage(business.financial_metrics?.gross_margin)}
|
||||
</Tag>
|
||||
{business.growth_metrics?.revenue_growth !== undefined && (
|
||||
<Tag
|
||||
size="sm"
|
||||
colorScheme={
|
||||
business.growth_metrics.revenue_growth > 0 ? 'red' : 'green'
|
||||
}
|
||||
>
|
||||
<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="blue.500">
|
||||
{getRevenueDisplay()}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
营业收入
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessTreeItem;
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 免责声明组件
|
||||
*
|
||||
* 显示 AI 分析内容的免责声明提示
|
||||
* 使用位置:深度分析各 Card 底部(共 6 处)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text } from '@chakra-ui/react';
|
||||
|
||||
const DisclaimerBox: React.FC = () => {
|
||||
return (
|
||||
<Text
|
||||
mb={4}
|
||||
color="gray.500"
|
||||
fontSize="12px"
|
||||
lineHeight="1.5"
|
||||
>
|
||||
免责声明:本内容由AI模型基于新闻、公告、研报等公开信息自动分析和生成,未经许可严禁转载。所有内容仅供参考,不构成任何投资建议,请投资者注意风险,独立审慎决策。
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisclaimerBox;
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 关键因素卡片组件
|
||||
*
|
||||
* 显示单个关键因素的详细信息
|
||||
* 使用位置:关键因素 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 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);
|
||||
const bgColor = 'white';
|
||||
const borderColor = 'gray.200';
|
||||
|
||||
return (
|
||||
<Card bg={bgColor} borderColor={borderColor} size="sm">
|
||||
<CardBody p={3}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="medium" fontSize="sm">
|
||||
{factor.factor_name}
|
||||
</Text>
|
||||
<Badge colorScheme={impactColor} size="sm">
|
||||
{getImpactLabel(factor.impact_direction)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="lg" fontWeight="bold" color={`${impactColor}.500`}>
|
||||
{factor.factor_value}
|
||||
{factor.factor_unit && ` ${factor.factor_unit}`}
|
||||
</Text>
|
||||
{factor.year_on_year !== undefined && (
|
||||
<Tag
|
||||
size="sm"
|
||||
colorScheme={factor.year_on_year > 0 ? 'red' : 'green'}
|
||||
>
|
||||
<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="gray.600" noOfLines={2}>
|
||||
{factor.factor_desc}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
影响权重: {factor.impact_weight}
|
||||
</Text>
|
||||
{factor.report_period && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{factor.report_period}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyFactorCard;
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 评分进度条组件
|
||||
*
|
||||
* 显示带图标的评分进度条
|
||||
* 使用位置:竞争力分析区域(共 8 处)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, HStack, Text, Badge, Progress, Icon } from '@chakra-ui/react';
|
||||
import type { ScoreBarProps } from '../types';
|
||||
|
||||
/**
|
||||
* 根据分数百分比获取颜色方案
|
||||
*/
|
||||
const getColorScheme = (percentage: number): string => {
|
||||
if (percentage >= 80) return 'purple';
|
||||
if (percentage >= 60) return 'blue';
|
||||
if (percentage >= 40) return 'yellow';
|
||||
return 'orange';
|
||||
};
|
||||
|
||||
const ScoreBar: React.FC<ScoreBarProps> = ({ label, score, icon }) => {
|
||||
const percentage = ((score || 0) / 100) * 100;
|
||||
const colorScheme = getColorScheme(percentage);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<HStack>
|
||||
{icon && (
|
||||
<Icon as={icon} boxSize={4} color={`${colorScheme}.500`} />
|
||||
)}
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{label}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Badge colorScheme={colorScheme}>{score || 0}</Badge>
|
||||
</HStack>
|
||||
<Progress
|
||||
value={percentage}
|
||||
size="sm"
|
||||
colorScheme={colorScheme}
|
||||
borderRadius="full"
|
||||
hasStripe
|
||||
isAnimated
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScoreBar;
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 原子组件导出
|
||||
*
|
||||
* 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';
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 业务板块详情卡片
|
||||
*
|
||||
* 显示公司各业务板块的详细信息
|
||||
*/
|
||||
|
||||
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 { DisclaimerBox } from '../atoms';
|
||||
import type { BusinessSegment } from '../types';
|
||||
|
||||
interface BusinessSegmentsCardProps {
|
||||
businessSegments: BusinessSegment[];
|
||||
expandedSegments: Record<number, boolean>;
|
||||
onToggleSegment: (index: number) => void;
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
|
||||
businessSegments,
|
||||
expandedSegments,
|
||||
onToggleSegment,
|
||||
cardBg,
|
||||
}) => {
|
||||
if (!businessSegments || businessSegments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} shadow="md">
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaIndustry} color="indigo.500" />
|
||||
<Heading size="sm">业务板块详情</Heading>
|
||||
<Badge>{businessSegments.length} 个板块</Badge>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<DisclaimerBox />
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||
{businessSegments.map((segment, idx) => {
|
||||
const isExpanded = expandedSegments[idx];
|
||||
|
||||
return (
|
||||
<Card key={idx} variant="outline">
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="bold" fontSize="md">
|
||||
{segment.segment_name}
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={
|
||||
<Icon as={isExpanded ? FaCompressAlt : FaExpandAlt} />
|
||||
}
|
||||
onClick={() => onToggleSegment(idx)}
|
||||
colorScheme="blue"
|
||||
>
|
||||
{isExpanded ? '折叠' : '展开'}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xs" color="gray.500" mb={1}>
|
||||
业务描述
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
noOfLines={isExpanded ? undefined : 3}
|
||||
>
|
||||
{segment.segment_description || '暂无描述'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xs" color="gray.500" mb={1}>
|
||||
竞争地位
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
noOfLines={isExpanded ? undefined : 2}
|
||||
>
|
||||
{segment.competitive_position || '暂无数据'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xs" color="gray.500" mb={1}>
|
||||
未来潜力
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
noOfLines={isExpanded ? undefined : 2}
|
||||
color="blue.600"
|
||||
>
|
||||
{segment.future_potential || '暂无数据'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{isExpanded && segment.key_products && (
|
||||
<Box>
|
||||
<Text fontSize="xs" color="gray.500" mb={1}>
|
||||
主要产品
|
||||
</Text>
|
||||
<Text fontSize="sm" color="green.600">
|
||||
{segment.key_products}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isExpanded && segment.market_share !== undefined && (
|
||||
<Box>
|
||||
<Text fontSize="xs" color="gray.500" mb={1}>
|
||||
市场份额
|
||||
</Text>
|
||||
<Badge colorScheme="purple" fontSize="sm">
|
||||
{segment.market_share}%
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isExpanded && segment.revenue_contribution !== undefined && (
|
||||
<Box>
|
||||
<Text fontSize="xs" color="gray.500" mb={1}>
|
||||
营收贡献
|
||||
</Text>
|
||||
<Badge colorScheme="orange" fontSize="sm">
|
||||
{segment.revenue_contribution}%
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessSegmentsCard;
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 业务结构分析卡片
|
||||
*
|
||||
* 显示公司业务结构树形图
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaChartPie } from 'react-icons/fa';
|
||||
import { DisclaimerBox, BusinessTreeItem } from '../atoms';
|
||||
import type { BusinessStructure } from '../types';
|
||||
|
||||
interface BusinessStructureCardProps {
|
||||
businessStructure: BusinessStructure[];
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const BusinessStructureCard: React.FC<BusinessStructureCardProps> = ({
|
||||
businessStructure,
|
||||
cardBg,
|
||||
}) => {
|
||||
if (!businessStructure || businessStructure.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} shadow="md">
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaChartPie} color="purple.500" />
|
||||
<Heading size="sm">业务结构分析</Heading>
|
||||
<Badge>{businessStructure[0]?.report_period}</Badge>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<DisclaimerBox />
|
||||
<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;
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 竞争地位分析卡片
|
||||
*
|
||||
* 显示竞争力评分、雷达图和竞争分析
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
Badge,
|
||||
Tag,
|
||||
TagLabel,
|
||||
Grid,
|
||||
GridItem,
|
||||
Box,
|
||||
Icon,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FaTrophy,
|
||||
FaCog,
|
||||
FaStar,
|
||||
FaChartLine,
|
||||
FaDollarSign,
|
||||
FaFlask,
|
||||
FaShieldAlt,
|
||||
FaRocket,
|
||||
FaUsers,
|
||||
} from 'react-icons/fa';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { ScoreBar } from '../atoms';
|
||||
import { getRadarChartOption } from '../utils/chartOptions';
|
||||
import type { ComprehensiveData, CompetitivePosition } 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 GRID_COLSPAN = { base: 2, lg: 1 } as const;
|
||||
const CHART_STYLE = { height: '320px' } as const;
|
||||
|
||||
interface CompetitiveAnalysisCardProps {
|
||||
comprehensiveData: ComprehensiveData;
|
||||
}
|
||||
|
||||
// 竞争对手标签组件
|
||||
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 }) => {
|
||||
const competitivePosition = comprehensiveData.competitive_position;
|
||||
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]
|
||||
);
|
||||
|
||||
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"
|
||||
>
|
||||
行业排名 {competitivePosition.ranking.industry_rank}/
|
||||
{competitivePosition.ranking.total_companies}
|
||||
</Badge>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CompetitiveAnalysisCard.displayName = 'CompetitiveAnalysisCard';
|
||||
|
||||
export default CompetitiveAnalysisCard;
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 投资亮点卡片组件
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Box, HStack, VStack, Icon, Text } from '@chakra-ui/react';
|
||||
import { FaUsers } from 'react-icons/fa';
|
||||
import { THEME, ICON_MAP, HIGHLIGHT_HOVER_STYLES } from '../theme';
|
||||
import type { InvestmentHighlightItem } from '../../../types';
|
||||
|
||||
interface HighlightCardProps {
|
||||
highlight: InvestmentHighlightItem;
|
||||
}
|
||||
|
||||
export const HighlightCard = memo<HighlightCardProps>(({ highlight }) => {
|
||||
const IconComponent = ICON_MAP[highlight.icon] || FaUsers;
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={4}
|
||||
bg={THEME.light.cardBg}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.100"
|
||||
{...HIGHLIGHT_HOVER_STYLES}
|
||||
transition="border-color 0.2s"
|
||||
>
|
||||
<HStack spacing={3} align="flex-start">
|
||||
<Box
|
||||
p={2}
|
||||
bg="whiteAlpha.100"
|
||||
borderRadius="md"
|
||||
color={THEME.light.titleColor}
|
||||
>
|
||||
<Icon as={IconComponent} boxSize={4} />
|
||||
</Box>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<Text fontWeight="bold" color={THEME.light.textColor} fontSize="sm">
|
||||
{highlight.title}
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={THEME.light.subtextColor}
|
||||
lineHeight="tall"
|
||||
>
|
||||
{highlight.description}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
HighlightCard.displayName = 'HighlightCard';
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 商业模式板块组件
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Box, VStack, HStack, Text, Tag, Divider } from '@chakra-ui/react';
|
||||
import { THEME } from '../theme';
|
||||
import type { BusinessModelSection } from '../../../types';
|
||||
|
||||
interface ModelBlockProps {
|
||||
section: BusinessModelSection;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export const ModelBlock = memo<ModelBlockProps>(({ section, isLast }) => (
|
||||
<Box>
|
||||
<VStack align="start" spacing={2}>
|
||||
<Text fontWeight="bold" color={THEME.light.textColor} fontSize="sm">
|
||||
{section.title}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={THEME.light.subtextColor} lineHeight="tall">
|
||||
{section.description}
|
||||
</Text>
|
||||
{section.tags && section.tags.length > 0 && (
|
||||
<HStack spacing={2} flexWrap="wrap" mt={1}>
|
||||
{section.tags.map((tag, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
size="sm"
|
||||
bg={THEME.light.tagBg}
|
||||
color={THEME.light.tagColor}
|
||||
borderRadius="full"
|
||||
px={3}
|
||||
py={1}
|
||||
fontSize="xs"
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
{!isLast && <Divider my={4} borderColor="whiteAlpha.100" />}
|
||||
</Box>
|
||||
));
|
||||
|
||||
ModelBlock.displayName = 'ModelBlock';
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 区域标题组件
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { HStack, Icon, Text } from '@chakra-ui/react';
|
||||
import type { IconType } from 'react-icons';
|
||||
import { THEME } from '../theme';
|
||||
|
||||
interface SectionHeaderProps {
|
||||
icon: IconType;
|
||||
title: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const SectionHeader = memo<SectionHeaderProps>(
|
||||
({ icon, title, color = THEME.dark.titleColor }) => (
|
||||
<HStack spacing={2} mb={4}>
|
||||
<Icon as={icon} color={color} boxSize={4} />
|
||||
<Text fontWeight="bold" color={color} fontSize="md">
|
||||
{title}
|
||||
</Text>
|
||||
</HStack>
|
||||
)
|
||||
);
|
||||
|
||||
SectionHeader.displayName = 'SectionHeader';
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* CorePositioningCard 原子组件统一导出
|
||||
*/
|
||||
|
||||
export { SectionHeader } from './SectionHeader';
|
||||
export { HighlightCard } from './HighlightCard';
|
||||
export { ModelBlock } from './ModelBlock';
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 核心定位卡片
|
||||
*
|
||||
* 显示公司的核心定位、投资亮点和商业模式
|
||||
* 黑金主题设计
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
VStack,
|
||||
Text,
|
||||
Box,
|
||||
Grid,
|
||||
GridItem,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaCrown, FaStar, FaBriefcase } from 'react-icons/fa';
|
||||
import type {
|
||||
QualitativeAnalysis,
|
||||
InvestmentHighlightItem,
|
||||
} from '../../types';
|
||||
import {
|
||||
THEME,
|
||||
CARD_STYLES,
|
||||
GRID_COLUMNS,
|
||||
BORDER_RIGHT_RESPONSIVE,
|
||||
} from './theme';
|
||||
import { SectionHeader, HighlightCard, ModelBlock } from './atoms';
|
||||
|
||||
// ==================== 主组件 ====================
|
||||
|
||||
interface CorePositioningCardProps {
|
||||
qualitativeAnalysis: QualitativeAnalysis;
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const CorePositioningCard: React.FC<CorePositioningCardProps> = memo(
|
||||
({ qualitativeAnalysis }) => {
|
||||
const corePositioning = qualitativeAnalysis.core_positioning;
|
||||
|
||||
// 判断是否有结构化数据
|
||||
const hasStructuredData = useMemo(
|
||||
() =>
|
||||
!!(
|
||||
corePositioning?.features?.length ||
|
||||
(Array.isArray(corePositioning?.investment_highlights) &&
|
||||
corePositioning.investment_highlights.length > 0) ||
|
||||
corePositioning?.business_model_sections?.length
|
||||
),
|
||||
[corePositioning]
|
||||
);
|
||||
|
||||
// 如果没有结构化数据,使用旧的文本格式渲染
|
||||
if (!hasStructuredData) {
|
||||
return (
|
||||
<Card {...CARD_STYLES}>
|
||||
<CardBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<SectionHeader icon={FaCrown} title="核心定位" />
|
||||
{corePositioning?.one_line_intro && (
|
||||
<Box
|
||||
p={4}
|
||||
bg={THEME.dark.cardBg}
|
||||
borderRadius="lg"
|
||||
borderLeft="4px solid"
|
||||
borderColor={THEME.dark.border}
|
||||
>
|
||||
<Text color={THEME.dark.textColor} fontWeight="medium">
|
||||
{corePositioning.one_line_intro}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Grid templateColumns={GRID_COLUMNS.twoColumnMd} gap={4}>
|
||||
<GridItem>
|
||||
<Box p={4} bg={THEME.light.cardBg} borderRadius="lg">
|
||||
<SectionHeader icon={FaStar} title="投资亮点" />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={THEME.light.subtextColor}
|
||||
whiteSpace="pre-wrap"
|
||||
>
|
||||
{corePositioning?.investment_highlights_text ||
|
||||
(typeof corePositioning?.investment_highlights === 'string'
|
||||
? corePositioning.investment_highlights
|
||||
: '暂无数据')}
|
||||
</Text>
|
||||
</Box>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Box p={4} bg={THEME.light.cardBg} borderRadius="lg">
|
||||
<SectionHeader icon={FaBriefcase} title="商业模式" />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={THEME.light.subtextColor}
|
||||
whiteSpace="pre-wrap"
|
||||
>
|
||||
{corePositioning?.business_model_desc || '暂无数据'}
|
||||
</Text>
|
||||
</Box>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 结构化数据渲染 - 缓存数组计算
|
||||
const highlights = useMemo(
|
||||
() =>
|
||||
(Array.isArray(corePositioning?.investment_highlights)
|
||||
? corePositioning.investment_highlights
|
||||
: []) as InvestmentHighlightItem[],
|
||||
[corePositioning?.investment_highlights]
|
||||
);
|
||||
|
||||
const businessSections = useMemo(
|
||||
() => corePositioning?.business_model_sections || [],
|
||||
[corePositioning?.business_model_sections]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card {...CARD_STYLES}>
|
||||
<CardBody p={0}>
|
||||
<VStack spacing={0} align="stretch">
|
||||
{/* 核心定位区域(深色背景) */}
|
||||
<Box p={6} bg={THEME.dark.bg}>
|
||||
<SectionHeader icon={FaCrown} title="核心定位" />
|
||||
|
||||
{/* 一句话介绍 */}
|
||||
{corePositioning?.one_line_intro && (
|
||||
<Box
|
||||
p={4}
|
||||
bg={THEME.dark.cardBg}
|
||||
borderRadius="lg"
|
||||
borderLeft="4px solid"
|
||||
borderColor={THEME.dark.border}
|
||||
>
|
||||
<Text color={THEME.dark.textColor} fontWeight="medium">
|
||||
{corePositioning.one_line_intro}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 投资亮点 + 商业模式区域 */}
|
||||
<Grid templateColumns={GRID_COLUMNS.twoColumn} bg={THEME.light.bg}>
|
||||
{/* 投资亮点区域 */}
|
||||
<GridItem
|
||||
p={6}
|
||||
borderRight={BORDER_RIGHT_RESPONSIVE}
|
||||
borderColor="whiteAlpha.100"
|
||||
>
|
||||
<SectionHeader icon={FaStar} title="投资亮点" />
|
||||
<VStack spacing={3} align="stretch">
|
||||
{highlights.length > 0 ? (
|
||||
highlights.map((highlight, idx) => (
|
||||
<HighlightCard key={idx} highlight={highlight} />
|
||||
))
|
||||
) : (
|
||||
<Text fontSize="sm" color={THEME.light.subtextColor}>
|
||||
暂无数据
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</GridItem>
|
||||
|
||||
{/* 商业模式区域 */}
|
||||
<GridItem p={6}>
|
||||
<SectionHeader icon={FaBriefcase} title="商业模式" />
|
||||
<Box
|
||||
p={4}
|
||||
bg={THEME.light.cardBg}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.100"
|
||||
>
|
||||
{businessSections.length > 0 ? (
|
||||
businessSections.map((section, idx) => (
|
||||
<ModelBlock
|
||||
key={idx}
|
||||
section={section}
|
||||
isLast={idx === businessSections.length - 1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Text fontSize="sm" color={THEME.light.subtextColor}>
|
||||
暂无数据
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CorePositioningCard.displayName = 'CorePositioningCard';
|
||||
|
||||
export default CorePositioningCard;
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* CorePositioningCard 主题和样式常量
|
||||
*/
|
||||
|
||||
import {
|
||||
FaUniversity,
|
||||
FaFire,
|
||||
FaUsers,
|
||||
FaChartLine,
|
||||
FaMicrochip,
|
||||
FaShieldAlt,
|
||||
} from 'react-icons/fa';
|
||||
import type { IconType } from 'react-icons';
|
||||
|
||||
// ==================== 主题常量 ====================
|
||||
|
||||
export const THEME = {
|
||||
// 深色背景区域(核心定位)
|
||||
dark: {
|
||||
bg: '#1A202C',
|
||||
cardBg: '#252D3A',
|
||||
border: '#C9A961',
|
||||
borderGradient: 'linear-gradient(90deg, #C9A961, #8B7355)',
|
||||
titleColor: '#C9A961',
|
||||
textColor: '#E2E8F0',
|
||||
subtextColor: '#A0AEC0',
|
||||
},
|
||||
// 浅色背景区域(投资亮点/商业模式)
|
||||
light: {
|
||||
bg: '#1E2530',
|
||||
cardBg: '#252D3A',
|
||||
titleColor: '#C9A961',
|
||||
textColor: '#E2E8F0',
|
||||
subtextColor: '#A0AEC0',
|
||||
tagBg: 'rgba(201, 169, 97, 0.15)',
|
||||
tagColor: '#C9A961',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ==================== 图标映射 ====================
|
||||
|
||||
export const ICON_MAP: Record<string, IconType> = {
|
||||
bank: FaUniversity,
|
||||
fire: FaFire,
|
||||
users: FaUsers,
|
||||
'trending-up': FaChartLine,
|
||||
cpu: FaMicrochip,
|
||||
'shield-check': FaShieldAlt,
|
||||
};
|
||||
|
||||
// ==================== 样式常量 ====================
|
||||
|
||||
// 卡片通用样式(含顶部金色边框)
|
||||
export const CARD_STYLES = {
|
||||
bg: THEME.dark.bg,
|
||||
shadow: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'whiteAlpha.100',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
_before: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: THEME.dark.borderGradient,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// HighlightCard hover 样式
|
||||
export const HIGHLIGHT_HOVER_STYLES = {
|
||||
_hover: { borderColor: 'whiteAlpha.200' },
|
||||
} as const;
|
||||
|
||||
// 响应式布局常量
|
||||
export const GRID_COLUMNS = {
|
||||
twoColumn: { base: '1fr', lg: 'repeat(2, 1fr)' },
|
||||
twoColumnMd: { base: '1fr', md: 'repeat(2, 1fr)' },
|
||||
} as const;
|
||||
|
||||
export const BORDER_RIGHT_RESPONSIVE = { lg: '1px solid' } as const;
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 关键因素卡片
|
||||
*
|
||||
* 显示影响公司的关键因素列表
|
||||
*/
|
||||
|
||||
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 { DisclaimerBox, KeyFactorCard } from '../atoms';
|
||||
import type { KeyFactors } from '../types';
|
||||
|
||||
interface KeyFactorsCardProps {
|
||||
keyFactors: KeyFactors;
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const KeyFactorsCard: React.FC<KeyFactorsCardProps> = ({
|
||||
keyFactors,
|
||||
cardBg,
|
||||
}) => {
|
||||
return (
|
||||
<Card bg={cardBg} shadow="md" h="full">
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaBalanceScale} color="orange.500" />
|
||||
<Heading size="sm">关键因素</Heading>
|
||||
<Badge>{keyFactors.total_factors} 项</Badge>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<DisclaimerBox />
|
||||
<Accordion allowMultiple>
|
||||
{keyFactors.categories.map((category, idx) => (
|
||||
<AccordionItem key={idx}>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
<HStack>
|
||||
<Text fontWeight="medium">{category.category_name}</Text>
|
||||
<Badge size="sm" variant="subtle">
|
||||
{category.factors.length}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</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;
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 战略分析卡片
|
||||
*
|
||||
* 显示公司战略方向和战略举措
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
Box,
|
||||
Icon,
|
||||
Grid,
|
||||
GridItem,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaRocket, FaChartBar } from 'react-icons/fa';
|
||||
import type { Strategy } from '../types';
|
||||
|
||||
// 样式常量 - 避免每次渲染创建新对象
|
||||
const CARD_STYLES = {
|
||||
bg: 'transparent',
|
||||
shadow: 'md',
|
||||
} as const;
|
||||
|
||||
const CONTENT_BOX_STYLES = {
|
||||
p: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'yellow.600',
|
||||
borderRadius: 'md',
|
||||
} as const;
|
||||
|
||||
const EMPTY_BOX_STYLES = {
|
||||
border: '1px dashed',
|
||||
borderColor: 'yellow.600',
|
||||
borderRadius: 'md',
|
||||
py: 12,
|
||||
} as const;
|
||||
|
||||
const GRID_RESPONSIVE_COLSPAN = { base: 2, md: 1 } as const;
|
||||
|
||||
interface StrategyAnalysisCardProps {
|
||||
strategy: Strategy;
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
// 空状态组件 - 独立 memo 避免重复渲染
|
||||
const EmptyState = memo(() => (
|
||||
<Box {...EMPTY_BOX_STYLES}>
|
||||
<Center>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FaChartBar} boxSize={10} color="yellow.600" />
|
||||
<Text fontWeight="medium">战略数据更新中</Text>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
战略方向和具体举措数据将在近期更新
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
</Box>
|
||||
));
|
||||
|
||||
EmptyState.displayName = 'StrategyEmptyState';
|
||||
|
||||
// 内容项组件 - 复用结构
|
||||
interface ContentItemProps {
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const ContentItem = memo<ContentItemProps>(({ title, content }) => (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontWeight="bold" fontSize="sm" color="yellow.500">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize="sm" color="white">
|
||||
{content}
|
||||
</Text>
|
||||
</VStack>
|
||||
));
|
||||
|
||||
ContentItem.displayName = 'StrategyContentItem';
|
||||
|
||||
const StrategyAnalysisCard: React.FC<StrategyAnalysisCardProps> = memo(
|
||||
({ strategy }) => {
|
||||
// 缓存数据检测结果
|
||||
const hasData = useMemo(
|
||||
() => !!(strategy?.strategy_description || strategy?.strategic_initiatives),
|
||||
[strategy?.strategy_description, strategy?.strategic_initiatives]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card {...CARD_STYLES}>
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaRocket} color="yellow.500" />
|
||||
<Heading size="sm" color="yellow.500">战略分析</Heading>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{!hasData ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<Box {...CONTENT_BOX_STYLES}>
|
||||
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
|
||||
<GridItem colSpan={GRID_RESPONSIVE_COLSPAN}>
|
||||
<ContentItem
|
||||
title="战略方向"
|
||||
content={strategy.strategy_description || '暂无数据'}
|
||||
/>
|
||||
</GridItem>
|
||||
<GridItem colSpan={GRID_RESPONSIVE_COLSPAN}>
|
||||
<ContentItem
|
||||
title="战略举措"
|
||||
content={strategy.strategic_initiatives || '暂无数据'}
|
||||
/>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
StrategyAnalysisCard.displayName = 'StrategyAnalysisCard';
|
||||
|
||||
export default StrategyAnalysisCard;
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 发展时间线卡片
|
||||
*
|
||||
* 显示公司发展历程时间线
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
Box,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaHistory } from 'react-icons/fa';
|
||||
import { DisclaimerBox } from '../atoms';
|
||||
import TimelineComponent from '../organisms/TimelineComponent';
|
||||
import type { DevelopmentTimeline } from '../types';
|
||||
|
||||
interface TimelineCardProps {
|
||||
developmentTimeline: DevelopmentTimeline;
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const TimelineCard: React.FC<TimelineCardProps> = ({
|
||||
developmentTimeline,
|
||||
cardBg,
|
||||
}) => {
|
||||
return (
|
||||
<Card bg={cardBg} shadow="md" h="full">
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaHistory} color="cyan.500" />
|
||||
<Heading size="sm">发展时间线</Heading>
|
||||
<HStack spacing={1}>
|
||||
<Badge colorScheme="red">
|
||||
正面 {developmentTimeline.statistics?.positive_events || 0}
|
||||
</Badge>
|
||||
<Badge colorScheme="green">
|
||||
负面 {developmentTimeline.statistics?.negative_events || 0}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<DisclaimerBox />
|
||||
<Box maxH="600px" overflowY="auto" pr={2}>
|
||||
<TimelineComponent events={developmentTimeline.events} />
|
||||
</Box>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineCard;
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 产业链分析卡片
|
||||
*
|
||||
* 显示产业链层级视图和流向关系
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
Badge,
|
||||
Box,
|
||||
Icon,
|
||||
SimpleGrid,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaNetworkWired } from 'react-icons/fa';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { DisclaimerBox } from '../atoms';
|
||||
import ValueChainNodeCard from '../organisms/ValueChainNodeCard';
|
||||
import { getSankeyChartOption } from '../utils/chartOptions';
|
||||
import type { ValueChainData } from '../types';
|
||||
|
||||
interface ValueChainCardProps {
|
||||
valueChainData: ValueChainData;
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const ValueChainCard: React.FC<ValueChainCardProps> = ({
|
||||
valueChainData,
|
||||
cardBg,
|
||||
}) => {
|
||||
const sankeyOption = getSankeyChartOption(valueChainData);
|
||||
const nodesByLevel = valueChainData.value_chain_structure?.nodes_by_level;
|
||||
|
||||
// 获取上游节点
|
||||
const upstreamNodes = [
|
||||
...(nodesByLevel?.['level_-2'] || []),
|
||||
...(nodesByLevel?.['level_-1'] || []),
|
||||
];
|
||||
|
||||
// 获取核心节点
|
||||
const coreNodes = nodesByLevel?.['level_0'] || [];
|
||||
|
||||
// 获取下游节点
|
||||
const downstreamNodes = [
|
||||
...(nodesByLevel?.['level_1'] || []),
|
||||
...(nodesByLevel?.['level_2'] || []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} shadow="md">
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaNetworkWired} color="teal.500" />
|
||||
<Heading size="sm">产业链分析</Heading>
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme="orange">
|
||||
上游 {valueChainData.analysis_summary?.upstream_nodes || 0}
|
||||
</Badge>
|
||||
<Badge colorScheme="blue">
|
||||
核心 {valueChainData.analysis_summary?.company_nodes || 0}
|
||||
</Badge>
|
||||
<Badge colorScheme="green">
|
||||
下游 {valueChainData.analysis_summary?.downstream_nodes || 0}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<DisclaimerBox />
|
||||
<Tabs variant="soft-rounded" colorScheme="teal">
|
||||
<TabList>
|
||||
<Tab>层级视图</Tab>
|
||||
<Tab>流向关系</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* 层级视图 */}
|
||||
<TabPanel>
|
||||
<VStack spacing={8} align="stretch">
|
||||
{/* 上游供应链 */}
|
||||
{upstreamNodes.length > 0 && (
|
||||
<Box>
|
||||
<HStack mb={4}>
|
||||
<Badge colorScheme="orange" fontSize="md" px={3} py={1}>
|
||||
上游供应链
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
原材料与供应商
|
||||
</Text>
|
||||
</HStack>
|
||||
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
|
||||
{upstreamNodes.map((node, idx) => (
|
||||
<ValueChainNodeCard
|
||||
key={idx}
|
||||
node={node}
|
||||
level={node.node_level}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 核心企业 */}
|
||||
{coreNodes.length > 0 && (
|
||||
<Box>
|
||||
<HStack mb={4}>
|
||||
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
|
||||
核心企业
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
公司主体与产品
|
||||
</Text>
|
||||
</HStack>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||
{coreNodes.map((node, idx) => (
|
||||
<ValueChainNodeCard
|
||||
key={idx}
|
||||
node={node}
|
||||
isCompany={node.node_type === 'company'}
|
||||
level={0}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 下游客户 */}
|
||||
{downstreamNodes.length > 0 && (
|
||||
<Box>
|
||||
<HStack mb={4}>
|
||||
<Badge colorScheme="green" fontSize="md" px={3} py={1}>
|
||||
下游客户
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
客户与终端市场
|
||||
</Text>
|
||||
</HStack>
|
||||
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
|
||||
{downstreamNodes.map((node, idx) => (
|
||||
<ValueChainNodeCard
|
||||
key={idx}
|
||||
node={node}
|
||||
level={node.node_level}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* 流向关系 */}
|
||||
<TabPanel>
|
||||
{sankeyOption ? (
|
||||
<ReactECharts
|
||||
option={sankeyOption}
|
||||
style={{ height: '500px' }}
|
||||
theme="light"
|
||||
/>
|
||||
) : (
|
||||
<Center h="200px">
|
||||
<Text color="gray.500">暂无流向数据</Text>
|
||||
</Center>
|
||||
)}
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValueChainCard;
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Card 子组件导出
|
||||
*
|
||||
* DeepAnalysisTab 的各个区块组件
|
||||
*/
|
||||
|
||||
export { default as CorePositioningCard } from './CorePositioningCard';
|
||||
export { default as CompetitiveAnalysisCard } from './CompetitiveAnalysisCard';
|
||||
export { default as BusinessStructureCard } from './BusinessStructureCard';
|
||||
export { default as ValueChainCard } from './ValueChainCard';
|
||||
export { default as KeyFactorsCard } from './KeyFactorsCard';
|
||||
export { default as TimelineCard } from './TimelineCard';
|
||||
export { default as BusinessSegmentsCard } from './BusinessSegmentsCard';
|
||||
export { default as StrategyAnalysisCard } from './StrategyAnalysisCard';
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 深度分析 Tab 主组件
|
||||
*
|
||||
* 组合所有子组件,显示公司深度分析内容
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { VStack, Center, Text, Spinner, Grid, GridItem } from '@chakra-ui/react';
|
||||
import {
|
||||
CorePositioningCard,
|
||||
CompetitiveAnalysisCard,
|
||||
BusinessStructureCard,
|
||||
ValueChainCard,
|
||||
KeyFactorsCard,
|
||||
TimelineCard,
|
||||
BusinessSegmentsCard,
|
||||
StrategyAnalysisCard,
|
||||
} from './components';
|
||||
import type { DeepAnalysisTabProps } from './types';
|
||||
|
||||
const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
|
||||
comprehensiveData,
|
||||
valueChainData,
|
||||
keyFactorsData,
|
||||
loading,
|
||||
cardBg,
|
||||
expandedSegments,
|
||||
onToggleSegment,
|
||||
}) => {
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Center h="200px">
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
<Text>加载深度分析数据...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 核心定位卡片 */}
|
||||
{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} />
|
||||
)}
|
||||
|
||||
{/* 业务结构分析 */}
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 产业链分析 */}
|
||||
{valueChainData && (
|
||||
<ValueChainCard valueChainData={valueChainData} cardBg={cardBg} />
|
||||
)}
|
||||
|
||||
{/* 关键因素与发展时间线 */}
|
||||
<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>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeepAnalysisTab;
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 事件详情模态框组件
|
||||
*
|
||||
* 显示时间线事件的详细信息
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Box,
|
||||
Progress,
|
||||
Icon,
|
||||
Button,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
|
||||
import type { TimelineEvent } from '../../types';
|
||||
|
||||
interface EventDetailModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
event: TimelineEvent | null;
|
||||
}
|
||||
|
||||
const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
event,
|
||||
}) => {
|
||||
if (!event) return null;
|
||||
|
||||
const isPositive = event.impact_metrics?.is_positive;
|
||||
const impactScore = event.impact_metrics?.impact_score || 0;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<HStack>
|
||||
<Icon
|
||||
as={isPositive ? FaCheckCircle : FaExclamationCircle}
|
||||
color={isPositive ? 'red.500' : 'green.500'}
|
||||
boxSize={6}
|
||||
/>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text>{event.event_title}</Text>
|
||||
<HStack>
|
||||
<Badge colorScheme={isPositive ? 'red' : 'green'}>
|
||||
{event.event_type}
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{event.event_date}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2} color="gray.600">
|
||||
事件详情
|
||||
</Text>
|
||||
<Text fontSize="sm" lineHeight="1.6">
|
||||
{event.event_desc}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{event.related_info?.financial_impact && (
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2} color="gray.600">
|
||||
财务影响
|
||||
</Text>
|
||||
<Text fontSize="sm" lineHeight="1.6" color="blue.600">
|
||||
{event.related_info.financial_impact}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2} color="gray.600">
|
||||
影响评估
|
||||
</Text>
|
||||
<HStack spacing={4}>
|
||||
<VStack spacing={1}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
影响度
|
||||
</Text>
|
||||
<Progress
|
||||
value={impactScore}
|
||||
size="lg"
|
||||
width="120px"
|
||||
colorScheme={impactScore > 70 ? 'red' : 'orange'}
|
||||
hasStripe
|
||||
isAnimated
|
||||
/>
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{impactScore}/100
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack>
|
||||
<Badge
|
||||
size="lg"
|
||||
colorScheme={isPositive ? 'red' : 'green'}
|
||||
px={3}
|
||||
py={1}
|
||||
>
|
||||
{isPositive ? '正面影响' : '负面影响'}
|
||||
</Badge>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button colorScheme="blue" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetailModal;
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 时间线组件
|
||||
*
|
||||
* 显示公司发展事件时间线
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Card,
|
||||
CardBody,
|
||||
Icon,
|
||||
Progress,
|
||||
Circle,
|
||||
Fade,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FaCalendarAlt,
|
||||
FaArrowUp,
|
||||
FaArrowDown,
|
||||
} from 'react-icons/fa';
|
||||
import EventDetailModal from './EventDetailModal';
|
||||
import type { TimelineComponentProps, TimelineEvent } from '../../types';
|
||||
|
||||
const TimelineComponent: React.FC<TimelineComponentProps> = ({ events }) => {
|
||||
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
// 背景颜色
|
||||
const positiveBgColor = 'red.50';
|
||||
const negativeBgColor = 'green.50';
|
||||
|
||||
const handleEventClick = (event: TimelineEvent) => {
|
||||
setSelectedEvent(event);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box position="relative" pl={8}>
|
||||
{/* 时间线轴 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
left="15px"
|
||||
top="20px"
|
||||
bottom="20px"
|
||||
width="2px"
|
||||
bg="gray.300"
|
||||
/>
|
||||
|
||||
<VStack align="stretch" spacing={6}>
|
||||
{events.map((event, idx) => {
|
||||
const isPositive = event.impact_metrics?.is_positive;
|
||||
const iconColor = isPositive ? 'red.500' : 'green.500';
|
||||
const bgColor = isPositive ? positiveBgColor : negativeBgColor;
|
||||
|
||||
return (
|
||||
<Fade in={true} key={idx}>
|
||||
<Box position="relative">
|
||||
{/* 时间点圆圈 */}
|
||||
<Circle
|
||||
size="30px"
|
||||
bg={iconColor}
|
||||
position="absolute"
|
||||
left="-15px"
|
||||
top="20px"
|
||||
zIndex={2}
|
||||
border="3px solid white"
|
||||
shadow="md"
|
||||
>
|
||||
<Icon
|
||||
as={isPositive ? FaArrowUp : FaArrowDown}
|
||||
color="white"
|
||||
boxSize={3}
|
||||
/>
|
||||
</Circle>
|
||||
|
||||
{/* 连接线 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
left="15px"
|
||||
top="35px"
|
||||
width="20px"
|
||||
height="2px"
|
||||
bg="gray.300"
|
||||
/>
|
||||
|
||||
{/* 事件卡片 */}
|
||||
<Card
|
||||
ml={10}
|
||||
bg={bgColor}
|
||||
cursor="pointer"
|
||||
onClick={() => handleEventClick(event)}
|
||||
_hover={{ shadow: 'lg', transform: 'translateX(4px)' }}
|
||||
transition="all 0.3s ease"
|
||||
>
|
||||
<CardBody p={4}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="bold" fontSize="sm">
|
||||
{event.event_title}
|
||||
</Text>
|
||||
<HStack spacing={2}>
|
||||
<Icon
|
||||
as={FaCalendarAlt}
|
||||
boxSize={3}
|
||||
color="gray.500"
|
||||
/>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="gray.500"
|
||||
fontWeight="medium"
|
||||
>
|
||||
{event.event_date}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Badge
|
||||
colorScheme={isPositive ? 'red' : 'green'}
|
||||
size="sm"
|
||||
>
|
||||
{event.event_type}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<Text fontSize="sm" color="gray.600" noOfLines={2}>
|
||||
{event.event_desc}
|
||||
</Text>
|
||||
|
||||
<HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
影响度:
|
||||
</Text>
|
||||
<Progress
|
||||
value={event.impact_metrics?.impact_score}
|
||||
size="xs"
|
||||
width="60px"
|
||||
colorScheme={
|
||||
(event.impact_metrics?.impact_score || 0) > 70
|
||||
? 'red'
|
||||
: 'orange'
|
||||
}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="gray.500"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{event.impact_metrics?.impact_score || 0}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<EventDetailModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
event={selectedEvent}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineComponent;
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* 相关公司模态框组件
|
||||
*
|
||||
* 显示产业链节点的相关上市公司列表
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Card,
|
||||
CardBody,
|
||||
Icon,
|
||||
IconButton,
|
||||
Center,
|
||||
Spinner,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
Box,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
Progress,
|
||||
Tooltip,
|
||||
Button,
|
||||
} from '@chakra-ui/react';
|
||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
||||
import {
|
||||
FaBuilding,
|
||||
FaHandshake,
|
||||
FaUserTie,
|
||||
FaIndustry,
|
||||
FaCog,
|
||||
FaNetworkWired,
|
||||
FaFlask,
|
||||
FaStar,
|
||||
FaArrowRight,
|
||||
FaArrowLeft,
|
||||
} from 'react-icons/fa';
|
||||
import type { ValueChainNode, RelatedCompany } from '../../types';
|
||||
|
||||
interface RelatedCompaniesModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
node: ValueChainNode;
|
||||
isCompany: boolean;
|
||||
colorScheme: string;
|
||||
relatedCompanies: RelatedCompany[];
|
||||
loadingRelated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点类型对应的图标
|
||||
*/
|
||||
const getNodeTypeIcon = (type: string) => {
|
||||
const icons: Record<string, React.ComponentType> = {
|
||||
company: FaBuilding,
|
||||
supplier: FaHandshake,
|
||||
customer: FaUserTie,
|
||||
product: FaIndustry,
|
||||
service: FaCog,
|
||||
channel: FaNetworkWired,
|
||||
raw_material: FaFlask,
|
||||
};
|
||||
return icons[type] || FaBuilding;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取重要度对应的颜色
|
||||
*/
|
||||
const getImportanceColor = (score?: number): string => {
|
||||
if (!score) return 'green';
|
||||
if (score >= 80) return 'red';
|
||||
if (score >= 60) return 'orange';
|
||||
if (score >= 40) return 'yellow';
|
||||
return 'green';
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取层级标签
|
||||
*/
|
||||
const getLevelLabel = (level?: number): { text: string; color: string } => {
|
||||
if (level === undefined) return { text: '未知', color: 'gray' };
|
||||
if (level < 0) return { text: '上游', color: 'orange' };
|
||||
if (level === 0) return { text: '核心', color: 'blue' };
|
||||
return { text: '下游', color: 'green' };
|
||||
};
|
||||
|
||||
const RelatedCompaniesModal: React.FC<RelatedCompaniesModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
node,
|
||||
isCompany,
|
||||
colorScheme,
|
||||
relatedCompanies,
|
||||
loadingRelated,
|
||||
}) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<HStack>
|
||||
<Icon
|
||||
as={getNodeTypeIcon(node.node_type)}
|
||||
color={`${colorScheme}.500`}
|
||||
boxSize={6}
|
||||
/>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text>{node.node_name}</Text>
|
||||
<HStack>
|
||||
<Badge colorScheme={colorScheme}>{node.node_type}</Badge>
|
||||
{isCompany && (
|
||||
<Badge colorScheme="blue" variant="solid">
|
||||
核心企业
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{node.node_description && (
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2} color="gray.600">
|
||||
节点描述
|
||||
</Text>
|
||||
<Text fontSize="sm" lineHeight="1.6">
|
||||
{node.node_description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<SimpleGrid columns={3} spacing={4}>
|
||||
<Stat>
|
||||
<StatLabel fontSize="xs">重要度评分</StatLabel>
|
||||
<StatNumber fontSize="lg">
|
||||
{node.importance_score || 0}
|
||||
</StatNumber>
|
||||
<StatHelpText>
|
||||
<Progress
|
||||
value={node.importance_score}
|
||||
size="sm"
|
||||
colorScheme={getImportanceColor(node.importance_score)}
|
||||
borderRadius="full"
|
||||
/>
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
|
||||
{node.market_share !== undefined && (
|
||||
<Stat>
|
||||
<StatLabel fontSize="xs">市场份额</StatLabel>
|
||||
<StatNumber fontSize="lg">{node.market_share}%</StatNumber>
|
||||
</Stat>
|
||||
)}
|
||||
|
||||
{node.dependency_degree !== undefined && (
|
||||
<Stat>
|
||||
<StatLabel fontSize="xs">依赖程度</StatLabel>
|
||||
<StatNumber fontSize="lg">
|
||||
{node.dependency_degree}%
|
||||
</StatNumber>
|
||||
<StatHelpText>
|
||||
<Progress
|
||||
value={node.dependency_degree}
|
||||
size="sm"
|
||||
colorScheme={
|
||||
node.dependency_degree > 50 ? 'orange' : 'green'
|
||||
}
|
||||
borderRadius="full"
|
||||
/>
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<HStack mb={3} justify="space-between">
|
||||
<Text fontWeight="bold" color="gray.600">
|
||||
相关公司
|
||||
</Text>
|
||||
{loadingRelated && <Spinner size="sm" />}
|
||||
</HStack>
|
||||
{loadingRelated ? (
|
||||
<Center py={4}>
|
||||
<Spinner size="md" />
|
||||
</Center>
|
||||
) : relatedCompanies.length > 0 ? (
|
||||
<VStack
|
||||
align="stretch"
|
||||
spacing={3}
|
||||
maxH="400px"
|
||||
overflowY="auto"
|
||||
>
|
||||
{relatedCompanies.map((company, idx) => {
|
||||
const levelInfo = getLevelLabel(company.node_info?.node_level);
|
||||
|
||||
return (
|
||||
<Card key={idx} variant="outline" size="sm">
|
||||
<CardBody p={3}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack flexWrap="wrap">
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{company.stock_name}
|
||||
</Text>
|
||||
<Badge size="sm" colorScheme="blue">
|
||||
{company.stock_code}
|
||||
</Badge>
|
||||
<Badge
|
||||
size="sm"
|
||||
colorScheme={levelInfo.color}
|
||||
variant="solid"
|
||||
>
|
||||
{levelInfo.text}
|
||||
</Badge>
|
||||
</HStack>
|
||||
{company.company_name && (
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="gray.500"
|
||||
noOfLines={1}
|
||||
>
|
||||
{company.company_name}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon={<ExternalLinkIcon />}
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
onClick={() => {
|
||||
window.location.href = `/company?stock_code=${company.stock_code}`;
|
||||
}}
|
||||
aria-label="查看公司详情"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{company.node_info?.node_description && (
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="gray.600"
|
||||
noOfLines={2}
|
||||
>
|
||||
{company.node_info.node_description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{company.relationships &&
|
||||
company.relationships.length > 0 && (
|
||||
<Box
|
||||
pt={2}
|
||||
borderTop="1px"
|
||||
borderColor="gray.100"
|
||||
>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color="gray.600"
|
||||
mb={1}
|
||||
>
|
||||
产业链关系:
|
||||
</Text>
|
||||
<VStack align="stretch" spacing={1}>
|
||||
{company.relationships.map((rel, ridx) => (
|
||||
<HStack
|
||||
key={ridx}
|
||||
fontSize="xs"
|
||||
spacing={2}
|
||||
>
|
||||
<Icon
|
||||
as={
|
||||
rel.role === 'source'
|
||||
? FaArrowRight
|
||||
: FaArrowLeft
|
||||
}
|
||||
color={
|
||||
rel.role === 'source'
|
||||
? 'green.500'
|
||||
: 'orange.500'
|
||||
}
|
||||
boxSize={3}
|
||||
/>
|
||||
<Text color="gray.700" noOfLines={1}>
|
||||
{rel.role === 'source'
|
||||
? '流向'
|
||||
: '来自'}
|
||||
<Text
|
||||
as="span"
|
||||
fontWeight="medium"
|
||||
mx={1}
|
||||
>
|
||||
{rel.connected_node}
|
||||
</Text>
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
) : (
|
||||
<Center py={4}>
|
||||
<VStack spacing={2}>
|
||||
<Icon as={FaBuilding} boxSize={8} color="gray.300" />
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
暂无相关公司
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button colorScheme="blue" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RelatedCompaniesModal;
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* 产业链节点卡片组件
|
||||
*
|
||||
* 显示产业链中的单个节点,点击可展开查看相关公司
|
||||
*/
|
||||
|
||||
import React, { useState } 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 { getApiBase } from '@utils/apiConfig';
|
||||
import RelatedCompaniesModal from './RelatedCompaniesModal';
|
||||
import type { ValueChainNodeCardProps, RelatedCompany } from '../../types';
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
/**
|
||||
* 获取节点类型对应的图标
|
||||
*/
|
||||
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 ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = ({
|
||||
node,
|
||||
isCompany = false,
|
||||
level = 0,
|
||||
}) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [relatedCompanies, setRelatedCompanies] = useState<RelatedCompany[]>([]);
|
||||
const [loadingRelated, setLoadingRelated] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
// 根据层级和是否为核心企业确定颜色方案
|
||||
const getColorScheme = (): string => {
|
||||
if (isCompany) return 'blue';
|
||||
if (level < 0) return 'orange';
|
||||
if (level > 0) return 'green';
|
||||
return 'gray';
|
||||
};
|
||||
|
||||
const colorScheme = getColorScheme();
|
||||
const bgColor = `${colorScheme}.50`;
|
||||
const borderColor = `${colorScheme}.200`;
|
||||
|
||||
// 获取相关公司数据
|
||||
const fetchRelatedCompanies = async () => {
|
||||
setLoadingRelated(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/company/value-chain/related-companies?node_name=${encodeURIComponent(
|
||||
node.node_name
|
||||
)}`
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setRelatedCompanies(data.data || []);
|
||||
} else {
|
||||
toast({
|
||||
title: '获取相关公司失败',
|
||||
description: data.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('ValueChainNodeCard', 'fetchRelatedCompanies', error, {
|
||||
node_name: node.node_name,
|
||||
});
|
||||
toast({
|
||||
title: '获取相关公司失败',
|
||||
description: error 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={bgColor}
|
||||
borderColor={borderColor}
|
||||
borderWidth={isCompany ? 3 : 1}
|
||||
shadow={isCompany ? 'lg' : 'sm'}
|
||||
cursor="pointer"
|
||||
onClick={handleCardClick}
|
||||
_hover={{
|
||||
shadow: 'xl',
|
||||
transform: 'translateY(-4px)',
|
||||
borderColor: `${colorScheme}.400`,
|
||||
}}
|
||||
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={`${colorScheme}.500`}
|
||||
boxSize={5}
|
||||
/>
|
||||
{isCompany && (
|
||||
<Badge colorScheme="blue" variant="solid">
|
||||
核心企业
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
{node.importance_score !== undefined &&
|
||||
node.importance_score >= 70 && (
|
||||
<Tooltip label="重要节点">
|
||||
<span>
|
||||
<Icon as={FaStar} color="orange.400" boxSize={4} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Text fontWeight="bold" fontSize="sm" noOfLines={2}>
|
||||
{node.node_name}
|
||||
</Text>
|
||||
|
||||
{node.node_description && (
|
||||
<Text fontSize="xs" color="gray.600" noOfLines={2}>
|
||||
{node.node_description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Badge variant="subtle" size="sm" colorScheme={colorScheme}>
|
||||
{node.node_type}
|
||||
</Badge>
|
||||
{node.market_share !== undefined && (
|
||||
<Badge variant="outline" size="sm">
|
||||
份额 {node.market_share}%
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{node.importance_score !== undefined && (
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
重要度
|
||||
</Text>
|
||||
<Text fontSize="xs" fontWeight="bold">
|
||||
{node.importance_score}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Progress
|
||||
value={node.importance_score}
|
||||
size="xs"
|
||||
colorScheme={getImportanceColor(node.importance_score)}
|
||||
borderRadius="full"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</ScaleFade>
|
||||
|
||||
<RelatedCompaniesModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
node={node}
|
||||
isCompany={isCompany}
|
||||
colorScheme={colorScheme}
|
||||
relatedCompanies={relatedCompanies}
|
||||
loadingRelated={loadingRelated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValueChainNodeCard;
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ==================== 主组件 Props 类型 ====================
|
||||
|
||||
export interface DeepAnalysisTabProps {
|
||||
comprehensiveData?: ComprehensiveData;
|
||||
valueChainData?: ValueChainData;
|
||||
keyFactorsData?: KeyFactorsData;
|
||||
loading?: boolean;
|
||||
cardBg?: string;
|
||||
expandedSegments: Record<number, boolean>;
|
||||
onToggleSegment: (index: number) => 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 };
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* DeepAnalysisTab 图表配置工具
|
||||
*
|
||||
* 生成雷达图和桑基图的 ECharts 配置
|
||||
*/
|
||||
|
||||
import type {
|
||||
ComprehensiveData,
|
||||
ValueChainData,
|
||||
RadarChartOption,
|
||||
SankeyChartOption,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* 生成竞争力雷达图配置
|
||||
* @param comprehensiveData - 综合分析数据
|
||||
* @returns ECharts 雷达图配置,或 null(数据不足时)
|
||||
*/
|
||||
export const getRadarChartOption = (
|
||||
comprehensiveData?: ComprehensiveData
|
||||
): RadarChartOption | null => {
|
||||
if (!comprehensiveData?.competitive_position?.scores) return null;
|
||||
|
||||
const scores = comprehensiveData.competitive_position.scores;
|
||||
const indicators = [
|
||||
{ name: '市场地位', max: 100 },
|
||||
{ name: '技术实力', max: 100 },
|
||||
{ name: '品牌价值', max: 100 },
|
||||
{ name: '运营效率', max: 100 },
|
||||
{ name: '财务健康', max: 100 },
|
||||
{ name: '创新能力', max: 100 },
|
||||
{ name: '风险控制', max: 100 },
|
||||
{ name: '成长潜力', max: 100 },
|
||||
];
|
||||
|
||||
const data = [
|
||||
scores.market_position || 0,
|
||||
scores.technology || 0,
|
||||
scores.brand || 0,
|
||||
scores.operation || 0,
|
||||
scores.finance || 0,
|
||||
scores.innovation || 0,
|
||||
scores.risk || 0,
|
||||
scores.growth || 0,
|
||||
];
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'item' },
|
||||
radar: {
|
||||
indicator: indicators,
|
||||
shape: 'polygon',
|
||||
splitNumber: 4,
|
||||
name: { textStyle: { color: '#666', fontSize: 12 } },
|
||||
splitLine: {
|
||||
lineStyle: { color: ['#e8e8e8', '#e0e0e0', '#d0d0d0', '#c0c0c0'] },
|
||||
},
|
||||
splitArea: {
|
||||
show: true,
|
||||
areaStyle: {
|
||||
color: ['rgba(250,250,250,0.3)', 'rgba(200,200,200,0.3)'],
|
||||
},
|
||||
},
|
||||
axisLine: { lineStyle: { color: '#ddd' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '竞争力评分',
|
||||
type: 'radar',
|
||||
data: [
|
||||
{
|
||||
value: data,
|
||||
name: '当前评分',
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
lineStyle: { width: 2, color: '#3182ce' },
|
||||
areaStyle: { color: 'rgba(49, 130, 206, 0.3)' },
|
||||
label: {
|
||||
show: true,
|
||||
formatter: (params: { value: number }) => params.value,
|
||||
color: '#3182ce',
|
||||
fontSize: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成产业链桑基图配置
|
||||
* @param valueChainData - 产业链数据
|
||||
* @returns ECharts 桑基图配置,或 null(数据不足时)
|
||||
*/
|
||||
export const getSankeyChartOption = (
|
||||
valueChainData?: ValueChainData
|
||||
): SankeyChartOption | null => {
|
||||
if (
|
||||
!valueChainData?.value_chain_flows ||
|
||||
valueChainData.value_chain_flows.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodes = new Set<string>();
|
||||
const links: Array<{
|
||||
source: string;
|
||||
target: string;
|
||||
value: number;
|
||||
lineStyle: { color: string; opacity: number };
|
||||
}> = [];
|
||||
|
||||
valueChainData.value_chain_flows.forEach((flow) => {
|
||||
if (!flow?.source?.node_name || !flow?.target?.node_name) return;
|
||||
nodes.add(flow.source.node_name);
|
||||
nodes.add(flow.target.node_name);
|
||||
links.push({
|
||||
source: flow.source.node_name,
|
||||
target: flow.target.node_name,
|
||||
value: parseFloat(flow.flow_metrics?.flow_ratio || '1') || 1,
|
||||
lineStyle: { color: 'source', opacity: 0.6 },
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'item', triggerOn: 'mousemove' },
|
||||
series: [
|
||||
{
|
||||
type: 'sankey',
|
||||
layout: 'none',
|
||||
emphasis: { focus: 'adjacency' },
|
||||
data: Array.from(nodes).map((name) => ({ name })),
|
||||
links: links,
|
||||
lineStyle: { color: 'gradient', curveness: 0.5 },
|
||||
label: { color: '#333', fontSize: 10 },
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
537
src/views/Company/components/CompanyOverview/NewsEventsTab.js
Normal file
537
src/views/Company/components/CompanyOverview/NewsEventsTab.js
Normal file
@@ -0,0 +1,537 @@
|
||||
// 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";
|
||||
|
||||
/**
|
||||
* 新闻动态 Tab 组件
|
||||
*
|
||||
* Props:
|
||||
* - newsEvents: 新闻事件列表数组
|
||||
* - newsLoading: 加载状态
|
||||
* - newsPagination: 分页信息 { page, per_page, total, pages, has_next, has_prev }
|
||||
* - searchQuery: 搜索关键词
|
||||
* - onSearchChange: 搜索输入回调 (value) => void
|
||||
* - onSearch: 搜索提交回调 () => void
|
||||
* - onPageChange: 分页回调 (page) => void
|
||||
* - cardBg: 卡片背景色
|
||||
*/
|
||||
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,
|
||||
}) => {
|
||||
// 事件类型图标映射
|
||||
const getEventTypeIcon = (eventType) => {
|
||||
const iconMap = {
|
||||
企业公告: FaBullhorn,
|
||||
政策: FaGavel,
|
||||
技术突破: FaFlask,
|
||||
企业融资: FaDollarSign,
|
||||
政策监管: FaShieldAlt,
|
||||
政策动态: FaFileAlt,
|
||||
行业事件: FaIndustry,
|
||||
};
|
||||
return iconMap[eventType] || FaNewspaper;
|
||||
};
|
||||
|
||||
// 重要性颜色映射
|
||||
const getImportanceColor = (importance) => {
|
||||
const colorMap = {
|
||||
S: "red",
|
||||
A: "orange",
|
||||
B: "yellow",
|
||||
C: "green",
|
||||
};
|
||||
return 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="gray.400">
|
||||
...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pageButtons.push(
|
||||
<Button
|
||||
key={i}
|
||||
size="sm"
|
||||
variant={i === currentPage ? "solid" : "outline"}
|
||||
colorScheme={i === currentPage ? "blue" : "gray"}
|
||||
onClick={() => handlePageChange(i)}
|
||||
isDisabled={newsLoading}
|
||||
>
|
||||
{i}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果结束页小于总页数,显示省略号
|
||||
if (endPage < totalPages) {
|
||||
pageButtons.push(
|
||||
<Text key="end-ellipsis" fontSize="sm" color="gray.400">
|
||||
...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return pageButtons;
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Card bg={cardBg} shadow="md">
|
||||
<CardBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 搜索框和统计信息 */}
|
||||
<HStack justify="space-between" flexWrap="wrap">
|
||||
<HStack flex={1} minW="300px">
|
||||
<InputGroup>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<SearchIcon color="gray.400" />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="搜索相关新闻..."
|
||||
value={searchQuery}
|
||||
onChange={handleInputChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleSearchSubmit}
|
||||
isLoading={newsLoading}
|
||||
minW="80px"
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{newsPagination.total > 0 && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaNewspaper} color="blue.500" />
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
共找到{" "}
|
||||
<Text as="span" fontWeight="bold" color="blue.600">
|
||||
{newsPagination.total}
|
||||
</Text>{" "}
|
||||
条新闻
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<div id="news-list-top" />
|
||||
|
||||
{/* 新闻列表 */}
|
||||
{newsLoading ? (
|
||||
<Center h="400px">
|
||||
<VStack spacing={3}>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color="gray.600">正在加载新闻...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : newsEvents.length > 0 ? (
|
||||
<>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{newsEvents.map((event, idx) => {
|
||||
const importanceColor = getImportanceColor(
|
||||
event.importance
|
||||
);
|
||||
const eventTypeIcon = getEventTypeIcon(event.event_type);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={event.id || idx}
|
||||
variant="outline"
|
||||
_hover={{
|
||||
bg: "gray.50",
|
||||
shadow: "md",
|
||||
borderColor: "blue.300",
|
||||
}}
|
||||
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="blue.500"
|
||||
boxSize={5}
|
||||
/>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
fontSize="lg"
|
||||
lineHeight="1.3"
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 标签栏 */}
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{event.importance && (
|
||||
<Badge
|
||||
colorScheme={importanceColor}
|
||||
variant="solid"
|
||||
px={2}
|
||||
>
|
||||
{event.importance}级
|
||||
</Badge>
|
||||
)}
|
||||
{event.event_type && (
|
||||
<Badge colorScheme="blue" variant="outline">
|
||||
{event.event_type}
|
||||
</Badge>
|
||||
)}
|
||||
{event.invest_score && (
|
||||
<Badge
|
||||
colorScheme="purple"
|
||||
variant="subtle"
|
||||
>
|
||||
投资分: {event.invest_score}
|
||||
</Badge>
|
||||
)}
|
||||
{event.keywords && event.keywords.length > 0 && (
|
||||
<>
|
||||
{event.keywords
|
||||
.slice(0, 4)
|
||||
.map((keyword, kidx) => (
|
||||
<Tag
|
||||
key={kidx}
|
||||
size="sm"
|
||||
colorScheme="cyan"
|
||||
variant="subtle"
|
||||
>
|
||||
{typeof keyword === "string"
|
||||
? keyword
|
||||
: keyword?.concept ||
|
||||
keyword?.name ||
|
||||
"未知"}
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 右侧信息栏 */}
|
||||
<VStack align="end" spacing={1} minW="100px">
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{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="gray.400"
|
||||
/>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{event.view_count}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
{event.hot_score !== undefined && (
|
||||
<HStack spacing={1}>
|
||||
<Icon
|
||||
as={FaFire}
|
||||
boxSize={3}
|
||||
color="orange.400"
|
||||
/>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{event.hot_score.toFixed(1)}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
{event.creator && (
|
||||
<Text fontSize="xs" color="gray.400">
|
||||
@{event.creator.username}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{/* 描述 */}
|
||||
{event.description && (
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color="gray.700"
|
||||
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="gray.200"
|
||||
>
|
||||
<HStack spacing={6} flexWrap="wrap">
|
||||
<HStack spacing={1}>
|
||||
<Icon
|
||||
as={FaChartLine}
|
||||
boxSize={3}
|
||||
color="gray.500"
|
||||
/>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="gray.500"
|
||||
fontWeight="medium"
|
||||
>
|
||||
相关涨跌:
|
||||
</Text>
|
||||
</HStack>
|
||||
{event.related_avg_chg !== null &&
|
||||
event.related_avg_chg !== undefined && (
|
||||
<HStack spacing={1}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
平均
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={
|
||||
event.related_avg_chg > 0
|
||||
? "red.500"
|
||||
: "green.500"
|
||||
}
|
||||
>
|
||||
{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="gray.500">
|
||||
最大
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={
|
||||
event.related_max_chg > 0
|
||||
? "red.500"
|
||||
: "green.500"
|
||||
}
|
||||
>
|
||||
{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="gray.500">
|
||||
周
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={
|
||||
event.related_week_chg > 0
|
||||
? "red.500"
|
||||
: "green.500"
|
||||
}
|
||||
>
|
||||
{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="gray.600">
|
||||
第 {newsPagination.page} / {newsPagination.pages} 页
|
||||
</Text>
|
||||
|
||||
{/* 分页按钮 */}
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(1)}
|
||||
isDisabled={!newsPagination.has_prev || newsLoading}
|
||||
leftIcon={<Icon as={FaChevronLeft} />}
|
||||
>
|
||||
首页
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handlePageChange(newsPagination.page - 1)
|
||||
}
|
||||
isDisabled={!newsPagination.has_prev || newsLoading}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
|
||||
{/* 页码按钮 */}
|
||||
{renderPaginationButtons()}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handlePageChange(newsPagination.page + 1)
|
||||
}
|
||||
isDisabled={!newsPagination.has_next || newsLoading}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
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="gray.300" />
|
||||
<Text color="gray.500" fontSize="lg" fontWeight="medium">
|
||||
暂无相关新闻
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.400">
|
||||
{searchQuery ? "尝试修改搜索关键词" : "该公司暂无新闻动态"}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsEventsTab;
|
||||
@@ -0,0 +1,96 @@
|
||||
// src/views/Company/components/CompanyOverview/components/shareholder/ActualControlCard.tsx
|
||||
// 实际控制人卡片组件
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
Badge,
|
||||
Icon,
|
||||
Card,
|
||||
CardBody,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
} from "@chakra-ui/react";
|
||||
import { FaCrown } from "react-icons/fa";
|
||||
import type { ActualControl } from "../../types";
|
||||
import { THEME } from "../../BasicInfoTab/config";
|
||||
|
||||
// 格式化工具函数
|
||||
const formatPercentage = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
return `${(value * 100).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
const formatShares = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
if (value >= 100000000) {
|
||||
return `${(value / 100000000).toFixed(2)}亿股`;
|
||||
} else if (value >= 10000) {
|
||||
return `${(value / 10000).toFixed(2)}万股`;
|
||||
}
|
||||
return `${value.toLocaleString()}股`;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string | null | undefined): string => {
|
||||
if (!dateStr) return "-";
|
||||
return dateStr.split("T")[0];
|
||||
};
|
||||
|
||||
interface ActualControlCardProps {
|
||||
actualControl: ActualControl[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际控制人卡片
|
||||
*/
|
||||
const ActualControlCard: React.FC<ActualControlCardProps> = ({ actualControl = [] }) => {
|
||||
if (!actualControl.length) return null;
|
||||
|
||||
const data = actualControl[0];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack mb={4}>
|
||||
<Icon as={FaCrown} color={THEME.gold} boxSize={5} />
|
||||
<Heading size="sm" color={THEME.gold}>实际控制人</Heading>
|
||||
</HStack>
|
||||
<Card bg={THEME.cardBg} borderColor={THEME.border} borderWidth="1px">
|
||||
<CardBody>
|
||||
<HStack
|
||||
justify="space-between"
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
align={{ base: "stretch", md: "center" }}
|
||||
gap={4}
|
||||
>
|
||||
<VStack align={{ base: "center", md: "start" }}>
|
||||
<Text fontWeight="bold" fontSize="lg" color={THEME.textPrimary}>
|
||||
{data.actual_controller_name}
|
||||
</Text>
|
||||
<HStack>
|
||||
<Badge colorScheme="purple">{data.control_type}</Badge>
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>
|
||||
截至 {formatDate(data.end_date)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Stat textAlign={{ base: "center", md: "right" }}>
|
||||
<StatLabel color={THEME.textSecondary}>控制比例</StatLabel>
|
||||
<StatNumber color={THEME.goldLight}>
|
||||
{formatPercentage(data.holding_ratio)}
|
||||
</StatNumber>
|
||||
<StatHelpText color={THEME.textSecondary}>{formatShares(data.holding_shares)}</StatHelpText>
|
||||
</Stat>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActualControlCard;
|
||||
@@ -0,0 +1,234 @@
|
||||
// src/views/Company/components/CompanyOverview/components/shareholder/ConcentrationCard.tsx
|
||||
// 股权集中度卡片组件
|
||||
|
||||
import React, { useMemo, useRef, useEffect } from "react";
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
Badge,
|
||||
Icon,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
SimpleGrid,
|
||||
} from "@chakra-ui/react";
|
||||
import { FaChartPie, FaArrowUp, FaArrowDown } from "react-icons/fa";
|
||||
import * as echarts from "echarts";
|
||||
import type { Concentration } from "../../types";
|
||||
import { THEME } from "../../BasicInfoTab/config";
|
||||
|
||||
// 格式化工具函数
|
||||
const formatPercentage = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
return `${(value * 100).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string | null | undefined): string => {
|
||||
if (!dateStr) return "-";
|
||||
return dateStr.split("T")[0];
|
||||
};
|
||||
|
||||
interface ConcentrationCardProps {
|
||||
concentration: Concentration[];
|
||||
}
|
||||
|
||||
// 饼图颜色配置(黑金主题)
|
||||
const PIE_COLORS = [
|
||||
"#D4AF37", // 金色 - 前1大股东
|
||||
"#F0D78C", // 浅金色 - 第2-3大股东
|
||||
"#B8860B", // 暗金色 - 第4-5大股东
|
||||
"#DAA520", // 金麒麟色 - 第6-10大股东
|
||||
"#4A5568", // 灰色 - 其他股东
|
||||
];
|
||||
|
||||
/**
|
||||
* 股权集中度卡片
|
||||
*/
|
||||
const ConcentrationCard: React.FC<ConcentrationCardProps> = ({ concentration = [] }) => {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const chartInstance = useRef<echarts.ECharts | null>(null);
|
||||
|
||||
// 按日期分组
|
||||
const groupedData = useMemo(() => {
|
||||
const grouped: Record<string, Record<string, Concentration>> = {};
|
||||
concentration.forEach((item) => {
|
||||
if (!grouped[item.end_date]) {
|
||||
grouped[item.end_date] = {};
|
||||
}
|
||||
grouped[item.end_date][item.stat_item] = item;
|
||||
});
|
||||
return Object.entries(grouped)
|
||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||
.slice(0, 1); // 只取最新一期
|
||||
}, [concentration]);
|
||||
|
||||
// 计算饼图数据
|
||||
const pieData = useMemo(() => {
|
||||
if (groupedData.length === 0) return [];
|
||||
|
||||
const [, items] = groupedData[0];
|
||||
const top1 = items["前1大股东"]?.holding_ratio || 0;
|
||||
const top3 = items["前3大股东"]?.holding_ratio || 0;
|
||||
const top5 = items["前5大股东"]?.holding_ratio || 0;
|
||||
const top10 = items["前10大股东"]?.holding_ratio || 0;
|
||||
|
||||
return [
|
||||
{ name: "前1大股东", value: Number((top1 * 100).toFixed(2)) },
|
||||
{ name: "第2-3大股东", value: Number(((top3 - top1) * 100).toFixed(2)) },
|
||||
{ name: "第4-5大股东", value: Number(((top5 - top3) * 100).toFixed(2)) },
|
||||
{ name: "第6-10大股东", value: Number(((top10 - top5) * 100).toFixed(2)) },
|
||||
{ name: "其他股东", value: Number(((1 - top10) * 100).toFixed(2)) },
|
||||
].filter(item => item.value > 0);
|
||||
}, [groupedData]);
|
||||
|
||||
// 初始化和更新图表
|
||||
useEffect(() => {
|
||||
if (!chartRef.current || pieData.length === 0) return;
|
||||
|
||||
// 使用 requestAnimationFrame 确保 DOM 渲染完成后再初始化
|
||||
const initChart = () => {
|
||||
if (!chartRef.current) return;
|
||||
|
||||
// 初始化图表
|
||||
if (!chartInstance.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
}
|
||||
|
||||
const option: echarts.EChartsOption = {
|
||||
backgroundColor: "transparent",
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
formatter: "{b}: {c}%",
|
||||
backgroundColor: "rgba(0,0,0,0.8)",
|
||||
borderColor: THEME.gold,
|
||||
textStyle: { color: "#fff" },
|
||||
},
|
||||
legend: {
|
||||
orient: "vertical",
|
||||
right: 10,
|
||||
top: "center",
|
||||
textStyle: { color: THEME.textSecondary, fontSize: 11 },
|
||||
itemWidth: 12,
|
||||
itemHeight: 12,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "股权集中度",
|
||||
type: "pie",
|
||||
radius: ["40%", "70%"],
|
||||
center: ["35%", "50%"],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 4,
|
||||
borderColor: THEME.cardBg,
|
||||
borderWidth: 2,
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
color: THEME.textPrimary,
|
||||
formatter: "{b}\n{c}%",
|
||||
},
|
||||
},
|
||||
labelLine: { show: false },
|
||||
data: pieData.map((item, index) => ({
|
||||
...item,
|
||||
itemStyle: { color: PIE_COLORS[index] },
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
chartInstance.current.setOption(option);
|
||||
|
||||
// 延迟 resize 确保容器尺寸已计算完成
|
||||
setTimeout(() => {
|
||||
chartInstance.current?.resize();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 延迟初始化,确保布局完成
|
||||
const rafId = requestAnimationFrame(initChart);
|
||||
|
||||
// 响应式
|
||||
const handleResize = () => chartInstance.current?.resize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [pieData]);
|
||||
|
||||
// 组件卸载时销毁图表
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
chartInstance.current?.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!concentration.length) return null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack mb={4}>
|
||||
<Icon as={FaChartPie} color={THEME.gold} boxSize={5} />
|
||||
<Heading size="sm" color={THEME.gold}>股权集中度</Heading>
|
||||
</HStack>
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{/* 数据卡片 */}
|
||||
{groupedData.map(([date, items]) => (
|
||||
<Card key={date} bg={THEME.cardBg} borderColor={THEME.border} borderWidth="1px">
|
||||
<CardHeader pb={2}>
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>
|
||||
{formatDate(date)}
|
||||
</Text>
|
||||
</CardHeader>
|
||||
<CardBody pt={2}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{Object.entries(items).map(([key, item]) => (
|
||||
<HStack key={key} justify="space-between">
|
||||
<Text fontSize="sm" color={THEME.textPrimary}>{item.stat_item}</Text>
|
||||
<HStack>
|
||||
<Text fontWeight="bold" color={THEME.goldLight}>
|
||||
{formatPercentage(item.holding_ratio)}
|
||||
</Text>
|
||||
{item.ratio_change && (
|
||||
<Badge
|
||||
colorScheme={item.ratio_change > 0 ? "red" : "green"}
|
||||
>
|
||||
<Icon
|
||||
as={item.ratio_change > 0 ? FaArrowUp : FaArrowDown}
|
||||
mr={1}
|
||||
boxSize={3}
|
||||
/>
|
||||
{Math.abs(item.ratio_change).toFixed(2)}%
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
{/* 饼图 */}
|
||||
<Card bg={THEME.cardBg} borderColor={THEME.border} borderWidth="1px">
|
||||
<CardBody p={2}>
|
||||
<Box ref={chartRef} h="180px" w="100%" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConcentrationCard;
|
||||
@@ -0,0 +1,226 @@
|
||||
// src/views/Company/components/CompanyOverview/components/shareholder/ShareholdersTable.tsx
|
||||
// 股东表格组件(合并版)- 支持十大股东和十大流通股东
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { Box, HStack, Heading, Badge, Icon, useBreakpointValue } from "@chakra-ui/react";
|
||||
import { Table, Tag, Tooltip, ConfigProvider } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { FaUsers, FaChartLine } from "react-icons/fa";
|
||||
import type { Shareholder } from "../../types";
|
||||
import { THEME } from "../../BasicInfoTab/config";
|
||||
|
||||
// antd 表格黑金主题配置
|
||||
const TABLE_THEME = {
|
||||
token: {
|
||||
colorBgContainer: "#2D3748", // gray.700
|
||||
colorText: "white",
|
||||
colorTextHeading: "#D4AF37", // 金色
|
||||
colorBorderSecondary: "rgba(212, 175, 55, 0.3)",
|
||||
},
|
||||
components: {
|
||||
Table: {
|
||||
headerBg: "#1A202C", // gray.900
|
||||
headerColor: "#D4AF37", // 金色
|
||||
rowHoverBg: "rgba(212, 175, 55, 0.15)", // 金色半透明,文字更清晰
|
||||
borderColor: "rgba(212, 175, 55, 0.2)",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 格式化工具函数
|
||||
const formatPercentage = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
return `${(value * 100).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
const formatShares = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
if (value >= 100000000) {
|
||||
return `${(value / 100000000).toFixed(2)}亿股`;
|
||||
} else if (value >= 10000) {
|
||||
return `${(value / 10000).toFixed(2)}万股`;
|
||||
}
|
||||
return `${value.toLocaleString()}股`;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string | null | undefined): string => {
|
||||
if (!dateStr) return "-";
|
||||
return dateStr.split("T")[0];
|
||||
};
|
||||
|
||||
// 股东类型颜色映射
|
||||
const shareholderTypeColors: Record<string, string> = {
|
||||
基金: "blue",
|
||||
个人: "green",
|
||||
法人: "purple",
|
||||
QFII: "orange",
|
||||
社保: "red",
|
||||
保险: "cyan",
|
||||
信托: "geekblue",
|
||||
券商: "magenta",
|
||||
企业: "purple",
|
||||
机构: "blue",
|
||||
};
|
||||
|
||||
const getShareholderTypeColor = (type: string | undefined): string => {
|
||||
if (!type) return "default";
|
||||
for (const [key, color] of Object.entries(shareholderTypeColors)) {
|
||||
if (type.includes(key)) return color;
|
||||
}
|
||||
return "default";
|
||||
};
|
||||
|
||||
interface ShareholdersTableProps {
|
||||
type?: "top" | "circulation";
|
||||
shareholders: Shareholder[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 股东表格组件
|
||||
* @param type - 表格类型: "top" 十大股东 | "circulation" 十大流通股东
|
||||
* @param shareholders - 股东数据数组
|
||||
* @param title - 自定义标题
|
||||
*/
|
||||
const ShareholdersTable: React.FC<ShareholdersTableProps> = ({
|
||||
type = "top",
|
||||
shareholders = [],
|
||||
title,
|
||||
}) => {
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
// 配置
|
||||
const config = useMemo(() => {
|
||||
if (type === "circulation") {
|
||||
return {
|
||||
title: title || "十大流通股东",
|
||||
icon: FaChartLine,
|
||||
iconColor: "purple.500",
|
||||
ratioField: "circulation_share_ratio" as keyof Shareholder,
|
||||
ratioLabel: "流通股比例",
|
||||
rankColor: "orange",
|
||||
showNature: true, // 与十大股东保持一致
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: title || "十大股东",
|
||||
icon: FaUsers,
|
||||
iconColor: "green.500",
|
||||
ratioField: "total_share_ratio" as keyof Shareholder,
|
||||
ratioLabel: "持股比例",
|
||||
rankColor: "red",
|
||||
showNature: true,
|
||||
};
|
||||
}, [type, title]);
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<Shareholder> = useMemo(() => {
|
||||
const baseColumns: ColumnsType<Shareholder> = [
|
||||
{
|
||||
title: "排名",
|
||||
dataIndex: "shareholder_rank",
|
||||
key: "rank",
|
||||
width: 45,
|
||||
render: (rank: number, _: Shareholder, index: number) => (
|
||||
<Tag color={index < 3 ? config.rankColor : "default"}>
|
||||
{rank || index + 1}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "股东名称",
|
||||
dataIndex: "shareholder_name",
|
||||
key: "name",
|
||||
ellipsis: true,
|
||||
render: (name: string) => (
|
||||
<Tooltip title={name}>
|
||||
<span style={{ fontWeight: 500, color: "#D4AF37" }}>{name}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "股东类型",
|
||||
dataIndex: "shareholder_type",
|
||||
key: "type",
|
||||
width: 90,
|
||||
responsive: ["md"],
|
||||
render: (shareholderType: string) => (
|
||||
<Tag color={getShareholderTypeColor(shareholderType)}>{shareholderType || "-"}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "持股数量",
|
||||
dataIndex: "holding_shares",
|
||||
key: "shares",
|
||||
width: 100,
|
||||
align: "right",
|
||||
responsive: ["md"],
|
||||
sorter: (a: Shareholder, b: Shareholder) => (a.holding_shares || 0) - (b.holding_shares || 0),
|
||||
render: (shares: number) => (
|
||||
<span style={{ color: "#D4AF37" }}>{formatShares(shares)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <span style={{ whiteSpace: "nowrap" }}>{config.ratioLabel}</span>,
|
||||
dataIndex: config.ratioField as string,
|
||||
key: "ratio",
|
||||
width: 110,
|
||||
align: "right",
|
||||
sorter: (a: Shareholder, b: Shareholder) => {
|
||||
const aVal = (a[config.ratioField] as number) || 0;
|
||||
const bVal = (b[config.ratioField] as number) || 0;
|
||||
return aVal - bVal;
|
||||
},
|
||||
defaultSortOrder: "descend",
|
||||
render: (ratio: number) => (
|
||||
<span style={{ color: type === "circulation" ? "#805AD5" : "#3182CE", fontWeight: "bold" }}>
|
||||
{formatPercentage(ratio)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 十大股东显示股份性质
|
||||
if (config.showNature) {
|
||||
baseColumns.push({
|
||||
title: "股份性质",
|
||||
dataIndex: "share_nature",
|
||||
key: "nature",
|
||||
width: 80,
|
||||
responsive: ["lg"],
|
||||
render: (nature: string) => (
|
||||
<Tag color="default">{nature || "流通股"}</Tag>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return baseColumns;
|
||||
}, [config, type]);
|
||||
|
||||
if (!shareholders.length) return null;
|
||||
|
||||
// 获取数据日期
|
||||
const reportDate = shareholders[0]?.end_date;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack mb={4}>
|
||||
<Icon as={config.icon} color={THEME.gold} boxSize={5} />
|
||||
<Heading size="sm" color={THEME.gold}>{config.title}</Heading>
|
||||
{reportDate && <Badge colorScheme="yellow" variant="subtle">{formatDate(reportDate)}</Badge>}
|
||||
</HStack>
|
||||
<ConfigProvider theme={TABLE_THEME}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={shareholders.slice(0, 10)}
|
||||
rowKey={(record: Shareholder, index?: number) => `${record.shareholder_name}-${index}`}
|
||||
pagination={false}
|
||||
size={isMobile ? "small" : "middle"}
|
||||
scroll={{ x: isMobile ? 400 : undefined }}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareholdersTable;
|
||||
@@ -0,0 +1,6 @@
|
||||
// src/views/Company/components/CompanyOverview/components/shareholder/index.ts
|
||||
// 股权结构子组件汇总导出
|
||||
|
||||
export { default as ActualControlCard } from "./ActualControlCard";
|
||||
export { default as ConcentrationCard } from "./ConcentrationCard";
|
||||
export { default as ShareholdersTable } from "./ShareholdersTable";
|
||||
@@ -0,0 +1,61 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts
|
||||
// 公告数据 Hook - 用于公司公告 Tab
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import type { Announcement } from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
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);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/stock/${stockCode}/announcements?limit=20`
|
||||
);
|
||||
const result = (await response.json()) as ApiResponse<Announcement[]>;
|
||||
|
||||
if (result.success) {
|
||||
setAnnouncements(result.data);
|
||||
} else {
|
||||
setError("加载公告数据失败");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("useAnnouncementsData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
return { announcements, loading, error };
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useBasicInfo.ts
|
||||
// 公司基本信息 Hook - 用于 CompanyHeaderCard
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import type { BasicInfo } from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
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);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/stock/${stockCode}/basic-info`);
|
||||
const result = (await response.json()) as ApiResponse<BasicInfo>;
|
||||
|
||||
if (result.success) {
|
||||
setBasicInfo(result.data);
|
||||
} else {
|
||||
setError("加载基本信息失败");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("useBasicInfo", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
return { basicInfo, loading, error };
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts
|
||||
// 分支机构数据 Hook - 用于分支机构 Tab
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import type { Branch } from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
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);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/stock/${stockCode}/branches`);
|
||||
const result = (await response.json()) as ApiResponse<Branch[]>;
|
||||
|
||||
if (result.success) {
|
||||
setBranches(result.data);
|
||||
} else {
|
||||
setError("加载分支机构数据失败");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("useBranchesData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
return { branches, loading, error };
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useCompanyOverviewData.ts
|
||||
// 公司概览数据加载 Hook
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import type {
|
||||
BasicInfo,
|
||||
ActualControl,
|
||||
Concentration,
|
||||
Management,
|
||||
Shareholder,
|
||||
Branch,
|
||||
Announcement,
|
||||
DisclosureSchedule,
|
||||
CompanyOverviewData,
|
||||
} from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 公司概览数据加载 Hook
|
||||
* @param propStockCode - 股票代码
|
||||
* @returns 公司概览数据
|
||||
*/
|
||||
export const useCompanyOverviewData = (propStockCode?: string): CompanyOverviewData => {
|
||||
const [stockCode, setStockCode] = useState(propStockCode || "000001");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dataLoaded, setDataLoaded] = useState(false);
|
||||
|
||||
// 基本信息数据
|
||||
const [basicInfo, setBasicInfo] = useState<BasicInfo | null>(null);
|
||||
const [actualControl, setActualControl] = useState<ActualControl[]>([]);
|
||||
const [concentration, setConcentration] = useState<Concentration[]>([]);
|
||||
const [management, setManagement] = useState<Management[]>([]);
|
||||
const [topCirculationShareholders, setTopCirculationShareholders] = useState<Shareholder[]>([]);
|
||||
const [topShareholders, setTopShareholders] = useState<Shareholder[]>([]);
|
||||
const [branches, setBranches] = useState<Branch[]>([]);
|
||||
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||
const [disclosureSchedule, setDisclosureSchedule] = useState<DisclosureSchedule[]>([]);
|
||||
|
||||
// 监听 props 中的 stockCode 变化
|
||||
useEffect(() => {
|
||||
if (propStockCode && propStockCode !== stockCode) {
|
||||
setStockCode(propStockCode);
|
||||
setDataLoaded(false);
|
||||
}
|
||||
}, [propStockCode, stockCode]);
|
||||
|
||||
// 加载基本信息数据(9个接口)
|
||||
const loadBasicInfoData = useCallback(async () => {
|
||||
if (dataLoaded) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const [
|
||||
basicRes,
|
||||
actualRes,
|
||||
concentrationRes,
|
||||
managementRes,
|
||||
circulationRes,
|
||||
shareholdersRes,
|
||||
branchesRes,
|
||||
announcementsRes,
|
||||
disclosureRes,
|
||||
] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/basic-info`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<BasicInfo>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/actual-control`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<ActualControl[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/concentration`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<Concentration[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/management?active_only=true`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<Management[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-circulation-shareholders?limit=10`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<Shareholder[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-shareholders?limit=10`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<Shareholder[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/branches`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<Branch[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/announcements?limit=20`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<Announcement[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/disclosure-schedule`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<DisclosureSchedule[]>>,
|
||||
]);
|
||||
|
||||
if (basicRes.success) setBasicInfo(basicRes.data);
|
||||
if (actualRes.success) setActualControl(actualRes.data);
|
||||
if (concentrationRes.success) setConcentration(concentrationRes.data);
|
||||
if (managementRes.success) setManagement(managementRes.data);
|
||||
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
|
||||
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
|
||||
if (branchesRes.success) setBranches(branchesRes.data);
|
||||
if (announcementsRes.success) setAnnouncements(announcementsRes.data);
|
||||
if (disclosureRes.success) setDisclosureSchedule(disclosureRes.data);
|
||||
|
||||
setDataLoaded(true);
|
||||
} catch (err) {
|
||||
logger.error("useCompanyOverviewData", "loadBasicInfoData", err, { stockCode });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode, dataLoaded]);
|
||||
|
||||
// 首次加载
|
||||
useEffect(() => {
|
||||
if (stockCode) {
|
||||
loadBasicInfoData();
|
||||
}
|
||||
}, [stockCode, loadBasicInfoData]);
|
||||
|
||||
return {
|
||||
basicInfo,
|
||||
actualControl,
|
||||
concentration,
|
||||
management,
|
||||
topCirculationShareholders,
|
||||
topShareholders,
|
||||
branches,
|
||||
announcements,
|
||||
disclosureSchedule,
|
||||
loading,
|
||||
dataLoaded,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts
|
||||
// 披露日程数据 Hook - 用于工商信息 Tab
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import type { DisclosureSchedule } from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
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);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/stock/${stockCode}/disclosure-schedule`
|
||||
);
|
||||
const result = (await response.json()) as ApiResponse<DisclosureSchedule[]>;
|
||||
|
||||
if (result.success) {
|
||||
setDisclosureSchedule(result.data);
|
||||
} else {
|
||||
setError("加载披露日程数据失败");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("useDisclosureData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
return { disclosureSchedule, loading, error };
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useManagementData.ts
|
||||
// 管理团队数据 Hook - 用于管理团队 Tab
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import type { Management } from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
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);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/stock/${stockCode}/management?active_only=true`
|
||||
);
|
||||
const result = (await response.json()) as ApiResponse<Management[]>;
|
||||
|
||||
if (result.success) {
|
||||
setManagement(result.data);
|
||||
} else {
|
||||
setError("加载管理团队数据失败");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("useManagementData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
return { management, loading, error };
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts
|
||||
// 股权结构数据 Hook - 用于股权结构 Tab
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import type { ActualControl, Concentration, Shareholder } from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
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);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [actualRes, concentrationRes, shareholdersRes, circulationRes] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/actual-control`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<ActualControl[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/concentration`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<Concentration[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-shareholders?limit=10`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<Shareholder[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-circulation-shareholders?limit=10`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<Shareholder[]>>,
|
||||
]);
|
||||
|
||||
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) {
|
||||
logger.error("useShareholderData", "loadData", err, { stockCode });
|
||||
setError("加载股权结构数据失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
return {
|
||||
actualControl,
|
||||
concentration,
|
||||
topShareholders,
|
||||
topCirculationShareholders,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
36
src/views/Company/components/CompanyOverview/index.tsx
Normal file
36
src/views/Company/components/CompanyOverview/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
// src/views/Company/components/CompanyOverview/index.tsx
|
||||
// 公司档案 - 主组件(组合层)
|
||||
|
||||
import React from "react";
|
||||
import { VStack } from "@chakra-ui/react";
|
||||
|
||||
import { useBasicInfo } from "./hooks/useBasicInfo";
|
||||
import type { CompanyOverviewProps } from "./types";
|
||||
|
||||
// 子组件(暂保持 JS)
|
||||
import BasicInfoTab from "./BasicInfoTab";
|
||||
|
||||
/**
|
||||
* 公司档案组件
|
||||
*
|
||||
* 功能:
|
||||
* - 显示基本信息 Tab(内部懒加载各子 Tab 数据)
|
||||
*
|
||||
* 懒加载策略:
|
||||
* - BasicInfoTab 内部根据 Tab 切换懒加载数据
|
||||
*/
|
||||
const CompanyOverview: React.FC<CompanyOverviewProps> = ({ stockCode }) => {
|
||||
const { basicInfo } = useBasicInfo(stockCode);
|
||||
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 基本信息内容 - 传入 stockCode,内部懒加载各 Tab 数据 */}
|
||||
<BasicInfoTab
|
||||
stockCode={stockCode}
|
||||
basicInfo={basicInfo}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompanyOverview;
|
||||
133
src/views/Company/components/CompanyOverview/types.ts
Normal file
133
src/views/Company/components/CompanyOverview/types.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际控制人
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* useCompanyOverviewData Hook 返回值
|
||||
*/
|
||||
export interface CompanyOverviewData {
|
||||
basicInfo: BasicInfo | null;
|
||||
actualControl: ActualControl[];
|
||||
concentration: Concentration[];
|
||||
management: Management[];
|
||||
topCirculationShareholders: Shareholder[];
|
||||
topShareholders: Shareholder[];
|
||||
branches: Branch[];
|
||||
announcements: Announcement[];
|
||||
disclosureSchedule: DisclosureSchedule[];
|
||||
loading: boolean;
|
||||
dataLoaded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CompanyOverview 组件 Props
|
||||
*/
|
||||
export interface CompanyOverviewProps {
|
||||
stockCode?: string;
|
||||
}
|
||||
|
||||
26
src/views/Company/components/CompanyOverview/utils.ts
Normal file
26
src/views/Company/components/CompanyOverview/utils.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// src/views/Company/components/CompanyOverview/utils.ts
|
||||
// 公司概览格式化工具函数
|
||||
|
||||
/**
|
||||
* 格式化注册资本
|
||||
* @param value - 注册资本(万元)
|
||||
* @returns 格式化后的字符串
|
||||
*/
|
||||
export const formatRegisteredCapital = (value: number | null | undefined): string => {
|
||||
if (!value && value !== 0) return "-";
|
||||
const absValue = Math.abs(value);
|
||||
if (absValue >= 100000) {
|
||||
return (value / 10000).toFixed(2) + "亿元";
|
||||
}
|
||||
return value.toFixed(2) + "万元";
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
* @param dateString - 日期字符串
|
||||
* @returns 格式化后的日期字符串
|
||||
*/
|
||||
export const formatDate = (dateString: string | null | undefined): string => {
|
||||
if (!dateString) return "-";
|
||||
return new Date(dateString).toLocaleDateString("zh-CN");
|
||||
};
|
||||
75
src/views/Company/components/CompanyTabs/index.js
Normal file
75
src/views/Company/components/CompanyTabs/index.js
Normal file
@@ -0,0 +1,75 @@
|
||||
// src/views/Company/components/CompanyTabs/index.js
|
||||
// Tab 容器组件 - 使用通用 TabContainer 组件
|
||||
|
||||
import React from 'react';
|
||||
import TabContainer from '@components/TabContainer';
|
||||
import { COMPANY_TABS, getTabNameByIndex } from '../../constants';
|
||||
|
||||
// 子组件导入(Tab 内容组件)
|
||||
import CompanyOverview from '../CompanyOverview';
|
||||
import DeepAnalysis from '../DeepAnalysis';
|
||||
import MarketDataView from '../MarketDataView';
|
||||
import FinancialPanorama from '../FinancialPanorama';
|
||||
import ForecastReport from '../ForecastReport';
|
||||
import DynamicTracking from '../DynamicTracking';
|
||||
|
||||
/**
|
||||
* Tab 组件映射
|
||||
*/
|
||||
const TAB_COMPONENTS = {
|
||||
overview: CompanyOverview,
|
||||
analysis: DeepAnalysis,
|
||||
market: MarketDataView,
|
||||
financial: FinancialPanorama,
|
||||
forecast: ForecastReport,
|
||||
tracking: DynamicTracking,
|
||||
};
|
||||
|
||||
/**
|
||||
* 构建 TabContainer 所需的 tabs 配置
|
||||
* 合并 COMPANY_TABS 和对应的组件
|
||||
*/
|
||||
const buildTabsConfig = () => {
|
||||
return COMPANY_TABS.map((tab) => ({
|
||||
...tab,
|
||||
component: TAB_COMPONENTS[tab.key],
|
||||
}));
|
||||
};
|
||||
|
||||
// 预构建 tabs 配置(避免每次渲染重新计算)
|
||||
const TABS_CONFIG = buildTabsConfig();
|
||||
|
||||
/**
|
||||
* 公司详情 Tab 容器组件
|
||||
*
|
||||
* 功能:
|
||||
* - 使用通用 TabContainer 组件
|
||||
* - 保持黑金主题风格
|
||||
* - 触发 Tab 变更追踪
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.stockCode - 当前股票代码
|
||||
* @param {Function} props.onTabChange - Tab 变更回调 (index, tabName, prevIndex) => void
|
||||
*/
|
||||
const CompanyTabs = ({ stockCode, onTabChange }) => {
|
||||
/**
|
||||
* 处理 Tab 切换
|
||||
* 转换 tabKey 为 tabName 以保持原有回调格式
|
||||
*/
|
||||
const handleTabChange = (index, tabKey, prevIndex) => {
|
||||
const tabName = getTabNameByIndex(index);
|
||||
onTabChange?.(index, tabName, prevIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<TabContainer
|
||||
tabs={TABS_CONFIG}
|
||||
componentProps={{ stockCode }}
|
||||
onTabChange={handleTabChange}
|
||||
themePreset="blackGold"
|
||||
borderRadius="16px"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompanyTabs;
|
||||
100
src/views/Company/components/DeepAnalysis/index.js
Normal file
100
src/views/Company/components/DeepAnalysis/index.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// src/views/Company/components/DeepAnalysis/index.js
|
||||
// 深度分析 - 独立一级 Tab 组件
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
|
||||
// 复用原有的展示组件
|
||||
import DeepAnalysisTab from "../CompanyOverview/DeepAnalysisTab";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
/**
|
||||
* 深度分析组件
|
||||
*
|
||||
* 功能:
|
||||
* - 加载深度分析数据(3个接口)
|
||||
* - 管理展开状态
|
||||
* - 渲染 DeepAnalysisTab 展示组件
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.stockCode - 股票代码
|
||||
*/
|
||||
const DeepAnalysis = ({ stockCode }) => {
|
||||
// 数据状态
|
||||
const [comprehensiveData, setComprehensiveData] = useState(null);
|
||||
const [valueChainData, setValueChainData] = useState(null);
|
||||
const [keyFactorsData, setKeyFactorsData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 业务板块展开状态
|
||||
const [expandedSegments, setExpandedSegments] = useState({});
|
||||
|
||||
// 切换业务板块展开状态
|
||||
const toggleSegmentExpansion = (segmentIndex) => {
|
||||
setExpandedSegments((prev) => ({
|
||||
...prev,
|
||||
[segmentIndex]: !prev[segmentIndex],
|
||||
}));
|
||||
};
|
||||
|
||||
// 加载深度分析数据(3个接口)
|
||||
const loadDeepAnalysisData = async () => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const requests = [
|
||||
fetch(
|
||||
`${API_BASE_URL}/api/company/comprehensive-analysis/${stockCode}`
|
||||
).then((r) => r.json()),
|
||||
fetch(
|
||||
`${API_BASE_URL}/api/company/value-chain-analysis/${stockCode}`
|
||||
).then((r) => r.json()),
|
||||
fetch(
|
||||
`${API_BASE_URL}/api/company/key-factors-timeline/${stockCode}`
|
||||
).then((r) => r.json()),
|
||||
];
|
||||
|
||||
const [comprehensiveRes, valueChainRes, keyFactorsRes] =
|
||||
await Promise.all(requests);
|
||||
|
||||
if (comprehensiveRes.success) setComprehensiveData(comprehensiveRes.data);
|
||||
if (valueChainRes.success) setValueChainData(valueChainRes.data);
|
||||
if (keyFactorsRes.success) setKeyFactorsData(keyFactorsRes.data);
|
||||
} catch (err) {
|
||||
logger.error("DeepAnalysis", "loadDeepAnalysisData", err, { stockCode });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// stockCode 变更时重新加载数据
|
||||
useEffect(() => {
|
||||
if (stockCode) {
|
||||
// 重置数据
|
||||
setComprehensiveData(null);
|
||||
setValueChainData(null);
|
||||
setKeyFactorsData(null);
|
||||
setExpandedSegments({});
|
||||
// 加载新数据
|
||||
loadDeepAnalysisData();
|
||||
}
|
||||
}, [stockCode]);
|
||||
|
||||
return (
|
||||
<DeepAnalysisTab
|
||||
comprehensiveData={comprehensiveData}
|
||||
valueChainData={valueChainData}
|
||||
keyFactorsData={keyFactorsData}
|
||||
loading={loading}
|
||||
cardBg="white"
|
||||
expandedSegments={expandedSegments}
|
||||
onToggleSegment={toggleSegmentExpansion}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeepAnalysis;
|
||||
206
src/views/Company/components/DynamicTracking/index.js
Normal file
206
src/views/Company/components/DynamicTracking/index.js
Normal file
@@ -0,0 +1,206 @@
|
||||
// src/views/Company/components/DynamicTracking/index.js
|
||||
// 动态跟踪 - 独立一级 Tab 组件(包含新闻动态等二级 Tab)
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Box,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
} from "@chakra-ui/react";
|
||||
import { FaNewspaper, FaBullhorn, FaCalendarAlt } from "react-icons/fa";
|
||||
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import NewsEventsTab from "../CompanyOverview/NewsEventsTab";
|
||||
import AnnouncementsPanel from "../CompanyOverview/BasicInfoTab/components/AnnouncementsPanel";
|
||||
import DisclosureSchedulePanel from "../CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel";
|
||||
import { THEME } from "../CompanyOverview/BasicInfoTab/config";
|
||||
|
||||
// API配置
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
// 二级 Tab 配置
|
||||
const TRACKING_TABS = [
|
||||
{ key: "news", name: "新闻动态", icon: FaNewspaper },
|
||||
{ key: "announcements", name: "公司公告", icon: FaBullhorn },
|
||||
{ key: "disclosure", name: "财报披露日程", icon: FaCalendarAlt },
|
||||
];
|
||||
|
||||
/**
|
||||
* 动态跟踪组件
|
||||
*
|
||||
* 功能:
|
||||
* - 二级 Tab 结构
|
||||
* - Tab1: 新闻动态(复用 NewsEventsTab)
|
||||
* - 预留后续扩展
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.stockCode - 股票代码
|
||||
*/
|
||||
const DynamicTracking = ({ stockCode: propStockCode }) => {
|
||||
const [stockCode, setStockCode] = useState(propStockCode || "000001");
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
// 新闻动态状态
|
||||
const [newsEvents, setNewsEvents] = useState([]);
|
||||
const [newsLoading, setNewsLoading] = useState(false);
|
||||
const [newsPagination, setNewsPagination] = 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 [dataLoaded, setDataLoaded] = useState(false);
|
||||
|
||||
// 监听 props 中的 stockCode 变化
|
||||
useEffect(() => {
|
||||
if (propStockCode && propStockCode !== stockCode) {
|
||||
setStockCode(propStockCode);
|
||||
setDataLoaded(false);
|
||||
setNewsEvents([]);
|
||||
setStockName("");
|
||||
setSearchQuery("");
|
||||
}
|
||||
}, [propStockCode, stockCode]);
|
||||
|
||||
// 获取股票名称(用于搜索)
|
||||
const fetchStockName = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/stock/${stockCode}/basic-info`
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
const name = result.data.SECNAME || result.data.ORGNAME || stockCode;
|
||||
setStockName(name);
|
||||
return name;
|
||||
}
|
||||
return stockCode;
|
||||
} catch (err) {
|
||||
logger.error("DynamicTracking", "fetchStockName", err, { stockCode });
|
||||
return stockCode;
|
||||
}
|
||||
}, [stockCode]);
|
||||
|
||||
// 加载新闻事件数据
|
||||
const loadNewsEvents = useCallback(
|
||||
async (query, page = 1) => {
|
||||
setNewsLoading(true);
|
||||
try {
|
||||
const searchTerm = query || stockName || stockCode;
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/events?q=${encodeURIComponent(searchTerm)}&page=${page}&per_page=10`
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setNewsEvents(result.data || []);
|
||||
setNewsPagination({
|
||||
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("DynamicTracking", "loadNewsEvents", err, { stockCode });
|
||||
setNewsEvents([]);
|
||||
} finally {
|
||||
setNewsLoading(false);
|
||||
}
|
||||
},
|
||||
[stockCode, stockName]
|
||||
);
|
||||
|
||||
// 首次加载
|
||||
useEffect(() => {
|
||||
const initLoad = async () => {
|
||||
if (stockCode && !dataLoaded) {
|
||||
const name = await fetchStockName();
|
||||
await loadNewsEvents(name, 1);
|
||||
setDataLoaded(true);
|
||||
}
|
||||
};
|
||||
initLoad();
|
||||
}, [stockCode, dataLoaded, fetchStockName, loadNewsEvents]);
|
||||
|
||||
// 搜索处理
|
||||
const handleSearchChange = (value) => {
|
||||
setSearchQuery(value);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
loadNewsEvents(searchQuery || stockName, 1);
|
||||
};
|
||||
|
||||
// 分页处理
|
||||
const handlePageChange = (page) => {
|
||||
loadNewsEvents(searchQuery || stockName, page);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg={THEME.bg} p={4} borderRadius="md">
|
||||
<Tabs
|
||||
variant="soft-rounded"
|
||||
index={activeTab}
|
||||
onChange={setActiveTab}
|
||||
isLazy
|
||||
>
|
||||
<TabList bg={THEME.cardBg} borderBottom="1px solid" borderColor={THEME.border}>
|
||||
{TRACKING_TABS.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
fontWeight="medium"
|
||||
color={THEME.textSecondary}
|
||||
_selected={{
|
||||
color: THEME.tabSelected.color,
|
||||
bg: THEME.tabSelected.bg,
|
||||
borderRadius: "md",
|
||||
}}
|
||||
_hover={{ color: THEME.gold }}
|
||||
>
|
||||
{tab.name}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* 新闻动态 Tab */}
|
||||
<TabPanel p={4}>
|
||||
<NewsEventsTab
|
||||
newsEvents={newsEvents}
|
||||
newsLoading={newsLoading}
|
||||
newsPagination={newsPagination}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearch={handleSearch}
|
||||
onPageChange={handlePageChange}
|
||||
cardBg="white"
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
{/* 公司公告 Tab */}
|
||||
<TabPanel p={4}>
|
||||
<AnnouncementsPanel stockCode={stockCode} />
|
||||
</TabPanel>
|
||||
|
||||
{/* 财报披露日程 Tab */}
|
||||
<TabPanel p={4}>
|
||||
<DisclosureSchedulePanel stockCode={stockCode} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicTracking;
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/views/Company/FinancialPanorama.jsx
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { logger } from '@utils/logger';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
VStack,
|
||||
HStack,
|
||||
Divider,
|
||||
useColorModeValue,
|
||||
Select,
|
||||
Button,
|
||||
Tooltip,
|
||||
@@ -60,7 +59,6 @@ import {
|
||||
ButtonGroup,
|
||||
Stack,
|
||||
Collapse,
|
||||
useColorMode,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
@@ -75,7 +73,7 @@ import {
|
||||
ArrowDownIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { financialService, formatUtils, chartUtils } from '../../services/financialService';
|
||||
import { financialService, formatUtils, chartUtils } from '@services/financialService';
|
||||
|
||||
const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
// 状态管理
|
||||
@@ -84,7 +82,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedPeriods, setSelectedPeriods] = useState(8);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
|
||||
// 财务数据状态
|
||||
const [stockInfo, setStockInfo] = useState(null);
|
||||
const [balanceSheet, setBalanceSheet] = useState([]);
|
||||
@@ -101,14 +99,13 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
const [modalContent, setModalContent] = useState(null);
|
||||
const [expandedRows, setExpandedRows] = useState({});
|
||||
const toast = useToast();
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
|
||||
// 颜色配置(中国市场:红涨绿跌)
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const positiveColor = useColorModeValue('red.500', 'red.400'); // 红涨
|
||||
const negativeColor = useColorModeValue('green.500', 'green.400'); // 绿跌
|
||||
const bgColor = 'white';
|
||||
const borderColor = 'gray.200';
|
||||
const hoverBg = 'gray.50';
|
||||
const positiveColor = 'red.500'; // 红涨
|
||||
const negativeColor = 'green.500'; // 绿跌
|
||||
|
||||
// 加载所有财务数据
|
||||
const loadFinancialData = async () => {
|
||||
@@ -492,7 +489,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
<React.Fragment key={section.key}>
|
||||
{section.title !== '资产总计' && section.title !== '负债合计' && (
|
||||
<Tr
|
||||
bg={useColorModeValue('gray.50', 'gray.700')}
|
||||
bg="gray.50"
|
||||
cursor="pointer"
|
||||
onClick={() => toggleSection(section.key)}
|
||||
>
|
||||
@@ -515,7 +512,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
key={metric.key}
|
||||
_hover={{ bg: hoverBg, cursor: 'pointer' }}
|
||||
onClick={() => showMetricChart(metric.name, metric.key, balanceSheet, metric.path)}
|
||||
bg={metric.isTotal ? useColorModeValue('blue.50', 'blue.900') : 'transparent'}
|
||||
bg={metric.isTotal ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
|
||||
<HStack spacing={2}>
|
||||
@@ -733,7 +730,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
const renderSection = (section) => (
|
||||
<React.Fragment key={section.key}>
|
||||
<Tr
|
||||
bg={useColorModeValue('gray.50', 'gray.700')}
|
||||
bg="gray.50"
|
||||
cursor="pointer"
|
||||
onClick={() => toggleSection(section.key)}
|
||||
>
|
||||
@@ -755,8 +752,8 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
key={metric.key}
|
||||
_hover={{ bg: hoverBg, cursor: 'pointer' }}
|
||||
onClick={() => showMetricChart(metric.name, metric.key, incomeStatement, metric.path)}
|
||||
bg={metric.isTotal ? useColorModeValue('blue.50', 'blue.900') :
|
||||
metric.isSubtotal ? useColorModeValue('orange.50', 'orange.900') : 'transparent'}
|
||||
bg={metric.isTotal ? 'blue.50' :
|
||||
metric.isSubtotal ? 'orange.50' : 'transparent'}
|
||||
>
|
||||
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
|
||||
<HStack spacing={2}>
|
||||
@@ -1268,7 +1265,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
{ label: '资产负债率', value: financialMetrics[0].solvency?.asset_liability_ratio, format: 'percent' },
|
||||
{ label: '研发费用率', value: financialMetrics[0].expense_ratios?.rd_expense_ratio, format: 'percent' },
|
||||
].map((item, idx) => (
|
||||
<Box key={idx} p={3} borderRadius="md" bg={useColorModeValue('gray.50', 'gray.700')}>
|
||||
<Box key={idx} p={3} borderRadius="md" bg="gray.50">
|
||||
<Text fontSize="xs" color="gray.500">{item.label}</Text>
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
{item.format === 'percent' ?
|
||||
@@ -4,7 +4,7 @@ import { Box, Flex, Input, Button, SimpleGrid, HStack, Text, Skeleton, VStack }
|
||||
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';
|
||||
import { stockService } from '@services/eventService';
|
||||
|
||||
const ForecastReport = ({ stockCode: propStockCode }) => {
|
||||
const [code, setCode] = useState(propStockCode || '600000');
|
||||
@@ -0,0 +1,188 @@
|
||||
// src/views/Company/components/MarketDataView/components/AnalysisModal.tsx
|
||||
// 涨幅分析模态框组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
VStack,
|
||||
HStack,
|
||||
Box,
|
||||
Heading,
|
||||
Text,
|
||||
Tag,
|
||||
Badge,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
||||
import MarkdownRenderer from './MarkdownRenderer';
|
||||
import { formatNumber } from '../utils/formatUtils';
|
||||
import type { AnalysisModalProps, RiseAnalysis, Theme } from '../types';
|
||||
|
||||
/**
|
||||
* 涨幅分析内容组件
|
||||
*/
|
||||
interface AnalysisContentProps {
|
||||
analysis: RiseAnalysis;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
export const AnalysisContent: React.FC<AnalysisContentProps> = ({ analysis, theme }) => {
|
||||
return (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{/* 头部信息 */}
|
||||
<Box>
|
||||
<Heading size="md" mb={2}>
|
||||
{analysis.stock_name} ({analysis.stock_code})
|
||||
</Heading>
|
||||
<HStack spacing={4} mb={4}>
|
||||
<Tag colorScheme="blue">日期: {analysis.trade_date}</Tag>
|
||||
<Tag colorScheme="red">涨幅: {analysis.rise_rate}%</Tag>
|
||||
<Tag colorScheme="green">收盘价: {analysis.close_price}</Tag>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 主营业务 */}
|
||||
{analysis.main_business && (
|
||||
<Box p={4} bg="gray.50" borderRadius="md">
|
||||
<Heading size="sm" mb={2} color={theme.primary}>
|
||||
主营业务
|
||||
</Heading>
|
||||
<Text color={theme.textPrimary}>{analysis.main_business}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 详细分析 */}
|
||||
{analysis.rise_reason_detail && (
|
||||
<Box p={4} bg="purple.50" borderRadius="md">
|
||||
<Heading size="sm" mb={2} color={theme.primary}>
|
||||
详细分析
|
||||
</Heading>
|
||||
<MarkdownRenderer theme={theme}>{analysis.rise_reason_detail}</MarkdownRenderer>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 相关公告 */}
|
||||
{analysis.announcements && analysis.announcements !== '[]' && (
|
||||
<Box p={4} bg="orange.50" borderRadius="md">
|
||||
<Heading size="sm" mb={2} color={theme.primary}>
|
||||
相关公告
|
||||
</Heading>
|
||||
<MarkdownRenderer theme={theme}>{analysis.announcements}</MarkdownRenderer>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 研报引用 */}
|
||||
{analysis.verification_reports && analysis.verification_reports.length > 0 && (
|
||||
<Box p={4} bg="blue.50" borderRadius="md">
|
||||
<Heading size="sm" mb={3} color={theme.primary}>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={ExternalLinkIcon} />
|
||||
<Text>研报引用 ({analysis.verification_reports.length})</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{analysis.verification_reports.map((report, reportIdx) => (
|
||||
<Box
|
||||
key={reportIdx}
|
||||
p={3}
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<HStack justify="space-between" mb={2}>
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme="blue" fontSize="xs">
|
||||
{report.publisher || '未知机构'}
|
||||
</Badge>
|
||||
{report.match_score && (
|
||||
<Badge
|
||||
colorScheme={
|
||||
report.match_score === '好'
|
||||
? 'green'
|
||||
: report.match_score === '中'
|
||||
? 'yellow'
|
||||
: 'gray'
|
||||
}
|
||||
fontSize="xs"
|
||||
>
|
||||
匹配度: {report.match_score}
|
||||
</Badge>
|
||||
)}
|
||||
{report.match_ratio != null && report.match_ratio > 0 && (
|
||||
<Badge colorScheme="purple" fontSize="xs">
|
||||
{(report.match_ratio * 100).toFixed(0)}%
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
{report.declare_date && (
|
||||
<Text fontSize="xs" color={theme.textMuted}>
|
||||
{report.declare_date.substring(0, 10)}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{report.report_title && (
|
||||
<Text fontWeight="bold" fontSize="sm" color={theme.textPrimary} mb={1}>
|
||||
《{report.report_title}》
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{report.author && (
|
||||
<Text fontSize="xs" color={theme.textMuted} mb={2}>
|
||||
分析师: {report.author}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{report.verification_item && (
|
||||
<Box p={2} bg="yellow.50" borderRadius="sm" mb={2}>
|
||||
<Text fontSize="xs" color={theme.textMuted}>
|
||||
<strong>验证项:</strong> {report.verification_item}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{report.content && (
|
||||
<Text fontSize="sm" color={theme.textSecondary} noOfLines={4}>
|
||||
{report.content}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 底部统计 */}
|
||||
<Box mt={4}>
|
||||
<Text fontSize="sm" color={theme.textMuted}>
|
||||
成交量: {formatNumber(analysis.volume)} | 成交额: {formatNumber(analysis.amount)} | 更新时间:{' '}
|
||||
{analysis.update_time || analysis.create_time || '-'}
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 涨幅分析模态框组件
|
||||
*/
|
||||
const AnalysisModal: React.FC<AnalysisModalProps> = ({ isOpen, onClose, content, theme }) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="4xl" scrollBehavior="inside">
|
||||
<ModalOverlay />
|
||||
<ModalContent bg={theme.bgCard}>
|
||||
<ModalHeader color={theme.textPrimary}>涨幅分析详情</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>{content}</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalysisModal;
|
||||
@@ -0,0 +1,65 @@
|
||||
// src/views/Company/components/MarketDataView/components/MarkdownRenderer.tsx
|
||||
// Markdown 渲染组件
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { MarkdownRendererProps } from '../types';
|
||||
|
||||
/**
|
||||
* Markdown 渲染组件
|
||||
* 提供统一的 Markdown 样式
|
||||
*/
|
||||
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ children, theme }) => {
|
||||
return (
|
||||
<Box
|
||||
color={theme.textPrimary}
|
||||
sx={{
|
||||
'& h1, & h2, & h3, & h4, & h5, & h6': {
|
||||
color: theme.primary,
|
||||
marginTop: 4,
|
||||
marginBottom: 2,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'& h1': { fontSize: '1.5em' },
|
||||
'& h2': { fontSize: '1.3em' },
|
||||
'& h3': { fontSize: '1.1em' },
|
||||
'& p': {
|
||||
marginBottom: 3,
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
'& ul, & ol': {
|
||||
paddingLeft: 4,
|
||||
marginBottom: 3,
|
||||
},
|
||||
'& li': {
|
||||
marginBottom: 1,
|
||||
},
|
||||
'& strong': {
|
||||
fontWeight: 'bold',
|
||||
color: theme.textSecondary,
|
||||
},
|
||||
'& em': {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'& code': {
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.9em',
|
||||
},
|
||||
'& blockquote': {
|
||||
borderLeft: `3px solid ${theme.primary}`,
|
||||
paddingLeft: 4,
|
||||
marginLeft: 2,
|
||||
fontStyle: 'italic',
|
||||
opacity: 0.9,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown>{children}</ReactMarkdown>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownRenderer;
|
||||
@@ -0,0 +1,133 @@
|
||||
// src/views/Company/components/MarketDataView/components/StockSummaryCard.tsx
|
||||
// 股票概览卡片组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
CardBody,
|
||||
Grid,
|
||||
GridItem,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
SimpleGrid,
|
||||
} from '@chakra-ui/react';
|
||||
import ThemedCard from './ThemedCard';
|
||||
import { formatNumber, formatPercent } from '../utils/formatUtils';
|
||||
import type { StockSummaryCardProps } from '../types';
|
||||
|
||||
/**
|
||||
* 股票概览卡片组件
|
||||
* 显示股票基本信息、最新交易数据和融资融券数据
|
||||
*/
|
||||
const StockSummaryCard: React.FC<StockSummaryCardProps> = ({ summary, theme }) => {
|
||||
if (!summary) return null;
|
||||
|
||||
const { latest_trade, latest_funding, latest_pledge } = summary;
|
||||
|
||||
return (
|
||||
<ThemedCard theme={theme}>
|
||||
<CardBody>
|
||||
<Grid templateColumns="repeat(12, 1fr)" gap={6}>
|
||||
{/* 左侧:股票名称和涨跌 */}
|
||||
<GridItem colSpan={{ base: 12, md: 4 }}>
|
||||
<VStack align="start" spacing={2}>
|
||||
<HStack>
|
||||
<Heading size="xl" color={theme.textSecondary}>
|
||||
{summary.stock_name}
|
||||
</Heading>
|
||||
<Badge colorScheme="blue" fontSize="lg">
|
||||
{summary.stock_code}
|
||||
</Badge>
|
||||
</HStack>
|
||||
{latest_trade && (
|
||||
<HStack spacing={4}>
|
||||
<Stat>
|
||||
<StatNumber fontSize="4xl" color={theme.textPrimary}>
|
||||
{latest_trade.close}
|
||||
</StatNumber>
|
||||
<StatHelpText fontSize="lg">
|
||||
<StatArrow
|
||||
type={latest_trade.change_percent >= 0 ? 'increase' : 'decrease'}
|
||||
color={latest_trade.change_percent >= 0 ? theme.success : theme.danger}
|
||||
/>
|
||||
{Math.abs(latest_trade.change_percent).toFixed(2)}%
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</GridItem>
|
||||
|
||||
{/* 右侧:详细指标 */}
|
||||
<GridItem colSpan={{ base: 12, md: 8 }}>
|
||||
{/* 交易指标 */}
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
|
||||
{latest_trade && (
|
||||
<>
|
||||
<Stat>
|
||||
<StatLabel color={theme.textMuted}>成交量</StatLabel>
|
||||
<StatNumber color={theme.textSecondary}>
|
||||
{formatNumber(latest_trade.volume, 0)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel color={theme.textMuted}>成交额</StatLabel>
|
||||
<StatNumber color={theme.textSecondary}>
|
||||
{formatNumber(latest_trade.amount)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel color={theme.textMuted}>换手率</StatLabel>
|
||||
<StatNumber color={theme.textSecondary}>
|
||||
{formatPercent(latest_trade.turnover_rate)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel color={theme.textMuted}>市盈率</StatLabel>
|
||||
<StatNumber color={theme.textSecondary}>
|
||||
{latest_trade.pe_ratio || '-'}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 融资融券和质押指标 */}
|
||||
{latest_funding && (
|
||||
<SimpleGrid columns={{ base: 2, md: 3 }} spacing={4} mt={4}>
|
||||
<Stat>
|
||||
<StatLabel color={theme.textMuted}>融资余额</StatLabel>
|
||||
<StatNumber color={theme.success} fontSize="lg">
|
||||
{formatNumber(latest_funding.financing_balance)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel color={theme.textMuted}>融券余额</StatLabel>
|
||||
<StatNumber color={theme.danger} fontSize="lg">
|
||||
{formatNumber(latest_funding.securities_balance)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
{latest_pledge && (
|
||||
<Stat>
|
||||
<StatLabel color={theme.textMuted}>质押比例</StatLabel>
|
||||
<StatNumber color={theme.warning} fontSize="lg">
|
||||
{formatPercent(latest_pledge.pledge_ratio)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</CardBody>
|
||||
</ThemedCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockSummaryCard;
|
||||
@@ -0,0 +1,32 @@
|
||||
// src/views/Company/components/MarketDataView/components/ThemedCard.tsx
|
||||
// 主题化卡片组件
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@chakra-ui/react';
|
||||
import type { ThemedCardProps } from '../types';
|
||||
|
||||
/**
|
||||
* 主题化卡片组件
|
||||
* 提供统一的卡片样式和悬停效果
|
||||
*/
|
||||
const ThemedCard: React.FC<ThemedCardProps> = ({ children, theme, ...props }) => {
|
||||
return (
|
||||
<Card
|
||||
bg={theme.bgCard}
|
||||
border="1px solid"
|
||||
borderColor={theme.border}
|
||||
borderRadius="xl"
|
||||
boxShadow="lg"
|
||||
transition="all 0.3s ease"
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 'xl',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemedCard;
|
||||
@@ -0,0 +1,7 @@
|
||||
// src/views/Company/components/MarketDataView/components/index.ts
|
||||
// 组件导出索引
|
||||
|
||||
export { default as ThemedCard } from './ThemedCard';
|
||||
export { default as MarkdownRenderer } from './MarkdownRenderer';
|
||||
export { default as StockSummaryCard } from './StockSummaryCard';
|
||||
export { default as AnalysisModal, AnalysisContent } from './AnalysisModal';
|
||||
49
src/views/Company/components/MarketDataView/constants.ts
Normal file
49
src/views/Company/components/MarketDataView/constants.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// src/views/Company/components/MarketDataView/constants.ts
|
||||
// MarketDataView 常量配置
|
||||
|
||||
import type { Theme } from './types';
|
||||
|
||||
/**
|
||||
* 主题配置
|
||||
*/
|
||||
export const themes: Record<'light', Theme> = {
|
||||
light: {
|
||||
// 日间模式 - 白+蓝
|
||||
primary: '#2B6CB0',
|
||||
primaryDark: '#1E4E8C',
|
||||
secondary: '#FFFFFF',
|
||||
secondaryDark: '#F7FAFC',
|
||||
success: '#FF4444', // 涨 - 红色
|
||||
danger: '#00C851', // 跌 - 绿色
|
||||
warning: '#FF9800',
|
||||
info: '#00BCD4',
|
||||
bgMain: '#F7FAFC',
|
||||
bgCard: '#FFFFFF',
|
||||
bgDark: '#EDF2F7',
|
||||
textPrimary: '#2D3748',
|
||||
textSecondary: '#4A5568',
|
||||
textMuted: '#718096',
|
||||
border: '#CBD5E0',
|
||||
chartBg: '#FFFFFF',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认股票代码
|
||||
*/
|
||||
export const DEFAULT_STOCK_CODE = '600000';
|
||||
|
||||
/**
|
||||
* 默认时间周期(天)
|
||||
*/
|
||||
export const DEFAULT_PERIOD = 60;
|
||||
|
||||
/**
|
||||
* 时间周期选项
|
||||
*/
|
||||
export const PERIOD_OPTIONS = [
|
||||
{ value: 30, label: '30天' },
|
||||
{ value: 60, label: '60天' },
|
||||
{ value: 120, label: '120天' },
|
||||
{ value: 250, label: '250天' },
|
||||
];
|
||||
@@ -0,0 +1,193 @@
|
||||
// src/views/Company/components/MarketDataView/hooks/useMarketData.ts
|
||||
// MarketDataView 数据获取 Hook
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { logger } from '@utils/logger';
|
||||
import { marketService } from '../services/marketService';
|
||||
import { DEFAULT_PERIOD } from '../constants';
|
||||
import type {
|
||||
MarketSummary,
|
||||
TradeDayData,
|
||||
FundingDayData,
|
||||
BigDealData,
|
||||
UnusualData,
|
||||
PledgeData,
|
||||
RiseAnalysis,
|
||||
MinuteData,
|
||||
UseMarketDataReturn,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* 市场数据获取 Hook
|
||||
* @param stockCode 股票代码
|
||||
* @param period 时间周期(天数)
|
||||
*/
|
||||
export const useMarketData = (
|
||||
stockCode: string,
|
||||
period: number = DEFAULT_PERIOD
|
||||
): UseMarketDataReturn => {
|
||||
// 主数据状态
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [summary, setSummary] = useState<MarketSummary | null>(null);
|
||||
const [tradeData, setTradeData] = useState<TradeDayData[]>([]);
|
||||
const [fundingData, setFundingData] = useState<FundingDayData[]>([]);
|
||||
const [bigDealData, setBigDealData] = useState<BigDealData>({ data: [], daily_stats: [] });
|
||||
const [unusualData, setUnusualData] = useState<UnusualData>({ data: [], grouped_data: [] });
|
||||
const [pledgeData, setPledgeData] = useState<PledgeData[]>([]);
|
||||
const [analysisMap, setAnalysisMap] = useState<Record<number, RiseAnalysis>>({});
|
||||
|
||||
// 分钟数据状态
|
||||
const [minuteData, setMinuteData] = useState<MinuteData | null>(null);
|
||||
const [minuteLoading, setMinuteLoading] = useState(false);
|
||||
|
||||
/**
|
||||
* 加载所有市场数据
|
||||
*/
|
||||
const loadMarketData = useCallback(async () => {
|
||||
if (!stockCode) return;
|
||||
|
||||
logger.debug('useMarketData', '开始加载市场数据', { stockCode, period });
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const [
|
||||
summaryRes,
|
||||
tradeRes,
|
||||
fundingRes,
|
||||
bigDealRes,
|
||||
unusualRes,
|
||||
pledgeRes,
|
||||
riseAnalysisRes,
|
||||
] = await Promise.all([
|
||||
marketService.getMarketSummary(stockCode),
|
||||
marketService.getTradeData(stockCode, period),
|
||||
marketService.getFundingData(stockCode, 30),
|
||||
marketService.getBigDealData(stockCode, 30),
|
||||
marketService.getUnusualData(stockCode, 30),
|
||||
marketService.getPledgeData(stockCode),
|
||||
marketService.getRiseAnalysis(stockCode),
|
||||
]);
|
||||
|
||||
// 设置概览数据
|
||||
if (summaryRes.success) {
|
||||
setSummary(summaryRes.data);
|
||||
}
|
||||
|
||||
// 设置交易数据
|
||||
if (tradeRes.success) {
|
||||
setTradeData(tradeRes.data);
|
||||
}
|
||||
|
||||
// 设置融资融券数据
|
||||
if (fundingRes.success) {
|
||||
setFundingData(fundingRes.data);
|
||||
}
|
||||
|
||||
// 设置大宗交易数据(包含 daily_stats)
|
||||
if (bigDealRes.success) {
|
||||
setBigDealData(bigDealRes);
|
||||
}
|
||||
|
||||
// 设置龙虎榜数据(包含 grouped_data)
|
||||
if (unusualRes.success) {
|
||||
setUnusualData(unusualRes);
|
||||
}
|
||||
|
||||
// 设置股权质押数据
|
||||
if (pledgeRes.success) {
|
||||
setPledgeData(pledgeRes.data);
|
||||
}
|
||||
|
||||
// 设置涨幅分析数据并创建映射
|
||||
if (riseAnalysisRes.success) {
|
||||
const tempAnalysisMap: Record<number, RiseAnalysis> = {};
|
||||
|
||||
if (tradeRes.success && tradeRes.data && riseAnalysisRes.data) {
|
||||
riseAnalysisRes.data.forEach((analysis) => {
|
||||
const dateIndex = tradeRes.data.findIndex(
|
||||
(item) => item.date.substring(0, 10) === analysis.trade_date
|
||||
);
|
||||
if (dateIndex !== -1) {
|
||||
tempAnalysisMap[dateIndex] = analysis;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setAnalysisMap(tempAnalysisMap);
|
||||
}
|
||||
|
||||
logger.info('useMarketData', '市场数据加载成功', { stockCode });
|
||||
} catch (error) {
|
||||
logger.error('useMarketData', 'loadMarketData', error, { stockCode, period });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode, period]);
|
||||
|
||||
/**
|
||||
* 加载分钟K线数据
|
||||
*/
|
||||
const loadMinuteData = useCallback(async () => {
|
||||
if (!stockCode) return;
|
||||
|
||||
logger.debug('useMarketData', '开始加载分钟频数据', { stockCode });
|
||||
setMinuteLoading(true);
|
||||
|
||||
try {
|
||||
const data = await marketService.getMinuteData(stockCode);
|
||||
setMinuteData(data);
|
||||
|
||||
if (data.data && data.data.length > 0) {
|
||||
logger.info('useMarketData', '分钟频数据加载成功', {
|
||||
stockCode,
|
||||
dataPoints: data.data.length,
|
||||
});
|
||||
} else {
|
||||
logger.warn('useMarketData', '分钟频数据为空', { stockCode });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('useMarketData', 'loadMinuteData', error, { stockCode });
|
||||
setMinuteData({
|
||||
data: [],
|
||||
code: stockCode,
|
||||
name: '',
|
||||
trade_date: '',
|
||||
type: 'minute',
|
||||
});
|
||||
} finally {
|
||||
setMinuteLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
|
||||
/**
|
||||
* 刷新所有数据
|
||||
*/
|
||||
const refetch = useCallback(async () => {
|
||||
await Promise.all([loadMarketData(), loadMinuteData()]);
|
||||
}, [loadMarketData, loadMinuteData]);
|
||||
|
||||
// 监听股票代码和周期变化,自动加载数据
|
||||
useEffect(() => {
|
||||
if (stockCode) {
|
||||
loadMarketData();
|
||||
loadMinuteData();
|
||||
}
|
||||
}, [stockCode, period, loadMarketData, loadMinuteData]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
summary,
|
||||
tradeData,
|
||||
fundingData,
|
||||
bigDealData,
|
||||
unusualData,
|
||||
pledgeData,
|
||||
minuteData,
|
||||
minuteLoading,
|
||||
analysisMap,
|
||||
refetch,
|
||||
loadMinuteData,
|
||||
};
|
||||
};
|
||||
|
||||
export default useMarketData;
|
||||
1049
src/views/Company/components/MarketDataView/index.tsx
Normal file
1049
src/views/Company/components/MarketDataView/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
||||
// src/views/Company/components/MarketDataView/services/marketService.ts
|
||||
// MarketDataView API 服务层
|
||||
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
import { logger } from '@utils/logger';
|
||||
import type {
|
||||
MarketSummary,
|
||||
TradeDayData,
|
||||
FundingDayData,
|
||||
BigDealData,
|
||||
UnusualData,
|
||||
PledgeData,
|
||||
RiseAnalysis,
|
||||
MinuteData,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* API 响应包装类型
|
||||
*/
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 基础 URL
|
||||
*/
|
||||
const getBaseUrl = (): string => getApiBase();
|
||||
|
||||
/**
|
||||
* 通用 API 请求函数
|
||||
*/
|
||||
const apiRequest = async <T>(url: string): Promise<ApiResponse<T>> => {
|
||||
try {
|
||||
const response = await fetch(`${getBaseUrl()}${url}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
logger.error('marketService', 'apiRequest', error, { url });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 市场数据服务
|
||||
*/
|
||||
export const marketService = {
|
||||
/**
|
||||
* 获取市场概览数据
|
||||
* @param stockCode 股票代码
|
||||
*/
|
||||
async getMarketSummary(stockCode: string): Promise<ApiResponse<MarketSummary>> {
|
||||
return apiRequest<MarketSummary>(`/api/market/summary/${stockCode}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取交易日数据
|
||||
* @param stockCode 股票代码
|
||||
* @param days 天数,默认 60 天
|
||||
*/
|
||||
async getTradeData(stockCode: string, days: number = 60): Promise<ApiResponse<TradeDayData[]>> {
|
||||
return apiRequest<TradeDayData[]>(`/api/market/trade/${stockCode}?days=${days}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取融资融券数据
|
||||
* @param stockCode 股票代码
|
||||
* @param days 天数,默认 30 天
|
||||
*/
|
||||
async getFundingData(stockCode: string, days: number = 30): Promise<ApiResponse<FundingDayData[]>> {
|
||||
return apiRequest<FundingDayData[]>(`/api/market/funding/${stockCode}?days=${days}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取大宗交易数据
|
||||
* @param stockCode 股票代码
|
||||
* @param days 天数,默认 30 天
|
||||
*/
|
||||
async getBigDealData(stockCode: string, days: number = 30): Promise<BigDealData> {
|
||||
const response = await fetch(`${getBaseUrl()}/api/market/bigdeal/${stockCode}?days=${days}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取龙虎榜数据
|
||||
* @param stockCode 股票代码
|
||||
* @param days 天数,默认 30 天
|
||||
*/
|
||||
async getUnusualData(stockCode: string, days: number = 30): Promise<UnusualData> {
|
||||
const response = await fetch(`${getBaseUrl()}/api/market/unusual/${stockCode}?days=${days}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取股权质押数据
|
||||
* @param stockCode 股票代码
|
||||
*/
|
||||
async getPledgeData(stockCode: string): Promise<ApiResponse<PledgeData[]>> {
|
||||
return apiRequest<PledgeData[]>(`/api/market/pledge/${stockCode}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取涨幅分析数据
|
||||
* @param stockCode 股票代码
|
||||
* @param startDate 开始日期(可选)
|
||||
* @param endDate 结束日期(可选)
|
||||
*/
|
||||
async getRiseAnalysis(
|
||||
stockCode: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<ApiResponse<RiseAnalysis[]>> {
|
||||
let url = `/api/market/rise-analysis/${stockCode}`;
|
||||
if (startDate && endDate) {
|
||||
url += `?start_date=${startDate}&end_date=${endDate}`;
|
||||
}
|
||||
return apiRequest<RiseAnalysis[]>(url);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取分钟K线数据
|
||||
* @param stockCode 股票代码
|
||||
*/
|
||||
async getMinuteData(stockCode: string): Promise<MinuteData> {
|
||||
try {
|
||||
const response = await fetch(`${getBaseUrl()}/api/stock/${stockCode}/latest-minute`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch minute data');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.data && Array.isArray(data.data)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// 返回空数据结构
|
||||
return {
|
||||
data: [],
|
||||
code: stockCode,
|
||||
name: '',
|
||||
trade_date: '',
|
||||
type: 'minute',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('marketService', 'getMinuteData', error, { stockCode });
|
||||
// 返回空数据结构
|
||||
return {
|
||||
data: [],
|
||||
code: stockCode,
|
||||
name: '',
|
||||
trade_date: '',
|
||||
type: 'minute',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default marketService;
|
||||
383
src/views/Company/components/MarketDataView/types.ts
Normal file
383
src/views/Company/components/MarketDataView/types.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
// src/views/Company/components/MarketDataView/types.ts
|
||||
// MarketDataView 组件类型定义
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* 主题配置类型
|
||||
*/
|
||||
export interface Theme {
|
||||
primary: string;
|
||||
primaryDark: string;
|
||||
secondary: string;
|
||||
secondaryDark: string;
|
||||
success: string; // 涨色 - 红色
|
||||
danger: string; // 跌色 - 绿色
|
||||
warning: string;
|
||||
info: string;
|
||||
bgMain: string;
|
||||
bgCard: string;
|
||||
bgDark: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
textMuted: string;
|
||||
border: string;
|
||||
chartBg: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 交易日数据
|
||||
*/
|
||||
export interface TradeDayData {
|
||||
date: string;
|
||||
open: number;
|
||||
close: number;
|
||||
high: number;
|
||||
low: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
change_percent: number;
|
||||
turnover_rate?: number;
|
||||
pe_ratio?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分钟K线数据点
|
||||
*/
|
||||
export interface MinuteDataPoint {
|
||||
time: string;
|
||||
open: number;
|
||||
close: number;
|
||||
high: number;
|
||||
low: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分钟K线数据
|
||||
*/
|
||||
export interface MinuteData {
|
||||
data: MinuteDataPoint[];
|
||||
code: string;
|
||||
name: string;
|
||||
trade_date: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 融资数据
|
||||
*/
|
||||
export interface FinancingInfo {
|
||||
balance: number;
|
||||
buy: number;
|
||||
repay: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 融券数据
|
||||
*/
|
||||
export interface SecuritiesInfo {
|
||||
balance: number;
|
||||
balance_amount: number;
|
||||
sell: number;
|
||||
repay: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 融资融券日数据
|
||||
*/
|
||||
export interface FundingDayData {
|
||||
date: string;
|
||||
financing: FinancingInfo;
|
||||
securities: SecuritiesInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 大宗交易明细
|
||||
*/
|
||||
export interface BigDealItem {
|
||||
buyer_dept?: string;
|
||||
seller_dept?: string;
|
||||
price?: number;
|
||||
volume?: number;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 大宗交易日统计
|
||||
*/
|
||||
export interface BigDealDayStats {
|
||||
date: string;
|
||||
count: number;
|
||||
total_volume: number;
|
||||
total_amount: number;
|
||||
avg_price?: number;
|
||||
deals?: BigDealItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 大宗交易数据
|
||||
*/
|
||||
export interface BigDealData {
|
||||
success?: boolean;
|
||||
data: BigDealItem[];
|
||||
daily_stats: BigDealDayStats[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 龙虎榜买卖方
|
||||
*/
|
||||
export interface UnusualTrader {
|
||||
dept_name: string;
|
||||
buy_amount?: number;
|
||||
sell_amount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 龙虎榜日数据
|
||||
*/
|
||||
export interface UnusualDayData {
|
||||
date: string;
|
||||
total_buy: number;
|
||||
total_sell: number;
|
||||
net_amount: number;
|
||||
buyers?: UnusualTrader[];
|
||||
sellers?: UnusualTrader[];
|
||||
info_types?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 龙虎榜数据
|
||||
*/
|
||||
export interface UnusualData {
|
||||
success?: boolean;
|
||||
data: unknown[];
|
||||
grouped_data: UnusualDayData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 股权质押数据
|
||||
*/
|
||||
export interface PledgeData {
|
||||
end_date: string;
|
||||
unrestricted_pledge: number;
|
||||
restricted_pledge: number;
|
||||
total_pledge: number;
|
||||
total_shares: number;
|
||||
pledge_ratio: number;
|
||||
pledge_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 最新交易数据
|
||||
*/
|
||||
export interface LatestTrade {
|
||||
close: number;
|
||||
change_percent: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
turnover_rate: number;
|
||||
pe_ratio?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 最新融资融券数据
|
||||
*/
|
||||
export interface LatestFunding {
|
||||
financing_balance: number;
|
||||
securities_balance: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 最新质押数据
|
||||
*/
|
||||
export interface LatestPledge {
|
||||
pledge_ratio: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 市场概览数据
|
||||
*/
|
||||
export interface MarketSummary {
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
latest_trade?: LatestTrade;
|
||||
latest_funding?: LatestFunding;
|
||||
latest_pledge?: LatestPledge;
|
||||
}
|
||||
|
||||
/**
|
||||
* 涨幅分析研报
|
||||
*/
|
||||
export interface VerificationReport {
|
||||
publisher?: string;
|
||||
match_score?: string;
|
||||
match_ratio?: number;
|
||||
declare_date?: string;
|
||||
report_title?: string;
|
||||
author?: string;
|
||||
verification_item?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 涨幅分析数据
|
||||
*/
|
||||
export interface RiseAnalysis {
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
trade_date: string;
|
||||
rise_rate: number;
|
||||
close_price: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
main_business?: string;
|
||||
rise_reason_brief?: string;
|
||||
rise_reason_detail?: string;
|
||||
announcements?: string;
|
||||
verification_reports?: VerificationReport[];
|
||||
update_time?: string;
|
||||
create_time?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MarketDataView 组件 Props
|
||||
*/
|
||||
export interface MarketDataViewProps {
|
||||
stockCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ThemedCard 组件 Props
|
||||
*/
|
||||
export interface ThemedCardProps {
|
||||
children: ReactNode;
|
||||
theme: Theme;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* MarkdownRenderer 组件 Props
|
||||
*/
|
||||
export interface MarkdownRendererProps {
|
||||
children: string;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* StockSummaryCard 组件 Props
|
||||
*/
|
||||
export interface StockSummaryCardProps {
|
||||
summary: MarketSummary;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* TradeDataTab 组件 Props
|
||||
*/
|
||||
export interface TradeDataTabProps {
|
||||
theme: Theme;
|
||||
tradeData: TradeDayData[];
|
||||
minuteData: MinuteData | null;
|
||||
minuteLoading: boolean;
|
||||
analysisMap: Record<number, RiseAnalysis>;
|
||||
onLoadMinuteData: () => void;
|
||||
onAnalysisClick: (analysis: RiseAnalysis) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* KLineChart 组件 Props
|
||||
*/
|
||||
export interface KLineChartProps {
|
||||
theme: Theme;
|
||||
tradeData: TradeDayData[];
|
||||
analysisMap: Record<number, RiseAnalysis>;
|
||||
onAnalysisClick: (analysis: RiseAnalysis) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* MinuteKLineChart 组件 Props
|
||||
*/
|
||||
export interface MinuteKLineChartProps {
|
||||
theme: Theme;
|
||||
minuteData: MinuteData | null;
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* TradeTable 组件 Props
|
||||
*/
|
||||
export interface TradeTableProps {
|
||||
theme: Theme;
|
||||
tradeData: TradeDayData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* FundingTab 组件 Props
|
||||
*/
|
||||
export interface FundingTabProps {
|
||||
theme: Theme;
|
||||
fundingData: FundingDayData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* BigDealTab 组件 Props
|
||||
*/
|
||||
export interface BigDealTabProps {
|
||||
theme: Theme;
|
||||
bigDealData: BigDealData;
|
||||
}
|
||||
|
||||
/**
|
||||
* UnusualTab 组件 Props
|
||||
*/
|
||||
export interface UnusualTabProps {
|
||||
theme: Theme;
|
||||
unusualData: UnusualData;
|
||||
}
|
||||
|
||||
/**
|
||||
* PledgeTab 组件 Props
|
||||
*/
|
||||
export interface PledgeTabProps {
|
||||
theme: Theme;
|
||||
pledgeData: PledgeData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* AnalysisModal 组件 Props
|
||||
*/
|
||||
export interface AnalysisModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
content: ReactNode;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* AnalysisModalContent 组件 Props
|
||||
*/
|
||||
export interface AnalysisModalContentProps {
|
||||
analysis: RiseAnalysis;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* useMarketData Hook 返回值
|
||||
*/
|
||||
export interface UseMarketDataReturn {
|
||||
loading: boolean;
|
||||
summary: MarketSummary | null;
|
||||
tradeData: TradeDayData[];
|
||||
fundingData: FundingDayData[];
|
||||
bigDealData: BigDealData;
|
||||
unusualData: UnusualData;
|
||||
pledgeData: PledgeData[];
|
||||
minuteData: MinuteData | null;
|
||||
minuteLoading: boolean;
|
||||
analysisMap: Record<number, RiseAnalysis>;
|
||||
refetch: () => Promise<void>;
|
||||
loadMinuteData: () => Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
// src/views/Company/components/MarketDataView/utils/chartOptions.ts
|
||||
// MarketDataView ECharts 图表配置生成器
|
||||
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import type {
|
||||
Theme,
|
||||
TradeDayData,
|
||||
MinuteData,
|
||||
FundingDayData,
|
||||
PledgeData,
|
||||
RiseAnalysis,
|
||||
} from '../types';
|
||||
import { formatNumber } from './formatUtils';
|
||||
|
||||
/**
|
||||
* 计算移动平均线
|
||||
* @param data 收盘价数组
|
||||
* @param period 周期
|
||||
*/
|
||||
export const calculateMA = (data: number[], period: number): (number | null)[] => {
|
||||
const result: (number | null)[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (i < period - 1) {
|
||||
result.push(null);
|
||||
continue;
|
||||
}
|
||||
let sum = 0;
|
||||
for (let j = 0; j < period; j++) {
|
||||
sum += data[i - j];
|
||||
}
|
||||
result.push(sum / period);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成日K线图配置
|
||||
*/
|
||||
export const getKLineOption = (
|
||||
theme: Theme,
|
||||
tradeData: TradeDayData[],
|
||||
analysisMap: Record<number, RiseAnalysis>
|
||||
): EChartsOption => {
|
||||
if (!tradeData || tradeData.length === 0) return {};
|
||||
|
||||
const dates = tradeData.map((item) => item.date.substring(5, 10));
|
||||
const kData = tradeData.map((item) => [item.open, item.close, item.low, item.high]);
|
||||
const volumes = tradeData.map((item) => item.volume);
|
||||
const closePrices = tradeData.map((item) => item.close);
|
||||
const ma5 = calculateMA(closePrices, 5);
|
||||
const ma10 = calculateMA(closePrices, 10);
|
||||
const ma20 = calculateMA(closePrices, 20);
|
||||
|
||||
// 创建涨幅分析标记点
|
||||
const scatterData: [number, number][] = [];
|
||||
Object.keys(analysisMap).forEach((dateIndex) => {
|
||||
const idx = parseInt(dateIndex);
|
||||
if (tradeData[idx]) {
|
||||
const value = tradeData[idx].high * 1.02;
|
||||
scatterData.push([idx, value]);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
backgroundColor: theme.chartBg,
|
||||
animation: true,
|
||||
legend: {
|
||||
data: ['K线', 'MA5', 'MA10', 'MA20'],
|
||||
top: 10,
|
||||
textStyle: {
|
||||
color: theme.textPrimary,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
lineStyle: {
|
||||
color: theme.primary,
|
||||
width: 1,
|
||||
opacity: 0.8,
|
||||
},
|
||||
},
|
||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||
borderColor: theme.primary,
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: theme.textPrimary,
|
||||
},
|
||||
},
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
data: dates,
|
||||
boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||||
axisLabel: { color: theme.textMuted },
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
gridIndex: 1,
|
||||
data: dates,
|
||||
boundaryGap: false,
|
||||
axisLine: { onZero: false, lineStyle: { color: theme.textMuted } },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
scale: true,
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: theme.border,
|
||||
},
|
||||
},
|
||||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||||
axisLabel: { color: theme.textMuted },
|
||||
},
|
||||
{
|
||||
scale: true,
|
||||
gridIndex: 1,
|
||||
splitNumber: 2,
|
||||
axisLabel: { show: false },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
grid: [
|
||||
{
|
||||
left: '10%',
|
||||
right: '10%',
|
||||
height: '50%',
|
||||
},
|
||||
{
|
||||
left: '10%',
|
||||
right: '10%',
|
||||
top: '65%',
|
||||
height: '20%',
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'K线',
|
||||
type: 'candlestick',
|
||||
data: kData,
|
||||
itemStyle: {
|
||||
color: theme.success,
|
||||
color0: theme.danger,
|
||||
borderColor: theme.success,
|
||||
borderColor0: theme.danger,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'MA5',
|
||||
type: 'line',
|
||||
data: ma5,
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.primary,
|
||||
width: 1,
|
||||
},
|
||||
itemStyle: {
|
||||
color: theme.primary,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'MA10',
|
||||
type: 'line',
|
||||
data: ma10,
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.info,
|
||||
width: 1,
|
||||
},
|
||||
itemStyle: {
|
||||
color: theme.info,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'MA20',
|
||||
type: 'line',
|
||||
data: ma20,
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.warning,
|
||||
width: 1,
|
||||
},
|
||||
itemStyle: {
|
||||
color: theme.warning,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '涨幅分析',
|
||||
type: 'scatter',
|
||||
data: scatterData,
|
||||
symbolSize: 30,
|
||||
symbol: 'pin',
|
||||
itemStyle: {
|
||||
color: '#FFD700',
|
||||
shadowBlur: 10,
|
||||
shadowColor: 'rgba(255, 215, 0, 0.5)',
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '★',
|
||||
fontSize: 20,
|
||||
position: 'inside',
|
||||
color: '#FF6B6B',
|
||||
},
|
||||
emphasis: {
|
||||
scale: 1.5,
|
||||
itemStyle: {
|
||||
color: '#FFA500',
|
||||
},
|
||||
},
|
||||
z: 100,
|
||||
},
|
||||
{
|
||||
name: '成交量',
|
||||
type: 'bar',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
data: volumes,
|
||||
itemStyle: {
|
||||
color: (params: { dataIndex: number }) => {
|
||||
const item = tradeData[params.dataIndex];
|
||||
return item.change_percent >= 0
|
||||
? 'rgba(255, 68, 68, 0.6)'
|
||||
: 'rgba(0, 200, 81, 0.6)';
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成分钟K线图配置
|
||||
*/
|
||||
export const getMinuteKLineOption = (theme: Theme, minuteData: MinuteData | null): EChartsOption => {
|
||||
if (!minuteData || !minuteData.data || minuteData.data.length === 0) return {};
|
||||
|
||||
const times = minuteData.data.map((item) => item.time);
|
||||
const kData = minuteData.data.map((item) => [item.open, item.close, item.low, item.high]);
|
||||
const volumes = minuteData.data.map((item) => item.volume);
|
||||
const closePrices = minuteData.data.map((item) => item.close);
|
||||
const avgPrice = calculateMA(closePrices, 5);
|
||||
|
||||
const openPrice = minuteData.data.length > 0 ? minuteData.data[0].open : 0;
|
||||
|
||||
return {
|
||||
backgroundColor: theme.chartBg,
|
||||
title: {
|
||||
text: `${minuteData.name} 分钟K线 (${minuteData.trade_date})`,
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
color: theme.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtextStyle: {
|
||||
color: theme.textMuted,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross' },
|
||||
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||
borderColor: theme.primary,
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: theme.textPrimary,
|
||||
fontSize: 12,
|
||||
},
|
||||
formatter: (params: unknown) => {
|
||||
const paramsArr = params as { name: string; marker: string; seriesName: string; data: number[] | number; value: number }[];
|
||||
let result = paramsArr[0].name + '<br/>';
|
||||
paramsArr.forEach((param) => {
|
||||
if (param.seriesName === '分钟K线') {
|
||||
const [open, close, , high] = param.data as number[];
|
||||
const low = (param.data as number[])[2];
|
||||
const changePercent =
|
||||
openPrice > 0 ? (((close - openPrice) / openPrice) * 100).toFixed(2) : '0.00';
|
||||
result += `${param.marker} ${param.seriesName}<br/>`;
|
||||
result += `开盘: <span style="font-weight: bold">${open.toFixed(2)}</span><br/>`;
|
||||
result += `收盘: <span style="font-weight: bold; color: ${close >= open ? theme.success : theme.danger}">${close.toFixed(2)}</span><br/>`;
|
||||
result += `最高: <span style="font-weight: bold">${high.toFixed(2)}</span><br/>`;
|
||||
result += `最低: <span style="font-weight: bold">${low.toFixed(2)}</span><br/>`;
|
||||
result += `涨跌: <span style="font-weight: bold; color: ${close >= openPrice ? theme.success : theme.danger}">${changePercent}%</span><br/>`;
|
||||
} else if (param.seriesName === '均价线') {
|
||||
result += `${param.marker} ${param.seriesName}: <span style="font-weight: bold">${(param.value as number).toFixed(2)}</span><br/>`;
|
||||
} else if (param.seriesName === '成交量') {
|
||||
result += `${param.marker} ${param.seriesName}: <span style="font-weight: bold">${formatNumber(param.value as number, 0)}</span><br/>`;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: ['分钟K线', '均价线', '成交量'],
|
||||
top: 35,
|
||||
textStyle: {
|
||||
color: theme.textPrimary,
|
||||
fontSize: 12,
|
||||
},
|
||||
itemWidth: 25,
|
||||
itemHeight: 14,
|
||||
},
|
||||
grid: [
|
||||
{
|
||||
left: '8%',
|
||||
right: '8%',
|
||||
top: '20%',
|
||||
height: '60%',
|
||||
},
|
||||
{
|
||||
left: '8%',
|
||||
right: '8%',
|
||||
top: '83%',
|
||||
height: '12%',
|
||||
},
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
data: times,
|
||||
boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||||
axisLabel: {
|
||||
color: theme.textMuted,
|
||||
fontSize: 10,
|
||||
interval: 'auto',
|
||||
},
|
||||
splitLine: { show: false },
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
gridIndex: 1,
|
||||
data: times,
|
||||
boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||||
axisLabel: {
|
||||
color: theme.textMuted,
|
||||
fontSize: 10,
|
||||
},
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
scale: true,
|
||||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||||
axisLabel: { color: theme.textMuted, fontSize: 10 },
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: theme.border,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
gridIndex: 1,
|
||||
scale: true,
|
||||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||||
axisLabel: { color: theme.textMuted, fontSize: 10 },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
dataZoom: [
|
||||
{
|
||||
type: 'inside',
|
||||
xAxisIndex: [0, 1],
|
||||
start: 70,
|
||||
end: 100,
|
||||
minValueSpan: 20,
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
xAxisIndex: [0, 1],
|
||||
type: 'slider',
|
||||
top: '95%',
|
||||
start: 70,
|
||||
end: 100,
|
||||
height: 20,
|
||||
handleSize: '100%',
|
||||
handleStyle: {
|
||||
color: theme.primary,
|
||||
},
|
||||
textStyle: {
|
||||
color: theme.textMuted,
|
||||
},
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '分钟K线',
|
||||
type: 'candlestick',
|
||||
data: kData,
|
||||
itemStyle: {
|
||||
color: theme.success,
|
||||
color0: theme.danger,
|
||||
borderColor: theme.success,
|
||||
borderColor0: theme.danger,
|
||||
borderWidth: 1,
|
||||
},
|
||||
barWidth: '60%',
|
||||
},
|
||||
{
|
||||
name: '均价线',
|
||||
type: 'line',
|
||||
data: avgPrice,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
color: theme.info,
|
||||
width: 2,
|
||||
opacity: 0.8,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '成交量',
|
||||
type: 'bar',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
data: volumes,
|
||||
barWidth: '50%',
|
||||
itemStyle: {
|
||||
color: (params: { dataIndex: number }) => {
|
||||
const item = minuteData.data[params.dataIndex];
|
||||
return item.close >= item.open
|
||||
? 'rgba(255, 68, 68, 0.6)'
|
||||
: 'rgba(0, 200, 81, 0.6)';
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成融资融券图表配置
|
||||
*/
|
||||
export const getFundingOption = (theme: Theme, fundingData: FundingDayData[]): EChartsOption => {
|
||||
if (!fundingData || fundingData.length === 0) return {};
|
||||
|
||||
const dates = fundingData.map((item) => item.date.substring(5, 10));
|
||||
const financing = fundingData.map((item) => item.financing.balance / 100000000);
|
||||
const securities = fundingData.map((item) => item.securities.balance_amount / 100000000);
|
||||
|
||||
return {
|
||||
backgroundColor: theme.chartBg,
|
||||
title: {
|
||||
text: '融资融券余额走势',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
color: theme.textPrimary,
|
||||
fontSize: 16,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||
borderColor: theme.primary,
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: theme.textPrimary,
|
||||
},
|
||||
formatter: (params: unknown) => {
|
||||
const paramsArr = params as { name: string; marker: string; seriesName: string; value: number }[];
|
||||
let result = paramsArr[0].name + '<br/>';
|
||||
paramsArr.forEach((param) => {
|
||||
result += `${param.marker} ${param.seriesName}: ${param.value.toFixed(2)}亿<br/>`;
|
||||
});
|
||||
return result;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: ['融资余额', '融券余额'],
|
||||
bottom: 10,
|
||||
textStyle: {
|
||||
color: theme.textPrimary,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '15%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: dates,
|
||||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||||
axisLabel: { color: theme.textMuted },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '金额(亿)',
|
||||
nameTextStyle: { color: theme.textMuted },
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: theme.border,
|
||||
},
|
||||
},
|
||||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||||
axisLabel: { color: theme.textMuted },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '融资余额',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(255, 68, 68, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(255, 68, 68, 0.05)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
lineStyle: {
|
||||
color: theme.success,
|
||||
width: 2,
|
||||
},
|
||||
itemStyle: {
|
||||
color: theme.success,
|
||||
borderColor: theme.success,
|
||||
borderWidth: 2,
|
||||
},
|
||||
data: financing,
|
||||
},
|
||||
{
|
||||
name: '融券余额',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'diamond',
|
||||
symbolSize: 8,
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(0, 200, 81, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(0, 200, 81, 0.05)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
lineStyle: {
|
||||
color: theme.danger,
|
||||
width: 2,
|
||||
},
|
||||
itemStyle: {
|
||||
color: theme.danger,
|
||||
borderColor: theme.danger,
|
||||
borderWidth: 2,
|
||||
},
|
||||
data: securities,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成股权质押图表配置
|
||||
*/
|
||||
export const getPledgeOption = (theme: Theme, pledgeData: PledgeData[]): EChartsOption => {
|
||||
if (!pledgeData || pledgeData.length === 0) return {};
|
||||
|
||||
const dates = pledgeData.map((item) => item.end_date.substring(5, 10));
|
||||
const ratios = pledgeData.map((item) => item.pledge_ratio);
|
||||
const counts = pledgeData.map((item) => item.pledge_count);
|
||||
|
||||
return {
|
||||
backgroundColor: theme.chartBg,
|
||||
title: {
|
||||
text: '股权质押趋势',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
color: theme.textPrimary,
|
||||
fontSize: 16,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||
borderColor: theme.primary,
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: theme.textPrimary,
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: ['质押比例', '质押笔数'],
|
||||
bottom: 10,
|
||||
textStyle: {
|
||||
color: theme.textPrimary,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '15%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||||
axisLabel: { color: theme.textMuted },
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '质押比例(%)',
|
||||
nameTextStyle: { color: theme.textMuted },
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: theme.border,
|
||||
},
|
||||
},
|
||||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||||
axisLabel: { color: theme.textMuted },
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '质押笔数',
|
||||
nameTextStyle: { color: theme.textMuted },
|
||||
axisLine: { lineStyle: { color: theme.textMuted } },
|
||||
axisLabel: { color: theme.textMuted },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '质押比例',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
lineStyle: {
|
||||
color: theme.warning,
|
||||
width: 2,
|
||||
shadowBlur: 10,
|
||||
shadowColor: theme.warning,
|
||||
},
|
||||
itemStyle: {
|
||||
color: theme.warning,
|
||||
borderColor: theme.bgCard,
|
||||
borderWidth: 2,
|
||||
},
|
||||
data: ratios,
|
||||
},
|
||||
{
|
||||
name: '质押笔数',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
barWidth: '50%',
|
||||
itemStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: theme.primary },
|
||||
{ offset: 1, color: theme.primaryDark },
|
||||
],
|
||||
},
|
||||
borderRadius: [5, 5, 0, 0],
|
||||
},
|
||||
data: counts,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
calculateMA,
|
||||
getKLineOption,
|
||||
getMinuteKLineOption,
|
||||
getFundingOption,
|
||||
getPledgeOption,
|
||||
};
|
||||
175
src/views/Company/components/MarketDataView/utils/formatUtils.ts
Normal file
175
src/views/Company/components/MarketDataView/utils/formatUtils.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// src/views/Company/components/MarketDataView/utils/formatUtils.ts
|
||||
// MarketDataView 格式化工具函数
|
||||
|
||||
/**
|
||||
* 格式化数字(自动转换为万/亿)
|
||||
* @param value 数值
|
||||
* @param decimals 小数位数,默认 2
|
||||
* @returns 格式化后的字符串
|
||||
*/
|
||||
export const formatNumber = (value: number | null | undefined, decimals: number = 2): string => {
|
||||
if (value === null || value === undefined) return '-';
|
||||
|
||||
const num = typeof value === 'number' ? value : parseFloat(String(value));
|
||||
if (isNaN(num)) return '-';
|
||||
|
||||
if (Math.abs(num) >= 100000000) {
|
||||
return (num / 100000000).toFixed(decimals) + '亿';
|
||||
} else if (Math.abs(num) >= 10000) {
|
||||
return (num / 10000).toFixed(decimals) + '万';
|
||||
}
|
||||
return num.toFixed(decimals);
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化百分比
|
||||
* @param value 数值(已经是百分比形式,如 3.5 表示 3.5%)
|
||||
* @param decimals 小数位数,默认 2
|
||||
* @returns 格式化后的字符串
|
||||
*/
|
||||
export const formatPercent = (value: number | null | undefined, decimals: number = 2): string => {
|
||||
if (value === null || value === undefined) return '-';
|
||||
|
||||
const num = typeof value === 'number' ? value : parseFloat(String(value));
|
||||
if (isNaN(num)) return '-';
|
||||
|
||||
return num.toFixed(decimals) + '%';
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化日期(取前 10 位)
|
||||
* @param dateStr 日期字符串
|
||||
* @returns 格式化后的日期(YYYY-MM-DD)
|
||||
*/
|
||||
export const formatDate = (dateStr: string | null | undefined): string => {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr.substring(0, 10);
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化价格
|
||||
* @param value 价格数值
|
||||
* @param decimals 小数位数,默认 2
|
||||
* @returns 格式化后的价格字符串
|
||||
*/
|
||||
export const formatPrice = (value: number | null | undefined, decimals: number = 2): string => {
|
||||
if (value === null || value === undefined) return '-';
|
||||
|
||||
const num = typeof value === 'number' ? value : parseFloat(String(value));
|
||||
if (isNaN(num)) return '-';
|
||||
|
||||
return num.toFixed(decimals);
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化成交量(带单位)
|
||||
* @param value 成交量数值
|
||||
* @returns 格式化后的成交量字符串
|
||||
*/
|
||||
export const formatVolume = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return '-';
|
||||
|
||||
const num = typeof value === 'number' ? value : parseFloat(String(value));
|
||||
if (isNaN(num)) return '-';
|
||||
|
||||
if (num >= 100000000) {
|
||||
return (num / 100000000).toFixed(2) + '亿股';
|
||||
} else if (num >= 10000) {
|
||||
return (num / 10000).toFixed(2) + '万股';
|
||||
}
|
||||
return num.toFixed(0) + '股';
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化金额(带单位)
|
||||
* @param value 金额数值
|
||||
* @returns 格式化后的金额字符串
|
||||
*/
|
||||
export const formatAmount = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return '-';
|
||||
|
||||
const num = typeof value === 'number' ? value : parseFloat(String(value));
|
||||
if (isNaN(num)) return '-';
|
||||
|
||||
if (Math.abs(num) >= 100000000) {
|
||||
return (num / 100000000).toFixed(2) + '亿';
|
||||
} else if (Math.abs(num) >= 10000) {
|
||||
return (num / 10000).toFixed(2) + '万';
|
||||
}
|
||||
return num.toFixed(2) + '元';
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化涨跌幅(带符号和颜色提示)
|
||||
* @param value 涨跌幅数值
|
||||
* @returns 带符号的涨跌幅字符串
|
||||
*/
|
||||
export const formatChange = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return '-';
|
||||
|
||||
const num = typeof value === 'number' ? value : parseFloat(String(value));
|
||||
if (isNaN(num)) return '-';
|
||||
|
||||
const sign = num > 0 ? '+' : '';
|
||||
return sign + num.toFixed(2) + '%';
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取涨跌颜色类型
|
||||
* @param value 涨跌幅数值
|
||||
* @returns 'up' | 'down' | 'neutral'
|
||||
*/
|
||||
export const getChangeType = (value: number | null | undefined): 'up' | 'down' | 'neutral' => {
|
||||
if (value === null || value === undefined) return 'neutral';
|
||||
|
||||
const num = typeof value === 'number' ? value : parseFloat(String(value));
|
||||
if (isNaN(num) || num === 0) return 'neutral';
|
||||
|
||||
return num > 0 ? 'up' : 'down';
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化短日期(MM-DD)
|
||||
* @param dateStr 日期字符串
|
||||
* @returns 格式化后的短日期
|
||||
*/
|
||||
export const formatShortDate = (dateStr: string | null | undefined): string => {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr.substring(5, 10);
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化时间(HH:mm)
|
||||
* @param timeStr 时间字符串
|
||||
* @returns 格式化后的时间
|
||||
*/
|
||||
export const formatTime = (timeStr: string | null | undefined): string => {
|
||||
if (!timeStr) return '-';
|
||||
// 支持多种格式
|
||||
if (timeStr.includes(':')) {
|
||||
return timeStr.substring(0, 5);
|
||||
}
|
||||
// 如果是 HHmm 格式
|
||||
if (timeStr.length >= 4) {
|
||||
return timeStr.substring(0, 2) + ':' + timeStr.substring(2, 4);
|
||||
}
|
||||
return timeStr;
|
||||
};
|
||||
|
||||
/**
|
||||
* 工具函数集合(兼容旧代码)
|
||||
*/
|
||||
export const formatUtils = {
|
||||
formatNumber,
|
||||
formatPercent,
|
||||
formatDate,
|
||||
formatPrice,
|
||||
formatVolume,
|
||||
formatAmount,
|
||||
formatChange,
|
||||
getChangeType,
|
||||
formatShortDate,
|
||||
formatTime,
|
||||
};
|
||||
|
||||
export default formatUtils;
|
||||
367
src/views/Company/components/StockQuoteCard/index.tsx
Normal file
367
src/views/Company/components/StockQuoteCard/index.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* StockQuoteCard - 股票行情卡片组件
|
||||
*
|
||||
* 展示股票的实时行情、关键指标和主力动态
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardBody,
|
||||
Flex,
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Badge,
|
||||
Progress,
|
||||
Skeleton,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Divider,
|
||||
Link,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { Share2, Calendar, Coins, MapPin, Globe } from 'lucide-react';
|
||||
import { formatRegisteredCapital, formatDate } from '../CompanyOverview/utils';
|
||||
|
||||
import FavoriteButton from '@components/FavoriteButton';
|
||||
import type { StockQuoteCardProps } from './types';
|
||||
|
||||
/**
|
||||
* 格式化价格显示
|
||||
*/
|
||||
const formatPrice = (price: number): string => {
|
||||
return price.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化涨跌幅显示
|
||||
*/
|
||||
const formatChangePercent = (percent: number): string => {
|
||||
const sign = percent >= 0 ? '+' : '';
|
||||
return `${sign}${percent.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化主力净流入显示
|
||||
*/
|
||||
const formatNetInflow = (value: number): string => {
|
||||
const sign = value >= 0 ? '+' : '';
|
||||
return `${sign}${value.toFixed(2)}亿`;
|
||||
};
|
||||
|
||||
const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||
data,
|
||||
isLoading = false,
|
||||
isInWatchlist = false,
|
||||
isWatchlistLoading = false,
|
||||
onWatchlistToggle,
|
||||
onShare,
|
||||
basicInfo,
|
||||
}) => {
|
||||
// 处理分享点击
|
||||
const handleShare = () => {
|
||||
onShare?.();
|
||||
};
|
||||
|
||||
// 黑金主题颜色配置
|
||||
const cardBg = '#1A202C';
|
||||
const borderColor = '#C9A961';
|
||||
const labelColor = '#C9A961';
|
||||
const valueColor = '#F4D03F';
|
||||
const sectionTitleColor = '#F4D03F';
|
||||
|
||||
// 涨跌颜色(红涨绿跌)
|
||||
const upColor = '#F44336'; // 涨 - 红色
|
||||
const downColor = '#4CAF50'; // 跌 - 绿色
|
||||
|
||||
// 加载中或无数据时显示骨架屏
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}>
|
||||
<CardBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Skeleton height="30px" width="200px" />
|
||||
<Skeleton height="60px" />
|
||||
<Skeleton height="80px" />
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const priceColor = data.changePercent >= 0 ? upColor : downColor;
|
||||
const inflowColor = data.mainNetInflow >= 0 ? upColor : downColor;
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}>
|
||||
<CardBody>
|
||||
{/* 顶部:股票名称 + 关注/分享按钮 + 更新时间 */}
|
||||
<Flex justify="space-between" align="center" mb={4}>
|
||||
{/* 左侧:股票名称 + 行业标签 + 指数标签 */}
|
||||
<HStack spacing={3} align="center">
|
||||
{/* 股票名称 - 突出显示 */}
|
||||
<Text fontSize="26px" fontWeight="800" color={valueColor}>
|
||||
{data.name}
|
||||
</Text>
|
||||
<Text fontSize="18px" fontWeight="normal" color={labelColor}>
|
||||
({data.code})
|
||||
</Text>
|
||||
|
||||
{/* 行业标签 */}
|
||||
{(data.industryL1 || data.industry) && (
|
||||
<Badge
|
||||
bg="transparent"
|
||||
color={labelColor}
|
||||
fontSize="14px"
|
||||
fontWeight="medium"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="md"
|
||||
>
|
||||
{data.industryL1 && data.industry
|
||||
? `${data.industryL1} · ${data.industry}`
|
||||
: data.industry || data.industryL1}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* 指数标签 */}
|
||||
{data.indexTags?.length > 0 && (
|
||||
<Text fontSize="14px" color={labelColor}>
|
||||
{data.indexTags.join('、')}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 右侧:关注 + 分享 + 时间 */}
|
||||
<HStack spacing={3}>
|
||||
<FavoriteButton
|
||||
isFavorite={isInWatchlist}
|
||||
isLoading={isWatchlistLoading}
|
||||
onClick={onWatchlistToggle || (() => {})}
|
||||
colorScheme="gold"
|
||||
size="sm"
|
||||
/>
|
||||
<Tooltip label="分享" placement="top">
|
||||
<IconButton
|
||||
aria-label="分享"
|
||||
icon={<Share2 size={18} />}
|
||||
variant="ghost"
|
||||
color={labelColor}
|
||||
size="sm"
|
||||
onClick={handleShare}
|
||||
_hover={{ bg: 'whiteAlpha.100' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Text fontSize="14px" color={labelColor}>
|
||||
{data.updateTime?.split(' ')[1] || '--:--'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 1:2 布局 */}
|
||||
<Flex gap={8}>
|
||||
{/* 左栏:价格信息 (flex=1) */}
|
||||
<Box flex="1" minWidth="0">
|
||||
<HStack align="baseline" spacing={3} mb={3}>
|
||||
<Text fontSize="48px" fontWeight="bold" color={priceColor}>
|
||||
{formatPrice(data.currentPrice)}
|
||||
</Text>
|
||||
<Badge
|
||||
bg={data.changePercent >= 0 ? upColor : downColor}
|
||||
color="#FFFFFF"
|
||||
fontSize="20px"
|
||||
fontWeight="bold"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
>
|
||||
{formatChangePercent(data.changePercent)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
{/* 次要行情:今开 | 昨收 | 最高 | 最低 */}
|
||||
<HStack spacing={4} fontSize="14px" flexWrap="wrap">
|
||||
<Text color={labelColor}>
|
||||
今开:
|
||||
<Text as="span" color={valueColor} fontWeight="bold">
|
||||
{formatPrice(data.todayOpen)}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text color={borderColor}>|</Text>
|
||||
<Text color={labelColor}>
|
||||
昨收:
|
||||
<Text as="span" color={valueColor} fontWeight="bold">
|
||||
{formatPrice(data.yesterdayClose)}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text color={borderColor}>|</Text>
|
||||
<Text color={labelColor}>
|
||||
最高:
|
||||
<Text as="span" color={upColor} fontWeight="bold">
|
||||
{formatPrice(data.todayHigh)}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text color={borderColor}>|</Text>
|
||||
<Text color={labelColor}>
|
||||
最低:
|
||||
<Text as="span" color={downColor} fontWeight="bold">
|
||||
{formatPrice(data.todayLow)}
|
||||
</Text>
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 右栏:关键指标 + 主力动态 (flex=2) */}
|
||||
<Flex flex="2" minWidth="0" gap={8} borderLeftWidth="1px" borderColor={borderColor} pl={8}>
|
||||
{/* 关键指标 */}
|
||||
<Box flex="1">
|
||||
<Text
|
||||
fontSize="14px"
|
||||
fontWeight="bold"
|
||||
color={sectionTitleColor}
|
||||
mb={3}
|
||||
>
|
||||
关键指标
|
||||
</Text>
|
||||
<VStack align="stretch" spacing={2} fontSize="14px">
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>市盈率(PE):</Text>
|
||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
||||
{data.pe.toFixed(2)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>市净率(PB):</Text>
|
||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
||||
{data.pb.toFixed(2)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>流通市值:</Text>
|
||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
||||
{data.marketCap}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>52周波动:</Text>
|
||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
||||
{formatPrice(data.week52Low)}-{formatPrice(data.week52High)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 主力动态 */}
|
||||
<Box flex="1" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
|
||||
<Text
|
||||
fontSize="14px"
|
||||
fontWeight="bold"
|
||||
color={sectionTitleColor}
|
||||
mb={3}
|
||||
>
|
||||
主力动态
|
||||
</Text>
|
||||
<VStack align="stretch" spacing={2} fontSize="14px">
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>主力净流入:</Text>
|
||||
<Text color={inflowColor} fontWeight="bold" fontSize="16px">
|
||||
{formatNetInflow(data.mainNetInflow)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>机构持仓:</Text>
|
||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
||||
{data.institutionHolding.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
{/* 买卖比例条 */}
|
||||
<Box mt={1}>
|
||||
<Progress
|
||||
value={data.buyRatio}
|
||||
size="sm"
|
||||
sx={{
|
||||
'& > div': { bg: upColor },
|
||||
}}
|
||||
bg={downColor}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<HStack justify="space-between" mt={1} fontSize="14px">
|
||||
<Text color={upColor}>买入{data.buyRatio}%</Text>
|
||||
<Text color={downColor}>卖出{data.sellRatio}%</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* 公司信息区块 - 1:2 布局 */}
|
||||
{basicInfo && (
|
||||
<>
|
||||
<Divider borderColor={borderColor} my={4} />
|
||||
<Flex gap={8}>
|
||||
{/* 左侧:公司关键属性 (flex=1) */}
|
||||
<Box flex="1" minWidth="0">
|
||||
<HStack spacing={4} flexWrap="wrap" fontSize="14px">
|
||||
<HStack spacing={1}>
|
||||
<Icon as={Calendar} color={labelColor} boxSize={4} />
|
||||
<Text color={labelColor}>成立:</Text>
|
||||
<Text color={valueColor} fontWeight="bold">
|
||||
{formatDate(basicInfo.establish_date)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={Coins} color={labelColor} boxSize={4} />
|
||||
<Text color={labelColor}>注册资本:</Text>
|
||||
<Text color={valueColor} fontWeight="bold">
|
||||
{formatRegisteredCapital(basicInfo.reg_capital)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={MapPin} color={labelColor} boxSize={4} />
|
||||
<Text color={labelColor}>所在地:</Text>
|
||||
<Text color={valueColor} fontWeight="bold">
|
||||
{basicInfo.province} {basicInfo.city}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={Globe} color={labelColor} boxSize={4} />
|
||||
{basicInfo.website ? (
|
||||
<Link
|
||||
href={basicInfo.website}
|
||||
isExternal
|
||||
color={valueColor}
|
||||
fontWeight="bold"
|
||||
_hover={{ color: labelColor }}
|
||||
>
|
||||
访问官网
|
||||
</Link>
|
||||
) : (
|
||||
<Text color={valueColor}>暂无官网</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:公司简介 (flex=2) */}
|
||||
<Box flex="2" minWidth="0" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
|
||||
<Text fontSize="14px" color={labelColor} noOfLines={2}>
|
||||
<Text as="span" fontWeight="bold" color={valueColor}>公司简介:</Text>
|
||||
{basicInfo.company_intro || '暂无'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockQuoteCard;
|
||||
38
src/views/Company/components/StockQuoteCard/mockData.ts
Normal file
38
src/views/Company/components/StockQuoteCard/mockData.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { StockQuoteCardData } from './types';
|
||||
|
||||
/**
|
||||
* 贵州茅台 Mock 数据
|
||||
*/
|
||||
export const mockStockQuoteData: StockQuoteCardData = {
|
||||
// 基础信息
|
||||
name: '贵州茅台',
|
||||
code: '600519.SH',
|
||||
indexTags: ['沪深300'],
|
||||
|
||||
// 价格信息
|
||||
currentPrice: 2178.5,
|
||||
changePercent: 3.65,
|
||||
todayOpen: 2156.0,
|
||||
yesterdayClose: 2101.0,
|
||||
todayHigh: 2185.0,
|
||||
todayLow: 2150.0,
|
||||
|
||||
// 关键指标
|
||||
pe: 38.62,
|
||||
pb: 14.82,
|
||||
marketCap: '2.73万亿',
|
||||
week52Low: 1980,
|
||||
week52High: 2350,
|
||||
|
||||
// 主力动态
|
||||
mainNetInflow: 1.28,
|
||||
institutionHolding: 72.35,
|
||||
buyRatio: 85,
|
||||
sellRatio: 15,
|
||||
|
||||
// 更新时间
|
||||
updateTime: '2025-12-03 14:30:25',
|
||||
|
||||
// 自选状态
|
||||
isFavorite: false,
|
||||
};
|
||||
60
src/views/Company/components/StockQuoteCard/types.ts
Normal file
60
src/views/Company/components/StockQuoteCard/types.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* StockQuoteCard 组件类型定义
|
||||
*/
|
||||
|
||||
import type { BasicInfo } from '../CompanyOverview/types';
|
||||
|
||||
/**
|
||||
* 股票行情卡片数据
|
||||
*/
|
||||
export interface StockQuoteCardData {
|
||||
// 基础信息
|
||||
name: string; // 股票名称
|
||||
code: string; // 股票代码
|
||||
indexTags: string[]; // 指数标签(如 沪深300、上证50)
|
||||
industry?: string; // 所属行业(二级),如 "银行"
|
||||
industryL1?: string; // 一级行业,如 "金融"
|
||||
|
||||
// 价格信息
|
||||
currentPrice: number; // 当前价格
|
||||
changePercent: number; // 涨跌幅(百分比,如 3.65 表示 +3.65%)
|
||||
todayOpen: number; // 今开
|
||||
yesterdayClose: number; // 昨收
|
||||
todayHigh: number; // 今日最高
|
||||
todayLow: number; // 今日最低
|
||||
|
||||
// 关键指标
|
||||
pe: number; // 市盈率
|
||||
pb: number; // 市净率
|
||||
marketCap: string; // 流通市值(已格式化,如 "2.73万亿")
|
||||
week52Low: number; // 52周最低
|
||||
week52High: number; // 52周最高
|
||||
|
||||
// 主力动态
|
||||
mainNetInflow: number; // 主力净流入(亿)
|
||||
institutionHolding: number; // 机构持仓比例(百分比)
|
||||
buyRatio: number; // 买入比例(百分比)
|
||||
sellRatio: number; // 卖出比例(百分比)
|
||||
|
||||
// 更新时间
|
||||
updateTime: string; // 格式:YYYY-MM-DD HH:mm:ss
|
||||
|
||||
// 自选状态
|
||||
isFavorite?: boolean; // 是否已加入自选
|
||||
}
|
||||
|
||||
/**
|
||||
* StockQuoteCard 组件 Props
|
||||
*/
|
||||
export interface StockQuoteCardProps {
|
||||
data?: StockQuoteCardData;
|
||||
isLoading?: boolean;
|
||||
// 自选股相关(与 WatchlistButton 接口保持一致)
|
||||
isInWatchlist?: boolean; // 是否在自选股中
|
||||
isWatchlistLoading?: boolean; // 自选股操作加载中
|
||||
onWatchlistToggle?: () => void; // 自选股切换回调
|
||||
// 分享
|
||||
onShare?: () => void; // 分享回调
|
||||
// 公司基本信息
|
||||
basicInfo?: BasicInfo;
|
||||
}
|
||||
55
src/views/Company/constants/index.js
Normal file
55
src/views/Company/constants/index.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// src/views/Company/constants/index.js
|
||||
// 公司详情页面常量配置
|
||||
|
||||
import { FaChartLine, FaMoneyBillWave, FaChartBar, FaInfoCircle, FaBrain, FaNewspaper } from 'react-icons/fa';
|
||||
|
||||
/**
|
||||
* Tab 配置
|
||||
* @type {Array<{key: string, name: string, icon: React.ComponentType}>}
|
||||
*/
|
||||
export const COMPANY_TABS = [
|
||||
{ key: 'overview', name: '公司档案', icon: FaInfoCircle },
|
||||
{ key: 'analysis', name: '深度分析', icon: FaBrain },
|
||||
{ key: 'market', name: '股票行情', icon: FaChartLine },
|
||||
{ key: 'financial', name: '财务全景', icon: FaMoneyBillWave },
|
||||
{ key: 'forecast', name: '盈利预测', icon: FaChartBar },
|
||||
{ key: 'tracking', name: '动态跟踪', icon: FaNewspaper },
|
||||
];
|
||||
|
||||
/**
|
||||
* Tab 选中状态样式
|
||||
*/
|
||||
export const TAB_SELECTED_STYLE = {
|
||||
transform: 'scale(1.02)',
|
||||
transition: 'all 0.2s',
|
||||
};
|
||||
|
||||
/**
|
||||
* Toast 消息配置
|
||||
*/
|
||||
export const TOAST_MESSAGES = {
|
||||
WATCHLIST_ADD: { title: '已加入自选', status: 'success', duration: 1500 },
|
||||
WATCHLIST_REMOVE: { title: '已从自选移除', status: 'info', duration: 1500 },
|
||||
WATCHLIST_ERROR: { title: '操作失败,请稍后重试', status: 'error', duration: 2000 },
|
||||
INVALID_CODE: { title: '无效的股票代码', status: 'error', duration: 2000 },
|
||||
LOGIN_REQUIRED: { title: '请先登录后再加入自选', status: 'warning', duration: 2000 },
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认股票代码
|
||||
*/
|
||||
export const DEFAULT_STOCK_CODE = '000001';
|
||||
|
||||
/**
|
||||
* URL 参数名
|
||||
*/
|
||||
export const URL_PARAM_NAME = 'scode';
|
||||
|
||||
/**
|
||||
* 根据索引获取 Tab 名称
|
||||
* @param {number} index - Tab 索引
|
||||
* @returns {string} Tab 名称
|
||||
*/
|
||||
export const getTabNameByIndex = (index) => {
|
||||
return COMPANY_TABS[index]?.name || 'Unknown';
|
||||
};
|
||||
91
src/views/Company/hooks/useCompanyStock.js
Normal file
91
src/views/Company/hooks/useCompanyStock.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// src/views/Company/hooks/useCompanyStock.js
|
||||
// 股票代码管理 Hook - 处理 URL 参数同步和搜索逻辑
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { DEFAULT_STOCK_CODE, URL_PARAM_NAME } from '../constants';
|
||||
|
||||
/**
|
||||
* 股票代码管理 Hook
|
||||
*
|
||||
* 功能:
|
||||
* - 管理当前股票代码状态
|
||||
* - 双向同步 URL 参数
|
||||
* - 处理搜索输入和提交
|
||||
*
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} [options.defaultCode] - 默认股票代码
|
||||
* @param {string} [options.paramName] - URL 参数名
|
||||
* @param {Function} [options.onStockChange] - 股票代码变化回调 (newCode, prevCode) => void
|
||||
* @returns {Object} 股票代码状态和操作方法
|
||||
*/
|
||||
export const useCompanyStock = (options = {}) => {
|
||||
const {
|
||||
defaultCode = DEFAULT_STOCK_CODE,
|
||||
paramName = URL_PARAM_NAME,
|
||||
onStockChange,
|
||||
} = options;
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// 从 URL 参数初始化股票代码
|
||||
const [stockCode, setStockCode] = useState(
|
||||
searchParams.get(paramName) || defaultCode
|
||||
);
|
||||
|
||||
// 输入框状态(默认为空,不显示默认股票代码)
|
||||
const [inputCode, setInputCode] = useState('');
|
||||
|
||||
/**
|
||||
* 监听 URL 参数变化,同步到本地状态
|
||||
* 支持浏览器前进/后退按钮
|
||||
*/
|
||||
useEffect(() => {
|
||||
const urlCode = searchParams.get(paramName);
|
||||
if (urlCode && urlCode !== stockCode) {
|
||||
setStockCode(urlCode);
|
||||
setInputCode(urlCode);
|
||||
}
|
||||
}, [searchParams, paramName, stockCode]);
|
||||
|
||||
/**
|
||||
* 执行搜索 - 更新 stockCode 和 URL
|
||||
* @param {string} [code] - 可选,直接传入股票代码(用于下拉选择)
|
||||
*/
|
||||
const handleSearch = useCallback((code) => {
|
||||
const trimmedCode = code || inputCode?.trim();
|
||||
|
||||
if (trimmedCode && trimmedCode !== stockCode) {
|
||||
// 触发变化回调(用于追踪)
|
||||
onStockChange?.(trimmedCode, stockCode);
|
||||
|
||||
// 更新状态
|
||||
setStockCode(trimmedCode);
|
||||
|
||||
// 更新 URL 参数
|
||||
setSearchParams({ [paramName]: trimmedCode });
|
||||
}
|
||||
}, [inputCode, stockCode, paramName, setSearchParams, onStockChange]);
|
||||
|
||||
/**
|
||||
* 处理键盘事件 - 回车键触发搜索
|
||||
*/
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
}, [handleSearch]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
stockCode, // 当前确认的股票代码
|
||||
inputCode, // 输入框中的值(未确认)
|
||||
|
||||
// 操作方法
|
||||
setInputCode, // 更新输入框
|
||||
handleSearch, // 执行搜索
|
||||
handleKeyDown, // 处理回车键(改用 onKeyDown)
|
||||
};
|
||||
};
|
||||
|
||||
export default useCompanyStock;
|
||||
166
src/views/Company/hooks/useCompanyWatchlist.js
Normal file
166
src/views/Company/hooks/useCompanyWatchlist.js
Normal file
@@ -0,0 +1,166 @@
|
||||
// src/views/Company/hooks/useCompanyWatchlist.js
|
||||
// 自选股管理 Hook - Company 页面专用,复用 Redux stockSlice
|
||||
|
||||
import { useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { useAuth } from '@contexts/AuthContext';
|
||||
import { logger } from '@utils/logger';
|
||||
import {
|
||||
loadWatchlist,
|
||||
toggleWatchlist,
|
||||
optimisticAddWatchlist,
|
||||
optimisticRemoveWatchlist
|
||||
} from '@store/slices/stockSlice';
|
||||
import { TOAST_MESSAGES } from '../constants';
|
||||
|
||||
/**
|
||||
* Company 页面自选股管理 Hook
|
||||
*
|
||||
* 功能:
|
||||
* - 检查当前股票是否在自选股中
|
||||
* - 提供添加/移除自选股功能
|
||||
* - 与 Redux stockSlice 同步
|
||||
*
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.stockCode - 当前股票代码
|
||||
* @param {Object} [options.tracking] - 追踪回调
|
||||
* @param {Function} [options.tracking.onAdd] - 添加自选时的追踪回调
|
||||
* @param {Function} [options.tracking.onRemove] - 移除自选时的追踪回调
|
||||
* @returns {Object} 自选股状态和操作方法
|
||||
*/
|
||||
export const useCompanyWatchlist = ({ stockCode, tracking = {} } = {}) => {
|
||||
const dispatch = useDispatch();
|
||||
const toast = useToast();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
// 从 Redux 获取自选股列表
|
||||
const watchlist = useSelector((state) => state.stock.watchlist);
|
||||
const watchlistLoading = useSelector((state) => state.stock.loading.watchlist);
|
||||
|
||||
// 追踪是否已初始化(防止无限循环)
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
/**
|
||||
* 派生状态:判断当前股票是否在自选股中
|
||||
* 使用 useMemo 避免重复计算
|
||||
*/
|
||||
const isInWatchlist = useMemo(() => {
|
||||
if (!stockCode || !Array.isArray(watchlist)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 标准化股票代码(提取6位数字)
|
||||
const normalize = (code) => String(code || '').match(/(\d{6})/)?.[1] || '';
|
||||
const targetCode = normalize(stockCode);
|
||||
|
||||
return watchlist.some((item) => normalize(item.stock_code) === targetCode);
|
||||
}, [watchlist, stockCode]);
|
||||
|
||||
/**
|
||||
* 初始化:加载自选股列表
|
||||
* 使用 hasInitializedRef 防止无限循环(用户可能确实没有自选股)
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!hasInitializedRef.current && isAuthenticated && !watchlistLoading) {
|
||||
hasInitializedRef.current = true;
|
||||
dispatch(loadWatchlist());
|
||||
}
|
||||
}, [isAuthenticated, watchlistLoading, dispatch]);
|
||||
|
||||
/**
|
||||
* 切换自选股状态(乐观更新模式)
|
||||
* 1. 立即更新 UI(无 loading)
|
||||
* 2. 后台静默请求 API
|
||||
* 3. 失败时回滚并提示
|
||||
*/
|
||||
const toggle = useCallback(async () => {
|
||||
// 参数校验
|
||||
if (!stockCode) {
|
||||
logger.warn('useCompanyWatchlist', 'toggle', '无效的股票代码', { stockCode });
|
||||
toast(TOAST_MESSAGES.INVALID_CODE);
|
||||
return;
|
||||
}
|
||||
|
||||
// 权限校验
|
||||
if (!isAuthenticated) {
|
||||
logger.warn('useCompanyWatchlist', 'toggle', '用户未登录', { stockCode });
|
||||
toast(TOAST_MESSAGES.LOGIN_REQUIRED);
|
||||
return;
|
||||
}
|
||||
|
||||
// 标准化股票代码用于匹配
|
||||
const normalize = (code) => String(code || '').match(/(\d{6})/)?.[1] || '';
|
||||
const targetCode = normalize(stockCode);
|
||||
|
||||
// 从 watchlist 中找到原始 stock_code(保持与后端数据结构一致)
|
||||
const matchedItem = watchlist.find(
|
||||
item => normalize(item.stock_code) === targetCode
|
||||
);
|
||||
// 移除时使用原始 stock_code,添加时使用传入的 stockCode
|
||||
const codeForApi = isInWatchlist ? (matchedItem?.stock_code || stockCode) : stockCode;
|
||||
|
||||
// 保存当前状态用于回滚
|
||||
const wasInWatchlist = isInWatchlist;
|
||||
|
||||
logger.debug('useCompanyWatchlist', '切换自选股(乐观更新)', {
|
||||
stockCode,
|
||||
codeForApi,
|
||||
wasInWatchlist,
|
||||
action: wasInWatchlist ? 'remove' : 'add',
|
||||
});
|
||||
|
||||
// 1. 乐观更新:立即更新 UI(不显示 loading)
|
||||
if (wasInWatchlist) {
|
||||
dispatch(optimisticRemoveWatchlist({ stockCode: codeForApi }));
|
||||
} else {
|
||||
dispatch(optimisticAddWatchlist({ stockCode: codeForApi, stockName: matchedItem?.stock_name || '' }));
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. 后台静默请求 API
|
||||
await dispatch(
|
||||
toggleWatchlist({
|
||||
stockCode: codeForApi,
|
||||
stockName: matchedItem?.stock_name || '',
|
||||
isInWatchlist: wasInWatchlist,
|
||||
})
|
||||
).unwrap();
|
||||
|
||||
// 3. 成功:触发追踪回调(不显示 toast,状态已更新)
|
||||
if (wasInWatchlist) {
|
||||
tracking.onRemove?.(stockCode);
|
||||
} else {
|
||||
tracking.onAdd?.(stockCode);
|
||||
}
|
||||
} catch (error) {
|
||||
// 4. 失败:回滚状态 + 显示错误提示
|
||||
logger.error('useCompanyWatchlist', 'toggle', error, {
|
||||
stockCode,
|
||||
wasInWatchlist,
|
||||
});
|
||||
|
||||
// 回滚操作
|
||||
if (wasInWatchlist) {
|
||||
// 之前在自选中,乐观删除了,现在要恢复
|
||||
dispatch(optimisticAddWatchlist({ stockCode: codeForApi, stockName: matchedItem?.stock_name || '' }));
|
||||
} else {
|
||||
// 之前不在自选中,乐观添加了,现在要移除
|
||||
dispatch(optimisticRemoveWatchlist({ stockCode: codeForApi }));
|
||||
}
|
||||
|
||||
toast(TOAST_MESSAGES.WATCHLIST_ERROR);
|
||||
}
|
||||
}, [stockCode, isAuthenticated, isInWatchlist, watchlist, dispatch, toast, tracking]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isInWatchlist, // 是否在自选股中
|
||||
isLoading: watchlistLoading, // 仅初始加载时显示 loading(乐观更新模式)
|
||||
|
||||
// 操作方法
|
||||
toggle, // 切换自选状态
|
||||
};
|
||||
};
|
||||
|
||||
export default useCompanyWatchlist;
|
||||
102
src/views/Company/hooks/useStockQuote.js
Normal file
102
src/views/Company/hooks/useStockQuote.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// src/views/Company/hooks/useStockQuote.js
|
||||
// 股票行情数据获取 Hook
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { stockService } from '@services/eventService';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 将 API 响应数据转换为 StockQuoteCard 所需格式
|
||||
*/
|
||||
const transformQuoteData = (apiData, stockCode) => {
|
||||
if (!apiData) return null;
|
||||
|
||||
return {
|
||||
// 基础信息
|
||||
name: apiData.name || apiData.stock_name || '未知',
|
||||
code: apiData.code || apiData.stock_code || stockCode,
|
||||
indexTags: apiData.index_tags || apiData.indexTags || [],
|
||||
industry: apiData.industry || apiData.sw_industry_l2 || '',
|
||||
industryL1: apiData.industry_l1 || apiData.sw_industry_l1 || '',
|
||||
|
||||
// 价格信息
|
||||
currentPrice: apiData.current_price || apiData.currentPrice || apiData.close || 0,
|
||||
changePercent: apiData.change_percent || apiData.changePercent || apiData.pct_chg || 0,
|
||||
todayOpen: apiData.today_open || apiData.todayOpen || apiData.open || 0,
|
||||
yesterdayClose: apiData.yesterday_close || apiData.yesterdayClose || apiData.pre_close || 0,
|
||||
todayHigh: apiData.today_high || apiData.todayHigh || apiData.high || 0,
|
||||
todayLow: apiData.today_low || apiData.todayLow || apiData.low || 0,
|
||||
|
||||
// 关键指标
|
||||
pe: apiData.pe || apiData.pe_ttm || 0,
|
||||
pb: apiData.pb || apiData.pb_mrq || 0,
|
||||
marketCap: apiData.market_cap || apiData.marketCap || apiData.circ_mv || '0',
|
||||
week52Low: apiData.week52_low || apiData.week52Low || 0,
|
||||
week52High: apiData.week52_high || apiData.week52High || 0,
|
||||
|
||||
// 主力动态
|
||||
mainNetInflow: apiData.main_net_inflow || apiData.mainNetInflow || 0,
|
||||
institutionHolding: apiData.institution_holding || apiData.institutionHolding || 0,
|
||||
buyRatio: apiData.buy_ratio || apiData.buyRatio || 50,
|
||||
sellRatio: apiData.sell_ratio || apiData.sellRatio || 50,
|
||||
|
||||
// 更新时间
|
||||
updateTime: apiData.update_time || apiData.updateTime || new Date().toLocaleString(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 股票行情数据获取 Hook
|
||||
*
|
||||
* @param {string} stockCode - 股票代码
|
||||
* @returns {Object} { data, isLoading, error, refetch }
|
||||
*/
|
||||
export const useStockQuote = (stockCode) => {
|
||||
const [data, setData] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stockCode) {
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchQuote = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
logger.debug('useStockQuote', '获取股票行情', { stockCode });
|
||||
const quotes = await stockService.getQuotes([stockCode]);
|
||||
|
||||
// API 返回格式: { [stockCode]: quoteData }
|
||||
const quoteData = quotes?.[stockCode] || quotes;
|
||||
const transformedData = transformQuoteData(quoteData, stockCode);
|
||||
|
||||
logger.debug('useStockQuote', '行情数据转换完成', { stockCode, hasData: !!transformedData });
|
||||
setData(transformedData);
|
||||
} catch (err) {
|
||||
logger.error('useStockQuote', '获取行情失败', err);
|
||||
setError(err);
|
||||
setData(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchQuote();
|
||||
}, [stockCode]);
|
||||
|
||||
// 手动刷新
|
||||
const refetch = () => {
|
||||
if (stockCode) {
|
||||
setData(null);
|
||||
// 触发 useEffect 重新执行
|
||||
}
|
||||
};
|
||||
|
||||
return { data, isLoading, error, refetch };
|
||||
};
|
||||
|
||||
export default useStockQuote;
|
||||
@@ -1,64 +1,56 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
// src/views/Company/index.js
|
||||
// 公司详情页面入口 - 纯组合层
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Container, VStack } from '@chakra-ui/react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { loadAllStocks } from '@store/slices/stockSlice';
|
||||
import { AutoComplete } from 'antd';
|
||||
import { stockService } from '@services/stockService';
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
Card,
|
||||
CardBody,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
HStack,
|
||||
VStack,
|
||||
Button,
|
||||
Text,
|
||||
Badge,
|
||||
Divider,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
useColorMode,
|
||||
IconButton,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { SearchIcon, MoonIcon, SunIcon, StarIcon } from '@chakra-ui/icons';
|
||||
import { FaChartLine, FaMoneyBillWave, FaChartBar, FaInfoCircle } from 'react-icons/fa';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getApiBase } from '../../utils/apiConfig';
|
||||
import FinancialPanorama from './FinancialPanorama';
|
||||
import ForecastReport from './ForecastReport';
|
||||
import MarketDataView from './MarketDataView';
|
||||
import CompanyOverview from './CompanyOverview';
|
||||
// 导入 PostHog 追踪 Hook
|
||||
|
||||
// 自定义 Hooks
|
||||
import { useCompanyStock } from './hooks/useCompanyStock';
|
||||
import { useCompanyWatchlist } from './hooks/useCompanyWatchlist';
|
||||
import { useCompanyEvents } from './hooks/useCompanyEvents';
|
||||
import { useStockQuote } from './hooks/useStockQuote';
|
||||
import { useBasicInfo } from './components/CompanyOverview/hooks/useBasicInfo';
|
||||
|
||||
// 页面组件
|
||||
import CompanyHeader from './components/CompanyHeader';
|
||||
import StockQuoteCard from './components/StockQuoteCard';
|
||||
import CompanyTabs from './components/CompanyTabs';
|
||||
|
||||
/**
|
||||
* 公司详情页面
|
||||
*
|
||||
* 功能:
|
||||
* - 股票搜索与代码管理
|
||||
* - 自选股添加/移除
|
||||
* - 多维度数据展示(概览、行情、财务、预测)
|
||||
* - PostHog 事件追踪
|
||||
*/
|
||||
const CompanyIndex = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [stockCode, setStockCode] = useState(searchParams.get('scode') || '000001');
|
||||
const [inputCode, setInputCode] = useState(stockCode);
|
||||
const [stockOptions, setStockOptions] = useState([]);
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const toast = useToast();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
// 从 Redux 获取股票列表数据
|
||||
const dispatch = useDispatch();
|
||||
const allStocks = useSelector((state) => state.stock.allStocks);
|
||||
|
||||
// 确保股票数据已加载
|
||||
// 1. 先获取股票代码(不带追踪回调)
|
||||
const {
|
||||
stockCode,
|
||||
inputCode,
|
||||
setInputCode,
|
||||
handleSearch,
|
||||
handleKeyDown,
|
||||
} = useCompanyStock();
|
||||
|
||||
// 加载全部股票列表(用于模糊搜索)
|
||||
useEffect(() => {
|
||||
if (!allStocks || allStocks.length === 0) {
|
||||
dispatch(loadAllStocks());
|
||||
}
|
||||
}, [dispatch, allStocks]);
|
||||
dispatch(loadAllStocks());
|
||||
}, [dispatch]);
|
||||
|
||||
// 🎯 PostHog 事件追踪
|
||||
// 2. 获取股票行情数据
|
||||
const { data: quoteData, isLoading: isQuoteLoading } = useStockQuote(stockCode);
|
||||
|
||||
// 2.1 获取公司基本信息
|
||||
const { basicInfo } = useBasicInfo(stockCode);
|
||||
|
||||
// 3. 再初始化事件追踪(传入 stockCode)
|
||||
const {
|
||||
trackStockSearched,
|
||||
trackTabChanged,
|
||||
@@ -66,321 +58,55 @@ const CompanyIndex = () => {
|
||||
trackWatchlistRemoved,
|
||||
} = useCompanyEvents({ stockCode });
|
||||
|
||||
// Tab 索引状态(用于追踪 Tab 切换)
|
||||
const [currentTabIndex, setCurrentTabIndex] = useState(0);
|
||||
// 3. 自选股管理
|
||||
const {
|
||||
isInWatchlist,
|
||||
isLoading: isWatchlistLoading,
|
||||
toggle: handleWatchlistToggle,
|
||||
} = useCompanyWatchlist({
|
||||
stockCode,
|
||||
tracking: {
|
||||
onAdd: trackWatchlistAdded,
|
||||
onRemove: trackWatchlistRemoved,
|
||||
},
|
||||
});
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const tabBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const activeBg = useColorModeValue('blue.500', 'blue.400');
|
||||
|
||||
const [isInWatchlist, setIsInWatchlist] = useState(false);
|
||||
const [isWatchlistLoading, setIsWatchlistLoading] = useState(false);
|
||||
|
||||
const loadWatchlistStatus = useCallback(async () => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const resp = await fetch(base + '/api/account/watchlist', {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
headers: { 'Cache-Control': 'no-cache' }
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setIsInWatchlist(false);
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const list = Array.isArray(data?.data) ? data.data : [];
|
||||
const codes = new Set(list.map((item) => item.stock_code));
|
||||
setIsInWatchlist(codes.has(stockCode));
|
||||
} catch (e) {
|
||||
setIsInWatchlist(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
|
||||
// 当URL参数变化时更新股票代码
|
||||
// 4. 监听 stockCode 变化,触发搜索追踪
|
||||
const prevStockCodeRef = useRef(stockCode);
|
||||
useEffect(() => {
|
||||
const scode = searchParams.get('scode');
|
||||
if (scode && scode !== stockCode) {
|
||||
setStockCode(scode);
|
||||
setInputCode(scode);
|
||||
if (stockCode !== prevStockCodeRef.current) {
|
||||
trackStockSearched(stockCode, prevStockCodeRef.current);
|
||||
prevStockCodeRef.current = stockCode;
|
||||
}
|
||||
}, [searchParams, stockCode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadWatchlistStatus();
|
||||
}, [loadWatchlistStatus]);
|
||||
|
||||
const handleSearch = () => {
|
||||
if (inputCode && inputCode !== stockCode) {
|
||||
// 🎯 追踪股票搜索
|
||||
trackStockSearched(inputCode, stockCode);
|
||||
|
||||
setStockCode(inputCode);
|
||||
setSearchParams({ scode: inputCode });
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
// 模糊搜索股票(由 onSearch 触发)
|
||||
const handleStockSearch = (value) => {
|
||||
if (!value || !allStocks || allStocks.length === 0) {
|
||||
setStockOptions([]);
|
||||
return;
|
||||
}
|
||||
const results = stockService.fuzzySearch(value, allStocks, 10);
|
||||
const options = results.map((stock) => ({
|
||||
value: stock.code,
|
||||
label: `${stock.code} ${stock.name}`,
|
||||
}));
|
||||
setStockOptions(options);
|
||||
};
|
||||
|
||||
// 选中股票
|
||||
const handleStockSelect = (value) => {
|
||||
setInputCode(value);
|
||||
setStockOptions([]);
|
||||
if (value !== stockCode) {
|
||||
trackStockSearched(value, stockCode);
|
||||
setStockCode(value);
|
||||
setSearchParams({ scode: value });
|
||||
}
|
||||
};
|
||||
|
||||
const handleWatchlistToggle = async () => {
|
||||
if (!stockCode) {
|
||||
logger.warn('CompanyIndex', 'handleWatchlistToggle', '无效的股票代码', { stockCode });
|
||||
toast({ title: '无效的股票代码', status: 'error', duration: 2000 });
|
||||
return;
|
||||
}
|
||||
if (!isAuthenticated) {
|
||||
logger.warn('CompanyIndex', 'handleWatchlistToggle', '用户未登录', { stockCode });
|
||||
toast({ title: '请先登录后再加入自选', status: 'warning', duration: 2000 });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsWatchlistLoading(true);
|
||||
const base = getApiBase();
|
||||
if (isInWatchlist) {
|
||||
logger.debug('CompanyIndex', '准备从自选移除', { stockCode });
|
||||
const url = base + `/api/account/watchlist/${stockCode}`;
|
||||
logger.api.request('DELETE', url, { stockCode });
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
logger.api.response('DELETE', url, resp.status);
|
||||
if (!resp.ok) throw new Error('删除失败');
|
||||
|
||||
// 🎯 追踪移除自选
|
||||
trackWatchlistRemoved(stockCode);
|
||||
|
||||
setIsInWatchlist(false);
|
||||
toast({ title: '已从自选移除', status: 'info', duration: 1500 });
|
||||
} else {
|
||||
logger.debug('CompanyIndex', '准备添加到自选', { stockCode });
|
||||
const url = base + '/api/account/watchlist';
|
||||
const body = { stock_code: stockCode };
|
||||
logger.api.request('POST', url, body);
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
logger.api.response('POST', url, resp.status);
|
||||
if (!resp.ok) throw new Error('添加失败');
|
||||
|
||||
// 🎯 追踪加入自选
|
||||
trackWatchlistAdded(stockCode);
|
||||
|
||||
setIsInWatchlist(true);
|
||||
toast({ title: '已加入自选', status: 'success', duration: 1500 });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('CompanyIndex', 'handleWatchlistToggle', error, { stockCode, isInWatchlist });
|
||||
toast({ title: '操作失败,请稍后重试', status: 'error', duration: 2000 });
|
||||
} finally {
|
||||
setIsWatchlistLoading(false);
|
||||
}
|
||||
};
|
||||
}, [stockCode, trackStockSearched]);
|
||||
|
||||
return (
|
||||
<Container maxW="container.xl" py={5}>
|
||||
{/* 页面标题和股票搜索 */}
|
||||
<VStack align="stretch" spacing={5}>
|
||||
<Card bg={bgColor} shadow="md">
|
||||
<CardBody>
|
||||
<HStack justify="space-between" align="center">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="lg">个股详情</Heading>
|
||||
<Text color="gray.600" fontSize="sm">
|
||||
查看股票实时行情、财务数据和盈利预测
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<HStack spacing={3}>
|
||||
<AutoComplete
|
||||
value={inputCode}
|
||||
options={stockOptions}
|
||||
onSearch={handleStockSearch}
|
||||
onSelect={handleStockSelect}
|
||||
onChange={(value) => setInputCode(value)}
|
||||
placeholder="输入股票代码或名称"
|
||||
style={{ width: 260 }}
|
||||
size="large"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
onClick={handleSearch}
|
||||
leftIcon={<SearchIcon />}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme={isInWatchlist ? 'yellow' : 'teal'}
|
||||
variant={isInWatchlist ? 'solid' : 'outline'}
|
||||
size="lg"
|
||||
onClick={handleWatchlistToggle}
|
||||
leftIcon={<StarIcon />}
|
||||
isLoading={isWatchlistLoading}
|
||||
>
|
||||
{isInWatchlist ? '已在自选' : '加入自选'}
|
||||
</Button>
|
||||
<IconButton
|
||||
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
|
||||
onClick={toggleColorMode}
|
||||
variant="outline"
|
||||
colorScheme={colorMode === 'light' ? 'blue' : 'yellow'}
|
||||
size="lg"
|
||||
aria-label="Toggle color mode"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 当前股票信息 */}
|
||||
<HStack mt={4} spacing={4}>
|
||||
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
|
||||
股票代码: {stockCode}
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
更新时间: {new Date().toLocaleString()}
|
||||
</Text>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 数据展示区域 */}
|
||||
<Card bg={bgColor} shadow="lg">
|
||||
<CardBody p={0}>
|
||||
<Tabs
|
||||
variant="soft-rounded"
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
index={currentTabIndex}
|
||||
onChange={(index) => {
|
||||
const tabNames = ['公司概览', '股票行情', '财务全景', '盈利预测'];
|
||||
// 🎯 追踪 Tab 切换
|
||||
trackTabChanged(index, tabNames[index], currentTabIndex);
|
||||
setCurrentTabIndex(index);
|
||||
}}
|
||||
>
|
||||
<TabList p={4} bg={tabBg}>
|
||||
<Tab
|
||||
_selected={{
|
||||
bg: activeBg,
|
||||
color: 'white',
|
||||
transform: 'scale(1.02)',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
mr={2}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaInfoCircle} />
|
||||
<Text>公司概览</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
<Tab
|
||||
_selected={{
|
||||
bg: activeBg,
|
||||
color: 'white',
|
||||
transform: 'scale(1.02)',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
mr={2}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaChartLine} />
|
||||
<Text>股票行情</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
<Tab
|
||||
_selected={{
|
||||
bg: activeBg,
|
||||
color: 'white',
|
||||
transform: 'scale(1.02)',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
mr={2}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaMoneyBillWave} />
|
||||
<Text>财务全景</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
<Tab
|
||||
_selected={{
|
||||
bg: activeBg,
|
||||
color: 'white',
|
||||
transform: 'scale(1.02)',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaChartBar} />
|
||||
<Text>盈利预测</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<Divider />
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel p={6}>
|
||||
<CompanyOverview stockCode={stockCode} />
|
||||
</TabPanel>
|
||||
<TabPanel p={6}>
|
||||
<MarketDataView stockCode={stockCode} />
|
||||
</TabPanel>
|
||||
<TabPanel p={6}>
|
||||
<FinancialPanorama stockCode={stockCode} />
|
||||
</TabPanel>
|
||||
<TabPanel p={6}>
|
||||
<ForecastReport stockCode={stockCode} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Container maxW="container.xl" py={0} bg='#1A202C'>
|
||||
<VStack align="stretch" spacing={0}>
|
||||
{/* 页面头部:标题、搜索 */}
|
||||
<CompanyHeader
|
||||
inputCode={inputCode}
|
||||
onInputChange={setInputCode}
|
||||
onSearch={handleSearch}
|
||||
onKeyDown={handleKeyDown}
|
||||
bgColor="#1A202C"
|
||||
/>
|
||||
|
||||
{/* 股票行情卡片:价格、关键指标、主力动态、公司信息 */}
|
||||
<StockQuoteCard
|
||||
data={quoteData}
|
||||
isLoading={isQuoteLoading}
|
||||
isInWatchlist={isInWatchlist}
|
||||
isWatchlistLoading={isWatchlistLoading}
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
basicInfo={basicInfo}
|
||||
/>
|
||||
|
||||
{/* Tab 切换区域:概览、行情、财务、预测 */}
|
||||
<CompanyTabs stockCode={stockCode} onTabChange={trackTabChanged} bgColor="#1A202C"/>
|
||||
</VStack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompanyIndex;
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user