diff --git a/src/mocks/handlers/stock.js b/src/mocks/handlers/stock.js index 4a69472b..f7bb95e2 100644 --- a/src/mocks/handlers/stock.js +++ b/src/mocks/handlers/stock.js @@ -628,7 +628,11 @@ export const stockHandlers = [ total_shares: `${(Math.random() * 100 + 10).toFixed(2)}亿`, circulating_shares: `${(Math.random() * 80 + 5).toFixed(2)}亿`, week52_high: parseFloat((basePrice * 1.3).toFixed(2)), - week52_low: parseFloat((basePrice * 0.7).toFixed(2)) + week52_low: parseFloat((basePrice * 0.7).toFixed(2)), + // 主力动态(当日快照) + net_inflow: parseFloat((Math.random() * 20000 - 10000).toFixed(2)), + main_inflow_ratio: parseFloat((Math.random() * 20 - 10).toFixed(2)), + net_active_buy_ratio: parseFloat((Math.random() * 60 - 30).toFixed(2)), }, message: '获取成功' }); @@ -687,4 +691,68 @@ export const stockHandlers = [ message: '获取成功' }); }), + + // 主力资金流时间序列数据 + http.get('/api/stock/:stockCode/main-capital-flow', async ({ params, request }) => { + await delay(150); + + const { stockCode } = params; + const url = new URL(request.url); + const days = parseInt(url.searchParams.get('days') || '20', 10); + + console.log('[Mock Stock] 获取主力资金流时间序列:', { stockCode, days }); + + // 生成指定天数的模拟数据 + const items = []; + const today = new Date(); + + // 使用股票代码作为种子让同一只股票的数据相对稳定 + const codeSeed = parseInt(stockCode.replace(/\D/g, '').slice(0, 6), 10) || 12345; + + // 模拟一个趋势(先生成一个基准线,然后在上面加噪声) + let trend = (codeSeed % 5 - 2) * 500; // -1000 ~ 1000 的趋势基准 + let cumulativeInflow = 0; + + for (let i = days - 1; i >= 0; i--) { + const date = new Date(today); + date.setDate(date.getDate() - i); + + // 跳过周末 + const dayOfWeek = date.getDay(); + if (dayOfWeek === 0 || dayOfWeek === 6) continue; + + // 带趋势的随机净流入(万元) + const noise = (Math.random() - 0.5) * 8000; + const trendShift = trend * (1 + (Math.random() - 0.5) * 0.3); + const netInflow = parseFloat((trendShift + noise).toFixed(2)); + cumulativeInflow += netInflow; + + // 占比 = 净流入 / 日均成交额 * 100(模拟) + const mainInflowRatio = parseFloat((netInflow / (Math.random() * 30000 + 20000) * 100).toFixed(2)); + + // 净主动买入占比:与净流入正相关但有随机偏移 + const netActiveBuyRatio = parseFloat( + (Math.sign(netInflow) * Math.random() * 25 + (netInflow > 0 ? 5 : -5) + (Math.random() - 0.5) * 15).toFixed(2) + ); + + items.push({ + trade_date: date.toISOString().split('T')[0], + net_inflow: netInflow, + main_inflow_ratio: mainInflowRatio, + net_active_buy_ratio: Math.max(-50, Math.min(50, netActiveBuyRatio)), + }); + + // 趋势缓慢漂移 + trend += (Math.random() - 0.5) * 200; + } + + return HttpResponse.json({ + success: true, + data: { + code: stockCode, + items, + }, + message: '获取成功' + }); + }), ]; diff --git a/src/views/Company/components/StockQuoteCard/components/MainCapitalFlowSection.tsx b/src/views/Company/components/StockQuoteCard/components/MainCapitalFlowSection.tsx new file mode 100644 index 00000000..070d1436 --- /dev/null +++ b/src/views/Company/components/StockQuoteCard/components/MainCapitalFlowSection.tsx @@ -0,0 +1,456 @@ +/** + * MainCapitalFlowSection - 主力资金流时间序列组件 + * + * 替代原有的简单快照展示,提供完整的时间序列视图: + * - 左侧:今日关键指标摘要(净流入、占比、净主动买入) + * - 右侧:ECharts 柱状图(净流入红绿柱)+ 折线图(占比趋势) + * - 底部:时间范围选择器(5日/10日/20日) + * + * 数据来源:stock_main_capital_flow 表 + */ + +import React, { memo, useMemo, useState, useCallback } from 'react'; +import { + Box, + Flex, + VStack, + HStack, + Text, + Button, + ButtonGroup, + Spinner, + Progress, +} from '@chakra-ui/react'; +import EChartsWrapper from '../../EChartsWrapper'; +import { DEEP_SPACE_THEME as T } from './theme'; +import { useMainCapitalFlow } from '../hooks'; +import type { MainCapitalFlowItem } from '../types'; + +// ============================================ +// 时间范围配置 +// ============================================ +const PERIOD_OPTIONS = [ + { label: '5日', days: 10 }, // 请求10天以确保拿到5个交易日 + { label: '10日', days: 18 }, + { label: '20日', days: 35 }, +] as const; + +type PeriodLabel = typeof PERIOD_OPTIONS[number]['label']; + +// ============================================ +// 子组件:今日摘要指标 +// ============================================ +interface TodaySummaryProps { + latestItem: MainCapitalFlowItem | null; +} + +/** 格式化万元数值,大值自动转亿 */ +const formatWanYuan = (value: number | null): string => { + if (value === null || value === undefined) return '--'; + const abs = Math.abs(value); + const sign = value >= 0 ? '+' : ''; + if (abs >= 10000) { + return `${sign}${(value / 10000).toFixed(2)}亿`; + } + return `${sign}${value.toFixed(0)}万`; +}; + +const formatPercent = (value: number | null): string => { + if (value === null || value === undefined) return '--'; + const sign = value >= 0 ? '+' : ''; + return `${sign}${value.toFixed(2)}%`; +}; + +const TodaySummary: React.FC = memo(({ latestItem }) => { + const netInflow = latestItem?.netInflow ?? null; + const mainInflowRatio = latestItem?.mainInflowRatio ?? null; + const netActiveBuyRatio = latestItem?.netActiveBuyRatio ?? null; + + const inflowColor = (netInflow ?? 0) >= 0 ? T.upColor : T.downColor; + const ratioColor = (mainInflowRatio ?? 0) >= 0 ? T.upColor : T.downColor; + const buyRatioValue = netActiveBuyRatio ?? 0; + const progressValue = Math.min(100, Math.max(0, 50 + buyRatioValue / 2)); + + return ( + + {/* 今日标签 */} + + {latestItem?.tradeDate || '今日'} + + + {/* 主力净流入 */} + + 主力净流入 + + {formatWanYuan(netInflow)} + + + + {/* 流入占比 */} + + 流入占比 + + {formatPercent(mainInflowRatio)} + + + + {/* 净主动买入占比 - 进度条 */} + + + 净主动买入 + = 0 ? T.upColor : T.downColor} + fontWeight="600" + fontSize="12px" + > + {formatPercent(netActiveBuyRatio)} + + + + div': { + bg: buyRatioValue >= 0 ? T.upColor : T.downColor, + boxShadow: buyRatioValue >= 0 ? T.upGlow : T.downGlow, + transition: 'all 0.3s ease', + }, + }} + bg="rgba(255,255,255,0.1)" + borderRadius="full" + h="6px" + /> + + + + 卖出 + 买入 + + + + ); +}); +TodaySummary.displayName = 'TodaySummary'; + +// ============================================ +// ECharts 图表配置 +// ============================================ +const buildChartOption = (items: MainCapitalFlowItem[]) => { + const dates = items.map((d) => { + // 只显示月-日 + const parts = d.tradeDate.split('-'); + return `${parts[1]}-${parts[2]}`; + }); + + const netInflowData = items.map((d) => d.netInflow); + const ratioData = items.map((d) => d.mainInflowRatio); + + // 计算累计净流入 + let cumulative = 0; + const cumulativeData = items.map((d) => { + cumulative += d.netInflow; + return parseFloat(cumulative.toFixed(2)); + }); + + return { + tooltip: { + trigger: 'axis', + backgroundColor: 'rgba(15, 18, 35, 0.95)', + borderColor: 'rgba(212, 175, 55, 0.3)', + borderWidth: 1, + textStyle: { color: '#F5F0E1', fontSize: 12 }, + formatter: (params: any[]) => { + if (!params || params.length === 0) return ''; + const idx = params[0].dataIndex; + const item = items[idx]; + if (!item) return ''; + + const inflowColor = item.netInflow >= 0 ? T.upColor : T.downColor; + const ratioColor = item.mainInflowRatio >= 0 ? T.upColor : T.downColor; + + return ` +
+ ${item.tradeDate} +
+
+ 主力净流入 + + ${formatWanYuan(item.netInflow)} + +
+
+ 流入占比 + + ${formatPercent(item.mainInflowRatio)} + +
+
+ 累计净流入 + + ${formatWanYuan(cumulativeData[idx])} + +
+ `; + }, + }, + legend: { + data: ['主力净流入', '累计净流入', '流入占比'], + top: 0, + right: 0, + textStyle: { color: 'rgba(235, 230, 215, 0.7)', fontSize: 11 }, + itemWidth: 12, + itemHeight: 8, + itemGap: 12, + }, + grid: { + top: 30, + left: 8, + right: 8, + bottom: 8, + containLabel: true, + }, + xAxis: { + type: 'category', + data: dates, + axisLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } }, + axisTick: { show: false }, + axisLabel: { + color: 'rgba(235, 230, 215, 0.6)', + fontSize: 10, + rotate: items.length > 15 ? 45 : 0, + }, + }, + yAxis: [ + { + type: 'value', + name: '万元', + nameTextStyle: { color: 'rgba(235, 230, 215, 0.5)', fontSize: 10 }, + splitLine: { lineStyle: { color: 'rgba(255,255,255,0.05)' } }, + axisLabel: { + color: 'rgba(235, 230, 215, 0.5)', + fontSize: 10, + formatter: (v: number) => { + const abs = Math.abs(v); + if (abs >= 10000) return `${(v / 10000).toFixed(1)}亿`; + if (abs >= 1000) return `${(v / 1000).toFixed(1)}千`; + return `${v}`; + }, + }, + }, + { + type: 'value', + name: '%', + nameTextStyle: { color: 'rgba(235, 230, 215, 0.5)', fontSize: 10 }, + splitLine: { show: false }, + axisLabel: { + color: 'rgba(235, 230, 215, 0.5)', + fontSize: 10, + formatter: '{value}%', + }, + }, + ], + series: [ + { + name: '主力净流入', + type: 'bar', + data: netInflowData.map((v) => ({ + value: v, + itemStyle: { + color: v >= 0 ? T.upColor : T.downColor, + borderRadius: v >= 0 ? [2, 2, 0, 0] : [0, 0, 2, 2], + }, + })), + barMaxWidth: 20, + }, + { + name: '累计净流入', + type: 'line', + data: cumulativeData, + smooth: true, + symbol: 'none', + lineStyle: { width: 2, color: T.gold }, + areaStyle: { + color: { + type: 'linear', + x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: 'rgba(212, 175, 55, 0.2)' }, + { offset: 1, color: 'rgba(212, 175, 55, 0.02)' }, + ], + }, + }, + }, + { + name: '流入占比', + type: 'line', + yAxisIndex: 1, + data: ratioData, + smooth: true, + symbol: 'none', + lineStyle: { width: 1.5, color: T.cyan, type: 'dashed' }, + }, + ], + }; +}; + +// ============================================ +// 主组件 +// ============================================ +export interface MainCapitalFlowSectionProps { + stockCode?: string; +} + +export const MainCapitalFlowSection: React.FC = memo( + ({ stockCode }) => { + const [selectedPeriod, setSelectedPeriod] = useState('10日'); + + // 根据选中周期获取请求天数 + const requestDays = useMemo( + () => PERIOD_OPTIONS.find((p) => p.label === selectedPeriod)?.days ?? 18, + [selectedPeriod], + ); + + const { items, isLoading } = useMainCapitalFlow(stockCode, requestDays); + + // 按选中周期裁剪数据(取最后 N 个交易日) + const displayItems = useMemo(() => { + const targetCount = parseInt(selectedPeriod, 10); // "5日" -> 5 + if (items.length <= targetCount) return items; + return items.slice(-targetCount); + }, [items, selectedPeriod]); + + // 最新一天的数据 + const latestItem = useMemo( + () => (displayItems.length > 0 ? displayItems[displayItems.length - 1] : null), + [displayItems], + ); + + // 图表配置 + const chartOption = useMemo( + () => (displayItems.length > 0 ? buildChartOption(displayItems) : null), + [displayItems], + ); + + const handlePeriodChange = useCallback((label: PeriodLabel) => { + setSelectedPeriod(label); + }, []); + + return ( + + {/* 顶部金色光条装饰 */} + + + {/* 标题行 + 时间选择器 */} + + + 主力动态 + + + + {PERIOD_OPTIONS.map(({ label }) => ( + + ))} + + + + {/* 内容区域 */} + {isLoading ? ( + + + + ) : displayItems.length === 0 ? ( + + + 暂无主力资金流数据 + + + ) : ( + + {/* 左侧:今日摘要 */} + + + {/* 右侧:时间序列图表 */} + + {chartOption && ( + + )} + + + )} + + ); + }, +); + +MainCapitalFlowSection.displayName = 'MainCapitalFlowSection'; + +export default MainCapitalFlowSection; diff --git a/src/views/Company/components/StockQuoteCard/components/index.ts b/src/views/Company/components/StockQuoteCard/components/index.ts index 73a33de5..916db56e 100644 --- a/src/views/Company/components/StockQuoteCard/components/index.ts +++ b/src/views/Company/components/StockQuoteCard/components/index.ts @@ -14,6 +14,7 @@ export { PriceDisplay } from './PriceDisplay'; export { SecondaryQuote } from './SecondaryQuote'; export { MainForceInfo } from './MainForceInfo'; +export { MainCapitalFlowSection } from './MainCapitalFlowSection'; export { StockHeader } from './StockHeader'; export { MetricRow } from './MetricRow'; @@ -46,6 +47,7 @@ export * from './formatters'; export type { PriceDisplayProps } from './PriceDisplay'; export type { SecondaryQuoteProps } from './SecondaryQuote'; export type { MainForceInfoProps } from './MainForceInfo'; +export type { MainCapitalFlowSectionProps } from './MainCapitalFlowSection'; export type { StockHeaderProps } from './StockHeader'; export type { MetricRowProps } from './MetricRow'; export type { GlassSectionProps } from './GlassSection'; diff --git a/src/views/Company/components/StockQuoteCard/hooks/index.ts b/src/views/Company/components/StockQuoteCard/hooks/index.ts index ed671e79..2fa12484 100644 --- a/src/views/Company/components/StockQuoteCard/hooks/index.ts +++ b/src/views/Company/components/StockQuoteCard/hooks/index.ts @@ -4,3 +4,4 @@ export { useStockQuoteData } from './useStockQuoteData'; export { useStockCompare } from './useStockCompare'; +export { useMainCapitalFlow } from './useMainCapitalFlow'; diff --git a/src/views/Company/components/StockQuoteCard/hooks/useMainCapitalFlow.ts b/src/views/Company/components/StockQuoteCard/hooks/useMainCapitalFlow.ts new file mode 100644 index 00000000..456cbf67 --- /dev/null +++ b/src/views/Company/components/StockQuoteCard/hooks/useMainCapitalFlow.ts @@ -0,0 +1,105 @@ +/** + * useMainCapitalFlow - 主力资金流时间序列数据获取 Hook + * + * 从 /api/stock/{code}/main-capital-flow 接口获取历史资金流数据 + * 供 MainCapitalFlowSection 组件使用 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { logger } from '@utils/logger'; +import axios from '@utils/axiosConfig'; +import type { MainCapitalFlowItem } from '../types'; + +interface UseMainCapitalFlowResult { + items: MainCapitalFlowItem[]; + isLoading: boolean; + error: string | null; + refetch: () => void; +} + +/** + * 将 API 响应转换为前端数据格式 + */ +const transformItems = (rawItems: any[]): MainCapitalFlowItem[] => { + if (!Array.isArray(rawItems)) return []; + return rawItems.map((item) => ({ + tradeDate: item.trade_date || item.tradeDate || '', + netInflow: item.net_inflow ?? item.netInflow ?? 0, + mainInflowRatio: item.main_inflow_ratio ?? item.mainInflowRatio ?? 0, + netActiveBuyRatio: item.net_active_buy_ratio ?? item.netActiveBuyRatio ?? 0, + })); +}; + +/** + * 主力资金流时间序列 Hook + * + * @param stockCode - 股票代码 + * @param days - 请求天数,默认 30 + */ +export const useMainCapitalFlow = ( + stockCode?: string, + days: number = 30, +): UseMainCapitalFlowResult => { + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchData = useCallback(async (signal?: AbortSignal) => { + if (!stockCode) return; + + const baseCode = stockCode.split('.')[0]; + setIsLoading(true); + setError(null); + + logger.debug('useMainCapitalFlow', '获取主力资金流时间序列', { stockCode, days }); + + try { + const result = await axios.get( + `/api/stock/${baseCode}/main-capital-flow`, + { params: { days }, signal }, + ); + + if (result.data.success && result.data.data?.items) { + const transformed = transformItems(result.data.data.items); + logger.debug('useMainCapitalFlow', '数据转换完成', { + stockCode, + count: transformed.length, + }); + setItems(transformed); + } else { + setError('获取主力资金流数据失败'); + setItems([]); + } + } catch (err: any) { + if (err.name === 'CanceledError') return; + logger.error('useMainCapitalFlow', '获取数据失败', err); + setError('获取主力资金流数据失败'); + setItems([]); + } finally { + setIsLoading(false); + } + }, [stockCode, days]); + + useEffect(() => { + if (!stockCode) { + setItems([]); + return; + } + + const controller = new AbortController(); + fetchData(controller.signal); + + return () => { + controller.abort(); + }; + }, [stockCode, days, fetchData]); + + return { + items, + isLoading, + error, + refetch: () => fetchData(), + }; +}; + +export default useMainCapitalFlow; diff --git a/src/views/Company/components/StockQuoteCard/index.tsx b/src/views/Company/components/StockQuoteCard/index.tsx index 42535821..d8af0c50 100644 --- a/src/views/Company/components/StockQuoteCard/index.tsx +++ b/src/views/Company/components/StockQuoteCard/index.tsx @@ -27,7 +27,7 @@ import { PriceDisplay, SecondaryQuote, MetricRow, - MainForceInfo, + MainCapitalFlowSection, DEEP_SPACE_THEME as T, formatPrice, } from './components'; @@ -181,7 +181,7 @@ const StockQuoteCard: React.FC = ({ todayLow={displayData.todayLow} /> - {/* ========== 数据区块(三列布局)========== */} + {/* ========== 数据区块(两列指标 + 全宽主力动态)========== */} {/* 第一列:估值指标 - PE、流通股本、换手率 */} @@ -223,17 +223,11 @@ const StockQuoteCard: React.FC = ({ /> - - {/* 第三列:主力动态 */} - - - + {/* ========== 主力动态(时间序列)========== */} + + {/* 公司信息区块已移至 CompanyOverview 模块 */} diff --git a/src/views/Company/components/StockQuoteCard/types.ts b/src/views/Company/components/StockQuoteCard/types.ts index 1266cdb1..cf5048d8 100644 --- a/src/views/Company/components/StockQuoteCard/types.ts +++ b/src/views/Company/components/StockQuoteCard/types.ts @@ -45,6 +45,24 @@ export interface StockQuoteCardData { isFavorite?: boolean; // 是否已加入自选 } +/** + * 主力资金流单日数据(来自 stock_main_capital_flow 表) + */ +export interface MainCapitalFlowItem { + tradeDate: string; // 交易日期 YYYY-MM-DD + netInflow: number; // 主力净流入量(万元) + mainInflowRatio: number; // 主力净流入量占比(%) + netActiveBuyRatio: number; // 净主动买入额占比(%) +} + +/** + * 主力资金流时间序列响应数据 + */ +export interface MainCapitalFlowData { + code: string; + items: MainCapitalFlowItem[]; +} + /** * StockQuoteCard 组件 Props(优化后) *