Compare commits

..

24 Commits

Author SHA1 Message Date
zdl
a446f71c04 feat: 添加 pre-commit hook 检查代码规范
- 新增文件必须使用 TypeScript (.ts/.tsx)
- 禁止使用 fetch,提示使用 axios
- 安装 husky 和 lint-staged 依赖

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 19:19:18 +08:00
zdl
e02cbcd9b7 feat(性能监控): 补全 T0 标记 + PostHog 上报
- index.js: 添加 html-loaded 标记(T0 监控点)
- performanceMonitor.ts: 调用 reportPerformanceMetrics 上报到 PostHog
- 现在完整监控 T0-T5 全部阶段并上报性能指标

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 18:29:35 +08:00
zdl
9bb9eab922 fix(MSW): Bytedesk 添加 mock 数据响应
- 未读消息数量返回 { count: 0 }
- 其他 API 返回通用成功响应
- 解决 mock 模式下 404 错误

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 16:41:45 +08:00
zdl
3d7b0045b7 fix(NotificationContext): Mock 模式下跳过 Socket 连接
- 添加 REACT_APP_ENABLE_MOCK 环境变量检查
- Mock 模式下直接 return,避免连接生产服务器失败的错误
- 消除开发环境控制台的 WebSocket 连接错误

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 16:34:44 +08:00
zdl
ada9f6e778 chore(StockQuoteCard): 删除未使用的 mockData.ts
- mockStockQuoteData 未被任何地方引用
- 数据现在通过 useStockQuoteData hook 从 API 获取

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 16:01:49 +08:00
zdl
07aebbece5 refactor(marketService): 移除 apiRequest 包装函数,统一使用 axios.get
- getMarketSummary, getTradeData, getFundingData, getPledgeData, getRiseAnalysis 改为直接使用 axios.get
- 删除 apiRequest<T> 包装函数
- 代码风格与 getBigDealData, getUnusualData, getMinuteData 保持一致

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 16:00:08 +08:00
zdl
7a11800cba docs(Company): 添加 API 接口清单到 STRUCTURE.md
- 梳理 Company 模块共 27 个 API 接口(去重后)
- 分 6 大类:股票基础信息(8)、股东信息(4)、行情数据(8)、深度分析(5)、财务数据(1)、事件新闻(1)
- 标注每个接口的方法类型和调用位置

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 15:42:55 +08:00
zdl
3b352be1a8 refactor(Company): 提取共享的 useStockSearch Hook
- 新增 useStockSearch.ts:统一股票模糊搜索逻辑
  - 支持按代码或名称搜索
  - 支持排除指定股票(用于对比场景)
  - 使用 useMemo 优化性能
- 重构 SearchBar.js:使用共享 Hook,减少 15 行代码
- 重构 CompareStockInput.tsx:使用共享 Hook,减少 20 行代码

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 15:34:36 +08:00
zdl
c49dee72eb fix(hooks): 添加 AbortController 解决竞态条件问题
在以下 Hook 中添加请求取消逻辑,防止快速切换股票时旧数据覆盖新数据:
- useBasicInfo
- useShareholderData
- useManagementData
- useBranchesData
- useAnnouncementsData
- useDisclosureData
- useStockQuoteData

修复前:stockCode 变化时,旧请求可能后返回,覆盖新数据
修复后:cleanup 时取消旧请求,确保数据一致性

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 15:20:36 +08:00
zdl
7159e510a6 fix(SubTabContainer): 修复 Tab 懒加载失效问题
- 添加 visitedTabs 状态记录已访问的 Tab 索引
- Tab 切换时更新已访问集合
- TabPanels 中实现条件渲染:只渲染当前或已访问过的 Tab

修复前:tabs.map() 会创建所有组件实例,导致 Hook 立即执行
修复后:仅首次访问 Tab 时才渲染组件,真正实现懒加载

效果:初始加载从 N 个请求减少到 1 个请求

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 14:44:46 +08:00
zdl
385d452f5a chore(CompanyOverview): 移除未使用的 CompanyOverviewData 类型定义
useCompanyOverviewData hook 已在 axios 迁移中删除,
对应的类型定义也应清理

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 12:46:16 +08:00
zdl
bdc823e122 fix(CompanyOverview): 修复 useBasicInfo 重复调用问题
- BusinessInfoPanel: 改为内部调用 useBasicInfo,自行获取数据
- BasicInfoTab: 移除 basicInfo prop 传递
- CompanyOverview: 移除顶层 useBasicInfo 调用
- types.ts: 补充 BasicInfo 工商信息字段类型定义

