update pay promo

This commit is contained in:
2026-02-03 17:19:58 +08:00
parent 5017d3b8aa
commit 49597b97f3
3 changed files with 417 additions and 2 deletions

View File

@@ -23,7 +23,7 @@ import {
Tooltip,
Icon,
} from '@chakra-ui/react';
import { Clock, Bell } from 'lucide-react';
import { Clock, Bell, HelpCircle } from 'lucide-react';
import { useNotification } from '@contexts/NotificationContext';
import EventScrollList from './EventScrollList';
import ModeToggleButtons from './ModeToggleButtons';
@@ -715,6 +715,35 @@ const [currentMode, setCurrentMode] = useState('vertical');
<HStack spacing={2}>
<Clock size={18} color={PROFESSIONAL_COLORS.gold[500]} />
<Text fontSize={isMobile ? "md" : "lg"} fontWeight="bold" bgGradient={PROFESSIONAL_COLORS.gradients.gold} bgClip="text">实时要闻·动态追踪</Text>
<Tooltip
label={
<Box fontSize="xs" lineHeight="1.8">
<Text fontWeight="bold" mb={2} fontSize="sm">📋 事件更新机制说明</Text>
<Box mb={2}>
<Text fontWeight="semibold" color="yellow.300">📈 交易时段周一至周五 9:30-15:00</Text>
<Text mt={1} pl={2}>我们使用大模型对市场新闻进行实时智能分析由于每天产生的财经资讯数量庞大系统处理需要一定时间事件展示会有约 3-5 分钟的延迟请您耐心等待</Text>
</Box>
<Box>
<Text fontWeight="semibold" color="blue.300">🌙 非交易时段盘后及节假日</Text>
<Text mt={1} pl={2}>盘后新闻噪音较多为确保分析质量我们采用"撮合分析"模式在以下固定时间点对期间内的所有新闻进行集中分析和更新</Text>
<Text mt={1} pl={4} fontFamily="mono" color="gray.300">18:00 · 20:00 · 次日0:00 · 5:40 · 8:40</Text>
</Box>
</Box>
}
placement="bottom-start"
hasArrow
bg="gray.800"
color="white"
px={4}
py={3}
borderRadius="lg"
maxW="360px"
boxShadow="xl"
>
<Box as="span" display="inline-flex" cursor="help">
<HelpCircle size={14} color={PROFESSIONAL_COLORS.text.secondary} />
</Box>
</Tooltip>
</HStack>
{/* 模式切换按钮(移动端隐藏) */}
{!isMobile && <ModeToggleButtons mode={mode} onModeChange={handleModeToggle} />}

View File

