Compare commits

...

33 Commits

Author SHA1 Message Date
zdl
1cd8a2d7e9 fix: 颜色调整 2025-12-10 15:56:08 +08:00
zdl
af3cdc24b1 style: CompanyHeaderCard 黑金主题三区块布局重构
- 布局调整:从两栏(8:4)改为垂直三区块(身份分类 | 关键属性 | 公司介绍)
- 黑金主题:卡片背景 gray.800,金色强调色 #D4AF37
- 移除字段:法定代表人、董事长、总经理、邮箱、电话
- 保留字段:公司名称、代码、行业分类、成立日期、注册资本、所在地、官网、公司介绍
- CompanyTabs: TabPanel 去掉左右边距

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 15:53:50 +08:00
zdl
bfb6ef63d0 refactor: MarketDataView TypeScript 重构 - 2060 行拆分为 12 个模块
- 将原 index.js (2060 行) 重构为 TypeScript 模块化架构
- 新增 types.ts: 383 行类型定义 (Theme, TradeDayData, MinuteData 等)
- 新增 services/marketService.ts: API 服务层封装
- 新增 hooks/useMarketData.ts: 数据获取 Hook
- 新增 utils/formatUtils.ts: 格式化工具函数
- 新增 utils/chartOptions.ts: ECharts 图表配置生成器 (698 行)
- 新增 components/: ThemedCard, MarkdownRenderer, StockSummaryCard, AnalysisModal
- 添加 Company/STRUCTURE.md 目录结构文档

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 15:14:23 +08:00
zdl
722d038b56 Merge branch 'feature_bugfix/251201_py_h5_ui' into feature_2025/251209_stock_pref
* feature_bugfix/251201_py_h5_ui:
  feat: Company 页面搜索框添加股票模糊搜索功能
  update pay ui
  update pay ui
  fix: 个股中心bug修复
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  feat: 替换公众号文件
  update pay ui
2025-12-10 14:30:25 +08:00
zdl
5f6e4387e5 perf: CompanyOverview 内层 Tab 懒加载优化
- 将 useCompanyOverviewData(9个API)拆分为独立 Hooks:
  - useBasicInfo: 基本信息(首屏唯一加载)
  - useShareholderData: 股东信息(4个API)
  - useManagementData: 管理层信息
  - useAnnouncementsData: 公告数据
  - useBranchesData: 分支机构
  - useDisclosureData: 披露日程
- BasicInfoTab 使用子组件实现真正的懒加载:
  - ShareholderTabPanel、ManagementTabPanel 等
  - 配合 Chakra UI isLazy,切换 Tab 时才加载数据
- 首屏 API 请求从 9 个减少到 1 个

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 13:05:27 +08:00
zdl
38076534b1 perf: CompanyTabs 添加 isLazy 实现 Tab 懒加载
- 页面打开时只渲染第一个 Tab(CompanyOverview)
- 其他 Tab(深度分析、行情、财务、预测、动态跟踪)点击时才渲染和请求
- 减少首屏请求数量,提升加载性能

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:40:34 +08:00
zdl
a7ab87f7c4 feat: StockQuoteCard 顶部导航区视觉优化
- 股票名称字号放大至 26px,字重 800,突出显示
- 添加行业标签(金融 · 银行),Badge 边框样式
- 保留指数标签(沪深300、上证180)
- Mock 数据补充 industry、industry_l1、index_tags 字段
- 类型定义新增 industry、industryL1 可选字段

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:30:18 +08:00
zdl
9a77bb6f0b refactor: CompanyOverview 组件 TypeScript 拆分
- 新增 index.tsx: 主组件(组合层,50 行)
- 新增 CompanyHeaderCard.tsx: 头部卡片组件(168 行)
- 新增 hooks/useCompanyOverviewData.ts: 数据加载 Hook
- 删除 index.js: 原 330 行代码精简 85%
- 修复 Company/index.js: 恢复 CompanyTabs 渲染

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:21:02 +08:00
zdl
bf8847698b feat: CompanyOverview TypeScript 类型定义和工具函数
- types.ts: 添加公司基本信息、股东、管理层等接口定义
- utils.ts: 添加注册资本、日期格式化函数

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:02:24 +08:00
zdl
7c83ffe008 perf: loadWatchlist 添加 localStorage 缓存(7天有效期)
- 添加 loadWatchlistFromCache/saveWatchlistToCache 缓存工具函数
- loadWatchlist 三级缓存策略:Redux → localStorage → API
- toggleWatchlist 成功后自动同步更新缓存
- 减少重复 API 请求,提升页面加载性能

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:01:33 +08:00
zdl
8786fa7b06 feat: StockQuoteCard 根据股票代码获取真实行情数据
- 新增 useStockQuote Hook 获取股票行情
- Company 页面使用 Hook 并传递数据给 StockQuoteCard
- StockQuoteCard 处理 null 数据显示骨架屏
- 股票代码变化时自动刷新行情数据

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:00:03 +08:00
zdl
0997cd9992 feat: 搜索栏交互优化 - 移除查询按钮,选择后直接跳转
- SearchBar: 移除"查询"按钮,简化交互
- SearchBar: 选择股票后直接触发搜索跳转
- useCompanyStock: handleSearch 支持直接传入股票代码参数

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 10:52:04 +08:00
zdl
c8d704363d fix: 搜索框默认值改为空,避免下拉弹窗自动打开
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 10:35:01 +08:00
zdl
0de4a1f7af feat: SearchBar 模糊搜索功能
- SearchBar: 添加股票代码/名称模糊搜索下拉列表
- SearchBar: 使用 Redux allStocks 数据源进行过滤
- SearchBar: 点击外部自动关闭下拉,选择后自动搜索
- useCompanyStock: handleKeyPress 改为 handleKeyDown(兼容性优化)
- Company/index: 初始化时加载全部股票列表

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 10:25:31 +08:00
zdl
3382dd1036 feat: UI调整 2025-12-10 10:09:24 +08:00
zdl
9423094af2 pref: 移除 useColorModeValue
UI调整
2025-12-09 19:26:52 +08:00
zdl
4f38505a80 style: StockQuoteCard 黑金主题 UI 调整
颜色配置:
- 背景:纯黑 #000000
- 边框/标签:金色 #C9A961
- 主要文字:亮金 #F4D03F
- 涨:红色 #F44336(红涨绿跌)
- 跌:绿色 #4CAF50

字体大小:
- 股票价格:48px bold
- 股票名称/代码:24px bold
- 涨跌幅 Badge:20px bold
- 关键指标数值:16px bold
- 标签文字:14px

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 18:54:19 +08:00
zdl
4274341ed5 feat: 动态跟踪添加新闻动态二级 Tab
- 添加 Tabs 结构支持二级 Tab 扩展
- Tab1: 新闻动态(复用 NewsEventsTab 组件)
- 实现 loadNewsEvents 数据加载逻辑
- 支持搜索和分页功能
- 自动获取股票名称用于新闻搜索

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 18:46:37 +08:00
zdl
40f6eaced6 refactor: 移除暗色模式相关代码,使用固定浅色主题
- DeepAnalysisTab: 移除 useColorModeValue,使用固定颜色值
- NewsEventsTab: 移除 useColorModeValue,简化 hover 颜色
- FinancialPanorama: 移除 useColorMode/useColorModeValue
- MarketDataView: 移除 dark 主题配置,简化颜色逻辑
- StockQuoteCard: 移除 useColorModeValue,使用固定颜色

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 18:46:30 +08:00
zdl
2dd7dd755a refactor: Company 页面一级 Tab 重构为 6 个
- 新增深度分析 Tab(从 CompanyOverview 提取为独立组件)
- 新增动态跟踪 Tab(占位组件,后续添加内容)
- Tab 顺序:公司概览 | 深度分析 | 股票行情 | 财务全景 | 盈利预测 | 动态跟踪
- 简化 CompanyOverview:移除内部 Tabs,只保留头部卡片 + 基本信息
- DeepAnalysis 组件独立管理深度分析数据加载(3个接口)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 17:52:23 +08:00
zdl
04ce16df56 perf: CompanyOverview Tab 懒加载优化
- 拆分 loadData 为 loadBasicInfoData 和 loadDeepAnalysisData
- 首次加载仅请求 9 个基本信息接口(原 12 个)
- 深度分析 3 个接口切换 Tab 时按需加载
- 新闻动态 1 个接口切换 Tab 时按需加载
- 调整 Tab 顺序:基本信息 → 深度分析 → 新闻动态
- stockCode 变更时重置 Tab 状态和数据

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 17:37:11 +08:00
zdl
d7759b1da3 feat: 添加股票行情卡片 2025-12-09 17:26:58 +08:00
zdl
701f96855e feat: 添加mock数据 2025-12-09 17:24:54 +08:00
zdl
cd1a5b743f feat: 添加mock 2025-12-09 17:12:13 +08:00
zdl
18c83237e2 refactor: CompanyOverview 组件按 Tab 拆分为独立子组件
将 2682 行的大型组件拆分为 4 个模块化文件:
- index.js (~550行): 状态管理 + 数据加载 + Tab 容器
- DeepAnalysisTab.js (~1800行): 深度分析 Tab(核心定位、竞争力、产业链)
- BasicInfoTab.js (~940行): 基本信息 Tab(股权结构、管理团队、公告)
- NewsEventsTab.js (~540行): 新闻动态 Tab(事件列表 + 分页)

重构内容:
- 提取 8 个内部子组件到对应 Tab 文件
- 修复 useColorModeValue 在 map 回调中调用的 hooks 规则违规
- 清理未使用的 imports
- 完善公告详情模态框(补全 ModalFooter)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 17:11:42 +08:00
zdl
c1e10e6205 Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_2025/251209_stock_pref
* feature_bugfix/251201_vf_h5_ui:
  feat: 事件关注功能优化 - Redux 乐观更新 + Mock 数据状态同步
  feat: 投资日历自选股功能优化 - Redux 集成 + 乐观更新
  fix: 修复投资日历切换月份时自动打开事件弹窗的问题
  fix: 修复 CompanyOverview 中 Hooks 顺序错误
2025-12-09 16:36:46 +08:00
zdl
4954c58525 refactor: Company 目录结构重组 - Tab 内容组件文件夹化
- 将 4 个 Tab 内容组件移动到 components/ 目录下
  - CompanyOverview.js → components/CompanyOverview/index.js
  - MarketDataView.js → components/MarketDataView/index.js
  - FinancialPanorama.js → components/FinancialPanorama/index.js
  - ForecastReport.js → components/ForecastReport/index.js
- 更新 CompanyTabs/index.js 导入路径
- 更新 routes/lazy-components.js 路由路径
- 修复组件内相对路径导入,改用 @utils/@services 别名

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 15:31:58 +08:00
zdl
91bd581a5e feat: 添加 useCompanyStock 股票代码管理 2025-12-09 15:18:06 +08:00
zdl
258708fca0 fix: bug修复 2025-12-09 15:16:02 +08:00
zdl
90391729bb feat: 处理自选股乐观更新 2025-12-09 15:15:20 +08:00
zdl
2148d319ad feat: 添加mock 数据 2025-12-09 15:08:15 +08:00
zdl
c61d58b0e3 feat: 添加Company 页面 Tab 切换组件 2025-12-09 15:01:16 +08:00
zdl
ed1c7b9fa9 feat: 添加Company 页面头部组件 CompanyHeader
index.js            # 组合导出
SearchBar.js        # 股票搜索栏
WatchlistButton.js  # 自选股按钮
2025-12-09 14:59:24 +08:00
52 changed files with 10570 additions and 5405 deletions

View File

@@ -0,0 +1,84 @@
/**
* FavoriteButton - 通用关注/收藏按钮组件(图标按钮)
*/
import React from 'react';
import { IconButton, Tooltip, Spinner } from '@chakra-ui/react';
import { Star } from 'lucide-react';
export interface FavoriteButtonProps {
/** 是否已关注 */
isFavorite: boolean;
/** 加载状态 */
isLoading?: boolean;
/** 点击回调 */
onClick: () => void;
/** 按钮大小 */
size?: 'sm' | 'md' | 'lg';
/** 颜色主题 */
colorScheme?: 'gold' | 'default';
/** 是否显示 tooltip */
showTooltip?: boolean;
}
// 颜色配置
const COLORS = {
gold: {
active: '#F4D03F', // 已关注 - 亮金色
inactive: '#C9A961', // 未关注 - 暗金色
hoverBg: 'whiteAlpha.100',
},
default: {
active: 'yellow.400',
inactive: 'gray.400',
hoverBg: 'gray.100',
},
};
const FavoriteButton: React.FC<FavoriteButtonProps> = ({
isFavorite,
isLoading = false,
onClick,
size = 'sm',
colorScheme = 'gold',
showTooltip = true,
}) => {
const colors = COLORS[colorScheme];
const currentColor = isFavorite ? colors.active : colors.inactive;
const label = isFavorite ? '取消关注' : '加入自选';
const iconButton = (
<IconButton
aria-label={label}
icon={
isLoading ? (
<Spinner size="sm" color={currentColor} />
) : (
<Star
size={size === 'sm' ? 18 : size === 'md' ? 20 : 24}
fill={isFavorite ? currentColor : 'none'}
stroke={currentColor}
/>
)
}
variant="ghost"
color={currentColor}
size={size}
onClick={onClick}
isDisabled={isLoading}
_hover={{ bg: colors.hoverBg }}
/>
);
if (showTooltip) {
return (
<Tooltip label={label} placement="top">
{iconButton}
</Tooltip>
);
}
return iconButton;
};
export default FavoriteButton;

View File

