update pay promo
This commit is contained in:
@@ -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} />}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user