Merge branch 'feature_bugfix/251201_py_h5_ui' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251201_py_h5_ui

* 'feature_bugfix/251201_py_h5_ui' of https://git.valuefrontier.cn/vf/vf_react:
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
This commit is contained in:
zdl
2025-12-11 14:17:26 +08:00
15 changed files with 1203 additions and 833 deletions

View File

@@ -307,7 +307,13 @@ export const conceptHandlers = [
const count = Math.min(limit, stockPool.length); const count = Math.min(limit, stockPool.length);
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const stock = stockPool[i]; const stock = stockPool[i];
const suffix = stock.code.startsWith('6') ? '.SH' : '.SZ'; // 根据股票代码判断交易所后缀
let suffix = '.SZ';
if (stock.code.startsWith('6')) {
suffix = '.SH';
} else if (stock.code.startsWith('8') || stock.code.startsWith('9') || stock.code.startsWith('4')) {
suffix = '.BJ';
}
stocks.push({ stocks.push({
stock_code: `${stock.code}${suffix}`, stock_code: `${stock.code}${suffix}`,
code: `${stock.code}${suffix}`, code: `${stock.code}${suffix}`,

View File

@@ -17,9 +17,9 @@ const formatStockCode = (code) => {
// 根据股票代码规则添加后缀 // 根据股票代码规则添加后缀
// 6开头 -> 上海 .SH // 6开头 -> 上海 .SH
// 0、3开头 -> 深圳 .SZ
// 688开头 -> 科创板(上海).SH // 688开头 -> 科创板(上海).SH
// 8开头北交所-> .BJ暂不处理大部分场景不需要 // 0、3开头 -> 深圳 .SZ
// 8、9、4开头 -> 北交所 .BJ
const firstChar = code.charAt(0); const firstChar = code.charAt(0);
const prefix = code.substring(0, 3); const prefix = code.substring(0, 3);
@@ -27,6 +27,9 @@ const formatStockCode = (code) => {
return `${code}.SH`; return `${code}.SH`;
} else if (firstChar === '0' || firstChar === '3') { } else if (firstChar === '0' || firstChar === '3') {
return `${code}.SZ`; return `${code}.SZ`;
} else if (firstChar === '8' || firstChar === '9' || firstChar === '4') {
// 北交所股票
return `${code}.BJ`;
} }
// 默认返回原代码(可能是指数或其他) // 默认返回原代码(可能是指数或其他)

View File

@@ -285,8 +285,9 @@ export default function InvestmentCalendarChakra() {
stockCode = `${stockCode}.SH`; stockCode = `${stockCode}.SH`;
} else if (stockCode.startsWith('0') || stockCode.startsWith('3')) { } else if (stockCode.startsWith('0') || stockCode.startsWith('3')) {
stockCode = `${stockCode}.SZ`; stockCode = `${stockCode}.SZ`;
} else if (stockCode.startsWith('688')) { } else if (stockCode.startsWith('8') || stockCode.startsWith('9') || stockCode.startsWith('4')) {
stockCode = `${stockCode}.SH`; // 北交所股票
stockCode = `${stockCode}.BJ`;
} }
} }

View File

@@ -65,13 +65,20 @@ const MiniTimelineChart: React.FC<MiniTimelineChartProps> = ({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// 是否首次加载
const isFirstLoad = useRef(true);
// 用 ref 追踪是否有数据(避免闭包问题)
const hasDataRef = useRef(false);
// 获取分钟数据 // 获取分钟数据
useEffect(() => { useEffect(() => {
if (!code) return; if (!code) return;
const fetchData = async (): Promise<void> => { const fetchData = async (): Promise<void> => {
setLoading(true); // 只在首次加载时显示 loading 状态
setError(null); if (isFirstLoad.current) {
setLoading(true);
}
try { try {
const apiPath = isIndex const apiPath = isIndex
@@ -81,23 +88,40 @@ const MiniTimelineChart: React.FC<MiniTimelineChartProps> = ({
const response = await fetch(apiPath); const response = await fetch(apiPath);
const result: KLineApiResponse = await response.json(); const result: KLineApiResponse = await response.json();
if (result.success !== false && result.data) { if (result.success !== false && result.data && result.data.length > 0) {
// 格式化数据 // 格式化数据
const formatted: TimelineDataPoint[] = result.data.map(item => ({ const formatted: TimelineDataPoint[] = result.data.map(item => ({
time: item.time || item.timestamp || '', time: item.time || item.timestamp || '',
price: item.close || item.price || 0, price: item.close || item.price || 0,
})); }));
setTimelineData(formatted); setTimelineData(formatted);
hasDataRef.current = true;
setError(null); // 清除之前的错误
} else { } else {
setError(result.error || '暂无数据'); // 只有在没有原有数据时才设置错误(保留原有数据)
if (!hasDataRef.current) {
setError(result.error || '暂无数据');
}
// 有原有数据时,静默失败,保持显示原有数据
} }
} catch (e) { } catch (e) {
setError('加载失败'); // 只有在没有原有数据时才设置错误(保留原有数据)
if (!hasDataRef.current) {
setError('加载失败');
}
// 有原有数据时,静默失败,保持显示原有数据
} finally { } finally {
setLoading(false); setLoading(false);
isFirstLoad.current = false;
} }
}; };
// 重置首次加载标记code 变化时)
isFirstLoad.current = true;
hasDataRef.current = false;
setTimelineData([]); // 切换股票时清空数据
setError(null);
fetchData(); fetchData();
// 交易时间内每分钟刷新 // 交易时间内每分钟刷新

View File

@@ -2,7 +2,7 @@
* 实时行情相关工具函数 * 实时行情相关工具函数
*/ */
import type { Exchange, OrderBookLevel } from '../types'; import type { Exchange } from '../types';
/** /**
* 判断证券代码属于哪个交易所 * 判断证券代码属于哪个交易所
@@ -75,23 +75,6 @@ export const normalizeCode = (code: string): string => {
return code.split('.')[0]; return code.split('.')[0];
}; };
/**
* 从深交所 bids/asks 数组提取价格和量数组
* 格式:[{price, volume}, ...]
* @param orderBook - 盘口数组
* @returns { prices, volumes }
*/
export const extractOrderBook = (
orderBook: OrderBookLevel[] | undefined
): { prices: number[]; volumes: number[] } => {
if (!orderBook || !Array.isArray(orderBook) || orderBook.length === 0) {
return { prices: [], volumes: [] };
}
const prices = orderBook.map(item => item.price || 0);
const volumes = orderBook.map(item => item.volume || 0);
return { prices, volumes };
};
/** /**
* 计算涨跌幅 * 计算涨跌幅
* @param price - 当前价 * @param price - 当前价

View File

@@ -128,7 +128,7 @@ const FlexScreen: React.FC = () => {
const borderColor = 'rgba(255, 255, 255, 0.08)'; const borderColor = 'rgba(255, 255, 255, 0.08)';
const textColor = 'rgba(255, 255, 255, 0.95)'; const textColor = 'rgba(255, 255, 255, 0.95)';
const subTextColor = 'rgba(255, 255, 255, 0.6)'; const subTextColor = 'rgba(255, 255, 255, 0.6)';
const searchBg = 'rgba(255, 255, 255, 0.05)'; const searchBg = 'rgba(255, 255, 255, 0.12)'; // 调亮搜索框背景
const hoverBg = 'rgba(255, 255, 255, 0.08)'; const hoverBg = 'rgba(255, 255, 255, 0.08)';
const accentColor = '#8b5cf6'; const accentColor = '#8b5cf6';

View File

@@ -1,5 +1,6 @@
/** /**
* 灵活屏组件类型定义 * 灵活屏组件类型定义
* 基于深交所 WebSocket API v4.0 (SZSE_WEBSOCKET_API.md)
*/ */
// ==================== WebSocket 相关类型 ==================== // ==================== WebSocket 相关类型 ====================
@@ -7,18 +8,15 @@
/** 交易所标识 */ /** 交易所标识 */
export type Exchange = 'SSE' | 'SZSE'; export type Exchange = 'SSE' | 'SZSE';
/** 深交所频道类型 */
export type SZSEChannel = 'stock' | 'index' | 'bond' | 'hkstock';
/** WebSocket 连接状态 */ /** WebSocket 连接状态 */
export interface ConnectionStatus { export interface ConnectionStatus {
SSE: boolean; SSE: boolean;
SZSE: boolean; SZSE: boolean;
} }
/** 盘口档位数据 */
export interface OrderBookLevel {
price: number;
volume: number;
}
// ==================== 行情数据类型 ==================== // ==================== 行情数据类型 ====================
/** 盘后交易数据 */ /** 盘后交易数据 */
@@ -59,7 +57,7 @@ export interface StockQuoteData extends BaseQuoteData {
askPrices: number[]; askPrices: number[];
askVolumes: number[]; askVolumes: number[];
tradingPhase?: string; tradingPhase?: string;
afterhours?: AfterhoursData; // 盘后交易数据 afterhours?: AfterhoursData;
} }
/** 指数行情数据 */ /** 指数行情数据 */
@@ -140,189 +138,180 @@ export interface SSEMessage {
// ==================== 深交所 WebSocket 消息类型 ==================== // ==================== 深交所 WebSocket 消息类型 ====================
// API 文档: SZSE_WEBSOCKET_API.md // API 文档: SZSE_WEBSOCKET_API.md
// 与上交所 API 保持一致的设计
/** 深交所数据类别(对应 channels */ /**
export type SZSECategory = 'stock' | 'bond' | 'fund'; * 深交所股票行情数据 (消息类型 300111)
* 字段名与 API 文档保持一致
/** 深交所股票行情数据(新 API 格式) */ */
export interface SZSEStockData { export interface SZSEStockData {
security_id: string; security_id: string;
md_stream_id?: string; // MDStreamID: 010 md_stream_id?: string;
orig_time?: number; orig_time?: number;
channel_no?: number; channel_no?: number;
trading_phase_code?: string; // 新字段名 trading_phase_code?: string;
trading_phase?: string; // 兼容旧字段名 prev_close_px: number;
prev_close_px: number; // 新字段名
prev_close?: number; // 兼容旧字段名
open_px: number; open_px: number;
high_px: number; high_px: number;
low_px: number; low_px: number;
last_px: number; last_px: number;
upper_limit_px?: number; // 新字段名 upper_limit_px?: number;
upper_limit?: number; // 兼容旧字段名 lower_limit_px?: number;
lower_limit_px?: number; // 新字段名
lower_limit?: number; // 兼容旧字段名
num_trades?: number; num_trades?: number;
total_volume_trade?: number; // 新字段名 (成交量) total_volume_trade: number;
total_value_trade?: number; // 新字段名 (成交额) total_value_trade: number;
volume?: number; // 兼容旧字段名 bid_prices: number[];
amount?: number; // 兼容旧字段名 bid_volumes: number[];
// 新 API 格式:直接是数组 ask_prices: number[];
bid_prices?: number[]; ask_volumes: number[];
bid_volumes?: number[];
ask_prices?: number[];
ask_volumes?: number[];
// 兼容旧格式
bids?: OrderBookLevel[];
asks?: OrderBookLevel[];
update_time?: string; update_time?: string;
} }
/** 深交所指数行情数据 */ /**
* 深交所指数行情数据 (消息类型 309011)
*/
export interface SZSEIndexData { export interface SZSEIndexData {
security_id: string; security_id: string;
orig_time?: number; md_stream_id?: string;
channel_no?: number; prev_close: number;
trading_phase?: string; num_trades?: number;
volume: number;
amount: number;
current_index: number; current_index: number;
open_index: number; open_index: number;
high_index: number; high_index: number;
low_index: number; low_index: number;
close_index?: number; close_index?: number;
prev_close: number; update_time?: string;
volume: number;
amount: number;
num_trades?: number;
} }
/** 深交所债券行情数据 */ /**
* 深交所债券行情数据 (消息类型 300211)
*/
export interface SZSEBondData { export interface SZSEBondData {
security_id: string; security_id: string;
orig_time?: number; md_stream_id?: string;
channel_no?: number; trading_phase_code?: string;
trading_phase?: string; prev_close: number;
last_px: number;
open_px: number; open_px: number;
high_px: number; high_px: number;
low_px: number; low_px: number;
prev_close: number; last_px: number;
weighted_avg_px?: number; weighted_avg_px?: number;
num_trades?: number;
volume: number; volume: number;
amount: number; amount: number;
num_trades?: number; update_time?: string;
auction_volume?: number;
auction_amount?: number;
} }
/** 深交所港股行情数据 */ /**
* 深交所港股行情数据 (消息类型 306311)
*/
export interface SZSEHKStockData { export interface SZSEHKStockData {
security_id: string; security_id: string;
orig_time?: number; md_stream_id?: string;
channel_no?: number; trading_phase_code?: string;
trading_phase?: string; prev_close: number;
last_px: number;
open_px: number; open_px: number;
high_px: number; high_px: number;
low_px: number; low_px: number;
prev_close: number; last_px: number;
nominal_px?: number; nominal_px?: number; // 按盘价
reference_px?: number; num_trades?: number;
volume: number; volume: number;
amount: number; amount: number;
num_trades?: number; bid_prices: number[];
vcm_start_time?: number; bid_volumes: number[];
vcm_end_time?: number; ask_prices: number[];
bids?: OrderBookLevel[]; ask_volumes: number[];
asks?: OrderBookLevel[]; update_time?: string;
} }
/** 深交所盘后交易数据 */ /**
export interface SZSEAfterhoursData { * 深交所实时推送消息 (批量格式)
security_id: string; * type 直接表示频道类型
orig_time?: number; */
channel_no?: number;
trading_phase?: string;
prev_close: number;
bid_px: number;
bid_size: number;
offer_px: number;
offer_size: number;
volume: number;
amount: number;
num_trades?: number;
}
/** 深交所实时消息(新 API 格式type 直接是 'stock' | 'bond' | 'fund' */
export interface SZSERealtimeMessage { export interface SZSERealtimeMessage {
type: 'stock' | 'bond' | 'fund' | 'realtime'; // 新 API 直接用 type='stock' type: 'stock' | 'index' | 'bond' | 'hkstock';
category?: SZSECategory; // 旧 API 使用 category data: Record<string, SZSEStockData | SZSEIndexData | SZSEBondData | SZSEHKStockData>;
msg_type?: number;
timestamp: string; timestamp: string;
data: SZSEStockData | SZSEIndexData | SZSEBondData | SZSEHKStockData | SZSEAfterhoursData;
} }
/** 深交所快照消息 */ /**
* 深交所快照响应消息
*/
export interface SZSESnapshotMessage { export interface SZSESnapshotMessage {
type: 'snapshot'; type: 'snapshot';
data: SZSEStockData | SZSEIndexData | SZSEBondData | SZSEHKStockData;
timestamp?: string; timestamp?: string;
data: SZSEStockData | {
// 兼容旧格式的批量快照
stocks?: SZSEStockData[];
indexes?: SZSEIndexData[];
bonds?: SZSEBondData[];
};
} }
/** 深交所欢迎消息 */ /**
export interface SZSEWelcomeMessage { * 深交所订阅确认消息
type: 'welcome'; */
message: string;
timestamp: string;
usage?: Record<string, unknown>;
categories?: string[];
}
/** 深交所订阅确认消息 */
export interface SZSESubscribedMessage { export interface SZSESubscribedMessage {
type: 'subscribed'; type: 'subscribed';
channels?: string[]; // 新 API 格式 channels: SZSEChannel[];
codes?: string[]; // 新 API 格式 codes: string[];
securities?: string[]; // 兼容旧格式
categories?: string[]; // 兼容旧格式
all?: boolean;
incremental_only?: boolean;
message?: string;
} }
/** 深交所取消订阅确认消息 */ /**
* 深交所取消订阅确认消息
*/
export interface SZSEUnsubscribedMessage { export interface SZSEUnsubscribedMessage {
type: 'unsubscribed'; type: 'unsubscribed';
channels?: string[]; // 新 API 格式 channels: SZSEChannel[];
codes?: string[]; // 新 API 格式 codes: string[];
securities?: string[]; // 兼容旧格式
categories?: string[]; // 兼容旧格式
remaining_securities?: string[];
remaining_categories?: string[];
} }
/** 深交所错误消息 */ /**
* 深交所订阅状态响应
*/
export interface SZSEStatusMessage {
type: 'status';
channels: SZSEChannel[];
codes: string[];
filter_active: boolean;
}
/**
* 深交所代码列表响应 (单频道)
*/
export interface SZSECodesListSingleMessage {
type: 'codes_list';
category: SZSEChannel;
codes: string[];
count: number;
}
/**
* 深交所代码列表响应 (全部频道)
*/
export interface SZSECodesListAllMessage {
type: 'codes_list';
data: Record<SZSEChannel, { codes: string[]; count: number }>;
}
/**
* 深交所错误消息
*/
export interface SZSEErrorMessage { export interface SZSEErrorMessage {
type: 'error'; type: 'error';
message: string; message: string;
} }
/** 深交所消息类型 */ /**
* 深交所消息联合类型
*/
export type SZSEMessage = export type SZSEMessage =
| SZSERealtimeMessage | SZSERealtimeMessage
| SZSESnapshotMessage | SZSESnapshotMessage
| SZSEWelcomeMessage
| SZSESubscribedMessage | SZSESubscribedMessage
| SZSEUnsubscribedMessage | SZSEUnsubscribedMessage
| SZSEStatusMessage
| SZSECodesListSingleMessage
| SZSECodesListAllMessage
| SZSEErrorMessage | SZSEErrorMessage
| { type: 'pong'; timestamp?: string } | { type: 'pong' };
| { type: 'query_result'; security_id: string; found: boolean; data: unknown }
| { type: 'query_batch_result'; count: number; found: number; data: Record<string, unknown> };
// ==================== 组件 Props 类型 ==================== // ==================== 组件 Props 类型 ====================
@@ -375,4 +364,8 @@ export interface UseRealtimeQuoteReturn {
connected: ConnectionStatus; connected: ConnectionStatus;
subscribe: (code: string) => void; subscribe: (code: string) => void;
unsubscribe: (code: string) => void; unsubscribe: (code: string) => void;
/** 获取订阅状态 (仅深交所) */
getStatus: () => void;
/** 获取单只股票快照 (仅深交所) */
getSnapshot: (code: string) => void;
} }

View File

@@ -0,0 +1,551 @@
/**
* 异动详情右边栏抽屉组件
* 点击分时图上的异动标记后显示,展示该时间段的所有异动详情
*/
import React, { useState, useCallback, useEffect } from 'react';
import {
Drawer,
DrawerBody,
DrawerHeader,
DrawerOverlay,
DrawerContent,
DrawerCloseButton,
Box,
VStack,
HStack,
Text,
Badge,
Icon,
Collapse,
Spinner,
Tooltip,
Flex,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
Portal,
} from '@chakra-ui/react';
import { keyframes, css } from '@emotion/react';
import {
Clock,
Zap,
TrendingUp,
TrendingDown,
ChevronDown,
ChevronRight,
BarChart3,
Flame,
Target,
Activity,
Rocket,
Waves,
Gauge,
Sparkles,
ExternalLink,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import { colors, glassEffect } from '../../../theme/glassTheme';
import {
ALERT_TYPE_CONFIG,
getAlertTypeLabel,
getAlertTypeDescription,
getScoreColor,
formatScore,
} from '../utils/chartHelpers';
import MiniTimelineChart from '@components/Charts/Stock/MiniTimelineChart';
// 动画
const pulseGlow = keyframes`
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
`;
/**
* 获取异动类型图标
*/
const getAlertIcon = (alertType) => {
const iconMap = {
surge_up: TrendingUp,
surge: Zap,
surge_down: TrendingDown,
volume_surge_up: Activity,
shrink_surge_up: Rocket,
volume_oscillation: Waves,
limit_up: Flame,
rank_jump: Target,
volume_spike: BarChart3,
};
return iconMap[alertType] || Zap;
};
/**
* 单个异动详情卡片
*/
const AlertDetailCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
const navigate = useNavigate();
const alertConfig = ALERT_TYPE_CONFIG[alert.alert_type] || ALERT_TYPE_CONFIG.surge;
const isUp = alert.alert_type !== 'surge_down';
const AlertIcon = getAlertIcon(alert.alert_type);
const handleStockClick = (e, stockCode) => {
e.stopPropagation();
navigate(`/company?scode=${stockCode}`);
};
const handleConceptClick = (e) => {
e.stopPropagation();
if (alert.concept_id) {
navigate(`/concept/${alert.concept_id}`);
}
};
return (
<Box
bg="rgba(255, 255, 255, 0.03)"
borderRadius="16px"
border={isExpanded ? `1px solid ${alertConfig.color}50` : '1px solid rgba(255, 255, 255, 0.08)'}
overflow="hidden"
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
_hover={{
border: `1px solid ${alertConfig.color}40`,
bg: 'rgba(255, 255, 255, 0.05)',
}}
>
{/* 顶部渐变条 */}
<Box
h="2px"
bgGradient={`linear(to-r, ${alertConfig.gradient[0]}, ${alertConfig.gradient[1]})`}
opacity={isExpanded ? 1 : 0.6}
/>
{/* 主内容区 - 可点击展开 */}
<Box
p={4}
cursor="pointer"
onClick={onToggle}
>
{/* 第一行:展开箭头 + 概念名称 + 评分 */}
<Flex justify="space-between" align="center" mb={3}>
<HStack spacing={3} flex={1}>
<Icon
as={isExpanded ? ChevronDown : ChevronRight}
color={colors.text.secondary}
boxSize={4}
transition="transform 0.2s"
/>
<Box
p={2}
borderRadius="10px"
bg={`${alertConfig.color}20`}
>
<Icon
as={AlertIcon}
boxSize={4}
color={alertConfig.color}
css={css`filter: drop-shadow(0 0 4px ${alertConfig.color}80);`}
/>
</Box>
<VStack align="flex-start" spacing={0}>
<HStack>
<Text
fontWeight="bold"
fontSize="md"
color={colors.text.primary}
css={css`text-shadow: 0 0 20px ${alertConfig.color}30;`}
>
{alert.concept_name}
</Text>
<Tooltip label="查看概念详情" hasArrow>
<Box
as="span"
cursor="pointer"
onClick={handleConceptClick}
_hover={{ color: alertConfig.color }}
>
<Icon as={ExternalLink} boxSize={3} color={colors.text.muted} />
</Box>
</Tooltip>
</HStack>
<Text fontSize="xs" color={colors.text.muted}>
{alert.time}
</Text>
</VStack>
</HStack>
{/* 评分 */}
<HStack
spacing={1}
px={3}
py={1.5}
borderRadius="full"
bg={`${getScoreColor(alert.final_score)}15`}
border={`1px solid ${getScoreColor(alert.final_score)}30`}
>
<Icon as={Gauge} boxSize={3.5} color={getScoreColor(alert.final_score)} />
<Text
fontSize="sm"
fontWeight="bold"
color={getScoreColor(alert.final_score)}
css={css`text-shadow: 0 0 10px ${getScoreColor(alert.final_score)}50;`}
>
{formatScore(alert.final_score)}
</Text>
<Text fontSize="xs" color={colors.text.muted}></Text>
</HStack>
</Flex>
{/* 第二行:类型标签 + Alpha + 其他指标 */}
<Flex gap={2} flexWrap="wrap" align="center">
<Tooltip label={getAlertTypeDescription(alert.alert_type)} hasArrow>
<Badge
bg={`${alertConfig.color}20`}
color={alertConfig.color}
fontSize="xs"
px={2}
py={1}
borderRadius="md"
cursor="help"
>
{getAlertTypeLabel(alert.alert_type)}
</Badge>
</Tooltip>
{alert.alpha != null && (
<HStack spacing={1}>
<Text fontSize="xs" color={colors.text.muted}>Alpha</Text>
<Text
fontSize="xs"
fontWeight="bold"
color={alert.alpha >= 0 ? colors.market.up : colors.market.down}
>
{alert.alpha >= 0 ? '+' : ''}{alert.alpha.toFixed(2)}%
</Text>
</HStack>
)}
{(alert.limit_up_ratio || 0) > 0.03 && (
<HStack spacing={1}>
<Icon as={Flame} boxSize={3} color="#fa541c" />
<Text fontSize="xs" fontWeight="bold" color="#fa541c">
{Math.round(alert.limit_up_ratio * 100)}%
</Text>
</HStack>
)}
{alert.is_v2 && alert.confirm_ratio != null && (
<HStack spacing={1}>
<Text fontSize="xs" color={colors.text.muted}>确认</Text>
<Text
fontSize="xs"
fontWeight="medium"
color={alert.confirm_ratio >= 0.8 ? '#52c41a' : alert.confirm_ratio >= 0.6 ? '#faad14' : '#ff4d4f'}
>
{Math.round(alert.confirm_ratio * 100)}%
</Text>
</HStack>
)}
</Flex>
</Box>
{/* 展开内容 - 相关股票 */}
<Collapse in={isExpanded} animateOpacity>
<Box
borderTop="1px solid rgba(255, 255, 255, 0.08)"
p={4}
bg="rgba(0, 0, 0, 0.2)"
>
{loadingStocks ? (
<HStack justify="center" py={4}>
<Spinner size="sm" color={alertConfig.color} />
<Text fontSize="sm" color={colors.text.secondary}>加载相关股票...</Text>
</HStack>
) : stocks && stocks.length > 0 ? (
<VStack align="stretch" spacing={2}>
{/* 统计信息 */}
{(() => {
const validStocks = stocks.filter(s => s.change_pct != null && !isNaN(s.change_pct));
if (validStocks.length === 0) return null;
const avgChange = validStocks.reduce((sum, s) => sum + s.change_pct, 0) / validStocks.length;
const upCount = validStocks.filter(s => s.change_pct > 0).length;
const downCount = validStocks.filter(s => s.change_pct < 0).length;
return (
<HStack
spacing={4}
p={3}
bg="rgba(255, 255, 255, 0.02)"
borderRadius="10px"
fontSize="xs"
>
<HStack>
<Text color={colors.text.muted}>均涨:</Text>
<Text fontWeight="bold" color={avgChange >= 0 ? colors.market.up : colors.market.down}>
{avgChange >= 0 ? '+' : ''}{avgChange.toFixed(2)}%
</Text>
</HStack>
<HStack spacing={1}>
<Text color={colors.market.up} fontWeight="medium">{upCount}</Text>
<Text color={colors.text.muted}>/</Text>
<Text color={colors.market.down} fontWeight="medium">{downCount}</Text>
</HStack>
</HStack>
);
})()}
{/* 股票列表 */}
<Box maxH="250px" overflowY="auto" pr={1}>
<VStack align="stretch" spacing={1}>
{stocks.slice(0, 15).map((stock, idx) => {
const changePct = stock.change_pct;
const hasChange = changePct != null && !isNaN(changePct);
const stockCode = stock.code || stock.stock_code;
const stockName = stock.name || stock.stock_name || '-';
return (
<HStack
key={idx}
p={2}
borderRadius="8px"
cursor="pointer"
onClick={(e) => handleStockClick(e, stockCode)}
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
transition="background 0.15s"
justify="space-between"
>
<HStack spacing={3} flex={1}>
{/* 股票名称 - 带迷你分时图悬停 */}
<Popover trigger="hover" placement="left" isLazy>
<PopoverTrigger>
<Text
fontSize="sm"
color="#60a5fa"
fontWeight="medium"
_hover={{ color: '#93c5fd', textDecoration: 'underline' }}
>
{stockName}
</Text>
</PopoverTrigger>
<Portal>
<PopoverContent
w="200px"
h="100px"
bg="rgba(15, 15, 25, 0.95)"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 8px 32px rgba(0, 0, 0, 0.5)"
onClick={(e) => e.stopPropagation()}
>
<PopoverBody p={2}>
<Text fontSize="xs" color={colors.text.secondary} mb={1}>
{stockName} 分时走势
</Text>
<Box h="70px">
<MiniTimelineChart stockCode={stockCode} />
</Box>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
<Text fontSize="xs" color={colors.text.muted}>
{stockCode}
</Text>
</HStack>
<Text
fontSize="sm"
fontWeight="bold"
color={
hasChange && changePct > 0 ? colors.market.up :
hasChange && changePct < 0 ? colors.market.down :
colors.text.muted
}
>
{hasChange ? `${changePct > 0 ? '+' : ''}${changePct.toFixed(2)}%` : '-'}
</Text>
</HStack>
);
})}
</VStack>
</Box>
{stocks.length > 15 && (
<Text fontSize="xs" color={colors.text.muted} textAlign="center">
{stocks.length} 只相关股票显示前 15
</Text>
)}
</VStack>
) : (
<Text fontSize="sm" color={colors.text.muted} textAlign="center" py={4}>
暂无相关股票数据
</Text>
)}
</Box>
</Collapse>
</Box>
);
};
/**
* 异动详情抽屉主组件
*/
const AlertDetailDrawer = ({ isOpen, onClose, alertData }) => {
const [expandedAlertId, setExpandedAlertId] = useState(null);
const [conceptStocks, setConceptStocks] = useState({});
const [loadingConcepts, setLoadingConcepts] = useState({});
const { alerts = [], timeRange, alertCount } = alertData || {};
// 重置状态当抽屉关闭或数据变化
useEffect(() => {
if (!isOpen) {
setExpandedAlertId(null);
}
}, [isOpen]);
// 获取概念相关股票
const fetchConceptStocks = useCallback(async (conceptId) => {
if (loadingConcepts[conceptId] || conceptStocks[conceptId]) return;
setLoadingConcepts(prev => ({ ...prev, [conceptId]: true }));
try {
const response = await axios.get(`/api/concept/${encodeURIComponent(conceptId)}/stocks`);
if (response.data?.success && response.data?.data?.stocks) {
setConceptStocks(prev => ({
...prev,
[conceptId]: response.data.data.stocks
}));
} else {
setConceptStocks(prev => ({ ...prev, [conceptId]: [] }));
}
} catch (error) {
console.error('获取概念股票失败:', error);
setConceptStocks(prev => ({ ...prev, [conceptId]: [] }));
} finally {
setLoadingConcepts(prev => ({ ...prev, [conceptId]: false }));
}
}, [loadingConcepts, conceptStocks]);
// 处理展开/收起
const handleToggle = useCallback((alert) => {
const alertId = `${alert.concept_id}-${alert.time}`;
if (expandedAlertId === alertId) {
setExpandedAlertId(null);
} else {
setExpandedAlertId(alertId);
if (alert.concept_id) {
fetchConceptStocks(alert.concept_id);
}
}
}, [expandedAlertId, fetchConceptStocks]);
// 按分数排序
const sortedAlerts = [...alerts].sort((a, b) =>
(b.final_score || 0) - (a.final_score || 0)
);
return (
<Drawer
isOpen={isOpen}
placement="right"
onClose={onClose}
size="md"
>
<DrawerOverlay bg="rgba(0, 0, 0, 0.6)" backdropFilter="blur(4px)" />
<DrawerContent
bg="rgba(10, 10, 15, 0.95)"
backdropFilter="blur(20px)"
borderLeft="1px solid rgba(255, 255, 255, 0.1)"
>
<DrawerCloseButton
color={colors.text.secondary}
_hover={{ color: colors.text.primary, bg: 'rgba(255, 255, 255, 0.1)' }}
/>
{/* 头部 */}
<DrawerHeader
borderBottomWidth="1px"
borderColor="rgba(255, 255, 255, 0.08)"
pb={4}
>
<VStack align="flex-start" spacing={2}>
<HStack spacing={3}>
<Box
p={2}
borderRadius="12px"
bgGradient="linear(to-br, #8b5cf6, #ec4899)"
boxShadow="0 4px 15px rgba(139, 92, 246, 0.4)"
>
<Icon as={Zap} boxSize={5} color="white" />
</Box>
<Text
fontSize="lg"
fontWeight="bold"
color={colors.text.primary}
css={css`text-shadow: 0 0 20px rgba(139, 92, 246, 0.4);`}
>
异动详情
</Text>
</HStack>
{/* 时间段和数量信息 */}
<HStack spacing={4}>
<HStack spacing={2}>
<Icon as={Clock} boxSize={4} color={colors.accent.purple} />
<Text fontSize="sm" color={colors.text.secondary}>
{timeRange || '未知时段'}
</Text>
</HStack>
<HStack
spacing={1}
px={3}
py={1}
borderRadius="full"
bg="rgba(139, 92, 246, 0.15)"
border="1px solid rgba(139, 92, 246, 0.3)"
>
<Icon as={Sparkles} boxSize={3} color={colors.accent.purple} />
<Text fontSize="sm" fontWeight="bold" color={colors.accent.purple}>
{alertCount || alerts.length} 个异动
</Text>
</HStack>
</HStack>
</VStack>
</DrawerHeader>
{/* 内容区 */}
<DrawerBody py={4}>
{alerts.length === 0 ? (
<Box
p={8}
textAlign="center"
bg="rgba(255, 255, 255, 0.02)"
borderRadius="16px"
border="1px solid rgba(255, 255, 255, 0.05)"
>
<Icon as={Zap} boxSize={10} color={colors.text.muted} mb={3} opacity={0.5} />
<Text color={colors.text.muted}>暂无异动数据</Text>
</Box>
) : (
<VStack spacing={3} align="stretch">
{sortedAlerts.map((alert, idx) => {
const alertId = `${alert.concept_id}-${alert.time}`;
return (
<AlertDetailCard
key={alertId || idx}
alert={alert}
isExpanded={expandedAlertId === alertId}
onToggle={() => handleToggle(alert)}
stocks={conceptStocks[alert.concept_id]}
loadingStocks={loadingConcepts[alert.concept_id]}
/>
);
})}
</VStack>
)}
</DrawerBody>
</DrawerContent>
</Drawer>
);
};
export default AlertDetailDrawer;

View File

@@ -11,7 +11,6 @@ import {
Badge, Badge,
Icon, Icon,
Tooltip, Tooltip,
useColorModeValue,
Flex, Flex,
Collapse, Collapse,
Spinner, Spinner,
@@ -84,10 +83,10 @@ const getAlertIcon = (alertType) => {
}; };
/** /**
* 指标提示组件 - 带详细说明 * 指标提示组件 - 带详细说明(深色主题)
*/ */
const MetricTooltip = ({ metricKey, children }) => { const MetricTooltip = ({ metricKey, children }) => {
const tooltipBg = useColorModeValue('gray.800', 'gray.700'); const tooltipBg = 'rgba(15, 15, 25, 0.95)';
const config = METRIC_CONFIG[metricKey]; const config = METRIC_CONFIG[metricKey];
if (!config) return children; if (!config) return children;
@@ -117,10 +116,10 @@ const MetricTooltip = ({ metricKey, children }) => {
}; };
/** /**
* 迷你进度条组件 * 迷你进度条组件(深色主题)
*/ */
const MiniProgressBar = ({ value, maxValue = 100, color, width = '40px', showGlow = false }) => { const MiniProgressBar = ({ value, maxValue = 100, color, width = '40px', showGlow = false }) => {
const bgColor = useColorModeValue('gray.200', 'gray.700'); const bgColor = 'rgba(255, 255, 255, 0.1)';
const percent = Math.min((value / maxValue) * 100, 100); const percent = Math.min((value / maxValue) * 100, 100);
return ( return (
@@ -147,10 +146,10 @@ const MiniProgressBar = ({ value, maxValue = 100, color, width = '40px', showGlo
}; };
/** /**
* Z-Score 双向进度条组件 * Z-Score 双向进度条组件(深色主题)
*/ */
const ZScoreBar = ({ value, color }) => { const ZScoreBar = ({ value, color }) => {
const bgColor = useColorModeValue('gray.200', 'gray.700'); const bgColor = 'rgba(255, 255, 255, 0.1)';
const absValue = Math.abs(value || 0); const absValue = Math.abs(value || 0);
const percent = Math.min(absValue / 4 * 50, 50); const percent = Math.min(absValue / 4 * 50, 50);
const isPositive = (value || 0) >= 0; const isPositive = (value || 0) >= 0;
@@ -176,7 +175,7 @@ const ZScoreBar = ({ value, color }) => {
transform="translateX(-50%)" transform="translateX(-50%)"
w="2px" w="2px"
h="6px" h="6px"
bg={useColorModeValue('gray.400', 'gray.500')} bg="rgba(255, 255, 255, 0.3)"
borderRadius="full" borderRadius="full"
/> />
</Box> </Box>
@@ -209,20 +208,20 @@ const TriggeredRuleBadge = ({ rule }) => {
}; };
/** /**
* 科技感异动卡片 * 科技感异动卡片 - 统一使用深色主题
*/ */
const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => { const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
const navigate = useNavigate(); const navigate = useNavigate();
// 颜色主题 // 统一深色主题配色(与 glassTheme 保持一致)
const cardBg = useColorModeValue('white', '#0d0d0d'); const cardBg = 'rgba(255, 255, 255, 0.03)';
const hoverBg = useColorModeValue('gray.50', '#1a1a1a'); const hoverBg = 'rgba(255, 255, 255, 0.06)';
const borderColor = useColorModeValue('gray.200', '#2d2d2d'); const borderColor = 'rgba(255, 255, 255, 0.08)';
const expandedBg = useColorModeValue('gray.50', '#111111'); const expandedBg = 'rgba(0, 0, 0, 0.2)';
const tableBg = useColorModeValue('gray.50', '#0a0a0a'); const tableBg = 'rgba(255, 255, 255, 0.02)';
const popoverBg = useColorModeValue('white', '#1a1a1a'); const popoverBg = 'rgba(15, 15, 25, 0.95)';
const textColor = useColorModeValue('gray.800', 'white'); const textColor = 'rgba(255, 255, 255, 0.95)';
const subTextColor = useColorModeValue('gray.500', 'gray.400'); const subTextColor = 'rgba(255, 255, 255, 0.6)';
const alertConfig = ALERT_TYPE_CONFIG[alert.alert_type] || ALERT_TYPE_CONFIG.surge; const alertConfig = ALERT_TYPE_CONFIG[alert.alert_type] || ALERT_TYPE_CONFIG.surge;
const isUp = alert.alert_type !== 'surge_down'; const isUp = alert.alert_type !== 'surge_down';
@@ -681,8 +680,9 @@ const ConceptAlertList = ({
const [conceptStocks, setConceptStocks] = useState({}); const [conceptStocks, setConceptStocks] = useState({});
const [loadingConcepts, setLoadingConcepts] = useState({}); const [loadingConcepts, setLoadingConcepts] = useState({});
const subTextColor = useColorModeValue('gray.500', 'gray.400'); // 统一深色主题配色
const emptyBg = useColorModeValue('gray.50', '#111111'); const subTextColor = 'rgba(255, 255, 255, 0.6)';
const emptyBg = 'rgba(255, 255, 255, 0.02)';
// 获取概念相关股票 - 使用 ref 避免依赖循环 // 获取概念相关股票 - 使用 ref 避免依赖循环
const fetchConceptStocks = useCallback(async (conceptId) => { const fetchConceptStocks = useCallback(async (conceptId) => {

View File

@@ -1,26 +1,28 @@
/** /**
* 指数分时图组件 * 指数分时图组件
* 展示大盘分时走势,支持概念异动标注 * 展示大盘分时走势,支持概念异动标注按10分钟分组
*/ */
import React, { useRef, useEffect, useCallback, useMemo } from 'react'; import React, { useRef, useEffect, useCallback, useMemo } from 'react';
import { Box, useColorModeValue } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import { getAlertMarkPoints } from '../utils/chartHelpers'; import { getAlertMarkPointsGrouped } from '../utils/chartHelpers';
import { colors, glassEffect } from '../../../theme/glassTheme';
/** /**
* @param {Object} props * @param {Object} props
* @param {Object} props.indexData - 指数数据 { timeline, prev_close, name, ... } * @param {Object} props.indexData - 指数数据 { timeline, prev_close, name, ... }
* @param {Array} props.alerts - 异动数据数组 * @param {Array} props.alerts - 异动数据数组
* @param {Function} props.onAlertClick - 点击异动标注的回调 * @param {Function} props.onAlertClick - 点击异动标注的回调(传递该时间段所有异动)
* @param {string} props.height - 图表高度 * @param {string} props.height - 图表高度
*/ */
const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350px' }) => { const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350px' }) => {
const chartRef = useRef(null); const chartRef = useRef(null);
const chartInstance = useRef(null); const chartInstance = useRef(null);
const textColor = useColorModeValue('gray.800', 'white'); // 使用 glassTheme 的深色主题颜色
const subTextColor = useColorModeValue('gray.600', 'gray.400'); const textColor = colors.text.primary;
const gridLineColor = useColorModeValue('#eee', '#333'); const subTextColor = colors.text.secondary;
const gridLineColor = 'rgba(255, 255, 255, 0.08)';
// 计算图表配置 // 计算图表配置
const chartOption = useMemo(() => { const chartOption = useMemo(() => {
@@ -44,8 +46,8 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p
const yAxisMin = priceMin - priceRange * 0.1; const yAxisMin = priceMin - priceRange * 0.1;
const yAxisMax = priceMax + priceRange * 0.25; // 上方留更多空间给标注 const yAxisMax = priceMax + priceRange * 0.25; // 上方留更多空间给标注
// 准备异动标注 // 准备异动标注 - 按10分钟分组
const markPoints = getAlertMarkPoints(alerts, times, prices, priceMax); const markPoints = getAlertMarkPointsGrouped(alerts, times, prices, priceMax, 10);
// 渐变色 - 根据涨跌 // 渐变色 - 根据涨跌
const latestChangePct = changePcts[changePcts.length - 1] || 0; const latestChangePct = changePcts[changePcts.length - 1] || 0;
@@ -67,8 +69,17 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p
trigger: 'axis', trigger: 'axis',
axisPointer: { axisPointer: {
type: 'cross', type: 'cross',
crossStyle: { color: '#999' }, crossStyle: { color: 'rgba(255, 255, 255, 0.3)' },
lineStyle: { color: 'rgba(139, 92, 246, 0.5)' },
}, },
backgroundColor: 'rgba(15, 15, 25, 0.95)',
borderColor: 'rgba(139, 92, 246, 0.3)',
borderWidth: 1,
padding: 0,
textStyle: {
color: colors.text.primary,
},
extraCssText: 'backdrop-filter: blur(12px); border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.4);',
formatter: (params) => { formatter: (params) => {
if (!params || params.length === 0) return ''; if (!params || params.length === 0) return '';
@@ -79,19 +90,19 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p
const volume = volumes[dataIndex]; const volume = volumes[dataIndex];
let html = ` let html = `
<div style="padding: 8px;"> <div style="padding: 12px; color: rgba(255,255,255,0.95);">
<div style="font-weight: bold; margin-bottom: 4px;">${time}</div> <div style="font-weight: bold; margin-bottom: 6px; color: rgba(255,255,255,0.7); font-size: 12px;">${time}</div>
<div>指数: <span style="color: ${changePct >= 0 ? '#ff4d4d' : '#22c55e'}; font-weight: bold;">${price?.toFixed(2)}</span></div> <div style="margin-bottom: 4px;">指数: <span style="color: ${changePct >= 0 ? '#ef4444' : '#22c55e'}; font-weight: bold;">${price?.toFixed(2)}</span></div>
<div>涨跌: <span style="color: ${changePct >= 0 ? '#ff4d4d' : '#22c55e'};">${changePct >= 0 ? '+' : ''}${changePct?.toFixed(2)}%</span></div> <div style="margin-bottom: 4px;">涨跌: <span style="color: ${changePct >= 0 ? '#ef4444' : '#22c55e'};">${changePct >= 0 ? '+' : ''}${changePct?.toFixed(2)}%</span></div>
<div>成交量: ${(volume / 10000).toFixed(0)}万手</div> <div style="color: rgba(255,255,255,0.7);">成交量: ${(volume / 10000).toFixed(0)}万手</div>
</div> </div>
`; `;
// 检查是否有异动 // 检查是否有异动
const alertsAtTime = alerts.filter((a) => a.time === time); const alertsAtTime = alerts.filter((a) => a.time === time);
if (alertsAtTime.length > 0) { if (alertsAtTime.length > 0) {
html += '<div style="border-top: 1px solid rgba(139,92,246,0.3); margin-top: 6px; padding-top: 6px;">'; html += '<div style="border-top: 1px solid rgba(139,92,246,0.3); margin: 0 12px; padding: 10px 0;">';
html += `<div style="font-weight: bold; color: #8b5cf6; margin-bottom: 4px;">📍 概念异动 (${alertsAtTime.length})</div>`; html += `<div style="font-weight: bold; color: #a78bfa; margin-bottom: 6px; font-size: 12px;">📍 概念异动 (${alertsAtTime.length})</div>`;
alertsAtTime.slice(0, 5).forEach((alert) => { alertsAtTime.slice(0, 5).forEach((alert) => {
const typeLabel = { const typeLabel = {
surge: '异动', surge: '异动',
@@ -104,13 +115,13 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p
rank_jump: '排名跃升', rank_jump: '排名跃升',
volume_spike: '放量', volume_spike: '放量',
}[alert.alert_type] || alert.alert_type; }[alert.alert_type] || alert.alert_type;
const typeColor = alert.alert_type === 'surge_down' ? '#52c41a' : '#ff4d4f'; const typeColor = alert.alert_type === 'surge_down' ? '#4ade80' : '#f87171';
const alpha = alert.alpha ? ` α${alert.alpha > 0 ? '+' : ''}${alert.alpha.toFixed(1)}%` : ''; const alpha = alert.alpha ? ` α${alert.alpha > 0 ? '+' : ''}${alert.alpha.toFixed(1)}%` : '';
const score = alert.final_score ? ` [${Math.round(alert.final_score)}分]` : ''; const score = alert.final_score ? ` [${Math.round(alert.final_score)}分]` : '';
html += `<div style="color: ${typeColor}; font-size: 11px; margin: 2px 0;">• ${alert.concept_name} <span style="opacity:0.8">(${typeLabel}${alpha}${score})</span></div>`; html += `<div style="color: ${typeColor}; font-size: 11px; margin: 3px 0;">• ${alert.concept_name} <span style="opacity:0.7">(${typeLabel}${alpha}${score})</span></div>`;
}); });
if (alertsAtTime.length > 5) { if (alertsAtTime.length > 5) {
html += `<div style="color: #8c8c8c; font-size: 10px; margin-top: 4px;">还有 ${alertsAtTime.length - 5} 个异动...</div>`; html += `<div style="color: rgba(255,255,255,0.4); font-size: 10px; margin-top: 6px;">还有 ${alertsAtTime.length - 5} 个异动...</div>`;
} }
html += '</div>'; html += '</div>';
} }
@@ -223,19 +234,18 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p
chartInstance.current.setOption(chartOption, true); chartInstance.current.setOption(chartOption, true);
// 点击事件 - 支持多个异动 // 点击事件 - 传递该时间段所有异动数据
if (onAlertClick) { if (onAlertClick) {
chartInstance.current.off('click'); chartInstance.current.off('click');
chartInstance.current.on('click', 'series.line.markPoint', (params) => { chartInstance.current.on('click', 'series.line.markPoint', (params) => {
if (params.data && params.data.alertData) { if (params.data) {
const alertData = params.data.alertData; // 传递完整的标记点数据,包含 alertData所有异动、timeRange、alertCount 等
// 如果是数组(多个异动),传递第一个(最高分) onAlertClick({
// 调用方可以从 alertData 中获取所有异动 alerts: params.data.alertData || [],
if (Array.isArray(alertData)) { timeRange: params.data.timeRange,
onAlertClick(alertData[0]); alertCount: params.data.alertCount || 1,
} else { time: params.data.time,
onAlertClick(alertData); });
}
} }
}); });
} }

View File

@@ -1,3 +1,4 @@
export { default as IndexMinuteChart } from './IndexMinuteChart'; export { default as IndexMinuteChart } from './IndexMinuteChart';
export { default as ConceptAlertList } from './ConceptAlertList'; export { default as ConceptAlertList } from './ConceptAlertList';
export { default as AlertSummary } from './AlertSummary'; export { default as AlertSummary } from './AlertSummary';
export { default as AlertDetailDrawer } from './AlertDetailDrawer';

View File

@@ -20,17 +20,14 @@ import {
Flex, Flex,
Spacer, Spacer,
Tooltip, Tooltip,
IconButton,
Collapse,
SimpleGrid, SimpleGrid,
useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { keyframes, css } from '@emotion/react'; import { keyframes, css } from '@emotion/react';
import { import {
Flame, Flame,
List, List,
LineChart, LineChart,
ChevronDown,
ChevronUp,
Info, Info,
Zap, Zap,
AlertCircle, AlertCircle,
@@ -40,7 +37,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useHotspotData } from './hooks'; import { useHotspotData } from './hooks';
import { IndexMinuteChart, ConceptAlertList, AlertSummary } from './components'; import { IndexMinuteChart, AlertDetailDrawer } from './components';
import { ALERT_TYPE_CONFIG, getAlertTypeLabel } from './utils/chartHelpers'; import { ALERT_TYPE_CONFIG, getAlertTypeLabel } from './utils/chartHelpers';
import { import {
glassEffect, glassEffect,
@@ -198,8 +195,10 @@ const CompactAlertCard = ({ alert, onClick, isSelected }) => {
*/ */
const HotspotOverview = ({ selectedDate }) => { const HotspotOverview = ({ selectedDate }) => {
const [selectedAlert, setSelectedAlert] = useState(null); const [selectedAlert, setSelectedAlert] = useState(null);
const [showDetailList, setShowDetailList] = useState(false); const [drawerAlertData, setDrawerAlertData] = useState(null);
const [autoExpandAlertKey, setAutoExpandAlertKey] = useState(null);
// 右边栏抽屉控制
const { isOpen: isDrawerOpen, onOpen: onDrawerOpen, onClose: onDrawerClose } = useDisclosure();
// 获取数据 // 获取数据
const { loading, error, data } = useHotspotData(selectedDate); const { loading, error, data } = useHotspotData(selectedDate);
@@ -212,14 +211,25 @@ const HotspotOverview = ({ selectedDate }) => {
const sectionBg = glassEffect.light.bg; const sectionBg = glassEffect.light.bg;
const scrollbarColor = 'rgba(139, 92, 246, 0.3)'; const scrollbarColor = 'rgba(139, 92, 246, 0.3)';
// 点击异动标注 - 自动展开详细列表并选中 // 点击分时图上的异动标注 - 打开右边栏抽屉显示详情
const handleAlertClick = useCallback((alert) => { const handleChartAlertClick = useCallback((alertGroupData) => {
// alertGroupData 包含 { alerts, timeRange, alertCount, time }
setDrawerAlertData(alertGroupData);
onDrawerOpen();
}, [onDrawerOpen]);
// 点击底部异动卡片 - 打开右边栏抽屉显示单个异动详情
const handleCardAlertClick = useCallback((alert) => {
setSelectedAlert(alert); setSelectedAlert(alert);
// 自动展开详细列表并设置需要展开的项 // 构造单个异动的数据格式
setShowDetailList(true); setDrawerAlertData({
const alertKey = `${alert.concept_id}-${alert.time}`; alerts: [alert],
setAutoExpandAlertKey(alertKey); timeRange: alert.time,
}, []); alertCount: 1,
time: alert.time,
});
onDrawerOpen();
}, [onDrawerOpen]);
// 渲染加载状态 - Glassmorphism 风格 // 渲染加载状态 - Glassmorphism 风格
if (loading) { if (loading) {
@@ -637,7 +647,7 @@ const HotspotOverview = ({ selectedDate }) => {
<IndexMinuteChart <IndexMinuteChart
indexData={index} indexData={index}
alerts={alerts} alerts={alerts}
onAlertClick={handleAlertClick} onAlertClick={handleChartAlertClick}
height="420px" height="420px"
/> />
</Box> </Box>
@@ -645,40 +655,23 @@ const HotspotOverview = ({ selectedDate }) => {
{/* 异动列表 - Glassmorphism 横向滚动 */} {/* 异动列表 - Glassmorphism 横向滚动 */}
{alerts.length > 0 && ( {alerts.length > 0 && (
<Box> <Box>
<Flex justify="space-between" align="center" mb={4}> <HStack spacing={3} mb={4}>
<HStack spacing={3}> <Box
<Box p={2}
p={2} borderRadius="12px"
borderRadius="12px" bg="rgba(249, 115, 22, 0.15)"
bg="rgba(249, 115, 22, 0.15)" border="1px solid rgba(249, 115, 22, 0.25)"
border="1px solid rgba(249, 115, 22, 0.25)" >
> <Icon
<Icon as={List}
as={List} boxSize={5}
boxSize={5} color={colors.accent.orange}
color={colors.accent.orange} css={css`filter: drop-shadow(0 0 6px #f97316);`}
css={css`filter: drop-shadow(0 0 6px #f97316);`}
/>
</Box>
<Text fontSize="sm" fontWeight="bold" color={textColor}>异动记录</Text>
<Text fontSize="xs" color={colors.text.muted}>点击卡片查看个股详情</Text>
</HStack>
<Tooltip label={showDetailList ? '收起详细列表' : '展开详细列表'} hasArrow>
<IconButton
icon={<Icon as={showDetailList ? ChevronUp : ChevronDown} boxSize={4} />}
size="sm"
variant="ghost"
borderRadius="12px"
color={colors.text.secondary}
_hover={{
bg: 'rgba(255,255,255,0.05)',
color: textColor,
}}
onClick={() => setShowDetailList(!showDetailList)}
aria-label="切换详细列表"
/> />
</Tooltip> </Box>
</Flex> <Text fontSize="sm" fontWeight="bold" color={textColor}>异动记录</Text>
<Text fontSize="xs" color={colors.text.muted}>点击卡片查看详情</Text>
</HStack>
{/* 横向滚动卡片 */} {/* 横向滚动卡片 */}
<Box <Box
@@ -701,48 +694,12 @@ const HotspotOverview = ({ selectedDate }) => {
<CompactAlertCard <CompactAlertCard
key={`${alert.concept_id}-${alert.time}-${idx}`} key={`${alert.concept_id}-${alert.time}-${idx}`}
alert={alert} alert={alert}
onClick={handleAlertClick} onClick={handleCardAlertClick}
isSelected={selectedAlert?.concept_id === alert.concept_id && selectedAlert?.time === alert.time} isSelected={selectedAlert?.concept_id === alert.concept_id && selectedAlert?.time === alert.time}
/> />
))} ))}
</HStack> </HStack>
</Box> </Box>
{/* 详细列表(可展开) - Glassmorphism */}
<Collapse in={showDetailList} animateOpacity>
<Box
mt={4}
bg={sectionBg}
backdropFilter={glassEffect.light.backdropFilter}
borderRadius="20px"
border={glassEffect.light.border}
p={5}
position="relative"
overflow="hidden"
>
{/* 背景光晕 */}
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
w="80%"
h="200px"
borderRadius="full"
bg="rgba(139, 92, 246, 0.05)"
filter="blur(60px)"
pointerEvents="none"
/>
<ConceptAlertList
alerts={alerts}
onAlertClick={handleAlertClick}
selectedAlert={selectedAlert}
maxHeight="400px"
autoExpandAlertKey={autoExpandAlertKey}
onAutoExpandComplete={() => setAutoExpandAlertKey(null)}
/>
</Box>
</Collapse>
</Box> </Box>
)} )}
@@ -790,6 +747,13 @@ const HotspotOverview = ({ selectedDate }) => {
</Center> </Center>
)} )}
</Box> </Box>
{/* 异动详情右边栏抽屉 */}
<AlertDetailDrawer
isOpen={isDrawerOpen}
onClose={onDrawerClose}
alertData={drawerAlertData}
/>
</Box> </Box>
); );
}; };

View File

@@ -281,7 +281,136 @@ export const getAlertTypeColor = (alertType) => {
}; };
/** /**
* 生成图表标注点数据 - 支持同一时间多个异动折叠显示 * 将时间字符串转换为分钟数
* @param {string} timeStr - 时间字符串,如 "09:30"
* @returns {number} 分钟数
*/
const timeToMinutes = (timeStr) => {
if (!timeStr) return 0;
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
};
/**
* 获取时间所属的分组区间
* @param {string} timeStr - 时间字符串
* @param {number} intervalMinutes - 分组间隔(分钟)
* @returns {string} 时间区间,如 "09:30-09:40"
*/
const getTimeGroup = (timeStr, intervalMinutes = 10) => {
const minutes = timeToMinutes(timeStr);
const groupStart = Math.floor(minutes / intervalMinutes) * intervalMinutes;
const groupEnd = groupStart + intervalMinutes;
const startHour = Math.floor(groupStart / 60);
const startMin = groupStart % 60;
const endHour = Math.floor(groupEnd / 60);
const endMin = groupEnd % 60;
const formatTime = (h, m) => `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
return `${formatTime(startHour, startMin)}-${formatTime(endHour, endMin)}`;
};
/**
* 生成图表标注点数据 - 按时间间隔分组
* @param {Array} alerts - 异动数据数组
* @param {Array} times - 时间数组
* @param {Array} prices - 价格数组
* @param {number} priceMax - 最高价格
* @param {number} intervalMinutes - 分组间隔分钟默认10分钟
* @returns {Array} ECharts markPoint data
*/
export const getAlertMarkPointsGrouped = (alerts, times, prices, priceMax, intervalMinutes = 10) => {
if (!alerts || alerts.length === 0) return [];
// 1. 按时间间隔分组
const alertsByGroup = {};
alerts.forEach(alert => {
const group = getTimeGroup(alert.time, intervalMinutes);
if (!alertsByGroup[group]) {
alertsByGroup[group] = [];
}
alertsByGroup[group].push(alert);
});
// 2. 对每个分组内的异动按分数排序
Object.keys(alertsByGroup).forEach(group => {
alertsByGroup[group].sort((a, b) =>
(b.final_score || b.importance_score || 0) - (a.final_score || a.importance_score || 0)
);
});
// 3. 生成标记点
return Object.entries(alertsByGroup).map(([timeRange, groupAlerts]) => {
// 找到该分组中间时间点对应的坐标
const midTime = groupAlerts[Math.floor(groupAlerts.length / 2)]?.time;
const timeIndex = times.indexOf(midTime);
const price = timeIndex >= 0 ? prices[timeIndex] : priceMax;
const alertCount = groupAlerts.length;
const topAlert = groupAlerts[0];
const hasMultiple = alertCount > 1;
// 使用最高分异动的样式
const { color, gradient } = getAlertStyle(
topAlert.alert_type,
topAlert.final_score / 100 || topAlert.importance_score || 0.5
);
// 生成显示标签
const [startTime] = timeRange.split('-');
const label = hasMultiple ? `${startTime} (${alertCount})` : topAlert.concept_name?.substring(0, 4) || startTime;
const isDown = topAlert.alert_type === 'surge_down';
const symbolSize = hasMultiple ? 45 + Math.min(alertCount * 2, 15) : 35;
return {
name: timeRange,
coord: [midTime || times[0], price],
value: label,
symbol: 'pin',
symbolSize,
itemStyle: {
color: {
type: 'radial',
x: 0.5, y: 0.5, r: 0.8,
colorStops: [
{ offset: 0, color: gradient[0] },
{ offset: 0.7, color: gradient[1] },
{ offset: 1, color: `${color}88` },
],
},
borderColor: hasMultiple ? '#ffffff' : 'rgba(255,255,255,0.8)',
borderWidth: hasMultiple ? 3 : 2,
shadowBlur: hasMultiple ? 20 : 10,
shadowColor: `${color}${hasMultiple ? 'aa' : '66'}`,
},
label: {
show: true,
position: isDown ? 'bottom' : 'top',
formatter: label,
fontSize: hasMultiple ? 11 : 10,
fontWeight: hasMultiple ? 700 : 500,
color: 'rgba(255, 255, 255, 0.95)',
backgroundColor: 'rgba(15, 15, 25, 0.9)',
padding: hasMultiple ? [5, 10] : [3, 6],
borderRadius: 6,
borderColor: `${color}80`,
borderWidth: 1,
shadowBlur: 8,
shadowColor: `${color}40`,
},
// 存储该时间段所有异动数据
alertData: groupAlerts,
alertCount,
timeRange,
time: midTime,
};
});
};
/**
* 生成图表标注点数据 - 支持同一时间多个异动折叠显示(原有函数保留)
* @param {Array} alerts - 异动数据数组 * @param {Array} alerts - 异动数据数组
* @param {Array} times - 时间数组 * @param {Array} times - 时间数组
* @param {Array} prices - 价格数组 * @param {Array} prices - 价格数组

View File

@@ -108,7 +108,7 @@ const StockOverview = () => {
const cardBg = 'rgba(255, 255, 255, 0.03)'; // 玻璃态卡片背景 const cardBg = 'rgba(255, 255, 255, 0.03)'; // 玻璃态卡片背景
const borderColor = 'rgba(255, 255, 255, 0.08)'; // 边框 const borderColor = 'rgba(255, 255, 255, 0.08)'; // 边框
const hoverBg = 'rgba(255, 255, 255, 0.06)'; // 悬停背景 const hoverBg = 'rgba(255, 255, 255, 0.06)'; // 悬停背景
const searchBg = 'rgba(255, 255, 255, 0.05)'; // 搜索框背景 const searchBg = 'rgba(255, 255, 255, 0.15)'; // 搜索框背景(调亮)
const textColor = 'rgba(255, 255, 255, 0.95)'; // 主文字 const textColor = 'rgba(255, 255, 255, 0.95)'; // 主文字
const subTextColor = 'rgba(255, 255, 255, 0.6)'; // 次要文字 const subTextColor = 'rgba(255, 255, 255, 0.6)'; // 次要文字
const goldColor = '#8b5cf6'; // 使用紫色作为强调色 const goldColor = '#8b5cf6'; // 使用紫色作为强调色