Compare commits
42 Commits
2eb2a22495
...
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 |
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",
|
||||
"gulp": "4.0.2",
|
||||
"gulp-append-prepend": "1.0.9",
|
||||
"husky": "^9.1.7",
|
||||
"imagemin": "^9.0.1",
|
||||
"imagemin-mozjpeg": "^10.0.0",
|
||||
"imagemin-pngquant": "^10.0.0",
|
||||
"kill-port": "^2.0.1",
|
||||
"less": "^4.4.2",
|
||||
"less-loader": "^12.3.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"msw": "^2.11.5",
|
||||
"prettier": "2.2.1",
|
||||
"react-error-overlay": "6.0.9",
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Icon,
|
||||
HStack,
|
||||
Text,
|
||||
Spacer,
|
||||
} from '@chakra-ui/react';
|
||||
import type { ComponentType } from 'react';
|
||||
import type { IconType } from 'react-icons';
|
||||
@@ -95,6 +96,8 @@ export interface SubTabContainerProps {
|
||||
contentPadding?: number;
|
||||
/** 是否懒加载 */
|
||||
isLazy?: boolean;
|
||||
/** TabList 右侧自定义内容 */
|
||||
rightElement?: React.ReactNode;
|
||||
}
|
||||
|
||||
const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
||||
@@ -107,6 +110,7 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
||||
theme: customTheme,
|
||||
contentPadding = 4,
|
||||
isLazy = true,
|
||||
rightElement,
|
||||
}) => {
|
||||
// 内部状态(非受控模式)
|
||||
const [internalIndex, setInternalIndex] = useState(defaultIndex);
|
||||
@@ -114,6 +118,11 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
||||
// 当前索引
|
||||
const currentIndex = controlledIndex ?? internalIndex;
|
||||
|
||||
// 记录已访问的 Tab 索引(用于真正的懒加载)
|
||||
const [visitedTabs, setVisitedTabs] = useState<Set<number>>(
|
||||
() => new Set([controlledIndex ?? defaultIndex])
|
||||
);
|
||||
|
||||
// 合并主题
|
||||
const theme: SubTabTheme = {
|
||||
...THEME_PRESETS[themePreset],
|
||||
@@ -128,6 +137,12 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
||||
const tabKey = tabs[newIndex]?.key || '';
|
||||
onTabChange?.(newIndex, tabKey);
|
||||
|
||||
// 记录已访问的 Tab(用于懒加载)
|
||||
setVisitedTabs(prev => {
|
||||
if (prev.has(newIndex)) return prev;
|
||||
return new Set(prev).add(newIndex);
|
||||
});
|
||||
|
||||
if (controlledIndex === undefined) {
|
||||
setInternalIndex(newIndex);
|
||||
}
|
||||
@@ -148,19 +163,27 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
||||
borderBottom="1px solid"
|
||||
borderColor={theme.borderColor}
|
||||
pl={0}
|
||||
pr={4}
|
||||
py={2}
|
||||
flexWrap="wrap"
|
||||
gap={2}
|
||||
pr={2}
|
||||
py={1.5}
|
||||
flexWrap="nowrap"
|
||||
gap={1}
|
||||
alignItems="center"
|
||||
overflowX="auto"
|
||||
css={{
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
scrollbarWidth: 'none',
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
color={theme.tabUnselectedColor}
|
||||
borderRadius="full"
|
||||
px={4}
|
||||
py={2}
|
||||
fontSize="sm"
|
||||
px={2.5}
|
||||
py={1.5}
|
||||
fontSize="xs"
|
||||
whiteSpace="nowrap"
|
||||
flexShrink={0}
|
||||
_selected={{
|
||||
bg: theme.tabSelectedBg,
|
||||
color: theme.tabSelectedColor,
|
||||
@@ -170,20 +193,31 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
||||
bg: theme.tabHoverBg,
|
||||
}}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
{tab.icon && <Icon as={tab.icon} boxSize={4} />}
|
||||
<HStack spacing={1}>
|
||||
{tab.icon && <Icon as={tab.icon} boxSize={3} />}
|
||||
<Text>{tab.name}</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
))}
|
||||
{rightElement && (
|
||||
<>
|
||||
<Spacer />
|
||||
<Box flexShrink={0}>{rightElement}</Box>
|
||||
</>
|
||||
)}
|
||||
</TabList>
|
||||
|
||||
<TabPanels p={contentPadding}>
|
||||
{tabs.map((tab) => {
|
||||
{tabs.map((tab, idx) => {
|
||||
const Component = tab.component;
|
||||
// 懒加载:只渲染已访问过的 Tab
|
||||
const shouldRender = !isLazy || visitedTabs.has(idx);
|
||||
|
||||
return (
|
||||
<TabPanel key={tab.key} p={0}>
|
||||
{Component ? <Component {...componentProps} /> : null}
|
||||
{shouldRender && Component ? (
|
||||
<Component {...componentProps} />
|
||||
) : null}
|
||||
</TabPanel>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -661,6 +661,12 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
// ========== 连接到 Socket 服务(⚡ 异步初始化,不阻塞首屏) ==========
|
||||
useEffect(() => {
|
||||
// ⚡ Mock 模式下跳过 Socket 连接(避免连接生产服务器失败的错误)
|
||||
if (process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
logger.debug('NotificationContext', 'Mock 模式,跳过 Socket 连接');
|
||||
return;
|
||||
}
|
||||
|
||||
// ⚡ 防止 React Strict Mode 导致的重复初始化
|
||||
if (socketInitialized) {
|
||||
logger.debug('NotificationContext', 'Socket 已初始化,跳过重复执行(Strict Mode 保护)');
|
||||
|
||||
11
src/index.js
11
src/index.js
@@ -5,6 +5,17 @@ import { BrowserRouter as Router } from 'react-router-dom';
|
||||
|
||||
// ⚡ 性能监控:在应用启动时尽早标记
|
||||
import { performanceMonitor } from './utils/performanceMonitor';
|
||||
|
||||
// T0: HTML 加载完成时间点
|
||||
if (document.readyState === 'complete') {
|
||||
performanceMonitor.mark('html-loaded');
|
||||
} else {
|
||||
window.addEventListener('load', () => {
|
||||
performanceMonitor.mark('html-loaded');
|
||||
});
|
||||
}
|
||||
|
||||
// T1: React 开始初始化
|
||||
performanceMonitor.mark('app-start');
|
||||
|
||||
// ⚡ 已删除 brainwave.css(项目未安装 Tailwind CSS,该文件无效)
|
||||
|
||||
@@ -874,8 +874,20 @@ export function generateMockEvents(params = {}) {
|
||||
e.title.toLowerCase().includes(query) ||
|
||||
e.description.toLowerCase().includes(query) ||
|
||||
// keywords 是对象数组 { concept, score, ... },需要访问 concept 属性
|
||||
e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query))
|
||||
e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query)) ||
|
||||
// 搜索 related_stocks 中的股票名称和代码
|
||||
(e.related_stocks && e.related_stocks.some(stock =>
|
||||
(stock.stock_name && stock.stock_name.toLowerCase().includes(query)) ||
|
||||
(stock.stock_code && stock.stock_code.toLowerCase().includes(query))
|
||||
)) ||
|
||||
// 搜索行业
|
||||
(e.industry && e.industry.toLowerCase().includes(query))
|
||||
);
|
||||
|
||||
// 如果搜索结果为空,返回所有事件(宽松模式)
|
||||
if (filteredEvents.length === 0) {
|
||||
filteredEvents = allEvents;
|
||||
}
|
||||
}
|
||||
|
||||
// 行业筛选
|
||||
@@ -1042,7 +1054,7 @@ function generateTransmissionChain(industry, index) {
|
||||
|
||||
let nodeName;
|
||||
if (nodeType === 'company' && industryStock) {
|
||||
nodeName = industryStock.name;
|
||||
nodeName = industryStock.stock_name;
|
||||
} else if (nodeType === 'industry') {
|
||||
nodeName = `${industry}产业`;
|
||||
} else if (nodeType === 'policy') {
|
||||
@@ -1133,7 +1145,7 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
|
||||
const stock = industryStocks[j % industryStocks.length];
|
||||
relatedStocks.push({
|
||||
stock_code: stock.stock_code,
|
||||
stock_name: stock.name,
|
||||
stock_name: stock.stock_name,
|
||||
relation_desc: relationDescriptions[j % relationDescriptions.length]
|
||||
});
|
||||
}
|
||||
@@ -1145,7 +1157,7 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
|
||||
if (!relatedStocks.some(s => s.stock_code === randomStock.stock_code)) {
|
||||
relatedStocks.push({
|
||||
stock_code: randomStock.stock_code,
|
||||
stock_name: randomStock.name,
|
||||
stock_name: randomStock.stock_name,
|
||||
relation_desc: relationDescriptions[relatedStocks.length % relationDescriptions.length]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,18 +258,75 @@ export const generateFinancialData = (stockCode) => {
|
||||
}
|
||||
})),
|
||||
|
||||
// 主营业务
|
||||
// 主营业务 - 按产品/业务分类
|
||||
mainBusiness: {
|
||||
by_product: [
|
||||
{ name: '对公业务', revenue: 68540, ratio: 42.2, yoy_growth: 6.8 },
|
||||
{ name: '零售业务', revenue: 81320, ratio: 50.1, yoy_growth: 11.2 },
|
||||
{ name: '金融市场业务', revenue: 12490, ratio: 7.7, yoy_growth: 3.5 }
|
||||
product_classification: [
|
||||
{
|
||||
period: '2024-09-30',
|
||||
report_type: '2024年三季报',
|
||||
products: [
|
||||
{ content: '零售金融业务', revenue: 81320000000, gross_margin: 68.5, profit_margin: 42.3, profit: 34398160000 },
|
||||
{ content: '对公金融业务', revenue: 68540000000, gross_margin: 62.8, profit_margin: 38.6, profit: 26456440000 },
|
||||
{ content: '金融市场业务', revenue: 12490000000, gross_margin: 75.2, profit_margin: 52.1, profit: 6507290000 },
|
||||
{ content: '合计', revenue: 162350000000, gross_margin: 67.5, profit_margin: 41.2, profit: 66883200000 },
|
||||
]
|
||||
},
|
||||
{
|
||||
period: '2024-06-30',
|
||||
report_type: '2024年中报',
|
||||
products: [
|
||||
{ content: '零售金融业务', revenue: 78650000000, gross_margin: 67.8, profit_margin: 41.5, profit: 32639750000 },
|
||||
{ content: '对公金融业务', revenue: 66280000000, gross_margin: 61.9, profit_margin: 37.8, profit: 25053840000 },
|
||||
{ content: '金融市场业务', revenue: 11870000000, gross_margin: 74.5, profit_margin: 51.2, profit: 6077440000 },
|
||||
{ content: '合计', revenue: 156800000000, gross_margin: 66.8, profit_margin: 40.5, profit: 63504000000 },
|
||||
]
|
||||
},
|
||||
{
|
||||
period: '2024-03-31',
|
||||
report_type: '2024年一季报',
|
||||
products: [
|
||||
{ content: '零售金融业务', revenue: 38920000000, gross_margin: 67.2, profit_margin: 40.8, profit: 15879360000 },
|
||||
{ content: '对公金融业务', revenue: 32650000000, gross_margin: 61.2, profit_margin: 37.1, profit: 12113150000 },
|
||||
{ content: '金融市场业务', revenue: 5830000000, gross_margin: 73.8, profit_margin: 50.5, profit: 2944150000 },
|
||||
{ content: '合计', revenue: 77400000000, gross_margin: 66.1, profit_margin: 39.8, profit: 30805200000 },
|
||||
]
|
||||
},
|
||||
{
|
||||
period: '2023-12-31',
|
||||
report_type: '2023年年报',
|
||||
products: [
|
||||
{ content: '零售金融业务', revenue: 152680000000, gross_margin: 66.5, profit_margin: 40.2, profit: 61377360000 },
|
||||
{ content: '对公金融业务', revenue: 128450000000, gross_margin: 60.5, profit_margin: 36.5, profit: 46884250000 },
|
||||
{ content: '金融市场业务', revenue: 22870000000, gross_margin: 73.2, profit_margin: 49.8, profit: 11389260000 },
|
||||
{ content: '合计', revenue: 304000000000, gross_margin: 65.2, profit_margin: 39.2, profit: 119168000000 },
|
||||
]
|
||||
},
|
||||
],
|
||||
by_region: [
|
||||
{ name: '华南地区', revenue: 56800, ratio: 35.0, yoy_growth: 9.2 },
|
||||
{ name: '华东地区', revenue: 48705, ratio: 30.0, yoy_growth: 8.5 },
|
||||
{ name: '华北地区', revenue: 32470, ratio: 20.0, yoy_growth: 7.8 },
|
||||
{ name: '其他地区', revenue: 24375, ratio: 15.0, yoy_growth: 6.5 }
|
||||
industry_classification: [
|
||||
{
|
||||
period: '2024-09-30',
|
||||
report_type: '2024年三季报',
|
||||
industries: [
|
||||
{ content: '华南地区', revenue: 56817500000, gross_margin: 69.2, profit_margin: 43.5, profit: 24715612500 },
|
||||
{ content: '华东地区', revenue: 48705000000, gross_margin: 67.8, profit_margin: 41.2, profit: 20066460000 },
|
||||
{ content: '华北地区', revenue: 32470000000, gross_margin: 65.5, profit_margin: 38.8, profit: 12598360000 },
|
||||
{ content: '西南地区', revenue: 16235000000, gross_margin: 64.2, profit_margin: 37.5, profit: 6088125000 },
|
||||
{ content: '其他地区', revenue: 8122500000, gross_margin: 62.8, profit_margin: 35.2, profit: 2859120000 },
|
||||
{ content: '合计', revenue: 162350000000, gross_margin: 67.5, profit_margin: 41.2, profit: 66883200000 },
|
||||
]
|
||||
},
|
||||
{
|
||||
period: '2024-06-30',
|
||||
report_type: '2024年中报',
|
||||
industries: [
|
||||
{ content: '华南地区', revenue: 54880000000, gross_margin: 68.5, profit_margin: 42.8, profit: 23488640000 },
|
||||
{ content: '华东地区', revenue: 47040000000, gross_margin: 67.1, profit_margin: 40.5, profit: 19051200000 },
|
||||
{ content: '华北地区', revenue: 31360000000, gross_margin: 64.8, profit_margin: 38.1, profit: 11948160000 },
|
||||
{ content: '西南地区', revenue: 15680000000, gross_margin: 63.5, profit_margin: 36.8, profit: 5770240000 },
|
||||
{ content: '其他地区', revenue: 7840000000, gross_margin: 62.1, profit_margin: 34.5, profit: 2704800000 },
|
||||
{ content: '合计', revenue: 156800000000, gross_margin: 66.8, profit_margin: 40.5, profit: 63504000000 },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
@@ -309,35 +366,50 @@ export const generateFinancialData = (stockCode) => {
|
||||
}
|
||||
],
|
||||
|
||||
// 期间对比
|
||||
periodComparison: {
|
||||
periods: ['Q3-2024', 'Q2-2024', 'Q1-2024', 'Q4-2023'],
|
||||
metrics: [
|
||||
{
|
||||
name: '营业收入',
|
||||
unit: '百万元',
|
||||
values: [41500, 40800, 40200, 40850],
|
||||
yoy: [8.2, 7.8, 8.5, 9.2]
|
||||
},
|
||||
{
|
||||
name: '净利润',
|
||||
unit: '百万元',
|
||||
values: [13420, 13180, 13050, 13210],
|
||||
yoy: [12.5, 11.2, 10.8, 12.3]
|
||||
},
|
||||
{
|
||||
name: 'ROE',
|
||||
unit: '%',
|
||||
values: [16.23, 15.98, 15.75, 16.02],
|
||||
yoy: [1.2, 0.8, 0.5, 1.0]
|
||||
},
|
||||
{
|
||||
name: 'EPS',
|
||||
unit: '元',
|
||||
values: [0.69, 0.68, 0.67, 0.68],
|
||||
yoy: [12.3, 11.5, 10.5, 12.0]
|
||||
// 期间对比 - 营收与利润趋势数据
|
||||
periodComparison: [
|
||||
{
|
||||
period: '2024-09-30',
|
||||
performance: {
|
||||
revenue: 41500000000, // 415亿
|
||||
net_profit: 13420000000 // 134.2亿
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
period: '2024-06-30',
|
||||
performance: {
|
||||
revenue: 40800000000, // 408亿
|
||||
net_profit: 13180000000 // 131.8亿
|
||||
}
|
||||
},
|
||||
{
|
||||
period: '2024-03-31',
|
||||
performance: {
|
||||
revenue: 40200000000, // 402亿
|
||||
net_profit: 13050000000 // 130.5亿
|
||||
}
|
||||
},
|
||||
{
|
||||
period: '2023-12-31',
|
||||
performance: {
|
||||
revenue: 40850000000, // 408.5亿
|
||||
net_profit: 13210000000 // 132.1亿
|
||||
}
|
||||
},
|
||||
{
|
||||
period: '2023-09-30',
|
||||
performance: {
|
||||
revenue: 38500000000, // 385亿
|
||||
net_profit: 11920000000 // 119.2亿
|
||||
}
|
||||
},
|
||||
{
|
||||
period: '2023-06-30',
|
||||
performance: {
|
||||
revenue: 37800000000, // 378亿
|
||||
net_profit: 11850000000 // 118.5亿
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
// src/mocks/handlers/bytedesk.js
|
||||
/**
|
||||
* Bytedesk 客服 Widget MSW Handler
|
||||
* 使用 passthrough 让请求通过到真实服务器,消除 MSW 警告
|
||||
* Mock 模式下返回模拟数据
|
||||
*/
|
||||
|
||||
import { http, passthrough } from 'msw';
|
||||
import { http, HttpResponse, passthrough } from 'msw';
|
||||
|
||||
export const bytedeskHandlers = [
|
||||
// Bytedesk API 请求 - 直接 passthrough
|
||||
// 匹配 /bytedesk/* 路径(通过代理访问后端)
|
||||
// 未读消息数量
|
||||
http.get('/bytedesk/visitor/api/v1/message/unread/count', () => {
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: { count: 0 },
|
||||
});
|
||||
}),
|
||||
|
||||
// 其他 Bytedesk API - 返回通用成功响应
|
||||
http.all('/bytedesk/*', () => {
|
||||
return passthrough();
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: null,
|
||||
});
|
||||
}),
|
||||
|
||||
// Bytedesk 外部 CDN/服务请求
|
||||
|
||||
@@ -119,9 +119,12 @@ export const eventHandlers = [
|
||||
try {
|
||||
const result = generateMockEvents(params);
|
||||
|
||||
// 返回格式兼容 NewsPanel 期望的结构
|
||||
// NewsPanel 期望: { success, data: [], pagination: {} }
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
data: result.events, // 事件数组
|
||||
pagination: result.pagination, // 分页信息
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -135,16 +138,14 @@ export const eventHandlers = [
|
||||
{
|
||||
success: false,
|
||||
error: '获取事件列表失败',
|
||||
data: {
|
||||
events: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
total: 0,
|
||||
pages: 0, // ← 对齐后端字段名
|
||||
has_prev: false, // ← 对齐后端
|
||||
has_next: false // ← 对齐后端
|
||||
}
|
||||
data: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
total: 0,
|
||||
pages: 0,
|
||||
has_prev: false,
|
||||
has_next: false
|
||||
}
|
||||
},
|
||||
{ status: 500 }
|
||||
|
||||
@@ -341,6 +341,68 @@ export const stockHandlers = [
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取股票业绩预告
|
||||
http.get('/api/stock/:stockCode/forecast', async ({ params }) => {
|
||||
await delay(200);
|
||||
|
||||
const { stockCode } = params;
|
||||
console.log('[Mock Stock] 获取业绩预告:', { stockCode });
|
||||
|
||||
// 生成股票列表用于查找名称
|
||||
const stockList = generateStockList();
|
||||
const stockInfo = stockList.find(s => s.code === stockCode.replace(/\.(SH|SZ)$/i, ''));
|
||||
const stockName = stockInfo?.name || `股票${stockCode}`;
|
||||
|
||||
// 业绩预告类型列表
|
||||
const forecastTypes = ['预增', '预减', '略增', '略减', '扭亏', '续亏', '首亏', '续盈'];
|
||||
|
||||
// 生成业绩预告数据
|
||||
const forecasts = [
|
||||
{
|
||||
forecast_type: '预增',
|
||||
report_date: '2024年年报',
|
||||
content: `${stockName}预计2024年度归属于上市公司股东的净利润为58亿元至62亿元,同比增长10%至17%。`,
|
||||
reason: '报告期内,公司主营业务收入稳步增长,产品结构持续优化,毛利率提升;同时公司加大研发投入,新产品市场表现良好。',
|
||||
change_range: {
|
||||
lower: 10,
|
||||
upper: 17
|
||||
},
|
||||
publish_date: '2024-10-15'
|
||||
},
|
||||
{
|
||||
forecast_type: '略增',
|
||||
report_date: '2024年三季报',
|
||||
content: `${stockName}预计2024年1-9月归属于上市公司股东的净利润为42亿元至45亿元,同比增长5%至12%。`,
|
||||
reason: '公司积极拓展市场渠道,销售规模持续扩大,经营效益稳步提升。',
|
||||
change_range: {
|
||||
lower: 5,
|
||||
upper: 12
|
||||
},
|
||||
publish_date: '2024-07-12'
|
||||
},
|
||||
{
|
||||
forecast_type: forecastTypes[Math.floor(Math.random() * forecastTypes.length)],
|
||||
report_date: '2024年中报',
|
||||
content: `${stockName}预计2024年上半年归属于上市公司股东的净利润为28亿元至30亿元。`,
|
||||
reason: '受益于行业景气度回升及公司降本增效措施效果显现,经营业绩同比有所改善。',
|
||||
change_range: {
|
||||
lower: 3,
|
||||
upper: 8
|
||||
},
|
||||
publish_date: '2024-04-20'
|
||||
}
|
||||
];
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
stock_code: stockCode,
|
||||
stock_name: stockName,
|
||||
forecasts: forecasts
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
// 获取股票报价(批量)
|
||||
http.post('/api/stock/quotes', async ({ request }) => {
|
||||
await delay(200);
|
||||
@@ -421,7 +483,19 @@ export const stockHandlers = [
|
||||
// 行业和指数标签
|
||||
industry_l1: industryInfo.industry_l1,
|
||||
industry: industryInfo.industry,
|
||||
index_tags: industryInfo.index_tags || []
|
||||
index_tags: industryInfo.index_tags || [],
|
||||
// 关键指标
|
||||
pe: parseFloat((Math.random() * 50 + 5).toFixed(2)),
|
||||
eps: parseFloat((Math.random() * 5 + 0.1).toFixed(3)),
|
||||
pb: parseFloat((Math.random() * 8 + 0.5).toFixed(2)),
|
||||
market_cap: `${(Math.random() * 5000 + 100).toFixed(0)}亿`,
|
||||
week52_low: parseFloat((basePrice * 0.7).toFixed(2)),
|
||||
week52_high: parseFloat((basePrice * 1.3).toFixed(2)),
|
||||
// 主力动态
|
||||
main_net_inflow: parseFloat((Math.random() * 10 - 5).toFixed(2)),
|
||||
institution_holding: parseFloat((Math.random() * 50 + 10).toFixed(2)),
|
||||
buy_ratio: parseFloat((Math.random() * 40 + 30).toFixed(2)),
|
||||
sell_ratio: parseFloat((100 - (Math.random() * 40 + 30)).toFixed(2))
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 性能监控工具 - 统计白屏时间和性能指标
|
||||
|
||||
import { logger } from './logger';
|
||||
import { reportPerformanceMetrics } from '../lib/posthog';
|
||||
|
||||
/**
|
||||
* 性能指标接口
|
||||
@@ -208,6 +209,9 @@ class PerformanceMonitor {
|
||||
// 性能分析建议
|
||||
this.analyzePerformance();
|
||||
|
||||
// 上报性能指标到 PostHog
|
||||
reportPerformanceMetrics(this.metrics);
|
||||
|
||||
return this.metrics;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Company 目录结构说明
|
||||
|
||||
> 最后更新:2025-12-16
|
||||
> 最后更新:2025-12-17(API 接口清单梳理)
|
||||
|
||||
## 目录结构
|
||||
|
||||
@@ -11,24 +11,40 @@ src/views/Company/
|
||||
│
|
||||
├── components/ # UI 组件
|
||||
│ │
|
||||
│ ├── LoadingState.tsx # 通用加载状态组件
|
||||
│ │
|
||||
│ ├── CompanyHeader/ # 页面头部
|
||||
│ │ ├── index.js # 组合导出
|
||||
│ │ └── SearchBar.js # 股票搜索栏
|
||||
│ │
|
||||
│ ├── CompanyTabs/ # Tab 切换容器
|
||||
│ │ ├── index.js # Tab 容器(状态管理 + 内容渲染)
|
||||
│ │ └── TabNavigation.js # Tab 导航栏
|
||||
│ │ └── index.js # Tab 容器(状态管理 + 内容渲染)
|
||||
│ │
|
||||
│ ├── StockQuoteCard/ # 股票行情卡片(TypeScript)
|
||||
│ │ ├── index.tsx # 主组件
|
||||
│ ├── StockQuoteCard/ # 股票行情卡片(TypeScript,数据已下沉)
|
||||
│ │ ├── index.tsx # 主组件(Props 从 11 个精简为 4 个)
|
||||
│ │ ├── 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)
|
||||
│ │ ├── index.tsx # 主组件(组合层)
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ ├── utils.ts # 格式化工具
|
||||
│ │ ├── DeepAnalysisTab/ # 深度分析 Tab(21 个 TS 文件)
|
||||
│ │ ├── NewsEventsTab.js # 新闻事件 Tab
|
||||
│ │ │
|
||||
│ │ ├── hooks/ # 数据 Hooks
|
||||
@@ -47,29 +63,69 @@ src/views/Company/
|
||||
│ │ │ ├── ConcentrationCard.tsx # 股权集中度卡片
|
||||
│ │ │ └── ShareholdersTable.tsx # 股东表格
|
||||
│ │ │
|
||||
│ │ └── BasicInfoTab/ # 基本信息 Tab(可配置化)
|
||||
│ │ ├── index.tsx # 主组件(可配置)
|
||||
│ │ ├── config.ts # Tab 配置 + 黑金主题
|
||||
│ │ ├── utils.ts # 格式化工具函数
|
||||
│ │ └── components/ # 子组件
|
||||
│ │ ├── index.ts # 组件统一导出
|
||||
│ │ ├── LoadingState.tsx # 加载状态组件
|
||||
│ │ ├── ShareholderPanel.tsx # 股权结构面板
|
||||
│ │ ├── AnnouncementsPanel.tsx # 公告信息面板
|
||||
│ │ ├── BranchesPanel.tsx # 分支机构面板
|
||||
│ │ ├── BusinessInfoPanel.tsx # 工商信息面板
|
||||
│ │ ├── DisclosureSchedulePanel.tsx # 披露日程面板
|
||||
│ │ └── management/ # 管理团队模块
|
||||
│ │ ├── index.ts # 模块导出
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ ├── ManagementPanel.tsx # 主组件(useMemo)
|
||||
│ │ ├── CategorySection.tsx # 分类区块(memo)
|
||||
│ │ └── ManagementCard.tsx # 人员卡片(memo)
|
||||
│ │ ├── BasicInfoTab/ # 基本信息 Tab(可配置化)
|
||||
│ │ │ ├── index.tsx # 主组件(可配置)
|
||||
│ │ │ ├── config.ts # Tab 配置 + 黑金主题
|
||||
│ │ │ ├── utils.ts # 格式化工具函数
|
||||
│ │ │ └── components/ # 子组件
|
||||
│ │ │ ├── index.ts # 组件统一导出
|
||||
│ │ │ ├── LoadingState.tsx # 加载状态组件
|
||||
│ │ │ ├── ShareholderPanel.tsx # 股权结构面板
|
||||
│ │ │ ├── AnnouncementsPanel.tsx # 公告信息面板
|
||||
│ │ │ ├── BranchesPanel.tsx # 分支机构面板
|
||||
│ │ │ ├── BusinessInfoPanel.tsx # 工商信息面板
|
||||
│ │ │ ├── DisclosureSchedulePanel.tsx # 披露日程面板
|
||||
│ │ │ └── management/ # 管理团队模块
|
||||
│ │ │ ├── index.ts # 模块导出
|
||||
│ │ │ ├── types.ts # 类型定义
|
||||
│ │ │ ├── ManagementPanel.tsx # 主组件(useMemo)
|
||||
│ │ │ ├── CategorySection.tsx # 分类区块(memo)
|
||||
│ │ │ └── ManagementCard.tsx # 人员卡片(memo)
|
||||
│ │ │
|
||||
│ │ └── DeepAnalysisTab/ # 深度分析 Tab(原子设计模式)
|
||||
│ │ ├── index.tsx # 主入口组件
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ ├── atoms/ # 原子组件
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── DisclaimerBox.tsx # 免责声明
|
||||
│ │ │ ├── ScoreBar.tsx # 评分进度条
|
||||
│ │ │ ├── BusinessTreeItem.tsx # 业务树形项
|
||||
│ │ │ ├── KeyFactorCard.tsx # 关键因素卡片
|
||||
│ │ │ ├── ProcessNavigation.tsx # 流程导航
|
||||
│ │ │ └── ValueChainFilterBar.tsx # 产业链筛选栏
|
||||
│ │ ├── components/ # Card 组件
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── CorePositioningCard/ # 核心定位卡片(含 atoms)
|
||||
│ │ │ │ ├── index.tsx
|
||||
│ │ │ │ ├── theme.ts
|
||||
│ │ │ │ └── atoms/
|
||||
│ │ │ ├── CompetitiveAnalysisCard.tsx
|
||||
│ │ │ ├── BusinessStructureCard.tsx
|
||||
│ │ │ ├── BusinessSegmentsCard.tsx
|
||||
│ │ │ ├── ValueChainCard.tsx
|
||||
│ │ │ ├── KeyFactorsCard.tsx
|
||||
│ │ │ ├── TimelineCard.tsx
|
||||
│ │ │ └── StrategyAnalysisCard.tsx
|
||||
│ │ ├── organisms/ # 复杂交互组件
|
||||
│ │ │ ├── ValueChainNodeCard/
|
||||
│ │ │ │ ├── index.tsx
|
||||
│ │ │ │ └── RelatedCompaniesModal.tsx
|
||||
│ │ │ └── TimelineComponent/
|
||||
│ │ │ ├── index.tsx
|
||||
│ │ │ └── EventDetailModal.tsx
|
||||
│ │ ├── tabs/ # Tab 面板
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── BusinessTab.tsx
|
||||
│ │ │ ├── DevelopmentTab.tsx
|
||||
│ │ │ ├── StrategyTab.tsx
|
||||
│ │ │ └── ValueChainTab.tsx
|
||||
│ │ └── utils/
|
||||
│ │ └── chartOptions.ts
|
||||
│ │
|
||||
│ ├── MarketDataView/ # Tab: 股票行情(TypeScript)
|
||||
│ │ ├── index.tsx # 主组件入口(~285 行,Tab 容器)
|
||||
│ │ ├── index.tsx # 主组件入口
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ ├── constants.ts # 主题配置、常量(含黑金主题 darkGoldTheme)
|
||||
│ │ ├── constants.ts # 主题配置(含黑金主题 darkGoldTheme)
|
||||
│ │ ├── services/
|
||||
│ │ │ └── marketService.ts # API 服务层
|
||||
│ │ ├── hooks/
|
||||
@@ -83,71 +139,90 @@ src/views/Company/
|
||||
│ │ ├── MarkdownRenderer.tsx # Markdown 渲染
|
||||
│ │ ├── AnalysisModal.tsx # 涨幅分析模态框
|
||||
│ │ ├── StockSummaryCard/ # 股票概览卡片(黑金主题 4 列布局)
|
||||
│ │ │ ├── index.tsx # 主组件(4 列 SimpleGrid 布局)
|
||||
│ │ │ ├── StockHeaderCard.tsx # 股票信息卡片(名称、价格、涨跌幅)
|
||||
│ │ │ ├── MetricCard.tsx # 指标卡片模板
|
||||
│ │ │ ├── utils.ts # 状态计算工具函数
|
||||
│ │ │ └── atoms/ # 原子组件
|
||||
│ │ │ ├── index.ts # 原子组件导出
|
||||
│ │ │ ├── DarkGoldCard.tsx # 黑金主题卡片容器
|
||||
│ │ │ ├── CardTitle.tsx # 卡片标题(图标+标题+副标题)
|
||||
│ │ │ ├── MetricValue.tsx # 核心数值展示
|
||||
│ │ │ ├── PriceDisplay.tsx # 价格显示(价格+涨跌箭头)
|
||||
│ │ │ └── StatusTag.tsx # 状态标签(活跃/健康等)
|
||||
│ │ │ ├── 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 # 主入口组件(~50 行)
|
||||
│ │ │ ├── KLineChart.tsx # 日K线图组件(~40 行)
|
||||
│ │ │ ├── MinuteKLineSection.tsx # 分钟K线区域(~95 行)
|
||||
│ │ │ ├── TradeTable.tsx # 交易明细表格(~75 行)
|
||||
│ │ │ └── atoms/ # 原子组件
|
||||
│ │ │ ├── index.ts # 统一导出
|
||||
│ │ │ ├── MinuteStats.tsx # 分钟数据统计(~80 行)
|
||||
│ │ │ ├── TradeAnalysis.tsx # 成交分析(~65 行)
|
||||
│ │ │ └── EmptyState.tsx # 空状态组件(~35 行)
|
||||
│ │ ├── FundingPanel.tsx # 融资融券面板
|
||||
│ │ ├── BigDealPanel.tsx # 大宗交易面板
|
||||
│ │ ├── UnusualPanel.tsx # 龙虎榜面板
|
||||
│ │ └── PledgePanel.tsx # 股权质押面板
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── TradeDataPanel/
|
||||
│ │ │ ├── index.tsx
|
||||
│ │ │ └── KLineModule.tsx
|
||||
│ │ ├── FundingPanel.tsx
|
||||
│ │ ├── BigDealPanel.tsx
|
||||
│ │ ├── UnusualPanel.tsx
|
||||
│ │ └── PledgePanel.tsx
|
||||
│ │
|
||||
│ ├── DeepAnalysis/ # Tab: 深度分析
|
||||
│ ├── DeepAnalysis/ # Tab: 深度分析(入口)
|
||||
│ │ └── index.js
|
||||
│ │
|
||||
│ ├── DynamicTracking/ # Tab: 动态跟踪
|
||||
│ │ └── index.js
|
||||
│ │ ├── index.js # 主组件
|
||||
│ │ └── components/
|
||||
│ │ ├── index.js # 组件导出
|
||||
│ │ ├── NewsPanel.js # 新闻面板
|
||||
│ │ └── ForecastPanel.js # 业绩预告面板
|
||||
│ │
|
||||
│ ├── FinancialPanorama/ # Tab: 财务全景(TypeScript 模块化)
|
||||
│ │ ├── index.tsx # 主组件入口(~400 行)
|
||||
│ │ ├── index.tsx # 主组件入口
|
||||
│ │ ├── types.ts # TypeScript 类型定义
|
||||
│ │ ├── constants.ts # 常量配置(颜色、指标定义)
|
||||
│ │ ├── hooks/
|
||||
│ │ │ ├── index.ts # Hook 统一导出
|
||||
│ │ │ └── useFinancialData.ts # 财务数据加载 Hook
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ └── useFinancialData.ts
|
||||
│ │ ├── utils/
|
||||
│ │ │ ├── index.ts # 工具函数统一导出
|
||||
│ │ │ ├── calculations.ts # 计算工具(同比变化、单元格颜色)
|
||||
│ │ │ └── chartOptions.ts # ECharts 图表配置生成器
|
||||
│ │ │ ├── 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 # 股票信息头部
|
||||
│ │ ├── BalanceSheetTable.tsx # 资产负债表
|
||||
│ │ ├── IncomeStatementTable.tsx # 利润表
|
||||
│ │ ├── CashflowTable.tsx # 现金流量表
|
||||
│ │ ├── FinancialMetricsTable.tsx # 财务指标表
|
||||
│ │ ├── MainBusinessAnalysis.tsx # 主营业务分析
|
||||
│ │ ├── IndustryRankingView.tsx # 行业排名
|
||||
│ │ ├── StockComparison.tsx # 股票对比
|
||||
│ │ └── ComparisonAnalysis.tsx # 综合对比分析
|
||||
│ │ ├── 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: 盈利预测(待拆分)
|
||||
│ └── index.js
|
||||
│ └── ForecastReport/ # Tab: 盈利预测(TypeScript,已模块化)
|
||||
│ ├── index.tsx # 主组件入口
|
||||
│ ├── types.ts # 类型定义
|
||||
│ ├── constants.ts # 配色、图表配置常量
|
||||
│ └── components/
|
||||
│ ├── index.ts
|
||||
│ ├── ChartCard.tsx # 图表卡片容器
|
||||
│ ├── IncomeProfitGrowthChart.tsx # 营收与利润趋势图
|
||||
│ ├── IncomeProfitChart.tsx # 营收利润图(备用)
|
||||
│ ├── GrowthChart.tsx # 增长率图(备用)
|
||||
│ ├── EpsChart.tsx # EPS 趋势图
|
||||
│ ├── PePegChart.tsx # PE/PEG 分析图
|
||||
│ └── DetailTable.tsx # 详细数据表格
|
||||
│
|
||||
├── hooks/ # 页面级 Hooks
|
||||
│ ├── useCompanyStock.js # 股票代码管理(URL 同步)
|
||||
│ ├── useCompanyWatchlist.js # 自选股管理(Redux 集成)
|
||||
│ ├── useCompanyEvents.js # PostHog 事件追踪
|
||||
│ └── useStockQuote.js # 股票行情数据 Hook
|
||||
│ └── useCompanyEvents.js # PostHog 事件追踪
|
||||
│ # 注:useStockQuote.js 已下沉到 StockQuoteCard/hooks/useStockQuoteData.ts
|
||||
│
|
||||
└── constants/ # 常量定义
|
||||
└── index.js # Tab 配置、Toast 消息、默认值
|
||||
@@ -155,19 +230,101 @@ src/views/Company/
|
||||
|
||||
---
|
||||
|
||||
## API 接口清单
|
||||
|
||||
Company 模块共使用 **27 个** API 接口(去重后)。
|
||||
|
||||
### 一、股票基础信息 (8 个)
|
||||
|
||||
| 接口 | 方法 | 调用位置 |
|
||||
|------|------|----------|
|
||||
| `/api/stock/${stockCode}/basic-info` | GET | useBasicInfo.ts, useStockQuoteData.ts, NewsPanel.js |
|
||||
| `/api/stock/${stockCode}/branches` | GET | useBranchesData.ts |
|
||||
| `/api/stock/${stockCode}/management?active_only=true` | GET | useManagementData.ts |
|
||||
| `/api/stock/${stockCode}/announcements?limit=20` | GET | useAnnouncementsData.ts |
|
||||
| `/api/stock/${stockCode}/disclosure-schedule` | GET | useDisclosureData.ts |
|
||||
| `/api/stock/${stockCode}/forecast` | GET | ForecastPanel.js |
|
||||
| `/api/stock/${stockCode}/forecast-report` | GET | ForecastReport/index.tsx |
|
||||
| `/api/stock/${stockCode}/latest-minute` | GET | marketService.ts |
|
||||
|
||||
### 二、股东信息 (4 个)
|
||||
|
||||
| 接口 | 方法 | 调用位置 |
|
||||
|------|------|----------|
|
||||
| `/api/stock/${stockCode}/actual-control` | GET | useShareholderData.ts |
|
||||
| `/api/stock/${stockCode}/concentration` | GET | useShareholderData.ts |
|
||||
| `/api/stock/${stockCode}/top-shareholders?limit=10` | GET | useShareholderData.ts |
|
||||
| `/api/stock/${stockCode}/top-circulation-shareholders?limit=10` | GET | useShareholderData.ts |
|
||||
|
||||
### 三、行情数据 (8 个)
|
||||
|
||||
| 接口 | 方法 | 调用位置 |
|
||||
|------|------|----------|
|
||||
| `/api/stock/quotes` | POST | stockService.getQuotes |
|
||||
| `/api/market/summary/${stockCode}` | GET | marketService.ts |
|
||||
| `/api/market/trade/${stockCode}?days=${days}` | GET | marketService.ts |
|
||||
| `/api/market/funding/${stockCode}?days=${days}` | GET | marketService.ts |
|
||||
| `/api/market/bigdeal/${stockCode}?days=${days}` | GET | marketService.ts |
|
||||
| `/api/market/unusual/${stockCode}?days=${days}` | GET | marketService.ts |
|
||||
| `/api/market/pledge/${stockCode}` | GET | marketService.ts |
|
||||
| `/api/market/rise-analysis/${stockCode}` | GET | marketService.ts |
|
||||
|
||||
### 四、深度分析 (5 个)
|
||||
|
||||
| 接口 | 方法 | 调用位置 |
|
||||
|------|------|----------|
|
||||
| `/api/company/comprehensive-analysis/${stockCode}` | GET | DeepAnalysis/index.js |
|
||||
| `/api/company/value-chain-analysis/${stockCode}` | GET | DeepAnalysis/index.js |
|
||||
| `/api/company/key-factors-timeline/${stockCode}` | GET | DeepAnalysis/index.js |
|
||||
| `/api/company/value-chain/related-companies?node_name=...` | GET | ValueChainNodeCard/index.tsx |
|
||||
| `/api/financial/industry-rank/${stockCode}` | GET | DeepAnalysis/index.js |
|
||||
|
||||
### 五、财务数据 (1 个)
|
||||
|
||||
| 接口 | 方法 | 调用位置 |
|
||||
|------|------|----------|
|
||||
| `/api/financial/financial-metrics/${stockCode}?limit=${limit}` | GET | financialService.getFinancialMetrics |
|
||||
|
||||
### 六、事件/新闻 (1 个)
|
||||
|
||||
| 接口 | 方法 | 调用位置 |
|
||||
|------|------|----------|
|
||||
| `/api/events?q=${searchTerm}&page=${page}&per_page=10` | GET | NewsPanel.js |
|
||||
|
||||
### 统计汇总
|
||||
|
||||
| 分类 | 数量 |
|
||||
|------|------|
|
||||
| 股票基础信息 | 8 |
|
||||
| 股东信息 | 4 |
|
||||
| 行情数据 | 8 |
|
||||
| 深度分析 | 5 |
|
||||
| 财务数据 | 1 |
|
||||
| 事件/新闻 | 1 |
|
||||
| **去重后总计** | **27** |
|
||||
|
||||
> 注:`/api/stock/${stockCode}/basic-info` 在 3 处调用,但只算 1 个接口。
|
||||
|
||||
---
|
||||
|
||||
## 文件职责说明
|
||||
|
||||
### 入口文件
|
||||
|
||||
#### `index.js` - 页面入口
|
||||
- **职责**:纯组合层,协调 Hooks 和 Components
|
||||
- **代码行数**:95 行
|
||||
- **代码行数**:~105 行(2025-12-17 优化后精简)
|
||||
- **依赖**:
|
||||
- `useCompanyStock` - 股票代码状态
|
||||
- `useCompanyWatchlist` - 自选股状态
|
||||
- `useCompanyEvents` - 事件追踪
|
||||
- `CompanyHeader` - 页面头部
|
||||
- `StockQuoteCard` - 股票行情卡片(内部自行获取数据)
|
||||
- `CompanyTabs` - Tab 切换区
|
||||
- **已移除**(2025-12-17):
|
||||
- `useStockQuote` - 已下沉到 StockQuoteCard
|
||||
- `useBasicInfo` - 已下沉到 StockQuoteCard
|
||||
- 股票对比逻辑 - 已下沉到 StockQuoteCard
|
||||
|
||||
---
|
||||
|
||||
@@ -999,4 +1156,98 @@ index.tsx
|
||||
- **原子设计模式**:atoms(MinuteStats、TradeAnalysis、EmptyState)→ 业务组件(KLineChart、MinuteKLineSection、TradeTable)→ 主组件
|
||||
- **职责分离**:图表、统计、表格各自独立
|
||||
- **组件复用**:EmptyState 可在其他场景复用
|
||||
- **类型安全**:完整的 Props 类型定义和导出
|
||||
- **类型安全**:完整的 Props 类型定义和导出
|
||||
|
||||
### 2025-12-17 StockQuoteCard 数据下沉优化
|
||||
|
||||
**改动概述**:
|
||||
- StockQuoteCard Props 从 **11 个** 精简至 **4 个**(减少 64%)
|
||||
- 行情数据、基本信息、股票对比逻辑全部下沉到组件内部
|
||||
- Company/index.js 移除约 **40 行** 数据获取代码
|
||||
- 删除 `Company/hooks/useStockQuote.js`
|
||||
|
||||
**创建的文件**:
|
||||
```
|
||||
StockQuoteCard/hooks/
|
||||
├── index.ts # hooks 导出索引
|
||||
├── useStockQuoteData.ts # 行情数据 + 基本信息获取(~152 行)
|
||||
└── useStockCompare.ts # 股票对比逻辑(~91 行)
|
||||
```
|
||||
|
||||
**Props 对比**:
|
||||
|
||||
**优化前(11 个 Props)**:
|
||||
```tsx
|
||||
<StockQuoteCard
|
||||
data={quoteData}
|
||||
isLoading={isQuoteLoading}
|
||||
basicInfo={basicInfo}
|
||||
currentStockInfo={currentStockInfo}
|
||||
compareStockInfo={compareStockInfo}
|
||||
isCompareLoading={isCompareLoading}
|
||||
onCompare={handleCompare}
|
||||
onCloseCompare={handleCloseCompare}
|
||||
isInWatchlist={isInWatchlist}
|
||||
isWatchlistLoading={isWatchlistLoading}
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
/>
|
||||
```
|
||||
|
||||
**优化后(4 个 Props)**:
|
||||
```tsx
|
||||
<StockQuoteCard
|
||||
stockCode={stockCode}
|
||||
isInWatchlist={isInWatchlist}
|
||||
isWatchlistLoading={isWatchlistLoading}
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
/>
|
||||
```
|
||||
|
||||
**Hook 返回值**:
|
||||
|
||||
`useStockQuoteData(stockCode)`:
|
||||
```typescript
|
||||
{
|
||||
quoteData: StockQuoteCardData | null; // 行情数据
|
||||
basicInfo: BasicInfo | null; // 基本信息
|
||||
isLoading: boolean; // 加载状态
|
||||
error: string | null; // 错误信息
|
||||
refetch: () => void; // 手动刷新
|
||||
}
|
||||
```
|
||||
|
||||
`useStockCompare(stockCode)`:
|
||||
```typescript
|
||||
{
|
||||
currentStockInfo: StockInfo | null; // 当前股票财务信息
|
||||
compareStockInfo: StockInfo | null; // 对比股票财务信息
|
||||
isCompareLoading: boolean; // 对比数据加载中
|
||||
handleCompare: (code: string) => Promise<void>; // 触发对比
|
||||
clearCompare: () => void; // 清除对比
|
||||
}
|
||||
```
|
||||
|
||||
**修改的文件**:
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `StockQuoteCard/hooks/useStockQuoteData.ts` | 新建 | 合并行情+基本信息获取 |
|
||||
| `StockQuoteCard/hooks/useStockCompare.ts` | 新建 | 股票对比逻辑 |
|
||||
| `StockQuoteCard/hooks/index.ts` | 新建 | hooks 导出索引 |
|
||||
| `StockQuoteCard/index.tsx` | 修改 | 使用内部 hooks,减少 props |
|
||||
| `StockQuoteCard/types.ts` | 修改 | Props 从 11 个精简为 4 个 |
|
||||
| `Company/index.js` | 修改 | 移除下沉的数据获取逻辑 |
|
||||
| `Company/hooks/useStockQuote.js` | 删除 | 已移到 StockQuoteCard |
|
||||
|
||||
**优化收益**:
|
||||
| 指标 | 优化前 | 优化后 | 改善 |
|
||||
|------|--------|--------|------|
|
||||
| Props 数量 | 11 | 4 | -64% |
|
||||
| Company/index.js 行数 | ~172 | ~105 | -39% |
|
||||
| 数据获取位置 | 页面层 | 组件内部 | 就近原则 |
|
||||
| 可复用性 | 依赖父组件 | 独立可用 | 提升 |
|
||||
|
||||
**设计原则**:
|
||||
- **数据就近获取**:组件自己获取自己需要的数据
|
||||
- **Props 最小化**:只传递真正需要外部控制的状态
|
||||
- **职责清晰**:自选股状态保留在页面层(涉及 Redux 和事件追踪)
|
||||
- **可复用性**:StockQuoteCard 可独立在其他页面使用
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { SearchIcon } from '@chakra-ui/icons';
|
||||
import { useStockSearch } from '../../hooks/useStockSearch';
|
||||
|
||||
/**
|
||||
* 股票搜索栏组件(带模糊搜索下拉)
|
||||
@@ -31,27 +32,18 @@ const SearchBar = ({
|
||||
}) => {
|
||||
// 下拉状态
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [filteredStocks, setFilteredStocks] = useState([]);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
// 从 Redux 获取全部股票列表
|
||||
const allStocks = useSelector(state => state.stock.allStocks);
|
||||
|
||||
// 模糊搜索过滤
|
||||
// 使用共享的搜索 Hook
|
||||
const filteredStocks = useStockSearch(allStocks, inputCode, { limit: 10 });
|
||||
|
||||
// 根据搜索结果更新下拉显示状态
|
||||
useEffect(() => {
|
||||
if (inputCode && inputCode.trim()) {
|
||||
const searchTerm = inputCode.trim().toLowerCase();
|
||||
const filtered = allStocks.filter(stock =>
|
||||
stock.code.toLowerCase().includes(searchTerm) ||
|
||||
stock.name.includes(inputCode.trim())
|
||||
).slice(0, 10); // 限制显示10条
|
||||
setFilteredStocks(filtered);
|
||||
setShowDropdown(filtered.length > 0);
|
||||
} else {
|
||||
setFilteredStocks([]);
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}, [inputCode, allStocks]);
|
||||
setShowDropdown(filteredStocks.length > 0 && !!inputCode?.trim());
|
||||
}, [filteredStocks, inputCode]);
|
||||
|
||||
// 点击外部关闭下拉
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
Card,
|
||||
CardBody,
|
||||
IconButton,
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { FaBullhorn } from "react-icons/fa";
|
||||
import { ExternalLinkIcon } from "@chakra-ui/icons";
|
||||
|
||||
import { useAnnouncementsData } from "../../hooks/useAnnouncementsData";
|
||||
@@ -55,10 +53,6 @@ const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode }) =>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 最新公告 */}
|
||||
<Box>
|
||||
<HStack mb={3}>
|
||||
<Icon as={FaBullhorn} color={THEME.gold} />
|
||||
<Text fontWeight="bold" color={THEME.textPrimary}>最新公告</Text>
|
||||
</HStack>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{announcements.map((announcement: any, idx: number) => (
|
||||
<Card
|
||||
|
||||
@@ -12,15 +12,27 @@ import {
|
||||
Divider,
|
||||
Center,
|
||||
Code,
|
||||
Spinner,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
import { THEME } from "../config";
|
||||
import { useBasicInfo } from "../../hooks/useBasicInfo";
|
||||
|
||||
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) {
|
||||
return (
|
||||
<Center h="200px">
|
||||
|
||||
@@ -5,15 +5,12 @@ import React from "react";
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
Card,
|
||||
CardBody,
|
||||
SimpleGrid,
|
||||
} from "@chakra-ui/react";
|
||||
import { FaCalendarAlt } from "react-icons/fa";
|
||||
|
||||
import { useDisclosureData } from "../../hooks/useDisclosureData";
|
||||
import { THEME } from "../config";
|
||||
@@ -42,10 +39,6 @@ const DisclosureSchedulePanel: React.FC<DisclosureSchedulePanelProps> = ({ stock
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Box>
|
||||
<HStack mb={3}>
|
||||
<Icon as={FaCalendarAlt} color={THEME.gold} />
|
||||
<Text fontWeight="bold" color={THEME.textPrimary}>财报披露日程</Text>
|
||||
</HStack>
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
|
||||
{disclosureSchedule.map((schedule: any, idx: number) => (
|
||||
<Card
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
// Props 类型定义
|
||||
export interface BasicInfoTabProps {
|
||||
stockCode: string;
|
||||
basicInfo?: any;
|
||||
|
||||
// 可配置项
|
||||
enabledTabs?: string[]; // 指定显示哪些 Tab(通过 key)
|
||||
@@ -59,7 +58,6 @@ const buildTabsConfig = (enabledKeys?: string[]): SubTabConfig[] => {
|
||||
*/
|
||||
const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
|
||||
stockCode,
|
||||
basicInfo,
|
||||
enabledTabs,
|
||||
defaultTabIndex = 0,
|
||||
onTabChange,
|
||||
@@ -72,7 +70,7 @@ const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
|
||||
<CardBody p={0}>
|
||||
<SubTabContainer
|
||||
tabs={tabs}
|
||||
componentProps={{ stockCode, basicInfo }}
|
||||
componentProps={{ stockCode }}
|
||||
defaultIndex={defaultTabIndex}
|
||||
onTabChange={onTabChange}
|
||||
themePreset="blackGold"
|
||||
|
||||
@@ -234,10 +234,10 @@ const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{/* 主要竞争对手 */}
|
||||
{/* {competitors.length > 0 && <CompetitorTags competitors={competitors} />} */}
|
||||
{competitors.length > 0 && <CompetitorTags competitors={competitors} />}
|
||||
|
||||
{/* 评分和雷达图 */}
|
||||
{/* <Grid templateColumns="repeat(2, 1fr)" gap={6}>
|
||||
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
|
||||
<GridItem colSpan={GRID_COLSPAN}>
|
||||
<ScoreSection scores={competitivePosition.scores} />
|
||||
</GridItem>
|
||||
@@ -251,9 +251,9 @@ const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
|
||||
/>
|
||||
)}
|
||||
</GridItem>
|
||||
</Grid> */}
|
||||
</Grid>
|
||||
|
||||
{/* <Divider my={4} borderColor="yellow.600" /> */}
|
||||
<Divider my={4} borderColor="yellow.600" />
|
||||
|
||||
{/* 竞争优势和劣势 */}
|
||||
<AdvantagesSection
|
||||
|
||||
@@ -155,24 +155,28 @@ const ValueChainCard: React.FC<ValueChainCardProps> = memo(({
|
||||
align="center"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
{/* 左侧:流程式导航 */}
|
||||
<ProcessNavigation
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
upstreamCount={upstreamNodes.length}
|
||||
coreCount={coreNodes.length}
|
||||
downstreamCount={downstreamNodes.length}
|
||||
/>
|
||||
{/* 左侧:流程式导航 - 仅在层级视图显示 */}
|
||||
{viewMode === 'hierarchy' && (
|
||||
<ProcessNavigation
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
upstreamCount={upstreamNodes.length}
|
||||
coreCount={coreNodes.length}
|
||||
downstreamCount={downstreamNodes.length}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 右侧:筛选与视图切换 */}
|
||||
<ValueChainFilterBar
|
||||
typeFilter={typeFilter}
|
||||
onTypeChange={setTypeFilter}
|
||||
importanceFilter={importanceFilter}
|
||||
onImportanceChange={setImportanceFilter}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
{/* 右侧:筛选与视图切换 - 始终靠右 */}
|
||||
<Box ml="auto">
|
||||
<ValueChainFilterBar
|
||||
typeFilter={typeFilter}
|
||||
onTypeChange={setTypeFilter}
|
||||
importanceFilter={importanceFilter}
|
||||
onImportanceChange={setImportanceFilter}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{/* 内容区域 */}
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
*/
|
||||
|
||||
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 SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
|
||||
import LoadingState from '../../LoadingState';
|
||||
import { StrategyTab, BusinessTab, ValueChainTab, DevelopmentTab } from './tabs';
|
||||
import type { DeepAnalysisTabProps, DeepAnalysisTabKey } from './types';
|
||||
|
||||
@@ -75,12 +76,7 @@ const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
|
||||
componentProps={{}}
|
||||
themePreset="blackGold"
|
||||
/>
|
||||
<Center h="200px">
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
<Text color="gray.400">加载数据中...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
<LoadingState message="加载数据中..." height="200px" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -32,12 +32,10 @@ import {
|
||||
FaStar,
|
||||
} from 'react-icons/fa';
|
||||
import { logger } from '@utils/logger';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
import axios from '@utils/axiosConfig';
|
||||
import RelatedCompaniesModal from './RelatedCompaniesModal';
|
||||
import type { ValueChainNodeCardProps, RelatedCompany } from '../../types';
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
// 黑金主题配置
|
||||
const THEME = {
|
||||
cardBg: 'gray.700',
|
||||
@@ -120,12 +118,11 @@ const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = memo(({
|
||||
const fetchRelatedCompanies = async () => {
|
||||
setLoadingRelated(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/company/value-chain/related-companies?node_name=${encodeURIComponent(
|
||||
const { data } = await axios.get(
|
||||
`/api/company/value-chain/related-companies?node_name=${encodeURIComponent(
|
||||
node.node_name
|
||||
)}`
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setRelatedCompanies(data.data || []);
|
||||
} else {
|
||||
|
||||
@@ -36,6 +36,58 @@ import {
|
||||
FaChevronRight,
|
||||
} from "react-icons/fa";
|
||||
|
||||
// 黑金主题配色
|
||||
const THEME_PRESETS = {
|
||||
blackGold: {
|
||||
bg: "#0A0E17",
|
||||
cardBg: "#1A1F2E",
|
||||
cardHoverBg: "#212633",
|
||||
cardBorder: "rgba(212, 175, 55, 0.2)",
|
||||
cardHoverBorder: "#D4AF37",
|
||||
textPrimary: "#E8E9ED",
|
||||
textSecondary: "#A0A4B8",
|
||||
textMuted: "#6B7280",
|
||||
gold: "#D4AF37",
|
||||
goldLight: "#FFD54F",
|
||||
inputBg: "#151922",
|
||||
inputBorder: "#2D3748",
|
||||
buttonBg: "#D4AF37",
|
||||
buttonText: "#0A0E17",
|
||||
buttonHoverBg: "#FFD54F",
|
||||
badgeS: { bg: "rgba(255, 195, 0, 0.2)", color: "#FFD54F" },
|
||||
badgeA: { bg: "rgba(249, 115, 22, 0.2)", color: "#FB923C" },
|
||||
badgeB: { bg: "rgba(59, 130, 246, 0.2)", color: "#60A5FA" },
|
||||
badgeC: { bg: "rgba(107, 114, 128, 0.2)", color: "#9CA3AF" },
|
||||
tagBg: "rgba(212, 175, 55, 0.15)",
|
||||
tagColor: "#D4AF37",
|
||||
spinnerColor: "#D4AF37",
|
||||
},
|
||||
default: {
|
||||
bg: "white",
|
||||
cardBg: "white",
|
||||
cardHoverBg: "gray.50",
|
||||
cardBorder: "gray.200",
|
||||
cardHoverBorder: "blue.300",
|
||||
textPrimary: "gray.800",
|
||||
textSecondary: "gray.600",
|
||||
textMuted: "gray.500",
|
||||
gold: "blue.500",
|
||||
goldLight: "blue.400",
|
||||
inputBg: "white",
|
||||
inputBorder: "gray.200",
|
||||
buttonBg: "blue.500",
|
||||
buttonText: "white",
|
||||
buttonHoverBg: "blue.600",
|
||||
badgeS: { bg: "red.100", color: "red.600" },
|
||||
badgeA: { bg: "orange.100", color: "orange.600" },
|
||||
badgeB: { bg: "yellow.100", color: "yellow.600" },
|
||||
badgeC: { bg: "green.100", color: "green.600" },
|
||||
tagBg: "cyan.50",
|
||||
tagColor: "cyan.600",
|
||||
spinnerColor: "blue.500",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 新闻动态 Tab 组件
|
||||
*
|
||||
@@ -48,6 +100,7 @@ import {
|
||||
* - onSearch: 搜索提交回调 () => void
|
||||
* - onPageChange: 分页回调 (page) => void
|
||||
* - cardBg: 卡片背景色
|
||||
* - themePreset: 主题预设 'blackGold' | 'default'
|
||||
*/
|
||||
const NewsEventsTab = ({
|
||||
newsEvents = [],
|
||||
@@ -65,7 +118,11 @@ const NewsEventsTab = ({
|
||||
onSearch,
|
||||
onPageChange,
|
||||
cardBg,
|
||||
themePreset = "default",
|
||||
}) => {
|
||||
// 获取主题配色
|
||||
const theme = THEME_PRESETS[themePreset] || THEME_PRESETS.default;
|
||||
const isBlackGold = themePreset === "blackGold";
|
||||
// 事件类型图标映射
|
||||
const getEventTypeIcon = (eventType) => {
|
||||
const iconMap = {
|
||||
@@ -80,15 +137,25 @@ const NewsEventsTab = ({
|
||||
return iconMap[eventType] || FaNewspaper;
|
||||
};
|
||||
|
||||
// 重要性颜色映射
|
||||
const getImportanceColor = (importance) => {
|
||||
// 重要性颜色映射 - 根据主题返回不同配色
|
||||
const getImportanceBadgeStyle = (importance) => {
|
||||
if (isBlackGold) {
|
||||
const styles = {
|
||||
S: theme.badgeS,
|
||||
A: theme.badgeA,
|
||||
B: theme.badgeB,
|
||||
C: theme.badgeC,
|
||||
};
|
||||
return styles[importance] || { bg: "rgba(107, 114, 128, 0.2)", color: "#9CA3AF" };
|
||||
}
|
||||
// 默认主题使用 colorScheme
|
||||
const colorMap = {
|
||||
S: "red",
|
||||
A: "orange",
|
||||
B: "yellow",
|
||||
C: "green",
|
||||
};
|
||||
return colorMap[importance] || "gray";
|
||||
return { colorScheme: colorMap[importance] || "gray" };
|
||||
};
|
||||
|
||||
// 处理搜索输入
|
||||
@@ -129,19 +196,26 @@ const NewsEventsTab = ({
|
||||
// 如果开始页大于1,显示省略号
|
||||
if (startPage > 1) {
|
||||
pageButtons.push(
|
||||
<Text key="start-ellipsis" fontSize="sm" color="gray.400">
|
||||
<Text key="start-ellipsis" fontSize="sm" color={theme.textMuted}>
|
||||
...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
const isActive = i === currentPage;
|
||||
pageButtons.push(
|
||||
<Button
|
||||
key={i}
|
||||
size="sm"
|
||||
variant={i === currentPage ? "solid" : "outline"}
|
||||
colorScheme={i === currentPage ? "blue" : "gray"}
|
||||
bg={isActive ? theme.buttonBg : (isBlackGold ? theme.inputBg : undefined)}
|
||||
color={isActive ? theme.buttonText : theme.textSecondary}
|
||||
borderColor={isActive ? theme.gold : theme.cardBorder}
|
||||
borderWidth="1px"
|
||||
_hover={{
|
||||
bg: isActive ? theme.buttonHoverBg : theme.cardHoverBg,
|
||||
borderColor: theme.gold
|
||||
}}
|
||||
onClick={() => handlePageChange(i)}
|
||||
isDisabled={newsLoading}
|
||||
>
|
||||
@@ -153,7 +227,7 @@ const NewsEventsTab = ({
|
||||
// 如果结束页小于总页数,显示省略号
|
||||
if (endPage < totalPages) {
|
||||
pageButtons.push(
|
||||
<Text key="end-ellipsis" fontSize="sm" color="gray.400">
|
||||
<Text key="end-ellipsis" fontSize="sm" color={theme.textMuted}>
|
||||
...
|
||||
</Text>
|
||||
);
|
||||
@@ -164,7 +238,7 @@ const NewsEventsTab = ({
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Card bg={cardBg} shadow="md">
|
||||
<Card bg={cardBg || theme.cardBg} shadow="md" borderColor={theme.cardBorder} borderWidth={isBlackGold ? "1px" : "0"}>
|
||||
<CardBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 搜索框和统计信息 */}
|
||||
@@ -172,17 +246,25 @@ const NewsEventsTab = ({
|
||||
<HStack flex={1} minW="300px">
|
||||
<InputGroup>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<SearchIcon color="gray.400" />
|
||||
<SearchIcon color={theme.textMuted} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="搜索相关新闻..."
|
||||
value={searchQuery}
|
||||
onChange={handleInputChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
bg={theme.inputBg}
|
||||
borderColor={theme.inputBorder}
|
||||
color={theme.textPrimary}
|
||||
_placeholder={{ color: theme.textMuted }}
|
||||
_hover={{ borderColor: theme.gold }}
|
||||
_focus={{ borderColor: theme.gold, boxShadow: `0 0 0 1px ${theme.gold}` }}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
bg={theme.buttonBg}
|
||||
color={theme.buttonText}
|
||||
_hover={{ bg: theme.buttonHoverBg }}
|
||||
onClick={handleSearchSubmit}
|
||||
isLoading={newsLoading}
|
||||
minW="80px"
|
||||
@@ -193,10 +275,10 @@ const NewsEventsTab = ({
|
||||
|
||||
{newsPagination.total > 0 && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaNewspaper} color="blue.500" />
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
<Icon as={FaNewspaper} color={theme.gold} />
|
||||
<Text fontSize="sm" color={theme.textSecondary}>
|
||||
共找到{" "}
|
||||
<Text as="span" fontWeight="bold" color="blue.600">
|
||||
<Text as="span" fontWeight="bold" color={theme.gold}>
|
||||
{newsPagination.total}
|
||||
</Text>{" "}
|
||||
条新闻
|
||||
@@ -211,15 +293,15 @@ const NewsEventsTab = ({
|
||||
{newsLoading ? (
|
||||
<Center h="400px">
|
||||
<VStack spacing={3}>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color="gray.600">正在加载新闻...</Text>
|
||||
<Spinner size="xl" color={theme.spinnerColor} thickness="4px" />
|
||||
<Text color={theme.textSecondary}>正在加载新闻...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : newsEvents.length > 0 ? (
|
||||
<>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{newsEvents.map((event, idx) => {
|
||||
const importanceColor = getImportanceColor(
|
||||
const importanceBadgeStyle = getImportanceBadgeStyle(
|
||||
event.importance
|
||||
);
|
||||
const eventTypeIcon = getEventTypeIcon(event.event_type);
|
||||
@@ -228,10 +310,12 @@ const NewsEventsTab = ({
|
||||
<Card
|
||||
key={event.id || idx}
|
||||
variant="outline"
|
||||
bg={theme.cardBg}
|
||||
borderColor={theme.cardBorder}
|
||||
_hover={{
|
||||
bg: "gray.50",
|
||||
bg: theme.cardHoverBg,
|
||||
shadow: "md",
|
||||
borderColor: "blue.300",
|
||||
borderColor: theme.cardHoverBorder,
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
@@ -243,13 +327,14 @@ const NewsEventsTab = ({
|
||||
<HStack>
|
||||
<Icon
|
||||
as={eventTypeIcon}
|
||||
color="blue.500"
|
||||
color={theme.gold}
|
||||
boxSize={5}
|
||||
/>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
fontSize="lg"
|
||||
lineHeight="1.3"
|
||||
color={theme.textPrimary}
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
@@ -259,22 +344,29 @@ const NewsEventsTab = ({
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{event.importance && (
|
||||
<Badge
|
||||
colorScheme={importanceColor}
|
||||
variant="solid"
|
||||
{...(isBlackGold ? {} : { colorScheme: importanceBadgeStyle.colorScheme, variant: "solid" })}
|
||||
bg={isBlackGold ? importanceBadgeStyle.bg : undefined}
|
||||
color={isBlackGold ? importanceBadgeStyle.color : undefined}
|
||||
px={2}
|
||||
>
|
||||
{event.importance}级
|
||||
</Badge>
|
||||
)}
|
||||
{event.event_type && (
|
||||
<Badge colorScheme="blue" variant="outline">
|
||||
<Badge
|
||||
{...(isBlackGold ? {} : { colorScheme: "blue", variant: "outline" })}
|
||||
bg={isBlackGold ? "rgba(59, 130, 246, 0.2)" : undefined}
|
||||
color={isBlackGold ? "#60A5FA" : undefined}
|
||||
borderColor={isBlackGold ? "rgba(59, 130, 246, 0.3)" : undefined}
|
||||
>
|
||||
{event.event_type}
|
||||
</Badge>
|
||||
)}
|
||||
{event.invest_score && (
|
||||
<Badge
|
||||
colorScheme="purple"
|
||||
variant="subtle"
|
||||
{...(isBlackGold ? {} : { colorScheme: "purple", variant: "subtle" })}
|
||||
bg={isBlackGold ? "rgba(139, 92, 246, 0.2)" : undefined}
|
||||
color={isBlackGold ? "#A78BFA" : undefined}
|
||||
>
|
||||
投资分: {event.invest_score}
|
||||
</Badge>
|
||||
@@ -287,8 +379,9 @@ const NewsEventsTab = ({
|
||||
<Tag
|
||||
key={kidx}
|
||||
size="sm"
|
||||
colorScheme="cyan"
|
||||
variant="subtle"
|
||||
{...(isBlackGold ? {} : { colorScheme: "cyan", variant: "subtle" })}
|
||||
bg={isBlackGold ? theme.tagBg : undefined}
|
||||
color={isBlackGold ? theme.tagColor : undefined}
|
||||
>
|
||||
{typeof keyword === "string"
|
||||
? keyword
|
||||
@@ -304,7 +397,7 @@ const NewsEventsTab = ({
|
||||
|
||||
{/* 右侧信息栏 */}
|
||||
<VStack align="end" spacing={1} minW="100px">
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
<Text fontSize="xs" color={theme.textMuted}>
|
||||
{event.created_at
|
||||
? new Date(
|
||||
event.created_at
|
||||
@@ -321,9 +414,9 @@ const NewsEventsTab = ({
|
||||
<Icon
|
||||
as={FaEye}
|
||||
boxSize={3}
|
||||
color="gray.400"
|
||||
color={theme.textMuted}
|
||||
/>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
<Text fontSize="xs" color={theme.textMuted}>
|
||||
{event.view_count}
|
||||
</Text>
|
||||
</HStack>
|
||||
@@ -333,16 +426,16 @@ const NewsEventsTab = ({
|
||||
<Icon
|
||||
as={FaFire}
|
||||
boxSize={3}
|
||||
color="orange.400"
|
||||
color={theme.goldLight}
|
||||
/>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
<Text fontSize="xs" color={theme.textMuted}>
|
||||
{event.hot_score.toFixed(1)}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
{event.creator && (
|
||||
<Text fontSize="xs" color="gray.400">
|
||||
<Text fontSize="xs" color={theme.textMuted}>
|
||||
@{event.creator.username}
|
||||
</Text>
|
||||
)}
|
||||
@@ -353,7 +446,7 @@ const NewsEventsTab = ({
|
||||
{event.description && (
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color="gray.700"
|
||||
color={theme.textSecondary}
|
||||
lineHeight="1.6"
|
||||
>
|
||||
{event.description}
|
||||
@@ -367,18 +460,18 @@ const NewsEventsTab = ({
|
||||
<Box
|
||||
pt={2}
|
||||
borderTop="1px"
|
||||
borderColor="gray.200"
|
||||
borderColor={theme.cardBorder}
|
||||
>
|
||||
<HStack spacing={6} flexWrap="wrap">
|
||||
<HStack spacing={1}>
|
||||
<Icon
|
||||
as={FaChartLine}
|
||||
boxSize={3}
|
||||
color="gray.500"
|
||||
color={theme.textMuted}
|
||||
/>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="gray.500"
|
||||
color={theme.textMuted}
|
||||
fontWeight="medium"
|
||||
>
|
||||
相关涨跌:
|
||||
@@ -387,7 +480,7 @@ const NewsEventsTab = ({
|
||||
{event.related_avg_chg !== null &&
|
||||
event.related_avg_chg !== undefined && (
|
||||
<HStack spacing={1}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
<Text fontSize="xs" color={theme.textMuted}>
|
||||
平均
|
||||
</Text>
|
||||
<Text
|
||||
@@ -395,8 +488,8 @@ const NewsEventsTab = ({
|
||||
fontWeight="bold"
|
||||
color={
|
||||
event.related_avg_chg > 0
|
||||
? "red.500"
|
||||
: "green.500"
|
||||
? "#EF4444"
|
||||
: "#10B981"
|
||||
}
|
||||
>
|
||||
{event.related_avg_chg > 0 ? "+" : ""}
|
||||
@@ -407,7 +500,7 @@ const NewsEventsTab = ({
|
||||
{event.related_max_chg !== null &&
|
||||
event.related_max_chg !== undefined && (
|
||||
<HStack spacing={1}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
<Text fontSize="xs" color={theme.textMuted}>
|
||||
最大
|
||||
</Text>
|
||||
<Text
|
||||
@@ -415,8 +508,8 @@ const NewsEventsTab = ({
|
||||
fontWeight="bold"
|
||||
color={
|
||||
event.related_max_chg > 0
|
||||
? "red.500"
|
||||
: "green.500"
|
||||
? "#EF4444"
|
||||
: "#10B981"
|
||||
}
|
||||
>
|
||||
{event.related_max_chg > 0 ? "+" : ""}
|
||||
@@ -427,7 +520,7 @@ const NewsEventsTab = ({
|
||||
{event.related_week_chg !== null &&
|
||||
event.related_week_chg !== undefined && (
|
||||
<HStack spacing={1}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
<Text fontSize="xs" color={theme.textMuted}>
|
||||
周
|
||||
</Text>
|
||||
<Text
|
||||
@@ -435,8 +528,8 @@ const NewsEventsTab = ({
|
||||
fontWeight="bold"
|
||||
color={
|
||||
event.related_week_chg > 0
|
||||
? "red.500"
|
||||
: "green.500"
|
||||
? "#EF4444"
|
||||
: "#10B981"
|
||||
}
|
||||
>
|
||||
{event.related_week_chg > 0
|
||||
@@ -465,7 +558,7 @@ const NewsEventsTab = ({
|
||||
flexWrap="wrap"
|
||||
>
|
||||
{/* 分页信息 */}
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
<Text fontSize="sm" color={theme.textSecondary}>
|
||||
第 {newsPagination.page} / {newsPagination.pages} 页
|
||||
</Text>
|
||||
|
||||
@@ -473,6 +566,11 @@ const NewsEventsTab = ({
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
bg={isBlackGold ? theme.inputBg : undefined}
|
||||
color={theme.textSecondary}
|
||||
borderColor={theme.cardBorder}
|
||||
borderWidth="1px"
|
||||
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
|
||||
onClick={() => handlePageChange(1)}
|
||||
isDisabled={!newsPagination.has_prev || newsLoading}
|
||||
leftIcon={<Icon as={FaChevronLeft} />}
|
||||
@@ -481,6 +579,11 @@ const NewsEventsTab = ({
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
bg={isBlackGold ? theme.inputBg : undefined}
|
||||
color={theme.textSecondary}
|
||||
borderColor={theme.cardBorder}
|
||||
borderWidth="1px"
|
||||
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
|
||||
onClick={() =>
|
||||
handlePageChange(newsPagination.page - 1)
|
||||
}
|
||||
@@ -494,6 +597,11 @@ const NewsEventsTab = ({
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
bg={isBlackGold ? theme.inputBg : undefined}
|
||||
color={theme.textSecondary}
|
||||
borderColor={theme.cardBorder}
|
||||
borderWidth="1px"
|
||||
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
|
||||
onClick={() =>
|
||||
handlePageChange(newsPagination.page + 1)
|
||||
}
|
||||
@@ -503,6 +611,11 @@ const NewsEventsTab = ({
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
bg={isBlackGold ? theme.inputBg : undefined}
|
||||
color={theme.textSecondary}
|
||||
borderColor={theme.cardBorder}
|
||||
borderWidth="1px"
|
||||
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
|
||||
onClick={() => handlePageChange(newsPagination.pages)}
|
||||
isDisabled={!newsPagination.has_next || newsLoading}
|
||||
rightIcon={<Icon as={FaChevronRight} />}
|
||||
@@ -517,11 +630,11 @@ const NewsEventsTab = ({
|
||||
) : (
|
||||
<Center h="400px">
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FaNewspaper} boxSize={16} color="gray.300" />
|
||||
<Text color="gray.500" fontSize="lg" fontWeight="medium">
|
||||
<Icon as={FaNewspaper} boxSize={16} color={isBlackGold ? theme.gold : "gray.300"} opacity={0.5} />
|
||||
<Text color={theme.textSecondary} fontSize="lg" fontWeight="medium">
|
||||
暂无相关新闻
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.400">
|
||||
<Text fontSize="sm" color={theme.textMuted}>
|
||||
{searchQuery ? "尝试修改搜索关键词" : "该公司暂无新闻动态"}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts
|
||||
// 公告数据 Hook - 用于公司公告 Tab
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import axios from "@utils/axiosConfig";
|
||||
import type { Announcement } from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
@@ -28,34 +26,38 @@ export const useAnnouncementsData = (stockCode?: string): UseAnnouncementsDataRe
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/stock/${stockCode}/announcements?limit=20`
|
||||
);
|
||||
const result = (await response.json()) as ApiResponse<Announcement[]>;
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (result.success) {
|
||||
setAnnouncements(result.data);
|
||||
} else {
|
||||
setError("加载公告数据失败");
|
||||
try {
|
||||
const { data: result } = await axios.get<ApiResponse<Announcement[]>>(
|
||||
`/api/stock/${stockCode}/announcements?limit=20`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setAnnouncements(result.data);
|
||||
} else {
|
||||
setError("加载公告数据失败");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === "CanceledError") return;
|
||||
logger.error("useAnnouncementsData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("useAnnouncementsData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
return () => controller.abort();
|
||||
}, [stockCode]);
|
||||
|
||||
return { announcements, loading, error };
|
||||
};
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useBasicInfo.ts
|
||||
// 公司基本信息 Hook - 用于 CompanyHeaderCard
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import axios from "@utils/axiosConfig";
|
||||
import type { BasicInfo } from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
@@ -28,32 +26,38 @@ export const useBasicInfo = (stockCode?: string): UseBasicInfoResult => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/stock/${stockCode}/basic-info`);
|
||||
const result = (await response.json()) as ApiResponse<BasicInfo>;
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (result.success) {
|
||||
setBasicInfo(result.data);
|
||||
} else {
|
||||
setError("加载基本信息失败");
|
||||
try {
|
||||
const { data: result } = await axios.get<ApiResponse<BasicInfo>>(
|
||||
`/api/stock/${stockCode}/basic-info`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setBasicInfo(result.data);
|
||||
} else {
|
||||
setError("加载基本信息失败");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === "CanceledError") return;
|
||||
logger.error("useBasicInfo", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("useBasicInfo", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
return () => controller.abort();
|
||||
}, [stockCode]);
|
||||
|
||||
return { basicInfo, loading, error };
|
||||
};
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts
|
||||
// 分支机构数据 Hook - 用于分支机构 Tab
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import axios from "@utils/axiosConfig";
|
||||
import type { Branch } from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
@@ -28,32 +26,38 @@ export const useBranchesData = (stockCode?: string): UseBranchesDataResult => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/stock/${stockCode}/branches`);
|
||||
const result = (await response.json()) as ApiResponse<Branch[]>;
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (result.success) {
|
||||
setBranches(result.data);
|
||||
} else {
|
||||
setError("加载分支机构数据失败");
|
||||
try {
|
||||
const { data: result } = await axios.get<ApiResponse<Branch[]>>(
|
||||
`/api/stock/${stockCode}/branches`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setBranches(result.data);
|
||||
} else {
|
||||
setError("加载分支机构数据失败");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === "CanceledError") return;
|
||||
logger.error("useBranchesData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("useBranchesData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
return () => controller.abort();
|
||||
}, [stockCode]);
|
||||
|
||||
return { branches, loading, error };
|
||||
};
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useCompanyOverviewData.ts
|
||||
// 公司概览数据加载 Hook
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import type {
|
||||
BasicInfo,
|
||||
ActualControl,
|
||||
Concentration,
|
||||
Management,
|
||||
Shareholder,
|
||||
Branch,
|
||||
Announcement,
|
||||
DisclosureSchedule,
|
||||
CompanyOverviewData,
|
||||
} from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 公司概览数据加载 Hook
|
||||
* @param propStockCode - 股票代码
|
||||
* @returns 公司概览数据
|
||||
*/
|
||||
export const useCompanyOverviewData = (propStockCode?: string): CompanyOverviewData => {
|
||||
const [stockCode, setStockCode] = useState(propStockCode || "000001");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dataLoaded, setDataLoaded] = useState(false);
|
||||
|
||||
// 基本信息数据
|
||||
const [basicInfo, setBasicInfo] = useState<BasicInfo | null>(null);
|
||||
const [actualControl, setActualControl] = useState<ActualControl[]>([]);
|
||||
const [concentration, setConcentration] = useState<Concentration[]>([]);
|
||||
const [management, setManagement] = useState<Management[]>([]);
|
||||
const [topCirculationShareholders, setTopCirculationShareholders] = useState<Shareholder[]>([]);
|
||||
const [topShareholders, setTopShareholders] = useState<Shareholder[]>([]);
|
||||
const [branches, setBranches] = useState<Branch[]>([]);
|
||||
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||
const [disclosureSchedule, setDisclosureSchedule] = useState<DisclosureSchedule[]>([]);
|
||||
|
||||
// 监听 props 中的 stockCode 变化
|
||||
useEffect(() => {
|
||||
if (propStockCode && propStockCode !== stockCode) {
|
||||
setStockCode(propStockCode);
|
||||
setDataLoaded(false);
|
||||
}
|
||||
}, [propStockCode, stockCode]);
|
||||
|
||||
// 加载基本信息数据(9个接口)
|
||||
const loadBasicInfoData = useCallback(async () => {
|
||||
if (dataLoaded) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const [
|
||||
basicRes,
|
||||
actualRes,
|
||||
concentrationRes,
|
||||
managementRes,
|
||||
circulationRes,
|
||||
shareholdersRes,
|
||||
branchesRes,
|
||||
announcementsRes,
|
||||
disclosureRes,
|
||||
] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/basic-info`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<BasicInfo>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/actual-control`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<ActualControl[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/concentration`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<Concentration[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/management?active_only=true`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<Management[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-circulation-shareholders?limit=10`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<Shareholder[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-shareholders?limit=10`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<Shareholder[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/branches`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<Branch[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/announcements?limit=20`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<Announcement[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/disclosure-schedule`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<DisclosureSchedule[]>>,
|
||||
]);
|
||||
|
||||
if (basicRes.success) setBasicInfo(basicRes.data);
|
||||
if (actualRes.success) setActualControl(actualRes.data);
|
||||
if (concentrationRes.success) setConcentration(concentrationRes.data);
|
||||
if (managementRes.success) setManagement(managementRes.data);
|
||||
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
|
||||
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
|
||||
if (branchesRes.success) setBranches(branchesRes.data);
|
||||
if (announcementsRes.success) setAnnouncements(announcementsRes.data);
|
||||
if (disclosureRes.success) setDisclosureSchedule(disclosureRes.data);
|
||||
|
||||
setDataLoaded(true);
|
||||
} catch (err) {
|
||||
logger.error("useCompanyOverviewData", "loadBasicInfoData", err, { stockCode });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode, dataLoaded]);
|
||||
|
||||
// 首次加载
|
||||
useEffect(() => {
|
||||
if (stockCode) {
|
||||
loadBasicInfoData();
|
||||
}
|
||||
}, [stockCode, loadBasicInfoData]);
|
||||
|
||||
return {
|
||||
basicInfo,
|
||||
actualControl,
|
||||
concentration,
|
||||
management,
|
||||
topCirculationShareholders,
|
||||
topShareholders,
|
||||
branches,
|
||||
announcements,
|
||||
disclosureSchedule,
|
||||
loading,
|
||||
dataLoaded,
|
||||
};
|
||||
};
|
||||
@@ -1,13 +1,11 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts
|
||||
// 披露日程数据 Hook - 用于工商信息 Tab
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import axios from "@utils/axiosConfig";
|
||||
import type { DisclosureSchedule } from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
@@ -28,34 +26,38 @@ export const useDisclosureData = (stockCode?: string): UseDisclosureDataResult =
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/stock/${stockCode}/disclosure-schedule`
|
||||
);
|
||||
const result = (await response.json()) as ApiResponse<DisclosureSchedule[]>;
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (result.success) {
|
||||
setDisclosureSchedule(result.data);
|
||||
} else {
|
||||
setError("加载披露日程数据失败");
|
||||
try {
|
||||
const { data: result } = await axios.get<ApiResponse<DisclosureSchedule[]>>(
|
||||
`/api/stock/${stockCode}/disclosure-schedule`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setDisclosureSchedule(result.data);
|
||||
} else {
|
||||
setError("加载披露日程数据失败");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === "CanceledError") return;
|
||||
logger.error("useDisclosureData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("useDisclosureData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
return () => controller.abort();
|
||||
}, [stockCode]);
|
||||
|
||||
return { disclosureSchedule, loading, error };
|
||||
};
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useManagementData.ts
|
||||
// 管理团队数据 Hook - 用于管理团队 Tab
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import axios from "@utils/axiosConfig";
|
||||
import type { Management } from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
@@ -28,34 +26,38 @@ export const useManagementData = (stockCode?: string): UseManagementDataResult =
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/stock/${stockCode}/management?active_only=true`
|
||||
);
|
||||
const result = (await response.json()) as ApiResponse<Management[]>;
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (result.success) {
|
||||
setManagement(result.data);
|
||||
} else {
|
||||
setError("加载管理团队数据失败");
|
||||
try {
|
||||
const { data: result } = await axios.get<ApiResponse<Management[]>>(
|
||||
`/api/stock/${stockCode}/management?active_only=true`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setManagement(result.data);
|
||||
} else {
|
||||
setError("加载管理团队数据失败");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === "CanceledError") return;
|
||||
logger.error("useManagementData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("useManagementData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
return () => controller.abort();
|
||||
}, [stockCode]);
|
||||
|
||||
return { management, loading, error };
|
||||
};
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts
|
||||
// 股权结构数据 Hook - 用于股权结构 Tab
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import axios from "@utils/axiosConfig";
|
||||
import type { ActualControl, Concentration, Shareholder } from "../types";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
@@ -34,43 +32,44 @@ export const useShareholderData = (stockCode?: string): UseShareholderDataResult
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const [actualRes, concentrationRes, shareholdersRes, circulationRes] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/actual-control`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<ActualControl[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/concentration`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<Concentration[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-shareholders?limit=10`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<Shareholder[]>>,
|
||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-circulation-shareholders?limit=10`).then((r) =>
|
||||
r.json()
|
||||
) as Promise<ApiResponse<Shareholder[]>>,
|
||||
]);
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (actualRes.success) setActualControl(actualRes.data);
|
||||
if (concentrationRes.success) setConcentration(concentrationRes.data);
|
||||
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
|
||||
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
|
||||
} catch (err) {
|
||||
logger.error("useShareholderData", "loadData", err, { stockCode });
|
||||
setError("加载股权结构数据失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
try {
|
||||
const [
|
||||
{ data: actualRes },
|
||||
{ data: concentrationRes },
|
||||
{ data: shareholdersRes },
|
||||
{ data: circulationRes },
|
||||
] = await Promise.all([
|
||||
axios.get<ApiResponse<ActualControl[]>>(`/api/stock/${stockCode}/actual-control`, { signal: controller.signal }),
|
||||
axios.get<ApiResponse<Concentration[]>>(`/api/stock/${stockCode}/concentration`, { signal: controller.signal }),
|
||||
axios.get<ApiResponse<Shareholder[]>>(`/api/stock/${stockCode}/top-shareholders?limit=10`, { signal: controller.signal }),
|
||||
axios.get<ApiResponse<Shareholder[]>>(`/api/stock/${stockCode}/top-circulation-shareholders?limit=10`, { signal: controller.signal }),
|
||||
]);
|
||||
|
||||
if (actualRes.success) setActualControl(actualRes.data);
|
||||
if (concentrationRes.success) setConcentration(concentrationRes.data);
|
||||
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
|
||||
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
|
||||
} catch (err: any) {
|
||||
if (err.name === "CanceledError") return;
|
||||
logger.error("useShareholderData", "loadData", err, { stockCode });
|
||||
setError("加载股权结构数据失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
return () => controller.abort();
|
||||
}, [stockCode]);
|
||||
|
||||
return {
|
||||
actualControl,
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
import React from "react";
|
||||
import { VStack } from "@chakra-ui/react";
|
||||
|
||||
import { useBasicInfo } from "./hooks/useBasicInfo";
|
||||
import type { CompanyOverviewProps } from "./types";
|
||||
|
||||
// 子组件(暂保持 JS)
|
||||
// 子组件
|
||||
import BasicInfoTab from "./BasicInfoTab";
|
||||
|
||||
/**
|
||||
@@ -18,17 +17,13 @@ import BasicInfoTab from "./BasicInfoTab";
|
||||
*
|
||||
* 懒加载策略:
|
||||
* - BasicInfoTab 内部根据 Tab 切换懒加载数据
|
||||
* - 各 Panel 组件自行获取所需数据(如 BusinessInfoPanel 调用 useBasicInfo)
|
||||
*/
|
||||
const CompanyOverview: React.FC<CompanyOverviewProps> = ({ stockCode }) => {
|
||||
const { basicInfo } = useBasicInfo(stockCode);
|
||||
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 基本信息内容 - 传入 stockCode,内部懒加载各 Tab 数据 */}
|
||||
<BasicInfoTab
|
||||
stockCode={stockCode}
|
||||
basicInfo={basicInfo}
|
||||
/>
|
||||
<BasicInfoTab stockCode={stockCode} />
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,6 +22,15 @@ export interface BasicInfo {
|
||||
email?: string;
|
||||
tel?: string;
|
||||
company_intro?: string;
|
||||
// 工商信息字段
|
||||
credit_code?: string;
|
||||
company_size?: string;
|
||||
reg_address?: string;
|
||||
office_address?: string;
|
||||
accounting_firm?: string;
|
||||
law_firm?: string;
|
||||
main_business?: string;
|
||||
business_scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,23 +116,6 @@ export interface DisclosureSchedule {
|
||||
disclosure_date?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* useCompanyOverviewData Hook 返回值
|
||||
*/
|
||||
export interface CompanyOverviewData {
|
||||
basicInfo: BasicInfo | null;
|
||||
actualControl: ActualControl[];
|
||||
concentration: Concentration[];
|
||||
management: Management[];
|
||||
topCirculationShareholders: Shareholder[];
|
||||
topShareholders: Shareholder[];
|
||||
branches: Branch[];
|
||||
announcements: Announcement[];
|
||||
disclosureSchedule: DisclosureSchedule[];
|
||||
loading: boolean;
|
||||
dataLoaded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CompanyOverview 组件 Props
|
||||
*/
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import axios from "@utils/axiosConfig";
|
||||
|
||||
// 复用原有的展示组件
|
||||
import DeepAnalysisTab from "../CompanyOverview/DeepAnalysisTab";
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
/**
|
||||
* Tab 与 API 接口映射
|
||||
* - strategy 和 business 共用 comprehensive 接口
|
||||
@@ -84,9 +82,9 @@ const DeepAnalysis = ({ stockCode }) => {
|
||||
switch (apiKey) {
|
||||
case "comprehensive":
|
||||
setComprehensiveLoading(true);
|
||||
const comprehensiveRes = await fetch(
|
||||
`${API_BASE_URL}/api/company/comprehensive-analysis/${stockCode}`
|
||||
).then((r) => r.json());
|
||||
const { data: comprehensiveRes } = await axios.get(
|
||||
`/api/company/comprehensive-analysis/${stockCode}`
|
||||
);
|
||||
// 检查 stockCode 是否已变更(防止竞态)
|
||||
if (currentStockCodeRef.current === stockCode) {
|
||||
if (comprehensiveRes.success)
|
||||
@@ -97,9 +95,9 @@ const DeepAnalysis = ({ stockCode }) => {
|
||||
|
||||
case "valueChain":
|
||||
setValueChainLoading(true);
|
||||
const valueChainRes = await fetch(
|
||||
`${API_BASE_URL}/api/company/value-chain-analysis/${stockCode}`
|
||||
).then((r) => r.json());
|
||||
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;
|
||||
@@ -108,9 +106,9 @@ const DeepAnalysis = ({ stockCode }) => {
|
||||
|
||||
case "keyFactors":
|
||||
setKeyFactorsLoading(true);
|
||||
const keyFactorsRes = await fetch(
|
||||
`${API_BASE_URL}/api/company/key-factors-timeline/${stockCode}`
|
||||
).then((r) => r.json());
|
||||
const { data: keyFactorsRes } = await axios.get(
|
||||
`/api/company/key-factors-timeline/${stockCode}`
|
||||
);
|
||||
if (currentStockCodeRef.current === stockCode) {
|
||||
if (keyFactorsRes.success) setKeyFactorsData(keyFactorsRes.data);
|
||||
loadedApisRef.current.keyFactors = true;
|
||||
@@ -119,9 +117,9 @@ const DeepAnalysis = ({ stockCode }) => {
|
||||
|
||||
case "industryRank":
|
||||
setIndustryRankLoading(true);
|
||||
const industryRankRes = await fetch(
|
||||
`${API_BASE_URL}/api/financial/industry-rank/${stockCode}`
|
||||
).then((r) => r.json());
|
||||
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;
|
||||
|
||||
@@ -1,22 +1,46 @@
|
||||
// src/views/Company/components/DynamicTracking/components/ForecastPanel.js
|
||||
// 业绩预告面板
|
||||
// 业绩预告面板 - 黑金主题
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
VStack,
|
||||
Card,
|
||||
CardBody,
|
||||
HStack,
|
||||
Badge,
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import { Tag } from 'antd';
|
||||
import { logger } from '@utils/logger';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
import { THEME } from '../../CompanyOverview/BasicInfoTab/config';
|
||||
import axios from '@utils/axiosConfig';
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
// 黑金主题
|
||||
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);
|
||||
@@ -27,10 +51,9 @@ const ForecastPanel = ({ stockCode }) => {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/stock/${stockCode}/forecast`
|
||||
const { data: result } = await axios.get(
|
||||
`/api/stock/${stockCode}/forecast`
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
setForecast(result.data);
|
||||
}
|
||||
@@ -63,33 +86,69 @@ const ForecastPanel = ({ stockCode }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{forecast.forecasts.map((item, idx) => (
|
||||
<Card key={idx} bg={THEME.cardBg} borderColor={THEME.border} borderWidth="1px">
|
||||
<CardBody>
|
||||
<HStack justify="space-between" mb={2}>
|
||||
<Badge colorScheme="blue">{item.forecast_type}</Badge>
|
||||
<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>
|
||||
</HStack>
|
||||
<Text mb={2} color={THEME.text}>{item.content}</Text>
|
||||
</Flex>
|
||||
|
||||
{/* 内容 */}
|
||||
<Text color={THEME.text} fontSize="sm" lineHeight="1.6" mb={3}>
|
||||
{item.content}
|
||||
</Text>
|
||||
|
||||
{/* 原因(如有) */}
|
||||
{item.reason && (
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} mb={3}>
|
||||
{item.reason}
|
||||
</Text>
|
||||
)}
|
||||
{item.change_range?.lower && (
|
||||
<HStack mt={2}>
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>预计变动范围:</Text>
|
||||
<Badge colorScheme="green">
|
||||
|
||||
{/* 变动范围 */}
|
||||
{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}%
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Tag>
|
||||
</Flex>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { logger } from '@utils/logger';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
import axios from '@utils/axiosConfig';
|
||||
import NewsEventsTab from '../../CompanyOverview/NewsEventsTab';
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
const NewsPanel = ({ stockCode }) => {
|
||||
const [newsEvents, setNewsEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -25,10 +23,9 @@ const NewsPanel = ({ stockCode }) => {
|
||||
// 获取股票名称
|
||||
const fetchStockName = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/stock/${stockCode}/basic-info`
|
||||
const { data: result } = await axios.get(
|
||||
`/api/stock/${stockCode}/basic-info`
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
const name = result.data.SECNAME || result.data.ORGNAME || stockCode;
|
||||
setStockName(name);
|
||||
@@ -47,10 +44,9 @@ const NewsPanel = ({ stockCode }) => {
|
||||
setLoading(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 { data: result } = await axios.get(
|
||||
`/api/events?q=${encodeURIComponent(searchTerm)}&page=${page}&per_page=10`
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setNewsEvents(result.data || []);
|
||||
@@ -107,7 +103,7 @@ const NewsPanel = ({ stockCode }) => {
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearch={handleSearch}
|
||||
onPageChange={handlePageChange}
|
||||
cardBg="white"
|
||||
themePreset="blackGold"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
/**
|
||||
* 资产负债表组件
|
||||
* 资产负债表组件 - Ant Design 黑金主题
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Box,
|
||||
Badge,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon, ViewIcon } from '@chakra-ui/icons';
|
||||
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,
|
||||
@@ -33,221 +18,308 @@ import {
|
||||
EQUITY_METRICS,
|
||||
} from '../constants';
|
||||
import { getValueByPath } from '../utils';
|
||||
import type { BalanceSheetTableProps, MetricSectionConfig } from '../types';
|
||||
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,
|
||||
getCellBackground,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
positiveColor = 'red.500',
|
||||
negativeColor = 'green.500',
|
||||
}) => {
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
currentAssets: true,
|
||||
nonCurrentAssets: true,
|
||||
currentLiabilities: true,
|
||||
nonCurrentLiabilities: true,
|
||||
equity: true,
|
||||
});
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections((prev) => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}));
|
||||
};
|
||||
|
||||
// 资产部分配置
|
||||
const assetSections: MetricSectionConfig[] = [
|
||||
CURRENT_ASSETS_METRICS,
|
||||
NON_CURRENT_ASSETS_METRICS,
|
||||
TOTAL_ASSETS_METRICS,
|
||||
];
|
||||
|
||||
// 负债部分配置
|
||||
const liabilitySections: MetricSectionConfig[] = [
|
||||
CURRENT_LIABILITIES_METRICS,
|
||||
NON_CURRENT_LIABILITIES_METRICS,
|
||||
TOTAL_LIABILITIES_METRICS,
|
||||
];
|
||||
|
||||
// 权益部分配置
|
||||
const equitySections: MetricSectionConfig[] = [EQUITY_METRICS];
|
||||
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
<Box p={4} textAlign="center" color="gray.400">
|
||||
暂无资产负债表数据
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const maxColumns = Math.min(data.length, 6);
|
||||
const displayData = data.slice(0, maxColumns);
|
||||
|
||||
const renderSection = (sections: MetricSectionConfig[]) => (
|
||||
<>
|
||||
{sections.map((section) => (
|
||||
<React.Fragment key={section.key}>
|
||||
{section.title !== '资产总计' &&
|
||||
section.title !== '负债合计' && (
|
||||
<Tr
|
||||
bg="gray.50"
|
||||
cursor="pointer"
|
||||
onClick={() => toggleSection(section.key)}
|
||||
>
|
||||
<Td colSpan={maxColumns + 2}>
|
||||
<HStack>
|
||||
{expandedSections[section.key] ? (
|
||||
<ChevronUpIcon />
|
||||
) : (
|
||||
<ChevronDownIcon />
|
||||
)}
|
||||
<Text fontWeight="bold">{section.title}</Text>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{(expandedSections[section.key] ||
|
||||
section.title === '资产总计' ||
|
||||
section.title === '负债合计' ||
|
||||
section.title === '股东权益合计') &&
|
||||
section.metrics.map((metric) => {
|
||||
const rowData = data.map((item) =>
|
||||
getValueByPath<number>(item, metric.path)
|
||||
);
|
||||
// 所有分类配置
|
||||
const allSections = [
|
||||
CURRENT_ASSETS_METRICS,
|
||||
NON_CURRENT_ASSETS_METRICS,
|
||||
TOTAL_ASSETS_METRICS,
|
||||
CURRENT_LIABILITIES_METRICS,
|
||||
NON_CURRENT_LIABILITIES_METRICS,
|
||||
TOTAL_LIABILITIES_METRICS,
|
||||
EQUITY_METRICS,
|
||||
];
|
||||
|
||||
return (
|
||||
<Tr
|
||||
key={metric.key}
|
||||
_hover={{ bg: hoverBg, cursor: 'pointer' }}
|
||||
onClick={() =>
|
||||
showMetricChart(metric.name, metric.key, data, metric.path)
|
||||
}
|
||||
bg={metric.isTotal ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
|
||||
<HStack spacing={2}>
|
||||
{!metric.isTotal && <Box w={4} />}
|
||||
<Text
|
||||
fontWeight={metric.isTotal ? 'bold' : 'medium'}
|
||||
fontSize={metric.isTotal ? 'sm' : 'xs'}
|
||||
>
|
||||
{metric.name}
|
||||
</Text>
|
||||
{metric.isCore && (
|
||||
<Badge size="xs" colorScheme="purple">
|
||||
核心
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</Td>
|
||||
{displayData.map((item, idx) => {
|
||||
const value = rowData[idx];
|
||||
const { change, intensity } = calculateYoYChange(
|
||||
value ?? 0,
|
||||
item.period,
|
||||
data,
|
||||
metric.path
|
||||
);
|
||||
// 构建表格数据
|
||||
const tableData = useMemo(() => {
|
||||
const rows: TableRowData[] = [];
|
||||
|
||||
return (
|
||||
<Td
|
||||
key={idx}
|
||||
isNumeric
|
||||
bg={getCellBackground(change, intensity)}
|
||||
position="relative"
|
||||
>
|
||||
<Tooltip
|
||||
label={
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text>
|
||||
数值: {formatUtils.formatLargeNumber(value)}
|
||||
</Text>
|
||||
<Text>同比: {change.toFixed(2)}%</Text>
|
||||
</VStack>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight={metric.isTotal ? 'bold' : 'normal'}
|
||||
>
|
||||
{formatUtils.formatLargeNumber(value, 0)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{Math.abs(change) > 30 && !metric.isTotal && (
|
||||
<Text
|
||||
position="absolute"
|
||||
top="-1"
|
||||
right="0"
|
||||
fontSize="2xs"
|
||||
color={change > 0 ? positiveColor : negativeColor}
|
||||
fontWeight="bold"
|
||||
>
|
||||
{change > 0 ? '↑' : '↓'}
|
||||
{Math.abs(change).toFixed(0)}%
|
||||
</Text>
|
||||
)}
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
<Td>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon={<ViewIcon />}
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
aria-label="查看图表"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
showMetricChart(metric.name, metric.key, data, metric.path);
|
||||
}}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
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 (
|
||||
<TableContainer>
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th position="sticky" left={0} bg={bgColor} zIndex={1} minW="200px">
|
||||
项目
|
||||
</Th>
|
||||
{displayData.map((item) => (
|
||||
<Th key={item.period} isNumeric fontSize="xs" minW="120px">
|
||||
<VStack spacing={0}>
|
||||
<Text>{formatUtils.getReportType(item.period)}</Text>
|
||||
<Text fontSize="2xs" color="gray.500">
|
||||
{item.period.substring(0, 10)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Th>
|
||||
))}
|
||||
<Th w="50px">操作</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{renderSection(assetSections)}
|
||||
<Tr height={2} />
|
||||
{renderSection(liabilitySections)}
|
||||
<Tr height={2} />
|
||||
{renderSection(equitySections)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,156 +1,268 @@
|
||||
/**
|
||||
* 现金流量表组件
|
||||
* 现金流量表组件 - Ant Design 黑金主题
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import { ViewIcon } from '@chakra-ui/icons';
|
||||
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,
|
||||
getCellBackground,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
positiveColor = 'red.500',
|
||||
negativeColor = 'green.500',
|
||||
}) => {
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
<Box p={4} textAlign="center" color="gray.400">
|
||||
暂无现金流量表数据
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const maxColumns = Math.min(data.length, 8);
|
||||
const displayData = data.slice(0, maxColumns);
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th position="sticky" left={0} bg={bgColor} zIndex={1}>
|
||||
项目
|
||||
</Th>
|
||||
{displayData.map((item) => (
|
||||
<Th key={item.period} isNumeric fontSize="xs">
|
||||
<VStack spacing={0}>
|
||||
<Text>{formatUtils.getReportType(item.period)}</Text>
|
||||
<Text fontSize="2xs" color="gray.500">
|
||||
{item.period.substring(0, 10)}
|
||||
// 构建表格数据
|
||||
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>
|
||||
</VStack>
|
||||
</Th>
|
||||
))}
|
||||
<Th>趋势</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{CASHFLOW_METRICS.map((metric) => {
|
||||
const rowData = data.map((item) => getValueByPath<number>(item, metric.path));
|
||||
)}
|
||||
</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 (
|
||||
<Tr
|
||||
key={metric.key}
|
||||
_hover={{ bg: hoverBg, cursor: 'pointer' }}
|
||||
onClick={() => showMetricChart(metric.name, metric.key, data, metric.path)}
|
||||
>
|
||||
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
|
||||
<HStack>
|
||||
<Text fontWeight="medium">{metric.name}</Text>
|
||||
{['operating_net', 'free_cash_flow'].includes(metric.key) && (
|
||||
<Badge colorScheme="purple">核心</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</Td>
|
||||
{displayData.map((item, idx) => {
|
||||
const value = rowData[idx];
|
||||
const isNegative = value !== undefined && value < 0;
|
||||
const { change, intensity } = calculateYoYChange(
|
||||
value ?? 0,
|
||||
item.period,
|
||||
data,
|
||||
metric.path
|
||||
);
|
||||
return cols;
|
||||
}, [displayData, data, showMetricChart]);
|
||||
|
||||
return (
|
||||
<Td
|
||||
key={idx}
|
||||
isNumeric
|
||||
bg={getCellBackground(change, intensity)}
|
||||
position="relative"
|
||||
>
|
||||
<Tooltip
|
||||
label={
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text>数值: {formatUtils.formatLargeNumber(value)}</Text>
|
||||
<Text>同比: {change.toFixed(2)}%</Text>
|
||||
</VStack>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={isNegative ? negativeColor : positiveColor}
|
||||
>
|
||||
{formatUtils.formatLargeNumber(value, 1)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{Math.abs(change) > 50 && (
|
||||
<Text
|
||||
position="absolute"
|
||||
top="0"
|
||||
right="1"
|
||||
fontSize="2xs"
|
||||
color={change > 0 ? positiveColor : negativeColor}
|
||||
fontWeight="bold"
|
||||
>
|
||||
{change > 0 ? '↑' : '↓'}
|
||||
</Text>
|
||||
)}
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
<Td>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon={<ViewIcon />}
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
aria-label="查看趋势"
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
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' },
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
locale={{ emptyText: '暂无数据' }}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
/**
|
||||
* 综合对比分析组件
|
||||
* 综合对比分析组件 - 黑金主题
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardBody } from '@chakra-ui/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;
|
||||
|
||||
@@ -29,11 +35,15 @@ export const ComparisonAnalysis: React.FC<ComparisonAnalysisProps> = ({ comparis
|
||||
const chartOption = getComparisonChartOption(revenueData, profitData);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<ReactECharts option={chartOption} style={{ height: '400px' }} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Box
|
||||
bg={THEME.cardBg}
|
||||
border="1px solid"
|
||||
borderColor={THEME.border}
|
||||
borderRadius="md"
|
||||
p={4}
|
||||
>
|
||||
<ReactECharts option={chartOption} style={{ height: '350px' }} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,33 +1,12 @@
|
||||
/**
|
||||
* 财务指标表格组件
|
||||
* 财务指标表格组件 - Ant Design 黑金主题
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
VStack,
|
||||
HStack,
|
||||
Button,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Text,
|
||||
Badge,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
SimpleGrid,
|
||||
Box,
|
||||
} from '@chakra-ui/react';
|
||||
import { ViewIcon } from '@chakra-ui/icons';
|
||||
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';
|
||||
@@ -35,25 +14,96 @@ 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,
|
||||
getCellBackground,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
}) => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('profitability');
|
||||
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
<Box p={4} textAlign="center" color="gray.400">
|
||||
暂无财务指标数据
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,172 +111,202 @@ export const FinancialMetricsTable: React.FC<FinancialMetricsTableProps> = ({
|
||||
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 (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Box>
|
||||
{/* 分类选择器 */}
|
||||
<HStack spacing={2} wrap="wrap">
|
||||
<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'}
|
||||
colorScheme="blue"
|
||||
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}
|
||||
{category.title.replace('指标', '')}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 指标表格 */}
|
||||
<TableContainer>
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th position="sticky" left={0} bg={bgColor} zIndex={1} minW="200px">
|
||||
{currentCategory.title}
|
||||
</Th>
|
||||
{displayData.map((item) => (
|
||||
<Th key={item.period} isNumeric fontSize="xs" minW="100px">
|
||||
<VStack spacing={0}>
|
||||
<Text>{formatUtils.getReportType(item.period)}</Text>
|
||||
<Text fontSize="2xs" color="gray.500">
|
||||
{item.period.substring(0, 10)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Th>
|
||||
))}
|
||||
<Th w="50px">趋势</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{currentCategory.metrics.map((metric) => {
|
||||
const rowData = data.map((item) =>
|
||||
getValueByPath<number>(item, metric.path)
|
||||
);
|
||||
|
||||
return (
|
||||
<Tr
|
||||
key={metric.key}
|
||||
_hover={{ bg: hoverBg, cursor: 'pointer' }}
|
||||
onClick={() =>
|
||||
showMetricChart(metric.name, metric.key, data, metric.path)
|
||||
}
|
||||
>
|
||||
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
|
||||
<HStack spacing={2}>
|
||||
<Text fontWeight="medium" fontSize="xs">
|
||||
{metric.name}
|
||||
</Text>
|
||||
{metric.isCore && (
|
||||
<Badge size="xs" colorScheme="purple">
|
||||
核心
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</Td>
|
||||
{displayData.map((item, idx) => {
|
||||
const value = rowData[idx];
|
||||
const { change, intensity } = calculateYoYChange(
|
||||
value ?? 0,
|
||||
item.period,
|
||||
data,
|
||||
metric.path
|
||||
);
|
||||
|
||||
// 判断指标性质
|
||||
const isNegative = isNegativeIndicator(metric.key);
|
||||
|
||||
// 对于负向指标,增加是坏事(绿色),减少是好事(红色)
|
||||
const displayColor = isNegative
|
||||
? change > 0
|
||||
? negativeColor
|
||||
: positiveColor
|
||||
: change > 0
|
||||
? positiveColor
|
||||
: negativeColor;
|
||||
|
||||
return (
|
||||
<Td
|
||||
key={idx}
|
||||
isNumeric
|
||||
bg={getCellBackground(change, intensity * 0.3)}
|
||||
position="relative"
|
||||
>
|
||||
<Tooltip
|
||||
label={
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text>
|
||||
{metric.name}: {value?.toFixed(2) || '-'}
|
||||
</Text>
|
||||
<Text>同比: {change.toFixed(2)}%</Text>
|
||||
</VStack>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={
|
||||
selectedCategory === 'growth'
|
||||
? value !== undefined && value > 0
|
||||
? positiveColor
|
||||
: value !== undefined && value < 0
|
||||
? negativeColor
|
||||
: 'gray.500'
|
||||
: 'inherit'
|
||||
}
|
||||
>
|
||||
{value?.toFixed(2) || '-'}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{Math.abs(change) > 20 &&
|
||||
value !== undefined &&
|
||||
Math.abs(value) > 0.01 && (
|
||||
<Text
|
||||
position="absolute"
|
||||
top="-1"
|
||||
right="0"
|
||||
fontSize="2xs"
|
||||
color={displayColor}
|
||||
fontWeight="bold"
|
||||
>
|
||||
{change > 0 ? '↑' : '↓'}
|
||||
</Text>
|
||||
)}
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
<Td>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon={<ViewIcon />}
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
aria-label="查看趋势"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
showMetricChart(metric.name, metric.key, data, metric.path);
|
||||
}}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
<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' },
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
locale={{ emptyText: '暂无数据' }}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
</Box>
|
||||
|
||||
{/* 关键指标快速对比 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="sm">关键指标速览</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<SimpleGrid columns={{ base: 2, md: 4, lg: 6 }} spacing={4}>
|
||||
{data[0] &&
|
||||
[
|
||||
{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'),
|
||||
@@ -258,21 +338,22 @@ export const FinancialMetricsTable: React.FC<FinancialMetricsTableProps> = ({
|
||||
format: 'percent',
|
||||
},
|
||||
].map((item, idx) => (
|
||||
<Box key={idx} p={3} borderRadius="md" bg="gray.50">
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
<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">
|
||||
<Text fontSize="lg" fontWeight="bold" color="#D4AF37">
|
||||
{item.format === 'percent'
|
||||
? formatUtils.formatPercent(item.value)
|
||||
: item.value?.toFixed(2) || '-'}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
</SimpleGrid>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -1,228 +1,325 @@
|
||||
/**
|
||||
* 利润表组件
|
||||
* 利润表组件 - Ant Design 黑金主题
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Box,
|
||||
Badge,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon, ViewIcon } from '@chakra-ui/icons';
|
||||
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 } from '../types';
|
||||
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,
|
||||
getCellBackground,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
positiveColor = 'red.500',
|
||||
negativeColor = 'green.500',
|
||||
}) => {
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
revenue: true,
|
||||
costs: true,
|
||||
otherGains: true,
|
||||
profits: true,
|
||||
eps: true,
|
||||
comprehensive: true,
|
||||
});
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections((prev) => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}));
|
||||
};
|
||||
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
<Box p={4} textAlign="center" color="gray.400">
|
||||
暂无利润表数据
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const maxColumns = Math.min(data.length, 6);
|
||||
const displayData = data.slice(0, maxColumns);
|
||||
|
||||
const renderSection = (section: (typeof INCOME_STATEMENT_SECTIONS)[0]) => (
|
||||
<React.Fragment key={section.key}>
|
||||
<Tr
|
||||
bg="gray.50"
|
||||
cursor="pointer"
|
||||
onClick={() => toggleSection(section.key)}
|
||||
>
|
||||
<Td colSpan={maxColumns + 2}>
|
||||
<HStack>
|
||||
{expandedSections[section.key] ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
<Text fontWeight="bold">{section.title}</Text>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
{expandedSections[section.key] &&
|
||||
section.metrics.map((metric) => {
|
||||
const rowData = data.map((item) => getValueByPath<number>(item, metric.path));
|
||||
// 构建表格数据
|
||||
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 (
|
||||
<Tr
|
||||
key={metric.key}
|
||||
_hover={{ bg: hoverBg, cursor: 'pointer' }}
|
||||
onClick={() => showMetricChart(metric.name, metric.key, data, metric.path)}
|
||||
bg={
|
||||
metric.isTotal
|
||||
? 'blue.50'
|
||||
: metric.isSubtotal
|
||||
? 'orange.50'
|
||||
: 'transparent'
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Text>数值: {isEPS ? value?.toFixed(3) : formatUtils.formatLargeNumber(value)}</Text>
|
||||
{yoy !== null && <Text>同比: {yoy.toFixed(2)}%</Text>}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
|
||||
<HStack spacing={2}>
|
||||
{!metric.isTotal &&
|
||||
!metric.isSubtotal && (
|
||||
<Box w={metric.name.startsWith(' ') ? 8 : 4} />
|
||||
)}
|
||||
<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
|
||||
fontWeight={metric.isTotal || metric.isSubtotal ? 'bold' : 'medium'}
|
||||
fontSize={metric.isTotal ? 'sm' : 'xs'}
|
||||
position="absolute"
|
||||
top="-12px"
|
||||
right="0"
|
||||
fontSize="10px"
|
||||
className={changeColor}
|
||||
>
|
||||
{metric.name}
|
||||
{yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}%
|
||||
</Text>
|
||||
{metric.isCore && (
|
||||
<Badge size="xs" colorScheme="purple">
|
||||
核心
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</Td>
|
||||
{displayData.map((item, idx) => {
|
||||
const value = rowData[idx];
|
||||
const { change, intensity } = calculateYoYChange(
|
||||
value ?? 0,
|
||||
item.period,
|
||||
data,
|
||||
metric.path
|
||||
);
|
||||
|
||||
// 特殊处理:成本费用类负向指标,增长用绿色,减少用红色
|
||||
const isCostItem = isNegativeIndicator(metric.key);
|
||||
const displayColor = isCostItem
|
||||
? change > 0
|
||||
? negativeColor
|
||||
: positiveColor
|
||||
: change > 0
|
||||
? positiveColor
|
||||
: negativeColor;
|
||||
|
||||
return (
|
||||
<Td
|
||||
key={idx}
|
||||
isNumeric
|
||||
bg={getCellBackground(change, intensity)}
|
||||
position="relative"
|
||||
>
|
||||
<Tooltip
|
||||
label={
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text>
|
||||
数值:{' '}
|
||||
{metric.key.includes('eps')
|
||||
? value?.toFixed(3)
|
||||
: formatUtils.formatLargeNumber(value)}
|
||||
</Text>
|
||||
<Text>同比: {change.toFixed(2)}%</Text>
|
||||
</VStack>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight={metric.isTotal || metric.isSubtotal ? 'bold' : 'normal'}
|
||||
color={value !== undefined && value < 0 ? 'red.500' : 'inherit'}
|
||||
>
|
||||
{metric.key.includes('eps')
|
||||
? value?.toFixed(3)
|
||||
: formatUtils.formatLargeNumber(value, 0)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{Math.abs(change) > 30 && !metric.isTotal && (
|
||||
<Text
|
||||
position="absolute"
|
||||
top="-1"
|
||||
right="0"
|
||||
fontSize="2xs"
|
||||
color={displayColor}
|
||||
fontWeight="bold"
|
||||
>
|
||||
{change > 0 ? '↑' : '↓'}
|
||||
{Math.abs(change).toFixed(0)}%
|
||||
</Text>
|
||||
)}
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
<Td>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon={<ViewIcon />}
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
aria-label="查看图表"
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
},
|
||||
})),
|
||||
{
|
||||
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 (
|
||||
<TableContainer>
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th position="sticky" left={0} bg={bgColor} zIndex={1} minW="250px">
|
||||
项目
|
||||
</Th>
|
||||
{displayData.map((item) => (
|
||||
<Th key={item.period} isNumeric fontSize="xs" minW="120px">
|
||||
<VStack spacing={0}>
|
||||
<Text>{formatUtils.getReportType(item.period)}</Text>
|
||||
<Text fontSize="2xs" color="gray.500">
|
||||
{item.period.substring(0, 10)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Th>
|
||||
))}
|
||||
<Th w="50px">操作</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{INCOME_STATEMENT_SECTIONS.map((section) => renderSection(section))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -1,26 +1,17 @@
|
||||
/**
|
||||
* 主营业务分析组件
|
||||
* 主营业务分析组件 - 黑金主题
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
VStack,
|
||||
Grid,
|
||||
GridItem,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Flex,
|
||||
Box,
|
||||
Heading,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
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';
|
||||
@@ -31,6 +22,192 @@ import type {
|
||||
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,
|
||||
}) => {
|
||||
@@ -42,8 +219,8 @@ export const MainBusinessAnalysis: React.FC<MainBusinessAnalysisProps> = ({
|
||||
|
||||
if (!hasProductData && !hasIndustryData) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
<Alert status="info" bg="rgba(212, 175, 55, 0.1)" color={THEME.headingColor}>
|
||||
<AlertIcon color={THEME.headingColor} />
|
||||
暂无主营业务数据
|
||||
</Alert>
|
||||
);
|
||||
@@ -82,101 +259,35 @@ export const MainBusinessAnalysis: React.FC<MainBusinessAnalysisProps> = ({
|
||||
: (mainBusiness!.industry_classification! as IndustryClassification[]);
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Grid templateColumns="repeat(2, 1fr)" gap={4}>
|
||||
<GridItem>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<ReactECharts option={pieOption} style={{ height: '300px' }} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="sm">业务明细 - {latestPeriod.report_type}</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<TableContainer>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>业务</Th>
|
||||
<Th isNumeric>营收</Th>
|
||||
<Th isNumeric>毛利率(%)</Th>
|
||||
<Th isNumeric>利润</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{businessItems
|
||||
.filter((item: BusinessItem) => item.content !== '合计')
|
||||
.map((item: BusinessItem, idx: number) => (
|
||||
<Tr key={idx}>
|
||||
<Td>{item.content}</Td>
|
||||
<Td isNumeric>{formatUtils.formatLargeNumber(item.revenue)}</Td>
|
||||
<Td isNumeric>
|
||||
{formatUtils.formatPercent(item.gross_margin || item.profit_margin)}
|
||||
</Td>
|
||||
<Td isNumeric>{formatUtils.formatLargeNumber(item.profit)}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
<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>
|
||||
|
||||
{/* 历史对比 */}
|
||||
{historicalData.length > 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="sm">主营业务历史对比</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<TableContainer>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>业务/期间</Th>
|
||||
{historicalData.slice(0, 3).map((period) => (
|
||||
<Th key={period.period} isNumeric>
|
||||
{period.report_type}
|
||||
</Th>
|
||||
))}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{businessItems
|
||||
.filter((item: BusinessItem) => item.content !== '合计')
|
||||
.map((item: BusinessItem, idx: number) => (
|
||||
<Tr key={idx}>
|
||||
<Td>{item.content}</Td>
|
||||
{historicalData.slice(0, 3).map((period) => {
|
||||
const periodItems: BusinessItem[] = hasProductData
|
||||
? (period as ProductClassification).products
|
||||
: (period as IndustryClassification).industries;
|
||||
const matchItem = periodItems.find(
|
||||
(p: BusinessItem) => p.content === item.content
|
||||
);
|
||||
return (
|
||||
<Td key={period.period} isNumeric>
|
||||
{matchItem
|
||||
? formatUtils.formatLargeNumber(matchItem.revenue)
|
||||
: '-'}
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</VStack>
|
||||
{/* 右侧:业务明细与历史对比表格 */}
|
||||
<Box flex={1} minW={0} overflow="hidden">
|
||||
{historicalData.length > 0 && (
|
||||
<HistoricalComparisonTable
|
||||
historicalData={historicalData}
|
||||
businessItems={businessItems}
|
||||
hasProductData={hasProductData}
|
||||
latestReportType={latestPeriod.report_type}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
/**
|
||||
* 期数选择器组件
|
||||
* 期数选择器组件 - 黑金主题
|
||||
* 用于选择显示的财务报表期数,并提供刷新功能
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
HStack,
|
||||
Text,
|
||||
Select,
|
||||
IconButton,
|
||||
} from '@chakra-ui/react';
|
||||
import { RepeatIcon } from '@chakra-ui/icons';
|
||||
import { HStack, Text, IconButton } from '@chakra-ui/react';
|
||||
import { Select } from 'antd';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
export interface PeriodSelectorProps {
|
||||
/** 当前选中的期数 */
|
||||
@@ -38,37 +32,62 @@ const PeriodSelector: React.FC<PeriodSelectorProps> = memo(({
|
||||
label = '显示期数:',
|
||||
}) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<HStack justify="space-between">
|
||||
<HStack>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{label}
|
||||
</Text>
|
||||
<Select
|
||||
value={selectedPeriods}
|
||||
onChange={(e) => onPeriodsChange(Number(e.target.value))}
|
||||
w="150px"
|
||||
size="sm"
|
||||
>
|
||||
{periodOptions.map((period) => (
|
||||
<option key={period} value={period}>
|
||||
最近{period}期
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon={<RepeatIcon />}
|
||||
onClick={onRefresh}
|
||||
isLoading={isLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="刷新数据"
|
||||
/>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -57,8 +57,8 @@ export const StockInfoHeader: React.FC<StockInfoHeaderProps> = ({
|
||||
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
|
||||
}}
|
||||
>
|
||||
<Grid templateColumns="repeat(6, 1fr)" gap={4} alignItems="center">
|
||||
<GridItem colSpan={{ base: 6, md: 2 }}>
|
||||
<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}>
|
||||
股票名称
|
||||
@@ -84,16 +84,6 @@ export const StockInfoHeader: React.FC<StockInfoHeaderProps> = ({
|
||||
</HStack>
|
||||
</VStack>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Stat>
|
||||
<StatLabel color={darkGoldTheme.textMuted} fontSize="xs">
|
||||
最新EPS
|
||||
</StatLabel>
|
||||
<StatNumber color={darkGoldTheme.goldLight} fontSize="lg">
|
||||
{stockInfo.key_metrics?.eps?.toFixed(3) || '-'}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Stat>
|
||||
<StatLabel color={darkGoldTheme.textMuted} fontSize="xs">
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
*/
|
||||
|
||||
export { PeriodSelector } from './PeriodSelector';
|
||||
export { FinancialOverviewPanel } from './FinancialOverviewPanel';
|
||||
// 保留旧组件导出(向后兼容)
|
||||
export { KeyMetricsOverview } from './KeyMetricsOverview';
|
||||
export { StockInfoHeader } from './StockInfoHeader';
|
||||
export { BalanceSheetTable } from './BalanceSheetTable';
|
||||
export { IncomeStatementTable } from './IncomeStatementTable';
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
*/
|
||||
|
||||
export { useFinancialData } from './useFinancialData';
|
||||
export type { DataTypeKey } from './useFinancialData';
|
||||
export type { default as UseFinancialDataReturn } from './useFinancialData';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 财务数据加载 Hook
|
||||
* 封装所有财务数据的加载逻辑
|
||||
* 封装所有财务数据的加载逻辑,支持按 Tab 独立刷新
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { logger } from '@utils/logger';
|
||||
import { financialService } from '@services/financialService';
|
||||
@@ -19,6 +19,19 @@ import type {
|
||||
ComparisonData,
|
||||
} from '../types';
|
||||
|
||||
// Tab key 到数据类型的映射
|
||||
export type DataTypeKey =
|
||||
| 'balance'
|
||||
| 'income'
|
||||
| 'cashflow'
|
||||
| 'profitability'
|
||||
| 'perShare'
|
||||
| 'growth'
|
||||
| 'operational'
|
||||
| 'solvency'
|
||||
| 'expense'
|
||||
| 'cashflowMetrics';
|
||||
|
||||
interface UseFinancialDataOptions {
|
||||
stockCode?: string;
|
||||
periods?: number;
|
||||
@@ -38,16 +51,20 @@ interface UseFinancialDataReturn {
|
||||
|
||||
// 加载状态
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,10 +79,12 @@ export const useFinancialData = (
|
||||
|
||||
// 参数状态
|
||||
const [stockCode, setStockCode] = useState(initialStockCode);
|
||||
const [selectedPeriods, setSelectedPeriods] = useState(initialPeriods);
|
||||
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);
|
||||
|
||||
// 财务数据状态
|
||||
@@ -80,9 +99,88 @@ export const useFinancialData = (
|
||||
const [comparison, setComparison] = useState<ComparisonData[]>([]);
|
||||
|
||||
const toast = useToast();
|
||||
const isInitialLoad = useRef(true);
|
||||
const prevPeriods = useRef(selectedPeriods);
|
||||
|
||||
// 加载所有财务数据
|
||||
const loadFinancialData = useCallback(async () => {
|
||||
// 判断 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({
|
||||
@@ -93,7 +191,7 @@ export const useFinancialData = (
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('useFinancialData', '开始加载财务数据', { stockCode, selectedPeriods });
|
||||
logger.debug('useFinancialData', '开始加载全部财务数据', { stockCode, selectedPeriods });
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -132,11 +230,11 @@ export const useFinancialData = (
|
||||
if (rankRes.success) setIndustryRank(rankRes.data);
|
||||
if (comparisonRes.success) setComparison(comparisonRes.data);
|
||||
|
||||
logger.info('useFinancialData', '财务数据加载成功', { stockCode });
|
||||
logger.info('useFinancialData', '全部财务数据加载成功', { stockCode });
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '未知错误';
|
||||
setError(errorMessage);
|
||||
logger.error('useFinancialData', 'loadFinancialData', err, { stockCode, selectedPeriods });
|
||||
logger.error('useFinancialData', 'loadAllFinancialData', err, { stockCode, selectedPeriods });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -149,12 +247,21 @@ export const useFinancialData = (
|
||||
}
|
||||
}, [initialStockCode]);
|
||||
|
||||
// 初始加载和参数变化时重新加载
|
||||
// 初始加载(仅股票代码变化时全量加载)
|
||||
useEffect(() => {
|
||||
if (stockCode) {
|
||||
loadFinancialData();
|
||||
loadAllFinancialData();
|
||||
isInitialLoad.current = false;
|
||||
}
|
||||
}, [stockCode, selectedPeriods, loadFinancialData]);
|
||||
}, [stockCode]); // 注意:这里只依赖 stockCode
|
||||
|
||||
// 期数变化时只刷新当前 Tab
|
||||
useEffect(() => {
|
||||
if (!isInitialLoad.current && prevPeriods.current !== selectedPeriods) {
|
||||
prevPeriods.current = selectedPeriods;
|
||||
refetchByTab(activeTab);
|
||||
}
|
||||
}, [selectedPeriods, activeTab, refetchByTab]);
|
||||
|
||||
return {
|
||||
// 数据状态
|
||||
@@ -170,16 +277,20 @@ export const useFinancialData = (
|
||||
|
||||
// 加载状态
|
||||
loading,
|
||||
loadingTab,
|
||||
error,
|
||||
|
||||
// 操作方法
|
||||
refetch: loadFinancialData,
|
||||
refetch: loadAllFinancialData,
|
||||
refetchByTab,
|
||||
setStockCode,
|
||||
setSelectedPeriods,
|
||||
setActiveTab,
|
||||
|
||||
// 当前参数
|
||||
currentStockCode: stockCode,
|
||||
selectedPeriods,
|
||||
activeTab,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -3,20 +3,16 @@
|
||||
* 重构后的主组件,使用模块化结构和 SubTabContainer 二级导航
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, ReactNode } from 'react';
|
||||
import React, { useState, useMemo, useCallback, ReactNode } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
VStack,
|
||||
HStack,
|
||||
Card,
|
||||
CardBody,
|
||||
Text,
|
||||
Select,
|
||||
IconButton,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Skeleton,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
@@ -33,29 +29,61 @@ import {
|
||||
TableContainer,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { RepeatIcon } from '@chakra-ui/icons';
|
||||
import { BarChart3, DollarSign, TrendingUp } from 'lucide-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 } from './hooks';
|
||||
import { useFinancialData, type DataTypeKey } from './hooks';
|
||||
import { COLORS } from './constants';
|
||||
import { calculateYoYChange, getCellBackground, getMetricChartOption } from './utils';
|
||||
import { PeriodSelector, FinancialOverviewPanel, MainBusinessAnalysis, ComparisonAnalysis } from './components';
|
||||
import {
|
||||
StockInfoHeader,
|
||||
FinancialMetricsTable,
|
||||
MainBusinessAnalysis,
|
||||
} from './components';
|
||||
import { BalanceSheetTab, IncomeStatementTab, CashflowTab } from './tabs';
|
||||
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 {
|
||||
@@ -65,13 +93,28 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
cashflow,
|
||||
financialMetrics,
|
||||
mainBusiness,
|
||||
comparison,
|
||||
loading,
|
||||
loadingTab,
|
||||
error,
|
||||
refetch,
|
||||
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);
|
||||
@@ -180,23 +223,21 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 通用表格属性
|
||||
const tableProps = {
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
};
|
||||
|
||||
// Tab 配置 - 只保留三大财务报表
|
||||
// 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: TrendingUp, component: CashflowTab },
|
||||
{ key: 'cashflow', name: '现金流量表', icon: TrendingDown, component: CashflowTab },
|
||||
],
|
||||
[]
|
||||
);
|
||||
@@ -208,6 +249,7 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
balanceSheet,
|
||||
incomeStatement,
|
||||
cashflow,
|
||||
financialMetrics,
|
||||
// 工具函数
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
@@ -222,6 +264,7 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
balanceSheet,
|
||||
incomeStatement,
|
||||
cashflow,
|
||||
financialMetrics,
|
||||
showMetricChart,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
@@ -233,64 +276,29 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
return (
|
||||
<Container maxW="container.xl" py={5}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 时间选择器 */}
|
||||
<Card>
|
||||
<CardBody>
|
||||
<HStack justify="space-between">
|
||||
<HStack>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
显示期数:
|
||||
</Text>
|
||||
<Select
|
||||
value={selectedPeriods}
|
||||
onChange={(e) => setSelectedPeriods(Number(e.target.value))}
|
||||
w="150px"
|
||||
size="sm"
|
||||
>
|
||||
<option value={4}>最近4期</option>
|
||||
<option value={8}>最近8期</option>
|
||||
<option value={12}>最近12期</option>
|
||||
<option value={16}>最近16期</option>
|
||||
</Select>
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon={<RepeatIcon />}
|
||||
onClick={refetch}
|
||||
isLoading={loading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="刷新数据"
|
||||
/>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 股票信息头部 */}
|
||||
{/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */}
|
||||
{loading ? (
|
||||
<Skeleton height="150px" />
|
||||
<LoadingState message="加载财务数据中..." height="300px" />
|
||||
) : (
|
||||
<StockInfoHeader
|
||||
<FinancialOverviewPanel
|
||||
stockInfo={stockInfo}
|
||||
positiveColor={positiveColor}
|
||||
negativeColor={negativeColor}
|
||||
financialMetrics={financialMetrics}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 财务指标速览 */}
|
||||
{!loading && stockInfo && (
|
||||
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
|
||||
{/* 营收与利润趋势 */}
|
||||
{!loading && comparison && comparison.length > 0 && (
|
||||
<ComparisonAnalysis comparison={comparison} />
|
||||
)}
|
||||
|
||||
{/* 主营业务 */}
|
||||
{!loading && stockInfo && (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Text fontSize="lg" fontWeight="bold" mb={4}>
|
||||
主营业务
|
||||
</Text>
|
||||
<MainBusinessAnalysis mainBusiness={mainBusiness} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold" mb={4} color="#D4AF37">
|
||||
主营业务
|
||||
</Text>
|
||||
<MainBusinessAnalysis mainBusiness={mainBusiness} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 三大财务报表 - 使用 SubTabContainer 二级导航 */}
|
||||
@@ -302,6 +310,15 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
componentProps={componentProps}
|
||||
themePreset="blackGold"
|
||||
isLazy
|
||||
onTabChange={handleTabChange}
|
||||
rightElement={
|
||||
<PeriodSelector
|
||||
selectedPeriods={selectedPeriods}
|
||||
onPeriodsChange={setSelectedPeriods}
|
||||
onRefresh={handleRefresh}
|
||||
isLoading={loadingTab !== null}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -3,16 +3,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
import { Box, VStack, HStack, Heading, Badge, Text } from '@chakra-ui/react';
|
||||
import { BalanceSheetTable } from '../components';
|
||||
import type { BalanceSheetData } from '../types';
|
||||
|
||||
@@ -48,29 +39,25 @@ const BalanceSheetTab: React.FC<BalanceSheetTabProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="md">资产负债表</Heading>
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme="blue">
|
||||
显示最近{Math.min(balanceSheet.length, 8)}期
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
红涨绿跌 | 同比变化
|
||||
</Text>
|
||||
</HStack>
|
||||
<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>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
提示:表格可横向滚动查看更多数据,点击行查看历史趋势
|
||||
</Text>
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<BalanceSheetTable data={balanceSheet} {...tableProps} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
提示:表格可横向滚动查看更多数据,点击行查看历史趋势
|
||||
</Text>
|
||||
</VStack>
|
||||
<BalanceSheetTable data={balanceSheet} {...tableProps} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,16 +3,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
import { Box, VStack, HStack, Heading, Badge, Text } from '@chakra-ui/react';
|
||||
import { CashflowTable } from '../components';
|
||||
import type { CashflowData } from '../types';
|
||||
|
||||
@@ -48,29 +39,25 @@ const CashflowTab: React.FC<CashflowTabProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="md">现金流量表</Heading>
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme="blue">
|
||||
显示最近{Math.min(cashflow.length, 8)}期
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
红涨绿跌 | 同比变化
|
||||
</Text>
|
||||
</HStack>
|
||||
<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>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
提示:现金流数据为累计值,正值红色表示现金流入,负值绿色表示现金流出
|
||||
</Text>
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<CashflowTable data={cashflow} {...tableProps} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
提示:现金流数据为累计值,正值红色表示现金流入,负值绿色表示现金流出
|
||||
</Text>
|
||||
</VStack>
|
||||
<CashflowTable data={cashflow} {...tableProps} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import React from 'react';
|
||||
import { FinancialMetricsTable } from '../components';
|
||||
import type { FinancialMetricsData } from '../types';
|
||||
|
||||
export interface MetricsTabProps {
|
||||
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 };
|
||||
@@ -17,7 +17,7 @@ export interface MetricsTabProps {
|
||||
hoverBg: string;
|
||||
}
|
||||
|
||||
const MetricsTab: React.FC<MetricsTabProps> = ({
|
||||
const FinancialMetricsTab: React.FC<FinancialMetricsTabProps> = ({
|
||||
financialMetrics,
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
@@ -37,7 +37,9 @@ const MetricsTab: React.FC<MetricsTabProps> = ({
|
||||
hoverBg,
|
||||
};
|
||||
|
||||
return <FinancialMetricsTable data={financialMetrics} {...tableProps} />;
|
||||
return (
|
||||
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
|
||||
);
|
||||
};
|
||||
|
||||
export default MetricsTab;
|
||||
export default FinancialMetricsTab;
|
||||
@@ -3,16 +3,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
import { Box, VStack, HStack, Heading, Badge, Text } from '@chakra-ui/react';
|
||||
import { IncomeStatementTable } from '../components';
|
||||
import type { IncomeStatementData } from '../types';
|
||||
|
||||
@@ -48,29 +39,25 @@ const IncomeStatementTab: React.FC<IncomeStatementTabProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="md">利润表</Heading>
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme="blue">
|
||||
显示最近{Math.min(incomeStatement.length, 8)}期
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
红涨绿跌 | 同比变化
|
||||
</Text>
|
||||
</HStack>
|
||||
<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>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
提示:Q1、中报、Q3、年报数据为累计值,同比显示与去年同期对比
|
||||
</Text>
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<IncomeStatementTable data={incomeStatement} {...tableProps} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
提示:Q1、中报、Q3、年报数据为累计值,同比显示与去年同期对比
|
||||
</Text>
|
||||
</VStack>
|
||||
<IncomeStatementTable data={incomeStatement} {...tableProps} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* 主营业务 Tab
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { MainBusinessAnalysis } from '../components';
|
||||
import type { MainBusinessData } from '../types';
|
||||
|
||||
export interface MainBusinessTabProps {
|
||||
mainBusiness: MainBusinessData | null;
|
||||
}
|
||||
|
||||
const MainBusinessTab: React.FC<MainBusinessTabProps> = ({ mainBusiness }) => {
|
||||
return <MainBusinessAnalysis mainBusiness={mainBusiness} />;
|
||||
};
|
||||
|
||||
export default MainBusinessTab;
|
||||
@@ -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;
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* 财务概览 Tab
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { VStack } from '@chakra-ui/react';
|
||||
import { ComparisonAnalysis, FinancialMetricsTable } from '../components';
|
||||
import type { FinancialMetricsData, ComparisonData } from '../types';
|
||||
|
||||
export interface OverviewTabProps {
|
||||
comparison: ComparisonData[];
|
||||
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 OverviewTab: React.FC<OverviewTabProps> = ({
|
||||
comparison,
|
||||
financialMetrics,
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
}) => {
|
||||
const tableProps = {
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<ComparisonAnalysis comparison={comparison} />
|
||||
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverviewTab;
|
||||
@@ -1,12 +1,28 @@
|
||||
/**
|
||||
* Tab 组件统一导出
|
||||
* 仅保留三大财务报表 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';
|
||||
|
||||
@@ -91,7 +91,7 @@ export const getMetricChartOption = (
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成营收与利润趋势图表配置
|
||||
* 生成营收与利润趋势图表配置 - 黑金主题
|
||||
* @param revenueData 营收数据
|
||||
* @param profitData 利润数据
|
||||
* @returns ECharts 配置
|
||||
@@ -101,34 +101,96 @@ export const getComparisonChartOption = (
|
||||
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: [
|
||||
@@ -139,10 +201,10 @@ export const getComparisonChartOption = (
|
||||
itemStyle: {
|
||||
color: (params: { dataIndex: number; value: number }) => {
|
||||
const idx = params.dataIndex;
|
||||
if (idx === 0) return '#3182CE';
|
||||
if (idx === 0) return '#D4AF37'; // 金色作为基准
|
||||
const prevValue = revenueData[idx - 1].value;
|
||||
const currValue = params.value;
|
||||
// 中国市场颜色
|
||||
// 红涨绿跌
|
||||
return currValue >= prevValue ? '#EF4444' : '#10B981';
|
||||
},
|
||||
},
|
||||
@@ -153,15 +215,40 @@ export const getComparisonChartOption = (
|
||||
yAxisIndex: 1,
|
||||
data: profitData.map((d) => d.value?.toFixed(2)),
|
||||
smooth: true,
|
||||
itemStyle: { color: '#F59E0B' },
|
||||
lineStyle: { width: 2 },
|
||||
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 饼图数据
|
||||
@@ -177,9 +264,22 @@ export const getMainBusinessPieOption = (
|
||||
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
|
||||
@@ -190,17 +290,34 @@ export const getMainBusinessPieOption = (
|
||||
orient: 'vertical',
|
||||
left: 'left',
|
||||
top: 'center',
|
||||
textStyle: {
|
||||
color: '#E2E8F0',
|
||||
fontSize: 12,
|
||||
},
|
||||
},
|
||||
color: BLACK_GOLD_PIE_COLORS,
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: '50%',
|
||||
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(0, 0, 0, 0.5)',
|
||||
shadowColor: 'rgba(212, 175, 55, 0.5)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -5,11 +5,7 @@ import React, { useState, useEffect, ReactNode, useMemo, useCallback } from 'rea
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
CardBody,
|
||||
Spinner,
|
||||
Center,
|
||||
VStack,
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
@@ -39,6 +35,7 @@ import {
|
||||
UnusualPanel,
|
||||
PledgePanel,
|
||||
} from './components/panels';
|
||||
import LoadingState from '../LoadingState';
|
||||
import type { MarketDataViewProps, RiseAnalysis } from './types';
|
||||
|
||||
/**
|
||||
@@ -161,22 +158,14 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
|
||||
|
||||
{/* 主要内容区域 - Tab */}
|
||||
{loading ? (
|
||||
<ThemedCard theme={theme}>
|
||||
<CardBody>
|
||||
<Center h="400px">
|
||||
<VStack spacing={4}>
|
||||
<Spinner
|
||||
thickness="4px"
|
||||
speed="0.65s"
|
||||
emptyColor={theme.bgDark}
|
||||
color={theme.primary}
|
||||
size="xl"
|
||||
/>
|
||||
<Text color={theme.textSecondary}>数据加载中...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
</CardBody>
|
||||
</ThemedCard>
|
||||
<Box
|
||||
bg="gray.900"
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.3)"
|
||||
borderRadius="xl"
|
||||
>
|
||||
<LoadingState message="数据加载中..." height="400px" />
|
||||
</Box>
|
||||
) : (
|
||||
<SubTabContainer
|
||||
tabs={tabConfigs}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Company/components/MarketDataView/services/marketService.ts
|
||||
// MarketDataView API 服务层
|
||||
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
import axios from '@utils/axiosConfig';
|
||||
import { logger } from '@utils/logger';
|
||||
import type {
|
||||
MarketSummary,
|
||||
@@ -23,27 +23,6 @@ interface ApiResponse<T> {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 基础 URL
|
||||
*/
|
||||
const getBaseUrl = (): string => getApiBase();
|
||||
|
||||
/**
|
||||
* 通用 API 请求函数
|
||||
*/
|
||||
const apiRequest = async <T>(url: string): Promise<ApiResponse<T>> => {
|
||||
try {
|
||||
const response = await fetch(`${getBaseUrl()}${url}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
logger.error('marketService', 'apiRequest', error, { url });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 市场数据服务
|
||||
*/
|
||||
@@ -53,7 +32,8 @@ export const marketService = {
|
||||
* @param stockCode 股票代码
|
||||
*/
|
||||
async getMarketSummary(stockCode: string): Promise<ApiResponse<MarketSummary>> {
|
||||
return apiRequest<MarketSummary>(`/api/market/summary/${stockCode}`);
|
||||
const { data } = await axios.get<ApiResponse<MarketSummary>>(`/api/market/summary/${stockCode}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -62,7 +42,8 @@ export const marketService = {
|
||||
* @param days 天数,默认 60 天
|
||||
*/
|
||||
async getTradeData(stockCode: string, days: number = 60): Promise<ApiResponse<TradeDayData[]>> {
|
||||
return apiRequest<TradeDayData[]>(`/api/market/trade/${stockCode}?days=${days}`);
|
||||
const { data } = await axios.get<ApiResponse<TradeDayData[]>>(`/api/market/trade/${stockCode}?days=${days}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -71,7 +52,8 @@ export const marketService = {
|
||||
* @param days 天数,默认 30 天
|
||||
*/
|
||||
async getFundingData(stockCode: string, days: number = 30): Promise<ApiResponse<FundingDayData[]>> {
|
||||
return apiRequest<FundingDayData[]>(`/api/market/funding/${stockCode}?days=${days}`);
|
||||
const { data } = await axios.get<ApiResponse<FundingDayData[]>>(`/api/market/funding/${stockCode}?days=${days}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -80,11 +62,8 @@ export const marketService = {
|
||||
* @param days 天数,默认 30 天
|
||||
*/
|
||||
async getBigDealData(stockCode: string, days: number = 30): Promise<BigDealData> {
|
||||
const response = await fetch(`${getBaseUrl()}/api/market/bigdeal/${stockCode}?days=${days}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
const { data } = await axios.get<BigDealData>(`/api/market/bigdeal/${stockCode}?days=${days}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -93,11 +72,8 @@ export const marketService = {
|
||||
* @param days 天数,默认 30 天
|
||||
*/
|
||||
async getUnusualData(stockCode: string, days: number = 30): Promise<UnusualData> {
|
||||
const response = await fetch(`${getBaseUrl()}/api/market/unusual/${stockCode}?days=${days}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
const { data } = await axios.get<UnusualData>(`/api/market/unusual/${stockCode}?days=${days}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -105,7 +81,8 @@ export const marketService = {
|
||||
* @param stockCode 股票代码
|
||||
*/
|
||||
async getPledgeData(stockCode: string): Promise<ApiResponse<PledgeData[]>> {
|
||||
return apiRequest<PledgeData[]>(`/api/market/pledge/${stockCode}`);
|
||||
const { data } = await axios.get<ApiResponse<PledgeData[]>>(`/api/market/pledge/${stockCode}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -123,7 +100,8 @@ export const marketService = {
|
||||
if (startDate && endDate) {
|
||||
url += `?start_date=${startDate}&end_date=${endDate}`;
|
||||
}
|
||||
return apiRequest<RiseAnalysis[]>(url);
|
||||
const { data } = await axios.get<ApiResponse<RiseAnalysis[]>>(url);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -132,18 +110,8 @@ export const marketService = {
|
||||
*/
|
||||
async getMinuteData(stockCode: string): Promise<MinuteData> {
|
||||
try {
|
||||
const response = await fetch(`${getBaseUrl()}/api/stock/${stockCode}/latest-minute`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
const { data } = await axios.get<MinuteData>(`/api/stock/${stockCode}/latest-minute`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch minute data');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.data && Array.isArray(data.data)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* CompanyInfo - 公司信息原子组件
|
||||
* 显示公司基本信息(成立日期、注册资本、所在地、官网、简介)
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Box, Flex, HStack, Text, Link, Icon, Divider } from '@chakra-ui/react';
|
||||
import { Calendar, Coins, MapPin, Globe } from 'lucide-react';
|
||||
import { formatRegisteredCapital, formatDate } from '../../CompanyOverview/utils';
|
||||
import { STOCK_CARD_THEME } from './theme';
|
||||
|
||||
export interface CompanyBasicInfo {
|
||||
establish_date?: string;
|
||||
reg_capital?: number;
|
||||
province?: string;
|
||||
city?: string;
|
||||
website?: string;
|
||||
company_intro?: string;
|
||||
}
|
||||
|
||||
export interface CompanyInfoProps {
|
||||
basicInfo: CompanyBasicInfo;
|
||||
}
|
||||
|
||||
export const CompanyInfo: React.FC<CompanyInfoProps> = memo(({ basicInfo }) => {
|
||||
const { labelColor, valueColor, borderColor } = STOCK_CARD_THEME;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider borderColor={borderColor} my={4} />
|
||||
<Flex gap={8}>
|
||||
{/* 左侧:公司关键属性 (flex=1) */}
|
||||
<Box flex="1" minWidth="0">
|
||||
<HStack spacing={4} flexWrap="wrap" fontSize="14px">
|
||||
<HStack spacing={1}>
|
||||
<Icon as={Calendar} color={labelColor} boxSize={4} />
|
||||
<Text color={labelColor}>成立:</Text>
|
||||
<Text color={valueColor} fontWeight="bold">
|
||||
{formatDate(basicInfo.establish_date)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={Coins} color={labelColor} boxSize={4} />
|
||||
<Text color={labelColor}>注册资本:</Text>
|
||||
<Text color={valueColor} fontWeight="bold">
|
||||
{formatRegisteredCapital(basicInfo.reg_capital)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={MapPin} color={labelColor} boxSize={4} />
|
||||
<Text color={labelColor}>所在地:</Text>
|
||||
<Text color={valueColor} fontWeight="bold">
|
||||
{basicInfo.province} {basicInfo.city}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={Globe} color={labelColor} boxSize={4} />
|
||||
{basicInfo.website ? (
|
||||
<Link
|
||||
href={basicInfo.website}
|
||||
isExternal
|
||||
color={valueColor}
|
||||
fontWeight="bold"
|
||||
_hover={{ color: labelColor }}
|
||||
>
|
||||
访问官网
|
||||
</Link>
|
||||
) : (
|
||||
<Text color={valueColor}>暂无官网</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:公司简介 (flex=2) */}
|
||||
<Box flex="2" minWidth="0" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
|
||||
<Text fontSize="14px" color={labelColor} noOfLines={2}>
|
||||
<Text as="span" fontWeight="bold" color={valueColor}>公司简介:</Text>
|
||||
{basicInfo.company_intro || '暂无'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
CompanyInfo.displayName = 'CompanyInfo';
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { SearchIcon } from '@chakra-ui/icons';
|
||||
import { BarChart2 } from 'lucide-react';
|
||||
import { useStockSearch, type Stock } from '../../../hooks/useStockSearch';
|
||||
|
||||
interface CompareStockInputProps {
|
||||
onCompare: (stockCode: string) => void;
|
||||
@@ -25,11 +26,6 @@ interface CompareStockInputProps {
|
||||
currentStockCode?: string;
|
||||
}
|
||||
|
||||
interface Stock {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface RootState {
|
||||
stock: {
|
||||
allStocks: Stock[];
|
||||
@@ -43,7 +39,6 @@ const CompareStockInput: React.FC<CompareStockInputProps> = ({
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [filteredStocks, setFilteredStocks] = useState<Stock[]>([]);
|
||||
const [selectedStock, setSelectedStock] = useState<Stock | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -55,25 +50,16 @@ const CompareStockInput: React.FC<CompareStockInputProps> = ({
|
||||
const goldColor = '#F4D03F';
|
||||
const bgColor = '#1A202C';
|
||||
|
||||
// 模糊搜索过滤
|
||||
// 使用共享的搜索 Hook(排除当前股票)
|
||||
const filteredStocks = useStockSearch(allStocks, inputValue, {
|
||||
excludeCode: currentStockCode,
|
||||
limit: 8,
|
||||
});
|
||||
|
||||
// 根据搜索结果更新下拉显示状态
|
||||
useEffect(() => {
|
||||
if (inputValue && inputValue.trim()) {
|
||||
const searchTerm = inputValue.trim().toLowerCase();
|
||||
const filtered = allStocks
|
||||
.filter(
|
||||
(stock) =>
|
||||
stock.code !== currentStockCode && // 排除当前股票
|
||||
(stock.code.toLowerCase().includes(searchTerm) ||
|
||||
stock.name.includes(inputValue.trim()))
|
||||
)
|
||||
.slice(0, 8); // 限制显示8条
|
||||
setFilteredStocks(filtered);
|
||||
setShowDropdown(filtered.length > 0);
|
||||
} else {
|
||||
setFilteredStocks([]);
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}, [inputValue, allStocks, currentStockCode]);
|
||||
setShowDropdown(filteredStocks.length > 0 && !!inputValue?.trim());
|
||||
}, [filteredStocks, inputValue]);
|
||||
|
||||
// 点击外部关闭下拉
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* KeyMetrics - 关键指标原子组件
|
||||
* 显示 PE、EPS、PB、流通市值、52周波动
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Box, VStack, HStack, Text } from '@chakra-ui/react';
|
||||
import { formatPrice } from './formatters';
|
||||
import { STOCK_CARD_THEME } from './theme';
|
||||
|
||||
export interface KeyMetricsProps {
|
||||
pe: number;
|
||||
eps?: number;
|
||||
pb: number;
|
||||
marketCap: string;
|
||||
week52Low: number;
|
||||
week52High: number;
|
||||
}
|
||||
|
||||
export const KeyMetrics: React.FC<KeyMetricsProps> = memo(({
|
||||
pe,
|
||||
eps,
|
||||
pb,
|
||||
marketCap,
|
||||
week52Low,
|
||||
week52High,
|
||||
}) => {
|
||||
const { labelColor, valueColor, sectionTitleColor } = STOCK_CARD_THEME;
|
||||
|
||||
return (
|
||||
<Box flex="1">
|
||||
<Text
|
||||
fontSize="14px"
|
||||
fontWeight="bold"
|
||||
color={sectionTitleColor}
|
||||
mb={3}
|
||||
>
|
||||
关键指标
|
||||
</Text>
|
||||
<VStack align="stretch" spacing={2} fontSize="14px">
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>市盈率(PE):</Text>
|
||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
||||
{pe.toFixed(2)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>每股收益(EPS):</Text>
|
||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
||||
{eps?.toFixed(3) || '-'}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>市净率(PB):</Text>
|
||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
||||
{pb.toFixed(2)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>流通市值:</Text>
|
||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
||||
{marketCap}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>52周波动:</Text>
|
||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
||||
{formatPrice(week52Low)}-{formatPrice(week52High)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
KeyMetrics.displayName = 'KeyMetrics';
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* MainForceInfo - 主力动态原子组件
|
||||
* 显示主力净流入、机构持仓、买卖比例
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Box, VStack, HStack, Text, Progress } from '@chakra-ui/react';
|
||||
import { formatNetInflow } from './formatters';
|
||||
import { STOCK_CARD_THEME } from './theme';
|
||||
|
||||
export interface MainForceInfoProps {
|
||||
mainNetInflow: number;
|
||||
institutionHolding: number;
|
||||
buyRatio: number;
|
||||
sellRatio: number;
|
||||
}
|
||||
|
||||
export const MainForceInfo: React.FC<MainForceInfoProps> = memo(({
|
||||
mainNetInflow,
|
||||
institutionHolding,
|
||||
buyRatio,
|
||||
sellRatio,
|
||||
}) => {
|
||||
const { labelColor, valueColor, sectionTitleColor, borderColor, upColor, downColor } = STOCK_CARD_THEME;
|
||||
const inflowColor = mainNetInflow >= 0 ? upColor : downColor;
|
||||
|
||||
return (
|
||||
<Box flex="1" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
|
||||
<Text
|
||||
fontSize="14px"
|
||||
fontWeight="bold"
|
||||
color={sectionTitleColor}
|
||||
mb={3}
|
||||
>
|
||||
主力动态
|
||||
</Text>
|
||||
<VStack align="stretch" spacing={2} fontSize="14px">
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>主力净流入:</Text>
|
||||
<Text color={inflowColor} fontWeight="bold" fontSize="16px">
|
||||
{formatNetInflow(mainNetInflow)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>机构持仓:</Text>
|
||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
||||
{institutionHolding.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
{/* 买卖比例条 */}
|
||||
<Box mt={1}>
|
||||
<Progress
|
||||
value={buyRatio}
|
||||
size="sm"
|
||||
sx={{
|
||||
'& > div': { bg: upColor },
|
||||
}}
|
||||
bg={downColor}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<HStack justify="space-between" mt={1} fontSize="14px">
|
||||
<Text color={upColor}>买入{buyRatio}%</Text>
|
||||
<Text color={downColor}>卖出{sellRatio}%</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
MainForceInfo.displayName = 'MainForceInfo';
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* PriceDisplay - 价格显示原子组件
|
||||
* 显示当前价格和涨跌幅 Badge
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { HStack, Text, Badge } from '@chakra-ui/react';
|
||||
import { formatPrice, formatChangePercent } from './formatters';
|
||||
import { STOCK_CARD_THEME } from './theme';
|
||||
|
||||
export interface PriceDisplayProps {
|
||||
currentPrice: number;
|
||||
changePercent: number;
|
||||
}
|
||||
|
||||
export const PriceDisplay: React.FC<PriceDisplayProps> = memo(({
|
||||
currentPrice,
|
||||
changePercent,
|
||||
}) => {
|
||||
const { upColor, downColor } = STOCK_CARD_THEME;
|
||||
const priceColor = changePercent >= 0 ? upColor : downColor;
|
||||
|
||||
return (
|
||||
<HStack align="baseline" spacing={3} mb={3}>
|
||||
<Text fontSize="48px" fontWeight="bold" color={priceColor}>
|
||||
{formatPrice(currentPrice)}
|
||||
</Text>
|
||||
<Badge
|
||||
bg={changePercent >= 0 ? upColor : downColor}
|
||||
color="#FFFFFF"
|
||||
fontSize="20px"
|
||||
fontWeight="bold"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
>
|
||||
{formatChangePercent(changePercent)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
);
|
||||
});
|
||||
|
||||
PriceDisplay.displayName = 'PriceDisplay';
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* SecondaryQuote - 次要行情原子组件
|
||||
* 显示今开、昨收、最高、最低
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { HStack, Text } from '@chakra-ui/react';
|
||||
import { formatPrice } from './formatters';
|
||||
import { STOCK_CARD_THEME } from './theme';
|
||||
|
||||
export interface SecondaryQuoteProps {
|
||||
todayOpen: number;
|
||||
yesterdayClose: number;
|
||||
todayHigh: number;
|
||||
todayLow: number;
|
||||
}
|
||||
|
||||
export const SecondaryQuote: React.FC<SecondaryQuoteProps> = memo(({
|
||||
todayOpen,
|
||||
yesterdayClose,
|
||||
todayHigh,
|
||||
todayLow,
|
||||
}) => {
|
||||
const { labelColor, valueColor, borderColor, upColor, downColor } = STOCK_CARD_THEME;
|
||||
|
||||
return (
|
||||
<HStack spacing={4} fontSize="14px" flexWrap="wrap">
|
||||
<Text color={labelColor}>
|
||||
今开:
|
||||
<Text as="span" color={valueColor} fontWeight="bold">
|
||||
{formatPrice(todayOpen)}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text color={borderColor}>|</Text>
|
||||
<Text color={labelColor}>
|
||||
昨收:
|
||||
<Text as="span" color={valueColor} fontWeight="bold">
|
||||
{formatPrice(yesterdayClose)}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text color={borderColor}>|</Text>
|
||||
<Text color={labelColor}>
|
||||
最高:
|
||||
<Text as="span" color={upColor} fontWeight="bold">
|
||||
{formatPrice(todayHigh)}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text color={borderColor}>|</Text>
|
||||
<Text color={labelColor}>
|
||||
最低:
|
||||
<Text as="span" color={downColor} fontWeight="bold">
|
||||
{formatPrice(todayLow)}
|
||||
</Text>
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
});
|
||||
|
||||
SecondaryQuote.displayName = 'SecondaryQuote';
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* StockHeader - 股票头部原子组件
|
||||
* 显示股票名称、代码、行业标签、指数标签、操作按钮
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Flex, HStack, Text, Badge, IconButton, Tooltip } from '@chakra-ui/react';
|
||||
import { Share2 } from 'lucide-react';
|
||||
import FavoriteButton from '@components/FavoriteButton';
|
||||
import CompareStockInput from './CompareStockInput';
|
||||
import { STOCK_CARD_THEME } from './theme';
|
||||
|
||||
export interface StockHeaderProps {
|
||||
name: string;
|
||||
code: string;
|
||||
industryL1?: string;
|
||||
industry?: string;
|
||||
indexTags?: string[];
|
||||
updateTime?: string;
|
||||
// 关注相关
|
||||
isInWatchlist?: boolean;
|
||||
isWatchlistLoading?: boolean;
|
||||
onWatchlistToggle?: () => void;
|
||||
// 分享
|
||||
onShare?: () => void;
|
||||
// 对比相关
|
||||
isCompareLoading?: boolean;
|
||||
onCompare?: (stockCode: string) => void;
|
||||
}
|
||||
|
||||
export const StockHeader: React.FC<StockHeaderProps> = memo(({
|
||||
name,
|
||||
code,
|
||||
industryL1,
|
||||
industry,
|
||||
indexTags,
|
||||
updateTime,
|
||||
isInWatchlist = false,
|
||||
isWatchlistLoading = false,
|
||||
onWatchlistToggle,
|
||||
onShare,
|
||||
isCompareLoading = false,
|
||||
onCompare,
|
||||
}) => {
|
||||
const { labelColor, valueColor, borderColor } = STOCK_CARD_THEME;
|
||||
|
||||
return (
|
||||
<Flex justify="space-between" align="center" mb={4}>
|
||||
{/* 左侧:股票名称 + 行业标签 + 指数标签 */}
|
||||
<HStack spacing={3} align="center">
|
||||
{/* 股票名称 - 突出显示 */}
|
||||
<Text fontSize="26px" fontWeight="800" color={valueColor}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text fontSize="18px" fontWeight="normal" color={labelColor}>
|
||||
({code})
|
||||
</Text>
|
||||
|
||||
{/* 行业标签 */}
|
||||
{(industryL1 || industry) && (
|
||||
<Badge
|
||||
bg="transparent"
|
||||
color={labelColor}
|
||||
fontSize="14px"
|
||||
fontWeight="medium"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="md"
|
||||
>
|
||||
{industryL1 && industry
|
||||
? `${industryL1} · ${industry}`
|
||||
: industry || industryL1}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* 指数标签 */}
|
||||
{indexTags && indexTags.length > 0 && (
|
||||
<Text fontSize="14px" color={labelColor}>
|
||||
{indexTags.join('、')}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 右侧:对比 + 关注 + 分享 + 时间 */}
|
||||
<HStack spacing={3}>
|
||||
{/* 股票对比输入 */}
|
||||
<CompareStockInput
|
||||
onCompare={onCompare || (() => {})}
|
||||
isLoading={isCompareLoading}
|
||||
currentStockCode={code}
|
||||
/>
|
||||
<FavoriteButton
|
||||
isFavorite={isInWatchlist}
|
||||
isLoading={isWatchlistLoading}
|
||||
onClick={onWatchlistToggle || (() => {})}
|
||||
colorScheme="gold"
|
||||
size="sm"
|
||||
/>
|
||||
<Tooltip label="分享" placement="top">
|
||||
<IconButton
|
||||
aria-label="分享"
|
||||
icon={<Share2 size={18} />}
|
||||
variant="ghost"
|
||||
color={labelColor}
|
||||
size="sm"
|
||||
onClick={onShare}
|
||||
_hover={{ bg: 'whiteAlpha.100' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Text fontSize="14px" color={labelColor}>
|
||||
{updateTime?.split(' ')[1] || '--:--'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
StockHeader.displayName = 'StockHeader';
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* StockQuoteCard 格式化工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 格式化价格显示
|
||||
*/
|
||||
export const formatPrice = (price: number): string => {
|
||||
return price.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化涨跌幅显示
|
||||
*/
|
||||
export const formatChangePercent = (percent: number): string => {
|
||||
const sign = percent >= 0 ? '+' : '';
|
||||
return `${sign}${percent.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化主力净流入显示
|
||||
*/
|
||||
export const formatNetInflow = (value: number): string => {
|
||||
const sign = value >= 0 ? '+' : '';
|
||||
return `${sign}${value.toFixed(2)}亿`;
|
||||
};
|
||||
@@ -1,6 +1,27 @@
|
||||
/**
|
||||
* StockQuoteCard 子组件导出
|
||||
* StockQuoteCard 组件统一导出
|
||||
*/
|
||||
|
||||
// 原子组件
|
||||
export { PriceDisplay } from './PriceDisplay';
|
||||
export { SecondaryQuote } from './SecondaryQuote';
|
||||
export { KeyMetrics } from './KeyMetrics';
|
||||
export { MainForceInfo } from './MainForceInfo';
|
||||
export { CompanyInfo } from './CompanyInfo';
|
||||
export { StockHeader } from './StockHeader';
|
||||
|
||||
// 复合组件
|
||||
export { default as CompareStockInput } from './CompareStockInput';
|
||||
export { default as StockCompareModal } from './StockCompareModal';
|
||||
|
||||
// 工具和主题
|
||||
export { STOCK_CARD_THEME } from './theme';
|
||||
export * from './formatters';
|
||||
|
||||
// 类型导出
|
||||
export type { PriceDisplayProps } from './PriceDisplay';
|
||||
export type { SecondaryQuoteProps } from './SecondaryQuote';
|
||||
export type { KeyMetricsProps } from './KeyMetrics';
|
||||
export type { MainForceInfoProps } from './MainForceInfo';
|
||||
export type { CompanyInfoProps, CompanyBasicInfo } from './CompanyInfo';
|
||||
export type { StockHeaderProps } from './StockHeader';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* StockQuoteCard 黑金主题配置
|
||||
*/
|
||||
|
||||
export const STOCK_CARD_THEME = {
|
||||
// 背景和边框
|
||||
cardBg: '#1A202C',
|
||||
borderColor: '#C9A961',
|
||||
|
||||
// 文字颜色
|
||||
labelColor: '#C9A961',
|
||||
valueColor: '#F4D03F',
|
||||
sectionTitleColor: '#F4D03F',
|
||||
|
||||
// 涨跌颜色(红涨绿跌)
|
||||
upColor: '#F44336',
|
||||
downColor: '#4CAF50',
|
||||
} as const;
|
||||
|
||||
export type StockCardTheme = typeof STOCK_CARD_THEME;
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* StockQuoteCard Hooks 导出索引
|
||||
*/
|
||||
|
||||
export { useStockQuoteData } from './useStockQuoteData';
|
||||
export { useStockCompare } from './useStockCompare';
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* useStockCompare - 股票对比逻辑 Hook
|
||||
*
|
||||
* 管理股票对比所需的数据获取和状态
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { financialService } from '@services/financialService';
|
||||
import { logger } from '@utils/logger';
|
||||
import type { StockInfo } from '../../FinancialPanorama/types';
|
||||
|
||||
interface UseStockCompareResult {
|
||||
currentStockInfo: StockInfo | null;
|
||||
compareStockInfo: StockInfo | null;
|
||||
isCompareLoading: boolean;
|
||||
handleCompare: (compareCode: string) => Promise<void>;
|
||||
clearCompare: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 股票对比 Hook
|
||||
*
|
||||
* @param stockCode - 当前股票代码
|
||||
*/
|
||||
export const useStockCompare = (stockCode?: string): UseStockCompareResult => {
|
||||
const toast = useToast();
|
||||
const [currentStockInfo, setCurrentStockInfo] = useState<StockInfo | null>(null);
|
||||
const [compareStockInfo, setCompareStockInfo] = useState<StockInfo | null>(null);
|
||||
const [isCompareLoading, setIsCompareLoading] = useState(false);
|
||||
|
||||
// 加载当前股票财务信息(用于对比)
|
||||
useEffect(() => {
|
||||
const loadCurrentStockInfo = async () => {
|
||||
if (!stockCode) {
|
||||
setCurrentStockInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await financialService.getStockInfo(stockCode);
|
||||
setCurrentStockInfo(res.data);
|
||||
} catch (error) {
|
||||
logger.error('useStockCompare', 'loadCurrentStockInfo', error, { stockCode });
|
||||
}
|
||||
};
|
||||
|
||||
loadCurrentStockInfo();
|
||||
// 股票代码变化时清除对比数据
|
||||
setCompareStockInfo(null);
|
||||
}, [stockCode]);
|
||||
|
||||
// 处理股票对比
|
||||
const handleCompare = useCallback(async (compareCode: string) => {
|
||||
if (!compareCode) return;
|
||||
|
||||
logger.debug('useStockCompare', '开始加载对比数据', { stockCode, compareCode });
|
||||
setIsCompareLoading(true);
|
||||
|
||||
try {
|
||||
const res = await financialService.getStockInfo(compareCode);
|
||||
setCompareStockInfo(res.data);
|
||||
logger.info('useStockCompare', '对比数据加载成功', { stockCode, compareCode });
|
||||
} catch (error) {
|
||||
logger.error('useStockCompare', 'handleCompare', error, { stockCode, compareCode });
|
||||
toast({
|
||||
title: '加载对比数据失败',
|
||||
description: '请检查股票代码是否正确',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setIsCompareLoading(false);
|
||||
}
|
||||
}, [stockCode, toast]);
|
||||
|
||||
// 清除对比数据
|
||||
const clearCompare = useCallback(() => {
|
||||
setCompareStockInfo(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
currentStockInfo,
|
||||
compareStockInfo,
|
||||
isCompareLoading,
|
||||
handleCompare,
|
||||
clearCompare,
|
||||
};
|
||||
};
|
||||
|
||||
export default useStockCompare;
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* useStockQuoteData - 股票行情数据获取 Hook
|
||||
*
|
||||
* 合并获取行情数据和基本信息,供 StockQuoteCard 内部使用
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { stockService } from '@services/eventService';
|
||||
import { logger } from '@utils/logger';
|
||||
import axios from '@utils/axiosConfig';
|
||||
import type { StockQuoteCardData } from '../types';
|
||||
import type { BasicInfo } from '../../CompanyOverview/types';
|
||||
|
||||
/**
|
||||
* 将 API 响应数据转换为 StockQuoteCard 所需格式
|
||||
*/
|
||||
const transformQuoteData = (apiData: any, stockCode: string): StockQuoteCardData | null => {
|
||||
if (!apiData) return null;
|
||||
|
||||
return {
|
||||
// 基础信息
|
||||
name: apiData.name || apiData.stock_name || '未知',
|
||||
code: apiData.code || apiData.stock_code || stockCode,
|
||||
indexTags: apiData.index_tags || apiData.indexTags || [],
|
||||
industry: apiData.industry || apiData.sw_industry_l2 || '',
|
||||
industryL1: apiData.industry_l1 || apiData.sw_industry_l1 || '',
|
||||
|
||||
// 价格信息
|
||||
currentPrice: apiData.current_price || apiData.currentPrice || apiData.close || 0,
|
||||
changePercent: apiData.change_percent || apiData.changePercent || apiData.pct_chg || 0,
|
||||
todayOpen: apiData.today_open || apiData.todayOpen || apiData.open || 0,
|
||||
yesterdayClose: apiData.yesterday_close || apiData.yesterdayClose || apiData.pre_close || 0,
|
||||
todayHigh: apiData.today_high || apiData.todayHigh || apiData.high || 0,
|
||||
todayLow: apiData.today_low || apiData.todayLow || apiData.low || 0,
|
||||
|
||||
// 关键指标
|
||||
pe: apiData.pe || apiData.pe_ttm || 0,
|
||||
eps: apiData.eps || apiData.basic_eps || undefined,
|
||||
pb: apiData.pb || apiData.pb_mrq || 0,
|
||||
marketCap: apiData.market_cap || apiData.marketCap || apiData.circ_mv || '0',
|
||||
week52Low: apiData.week52_low || apiData.week52Low || 0,
|
||||
week52High: apiData.week52_high || apiData.week52High || 0,
|
||||
|
||||
// 主力动态
|
||||
mainNetInflow: apiData.main_net_inflow || apiData.mainNetInflow || 0,
|
||||
institutionHolding: apiData.institution_holding || apiData.institutionHolding || 0,
|
||||
buyRatio: apiData.buy_ratio || apiData.buyRatio || 50,
|
||||
sellRatio: apiData.sell_ratio || apiData.sellRatio || 50,
|
||||
|
||||
// 更新时间
|
||||
updateTime: apiData.update_time || apiData.updateTime || new Date().toLocaleString(),
|
||||
};
|
||||
};
|
||||
|
||||
interface UseStockQuoteDataResult {
|
||||
quoteData: StockQuoteCardData | null;
|
||||
basicInfo: BasicInfo | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 股票行情数据获取 Hook
|
||||
* 合并获取行情数据和基本信息
|
||||
*
|
||||
* @param stockCode - 股票代码
|
||||
*/
|
||||
export const useStockQuoteData = (stockCode?: string): UseStockQuoteDataResult => {
|
||||
const [quoteData, setQuoteData] = useState<StockQuoteCardData | null>(null);
|
||||
const [basicInfo, setBasicInfo] = useState<BasicInfo | null>(null);
|
||||
const [quoteLoading, setQuoteLoading] = useState(false);
|
||||
const [basicLoading, setBasicLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 用于手动刷新的 ref
|
||||
const refetchRef = useCallback(async () => {
|
||||
if (!stockCode) return;
|
||||
|
||||
// 获取行情数据
|
||||
setQuoteLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
logger.debug('useStockQuoteData', '获取股票行情', { stockCode });
|
||||
const quotes = await stockService.getQuotes([stockCode]);
|
||||
const quoteResult = quotes?.[stockCode] || quotes;
|
||||
const transformedData = transformQuoteData(quoteResult, stockCode);
|
||||
logger.debug('useStockQuoteData', '行情数据转换完成', { stockCode, hasData: !!transformedData });
|
||||
setQuoteData(transformedData);
|
||||
} catch (err) {
|
||||
logger.error('useStockQuoteData', '获取行情失败', err);
|
||||
setError('获取行情数据失败');
|
||||
setQuoteData(null);
|
||||
} finally {
|
||||
setQuoteLoading(false);
|
||||
}
|
||||
|
||||
// 获取基本信息
|
||||
setBasicLoading(true);
|
||||
try {
|
||||
const { data: result } = await axios.get(`/api/stock/${stockCode}/basic-info`);
|
||||
if (result.success) {
|
||||
setBasicInfo(result.data);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('useStockQuoteData', '获取基本信息失败', err);
|
||||
} finally {
|
||||
setBasicLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
|
||||
// stockCode 变化时重新获取数据(带取消支持)
|
||||
useEffect(() => {
|
||||
if (!stockCode) {
|
||||
setQuoteData(null);
|
||||
setBasicInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let isCancelled = false;
|
||||
|
||||
const fetchData = async () => {
|
||||
// 获取行情数据
|
||||
setQuoteLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
logger.debug('useStockQuoteData', '获取股票行情', { stockCode });
|
||||
const quotes = await stockService.getQuotes([stockCode]);
|
||||
if (isCancelled) return;
|
||||
const quoteResult = quotes?.[stockCode] || quotes;
|
||||
const transformedData = transformQuoteData(quoteResult, stockCode);
|
||||
logger.debug('useStockQuoteData', '行情数据转换完成', { stockCode, hasData: !!transformedData });
|
||||
setQuoteData(transformedData);
|
||||
} catch (err: any) {
|
||||
if (isCancelled || err.name === 'CanceledError') return;
|
||||
logger.error('useStockQuoteData', '获取行情失败', err);
|
||||
setError('获取行情数据失败');
|
||||
setQuoteData(null);
|
||||
} finally {
|
||||
if (!isCancelled) setQuoteLoading(false);
|
||||
}
|
||||
|
||||
// 获取基本信息
|
||||
setBasicLoading(true);
|
||||
try {
|
||||
const { data: result } = await axios.get(`/api/stock/${stockCode}/basic-info`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (isCancelled) return;
|
||||
if (result.success) {
|
||||
setBasicInfo(result.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (isCancelled || err.name === 'CanceledError') return;
|
||||
logger.error('useStockQuoteData', '获取基本信息失败', err);
|
||||
} finally {
|
||||
if (!isCancelled) setBasicLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [stockCode]);
|
||||
|
||||
return {
|
||||
quoteData,
|
||||
basicInfo,
|
||||
isLoading: quoteLoading || basicLoading,
|
||||
error,
|
||||
refetch: refetchRef,
|
||||
};
|
||||
};
|
||||
|
||||
export default useStockQuoteData;
|
||||
@@ -2,6 +2,9 @@
|
||||
* StockQuoteCard - 股票行情卡片组件
|
||||
*
|
||||
* 展示股票的实时行情、关键指标和主力动态
|
||||
* 采用原子组件拆分,提高可维护性和复用性
|
||||
*
|
||||
* 优化:数据获取已下沉到组件内部,Props 从 11 个精简为 4 个
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
@@ -10,100 +13,61 @@ import {
|
||||
Card,
|
||||
CardBody,
|
||||
Flex,
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Badge,
|
||||
Progress,
|
||||
Skeleton,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Divider,
|
||||
Link,
|
||||
Icon,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { Share2, Calendar, Coins, MapPin, Globe } from 'lucide-react';
|
||||
import { formatRegisteredCapital, formatDate } from '../CompanyOverview/utils';
|
||||
|
||||
import FavoriteButton from '@components/FavoriteButton';
|
||||
import { CompareStockInput, StockCompareModal } from './components';
|
||||
import {
|
||||
StockHeader,
|
||||
PriceDisplay,
|
||||
SecondaryQuote,
|
||||
KeyMetrics,
|
||||
MainForceInfo,
|
||||
CompanyInfo,
|
||||
StockCompareModal,
|
||||
STOCK_CARD_THEME,
|
||||
} from './components';
|
||||
import { useStockQuoteData, useStockCompare } from './hooks';
|
||||
import type { StockQuoteCardProps } from './types';
|
||||
|
||||
/**
|
||||
* 格式化价格显示
|
||||
*/
|
||||
const formatPrice = (price: number): string => {
|
||||
return price.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化涨跌幅显示
|
||||
*/
|
||||
const formatChangePercent = (percent: number): string => {
|
||||
const sign = percent >= 0 ? '+' : '';
|
||||
return `${sign}${percent.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化主力净流入显示
|
||||
*/
|
||||
const formatNetInflow = (value: number): string => {
|
||||
const sign = value >= 0 ? '+' : '';
|
||||
return `${sign}${value.toFixed(2)}亿`;
|
||||
};
|
||||
|
||||
const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||
data,
|
||||
isLoading = false,
|
||||
stockCode,
|
||||
isInWatchlist = false,
|
||||
isWatchlistLoading = false,
|
||||
onWatchlistToggle,
|
||||
onShare,
|
||||
basicInfo,
|
||||
// 对比相关
|
||||
currentStockInfo,
|
||||
compareStockInfo,
|
||||
isCompareLoading = false,
|
||||
onCompare,
|
||||
onCloseCompare,
|
||||
}) => {
|
||||
// 内部获取行情数据和基本信息
|
||||
const { quoteData, basicInfo, isLoading } = useStockQuoteData(stockCode);
|
||||
|
||||
// 内部管理股票对比逻辑
|
||||
const {
|
||||
currentStockInfo,
|
||||
compareStockInfo,
|
||||
isCompareLoading,
|
||||
handleCompare: triggerCompare,
|
||||
clearCompare,
|
||||
} = useStockCompare(stockCode);
|
||||
|
||||
// 对比弹窗控制
|
||||
const { isOpen: isCompareModalOpen, onOpen: openCompareModal, onClose: closeCompareModal } = useDisclosure();
|
||||
|
||||
// 处理分享点击
|
||||
const handleShare = () => {
|
||||
onShare?.();
|
||||
};
|
||||
|
||||
// 处理对比按钮点击
|
||||
const handleCompare = (stockCode: string) => {
|
||||
onCompare?.(stockCode);
|
||||
const handleCompare = (compareCode: string) => {
|
||||
triggerCompare(compareCode);
|
||||
openCompareModal();
|
||||
};
|
||||
|
||||
// 处理关闭对比弹窗
|
||||
const handleCloseCompare = () => {
|
||||
closeCompareModal();
|
||||
onCloseCompare?.();
|
||||
clearCompare();
|
||||
};
|
||||
|
||||
// 黑金主题颜色配置
|
||||
const cardBg = '#1A202C';
|
||||
const borderColor = '#C9A961';
|
||||
const labelColor = '#C9A961';
|
||||
const valueColor = '#F4D03F';
|
||||
const sectionTitleColor = '#F4D03F';
|
||||
|
||||
// 涨跌颜色(红涨绿跌)
|
||||
const upColor = '#F44336'; // 涨 - 红色
|
||||
const downColor = '#4CAF50'; // 跌 - 绿色
|
||||
const { cardBg, borderColor } = STOCK_CARD_THEME;
|
||||
|
||||
// 加载中或无数据时显示骨架屏
|
||||
if (isLoading || !data) {
|
||||
if (isLoading || !quoteData) {
|
||||
return (
|
||||
<Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}>
|
||||
<CardBody>
|
||||
@@ -117,88 +81,29 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const priceColor = data.changePercent >= 0 ? upColor : downColor;
|
||||
const inflowColor = data.mainNetInflow >= 0 ? upColor : downColor;
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}>
|
||||
<CardBody>
|
||||
{/* 顶部:股票名称 + 关注/分享按钮 + 更新时间 */}
|
||||
<Flex justify="space-between" align="center" mb={4}>
|
||||
{/* 左侧:股票名称 + 行业标签 + 指数标签 */}
|
||||
<HStack spacing={3} align="center">
|
||||
{/* 股票名称 - 突出显示 */}
|
||||
<Text fontSize="26px" fontWeight="800" color={valueColor}>
|
||||
{data.name}
|
||||
</Text>
|
||||
<Text fontSize="18px" fontWeight="normal" color={labelColor}>
|
||||
({data.code})
|
||||
</Text>
|
||||
|
||||
{/* 行业标签 */}
|
||||
{(data.industryL1 || data.industry) && (
|
||||
<Badge
|
||||
bg="transparent"
|
||||
color={labelColor}
|
||||
fontSize="14px"
|
||||
fontWeight="medium"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="md"
|
||||
>
|
||||
{data.industryL1 && data.industry
|
||||
? `${data.industryL1} · ${data.industry}`
|
||||
: data.industry || data.industryL1}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* 指数标签 */}
|
||||
{data.indexTags?.length > 0 && (
|
||||
<Text fontSize="14px" color={labelColor}>
|
||||
{data.indexTags.join('、')}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 右侧:对比 + 关注 + 分享 + 时间 */}
|
||||
<HStack spacing={3}>
|
||||
{/* 股票对比输入 */}
|
||||
<CompareStockInput
|
||||
onCompare={handleCompare}
|
||||
isLoading={isCompareLoading}
|
||||
currentStockCode={data.code}
|
||||
/>
|
||||
<FavoriteButton
|
||||
isFavorite={isInWatchlist}
|
||||
isLoading={isWatchlistLoading}
|
||||
onClick={onWatchlistToggle || (() => {})}
|
||||
colorScheme="gold"
|
||||
size="sm"
|
||||
/>
|
||||
<Tooltip label="分享" placement="top">
|
||||
<IconButton
|
||||
aria-label="分享"
|
||||
icon={<Share2 size={18} />}
|
||||
variant="ghost"
|
||||
color={labelColor}
|
||||
size="sm"
|
||||
onClick={handleShare}
|
||||
_hover={{ bg: 'whiteAlpha.100' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Text fontSize="14px" color={labelColor}>
|
||||
{data.updateTime?.split(' ')[1] || '--:--'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
<StockHeader
|
||||
name={quoteData.name}
|
||||
code={quoteData.code}
|
||||
industryL1={quoteData.industryL1}
|
||||
industry={quoteData.industry}
|
||||
indexTags={quoteData.indexTags}
|
||||
updateTime={quoteData.updateTime}
|
||||
isInWatchlist={isInWatchlist}
|
||||
isWatchlistLoading={isWatchlistLoading}
|
||||
onWatchlistToggle={onWatchlistToggle}
|
||||
isCompareLoading={isCompareLoading}
|
||||
onCompare={handleCompare}
|
||||
/>
|
||||
|
||||
{/* 股票对比弹窗 */}
|
||||
<StockCompareModal
|
||||
isOpen={isCompareModalOpen}
|
||||
onClose={handleCloseCompare}
|
||||
currentStock={data.code}
|
||||
currentStock={quoteData.code}
|
||||
currentStockInfo={currentStockInfo || null}
|
||||
compareStock={compareStockInfo?.stock_code || ''}
|
||||
compareStockInfo={compareStockInfo || null}
|
||||
@@ -209,196 +114,39 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||
<Flex gap={8}>
|
||||
{/* 左栏:价格信息 (flex=1) */}
|
||||
<Box flex="1" minWidth="0">
|
||||
<HStack align="baseline" spacing={3} mb={3}>
|
||||
<Text fontSize="48px" fontWeight="bold" color={priceColor}>
|
||||
{formatPrice(data.currentPrice)}
|
||||
</Text>
|
||||
<Badge
|
||||
bg={data.changePercent >= 0 ? upColor : downColor}
|
||||
color="#FFFFFF"
|
||||
fontSize="20px"
|
||||
fontWeight="bold"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
>
|
||||
{formatChangePercent(data.changePercent)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
{/* 次要行情:今开 | 昨收 | 最高 | 最低 */}
|
||||
<HStack spacing={4} fontSize="14px" flexWrap="wrap">
|
||||
<Text color={labelColor}>
|
||||
今开:
|
||||
<Text as="span" color={valueColor} fontWeight="bold">
|
||||
{formatPrice(data.todayOpen)}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text color={borderColor}>|</Text>
|
||||
<Text color={labelColor}>
|
||||
昨收:
|
||||
<Text as="span" color={valueColor} fontWeight="bold">
|
||||
{formatPrice(data.yesterdayClose)}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text color={borderColor}>|</Text>
|
||||
<Text color={labelColor}>
|
||||
最高:
|
||||
<Text as="span" color={upColor} fontWeight="bold">
|
||||
{formatPrice(data.todayHigh)}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text color={borderColor}>|</Text>
|
||||
<Text color={labelColor}>
|
||||
最低:
|
||||
<Text as="span" color={downColor} fontWeight="bold">
|
||||
{formatPrice(data.todayLow)}
|
||||
</Text>
|
||||
</Text>
|
||||
</HStack>
|
||||
<PriceDisplay
|
||||
currentPrice={quoteData.currentPrice}
|
||||
changePercent={quoteData.changePercent}
|
||||
/>
|
||||
<SecondaryQuote
|
||||
todayOpen={quoteData.todayOpen}
|
||||
yesterdayClose={quoteData.yesterdayClose}
|
||||
todayHigh={quoteData.todayHigh}
|
||||
todayLow={quoteData.todayLow}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 右栏:关键指标 + 主力动态 (flex=2) */}
|
||||
<Flex flex="2" minWidth="0" gap={8} borderLeftWidth="1px" borderColor={borderColor} pl={8}>
|
||||
{/* 关键指标 */}
|
||||
<Box flex="1">
|
||||
<Text
|
||||
fontSize="14px"
|
||||
fontWeight="bold"
|
||||
color={sectionTitleColor}
|
||||
mb={3}
|
||||
>
|
||||
关键指标
|
||||
</Text>
|
||||
<VStack align="stretch" spacing={2} fontSize="14px">
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>市盈率(PE):</Text>
|
||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
||||
{data.pe.toFixed(2)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>市净率(PB):</Text>
|
||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
||||
{data.pb.toFixed(2)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>流通市值:</Text>
|
||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
||||
{data.marketCap}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>52周波动:</Text>
|
||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
||||
{formatPrice(data.week52Low)}-{formatPrice(data.week52High)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 主力动态 */}
|
||||
<Box flex="1" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
|
||||
<Text
|
||||
fontSize="14px"
|
||||
fontWeight="bold"
|
||||
color={sectionTitleColor}
|
||||
mb={3}
|
||||
>
|
||||
主力动态
|
||||
</Text>
|
||||
<VStack align="stretch" spacing={2} fontSize="14px">
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>主力净流入:</Text>
|
||||
<Text color={inflowColor} fontWeight="bold" fontSize="16px">
|
||||
{formatNetInflow(data.mainNetInflow)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={labelColor}>机构持仓:</Text>
|
||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
||||
{data.institutionHolding.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
{/* 买卖比例条 */}
|
||||
<Box mt={1}>
|
||||
<Progress
|
||||
value={data.buyRatio}
|
||||
size="sm"
|
||||
sx={{
|
||||
'& > div': { bg: upColor },
|
||||
}}
|
||||
bg={downColor}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<HStack justify="space-between" mt={1} fontSize="14px">
|
||||
<Text color={upColor}>买入{data.buyRatio}%</Text>
|
||||
<Text color={downColor}>卖出{data.sellRatio}%</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
<KeyMetrics
|
||||
pe={quoteData.pe}
|
||||
eps={quoteData.eps}
|
||||
pb={quoteData.pb}
|
||||
marketCap={quoteData.marketCap}
|
||||
week52Low={quoteData.week52Low}
|
||||
week52High={quoteData.week52High}
|
||||
/>
|
||||
<MainForceInfo
|
||||
mainNetInflow={quoteData.mainNetInflow}
|
||||
institutionHolding={quoteData.institutionHolding}
|
||||
buyRatio={quoteData.buyRatio}
|
||||
sellRatio={quoteData.sellRatio}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* 公司信息区块 - 1:2 布局 */}
|
||||
{basicInfo && (
|
||||
<>
|
||||
<Divider borderColor={borderColor} my={4} />
|
||||
<Flex gap={8}>
|
||||
{/* 左侧:公司关键属性 (flex=1) */}
|
||||
<Box flex="1" minWidth="0">
|
||||
<HStack spacing={4} flexWrap="wrap" fontSize="14px">
|
||||
<HStack spacing={1}>
|
||||
<Icon as={Calendar} color={labelColor} boxSize={4} />
|
||||
<Text color={labelColor}>成立:</Text>
|
||||
<Text color={valueColor} fontWeight="bold">
|
||||
{formatDate(basicInfo.establish_date)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={Coins} color={labelColor} boxSize={4} />
|
||||
<Text color={labelColor}>注册资本:</Text>
|
||||
<Text color={valueColor} fontWeight="bold">
|
||||
{formatRegisteredCapital(basicInfo.reg_capital)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={MapPin} color={labelColor} boxSize={4} />
|
||||
<Text color={labelColor}>所在地:</Text>
|
||||
<Text color={valueColor} fontWeight="bold">
|
||||
{basicInfo.province} {basicInfo.city}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={Globe} color={labelColor} boxSize={4} />
|
||||
{basicInfo.website ? (
|
||||
<Link
|
||||
href={basicInfo.website}
|
||||
isExternal
|
||||
color={valueColor}
|
||||
fontWeight="bold"
|
||||
_hover={{ color: labelColor }}
|
||||
>
|
||||
访问官网
|
||||
</Link>
|
||||
) : (
|
||||
<Text color={valueColor}>暂无官网</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:公司简介 (flex=2) */}
|
||||
<Box flex="2" minWidth="0" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
|
||||
<Text fontSize="14px" color={labelColor} noOfLines={2}>
|
||||
<Text as="span" fontWeight="bold" color={valueColor}>公司简介:</Text>
|
||||
{basicInfo.company_intro || '暂无'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
{/* 公司信息区块 */}
|
||||
{basicInfo && <CompanyInfo basicInfo={basicInfo} />}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { StockQuoteCardData } from './types';
|
||||
|
||||
/**
|
||||
* 贵州茅台 Mock 数据
|
||||
*/
|
||||
export const mockStockQuoteData: StockQuoteCardData = {
|
||||
// 基础信息
|
||||
name: '贵州茅台',
|
||||
code: '600519.SH',
|
||||
indexTags: ['沪深300'],
|
||||
|
||||
// 价格信息
|
||||
currentPrice: 2178.5,
|
||||
changePercent: 3.65,
|
||||
todayOpen: 2156.0,
|
||||
yesterdayClose: 2101.0,
|
||||
todayHigh: 2185.0,
|
||||
todayLow: 2150.0,
|
||||
|
||||
// 关键指标
|
||||
pe: 38.62,
|
||||
pb: 14.82,
|
||||
marketCap: '2.73万亿',
|
||||
week52Low: 1980,
|
||||
week52High: 2350,
|
||||
|
||||
// 主力动态
|
||||
mainNetInflow: 1.28,
|
||||
institutionHolding: 72.35,
|
||||
buyRatio: 85,
|
||||
sellRatio: 15,
|
||||
|
||||
// 更新时间
|
||||
updateTime: '2025-12-03 14:30:25',
|
||||
|
||||
// 自选状态
|
||||
isFavorite: false,
|
||||
};
|
||||
@@ -2,8 +2,8 @@
|
||||
* StockQuoteCard 组件类型定义
|
||||
*/
|
||||
|
||||
import type { BasicInfo } from '../CompanyOverview/types';
|
||||
import type { StockInfo } from '../FinancialPanorama/types';
|
||||
// 注:BasicInfo 和 StockInfo 类型由内部 hooks 使用,不再在 Props 中传递
|
||||
export type { StockInfo } from '../FinancialPanorama/types';
|
||||
|
||||
/**
|
||||
* 股票行情卡片数据
|
||||
@@ -26,6 +26,7 @@ export interface StockQuoteCardData {
|
||||
|
||||
// 关键指标
|
||||
pe: number; // 市盈率
|
||||
eps?: number; // 每股收益
|
||||
pb: number; // 市净率
|
||||
marketCap: string; // 流通市值(已格式化,如 "2.73万亿")
|
||||
week52Low: number; // 52周最低
|
||||
@@ -45,26 +46,18 @@ export interface StockQuoteCardData {
|
||||
}
|
||||
|
||||
/**
|
||||
* StockQuoteCard 组件 Props
|
||||
* StockQuoteCard 组件 Props(优化后)
|
||||
*
|
||||
* 行情数据、基本信息、对比逻辑已下沉到组件内部 hooks 获取
|
||||
* Props 从 11 个精简为 4 个
|
||||
*/
|
||||
export interface StockQuoteCardProps {
|
||||
data?: StockQuoteCardData;
|
||||
isLoading?: boolean;
|
||||
// 自选股相关(与 WatchlistButton 接口保持一致)
|
||||
isInWatchlist?: boolean; // 是否在自选股中
|
||||
isWatchlistLoading?: boolean; // 自选股操作加载中
|
||||
onWatchlistToggle?: () => void; // 自选股切换回调
|
||||
// 分享
|
||||
onShare?: () => void; // 分享回调
|
||||
// 公司基本信息
|
||||
basicInfo?: BasicInfo;
|
||||
// 股票对比相关
|
||||
currentStockInfo?: StockInfo; // 当前股票财务信息(用于对比)
|
||||
compareStockInfo?: StockInfo; // 对比股票财务信息
|
||||
isCompareLoading?: boolean; // 对比数据加载中
|
||||
onCompare?: (stockCode: string) => void; // 触发对比回调
|
||||
onCloseCompare?: () => void; // 关闭对比弹窗回调
|
||||
/** 股票代码 - 用于内部数据获取 */
|
||||
stockCode?: string;
|
||||
/** 是否在自选股中(保留:涉及 Redux 和事件追踪回调) */
|
||||
isInWatchlist?: boolean;
|
||||
/** 自选股操作加载中 */
|
||||
isWatchlistLoading?: boolean;
|
||||
/** 自选股切换回调 */
|
||||
onWatchlistToggle?: () => void;
|
||||
}
|
||||
|
||||
// 重新导出 StockInfo 类型以便外部使用
|
||||
export type { StockInfo };
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
// src/views/Company/hooks/useStockQuote.js
|
||||
// 股票行情数据获取 Hook
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { stockService } from '@services/eventService';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 将 API 响应数据转换为 StockQuoteCard 所需格式
|
||||
*/
|
||||
const transformQuoteData = (apiData, stockCode) => {
|
||||
if (!apiData) return null;
|
||||
|
||||
return {
|
||||
// 基础信息
|
||||
name: apiData.name || apiData.stock_name || '未知',
|
||||
code: apiData.code || apiData.stock_code || stockCode,
|
||||
indexTags: apiData.index_tags || apiData.indexTags || [],
|
||||
industry: apiData.industry || apiData.sw_industry_l2 || '',
|
||||
industryL1: apiData.industry_l1 || apiData.sw_industry_l1 || '',
|
||||
|
||||
// 价格信息
|
||||
currentPrice: apiData.current_price || apiData.currentPrice || apiData.close || 0,
|
||||
changePercent: apiData.change_percent || apiData.changePercent || apiData.pct_chg || 0,
|
||||
todayOpen: apiData.today_open || apiData.todayOpen || apiData.open || 0,
|
||||
yesterdayClose: apiData.yesterday_close || apiData.yesterdayClose || apiData.pre_close || 0,
|
||||
todayHigh: apiData.today_high || apiData.todayHigh || apiData.high || 0,
|
||||
todayLow: apiData.today_low || apiData.todayLow || apiData.low || 0,
|
||||
|
||||
// 关键指标
|
||||
pe: apiData.pe || apiData.pe_ttm || 0,
|
||||
pb: apiData.pb || apiData.pb_mrq || 0,
|
||||
marketCap: apiData.market_cap || apiData.marketCap || apiData.circ_mv || '0',
|
||||
week52Low: apiData.week52_low || apiData.week52Low || 0,
|
||||
week52High: apiData.week52_high || apiData.week52High || 0,
|
||||
|
||||
// 主力动态
|
||||
mainNetInflow: apiData.main_net_inflow || apiData.mainNetInflow || 0,
|
||||
institutionHolding: apiData.institution_holding || apiData.institutionHolding || 0,
|
||||
buyRatio: apiData.buy_ratio || apiData.buyRatio || 50,
|
||||
sellRatio: apiData.sell_ratio || apiData.sellRatio || 50,
|
||||
|
||||
// 更新时间
|
||||
updateTime: apiData.update_time || apiData.updateTime || new Date().toLocaleString(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 股票行情数据获取 Hook
|
||||
*
|
||||
* @param {string} stockCode - 股票代码
|
||||
* @returns {Object} { data, isLoading, error, refetch }
|
||||
*/
|
||||
export const useStockQuote = (stockCode) => {
|
||||
const [data, setData] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stockCode) {
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchQuote = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
logger.debug('useStockQuote', '获取股票行情', { stockCode });
|
||||
const quotes = await stockService.getQuotes([stockCode]);
|
||||
|
||||
// API 返回格式: { [stockCode]: quoteData }
|
||||
const quoteData = quotes?.[stockCode] || quotes;
|
||||
const transformedData = transformQuoteData(quoteData, stockCode);
|
||||
|
||||
logger.debug('useStockQuote', '行情数据转换完成', { stockCode, hasData: !!transformedData });
|
||||
setData(transformedData);
|
||||
} catch (err) {
|
||||
logger.error('useStockQuote', '获取行情失败', err);
|
||||
setError(err);
|
||||
setData(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchQuote();
|
||||
}, [stockCode]);
|
||||
|
||||
// 手动刷新
|
||||
const refetch = () => {
|
||||
if (stockCode) {
|
||||
setData(null);
|
||||
// 触发 useEffect 重新执行
|
||||
}
|
||||
};
|
||||
|
||||
return { data, isLoading, error, refetch };
|
||||
};
|
||||
|
||||
export default useStockQuote;
|
||||
59
src/views/Company/hooks/useStockSearch.ts
Normal file
59
src/views/Company/hooks/useStockSearch.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* useStockSearch - 股票模糊搜索 Hook
|
||||
*
|
||||
* 提取自 SearchBar.js 和 CompareStockInput.tsx 的共享搜索逻辑
|
||||
* 支持按代码或名称搜索,可选排除指定股票
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export interface Stock {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface UseStockSearchOptions {
|
||||
excludeCode?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 股票模糊搜索 Hook
|
||||
*
|
||||
* @param allStocks - 全部股票列表
|
||||
* @param searchTerm - 搜索关键词
|
||||
* @param options - 可选配置
|
||||
* @param options.excludeCode - 排除的股票代码(用于对比场景)
|
||||
* @param options.limit - 返回结果数量限制,默认 10
|
||||
* @returns 过滤后的股票列表
|
||||
*/
|
||||
export const useStockSearch = (
|
||||
allStocks: Stock[],
|
||||
searchTerm: string,
|
||||
options: UseStockSearchOptions = {}
|
||||
): Stock[] => {
|
||||
const { excludeCode, limit = 10 } = options;
|
||||
|
||||
return useMemo(() => {
|
||||
const trimmed = searchTerm?.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const term = trimmed.toLowerCase();
|
||||
|
||||
return allStocks
|
||||
.filter((stock) => {
|
||||
// 排除指定股票
|
||||
if (excludeCode && stock.code === excludeCode) {
|
||||
return false;
|
||||
}
|
||||
// 按代码或名称匹配
|
||||
return (
|
||||
stock.code.toLowerCase().includes(term) ||
|
||||
stock.name.includes(trimmed)
|
||||
);
|
||||
})
|
||||
.slice(0, limit);
|
||||
}, [allStocks, searchTerm, excludeCode, limit]);
|
||||
};
|
||||
|
||||
export default useStockSearch;
|
||||
@@ -1,19 +1,17 @@
|
||||
// src/views/Company/index.js
|
||||
// 公司详情页面入口 - 纯组合层
|
||||
//
|
||||
// 优化:行情数据、基本信息、对比逻辑已下沉到 StockQuoteCard 内部
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Container, VStack, useToast } from '@chakra-ui/react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Container, VStack } from '@chakra-ui/react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { loadAllStocks } from '@store/slices/stockSlice';
|
||||
import { financialService } from '@services/financialService';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
// 自定义 Hooks
|
||||
import { useCompanyStock } from './hooks/useCompanyStock';
|
||||
import { useCompanyWatchlist } from './hooks/useCompanyWatchlist';
|
||||
import { useCompanyEvents } from './hooks/useCompanyEvents';
|
||||
import { useStockQuote } from './hooks/useStockQuote';
|
||||
import { useBasicInfo } from './components/CompanyOverview/hooks/useBasicInfo';
|
||||
|
||||
// 页面组件
|
||||
import CompanyHeader from './components/CompanyHeader';
|
||||
@@ -31,9 +29,8 @@ import CompanyTabs from './components/CompanyTabs';
|
||||
*/
|
||||
const CompanyIndex = () => {
|
||||
const dispatch = useDispatch();
|
||||
const toast = useToast();
|
||||
|
||||
// 1. 先获取股票代码(不带追踪回调)
|
||||
// 1. 获取股票代码(不带追踪回调)
|
||||
const {
|
||||
stockCode,
|
||||
inputCode,
|
||||
@@ -47,64 +44,7 @@ const CompanyIndex = () => {
|
||||
dispatch(loadAllStocks());
|
||||
}, [dispatch]);
|
||||
|
||||
// 2. 获取股票行情数据
|
||||
const { data: quoteData, isLoading: isQuoteLoading } = useStockQuote(stockCode);
|
||||
|
||||
// 2.1 获取公司基本信息
|
||||
const { basicInfo } = useBasicInfo(stockCode);
|
||||
|
||||
// 5. 股票对比状态管理
|
||||
const [currentStockInfo, setCurrentStockInfo] = useState(null);
|
||||
const [compareStockInfo, setCompareStockInfo] = useState(null);
|
||||
const [isCompareLoading, setIsCompareLoading] = useState(false);
|
||||
|
||||
// 加载当前股票财务信息(用于对比)
|
||||
useEffect(() => {
|
||||
const loadCurrentStockInfo = async () => {
|
||||
if (!stockCode) return;
|
||||
try {
|
||||
const res = await financialService.getStockInfo(stockCode);
|
||||
setCurrentStockInfo(res.data);
|
||||
} catch (error) {
|
||||
logger.error('CompanyIndex', 'loadCurrentStockInfo', error, { stockCode });
|
||||
}
|
||||
};
|
||||
loadCurrentStockInfo();
|
||||
// 清除对比数据
|
||||
setCompareStockInfo(null);
|
||||
}, [stockCode]);
|
||||
|
||||
// 处理股票对比
|
||||
const handleCompare = useCallback(async (compareCode) => {
|
||||
if (!compareCode) return;
|
||||
|
||||
logger.debug('CompanyIndex', '开始加载对比数据', { stockCode, compareCode });
|
||||
setIsCompareLoading(true);
|
||||
|
||||
try {
|
||||
const res = await financialService.getStockInfo(compareCode);
|
||||
setCompareStockInfo(res.data);
|
||||
logger.info('CompanyIndex', '对比数据加载成功', { stockCode, compareCode });
|
||||
} catch (error) {
|
||||
logger.error('CompanyIndex', 'handleCompare', error, { stockCode, compareCode });
|
||||
toast({
|
||||
title: '加载对比数据失败',
|
||||
description: '请检查股票代码是否正确',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setIsCompareLoading(false);
|
||||
}
|
||||
}, [stockCode, toast]);
|
||||
|
||||
// 关闭对比弹窗
|
||||
const handleCloseCompare = useCallback(() => {
|
||||
// 可选:清除对比数据
|
||||
// setCompareStockInfo(null);
|
||||
}, []);
|
||||
|
||||
// 3. 再初始化事件追踪(传入 stockCode)
|
||||
// 2. 初始化事件追踪(传入 stockCode)
|
||||
const {
|
||||
trackStockSearched,
|
||||
trackTabChanged,
|
||||
@@ -147,19 +87,12 @@ const CompanyIndex = () => {
|
||||
/>
|
||||
|
||||
{/* 股票行情卡片:价格、关键指标、主力动态、公司信息、股票对比 */}
|
||||
{/* 优化:数据获取已下沉到组件内部,Props 从 11 个精简为 4 个 */}
|
||||
<StockQuoteCard
|
||||
data={quoteData}
|
||||
isLoading={isQuoteLoading}
|
||||
stockCode={stockCode}
|
||||
isInWatchlist={isInWatchlist}
|
||||
isWatchlistLoading={isWatchlistLoading}
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
basicInfo={basicInfo}
|
||||
// 股票对比相关
|
||||
currentStockInfo={currentStockInfo}
|
||||
compareStockInfo={compareStockInfo}
|
||||
isCompareLoading={isCompareLoading}
|
||||
onCompare={handleCompare}
|
||||
onCloseCompare={handleCloseCompare}
|
||||
/>
|
||||
|
||||
{/* Tab 切换区域:概览、行情、财务、预测 */}
|
||||
|
||||
Reference in New Issue
Block a user