@@ -0,0 +1,343 @@
// MiniChartPopover - 鼠标悬停显示分时图浮窗
// 类似东方财富的悬浮分时图效果
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { Box, Text, HStack, VStack, Spinner, Portal } from '@chakra-ui/react';
import ReactECharts from 'echarts-for-react';
import { getApiBase } from '@utils/apiConfig';
import dayjs from 'dayjs';
/**
* 获取分时数据
*/
const fetchMinuteData = async (stockCode) => {
try {
// 转换股票代码格式:需要带后缀
let code = stockCode;
if (!code.includes('.')) {
if (code.startsWith('6') || code.startsWith('5')) {
code = `${code}.SH`;
} else {
code = `${code}.SZ`;
}
}
const response = await fetch(`${getApiBase()}/api/stock/${code}/latest-minute`);
if (!response.ok) throw new Error('获取分时数据失败');
const result = await response.json();
// API 返回格式:{ data: [...], code, name, trade_date, type }
if (result.data && Array.isArray(result.data)) {
return result.data;
}
return null;
} catch (error) {
console.error('获取分时数据失败:', error);
return null;
}
};
/**
* MiniChartPopover 组件
*/
const MiniChartPopover = ({
stock,
quote,
isVisible,
anchorRef,
position = 'left', // 'left' | 'right'
}) => {
const [minuteData, setMinuteData] = useState(null);
const [loading, setLoading] = useState(false);
const [popoverPosition, setPopoverPosition] = useState({ top: 0, left: 0 });
const popoverRef = useRef(null);
// 计算弹窗位置
useEffect(() => {
if (isVisible && anchorRef?.current) {
const rect = anchorRef.current.getBoundingClientRect();
const popoverWidth = 320;
const popoverHeight = 240;
let left, top;
if (position === 'left') {
left = rect.left - popoverWidth - 12;
} else {
left = rect.right + 12;
}
// 垂直居中对齐
top = rect.top + rect.height / 2 - popoverHeight / 2;
// 边界检查
if (left < 10) left = rect.right + 12;
if (left + popoverWidth > window.innerWidth - 10) {
left = rect.left - popoverWidth - 12;
}
if (top < 10) top = 10;
if (top + popoverHeight > window.innerHeight - 10) {
top = window.innerHeight - popoverHeight - 10;
}
setPopoverPosition({ top, left });
}
}, [isVisible, anchorRef, position]);
// 加载分时数据
useEffect(() => {
if (isVisible && stock?.stock_code) {
setLoading(true);
fetchMinuteData(stock.stock_code).then((data) => {
setMinuteData(data);
setLoading(false);
});
}
}, [isVisible, stock?.stock_code]);
// 计算昨收价(用于绘制基准线)
const prevClose = useMemo(() => {
if (!minuteData?.length) return quote?.prev_close || 0;
// 通常分时数据会包含昨收价
return minuteData[0]?.prev_close || quote?.prev_close || minuteData[0]?.price || 0;
}, [minuteData, quote?.prev_close]);
// ECharts 配置
const chartOption = useMemo(() => {
if (!minuteData?.length) return null;
const times = minuteData.map((d) => d.time);
const prices = minuteData.map((d) => d.close);
// 计算均价(如果没有提供则计算累计均价)
let cumulativeAmount = 0;
let cumulativeVolume = 0;
const avgPrices = minuteData.map((d) => {
cumulativeAmount += d.amount || 0;
cumulativeVolume += d.volume || 0;
return cumulativeVolume > 0 ? cumulativeAmount / cumulativeVolume / 100 : d.close;
});
// 计算价格范围
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const priceRange = Math.max(maxPrice - prevClose, prevClose - minPrice);
const yMin = prevClose - priceRange * 1.1;
const yMax = prevClose + priceRange * 1.1;
// 计算涨跌幅刻度
const maxChangePercent = ((yMax - prevClose) / prevClose) * 100;
return {
animation: false,
grid: {
left: 50,
right: 50,
top: 10,
bottom: 25,
},
xAxis: {
type: 'category',
data: times,
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } },
axisTick: { show: false },
axisLabel: {
color: 'rgba(255,255,255,0.5)',
fontSize: 10,
interval: 'auto',
showMaxLabel: true,
showMinLabel: true,
},
splitLine: { show: false },
},
yAxis: [
{
type: 'value',
min: yMin,
max: yMax,
position: 'left',
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: 'rgba(255,255,255,0.5)',
fontSize: 10,
formatter: (val) => val.toFixed(2),
},
splitLine: {
lineStyle: { color: 'rgba(255,255,255,0.06)' },
},
},
{
type: 'value',
min: -maxChangePercent,
max: maxChangePercent,
position: 'right',
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: (value) => {
if (value > 0) return '#EF4444';
if (value < 0) return '#22C55E';
return 'rgba(255,255,255,0.5)';
},
fontSize: 10,
formatter: (val) => `${val > 0 ? '+' : ''}${val.toFixed(2)}%`,
},
splitLine: { show: false },
},
],
series: [
{
name: '价格',
type: 'line',
data: prices,
symbol: 'none',
lineStyle: {
color: prices[prices.length - 1] >= prevClose ? '#60A5FA' : '#22C55E',
width: 1.5,
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color:
prices[prices.length - 1] >= prevClose
? 'rgba(96, 165, 250, 0.3)'
: 'rgba(34, 197, 94, 0.3)',
},
{ offset: 1, color: 'rgba(96, 165, 250, 0)' },
],
},
},
},
{
name: '均价',
type: 'line',
data: avgPrices,
symbol: 'none',
lineStyle: {
color: '#FBBF24',
width: 1,
type: 'dashed',
},
},
{
name: '昨收',
type: 'line',
data: new Array(times.length).fill(prevClose),
symbol: 'none',
lineStyle: {
color: 'rgba(255,255,255,0.3)',
width: 1,
type: 'dashed',
},
},
],
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(20, 20, 40, 0.95)',
borderColor: 'rgba(255,255,255,0.1)',
textStyle: { color: '#fff', fontSize: 11 },
formatter: (params) => {
const price = params[0];
const avg = params[1];
const changePercent = ((price.value - prevClose) / prevClose) * 100;
const changeColor = changePercent >= 0 ? '#EF4444' : '#22C55E';
return `
<div style="font-size:11px">
<div style="color:rgba(255,255,255,0.6)">${price.axisValue}</div>
<div>价格: <span style="color:${changeColor};font-weight:bold">${price.value?.toFixed(2)}</span></div>
<div>涨幅: <span style="color:${changeColor}">${changePercent >= 0 ? '+' : ''}${changePercent.toFixed(2)}%</span></div>
${avg?.value ? `<div>均价: <span style="color:#FBBF24">${avg.value?.toFixed(2)}</span></div>` : ''}
</div>
`;
},
},
};
}, [minuteData, prevClose]);
// 当前价格和涨跌幅
const currentPrice = quote?.current_price || stock?.current_price || 0;
const changePercent = quote?.change_percent ?? stock?.change_percent ?? 0;
const isUp = changePercent > 0;
const changeColor = isUp ? '#EF4444' : changePercent < 0 ? '#22C55E' : 'rgba(255,255,255,0.6)';
if (!isVisible) return null;
return (
<Portal>
<Box
ref={popoverRef}
position="fixed"
top={`${popoverPosition.top}px`}
left={`${popoverPosition.left}px`}
w="320px"
bg="rgba(15, 15, 30, 0.98)"
borderRadius="lg"
border="1px solid rgba(255, 255, 255, 0.15)"
boxShadow="0 8px 32px rgba(0, 0, 0, 0.5)"
zIndex={9999}
overflow="hidden"
backdropFilter="blur(10px)"
>
{/* 标题栏 */}
<HStack
px={3}
py={2}
bg="rgba(255, 255, 255, 0.03)"
borderBottom="1px solid rgba(255, 255, 255, 0.08)"
justify="space-between"
>
<HStack spacing={2}>
<Text fontSize="sm" fontWeight="bold" color="white">
{stock?.stock_name || stock?.stock_code}
</Text>
<Text fontSize="xs" color="rgba(255,255,255,0.5)">
{dayjs().format('YYYY-MM-DD HH:mm')}
</Text>
</HStack>
<HStack spacing={2}>
<Text fontSize="sm" fontWeight="bold" color={changeColor}>
{currentPrice?.toFixed(2)}
</Text>
<Text fontSize="xs" color={changeColor}>
{isUp ? '+' : ''}
{changePercent?.toFixed(2)}%
</Text>
</HStack>
</HStack>
{/* 图表区域 */}
<Box h="180px" p={1}>
{loading ? (
<VStack justify="center" h="100%">
<Spinner size="sm" color="rgba(212, 175, 55, 0.6)" />
<Text fontSize="xs" color="rgba(255,255,255,0.4)">
加载分时数据...
</Text>
</VStack>
) : chartOption ? (
<ReactECharts
option={chartOption}
style={{ height: '100%', width: '100%' }}
opts={{ renderer: 'canvas' }}
/>
) : (
<VStack justify="center" h="100%">
<Text fontSize="xs" color="rgba(255,255,255,0.4)">
暂无分时数据
</Text>
</VStack>
)}
</Box>
</Box>
</Portal>
);
};
export default MiniChartPopover;

