Compare commits
78 Commits
e049429b09
...
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 |
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
|
||||||
@@ -131,12 +131,14 @@
|
|||||||
"eslint-plugin-prettier": "3.4.0",
|
"eslint-plugin-prettier": "3.4.0",
|
||||||
"gulp": "4.0.2",
|
"gulp": "4.0.2",
|
||||||
"gulp-append-prepend": "1.0.9",
|
"gulp-append-prepend": "1.0.9",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"imagemin": "^9.0.1",
|
"imagemin": "^9.0.1",
|
||||||
"imagemin-mozjpeg": "^10.0.0",
|
"imagemin-mozjpeg": "^10.0.0",
|
||||||
"imagemin-pngquant": "^10.0.0",
|
"imagemin-pngquant": "^10.0.0",
|
||||||
"kill-port": "^2.0.1",
|
"kill-port": "^2.0.1",
|
||||||
"less": "^4.4.2",
|
"less": "^4.4.2",
|
||||||
"less-loader": "^12.3.0",
|
"less-loader": "^12.3.0",
|
||||||
|
"lint-staged": "^16.2.7",
|
||||||
"msw": "^2.11.5",
|
"msw": "^2.11.5",
|
||||||
"prettier": "2.2.1",
|
"prettier": "2.2.1",
|
||||||
"react-error-overlay": "6.0.9",
|
"react-error-overlay": "6.0.9",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
Icon,
|
Icon,
|
||||||
HStack,
|
HStack,
|
||||||
Text,
|
Text,
|
||||||
|
Spacer,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
import type { IconType } from 'react-icons';
|
import type { IconType } from 'react-icons';
|
||||||
@@ -95,6 +96,8 @@ export interface SubTabContainerProps {
|
|||||||
contentPadding?: number;
|
contentPadding?: number;
|
||||||
/** 是否懒加载 */
|
/** 是否懒加载 */
|
||||||
isLazy?: boolean;
|
isLazy?: boolean;
|
||||||
|
/** TabList 右侧自定义内容 */
|
||||||
|
rightElement?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
||||||
@@ -107,6 +110,7 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
|||||||
theme: customTheme,
|
theme: customTheme,
|
||||||
contentPadding = 4,
|
contentPadding = 4,
|
||||||
isLazy = true,
|
isLazy = true,
|
||||||
|
rightElement,
|
||||||
}) => {
|
}) => {
|
||||||
// 内部状态(非受控模式)
|
// 内部状态(非受控模式)
|
||||||
const [internalIndex, setInternalIndex] = useState(defaultIndex);
|
const [internalIndex, setInternalIndex] = useState(defaultIndex);
|
||||||
@@ -114,6 +118,11 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
|||||||
// 当前索引
|
// 当前索引
|
||||||
const currentIndex = controlledIndex ?? internalIndex;
|
const currentIndex = controlledIndex ?? internalIndex;
|
||||||
|
|
||||||
|
// 记录已访问的 Tab 索引(用于真正的懒加载)
|
||||||
|
const [visitedTabs, setVisitedTabs] = useState<Set<number>>(
|
||||||
|
() => new Set([controlledIndex ?? defaultIndex])
|
||||||
|
);
|
||||||
|
|
||||||
// 合并主题
|
// 合并主题
|
||||||
const theme: SubTabTheme = {
|
const theme: SubTabTheme = {
|
||||||
...THEME_PRESETS[themePreset],
|
...THEME_PRESETS[themePreset],
|
||||||
@@ -128,6 +137,12 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
|||||||
const tabKey = tabs[newIndex]?.key || '';
|
const tabKey = tabs[newIndex]?.key || '';
|
||||||
onTabChange?.(newIndex, tabKey);
|
onTabChange?.(newIndex, tabKey);
|
||||||
|
|
||||||
|
// 记录已访问的 Tab(用于懒加载)
|
||||||
|
setVisitedTabs(prev => {
|
||||||
|
if (prev.has(newIndex)) return prev;
|
||||||
|
return new Set(prev).add(newIndex);
|
||||||
|
});
|
||||||
|
|
||||||
if (controlledIndex === undefined) {
|
if (controlledIndex === undefined) {
|
||||||
setInternalIndex(newIndex);
|
setInternalIndex(newIndex);
|
||||||
}
|
}
|
||||||
@@ -147,19 +162,28 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
|||||||
bg={theme.bg}
|
bg={theme.bg}
|
||||||
borderBottom="1px solid"
|
borderBottom="1px solid"
|
||||||
borderColor={theme.borderColor}
|
borderColor={theme.borderColor}
|
||||||
px={4}
|
pl={0}
|
||||||
py={2}
|
pr={2}
|
||||||
flexWrap="wrap"
|
py={1.5}
|
||||||
gap={2}
|
flexWrap="nowrap"
|
||||||
|
gap={1}
|
||||||
|
alignItems="center"
|
||||||
|
overflowX="auto"
|
||||||
|
css={{
|
||||||
|
'&::-webkit-scrollbar': { display: 'none' },
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
color={theme.tabUnselectedColor}
|
color={theme.tabUnselectedColor}
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
px={4}
|
px={2.5}
|
||||||
py={2}
|
py={1.5}
|
||||||
fontSize="sm"
|
fontSize="xs"
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
flexShrink={0}
|
||||||
_selected={{
|
_selected={{
|
||||||
bg: theme.tabSelectedBg,
|
bg: theme.tabSelectedBg,
|
||||||
color: theme.tabSelectedColor,
|
color: theme.tabSelectedColor,
|
||||||
@@ -169,20 +193,31 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
|||||||
bg: theme.tabHoverBg,
|
bg: theme.tabHoverBg,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={1}>
|
||||||
{tab.icon && <Icon as={tab.icon} boxSize={4} />}
|
{tab.icon && <Icon as={tab.icon} boxSize={3} />}
|
||||||
<Text>{tab.name}</Text>
|
<Text>{tab.name}</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Tab>
|
</Tab>
|
||||||
))}
|
))}
|
||||||
|
{rightElement && (
|
||||||
|
<>
|
||||||
|
<Spacer />
|
||||||
|
<Box flexShrink={0}>{rightElement}</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
<TabPanels p={contentPadding}>
|
<TabPanels p={contentPadding}>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab, idx) => {
|
||||||
const Component = tab.component;
|
const Component = tab.component;
|
||||||
|
// 懒加载:只渲染已访问过的 Tab
|
||||||
|
const shouldRender = !isLazy || visitedTabs.has(idx);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabPanel key={tab.key} p={0}>
|
<TabPanel key={tab.key} p={0}>
|
||||||
{Component ? <Component {...componentProps} /> : null}
|
{shouldRender && Component ? (
|
||||||
|
<Component {...componentProps} />
|
||||||
|
) : null}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ const TabNavigation: React.FC<TabNavigationProps> = ({
|
|||||||
borderColor={themeColors.dividerColor}
|
borderColor={themeColors.dividerColor}
|
||||||
borderTopLeftRadius={borderRadius}
|
borderTopLeftRadius={borderRadius}
|
||||||
borderTopRightRadius={borderRadius}
|
borderTopRightRadius={borderRadius}
|
||||||
px={4}
|
pl={0}
|
||||||
|
pr={4}
|
||||||
py={2}
|
py={2}
|
||||||
flexWrap="wrap"
|
flexWrap="wrap"
|
||||||
gap={2}
|
gap={2}
|
||||||
|
|||||||
@@ -661,6 +661,12 @@ export const NotificationProvider = ({ children }) => {
|
|||||||
|
|
||||||
// ========== 连接到 Socket 服务(⚡ 异步初始化,不阻塞首屏) ==========
|
// ========== 连接到 Socket 服务(⚡ 异步初始化,不阻塞首屏) ==========
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// ⚡ Mock 模式下跳过 Socket 连接(避免连接生产服务器失败的错误)
|
||||||
|
if (process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||||
|
logger.debug('NotificationContext', 'Mock 模式,跳过 Socket 连接');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ⚡ 防止 React Strict Mode 导致的重复初始化
|
// ⚡ 防止 React Strict Mode 导致的重复初始化
|
||||||
if (socketInitialized) {
|
if (socketInitialized) {
|
||||||
logger.debug('NotificationContext', 'Socket 已初始化,跳过重复执行(Strict Mode 保护)');
|
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';
|
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');
|
performanceMonitor.mark('app-start');
|
||||||
|
|
||||||
// ⚡ 已删除 brainwave.css(项目未安装 Tailwind CSS,该文件无效)
|
// ⚡ 已删除 brainwave.css(项目未安装 Tailwind CSS,该文件无效)
|
||||||
|
|||||||
@@ -42,6 +42,72 @@ export const PINGAN_BANK_DATA = {
|
|||||||
employees: 42099,
|
employees: 42099,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 市场概览数据 - 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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 当日分钟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: [
|
actualControl: [
|
||||||
{
|
{
|
||||||
@@ -473,147 +539,65 @@ export const PINGAN_BANK_DATA = {
|
|||||||
},
|
},
|
||||||
business_structure: [
|
business_structure: [
|
||||||
{
|
{
|
||||||
business_name: '零售金融',
|
business_name: '舒泰清(复方聚乙二醇电解质散IV)',
|
||||||
business_level: 1,
|
business_level: 1,
|
||||||
revenue: 812300,
|
revenue: 17900,
|
||||||
revenue_unit: '万元',
|
revenue_unit: '万元',
|
||||||
financial_metrics: {
|
financial_metrics: {
|
||||||
revenue_ratio: 50.1,
|
revenue_ratio: 55.16,
|
||||||
gross_margin: 42.5
|
gross_margin: 78.21
|
||||||
},
|
},
|
||||||
growth_metrics: {
|
growth_metrics: {
|
||||||
revenue_growth: 11.2
|
revenue_growth: -8.20
|
||||||
},
|
},
|
||||||
report_period: '2024Q3'
|
report_period: '2024年报'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
business_name: '信用卡业务',
|
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_level: 1,
|
business_level: 1,
|
||||||
revenue: 685400,
|
revenue: 13400,
|
||||||
revenue_unit: '万元',
|
revenue_unit: '万元',
|
||||||
financial_metrics: {
|
financial_metrics: {
|
||||||
revenue_ratio: 42.2,
|
revenue_ratio: 41.21,
|
||||||
gross_margin: 38.6
|
gross_margin: 89.11
|
||||||
},
|
},
|
||||||
growth_metrics: {
|
growth_metrics: {
|
||||||
revenue_growth: 6.8
|
revenue_growth: -17.30
|
||||||
},
|
},
|
||||||
report_period: '2024Q3'
|
report_period: '2024年报'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
business_name: '公司贷款',
|
business_name: '舒斯通(复方聚乙二醇(3350)电解质散)',
|
||||||
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_level: 1,
|
business_level: 1,
|
||||||
revenue: 125800,
|
revenue: 771,
|
||||||
revenue_unit: '万元',
|
revenue_unit: '万元',
|
||||||
financial_metrics: {
|
financial_metrics: {
|
||||||
revenue_ratio: 7.7,
|
revenue_ratio: 2.37
|
||||||
gross_margin: 28.2
|
|
||||||
},
|
},
|
||||||
growth_metrics: {
|
report_period: '2024年报'
|
||||||
revenue_growth: 3.5
|
},
|
||||||
|
{
|
||||||
|
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: [
|
business_segments: [
|
||||||
{
|
{
|
||||||
segment_name: '信用卡业务',
|
segment_name: '已上市药品营销',
|
||||||
description: '国内领先的信用卡发卡银行,流通卡量超7000万张',
|
segment_description: '舒泰神已上市药品营销业务主要包括舒泰清(复方聚乙二醇电解质散IV)和苏肽生(注射用鼠神经生长因子)两大核心产品。2024年实现营业收入3.25亿元,其中舒泰清贡献1.79亿元(55.16%),苏肽生贡献1.34亿元(41.21%)。尽管面临市场竞争压力,产品毛利率保持高位,综合毛利率达80.83%,其中苏肽生毛利率高达89.11%。',
|
||||||
key_metrics: { cards_issued: 7200, transaction_volume: 28500, market_share: 8.5 }
|
competitive_position: '舒泰清为《中国消化内镜诊疗肠道准备指南》和《慢性便秘诊治指南》一线用药,苏肽生是国内首个国药准字鼠神经生长因子产品。公司医保目录产品舒斯通已落地,并布局舒亦清、舒常轻等系列产品形成梯队,构建了一定市场竞争优势。然而,2024年集采中同类(III型)产品中选,对舒泰清(IV型)形成潜在价格压力。',
|
||||||
},
|
future_potential: '公司正在构建系列化产品线应对市场变化,研发投入保持高强度(1.62亿元,占营收49.97%)。在研管线中,STSP-0601血友病药物获FDA孤儿药资格,BDB-001被纳入突破性治疗品种,创新药研发持续推进。国家政策支持创新药发展,行业环境向好,同时国际化布局已有初步进展,未来3-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 }
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1336,11 +1320,68 @@ export const generateCompanyData = (stockCode, stockName = '示例公司') => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
business_structure: [
|
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: '舒泰清(复方聚乙二醇电解质散IV)',
|
||||||
{ business_name: '其他业务', revenue: baseRevenue * 0.15, ratio: 15, growth: 5.8, report_period: '2024Q3' }
|
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: {
|
valueChainAnalysis: {
|
||||||
value_chain_flows: [
|
value_chain_flows: [
|
||||||
|
|||||||
@@ -874,8 +874,20 @@ export function generateMockEvents(params = {}) {
|
|||||||
e.title.toLowerCase().includes(query) ||
|
e.title.toLowerCase().includes(query) ||
|
||||||
e.description.toLowerCase().includes(query) ||
|
e.description.toLowerCase().includes(query) ||
|
||||||
// keywords 是对象数组 { concept, score, ... },需要访问 concept 属性
|
// 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;
|
let nodeName;
|
||||||
if (nodeType === 'company' && industryStock) {
|
if (nodeType === 'company' && industryStock) {
|
||||||
nodeName = industryStock.name;
|
nodeName = industryStock.stock_name;
|
||||||
} else if (nodeType === 'industry') {
|
} else if (nodeType === 'industry') {
|
||||||
nodeName = `${industry}产业`;
|
nodeName = `${industry}产业`;
|
||||||
} else if (nodeType === 'policy') {
|
} else if (nodeType === 'policy') {
|
||||||
@@ -1133,7 +1145,7 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
|
|||||||
const stock = industryStocks[j % industryStocks.length];
|
const stock = industryStocks[j % industryStocks.length];
|
||||||
relatedStocks.push({
|
relatedStocks.push({
|
||||||
stock_code: stock.stock_code,
|
stock_code: stock.stock_code,
|
||||||
stock_name: stock.name,
|
stock_name: stock.stock_name,
|
||||||
relation_desc: relationDescriptions[j % relationDescriptions.length]
|
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)) {
|
if (!relatedStocks.some(s => s.stock_code === randomStock.stock_code)) {
|
||||||
relatedStocks.push({
|
relatedStocks.push({
|
||||||
stock_code: randomStock.stock_code,
|
stock_code: randomStock.stock_code,
|
||||||
stock_name: randomStock.name,
|
stock_name: randomStock.stock_name,
|
||||||
relation_desc: relationDescriptions[relatedStocks.length % relationDescriptions.length]
|
relation_desc: relationDescriptions[relatedStocks.length % relationDescriptions.length]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,73 +10,323 @@ export const generateFinancialData = (stockCode) => {
|
|||||||
|
|
||||||
// 股票基本信息
|
// 股票基本信息
|
||||||
stockInfo: {
|
stockInfo: {
|
||||||
code: stockCode,
|
stock_code: stockCode,
|
||||||
name: stockCode === '000001' ? '平安银行' : '示例公司',
|
stock_name: stockCode === '000001' ? '平安银行' : '示例公司',
|
||||||
industry: stockCode === '000001' ? '银行' : '制造业',
|
industry: stockCode === '000001' ? '银行' : '制造业',
|
||||||
list_date: '1991-04-03',
|
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) => ({
|
balanceSheet: periods.map((period, i) => ({
|
||||||
period,
|
period,
|
||||||
total_assets: 5024560 - i * 50000, // 百万元
|
assets: {
|
||||||
total_liabilities: 4698880 - i * 48000,
|
current_assets: {
|
||||||
shareholders_equity: 325680 - i * 2000,
|
cash: 856780 - i * 10000,
|
||||||
current_assets: 2512300 - i * 25000,
|
trading_financial_assets: 234560 - i * 5000,
|
||||||
non_current_assets: 2512260 - i * 25000,
|
notes_receivable: 12340 - i * 200,
|
||||||
current_liabilities: 3456780 - i * 35000,
|
accounts_receivable: 45670 - i * 1000,
|
||||||
non_current_liabilities: 1242100 - i * 13000
|
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) => ({
|
incomeStatement: periods.map((period, i) => ({
|
||||||
period,
|
period,
|
||||||
revenue: 162350 - i * 4000, // 百万元
|
revenue: {
|
||||||
operating_cost: 45620 - i * 1200,
|
total_operating_revenue: 162350 - i * 4000,
|
||||||
gross_profit: 116730 - i * 2800,
|
operating_revenue: 158900 - i * 3900,
|
||||||
operating_profit: 68450 - i * 1500,
|
other_income: 3450 - i * 100
|
||||||
net_profit: 52860 - i * 1200,
|
},
|
||||||
eps: 2.72 - i * 0.06
|
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) => ({
|
cashflow: periods.map((period, i) => ({
|
||||||
period,
|
period,
|
||||||
operating_cashflow: 125600 - i * 3000, // 百万元
|
operating_activities: {
|
||||||
investing_cashflow: -45300 - i * 1000,
|
inflow: {
|
||||||
financing_cashflow: -38200 + i * 500,
|
cash_from_sales: 178500 - i * 4500
|
||||||
net_cashflow: 42100 - i * 1500,
|
},
|
||||||
cash_ending: 456780 - i * 10000
|
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) => ({
|
financialMetrics: periods.map((period, i) => ({
|
||||||
period,
|
period,
|
||||||
roe: 16.23 - i * 0.3, // %
|
profitability: {
|
||||||
roa: 1.05 - i * 0.02,
|
roe: 16.23 - i * 0.3,
|
||||||
gross_margin: 71.92 - i * 0.5,
|
roe_deducted: 15.89 - i * 0.3,
|
||||||
net_margin: 32.56 - i * 0.3,
|
roe_weighted: 16.45 - i * 0.3,
|
||||||
current_ratio: 0.73 + i * 0.01,
|
roa: 1.05 - i * 0.02,
|
||||||
quick_ratio: 0.71 + i * 0.01,
|
gross_margin: 71.92 - i * 0.5,
|
||||||
debt_ratio: 93.52 + i * 0.05,
|
net_profit_margin: 32.56 - i * 0.3,
|
||||||
asset_turnover: 0.41 - i * 0.01,
|
operating_profit_margin: 42.16 - i * 0.4,
|
||||||
inventory_turnover: 0, // 银行无库存
|
cost_profit_ratio: 115.8 - i * 1.2,
|
||||||
receivable_turnover: 0 // 银行特殊
|
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: {
|
mainBusiness: {
|
||||||
by_product: [
|
product_classification: [
|
||||||
{ name: '对公业务', revenue: 68540, ratio: 42.2, yoy_growth: 6.8 },
|
{
|
||||||
{ name: '零售业务', revenue: 81320, ratio: 50.1, yoy_growth: 11.2 },
|
period: '2024-09-30',
|
||||||
{ name: '金融市场业务', revenue: 12490, ratio: 7.7, yoy_growth: 3.5 }
|
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: [
|
industry_classification: [
|
||||||
{ name: '华南地区', revenue: 56800, ratio: 35.0, yoy_growth: 9.2 },
|
{
|
||||||
{ name: '华东地区', revenue: 48705, ratio: 30.0, yoy_growth: 8.5 },
|
period: '2024-09-30',
|
||||||
{ name: '华北地区', revenue: 32470, ratio: 20.0, yoy_growth: 7.8 },
|
report_type: '2024年三季报',
|
||||||
{ name: '其他地区', revenue: 24375, ratio: 15.0, yoy_growth: 6.5 }
|
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'
|
publish_date: '2024-10-15'
|
||||||
},
|
},
|
||||||
|
|
||||||
// 行业排名
|
// 行业排名(数组格式,符合 IndustryRankingView 组件要求)
|
||||||
industryRank: {
|
industryRank: [
|
||||||
industry: '银行',
|
{
|
||||||
total_companies: 42,
|
period: '2024-09-30',
|
||||||
rankings: [
|
report_type: '三季报',
|
||||||
{ metric: '总资产', rank: 8, value: 5024560, percentile: 19 },
|
rankings: [
|
||||||
{ metric: '营业收入', rank: 9, value: 162350, percentile: 21 },
|
{
|
||||||
{ metric: '净利润', rank: 8, value: 52860, percentile: 19 },
|
industry_name: stockCode === '000001' ? '银行' : '制造业',
|
||||||
{ metric: 'ROE', rank: 12, value: 16.23, percentile: 29 },
|
level_description: '一级行业',
|
||||||
{ metric: '不良贷款率', rank: 18, value: 1.02, percentile: 43 }
|
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: {
|
periodComparison: [
|
||||||
periods: ['Q3-2024', 'Q2-2024', 'Q1-2024', 'Q4-2023'],
|
{
|
||||||
metrics: [
|
period: '2024-09-30',
|
||||||
{
|
performance: {
|
||||||
name: '营业收入',
|
revenue: 41500000000, // 415亿
|
||||||
unit: '百万元',
|
net_profit: 13420000000 // 134.2亿
|
||||||
values: [41500, 40800, 40200, 40850],
|
|
||||||
yoy: [8.2, 7.8, 8.5, 9.2]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '净利润',
|
|
||||||
unit: '百万元',
|
|
||||||
values: [13420, 13180, 13050, 13210],
|
|
||||||
yoy: [12.5, 11.2, 10.8, 12.3]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ROE',
|
|
||||||
unit: '%',
|
|
||||||
values: [16.23, 15.98, 15.75, 16.02],
|
|
||||||
yoy: [1.2, 0.8, 0.5, 1.0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'EPS',
|
|
||||||
unit: '元',
|
|
||||||
values: [0.69, 0.68, 0.67, 0.68],
|
|
||||||
yoy: [12.3, 11.5, 10.5, 12.0]
|
|
||||||
}
|
}
|
||||||
]
|
},
|
||||||
}
|
{
|
||||||
|
period: '2024-06-30',
|
||||||
|
performance: {
|
||||||
|
revenue: 40800000000, // 408亿
|
||||||
|
net_profit: 13180000000 // 131.8亿
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: '2024-03-31',
|
||||||
|
performance: {
|
||||||
|
revenue: 40200000000, // 402亿
|
||||||
|
net_profit: 13050000000 // 130.5亿
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: '2023-12-31',
|
||||||
|
performance: {
|
||||||
|
revenue: 40850000000, // 408.5亿
|
||||||
|
net_profit: 13210000000 // 132.1亿
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: '2023-09-30',
|
||||||
|
performance: {
|
||||||
|
revenue: 38500000000, // 385亿
|
||||||
|
net_profit: 11920000000 // 119.2亿
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: '2023-06-30',
|
||||||
|
performance: {
|
||||||
|
revenue: 37800000000, // 378亿
|
||||||
|
net_profit: 11850000000 // 118.5亿
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ export const generateMarketData = (stockCode) => {
|
|||||||
low: parseFloat(low.toFixed(2)),
|
low: parseFloat(low.toFixed(2)),
|
||||||
volume: Math.floor(Math.random() * 500000000) + 100000000, // 1-6亿股
|
volume: Math.floor(Math.random() * 500000000) + 100000000, // 1-6亿股
|
||||||
amount: Math.floor(Math.random() * 7000000000) + 1300000000, // 13-80亿元
|
amount: Math.floor(Math.random() * 7000000000) + 1300000000, // 13-80亿元
|
||||||
turnover_rate: (Math.random() * 2 + 0.5).toFixed(2), // 0.5-2.5%
|
turnover_rate: parseFloat((Math.random() * 2 + 0.5).toFixed(2)), // 0.5-2.5%
|
||||||
change_pct: (Math.random() * 6 - 3).toFixed(2) // -3% to +3%
|
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: {
|
pledgeData: {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: Array(12).fill(null).map((_, i) => {
|
||||||
total_pledged: 25.6, // 质押比例%
|
const date = new Date();
|
||||||
major_shareholders: [
|
date.setMonth(date.getMonth() - (11 - i));
|
||||||
{ name: '中国平安保险集团', pledged_shares: 0, total_shares: 10168542300, pledge_ratio: 0 },
|
return {
|
||||||
{ name: '深圳市投资控股', pledged_shares: 50000000, total_shares: 382456100, pledge_ratio: 13.08 }
|
end_date: date.toISOString().split('T')[0].slice(0, 7) + '-01',
|
||||||
],
|
unrestricted_pledge: Math.floor(Math.random() * 1000000000) + 500000000,
|
||||||
update_date: '2024-09-30'
|
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: {
|
summaryData: {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
current_price: basePrice,
|
stock_code: stockCode,
|
||||||
change: 0.25,
|
stock_name: stockCode === '000001' ? '平安银行' : '示例股票',
|
||||||
change_pct: 1.89,
|
latest_trade: {
|
||||||
open: 13.35,
|
close: basePrice,
|
||||||
high: 13.68,
|
change_percent: 1.89,
|
||||||
low: 13.28,
|
volume: 345678900,
|
||||||
volume: 345678900,
|
amount: 4678900000,
|
||||||
amount: 4678900000,
|
turnover_rate: 1.78,
|
||||||
turnover_rate: 1.78,
|
pe_ratio: 4.96
|
||||||
pe_ratio: 4.96,
|
},
|
||||||
pb_ratio: 0.72,
|
latest_funding: {
|
||||||
total_market_cap: 262300000000,
|
financing_balance: 5823000000,
|
||||||
circulating_market_cap: 262300000000
|
securities_balance: 125600000
|
||||||
|
},
|
||||||
|
latest_pledge: {
|
||||||
|
pledge_ratio: 8.25
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -131,26 +141,57 @@ export const generateMarketData = (stockCode) => {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
// 最新分时数据
|
// 最新分时数据 - 匹配 MinuteData 类型
|
||||||
latestMinuteData: {
|
latestMinuteData: {
|
||||||
success: true,
|
success: true,
|
||||||
data: Array(240).fill(null).map((_, i) => {
|
data: (() => {
|
||||||
const minute = 9 * 60 + 30 + i; // 从9:30开始
|
const minuteData = [];
|
||||||
const hour = Math.floor(minute / 60);
|
// 上午 9:30-11:30 (120分钟)
|
||||||
const min = minute % 60;
|
for (let i = 0; i < 120; i++) {
|
||||||
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
|
const hour = 9 + Math.floor((30 + i) / 60);
|
||||||
const randomChange = (Math.random() - 0.5) * 0.1;
|
const min = (30 + i) % 60;
|
||||||
return {
|
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
|
||||||
time,
|
const randomChange = (Math.random() - 0.5) * 0.1;
|
||||||
price: (basePrice + randomChange).toFixed(2),
|
const open = parseFloat((basePrice + randomChange).toFixed(2));
|
||||||
volume: Math.floor(Math.random() * 2000000) + 500000,
|
const close = parseFloat((basePrice + randomChange + (Math.random() - 0.5) * 0.05).toFixed(2));
|
||||||
avg_price: (basePrice + randomChange * 0.8).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,
|
code: stockCode,
|
||||||
name: stockCode === '000001' ? '平安银行' : '示例股票',
|
name: stockCode === '000001' ? '平安银行' : '示例股票',
|
||||||
trade_date: new Date().toISOString().split('T')[0],
|
trade_date: new Date().toISOString().split('T')[0],
|
||||||
type: 'minute'
|
type: '1min'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
// src/mocks/handlers/bytedesk.js
|
// src/mocks/handlers/bytedesk.js
|
||||||
/**
|
/**
|
||||||
* Bytedesk 客服 Widget MSW Handler
|
* Bytedesk 客服 Widget MSW Handler
|
||||||
* 使用 passthrough 让请求通过到真实服务器,消除 MSW 警告
|
* Mock 模式下返回模拟数据
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { http, passthrough } from 'msw';
|
import { http, HttpResponse, passthrough } from 'msw';
|
||||||
|
|
||||||
export const bytedeskHandlers = [
|
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/*', () => {
|
http.all('/bytedesk/*', () => {
|
||||||
return passthrough();
|
return HttpResponse.json({
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Bytedesk 外部 CDN/服务请求
|
// Bytedesk 外部 CDN/服务请求
|
||||||
|
|||||||
@@ -119,9 +119,12 @@ export const eventHandlers = [
|
|||||||
try {
|
try {
|
||||||
const result = generateMockEvents(params);
|
const result = generateMockEvents(params);
|
||||||
|
|
||||||
|
// 返回格式兼容 NewsPanel 期望的结构
|
||||||
|
// NewsPanel 期望: { success, data: [], pagination: {} }
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: result,
|
data: result.events, // 事件数组
|
||||||
|
pagination: result.pagination, // 分页信息
|
||||||
message: '获取成功'
|
message: '获取成功'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -135,16 +138,14 @@ export const eventHandlers = [
|
|||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: '获取事件列表失败',
|
error: '获取事件列表失败',
|
||||||
data: {
|
data: [],
|
||||||
events: [],
|
pagination: {
|
||||||
pagination: {
|
page: 1,
|
||||||
page: 1,
|
per_page: 10,
|
||||||
per_page: 10,
|
total: 0,
|
||||||
total: 0,
|
pages: 0,
|
||||||
pages: 0, // ← 对齐后端字段名
|
has_prev: false,
|
||||||
has_prev: false, // ← 对齐后端
|
has_next: false
|
||||||
has_next: false // ← 对齐后端
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ 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 }) => {
|
http.post('/api/stock/quotes', async ({ request }) => {
|
||||||
await delay(200);
|
await delay(200);
|
||||||
@@ -421,7 +483,19 @@ export const stockHandlers = [
|
|||||||
// 行业和指数标签
|
// 行业和指数标签
|
||||||
industry_l1: industryInfo.industry_l1,
|
industry_l1: industryInfo.industry_l1,
|
||||||
industry: industryInfo.industry,
|
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 { logger } from './logger';
|
||||||
|
import { reportPerformanceMetrics } from '../lib/posthog';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 性能指标接口
|
* 性能指标接口
|
||||||
@@ -208,6 +209,9 @@ class PerformanceMonitor {
|
|||||||
// 性能分析建议
|
// 性能分析建议
|
||||||
this.analyzePerformance();
|
this.analyzePerformance();
|
||||||
|
|
||||||
|
// 上报性能指标到 PostHog
|
||||||
|
reportPerformanceMetrics(this.metrics);
|
||||||
|
|
||||||
return this.metrics;
|
return this.metrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Company 目录结构说明
|
# Company 目录结构说明
|
||||||
|
|
||||||
> 最后更新:2025-12-11
|
> 最后更新:2025-12-17(API 接口清单梳理)
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
@@ -11,24 +11,40 @@ src/views/Company/
|
|||||||
│
|
│
|
||||||
├── components/ # UI 组件
|
├── components/ # UI 组件
|
||||||
│ │
|
│ │
|
||||||
|
│ ├── LoadingState.tsx # 通用加载状态组件
|
||||||
|
│ │
|
||||||
│ ├── CompanyHeader/ # 页面头部
|
│ ├── CompanyHeader/ # 页面头部
|
||||||
│ │ ├── index.js # 组合导出
|
│ │ ├── index.js # 组合导出
|
||||||
│ │ └── SearchBar.js # 股票搜索栏
|
│ │ └── SearchBar.js # 股票搜索栏
|
||||||
│ │
|
│ │
|
||||||
│ ├── CompanyTabs/ # Tab 切换容器
|
│ ├── CompanyTabs/ # Tab 切换容器
|
||||||
│ │ ├── index.js # Tab 容器(状态管理 + 内容渲染)
|
│ │ └── index.js # Tab 容器(状态管理 + 内容渲染)
|
||||||
│ │ └── TabNavigation.js # Tab 导航栏
|
|
||||||
│ │
|
│ │
|
||||||
│ ├── StockQuoteCard/ # 股票行情卡片(TypeScript)
|
│ ├── StockQuoteCard/ # 股票行情卡片(TypeScript,数据已下沉)
|
||||||
│ │ ├── index.tsx # 主组件
|
│ │ ├── index.tsx # 主组件(Props 从 11 个精简为 4 个)
|
||||||
│ │ ├── types.ts # 类型定义
|
│ │ ├── types.ts # 类型定义
|
||||||
│ │ └── mockData.ts # Mock 数据
|
│ │ ├── 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)
|
│ ├── CompanyOverview/ # Tab: 公司概览(TypeScript)
|
||||||
│ │ ├── index.tsx # 主组件(组合层)
|
│ │ ├── index.tsx # 主组件(组合层)
|
||||||
│ │ ├── types.ts # 类型定义
|
│ │ ├── types.ts # 类型定义
|
||||||
│ │ ├── utils.ts # 格式化工具
|
│ │ ├── utils.ts # 格式化工具
|
||||||
│ │ ├── DeepAnalysisTab/ # 深度分析 Tab(21 个 TS 文件)
|
|
||||||
│ │ ├── NewsEventsTab.js # 新闻事件 Tab
|
│ │ ├── NewsEventsTab.js # 新闻事件 Tab
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── hooks/ # 数据 Hooks
|
│ │ ├── hooks/ # 数据 Hooks
|
||||||
@@ -47,29 +63,69 @@ src/views/Company/
|
|||||||
│ │ │ ├── ConcentrationCard.tsx # 股权集中度卡片
|
│ │ │ ├── ConcentrationCard.tsx # 股权集中度卡片
|
||||||
│ │ │ └── ShareholdersTable.tsx # 股东表格
|
│ │ │ └── ShareholdersTable.tsx # 股东表格
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ └── BasicInfoTab/ # 基本信息 Tab(可配置化)
|
│ │ ├── BasicInfoTab/ # 基本信息 Tab(可配置化)
|
||||||
│ │ ├── index.tsx # 主组件(可配置)
|
│ │ │ ├── index.tsx # 主组件(可配置)
|
||||||
│ │ ├── config.ts # Tab 配置 + 黑金主题
|
│ │ │ ├── config.ts # Tab 配置 + 黑金主题
|
||||||
│ │ ├── utils.ts # 格式化工具函数
|
│ │ │ ├── utils.ts # 格式化工具函数
|
||||||
│ │ └── components/ # 子组件
|
│ │ │ └── components/ # 子组件
|
||||||
│ │ ├── index.ts # 组件统一导出
|
│ │ │ ├── index.ts # 组件统一导出
|
||||||
│ │ ├── LoadingState.tsx # 加载状态组件
|
│ │ │ ├── LoadingState.tsx # 加载状态组件
|
||||||
│ │ ├── ShareholderPanel.tsx # 股权结构面板
|
│ │ │ ├── ShareholderPanel.tsx # 股权结构面板
|
||||||
│ │ ├── AnnouncementsPanel.tsx # 公告信息面板
|
│ │ │ ├── AnnouncementsPanel.tsx # 公告信息面板
|
||||||
│ │ ├── BranchesPanel.tsx # 分支机构面板
|
│ │ │ ├── BranchesPanel.tsx # 分支机构面板
|
||||||
│ │ ├── BusinessInfoPanel.tsx # 工商信息面板
|
│ │ │ ├── BusinessInfoPanel.tsx # 工商信息面板
|
||||||
│ │ ├── DisclosureSchedulePanel.tsx # 披露日程面板
|
│ │ │ ├── DisclosureSchedulePanel.tsx # 披露日程面板
|
||||||
│ │ └── management/ # 管理团队模块
|
│ │ │ └── management/ # 管理团队模块
|
||||||
│ │ ├── index.ts # 模块导出
|
│ │ │ ├── index.ts # 模块导出
|
||||||
│ │ ├── types.ts # 类型定义
|
│ │ │ ├── types.ts # 类型定义
|
||||||
│ │ ├── ManagementPanel.tsx # 主组件(useMemo)
|
│ │ │ ├── ManagementPanel.tsx # 主组件(useMemo)
|
||||||
│ │ ├── CategorySection.tsx # 分类区块(memo)
|
│ │ │ ├── CategorySection.tsx # 分类区块(memo)
|
||||||
│ │ └── ManagementCard.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)
|
│ ├── MarketDataView/ # Tab: 股票行情(TypeScript)
|
||||||
│ │ ├── index.tsx # 主组件入口
|
│ │ ├── index.tsx # 主组件入口
|
||||||
│ │ ├── types.ts # 类型定义
|
│ │ ├── types.ts # 类型定义
|
||||||
│ │ ├── constants.ts # 主题配置、常量
|
│ │ ├── constants.ts # 主题配置(含黑金主题 darkGoldTheme)
|
||||||
│ │ ├── services/
|
│ │ ├── services/
|
||||||
│ │ │ └── marketService.ts # API 服务层
|
│ │ │ └── marketService.ts # API 服务层
|
||||||
│ │ ├── hooks/
|
│ │ ├── hooks/
|
||||||
@@ -81,26 +137,92 @@ src/views/Company/
|
|||||||
│ │ ├── index.ts # 组件导出
|
│ │ ├── index.ts # 组件导出
|
||||||
│ │ ├── ThemedCard.tsx # 主题化卡片
|
│ │ ├── ThemedCard.tsx # 主题化卡片
|
||||||
│ │ ├── MarkdownRenderer.tsx # Markdown 渲染
|
│ │ ├── 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
|
||||||
│ │
|
│ │
|
||||||
│ ├── DeepAnalysis/ # Tab: 深度分析
|
│ ├── DeepAnalysis/ # Tab: 深度分析(入口)
|
||||||
│ │ └── index.js
|
│ │ └── index.js
|
||||||
│ │
|
│ │
|
||||||
│ ├── DynamicTracking/ # Tab: 动态跟踪
|
│ ├── DynamicTracking/ # Tab: 动态跟踪
|
||||||
│ │ └── index.js
|
│ │ ├── index.js # 主组件
|
||||||
|
│ │ └── components/
|
||||||
|
│ │ ├── index.js # 组件导出
|
||||||
|
│ │ ├── NewsPanel.js # 新闻面板
|
||||||
|
│ │ └── ForecastPanel.js # 业绩预告面板
|
||||||
│ │
|
│ │
|
||||||
│ ├── FinancialPanorama/ # Tab: 财务全景(待拆分)
|
│ ├── FinancialPanorama/ # Tab: 财务全景(TypeScript 模块化)
|
||||||
│ │ └── index.js
|
│ │ ├── 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: 盈利预测(待拆分)
|
│ └── ForecastReport/ # Tab: 盈利预测(TypeScript,已模块化)
|
||||||
│ └── index.js
|
│ ├── 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 同步)
|
│ ├── useCompanyStock.js # 股票代码管理(URL 同步)
|
||||||
│ ├── useCompanyWatchlist.js # 自选股管理(Redux 集成)
|
│ ├── useCompanyWatchlist.js # 自选股管理(Redux 集成)
|
||||||
│ ├── useCompanyEvents.js # PostHog 事件追踪
|
│ └── useCompanyEvents.js # PostHog 事件追踪
|
||||||
│ └── useStockQuote.js # 股票行情数据 Hook
|
│ # 注:useStockQuote.js 已下沉到 StockQuoteCard/hooks/useStockQuoteData.ts
|
||||||
│
|
│
|
||||||
└── constants/ # 常量定义
|
└── constants/ # 常量定义
|
||||||
└── index.js # Tab 配置、Toast 消息、默认值
|
└── index.js # Tab 配置、Toast 消息、默认值
|
||||||
@@ -108,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` - 页面入口
|
#### `index.js` - 页面入口
|
||||||
- **职责**:纯组合层,协调 Hooks 和 Components
|
- **职责**:纯组合层,协调 Hooks 和 Components
|
||||||
- **代码行数**:95 行
|
- **代码行数**:~105 行(2025-12-17 优化后精简)
|
||||||
- **依赖**:
|
- **依赖**:
|
||||||
- `useCompanyStock` - 股票代码状态
|
- `useCompanyStock` - 股票代码状态
|
||||||
- `useCompanyWatchlist` - 自选股状态
|
- `useCompanyWatchlist` - 自选股状态
|
||||||
- `useCompanyEvents` - 事件追踪
|
- `useCompanyEvents` - 事件追踪
|
||||||
- `CompanyHeader` - 页面头部
|
- `CompanyHeader` - 页面头部
|
||||||
|
- `StockQuoteCard` - 股票行情卡片(内部自行获取数据)
|
||||||
- `CompanyTabs` - Tab 切换区
|
- `CompanyTabs` - Tab 切换区
|
||||||
|
- **已移除**(2025-12-17):
|
||||||
|
- `useStockQuote` - 已下沉到 StockQuoteCard
|
||||||
|
- `useBasicInfo` - 已下沉到 StockQuoteCard
|
||||||
|
- 股票对比逻辑 - 已下沉到 StockQuoteCard
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -431,7 +635,7 @@ CompanyOverview/
|
|||||||
**拆分后文件结构**:
|
**拆分后文件结构**:
|
||||||
```
|
```
|
||||||
MarketDataView/
|
MarketDataView/
|
||||||
├── index.tsx # 主组件入口(~1049 行)
|
├── index.tsx # 主组件入口(~285 行,Tab 容器)
|
||||||
├── types.ts # 类型定义(~383 行)
|
├── types.ts # 类型定义(~383 行)
|
||||||
├── constants.ts # 主题配置、常量(~49 行)
|
├── constants.ts # 主题配置、常量(~49 行)
|
||||||
├── services/
|
├── services/
|
||||||
@@ -446,14 +650,21 @@ MarketDataView/
|
|||||||
├── ThemedCard.tsx # 主题化卡片(~32 行)
|
├── ThemedCard.tsx # 主题化卡片(~32 行)
|
||||||
├── MarkdownRenderer.tsx # Markdown 渲染(~65 行)
|
├── MarkdownRenderer.tsx # Markdown 渲染(~65 行)
|
||||||
├── StockSummaryCard.tsx # 股票概览卡片(~133 行)
|
├── 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 等) |
|
| `types.ts` | ~383 | 所有 TypeScript 类型定义(Theme、TradeDayData、MinuteData、FundingData 等) |
|
||||||
| `constants.ts` | ~49 | 主题配置(light/dark)、周期选项常量 |
|
| `constants.ts` | ~49 | 主题配置(light/dark)、周期选项常量 |
|
||||||
| `marketService.ts` | ~173 | API 服务封装(getMarketData、getMinuteData、getBigDealData 等) |
|
| `marketService.ts` | ~173 | API 服务封装(getMarketData、getMinuteData、getBigDealData 等) |
|
||||||
@@ -632,4 +843,411 @@ index.tsx
|
|||||||
- **原子设计模式**:atoms(基础元素)→ components(区块)→ organisms(复杂交互)
|
- **原子设计模式**:atoms(基础元素)→ components(区块)→ organisms(复杂交互)
|
||||||
- **TypeScript 类型安全**:完整的接口定义,消除 any 类型
|
- **TypeScript 类型安全**:完整的接口定义,消除 any 类型
|
||||||
- **职责分离**:UI 渲染与 API 调用分离,模态框独立管理
|
- **职责分离**:UI 渲染与 API 调用分离,模态框独立管理
|
||||||
- **代码复用**:DisclaimerBox、ScoreBar 等原子组件多处复用
|
- **代码复用**: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,
|
VStack,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { SearchIcon } from '@chakra-ui/icons';
|
import { SearchIcon } from '@chakra-ui/icons';
|
||||||
|
import { useStockSearch } from '../../hooks/useStockSearch';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 股票搜索栏组件(带模糊搜索下拉)
|
* 股票搜索栏组件(带模糊搜索下拉)
|
||||||
@@ -31,27 +32,18 @@ const SearchBar = ({
|
|||||||
}) => {
|
}) => {
|
||||||
// 下拉状态
|
// 下拉状态
|
||||||
const [showDropdown, setShowDropdown] = useState(false);
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
const [filteredStocks, setFilteredStocks] = useState([]);
|
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
// 从 Redux 获取全部股票列表
|
// 从 Redux 获取全部股票列表
|
||||||
const allStocks = useSelector(state => state.stock.allStocks);
|
const allStocks = useSelector(state => state.stock.allStocks);
|
||||||
|
|
||||||
// 模糊搜索过滤
|
// 使用共享的搜索 Hook
|
||||||
|
const filteredStocks = useStockSearch(allStocks, inputCode, { limit: 10 });
|
||||||
|
|
||||||
|
// 根据搜索结果更新下拉显示状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inputCode && inputCode.trim()) {
|
setShowDropdown(filteredStocks.length > 0 && !!inputCode?.trim());
|
||||||
const searchTerm = inputCode.trim().toLowerCase();
|
}, [filteredStocks, inputCode]);
|
||||||
const filtered = allStocks.filter(stock =>
|
|
||||||
stock.code.toLowerCase().includes(searchTerm) ||
|
|
||||||
stock.name.includes(inputCode.trim())
|
|
||||||
).slice(0, 10); // 限制显示10条
|
|
||||||
setFilteredStocks(filtered);
|
|
||||||
setShowDropdown(filtered.length > 0);
|
|
||||||
} else {
|
|
||||||
setFilteredStocks([]);
|
|
||||||
setShowDropdown(false);
|
|
||||||
}
|
|
||||||
}, [inputCode, allStocks]);
|
|
||||||
|
|
||||||
// 点击外部关闭下拉
|
// 点击外部关闭下拉
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
HStack,
|
HStack,
|
||||||
Text,
|
Text,
|
||||||
Badge,
|
Badge,
|
||||||
Icon,
|
|
||||||
Card,
|
Card,
|
||||||
CardBody,
|
CardBody,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -23,7 +22,6 @@ import {
|
|||||||
ModalFooter,
|
ModalFooter,
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { FaBullhorn } from "react-icons/fa";
|
|
||||||
import { ExternalLinkIcon } from "@chakra-ui/icons";
|
import { ExternalLinkIcon } from "@chakra-ui/icons";
|
||||||
|
|
||||||
import { useAnnouncementsData } from "../../hooks/useAnnouncementsData";
|
import { useAnnouncementsData } from "../../hooks/useAnnouncementsData";
|
||||||
@@ -55,10 +53,6 @@ const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode }) =>
|
|||||||
<VStack spacing={4} align="stretch">
|
<VStack spacing={4} align="stretch">
|
||||||
{/* 最新公告 */}
|
{/* 最新公告 */}
|
||||||
<Box>
|
<Box>
|
||||||
<HStack mb={3}>
|
|
||||||
<Icon as={FaBullhorn} color={THEME.gold} />
|
|
||||||
<Text fontWeight="bold" color={THEME.textPrimary}>最新公告</Text>
|
|
||||||
</HStack>
|
|
||||||
<VStack spacing={2} align="stretch">
|
<VStack spacing={2} align="stretch">
|
||||||
{announcements.map((announcement: any, idx: number) => (
|
{announcements.map((announcement: any, idx: number) => (
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@@ -12,15 +12,27 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Center,
|
Center,
|
||||||
Code,
|
Code,
|
||||||
|
Spinner,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
|
|
||||||
import { THEME } from "../config";
|
import { THEME } from "../config";
|
||||||
|
import { useBasicInfo } from "../../hooks/useBasicInfo";
|
||||||
|
|
||||||
interface BusinessInfoPanelProps {
|
interface BusinessInfoPanelProps {
|
||||||
basicInfo: any;
|
stockCode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ basicInfo }) => {
|
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) {
|
if (!basicInfo) {
|
||||||
return (
|
return (
|
||||||
<Center h="200px">
|
<Center h="200px">
|
||||||
|
|||||||
@@ -5,15 +5,12 @@ import React from "react";
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
VStack,
|
VStack,
|
||||||
HStack,
|
|
||||||
Text,
|
Text,
|
||||||
Badge,
|
Badge,
|
||||||
Icon,
|
|
||||||
Card,
|
Card,
|
||||||
CardBody,
|
CardBody,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { FaCalendarAlt } from "react-icons/fa";
|
|
||||||
|
|
||||||
import { useDisclosureData } from "../../hooks/useDisclosureData";
|
import { useDisclosureData } from "../../hooks/useDisclosureData";
|
||||||
import { THEME } from "../config";
|
import { THEME } from "../config";
|
||||||
@@ -42,10 +39,6 @@ const DisclosureSchedulePanel: React.FC<DisclosureSchedulePanelProps> = ({ stock
|
|||||||
return (
|
return (
|
||||||
<VStack spacing={4} align="stretch">
|
<VStack spacing={4} align="stretch">
|
||||||
<Box>
|
<Box>
|
||||||
<HStack mb={3}>
|
|
||||||
<Icon as={FaCalendarAlt} color={THEME.gold} />
|
|
||||||
<Text fontWeight="bold" color={THEME.textPrimary}>财报披露日程</Text>
|
|
||||||
</HStack>
|
|
||||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
|
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
|
||||||
{disclosureSchedule.map((schedule: any, idx: number) => (
|
{disclosureSchedule.map((schedule: any, idx: number) => (
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
// Props 类型定义
|
// Props 类型定义
|
||||||
export interface BasicInfoTabProps {
|
export interface BasicInfoTabProps {
|
||||||
stockCode: string;
|
stockCode: string;
|
||||||
basicInfo?: any;
|
|
||||||
|
|
||||||
// 可配置项
|
// 可配置项
|
||||||
enabledTabs?: string[]; // 指定显示哪些 Tab(通过 key)
|
enabledTabs?: string[]; // 指定显示哪些 Tab(通过 key)
|
||||||
@@ -59,7 +58,6 @@ const buildTabsConfig = (enabledKeys?: string[]): SubTabConfig[] => {
|
|||||||
*/
|
*/
|
||||||
const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
|
const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
|
||||||
stockCode,
|
stockCode,
|
||||||
basicInfo,
|
|
||||||
enabledTabs,
|
enabledTabs,
|
||||||
defaultTabIndex = 0,
|
defaultTabIndex = 0,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
@@ -72,7 +70,7 @@ const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
|
|||||||
<CardBody p={0}>
|
<CardBody p={0}>
|
||||||
<SubTabContainer
|
<SubTabContainer
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
componentProps={{ stockCode, basicInfo }}
|
componentProps={{ stockCode }}
|
||||||
defaultIndex={defaultTabIndex}
|
defaultIndex={defaultTabIndex}
|
||||||
onTabChange={onTabChange}
|
onTabChange={onTabChange}
|
||||||
themePreset="blackGold"
|
themePreset="blackGold"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*
|
*
|
||||||
* 递归显示业务结构层级
|
* 递归显示业务结构层级
|
||||||
* 使用位置:业务结构分析卡片
|
* 使用位置:业务结构分析卡片
|
||||||
|
* 黑金主题风格
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -10,9 +11,17 @@ import { Box, HStack, VStack, Text, Badge, Tag, TagLabel } from '@chakra-ui/reac
|
|||||||
import { formatPercentage, formatBusinessRevenue } from '@utils/priceFormatters';
|
import { formatPercentage, formatBusinessRevenue } from '@utils/priceFormatters';
|
||||||
import type { BusinessTreeItemProps } from '../types';
|
import type { BusinessTreeItemProps } from '../types';
|
||||||
|
|
||||||
const BusinessTreeItem: React.FC<BusinessTreeItemProps> = ({ business, depth = 0 }) => {
|
// 黑金主题配置
|
||||||
const bgColor = 'gray.50';
|
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 getRevenueDisplay = (): string => {
|
||||||
const revenue = business.revenue || business.financial_metrics?.revenue;
|
const revenue = business.revenue || business.financial_metrics?.revenue;
|
||||||
@@ -27,40 +36,39 @@ const BusinessTreeItem: React.FC<BusinessTreeItemProps> = ({ business, depth = 0
|
|||||||
<Box
|
<Box
|
||||||
ml={depth * 6}
|
ml={depth * 6}
|
||||||
p={3}
|
p={3}
|
||||||
bg={bgColor}
|
bg={THEME.bg}
|
||||||
borderLeft={depth > 0 ? '4px solid' : 'none'}
|
borderLeft={depth > 0 ? '4px solid' : 'none'}
|
||||||
borderLeftColor="blue.400"
|
borderLeftColor={THEME.gold}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
mb={2}
|
mb={2}
|
||||||
_hover={{ shadow: 'md' }}
|
_hover={{ shadow: 'md', bg: 'gray.600' }}
|
||||||
transition="all 0.2s"
|
transition="all 0.2s"
|
||||||
>
|
>
|
||||||
<HStack justify="space-between">
|
<HStack justify="space-between">
|
||||||
<VStack align="start" spacing={1}>
|
<VStack align="start" spacing={1}>
|
||||||
<HStack>
|
<HStack>
|
||||||
<Text fontWeight="bold" fontSize={depth === 0 ? 'md' : 'sm'}>
|
<Text fontWeight="bold" fontSize={depth === 0 ? 'md' : 'sm'} color={THEME.textPrimary}>
|
||||||
{business.business_name}
|
{business.business_name}
|
||||||
</Text>
|
</Text>
|
||||||
{business.financial_metrics?.revenue_ratio &&
|
{business.financial_metrics?.revenue_ratio &&
|
||||||
business.financial_metrics.revenue_ratio > 30 && (
|
business.financial_metrics.revenue_ratio > 30 && (
|
||||||
<Badge colorScheme="purple" size="sm">
|
<Badge bg={THEME.gold} color="gray.900" size="sm">
|
||||||
核心业务
|
核心业务
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack spacing={4} flexWrap="wrap">
|
<HStack spacing={4} flexWrap="wrap">
|
||||||
<Tag size="sm" variant="subtle">
|
<Tag size="sm" bg="gray.600" color={THEME.textPrimary}>
|
||||||
营收占比: {formatPercentage(business.financial_metrics?.revenue_ratio)}
|
营收占比: {formatPercentage(business.financial_metrics?.revenue_ratio)}
|
||||||
</Tag>
|
</Tag>
|
||||||
<Tag size="sm" variant="subtle">
|
<Tag size="sm" bg="gray.600" color={THEME.textPrimary}>
|
||||||
毛利率: {formatPercentage(business.financial_metrics?.gross_margin)}
|
毛利率: {formatPercentage(business.financial_metrics?.gross_margin)}
|
||||||
</Tag>
|
</Tag>
|
||||||
{business.growth_metrics?.revenue_growth !== undefined && (
|
{business.growth_metrics?.revenue_growth !== undefined && (
|
||||||
<Tag
|
<Tag
|
||||||
size="sm"
|
size="sm"
|
||||||
colorScheme={
|
bg={business.growth_metrics.revenue_growth > 0 ? 'red.600' : 'green.600'}
|
||||||
business.growth_metrics.revenue_growth > 0 ? 'red' : 'green'
|
color="white"
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<TagLabel>
|
<TagLabel>
|
||||||
增长: {business.growth_metrics.revenue_growth > 0 ? '+' : ''}
|
增长: {business.growth_metrics.revenue_growth > 0 ? '+' : ''}
|
||||||
@@ -71,10 +79,10 @@ const BusinessTreeItem: React.FC<BusinessTreeItemProps> = ({ business, depth = 0
|
|||||||
</HStack>
|
</HStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
<VStack align="end" spacing={0}>
|
<VStack align="end" spacing={0}>
|
||||||
<Text fontSize="lg" fontWeight="bold" color="blue.500">
|
<Text fontSize="lg" fontWeight="bold" color={THEME.gold}>
|
||||||
{getRevenueDisplay()}
|
{getRevenueDisplay()}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="xs" color="gray.500">
|
<Text fontSize="xs" color={THEME.textSecondary}>
|
||||||
营业收入
|
营业收入
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*
|
*
|
||||||
* 显示单个关键因素的详细信息
|
* 显示单个关键因素的详细信息
|
||||||
* 使用位置:关键因素 Accordion 内
|
* 使用位置:关键因素 Accordion 内
|
||||||
|
* 黑金主题设计
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -19,6 +20,13 @@ import {
|
|||||||
import { FaArrowUp, FaArrowDown } from 'react-icons/fa';
|
import { FaArrowUp, FaArrowDown } from 'react-icons/fa';
|
||||||
import type { KeyFactorCardProps, ImpactDirection } from '../types';
|
import type { KeyFactorCardProps, ImpactDirection } from '../types';
|
||||||
|
|
||||||
|
// 黑金主题样式常量
|
||||||
|
const THEME = {
|
||||||
|
cardBg: '#252D3A',
|
||||||
|
textColor: '#E2E8F0',
|
||||||
|
subtextColor: '#A0AEC0',
|
||||||
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取影响方向对应的颜色
|
* 获取影响方向对应的颜色
|
||||||
*/
|
*/
|
||||||
@@ -47,31 +55,43 @@ const getImpactLabel = (direction?: ImpactDirection): string => {
|
|||||||
|
|
||||||
const KeyFactorCard: React.FC<KeyFactorCardProps> = ({ factor }) => {
|
const KeyFactorCard: React.FC<KeyFactorCardProps> = ({ factor }) => {
|
||||||
const impactColor = getImpactColor(factor.impact_direction);
|
const impactColor = getImpactColor(factor.impact_direction);
|
||||||
const bgColor = 'white';
|
|
||||||
const borderColor = 'gray.200';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card bg={bgColor} borderColor={borderColor} size="sm">
|
<Card
|
||||||
|
bg={THEME.cardBg}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor="whiteAlpha.100"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
<CardBody p={3}>
|
<CardBody p={3}>
|
||||||
<VStack align="stretch" spacing={2}>
|
<VStack align="stretch" spacing={2}>
|
||||||
<HStack justify="space-between">
|
<HStack justify="space-between">
|
||||||
<Text fontWeight="medium" fontSize="sm">
|
<Text fontWeight="medium" fontSize="sm" color={THEME.textColor}>
|
||||||
{factor.factor_name}
|
{factor.factor_name}
|
||||||
</Text>
|
</Text>
|
||||||
<Badge colorScheme={impactColor} size="sm">
|
<Badge
|
||||||
|
bg="transparent"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={`${impactColor}.400`}
|
||||||
|
color={`${impactColor}.400`}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
{getImpactLabel(factor.impact_direction)}
|
{getImpactLabel(factor.impact_direction)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<Text fontSize="lg" fontWeight="bold" color={`${impactColor}.500`}>
|
<Text fontSize="lg" fontWeight="bold" color={`${impactColor}.400`}>
|
||||||
{factor.factor_value}
|
{factor.factor_value}
|
||||||
{factor.factor_unit && ` ${factor.factor_unit}`}
|
{factor.factor_unit && ` ${factor.factor_unit}`}
|
||||||
</Text>
|
</Text>
|
||||||
{factor.year_on_year !== undefined && (
|
{factor.year_on_year !== undefined && (
|
||||||
<Tag
|
<Tag
|
||||||
size="sm"
|
size="sm"
|
||||||
colorScheme={factor.year_on_year > 0 ? 'red' : 'green'}
|
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
|
<Icon
|
||||||
as={factor.year_on_year > 0 ? FaArrowUp : FaArrowDown}
|
as={factor.year_on_year > 0 ? FaArrowUp : FaArrowDown}
|
||||||
@@ -84,17 +104,17 @@ const KeyFactorCard: React.FC<KeyFactorCardProps> = ({ factor }) => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
{factor.factor_desc && (
|
{factor.factor_desc && (
|
||||||
<Text fontSize="xs" color="gray.600" noOfLines={2}>
|
<Text fontSize="xs" color={THEME.subtextColor} noOfLines={2}>
|
||||||
{factor.factor_desc}
|
{factor.factor_desc}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<HStack justify="space-between">
|
<HStack justify="space-between">
|
||||||
<Text fontSize="xs" color="gray.500">
|
<Text fontSize="xs" color={THEME.subtextColor}>
|
||||||
影响权重: {factor.impact_weight}
|
影响权重: {factor.impact_weight}
|
||||||
</Text>
|
</Text>
|
||||||
{factor.report_period && (
|
{factor.report_period && (
|
||||||
<Text fontSize="xs" color="gray.500">
|
<Text fontSize="xs" color={THEME.subtextColor}>
|
||||||
{factor.report_period}
|
{factor.report_period}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,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;
|
||||||
@@ -8,3 +8,7 @@ export { default as DisclaimerBox } from './DisclaimerBox';
|
|||||||
export { default as ScoreBar } from './ScoreBar';
|
export { default as ScoreBar } from './ScoreBar';
|
||||||
export { default as BusinessTreeItem } from './BusinessTreeItem';
|
export { default as BusinessTreeItem } from './BusinessTreeItem';
|
||||||
export { default as KeyFactorCard } from './KeyFactorCard';
|
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';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* 业务板块详情卡片
|
* 业务板块详情卡片
|
||||||
*
|
*
|
||||||
* 显示公司各业务板块的详细信息
|
* 显示公司各业务板块的详细信息
|
||||||
|
* 黑金主题风格
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -20,9 +21,19 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { FaIndustry, FaExpandAlt, FaCompressAlt } from 'react-icons/fa';
|
import { FaIndustry, FaExpandAlt, FaCompressAlt } from 'react-icons/fa';
|
||||||
import { DisclaimerBox } from '../atoms';
|
|
||||||
import type { BusinessSegment } from '../types';
|
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 {
|
interface BusinessSegmentsCardProps {
|
||||||
businessSegments: BusinessSegment[];
|
businessSegments: BusinessSegment[];
|
||||||
expandedSegments: Record<number, boolean>;
|
expandedSegments: Record<number, boolean>;
|
||||||
@@ -34,31 +45,29 @@ const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
|
|||||||
businessSegments,
|
businessSegments,
|
||||||
expandedSegments,
|
expandedSegments,
|
||||||
onToggleSegment,
|
onToggleSegment,
|
||||||
cardBg,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (!businessSegments || businessSegments.length === 0) return null;
|
if (!businessSegments || businessSegments.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card bg={cardBg} shadow="md">
|
<Card bg={THEME.cardBg} shadow="md">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<HStack>
|
<HStack>
|
||||||
<Icon as={FaIndustry} color="indigo.500" />
|
<Icon as={FaIndustry} color={THEME.gold} />
|
||||||
<Heading size="sm">业务板块详情</Heading>
|
<Heading size="sm" color={THEME.textPrimary}>业务板块详情</Heading>
|
||||||
<Badge>{businessSegments.length} 个板块</Badge>
|
<Badge bg={THEME.gold} color="gray.900">{businessSegments.length} 个板块</Badge>
|
||||||
</HStack>
|
</HStack>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody px={2}>
|
||||||
<DisclaimerBox />
|
|
||||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||||
{businessSegments.map((segment, idx) => {
|
{businessSegments.map((segment, idx) => {
|
||||||
const isExpanded = expandedSegments[idx];
|
const isExpanded = expandedSegments[idx];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={idx} variant="outline">
|
<Card key={idx} bg={THEME.innerCardBg}>
|
||||||
<CardBody>
|
<CardBody px={2}>
|
||||||
<VStack align="stretch" spacing={3}>
|
<VStack align="stretch" spacing={3}>
|
||||||
<HStack justify="space-between">
|
<HStack justify="space-between">
|
||||||
<Text fontWeight="bold" fontSize="md">
|
<Text fontWeight="bold" fontSize="md" color={THEME.textPrimary}>
|
||||||
{segment.segment_name}
|
{segment.segment_name}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
@@ -68,18 +77,20 @@ const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
|
|||||||
<Icon as={isExpanded ? FaCompressAlt : FaExpandAlt} />
|
<Icon as={isExpanded ? FaCompressAlt : FaExpandAlt} />
|
||||||
}
|
}
|
||||||
onClick={() => onToggleSegment(idx)}
|
onClick={() => onToggleSegment(idx)}
|
||||||
colorScheme="blue"
|
color={THEME.gold}
|
||||||
|
_hover={{ bg: 'gray.600' }}
|
||||||
>
|
>
|
||||||
{isExpanded ? '折叠' : '展开'}
|
{isExpanded ? '折叠' : '展开'}
|
||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text fontSize="xs" color="gray.500" mb={1}>
|
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||||
业务描述
|
业务描述
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
|
color={THEME.textPrimary}
|
||||||
noOfLines={isExpanded ? undefined : 3}
|
noOfLines={isExpanded ? undefined : 3}
|
||||||
>
|
>
|
||||||
{segment.segment_description || '暂无描述'}
|
{segment.segment_description || '暂无描述'}
|
||||||
@@ -87,11 +98,12 @@ const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text fontSize="xs" color="gray.500" mb={1}>
|
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||||
竞争地位
|
竞争地位
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
|
color={THEME.textPrimary}
|
||||||
noOfLines={isExpanded ? undefined : 2}
|
noOfLines={isExpanded ? undefined : 2}
|
||||||
>
|
>
|
||||||
{segment.competitive_position || '暂无数据'}
|
{segment.competitive_position || '暂无数据'}
|
||||||
@@ -99,13 +111,13 @@ const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text fontSize="xs" color="gray.500" mb={1}>
|
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||||
未来潜力
|
未来潜力
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
noOfLines={isExpanded ? undefined : 2}
|
noOfLines={isExpanded ? undefined : 2}
|
||||||
color="blue.600"
|
color={THEME.goldLight}
|
||||||
>
|
>
|
||||||
{segment.future_potential || '暂无数据'}
|
{segment.future_potential || '暂无数据'}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -113,10 +125,10 @@ const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
|
|||||||
|
|
||||||
{isExpanded && segment.key_products && (
|
{isExpanded && segment.key_products && (
|
||||||
<Box>
|
<Box>
|
||||||
<Text fontSize="xs" color="gray.500" mb={1}>
|
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||||
主要产品
|
主要产品
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="sm" color="green.600">
|
<Text fontSize="sm" color="green.300">
|
||||||
{segment.key_products}
|
{segment.key_products}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -124,10 +136,10 @@ const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
|
|||||||
|
|
||||||
{isExpanded && segment.market_share !== undefined && (
|
{isExpanded && segment.market_share !== undefined && (
|
||||||
<Box>
|
<Box>
|
||||||
<Text fontSize="xs" color="gray.500" mb={1}>
|
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||||
市场份额
|
市场份额
|
||||||
</Text>
|
</Text>
|
||||||
<Badge colorScheme="purple" fontSize="sm">
|
<Badge bg="purple.600" color="white" fontSize="sm">
|
||||||
{segment.market_share}%
|
{segment.market_share}%
|
||||||
</Badge>
|
</Badge>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -135,10 +147,10 @@ const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
|
|||||||
|
|
||||||
{isExpanded && segment.revenue_contribution !== undefined && (
|
{isExpanded && segment.revenue_contribution !== undefined && (
|
||||||
<Box>
|
<Box>
|
||||||
<Text fontSize="xs" color="gray.500" mb={1}>
|
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||||
营收贡献
|
营收贡献
|
||||||
</Text>
|
</Text>
|
||||||
<Badge colorScheme="orange" fontSize="sm">
|
<Badge bg={THEME.gold} color="gray.900" fontSize="sm">
|
||||||
{segment.revenue_contribution}%
|
{segment.revenue_contribution}%
|
||||||
</Badge>
|
</Badge>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* 业务结构分析卡片
|
* 业务结构分析卡片
|
||||||
*
|
*
|
||||||
* 显示公司业务结构树形图
|
* 显示公司业务结构树形图
|
||||||
|
* 黑金主题风格
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -16,9 +17,17 @@ import {
|
|||||||
Icon,
|
Icon,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { FaChartPie } from 'react-icons/fa';
|
import { FaChartPie } from 'react-icons/fa';
|
||||||
import { DisclaimerBox, BusinessTreeItem } from '../atoms';
|
import { BusinessTreeItem } from '../atoms';
|
||||||
import type { BusinessStructure } from '../types';
|
import type { BusinessStructure } from '../types';
|
||||||
|
|
||||||
|
// 黑金主题配置
|
||||||
|
const THEME = {
|
||||||
|
cardBg: 'gray.800',
|
||||||
|
gold: '#D4AF37',
|
||||||
|
textPrimary: '#D4AF37',
|
||||||
|
border: 'rgba(212, 175, 55, 0.3)',
|
||||||
|
};
|
||||||
|
|
||||||
interface BusinessStructureCardProps {
|
interface BusinessStructureCardProps {
|
||||||
businessStructure: BusinessStructure[];
|
businessStructure: BusinessStructure[];
|
||||||
cardBg?: string;
|
cardBg?: string;
|
||||||
@@ -26,21 +35,19 @@ interface BusinessStructureCardProps {
|
|||||||
|
|
||||||
const BusinessStructureCard: React.FC<BusinessStructureCardProps> = ({
|
const BusinessStructureCard: React.FC<BusinessStructureCardProps> = ({
|
||||||
businessStructure,
|
businessStructure,
|
||||||
cardBg,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (!businessStructure || businessStructure.length === 0) return null;
|
if (!businessStructure || businessStructure.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card bg={cardBg} shadow="md">
|
<Card bg={THEME.cardBg} shadow="md">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<HStack>
|
<HStack>
|
||||||
<Icon as={FaChartPie} color="purple.500" />
|
<Icon as={FaChartPie} color={THEME.gold} />
|
||||||
<Heading size="sm">业务结构分析</Heading>
|
<Heading size="sm" color={THEME.textPrimary}>业务结构分析</Heading>
|
||||||
<Badge>{businessStructure[0]?.report_period}</Badge>
|
<Badge bg={THEME.gold} color="gray.900">{businessStructure[0]?.report_period}</Badge>
|
||||||
</HStack>
|
</HStack>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody px={0}>
|
||||||
<DisclaimerBox />
|
|
||||||
<VStack spacing={3} align="stretch">
|
<VStack spacing={3} align="stretch">
|
||||||
{businessStructure.map((business, idx) => (
|
{businessStructure.map((business, idx) => (
|
||||||
<BusinessTreeItem
|
<BusinessTreeItem
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* 竞争地位分析卡片
|
* 竞争地位分析卡片
|
||||||
*
|
*
|
||||||
* 显示竞争力评分、雷达图和竞争分析
|
* 显示竞争力评分、雷达图和竞争分析
|
||||||
|
* 包含行业排名弹窗功能
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { memo, useMemo } from 'react';
|
import React, { memo, useMemo } from 'react';
|
||||||
@@ -22,6 +23,14 @@ import {
|
|||||||
Icon,
|
Icon,
|
||||||
Divider,
|
Divider,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
useDisclosure,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import {
|
import {
|
||||||
FaTrophy,
|
FaTrophy,
|
||||||
@@ -33,11 +42,32 @@ import {
|
|||||||
FaShieldAlt,
|
FaShieldAlt,
|
||||||
FaRocket,
|
FaRocket,
|
||||||
FaUsers,
|
FaUsers,
|
||||||
|
FaExternalLinkAlt,
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import ReactECharts from 'echarts-for-react';
|
import ReactECharts from 'echarts-for-react';
|
||||||
import { ScoreBar } from '../atoms';
|
import { ScoreBar } from '../atoms';
|
||||||
import { getRadarChartOption } from '../utils/chartOptions';
|
import { getRadarChartOption } from '../utils/chartOptions';
|
||||||
import type { ComprehensiveData, CompetitivePosition } from '../types';
|
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 = {
|
const CARD_STYLES = {
|
||||||
@@ -57,6 +87,7 @@ const CHART_STYLE = { height: '320px' } as const;
|
|||||||
|
|
||||||
interface CompetitiveAnalysisCardProps {
|
interface CompetitiveAnalysisCardProps {
|
||||||
comprehensiveData: ComprehensiveData;
|
comprehensiveData: ComprehensiveData;
|
||||||
|
industryRankData?: IndustryRankData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 竞争对手标签组件
|
// 竞争对手标签组件
|
||||||
@@ -141,8 +172,10 @@ const AdvantagesSection = memo<AdvantagesSectionProps>(
|
|||||||
AdvantagesSection.displayName = 'AdvantagesSection';
|
AdvantagesSection.displayName = 'AdvantagesSection';
|
||||||
|
|
||||||
const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
|
const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
|
||||||
({ comprehensiveData }) => {
|
({ comprehensiveData, industryRankData }) => {
|
||||||
const competitivePosition = comprehensiveData.competitive_position;
|
const competitivePosition = comprehensiveData.competitive_position;
|
||||||
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
|
|
||||||
if (!competitivePosition) return null;
|
if (!competitivePosition) return null;
|
||||||
|
|
||||||
// 缓存雷达图配置
|
// 缓存雷达图配置
|
||||||
@@ -160,56 +193,99 @@ const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
|
|||||||
[competitivePosition.analysis?.main_competitors]
|
[competitivePosition.analysis?.main_competitors]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 判断是否有行业排名数据可展示
|
||||||
|
const hasIndustryRankData = industryRankData && industryRankData.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card {...CARD_STYLES}>
|
<>
|
||||||
<CardHeader>
|
<Card {...CARD_STYLES}>
|
||||||
<HStack>
|
<CardHeader>
|
||||||
<Icon as={FaTrophy} color="yellow.500" />
|
<HStack>
|
||||||
<Heading size="sm" color="yellow.500">竞争地位分析</Heading>
|
<Icon as={FaTrophy} color="yellow.500" />
|
||||||
{competitivePosition.ranking && (
|
<Heading size="sm" color="yellow.500">竞争地位分析</Heading>
|
||||||
<Badge
|
{competitivePosition.ranking && (
|
||||||
ml={2}
|
<Badge
|
||||||
bg="transparent"
|
ml={2}
|
||||||
border="1px solid"
|
bg="transparent"
|
||||||
borderColor="yellow.600"
|
border="1px solid"
|
||||||
color="yellow.500"
|
borderColor="yellow.600"
|
||||||
>
|
color="yellow.500"
|
||||||
行业排名 {competitivePosition.ranking.industry_rank}/
|
cursor={hasIndustryRankData ? 'pointer' : 'default'}
|
||||||
{competitivePosition.ranking.total_companies}
|
onClick={hasIndustryRankData ? onOpen : undefined}
|
||||||
</Badge>
|
_hover={hasIndustryRankData ? { bg: 'rgba(212, 175, 55, 0.1)' } : undefined}
|
||||||
)}
|
>
|
||||||
</HStack>
|
行业排名 {competitivePosition.ranking.industry_rank}/
|
||||||
</CardHeader>
|
{competitivePosition.ranking.total_companies}
|
||||||
<CardBody>
|
</Badge>
|
||||||
{/* 主要竞争对手 */}
|
)}
|
||||||
{/* {competitors.length > 0 && <CompetitorTags competitors={competitors} />} */}
|
{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}>
|
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
|
||||||
<GridItem colSpan={GRID_COLSPAN}>
|
<GridItem colSpan={GRID_COLSPAN}>
|
||||||
<ScoreSection scores={competitivePosition.scores} />
|
<ScoreSection scores={competitivePosition.scores} />
|
||||||
</GridItem>
|
</GridItem>
|
||||||
|
|
||||||
<GridItem colSpan={GRID_COLSPAN}>
|
<GridItem colSpan={GRID_COLSPAN}>
|
||||||
{radarOption && (
|
{radarOption && (
|
||||||
<ReactECharts
|
<ReactECharts
|
||||||
option={radarOption}
|
option={radarOption}
|
||||||
style={CHART_STYLE}
|
style={CHART_STYLE}
|
||||||
theme="dark"
|
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)"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</GridItem>
|
</ModalBody>
|
||||||
</Grid> */}
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
{/* <Divider my={4} borderColor="yellow.600" /> */}
|
</>
|
||||||
|
|
||||||
{/* 竞争优势和劣势 */}
|
|
||||||
<AdvantagesSection
|
|
||||||
advantages={competitivePosition.analysis?.competitive_advantages}
|
|
||||||
disadvantages={competitivePosition.analysis?.competitive_disadvantages}
|
|
||||||
/>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* 关键因素卡片
|
* 关键因素卡片
|
||||||
*
|
*
|
||||||
* 显示影响公司的关键因素列表
|
* 显示影响公司的关键因素列表
|
||||||
|
* 黑金主题设计
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -23,42 +24,87 @@ import {
|
|||||||
AccordionIcon,
|
AccordionIcon,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { FaBalanceScale } from 'react-icons/fa';
|
import { FaBalanceScale } from 'react-icons/fa';
|
||||||
import { DisclaimerBox, KeyFactorCard } from '../atoms';
|
import { KeyFactorCard } from '../atoms';
|
||||||
import type { KeyFactors } from '../types';
|
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 {
|
interface KeyFactorsCardProps {
|
||||||
keyFactors: KeyFactors;
|
keyFactors: KeyFactors;
|
||||||
cardBg?: string;
|
cardBg?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KeyFactorsCard: React.FC<KeyFactorsCardProps> = ({
|
const KeyFactorsCard: React.FC<KeyFactorsCardProps> = ({ keyFactors }) => {
|
||||||
keyFactors,
|
|
||||||
cardBg,
|
|
||||||
}) => {
|
|
||||||
return (
|
return (
|
||||||
<Card bg={cardBg} shadow="md" h="full">
|
<Card {...CARD_STYLES} h="full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<HStack>
|
<HStack>
|
||||||
<Icon as={FaBalanceScale} color="orange.500" />
|
<Icon as={FaBalanceScale} color="yellow.500" />
|
||||||
<Heading size="sm">关键因素</Heading>
|
<Heading size="sm" color={THEME.titleColor}>
|
||||||
<Badge>{keyFactors.total_factors} 项</Badge>
|
关键因素
|
||||||
|
</Heading>
|
||||||
|
<Badge
|
||||||
|
bg="transparent"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor="yellow.600"
|
||||||
|
color="yellow.500"
|
||||||
|
>
|
||||||
|
{keyFactors.total_factors} 项
|
||||||
|
</Badge>
|
||||||
</HStack>
|
</HStack>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<DisclaimerBox />
|
|
||||||
<Accordion allowMultiple>
|
<Accordion allowMultiple>
|
||||||
{keyFactors.categories.map((category, idx) => (
|
{keyFactors.categories.map((category, idx) => (
|
||||||
<AccordionItem key={idx}>
|
<AccordionItem key={idx} border="none">
|
||||||
<AccordionButton>
|
<AccordionButton
|
||||||
|
bg={THEME.cardBg}
|
||||||
|
borderRadius="md"
|
||||||
|
mb={2}
|
||||||
|
_hover={{ bg: 'whiteAlpha.100' }}
|
||||||
|
>
|
||||||
<Box flex="1" textAlign="left">
|
<Box flex="1" textAlign="left">
|
||||||
<HStack>
|
<HStack>
|
||||||
<Text fontWeight="medium">{category.category_name}</Text>
|
<Text fontWeight="medium" color={THEME.textColor}>
|
||||||
<Badge size="sm" variant="subtle">
|
{category.category_name}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
bg="whiteAlpha.100"
|
||||||
|
color={THEME.subtextColor}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
{category.factors.length}
|
{category.factors.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Box>
|
</Box>
|
||||||
<AccordionIcon />
|
<AccordionIcon color={THEME.subtextColor} />
|
||||||
</AccordionButton>
|
</AccordionButton>
|
||||||
<AccordionPanel pb={4}>
|
<AccordionPanel pb={4}>
|
||||||
<VStack spacing={3} align="stretch">
|
<VStack spacing={3} align="stretch">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* 发展时间线卡片
|
* 发展时间线卡片
|
||||||
*
|
*
|
||||||
* 显示公司发展历程时间线
|
* 显示公司发展历程时间线
|
||||||
|
* 黑金主题设计
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -16,37 +17,73 @@ import {
|
|||||||
Icon,
|
Icon,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { FaHistory } from 'react-icons/fa';
|
import { FaHistory } from 'react-icons/fa';
|
||||||
import { DisclaimerBox } from '../atoms';
|
|
||||||
import TimelineComponent from '../organisms/TimelineComponent';
|
import TimelineComponent from '../organisms/TimelineComponent';
|
||||||
import type { DevelopmentTimeline } from '../types';
|
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 {
|
interface TimelineCardProps {
|
||||||
developmentTimeline: DevelopmentTimeline;
|
developmentTimeline: DevelopmentTimeline;
|
||||||
cardBg?: string;
|
cardBg?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TimelineCard: React.FC<TimelineCardProps> = ({
|
const TimelineCard: React.FC<TimelineCardProps> = ({ developmentTimeline }) => {
|
||||||
developmentTimeline,
|
|
||||||
cardBg,
|
|
||||||
}) => {
|
|
||||||
return (
|
return (
|
||||||
<Card bg={cardBg} shadow="md" h="full">
|
<Card {...CARD_STYLES} h="full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<HStack>
|
<HStack>
|
||||||
<Icon as={FaHistory} color="cyan.500" />
|
<Icon as={FaHistory} color="yellow.500" />
|
||||||
<Heading size="sm">发展时间线</Heading>
|
<Heading size="sm" color={THEME.titleColor}>
|
||||||
|
发展时间线
|
||||||
|
</Heading>
|
||||||
<HStack spacing={1}>
|
<HStack spacing={1}>
|
||||||
<Badge colorScheme="red">
|
<Badge
|
||||||
|
bg="transparent"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor="red.400"
|
||||||
|
color="red.400"
|
||||||
|
>
|
||||||
正面 {developmentTimeline.statistics?.positive_events || 0}
|
正面 {developmentTimeline.statistics?.positive_events || 0}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge colorScheme="green">
|
<Badge
|
||||||
|
bg="transparent"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor="green.400"
|
||||||
|
color="green.400"
|
||||||
|
>
|
||||||
负面 {developmentTimeline.statistics?.negative_events || 0}
|
负面 {developmentTimeline.statistics?.negative_events || 0}
|
||||||
</Badge>
|
</Badge>
|
||||||
</HStack>
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<DisclaimerBox />
|
|
||||||
<Box maxH="600px" overflowY="auto" pr={2}>
|
<Box maxH="600px" overflowY="auto" pr={2}>
|
||||||
<TimelineComponent events={developmentTimeline.events} />
|
<TimelineComponent events={developmentTimeline.events} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -2,184 +2,219 @@
|
|||||||
* 产业链分析卡片
|
* 产业链分析卡片
|
||||||
*
|
*
|
||||||
* 显示产业链层级视图和流向关系
|
* 显示产业链层级视图和流向关系
|
||||||
|
* 黑金主题风格 + 流程式导航
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState, useMemo, memo } from 'react';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardBody,
|
CardBody,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
VStack,
|
|
||||||
HStack,
|
HStack,
|
||||||
Text,
|
Text,
|
||||||
Heading,
|
Heading,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
|
||||||
Icon,
|
Icon,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Tabs,
|
|
||||||
TabList,
|
|
||||||
TabPanels,
|
|
||||||
Tab,
|
|
||||||
TabPanel,
|
|
||||||
Center,
|
Center,
|
||||||
|
Box,
|
||||||
|
Flex,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { FaNetworkWired } from 'react-icons/fa';
|
import { FaNetworkWired } from 'react-icons/fa';
|
||||||
import ReactECharts from 'echarts-for-react';
|
import ReactECharts from 'echarts-for-react';
|
||||||
import { DisclaimerBox } from '../atoms';
|
import {
|
||||||
|
ProcessNavigation,
|
||||||
|
ValueChainFilterBar,
|
||||||
|
} from '../atoms';
|
||||||
|
import type { TabType, ViewMode } from '../atoms';
|
||||||
import ValueChainNodeCard from '../organisms/ValueChainNodeCard';
|
import ValueChainNodeCard from '../organisms/ValueChainNodeCard';
|
||||||
import { getSankeyChartOption } from '../utils/chartOptions';
|
import { getSankeyChartOption } from '../utils/chartOptions';
|
||||||
import type { ValueChainData } from '../types';
|
import type { ValueChainData, ValueChainNode } from '../types';
|
||||||
|
|
||||||
|
// 黑金主题配置
|
||||||
|
const THEME = {
|
||||||
|
cardBg: 'gray.800',
|
||||||
|
gold: '#D4AF37',
|
||||||
|
goldLight: '#F0D78C',
|
||||||
|
textPrimary: '#D4AF37',
|
||||||
|
textSecondary: 'gray.400',
|
||||||
|
};
|
||||||
|
|
||||||
interface ValueChainCardProps {
|
interface ValueChainCardProps {
|
||||||
valueChainData: ValueChainData;
|
valueChainData: ValueChainData;
|
||||||
|
companyName?: string;
|
||||||
cardBg?: string;
|
cardBg?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ValueChainCard: React.FC<ValueChainCardProps> = ({
|
const ValueChainCard: React.FC<ValueChainCardProps> = memo(({
|
||||||
valueChainData,
|
valueChainData,
|
||||||
cardBg,
|
companyName = '目标公司',
|
||||||
}) => {
|
}) => {
|
||||||
const sankeyOption = getSankeyChartOption(valueChainData);
|
// 状态管理
|
||||||
|
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 nodesByLevel = valueChainData.value_chain_structure?.nodes_by_level;
|
||||||
|
|
||||||
// 获取上游节点
|
// 获取上游节点
|
||||||
const upstreamNodes = [
|
const upstreamNodes = useMemo(() => [
|
||||||
...(nodesByLevel?.['level_-2'] || []),
|
...(nodesByLevel?.['level_-2'] || []),
|
||||||
...(nodesByLevel?.['level_-1'] || []),
|
...(nodesByLevel?.['level_-1'] || []),
|
||||||
];
|
], [nodesByLevel]);
|
||||||
|
|
||||||
// 获取核心节点
|
// 获取核心节点
|
||||||
const coreNodes = nodesByLevel?.['level_0'] || [];
|
const coreNodes = useMemo(() =>
|
||||||
|
nodesByLevel?.['level_0'] || [],
|
||||||
|
[nodesByLevel]);
|
||||||
|
|
||||||
// 获取下游节点
|
// 获取下游节点
|
||||||
const downstreamNodes = [
|
const downstreamNodes = useMemo(() => [
|
||||||
...(nodesByLevel?.['level_1'] || []),
|
...(nodesByLevel?.['level_1'] || []),
|
||||||
...(nodesByLevel?.['level_2'] || []),
|
...(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 (
|
return (
|
||||||
<Card bg={cardBg} shadow="md">
|
<Card bg={THEME.cardBg} shadow="md">
|
||||||
<CardHeader>
|
{/* 头部区域 */}
|
||||||
<HStack>
|
<CardHeader py={0}>
|
||||||
<Icon as={FaNetworkWired} color="teal.500" />
|
<HStack flexWrap="wrap" gap={0}>
|
||||||
<Heading size="sm">产业链分析</Heading>
|
<Icon as={FaNetworkWired} color={THEME.gold} />
|
||||||
<HStack spacing={2}>
|
<Heading size="sm" color={THEME.textPrimary}>
|
||||||
<Badge colorScheme="orange">
|
产业链分析
|
||||||
上游 {valueChainData.analysis_summary?.upstream_nodes || 0}
|
</Heading>
|
||||||
</Badge>
|
<Text color={THEME.textSecondary} fontSize="sm">
|
||||||
<Badge colorScheme="blue">
|
| {companyName}供应链图谱
|
||||||
核心 {valueChainData.analysis_summary?.company_nodes || 0}
|
</Text>
|
||||||
</Badge>
|
<Badge bg={THEME.gold} color="gray.900">
|
||||||
<Badge colorScheme="green">
|
节点 {totalNodes}
|
||||||
下游 {valueChainData.analysis_summary?.downstream_nodes || 0}
|
</Badge>
|
||||||
</Badge>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
|
||||||
<DisclaimerBox />
|
|
||||||
<Tabs variant="soft-rounded" colorScheme="teal">
|
|
||||||
<TabList>
|
|
||||||
<Tab>层级视图</Tab>
|
|
||||||
<Tab>流向关系</Tab>
|
|
||||||
</TabList>
|
|
||||||
|
|
||||||
<TabPanels>
|
<CardBody px={2}>
|
||||||
{/* 层级视图 */}
|
{/* 工具栏:左侧流程导航 + 右侧筛选 */}
|
||||||
<TabPanel>
|
<Flex
|
||||||
<VStack spacing={8} align="stretch">
|
borderBottom="1px solid"
|
||||||
{/* 上游供应链 */}
|
borderColor="gray.700"
|
||||||
{upstreamNodes.length > 0 && (
|
justify="space-between"
|
||||||
<Box>
|
align="center"
|
||||||
<HStack mb={4}>
|
flexWrap="wrap"
|
||||||
<Badge colorScheme="orange" fontSize="md" px={3} py={1}>
|
>
|
||||||
上游供应链
|
{/* 左侧:流程式导航 - 仅在层级视图显示 */}
|
||||||
</Badge>
|
{viewMode === 'hierarchy' && (
|
||||||
<Text fontSize="sm" color="gray.600">
|
<ProcessNavigation
|
||||||
原材料与供应商
|
activeTab={activeTab}
|
||||||
</Text>
|
onTabChange={setActiveTab}
|
||||||
</HStack>
|
upstreamCount={upstreamNodes.length}
|
||||||
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
|
coreCount={coreNodes.length}
|
||||||
{upstreamNodes.map((node, idx) => (
|
downstreamCount={downstreamNodes.length}
|
||||||
<ValueChainNodeCard
|
/>
|
||||||
key={idx}
|
)}
|
||||||
node={node}
|
|
||||||
level={node.node_level}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 核心企业 */}
|
{/* 右侧:筛选与视图切换 - 始终靠右 */}
|
||||||
{coreNodes.length > 0 && (
|
<Box ml="auto">
|
||||||
<Box>
|
<ValueChainFilterBar
|
||||||
<HStack mb={4}>
|
typeFilter={typeFilter}
|
||||||
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
|
onTypeChange={setTypeFilter}
|
||||||
核心企业
|
importanceFilter={importanceFilter}
|
||||||
</Badge>
|
onImportanceChange={setImportanceFilter}
|
||||||
<Text fontSize="sm" color="gray.600">
|
viewMode={viewMode}
|
||||||
公司主体与产品
|
onViewModeChange={setViewMode}
|
||||||
</Text>
|
/>
|
||||||
</HStack>
|
</Box>
|
||||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
</Flex>
|
||||||
{coreNodes.map((node, idx) => (
|
|
||||||
<ValueChainNodeCard
|
|
||||||
key={idx}
|
|
||||||
node={node}
|
|
||||||
isCompany={node.node_type === 'company'}
|
|
||||||
level={0}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 下游客户 */}
|
{/* 内容区域 */}
|
||||||
{downstreamNodes.length > 0 && (
|
<Box px={0} pt={4}>
|
||||||
<Box>
|
{viewMode === 'hierarchy' ? (
|
||||||
<HStack mb={4}>
|
filteredNodes.length > 0 ? (
|
||||||
<Badge colorScheme="green" fontSize="md" px={3} py={1}>
|
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
|
||||||
下游客户
|
{filteredNodes.map((node, idx) => (
|
||||||
</Badge>
|
<ValueChainNodeCard
|
||||||
<Text fontSize="sm" color="gray.600">
|
key={idx}
|
||||||
客户与终端市场
|
node={node}
|
||||||
</Text>
|
isCompany={node.node_type === 'company'}
|
||||||
</HStack>
|
level={node.node_level}
|
||||||
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
|
/>
|
||||||
{downstreamNodes.map((node, idx) => (
|
))}
|
||||||
<ValueChainNodeCard
|
</SimpleGrid>
|
||||||
key={idx}
|
) : (
|
||||||
node={node}
|
<Center h="200px">
|
||||||
level={node.node_level}
|
<Text color={THEME.textSecondary}>暂无匹配的节点数据</Text>
|
||||||
/>
|
</Center>
|
||||||
))}
|
)
|
||||||
</SimpleGrid>
|
) : sankeyOption ? (
|
||||||
</Box>
|
<ReactECharts
|
||||||
)}
|
option={sankeyOption}
|
||||||
</VStack>
|
style={{ height: '500px' }}
|
||||||
</TabPanel>
|
theme="dark"
|
||||||
|
/>
|
||||||
{/* 流向关系 */}
|
) : (
|
||||||
<TabPanel>
|
<Center h="200px">
|
||||||
{sankeyOption ? (
|
<Text color={THEME.textSecondary}>暂无流向数据</Text>
|
||||||
<ReactECharts
|
</Center>
|
||||||
option={sankeyOption}
|
)}
|
||||||
style={{ height: '500px' }}
|
</Box>
|
||||||
theme="light"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Center h="200px">
|
|
||||||
<Text color="gray.500">暂无流向数据</Text>
|
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
</TabPanel>
|
|
||||||
</TabPanels>
|
|
||||||
</Tabs>
|
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
ValueChainCard.displayName = 'ValueChainCard';
|
||||||
|
|
||||||
export default ValueChainCard;
|
export default ValueChainCard;
|
||||||
|
|||||||
@@ -6,14 +6,17 @@
|
|||||||
* 2. 业务结构 - 业务结构树 + 业务板块详情
|
* 2. 业务结构 - 业务结构树 + 业务板块详情
|
||||||
* 3. 产业链 - 产业链分析(独立,含 Sankey 图)
|
* 3. 产业链 - 产业链分析(独立,含 Sankey 图)
|
||||||
* 4. 发展历程 - 关键因素 + 时间线
|
* 4. 发展历程 - 关键因素 + 时间线
|
||||||
|
*
|
||||||
|
* 支持懒加载:通过 activeTab 和 onTabChange 实现按需加载数据
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Card, CardBody, Center, VStack, Spinner, Text } from '@chakra-ui/react';
|
import { Card, CardBody } from '@chakra-ui/react';
|
||||||
import { FaBrain, FaBuilding, FaLink, FaHistory } from 'react-icons/fa';
|
import { FaBrain, FaBuilding, FaLink, FaHistory } from 'react-icons/fa';
|
||||||
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
|
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
|
||||||
|
import LoadingState from '../../LoadingState';
|
||||||
import { StrategyTab, BusinessTab, ValueChainTab, DevelopmentTab } from './tabs';
|
import { StrategyTab, BusinessTab, ValueChainTab, DevelopmentTab } from './tabs';
|
||||||
import type { DeepAnalysisTabProps } from './types';
|
import type { DeepAnalysisTabProps, DeepAnalysisTabKey } from './types';
|
||||||
|
|
||||||
// 主题配置(与 BasicInfoTab 保持一致)
|
// 主题配置(与 BasicInfoTab 保持一致)
|
||||||
const THEME = {
|
const THEME = {
|
||||||
@@ -31,24 +34,51 @@ const DEEP_ANALYSIS_TABS: SubTabConfig[] = [
|
|||||||
{ key: 'development', name: '发展历程', icon: FaHistory, component: DevelopmentTab },
|
{ 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> = ({
|
const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
|
||||||
comprehensiveData,
|
comprehensiveData,
|
||||||
valueChainData,
|
valueChainData,
|
||||||
keyFactorsData,
|
keyFactorsData,
|
||||||
|
industryRankData,
|
||||||
loading,
|
loading,
|
||||||
cardBg,
|
cardBg,
|
||||||
expandedSegments,
|
expandedSegments,
|
||||||
onToggleSegment,
|
onToggleSegment,
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
}) => {
|
}) => {
|
||||||
|
// 计算当前 Tab 索引(受控模式)
|
||||||
|
const currentIndex = useMemo(() => {
|
||||||
|
if (activeTab) {
|
||||||
|
return TAB_KEY_TO_INDEX[activeTab] ?? 0;
|
||||||
|
}
|
||||||
|
return undefined; // 非受控模式
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
// 加载状态
|
// 加载状态
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Center h="200px">
|
<Card bg={THEME.cardBg} shadow="md" border="1px solid" borderColor={THEME.border}>
|
||||||
<VStack spacing={4}>
|
<CardBody p={0}>
|
||||||
<Spinner size="xl" color="blue.500" />
|
<SubTabContainer
|
||||||
<Text>加载深度分析数据...</Text>
|
tabs={DEEP_ANALYSIS_TABS}
|
||||||
</VStack>
|
index={currentIndex}
|
||||||
</Center>
|
onTabChange={onTabChange}
|
||||||
|
componentProps={{}}
|
||||||
|
themePreset="blackGold"
|
||||||
|
/>
|
||||||
|
<LoadingState message="加载数据中..." height="200px" />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,10 +87,13 @@ const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
|
|||||||
<CardBody p={0}>
|
<CardBody p={0}>
|
||||||
<SubTabContainer
|
<SubTabContainer
|
||||||
tabs={DEEP_ANALYSIS_TABS}
|
tabs={DEEP_ANALYSIS_TABS}
|
||||||
|
index={currentIndex}
|
||||||
|
onTabChange={onTabChange}
|
||||||
componentProps={{
|
componentProps={{
|
||||||
comprehensiveData,
|
comprehensiveData,
|
||||||
valueChainData,
|
valueChainData,
|
||||||
keyFactorsData,
|
keyFactorsData,
|
||||||
|
industryRankData,
|
||||||
cardBg,
|
cardBg,
|
||||||
expandedSegments,
|
expandedSegments,
|
||||||
onToggleSegment,
|
onToggleSegment,
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
* 产业链节点卡片组件
|
* 产业链节点卡片组件
|
||||||
*
|
*
|
||||||
* 显示产业链中的单个节点,点击可展开查看相关公司
|
* 显示产业链中的单个节点,点击可展开查看相关公司
|
||||||
|
* 黑金主题风格
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, memo } from 'react';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardBody,
|
CardBody,
|
||||||
@@ -31,11 +32,39 @@ import {
|
|||||||
FaStar,
|
FaStar,
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import { logger } from '@utils/logger';
|
import { logger } from '@utils/logger';
|
||||||
import { getApiBase } from '@utils/apiConfig';
|
import axios from '@utils/axiosConfig';
|
||||||
import RelatedCompaniesModal from './RelatedCompaniesModal';
|
import RelatedCompaniesModal from './RelatedCompaniesModal';
|
||||||
import type { ValueChainNodeCardProps, RelatedCompany } from '../../types';
|
import type { ValueChainNodeCardProps, RelatedCompany } from '../../types';
|
||||||
|
|
||||||
const API_BASE_URL = getApiBase();
|
// 黑金主题配置
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取节点类型对应的图标
|
* 获取节点类型对应的图标
|
||||||
@@ -49,6 +78,8 @@ const getNodeTypeIcon = (type: string) => {
|
|||||||
service: FaCog,
|
service: FaCog,
|
||||||
channel: FaNetworkWired,
|
channel: FaNetworkWired,
|
||||||
raw_material: FaFlask,
|
raw_material: FaFlask,
|
||||||
|
regulator: FaBuilding,
|
||||||
|
end_user: FaUserTie,
|
||||||
};
|
};
|
||||||
return icons[type] || FaBuilding;
|
return icons[type] || FaBuilding;
|
||||||
};
|
};
|
||||||
@@ -64,7 +95,7 @@ const getImportanceColor = (score?: number): string => {
|
|||||||
return 'green';
|
return 'green';
|
||||||
};
|
};
|
||||||
|
|
||||||
const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = ({
|
const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = memo(({
|
||||||
node,
|
node,
|
||||||
isCompany = false,
|
isCompany = false,
|
||||||
level = 0,
|
level = 0,
|
||||||
@@ -74,28 +105,24 @@ const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = ({
|
|||||||
const [loadingRelated, setLoadingRelated] = useState(false);
|
const [loadingRelated, setLoadingRelated] = useState(false);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
// 根据层级和是否为核心企业确定颜色方案
|
// 根据层级确定颜色方案
|
||||||
const getColorScheme = (): string => {
|
const getColorConfig = () => {
|
||||||
if (isCompany) return 'blue';
|
if (isCompany || level === 0) return THEME.core;
|
||||||
if (level < 0) return 'orange';
|
if (level < 0) return THEME.upstream;
|
||||||
if (level > 0) return 'green';
|
return THEME.downstream;
|
||||||
return 'gray';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const colorScheme = getColorScheme();
|
const colorConfig = getColorConfig();
|
||||||
const bgColor = `${colorScheme}.50`;
|
|
||||||
const borderColor = `${colorScheme}.200`;
|
|
||||||
|
|
||||||
// 获取相关公司数据
|
// 获取相关公司数据
|
||||||
const fetchRelatedCompanies = async () => {
|
const fetchRelatedCompanies = async () => {
|
||||||
setLoadingRelated(true);
|
setLoadingRelated(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const { data } = await axios.get(
|
||||||
`${API_BASE_URL}/api/company/value-chain/related-companies?node_name=${encodeURIComponent(
|
`/api/company/value-chain/related-companies?node_name=${encodeURIComponent(
|
||||||
node.node_name
|
node.node_name
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
const data = await response.json();
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setRelatedCompanies(data.data || []);
|
setRelatedCompanies(data.data || []);
|
||||||
} else {
|
} else {
|
||||||
@@ -135,16 +162,16 @@ const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = ({
|
|||||||
<>
|
<>
|
||||||
<ScaleFade in={true} initialScale={0.9}>
|
<ScaleFade in={true} initialScale={0.9}>
|
||||||
<Card
|
<Card
|
||||||
bg={bgColor}
|
bg={colorConfig.bg}
|
||||||
borderColor={borderColor}
|
borderColor={colorConfig.border}
|
||||||
borderWidth={isCompany ? 3 : 1}
|
borderWidth={isCompany ? 2 : 1}
|
||||||
shadow={isCompany ? 'lg' : 'sm'}
|
shadow={isCompany ? 'lg' : 'sm'}
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
_hover={{
|
_hover={{
|
||||||
shadow: 'xl',
|
shadow: 'xl',
|
||||||
transform: 'translateY(-4px)',
|
transform: 'translateY(-4px)',
|
||||||
borderColor: `${colorScheme}.400`,
|
borderColor: THEME.gold,
|
||||||
}}
|
}}
|
||||||
transition="all 0.3s ease"
|
transition="all 0.3s ease"
|
||||||
minH="140px"
|
minH="140px"
|
||||||
@@ -155,11 +182,11 @@ const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = ({
|
|||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<Icon
|
<Icon
|
||||||
as={getNodeTypeIcon(node.node_type)}
|
as={getNodeTypeIcon(node.node_type)}
|
||||||
color={`${colorScheme}.500`}
|
color={colorConfig.icon}
|
||||||
boxSize={5}
|
boxSize={5}
|
||||||
/>
|
/>
|
||||||
{isCompany && (
|
{isCompany && (
|
||||||
<Badge colorScheme="blue" variant="solid">
|
<Badge colorScheme={colorConfig.badge} variant="solid">
|
||||||
核心企业
|
核心企业
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -168,28 +195,28 @@ const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = ({
|
|||||||
node.importance_score >= 70 && (
|
node.importance_score >= 70 && (
|
||||||
<Tooltip label="重要节点">
|
<Tooltip label="重要节点">
|
||||||
<span>
|
<span>
|
||||||
<Icon as={FaStar} color="orange.400" boxSize={4} />
|
<Icon as={FaStar} color={THEME.gold} boxSize={4} />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
<Text fontWeight="bold" fontSize="sm" noOfLines={2}>
|
<Text fontWeight="bold" fontSize="sm" color={THEME.textPrimary} noOfLines={2}>
|
||||||
{node.node_name}
|
{node.node_name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{node.node_description && (
|
{node.node_description && (
|
||||||
<Text fontSize="xs" color="gray.600" noOfLines={2}>
|
<Text fontSize="xs" color={THEME.textSecondary} noOfLines={2}>
|
||||||
{node.node_description}
|
{node.node_description}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<HStack spacing={2} flexWrap="wrap">
|
<HStack spacing={2} flexWrap="wrap">
|
||||||
<Badge variant="subtle" size="sm" colorScheme={colorScheme}>
|
<Badge variant="subtle" size="sm" colorScheme={colorConfig.badge}>
|
||||||
{node.node_type}
|
{node.node_type}
|
||||||
</Badge>
|
</Badge>
|
||||||
{node.market_share !== undefined && (
|
{node.market_share !== undefined && (
|
||||||
<Badge variant="outline" size="sm">
|
<Badge variant="outline" size="sm" color={THEME.goldLight}>
|
||||||
份额 {node.market_share}%
|
份额 {node.market_share}%
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -198,10 +225,10 @@ const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = ({
|
|||||||
{node.importance_score !== undefined && (
|
{node.importance_score !== undefined && (
|
||||||
<Box>
|
<Box>
|
||||||
<HStack justify="space-between" mb={1}>
|
<HStack justify="space-between" mb={1}>
|
||||||
<Text fontSize="xs" color="gray.500">
|
<Text fontSize="xs" color={THEME.textSecondary}>
|
||||||
重要度
|
重要度
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="xs" fontWeight="bold">
|
<Text fontSize="xs" fontWeight="bold" color={THEME.goldLight}>
|
||||||
{node.importance_score}
|
{node.importance_score}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
@@ -210,6 +237,7 @@ const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = ({
|
|||||||
size="xs"
|
size="xs"
|
||||||
colorScheme={getImportanceColor(node.importance_score)}
|
colorScheme={getImportanceColor(node.importance_score)}
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
|
bg="gray.600"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -223,12 +251,14 @@ const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = ({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
node={node}
|
node={node}
|
||||||
isCompany={isCompany}
|
isCompany={isCompany}
|
||||||
colorScheme={colorScheme}
|
colorScheme={colorConfig.badge}
|
||||||
relatedCompanies={relatedCompanies}
|
relatedCompanies={relatedCompanies}
|
||||||
loadingRelated={loadingRelated}
|
loadingRelated={loadingRelated}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
ValueChainNodeCard.displayName = 'ValueChainNodeCard';
|
||||||
|
|
||||||
export default ValueChainNodeCard;
|
export default ValueChainNodeCard;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 战略分析 Tab
|
* 战略分析 Tab
|
||||||
*
|
*
|
||||||
* 包含:核心定位 + 战略分析 + 竞争地位分析
|
* 包含:核心定位 + 战略分析 + 竞争地位分析(含行业排名弹窗)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
@@ -11,15 +11,17 @@ import {
|
|||||||
StrategyAnalysisCard,
|
StrategyAnalysisCard,
|
||||||
CompetitiveAnalysisCard,
|
CompetitiveAnalysisCard,
|
||||||
} from '../components';
|
} from '../components';
|
||||||
import type { ComprehensiveData } from '../types';
|
import type { ComprehensiveData, IndustryRankData } from '../types';
|
||||||
|
|
||||||
export interface StrategyTabProps {
|
export interface StrategyTabProps {
|
||||||
comprehensiveData?: ComprehensiveData;
|
comprehensiveData?: ComprehensiveData;
|
||||||
|
industryRankData?: IndustryRankData[];
|
||||||
cardBg?: string;
|
cardBg?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StrategyTab: React.FC<StrategyTabProps> = memo(({
|
const StrategyTab: React.FC<StrategyTabProps> = memo(({
|
||||||
comprehensiveData,
|
comprehensiveData,
|
||||||
|
industryRankData,
|
||||||
cardBg,
|
cardBg,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
@@ -40,9 +42,12 @@ const StrategyTab: React.FC<StrategyTabProps> = memo(({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 竞争地位分析 */}
|
{/* 竞争地位分析(包含行业排名弹窗) */}
|
||||||
{comprehensiveData?.competitive_position && (
|
{comprehensiveData?.competitive_position && (
|
||||||
<CompetitiveAnalysisCard comprehensiveData={comprehensiveData} />
|
<CompetitiveAnalysisCard
|
||||||
|
comprehensiveData={comprehensiveData}
|
||||||
|
industryRankData={industryRankData}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</TabPanelContainer>
|
</TabPanelContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ export interface AnalysisSummary {
|
|||||||
upstream_nodes?: number;
|
upstream_nodes?: number;
|
||||||
company_nodes?: number;
|
company_nodes?: number;
|
||||||
downstream_nodes?: number;
|
downstream_nodes?: number;
|
||||||
|
total_nodes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValueChainData {
|
export interface ValueChainData {
|
||||||
@@ -264,16 +265,53 @@ export interface KeyFactorsData {
|
|||||||
development_timeline?: DevelopmentTimeline;
|
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 类型 ====================
|
// ==================== 主组件 Props 类型 ====================
|
||||||
|
|
||||||
|
/** Tab 类型 */
|
||||||
|
export type DeepAnalysisTabKey = 'strategy' | 'business' | 'valueChain' | 'development';
|
||||||
|
|
||||||
export interface DeepAnalysisTabProps {
|
export interface DeepAnalysisTabProps {
|
||||||
comprehensiveData?: ComprehensiveData;
|
comprehensiveData?: ComprehensiveData;
|
||||||
valueChainData?: ValueChainData;
|
valueChainData?: ValueChainData;
|
||||||
keyFactorsData?: KeyFactorsData;
|
keyFactorsData?: KeyFactorsData;
|
||||||
|
industryRankData?: IndustryRankData[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
cardBg?: string;
|
cardBg?: string;
|
||||||
expandedSegments: Record<number, boolean>;
|
expandedSegments: Record<number, boolean>;
|
||||||
onToggleSegment: (index: number) => void;
|
onToggleSegment: (index: number) => void;
|
||||||
|
/** 当前激活的 Tab(受控模式) */
|
||||||
|
activeTab?: DeepAnalysisTabKey;
|
||||||
|
/** Tab 切换回调(懒加载触发) */
|
||||||
|
onTabChange?: (index: number, tabKey: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 子组件 Props 类型 ====================
|
// ==================== 子组件 Props 类型 ====================
|
||||||
|
|||||||
@@ -36,6 +36,58 @@ import {
|
|||||||
FaChevronRight,
|
FaChevronRight,
|
||||||
} from "react-icons/fa";
|
} 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 组件
|
* 新闻动态 Tab 组件
|
||||||
*
|
*
|
||||||
@@ -48,6 +100,7 @@ import {
|
|||||||
* - onSearch: 搜索提交回调 () => void
|
* - onSearch: 搜索提交回调 () => void
|
||||||
* - onPageChange: 分页回调 (page) => void
|
* - onPageChange: 分页回调 (page) => void
|
||||||
* - cardBg: 卡片背景色
|
* - cardBg: 卡片背景色
|
||||||
|
* - themePreset: 主题预设 'blackGold' | 'default'
|
||||||
*/
|
*/
|
||||||
const NewsEventsTab = ({
|
const NewsEventsTab = ({
|
||||||
newsEvents = [],
|
newsEvents = [],
|
||||||
@@ -65,7 +118,11 @@ const NewsEventsTab = ({
|
|||||||
onSearch,
|
onSearch,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
cardBg,
|
cardBg,
|
||||||
|
themePreset = "default",
|
||||||
}) => {
|
}) => {
|
||||||
|
// 获取主题配色
|
||||||
|
const theme = THEME_PRESETS[themePreset] || THEME_PRESETS.default;
|
||||||
|
const isBlackGold = themePreset === "blackGold";
|
||||||
// 事件类型图标映射
|
// 事件类型图标映射
|
||||||
const getEventTypeIcon = (eventType) => {
|
const getEventTypeIcon = (eventType) => {
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
@@ -80,15 +137,25 @@ const NewsEventsTab = ({
|
|||||||
return iconMap[eventType] || FaNewspaper;
|
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 = {
|
const colorMap = {
|
||||||
S: "red",
|
S: "red",
|
||||||
A: "orange",
|
A: "orange",
|
||||||
B: "yellow",
|
B: "yellow",
|
||||||
C: "green",
|
C: "green",
|
||||||
};
|
};
|
||||||
return colorMap[importance] || "gray";
|
return { colorScheme: colorMap[importance] || "gray" };
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理搜索输入
|
// 处理搜索输入
|
||||||
@@ -129,19 +196,26 @@ const NewsEventsTab = ({
|
|||||||
// 如果开始页大于1,显示省略号
|
// 如果开始页大于1,显示省略号
|
||||||
if (startPage > 1) {
|
if (startPage > 1) {
|
||||||
pageButtons.push(
|
pageButtons.push(
|
||||||
<Text key="start-ellipsis" fontSize="sm" color="gray.400">
|
<Text key="start-ellipsis" fontSize="sm" color={theme.textMuted}>
|
||||||
...
|
...
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = startPage; i <= endPage; i++) {
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
const isActive = i === currentPage;
|
||||||
pageButtons.push(
|
pageButtons.push(
|
||||||
<Button
|
<Button
|
||||||
key={i}
|
key={i}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={i === currentPage ? "solid" : "outline"}
|
bg={isActive ? theme.buttonBg : (isBlackGold ? theme.inputBg : undefined)}
|
||||||
colorScheme={i === currentPage ? "blue" : "gray"}
|
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)}
|
onClick={() => handlePageChange(i)}
|
||||||
isDisabled={newsLoading}
|
isDisabled={newsLoading}
|
||||||
>
|
>
|
||||||
@@ -153,7 +227,7 @@ const NewsEventsTab = ({
|
|||||||
// 如果结束页小于总页数,显示省略号
|
// 如果结束页小于总页数,显示省略号
|
||||||
if (endPage < totalPages) {
|
if (endPage < totalPages) {
|
||||||
pageButtons.push(
|
pageButtons.push(
|
||||||
<Text key="end-ellipsis" fontSize="sm" color="gray.400">
|
<Text key="end-ellipsis" fontSize="sm" color={theme.textMuted}>
|
||||||
...
|
...
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@@ -164,7 +238,7 @@ const NewsEventsTab = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack spacing={4} align="stretch">
|
<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>
|
<CardBody>
|
||||||
<VStack spacing={4} align="stretch">
|
<VStack spacing={4} align="stretch">
|
||||||
{/* 搜索框和统计信息 */}
|
{/* 搜索框和统计信息 */}
|
||||||
@@ -172,17 +246,25 @@ const NewsEventsTab = ({
|
|||||||
<HStack flex={1} minW="300px">
|
<HStack flex={1} minW="300px">
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<InputLeftElement pointerEvents="none">
|
<InputLeftElement pointerEvents="none">
|
||||||
<SearchIcon color="gray.400" />
|
<SearchIcon color={theme.textMuted} />
|
||||||
</InputLeftElement>
|
</InputLeftElement>
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索相关新闻..."
|
placeholder="搜索相关新闻..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyPress={handleKeyPress}
|
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>
|
</InputGroup>
|
||||||
<Button
|
<Button
|
||||||
colorScheme="blue"
|
bg={theme.buttonBg}
|
||||||
|
color={theme.buttonText}
|
||||||
|
_hover={{ bg: theme.buttonHoverBg }}
|
||||||
onClick={handleSearchSubmit}
|
onClick={handleSearchSubmit}
|
||||||
isLoading={newsLoading}
|
isLoading={newsLoading}
|
||||||
minW="80px"
|
minW="80px"
|
||||||
@@ -193,10 +275,10 @@ const NewsEventsTab = ({
|
|||||||
|
|
||||||
{newsPagination.total > 0 && (
|
{newsPagination.total > 0 && (
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<Icon as={FaNewspaper} color="blue.500" />
|
<Icon as={FaNewspaper} color={theme.gold} />
|
||||||
<Text fontSize="sm" color="gray.600">
|
<Text fontSize="sm" color={theme.textSecondary}>
|
||||||
共找到{" "}
|
共找到{" "}
|
||||||
<Text as="span" fontWeight="bold" color="blue.600">
|
<Text as="span" fontWeight="bold" color={theme.gold}>
|
||||||
{newsPagination.total}
|
{newsPagination.total}
|
||||||
</Text>{" "}
|
</Text>{" "}
|
||||||
条新闻
|
条新闻
|
||||||
@@ -211,15 +293,15 @@ const NewsEventsTab = ({
|
|||||||
{newsLoading ? (
|
{newsLoading ? (
|
||||||
<Center h="400px">
|
<Center h="400px">
|
||||||
<VStack spacing={3}>
|
<VStack spacing={3}>
|
||||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
<Spinner size="xl" color={theme.spinnerColor} thickness="4px" />
|
||||||
<Text color="gray.600">正在加载新闻...</Text>
|
<Text color={theme.textSecondary}>正在加载新闻...</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Center>
|
</Center>
|
||||||
) : newsEvents.length > 0 ? (
|
) : newsEvents.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<VStack spacing={3} align="stretch">
|
<VStack spacing={3} align="stretch">
|
||||||
{newsEvents.map((event, idx) => {
|
{newsEvents.map((event, idx) => {
|
||||||
const importanceColor = getImportanceColor(
|
const importanceBadgeStyle = getImportanceBadgeStyle(
|
||||||
event.importance
|
event.importance
|
||||||
);
|
);
|
||||||
const eventTypeIcon = getEventTypeIcon(event.event_type);
|
const eventTypeIcon = getEventTypeIcon(event.event_type);
|
||||||
@@ -228,10 +310,12 @@ const NewsEventsTab = ({
|
|||||||
<Card
|
<Card
|
||||||
key={event.id || idx}
|
key={event.id || idx}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
bg={theme.cardBg}
|
||||||
|
borderColor={theme.cardBorder}
|
||||||
_hover={{
|
_hover={{
|
||||||
bg: "gray.50",
|
bg: theme.cardHoverBg,
|
||||||
shadow: "md",
|
shadow: "md",
|
||||||
borderColor: "blue.300",
|
borderColor: theme.cardHoverBorder,
|
||||||
}}
|
}}
|
||||||
transition="all 0.2s"
|
transition="all 0.2s"
|
||||||
>
|
>
|
||||||
@@ -243,13 +327,14 @@ const NewsEventsTab = ({
|
|||||||
<HStack>
|
<HStack>
|
||||||
<Icon
|
<Icon
|
||||||
as={eventTypeIcon}
|
as={eventTypeIcon}
|
||||||
color="blue.500"
|
color={theme.gold}
|
||||||
boxSize={5}
|
boxSize={5}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
fontSize="lg"
|
fontSize="lg"
|
||||||
lineHeight="1.3"
|
lineHeight="1.3"
|
||||||
|
color={theme.textPrimary}
|
||||||
>
|
>
|
||||||
{event.title}
|
{event.title}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -259,22 +344,29 @@ const NewsEventsTab = ({
|
|||||||
<HStack spacing={2} flexWrap="wrap">
|
<HStack spacing={2} flexWrap="wrap">
|
||||||
{event.importance && (
|
{event.importance && (
|
||||||
<Badge
|
<Badge
|
||||||
colorScheme={importanceColor}
|
{...(isBlackGold ? {} : { colorScheme: importanceBadgeStyle.colorScheme, variant: "solid" })}
|
||||||
variant="solid"
|
bg={isBlackGold ? importanceBadgeStyle.bg : undefined}
|
||||||
|
color={isBlackGold ? importanceBadgeStyle.color : undefined}
|
||||||
px={2}
|
px={2}
|
||||||
>
|
>
|
||||||
{event.importance}级
|
{event.importance}级
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{event.event_type && (
|
{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}
|
{event.event_type}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{event.invest_score && (
|
{event.invest_score && (
|
||||||
<Badge
|
<Badge
|
||||||
colorScheme="purple"
|
{...(isBlackGold ? {} : { colorScheme: "purple", variant: "subtle" })}
|
||||||
variant="subtle"
|
bg={isBlackGold ? "rgba(139, 92, 246, 0.2)" : undefined}
|
||||||
|
color={isBlackGold ? "#A78BFA" : undefined}
|
||||||
>
|
>
|
||||||
投资分: {event.invest_score}
|
投资分: {event.invest_score}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -287,8 +379,9 @@ const NewsEventsTab = ({
|
|||||||
<Tag
|
<Tag
|
||||||
key={kidx}
|
key={kidx}
|
||||||
size="sm"
|
size="sm"
|
||||||
colorScheme="cyan"
|
{...(isBlackGold ? {} : { colorScheme: "cyan", variant: "subtle" })}
|
||||||
variant="subtle"
|
bg={isBlackGold ? theme.tagBg : undefined}
|
||||||
|
color={isBlackGold ? theme.tagColor : undefined}
|
||||||
>
|
>
|
||||||
{typeof keyword === "string"
|
{typeof keyword === "string"
|
||||||
? keyword
|
? keyword
|
||||||
@@ -304,7 +397,7 @@ const NewsEventsTab = ({
|
|||||||
|
|
||||||
{/* 右侧信息栏 */}
|
{/* 右侧信息栏 */}
|
||||||
<VStack align="end" spacing={1} minW="100px">
|
<VStack align="end" spacing={1} minW="100px">
|
||||||
<Text fontSize="xs" color="gray.500">
|
<Text fontSize="xs" color={theme.textMuted}>
|
||||||
{event.created_at
|
{event.created_at
|
||||||
? new Date(
|
? new Date(
|
||||||
event.created_at
|
event.created_at
|
||||||
@@ -321,9 +414,9 @@ const NewsEventsTab = ({
|
|||||||
<Icon
|
<Icon
|
||||||
as={FaEye}
|
as={FaEye}
|
||||||
boxSize={3}
|
boxSize={3}
|
||||||
color="gray.400"
|
color={theme.textMuted}
|
||||||
/>
|
/>
|
||||||
<Text fontSize="xs" color="gray.500">
|
<Text fontSize="xs" color={theme.textMuted}>
|
||||||
{event.view_count}
|
{event.view_count}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
@@ -333,16 +426,16 @@ const NewsEventsTab = ({
|
|||||||
<Icon
|
<Icon
|
||||||
as={FaFire}
|
as={FaFire}
|
||||||
boxSize={3}
|
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)}
|
{event.hot_score.toFixed(1)}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
{event.creator && (
|
{event.creator && (
|
||||||
<Text fontSize="xs" color="gray.400">
|
<Text fontSize="xs" color={theme.textMuted}>
|
||||||
@{event.creator.username}
|
@{event.creator.username}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -353,7 +446,7 @@ const NewsEventsTab = ({
|
|||||||
{event.description && (
|
{event.description && (
|
||||||
<Text
|
<Text
|
||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
color="gray.700"
|
color={theme.textSecondary}
|
||||||
lineHeight="1.6"
|
lineHeight="1.6"
|
||||||
>
|
>
|
||||||
{event.description}
|
{event.description}
|
||||||
@@ -367,18 +460,18 @@ const NewsEventsTab = ({
|
|||||||
<Box
|
<Box
|
||||||
pt={2}
|
pt={2}
|
||||||
borderTop="1px"
|
borderTop="1px"
|
||||||
borderColor="gray.200"
|
borderColor={theme.cardBorder}
|
||||||
>
|
>
|
||||||
<HStack spacing={6} flexWrap="wrap">
|
<HStack spacing={6} flexWrap="wrap">
|
||||||
<HStack spacing={1}>
|
<HStack spacing={1}>
|
||||||
<Icon
|
<Icon
|
||||||
as={FaChartLine}
|
as={FaChartLine}
|
||||||
boxSize={3}
|
boxSize={3}
|
||||||
color="gray.500"
|
color={theme.textMuted}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
fontSize="xs"
|
fontSize="xs"
|
||||||
color="gray.500"
|
color={theme.textMuted}
|
||||||
fontWeight="medium"
|
fontWeight="medium"
|
||||||
>
|
>
|
||||||
相关涨跌:
|
相关涨跌:
|
||||||
@@ -387,7 +480,7 @@ const NewsEventsTab = ({
|
|||||||
{event.related_avg_chg !== null &&
|
{event.related_avg_chg !== null &&
|
||||||
event.related_avg_chg !== undefined && (
|
event.related_avg_chg !== undefined && (
|
||||||
<HStack spacing={1}>
|
<HStack spacing={1}>
|
||||||
<Text fontSize="xs" color="gray.500">
|
<Text fontSize="xs" color={theme.textMuted}>
|
||||||
平均
|
平均
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
@@ -395,8 +488,8 @@ const NewsEventsTab = ({
|
|||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
color={
|
color={
|
||||||
event.related_avg_chg > 0
|
event.related_avg_chg > 0
|
||||||
? "red.500"
|
? "#EF4444"
|
||||||
: "green.500"
|
: "#10B981"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{event.related_avg_chg > 0 ? "+" : ""}
|
{event.related_avg_chg > 0 ? "+" : ""}
|
||||||
@@ -407,7 +500,7 @@ const NewsEventsTab = ({
|
|||||||
{event.related_max_chg !== null &&
|
{event.related_max_chg !== null &&
|
||||||
event.related_max_chg !== undefined && (
|
event.related_max_chg !== undefined && (
|
||||||
<HStack spacing={1}>
|
<HStack spacing={1}>
|
||||||
<Text fontSize="xs" color="gray.500">
|
<Text fontSize="xs" color={theme.textMuted}>
|
||||||
最大
|
最大
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
@@ -415,8 +508,8 @@ const NewsEventsTab = ({
|
|||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
color={
|
color={
|
||||||
event.related_max_chg > 0
|
event.related_max_chg > 0
|
||||||
? "red.500"
|
? "#EF4444"
|
||||||
: "green.500"
|
: "#10B981"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{event.related_max_chg > 0 ? "+" : ""}
|
{event.related_max_chg > 0 ? "+" : ""}
|
||||||
@@ -427,7 +520,7 @@ const NewsEventsTab = ({
|
|||||||
{event.related_week_chg !== null &&
|
{event.related_week_chg !== null &&
|
||||||
event.related_week_chg !== undefined && (
|
event.related_week_chg !== undefined && (
|
||||||
<HStack spacing={1}>
|
<HStack spacing={1}>
|
||||||
<Text fontSize="xs" color="gray.500">
|
<Text fontSize="xs" color={theme.textMuted}>
|
||||||
周
|
周
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
@@ -435,8 +528,8 @@ const NewsEventsTab = ({
|
|||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
color={
|
color={
|
||||||
event.related_week_chg > 0
|
event.related_week_chg > 0
|
||||||
? "red.500"
|
? "#EF4444"
|
||||||
: "green.500"
|
: "#10B981"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{event.related_week_chg > 0
|
{event.related_week_chg > 0
|
||||||
@@ -465,7 +558,7 @@ const NewsEventsTab = ({
|
|||||||
flexWrap="wrap"
|
flexWrap="wrap"
|
||||||
>
|
>
|
||||||
{/* 分页信息 */}
|
{/* 分页信息 */}
|
||||||
<Text fontSize="sm" color="gray.600">
|
<Text fontSize="sm" color={theme.textSecondary}>
|
||||||
第 {newsPagination.page} / {newsPagination.pages} 页
|
第 {newsPagination.page} / {newsPagination.pages} 页
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@@ -473,6 +566,11 @@ const NewsEventsTab = ({
|
|||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
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)}
|
onClick={() => handlePageChange(1)}
|
||||||
isDisabled={!newsPagination.has_prev || newsLoading}
|
isDisabled={!newsPagination.has_prev || newsLoading}
|
||||||
leftIcon={<Icon as={FaChevronLeft} />}
|
leftIcon={<Icon as={FaChevronLeft} />}
|
||||||
@@ -481,6 +579,11 @@ const NewsEventsTab = ({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
bg={isBlackGold ? theme.inputBg : undefined}
|
||||||
|
color={theme.textSecondary}
|
||||||
|
borderColor={theme.cardBorder}
|
||||||
|
borderWidth="1px"
|
||||||
|
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handlePageChange(newsPagination.page - 1)
|
handlePageChange(newsPagination.page - 1)
|
||||||
}
|
}
|
||||||
@@ -494,6 +597,11 @@ const NewsEventsTab = ({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
bg={isBlackGold ? theme.inputBg : undefined}
|
||||||
|
color={theme.textSecondary}
|
||||||
|
borderColor={theme.cardBorder}
|
||||||
|
borderWidth="1px"
|
||||||
|
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handlePageChange(newsPagination.page + 1)
|
handlePageChange(newsPagination.page + 1)
|
||||||
}
|
}
|
||||||
@@ -503,6 +611,11 @@ const NewsEventsTab = ({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
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)}
|
onClick={() => handlePageChange(newsPagination.pages)}
|
||||||
isDisabled={!newsPagination.has_next || newsLoading}
|
isDisabled={!newsPagination.has_next || newsLoading}
|
||||||
rightIcon={<Icon as={FaChevronRight} />}
|
rightIcon={<Icon as={FaChevronRight} />}
|
||||||
@@ -517,11 +630,11 @@ const NewsEventsTab = ({
|
|||||||
) : (
|
) : (
|
||||||
<Center h="400px">
|
<Center h="400px">
|
||||||
<VStack spacing={3}>
|
<VStack spacing={3}>
|
||||||
<Icon as={FaNewspaper} boxSize={16} color="gray.300" />
|
<Icon as={FaNewspaper} boxSize={16} color={isBlackGold ? theme.gold : "gray.300"} opacity={0.5} />
|
||||||
<Text color="gray.500" fontSize="lg" fontWeight="medium">
|
<Text color={theme.textSecondary} fontSize="lg" fontWeight="medium">
|
||||||
暂无相关新闻
|
暂无相关新闻
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="sm" color="gray.400">
|
<Text fontSize="sm" color={theme.textMuted}>
|
||||||
{searchQuery ? "尝试修改搜索关键词" : "该公司暂无新闻动态"}
|
{searchQuery ? "尝试修改搜索关键词" : "该公司暂无新闻动态"}
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
// src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts
|
// src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts
|
||||||
// 公告数据 Hook - 用于公司公告 Tab
|
// 公告数据 Hook - 用于公司公告 Tab
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { logger } from "@utils/logger";
|
import { logger } from "@utils/logger";
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
import type { Announcement } from "../types";
|
import type { Announcement } from "../types";
|
||||||
|
|
||||||
const API_BASE_URL = getApiBase();
|
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: T;
|
data: T;
|
||||||
@@ -28,34 +26,38 @@ export const useAnnouncementsData = (stockCode?: string): UseAnnouncementsDataRe
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
useEffect(() => {
|
||||||
if (!stockCode) return;
|
if (!stockCode) return;
|
||||||
|
|
||||||
setLoading(true);
|
const controller = new AbortController();
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
const loadData = async () => {
|
||||||
const response = await fetch(
|
setLoading(true);
|
||||||
`${API_BASE_URL}/api/stock/${stockCode}/announcements?limit=20`
|
setError(null);
|
||||||
);
|
|
||||||
const result = (await response.json()) as ApiResponse<Announcement[]>;
|
|
||||||
|
|
||||||
if (result.success) {
|
try {
|
||||||
setAnnouncements(result.data);
|
const { data: result } = await axios.get<ApiResponse<Announcement[]>>(
|
||||||
} else {
|
`/api/stock/${stockCode}/announcements?limit=20`,
|
||||||
setError("加载公告数据失败");
|
{ 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();
|
||||||
}, [loadData]);
|
return () => controller.abort();
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
return { announcements, loading, error };
|
return { announcements, loading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
// src/views/Company/components/CompanyOverview/hooks/useBasicInfo.ts
|
// src/views/Company/components/CompanyOverview/hooks/useBasicInfo.ts
|
||||||
// 公司基本信息 Hook - 用于 CompanyHeaderCard
|
// 公司基本信息 Hook - 用于 CompanyHeaderCard
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { logger } from "@utils/logger";
|
import { logger } from "@utils/logger";
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
import type { BasicInfo } from "../types";
|
import type { BasicInfo } from "../types";
|
||||||
|
|
||||||
const API_BASE_URL = getApiBase();
|
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: T;
|
data: T;
|
||||||
@@ -28,32 +26,38 @@ export const useBasicInfo = (stockCode?: string): UseBasicInfoResult => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
useEffect(() => {
|
||||||
if (!stockCode) return;
|
if (!stockCode) return;
|
||||||
|
|
||||||
setLoading(true);
|
const controller = new AbortController();
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
const loadData = async () => {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/stock/${stockCode}/basic-info`);
|
setLoading(true);
|
||||||
const result = (await response.json()) as ApiResponse<BasicInfo>;
|
setError(null);
|
||||||
|
|
||||||
if (result.success) {
|
try {
|
||||||
setBasicInfo(result.data);
|
const { data: result } = await axios.get<ApiResponse<BasicInfo>>(
|
||||||
} else {
|
`/api/stock/${stockCode}/basic-info`,
|
||||||
setError("加载基本信息失败");
|
{ 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();
|
||||||
}, [loadData]);
|
return () => controller.abort();
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
return { basicInfo, loading, error };
|
return { basicInfo, loading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
// src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts
|
// src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts
|
||||||
// 分支机构数据 Hook - 用于分支机构 Tab
|
// 分支机构数据 Hook - 用于分支机构 Tab
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { logger } from "@utils/logger";
|
import { logger } from "@utils/logger";
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
import type { Branch } from "../types";
|
import type { Branch } from "../types";
|
||||||
|
|
||||||
const API_BASE_URL = getApiBase();
|
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: T;
|
data: T;
|
||||||
@@ -28,32 +26,38 @@ export const useBranchesData = (stockCode?: string): UseBranchesDataResult => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
useEffect(() => {
|
||||||
if (!stockCode) return;
|
if (!stockCode) return;
|
||||||
|
|
||||||
setLoading(true);
|
const controller = new AbortController();
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
const loadData = async () => {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/stock/${stockCode}/branches`);
|
setLoading(true);
|
||||||
const result = (await response.json()) as ApiResponse<Branch[]>;
|
setError(null);
|
||||||
|
|
||||||
if (result.success) {
|
try {
|
||||||
setBranches(result.data);
|
const { data: result } = await axios.get<ApiResponse<Branch[]>>(
|
||||||
} else {
|
`/api/stock/${stockCode}/branches`,
|
||||||
setError("加载分支机构数据失败");
|
{ 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();
|
||||||
}, [loadData]);
|
return () => controller.abort();
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
return { branches, loading, error };
|
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
|
// src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts
|
||||||
// 披露日程数据 Hook - 用于工商信息 Tab
|
// 披露日程数据 Hook - 用于工商信息 Tab
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { logger } from "@utils/logger";
|
import { logger } from "@utils/logger";
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
import type { DisclosureSchedule } from "../types";
|
import type { DisclosureSchedule } from "../types";
|
||||||
|
|
||||||
const API_BASE_URL = getApiBase();
|
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: T;
|
data: T;
|
||||||
@@ -28,34 +26,38 @@ export const useDisclosureData = (stockCode?: string): UseDisclosureDataResult =
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
useEffect(() => {
|
||||||
if (!stockCode) return;
|
if (!stockCode) return;
|
||||||
|
|
||||||
setLoading(true);
|
const controller = new AbortController();
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
const loadData = async () => {
|
||||||
const response = await fetch(
|
setLoading(true);
|
||||||
`${API_BASE_URL}/api/stock/${stockCode}/disclosure-schedule`
|
setError(null);
|
||||||
);
|
|
||||||
const result = (await response.json()) as ApiResponse<DisclosureSchedule[]>;
|
|
||||||
|
|
||||||
if (result.success) {
|
try {
|
||||||
setDisclosureSchedule(result.data);
|
const { data: result } = await axios.get<ApiResponse<DisclosureSchedule[]>>(
|
||||||
} else {
|
`/api/stock/${stockCode}/disclosure-schedule`,
|
||||||
setError("加载披露日程数据失败");
|
{ 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();
|
||||||
}, [loadData]);
|
return () => controller.abort();
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
return { disclosureSchedule, loading, error };
|
return { disclosureSchedule, loading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
// src/views/Company/components/CompanyOverview/hooks/useManagementData.ts
|
// src/views/Company/components/CompanyOverview/hooks/useManagementData.ts
|
||||||
// 管理团队数据 Hook - 用于管理团队 Tab
|
// 管理团队数据 Hook - 用于管理团队 Tab
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { logger } from "@utils/logger";
|
import { logger } from "@utils/logger";
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
import type { Management } from "../types";
|
import type { Management } from "../types";
|
||||||
|
|
||||||
const API_BASE_URL = getApiBase();
|
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: T;
|
data: T;
|
||||||
@@ -28,34 +26,38 @@ export const useManagementData = (stockCode?: string): UseManagementDataResult =
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
useEffect(() => {
|
||||||
if (!stockCode) return;
|
if (!stockCode) return;
|
||||||
|
|
||||||
setLoading(true);
|
const controller = new AbortController();
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
const loadData = async () => {
|
||||||
const response = await fetch(
|
setLoading(true);
|
||||||
`${API_BASE_URL}/api/stock/${stockCode}/management?active_only=true`
|
setError(null);
|
||||||
);
|
|
||||||
const result = (await response.json()) as ApiResponse<Management[]>;
|
|
||||||
|
|
||||||
if (result.success) {
|
try {
|
||||||
setManagement(result.data);
|
const { data: result } = await axios.get<ApiResponse<Management[]>>(
|
||||||
} else {
|
`/api/stock/${stockCode}/management?active_only=true`,
|
||||||
setError("加载管理团队数据失败");
|
{ 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();
|
||||||
}, [loadData]);
|
return () => controller.abort();
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
return { management, loading, error };
|
return { management, loading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
// src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts
|
// src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts
|
||||||
// 股权结构数据 Hook - 用于股权结构 Tab
|
// 股权结构数据 Hook - 用于股权结构 Tab
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { logger } from "@utils/logger";
|
import { logger } from "@utils/logger";
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
import type { ActualControl, Concentration, Shareholder } from "../types";
|
import type { ActualControl, Concentration, Shareholder } from "../types";
|
||||||
|
|
||||||
const API_BASE_URL = getApiBase();
|
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: T;
|
data: T;
|
||||||
@@ -34,43 +32,44 @@ export const useShareholderData = (stockCode?: string): UseShareholderDataResult
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
useEffect(() => {
|
||||||
if (!stockCode) return;
|
if (!stockCode) return;
|
||||||
|
|
||||||
setLoading(true);
|
const controller = new AbortController();
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
const loadData = async () => {
|
||||||
const [actualRes, concentrationRes, shareholdersRes, circulationRes] = await Promise.all([
|
setLoading(true);
|
||||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/actual-control`).then((r) =>
|
setError(null);
|
||||||
r.json()
|
|
||||||
) as Promise<ApiResponse<ActualControl[]>>,
|
|
||||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/concentration`).then((r) =>
|
|
||||||
r.json()
|
|
||||||
) as Promise<ApiResponse<Concentration[]>>,
|
|
||||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-shareholders?limit=10`).then((r) =>
|
|
||||||
r.json()
|
|
||||||
) as Promise<ApiResponse<Shareholder[]>>,
|
|
||||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-circulation-shareholders?limit=10`).then((r) =>
|
|
||||||
r.json()
|
|
||||||
) as Promise<ApiResponse<Shareholder[]>>,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (actualRes.success) setActualControl(actualRes.data);
|
try {
|
||||||
if (concentrationRes.success) setConcentration(concentrationRes.data);
|
const [
|
||||||
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
|
{ data: actualRes },
|
||||||
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
|
{ data: concentrationRes },
|
||||||
} catch (err) {
|
{ data: shareholdersRes },
|
||||||
logger.error("useShareholderData", "loadData", err, { stockCode });
|
{ data: circulationRes },
|
||||||
setError("加载股权结构数据失败");
|
] = await Promise.all([
|
||||||
} finally {
|
axios.get<ApiResponse<ActualControl[]>>(`/api/stock/${stockCode}/actual-control`, { signal: controller.signal }),
|
||||||
setLoading(false);
|
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 }),
|
||||||
}, [stockCode]);
|
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();
|
||||||
}, [loadData]);
|
return () => controller.abort();
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actualControl,
|
actualControl,
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { VStack } from "@chakra-ui/react";
|
import { VStack } from "@chakra-ui/react";
|
||||||
|
|
||||||
import { useBasicInfo } from "./hooks/useBasicInfo";
|
|
||||||
import type { CompanyOverviewProps } from "./types";
|
import type { CompanyOverviewProps } from "./types";
|
||||||
|
|
||||||
// 子组件(暂保持 JS)
|
// 子组件
|
||||||
import BasicInfoTab from "./BasicInfoTab";
|
import BasicInfoTab from "./BasicInfoTab";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,17 +17,13 @@ import BasicInfoTab from "./BasicInfoTab";
|
|||||||
*
|
*
|
||||||
* 懒加载策略:
|
* 懒加载策略:
|
||||||
* - BasicInfoTab 内部根据 Tab 切换懒加载数据
|
* - BasicInfoTab 内部根据 Tab 切换懒加载数据
|
||||||
|
* - 各 Panel 组件自行获取所需数据(如 BusinessInfoPanel 调用 useBasicInfo)
|
||||||
*/
|
*/
|
||||||
const CompanyOverview: React.FC<CompanyOverviewProps> = ({ stockCode }) => {
|
const CompanyOverview: React.FC<CompanyOverviewProps> = ({ stockCode }) => {
|
||||||
const { basicInfo } = useBasicInfo(stockCode);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack spacing={6} align="stretch">
|
<VStack spacing={6} align="stretch">
|
||||||
{/* 基本信息内容 - 传入 stockCode,内部懒加载各 Tab 数据 */}
|
{/* 基本信息内容 - 传入 stockCode,内部懒加载各 Tab 数据 */}
|
||||||
<BasicInfoTab
|
<BasicInfoTab stockCode={stockCode} />
|
||||||
stockCode={stockCode}
|
|
||||||
basicInfo={basicInfo}
|
|
||||||
/>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ export interface BasicInfo {
|
|||||||
email?: string;
|
email?: string;
|
||||||
tel?: string;
|
tel?: string;
|
||||||
company_intro?: 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,23 +116,6 @@ export interface DisclosureSchedule {
|
|||||||
disclosure_date?: string;
|
disclosure_date?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* useCompanyOverviewData Hook 返回值
|
|
||||||
*/
|
|
||||||
export interface CompanyOverviewData {
|
|
||||||
basicInfo: BasicInfo | null;
|
|
||||||
actualControl: ActualControl[];
|
|
||||||
concentration: Concentration[];
|
|
||||||
management: Management[];
|
|
||||||
topCirculationShareholders: Shareholder[];
|
|
||||||
topShareholders: Shareholder[];
|
|
||||||
branches: Branch[];
|
|
||||||
announcements: Announcement[];
|
|
||||||
disclosureSchedule: DisclosureSchedule[];
|
|
||||||
loading: boolean;
|
|
||||||
dataLoaded: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CompanyOverview 组件 Props
|
* CompanyOverview 组件 Props
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,36 +1,65 @@
|
|||||||
// src/views/Company/components/DeepAnalysis/index.js
|
// 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 { logger } from "@utils/logger";
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
|
|
||||||
// 复用原有的展示组件
|
// 复用原有的展示组件
|
||||||
import DeepAnalysisTab from "../CompanyOverview/DeepAnalysisTab";
|
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 {Object} props
|
||||||
* @param {string} props.stockCode - 股票代码
|
* @param {string} props.stockCode - 股票代码
|
||||||
*/
|
*/
|
||||||
const DeepAnalysis = ({ stockCode }) => {
|
const DeepAnalysis = ({ stockCode }) => {
|
||||||
|
// 当前 Tab
|
||||||
|
const [activeTab, setActiveTab] = useState("strategy");
|
||||||
|
|
||||||
// 数据状态
|
// 数据状态
|
||||||
const [comprehensiveData, setComprehensiveData] = useState(null);
|
const [comprehensiveData, setComprehensiveData] = useState(null);
|
||||||
const [valueChainData, setValueChainData] = useState(null);
|
const [valueChainData, setValueChainData] = useState(null);
|
||||||
const [keyFactorsData, setKeyFactorsData] = 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({});
|
const [expandedSegments, setExpandedSegments] = useState({});
|
||||||
|
|
||||||
|
// 用于追踪当前 stockCode,避免竞态条件
|
||||||
|
const currentStockCodeRef = useRef(stockCode);
|
||||||
|
|
||||||
// 切换业务板块展开状态
|
// 切换业务板块展开状态
|
||||||
const toggleSegmentExpansion = (segmentIndex) => {
|
const toggleSegmentExpansion = (segmentIndex) => {
|
||||||
setExpandedSegments((prev) => ({
|
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 {
|
try {
|
||||||
const requests = [
|
switch (apiKey) {
|
||||||
fetch(
|
case "comprehensive":
|
||||||
`${API_BASE_URL}/api/company/comprehensive-analysis/${stockCode}`
|
setComprehensiveLoading(true);
|
||||||
).then((r) => r.json()),
|
const { data: comprehensiveRes } = await axios.get(
|
||||||
fetch(
|
`/api/company/comprehensive-analysis/${stockCode}`
|
||||||
`${API_BASE_URL}/api/company/value-chain-analysis/${stockCode}`
|
);
|
||||||
).then((r) => r.json()),
|
// 检查 stockCode 是否已变更(防止竞态)
|
||||||
fetch(
|
if (currentStockCodeRef.current === stockCode) {
|
||||||
`${API_BASE_URL}/api/company/key-factors-timeline/${stockCode}`
|
if (comprehensiveRes.success)
|
||||||
).then((r) => r.json()),
|
setComprehensiveData(comprehensiveRes.data);
|
||||||
];
|
loadedApisRef.current.comprehensive = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
const [comprehensiveRes, valueChainRes, keyFactorsRes] =
|
case "valueChain":
|
||||||
await Promise.all(requests);
|
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);
|
case "keyFactors":
|
||||||
if (valueChainRes.success) setValueChainData(valueChainRes.data);
|
setKeyFactorsLoading(true);
|
||||||
if (keyFactorsRes.success) setKeyFactorsData(keyFactorsRes.data);
|
const { data: keyFactorsRes } = await axios.get(
|
||||||
} catch (err) {
|
`/api/company/key-factors-timeline/${stockCode}`
|
||||||
logger.error("DeepAnalysis", "loadDeepAnalysisData", err, { stockCode });
|
);
|
||||||
} finally {
|
if (currentStockCodeRef.current === stockCode) {
|
||||||
setLoading(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (stockCode) {
|
if (stockCode) {
|
||||||
// 重置数据
|
// 更新 ref
|
||||||
|
currentStockCodeRef.current = stockCode;
|
||||||
|
|
||||||
|
// 重置所有数据和状态
|
||||||
setComprehensiveData(null);
|
setComprehensiveData(null);
|
||||||
setValueChainData(null);
|
setValueChainData(null);
|
||||||
setKeyFactorsData(null);
|
setKeyFactorsData(null);
|
||||||
|
setIndustryRankData(null);
|
||||||
setExpandedSegments({});
|
setExpandedSegments({});
|
||||||
// 加载新数据
|
loadedApisRef.current = {
|
||||||
loadDeepAnalysisData();
|
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 (
|
return (
|
||||||
<DeepAnalysisTab
|
<DeepAnalysisTab
|
||||||
comprehensiveData={comprehensiveData}
|
comprehensiveData={comprehensiveData}
|
||||||
valueChainData={valueChainData}
|
valueChainData={valueChainData}
|
||||||
keyFactorsData={keyFactorsData}
|
keyFactorsData={keyFactorsData}
|
||||||
loading={loading}
|
industryRankData={industryRankData}
|
||||||
|
loading={getCurrentLoading()}
|
||||||
cardBg="white"
|
cardBg="white"
|
||||||
expandedSegments={expandedSegments}
|
expandedSegments={expandedSegments}
|
||||||
onToggleSegment={toggleSegmentExpansion}
|
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,204 +1,65 @@
|
|||||||
// src/views/Company/components/DynamicTracking/index.js
|
// src/views/Company/components/DynamicTracking/index.js
|
||||||
// 动态跟踪 - 独立一级 Tab 组件(包含新闻动态等二级 Tab)
|
// 动态跟踪 - 独立一级 Tab 组件(包含新闻动态等二级 Tab)
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import { Box } from '@chakra-ui/react';
|
||||||
Box,
|
import { FaNewspaper, FaBullhorn, FaCalendarAlt, FaChartBar } from 'react-icons/fa';
|
||||||
Tabs,
|
|
||||||
TabList,
|
|
||||||
TabPanels,
|
|
||||||
Tab,
|
|
||||||
TabPanel,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
import { FaNewspaper, FaBullhorn, FaCalendarAlt } from "react-icons/fa";
|
|
||||||
|
|
||||||
import { logger } from "@utils/logger";
|
import SubTabContainer from '@components/SubTabContainer';
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
import AnnouncementsPanel from '../CompanyOverview/BasicInfoTab/components/AnnouncementsPanel';
|
||||||
import NewsEventsTab from "../CompanyOverview/NewsEventsTab";
|
import DisclosureSchedulePanel from '../CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel';
|
||||||
import AnnouncementsPanel from "../CompanyOverview/BasicInfoTab/components/AnnouncementsPanel";
|
import { NewsPanel, ForecastPanel } from './components';
|
||||||
import DisclosureSchedulePanel from "../CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel";
|
|
||||||
import { THEME } from "../CompanyOverview/BasicInfoTab/config";
|
|
||||||
|
|
||||||
// API配置
|
|
||||||
const API_BASE_URL = getApiBase();
|
|
||||||
|
|
||||||
// 二级 Tab 配置
|
// 二级 Tab 配置
|
||||||
const TRACKING_TABS = [
|
const TRACKING_TABS = [
|
||||||
{ key: "news", name: "新闻动态", icon: FaNewspaper },
|
{ key: 'news', name: '新闻动态', icon: FaNewspaper, component: NewsPanel },
|
||||||
{ key: "announcements", name: "公司公告", icon: FaBullhorn },
|
{ key: 'announcements', name: '公司公告', icon: FaBullhorn, component: AnnouncementsPanel },
|
||||||
{ key: "disclosure", name: "财报披露日程", icon: FaCalendarAlt },
|
{ key: 'disclosure', name: '财报披露日程', icon: FaCalendarAlt, component: DisclosureSchedulePanel },
|
||||||
|
{ key: 'forecast', name: '业绩预告', icon: FaChartBar, component: ForecastPanel },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 动态跟踪组件
|
* 动态跟踪组件
|
||||||
*
|
*
|
||||||
* 功能:
|
* 功能:
|
||||||
* - 二级 Tab 结构
|
* - 使用 SubTabContainer 实现二级导航
|
||||||
* - Tab1: 新闻动态(复用 NewsEventsTab)
|
* - Tab1: 新闻动态
|
||||||
* - 预留后续扩展
|
* - Tab2: 公司公告
|
||||||
|
* - Tab3: 财报披露日程
|
||||||
|
* - Tab4: 业绩预告
|
||||||
*
|
*
|
||||||
* @param {Object} props
|
* @param {Object} props
|
||||||
* @param {string} props.stockCode - 股票代码
|
* @param {string} props.stockCode - 股票代码
|
||||||
*/
|
*/
|
||||||
const DynamicTracking = ({ stockCode: propStockCode }) => {
|
const DynamicTracking = ({ stockCode: propStockCode }) => {
|
||||||
const [stockCode, setStockCode] = useState(propStockCode || "000001");
|
const [stockCode, setStockCode] = useState(propStockCode || '000001');
|
||||||
const [activeTab, setActiveTab] = useState(0);
|
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 变化
|
// 监听 props 中的 stockCode 变化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (propStockCode && propStockCode !== stockCode) {
|
if (propStockCode && propStockCode !== stockCode) {
|
||||||
setStockCode(propStockCode);
|
setStockCode(propStockCode);
|
||||||
setDataLoaded(false);
|
|
||||||
setNewsEvents([]);
|
|
||||||
setStockName("");
|
|
||||||
setSearchQuery("");
|
|
||||||
}
|
}
|
||||||
}, [propStockCode, stockCode]);
|
}, [propStockCode, stockCode]);
|
||||||
|
|
||||||
// 获取股票名称(用于搜索)
|
// 传递给子组件的 props
|
||||||
const fetchStockName = useCallback(async () => {
|
const componentProps = useMemo(
|
||||||
try {
|
() => ({
|
||||||
const response = await fetch(
|
stockCode,
|
||||||
`${API_BASE_URL}/api/stock/${stockCode}/basic-info`
|
}),
|
||||||
);
|
[stockCode]
|
||||||
const result = await response.json();
|
|
||||||
if (result.success && result.data) {
|
|
||||||
const name = result.data.SECNAME || result.data.ORGNAME || stockCode;
|
|
||||||
setStockName(name);
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
return stockCode;
|
|
||||||
} catch (err) {
|
|
||||||
logger.error("DynamicTracking", "fetchStockName", err, { stockCode });
|
|
||||||
return stockCode;
|
|
||||||
}
|
|
||||||
}, [stockCode]);
|
|
||||||
|
|
||||||
// 加载新闻事件数据
|
|
||||||
const loadNewsEvents = useCallback(
|
|
||||||
async (query, page = 1) => {
|
|
||||||
setNewsLoading(true);
|
|
||||||
try {
|
|
||||||
const searchTerm = query || stockName || stockCode;
|
|
||||||
const response = await fetch(
|
|
||||||
`${API_BASE_URL}/api/events?q=${encodeURIComponent(searchTerm)}&page=${page}&per_page=10`
|
|
||||||
);
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setNewsEvents(result.data || []);
|
|
||||||
setNewsPagination({
|
|
||||||
page: result.pagination?.page || page,
|
|
||||||
per_page: result.pagination?.per_page || 10,
|
|
||||||
total: result.pagination?.total || 0,
|
|
||||||
pages: result.pagination?.pages || 0,
|
|
||||||
has_next: result.pagination?.has_next || false,
|
|
||||||
has_prev: result.pagination?.has_prev || false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error("DynamicTracking", "loadNewsEvents", err, { stockCode });
|
|
||||||
setNewsEvents([]);
|
|
||||||
} finally {
|
|
||||||
setNewsLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[stockCode, stockName]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 首次加载
|
|
||||||
useEffect(() => {
|
|
||||||
const initLoad = async () => {
|
|
||||||
if (stockCode && !dataLoaded) {
|
|
||||||
const name = await fetchStockName();
|
|
||||||
await loadNewsEvents(name, 1);
|
|
||||||
setDataLoaded(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
initLoad();
|
|
||||||
}, [stockCode, dataLoaded, fetchStockName, loadNewsEvents]);
|
|
||||||
|
|
||||||
// 搜索处理
|
|
||||||
const handleSearchChange = (value) => {
|
|
||||||
setSearchQuery(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearch = () => {
|
|
||||||
loadNewsEvents(searchQuery || stockName, 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 分页处理
|
|
||||||
const handlePageChange = (page) => {
|
|
||||||
loadNewsEvents(searchQuery || stockName, page);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box bg={THEME.bg} p={4} borderRadius="md">
|
<Box>
|
||||||
<Tabs
|
<SubTabContainer
|
||||||
variant="soft-rounded"
|
tabs={TRACKING_TABS}
|
||||||
|
componentProps={componentProps}
|
||||||
|
themePreset="blackGold"
|
||||||
index={activeTab}
|
index={activeTab}
|
||||||
onChange={setActiveTab}
|
onTabChange={(index) => setActiveTab(index)}
|
||||||
isLazy
|
isLazy
|
||||||
>
|
/>
|
||||||
<TabList bg={THEME.cardBg} borderBottom="1px solid" borderColor={THEME.border}>
|
|
||||||
{TRACKING_TABS.map((tab) => (
|
|
||||||
<Tab
|
|
||||||
key={tab.key}
|
|
||||||
fontWeight="medium"
|
|
||||||
color={THEME.textSecondary}
|
|
||||||
_selected={{
|
|
||||||
color: THEME.tabSelected.color,
|
|
||||||
bg: THEME.tabSelected.bg,
|
|
||||||
borderRadius: "md",
|
|
||||||
}}
|
|
||||||
_hover={{ color: THEME.gold }}
|
|
||||||
>
|
|
||||||
{tab.name}
|
|
||||||
</Tab>
|
|
||||||
))}
|
|
||||||
</TabList>
|
|
||||||
|
|
||||||
<TabPanels>
|
|
||||||
{/* 新闻动态 Tab */}
|
|
||||||
<TabPanel p={4}>
|
|
||||||
<NewsEventsTab
|
|
||||||
newsEvents={newsEvents}
|
|
||||||
newsLoading={newsLoading}
|
|
||||||
newsPagination={newsPagination}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
onSearchChange={handleSearchChange}
|
|
||||||
onSearch={handleSearch}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
cardBg="white"
|
|
||||||
/>
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
{/* 公司公告 Tab */}
|
|
||||||
<TabPanel p={4}>
|
|
||||||
<AnnouncementsPanel stockCode={stockCode} />
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
{/* 财报披露日程 Tab */}
|
|
||||||
<TabPanel p={4}>
|
|
||||||
<DisclosureSchedulePanel stockCode={stockCode} />
|
|
||||||
</TabPanel>
|
|
||||||
</TabPanels>
|
|
||||||
</Tabs>
|
|
||||||
</Box>
|
</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;
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* 通用财务表格组件 - Ant Design 黑金主题
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
|
||||||
|
import { Table, ConfigProvider, Tooltip, Badge } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { Eye } from 'lucide-react';
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
|
||||||
|
// Ant Design 表格黑金主题配置
|
||||||
|
export const FINANCIAL_TABLE_THEME = {
|
||||||
|
token: {
|
||||||
|
colorBgContainer: 'transparent',
|
||||||
|
colorText: '#E2E8F0',
|
||||||
|
colorTextHeading: '#D4AF37',
|
||||||
|
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Table: {
|
||||||
|
headerBg: 'rgba(26, 32, 44, 0.8)',
|
||||||
|
headerColor: '#D4AF37',
|
||||||
|
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
borderColor: 'rgba(212, 175, 55, 0.15)',
|
||||||
|
cellPaddingBlock: 8,
|
||||||
|
cellPaddingInline: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 通用样式
|
||||||
|
export const tableStyles = `
|
||||||
|
.financial-table .ant-table {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
.financial-table .ant-table-thead > tr > th {
|
||||||
|
background: rgba(26, 32, 44, 0.8) !important;
|
||||||
|
color: #D4AF37 !important;
|
||||||
|
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.financial-table .ant-table-tbody > tr > td {
|
||||||
|
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
|
||||||
|
color: #E2E8F0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.financial-table .ant-table-tbody > tr:hover > td {
|
||||||
|
background: rgba(212, 175, 55, 0.08) !important;
|
||||||
|
}
|
||||||
|
.financial-table .ant-table-tbody > tr.total-row > td {
|
||||||
|
background: rgba(212, 175, 55, 0.15) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.financial-table .ant-table-tbody > tr.section-header > td {
|
||||||
|
background: rgba(212, 175, 55, 0.08) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #D4AF37;
|
||||||
|
}
|
||||||
|
.financial-table .ant-table-cell-fix-left,
|
||||||
|
.financial-table .ant-table-cell-fix-right {
|
||||||
|
background: #1A202C !important;
|
||||||
|
}
|
||||||
|
.financial-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
|
||||||
|
.financial-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
|
||||||
|
background: rgba(26, 32, 44, 0.95) !important;
|
||||||
|
}
|
||||||
|
.financial-table .positive-change {
|
||||||
|
color: #E53E3E;
|
||||||
|
}
|
||||||
|
.financial-table .negative-change {
|
||||||
|
color: #48BB78;
|
||||||
|
}
|
||||||
|
.financial-table .ant-table-placeholder {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
.financial-table .ant-empty-description {
|
||||||
|
color: #A0AEC0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 指标类型
|
||||||
|
export interface MetricConfig {
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
path: string;
|
||||||
|
isCore?: boolean;
|
||||||
|
isTotal?: boolean;
|
||||||
|
isSubtotal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricSectionConfig {
|
||||||
|
title: string;
|
||||||
|
key: string;
|
||||||
|
metrics: MetricConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格行数据类型
|
||||||
|
export interface FinancialTableRow {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isCore?: boolean;
|
||||||
|
isTotal?: boolean;
|
||||||
|
isSection?: boolean;
|
||||||
|
indent?: number;
|
||||||
|
[period: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件 Props
|
||||||
|
export interface FinancialTableProps {
|
||||||
|
data: Array<{ period: string; [key: string]: unknown }>;
|
||||||
|
sections: MetricSectionConfig[];
|
||||||
|
onRowClick?: (name: string, key: string, path: string) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
maxColumns?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取嵌套路径的值
|
||||||
|
const getValueByPath = (obj: Record<string, unknown>, path: string): number | undefined => {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let value: unknown = obj;
|
||||||
|
for (const key of keys) {
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
value = (value as Record<string, unknown>)[key];
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return typeof value === 'number' ? value : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算同比变化
|
||||||
|
const calculateYoY = (
|
||||||
|
currentValue: number | undefined,
|
||||||
|
currentPeriod: string,
|
||||||
|
data: Array<{ period: string; [key: string]: unknown }>,
|
||||||
|
path: string
|
||||||
|
): number | null => {
|
||||||
|
if (currentValue === undefined || currentValue === null) return null;
|
||||||
|
|
||||||
|
const currentDate = new Date(currentPeriod);
|
||||||
|
const lastYearPeriod = data.find((item) => {
|
||||||
|
const date = new Date(item.period);
|
||||||
|
return (
|
||||||
|
date.getFullYear() === currentDate.getFullYear() - 1 &&
|
||||||
|
date.getMonth() === currentDate.getMonth()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!lastYearPeriod) return null;
|
||||||
|
|
||||||
|
const lastYearValue = getValueByPath(lastYearPeriod as Record<string, unknown>, path);
|
||||||
|
if (lastYearValue === undefined || lastYearValue === 0) return null;
|
||||||
|
|
||||||
|
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FinancialTable: React.FC<FinancialTableProps> = ({
|
||||||
|
data,
|
||||||
|
sections,
|
||||||
|
onRowClick,
|
||||||
|
loading = false,
|
||||||
|
maxColumns = 6,
|
||||||
|
}) => {
|
||||||
|
// 限制显示列数
|
||||||
|
const displayData = data.slice(0, maxColumns);
|
||||||
|
|
||||||
|
// 构建表格数据
|
||||||
|
const tableData: FinancialTableRow[] = [];
|
||||||
|
|
||||||
|
sections.forEach((section) => {
|
||||||
|
// 添加分组标题行(除了汇总行)
|
||||||
|
if (!section.title.includes('总计') && !section.title.includes('合计')) {
|
||||||
|
tableData.push({
|
||||||
|
key: `section-${section.key}`,
|
||||||
|
name: section.title,
|
||||||
|
path: '',
|
||||||
|
isSection: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加指标行
|
||||||
|
section.metrics.forEach((metric) => {
|
||||||
|
const row: FinancialTableRow = {
|
||||||
|
key: metric.key,
|
||||||
|
name: metric.name,
|
||||||
|
path: metric.path,
|
||||||
|
isCore: metric.isCore,
|
||||||
|
isTotal: metric.isTotal || section.title.includes('总计') || section.title.includes('合计'),
|
||||||
|
indent: metric.isTotal ? 0 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加各期数值
|
||||||
|
displayData.forEach((item) => {
|
||||||
|
const value = getValueByPath(item as Record<string, unknown>, metric.path);
|
||||||
|
row[item.period] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
tableData.push(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 构建列定义
|
||||||
|
const columns: ColumnsType<FinancialTableRow> = [
|
||||||
|
{
|
||||||
|
title: '项目',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
fixed: 'left',
|
||||||
|
width: 180,
|
||||||
|
render: (name: string, record: FinancialTableRow) => {
|
||||||
|
if (record.isSection) {
|
||||||
|
return <Text fontWeight="bold" color="#D4AF37">{name}</Text>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<HStack spacing={2} pl={record.indent ? 4 : 0}>
|
||||||
|
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>{name}</Text>
|
||||||
|
{record.isCore && (
|
||||||
|
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
||||||
|
核心
|
||||||
|
</ChakraBadge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...displayData.map((item) => ({
|
||||||
|
title: (
|
||||||
|
<Box textAlign="center">
|
||||||
|
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
|
||||||
|
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
dataIndex: item.period,
|
||||||
|
key: item.period,
|
||||||
|
width: 110,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (value: number | undefined, record: FinancialTableRow) => {
|
||||||
|
if (record.isSection) return null;
|
||||||
|
|
||||||
|
const yoy = calculateYoY(value, item.period, data, record.path);
|
||||||
|
const formattedValue = formatUtils.formatLargeNumber(value, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<Box>
|
||||||
|
<Text>数值: {formatUtils.formatLargeNumber(value)}</Text>
|
||||||
|
{yoy !== null && <Text>同比: {yoy.toFixed(2)}%</Text>}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box position="relative">
|
||||||
|
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>
|
||||||
|
{formattedValue}
|
||||||
|
</Text>
|
||||||
|
{yoy !== null && Math.abs(yoy) > 30 && !record.isTotal && (
|
||||||
|
<Text
|
||||||
|
position="absolute"
|
||||||
|
top="-12px"
|
||||||
|
right="0"
|
||||||
|
fontSize="10px"
|
||||||
|
className={yoy > 0 ? 'positive-change' : 'negative-change'}
|
||||||
|
>
|
||||||
|
{yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'action',
|
||||||
|
width: 40,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_: unknown, record: FinancialTableRow) => {
|
||||||
|
if (record.isSection) return null;
|
||||||
|
return (
|
||||||
|
<Eye
|
||||||
|
size={14}
|
||||||
|
color="#D4AF37"
|
||||||
|
style={{ cursor: 'pointer', opacity: 0.7 }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRowClick?.(record.name, record.key, record.path);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="financial-table">
|
||||||
|
<style>{tableStyles}</style>
|
||||||
|
<ConfigProvider theme={FINANCIAL_TABLE_THEME}>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={tableData}
|
||||||
|
pagination={false}
|
||||||
|
loading={loading}
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
rowClassName={(record) => {
|
||||||
|
if (record.isSection) return 'section-header';
|
||||||
|
if (record.isTotal) return 'total-row';
|
||||||
|
return '';
|
||||||
|
}}
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () => {
|
||||||
|
if (!record.isSection && onRowClick) {
|
||||||
|
onRowClick(record.name, record.key, record.path);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: { cursor: record.isSection ? 'default' : 'pointer' },
|
||||||
|
})}
|
||||||
|
locale={{ emptyText: '暂无数据' }}
|
||||||
|
/>
|
||||||
|
</ConfigProvider>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FinancialTable;
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
/**
|
||||||
|
* 利润表组件 - Ant Design 黑金主题
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
|
||||||
|
import { Table, ConfigProvider, Tooltip } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { Eye } from 'lucide-react';
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
import { INCOME_STATEMENT_SECTIONS } from '../constants';
|
||||||
|
import { getValueByPath, isNegativeIndicator } from '../utils';
|
||||||
|
import type { IncomeStatementTableProps, MetricConfig } from '../types';
|
||||||
|
|
||||||
|
// Ant Design 黑金主题配置
|
||||||
|
const BLACK_GOLD_THEME = {
|
||||||
|
token: {
|
||||||
|
colorBgContainer: 'transparent',
|
||||||
|
colorText: '#E2E8F0',
|
||||||
|
colorTextHeading: '#D4AF37',
|
||||||
|
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Table: {
|
||||||
|
headerBg: 'rgba(26, 32, 44, 0.8)',
|
||||||
|
headerColor: '#D4AF37',
|
||||||
|
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
borderColor: 'rgba(212, 175, 55, 0.15)',
|
||||||
|
cellPaddingBlock: 8,
|
||||||
|
cellPaddingInline: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 黑金主题CSS
|
||||||
|
const tableStyles = `
|
||||||
|
.income-statement-table .ant-table {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
.income-statement-table .ant-table-thead > tr > th {
|
||||||
|
background: rgba(26, 32, 44, 0.8) !important;
|
||||||
|
color: #D4AF37 !important;
|
||||||
|
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.income-statement-table .ant-table-tbody > tr > td {
|
||||||
|
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
|
||||||
|
color: #E2E8F0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.income-statement-table .ant-table-tbody > tr:hover > td {
|
||||||
|
background: rgba(212, 175, 55, 0.08) !important;
|
||||||
|
}
|
||||||
|
.income-statement-table .ant-table-tbody > tr.total-row > td {
|
||||||
|
background: rgba(212, 175, 55, 0.15) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.income-statement-table .ant-table-tbody > tr.subtotal-row > td {
|
||||||
|
background: rgba(212, 175, 55, 0.1) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.income-statement-table .ant-table-tbody > tr.section-header > td {
|
||||||
|
background: rgba(212, 175, 55, 0.08) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #D4AF37;
|
||||||
|
}
|
||||||
|
.income-statement-table .ant-table-cell-fix-left,
|
||||||
|
.income-statement-table .ant-table-cell-fix-right {
|
||||||
|
background: #1A202C !important;
|
||||||
|
}
|
||||||
|
.income-statement-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
|
||||||
|
.income-statement-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
|
||||||
|
background: rgba(26, 32, 44, 0.95) !important;
|
||||||
|
}
|
||||||
|
.income-statement-table .positive-change {
|
||||||
|
color: #E53E3E;
|
||||||
|
}
|
||||||
|
.income-statement-table .negative-change {
|
||||||
|
color: #48BB78;
|
||||||
|
}
|
||||||
|
.income-statement-table .negative-value {
|
||||||
|
color: #E53E3E;
|
||||||
|
}
|
||||||
|
.income-statement-table .ant-table-placeholder {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
.income-statement-table .ant-empty-description {
|
||||||
|
color: #A0AEC0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 表格行数据类型
|
||||||
|
interface TableRowData {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isCore?: boolean;
|
||||||
|
isTotal?: boolean;
|
||||||
|
isSubtotal?: boolean;
|
||||||
|
isSection?: boolean;
|
||||||
|
indent?: number;
|
||||||
|
[period: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncomeStatementTable: React.FC<IncomeStatementTableProps> = ({
|
||||||
|
data,
|
||||||
|
showMetricChart,
|
||||||
|
calculateYoYChange,
|
||||||
|
positiveColor = 'red.500',
|
||||||
|
negativeColor = 'green.500',
|
||||||
|
}) => {
|
||||||
|
// 数组安全检查
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box p={4} textAlign="center" color="gray.400">
|
||||||
|
暂无利润表数据
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxColumns = Math.min(data.length, 6);
|
||||||
|
const displayData = data.slice(0, maxColumns);
|
||||||
|
|
||||||
|
// 构建表格数据
|
||||||
|
const tableData = useMemo(() => {
|
||||||
|
const rows: TableRowData[] = [];
|
||||||
|
|
||||||
|
INCOME_STATEMENT_SECTIONS.forEach((section) => {
|
||||||
|
// 添加分组标题行
|
||||||
|
rows.push({
|
||||||
|
key: `section-${section.key}`,
|
||||||
|
name: section.title,
|
||||||
|
path: '',
|
||||||
|
isSection: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加指标行
|
||||||
|
section.metrics.forEach((metric: MetricConfig) => {
|
||||||
|
const row: TableRowData = {
|
||||||
|
key: metric.key,
|
||||||
|
name: metric.name,
|
||||||
|
path: metric.path,
|
||||||
|
isCore: metric.isCore,
|
||||||
|
isTotal: metric.isTotal,
|
||||||
|
isSubtotal: metric.isSubtotal,
|
||||||
|
indent: metric.isTotal || metric.isSubtotal ? 0 : (metric.name.startsWith(' ') ? 2 : 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加各期数值
|
||||||
|
displayData.forEach((item) => {
|
||||||
|
const value = getValueByPath<number>(item, metric.path);
|
||||||
|
row[item.period] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.push(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}, [data, displayData]);
|
||||||
|
|
||||||
|
// 计算同比变化
|
||||||
|
const calculateYoY = (
|
||||||
|
currentValue: number | undefined,
|
||||||
|
currentPeriod: string,
|
||||||
|
path: string
|
||||||
|
): number | null => {
|
||||||
|
if (currentValue === undefined || currentValue === null) return null;
|
||||||
|
|
||||||
|
const currentDate = new Date(currentPeriod);
|
||||||
|
const lastYearPeriod = data.find((item) => {
|
||||||
|
const date = new Date(item.period);
|
||||||
|
return (
|
||||||
|
date.getFullYear() === currentDate.getFullYear() - 1 &&
|
||||||
|
date.getMonth() === currentDate.getMonth()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!lastYearPeriod) return null;
|
||||||
|
|
||||||
|
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
|
||||||
|
if (lastYearValue === undefined || lastYearValue === 0) return null;
|
||||||
|
|
||||||
|
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构建列定义
|
||||||
|
const columns: ColumnsType<TableRowData> = useMemo(() => {
|
||||||
|
const cols: ColumnsType<TableRowData> = [
|
||||||
|
{
|
||||||
|
title: '项目',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
fixed: 'left',
|
||||||
|
width: 250,
|
||||||
|
render: (name: string, record: TableRowData) => {
|
||||||
|
if (record.isSection) {
|
||||||
|
return <Text fontWeight="bold" color="#D4AF37">{name}</Text>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<HStack spacing={2} pl={record.indent ? record.indent * 4 : 0}>
|
||||||
|
<Text fontWeight={record.isTotal || record.isSubtotal ? 'bold' : 'normal'}>{name}</Text>
|
||||||
|
{record.isCore && (
|
||||||
|
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
||||||
|
核心
|
||||||
|
</ChakraBadge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...displayData.map((item) => ({
|
||||||
|
title: (
|
||||||
|
<Box textAlign="center">
|
||||||
|
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
|
||||||
|
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
dataIndex: item.period,
|
||||||
|
key: item.period,
|
||||||
|
width: 120,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (value: number | undefined, record: TableRowData) => {
|
||||||
|
if (record.isSection) return null;
|
||||||
|
|
||||||
|
const yoy = calculateYoY(value, item.period, record.path);
|
||||||
|
const isEPS = record.key.includes('eps');
|
||||||
|
const formattedValue = isEPS ? value?.toFixed(3) : formatUtils.formatLargeNumber(value, 0);
|
||||||
|
const isNegative = value !== undefined && value < 0;
|
||||||
|
|
||||||
|
// 成本费用类负向指标,增长用绿色,减少用红色
|
||||||
|
const isCostItem = isNegativeIndicator(record.key);
|
||||||
|
const changeColor = isCostItem
|
||||||
|
? (yoy && yoy > 0 ? 'negative-change' : 'positive-change')
|
||||||
|
: (yoy && yoy > 0 ? 'positive-change' : 'negative-change');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<Box>
|
||||||
|
<Text>数值: {isEPS ? value?.toFixed(3) : formatUtils.formatLargeNumber(value)}</Text>
|
||||||
|
{yoy !== null && <Text>同比: {yoy.toFixed(2)}%</Text>}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box position="relative">
|
||||||
|
<Text
|
||||||
|
fontWeight={record.isTotal || record.isSubtotal ? 'bold' : 'normal'}
|
||||||
|
className={isNegative ? 'negative-value' : undefined}
|
||||||
|
>
|
||||||
|
{formattedValue}
|
||||||
|
</Text>
|
||||||
|
{yoy !== null && Math.abs(yoy) > 30 && !record.isTotal && (
|
||||||
|
<Text
|
||||||
|
position="absolute"
|
||||||
|
top="-12px"
|
||||||
|
right="0"
|
||||||
|
fontSize="10px"
|
||||||
|
className={changeColor}
|
||||||
|
>
|
||||||
|
{yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'action',
|
||||||
|
width: 40,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_: unknown, record: TableRowData) => {
|
||||||
|
if (record.isSection) return null;
|
||||||
|
return (
|
||||||
|
<Eye
|
||||||
|
size={14}
|
||||||
|
color="#D4AF37"
|
||||||
|
style={{ cursor: 'pointer', opacity: 0.7 }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showMetricChart(record.name, record.key, data, record.path);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return cols;
|
||||||
|
}, [displayData, data, showMetricChart]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="income-statement-table">
|
||||||
|
<style>{tableStyles}</style>
|
||||||
|
<ConfigProvider theme={BLACK_GOLD_THEME}>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={tableData}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
rowClassName={(record) => {
|
||||||
|
if (record.isSection) return 'section-header';
|
||||||
|
if (record.isTotal) return 'total-row';
|
||||||
|
if (record.isSubtotal) return 'subtotal-row';
|
||||||
|
return '';
|
||||||
|
}}
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () => {
|
||||||
|
if (!record.isSection) {
|
||||||
|
showMetricChart(record.name, record.key, data, record.path);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: { cursor: record.isSection ? 'default' : 'pointer' },
|
||||||
|
})}
|
||||||
|
locale={{ emptyText: '暂无数据' }}
|
||||||
|
/>
|
||||||
|
</ConfigProvider>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IncomeStatementTable;
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* 行业排名组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
VStack,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Heading,
|
||||||
|
Text,
|
||||||
|
Box,
|
||||||
|
HStack,
|
||||||
|
Badge,
|
||||||
|
SimpleGrid,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
import { RANKING_METRICS } from '../constants';
|
||||||
|
import type { IndustryRankingViewProps } from '../types';
|
||||||
|
|
||||||
|
export const IndustryRankingView: React.FC<IndustryRankingViewProps> = ({
|
||||||
|
industryRank,
|
||||||
|
bgColor = 'white',
|
||||||
|
borderColor = 'gray.200',
|
||||||
|
textColor,
|
||||||
|
labelColor,
|
||||||
|
}) => {
|
||||||
|
// 判断是否为深色主题
|
||||||
|
const isDarkTheme = bgColor === 'gray.800' || bgColor === 'gray.900';
|
||||||
|
const resolvedTextColor = textColor || (isDarkTheme ? 'white' : 'gray.800');
|
||||||
|
const resolvedLabelColor = labelColor || (isDarkTheme ? 'gray.400' : 'gray.500');
|
||||||
|
const cardBg = isDarkTheme ? 'transparent' : 'white';
|
||||||
|
const headingColor = isDarkTheme ? 'yellow.500' : 'gray.800';
|
||||||
|
|
||||||
|
if (!industryRank || !Array.isArray(industryRank) || industryRank.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
|
||||||
|
<CardBody>
|
||||||
|
<Text textAlign="center" color={resolvedLabelColor} py={8}>
|
||||||
|
暂无行业排名数据
|
||||||
|
</Text>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack spacing={4} align="stretch">
|
||||||
|
{industryRank.map((periodData, periodIdx) => (
|
||||||
|
<Card
|
||||||
|
key={periodIdx}
|
||||||
|
bg={cardBg}
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderWidth="1px"
|
||||||
|
>
|
||||||
|
<CardHeader pb={2}>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Heading size="sm" color={headingColor}>
|
||||||
|
{periodData.report_type} 行业排名
|
||||||
|
</Heading>
|
||||||
|
<Badge
|
||||||
|
bg={isDarkTheme ? 'transparent' : undefined}
|
||||||
|
borderWidth={isDarkTheme ? '1px' : 0}
|
||||||
|
borderColor={isDarkTheme ? 'yellow.600' : undefined}
|
||||||
|
color={isDarkTheme ? 'yellow.500' : undefined}
|
||||||
|
colorScheme={isDarkTheme ? undefined : 'purple'}
|
||||||
|
>
|
||||||
|
{periodData.period}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody pt={2}>
|
||||||
|
{periodData.rankings?.map((ranking, idx) => (
|
||||||
|
<Box key={idx} mb={6}>
|
||||||
|
<Text fontWeight="bold" mb={3} color={resolvedTextColor}>
|
||||||
|
{ranking.industry_name} ({ranking.level_description})
|
||||||
|
</Text>
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} spacing={3}>
|
||||||
|
{RANKING_METRICS.map((metric) => {
|
||||||
|
const metricData = ranking.metrics?.[metric.key as keyof typeof ranking.metrics];
|
||||||
|
if (!metricData) return null;
|
||||||
|
|
||||||
|
const isGood = metricData.rank && metricData.rank <= 10;
|
||||||
|
const isBad = metricData.rank && metricData.rank > 30;
|
||||||
|
|
||||||
|
const isPercentMetric =
|
||||||
|
metric.key.includes('growth') ||
|
||||||
|
metric.key.includes('margin') ||
|
||||||
|
metric.key === 'roe';
|
||||||
|
|
||||||
|
// 格式化数值
|
||||||
|
const formattedValue = isPercentMetric
|
||||||
|
? formatUtils.formatPercent(metricData.value)
|
||||||
|
: metricData.value?.toFixed(2) ?? '-';
|
||||||
|
|
||||||
|
const formattedAvg = isPercentMetric
|
||||||
|
? formatUtils.formatPercent(metricData.industry_avg)
|
||||||
|
: metricData.industry_avg?.toFixed(2) ?? '-';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={metric.key}
|
||||||
|
p={3}
|
||||||
|
borderRadius="md"
|
||||||
|
bg={bgColor}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
>
|
||||||
|
<Text fontSize="xs" color={resolvedLabelColor}>
|
||||||
|
{metric.name}
|
||||||
|
</Text>
|
||||||
|
<HStack mt={1} spacing={2}>
|
||||||
|
<Text fontWeight="bold" fontSize="lg" color={resolvedTextColor}>
|
||||||
|
{formattedValue}
|
||||||
|
</Text>
|
||||||
|
{metricData.rank && (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
colorScheme={isGood ? 'red' : isBad ? 'green' : 'gray'}
|
||||||
|
>
|
||||||
|
#{metricData.rank}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="xs" color={resolvedLabelColor} mt={1}>
|
||||||
|
行业均值: {formattedAvg}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IndustryRankingView;
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* 关键指标速览组件 - 黑金主题
|
||||||
|
* 展示核心财务指标的快速概览
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import { Box, Heading, SimpleGrid, Text, HStack, Icon } from '@chakra-ui/react';
|
||||||
|
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
import type { FinancialMetricsData } from '../types';
|
||||||
|
|
||||||
|
// 黑金主题样式
|
||||||
|
const THEME = {
|
||||||
|
cardBg: 'transparent',
|
||||||
|
border: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
headingColor: '#D4AF37',
|
||||||
|
itemBg: 'rgba(212, 175, 55, 0.05)',
|
||||||
|
itemBorder: 'rgba(212, 175, 55, 0.15)',
|
||||||
|
labelColor: 'gray.400',
|
||||||
|
valueColor: 'white',
|
||||||
|
positiveColor: '#22c55e',
|
||||||
|
negativeColor: '#ef4444',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 指标配置
|
||||||
|
const KEY_METRICS = [
|
||||||
|
{ label: 'ROE', path: 'profitability.roe', format: 'percent', higherBetter: true },
|
||||||
|
{ label: '毛利率', path: 'profitability.gross_margin', format: 'percent', higherBetter: true },
|
||||||
|
{ label: '净利率', path: 'profitability.net_profit_margin', format: 'percent', higherBetter: true },
|
||||||
|
{ label: '流动比率', path: 'solvency.current_ratio', format: 'decimal', higherBetter: true },
|
||||||
|
{ label: '资产负债率', path: 'solvency.asset_liability_ratio', format: 'percent', higherBetter: false },
|
||||||
|
{ label: '研发费用率', path: 'expense_ratios.rd_expense_ratio', format: 'percent', higherBetter: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 通过路径获取值
|
||||||
|
const getValueByPath = <T,>(obj: FinancialMetricsData, path: string): T | undefined => {
|
||||||
|
return path.split('.').reduce((acc: unknown, key: string) => {
|
||||||
|
if (acc && typeof acc === 'object') {
|
||||||
|
return (acc as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, obj as unknown) as T | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface KeyMetricsOverviewProps {
|
||||||
|
financialMetrics: FinancialMetricsData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KeyMetricsOverview: React.FC<KeyMetricsOverviewProps> = memo(({
|
||||||
|
financialMetrics,
|
||||||
|
}) => {
|
||||||
|
if (!financialMetrics || financialMetrics.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPeriod = financialMetrics[0];
|
||||||
|
const previousPeriod = financialMetrics[1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg={THEME.cardBg}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={THEME.border}
|
||||||
|
borderRadius="md"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<Box px={4} py={3} borderBottom="1px solid" borderColor={THEME.border}>
|
||||||
|
<Heading size="sm" color={THEME.headingColor}>
|
||||||
|
关键指标速览
|
||||||
|
</Heading>
|
||||||
|
</Box>
|
||||||
|
<Box p={4}>
|
||||||
|
<SimpleGrid columns={{ base: 2, md: 3, lg: 6 }} spacing={3}>
|
||||||
|
{KEY_METRICS.map((metric, idx) => {
|
||||||
|
const currentValue = getValueByPath<number>(currentPeriod, metric.path);
|
||||||
|
const previousValue = previousPeriod
|
||||||
|
? getValueByPath<number>(previousPeriod, metric.path)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// 计算变化
|
||||||
|
let change: number | null = null;
|
||||||
|
let trend: 'up' | 'down' | 'flat' = 'flat';
|
||||||
|
if (currentValue !== undefined && previousValue !== undefined && previousValue !== 0) {
|
||||||
|
change = currentValue - previousValue;
|
||||||
|
if (Math.abs(change) > 0.01) {
|
||||||
|
trend = change > 0 ? 'up' : 'down';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断趋势是好是坏
|
||||||
|
const isPositiveTrend = metric.higherBetter ? trend === 'up' : trend === 'down';
|
||||||
|
const trendColor = trend === 'flat'
|
||||||
|
? 'gray.500'
|
||||||
|
: isPositiveTrend
|
||||||
|
? THEME.positiveColor
|
||||||
|
: THEME.negativeColor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={idx}
|
||||||
|
p={3}
|
||||||
|
borderRadius="md"
|
||||||
|
bg={THEME.itemBg}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={THEME.itemBorder}
|
||||||
|
>
|
||||||
|
<Text fontSize="xs" color={THEME.labelColor} mb={1}>
|
||||||
|
{metric.label}
|
||||||
|
</Text>
|
||||||
|
<HStack justify="space-between" align="flex-end">
|
||||||
|
<Text fontSize="lg" fontWeight="bold" color={THEME.valueColor}>
|
||||||
|
{metric.format === 'percent'
|
||||||
|
? formatUtils.formatPercent(currentValue)
|
||||||
|
: currentValue?.toFixed(2) ?? '-'}
|
||||||
|
</Text>
|
||||||
|
{trend !== 'flat' && (
|
||||||
|
<Icon
|
||||||
|
as={trend === 'up' ? TrendingUp : TrendingDown}
|
||||||
|
boxSize={4}
|
||||||
|
color={trendColor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{trend === 'flat' && (
|
||||||
|
<Icon as={Minus} boxSize={4} color="gray.500" />
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
KeyMetricsOverview.displayName = 'KeyMetricsOverview';
|
||||||
|
|
||||||
|
export default KeyMetricsOverview;
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* 主营业务分析组件 - 黑金主题
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Flex,
|
||||||
|
Box,
|
||||||
|
Heading,
|
||||||
|
Alert,
|
||||||
|
AlertIcon,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { Table as AntTable, ConfigProvider, theme as antTheme } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
import { getMainBusinessPieOption } from '../utils';
|
||||||
|
import type {
|
||||||
|
MainBusinessAnalysisProps,
|
||||||
|
BusinessItem,
|
||||||
|
ProductClassification,
|
||||||
|
IndustryClassification,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
// 黑金主题样式
|
||||||
|
const THEME = {
|
||||||
|
cardBg: 'transparent',
|
||||||
|
border: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
headingColor: '#D4AF37',
|
||||||
|
textColor: 'gray.300',
|
||||||
|
thColor: 'gray.400',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ant Design 黑金主题配置
|
||||||
|
const BLACK_GOLD_THEME = {
|
||||||
|
algorithm: antTheme.darkAlgorithm,
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#D4AF37',
|
||||||
|
colorBgContainer: '#1A202C',
|
||||||
|
colorBgElevated: '#1a1a2e',
|
||||||
|
colorBorder: 'rgba(212, 175, 55, 0.3)',
|
||||||
|
colorText: '#e0e0e0',
|
||||||
|
colorTextSecondary: '#a0a0a0',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Table: {
|
||||||
|
headerBg: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
headerColor: '#D4AF37',
|
||||||
|
rowHoverBg: 'rgba(212, 175, 55, 0.05)',
|
||||||
|
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
cellPaddingBlock: 8,
|
||||||
|
cellPaddingInline: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 固定列背景样式(防止滚动时内容重叠)
|
||||||
|
const fixedColumnStyles = `
|
||||||
|
.main-business-table .ant-table-cell-fix-left,
|
||||||
|
.main-business-table .ant-table-cell-fix-right {
|
||||||
|
background: #1A202C !important;
|
||||||
|
}
|
||||||
|
.main-business-table .ant-table-thead .ant-table-cell-fix-left,
|
||||||
|
.main-business-table .ant-table-thead .ant-table-cell-fix-right {
|
||||||
|
background: rgba(26, 32, 44, 0.95) !important;
|
||||||
|
}
|
||||||
|
.main-business-table .ant-table-tbody > tr:hover > td {
|
||||||
|
background: rgba(212, 175, 55, 0.08) !important;
|
||||||
|
}
|
||||||
|
.main-business-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-left,
|
||||||
|
.main-business-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-right {
|
||||||
|
background: #242d3d !important;
|
||||||
|
}
|
||||||
|
.main-business-table .ant-table-tbody > tr > td {
|
||||||
|
background: #1A202C !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 历史对比表格数据行类型(包含业务明细)
|
||||||
|
interface HistoricalRowData {
|
||||||
|
key: string;
|
||||||
|
business: string;
|
||||||
|
grossMargin?: number;
|
||||||
|
profit?: number;
|
||||||
|
[period: string]: string | number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 历史对比表格组件(整合业务明细)
|
||||||
|
interface HistoricalComparisonTableProps {
|
||||||
|
historicalData: (ProductClassification | IndustryClassification)[];
|
||||||
|
businessItems: BusinessItem[];
|
||||||
|
hasProductData: boolean;
|
||||||
|
latestReportType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HistoricalComparisonTable: React.FC<HistoricalComparisonTableProps> = ({
|
||||||
|
historicalData,
|
||||||
|
businessItems,
|
||||||
|
hasProductData,
|
||||||
|
latestReportType,
|
||||||
|
}) => {
|
||||||
|
// 动态生成列配置
|
||||||
|
const columns: ColumnsType<HistoricalRowData> = useMemo(() => {
|
||||||
|
const cols: ColumnsType<HistoricalRowData> = [
|
||||||
|
{
|
||||||
|
title: '业务',
|
||||||
|
dataIndex: 'business',
|
||||||
|
key: 'business',
|
||||||
|
fixed: 'left',
|
||||||
|
width: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `毛利率(${latestReportType})`,
|
||||||
|
dataIndex: 'grossMargin',
|
||||||
|
key: 'grossMargin',
|
||||||
|
align: 'right',
|
||||||
|
width: 120,
|
||||||
|
render: (value: number | undefined) =>
|
||||||
|
value !== undefined ? formatUtils.formatPercent(value) : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `利润(${latestReportType})`,
|
||||||
|
dataIndex: 'profit',
|
||||||
|
key: 'profit',
|
||||||
|
align: 'right',
|
||||||
|
width: 100,
|
||||||
|
render: (value: number | undefined) =>
|
||||||
|
value !== undefined ? formatUtils.formatLargeNumber(value) : '-',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 添加各期间营收列
|
||||||
|
historicalData.slice(0, 4).forEach((period) => {
|
||||||
|
cols.push({
|
||||||
|
title: `营收(${period.report_type})`,
|
||||||
|
dataIndex: period.period,
|
||||||
|
key: period.period,
|
||||||
|
align: 'right',
|
||||||
|
width: 120,
|
||||||
|
render: (value: number | string | undefined) =>
|
||||||
|
value !== undefined && value !== '-'
|
||||||
|
? formatUtils.formatLargeNumber(value as number)
|
||||||
|
: '-',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return cols;
|
||||||
|
}, [historicalData, latestReportType]);
|
||||||
|
|
||||||
|
// 生成表格数据(包含业务明细)
|
||||||
|
const dataSource: HistoricalRowData[] = useMemo(() => {
|
||||||
|
return businessItems
|
||||||
|
.filter((item: BusinessItem) => item.content !== '合计')
|
||||||
|
.map((item: BusinessItem, idx: number) => {
|
||||||
|
const row: HistoricalRowData = {
|
||||||
|
key: `${idx}`,
|
||||||
|
business: item.content,
|
||||||
|
grossMargin: item.gross_margin || item.profit_margin,
|
||||||
|
profit: item.profit,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加各期间营收数据
|
||||||
|
historicalData.slice(0, 4).forEach((period) => {
|
||||||
|
const periodItems: BusinessItem[] = hasProductData
|
||||||
|
? (period as ProductClassification).products
|
||||||
|
: (period as IndustryClassification).industries;
|
||||||
|
const matchItem = periodItems.find(
|
||||||
|
(p: BusinessItem) => p.content === item.content
|
||||||
|
);
|
||||||
|
row[period.period] = matchItem?.revenue ?? '-';
|
||||||
|
});
|
||||||
|
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
}, [businessItems, historicalData, hasProductData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg={THEME.cardBg}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={THEME.border}
|
||||||
|
borderRadius="md"
|
||||||
|
overflow="hidden"
|
||||||
|
h="100%"
|
||||||
|
className="main-business-table"
|
||||||
|
>
|
||||||
|
<style>{fixedColumnStyles}</style>
|
||||||
|
<Box px={4} py={3} borderBottom="1px solid" borderColor={THEME.border}>
|
||||||
|
<Heading size="sm" color={THEME.headingColor}>
|
||||||
|
主营业务明细与历史对比
|
||||||
|
</Heading>
|
||||||
|
</Box>
|
||||||
|
<Box p={4} overflowX="auto">
|
||||||
|
<ConfigProvider theme={BLACK_GOLD_THEME}>
|
||||||
|
<AntTable<HistoricalRowData>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={dataSource}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
bordered
|
||||||
|
/>
|
||||||
|
</ConfigProvider>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MainBusinessAnalysis: React.FC<MainBusinessAnalysisProps> = ({
|
||||||
|
mainBusiness,
|
||||||
|
}) => {
|
||||||
|
// 优先使用product_classification,如果为空则使用industry_classification
|
||||||
|
const hasProductData =
|
||||||
|
mainBusiness?.product_classification && mainBusiness.product_classification.length > 0;
|
||||||
|
const hasIndustryData =
|
||||||
|
mainBusiness?.industry_classification && mainBusiness.industry_classification.length > 0;
|
||||||
|
|
||||||
|
if (!hasProductData && !hasIndustryData) {
|
||||||
|
return (
|
||||||
|
<Alert status="info" bg="rgba(212, 175, 55, 0.1)" color={THEME.headingColor}>
|
||||||
|
<AlertIcon color={THEME.headingColor} />
|
||||||
|
暂无主营业务数据
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择数据源
|
||||||
|
const dataSource = hasProductData ? 'product' : 'industry';
|
||||||
|
|
||||||
|
// 获取最新期间数据
|
||||||
|
const latestPeriod = hasProductData
|
||||||
|
? (mainBusiness!.product_classification![0] as ProductClassification)
|
||||||
|
: (mainBusiness!.industry_classification![0] as IndustryClassification);
|
||||||
|
|
||||||
|
// 获取业务项目
|
||||||
|
const businessItems: BusinessItem[] = hasProductData
|
||||||
|
? (latestPeriod as ProductClassification).products
|
||||||
|
: (latestPeriod as IndustryClassification).industries;
|
||||||
|
|
||||||
|
// 过滤掉"合计"项,准备饼图数据
|
||||||
|
const pieData = businessItems
|
||||||
|
.filter((item: BusinessItem) => item.content !== '合计')
|
||||||
|
.map((item: BusinessItem) => ({
|
||||||
|
name: item.content,
|
||||||
|
value: item.revenue || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pieOption = getMainBusinessPieOption(
|
||||||
|
`主营业务构成 - ${latestPeriod.report_type}`,
|
||||||
|
dataSource === 'industry' ? '按行业分类' : '按产品分类',
|
||||||
|
pieData
|
||||||
|
);
|
||||||
|
|
||||||
|
// 历史对比数据
|
||||||
|
const historicalData = hasProductData
|
||||||
|
? (mainBusiness!.product_classification! as ProductClassification[])
|
||||||
|
: (mainBusiness!.industry_classification! as IndustryClassification[]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
direction={{ base: 'column', lg: 'row' }}
|
||||||
|
gap={4}
|
||||||
|
>
|
||||||
|
{/* 左侧:饼图 */}
|
||||||
|
<Box
|
||||||
|
flexShrink={0}
|
||||||
|
w={{ base: '100%', lg: '340px' }}
|
||||||
|
bg={THEME.cardBg}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={THEME.border}
|
||||||
|
borderRadius="md"
|
||||||
|
p={4}
|
||||||
|
>
|
||||||
|
<ReactECharts option={pieOption} style={{ height: '280px' }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 右侧:业务明细与历史对比表格 */}
|
||||||
|
<Box flex={1} minW={0} overflow="hidden">
|
||||||
|
{historicalData.length > 0 && (
|
||||||
|
<HistoricalComparisonTable
|
||||||
|
historicalData={historicalData}
|
||||||
|
businessItems={businessItems}
|
||||||
|
hasProductData={hasProductData}
|
||||||
|
latestReportType={latestPeriod.report_type}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainBusinessAnalysis;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 期数选择器组件 - 黑金主题
|
||||||
|
* 用于选择显示的财务报表期数,并提供刷新功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import { HStack, Text, IconButton } from '@chakra-ui/react';
|
||||||
|
import { Select } from 'antd';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface PeriodSelectorProps {
|
||||||
|
/** 当前选中的期数 */
|
||||||
|
selectedPeriods: number;
|
||||||
|
/** 期数变更回调 */
|
||||||
|
onPeriodsChange: (periods: number) => void;
|
||||||
|
/** 刷新回调 */
|
||||||
|
onRefresh: () => void;
|
||||||
|
/** 是否加载中 */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** 可选期数列表,默认 [4, 8, 12, 16] */
|
||||||
|
periodOptions?: number[];
|
||||||
|
/** 标签文本 */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PeriodSelector: React.FC<PeriodSelectorProps> = memo(({
|
||||||
|
selectedPeriods,
|
||||||
|
onPeriodsChange,
|
||||||
|
onRefresh,
|
||||||
|
isLoading = false,
|
||||||
|
periodOptions = [4, 8, 12, 16],
|
||||||
|
label = '显示期数:',
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<HStack spacing={2} align="center" flexWrap="wrap">
|
||||||
|
<Text fontSize="sm" color="gray.400" whiteSpace="nowrap">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
value={selectedPeriods}
|
||||||
|
onChange={(value) => onPeriodsChange(value)}
|
||||||
|
style={{
|
||||||
|
minWidth: 110,
|
||||||
|
background: 'transparent',
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
popupClassName="period-selector-dropdown"
|
||||||
|
options={periodOptions.map((period) => ({
|
||||||
|
value: period,
|
||||||
|
label: `最近${period}期`,
|
||||||
|
}))}
|
||||||
|
dropdownStyle={{
|
||||||
|
background: '#1A202C',
|
||||||
|
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={<RefreshCw size={14} className={isLoading ? 'spin' : ''} />}
|
||||||
|
onClick={onRefresh}
|
||||||
|
isLoading={isLoading}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="刷新数据"
|
||||||
|
borderColor="rgba(212, 175, 55, 0.3)"
|
||||||
|
color="#D4AF37"
|
||||||
|
_hover={{
|
||||||
|
bg: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
borderColor: 'rgba(212, 175, 55, 0.5)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<style>{`
|
||||||
|
.period-selector-dropdown .ant-select-item {
|
||||||
|
color: #E2E8F0;
|
||||||
|
}
|
||||||
|
.period-selector-dropdown .ant-select-item-option-selected {
|
||||||
|
background: rgba(212, 175, 55, 0.2) !important;
|
||||||
|
color: #D4AF37;
|
||||||
|
}
|
||||||
|
.period-selector-dropdown .ant-select-item-option-active {
|
||||||
|
background: rgba(212, 175, 55, 0.1) !important;
|
||||||
|
}
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
PeriodSelector.displayName = 'PeriodSelector';
|
||||||
|
|
||||||
|
export { PeriodSelector };
|
||||||
|
export default PeriodSelector;
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* 股票对比组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
VStack,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Heading,
|
||||||
|
HStack,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Table,
|
||||||
|
Thead,
|
||||||
|
Tbody,
|
||||||
|
Tr,
|
||||||
|
Th,
|
||||||
|
Td,
|
||||||
|
TableContainer,
|
||||||
|
Text,
|
||||||
|
Grid,
|
||||||
|
GridItem,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { ArrowUpIcon, ArrowDownIcon } from '@chakra-ui/icons';
|
||||||
|
import { useToast } from '@chakra-ui/react';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import { logger } from '@utils/logger';
|
||||||
|
import { financialService, formatUtils } from '@services/financialService';
|
||||||
|
import { COMPARE_METRICS } from '../constants';
|
||||||
|
import { getValueByPath, getCompareBarChartOption } from '../utils';
|
||||||
|
import type { StockComparisonProps, StockInfo } from '../types';
|
||||||
|
|
||||||
|
interface CompareData {
|
||||||
|
stockInfo: StockInfo;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
metrics: any[];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
comparison: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StockComparison: React.FC<StockComparisonProps> = ({
|
||||||
|
currentStock,
|
||||||
|
stockInfo,
|
||||||
|
positiveColor,
|
||||||
|
negativeColor,
|
||||||
|
}) => {
|
||||||
|
const [compareStock, setCompareStock] = useState('');
|
||||||
|
const [compareData, setCompareData] = useState<CompareData | null>(null);
|
||||||
|
const [compareLoading, setCompareLoading] = useState(false);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const loadCompareData = async () => {
|
||||||
|
if (!compareStock || compareStock.length !== 6) {
|
||||||
|
logger.warn('StockComparison', '无效的对比股票代码', { compareStock });
|
||||||
|
toast({
|
||||||
|
title: '请输入有效的6位股票代码',
|
||||||
|
status: 'warning',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('StockComparison', '开始加载对比数据', { currentStock, compareStock });
|
||||||
|
setCompareLoading(true);
|
||||||
|
try {
|
||||||
|
const [stockInfoRes, metricsRes, comparisonRes] = await Promise.all([
|
||||||
|
financialService.getStockInfo(compareStock),
|
||||||
|
financialService.getFinancialMetrics(compareStock, 4),
|
||||||
|
financialService.getPeriodComparison(compareStock, 4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setCompareData({
|
||||||
|
stockInfo: stockInfoRes.data,
|
||||||
|
metrics: metricsRes.data,
|
||||||
|
comparison: comparisonRes.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('StockComparison', '对比数据加载成功', { currentStock, compareStock });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('StockComparison', 'loadCompareData', error, {
|
||||||
|
currentStock,
|
||||||
|
compareStock,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setCompareLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack spacing={4} align="stretch">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<HStack>
|
||||||
|
<Input
|
||||||
|
placeholder="输入对比股票代码"
|
||||||
|
value={compareStock}
|
||||||
|
onChange={(e) => setCompareStock(e.target.value)}
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
colorScheme="blue"
|
||||||
|
onClick={loadCompareData}
|
||||||
|
isLoading={compareLoading}
|
||||||
|
>
|
||||||
|
添加对比
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{compareData && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Heading size="md">
|
||||||
|
{stockInfo?.stock_name} ({currentStock}) VS{' '}
|
||||||
|
{compareData.stockInfo?.stock_name} ({compareStock})
|
||||||
|
</Heading>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="sm">
|
||||||
|
<Thead>
|
||||||
|
<Tr>
|
||||||
|
<Th>指标</Th>
|
||||||
|
<Th isNumeric>{stockInfo?.stock_name}</Th>
|
||||||
|
<Th isNumeric>{compareData.stockInfo?.stock_name}</Th>
|
||||||
|
<Th isNumeric>差异</Th>
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
<Tbody>
|
||||||
|
{COMPARE_METRICS.map((metric) => {
|
||||||
|
const value1 = getValueByPath<number>(stockInfo, metric.path);
|
||||||
|
const value2 = getValueByPath<number>(
|
||||||
|
compareData.stockInfo,
|
||||||
|
metric.path
|
||||||
|
);
|
||||||
|
|
||||||
|
let diff: number | null = null;
|
||||||
|
let diffColor = 'gray.500';
|
||||||
|
|
||||||
|
if (value1 !== undefined && value2 !== undefined) {
|
||||||
|
if (metric.format === 'percent') {
|
||||||
|
diff = value1 - value2;
|
||||||
|
diffColor = diff > 0 ? positiveColor : negativeColor;
|
||||||
|
} else {
|
||||||
|
diff = ((value1 - value2) / value2) * 100;
|
||||||
|
diffColor = diff > 0 ? positiveColor : negativeColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr key={metric.key}>
|
||||||
|
<Td>{metric.label}</Td>
|
||||||
|
<Td isNumeric>
|
||||||
|
{metric.format === 'percent'
|
||||||
|
? formatUtils.formatPercent(value1)
|
||||||
|
: formatUtils.formatLargeNumber(value1)}
|
||||||
|
</Td>
|
||||||
|
<Td isNumeric>
|
||||||
|
{metric.format === 'percent'
|
||||||
|
? formatUtils.formatPercent(value2)
|
||||||
|
: formatUtils.formatLargeNumber(value2)}
|
||||||
|
</Td>
|
||||||
|
<Td isNumeric color={diffColor}>
|
||||||
|
{diff !== null ? (
|
||||||
|
<HStack spacing={1} justify="flex-end">
|
||||||
|
{diff > 0 && <ArrowUpIcon boxSize={3} />}
|
||||||
|
{diff < 0 && <ArrowDownIcon boxSize={3} />}
|
||||||
|
<Text>
|
||||||
|
{metric.format === 'percent'
|
||||||
|
? `${Math.abs(diff).toFixed(2)}pp`
|
||||||
|
: `${Math.abs(diff).toFixed(2)}%`}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
{/* 对比图表 */}
|
||||||
|
<Grid templateColumns="repeat(2, 1fr)" gap={4} mt={6}>
|
||||||
|
<GridItem>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Heading size="sm">盈利能力对比</Heading>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<ReactECharts
|
||||||
|
option={getCompareBarChartOption(
|
||||||
|
'盈利能力对比',
|
||||||
|
stockInfo?.stock_name || '',
|
||||||
|
compareData.stockInfo?.stock_name || '',
|
||||||
|
['ROE', 'ROA', '毛利率', '净利率'],
|
||||||
|
[
|
||||||
|
stockInfo?.key_metrics?.roe,
|
||||||
|
stockInfo?.key_metrics?.roa,
|
||||||
|
stockInfo?.key_metrics?.gross_margin,
|
||||||
|
stockInfo?.key_metrics?.net_margin,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
compareData.stockInfo?.key_metrics?.roe,
|
||||||
|
compareData.stockInfo?.key_metrics?.roa,
|
||||||
|
compareData.stockInfo?.key_metrics?.gross_margin,
|
||||||
|
compareData.stockInfo?.key_metrics?.net_margin,
|
||||||
|
]
|
||||||
|
)}
|
||||||
|
style={{ height: '300px' }}
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</GridItem>
|
||||||
|
|
||||||
|
<GridItem>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Heading size="sm">成长能力对比</Heading>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<ReactECharts
|
||||||
|
option={getCompareBarChartOption(
|
||||||
|
'成长能力对比',
|
||||||
|
stockInfo?.stock_name || '',
|
||||||
|
compareData.stockInfo?.stock_name || '',
|
||||||
|
['营收增长', '利润增长', '资产增长', '股东权益增长'],
|
||||||
|
[
|
||||||
|
stockInfo?.growth_rates?.revenue_growth,
|
||||||
|
stockInfo?.growth_rates?.profit_growth,
|
||||||
|
stockInfo?.growth_rates?.asset_growth,
|
||||||
|
stockInfo?.growth_rates?.equity_growth,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
compareData.stockInfo?.growth_rates?.revenue_growth,
|
||||||
|
compareData.stockInfo?.growth_rates?.profit_growth,
|
||||||
|
compareData.stockInfo?.growth_rates?.asset_growth,
|
||||||
|
compareData.stockInfo?.growth_rates?.equity_growth,
|
||||||
|
]
|
||||||
|
)}
|
||||||
|
style={{ height: '300px' }}
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</GridItem>
|
||||||
|
</Grid>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StockComparison;
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* 股票信息头部组件 - 黑金主题
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Grid,
|
||||||
|
GridItem,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Heading,
|
||||||
|
Badge,
|
||||||
|
Stat,
|
||||||
|
StatLabel,
|
||||||
|
StatNumber,
|
||||||
|
Alert,
|
||||||
|
AlertIcon,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
import type { StockInfoHeaderProps } from '../types';
|
||||||
|
|
||||||
|
// 黑金主题配置
|
||||||
|
const darkGoldTheme = {
|
||||||
|
bgCard: 'rgba(26, 32, 44, 0.95)',
|
||||||
|
border: 'rgba(212, 175, 55, 0.3)',
|
||||||
|
borderHover: 'rgba(212, 175, 55, 0.5)',
|
||||||
|
gold: '#D4AF37',
|
||||||
|
goldLight: '#F4D03F',
|
||||||
|
orange: '#FF9500',
|
||||||
|
red: '#FF4444',
|
||||||
|
green: '#00C851',
|
||||||
|
textPrimary: 'rgba(255, 255, 255, 0.92)',
|
||||||
|
textSecondary: 'rgba(255, 255, 255, 0.7)',
|
||||||
|
textMuted: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
tagBg: 'rgba(212, 175, 55, 0.15)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StockInfoHeader: React.FC<StockInfoHeaderProps> = ({
|
||||||
|
stockInfo,
|
||||||
|
}) => {
|
||||||
|
if (!stockInfo) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
mb={4}
|
||||||
|
bg={darkGoldTheme.bgCard}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={darkGoldTheme.border}
|
||||||
|
borderRadius="xl"
|
||||||
|
p={5}
|
||||||
|
boxShadow="0 4px 20px rgba(0, 0, 0, 0.3)"
|
||||||
|
transition="all 0.3s ease"
|
||||||
|
_hover={{
|
||||||
|
borderColor: darkGoldTheme.borderHover,
|
||||||
|
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid templateColumns="repeat(5, 1fr)" gap={4} alignItems="center">
|
||||||
|
<GridItem colSpan={{ base: 5, md: 2 }}>
|
||||||
|
<VStack align="start" spacing={1}>
|
||||||
|
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
|
||||||
|
股票名称
|
||||||
|
</Text>
|
||||||
|
<HStack>
|
||||||
|
<Heading
|
||||||
|
size="md"
|
||||||
|
bgGradient={`linear(to-r, ${darkGoldTheme.gold}, ${darkGoldTheme.orange})`}
|
||||||
|
bgClip="text"
|
||||||
|
>
|
||||||
|
{stockInfo.stock_name}
|
||||||
|
</Heading>
|
||||||
|
<Badge
|
||||||
|
bg={darkGoldTheme.tagBg}
|
||||||
|
color={darkGoldTheme.gold}
|
||||||
|
fontSize="xs"
|
||||||
|
px={2}
|
||||||
|
py={1}
|
||||||
|
borderRadius="md"
|
||||||
|
>
|
||||||
|
{stockInfo.stock_code}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</GridItem>
|
||||||
|
<GridItem>
|
||||||
|
<Stat>
|
||||||
|
<StatLabel color={darkGoldTheme.textMuted} fontSize="xs">
|
||||||
|
ROE
|
||||||
|
</StatLabel>
|
||||||
|
<StatNumber color={darkGoldTheme.goldLight} fontSize="lg">
|
||||||
|
{formatUtils.formatPercent(stockInfo.key_metrics?.roe)}
|
||||||
|
</StatNumber>
|
||||||
|
</Stat>
|
||||||
|
</GridItem>
|
||||||
|
<GridItem>
|
||||||
|
<Stat>
|
||||||
|
<StatLabel color={darkGoldTheme.textMuted} fontSize="xs">
|
||||||
|
营收增长
|
||||||
|
</StatLabel>
|
||||||
|
<StatNumber
|
||||||
|
fontSize="lg"
|
||||||
|
color={
|
||||||
|
stockInfo.growth_rates?.revenue_growth
|
||||||
|
? stockInfo.growth_rates.revenue_growth > 0
|
||||||
|
? darkGoldTheme.red
|
||||||
|
: darkGoldTheme.green
|
||||||
|
: darkGoldTheme.textMuted
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatUtils.formatPercent(stockInfo.growth_rates?.revenue_growth)}
|
||||||
|
</StatNumber>
|
||||||
|
</Stat>
|
||||||
|
</GridItem>
|
||||||
|
<GridItem>
|
||||||
|
<Stat>
|
||||||
|
<StatLabel color={darkGoldTheme.textMuted} fontSize="xs">
|
||||||
|
利润增长
|
||||||
|
</StatLabel>
|
||||||
|
<StatNumber
|
||||||
|
fontSize="lg"
|
||||||
|
color={
|
||||||
|
stockInfo.growth_rates?.profit_growth
|
||||||
|
? stockInfo.growth_rates.profit_growth > 0
|
||||||
|
? darkGoldTheme.red
|
||||||
|
: darkGoldTheme.green
|
||||||
|
: darkGoldTheme.textMuted
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatUtils.formatPercent(stockInfo.growth_rates?.profit_growth)}
|
||||||
|
</StatNumber>
|
||||||
|
</Stat>
|
||||||
|
</GridItem>
|
||||||
|
</Grid>
|
||||||
|
{stockInfo.latest_forecast && (
|
||||||
|
<Alert
|
||||||
|
status="info"
|
||||||
|
mt={4}
|
||||||
|
bg="rgba(212, 175, 55, 0.1)"
|
||||||
|
borderRadius="lg"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={darkGoldTheme.border}
|
||||||
|
>
|
||||||
|
<AlertIcon color={darkGoldTheme.gold} />
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="bold" color={darkGoldTheme.gold}>
|
||||||
|
{stockInfo.latest_forecast.forecast_type}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color={darkGoldTheme.textSecondary}>
|
||||||
|
{stockInfo.latest_forecast.content}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StockInfoHeader;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 组件统一导出
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { PeriodSelector } from './PeriodSelector';
|
||||||
|
export { FinancialOverviewPanel } from './FinancialOverviewPanel';
|
||||||
|
// 保留旧组件导出(向后兼容)
|
||||||
|
export { KeyMetricsOverview } from './KeyMetricsOverview';
|
||||||
|
export { StockInfoHeader } from './StockInfoHeader';
|
||||||
|
export { BalanceSheetTable } from './BalanceSheetTable';
|
||||||
|
export { IncomeStatementTable } from './IncomeStatementTable';
|
||||||
|
export { CashflowTable } from './CashflowTable';
|
||||||
|
export { FinancialMetricsTable } from './FinancialMetricsTable';
|
||||||
|
export { MainBusinessAnalysis } from './MainBusinessAnalysis';
|
||||||
|
export { IndustryRankingView } from './IndustryRankingView';
|
||||||
|
export { StockComparison } from './StockComparison';
|
||||||
|
export { ComparisonAnalysis } from './ComparisonAnalysis';
|
||||||
341
src/views/Company/components/FinancialPanorama/constants.ts
Normal file
341
src/views/Company/components/FinancialPanorama/constants.ts
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
/**
|
||||||
|
* FinancialPanorama 常量配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MetricSectionConfig, MetricsCategoryMap } from './types';
|
||||||
|
|
||||||
|
// ==================== 颜色配置 ====================
|
||||||
|
|
||||||
|
/** 中国市场颜色:红涨绿跌 */
|
||||||
|
export const COLORS = {
|
||||||
|
positiveColor: 'red.500', // 涨
|
||||||
|
negativeColor: 'green.500', // 跌
|
||||||
|
bgColor: 'white',
|
||||||
|
borderColor: 'gray.200',
|
||||||
|
hoverBg: 'gray.50',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ==================== 资产负债表指标定义 ====================
|
||||||
|
|
||||||
|
/** 流动资产指标 */
|
||||||
|
export const CURRENT_ASSETS_METRICS: MetricSectionConfig = {
|
||||||
|
title: '流动资产',
|
||||||
|
key: 'currentAssets',
|
||||||
|
metrics: [
|
||||||
|
{ name: '货币资金', key: 'cash', path: 'assets.current_assets.cash', isCore: true },
|
||||||
|
{ name: '交易性金融资产', key: 'trading_financial_assets', path: 'assets.current_assets.trading_financial_assets' },
|
||||||
|
{ name: '应收票据', key: 'notes_receivable', path: 'assets.current_assets.notes_receivable' },
|
||||||
|
{ name: '应收账款', key: 'accounts_receivable', path: 'assets.current_assets.accounts_receivable', isCore: true },
|
||||||
|
{ name: '预付款项', key: 'prepayments', path: 'assets.current_assets.prepayments' },
|
||||||
|
{ name: '其他应收款', key: 'other_receivables', path: 'assets.current_assets.other_receivables' },
|
||||||
|
{ name: '存货', key: 'inventory', path: 'assets.current_assets.inventory', isCore: true },
|
||||||
|
{ name: '合同资产', key: 'contract_assets', path: 'assets.current_assets.contract_assets' },
|
||||||
|
{ name: '其他流动资产', key: 'other_current_assets', path: 'assets.current_assets.other_current_assets' },
|
||||||
|
{ name: '流动资产合计', key: 'total_current_assets', path: 'assets.current_assets.total', isTotal: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 非流动资产指标 */
|
||||||
|
export const NON_CURRENT_ASSETS_METRICS: MetricSectionConfig = {
|
||||||
|
title: '非流动资产',
|
||||||
|
key: 'nonCurrentAssets',
|
||||||
|
metrics: [
|
||||||
|
{ name: '长期股权投资', key: 'long_term_equity_investments', path: 'assets.non_current_assets.long_term_equity_investments' },
|
||||||
|
{ name: '投资性房地产', key: 'investment_property', path: 'assets.non_current_assets.investment_property' },
|
||||||
|
{ name: '固定资产', key: 'fixed_assets', path: 'assets.non_current_assets.fixed_assets', isCore: true },
|
||||||
|
{ name: '在建工程', key: 'construction_in_progress', path: 'assets.non_current_assets.construction_in_progress' },
|
||||||
|
{ name: '使用权资产', key: 'right_of_use_assets', path: 'assets.non_current_assets.right_of_use_assets' },
|
||||||
|
{ name: '无形资产', key: 'intangible_assets', path: 'assets.non_current_assets.intangible_assets', isCore: true },
|
||||||
|
{ name: '商誉', key: 'goodwill', path: 'assets.non_current_assets.goodwill', isCore: true },
|
||||||
|
{ name: '递延所得税资产', key: 'deferred_tax_assets', path: 'assets.non_current_assets.deferred_tax_assets' },
|
||||||
|
{ name: '其他非流动资产', key: 'other_non_current_assets', path: 'assets.non_current_assets.other_non_current_assets' },
|
||||||
|
{ name: '非流动资产合计', key: 'total_non_current_assets', path: 'assets.non_current_assets.total', isTotal: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 资产总计指标 */
|
||||||
|
export const TOTAL_ASSETS_METRICS: MetricSectionConfig = {
|
||||||
|
title: '资产总计',
|
||||||
|
key: 'totalAssets',
|
||||||
|
metrics: [
|
||||||
|
{ name: '资产总计', key: 'total_assets', path: 'assets.total', isTotal: true, isCore: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 流动负债指标 */
|
||||||
|
export const CURRENT_LIABILITIES_METRICS: MetricSectionConfig = {
|
||||||
|
title: '流动负债',
|
||||||
|
key: 'currentLiabilities',
|
||||||
|
metrics: [
|
||||||
|
{ name: '短期借款', key: 'short_term_borrowings', path: 'liabilities.current_liabilities.short_term_borrowings', isCore: true },
|
||||||
|
{ name: '应付票据', key: 'notes_payable', path: 'liabilities.current_liabilities.notes_payable' },
|
||||||
|
{ name: '应付账款', key: 'accounts_payable', path: 'liabilities.current_liabilities.accounts_payable', isCore: true },
|
||||||
|
{ name: '预收款项', key: 'advance_receipts', path: 'liabilities.current_liabilities.advance_receipts' },
|
||||||
|
{ name: '合同负债', key: 'contract_liabilities', path: 'liabilities.current_liabilities.contract_liabilities' },
|
||||||
|
{ name: '应付职工薪酬', key: 'employee_compensation_payable', path: 'liabilities.current_liabilities.employee_compensation_payable' },
|
||||||
|
{ name: '应交税费', key: 'taxes_payable', path: 'liabilities.current_liabilities.taxes_payable' },
|
||||||
|
{ name: '其他应付款', key: 'other_payables', path: 'liabilities.current_liabilities.other_payables' },
|
||||||
|
{ name: '一年内到期的非流动负债', key: 'non_current_due_within_one_year', path: 'liabilities.current_liabilities.non_current_liabilities_due_within_one_year' },
|
||||||
|
{ name: '流动负债合计', key: 'total_current_liabilities', path: 'liabilities.current_liabilities.total', isTotal: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 非流动负债指标 */
|
||||||
|
export const NON_CURRENT_LIABILITIES_METRICS: MetricSectionConfig = {
|
||||||
|
title: '非流动负债',
|
||||||
|
key: 'nonCurrentLiabilities',
|
||||||
|
metrics: [
|
||||||
|
{ name: '长期借款', key: 'long_term_borrowings', path: 'liabilities.non_current_liabilities.long_term_borrowings', isCore: true },
|
||||||
|
{ name: '应付债券', key: 'bonds_payable', path: 'liabilities.non_current_liabilities.bonds_payable' },
|
||||||
|
{ name: '租赁负债', key: 'lease_liabilities', path: 'liabilities.non_current_liabilities.lease_liabilities' },
|
||||||
|
{ name: '递延所得税负债', key: 'deferred_tax_liabilities', path: 'liabilities.non_current_liabilities.deferred_tax_liabilities' },
|
||||||
|
{ name: '其他非流动负债', key: 'other_non_current_liabilities', path: 'liabilities.non_current_liabilities.other_non_current_liabilities' },
|
||||||
|
{ name: '非流动负债合计', key: 'total_non_current_liabilities', path: 'liabilities.non_current_liabilities.total', isTotal: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 负债合计指标 */
|
||||||
|
export const TOTAL_LIABILITIES_METRICS: MetricSectionConfig = {
|
||||||
|
title: '负债合计',
|
||||||
|
key: 'totalLiabilities',
|
||||||
|
metrics: [
|
||||||
|
{ name: '负债合计', key: 'total_liabilities', path: 'liabilities.total', isTotal: true, isCore: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 股东权益指标 */
|
||||||
|
export const EQUITY_METRICS: MetricSectionConfig = {
|
||||||
|
title: '股东权益',
|
||||||
|
key: 'equity',
|
||||||
|
metrics: [
|
||||||
|
{ name: '股本', key: 'share_capital', path: 'equity.share_capital', isCore: true },
|
||||||
|
{ name: '资本公积', key: 'capital_reserve', path: 'equity.capital_reserve' },
|
||||||
|
{ name: '盈余公积', key: 'surplus_reserve', path: 'equity.surplus_reserve' },
|
||||||
|
{ name: '未分配利润', key: 'undistributed_profit', path: 'equity.undistributed_profit', isCore: true },
|
||||||
|
{ name: '库存股', key: 'treasury_stock', path: 'equity.treasury_stock' },
|
||||||
|
{ name: '其他综合收益', key: 'other_comprehensive_income', path: 'equity.other_comprehensive_income' },
|
||||||
|
{ name: '归属母公司股东权益', key: 'parent_company_equity', path: 'equity.parent_company_equity', isCore: true },
|
||||||
|
{ name: '少数股东权益', key: 'minority_interests', path: 'equity.minority_interests' },
|
||||||
|
{ name: '股东权益合计', key: 'total_equity', path: 'equity.total', isTotal: true, isCore: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 资产负债表所有分类 */
|
||||||
|
export const BALANCE_SHEET_SECTIONS = {
|
||||||
|
assets: [CURRENT_ASSETS_METRICS, NON_CURRENT_ASSETS_METRICS, TOTAL_ASSETS_METRICS],
|
||||||
|
liabilities: [CURRENT_LIABILITIES_METRICS, NON_CURRENT_LIABILITIES_METRICS, TOTAL_LIABILITIES_METRICS],
|
||||||
|
equity: [EQUITY_METRICS],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 利润表指标定义 ====================
|
||||||
|
|
||||||
|
export const INCOME_STATEMENT_SECTIONS: MetricSectionConfig[] = [
|
||||||
|
{
|
||||||
|
title: '营业收入',
|
||||||
|
key: 'revenue',
|
||||||
|
metrics: [
|
||||||
|
{ name: '营业总收入', key: 'total_revenue', path: 'revenue.total_operating_revenue', isCore: true },
|
||||||
|
{ name: '营业收入', key: 'revenue', path: 'revenue.operating_revenue', isCore: true },
|
||||||
|
{ name: '其他业务收入', key: 'other_income', path: 'revenue.other_income' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '营业成本与费用',
|
||||||
|
key: 'costs',
|
||||||
|
metrics: [
|
||||||
|
{ name: '营业总成本', key: 'total_cost', path: 'costs.total_operating_cost', isTotal: true },
|
||||||
|
{ name: '营业成本', key: 'cost', path: 'costs.operating_cost', isCore: true },
|
||||||
|
{ name: '税金及附加', key: 'taxes_and_surcharges', path: 'costs.taxes_and_surcharges' },
|
||||||
|
{ name: '销售费用', key: 'selling_expenses', path: 'costs.selling_expenses', isCore: true },
|
||||||
|
{ name: '管理费用', key: 'admin_expenses', path: 'costs.admin_expenses', isCore: true },
|
||||||
|
{ name: '研发费用', key: 'rd_expenses', path: 'costs.rd_expenses', isCore: true },
|
||||||
|
{ name: '财务费用', key: 'financial_expenses', path: 'costs.financial_expenses' },
|
||||||
|
{ name: ' 其中:利息费用', key: 'interest_expense', path: 'costs.interest_expense' },
|
||||||
|
{ name: ' 利息收入', key: 'interest_income', path: 'costs.interest_income' },
|
||||||
|
{ name: '三费合计', key: 'three_expenses', path: 'costs.three_expenses_total', isSubtotal: true },
|
||||||
|
{ name: '四费合计(含研发)', key: 'four_expenses', path: 'costs.four_expenses_total', isSubtotal: true },
|
||||||
|
{ name: '资产减值损失', key: 'asset_impairment', path: 'costs.asset_impairment_loss' },
|
||||||
|
{ name: '信用减值损失', key: 'credit_impairment', path: 'costs.credit_impairment_loss' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '其他收益',
|
||||||
|
key: 'otherGains',
|
||||||
|
metrics: [
|
||||||
|
{ name: '公允价值变动收益', key: 'fair_value_change', path: 'other_gains.fair_value_change' },
|
||||||
|
{ name: '投资收益', key: 'investment_income', path: 'other_gains.investment_income', isCore: true },
|
||||||
|
{ name: ' 其中:对联营企业和合营企业的投资收益', key: 'investment_income_associates', path: 'other_gains.investment_income_from_associates' },
|
||||||
|
{ name: '汇兑收益', key: 'exchange_income', path: 'other_gains.exchange_income' },
|
||||||
|
{ name: '资产处置收益', key: 'asset_disposal_income', path: 'other_gains.asset_disposal_income' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '利润',
|
||||||
|
key: 'profits',
|
||||||
|
metrics: [
|
||||||
|
{ name: '营业利润', key: 'operating_profit', path: 'profit.operating_profit', isCore: true, isTotal: true },
|
||||||
|
{ name: '加:营业外收入', key: 'non_operating_income', path: 'non_operating.non_operating_income' },
|
||||||
|
{ name: '减:营业外支出', key: 'non_operating_expenses', path: 'non_operating.non_operating_expenses' },
|
||||||
|
{ name: '利润总额', key: 'total_profit', path: 'profit.total_profit', isCore: true, isTotal: true },
|
||||||
|
{ name: '减:所得税费用', key: 'income_tax', path: 'profit.income_tax_expense' },
|
||||||
|
{ name: '净利润', key: 'net_profit', path: 'profit.net_profit', isCore: true, isTotal: true },
|
||||||
|
{ name: ' 归属母公司所有者的净利润', key: 'parent_net_profit', path: 'profit.parent_net_profit', isCore: true },
|
||||||
|
{ name: ' 少数股东损益', key: 'minority_profit', path: 'profit.minority_profit' },
|
||||||
|
{ name: '持续经营净利润', key: 'continuing_net_profit', path: 'profit.continuing_operations_net_profit' },
|
||||||
|
{ name: '终止经营净利润', key: 'discontinued_net_profit', path: 'profit.discontinued_operations_net_profit' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '每股收益',
|
||||||
|
key: 'eps',
|
||||||
|
metrics: [
|
||||||
|
{ name: '基本每股收益(元)', key: 'basic_eps', path: 'per_share.basic_eps', isCore: true },
|
||||||
|
{ name: '稀释每股收益(元)', key: 'diluted_eps', path: 'per_share.diluted_eps' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '综合收益',
|
||||||
|
key: 'comprehensive',
|
||||||
|
metrics: [
|
||||||
|
{ name: '其他综合收益(税后)', key: 'other_comprehensive_income', path: 'comprehensive_income.other_comprehensive_income' },
|
||||||
|
{ name: '综合收益总额', key: 'total_comprehensive_income', path: 'comprehensive_income.total_comprehensive_income', isTotal: true },
|
||||||
|
{ name: ' 归属母公司', key: 'parent_comprehensive_income', path: 'comprehensive_income.parent_comprehensive_income' },
|
||||||
|
{ name: ' 归属少数股东', key: 'minority_comprehensive_income', path: 'comprehensive_income.minority_comprehensive_income' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ==================== 现金流量表指标定义 ====================
|
||||||
|
|
||||||
|
export const CASHFLOW_METRICS = [
|
||||||
|
{ name: '经营现金流净额', key: 'operating_net', path: 'operating_activities.net_flow' },
|
||||||
|
{ name: '销售收现', key: 'cash_from_sales', path: 'operating_activities.inflow.cash_from_sales' },
|
||||||
|
{ name: '购买支付现金', key: 'cash_for_goods', path: 'operating_activities.outflow.cash_for_goods' },
|
||||||
|
{ name: '投资现金流净额', key: 'investment_net', path: 'investment_activities.net_flow' },
|
||||||
|
{ name: '筹资现金流净额', key: 'financing_net', path: 'financing_activities.net_flow' },
|
||||||
|
{ name: '现金净增加额', key: 'net_increase', path: 'cash_changes.net_increase' },
|
||||||
|
{ name: '期末现金余额', key: 'ending_balance', path: 'cash_changes.ending_balance' },
|
||||||
|
{ name: '自由现金流', key: 'free_cash_flow', path: 'key_metrics.free_cash_flow' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ==================== 财务指标分类定义 ====================
|
||||||
|
|
||||||
|
export const FINANCIAL_METRICS_CATEGORIES: MetricsCategoryMap = {
|
||||||
|
profitability: {
|
||||||
|
title: '盈利能力指标',
|
||||||
|
metrics: [
|
||||||
|
{ name: '净资产收益率(ROE)%', key: 'roe', path: 'profitability.roe', isCore: true },
|
||||||
|
{ name: '净资产收益率(扣非)%', key: 'roe_deducted', path: 'profitability.roe_deducted' },
|
||||||
|
{ name: '净资产收益率(加权)%', key: 'roe_weighted', path: 'profitability.roe_weighted', isCore: true },
|
||||||
|
{ name: '总资产报酬率(ROA)%', key: 'roa', path: 'profitability.roa', isCore: true },
|
||||||
|
{ name: '毛利率%', key: 'gross_margin', path: 'profitability.gross_margin', isCore: true },
|
||||||
|
{ name: '净利率%', key: 'net_margin', path: 'profitability.net_profit_margin', isCore: true },
|
||||||
|
{ name: '营业利润率%', key: 'operating_margin', path: 'profitability.operating_profit_margin' },
|
||||||
|
{ name: '成本费用利润率%', key: 'cost_profit_ratio', path: 'profitability.cost_profit_ratio' },
|
||||||
|
{ name: 'EBIT', key: 'ebit', path: 'profitability.ebit' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
perShare: {
|
||||||
|
title: '每股指标',
|
||||||
|
metrics: [
|
||||||
|
{ name: '每股收益(EPS)', key: 'eps', path: 'per_share_metrics.eps', isCore: true },
|
||||||
|
{ name: '基本每股收益', key: 'basic_eps', path: 'per_share_metrics.basic_eps', isCore: true },
|
||||||
|
{ name: '稀释每股收益', key: 'diluted_eps', path: 'per_share_metrics.diluted_eps' },
|
||||||
|
{ name: '扣非每股收益', key: 'deducted_eps', path: 'per_share_metrics.deducted_eps', isCore: true },
|
||||||
|
{ name: '每股净资产', key: 'bvps', path: 'per_share_metrics.bvps', isCore: true },
|
||||||
|
{ name: '每股经营现金流', key: 'operating_cash_flow_ps', path: 'per_share_metrics.operating_cash_flow_ps' },
|
||||||
|
{ name: '每股资本公积', key: 'capital_reserve_ps', path: 'per_share_metrics.capital_reserve_ps' },
|
||||||
|
{ name: '每股未分配利润', key: 'undistributed_profit_ps', path: 'per_share_metrics.undistributed_profit_ps' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
growth: {
|
||||||
|
title: '成长能力指标',
|
||||||
|
metrics: [
|
||||||
|
{ name: '营收增长率%', key: 'revenue_growth', path: 'growth.revenue_growth', isCore: true },
|
||||||
|
{ name: '净利润增长率%', key: 'profit_growth', path: 'growth.net_profit_growth', isCore: true },
|
||||||
|
{ name: '扣非净利润增长率%', key: 'deducted_profit_growth', path: 'growth.deducted_profit_growth', isCore: true },
|
||||||
|
{ name: '归母净利润增长率%', key: 'parent_profit_growth', path: 'growth.parent_profit_growth' },
|
||||||
|
{ name: '经营现金流增长率%', key: 'operating_cash_flow_growth', path: 'growth.operating_cash_flow_growth' },
|
||||||
|
{ name: '总资产增长率%', key: 'asset_growth', path: 'growth.total_asset_growth' },
|
||||||
|
{ name: '净资产增长率%', key: 'equity_growth', path: 'growth.equity_growth' },
|
||||||
|
{ name: '固定资产增长率%', key: 'fixed_asset_growth', path: 'growth.fixed_asset_growth' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
operational: {
|
||||||
|
title: '运营效率指标',
|
||||||
|
metrics: [
|
||||||
|
{ name: '总资产周转率', key: 'asset_turnover', path: 'operational_efficiency.total_asset_turnover', isCore: true },
|
||||||
|
{ name: '固定资产周转率', key: 'fixed_asset_turnover', path: 'operational_efficiency.fixed_asset_turnover' },
|
||||||
|
{ name: '流动资产周转率', key: 'current_asset_turnover', path: 'operational_efficiency.current_asset_turnover' },
|
||||||
|
{ name: '应收账款周转率', key: 'receivable_turnover', path: 'operational_efficiency.receivable_turnover', isCore: true },
|
||||||
|
{ name: '应收账款周转天数', key: 'receivable_days', path: 'operational_efficiency.receivable_days', isCore: true },
|
||||||
|
{ name: '存货周转率', key: 'inventory_turnover', path: 'operational_efficiency.inventory_turnover', isCore: true },
|
||||||
|
{ name: '存货周转天数', key: 'inventory_days', path: 'operational_efficiency.inventory_days' },
|
||||||
|
{ name: '营运资金周转率', key: 'working_capital_turnover', path: 'operational_efficiency.working_capital_turnover' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
solvency: {
|
||||||
|
title: '偿债能力指标',
|
||||||
|
metrics: [
|
||||||
|
{ name: '流动比率', key: 'current_ratio', path: 'solvency.current_ratio', isCore: true },
|
||||||
|
{ name: '速动比率', key: 'quick_ratio', path: 'solvency.quick_ratio', isCore: true },
|
||||||
|
{ name: '现金比率', key: 'cash_ratio', path: 'solvency.cash_ratio' },
|
||||||
|
{ name: '保守速动比率', key: 'conservative_quick_ratio', path: 'solvency.conservative_quick_ratio' },
|
||||||
|
{ name: '资产负债率%', key: 'debt_ratio', path: 'solvency.asset_liability_ratio', isCore: true },
|
||||||
|
{ name: '利息保障倍数', key: 'interest_coverage', path: 'solvency.interest_coverage' },
|
||||||
|
{ name: '现金到期债务比', key: 'cash_to_maturity_debt', path: 'solvency.cash_to_maturity_debt_ratio' },
|
||||||
|
{ name: '有形资产净值债务率%', key: 'tangible_asset_debt_ratio', path: 'solvency.tangible_asset_debt_ratio' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
expense: {
|
||||||
|
title: '费用率指标',
|
||||||
|
metrics: [
|
||||||
|
{ name: '销售费用率%', key: 'selling_expense_ratio', path: 'expense_ratios.selling_expense_ratio', isCore: true },
|
||||||
|
{ name: '管理费用率%', key: 'admin_expense_ratio', path: 'expense_ratios.admin_expense_ratio', isCore: true },
|
||||||
|
{ name: '财务费用率%', key: 'financial_expense_ratio', path: 'expense_ratios.financial_expense_ratio' },
|
||||||
|
{ name: '研发费用率%', key: 'rd_expense_ratio', path: 'expense_ratios.rd_expense_ratio', isCore: true },
|
||||||
|
{ name: '三费费用率%', key: 'three_expense_ratio', path: 'expense_ratios.three_expense_ratio' },
|
||||||
|
{ name: '四费费用率%', key: 'four_expense_ratio', path: 'expense_ratios.four_expense_ratio' },
|
||||||
|
{ name: '营业成本率%', key: 'cost_ratio', path: 'expense_ratios.cost_ratio' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
cashflow: {
|
||||||
|
title: '现金流量指标',
|
||||||
|
metrics: [
|
||||||
|
{ name: '经营现金流/净利润', key: 'cash_to_profit', path: 'cash_flow_quality.operating_cash_to_profit_ratio', isCore: true },
|
||||||
|
{ name: '净利含金量', key: 'profit_cash_content', path: 'cash_flow_quality.cash_to_profit_ratio', isCore: true },
|
||||||
|
{ name: '营收现金含量', key: 'revenue_cash_content', path: 'cash_flow_quality.cash_revenue_ratio' },
|
||||||
|
{ name: '全部资产现金回收率%', key: 'cash_recovery_rate', path: 'cash_flow_quality.cash_recovery_rate' },
|
||||||
|
{ name: '经营现金流/短期债务', key: 'cash_to_short_debt', path: 'cash_flow_quality.operating_cash_to_short_debt' },
|
||||||
|
{ name: '经营现金流/总债务', key: 'cash_to_total_debt', path: 'cash_flow_quality.operating_cash_to_total_debt' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 行业排名指标 ====================
|
||||||
|
|
||||||
|
export const RANKING_METRICS = [
|
||||||
|
{ name: 'EPS', key: 'eps' },
|
||||||
|
{ name: '每股净资产', key: 'bvps' },
|
||||||
|
{ name: 'ROE', key: 'roe' },
|
||||||
|
{ name: '营收增长率', key: 'revenue_growth' },
|
||||||
|
{ name: '利润增长率', key: 'profit_growth' },
|
||||||
|
{ name: '营业利润率', key: 'operating_margin' },
|
||||||
|
{ name: '资产负债率', key: 'debt_ratio' },
|
||||||
|
{ name: '应收账款周转率', key: 'receivable_turnover' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ==================== 对比指标 ====================
|
||||||
|
|
||||||
|
export const COMPARE_METRICS = [
|
||||||
|
{ label: '营业收入', key: 'revenue', path: 'financial_summary.revenue' },
|
||||||
|
{ label: '净利润', key: 'net_profit', path: 'financial_summary.net_profit' },
|
||||||
|
{ label: 'ROE', key: 'roe', path: 'key_metrics.roe', format: 'percent' },
|
||||||
|
{ label: 'ROA', key: 'roa', path: 'key_metrics.roa', format: 'percent' },
|
||||||
|
{ label: '毛利率', key: 'gross_margin', path: 'key_metrics.gross_margin', format: 'percent' },
|
||||||
|
{ label: '净利率', key: 'net_margin', path: 'key_metrics.net_margin', format: 'percent' },
|
||||||
|
{ label: '营收增长率', key: 'revenue_growth', path: 'growth_rates.revenue_growth', format: 'percent' },
|
||||||
|
{ label: '利润增长率', key: 'profit_growth', path: 'growth_rates.profit_growth', format: 'percent' },
|
||||||
|
{ label: '资产总额', key: 'total_assets', path: 'financial_summary.total_assets' },
|
||||||
|
{ label: '负债总额', key: 'total_liabilities', path: 'financial_summary.total_liabilities' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Hooks 统一导出
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { useFinancialData } from './useFinancialData';
|
||||||
|
export type { DataTypeKey } from './useFinancialData';
|
||||||
|
export type { default as UseFinancialDataReturn } from './useFinancialData';
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
/**
|
||||||
|
* 财务数据加载 Hook
|
||||||
|
* 封装所有财务数据的加载逻辑,支持按 Tab 独立刷新
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useToast } from '@chakra-ui/react';
|
||||||
|
import { logger } from '@utils/logger';
|
||||||
|
import { financialService } from '@services/financialService';
|
||||||
|
import type {
|
||||||
|
StockInfo,
|
||||||
|
BalanceSheetData,
|
||||||
|
IncomeStatementData,
|
||||||
|
CashflowData,
|
||||||
|
FinancialMetricsData,
|
||||||
|
MainBusinessData,
|
||||||
|
ForecastData,
|
||||||
|
IndustryRankData,
|
||||||
|
ComparisonData,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
// Tab key 到数据类型的映射
|
||||||
|
export type DataTypeKey =
|
||||||
|
| 'balance'
|
||||||
|
| 'income'
|
||||||
|
| 'cashflow'
|
||||||
|
| 'profitability'
|
||||||
|
| 'perShare'
|
||||||
|
| 'growth'
|
||||||
|
| 'operational'
|
||||||
|
| 'solvency'
|
||||||
|
| 'expense'
|
||||||
|
| 'cashflowMetrics';
|
||||||
|
|
||||||
|
interface UseFinancialDataOptions {
|
||||||
|
stockCode?: string;
|
||||||
|
periods?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFinancialDataReturn {
|
||||||
|
// 数据状态
|
||||||
|
stockInfo: StockInfo | null;
|
||||||
|
balanceSheet: BalanceSheetData[];
|
||||||
|
incomeStatement: IncomeStatementData[];
|
||||||
|
cashflow: CashflowData[];
|
||||||
|
financialMetrics: FinancialMetricsData[];
|
||||||
|
mainBusiness: MainBusinessData | null;
|
||||||
|
forecast: ForecastData | null;
|
||||||
|
industryRank: IndustryRankData[];
|
||||||
|
comparison: ComparisonData[];
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
loading: boolean;
|
||||||
|
loadingTab: DataTypeKey | null; // 当前正在加载的 Tab
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// 操作方法
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
refetchByTab: (tabKey: DataTypeKey) => Promise<void>;
|
||||||
|
setStockCode: (code: string) => void;
|
||||||
|
setSelectedPeriods: (periods: number) => void;
|
||||||
|
setActiveTab: (tabKey: DataTypeKey) => void;
|
||||||
|
|
||||||
|
// 当前参数
|
||||||
|
currentStockCode: string;
|
||||||
|
selectedPeriods: number;
|
||||||
|
activeTab: DataTypeKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 财务数据加载 Hook
|
||||||
|
* @param options - 配置选项
|
||||||
|
* @returns 财务数据和操作方法
|
||||||
|
*/
|
||||||
|
export const useFinancialData = (
|
||||||
|
options: UseFinancialDataOptions = {}
|
||||||
|
): UseFinancialDataReturn => {
|
||||||
|
const { stockCode: initialStockCode = '600000', periods: initialPeriods = 8 } = options;
|
||||||
|
|
||||||
|
// 参数状态
|
||||||
|
const [stockCode, setStockCode] = useState(initialStockCode);
|
||||||
|
const [selectedPeriods, setSelectedPeriodsState] = useState(initialPeriods);
|
||||||
|
const [activeTab, setActiveTab] = useState<DataTypeKey>('profitability');
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingTab, setLoadingTab] = useState<DataTypeKey | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 财务数据状态
|
||||||
|
const [stockInfo, setStockInfo] = useState<StockInfo | null>(null);
|
||||||
|
const [balanceSheet, setBalanceSheet] = useState<BalanceSheetData[]>([]);
|
||||||
|
const [incomeStatement, setIncomeStatement] = useState<IncomeStatementData[]>([]);
|
||||||
|
const [cashflow, setCashflow] = useState<CashflowData[]>([]);
|
||||||
|
const [financialMetrics, setFinancialMetrics] = useState<FinancialMetricsData[]>([]);
|
||||||
|
const [mainBusiness, setMainBusiness] = useState<MainBusinessData | null>(null);
|
||||||
|
const [forecast, setForecast] = useState<ForecastData | null>(null);
|
||||||
|
const [industryRank, setIndustryRank] = useState<IndustryRankData[]>([]);
|
||||||
|
const [comparison, setComparison] = useState<ComparisonData[]>([]);
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const isInitialLoad = useRef(true);
|
||||||
|
const prevPeriods = useRef(selectedPeriods);
|
||||||
|
|
||||||
|
// 判断 Tab key 对应的数据类型
|
||||||
|
const getDataTypeForTab = (tabKey: DataTypeKey): 'balance' | 'income' | 'cashflow' | 'metrics' => {
|
||||||
|
switch (tabKey) {
|
||||||
|
case 'balance':
|
||||||
|
return 'balance';
|
||||||
|
case 'income':
|
||||||
|
return 'income';
|
||||||
|
case 'cashflow':
|
||||||
|
return 'cashflow';
|
||||||
|
default:
|
||||||
|
// 所有财务指标类 tab 都使用 metrics 数据
|
||||||
|
return 'metrics';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 按数据类型加载数据
|
||||||
|
const loadDataByType = useCallback(async (
|
||||||
|
dataType: 'balance' | 'income' | 'cashflow' | 'metrics',
|
||||||
|
periods: number
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
switch (dataType) {
|
||||||
|
case 'balance': {
|
||||||
|
const res = await financialService.getBalanceSheet(stockCode, periods);
|
||||||
|
if (res.success) setBalanceSheet(res.data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'income': {
|
||||||
|
const res = await financialService.getIncomeStatement(stockCode, periods);
|
||||||
|
if (res.success) setIncomeStatement(res.data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'cashflow': {
|
||||||
|
const res = await financialService.getCashflow(stockCode, periods);
|
||||||
|
if (res.success) setCashflow(res.data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'metrics': {
|
||||||
|
const res = await financialService.getFinancialMetrics(stockCode, periods);
|
||||||
|
if (res.success) setFinancialMetrics(res.data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('useFinancialData', 'loadDataByType', err, { dataType, periods });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
|
// 按 Tab 刷新数据
|
||||||
|
const refetchByTab = useCallback(async (tabKey: DataTypeKey) => {
|
||||||
|
if (!stockCode || stockCode.length !== 6) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataType = getDataTypeForTab(tabKey);
|
||||||
|
logger.debug('useFinancialData', '刷新单个 Tab 数据', { tabKey, dataType, selectedPeriods });
|
||||||
|
|
||||||
|
setLoadingTab(tabKey);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadDataByType(dataType, selectedPeriods);
|
||||||
|
logger.info('useFinancialData', `${tabKey} 数据刷新成功`);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '未知错误';
|
||||||
|
setError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setLoadingTab(null);
|
||||||
|
}
|
||||||
|
}, [stockCode, selectedPeriods, loadDataByType]);
|
||||||
|
|
||||||
|
// 设置期数(只刷新当前 Tab)
|
||||||
|
const setSelectedPeriods = useCallback((periods: number) => {
|
||||||
|
setSelectedPeriodsState(periods);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 加载所有财务数据(初始加载)
|
||||||
|
const loadAllFinancialData = useCallback(async () => {
|
||||||
|
if (!stockCode || stockCode.length !== 6) {
|
||||||
|
logger.warn('useFinancialData', '无效的股票代码', { stockCode });
|
||||||
|
toast({
|
||||||
|
title: '请输入有效的6位股票代码',
|
||||||
|
status: 'warning',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('useFinancialData', '开始加载全部财务数据', { stockCode, selectedPeriods });
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 并行加载所有数据
|
||||||
|
const [
|
||||||
|
stockInfoRes,
|
||||||
|
balanceRes,
|
||||||
|
incomeRes,
|
||||||
|
cashflowRes,
|
||||||
|
metricsRes,
|
||||||
|
businessRes,
|
||||||
|
forecastRes,
|
||||||
|
rankRes,
|
||||||
|
comparisonRes,
|
||||||
|
] = await Promise.all([
|
||||||
|
financialService.getStockInfo(stockCode),
|
||||||
|
financialService.getBalanceSheet(stockCode, selectedPeriods),
|
||||||
|
financialService.getIncomeStatement(stockCode, selectedPeriods),
|
||||||
|
financialService.getCashflow(stockCode, selectedPeriods),
|
||||||
|
financialService.getFinancialMetrics(stockCode, selectedPeriods),
|
||||||
|
financialService.getMainBusiness(stockCode, 4),
|
||||||
|
financialService.getForecast(stockCode),
|
||||||
|
financialService.getIndustryRank(stockCode, 4),
|
||||||
|
financialService.getPeriodComparison(stockCode, selectedPeriods),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 设置数据
|
||||||
|
if (stockInfoRes.success) setStockInfo(stockInfoRes.data);
|
||||||
|
if (balanceRes.success) setBalanceSheet(balanceRes.data);
|
||||||
|
if (incomeRes.success) setIncomeStatement(incomeRes.data);
|
||||||
|
if (cashflowRes.success) setCashflow(cashflowRes.data);
|
||||||
|
if (metricsRes.success) setFinancialMetrics(metricsRes.data);
|
||||||
|
if (businessRes.success) setMainBusiness(businessRes.data);
|
||||||
|
if (forecastRes.success) setForecast(forecastRes.data);
|
||||||
|
if (rankRes.success) setIndustryRank(rankRes.data);
|
||||||
|
if (comparisonRes.success) setComparison(comparisonRes.data);
|
||||||
|
|
||||||
|
logger.info('useFinancialData', '全部财务数据加载成功', { stockCode });
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '未知错误';
|
||||||
|
setError(errorMessage);
|
||||||
|
logger.error('useFinancialData', 'loadAllFinancialData', err, { stockCode, selectedPeriods });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [stockCode, selectedPeriods, toast]);
|
||||||
|
|
||||||
|
// 监听 props 中的 stockCode 变化
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialStockCode && initialStockCode !== stockCode) {
|
||||||
|
setStockCode(initialStockCode);
|
||||||
|
}
|
||||||
|
}, [initialStockCode]);
|
||||||
|
|
||||||
|
// 初始加载(仅股票代码变化时全量加载)
|
||||||
|
useEffect(() => {
|
||||||
|
if (stockCode) {
|
||||||
|
loadAllFinancialData();
|
||||||
|
isInitialLoad.current = false;
|
||||||
|
}
|
||||||
|
}, [stockCode]); // 注意:这里只依赖 stockCode
|
||||||
|
|
||||||
|
// 期数变化时只刷新当前 Tab
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialLoad.current && prevPeriods.current !== selectedPeriods) {
|
||||||
|
prevPeriods.current = selectedPeriods;
|
||||||
|
refetchByTab(activeTab);
|
||||||
|
}
|
||||||
|
}, [selectedPeriods, activeTab, refetchByTab]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 数据状态
|
||||||
|
stockInfo,
|
||||||
|
balanceSheet,
|
||||||
|
incomeStatement,
|
||||||
|
cashflow,
|
||||||
|
financialMetrics,
|
||||||
|
mainBusiness,
|
||||||
|
forecast,
|
||||||
|
industryRank,
|
||||||
|
comparison,
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
loading,
|
||||||
|
loadingTab,
|
||||||
|
error,
|
||||||
|
|
||||||
|
// 操作方法
|
||||||
|
refetch: loadAllFinancialData,
|
||||||
|
refetchByTab,
|
||||||
|
setStockCode,
|
||||||
|
setSelectedPeriods,
|
||||||
|
setActiveTab,
|
||||||
|
|
||||||
|
// 当前参数
|
||||||
|
currentStockCode: stockCode,
|
||||||
|
selectedPeriods,
|
||||||
|
activeTab,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useFinancialData;
|
||||||
File diff suppressed because it is too large
Load Diff
349
src/views/Company/components/FinancialPanorama/index.tsx
Normal file
349
src/views/Company/components/FinancialPanorama/index.tsx
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
/**
|
||||||
|
* 财务全景组件
|
||||||
|
* 重构后的主组件,使用模块化结构和 SubTabContainer 二级导航
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo, useCallback, ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
VStack,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
Text,
|
||||||
|
Alert,
|
||||||
|
AlertIcon,
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
useDisclosure,
|
||||||
|
Table,
|
||||||
|
Thead,
|
||||||
|
Tbody,
|
||||||
|
Tr,
|
||||||
|
Th,
|
||||||
|
Td,
|
||||||
|
TableContainer,
|
||||||
|
Divider,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
DollarSign,
|
||||||
|
TrendingUp,
|
||||||
|
PieChart,
|
||||||
|
Percent,
|
||||||
|
TrendingDown,
|
||||||
|
Activity,
|
||||||
|
Shield,
|
||||||
|
Receipt,
|
||||||
|
Banknote,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
|
||||||
|
// 通用组件
|
||||||
|
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
|
||||||
|
import LoadingState from '../LoadingState';
|
||||||
|
|
||||||
|
// 内部模块导入
|
||||||
|
import { useFinancialData, type DataTypeKey } from './hooks';
|
||||||
|
import { COLORS } from './constants';
|
||||||
|
import { calculateYoYChange, getCellBackground, getMetricChartOption } from './utils';
|
||||||
|
import { PeriodSelector, FinancialOverviewPanel, MainBusinessAnalysis, ComparisonAnalysis } from './components';
|
||||||
|
import {
|
||||||
|
BalanceSheetTab,
|
||||||
|
IncomeStatementTab,
|
||||||
|
CashflowTab,
|
||||||
|
ProfitabilityTab,
|
||||||
|
PerShareTab,
|
||||||
|
GrowthTab,
|
||||||
|
OperationalTab,
|
||||||
|
SolvencyTab,
|
||||||
|
ExpenseTab,
|
||||||
|
CashflowMetricsTab,
|
||||||
|
} from './tabs';
|
||||||
|
import type { FinancialPanoramaProps } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 财务全景主组件
|
||||||
|
*/
|
||||||
|
// Tab key 映射表(SubTabContainer index -> DataTypeKey)
|
||||||
|
const TAB_KEY_MAP: DataTypeKey[] = [
|
||||||
|
'profitability',
|
||||||
|
'perShare',
|
||||||
|
'growth',
|
||||||
|
'operational',
|
||||||
|
'solvency',
|
||||||
|
'expense',
|
||||||
|
'cashflowMetrics',
|
||||||
|
'balance',
|
||||||
|
'income',
|
||||||
|
'cashflow',
|
||||||
|
];
|
||||||
|
|
||||||
|
const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propStockCode }) => {
|
||||||
|
// 使用数据加载 Hook
|
||||||
|
const {
|
||||||
|
stockInfo,
|
||||||
|
balanceSheet,
|
||||||
|
incomeStatement,
|
||||||
|
cashflow,
|
||||||
|
financialMetrics,
|
||||||
|
mainBusiness,
|
||||||
|
comparison,
|
||||||
|
loading,
|
||||||
|
loadingTab,
|
||||||
|
error,
|
||||||
|
refetchByTab,
|
||||||
|
selectedPeriods,
|
||||||
|
setSelectedPeriods,
|
||||||
|
setActiveTab,
|
||||||
|
activeTab,
|
||||||
|
} = useFinancialData({ stockCode: propStockCode });
|
||||||
|
|
||||||
|
// 处理 Tab 切换
|
||||||
|
const handleTabChange = useCallback((index: number, tabKey: string) => {
|
||||||
|
const dataTypeKey = TAB_KEY_MAP[index] || (tabKey as DataTypeKey);
|
||||||
|
setActiveTab(dataTypeKey);
|
||||||
|
}, [setActiveTab]);
|
||||||
|
|
||||||
|
// 处理刷新 - 只刷新当前 Tab
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
refetchByTab(activeTab);
|
||||||
|
}, [refetchByTab, activeTab]);
|
||||||
|
|
||||||
|
// UI 状态
|
||||||
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
|
const [modalContent, setModalContent] = useState<ReactNode>(null);
|
||||||
|
|
||||||
|
// 颜色配置
|
||||||
|
const { bgColor, hoverBg, positiveColor, negativeColor } = COLORS;
|
||||||
|
|
||||||
|
// 点击指标行显示图表
|
||||||
|
const showMetricChart = (
|
||||||
|
metricName: string,
|
||||||
|
_metricKey: string,
|
||||||
|
data: Array<{ period: string; [key: string]: unknown }>,
|
||||||
|
dataPath: string
|
||||||
|
) => {
|
||||||
|
const chartData = data
|
||||||
|
.map((item) => {
|
||||||
|
const value = dataPath.split('.').reduce((obj: unknown, key: string) => {
|
||||||
|
if (obj && typeof obj === 'object') {
|
||||||
|
return (obj as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, item) as number | undefined;
|
||||||
|
return {
|
||||||
|
period: formatUtils.getReportType(item.period),
|
||||||
|
date: item.period,
|
||||||
|
value: value ?? 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.reverse();
|
||||||
|
|
||||||
|
const option = getMetricChartOption(metricName, chartData);
|
||||||
|
|
||||||
|
setModalContent(
|
||||||
|
<Box>
|
||||||
|
<ReactECharts option={option} style={{ height: '400px', width: '100%' }} />
|
||||||
|
<Divider my={4} />
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="sm">
|
||||||
|
<Thead>
|
||||||
|
<Tr>
|
||||||
|
<Th>报告期</Th>
|
||||||
|
<Th isNumeric>数值</Th>
|
||||||
|
<Th isNumeric>同比</Th>
|
||||||
|
<Th isNumeric>环比</Th>
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
<Tbody>
|
||||||
|
{chartData.map((item, idx) => {
|
||||||
|
// 计算环比
|
||||||
|
const qoq =
|
||||||
|
idx > 0
|
||||||
|
? ((item.value - chartData[idx - 1].value) /
|
||||||
|
Math.abs(chartData[idx - 1].value)) *
|
||||||
|
100
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 计算同比
|
||||||
|
const currentDate = new Date(item.date);
|
||||||
|
const lastYearItem = chartData.find((d) => {
|
||||||
|
const date = new Date(d.date);
|
||||||
|
return (
|
||||||
|
date.getFullYear() === currentDate.getFullYear() - 1 &&
|
||||||
|
date.getMonth() === currentDate.getMonth()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const yoy = lastYearItem
|
||||||
|
? ((item.value - lastYearItem.value) / Math.abs(lastYearItem.value)) * 100
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr key={idx}>
|
||||||
|
<Td>{item.period}</Td>
|
||||||
|
<Td isNumeric>{formatUtils.formatLargeNumber(item.value)}</Td>
|
||||||
|
<Td
|
||||||
|
isNumeric
|
||||||
|
color={
|
||||||
|
yoy !== null && yoy > 0
|
||||||
|
? positiveColor
|
||||||
|
: yoy !== null && yoy < 0
|
||||||
|
? negativeColor
|
||||||
|
: 'gray.500'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{yoy !== null ? `${yoy.toFixed(2)}%` : '-'}
|
||||||
|
</Td>
|
||||||
|
<Td
|
||||||
|
isNumeric
|
||||||
|
color={
|
||||||
|
qoq !== null && qoq > 0
|
||||||
|
? positiveColor
|
||||||
|
: qoq !== null && qoq < 0
|
||||||
|
? negativeColor
|
||||||
|
: 'gray.500'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{qoq !== null ? `${qoq.toFixed(2)}%` : '-'}
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
onOpen();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tab 配置 - 财务指标分类 + 三大财务报表
|
||||||
|
const tabConfigs: SubTabConfig[] = useMemo(
|
||||||
|
() => [
|
||||||
|
// 财务指标分类(7个)
|
||||||
|
{ key: 'profitability', name: '盈利能力', icon: PieChart, component: ProfitabilityTab },
|
||||||
|
{ key: 'perShare', name: '每股指标', icon: Percent, component: PerShareTab },
|
||||||
|
{ key: 'growth', name: '成长能力', icon: TrendingUp, component: GrowthTab },
|
||||||
|
{ key: 'operational', name: '运营效率', icon: Activity, component: OperationalTab },
|
||||||
|
{ key: 'solvency', name: '偿债能力', icon: Shield, component: SolvencyTab },
|
||||||
|
{ key: 'expense', name: '费用率', icon: Receipt, component: ExpenseTab },
|
||||||
|
{ key: 'cashflowMetrics', name: '现金流指标', icon: Banknote, component: CashflowMetricsTab },
|
||||||
|
// 三大财务报表
|
||||||
|
{ key: 'balance', name: '资产负债表', icon: BarChart3, component: BalanceSheetTab },
|
||||||
|
{ key: 'income', name: '利润表', icon: DollarSign, component: IncomeStatementTab },
|
||||||
|
{ key: 'cashflow', name: '现金流量表', icon: TrendingDown, component: CashflowTab },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 传递给 Tab 组件的 props
|
||||||
|
const componentProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
// 数据
|
||||||
|
balanceSheet,
|
||||||
|
incomeStatement,
|
||||||
|
cashflow,
|
||||||
|
financialMetrics,
|
||||||
|
// 工具函数
|
||||||
|
showMetricChart,
|
||||||
|
calculateYoYChange,
|
||||||
|
getCellBackground,
|
||||||
|
// 颜色配置
|
||||||
|
positiveColor,
|
||||||
|
negativeColor,
|
||||||
|
bgColor,
|
||||||
|
hoverBg,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
balanceSheet,
|
||||||
|
incomeStatement,
|
||||||
|
cashflow,
|
||||||
|
financialMetrics,
|
||||||
|
showMetricChart,
|
||||||
|
positiveColor,
|
||||||
|
negativeColor,
|
||||||
|
bgColor,
|
||||||
|
hoverBg,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxW="container.xl" py={5}>
|
||||||
|
<VStack spacing={6} align="stretch">
|
||||||
|
{/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */}
|
||||||
|
{loading ? (
|
||||||
|
<LoadingState message="加载财务数据中..." height="300px" />
|
||||||
|
) : (
|
||||||
|
<FinancialOverviewPanel
|
||||||
|
stockInfo={stockInfo}
|
||||||
|
financialMetrics={financialMetrics}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 营收与利润趋势 */}
|
||||||
|
{!loading && comparison && comparison.length > 0 && (
|
||||||
|
<ComparisonAnalysis comparison={comparison} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 主营业务 */}
|
||||||
|
{!loading && stockInfo && (
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="lg" fontWeight="bold" mb={4} color="#D4AF37">
|
||||||
|
主营业务
|
||||||
|
</Text>
|
||||||
|
<MainBusinessAnalysis mainBusiness={mainBusiness} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 三大财务报表 - 使用 SubTabContainer 二级导航 */}
|
||||||
|
{!loading && stockInfo && (
|
||||||
|
<Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
|
||||||
|
<CardBody p={0}>
|
||||||
|
<SubTabContainer
|
||||||
|
tabs={tabConfigs}
|
||||||
|
componentProps={componentProps}
|
||||||
|
themePreset="blackGold"
|
||||||
|
isLazy
|
||||||
|
onTabChange={handleTabChange}
|
||||||
|
rightElement={
|
||||||
|
<PeriodSelector
|
||||||
|
selectedPeriods={selectedPeriods}
|
||||||
|
onPeriodsChange={setSelectedPeriods}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
isLoading={loadingTab !== null}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<Alert status="error">
|
||||||
|
<AlertIcon />
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 弹出模态框 */}
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent maxW="900px">
|
||||||
|
<ModalHeader>指标详情</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody pb={6}>{modalContent}</ModalBody>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
</VStack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FinancialPanorama;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* 资产负债表 Tab
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, VStack, HStack, Heading, Badge, Text } from '@chakra-ui/react';
|
||||||
|
import { BalanceSheetTable } from '../components';
|
||||||
|
import type { BalanceSheetData } from '../types';
|
||||||
|
|
||||||
|
export interface BalanceSheetTabProps {
|
||||||
|
balanceSheet: BalanceSheetData[];
|
||||||
|
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
||||||
|
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||||
|
getCellBackground: (change: number, intensity: number) => string;
|
||||||
|
positiveColor: string;
|
||||||
|
negativeColor: string;
|
||||||
|
bgColor: string;
|
||||||
|
hoverBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BalanceSheetTab: React.FC<BalanceSheetTabProps> = ({
|
||||||
|
balanceSheet,
|
||||||
|
showMetricChart,
|
||||||
|
calculateYoYChange,
|
||||||
|
getCellBackground,
|
||||||
|
positiveColor,
|
||||||
|
negativeColor,
|
||||||
|
bgColor,
|
||||||
|
hoverBg,
|
||||||
|
}) => {
|
||||||
|
const tableProps = {
|
||||||
|
showMetricChart,
|
||||||
|
calculateYoYChange,
|
||||||
|
getCellBackground,
|
||||||
|
positiveColor,
|
||||||
|
negativeColor,
|
||||||
|
bgColor,
|
||||||
|
hoverBg,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<VStack align="stretch" spacing={2} mb={4}>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Heading size="md" color="#D4AF37">资产负债表</Heading>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Badge bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
||||||
|
显示最近{Math.min(balanceSheet.length, 8)}期
|
||||||
|
</Badge>
|
||||||
|
<Text fontSize="sm" color="gray.400">
|
||||||
|
红涨绿跌 | 同比变化
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="xs" color="gray.500">
|
||||||
|
提示:表格可横向滚动查看更多数据,点击行查看历史趋势
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<BalanceSheetTable data={balanceSheet} {...tableProps} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BalanceSheetTab;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* 现金流量表 Tab
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, VStack, HStack, Heading, Badge, Text } from '@chakra-ui/react';
|
||||||
|
import { CashflowTable } from '../components';
|
||||||
|
import type { CashflowData } from '../types';
|
||||||
|
|
||||||
|
export interface CashflowTabProps {
|
||||||
|
cashflow: CashflowData[];
|
||||||
|
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
||||||
|
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||||
|
getCellBackground: (change: number, intensity: number) => string;
|
||||||
|
positiveColor: string;
|
||||||
|
negativeColor: string;
|
||||||
|
bgColor: string;
|
||||||
|
hoverBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CashflowTab: React.FC<CashflowTabProps> = ({
|
||||||
|
cashflow,
|
||||||
|
showMetricChart,
|
||||||
|
calculateYoYChange,
|
||||||
|
getCellBackground,
|
||||||
|
positiveColor,
|
||||||
|
negativeColor,
|
||||||
|
bgColor,
|
||||||
|
hoverBg,
|
||||||
|
}) => {
|
||||||
|
const tableProps = {
|
||||||
|
showMetricChart,
|
||||||
|
calculateYoYChange,
|
||||||
|
getCellBackground,
|
||||||
|
positiveColor,
|
||||||
|
negativeColor,
|
||||||
|
bgColor,
|
||||||
|
hoverBg,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<VStack align="stretch" spacing={2} mb={4}>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Heading size="md" color="#D4AF37">现金流量表</Heading>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Badge bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
||||||
|
显示最近{Math.min(cashflow.length, 8)}期
|
||||||
|
</Badge>
|
||||||
|
<Text fontSize="sm" color="gray.400">
|
||||||
|
红涨绿跌 | 同比变化
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="xs" color="gray.500">
|
||||||
|
提示:现金流数据为累计值,正值红色表示现金流入,负值绿色表示现金流出
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<CashflowTable data={cashflow} {...tableProps} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CashflowTab;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* 财务指标 Tab
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { FinancialMetricsTable } from '../components';
|
||||||
|
import type { FinancialMetricsData } from '../types';
|
||||||
|
|
||||||
|
export interface FinancialMetricsTabProps {
|
||||||
|
financialMetrics: FinancialMetricsData[];
|
||||||
|
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
||||||
|
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||||
|
getCellBackground: (change: number, intensity: number) => string;
|
||||||
|
positiveColor: string;
|
||||||
|
negativeColor: string;
|
||||||
|
bgColor: string;
|
||||||
|
hoverBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FinancialMetricsTab: React.FC<FinancialMetricsTabProps> = ({
|
||||||
|
financialMetrics,
|
||||||
|
showMetricChart,
|
||||||
|
calculateYoYChange,
|
||||||
|
getCellBackground,
|
||||||
|
positiveColor,
|
||||||
|
negativeColor,
|
||||||
|
bgColor,
|
||||||
|
hoverBg,
|
||||||
|
}) => {
|
||||||
|
const tableProps = {
|
||||||
|
showMetricChart,
|
||||||
|
calculateYoYChange,
|
||||||
|
getCellBackground,
|
||||||
|
positiveColor,
|
||||||
|
negativeColor,
|
||||||
|
bgColor,
|
||||||
|
hoverBg,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FinancialMetricsTab;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* 利润表 Tab
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, VStack, HStack, Heading, Badge, Text } from '@chakra-ui/react';
|
||||||
|
import { IncomeStatementTable } from '../components';
|
||||||
|
import type { IncomeStatementData } from '../types';
|
||||||
|
|
||||||
|
export interface IncomeStatementTabProps {
|
||||||
|
incomeStatement: IncomeStatementData[];
|
||||||
|
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
||||||
|
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||||
|
getCellBackground: (change: number, intensity: number) => string;
|
||||||
|
positiveColor: string;
|
||||||
|
negativeColor: string;
|
||||||
|
bgColor: string;
|
||||||
|
hoverBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IncomeStatementTab: React.FC<IncomeStatementTabProps> = ({
|
||||||
|
incomeStatement,
|
||||||
|
showMetricChart,
|
||||||
|
calculateYoYChange,
|
||||||
|
getCellBackground,
|
||||||
|
positiveColor,
|
||||||
|
negativeColor,
|
||||||
|
bgColor,
|
||||||
|
hoverBg,
|
||||||
|
}) => {
|
||||||
|
const tableProps = {
|
||||||
|
showMetricChart,
|
||||||
|
calculateYoYChange,
|
||||||
|
getCellBackground,
|
||||||
|
positiveColor,
|
||||||
|
negativeColor,
|
||||||
|
bgColor,
|
||||||
|
hoverBg,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<VStack align="stretch" spacing={2} mb={4}>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Heading size="md" color="#D4AF37">利润表</Heading>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Badge bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
||||||
|
显示最近{Math.min(incomeStatement.length, 8)}期
|
||||||
|
</Badge>
|
||||||
|
<Text fontSize="sm" color="gray.400">
|
||||||
|
红涨绿跌 | 同比变化
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="xs" color="gray.500">
|
||||||
|
提示:Q1、中报、Q3、年报数据为累计值,同比显示与去年同期对比
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<IncomeStatementTable data={incomeStatement} {...tableProps} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IncomeStatementTab;
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
/**
|
||||||
|
* 财务指标分类 Tab - Ant Design 黑金主题
|
||||||
|
* 接受 categoryKey 显示单个分类的指标表格
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
|
||||||
|
import { Table, ConfigProvider, Tooltip } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { Eye } from 'lucide-react';
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
import { FINANCIAL_METRICS_CATEGORIES } from '../constants';
|
||||||
|
import { getValueByPath, isNegativeIndicator } from '../utils';
|
||||||
|
import type { FinancialMetricsData } from '../types';
|
||||||
|
|
||||||
|
type CategoryKey = keyof typeof FINANCIAL_METRICS_CATEGORIES;
|
||||||
|
|
||||||
|
// Ant Design 黑金主题配置
|
||||||
|
const BLACK_GOLD_THEME = {
|
||||||
|
token: {
|
||||||
|
colorBgContainer: 'transparent',
|
||||||
|
colorText: '#E2E8F0',
|
||||||
|
colorTextHeading: '#D4AF37',
|
||||||
|
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Table: {
|
||||||
|
headerBg: 'rgba(26, 32, 44, 0.8)',
|
||||||
|
headerColor: '#D4AF37',
|
||||||
|
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
borderColor: 'rgba(212, 175, 55, 0.15)',
|
||||||
|
cellPaddingBlock: 8,
|
||||||
|
cellPaddingInline: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 黑金主题CSS
|
||||||
|
const tableStyles = `
|
||||||
|
.metrics-category-table .ant-table {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
.metrics-category-table .ant-table-thead > tr > th {
|
||||||
|
background: rgba(26, 32, 44, 0.8) !important;
|
||||||
|
color: #D4AF37 !important;
|
||||||
|
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.metrics-category-table .ant-table-tbody > tr > td {
|
||||||
|
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
|
||||||
|
color: #E2E8F0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.metrics-category-table .ant-table-tbody > tr:hover > td {
|
||||||
|
background: rgba(212, 175, 55, 0.08) !important;
|
||||||
|
}
|
||||||
|
.metrics-category-table .ant-table-cell-fix-left,
|
||||||
|
.metrics-category-table .ant-table-cell-fix-right {
|
||||||
|
background: #1A202C !important;
|
||||||
|
}
|
||||||
|
.metrics-category-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
|
||||||
|
.metrics-category-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
|
||||||
|
background: rgba(26, 32, 44, 0.95) !important;
|
||||||
|
}
|
||||||
|
.metrics-category-table .positive-change {
|
||||||
|
color: #E53E3E;
|
||||||
|
}
|
||||||
|
.metrics-category-table .negative-change {
|
||||||
|
color: #48BB78;
|
||||||
|
}
|
||||||
|
.metrics-category-table .positive-value {
|
||||||
|
color: #E53E3E;
|
||||||
|
}
|
||||||
|
.metrics-category-table .negative-value {
|
||||||
|
color: #48BB78;
|
||||||
|
}
|
||||||
|
.metrics-category-table .ant-table-placeholder {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
.metrics-category-table .ant-empty-description {
|
||||||
|
color: #A0AEC0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export interface MetricsCategoryTabProps {
|
||||||
|
categoryKey: CategoryKey;
|
||||||
|
financialMetrics: FinancialMetricsData[];
|
||||||
|
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
||||||
|
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||||
|
getCellBackground: (change: number, intensity: number) => string;
|
||||||
|
positiveColor: string;
|
||||||
|
negativeColor: string;
|
||||||
|
bgColor: string;
|
||||||
|
hoverBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格行数据类型
|
||||||
|
interface TableRowData {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isCore?: boolean;
|
||||||
|
[period: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MetricsCategoryTab: React.FC<MetricsCategoryTabProps> = ({
|
||||||
|
categoryKey,
|
||||||
|
financialMetrics,
|
||||||
|
showMetricChart,
|
||||||
|
calculateYoYChange,
|
||||||
|
}) => {
|
||||||
|
// 数组安全检查
|
||||||
|
if (!Array.isArray(financialMetrics) || financialMetrics.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box p={4} textAlign="center" color="gray.400">
|
||||||
|
暂无财务指标数据
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxColumns = Math.min(financialMetrics.length, 6);
|
||||||
|
const displayData = financialMetrics.slice(0, maxColumns);
|
||||||
|
const category = FINANCIAL_METRICS_CATEGORIES[categoryKey];
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return (
|
||||||
|
<Box p={4} textAlign="center" color="gray.400">
|
||||||
|
未找到指标分类配置
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建表格数据
|
||||||
|
const tableData = useMemo(() => {
|
||||||
|
return category.metrics.map((metric) => {
|
||||||
|
const row: TableRowData = {
|
||||||
|
key: metric.key,
|
||||||
|
name: metric.name,
|
||||||
|
path: metric.path,
|
||||||
|
isCore: metric.isCore,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加各期数值
|
||||||
|
displayData.forEach((item) => {
|
||||||
|
const value = getValueByPath<number>(item, metric.path);
|
||||||
|
row[item.period] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
}, [financialMetrics, displayData, category]);
|
||||||
|
|
||||||
|
// 计算同比变化
|
||||||
|
const calculateYoY = (
|
||||||
|
currentValue: number | undefined,
|
||||||
|
currentPeriod: string,
|
||||||
|
path: string
|
||||||
|
): number | null => {
|
||||||
|
if (currentValue === undefined || currentValue === null) return null;
|
||||||
|
|
||||||
|
const currentDate = new Date(currentPeriod);
|
||||||
|
const lastYearPeriod = financialMetrics.find((item) => {
|
||||||
|
const date = new Date(item.period);
|
||||||
|
return (
|
||||||
|
date.getFullYear() === currentDate.getFullYear() - 1 &&
|
||||||
|
date.getMonth() === currentDate.getMonth()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!lastYearPeriod) return null;
|
||||||
|
|
||||||
|
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
|
||||||
|
if (lastYearValue === undefined || lastYearValue === 0) return null;
|
||||||
|
|
||||||
|
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构建列定义
|
||||||
|
const columns: ColumnsType<TableRowData> = useMemo(() => {
|
||||||
|
const cols: ColumnsType<TableRowData> = [
|
||||||
|
{
|
||||||
|
title: category.title,
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
fixed: 'left',
|
||||||
|
width: 200,
|
||||||
|
render: (name: string, record: TableRowData) => (
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Text fontWeight="medium" fontSize="xs">{name}</Text>
|
||||||
|
{record.isCore && (
|
||||||
|
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
||||||
|
核心
|
||||||
|
</ChakraBadge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...displayData.map((item) => ({
|
||||||
|
title: (
|
||||||
|
<Box textAlign="center">
|
||||||
|
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
|
||||||
|
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
dataIndex: item.period,
|
||||||
|
key: item.period,
|
||||||
|
width: 100,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (value: number | undefined, record: TableRowData) => {
|
||||||
|
const yoy = calculateYoY(value, item.period, record.path);
|
||||||
|
const isNegative = isNegativeIndicator(record.key);
|
||||||
|
|
||||||
|
// 对于负向指标,增加是坏事(绿色),减少是好事(红色)
|
||||||
|
const changeColor = isNegative
|
||||||
|
? (yoy && yoy > 0 ? 'negative-change' : 'positive-change')
|
||||||
|
: (yoy && yoy > 0 ? 'positive-change' : 'negative-change');
|
||||||
|
|
||||||
|
// 成长能力指标特殊处理:正值红色,负值绿色
|
||||||
|
const valueColor = categoryKey === 'growth'
|
||||||
|
? (value !== undefined && value > 0 ? 'positive-value' : value !== undefined && value < 0 ? 'negative-value' : '')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<Box>
|
||||||
|
<Text>{record.name}: {value?.toFixed(2) || '-'}</Text>
|
||||||
|
{yoy !== null && <Text>同比: {yoy.toFixed(2)}%</Text>}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box position="relative">
|
||||||
|
<Text fontSize="xs" className={valueColor || undefined}>
|
||||||
|
{value?.toFixed(2) || '-'}
|
||||||
|
</Text>
|
||||||
|
{yoy !== null && Math.abs(yoy) > 20 && value !== undefined && Math.abs(value) > 0.01 && (
|
||||||
|
<Text
|
||||||
|
position="absolute"
|
||||||
|
top="-12px"
|
||||||
|
right="0"
|
||||||
|
fontSize="10px"
|
||||||
|
className={changeColor}
|
||||||
|
>
|
||||||
|
{yoy > 0 ? '↑' : '↓'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'action',
|
||||||
|
width: 40,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_: unknown, record: TableRowData) => (
|
||||||
|
<Eye
|
||||||
|
size={14}
|
||||||
|
color="#D4AF37"
|
||||||
|
style={{ cursor: 'pointer', opacity: 0.7 }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showMetricChart(record.name, record.key, financialMetrics, record.path);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return cols;
|
||||||
|
}, [displayData, financialMetrics, showMetricChart, category, categoryKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box className="metrics-category-table">
|
||||||
|
<style>{tableStyles}</style>
|
||||||
|
<ConfigProvider theme={BLACK_GOLD_THEME}>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={tableData}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () => {
|
||||||
|
showMetricChart(record.name, record.key, financialMetrics, record.path);
|
||||||
|
},
|
||||||
|
style: { cursor: 'pointer' },
|
||||||
|
})}
|
||||||
|
locale={{ emptyText: '暂无数据' }}
|
||||||
|
/>
|
||||||
|
</ConfigProvider>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 为每个分类创建预配置的组件
|
||||||
|
export const ProfitabilityTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
|
||||||
|
<MetricsCategoryTab categoryKey="profitability" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PerShareTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
|
||||||
|
<MetricsCategoryTab categoryKey="perShare" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const GrowthTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
|
||||||
|
<MetricsCategoryTab categoryKey="growth" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const OperationalTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
|
||||||
|
<MetricsCategoryTab categoryKey="operational" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SolvencyTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
|
||||||
|
<MetricsCategoryTab categoryKey="solvency" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ExpenseTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
|
||||||
|
<MetricsCategoryTab categoryKey="expense" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CashflowMetricsTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
|
||||||
|
<MetricsCategoryTab categoryKey="cashflow" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export default MetricsCategoryTab;
|
||||||
28
src/views/Company/components/FinancialPanorama/tabs/index.ts
Normal file
28
src/views/Company/components/FinancialPanorama/tabs/index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Tab 组件统一导出
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 三大财务报表
|
||||||
|
export { default as BalanceSheetTab } from './BalanceSheetTab';
|
||||||
|
export { default as IncomeStatementTab } from './IncomeStatementTab';
|
||||||
|
export { default as CashflowTab } from './CashflowTab';
|
||||||
|
|
||||||
|
// 财务指标分类 tabs
|
||||||
|
export {
|
||||||
|
ProfitabilityTab,
|
||||||
|
PerShareTab,
|
||||||
|
GrowthTab,
|
||||||
|
OperationalTab,
|
||||||
|
SolvencyTab,
|
||||||
|
ExpenseTab,
|
||||||
|
CashflowMetricsTab,
|
||||||
|
} from './MetricsCategoryTab';
|
||||||
|
|
||||||
|
// 旧的综合财务指标 tab(保留兼容)
|
||||||
|
export { default as FinancialMetricsTab } from './FinancialMetricsTab';
|
||||||
|
|
||||||
|
export type { BalanceSheetTabProps } from './BalanceSheetTab';
|
||||||
|
export type { IncomeStatementTabProps } from './IncomeStatementTab';
|
||||||
|
export type { CashflowTabProps } from './CashflowTab';
|
||||||
|
export type { FinancialMetricsTabProps } from './FinancialMetricsTab';
|
||||||
|
export type { MetricsCategoryTabProps } from './MetricsCategoryTab';
|
||||||
439
src/views/Company/components/FinancialPanorama/types.ts
Normal file
439
src/views/Company/components/FinancialPanorama/types.ts
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
/**
|
||||||
|
* FinancialPanorama 组件类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== 基础类型 ====================
|
||||||
|
|
||||||
|
/** 股票基本信息 */
|
||||||
|
export interface StockInfo {
|
||||||
|
stock_code: string;
|
||||||
|
stock_name: string;
|
||||||
|
key_metrics?: {
|
||||||
|
eps?: number;
|
||||||
|
roe?: number;
|
||||||
|
gross_margin?: number;
|
||||||
|
net_margin?: number;
|
||||||
|
roa?: number;
|
||||||
|
};
|
||||||
|
growth_rates?: {
|
||||||
|
revenue_growth?: number;
|
||||||
|
profit_growth?: number;
|
||||||
|
asset_growth?: number;
|
||||||
|
equity_growth?: number;
|
||||||
|
};
|
||||||
|
financial_summary?: {
|
||||||
|
revenue?: number;
|
||||||
|
net_profit?: number;
|
||||||
|
total_assets?: number;
|
||||||
|
total_liabilities?: number;
|
||||||
|
};
|
||||||
|
latest_forecast?: {
|
||||||
|
forecast_type: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 财务报表类型 ====================
|
||||||
|
|
||||||
|
/** 资产负债表数据 */
|
||||||
|
export interface BalanceSheetData {
|
||||||
|
period: string;
|
||||||
|
assets: {
|
||||||
|
current_assets: {
|
||||||
|
cash?: number;
|
||||||
|
trading_financial_assets?: number;
|
||||||
|
notes_receivable?: number;
|
||||||
|
accounts_receivable?: number;
|
||||||
|
prepayments?: number;
|
||||||
|
other_receivables?: number;
|
||||||
|
inventory?: number;
|
||||||
|
contract_assets?: number;
|
||||||
|
other_current_assets?: number;
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
|
non_current_assets: {
|
||||||
|
long_term_equity_investments?: number;
|
||||||
|
investment_property?: number;
|
||||||
|
fixed_assets?: number;
|
||||||
|
construction_in_progress?: number;
|
||||||
|
right_of_use_assets?: number;
|
||||||
|
intangible_assets?: number;
|
||||||
|
goodwill?: number;
|
||||||
|
deferred_tax_assets?: number;
|
||||||
|
other_non_current_assets?: number;
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
|
liabilities: {
|
||||||
|
current_liabilities: {
|
||||||
|
short_term_borrowings?: number;
|
||||||
|
notes_payable?: number;
|
||||||
|
accounts_payable?: number;
|
||||||
|
advance_receipts?: number;
|
||||||
|
contract_liabilities?: number;
|
||||||
|
employee_compensation_payable?: number;
|
||||||
|
taxes_payable?: number;
|
||||||
|
other_payables?: number;
|
||||||
|
non_current_liabilities_due_within_one_year?: number;
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
|
non_current_liabilities: {
|
||||||
|
long_term_borrowings?: number;
|
||||||
|
bonds_payable?: number;
|
||||||
|
lease_liabilities?: number;
|
||||||
|
deferred_tax_liabilities?: number;
|
||||||
|
other_non_current_liabilities?: number;
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
|
equity: {
|
||||||
|
share_capital?: number;
|
||||||
|
capital_reserve?: number;
|
||||||
|
surplus_reserve?: number;
|
||||||
|
undistributed_profit?: number;
|
||||||
|
treasury_stock?: number;
|
||||||
|
other_comprehensive_income?: number;
|
||||||
|
parent_company_equity?: number;
|
||||||
|
minority_interests?: number;
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 利润表数据 */
|
||||||
|
export interface IncomeStatementData {
|
||||||
|
period: string;
|
||||||
|
revenue: {
|
||||||
|
total_operating_revenue?: number;
|
||||||
|
operating_revenue?: number;
|
||||||
|
other_income?: number;
|
||||||
|
};
|
||||||
|
costs: {
|
||||||
|
total_operating_cost?: number;
|
||||||
|
operating_cost?: number;
|
||||||
|
taxes_and_surcharges?: number;
|
||||||
|
selling_expenses?: number;
|
||||||
|
admin_expenses?: number;
|
||||||
|
rd_expenses?: number;
|
||||||
|
financial_expenses?: number;
|
||||||
|
interest_expense?: number;
|
||||||
|
interest_income?: number;
|
||||||
|
three_expenses_total?: number;
|
||||||
|
four_expenses_total?: number;
|
||||||
|
asset_impairment_loss?: number;
|
||||||
|
credit_impairment_loss?: number;
|
||||||
|
};
|
||||||
|
other_gains: {
|
||||||
|
fair_value_change?: number;
|
||||||
|
investment_income?: number;
|
||||||
|
investment_income_from_associates?: number;
|
||||||
|
exchange_income?: number;
|
||||||
|
asset_disposal_income?: number;
|
||||||
|
};
|
||||||
|
profit: {
|
||||||
|
operating_profit?: number;
|
||||||
|
total_profit?: number;
|
||||||
|
income_tax_expense?: number;
|
||||||
|
net_profit?: number;
|
||||||
|
parent_net_profit?: number;
|
||||||
|
minority_profit?: number;
|
||||||
|
continuing_operations_net_profit?: number;
|
||||||
|
discontinued_operations_net_profit?: number;
|
||||||
|
};
|
||||||
|
non_operating: {
|
||||||
|
non_operating_income?: number;
|
||||||
|
non_operating_expenses?: number;
|
||||||
|
};
|
||||||
|
per_share: {
|
||||||
|
basic_eps?: number;
|
||||||
|
diluted_eps?: number;
|
||||||
|
};
|
||||||
|
comprehensive_income: {
|
||||||
|
other_comprehensive_income?: number;
|
||||||
|
total_comprehensive_income?: number;
|
||||||
|
parent_comprehensive_income?: number;
|
||||||
|
minority_comprehensive_income?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 现金流量表数据 */
|
||||||
|
export interface CashflowData {
|
||||||
|
period: string;
|
||||||
|
operating_activities: {
|
||||||
|
inflow: {
|
||||||
|
cash_from_sales?: number;
|
||||||
|
};
|
||||||
|
outflow: {
|
||||||
|
cash_for_goods?: number;
|
||||||
|
};
|
||||||
|
net_flow?: number;
|
||||||
|
};
|
||||||
|
investment_activities: {
|
||||||
|
net_flow?: number;
|
||||||
|
};
|
||||||
|
financing_activities: {
|
||||||
|
net_flow?: number;
|
||||||
|
};
|
||||||
|
cash_changes: {
|
||||||
|
net_increase?: number;
|
||||||
|
ending_balance?: number;
|
||||||
|
};
|
||||||
|
key_metrics: {
|
||||||
|
free_cash_flow?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 财务指标数据 */
|
||||||
|
export interface FinancialMetricsData {
|
||||||
|
period: string;
|
||||||
|
profitability: {
|
||||||
|
roe?: number;
|
||||||
|
roe_deducted?: number;
|
||||||
|
roe_weighted?: number;
|
||||||
|
roa?: number;
|
||||||
|
gross_margin?: number;
|
||||||
|
net_profit_margin?: number;
|
||||||
|
operating_profit_margin?: number;
|
||||||
|
cost_profit_ratio?: number;
|
||||||
|
ebit?: number;
|
||||||
|
};
|
||||||
|
per_share_metrics: {
|
||||||
|
eps?: number;
|
||||||
|
basic_eps?: number;
|
||||||
|
diluted_eps?: number;
|
||||||
|
deducted_eps?: number;
|
||||||
|
bvps?: number;
|
||||||
|
operating_cash_flow_ps?: number;
|
||||||
|
capital_reserve_ps?: number;
|
||||||
|
undistributed_profit_ps?: number;
|
||||||
|
};
|
||||||
|
growth: {
|
||||||
|
revenue_growth?: number;
|
||||||
|
net_profit_growth?: number;
|
||||||
|
deducted_profit_growth?: number;
|
||||||
|
parent_profit_growth?: number;
|
||||||
|
operating_cash_flow_growth?: number;
|
||||||
|
total_asset_growth?: number;
|
||||||
|
equity_growth?: number;
|
||||||
|
fixed_asset_growth?: number;
|
||||||
|
};
|
||||||
|
operational_efficiency: {
|
||||||
|
total_asset_turnover?: number;
|
||||||
|
fixed_asset_turnover?: number;
|
||||||
|
current_asset_turnover?: number;
|
||||||
|
receivable_turnover?: number;
|
||||||
|
receivable_days?: number;
|
||||||
|
inventory_turnover?: number;
|
||||||
|
inventory_days?: number;
|
||||||
|
working_capital_turnover?: number;
|
||||||
|
};
|
||||||
|
solvency: {
|
||||||
|
current_ratio?: number;
|
||||||
|
quick_ratio?: number;
|
||||||
|
cash_ratio?: number;
|
||||||
|
conservative_quick_ratio?: number;
|
||||||
|
asset_liability_ratio?: number;
|
||||||
|
interest_coverage?: number;
|
||||||
|
cash_to_maturity_debt_ratio?: number;
|
||||||
|
tangible_asset_debt_ratio?: number;
|
||||||
|
};
|
||||||
|
expense_ratios: {
|
||||||
|
selling_expense_ratio?: number;
|
||||||
|
admin_expense_ratio?: number;
|
||||||
|
financial_expense_ratio?: number;
|
||||||
|
rd_expense_ratio?: number;
|
||||||
|
three_expense_ratio?: number;
|
||||||
|
four_expense_ratio?: number;
|
||||||
|
cost_ratio?: number;
|
||||||
|
};
|
||||||
|
cash_flow_quality: {
|
||||||
|
operating_cash_to_profit_ratio?: number;
|
||||||
|
cash_to_profit_ratio?: number;
|
||||||
|
cash_revenue_ratio?: number;
|
||||||
|
cash_recovery_rate?: number;
|
||||||
|
operating_cash_to_short_debt?: number;
|
||||||
|
operating_cash_to_total_debt?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 业务分析类型 ====================
|
||||||
|
|
||||||
|
/** 业务项目 */
|
||||||
|
export interface BusinessItem {
|
||||||
|
content: string;
|
||||||
|
revenue?: number;
|
||||||
|
gross_margin?: number;
|
||||||
|
profit_margin?: number;
|
||||||
|
profit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主营业务产品分类 */
|
||||||
|
export interface ProductClassification {
|
||||||
|
period: string;
|
||||||
|
report_type: string;
|
||||||
|
products: BusinessItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主营业务行业分类 */
|
||||||
|
export interface IndustryClassification {
|
||||||
|
period: string;
|
||||||
|
report_type: string;
|
||||||
|
industries: BusinessItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主营业务数据 */
|
||||||
|
export interface MainBusinessData {
|
||||||
|
product_classification?: ProductClassification[];
|
||||||
|
industry_classification?: IndustryClassification[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行业排名指标 */
|
||||||
|
export interface RankingMetric {
|
||||||
|
value?: number;
|
||||||
|
rank?: number;
|
||||||
|
industry_avg?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行业排名数据 */
|
||||||
|
export interface IndustryRankData {
|
||||||
|
period: string;
|
||||||
|
report_type: string;
|
||||||
|
rankings?: {
|
||||||
|
industry_name: string;
|
||||||
|
level_description: string;
|
||||||
|
metrics?: {
|
||||||
|
eps?: RankingMetric;
|
||||||
|
bvps?: RankingMetric;
|
||||||
|
roe?: RankingMetric;
|
||||||
|
revenue_growth?: RankingMetric;
|
||||||
|
profit_growth?: RankingMetric;
|
||||||
|
operating_margin?: RankingMetric;
|
||||||
|
debt_ratio?: RankingMetric;
|
||||||
|
receivable_turnover?: RankingMetric;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业绩预告数据 */
|
||||||
|
export interface ForecastData {
|
||||||
|
forecasts?: {
|
||||||
|
forecast_type: string;
|
||||||
|
report_date: string;
|
||||||
|
content: string;
|
||||||
|
reason?: string;
|
||||||
|
change_range?: {
|
||||||
|
lower?: number;
|
||||||
|
upper?: number;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对比数据 */
|
||||||
|
export interface ComparisonData {
|
||||||
|
period: string;
|
||||||
|
performance: {
|
||||||
|
revenue?: number;
|
||||||
|
net_profit?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 组件 Props 类型 ====================
|
||||||
|
|
||||||
|
/** 主组件 Props */
|
||||||
|
export interface FinancialPanoramaProps {
|
||||||
|
stockCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 股票信息头部 Props */
|
||||||
|
export interface StockInfoHeaderProps {
|
||||||
|
stockInfo: StockInfo | null;
|
||||||
|
positiveColor: string;
|
||||||
|
negativeColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表格通用 Props */
|
||||||
|
export interface TableProps {
|
||||||
|
data: unknown[];
|
||||||
|
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
||||||
|
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||||
|
getCellBackground: (change: number, intensity: number) => string;
|
||||||
|
positiveColor: string;
|
||||||
|
negativeColor: string;
|
||||||
|
bgColor: string;
|
||||||
|
hoverBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 资产负债表 Props */
|
||||||
|
export interface BalanceSheetTableProps extends TableProps {
|
||||||
|
data: BalanceSheetData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 利润表 Props */
|
||||||
|
export interface IncomeStatementTableProps extends TableProps {
|
||||||
|
data: IncomeStatementData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 现金流量表 Props */
|
||||||
|
export interface CashflowTableProps extends TableProps {
|
||||||
|
data: CashflowData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 财务指标表 Props */
|
||||||
|
export interface FinancialMetricsTableProps extends TableProps {
|
||||||
|
data: FinancialMetricsData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主营业务分析 Props */
|
||||||
|
export interface MainBusinessAnalysisProps {
|
||||||
|
mainBusiness: MainBusinessData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行业排名 Props */
|
||||||
|
export interface IndustryRankingViewProps {
|
||||||
|
industryRank: IndustryRankData[];
|
||||||
|
bgColor?: string;
|
||||||
|
borderColor?: string;
|
||||||
|
textColor?: string;
|
||||||
|
labelColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 股票对比 Props */
|
||||||
|
export interface StockComparisonProps {
|
||||||
|
currentStock: string;
|
||||||
|
stockInfo: StockInfo | null;
|
||||||
|
positiveColor: string;
|
||||||
|
negativeColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 综合对比分析 Props */
|
||||||
|
export interface ComparisonAnalysisProps {
|
||||||
|
comparison: ComparisonData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 指标定义类型 ====================
|
||||||
|
|
||||||
|
/** 指标配置 */
|
||||||
|
export interface MetricConfig {
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
path: string;
|
||||||
|
isCore?: boolean;
|
||||||
|
isTotal?: boolean;
|
||||||
|
isSubtotal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指标分类配置 */
|
||||||
|
export interface MetricSectionConfig {
|
||||||
|
title: string;
|
||||||
|
key: string;
|
||||||
|
metrics: MetricConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指标分类映射 */
|
||||||
|
export interface MetricsCategoryMap {
|
||||||
|
[key: string]: {
|
||||||
|
title: string;
|
||||||
|
metrics: MetricConfig[];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* 财务计算工具函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算同比变化率
|
||||||
|
* @param currentValue 当前值
|
||||||
|
* @param currentPeriod 当前期间
|
||||||
|
* @param allData 所有数据
|
||||||
|
* @param metricPath 指标路径
|
||||||
|
* @returns 变化率和强度
|
||||||
|
*/
|
||||||
|
export const calculateYoYChange = (
|
||||||
|
currentValue: number | null | undefined,
|
||||||
|
currentPeriod: string,
|
||||||
|
allData: Array<{ period: string; [key: string]: unknown }>,
|
||||||
|
metricPath: string
|
||||||
|
): { change: number; intensity: number } => {
|
||||||
|
if (!currentValue || !currentPeriod) return { change: 0, intensity: 0 };
|
||||||
|
|
||||||
|
// 找到去年同期的数据
|
||||||
|
const currentDate = new Date(currentPeriod);
|
||||||
|
const currentYear = currentDate.getFullYear();
|
||||||
|
const currentMonth = currentDate.getMonth() + 1;
|
||||||
|
|
||||||
|
// 查找去年同期
|
||||||
|
const lastYearSamePeriod = allData.find((item) => {
|
||||||
|
const itemDate = new Date(item.period);
|
||||||
|
const itemYear = itemDate.getFullYear();
|
||||||
|
const itemMonth = itemDate.getMonth() + 1;
|
||||||
|
return itemYear === currentYear - 1 && itemMonth === currentMonth;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!lastYearSamePeriod) return { change: 0, intensity: 0 };
|
||||||
|
|
||||||
|
const previousValue = metricPath
|
||||||
|
.split('.')
|
||||||
|
.reduce((obj: unknown, key: string) => {
|
||||||
|
if (obj && typeof obj === 'object') {
|
||||||
|
return (obj as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, lastYearSamePeriod) as number | undefined;
|
||||||
|
|
||||||
|
if (!previousValue || previousValue === 0) return { change: 0, intensity: 0 };
|
||||||
|
|
||||||
|
const change = ((currentValue - previousValue) / Math.abs(previousValue)) * 100;
|
||||||
|
const intensity = Math.min(Math.abs(change) / 50, 1); // 50%变化达到最大强度
|
||||||
|
return { change, intensity };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单元格背景色(中国市场颜色)
|
||||||
|
* @param change 变化率
|
||||||
|
* @param intensity 强度
|
||||||
|
* @returns 背景色
|
||||||
|
*/
|
||||||
|
export const getCellBackground = (change: number, intensity: number): string => {
|
||||||
|
if (change > 0) {
|
||||||
|
return `rgba(239, 68, 68, ${intensity * 0.15})`; // 红色背景,涨
|
||||||
|
} else if (change < 0) {
|
||||||
|
return `rgba(34, 197, 94, ${intensity * 0.15})`; // 绿色背景,跌
|
||||||
|
}
|
||||||
|
return 'transparent';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从对象中获取嵌套路径的值
|
||||||
|
* @param obj 对象
|
||||||
|
* @param path 路径(如 'assets.current_assets.cash')
|
||||||
|
* @returns 值
|
||||||
|
*/
|
||||||
|
export const getValueByPath = <T = unknown>(
|
||||||
|
obj: unknown,
|
||||||
|
path: string
|
||||||
|
): T | undefined => {
|
||||||
|
return path.split('.').reduce((current: unknown, key: string) => {
|
||||||
|
if (current && typeof current === 'object') {
|
||||||
|
return (current as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, obj) as T | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为成本费用类指标(负向指标)
|
||||||
|
* @param key 指标 key
|
||||||
|
* @returns 是否为负向指标
|
||||||
|
*/
|
||||||
|
export const isNegativeIndicator = (key: string): boolean => {
|
||||||
|
return (
|
||||||
|
key.includes('cost') ||
|
||||||
|
key.includes('expense') ||
|
||||||
|
key === 'income_tax' ||
|
||||||
|
key.includes('impairment') ||
|
||||||
|
key.includes('days') ||
|
||||||
|
key.includes('debt_ratio')
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
/**
|
||||||
|
* ECharts 图表配置生成器
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
|
||||||
|
interface ChartDataItem {
|
||||||
|
period: string;
|
||||||
|
date: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成指标趋势图表配置
|
||||||
|
* @param metricName 指标名称
|
||||||
|
* @param data 图表数据
|
||||||
|
* @returns ECharts 配置
|
||||||
|
*/
|
||||||
|
export const getMetricChartOption = (
|
||||||
|
metricName: string,
|
||||||
|
data: ChartDataItem[]
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
text: metricName,
|
||||||
|
left: 'center',
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (params: Array<{ name: string; value: number }>) => {
|
||||||
|
const value = params[0].value;
|
||||||
|
const formattedValue =
|
||||||
|
value > 10000
|
||||||
|
? formatUtils.formatLargeNumber(value)
|
||||||
|
: value?.toFixed(2);
|
||||||
|
return `${params[0].name}<br/>${metricName}: ${formattedValue}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: data.map((d) => d.period),
|
||||||
|
axisLabel: {
|
||||||
|
rotate: 45,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (value: number) => {
|
||||||
|
if (Math.abs(value) >= 100000000) {
|
||||||
|
return (value / 100000000).toFixed(0) + '亿';
|
||||||
|
} else if (Math.abs(value) >= 10000) {
|
||||||
|
return (value / 10000).toFixed(0) + '万';
|
||||||
|
}
|
||||||
|
return value.toFixed(0);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
data: data.map((d) => d.value),
|
||||||
|
itemStyle: {
|
||||||
|
color: (params: { dataIndex: number; value: number }) => {
|
||||||
|
const idx = params.dataIndex;
|
||||||
|
if (idx === 0) return '#3182CE';
|
||||||
|
const prevValue = data[idx - 1].value;
|
||||||
|
const currValue = params.value;
|
||||||
|
// 中国市场颜色:红涨绿跌
|
||||||
|
return currValue >= prevValue ? '#EF4444' : '#10B981';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
formatter: (params: { value: number }) => {
|
||||||
|
const value = params.value;
|
||||||
|
if (Math.abs(value) >= 100000000) {
|
||||||
|
return (value / 100000000).toFixed(1) + '亿';
|
||||||
|
} else if (Math.abs(value) >= 10000) {
|
||||||
|
return (value / 10000).toFixed(1) + '万';
|
||||||
|
} else if (Math.abs(value) >= 1) {
|
||||||
|
return value.toFixed(1);
|
||||||
|
}
|
||||||
|
return value.toFixed(2);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成营收与利润趋势图表配置 - 黑金主题
|
||||||
|
* @param revenueData 营收数据
|
||||||
|
* @param profitData 利润数据
|
||||||
|
* @returns ECharts 配置
|
||||||
|
*/
|
||||||
|
export const getComparisonChartOption = (
|
||||||
|
revenueData: { period: string; value: number }[],
|
||||||
|
profitData: { period: string; value: number }[]
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
title: {
|
||||||
|
text: '营收与利润趋势',
|
||||||
|
left: 'center',
|
||||||
|
textStyle: {
|
||||||
|
color: '#D4AF37',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
backgroundColor: 'rgba(26, 32, 44, 0.95)',
|
||||||
|
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||||
|
textStyle: {
|
||||||
|
color: '#E2E8F0',
|
||||||
|
},
|
||||||
|
axisPointer: {
|
||||||
|
type: 'cross',
|
||||||
|
crossStyle: {
|
||||||
|
color: 'rgba(212, 175, 55, 0.5)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: ['营业收入', '净利润'],
|
||||||
|
bottom: 0,
|
||||||
|
textStyle: {
|
||||||
|
color: '#A0AEC0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
bottom: '12%',
|
||||||
|
top: '15%',
|
||||||
|
containLabel: true,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: revenueData.map((d) => d.period),
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: 'rgba(212, 175, 55, 0.3)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: '#A0AEC0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: '营收(亿)',
|
||||||
|
position: 'left',
|
||||||
|
nameTextStyle: {
|
||||||
|
color: '#A0AEC0',
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: 'rgba(212, 175, 55, 0.3)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: '#A0AEC0',
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: '利润(亿)',
|
||||||
|
position: 'right',
|
||||||
|
nameTextStyle: {
|
||||||
|
color: '#A0AEC0',
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: 'rgba(212, 175, 55, 0.3)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: '#A0AEC0',
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '营业收入',
|
||||||
|
type: 'bar',
|
||||||
|
data: revenueData.map((d) => d.value?.toFixed(2)),
|
||||||
|
itemStyle: {
|
||||||
|
color: (params: { dataIndex: number; value: number }) => {
|
||||||
|
const idx = params.dataIndex;
|
||||||
|
if (idx === 0) return '#D4AF37'; // 金色作为基准
|
||||||
|
const prevValue = revenueData[idx - 1].value;
|
||||||
|
const currValue = params.value;
|
||||||
|
// 红涨绿跌
|
||||||
|
return currValue >= prevValue ? '#EF4444' : '#10B981';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '净利润',
|
||||||
|
type: 'line',
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: profitData.map((d) => d.value?.toFixed(2)),
|
||||||
|
smooth: true,
|
||||||
|
itemStyle: { color: '#D4AF37' },
|
||||||
|
lineStyle: { width: 2, color: '#D4AF37' },
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
x2: 0,
|
||||||
|
y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(212, 175, 55, 0.3)' },
|
||||||
|
{ offset: 1, color: 'rgba(212, 175, 55, 0.05)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 黑金主题饼图配色
|
||||||
|
const BLACK_GOLD_PIE_COLORS = [
|
||||||
|
'#D4AF37', // 金色
|
||||||
|
'#B8860B', // 深金色
|
||||||
|
'#FFD700', // 亮金色
|
||||||
|
'#DAA520', // 金菊色
|
||||||
|
'#CD853F', // 秘鲁色
|
||||||
|
'#F4A460', // 沙褐色
|
||||||
|
'#DEB887', // 实木色
|
||||||
|
'#D2691E', // 巧克力色
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成主营业务饼图配置 - 黑金主题
|
||||||
|
* @param title 标题
|
||||||
|
* @param subtitle 副标题
|
||||||
|
* @param data 饼图数据
|
||||||
|
* @returns ECharts 配置
|
||||||
|
*/
|
||||||
|
export const getMainBusinessPieOption = (
|
||||||
|
title: string,
|
||||||
|
subtitle: string,
|
||||||
|
data: { name: string; value: number }[]
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
text: title,
|
||||||
|
subtext: subtitle,
|
||||||
|
left: 'center',
|
||||||
|
textStyle: {
|
||||||
|
color: '#D4AF37',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
subtextStyle: {
|
||||||
|
color: '#A0AEC0',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
backgroundColor: 'rgba(26, 32, 44, 0.95)',
|
||||||
|
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||||
|
textStyle: {
|
||||||
|
color: '#E2E8F0',
|
||||||
|
},
|
||||||
|
formatter: (params: { name: string; value: number; percent: number }) => {
|
||||||
|
return `${params.name}<br/>营收: ${formatUtils.formatLargeNumber(
|
||||||
|
params.value
|
||||||
|
)}<br/>占比: ${params.percent}%`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
orient: 'vertical',
|
||||||
|
left: 'left',
|
||||||
|
top: 'center',
|
||||||
|
textStyle: {
|
||||||
|
color: '#E2E8F0',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
color: BLACK_GOLD_PIE_COLORS,
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'pie',
|
||||||
|
radius: '55%',
|
||||||
|
center: ['55%', '50%'],
|
||||||
|
data: data,
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
color: '#E2E8F0',
|
||||||
|
fontSize: 11,
|
||||||
|
formatter: '{b}: {d}%',
|
||||||
|
},
|
||||||
|
labelLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: 'rgba(212, 175, 55, 0.5)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: {
|
||||||
|
shadowBlur: 10,
|
||||||
|
shadowOffsetX: 0,
|
||||||
|
shadowColor: 'rgba(212, 175, 55, 0.5)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成对比柱状图配置
|
||||||
|
* @param title 标题
|
||||||
|
* @param stockName1 股票1名称
|
||||||
|
* @param stockName2 股票2名称
|
||||||
|
* @param categories X轴分类
|
||||||
|
* @param data1 股票1数据
|
||||||
|
* @param data2 股票2数据
|
||||||
|
* @returns ECharts 配置
|
||||||
|
*/
|
||||||
|
export const getCompareBarChartOption = (
|
||||||
|
title: string,
|
||||||
|
stockName1: string,
|
||||||
|
stockName2: string,
|
||||||
|
categories: string[],
|
||||||
|
data1: (number | undefined)[],
|
||||||
|
data2: (number | undefined)[]
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { data: [stockName1, stockName2] },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: categories,
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value', axisLabel: { formatter: '{value}%' } },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: stockName1,
|
||||||
|
type: 'bar',
|
||||||
|
data: data1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: stockName2,
|
||||||
|
type: 'bar',
|
||||||
|
data: data2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 工具函数统一导出
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
calculateYoYChange,
|
||||||
|
getCellBackground,
|
||||||
|
getValueByPath,
|
||||||
|
isNegativeIndicator,
|
||||||
|
} from './calculations';
|
||||||
|
|
||||||
|
export {
|
||||||
|
getMetricChartOption,
|
||||||
|
getComparisonChartOption,
|
||||||
|
getMainBusinessPieOption,
|
||||||
|
getCompareBarChartOption,
|
||||||
|
} from './chartOptions';
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 通用图表卡片组件 - 黑金主题
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Heading } from '@chakra-ui/react';
|
||||||
|
import { THEME } from '../constants';
|
||||||
|
import type { ChartCardProps } from '../types';
|
||||||
|
|
||||||
|
const ChartCard: React.FC<ChartCardProps> = ({ title, children }) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg={THEME.bgDark}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={THEME.goldBorder}
|
||||||
|
borderRadius="md"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
px={4}
|
||||||
|
py={3}
|
||||||
|
borderBottom="1px solid"
|
||||||
|
borderColor={THEME.goldBorder}
|
||||||
|
bg={THEME.goldLight}
|
||||||
|
>
|
||||||
|
<Heading size="sm" color={THEME.gold}>
|
||||||
|
{title}
|
||||||
|
</Heading>
|
||||||
|
</Box>
|
||||||
|
<Box p={4}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChartCard;
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* 详细数据表格 - 黑金主题
|
||||||
|
* 优化:斑马纹、等宽字体、首列高亮、重要行强调、预测列区分
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { Box, Text } from '@chakra-ui/react';
|
||||||
|
import { Table, ConfigProvider, Tag, theme as antTheme } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import type { DetailTableProps, DetailTableRow } from '../types';
|
||||||
|
|
||||||
|
// 判断是否为预测年份
|
||||||
|
const isForecastYear = (year: string) => year.includes('E');
|
||||||
|
|
||||||
|
// 重要指标(需要高亮的行)
|
||||||
|
const IMPORTANT_METRICS = ['归母净利润', 'ROE', 'EPS', '营业总收入'];
|
||||||
|
|
||||||
|
// Ant Design 黑金主题配置
|
||||||
|
const BLACK_GOLD_THEME = {
|
||||||
|
algorithm: antTheme.darkAlgorithm,
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#D4AF37',
|
||||||
|
colorBgContainer: 'transparent',
|
||||||
|
colorBgElevated: '#1a1a2e',
|
||||||
|
colorBorder: 'rgba(212, 175, 55, 0.3)',
|
||||||
|
colorText: '#e0e0e0',
|
||||||
|
colorTextSecondary: '#a0a0a0',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Table: {
|
||||||
|
headerBg: 'rgba(212, 175, 55, 0.12)',
|
||||||
|
headerColor: '#D4AF37',
|
||||||
|
rowHoverBg: 'rgba(212, 175, 55, 0.08)',
|
||||||
|
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
cellPaddingBlock: 12, // 增加行高
|
||||||
|
cellPaddingInline: 14,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 表格样式 - 斑马纹、等宽字体、预测列区分
|
||||||
|
const tableStyles = `
|
||||||
|
/* 固定列背景 */
|
||||||
|
.forecast-detail-table .ant-table-cell-fix-left,
|
||||||
|
.forecast-detail-table .ant-table-cell-fix-right {
|
||||||
|
background: #1A202C !important;
|
||||||
|
}
|
||||||
|
.forecast-detail-table .ant-table-thead .ant-table-cell-fix-left,
|
||||||
|
.forecast-detail-table .ant-table-thead .ant-table-cell-fix-right {
|
||||||
|
background: rgba(26, 32, 44, 0.98) !important;
|
||||||
|
}
|
||||||
|
.forecast-detail-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-left {
|
||||||
|
background: #242d3d !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 指标标签样式 */
|
||||||
|
.forecast-detail-table .metric-tag {
|
||||||
|
background: rgba(212, 175, 55, 0.15);
|
||||||
|
border-color: rgba(212, 175, 55, 0.3);
|
||||||
|
color: #D4AF37;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 重要指标行高亮 */
|
||||||
|
.forecast-detail-table .important-row {
|
||||||
|
background: rgba(212, 175, 55, 0.06) !important;
|
||||||
|
}
|
||||||
|
.forecast-detail-table .important-row .metric-tag {
|
||||||
|
background: rgba(212, 175, 55, 0.25);
|
||||||
|
color: #FFD700;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 斑马纹 - 奇数行 */
|
||||||
|
.forecast-detail-table .ant-table-tbody > tr:nth-child(odd) > td {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
.forecast-detail-table .ant-table-tbody > tr:nth-child(odd):hover > td {
|
||||||
|
background: rgba(212, 175, 55, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 等宽字体 - 数值列 */
|
||||||
|
.forecast-detail-table .data-cell {
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 预测列样式 */
|
||||||
|
.forecast-detail-table .forecast-col {
|
||||||
|
background: rgba(212, 175, 55, 0.04) !important;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.forecast-detail-table .ant-table-thead .forecast-col {
|
||||||
|
color: #FFD700 !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 负数红色显示 */
|
||||||
|
.forecast-detail-table .negative-value {
|
||||||
|
color: #FC8181;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 正增长绿色 */
|
||||||
|
.forecast-detail-table .positive-growth {
|
||||||
|
color: #68D391;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表头预测/历史分隔线 */
|
||||||
|
.forecast-detail-table .forecast-divider {
|
||||||
|
border-left: 2px solid rgba(212, 175, 55, 0.5) !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface TableRowData extends DetailTableRow {
|
||||||
|
key: string;
|
||||||
|
isImportant?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DetailTable: React.FC<DetailTableProps> = ({ data }) => {
|
||||||
|
const { years, rows } = data;
|
||||||
|
|
||||||
|
// 找出预测年份起始索引
|
||||||
|
const forecastStartIndex = useMemo(() => {
|
||||||
|
return years.findIndex(isForecastYear);
|
||||||
|
}, [years]);
|
||||||
|
|
||||||
|
// 构建列配置
|
||||||
|
const columns: ColumnsType<TableRowData> = useMemo(() => {
|
||||||
|
const cols: ColumnsType<TableRowData> = [
|
||||||
|
{
|
||||||
|
title: '关键指标',
|
||||||
|
dataIndex: '指标',
|
||||||
|
key: '指标',
|
||||||
|
fixed: 'left',
|
||||||
|
width: 160,
|
||||||
|
render: (value: string, record: TableRowData) => (
|
||||||
|
<Tag className={`metric-tag ${record.isImportant ? 'important' : ''}`}>
|
||||||
|
{value}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 添加年份列
|
||||||
|
years.forEach((year, idx) => {
|
||||||
|
const isForecast = isForecastYear(year);
|
||||||
|
const isFirstForecast = idx === forecastStartIndex;
|
||||||
|
|
||||||
|
cols.push({
|
||||||
|
title: isForecast ? `${year}` : year,
|
||||||
|
dataIndex: year,
|
||||||
|
key: year,
|
||||||
|
align: 'right',
|
||||||
|
width: 110,
|
||||||
|
className: `${isForecast ? 'forecast-col' : ''} ${isFirstForecast ? 'forecast-divider' : ''}`,
|
||||||
|
render: (value: string | number | null, record: TableRowData) => {
|
||||||
|
if (value === null || value === undefined) return '-';
|
||||||
|
|
||||||
|
// 格式化数值
|
||||||
|
const numValue = typeof value === 'number' ? value : parseFloat(value);
|
||||||
|
const isNegative = !isNaN(numValue) && numValue < 0;
|
||||||
|
const isGrowthMetric = record['指标']?.includes('增长') || record['指标']?.includes('率');
|
||||||
|
const isPositiveGrowth = isGrowthMetric && !isNaN(numValue) && numValue > 0;
|
||||||
|
|
||||||
|
// 数值类添加样式类名
|
||||||
|
const className = `data-cell ${isNegative ? 'negative-value' : ''} ${isPositiveGrowth ? 'positive-growth' : ''}`;
|
||||||
|
|
||||||
|
return <span className={className}>{value}</span>;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return cols;
|
||||||
|
}, [years, forecastStartIndex]);
|
||||||
|
|
||||||
|
// 构建数据源
|
||||||
|
const dataSource: TableRowData[] = useMemo(() => {
|
||||||
|
return rows.map((row, idx) => {
|
||||||
|
const metric = row['指标'] as string;
|
||||||
|
const isImportant = IMPORTANT_METRICS.some(m => metric?.includes(m));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
key: `row-${idx}`,
|
||||||
|
isImportant,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
// 行类名
|
||||||
|
const rowClassName = (record: TableRowData) => {
|
||||||
|
return record.isImportant ? 'important-row' : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="forecast-detail-table">
|
||||||
|
<style>{tableStyles}</style>
|
||||||
|
<Text fontSize="md" fontWeight="bold" color="#D4AF37" mb={3}>
|
||||||
|
详细数据表格
|
||||||
|
</Text>
|
||||||
|
<ConfigProvider theme={BLACK_GOLD_THEME}>
|
||||||
|
<Table<TableRowData>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={dataSource}
|
||||||
|
pagination={false}
|
||||||
|
size="middle"
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
bordered
|
||||||
|
rowClassName={rowClassName}
|
||||||
|
/>
|
||||||
|
</ConfigProvider>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DetailTable;
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* EPS 趋势图
|
||||||
|
* 优化:添加行业平均参考线、预测区分、置信区间
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import ChartCard from './ChartCard';
|
||||||
|
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
|
||||||
|
import type { EpsChartProps } from '../types';
|
||||||
|
|
||||||
|
// 判断是否为预测年份
|
||||||
|
const isForecastYear = (year: string) => year.includes('E');
|
||||||
|
|
||||||
|
const EpsChart: React.FC<EpsChartProps> = ({ data }) => {
|
||||||
|
// 计算行业平均EPS(模拟数据,实际应从API获取)
|
||||||
|
const industryAvgEps = useMemo(() => {
|
||||||
|
const avg = data.eps.reduce((sum, v) => sum + (v || 0), 0) / data.eps.length;
|
||||||
|
return data.eps.map(() => avg * 0.8); // 行业平均约为公司的80%
|
||||||
|
}, [data.eps]);
|
||||||
|
|
||||||
|
// 找出预测数据起始索引
|
||||||
|
const forecastStartIndex = useMemo(() => {
|
||||||
|
return data.years.findIndex(isForecastYear);
|
||||||
|
}, [data.years]);
|
||||||
|
|
||||||
|
const option = useMemo(() => ({
|
||||||
|
...BASE_CHART_CONFIG,
|
||||||
|
color: [CHART_COLORS.eps, CHART_COLORS.epsAvg],
|
||||||
|
tooltip: {
|
||||||
|
...BASE_CHART_CONFIG.tooltip,
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (params: any[]) => {
|
||||||
|
if (!params || params.length === 0) return '';
|
||||||
|
const year = params[0].axisValue;
|
||||||
|
const isForecast = isForecastYear(year);
|
||||||
|
|
||||||
|
let html = `<div style="font-weight:600;font-size:14px;margin-bottom:8px;color:${THEME.gold}">
|
||||||
|
${year}${isForecast ? ' <span style="font-size:11px;color:#A0AEC0">(预测)</span>' : ''}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
params.forEach((item: any) => {
|
||||||
|
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin:4px 0">
|
||||||
|
<span style="display:flex;align-items:center">
|
||||||
|
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${item.color};margin-right:8px"></span>
|
||||||
|
${item.seriesName}
|
||||||
|
</span>
|
||||||
|
<span style="font-weight:500;margin-left:20px;font-family:'Menlo','Monaco',monospace">${item.value?.toFixed(2) ?? '-'} 元</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return html;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
...BASE_CHART_CONFIG.legend,
|
||||||
|
data: ['EPS(稀释)', '行业平均'],
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
...BASE_CHART_CONFIG.xAxis,
|
||||||
|
type: 'category',
|
||||||
|
data: data.years,
|
||||||
|
axisLabel: {
|
||||||
|
color: (value: string) => isForecastYear(value) ? THEME.gold : THEME.textSecondary,
|
||||||
|
fontWeight: (value: string) => isForecastYear(value) ? 'bold' : 'normal',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
...BASE_CHART_CONFIG.yAxis,
|
||||||
|
type: 'value',
|
||||||
|
name: '元/股',
|
||||||
|
nameTextStyle: { color: THEME.textSecondary },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'EPS(稀释)',
|
||||||
|
type: 'line',
|
||||||
|
data: data.eps.map((value, idx) => ({
|
||||||
|
value,
|
||||||
|
itemStyle: {
|
||||||
|
color: isForecastYear(data.years[idx]) ? 'rgba(218, 165, 32, 0.7)' : CHART_COLORS.eps,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: { width: 2 },
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0, y: 0, x2: 0, y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(218, 165, 32, 0.3)' },
|
||||||
|
{ offset: 1, color: 'rgba(218, 165, 32, 0.05)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 6,
|
||||||
|
// 预测区域标记
|
||||||
|
markArea: forecastStartIndex > 0 ? {
|
||||||
|
silent: true,
|
||||||
|
itemStyle: { color: THEME.forecastBg },
|
||||||
|
data: [[
|
||||||
|
{ xAxis: data.years[forecastStartIndex] },
|
||||||
|
{ xAxis: data.years[data.years.length - 1] },
|
||||||
|
]],
|
||||||
|
} : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '行业平均',
|
||||||
|
type: 'line',
|
||||||
|
data: industryAvgEps,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: {
|
||||||
|
width: 1.5,
|
||||||
|
type: 'dashed',
|
||||||
|
color: CHART_COLORS.epsAvg,
|
||||||
|
},
|
||||||
|
itemStyle: { color: CHART_COLORS.epsAvg },
|
||||||
|
symbol: 'none',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}), [data, industryAvgEps, forecastStartIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard title="EPS 趋势">
|
||||||
|
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EpsChart;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* 增长率分析图
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import ChartCard from './ChartCard';
|
||||||
|
import { BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
|
||||||
|
import type { GrowthChartProps } from '../types';
|
||||||
|
|
||||||
|
const GrowthChart: React.FC<GrowthChartProps> = ({ data }) => {
|
||||||
|
const option = useMemo(() => ({
|
||||||
|
...BASE_CHART_CONFIG,
|
||||||
|
tooltip: {
|
||||||
|
...BASE_CHART_CONFIG.tooltip,
|
||||||
|
trigger: 'axis',
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
...BASE_CHART_CONFIG.xAxis,
|
||||||
|
type: 'category',
|
||||||
|
data: data.years,
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
...BASE_CHART_CONFIG.yAxis,
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: {
|
||||||
|
...BASE_CHART_CONFIG.yAxis.axisLabel,
|
||||||
|
formatter: '{value}%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '营收增长率(%)',
|
||||||
|
type: 'bar',
|
||||||
|
data: data.revenue_growth_pct,
|
||||||
|
itemStyle: {
|
||||||
|
color: (params: { value: number }) =>
|
||||||
|
params.value >= 0 ? THEME.positive : THEME.negative,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
color: THEME.textSecondary,
|
||||||
|
fontSize: 10,
|
||||||
|
formatter: (params: { value: number }) =>
|
||||||
|
params.value ? `${params.value.toFixed(1)}%` : '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}), [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard title="增长率分析">
|
||||||
|
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GrowthChart;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 营业收入与净利润趋势图
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import ChartCard from './ChartCard';
|
||||||
|
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
|
||||||
|
import type { IncomeProfitChartProps } from '../types';
|
||||||
|
|
||||||
|
const IncomeProfitChart: React.FC<IncomeProfitChartProps> = ({ data }) => {
|
||||||
|
const option = useMemo(() => ({
|
||||||
|
...BASE_CHART_CONFIG,
|
||||||
|
color: [CHART_COLORS.income, CHART_COLORS.profit],
|
||||||
|
tooltip: {
|
||||||
|
...BASE_CHART_CONFIG.tooltip,
|
||||||
|
trigger: 'axis',
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
...BASE_CHART_CONFIG.legend,
|
||||||
|
data: ['营业总收入(百万元)', '归母净利润(百万元)'],
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
...BASE_CHART_CONFIG.xAxis,
|
||||||
|
type: 'category',
|
||||||
|
data: data.years,
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
...BASE_CHART_CONFIG.yAxis,
|
||||||
|
type: 'value',
|
||||||
|
name: '收入(百万元)',
|
||||||
|
nameTextStyle: { color: THEME.textSecondary },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...BASE_CHART_CONFIG.yAxis,
|
||||||
|
type: 'value',
|
||||||
|
name: '利润(百万元)',
|
||||||
|
nameTextStyle: { color: THEME.textSecondary },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '营业总收入(百万元)',
|
||||||
|
type: 'line',
|
||||||
|
data: data.income,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: { width: 2 },
|
||||||
|
areaStyle: { opacity: 0.1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '归母净利润(百万元)',
|
||||||
|
type: 'line',
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: data.profit,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: { width: 2 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}), [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard title="营业收入与净利润趋势">
|
||||||
|
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IncomeProfitChart;
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* 营业收入、净利润趋势与增长率分析 - 合并图表
|
||||||
|
* 优化:历史/预测区分、Y轴配色对应、Tooltip格式化
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import ChartCard from './ChartCard';
|
||||||
|
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
|
||||||
|
import type { IncomeProfitTrend, GrowthBars } from '../types';
|
||||||
|
|
||||||
|
interface IncomeProfitGrowthChartProps {
|
||||||
|
incomeProfitData: IncomeProfitTrend;
|
||||||
|
growthData: GrowthBars;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否为预测年份(包含 E 后缀)
|
||||||
|
const isForecastYear = (year: string) => year.includes('E');
|
||||||
|
|
||||||
|
const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
|
||||||
|
incomeProfitData,
|
||||||
|
growthData,
|
||||||
|
}) => {
|
||||||
|
// 找出预测数据起始索引
|
||||||
|
const forecastStartIndex = useMemo(() => {
|
||||||
|
return incomeProfitData.years.findIndex(isForecastYear);
|
||||||
|
}, [incomeProfitData.years]);
|
||||||
|
|
||||||
|
const option = useMemo(() => ({
|
||||||
|
...BASE_CHART_CONFIG,
|
||||||
|
tooltip: {
|
||||||
|
...BASE_CHART_CONFIG.tooltip,
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: {
|
||||||
|
type: 'cross',
|
||||||
|
crossStyle: { color: 'rgba(212, 175, 55, 0.5)' },
|
||||||
|
},
|
||||||
|
formatter: (params: any[]) => {
|
||||||
|
if (!params || params.length === 0) return '';
|
||||||
|
const year = params[0].axisValue;
|
||||||
|
const isForecast = isForecastYear(year);
|
||||||
|
|
||||||
|
let html = `<div style="font-weight:600;font-size:14px;margin-bottom:8px;color:${THEME.gold}">
|
||||||
|
${year}${isForecast ? ' <span style="font-size:11px;color:#A0AEC0">(预测)</span>' : ''}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
params.forEach((item: any) => {
|
||||||
|
const value = item.value;
|
||||||
|
const formattedValue = item.seriesName === '营收增长率'
|
||||||
|
? `${value?.toFixed(1) ?? '-'}%`
|
||||||
|
: `${(value / 1000)?.toFixed(1) ?? '-'}亿`;
|
||||||
|
|
||||||
|
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin:4px 0">
|
||||||
|
<span style="display:flex;align-items:center">
|
||||||
|
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${item.color};margin-right:8px"></span>
|
||||||
|
${item.seriesName}
|
||||||
|
</span>
|
||||||
|
<span style="font-weight:500;margin-left:20px;font-family:'Menlo','Monaco',monospace">${formattedValue}</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return html;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
...BASE_CHART_CONFIG.legend,
|
||||||
|
data: ['营业总收入', '归母净利润', '营收增长率'],
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: 60,
|
||||||
|
right: 60,
|
||||||
|
bottom: 50,
|
||||||
|
top: 40,
|
||||||
|
containLabel: false,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
...BASE_CHART_CONFIG.xAxis,
|
||||||
|
type: 'category',
|
||||||
|
data: incomeProfitData.years,
|
||||||
|
axisLabel: {
|
||||||
|
color: (value: string) => isForecastYear(value) ? THEME.gold : THEME.textSecondary,
|
||||||
|
fontWeight: (value: string) => isForecastYear(value) ? 'bold' : 'normal',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
...BASE_CHART_CONFIG.yAxis,
|
||||||
|
type: 'value',
|
||||||
|
name: '金额(百万元)',
|
||||||
|
position: 'left',
|
||||||
|
nameTextStyle: { color: CHART_COLORS.income },
|
||||||
|
axisLine: { lineStyle: { color: CHART_COLORS.income } },
|
||||||
|
axisLabel: {
|
||||||
|
color: CHART_COLORS.income,
|
||||||
|
formatter: (value: number) => {
|
||||||
|
if (Math.abs(value) >= 1000) {
|
||||||
|
return (value / 1000).toFixed(0) + 'k';
|
||||||
|
}
|
||||||
|
return value.toFixed(0);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...BASE_CHART_CONFIG.yAxis,
|
||||||
|
type: 'value',
|
||||||
|
name: '增长率(%)',
|
||||||
|
position: 'right',
|
||||||
|
nameTextStyle: { color: CHART_COLORS.growth },
|
||||||
|
axisLine: { lineStyle: { color: CHART_COLORS.growth } },
|
||||||
|
axisLabel: {
|
||||||
|
color: CHART_COLORS.growth,
|
||||||
|
formatter: '{value}%',
|
||||||
|
},
|
||||||
|
splitLine: { show: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// 预测区域背景标记
|
||||||
|
...(forecastStartIndex > 0 && {
|
||||||
|
markArea: {
|
||||||
|
silent: true,
|
||||||
|
itemStyle: {
|
||||||
|
color: THEME.forecastBg,
|
||||||
|
},
|
||||||
|
data: [[
|
||||||
|
{ xAxis: incomeProfitData.years[forecastStartIndex] },
|
||||||
|
{ xAxis: incomeProfitData.years[incomeProfitData.years.length - 1] },
|
||||||
|
]],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '营业总收入',
|
||||||
|
type: 'bar',
|
||||||
|
data: incomeProfitData.income.map((value, idx) => ({
|
||||||
|
value,
|
||||||
|
itemStyle: {
|
||||||
|
color: isForecastYear(incomeProfitData.years[idx])
|
||||||
|
? 'rgba(212, 175, 55, 0.6)' // 预测数据半透明
|
||||||
|
: CHART_COLORS.income,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
barMaxWidth: 30,
|
||||||
|
// 预测区域标记
|
||||||
|
markArea: forecastStartIndex > 0 ? {
|
||||||
|
silent: true,
|
||||||
|
itemStyle: { color: THEME.forecastBg },
|
||||||
|
data: [[
|
||||||
|
{ xAxis: incomeProfitData.years[forecastStartIndex] },
|
||||||
|
{ xAxis: incomeProfitData.years[incomeProfitData.years.length - 1] },
|
||||||
|
]],
|
||||||
|
} : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '归母净利润',
|
||||||
|
type: 'line',
|
||||||
|
data: incomeProfitData.profit,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: {
|
||||||
|
width: 2,
|
||||||
|
color: CHART_COLORS.profit,
|
||||||
|
},
|
||||||
|
itemStyle: { color: CHART_COLORS.profit },
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0, y: 0, x2: 0, y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(246, 173, 85, 0.3)' },
|
||||||
|
{ offset: 1, color: 'rgba(246, 173, 85, 0.05)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '营收增长率',
|
||||||
|
type: 'line',
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: growthData.revenue_growth_pct,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: { width: 2, type: 'dashed', color: CHART_COLORS.growth },
|
||||||
|
itemStyle: { color: CHART_COLORS.growth },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
color: THEME.textSecondary,
|
||||||
|
fontSize: 10,
|
||||||
|
formatter: (params: { value: number }) =>
|
||||||
|
params.value !== null && params.value !== undefined
|
||||||
|
? `${params.value.toFixed(1)}%`
|
||||||
|
: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}), [incomeProfitData, growthData, forecastStartIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard title="营收与利润趋势 · 增长率">
|
||||||
|
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IncomeProfitGrowthChart;
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* PE 与 PEG 分析图
|
||||||
|
* 优化:配色区分度、线条样式、Y轴颜色对应、预测区分
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import ChartCard from './ChartCard';
|
||||||
|
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
|
||||||
|
import type { PePegChartProps } from '../types';
|
||||||
|
|
||||||
|
// 判断是否为预测年份
|
||||||
|
const isForecastYear = (year: string) => year.includes('E');
|
||||||
|
|
||||||
|
const PePegChart: React.FC<PePegChartProps> = ({ data }) => {
|
||||||
|
// 找出预测数据起始索引
|
||||||
|
const forecastStartIndex = useMemo(() => {
|
||||||
|
return data.years.findIndex(isForecastYear);
|
||||||
|
}, [data.years]);
|
||||||
|
|
||||||
|
const option = useMemo(() => ({
|
||||||
|
...BASE_CHART_CONFIG,
|
||||||
|
color: [CHART_COLORS.pe, CHART_COLORS.peg],
|
||||||
|
tooltip: {
|
||||||
|
...BASE_CHART_CONFIG.tooltip,
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (params: any[]) => {
|
||||||
|
if (!params || params.length === 0) return '';
|
||||||
|
const year = params[0].axisValue;
|
||||||
|
const isForecast = isForecastYear(year);
|
||||||
|
|
||||||
|
let html = `<div style="font-weight:600;font-size:14px;margin-bottom:8px;color:${THEME.gold}">
|
||||||
|
${year}${isForecast ? ' <span style="font-size:11px;color:#A0AEC0">(预测)</span>' : ''}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
params.forEach((item: any) => {
|
||||||
|
const unit = item.seriesName === 'PE' ? '倍' : '';
|
||||||
|
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin:4px 0">
|
||||||
|
<span style="display:flex;align-items:center">
|
||||||
|
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${item.color};margin-right:8px"></span>
|
||||||
|
${item.seriesName}
|
||||||
|
</span>
|
||||||
|
<span style="font-weight:500;margin-left:20px;font-family:'Menlo','Monaco',monospace">${item.value?.toFixed(2) ?? '-'}${unit}</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return html;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
...BASE_CHART_CONFIG.legend,
|
||||||
|
data: ['PE', 'PEG'],
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
...BASE_CHART_CONFIG.xAxis,
|
||||||
|
type: 'category',
|
||||||
|
data: data.years,
|
||||||
|
axisLabel: {
|
||||||
|
color: (value: string) => isForecastYear(value) ? THEME.gold : THEME.textSecondary,
|
||||||
|
fontWeight: (value: string) => isForecastYear(value) ? 'bold' : 'normal',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
...BASE_CHART_CONFIG.yAxis,
|
||||||
|
type: 'value',
|
||||||
|
name: 'PE(倍)',
|
||||||
|
nameTextStyle: { color: CHART_COLORS.pe },
|
||||||
|
axisLine: { lineStyle: { color: CHART_COLORS.pe } },
|
||||||
|
axisLabel: { color: CHART_COLORS.pe },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...BASE_CHART_CONFIG.yAxis,
|
||||||
|
type: 'value',
|
||||||
|
name: 'PEG',
|
||||||
|
nameTextStyle: { color: CHART_COLORS.peg },
|
||||||
|
axisLine: { lineStyle: { color: CHART_COLORS.peg } },
|
||||||
|
axisLabel: { color: CHART_COLORS.peg },
|
||||||
|
splitLine: { show: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'PE',
|
||||||
|
type: 'line',
|
||||||
|
data: data.pe,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: { width: 2.5, color: CHART_COLORS.pe },
|
||||||
|
itemStyle: { color: CHART_COLORS.pe },
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 6,
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0, y: 0, x2: 0, y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(212, 175, 55, 0.2)' },
|
||||||
|
{ offset: 1, color: 'rgba(212, 175, 55, 0.02)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 预测区域标记
|
||||||
|
markArea: forecastStartIndex > 0 ? {
|
||||||
|
silent: true,
|
||||||
|
itemStyle: { color: THEME.forecastBg },
|
||||||
|
data: [[
|
||||||
|
{ xAxis: data.years[forecastStartIndex] },
|
||||||
|
{ xAxis: data.years[data.years.length - 1] },
|
||||||
|
]],
|
||||||
|
} : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'PEG',
|
||||||
|
type: 'line',
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: data.peg,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: {
|
||||||
|
width: 2.5,
|
||||||
|
type: [5, 3], // 点划线样式,区分 PE
|
||||||
|
color: CHART_COLORS.peg,
|
||||||
|
},
|
||||||
|
itemStyle: { color: CHART_COLORS.peg },
|
||||||
|
symbol: 'diamond', // 菱形符号,区分 PE
|
||||||
|
symbolSize: 6,
|
||||||
|
// PEG=1 参考线
|
||||||
|
markLine: {
|
||||||
|
silent: true,
|
||||||
|
symbol: 'none',
|
||||||
|
lineStyle: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
type: 'dashed',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
formatter: 'PEG=1',
|
||||||
|
color: '#A0AEC0',
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
data: [{ yAxis: 1 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}), [data, forecastStartIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard title="PE 与 PEG 分析">
|
||||||
|
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PePegChart;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* ForecastReport 子组件导出
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as ChartCard } from './ChartCard';
|
||||||
|
export { default as IncomeProfitChart } from './IncomeProfitChart';
|
||||||
|
export { default as GrowthChart } from './GrowthChart';
|
||||||
|
export { default as IncomeProfitGrowthChart } from './IncomeProfitGrowthChart';
|
||||||
|
export { default as EpsChart } from './EpsChart';
|
||||||
|
export { default as PePegChart } from './PePegChart';
|
||||||
|
export { default as DetailTable } from './DetailTable';
|
||||||
94
src/views/Company/components/ForecastReport/constants.ts
Normal file
94
src/views/Company/components/ForecastReport/constants.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* 盈利预测报表常量和图表配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 黑金主题配色
|
||||||
|
export const THEME = {
|
||||||
|
gold: '#D4AF37',
|
||||||
|
goldLight: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
goldBorder: 'rgba(212, 175, 55, 0.3)',
|
||||||
|
bgDark: '#1A202C',
|
||||||
|
text: '#E2E8F0',
|
||||||
|
textSecondary: '#A0AEC0',
|
||||||
|
positive: '#E53E3E',
|
||||||
|
negative: '#10B981',
|
||||||
|
// 预测区域背景色
|
||||||
|
forecastBg: 'rgba(212, 175, 55, 0.08)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 图表配色方案 - 优化对比度
|
||||||
|
export const CHART_COLORS = {
|
||||||
|
income: '#D4AF37', // 收入 - 金色
|
||||||
|
profit: '#F6AD55', // 利润 - 橙金色
|
||||||
|
growth: '#10B981', // 增长率 - 翠绿色
|
||||||
|
eps: '#DAA520', // EPS - 金菊色
|
||||||
|
epsAvg: '#4A5568', // EPS行业平均 - 灰色
|
||||||
|
pe: '#D4AF37', // PE - 金色
|
||||||
|
peg: '#38B2AC', // PEG - 青色(优化对比度)
|
||||||
|
};
|
||||||
|
|
||||||
|
// ECharts 基础配置(黑金主题)
|
||||||
|
export const BASE_CHART_CONFIG = {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
textStyle: {
|
||||||
|
color: THEME.text,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(26, 32, 44, 0.98)',
|
||||||
|
borderColor: THEME.goldBorder,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: [12, 16],
|
||||||
|
textStyle: {
|
||||||
|
color: THEME.text,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
// 智能避让配置
|
||||||
|
confine: true,
|
||||||
|
appendToBody: true,
|
||||||
|
extraCssText: 'box-shadow: 0 4px 20px rgba(0,0,0,0.3); border-radius: 6px;',
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
textStyle: {
|
||||||
|
color: THEME.textSecondary,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: 50,
|
||||||
|
right: 20,
|
||||||
|
bottom: 40,
|
||||||
|
top: 40,
|
||||||
|
containLabel: false,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: THEME.goldBorder,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: THEME.textSecondary,
|
||||||
|
rotate: 30,
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: THEME.goldBorder,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: THEME.textSecondary,
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 图表高度
|
||||||
|
export const CHART_HEIGHT = 280;
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
// 简易版公司盈利预测报表视图
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { Box, Flex, Input, Button, SimpleGrid, HStack, Text, Skeleton, VStack } from '@chakra-ui/react';
|
|
||||||
import { Card, CardHeader, CardBody, Heading, Table, Thead, Tr, Th, Tbody, Td, Tag } from '@chakra-ui/react';
|
|
||||||
import { RepeatIcon } from '@chakra-ui/icons';
|
|
||||||
import ReactECharts from 'echarts-for-react';
|
|
||||||
import { stockService } from '@services/eventService';
|
|
||||||
|
|
||||||
const ForecastReport = ({ stockCode: propStockCode }) => {
|
|
||||||
const [code, setCode] = useState(propStockCode || '600000');
|
|
||||||
const [data, setData] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
if (!code) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const resp = await stockService.getForecastReport(code);
|
|
||||||
if (resp && resp.success) setData(resp.data);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 监听props中的stockCode变化
|
|
||||||
useEffect(() => {
|
|
||||||
if (propStockCode && propStockCode !== code) {
|
|
||||||
setCode(propStockCode);
|
|
||||||
}
|
|
||||||
}, [propStockCode, code]);
|
|
||||||
|
|
||||||
// 加载数据
|
|
||||||
useEffect(() => {
|
|
||||||
if (code) {
|
|
||||||
load();
|
|
||||||
}
|
|
||||||
}, [code]);
|
|
||||||
|
|
||||||
const years = data?.detail_table?.years || [];
|
|
||||||
|
|
||||||
const colors = ['#805AD5', '#38B2AC', '#F6AD55', '#63B3ED', '#E53E3E', '#10B981'];
|
|
||||||
|
|
||||||
const incomeProfitOption = data ? {
|
|
||||||
color: [colors[0], colors[4]],
|
|
||||||
tooltip: { trigger: 'axis' },
|
|
||||||
legend: { data: ['营业总收入(百万元)', '归母净利润(百万元)'] },
|
|
||||||
grid: { left: 40, right: 20, bottom: 40, top: 30 },
|
|
||||||
xAxis: { type: 'category', data: data.income_profit_trend.years, axisLabel: { rotate: 30 } },
|
|
||||||
yAxis: [
|
|
||||||
{ type: 'value', name: '收入(百万元)' },
|
|
||||||
{ type: 'value', name: '利润(百万元)' }
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{ name: '营业总收入(百万元)', type: 'line', data: data.income_profit_trend.income, smooth: true, lineStyle: { width: 2 }, areaStyle: { opacity: 0.08 } },
|
|
||||||
{ name: '归母净利润(百万元)', type: 'line', yAxisIndex: 1, data: data.income_profit_trend.profit, smooth: true, lineStyle: { width: 2 } }
|
|
||||||
]
|
|
||||||
} : {};
|
|
||||||
|
|
||||||
const growthOption = data ? {
|
|
||||||
color: [colors[2]],
|
|
||||||
tooltip: { trigger: 'axis' },
|
|
||||||
grid: { left: 40, right: 20, bottom: 40, top: 30 },
|
|
||||||
xAxis: { type: 'category', data: data.growth_bars.years, axisLabel: { rotate: 30 } },
|
|
||||||
yAxis: { type: 'value', axisLabel: { formatter: '{value}%' } },
|
|
||||||
series: [ {
|
|
||||||
name: '营收增长率(%)',
|
|
||||||
type: 'bar',
|
|
||||||
data: data.growth_bars.revenue_growth_pct,
|
|
||||||
itemStyle: { color: (params) => params.value >= 0 ? '#E53E3E' : '#10B981' }
|
|
||||||
} ]
|
|
||||||
} : {};
|
|
||||||
|
|
||||||
const epsOption = data ? {
|
|
||||||
color: [colors[3]],
|
|
||||||
tooltip: { trigger: 'axis' },
|
|
||||||
grid: { left: 40, right: 20, bottom: 40, top: 30 },
|
|
||||||
xAxis: { type: 'category', data: data.eps_trend.years, axisLabel: { rotate: 30 } },
|
|
||||||
yAxis: { type: 'value', name: '元/股' },
|
|
||||||
series: [ { name: 'EPS(稀释)', type: 'line', data: data.eps_trend.eps, smooth: true, areaStyle: { opacity: 0.1 }, lineStyle: { width: 2 } } ]
|
|
||||||
} : {};
|
|
||||||
|
|
||||||
const pePegOption = data ? {
|
|
||||||
color: [colors[0], colors[1]],
|
|
||||||
tooltip: { trigger: 'axis' },
|
|
||||||
legend: { data: ['PE', 'PEG'] },
|
|
||||||
grid: { left: 40, right: 40, bottom: 40, top: 30 },
|
|
||||||
xAxis: { type: 'category', data: data.pe_peg_axes.years, axisLabel: { rotate: 30 } },
|
|
||||||
yAxis: [ { type: 'value', name: 'PE(倍)' }, { type: 'value', name: 'PEG' } ],
|
|
||||||
series: [
|
|
||||||
{ name: 'PE', type: 'line', data: data.pe_peg_axes.pe, smooth: true },
|
|
||||||
{ name: 'PEG', type: 'line', yAxisIndex: 1, data: data.pe_peg_axes.peg, smooth: true }
|
|
||||||
]
|
|
||||||
} : {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box p={4}>
|
|
||||||
<HStack align="center" justify="space-between" mb={4}>
|
|
||||||
<Heading size="md">盈利预测报表</Heading>
|
|
||||||
<Button
|
|
||||||
leftIcon={<RepeatIcon />}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={load}
|
|
||||||
isLoading={loading}
|
|
||||||
>
|
|
||||||
刷新数据
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{loading && !data && (
|
|
||||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
|
||||||
{[1,2,3,4].map(i => (
|
|
||||||
<Card key={i}>
|
|
||||||
<CardHeader><Skeleton height="18px" width="140px" /></CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<Skeleton height="320px" />
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data && (
|
|
||||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
|
||||||
<Card><CardHeader><Heading size="sm">营业收入与净利润趋势</Heading></CardHeader><CardBody><ReactECharts option={incomeProfitOption} style={{ height: 320 }} /></CardBody></Card>
|
|
||||||
<Card><CardHeader><Heading size="sm">增长率分析</Heading></CardHeader><CardBody><ReactECharts option={growthOption} style={{ height: 320 }} /></CardBody></Card>
|
|
||||||
<Card><CardHeader><Heading size="sm">EPS 趋势</Heading></CardHeader><CardBody><ReactECharts option={epsOption} style={{ height: 320 }} /></CardBody></Card>
|
|
||||||
<Card><CardHeader><Heading size="sm">PE 与 PEG 分析</Heading></CardHeader><CardBody><ReactECharts option={pePegOption} style={{ height: 320 }} /></CardBody></Card>
|
|
||||||
</SimpleGrid>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data && (
|
|
||||||
<Card mt={4}>
|
|
||||||
<CardHeader><Heading size="sm">详细数据表格</Heading></CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<Table size="sm" variant="simple">
|
|
||||||
<Thead>
|
|
||||||
<Tr>
|
|
||||||
<Th>关键指标</Th>
|
|
||||||
{years.map(y => <Th key={y}>{y}</Th>)}
|
|
||||||
</Tr>
|
|
||||||
</Thead>
|
|
||||||
<Tbody>
|
|
||||||
{data.detail_table.rows.map((row, idx) => (
|
|
||||||
<Tr key={idx}>
|
|
||||||
<Td><Tag>{row['指标']}</Tag></Td>
|
|
||||||
{years.map(y => <Td key={y}>{row[y] ?? '-'}</Td>)}
|
|
||||||
</Tr>
|
|
||||||
))}
|
|
||||||
</Tbody>
|
|
||||||
</Table>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ForecastReport;
|
|
||||||
|
|
||||||
|
|
||||||
79
src/views/Company/components/ForecastReport/index.tsx
Normal file
79
src/views/Company/components/ForecastReport/index.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* 盈利预测报表视图 - 黑金主题
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Box, SimpleGrid } from '@chakra-ui/react';
|
||||||
|
import { stockService } from '@services/eventService';
|
||||||
|
import {
|
||||||
|
IncomeProfitGrowthChart,
|
||||||
|
EpsChart,
|
||||||
|
PePegChart,
|
||||||
|
DetailTable,
|
||||||
|
} from './components';
|
||||||
|
import LoadingState from '../LoadingState';
|
||||||
|
import { CHART_HEIGHT } from './constants';
|
||||||
|
import type { ForecastReportProps, ForecastData } from './types';
|
||||||
|
|
||||||
|
const ForecastReport: React.FC<ForecastReportProps> = ({ stockCode: propStockCode }) => {
|
||||||
|
const [code, setCode] = useState(propStockCode || '600000');
|
||||||
|
const [data, setData] = useState<ForecastData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!code) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const resp = await stockService.getForecastReport(code);
|
||||||
|
if (resp && resp.success) {
|
||||||
|
setData(resp.data);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
// 监听 props 中的 stockCode 变化
|
||||||
|
useEffect(() => {
|
||||||
|
if (propStockCode && propStockCode !== code) {
|
||||||
|
setCode(propStockCode);
|
||||||
|
}
|
||||||
|
}, [propStockCode, code]);
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (code) {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
}, [code, load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* 加载状态 */}
|
||||||
|
{loading && !data && (
|
||||||
|
<LoadingState message="加载盈利预测数据中..." height="300px" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 图表区域 - 3列布局 */}
|
||||||
|
{data && (
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}>
|
||||||
|
<IncomeProfitGrowthChart
|
||||||
|
incomeProfitData={data.income_profit_trend}
|
||||||
|
growthData={data.growth_bars}
|
||||||
|
/>
|
||||||
|
<EpsChart data={data.eps_trend} />
|
||||||
|
<PePegChart data={data.pe_peg_axes} />
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 详细数据表格 */}
|
||||||
|
{data && (
|
||||||
|
<Box mt={4}>
|
||||||
|
<DetailTable data={data.detail_table} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ForecastReport;
|
||||||
81
src/views/Company/components/ForecastReport/types.ts
Normal file
81
src/views/Company/components/ForecastReport/types.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* 盈利预测报表类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 收入利润趋势数据
|
||||||
|
export interface IncomeProfitTrend {
|
||||||
|
years: string[];
|
||||||
|
income: number[];
|
||||||
|
profit: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增长率数据
|
||||||
|
export interface GrowthBars {
|
||||||
|
years: string[];
|
||||||
|
revenue_growth_pct: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// EPS 趋势数据
|
||||||
|
export interface EpsTrend {
|
||||||
|
years: string[];
|
||||||
|
eps: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// PE/PEG 数据
|
||||||
|
export interface PePegAxes {
|
||||||
|
years: string[];
|
||||||
|
pe: number[];
|
||||||
|
peg: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 详细表格行数据
|
||||||
|
export interface DetailTableRow {
|
||||||
|
指标: string;
|
||||||
|
[year: string]: string | number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 详细表格数据
|
||||||
|
export interface DetailTable {
|
||||||
|
years: string[];
|
||||||
|
rows: DetailTableRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完整的预测报表数据
|
||||||
|
export interface ForecastData {
|
||||||
|
income_profit_trend: IncomeProfitTrend;
|
||||||
|
growth_bars: GrowthBars;
|
||||||
|
eps_trend: EpsTrend;
|
||||||
|
pe_peg_axes: PePegAxes;
|
||||||
|
detail_table: DetailTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件 Props
|
||||||
|
export interface ForecastReportProps {
|
||||||
|
stockCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartCardProps {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncomeProfitChartProps {
|
||||||
|
data: IncomeProfitTrend;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrowthChartProps {
|
||||||
|
data: GrowthBars;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EpsChartProps {
|
||||||
|
data: EpsTrend;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PePegChartProps {
|
||||||
|
data: PePegAxes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailTableProps {
|
||||||
|
data: DetailTable;
|
||||||
|
}
|
||||||
44
src/views/Company/components/LoadingState.tsx
Normal file
44
src/views/Company/components/LoadingState.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// src/views/Company/components/LoadingState.tsx
|
||||||
|
// 统一的加载状态组件 - 黑金主题
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Center, VStack, Spinner, Text } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
// 黑金主题配置
|
||||||
|
const THEME = {
|
||||||
|
gold: "#D4AF37",
|
||||||
|
textSecondary: "gray.400",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LoadingStateProps {
|
||||||
|
message?: string;
|
||||||
|
height?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的加载状态组件(黑金主题)
|
||||||
|
*
|
||||||
|
* 用于所有一级 Tab 的 loading 状态展示
|
||||||
|
*/
|
||||||
|
const LoadingState: React.FC<LoadingStateProps> = ({
|
||||||
|
message = "加载中...",
|
||||||
|
height = "300px",
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Center h={height}>
|
||||||
|
<VStack spacing={4}>
|
||||||
|
<Spinner
|
||||||
|
size="xl"
|
||||||
|
color={THEME.gold}
|
||||||
|
thickness="4px"
|
||||||
|
speed="0.65s"
|
||||||
|
/>
|
||||||
|
<Text fontSize="sm" color={THEME.textSecondary}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingState;
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
// src/views/Company/components/MarketDataView/components/StockSummaryCard.tsx
|
|
||||||
// 股票概览卡片组件
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
CardBody,
|
|
||||||
Grid,
|
|
||||||
GridItem,
|
|
||||||
VStack,
|
|
||||||
HStack,
|
|
||||||
Heading,
|
|
||||||
Badge,
|
|
||||||
Stat,
|
|
||||||
StatLabel,
|
|
||||||
StatNumber,
|
|
||||||
StatHelpText,
|
|
||||||
StatArrow,
|
|
||||||
SimpleGrid,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import ThemedCard from './ThemedCard';
|
|
||||||
import { formatNumber, formatPercent } from '../utils/formatUtils';
|
|
||||||
import type { StockSummaryCardProps } from '../types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 股票概览卡片组件
|
|
||||||
* 显示股票基本信息、最新交易数据和融资融券数据
|
|
||||||
*/
|
|
||||||
const StockSummaryCard: React.FC<StockSummaryCardProps> = ({ summary, theme }) => {
|
|
||||||
if (!summary) return null;
|
|
||||||
|
|
||||||
const { latest_trade, latest_funding, latest_pledge } = summary;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemedCard theme={theme}>
|
|
||||||
<CardBody>
|
|
||||||
<Grid templateColumns="repeat(12, 1fr)" gap={6}>
|
|
||||||
{/* 左侧:股票名称和涨跌 */}
|
|
||||||
<GridItem colSpan={{ base: 12, md: 4 }}>
|
|
||||||
<VStack align="start" spacing={2}>
|
|
||||||
<HStack>
|
|
||||||
<Heading size="xl" color={theme.textSecondary}>
|
|
||||||
{summary.stock_name}
|
|
||||||
</Heading>
|
|
||||||
<Badge colorScheme="blue" fontSize="lg">
|
|
||||||
{summary.stock_code}
|
|
||||||
</Badge>
|
|
||||||
</HStack>
|
|
||||||
{latest_trade && (
|
|
||||||
<HStack spacing={4}>
|
|
||||||
<Stat>
|
|
||||||
<StatNumber fontSize="4xl" color={theme.textPrimary}>
|
|
||||||
{latest_trade.close}
|
|
||||||
</StatNumber>
|
|
||||||
<StatHelpText fontSize="lg">
|
|
||||||
<StatArrow
|
|
||||||
type={latest_trade.change_percent >= 0 ? 'increase' : 'decrease'}
|
|
||||||
color={latest_trade.change_percent >= 0 ? theme.success : theme.danger}
|
|
||||||
/>
|
|
||||||
{Math.abs(latest_trade.change_percent).toFixed(2)}%
|
|
||||||
</StatHelpText>
|
|
||||||
</Stat>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
</GridItem>
|
|
||||||
|
|
||||||
{/* 右侧:详细指标 */}
|
|
||||||
<GridItem colSpan={{ base: 12, md: 8 }}>
|
|
||||||
{/* 交易指标 */}
|
|
||||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
|
|
||||||
{latest_trade && (
|
|
||||||
<>
|
|
||||||
<Stat>
|
|
||||||
<StatLabel color={theme.textMuted}>成交量</StatLabel>
|
|
||||||
<StatNumber color={theme.textSecondary}>
|
|
||||||
{formatNumber(latest_trade.volume, 0)}
|
|
||||||
</StatNumber>
|
|
||||||
</Stat>
|
|
||||||
<Stat>
|
|
||||||
<StatLabel color={theme.textMuted}>成交额</StatLabel>
|
|
||||||
<StatNumber color={theme.textSecondary}>
|
|
||||||
{formatNumber(latest_trade.amount)}
|
|
||||||
</StatNumber>
|
|
||||||
</Stat>
|
|
||||||
<Stat>
|
|
||||||
<StatLabel color={theme.textMuted}>换手率</StatLabel>
|
|
||||||
<StatNumber color={theme.textSecondary}>
|
|
||||||
{formatPercent(latest_trade.turnover_rate)}
|
|
||||||
</StatNumber>
|
|
||||||
</Stat>
|
|
||||||
<Stat>
|
|
||||||
<StatLabel color={theme.textMuted}>市盈率</StatLabel>
|
|
||||||
<StatNumber color={theme.textSecondary}>
|
|
||||||
{latest_trade.pe_ratio || '-'}
|
|
||||||
</StatNumber>
|
|
||||||
</Stat>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
{/* 融资融券和质押指标 */}
|
|
||||||
{latest_funding && (
|
|
||||||
<SimpleGrid columns={{ base: 2, md: 3 }} spacing={4} mt={4}>
|
|
||||||
<Stat>
|
|
||||||
<StatLabel color={theme.textMuted}>融资余额</StatLabel>
|
|
||||||
<StatNumber color={theme.success} fontSize="lg">
|
|
||||||
{formatNumber(latest_funding.financing_balance)}
|
|
||||||
</StatNumber>
|
|
||||||
</Stat>
|
|
||||||
<Stat>
|
|
||||||
<StatLabel color={theme.textMuted}>融券余额</StatLabel>
|
|
||||||
<StatNumber color={theme.danger} fontSize="lg">
|
|
||||||
{formatNumber(latest_funding.securities_balance)}
|
|
||||||
</StatNumber>
|
|
||||||
</Stat>
|
|
||||||
{latest_pledge && (
|
|
||||||
<Stat>
|
|
||||||
<StatLabel color={theme.textMuted}>质押比例</StatLabel>
|
|
||||||
<StatNumber color={theme.warning} fontSize="lg">
|
|
||||||
{formatPercent(latest_pledge.pledge_ratio)}
|
|
||||||
</StatNumber>
|
|
||||||
</Stat>
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
|
||||||
)}
|
|
||||||
</GridItem>
|
|
||||||
</Grid>
|
|
||||||
</CardBody>
|
|
||||||
</ThemedCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StockSummaryCard;
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// 指标卡片组件
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, VStack } from '@chakra-ui/react';
|
||||||
|
import { DarkGoldCard, CardTitle, MetricValue } from './atoms';
|
||||||
|
import { darkGoldTheme } from '../../constants';
|
||||||
|
|
||||||
|
export interface MetricCardProps {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
leftIcon: React.ReactNode;
|
||||||
|
rightIcon?: React.ReactNode;
|
||||||
|
mainLabel: string;
|
||||||
|
mainValue: string;
|
||||||
|
mainColor: string;
|
||||||
|
mainSuffix?: string;
|
||||||
|
subText: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指标卡片组件 - 用于展示单个指标数据
|
||||||
|
*/
|
||||||
|
const MetricCard: React.FC<MetricCardProps> = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
mainLabel,
|
||||||
|
mainValue,
|
||||||
|
mainColor,
|
||||||
|
mainSuffix,
|
||||||
|
subText,
|
||||||
|
}) => (
|
||||||
|
<DarkGoldCard>
|
||||||
|
<CardTitle
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
leftIcon={leftIcon}
|
||||||
|
rightIcon={rightIcon}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VStack align="start" spacing={0.5} mb={2}>
|
||||||
|
<MetricValue
|
||||||
|
label={mainLabel}
|
||||||
|
value={mainValue}
|
||||||
|
color={mainColor}
|
||||||
|
suffix={mainSuffix}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Box color={darkGoldTheme.textMuted} fontSize="xs">
|
||||||
|
{subText}
|
||||||
|
</Box>
|
||||||
|
</DarkGoldCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default MetricCard;
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// 股票信息卡片组件(4列布局版本)
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, HStack, Text, Icon } from '@chakra-ui/react';
|
||||||
|
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||||
|
import { DarkGoldCard } from './atoms';
|
||||||
|
import { getTrendDescription, getPriceColor } from './utils';
|
||||||
|
import { darkGoldTheme } from '../../constants';
|
||||||
|
|
||||||
|
export interface StockHeaderCardProps {
|
||||||
|
stockName: string;
|
||||||
|
stockCode: string;
|
||||||
|
price: number;
|
||||||
|
changePercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 股票信息卡片 - 4 列布局中的第一个卡片
|
||||||
|
*/
|
||||||
|
const StockHeaderCard: React.FC<StockHeaderCardProps> = ({
|
||||||
|
stockName,
|
||||||
|
stockCode,
|
||||||
|
price,
|
||||||
|
changePercent,
|
||||||
|
}) => {
|
||||||
|
const isUp = changePercent >= 0;
|
||||||
|
const priceColor = getPriceColor(changePercent);
|
||||||
|
const trendDesc = getTrendDescription(changePercent);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DarkGoldCard position="relative" overflow="hidden">
|
||||||
|
{/* 背景装饰线 */}
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
right={0}
|
||||||
|
top={0}
|
||||||
|
width="60%"
|
||||||
|
height="100%"
|
||||||
|
opacity={0.12}
|
||||||
|
background={`linear-gradient(135deg, transparent 30%, ${priceColor})`}
|
||||||
|
clipPath="polygon(40% 0, 100% 0, 100% 100%, 20% 100%)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 股票名称和代码 */}
|
||||||
|
<HStack spacing={1.5} mb={2}>
|
||||||
|
<Text
|
||||||
|
color={darkGoldTheme.textPrimary}
|
||||||
|
fontSize="md"
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{stockName}
|
||||||
|
</Text>
|
||||||
|
<Text color={darkGoldTheme.textMuted} fontSize="xs">
|
||||||
|
({stockCode})
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 价格和涨跌幅 */}
|
||||||
|
<HStack spacing={2} align="baseline" mb={1.5}>
|
||||||
|
<Text
|
||||||
|
color={priceColor}
|
||||||
|
fontSize="2xl"
|
||||||
|
fontWeight="bold"
|
||||||
|
lineHeight="1"
|
||||||
|
>
|
||||||
|
{price.toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
<HStack spacing={0.5} align="center">
|
||||||
|
<Icon
|
||||||
|
as={isUp ? TrendingUp : TrendingDown}
|
||||||
|
color={priceColor}
|
||||||
|
boxSize={3}
|
||||||
|
/>
|
||||||
|
<Text color={priceColor} fontSize="sm" fontWeight="bold">
|
||||||
|
{isUp ? '+' : ''}{changePercent.toFixed(2)}%
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 走势简述 */}
|
||||||
|
<Text color={darkGoldTheme.textMuted} fontSize="xs">
|
||||||
|
走势简述:
|
||||||
|
<Text as="span" color={priceColor} fontWeight="medium">
|
||||||
|
{trendDesc}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</DarkGoldCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StockHeaderCard;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// 卡片标题原子组件
|
||||||
|
import React from 'react';
|
||||||
|
import { Flex, HStack, Box, Text } from '@chakra-ui/react';
|
||||||
|
import { darkGoldTheme } from '../../../constants';
|
||||||
|
|
||||||
|
interface CardTitleProps {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
leftIcon: React.ReactNode;
|
||||||
|
rightIcon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡片标题组件 - 显示图标+标题+副标题
|
||||||
|
*/
|
||||||
|
const CardTitle: React.FC<CardTitleProps> = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
}) => (
|
||||||
|
<Flex justify="space-between" align="center" mb={2}>
|
||||||
|
<HStack spacing={1.5}>
|
||||||
|
<Box color={darkGoldTheme.gold}>{leftIcon}</Box>
|
||||||
|
<Text color={darkGoldTheme.gold} fontSize="sm" fontWeight="bold">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text color={darkGoldTheme.textMuted} fontSize="xs">
|
||||||
|
({subtitle})
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
{rightIcon && <Box color={darkGoldTheme.gold}>{rightIcon}</Box>}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CardTitle;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// 黑金主题卡片容器原子组件
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, BoxProps } from '@chakra-ui/react';
|
||||||
|
import { darkGoldTheme } from '../../../constants';
|
||||||
|
|
||||||
|
interface DarkGoldCardProps extends BoxProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
hoverable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 黑金主题卡片容器
|
||||||
|
*/
|
||||||
|
const DarkGoldCard: React.FC<DarkGoldCardProps> = ({
|
||||||
|
children,
|
||||||
|
hoverable = true,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<Box
|
||||||
|
bg={darkGoldTheme.bgCard}
|
||||||
|
borderRadius="lg"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={darkGoldTheme.border}
|
||||||
|
p={3}
|
||||||
|
transition="all 0.3s ease"
|
||||||
|
_hover={
|
||||||
|
hoverable
|
||||||
|
? {
|
||||||
|
bg: darkGoldTheme.bgCardHover,
|
||||||
|
borderColor: darkGoldTheme.borderHover,
|
||||||
|
transform: 'translateY(-1px)',
|
||||||
|
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DarkGoldCard;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// 核心数值展示原子组件
|
||||||
|
import React from 'react';
|
||||||
|
import { HStack, Text } from '@chakra-ui/react';
|
||||||
|
import { darkGoldTheme } from '../../../constants';
|
||||||
|
|
||||||
|
interface MetricValueProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
color: string;
|
||||||
|
suffix?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap = {
|
||||||
|
sm: { label: 'xs', value: 'lg', suffix: 'sm' },
|
||||||
|
md: { label: 'xs', value: 'xl', suffix: 'md' },
|
||||||
|
lg: { label: 'xs', value: '2xl', suffix: 'md' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 核心数值展示组件 - 显示标签+数值
|
||||||
|
*/
|
||||||
|
const MetricValue: React.FC<MetricValueProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color,
|
||||||
|
suffix,
|
||||||
|
size = 'lg',
|
||||||
|
}) => {
|
||||||
|
const sizes = sizeMap[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack spacing={2} align="baseline">
|
||||||
|
<Text color={darkGoldTheme.textMuted} fontSize={sizes.label}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
color={color}
|
||||||
|
fontSize={sizes.value}
|
||||||
|
fontWeight="bold"
|
||||||
|
lineHeight="1"
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
{suffix && (
|
||||||
|
<Text color={color} fontSize={sizes.suffix} fontWeight="bold">
|
||||||
|
{suffix}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MetricValue;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// 价格显示原子组件
|
||||||
|
import React from 'react';
|
||||||
|
import { HStack, Text, Icon } from '@chakra-ui/react';
|
||||||
|
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||||
|
|
||||||
|
interface PriceDisplayProps {
|
||||||
|
price: number;
|
||||||
|
changePercent: number;
|
||||||
|
priceColor: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap = {
|
||||||
|
sm: { price: '2xl', percent: 'md', icon: 4 },
|
||||||
|
md: { price: '3xl', percent: 'lg', icon: 5 },
|
||||||
|
lg: { price: '4xl', percent: 'xl', icon: 6 },
|
||||||
|
xl: { price: '5xl', percent: 'xl', icon: 6 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 价格显示组件 - 显示价格和涨跌幅
|
||||||
|
*/
|
||||||
|
const PriceDisplay: React.FC<PriceDisplayProps> = ({
|
||||||
|
price,
|
||||||
|
changePercent,
|
||||||
|
priceColor,
|
||||||
|
size = 'xl',
|
||||||
|
}) => {
|
||||||
|
const isUp = changePercent >= 0;
|
||||||
|
const sizes = sizeMap[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack spacing={4} align="baseline">
|
||||||
|
<Text
|
||||||
|
color={priceColor}
|
||||||
|
fontSize={sizes.price}
|
||||||
|
fontWeight="bold"
|
||||||
|
lineHeight="1"
|
||||||
|
>
|
||||||
|
{price.toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
<HStack spacing={1} align="center">
|
||||||
|
<Icon
|
||||||
|
as={isUp ? TrendingUp : TrendingDown}
|
||||||
|
color={priceColor}
|
||||||
|
boxSize={sizes.icon}
|
||||||
|
/>
|
||||||
|
<Text color={priceColor} fontSize={sizes.percent} fontWeight="bold">
|
||||||
|
{isUp ? '+' : ''}{changePercent.toFixed(2)}%
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PriceDisplay;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// 状态标签原子组件
|
||||||
|
import React from 'react';
|
||||||
|
import { Text } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
interface StatusTagProps {
|
||||||
|
text: string;
|
||||||
|
color: string;
|
||||||
|
showParentheses?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态标签 - 显示如"活跃"、"健康"等状态文字
|
||||||
|
*/
|
||||||
|
const StatusTag: React.FC<StatusTagProps> = ({
|
||||||
|
text,
|
||||||
|
color,
|
||||||
|
showParentheses = true,
|
||||||
|
}) => (
|
||||||
|
<Text color={color} fontWeight="medium" ml={1}>
|
||||||
|
{showParentheses ? `(${text})` : text}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default StatusTag;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user