修复前:CompanyOverview 和各子组件重复请求 /api/stock/{code}/basic-info
修复后:仅 BusinessInfoPanel 在需要时请求一次

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 12:02:31 +08:00
zdl
c83d239219 refactor(Company): fetch 请求迁移至 axios
- DeepAnalysis: 4 个 fetch → axios
- DynamicTracking: 3 个 fetch → axios (NewsPanel, ForecastPanel)
- MarketDataView/services: 4 个 fetch → axios
- CompanyOverview/hooks: 9 个 fetch → axios (6 个文件)
- StockQuoteCard/hooks: 1 个 fetch → axios
- ValueChainNodeCard: 1 个 fetch → axios

清理:
- 删除未使用的 useCompanyOverviewData.ts
- 移除所有 getApiBase/API_BASE_URL 引用

总计: 22 个 fetch 调用迁移, 复用项目已有的 axios 拦截器配置

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 11:54:32 +08:00
zdl
c4900bd280 docs(Company): 更新 STRUCTURE.md 添加数据下沉优化记录
- 更新目录结构:新增 StockQuoteCard/hooks/
- 更新 hooks 目录说明:标注 useStockQuote.js 已下沉
- 更新入口文件说明:列出已移除的模块
- 新增 2025-12-17 重构记录:StockQuoteCard 数据下沉优化详情

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 11:17:25 +08:00
zdl
7736212235 refactor(StockQuoteCard): 数据下沉优化,Props 从 11 个精简为 4 个
- StockQuoteCard 使用内部 hooks 获取行情数据、基本信息和对比数据
- 更新 types.ts,简化 Props 接口
- Company/index.js 移除已下沉的数据获取逻辑(~40 行)
- 删除 Company/hooks/useStockQuote.js(已移至组件内部)

优化收益:
- Props 数量: 11 → 4 (-64%)
- Company/index.js: ~172 → ~105 行 (-39%)
- 组件可独立复用

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 11:17:07 +08:00
zdl
348d8a0ec3 feat(StockQuoteCard): 新增内部数据获取 hooks
- useStockQuoteData: 合并行情数据和基本信息获取
- useStockCompare: 股票对比逻辑封装
- 为数据下沉优化做准备

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 11:12:14 +08:00
zdl
5a0d6e1569 fix(MarketDataView): 添加缺失的 VStack 导入
- 修复 TypeScript 类型检查错误

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 11:11:59 +08:00
zdl
bc2b6ae41c fix(MarketDataView): loading 背景色改为深色与整体一致
- 移除白色 ThemedCard 包装
- 使用 gray.900 背景 + 金色边框

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 10:34:36 +08:00
zdl
ac7e627b2d refactor(Company): 统一所有 Tab 的 loading 状态组件
- 创建共享的 LoadingState 组件(黑金主题)
- DeepAnalysisTab: 使用统一 LoadingState 替换蓝色 Spinner
- FinancialPanorama: 使用 LoadingState 替换 Skeleton
- MarketDataView: 使用 LoadingState 替换自定义 Spinner
- ForecastReport: 使用 LoadingState 替换 Skeleton 骨架屏