View File

@@ -1,9 +1,10 @@
// 关注股票面板 - 紧凑版
import React, { useState } from 'react';
import React, { useState, useRef, useCallback } from 'react';
import { Box, Text, VStack, HStack, Icon, Badge, useDisclosure } from '@chakra-ui/react';
import { BarChart2, Plus } from 'lucide-react';
import FavoriteButton from '@/components/FavoriteButton';
import AddStockModal from '@/components/AddStockModal';
import MiniChartPopover from './MiniChartPopover';
/**
* 格式化涨跌幅
@@ -29,6 +30,34 @@ const WatchlistPanel = ({
const [removingCode, setRemovingCode] = useState(null);
const { isOpen: isAddModalOpen, onOpen: onAddModalOpen, onClose: onAddModalClose } = useDisclosure();
// MiniChart 悬浮弹窗状态
const [hoveredStock, setHoveredStock] = useState(null);
const [hoveredQuote, setHoveredQuote] = useState(null);
const hoverTimeoutRef = useRef(null);
const stockRefs = useRef({});
// 鼠标进入股票卡片 - 延迟显示
const handleMouseEnter = useCallback((stock, quote) => {
// 清除之前的延迟
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
// 延迟 300ms 显示,避免快速滑过时闪烁
hoverTimeoutRef.current = setTimeout(() => {
setHoveredStock(stock);
setHoveredQuote(quote);
}, 300);
}, []);
// 鼠标离开股票卡片
const handleMouseLeave = useCallback(() => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
setHoveredStock(null);
setHoveredQuote(null);
}, []);
const handleUnwatch = async (stockCode) => {
if (removingCode) return;
setRemovingCode(stockCode);
@@ -114,6 +143,7 @@ const WatchlistPanel = ({
return (
<Box
key={stock.stock_code}
ref={(el) => (stockRefs.current[stock.stock_code] = el)}
py={2}
px={2}
cursor="pointer"
@@ -121,6 +151,8 @@ const WatchlistPanel = ({
bg="rgba(37, 37, 64, 0.3)"
_hover={{ bg: 'rgba(37, 37, 64, 0.6)' }}
onClick={() => onStockClick?.(stock)}
onMouseEnter={() => handleMouseEnter(stock, quote)}
onMouseLeave={handleMouseLeave}
role="group"
>
{/* 第一行:股票名称 + 价格/涨跌幅 + 取消关注按钮 */}
@@ -201,6 +233,17 @@ const WatchlistPanel = ({
{/* 添加自选股弹窗 */}
<AddStockModal isOpen={isAddModalOpen} onClose={onAddModalClose} />
{/* MiniChart 悬浮弹窗 */}
{hoveredStock && (
<MiniChartPopover
stock={hoveredStock}
quote={hoveredQuote}
isVisible={!!hoveredStock}
anchorRef={{ current: stockRefs.current[hoveredStock.stock_code] }}
position="left"
/>
)}
</Box>
);
};