Compare commits
106 Commits
1cd8a2d7e9
...
feature_20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a446f71c04 | ||
|
|
e02cbcd9b7 | ||
|
|
9bb9eab922 | ||
|
|
3d7b0045b7 | ||
|
|
ada9f6e778 | ||
|
|
07aebbece5 | ||
|
|
7a11800cba | ||
|
|
3b352be1a8 | ||
|
|
c49dee72eb | ||
|
|
7159e510a6 | ||
|
|
385d452f5a | ||
|
|
bdc823e122 | ||
|
|
c83d239219 | ||
|
|
c4900bd280 | ||
|
|
7736212235 | ||
|
|
348d8a0ec3 | ||
|
|
5a0d6e1569 | ||
|
|
bc2b6ae41c | ||
|
|
ac7e627b2d | ||
|
|
21e83ac1bc | ||
|
|
e2dd9e2648 | ||
|
|
f2463922f3 | ||
|
|
9aaad00f87 | ||
|
|
024126025d | ||
|
|
e2f9f3278f | ||
|
|
2d03c88f43 | ||
|
|
515b538c84 | ||
|
|
b52b54347d | ||
|
|
4954373b5b | ||
|
|
66cd6c3a29 | ||
|
|
ba99f55b16 | ||
|
|
2f69f83d16 | ||
|
|
3bd48e1ddd | ||
|
|
84914b3cca | ||
|
|
da455946a3 | ||
|
|
e734319ec4 | ||
|
|
faf2446203 | ||
|
|
83b24b6d54 | ||
|
|
ab7164681a | ||
|
|
bc6d370f55 | ||
|
|
42215b2d59 | ||
|
|
c34aa37731 | ||
|
|
2eb2a22495 | ||
|
|
6a4c475d3a | ||
|
|
e08b9d2104 | ||
|
|
3f1f438440 | ||
|
|
24720dbba0 | ||
|
|
7877c41e9c | ||
|
|
b25d48e167 | ||
|
|
804de885e1 | ||
|
|
6738a09e3a | ||
|
|
67340e9b82 | ||
|
|
00f2937a34 | ||
|
|
91ed649220 | ||
|
|
391955f88c | ||
|
|
59f4b1cdb9 | ||
|
|
3d6d01964d | ||
|
|
3f3e13bddd | ||
|
|
d27cf5b7d8 | ||
|
|
03bc2d681b | ||
|
|
1022fa4077 | ||
|
|
406b951e53 | ||
|
|
7f392619e7 | ||
|
|
09ca7265d7 | ||
|
|
276b280cb9 | ||
|
|
adfc0bd478 | ||
|
|
85a857dc19 | ||
|
|
b89837d22e | ||
|
|
942dd16800 | ||
|
|
35e3b66684 | ||
|
|
b9ea08e601 | ||
|
|
d9106bf9f7 | ||
|
|
fb42ef566b | ||
|
|
a424b3338d | ||
|
|
9e6e3ae322 | ||
|
|
e92cc09e06 | ||
|
|
23112db115 | ||
|
|
7c7c70c4d9 | ||
|
|
e049429b09 | ||
|
|
b8cd520014 | ||
|
|
96fe919164 | ||
|
|
4672a24353 | ||
|
|
26bc5fece0 | ||
|
|
1c35ea24cd | ||
|
|
d76b0d32d6 | ||
|
|
eb093a5189 | ||
|
|
2c0b06e6a0 | ||
|
|
b3fb472c66 | ||
|
|
6797f54b6c | ||
|
|
a47e0feed8 | ||
|
|
13fa91a998 | ||
|
|
fba7a7ee96 | ||
|
|
32a73efb55 | ||
|
|
7819b4f8a2 | ||
|
|
6f74c1c1de | ||
|
|
3fed9d2d65 | ||
|
|
514917c0eb | ||
|
|
6ce913d79b | ||
|
|
6d5594556b | ||
|
|
c32091e83e | ||
|
|
2994de98c2 | ||
|
|
c237a4dc0c | ||
|
|
395dc27fe2 | ||
|
|
3abee6b907 | ||
|
|
d86cef9f79 | ||
|
|
9aaf4400c1 |
110
.husky/pre-commit
Executable file
110
.husky/pre-commit
Executable file
@@ -0,0 +1,110 @@
|
||||
#!/bin/sh
|
||||
|
||||
# ============================================
|
||||
# Git Pre-commit Hook
|
||||
# ============================================
|
||||
# 规则:
|
||||
# 1. src 目录下新增的代码文件必须使用 TypeScript (.ts/.tsx)
|
||||
# 2. 修改的代码不能使用 fetch,应使用 axios
|
||||
# ============================================
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
has_error=0
|
||||
|
||||
echo ""
|
||||
echo "🔍 正在检查代码规范..."
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 规则 1: 新文件必须使用 TypeScript
|
||||
# ============================================
|
||||
|
||||
# 获取新增的文件(只检查 src 目录下的代码文件)
|
||||
new_js_files=$(git diff --cached --name-only --diff-filter=A | grep -E '^src/.*\.(js|jsx)$' || true)
|
||||
|
||||
if [ -n "$new_js_files" ]; then
|
||||
echo "${RED}❌ 错误: 发现新增的 JavaScript 文件${NC}"
|
||||
echo "${YELLOW} 新文件必须使用 TypeScript (.ts/.tsx)${NC}"
|
||||
echo ""
|
||||
echo " 以下文件需要改为 TypeScript:"
|
||||
echo "$new_js_files" | while read file; do
|
||||
echo " - $file"
|
||||
done
|
||||
echo ""
|
||||
echo " 💡 提示: 请将文件扩展名改为 .ts 或 .tsx"
|
||||
echo ""
|
||||
has_error=1
|
||||
fi
|
||||
|
||||
# ============================================
|
||||
# 规则 2: 禁止使用 fetch,应使用 axios
|
||||
# ============================================
|
||||
|
||||
# 获取所有暂存的文件(新增 + 修改)
|
||||
staged_files=$(git diff --cached --name-only --diff-filter=AM | grep -E '^src/.*\.(js|jsx|ts|tsx)$' || true)
|
||||
|
||||
if [ -n "$staged_files" ]; then
|
||||
# 检查暂存内容中是否包含 fetch 调用
|
||||
# 使用 git diff --cached 检查实际修改的内容
|
||||
fetch_found=""
|
||||
|
||||
for file in $staged_files; do
|
||||
# 检查该文件暂存的更改中是否有 fetch 调用
|
||||
# 排除注释和字符串中的 fetch
|
||||
# 匹配: fetch(, await fetch, .fetch(
|
||||
fetch_matches=$(git diff --cached -U0 "$file" 2>/dev/null | grep -E '^\+.*[^a-zA-Z_]fetch\s*\(' | grep -v '^\+\s*//' || true)
|
||||
|
||||
if [ -n "$fetch_matches" ]; then
|
||||
fetch_found="$fetch_found
|
||||
$file"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$fetch_found" ]; then
|
||||
echo "${RED}❌ 错误: 检测到使用了 fetch API${NC}"
|
||||
echo "${YELLOW} 请使用 axios 进行 HTTP 请求${NC}"
|
||||
echo ""
|
||||
echo " 以下文件包含 fetch 调用:"
|
||||
echo "$fetch_found" | while read file; do
|
||||
if [ -n "$file" ]; then
|
||||
echo " - $file"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
echo " 💡 修改建议:"
|
||||
echo " ${GREEN}// 替换前${NC}"
|
||||
echo " fetch('/api/data').then(res => res.json())"
|
||||
echo ""
|
||||
echo " ${GREEN}// 替换后${NC}"
|
||||
echo " import axios from 'axios';"
|
||||
echo " axios.get('/api/data').then(res => res.data)"
|
||||
echo ""
|
||||
has_error=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ============================================
|
||||
# 检查结果
|
||||
# ============================================
|
||||
|
||||
if [ $has_error -eq 1 ]; then
|
||||
echo "${RED}========================================${NC}"
|
||||
echo "${RED}提交被阻止,请修复以上问题后重试${NC}"
|
||||
echo "${RED}========================================${NC}"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${GREEN}✅ 代码规范检查通过${NC}"
|
||||
echo ""
|
||||
|
||||
# 运行 lint-staged(如果配置了)
|
||||
# 可选:在 package.json 中添加 "lint-staged" 配置来启用代码格式化
|
||||
# if [ -f "package.json" ] && grep -q '"lint-staged"' package.json; then
|
||||
# npx lint-staged
|
||||
# fi
|
||||
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'
|
||||
|
||||
# 微信回调地址
|
||||
|
||||
@@ -131,12 +131,14 @@
|
||||
"eslint-plugin-prettier": "3.4.0",
|
||||
"gulp": "4.0.2",
|
||||
"gulp-append-prepend": "1.0.9",
|
||||
"husky": "^9.1.7",
|
||||
"imagemin": "^9.0.1",
|
||||
"imagemin-mozjpeg": "^10.0.0",
|
||||
"imagemin-pngquant": "^10.0.0",
|
||||
"kill-port": "^2.0.1",
|
||||
"less": "^4.4.2",
|
||||
"less-loader": "^12.3.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"msw": "^2.11.5",
|
||||
"prettier": "2.2.1",
|
||||
"react-error-overlay": "6.0.9",
|
||||
|
||||
232
src/components/SubTabContainer/index.tsx
Normal file
232
src/components/SubTabContainer/index.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* SubTabContainer - 二级导航容器组件
|
||||
*
|
||||
* 用于模块内的子功能切换(如公司档案下的股权结构、管理团队等)
|
||||
* 与 TabContainer(一级导航)区分:无 Card 包裹,直接融入父容器
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SubTabContainer
|
||||
* tabs={[
|
||||
* { key: 'tab1', name: 'Tab 1', icon: FaHome, component: Tab1 },
|
||||
* { key: 'tab2', name: 'Tab 2', icon: FaUser, component: Tab2 },
|
||||
* ]}
|
||||
* componentProps={{ stockCode: '000001' }}
|
||||
* onTabChange={(index, key) => console.log('切换到', key)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Icon,
|
||||
HStack,
|
||||
Text,
|
||||
Spacer,
|
||||
} from '@chakra-ui/react';
|
||||
import type { ComponentType } from 'react';
|
||||
import type { IconType } from 'react-icons';
|
||||
|
||||
/**
|
||||
* Tab 配置项
|
||||
*/
|
||||
export interface SubTabConfig {
|
||||
key: string;
|
||||
name: string;
|
||||
icon?: IconType | ComponentType;
|
||||
component?: ComponentType<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题配置
|
||||
*/
|
||||
export interface SubTabTheme {
|
||||
bg: string;
|
||||
borderColor: string;
|
||||
tabSelectedBg: string;
|
||||
tabSelectedColor: string;
|
||||
tabUnselectedColor: string;
|
||||
tabHoverBg: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设主题
|
||||
*/
|
||||
const THEME_PRESETS: Record<string, SubTabTheme> = {
|
||||
blackGold: {
|
||||
bg: 'gray.900',
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
tabSelectedBg: '#D4AF37',
|
||||
tabSelectedColor: 'gray.900',
|
||||
tabUnselectedColor: '#D4AF37',
|
||||
tabHoverBg: 'gray.600',
|
||||
},
|
||||
default: {
|
||||
bg: 'white',
|
||||
borderColor: 'gray.200',
|
||||
tabSelectedBg: 'blue.500',
|
||||
tabSelectedColor: 'white',
|
||||
tabUnselectedColor: 'gray.600',
|
||||
tabHoverBg: 'gray.100',
|
||||
},
|
||||
};
|
||||
|
||||
export interface SubTabContainerProps {
|
||||
/** Tab 配置数组 */
|
||||
tabs: SubTabConfig[];
|
||||
/** 传递给 Tab 内容组件的 props */
|
||||
componentProps?: Record<string, any>;
|
||||
/** 默认选中的 Tab 索引 */
|
||||
defaultIndex?: number;
|
||||
/** 受控模式下的当前索引 */
|
||||
index?: number;
|
||||
/** Tab 变更回调 */
|
||||
onTabChange?: (index: number, tabKey: string) => void;
|
||||
/** 主题预设 */
|
||||
themePreset?: 'blackGold' | 'default';
|
||||
/** 自定义主题(优先级高于预设) */
|
||||
theme?: Partial<SubTabTheme>;
|
||||
/** 内容区内边距 */
|
||||
contentPadding?: number;
|
||||
/** 是否懒加载 */
|
||||
isLazy?: boolean;
|
||||
/** TabList 右侧自定义内容 */
|
||||
rightElement?: React.ReactNode;
|
||||
}
|
||||
|
||||
const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
||||
tabs,
|
||||
componentProps = {},
|
||||
defaultIndex = 0,
|
||||
index: controlledIndex,
|
||||
onTabChange,
|
||||
themePreset = 'blackGold',
|
||||
theme: customTheme,
|
||||
contentPadding = 4,
|
||||
isLazy = true,
|
||||
rightElement,
|
||||
}) => {
|
||||
// 内部状态(非受控模式)
|
||||
const [internalIndex, setInternalIndex] = useState(defaultIndex);
|
||||
|
||||
// 当前索引
|
||||
const currentIndex = controlledIndex ?? internalIndex;
|
||||
|
||||
// 记录已访问的 Tab 索引(用于真正的懒加载)
|
||||
const [visitedTabs, setVisitedTabs] = useState<Set<number>>(
|
||||
() => new Set([controlledIndex ?? defaultIndex])
|
||||
);
|
||||
|
||||
// 合并主题
|
||||
const theme: SubTabTheme = {
|
||||
...THEME_PRESETS[themePreset],
|
||||
...customTheme,
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 Tab 切换
|
||||
*/
|
||||
const handleTabChange = useCallback(
|
||||
(newIndex: number) => {
|
||||
const tabKey = tabs[newIndex]?.key || '';
|
||||
onTabChange?.(newIndex, tabKey);
|
||||
|
||||
// 记录已访问的 Tab(用于懒加载)
|
||||
setVisitedTabs(prev => {
|
||||
if (prev.has(newIndex)) return prev;
|
||||
return new Set(prev).add(newIndex);
|
||||
});
|
||||
|
||||
if (controlledIndex === undefined) {
|
||||
setInternalIndex(newIndex);
|
||||
}
|
||||
},
|
||||
[tabs, onTabChange, controlledIndex]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Tabs
|
||||
isLazy={isLazy}
|
||||
variant="unstyled"
|
||||
index={currentIndex}
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
<TabList
|
||||
bg={theme.bg}
|
||||
borderBottom="1px solid"
|
||||
borderColor={theme.borderColor}
|
||||
pl={0}
|
||||
pr={2}
|
||||
py={1.5}
|
||||
flexWrap="nowrap"
|
||||
gap={1}
|
||||
alignItems="center"
|
||||
overflowX="auto"
|
||||
css={{
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
scrollbarWidth: 'none',
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
color={theme.tabUnselectedColor}
|
||||
borderRadius="full"
|
||||
px={2.5}
|
||||
py={1.5}
|
||||
fontSize="xs"
|
||||
whiteSpace="nowrap"
|
||||
flexShrink={0}
|
||||
_selected={{
|
||||
bg: theme.tabSelectedBg,
|
||||
color: theme.tabSelectedColor,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
_hover={{
|
||||
bg: theme.tabHoverBg,
|
||||
}}
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
{tab.icon && <Icon as={tab.icon} boxSize={3} />}
|
||||
<Text>{tab.name}</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
))}
|
||||
{rightElement && (
|
||||
<>
|
||||
<Spacer />
|
||||
<Box flexShrink={0}>{rightElement}</Box>
|
||||
</>
|
||||
)}
|
||||
</TabList>
|
||||
|
||||
<TabPanels p={contentPadding}>
|
||||
{tabs.map((tab, idx) => {
|
||||
const Component = tab.component;
|
||||
// 懒加载:只渲染已访问过的 Tab
|
||||
const shouldRender = !isLazy || visitedTabs.has(idx);
|
||||
|
||||
return (
|
||||
<TabPanel key={tab.key} p={0}>
|
||||
{shouldRender && Component ? (
|
||||
<Component {...componentProps} />
|
||||
) : null}
|
||||
</TabPanel>
|
||||
);
|
||||
})}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
SubTabContainer.displayName = 'SubTabContainer';
|
||||
|
||||
export default SubTabContainer;
|
||||
56
src/components/TabContainer/TabNavigation.tsx
Normal file
56
src/components/TabContainer/TabNavigation.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* TabNavigation 通用导航组件
|
||||
*
|
||||
* 渲染 Tab 按钮列表,支持图标 + 文字
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { TabList, Tab, HStack, Icon, Text } from '@chakra-ui/react';
|
||||
import type { TabNavigationProps } from './types';
|
||||
|
||||
const TabNavigation: React.FC<TabNavigationProps> = ({
|
||||
tabs,
|
||||
themeColors,
|
||||
borderRadius = 'lg',
|
||||
}) => {
|
||||
return (
|
||||
<TabList
|
||||
bg={themeColors.bg}
|
||||
borderBottom="1px solid"
|
||||
borderColor={themeColors.dividerColor}
|
||||
borderTopLeftRadius={borderRadius}
|
||||
borderTopRightRadius={borderRadius}
|
||||
pl={0}
|
||||
pr={4}
|
||||
py={2}
|
||||
flexWrap="wrap"
|
||||
gap={2}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
color={themeColors.unselectedText}
|
||||
borderRadius="full"
|
||||
px={4}
|
||||
py={2}
|
||||
fontSize="sm"
|
||||
_selected={{
|
||||
bg: themeColors.selectedBg,
|
||||
color: themeColors.selectedText,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
_hover={{
|
||||
bg: 'whiteAlpha.100',
|
||||
}}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
{tab.icon && <Icon as={tab.icon} boxSize={4} />}
|
||||
<Text>{tab.name}</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabNavigation;
|
||||
55
src/components/TabContainer/constants.ts
Normal file
55
src/components/TabContainer/constants.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* TabContainer 常量和主题预设
|
||||
*/
|
||||
|
||||
import type { ThemeColors, ThemePreset } from './types';
|
||||
|
||||
/**
|
||||
* 主题预设配置
|
||||
*/
|
||||
export const THEME_PRESETS: Record<ThemePreset, Required<ThemeColors>> = {
|
||||
// 黑金主题(原 Company 模块风格)
|
||||
blackGold: {
|
||||
bg: '#1A202C',
|
||||
selectedBg: '#C9A961',
|
||||
selectedText: '#FFFFFF',
|
||||
unselectedText: '#D4AF37',
|
||||
dividerColor: 'gray.600',
|
||||
},
|
||||
// 默认主题(Chakra 风格)
|
||||
default: {
|
||||
bg: 'white',
|
||||
selectedBg: 'blue.500',
|
||||
selectedText: 'white',
|
||||
unselectedText: 'gray.600',
|
||||
dividerColor: 'gray.200',
|
||||
},
|
||||
// 深色主题
|
||||
dark: {
|
||||
bg: 'gray.800',
|
||||
selectedBg: 'blue.400',
|
||||
selectedText: 'white',
|
||||
unselectedText: 'gray.300',
|
||||
dividerColor: 'gray.600',
|
||||
},
|
||||
// 浅色主题
|
||||
light: {
|
||||
bg: 'gray.50',
|
||||
selectedBg: 'blue.500',
|
||||
selectedText: 'white',
|
||||
unselectedText: 'gray.700',
|
||||
dividerColor: 'gray.300',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认配置
|
||||
*/
|
||||
export const DEFAULT_CONFIG = {
|
||||
themePreset: 'blackGold' as ThemePreset,
|
||||
isLazy: true,
|
||||
size: 'lg' as const,
|
||||
borderRadius: 'lg',
|
||||
shadow: 'lg',
|
||||
panelPadding: 0,
|
||||
};
|
||||
134
src/components/TabContainer/index.tsx
Normal file
134
src/components/TabContainer/index.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* TabContainer 通用 Tab 容器组件
|
||||
*
|
||||
* 功能:
|
||||
* - 管理 Tab 切换状态(支持受控/非受控模式)
|
||||
* - 动态渲染 Tab 导航和内容
|
||||
* - 支持多种主题预设(黑金、默认、深色、浅色)
|
||||
* - 支持自定义主题颜色
|
||||
* - 支持懒加载
|
||||
*
|
||||
* @example
|
||||
* // 基础用法(传入 components)
|
||||
* <TabContainer
|
||||
* tabs={[
|
||||
* { key: 'tab1', name: 'Tab 1', icon: FaHome, component: Tab1Content },
|
||||
* { key: 'tab2', name: 'Tab 2', icon: FaUser, component: Tab2Content },
|
||||
* ]}
|
||||
* componentProps={{ userId: '123' }}
|
||||
* onTabChange={(index, key) => console.log('切换到', key)}
|
||||
* />
|
||||
*
|
||||
* @example
|
||||
* // 自定义渲染用法(使用 children)
|
||||
* <TabContainer tabs={tabs} themePreset="dark">
|
||||
* <TabPanel>自定义内容 1</TabPanel>
|
||||
* <TabPanel>自定义内容 2</TabPanel>
|
||||
* </TabContainer>
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
Tabs,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import TabNavigation from './TabNavigation';
|
||||
import { THEME_PRESETS, DEFAULT_CONFIG } from './constants';
|
||||
import type { TabContainerProps, ThemeColors } from './types';
|
||||
|
||||
// 导出类型和常量
|
||||
export type { TabConfig, ThemeColors, ThemePreset, TabContainerProps } from './types';
|
||||
export { THEME_PRESETS } from './constants';
|
||||
|
||||
const TabContainer: React.FC<TabContainerProps> = ({
|
||||
tabs,
|
||||
componentProps = {},
|
||||
onTabChange,
|
||||
defaultIndex = 0,
|
||||
index: controlledIndex,
|
||||
themePreset = DEFAULT_CONFIG.themePreset,
|
||||
themeColors: customThemeColors,
|
||||
isLazy = DEFAULT_CONFIG.isLazy,
|
||||
size = DEFAULT_CONFIG.size,
|
||||
borderRadius = DEFAULT_CONFIG.borderRadius,
|
||||
shadow = DEFAULT_CONFIG.shadow,
|
||||
panelPadding = DEFAULT_CONFIG.panelPadding,
|
||||
children,
|
||||
}) => {
|
||||
// 内部状态(非受控模式)
|
||||
const [internalIndex, setInternalIndex] = useState(defaultIndex);
|
||||
|
||||
// 当前索引(支持受控/非受控)
|
||||
const currentIndex = controlledIndex ?? internalIndex;
|
||||
|
||||
// 合并主题颜色(自定义颜色优先)
|
||||
const themeColors: Required<ThemeColors> = useMemo(() => ({
|
||||
...THEME_PRESETS[themePreset],
|
||||
...customThemeColors,
|
||||
}), [themePreset, customThemeColors]);
|
||||
|
||||
/**
|
||||
* 处理 Tab 切换
|
||||
*/
|
||||
const handleTabChange = useCallback((newIndex: number) => {
|
||||
const tabKey = tabs[newIndex]?.key || '';
|
||||
|
||||
// 触发回调
|
||||
onTabChange?.(newIndex, tabKey, currentIndex);
|
||||
|
||||
// 非受控模式下更新内部状态
|
||||
if (controlledIndex === undefined) {
|
||||
setInternalIndex(newIndex);
|
||||
}
|
||||
}, [tabs, onTabChange, currentIndex, controlledIndex]);
|
||||
|
||||
/**
|
||||
* 渲染 Tab 内容
|
||||
*/
|
||||
const renderTabPanels = () => {
|
||||
// 如果传入了 children,直接渲染 children
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// 否则根据 tabs 配置渲染
|
||||
return tabs.map((tab) => {
|
||||
const Component = tab.component;
|
||||
return (
|
||||
<TabPanel key={tab.key} px={panelPadding} py={panelPadding}>
|
||||
{Component ? <Component {...componentProps} /> : null}
|
||||
</TabPanel>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card shadow={shadow} bg={themeColors.bg} borderRadius={borderRadius}>
|
||||
<CardBody p={0}>
|
||||
<Tabs
|
||||
isLazy={isLazy}
|
||||
variant="unstyled"
|
||||
size={size}
|
||||
index={currentIndex}
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
{/* Tab 导航 */}
|
||||
<TabNavigation
|
||||
tabs={tabs}
|
||||
themeColors={themeColors}
|
||||
borderRadius={borderRadius}
|
||||
/>
|
||||
|
||||
{/* Tab 内容面板 */}
|
||||
<TabPanels>{renderTabPanels()}</TabPanels>
|
||||
</Tabs>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabContainer;
|
||||
85
src/components/TabContainer/types.ts
Normal file
85
src/components/TabContainer/types.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* TabContainer 通用 Tab 容器组件类型定义
|
||||
*/
|
||||
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import type { IconType } from 'react-icons';
|
||||
|
||||
/**
|
||||
* Tab 配置项
|
||||
*/
|
||||
export interface TabConfig {
|
||||
/** Tab 唯一标识 */
|
||||
key: string;
|
||||
/** Tab 显示名称 */
|
||||
name: string;
|
||||
/** Tab 图标(可选) */
|
||||
icon?: IconType | ComponentType;
|
||||
/** Tab 内容组件(可选,如果不传则使用 children 渲染) */
|
||||
component?: ComponentType<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题颜色配置
|
||||
*/
|
||||
export interface ThemeColors {
|
||||
/** 容器背景色 */
|
||||
bg?: string;
|
||||
/** 选中 Tab 背景色 */
|
||||
selectedBg?: string;
|
||||
/** 选中 Tab 文字颜色 */
|
||||
selectedText?: string;
|
||||
/** 未选中 Tab 文字颜色 */
|
||||
unselectedText?: string;
|
||||
/** 分割线颜色 */
|
||||
dividerColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设主题类型
|
||||
*/
|
||||
export type ThemePreset = 'blackGold' | 'default' | 'dark' | 'light';
|
||||
|
||||
/**
|
||||
* TabContainer 组件 Props
|
||||
*/
|
||||
export interface TabContainerProps {
|
||||
/** Tab 配置数组 */
|
||||
tabs: TabConfig[];
|
||||
/** 传递给 Tab 内容组件的通用 props */
|
||||
componentProps?: Record<string, any>;
|
||||
/** Tab 变更回调 */
|
||||
onTabChange?: (index: number, tabKey: string, prevIndex: number) => void;
|
||||
/** 默认选中的 Tab 索引 */
|
||||
defaultIndex?: number;
|
||||
/** 受控模式下的当前索引 */
|
||||
index?: number;
|
||||
/** 主题预设 */
|
||||
themePreset?: ThemePreset;
|
||||
/** 自定义主题颜色(优先级高于预设) */
|
||||
themeColors?: ThemeColors;
|
||||
/** 是否启用懒加载 */
|
||||
isLazy?: boolean;
|
||||
/** Tab 尺寸 */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** 容器圆角 */
|
||||
borderRadius?: string;
|
||||
/** 容器阴影 */
|
||||
shadow?: string;
|
||||
/** 自定义 Tab 面板内边距 */
|
||||
panelPadding?: number | string;
|
||||
/** 子元素(用于自定义渲染 Tab 内容) */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* TabNavigation 组件 Props
|
||||
*/
|
||||
export interface TabNavigationProps {
|
||||
/** Tab 配置数组 */
|
||||
tabs: TabConfig[];
|
||||
/** 主题颜色 */
|
||||
themeColors: Required<ThemeColors>;
|
||||
/** 容器圆角 */
|
||||
borderRadius?: string;
|
||||
}
|
||||
100
src/components/TabPanelContainer/index.tsx
Normal file
100
src/components/TabPanelContainer/index.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* TabPanelContainer - Tab 面板通用容器组件
|
||||
*
|
||||
* 提供统一的:
|
||||
* - Loading 状态处理
|
||||
* - VStack 布局
|
||||
* - 免责声明(可选)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <TabPanelContainer loading={loading} showDisclaimer>
|
||||
* <YourContent />
|
||||
* </TabPanelContainer>
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { VStack, Center, Spinner, Text, Box } from '@chakra-ui/react';
|
||||
|
||||
// 默认免责声明文案
|
||||
const DEFAULT_DISCLAIMER =
|
||||
'免责声明:本内容由AI模型基于新闻、公告、研报等公开信息自动分析和生成,未经许可严禁转载。所有内容仅供参考,不构成任何投资建议,请投资者注意风险,独立审慎决策。';
|
||||
|
||||
export interface TabPanelContainerProps {
|
||||
/** 是否处于加载状态 */
|
||||
loading?: boolean;
|
||||
/** 加载状态显示的文案 */
|
||||
loadingMessage?: string;
|
||||
/** 加载状态高度 */
|
||||
loadingHeight?: string;
|
||||
/** 子组件间距,默认 6 */
|
||||
spacing?: number;
|
||||
/** 内边距,默认 4 */
|
||||
padding?: number;
|
||||
/** 是否显示免责声明,默认 false */
|
||||
showDisclaimer?: boolean;
|
||||
/** 自定义免责声明文案 */
|
||||
disclaimerText?: string;
|
||||
/** 子组件 */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载状态组件
|
||||
*/
|
||||
const LoadingState: React.FC<{ message: string; height: string }> = ({
|
||||
message,
|
||||
height,
|
||||
}) => (
|
||||
<Center h={height}>
|
||||
<VStack spacing={3}>
|
||||
<Spinner size="lg" color="#D4AF37" thickness="3px" />
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{message}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
|
||||
/**
|
||||
* 免责声明组件
|
||||
*/
|
||||
const DisclaimerText: React.FC<{ text: string }> = ({ text }) => (
|
||||
<Text mt={4} color="gray.500" fontSize="12px" lineHeight="1.5">
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
|
||||
/**
|
||||
* Tab 面板通用容器
|
||||
*/
|
||||
const TabPanelContainer: React.FC<TabPanelContainerProps> = memo(
|
||||
({
|
||||
loading = false,
|
||||
loadingMessage = '加载中...',
|
||||
loadingHeight = '200px',
|
||||
spacing = 6,
|
||||
padding = 4,
|
||||
showDisclaimer = false,
|
||||
disclaimerText = DEFAULT_DISCLAIMER,
|
||||
children,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return <LoadingState message={loadingMessage} height={loadingHeight} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={padding}>
|
||||
<VStack spacing={spacing} align="stretch">
|
||||
{children}
|
||||
</VStack>
|
||||
{showDisclaimer && <DisclaimerText text={disclaimerText} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TabPanelContainer.displayName = 'TabPanelContainer';
|
||||
|
||||
export default TabPanelContainer;
|
||||
@@ -661,6 +661,12 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
// ========== 连接到 Socket 服务(⚡ 异步初始化,不阻塞首屏) ==========
|
||||
useEffect(() => {
|
||||
// ⚡ Mock 模式下跳过 Socket 连接(避免连接生产服务器失败的错误)
|
||||
if (process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
logger.debug('NotificationContext', 'Mock 模式,跳过 Socket 连接');
|
||||
return;
|
||||
}
|
||||
|
||||
// ⚡ 防止 React Strict Mode 导致的重复初始化
|
||||
if (socketInitialized) {
|
||||
logger.debug('NotificationContext', 'Socket 已初始化,跳过重复执行(Strict Mode 保护)');
|
||||
|
||||
11
src/index.js
11
src/index.js
@@ -5,6 +5,17 @@ import { BrowserRouter as Router } from 'react-router-dom';
|
||||
|
||||
// ⚡ 性能监控:在应用启动时尽早标记
|
||||
import { performanceMonitor } from './utils/performanceMonitor';
|
||||
|
||||
// T0: HTML 加载完成时间点
|
||||
if (document.readyState === 'complete') {
|
||||
performanceMonitor.mark('html-loaded');
|
||||
} else {
|
||||
window.addEventListener('load', () => {
|
||||
performanceMonitor.mark('html-loaded');
|
||||
});
|
||||
}
|
||||
|
||||
// T1: React 开始初始化
|
||||
performanceMonitor.mark('app-start');
|
||||
|
||||
// ⚡ 已删除 brainwave.css(项目未安装 Tailwind CSS,该文件无效)
|
||||
|
||||
@@ -42,180 +42,412 @@ export const PINGAN_BANK_DATA = {
|
||||
employees: 42099,
|
||||
},
|
||||
|
||||
// 实际控制人信息
|
||||
actualControl: {
|
||||
controller_name: '中国平安保险(集团)股份有限公司',
|
||||
controller_type: '企业',
|
||||
shareholding_ratio: 52.38,
|
||||
control_chain: '中国平安保险(集团)股份有限公司 -> 平安银行股份有限公司',
|
||||
is_listed: true,
|
||||
change_date: '2023-12-31',
|
||||
remark: '中国平安通过直接和间接方式控股平安银行',
|
||||
// 市场概览数据 - StockSummaryCard 使用
|
||||
marketSummary: {
|
||||
stock_code: '000001',
|
||||
stock_name: '平安银行',
|
||||
latest_trade: {
|
||||
close: 11.28,
|
||||
change_percent: 2.35,
|
||||
volume: 58623400,
|
||||
amount: 659800000,
|
||||
turnover_rate: 0.30,
|
||||
pe_ratio: 4.92
|
||||
},
|
||||
latest_funding: {
|
||||
financing_balance: 5823000000,
|
||||
securities_balance: 125600000
|
||||
},
|
||||
latest_pledge: {
|
||||
pledge_ratio: 8.25
|
||||
}
|
||||
},
|
||||
|
||||
// 股权集中度
|
||||
concentration: {
|
||||
top1_ratio: 52.38,
|
||||
top3_ratio: 58.42,
|
||||
top5_ratio: 60.15,
|
||||
top10_ratio: 63.28,
|
||||
update_date: '2024-09-30',
|
||||
concentration_level: '高度集中',
|
||||
herfindahl_index: 0.2845,
|
||||
// 当日分钟K线数据 - MinuteKLineChart 使用
|
||||
minuteData: {
|
||||
code: '000001',
|
||||
name: '平安银行',
|
||||
trade_date: '2024-12-12',
|
||||
type: '1min',
|
||||
data: [
|
||||
// 上午交易时段 9:30 - 11:30
|
||||
{ time: '09:30', open: 11.02, close: 11.05, high: 11.06, low: 11.01, volume: 1856000, amount: 20458000 },
|
||||
{ time: '09:31', open: 11.05, close: 11.08, high: 11.09, low: 11.04, volume: 1423000, amount: 15782000 },
|
||||
{ time: '09:32', open: 11.08, close: 11.06, high: 11.10, low: 11.05, volume: 1125000, amount: 12468000 },
|
||||
{ time: '09:33', open: 11.06, close: 11.10, high: 11.11, low: 11.05, volume: 1678000, amount: 18623000 },
|
||||
{ time: '09:34', open: 11.10, close: 11.12, high: 11.14, low: 11.09, volume: 2134000, amount: 23725000 },
|
||||
{ time: '09:35', open: 11.12, close: 11.15, high: 11.16, low: 11.11, volume: 1892000, amount: 21082000 },
|
||||
{ time: '09:40', open: 11.15, close: 11.18, high: 11.20, low: 11.14, volume: 1567000, amount: 17523000 },
|
||||
{ time: '09:45', open: 11.18, close: 11.16, high: 11.19, low: 11.15, volume: 1234000, amount: 13782000 },
|
||||
{ time: '09:50', open: 11.16, close: 11.20, high: 11.21, low: 11.15, volume: 1456000, amount: 16298000 },
|
||||
{ time: '09:55', open: 11.20, close: 11.22, high: 11.24, low: 11.19, volume: 1789000, amount: 20068000 },
|
||||
{ time: '10:00', open: 11.22, close: 11.25, high: 11.26, low: 11.21, volume: 2012000, amount: 22635000 },
|
||||
{ time: '10:10', open: 11.25, close: 11.23, high: 11.26, low: 11.22, volume: 1345000, amount: 15123000 },
|
||||
{ time: '10:20', open: 11.23, close: 11.26, high: 11.28, low: 11.22, volume: 1678000, amount: 18912000 },
|
||||
{ time: '10:30', open: 11.26, close: 11.24, high: 11.27, low: 11.23, volume: 1123000, amount: 12645000 },
|
||||
{ time: '10:40', open: 11.24, close: 11.27, high: 11.28, low: 11.23, volume: 1456000, amount: 16412000 },
|
||||
{ time: '10:50', open: 11.27, close: 11.25, high: 11.28, low: 11.24, volume: 1234000, amount: 13902000 },
|
||||
{ time: '11:00', open: 11.25, close: 11.28, high: 11.30, low: 11.24, volume: 1567000, amount: 17689000 },
|
||||
{ time: '11:10', open: 11.28, close: 11.26, high: 11.29, low: 11.25, volume: 1089000, amount: 12278000 },
|
||||
{ time: '11:20', open: 11.26, close: 11.28, high: 11.29, low: 11.25, volume: 1234000, amount: 13912000 },
|
||||
{ time: '11:30', open: 11.28, close: 11.27, high: 11.29, low: 11.26, volume: 987000, amount: 11134000 },
|
||||
// 下午交易时段 13:00 - 15:00
|
||||
{ time: '13:00', open: 11.27, close: 11.30, high: 11.31, low: 11.26, volume: 1456000, amount: 16456000 },
|
||||
{ time: '13:10', open: 11.30, close: 11.28, high: 11.31, low: 11.27, volume: 1123000, amount: 12689000 },
|
||||
{ time: '13:20', open: 11.28, close: 11.32, high: 11.33, low: 11.27, volume: 1789000, amount: 20245000 },
|
||||
{ time: '13:30', open: 11.32, close: 11.30, high: 11.33, low: 11.29, volume: 1345000, amount: 15212000 },
|
||||
{ time: '13:40', open: 11.30, close: 11.33, high: 11.35, low: 11.29, volume: 1678000, amount: 18978000 },
|
||||
{ time: '13:50', open: 11.33, close: 11.31, high: 11.34, low: 11.30, volume: 1234000, amount: 13956000 },
|
||||
{ time: '14:00', open: 11.31, close: 11.34, high: 11.36, low: 11.30, volume: 1567000, amount: 17789000 },
|
||||
{ time: '14:10', open: 11.34, close: 11.32, high: 11.35, low: 11.31, volume: 1123000, amount: 12712000 },
|
||||
{ time: '14:20', open: 11.32, close: 11.30, high: 11.33, low: 11.29, volume: 1456000, amount: 16478000 },
|
||||
{ time: '14:30', open: 11.30, close: 11.28, high: 11.31, low: 11.27, volume: 1678000, amount: 18956000 },
|
||||
{ time: '14:40', open: 11.28, close: 11.26, high: 11.29, low: 11.25, volume: 1345000, amount: 15167000 },
|
||||
{ time: '14:50', open: 11.26, close: 11.28, high: 11.30, low: 11.25, volume: 1892000, amount: 21345000 },
|
||||
{ time: '15:00', open: 11.28, close: 11.28, high: 11.29, low: 11.27, volume: 2345000, amount: 26478000 }
|
||||
]
|
||||
},
|
||||
|
||||
// 高管信息
|
||||
// 实际控制人信息(数组格式)
|
||||
actualControl: [
|
||||
{
|
||||
actual_controller_name: '中国平安保险(集团)股份有限公司',
|
||||
controller_name: '中国平安保险(集团)股份有限公司',
|
||||
control_type: '企业法人',
|
||||
controller_type: '企业',
|
||||
holding_ratio: 52.38,
|
||||
holding_shares: 10168542300,
|
||||
end_date: '2024-09-30',
|
||||
control_chain: '中国平安保险(集团)股份有限公司 -> 平安银行股份有限公司',
|
||||
is_listed: true,
|
||||
remark: '中国平安通过直接和间接方式控股平安银行',
|
||||
}
|
||||
],
|
||||
|
||||
// 股权集中度(数组格式,按统计项分组)
|
||||
concentration: [
|
||||
{ stat_item: '前1大股东', holding_ratio: 52.38, ratio_change: 0.00, end_date: '2024-09-30' },
|
||||
{ stat_item: '前3大股东', holding_ratio: 58.42, ratio_change: 0.15, end_date: '2024-09-30' },
|
||||
{ stat_item: '前5大股东', holding_ratio: 60.15, ratio_change: 0.22, end_date: '2024-09-30' },
|
||||
{ stat_item: '前10大股东', holding_ratio: 63.28, ratio_change: 0.35, end_date: '2024-09-30' },
|
||||
{ stat_item: '前1大股东', holding_ratio: 52.38, ratio_change: -0.12, end_date: '2024-06-30' },
|
||||
{ stat_item: '前3大股东', holding_ratio: 58.27, ratio_change: -0.08, end_date: '2024-06-30' },
|
||||
{ stat_item: '前5大股东', holding_ratio: 59.93, ratio_change: -0.15, end_date: '2024-06-30' },
|
||||
{ stat_item: '前10大股东', holding_ratio: 62.93, ratio_change: -0.22, end_date: '2024-06-30' },
|
||||
],
|
||||
|
||||
// 高管信息(包含高管、董事、监事、其他)
|
||||
management: [
|
||||
// === 高管 ===
|
||||
{
|
||||
name: '谢永林',
|
||||
position: '董事长',
|
||||
position_name: '董事长',
|
||||
position_category: '高管',
|
||||
gender: '男',
|
||||
age: 56,
|
||||
birth_year: '1968',
|
||||
education: '硕士',
|
||||
appointment_date: '2019-01-01',
|
||||
annual_compensation: 723.8,
|
||||
shareholding: 0,
|
||||
background: '中国平安保险(集团)股份有限公司副总经理兼首席保险业务执行官',
|
||||
nationality: '中国',
|
||||
start_date: '2019-01-01',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: '冀光恒',
|
||||
position: '行长',
|
||||
position_name: '行长',
|
||||
position_category: '高管',
|
||||
gender: '男',
|
||||
age: 52,
|
||||
birth_year: '1972',
|
||||
education: '博士',
|
||||
appointment_date: '2023-08-01',
|
||||
annual_compensation: 650.5,
|
||||
shareholding: 0,
|
||||
background: '原中国工商银行总行部门总经理',
|
||||
nationality: '中国',
|
||||
start_date: '2023-08-01',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: '周强',
|
||||
position: '执行董事、副行长、董事会秘书',
|
||||
position_name: '副行长、董事会秘书',
|
||||
position_category: '高管',
|
||||
gender: '男',
|
||||
age: 54,
|
||||
birth_year: '1970',
|
||||
education: '硕士',
|
||||
appointment_date: '2016-06-01',
|
||||
annual_compensation: 542.3,
|
||||
shareholding: 0.002,
|
||||
background: '历任平安银行深圳分行行长',
|
||||
nationality: '中国',
|
||||
start_date: '2016-06-01',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: '郭世邦',
|
||||
position: '执行董事、副行长、首席财务官',
|
||||
position_name: '副行长、首席财务官',
|
||||
position_category: '高管',
|
||||
gender: '男',
|
||||
age: 52,
|
||||
birth_year: '1972',
|
||||
education: '博士',
|
||||
appointment_date: '2018-03-01',
|
||||
annual_compensation: 498.6,
|
||||
shareholding: 0.001,
|
||||
background: '历任中国平安集团财务负责人',
|
||||
nationality: '中国',
|
||||
start_date: '2018-03-01',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: '项有志',
|
||||
position: '副行长、首席信息官',
|
||||
position_name: '副行长、首席信息官',
|
||||
position_category: '高管',
|
||||
gender: '男',
|
||||
age: 49,
|
||||
birth_year: '1975',
|
||||
education: '硕士',
|
||||
appointment_date: '2019-09-01',
|
||||
annual_compensation: 425.1,
|
||||
shareholding: 0,
|
||||
background: '历任中国平安科技公司总经理',
|
||||
nationality: '中国',
|
||||
start_date: '2019-09-01',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: '张小璐',
|
||||
position_name: '副行长、首席风险官',
|
||||
position_category: '高管',
|
||||
gender: '女',
|
||||
birth_year: '1973',
|
||||
education: '硕士',
|
||||
nationality: '中国',
|
||||
start_date: '2020-03-15',
|
||||
status: 'active'
|
||||
},
|
||||
// === 董事 ===
|
||||
{
|
||||
name: '马明哲',
|
||||
position_name: '非执行董事',
|
||||
position_category: '董事',
|
||||
gender: '男',
|
||||
birth_year: '1955',
|
||||
education: '博士',
|
||||
nationality: '中国',
|
||||
start_date: '2012-06-15',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: '孙建一',
|
||||
position_name: '非执行董事',
|
||||
position_category: '董事',
|
||||
gender: '男',
|
||||
birth_year: '1960',
|
||||
education: '硕士',
|
||||
nationality: '中国',
|
||||
start_date: '2016-08-20',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: '陈心颖',
|
||||
position_name: '非执行董事',
|
||||
position_category: '董事',
|
||||
gender: '女',
|
||||
birth_year: '1977',
|
||||
education: '硕士',
|
||||
nationality: '新加坡',
|
||||
start_date: '2018-06-01',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: '黄宝新',
|
||||
position_name: '独立非执行董事',
|
||||
position_category: '董事',
|
||||
gender: '男',
|
||||
birth_year: '1962',
|
||||
education: '博士',
|
||||
nationality: '中国',
|
||||
start_date: '2019-06-20',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: '王志良',
|
||||
position_name: '独立非执行董事',
|
||||
position_category: '董事',
|
||||
gender: '男',
|
||||
birth_year: '1958',
|
||||
education: '博士',
|
||||
nationality: '美国',
|
||||
start_date: '2020-06-18',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: '李曙光',
|
||||
position_name: '独立非执行董事',
|
||||
position_category: '董事',
|
||||
gender: '男',
|
||||
birth_year: '1963',
|
||||
education: '博士',
|
||||
nationality: '中国',
|
||||
start_date: '2021-06-25',
|
||||
status: 'active'
|
||||
},
|
||||
// === 监事 ===
|
||||
{
|
||||
name: '王选庆',
|
||||
position_name: '监事会主席',
|
||||
position_category: '监事',
|
||||
gender: '男',
|
||||
birth_year: '1965',
|
||||
education: '硕士',
|
||||
nationality: '中国',
|
||||
start_date: '2017-06-15',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: '杨峻',
|
||||
position_name: '职工监事',
|
||||
position_category: '监事',
|
||||
gender: '男',
|
||||
birth_year: '1970',
|
||||
education: '本科',
|
||||
nationality: '中国',
|
||||
start_date: '2019-06-20',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: '刘春华',
|
||||
position_name: '外部监事',
|
||||
position_category: '监事',
|
||||
gender: '女',
|
||||
birth_year: '1968',
|
||||
education: '硕士',
|
||||
nationality: '中国',
|
||||
start_date: '2020-06-18',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: '张伟民',
|
||||
position_name: '外部监事',
|
||||
position_category: '监事',
|
||||
gender: '男',
|
||||
birth_year: '1966',
|
||||
education: '博士',
|
||||
nationality: '中国',
|
||||
start_date: '2021-06-25',
|
||||
status: 'active'
|
||||
},
|
||||
// === 其他 ===
|
||||
{
|
||||
name: '陈敏',
|
||||
position_name: '合规总监',
|
||||
position_category: '其他',
|
||||
gender: '女',
|
||||
birth_year: '1975',
|
||||
education: '硕士',
|
||||
nationality: '中国',
|
||||
start_date: '2018-09-01',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: '李明',
|
||||
position_name: '审计部总经理',
|
||||
position_category: '其他',
|
||||
gender: '男',
|
||||
birth_year: '1978',
|
||||
education: '硕士',
|
||||
nationality: '中国',
|
||||
start_date: '2019-03-15',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: '王建国',
|
||||
position_name: '法务部总经理',
|
||||
position_category: '其他',
|
||||
gender: '男',
|
||||
birth_year: '1972',
|
||||
education: '博士',
|
||||
nationality: '中国',
|
||||
start_date: '2017-06-01',
|
||||
status: 'active'
|
||||
}
|
||||
],
|
||||
|
||||
// 十大流通股东
|
||||
// 十大流通股东(字段名与组件期望格式匹配)
|
||||
topCirculationShareholders: [
|
||||
{ shareholder_name: '中国平安保险(集团)股份有限公司', shares: 10168542300, ratio: 52.38, change: 0, shareholder_type: '企业' },
|
||||
{ shareholder_name: '香港中央结算有限公司', shares: 542138600, ratio: 2.79, change: 12450000, shareholder_type: '境外法人' },
|
||||
{ shareholder_name: '深圳市投资控股有限公司', shares: 382456100, ratio: 1.97, change: 0, shareholder_type: '国有企业' },
|
||||
{ shareholder_name: '中国证券金融股份有限公司', shares: 298654200, ratio: 1.54, change: -5000000, shareholder_type: '证金公司' },
|
||||
{ shareholder_name: '中央汇金资产管理有限责任公司', shares: 267842100, ratio: 1.38, change: 0, shareholder_type: '中央汇金' },
|
||||
{ shareholder_name: '全国社保基金一零三组合', shares: 156234500, ratio: 0.80, change: 23400000, shareholder_type: '社保基金' },
|
||||
{ shareholder_name: '全国社保基金一零一组合', shares: 142356700, ratio: 0.73, change: 15600000, shareholder_type: '社保基金' },
|
||||
{ shareholder_name: '中国人寿保险股份有限公司', shares: 128945600, ratio: 0.66, change: 0, shareholder_type: '保险公司' },
|
||||
{ shareholder_name: 'GIC PRIVATE LIMITED', shares: 98765400, ratio: 0.51, change: -8900000, shareholder_type: '境外法人' },
|
||||
{ shareholder_name: '挪威中央银行', shares: 87654300, ratio: 0.45, change: 5600000, shareholder_type: '境外法人' }
|
||||
{ shareholder_rank: 1, shareholder_name: '中国平安保险(集团)股份有限公司', holding_shares: 10168542300, circulation_share_ratio: 52.38, shareholder_type: '法人', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 2, shareholder_name: '香港中央结算有限公司(陆股通)', holding_shares: 542138600, circulation_share_ratio: 2.79, shareholder_type: 'QFII', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 3, shareholder_name: '中国平安人寿保险股份有限公司-传统-普通保险产品', holding_shares: 382456100, circulation_share_ratio: 1.97, shareholder_type: '保险', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 4, shareholder_name: '中国证券金融股份有限公司', holding_shares: 298654200, circulation_share_ratio: 1.54, shareholder_type: '券商', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 5, shareholder_name: '中央汇金资产管理有限责任公司', holding_shares: 267842100, circulation_share_ratio: 1.38, shareholder_type: '法人', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 6, shareholder_name: '全国社保基金一零三组合', holding_shares: 156234500, circulation_share_ratio: 0.80, shareholder_type: '社保', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 7, shareholder_name: '华夏上证50交易型开放式指数证券投资基金', holding_shares: 142356700, circulation_share_ratio: 0.73, shareholder_type: '基金', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 8, shareholder_name: '中国人寿保险股份有限公司-分红-个人分红-005L-FH002深', holding_shares: 128945600, circulation_share_ratio: 0.66, shareholder_type: '保险', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 9, shareholder_name: '易方达沪深300交易型开放式指数发起式证券投资基金', holding_shares: 98765400, circulation_share_ratio: 0.51, shareholder_type: '基金', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 10, shareholder_name: '嘉实沪深300交易型开放式指数证券投资基金', holding_shares: 87654300, circulation_share_ratio: 0.45, shareholder_type: '基金', end_date: '2024-09-30' }
|
||||
],
|
||||
|
||||
// 十大股东
|
||||
// 十大股东(字段名与组件期望格式匹配)
|
||||
topShareholders: [
|
||||
{ shareholder_name: '中国平安保险(集团)股份有限公司', shares: 10168542300, ratio: 52.38, change: 0, shareholder_type: '企业', is_restricted: false },
|
||||
{ shareholder_name: '香港中央结算有限公司', shares: 542138600, ratio: 2.79, change: 12450000, shareholder_type: '境外法人', is_restricted: false },
|
||||
{ shareholder_name: '深圳市投资控股有限公司', shares: 382456100, ratio: 1.97, change: 0, shareholder_type: '国有企业', is_restricted: false },
|
||||
{ shareholder_name: '中国证券金融股份有限公司', shares: 298654200, ratio: 1.54, change: -5000000, shareholder_type: '证金公司', is_restricted: false },
|
||||
{ shareholder_name: '中央汇金资产管理有限责任公司', shares: 267842100, ratio: 1.38, change: 0, shareholder_type: '中央汇金', is_restricted: false },
|
||||
{ shareholder_name: '全国社保基金一零三组合', shares: 156234500, ratio: 0.80, change: 23400000, shareholder_type: '社保基金', is_restricted: false },
|
||||
{ shareholder_name: '全国社保基金一零一组合', shares: 142356700, ratio: 0.73, change: 15600000, shareholder_type: '社保基金', is_restricted: false },
|
||||
{ shareholder_name: '中国人寿保险股份有限公司', shares: 128945600, ratio: 0.66, change: 0, shareholder_type: '保险公司', is_restricted: false },
|
||||
{ shareholder_name: 'GIC PRIVATE LIMITED', shares: 98765400, ratio: 0.51, change: -8900000, shareholder_type: '境外法人', is_restricted: false },
|
||||
{ shareholder_name: '挪威中央银行', shares: 87654300, ratio: 0.45, change: 5600000, shareholder_type: '境外法人', is_restricted: false }
|
||||
{ shareholder_rank: 1, shareholder_name: '中国平安保险(集团)股份有限公司', holding_shares: 10168542300, total_share_ratio: 52.38, shareholder_type: '法人', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 2, shareholder_name: '香港中央结算有限公司(陆股通)', holding_shares: 542138600, total_share_ratio: 2.79, shareholder_type: 'QFII', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 3, shareholder_name: '中国平安人寿保险股份有限公司-传统-普通保险产品', holding_shares: 382456100, total_share_ratio: 1.97, shareholder_type: '保险', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 4, shareholder_name: '中国证券金融股份有限公司', holding_shares: 298654200, total_share_ratio: 1.54, shareholder_type: '券商', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 5, shareholder_name: '中央汇金资产管理有限责任公司', holding_shares: 267842100, total_share_ratio: 1.38, shareholder_type: '法人', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 6, shareholder_name: '全国社保基金一零三组合', holding_shares: 156234500, total_share_ratio: 0.80, shareholder_type: '社保', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 7, shareholder_name: '华夏上证50交易型开放式指数证券投资基金', holding_shares: 142356700, total_share_ratio: 0.73, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 8, shareholder_name: '中国人寿保险股份有限公司-分红-个人分红-005L-FH002深', holding_shares: 128945600, total_share_ratio: 0.66, shareholder_type: '保险', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 9, shareholder_name: '易方达沪深300交易型开放式指数发起式证券投资基金', holding_shares: 98765400, total_share_ratio: 0.51, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 10, shareholder_name: '嘉实沪深300交易型开放式指数证券投资基金', holding_shares: 87654300, total_share_ratio: 0.45, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' }
|
||||
],
|
||||
|
||||
// 分支机构
|
||||
// 分支机构(字段与 BranchesPanel 组件匹配)
|
||||
branches: [
|
||||
{ name: '北京分行', address: '北京市朝阳区建国路88号SOHO现代城', phone: '010-85806888', type: '一级分行', establish_date: '2007-03-15' },
|
||||
{ name: '上海分行', address: '上海市浦东新区陆家嘴环路1366号', phone: '021-38637777', type: '一级分行', establish_date: '2007-05-20' },
|
||||
{ name: '广州分行', address: '广州市天河区珠江新城珠江东路32号', phone: '020-38390888', type: '一级分行', establish_date: '2007-06-10' },
|
||||
{ name: '深圳分行', address: '深圳市福田区益田路5033号', phone: '0755-82538888', type: '一级分行', establish_date: '1995-01-01' },
|
||||
{ name: '杭州分行', address: '杭州市江干区钱江路1366号', phone: '0571-87028888', type: '一级分行', establish_date: '2008-09-12' },
|
||||
{ name: '成都分行', address: '成都市武侯区人民南路四段13号', phone: '028-85266888', type: '一级分行', establish_date: '2009-04-25' },
|
||||
{ name: '南京分行', address: '南京市建邺区江东中路359号', phone: '025-86625888', type: '一级分行', establish_date: '2010-06-30' },
|
||||
{ name: '武汉分行', address: '武汉市江汉区建设大道568号', phone: '027-85712888', type: '一级分行', establish_date: '2011-08-15' },
|
||||
{ name: '西安分行', address: '西安市高新区唐延路35号', phone: '029-88313888', type: '一级分行', establish_date: '2012-10-20' },
|
||||
{ name: '天津分行', address: '天津市和平区南京路189号', phone: '022-23399888', type: '一级分行', establish_date: '2013-03-18' }
|
||||
{ branch_name: '平安银行股份有限公司北京分行', business_status: '存续', register_capital: '20亿元', legal_person: '张伟', register_date: '2007-03-15', related_company_count: 156 },
|
||||
{ branch_name: '平安银行股份有限公司上海分行', business_status: '存续', register_capital: '25亿元', legal_person: '李明', register_date: '2007-05-20', related_company_count: 203 },
|
||||
{ branch_name: '平安银行股份有限公司广州分行', business_status: '存续', register_capital: '18亿元', legal_person: '王芳', register_date: '2007-06-10', related_company_count: 142 },
|
||||
{ branch_name: '平安银行股份有限公司深圳分行', business_status: '存续', register_capital: '30亿元', legal_person: '陈强', register_date: '1995-01-01', related_company_count: 287 },
|
||||
{ branch_name: '平安银行股份有限公司杭州分行', business_status: '存续', register_capital: '15亿元', legal_person: '刘洋', register_date: '2008-09-12', related_company_count: 98 },
|
||||
{ branch_name: '平安银行股份有限公司成都分行', business_status: '存续', register_capital: '12亿元', legal_person: '赵静', register_date: '2009-04-25', related_company_count: 76 },
|
||||
{ branch_name: '平安银行股份有限公司南京分行', business_status: '存续', register_capital: '14亿元', legal_person: '周涛', register_date: '2010-06-30', related_company_count: 89 },
|
||||
{ branch_name: '平安银行股份有限公司武汉分行', business_status: '存续', register_capital: '10亿元', legal_person: '吴磊', register_date: '2011-08-15', related_company_count: 65 },
|
||||
{ branch_name: '平安银行股份有限公司西安分行', business_status: '存续', register_capital: '8亿元', legal_person: '郑华', register_date: '2012-10-20', related_company_count: 52 },
|
||||
{ branch_name: '平安银行股份有限公司天津分行', business_status: '存续', register_capital: '10亿元', legal_person: '孙丽', register_date: '2013-03-18', related_company_count: 71 },
|
||||
{ branch_name: '平安银行股份有限公司重庆分行', business_status: '存续', register_capital: '9亿元', legal_person: '钱峰', register_date: '2014-05-08', related_company_count: 58 },
|
||||
{ branch_name: '平安银行股份有限公司苏州分行', business_status: '存续', register_capital: '6亿元', legal_person: '冯雪', register_date: '2015-07-22', related_company_count: 45 },
|
||||
],
|
||||
|
||||
// 公告列表
|
||||
announcements: [
|
||||
{
|
||||
title: '平安银行股份有限公司2024年第三季度报告',
|
||||
publish_date: '2024-10-28',
|
||||
type: '定期报告',
|
||||
summary: '2024年前三季度实现营业收入1245.6亿元,同比增长8.2%;净利润402.3亿元,同比增长12.5%',
|
||||
announce_date: '2024-10-28',
|
||||
info_type: '定期报告',
|
||||
format: 'PDF',
|
||||
file_size: 2580,
|
||||
url: '/announcement/detail/ann_20241028_001'
|
||||
},
|
||||
{
|
||||
title: '关于召开2024年第一次临时股东大会的通知',
|
||||
publish_date: '2024-10-15',
|
||||
type: '临时公告',
|
||||
summary: '定于2024年11月5日召开2024年第一次临时股东大会,审议关于调整董事会成员等议案',
|
||||
announce_date: '2024-10-15',
|
||||
info_type: '临时公告',
|
||||
format: 'PDF',
|
||||
file_size: 156,
|
||||
url: '/announcement/detail/ann_20241015_001'
|
||||
},
|
||||
{
|
||||
title: '平安银行股份有限公司关于完成注册资本变更登记的公告',
|
||||
publish_date: '2024-09-20',
|
||||
type: '临时公告',
|
||||
summary: '公司已完成注册资本由人民币194.06亿元变更为194.06亿元的工商变更登记手续',
|
||||
announce_date: '2024-09-20',
|
||||
info_type: '临时公告',
|
||||
format: 'PDF',
|
||||
file_size: 89,
|
||||
url: '/announcement/detail/ann_20240920_001'
|
||||
},
|
||||
{
|
||||
title: '平安银行股份有限公司2024年半年度报告',
|
||||
publish_date: '2024-08-28',
|
||||
type: '定期报告',
|
||||
summary: '2024年上半年实现营业收入828.5亿元,同比增长7.8%;净利润265.4亿元,同比增长11.2%',
|
||||
announce_date: '2024-08-28',
|
||||
info_type: '定期报告',
|
||||
format: 'PDF',
|
||||
file_size: 3420,
|
||||
url: '/announcement/detail/ann_20240828_001'
|
||||
},
|
||||
{
|
||||
title: '关于2024年上半年利润分配预案的公告',
|
||||
publish_date: '2024-08-20',
|
||||
type: '分配方案',
|
||||
summary: '拟以总股本194.06亿股为基数,向全体股东每10股派发现金红利2.8元(含税)',
|
||||
announce_date: '2024-08-20',
|
||||
info_type: '分配方案',
|
||||
format: 'PDF',
|
||||
file_size: 245,
|
||||
url: '/announcement/detail/ann_20240820_001'
|
||||
}
|
||||
],
|
||||
|
||||
// 披露时间表
|
||||
disclosureSchedule: [
|
||||
{ report_type: '2024年年度报告', planned_date: '2025-04-30', status: '未披露' },
|
||||
{ report_type: '2024年第四季度报告', planned_date: '2025-01-31', status: '未披露' },
|
||||
{ report_type: '2024年第三季度报告', planned_date: '2024-10-31', status: '已披露' },
|
||||
{ report_type: '2024年半年度报告', planned_date: '2024-08-31', status: '已披露' },
|
||||
{ report_type: '2024年第一季度报告', planned_date: '2024-04-30', status: '已披露' }
|
||||
{ report_name: '2024年年度报告', is_disclosed: false, actual_date: null, latest_scheduled_date: '2025-04-30' },
|
||||
{ report_name: '2024年第四季度报告', is_disclosed: false, actual_date: null, latest_scheduled_date: '2025-01-31' },
|
||||
{ report_name: '2024年第三季度报告', is_disclosed: true, actual_date: '2024-10-28', latest_scheduled_date: '2024-10-31' },
|
||||
{ report_name: '2024年半年度报告', is_disclosed: true, actual_date: '2024-08-28', latest_scheduled_date: '2024-08-31' },
|
||||
{ report_name: '2024年第一季度报告', is_disclosed: true, actual_date: '2024-04-28', latest_scheduled_date: '2024-04-30' }
|
||||
],
|
||||
|
||||
// 综合分析 - 结构与组件期望格式匹配
|
||||
@@ -223,10 +455,66 @@ export const PINGAN_BANK_DATA = {
|
||||
qualitative_analysis: {
|
||||
core_positioning: {
|
||||
one_line_intro: '中国领先的股份制商业银行,平安集团综合金融战略的核心载体',
|
||||
investment_highlights: '1. 背靠平安集团,综合金融优势显著,交叉销售和客户资源共享带来持续增长动力;\n2. 零售转型成效显著,零售业务收入占比超50%,个人客户突破1.2亿户;\n3. 金融科技领先同业,AI、大数据、区块链等技术应用深化,运营效率持续提升;\n4. 风险管理体系完善,不良贷款率控制在较低水平,拨备覆盖率保持充足。',
|
||||
business_model_desc: '平安银行以零售银行业务为核心驱动,依托平安集团综合金融平台,构建"三位一体"(智能化银行、移动化银行、综合化银行)发展模式。通过科技赋能实现业务流程数字化,降本增效的同时提升客户体验。对公业务聚焦供应链金融和产业互联网,服务实体经济高质量发展。'
|
||||
// 核心特性(显示在核心定位区域下方的两个卡片)
|
||||
features: [
|
||||
{
|
||||
icon: 'bank',
|
||||
title: '零售业务',
|
||||
description: '收入占比超50%,个人客户突破1.2亿户,零售AUM 4.2万亿'
|
||||
},
|
||||
{
|
||||
icon: 'fire',
|
||||
title: '综合金融',
|
||||
description: '交叉销售和客户资源共享带来持续增长,成本趋近于零'
|
||||
}
|
||||
],
|
||||
// 结构化投资亮点
|
||||
investment_highlights: [
|
||||
{
|
||||
icon: 'users',
|
||||
title: '综合金融优势',
|
||||
description: '背靠平安集团,客户资源共享和交叉销售带来持续增长动力'
|
||||
},
|
||||
{
|
||||
icon: 'trending-up',
|
||||
title: '零售转型成效',
|
||||
description: '零售业务收入占比超50%,个人客户突破1.2亿户'
|
||||
},
|
||||
{
|
||||
icon: 'cpu',
|
||||
title: '金融科技领先',
|
||||
description: 'AI、大数据、区块链等技术深化应用,运营效率持续提升'
|
||||
},
|
||||
{
|
||||
icon: 'shield-check',
|
||||
title: '风险管理体系',
|
||||
description: '不良贷款率控制在较低水平,拨备覆盖率保持充足'
|
||||
}
|
||||
],
|
||||
// 结构化商业模式
|
||||
business_model_sections: [
|
||||
{
|
||||
title: '零售银行核心驱动',
|
||||
description: '以零售银行业务为核心驱动,依托平安集团综合金融平台,构建智能化、移动化、综合化三位一体发展模式。'
|
||||
},
|
||||
{
|
||||
title: '科技赋能转型',
|
||||
description: '通过科技赋能实现业务流程数字化,降本增效的同时提升客户体验。',
|
||||
tags: ['AI应用深化', '大数据分析']
|
||||
},
|
||||
{
|
||||
title: '对公业务聚焦',
|
||||
description: '聚焦供应链金融和产业互联网,服务实体经济高质量发展。'
|
||||
}
|
||||
],
|
||||
// 兼容旧数据格式
|
||||
investment_highlights_text: '1. 零售AUM 4.2万亿、抵押贷占比63%,低不良+高拨备形成稀缺安全垫\n2. 背靠平安集团,保险-银行-投资生态协同,交叉销售成本趋近于零\n3. 战略收缩高风险消费贷、发力科技/绿色/普惠"五篇大文章",资产重构带来息差与估值双升期权',
|
||||
business_model_desc: '以零售金融为压舱石,通过按揭、私行财富、信用卡获取低成本负债;对公金融做精行业赛道,输出供应链金融与跨境金融解决方案;同业金融做专投资交易,赚取做市与波段收益。'
|
||||
},
|
||||
strategy: '坚持"科技引领、零售突破、对公做精"战略方针,深化数字化转型,打造智能化零售银行标杆。持续推进组织架构扁平化和敏捷化改革,提升经营效率。强化风险管理,保持资产质量稳定。'
|
||||
strategy: {
|
||||
strategy_description: '以"零售做强、对公做精、同业做专"为主线,通过压降高风险资产、深耕科技绿色普惠、强化集团协同,实现轻资本、弱周期、高股息的高质量增长。',
|
||||
strategic_initiatives: '2025年AI 138个项目落地,构建智能风控、智能投顾与智能运营,目标3年降低单位成本10%以上;发行800亿元资本债,用于置换存量高成本次级债并支持科技绿色贷款扩张,目标2026年科技绿色贷款占比提升至15%'
|
||||
}
|
||||
},
|
||||
competitive_position: {
|
||||
ranking: {
|
||||
@@ -251,147 +539,65 @@ export const PINGAN_BANK_DATA = {
|
||||
},
|
||||
business_structure: [
|
||||
{
|
||||
business_name: '零售金融',
|
||||
business_name: '舒泰清(复方聚乙二醇电解质散IV)',
|
||||
business_level: 1,
|
||||
revenue: 812300,
|
||||
revenue: 17900,
|
||||
revenue_unit: '万元',
|
||||
financial_metrics: {
|
||||
revenue_ratio: 50.1,
|
||||
gross_margin: 42.5
|
||||
revenue_ratio: 55.16,
|
||||
gross_margin: 78.21
|
||||
},
|
||||
growth_metrics: {
|
||||
revenue_growth: 11.2
|
||||
revenue_growth: -8.20
|
||||
},
|
||||
report_period: '2024Q3'
|
||||
report_period: '2024年报'
|
||||
},
|
||||
{
|
||||
business_name: '信用卡业务',
|
||||
business_level: 2,
|
||||
revenue: 325000,
|
||||
revenue_unit: '万元',
|
||||
financial_metrics: {
|
||||
revenue_ratio: 20.1,
|
||||
gross_margin: 38.2
|
||||
},
|
||||
growth_metrics: {
|
||||
revenue_growth: 15.8
|
||||
},
|
||||
report_period: '2024Q3'
|
||||
},
|
||||
{
|
||||
business_name: '财富管理',
|
||||
business_level: 2,
|
||||
revenue: 280500,
|
||||
revenue_unit: '万元',
|
||||
financial_metrics: {
|
||||
revenue_ratio: 17.3,
|
||||
gross_margin: 52.1
|
||||
},
|
||||
growth_metrics: {
|
||||
revenue_growth: 22.5
|
||||
},
|
||||
report_period: '2024Q3'
|
||||
},
|
||||
{
|
||||
business_name: '消费信贷',
|
||||
business_level: 2,
|
||||
revenue: 206800,
|
||||
revenue_unit: '万元',
|
||||
financial_metrics: {
|
||||
revenue_ratio: 12.7,
|
||||
gross_margin: 35.8
|
||||
},
|
||||
growth_metrics: {
|
||||
revenue_growth: 8.6
|
||||
},
|
||||
report_period: '2024Q3'
|
||||
},
|
||||
{
|
||||
business_name: '对公金融',
|
||||
business_name: '苏肽生(注射用鼠神经生长因子)',
|
||||
business_level: 1,
|
||||
revenue: 685400,
|
||||
revenue: 13400,
|
||||
revenue_unit: '万元',
|
||||
financial_metrics: {
|
||||
revenue_ratio: 42.2,
|
||||
gross_margin: 38.6
|
||||
revenue_ratio: 41.21,
|
||||
gross_margin: 89.11
|
||||
},
|
||||
growth_metrics: {
|
||||
revenue_growth: 6.8
|
||||
revenue_growth: -17.30
|
||||
},
|
||||
report_period: '2024Q3'
|
||||
report_period: '2024年报'
|
||||
},
|
||||
{
|
||||
business_name: '公司贷款',
|
||||
business_level: 2,
|
||||
revenue: 412000,
|
||||
revenue_unit: '万元',
|
||||
financial_metrics: {
|
||||
revenue_ratio: 25.4,
|
||||
gross_margin: 36.2
|
||||
},
|
||||
growth_metrics: {
|
||||
revenue_growth: 5.2
|
||||
},
|
||||
report_period: '2024Q3'
|
||||
},
|
||||
{
|
||||
business_name: '供应链金融',
|
||||
business_level: 2,
|
||||
revenue: 185600,
|
||||
revenue_unit: '万元',
|
||||
financial_metrics: {
|
||||
revenue_ratio: 11.4,
|
||||
gross_margin: 41.5
|
||||
},
|
||||
growth_metrics: {
|
||||
revenue_growth: 18.3
|
||||
},
|
||||
report_period: '2024Q3'
|
||||
},
|
||||
{
|
||||
business_name: '投资银行',
|
||||
business_level: 2,
|
||||
revenue: 87800,
|
||||
revenue_unit: '万元',
|
||||
financial_metrics: {
|
||||
revenue_ratio: 5.4,
|
||||
gross_margin: 45.2
|
||||
},
|
||||
growth_metrics: {
|
||||
revenue_growth: -2.3
|
||||
},
|
||||
report_period: '2024Q3'
|
||||
},
|
||||
{
|
||||
business_name: '资金同业',
|
||||
business_name: '舒斯通(复方聚乙二醇(3350)电解质散)',
|
||||
business_level: 1,
|
||||
revenue: 125800,
|
||||
revenue: 771,
|
||||
revenue_unit: '万元',
|
||||
financial_metrics: {
|
||||
revenue_ratio: 7.7,
|
||||
gross_margin: 28.2
|
||||
revenue_ratio: 2.37
|
||||
},
|
||||
growth_metrics: {
|
||||
revenue_growth: 3.5
|
||||
report_period: '2024年报'
|
||||
},
|
||||
{
|
||||
business_name: '阿司匹林肠溶片',
|
||||
business_level: 1,
|
||||
revenue: 396,
|
||||
revenue_unit: '万元',
|
||||
financial_metrics: {
|
||||
revenue_ratio: 1.22
|
||||
},
|
||||
report_period: '2024Q3'
|
||||
report_period: '2024年报'
|
||||
},
|
||||
{
|
||||
business_name: '研发业务',
|
||||
business_level: 1,
|
||||
report_period: '2024年报'
|
||||
}
|
||||
],
|
||||
business_segments: [
|
||||
{
|
||||
segment_name: '信用卡业务',
|
||||
description: '国内领先的信用卡发卡银行,流通卡量超7000万张',
|
||||
key_metrics: { cards_issued: 7200, transaction_volume: 28500, market_share: 8.5 }
|
||||
},
|
||||
{
|
||||
segment_name: '财富管理',
|
||||
description: '私人银行及财富管理业务快速发展,AUM突破4万亿',
|
||||
key_metrics: { aum: 42000, private_banking_customers: 125000, wealth_customers: 1200000 }
|
||||
},
|
||||
{
|
||||
segment_name: '供应链金融',
|
||||
description: '依托科技平台打造智慧供应链金融生态',
|
||||
key_metrics: { platform_customers: 35000, financing_balance: 5600, digitization_rate: 95 }
|
||||
segment_name: '已上市药品营销',
|
||||
segment_description: '舒泰神已上市药品营销业务主要包括舒泰清(复方聚乙二醇电解质散IV)和苏肽生(注射用鼠神经生长因子)两大核心产品。2024年实现营业收入3.25亿元,其中舒泰清贡献1.79亿元(55.16%),苏肽生贡献1.34亿元(41.21%)。尽管面临市场竞争压力,产品毛利率保持高位,综合毛利率达80.83%,其中苏肽生毛利率高达89.11%。',
|
||||
competitive_position: '舒泰清为《中国消化内镜诊疗肠道准备指南》和《慢性便秘诊治指南》一线用药,苏肽生是国内首个国药准字鼠神经生长因子产品。公司医保目录产品舒斯通已落地,并布局舒亦清、舒常轻等系列产品形成梯队,构建了一定市场竞争优势。然而,2024年集采中同类(III型)产品中选,对舒泰清(IV型)形成潜在价格压力。',
|
||||
future_potential: '公司正在构建系列化产品线应对市场变化,研发投入保持高强度(1.62亿元,占营收49.97%)。在研管线中,STSP-0601血友病药物获FDA孤儿药资格,BDB-001被纳入突破性治疗品种,创新药研发持续推进。国家政策支持创新药发展,行业环境向好,同时国际化布局已有初步进展,未来3-5年有望通过新产品上市实现业绩突破。'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -998,59 +1204,90 @@ export const generateCompanyData = (stockCode, stockName = '示例公司') => {
|
||||
business_scope: '电子产品、通信设备、计算机软硬件的研发、生产、销售;技术咨询、技术服务;货物进出口、技术进出口。',
|
||||
employees: employeeCount,
|
||||
},
|
||||
actualControl: {
|
||||
controller_name: '某控股集团有限公司',
|
||||
controller_type: '企业',
|
||||
shareholding_ratio: 35.5,
|
||||
control_chain: `某控股集团有限公司 -> ${stockName}股份有限公司`,
|
||||
is_listed: false,
|
||||
change_date: '2023-12-31',
|
||||
},
|
||||
concentration: {
|
||||
top1_ratio: 35.5,
|
||||
top3_ratio: 52.3,
|
||||
top5_ratio: 61.8,
|
||||
top10_ratio: 72.5,
|
||||
update_date: '2024-09-30',
|
||||
concentration_level: '适度集中',
|
||||
herfindahl_index: 0.1856,
|
||||
},
|
||||
management: [
|
||||
{ name: '张三', position: '董事长', gender: '男', age: 55, education: '硕士', annual_compensation: 320.5, status: 'active' },
|
||||
{ name: '李四', position: '总经理', gender: '男', age: 50, education: '硕士', annual_compensation: 280.3, status: 'active' },
|
||||
{ name: '王五', position: '董事会秘书', gender: '女', age: 45, education: '本科', annual_compensation: 180.2, status: 'active' },
|
||||
{ name: '赵六', position: '财务总监', gender: '男', age: 48, education: '硕士', annual_compensation: 200.5, status: 'active' },
|
||||
{ name: '钱七', position: '技术总监', gender: '男', age: 42, education: '博士', annual_compensation: 250.8, status: 'active' },
|
||||
actualControl: [
|
||||
{
|
||||
actual_controller_name: '某控股集团有限公司',
|
||||
controller_name: '某控股集团有限公司',
|
||||
control_type: '企业法人',
|
||||
controller_type: '企业',
|
||||
holding_ratio: 35.5,
|
||||
holding_shares: 1560000000,
|
||||
end_date: '2024-09-30',
|
||||
control_chain: `某控股集团有限公司 -> ${stockName}股份有限公司`,
|
||||
is_listed: false,
|
||||
}
|
||||
],
|
||||
concentration: [
|
||||
{ stat_item: '前1大股东', holding_ratio: 35.5, ratio_change: 0.12, end_date: '2024-09-30' },
|
||||
{ stat_item: '前3大股东', holding_ratio: 52.3, ratio_change: 0.25, end_date: '2024-09-30' },
|
||||
{ stat_item: '前5大股东', holding_ratio: 61.8, ratio_change: 0.18, end_date: '2024-09-30' },
|
||||
{ stat_item: '前10大股东', holding_ratio: 72.5, ratio_change: 0.32, end_date: '2024-09-30' },
|
||||
{ stat_item: '前1大股东', holding_ratio: 35.38, ratio_change: -0.08, end_date: '2024-06-30' },
|
||||
{ stat_item: '前3大股东', holding_ratio: 52.05, ratio_change: -0.15, end_date: '2024-06-30' },
|
||||
{ stat_item: '前5大股东', holding_ratio: 61.62, ratio_change: -0.10, end_date: '2024-06-30' },
|
||||
{ stat_item: '前10大股东', holding_ratio: 72.18, ratio_change: -0.20, end_date: '2024-06-30' },
|
||||
],
|
||||
management: [
|
||||
// 高管
|
||||
{ name: '张三', position_name: '董事长', position_category: '高管', gender: '男', birth_year: '1969', education: '硕士', nationality: '中国', start_date: '2018-06-01', status: 'active' },
|
||||
{ name: '李四', position_name: '总经理', position_category: '高管', gender: '男', birth_year: '1974', education: '硕士', nationality: '中国', start_date: '2019-03-15', status: 'active' },
|
||||
{ name: '王五', position_name: '董事会秘书', position_category: '高管', gender: '女', birth_year: '1979', education: '本科', nationality: '中国', start_date: '2020-01-10', status: 'active' },
|
||||
{ name: '赵六', position_name: '财务总监', position_category: '高管', gender: '男', birth_year: '1976', education: '硕士', nationality: '中国', start_date: '2017-09-01', status: 'active' },
|
||||
{ name: '钱七', position_name: '技术总监', position_category: '高管', gender: '男', birth_year: '1982', education: '博士', nationality: '中国', start_date: '2021-06-01', status: 'active' },
|
||||
// 董事
|
||||
{ name: '孙八', position_name: '非执行董事', position_category: '董事', gender: '男', birth_year: '1965', education: '博士', nationality: '中国', start_date: '2016-06-15', status: 'active' },
|
||||
{ name: '周九', position_name: '非执行董事', position_category: '董事', gender: '男', birth_year: '1968', education: '硕士', nationality: '中国', start_date: '2018-06-20', status: 'active' },
|
||||
{ name: '吴十', position_name: '独立董事', position_category: '董事', gender: '女', birth_year: '1972', education: '博士', nationality: '美国', start_date: '2019-06-18', status: 'active' },
|
||||
{ name: '郑十一', position_name: '独立董事', position_category: '董事', gender: '男', birth_year: '1970', education: '博士', nationality: '中国', start_date: '2020-06-25', status: 'active' },
|
||||
// 监事
|
||||
{ name: '冯十二', position_name: '监事会主席', position_category: '监事', gender: '男', birth_year: '1967', education: '硕士', nationality: '中国', start_date: '2017-06-15', status: 'active' },
|
||||
{ name: '陈十三', position_name: '职工监事', position_category: '监事', gender: '女', birth_year: '1975', education: '本科', nationality: '中国', start_date: '2019-06-20', status: 'active' },
|
||||
{ name: '楚十四', position_name: '外部监事', position_category: '监事', gender: '男', birth_year: '1971', education: '硕士', nationality: '中国', start_date: '2020-06-18', status: 'active' },
|
||||
// 其他
|
||||
{ name: '卫十五', position_name: '合规负责人', position_category: '其他', gender: '男', birth_year: '1978', education: '硕士', nationality: '中国', start_date: '2018-09-01', status: 'active' },
|
||||
{ name: '蒋十六', position_name: '内审部负责人', position_category: '其他', gender: '女', birth_year: '1980', education: '硕士', nationality: '中国', start_date: '2019-03-15', status: 'active' },
|
||||
],
|
||||
topCirculationShareholders: [
|
||||
{ shareholder_rank: 1, shareholder_name: '某控股集团有限公司', holding_shares: 560000000, circulation_share_ratio: 35.50, shareholder_type: '法人', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 2, shareholder_name: '香港中央结算有限公司(陆股通)', holding_shares: 156000000, circulation_share_ratio: 9.88, shareholder_type: 'QFII', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 3, shareholder_name: '中国平安人寿保险股份有限公司-传统-普通保险产品', holding_shares: 89000000, circulation_share_ratio: 5.64, shareholder_type: '保险', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 4, shareholder_name: '中国证券金融股份有限公司', holding_shares: 67000000, circulation_share_ratio: 4.24, shareholder_type: '券商', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 5, shareholder_name: '中央汇金资产管理有限责任公司', holding_shares: 45000000, circulation_share_ratio: 2.85, shareholder_type: '法人', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 6, shareholder_name: '全国社保基金一零三组合', holding_shares: 34000000, circulation_share_ratio: 2.15, shareholder_type: '社保', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 7, shareholder_name: '华夏上证50交易型开放式指数证券投资基金', holding_shares: 28000000, circulation_share_ratio: 1.77, shareholder_type: '基金', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 8, shareholder_name: '中国人寿保险股份有限公司-分红-个人分红-005L-FH002深', holding_shares: 23000000, circulation_share_ratio: 1.46, shareholder_type: '保险', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 9, shareholder_name: '易方达沪深300交易型开放式指数发起式证券投资基金', holding_shares: 19000000, circulation_share_ratio: 1.20, shareholder_type: '基金', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 10, shareholder_name: '嘉实沪深300交易型开放式指数证券投资基金', holding_shares: 15000000, circulation_share_ratio: 0.95, shareholder_type: '基金', end_date: '2024-09-30' }
|
||||
],
|
||||
topShareholders: [
|
||||
{ shareholder_rank: 1, shareholder_name: '某控股集团有限公司', holding_shares: 560000000, total_share_ratio: 35.50, shareholder_type: '法人', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 2, shareholder_name: '香港中央结算有限公司(陆股通)', holding_shares: 156000000, total_share_ratio: 9.88, shareholder_type: 'QFII', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 3, shareholder_name: '中国平安人寿保险股份有限公司-传统-普通保险产品', holding_shares: 89000000, total_share_ratio: 5.64, shareholder_type: '保险', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 4, shareholder_name: '中国证券金融股份有限公司', holding_shares: 67000000, total_share_ratio: 4.24, shareholder_type: '券商', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 5, shareholder_name: '中央汇金资产管理有限责任公司', holding_shares: 45000000, total_share_ratio: 2.85, shareholder_type: '法人', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 6, shareholder_name: '全国社保基金一零三组合', holding_shares: 34000000, total_share_ratio: 2.15, shareholder_type: '社保', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 7, shareholder_name: '华夏上证50交易型开放式指数证券投资基金', holding_shares: 28000000, total_share_ratio: 1.77, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 8, shareholder_name: '中国人寿保险股份有限公司-分红-个人分红-005L-FH002深', holding_shares: 23000000, total_share_ratio: 1.46, shareholder_type: '保险', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 9, shareholder_name: '易方达沪深300交易型开放式指数发起式证券投资基金', holding_shares: 19000000, total_share_ratio: 1.20, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 10, shareholder_name: '嘉实沪深300交易型开放式指数证券投资基金', holding_shares: 15000000, total_share_ratio: 0.95, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' }
|
||||
],
|
||||
topCirculationShareholders: Array(10).fill(null).map((_, i) => ({
|
||||
shareholder_name: `股东${i + 1}`,
|
||||
shares: Math.floor(Math.random() * 100000000),
|
||||
ratio: parseFloat(((10 - i) * 0.8 + Math.random() * 2).toFixed(2)),
|
||||
change: Math.floor(Math.random() * 10000000) - 5000000,
|
||||
shareholder_type: i < 3 ? '企业' : (i < 6 ? '个人' : '机构')
|
||||
})),
|
||||
topShareholders: Array(10).fill(null).map((_, i) => ({
|
||||
shareholder_name: `股东${i + 1}`,
|
||||
shares: Math.floor(Math.random() * 100000000),
|
||||
ratio: parseFloat(((10 - i) * 0.8 + Math.random() * 2).toFixed(2)),
|
||||
change: Math.floor(Math.random() * 10000000) - 5000000,
|
||||
shareholder_type: i < 3 ? '企业' : (i < 6 ? '个人' : '机构'),
|
||||
is_restricted: i < 2
|
||||
})),
|
||||
branches: [
|
||||
{ name: '北京分公司', address: '北京市朝阳区某路123号', phone: '010-12345678', type: '分公司', establish_date: '2012-05-01' },
|
||||
{ name: '上海分公司', address: '上海市浦东新区某路456号', phone: '021-12345678', type: '分公司', establish_date: '2013-08-15' },
|
||||
{ name: '广州分公司', address: '广州市天河区某路789号', phone: '020-12345678', type: '分公司', establish_date: '2014-03-20' },
|
||||
{ branch_name: `${stockName}北京分公司`, business_status: '存续', register_capital: '5000万元', legal_person: '张伟', register_date: '2012-05-01', related_company_count: 23 },
|
||||
{ branch_name: `${stockName}上海分公司`, business_status: '存续', register_capital: '8000万元', legal_person: '李明', register_date: '2013-08-15', related_company_count: 35 },
|
||||
{ branch_name: `${stockName}广州分公司`, business_status: '存续', register_capital: '3000万元', legal_person: '王芳', register_date: '2014-03-20', related_company_count: 18 },
|
||||
{ branch_name: `${stockName}深圳分公司`, business_status: '存续', register_capital: '6000万元', legal_person: '陈强', register_date: '2015-06-10', related_company_count: 28 },
|
||||
{ branch_name: `${stockName}成都分公司`, business_status: '存续', register_capital: '2000万元', legal_person: '刘洋', register_date: '2018-09-25', related_company_count: 12 },
|
||||
{ branch_name: `${stockName}武汉子公司`, business_status: '注销', register_capital: '1000万元', legal_person: '赵静', register_date: '2016-04-18', related_company_count: 5 },
|
||||
],
|
||||
announcements: [
|
||||
{ title: `${stockName}2024年第三季度报告`, publish_date: '2024-10-28', type: '定期报告', summary: '业绩稳步增长', url: '#' },
|
||||
{ title: `${stockName}2024年半年度报告`, publish_date: '2024-08-28', type: '定期报告', summary: '经营情况良好', url: '#' },
|
||||
{ title: `关于重大合同签订的公告`, publish_date: '2024-07-15', type: '临时公告', summary: '签订重要销售合同', url: '#' },
|
||||
{ title: `${stockName}2024年第三季度报告`, announce_date: '2024-10-28', info_type: '定期报告', format: 'PDF', file_size: 1850, url: '#' },
|
||||
{ title: `${stockName}2024年半年度报告`, announce_date: '2024-08-28', info_type: '定期报告', format: 'PDF', file_size: 2340, url: '#' },
|
||||
{ title: `关于重大合同签订的公告`, announce_date: '2024-07-15', info_type: '临时公告', format: 'PDF', file_size: 128, url: '#' },
|
||||
],
|
||||
disclosureSchedule: [
|
||||
{ report_type: '2024年年度报告', planned_date: '2025-04-30', status: '未披露' },
|
||||
{ report_type: '2024年第三季度报告', planned_date: '2024-10-31', status: '已披露' },
|
||||
{ report_type: '2024年半年度报告', planned_date: '2024-08-31', status: '已披露' },
|
||||
{ report_name: '2024年年度报告', is_disclosed: false, actual_date: null, latest_scheduled_date: '2025-04-30' },
|
||||
{ report_name: '2024年第三季度报告', is_disclosed: true, actual_date: '2024-10-28', latest_scheduled_date: '2024-10-31' },
|
||||
{ report_name: '2024年半年度报告', is_disclosed: true, actual_date: '2024-08-28', latest_scheduled_date: '2024-08-31' },
|
||||
],
|
||||
comprehensiveAnalysis: {
|
||||
qualitative_analysis: {
|
||||
@@ -1083,11 +1320,68 @@ export const generateCompanyData = (stockCode, stockName = '示例公司') => {
|
||||
}
|
||||
},
|
||||
business_structure: [
|
||||
{ business_name: '核心产品', revenue: baseRevenue * 0.6, ratio: 60, growth: 12.5, report_period: '2024Q3' },
|
||||
{ business_name: '增值服务', revenue: baseRevenue * 0.25, ratio: 25, growth: 18.2, report_period: '2024Q3' },
|
||||
{ business_name: '其他业务', revenue: baseRevenue * 0.15, ratio: 15, growth: 5.8, report_period: '2024Q3' }
|
||||
{
|
||||
business_name: '舒泰清(复方聚乙二醇电解质散IV)',
|
||||
business_level: 1,
|
||||
revenue: 17900,
|
||||
revenue_unit: '万元',
|
||||
financial_metrics: {
|
||||
revenue_ratio: 55.16,
|
||||
gross_margin: 78.21
|
||||
},
|
||||
growth_metrics: {
|
||||
revenue_growth: -8.20
|
||||
},
|
||||
report_period: '2024年报'
|
||||
},
|
||||
{
|
||||
business_name: '苏肽生(注射用鼠神经生长因子)',
|
||||
business_level: 1,
|
||||
revenue: 13400,
|
||||
revenue_unit: '万元',
|
||||
financial_metrics: {
|
||||
revenue_ratio: 41.21,
|
||||
gross_margin: 89.11
|
||||
},
|
||||
growth_metrics: {
|
||||
revenue_growth: -17.30
|
||||
},
|
||||
report_period: '2024年报'
|
||||
},
|
||||
{
|
||||
business_name: '舒斯通(复方聚乙二醇(3350)电解质散)',
|
||||
business_level: 1,
|
||||
revenue: 771,
|
||||
revenue_unit: '万元',
|
||||
financial_metrics: {
|
||||
revenue_ratio: 2.37
|
||||
},
|
||||
report_period: '2024年报'
|
||||
},
|
||||
{
|
||||
business_name: '阿司匹林肠溶片',
|
||||
business_level: 1,
|
||||
revenue: 396,
|
||||
revenue_unit: '万元',
|
||||
financial_metrics: {
|
||||
revenue_ratio: 1.22
|
||||
},
|
||||
report_period: '2024年报'
|
||||
},
|
||||
{
|
||||
business_name: '研发业务',
|
||||
business_level: 1,
|
||||
report_period: '2024年报'
|
||||
}
|
||||
],
|
||||
business_segments: []
|
||||
business_segments: [
|
||||
{
|
||||
segment_name: '已上市药品营销',
|
||||
segment_description: '舒泰神已上市药品营销业务主要包括舒泰清(复方聚乙二醇电解质散IV)和苏肽生(注射用鼠神经生长因子)两大核心产品。2024年实现营业收入3.25亿元,其中舒泰清贡献1.79亿元(55.16%),苏肽生贡献1.34亿元(41.21%)。尽管面临市场竞争压力,产品毛利率保持高位,综合毛利率达80.83%,其中苏肽生毛利率高达89.11%。',
|
||||
competitive_position: '舒泰清为《中国消化内镜诊疗肠道准备指南》和《慢性便秘诊治指南》一线用药,苏肽生是国内首个国药准字鼠神经生长因子产品。公司医保目录产品舒斯通已落地,并布局舒亦清、舒常轻等系列产品形成梯队,构建了一定市场竞争优势。然而,2024年集采中同类(III型)产品中选,对舒泰清(IV型)形成潜在价格压力。',
|
||||
future_potential: '公司正在构建系列化产品线应对市场变化,研发投入保持高强度(1.62亿元,占营收49.97%)。在研管线中,STSP-0601血友病药物获FDA孤儿药资格,BDB-001被纳入突破性治疗品种,创新药研发持续推进。国家政策支持创新药发展,行业环境向好,同时国际化布局已有初步进展,未来3-5年有望通过新产品上市实现业绩突破。'
|
||||
}
|
||||
]
|
||||
},
|
||||
valueChainAnalysis: {
|
||||
value_chain_flows: [
|
||||
|
||||
@@ -874,8 +874,20 @@ export function generateMockEvents(params = {}) {
|
||||
e.title.toLowerCase().includes(query) ||
|
||||
e.description.toLowerCase().includes(query) ||
|
||||
// keywords 是对象数组 { concept, score, ... },需要访问 concept 属性
|
||||
e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query))
|
||||
e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query)) ||
|
||||
// 搜索 related_stocks 中的股票名称和代码
|
||||
(e.related_stocks && e.related_stocks.some(stock =>
|
||||
(stock.stock_name && stock.stock_name.toLowerCase().includes(query)) ||
|
||||
(stock.stock_code && stock.stock_code.toLowerCase().includes(query))
|
||||
)) ||
|
||||
// 搜索行业
|
||||
(e.industry && e.industry.toLowerCase().includes(query))
|
||||
);
|
||||
|
||||
// 如果搜索结果为空,返回所有事件(宽松模式)
|
||||
if (filteredEvents.length === 0) {
|
||||
filteredEvents = allEvents;
|
||||
}
|
||||
}
|
||||
|
||||
// 行业筛选
|
||||
@@ -1042,7 +1054,7 @@ function generateTransmissionChain(industry, index) {
|
||||
|
||||
let nodeName;
|
||||
if (nodeType === 'company' && industryStock) {
|
||||
nodeName = industryStock.name;
|
||||
nodeName = industryStock.stock_name;
|
||||
} else if (nodeType === 'industry') {
|
||||
nodeName = `${industry}产业`;
|
||||
} else if (nodeType === 'policy') {
|
||||
@@ -1133,7 +1145,7 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
|
||||
const stock = industryStocks[j % industryStocks.length];
|
||||
relatedStocks.push({
|
||||
stock_code: stock.stock_code,
|
||||
stock_name: stock.name,
|
||||
stock_name: stock.stock_name,
|
||||
relation_desc: relationDescriptions[j % relationDescriptions.length]
|
||||
});
|
||||
}
|
||||
@@ -1145,7 +1157,7 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
|
||||
if (!relatedStocks.some(s => s.stock_code === randomStock.stock_code)) {
|
||||
relatedStocks.push({
|
||||
stock_code: randomStock.stock_code,
|
||||
stock_name: randomStock.name,
|
||||
stock_name: randomStock.stock_name,
|
||||
relation_desc: relationDescriptions[relatedStocks.length % relationDescriptions.length]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,73 +10,323 @@ export const generateFinancialData = (stockCode) => {
|
||||
|
||||
// 股票基本信息
|
||||
stockInfo: {
|
||||
code: stockCode,
|
||||
name: stockCode === '000001' ? '平安银行' : '示例公司',
|
||||
stock_code: stockCode,
|
||||
stock_name: stockCode === '000001' ? '平安银行' : '示例公司',
|
||||
industry: stockCode === '000001' ? '银行' : '制造业',
|
||||
list_date: '1991-04-03',
|
||||
market: 'SZ'
|
||||
market: 'SZ',
|
||||
// 关键指标
|
||||
key_metrics: {
|
||||
eps: 2.72,
|
||||
roe: 16.23,
|
||||
gross_margin: 71.92,
|
||||
net_margin: 32.56,
|
||||
roa: 1.05
|
||||
},
|
||||
// 增长率
|
||||
growth_rates: {
|
||||
revenue_growth: 8.2,
|
||||
profit_growth: 12.5,
|
||||
asset_growth: 5.6,
|
||||
equity_growth: 6.8
|
||||
},
|
||||
// 财务概要
|
||||
financial_summary: {
|
||||
revenue: 162350,
|
||||
net_profit: 52860,
|
||||
total_assets: 5024560,
|
||||
total_liabilities: 4698880
|
||||
},
|
||||
// 最新业绩预告
|
||||
latest_forecast: {
|
||||
forecast_type: '预增',
|
||||
content: '预计全年净利润同比增长10%-17%'
|
||||
}
|
||||
},
|
||||
|
||||
// 资产负债表
|
||||
// 资产负债表 - 嵌套结构
|
||||
balanceSheet: periods.map((period, i) => ({
|
||||
period,
|
||||
total_assets: 5024560 - i * 50000, // 百万元
|
||||
total_liabilities: 4698880 - i * 48000,
|
||||
shareholders_equity: 325680 - i * 2000,
|
||||
current_assets: 2512300 - i * 25000,
|
||||
non_current_assets: 2512260 - i * 25000,
|
||||
current_liabilities: 3456780 - i * 35000,
|
||||
non_current_liabilities: 1242100 - i * 13000
|
||||
assets: {
|
||||
current_assets: {
|
||||
cash: 856780 - i * 10000,
|
||||
trading_financial_assets: 234560 - i * 5000,
|
||||
notes_receivable: 12340 - i * 200,
|
||||
accounts_receivable: 45670 - i * 1000,
|
||||
prepayments: 8900 - i * 100,
|
||||
other_receivables: 23450 - i * 500,
|
||||
inventory: 156780 - i * 3000,
|
||||
contract_assets: 34560 - i * 800,
|
||||
other_current_assets: 67890 - i * 1500,
|
||||
total: 2512300 - i * 25000
|
||||
},
|
||||
non_current_assets: {
|
||||
long_term_equity_investments: 234560 - i * 5000,
|
||||
investment_property: 45670 - i * 1000,
|
||||
fixed_assets: 678900 - i * 15000,
|
||||
construction_in_progress: 123450 - i * 3000,
|
||||
right_of_use_assets: 34560 - i * 800,
|
||||
intangible_assets: 89012 - i * 2000,
|
||||
goodwill: 45670 - i * 1000,
|
||||
deferred_tax_assets: 12340 - i * 300,
|
||||
other_non_current_assets: 67890 - i * 1500,
|
||||
total: 2512260 - i * 25000
|
||||
},
|
||||
total: 5024560 - i * 50000
|
||||
},
|
||||
liabilities: {
|
||||
current_liabilities: {
|
||||
short_term_borrowings: 456780 - i * 10000,
|
||||
notes_payable: 23450 - i * 500,
|
||||
accounts_payable: 234560 - i * 5000,
|
||||
advance_receipts: 12340 - i * 300,
|
||||
contract_liabilities: 34560 - i * 800,
|
||||
employee_compensation_payable: 45670 - i * 1000,
|
||||
taxes_payable: 23450 - i * 500,
|
||||
other_payables: 78900 - i * 1500,
|
||||
non_current_liabilities_due_within_one_year: 89012 - i * 2000,
|
||||
total: 3456780 - i * 35000
|
||||
},
|
||||
non_current_liabilities: {
|
||||
long_term_borrowings: 678900 - i * 15000,
|
||||
bonds_payable: 234560 - i * 5000,
|
||||
lease_liabilities: 45670 - i * 1000,
|
||||
deferred_tax_liabilities: 12340 - i * 300,
|
||||
other_non_current_liabilities: 89012 - i * 2000,
|
||||
total: 1242100 - i * 13000
|
||||
},
|
||||
total: 4698880 - i * 48000
|
||||
},
|
||||
equity: {
|
||||
share_capital: 19405,
|
||||
capital_reserve: 89012 - i * 2000,
|
||||
surplus_reserve: 45670 - i * 1000,
|
||||
undistributed_profit: 156780 - i * 3000,
|
||||
treasury_stock: 0,
|
||||
other_comprehensive_income: 12340 - i * 300,
|
||||
parent_company_equity: 315680 - i * 1800,
|
||||
minority_interests: 10000 - i * 200,
|
||||
total: 325680 - i * 2000
|
||||
}
|
||||
})),
|
||||
|
||||
// 利润表
|
||||
// 利润表 - 嵌套结构
|
||||
incomeStatement: periods.map((period, i) => ({
|
||||
period,
|
||||
revenue: 162350 - i * 4000, // 百万元
|
||||
operating_cost: 45620 - i * 1200,
|
||||
gross_profit: 116730 - i * 2800,
|
||||
operating_profit: 68450 - i * 1500,
|
||||
net_profit: 52860 - i * 1200,
|
||||
eps: 2.72 - i * 0.06
|
||||
revenue: {
|
||||
total_operating_revenue: 162350 - i * 4000,
|
||||
operating_revenue: 158900 - i * 3900,
|
||||
other_income: 3450 - i * 100
|
||||
},
|
||||
costs: {
|
||||
total_operating_cost: 93900 - i * 2500,
|
||||
operating_cost: 45620 - i * 1200,
|
||||
taxes_and_surcharges: 4560 - i * 100,
|
||||
selling_expenses: 12340 - i * 300,
|
||||
admin_expenses: 15670 - i * 400,
|
||||
rd_expenses: 8900 - i * 200,
|
||||
financial_expenses: 6810 - i * 300,
|
||||
interest_expense: 8900 - i * 200,
|
||||
interest_income: 2090 - i * 50,
|
||||
three_expenses_total: 34820 - i * 1000,
|
||||
four_expenses_total: 43720 - i * 1200,
|
||||
asset_impairment_loss: 1200 - i * 50,
|
||||
credit_impairment_loss: 2340 - i * 100
|
||||
},
|
||||
other_gains: {
|
||||
fair_value_change: 1230 - i * 50,
|
||||
investment_income: 3450 - i * 100,
|
||||
investment_income_from_associates: 890 - i * 20,
|
||||
exchange_income: 560 - i * 10,
|
||||
asset_disposal_income: 340 - i * 10
|
||||
},
|
||||
profit: {
|
||||
operating_profit: 68450 - i * 1500,
|
||||
total_profit: 69500 - i * 1500,
|
||||
income_tax_expense: 16640 - i * 300,
|
||||
net_profit: 52860 - i * 1200,
|
||||
parent_net_profit: 51200 - i * 1150,
|
||||
minority_profit: 1660 - i * 50,
|
||||
continuing_operations_net_profit: 52860 - i * 1200,
|
||||
discontinued_operations_net_profit: 0
|
||||
},
|
||||
non_operating: {
|
||||
non_operating_income: 1050 - i * 20,
|
||||
non_operating_expenses: 450 - i * 10
|
||||
},
|
||||
per_share: {
|
||||
basic_eps: 2.72 - i * 0.06,
|
||||
diluted_eps: 2.70 - i * 0.06
|
||||
},
|
||||
comprehensive_income: {
|
||||
other_comprehensive_income: 890 - i * 20,
|
||||
total_comprehensive_income: 53750 - i * 1220,
|
||||
parent_comprehensive_income: 52050 - i * 1170,
|
||||
minority_comprehensive_income: 1700 - i * 50
|
||||
}
|
||||
})),
|
||||
|
||||
// 现金流量表
|
||||
// 现金流量表 - 嵌套结构
|
||||
cashflow: periods.map((period, i) => ({
|
||||
period,
|
||||
operating_cashflow: 125600 - i * 3000, // 百万元
|
||||
investing_cashflow: -45300 - i * 1000,
|
||||
financing_cashflow: -38200 + i * 500,
|
||||
net_cashflow: 42100 - i * 1500,
|
||||
cash_ending: 456780 - i * 10000
|
||||
operating_activities: {
|
||||
inflow: {
|
||||
cash_from_sales: 178500 - i * 4500
|
||||
},
|
||||
outflow: {
|
||||
cash_for_goods: 52900 - i * 1500
|
||||
},
|
||||
net_flow: 125600 - i * 3000
|
||||
},
|
||||
investment_activities: {
|
||||
net_flow: -45300 - i * 1000
|
||||
},
|
||||
financing_activities: {
|
||||
net_flow: -38200 + i * 500
|
||||
},
|
||||
cash_changes: {
|
||||
net_increase: 42100 - i * 1500,
|
||||
ending_balance: 456780 - i * 10000
|
||||
},
|
||||
key_metrics: {
|
||||
free_cash_flow: 80300 - i * 2000
|
||||
}
|
||||
})),
|
||||
|
||||
// 财务指标
|
||||
// 财务指标 - 嵌套结构
|
||||
financialMetrics: periods.map((period, i) => ({
|
||||
period,
|
||||
roe: 16.23 - i * 0.3, // %
|
||||
roa: 1.05 - i * 0.02,
|
||||
gross_margin: 71.92 - i * 0.5,
|
||||
net_margin: 32.56 - i * 0.3,
|
||||
current_ratio: 0.73 + i * 0.01,
|
||||
quick_ratio: 0.71 + i * 0.01,
|
||||
debt_ratio: 93.52 + i * 0.05,
|
||||
asset_turnover: 0.41 - i * 0.01,
|
||||
inventory_turnover: 0, // 银行无库存
|
||||
receivable_turnover: 0 // 银行特殊
|
||||
profitability: {
|
||||
roe: 16.23 - i * 0.3,
|
||||
roe_deducted: 15.89 - i * 0.3,
|
||||
roe_weighted: 16.45 - i * 0.3,
|
||||
roa: 1.05 - i * 0.02,
|
||||
gross_margin: 71.92 - i * 0.5,
|
||||
net_profit_margin: 32.56 - i * 0.3,
|
||||
operating_profit_margin: 42.16 - i * 0.4,
|
||||
cost_profit_ratio: 115.8 - i * 1.2,
|
||||
ebit: 86140 - i * 1800
|
||||
},
|
||||
per_share_metrics: {
|
||||
eps: 2.72 - i * 0.06,
|
||||
basic_eps: 2.72 - i * 0.06,
|
||||
diluted_eps: 2.70 - i * 0.06,
|
||||
deducted_eps: 2.65 - i * 0.06,
|
||||
bvps: 16.78 - i * 0.1,
|
||||
operating_cash_flow_ps: 6.47 - i * 0.15,
|
||||
capital_reserve_ps: 4.59 - i * 0.1,
|
||||
undistributed_profit_ps: 8.08 - i * 0.15
|
||||
},
|
||||
growth: {
|
||||
revenue_growth: 8.2 - i * 0.5,
|
||||
net_profit_growth: 12.5 - i * 0.8,
|
||||
deducted_profit_growth: 11.8 - i * 0.7,
|
||||
parent_profit_growth: 12.3 - i * 0.75,
|
||||
operating_cash_flow_growth: 15.6 - i * 1.0,
|
||||
total_asset_growth: 5.6 - i * 0.3,
|
||||
equity_growth: 6.8 - i * 0.4,
|
||||
fixed_asset_growth: 4.2 - i * 0.2
|
||||
},
|
||||
operational_efficiency: {
|
||||
total_asset_turnover: 0.41 - i * 0.01,
|
||||
fixed_asset_turnover: 2.35 - i * 0.05,
|
||||
current_asset_turnover: 0.82 - i * 0.02,
|
||||
receivable_turnover: 12.5 - i * 0.3,
|
||||
receivable_days: 29.2 + i * 0.7,
|
||||
inventory_turnover: 0, // 银行无库存
|
||||
inventory_days: 0,
|
||||
working_capital_turnover: 1.68 - i * 0.04
|
||||
},
|
||||
solvency: {
|
||||
current_ratio: 0.73 + i * 0.01,
|
||||
quick_ratio: 0.71 + i * 0.01,
|
||||
cash_ratio: 0.25 + i * 0.005,
|
||||
conservative_quick_ratio: 0.68 + i * 0.01,
|
||||
asset_liability_ratio: 93.52 + i * 0.05,
|
||||
interest_coverage: 8.56 - i * 0.2,
|
||||
cash_to_maturity_debt_ratio: 0.45 - i * 0.01,
|
||||
tangible_asset_debt_ratio: 94.12 + i * 0.05
|
||||
},
|
||||
expense_ratios: {
|
||||
selling_expense_ratio: 7.60 + i * 0.1,
|
||||
admin_expense_ratio: 9.65 + i * 0.1,
|
||||
financial_expense_ratio: 4.19 + i * 0.1,
|
||||
rd_expense_ratio: 5.48 + i * 0.1,
|
||||
three_expense_ratio: 21.44 + i * 0.3,
|
||||
four_expense_ratio: 26.92 + i * 0.4,
|
||||
cost_ratio: 28.10 + i * 0.2
|
||||
}
|
||||
})),
|
||||
|
||||
// 主营业务
|
||||
// 主营业务 - 按产品/业务分类
|
||||
mainBusiness: {
|
||||
by_product: [
|
||||
{ name: '对公业务', revenue: 68540, ratio: 42.2, yoy_growth: 6.8 },
|
||||
{ name: '零售业务', revenue: 81320, ratio: 50.1, yoy_growth: 11.2 },
|
||||
{ name: '金融市场业务', revenue: 12490, ratio: 7.7, yoy_growth: 3.5 }
|
||||
product_classification: [
|
||||
{
|
||||
period: '2024-09-30',
|
||||
report_type: '2024年三季报',
|
||||
products: [
|
||||
{ content: '零售金融业务', revenue: 81320000000, gross_margin: 68.5, profit_margin: 42.3, profit: 34398160000 },
|
||||
{ content: '对公金融业务', revenue: 68540000000, gross_margin: 62.8, profit_margin: 38.6, profit: 26456440000 },
|
||||
{ content: '金融市场业务', revenue: 12490000000, gross_margin: 75.2, profit_margin: 52.1, profit: 6507290000 },
|
||||
{ content: '合计', revenue: 162350000000, gross_margin: 67.5, profit_margin: 41.2, profit: 66883200000 },
|
||||
]
|
||||
},
|
||||
{
|
||||
period: '2024-06-30',
|
||||
report_type: '2024年中报',
|
||||
products: [
|
||||
{ content: '零售金融业务', revenue: 78650000000, gross_margin: 67.8, profit_margin: 41.5, profit: 32639750000 },
|
||||
{ content: '对公金融业务', revenue: 66280000000, gross_margin: 61.9, profit_margin: 37.8, profit: 25053840000 },
|
||||
{ content: '金融市场业务', revenue: 11870000000, gross_margin: 74.5, profit_margin: 51.2, profit: 6077440000 },
|
||||
{ content: '合计', revenue: 156800000000, gross_margin: 66.8, profit_margin: 40.5, profit: 63504000000 },
|
||||
]
|
||||
},
|
||||
{
|
||||
period: '2024-03-31',
|
||||
report_type: '2024年一季报',
|
||||
products: [
|
||||
{ content: '零售金融业务', revenue: 38920000000, gross_margin: 67.2, profit_margin: 40.8, profit: 15879360000 },
|
||||
{ content: '对公金融业务', revenue: 32650000000, gross_margin: 61.2, profit_margin: 37.1, profit: 12113150000 },
|
||||
{ content: '金融市场业务', revenue: 5830000000, gross_margin: 73.8, profit_margin: 50.5, profit: 2944150000 },
|
||||
{ content: '合计', revenue: 77400000000, gross_margin: 66.1, profit_margin: 39.8, profit: 30805200000 },
|
||||
]
|
||||
},
|
||||
{
|
||||
period: '2023-12-31',
|
||||
report_type: '2023年年报',
|
||||
products: [
|
||||
{ content: '零售金融业务', revenue: 152680000000, gross_margin: 66.5, profit_margin: 40.2, profit: 61377360000 },
|
||||
{ content: '对公金融业务', revenue: 128450000000, gross_margin: 60.5, profit_margin: 36.5, profit: 46884250000 },
|
||||
{ content: '金融市场业务', revenue: 22870000000, gross_margin: 73.2, profit_margin: 49.8, profit: 11389260000 },
|
||||
{ content: '合计', revenue: 304000000000, gross_margin: 65.2, profit_margin: 39.2, profit: 119168000000 },
|
||||
]
|
||||
},
|
||||
],
|
||||
by_region: [
|
||||
{ name: '华南地区', revenue: 56800, ratio: 35.0, yoy_growth: 9.2 },
|
||||
{ name: '华东地区', revenue: 48705, ratio: 30.0, yoy_growth: 8.5 },
|
||||
{ name: '华北地区', revenue: 32470, ratio: 20.0, yoy_growth: 7.8 },
|
||||
{ name: '其他地区', revenue: 24375, ratio: 15.0, yoy_growth: 6.5 }
|
||||
industry_classification: [
|
||||
{
|
||||
period: '2024-09-30',
|
||||
report_type: '2024年三季报',
|
||||
industries: [
|
||||
{ content: '华南地区', revenue: 56817500000, gross_margin: 69.2, profit_margin: 43.5, profit: 24715612500 },
|
||||
{ content: '华东地区', revenue: 48705000000, gross_margin: 67.8, profit_margin: 41.2, profit: 20066460000 },
|
||||
{ content: '华北地区', revenue: 32470000000, gross_margin: 65.5, profit_margin: 38.8, profit: 12598360000 },
|
||||
{ content: '西南地区', revenue: 16235000000, gross_margin: 64.2, profit_margin: 37.5, profit: 6088125000 },
|
||||
{ content: '其他地区', revenue: 8122500000, gross_margin: 62.8, profit_margin: 35.2, profit: 2859120000 },
|
||||
{ content: '合计', revenue: 162350000000, gross_margin: 67.5, profit_margin: 41.2, profit: 66883200000 },
|
||||
]
|
||||
},
|
||||
{
|
||||
period: '2024-06-30',
|
||||
report_type: '2024年中报',
|
||||
industries: [
|
||||
{ content: '华南地区', revenue: 54880000000, gross_margin: 68.5, profit_margin: 42.8, profit: 23488640000 },
|
||||
{ content: '华东地区', revenue: 47040000000, gross_margin: 67.1, profit_margin: 40.5, profit: 19051200000 },
|
||||
{ content: '华北地区', revenue: 31360000000, gross_margin: 64.8, profit_margin: 38.1, profit: 11948160000 },
|
||||
{ content: '西南地区', revenue: 15680000000, gross_margin: 63.5, profit_margin: 36.8, profit: 5770240000 },
|
||||
{ content: '其他地区', revenue: 7840000000, gross_margin: 62.1, profit_margin: 34.5, profit: 2704800000 },
|
||||
{ content: '合计', revenue: 156800000000, gross_margin: 66.8, profit_margin: 40.5, profit: 63504000000 },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
@@ -92,48 +342,74 @@ export const generateFinancialData = (stockCode) => {
|
||||
publish_date: '2024-10-15'
|
||||
},
|
||||
|
||||
// 行业排名
|
||||
industryRank: {
|
||||
industry: '银行',
|
||||
total_companies: 42,
|
||||
rankings: [
|
||||
{ metric: '总资产', rank: 8, value: 5024560, percentile: 19 },
|
||||
{ metric: '营业收入', rank: 9, value: 162350, percentile: 21 },
|
||||
{ metric: '净利润', rank: 8, value: 52860, percentile: 19 },
|
||||
{ metric: 'ROE', rank: 12, value: 16.23, percentile: 29 },
|
||||
{ metric: '不良贷款率', rank: 18, value: 1.02, percentile: 43 }
|
||||
]
|
||||
},
|
||||
// 行业排名(数组格式,符合 IndustryRankingView 组件要求)
|
||||
industryRank: [
|
||||
{
|
||||
period: '2024-09-30',
|
||||
report_type: '三季报',
|
||||
rankings: [
|
||||
{
|
||||
industry_name: stockCode === '000001' ? '银行' : '制造业',
|
||||
level_description: '一级行业',
|
||||
metrics: {
|
||||
eps: { value: 2.72, rank: 8, industry_avg: 1.85 },
|
||||
bvps: { value: 15.23, rank: 12, industry_avg: 12.50 },
|
||||
roe: { value: 16.23, rank: 10, industry_avg: 12.00 },
|
||||
revenue_growth: { value: 8.2, rank: 15, industry_avg: 5.50 },
|
||||
profit_growth: { value: 12.5, rank: 9, industry_avg: 8.00 },
|
||||
operating_margin: { value: 32.56, rank: 6, industry_avg: 25.00 },
|
||||
debt_ratio: { value: 92.5, rank: 35, industry_avg: 88.00 },
|
||||
receivable_turnover: { value: 5.2, rank: 18, industry_avg: 4.80 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
// 期间对比
|
||||
periodComparison: {
|
||||
periods: ['Q3-2024', 'Q2-2024', 'Q1-2024', 'Q4-2023'],
|
||||
metrics: [
|
||||
{
|
||||
name: '营业收入',
|
||||
unit: '百万元',
|
||||
values: [41500, 40800, 40200, 40850],
|
||||
yoy: [8.2, 7.8, 8.5, 9.2]
|
||||
},
|
||||
{
|
||||
name: '净利润',
|
||||
unit: '百万元',
|
||||
values: [13420, 13180, 13050, 13210],
|
||||
yoy: [12.5, 11.2, 10.8, 12.3]
|
||||
},
|
||||
{
|
||||
name: 'ROE',
|
||||
unit: '%',
|
||||
values: [16.23, 15.98, 15.75, 16.02],
|
||||
yoy: [1.2, 0.8, 0.5, 1.0]
|
||||
},
|
||||
{
|
||||
name: 'EPS',
|
||||
unit: '元',
|
||||
values: [0.69, 0.68, 0.67, 0.68],
|
||||
yoy: [12.3, 11.5, 10.5, 12.0]
|
||||
// 期间对比 - 营收与利润趋势数据
|
||||
periodComparison: [
|
||||
{
|
||||
period: '2024-09-30',
|
||||
performance: {
|
||||
revenue: 41500000000, // 415亿
|
||||
net_profit: 13420000000 // 134.2亿
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
period: '2024-06-30',
|
||||
performance: {
|
||||
revenue: 40800000000, // 408亿
|
||||
net_profit: 13180000000 // 131.8亿
|
||||
}
|
||||
},
|
||||
{
|
||||
period: '2024-03-31',
|
||||
performance: {
|
||||
revenue: 40200000000, // 402亿
|
||||
net_profit: 13050000000 // 130.5亿
|
||||
}
|
||||
},
|
||||
{
|
||||
period: '2023-12-31',
|
||||
performance: {
|
||||
revenue: 40850000000, // 408.5亿
|
||||
net_profit: 13210000000 // 132.1亿
|
||||
}
|
||||
},
|
||||
{
|
||||
period: '2023-09-30',
|
||||
performance: {
|
||||
revenue: 38500000000, // 385亿
|
||||
net_profit: 11920000000 // 119.2亿
|
||||
}
|
||||
},
|
||||
{
|
||||
period: '2023-06-30',
|
||||
performance: {
|
||||
revenue: 37800000000, // 378亿
|
||||
net_profit: 11850000000 // 118.5亿
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
@@ -24,8 +24,9 @@ export const generateMarketData = (stockCode) => {
|
||||
low: parseFloat(low.toFixed(2)),
|
||||
volume: Math.floor(Math.random() * 500000000) + 100000000, // 1-6亿股
|
||||
amount: Math.floor(Math.random() * 7000000000) + 1300000000, // 13-80亿元
|
||||
turnover_rate: (Math.random() * 2 + 0.5).toFixed(2), // 0.5-2.5%
|
||||
change_pct: (Math.random() * 6 - 3).toFixed(2) // -3% to +3%
|
||||
turnover_rate: parseFloat((Math.random() * 2 + 0.5).toFixed(2)), // 0.5-2.5%
|
||||
change_percent: parseFloat((Math.random() * 6 - 3).toFixed(2)), // -3% to +3%
|
||||
pe_ratio: parseFloat((Math.random() * 3 + 4).toFixed(2)) // 4-7
|
||||
};
|
||||
})
|
||||
},
|
||||
@@ -78,36 +79,45 @@ export const generateMarketData = (stockCode) => {
|
||||
}))
|
||||
},
|
||||
|
||||
// 股权质押
|
||||
// 股权质押 - 匹配 PledgeData[] 类型
|
||||
pledgeData: {
|
||||
success: true,
|
||||
data: {
|
||||
total_pledged: 25.6, // 质押比例%
|
||||
major_shareholders: [
|
||||
{ name: '中国平安保险集团', pledged_shares: 0, total_shares: 10168542300, pledge_ratio: 0 },
|
||||
{ name: '深圳市投资控股', pledged_shares: 50000000, total_shares: 382456100, pledge_ratio: 13.08 }
|
||||
],
|
||||
update_date: '2024-09-30'
|
||||
}
|
||||
data: Array(12).fill(null).map((_, i) => {
|
||||
const date = new Date();
|
||||
date.setMonth(date.getMonth() - (11 - i));
|
||||
return {
|
||||
end_date: date.toISOString().split('T')[0].slice(0, 7) + '-01',
|
||||
unrestricted_pledge: Math.floor(Math.random() * 1000000000) + 500000000,
|
||||
restricted_pledge: Math.floor(Math.random() * 200000000) + 50000000,
|
||||
total_pledge: Math.floor(Math.random() * 1200000000) + 550000000,
|
||||
total_shares: 19405918198,
|
||||
pledge_ratio: parseFloat((Math.random() * 3 + 6).toFixed(2)), // 6-9%
|
||||
pledge_count: Math.floor(Math.random() * 50) + 100 // 100-150
|
||||
};
|
||||
})
|
||||
},
|
||||
|
||||
// 市场摘要
|
||||
// 市场摘要 - 匹配 MarketSummary 类型
|
||||
summaryData: {
|
||||
success: true,
|
||||
data: {
|
||||
current_price: basePrice,
|
||||
change: 0.25,
|
||||
change_pct: 1.89,
|
||||
open: 13.35,
|
||||
high: 13.68,
|
||||
low: 13.28,
|
||||
volume: 345678900,
|
||||
amount: 4678900000,
|
||||
turnover_rate: 1.78,
|
||||
pe_ratio: 4.96,
|
||||
pb_ratio: 0.72,
|
||||
total_market_cap: 262300000000,
|
||||
circulating_market_cap: 262300000000
|
||||
stock_code: stockCode,
|
||||
stock_name: stockCode === '000001' ? '平安银行' : '示例股票',
|
||||
latest_trade: {
|
||||
close: basePrice,
|
||||
change_percent: 1.89,
|
||||
volume: 345678900,
|
||||
amount: 4678900000,
|
||||
turnover_rate: 1.78,
|
||||
pe_ratio: 4.96
|
||||
},
|
||||
latest_funding: {
|
||||
financing_balance: 5823000000,
|
||||
securities_balance: 125600000
|
||||
},
|
||||
latest_pledge: {
|
||||
pledge_ratio: 8.25
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -131,26 +141,57 @@ export const generateMarketData = (stockCode) => {
|
||||
})
|
||||
},
|
||||
|
||||
// 最新分时数据
|
||||
// 最新分时数据 - 匹配 MinuteData 类型
|
||||
latestMinuteData: {
|
||||
success: true,
|
||||
data: Array(240).fill(null).map((_, i) => {
|
||||
const minute = 9 * 60 + 30 + i; // 从9:30开始
|
||||
const hour = Math.floor(minute / 60);
|
||||
const min = minute % 60;
|
||||
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
|
||||
const randomChange = (Math.random() - 0.5) * 0.1;
|
||||
return {
|
||||
time,
|
||||
price: (basePrice + randomChange).toFixed(2),
|
||||
volume: Math.floor(Math.random() * 2000000) + 500000,
|
||||
avg_price: (basePrice + randomChange * 0.8).toFixed(2)
|
||||
};
|
||||
}),
|
||||
data: (() => {
|
||||
const minuteData = [];
|
||||
// 上午 9:30-11:30 (120分钟)
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const hour = 9 + Math.floor((30 + i) / 60);
|
||||
const min = (30 + i) % 60;
|
||||
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
|
||||
const randomChange = (Math.random() - 0.5) * 0.1;
|
||||
const open = parseFloat((basePrice + randomChange).toFixed(2));
|
||||
const close = parseFloat((basePrice + randomChange + (Math.random() - 0.5) * 0.05).toFixed(2));
|
||||
const high = parseFloat(Math.max(open, close, open + Math.random() * 0.05).toFixed(2));
|
||||
const low = parseFloat(Math.min(open, close, close - Math.random() * 0.05).toFixed(2));
|
||||
minuteData.push({
|
||||
time,
|
||||
open,
|
||||
close,
|
||||
high,
|
||||
low,
|
||||
volume: Math.floor(Math.random() * 2000000) + 500000,
|
||||
amount: Math.floor(Math.random() * 30000000) + 5000000
|
||||
});
|
||||
}
|
||||
// 下午 13:00-15:00 (120分钟)
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const hour = 13 + Math.floor(i / 60);
|
||||
const min = i % 60;
|
||||
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
|
||||
const randomChange = (Math.random() - 0.5) * 0.1;
|
||||
const open = parseFloat((basePrice + randomChange).toFixed(2));
|
||||
const close = parseFloat((basePrice + randomChange + (Math.random() - 0.5) * 0.05).toFixed(2));
|
||||
const high = parseFloat(Math.max(open, close, open + Math.random() * 0.05).toFixed(2));
|
||||
const low = parseFloat(Math.min(open, close, close - Math.random() * 0.05).toFixed(2));
|
||||
minuteData.push({
|
||||
time,
|
||||
open,
|
||||
close,
|
||||
high,
|
||||
low,
|
||||
volume: Math.floor(Math.random() * 1500000) + 400000,
|
||||
amount: Math.floor(Math.random() * 25000000) + 4000000
|
||||
});
|
||||
}
|
||||
return minuteData;
|
||||
})(),
|
||||
code: stockCode,
|
||||
name: stockCode === '000001' ? '平安银行' : '示例股票',
|
||||
trade_date: new Date().toISOString().split('T')[0],
|
||||
type: 'minute'
|
||||
type: '1min'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
// src/mocks/handlers/bytedesk.js
|
||||
/**
|
||||
* Bytedesk 客服 Widget MSW Handler
|
||||
* 使用 passthrough 让请求通过到真实服务器,消除 MSW 警告
|
||||
* Mock 模式下返回模拟数据
|
||||
*/
|
||||
|
||||
import { http, passthrough } from 'msw';
|
||||
import { http, HttpResponse, passthrough } from 'msw';
|
||||
|
||||
export const bytedeskHandlers = [
|
||||
// Bytedesk API 请求 - 直接 passthrough
|
||||
// 匹配 /bytedesk/* 路径(通过代理访问后端)
|
||||
// 未读消息数量
|
||||
http.get('/bytedesk/visitor/api/v1/message/unread/count', () => {
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: { count: 0 },
|
||||
});
|
||||
}),
|
||||
|
||||
// 其他 Bytedesk API - 返回通用成功响应
|
||||
http.all('/bytedesk/*', () => {
|
||||
return passthrough();
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: null,
|
||||
});
|
||||
}),
|
||||
|
||||
// Bytedesk 外部 CDN/服务请求
|
||||
|
||||
@@ -67,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
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -79,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
|
||||
});
|
||||
}),
|
||||
|
||||
|
||||
@@ -119,9 +119,12 @@ export const eventHandlers = [
|
||||
try {
|
||||
const result = generateMockEvents(params);
|
||||
|
||||
// 返回格式兼容 NewsPanel 期望的结构
|
||||
// NewsPanel 期望: { success, data: [], pagination: {} }
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
data: result.events, // 事件数组
|
||||
pagination: result.pagination, // 分页信息
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -135,16 +138,14 @@ export const eventHandlers = [
|
||||
{
|
||||
success: false,
|
||||
error: '获取事件列表失败',
|
||||
data: {
|
||||
events: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
total: 0,
|
||||
pages: 0, // ← 对齐后端字段名
|
||||
has_prev: false, // ← 对齐后端
|
||||
has_next: false // ← 对齐后端
|
||||
}
|
||||
data: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
total: 0,
|
||||
pages: 0,
|
||||
has_prev: false,
|
||||
has_next: false
|
||||
}
|
||||
},
|
||||
{ status: 500 }
|
||||
|
||||
@@ -341,6 +341,68 @@ export const stockHandlers = [
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取股票业绩预告
|
||||
http.get('/api/stock/:stockCode/forecast', async ({ params }) => {
|
||||
await delay(200);
|
||||
|
||||
const { stockCode } = params;
|
||||
console.log('[Mock Stock] 获取业绩预告:', { stockCode });
|
||||
|
||||
// 生成股票列表用于查找名称
|
||||
const stockList = generateStockList();
|
||||
const stockInfo = stockList.find(s => s.code === stockCode.replace(/\.(SH|SZ)$/i, ''));
|
||||
const stockName = stockInfo?.name || `股票${stockCode}`;
|
||||
|
||||
// 业绩预告类型列表
|
||||
const forecastTypes = ['预增', '预减', '略增', '略减', '扭亏', '续亏', '首亏', '续盈'];
|
||||
|
||||
// 生成业绩预告数据
|
||||
const forecasts = [
|
||||
{
|
||||
forecast_type: '预增',
|
||||
report_date: '2024年年报',
|
||||
content: `${stockName}预计2024年度归属于上市公司股东的净利润为58亿元至62亿元,同比增长10%至17%。`,
|
||||
reason: '报告期内,公司主营业务收入稳步增长,产品结构持续优化,毛利率提升;同时公司加大研发投入,新产品市场表现良好。',
|
||||
change_range: {
|
||||
lower: 10,
|
||||
upper: 17
|
||||
},
|
||||
publish_date: '2024-10-15'
|
||||
},
|
||||
{
|
||||
forecast_type: '略增',
|
||||
report_date: '2024年三季报',
|
||||
content: `${stockName}预计2024年1-9月归属于上市公司股东的净利润为42亿元至45亿元,同比增长5%至12%。`,
|
||||
reason: '公司积极拓展市场渠道,销售规模持续扩大,经营效益稳步提升。',
|
||||
change_range: {
|
||||
lower: 5,
|
||||
upper: 12
|
||||
},
|
||||
publish_date: '2024-07-12'
|
||||
},
|
||||
{
|
||||
forecast_type: forecastTypes[Math.floor(Math.random() * forecastTypes.length)],
|
||||
report_date: '2024年中报',
|
||||
content: `${stockName}预计2024年上半年归属于上市公司股东的净利润为28亿元至30亿元。`,
|
||||
reason: '受益于行业景气度回升及公司降本增效措施效果显现,经营业绩同比有所改善。',
|
||||
change_range: {
|
||||
lower: 3,
|
||||
upper: 8
|
||||
},
|
||||
publish_date: '2024-04-20'
|
||||
}
|
||||
];
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
stock_code: stockCode,
|
||||
stock_name: stockName,
|
||||
forecasts: forecasts
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
// 获取股票报价(批量)
|
||||
http.post('/api/stock/quotes', async ({ request }) => {
|
||||
await delay(200);
|
||||
@@ -421,7 +483,19 @@ export const stockHandlers = [
|
||||
// 行业和指数标签
|
||||
industry_l1: industryInfo.industry_l1,
|
||||
industry: industryInfo.industry,
|
||||
index_tags: industryInfo.index_tags || []
|
||||
index_tags: industryInfo.index_tags || [],
|
||||
// 关键指标
|
||||
pe: parseFloat((Math.random() * 50 + 5).toFixed(2)),
|
||||
eps: parseFloat((Math.random() * 5 + 0.1).toFixed(3)),
|
||||
pb: parseFloat((Math.random() * 8 + 0.5).toFixed(2)),
|
||||
market_cap: `${(Math.random() * 5000 + 100).toFixed(0)}亿`,
|
||||
week52_low: parseFloat((basePrice * 0.7).toFixed(2)),
|
||||
week52_high: parseFloat((basePrice * 1.3).toFixed(2)),
|
||||
// 主力动态
|
||||
main_net_inflow: parseFloat((Math.random() * 10 - 5).toFixed(2)),
|
||||
institution_holding: parseFloat((Math.random() * 50 + 10).toFixed(2)),
|
||||
buy_ratio: parseFloat((Math.random() * 40 + 30).toFixed(2)),
|
||||
sell_ratio: parseFloat((100 - (Math.random() * 40 + 30)).toFixed(2))
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 性能监控工具 - 统计白屏时间和性能指标
|
||||
|
||||
import { logger } from './logger';
|
||||
import { reportPerformanceMetrics } from '../lib/posthog';
|
||||
|
||||
/**
|
||||
* 性能指标接口
|
||||
@@ -208,6 +209,9 @@ class PerformanceMonitor {
|
||||
// 性能分析建议
|
||||
this.analyzePerformance();
|
||||
|
||||
// 上报性能指标到 PostHog
|
||||
reportPerformanceMetrics(this.metrics);
|
||||
|
||||
return this.metrics;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) + '%';
|
||||
};
|
||||
|
||||
@@ -1,63 +1,228 @@
|
||||
# Company 目录结构说明
|
||||
|
||||
> 最后更新:2025-12-10
|
||||
> 最后更新:2025-12-17(API 接口清单梳理)
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/views/Company/
|
||||
├── index.js # 页面入口(95行,纯组合层)
|
||||
├── index.js # 页面入口(纯组合层)
|
||||
├── STRUCTURE.md # 本文档
|
||||
│
|
||||
├── components/ # UI 组件
|
||||
│ │
|
||||
│ ├── LoadingState.tsx # 通用加载状态组件
|
||||
│ │
|
||||
│ ├── CompanyHeader/ # 页面头部
|
||||
│ │ ├── index.js # 组合导出
|
||||
│ │ ├── SearchBar.js # 股票搜索栏
|
||||
│ │ └── WatchlistButton.js # 自选股按钮
|
||||
│ │ └── SearchBar.js # 股票搜索栏
|
||||
│ │
|
||||
│ ├── CompanyTabs/ # Tab 切换容器
|
||||
│ │ ├── index.js # Tab 容器(状态管理 + 内容渲染)
|
||||
│ │ └── TabNavigation.js # Tab 导航栏
|
||||
│ │ └── index.js # Tab 容器(状态管理 + 内容渲染)
|
||||
│ │
|
||||
│ ├── CompanyOverview/ # Tab: 公司概览(TypeScript 拆分)
|
||||
│ │ ├── index.tsx # 主组件(组合层,约 50 行)
|
||||
│ │ ├── CompanyHeaderCard.tsx # 头部卡片组件(约 130 行)
|
||||
│ │ ├── BasicInfoTab.js # 基本信息 Tab(暂保持 JS)
|
||||
│ │ ├── DeepAnalysisTab.js # 深度分析 Tab
|
||||
│ ├── StockQuoteCard/ # 股票行情卡片(TypeScript,数据已下沉)
|
||||
│ │ ├── index.tsx # 主组件(Props 从 11 个精简为 4 个)
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ ├── mockData.ts # Mock 数据
|
||||
│ │ ├── hooks/ # 内部数据 Hooks(2025-12-17 新增)
|
||||
│ │ │ ├── index.ts # hooks 导出索引
|
||||
│ │ │ ├── useStockQuoteData.ts # 行情数据+基本信息获取
|
||||
│ │ │ └── useStockCompare.ts # 股票对比逻辑
|
||||
│ │ └── components/ # 子组件
|
||||
│ │ ├── index.ts # 组件导出
|
||||
│ │ ├── theme.ts # 主题配置
|
||||
│ │ ├── formatters.ts # 格式化工具
|
||||
│ │ ├── StockHeader.tsx # 股票头部(名称、代码、收藏按钮)
|
||||
│ │ ├── PriceDisplay.tsx # 价格显示组件
|
||||
│ │ ├── CompanyInfo.tsx # 公司信息(行业、市值等)
|
||||
│ │ ├── KeyMetrics.tsx # 关键指标(PE、PB、换手率等)
|
||||
│ │ ├── MainForceInfo.tsx # 主力资金信息
|
||||
│ │ ├── SecondaryQuote.tsx # 副行情(对比股票)
|
||||
│ │ ├── CompareStockInput.tsx # 对比股票输入
|
||||
│ │ └── StockCompareModal.tsx # 股票对比弹窗
|
||||
│ │
|
||||
│ ├── CompanyOverview/ # Tab: 公司概览(TypeScript)
|
||||
│ │ ├── index.tsx # 主组件(组合层)
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ ├── utils.ts # 格式化工具
|
||||
│ │ ├── NewsEventsTab.js # 新闻事件 Tab
|
||||
│ │ ├── types.ts # 类型定义(约 50 行)
|
||||
│ │ ├── utils.ts # 格式化工具(约 20 行)
|
||||
│ │ └── hooks/
|
||||
│ │ └── useCompanyOverviewData.ts # 数据 Hook(约 100 行)
|
||||
│ │ │
|
||||
│ │ ├── hooks/ # 数据 Hooks
|
||||
│ │ │ ├── useBasicInfo.ts # 基本信息 Hook
|
||||
│ │ │ ├── useShareholderData.ts # 股权结构 Hook(4 APIs)
|
||||
│ │ │ ├── useManagementData.ts # 管理团队 Hook
|
||||
│ │ │ ├── useAnnouncementsData.ts # 公告数据 Hook
|
||||
│ │ │ ├── useBranchesData.ts # 分支机构 Hook
|
||||
│ │ │ ├── useDisclosureData.ts # 披露日程 Hook
|
||||
│ │ │ └── useCompanyOverviewData.ts # [已废弃] 原合并 Hook
|
||||
│ │ │
|
||||
│ │ ├── components/ # 股权结构子组件
|
||||
│ │ │ └── shareholder/
|
||||
│ │ │ ├── index.ts # 导出
|
||||
│ │ │ ├── ActualControlCard.tsx # 实控人卡片
|
||||
│ │ │ ├── ConcentrationCard.tsx # 股权集中度卡片
|
||||
│ │ │ └── ShareholdersTable.tsx # 股东表格
|
||||
│ │ │
|
||||
│ │ ├── BasicInfoTab/ # 基本信息 Tab(可配置化)
|
||||
│ │ │ ├── index.tsx # 主组件(可配置)
|
||||
│ │ │ ├── config.ts # Tab 配置 + 黑金主题
|
||||
│ │ │ ├── utils.ts # 格式化工具函数
|
||||
│ │ │ └── components/ # 子组件
|
||||
│ │ │ ├── index.ts # 组件统一导出
|
||||
│ │ │ ├── LoadingState.tsx # 加载状态组件
|
||||
│ │ │ ├── ShareholderPanel.tsx # 股权结构面板
|
||||
│ │ │ ├── AnnouncementsPanel.tsx # 公告信息面板
|
||||
│ │ │ ├── BranchesPanel.tsx # 分支机构面板
|
||||
│ │ │ ├── BusinessInfoPanel.tsx # 工商信息面板
|
||||
│ │ │ ├── DisclosureSchedulePanel.tsx # 披露日程面板
|
||||
│ │ │ └── management/ # 管理团队模块
|
||||
│ │ │ ├── index.ts # 模块导出
|
||||
│ │ │ ├── types.ts # 类型定义
|
||||
│ │ │ ├── ManagementPanel.tsx # 主组件(useMemo)
|
||||
│ │ │ ├── CategorySection.tsx # 分类区块(memo)
|
||||
│ │ │ └── ManagementCard.tsx # 人员卡片(memo)
|
||||
│ │ │
|
||||
│ │ └── DeepAnalysisTab/ # 深度分析 Tab(原子设计模式)
|
||||
│ │ ├── index.tsx # 主入口组件
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ ├── atoms/ # 原子组件
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── DisclaimerBox.tsx # 免责声明
|
||||
│ │ │ ├── ScoreBar.tsx # 评分进度条
|
||||
│ │ │ ├── BusinessTreeItem.tsx # 业务树形项
|
||||
│ │ │ ├── KeyFactorCard.tsx # 关键因素卡片
|
||||
│ │ │ ├── ProcessNavigation.tsx # 流程导航
|
||||
│ │ │ └── ValueChainFilterBar.tsx # 产业链筛选栏
|
||||
│ │ ├── components/ # Card 组件
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── CorePositioningCard/ # 核心定位卡片(含 atoms)
|
||||
│ │ │ │ ├── index.tsx
|
||||
│ │ │ │ ├── theme.ts
|
||||
│ │ │ │ └── atoms/
|
||||
│ │ │ ├── CompetitiveAnalysisCard.tsx
|
||||
│ │ │ ├── BusinessStructureCard.tsx
|
||||
│ │ │ ├── BusinessSegmentsCard.tsx
|
||||
│ │ │ ├── ValueChainCard.tsx
|
||||
│ │ │ ├── KeyFactorsCard.tsx
|
||||
│ │ │ ├── TimelineCard.tsx
|
||||
│ │ │ └── StrategyAnalysisCard.tsx
|
||||
│ │ ├── organisms/ # 复杂交互组件
|
||||
│ │ │ ├── ValueChainNodeCard/
|
||||
│ │ │ │ ├── index.tsx
|
||||
│ │ │ │ └── RelatedCompaniesModal.tsx
|
||||
│ │ │ └── TimelineComponent/
|
||||
│ │ │ ├── index.tsx
|
||||
│ │ │ └── EventDetailModal.tsx
|
||||
│ │ ├── tabs/ # Tab 面板
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── BusinessTab.tsx
|
||||
│ │ │ ├── DevelopmentTab.tsx
|
||||
│ │ │ ├── StrategyTab.tsx
|
||||
│ │ │ └── ValueChainTab.tsx
|
||||
│ │ └── utils/
|
||||
│ │ └── chartOptions.ts
|
||||
│ │
|
||||
│ ├── MarketDataView/ # Tab: 股票行情(TypeScript 拆分)
|
||||
│ │ ├── index.tsx # 主组件入口(~1049 行)
|
||||
│ │ ├── types.ts # 类型定义(~383 行)
|
||||
│ │ ├── constants.ts # 主题配置、常量
|
||||
│ ├── MarketDataView/ # Tab: 股票行情(TypeScript)
|
||||
│ │ ├── index.tsx # 主组件入口
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ ├── constants.ts # 主题配置(含黑金主题 darkGoldTheme)
|
||||
│ │ ├── services/
|
||||
│ │ │ └── marketService.ts # API 服务层
|
||||
│ │ ├── hooks/
|
||||
│ │ │ └── useMarketData.ts # 数据获取 Hook
|
||||
│ │ ├── utils/
|
||||
│ │ │ ├── formatUtils.ts # 格式化工具函数
|
||||
│ │ │ └── chartOptions.ts # ECharts 图表配置生成器
|
||||
│ │ │ └── chartOptions.ts # ECharts 图表配置
|
||||
│ │ └── components/
|
||||
│ │ ├── index.ts # 组件导出
|
||||
│ │ ├── ThemedCard.tsx # 主题化卡片
|
||||
│ │ ├── MarkdownRenderer.tsx # Markdown 渲染
|
||||
│ │ ├── StockSummaryCard.tsx # 股票概览卡片
|
||||
│ │ └── AnalysisModal.tsx # 涨幅分析模态框
|
||||
│ │ ├── AnalysisModal.tsx # 涨幅分析模态框
|
||||
│ │ ├── StockSummaryCard/ # 股票概览卡片(黑金主题 4 列布局)
|
||||
│ │ │ ├── index.tsx
|
||||
│ │ │ ├── StockHeaderCard.tsx
|
||||
│ │ │ ├── MetricCard.tsx
|
||||
│ │ │ ├── utils.ts
|
||||
│ │ │ └── atoms/
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── DarkGoldCard.tsx
|
||||
│ │ │ ├── CardTitle.tsx
|
||||
│ │ │ ├── MetricValue.tsx
|
||||
│ │ │ ├── PriceDisplay.tsx
|
||||
│ │ │ └── StatusTag.tsx
|
||||
│ │ └── panels/ # Tab 面板组件
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── TradeDataPanel/
|
||||
│ │ │ ├── index.tsx
|
||||
│ │ │ └── KLineModule.tsx
|
||||
│ │ ├── FundingPanel.tsx
|
||||
│ │ ├── BigDealPanel.tsx
|
||||
│ │ ├── UnusualPanel.tsx
|
||||
│ │ └── PledgePanel.tsx
|
||||
│ │
|
||||
│ ├── FinancialPanorama/ # Tab: 财务全景(2153 行,待拆分)
|
||||
│ ├── DeepAnalysis/ # Tab: 深度分析(入口)
|
||||
│ │ └── index.js
|
||||
│ │
|
||||
│ └── ForecastReport/ # Tab: 盈利预测(161 行,待拆分)
|
||||
│ └── index.js
|
||||
│ ├── DynamicTracking/ # Tab: 动态跟踪
|
||||
│ │ ├── index.js # 主组件
|
||||
│ │ └── components/
|
||||
│ │ ├── index.js # 组件导出
|
||||
│ │ ├── NewsPanel.js # 新闻面板
|
||||
│ │ └── ForecastPanel.js # 业绩预告面板
|
||||
│ │
|
||||
│ ├── FinancialPanorama/ # Tab: 财务全景(TypeScript 模块化)
|
||||
│ │ ├── index.tsx # 主组件入口
|
||||
│ │ ├── types.ts # TypeScript 类型定义
|
||||
│ │ ├── constants.ts # 常量配置(颜色、指标定义)
|
||||
│ │ ├── hooks/
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ └── useFinancialData.ts
|
||||
│ │ ├── utils/
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── calculations.ts
|
||||
│ │ │ └── chartOptions.ts
|
||||
│ │ ├── tabs/ # Tab 面板组件
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── BalanceSheetTab.tsx
|
||||
│ │ │ ├── CashflowTab.tsx
|
||||
│ │ │ ├── FinancialMetricsTab.tsx
|
||||
│ │ │ ├── IncomeStatementTab.tsx
|
||||
│ │ │ └── MetricsCategoryTab.tsx
|
||||
│ │ └── components/
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── StockInfoHeader.tsx
|
||||
│ │ ├── FinancialTable.tsx # 通用财务表格
|
||||
│ │ ├── FinancialOverviewPanel.tsx # 财务概览面板
|
||||
│ │ ├── KeyMetricsOverview.tsx # 关键指标概览
|
||||
│ │ ├── PeriodSelector.tsx # 期数选择器
|
||||
│ │ ├── BalanceSheetTable.tsx
|
||||
│ │ ├── IncomeStatementTable.tsx
|
||||
│ │ ├── CashflowTable.tsx
|
||||
│ │ ├── FinancialMetricsTable.tsx
|
||||
│ │ ├── MainBusinessAnalysis.tsx
|
||||
│ │ ├── IndustryRankingView.tsx
|
||||
│ │ ├── StockComparison.tsx
|
||||
│ │ └── ComparisonAnalysis.tsx
|
||||
│ │
|
||||
│ └── ForecastReport/ # Tab: 盈利预测(TypeScript,已模块化)
|
||||
│ ├── index.tsx # 主组件入口
|
||||
│ ├── types.ts # 类型定义
|
||||
│ ├── constants.ts # 配色、图表配置常量
|
||||
│ └── components/
|
||||
│ ├── index.ts
|
||||
│ ├── ChartCard.tsx # 图表卡片容器
|
||||
│ ├── IncomeProfitGrowthChart.tsx # 营收与利润趋势图
|
||||
│ ├── IncomeProfitChart.tsx # 营收利润图(备用)
|
||||
│ ├── GrowthChart.tsx # 增长率图(备用)
|
||||
│ ├── EpsChart.tsx # EPS 趋势图
|
||||
│ ├── PePegChart.tsx # PE/PEG 分析图
|
||||
│ └── DetailTable.tsx # 详细数据表格
|
||||
│
|
||||
├── hooks/ # 自定义 Hooks
|
||||
├── hooks/ # 页面级 Hooks
|
||||
│ ├── useCompanyStock.js # 股票代码管理(URL 同步)
|
||||
│ ├── useCompanyWatchlist.js # 自选股管理(Redux 集成)
|
||||
│ └── useCompanyEvents.js # PostHog 事件追踪
|
||||
│ # 注:useStockQuote.js 已下沉到 StockQuoteCard/hooks/useStockQuoteData.ts
|
||||
│
|
||||
└── constants/ # 常量定义
|
||||
└── index.js # Tab 配置、Toast 消息、默认值
|
||||
@@ -65,19 +230,101 @@ src/views/Company/
|
||||
|
||||
---
|
||||
|
||||
## API 接口清单
|
||||
|
||||
Company 模块共使用 **27 个** API 接口(去重后)。
|
||||
|
||||
### 一、股票基础信息 (8 个)
|
||||
|
||||
| 接口 | 方法 | 调用位置 |
|
||||
|------|------|----------|
|
||||
| `/api/stock/${stockCode}/basic-info` | GET | useBasicInfo.ts, useStockQuoteData.ts, NewsPanel.js |
|
||||
| `/api/stock/${stockCode}/branches` | GET | useBranchesData.ts |
|
||||
| `/api/stock/${stockCode}/management?active_only=true` | GET | useManagementData.ts |
|
||||
| `/api/stock/${stockCode}/announcements?limit=20` | GET | useAnnouncementsData.ts |
|
||||
| `/api/stock/${stockCode}/disclosure-schedule` | GET | useDisclosureData.ts |
|
||||
| `/api/stock/${stockCode}/forecast` | GET | ForecastPanel.js |
|
||||
| `/api/stock/${stockCode}/forecast-report` | GET | ForecastReport/index.tsx |
|
||||
| `/api/stock/${stockCode}/latest-minute` | GET | marketService.ts |
|
||||
|
||||
### 二、股东信息 (4 个)
|
||||
|
||||
| 接口 | 方法 | 调用位置 |
|
||||
|------|------|----------|
|
||||
| `/api/stock/${stockCode}/actual-control` | GET | useShareholderData.ts |
|
||||
| `/api/stock/${stockCode}/concentration` | GET | useShareholderData.ts |
|
||||
| `/api/stock/${stockCode}/top-shareholders?limit=10` | GET | useShareholderData.ts |
|
||||
| `/api/stock/${stockCode}/top-circulation-shareholders?limit=10` | GET | useShareholderData.ts |
|
||||
|
||||
### 三、行情数据 (8 个)
|
||||
|
||||
| 接口 | 方法 | 调用位置 |
|
||||
|------|------|----------|
|
||||
| `/api/stock/quotes` | POST | stockService.getQuotes |
|
||||
| `/api/market/summary/${stockCode}` | GET | marketService.ts |
|
||||
| `/api/market/trade/${stockCode}?days=${days}` | GET | marketService.ts |
|
||||
| `/api/market/funding/${stockCode}?days=${days}` | GET | marketService.ts |
|
||||
| `/api/market/bigdeal/${stockCode}?days=${days}` | GET | marketService.ts |
|
||||
| `/api/market/unusual/${stockCode}?days=${days}` | GET | marketService.ts |
|
||||
| `/api/market/pledge/${stockCode}` | GET | marketService.ts |
|
||||
| `/api/market/rise-analysis/${stockCode}` | GET | marketService.ts |
|
||||
|
||||
### 四、深度分析 (5 个)
|
||||
|
||||
| 接口 | 方法 | 调用位置 |
|
||||
|------|------|----------|
|
||||
| `/api/company/comprehensive-analysis/${stockCode}` | GET | DeepAnalysis/index.js |
|
||||
| `/api/company/value-chain-analysis/${stockCode}` | GET | DeepAnalysis/index.js |
|
||||
| `/api/company/key-factors-timeline/${stockCode}` | GET | DeepAnalysis/index.js |
|
||||
| `/api/company/value-chain/related-companies?node_name=...` | GET | ValueChainNodeCard/index.tsx |
|
||||
| `/api/financial/industry-rank/${stockCode}` | GET | DeepAnalysis/index.js |
|
||||
|
||||
### 五、财务数据 (1 个)
|
||||
|
||||
| 接口 | 方法 | 调用位置 |
|
||||
|------|------|----------|
|
||||
| `/api/financial/financial-metrics/${stockCode}?limit=${limit}` | GET | financialService.getFinancialMetrics |
|
||||
|
||||
### 六、事件/新闻 (1 个)
|
||||
|
||||
| 接口 | 方法 | 调用位置 |
|
||||
|------|------|----------|
|
||||
| `/api/events?q=${searchTerm}&page=${page}&per_page=10` | GET | NewsPanel.js |
|
||||
|
||||
### 统计汇总
|
||||
|
||||
| 分类 | 数量 |
|
||||
|------|------|
|
||||
| 股票基础信息 | 8 |
|
||||
| 股东信息 | 4 |
|
||||
| 行情数据 | 8 |
|
||||
| 深度分析 | 5 |
|
||||
| 财务数据 | 1 |
|
||||
| 事件/新闻 | 1 |
|
||||
| **去重后总计** | **27** |
|
||||
|
||||
> 注:`/api/stock/${stockCode}/basic-info` 在 3 处调用,但只算 1 个接口。
|
||||
|
||||
---
|
||||
|
||||
## 文件职责说明
|
||||
|
||||
### 入口文件
|
||||
|
||||
#### `index.js` - 页面入口
|
||||
- **职责**:纯组合层,协调 Hooks 和 Components
|
||||
- **代码行数**:95 行
|
||||
- **代码行数**:~105 行(2025-12-17 优化后精简)
|
||||
- **依赖**:
|
||||
- `useCompanyStock` - 股票代码状态
|
||||
- `useCompanyWatchlist` - 自选股状态
|
||||
- `useCompanyEvents` - 事件追踪
|
||||
- `CompanyHeader` - 页面头部
|
||||
- `StockQuoteCard` - 股票行情卡片(内部自行获取数据)
|
||||
- `CompanyTabs` - Tab 切换区
|
||||
- **已移除**(2025-12-17):
|
||||
- `useStockQuote` - 已下沉到 StockQuoteCard
|
||||
- `useBasicInfo` - 已下沉到 StockQuoteCard
|
||||
- 股票对比逻辑 - 已下沉到 StockQuoteCard
|
||||
|
||||
---
|
||||
|
||||
@@ -292,7 +539,7 @@ CompanyOverview/
|
||||
├── index.tsx # 主组件(组合层,约 60 行)
|
||||
├── CompanyHeaderCard.tsx # 头部卡片组件(约 130 行)
|
||||
├── BasicInfoTab.js # 基本信息 Tab(懒加载版本,约 994 行)
|
||||
├── DeepAnalysisTab.js # 深度分析 Tab
|
||||
├── DeepAnalysisTab/ # 深度分析 Tab(21 个 TS 文件,见 2025-12-11 重构记录)
|
||||
├── NewsEventsTab.js # 新闻事件 Tab
|
||||
├── types.ts # 类型定义(约 50 行)
|
||||
├── utils.ts # 格式化工具(约 20 行)
|
||||
@@ -388,7 +635,7 @@ CompanyOverview/
|
||||
**拆分后文件结构**:
|
||||
```
|
||||
MarketDataView/
|
||||
├── index.tsx # 主组件入口(~1049 行)
|
||||
├── index.tsx # 主组件入口(~285 行,Tab 容器)
|
||||
├── types.ts # 类型定义(~383 行)
|
||||
├── constants.ts # 主题配置、常量(~49 行)
|
||||
├── services/
|
||||
@@ -403,14 +650,21 @@ MarketDataView/
|
||||
├── ThemedCard.tsx # 主题化卡片(~32 行)
|
||||
├── MarkdownRenderer.tsx # Markdown 渲染(~65 行)
|
||||
├── StockSummaryCard.tsx # 股票概览卡片(~133 行)
|
||||
└── AnalysisModal.tsx # 涨幅分析模态框(~188 行)
|
||||
├── AnalysisModal.tsx # 涨幅分析模态框(~188 行)
|
||||
└── panels/ # Tab 面板组件(2025-12-12 拆分)
|
||||
├── index.ts # 面板组件统一导出
|
||||
├── TradeDataPanel.tsx # 交易数据面板(~381 行)
|
||||
├── FundingPanel.tsx # 融资融券面板(~113 行)
|
||||
├── BigDealPanel.tsx # 大宗交易面板(~143 行)
|
||||
├── UnusualPanel.tsx # 龙虎榜面板(~163 行)
|
||||
└── PledgePanel.tsx # 股权质押面板(~124 行)
|
||||
```
|
||||
|
||||
**文件职责说明**:
|
||||
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `index.tsx` | ~1049 | 主组件,包含 5 个 Tab 面板(交易数据、融资融券、大宗交易、龙虎榜、股权质押) |
|
||||
| `index.tsx` | ~285 | 主组件,Tab 容器和状态管理,导入使用 5 个 Panel 组件 |
|
||||
| `types.ts` | ~383 | 所有 TypeScript 类型定义(Theme、TradeDayData、MinuteData、FundingData 等) |
|
||||
| `constants.ts` | ~49 | 主题配置(light/dark)、周期选项常量 |
|
||||
| `marketService.ts` | ~173 | API 服务封装(getMarketData、getMinuteData、getBigDealData 等) |
|
||||
@@ -457,4 +711,543 @@ MarketDataView/
|
||||
- **TypeScript 类型安全**:所有数据结构有完整类型定义
|
||||
- **服务层分离**:API 调用统一在 `marketService.ts` 中管理
|
||||
- **图表配置抽离**:复杂的 ECharts 配置集中在 `chartOptions.ts`
|
||||
- **组件复用**:通用组件(ThemedCard、MarkdownRenderer)可在其他模块使用
|
||||
- **组件复用**:通用组件(ThemedCard、MarkdownRenderer)可在其他模块使用
|
||||
|
||||
### 2025-12-10 ManagementPanel 拆分重构
|
||||
|
||||
**改动概述**:
|
||||
- `ManagementPanel.tsx` 从 **180 行** 拆分为 **5 个 TypeScript 文件**
|
||||
- 创建 `management/` 子目录,模块化管理
|
||||
- 添加性能优化(`useMemo`、`React.memo`)
|
||||
|
||||
**拆分后文件结构**:
|
||||
```
|
||||
components/management/
|
||||
├── index.ts # 模块导出
|
||||
├── types.ts # 类型定义(~35 行)
|
||||
├── ManagementPanel.tsx # 主组件(~105 行,useMemo 优化)
|
||||
├── CategorySection.tsx # 分类区块组件(~65 行,memo)
|
||||
└── ManagementCard.tsx # 人员卡片组件(~100 行,memo)
|
||||
```
|
||||
|
||||
**类型定义**(`types.ts`):
|
||||
- `ManagementPerson` - 管理人员信息
|
||||
- `ManagementCategory` - 分类类型(高管/董事/监事/其他)
|
||||
- `CategorizedManagement` - 分类后的数据结构
|
||||
- `CategoryConfig` - 分类配置(图标、颜色)
|
||||
|
||||
**性能优化**:
|
||||
- `useMemo` - 缓存 `categorizeManagement()` 分类计算结果
|
||||
- `React.memo` - `ManagementCard` 和 `CategorySection` 使用 memo 包装
|
||||
- 常量提取 - `CATEGORY_CONFIG` 和 `CATEGORY_ORDER` 提取到组件外部
|
||||
|
||||
**设计原则**:
|
||||
- **职责分离**:卡片渲染、分类区块、数据处理各自独立
|
||||
- **类型安全**:消除 `any` 类型,完整的 TypeScript 类型定义
|
||||
- **可复用性**:`ManagementCard` 可独立使用
|
||||
|
||||
### 2025-12-11 DeepAnalysisTab 模块化拆分(TypeScript)
|
||||
|
||||
**改动概述**:
|
||||
- `DeepAnalysisTab.js` 从 **1,796 行** 拆分为 **21 个 TypeScript 文件**
|
||||
- 采用**原子设计模式**(atoms/components/organisms)组织代码
|
||||
- 完整 TypeScript 类型定义
|
||||
- 格式化工具合并到全局 `src/utils/priceFormatters.js`
|
||||
|
||||
**拆分后文件结构**:
|
||||
```
|
||||
DeepAnalysisTab/
|
||||
├── index.tsx # 主入口组件,组合所有 Card 子组件
|
||||
├── types.ts # TypeScript 类型定义(接口、数据结构)
|
||||
├── atoms/ # 原子组件(基础 UI 元素)
|
||||
│ ├── index.ts # 原子组件统一导出
|
||||
│ ├── DisclaimerBox.tsx # 免责声明警告框(黄色 Alert,用 6 次)
|
||||
│ ├── ScoreBar.tsx # 评分进度条(带颜色渐变,用 8 次)
|
||||
│ ├── BusinessTreeItem.tsx # 业务结构树形项(递归组件)
|
||||
│ └── KeyFactorCard.tsx # 关键因素卡片(带影响方向图标)
|
||||
├── components/ # Card 容器组件(页面区块)
|
||||
│ ├── index.ts # Card 组件统一导出
|
||||
│ ├── CorePositioningCard.tsx # 核心定位卡片(行业地位、核心优势)
|
||||
│ ├── CompetitiveAnalysisCard.tsx # 竞争地位分析卡片(雷达图 + 评分条)
|
||||
│ ├── BusinessStructureCard.tsx # 业务结构分析卡片(树形展示)
|
||||
│ ├── ValueChainCard.tsx # 产业链分析卡片(Tabs: 上游/中游/下游)
|
||||
│ ├── KeyFactorsCard.tsx # 关键因素卡片(Accordion 折叠面板)
|
||||
│ ├── TimelineCard.tsx # 发展时间线卡片(正面/负面事件统计)
|
||||
│ ├── BusinessSegmentsCard.tsx # 业务板块详情卡片(可展开/折叠)
|
||||
│ └── StrategyAnalysisCard.tsx # 战略分析卡片(战略方向 + 战略举措)
|
||||
├── organisms/ # 复杂组件(含状态管理和 API 调用)
|
||||
│ ├── ValueChainNodeCard/ # 产业链节点组件
|
||||
│ │ ├── index.tsx # 产业链节点卡片(点击展开详情)
|
||||
│ │ └── RelatedCompaniesModal.tsx # 相关公司模态框(API 获取公司列表)
|
||||
│ └── TimelineComponent/ # 时间线组件
|
||||
│ ├── index.tsx # 时间线主组件(事件列表渲染)
|
||||
│ └── EventDetailModal.tsx # 事件详情模态框(查看完整事件信息)
|
||||
└── utils/
|
||||
└── chartOptions.ts # ECharts 图表配置(雷达图、桑基图)
|
||||
```
|
||||
|
||||
**组件依赖关系**:
|
||||
```
|
||||
index.tsx
|
||||
├── CorePositioningCard
|
||||
├── CompetitiveAnalysisCard
|
||||
│ ├── ScoreBar (atom)
|
||||
│ ├── DisclaimerBox (atom)
|
||||
│ └── ReactECharts (雷达图)
|
||||
├── BusinessStructureCard
|
||||
│ └── BusinessTreeItem (atom, 递归)
|
||||
├── ValueChainCard
|
||||
│ └── ValueChainNodeCard (organism)
|
||||
│ └── RelatedCompaniesModal
|
||||
├── KeyFactorsCard
|
||||
│ └── KeyFactorCard (atom)
|
||||
├── TimelineCard
|
||||
│ └── TimelineComponent (organism)
|
||||
│ └── EventDetailModal
|
||||
├── BusinessSegmentsCard
|
||||
└── StrategyAnalysisCard
|
||||
└── DisclaimerBox (atom)
|
||||
```
|
||||
|
||||
**类型定义**(`types.ts`):
|
||||
- `DeepAnalysisTabProps` - 主组件 Props
|
||||
- `QualitativeAnalysis` - 定性分析数据
|
||||
- `CompetitivePosition` - 竞争地位数据
|
||||
- `BusinessStructureItem` - 业务结构项
|
||||
- `ValueChainData` - 产业链数据
|
||||
- `ValueChainNode` - 产业链节点
|
||||
- `KeyFactor` - 关键因素
|
||||
- `DevelopmentTimeline` - 发展时间线
|
||||
- `TimelineEvent` - 时间线事件
|
||||
- `BusinessSegment` - 业务板块
|
||||
- `Strategy` - 战略分析
|
||||
|
||||
**工具函数位置**:
|
||||
| 函数 | 文件位置 | 说明 |
|
||||
|------|----------|------|
|
||||
| `formatCurrency` | `src/utils/priceFormatters.js` | 货币格式化 |
|
||||
| `formatBusinessRevenue` | `src/utils/priceFormatters.js` | 营收格式化(亿/万) |
|
||||
| `formatPercentage` | `src/utils/priceFormatters.js` | 百分比格式化 |
|
||||
| `getRadarChartOption` | `DeepAnalysisTab/utils/chartOptions.ts` | 雷达图 ECharts 配置 |
|
||||
| `getSankeyChartOption` | `DeepAnalysisTab/utils/chartOptions.ts` | 桑基图 ECharts 配置 |
|
||||
|
||||
**优化效果**:
|
||||
| 指标 | 优化前 | 优化后 | 改善 |
|
||||
|------|--------|--------|------|
|
||||
| 主文件行数 | 1,796 | ~117 | -93% |
|
||||
| 文件数量 | 1 (.js) | 21 (.tsx/.ts) | 模块化 + TS |
|
||||
| 可复用组件 | 0 | 4 原子 + 2 复杂 | 提升 |
|
||||
| 类型安全 | 无 | 完整 | TypeScript |
|
||||
|
||||
**设计原则**:
|
||||
- **原子设计模式**:atoms(基础元素)→ components(区块)→ organisms(复杂交互)
|
||||
- **TypeScript 类型安全**:完整的接口定义,消除 any 类型
|
||||
- **职责分离**:UI 渲染与 API 调用分离,模态框独立管理
|
||||
- **代码复用**:DisclaimerBox、ScoreBar 等原子组件多处复用
|
||||
|
||||
### 2025-12-12 FinancialPanorama 模块化拆分(TypeScript)
|
||||
|
||||
**改动概述**:
|
||||
- `FinancialPanorama/index.js` 从 **2,150 行** 拆分为 **21 个 TypeScript 文件**
|
||||
- 提取 **1 个自定义 Hook**(`useFinancialData`)
|
||||
- 提取 **9 个子组件**(表格组件 + 分析组件)
|
||||
- 抽离类型定义到 `types.ts`
|
||||
- 抽离常量配置到 `constants.ts`
|
||||
- 抽离工具函数到 `utils/`
|
||||
|
||||
**拆分后文件结构**:
|
||||
```
|
||||
FinancialPanorama/
|
||||
├── index.tsx # 主入口组件(~400 行)
|
||||
├── types.ts # TypeScript 类型定义(~441 行)
|
||||
├── constants.ts # 常量配置(颜色、指标定义)
|
||||
├── hooks/
|
||||
│ ├── index.ts # Hook 统一导出
|
||||
│ └── useFinancialData.ts # 财务数据加载 Hook(9 API 并行加载)
|
||||
├── utils/
|
||||
│ ├── index.ts # 工具函数统一导出
|
||||
│ ├── calculations.ts # 计算工具(同比变化率、单元格背景色)
|
||||
│ └── chartOptions.ts # ECharts 图表配置生成器
|
||||
└── components/
|
||||
├── index.ts # 组件统一导出
|
||||
├── StockInfoHeader.tsx # 股票信息头部(~95 行)
|
||||
├── BalanceSheetTable.tsx # 资产负债表(~220 行,可展开分组)
|
||||
├── IncomeStatementTable.tsx # 利润表(~205 行,可展开分组)
|
||||
├── CashflowTable.tsx # 现金流量表(~140 行)
|
||||
├── FinancialMetricsTable.tsx # 财务指标表(~260 行,7 分类切换)
|
||||
├── MainBusinessAnalysis.tsx # 主营业务分析(~180 行,饼图 + 表格)
|
||||
├── IndustryRankingView.tsx # 行业排名(~110 行)
|
||||
├── StockComparison.tsx # 股票对比(~210 行,含独立数据加载)
|
||||
└── ComparisonAnalysis.tsx # 综合对比分析(~40 行)
|
||||
```
|
||||
|
||||
**组件依赖关系**:
|
||||
```
|
||||
index.tsx
|
||||
├── useFinancialData (hook) # 数据加载
|
||||
├── StockInfoHeader # 股票基本信息展示
|
||||
├── ComparisonAnalysis # 营收与利润趋势图
|
||||
├── FinancialMetricsTable # 财务指标表(7 分类)
|
||||
├── BalanceSheetTable # 资产负债表(可展开)
|
||||
├── IncomeStatementTable # 利润表(可展开)
|
||||
├── CashflowTable # 现金流量表
|
||||
├── MainBusinessAnalysis # 主营业务(饼图)
|
||||
├── IndustryRankingView # 行业排名
|
||||
└── StockComparison # 股票对比(独立状态)
|
||||
```
|
||||
|
||||
**类型定义**(`types.ts`):
|
||||
- `StockInfo` - 股票基本信息
|
||||
- `BalanceSheetData` - 资产负债表数据
|
||||
- `IncomeStatementData` - 利润表数据
|
||||
- `CashflowData` - 现金流量表数据
|
||||
- `FinancialMetricsData` - 财务指标数据(7 分类)
|
||||
- `ProductClassification` / `IndustryClassification` - 主营业务分类
|
||||
- `IndustryRankData` - 行业排名数据
|
||||
- `ForecastData` - 业绩预告数据
|
||||
- `ComparisonData` - 对比数据
|
||||
- `MetricConfig` / `MetricSectionConfig` - 指标配置类型
|
||||
- 各组件 Props 类型
|
||||
|
||||
**常量配置**(`constants.ts`):
|
||||
- `COLORS` - 颜色配置(中国市场:红涨绿跌)
|
||||
- `CURRENT_ASSETS_METRICS` / `NON_CURRENT_ASSETS_METRICS` 等 - 资产负债表指标
|
||||
- `INCOME_STATEMENT_SECTIONS` - 利润表分组配置
|
||||
- `CASHFLOW_METRICS` - 现金流量表指标
|
||||
- `FINANCIAL_METRICS_CATEGORIES` - 财务指标 7 大分类
|
||||
- `RANKING_METRICS` / `COMPARE_METRICS` - 排名和对比指标
|
||||
|
||||
**工具函数**(`utils/`):
|
||||
| 函数 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| `calculateYoYChange` | calculations.ts | 计算同比变化率和强度 |
|
||||
| `getCellBackground` | calculations.ts | 根据变化率返回单元格背景色 |
|
||||
| `getValueByPath` | calculations.ts | 从嵌套对象获取值 |
|
||||
| `isNegativeIndicator` | calculations.ts | 判断是否为负向指标 |
|
||||
| `getMetricChartOption` | chartOptions.ts | 指标趋势图配置 |
|
||||
| `getComparisonChartOption` | chartOptions.ts | 营收与利润对比图配置 |
|
||||
| `getMainBusinessPieOption` | chartOptions.ts | 主营业务饼图配置 |
|
||||
| `getCompareBarChartOption` | chartOptions.ts | 股票对比柱状图配置 |
|
||||
|
||||
**Hook 返回值**(`useFinancialData`):
|
||||
```typescript
|
||||
{
|
||||
// 数据状态
|
||||
stockInfo: StockInfo | null;
|
||||
balanceSheet: BalanceSheetData[];
|
||||
incomeStatement: IncomeStatementData[];
|
||||
cashflow: CashflowData[];
|
||||
financialMetrics: FinancialMetricsData[];
|
||||
mainBusiness: MainBusinessData | null;
|
||||
forecast: ForecastData | null;
|
||||
industryRank: IndustryRankData[];
|
||||
comparison: ComparisonData[];
|
||||
|
||||
// 加载状态
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// 操作方法
|
||||
refetch: () => Promise<void>;
|
||||
setStockCode: (code: string) => void;
|
||||
setSelectedPeriods: (periods: number) => void;
|
||||
|
||||
// 当前参数
|
||||
currentStockCode: string;
|
||||
selectedPeriods: number;
|
||||
}
|
||||
```
|
||||
|
||||
**优化效果**:
|
||||
| 指标 | 优化前 | 优化后 | 改善 |
|
||||
|------|--------|--------|------|
|
||||
| 主文件行数 | 2,150 | ~400 | -81% |
|
||||
| 文件数量 | 1 (.js) | 21 (.tsx/.ts) | 模块化 + TS |
|
||||
| 可复用组件 | 0(内联) | 9 个独立组件 | 提升 |
|
||||
| 类型安全 | 无 | 完整 | TypeScript |
|
||||
|
||||
**设计原则**:
|
||||
- **TypeScript 类型安全**:完整的接口定义,消除 any 类型
|
||||
- **Hook 数据层**:`useFinancialData` 封装 9 个 API 并行加载
|
||||
- **组件解耦**:每个表格/分析视图独立为组件
|
||||
- **常量配置化**:指标定义可维护、可扩展
|
||||
- **工具函数复用**:计算和图表配置统一管理
|
||||
|
||||
### 2025-12-12 MarketDataView Panel 拆分
|
||||
|
||||
**改动概述**:
|
||||
- `MarketDataView/index.tsx` 从 **1,049 行** 精简至 **285 行**(减少 73%)
|
||||
- 将 5 个 TabPanel 拆分为独立的面板组件
|
||||
- 创建 `components/panels/` 子目录
|
||||
|
||||
**拆分后文件结构**:
|
||||
```
|
||||
MarketDataView/components/panels/
|
||||
├── index.ts # 面板组件统一导出
|
||||
├── TradeDataPanel.tsx # 交易数据面板(~381 行)
|
||||
├── FundingPanel.tsx # 融资融券面板(~113 行)
|
||||
├── BigDealPanel.tsx # 大宗交易面板(~143 行)
|
||||
├── UnusualPanel.tsx # 龙虎榜面板(~163 行)
|
||||
└── PledgePanel.tsx # 股权质押面板(~124 行)
|
||||
```
|
||||
|
||||
**面板组件职责**:
|
||||
|
||||
| 组件 | 行数 | 功能 |
|
||||
|------|------|------|
|
||||
| `TradeDataPanel` | ~381 | K线图、分钟K线图、交易明细表格 |
|
||||
| `FundingPanel` | ~113 | 融资融券图表和数据卡片 |
|
||||
| `BigDealPanel` | ~143 | 大宗交易记录表格 |
|
||||
| `UnusualPanel` | ~163 | 龙虎榜数据(买入/卖出前五) |
|
||||
| `PledgePanel` | ~124 | 股权质押图表和明细表格 |
|
||||
|
||||
**优化效果**:
|
||||
| 指标 | 优化前 | 优化后 | 改善 |
|
||||
|------|--------|--------|------|
|
||||
| 主文件行数 | 1,049 | 285 | -73% |
|
||||
| 面板组件 | 内联 | 5 个独立文件 | 模块化 |
|
||||
| 可维护性 | 低 | 高 | 每个面板独立维护 |
|
||||
|
||||
**设计原则**:
|
||||
- **职责分离**:主组件只负责 Tab 容器和状态管理
|
||||
- **组件复用**:面板组件可独立测试和维护
|
||||
- **类型安全**:每个面板组件有独立的 Props 类型定义
|
||||
|
||||
### 2025-12-16 StockSummaryCard 黑金主题重构
|
||||
|
||||
**改动概述**:
|
||||
- `StockSummaryCard.tsx` 从单文件重构为**原子设计模式**的目录结构
|
||||
- 布局从 **1+3**(头部+三卡片)改为 **4 列横向排列**
|
||||
- 新增**黑金主题**(`darkGoldTheme`)
|
||||
- 提取 **5 个原子组件** + **2 个业务组件**
|
||||
|
||||
**拆分后文件结构**:
|
||||
```
|
||||
StockSummaryCard/
|
||||
├── index.tsx # 主组件(4 列 SimpleGrid 布局)
|
||||
├── StockHeaderCard.tsx # 股票信息卡片(名称、价格、涨跌幅、走势)
|
||||
├── MetricCard.tsx # 指标卡片模板组件
|
||||
├── utils.ts # 状态计算工具函数
|
||||
└── atoms/ # 原子组件
|
||||
├── index.ts # 统一导出
|
||||
├── DarkGoldCard.tsx # 黑金主题卡片容器(渐变背景、金色边框)
|
||||
├── CardTitle.tsx # 卡片标题(图标+标题+副标题)
|
||||
├── MetricValue.tsx # 核心数值展示(标签+数值+后缀)
|
||||
├── PriceDisplay.tsx # 价格显示(价格+涨跌箭头+百分比)
|
||||
└── StatusTag.tsx # 状态标签(活跃/健康/警惕等)
|
||||
```
|
||||
|
||||
**4 列布局设计**:
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ 股票信息 │ │ 交易热度 │ │ 估值VS安全 │ │ 情绪与风险 │
|
||||
│ 平安银行 │ │ (流动性) │ │ (便宜否) │ │ (资金面) │
|
||||
│ (000001) │ │ │ │ │ │ │
|
||||
│ 13.50 ↗+1.89%│ │ 成交额 46.79亿│ │ PE 4.96 │ │ 融资 58.23亿 │
|
||||
│ 走势:小幅上涨 │ │ 成交量|换手率 │ │ 质押率(健康) │ │ 融券 1.26亿 │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
**黑金主题配置**(`constants.ts`):
|
||||
```typescript
|
||||
export const darkGoldTheme = {
|
||||
bgCard: 'linear-gradient(135deg, #1a1a2e 0%, #0f0f1a 100%)',
|
||||
border: 'rgba(212, 175, 55, 0.3)',
|
||||
gold: '#D4AF37',
|
||||
orange: '#FF9500',
|
||||
green: '#00C851',
|
||||
red: '#FF4444',
|
||||
textPrimary: '#FFFFFF',
|
||||
textMuted: 'rgba(255, 255, 255, 0.6)',
|
||||
};
|
||||
```
|
||||
|
||||
**状态计算工具**(`utils.ts`):
|
||||
| 函数 | 功能 |
|
||||
|------|------|
|
||||
| `getTrendDescription` | 根据涨跌幅返回走势描述(强势上涨/小幅下跌等) |
|
||||
| `getTurnoverStatus` | 换手率状态(≥3% 活跃, ≥1% 正常, <1% 冷清) |
|
||||
| `getPEStatus` | 市盈率估值评级(极低估值/合理/偏高/泡沫风险) |
|
||||
| `getPledgeStatus` | 质押率健康状态(<10% 健康, <30% 正常, <50% 偏高, ≥50% 警惕) |
|
||||
| `getPriceColor` | 根据涨跌返回颜色(红涨绿跌) |
|
||||
|
||||
**原子组件说明**:
|
||||
| 组件 | 行数 | 用途 | 可复用场景 |
|
||||
|------|------|------|-----------|
|
||||
| `DarkGoldCard` | ~40 | 黑金主题卡片容器 | 任何需要黑金风格的卡片 |
|
||||
| `CardTitle` | ~30 | 卡片标题行 | 带图标的标题展示 |
|
||||
| `MetricValue` | ~45 | 核心数值展示 | 各种指标数值展示 |
|
||||
| `PriceDisplay` | ~55 | 价格+涨跌幅 | 股票价格展示 |
|
||||
| `StatusTag` | ~20 | 状态标签 | 各种状态文字标签 |
|
||||
|
||||
**响应式断点**:
|
||||
- `lg` (≥992px): 4 列
|
||||
- `md` (≥768px): 2 列
|
||||
- `base` (<768px): 1 列
|
||||
|
||||
**类型定义更新**(`types.ts`):
|
||||
- `StockSummaryCardProps.theme` 改为可选参数,组件内置使用 `darkGoldTheme`
|
||||
|
||||
**优化效果**:
|
||||
| 指标 | 优化前 | 优化后 | 改善 |
|
||||
|------|--------|--------|------|
|
||||
| 主文件行数 | ~350 | ~115 | -67% |
|
||||
| 文件数量 | 1 | 8 | 原子设计模式 |
|
||||
| 可复用组件 | 0 | 5 原子 + 2 业务 | 提升 |
|
||||
| 主题支持 | 依赖传入 | 内置黑金主题 | 独立 |
|
||||
|
||||
**设计原则**:
|
||||
- **原子设计模式**:atoms(基础元素)→ 业务组件(MetricCard、StockHeaderCard)→ 页面组件(index.tsx)
|
||||
- **主题独立**:StockSummaryCard 使用内置黑金主题,不依赖外部传入
|
||||
- **职责分离**:状态计算逻辑提取到 `utils.ts`,UI 与逻辑解耦
|
||||
- **组件复用**:原子组件可在其他黑金主题场景复用
|
||||
|
||||
### 2025-12-16 TradeDataPanel 原子设计模式拆分
|
||||
|
||||
**改动概述**:
|
||||
- `TradeDataPanel.tsx` 从 **382 行** 拆分为 **8 个 TypeScript 文件**
|
||||
- 采用**原子设计模式**组织代码
|
||||
- 提取 **3 个原子组件** + **3 个业务组件**
|
||||
|
||||
**拆分后文件结构**:
|
||||
```
|
||||
TradeDataPanel/
|
||||
├── index.tsx # 主入口组件(~50 行,组合 3 个子组件)
|
||||
├── KLineChart.tsx # 日K线图组件(~40 行)
|
||||
├── MinuteKLineSection.tsx # 分钟K线区域(~95 行,含加载/空状态处理)
|
||||
├── TradeTable.tsx # 交易明细表格(~75 行)
|
||||
└── atoms/ # 原子组件
|
||||
├── index.ts # 统一导出
|
||||
├── MinuteStats.tsx # 分钟数据统计(~80 行,4 个 Stat 卡片)
|
||||
├── TradeAnalysis.tsx # 成交分析(~65 行,活跃时段/平均价格等)
|
||||
└── EmptyState.tsx # 空状态组件(~35 行,可复用)
|
||||
```
|
||||
|
||||
**组件依赖关系**:
|
||||
```
|
||||
index.tsx
|
||||
├── KLineChart # 日K线图(ECharts)
|
||||
├── MinuteKLineSection # 分钟K线区域
|
||||
│ ├── MinuteStats (atom) # 开盘/当前/最高/最低价统计
|
||||
│ ├── TradeAnalysis (atom) # 成交数据分析
|
||||
│ └── EmptyState (atom) # 空状态提示
|
||||
└── TradeTable # 交易明细表格(最近 10 天)
|
||||
```
|
||||
|
||||
**组件职责**:
|
||||
| 组件 | 行数 | 功能 |
|
||||
|------|------|------|
|
||||
| `index.tsx` | ~50 | 主入口,组合 3 个子组件 |
|
||||
| `KLineChart` | ~40 | 日K线图渲染,支持图表点击事件 |
|
||||
| `MinuteKLineSection` | ~95 | 分钟K线区域,含加载状态、空状态、统计数据 |
|
||||
| `TradeTable` | ~75 | 最近 10 天交易明细表格 |
|
||||
| `MinuteStats` | ~80 | 分钟数据四宫格统计(开盘/当前/最高/最低价) |
|
||||
| `TradeAnalysis` | ~65 | 成交数据分析(活跃时段、平均价格、数据点数) |
|
||||
| `EmptyState` | ~35 | 通用空状态组件(可配置标题和描述) |
|
||||
|
||||
**优化效果**:
|
||||
| 指标 | 优化前 | 优化后 | 改善 |
|
||||
|------|--------|--------|------|
|
||||
| 主文件行数 | 382 | ~50 | -87% |
|
||||
| 文件数量 | 1 | 8 | 原子设计模式 |
|
||||
| 可复用组件 | 0 | 3 原子 + 3 业务 | 提升 |
|
||||
|
||||
**设计原则**:
|
||||
- **原子设计模式**:atoms(MinuteStats、TradeAnalysis、EmptyState)→ 业务组件(KLineChart、MinuteKLineSection、TradeTable)→ 主组件
|
||||
- **职责分离**:图表、统计、表格各自独立
|
||||
- **组件复用**:EmptyState 可在其他场景复用
|
||||
- **类型安全**:完整的 Props 类型定义和导出
|
||||
|
||||
### 2025-12-17 StockQuoteCard 数据下沉优化
|
||||
|
||||
**改动概述**:
|
||||
- StockQuoteCard Props 从 **11 个** 精简至 **4 个**(减少 64%)
|
||||
- 行情数据、基本信息、股票对比逻辑全部下沉到组件内部
|
||||
- Company/index.js 移除约 **40 行** 数据获取代码
|
||||
- 删除 `Company/hooks/useStockQuote.js`
|
||||
|
||||
**创建的文件**:
|
||||
```
|
||||
StockQuoteCard/hooks/
|
||||
├── index.ts # hooks 导出索引
|
||||
├── useStockQuoteData.ts # 行情数据 + 基本信息获取(~152 行)
|
||||
└── useStockCompare.ts # 股票对比逻辑(~91 行)
|
||||
```
|
||||
|
||||
**Props 对比**:
|
||||
|
||||
**优化前(11 个 Props)**:
|
||||
```tsx
|
||||
<StockQuoteCard
|
||||
data={quoteData}
|
||||
isLoading={isQuoteLoading}
|
||||
basicInfo={basicInfo}
|
||||
currentStockInfo={currentStockInfo}
|
||||
compareStockInfo={compareStockInfo}
|
||||
isCompareLoading={isCompareLoading}
|
||||
onCompare={handleCompare}
|
||||
onCloseCompare={handleCloseCompare}
|
||||
isInWatchlist={isInWatchlist}
|
||||
isWatchlistLoading={isWatchlistLoading}
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
/>
|
||||
```
|
||||
|
||||
**优化后(4 个 Props)**:
|
||||
```tsx
|
||||
<StockQuoteCard
|
||||
stockCode={stockCode}
|
||||
isInWatchlist={isInWatchlist}
|
||||
isWatchlistLoading={isWatchlistLoading}
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
/>
|
||||
```
|
||||
|
||||
**Hook 返回值**:
|
||||
|
||||
`useStockQuoteData(stockCode)`:
|
||||
```typescript
|
||||
{
|
||||
quoteData: StockQuoteCardData | null; // 行情数据
|
||||
basicInfo: BasicInfo | null; // 基本信息
|
||||
isLoading: boolean; // 加载状态
|
||||
error: string | null; // 错误信息
|
||||
refetch: () => void; // 手动刷新
|
||||
}
|
||||
```
|
||||
|
||||
`useStockCompare(stockCode)`:
|
||||
```typescript
|
||||
{
|
||||
currentStockInfo: StockInfo | null; // 当前股票财务信息
|
||||
compareStockInfo: StockInfo | null; // 对比股票财务信息
|
||||
isCompareLoading: boolean; // 对比数据加载中
|
||||
handleCompare: (code: string) => Promise<void>; // 触发对比
|
||||
clearCompare: () => void; // 清除对比
|
||||
}
|
||||
```
|
||||
|
||||
**修改的文件**:
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `StockQuoteCard/hooks/useStockQuoteData.ts` | 新建 | 合并行情+基本信息获取 |
|
||||
| `StockQuoteCard/hooks/useStockCompare.ts` | 新建 | 股票对比逻辑 |
|
||||
| `StockQuoteCard/hooks/index.ts` | 新建 | hooks 导出索引 |
|
||||
| `StockQuoteCard/index.tsx` | 修改 | 使用内部 hooks,减少 props |
|
||||
| `StockQuoteCard/types.ts` | 修改 | Props 从 11 个精简为 4 个 |
|
||||
| `Company/index.js` | 修改 | 移除下沉的数据获取逻辑 |
|
||||
| `Company/hooks/useStockQuote.js` | 删除 | 已移到 StockQuoteCard |
|
||||
|
||||
**优化收益**:
|
||||
| 指标 | 优化前 | 优化后 | 改善 |
|
||||
|------|--------|--------|------|
|
||||
| Props 数量 | 11 | 4 | -64% |
|
||||
| Company/index.js 行数 | ~172 | ~105 | -39% |
|
||||
| 数据获取位置 | 页面层 | 组件内部 | 就近原则 |
|
||||
| 可复用性 | 依赖父组件 | 独立可用 | 提升 |
|
||||
|
||||
**设计原则**:
|
||||
- **数据就近获取**:组件自己获取自己需要的数据
|
||||
- **Props 最小化**:只传递真正需要外部控制的状态
|
||||
- **职责清晰**:自选股状态保留在页面层(涉及 Redux 和事件追踪)
|
||||
- **可复用性**:StockQuoteCard 可独立在其他页面使用
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { SearchIcon } from '@chakra-ui/icons';
|
||||
import { useStockSearch } from '../../hooks/useStockSearch';
|
||||
|
||||
/**
|
||||
* 股票搜索栏组件(带模糊搜索下拉)
|
||||
@@ -31,27 +32,18 @@ const SearchBar = ({
|
||||
}) => {
|
||||
// 下拉状态
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [filteredStocks, setFilteredStocks] = useState([]);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
// 从 Redux 获取全部股票列表
|
||||
const allStocks = useSelector(state => state.stock.allStocks);
|
||||
|
||||
// 模糊搜索过滤
|
||||
// 使用共享的搜索 Hook
|
||||
const filteredStocks = useStockSearch(allStocks, inputCode, { limit: 10 });
|
||||
|
||||
// 根据搜索结果更新下拉显示状态
|
||||
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]);
|
||||
setShowDropdown(filteredStocks.length > 0 && !!inputCode?.trim());
|
||||
}, [filteredStocks, inputCode]);
|
||||
|
||||
// 点击外部关闭下拉
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,994 +0,0 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab.js
|
||||
// 基本信息 Tab - 股权结构、管理团队、公司公告、分支机构、工商信息
|
||||
// 懒加载优化:使用 isLazy + 独立 Hooks,点击 Tab 时才加载对应数据
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
Badge,
|
||||
Icon,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
SimpleGrid,
|
||||
Avatar,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Divider,
|
||||
Center,
|
||||
Code,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
IconButton,
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
Spinner,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
// 懒加载 Hooks
|
||||
import { useShareholderData } from "./hooks/useShareholderData";
|
||||
import { useManagementData } from "./hooks/useManagementData";
|
||||
import { useAnnouncementsData } from "./hooks/useAnnouncementsData";
|
||||
import { useBranchesData } from "./hooks/useBranchesData";
|
||||
import { useDisclosureData } from "./hooks/useDisclosureData";
|
||||
import { ExternalLinkIcon } from "@chakra-ui/icons";
|
||||
import {
|
||||
FaShareAlt,
|
||||
FaUserTie,
|
||||
FaBullhorn,
|
||||
FaSitemap,
|
||||
FaInfoCircle,
|
||||
FaCrown,
|
||||
FaChartPie,
|
||||
FaUsers,
|
||||
FaChartLine,
|
||||
FaArrowUp,
|
||||
FaArrowDown,
|
||||
FaChartBar,
|
||||
FaBuilding,
|
||||
FaGlobe,
|
||||
FaShieldAlt,
|
||||
FaBriefcase,
|
||||
FaCircle,
|
||||
FaEye,
|
||||
FaVenusMars,
|
||||
FaGraduationCap,
|
||||
FaPassport,
|
||||
FaCalendarAlt,
|
||||
} from "react-icons/fa";
|
||||
|
||||
// 格式化工具函数
|
||||
const formatUtils = {
|
||||
formatPercentage: (value) => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
return `${(value * 100).toFixed(2)}%`;
|
||||
},
|
||||
formatNumber: (value) => {
|
||||
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();
|
||||
},
|
||||
formatShares: (value) => {
|
||||
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()}股`;
|
||||
},
|
||||
formatDate: (dateStr) => {
|
||||
if (!dateStr) return "-";
|
||||
return dateStr.split("T")[0];
|
||||
},
|
||||
};
|
||||
|
||||
// 股东类型标签组件
|
||||
const ShareholderTypeBadge = ({ type }) => {
|
||||
const typeConfig = {
|
||||
基金: { color: "blue", icon: FaChartBar },
|
||||
个人: { color: "green", icon: FaUserTie },
|
||||
法人: { color: "purple", icon: FaBuilding },
|
||||
QFII: { color: "orange", icon: FaGlobe },
|
||||
社保: { color: "red", icon: FaShieldAlt },
|
||||
保险: { color: "teal", icon: FaShieldAlt },
|
||||
信托: { color: "cyan", icon: FaBriefcase },
|
||||
券商: { color: "pink", icon: FaChartLine },
|
||||
};
|
||||
|
||||
const config = Object.entries(typeConfig).find(([key]) =>
|
||||
type?.includes(key)
|
||||
)?.[1] || { color: "gray", icon: FaCircle };
|
||||
|
||||
return (
|
||||
<Badge colorScheme={config.color} size="sm">
|
||||
<Icon as={config.icon} mr={1} boxSize={3} />
|
||||
{type}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 懒加载 TabPanel 子组件
|
||||
// 每个子组件独立调用 Hook,配合 isLazy 实现真正的懒加载
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 股权结构 Tab Panel - 懒加载子组件
|
||||
*/
|
||||
const ShareholderTabPanel = ({ stockCode }) => {
|
||||
const {
|
||||
actualControl,
|
||||
concentration,
|
||||
topShareholders,
|
||||
topCirculationShareholders,
|
||||
loading,
|
||||
} = useShareholderData(stockCode);
|
||||
|
||||
// 计算股权集中度变化
|
||||
const getConcentrationTrend = () => {
|
||||
const grouped = {};
|
||||
concentration.forEach((item) => {
|
||||
if (!grouped[item.end_date]) {
|
||||
grouped[item.end_date] = {};
|
||||
}
|
||||
grouped[item.end_date][item.stat_item] = item;
|
||||
});
|
||||
return Object.entries(grouped)
|
||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||
.slice(0, 5);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Center h="200px">
|
||||
<VStack>
|
||||
<Spinner size="lg" color="blue.500" />
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
加载股权结构数据...
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
{actualControl.length > 0 && (
|
||||
<Box>
|
||||
<HStack mb={4}>
|
||||
<Icon as={FaCrown} color="gold" boxSize={5} />
|
||||
<Heading size="sm">实际控制人</Heading>
|
||||
</HStack>
|
||||
<Card variant="outline">
|
||||
<CardBody>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start">
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{actualControl[0].actual_controller_name}
|
||||
</Text>
|
||||
<HStack>
|
||||
<Badge colorScheme="purple">
|
||||
{actualControl[0].control_type}
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
截至 {formatUtils.formatDate(actualControl[0].end_date)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Stat textAlign="right">
|
||||
<StatLabel>控制比例</StatLabel>
|
||||
<StatNumber color="purple.500">
|
||||
{formatUtils.formatPercentage(actualControl[0].holding_ratio)}
|
||||
</StatNumber>
|
||||
<StatHelpText>
|
||||
{formatUtils.formatShares(actualControl[0].holding_shares)}
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{concentration.length > 0 && (
|
||||
<Box>
|
||||
<HStack mb={4}>
|
||||
<Icon as={FaChartPie} color="blue.500" boxSize={5} />
|
||||
<Heading size="sm">股权集中度</Heading>
|
||||
</HStack>
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{getConcentrationTrend()
|
||||
.slice(0, 1)
|
||||
.map(([date, items]) => (
|
||||
<Card key={date} variant="outline">
|
||||
<CardHeader pb={2}>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{formatUtils.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">{item.stat_item}</Text>
|
||||
<HStack>
|
||||
<Text fontWeight="bold" color="blue.500">
|
||||
{formatUtils.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>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{topShareholders.length > 0 && (
|
||||
<Box>
|
||||
<HStack mb={4}>
|
||||
<Icon as={FaUsers} color="green.500" boxSize={5} />
|
||||
<Heading size="sm">十大股东</Heading>
|
||||
<Badge>
|
||||
{formatUtils.formatDate(topShareholders[0].end_date)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<TableContainer>
|
||||
<Table size="sm" variant="striped">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>排名</Th>
|
||||
<Th>股东名称</Th>
|
||||
<Th>股东类型</Th>
|
||||
<Th isNumeric>持股数量</Th>
|
||||
<Th isNumeric>持股比例</Th>
|
||||
<Th>股份性质</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{topShareholders.slice(0, 10).map((shareholder, idx) => (
|
||||
<Tr key={idx}>
|
||||
<Td>
|
||||
<Badge colorScheme={idx < 3 ? "red" : "gray"}>
|
||||
{shareholder.shareholder_rank}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip label={shareholder.shareholder_name}>
|
||||
<Text noOfLines={1} maxW="200px">
|
||||
{shareholder.shareholder_name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td>
|
||||
<ShareholderTypeBadge type={shareholder.shareholder_type} />
|
||||
</Td>
|
||||
<Td isNumeric fontWeight="medium">
|
||||
{formatUtils.formatShares(shareholder.holding_shares)}
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<Text color="blue.500" fontWeight="bold">
|
||||
{formatUtils.formatPercentage(
|
||||
shareholder.total_share_ratio
|
||||
)}
|
||||
</Text>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge size="sm" variant="outline">
|
||||
{shareholder.share_nature || "流通股"}
|
||||
</Badge>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{topCirculationShareholders.length > 0 && (
|
||||
<Box>
|
||||
<HStack mb={4}>
|
||||
<Icon as={FaChartLine} color="purple.500" boxSize={5} />
|
||||
<Heading size="sm">十大流通股东</Heading>
|
||||
<Badge>
|
||||
{formatUtils.formatDate(topCirculationShareholders[0].end_date)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<TableContainer>
|
||||
<Table size="sm" variant="striped">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>排名</Th>
|
||||
<Th>股东名称</Th>
|
||||
<Th>股东类型</Th>
|
||||
<Th isNumeric>持股数量</Th>
|
||||
<Th isNumeric>流通股比例</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{topCirculationShareholders.slice(0, 10).map((shareholder, idx) => (
|
||||
<Tr key={idx}>
|
||||
<Td>
|
||||
<Badge colorScheme={idx < 3 ? "orange" : "gray"}>
|
||||
{shareholder.shareholder_rank}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip label={shareholder.shareholder_name}>
|
||||
<Text noOfLines={1} maxW="250px">
|
||||
{shareholder.shareholder_name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td>
|
||||
<ShareholderTypeBadge type={shareholder.shareholder_type} />
|
||||
</Td>
|
||||
<Td isNumeric fontWeight="medium">
|
||||
{formatUtils.formatShares(shareholder.holding_shares)}
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<Text color="purple.500" fontWeight="bold">
|
||||
{formatUtils.formatPercentage(
|
||||
shareholder.circulation_share_ratio
|
||||
)}
|
||||
</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 管理团队 Tab Panel - 懒加载子组件
|
||||
*/
|
||||
const ManagementTabPanel = ({ stockCode }) => {
|
||||
const { management, loading } = useManagementData(stockCode);
|
||||
|
||||
// 管理层职位分类
|
||||
const getManagementByCategory = () => {
|
||||
const categories = {
|
||||
高管: [],
|
||||
董事: [],
|
||||
监事: [],
|
||||
其他: [],
|
||||
};
|
||||
|
||||
management.forEach((person) => {
|
||||
if (
|
||||
person.position_category === "高管" ||
|
||||
person.position_name?.includes("总")
|
||||
) {
|
||||
categories["高管"].push(person);
|
||||
} else if (
|
||||
person.position_category === "董事" ||
|
||||
person.position_name?.includes("董事")
|
||||
) {
|
||||
categories["董事"].push(person);
|
||||
} else if (
|
||||
person.position_category === "监事" ||
|
||||
person.position_name?.includes("监事")
|
||||
) {
|
||||
categories["监事"].push(person);
|
||||
} else {
|
||||
categories["其他"].push(person);
|
||||
}
|
||||
});
|
||||
|
||||
return categories;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Center h="200px">
|
||||
<VStack>
|
||||
<Spinner size="lg" color="blue.500" />
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
加载管理团队数据...
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
{Object.entries(getManagementByCategory()).map(
|
||||
([category, people]) =>
|
||||
people.length > 0 && (
|
||||
<Box key={category}>
|
||||
<HStack mb={4}>
|
||||
<Icon
|
||||
as={
|
||||
category === "高管"
|
||||
? FaUserTie
|
||||
: category === "董事"
|
||||
? FaCrown
|
||||
: category === "监事"
|
||||
? FaEye
|
||||
: FaUsers
|
||||
}
|
||||
color={
|
||||
category === "高管"
|
||||
? "blue.500"
|
||||
: category === "董事"
|
||||
? "purple.500"
|
||||
: category === "监事"
|
||||
? "green.500"
|
||||
: "gray.500"
|
||||
}
|
||||
boxSize={5}
|
||||
/>
|
||||
<Heading size="sm">{category}</Heading>
|
||||
<Badge>{people.length}人</Badge>
|
||||
</HStack>
|
||||
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||
{people.map((person, idx) => (
|
||||
<Card key={idx} variant="outline" size="sm">
|
||||
<CardBody>
|
||||
<HStack spacing={3} align="start">
|
||||
<Avatar
|
||||
name={person.name}
|
||||
size="md"
|
||||
bg={
|
||||
category === "高管"
|
||||
? "blue.500"
|
||||
: category === "董事"
|
||||
? "purple.500"
|
||||
: category === "监事"
|
||||
? "green.500"
|
||||
: "gray.500"
|
||||
}
|
||||
/>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Text fontWeight="bold">{person.name}</Text>
|
||||
{person.gender && (
|
||||
<Icon
|
||||
as={FaVenusMars}
|
||||
color={
|
||||
person.gender === "男"
|
||||
? "blue.400"
|
||||
: "pink.400"
|
||||
}
|
||||
boxSize={3}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
<Text fontSize="sm" color="blue.600">
|
||||
{person.position_name}
|
||||
</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{person.education && (
|
||||
<Tag size="sm" variant="subtle">
|
||||
<Icon as={FaGraduationCap} mr={1} boxSize={3} />
|
||||
{person.education}
|
||||
</Tag>
|
||||
)}
|
||||
{person.birth_year && (
|
||||
<Tag size="sm" variant="subtle">
|
||||
{new Date().getFullYear() -
|
||||
parseInt(person.birth_year)}
|
||||
岁
|
||||
</Tag>
|
||||
)}
|
||||
{person.nationality &&
|
||||
person.nationality !== "中国" && (
|
||||
<Tag size="sm" colorScheme="orange">
|
||||
<Icon as={FaPassport} mr={1} boxSize={3} />
|
||||
{person.nationality}
|
||||
</Tag>
|
||||
)}
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
任职日期:{formatUtils.formatDate(person.start_date)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 公司公告 Tab Panel - 懒加载子组件
|
||||
*/
|
||||
const AnnouncementsTabPanel = ({ stockCode }) => {
|
||||
const { announcements, loading: announcementsLoading } =
|
||||
useAnnouncementsData(stockCode);
|
||||
const { disclosureSchedule, loading: disclosureLoading } =
|
||||
useDisclosureData(stockCode);
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [selectedAnnouncement, setSelectedAnnouncement] = React.useState(null);
|
||||
|
||||
const handleAnnouncementClick = (announcement) => {
|
||||
setSelectedAnnouncement(announcement);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const loading = announcementsLoading || disclosureLoading;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Center h="200px">
|
||||
<VStack>
|
||||
<Spinner size="lg" color="blue.500" />
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
加载公告数据...
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{disclosureSchedule.length > 0 && (
|
||||
<Box>
|
||||
<HStack mb={3}>
|
||||
<Icon as={FaCalendarAlt} color="orange.500" />
|
||||
<Text fontWeight="bold">财报披露日程</Text>
|
||||
</HStack>
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
|
||||
{disclosureSchedule.slice(0, 4).map((schedule, idx) => (
|
||||
<Card
|
||||
key={idx}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
bg={schedule.is_disclosed ? "green.50" : "orange.50"}
|
||||
>
|
||||
<CardBody p={3}>
|
||||
<VStack spacing={1}>
|
||||
<Badge
|
||||
colorScheme={schedule.is_disclosed ? "green" : "orange"}
|
||||
>
|
||||
{schedule.report_name}
|
||||
</Badge>
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{schedule.is_disclosed ? "已披露" : "预计"}
|
||||
</Text>
|
||||
<Text fontSize="xs">
|
||||
{formatUtils.formatDate(
|
||||
schedule.is_disclosed
|
||||
? schedule.actual_date
|
||||
: schedule.latest_scheduled_date
|
||||
)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<HStack mb={3}>
|
||||
<Icon as={FaBullhorn} color="blue.500" />
|
||||
<Text fontWeight="bold">最新公告</Text>
|
||||
</HStack>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{announcements.map((announcement, idx) => (
|
||||
<Card
|
||||
key={idx}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
cursor="pointer"
|
||||
onClick={() => handleAnnouncementClick(announcement)}
|
||||
_hover={{ bg: "gray.50" }}
|
||||
>
|
||||
<CardBody p={3}>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Badge size="sm" colorScheme="blue">
|
||||
{announcement.info_type || "公告"}
|
||||
</Badge>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{formatUtils.formatDate(announcement.announce_date)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm" fontWeight="medium" noOfLines={1}>
|
||||
{announcement.title}
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack>
|
||||
{announcement.format && (
|
||||
<Tag size="sm" variant="subtle">
|
||||
{announcement.format}
|
||||
</Tag>
|
||||
)}
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon={<ExternalLinkIcon />}
|
||||
variant="ghost"
|
||||
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>
|
||||
<ModalHeader>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text>{selectedAnnouncement?.title}</Text>
|
||||
<HStack>
|
||||
<Badge colorScheme="blue">
|
||||
{selectedAnnouncement?.info_type || "公告"}
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{formatUtils.formatDate(selectedAnnouncement?.announce_date)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="start" spacing={3}>
|
||||
<Text fontSize="sm">
|
||||
文件格式:{selectedAnnouncement?.format || "-"}
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
文件大小:{selectedAnnouncement?.file_size || "-"} KB
|
||||
</Text>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
mr={3}
|
||||
onClick={() => window.open(selectedAnnouncement?.url, "_blank")}
|
||||
>
|
||||
查看原文
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 分支机构 Tab Panel - 懒加载子组件
|
||||
*/
|
||||
const BranchesTabPanel = ({ stockCode }) => {
|
||||
const { branches, loading } = useBranchesData(stockCode);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Center h="200px">
|
||||
<VStack>
|
||||
<Spinner size="lg" color="blue.500" />
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
加载分支机构数据...
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (branches.length === 0) {
|
||||
return (
|
||||
<Center h="200px">
|
||||
<VStack>
|
||||
<Icon as={FaSitemap} boxSize={12} color="gray.300" />
|
||||
<Text color="gray.500">暂无分支机构信息</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{branches.map((branch, idx) => (
|
||||
<Card key={idx} variant="outline">
|
||||
<CardBody>
|
||||
<VStack align="start" spacing={3}>
|
||||
<HStack justify="space-between" w="full">
|
||||
<Text fontWeight="bold">{branch.branch_name}</Text>
|
||||
<Badge
|
||||
colorScheme={
|
||||
branch.business_status === "存续" ? "green" : "red"
|
||||
}
|
||||
>
|
||||
{branch.business_status}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<SimpleGrid columns={2} spacing={2} w="full">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
注册资本
|
||||
</Text>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{branch.register_capital || "-"}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
法人代表
|
||||
</Text>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{branch.legal_person || "-"}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
成立日期
|
||||
</Text>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{formatUtils.formatDate(branch.register_date)}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
关联企业
|
||||
</Text>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{branch.related_company_count || 0} 家
|
||||
</Text>
|
||||
</VStack>
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 工商信息 Tab Panel - 使用父组件传入的 basicInfo
|
||||
*/
|
||||
const BusinessInfoTabPanel = ({ basicInfo }) => {
|
||||
if (!basicInfo) {
|
||||
return (
|
||||
<Center h="200px">
|
||||
<Text color="gray.500">暂无工商信息</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
|
||||
<Box>
|
||||
<Heading size="sm" mb={3}>
|
||||
工商信息
|
||||
</Heading>
|
||||
<VStack align="start" spacing={2}>
|
||||
<HStack w="full">
|
||||
<Text fontSize="sm" color="gray.600" minW="80px">
|
||||
统一信用代码
|
||||
</Text>
|
||||
<Code fontSize="xs">{basicInfo.credit_code}</Code>
|
||||
</HStack>
|
||||
<HStack w="full">
|
||||
<Text fontSize="sm" color="gray.600" minW="80px">
|
||||
公司规模
|
||||
</Text>
|
||||
<Text fontSize="sm">{basicInfo.company_size}</Text>
|
||||
</HStack>
|
||||
<HStack w="full">
|
||||
<Text fontSize="sm" color="gray.600" minW="80px">
|
||||
注册地址
|
||||
</Text>
|
||||
<Text fontSize="sm" noOfLines={2}>
|
||||
{basicInfo.reg_address}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack w="full">
|
||||
<Text fontSize="sm" color="gray.600" minW="80px">
|
||||
办公地址
|
||||
</Text>
|
||||
<Text fontSize="sm" noOfLines={2}>
|
||||
{basicInfo.office_address}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading size="sm" mb={3}>
|
||||
服务机构
|
||||
</Heading>
|
||||
<VStack align="start" spacing={2}>
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
会计师事务所
|
||||
</Text>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{basicInfo.accounting_firm}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
律师事务所
|
||||
</Text>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{basicInfo.law_firm}
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Heading size="sm" mb={3}>
|
||||
主营业务
|
||||
</Heading>
|
||||
<Text fontSize="sm" lineHeight="tall">
|
||||
{basicInfo.main_business}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading size="sm" mb={3}>
|
||||
经营范围
|
||||
</Heading>
|
||||
<Text fontSize="sm" lineHeight="tall" color="gray.700">
|
||||
{basicInfo.business_scope}
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 主组件
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 基本信息 Tab 组件(懒加载版本)
|
||||
*
|
||||
* Props:
|
||||
* - stockCode: 股票代码(用于懒加载数据)
|
||||
* - basicInfo: 公司基本信息(从父组件传入,用于工商信息 Tab)
|
||||
* - cardBg: 卡片背景色
|
||||
*
|
||||
* 懒加载策略:
|
||||
* - 使用 Chakra UI Tabs 的 isLazy 属性
|
||||
* - 每个 TabPanel 使用独立子组件,在首次激活时才渲染并加载数据
|
||||
*/
|
||||
const BasicInfoTab = ({ stockCode, basicInfo, cardBg }) => {
|
||||
return (
|
||||
<Card bg={cardBg} shadow="md">
|
||||
<CardBody>
|
||||
<Tabs isLazy variant="enclosed" colorScheme="blue">
|
||||
<TabList flexWrap="wrap">
|
||||
<Tab>
|
||||
<Icon as={FaShareAlt} mr={2} />
|
||||
股权结构
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FaUserTie} mr={2} />
|
||||
管理团队
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FaBullhorn} mr={2} />
|
||||
公司公告
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FaSitemap} mr={2} />
|
||||
分支机构
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FaInfoCircle} mr={2} />
|
||||
工商信息
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* 股权结构 - 懒加载 */}
|
||||
<TabPanel>
|
||||
<ShareholderTabPanel stockCode={stockCode} />
|
||||
</TabPanel>
|
||||
|
||||
{/* 管理团队 - 懒加载 */}
|
||||
<TabPanel>
|
||||
<ManagementTabPanel stockCode={stockCode} />
|
||||
</TabPanel>
|
||||
|
||||
{/* 公司公告 - 懒加载 */}
|
||||
<TabPanel>
|
||||
<AnnouncementsTabPanel stockCode={stockCode} />
|
||||
</TabPanel>
|
||||
|
||||
{/* 分支机构 - 懒加载 */}
|
||||
<TabPanel>
|
||||
<BranchesTabPanel stockCode={stockCode} />
|
||||
</TabPanel>
|
||||
|
||||
{/* 工商信息 - 使用父组件传入的 basicInfo */}
|
||||
<TabPanel>
|
||||
<BusinessInfoTabPanel basicInfo={basicInfo} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicInfoTab;
|
||||
@@ -0,0 +1,157 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/AnnouncementsPanel.tsx
|
||||
// 公司公告 Tab Panel
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Card,
|
||||
CardBody,
|
||||
IconButton,
|
||||
Button,
|
||||
Tag,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { ExternalLinkIcon } from "@chakra-ui/icons";
|
||||
|
||||
import { useAnnouncementsData } from "../../hooks/useAnnouncementsData";
|
||||
import { THEME } from "../config";
|
||||
import { formatDate } from "../utils";
|
||||
import LoadingState from "./LoadingState";
|
||||
|
||||
interface AnnouncementsPanelProps {
|
||||
stockCode: string;
|
||||
}
|
||||
|
||||
const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode }) => {
|
||||
const { announcements, loading } = useAnnouncementsData(stockCode);
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [selectedAnnouncement, setSelectedAnnouncement] = useState<any>(null);
|
||||
|
||||
const handleAnnouncementClick = (announcement: any) => {
|
||||
setSelectedAnnouncement(announcement);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="加载公告数据..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 最新公告 */}
|
||||
<Box>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{announcements.map((announcement: any, idx: number) => (
|
||||
<Card
|
||||
key={idx}
|
||||
bg={THEME.tableBg}
|
||||
border="1px solid"
|
||||
borderColor={THEME.border}
|
||||
size="sm"
|
||||
cursor="pointer"
|
||||
onClick={() => handleAnnouncementClick(announcement)}
|
||||
_hover={{ bg: THEME.tableHoverBg }}
|
||||
>
|
||||
<CardBody p={3}>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Badge size="sm" bg={THEME.gold} color="gray.900">
|
||||
{announcement.info_type || "公告"}
|
||||
</Badge>
|
||||
<Text fontSize="xs" color={THEME.textSecondary}>
|
||||
{formatDate(announcement.announce_date)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm" fontWeight="medium" noOfLines={1} color={THEME.textPrimary}>
|
||||
{announcement.title}
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack>
|
||||
{announcement.format && (
|
||||
<Tag size="sm" bg={THEME.tableHoverBg} color={THEME.textSecondary}>
|
||||
{announcement.format}
|
||||
</Tag>
|
||||
)}
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon={<ExternalLinkIcon />}
|
||||
variant="ghost"
|
||||
color={THEME.goldLight}
|
||||
aria-label="查看原文"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(announcement.url, "_blank");
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
|
||||
{/* 公告详情模态框 */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent bg={THEME.cardBg}>
|
||||
<ModalHeader color={THEME.textPrimary}>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text>{selectedAnnouncement?.title}</Text>
|
||||
<HStack>
|
||||
<Badge bg={THEME.gold} color="gray.900">
|
||||
{selectedAnnouncement?.info_type || "公告"}
|
||||
</Badge>
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>
|
||||
{formatDate(selectedAnnouncement?.announce_date)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color={THEME.textPrimary} />
|
||||
<ModalBody>
|
||||
<VStack align="start" spacing={3}>
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>
|
||||
文件格式:{selectedAnnouncement?.format || "-"}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>
|
||||
文件大小:{selectedAnnouncement?.file_size || "-"} KB
|
||||
</Text>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
bg={THEME.gold}
|
||||
color="gray.900"
|
||||
mr={3}
|
||||
_hover={{ bg: THEME.goldLight }}
|
||||
onClick={() => window.open(selectedAnnouncement?.url, "_blank")}
|
||||
>
|
||||
查看原文
|
||||
</Button>
|
||||
<Button variant="ghost" color={THEME.textSecondary} onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnouncementsPanel;
|
||||
@@ -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,121 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/BusinessInfoPanel.tsx
|
||||
// 工商信息 Tab Panel
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
Center,
|
||||
Code,
|
||||
Spinner,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
import { THEME } from "../config";
|
||||
import { useBasicInfo } from "../../hooks/useBasicInfo";
|
||||
|
||||
interface BusinessInfoPanelProps {
|
||||
stockCode: string;
|
||||
}
|
||||
|
||||
const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ stockCode }) => {
|
||||
const { basicInfo, loading } = useBasicInfo(stockCode);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Center h="200px">
|
||||
<Spinner size="lg" color={THEME.gold} />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!basicInfo) {
|
||||
return (
|
||||
<Center h="200px">
|
||||
<Text color={THEME.textSecondary}>暂无工商信息</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
|
||||
<Box>
|
||||
<Heading size="sm" mb={3} color={THEME.textPrimary}>工商信息</Heading>
|
||||
<VStack align="start" spacing={2}>
|
||||
<HStack w="full">
|
||||
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
|
||||
统一信用代码
|
||||
</Text>
|
||||
<Code fontSize="xs" bg={THEME.tableHoverBg} color={THEME.goldLight}>
|
||||
{basicInfo.credit_code}
|
||||
</Code>
|
||||
</HStack>
|
||||
<HStack w="full">
|
||||
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
|
||||
公司规模
|
||||
</Text>
|
||||
<Text fontSize="sm" color={THEME.textPrimary}>{basicInfo.company_size}</Text>
|
||||
</HStack>
|
||||
<HStack w="full" align="start">
|
||||
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
|
||||
注册地址
|
||||
</Text>
|
||||
<Text fontSize="sm" noOfLines={2} color={THEME.textPrimary}>
|
||||
{basicInfo.reg_address}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack w="full" align="start">
|
||||
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
|
||||
办公地址
|
||||
</Text>
|
||||
<Text fontSize="sm" noOfLines={2} color={THEME.textPrimary}>
|
||||
{basicInfo.office_address}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading size="sm" mb={3} color={THEME.textPrimary}>服务机构</Heading>
|
||||
<VStack align="start" spacing={2}>
|
||||
<Box>
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>会计师事务所</Text>
|
||||
<Text fontSize="sm" fontWeight="medium" color={THEME.textPrimary}>
|
||||
{basicInfo.accounting_firm}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>律师事务所</Text>
|
||||
<Text fontSize="sm" fontWeight="medium" color={THEME.textPrimary}>
|
||||
{basicInfo.law_firm}
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider borderColor={THEME.border} />
|
||||
|
||||
<Box>
|
||||
<Heading size="sm" mb={3} color={THEME.textPrimary}>主营业务</Heading>
|
||||
<Text fontSize="sm" lineHeight="tall" color={THEME.textSecondary}>
|
||||
{basicInfo.main_business}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading size="sm" mb={3} color={THEME.textPrimary}>经营范围</Heading>
|
||||
<Text fontSize="sm" lineHeight="tall" color={THEME.textSecondary}>
|
||||
{basicInfo.business_scope}
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessInfoPanel;
|
||||
@@ -0,0 +1,76 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel.tsx
|
||||
// 财报披露日程 Tab Panel
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
Text,
|
||||
Badge,
|
||||
Card,
|
||||
CardBody,
|
||||
SimpleGrid,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
import { useDisclosureData } from "../../hooks/useDisclosureData";
|
||||
import { THEME } from "../config";
|
||||
import { formatDate } from "../utils";
|
||||
import LoadingState from "./LoadingState";
|
||||
|
||||
interface DisclosureSchedulePanelProps {
|
||||
stockCode: string;
|
||||
}
|
||||
|
||||
const DisclosureSchedulePanel: React.FC<DisclosureSchedulePanelProps> = ({ stockCode }) => {
|
||||
const { disclosureSchedule, loading } = useDisclosureData(stockCode);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="加载披露日程..." />;
|
||||
}
|
||||
|
||||
if (disclosureSchedule.length === 0) {
|
||||
return (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color={THEME.textSecondary}>暂无披露日程数据</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Box>
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
|
||||
{disclosureSchedule.map((schedule: any, idx: number) => (
|
||||
<Card
|
||||
key={idx}
|
||||
bg={schedule.is_disclosed ? "green.900" : "orange.900"}
|
||||
border="1px solid"
|
||||
borderColor={schedule.is_disclosed ? "green.600" : "orange.600"}
|
||||
size="sm"
|
||||
>
|
||||
<CardBody p={3}>
|
||||
<VStack spacing={1}>
|
||||
<Badge colorScheme={schedule.is_disclosed ? "green" : "orange"}>
|
||||
{schedule.report_name}
|
||||
</Badge>
|
||||
<Text fontSize="sm" fontWeight="bold" color={THEME.textPrimary}>
|
||||
{schedule.is_disclosed ? "已披露" : "预计"}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={THEME.textSecondary}>
|
||||
{formatDate(
|
||||
schedule.is_disclosed
|
||||
? schedule.actual_date
|
||||
: schedule.latest_scheduled_date
|
||||
)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisclosureSchedulePanel;
|
||||
@@ -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,60 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/ShareholderPanel.tsx
|
||||
// 股权结构 Tab Panel - 使用拆分后的子组件
|
||||
|
||||
import React from "react";
|
||||
import { SimpleGrid, Box } from "@chakra-ui/react";
|
||||
|
||||
import { useShareholderData } from "../../hooks/useShareholderData";
|
||||
import {
|
||||
ActualControlCard,
|
||||
ConcentrationCard,
|
||||
ShareholdersTable,
|
||||
} from "../../components/shareholder";
|
||||
import TabPanelContainer from "@components/TabPanelContainer";
|
||||
|
||||
interface ShareholderPanelProps {
|
||||
stockCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 股权结构面板
|
||||
* 使用拆分后的子组件:
|
||||
* - ActualControlCard: 实际控制人卡片
|
||||
* - ConcentrationCard: 股权集中度卡片
|
||||
* - ShareholdersTable: 股东表格(合并版,支持十大股东和十大流通股东)
|
||||
*/
|
||||
const ShareholderPanel: React.FC<ShareholderPanelProps> = ({ stockCode }) => {
|
||||
const {
|
||||
actualControl,
|
||||
concentration,
|
||||
topShareholders,
|
||||
topCirculationShareholders,
|
||||
loading,
|
||||
} = useShareholderData(stockCode);
|
||||
|
||||
return (
|
||||
<TabPanelContainer loading={loading} loadingMessage="加载股权结构数据...">
|
||||
{/* 实际控制人 + 股权集中度 左右分布 */}
|
||||
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
|
||||
<Box>
|
||||
<ActualControlCard actualControl={actualControl} />
|
||||
</Box>
|
||||
<Box>
|
||||
<ConcentrationCard concentration={concentration} />
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 十大股东 + 十大流通股东 左右分布 */}
|
||||
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
|
||||
<Box>
|
||||
<ShareholdersTable type="top" shareholders={topShareholders} />
|
||||
</Box>
|
||||
<Box>
|
||||
<ShareholdersTable type="circulation" shareholders={topCirculationShareholders} />
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
</TabPanelContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareholderPanel;
|
||||
@@ -0,0 +1,11 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/index.ts
|
||||
// 组件导出
|
||||
|
||||
export { default as LoadingState } from "./LoadingState";
|
||||
// TabPanelContainer 已提升为通用组件,从 @components/TabPanelContainer 导入
|
||||
export { default as TabPanelContainer } from "@components/TabPanelContainer";
|
||||
export { default as ShareholderPanel } from "./ShareholderPanel";
|
||||
export { ManagementPanel } from "./management";
|
||||
export { default as AnnouncementsPanel } from "./AnnouncementsPanel";
|
||||
export { default as BranchesPanel } from "./BranchesPanel";
|
||||
export { default as BusinessInfoPanel } from "./BusinessInfoPanel";
|
||||
@@ -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,100 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/ManagementPanel.tsx
|
||||
// 管理团队 Tab Panel(重构版)
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
FaUserTie,
|
||||
FaCrown,
|
||||
FaEye,
|
||||
FaUsers,
|
||||
} from "react-icons/fa";
|
||||
|
||||
import { useManagementData } from "../../../hooks/useManagementData";
|
||||
import { THEME } from "../../config";
|
||||
import TabPanelContainer from "@components/TabPanelContainer";
|
||||
import CategorySection from "./CategorySection";
|
||||
import type {
|
||||
ManagementPerson,
|
||||
ManagementCategory,
|
||||
CategorizedManagement,
|
||||
CategoryConfig,
|
||||
} from "./types";
|
||||
|
||||
interface ManagementPanelProps {
|
||||
stockCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类配置映射
|
||||
*/
|
||||
const CATEGORY_CONFIG: Record<ManagementCategory, CategoryConfig> = {
|
||||
高管: { icon: FaUserTie, color: THEME.gold },
|
||||
董事: { icon: FaCrown, color: THEME.goldLight },
|
||||
监事: { icon: FaEye, color: "green.400" },
|
||||
其他: { icon: FaUsers, color: THEME.textSecondary },
|
||||
};
|
||||
|
||||
/**
|
||||
* 分类顺序
|
||||
*/
|
||||
const CATEGORY_ORDER: ManagementCategory[] = ["高管", "董事", "监事", "其他"];
|
||||
|
||||
/**
|
||||
* 根据职位信息对管理人员进行分类
|
||||
*/
|
||||
const categorizeManagement = (management: ManagementPerson[]): CategorizedManagement => {
|
||||
const categories: CategorizedManagement = {
|
||||
高管: [],
|
||||
董事: [],
|
||||
监事: [],
|
||||
其他: [],
|
||||
};
|
||||
|
||||
management.forEach((person) => {
|
||||
const positionCategory = person.position_category;
|
||||
const positionName = person.position_name || "";
|
||||
|
||||
if (positionCategory === "高管" || positionName.includes("总")) {
|
||||
categories["高管"].push(person);
|
||||
} else if (positionCategory === "董事" || positionName.includes("董事")) {
|
||||
categories["董事"].push(person);
|
||||
} else if (positionCategory === "监事" || positionName.includes("监事")) {
|
||||
categories["监事"].push(person);
|
||||
} else {
|
||||
categories["其他"].push(person);
|
||||
}
|
||||
});
|
||||
|
||||
return categories;
|
||||
};
|
||||
|
||||
const ManagementPanel: React.FC<ManagementPanelProps> = ({ stockCode }) => {
|
||||
const { management, loading } = useManagementData(stockCode);
|
||||
|
||||
// 使用 useMemo 缓存分类计算结果
|
||||
const categorizedManagement = useMemo(
|
||||
() => categorizeManagement(management as ManagementPerson[]),
|
||||
[management]
|
||||
);
|
||||
|
||||
return (
|
||||
<TabPanelContainer loading={loading} loadingMessage="加载管理团队数据...">
|
||||
{CATEGORY_ORDER.map((category) => {
|
||||
const config = CATEGORY_CONFIG[category];
|
||||
const people = categorizedManagement[category];
|
||||
|
||||
return (
|
||||
<CategorySection
|
||||
key={category}
|
||||
category={category}
|
||||
people={people}
|
||||
icon={config.icon}
|
||||
color={config.color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TabPanelContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagementPanel;
|
||||
@@ -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,87 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/index.tsx
|
||||
// 基本信息 Tab 组件 - 使用 SubTabContainer 通用组件
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { Card, CardBody } from "@chakra-ui/react";
|
||||
import SubTabContainer, { type SubTabConfig } from "@components/SubTabContainer";
|
||||
|
||||
import { THEME, TAB_CONFIG, getEnabledTabs } from "./config";
|
||||
import {
|
||||
ShareholderPanel,
|
||||
ManagementPanel,
|
||||
AnnouncementsPanel,
|
||||
BranchesPanel,
|
||||
BusinessInfoPanel,
|
||||
} from "./components";
|
||||
|
||||
// Props 类型定义
|
||||
export interface BasicInfoTabProps {
|
||||
stockCode: string;
|
||||
|
||||
// 可配置项
|
||||
enabledTabs?: string[]; // 指定显示哪些 Tab(通过 key)
|
||||
defaultTabIndex?: number; // 默认选中 Tab
|
||||
onTabChange?: (index: number, tabKey: string) => void;
|
||||
}
|
||||
|
||||
// Tab 组件映射
|
||||
const TAB_COMPONENTS: Record<string, React.FC<any>> = {
|
||||
shareholder: ShareholderPanel,
|
||||
management: ManagementPanel,
|
||||
announcements: AnnouncementsPanel,
|
||||
branches: BranchesPanel,
|
||||
business: BusinessInfoPanel,
|
||||
};
|
||||
|
||||
/**
|
||||
* 构建 SubTabContainer 所需的 tabs 配置
|
||||
*/
|
||||
const buildTabsConfig = (enabledKeys?: string[]): SubTabConfig[] => {
|
||||
const enabledTabs = getEnabledTabs(enabledKeys);
|
||||
return enabledTabs.map((tab) => ({
|
||||
key: tab.key,
|
||||
name: tab.name,
|
||||
icon: tab.icon,
|
||||
component: TAB_COMPONENTS[tab.key],
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 基本信息 Tab 组件
|
||||
*
|
||||
* 特性:
|
||||
* - 使用 SubTabContainer 通用组件
|
||||
* - 可配置显示哪些 Tab(enabledTabs)
|
||||
* - 黑金主题
|
||||
* - 懒加载
|
||||
* - 支持 Tab 变更回调
|
||||
*/
|
||||
const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
|
||||
stockCode,
|
||||
enabledTabs,
|
||||
defaultTabIndex = 0,
|
||||
onTabChange,
|
||||
}) => {
|
||||
// 构建 tabs 配置(缓存避免重复计算)
|
||||
const tabs = useMemo(() => buildTabsConfig(enabledTabs), [enabledTabs]);
|
||||
|
||||
return (
|
||||
<Card bg={THEME.cardBg} shadow="md" border="1px solid" borderColor={THEME.border}>
|
||||
<CardBody p={0}>
|
||||
<SubTabContainer
|
||||
tabs={tabs}
|
||||
componentProps={{ stockCode }}
|
||||
defaultIndex={defaultTabIndex}
|
||||
onTabChange={onTabChange}
|
||||
themePreset="blackGold"
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicInfoTab;
|
||||
|
||||
// 导出配置和工具,供外部使用
|
||||
export { THEME, TAB_CONFIG, getEnabledTabs } from "./config";
|
||||
export * from "./utils";
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,201 +0,0 @@
|
||||
// src/views/Company/components/CompanyOverview/CompanyHeaderCard.tsx
|
||||
// 公司头部信息卡片组件 - 黑金主题
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Card,
|
||||
CardBody,
|
||||
Heading,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
Icon,
|
||||
Box,
|
||||
Link,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
FaBuilding,
|
||||
FaMapMarkerAlt,
|
||||
FaCalendarAlt,
|
||||
FaGlobe,
|
||||
FaCoins,
|
||||
} from "react-icons/fa";
|
||||
import { ExternalLinkIcon } from "@chakra-ui/icons";
|
||||
|
||||
import type { CompanyHeaderCardProps } from "./types";
|
||||
import { formatRegisteredCapital, formatDate } from "./utils";
|
||||
|
||||
// 黑金主题色
|
||||
const THEME = {
|
||||
bg: "gray.900",
|
||||
cardBg: "gray.800",
|
||||
gold: "#D4AF37",
|
||||
goldLight: "#F0D78C",
|
||||
textPrimary: "white",
|
||||
textSecondary: "gray.400",
|
||||
border: "rgba(212, 175, 55, 0.3)",
|
||||
};
|
||||
|
||||
/**
|
||||
* 公司头部信息卡片组件
|
||||
* 三区块布局:身份分类 | 关键属性 | 公司介绍
|
||||
* 黑金主题
|
||||
*/
|
||||
const CompanyHeaderCard: React.FC<CompanyHeaderCardProps> = ({ basicInfo }) => {
|
||||
return (
|
||||
<Card
|
||||
bg={THEME.cardBg}
|
||||
shadow="xl"
|
||||
borderTop="3px solid"
|
||||
borderTopColor={THEME.gold}
|
||||
borderRadius="lg"
|
||||
>
|
||||
<CardBody px={0}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{/* 区块一:公司身份与分类 */}
|
||||
<HStack spacing={4}>
|
||||
<Box
|
||||
w="56px"
|
||||
h="56px"
|
||||
borderRadius="full"
|
||||
bg={`linear-gradient(135deg, ${THEME.gold} 0%, ${THEME.goldLight} 100%)`}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
boxShadow={`0 4px 14px rgba(212, 175, 55, 0.4)`}
|
||||
>
|
||||
<Icon as={FaBuilding} color="gray.900" boxSize={7} />
|
||||
</Box>
|
||||
<VStack align="start" spacing={1}>
|
||||
<HStack flexWrap="wrap">
|
||||
<Heading size="lg" color={THEME.textPrimary}>
|
||||
{basicInfo.ORGNAME || basicInfo.SECNAME}
|
||||
</Heading>
|
||||
<Badge
|
||||
bg={THEME.gold}
|
||||
color="gray.900"
|
||||
fontSize="md"
|
||||
px={2}
|
||||
py={1}
|
||||
fontWeight="bold"
|
||||
>
|
||||
{basicInfo.SECCODE}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Badge
|
||||
bg="transparent"
|
||||
border="1px solid"
|
||||
borderColor={THEME.gold}
|
||||
color={THEME.goldLight}
|
||||
fontSize="xs"
|
||||
>
|
||||
{basicInfo.sw_industry_l1}
|
||||
</Badge>
|
||||
<Badge
|
||||
bg="transparent"
|
||||
border="1px solid"
|
||||
borderColor="gray.600"
|
||||
color={THEME.textSecondary}
|
||||
fontSize="xs"
|
||||
>
|
||||
{basicInfo.sw_industry_l2}
|
||||
</Badge>
|
||||
{basicInfo.sw_industry_l3 && (
|
||||
<Badge
|
||||
bg="transparent"
|
||||
border="1px solid"
|
||||
borderColor="gray.600"
|
||||
color={THEME.textSecondary}
|
||||
fontSize="xs"
|
||||
>
|
||||
{basicInfo.sw_industry_l3}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<Divider borderColor={THEME.border} />
|
||||
|
||||
{/* 区块二:关键属性网格 */}
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
|
||||
<HStack>
|
||||
<Icon as={FaCalendarAlt} color={THEME.gold} boxSize={4} />
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary}>成立日期</Text>
|
||||
<Text fontSize="sm" fontWeight="bold" color={THEME.textPrimary}>
|
||||
{formatDate(basicInfo.establish_date)}
|
||||
</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Icon as={FaCoins} color={THEME.gold} boxSize={4} />
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary}>注册资本</Text>
|
||||
<Text fontSize="sm" fontWeight="bold" color={THEME.goldLight}>
|
||||
{formatRegisteredCapital(basicInfo.reg_capital)}
|
||||
</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Icon as={FaMapMarkerAlt} color={THEME.gold} boxSize={4} />
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary}>所在地</Text>
|
||||
<Text fontSize="sm" fontWeight="bold" color={THEME.textPrimary}>
|
||||
{basicInfo.province} {basicInfo.city}
|
||||
</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Icon as={FaGlobe} color={THEME.gold} boxSize={4} />
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary}>官网</Text>
|
||||
<Link
|
||||
href={basicInfo.website}
|
||||
isExternal
|
||||
color={THEME.goldLight}
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
noOfLines={1}
|
||||
_hover={{ color: THEME.gold }}
|
||||
>
|
||||
{basicInfo.website ? (
|
||||
<>访问官网 <ExternalLinkIcon mx="2px" /></>
|
||||
) : (
|
||||
"暂无"
|
||||
)}
|
||||
</Link>
|
||||
</Box>
|
||||
</HStack>
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider borderColor={THEME.border} />
|
||||
|
||||
{/* 区块三:公司介绍 */}
|
||||
<Box>
|
||||
<Text fontSize="sm" color={THEME.textSecondary} noOfLines={2}>
|
||||
{basicInfo.company_intro}
|
||||
</Text>
|
||||
{basicInfo.company_intro && basicInfo.company_intro.length > 100 && (
|
||||
<Link
|
||||
color={THEME.goldLight}
|
||||
fontSize="sm"
|
||||
mt={1}
|
||||
display="inline-block"
|
||||
_hover={{ color: THEME.gold }}
|
||||
>
|
||||
查看完整介绍
|
||||
</Link>
|
||||
)}
|
||||
</Box>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompanyHeaderCard;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 业务结构树形项组件
|
||||
*
|
||||
* 递归显示业务结构层级
|
||||
* 使用位置:业务结构分析卡片
|
||||
* 黑金主题风格
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, HStack, VStack, Text, Badge, Tag, TagLabel } from '@chakra-ui/react';
|
||||
import { formatPercentage, formatBusinessRevenue } from '@utils/priceFormatters';
|
||||
import type { BusinessTreeItemProps } from '../types';
|
||||
|
||||
// 黑金主题配置
|
||||
const THEME = {
|
||||
bg: 'gray.700',
|
||||
gold: '#D4AF37',
|
||||
goldLight: '#F0D78C',
|
||||
textPrimary: '#D4AF37',
|
||||
textSecondary: 'gray.400',
|
||||
border: 'rgba(212, 175, 55, 0.5)',
|
||||
};
|
||||
|
||||
const BusinessTreeItem: React.FC<BusinessTreeItemProps> = ({ business, depth = 0 }) => {
|
||||
// 获取营收显示
|
||||
const getRevenueDisplay = (): string => {
|
||||
const revenue = business.revenue || business.financial_metrics?.revenue;
|
||||
const unit = business.revenue_unit;
|
||||
if (revenue !== undefined && revenue !== null) {
|
||||
return formatBusinessRevenue(revenue, unit);
|
||||
}
|
||||
return '-';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
ml={depth * 6}
|
||||
p={3}
|
||||
bg={THEME.bg}
|
||||
borderLeft={depth > 0 ? '4px solid' : 'none'}
|
||||
borderLeftColor={THEME.gold}
|
||||
borderRadius="md"
|
||||
mb={2}
|
||||
_hover={{ shadow: 'md', bg: 'gray.600' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" spacing={1}>
|
||||
<HStack>
|
||||
<Text fontWeight="bold" fontSize={depth === 0 ? 'md' : 'sm'} color={THEME.textPrimary}>
|
||||
{business.business_name}
|
||||
</Text>
|
||||
{business.financial_metrics?.revenue_ratio &&
|
||||
business.financial_metrics.revenue_ratio > 30 && (
|
||||
<Badge bg={THEME.gold} color="gray.900" size="sm">
|
||||
核心业务
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack spacing={4} flexWrap="wrap">
|
||||
<Tag size="sm" bg="gray.600" color={THEME.textPrimary}>
|
||||
营收占比: {formatPercentage(business.financial_metrics?.revenue_ratio)}
|
||||
</Tag>
|
||||
<Tag size="sm" bg="gray.600" color={THEME.textPrimary}>
|
||||
毛利率: {formatPercentage(business.financial_metrics?.gross_margin)}
|
||||
</Tag>
|
||||
{business.growth_metrics?.revenue_growth !== undefined && (
|
||||
<Tag
|
||||
size="sm"
|
||||
bg={business.growth_metrics.revenue_growth > 0 ? 'red.600' : 'green.600'}
|
||||
color="white"
|
||||
>
|
||||
<TagLabel>
|
||||
增长: {business.growth_metrics.revenue_growth > 0 ? '+' : ''}
|
||||
{formatPercentage(business.growth_metrics.revenue_growth)}
|
||||
</TagLabel>
|
||||
</Tag>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
<VStack align="end" spacing={0}>
|
||||
<Text fontSize="lg" fontWeight="bold" color={THEME.gold}>
|
||||
{getRevenueDisplay()}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={THEME.textSecondary}>
|
||||
营业收入
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessTreeItem;
|
||||
@@ -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,128 @@
|
||||
/**
|
||||
* 关键因素卡片组件
|
||||
*
|
||||
* 显示单个关键因素的详细信息
|
||||
* 使用位置:关键因素 Accordion 内
|
||||
* 黑金主题设计
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Tag,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaArrowUp, FaArrowDown } from 'react-icons/fa';
|
||||
import type { KeyFactorCardProps, ImpactDirection } from '../types';
|
||||
|
||||
// 黑金主题样式常量
|
||||
const THEME = {
|
||||
cardBg: '#252D3A',
|
||||
textColor: '#E2E8F0',
|
||||
subtextColor: '#A0AEC0',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 获取影响方向对应的颜色
|
||||
*/
|
||||
const getImpactColor = (direction?: ImpactDirection): string => {
|
||||
const colorMap: Record<ImpactDirection, string> = {
|
||||
positive: 'red',
|
||||
negative: 'green',
|
||||
neutral: 'gray',
|
||||
mixed: 'yellow',
|
||||
};
|
||||
return colorMap[direction || 'neutral'] || 'gray';
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取影响方向的中文标签
|
||||
*/
|
||||
const getImpactLabel = (direction?: ImpactDirection): string => {
|
||||
const labelMap: Record<ImpactDirection, string> = {
|
||||
positive: '正面',
|
||||
negative: '负面',
|
||||
neutral: '中性',
|
||||
mixed: '混合',
|
||||
};
|
||||
return labelMap[direction || 'neutral'] || '中性';
|
||||
};
|
||||
|
||||
const KeyFactorCard: React.FC<KeyFactorCardProps> = ({ factor }) => {
|
||||
const impactColor = getImpactColor(factor.impact_direction);
|
||||
|
||||
return (
|
||||
<Card
|
||||
bg={THEME.cardBg}
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.100"
|
||||
size="sm"
|
||||
>
|
||||
<CardBody p={3}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="medium" fontSize="sm" color={THEME.textColor}>
|
||||
{factor.factor_name}
|
||||
</Text>
|
||||
<Badge
|
||||
bg="transparent"
|
||||
border="1px solid"
|
||||
borderColor={`${impactColor}.400`}
|
||||
color={`${impactColor}.400`}
|
||||
size="sm"
|
||||
>
|
||||
{getImpactLabel(factor.impact_direction)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="lg" fontWeight="bold" color={`${impactColor}.400`}>
|
||||
{factor.factor_value}
|
||||
{factor.factor_unit && ` ${factor.factor_unit}`}
|
||||
</Text>
|
||||
{factor.year_on_year !== undefined && (
|
||||
<Tag
|
||||
size="sm"
|
||||
bg="transparent"
|
||||
border="1px solid"
|
||||
borderColor={factor.year_on_year > 0 ? 'red.400' : 'green.400'}
|
||||
color={factor.year_on_year > 0 ? 'red.400' : 'green.400'}
|
||||
>
|
||||
<Icon
|
||||
as={factor.year_on_year > 0 ? FaArrowUp : FaArrowDown}
|
||||
mr={1}
|
||||
boxSize={3}
|
||||
/>
|
||||
{Math.abs(factor.year_on_year)}%
|
||||
</Tag>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{factor.factor_desc && (
|
||||
<Text fontSize="xs" color={THEME.subtextColor} noOfLines={2}>
|
||||
{factor.factor_desc}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="xs" color={THEME.subtextColor}>
|
||||
影响权重: {factor.impact_weight}
|
||||
</Text>
|
||||
{factor.report_period && (
|
||||
<Text fontSize="xs" color={THEME.subtextColor}>
|
||||
{factor.report_period}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyFactorCard;
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 产业链流程式导航组件
|
||||
*
|
||||
* 显示上游 → 核心 → 下游的流程式导航
|
||||
* 带图标箭头连接符
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { HStack, VStack, Box, Text, Icon, Badge } from '@chakra-ui/react';
|
||||
import { FaArrowRight } from 'react-icons/fa';
|
||||
|
||||
// 黑金主题配置
|
||||
const THEME = {
|
||||
gold: '#D4AF37',
|
||||
textSecondary: 'gray.400',
|
||||
upstream: {
|
||||
active: 'orange.500',
|
||||
activeBg: 'orange.900',
|
||||
inactive: 'white',
|
||||
inactiveBg: 'gray.700',
|
||||
},
|
||||
core: {
|
||||
active: 'blue.500',
|
||||
activeBg: 'blue.900',
|
||||
inactive: 'white',
|
||||
inactiveBg: 'gray.700',
|
||||
},
|
||||
downstream: {
|
||||
active: 'green.500',
|
||||
activeBg: 'green.900',
|
||||
inactive: 'white',
|
||||
inactiveBg: 'gray.700',
|
||||
},
|
||||
};
|
||||
|
||||
export type TabType = 'upstream' | 'core' | 'downstream';
|
||||
|
||||
interface ProcessNavigationProps {
|
||||
activeTab: TabType;
|
||||
onTabChange: (tab: TabType) => void;
|
||||
upstreamCount: number;
|
||||
coreCount: number;
|
||||
downstreamCount: number;
|
||||
}
|
||||
|
||||
interface NavItemProps {
|
||||
label: string;
|
||||
subtitle: string;
|
||||
count: number;
|
||||
isActive: boolean;
|
||||
colorKey: 'upstream' | 'core' | 'downstream';
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const NavItem: React.FC<NavItemProps> = memo(({
|
||||
label,
|
||||
subtitle,
|
||||
count,
|
||||
isActive,
|
||||
colorKey,
|
||||
onClick,
|
||||
}) => {
|
||||
const colors = THEME[colorKey];
|
||||
|
||||
return (
|
||||
<Box
|
||||
px={4}
|
||||
py={2}
|
||||
borderRadius="lg"
|
||||
cursor="pointer"
|
||||
bg={isActive ? colors.activeBg : colors.inactiveBg}
|
||||
borderWidth={2}
|
||||
borderColor={isActive ? colors.active : 'gray.600'}
|
||||
onClick={onClick}
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
borderColor: colors.active,
|
||||
transform: 'translateY(-2px)',
|
||||
}}
|
||||
>
|
||||
<VStack spacing={1} align="center">
|
||||
<HStack spacing={2}>
|
||||
<Text
|
||||
fontWeight={isActive ? 'bold' : 'medium'}
|
||||
color={isActive ? colors.active : colors.inactive}
|
||||
fontSize="sm"
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Badge
|
||||
bg={isActive ? colors.active : 'gray.600'}
|
||||
color="white"
|
||||
borderRadius="full"
|
||||
px={2}
|
||||
fontSize="xs"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={THEME.textSecondary}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
NavItem.displayName = 'NavItem';
|
||||
|
||||
const ProcessNavigation: React.FC<ProcessNavigationProps> = memo(({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
upstreamCount,
|
||||
coreCount,
|
||||
downstreamCount,
|
||||
}) => {
|
||||
return (
|
||||
<HStack
|
||||
spacing={2}
|
||||
flexWrap="wrap"
|
||||
gap={2}
|
||||
>
|
||||
<NavItem
|
||||
label="上游供应链"
|
||||
subtitle="原材料与供应商"
|
||||
count={upstreamCount}
|
||||
isActive={activeTab === 'upstream'}
|
||||
colorKey="upstream"
|
||||
onClick={() => onTabChange('upstream')}
|
||||
/>
|
||||
|
||||
<Icon
|
||||
as={FaArrowRight}
|
||||
color={THEME.textSecondary}
|
||||
boxSize={4}
|
||||
/>
|
||||
|
||||
<NavItem
|
||||
label="核心企业"
|
||||
subtitle="公司主体与产品"
|
||||
count={coreCount}
|
||||
isActive={activeTab === 'core'}
|
||||
colorKey="core"
|
||||
onClick={() => onTabChange('core')}
|
||||
/>
|
||||
|
||||
<Icon
|
||||
as={FaArrowRight}
|
||||
color={THEME.textSecondary}
|
||||
boxSize={4}
|
||||
/>
|
||||
|
||||
<NavItem
|
||||
label="下游客户"
|
||||
subtitle="客户与终端市场"
|
||||
count={downstreamCount}
|
||||
isActive={activeTab === 'downstream'}
|
||||
colorKey="downstream"
|
||||
onClick={() => onTabChange('downstream')}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
});
|
||||
|
||||
ProcessNavigation.displayName = 'ProcessNavigation';
|
||||
|
||||
export default ProcessNavigation;
|
||||
@@ -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,151 @@
|
||||
/**
|
||||
* 产业链筛选栏组件
|
||||
*
|
||||
* 提供类型筛选、重要度筛选和视图切换功能
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
HStack,
|
||||
Select,
|
||||
Tabs,
|
||||
TabList,
|
||||
Tab,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
// 黑金主题配置
|
||||
const THEME = {
|
||||
gold: '#D4AF37',
|
||||
textPrimary: '#D4AF37',
|
||||
textSecondary: 'gray.400',
|
||||
inputBg: 'gray.700',
|
||||
inputBorder: 'gray.600',
|
||||
};
|
||||
|
||||
export type ViewMode = 'hierarchy' | 'flow';
|
||||
|
||||
// 节点类型选项
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'all', label: '全部类型' },
|
||||
{ value: 'company', label: '公司' },
|
||||
{ value: 'supplier', label: '供应商' },
|
||||
{ value: 'customer', label: '客户' },
|
||||
{ value: 'regulator', label: '监管机构' },
|
||||
{ value: 'product', label: '产品' },
|
||||
{ value: 'service', label: '服务' },
|
||||
{ value: 'channel', label: '渠道' },
|
||||
{ value: 'raw_material', label: '原材料' },
|
||||
{ value: 'end_user', label: '终端用户' },
|
||||
];
|
||||
|
||||
// 重要度选项
|
||||
const IMPORTANCE_OPTIONS = [
|
||||
{ value: 'all', label: '全部重要度' },
|
||||
{ value: 'high', label: '高 (≥80)' },
|
||||
{ value: 'medium', label: '中 (50-79)' },
|
||||
{ value: 'low', label: '低 (<50)' },
|
||||
];
|
||||
|
||||
interface ValueChainFilterBarProps {
|
||||
typeFilter: string;
|
||||
onTypeChange: (value: string) => void;
|
||||
importanceFilter: string;
|
||||
onImportanceChange: (value: string) => void;
|
||||
viewMode: ViewMode;
|
||||
onViewModeChange: (value: ViewMode) => void;
|
||||
}
|
||||
|
||||
const ValueChainFilterBar: React.FC<ValueChainFilterBarProps> = memo(({
|
||||
typeFilter,
|
||||
onTypeChange,
|
||||
importanceFilter,
|
||||
onImportanceChange,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
}) => {
|
||||
return (
|
||||
<HStack
|
||||
spacing={3}
|
||||
flexWrap="wrap"
|
||||
gap={3}
|
||||
>
|
||||
{/* 左侧筛选区 */}
|
||||
{/* <HStack spacing={3}>
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={(e) => onTypeChange(e.target.value)}
|
||||
size="sm"
|
||||
w="140px"
|
||||
bg={THEME.inputBg}
|
||||
borderColor={THEME.inputBorder}
|
||||
color={THEME.textPrimary}
|
||||
_hover={{ borderColor: THEME.gold }}
|
||||
_focus={{ borderColor: THEME.gold, boxShadow: 'none' }}
|
||||
>
|
||||
{TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value} style={{ background: '#2D3748' }}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={importanceFilter}
|
||||
onChange={(e) => onImportanceChange(e.target.value)}
|
||||
size="sm"
|
||||
w="140px"
|
||||
bg={THEME.inputBg}
|
||||
borderColor={THEME.inputBorder}
|
||||
color={THEME.textPrimary}
|
||||
_hover={{ borderColor: THEME.gold }}
|
||||
_focus={{ borderColor: THEME.gold, boxShadow: 'none' }}
|
||||
>
|
||||
{IMPORTANCE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value} style={{ background: '#2D3748' }}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack> */}
|
||||
|
||||
{/* 右侧视图切换 */}
|
||||
<Tabs
|
||||
index={viewMode === 'hierarchy' ? 0 : 1}
|
||||
onChange={(index) => onViewModeChange(index === 0 ? 'hierarchy' : 'flow')}
|
||||
variant="soft-rounded"
|
||||
size="sm"
|
||||
>
|
||||
<TabList>
|
||||
<Tab
|
||||
color={THEME.textSecondary}
|
||||
_selected={{
|
||||
bg: THEME.gold,
|
||||
color: 'gray.900',
|
||||
}}
|
||||
_hover={{
|
||||
bg: 'gray.600',
|
||||
}}
|
||||
>
|
||||
层级视图
|
||||
</Tab>
|
||||
<Tab
|
||||
color={THEME.textSecondary}
|
||||
_selected={{
|
||||
bg: THEME.gold,
|
||||
color: 'gray.900',
|
||||
}}
|
||||
_hover={{
|
||||
bg: 'gray.600',
|
||||
}}
|
||||
>
|
||||
流向关系
|
||||
</Tab>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
</HStack>
|
||||
);
|
||||
});
|
||||
|
||||
ValueChainFilterBar.displayName = 'ValueChainFilterBar';
|
||||
|
||||
export default ValueChainFilterBar;
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 原子组件导出
|
||||
*
|
||||
* DeepAnalysisTab 内部使用的基础 UI 组件
|
||||
*/
|
||||
|
||||
export { default as DisclaimerBox } from './DisclaimerBox';
|
||||
export { default as ScoreBar } from './ScoreBar';
|
||||
export { default as BusinessTreeItem } from './BusinessTreeItem';
|
||||
export { default as KeyFactorCard } from './KeyFactorCard';
|
||||
export { default as ProcessNavigation } from './ProcessNavigation';
|
||||
export { default as ValueChainFilterBar } from './ValueChainFilterBar';
|
||||
export type { TabType } from './ProcessNavigation';
|
||||
export type { ViewMode } from './ValueChainFilterBar';
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 业务板块详情卡片
|
||||
*
|
||||
* 显示公司各业务板块的详细信息
|
||||
* 黑金主题风格
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
Badge,
|
||||
Box,
|
||||
Icon,
|
||||
SimpleGrid,
|
||||
Button,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaIndustry, FaExpandAlt, FaCompressAlt } from 'react-icons/fa';
|
||||
import type { BusinessSegment } from '../types';
|
||||
|
||||
// 黑金主题配置
|
||||
const THEME = {
|
||||
cardBg: 'gray.800',
|
||||
innerCardBg: 'gray.700',
|
||||
gold: '#D4AF37',
|
||||
goldLight: '#F0D78C',
|
||||
textPrimary: '#D4AF37',
|
||||
textSecondary: 'gray.400',
|
||||
border: 'rgba(212, 175, 55, 0.3)',
|
||||
};
|
||||
|
||||
interface BusinessSegmentsCardProps {
|
||||
businessSegments: BusinessSegment[];
|
||||
expandedSegments: Record<number, boolean>;
|
||||
onToggleSegment: (index: number) => void;
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
|
||||
businessSegments,
|
||||
expandedSegments,
|
||||
onToggleSegment,
|
||||
}) => {
|
||||
if (!businessSegments || businessSegments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card bg={THEME.cardBg} shadow="md">
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaIndustry} color={THEME.gold} />
|
||||
<Heading size="sm" color={THEME.textPrimary}>业务板块详情</Heading>
|
||||
<Badge bg={THEME.gold} color="gray.900">{businessSegments.length} 个板块</Badge>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody px={2}>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||
{businessSegments.map((segment, idx) => {
|
||||
const isExpanded = expandedSegments[idx];
|
||||
|
||||
return (
|
||||
<Card key={idx} bg={THEME.innerCardBg}>
|
||||
<CardBody px={2}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="bold" fontSize="md" color={THEME.textPrimary}>
|
||||
{segment.segment_name}
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={
|
||||
<Icon as={isExpanded ? FaCompressAlt : FaExpandAlt} />
|
||||
}
|
||||
onClick={() => onToggleSegment(idx)}
|
||||
color={THEME.gold}
|
||||
_hover={{ bg: 'gray.600' }}
|
||||
>
|
||||
{isExpanded ? '折叠' : '展开'}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||
业务描述
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={THEME.textPrimary}
|
||||
noOfLines={isExpanded ? undefined : 3}
|
||||
>
|
||||
{segment.segment_description || '暂无描述'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||
竞争地位
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={THEME.textPrimary}
|
||||
noOfLines={isExpanded ? undefined : 2}
|
||||
>
|
||||
{segment.competitive_position || '暂无数据'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||
未来潜力
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
noOfLines={isExpanded ? undefined : 2}
|
||||
color={THEME.goldLight}
|
||||
>
|
||||
{segment.future_potential || '暂无数据'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{isExpanded && segment.key_products && (
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||
主要产品
|
||||
</Text>
|
||||
<Text fontSize="sm" color="green.300">
|
||||
{segment.key_products}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isExpanded && segment.market_share !== undefined && (
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||
市场份额
|
||||
</Text>
|
||||
<Badge bg="purple.600" color="white" fontSize="sm">
|
||||
{segment.market_share}%
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isExpanded && segment.revenue_contribution !== undefined && (
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||
营收贡献
|
||||
</Text>
|
||||
<Badge bg={THEME.gold} color="gray.900" fontSize="sm">
|
||||
{segment.revenue_contribution}%
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessSegmentsCard;
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 业务结构分析卡片
|
||||
*
|
||||
* 显示公司业务结构树形图
|
||||
* 黑金主题风格
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaChartPie } from 'react-icons/fa';
|
||||
import { BusinessTreeItem } from '../atoms';
|
||||
import type { BusinessStructure } from '../types';
|
||||
|
||||
// 黑金主题配置
|
||||
const THEME = {
|
||||
cardBg: 'gray.800',
|
||||
gold: '#D4AF37',
|
||||
textPrimary: '#D4AF37',
|
||||
border: 'rgba(212, 175, 55, 0.3)',
|
||||
};
|
||||
|
||||
interface BusinessStructureCardProps {
|
||||
businessStructure: BusinessStructure[];
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const BusinessStructureCard: React.FC<BusinessStructureCardProps> = ({
|
||||
businessStructure,
|
||||
}) => {
|
||||
if (!businessStructure || businessStructure.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card bg={THEME.cardBg} shadow="md">
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaChartPie} color={THEME.gold} />
|
||||
<Heading size="sm" color={THEME.textPrimary}>业务结构分析</Heading>
|
||||
<Badge bg={THEME.gold} color="gray.900">{businessStructure[0]?.report_period}</Badge>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody px={0}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{businessStructure.map((business, idx) => (
|
||||
<BusinessTreeItem
|
||||
key={idx}
|
||||
business={business}
|
||||
depth={business.business_level - 1}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessStructureCard;
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* 竞争地位分析卡片
|
||||
*
|
||||
* 显示竞争力评分、雷达图和竞争分析
|
||||
* 包含行业排名弹窗功能
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
Badge,
|
||||
Tag,
|
||||
TagLabel,
|
||||
Grid,
|
||||
GridItem,
|
||||
Box,
|
||||
Icon,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FaTrophy,
|
||||
FaCog,
|
||||
FaStar,
|
||||
FaChartLine,
|
||||
FaDollarSign,
|
||||
FaFlask,
|
||||
FaShieldAlt,
|
||||
FaRocket,
|
||||
FaUsers,
|
||||
FaExternalLinkAlt,
|
||||
} from 'react-icons/fa';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { ScoreBar } from '../atoms';
|
||||
import { getRadarChartOption } from '../utils/chartOptions';
|
||||
import { IndustryRankingView } from '../../../FinancialPanorama/components';
|
||||
import type { ComprehensiveData, CompetitivePosition, IndustryRankData } from '../types';
|
||||
|
||||
// 黑金主题弹窗样式
|
||||
const MODAL_STYLES = {
|
||||
content: {
|
||||
bg: 'gray.900',
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
borderWidth: '1px',
|
||||
maxW: '900px',
|
||||
},
|
||||
header: {
|
||||
color: 'yellow.500',
|
||||
borderBottomColor: 'rgba(212, 175, 55, 0.2)',
|
||||
borderBottomWidth: '1px',
|
||||
},
|
||||
closeButton: {
|
||||
color: 'yellow.500',
|
||||
_hover: { bg: 'rgba(212, 175, 55, 0.1)' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
// 样式常量 - 避免每次渲染创建新对象
|
||||
const CARD_STYLES = {
|
||||
bg: 'transparent',
|
||||
shadow: 'md',
|
||||
} as const;
|
||||
|
||||
const CONTENT_BOX_STYLES = {
|
||||
p: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'yellow.600',
|
||||
borderRadius: 'md',
|
||||
} as const;
|
||||
|
||||
const GRID_COLSPAN = { base: 2, lg: 1 } as const;
|
||||
const CHART_STYLE = { height: '320px' } as const;
|
||||
|
||||
interface CompetitiveAnalysisCardProps {
|
||||
comprehensiveData: ComprehensiveData;
|
||||
industryRankData?: IndustryRankData[];
|
||||
}
|
||||
|
||||
// 竞争对手标签组件
|
||||
interface CompetitorTagsProps {
|
||||
competitors: string[];
|
||||
}
|
||||
|
||||
const CompetitorTags = memo<CompetitorTagsProps>(({ competitors }) => (
|
||||
<Box mb={4}>
|
||||
<Text fontWeight="bold" fontSize="sm" mb={2} color="yellow.500">
|
||||
主要竞争对手
|
||||
</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{competitors.map((competitor, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
size="md"
|
||||
variant="outline"
|
||||
borderColor="yellow.600"
|
||||
color="yellow.500"
|
||||
borderRadius="full"
|
||||
>
|
||||
<Icon as={FaUsers} mr={1} />
|
||||
<TagLabel>{competitor}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
));
|
||||
|
||||
CompetitorTags.displayName = 'CompetitorTags';
|
||||
|
||||
// 评分区域组件
|
||||
interface ScoreSectionProps {
|
||||
scores: CompetitivePosition['scores'];
|
||||
}
|
||||
|
||||
const ScoreSection = memo<ScoreSectionProps>(({ scores }) => (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<ScoreBar label="市场地位" score={scores?.market_position} icon={FaTrophy} />
|
||||
<ScoreBar label="技术实力" score={scores?.technology} icon={FaCog} />
|
||||
<ScoreBar label="品牌价值" score={scores?.brand} icon={FaStar} />
|
||||
<ScoreBar label="运营效率" score={scores?.operation} icon={FaChartLine} />
|
||||
<ScoreBar label="财务健康" score={scores?.finance} icon={FaDollarSign} />
|
||||
<ScoreBar label="创新能力" score={scores?.innovation} icon={FaFlask} />
|
||||
<ScoreBar label="风险控制" score={scores?.risk} icon={FaShieldAlt} />
|
||||
<ScoreBar label="成长潜力" score={scores?.growth} icon={FaRocket} />
|
||||
</VStack>
|
||||
));
|
||||
|
||||
ScoreSection.displayName = 'ScoreSection';
|
||||
|
||||
// 竞争优劣势组件
|
||||
interface AdvantagesSectionProps {
|
||||
advantages?: string;
|
||||
disadvantages?: string;
|
||||
}
|
||||
|
||||
const AdvantagesSection = memo<AdvantagesSectionProps>(
|
||||
({ advantages, disadvantages }) => (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
<Box {...CONTENT_BOX_STYLES}>
|
||||
<Text fontWeight="bold" fontSize="sm" mb={2} color="green.400">
|
||||
竞争优势
|
||||
</Text>
|
||||
<Text fontSize="sm" color="white">
|
||||
{advantages || '暂无数据'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box {...CONTENT_BOX_STYLES}>
|
||||
<Text fontWeight="bold" fontSize="sm" mb={2} color="red.400">
|
||||
竞争劣势
|
||||
</Text>
|
||||
<Text fontSize="sm" color="white">
|
||||
{disadvantages || '暂无数据'}
|
||||
</Text>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
)
|
||||
);
|
||||
|
||||
AdvantagesSection.displayName = 'AdvantagesSection';
|
||||
|
||||
const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
|
||||
({ comprehensiveData, industryRankData }) => {
|
||||
const competitivePosition = comprehensiveData.competitive_position;
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
if (!competitivePosition) return null;
|
||||
|
||||
// 缓存雷达图配置
|
||||
const radarOption = useMemo(
|
||||
() => getRadarChartOption(comprehensiveData),
|
||||
[comprehensiveData]
|
||||
);
|
||||
|
||||
// 缓存竞争对手列表
|
||||
const competitors = useMemo(
|
||||
() =>
|
||||
competitivePosition.analysis?.main_competitors
|
||||
?.split(',')
|
||||
.map((c) => c.trim()) || [],
|
||||
[competitivePosition.analysis?.main_competitors]
|
||||
);
|
||||
|
||||
// 判断是否有行业排名数据可展示
|
||||
const hasIndustryRankData = industryRankData && industryRankData.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card {...CARD_STYLES}>
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaTrophy} color="yellow.500" />
|
||||
<Heading size="sm" color="yellow.500">竞争地位分析</Heading>
|
||||
{competitivePosition.ranking && (
|
||||
<Badge
|
||||
ml={2}
|
||||
bg="transparent"
|
||||
border="1px solid"
|
||||
borderColor="yellow.600"
|
||||
color="yellow.500"
|
||||
cursor={hasIndustryRankData ? 'pointer' : 'default'}
|
||||
onClick={hasIndustryRankData ? onOpen : undefined}
|
||||
_hover={hasIndustryRankData ? { bg: 'rgba(212, 175, 55, 0.1)' } : undefined}
|
||||
>
|
||||
行业排名 {competitivePosition.ranking.industry_rank}/
|
||||
{competitivePosition.ranking.total_companies}
|
||||
</Badge>
|
||||
)}
|
||||
{hasIndustryRankData && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="yellow.500"
|
||||
rightIcon={<Icon as={FaExternalLinkAlt} boxSize={3} />}
|
||||
onClick={onOpen}
|
||||
_hover={{ bg: 'rgba(212, 175, 55, 0.1)' }}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{/* 主要竞争对手 */}
|
||||
{competitors.length > 0 && <CompetitorTags competitors={competitors} />}
|
||||
|
||||
{/* 评分和雷达图 */}
|
||||
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
|
||||
<GridItem colSpan={GRID_COLSPAN}>
|
||||
<ScoreSection scores={competitivePosition.scores} />
|
||||
</GridItem>
|
||||
|
||||
<GridItem colSpan={GRID_COLSPAN}>
|
||||
{radarOption && (
|
||||
<ReactECharts
|
||||
option={radarOption}
|
||||
style={CHART_STYLE}
|
||||
theme="dark"
|
||||
/>
|
||||
)}
|
||||
</GridItem>
|
||||
</Grid>
|
||||
|
||||
<Divider my={4} borderColor="yellow.600" />
|
||||
|
||||
{/* 竞争优势和劣势 */}
|
||||
<AdvantagesSection
|
||||
advantages={competitivePosition.analysis?.competitive_advantages}
|
||||
disadvantages={competitivePosition.analysis?.competitive_disadvantages}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 行业排名弹窗 - 黑金主题 */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="4xl" scrollBehavior="inside">
|
||||
<ModalOverlay bg="blackAlpha.700" />
|
||||
<ModalContent {...MODAL_STYLES.content}>
|
||||
<ModalHeader {...MODAL_STYLES.header}>
|
||||
<HStack>
|
||||
<Icon as={FaTrophy} color="yellow.500" />
|
||||
<Text>行业排名详情</Text>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton {...MODAL_STYLES.closeButton} />
|
||||
<ModalBody py={4}>
|
||||
{hasIndustryRankData && (
|
||||
<IndustryRankingView
|
||||
industryRank={industryRankData}
|
||||
bgColor="gray.800"
|
||||
borderColor="rgba(212, 175, 55, 0.3)"
|
||||
/>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CompetitiveAnalysisCard.displayName = 'CompetitiveAnalysisCard';
|
||||
|
||||
export default CompetitiveAnalysisCard;
|
||||
@@ -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,124 @@
|
||||
/**
|
||||
* 关键因素卡片
|
||||
*
|
||||
* 显示影响公司的关键因素列表
|
||||
* 黑金主题设计
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
Badge,
|
||||
Box,
|
||||
Icon,
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionButton,
|
||||
AccordionPanel,
|
||||
AccordionIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaBalanceScale } from 'react-icons/fa';
|
||||
import { KeyFactorCard } from '../atoms';
|
||||
import type { KeyFactors } from '../types';
|
||||
|
||||
// 黑金主题样式常量
|
||||
const THEME = {
|
||||
bg: '#1A202C',
|
||||
cardBg: '#252D3A',
|
||||
border: '#C9A961',
|
||||
borderGradient: 'linear-gradient(90deg, #C9A961, #8B7355)',
|
||||
titleColor: '#C9A961',
|
||||
textColor: '#E2E8F0',
|
||||
subtextColor: '#A0AEC0',
|
||||
} as const;
|
||||
|
||||
const CARD_STYLES = {
|
||||
bg: THEME.bg,
|
||||
shadow: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'whiteAlpha.100',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
_before: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: THEME.borderGradient,
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface KeyFactorsCardProps {
|
||||
keyFactors: KeyFactors;
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const KeyFactorsCard: React.FC<KeyFactorsCardProps> = ({ keyFactors }) => {
|
||||
return (
|
||||
<Card {...CARD_STYLES} h="full">
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaBalanceScale} color="yellow.500" />
|
||||
<Heading size="sm" color={THEME.titleColor}>
|
||||
关键因素
|
||||
</Heading>
|
||||
<Badge
|
||||
bg="transparent"
|
||||
border="1px solid"
|
||||
borderColor="yellow.600"
|
||||
color="yellow.500"
|
||||
>
|
||||
{keyFactors.total_factors} 项
|
||||
</Badge>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Accordion allowMultiple>
|
||||
{keyFactors.categories.map((category, idx) => (
|
||||
<AccordionItem key={idx} border="none">
|
||||
<AccordionButton
|
||||
bg={THEME.cardBg}
|
||||
borderRadius="md"
|
||||
mb={2}
|
||||
_hover={{ bg: 'whiteAlpha.100' }}
|
||||
>
|
||||
<Box flex="1" textAlign="left">
|
||||
<HStack>
|
||||
<Text fontWeight="medium" color={THEME.textColor}>
|
||||
{category.category_name}
|
||||
</Text>
|
||||
<Badge
|
||||
bg="whiteAlpha.100"
|
||||
color={THEME.subtextColor}
|
||||
size="sm"
|
||||
>
|
||||
{category.factors.length}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Box>
|
||||
<AccordionIcon color={THEME.subtextColor} />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{category.factors.map((factor, fidx) => (
|
||||
<KeyFactorCard key={fidx} factor={factor} />
|
||||
))}
|
||||
</VStack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyFactorsCard;
|
||||
@@ -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,95 @@
|
||||
/**
|
||||
* 发展时间线卡片
|
||||
*
|
||||
* 显示公司发展历程时间线
|
||||
* 黑金主题设计
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
Box,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaHistory } from 'react-icons/fa';
|
||||
import TimelineComponent from '../organisms/TimelineComponent';
|
||||
import type { DevelopmentTimeline } from '../types';
|
||||
|
||||
// 黑金主题样式常量
|
||||
const THEME = {
|
||||
bg: '#1A202C',
|
||||
cardBg: '#252D3A',
|
||||
border: '#C9A961',
|
||||
borderGradient: 'linear-gradient(90deg, #C9A961, #8B7355)',
|
||||
titleColor: '#C9A961',
|
||||
textColor: '#E2E8F0',
|
||||
subtextColor: '#A0AEC0',
|
||||
} as const;
|
||||
|
||||
const CARD_STYLES = {
|
||||
bg: THEME.bg,
|
||||
shadow: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'whiteAlpha.100',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
_before: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: THEME.borderGradient,
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface TimelineCardProps {
|
||||
developmentTimeline: DevelopmentTimeline;
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const TimelineCard: React.FC<TimelineCardProps> = ({ developmentTimeline }) => {
|
||||
return (
|
||||
<Card {...CARD_STYLES} h="full">
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaHistory} color="yellow.500" />
|
||||
<Heading size="sm" color={THEME.titleColor}>
|
||||
发展时间线
|
||||
</Heading>
|
||||
<HStack spacing={1}>
|
||||
<Badge
|
||||
bg="transparent"
|
||||
border="1px solid"
|
||||
borderColor="red.400"
|
||||
color="red.400"
|
||||
>
|
||||
正面 {developmentTimeline.statistics?.positive_events || 0}
|
||||
</Badge>
|
||||
<Badge
|
||||
bg="transparent"
|
||||
border="1px solid"
|
||||
borderColor="green.400"
|
||||
color="green.400"
|
||||
>
|
||||
负面 {developmentTimeline.statistics?.negative_events || 0}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Box maxH="600px" overflowY="auto" pr={2}>
|
||||
<TimelineComponent events={developmentTimeline.events} />
|
||||
</Box>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineCard;
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 产业链分析卡片
|
||||
*
|
||||
* 显示产业链层级视图和流向关系
|
||||
* 黑金主题风格 + 流程式导航
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, memo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
Badge,
|
||||
Icon,
|
||||
SimpleGrid,
|
||||
Center,
|
||||
Box,
|
||||
Flex,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaNetworkWired } from 'react-icons/fa';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import {
|
||||
ProcessNavigation,
|
||||
ValueChainFilterBar,
|
||||
} from '../atoms';
|
||||
import type { TabType, ViewMode } from '../atoms';
|
||||
import ValueChainNodeCard from '../organisms/ValueChainNodeCard';
|
||||
import { getSankeyChartOption } from '../utils/chartOptions';
|
||||
import type { ValueChainData, ValueChainNode } from '../types';
|
||||
|
||||
// 黑金主题配置
|
||||
const THEME = {
|
||||
cardBg: 'gray.800',
|
||||
gold: '#D4AF37',
|
||||
goldLight: '#F0D78C',
|
||||
textPrimary: '#D4AF37',
|
||||
textSecondary: 'gray.400',
|
||||
};
|
||||
|
||||
interface ValueChainCardProps {
|
||||
valueChainData: ValueChainData;
|
||||
companyName?: string;
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const ValueChainCard: React.FC<ValueChainCardProps> = memo(({
|
||||
valueChainData,
|
||||
companyName = '目标公司',
|
||||
}) => {
|
||||
// 状态管理
|
||||
const [activeTab, setActiveTab] = useState<TabType>('upstream');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [importanceFilter, setImportanceFilter] = useState<string>('all');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('hierarchy');
|
||||
|
||||
// 解析节点数据
|
||||
const nodesByLevel = valueChainData.value_chain_structure?.nodes_by_level;
|
||||
|
||||
// 获取上游节点
|
||||
const upstreamNodes = useMemo(() => [
|
||||
...(nodesByLevel?.['level_-2'] || []),
|
||||
...(nodesByLevel?.['level_-1'] || []),
|
||||
], [nodesByLevel]);
|
||||
|
||||
// 获取核心节点
|
||||
const coreNodes = useMemo(() =>
|
||||
nodesByLevel?.['level_0'] || [],
|
||||
[nodesByLevel]);
|
||||
|
||||
// 获取下游节点
|
||||
const downstreamNodes = useMemo(() => [
|
||||
...(nodesByLevel?.['level_1'] || []),
|
||||
...(nodesByLevel?.['level_2'] || []),
|
||||
], [nodesByLevel]);
|
||||
|
||||
// 计算总节点数
|
||||
const totalNodes = valueChainData.analysis_summary?.total_nodes ||
|
||||
(upstreamNodes.length + coreNodes.length + downstreamNodes.length);
|
||||
|
||||
// 根据 activeTab 获取当前节点
|
||||
const currentNodes = useMemo(() => {
|
||||
switch (activeTab) {
|
||||
case 'upstream':
|
||||
return upstreamNodes;
|
||||
case 'core':
|
||||
return coreNodes;
|
||||
case 'downstream':
|
||||
return downstreamNodes;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}, [activeTab, upstreamNodes, coreNodes, downstreamNodes]);
|
||||
|
||||
// 筛选节点
|
||||
const filteredNodes = useMemo(() => {
|
||||
let nodes = [...currentNodes];
|
||||
|
||||
// 类型筛选
|
||||
if (typeFilter !== 'all') {
|
||||
nodes = nodes.filter((n: ValueChainNode) => n.node_type === typeFilter);
|
||||
}
|
||||
|
||||
// 重要度筛选
|
||||
if (importanceFilter !== 'all') {
|
||||
nodes = nodes.filter((n: ValueChainNode) => {
|
||||
const score = n.importance_score || 0;
|
||||
switch (importanceFilter) {
|
||||
case 'high':
|
||||
return score >= 80;
|
||||
case 'medium':
|
||||
return score >= 50 && score < 80;
|
||||
case 'low':
|
||||
return score < 50;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}, [currentNodes, typeFilter, importanceFilter]);
|
||||
|
||||
// Sankey 图配置
|
||||
const sankeyOption = useMemo(() =>
|
||||
getSankeyChartOption(valueChainData),
|
||||
[valueChainData]);
|
||||
|
||||
return (
|
||||
<Card bg={THEME.cardBg} shadow="md">
|
||||
{/* 头部区域 */}
|
||||
<CardHeader py={0}>
|
||||
<HStack flexWrap="wrap" gap={0}>
|
||||
<Icon as={FaNetworkWired} color={THEME.gold} />
|
||||
<Heading size="sm" color={THEME.textPrimary}>
|
||||
产业链分析
|
||||
</Heading>
|
||||
<Text color={THEME.textSecondary} fontSize="sm">
|
||||
| {companyName}供应链图谱
|
||||
</Text>
|
||||
<Badge bg={THEME.gold} color="gray.900">
|
||||
节点 {totalNodes}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody px={2}>
|
||||
{/* 工具栏:左侧流程导航 + 右侧筛选 */}
|
||||
<Flex
|
||||
borderBottom="1px solid"
|
||||
borderColor="gray.700"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
{/* 左侧:流程式导航 - 仅在层级视图显示 */}
|
||||
{viewMode === 'hierarchy' && (
|
||||
<ProcessNavigation
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
upstreamCount={upstreamNodes.length}
|
||||
coreCount={coreNodes.length}
|
||||
downstreamCount={downstreamNodes.length}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 右侧:筛选与视图切换 - 始终靠右 */}
|
||||
<Box ml="auto">
|
||||
<ValueChainFilterBar
|
||||
typeFilter={typeFilter}
|
||||
onTypeChange={setTypeFilter}
|
||||
importanceFilter={importanceFilter}
|
||||
onImportanceChange={setImportanceFilter}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<Box px={0} pt={4}>
|
||||
{viewMode === 'hierarchy' ? (
|
||||
filteredNodes.length > 0 ? (
|
||||
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
|
||||
{filteredNodes.map((node, idx) => (
|
||||
<ValueChainNodeCard
|
||||
key={idx}
|
||||
node={node}
|
||||
isCompany={node.node_type === 'company'}
|
||||
level={node.node_level}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Center h="200px">
|
||||
<Text color={THEME.textSecondary}>暂无匹配的节点数据</Text>
|
||||
</Center>
|
||||
)
|
||||
) : sankeyOption ? (
|
||||
<ReactECharts
|
||||
option={sankeyOption}
|
||||
style={{ height: '500px' }}
|
||||
theme="dark"
|
||||
/>
|
||||
) : (
|
||||
<Center h="200px">
|
||||
<Text color={THEME.textSecondary}>暂无流向数据</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
ValueChainCard.displayName = 'ValueChainCard';
|
||||
|
||||
export default ValueChainCard;
|
||||
@@ -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,108 @@
|
||||
/**
|
||||
* 深度分析 Tab 主组件
|
||||
*
|
||||
* 使用 SubTabContainer 二级导航组件,分为 4 个子 Tab:
|
||||
* 1. 战略分析 - 核心定位 + 战略分析 + 竞争地位
|
||||
* 2. 业务结构 - 业务结构树 + 业务板块详情
|
||||
* 3. 产业链 - 产业链分析(独立,含 Sankey 图)
|
||||
* 4. 发展历程 - 关键因素 + 时间线
|
||||
*
|
||||
* 支持懒加载:通过 activeTab 和 onTabChange 实现按需加载数据
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card, CardBody } from '@chakra-ui/react';
|
||||
import { FaBrain, FaBuilding, FaLink, FaHistory } from 'react-icons/fa';
|
||||
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
|
||||
import LoadingState from '../../LoadingState';
|
||||
import { StrategyTab, BusinessTab, ValueChainTab, DevelopmentTab } from './tabs';
|
||||
import type { DeepAnalysisTabProps, DeepAnalysisTabKey } from './types';
|
||||
|
||||
// 主题配置(与 BasicInfoTab 保持一致)
|
||||
const THEME = {
|
||||
cardBg: 'gray.900',
|
||||
border: 'rgba(212, 175, 55, 0.3)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Tab 配置
|
||||
*/
|
||||
const DEEP_ANALYSIS_TABS: SubTabConfig[] = [
|
||||
{ key: 'strategy', name: '战略分析', icon: FaBrain, component: StrategyTab },
|
||||
{ key: 'business', name: '业务结构', icon: FaBuilding, component: BusinessTab },
|
||||
{ key: 'valueChain', name: '产业链', icon: FaLink, component: ValueChainTab },
|
||||
{ key: 'development', name: '发展历程', icon: FaHistory, component: DevelopmentTab },
|
||||
];
|
||||
|
||||
/**
|
||||
* Tab key 到 index 的映射
|
||||
*/
|
||||
const TAB_KEY_TO_INDEX: Record<DeepAnalysisTabKey, number> = {
|
||||
strategy: 0,
|
||||
business: 1,
|
||||
valueChain: 2,
|
||||
development: 3,
|
||||
};
|
||||
|
||||
const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
|
||||
comprehensiveData,
|
||||
valueChainData,
|
||||
keyFactorsData,
|
||||
industryRankData,
|
||||
loading,
|
||||
cardBg,
|
||||
expandedSegments,
|
||||
onToggleSegment,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
}) => {
|
||||
// 计算当前 Tab 索引(受控模式)
|
||||
const currentIndex = useMemo(() => {
|
||||
if (activeTab) {
|
||||
return TAB_KEY_TO_INDEX[activeTab] ?? 0;
|
||||
}
|
||||
return undefined; // 非受控模式
|
||||
}, [activeTab]);
|
||||
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Card bg={THEME.cardBg} shadow="md" border="1px solid" borderColor={THEME.border}>
|
||||
<CardBody p={0}>
|
||||
<SubTabContainer
|
||||
tabs={DEEP_ANALYSIS_TABS}
|
||||
index={currentIndex}
|
||||
onTabChange={onTabChange}
|
||||
componentProps={{}}
|
||||
themePreset="blackGold"
|
||||
/>
|
||||
<LoadingState message="加载数据中..." height="200px" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card bg={THEME.cardBg} shadow="md" border="1px solid" borderColor={THEME.border}>
|
||||
<CardBody p={0}>
|
||||
<SubTabContainer
|
||||
tabs={DEEP_ANALYSIS_TABS}
|
||||
index={currentIndex}
|
||||
onTabChange={onTabChange}
|
||||
componentProps={{
|
||||
comprehensiveData,
|
||||
valueChainData,
|
||||
keyFactorsData,
|
||||
industryRankData,
|
||||
cardBg,
|
||||
expandedSegments,
|
||||
onToggleSegment,
|
||||
}}
|
||||
themePreset="blackGold"
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeepAnalysisTab;
|
||||
@@ -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,264 @@
|
||||
/**
|
||||
* 产业链节点卡片组件
|
||||
*
|
||||
* 显示产业链中的单个节点,点击可展开查看相关公司
|
||||
* 黑金主题风格
|
||||
*/
|
||||
|
||||
import React, { useState, memo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
Progress,
|
||||
Box,
|
||||
Tooltip,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
ScaleFade,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FaBuilding,
|
||||
FaHandshake,
|
||||
FaUserTie,
|
||||
FaIndustry,
|
||||
FaCog,
|
||||
FaNetworkWired,
|
||||
FaFlask,
|
||||
FaStar,
|
||||
} from 'react-icons/fa';
|
||||
import { logger } from '@utils/logger';
|
||||
import axios from '@utils/axiosConfig';
|
||||
import RelatedCompaniesModal from './RelatedCompaniesModal';
|
||||
import type { ValueChainNodeCardProps, RelatedCompany } from '../../types';
|
||||
|
||||
// 黑金主题配置
|
||||
const THEME = {
|
||||
cardBg: 'gray.700',
|
||||
gold: '#D4AF37',
|
||||
goldLight: '#F0D78C',
|
||||
textPrimary: 'white',
|
||||
textSecondary: 'gray.400',
|
||||
// 上游颜色
|
||||
upstream: {
|
||||
bg: 'rgba(237, 137, 54, 0.1)',
|
||||
border: 'orange.600',
|
||||
badge: 'orange',
|
||||
icon: 'orange.400',
|
||||
},
|
||||
// 核心企业颜色
|
||||
core: {
|
||||
bg: 'rgba(66, 153, 225, 0.15)',
|
||||
border: 'blue.500',
|
||||
badge: 'blue',
|
||||
icon: 'blue.400',
|
||||
},
|
||||
// 下游颜色
|
||||
downstream: {
|
||||
bg: 'rgba(72, 187, 120, 0.1)',
|
||||
border: 'green.600',
|
||||
badge: 'green',
|
||||
icon: 'green.400',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取节点类型对应的图标
|
||||
*/
|
||||
const getNodeTypeIcon = (type: string) => {
|
||||
const icons: Record<string, React.ComponentType> = {
|
||||
company: FaBuilding,
|
||||
supplier: FaHandshake,
|
||||
customer: FaUserTie,
|
||||
product: FaIndustry,
|
||||
service: FaCog,
|
||||
channel: FaNetworkWired,
|
||||
raw_material: FaFlask,
|
||||
regulator: FaBuilding,
|
||||
end_user: FaUserTie,
|
||||
};
|
||||
return icons[type] || FaBuilding;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取重要度对应的颜色
|
||||
*/
|
||||
const getImportanceColor = (score?: number): string => {
|
||||
if (!score) return 'green';
|
||||
if (score >= 80) return 'red';
|
||||
if (score >= 60) return 'orange';
|
||||
if (score >= 40) return 'yellow';
|
||||
return 'green';
|
||||
};
|
||||
|
||||
const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = memo(({
|
||||
node,
|
||||
isCompany = false,
|
||||
level = 0,
|
||||
}) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [relatedCompanies, setRelatedCompanies] = useState<RelatedCompany[]>([]);
|
||||
const [loadingRelated, setLoadingRelated] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
// 根据层级确定颜色方案
|
||||
const getColorConfig = () => {
|
||||
if (isCompany || level === 0) return THEME.core;
|
||||
if (level < 0) return THEME.upstream;
|
||||
return THEME.downstream;
|
||||
};
|
||||
|
||||
const colorConfig = getColorConfig();
|
||||
|
||||
// 获取相关公司数据
|
||||
const fetchRelatedCompanies = async () => {
|
||||
setLoadingRelated(true);
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
`/api/company/value-chain/related-companies?node_name=${encodeURIComponent(
|
||||
node.node_name
|
||||
)}`
|
||||
);
|
||||
if (data.success) {
|
||||
setRelatedCompanies(data.data || []);
|
||||
} else {
|
||||
toast({
|
||||
title: '获取相关公司失败',
|
||||
description: data.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('ValueChainNodeCard', 'fetchRelatedCompanies', error, {
|
||||
node_name: node.node_name,
|
||||
});
|
||||
toast({
|
||||
title: '获取相关公司失败',
|
||||
description: error instanceof Error ? error.message : '未知错误',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setLoadingRelated(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 点击卡片打开模态框
|
||||
const handleCardClick = () => {
|
||||
onOpen();
|
||||
if (relatedCompanies.length === 0) {
|
||||
fetchRelatedCompanies();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaleFade in={true} initialScale={0.9}>
|
||||
<Card
|
||||
bg={colorConfig.bg}
|
||||
borderColor={colorConfig.border}
|
||||
borderWidth={isCompany ? 2 : 1}
|
||||
shadow={isCompany ? 'lg' : 'sm'}
|
||||
cursor="pointer"
|
||||
onClick={handleCardClick}
|
||||
_hover={{
|
||||
shadow: 'xl',
|
||||
transform: 'translateY(-4px)',
|
||||
borderColor: THEME.gold,
|
||||
}}
|
||||
transition="all 0.3s ease"
|
||||
minH="140px"
|
||||
>
|
||||
<CardBody p={4}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
<HStack justify="space-between">
|
||||
<HStack spacing={2}>
|
||||
<Icon
|
||||
as={getNodeTypeIcon(node.node_type)}
|
||||
color={colorConfig.icon}
|
||||
boxSize={5}
|
||||
/>
|
||||
{isCompany && (
|
||||
<Badge colorScheme={colorConfig.badge} variant="solid">
|
||||
核心企业
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
{node.importance_score !== undefined &&
|
||||
node.importance_score >= 70 && (
|
||||
<Tooltip label="重要节点">
|
||||
<span>
|
||||
<Icon as={FaStar} color={THEME.gold} boxSize={4} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Text fontWeight="bold" fontSize="sm" color={THEME.textPrimary} noOfLines={2}>
|
||||
{node.node_name}
|
||||
</Text>
|
||||
|
||||
{node.node_description && (
|
||||
<Text fontSize="xs" color={THEME.textSecondary} noOfLines={2}>
|
||||
{node.node_description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Badge variant="subtle" size="sm" colorScheme={colorConfig.badge}>
|
||||
{node.node_type}
|
||||
</Badge>
|
||||
{node.market_share !== undefined && (
|
||||
<Badge variant="outline" size="sm" color={THEME.goldLight}>
|
||||
份额 {node.market_share}%
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{node.importance_score !== undefined && (
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<Text fontSize="xs" color={THEME.textSecondary}>
|
||||
重要度
|
||||
</Text>
|
||||
<Text fontSize="xs" fontWeight="bold" color={THEME.goldLight}>
|
||||
{node.importance_score}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Progress
|
||||
value={node.importance_score}
|
||||
size="xs"
|
||||
colorScheme={getImportanceColor(node.importance_score)}
|
||||
borderRadius="full"
|
||||
bg="gray.600"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</ScaleFade>
|
||||
|
||||
<RelatedCompaniesModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
node={node}
|
||||
isCompany={isCompany}
|
||||
colorScheme={colorConfig.badge}
|
||||
relatedCompanies={relatedCompanies}
|
||||
loadingRelated={loadingRelated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ValueChainNodeCard.displayName = 'ValueChainNodeCard';
|
||||
|
||||
export default ValueChainNodeCard;
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 业务结构 Tab
|
||||
*
|
||||
* 包含:业务结构分析 + 业务板块详情
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import TabPanelContainer from '@components/TabPanelContainer';
|
||||
import { BusinessStructureCard, BusinessSegmentsCard } from '../components';
|
||||
import type { ComprehensiveData } from '../types';
|
||||
|
||||
export interface BusinessTabProps {
|
||||
comprehensiveData?: ComprehensiveData;
|
||||
cardBg?: string;
|
||||
expandedSegments: Record<number, boolean>;
|
||||
onToggleSegment: (index: number) => void;
|
||||
}
|
||||
|
||||
const BusinessTab: React.FC<BusinessTabProps> = memo(({
|
||||
comprehensiveData,
|
||||
cardBg,
|
||||
expandedSegments,
|
||||
onToggleSegment,
|
||||
}) => {
|
||||
return (
|
||||
<TabPanelContainer showDisclaimer>
|
||||
{/* 业务结构分析 */}
|
||||
{comprehensiveData?.business_structure &&
|
||||
comprehensiveData.business_structure.length > 0 && (
|
||||
<BusinessStructureCard
|
||||
businessStructure={comprehensiveData.business_structure}
|
||||
cardBg={cardBg}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 业务板块详情 */}
|
||||
{comprehensiveData?.business_segments &&
|
||||
comprehensiveData.business_segments.length > 0 && (
|
||||
<BusinessSegmentsCard
|
||||
businessSegments={comprehensiveData.business_segments}
|
||||
expandedSegments={expandedSegments}
|
||||
onToggleSegment={onToggleSegment}
|
||||
cardBg={cardBg}
|
||||
/>
|
||||
)}
|
||||
</TabPanelContainer>
|
||||
);
|
||||
});
|
||||
|
||||
BusinessTab.displayName = 'BusinessTab';
|
||||
|
||||
export default BusinessTab;
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 发展历程 Tab
|
||||
*
|
||||
* 包含:关键因素 + 发展时间线(Grid 布局)
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Grid, GridItem } from '@chakra-ui/react';
|
||||
import TabPanelContainer from '@components/TabPanelContainer';
|
||||
import { KeyFactorsCard, TimelineCard } from '../components';
|
||||
import type { KeyFactorsData } from '../types';
|
||||
|
||||
export interface DevelopmentTabProps {
|
||||
keyFactorsData?: KeyFactorsData;
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const DevelopmentTab: React.FC<DevelopmentTabProps> = memo(({
|
||||
keyFactorsData,
|
||||
cardBg,
|
||||
}) => {
|
||||
return (
|
||||
<TabPanelContainer showDisclaimer>
|
||||
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
|
||||
<GridItem colSpan={{ base: 2, lg: 1 }}>
|
||||
{keyFactorsData?.key_factors && (
|
||||
<KeyFactorsCard
|
||||
keyFactors={keyFactorsData.key_factors}
|
||||
cardBg={cardBg}
|
||||
/>
|
||||
)}
|
||||
</GridItem>
|
||||
|
||||
<GridItem colSpan={{ base: 2, lg: 1 }}>
|
||||
{keyFactorsData?.development_timeline && (
|
||||
<TimelineCard
|
||||
developmentTimeline={keyFactorsData.development_timeline}
|
||||
cardBg={cardBg}
|
||||
/>
|
||||
)}
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</TabPanelContainer>
|
||||
);
|
||||
});
|
||||
|
||||
DevelopmentTab.displayName = 'DevelopmentTab';
|
||||
|
||||
export default DevelopmentTab;
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 战略分析 Tab
|
||||
*
|
||||
* 包含:核心定位 + 战略分析 + 竞争地位分析(含行业排名弹窗)
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import TabPanelContainer from '@components/TabPanelContainer';
|
||||
import {
|
||||
CorePositioningCard,
|
||||
StrategyAnalysisCard,
|
||||
CompetitiveAnalysisCard,
|
||||
} from '../components';
|
||||
import type { ComprehensiveData, IndustryRankData } from '../types';
|
||||
|
||||
export interface StrategyTabProps {
|
||||
comprehensiveData?: ComprehensiveData;
|
||||
industryRankData?: IndustryRankData[];
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const StrategyTab: React.FC<StrategyTabProps> = memo(({
|
||||
comprehensiveData,
|
||||
industryRankData,
|
||||
cardBg,
|
||||
}) => {
|
||||
return (
|
||||
<TabPanelContainer showDisclaimer>
|
||||
{/* 核心定位卡片 */}
|
||||
{comprehensiveData?.qualitative_analysis && (
|
||||
<CorePositioningCard
|
||||
qualitativeAnalysis={comprehensiveData.qualitative_analysis}
|
||||
cardBg={cardBg}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 战略分析 */}
|
||||
{comprehensiveData?.qualitative_analysis?.strategy && (
|
||||
<StrategyAnalysisCard
|
||||
strategy={comprehensiveData.qualitative_analysis.strategy}
|
||||
cardBg={cardBg}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 竞争地位分析(包含行业排名弹窗) */}
|
||||
{comprehensiveData?.competitive_position && (
|
||||
<CompetitiveAnalysisCard
|
||||
comprehensiveData={comprehensiveData}
|
||||
industryRankData={industryRankData}
|
||||
/>
|
||||
)}
|
||||
</TabPanelContainer>
|
||||
);
|
||||
});
|
||||
|
||||
StrategyTab.displayName = 'StrategyTab';
|
||||
|
||||
export default StrategyTab;
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 产业链 Tab
|
||||
*
|
||||
* 包含:产业链分析(层级视图 + Sankey 流向图)
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import TabPanelContainer from '@components/TabPanelContainer';
|
||||
import { ValueChainCard } from '../components';
|
||||
import type { ValueChainData } from '../types';
|
||||
|
||||
export interface ValueChainTabProps {
|
||||
valueChainData?: ValueChainData;
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const ValueChainTab: React.FC<ValueChainTabProps> = memo(({
|
||||
valueChainData,
|
||||
cardBg,
|
||||
}) => {
|
||||
return (
|
||||
<TabPanelContainer showDisclaimer>
|
||||
{valueChainData && (
|
||||
<ValueChainCard valueChainData={valueChainData} cardBg={cardBg} />
|
||||
)}
|
||||
</TabPanelContainer>
|
||||
);
|
||||
});
|
||||
|
||||
ValueChainTab.displayName = 'ValueChainTab';
|
||||
|
||||
export default ValueChainTab;
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* DeepAnalysisTab - Tab 组件导出
|
||||
*/
|
||||
|
||||
export { default as StrategyTab } from './StrategyTab';
|
||||
export { default as BusinessTab } from './BusinessTab';
|
||||
export { default as ValueChainTab } from './ValueChainTab';
|
||||
export { default as DevelopmentTab } from './DevelopmentTab';
|
||||
|
||||
// 导出类型
|
||||
export type { StrategyTabProps } from './StrategyTab';
|
||||
export type { BusinessTabProps } from './BusinessTab';
|
||||
export type { ValueChainTabProps } from './ValueChainTab';
|
||||
export type { DevelopmentTabProps } from './DevelopmentTab';
|
||||
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* DeepAnalysisTab 组件类型定义
|
||||
*
|
||||
* 深度分析 Tab 所需的所有数据接口类型
|
||||
*/
|
||||
|
||||
// ==================== 格式化工具类型 ====================
|
||||
|
||||
export interface FormatUtils {
|
||||
formatCurrency: (value: number | null | undefined) => string;
|
||||
formatBusinessRevenue: (value: number | null | undefined, unit?: string) => string;
|
||||
formatPercentage: (value: number | null | undefined) => string;
|
||||
}
|
||||
|
||||
// ==================== 竞争力评分类型 ====================
|
||||
|
||||
export interface CompetitiveScores {
|
||||
market_position?: number;
|
||||
technology?: number;
|
||||
brand?: number;
|
||||
operation?: number;
|
||||
finance?: number;
|
||||
innovation?: number;
|
||||
risk?: number;
|
||||
growth?: number;
|
||||
}
|
||||
|
||||
export interface CompetitiveRanking {
|
||||
industry_rank: number;
|
||||
total_companies: number;
|
||||
}
|
||||
|
||||
export interface CompetitiveAnalysis {
|
||||
main_competitors?: string;
|
||||
competitive_advantages?: string;
|
||||
competitive_disadvantages?: string;
|
||||
}
|
||||
|
||||
export interface CompetitivePosition {
|
||||
scores?: CompetitiveScores;
|
||||
ranking?: CompetitiveRanking;
|
||||
analysis?: CompetitiveAnalysis;
|
||||
}
|
||||
|
||||
// ==================== 核心定位类型 ====================
|
||||
|
||||
/** 特性项(用于核心定位下方的两个区块:零售业务/综合金融) */
|
||||
export interface FeatureItem {
|
||||
/** 图标名称,如 'bank', 'fire' */
|
||||
icon: string;
|
||||
/** 标题,如 '零售业务' */
|
||||
title: string;
|
||||
/** 描述文字 */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 投资亮点项(结构化) */
|
||||
export interface InvestmentHighlightItem {
|
||||
/** 图标名称,如 'users', 'trending-up' */
|
||||
icon: string;
|
||||
/** 标题,如 '综合金融优势' */
|
||||
title: string;
|
||||
/** 描述文字 */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 商业模式板块 */
|
||||
export interface BusinessModelSection {
|
||||
/** 标题,如 '零售银行核心驱动' */
|
||||
title: string;
|
||||
/** 描述文字 */
|
||||
description: string;
|
||||
/** 可选的标签,如 ['AI应用深化', '大数据分析'] */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface CorePositioning {
|
||||
/** 一句话介绍 */
|
||||
one_line_intro?: string;
|
||||
/** 核心特性(2个,显示在核心定位区域下方) */
|
||||
features?: FeatureItem[];
|
||||
/** 投资亮点 - 支持结构化数组(新格式)或字符串(旧格式) */
|
||||
investment_highlights?: InvestmentHighlightItem[] | string;
|
||||
/** 结构化商业模式数组 */
|
||||
business_model_sections?: BusinessModelSection[];
|
||||
/** 原 investment_highlights 文本格式(兼容旧数据,优先级低于 investment_highlights) */
|
||||
investment_highlights_text?: string;
|
||||
/** 商业模式描述(兼容旧数据) */
|
||||
business_model_desc?: string;
|
||||
}
|
||||
|
||||
export interface Strategy {
|
||||
strategy_description?: string;
|
||||
strategic_initiatives?: string;
|
||||
}
|
||||
|
||||
export interface QualitativeAnalysis {
|
||||
core_positioning?: CorePositioning;
|
||||
strategy?: Strategy;
|
||||
}
|
||||
|
||||
// ==================== 业务结构类型 ====================
|
||||
|
||||
export interface FinancialMetrics {
|
||||
revenue?: number;
|
||||
revenue_ratio?: number;
|
||||
gross_margin?: number;
|
||||
}
|
||||
|
||||
export interface GrowthMetrics {
|
||||
revenue_growth?: number;
|
||||
}
|
||||
|
||||
export interface BusinessStructure {
|
||||
business_name: string;
|
||||
business_level: number;
|
||||
revenue?: number;
|
||||
revenue_unit?: string;
|
||||
financial_metrics?: FinancialMetrics;
|
||||
growth_metrics?: GrowthMetrics;
|
||||
report_period?: string;
|
||||
}
|
||||
|
||||
// ==================== 业务板块类型 ====================
|
||||
|
||||
export interface BusinessSegment {
|
||||
segment_name: string;
|
||||
segment_description?: string;
|
||||
competitive_position?: string;
|
||||
future_potential?: string;
|
||||
key_products?: string;
|
||||
market_share?: number;
|
||||
revenue_contribution?: number;
|
||||
}
|
||||
|
||||
// ==================== 综合数据类型 ====================
|
||||
|
||||
export interface ComprehensiveData {
|
||||
qualitative_analysis?: QualitativeAnalysis;
|
||||
competitive_position?: CompetitivePosition;
|
||||
business_structure?: BusinessStructure[];
|
||||
business_segments?: BusinessSegment[];
|
||||
}
|
||||
|
||||
// ==================== 产业链类型 ====================
|
||||
|
||||
export interface ValueChainNode {
|
||||
node_name: string;
|
||||
node_type: string;
|
||||
node_description?: string;
|
||||
node_level?: number;
|
||||
importance_score?: number;
|
||||
market_share?: number;
|
||||
dependency_degree?: number;
|
||||
}
|
||||
|
||||
export interface ValueChainFlow {
|
||||
source?: { node_name: string };
|
||||
target?: { node_name: string };
|
||||
flow_metrics?: {
|
||||
flow_ratio?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NodesByLevel {
|
||||
[key: string]: ValueChainNode[];
|
||||
}
|
||||
|
||||
export interface ValueChainStructure {
|
||||
nodes_by_level?: NodesByLevel;
|
||||
}
|
||||
|
||||
export interface AnalysisSummary {
|
||||
upstream_nodes?: number;
|
||||
company_nodes?: number;
|
||||
downstream_nodes?: number;
|
||||
total_nodes?: number;
|
||||
}
|
||||
|
||||
export interface ValueChainData {
|
||||
value_chain_flows?: ValueChainFlow[];
|
||||
value_chain_structure?: ValueChainStructure;
|
||||
analysis_summary?: AnalysisSummary;
|
||||
}
|
||||
|
||||
// ==================== 相关公司类型 ====================
|
||||
|
||||
export interface RelatedCompanyRelationship {
|
||||
role: 'source' | 'target';
|
||||
connected_node: string;
|
||||
}
|
||||
|
||||
export interface RelatedCompanyNodeInfo {
|
||||
node_level?: number;
|
||||
node_description?: string;
|
||||
}
|
||||
|
||||
export interface RelatedCompany {
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
company_name?: string;
|
||||
node_info?: RelatedCompanyNodeInfo;
|
||||
relationships?: RelatedCompanyRelationship[];
|
||||
}
|
||||
|
||||
// ==================== 关键因素类型 ====================
|
||||
|
||||
export type ImpactDirection = 'positive' | 'negative' | 'neutral' | 'mixed';
|
||||
|
||||
export interface KeyFactor {
|
||||
factor_name: string;
|
||||
factor_value: string | number;
|
||||
factor_unit?: string;
|
||||
factor_desc?: string;
|
||||
impact_direction?: ImpactDirection;
|
||||
impact_weight?: number;
|
||||
year_on_year?: number;
|
||||
report_period?: string;
|
||||
}
|
||||
|
||||
export interface FactorCategory {
|
||||
category_name: string;
|
||||
factors: KeyFactor[];
|
||||
}
|
||||
|
||||
export interface KeyFactors {
|
||||
total_factors?: number;
|
||||
categories: FactorCategory[];
|
||||
}
|
||||
|
||||
// ==================== 时间线事件类型 ====================
|
||||
|
||||
export interface ImpactMetrics {
|
||||
is_positive?: boolean;
|
||||
impact_score?: number;
|
||||
}
|
||||
|
||||
export interface RelatedInfo {
|
||||
financial_impact?: string;
|
||||
}
|
||||
|
||||
export interface TimelineEvent {
|
||||
event_title: string;
|
||||
event_date: string;
|
||||
event_type: string;
|
||||
event_desc: string;
|
||||
impact_metrics?: ImpactMetrics;
|
||||
related_info?: RelatedInfo;
|
||||
}
|
||||
|
||||
export interface TimelineStatistics {
|
||||
positive_events?: number;
|
||||
negative_events?: number;
|
||||
}
|
||||
|
||||
export interface DevelopmentTimeline {
|
||||
events: TimelineEvent[];
|
||||
statistics?: TimelineStatistics;
|
||||
}
|
||||
|
||||
// ==================== 关键因素数据类型 ====================
|
||||
|
||||
export interface KeyFactorsData {
|
||||
key_factors?: KeyFactors;
|
||||
development_timeline?: DevelopmentTimeline;
|
||||
}
|
||||
|
||||
// ==================== 行业排名类型 ====================
|
||||
|
||||
/** 行业排名指标 */
|
||||
export interface RankingMetric {
|
||||
value?: number;
|
||||
rank?: number;
|
||||
industry_avg?: number;
|
||||
}
|
||||
|
||||
/** 行业排名数据 */
|
||||
export interface IndustryRankData {
|
||||
period: string;
|
||||
report_type: string;
|
||||
rankings?: {
|
||||
industry_name: string;
|
||||
level_description: string;
|
||||
metrics?: {
|
||||
eps?: RankingMetric;
|
||||
bvps?: RankingMetric;
|
||||
roe?: RankingMetric;
|
||||
revenue_growth?: RankingMetric;
|
||||
profit_growth?: RankingMetric;
|
||||
operating_margin?: RankingMetric;
|
||||
debt_ratio?: RankingMetric;
|
||||
receivable_turnover?: RankingMetric;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
// ==================== 主组件 Props 类型 ====================
|
||||
|
||||
/** Tab 类型 */
|
||||
export type DeepAnalysisTabKey = 'strategy' | 'business' | 'valueChain' | 'development';
|
||||
|
||||
export interface DeepAnalysisTabProps {
|
||||
comprehensiveData?: ComprehensiveData;
|
||||
valueChainData?: ValueChainData;
|
||||
keyFactorsData?: KeyFactorsData;
|
||||
industryRankData?: IndustryRankData[];
|
||||
loading?: boolean;
|
||||
cardBg?: string;
|
||||
expandedSegments: Record<number, boolean>;
|
||||
onToggleSegment: (index: number) => void;
|
||||
/** 当前激活的 Tab(受控模式) */
|
||||
activeTab?: DeepAnalysisTabKey;
|
||||
/** Tab 切换回调(懒加载触发) */
|
||||
onTabChange?: (index: number, tabKey: string) => void;
|
||||
}
|
||||
|
||||
// ==================== 子组件 Props 类型 ====================
|
||||
|
||||
export interface DisclaimerBoxProps {
|
||||
// 无需 props
|
||||
}
|
||||
|
||||
export interface ScoreBarProps {
|
||||
label: string;
|
||||
score?: number;
|
||||
icon?: React.ComponentType;
|
||||
}
|
||||
|
||||
export interface BusinessTreeItemProps {
|
||||
business: BusinessStructure;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export interface KeyFactorCardProps {
|
||||
factor: KeyFactor;
|
||||
}
|
||||
|
||||
export interface ValueChainNodeCardProps {
|
||||
node: ValueChainNode;
|
||||
isCompany?: boolean;
|
||||
level?: number;
|
||||
}
|
||||
|
||||
export interface TimelineComponentProps {
|
||||
events: TimelineEvent[];
|
||||
}
|
||||
|
||||
// ==================== 图表配置类型 ====================
|
||||
|
||||
export interface RadarIndicator {
|
||||
name: string;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface RadarChartOption {
|
||||
tooltip: { trigger: string };
|
||||
radar: {
|
||||
indicator: RadarIndicator[];
|
||||
shape: string;
|
||||
splitNumber: number;
|
||||
name: { textStyle: { color: string; fontSize: number } };
|
||||
splitLine: { lineStyle: { color: string[] } };
|
||||
splitArea: { show: boolean; areaStyle: { color: string[] } };
|
||||
axisLine: { lineStyle: { color: string } };
|
||||
};
|
||||
series: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
data: Array<{
|
||||
value: number[];
|
||||
name: string;
|
||||
symbol: string;
|
||||
symbolSize: number;
|
||||
lineStyle: { width: number; color: string };
|
||||
areaStyle: { color: string };
|
||||
label: { show: boolean; formatter: (params: { value: number }) => number; color: string; fontSize: number };
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SankeyNode {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SankeyLink {
|
||||
source: string;
|
||||
target: string;
|
||||
value: number;
|
||||
lineStyle: { color: string; opacity: number };
|
||||
}
|
||||
|
||||
export interface SankeyChartOption {
|
||||
tooltip: { trigger: string; triggerOn: string };
|
||||
series: Array<{
|
||||
type: string;
|
||||
layout: string;
|
||||
emphasis: { focus: string };
|
||||
data: SankeyNode[];
|
||||
links: SankeyLink[];
|
||||
lineStyle: { color: string; curveness: number };
|
||||
label: { color: string; fontSize: number };
|
||||
}>;
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -36,6 +36,58 @@ import {
|
||||
FaChevronRight,
|
||||
} from "react-icons/fa";
|
||||
|
||||
// 黑金主题配色
|
||||
const THEME_PRESETS = {
|
||||
blackGold: {
|
||||
bg: "#0A0E17",
|
||||
cardBg: "#1A1F2E",
|
||||
cardHoverBg: "#212633",
|
||||
cardBorder: "rgba(212, 175, 55, 0.2)",
|
||||
cardHoverBorder: "#D4AF37",
|
||||
textPrimary: "#E8E9ED",
|
||||
textSecondary: "#A0A4B8",
|
||||
textMuted: "#6B7280",
|
||||
gold: "#D4AF37",
|
||||
goldLight: "#FFD54F",
|
||||
inputBg: "#151922",
|
||||
inputBorder: "#2D3748",
|
||||
buttonBg: "#D4AF37",
|
||||
buttonText: "#0A0E17",
|
||||
buttonHoverBg: "#FFD54F",
|
||||
badgeS: { bg: "rgba(255, 195, 0, 0.2)", color: "#FFD54F" },
|
||||
badgeA: { bg: "rgba(249, 115, 22, 0.2)", color: "#FB923C" },
|
||||
badgeB: { bg: "rgba(59, 130, 246, 0.2)", color: "#60A5FA" },
|
||||
badgeC: { bg: "rgba(107, 114, 128, 0.2)", color: "#9CA3AF" },
|
||||
tagBg: "rgba(212, 175, 55, 0.15)",
|
||||
tagColor: "#D4AF37",
|
||||
spinnerColor: "#D4AF37",
|
||||
},
|
||||
default: {
|
||||
bg: "white",
|
||||
cardBg: "white",
|
||||
cardHoverBg: "gray.50",
|
||||
cardBorder: "gray.200",
|
||||
cardHoverBorder: "blue.300",
|
||||
textPrimary: "gray.800",
|
||||
textSecondary: "gray.600",
|
||||
textMuted: "gray.500",
|
||||
gold: "blue.500",
|
||||
goldLight: "blue.400",
|
||||
inputBg: "white",
|
||||
inputBorder: "gray.200",
|
||||
buttonBg: "blue.500",
|
||||
buttonText: "white",
|
||||
buttonHoverBg: "blue.600",
|
||||
badgeS: { bg: "red.100", color: "red.600" },
|
||||
badgeA: { bg: "orange.100", color: "orange.600" },
|
||||
badgeB: { bg: "yellow.100", color: "yellow.600" },
|
||||
badgeC: { bg: "green.100", color: "green.600" },
|
||||
tagBg: "cyan.50",
|
||||
tagColor: "cyan.600",
|
||||
spinnerColor: "blue.500",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 新闻动态 Tab 组件
|
||||
*
|
||||
@@ -48,6 +100,7 @@ import {
|
||||
* - onSearch: 搜索提交回调 () => void
|
||||
* - onPageChange: 分页回调 (page) => void
|
||||
* - cardBg: 卡片背景色
|
||||
* - themePreset: 主题预设 'blackGold' | 'default'
|
||||
*/
|
||||
const NewsEventsTab = ({
|
||||
newsEvents = [],
|
||||
@@ -65,7 +118,11 @@ const NewsEventsTab = ({
|
||||
onSearch,
|
||||
onPageChange,
|
||||
cardBg,
|
||||
themePreset = "default",
|
||||
}) => {
|
||||
// 获取主题配色
|
||||
const theme = THEME_PRESETS[themePreset] || THEME_PRESETS.default;
|
||||
const isBlackGold = themePreset === "blackGold";
|
||||
// 事件类型图标映射
|
||||
const getEventTypeIcon = (eventType) => {
|
||||
const iconMap = {
|
||||
@@ -80,15 +137,25 @@ const NewsEventsTab = ({
|
||||
return iconMap[eventType] || FaNewspaper;
|
||||
};
|
||||
|
||||
// 重要性颜色映射
|
||||
const getImportanceColor = (importance) => {
|
||||
// 重要性颜色映射 - 根据主题返回不同配色
|
||||
const getImportanceBadgeStyle = (importance) => {
|
||||
if (isBlackGold) {
|
||||
const styles = {
|
||||
S: theme.badgeS,
|
||||
A: theme.badgeA,
|
||||
B: theme.badgeB,
|
||||
C: theme.badgeC,
|
||||
};
|
||||
return styles[importance] || { bg: "rgba(107, 114, 128, 0.2)", color: "#9CA3AF" };
|
||||
}
|
||||
// 默认主题使用 colorScheme
|
||||
const colorMap = {
|
||||
S: "red",
|
||||
A: "orange",
|
||||
B: "yellow",
|
||||
C: "green",
|
||||
};
|
||||
return colorMap[importance] || "gray";
|
||||
return { colorScheme: colorMap[importance] || "gray" };
|
||||
};
|
||||
|
||||
// 处理搜索输入
|
||||
@@ -129,19 +196,26 @@ const NewsEventsTab = ({
|
||||
// 如果开始页大于1,显示省略号
|
||||
if (startPage > 1) {
|
||||
pageButtons.push(
|
||||
<Text key="start-ellipsis" fontSize="sm" color="gray.400">
|
||||
<Text key="start-ellipsis" fontSize="sm" color={theme.textMuted}>
|
||||
...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
const isActive = i === currentPage;
|
||||
pageButtons.push(
|
||||
<Button
|
||||
key={i}
|
||||
size="sm"
|
||||
variant={i === currentPage ? "solid" : "outline"}
|
||||
colorScheme={i === currentPage ? "blue" : "gray"}
|
||||
bg={isActive ? theme.buttonBg : (isBlackGold ? theme.inputBg : undefined)}
|
||||
color={isActive ? theme.buttonText : theme.textSecondary}
|
||||
borderColor={isActive ? theme.gold : theme.cardBorder}
|
||||
borderWidth="1px"
|
||||
_hover={{
|
||||
bg: isActive ? theme.buttonHoverBg : theme.cardHoverBg,
|
||||
borderColor: theme.gold
|
||||
}}
|
||||
onClick={() => handlePageChange(i)}
|
||||
isDisabled={newsLoading}
|
||||
>
|
||||
@@ -153,7 +227,7 @@ const NewsEventsTab = ({
|
||||
// 如果结束页小于总页数,显示省略号
|
||||
if (endPage < totalPages) {
|
||||
pageButtons.push(
|
||||
<Text key="end-ellipsis" fontSize="sm" color="gray.400">
|
||||
<Text key="end-ellipsis" fontSize="sm" color={theme.textMuted}>
|
||||
...
|
||||
</Text>
|
||||
);
|
||||
@@ -164,7 +238,7 @@ const NewsEventsTab = ({
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Card bg={cardBg} shadow="md">
|
||||
<Card bg={cardBg || theme.cardBg} shadow="md" borderColor={theme.cardBorder} borderWidth={isBlackGold ? "1px" : "0"}>
|
||||
<CardBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 搜索框和统计信息 */}
|
||||
@@ -172,17 +246,25 @@ const NewsEventsTab = ({
|
||||
<HStack flex={1} minW="300px">
|
||||
<InputGroup>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<SearchIcon color="gray.400" />
|
||||
<SearchIcon color={theme.textMuted} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="搜索相关新闻..."
|
||||
value={searchQuery}
|
||||
onChange={handleInputChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
bg={theme.inputBg}
|
||||
borderColor={theme.inputBorder}
|
||||
color={theme.textPrimary}
|
||||
_placeholder={{ color: theme.textMuted }}
|
||||
_hover={{ borderColor: theme.gold }}
|
||||
_focus={{ borderColor: theme.gold, boxShadow: `0 0 0 1px ${theme.gold}` }}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
bg={theme.buttonBg}
|
||||
color={theme.buttonText}
|
||||
_hover={{ bg: theme.buttonHoverBg }}
|
||||
onClick={handleSearchSubmit}
|
||||
isLoading={newsLoading}
|
||||
minW="80px"
|
||||
@@ -193,10 +275,10 @@ const NewsEventsTab = ({
|
||||
|
||||
{newsPagination.total > 0 && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaNewspaper} color="blue.500" />
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
<Icon as={FaNewspaper} color={theme.gold} />
|
||||
<Text fontSize="sm" color={theme.textSecondary}>
|
||||
共找到{" "}
|
||||
<Text as="span" fontWeight="bold" color="blue.600">
|
||||
<Text as="span" fontWeight="bold" color={theme.gold}>
|
||||
{newsPagination.total}
|
||||
</Text>{" "}
|
||||
条新闻
|
||||
@@ -211,15 +293,15 @@ const NewsEventsTab = ({
|
||||
{newsLoading ? (
|
||||
<Center h="400px">
|
||||
<VStack spacing={3}>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color="gray.600">正在加载新闻...</Text>
|
||||
<Spinner size="xl" color={theme.spinnerColor} thickness="4px" />
|
||||
<Text color={theme.textSecondary}>正在加载新闻...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : newsEvents.length > 0 ? (
|
||||
<>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{newsEvents.map((event, idx) => {
|
||||
const importanceColor = getImportanceColor(
|
||||
const importanceBadgeStyle = getImportanceBadgeStyle(
|
||||
event.importance
|
||||
);
|
||||
const eventTypeIcon = getEventTypeIcon(event.event_type);
|
||||
@@ -228,10 +310,12 @@ const NewsEventsTab = ({
|
||||
<Card
|
||||
key={event.id || idx}
|
||||
variant="outline"
|
||||
bg={theme.cardBg}
|
||||
borderColor={theme.cardBorder}
|
||||
_hover={{
|
||||
bg: "gray.50",
|
||||
bg: theme.cardHoverBg,
|
||||
shadow: "md",
|
||||
borderColor: "blue.300",
|
||||
borderColor: theme.cardHoverBorder,
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
@@ -243,13 +327,14 @@ const NewsEventsTab = ({
|
||||
<HStack>
|
||||
<Icon
|
||||
as={eventTypeIcon}
|
||||
color="blue.500"
|
||||
color={theme.gold}
|
||||
boxSize={5}
|
||||
/>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
fontSize="lg"
|
||||
lineHeight="1.3"
|
||||
color={theme.textPrimary}
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
@@ -259,22 +344,29 @@ const NewsEventsTab = ({
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{event.importance && (
|
||||
<Badge
|
||||
colorScheme={importanceColor}
|
||||
variant="solid"
|
||||
{...(isBlackGold ? {} : { colorScheme: importanceBadgeStyle.colorScheme, variant: "solid" })}
|
||||
bg={isBlackGold ? importanceBadgeStyle.bg : undefined}
|
||||
color={isBlackGold ? importanceBadgeStyle.color : undefined}
|
||||
px={2}
|
||||
>
|
||||
{event.importance}级
|
||||
</Badge>
|
||||
)}
|
||||
{event.event_type && (
|
||||
<Badge colorScheme="blue" variant="outline">
|
||||
<Badge
|
||||
{...(isBlackGold ? {} : { colorScheme: "blue", variant: "outline" })}
|
||||
bg={isBlackGold ? "rgba(59, 130, 246, 0.2)" : undefined}
|
||||
color={isBlackGold ? "#60A5FA" : undefined}
|
||||
borderColor={isBlackGold ? "rgba(59, 130, 246, 0.3)" : undefined}
|
||||
>
|
||||
{event.event_type}
|
||||
</Badge>
|
||||
)}
|
||||
{event.invest_score && (
|
||||
<Badge
|
||||
colorScheme="purple"
|
||||
variant="subtle"
|
||||
{...(isBlackGold ? {} : { colorScheme: "purple", variant: "subtle" })}
|
||||
bg={isBlackGold ? "rgba(139, 92, 246, 0.2)" : undefined}
|
||||
color={isBlackGold ? "#A78BFA" : undefined}
|
||||
>
|
||||
投资分: {event.invest_score}
|
||||
</Badge>
|
||||
@@ -287,8 +379,9 @@ const NewsEventsTab = ({
|
||||
<Tag
|
||||
key={kidx}
|
||||
size="sm"
|
||||
colorScheme="cyan"
|
||||
variant="subtle"
|
||||
{...(isBlackGold ? {} : { colorScheme: "cyan", variant: "subtle" })}
|
||||
bg={isBlackGold ? theme.tagBg : undefined}
|
||||
color={isBlackGold ? theme.tagColor : undefined}
|
||||
>
|
||||
{typeof keyword === "string"
|
||||
? keyword
|
||||
@@ -304,7 +397,7 @@ const NewsEventsTab = ({
|
||||
|
||||
{/* 右侧信息栏 */}
|
||||
<VStack align="end" spacing={1} minW="100px">
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
<Text fontSize="xs" color={theme.textMuted}>
|
||||
{event.created_at
|
||||
? new Date(
|
||||
event.created_at
|
||||
@@ -321,9 +414,9 @@ const NewsEventsTab = ({
|
||||
<Icon
|
||||
as={FaEye}
|
||||
boxSize={3}
|
||||
color="gray.400"
|
||||
color={theme.textMuted}
|
||||
/>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
<Text fontSize="xs" color={theme.textMuted}>
|
||||
{event.view_count}
|
||||
</Text>
|
||||
</HStack>
|
||||
@@ -333,16 +426,16 @@ const NewsEventsTab = ({
|
||||
<Icon
|
||||
as={FaFire}
|
||||
boxSize={3}
|
||||
color="orange.400"
|
||||
color={theme.goldLight}
|
||||
/>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
<Text fontSize="xs" color={theme.textMuted}>
|
||||
{event.hot_score.toFixed(1)}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
{event.creator && (
|
||||
<Text fontSize="xs" color="gray.400">
|
||||
<Text fontSize="xs" color={theme.textMuted}>
|
||||
@{event.creator.username}
|
||||
</Text>
|
||||
)}
|
||||
@@ -353,7 +446,7 @@ const NewsEventsTab = ({
|
||||
{event.description && (
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color="gray.700"
|
||||
color={theme.textSecondary}
|
||||
lineHeight="1.6"
|
||||
>
|
||||
{event.description}
|
||||
@@ -367,18 +460,18 @@ const NewsEventsTab = ({
|
||||
<Box
|
||||
pt={2}
|
||||
borderTop="1px"
|
||||
borderColor="gray.200"
|
||||
borderColor={theme.cardBorder}
|
||||
>
|
||||
<HStack spacing={6} flexWrap="wrap">
|
||||
<HStack spacing={1}>
|
||||
<Icon
|
||||
as={FaChartLine}
|
||||
boxSize={3}
|
||||
color="gray.500"
|
||||
color={theme.textMuted}
|
||||
/>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="gray.500"
|
||||
color={theme.textMuted}
|
||||
fontWeight="medium"
|
||||
>
|
||||
相关涨跌:
|
||||
@@ -387,7 +480,7 @@ const NewsEventsTab = ({
|
||||
{event.related_avg_chg !== null &&
|
||||
event.related_avg_chg !== undefined && (
|
||||
<HStack spacing={1}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
<Text fontSize="xs" color={theme.textMuted}>
|
||||
平均
|
||||
</Text>
|
||||
<Text
|
||||
@@ -395,8 +488,8 @@ const NewsEventsTab = ({
|
||||
fontWeight="bold"
|
||||
color={
|
||||
event.related_avg_chg > 0
|
||||
? "red.500"
|
||||
: "green.500"
|
||||
? "#EF4444"
|
||||
: "#10B981"
|
||||
}
|
||||
>
|
||||
{event.related_avg_chg > 0 ? "+" : ""}
|
||||
@@ -407,7 +500,7 @@ const NewsEventsTab = ({
|
||||
{event.related_max_chg !== null &&
|
||||
event.related_max_chg !== undefined && (
|
||||
<HStack spacing={1}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
<Text fontSize="xs" color={theme.textMuted}>
|
||||
最大
|
||||
</Text>
|
||||
<Text
|
||||
@@ -415,8 +508,8 @@ const NewsEventsTab = ({
|
||||
fontWeight="bold"
|
||||
color={
|
||||
event.related_max_chg > 0
|
||||
? "red.500"
|
||||
: "green.500"
|
||||
? "#EF4444"
|
||||
: "#10B981"
|
||||
}
|
||||
>
|
||||
{event.related_max_chg > 0 ? "+" : ""}
|
||||
@@ -427,7 +520,7 @@ const NewsEventsTab = ({
|
||||
{event.related_week_chg !== null &&
|
||||
event.related_week_chg !== undefined && (
|
||||
<HStack spacing={1}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
<Text fontSize="xs" color={theme.textMuted}>
|
||||
周
|
||||
</Text>
|
||||
<Text
|
||||
@@ -435,8 +528,8 @@ const NewsEventsTab = ({
|
||||
fontWeight="bold"
|
||||
color={
|
||||
event.related_week_chg > 0
|
||||
? "red.500"
|
||||
: "green.500"
|
||||
? "#EF4444"
|
||||
: "#10B981"
|
||||
}
|
||||
>
|
||||
{event.related_week_chg > 0
|
||||
@@ -465,7 +558,7 @@ const NewsEventsTab = ({
|
||||
flexWrap="wrap"
|
||||
>
|
||||
{/* 分页信息 */}
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
<Text fontSize="sm" color={theme.textSecondary}>
|
||||
第 {newsPagination.page} / {newsPagination.pages} 页
|
||||
</Text>
|
||||
|
||||
@@ -473,6 +566,11 @@ const NewsEventsTab = ({
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
bg={isBlackGold ? theme.inputBg : undefined}
|
||||
color={theme.textSecondary}
|
||||
borderColor={theme.cardBorder}
|
||||
borderWidth="1px"
|
||||
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
|
||||
onClick={() => handlePageChange(1)}
|
||||
isDisabled={!newsPagination.has_prev || newsLoading}
|
||||
leftIcon={<Icon as={FaChevronLeft} />}
|
||||
@@ -481,6 +579,11 @@ const NewsEventsTab = ({
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
bg={isBlackGold ? theme.inputBg : undefined}
|
||||
color={theme.textSecondary}
|
||||
borderColor={theme.cardBorder}
|
||||
borderWidth="1px"
|
||||
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
|
||||
onClick={() =>
|
||||
handlePageChange(newsPagination.page - 1)
|
||||
}
|
||||
@@ -494,6 +597,11 @@ const NewsEventsTab = ({
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
bg={isBlackGold ? theme.inputBg : undefined}
|
||||
color={theme.textSecondary}
|
||||
borderColor={theme.cardBorder}
|
||||
borderWidth="1px"
|
||||
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
|
||||
onClick={() =>
|
||||
handlePageChange(newsPagination.page + 1)
|
||||
}
|
||||
@@ -503,6 +611,11 @@ const NewsEventsTab = ({
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
bg={isBlackGold ? theme.inputBg : undefined}
|
||||
color={theme.textSecondary}
|
||||
borderColor={theme.cardBorder}
|
||||
borderWidth="1px"
|
||||
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
|
||||
onClick={() => handlePageChange(newsPagination.pages)}
|
||||
isDisabled={!newsPagination.has_next || newsLoading}
|
||||
rightIcon={<Icon as={FaChevronRight} />}
|
||||
@@ -517,11 +630,11 @@ const NewsEventsTab = ({
|
||||
) : (
|
||||
<Center h="400px">
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FaNewspaper} boxSize={16} color="gray.300" />
|
||||
<Text color="gray.500" fontSize="lg" fontWeight="medium">
|
||||
<Icon as={FaNewspaper} boxSize={16} color={isBlackGold ? theme.gold : "gray.300"} opacity={0.5} />
|
||||
<Text color={theme.textSecondary} fontSize="lg" fontWeight="medium">
|
||||
暂无相关新闻
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.400">
|
||||
<Text fontSize="sm" color={theme.textMuted}>
|
||||
{searchQuery ? "尝试修改搜索关键词" : "该公司暂无新闻动态"}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
@@ -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";
|
||||
@@ -1,13 +1,11 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts
|
||||
// 公告数据 Hook - 用于公司公告 Tab
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import axios from "@utils/axiosConfig";
|
||||
import type { Announcement } from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
@@ -28,34 +26,38 @@ export const useAnnouncementsData = (stockCode?: string): UseAnnouncementsDataRe
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/stock/${stockCode}/announcements?limit=20`
|
||||
);
|
||||
const result = (await response.json()) as ApiResponse<Announcement[]>;
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (result.success) {
|
||||
setAnnouncements(result.data);
|
||||
} else {
|
||||
setError("加载公告数据失败");
|
||||
try {
|
||||
const { data: result } = await axios.get<ApiResponse<Announcement[]>>(
|
||||
`/api/stock/${stockCode}/announcements?limit=20`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setAnnouncements(result.data);
|
||||
} else {
|
||||
setError("加载公告数据失败");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === "CanceledError") return;
|
||||
logger.error("useAnnouncementsData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("useAnnouncementsData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
return () => controller.abort();
|
||||
}, [stockCode]);
|
||||
|
||||
return { announcements, loading, error };
|
||||
};
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useBasicInfo.ts
|
||||
// 公司基本信息 Hook - 用于 CompanyHeaderCard
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import axios from "@utils/axiosConfig";
|
||||
import type { BasicInfo } from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
@@ -28,32 +26,38 @@ export const useBasicInfo = (stockCode?: string): UseBasicInfoResult => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/stock/${stockCode}/basic-info`);
|
||||
const result = (await response.json()) as ApiResponse<BasicInfo>;
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (result.success) {
|
||||
setBasicInfo(result.data);
|
||||
} else {
|
||||
setError("加载基本信息失败");
|
||||
try {
|
||||
const { data: result } = await axios.get<ApiResponse<BasicInfo>>(
|
||||
`/api/stock/${stockCode}/basic-info`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setBasicInfo(result.data);
|
||||
} else {
|
||||
setError("加载基本信息失败");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === "CanceledError") return;
|
||||
logger.error("useBasicInfo", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("useBasicInfo", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
return () => controller.abort();
|
||||
}, [stockCode]);
|
||||
|
||||
return { basicInfo, loading, error };
|
||||
};
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts
|
||||
// 分支机构数据 Hook - 用于分支机构 Tab
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import axios from "@utils/axiosConfig";
|
||||
import type { Branch } from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
@@ -28,32 +26,38 @@ export const useBranchesData = (stockCode?: string): UseBranchesDataResult => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/stock/${stockCode}/branches`);
|
||||
const result = (await response.json()) as ApiResponse<Branch[]>;
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (result.success) {
|
||||
setBranches(result.data);
|
||||
} else {
|
||||
setError("加载分支机构数据失败");
|
||||
try {
|
||||
const { data: result } = await axios.get<ApiResponse<Branch[]>>(
|
||||
`/api/stock/${stockCode}/branches`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setBranches(result.data);
|
||||
} else {
|
||||
setError("加载分支机构数据失败");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === "CanceledError") return;
|
||||
logger.error("useBranchesData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("useBranchesData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
return () => controller.abort();
|
||||
}, [stockCode]);
|
||||
|
||||
return { branches, loading, error };
|
||||
};
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
};
|
||||
@@ -1,13 +1,11 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts
|
||||
// 披露日程数据 Hook - 用于工商信息 Tab
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import axios from "@utils/axiosConfig";
|
||||
import type { DisclosureSchedule } from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
@@ -28,34 +26,38 @@ export const useDisclosureData = (stockCode?: string): UseDisclosureDataResult =
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/stock/${stockCode}/disclosure-schedule`
|
||||
);
|
||||
const result = (await response.json()) as ApiResponse<DisclosureSchedule[]>;
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (result.success) {
|
||||
setDisclosureSchedule(result.data);
|
||||
} else {
|
||||
setError("加载披露日程数据失败");
|
||||
try {
|
||||
const { data: result } = await axios.get<ApiResponse<DisclosureSchedule[]>>(
|
||||
`/api/stock/${stockCode}/disclosure-schedule`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setDisclosureSchedule(result.data);
|
||||
} else {
|
||||
setError("加载披露日程数据失败");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === "CanceledError") return;
|
||||
logger.error("useDisclosureData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("useDisclosureData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
return () => controller.abort();
|
||||
}, [stockCode]);
|
||||
|
||||
return { disclosureSchedule, loading, error };
|
||||
};
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useManagementData.ts
|
||||
// 管理团队数据 Hook - 用于管理团队 Tab
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import axios from "@utils/axiosConfig";
|
||||
import type { Management } from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
@@ -28,34 +26,38 @@ export const useManagementData = (stockCode?: string): UseManagementDataResult =
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/stock/${stockCode}/management?active_only=true`
|
||||
);
|
||||
const result = (await response.json()) as ApiResponse<Management[]>;
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (result.success) {
|
||||
setManagement(result.data);
|
||||
} else {
|
||||
setError("加载管理团队数据失败");
|
||||
try {
|
||||
const { data: result } = await axios.get<ApiResponse<Management[]>>(
|
||||
`/api/stock/${stockCode}/management?active_only=true`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setManagement(result.data);
|
||||
} else {
|
||||
setError("加载管理团队数据失败");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === "CanceledError") return;
|
||||
logger.error("useManagementData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("useManagementData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
return () => controller.abort();
|
||||
}, [stockCode]);
|
||||
|
||||
return { management, loading, error };
|
||||
};
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts
|
||||
// 股权结构数据 Hook - 用于股权结构 Tab
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import axios from "@utils/axiosConfig";
|
||||
import type { ActualControl, Concentration, Shareholder } from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
@@ -34,43 +32,44 @@ export const useShareholderData = (stockCode?: string): UseShareholderDataResult
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
|
||||
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[]>>,
|
||||
]);
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
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]);
|
||||
try {
|
||||
const [
|
||||
{ data: actualRes },
|
||||
{ data: concentrationRes },
|
||||
{ data: shareholdersRes },
|
||||
{ data: circulationRes },
|
||||
] = await Promise.all([
|
||||
axios.get<ApiResponse<ActualControl[]>>(`/api/stock/${stockCode}/actual-control`, { signal: controller.signal }),
|
||||
axios.get<ApiResponse<Concentration[]>>(`/api/stock/${stockCode}/concentration`, { signal: controller.signal }),
|
||||
axios.get<ApiResponse<Shareholder[]>>(`/api/stock/${stockCode}/top-shareholders?limit=10`, { signal: controller.signal }),
|
||||
axios.get<ApiResponse<Shareholder[]>>(`/api/stock/${stockCode}/top-circulation-shareholders?limit=10`, { signal: controller.signal }),
|
||||
]);
|
||||
|
||||
if (actualRes.success) setActualControl(actualRes.data);
|
||||
if (concentrationRes.success) setConcentration(concentrationRes.data);
|
||||
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
|
||||
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
|
||||
} catch (err: any) {
|
||||
if (err.name === "CanceledError") return;
|
||||
logger.error("useShareholderData", "loadData", err, { stockCode });
|
||||
setError("加载股权结构数据失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
return () => controller.abort();
|
||||
}, [stockCode]);
|
||||
|
||||
return {
|
||||
actualControl,
|
||||
|
||||
@@ -1,63 +1,29 @@
|
||||
// src/views/Company/components/CompanyOverview/index.tsx
|
||||
// 公司概览 - 主组件(组合层)
|
||||
// 懒加载优化:只加载头部卡片数据,BasicInfoTab 内部懒加载各 Tab 数据
|
||||
// 公司档案 - 主组件(组合层)
|
||||
|
||||
import React from "react";
|
||||
import { VStack, Spinner, Center, Text } from "@chakra-ui/react";
|
||||
import { VStack } from "@chakra-ui/react";
|
||||
|
||||
import { useBasicInfo } from "./hooks/useBasicInfo";
|
||||
import CompanyHeaderCard from "./CompanyHeaderCard";
|
||||
import type { CompanyOverviewProps } from "./types";
|
||||
|
||||
// 子组件(暂保持 JS)
|
||||
// 子组件
|
||||
import BasicInfoTab from "./BasicInfoTab";
|
||||
|
||||
/**
|
||||
* 公司概览组件
|
||||
* 公司档案组件
|
||||
*
|
||||
* 功能:
|
||||
* - 显示公司头部信息卡片(useBasicInfo)
|
||||
* - 显示基本信息 Tab(内部懒加载各子 Tab 数据)
|
||||
*
|
||||
* 懒加载策略:
|
||||
* - 主组件只加载 basicInfo(1 个 API)
|
||||
* - BasicInfoTab 内部根据 Tab 切换懒加载其他数据
|
||||
* - BasicInfoTab 内部根据 Tab 切换懒加载数据
|
||||
* - 各 Panel 组件自行获取所需数据(如 BusinessInfoPanel 调用 useBasicInfo)
|
||||
*/
|
||||
const CompanyOverview: React.FC<CompanyOverviewProps> = ({ stockCode }) => {
|
||||
const { basicInfo, loading, error } = useBasicInfo(stockCode);
|
||||
|
||||
// 加载状态
|
||||
if (loading && !basicInfo) {
|
||||
return (
|
||||
<Center h="300px">
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text>正在加载公司概览数据...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (error && !basicInfo) {
|
||||
return (
|
||||
<Center h="300px">
|
||||
<Text color="red.500">{error}</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 公司头部信息卡片 */}
|
||||
{basicInfo && <CompanyHeaderCard basicInfo={basicInfo} />}
|
||||
|
||||
{/* 基本信息内容 - 传入 stockCode,内部懒加载各 Tab 数据 */}
|
||||
<BasicInfoTab
|
||||
stockCode={stockCode}
|
||||
basicInfo={basicInfo}
|
||||
cardBg="white"
|
||||
/>
|
||||
<BasicInfoTab stockCode={stockCode} />
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,15 +22,28 @@ export interface BasicInfo {
|
||||
email?: string;
|
||||
tel?: string;
|
||||
company_intro?: string;
|
||||
// 工商信息字段
|
||||
credit_code?: string;
|
||||
company_size?: string;
|
||||
reg_address?: string;
|
||||
office_address?: string;
|
||||
accounting_firm?: string;
|
||||
law_firm?: string;
|
||||
main_business?: string;
|
||||
business_scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际控制人
|
||||
*/
|
||||
export interface ActualControl {
|
||||
actual_controller_name?: string;
|
||||
controller_name?: string;
|
||||
control_type?: string;
|
||||
controller_type?: string;
|
||||
holding_ratio?: number;
|
||||
holding_shares?: number;
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +53,10 @@ export interface Concentration {
|
||||
top1_ratio?: number;
|
||||
top5_ratio?: number;
|
||||
top10_ratio?: number;
|
||||
stat_item?: string;
|
||||
holding_ratio?: number;
|
||||
ratio_change?: number;
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,8 +65,14 @@ export interface Concentration {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,8 +80,15 @@ export interface Management {
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,23 +116,6 @@ export interface DisclosureSchedule {
|
||||
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
|
||||
*/
|
||||
@@ -110,9 +123,3 @@ export interface CompanyOverviewProps {
|
||||
stockCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CompanyHeaderCard 组件 Props
|
||||
*/
|
||||
export interface CompanyHeaderCardProps {
|
||||
basicInfo: BasicInfo;
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
// src/views/Company/components/CompanyTabs/TabNavigation.js
|
||||
// Tab 导航组件 - 动态渲染 Tab 按钮(黑金主题)
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
TabList,
|
||||
Tab,
|
||||
HStack,
|
||||
Icon,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { COMPANY_TABS } from '../../constants';
|
||||
|
||||
// 黑金主题颜色配置
|
||||
const THEME_COLORS = {
|
||||
bg: '#1A202C', // 背景纯黑
|
||||
selectedBg: '#C9A961', // 选中项金色背景
|
||||
selectedText: '#FFFFFF', // 选中项白色文字
|
||||
unselectedText: '#D4AF37', // 未选中项金色
|
||||
};
|
||||
|
||||
/**
|
||||
* Tab 导航组件(黑金主题)
|
||||
*/
|
||||
const TabNavigation = () => {
|
||||
return (
|
||||
<TabList py={4} bg={THEME_COLORS.bg} borderTopLeftRadius="16px" borderTopRightRadius="16px">
|
||||
{COMPANY_TABS.map((tab, index) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
color={THEME_COLORS.unselectedText}
|
||||
borderRadius="full"
|
||||
px={4}
|
||||
py={2}
|
||||
_selected={{
|
||||
bg: THEME_COLORS.selectedBg,
|
||||
color: THEME_COLORS.selectedText,
|
||||
}}
|
||||
_hover={{
|
||||
color: THEME_COLORS.selectedText,
|
||||
}}
|
||||
mr={index < COMPANY_TABS.length - 1 ? 2 : 0}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={tab.icon} boxSize="18px" />
|
||||
<Text fontSize="15px">{tab.name}</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabNavigation;
|
||||
@@ -1,17 +1,8 @@
|
||||
// src/views/Company/components/CompanyTabs/index.js
|
||||
// Tab 容器组件 - 管理 Tab 切换和内容渲染
|
||||
// Tab 容器组件 - 使用通用 TabContainer 组件
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
Tabs,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import TabNavigation from './TabNavigation';
|
||||
import React from 'react';
|
||||
import TabContainer from '@components/TabContainer';
|
||||
import { COMPANY_TABS, getTabNameByIndex } from '../../constants';
|
||||
|
||||
// 子组件导入(Tab 内容组件)
|
||||
@@ -24,7 +15,6 @@ import DynamicTracking from '../DynamicTracking';
|
||||
|
||||
/**
|
||||
* Tab 组件映射
|
||||
* key 与 COMPANY_TABS 中的 key 对应
|
||||
*/
|
||||
const TAB_COMPONENTS = {
|
||||
overview: CompanyOverview,
|
||||
@@ -36,11 +26,25 @@ const TAB_COMPONENTS = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Tab 容器组件
|
||||
* 构建 TabContainer 所需的 tabs 配置
|
||||
* 合并 COMPANY_TABS 和对应的组件
|
||||
*/
|
||||
const buildTabsConfig = () => {
|
||||
return COMPANY_TABS.map((tab) => ({
|
||||
...tab,
|
||||
component: TAB_COMPONENTS[tab.key],
|
||||
}));
|
||||
};
|
||||
|
||||
// 预构建 tabs 配置(避免每次渲染重新计算)
|
||||
const TABS_CONFIG = buildTabsConfig();
|
||||
|
||||
/**
|
||||
* 公司详情 Tab 容器组件
|
||||
*
|
||||
* 功能:
|
||||
* - 管理 Tab 切换状态
|
||||
* - 动态渲染 Tab 导航和内容
|
||||
* - 使用通用 TabContainer 组件
|
||||
* - 保持黑金主题风格
|
||||
* - 触发 Tab 变更追踪
|
||||
*
|
||||
* @param {Object} props
|
||||
@@ -48,51 +52,23 @@ const TAB_COMPONENTS = {
|
||||
* @param {Function} props.onTabChange - Tab 变更回调 (index, tabName, prevIndex) => void
|
||||
*/
|
||||
const CompanyTabs = ({ stockCode, onTabChange }) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
/**
|
||||
* 处理 Tab 切换
|
||||
* 转换 tabKey 为 tabName 以保持原有回调格式
|
||||
*/
|
||||
const handleTabChange = (index) => {
|
||||
const handleTabChange = (index, tabKey, prevIndex) => {
|
||||
const tabName = getTabNameByIndex(index);
|
||||
|
||||
// 触发追踪回调
|
||||
onTabChange?.(index, tabName, currentIndex);
|
||||
|
||||
// 更新状态
|
||||
setCurrentIndex(index);
|
||||
onTabChange?.(index, tabName, prevIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card shadow="lg" bg='#1A202C'>
|
||||
<CardBody p={0}>
|
||||
<Tabs
|
||||
isLazy
|
||||
variant="soft-rounded"
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
index={currentIndex}
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
{/* Tab 导航(黑金主题) */}
|
||||
<TabNavigation />
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Tab 内容面板 */}
|
||||
<TabPanels>
|
||||
{COMPANY_TABS.map((tab) => {
|
||||
const Component = TAB_COMPONENTS[tab.key];
|
||||
return (
|
||||
<TabPanel key={tab.key} px={0}>
|
||||
<Component stockCode={stockCode} />
|
||||
</TabPanel>
|
||||
);
|
||||
})}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<TabContainer
|
||||
tabs={TABS_CONFIG}
|
||||
componentProps={{ stockCode }}
|
||||
onTabChange={handleTabChange}
|
||||
themePreset="blackGold"
|
||||
borderRadius="16px"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,36 +1,65 @@
|
||||
// src/views/Company/components/DeepAnalysis/index.js
|
||||
// 深度分析 - 独立一级 Tab 组件
|
||||
// 深度分析 - 独立一级 Tab 组件(懒加载版本)
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import axios from "@utils/axiosConfig";
|
||||
|
||||
// 复用原有的展示组件
|
||||
import DeepAnalysisTab from "../CompanyOverview/DeepAnalysisTab";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
/**
|
||||
* Tab 与 API 接口映射
|
||||
* - strategy 和 business 共用 comprehensive 接口
|
||||
*/
|
||||
const TAB_API_MAP = {
|
||||
strategy: "comprehensive",
|
||||
business: "comprehensive",
|
||||
valueChain: "valueChain",
|
||||
development: "keyFactors",
|
||||
};
|
||||
|
||||
/**
|
||||
* 深度分析组件
|
||||
*
|
||||
* 功能:
|
||||
* - 加载深度分析数据(3个接口)
|
||||
* - 按 Tab 懒加载数据(默认只加载战略分析)
|
||||
* - 已加载的数据缓存,切换 Tab 不重复请求
|
||||
* - 管理展开状态
|
||||
* - 渲染 DeepAnalysisTab 展示组件
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.stockCode - 股票代码
|
||||
*/
|
||||
const DeepAnalysis = ({ stockCode }) => {
|
||||
// 当前 Tab
|
||||
const [activeTab, setActiveTab] = useState("strategy");
|
||||
|
||||
// 数据状态
|
||||
const [comprehensiveData, setComprehensiveData] = useState(null);
|
||||
const [valueChainData, setValueChainData] = useState(null);
|
||||
const [keyFactorsData, setKeyFactorsData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [industryRankData, setIndustryRankData] = useState(null);
|
||||
|
||||
// 各接口独立的 loading 状态
|
||||
const [comprehensiveLoading, setComprehensiveLoading] = useState(false);
|
||||
const [valueChainLoading, setValueChainLoading] = useState(false);
|
||||
const [keyFactorsLoading, setKeyFactorsLoading] = useState(false);
|
||||
const [industryRankLoading, setIndustryRankLoading] = useState(false);
|
||||
|
||||
// 已加载的接口记录(用于缓存判断)
|
||||
const loadedApisRef = useRef({
|
||||
comprehensive: false,
|
||||
valueChain: false,
|
||||
keyFactors: false,
|
||||
industryRank: false,
|
||||
});
|
||||
|
||||
// 业务板块展开状态
|
||||
const [expandedSegments, setExpandedSegments] = useState({});
|
||||
|
||||
// 用于追踪当前 stockCode,避免竞态条件
|
||||
const currentStockCodeRef = useRef(stockCode);
|
||||
|
||||
// 切换业务板块展开状态
|
||||
const toggleSegmentExpansion = (segmentIndex) => {
|
||||
setExpandedSegments((prev) => ({
|
||||
@@ -39,60 +68,160 @@ const DeepAnalysis = ({ stockCode }) => {
|
||||
}));
|
||||
};
|
||||
|
||||
// 加载深度分析数据(3个接口)
|
||||
const loadDeepAnalysisData = async () => {
|
||||
if (!stockCode) return;
|
||||
/**
|
||||
* 加载指定接口的数据
|
||||
*/
|
||||
const loadApiData = useCallback(
|
||||
async (apiKey) => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
// 已加载则跳过
|
||||
if (loadedApisRef.current[apiKey]) return;
|
||||
|
||||
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()),
|
||||
];
|
||||
try {
|
||||
switch (apiKey) {
|
||||
case "comprehensive":
|
||||
setComprehensiveLoading(true);
|
||||
const { data: comprehensiveRes } = await axios.get(
|
||||
`/api/company/comprehensive-analysis/${stockCode}`
|
||||
);
|
||||
// 检查 stockCode 是否已变更(防止竞态)
|
||||
if (currentStockCodeRef.current === stockCode) {
|
||||
if (comprehensiveRes.success)
|
||||
setComprehensiveData(comprehensiveRes.data);
|
||||
loadedApisRef.current.comprehensive = true;
|
||||
}
|
||||
break;
|
||||
|
||||
const [comprehensiveRes, valueChainRes, keyFactorsRes] =
|
||||
await Promise.all(requests);
|
||||
case "valueChain":
|
||||
setValueChainLoading(true);
|
||||
const { data: valueChainRes } = await axios.get(
|
||||
`/api/company/value-chain-analysis/${stockCode}`
|
||||
);
|
||||
if (currentStockCodeRef.current === stockCode) {
|
||||
if (valueChainRes.success) setValueChainData(valueChainRes.data);
|
||||
loadedApisRef.current.valueChain = true;
|
||||
}
|
||||
break;
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
case "keyFactors":
|
||||
setKeyFactorsLoading(true);
|
||||
const { data: keyFactorsRes } = await axios.get(
|
||||
`/api/company/key-factors-timeline/${stockCode}`
|
||||
);
|
||||
if (currentStockCodeRef.current === stockCode) {
|
||||
if (keyFactorsRes.success) setKeyFactorsData(keyFactorsRes.data);
|
||||
loadedApisRef.current.keyFactors = true;
|
||||
}
|
||||
break;
|
||||
|
||||
// stockCode 变更时重新加载数据
|
||||
case "industryRank":
|
||||
setIndustryRankLoading(true);
|
||||
const { data: industryRankRes } = await axios.get(
|
||||
`/api/financial/industry-rank/${stockCode}`
|
||||
);
|
||||
if (currentStockCodeRef.current === stockCode) {
|
||||
if (industryRankRes.success) setIndustryRankData(industryRankRes.data);
|
||||
loadedApisRef.current.industryRank = true;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("DeepAnalysis", `loadApiData:${apiKey}`, err, {
|
||||
stockCode,
|
||||
});
|
||||
} finally {
|
||||
// 清除 loading 状态
|
||||
if (apiKey === "comprehensive") setComprehensiveLoading(false);
|
||||
if (apiKey === "valueChain") setValueChainLoading(false);
|
||||
if (apiKey === "keyFactors") setKeyFactorsLoading(false);
|
||||
if (apiKey === "industryRank") setIndustryRankLoading(false);
|
||||
}
|
||||
},
|
||||
[stockCode]
|
||||
);
|
||||
|
||||
/**
|
||||
* 根据 Tab 加载对应的数据
|
||||
*/
|
||||
const loadTabData = useCallback(
|
||||
(tabKey) => {
|
||||
const apiKey = TAB_API_MAP[tabKey];
|
||||
if (apiKey) {
|
||||
loadApiData(apiKey);
|
||||
}
|
||||
},
|
||||
[loadApiData]
|
||||
);
|
||||
|
||||
/**
|
||||
* Tab 切换回调
|
||||
*/
|
||||
const handleTabChange = useCallback(
|
||||
(index, tabKey) => {
|
||||
setActiveTab(tabKey);
|
||||
loadTabData(tabKey);
|
||||
},
|
||||
[loadTabData]
|
||||
);
|
||||
|
||||
// stockCode 变更时重置并加载默认 Tab 数据
|
||||
useEffect(() => {
|
||||
if (stockCode) {
|
||||
// 重置数据
|
||||
// 更新 ref
|
||||
currentStockCodeRef.current = stockCode;
|
||||
|
||||
// 重置所有数据和状态
|
||||
setComprehensiveData(null);
|
||||
setValueChainData(null);
|
||||
setKeyFactorsData(null);
|
||||
setIndustryRankData(null);
|
||||
setExpandedSegments({});
|
||||
// 加载新数据
|
||||
loadDeepAnalysisData();
|
||||
loadedApisRef.current = {
|
||||
comprehensive: false,
|
||||
valueChain: false,
|
||||
keyFactors: false,
|
||||
industryRank: false,
|
||||
};
|
||||
|
||||
// 重置为默认 Tab 并加载数据
|
||||
setActiveTab("strategy");
|
||||
// 加载默认 Tab 的数据(战略分析需要 comprehensive 和 industryRank)
|
||||
loadApiData("comprehensive");
|
||||
loadApiData("industryRank");
|
||||
}
|
||||
}, [stockCode]);
|
||||
}, [stockCode, loadApiData]);
|
||||
|
||||
// 计算当前 Tab 的 loading 状态
|
||||
const getCurrentLoading = () => {
|
||||
const apiKey = TAB_API_MAP[activeTab];
|
||||
switch (apiKey) {
|
||||
case "comprehensive":
|
||||
return comprehensiveLoading;
|
||||
case "valueChain":
|
||||
return valueChainLoading;
|
||||
case "keyFactors":
|
||||
return keyFactorsLoading;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DeepAnalysisTab
|
||||
comprehensiveData={comprehensiveData}
|
||||
valueChainData={valueChainData}
|
||||
keyFactorsData={keyFactorsData}
|
||||
loading={loading}
|
||||
industryRankData={industryRankData}
|
||||
loading={getCurrentLoading()}
|
||||
cardBg="white"
|
||||
expandedSegments={expandedSegments}
|
||||
onToggleSegment={toggleSegmentExpansion}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// src/views/Company/components/DynamicTracking/components/ForecastPanel.js
|
||||
// 业绩预告面板 - 黑金主题
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
VStack,
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import { Tag } from 'antd';
|
||||
import { logger } from '@utils/logger';
|
||||
import axios from '@utils/axiosConfig';
|
||||
|
||||
// 黑金主题
|
||||
const THEME = {
|
||||
gold: '#D4AF37',
|
||||
goldLight: 'rgba(212, 175, 55, 0.15)',
|
||||
goldBorder: 'rgba(212, 175, 55, 0.3)',
|
||||
bgDark: '#1A202C',
|
||||
cardBg: 'rgba(26, 32, 44, 0.6)',
|
||||
text: '#E2E8F0',
|
||||
textSecondary: '#A0AEC0',
|
||||
positive: '#E53E3E',
|
||||
negative: '#48BB78',
|
||||
};
|
||||
|
||||
// 预告类型配色
|
||||
const getForecastTypeStyle = (type) => {
|
||||
const styles = {
|
||||
'预增': { color: '#E53E3E', bg: 'rgba(229, 62, 62, 0.15)', border: 'rgba(229, 62, 62, 0.3)' },
|
||||
'预减': { color: '#48BB78', bg: 'rgba(72, 187, 120, 0.15)', border: 'rgba(72, 187, 120, 0.3)' },
|
||||
'扭亏': { color: '#D4AF37', bg: 'rgba(212, 175, 55, 0.15)', border: 'rgba(212, 175, 55, 0.3)' },
|
||||
'首亏': { color: '#48BB78', bg: 'rgba(72, 187, 120, 0.15)', border: 'rgba(72, 187, 120, 0.3)' },
|
||||
'续亏': { color: '#718096', bg: 'rgba(113, 128, 150, 0.15)', border: 'rgba(113, 128, 150, 0.3)' },
|
||||
'续盈': { color: '#E53E3E', bg: 'rgba(229, 62, 62, 0.15)', border: 'rgba(229, 62, 62, 0.3)' },
|
||||
'略增': { color: '#ED8936', bg: 'rgba(237, 137, 54, 0.15)', border: 'rgba(237, 137, 54, 0.3)' },
|
||||
'略减': { color: '#38B2AC', bg: 'rgba(56, 178, 172, 0.15)', border: 'rgba(56, 178, 172, 0.3)' },
|
||||
};
|
||||
return styles[type] || { color: THEME.gold, bg: THEME.goldLight, border: THEME.goldBorder };
|
||||
};
|
||||
|
||||
const ForecastPanel = ({ stockCode }) => {
|
||||
const [forecast, setForecast] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadForecast = useCallback(async () => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data: result } = await axios.get(
|
||||
`/api/stock/${stockCode}/forecast`
|
||||
);
|
||||
if (result.success && result.data) {
|
||||
setForecast(result.data);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('ForecastPanel', 'loadForecast', err, { stockCode });
|
||||
setForecast(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadForecast();
|
||||
}, [loadForecast]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Center py={10}>
|
||||
<Spinner size="lg" color={THEME.gold} />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!forecast?.forecasts?.length) {
|
||||
return (
|
||||
<Center py={10}>
|
||||
<Text color={THEME.textSecondary}>暂无业绩预告数据</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={3} align="stretch">
|
||||
{forecast.forecasts.map((item, idx) => {
|
||||
const typeStyle = getForecastTypeStyle(item.forecast_type);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={idx}
|
||||
bg={THEME.cardBg}
|
||||
border="1px solid"
|
||||
borderColor={THEME.goldBorder}
|
||||
borderRadius="md"
|
||||
p={4}
|
||||
>
|
||||
{/* 头部:类型标签 + 报告期 */}
|
||||
<Flex justify="space-between" align="center" mb={3}>
|
||||
<Tag
|
||||
style={{
|
||||
color: typeStyle.color,
|
||||
background: typeStyle.bg,
|
||||
border: `1px solid ${typeStyle.border}`,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{item.forecast_type}
|
||||
</Tag>
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>
|
||||
报告期: {item.report_date}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* 内容 */}
|
||||
<Text color={THEME.text} fontSize="sm" lineHeight="1.6" mb={3}>
|
||||
{item.content}
|
||||
</Text>
|
||||
|
||||
{/* 原因(如有) */}
|
||||
{item.reason && (
|
||||
<Text fontSize="xs" color={THEME.textSecondary} mb={3}>
|
||||
{item.reason}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 变动范围 */}
|
||||
{item.change_range?.lower !== undefined && (
|
||||
<Flex align="center" gap={2}>
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>
|
||||
预计变动范围:
|
||||
</Text>
|
||||
<Tag
|
||||
style={{
|
||||
color: THEME.gold,
|
||||
background: THEME.goldLight,
|
||||
border: `1px solid ${THEME.goldBorder}`,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{item.change_range.lower}% ~ {item.change_range.upper}%
|
||||
</Tag>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForecastPanel;
|
||||
@@ -0,0 +1,111 @@
|
||||
// src/views/Company/components/DynamicTracking/components/NewsPanel.js
|
||||
// 新闻动态面板(包装 NewsEventsTab)
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { logger } from '@utils/logger';
|
||||
import axios from '@utils/axiosConfig';
|
||||
import NewsEventsTab from '../../CompanyOverview/NewsEventsTab';
|
||||
|
||||
const NewsPanel = ({ stockCode }) => {
|
||||
const [newsEvents, setNewsEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
total: 0,
|
||||
pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [stockName, setStockName] = useState('');
|
||||
|
||||
// 获取股票名称
|
||||
const fetchStockName = useCallback(async () => {
|
||||
try {
|
||||
const { data: result } = await axios.get(
|
||||
`/api/stock/${stockCode}/basic-info`
|
||||
);
|
||||
if (result.success && result.data) {
|
||||
const name = result.data.SECNAME || result.data.ORGNAME || stockCode;
|
||||
setStockName(name);
|
||||
return name;
|
||||
}
|
||||
return stockCode;
|
||||
} catch (err) {
|
||||
logger.error('NewsPanel', 'fetchStockName', err, { stockCode });
|
||||
return stockCode;
|
||||
}
|
||||
}, [stockCode]);
|
||||
|
||||
// 加载新闻事件
|
||||
const loadNewsEvents = useCallback(
|
||||
async (query, page = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const searchTerm = query || stockName || stockCode;
|
||||
const { data: result } = await axios.get(
|
||||
`/api/events?q=${encodeURIComponent(searchTerm)}&page=${page}&per_page=10`
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setNewsEvents(result.data || []);
|
||||
setPagination({
|
||||
page: result.pagination?.page || page,
|
||||
per_page: result.pagination?.per_page || 10,
|
||||
total: result.pagination?.total || 0,
|
||||
pages: result.pagination?.pages || 0,
|
||||
has_next: result.pagination?.has_next || false,
|
||||
has_prev: result.pagination?.has_prev || false,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('NewsPanel', 'loadNewsEvents', err, { stockCode });
|
||||
setNewsEvents([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[stockCode, stockName]
|
||||
);
|
||||
|
||||
// 首次加载
|
||||
useEffect(() => {
|
||||
const initLoad = async () => {
|
||||
if (stockCode) {
|
||||
const name = await fetchStockName();
|
||||
await loadNewsEvents(name, 1);
|
||||
}
|
||||
};
|
||||
initLoad();
|
||||
}, [stockCode, fetchStockName, loadNewsEvents]);
|
||||
|
||||
// 搜索处理
|
||||
const handleSearchChange = (value) => {
|
||||
setSearchQuery(value);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
loadNewsEvents(searchQuery || stockName, 1);
|
||||
};
|
||||
|
||||
// 分页处理
|
||||
const handlePageChange = (page) => {
|
||||
loadNewsEvents(searchQuery || stockName, page);
|
||||
};
|
||||
|
||||
return (
|
||||
<NewsEventsTab
|
||||
newsEvents={newsEvents}
|
||||
newsLoading={loading}
|
||||
newsPagination={pagination}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearch={handleSearch}
|
||||
onPageChange={handlePageChange}
|
||||
themePreset="blackGold"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsPanel;
|
||||
@@ -0,0 +1,4 @@
|
||||
// src/views/Company/components/DynamicTracking/components/index.js
|
||||
|
||||
export { default as NewsPanel } from './NewsPanel';
|
||||
export { default as ForecastPanel } from './ForecastPanel';
|
||||
@@ -1,182 +1,65 @@
|
||||
// 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 } from "react-icons/fa";
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { FaNewspaper, FaBullhorn, FaCalendarAlt, FaChartBar } from 'react-icons/fa';
|
||||
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import NewsEventsTab from "../CompanyOverview/NewsEventsTab";
|
||||
|
||||
// API配置
|
||||
const API_BASE_URL = getApiBase();
|
||||
import SubTabContainer from '@components/SubTabContainer';
|
||||
import AnnouncementsPanel from '../CompanyOverview/BasicInfoTab/components/AnnouncementsPanel';
|
||||
import DisclosureSchedulePanel from '../CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel';
|
||||
import { NewsPanel, ForecastPanel } from './components';
|
||||
|
||||
// 二级 Tab 配置
|
||||
const TRACKING_TABS = [
|
||||
{ key: "news", name: "新闻动态", icon: FaNewspaper },
|
||||
// 后续可扩展更多二级 Tab
|
||||
{ key: 'news', name: '新闻动态', icon: FaNewspaper, component: NewsPanel },
|
||||
{ key: 'announcements', name: '公司公告', icon: FaBullhorn, component: AnnouncementsPanel },
|
||||
{ key: 'disclosure', name: '财报披露日程', icon: FaCalendarAlt, component: DisclosureSchedulePanel },
|
||||
{ key: 'forecast', name: '业绩预告', icon: FaChartBar, component: ForecastPanel },
|
||||
];
|
||||
|
||||
/**
|
||||
* 动态跟踪组件
|
||||
*
|
||||
* 功能:
|
||||
* - 二级 Tab 结构
|
||||
* - Tab1: 新闻动态(复用 NewsEventsTab)
|
||||
* - 预留后续扩展
|
||||
* - 使用 SubTabContainer 实现二级导航
|
||||
* - Tab1: 新闻动态
|
||||
* - Tab2: 公司公告
|
||||
* - Tab3: 财报披露日程
|
||||
* - Tab4: 业绩预告
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.stockCode - 股票代码
|
||||
*/
|
||||
const DynamicTracking = ({ stockCode: propStockCode }) => {
|
||||
const [stockCode, setStockCode] = useState(propStockCode || "000001");
|
||||
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]
|
||||
// 传递给子组件的 props
|
||||
const componentProps = useMemo(
|
||||
() => ({
|
||||
stockCode,
|
||||
}),
|
||||
[stockCode]
|
||||
);
|
||||
|
||||
// 首次加载
|
||||
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>
|
||||
<Tabs
|
||||
variant="enclosed"
|
||||
colorScheme="blue"
|
||||
<SubTabContainer
|
||||
tabs={TRACKING_TABS}
|
||||
componentProps={componentProps}
|
||||
themePreset="blackGold"
|
||||
index={activeTab}
|
||||
onChange={setActiveTab}
|
||||
>
|
||||
<TabList>
|
||||
{TRACKING_TABS.map((tab) => (
|
||||
<Tab key={tab.key} fontWeight="medium">
|
||||
{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 Panel */}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
onTabChange={(index) => setActiveTab(index)}
|
||||
isLazy
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* 资产负债表组件 - Ant Design 黑金主题
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
|
||||
import { Table, ConfigProvider, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Eye } from 'lucide-react';
|
||||
import { formatUtils } from '@services/financialService';
|
||||
import {
|
||||
CURRENT_ASSETS_METRICS,
|
||||
NON_CURRENT_ASSETS_METRICS,
|
||||
TOTAL_ASSETS_METRICS,
|
||||
CURRENT_LIABILITIES_METRICS,
|
||||
NON_CURRENT_LIABILITIES_METRICS,
|
||||
TOTAL_LIABILITIES_METRICS,
|
||||
EQUITY_METRICS,
|
||||
} from '../constants';
|
||||
import { getValueByPath } from '../utils';
|
||||
import type { BalanceSheetTableProps, MetricConfig } from '../types';
|
||||
|
||||
// Ant Design 黑金主题配置
|
||||
const BLACK_GOLD_THEME = {
|
||||
token: {
|
||||
colorBgContainer: 'transparent',
|
||||
colorText: '#E2E8F0',
|
||||
colorTextHeading: '#D4AF37',
|
||||
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
|
||||
},
|
||||
components: {
|
||||
Table: {
|
||||
headerBg: 'rgba(26, 32, 44, 0.8)',
|
||||
headerColor: '#D4AF37',
|
||||
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.15)',
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 12,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 黑金主题CSS
|
||||
const tableStyles = `
|
||||
.balance-sheet-table .ant-table {
|
||||
background: transparent !important;
|
||||
}
|
||||
.balance-sheet-table .ant-table-thead > tr > th {
|
||||
background: rgba(26, 32, 44, 0.8) !important;
|
||||
color: #D4AF37 !important;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.balance-sheet-table .ant-table-tbody > tr > td {
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
|
||||
color: #E2E8F0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.balance-sheet-table .ant-table-tbody > tr:hover > td {
|
||||
background: rgba(212, 175, 55, 0.08) !important;
|
||||
}
|
||||
.balance-sheet-table .ant-table-tbody > tr.total-row > td {
|
||||
background: rgba(212, 175, 55, 0.15) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.balance-sheet-table .ant-table-tbody > tr.section-header > td {
|
||||
background: rgba(212, 175, 55, 0.08) !important;
|
||||
font-weight: 600;
|
||||
color: #D4AF37;
|
||||
}
|
||||
.balance-sheet-table .ant-table-cell-fix-left,
|
||||
.balance-sheet-table .ant-table-cell-fix-right {
|
||||
background: #1A202C !important;
|
||||
}
|
||||
.balance-sheet-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
|
||||
.balance-sheet-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
|
||||
background: rgba(26, 32, 44, 0.95) !important;
|
||||
}
|
||||
.balance-sheet-table .positive-change {
|
||||
color: #E53E3E;
|
||||
}
|
||||
.balance-sheet-table .negative-change {
|
||||
color: #48BB78;
|
||||
}
|
||||
.balance-sheet-table .ant-table-placeholder {
|
||||
background: transparent !important;
|
||||
}
|
||||
.balance-sheet-table .ant-empty-description {
|
||||
color: #A0AEC0;
|
||||
}
|
||||
`;
|
||||
|
||||
// 表格行数据类型
|
||||
interface TableRowData {
|
||||
key: string;
|
||||
name: string;
|
||||
path: string;
|
||||
isCore?: boolean;
|
||||
isTotal?: boolean;
|
||||
isSection?: boolean;
|
||||
indent?: number;
|
||||
[period: string]: unknown;
|
||||
}
|
||||
|
||||
export const BalanceSheetTable: React.FC<BalanceSheetTableProps> = ({
|
||||
data,
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
positiveColor = 'red.500',
|
||||
negativeColor = 'green.500',
|
||||
}) => {
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<Box p={4} textAlign="center" color="gray.400">
|
||||
暂无资产负债表数据
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const maxColumns = Math.min(data.length, 6);
|
||||
const displayData = data.slice(0, maxColumns);
|
||||
|
||||
// 所有分类配置
|
||||
const allSections = [
|
||||
CURRENT_ASSETS_METRICS,
|
||||
NON_CURRENT_ASSETS_METRICS,
|
||||
TOTAL_ASSETS_METRICS,
|
||||
CURRENT_LIABILITIES_METRICS,
|
||||
NON_CURRENT_LIABILITIES_METRICS,
|
||||
TOTAL_LIABILITIES_METRICS,
|
||||
EQUITY_METRICS,
|
||||
];
|
||||
|
||||
// 构建表格数据
|
||||
const tableData = useMemo(() => {
|
||||
const rows: TableRowData[] = [];
|
||||
|
||||
allSections.forEach((section) => {
|
||||
// 添加分组标题行(汇总行不显示标题)
|
||||
if (!['资产总计', '负债合计'].includes(section.title)) {
|
||||
rows.push({
|
||||
key: `section-${section.key}`,
|
||||
name: section.title,
|
||||
path: '',
|
||||
isSection: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 添加指标行
|
||||
section.metrics.forEach((metric: MetricConfig) => {
|
||||
const row: TableRowData = {
|
||||
key: metric.key,
|
||||
name: metric.name,
|
||||
path: metric.path,
|
||||
isCore: metric.isCore,
|
||||
isTotal: metric.isTotal || ['资产总计', '负债合计'].includes(section.title),
|
||||
indent: metric.isTotal ? 0 : 1,
|
||||
};
|
||||
|
||||
// 添加各期数值
|
||||
displayData.forEach((item) => {
|
||||
const value = getValueByPath<number>(item, metric.path);
|
||||
row[item.period] = value;
|
||||
});
|
||||
|
||||
rows.push(row);
|
||||
});
|
||||
});
|
||||
|
||||
return rows;
|
||||
}, [data, displayData]);
|
||||
|
||||
// 计算同比变化
|
||||
const calculateYoY = (
|
||||
currentValue: number | undefined,
|
||||
currentPeriod: string,
|
||||
path: string
|
||||
): number | null => {
|
||||
if (currentValue === undefined || currentValue === null) return null;
|
||||
|
||||
const currentDate = new Date(currentPeriod);
|
||||
const lastYearPeriod = data.find((item) => {
|
||||
const date = new Date(item.period);
|
||||
return (
|
||||
date.getFullYear() === currentDate.getFullYear() - 1 &&
|
||||
date.getMonth() === currentDate.getMonth()
|
||||
);
|
||||
});
|
||||
|
||||
if (!lastYearPeriod) return null;
|
||||
|
||||
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
|
||||
if (lastYearValue === undefined || lastYearValue === 0) return null;
|
||||
|
||||
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
|
||||
};
|
||||
|
||||
// 构建列定义
|
||||
const columns: ColumnsType<TableRowData> = useMemo(() => {
|
||||
const cols: ColumnsType<TableRowData> = [
|
||||
{
|
||||
title: '项目',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
fixed: 'left',
|
||||
width: 200,
|
||||
render: (name: string, record: TableRowData) => {
|
||||
if (record.isSection) {
|
||||
return <Text fontWeight="bold" color="#D4AF37">{name}</Text>;
|
||||
}
|
||||
return (
|
||||
<HStack spacing={2} pl={record.indent ? 4 : 0}>
|
||||
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>{name}</Text>
|
||||
{record.isCore && (
|
||||
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
||||
核心
|
||||
</ChakraBadge>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
},
|
||||
},
|
||||
...displayData.map((item) => ({
|
||||
title: (
|
||||
<Box textAlign="center">
|
||||
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
|
||||
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
|
||||
</Box>
|
||||
),
|
||||
dataIndex: item.period,
|
||||
key: item.period,
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: (value: number | undefined, record: TableRowData) => {
|
||||
if (record.isSection) return null;
|
||||
|
||||
const yoy = calculateYoY(value, item.period, record.path);
|
||||
const formattedValue = formatUtils.formatLargeNumber(value, 0);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Text>数值: {formatUtils.formatLargeNumber(value)}</Text>
|
||||
{yoy !== null && <Text>同比: {yoy.toFixed(2)}%</Text>}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box position="relative">
|
||||
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>
|
||||
{formattedValue}
|
||||
</Text>
|
||||
{yoy !== null && Math.abs(yoy) > 30 && !record.isTotal && (
|
||||
<Text
|
||||
position="absolute"
|
||||
top="-12px"
|
||||
right="0"
|
||||
fontSize="10px"
|
||||
className={yoy > 0 ? 'positive-change' : 'negative-change'}
|
||||
>
|
||||
{yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}%
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
})),
|
||||
{
|
||||
title: '',
|
||||
key: 'action',
|
||||
width: 40,
|
||||
fixed: 'right',
|
||||
render: (_: unknown, record: TableRowData) => {
|
||||
if (record.isSection) return null;
|
||||
return (
|
||||
<Eye
|
||||
size={14}
|
||||
color="#D4AF37"
|
||||
style={{ cursor: 'pointer', opacity: 0.7 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
showMetricChart(record.name, record.key, data, record.path);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return cols;
|
||||
}, [displayData, data, showMetricChart]);
|
||||
|
||||
return (
|
||||
<Box className="balance-sheet-table">
|
||||
<style>{tableStyles}</style>
|
||||
<ConfigProvider theme={BLACK_GOLD_THEME}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowClassName={(record) => {
|
||||
if (record.isSection) return 'section-header';
|
||||
if (record.isTotal) return 'total-row';
|
||||
return '';
|
||||
}}
|
||||
onRow={(record) => ({
|
||||
onClick: () => {
|
||||
if (!record.isSection) {
|
||||
showMetricChart(record.name, record.key, data, record.path);
|
||||
}
|
||||
},
|
||||
style: { cursor: record.isSection ? 'default' : 'pointer' },
|
||||
})}
|
||||
locale={{ emptyText: '暂无数据' }}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BalanceSheetTable;
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* 现金流量表组件 - Ant Design 黑金主题
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
|
||||
import { Table, ConfigProvider, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Eye } from 'lucide-react';
|
||||
import { formatUtils } from '@services/financialService';
|
||||
import { CASHFLOW_METRICS } from '../constants';
|
||||
import { getValueByPath } from '../utils';
|
||||
import type { CashflowTableProps } from '../types';
|
||||
|
||||
// Ant Design 黑金主题配置
|
||||
const BLACK_GOLD_THEME = {
|
||||
token: {
|
||||
colorBgContainer: 'transparent',
|
||||
colorText: '#E2E8F0',
|
||||
colorTextHeading: '#D4AF37',
|
||||
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
|
||||
},
|
||||
components: {
|
||||
Table: {
|
||||
headerBg: 'rgba(26, 32, 44, 0.8)',
|
||||
headerColor: '#D4AF37',
|
||||
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.15)',
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 12,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 黑金主题CSS
|
||||
const tableStyles = `
|
||||
.cashflow-table .ant-table {
|
||||
background: transparent !important;
|
||||
}
|
||||
.cashflow-table .ant-table-thead > tr > th {
|
||||
background: rgba(26, 32, 44, 0.8) !important;
|
||||
color: #D4AF37 !important;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cashflow-table .ant-table-tbody > tr > td {
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
|
||||
color: #E2E8F0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cashflow-table .ant-table-tbody > tr:hover > td {
|
||||
background: rgba(212, 175, 55, 0.08) !important;
|
||||
}
|
||||
.cashflow-table .ant-table-cell-fix-left,
|
||||
.cashflow-table .ant-table-cell-fix-right {
|
||||
background: #1A202C !important;
|
||||
}
|
||||
.cashflow-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
|
||||
.cashflow-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
|
||||
background: rgba(26, 32, 44, 0.95) !important;
|
||||
}
|
||||
.cashflow-table .positive-value {
|
||||
color: #E53E3E;
|
||||
}
|
||||
.cashflow-table .negative-value {
|
||||
color: #48BB78;
|
||||
}
|
||||
.cashflow-table .positive-change {
|
||||
color: #E53E3E;
|
||||
}
|
||||
.cashflow-table .negative-change {
|
||||
color: #48BB78;
|
||||
}
|
||||
.cashflow-table .ant-table-placeholder {
|
||||
background: transparent !important;
|
||||
}
|
||||
.cashflow-table .ant-empty-description {
|
||||
color: #A0AEC0;
|
||||
}
|
||||
`;
|
||||
|
||||
// 核心指标
|
||||
const CORE_METRICS = ['operating_net', 'free_cash_flow'];
|
||||
|
||||
// 表格行数据类型
|
||||
interface TableRowData {
|
||||
key: string;
|
||||
name: string;
|
||||
path: string;
|
||||
isCore?: boolean;
|
||||
[period: string]: unknown;
|
||||
}
|
||||
|
||||
export const CashflowTable: React.FC<CashflowTableProps> = ({
|
||||
data,
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
positiveColor = 'red.500',
|
||||
negativeColor = 'green.500',
|
||||
}) => {
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<Box p={4} textAlign="center" color="gray.400">
|
||||
暂无现金流量表数据
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const maxColumns = Math.min(data.length, 8);
|
||||
const displayData = data.slice(0, maxColumns);
|
||||
|
||||
// 构建表格数据
|
||||
const tableData = useMemo(() => {
|
||||
return CASHFLOW_METRICS.map((metric) => {
|
||||
const row: TableRowData = {
|
||||
key: metric.key,
|
||||
name: metric.name,
|
||||
path: metric.path,
|
||||
isCore: CORE_METRICS.includes(metric.key),
|
||||
};
|
||||
|
||||
// 添加各期数值
|
||||
displayData.forEach((item) => {
|
||||
const value = getValueByPath<number>(item, metric.path);
|
||||
row[item.period] = value;
|
||||
});
|
||||
|
||||
return row;
|
||||
});
|
||||
}, [data, displayData]);
|
||||
|
||||
// 计算同比变化
|
||||
const calculateYoY = (
|
||||
currentValue: number | undefined,
|
||||
currentPeriod: string,
|
||||
path: string
|
||||
): number | null => {
|
||||
if (currentValue === undefined || currentValue === null) return null;
|
||||
|
||||
const currentDate = new Date(currentPeriod);
|
||||
const lastYearPeriod = data.find((item) => {
|
||||
const date = new Date(item.period);
|
||||
return (
|
||||
date.getFullYear() === currentDate.getFullYear() - 1 &&
|
||||
date.getMonth() === currentDate.getMonth()
|
||||
);
|
||||
});
|
||||
|
||||
if (!lastYearPeriod) return null;
|
||||
|
||||
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
|
||||
if (lastYearValue === undefined || lastYearValue === 0) return null;
|
||||
|
||||
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
|
||||
};
|
||||
|
||||
// 构建列定义
|
||||
const columns: ColumnsType<TableRowData> = useMemo(() => {
|
||||
const cols: ColumnsType<TableRowData> = [
|
||||
{
|
||||
title: '项目',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
fixed: 'left',
|
||||
width: 180,
|
||||
render: (name: string, record: TableRowData) => (
|
||||
<HStack spacing={2}>
|
||||
<Text fontWeight="medium">{name}</Text>
|
||||
{record.isCore && (
|
||||
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
||||
核心
|
||||
</ChakraBadge>
|
||||
)}
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
...displayData.map((item) => ({
|
||||
title: (
|
||||
<Box textAlign="center">
|
||||
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
|
||||
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
|
||||
</Box>
|
||||
),
|
||||
dataIndex: item.period,
|
||||
key: item.period,
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (value: number | undefined, record: TableRowData) => {
|
||||
const yoy = calculateYoY(value, item.period, record.path);
|
||||
const formattedValue = formatUtils.formatLargeNumber(value, 1);
|
||||
const isNegative = value !== undefined && value < 0;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Text>数值: {formatUtils.formatLargeNumber(value)}</Text>
|
||||
{yoy !== null && <Text>同比: {yoy.toFixed(2)}%</Text>}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box position="relative">
|
||||
<Text className={isNegative ? 'negative-value' : 'positive-value'}>
|
||||
{formattedValue}
|
||||
</Text>
|
||||
{yoy !== null && Math.abs(yoy) > 50 && (
|
||||
<Text
|
||||
position="absolute"
|
||||
top="-12px"
|
||||
right="0"
|
||||
fontSize="10px"
|
||||
className={yoy > 0 ? 'positive-change' : 'negative-change'}
|
||||
>
|
||||
{yoy > 0 ? '↑' : '↓'}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
})),
|
||||
{
|
||||
title: '',
|
||||
key: 'action',
|
||||
width: 40,
|
||||
fixed: 'right',
|
||||
render: (_: unknown, record: TableRowData) => (
|
||||
<Eye
|
||||
size={14}
|
||||
color="#D4AF37"
|
||||
style={{ cursor: 'pointer', opacity: 0.7 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
showMetricChart(record.name, record.key, data, record.path);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return cols;
|
||||
}, [displayData, data, showMetricChart]);
|
||||
|
||||
return (
|
||||
<Box className="cashflow-table">
|
||||
<style>{tableStyles}</style>
|
||||
<ConfigProvider theme={BLACK_GOLD_THEME}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 'max-content' }}
|
||||
onRow={(record) => ({
|
||||
onClick: () => {
|
||||
showMetricChart(record.name, record.key, data, record.path);
|
||||
},
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
locale={{ emptyText: '暂无数据' }}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CashflowTable;
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 综合对比分析组件 - 黑金主题
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { formatUtils } from '@services/financialService';
|
||||
import { getComparisonChartOption } from '../utils';
|
||||
import type { ComparisonAnalysisProps } from '../types';
|
||||
|
||||
// 黑金主题样式
|
||||
const THEME = {
|
||||
cardBg: 'transparent',
|
||||
border: 'rgba(212, 175, 55, 0.2)',
|
||||
};
|
||||
|
||||
export const ComparisonAnalysis: React.FC<ComparisonAnalysisProps> = ({ comparison }) => {
|
||||
if (!Array.isArray(comparison) || comparison.length === 0) return null;
|
||||
|
||||
const revenueData = comparison
|
||||
.map((item) => ({
|
||||
period: formatUtils.getReportType(item.period),
|
||||
value: item.performance.revenue / 100000000, // 转换为亿
|
||||
}))
|
||||
.reverse();
|
||||
|
||||
const profitData = comparison
|
||||
.map((item) => ({
|
||||
period: formatUtils.getReportType(item.period),
|
||||
value: item.performance.net_profit / 100000000, // 转换为亿
|
||||
}))
|
||||
.reverse();
|
||||
|
||||
const chartOption = getComparisonChartOption(revenueData, profitData);
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={THEME.cardBg}
|
||||
border="1px solid"
|
||||
borderColor={THEME.border}
|
||||
borderRadius="md"
|
||||
p={4}
|
||||
>
|
||||
<ReactECharts option={chartOption} style={{ height: '350px' }} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComparisonAnalysis;
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* 财务指标表格组件 - Ant Design 黑金主题
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Box, Text, HStack, Badge as ChakraBadge, SimpleGrid, Card, CardBody, CardHeader, Heading, Button } from '@chakra-ui/react';
|
||||
import { Table, ConfigProvider, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Eye } from 'lucide-react';
|
||||
import { formatUtils } from '@services/financialService';
|
||||
import { FINANCIAL_METRICS_CATEGORIES } from '../constants';
|
||||
import { getValueByPath, isNegativeIndicator } from '../utils';
|
||||
import type { FinancialMetricsTableProps } from '../types';
|
||||
|
||||
type CategoryKey = keyof typeof FINANCIAL_METRICS_CATEGORIES;
|
||||
|
||||
// Ant Design 黑金主题配置
|
||||
const BLACK_GOLD_THEME = {
|
||||
token: {
|
||||
colorBgContainer: 'transparent',
|
||||
colorText: '#E2E8F0',
|
||||
colorTextHeading: '#D4AF37',
|
||||
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
|
||||
},
|
||||
components: {
|
||||
Table: {
|
||||
headerBg: 'rgba(26, 32, 44, 0.8)',
|
||||
headerColor: '#D4AF37',
|
||||
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.15)',
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 12,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 黑金主题CSS
|
||||
const tableStyles = `
|
||||
.financial-metrics-table .ant-table {
|
||||
background: transparent !important;
|
||||
}
|
||||
.financial-metrics-table .ant-table-thead > tr > th {
|
||||
background: rgba(26, 32, 44, 0.8) !important;
|
||||
color: #D4AF37 !important;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.financial-metrics-table .ant-table-tbody > tr > td {
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
|
||||
color: #E2E8F0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.financial-metrics-table .ant-table-tbody > tr:hover > td {
|
||||
background: rgba(212, 175, 55, 0.08) !important;
|
||||
}
|
||||
.financial-metrics-table .ant-table-cell-fix-left,
|
||||
.financial-metrics-table .ant-table-cell-fix-right {
|
||||
background: #1A202C !important;
|
||||
}
|
||||
.financial-metrics-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
|
||||
.financial-metrics-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
|
||||
background: rgba(26, 32, 44, 0.95) !important;
|
||||
}
|
||||
.financial-metrics-table .positive-change {
|
||||
color: #E53E3E;
|
||||
}
|
||||
.financial-metrics-table .negative-change {
|
||||
color: #48BB78;
|
||||
}
|
||||
.financial-metrics-table .positive-value {
|
||||
color: #E53E3E;
|
||||
}
|
||||
.financial-metrics-table .negative-value {
|
||||
color: #48BB78;
|
||||
}
|
||||
.financial-metrics-table .ant-table-placeholder {
|
||||
background: transparent !important;
|
||||
}
|
||||
.financial-metrics-table .ant-empty-description {
|
||||
color: #A0AEC0;
|
||||
}
|
||||
`;
|
||||
|
||||
// 表格行数据类型
|
||||
interface TableRowData {
|
||||
key: string;
|
||||
name: string;
|
||||
path: string;
|
||||
isCore?: boolean;
|
||||
[period: string]: unknown;
|
||||
}
|
||||
|
||||
export const FinancialMetricsTable: React.FC<FinancialMetricsTableProps> = ({
|
||||
data,
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
}) => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('profitability');
|
||||
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<Box p={4} textAlign="center" color="gray.400">
|
||||
暂无财务指标数据
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const maxColumns = Math.min(data.length, 6);
|
||||
const displayData = data.slice(0, maxColumns);
|
||||
const currentCategory = FINANCIAL_METRICS_CATEGORIES[selectedCategory];
|
||||
|
||||
// 构建表格数据
|
||||
const tableData = useMemo(() => {
|
||||
return currentCategory.metrics.map((metric) => {
|
||||
const row: TableRowData = {
|
||||
key: metric.key,
|
||||
name: metric.name,
|
||||
path: metric.path,
|
||||
isCore: metric.isCore,
|
||||
};
|
||||
|
||||
// 添加各期数值
|
||||
displayData.forEach((item) => {
|
||||
const value = getValueByPath<number>(item, metric.path);
|
||||
row[item.period] = value;
|
||||
});
|
||||
|
||||
return row;
|
||||
});
|
||||
}, [data, displayData, currentCategory]);
|
||||
|
||||
// 计算同比变化
|
||||
const calculateYoY = (
|
||||
currentValue: number | undefined,
|
||||
currentPeriod: string,
|
||||
path: string
|
||||
): number | null => {
|
||||
if (currentValue === undefined || currentValue === null) return null;
|
||||
|
||||
const currentDate = new Date(currentPeriod);
|
||||
const lastYearPeriod = data.find((item) => {
|
||||
const date = new Date(item.period);
|
||||
return (
|
||||
date.getFullYear() === currentDate.getFullYear() - 1 &&
|
||||
date.getMonth() === currentDate.getMonth()
|
||||
);
|
||||
});
|
||||
|
||||
if (!lastYearPeriod) return null;
|
||||
|
||||
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
|
||||
if (lastYearValue === undefined || lastYearValue === 0) return null;
|
||||
|
||||
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
|
||||
};
|
||||
|
||||
// 构建列定义
|
||||
const columns: ColumnsType<TableRowData> = useMemo(() => {
|
||||
const cols: ColumnsType<TableRowData> = [
|
||||
{
|
||||
title: currentCategory.title,
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
fixed: 'left',
|
||||
width: 200,
|
||||
render: (name: string, record: TableRowData) => (
|
||||
<HStack spacing={2}>
|
||||
<Text fontWeight="medium" fontSize="xs">{name}</Text>
|
||||
{record.isCore && (
|
||||
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
||||
核心
|
||||
</ChakraBadge>
|
||||
)}
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
...displayData.map((item) => ({
|
||||
title: (
|
||||
<Box textAlign="center">
|
||||
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
|
||||
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
|
||||
</Box>
|
||||
),
|
||||
dataIndex: item.period,
|
||||
key: item.period,
|
||||
width: 100,
|
||||
align: 'right' as const,
|
||||
render: (value: number | undefined, record: TableRowData) => {
|
||||
const yoy = calculateYoY(value, item.period, record.path);
|
||||
const isNegative = isNegativeIndicator(record.key);
|
||||
|
||||
// 对于负向指标,增加是坏事(绿色),减少是好事(红色)
|
||||
const changeColor = isNegative
|
||||
? (yoy && yoy > 0 ? 'negative-change' : 'positive-change')
|
||||
: (yoy && yoy > 0 ? 'positive-change' : 'negative-change');
|
||||
|
||||
// 成长能力指标特殊处理:正值红色,负值绿色
|
||||
const valueColor = selectedCategory === 'growth'
|
||||
? (value !== undefined && value > 0 ? 'positive-value' : value !== undefined && value < 0 ? 'negative-value' : '')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Text>{record.name}: {value?.toFixed(2) || '-'}</Text>
|
||||
{yoy !== null && <Text>同比: {yoy.toFixed(2)}%</Text>}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box position="relative">
|
||||
<Text fontSize="xs" className={valueColor || undefined}>
|
||||
{value?.toFixed(2) || '-'}
|
||||
</Text>
|
||||
{yoy !== null && Math.abs(yoy) > 20 && value !== undefined && Math.abs(value) > 0.01 && (
|
||||
<Text
|
||||
position="absolute"
|
||||
top="-12px"
|
||||
right="0"
|
||||
fontSize="10px"
|
||||
className={changeColor}
|
||||
>
|
||||
{yoy > 0 ? '↑' : '↓'}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
})),
|
||||
{
|
||||
title: '',
|
||||
key: 'action',
|
||||
width: 40,
|
||||
fixed: 'right',
|
||||
render: (_: unknown, record: TableRowData) => (
|
||||
<Eye
|
||||
size={14}
|
||||
color="#D4AF37"
|
||||
style={{ cursor: 'pointer', opacity: 0.7 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
showMetricChart(record.name, record.key, data, record.path);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return cols;
|
||||
}, [displayData, data, showMetricChart, currentCategory, selectedCategory]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 分类选择器 */}
|
||||
<HStack spacing={2} mb={4} flexWrap="wrap">
|
||||
{(Object.entries(FINANCIAL_METRICS_CATEGORIES) as [CategoryKey, typeof currentCategory][]).map(
|
||||
([key, category]) => (
|
||||
<Button
|
||||
key={key}
|
||||
size="sm"
|
||||
variant={selectedCategory === key ? 'solid' : 'outline'}
|
||||
bg={selectedCategory === key ? 'rgba(212, 175, 55, 0.3)' : 'transparent'}
|
||||
color={selectedCategory === key ? '#D4AF37' : 'gray.400'}
|
||||
borderColor="rgba(212, 175, 55, 0.3)"
|
||||
_hover={{
|
||||
bg: 'rgba(212, 175, 55, 0.2)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.5)',
|
||||
}}
|
||||
onClick={() => setSelectedCategory(key)}
|
||||
>
|
||||
{category.title.replace('指标', '')}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 指标表格 */}
|
||||
<Box className="financial-metrics-table">
|
||||
<style>{tableStyles}</style>
|
||||
<ConfigProvider theme={BLACK_GOLD_THEME}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 'max-content' }}
|
||||
onRow={(record) => ({
|
||||
onClick: () => {
|
||||
showMetricChart(record.name, record.key, data, record.path);
|
||||
},
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
locale={{ emptyText: '暂无数据' }}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
</Box>
|
||||
|
||||
{/* 关键指标快速对比 */}
|
||||
{data[0] && (
|
||||
<Card mt={4} bg="transparent" border="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
|
||||
<CardHeader py={3} borderBottom="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
|
||||
<Heading size="sm" color="#D4AF37">关键指标速览</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<SimpleGrid columns={{ base: 2, md: 4, lg: 6 }} spacing={4}>
|
||||
{[
|
||||
{
|
||||
label: 'ROE',
|
||||
value: getValueByPath<number>(data[0], 'profitability.roe'),
|
||||
format: 'percent',
|
||||
},
|
||||
{
|
||||
label: '毛利率',
|
||||
value: getValueByPath<number>(data[0], 'profitability.gross_margin'),
|
||||
format: 'percent',
|
||||
},
|
||||
{
|
||||
label: '净利率',
|
||||
value: getValueByPath<number>(data[0], 'profitability.net_profit_margin'),
|
||||
format: 'percent',
|
||||
},
|
||||
{
|
||||
label: '流动比率',
|
||||
value: getValueByPath<number>(data[0], 'solvency.current_ratio'),
|
||||
format: 'decimal',
|
||||
},
|
||||
{
|
||||
label: '资产负债率',
|
||||
value: getValueByPath<number>(data[0], 'solvency.asset_liability_ratio'),
|
||||
format: 'percent',
|
||||
},
|
||||
{
|
||||
label: '研发费用率',
|
||||
value: getValueByPath<number>(data[0], 'expense_ratios.rd_expense_ratio'),
|
||||
format: 'percent',
|
||||
},
|
||||
].map((item, idx) => (
|
||||
<Box key={idx} p={3} borderRadius="md" bg="rgba(212, 175, 55, 0.1)" border="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
|
||||
<Text fontSize="xs" color="gray.400">
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text fontSize="lg" fontWeight="bold" color="#D4AF37">
|
||||
{item.format === 'percent'
|
||||
? formatUtils.formatPercent(item.value)
|
||||
: item.value?.toFixed(2) || '-'}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinancialMetricsTable;
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 财务全景面板组件 - 三列布局
|
||||
* 复用 MarketDataView 的 MetricCard 组件
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { SimpleGrid, HStack, VStack, Text, Badge } from '@chakra-ui/react';
|
||||
import { TrendingUp, Coins, Shield, TrendingDown, Activity, PieChart } from 'lucide-react';
|
||||
import { formatUtils } from '@services/financialService';
|
||||
|
||||
// 复用 MarketDataView 的组件
|
||||
import MetricCard from '../../MarketDataView/components/StockSummaryCard/MetricCard';
|
||||
import { StatusTag } from '../../MarketDataView/components/StockSummaryCard/atoms';
|
||||
import { darkGoldTheme } from '../../MarketDataView/constants';
|
||||
|
||||
import type { StockInfo, FinancialMetricsData } from '../types';
|
||||
|
||||
export interface FinancialOverviewPanelProps {
|
||||
stockInfo: StockInfo | null;
|
||||
financialMetrics: FinancialMetricsData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成长状态
|
||||
*/
|
||||
const getGrowthStatus = (value: number | undefined): { text: string; color: string } => {
|
||||
if (value === undefined || value === null) return { text: '-', color: darkGoldTheme.textMuted };
|
||||
if (value > 30) return { text: '高速增长', color: darkGoldTheme.green };
|
||||
if (value > 10) return { text: '稳健增长', color: darkGoldTheme.gold };
|
||||
if (value > 0) return { text: '低速增长', color: darkGoldTheme.orange };
|
||||
if (value > -10) return { text: '小幅下滑', color: darkGoldTheme.orange };
|
||||
return { text: '大幅下滑', color: darkGoldTheme.red };
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取 ROE 状态
|
||||
*/
|
||||
const getROEStatus = (value: number | undefined): { text: string; color: string } => {
|
||||
if (value === undefined || value === null) return { text: '-', color: darkGoldTheme.textMuted };
|
||||
if (value > 20) return { text: '优秀', color: darkGoldTheme.green };
|
||||
if (value > 15) return { text: '良好', color: darkGoldTheme.gold };
|
||||
if (value > 10) return { text: '一般', color: darkGoldTheme.orange };
|
||||
return { text: '较低', color: darkGoldTheme.red };
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取资产负债率状态
|
||||
*/
|
||||
const getDebtStatus = (value: number | undefined): { text: string; color: string } => {
|
||||
if (value === undefined || value === null) return { text: '-', color: darkGoldTheme.textMuted };
|
||||
if (value < 40) return { text: '安全', color: darkGoldTheme.green };
|
||||
if (value < 60) return { text: '适中', color: darkGoldTheme.gold };
|
||||
if (value < 70) return { text: '偏高', color: darkGoldTheme.orange };
|
||||
return { text: '风险', color: darkGoldTheme.red };
|
||||
};
|
||||
|
||||
/**
|
||||
* 财务全景面板组件
|
||||
*/
|
||||
export const FinancialOverviewPanel: React.FC<FinancialOverviewPanelProps> = memo(({
|
||||
stockInfo,
|
||||
financialMetrics,
|
||||
}) => {
|
||||
if (!stockInfo && (!financialMetrics || financialMetrics.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取最新一期财务指标
|
||||
const latestMetrics = financialMetrics?.[0];
|
||||
|
||||
// 成长指标(来自 stockInfo)
|
||||
const revenueGrowth = stockInfo?.growth_rates?.revenue_growth;
|
||||
const profitGrowth = stockInfo?.growth_rates?.profit_growth;
|
||||
const forecast = stockInfo?.latest_forecast;
|
||||
|
||||
// 盈利指标(来自 financialMetrics)
|
||||
const roe = latestMetrics?.profitability?.roe;
|
||||
const netProfitMargin = latestMetrics?.profitability?.net_profit_margin;
|
||||
const grossMargin = latestMetrics?.profitability?.gross_margin;
|
||||
|
||||
// 风险与运营指标(来自 financialMetrics)
|
||||
const assetLiabilityRatio = latestMetrics?.solvency?.asset_liability_ratio;
|
||||
const currentRatio = latestMetrics?.solvency?.current_ratio;
|
||||
const rdExpenseRatio = latestMetrics?.expense_ratios?.rd_expense_ratio;
|
||||
|
||||
// 计算状态
|
||||
const growthStatus = getGrowthStatus(profitGrowth);
|
||||
const roeStatus = getROEStatus(roe);
|
||||
const debtStatus = getDebtStatus(assetLiabilityRatio);
|
||||
|
||||
// 格式化涨跌显示
|
||||
const formatGrowth = (value: number | undefined) => {
|
||||
if (value === undefined || value === null) return '-';
|
||||
const sign = value >= 0 ? '+' : '';
|
||||
return `${sign}${value.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
return (
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
|
||||
{/* 卡片1: 成长能力 */}
|
||||
<MetricCard
|
||||
title="成长能力"
|
||||
subtitle="增长动力"
|
||||
leftIcon={<TrendingUp size={14} />}
|
||||
rightIcon={<Activity size={14} />}
|
||||
mainLabel="利润增长"
|
||||
mainValue={formatGrowth(profitGrowth)}
|
||||
mainColor={profitGrowth !== undefined && profitGrowth >= 0 ? darkGoldTheme.green : darkGoldTheme.red}
|
||||
subText={
|
||||
<VStack align="start" spacing={1}>
|
||||
<HStack spacing={1} flexWrap="wrap">
|
||||
<Text>营收增长</Text>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={revenueGrowth !== undefined && revenueGrowth >= 0 ? darkGoldTheme.green : darkGoldTheme.red}
|
||||
>
|
||||
{formatGrowth(revenueGrowth)}
|
||||
</Text>
|
||||
<StatusTag text={growthStatus.text} color={growthStatus.color} />
|
||||
</HStack>
|
||||
{forecast && (
|
||||
<Badge
|
||||
bg="rgba(212, 175, 55, 0.15)"
|
||||
color={darkGoldTheme.gold}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="md"
|
||||
>
|
||||
{forecast.forecast_type} {forecast.content}
|
||||
</Badge>
|
||||
)}
|
||||
</VStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 卡片2: 盈利与回报 */}
|
||||
<MetricCard
|
||||
title="盈利与回报"
|
||||
subtitle="赚钱能力"
|
||||
leftIcon={<Coins size={14} />}
|
||||
rightIcon={<PieChart size={14} />}
|
||||
mainLabel="ROE"
|
||||
mainValue={formatUtils.formatPercent(roe)}
|
||||
mainColor={darkGoldTheme.orange}
|
||||
subText={
|
||||
<VStack align="start" spacing={0.5}>
|
||||
<Text color={roeStatus.color} fontWeight="medium">
|
||||
{roeStatus.text}
|
||||
</Text>
|
||||
<HStack spacing={1} flexWrap="wrap">
|
||||
<Text>净利率 {formatUtils.formatPercent(netProfitMargin)}</Text>
|
||||
<Text>|</Text>
|
||||
<Text>毛利率 {formatUtils.formatPercent(grossMargin)}</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 卡片3: 风险与运营 */}
|
||||
<MetricCard
|
||||
title="风险与运营"
|
||||
subtitle="安全边际"
|
||||
leftIcon={<Shield size={14} />}
|
||||
rightIcon={<TrendingDown size={14} />}
|
||||
mainLabel="资产负债率"
|
||||
mainValue={formatUtils.formatPercent(assetLiabilityRatio)}
|
||||
mainColor={debtStatus.color}
|
||||
subText={
|
||||
<VStack align="start" spacing={0.5}>
|
||||
<Text color={debtStatus.color} fontWeight="medium">
|
||||
{debtStatus.text}
|
||||
</Text>
|
||||
<HStack spacing={1} flexWrap="wrap">
|
||||
<Text>流动比率 {currentRatio?.toFixed(2) ?? '-'}</Text>
|
||||
<Text>|</Text>
|
||||
<Text>研发费用率 {formatUtils.formatPercent(rdExpenseRatio)}</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
);
|
||||
});
|
||||
|
||||
FinancialOverviewPanel.displayName = 'FinancialOverviewPanel';
|
||||
|
||||
export default FinancialOverviewPanel;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user