所有一级 Tab 现在使用一致的金色 Spinner + 加载提示文案

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 10:31:38 +08:00
zdl
21e83ac1bc style(ForecastReport): 详细数据表格 UI 优化
- 斑马纹(奇数行浅色背景)
- 等宽字体(SF Mono/Monaco/Menlo)
- 重要指标行高亮(归母净利润、ROE、EPS、营业总收入)
- 预测列区分样式(斜体+浅金背景+分隔线)
- 负数红色/正增长绿色显示

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 10:27:38 +08:00
zdl
e2dd9e2648 style(ForecastReport): 盈利预测图表优化
- 预测年份 X 轴金色高亮,预测区域添加背景标记
- Y 轴颜色与对应数据系列匹配
- PEG 改用青色点划线+菱形符号,增加 PEG=1 参考线
- EPS 图添加行业平均参考线
- Tooltip 显示预测标签,智能避让

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 10:27:30 +08:00
zdl
f2463922f3 fix(ValueChainCard): 视图切换按钮始终靠右显示
使用 ml="auto" 确保切换按钮在流向关系视图时保持右侧位置

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:53:08 +08:00
zdl
9aaad00f87 refactor(CompanyOverview): 优化多个面板显示逻辑
- ValueChainCard: 流向关系视图时隐藏左侧导航选项
- AnnouncementsPanel: 移除重复的"最新公告"标题
- DisclosureSchedulePanel: 移除重复的"财报披露日程"标题
- CompetitiveAnalysisCard: 恢复竞争对手标签和雷达图显示

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:51:27 +08:00
zdl
024126025d style(DetailTable): 简化布局,标题+表格无嵌套
- 移除外层卡片包装,直接显示标题和表格
- 使用 Chakra Text 作为标题
- 表格背景改为透明

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:43:05 +08:00
49 changed files with 1643 additions and 982 deletions

110
.husky/pre-commit Executable file
View 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

View File

@@ -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",

View File

@@ -118,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],
@@ -132,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);
}
@@ -197,11 +208,16 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
</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>
);
})}

View File

@@ -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 保护)');

View File

@@ -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该文件无效

View File

@@ -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/服务请求

View File

@@ -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;
}

View File

@@ -1,6 +1,6 @@
# Company 目录结构说明
> 最后更新2025-12-16
> 最后更新2025-12-17API 接口清单梳理)
## 目录结构
@@ -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/ # 内部数据 Hooks2025-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/ # 深度分析 Tab21 个 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
- **原子设计模式**atomsMinuteStats、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 可独立在其他页面使用

View File

@@ -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(() => {

View File

@@ -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

View File

@@ -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">

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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>
{/* 内容区域 */}

View File

@@ -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>
);

View File

@@ -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 {

View File

@@ -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 };
};

View File

@@ -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 };
};

View File

@@ -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 };
};

View File

@@ -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,
};
};

View File

@@ -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 };
};

View File

@@ -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 };
};

View File

@@ -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,

View File

@@ -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>
);
};

View File

@@ -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
*/

View File

@@ -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;

View File

@@ -12,9 +12,7 @@ import {
} from '@chakra-ui/react';
import { Tag } from 'antd';
import { logger } from '@utils/logger';
import { getApiBase } from '@utils/apiConfig';
const API_BASE_URL = getApiBase();
import axios from '@utils/axiosConfig';
// 黑金主题
const THEME = {
@@ -53,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);
}

View File

@@ -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 || []);

View File

