Compare commits
18 Commits
ac7e627b2d
...
feature_20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a446f71c04 | ||
|
|
e02cbcd9b7 | ||
|
|
9bb9eab922 | ||
|
|
3d7b0045b7 | ||
|
|
ada9f6e778 | ||
|
|
07aebbece5 | ||
|
|
7a11800cba | ||
|
|
3b352be1a8 | ||
|
|
c49dee72eb | ||
|
|
7159e510a6 | ||
|
|
385d452f5a | ||
|
|
bdc823e122 | ||
|
|
c83d239219 | ||
|
|
c4900bd280 | ||
|
|
7736212235 | ||
|
|
348d8a0ec3 | ||
|
|
5a0d6e1569 | ||
|
|
bc2b6ae41c |
110
.husky/pre-commit
Executable file
110
.husky/pre-commit
Executable file
@@ -0,0 +1,110 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Git Pre-commit Hook
|
||||||
|
# ============================================
|
||||||
|
# 规则:
|
||||||
|
# 1. src 目录下新增的代码文件必须使用 TypeScript (.ts/.tsx)
|
||||||
|
# 2. 修改的代码不能使用 fetch,应使用 axios
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 颜色定义
|
||||||
|
RED='\033[0;31m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
has_error=0
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🔍 正在检查代码规范..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 规则 1: 新文件必须使用 TypeScript
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 获取新增的文件(只检查 src 目录下的代码文件)
|
||||||
|
new_js_files=$(git diff --cached --name-only --diff-filter=A | grep -E '^src/.*\.(js|jsx)$' || true)
|
||||||
|
|
||||||
|
if [ -n "$new_js_files" ]; then
|
||||||
|
echo "${RED}❌ 错误: 发现新增的 JavaScript 文件${NC}"
|
||||||
|
echo "${YELLOW} 新文件必须使用 TypeScript (.ts/.tsx)${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " 以下文件需要改为 TypeScript:"
|
||||||
|
echo "$new_js_files" | while read file; do
|
||||||
|
echo " - $file"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo " 💡 提示: 请将文件扩展名改为 .ts 或 .tsx"
|
||||||
|
echo ""
|
||||||
|
has_error=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 规则 2: 禁止使用 fetch,应使用 axios
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 获取所有暂存的文件(新增 + 修改)
|
||||||
|
staged_files=$(git diff --cached --name-only --diff-filter=AM | grep -E '^src/.*\.(js|jsx|ts|tsx)$' || true)
|
||||||
|
|
||||||
|
if [ -n "$staged_files" ]; then
|
||||||
|
# 检查暂存内容中是否包含 fetch 调用
|
||||||
|
# 使用 git diff --cached 检查实际修改的内容
|
||||||
|
fetch_found=""
|
||||||
|
|
||||||
|
for file in $staged_files; do
|
||||||
|
# 检查该文件暂存的更改中是否有 fetch 调用
|
||||||
|
# 排除注释和字符串中的 fetch
|
||||||
|
# 匹配: fetch(, await fetch, .fetch(
|
||||||
|
fetch_matches=$(git diff --cached -U0 "$file" 2>/dev/null | grep -E '^\+.*[^a-zA-Z_]fetch\s*\(' | grep -v '^\+\s*//' || true)
|
||||||
|
|
||||||
|
if [ -n "$fetch_matches" ]; then
|
||||||
|
fetch_found="$fetch_found
|
||||||
|
$file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$fetch_found" ]; then
|
||||||
|
echo "${RED}❌ 错误: 检测到使用了 fetch API${NC}"
|
||||||
|
echo "${YELLOW} 请使用 axios 进行 HTTP 请求${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " 以下文件包含 fetch 调用:"
|
||||||
|
echo "$fetch_found" | while read file; do
|
||||||
|
if [ -n "$file" ]; then
|
||||||
|
echo " - $file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo " 💡 修改建议:"
|
||||||
|
echo " ${GREEN}// 替换前${NC}"
|
||||||
|
echo " fetch('/api/data').then(res => res.json())"
|
||||||
|
echo ""
|
||||||
|
echo " ${GREEN}// 替换后${NC}"
|
||||||
|
echo " import axios from 'axios';"
|
||||||
|
echo " axios.get('/api/data').then(res => res.data)"
|
||||||
|
echo ""
|
||||||
|
has_error=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 检查结果
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
if [ $has_error -eq 1 ]; then
|
||||||
|
echo "${RED}========================================${NC}"
|
||||||
|
echo "${RED}提交被阻止,请修复以上问题后重试${NC}"
|
||||||
|
echo "${RED}========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "${GREEN}✅ 代码规范检查通过${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 运行 lint-staged(如果配置了)
|
||||||
|
# 可选:在 package.json 中添加 "lint-staged" 配置来启用代码格式化
|
||||||
|
# if [ -f "package.json" ] && grep -q '"lint-staged"' package.json; then
|
||||||
|
# npx lint-staged
|
||||||
|
# fi
|
||||||
@@ -131,12 +131,14 @@
|
|||||||
"eslint-plugin-prettier": "3.4.0",
|
"eslint-plugin-prettier": "3.4.0",
|
||||||
"gulp": "4.0.2",
|
"gulp": "4.0.2",
|
||||||
"gulp-append-prepend": "1.0.9",
|
"gulp-append-prepend": "1.0.9",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"imagemin": "^9.0.1",
|
"imagemin": "^9.0.1",
|
||||||
"imagemin-mozjpeg": "^10.0.0",
|
"imagemin-mozjpeg": "^10.0.0",
|
||||||
"imagemin-pngquant": "^10.0.0",
|
"imagemin-pngquant": "^10.0.0",
|
||||||
"kill-port": "^2.0.1",
|
"kill-port": "^2.0.1",
|
||||||
"less": "^4.4.2",
|
"less": "^4.4.2",
|
||||||
"less-loader": "^12.3.0",
|
"less-loader": "^12.3.0",
|
||||||
|
"lint-staged": "^16.2.7",
|
||||||
"msw": "^2.11.5",
|
"msw": "^2.11.5",
|
||||||
"prettier": "2.2.1",
|
"prettier": "2.2.1",
|
||||||
"react-error-overlay": "6.0.9",
|
"react-error-overlay": "6.0.9",
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
|||||||
// 当前索引
|
// 当前索引
|
||||||
const currentIndex = controlledIndex ?? internalIndex;
|
const currentIndex = controlledIndex ?? internalIndex;
|
||||||
|
|
||||||
|
// 记录已访问的 Tab 索引(用于真正的懒加载)
|
||||||
|
const [visitedTabs, setVisitedTabs] = useState<Set<number>>(
|
||||||
|
() => new Set([controlledIndex ?? defaultIndex])
|
||||||
|
);
|
||||||
|
|
||||||
// 合并主题
|
// 合并主题
|
||||||
const theme: SubTabTheme = {
|
const theme: SubTabTheme = {
|
||||||
...THEME_PRESETS[themePreset],
|
...THEME_PRESETS[themePreset],
|
||||||
@@ -132,6 +137,12 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
|||||||
const tabKey = tabs[newIndex]?.key || '';
|
const tabKey = tabs[newIndex]?.key || '';
|
||||||
onTabChange?.(newIndex, tabKey);
|
onTabChange?.(newIndex, tabKey);
|
||||||
|
|
||||||
|
// 记录已访问的 Tab(用于懒加载)
|
||||||
|
setVisitedTabs(prev => {
|
||||||
|
if (prev.has(newIndex)) return prev;
|
||||||
|
return new Set(prev).add(newIndex);
|
||||||
|
});
|
||||||
|
|
||||||
if (controlledIndex === undefined) {
|
if (controlledIndex === undefined) {
|
||||||
setInternalIndex(newIndex);
|
setInternalIndex(newIndex);
|
||||||
}
|
}
|
||||||
@@ -197,11 +208,16 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
|||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
<TabPanels p={contentPadding}>
|
<TabPanels p={contentPadding}>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab, idx) => {
|
||||||
const Component = tab.component;
|
const Component = tab.component;
|
||||||
|
// 懒加载:只渲染已访问过的 Tab
|
||||||
|
const shouldRender = !isLazy || visitedTabs.has(idx);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabPanel key={tab.key} p={0}>
|
<TabPanel key={tab.key} p={0}>
|
||||||
{Component ? <Component {...componentProps} /> : null}
|
{shouldRender && Component ? (
|
||||||
|
<Component {...componentProps} />
|
||||||
|
) : null}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -661,6 +661,12 @@ export const NotificationProvider = ({ children }) => {
|
|||||||
|
|
||||||
// ========== 连接到 Socket 服务(⚡ 异步初始化,不阻塞首屏) ==========
|
// ========== 连接到 Socket 服务(⚡ 异步初始化,不阻塞首屏) ==========
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// ⚡ Mock 模式下跳过 Socket 连接(避免连接生产服务器失败的错误)
|
||||||
|
if (process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||||
|
logger.debug('NotificationContext', 'Mock 模式,跳过 Socket 连接');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ⚡ 防止 React Strict Mode 导致的重复初始化
|
// ⚡ 防止 React Strict Mode 导致的重复初始化
|
||||||
if (socketInitialized) {
|
if (socketInitialized) {
|
||||||
logger.debug('NotificationContext', 'Socket 已初始化,跳过重复执行(Strict Mode 保护)');
|
logger.debug('NotificationContext', 'Socket 已初始化,跳过重复执行(Strict Mode 保护)');
|
||||||
|
|||||||
11
src/index.js
11
src/index.js
@@ -5,6 +5,17 @@ import { BrowserRouter as Router } from 'react-router-dom';
|
|||||||
|
|
||||||
// ⚡ 性能监控:在应用启动时尽早标记
|
// ⚡ 性能监控:在应用启动时尽早标记
|
||||||
import { performanceMonitor } from './utils/performanceMonitor';
|
import { performanceMonitor } from './utils/performanceMonitor';
|
||||||
|
|
||||||
|
// T0: HTML 加载完成时间点
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
performanceMonitor.mark('html-loaded');
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
performanceMonitor.mark('html-loaded');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// T1: React 开始初始化
|
||||||
performanceMonitor.mark('app-start');
|
performanceMonitor.mark('app-start');
|
||||||
|
|
||||||
// ⚡ 已删除 brainwave.css(项目未安装 Tailwind CSS,该文件无效)
|
// ⚡ 已删除 brainwave.css(项目未安装 Tailwind CSS,该文件无效)
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
// src/mocks/handlers/bytedesk.js
|
// src/mocks/handlers/bytedesk.js
|
||||||
/**
|
/**
|
||||||
* Bytedesk 客服 Widget MSW Handler
|
* Bytedesk 客服 Widget MSW Handler
|
||||||
* 使用 passthrough 让请求通过到真实服务器,消除 MSW 警告
|
* Mock 模式下返回模拟数据
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { http, passthrough } from 'msw';
|
import { http, HttpResponse, passthrough } from 'msw';
|
||||||
|
|
||||||
export const bytedeskHandlers = [
|
export const bytedeskHandlers = [
|
||||||
// Bytedesk API 请求 - 直接 passthrough
|
// 未读消息数量
|
||||||
// 匹配 /bytedesk/* 路径(通过代理访问后端)
|
http.get('/bytedesk/visitor/api/v1/message/unread/count', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: { count: 0 },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 其他 Bytedesk API - 返回通用成功响应
|
||||||
http.all('/bytedesk/*', () => {
|
http.all('/bytedesk/*', () => {
|
||||||
return passthrough();
|
return HttpResponse.json({
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Bytedesk 外部 CDN/服务请求
|
// Bytedesk 外部 CDN/服务请求
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// 性能监控工具 - 统计白屏时间和性能指标
|
// 性能监控工具 - 统计白屏时间和性能指标
|
||||||
|
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
import { reportPerformanceMetrics } from '../lib/posthog';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 性能指标接口
|
* 性能指标接口
|
||||||
@@ -208,6 +209,9 @@ class PerformanceMonitor {
|
|||||||
// 性能分析建议
|
// 性能分析建议
|
||||||
this.analyzePerformance();
|
this.analyzePerformance();
|
||||||
|
|
||||||
|
// 上报性能指标到 PostHog
|
||||||
|
reportPerformanceMetrics(this.metrics);
|
||||||
|
|
||||||
return this.metrics;
|
return this.metrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Company 目录结构说明
|
# Company 目录结构说明
|
||||||
|
|
||||||
> 最后更新:2025-12-16
|
> 最后更新:2025-12-17(API 接口清单梳理)
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
@@ -11,24 +11,40 @@ src/views/Company/
|
|||||||
│
|
│
|
||||||
├── components/ # UI 组件
|
├── components/ # UI 组件
|
||||||
│ │
|
│ │
|
||||||
|
│ ├── LoadingState.tsx # 通用加载状态组件
|
||||||
|
│ │
|
||||||
│ ├── CompanyHeader/ # 页面头部
|
│ ├── CompanyHeader/ # 页面头部
|
||||||
│ │ ├── index.js # 组合导出
|
│ │ ├── index.js # 组合导出
|
||||||
│ │ └── SearchBar.js # 股票搜索栏
|
│ │ └── SearchBar.js # 股票搜索栏
|
||||||
│ │
|
│ │
|
||||||
│ ├── CompanyTabs/ # Tab 切换容器
|
│ ├── CompanyTabs/ # Tab 切换容器
|
||||||
│ │ ├── index.js # Tab 容器(状态管理 + 内容渲染)
|
│ │ └── index.js # Tab 容器(状态管理 + 内容渲染)
|
||||||
│ │ └── TabNavigation.js # Tab 导航栏
|
|
||||||
│ │
|
│ │
|
||||||
│ ├── StockQuoteCard/ # 股票行情卡片(TypeScript)
|
│ ├── StockQuoteCard/ # 股票行情卡片(TypeScript,数据已下沉)
|
||||||
│ │ ├── index.tsx # 主组件
|
│ │ ├── index.tsx # 主组件(Props 从 11 个精简为 4 个)
|
||||||
│ │ ├── types.ts # 类型定义
|
│ │ ├── types.ts # 类型定义
|
||||||
│ │ └── mockData.ts # Mock 数据
|
│ │ ├── mockData.ts # Mock 数据
|
||||||
|
│ │ ├── hooks/ # 内部数据 Hooks(2025-12-17 新增)
|
||||||
|
│ │ │ ├── index.ts # hooks 导出索引
|
||||||
|
│ │ │ ├── useStockQuoteData.ts # 行情数据+基本信息获取
|
||||||
|
│ │ │ └── useStockCompare.ts # 股票对比逻辑
|
||||||
|
│ │ └── components/ # 子组件
|
||||||
|
│ │ ├── index.ts # 组件导出
|
||||||
|
│ │ ├── theme.ts # 主题配置
|
||||||
|
│ │ ├── formatters.ts # 格式化工具
|
||||||
|
│ │ ├── StockHeader.tsx # 股票头部(名称、代码、收藏按钮)
|
||||||
|
│ │ ├── PriceDisplay.tsx # 价格显示组件
|
||||||
|
│ │ ├── CompanyInfo.tsx # 公司信息(行业、市值等)
|
||||||
|
│ │ ├── KeyMetrics.tsx # 关键指标(PE、PB、换手率等)
|
||||||
|
│ │ ├── MainForceInfo.tsx # 主力资金信息
|
||||||
|
│ │ ├── SecondaryQuote.tsx # 副行情(对比股票)
|
||||||
|
│ │ ├── CompareStockInput.tsx # 对比股票输入
|
||||||
|
│ │ └── StockCompareModal.tsx # 股票对比弹窗
|
||||||
│ │
|
│ │
|
||||||
│ ├── CompanyOverview/ # Tab: 公司概览(TypeScript)
|
│ ├── CompanyOverview/ # Tab: 公司概览(TypeScript)
|
||||||
│ │ ├── index.tsx # 主组件(组合层)
|
│ │ ├── index.tsx # 主组件(组合层)
|
||||||
│ │ ├── types.ts # 类型定义
|
│ │ ├── types.ts # 类型定义
|
||||||
│ │ ├── utils.ts # 格式化工具
|
│ │ ├── utils.ts # 格式化工具
|
||||||
│ │ ├── DeepAnalysisTab/ # 深度分析 Tab(21 个 TS 文件)
|
|
||||||
│ │ ├── NewsEventsTab.js # 新闻事件 Tab
|
│ │ ├── NewsEventsTab.js # 新闻事件 Tab
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── hooks/ # 数据 Hooks
|
│ │ ├── hooks/ # 数据 Hooks
|
||||||
@@ -47,29 +63,69 @@ src/views/Company/
|
|||||||
│ │ │ ├── ConcentrationCard.tsx # 股权集中度卡片
|
│ │ │ ├── ConcentrationCard.tsx # 股权集中度卡片
|
||||||
│ │ │ └── ShareholdersTable.tsx # 股东表格
|
│ │ │ └── ShareholdersTable.tsx # 股东表格
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ └── BasicInfoTab/ # 基本信息 Tab(可配置化)
|
│ │ ├── BasicInfoTab/ # 基本信息 Tab(可配置化)
|
||||||
│ │ ├── index.tsx # 主组件(可配置)
|
│ │ │ ├── index.tsx # 主组件(可配置)
|
||||||
│ │ ├── config.ts # Tab 配置 + 黑金主题
|
│ │ │ ├── config.ts # Tab 配置 + 黑金主题
|
||||||
│ │ ├── utils.ts # 格式化工具函数
|
│ │ │ ├── utils.ts # 格式化工具函数
|
||||||
│ │ └── components/ # 子组件
|
│ │ │ └── components/ # 子组件
|
||||||
│ │ ├── index.ts # 组件统一导出
|
│ │ │ ├── index.ts # 组件统一导出
|
||||||
│ │ ├── LoadingState.tsx # 加载状态组件
|
│ │ │ ├── LoadingState.tsx # 加载状态组件
|
||||||
│ │ ├── ShareholderPanel.tsx # 股权结构面板
|
│ │ │ ├── ShareholderPanel.tsx # 股权结构面板
|
||||||
│ │ ├── AnnouncementsPanel.tsx # 公告信息面板
|
│ │ │ ├── AnnouncementsPanel.tsx # 公告信息面板
|
||||||
│ │ ├── BranchesPanel.tsx # 分支机构面板
|
│ │ │ ├── BranchesPanel.tsx # 分支机构面板
|
||||||
│ │ ├── BusinessInfoPanel.tsx # 工商信息面板
|
│ │ │ ├── BusinessInfoPanel.tsx # 工商信息面板
|
||||||
│ │ ├── DisclosureSchedulePanel.tsx # 披露日程面板
|
│ │ │ ├── DisclosureSchedulePanel.tsx # 披露日程面板
|
||||||
│ │ └── management/ # 管理团队模块
|
│ │ │ └── management/ # 管理团队模块
|
||||||
│ │ ├── index.ts # 模块导出
|
│ │ │ ├── index.ts # 模块导出
|
||||||
│ │ ├── types.ts # 类型定义
|
│ │ │ ├── types.ts # 类型定义
|
||||||
│ │ ├── ManagementPanel.tsx # 主组件(useMemo)
|
│ │ │ ├── ManagementPanel.tsx # 主组件(useMemo)
|
||||||
│ │ ├── CategorySection.tsx # 分类区块(memo)
|
│ │ │ ├── CategorySection.tsx # 分类区块(memo)
|
||||||
│ │ └── ManagementCard.tsx # 人员卡片(memo)
|
│ │ │ └── ManagementCard.tsx # 人员卡片(memo)
|
||||||
|
│ │ │
|
||||||
|
│ │ └── DeepAnalysisTab/ # 深度分析 Tab(原子设计模式)
|
||||||
|
│ │ ├── index.tsx # 主入口组件
|
||||||
|
│ │ ├── types.ts # 类型定义
|
||||||
|
│ │ ├── atoms/ # 原子组件
|
||||||
|
│ │ │ ├── index.ts
|
||||||
|
│ │ │ ├── DisclaimerBox.tsx # 免责声明
|
||||||
|
│ │ │ ├── ScoreBar.tsx # 评分进度条
|
||||||
|
│ │ │ ├── BusinessTreeItem.tsx # 业务树形项
|
||||||
|
│ │ │ ├── KeyFactorCard.tsx # 关键因素卡片
|
||||||
|
│ │ │ ├── ProcessNavigation.tsx # 流程导航
|
||||||
|
│ │ │ └── ValueChainFilterBar.tsx # 产业链筛选栏
|
||||||
|
│ │ ├── components/ # Card 组件
|
||||||
|
│ │ │ ├── index.ts
|
||||||
|
│ │ │ ├── CorePositioningCard/ # 核心定位卡片(含 atoms)
|
||||||
|
│ │ │ │ ├── index.tsx
|
||||||
|
│ │ │ │ ├── theme.ts
|
||||||
|
│ │ │ │ └── atoms/
|
||||||
|
│ │ │ ├── CompetitiveAnalysisCard.tsx
|
||||||
|
│ │ │ ├── BusinessStructureCard.tsx
|
||||||
|
│ │ │ ├── BusinessSegmentsCard.tsx
|
||||||
|
│ │ │ ├── ValueChainCard.tsx
|
||||||
|
│ │ │ ├── KeyFactorsCard.tsx
|
||||||
|
│ │ │ ├── TimelineCard.tsx
|
||||||
|
│ │ │ └── StrategyAnalysisCard.tsx
|
||||||
|
│ │ ├── organisms/ # 复杂交互组件
|
||||||
|
│ │ │ ├── ValueChainNodeCard/
|
||||||
|
│ │ │ │ ├── index.tsx
|
||||||
|
│ │ │ │ └── RelatedCompaniesModal.tsx
|
||||||
|
│ │ │ └── TimelineComponent/
|
||||||
|
│ │ │ ├── index.tsx
|
||||||
|
│ │ │ └── EventDetailModal.tsx
|
||||||
|
│ │ ├── tabs/ # Tab 面板
|
||||||
|
│ │ │ ├── index.ts
|
||||||
|
│ │ │ ├── BusinessTab.tsx
|
||||||
|
│ │ │ ├── DevelopmentTab.tsx
|
||||||
|
│ │ │ ├── StrategyTab.tsx
|
||||||
|
│ │ │ └── ValueChainTab.tsx
|
||||||
|
│ │ └── utils/
|
||||||
|
│ │ └── chartOptions.ts
|
||||||
│ │
|
│ │
|
||||||
│ ├── MarketDataView/ # Tab: 股票行情(TypeScript)
|
│ ├── MarketDataView/ # Tab: 股票行情(TypeScript)
|
||||||
│ │ ├── index.tsx # 主组件入口(~285 行,Tab 容器)
|
│ │ ├── index.tsx # 主组件入口
|
||||||
│ │ ├── types.ts # 类型定义
|
│ │ ├── types.ts # 类型定义
|
||||||
│ │ ├── constants.ts # 主题配置、常量(含黑金主题 darkGoldTheme)
|
│ │ ├── constants.ts # 主题配置(含黑金主题 darkGoldTheme)
|
||||||
│ │ ├── services/
|
│ │ ├── services/
|
||||||
│ │ │ └── marketService.ts # API 服务层
|
│ │ │ └── marketService.ts # API 服务层
|
||||||
│ │ ├── hooks/
|
│ │ ├── hooks/
|
||||||
@@ -83,71 +139,90 @@ src/views/Company/
|
|||||||
│ │ ├── MarkdownRenderer.tsx # Markdown 渲染
|
│ │ ├── MarkdownRenderer.tsx # Markdown 渲染
|
||||||
│ │ ├── AnalysisModal.tsx # 涨幅分析模态框
|
│ │ ├── AnalysisModal.tsx # 涨幅分析模态框
|
||||||
│ │ ├── StockSummaryCard/ # 股票概览卡片(黑金主题 4 列布局)
|
│ │ ├── StockSummaryCard/ # 股票概览卡片(黑金主题 4 列布局)
|
||||||
│ │ │ ├── index.tsx # 主组件(4 列 SimpleGrid 布局)
|
│ │ │ ├── index.tsx
|
||||||
│ │ │ ├── StockHeaderCard.tsx # 股票信息卡片(名称、价格、涨跌幅)
|
│ │ │ ├── StockHeaderCard.tsx
|
||||||
│ │ │ ├── MetricCard.tsx # 指标卡片模板
|
│ │ │ ├── MetricCard.tsx
|
||||||
│ │ │ ├── utils.ts # 状态计算工具函数
|
│ │ │ ├── utils.ts
|
||||||
│ │ │ └── atoms/ # 原子组件
|
│ │ │ └── atoms/
|
||||||
│ │ │ ├── index.ts # 原子组件导出
|
│ │ │ ├── index.ts
|
||||||
│ │ │ ├── DarkGoldCard.tsx # 黑金主题卡片容器
|
│ │ │ ├── DarkGoldCard.tsx
|
||||||
│ │ │ ├── CardTitle.tsx # 卡片标题(图标+标题+副标题)
|
│ │ │ ├── CardTitle.tsx
|
||||||
│ │ │ ├── MetricValue.tsx # 核心数值展示
|
│ │ │ ├── MetricValue.tsx
|
||||||
│ │ │ ├── PriceDisplay.tsx # 价格显示(价格+涨跌箭头)
|
│ │ │ ├── PriceDisplay.tsx
|
||||||
│ │ │ └── StatusTag.tsx # 状态标签(活跃/健康等)
|
│ │ │ └── StatusTag.tsx
|
||||||
│ │ └── panels/ # Tab 面板组件
|
│ │ └── panels/ # Tab 面板组件
|
||||||
│ │ ├── index.ts # 面板组件统一导出
|
│ │ ├── index.ts
|
||||||
│ │ ├── TradeDataPanel/ # 交易数据面板(原子设计模式)
|
│ │ ├── TradeDataPanel/
|
||||||
│ │ │ ├── index.tsx # 主入口组件(~50 行)
|
│ │ │ ├── index.tsx
|
||||||
│ │ │ ├── KLineChart.tsx # 日K线图组件(~40 行)
|
│ │ │ └── KLineModule.tsx
|
||||||
│ │ │ ├── MinuteKLineSection.tsx # 分钟K线区域(~95 行)
|
│ │ ├── FundingPanel.tsx
|
||||||
│ │ │ ├── TradeTable.tsx # 交易明细表格(~75 行)
|
│ │ ├── BigDealPanel.tsx
|
||||||
│ │ │ └── atoms/ # 原子组件
|
│ │ ├── UnusualPanel.tsx
|
||||||
│ │ │ ├── index.ts # 统一导出
|
│ │ └── PledgePanel.tsx
|
||||||
│ │ │ ├── MinuteStats.tsx # 分钟数据统计(~80 行)
|
|
||||||
│ │ │ ├── TradeAnalysis.tsx # 成交分析(~65 行)
|
|
||||||
│ │ │ └── EmptyState.tsx # 空状态组件(~35 行)
|
|
||||||
│ │ ├── FundingPanel.tsx # 融资融券面板
|
|
||||||
│ │ ├── BigDealPanel.tsx # 大宗交易面板
|
|
||||||
│ │ ├── UnusualPanel.tsx # 龙虎榜面板
|
|
||||||
│ │ └── PledgePanel.tsx # 股权质押面板
|
|
||||||
│ │
|
│ │
|
||||||
│ ├── DeepAnalysis/ # Tab: 深度分析
|
│ ├── DeepAnalysis/ # Tab: 深度分析(入口)
|
||||||
│ │ └── index.js
|
│ │ └── index.js
|
||||||
│ │
|
│ │
|
||||||
│ ├── DynamicTracking/ # Tab: 动态跟踪
|
│ ├── DynamicTracking/ # Tab: 动态跟踪
|
||||||
│ │ └── index.js
|
│ │ ├── index.js # 主组件
|
||||||
|
│ │ └── components/
|
||||||
|
│ │ ├── index.js # 组件导出
|
||||||
|
│ │ ├── NewsPanel.js # 新闻面板
|
||||||
|
│ │ └── ForecastPanel.js # 业绩预告面板
|
||||||
│ │
|
│ │
|
||||||
│ ├── FinancialPanorama/ # Tab: 财务全景(TypeScript 模块化)
|
│ ├── FinancialPanorama/ # Tab: 财务全景(TypeScript 模块化)
|
||||||
│ │ ├── index.tsx # 主组件入口(~400 行)
|
│ │ ├── index.tsx # 主组件入口
|
||||||
│ │ ├── types.ts # TypeScript 类型定义
|
│ │ ├── types.ts # TypeScript 类型定义
|
||||||
│ │ ├── constants.ts # 常量配置(颜色、指标定义)
|
│ │ ├── constants.ts # 常量配置(颜色、指标定义)
|
||||||
│ │ ├── hooks/
|
│ │ ├── hooks/
|
||||||
│ │ │ ├── index.ts # Hook 统一导出
|
│ │ │ ├── index.ts
|
||||||
│ │ │ └── useFinancialData.ts # 财务数据加载 Hook
|
│ │ │ └── useFinancialData.ts
|
||||||
│ │ ├── utils/
|
│ │ ├── utils/
|
||||||
│ │ │ ├── index.ts # 工具函数统一导出
|
│ │ │ ├── index.ts
|
||||||
│ │ │ ├── calculations.ts # 计算工具(同比变化、单元格颜色)
|
│ │ │ ├── calculations.ts
|
||||||
│ │ │ └── chartOptions.ts # ECharts 图表配置生成器
|
│ │ │ └── chartOptions.ts
|
||||||
|
│ │ ├── tabs/ # Tab 面板组件
|
||||||
|
│ │ │ ├── index.ts
|
||||||
|
│ │ │ ├── BalanceSheetTab.tsx
|
||||||
|
│ │ │ ├── CashflowTab.tsx
|
||||||
|
│ │ │ ├── FinancialMetricsTab.tsx
|
||||||
|
│ │ │ ├── IncomeStatementTab.tsx
|
||||||
|
│ │ │ └── MetricsCategoryTab.tsx
|
||||||
│ │ └── components/
|
│ │ └── components/
|
||||||
│ │ ├── index.ts # 组件统一导出
|
│ │ ├── index.ts
|
||||||
│ │ ├── StockInfoHeader.tsx # 股票信息头部
|
│ │ ├── StockInfoHeader.tsx
|
||||||
│ │ ├── BalanceSheetTable.tsx # 资产负债表
|
│ │ ├── FinancialTable.tsx # 通用财务表格
|
||||||
│ │ ├── IncomeStatementTable.tsx # 利润表
|
│ │ ├── FinancialOverviewPanel.tsx # 财务概览面板
|
||||||
│ │ ├── CashflowTable.tsx # 现金流量表
|
│ │ ├── KeyMetricsOverview.tsx # 关键指标概览
|
||||||
│ │ ├── FinancialMetricsTable.tsx # 财务指标表
|
│ │ ├── PeriodSelector.tsx # 期数选择器
|
||||||
│ │ ├── MainBusinessAnalysis.tsx # 主营业务分析
|
│ │ ├── BalanceSheetTable.tsx
|
||||||
│ │ ├── IndustryRankingView.tsx # 行业排名
|
│ │ ├── IncomeStatementTable.tsx
|
||||||
│ │ ├── StockComparison.tsx # 股票对比
|
│ │ ├── CashflowTable.tsx
|
||||||
│ │ └── ComparisonAnalysis.tsx # 综合对比分析
|
│ │ ├── FinancialMetricsTable.tsx
|
||||||
|
│ │ ├── MainBusinessAnalysis.tsx
|
||||||
|
│ │ ├── IndustryRankingView.tsx
|
||||||
|
│ │ ├── StockComparison.tsx
|
||||||
|
│ │ └── ComparisonAnalysis.tsx
|
||||||
│ │
|
│ │
|
||||||
│ └── ForecastReport/ # Tab: 盈利预测(待拆分)
|
│ └── ForecastReport/ # Tab: 盈利预测(TypeScript,已模块化)
|
||||||
│ └── index.js
|
│ ├── index.tsx # 主组件入口
|
||||||
|
│ ├── types.ts # 类型定义
|
||||||
|
│ ├── constants.ts # 配色、图表配置常量
|
||||||
|
│ └── components/
|
||||||
|
│ ├── index.ts
|
||||||
|
│ ├── ChartCard.tsx # 图表卡片容器
|
||||||
|
│ ├── IncomeProfitGrowthChart.tsx # 营收与利润趋势图
|
||||||
|
│ ├── IncomeProfitChart.tsx # 营收利润图(备用)
|
||||||
|
│ ├── GrowthChart.tsx # 增长率图(备用)
|
||||||
|
│ ├── EpsChart.tsx # EPS 趋势图
|
||||||
|
│ ├── PePegChart.tsx # PE/PEG 分析图
|
||||||
|
│ └── DetailTable.tsx # 详细数据表格
|
||||||
│
|
│
|
||||||
├── hooks/ # 页面级 Hooks
|
├── hooks/ # 页面级 Hooks
|
||||||
│ ├── useCompanyStock.js # 股票代码管理(URL 同步)
|
│ ├── useCompanyStock.js # 股票代码管理(URL 同步)
|
||||||
│ ├── useCompanyWatchlist.js # 自选股管理(Redux 集成)
|
│ ├── useCompanyWatchlist.js # 自选股管理(Redux 集成)
|
||||||
│ ├── useCompanyEvents.js # PostHog 事件追踪
|
│ └── useCompanyEvents.js # PostHog 事件追踪
|
||||||
│ └── useStockQuote.js # 股票行情数据 Hook
|
│ # 注:useStockQuote.js 已下沉到 StockQuoteCard/hooks/useStockQuoteData.ts
|
||||||
│
|
│
|
||||||
└── constants/ # 常量定义
|
└── constants/ # 常量定义
|
||||||
└── index.js # Tab 配置、Toast 消息、默认值
|
└── index.js # Tab 配置、Toast 消息、默认值
|
||||||
@@ -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` - 页面入口
|
#### `index.js` - 页面入口
|
||||||
- **职责**:纯组合层,协调 Hooks 和 Components
|
- **职责**:纯组合层,协调 Hooks 和 Components
|
||||||
- **代码行数**:95 行
|
- **代码行数**:~105 行(2025-12-17 优化后精简)
|
||||||
- **依赖**:
|
- **依赖**:
|
||||||
- `useCompanyStock` - 股票代码状态
|
- `useCompanyStock` - 股票代码状态
|
||||||
- `useCompanyWatchlist` - 自选股状态
|
- `useCompanyWatchlist` - 自选股状态
|
||||||
- `useCompanyEvents` - 事件追踪
|
- `useCompanyEvents` - 事件追踪
|
||||||
- `CompanyHeader` - 页面头部
|
- `CompanyHeader` - 页面头部
|
||||||
|
- `StockQuoteCard` - 股票行情卡片(内部自行获取数据)
|
||||||
- `CompanyTabs` - Tab 切换区
|
- `CompanyTabs` - Tab 切换区
|
||||||
|
- **已移除**(2025-12-17):
|
||||||
|
- `useStockQuote` - 已下沉到 StockQuoteCard
|
||||||
|
- `useBasicInfo` - 已下沉到 StockQuoteCard
|
||||||
|
- 股票对比逻辑 - 已下沉到 StockQuoteCard
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -999,4 +1156,98 @@ index.tsx
|
|||||||
- **原子设计模式**:atoms(MinuteStats、TradeAnalysis、EmptyState)→ 业务组件(KLineChart、MinuteKLineSection、TradeTable)→ 主组件
|
- **原子设计模式**:atoms(MinuteStats、TradeAnalysis、EmptyState)→ 业务组件(KLineChart、MinuteKLineSection、TradeTable)→ 主组件
|
||||||
- **职责分离**:图表、统计、表格各自独立
|
- **职责分离**:图表、统计、表格各自独立
|
||||||
- **组件复用**:EmptyState 可在其他场景复用
|
- **组件复用**:EmptyState 可在其他场景复用
|
||||||
- **类型安全**:完整的 Props 类型定义和导出
|
- **类型安全**:完整的 Props 类型定义和导出
|
||||||
|
|
||||||
|
### 2025-12-17 StockQuoteCard 数据下沉优化
|
||||||
|
|
||||||
|
**改动概述**:
|
||||||
|
- StockQuoteCard Props 从 **11 个** 精简至 **4 个**(减少 64%)
|
||||||
|
- 行情数据、基本信息、股票对比逻辑全部下沉到组件内部
|
||||||
|
- Company/index.js 移除约 **40 行** 数据获取代码
|
||||||
|
- 删除 `Company/hooks/useStockQuote.js`
|
||||||
|
|
||||||
|
**创建的文件**:
|
||||||
|
```
|
||||||
|
StockQuoteCard/hooks/
|
||||||
|
├── index.ts # hooks 导出索引
|
||||||
|
├── useStockQuoteData.ts # 行情数据 + 基本信息获取(~152 行)
|
||||||
|
└── useStockCompare.ts # 股票对比逻辑(~91 行)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props 对比**:
|
||||||
|
|
||||||
|
**优化前(11 个 Props)**:
|
||||||
|
```tsx
|
||||||
|
<StockQuoteCard
|
||||||
|
data={quoteData}
|
||||||
|
isLoading={isQuoteLoading}
|
||||||
|
basicInfo={basicInfo}
|
||||||
|
currentStockInfo={currentStockInfo}
|
||||||
|
compareStockInfo={compareStockInfo}
|
||||||
|
isCompareLoading={isCompareLoading}
|
||||||
|
onCompare={handleCompare}
|
||||||
|
onCloseCompare={handleCloseCompare}
|
||||||
|
isInWatchlist={isInWatchlist}
|
||||||
|
isWatchlistLoading={isWatchlistLoading}
|
||||||
|
onWatchlistToggle={handleWatchlistToggle}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**优化后(4 个 Props)**:
|
||||||
|
```tsx
|
||||||
|
<StockQuoteCard
|
||||||
|
stockCode={stockCode}
|
||||||
|
isInWatchlist={isInWatchlist}
|
||||||
|
isWatchlistLoading={isWatchlistLoading}
|
||||||
|
onWatchlistToggle={handleWatchlistToggle}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hook 返回值**:
|
||||||
|
|
||||||
|
`useStockQuoteData(stockCode)`:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
quoteData: StockQuoteCardData | null; // 行情数据
|
||||||
|
basicInfo: BasicInfo | null; // 基本信息
|
||||||
|
isLoading: boolean; // 加载状态
|
||||||
|
error: string | null; // 错误信息
|
||||||
|
refetch: () => void; // 手动刷新
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`useStockCompare(stockCode)`:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
currentStockInfo: StockInfo | null; // 当前股票财务信息
|
||||||
|
compareStockInfo: StockInfo | null; // 对比股票财务信息
|
||||||
|
isCompareLoading: boolean; // 对比数据加载中
|
||||||
|
handleCompare: (code: string) => Promise<void>; // 触发对比
|
||||||
|
clearCompare: () => void; // 清除对比
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改的文件**:
|
||||||
|
| 文件 | 操作 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `StockQuoteCard/hooks/useStockQuoteData.ts` | 新建 | 合并行情+基本信息获取 |
|
||||||
|
| `StockQuoteCard/hooks/useStockCompare.ts` | 新建 | 股票对比逻辑 |
|
||||||
|
| `StockQuoteCard/hooks/index.ts` | 新建 | hooks 导出索引 |
|
||||||
|
| `StockQuoteCard/index.tsx` | 修改 | 使用内部 hooks,减少 props |
|
||||||
|
| `StockQuoteCard/types.ts` | 修改 | Props 从 11 个精简为 4 个 |
|
||||||
|
| `Company/index.js` | 修改 | 移除下沉的数据获取逻辑 |
|
||||||
|
| `Company/hooks/useStockQuote.js` | 删除 | 已移到 StockQuoteCard |
|
||||||
|
|
||||||
|
**优化收益**:
|
||||||
|
| 指标 | 优化前 | 优化后 | 改善 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| Props 数量 | 11 | 4 | -64% |
|
||||||
|
| Company/index.js 行数 | ~172 | ~105 | -39% |
|
||||||
|
| 数据获取位置 | 页面层 | 组件内部 | 就近原则 |
|
||||||
|
| 可复用性 | 依赖父组件 | 独立可用 | 提升 |
|
||||||
|
|
||||||
|
**设计原则**:
|
||||||
|
- **数据就近获取**:组件自己获取自己需要的数据
|
||||||
|
- **Props 最小化**:只传递真正需要外部控制的状态
|
||||||
|
- **职责清晰**:自选股状态保留在页面层(涉及 Redux 和事件追踪)
|
||||||
|
- **可复用性**:StockQuoteCard 可独立在其他页面使用
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
VStack,
|
VStack,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { SearchIcon } from '@chakra-ui/icons';
|
import { SearchIcon } from '@chakra-ui/icons';
|
||||||
|
import { useStockSearch } from '../../hooks/useStockSearch';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 股票搜索栏组件(带模糊搜索下拉)
|
* 股票搜索栏组件(带模糊搜索下拉)
|
||||||
@@ -31,27 +32,18 @@ const SearchBar = ({
|
|||||||
}) => {
|
}) => {
|
||||||
// 下拉状态
|
// 下拉状态
|
||||||
const [showDropdown, setShowDropdown] = useState(false);
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
const [filteredStocks, setFilteredStocks] = useState([]);
|
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
// 从 Redux 获取全部股票列表
|
// 从 Redux 获取全部股票列表
|
||||||
const allStocks = useSelector(state => state.stock.allStocks);
|
const allStocks = useSelector(state => state.stock.allStocks);
|
||||||
|
|
||||||
// 模糊搜索过滤
|
// 使用共享的搜索 Hook
|
||||||
|
const filteredStocks = useStockSearch(allStocks, inputCode, { limit: 10 });
|
||||||
|
|
||||||
|
// 根据搜索结果更新下拉显示状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inputCode && inputCode.trim()) {
|
setShowDropdown(filteredStocks.length > 0 && !!inputCode?.trim());
|
||||||
const searchTerm = inputCode.trim().toLowerCase();
|
}, [filteredStocks, inputCode]);
|
||||||
const filtered = allStocks.filter(stock =>
|
|
||||||
stock.code.toLowerCase().includes(searchTerm) ||
|
|
||||||
stock.name.includes(inputCode.trim())
|
|
||||||
).slice(0, 10); // 限制显示10条
|
|
||||||
setFilteredStocks(filtered);
|
|
||||||
setShowDropdown(filtered.length > 0);
|
|
||||||
} else {
|
|
||||||
setFilteredStocks([]);
|
|
||||||
setShowDropdown(false);
|
|
||||||
}
|
|
||||||
}, [inputCode, allStocks]);
|
|
||||||
|
|
||||||
// 点击外部关闭下拉
|
// 点击外部关闭下拉
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -12,15 +12,27 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Center,
|
Center,
|
||||||
Code,
|
Code,
|
||||||
|
Spinner,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
|
|
||||||
import { THEME } from "../config";
|
import { THEME } from "../config";
|
||||||
|
import { useBasicInfo } from "../../hooks/useBasicInfo";
|
||||||
|
|
||||||
interface BusinessInfoPanelProps {
|
interface BusinessInfoPanelProps {
|
||||||
basicInfo: any;
|
stockCode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ basicInfo }) => {
|
const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ stockCode }) => {
|
||||||
|
const { basicInfo, loading } = useBasicInfo(stockCode);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Center h="200px">
|
||||||
|
<Spinner size="lg" color={THEME.gold} />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!basicInfo) {
|
if (!basicInfo) {
|
||||||
return (
|
return (
|
||||||
<Center h="200px">
|
<Center h="200px">
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
// Props 类型定义
|
// Props 类型定义
|
||||||
export interface BasicInfoTabProps {
|
export interface BasicInfoTabProps {
|
||||||
stockCode: string;
|
stockCode: string;
|
||||||
basicInfo?: any;
|
|
||||||
|
|
||||||
// 可配置项
|
// 可配置项
|
||||||
enabledTabs?: string[]; // 指定显示哪些 Tab(通过 key)
|
enabledTabs?: string[]; // 指定显示哪些 Tab(通过 key)
|
||||||
@@ -59,7 +58,6 @@ const buildTabsConfig = (enabledKeys?: string[]): SubTabConfig[] => {
|
|||||||
*/
|
*/
|
||||||
const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
|
const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
|
||||||
stockCode,
|
stockCode,
|
||||||
basicInfo,
|
|
||||||
enabledTabs,
|
enabledTabs,
|
||||||
defaultTabIndex = 0,
|
defaultTabIndex = 0,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
@@ -72,7 +70,7 @@ const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
|
|||||||
<CardBody p={0}>
|
<CardBody p={0}>
|
||||||
<SubTabContainer
|
<SubTabContainer
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
componentProps={{ stockCode, basicInfo }}
|
componentProps={{ stockCode }}
|
||||||
defaultIndex={defaultTabIndex}
|
defaultIndex={defaultTabIndex}
|
||||||
onTabChange={onTabChange}
|
onTabChange={onTabChange}
|
||||||
themePreset="blackGold"
|
themePreset="blackGold"
|
||||||
|
|||||||
@@ -32,12 +32,10 @@ import {
|
|||||||
FaStar,
|
FaStar,
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import { logger } from '@utils/logger';
|
import { logger } from '@utils/logger';
|
||||||
import { getApiBase } from '@utils/apiConfig';
|
import axios from '@utils/axiosConfig';
|
||||||
import RelatedCompaniesModal from './RelatedCompaniesModal';
|
import RelatedCompaniesModal from './RelatedCompaniesModal';
|
||||||
import type { ValueChainNodeCardProps, RelatedCompany } from '../../types';
|
import type { ValueChainNodeCardProps, RelatedCompany } from '../../types';
|
||||||
|
|
||||||
const API_BASE_URL = getApiBase();
|
|
||||||
|
|
||||||
// 黑金主题配置
|
// 黑金主题配置
|
||||||
const THEME = {
|
const THEME = {
|
||||||
cardBg: 'gray.700',
|
cardBg: 'gray.700',
|
||||||
@@ -120,12 +118,11 @@ const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = memo(({
|
|||||||
const fetchRelatedCompanies = async () => {
|
const fetchRelatedCompanies = async () => {
|
||||||
setLoadingRelated(true);
|
setLoadingRelated(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const { data } = await axios.get(
|
||||||
`${API_BASE_URL}/api/company/value-chain/related-companies?node_name=${encodeURIComponent(
|
`/api/company/value-chain/related-companies?node_name=${encodeURIComponent(
|
||||||
node.node_name
|
node.node_name
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
const data = await response.json();
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setRelatedCompanies(data.data || []);
|
setRelatedCompanies(data.data || []);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
// src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts
|
// src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts
|
||||||
// 公告数据 Hook - 用于公司公告 Tab
|
// 公告数据 Hook - 用于公司公告 Tab
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { logger } from "@utils/logger";
|
import { logger } from "@utils/logger";
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
import type { Announcement } from "../types";
|
import type { Announcement } from "../types";
|
||||||
|
|
||||||
const API_BASE_URL = getApiBase();
|
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: T;
|
data: T;
|
||||||
@@ -28,34 +26,38 @@ export const useAnnouncementsData = (stockCode?: string): UseAnnouncementsDataRe
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
useEffect(() => {
|
||||||
if (!stockCode) return;
|
if (!stockCode) return;
|
||||||
|
|
||||||
setLoading(true);
|
const controller = new AbortController();
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
const loadData = async () => {
|
||||||
const response = await fetch(
|
setLoading(true);
|
||||||
`${API_BASE_URL}/api/stock/${stockCode}/announcements?limit=20`
|
setError(null);
|
||||||
);
|
|
||||||
const result = (await response.json()) as ApiResponse<Announcement[]>;
|
|
||||||
|
|
||||||
if (result.success) {
|
try {
|
||||||
setAnnouncements(result.data);
|
const { data: result } = await axios.get<ApiResponse<Announcement[]>>(
|
||||||
} else {
|
`/api/stock/${stockCode}/announcements?limit=20`,
|
||||||
setError("加载公告数据失败");
|
{ signal: controller.signal }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setAnnouncements(result.data);
|
||||||
|
} else {
|
||||||
|
setError("加载公告数据失败");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === "CanceledError") return;
|
||||||
|
logger.error("useAnnouncementsData", "loadData", err, { stockCode });
|
||||||
|
setError("网络请求失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
};
|
||||||
logger.error("useAnnouncementsData", "loadData", err, { stockCode });
|
|
||||||
setError("网络请求失败");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [stockCode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
return () => controller.abort();
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
return { announcements, loading, error };
|
return { announcements, loading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
// src/views/Company/components/CompanyOverview/hooks/useBasicInfo.ts
|
// src/views/Company/components/CompanyOverview/hooks/useBasicInfo.ts
|
||||||
// 公司基本信息 Hook - 用于 CompanyHeaderCard
|
// 公司基本信息 Hook - 用于 CompanyHeaderCard
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { logger } from "@utils/logger";
|
import { logger } from "@utils/logger";
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
import type { BasicInfo } from "../types";
|
import type { BasicInfo } from "../types";
|
||||||
|
|
||||||
const API_BASE_URL = getApiBase();
|
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: T;
|
data: T;
|
||||||
@@ -28,32 +26,38 @@ export const useBasicInfo = (stockCode?: string): UseBasicInfoResult => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
useEffect(() => {
|
||||||
if (!stockCode) return;
|
if (!stockCode) return;
|
||||||
|
|
||||||
setLoading(true);
|
const controller = new AbortController();
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
const loadData = async () => {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/stock/${stockCode}/basic-info`);
|
setLoading(true);
|
||||||
const result = (await response.json()) as ApiResponse<BasicInfo>;
|
setError(null);
|
||||||
|
|
||||||
if (result.success) {
|
try {
|
||||||
setBasicInfo(result.data);
|
const { data: result } = await axios.get<ApiResponse<BasicInfo>>(
|
||||||
} else {
|
`/api/stock/${stockCode}/basic-info`,
|
||||||
setError("加载基本信息失败");
|
{ signal: controller.signal }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setBasicInfo(result.data);
|
||||||
|
} else {
|
||||||
|
setError("加载基本信息失败");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === "CanceledError") return;
|
||||||
|
logger.error("useBasicInfo", "loadData", err, { stockCode });
|
||||||
|
setError("网络请求失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
};
|
||||||
logger.error("useBasicInfo", "loadData", err, { stockCode });
|
|
||||||
setError("网络请求失败");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [stockCode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
return () => controller.abort();
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
return { basicInfo, loading, error };
|
return { basicInfo, loading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
// src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts
|
// src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts
|
||||||
// 分支机构数据 Hook - 用于分支机构 Tab
|
// 分支机构数据 Hook - 用于分支机构 Tab
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { logger } from "@utils/logger";
|
import { logger } from "@utils/logger";
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
import type { Branch } from "../types";
|
import type { Branch } from "../types";
|
||||||
|
|
||||||
const API_BASE_URL = getApiBase();
|
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: T;
|
data: T;
|
||||||
@@ -28,32 +26,38 @@ export const useBranchesData = (stockCode?: string): UseBranchesDataResult => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
useEffect(() => {
|
||||||
if (!stockCode) return;
|
if (!stockCode) return;
|
||||||
|
|
||||||
setLoading(true);
|
const controller = new AbortController();
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
const loadData = async () => {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/stock/${stockCode}/branches`);
|
setLoading(true);
|
||||||
const result = (await response.json()) as ApiResponse<Branch[]>;
|
setError(null);
|
||||||
|
|
||||||
if (result.success) {
|
try {
|
||||||
setBranches(result.data);
|
const { data: result } = await axios.get<ApiResponse<Branch[]>>(
|
||||||
} else {
|
`/api/stock/${stockCode}/branches`,
|
||||||
setError("加载分支机构数据失败");
|
{ signal: controller.signal }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setBranches(result.data);
|
||||||
|
} else {
|
||||||
|
setError("加载分支机构数据失败");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === "CanceledError") return;
|
||||||
|
logger.error("useBranchesData", "loadData", err, { stockCode });
|
||||||
|
setError("网络请求失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
};
|
||||||
logger.error("useBranchesData", "loadData", err, { stockCode });
|
|
||||||
setError("网络请求失败");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [stockCode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
return () => controller.abort();
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
return { branches, loading, error };
|
return { branches, loading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
// src/views/Company/components/CompanyOverview/hooks/useCompanyOverviewData.ts
|
|
||||||
// 公司概览数据加载 Hook
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { logger } from "@utils/logger";
|
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
|
||||||
import type {
|
|
||||||
BasicInfo,
|
|
||||||
ActualControl,
|
|
||||||
Concentration,
|
|
||||||
Management,
|
|
||||||
Shareholder,
|
|
||||||
Branch,
|
|
||||||
Announcement,
|
|
||||||
DisclosureSchedule,
|
|
||||||
CompanyOverviewData,
|
|
||||||
} from "../types";
|
|
||||||
|
|
||||||
const API_BASE_URL = getApiBase();
|
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
success: boolean;
|
|
||||||
data: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 公司概览数据加载 Hook
|
|
||||||
* @param propStockCode - 股票代码
|
|
||||||
* @returns 公司概览数据
|
|
||||||
*/
|
|
||||||
export const useCompanyOverviewData = (propStockCode?: string): CompanyOverviewData => {
|
|
||||||
const [stockCode, setStockCode] = useState(propStockCode || "000001");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [dataLoaded, setDataLoaded] = useState(false);
|
|
||||||
|
|
||||||
// 基本信息数据
|
|
||||||
const [basicInfo, setBasicInfo] = useState<BasicInfo | null>(null);
|
|
||||||
const [actualControl, setActualControl] = useState<ActualControl[]>([]);
|
|
||||||
const [concentration, setConcentration] = useState<Concentration[]>([]);
|
|
||||||
const [management, setManagement] = useState<Management[]>([]);
|
|
||||||
const [topCirculationShareholders, setTopCirculationShareholders] = useState<Shareholder[]>([]);
|
|
||||||
const [topShareholders, setTopShareholders] = useState<Shareholder[]>([]);
|
|
||||||
const [branches, setBranches] = useState<Branch[]>([]);
|
|
||||||
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
|
||||||
const [disclosureSchedule, setDisclosureSchedule] = useState<DisclosureSchedule[]>([]);
|
|
||||||
|
|
||||||
// 监听 props 中的 stockCode 变化
|
|
||||||
useEffect(() => {
|
|
||||||
if (propStockCode && propStockCode !== stockCode) {
|
|
||||||
setStockCode(propStockCode);
|
|
||||||
setDataLoaded(false);
|
|
||||||
}
|
|
||||||
}, [propStockCode, stockCode]);
|
|
||||||
|
|
||||||
// 加载基本信息数据(9个接口)
|
|
||||||
const loadBasicInfoData = useCallback(async () => {
|
|
||||||
if (dataLoaded) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [
|
|
||||||
basicRes,
|
|
||||||
actualRes,
|
|
||||||
concentrationRes,
|
|
||||||
managementRes,
|
|
||||||
circulationRes,
|
|
||||||
shareholdersRes,
|
|
||||||
branchesRes,
|
|
||||||
announcementsRes,
|
|
||||||
disclosureRes,
|
|
||||||
] = await Promise.all([
|
|
||||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/basic-info`).then((r) =>
|
|
||||||
r.json()
|
|
||||||
) as Promise<ApiResponse<BasicInfo>>,
|
|
||||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/actual-control`).then((r) =>
|
|
||||||
r.json()
|
|
||||||
) as Promise<ApiResponse<ActualControl[]>>,
|
|
||||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/concentration`).then((r) =>
|
|
||||||
r.json()
|
|
||||||
) as Promise<ApiResponse<Concentration[]>>,
|
|
||||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/management?active_only=true`).then((r) =>
|
|
||||||
r.json()
|
|
||||||
) as Promise<ApiResponse<Management[]>>,
|
|
||||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-circulation-shareholders?limit=10`).then((r) =>
|
|
||||||
r.json()
|
|
||||||
) as Promise<ApiResponse<Shareholder[]>>,
|
|
||||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-shareholders?limit=10`).then((r) =>
|
|
||||||
r.json()
|
|
||||||
) as Promise<ApiResponse<Shareholder[]>>,
|
|
||||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/branches`).then((r) =>
|
|
||||||
r.json()
|
|
||||||
) as Promise<ApiResponse<Branch[]>>,
|
|
||||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/announcements?limit=20`).then((r) =>
|
|
||||||
r.json()
|
|
||||||
) as Promise<ApiResponse<Announcement[]>>,
|
|
||||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/disclosure-schedule`).then((r) =>
|
|
||||||
r.json()
|
|
||||||
) as Promise<ApiResponse<DisclosureSchedule[]>>,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (basicRes.success) setBasicInfo(basicRes.data);
|
|
||||||
if (actualRes.success) setActualControl(actualRes.data);
|
|
||||||
if (concentrationRes.success) setConcentration(concentrationRes.data);
|
|
||||||
if (managementRes.success) setManagement(managementRes.data);
|
|
||||||
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
|
|
||||||
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
|
|
||||||
if (branchesRes.success) setBranches(branchesRes.data);
|
|
||||||
if (announcementsRes.success) setAnnouncements(announcementsRes.data);
|
|
||||||
if (disclosureRes.success) setDisclosureSchedule(disclosureRes.data);
|
|
||||||
|
|
||||||
setDataLoaded(true);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error("useCompanyOverviewData", "loadBasicInfoData", err, { stockCode });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [stockCode, dataLoaded]);
|
|
||||||
|
|
||||||
// 首次加载
|
|
||||||
useEffect(() => {
|
|
||||||
if (stockCode) {
|
|
||||||
loadBasicInfoData();
|
|
||||||
}
|
|
||||||
}, [stockCode, loadBasicInfoData]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
basicInfo,
|
|
||||||
actualControl,
|
|
||||||
concentration,
|
|
||||||
management,
|
|
||||||
topCirculationShareholders,
|
|
||||||
topShareholders,
|
|
||||||
branches,
|
|
||||||
announcements,
|
|
||||||
disclosureSchedule,
|
|
||||||
loading,
|
|
||||||
dataLoaded,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
// src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts
|
// src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts
|
||||||
// 披露日程数据 Hook - 用于工商信息 Tab
|
// 披露日程数据 Hook - 用于工商信息 Tab
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { logger } from "@utils/logger";
|
import { logger } from "@utils/logger";
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
import type { DisclosureSchedule } from "../types";
|
import type { DisclosureSchedule } from "../types";
|
||||||
|
|
||||||
const API_BASE_URL = getApiBase();
|
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: T;
|
data: T;
|
||||||
@@ -28,34 +26,38 @@ export const useDisclosureData = (stockCode?: string): UseDisclosureDataResult =
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
useEffect(() => {
|
||||||
if (!stockCode) return;
|
if (!stockCode) return;
|
||||||
|
|
||||||
setLoading(true);
|
const controller = new AbortController();
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
const loadData = async () => {
|
||||||
const response = await fetch(
|
setLoading(true);
|
||||||
`${API_BASE_URL}/api/stock/${stockCode}/disclosure-schedule`
|
setError(null);
|
||||||
);
|
|
||||||
const result = (await response.json()) as ApiResponse<DisclosureSchedule[]>;
|
|
||||||
|
|
||||||
if (result.success) {
|
try {
|
||||||
setDisclosureSchedule(result.data);
|
const { data: result } = await axios.get<ApiResponse<DisclosureSchedule[]>>(
|
||||||
} else {
|
`/api/stock/${stockCode}/disclosure-schedule`,
|
||||||
setError("加载披露日程数据失败");
|
{ signal: controller.signal }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setDisclosureSchedule(result.data);
|
||||||
|
} else {
|
||||||
|
setError("加载披露日程数据失败");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === "CanceledError") return;
|
||||||
|
logger.error("useDisclosureData", "loadData", err, { stockCode });
|
||||||
|
setError("网络请求失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
};
|
||||||
logger.error("useDisclosureData", "loadData", err, { stockCode });
|
|
||||||
setError("网络请求失败");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [stockCode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
return () => controller.abort();
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
return { disclosureSchedule, loading, error };
|
return { disclosureSchedule, loading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
// src/views/Company/components/CompanyOverview/hooks/useManagementData.ts
|
// src/views/Company/components/CompanyOverview/hooks/useManagementData.ts
|
||||||
// 管理团队数据 Hook - 用于管理团队 Tab
|
// 管理团队数据 Hook - 用于管理团队 Tab
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { logger } from "@utils/logger";
|
import { logger } from "@utils/logger";
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
import type { Management } from "../types";
|
import type { Management } from "../types";
|
||||||
|
|
||||||
const API_BASE_URL = getApiBase();
|
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: T;
|
data: T;
|
||||||
@@ -28,34 +26,38 @@ export const useManagementData = (stockCode?: string): UseManagementDataResult =
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
useEffect(() => {
|
||||||
if (!stockCode) return;
|
if (!stockCode) return;
|
||||||
|
|
||||||
setLoading(true);
|
const controller = new AbortController();
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
const loadData = async () => {
|
||||||
const response = await fetch(
|
setLoading(true);
|
||||||
`${API_BASE_URL}/api/stock/${stockCode}/management?active_only=true`
|
setError(null);
|
||||||
);
|
|
||||||
const result = (await response.json()) as ApiResponse<Management[]>;
|
|
||||||
|
|
||||||
if (result.success) {
|
try {
|
||||||
setManagement(result.data);
|
const { data: result } = await axios.get<ApiResponse<Management[]>>(
|
||||||
} else {
|
`/api/stock/${stockCode}/management?active_only=true`,
|
||||||
setError("加载管理团队数据失败");
|
{ signal: controller.signal }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setManagement(result.data);
|
||||||
|
} else {
|
||||||
|
setError("加载管理团队数据失败");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === "CanceledError") return;
|
||||||
|
logger.error("useManagementData", "loadData", err, { stockCode });
|
||||||
|
setError("网络请求失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
};
|
||||||
logger.error("useManagementData", "loadData", err, { stockCode });
|
|
||||||
setError("网络请求失败");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [stockCode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
return () => controller.abort();
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
return { management, loading, error };
|
return { management, loading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
// src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts
|
// src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts
|
||||||
// 股权结构数据 Hook - 用于股权结构 Tab
|
// 股权结构数据 Hook - 用于股权结构 Tab
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { logger } from "@utils/logger";
|
import { logger } from "@utils/logger";
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
import type { ActualControl, Concentration, Shareholder } from "../types";
|
import type { ActualControl, Concentration, Shareholder } from "../types";
|
||||||
|
|
||||||
const API_BASE_URL = getApiBase();
|
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: T;
|
data: T;
|
||||||
@@ -34,43 +32,44 @@ export const useShareholderData = (stockCode?: string): UseShareholderDataResult
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
useEffect(() => {
|
||||||
if (!stockCode) return;
|
if (!stockCode) return;
|
||||||
|
|
||||||
setLoading(true);
|
const controller = new AbortController();
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
const loadData = async () => {
|
||||||
const [actualRes, concentrationRes, shareholdersRes, circulationRes] = await Promise.all([
|
setLoading(true);
|
||||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/actual-control`).then((r) =>
|
setError(null);
|
||||||
r.json()
|
|
||||||
) as Promise<ApiResponse<ActualControl[]>>,
|
|
||||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/concentration`).then((r) =>
|
|
||||||
r.json()
|
|
||||||
) as Promise<ApiResponse<Concentration[]>>,
|
|
||||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-shareholders?limit=10`).then((r) =>
|
|
||||||
r.json()
|
|
||||||
) as Promise<ApiResponse<Shareholder[]>>,
|
|
||||||
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-circulation-shareholders?limit=10`).then((r) =>
|
|
||||||
r.json()
|
|
||||||
) as Promise<ApiResponse<Shareholder[]>>,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (actualRes.success) setActualControl(actualRes.data);
|
try {
|
||||||
if (concentrationRes.success) setConcentration(concentrationRes.data);
|
const [
|
||||||
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
|
{ data: actualRes },
|
||||||
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
|
{ data: concentrationRes },
|
||||||
} catch (err) {
|
{ data: shareholdersRes },
|
||||||
logger.error("useShareholderData", "loadData", err, { stockCode });
|
{ data: circulationRes },
|
||||||
setError("加载股权结构数据失败");
|
] = await Promise.all([
|
||||||
} finally {
|
axios.get<ApiResponse<ActualControl[]>>(`/api/stock/${stockCode}/actual-control`, { signal: controller.signal }),
|
||||||
setLoading(false);
|
axios.get<ApiResponse<Concentration[]>>(`/api/stock/${stockCode}/concentration`, { signal: controller.signal }),
|
||||||
}
|
axios.get<ApiResponse<Shareholder[]>>(`/api/stock/${stockCode}/top-shareholders?limit=10`, { signal: controller.signal }),
|
||||||
}, [stockCode]);
|
axios.get<ApiResponse<Shareholder[]>>(`/api/stock/${stockCode}/top-circulation-shareholders?limit=10`, { signal: controller.signal }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (actualRes.success) setActualControl(actualRes.data);
|
||||||
|
if (concentrationRes.success) setConcentration(concentrationRes.data);
|
||||||
|
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
|
||||||
|
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === "CanceledError") return;
|
||||||
|
logger.error("useShareholderData", "loadData", err, { stockCode });
|
||||||
|
setError("加载股权结构数据失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
return () => controller.abort();
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actualControl,
|
actualControl,
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { VStack } from "@chakra-ui/react";
|
import { VStack } from "@chakra-ui/react";
|
||||||
|
|
||||||
import { useBasicInfo } from "./hooks/useBasicInfo";
|
|
||||||
import type { CompanyOverviewProps } from "./types";
|
import type { CompanyOverviewProps } from "./types";
|
||||||
|
|
||||||
// 子组件(暂保持 JS)
|
// 子组件
|
||||||
import BasicInfoTab from "./BasicInfoTab";
|
import BasicInfoTab from "./BasicInfoTab";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,17 +17,13 @@ import BasicInfoTab from "./BasicInfoTab";
|
|||||||
*
|
*
|
||||||
* 懒加载策略:
|
* 懒加载策略:
|
||||||
* - BasicInfoTab 内部根据 Tab 切换懒加载数据
|
* - BasicInfoTab 内部根据 Tab 切换懒加载数据
|
||||||
|
* - 各 Panel 组件自行获取所需数据(如 BusinessInfoPanel 调用 useBasicInfo)
|
||||||
*/
|
*/
|
||||||
const CompanyOverview: React.FC<CompanyOverviewProps> = ({ stockCode }) => {
|
const CompanyOverview: React.FC<CompanyOverviewProps> = ({ stockCode }) => {
|
||||||
const { basicInfo } = useBasicInfo(stockCode);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack spacing={6} align="stretch">
|
<VStack spacing={6} align="stretch">
|
||||||
{/* 基本信息内容 - 传入 stockCode,内部懒加载各 Tab 数据 */}
|
{/* 基本信息内容 - 传入 stockCode,内部懒加载各 Tab 数据 */}
|
||||||
<BasicInfoTab
|
<BasicInfoTab stockCode={stockCode} />
|
||||||
stockCode={stockCode}
|
|
||||||
basicInfo={basicInfo}
|
|
||||||
/>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ export interface BasicInfo {
|
|||||||
email?: string;
|
email?: string;
|
||||||
tel?: string;
|
tel?: string;
|
||||||
company_intro?: string;
|
company_intro?: string;
|
||||||
|
// 工商信息字段
|
||||||
|
credit_code?: string;
|
||||||
|
company_size?: string;
|
||||||
|
reg_address?: string;
|
||||||
|
office_address?: string;
|
||||||
|
accounting_firm?: string;
|
||||||
|
law_firm?: string;
|
||||||
|
main_business?: string;
|
||||||
|
business_scope?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,23 +116,6 @@ export interface DisclosureSchedule {
|
|||||||
disclosure_date?: string;
|
disclosure_date?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* useCompanyOverviewData Hook 返回值
|
|
||||||
*/
|
|
||||||
export interface CompanyOverviewData {
|
|
||||||
basicInfo: BasicInfo | null;
|
|
||||||
actualControl: ActualControl[];
|
|
||||||
concentration: Concentration[];
|
|
||||||
management: Management[];
|
|
||||||
topCirculationShareholders: Shareholder[];
|
|
||||||
topShareholders: Shareholder[];
|
|
||||||
branches: Branch[];
|
|
||||||
announcements: Announcement[];
|
|
||||||
disclosureSchedule: DisclosureSchedule[];
|
|
||||||
loading: boolean;
|
|
||||||
dataLoaded: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CompanyOverview 组件 Props
|
* CompanyOverview 组件 Props
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,13 +3,11 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { logger } from "@utils/logger";
|
import { logger } from "@utils/logger";
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
|
|
||||||
// 复用原有的展示组件
|
// 复用原有的展示组件
|
||||||
import DeepAnalysisTab from "../CompanyOverview/DeepAnalysisTab";
|
import DeepAnalysisTab from "../CompanyOverview/DeepAnalysisTab";
|
||||||
|
|
||||||
const API_BASE_URL = getApiBase();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tab 与 API 接口映射
|
* Tab 与 API 接口映射
|
||||||
* - strategy 和 business 共用 comprehensive 接口
|
* - strategy 和 business 共用 comprehensive 接口
|
||||||
@@ -84,9 +82,9 @@ const DeepAnalysis = ({ stockCode }) => {
|
|||||||
switch (apiKey) {
|
switch (apiKey) {
|
||||||
case "comprehensive":
|
case "comprehensive":
|
||||||
setComprehensiveLoading(true);
|
setComprehensiveLoading(true);
|
||||||
const comprehensiveRes = await fetch(
|
const { data: comprehensiveRes } = await axios.get(
|
||||||
`${API_BASE_URL}/api/company/comprehensive-analysis/${stockCode}`
|
`/api/company/comprehensive-analysis/${stockCode}`
|
||||||
).then((r) => r.json());
|
);
|
||||||
// 检查 stockCode 是否已变更(防止竞态)
|
// 检查 stockCode 是否已变更(防止竞态)
|
||||||
if (currentStockCodeRef.current === stockCode) {
|
if (currentStockCodeRef.current === stockCode) {
|
||||||
if (comprehensiveRes.success)
|
if (comprehensiveRes.success)
|
||||||
@@ -97,9 +95,9 @@ const DeepAnalysis = ({ stockCode }) => {
|
|||||||
|
|
||||||
case "valueChain":
|
case "valueChain":
|
||||||
setValueChainLoading(true);
|
setValueChainLoading(true);
|
||||||
const valueChainRes = await fetch(
|
const { data: valueChainRes } = await axios.get(
|
||||||
`${API_BASE_URL}/api/company/value-chain-analysis/${stockCode}`
|
`/api/company/value-chain-analysis/${stockCode}`
|
||||||
).then((r) => r.json());
|
);
|
||||||
if (currentStockCodeRef.current === stockCode) {
|
if (currentStockCodeRef.current === stockCode) {
|
||||||
if (valueChainRes.success) setValueChainData(valueChainRes.data);
|
if (valueChainRes.success) setValueChainData(valueChainRes.data);
|
||||||
loadedApisRef.current.valueChain = true;
|
loadedApisRef.current.valueChain = true;
|
||||||
@@ -108,9 +106,9 @@ const DeepAnalysis = ({ stockCode }) => {
|
|||||||
|
|
||||||
case "keyFactors":
|
case "keyFactors":
|
||||||
setKeyFactorsLoading(true);
|
setKeyFactorsLoading(true);
|
||||||
const keyFactorsRes = await fetch(
|
const { data: keyFactorsRes } = await axios.get(
|
||||||
`${API_BASE_URL}/api/company/key-factors-timeline/${stockCode}`
|
`/api/company/key-factors-timeline/${stockCode}`
|
||||||
).then((r) => r.json());
|
);
|
||||||
if (currentStockCodeRef.current === stockCode) {
|
if (currentStockCodeRef.current === stockCode) {
|
||||||
if (keyFactorsRes.success) setKeyFactorsData(keyFactorsRes.data);
|
if (keyFactorsRes.success) setKeyFactorsData(keyFactorsRes.data);
|
||||||
loadedApisRef.current.keyFactors = true;
|
loadedApisRef.current.keyFactors = true;
|
||||||
@@ -119,9 +117,9 @@ const DeepAnalysis = ({ stockCode }) => {
|
|||||||
|
|
||||||
case "industryRank":
|
case "industryRank":
|
||||||
setIndustryRankLoading(true);
|
setIndustryRankLoading(true);
|
||||||
const industryRankRes = await fetch(
|
const { data: industryRankRes } = await axios.get(
|
||||||
`${API_BASE_URL}/api/financial/industry-rank/${stockCode}`
|
`/api/financial/industry-rank/${stockCode}`
|
||||||
).then((r) => r.json());
|
);
|
||||||
if (currentStockCodeRef.current === stockCode) {
|
if (currentStockCodeRef.current === stockCode) {
|
||||||
if (industryRankRes.success) setIndustryRankData(industryRankRes.data);
|
if (industryRankRes.success) setIndustryRankData(industryRankRes.data);
|
||||||
loadedApisRef.current.industryRank = true;
|
loadedApisRef.current.industryRank = true;
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ import {
|
|||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { Tag } from 'antd';
|
import { Tag } from 'antd';
|
||||||
import { logger } from '@utils/logger';
|
import { logger } from '@utils/logger';
|
||||||
import { getApiBase } from '@utils/apiConfig';
|
import axios from '@utils/axiosConfig';
|
||||||
|
|
||||||
const API_BASE_URL = getApiBase();
|
|
||||||
|
|
||||||
// 黑金主题
|
// 黑金主题
|
||||||
const THEME = {
|
const THEME = {
|
||||||
@@ -53,10 +51,9 @@ const ForecastPanel = ({ stockCode }) => {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const { data: result } = await axios.get(
|
||||||
`${API_BASE_URL}/api/stock/${stockCode}/forecast`
|
`/api/stock/${stockCode}/forecast`
|
||||||
);
|
);
|
||||||
const result = await response.json();
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setForecast(result.data);
|
setForecast(result.data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { logger } from '@utils/logger';
|
import { logger } from '@utils/logger';
|
||||||
import { getApiBase } from '@utils/apiConfig';
|
import axios from '@utils/axiosConfig';
|
||||||
import NewsEventsTab from '../../CompanyOverview/NewsEventsTab';
|
import NewsEventsTab from '../../CompanyOverview/NewsEventsTab';
|
||||||
|
|
||||||
const API_BASE_URL = getApiBase();
|
|
||||||
|
|
||||||
const NewsPanel = ({ stockCode }) => {
|
const NewsPanel = ({ stockCode }) => {
|
||||||
const [newsEvents, setNewsEvents] = useState([]);
|
const [newsEvents, setNewsEvents] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -25,10 +23,9 @@ const NewsPanel = ({ stockCode }) => {
|
|||||||
// 获取股票名称
|
// 获取股票名称
|
||||||
const fetchStockName = useCallback(async () => {
|
const fetchStockName = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const { data: result } = await axios.get(
|
||||||
`${API_BASE_URL}/api/stock/${stockCode}/basic-info`
|
`/api/stock/${stockCode}/basic-info`
|
||||||
);
|
);
|
||||||
const result = await response.json();
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
const name = result.data.SECNAME || result.data.ORGNAME || stockCode;
|
const name = result.data.SECNAME || result.data.ORGNAME || stockCode;
|
||||||
setStockName(name);
|
setStockName(name);
|
||||||
@@ -47,10 +44,9 @@ const NewsPanel = ({ stockCode }) => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const searchTerm = query || stockName || stockCode;
|
const searchTerm = query || stockName || stockCode;
|
||||||
const response = await fetch(
|
const { data: result } = await axios.get(
|
||||||
`${API_BASE_URL}/api/events?q=${encodeURIComponent(searchTerm)}&page=${page}&per_page=10`
|
`/api/events?q=${encodeURIComponent(searchTerm)}&page=${page}&per_page=10`
|
||||||
);
|
);
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setNewsEvents(result.data || []);
|
setNewsEvents(result.data || []);
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ import React, { useState, useEffect, ReactNode, useMemo, useCallback } from 'rea
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
Container,
|
||||||
CardBody,
|
|
||||||
Center,
|
|
||||||
VStack,
|
VStack,
|
||||||
Text,
|
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import {
|
import {
|
||||||
@@ -161,11 +158,14 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
|
|||||||
|
|
||||||
{/* 主要内容区域 - Tab */}
|
{/* 主要内容区域 - Tab */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ThemedCard theme={theme}>
|
<Box
|
||||||
<CardBody>
|
bg="gray.900"
|
||||||
<LoadingState message="数据加载中..." height="400px" />
|
border="1px solid"
|
||||||
</CardBody>
|
borderColor="rgba(212, 175, 55, 0.3)"
|
||||||
</ThemedCard>
|
borderRadius="xl"
|
||||||
|
>
|
||||||
|
<LoadingState message="数据加载中..." height="400px" />
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<SubTabContainer
|
<SubTabContainer
|
||||||
tabs={tabConfigs}
|
tabs={tabConfigs}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/views/Company/components/MarketDataView/services/marketService.ts
|
// src/views/Company/components/MarketDataView/services/marketService.ts
|
||||||
// MarketDataView API 服务层
|
// MarketDataView API 服务层
|
||||||
|
|
||||||
import { getApiBase } from '@utils/apiConfig';
|
import axios from '@utils/axiosConfig';
|
||||||
import { logger } from '@utils/logger';
|
import { logger } from '@utils/logger';
|
||||||
import type {
|
import type {
|
||||||
MarketSummary,
|
MarketSummary,
|
||||||
@@ -23,27 +23,6 @@ interface ApiResponse<T> {
|
|||||||
message?: string;
|
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 股票代码
|
* @param stockCode 股票代码
|
||||||
*/
|
*/
|
||||||
async getMarketSummary(stockCode: string): Promise<ApiResponse<MarketSummary>> {
|
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 天
|
* @param days 天数,默认 60 天
|
||||||
*/
|
*/
|
||||||
async getTradeData(stockCode: string, days: number = 60): Promise<ApiResponse<TradeDayData[]>> {
|
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 天
|
* @param days 天数,默认 30 天
|
||||||
*/
|
*/
|
||||||
async getFundingData(stockCode: string, days: number = 30): Promise<ApiResponse<FundingDayData[]>> {
|
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 天
|
* @param days 天数,默认 30 天
|
||||||
*/
|
*/
|
||||||
async getBigDealData(stockCode: string, days: number = 30): Promise<BigDealData> {
|
async getBigDealData(stockCode: string, days: number = 30): Promise<BigDealData> {
|
||||||
const response = await fetch(`${getBaseUrl()}/api/market/bigdeal/${stockCode}?days=${days}`);
|
const { data } = await axios.get<BigDealData>(`/api/market/bigdeal/${stockCode}?days=${days}`);
|
||||||
if (!response.ok) {
|
return data;
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,11 +72,8 @@ export const marketService = {
|
|||||||
* @param days 天数,默认 30 天
|
* @param days 天数,默认 30 天
|
||||||
*/
|
*/
|
||||||
async getUnusualData(stockCode: string, days: number = 30): Promise<UnusualData> {
|
async getUnusualData(stockCode: string, days: number = 30): Promise<UnusualData> {
|
||||||
const response = await fetch(`${getBaseUrl()}/api/market/unusual/${stockCode}?days=${days}`);
|
const { data } = await axios.get<UnusualData>(`/api/market/unusual/${stockCode}?days=${days}`);
|
||||||
if (!response.ok) {
|
return data;
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,7 +81,8 @@ export const marketService = {
|
|||||||
* @param stockCode 股票代码
|
* @param stockCode 股票代码
|
||||||
*/
|
*/
|
||||||
async getPledgeData(stockCode: string): Promise<ApiResponse<PledgeData[]>> {
|
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) {
|
if (startDate && endDate) {
|
||||||
url += `?start_date=${startDate}&end_date=${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> {
|
async getMinuteData(stockCode: string): Promise<MinuteData> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${getBaseUrl()}/api/stock/${stockCode}/latest-minute`, {
|
const { data } = await axios.get<MinuteData>(`/api/stock/${stockCode}/latest-minute`);
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch minute data');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.data && Array.isArray(data.data)) {
|
if (data.data && Array.isArray(data.data)) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { SearchIcon } from '@chakra-ui/icons';
|
import { SearchIcon } from '@chakra-ui/icons';
|
||||||
import { BarChart2 } from 'lucide-react';
|
import { BarChart2 } from 'lucide-react';
|
||||||
|
import { useStockSearch, type Stock } from '../../../hooks/useStockSearch';
|
||||||
|
|
||||||
interface CompareStockInputProps {
|
interface CompareStockInputProps {
|
||||||
onCompare: (stockCode: string) => void;
|
onCompare: (stockCode: string) => void;
|
||||||
@@ -25,11 +26,6 @@ interface CompareStockInputProps {
|
|||||||
currentStockCode?: string;
|
currentStockCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Stock {
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RootState {
|
interface RootState {
|
||||||
stock: {
|
stock: {
|
||||||
allStocks: Stock[];
|
allStocks: Stock[];
|
||||||
@@ -43,7 +39,6 @@ const CompareStockInput: React.FC<CompareStockInputProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [showDropdown, setShowDropdown] = useState(false);
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
const [filteredStocks, setFilteredStocks] = useState<Stock[]>([]);
|
|
||||||
const [selectedStock, setSelectedStock] = useState<Stock | null>(null);
|
const [selectedStock, setSelectedStock] = useState<Stock | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -55,25 +50,16 @@ const CompareStockInput: React.FC<CompareStockInputProps> = ({
|
|||||||
const goldColor = '#F4D03F';
|
const goldColor = '#F4D03F';
|
||||||
const bgColor = '#1A202C';
|
const bgColor = '#1A202C';
|
||||||
|
|
||||||
// 模糊搜索过滤
|
// 使用共享的搜索 Hook(排除当前股票)
|
||||||
|
const filteredStocks = useStockSearch(allStocks, inputValue, {
|
||||||
|
excludeCode: currentStockCode,
|
||||||
|
limit: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 根据搜索结果更新下拉显示状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inputValue && inputValue.trim()) {
|
setShowDropdown(filteredStocks.length > 0 && !!inputValue?.trim());
|
||||||
const searchTerm = inputValue.trim().toLowerCase();
|
}, [filteredStocks, inputValue]);
|
||||||
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]);
|
|
||||||
|
|
||||||
// 点击外部关闭下拉
|
// 点击外部关闭下拉
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* StockQuoteCard Hooks 导出索引
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { useStockQuoteData } from './useStockQuoteData';
|
||||||
|
export { useStockCompare } from './useStockCompare';
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* useStockCompare - 股票对比逻辑 Hook
|
||||||
|
*
|
||||||
|
* 管理股票对比所需的数据获取和状态
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useToast } from '@chakra-ui/react';
|
||||||
|
import { financialService } from '@services/financialService';
|
||||||
|
import { logger } from '@utils/logger';
|
||||||
|
import type { StockInfo } from '../../FinancialPanorama/types';
|
||||||
|
|
||||||
|
interface UseStockCompareResult {
|
||||||
|
currentStockInfo: StockInfo | null;
|
||||||
|
compareStockInfo: StockInfo | null;
|
||||||
|
isCompareLoading: boolean;
|
||||||
|
handleCompare: (compareCode: string) => Promise<void>;
|
||||||
|
clearCompare: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 股票对比 Hook
|
||||||
|
*
|
||||||
|
* @param stockCode - 当前股票代码
|
||||||
|
*/
|
||||||
|
export const useStockCompare = (stockCode?: string): UseStockCompareResult => {
|
||||||
|
const toast = useToast();
|
||||||
|
const [currentStockInfo, setCurrentStockInfo] = useState<StockInfo | null>(null);
|
||||||
|
const [compareStockInfo, setCompareStockInfo] = useState<StockInfo | null>(null);
|
||||||
|
const [isCompareLoading, setIsCompareLoading] = useState(false);
|
||||||
|
|
||||||
|
// 加载当前股票财务信息(用于对比)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCurrentStockInfo = async () => {
|
||||||
|
if (!stockCode) {
|
||||||
|
setCurrentStockInfo(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await financialService.getStockInfo(stockCode);
|
||||||
|
setCurrentStockInfo(res.data);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('useStockCompare', 'loadCurrentStockInfo', error, { stockCode });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCurrentStockInfo();
|
||||||
|
// 股票代码变化时清除对比数据
|
||||||
|
setCompareStockInfo(null);
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
|
// 处理股票对比
|
||||||
|
const handleCompare = useCallback(async (compareCode: string) => {
|
||||||
|
if (!compareCode) return;
|
||||||
|
|
||||||
|
logger.debug('useStockCompare', '开始加载对比数据', { stockCode, compareCode });
|
||||||
|
setIsCompareLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await financialService.getStockInfo(compareCode);
|
||||||
|
setCompareStockInfo(res.data);
|
||||||
|
logger.info('useStockCompare', '对比数据加载成功', { stockCode, compareCode });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('useStockCompare', 'handleCompare', error, { stockCode, compareCode });
|
||||||
|
toast({
|
||||||
|
title: '加载对比数据失败',
|
||||||
|
description: '请检查股票代码是否正确',
|
||||||
|
status: 'error',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCompareLoading(false);
|
||||||
|
}
|
||||||
|
}, [stockCode, toast]);
|
||||||
|
|
||||||
|
// 清除对比数据
|
||||||
|
const clearCompare = useCallback(() => {
|
||||||
|
setCompareStockInfo(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentStockInfo,
|
||||||
|
compareStockInfo,
|
||||||
|
isCompareLoading,
|
||||||
|
handleCompare,
|
||||||
|
clearCompare,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useStockCompare;
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* useStockQuoteData - 股票行情数据获取 Hook
|
||||||
|
*
|
||||||
|
* 合并获取行情数据和基本信息,供 StockQuoteCard 内部使用
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { stockService } from '@services/eventService';
|
||||||
|
import { logger } from '@utils/logger';
|
||||||
|
import axios from '@utils/axiosConfig';
|
||||||
|
import type { StockQuoteCardData } from '../types';
|
||||||
|
import type { BasicInfo } from '../../CompanyOverview/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 API 响应数据转换为 StockQuoteCard 所需格式
|
||||||
|
*/
|
||||||
|
const transformQuoteData = (apiData: any, stockCode: string): StockQuoteCardData | null => {
|
||||||
|
if (!apiData) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 基础信息
|
||||||
|
name: apiData.name || apiData.stock_name || '未知',
|
||||||
|
code: apiData.code || apiData.stock_code || stockCode,
|
||||||
|
indexTags: apiData.index_tags || apiData.indexTags || [],
|
||||||
|
industry: apiData.industry || apiData.sw_industry_l2 || '',
|
||||||
|
industryL1: apiData.industry_l1 || apiData.sw_industry_l1 || '',
|
||||||
|
|
||||||
|
// 价格信息
|
||||||
|
currentPrice: apiData.current_price || apiData.currentPrice || apiData.close || 0,
|
||||||
|
changePercent: apiData.change_percent || apiData.changePercent || apiData.pct_chg || 0,
|
||||||
|
todayOpen: apiData.today_open || apiData.todayOpen || apiData.open || 0,
|
||||||
|
yesterdayClose: apiData.yesterday_close || apiData.yesterdayClose || apiData.pre_close || 0,
|
||||||
|
todayHigh: apiData.today_high || apiData.todayHigh || apiData.high || 0,
|
||||||
|
todayLow: apiData.today_low || apiData.todayLow || apiData.low || 0,
|
||||||
|
|
||||||
|
// 关键指标
|
||||||
|
pe: apiData.pe || apiData.pe_ttm || 0,
|
||||||
|
eps: apiData.eps || apiData.basic_eps || undefined,
|
||||||
|
pb: apiData.pb || apiData.pb_mrq || 0,
|
||||||
|
marketCap: apiData.market_cap || apiData.marketCap || apiData.circ_mv || '0',
|
||||||
|
week52Low: apiData.week52_low || apiData.week52Low || 0,
|
||||||
|
week52High: apiData.week52_high || apiData.week52High || 0,
|
||||||
|
|
||||||
|
// 主力动态
|
||||||
|
mainNetInflow: apiData.main_net_inflow || apiData.mainNetInflow || 0,
|
||||||
|
institutionHolding: apiData.institution_holding || apiData.institutionHolding || 0,
|
||||||
|
buyRatio: apiData.buy_ratio || apiData.buyRatio || 50,
|
||||||
|
sellRatio: apiData.sell_ratio || apiData.sellRatio || 50,
|
||||||
|
|
||||||
|
// 更新时间
|
||||||
|
updateTime: apiData.update_time || apiData.updateTime || new Date().toLocaleString(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseStockQuoteDataResult {
|
||||||
|
quoteData: StockQuoteCardData | null;
|
||||||
|
basicInfo: BasicInfo | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 股票行情数据获取 Hook
|
||||||
|
* 合并获取行情数据和基本信息
|
||||||
|
*
|
||||||
|
* @param stockCode - 股票代码
|
||||||
|
*/
|
||||||
|
export const useStockQuoteData = (stockCode?: string): UseStockQuoteDataResult => {
|
||||||
|
const [quoteData, setQuoteData] = useState<StockQuoteCardData | null>(null);
|
||||||
|
const [basicInfo, setBasicInfo] = useState<BasicInfo | null>(null);
|
||||||
|
const [quoteLoading, setQuoteLoading] = useState(false);
|
||||||
|
const [basicLoading, setBasicLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 用于手动刷新的 ref
|
||||||
|
const refetchRef = useCallback(async () => {
|
||||||
|
if (!stockCode) return;
|
||||||
|
|
||||||
|
// 获取行情数据
|
||||||
|
setQuoteLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
logger.debug('useStockQuoteData', '获取股票行情', { stockCode });
|
||||||
|
const quotes = await stockService.getQuotes([stockCode]);
|
||||||
|
const quoteResult = quotes?.[stockCode] || quotes;
|
||||||
|
const transformedData = transformQuoteData(quoteResult, stockCode);
|
||||||
|
logger.debug('useStockQuoteData', '行情数据转换完成', { stockCode, hasData: !!transformedData });
|
||||||
|
setQuoteData(transformedData);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('useStockQuoteData', '获取行情失败', err);
|
||||||
|
setError('获取行情数据失败');
|
||||||
|
setQuoteData(null);
|
||||||
|
} finally {
|
||||||
|
setQuoteLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取基本信息
|
||||||
|
setBasicLoading(true);
|
||||||
|
try {
|
||||||
|
const { data: result } = await axios.get(`/api/stock/${stockCode}/basic-info`);
|
||||||
|
if (result.success) {
|
||||||
|
setBasicInfo(result.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('useStockQuoteData', '获取基本信息失败', err);
|
||||||
|
} finally {
|
||||||
|
setBasicLoading(false);
|
||||||
|
}
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
|
// stockCode 变化时重新获取数据(带取消支持)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!stockCode) {
|
||||||
|
setQuoteData(null);
|
||||||
|
setBasicInfo(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
// 获取行情数据
|
||||||
|
setQuoteLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
logger.debug('useStockQuoteData', '获取股票行情', { stockCode });
|
||||||
|
const quotes = await stockService.getQuotes([stockCode]);
|
||||||
|
if (isCancelled) return;
|
||||||
|
const quoteResult = quotes?.[stockCode] || quotes;
|
||||||
|
const transformedData = transformQuoteData(quoteResult, stockCode);
|
||||||
|
logger.debug('useStockQuoteData', '行情数据转换完成', { stockCode, hasData: !!transformedData });
|
||||||
|
setQuoteData(transformedData);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (isCancelled || err.name === 'CanceledError') return;
|
||||||
|
logger.error('useStockQuoteData', '获取行情失败', err);
|
||||||
|
setError('获取行情数据失败');
|
||||||
|
setQuoteData(null);
|
||||||
|
} finally {
|
||||||
|
if (!isCancelled) setQuoteLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取基本信息
|
||||||
|
setBasicLoading(true);
|
||||||
|
try {
|
||||||
|
const { data: result } = await axios.get(`/api/stock/${stockCode}/basic-info`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (isCancelled) return;
|
||||||
|
if (result.success) {
|
||||||
|
setBasicInfo(result.data);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (isCancelled || err.name === 'CanceledError') return;
|
||||||
|
logger.error('useStockQuoteData', '获取基本信息失败', err);
|
||||||
|
} finally {
|
||||||
|
if (!isCancelled) setBasicLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
quoteData,
|
||||||
|
basicInfo,
|
||||||
|
isLoading: quoteLoading || basicLoading,
|
||||||
|
error,
|
||||||
|
refetch: refetchRef,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useStockQuoteData;
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
*
|
*
|
||||||
* 展示股票的实时行情、关键指标和主力动态
|
* 展示股票的实时行情、关键指标和主力动态
|
||||||
* 采用原子组件拆分,提高可维护性和复用性
|
* 采用原子组件拆分,提高可维护性和复用性
|
||||||
|
*
|
||||||
|
* 优化:数据获取已下沉到组件内部,Props 从 11 个精简为 4 个
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -26,42 +28,46 @@ import {
|
|||||||
StockCompareModal,
|
StockCompareModal,
|
||||||
STOCK_CARD_THEME,
|
STOCK_CARD_THEME,
|
||||||
} from './components';
|
} from './components';
|
||||||
|
import { useStockQuoteData, useStockCompare } from './hooks';
|
||||||
import type { StockQuoteCardProps } from './types';
|
import type { StockQuoteCardProps } from './types';
|
||||||
|
|
||||||
const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||||
data,
|
stockCode,
|
||||||
isLoading = false,
|
|
||||||
isInWatchlist = false,
|
isInWatchlist = false,
|
||||||
isWatchlistLoading = false,
|
isWatchlistLoading = false,
|
||||||
onWatchlistToggle,
|
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 { isOpen: isCompareModalOpen, onOpen: openCompareModal, onClose: closeCompareModal } = useDisclosure();
|
||||||
|
|
||||||
// 处理对比按钮点击
|
// 处理对比按钮点击
|
||||||
const handleCompare = (stockCode: string) => {
|
const handleCompare = (compareCode: string) => {
|
||||||
onCompare?.(stockCode);
|
triggerCompare(compareCode);
|
||||||
openCompareModal();
|
openCompareModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理关闭对比弹窗
|
// 处理关闭对比弹窗
|
||||||
const handleCloseCompare = () => {
|
const handleCloseCompare = () => {
|
||||||
closeCompareModal();
|
closeCompareModal();
|
||||||
onCloseCompare?.();
|
clearCompare();
|
||||||
};
|
};
|
||||||
|
|
||||||
const { cardBg, borderColor } = STOCK_CARD_THEME;
|
const { cardBg, borderColor } = STOCK_CARD_THEME;
|
||||||
|
|
||||||
// 加载中或无数据时显示骨架屏
|
// 加载中或无数据时显示骨架屏
|
||||||
if (isLoading || !data) {
|
if (isLoading || !quoteData) {
|
||||||
return (
|
return (
|
||||||
<Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}>
|
<Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
@@ -80,16 +86,15 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
|||||||
<CardBody>
|
<CardBody>
|
||||||
{/* 顶部:股票名称 + 关注/分享按钮 + 更新时间 */}
|
{/* 顶部:股票名称 + 关注/分享按钮 + 更新时间 */}
|
||||||
<StockHeader
|
<StockHeader
|
||||||
name={data.name}
|
name={quoteData.name}
|
||||||
code={data.code}
|
code={quoteData.code}
|
||||||
industryL1={data.industryL1}
|
industryL1={quoteData.industryL1}
|
||||||
industry={data.industry}
|
industry={quoteData.industry}
|
||||||
indexTags={data.indexTags}
|
indexTags={quoteData.indexTags}
|
||||||
updateTime={data.updateTime}
|
updateTime={quoteData.updateTime}
|
||||||
isInWatchlist={isInWatchlist}
|
isInWatchlist={isInWatchlist}
|
||||||
isWatchlistLoading={isWatchlistLoading}
|
isWatchlistLoading={isWatchlistLoading}
|
||||||
onWatchlistToggle={onWatchlistToggle}
|
onWatchlistToggle={onWatchlistToggle}
|
||||||
onShare={onShare}
|
|
||||||
isCompareLoading={isCompareLoading}
|
isCompareLoading={isCompareLoading}
|
||||||
onCompare={handleCompare}
|
onCompare={handleCompare}
|
||||||
/>
|
/>
|
||||||
@@ -98,7 +103,7 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
|||||||
<StockCompareModal
|
<StockCompareModal
|
||||||
isOpen={isCompareModalOpen}
|
isOpen={isCompareModalOpen}
|
||||||
onClose={handleCloseCompare}
|
onClose={handleCloseCompare}
|
||||||
currentStock={data.code}
|
currentStock={quoteData.code}
|
||||||
currentStockInfo={currentStockInfo || null}
|
currentStockInfo={currentStockInfo || null}
|
||||||
compareStock={compareStockInfo?.stock_code || ''}
|
compareStock={compareStockInfo?.stock_code || ''}
|
||||||
compareStockInfo={compareStockInfo || null}
|
compareStockInfo={compareStockInfo || null}
|
||||||
@@ -110,32 +115,32 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
|||||||
{/* 左栏:价格信息 (flex=1) */}
|
{/* 左栏:价格信息 (flex=1) */}
|
||||||
<Box flex="1" minWidth="0">
|
<Box flex="1" minWidth="0">
|
||||||
<PriceDisplay
|
<PriceDisplay
|
||||||
currentPrice={data.currentPrice}
|
currentPrice={quoteData.currentPrice}
|
||||||
changePercent={data.changePercent}
|
changePercent={quoteData.changePercent}
|
||||||
/>
|
/>
|
||||||
<SecondaryQuote
|
<SecondaryQuote
|
||||||
todayOpen={data.todayOpen}
|
todayOpen={quoteData.todayOpen}
|
||||||
yesterdayClose={data.yesterdayClose}
|
yesterdayClose={quoteData.yesterdayClose}
|
||||||
todayHigh={data.todayHigh}
|
todayHigh={quoteData.todayHigh}
|
||||||
todayLow={data.todayLow}
|
todayLow={quoteData.todayLow}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 右栏:关键指标 + 主力动态 (flex=2) */}
|
{/* 右栏:关键指标 + 主力动态 (flex=2) */}
|
||||||
<Flex flex="2" minWidth="0" gap={8} borderLeftWidth="1px" borderColor={borderColor} pl={8}>
|
<Flex flex="2" minWidth="0" gap={8} borderLeftWidth="1px" borderColor={borderColor} pl={8}>
|
||||||
<KeyMetrics
|
<KeyMetrics
|
||||||
pe={data.pe}
|
pe={quoteData.pe}
|
||||||
eps={data.eps}
|
eps={quoteData.eps}
|
||||||
pb={data.pb}
|
pb={quoteData.pb}
|
||||||
marketCap={data.marketCap}
|
marketCap={quoteData.marketCap}
|
||||||
week52Low={data.week52Low}
|
week52Low={quoteData.week52Low}
|
||||||
week52High={data.week52High}
|
week52High={quoteData.week52High}
|
||||||
/>
|
/>
|
||||||
<MainForceInfo
|
<MainForceInfo
|
||||||
mainNetInflow={data.mainNetInflow}
|
mainNetInflow={quoteData.mainNetInflow}
|
||||||
institutionHolding={data.institutionHolding}
|
institutionHolding={quoteData.institutionHolding}
|
||||||
buyRatio={data.buyRatio}
|
buyRatio={quoteData.buyRatio}
|
||||||
sellRatio={data.sellRatio}
|
sellRatio={quoteData.sellRatio}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import type { StockQuoteCardData } from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 贵州茅台 Mock 数据
|
|
||||||
*/
|
|
||||||
export const mockStockQuoteData: StockQuoteCardData = {
|
|
||||||
// 基础信息
|
|
||||||
name: '贵州茅台',
|
|
||||||
code: '600519.SH',
|
|
||||||
indexTags: ['沪深300'],
|
|
||||||
|
|
||||||
// 价格信息
|
|
||||||
currentPrice: 2178.5,
|
|
||||||
changePercent: 3.65,
|
|
||||||
todayOpen: 2156.0,
|
|
||||||
yesterdayClose: 2101.0,
|
|
||||||
todayHigh: 2185.0,
|
|
||||||
todayLow: 2150.0,
|
|
||||||
|
|
||||||
// 关键指标
|
|
||||||
pe: 38.62,
|
|
||||||
pb: 14.82,
|
|
||||||
marketCap: '2.73万亿',
|
|
||||||
week52Low: 1980,
|
|
||||||
week52High: 2350,
|
|
||||||
|
|
||||||
// 主力动态
|
|
||||||
mainNetInflow: 1.28,
|
|
||||||
institutionHolding: 72.35,
|
|
||||||
buyRatio: 85,
|
|
||||||
sellRatio: 15,
|
|
||||||
|
|
||||||
// 更新时间
|
|
||||||
updateTime: '2025-12-03 14:30:25',
|
|
||||||
|
|
||||||
// 自选状态
|
|
||||||
isFavorite: false,
|
|
||||||
};
|
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
* StockQuoteCard 组件类型定义
|
* StockQuoteCard 组件类型定义
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BasicInfo } from '../CompanyOverview/types';
|
// 注:BasicInfo 和 StockInfo 类型由内部 hooks 使用,不再在 Props 中传递
|
||||||
import type { StockInfo } from '../FinancialPanorama/types';
|
export type { StockInfo } from '../FinancialPanorama/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 股票行情卡片数据
|
* 股票行情卡片数据
|
||||||
@@ -46,26 +46,18 @@ export interface StockQuoteCardData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* StockQuoteCard 组件 Props
|
* StockQuoteCard 组件 Props(优化后)
|
||||||
|
*
|
||||||
|
* 行情数据、基本信息、对比逻辑已下沉到组件内部 hooks 获取
|
||||||
|
* Props 从 11 个精简为 4 个
|
||||||
*/
|
*/
|
||||||
export interface StockQuoteCardProps {
|
export interface StockQuoteCardProps {
|
||||||
data?: StockQuoteCardData;
|
/** 股票代码 - 用于内部数据获取 */
|
||||||
isLoading?: boolean;
|
stockCode?: string;
|
||||||
// 自选股相关(与 WatchlistButton 接口保持一致)
|
/** 是否在自选股中(保留:涉及 Redux 和事件追踪回调) */
|
||||||
isInWatchlist?: boolean; // 是否在自选股中
|
isInWatchlist?: boolean;
|
||||||
isWatchlistLoading?: boolean; // 自选股操作加载中
|
/** 自选股操作加载中 */
|
||||||
onWatchlistToggle?: () => void; // 自选股切换回调
|
isWatchlistLoading?: boolean;
|
||||||
// 分享
|
/** 自选股切换回调 */
|
||||||
onShare?: () => void; // 分享回调
|
onWatchlistToggle?: () => void;
|
||||||
// 公司基本信息
|
|
||||||
basicInfo?: BasicInfo;
|
|
||||||
// 股票对比相关
|
|
||||||
currentStockInfo?: StockInfo; // 当前股票财务信息(用于对比)
|
|
||||||
compareStockInfo?: StockInfo; // 对比股票财务信息
|
|
||||||
isCompareLoading?: boolean; // 对比数据加载中
|
|
||||||
onCompare?: (stockCode: string) => void; // 触发对比回调
|
|
||||||
onCloseCompare?: () => void; // 关闭对比弹窗回调
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新导出 StockInfo 类型以便外部使用
|
|
||||||
export type { StockInfo };
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
59
src/views/Company/hooks/useStockSearch.ts
Normal file
59
src/views/Company/hooks/useStockSearch.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* useStockSearch - 股票模糊搜索 Hook
|
||||||
|
*
|
||||||
|
* 提取自 SearchBar.js 和 CompareStockInput.tsx 的共享搜索逻辑
|
||||||
|
* 支持按代码或名称搜索,可选排除指定股票
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export interface Stock {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseStockSearchOptions {
|
||||||
|
excludeCode?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 股票模糊搜索 Hook
|
||||||
|
*
|
||||||
|
* @param allStocks - 全部股票列表
|
||||||
|
* @param searchTerm - 搜索关键词
|
||||||
|
* @param options - 可选配置
|
||||||
|
* @param options.excludeCode - 排除的股票代码(用于对比场景)
|
||||||
|
* @param options.limit - 返回结果数量限制,默认 10
|
||||||
|
* @returns 过滤后的股票列表
|
||||||
|
*/
|
||||||
|
export const useStockSearch = (
|
||||||
|
allStocks: Stock[],
|
||||||
|
searchTerm: string,
|
||||||
|
options: UseStockSearchOptions = {}
|
||||||
|
): Stock[] => {
|
||||||
|
const { excludeCode, limit = 10 } = options;
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const trimmed = searchTerm?.trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
|
||||||
|
const term = trimmed.toLowerCase();
|
||||||
|
|
||||||
|
return allStocks
|
||||||
|
.filter((stock) => {
|
||||||
|
// 排除指定股票
|
||||||
|
if (excludeCode && stock.code === excludeCode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 按代码或名称匹配
|
||||||
|
return (
|
||||||
|
stock.code.toLowerCase().includes(term) ||
|
||||||
|
stock.name.includes(trimmed)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.slice(0, limit);
|
||||||
|
}, [allStocks, searchTerm, excludeCode, limit]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useStockSearch;
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
// src/views/Company/index.js
|
// src/views/Company/index.js
|
||||||
// 公司详情页面入口 - 纯组合层
|
// 公司详情页面入口 - 纯组合层
|
||||||
|
//
|
||||||
|
// 优化:行情数据、基本信息、对比逻辑已下沉到 StockQuoteCard 内部
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { Container, VStack, useToast } from '@chakra-ui/react';
|
import { Container, VStack } from '@chakra-ui/react';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { loadAllStocks } from '@store/slices/stockSlice';
|
import { loadAllStocks } from '@store/slices/stockSlice';
|
||||||
import { financialService } from '@services/financialService';
|
|
||||||
import { logger } from '@utils/logger';
|
|
||||||
|
|
||||||
// 自定义 Hooks
|
// 自定义 Hooks
|
||||||
import { useCompanyStock } from './hooks/useCompanyStock';
|
import { useCompanyStock } from './hooks/useCompanyStock';
|
||||||
import { useCompanyWatchlist } from './hooks/useCompanyWatchlist';
|
import { useCompanyWatchlist } from './hooks/useCompanyWatchlist';
|
||||||
import { useCompanyEvents } from './hooks/useCompanyEvents';
|
import { useCompanyEvents } from './hooks/useCompanyEvents';
|
||||||
import { useStockQuote } from './hooks/useStockQuote';
|
|
||||||
import { useBasicInfo } from './components/CompanyOverview/hooks/useBasicInfo';
|
|
||||||
|
|
||||||
// 页面组件
|
// 页面组件
|
||||||
import CompanyHeader from './components/CompanyHeader';
|
import CompanyHeader from './components/CompanyHeader';
|
||||||
@@ -31,9 +29,8 @@ import CompanyTabs from './components/CompanyTabs';
|
|||||||
*/
|
*/
|
||||||
const CompanyIndex = () => {
|
const CompanyIndex = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const toast = useToast();
|
|
||||||
|
|
||||||
// 1. 先获取股票代码(不带追踪回调)
|
// 1. 获取股票代码(不带追踪回调)
|
||||||
const {
|
const {
|
||||||
stockCode,
|
stockCode,
|
||||||
inputCode,
|
inputCode,
|
||||||
@@ -47,64 +44,7 @@ const CompanyIndex = () => {
|
|||||||
dispatch(loadAllStocks());
|
dispatch(loadAllStocks());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
// 2. 获取股票行情数据
|
// 2. 初始化事件追踪(传入 stockCode)
|
||||||
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)
|
|
||||||
const {
|
const {
|
||||||
trackStockSearched,
|
trackStockSearched,
|
||||||
trackTabChanged,
|
trackTabChanged,
|
||||||
@@ -147,19 +87,12 @@ const CompanyIndex = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 股票行情卡片:价格、关键指标、主力动态、公司信息、股票对比 */}
|
{/* 股票行情卡片:价格、关键指标、主力动态、公司信息、股票对比 */}
|
||||||
|
{/* 优化:数据获取已下沉到组件内部,Props 从 11 个精简为 4 个 */}
|
||||||
<StockQuoteCard
|
<StockQuoteCard
|
||||||
data={quoteData}
|
stockCode={stockCode}
|
||||||
isLoading={isQuoteLoading}
|
|
||||||
isInWatchlist={isInWatchlist}
|
isInWatchlist={isInWatchlist}
|
||||||
isWatchlistLoading={isWatchlistLoading}
|
isWatchlistLoading={isWatchlistLoading}
|
||||||
onWatchlistToggle={handleWatchlistToggle}
|
onWatchlistToggle={handleWatchlistToggle}
|
||||||
basicInfo={basicInfo}
|
|
||||||
// 股票对比相关
|
|
||||||
currentStockInfo={currentStockInfo}
|
|
||||||
compareStockInfo={compareStockInfo}
|
|
||||||
isCompareLoading={isCompareLoading}
|
|
||||||
onCompare={handleCompare}
|
|
||||||
onCloseCompare={handleCloseCompare}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Tab 切换区域:概览、行情、财务、预测 */}
|
{/* Tab 切换区域:概览、行情、财务、预测 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user