@@ -544,19 +544,13 @@ const InvestmentCalendar = () => {
render: (concepts) => (
<Space wrap>
{concepts && concepts.length > 0 ? (
concepts.slice(0, 3).map((concept, index) => {
// 兼容多种数据格式:字符串、数组、对象
const conceptName = typeof concept === 'string'
? concept
: Array.isArray(concept)
? concept[0]
: concept?.concept || concept?.name || '';
return (
<Tag key={index} icon={<TagsOutlined />}>
{conceptName}
</Tag>
);
})
concepts.slice(0, 3).map((concept, index) => (
<Tag key={index} icon={<TagsOutlined />}>
{typeof concept === 'string'
? concept
: (concept?.concept || concept?.name || '未知')}
</Tag>
))
) : (
<Text type="secondary"></Text>
)}
@@ -948,7 +942,7 @@ const InvestmentCalendar = () => {
<Table
dataSource={selectedStocks}
columns={stockColumns}
rowKey={(record) => record[0]}
rowKey={(record) => record.code}
size="middle"
pagination={false}
/>

File diff suppressed because it is too large Load Diff

View File

@@ -43,12 +43,10 @@ export const companyHandlers = [
const { stockCode } = params;
const data = getCompanyData(stockCode);
// 直接返回 keyFactorsTimeline 对象(包含 key_factors 和 development_timeline
return HttpResponse.json({
success: true,
data: {
timeline: data.keyFactorsTimeline,
total: data.keyFactorsTimeline.length
}
data: data.keyFactorsTimeline
});
}),

View File

@@ -368,6 +368,25 @@ export const stockHandlers = [
stockMap[s.code] = s.name;
});
// 行业和指数映射表
const stockIndustryMap = {
'000001': { industry_l1: '金融', industry: '银行', index_tags: ['沪深300', '上证180'] },
'600519': { industry_l1: '消费', industry: '白酒', index_tags: ['沪深300', '上证50'] },
'300750': { industry_l1: '工业', industry: '电池', index_tags: ['创业板50'] },
'601318': { industry_l1: '金融', industry: '保险', index_tags: ['沪深300', '上证50'] },
'600036': { industry_l1: '金融', industry: '银行', index_tags: ['沪深300', '上证50'] },
'000858': { industry_l1: '消费', industry: '白酒', index_tags: ['沪深300'] },
'002594': { industry_l1: '汽车', industry: '乘用车', index_tags: ['沪深300', '创业板指'] },
};
const defaultIndustries = [
{ industry_l1: '科技', industry: '软件' },
{ industry_l1: '医药', industry: '化学制药' },
{ industry_l1: '消费', industry: '食品' },
{ industry_l1: '金融', industry: '证券' },
{ industry_l1: '工业', industry: '机械' },
];
// 为每只股票生成报价数据
const quotesData = {};
codes.forEach(stockCode => {
@@ -380,6 +399,11 @@ export const stockHandlers = [
// 昨收
const prevClose = parseFloat((basePrice - change).toFixed(2));
// 获取行业和指数信息
const codeWithoutSuffix = stockCode.replace(/\.(SH|SZ)$/i, '');
const industryInfo = stockIndustryMap[codeWithoutSuffix] ||
defaultIndustries[Math.floor(Math.random() * defaultIndustries.length)];
quotesData[stockCode] = {
code: stockCode,
name: stockMap[stockCode] || `股票${stockCode}`,
@@ -393,7 +417,11 @@ export const stockHandlers = [
volume: Math.floor(Math.random() * 100000000),
amount: parseFloat((Math.random() * 10000000000).toFixed(2)),
market: stockCode.startsWith('6') ? 'SH' : 'SZ',
update_time: new Date().toISOString()
update_time: new Date().toISOString(),
// 行业和指数标签
industry_l1: industryInfo.industry_l1,
industry: industryInfo.industry,
index_tags: industryInfo.index_tags || []
};
});

View File

@@ -35,9 +35,9 @@ export const lazyComponents = {
// 公司相关模块
CompanyIndex: React.lazy(() => import('@views/Company')),
ForecastReport: React.lazy(() => import('@views/Company/ForecastReport')),
FinancialPanorama: React.lazy(() => import('@views/Company/FinancialPanorama')),
MarketDataView: React.lazy(() => import('@views/Company/MarketDataView')),
ForecastReport: React.lazy(() => import('@views/Company/components/ForecastReport')),
FinancialPanorama: React.lazy(() => import('@views/Company/components/FinancialPanorama')),
MarketDataView: React.lazy(() => import('@views/Company/components/MarketDataView')),
// Agent模块
AgentChat: React.lazy(() => import('@views/AgentChat')),

View File

@@ -4,6 +4,56 @@ import { eventService, stockService } from '../../services/eventService';
import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig';
// ==================== Watchlist 缓存配置 ====================
const WATCHLIST_CACHE_KEY = 'watchlist_cache';
const WATCHLIST_CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7天
/**
* 从 localStorage 读取自选股缓存
*/
const loadWatchlistFromCache = () => {
try {
const cached = localStorage.getItem(WATCHLIST_CACHE_KEY);
if (!cached) return null;
const { data, timestamp } = JSON.parse(cached);
const now = Date.now();
// 检查缓存是否过期7天
if (now - timestamp > WATCHLIST_CACHE_DURATION) {
localStorage.removeItem(WATCHLIST_CACHE_KEY);
logger.debug('stockSlice', '自选股缓存已过期');
return null;
}
logger.debug('stockSlice', '自选股 localStorage 缓存命中', {
count: data?.length || 0,
age: Math.round((now - timestamp) / 1000 / 60) + '分钟前'
});
return data;
} catch (error) {
logger.error('stockSlice', 'loadWatchlistFromCache', error);
return null;
}
};
/**
* 保存自选股到 localStorage
*/
const saveWatchlistToCache = (data) => {
try {
localStorage.setItem(WATCHLIST_CACHE_KEY, JSON.stringify({
data,
timestamp: Date.now()
}));
logger.debug('stockSlice', '自选股已缓存到 localStorage', {
count: data?.length || 0
});
} catch (error) {
logger.error('stockSlice', 'saveWatchlistToCache', error);
}
};
// ==================== Async Thunks ====================
/**
@@ -153,13 +203,28 @@ export const fetchExpectationScore = createAsyncThunk(
/**
* 加载用户自选股列表(包含完整信息)
* 缓存策略Redux 内存缓存 → localStorage 持久缓存7天 → API 请求
*/
export const loadWatchlist = createAsyncThunk(
'stock/loadWatchlist',
async () => {
async (_, { getState }) => {
logger.debug('stockSlice', 'loadWatchlist');
try {
// 1. 先检查 Redux 内存缓存
const reduxCached = getState().stock.watchlist;
if (reduxCached && reduxCached.length > 0) {
logger.debug('stockSlice', 'Redux watchlist 缓存命中', { count: reduxCached.length });
return reduxCached;
}
// 2. 再检查 localStorage 持久缓存7天有效期
const localCached = loadWatchlistFromCache();
if (localCached && localCached.length > 0) {
return localCached;
}
// 3. 缓存无效,调用 API
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/api/account/watchlist`, {
credentials: 'include'
@@ -172,6 +237,10 @@ export const loadWatchlist = createAsyncThunk(
stock_code: item.stock_code,
stock_name: item.stock_name,
}));
// 保存到 localStorage 缓存
saveWatchlistToCache(watchlistData);
logger.debug('stockSlice', '自选股列表加载成功', {
count: watchlistData.length
});
@@ -340,6 +409,26 @@ const stockSlice = createSlice({
delete state.historicalEventsCache[eventId];
delete state.chainAnalysisCache[eventId];
delete state.expectationScores[eventId];
},
/**
* 乐观更新:添加自选股(同步)
*/
optimisticAddWatchlist: (state, action) => {
const { stockCode, stockName } = action.payload;
// 避免重复添加
const exists = state.watchlist.some(item => item.stock_code === stockCode);
if (!exists) {
state.watchlist.push({ stock_code: stockCode, stock_name: stockName || '' });
}
},
/**
* 乐观更新:移除自选股(同步)
*/
optimisticRemoveWatchlist: (state, action) => {
const { stockCode } = action.payload;
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
}
},
extraReducers: (builder) => {
@@ -470,9 +559,10 @@ const stockSlice = createSlice({
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
}
})
// fulfilled: 乐观更新模式下状态已在 pending 更新,这里无需操作
.addCase(toggleWatchlist.fulfilled, () => {
// 状态已在 pending 时更新
// fulfilled: 同步更新 localStorage 缓存
.addCase(toggleWatchlist.fulfilled, (state) => {
// 状态已在 pending 时更新,这里同步到 localStorage
saveWatchlistToCache(state.watchlist);
});
}
});
@@ -481,7 +571,9 @@ export const {
updateQuote,
updateQuotes,
clearQuotes,
clearEventCache
clearEventCache,
optimisticAddWatchlist,
optimisticRemoveWatchlist
} = stockSlice.actions;
export default stockSlice.reducer;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,460 @@
# Company 目录结构说明
> 最后更新2025-12-10
## 目录结构
```
src/views/Company/
├── index.js # 页面入口95行纯组合层
├── STRUCTURE.md # 本文档
├── components/ # UI 组件
│ ├── CompanyHeader/ # 页面头部
│ │ ├── index.js # 组合导出
│ │ ├── SearchBar.js # 股票搜索栏
│ │ └── WatchlistButton.js # 自选股按钮
│ │
│ ├── CompanyTabs/ # Tab 切换容器
│ │ ├── index.js # Tab 容器(状态管理 + 内容渲染)
│ │ └── TabNavigation.js # Tab 导航栏
│ │
│ ├── CompanyOverview/ # Tab: 公司概览TypeScript 拆分)
│ │ ├── index.tsx # 主组件(组合层,约 50 行)
│ │ ├── CompanyHeaderCard.tsx # 头部卡片组件(约 130 行)
│ │ ├── BasicInfoTab.js # 基本信息 Tab暂保持 JS
│ │ ├── DeepAnalysisTab.js # 深度分析 Tab
│ │ ├── NewsEventsTab.js # 新闻事件 Tab
│ │ ├── types.ts # 类型定义(约 50 行)
│ │ ├── utils.ts # 格式化工具(约 20 行)
│ │ └── hooks/
│ │ └── useCompanyOverviewData.ts # 数据 Hook约 100 行)
│ │
│ ├── MarketDataView/ # Tab: 股票行情TypeScript 拆分)
│ │ ├── index.tsx # 主组件入口(~1049 行)
│ │ ├── types.ts # 类型定义(~383 行)
│ │ ├── constants.ts # 主题配置、常量
│ │ ├── services/
│ │ │ └── marketService.ts # API 服务层
│ │ ├── hooks/
│ │ │ └── useMarketData.ts # 数据获取 Hook
│ │ ├── utils/
│ │ │ ├── formatUtils.ts # 格式化工具函数
│ │ │ └── chartOptions.ts # ECharts 图表配置生成器
│ │ └── components/
│ │ ├── index.ts # 组件导出
│ │ ├── ThemedCard.tsx # 主题化卡片
│ │ ├── MarkdownRenderer.tsx # Markdown 渲染
│ │ ├── StockSummaryCard.tsx # 股票概览卡片
│ │ └── AnalysisModal.tsx # 涨幅分析模态框
│ │
│ ├── FinancialPanorama/ # Tab: 财务全景2153 行,待拆分)
│ │ └── index.js
│ │
│ └── ForecastReport/ # Tab: 盈利预测161 行,待拆分)
│ └── index.js
├── hooks/ # 自定义 Hooks
│ ├── useCompanyStock.js # 股票代码管理URL 同步)
│ ├── useCompanyWatchlist.js # 自选股管理Redux 集成)
│ └── useCompanyEvents.js # PostHog 事件追踪
└── constants/ # 常量定义
└── index.js # Tab 配置、Toast 消息、默认值
```
---
## 文件职责说明
### 入口文件
#### `index.js` - 页面入口
- **职责**:纯组合层,协调 Hooks 和 Components
- **代码行数**95 行
- **依赖**
- `useCompanyStock` - 股票代码状态
- `useCompanyWatchlist` - 自选股状态
- `useCompanyEvents` - 事件追踪
- `CompanyHeader` - 页面头部
- `CompanyTabs` - Tab 切换区
---
### Hooks 目录
#### `useCompanyStock.js` - 股票代码管理
- **功能**
- 管理当前股票代码状态
- 双向同步 URL 参数(支持浏览器前进/后退)
- 处理搜索输入和提交
- **返回值**
```js
{
stockCode, // 当前确认的股票代码
inputCode, // 输入框中的值(未确认)
setInputCode, // 更新输入框
handleSearch, // 执行搜索
handleKeyPress, // 处理回车键
}
```
- **依赖**`react-router-dom` (useSearchParams)
#### `useCompanyWatchlist.js` - 自选股管理
- **功能**
- 检查当前股票是否在自选股中
- 提供添加/移除自选股功能
- 与 Redux stockSlice 同步
- **返回值**
```js
{
isInWatchlist, // 是否在自选股中
isLoading, // 操作进行中
toggle, // 切换自选状态
}
```
- **依赖**Redux (`stockSlice`)、`AuthContext`、Chakra UI (useToast)
#### `useCompanyEvents.js` - 事件追踪
- **功能**
- 页面浏览追踪
- 股票搜索追踪
- Tab 切换追踪
- 自选股操作追踪
- **返回值**
```js
{
trackStockSearched, // 追踪股票搜索
trackTabChanged, // 追踪 Tab 切换
trackWatchlistAdded, // 追踪添加自选
trackWatchlistRemoved, // 追踪移除自选
}
```
- **依赖**PostHog (`usePostHogTrack`)
---
### Components 目录
#### `CompanyHeader/` - 页面头部
| 文件 | 职责 |
|------|------|
| `index.js` | 组合 SearchBar 和 WatchlistButton |
| `SearchBar.js` | 股票代码搜索输入框 |
| `WatchlistButton.js` | 自选股添加/移除按钮 |
**Props 接口**
```js
<CompanyHeader
stockCode={string} // 当前股票代码
inputCode={string} // 输入框值
onInputChange={func} // 输入变化回调
onSearch={func} // 搜索回调
onKeyPress={func} // 键盘事件回调
isInWatchlist={bool} // 是否在自选中
isWatchlistLoading={bool} // 自选操作加载中
onWatchlistToggle={func} // 自选切换回调
bgColor={string} // 背景色
/>
```
#### `CompanyTabs/` - Tab 切换
| 文件 | 职责 |
|------|------|
| `index.js` | Tab 容器,管理切换状态,渲染 Tab 内容 |
| `TabNavigation.js` | Tab 导航栏4个 Tab 按钮) |
**Props 接口**
```js
<CompanyTabs
stockCode={string} // 当前股票代码
onTabChange={func} // Tab 变更回调 (index, tabName, prevIndex)
bgColor={string} // 背景色
/>
```
---
### Constants 目录
#### `constants/index.js` - 常量配置
- `COMPANY_TABS` - Tab 配置数组key, name, icon
- `TAB_SELECTED_STYLE` - Tab 选中样式
- `TOAST_MESSAGES` - Toast 消息配置
- `DEFAULT_STOCK_CODE` - 默认股票代码 ('000001')
- `URL_PARAM_NAME` - URL 参数名 ('scode')
- `getTabNameByIndex()` - 根据索引获取 Tab 名称
---
### Tab 内容组件(`components/` 目录下)
| 组件 | Tab 名称 | 职责 | 代码行数 |
|------|----------|------|----------|
| `CompanyOverview/` | 公司概览 | 公司基本信息、相关事件 | - |
| `MarketDataView/` | 股票行情 | K线图、实时行情 | - |
| `FinancialPanorama/` | 财务全景 | 财务报表、指标分析 | 2153 行 |
| `ForecastReport/` | 盈利预测 | 分析师预测、目标价 | 161 行 |
> 📌 所有 Tab 内容组件已文件夹化并统一放置在 `components/` 目录下
---
## 数据流示意
```
┌─────────────────────────────────────────────────────────────┐
│ index.js (页面入口) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
│ │ useCompanyStock │ │useCompanyWatchlist│ │useCompanyEvents│
│ │ │ │ │ │ │ │
│ │ • stockCode │ │ • isInWatchlist │ │ • track* │ │
│ │ • inputCode │ │ • toggle │ │ functions │ │
│ │ • handleSearch │ │ │ │ │ │
│ └────────┬────────┘ └────────┬────────┘ └──────┬──────┘ │
│ │ │ │ │
│ └──────────┬─────────┴───────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ CompanyHeader │ │
│ │ ┌─────────────┐ ┌──────────────────┐ │ │
│ │ │ SearchBar │ │ WatchlistButton │ │ │
│ │ └─────────────┘ └──────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ CompanyTabs │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ TabNavigation │ │ │
│ │ │ [概览] [行情] [财务] [预测] │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ TabPanels │ │ │
│ │ │ • CompanyOverview │ │ │
│ │ │ • MarketDataView │ │ │
│ │ │ • FinancialPanorama │ │ │
│ │ │ • ForecastReport │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## 重构记录
### 2025-12-09 重构
**改动概述**
- `index.js` 从 **349 行** 精简至 **95 行**(减少 73%
- 提取 **3 个自定义 Hooks**
- 提取 **2 个组件目录**CompanyHeader、CompanyTabs
- 抽离常量到 `constants/index.js`
**修复的问题**
1. **无限循环 Bug**`useCompanyWatchlist` 中使用 `useRef` 防止重复初始化
2. **Hook 调用顺序**:确保 `useCompanyEvents` 在 `useCompanyStock` 之后调用(依赖 stockCode
3. **类型检查**`CompanyOverview.js` 中 `event.keywords` 渲染时添加类型检查,支持字符串和对象两种格式
**设计原则**
- **关注点分离**:每个 Hook 只负责单一职责
- **纯组合层**index.js 不包含业务逻辑,只负责组合
- **Props 透传**:通过 Props 将状态和回调传递给子组件
### 2025-12-09 文件夹化
**改动概述**
- 所有 4 个 Tab 内容组件统一移动到 `components/` 目录
- `CompanyOverview.js` → `components/CompanyOverview/index.js`
- `MarketDataView.js` → `components/MarketDataView/index.js`
- `FinancialPanorama.js` → `components/FinancialPanorama/index.js`2153 行)
- `ForecastReport.js` → `components/ForecastReport/index.js`161 行)
- 更新 `CompanyTabs/index.js` 中的导入路径
**目的**
- 统一目录结构,所有组件都在 `components/` 下
- 为后期组件拆分做准备便于添加子组件、hooks、utils 等
### 2025-12-10 CompanyOverview 拆分TypeScript
**改动概述**
- `CompanyOverview/index.js` 从 **330 行** 精简至 **50 行**(减少 85%
- 采用 **TypeScript** 进行拆分,提高类型安全性
- 提取 **1 个自定义 Hook**`useCompanyOverviewData`
- 提取 **1 个子组件**`CompanyHeaderCard`
- 抽离类型定义到 `types.ts`
- 抽离工具函数到 `utils.ts`
**拆分后文件结构**
```
CompanyOverview/
├── index.tsx # 主组件(组合层,约 60 行)
├── CompanyHeaderCard.tsx # 头部卡片组件(约 130 行)
├── BasicInfoTab.js # 基本信息 Tab懒加载版本约 994 行)
├── DeepAnalysisTab.js # 深度分析 Tab
├── NewsEventsTab.js # 新闻事件 Tab
├── types.ts # 类型定义(约 50 行)
├── utils.ts # 格式化工具(约 20 行)
└── hooks/
├── useBasicInfo.ts # 基本信息 Hook1 API
├── useShareholderData.ts # 股权结构 Hook4 APIs
├── useManagementData.ts # 管理团队 Hook1 API
├── useAnnouncementsData.ts # 公告数据 Hook1 API
├── useBranchesData.ts # 分支机构 Hook1 API
├── useDisclosureData.ts # 披露日程 Hook1 API
└── useCompanyOverviewData.ts # [已废弃] 原合并 Hook
```
**懒加载架构**2025-12-10 优化):
- `index.tsx` 只加载 `useBasicInfo`1 个 API用于头部卡片
- `BasicInfoTab.js` 使用 `isLazy` + 独立子组件实现懒加载
- 每个内层 Tab 使用独立 Hook点击时才加载数据
**Hooks 说明**
| Hook | API 数量 | 用途 |
|------|----------|------|
| `useBasicInfo` | 1 | 公司基本信息(头部卡片 + 工商信息 Tab |
| `useShareholderData` | 4 | 实控人、股权集中度、十大股东、十大流通股东 |
| `useManagementData` | 1 | 管理团队数据 |
| `useAnnouncementsData` | 1 | 公司公告列表 |
| `useBranchesData` | 1 | 分支机构列表 |
| `useDisclosureData` | 1 | 财报披露日程 |
**类型定义**`types.ts`
- `BasicInfo` - 公司基本信息
- `ActualControl` - 实际控制人
- `Concentration` - 股权集中度
- `Management` - 管理层信息
- `Shareholder` - 股东信息
- `Branch` - 分支机构
- `Announcement` - 公告信息
- `DisclosureSchedule` - 披露计划
- `CompanyOverviewData` - Hook 返回值类型
- `CompanyOverviewProps` - 组件 Props 类型
- `CompanyHeaderCardProps` - 头部卡片 Props 类型
**工具函数**`utils.ts`
- `formatRegisteredCapital(value)` - 格式化注册资本(万元/亿元)
- `formatDate(dateString)` - 格式化日期
**设计原则**
- **渐进式 TypeScript 迁移**:新拆分的文件使用 TypeScript旧文件暂保持 JS
- **关注点分离**:数据加载逻辑提取到 HookUI 逻辑保留在组件
- **类型复用**:统一的类型定义便于在多个文件间共享
- **懒加载优化**:减少首屏 API 请求,按需加载数据
### 2025-12-10 懒加载优化
**改动概述**
- 将 `useCompanyOverviewData`9 个 API拆分为 6 个独立 Hook
- `CompanyOverview/index.tsx` 只加载 `useBasicInfo`1 个 API
- `BasicInfoTab.js` 使用 5 个懒加载子组件,配合 `isLazy` 实现按需加载
- 页面初次加载从 **9 个 API** 减少到 **1 个 API**
**懒加载子组件**BasicInfoTab.js 内部):
| 子组件 | Hook | 功能 |
|--------|------|------|
| `ShareholderTabPanel` | `useShareholderData` | 股权结构4 APIs |
| `ManagementTabPanel` | `useManagementData` | 管理团队 |
| `AnnouncementsTabPanel` | `useAnnouncementsData` + `useDisclosureData` | 公告 + 披露日程 |
| `BranchesTabPanel` | `useBranchesData` | 分支机构 |
| `BusinessInfoTabPanel` | - | 工商信息(使用父组件传入的 basicInfo |
**实现原理**
- Chakra UI `Tabs` 的 `isLazy` 属性延迟渲染 TabPanel
- 每个 TabPanel 使用独立子组件,组件内调用 Hook
- 子组件只在首次激活时渲染,此时 Hook 才执行并发起 API 请求
| Tab 模块 | 中文名称 | 功能说明 |
|-------------------|------|----------------------------|
| CompanyOverview | 公司概览 | 公司基本信息、股权结构、管理层、公告等9个接口 |
| DeepAnalysis | 深度分析 | 公司深度研究报告、投资逻辑分析 |
| MarketDataView | 股票行情 | K线图、实时行情、技术指标 |
| FinancialPanorama | 财务全景 | 财务报表(资产负债表、利润表、现金流)、财务指标分析 |
| ForecastReport | 盈利预测 | 分析师预测、目标价、评级 |
| DynamicTracking | 动态跟踪 | 相关事件、新闻动态、投资日历 |
### 2025-12-10 MarketDataView TypeScript 拆分
**改动概述**
- `MarketDataView/index.js` 从 **2060 行** 拆分为 **12 个 TypeScript 文件**
- 采用 **TypeScript** 进行重构,提高类型安全性
- 提取 **1 个自定义 Hook**`useMarketData`
- 提取 **4 个子组件**ThemedCard、MarkdownRenderer、StockSummaryCard、AnalysisModal
- 抽离 API 服务到 `services/marketService.ts`
- 抽离图表配置到 `utils/chartOptions.ts`
**拆分后文件结构**
```
MarketDataView/
├── index.tsx # 主组件入口(~1049 行)
├── types.ts # 类型定义(~383 行)
├── constants.ts # 主题配置、常量(~49 行)
├── services/
│ └── marketService.ts # API 服务层(~173 行)
├── hooks/
│ └── useMarketData.ts # 数据获取 Hook~193 行)
├── utils/
│ ├── formatUtils.ts # 格式化工具函数(~175 行)
│ └── chartOptions.ts # ECharts 图表配置生成器(~698 行)
└── components/
├── index.ts # 组件导出(~8 行)
├── ThemedCard.tsx # 主题化卡片(~32 行)
├── MarkdownRenderer.tsx # Markdown 渲染(~65 行)
├── StockSummaryCard.tsx # 股票概览卡片(~133 行)
└── AnalysisModal.tsx # 涨幅分析模态框(~188 行)
```
**文件职责说明**
| 文件 | 行数 | 职责 |
|------|------|------|
| `index.tsx` | ~1049 | 主组件,包含 5 个 Tab 面板(交易数据、融资融券、大宗交易、龙虎榜、股权质押) |
| `types.ts` | ~383 | 所有 TypeScript 类型定义Theme、TradeDayData、MinuteData、FundingData 等) |
| `constants.ts` | ~49 | 主题配置light/dark、周期选项常量 |
| `marketService.ts` | ~173 | API 服务封装getMarketData、getMinuteData、getBigDealData 等) |
| `useMarketData.ts` | ~193 | 数据获取 Hook管理所有市场数据状态 |
| `formatUtils.ts` | ~175 | 数字/日期/涨跌幅格式化工具 |
| `chartOptions.ts` | ~698 | ECharts 配置生成器K线图、分钟图、融资融券图、质押图 |
| `ThemedCard.tsx` | ~32 | 主题化卡片容器组件 |
| `MarkdownRenderer.tsx` | ~65 | Markdown 内容渲染组件 |
| `StockSummaryCard.tsx` | ~133 | 股票概览卡片(价格、涨跌幅、成交量等) |
| `AnalysisModal.tsx` | ~188 | 涨幅分析详情模态框 |
**类型定义**`types.ts`
- `Theme` - 主题配置类型
- `TradeDayData` - 日线交易数据
- `MinuteData` - 分钟线数据
- `FundingDayData` - 融资融券数据
- `BigDealData` / `BigDealDayStats` - 大宗交易数据
- `UnusualData` / `UnusualDayData` - 龙虎榜数据
- `PledgeData` - 股权质押数据
- `RiseAnalysis` - 涨幅分析数据
- `MarketSummary` - 市场概览数据
- `VerificationReport` - 验证报告数据
- 各组件 Props 类型
**Hook 返回值**`useMarketData`
```typescript
{
loading: boolean;
summary: MarketSummary | null;
tradeData: TradeDayData[];
minuteData: MinuteData | null;
minuteLoading: boolean;
fundingData: FundingDayData[];
bigDealData: BigDealData | null;
unusualData: UnusualData | null;
pledgeData: PledgeData | null;
analysisMap: Record<number, RiseAnalysis>;
refetch: () => Promise<void>;
loadMinuteData: () => Promise<void>;
}
```
**设计原则**
- **TypeScript 类型安全**:所有数据结构有完整类型定义
- **服务层分离**API 调用统一在 `marketService.ts` 中管理
- **图表配置抽离**:复杂的 ECharts 配置集中在 `chartOptions.ts`
- **组件复用**通用组件ThemedCard、MarkdownRenderer可在其他模块使用

View File

@@ -0,0 +1,155 @@
// src/views/Company/components/CompanyHeader/SearchBar.js
// 股票搜索栏组件 - 金色主题 + 模糊搜索下拉
import React, { useState, useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import {
Box,
HStack,
Input,
InputGroup,
InputLeftElement,
Text,
VStack,
} from '@chakra-ui/react';
import { SearchIcon } from '@chakra-ui/icons';
/**
* 股票搜索栏组件(带模糊搜索下拉)
*
* @param {Object} props
* @param {string} props.inputCode - 输入框当前值
* @param {Function} props.onInputChange - 输入变化回调
* @param {Function} props.onSearch - 搜索按钮点击回调
* @param {Function} props.onKeyDown - 键盘事件回调
*/
const SearchBar = ({
inputCode,
onInputChange,
onSearch,
onKeyDown,
}) => {
// 下拉状态
const [showDropdown, setShowDropdown] = useState(false);
const [filteredStocks, setFilteredStocks] = useState([]);
const containerRef = useRef(null);
// 从 Redux 获取全部股票列表
const allStocks = useSelector(state => state.stock.allStocks);
// 模糊搜索过滤
useEffect(() => {
if (inputCode && inputCode.trim()) {
const searchTerm = inputCode.trim().toLowerCase();
const filtered = allStocks.filter(stock =>
stock.code.toLowerCase().includes(searchTerm) ||
stock.name.includes(inputCode.trim())
).slice(0, 10); // 限制显示10条
setFilteredStocks(filtered);
setShowDropdown(filtered.length > 0);
} else {
setFilteredStocks([]);
setShowDropdown(false);
}
}, [inputCode, allStocks]);
// 点击外部关闭下拉
useEffect(() => {
const handleClickOutside = (event) => {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 选择股票 - 直接触发搜索跳转
const handleSelectStock = (stock) => {
onInputChange(stock.code);
setShowDropdown(false);
onSearch(stock.code);
};
// 处理键盘事件
const handleKeyDownWrapper = (e) => {
if (e.key === 'Enter') {
setShowDropdown(false);
}
onKeyDown?.(e);
};
return (
<Box ref={containerRef} position="relative" w="300px">
<InputGroup size="lg">
<InputLeftElement pointerEvents="none">
<SearchIcon color="#C9A961" />
</InputLeftElement>
<Input
placeholder="输入股票代码或名称"
value={inputCode}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={handleKeyDownWrapper}
onFocus={() => inputCode && filteredStocks.length > 0 && setShowDropdown(true)}
borderRadius="md"
color="white"
borderColor="#C9A961"
_placeholder={{ color: '#C9A961' }}
_focus={{
borderColor: '#F4D03F',
boxShadow: '0 0 0 1px #F4D03F',
}}
_hover={{
borderColor: '#F4D03F',
}}
/>
</InputGroup>
{/* 模糊搜索下拉列表 */}
{showDropdown && (
<Box
position="absolute"
top="100%"
left={0}
mt={1}
w="100%"
bg="#1A202C"
border="1px solid #C9A961"
borderRadius="md"
maxH="300px"
overflowY="auto"
zIndex={1000}
boxShadow="0 4px 12px rgba(0, 0, 0, 0.3)"
>
<VStack align="stretch" spacing={0}>
{filteredStocks.map((stock) => (
<Box
key={stock.code}
px={4}
py={2}
cursor="pointer"
_hover={{ bg: 'whiteAlpha.100' }}
onClick={() => handleSelectStock(stock)}
borderBottom="1px solid"
borderColor="whiteAlpha.100"
_last={{ borderBottom: 'none' }}
>
<HStack justify="space-between">
<Text color="#F4D03F" fontWeight="bold" fontSize="sm">
{stock.code}
</Text>
<Text color="#C9A961" fontSize="sm" noOfLines={1} maxW="180px">
{stock.name}
</Text>
</HStack>
</Box>
))}
</VStack>
</Box>
)}
</Box>
);
};
export default SearchBar;

View File

@@ -0,0 +1,62 @@
// src/views/Company/components/CompanyHeader/index.js
// 公司详情页面头部区域组件
import React from 'react';
import {
Card,
CardBody,
HStack,
VStack,
Heading,
Text,
} from '@chakra-ui/react';
import SearchBar from './SearchBar';
/**
* 公司详情页面头部区域组件
*
* 包含:
* - 页面标题和描述(金色主题)
* - 股票搜索栏(支持模糊搜索)
*
* @param {Object} props
* @param {string} props.inputCode - 搜索输入框值
* @param {Function} props.onInputChange - 输入变化回调
* @param {Function} props.onSearch - 搜索回调
* @param {Function} props.onKeyDown - 键盘事件回调
* @param {string} props.bgColor - 背景颜色
*/
const CompanyHeader = ({
inputCode,
onInputChange,
onSearch,
onKeyDown,
bgColor,
}) => {
return (
<Card bg={bgColor} shadow="md">
<CardBody>
<HStack justify="space-between" align="center">
{/* 标题区域 - 金色主题 */}
<VStack align="start" spacing={1}>
<Heading size="lg" color="#F4D03F">个股详情</Heading>
<Text color="#C9A961" fontSize="sm">
查看股票实时行情财务数据和盈利预测
</Text>
</VStack>
{/* 搜索栏 */}
<SearchBar
inputCode={inputCode}
onInputChange={onInputChange}
onSearch={onSearch}
onKeyDown={onKeyDown}
/>
</HStack>
</CardBody>
</Card>
);
};
export default CompanyHeader;

View File

@@ -0,0 +1,994 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab.js
// 基本信息 Tab - 股权结构、管理团队、公司公告、分支机构、工商信息
// 懒加载优化:使用 isLazy + 独立 Hooks点击 Tab 时才加载对应数据
import React from "react";
import {
Box,
VStack,
HStack,
Text,
Heading,
Badge,
Icon,
Card,
CardBody,
CardHeader,
SimpleGrid,
Avatar,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Tag,
Tooltip,
Divider,
Center,
Code,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Stat,
StatLabel,
StatNumber,
StatHelpText,
IconButton,
Button,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
useDisclosure,
Spinner,
} from "@chakra-ui/react";
// 懒加载 Hooks
import { useShareholderData } from "./hooks/useShareholderData";
import { useManagementData } from "./hooks/useManagementData";
import { useAnnouncementsData } from "./hooks/useAnnouncementsData";
import { useBranchesData } from "./hooks/useBranchesData";
import { useDisclosureData } from "./hooks/useDisclosureData";
import { ExternalLinkIcon } from "@chakra-ui/icons";
import {
FaShareAlt,
FaUserTie,
FaBullhorn,
FaSitemap,
FaInfoCircle,
FaCrown,
FaChartPie,
FaUsers,
FaChartLine,
FaArrowUp,
FaArrowDown,
FaChartBar,
FaBuilding,
FaGlobe,
FaShieldAlt,
FaBriefcase,
FaCircle,
FaEye,
FaVenusMars,
FaGraduationCap,
FaPassport,
FaCalendarAlt,
} from "react-icons/fa";
// 格式化工具函数
const formatUtils = {
formatPercentage: (value) => {
if (value === null || value === undefined) return "-";
return `${(value * 100).toFixed(2)}%`;
},
formatNumber: (value) => {
if (value === null || value === undefined) return "-";
if (value >= 100000000) {
return `${(value / 100000000).toFixed(2)}亿`;
} else if (value >= 10000) {
return `${(value / 10000).toFixed(2)}`;
}
return value.toLocaleString();
},
formatShares: (value) => {
if (value === null || value === undefined) return "-";
if (value >= 100000000) {
return `${(value / 100000000).toFixed(2)}亿股`;
} else if (value >= 10000) {
return `${(value / 10000).toFixed(2)}万股`;
}
return `${value.toLocaleString()}`;
},
formatDate: (dateStr) => {
if (!dateStr) return "-";
return dateStr.split("T")[0];
},
};
// 股东类型标签组件
const ShareholderTypeBadge = ({ type }) => {
const typeConfig = {
基金: { color: "blue", icon: FaChartBar },
个人: { color: "green", icon: FaUserTie },
法人: { color: "purple", icon: FaBuilding },
QFII: { color: "orange", icon: FaGlobe },
社保: { color: "red", icon: FaShieldAlt },
保险: { color: "teal", icon: FaShieldAlt },
信托: { color: "cyan", icon: FaBriefcase },
券商: { color: "pink", icon: FaChartLine },
};
const config = Object.entries(typeConfig).find(([key]) =>
type?.includes(key)
)?.[1] || { color: "gray", icon: FaCircle };
return (
<Badge colorScheme={config.color} size="sm">
<Icon as={config.icon} mr={1} boxSize={3} />
{type}
</Badge>
);
};
// ============================================
// 懒加载 TabPanel 子组件
// 每个子组件独立调用 Hook配合 isLazy 实现真正的懒加载
// ============================================
/**
* 股权结构 Tab Panel - 懒加载子组件
*/
const ShareholderTabPanel = ({ stockCode }) => {
const {
actualControl,
concentration,
topShareholders,
topCirculationShareholders,
loading,
} = useShareholderData(stockCode);
// 计算股权集中度变化
const getConcentrationTrend = () => {
const grouped = {};
concentration.forEach((item) => {
if (!grouped[item.end_date]) {
grouped[item.end_date] = {};
}
grouped[item.end_date][item.stat_item] = item;
});
return Object.entries(grouped)
.sort((a, b) => b[0].localeCompare(a[0]))
.slice(0, 5);
};
if (loading) {
return (
<Center h="200px">
<VStack>
<Spinner size="lg" color="blue.500" />
<Text fontSize="sm" color="gray.500">
加载股权结构数据...
</Text>
</VStack>
</Center>
);
}
return (
<VStack spacing={6} align="stretch">
{actualControl.length > 0 && (
<Box>
<HStack mb={4}>
<Icon as={FaCrown} color="gold" boxSize={5} />
<Heading size="sm">实际控制人</Heading>
</HStack>
<Card variant="outline">
<CardBody>
<HStack justify="space-between">
<VStack align="start">
<Text fontWeight="bold" fontSize="lg">
{actualControl[0].actual_controller_name}
</Text>
<HStack>
<Badge colorScheme="purple">
{actualControl[0].control_type}
</Badge>
<Text fontSize="sm" color="gray.500">
截至 {formatUtils.formatDate(actualControl[0].end_date)}
</Text>
</HStack>
</VStack>
<Stat textAlign="right">
<StatLabel>控制比例</StatLabel>
<StatNumber color="purple.500">
{formatUtils.formatPercentage(actualControl[0].holding_ratio)}
</StatNumber>
<StatHelpText>
{formatUtils.formatShares(actualControl[0].holding_shares)}
</StatHelpText>
</Stat>
</HStack>
</CardBody>
</Card>
</Box>
)}
{concentration.length > 0 && (
<Box>
<HStack mb={4}>
<Icon as={FaChartPie} color="blue.500" boxSize={5} />
<Heading size="sm">股权集中度</Heading>
</HStack>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{getConcentrationTrend()
.slice(0, 1)
.map(([date, items]) => (
<Card key={date} variant="outline">
<CardHeader pb={2}>
<Text fontSize="sm" color="gray.500">
{formatUtils.formatDate(date)}
</Text>
</CardHeader>
<CardBody pt={2}>
<VStack spacing={3} align="stretch">
{Object.entries(items).map(([key, item]) => (
<HStack key={key} justify="space-between">
<Text fontSize="sm">{item.stat_item}</Text>
<HStack>
<Text fontWeight="bold" color="blue.500">
{formatUtils.formatPercentage(item.holding_ratio)}
</Text>
{item.ratio_change && (
<Badge
colorScheme={
item.ratio_change > 0 ? "red" : "green"
}
>
<Icon
as={
item.ratio_change > 0 ? FaArrowUp : FaArrowDown
}
mr={1}
boxSize={3}
/>
{Math.abs(item.ratio_change).toFixed(2)}%
</Badge>
)}
</HStack>
</HStack>
))}
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
</Box>
)}
{topShareholders.length > 0 && (
<Box>
<HStack mb={4}>
<Icon as={FaUsers} color="green.500" boxSize={5} />
<Heading size="sm">十大股东</Heading>
<Badge>
{formatUtils.formatDate(topShareholders[0].end_date)}
</Badge>
</HStack>
<TableContainer>
<Table size="sm" variant="striped">
<Thead>
<Tr>
<Th>排名</Th>
<Th>股东名称</Th>
<Th>股东类型</Th>
<Th isNumeric>持股数量</Th>
<Th isNumeric>持股比例</Th>
<Th>股份性质</Th>
</Tr>
</Thead>
<Tbody>
{topShareholders.slice(0, 10).map((shareholder, idx) => (
<Tr key={idx}>
<Td>
<Badge colorScheme={idx < 3 ? "red" : "gray"}>
{shareholder.shareholder_rank}
</Badge>
</Td>
<Td>
<Tooltip label={shareholder.shareholder_name}>
<Text noOfLines={1} maxW="200px">
{shareholder.shareholder_name}
</Text>
</Tooltip>
</Td>
<Td>
<ShareholderTypeBadge type={shareholder.shareholder_type} />
</Td>
<Td isNumeric fontWeight="medium">
{formatUtils.formatShares(shareholder.holding_shares)}
</Td>
<Td isNumeric>
<Text color="blue.500" fontWeight="bold">
{formatUtils.formatPercentage(
shareholder.total_share_ratio
)}
</Text>
</Td>
<Td>
<Badge size="sm" variant="outline">
{shareholder.share_nature || "流通股"}
</Badge>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
)}
{topCirculationShareholders.length > 0 && (
<Box>
<HStack mb={4}>
<Icon as={FaChartLine} color="purple.500" boxSize={5} />
<Heading size="sm">十大流通股东</Heading>
<Badge>
{formatUtils.formatDate(topCirculationShareholders[0].end_date)}
</Badge>
</HStack>
<TableContainer>
<Table size="sm" variant="striped">
<Thead>
<Tr>
<Th>排名</Th>
<Th>股东名称</Th>
<Th>股东类型</Th>
<Th isNumeric>持股数量</Th>
<Th isNumeric>流通股比例</Th>
</Tr>
</Thead>
<Tbody>
{topCirculationShareholders.slice(0, 10).map((shareholder, idx) => (
<Tr key={idx}>
<Td>
<Badge colorScheme={idx < 3 ? "orange" : "gray"}>
{shareholder.shareholder_rank}
</Badge>
</Td>
<Td>
<Tooltip label={shareholder.shareholder_name}>
<Text noOfLines={1} maxW="250px">
{shareholder.shareholder_name}
</Text>
</Tooltip>
</Td>
<Td>
<ShareholderTypeBadge type={shareholder.shareholder_type} />
</Td>
<Td isNumeric fontWeight="medium">
{formatUtils.formatShares(shareholder.holding_shares)}
</Td>
<Td isNumeric>
<Text color="purple.500" fontWeight="bold">
{formatUtils.formatPercentage(
shareholder.circulation_share_ratio
)}
</Text>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
)}
</VStack>
);
};
/**
* 管理团队 Tab Panel - 懒加载子组件
*/
const ManagementTabPanel = ({ stockCode }) => {
const { management, loading } = useManagementData(stockCode);
// 管理层职位分类
const getManagementByCategory = () => {
const categories = {
高管: [],
董事: [],
监事: [],
其他: [],
};
management.forEach((person) => {
if (
person.position_category === "高管" ||
person.position_name?.includes("总")
) {
categories["高管"].push(person);
} else if (
person.position_category === "董事" ||
person.position_name?.includes("董事")
) {
categories["董事"].push(person);
} else if (
person.position_category === "监事" ||
person.position_name?.includes("监事")
) {
categories["监事"].push(person);
} else {
categories["其他"].push(person);
}
});
return categories;
};
if (loading) {
return (
<Center h="200px">
<VStack>
<Spinner size="lg" color="blue.500" />
<Text fontSize="sm" color="gray.500">
加载管理团队数据...
</Text>
</VStack>
</Center>
);
}
return (
<VStack spacing={6} align="stretch">
{Object.entries(getManagementByCategory()).map(
([category, people]) =>
people.length > 0 && (
<Box key={category}>
<HStack mb={4}>
<Icon
as={
category === "高管"
? FaUserTie
: category === "董事"
? FaCrown
: category === "监事"
? FaEye
: FaUsers
}
color={
category === "高管"
? "blue.500"
: category === "董事"
? "purple.500"
: category === "监事"
? "green.500"
: "gray.500"
}
boxSize={5}
/>
<Heading size="sm">{category}</Heading>
<Badge>{people.length}</Badge>
</HStack>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{people.map((person, idx) => (
<Card key={idx} variant="outline" size="sm">
<CardBody>
<HStack spacing={3} align="start">
<Avatar
name={person.name}
size="md"
bg={
category === "高管"
? "blue.500"
: category === "董事"
? "purple.500"
: category === "监事"
? "green.500"
: "gray.500"
}
/>
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Text fontWeight="bold">{person.name}</Text>
{person.gender && (
<Icon
as={FaVenusMars}
color={
person.gender === "男"
? "blue.400"
: "pink.400"
}
boxSize={3}
/>
)}
</HStack>
<Text fontSize="sm" color="blue.600">
{person.position_name}
</Text>
<HStack spacing={2} flexWrap="wrap">
{person.education && (
<Tag size="sm" variant="subtle">
<Icon as={FaGraduationCap} mr={1} boxSize={3} />
{person.education}
</Tag>
)}
{person.birth_year && (
<Tag size="sm" variant="subtle">
{new Date().getFullYear() -
parseInt(person.birth_year)}
</Tag>
)}
{person.nationality &&
person.nationality !== "中国" && (
<Tag size="sm" colorScheme="orange">
<Icon as={FaPassport} mr={1} boxSize={3} />
{person.nationality}
</Tag>
)}
</HStack>
<Text fontSize="xs" color="gray.500">
任职日期{formatUtils.formatDate(person.start_date)}
</Text>
</VStack>
</HStack>
</CardBody>
</Card>
))}
</SimpleGrid>
</Box>
)
)}
</VStack>
);
};
/**
* 公司公告 Tab Panel - 懒加载子组件
*/
const AnnouncementsTabPanel = ({ stockCode }) => {
const { announcements, loading: announcementsLoading } =
useAnnouncementsData(stockCode);
const { disclosureSchedule, loading: disclosureLoading } =
useDisclosureData(stockCode);
const { isOpen, onOpen, onClose } = useDisclosure();
const [selectedAnnouncement, setSelectedAnnouncement] = React.useState(null);
const handleAnnouncementClick = (announcement) => {
setSelectedAnnouncement(announcement);
onOpen();
};
const loading = announcementsLoading || disclosureLoading;
if (loading) {
return (
<Center h="200px">
<VStack>
<Spinner size="lg" color="blue.500" />
<Text fontSize="sm" color="gray.500">
加载公告数据...
</Text>
</VStack>
</Center>
);
}
return (
<>
<VStack spacing={4} align="stretch">
{disclosureSchedule.length > 0 && (
<Box>
<HStack mb={3}>
<Icon as={FaCalendarAlt} color="orange.500" />
<Text fontWeight="bold">财报披露日程</Text>
</HStack>
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
{disclosureSchedule.slice(0, 4).map((schedule, idx) => (
<Card
key={idx}
variant="outline"
size="sm"
bg={schedule.is_disclosed ? "green.50" : "orange.50"}
>
<CardBody p={3}>
<VStack spacing={1}>
<Badge
colorScheme={schedule.is_disclosed ? "green" : "orange"}
>
{schedule.report_name}
</Badge>
<Text fontSize="sm" fontWeight="bold">
{schedule.is_disclosed ? "已披露" : "预计"}
</Text>
<Text fontSize="xs">
{formatUtils.formatDate(
schedule.is_disclosed
? schedule.actual_date
: schedule.latest_scheduled_date
)}
</Text>
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
</Box>
)}
<Divider />
<Box>
<HStack mb={3}>
<Icon as={FaBullhorn} color="blue.500" />
<Text fontWeight="bold">最新公告</Text>
</HStack>
<VStack spacing={2} align="stretch">
{announcements.map((announcement, idx) => (
<Card
key={idx}
variant="outline"
size="sm"
cursor="pointer"
onClick={() => handleAnnouncementClick(announcement)}
_hover={{ bg: "gray.50" }}
>
<CardBody p={3}>
<HStack justify="space-between">
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Badge size="sm" colorScheme="blue">
{announcement.info_type || "公告"}
</Badge>
<Text fontSize="xs" color="gray.500">
{formatUtils.formatDate(announcement.announce_date)}
</Text>
</HStack>
<Text fontSize="sm" fontWeight="medium" noOfLines={1}>
{announcement.title}
</Text>
</VStack>
<HStack>
{announcement.format && (
<Tag size="sm" variant="subtle">
{announcement.format}
</Tag>
)}
<IconButton
size="sm"
icon={<ExternalLinkIcon />}
variant="ghost"
onClick={(e) => {
e.stopPropagation();
window.open(announcement.url, "_blank");
}}
/>
</HStack>
</HStack>
</CardBody>
</Card>
))}
</VStack>
</Box>
</VStack>
{/* 公告详情模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>
<VStack align="start" spacing={1}>
<Text>{selectedAnnouncement?.title}</Text>
<HStack>
<Badge colorScheme="blue">
{selectedAnnouncement?.info_type || "公告"}
</Badge>
<Text fontSize="sm" color="gray.500">
{formatUtils.formatDate(selectedAnnouncement?.announce_date)}
</Text>
</HStack>
</VStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="start" spacing={3}>
<Text fontSize="sm">
文件格式{selectedAnnouncement?.format || "-"}
</Text>
<Text fontSize="sm">
文件大小{selectedAnnouncement?.file_size || "-"} KB
</Text>
</VStack>
</ModalBody>
<ModalFooter>
<Button
colorScheme="blue"
mr={3}
onClick={() => window.open(selectedAnnouncement?.url, "_blank")}
>
查看原文
</Button>
<Button variant="ghost" onClick={onClose}>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
};
/**
* 分支机构 Tab Panel - 懒加载子组件
*/
const BranchesTabPanel = ({ stockCode }) => {
const { branches, loading } = useBranchesData(stockCode);
if (loading) {
return (
<Center h="200px">
<VStack>
<Spinner size="lg" color="blue.500" />
<Text fontSize="sm" color="gray.500">
加载分支机构数据...
</Text>
</VStack>
</Center>
);
}
if (branches.length === 0) {
return (
<Center h="200px">
<VStack>
<Icon as={FaSitemap} boxSize={12} color="gray.300" />
<Text color="gray.500">暂无分支机构信息</Text>
</VStack>
</Center>
);
}
return (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{branches.map((branch, idx) => (
<Card key={idx} variant="outline">
<CardBody>
<VStack align="start" spacing={3}>
<HStack justify="space-between" w="full">
<Text fontWeight="bold">{branch.branch_name}</Text>
<Badge
colorScheme={
branch.business_status === "存续" ? "green" : "red"
}
>
{branch.business_status}
</Badge>
</HStack>
<SimpleGrid columns={2} spacing={2} w="full">
<VStack align="start" spacing={1}>
<Text fontSize="xs" color="gray.500">
注册资本
</Text>
<Text fontSize="sm" fontWeight="medium">
{branch.register_capital || "-"}
</Text>
</VStack>
<VStack align="start" spacing={1}>
<Text fontSize="xs" color="gray.500">
法人代表
</Text>
<Text fontSize="sm" fontWeight="medium">
{branch.legal_person || "-"}
</Text>
</VStack>
<VStack align="start" spacing={1}>
<Text fontSize="xs" color="gray.500">
成立日期
</Text>
<Text fontSize="sm" fontWeight="medium">
{formatUtils.formatDate(branch.register_date)}
</Text>
</VStack>
<VStack align="start" spacing={1}>
<Text fontSize="xs" color="gray.500">
关联企业
</Text>
<Text fontSize="sm" fontWeight="medium">
{branch.related_company_count || 0}
</Text>
</VStack>
</SimpleGrid>
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
);
};
/**
* 工商信息 Tab Panel - 使用父组件传入的 basicInfo
*/
const BusinessInfoTabPanel = ({ basicInfo }) => {
if (!basicInfo) {
return (
<Center h="200px">
<Text color="gray.500">暂无工商信息</Text>
</Center>
);
}
return (
<VStack spacing={4} align="stretch">
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
<Box>
<Heading size="sm" mb={3}>
工商信息
</Heading>
<VStack align="start" spacing={2}>
<HStack w="full">
<Text fontSize="sm" color="gray.600" minW="80px">
统一信用代码
</Text>
<Code fontSize="xs">{basicInfo.credit_code}</Code>
</HStack>
<HStack w="full">
<Text fontSize="sm" color="gray.600" minW="80px">
公司规模
</Text>
<Text fontSize="sm">{basicInfo.company_size}</Text>
</HStack>
<HStack w="full">
<Text fontSize="sm" color="gray.600" minW="80px">
注册地址
</Text>
<Text fontSize="sm" noOfLines={2}>
{basicInfo.reg_address}
</Text>
</HStack>
<HStack w="full">
<Text fontSize="sm" color="gray.600" minW="80px">
办公地址
</Text>
<Text fontSize="sm" noOfLines={2}>
{basicInfo.office_address}
</Text>
</HStack>
</VStack>
</Box>
<Box>
<Heading size="sm" mb={3}>
服务机构
</Heading>
<VStack align="start" spacing={2}>
<Box>
<Text fontSize="sm" color="gray.600">
会计师事务所
</Text>
<Text fontSize="sm" fontWeight="medium">
{basicInfo.accounting_firm}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
律师事务所
</Text>
<Text fontSize="sm" fontWeight="medium">
{basicInfo.law_firm}
</Text>
</Box>
</VStack>
</Box>
</SimpleGrid>
<Divider />
<Box>
<Heading size="sm" mb={3}>
主营业务
</Heading>
<Text fontSize="sm" lineHeight="tall">
{basicInfo.main_business}
</Text>
</Box>
<Box>
<Heading size="sm" mb={3}>
经营范围
</Heading>
<Text fontSize="sm" lineHeight="tall" color="gray.700">
{basicInfo.business_scope}
</Text>
</Box>
</VStack>
);
};
// ============================================
// 主组件
// ============================================
/**
* 基本信息 Tab 组件(懒加载版本)
*
* Props:
* - stockCode: 股票代码(用于懒加载数据)
* - basicInfo: 公司基本信息(从父组件传入,用于工商信息 Tab
* - cardBg: 卡片背景色
*
* 懒加载策略:
* - 使用 Chakra UI Tabs 的 isLazy 属性
* - 每个 TabPanel 使用独立子组件,在首次激活时才渲染并加载数据
*/
const BasicInfoTab = ({ stockCode, basicInfo, cardBg }) => {
return (
<Card bg={cardBg} shadow="md">
<CardBody>
<Tabs isLazy variant="enclosed" colorScheme="blue">
<TabList flexWrap="wrap">
<Tab>
<Icon as={FaShareAlt} mr={2} />
股权结构
</Tab>
<Tab>
<Icon as={FaUserTie} mr={2} />
管理团队
</Tab>
<Tab>
<Icon as={FaBullhorn} mr={2} />
公司公告
</Tab>
<Tab>
<Icon as={FaSitemap} mr={2} />
分支机构
</Tab>
<Tab>
<Icon as={FaInfoCircle} mr={2} />
工商信息
</Tab>
</TabList>
<TabPanels>
{/* 股权结构 - 懒加载 */}
<TabPanel>
<ShareholderTabPanel stockCode={stockCode} />
</TabPanel>
{/* 管理团队 - 懒加载 */}
<TabPanel>
<ManagementTabPanel stockCode={stockCode} />
</TabPanel>
{/* 公司公告 - 懒加载 */}
<TabPanel>
<AnnouncementsTabPanel stockCode={stockCode} />
</TabPanel>
{/* 分支机构 - 懒加载 */}
<TabPanel>
<BranchesTabPanel stockCode={stockCode} />
</TabPanel>
{/* 工商信息 - 使用父组件传入的 basicInfo */}
<TabPanel>
<BusinessInfoTabPanel basicInfo={basicInfo} />
</TabPanel>
</TabPanels>
</Tabs>
</CardBody>
</Card>
);
};
export default BasicInfoTab;

View File

@@ -0,0 +1,201 @@
// src/views/Company/components/CompanyOverview/CompanyHeaderCard.tsx
// 公司头部信息卡片组件 - 黑金主题
import React from "react";
import {
VStack,
HStack,
Text,
Badge,
Card,
CardBody,
Heading,
SimpleGrid,
Divider,
Icon,
Box,
Link,
} from "@chakra-ui/react";
import {
FaBuilding,
FaMapMarkerAlt,
FaCalendarAlt,
FaGlobe,
FaCoins,
} from "react-icons/fa";
import { ExternalLinkIcon } from "@chakra-ui/icons";
import type { CompanyHeaderCardProps } from "./types";
import { formatRegisteredCapital, formatDate } from "./utils";
// 黑金主题色
const THEME = {
bg: "gray.900",
cardBg: "gray.800",
gold: "#D4AF37",
goldLight: "#F0D78C",
textPrimary: "white",
textSecondary: "gray.400",
border: "rgba(212, 175, 55, 0.3)",
};
/**
* 公司头部信息卡片组件
* 三区块布局:身份分类 | 关键属性 | 公司介绍
* 黑金主题
*/
const CompanyHeaderCard: React.FC<CompanyHeaderCardProps> = ({ basicInfo }) => {
return (
<Card
bg={THEME.cardBg}
shadow="xl"
borderTop="3px solid"
borderTopColor={THEME.gold}
borderRadius="lg"
>
<CardBody px={0}>
<VStack align="stretch" spacing={4}>
{/* 区块一:公司身份与分类 */}
<HStack spacing={4}>
<Box
w="56px"
h="56px"
borderRadius="full"
bg={`linear-gradient(135deg, ${THEME.gold} 0%, ${THEME.goldLight} 100%)`}
display="flex"
alignItems="center"
justifyContent="center"
boxShadow={`0 4px 14px rgba(212, 175, 55, 0.4)`}
>
<Icon as={FaBuilding} color="gray.900" boxSize={7} />
</Box>
<VStack align="start" spacing={1}>
<HStack flexWrap="wrap">
<Heading size="lg" color={THEME.textPrimary}>
{basicInfo.ORGNAME || basicInfo.SECNAME}
</Heading>
<Badge
bg={THEME.gold}
color="gray.900"
fontSize="md"
px={2}
py={1}
fontWeight="bold"
>
{basicInfo.SECCODE}
</Badge>
</HStack>
<HStack spacing={2} flexWrap="wrap">
<Badge
bg="transparent"
border="1px solid"
borderColor={THEME.gold}
color={THEME.goldLight}
fontSize="xs"
>
{basicInfo.sw_industry_l1}
</Badge>
<Badge
bg="transparent"
border="1px solid"
borderColor="gray.600"
color={THEME.textSecondary}
fontSize="xs"
>
{basicInfo.sw_industry_l2}
</Badge>
{basicInfo.sw_industry_l3 && (
<Badge
bg="transparent"
border="1px solid"
borderColor="gray.600"
color={THEME.textSecondary}
fontSize="xs"
>
{basicInfo.sw_industry_l3}
</Badge>
)}
</HStack>
</VStack>
</HStack>
<Divider borderColor={THEME.border} />
{/* 区块二:关键属性网格 */}
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
<HStack>
<Icon as={FaCalendarAlt} color={THEME.gold} boxSize={4} />
<Box>
<Text fontSize="xs" color={THEME.textSecondary}></Text>
<Text fontSize="sm" fontWeight="bold" color={THEME.textPrimary}>
{formatDate(basicInfo.establish_date)}
</Text>
</Box>
</HStack>
<HStack>
<Icon as={FaCoins} color={THEME.gold} boxSize={4} />
<Box>
<Text fontSize="xs" color={THEME.textSecondary}></Text>
<Text fontSize="sm" fontWeight="bold" color={THEME.goldLight}>
{formatRegisteredCapital(basicInfo.reg_capital)}
</Text>
</Box>
</HStack>
<HStack>
<Icon as={FaMapMarkerAlt} color={THEME.gold} boxSize={4} />
<Box>
<Text fontSize="xs" color={THEME.textSecondary}></Text>
<Text fontSize="sm" fontWeight="bold" color={THEME.textPrimary}>
{basicInfo.province} {basicInfo.city}
</Text>
</Box>
</HStack>
<HStack>
<Icon as={FaGlobe} color={THEME.gold} boxSize={4} />
<Box>
<Text fontSize="xs" color={THEME.textSecondary}></Text>
<Link
href={basicInfo.website}
isExternal
color={THEME.goldLight}
fontSize="sm"
fontWeight="bold"
noOfLines={1}
_hover={{ color: THEME.gold }}
>
{basicInfo.website ? (
<>访 <ExternalLinkIcon mx="2px" /></>
) : (
"暂无"
)}
</Link>
</Box>
</HStack>
</SimpleGrid>
<Divider borderColor={THEME.border} />
{/* 区块三:公司介绍 */}
<Box>
<Text fontSize="sm" color={THEME.textSecondary} noOfLines={2}>
{basicInfo.company_intro}
</Text>
{basicInfo.company_intro && basicInfo.company_intro.length > 100 && (
<Link
color={THEME.goldLight}
fontSize="sm"
mt={1}
display="inline-block"
_hover={{ color: THEME.gold }}
>
</Link>
)}
</Box>
</VStack>
</CardBody>
</Card>
);
};
export default CompanyHeaderCard;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,537 @@
// src/views/Company/components/CompanyOverview/NewsEventsTab.js
// 新闻动态 Tab - 相关新闻事件列表 + 分页
import React from "react";
import {
Box,
VStack,
HStack,
Text,
Badge,
Icon,
Card,
CardBody,
Button,
Input,
InputGroup,
InputLeftElement,
Tag,
Center,
Spinner,
} from "@chakra-ui/react";
import { SearchIcon } from "@chakra-ui/icons";
import {
FaNewspaper,
FaBullhorn,
FaGavel,
FaFlask,
FaDollarSign,
FaShieldAlt,
FaFileAlt,
FaIndustry,
FaEye,
FaFire,
FaChartLine,
FaChevronLeft,
FaChevronRight,
} from "react-icons/fa";
/**
* 新闻动态 Tab 组件
*
* Props:
* - newsEvents: 新闻事件列表数组
* - newsLoading: 加载状态
* - newsPagination: 分页信息 { page, per_page, total, pages, has_next, has_prev }
* - searchQuery: 搜索关键词
* - onSearchChange: 搜索输入回调 (value) => void
* - onSearch: 搜索提交回调 () => void
* - onPageChange: 分页回调 (page) => void
* - cardBg: 卡片背景色
*/
const NewsEventsTab = ({
newsEvents = [],
newsLoading = false,
newsPagination = {
page: 1,
per_page: 10,
total: 0,
pages: 0,
has_next: false,
has_prev: false,
},
searchQuery = "",
onSearchChange,
onSearch,
onPageChange,
cardBg,
}) => {
// 事件类型图标映射
const getEventTypeIcon = (eventType) => {
const iconMap = {
企业公告: FaBullhorn,
政策: FaGavel,
技术突破: FaFlask,
企业融资: FaDollarSign,
政策监管: FaShieldAlt,
政策动态: FaFileAlt,
行业事件: FaIndustry,
};
return iconMap[eventType] || FaNewspaper;
};
// 重要性颜色映射
const getImportanceColor = (importance) => {
const colorMap = {
S: "red",
A: "orange",
B: "yellow",
C: "green",
};
return colorMap[importance] || "gray";
};
// 处理搜索输入
const handleInputChange = (e) => {
onSearchChange?.(e.target.value);
};
// 处理搜索提交
const handleSearchSubmit = () => {
onSearch?.();
};
// 处理键盘事件
const handleKeyPress = (e) => {
if (e.key === "Enter") {
handleSearchSubmit();
}
};
// 处理分页
const handlePageChange = (page) => {
onPageChange?.(page);
// 滚动到列表顶部
document
.getElementById("news-list-top")
?.scrollIntoView({ behavior: "smooth" });
};
// 渲染分页按钮
const renderPaginationButtons = () => {
const { page: currentPage, pages: totalPages } = newsPagination;
const pageButtons = [];
// 显示当前页及前后各2页
let startPage = Math.max(1, currentPage - 2);
let endPage = Math.min(totalPages, currentPage + 2);
// 如果开始页大于1显示省略号
if (startPage > 1) {
pageButtons.push(
<Text key="start-ellipsis" fontSize="sm" color="gray.400">
...
</Text>
);
}
for (let i = startPage; i <= endPage; i++) {
pageButtons.push(
<Button
key={i}
size="sm"
variant={i === currentPage ? "solid" : "outline"}
colorScheme={i === currentPage ? "blue" : "gray"}
onClick={() => handlePageChange(i)}
isDisabled={newsLoading}
>
{i}
</Button>
);
}
// 如果结束页小于总页数,显示省略号
if (endPage < totalPages) {
pageButtons.push(
<Text key="end-ellipsis" fontSize="sm" color="gray.400">
...
</Text>
);
}
return pageButtons;
};
return (
<VStack spacing={4} align="stretch">
<Card bg={cardBg} shadow="md">
<CardBody>
<VStack spacing={4} align="stretch">
{/* 搜索框和统计信息 */}
<HStack justify="space-between" flexWrap="wrap">
<HStack flex={1} minW="300px">
<InputGroup>
<InputLeftElement pointerEvents="none">
<SearchIcon color="gray.400" />
</InputLeftElement>
<Input
placeholder="搜索相关新闻..."
value={searchQuery}
onChange={handleInputChange}
onKeyPress={handleKeyPress}
/>
</InputGroup>
<Button
colorScheme="blue"
onClick={handleSearchSubmit}
isLoading={newsLoading}
minW="80px"
>
搜索
</Button>
</HStack>
{newsPagination.total > 0 && (
<HStack spacing={2}>
<Icon as={FaNewspaper} color="blue.500" />
<Text fontSize="sm" color="gray.600">
共找到{" "}
<Text as="span" fontWeight="bold" color="blue.600">
{newsPagination.total}
</Text>{" "}
条新闻
</Text>
</HStack>
)}
</HStack>
<div id="news-list-top" />
{/* 新闻列表 */}
{newsLoading ? (
<Center h="400px">
<VStack spacing={3}>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color="gray.600">正在加载新闻...</Text>
</VStack>
</Center>
) : newsEvents.length > 0 ? (
<>
<VStack spacing={3} align="stretch">
{newsEvents.map((event, idx) => {
const importanceColor = getImportanceColor(
event.importance
);
const eventTypeIcon = getEventTypeIcon(event.event_type);
return (
<Card
key={event.id || idx}
variant="outline"
_hover={{
bg: "gray.50",
shadow: "md",
borderColor: "blue.300",
}}
transition="all 0.2s"
>
<CardBody p={4}>
<VStack align="stretch" spacing={3}>
{/* 标题栏 */}
<HStack justify="space-between" align="start">
<VStack align="start" spacing={2} flex={1}>
<HStack>
<Icon
as={eventTypeIcon}
color="blue.500"
boxSize={5}
/>
<Text
fontWeight="bold"
fontSize="lg"
lineHeight="1.3"
>
{event.title}
</Text>
</HStack>
{/* 标签栏 */}
<HStack spacing={2} flexWrap="wrap">
{event.importance && (
<Badge
colorScheme={importanceColor}
variant="solid"
px={2}
>
{event.importance}
</Badge>
)}
{event.event_type && (
<Badge colorScheme="blue" variant="outline">
{event.event_type}
</Badge>
)}
{event.invest_score && (
<Badge
colorScheme="purple"
variant="subtle"
>
投资分: {event.invest_score}
</Badge>
)}
{event.keywords && event.keywords.length > 0 && (
<>
{event.keywords
.slice(0, 4)
.map((keyword, kidx) => (
<Tag
key={kidx}
size="sm"
colorScheme="cyan"
variant="subtle"
>
{typeof keyword === "string"
? keyword
: keyword?.concept ||
keyword?.name ||
"未知"}
</Tag>
))}
</>
)}
</HStack>
</VStack>
{/* 右侧信息栏 */}
<VStack align="end" spacing={1} minW="100px">
<Text fontSize="xs" color="gray.500">
{event.created_at
? new Date(
event.created_at
).toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
: ""}
</Text>
<HStack spacing={3}>
{event.view_count !== undefined && (
<HStack spacing={1}>
<Icon
as={FaEye}
boxSize={3}
color="gray.400"
/>
<Text fontSize="xs" color="gray.500">
{event.view_count}
</Text>
</HStack>
)}
{event.hot_score !== undefined && (
<HStack spacing={1}>
<Icon
as={FaFire}
boxSize={3}
color="orange.400"
/>
<Text fontSize="xs" color="gray.500">
{event.hot_score.toFixed(1)}
</Text>
</HStack>
)}
</HStack>
{event.creator && (
<Text fontSize="xs" color="gray.400">
@{event.creator.username}
</Text>
)}
</VStack>
</HStack>
{/* 描述 */}
{event.description && (
<Text
fontSize="sm"
color="gray.700"
lineHeight="1.6"
>
{event.description}
</Text>
)}
{/* 收益率数据 */}
{(event.related_avg_chg !== null ||
event.related_max_chg !== null ||
event.related_week_chg !== null) && (
<Box
pt={2}
borderTop="1px"
borderColor="gray.200"
>
<HStack spacing={6} flexWrap="wrap">
<HStack spacing={1}>
<Icon
as={FaChartLine}
boxSize={3}
color="gray.500"
/>
<Text
fontSize="xs"
color="gray.500"
fontWeight="medium"
>
相关涨跌:
</Text>
</HStack>
{event.related_avg_chg !== null &&
event.related_avg_chg !== undefined && (
<HStack spacing={1}>
<Text fontSize="xs" color="gray.500">
平均
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={
event.related_avg_chg > 0
? "red.500"
: "green.500"
}
>
{event.related_avg_chg > 0 ? "+" : ""}
{event.related_avg_chg.toFixed(2)}%
</Text>
</HStack>
)}
{event.related_max_chg !== null &&
event.related_max_chg !== undefined && (
<HStack spacing={1}>
<Text fontSize="xs" color="gray.500">
最大
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={
event.related_max_chg > 0
? "red.500"
: "green.500"
}
>
{event.related_max_chg > 0 ? "+" : ""}
{event.related_max_chg.toFixed(2)}%
</Text>
</HStack>
)}
{event.related_week_chg !== null &&
event.related_week_chg !== undefined && (
<HStack spacing={1}>
<Text fontSize="xs" color="gray.500">
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={
event.related_week_chg > 0
? "red.500"
: "green.500"
}
>
{event.related_week_chg > 0
? "+"
: ""}
{event.related_week_chg.toFixed(2)}%
</Text>
</HStack>
)}
</HStack>
</Box>
)}
</VStack>
</CardBody>
</Card>
);
})}
</VStack>
{/* 分页控件 */}
{newsPagination.pages > 1 && (
<Box pt={4}>
<HStack
justify="space-between"
align="center"
flexWrap="wrap"
>
{/* 分页信息 */}
<Text fontSize="sm" color="gray.600">
{newsPagination.page} / {newsPagination.pages}
</Text>
{/* 分页按钮 */}
<HStack spacing={2}>
<Button
size="sm"
onClick={() => handlePageChange(1)}
isDisabled={!newsPagination.has_prev || newsLoading}
leftIcon={<Icon as={FaChevronLeft} />}
>
首页
</Button>
<Button
size="sm"
onClick={() =>
handlePageChange(newsPagination.page - 1)
}
isDisabled={!newsPagination.has_prev || newsLoading}
>
上一页
</Button>
{/* 页码按钮 */}
{renderPaginationButtons()}
<Button
size="sm"
onClick={() =>
handlePageChange(newsPagination.page + 1)
}
isDisabled={!newsPagination.has_next || newsLoading}
>
下一页
</Button>
<Button
size="sm"
onClick={() => handlePageChange(newsPagination.pages)}
isDisabled={!newsPagination.has_next || newsLoading}
rightIcon={<Icon as={FaChevronRight} />}
>
末页
</Button>
</HStack>
</HStack>
</Box>
)}
</>
) : (
<Center h="400px">
<VStack spacing={3}>
<Icon as={FaNewspaper} boxSize={16} color="gray.300" />
<Text color="gray.500" fontSize="lg" fontWeight="medium">
暂无相关新闻
</Text>
<Text fontSize="sm" color="gray.400">
{searchQuery ? "尝试修改搜索关键词" : "该公司暂无新闻动态"}
</Text>
</VStack>
</Center>
)}
</VStack>
</CardBody>
</Card>
</VStack>
);
};
export default NewsEventsTab;

View File

@@ -0,0 +1,61 @@
// src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts
// 公告数据 Hook - 用于公司公告 Tab
import { useState, useEffect, useCallback } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import type { Announcement } from "../types";
const API_BASE_URL = getApiBase();
interface ApiResponse<T> {
success: boolean;
data: T;
}
interface UseAnnouncementsDataResult {
announcements: Announcement[];
loading: boolean;
error: string | null;
}
/**
* 公告数据 Hook
* @param stockCode - 股票代码
*/
export const useAnnouncementsData = (stockCode?: string): UseAnnouncementsDataResult => {
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => {
if (!stockCode) return;
setLoading(true);
setError(null);
try {
const response = await fetch(
`${API_BASE_URL}/api/stock/${stockCode}/announcements?limit=20`
);
const result = (await response.json()) as ApiResponse<Announcement[]>;
if (result.success) {
setAnnouncements(result.data);
} else {
setError("加载公告数据失败");
}
} catch (err) {
logger.error("useAnnouncementsData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadData();
}, [loadData]);
return { announcements, loading, error };
};

View File

@@ -0,0 +1,59 @@
// src/views/Company/components/CompanyOverview/hooks/useBasicInfo.ts
// 公司基本信息 Hook - 用于 CompanyHeaderCard
import { useState, useEffect, useCallback } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import type { BasicInfo } from "../types";
const API_BASE_URL = getApiBase();
interface ApiResponse<T> {
success: boolean;
data: T;
}
interface UseBasicInfoResult {
basicInfo: BasicInfo | null;
loading: boolean;
error: string | null;
}
/**
* 公司基本信息 Hook
* @param stockCode - 股票代码
*/
export const useBasicInfo = (stockCode?: string): UseBasicInfoResult => {
const [basicInfo, setBasicInfo] = useState<BasicInfo | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => {
if (!stockCode) return;
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/stock/${stockCode}/basic-info`);
const result = (await response.json()) as ApiResponse<BasicInfo>;
if (result.success) {
setBasicInfo(result.data);
} else {
setError("加载基本信息失败");
}
} catch (err) {
logger.error("useBasicInfo", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadData();
}, [loadData]);
return { basicInfo, loading, error };
};

View File

@@ -0,0 +1,59 @@
// src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts
// 分支机构数据 Hook - 用于分支机构 Tab
import { useState, useEffect, useCallback } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import type { Branch } from "../types";
const API_BASE_URL = getApiBase();
interface ApiResponse<T> {
success: boolean;
data: T;
}
interface UseBranchesDataResult {
branches: Branch[];
loading: boolean;
error: string | null;
}
/**
* 分支机构数据 Hook
* @param stockCode - 股票代码
*/
export const useBranchesData = (stockCode?: string): UseBranchesDataResult => {
const [branches, setBranches] = useState<Branch[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => {
if (!stockCode) return;
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/stock/${stockCode}/branches`);
const result = (await response.json()) as ApiResponse<Branch[]>;
if (result.success) {
setBranches(result.data);
} else {
setError("加载分支机构数据失败");
}
} catch (err) {
logger.error("useBranchesData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadData();
}, [loadData]);
return { branches, loading, error };
};

View File

@@ -0,0 +1,140 @@
// src/views/Company/components/CompanyOverview/hooks/useCompanyOverviewData.ts
// 公司概览数据加载 Hook
import { useState, useEffect, useCallback } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import type {
BasicInfo,
ActualControl,
Concentration,
Management,
Shareholder,
Branch,
Announcement,
DisclosureSchedule,
CompanyOverviewData,
} from "../types";
const API_BASE_URL = getApiBase();
interface ApiResponse<T> {
success: boolean;
data: T;
}
/**
* 公司概览数据加载 Hook
* @param propStockCode - 股票代码
* @returns 公司概览数据
*/
export const useCompanyOverviewData = (propStockCode?: string): CompanyOverviewData => {
const [stockCode, setStockCode] = useState(propStockCode || "000001");
const [loading, setLoading] = useState(false);
const [dataLoaded, setDataLoaded] = useState(false);
// 基本信息数据
const [basicInfo, setBasicInfo] = useState<BasicInfo | null>(null);
const [actualControl, setActualControl] = useState<ActualControl[]>([]);
const [concentration, setConcentration] = useState<Concentration[]>([]);
const [management, setManagement] = useState<Management[]>([]);
const [topCirculationShareholders, setTopCirculationShareholders] = useState<Shareholder[]>([]);
const [topShareholders, setTopShareholders] = useState<Shareholder[]>([]);
const [branches, setBranches] = useState<Branch[]>([]);
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [disclosureSchedule, setDisclosureSchedule] = useState<DisclosureSchedule[]>([]);
// 监听 props 中的 stockCode 变化
useEffect(() => {
if (propStockCode && propStockCode !== stockCode) {
setStockCode(propStockCode);
setDataLoaded(false);
}
}, [propStockCode, stockCode]);
// 加载基本信息数据9个接口
const loadBasicInfoData = useCallback(async () => {
if (dataLoaded) return;
setLoading(true);
try {
const [
basicRes,
actualRes,
concentrationRes,
managementRes,
circulationRes,
shareholdersRes,
branchesRes,
announcementsRes,
disclosureRes,
] = await Promise.all([
fetch(`${API_BASE_URL}/api/stock/${stockCode}/basic-info`).then((r) =>
r.json()
) as Promise<ApiResponse<BasicInfo>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/actual-control`).then((r) =>
r.json()
) as Promise<ApiResponse<ActualControl[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/concentration`).then((r) =>
r.json()
) as Promise<ApiResponse<Concentration[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/management?active_only=true`).then((r) =>
r.json()
) as Promise<ApiResponse<Management[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-circulation-shareholders?limit=10`).then((r) =>
r.json()
) as Promise<ApiResponse<Shareholder[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-shareholders?limit=10`).then((r) =>
r.json()
) as Promise<ApiResponse<Shareholder[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/branches`).then((r) =>
r.json()
) as Promise<ApiResponse<Branch[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/announcements?limit=20`).then((r) =>
r.json()
) as Promise<ApiResponse<Announcement[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/disclosure-schedule`).then((r) =>
r.json()
) as Promise<ApiResponse<DisclosureSchedule[]>>,
]);
if (basicRes.success) setBasicInfo(basicRes.data);
if (actualRes.success) setActualControl(actualRes.data);
if (concentrationRes.success) setConcentration(concentrationRes.data);
if (managementRes.success) setManagement(managementRes.data);
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
if (branchesRes.success) setBranches(branchesRes.data);
if (announcementsRes.success) setAnnouncements(announcementsRes.data);
if (disclosureRes.success) setDisclosureSchedule(disclosureRes.data);
setDataLoaded(true);
} catch (err) {
logger.error("useCompanyOverviewData", "loadBasicInfoData", err, { stockCode });
} finally {
setLoading(false);
}
}, [stockCode, dataLoaded]);
// 首次加载
useEffect(() => {
if (stockCode) {
loadBasicInfoData();
}
}, [stockCode, loadBasicInfoData]);
return {
basicInfo,
actualControl,
concentration,
management,
topCirculationShareholders,
topShareholders,
branches,
announcements,
disclosureSchedule,
loading,
dataLoaded,
};
};

View File

@@ -0,0 +1,61 @@
// src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts
// 披露日程数据 Hook - 用于工商信息 Tab
import { useState, useEffect, useCallback } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import type { DisclosureSchedule } from "../types";
const API_BASE_URL = getApiBase();
interface ApiResponse<T> {
success: boolean;
data: T;
}
interface UseDisclosureDataResult {
disclosureSchedule: DisclosureSchedule[];
loading: boolean;
error: string | null;
}
/**
* 披露日程数据 Hook
* @param stockCode - 股票代码
*/
export const useDisclosureData = (stockCode?: string): UseDisclosureDataResult => {
const [disclosureSchedule, setDisclosureSchedule] = useState<DisclosureSchedule[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => {
if (!stockCode) return;
setLoading(true);
setError(null);
try {
const response = await fetch(
`${API_BASE_URL}/api/stock/${stockCode}/disclosure-schedule`
);
const result = (await response.json()) as ApiResponse<DisclosureSchedule[]>;
if (result.success) {
setDisclosureSchedule(result.data);
} else {
setError("加载披露日程数据失败");
}
} catch (err) {
logger.error("useDisclosureData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadData();
}, [loadData]);
return { disclosureSchedule, loading, error };
};

View File

@@ -0,0 +1,61 @@
// src/views/Company/components/CompanyOverview/hooks/useManagementData.ts
// 管理团队数据 Hook - 用于管理团队 Tab
import { useState, useEffect, useCallback } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import type { Management } from "../types";
const API_BASE_URL = getApiBase();
interface ApiResponse<T> {
success: boolean;
data: T;
}
interface UseManagementDataResult {
management: Management[];
loading: boolean;
error: string | null;
}
/**
* 管理团队数据 Hook
* @param stockCode - 股票代码
*/
export const useManagementData = (stockCode?: string): UseManagementDataResult => {
const [management, setManagement] = useState<Management[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => {
if (!stockCode) return;
setLoading(true);
setError(null);
try {
const response = await fetch(
`${API_BASE_URL}/api/stock/${stockCode}/management?active_only=true`
);
const result = (await response.json()) as ApiResponse<Management[]>;
if (result.success) {
setManagement(result.data);
} else {
setError("加载管理团队数据失败");
}
} catch (err) {
logger.error("useManagementData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadData();
}, [loadData]);
return { management, loading, error };
};

View File

@@ -0,0 +1,83 @@
// src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts
// 股权结构数据 Hook - 用于股权结构 Tab
import { useState, useEffect, useCallback } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import type { ActualControl, Concentration, Shareholder } from "../types";
const API_BASE_URL = getApiBase();
interface ApiResponse<T> {
success: boolean;
data: T;
}
interface UseShareholderDataResult {
actualControl: ActualControl[];
concentration: Concentration[];
topShareholders: Shareholder[];
topCirculationShareholders: Shareholder[];
loading: boolean;
error: string | null;
}
/**
* 股权结构数据 Hook
* @param stockCode - 股票代码
*/
export const useShareholderData = (stockCode?: string): UseShareholderDataResult => {
const [actualControl, setActualControl] = useState<ActualControl[]>([]);
const [concentration, setConcentration] = useState<Concentration[]>([]);
const [topShareholders, setTopShareholders] = useState<Shareholder[]>([]);
const [topCirculationShareholders, setTopCirculationShareholders] = useState<Shareholder[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => {
if (!stockCode) return;
setLoading(true);
setError(null);
try {
const [actualRes, concentrationRes, shareholdersRes, circulationRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/stock/${stockCode}/actual-control`).then((r) =>
r.json()
) as Promise<ApiResponse<ActualControl[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/concentration`).then((r) =>
r.json()
) as Promise<ApiResponse<Concentration[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-shareholders?limit=10`).then((r) =>
r.json()
) as Promise<ApiResponse<Shareholder[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-circulation-shareholders?limit=10`).then((r) =>
r.json()
) as Promise<ApiResponse<Shareholder[]>>,
]);
if (actualRes.success) setActualControl(actualRes.data);
if (concentrationRes.success) setConcentration(concentrationRes.data);
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
} catch (err) {
logger.error("useShareholderData", "loadData", err, { stockCode });
setError("加载股权结构数据失败");
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadData();
}, [loadData]);
return {
actualControl,
concentration,
topShareholders,
topCirculationShareholders,
loading,
error,
};
};

View File

@@ -0,0 +1,65 @@
// src/views/Company/components/CompanyOverview/index.tsx
// 公司概览 - 主组件(组合层)
// 懒加载优化只加载头部卡片数据BasicInfoTab 内部懒加载各 Tab 数据
import React from "react";
import { VStack, Spinner, Center, Text } from "@chakra-ui/react";
import { useBasicInfo } from "./hooks/useBasicInfo";
import CompanyHeaderCard from "./CompanyHeaderCard";
import type { CompanyOverviewProps } from "./types";
// 子组件(暂保持 JS
import BasicInfoTab from "./BasicInfoTab";
/**
* 公司概览组件
*
* 功能:
* - 显示公司头部信息卡片useBasicInfo
* - 显示基本信息 Tab内部懒加载各子 Tab 数据)
*
* 懒加载策略:
* - 主组件只加载 basicInfo1 个 API
* - BasicInfoTab 内部根据 Tab 切换懒加载其他数据
*/
const CompanyOverview: React.FC<CompanyOverviewProps> = ({ stockCode }) => {
const { basicInfo, loading, error } = useBasicInfo(stockCode);
// 加载状态
if (loading && !basicInfo) {
return (
<Center h="300px">
<VStack spacing={4}>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text>...</Text>
</VStack>
</Center>
);
}
// 错误状态
if (error && !basicInfo) {
return (
<Center h="300px">
<Text color="red.500">{error}</Text>
</Center>
);
}
return (
<VStack spacing={6} align="stretch">
{/* 公司头部信息卡片 */}
{basicInfo && <CompanyHeaderCard basicInfo={basicInfo} />}
{/* 基本信息内容 - 传入 stockCode内部懒加载各 Tab 数据 */}
<BasicInfoTab
stockCode={stockCode}
basicInfo={basicInfo}
cardBg="white"
/>
</VStack>
);
};
export default CompanyOverview;

View File

@@ -0,0 +1,118 @@
// src/views/Company/components/CompanyOverview/types.ts
// 公司概览组件类型定义
/**
* 公司基本信息
*/
export interface BasicInfo {
ORGNAME?: string;
SECNAME?: string;
SECCODE?: string;
sw_industry_l1?: string;
sw_industry_l2?: string;
sw_industry_l3?: string;
legal_representative?: string;
chairman?: string;
general_manager?: string;
establish_date?: string;
reg_capital?: number;
province?: string;
city?: string;
website?: string;
email?: string;
tel?: string;
company_intro?: string;
}
/**
* 实际控制人
*/
export interface ActualControl {
controller_name?: string;
controller_type?: string;
holding_ratio?: number;
}
/**
* 股权集中度
*/
export interface Concentration {
top1_ratio?: number;
top5_ratio?: number;
top10_ratio?: number;
}
/**
* 管理层信息
*/
export interface Management {
name?: string;
position?: string;
start_date?: string;
end_date?: string;
}
/**
* 股东信息
*/
export interface Shareholder {
shareholder_name?: string;
holding_ratio?: number;
holding_amount?: number;
}
/**
* 分支机构
*/
export interface Branch {
branch_name?: string;
address?: string;
}
/**
* 公告信息
*/
export interface Announcement {
title?: string;
publish_date?: string;
url?: string;
}
/**
* 披露计划
*/
export interface DisclosureSchedule {
report_type?: 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
*/
export interface CompanyOverviewProps {
stockCode?: string;
}
/**
* CompanyHeaderCard 组件 Props
*/
export interface CompanyHeaderCardProps {
basicInfo: BasicInfo;
}

View File

@@ -0,0 +1,26 @@
// src/views/Company/components/CompanyOverview/utils.ts
// 公司概览格式化工具函数
/**
* 格式化注册资本
* @param value - 注册资本(万元)
* @returns 格式化后的字符串
*/
export const formatRegisteredCapital = (value: number | null | undefined): string => {
if (!value && value !== 0) return "-";
const absValue = Math.abs(value);
if (absValue >= 100000) {
return (value / 10000).toFixed(2) + "亿元";
}
return value.toFixed(2) + "万元";
};
/**
* 格式化日期
* @param dateString - 日期字符串
* @returns 格式化后的日期字符串
*/
export const formatDate = (dateString: string | null | undefined): string => {
if (!dateString) return "-";
return new Date(dateString).toLocaleDateString("zh-CN");
};

View File

@@ -0,0 +1,55 @@
// src/views/Company/components/CompanyTabs/TabNavigation.js
// Tab 导航组件 - 动态渲染 Tab 按钮(黑金主题)
import React from 'react';
import {
TabList,
Tab,
HStack,
Icon,
Text,
} from '@chakra-ui/react';
import { COMPANY_TABS } from '../../constants';
// 黑金主题颜色配置
const THEME_COLORS = {
bg: '#1A202C', // 背景纯黑
selectedBg: '#C9A961', // 选中项金色背景
selectedText: '#FFFFFF', // 选中项白色文字
unselectedText: '#D4AF37', // 未选中项金色
};
/**
* Tab 导航组件(黑金主题)
*/
const TabNavigation = () => {
return (
<TabList py={4} bg={THEME_COLORS.bg} borderTopLeftRadius="16px" borderTopRightRadius="16px">
{COMPANY_TABS.map((tab, index) => (
<Tab
key={tab.key}
color={THEME_COLORS.unselectedText}
borderRadius="full"
px={4}
py={2}
_selected={{
bg: THEME_COLORS.selectedBg,
color: THEME_COLORS.selectedText,
}}
_hover={{
color: THEME_COLORS.selectedText,
}}
mr={index < COMPANY_TABS.length - 1 ? 2 : 0}
>
<HStack spacing={2}>
<Icon as={tab.icon} boxSize="18px" />
<Text fontSize="15px">{tab.name}</Text>
</HStack>
</Tab>
))}
</TabList>
);
};
export default TabNavigation;

View File

@@ -0,0 +1,99 @@
// src/views/Company/components/CompanyTabs/index.js
// Tab 容器组件 - 管理 Tab 切换和内容渲染
import React, { useState } from 'react';
import {
Card,
CardBody,
Tabs,
TabPanels,
TabPanel,
Divider,
} from '@chakra-ui/react';
import TabNavigation from './TabNavigation';
import { COMPANY_TABS, getTabNameByIndex } from '../../constants';
// 子组件导入Tab 内容组件)
import CompanyOverview from '../CompanyOverview';
import DeepAnalysis from '../DeepAnalysis';
import MarketDataView from '../MarketDataView';
import FinancialPanorama from '../FinancialPanorama';
import ForecastReport from '../ForecastReport';
import DynamicTracking from '../DynamicTracking';
/**
* Tab 组件映射
* key 与 COMPANY_TABS 中的 key 对应
*/
const TAB_COMPONENTS = {
overview: CompanyOverview,
analysis: DeepAnalysis,
market: MarketDataView,
financial: FinancialPanorama,
forecast: ForecastReport,
tracking: DynamicTracking,
};
/**
* Tab 容器组件
*
* 功能:
* - 管理 Tab 切换状态
* - 动态渲染 Tab 导航和内容
* - 触发 Tab 变更追踪
*
* @param {Object} props
* @param {string} props.stockCode - 当前股票代码
* @param {Function} props.onTabChange - Tab 变更回调 (index, tabName, prevIndex) => void
*/
const CompanyTabs = ({ stockCode, onTabChange }) => {
const [currentIndex, setCurrentIndex] = useState(0);
/**
* 处理 Tab 切换
*/
const handleTabChange = (index) => {
const tabName = getTabNameByIndex(index);
// 触发追踪回调
onTabChange?.(index, tabName, currentIndex);
// 更新状态
setCurrentIndex(index);
};
return (
<Card shadow="lg" bg='#1A202C'>
<CardBody p={0}>
<Tabs
isLazy
variant="soft-rounded"
colorScheme="blue"
size="lg"
index={currentIndex}
onChange={handleTabChange}
>
{/* Tab 导航(黑金主题) */}
<TabNavigation />
<Divider />
{/* Tab 内容面板 */}
<TabPanels>
{COMPANY_TABS.map((tab) => {
const Component = TAB_COMPONENTS[tab.key];
return (
<TabPanel key={tab.key} px={0}>
<Component stockCode={stockCode} />
</TabPanel>
);
})}
</TabPanels>
</Tabs>
</CardBody>
</Card>
);
};
export default CompanyTabs;

View File

@@ -0,0 +1,100 @@
// src/views/Company/components/DeepAnalysis/index.js
// 深度分析 - 独立一级 Tab 组件
import React, { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
// 复用原有的展示组件
import DeepAnalysisTab from "../CompanyOverview/DeepAnalysisTab";
const API_BASE_URL = getApiBase();
/**
* 深度分析组件
*
* 功能:
* - 加载深度分析数据3个接口
* - 管理展开状态
* - 渲染 DeepAnalysisTab 展示组件
*
* @param {Object} props
* @param {string} props.stockCode - 股票代码
*/
const DeepAnalysis = ({ stockCode }) => {
// 数据状态
const [comprehensiveData, setComprehensiveData] = useState(null);
const [valueChainData, setValueChainData] = useState(null);
const [keyFactorsData, setKeyFactorsData] = useState(null);
const [loading, setLoading] = useState(false);
// 业务板块展开状态
const [expandedSegments, setExpandedSegments] = useState({});
// 切换业务板块展开状态
const toggleSegmentExpansion = (segmentIndex) => {
setExpandedSegments((prev) => ({
...prev,
[segmentIndex]: !prev[segmentIndex],
}));
};
// 加载深度分析数据3个接口
const loadDeepAnalysisData = async () => {
if (!stockCode) return;
setLoading(true);
try {
const requests = [
fetch(
`${API_BASE_URL}/api/company/comprehensive-analysis/${stockCode}`
).then((r) => r.json()),
fetch(
`${API_BASE_URL}/api/company/value-chain-analysis/${stockCode}`
).then((r) => r.json()),
fetch(
`${API_BASE_URL}/api/company/key-factors-timeline/${stockCode}`
).then((r) => r.json()),
];
const [comprehensiveRes, valueChainRes, keyFactorsRes] =
await Promise.all(requests);
if (comprehensiveRes.success) setComprehensiveData(comprehensiveRes.data);
if (valueChainRes.success) setValueChainData(valueChainRes.data);
if (keyFactorsRes.success) setKeyFactorsData(keyFactorsRes.data);
} catch (err) {
logger.error("DeepAnalysis", "loadDeepAnalysisData", err, { stockCode });
} finally {
setLoading(false);
}
};
// stockCode 变更时重新加载数据
useEffect(() => {
if (stockCode) {
// 重置数据
setComprehensiveData(null);
setValueChainData(null);
setKeyFactorsData(null);
setExpandedSegments({});
// 加载新数据
loadDeepAnalysisData();
}
}, [stockCode]);
return (
<DeepAnalysisTab
comprehensiveData={comprehensiveData}
valueChainData={valueChainData}
keyFactorsData={keyFactorsData}
loading={loading}
cardBg="white"
expandedSegments={expandedSegments}
onToggleSegment={toggleSegmentExpansion}
/>
);
};
export default DeepAnalysis;

View File

@@ -0,0 +1,184 @@
// src/views/Company/components/DynamicTracking/index.js
// 动态跟踪 - 独立一级 Tab 组件(包含新闻动态等二级 Tab
import React, { useState, useEffect, useCallback } from "react";
import {
Box,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
} from "@chakra-ui/react";
import { FaNewspaper } from "react-icons/fa";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import NewsEventsTab from "../CompanyOverview/NewsEventsTab";
// API配置
const API_BASE_URL = getApiBase();
// 二级 Tab 配置
const TRACKING_TABS = [
{ key: "news", name: "新闻动态", icon: FaNewspaper },
// 后续可扩展更多二级 Tab
];
/**
* 动态跟踪组件
*
* 功能:
* - 二级 Tab 结构
* - Tab1: 新闻动态(复用 NewsEventsTab
* - 预留后续扩展
*
* @param {Object} props
* @param {string} props.stockCode - 股票代码
*/
const DynamicTracking = ({ stockCode: propStockCode }) => {
const [stockCode, setStockCode] = useState(propStockCode || "000001");
const [activeTab, setActiveTab] = useState(0);
// 新闻动态状态
const [newsEvents, setNewsEvents] = useState([]);
const [newsLoading, setNewsLoading] = useState(false);
const [newsPagination, setNewsPagination] = useState({
page: 1,
per_page: 10,
total: 0,
pages: 0,
has_next: false,
has_prev: false,
});
const [searchQuery, setSearchQuery] = useState("");
const [stockName, setStockName] = useState("");
const [dataLoaded, setDataLoaded] = useState(false);
// 监听 props 中的 stockCode 变化
useEffect(() => {
if (propStockCode && propStockCode !== stockCode) {
setStockCode(propStockCode);
setDataLoaded(false);
setNewsEvents([]);
setStockName("");
setSearchQuery("");
}
}, [propStockCode, stockCode]);
// 获取股票名称(用于搜索)
const fetchStockName = useCallback(async () => {
try {
const response = await fetch(
`${API_BASE_URL}/api/stock/${stockCode}/basic-info`
);
const result = await response.json();
if (result.success && result.data) {
const name = result.data.SECNAME || result.data.ORGNAME || stockCode;
setStockName(name);
return name;
}
return stockCode;
} catch (err) {
logger.error("DynamicTracking", "fetchStockName", err, { stockCode });
return stockCode;
}
}, [stockCode]);
// 加载新闻事件数据
const loadNewsEvents = useCallback(
async (query, page = 1) => {
setNewsLoading(true);
try {
const searchTerm = query || stockName || stockCode;
const response = await fetch(
`${API_BASE_URL}/api/events?q=${encodeURIComponent(searchTerm)}&page=${page}&per_page=10`
);
const result = await response.json();
if (result.success) {
setNewsEvents(result.data || []);
setNewsPagination({
page: result.pagination?.page || page,
per_page: result.pagination?.per_page || 10,
total: result.pagination?.total || 0,
pages: result.pagination?.pages || 0,
has_next: result.pagination?.has_next || false,
has_prev: result.pagination?.has_prev || false,
});
}
} catch (err) {
logger.error("DynamicTracking", "loadNewsEvents", err, { stockCode });
setNewsEvents([]);
} finally {
setNewsLoading(false);
}
},
[stockCode, stockName]
);
// 首次加载
useEffect(() => {
const initLoad = async () => {
if (stockCode && !dataLoaded) {
const name = await fetchStockName();
await loadNewsEvents(name, 1);
setDataLoaded(true);
}
};
initLoad();
}, [stockCode, dataLoaded, fetchStockName, loadNewsEvents]);
// 搜索处理
const handleSearchChange = (value) => {
setSearchQuery(value);
};
const handleSearch = () => {
loadNewsEvents(searchQuery || stockName, 1);
};
// 分页处理
const handlePageChange = (page) => {
loadNewsEvents(searchQuery || stockName, page);
};
return (
<Box>
<Tabs
variant="enclosed"
colorScheme="blue"
index={activeTab}
onChange={setActiveTab}
>
<TabList>
{TRACKING_TABS.map((tab) => (
<Tab key={tab.key} fontWeight="medium">
{tab.name}
</Tab>
))}
</TabList>
<TabPanels>
{/* 新闻动态 Tab */}
<TabPanel p={4}>
<NewsEventsTab
newsEvents={newsEvents}
newsLoading={newsLoading}
newsPagination={newsPagination}
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
onSearch={handleSearch}
onPageChange={handlePageChange}
cardBg="white"
/>
</TabPanel>
{/* 后续可扩展更多 Tab Panel */}
</TabPanels>
</Tabs>
</Box>
);
};
export default DynamicTracking;

View File

@@ -1,6 +1,6 @@
// src/views/Company/FinancialPanorama.jsx
import React, { useState, useEffect, useMemo } from 'react';
import { logger } from '../../utils/logger';
import { logger } from '@utils/logger';
import {
Box,
Container,
@@ -35,7 +35,6 @@ import {
VStack,
HStack,
Divider,
useColorModeValue,
Select,
Button,
Tooltip,
@@ -60,7 +59,6 @@ import {
ButtonGroup,
Stack,
Collapse,
useColorMode,
} from '@chakra-ui/react';
import {
ChevronDownIcon,
@@ -75,7 +73,7 @@ import {
ArrowDownIcon,
} from '@chakra-ui/icons';
import ReactECharts from 'echarts-for-react';
import { financialService, formatUtils, chartUtils } from '../../services/financialService';
import { financialService, formatUtils, chartUtils } from '@services/financialService';
const FinancialPanorama = ({ stockCode: propStockCode }) => {
// 状态管理
@@ -84,7 +82,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
const [error, setError] = useState(null);
const [selectedPeriods, setSelectedPeriods] = useState(8);
const [activeTab, setActiveTab] = useState(0);
// 财务数据状态
const [stockInfo, setStockInfo] = useState(null);
const [balanceSheet, setBalanceSheet] = useState([]);
@@ -101,14 +99,13 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
const [modalContent, setModalContent] = useState(null);
const [expandedRows, setExpandedRows] = useState({});
const toast = useToast();
const { colorMode } = useColorMode();
// 颜色配置(中国市场:红涨绿跌)
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const positiveColor = useColorModeValue('red.500', 'red.400'); // 红涨
const negativeColor = useColorModeValue('green.500', 'green.400'); // 绿跌
const bgColor = 'white';
const borderColor = 'gray.200';
const hoverBg = 'gray.50';
const positiveColor = 'red.500'; // 红涨
const negativeColor = 'green.500'; // 绿跌
// 加载所有财务数据
const loadFinancialData = async () => {
@@ -492,7 +489,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
<React.Fragment key={section.key}>
{section.title !== '资产总计' && section.title !== '负债合计' && (
<Tr
bg={useColorModeValue('gray.50', 'gray.700')}
bg="gray.50"
cursor="pointer"
onClick={() => toggleSection(section.key)}
>
@@ -515,7 +512,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
key={metric.key}
_hover={{ bg: hoverBg, cursor: 'pointer' }}
onClick={() => showMetricChart(metric.name, metric.key, balanceSheet, metric.path)}
bg={metric.isTotal ? useColorModeValue('blue.50', 'blue.900') : 'transparent'}
bg={metric.isTotal ? 'blue.50' : 'transparent'}
>
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
<HStack spacing={2}>
@@ -733,7 +730,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
const renderSection = (section) => (
<React.Fragment key={section.key}>
<Tr
bg={useColorModeValue('gray.50', 'gray.700')}
bg="gray.50"
cursor="pointer"
onClick={() => toggleSection(section.key)}
>
@@ -755,8 +752,8 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
key={metric.key}
_hover={{ bg: hoverBg, cursor: 'pointer' }}
onClick={() => showMetricChart(metric.name, metric.key, incomeStatement, metric.path)}
bg={metric.isTotal ? useColorModeValue('blue.50', 'blue.900') :
metric.isSubtotal ? useColorModeValue('orange.50', 'orange.900') : 'transparent'}
bg={metric.isTotal ? 'blue.50' :
metric.isSubtotal ? 'orange.50' : 'transparent'}
>
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
<HStack spacing={2}>
@@ -1268,7 +1265,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
{ label: '资产负债率', value: financialMetrics[0].solvency?.asset_liability_ratio, format: 'percent' },
{ label: '研发费用率', value: financialMetrics[0].expense_ratios?.rd_expense_ratio, format: 'percent' },
].map((item, idx) => (
<Box key={idx} p={3} borderRadius="md" bg={useColorModeValue('gray.50', 'gray.700')}>
<Box key={idx} p={3} borderRadius="md" bg="gray.50">
<Text fontSize="xs" color="gray.500">{item.label}</Text>
<Text fontSize="lg" fontWeight="bold">
{item.format === 'percent' ?

View File

@@ -4,7 +4,7 @@ import { Box, Flex, Input, Button, SimpleGrid, HStack, Text, Skeleton, VStack }
import { Card, CardHeader, CardBody, Heading, Table, Thead, Tr, Th, Tbody, Td, Tag } from '@chakra-ui/react';
import { RepeatIcon } from '@chakra-ui/icons';
import ReactECharts from 'echarts-for-react';
import { stockService } from '../../services/eventService';
import { stockService } from '@services/eventService';
const ForecastReport = ({ stockCode: propStockCode }) => {
const [code, setCode] = useState(propStockCode || '600000');

View File

@@ -0,0 +1,188 @@
// src/views/Company/components/MarketDataView/components/AnalysisModal.tsx
// 涨幅分析模态框组件
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
VStack,
HStack,
Box,
Heading,
Text,
Tag,
Badge,
Icon,
} from '@chakra-ui/react';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import MarkdownRenderer from './MarkdownRenderer';
import { formatNumber } from '../utils/formatUtils';
import type { AnalysisModalProps, RiseAnalysis, Theme } from '../types';
/**
* 涨幅分析内容组件
*/
interface AnalysisContentProps {
analysis: RiseAnalysis;
theme: Theme;
}
export const AnalysisContent: React.FC<AnalysisContentProps> = ({ analysis, theme }) => {
return (
<VStack align="stretch" spacing={4}>
{/* 头部信息 */}
<Box>
<Heading size="md" mb={2}>
{analysis.stock_name} ({analysis.stock_code})
</Heading>
<HStack spacing={4} mb={4}>
<Tag colorScheme="blue">: {analysis.trade_date}</Tag>
<Tag colorScheme="red">: {analysis.rise_rate}%</Tag>
<Tag colorScheme="green">: {analysis.close_price}</Tag>
</HStack>
</Box>
{/* 主营业务 */}
{analysis.main_business && (
<Box p={4} bg="gray.50" borderRadius="md">
<Heading size="sm" mb={2} color={theme.primary}>
</Heading>
<Text color={theme.textPrimary}>{analysis.main_business}</Text>
</Box>
)}
{/* 详细分析 */}
{analysis.rise_reason_detail && (
<Box p={4} bg="purple.50" borderRadius="md">
<Heading size="sm" mb={2} color={theme.primary}>
</Heading>
<MarkdownRenderer theme={theme}>{analysis.rise_reason_detail}</MarkdownRenderer>
</Box>
)}
{/* 相关公告 */}
{analysis.announcements && analysis.announcements !== '[]' && (
<Box p={4} bg="orange.50" borderRadius="md">
<Heading size="sm" mb={2} color={theme.primary}>
</Heading>
<MarkdownRenderer theme={theme}>{analysis.announcements}</MarkdownRenderer>
</Box>
)}
{/* 研报引用 */}
{analysis.verification_reports && analysis.verification_reports.length > 0 && (
<Box p={4} bg="blue.50" borderRadius="md">
<Heading size="sm" mb={3} color={theme.primary}>
<HStack spacing={2}>
<Icon as={ExternalLinkIcon} />
<Text> ({analysis.verification_reports.length})</Text>
</HStack>
</Heading>
<VStack spacing={3} align="stretch">
{analysis.verification_reports.map((report, reportIdx) => (
<Box
key={reportIdx}
p={3}
bg="white"
borderRadius="md"
border="1px solid"
borderColor={theme.border}
>
<HStack justify="space-between" mb={2}>
<HStack spacing={2}>
<Badge colorScheme="blue" fontSize="xs">
{report.publisher || '未知机构'}
</Badge>
{report.match_score && (
<Badge
colorScheme={
report.match_score === '好'
? 'green'
: report.match_score === '中'
? 'yellow'
: 'gray'
}
fontSize="xs"
>
: {report.match_score}
</Badge>
)}
{report.match_ratio != null && report.match_ratio > 0 && (
<Badge colorScheme="purple" fontSize="xs">
{(report.match_ratio * 100).toFixed(0)}%
</Badge>
)}
</HStack>
{report.declare_date && (
<Text fontSize="xs" color={theme.textMuted}>
{report.declare_date.substring(0, 10)}
</Text>
)}
</HStack>
{report.report_title && (
<Text fontWeight="bold" fontSize="sm" color={theme.textPrimary} mb={1}>
{report.report_title}
</Text>
)}
{report.author && (
<Text fontSize="xs" color={theme.textMuted} mb={2}>
: {report.author}
</Text>
)}
{report.verification_item && (
<Box p={2} bg="yellow.50" borderRadius="sm" mb={2}>
<Text fontSize="xs" color={theme.textMuted}>
<strong>:</strong> {report.verification_item}
</Text>
</Box>
)}
{report.content && (
<Text fontSize="sm" color={theme.textSecondary} noOfLines={4}>
{report.content}
</Text>
)}
</Box>
))}
</VStack>
</Box>
)}
{/* 底部统计 */}
<Box mt={4}>
<Text fontSize="sm" color={theme.textMuted}>
: {formatNumber(analysis.volume)} | : {formatNumber(analysis.amount)} | :{' '}
{analysis.update_time || analysis.create_time || '-'}
</Text>
</Box>
</VStack>
);
};
/**
* 涨幅分析模态框组件
*/
const AnalysisModal: React.FC<AnalysisModalProps> = ({ isOpen, onClose, content, theme }) => {
return (
<Modal isOpen={isOpen} onClose={onClose} size="4xl" scrollBehavior="inside">
<ModalOverlay />
<ModalContent bg={theme.bgCard}>
<ModalHeader color={theme.textPrimary}></ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>{content}</ModalBody>
</ModalContent>
</Modal>
);
};
export default AnalysisModal;

View File

@@ -0,0 +1,65 @@
// src/views/Company/components/MarketDataView/components/MarkdownRenderer.tsx
// Markdown 渲染组件
import React from 'react';
import { Box } from '@chakra-ui/react';
import ReactMarkdown from 'react-markdown';
import type { MarkdownRendererProps } from '../types';
/**
* Markdown 渲染组件
* 提供统一的 Markdown 样式
*/
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ children, theme }) => {
return (
<Box
color={theme.textPrimary}
sx={{
'& h1, & h2, & h3, & h4, & h5, & h6': {
color: theme.primary,
marginTop: 4,
marginBottom: 2,
fontWeight: 'bold',
},
'& h1': { fontSize: '1.5em' },
'& h2': { fontSize: '1.3em' },
'& h3': { fontSize: '1.1em' },
'& p': {
marginBottom: 3,
lineHeight: 1.6,
},
'& ul, & ol': {
paddingLeft: 4,
marginBottom: 3,
},
'& li': {
marginBottom: 1,
},
'& strong': {
fontWeight: 'bold',
color: theme.textSecondary,
},
'& em': {
fontStyle: 'italic',
},
'& code': {
backgroundColor: 'rgba(0,0,0,0.05)',
padding: '2px 4px',
borderRadius: '4px',
fontSize: '0.9em',
},
'& blockquote': {
borderLeft: `3px solid ${theme.primary}`,
paddingLeft: 4,
marginLeft: 2,
fontStyle: 'italic',
opacity: 0.9,
},
}}
>
<ReactMarkdown>{children}</ReactMarkdown>
</Box>
);
};
export default MarkdownRenderer;

View File

@@ -0,0 +1,133 @@
// src/views/Company/components/MarketDataView/components/StockSummaryCard.tsx
// 股票概览卡片组件
import React from 'react';
import {
CardBody,
Grid,
GridItem,
VStack,
HStack,
Heading,
Badge,
Stat,
StatLabel,
StatNumber,
StatHelpText,
StatArrow,
SimpleGrid,
} from '@chakra-ui/react';
import ThemedCard from './ThemedCard';
import { formatNumber, formatPercent } from '../utils/formatUtils';
import type { StockSummaryCardProps } from '../types';
/**
* 股票概览卡片组件
* 显示股票基本信息、最新交易数据和融资融券数据
*/
const StockSummaryCard: React.FC<StockSummaryCardProps> = ({ summary, theme }) => {
if (!summary) return null;
const { latest_trade, latest_funding, latest_pledge } = summary;
return (
<ThemedCard theme={theme}>
<CardBody>
<Grid templateColumns="repeat(12, 1fr)" gap={6}>
{/* 左侧:股票名称和涨跌 */}
<GridItem colSpan={{ base: 12, md: 4 }}>
<VStack align="start" spacing={2}>
<HStack>
<Heading size="xl" color={theme.textSecondary}>
{summary.stock_name}
</Heading>
<Badge colorScheme="blue" fontSize="lg">
{summary.stock_code}
</Badge>
</HStack>
{latest_trade && (
<HStack spacing={4}>
<Stat>
<StatNumber fontSize="4xl" color={theme.textPrimary}>
{latest_trade.close}
</StatNumber>
<StatHelpText fontSize="lg">
<StatArrow
type={latest_trade.change_percent >= 0 ? 'increase' : 'decrease'}
color={latest_trade.change_percent >= 0 ? theme.success : theme.danger}
/>
{Math.abs(latest_trade.change_percent).toFixed(2)}%
</StatHelpText>
</Stat>
</HStack>
)}
</VStack>
</GridItem>
{/* 右侧:详细指标 */}
<GridItem colSpan={{ base: 12, md: 8 }}>
{/* 交易指标 */}
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
{latest_trade && (
<>
<Stat>
<StatLabel color={theme.textMuted}></StatLabel>
<StatNumber color={theme.textSecondary}>
{formatNumber(latest_trade.volume, 0)}
</StatNumber>
</Stat>
<Stat>
<StatLabel color={theme.textMuted}></StatLabel>
<StatNumber color={theme.textSecondary}>
{formatNumber(latest_trade.amount)}
</StatNumber>
</Stat>
<Stat>
<StatLabel color={theme.textMuted}></StatLabel>
<StatNumber color={theme.textSecondary}>
{formatPercent(latest_trade.turnover_rate)}
</StatNumber>
</Stat>
<Stat>
<StatLabel color={theme.textMuted}></StatLabel>
<StatNumber color={theme.textSecondary}>
{latest_trade.pe_ratio || '-'}
</StatNumber>
</Stat>
</>
)}
</SimpleGrid>
{/* 融资融券和质押指标 */}
{latest_funding && (
<SimpleGrid columns={{ base: 2, md: 3 }} spacing={4} mt={4}>
<Stat>
<StatLabel color={theme.textMuted}></StatLabel>
<StatNumber color={theme.success} fontSize="lg">
{formatNumber(latest_funding.financing_balance)}
</StatNumber>
</Stat>
<Stat>
<StatLabel color={theme.textMuted}></StatLabel>
<StatNumber color={theme.danger} fontSize="lg">
{formatNumber(latest_funding.securities_balance)}
</StatNumber>
</Stat>
{latest_pledge && (
<Stat>
<StatLabel color={theme.textMuted}></StatLabel>
<StatNumber color={theme.warning} fontSize="lg">
{formatPercent(latest_pledge.pledge_ratio)}
</StatNumber>
</Stat>
)}
</SimpleGrid>
)}
</GridItem>
</Grid>
</CardBody>
</ThemedCard>
);
};
export default StockSummaryCard;

View File

@@ -0,0 +1,32 @@
// src/views/Company/components/MarketDataView/components/ThemedCard.tsx
// 主题化卡片组件
import React from 'react';
import { Card } from '@chakra-ui/react';
import type { ThemedCardProps } from '../types';
/**
* 主题化卡片组件
* 提供统一的卡片样式和悬停效果
*/
const ThemedCard: React.FC<ThemedCardProps> = ({ children, theme, ...props }) => {
return (
<Card
bg={theme.bgCard}
border="1px solid"
borderColor={theme.border}
borderRadius="xl"
boxShadow="lg"
transition="all 0.3s ease"
_hover={{
transform: 'translateY(-2px)',
boxShadow: 'xl',
}}
{...props}
>
{children}
</Card>
);
};
export default ThemedCard;

View File

@@ -0,0 +1,7 @@
// src/views/Company/components/MarketDataView/components/index.ts
// 组件导出索引
export { default as ThemedCard } from './ThemedCard';
export { default as MarkdownRenderer } from './MarkdownRenderer';
export { default as StockSummaryCard } from './StockSummaryCard';
export { default as AnalysisModal, AnalysisContent } from './AnalysisModal';

View File

@@ -0,0 +1,49 @@
// src/views/Company/components/MarketDataView/constants.ts
// MarketDataView 常量配置
import type { Theme } from './types';
/**
* 主题配置
*/
export const themes: Record<'light', Theme> = {
light: {
// 日间模式 - 白+蓝
primary: '#2B6CB0',
primaryDark: '#1E4E8C',
secondary: '#FFFFFF',
secondaryDark: '#F7FAFC',
success: '#FF4444', // 涨 - 红色
danger: '#00C851', // 跌 - 绿色
warning: '#FF9800',
info: '#00BCD4',
bgMain: '#F7FAFC',
bgCard: '#FFFFFF',
bgDark: '#EDF2F7',
textPrimary: '#2D3748',
textSecondary: '#4A5568',
textMuted: '#718096',
border: '#CBD5E0',
chartBg: '#FFFFFF',
},
};
/**
* 默认股票代码
*/
export const DEFAULT_STOCK_CODE = '600000';
/**
* 默认时间周期(天)
*/
export const DEFAULT_PERIOD = 60;
/**
* 时间周期选项
*/
export const PERIOD_OPTIONS = [
{ value: 30, label: '30天' },
{ value: 60, label: '60天' },
{ value: 120, label: '120天' },
{ value: 250, label: '250天' },
];

View File

@@ -0,0 +1,193 @@
// src/views/Company/components/MarketDataView/hooks/useMarketData.ts
// MarketDataView 数据获取 Hook
import { useState, useEffect, useCallback } from 'react';
import { logger } from '@utils/logger';
import { marketService } from '../services/marketService';
import { DEFAULT_PERIOD } from '../constants';
import type {
MarketSummary,
TradeDayData,
FundingDayData,
BigDealData,
UnusualData,
PledgeData,
RiseAnalysis,
MinuteData,
UseMarketDataReturn,
} from '../types';
/**
* 市场数据获取 Hook
* @param stockCode 股票代码
* @param period 时间周期(天数)
*/
export const useMarketData = (
stockCode: string,
period: number = DEFAULT_PERIOD
): UseMarketDataReturn => {
// 主数据状态
const [loading, setLoading] = useState(false);
const [summary, setSummary] = useState<MarketSummary | null>(null);
const [tradeData, setTradeData] = useState<TradeDayData[]>([]);
const [fundingData, setFundingData] = useState<FundingDayData[]>([]);
const [bigDealData, setBigDealData] = useState<BigDealData>({ data: [], daily_stats: [] });
const [unusualData, setUnusualData] = useState<UnusualData>({ data: [], grouped_data: [] });
const [pledgeData, setPledgeData] = useState<PledgeData[]>([]);
const [analysisMap, setAnalysisMap] = useState<Record<number, RiseAnalysis>>({});
// 分钟数据状态
const [minuteData, setMinuteData] = useState<MinuteData | null>(null);
const [minuteLoading, setMinuteLoading] = useState(false);
/**
* 加载所有市场数据
*/
const loadMarketData = useCallback(async () => {
if (!stockCode) return;
logger.debug('useMarketData', '开始加载市场数据', { stockCode, period });
setLoading(true);
try {
const [
summaryRes,
tradeRes,
fundingRes,
bigDealRes,
unusualRes,
pledgeRes,
riseAnalysisRes,
] = await Promise.all([
marketService.getMarketSummary(stockCode),
marketService.getTradeData(stockCode, period),
marketService.getFundingData(stockCode, 30),
marketService.getBigDealData(stockCode, 30),
marketService.getUnusualData(stockCode, 30),
marketService.getPledgeData(stockCode),
marketService.getRiseAnalysis(stockCode),
]);
// 设置概览数据
if (summaryRes.success) {
setSummary(summaryRes.data);
}
// 设置交易数据
if (tradeRes.success) {
setTradeData(tradeRes.data);
}
// 设置融资融券数据
if (fundingRes.success) {
setFundingData(fundingRes.data);
}
// 设置大宗交易数据(包含 daily_stats
if (bigDealRes.success) {
setBigDealData(bigDealRes);
}
// 设置龙虎榜数据(包含 grouped_data
if (unusualRes.success) {
setUnusualData(unusualRes);
}
// 设置股权质押数据
if (pledgeRes.success) {
setPledgeData(pledgeRes.data);
}
// 设置涨幅分析数据并创建映射
if (riseAnalysisRes.success) {
const tempAnalysisMap: Record<number, RiseAnalysis> = {};
if (tradeRes.success && tradeRes.data && riseAnalysisRes.data) {
riseAnalysisRes.data.forEach((analysis) => {
const dateIndex = tradeRes.data.findIndex(
(item) => item.date.substring(0, 10) === analysis.trade_date
);
if (dateIndex !== -1) {
tempAnalysisMap[dateIndex] = analysis;
}
});
}
setAnalysisMap(tempAnalysisMap);
}
logger.info('useMarketData', '市场数据加载成功', { stockCode });
} catch (error) {
logger.error('useMarketData', 'loadMarketData', error, { stockCode, period });
} finally {
setLoading(false);
}
}, [stockCode, period]);
/**
* 加载分钟K线数据
*/
const loadMinuteData = useCallback(async () => {
if (!stockCode) return;
logger.debug('useMarketData', '开始加载分钟频数据', { stockCode });
setMinuteLoading(true);
try {
const data = await marketService.getMinuteData(stockCode);
setMinuteData(data);
if (data.data && data.data.length > 0) {
logger.info('useMarketData', '分钟频数据加载成功', {
stockCode,
dataPoints: data.data.length,
});
} else {
logger.warn('useMarketData', '分钟频数据为空', { stockCode });
}
} catch (error) {
logger.error('useMarketData', 'loadMinuteData', error, { stockCode });
setMinuteData({
data: [],
code: stockCode,
name: '',
trade_date: '',
type: 'minute',
});
} finally {
setMinuteLoading(false);
}
}, [stockCode]);
/**
* 刷新所有数据
*/
const refetch = useCallback(async () => {
await Promise.all([loadMarketData(), loadMinuteData()]);
}, [loadMarketData, loadMinuteData]);
// 监听股票代码和周期变化,自动加载数据
useEffect(() => {
if (stockCode) {
loadMarketData();
loadMinuteData();
}
}, [stockCode, period, loadMarketData, loadMinuteData]);
return {
loading,
summary,
tradeData,
fundingData,
bigDealData,
unusualData,
pledgeData,
minuteData,
minuteLoading,
analysisMap,
refetch,
loadMinuteData,
};
};
export default useMarketData;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
// src/views/Company/components/MarketDataView/services/marketService.ts
// MarketDataView API 服务层
import { getApiBase } from '@utils/apiConfig';
import { logger } from '@utils/logger';
import type {
MarketSummary,
TradeDayData,
FundingDayData,
BigDealData,
UnusualData,
PledgeData,
RiseAnalysis,
MinuteData,
} from '../types';
/**
* API 响应包装类型
*/
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
/**
* API 基础 URL
*/
const getBaseUrl = (): string => getApiBase();
/**
* 通用 API 请求函数
*/
const apiRequest = async <T>(url: string): Promise<ApiResponse<T>> => {
try {
const response = await fetch(`${getBaseUrl()}${url}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
} catch (error) {
logger.error('marketService', 'apiRequest', error, { url });
throw error;
}
};
/**
* 市场数据服务
*/
export const marketService = {
/**
* 获取市场概览数据
* @param stockCode 股票代码
*/
async getMarketSummary(stockCode: string): Promise<ApiResponse<MarketSummary>> {
return apiRequest<MarketSummary>(`/api/market/summary/${stockCode}`);
},
/**
* 获取交易日数据
* @param stockCode 股票代码
* @param days 天数,默认 60 天
*/
async getTradeData(stockCode: string, days: number = 60): Promise<ApiResponse<TradeDayData[]>> {
return apiRequest<TradeDayData[]>(`/api/market/trade/${stockCode}?days=${days}`);
},
/**
* 获取融资融券数据
* @param stockCode 股票代码
* @param days 天数,默认 30 天
*/
async getFundingData(stockCode: string, days: number = 30): Promise<ApiResponse<FundingDayData[]>> {
return apiRequest<FundingDayData[]>(`/api/market/funding/${stockCode}?days=${days}`);
},
/**
* 获取大宗交易数据
* @param stockCode 股票代码
* @param days 天数,默认 30 天
*/
async getBigDealData(stockCode: string, days: number = 30): Promise<BigDealData> {
const response = await fetch(`${getBaseUrl()}/api/market/bigdeal/${stockCode}?days=${days}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
},
/**
* 获取龙虎榜数据
* @param stockCode 股票代码
* @param days 天数,默认 30 天
*/
async getUnusualData(stockCode: string, days: number = 30): Promise<UnusualData> {
const response = await fetch(`${getBaseUrl()}/api/market/unusual/${stockCode}?days=${days}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
},
/**
* 获取股权质押数据
* @param stockCode 股票代码
*/
async getPledgeData(stockCode: string): Promise<ApiResponse<PledgeData[]>> {
return apiRequest<PledgeData[]>(`/api/market/pledge/${stockCode}`);
},
/**
* 获取涨幅分析数据
* @param stockCode 股票代码
* @param startDate 开始日期(可选)
* @param endDate 结束日期(可选)
*/
async getRiseAnalysis(
stockCode: string,
startDate?: string,
endDate?: string
): Promise<ApiResponse<RiseAnalysis[]>> {
let url = `/api/market/rise-analysis/${stockCode}`;
if (startDate && endDate) {
url += `?start_date=${startDate}&end_date=${endDate}`;
}
return apiRequest<RiseAnalysis[]>(url);
},
/**
* 获取分钟K线数据
* @param stockCode 股票代码
*/
async getMinuteData(stockCode: string): Promise<MinuteData> {
try {
const response = await fetch(`${getBaseUrl()}/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)) {
return data;
}
// 返回空数据结构
return {
data: [],
code: stockCode,
name: '',
trade_date: '',
type: 'minute',
};
} catch (error) {
logger.error('marketService', 'getMinuteData', error, { stockCode });
// 返回空数据结构
return {
data: [],
code: stockCode,
name: '',
trade_date: '',
type: 'minute',
};
}
},
};
export default marketService;

View File

@@ -0,0 +1,383 @@
// src/views/Company/components/MarketDataView/types.ts
// MarketDataView 组件类型定义
import type { ReactNode } from 'react';
/**
* 主题配置类型
*/
export interface Theme {
primary: string;
primaryDark: string;
secondary: string;
secondaryDark: string;
success: string; // 涨色 - 红色
danger: string; // 跌色 - 绿色
warning: string;
info: string;
bgMain: string;
bgCard: string;
bgDark: string;
textPrimary: string;
textSecondary: string;
textMuted: string;
border: string;
chartBg: string;
}
/**
* 交易日数据
*/
export interface TradeDayData {
date: string;
open: number;
close: number;
high: number;
low: number;
volume: number;
amount: number;
change_percent: number;
turnover_rate?: number;
pe_ratio?: number;
}
/**
* 分钟K线数据点
*/
export interface MinuteDataPoint {
time: string;
open: number;
close: number;
high: number;
low: number;
volume: number;
amount: number;
}
/**
* 分钟K线数据
*/
export interface MinuteData {
data: MinuteDataPoint[];
code: string;
name: string;
trade_date: string;
type: string;
}
/**
* 融资数据
*/
export interface FinancingInfo {
balance: number;
buy: number;
repay: number;
}
/**
* 融券数据
*/
export interface SecuritiesInfo {
balance: number;
balance_amount: number;
sell: number;
repay: number;
}
/**
* 融资融券日数据
*/
export interface FundingDayData {
date: string;
financing: FinancingInfo;
securities: SecuritiesInfo;
}
/**
* 大宗交易明细
*/
export interface BigDealItem {
buyer_dept?: string;
seller_dept?: string;
price?: number;
volume?: number;
amount?: number;
}
/**
* 大宗交易日统计
*/
export interface BigDealDayStats {
date: string;
count: number;
total_volume: number;
total_amount: number;
avg_price?: number;
deals?: BigDealItem[];
}
/**
* 大宗交易数据
*/
export interface BigDealData {
success?: boolean;
data: BigDealItem[];
daily_stats: BigDealDayStats[];
}
/**
* 龙虎榜买卖方
*/
export interface UnusualTrader {
dept_name: string;
buy_amount?: number;
sell_amount?: number;
}
/**
* 龙虎榜日数据
*/
export interface UnusualDayData {
date: string;
total_buy: number;
total_sell: number;
net_amount: number;
buyers?: UnusualTrader[];
sellers?: UnusualTrader[];
info_types?: string[];
}
/**
* 龙虎榜数据
*/
export interface UnusualData {
success?: boolean;
data: unknown[];
grouped_data: UnusualDayData[];
}
/**
* 股权质押数据
*/
export interface PledgeData {
end_date: string;
unrestricted_pledge: number;
restricted_pledge: number;
total_pledge: number;
total_shares: number;
pledge_ratio: number;
pledge_count: number;
}
/**
* 最新交易数据
*/
export interface LatestTrade {
close: number;
change_percent: number;
volume: number;
amount: number;
turnover_rate: number;
pe_ratio?: number;
}
/**
* 最新融资融券数据
*/
export interface LatestFunding {
financing_balance: number;
securities_balance: number;
}
/**
* 最新质押数据
*/
export interface LatestPledge {
pledge_ratio: number;
}
/**
* 市场概览数据
*/
export interface MarketSummary {
stock_code: string;
stock_name: string;
latest_trade?: LatestTrade;
latest_funding?: LatestFunding;
latest_pledge?: LatestPledge;
}
/**
* 涨幅分析研报
*/
export interface VerificationReport {
publisher?: string;
match_score?: string;
match_ratio?: number;
declare_date?: string;
report_title?: string;
author?: string;
verification_item?: string;
content?: string;
}
/**
* 涨幅分析数据
*/
export interface RiseAnalysis {
stock_code: string;
stock_name: string;
trade_date: string;
rise_rate: number;
close_price: number;
volume: number;
amount: number;
main_business?: string;
rise_reason_brief?: string;
rise_reason_detail?: string;
announcements?: string;
verification_reports?: VerificationReport[];
update_time?: string;
create_time?: string;
}
/**
* MarketDataView 组件 Props
*/
export interface MarketDataViewProps {
stockCode?: string;
}
/**
* ThemedCard 组件 Props
*/
export interface ThemedCardProps {
children: ReactNode;
theme: Theme;
[key: string]: unknown;
}
/**
* MarkdownRenderer 组件 Props
*/
export interface MarkdownRendererProps {
children: string;
theme: Theme;
}
/**
* StockSummaryCard 组件 Props
*/
export interface StockSummaryCardProps {
summary: MarketSummary;
theme: Theme;
}
/**
* TradeDataTab 组件 Props
*/
export interface TradeDataTabProps {
theme: Theme;
tradeData: TradeDayData[];
minuteData: MinuteData | null;
minuteLoading: boolean;
analysisMap: Record<number, RiseAnalysis>;
onLoadMinuteData: () => void;
onAnalysisClick: (analysis: RiseAnalysis) => void;
}
/**
* KLineChart 组件 Props
*/
export interface KLineChartProps {
theme: Theme;
tradeData: TradeDayData[];
analysisMap: Record<number, RiseAnalysis>;
onAnalysisClick: (analysis: RiseAnalysis) => void;
}
/**
* MinuteKLineChart 组件 Props
*/
export interface MinuteKLineChartProps {
theme: Theme;
minuteData: MinuteData | null;
loading: boolean;
onRefresh: () => void;
}
/**
* TradeTable 组件 Props
*/
export interface TradeTableProps {
theme: Theme;
tradeData: TradeDayData[];
}
/**
* FundingTab 组件 Props
*/
export interface FundingTabProps {
theme: Theme;
fundingData: FundingDayData[];
}
/**
* BigDealTab 组件 Props
*/
export interface BigDealTabProps {
theme: Theme;
bigDealData: BigDealData;
}
/**
* UnusualTab 组件 Props
*/
export interface UnusualTabProps {
theme: Theme;
unusualData: UnusualData;
}
/**
* PledgeTab 组件 Props
*/
export interface PledgeTabProps {
theme: Theme;
pledgeData: PledgeData[];
}
/**
* AnalysisModal 组件 Props
*/
export interface AnalysisModalProps {
isOpen: boolean;
onClose: () => void;
content: ReactNode;
theme: Theme;
}
/**
* AnalysisModalContent 组件 Props
*/
export interface AnalysisModalContentProps {
analysis: RiseAnalysis;
theme: Theme;
}
/**
* useMarketData Hook 返回值
*/
export interface UseMarketDataReturn {
loading: boolean;
summary: MarketSummary | null;
tradeData: TradeDayData[];
fundingData: FundingDayData[];
bigDealData: BigDealData;
unusualData: UnusualData;
pledgeData: PledgeData[];
minuteData: MinuteData | null;
minuteLoading: boolean;
analysisMap: Record<number, RiseAnalysis>;
refetch: () => Promise<void>;
loadMinuteData: () => Promise<void>;
}

View File

@@ -0,0 +1,698 @@
// src/views/Company/components/MarketDataView/utils/chartOptions.ts
// MarketDataView ECharts 图表配置生成器
import type { EChartsOption } from 'echarts';
import type {
Theme,
TradeDayData,
MinuteData,
FundingDayData,
PledgeData,
RiseAnalysis,
} from '../types';
import { formatNumber } from './formatUtils';
/**
* 计算移动平均线
* @param data 收盘价数组
* @param period 周期
*/
export const calculateMA = (data: number[], period: number): (number | null)[] => {
const result: (number | null)[] = [];
for (let i = 0; i < data.length; i++) {
if (i < period - 1) {
result.push(null);
continue;
}
let sum = 0;
for (let j = 0; j < period; j++) {
sum += data[i - j];
}
result.push(sum / period);
}
return result;
};
/**
* 生成日K线图配置
*/
export const getKLineOption = (
theme: Theme,
tradeData: TradeDayData[],
analysisMap: Record<number, RiseAnalysis>
): EChartsOption => {
if (!tradeData || tradeData.length === 0) return {};
const dates = tradeData.map((item) => item.date.substring(5, 10));
const kData = tradeData.map((item) => [item.open, item.close, item.low, item.high]);
const volumes = tradeData.map((item) => item.volume);
const closePrices = tradeData.map((item) => item.close);
const ma5 = calculateMA(closePrices, 5);
const ma10 = calculateMA(closePrices, 10);
const ma20 = calculateMA(closePrices, 20);
// 创建涨幅分析标记点
const scatterData: [number, number][] = [];
Object.keys(analysisMap).forEach((dateIndex) => {
const idx = parseInt(dateIndex);
if (tradeData[idx]) {
const value = tradeData[idx].high * 1.02;
scatterData.push([idx, value]);
}
});
return {
backgroundColor: theme.chartBg,
animation: true,
legend: {
data: ['K线', 'MA5', 'MA10', 'MA20'],
top: 10,
textStyle: {
color: theme.textPrimary,
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
lineStyle: {
color: theme.primary,
width: 1,
opacity: 0.8,
},
},
backgroundColor: 'rgba(255,255,255,0.9)',
borderColor: theme.primary,
borderWidth: 1,
textStyle: {
color: theme.textPrimary,
},
},
xAxis: [
{
type: 'category',
data: dates,
boundaryGap: false,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
{
type: 'category',
gridIndex: 1,
data: dates,
boundaryGap: false,
axisLine: { onZero: false, lineStyle: { color: theme.textMuted } },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
},
],
yAxis: [
{
scale: true,
splitLine: {
show: true,
lineStyle: {
color: theme.border,
},
},
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
{
scale: true,
gridIndex: 1,
splitNumber: 2,
axisLabel: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
},
],
grid: [
{
left: '10%',
right: '10%',
height: '50%',
},
{
left: '10%',
right: '10%',
top: '65%',
height: '20%',
},
],
series: [
{
name: 'K线',
type: 'candlestick',
data: kData,
itemStyle: {
color: theme.success,
color0: theme.danger,
borderColor: theme.success,
borderColor0: theme.danger,
},
},
{
name: 'MA5',
type: 'line',
data: ma5,
smooth: true,
lineStyle: {
color: theme.primary,
width: 1,
},
itemStyle: {
color: theme.primary,
},
},
{
name: 'MA10',
type: 'line',
data: ma10,
smooth: true,
lineStyle: {
color: theme.info,
width: 1,
},
itemStyle: {
color: theme.info,
},
},
{
name: 'MA20',
type: 'line',
data: ma20,
smooth: true,
lineStyle: {
color: theme.warning,
width: 1,
},
itemStyle: {
color: theme.warning,
},
},
{
name: '涨幅分析',
type: 'scatter',
data: scatterData,
symbolSize: 30,
symbol: 'pin',
itemStyle: {
color: '#FFD700',
shadowBlur: 10,
shadowColor: 'rgba(255, 215, 0, 0.5)',
},
label: {
show: true,
formatter: '★',
fontSize: 20,
position: 'inside',
color: '#FF6B6B',
},
emphasis: {
scale: 1.5,
itemStyle: {
color: '#FFA500',
},
},
z: 100,
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes,
itemStyle: {
color: (params: { dataIndex: number }) => {
const item = tradeData[params.dataIndex];
return item.change_percent >= 0
? 'rgba(255, 68, 68, 0.6)'
: 'rgba(0, 200, 81, 0.6)';
},
},
},
],
};
};
/**
* 生成分钟K线图配置
*/
export const getMinuteKLineOption = (theme: Theme, minuteData: MinuteData | null): EChartsOption => {
if (!minuteData || !minuteData.data || minuteData.data.length === 0) return {};
const times = minuteData.data.map((item) => item.time);
const kData = minuteData.data.map((item) => [item.open, item.close, item.low, item.high]);
const volumes = minuteData.data.map((item) => item.volume);
const closePrices = minuteData.data.map((item) => item.close);
const avgPrice = calculateMA(closePrices, 5);
const openPrice = minuteData.data.length > 0 ? minuteData.data[0].open : 0;
return {
backgroundColor: theme.chartBg,
title: {
text: `${minuteData.name} 分钟K线 (${minuteData.trade_date})`,
left: 'center',
textStyle: {
color: theme.textPrimary,
fontSize: 16,
fontWeight: 'bold',
},
subtextStyle: {
color: theme.textMuted,
},
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: theme.primary,
borderWidth: 1,
textStyle: {
color: theme.textPrimary,
fontSize: 12,
},
formatter: (params: unknown) => {
const paramsArr = params as { name: string; marker: string; seriesName: string; data: number[] | number; value: number }[];
let result = paramsArr[0].name + '<br/>';
paramsArr.forEach((param) => {
if (param.seriesName === '分钟K线') {
const [open, close, , high] = param.data as number[];
const low = (param.data as number[])[2];
const changePercent =
openPrice > 0 ? (((close - openPrice) / openPrice) * 100).toFixed(2) : '0.00';
result += `${param.marker} ${param.seriesName}<br/>`;
result += `开盘: <span style="font-weight: bold">${open.toFixed(2)}</span><br/>`;
result += `收盘: <span style="font-weight: bold; color: ${close >= open ? theme.success : theme.danger}">${close.toFixed(2)}</span><br/>`;
result += `最高: <span style="font-weight: bold">${high.toFixed(2)}</span><br/>`;
result += `最低: <span style="font-weight: bold">${low.toFixed(2)}</span><br/>`;
result += `涨跌: <span style="font-weight: bold; color: ${close >= openPrice ? theme.success : theme.danger}">${changePercent}%</span><br/>`;
} else if (param.seriesName === '均价线') {
result += `${param.marker} ${param.seriesName}: <span style="font-weight: bold">${(param.value as number).toFixed(2)}</span><br/>`;
} else if (param.seriesName === '成交量') {
result += `${param.marker} ${param.seriesName}: <span style="font-weight: bold">${formatNumber(param.value as number, 0)}</span><br/>`;
}
});
return result;
},
},
legend: {
data: ['分钟K线', '均价线', '成交量'],
top: 35,
textStyle: {
color: theme.textPrimary,
fontSize: 12,
},
itemWidth: 25,
itemHeight: 14,
},
grid: [
{
left: '8%',
right: '8%',
top: '20%',
height: '60%',
},
{
left: '8%',
right: '8%',
top: '83%',
height: '12%',
},
],
xAxis: [
{
type: 'category',
data: times,
boundaryGap: false,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: {
color: theme.textMuted,
fontSize: 10,
interval: 'auto',
},
splitLine: { show: false },
},
{
type: 'category',
gridIndex: 1,
data: times,
boundaryGap: false,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: {
color: theme.textMuted,
fontSize: 10,
},
splitLine: { show: false },
},
],
yAxis: [
{
scale: true,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted, fontSize: 10 },
splitLine: {
lineStyle: {
color: theme.border,
type: 'dashed',
},
},
},
{
gridIndex: 1,
scale: true,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted, fontSize: 10 },
splitLine: { show: false },
},
],
dataZoom: [
{
type: 'inside',
xAxisIndex: [0, 1],
start: 70,
end: 100,
minValueSpan: 20,
},
{
show: true,
xAxisIndex: [0, 1],
type: 'slider',
top: '95%',
start: 70,
end: 100,
height: 20,
handleSize: '100%',
handleStyle: {
color: theme.primary,
},
textStyle: {
color: theme.textMuted,
},
},
],
series: [
{
name: '分钟K线',
type: 'candlestick',
data: kData,
itemStyle: {
color: theme.success,
color0: theme.danger,
borderColor: theme.success,
borderColor0: theme.danger,
borderWidth: 1,
},
barWidth: '60%',
},
{
name: '均价线',
type: 'line',
data: avgPrice,
smooth: true,
symbol: 'none',
lineStyle: {
color: theme.info,
width: 2,
opacity: 0.8,
},
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes,
barWidth: '50%',
itemStyle: {
color: (params: { dataIndex: number }) => {
const item = minuteData.data[params.dataIndex];
return item.close >= item.open
? 'rgba(255, 68, 68, 0.6)'
: 'rgba(0, 200, 81, 0.6)';
},
},
},
],
};
};
/**
* 生成融资融券图表配置
*/
export const getFundingOption = (theme: Theme, fundingData: FundingDayData[]): EChartsOption => {
if (!fundingData || fundingData.length === 0) return {};
const dates = fundingData.map((item) => item.date.substring(5, 10));
const financing = fundingData.map((item) => item.financing.balance / 100000000);
const securities = fundingData.map((item) => item.securities.balance_amount / 100000000);
return {
backgroundColor: theme.chartBg,
title: {
text: '融资融券余额走势',
left: 'center',
textStyle: {
color: theme.textPrimary,
fontSize: 16,
},
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.9)',
borderColor: theme.primary,
borderWidth: 1,
textStyle: {
color: theme.textPrimary,
},
formatter: (params: unknown) => {
const paramsArr = params as { name: string; marker: string; seriesName: string; value: number }[];
let result = paramsArr[0].name + '<br/>';
paramsArr.forEach((param) => {
result += `${param.marker} ${param.seriesName}: ${param.value.toFixed(2)}亿<br/>`;
});
return result;
},
},
legend: {
data: ['融资余额', '融券余额'],
bottom: 10,
textStyle: {
color: theme.textPrimary,
},
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
yAxis: {
type: 'value',
name: '金额(亿)',
nameTextStyle: { color: theme.textMuted },
splitLine: {
lineStyle: {
color: theme.border,
},
},
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
series: [
{
name: '融资余额',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(255, 68, 68, 0.3)' },
{ offset: 1, color: 'rgba(255, 68, 68, 0.05)' },
],
},
},
lineStyle: {
color: theme.success,
width: 2,
},
itemStyle: {
color: theme.success,
borderColor: theme.success,
borderWidth: 2,
},
data: financing,
},
{
name: '融券余额',
type: 'line',
smooth: true,
symbol: 'diamond',
symbolSize: 8,
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(0, 200, 81, 0.3)' },
{ offset: 1, color: 'rgba(0, 200, 81, 0.05)' },
],
},
},
lineStyle: {
color: theme.danger,
width: 2,
},
itemStyle: {
color: theme.danger,
borderColor: theme.danger,
borderWidth: 2,
},
data: securities,
},
],
};
};
/**
* 生成股权质押图表配置
*/
export const getPledgeOption = (theme: Theme, pledgeData: PledgeData[]): EChartsOption => {
if (!pledgeData || pledgeData.length === 0) return {};
const dates = pledgeData.map((item) => item.end_date.substring(5, 10));
const ratios = pledgeData.map((item) => item.pledge_ratio);
const counts = pledgeData.map((item) => item.pledge_count);
return {
backgroundColor: theme.chartBg,
title: {
text: '股权质押趋势',
left: 'center',
textStyle: {
color: theme.textPrimary,
fontSize: 16,
},
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.9)',
borderColor: theme.primary,
borderWidth: 1,
textStyle: {
color: theme.textPrimary,
},
},
legend: {
data: ['质押比例', '质押笔数'],
bottom: 10,
textStyle: {
color: theme.textPrimary,
},
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
containLabel: true,
},
xAxis: {
type: 'category',
data: dates,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
yAxis: [
{
type: 'value',
name: '质押比例(%)',
nameTextStyle: { color: theme.textMuted },
splitLine: {
lineStyle: {
color: theme.border,
},
},
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
{
type: 'value',
name: '质押笔数',
nameTextStyle: { color: theme.textMuted },
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
],
series: [
{
name: '质押比例',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
lineStyle: {
color: theme.warning,
width: 2,
shadowBlur: 10,
shadowColor: theme.warning,
},
itemStyle: {
color: theme.warning,
borderColor: theme.bgCard,
borderWidth: 2,
},
data: ratios,
},
{
name: '质押笔数',
type: 'bar',
yAxisIndex: 1,
barWidth: '50%',
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: theme.primary },
{ offset: 1, color: theme.primaryDark },
],
},
borderRadius: [5, 5, 0, 0],
},
data: counts,
},
],
};
};
export default {
calculateMA,
getKLineOption,
getMinuteKLineOption,
getFundingOption,
getPledgeOption,
};

View File

@@ -0,0 +1,175 @@
// src/views/Company/components/MarketDataView/utils/formatUtils.ts
// MarketDataView 格式化工具函数
/**
* 格式化数字(自动转换为万/亿)
* @param value 数值
* @param decimals 小数位数,默认 2
* @returns 格式化后的字符串
*/
export const formatNumber = (value: number | null | undefined, decimals: number = 2): string => {
if (value === null || value === undefined) return '-';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num)) return '-';
if (Math.abs(num) >= 100000000) {
return (num / 100000000).toFixed(decimals) + '亿';
} else if (Math.abs(num) >= 10000) {
return (num / 10000).toFixed(decimals) + '万';
}
return num.toFixed(decimals);
};
/**
* 格式化百分比
* @param value 数值(已经是百分比形式,如 3.5 表示 3.5%
* @param decimals 小数位数,默认 2
* @returns 格式化后的字符串
*/
export const formatPercent = (value: number | null | undefined, decimals: number = 2): string => {
if (value === null || value === undefined) return '-';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num)) return '-';
return num.toFixed(decimals) + '%';
};
/**
* 格式化日期(取前 10 位)
* @param dateStr 日期字符串
* @returns 格式化后的日期YYYY-MM-DD
*/
export const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return '-';
return dateStr.substring(0, 10);
};
/**
* 格式化价格
* @param value 价格数值
* @param decimals 小数位数,默认 2
* @returns 格式化后的价格字符串
*/
export const formatPrice = (value: number | null | undefined, decimals: number = 2): string => {
if (value === null || value === undefined) return '-';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num)) return '-';
return num.toFixed(decimals);
};
/**
* 格式化成交量(带单位)
* @param value 成交量数值
* @returns 格式化后的成交量字符串
*/
export const formatVolume = (value: number | null | undefined): string => {
if (value === null || value === undefined) return '-';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num)) return '-';
if (num >= 100000000) {
return (num / 100000000).toFixed(2) + '亿股';
} else if (num >= 10000) {
return (num / 10000).toFixed(2) + '万股';
}
return num.toFixed(0) + '股';
};
/**
* 格式化金额(带单位)
* @param value 金额数值
* @returns 格式化后的金额字符串
*/
export const formatAmount = (value: number | null | undefined): string => {
if (value === null || value === undefined) return '-';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num)) return '-';
if (Math.abs(num) >= 100000000) {
return (num / 100000000).toFixed(2) + '亿';
} else if (Math.abs(num) >= 10000) {
return (num / 10000).toFixed(2) + '万';
}
return num.toFixed(2) + '元';
};
/**
* 格式化涨跌幅(带符号和颜色提示)
* @param value 涨跌幅数值
* @returns 带符号的涨跌幅字符串
*/
export const formatChange = (value: number | null | undefined): string => {
if (value === null || value === undefined) return '-';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num)) return '-';
const sign = num > 0 ? '+' : '';
return sign + num.toFixed(2) + '%';
};
/**
* 获取涨跌颜色类型
* @param value 涨跌幅数值
* @returns 'up' | 'down' | 'neutral'
*/
export const getChangeType = (value: number | null | undefined): 'up' | 'down' | 'neutral' => {
if (value === null || value === undefined) return 'neutral';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num) || num === 0) return 'neutral';
return num > 0 ? 'up' : 'down';
};
/**
* 格式化短日期MM-DD
* @param dateStr 日期字符串
* @returns 格式化后的短日期
*/
export const formatShortDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return '-';
return dateStr.substring(5, 10);
};
/**
* 格式化时间HH:mm
* @param timeStr 时间字符串
* @returns 格式化后的时间
*/
export const formatTime = (timeStr: string | null | undefined): string => {
if (!timeStr) return '-';
// 支持多种格式
if (timeStr.includes(':')) {
return timeStr.substring(0, 5);
}
// 如果是 HHmm 格式
if (timeStr.length >= 4) {
return timeStr.substring(0, 2) + ':' + timeStr.substring(2, 4);
}
return timeStr;
};
/**
* 工具函数集合(兼容旧代码)
*/
export const formatUtils = {
formatNumber,
formatPercent,
formatDate,
formatPrice,
formatVolume,
formatAmount,
formatChange,
getChangeType,
formatShortDate,
formatTime,
};
export default formatUtils;

View File

@@ -0,0 +1,300 @@
/**
* StockQuoteCard - 股票行情卡片组件
*
* 展示股票的实时行情、关键指标和主力动态
*/
import React from 'react';
import {
Box,
Card,
CardBody,
Flex,
HStack,
VStack,
Text,
Badge,
Progress,
Skeleton,
IconButton,
Tooltip,
} from '@chakra-ui/react';
import { Share2 } from 'lucide-react';
import FavoriteButton from '@components/FavoriteButton';
import type { StockQuoteCardProps } from './types';
/**
* 格式化价格显示
*/
const formatPrice = (price: number): string => {
return price.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
};
/**
* 格式化涨跌幅显示
*/
const formatChangePercent = (percent: number): string => {
const sign = percent >= 0 ? '+' : '';
return `${sign}${percent.toFixed(2)}%`;
};
/**
* 格式化主力净流入显示
*/
const formatNetInflow = (value: number): string => {
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(2)}亿`;
};
const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
data,
isLoading = false,
isInWatchlist = false,
isWatchlistLoading = false,
onWatchlistToggle,
onShare,
}) => {
// 处理分享点击
const handleShare = () => {
onShare?.();
};
// 黑金主题颜色配置
const cardBg = '#1A202C';
const borderColor = '#C9A961';
const labelColor = '#C9A961';
const valueColor = '#F4D03F';
const sectionTitleColor = '#F4D03F';
// 涨跌颜色(红涨绿跌)
const upColor = '#F44336'; // 涨 - 红色
const downColor = '#4CAF50'; // 跌 - 绿色
// 加载中或无数据时显示骨架屏
if (isLoading || !data) {
return (
<Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}>
<CardBody>
<VStack spacing={4} align="stretch">
<Skeleton height="30px" width="200px" />
<Skeleton height="60px" />
<Skeleton height="80px" />
</VStack>
</CardBody>
</Card>
);
}
const priceColor = data.changePercent >= 0 ? upColor : downColor;
const inflowColor = data.mainNetInflow >= 0 ? upColor : downColor;
return (
<Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}>
<CardBody>
{/* 顶部:股票名称 + 关注/分享按钮 + 更新时间 */}
<Flex justify="space-between" align="center" mb={4}>
{/* 左侧:股票名称 + 行业标签 + 指数标签 */}
<HStack spacing={3} align="center">
{/* 股票名称 - 突出显示 */}
<Text fontSize="26px" fontWeight="800" color={valueColor}>
{data.name}
</Text>
<Text fontSize="18px" fontWeight="normal" color={labelColor}>
({data.code})
</Text>
{/* 行业标签 */}
{(data.industryL1 || data.industry) && (
<Badge
bg="transparent"
color={labelColor}
fontSize="14px"
fontWeight="medium"
border="1px solid"
borderColor={borderColor}
px={2}
py={0.5}
borderRadius="md"
>
{data.industryL1 && data.industry
? `${data.industryL1} · ${data.industry}`
: data.industry || data.industryL1}
</Badge>
)}
{/* 指数标签 */}
{data.indexTags?.length > 0 && (
<Text fontSize="14px" color={labelColor}>
{data.indexTags.join('、')}
</Text>
)}
</HStack>
{/* 右侧:关注 + 分享 + 时间 */}
<HStack spacing={3}>
<FavoriteButton
isFavorite={isInWatchlist}
isLoading={isWatchlistLoading}
onClick={onWatchlistToggle || (() => {})}
colorScheme="gold"
size="sm"
/>
<Tooltip label="分享" placement="top">
<IconButton
aria-label="分享"
icon={<Share2 size={18} />}
variant="ghost"
color={labelColor}
size="sm"
onClick={handleShare}
_hover={{ bg: 'whiteAlpha.100' }}
/>
</Tooltip>
<Text fontSize="14px" color={labelColor}>
{data.updateTime?.split(' ')[1] || '--:--'}
</Text>
</HStack>
</Flex>
{/* 三栏布局 */}
<Flex gap={8}>
{/* 左栏:价格信息 */}
<Box flex="1">
<HStack align="baseline" spacing={3} mb={3}>
<Text fontSize="48px" fontWeight="bold" color={priceColor}>
{formatPrice(data.currentPrice)}
</Text>
<Badge
bg={data.changePercent >= 0 ? upColor : downColor}
color="#FFFFFF"
fontSize="20px"
fontWeight="bold"
px={3}
py={1}
borderRadius="md"
>
{formatChangePercent(data.changePercent)}
</Badge>
</HStack>
{/* 次要行情:今开 | 昨收 | 最高 | 最低 */}
<HStack spacing={4} fontSize="14px" flexWrap="wrap">
<Text color={labelColor}>
<Text as="span" color={valueColor} fontWeight="bold">
{formatPrice(data.todayOpen)}
</Text>
</Text>
<Text color={borderColor}>|</Text>
<Text color={labelColor}>
<Text as="span" color={valueColor} fontWeight="bold">
{formatPrice(data.yesterdayClose)}
</Text>
</Text>
<Text color={borderColor}>|</Text>
<Text color={labelColor}>
<Text as="span" color={upColor} fontWeight="bold">
{formatPrice(data.todayHigh)}
</Text>
</Text>
<Text color={borderColor}>|</Text>
<Text color={labelColor}>
<Text as="span" color={downColor} fontWeight="bold">
{formatPrice(data.todayLow)}
</Text>
</Text>
</HStack>
</Box>
{/* 中栏:关键指标 */}
<Box flex="1" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
<Text
fontSize="14px"
fontWeight="bold"
color={sectionTitleColor}
mb={3}
>
</Text>
<VStack align="stretch" spacing={2} fontSize="14px">
<HStack justify="space-between">
<Text color={labelColor}>(PE)</Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.pe.toFixed(2)}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}>(PB)</Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.pb.toFixed(2)}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.marketCap}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}>52</Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{formatPrice(data.week52Low)}-{formatPrice(data.week52High)}
</Text>
</HStack>
</VStack>
</Box>
{/* 右栏:主力动态 */}
<Box flex="1" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
<Text
fontSize="14px"
fontWeight="bold"
color={sectionTitleColor}
mb={3}
>
</Text>
<VStack align="stretch" spacing={2} fontSize="14px">
<HStack justify="space-between">
<Text color={labelColor}></Text>
<Text color={inflowColor} fontWeight="bold" fontSize="16px">
{formatNetInflow(data.mainNetInflow)}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.institutionHolding.toFixed(2)}%
</Text>
</HStack>
{/* 买卖比例条 */}
<Box mt={1}>
<Progress
value={data.buyRatio}
size="sm"
sx={{
'& > div': { bg: upColor },
}}
bg={downColor}
borderRadius="full"
/>
<HStack justify="space-between" mt={1} fontSize="14px">
<Text color={upColor}>{data.buyRatio}%</Text>
<Text color={downColor}>{data.sellRatio}%</Text>
</HStack>
</Box>
</VStack>
</Box>
</Flex>
</CardBody>
</Card>
);
};
export default StockQuoteCard;

View File

@@ -0,0 +1,38 @@
import type { StockQuoteCardData } from './types';
/**
* 贵州茅台 Mock 数据
*/
export const mockStockQuoteData: StockQuoteCardData = {
// 基础信息
name: '贵州茅台',
code: '600519.SH',
indexTags: ['沪深300'],
// 价格信息
currentPrice: 2178.5,
changePercent: 3.65,
todayOpen: 2156.0,
yesterdayClose: 2101.0,
todayHigh: 2185.0,
todayLow: 2150.0,
// 关键指标
pe: 38.62,
pb: 14.82,
marketCap: '2.73万亿',
week52Low: 1980,
week52High: 2350,
// 主力动态
mainNetInflow: 1.28,
institutionHolding: 72.35,
buyRatio: 85,
sellRatio: 15,
// 更新时间
updateTime: '2025-12-03 14:30:25',
// 自选状态
isFavorite: false,
};

View File

@@ -0,0 +1,56 @@
/**
* StockQuoteCard 组件类型定义
*/
/**
* 股票行情卡片数据
*/
export interface StockQuoteCardData {
// 基础信息
name: string; // 股票名称
code: string; // 股票代码
indexTags: string[]; // 指数标签(如 沪深300、上证50
industry?: string; // 所属行业(二级),如 "银行"
industryL1?: string; // 一级行业,如 "金融"
// 价格信息
currentPrice: number; // 当前价格
changePercent: number; // 涨跌幅(百分比,如 3.65 表示 +3.65%
todayOpen: number; // 今开
yesterdayClose: number; // 昨收
todayHigh: number; // 今日最高
todayLow: number; // 今日最低
// 关键指标
pe: number; // 市盈率
pb: number; // 市净率
marketCap: string; // 流通市值(已格式化,如 "2.73万亿"
week52Low: number; // 52周最低
week52High: number; // 52周最高
// 主力动态
mainNetInflow: number; // 主力净流入(亿)
institutionHolding: number; // 机构持仓比例(百分比)
buyRatio: number; // 买入比例(百分比)
sellRatio: number; // 卖出比例(百分比)
// 更新时间
updateTime: string; // 格式YYYY-MM-DD HH:mm:ss
// 自选状态
isFavorite?: boolean; // 是否已加入自选
}
/**
* StockQuoteCard 组件 Props
*/
export interface StockQuoteCardProps {
data?: StockQuoteCardData;
isLoading?: boolean;
// 自选股相关(与 WatchlistButton 接口保持一致)
isInWatchlist?: boolean; // 是否在自选股中
isWatchlistLoading?: boolean; // 自选股操作加载中
onWatchlistToggle?: () => void; // 自选股切换回调
// 分享
onShare?: () => void; // 分享回调
}

View File

@@ -0,0 +1,55 @@
// src/views/Company/constants/index.js
// 公司详情页面常量配置
import { FaChartLine, FaMoneyBillWave, FaChartBar, FaInfoCircle, FaBrain, FaNewspaper } from 'react-icons/fa';
/**
* Tab 配置
* @type {Array<{key: string, name: string, icon: React.ComponentType}>}
*/
export const COMPANY_TABS = [
{ key: 'overview', name: '公司概览', icon: FaInfoCircle },
{ key: 'analysis', name: '深度分析', icon: FaBrain },
{ key: 'market', name: '股票行情', icon: FaChartLine },
{ key: 'financial', name: '财务全景', icon: FaMoneyBillWave },
{ key: 'forecast', name: '盈利预测', icon: FaChartBar },
{ key: 'tracking', name: '动态跟踪', icon: FaNewspaper },
];
/**
* Tab 选中状态样式
*/
export const TAB_SELECTED_STYLE = {
transform: 'scale(1.02)',
transition: 'all 0.2s',
};
/**
* Toast 消息配置
*/
export const TOAST_MESSAGES = {
WATCHLIST_ADD: { title: '已加入自选', status: 'success', duration: 1500 },
WATCHLIST_REMOVE: { title: '已从自选移除', status: 'info', duration: 1500 },
WATCHLIST_ERROR: { title: '操作失败,请稍后重试', status: 'error', duration: 2000 },
INVALID_CODE: { title: '无效的股票代码', status: 'error', duration: 2000 },
LOGIN_REQUIRED: { title: '请先登录后再加入自选', status: 'warning', duration: 2000 },
};
/**
* 默认股票代码
*/
export const DEFAULT_STOCK_CODE = '000001';
/**
* URL 参数名
*/
export const URL_PARAM_NAME = 'scode';
/**
* 根据索引获取 Tab 名称
* @param {number} index - Tab 索引
* @returns {string} Tab 名称
*/
export const getTabNameByIndex = (index) => {
return COMPANY_TABS[index]?.name || 'Unknown';
};

View File

@@ -0,0 +1,91 @@
// src/views/Company/hooks/useCompanyStock.js
// 股票代码管理 Hook - 处理 URL 参数同步和搜索逻辑
import { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { DEFAULT_STOCK_CODE, URL_PARAM_NAME } from '../constants';
/**
* 股票代码管理 Hook
*
* 功能:
* - 管理当前股票代码状态
* - 双向同步 URL 参数
* - 处理搜索输入和提交
*
* @param {Object} options - 配置选项
* @param {string} [options.defaultCode] - 默认股票代码
* @param {string} [options.paramName] - URL 参数名
* @param {Function} [options.onStockChange] - 股票代码变化回调 (newCode, prevCode) => void
* @returns {Object} 股票代码状态和操作方法
*/
export const useCompanyStock = (options = {}) => {
const {
defaultCode = DEFAULT_STOCK_CODE,
paramName = URL_PARAM_NAME,
onStockChange,
} = options;
const [searchParams, setSearchParams] = useSearchParams();
// 从 URL 参数初始化股票代码
const [stockCode, setStockCode] = useState(
searchParams.get(paramName) || defaultCode
);
// 输入框状态(默认为空,不显示默认股票代码)
const [inputCode, setInputCode] = useState('');
/**
* 监听 URL 参数变化,同步到本地状态
* 支持浏览器前进/后退按钮
*/
useEffect(() => {
const urlCode = searchParams.get(paramName);
if (urlCode && urlCode !== stockCode) {
setStockCode(urlCode);
setInputCode(urlCode);
}
}, [searchParams, paramName, stockCode]);
/**
* 执行搜索 - 更新 stockCode 和 URL
* @param {string} [code] - 可选,直接传入股票代码(用于下拉选择)
*/
const handleSearch = useCallback((code) => {
const trimmedCode = code || inputCode?.trim();
if (trimmedCode && trimmedCode !== stockCode) {
// 触发变化回调(用于追踪)
onStockChange?.(trimmedCode, stockCode);
// 更新状态
setStockCode(trimmedCode);
// 更新 URL 参数
setSearchParams({ [paramName]: trimmedCode });
}
}, [inputCode, stockCode, paramName, setSearchParams, onStockChange]);
/**
* 处理键盘事件 - 回车键触发搜索
*/
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter') {
handleSearch();
}
}, [handleSearch]);
return {
// 状态
stockCode, // 当前确认的股票代码
inputCode, // 输入框中的值(未确认)
// 操作方法
setInputCode, // 更新输入框
handleSearch, // 执行搜索
handleKeyDown, // 处理回车键(改用 onKeyDown
};
};
export default useCompanyStock;

View File

@@ -0,0 +1,166 @@
// src/views/Company/hooks/useCompanyWatchlist.js
// 自选股管理 Hook - Company 页面专用,复用 Redux stockSlice
import { useEffect, useCallback, useMemo, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useToast } from '@chakra-ui/react';
import { useAuth } from '@contexts/AuthContext';
import { logger } from '@utils/logger';
import {
loadWatchlist,
toggleWatchlist,
optimisticAddWatchlist,
optimisticRemoveWatchlist
} from '@store/slices/stockSlice';
import { TOAST_MESSAGES } from '../constants';
/**
* Company 页面自选股管理 Hook
*
* 功能:
* - 检查当前股票是否在自选股中
* - 提供添加/移除自选股功能
* - 与 Redux stockSlice 同步
*
* @param {Object} options - 配置选项
* @param {string} options.stockCode - 当前股票代码
* @param {Object} [options.tracking] - 追踪回调
* @param {Function} [options.tracking.onAdd] - 添加自选时的追踪回调
* @param {Function} [options.tracking.onRemove] - 移除自选时的追踪回调
* @returns {Object} 自选股状态和操作方法
*/
export const useCompanyWatchlist = ({ stockCode, tracking = {} } = {}) => {
const dispatch = useDispatch();
const toast = useToast();
const { isAuthenticated } = useAuth();
// 从 Redux 获取自选股列表
const watchlist = useSelector((state) => state.stock.watchlist);
const watchlistLoading = useSelector((state) => state.stock.loading.watchlist);
// 追踪是否已初始化(防止无限循环)
const hasInitializedRef = useRef(false);
/**
* 派生状态:判断当前股票是否在自选股中
* 使用 useMemo 避免重复计算
*/
const isInWatchlist = useMemo(() => {
if (!stockCode || !Array.isArray(watchlist)) {
return false;
}
// 标准化股票代码提取6位数字
const normalize = (code) => String(code || '').match(/(\d{6})/)?.[1] || '';
const targetCode = normalize(stockCode);
return watchlist.some((item) => normalize(item.stock_code) === targetCode);
}, [watchlist, stockCode]);
/**
* 初始化:加载自选股列表
* 使用 hasInitializedRef 防止无限循环(用户可能确实没有自选股)
*/
useEffect(() => {
if (!hasInitializedRef.current && isAuthenticated && !watchlistLoading) {
hasInitializedRef.current = true;
dispatch(loadWatchlist());
}
}, [isAuthenticated, watchlistLoading, dispatch]);
/**
* 切换自选股状态(乐观更新模式)
* 1. 立即更新 UI无 loading
* 2. 后台静默请求 API
* 3. 失败时回滚并提示
*/
const toggle = useCallback(async () => {
// 参数校验
if (!stockCode) {
logger.warn('useCompanyWatchlist', 'toggle', '无效的股票代码', { stockCode });
toast(TOAST_MESSAGES.INVALID_CODE);
return;
}
// 权限校验
if (!isAuthenticated) {
logger.warn('useCompanyWatchlist', 'toggle', '用户未登录', { stockCode });
toast(TOAST_MESSAGES.LOGIN_REQUIRED);
return;
}
// 标准化股票代码用于匹配
const normalize = (code) => String(code || '').match(/(\d{6})/)?.[1] || '';
const targetCode = normalize(stockCode);
// 从 watchlist 中找到原始 stock_code保持与后端数据结构一致
const matchedItem = watchlist.find(
item => normalize(item.stock_code) === targetCode
);
// 移除时使用原始 stock_code添加时使用传入的 stockCode
const codeForApi = isInWatchlist ? (matchedItem?.stock_code || stockCode) : stockCode;
// 保存当前状态用于回滚
const wasInWatchlist = isInWatchlist;
logger.debug('useCompanyWatchlist', '切换自选股(乐观更新)', {
stockCode,
codeForApi,
wasInWatchlist,
action: wasInWatchlist ? 'remove' : 'add',
});
// 1. 乐观更新:立即更新 UI不显示 loading
if (wasInWatchlist) {
dispatch(optimisticRemoveWatchlist({ stockCode: codeForApi }));
} else {
dispatch(optimisticAddWatchlist({ stockCode: codeForApi, stockName: matchedItem?.stock_name || '' }));
}
try {
// 2. 后台静默请求 API
await dispatch(
toggleWatchlist({
stockCode: codeForApi,
stockName: matchedItem?.stock_name || '',
isInWatchlist: wasInWatchlist,
})
).unwrap();
// 3. 成功:触发追踪回调(不显示 toast状态已更新
if (wasInWatchlist) {
tracking.onRemove?.(stockCode);
} else {
tracking.onAdd?.(stockCode);
}
} catch (error) {
// 4. 失败:回滚状态 + 显示错误提示
logger.error('useCompanyWatchlist', 'toggle', error, {
stockCode,
wasInWatchlist,
});
// 回滚操作
if (wasInWatchlist) {
// 之前在自选中,乐观删除了,现在要恢复
dispatch(optimisticAddWatchlist({ stockCode: codeForApi, stockName: matchedItem?.stock_name || '' }));
} else {
// 之前不在自选中,乐观添加了,现在要移除
dispatch(optimisticRemoveWatchlist({ stockCode: codeForApi }));
}
toast(TOAST_MESSAGES.WATCHLIST_ERROR);
}
}, [stockCode, isAuthenticated, isInWatchlist, watchlist, dispatch, toast, tracking]);
return {
// 状态
isInWatchlist, // 是否在自选股中
isLoading: watchlistLoading, // 仅初始加载时显示 loading乐观更新模式
// 操作方法
toggle, // 切换自选状态
};
};
export default useCompanyWatchlist;

View File

@@ -0,0 +1,102 @@
// src/views/Company/hooks/useStockQuote.js
// 股票行情数据获取 Hook
import { useState, useEffect } from 'react';
import { stockService } from '@services/eventService';
import { logger } from '@utils/logger';
/**
* 将 API 响应数据转换为 StockQuoteCard 所需格式
*/
const transformQuoteData = (apiData, stockCode) => {
if (!apiData) return null;
return {
// 基础信息
name: apiData.name || apiData.stock_name || '未知',
code: apiData.code || apiData.stock_code || stockCode,
indexTags: apiData.index_tags || apiData.indexTags || [],
industry: apiData.industry || apiData.sw_industry_l2 || '',
industryL1: apiData.industry_l1 || apiData.sw_industry_l1 || '',
// 价格信息
currentPrice: apiData.current_price || apiData.currentPrice || apiData.close || 0,
changePercent: apiData.change_percent || apiData.changePercent || apiData.pct_chg || 0,
todayOpen: apiData.today_open || apiData.todayOpen || apiData.open || 0,
yesterdayClose: apiData.yesterday_close || apiData.yesterdayClose || apiData.pre_close || 0,
todayHigh: apiData.today_high || apiData.todayHigh || apiData.high || 0,
todayLow: apiData.today_low || apiData.todayLow || apiData.low || 0,
// 关键指标
pe: apiData.pe || apiData.pe_ttm || 0,
pb: apiData.pb || apiData.pb_mrq || 0,
marketCap: apiData.market_cap || apiData.marketCap || apiData.circ_mv || '0',
week52Low: apiData.week52_low || apiData.week52Low || 0,
week52High: apiData.week52_high || apiData.week52High || 0,
// 主力动态
mainNetInflow: apiData.main_net_inflow || apiData.mainNetInflow || 0,
institutionHolding: apiData.institution_holding || apiData.institutionHolding || 0,
buyRatio: apiData.buy_ratio || apiData.buyRatio || 50,
sellRatio: apiData.sell_ratio || apiData.sellRatio || 50,
// 更新时间
updateTime: apiData.update_time || apiData.updateTime || new Date().toLocaleString(),
};
};
/**
* 股票行情数据获取 Hook
*
* @param {string} stockCode - 股票代码
* @returns {Object} { data, isLoading, error, refetch }
*/
export const useStockQuote = (stockCode) => {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!stockCode) {
setData(null);
return;
}
const fetchQuote = async () => {
setIsLoading(true);
setError(null);
try {
logger.debug('useStockQuote', '获取股票行情', { stockCode });
const quotes = await stockService.getQuotes([stockCode]);
// API 返回格式: { [stockCode]: quoteData }
const quoteData = quotes?.[stockCode] || quotes;
const transformedData = transformQuoteData(quoteData, stockCode);
logger.debug('useStockQuote', '行情数据转换完成', { stockCode, hasData: !!transformedData });
setData(transformedData);
} catch (err) {
logger.error('useStockQuote', '获取行情失败', err);
setError(err);
setData(null);
} finally {
setIsLoading(false);
}
};
fetchQuote();
}, [stockCode]);
// 手动刷新
const refetch = () => {
if (stockCode) {
setData(null);
// 触发 useEffect 重新执行
}
};
return { data, isLoading, error, refetch };
};
export default useStockQuote;

View File

@@ -1,64 +1,52 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
// src/views/Company/index.js
// 公司详情页面入口 - 纯组合层
import React, { useEffect, useRef } from 'react';
import { Container, VStack } from '@chakra-ui/react';
import { useDispatch } from 'react-redux';
import { loadAllStocks } from '@store/slices/stockSlice';
import { AutoComplete } from 'antd';
import { stockService } from '@services/stockService';
import {
Container,
Heading,
Card,
CardBody,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
HStack,
VStack,
Button,
Text,
Badge,
Divider,
Icon,
useColorModeValue,
useColorMode,
IconButton,
useToast,
} from '@chakra-ui/react';
import { SearchIcon, MoonIcon, SunIcon, StarIcon } from '@chakra-ui/icons';
import { FaChartLine, FaMoneyBillWave, FaChartBar, FaInfoCircle } from 'react-icons/fa';
import { useAuth } from '../../contexts/AuthContext';
import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig';
import FinancialPanorama from './FinancialPanorama';
import ForecastReport from './ForecastReport';
import MarketDataView from './MarketDataView';
import CompanyOverview from './CompanyOverview';
// 导入 PostHog 追踪 Hook
// 自定义 Hooks
import { useCompanyStock } from './hooks/useCompanyStock';
import { useCompanyWatchlist } from './hooks/useCompanyWatchlist';
import { useCompanyEvents } from './hooks/useCompanyEvents';
import { useStockQuote } from './hooks/useStockQuote';
// 页面组件
import CompanyHeader from './components/CompanyHeader';
import StockQuoteCard from './components/StockQuoteCard';
import CompanyTabs from './components/CompanyTabs';
/**
* 公司详情页面
*
* 功能:
* - 股票搜索与代码管理
* - 自选股添加/移除
* - 多维度数据展示(概览、行情、财务、预测)
* - PostHog 事件追踪
*/
const CompanyIndex = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [stockCode, setStockCode] = useState(searchParams.get('scode') || '000001');
const [inputCode, setInputCode] = useState(stockCode);
const [stockOptions, setStockOptions] = useState([]);
const { colorMode, toggleColorMode } = useColorMode();
const toast = useToast();
const { isAuthenticated } = useAuth();
// 从 Redux 获取股票列表数据
const dispatch = useDispatch();
const allStocks = useSelector((state) => state.stock.allStocks);
// 确保股票数据已加载
// 1. 先获取股票代码(不带追踪回调)
const {
stockCode,
inputCode,
setInputCode,
handleSearch,
handleKeyDown,
} = useCompanyStock();
// 加载全部股票列表(用于模糊搜索)
useEffect(() => {
if (!allStocks || allStocks.length === 0) {
dispatch(loadAllStocks());
}
}, [dispatch, allStocks]);
dispatch(loadAllStocks());
}, [dispatch]);
// 🎯 PostHog 事件追踪
// 2. 获取股票行情数据
const { data: quoteData, isLoading: isQuoteLoading } = useStockQuote(stockCode);
// 3. 再初始化事件追踪(传入 stockCode
const {
trackStockSearched,
trackTabChanged,
@@ -66,321 +54,54 @@ const CompanyIndex = () => {
trackWatchlistRemoved,
} = useCompanyEvents({ stockCode });
// Tab 索引状态(用于追踪 Tab 切换)
const [currentTabIndex, setCurrentTabIndex] = useState(0);
// 3. 自选股管理
const {
isInWatchlist,
isLoading: isWatchlistLoading,
toggle: handleWatchlistToggle,
} = useCompanyWatchlist({
stockCode,
tracking: {
onAdd: trackWatchlistAdded,
onRemove: trackWatchlistRemoved,
},
});
const bgColor = useColorModeValue('white', 'gray.800');
const tabBg = useColorModeValue('gray.50', 'gray.700');
const activeBg = useColorModeValue('blue.500', 'blue.400');
const [isInWatchlist, setIsInWatchlist] = useState(false);
const [isWatchlistLoading, setIsWatchlistLoading] = useState(false);
const loadWatchlistStatus = useCallback(async () => {
try {
const base = getApiBase();
const resp = await fetch(base + '/api/account/watchlist', {
credentials: 'include',
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' }
});
if (!resp.ok) {
setIsInWatchlist(false);
return;
}
const data = await resp.json();
const list = Array.isArray(data?.data) ? data.data : [];
const codes = new Set(list.map((item) => item.stock_code));
setIsInWatchlist(codes.has(stockCode));
} catch (e) {
setIsInWatchlist(false);
}
}, [stockCode]);
// 当URL参数变化时更新股票代码
// 4. 监听 stockCode 变化,触发搜索追踪
const prevStockCodeRef = useRef(stockCode);
useEffect(() => {
const scode = searchParams.get('scode');
if (scode && scode !== stockCode) {
setStockCode(scode);
setInputCode(scode);
if (stockCode !== prevStockCodeRef.current) {
trackStockSearched(stockCode, prevStockCodeRef.current);
prevStockCodeRef.current = stockCode;
}
}, [searchParams, stockCode]);
useEffect(() => {
loadWatchlistStatus();
}, [loadWatchlistStatus]);
const handleSearch = () => {
if (inputCode && inputCode !== stockCode) {
// 🎯 追踪股票搜索
trackStockSearched(inputCode, stockCode);
setStockCode(inputCode);
setSearchParams({ scode: inputCode });
}
};
const handleKeyPress = (e) => {
if (e.key === 'Enter') {
handleSearch();
}
};
// 模糊搜索股票(由 onSearch 触发)
const handleStockSearch = (value) => {
if (!value || !allStocks || allStocks.length === 0) {
setStockOptions([]);
return;
}
const results = stockService.fuzzySearch(value, allStocks, 10);
const options = results.map((stock) => ({
value: stock.code,
label: `${stock.code} ${stock.name}`,
}));
setStockOptions(options);
};
// 选中股票
const handleStockSelect = (value) => {
setInputCode(value);
setStockOptions([]);
if (value !== stockCode) {
trackStockSearched(value, stockCode);
setStockCode(value);
setSearchParams({ scode: value });
}
};
const handleWatchlistToggle = async () => {
if (!stockCode) {
logger.warn('CompanyIndex', 'handleWatchlistToggle', '无效的股票代码', { stockCode });
toast({ title: '无效的股票代码', status: 'error', duration: 2000 });
return;
}
if (!isAuthenticated) {
logger.warn('CompanyIndex', 'handleWatchlistToggle', '用户未登录', { stockCode });
toast({ title: '请先登录后再加入自选', status: 'warning', duration: 2000 });
return;
}
try {
setIsWatchlistLoading(true);
const base = getApiBase();
if (isInWatchlist) {
logger.debug('CompanyIndex', '准备从自选移除', { stockCode });
const url = base + `/api/account/watchlist/${stockCode}`;
logger.api.request('DELETE', url, { stockCode });
const resp = await fetch(url, {
method: 'DELETE',
credentials: 'include'
});
logger.api.response('DELETE', url, resp.status);
if (!resp.ok) throw new Error('删除失败');
// 🎯 追踪移除自选
trackWatchlistRemoved(stockCode);
setIsInWatchlist(false);
toast({ title: '已从自选移除', status: 'info', duration: 1500 });
} else {
logger.debug('CompanyIndex', '准备添加到自选', { stockCode });
const url = base + '/api/account/watchlist';
const body = { stock_code: stockCode };
logger.api.request('POST', url, body);
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body)
});
logger.api.response('POST', url, resp.status);
if (!resp.ok) throw new Error('添加失败');
// 🎯 追踪加入自选
trackWatchlistAdded(stockCode);
setIsInWatchlist(true);
toast({ title: '已加入自选', status: 'success', duration: 1500 });
}
} catch (error) {
logger.error('CompanyIndex', 'handleWatchlistToggle', error, { stockCode, isInWatchlist });
toast({ title: '操作失败,请稍后重试', status: 'error', duration: 2000 });
} finally {
setIsWatchlistLoading(false);
}
};
}, [stockCode, trackStockSearched]);
return (
<Container maxW="container.xl" py={5}>
{/* 页面标题和股票搜索 */}
<VStack align="stretch" spacing={5}>
<Card bg={bgColor} shadow="md">
<CardBody>
<HStack justify="space-between" align="center">
<VStack align="start" spacing={1}>
<Heading size="lg">个股详情</Heading>
<Text color="gray.600" fontSize="sm">
查看股票实时行情财务数据和盈利预测
</Text>
</VStack>
<HStack spacing={3}>
<AutoComplete
value={inputCode}
options={stockOptions}
onSearch={handleStockSearch}
onSelect={handleStockSelect}
onChange={(value) => setInputCode(value)}
placeholder="输入股票代码或名称"
style={{ width: 260 }}
size="large"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSearch();
}
}}
/>
<Button
colorScheme="blue"
size="lg"
onClick={handleSearch}
leftIcon={<SearchIcon />}
>
查询
</Button>
<Button
colorScheme={isInWatchlist ? 'yellow' : 'teal'}
variant={isInWatchlist ? 'solid' : 'outline'}
size="lg"
onClick={handleWatchlistToggle}
leftIcon={<StarIcon />}
isLoading={isWatchlistLoading}
>
{isInWatchlist ? '已在自选' : '加入自选'}
</Button>
<IconButton
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
onClick={toggleColorMode}
variant="outline"
colorScheme={colorMode === 'light' ? 'blue' : 'yellow'}
size="lg"
aria-label="Toggle color mode"
/>
</HStack>
</HStack>
{/* 当前股票信息 */}
<HStack mt={4} spacing={4}>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
股票代码: {stockCode}
</Badge>
<Text fontSize="sm" color="gray.600">
更新时间: {new Date().toLocaleString()}
</Text>
</HStack>
</CardBody>
</Card>
{/* 数据展示区域 */}
<Card bg={bgColor} shadow="lg">
<CardBody p={0}>
<Tabs
variant="soft-rounded"
colorScheme="blue"
size="lg"
index={currentTabIndex}
onChange={(index) => {
const tabNames = ['公司概览', '股票行情', '财务全景', '盈利预测'];
// 🎯 追踪 Tab 切换
trackTabChanged(index, tabNames[index], currentTabIndex);
setCurrentTabIndex(index);
}}
>
<TabList p={4} bg={tabBg}>
<Tab
_selected={{
bg: activeBg,
color: 'white',
transform: 'scale(1.02)',
transition: 'all 0.2s'
}}
mr={2}
>
<HStack spacing={2}>
<Icon as={FaInfoCircle} />
<Text>公司概览</Text>
</HStack>
</Tab>
<Tab
_selected={{
bg: activeBg,
color: 'white',
transform: 'scale(1.02)',
transition: 'all 0.2s'
}}
mr={2}
>
<HStack spacing={2}>
<Icon as={FaChartLine} />
<Text>股票行情</Text>
</HStack>
</Tab>
<Tab
_selected={{
bg: activeBg,
color: 'white',
transform: 'scale(1.02)',
transition: 'all 0.2s'
}}
mr={2}
>
<HStack spacing={2}>
<Icon as={FaMoneyBillWave} />
<Text>财务全景</Text>
</HStack>
</Tab>
<Tab
_selected={{
bg: activeBg,
color: 'white',
transform: 'scale(1.02)',
transition: 'all 0.2s'
}}
>
<HStack spacing={2}>
<Icon as={FaChartBar} />
<Text>盈利预测</Text>
</HStack>
</Tab>
</TabList>
<Divider />
<TabPanels>
<TabPanel p={6}>
<CompanyOverview stockCode={stockCode} />
</TabPanel>
<TabPanel p={6}>
<MarketDataView stockCode={stockCode} />
</TabPanel>
<TabPanel p={6}>
<FinancialPanorama stockCode={stockCode} />
</TabPanel>
<TabPanel p={6}>
<ForecastReport stockCode={stockCode} />
</TabPanel>
</TabPanels>
</Tabs>
</CardBody>
</Card>
<Container maxW="container.xl" py={0} bg='#1A202C'>
<VStack align="stretch" spacing={0}>
{/* 页面头部:标题、搜索 */}
<CompanyHeader
inputCode={inputCode}
onInputChange={setInputCode}
onSearch={handleSearch}
onKeyDown={handleKeyDown}
bgColor="#1A202C"
/>
{/* 股票行情卡片:价格、关键指标、主力动态、自选股按钮 */}
<StockQuoteCard
data={quoteData}
isLoading={isQuoteLoading}
isInWatchlist={isInWatchlist}
isWatchlistLoading={isWatchlistLoading}
onWatchlistToggle={handleWatchlistToggle}
/>
{/* Tab 切换区域:概览、行情、财务、预测 */}
<CompanyTabs stockCode={stockCode} onTabChange={trackTabChanged} bgColor="#1A202C"/>
</VStack>
</Container>
);
};
export default CompanyIndex;