@@ -13,7 +13,6 @@ import {
Text,
Alert,
AlertIcon,
Skeleton,
Modal,
ModalOverlay,
ModalContent,
@@ -47,6 +46,7 @@ import { formatUtils } from '@services/financialService';
// 通用组件
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
import LoadingState from '../LoadingState';
// 内部模块导入
import { useFinancialData, type DataTypeKey } from './hooks';
@@ -278,7 +278,7 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
<VStack spacing={6} align="stretch">
{/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */}
{loading ? (
<Skeleton height="100px" />
<LoadingState message="加载财务数据中..." height="300px" />
) : (
<FinancialOverviewPanel
stockInfo={stockInfo}

View File

@@ -1,18 +1,26 @@
/**
* 详细数据表格 - 纯 Ant Design 黑金主题
* 详细数据表格 - 黑金主题
* 优化:斑马纹、等宽字体、首列高亮、重要行强调、预测列区分
*/
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: '#1A202C',
colorBgContainer: 'transparent',
colorBgElevated: '#1a1a2e',
colorBorder: 'rgba(212, 175, 55, 0.3)',
colorText: '#e0e0e0',
@@ -22,69 +30,103 @@ const BLACK_GOLD_THEME = {
},
components: {
Table: {
headerBg: 'rgba(212, 175, 55, 0.1)',
headerBg: 'rgba(212, 175, 55, 0.12)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.05)',
rowHoverBg: 'rgba(212, 175, 55, 0.08)',
borderColor: 'rgba(212, 175, 55, 0.2)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
cellPaddingBlock: 12, // 增加行高
cellPaddingInline: 14,
},
},
};
// 表格样式
// 表格样式 - 斑马纹、等宽字体、预测列区分
const tableStyles = `
.forecast-detail-table {
background: #1A202C;
border: 1px solid rgba(212, 175, 55, 0.3);
border-radius: 6px;
overflow: hidden;
}
.forecast-detail-table .table-header {
padding: 12px 16px;
border-bottom: 1px solid rgba(212, 175, 55, 0.3);
background: rgba(212, 175, 55, 0.1);
}
.forecast-detail-table .table-header h4 {
margin: 0;
color: #D4AF37;
font-size: 14px;
font-weight: 600;
}
.forecast-detail-table .table-body {
padding: 16px;
}
/* 固定列背景 */
.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.95) !important;
}
.forecast-detail-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
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 .ant-table-tbody > tr > td {
background: #1A202C !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> = [
@@ -94,54 +136,83 @@ const DetailTable: React.FC<DetailTableProps> = ({ data }) => {
key: '指标',
fixed: 'left',
width: 160,
render: (value: string) => (
<Tag className="metric-tag">{value}</Tag>
render: (value: string, record: TableRowData) => (
<Tag className={`metric-tag ${record.isImportant ? 'important' : ''}`}>
{value}
</Tag>
),
},
];
// 添加年份列
years.forEach((year) => {
years.forEach((year, idx) => {
const isForecast = isForecastYear(year);
const isFirstForecast = idx === forecastStartIndex;
cols.push({
title: year,
title: isForecast ? `${year}` : year,
dataIndex: year,
key: year,
align: 'right',
width: 100,
render: (value: string | number | null) => value ?? '-',
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]);
}, [years, forecastStartIndex]);
// 构建数据源
const dataSource: TableRowData[] = useMemo(() => {
return rows.map((row, idx) => ({
...row,
key: `row-${idx}`,
}));
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 (
<div className="forecast-detail-table">
<Box className="forecast-detail-table">
<style>{tableStyles}</style>
<div className="table-header">
<h4></h4>
</div>
<div className="table-body">
<ConfigProvider theme={BLACK_GOLD_THEME}>
<Table<TableRowData>
columns={columns}
dataSource={dataSource}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
bordered
/>
</ConfigProvider>
</div>
</div>
<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>
);
};

View File

@@ -1,5 +1,6 @@
/**
* EPS 趋势图
* 优化:添加行业平均参考线、预测区分、置信区间
*/
import React, { useMemo } from 'react';
@@ -8,18 +9,62 @@ 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],
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,
@@ -31,15 +76,51 @@ const EpsChart: React.FC<EpsChartProps> = ({ data }) => {
{
name: 'EPS(稀释)',
type: 'line',
data: data.eps,
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: { opacity: 0.15 },
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]);
}), [data, industryAvgEps, forecastStartIndex]);
return (
<ChartCard title="EPS 趋势">

View File

@@ -1,5 +1,6 @@
/**
* 营业收入、净利润趋势与增长率分析 - 合并图表
* 优化:历史/预测区分、Y轴配色对应、Tooltip格式化
*/
import React, { useMemo } from 'react';
@@ -13,10 +14,18 @@ interface IncomeProfitGrowthChartProps {
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: {
@@ -24,9 +33,33 @@ const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
trigger: 'axis',
axisPointer: {
type: 'cross',
crossStyle: {
color: 'rgba(212, 175, 55, 0.5)',
},
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: {
@@ -45,6 +78,10 @@ const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
...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: [
{
@@ -52,9 +89,10 @@ const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
type: 'value',
name: '金额(百万元)',
position: 'left',
nameTextStyle: { color: THEME.textSecondary },
nameTextStyle: { color: CHART_COLORS.income },
axisLine: { lineStyle: { color: CHART_COLORS.income } },
axisLabel: {
color: THEME.textSecondary,
color: CHART_COLORS.income,
formatter: (value: number) => {
if (Math.abs(value) >= 1000) {
return (value / 1000).toFixed(0) + 'k';
@@ -68,40 +106,65 @@ const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
type: 'value',
name: '增长率(%)',
position: 'right',
nameTextStyle: { color: THEME.textSecondary },
nameTextStyle: { color: CHART_COLORS.growth },
axisLine: { lineStyle: { color: CHART_COLORS.growth } },
axisLabel: {
color: THEME.textSecondary,
color: CHART_COLORS.growth,
formatter: '{value}%',
},
splitLine: {
show: false,
},
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,
itemStyle: {
color: CHART_COLORS.income,
},
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 },
lineStyle: {
width: 2,
color: CHART_COLORS.profit,
},
itemStyle: { color: CHART_COLORS.profit },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
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)' },
@@ -115,11 +178,8 @@ const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
yAxisIndex: 1,
data: growthData.revenue_growth_pct,
smooth: true,
lineStyle: { width: 2, type: 'dashed', color: '#10B981' },
itemStyle: {
color: (params: { value: number }) =>
params.value >= 0 ? THEME.positive : THEME.negative,
},
lineStyle: { width: 2, type: 'dashed', color: CHART_COLORS.growth },
itemStyle: { color: CHART_COLORS.growth },
label: {
show: true,
position: 'top',
@@ -132,7 +192,7 @@ const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
},
},
],
}), [incomeProfitData, growthData]);
}), [incomeProfitData, growthData, forecastStartIndex]);
return (
<ChartCard title="营收与利润趋势 · 增长率">

View File

@@ -1,5 +1,6 @@
/**
* PE 与 PEG 分析图
* 优化配色区分度、线条样式、Y轴颜色对应、预测区分
*/
import React, { useMemo } from 'react';
@@ -8,35 +9,75 @@ 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: THEME.textSecondary },
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: THEME.textSecondary },
nameTextStyle: { color: CHART_COLORS.peg },
axisLine: { lineStyle: { color: CHART_COLORS.peg } },
axisLabel: { color: CHART_COLORS.peg },
splitLine: { show: false },
},
],
series: [
@@ -45,7 +86,29 @@ const PePegChart: React.FC<PePegChartProps> = ({ data }) => {
type: 'line',
data: data.pe,
smooth: true,
lineStyle: { width: 2 },
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',
@@ -53,10 +116,32 @@ const PePegChart: React.FC<PePegChartProps> = ({ data }) => {
yAxisIndex: 1,
data: data.peg,
smooth: true,
lineStyle: { width: 2 },
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]);
}), [data, forecastStartIndex]);
return (
<ChartCard title="PE 与 PEG 分析">

View File

@@ -12,16 +12,19 @@ export const THEME = {
textSecondary: '#A0AEC0',
positive: '#E53E3E',
negative: '#10B981',
// 预测区域背景色
forecastBg: 'rgba(212, 175, 55, 0.08)',
};
// 图表配色方案
// 图表配色方案 - 优化对比度
export const CHART_COLORS = {
income: '#D4AF37', // 收入 - 金色
profit: '#F6AD55', // 利润 - 橙金色
growth: '#B8860B', // 增长 - 深金
growth: '#10B981', // 增长 - 翠绿
eps: '#DAA520', // EPS - 金菊色
epsAvg: '#4A5568', // EPS行业平均 - 灰色
pe: '#D4AF37', // PE - 金色
peg: '#CD853F', // PEG - 秘鲁色
peg: '#38B2AC', // PEG - 青色(优化对比度)
};
// ECharts 基础配置(黑金主题)
@@ -31,11 +34,18 @@ export const BASE_CHART_CONFIG = {
color: THEME.text,
},
tooltip: {
backgroundColor: 'rgba(26, 32, 44, 0.95)',
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: {

View File

@@ -3,15 +3,15 @@
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, SimpleGrid, Skeleton } from '@chakra-ui/react';
import { Box, SimpleGrid } from '@chakra-ui/react';
import { stockService } from '@services/eventService';
import {
IncomeProfitGrowthChart,
EpsChart,
PePegChart,
DetailTable,
ChartCard,
} from './components';
import LoadingState from '../LoadingState';
import { CHART_HEIGHT } from './constants';
import type { ForecastReportProps, ForecastData } from './types';
@@ -49,15 +49,9 @@ const ForecastReport: React.FC<ForecastReportProps> = ({ stockCode: propStockCod
return (
<Box>
{/* 加载骨架屏 */}
{/* 加载状态 */}
{loading && !data && (
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}>
{[1, 2, 3].map((i) => (
<ChartCard key={i} title="加载中...">
<Skeleton height={`${CHART_HEIGHT}px`} />
</ChartCard>
))}
</SimpleGrid>
<LoadingState message="加载盈利预测数据中..." height="300px" />
)}
{/* 图表区域 - 3列布局 */}

View 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;

View File

@@ -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}

View File

@@ -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;
}

View File

@@ -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(() => {

View File

@@ -0,0 +1,6 @@
/**
* StockQuoteCard Hooks 导出索引
*/
export { useStockQuoteData } from './useStockQuoteData';
export { useStockCompare } from './useStockCompare';

View File

@@ -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;

View File

@@ -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;

View File

@@ -3,6 +3,8 @@
*
* 展示股票的实时行情、关键指标和主力动态
* 采用原子组件拆分,提高可维护性和复用性
*
* 优化数据获取已下沉到组件内部Props 从 11 个精简为 4 个
*/
import React from 'react';
@@ -26,42 +28,46 @@ import {
StockCompareModal,
STOCK_CARD_THEME,
} from './components';
import { useStockQuoteData, useStockCompare } from './hooks';
import type { StockQuoteCardProps } from './types';
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 handleCompare = (stockCode: string) => {
onCompare?.(stockCode);
const handleCompare = (compareCode: string) => {
triggerCompare(compareCode);
openCompareModal();
};
// 处理关闭对比弹窗
const handleCloseCompare = () => {
closeCompareModal();
onCloseCompare?.();
clearCompare();
};
const { cardBg, borderColor } = STOCK_CARD_THEME;
// 加载中或无数据时显示骨架屏
if (isLoading || !data) {
if (isLoading || !quoteData) {
return (
<Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}>
<CardBody>
@@ -80,16 +86,15 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
<CardBody>
{/* 顶部:股票名称 + 关注/分享按钮 + 更新时间 */}
<StockHeader
name={data.name}
code={data.code}
industryL1={data.industryL1}
industry={data.industry}
indexTags={data.indexTags}
updateTime={data.updateTime}
name={quoteData.name}
code={quoteData.code}
industryL1={quoteData.industryL1}
industry={quoteData.industry}
indexTags={quoteData.indexTags}
updateTime={quoteData.updateTime}
isInWatchlist={isInWatchlist}
isWatchlistLoading={isWatchlistLoading}
onWatchlistToggle={onWatchlistToggle}
onShare={onShare}
isCompareLoading={isCompareLoading}
onCompare={handleCompare}
/>
@@ -98,7 +103,7 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
<StockCompareModal
isOpen={isCompareModalOpen}
onClose={handleCloseCompare}
currentStock={data.code}
currentStock={quoteData.code}
currentStockInfo={currentStockInfo || null}
compareStock={compareStockInfo?.stock_code || ''}
compareStockInfo={compareStockInfo || null}
@@ -110,32 +115,32 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
{/* 左栏:价格信息 (flex=1) */}
<Box flex="1" minWidth="0">
<PriceDisplay
currentPrice={data.currentPrice}
changePercent={data.changePercent}
currentPrice={quoteData.currentPrice}
changePercent={quoteData.changePercent}
/>
<SecondaryQuote
todayOpen={data.todayOpen}
yesterdayClose={data.yesterdayClose}
todayHigh={data.todayHigh}
todayLow={data.todayLow}
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}>
<KeyMetrics
pe={data.pe}
eps={data.eps}
pb={data.pb}
marketCap={data.marketCap}
week52Low={data.week52Low}
week52High={data.week52High}
pe={quoteData.pe}
eps={quoteData.eps}
pb={quoteData.pb}
marketCap={quoteData.marketCap}
week52Low={quoteData.week52Low}
week52High={quoteData.week52High}
/>
<MainForceInfo
mainNetInflow={data.mainNetInflow}
institutionHolding={data.institutionHolding}
buyRatio={data.buyRatio}
sellRatio={data.sellRatio}
mainNetInflow={quoteData.mainNetInflow}
institutionHolding={quoteData.institutionHolding}
buyRatio={quoteData.buyRatio}
sellRatio={quoteData.sellRatio}
/>
</Flex>
</Flex>

View File

@@ -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,
};

View File

@@ -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';
/**
* 股票行情卡片数据
@@ -46,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 };

View File

@@ -1,103 +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,
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(),
};
};
/**
* 股票行情数据获取 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;

View 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;

View File

@@ -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 切换区域:概览、行情、财务、预测 */}