update pay promo

This commit is contained in:
2026-02-06 19:21:35 +08:00
parent a819beb1f2
commit bbdde21229
7 changed files with 656 additions and 12 deletions

View File

@@ -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: '获取成功'
});
}),
];

View File

@@ -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<TodaySummaryProps> = 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 (
<VStack align="stretch" spacing={3} minW="160px">
{/* 今日标签 */}
<Text fontSize="11px" color={T.textMuted} letterSpacing="0.05em">
{latestItem?.tradeDate || '今日'}
</Text>
{/* 主力净流入 */}
<Box>
<Text fontSize="11px" color={T.textMuted} mb={1}></Text>
<Text
fontSize="20px"
fontWeight="700"
color={inflowColor}
textShadow={`0 0 12px ${inflowColor}40`}
fontFamily="'Menlo', 'Monaco', monospace"
lineHeight="1.2"
>
{formatWanYuan(netInflow)}
</Text>
</Box>
{/* 流入占比 */}
<HStack justify="space-between" fontSize="12px">
<Text color={T.textMuted}></Text>
<Text color={ratioColor} fontWeight="600">
{formatPercent(mainInflowRatio)}
</Text>
</HStack>
{/* 净主动买入占比 - 进度条 */}
<Box>
<HStack justify="space-between" mb={1} fontSize="11px">
<Text color={T.textMuted}></Text>
<Text
color={buyRatioValue >= 0 ? T.upColor : T.downColor}
fontWeight="600"
fontSize="12px"
>
{formatPercent(netActiveBuyRatio)}
</Text>
</HStack>
<Box position="relative">
<Progress
value={progressValue}
size="sm"
sx={{
'& > 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"
/>
<Box
position="absolute"
left="50%"
top="0"
bottom="0"
w="1px"
bg="rgba(255,255,255,0.3)"
transform="translateX(-50%)"
/>
</Box>
<HStack justify="space-between" mt={0.5} fontSize="10px">
<Text color={T.downColor}></Text>
<Text color={T.upColor}></Text>
</HStack>
</Box>
</VStack>
);
});
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 `
<div style="font-weight:600;font-size:13px;color:${T.gold};margin-bottom:6px">
${item.tradeDate}
</div>
<div style="margin:3px 0;display:flex;justify-content:space-between;gap:20px">
<span>主力净流入</span>
<span style="color:${inflowColor};font-weight:600;font-family:'Menlo',monospace">
${formatWanYuan(item.netInflow)}
</span>
</div>
<div style="margin:3px 0;display:flex;justify-content:space-between;gap:20px">
<span>流入占比</span>
<span style="color:${ratioColor};font-weight:600;font-family:'Menlo',monospace">
${formatPercent(item.mainInflowRatio)}
</span>
</div>
<div style="margin:3px 0;display:flex;justify-content:space-between;gap:20px">
<span>累计净流入</span>
<span style="color:${cumulativeData[idx] >= 0 ? T.upColor : T.downColor};font-weight:600;font-family:'Menlo',monospace">
${formatWanYuan(cumulativeData[idx])}
</span>
</div>
`;
},
},
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<MainCapitalFlowSectionProps> = memo(
({ stockCode }) => {
const [selectedPeriod, setSelectedPeriod] = useState<PeriodLabel>('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 (
<Box
bg={T.bgInset}
borderRadius={T.radiusLG}
border={`1px solid ${T.borderGlass}`}
p={4}
position="relative"
transition={T.transitionFast}
_hover={{
borderColor: T.borderGoldHover,
bg: 'rgba(15, 18, 35, 0.6)',
}}
>
{/* 顶部金色光条装饰 */}
<Box
position="absolute"
top={0}
left="20px"
right="20px"
height="1px"
background={`linear-gradient(90deg, transparent, ${T.gold}40, transparent)`}
/>
{/* 标题行 + 时间选择器 */}
<Flex justify="space-between" align="center" mb={3}>
<Text
fontSize="14px"
fontWeight="700"
color={T.gold}
textTransform="uppercase"
letterSpacing="0.1em"
textShadow={`0 0 12px ${T.gold}60`}
>
</Text>
<ButtonGroup size="xs" spacing={1}>
{PERIOD_OPTIONS.map(({ label }) => (
<Button
key={label}
variant={selectedPeriod === label ? 'solid' : 'ghost'}
bg={
selectedPeriod === label
? 'rgba(212, 175, 55, 0.2)'
: 'transparent'
}
color={selectedPeriod === label ? T.gold : T.textMuted}
border={
selectedPeriod === label
? `1px solid ${T.borderGold}`
: '1px solid transparent'
}
_hover={{
bg: 'rgba(212, 175, 55, 0.15)',
color: T.gold,
}}
onClick={() => handlePeriodChange(label)}
fontSize="11px"
h="24px"
px={2}
borderRadius="6px"
>
{label}
</Button>
))}
</ButtonGroup>
</Flex>
{/* 内容区域 */}
{isLoading ? (
<Flex justify="center" align="center" h="180px">
<Spinner size="sm" color={T.gold} />
</Flex>
) : displayItems.length === 0 ? (
<Flex justify="center" align="center" h="180px">
<Text color={T.textMuted} fontSize="13px">
</Text>
</Flex>
) : (
<Flex gap={4} flexDirection={{ base: 'column', md: 'row' }}>
{/* 左侧:今日摘要 */}
<TodaySummary latestItem={latestItem} />
{/* 右侧:时间序列图表 */}
<Box flex={1} minH="180px">
{chartOption && (
<EChartsWrapper
option={chartOption}
style={{ height: '200px', width: '100%' }}
/>
)}
</Box>
</Flex>
)}
</Box>
);
},
);
MainCapitalFlowSection.displayName = 'MainCapitalFlowSection';
export default MainCapitalFlowSection;

View File

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

View File

@@ -4,3 +4,4 @@
export { useStockQuoteData } from './useStockQuoteData';
export { useStockCompare } from './useStockCompare';
export { useMainCapitalFlow } from './useMainCapitalFlow';

View File

@@ -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<MainCapitalFlowItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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;

View File

@@ -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<StockQuoteCardProps> = ({
todayLow={displayData.todayLow}
/>
{/* ========== 数据区块(三列布局========== */}
{/* ========== 数据区块(两列指标 + 全宽主力动态========== */}
<Flex gap={4} flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
{/* 第一列:估值指标 - PE、流通股本、换手率 */}
<GlassSection title="估值指标" flex={1}>
@@ -223,17 +223,11 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
/>
</VStack>
</GlassSection>
{/* 第三列:主力动态 */}
<GlassSection title="主力动态" flex={1}>
<MainForceInfo
netInflow={quoteData.netInflow}
mainInflowRatio={quoteData.mainInflowRatio}
netActiveBuyRatio={quoteData.netActiveBuyRatio}
/>
</GlassSection>
</Flex>
{/* ========== 主力动态(时间序列)========== */}
<MainCapitalFlowSection stockCode={stockCode} />
{/* 公司信息区块已移至 CompanyOverview 模块 */}
</VStack>
</Box>

View File

@@ -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优化后
*