Compare commits
21 Commits
feature_20
...
b5d054d89f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5d054d89f | ||
|
|
b66c1585f7 | ||
|
|
5efd598694 | ||
|
|
b1d5b217d3 | ||
|
|
5f6b933172 | ||
|
|
0c291de182 | ||
|
|
61ed1510c2 | ||
|
|
0edc6a5e00 | ||
|
|
a569a63a85 | ||
|
|
77af61a93a | ||
|
|
999fd9b0a3 | ||
|
|
8d3e92dfaf | ||
|
|
e8c21f7863 | ||
|
|
3f518def09 | ||
|
|
f521b89c27 | ||
|
|
ac421011eb | ||
|
|
6628ddc7b2 | ||
|
|
5dc480f5f4 | ||
|
|
99f102a213 | ||
|
|
9f6c98135f | ||
|
|
f0074bca42 |
30
app.py
30
app.py
@@ -12232,12 +12232,19 @@ def get_market_statistics():
|
|||||||
|
|
||||||
available_dates = [str(row.TRADEDATE) for row in available_dates_result]
|
available_dates = [str(row.TRADEDATE) for row in available_dates_result]
|
||||||
|
|
||||||
|
# 格式化日期为 YYYY-MM-DD
|
||||||
|
formatted_trade_date = trade_date.strftime('%Y-%m-%d') if hasattr(trade_date, 'strftime') else str(trade_date).split(' ')[0][:10]
|
||||||
|
formatted_available_dates = [
|
||||||
|
d.strftime('%Y-%m-%d') if hasattr(d, 'strftime') else str(d).split(' ')[0][:10]
|
||||||
|
for d in [row.TRADEDATE for row in available_dates_result]
|
||||||
|
]
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'trade_date': str(trade_date),
|
'trade_date': formatted_trade_date,
|
||||||
'summary': summary,
|
'summary': summary,
|
||||||
'details': list(statistics.values()),
|
'details': list(statistics.values()),
|
||||||
'available_dates': available_dates
|
'available_dates': formatted_available_dates
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -12277,19 +12284,30 @@ def get_daily_top_concepts():
|
|||||||
top_concepts = []
|
top_concepts = []
|
||||||
|
|
||||||
for concept in data.get('results', []):
|
for concept in data.get('results', []):
|
||||||
|
# 保持与 /concept-api/search 相同的字段结构
|
||||||
top_concepts.append({
|
top_concepts.append({
|
||||||
'concept_id': concept.get('concept_id'),
|
'concept_id': concept.get('concept_id'),
|
||||||
'concept_name': concept.get('concept'),
|
'concept': concept.get('concept'), # 原始字段名
|
||||||
|
'concept_name': concept.get('concept'), # 兼容旧字段名
|
||||||
'description': concept.get('description'),
|
'description': concept.get('description'),
|
||||||
'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0),
|
|
||||||
'stock_count': concept.get('stock_count', 0),
|
'stock_count': concept.get('stock_count', 0),
|
||||||
'stocks': concept.get('stocks', [])[:5] # 只返回前5只股票
|
'score': concept.get('score'),
|
||||||
|
'match_type': concept.get('match_type'),
|
||||||
|
'price_info': concept.get('price_info', {}), # 完整的价格信息
|
||||||
|
'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0), # 兼容旧字段
|
||||||
|
'happened_times': concept.get('happened_times', []), # 历史触发时间
|
||||||
|
'stocks': concept.get('stocks', []), # 返回完整股票列表
|
||||||
|
'hot_score': concept.get('hot_score')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# 格式化日期为 YYYY-MM-DD
|
||||||
|
price_date = data.get('price_date', '')
|
||||||
|
formatted_date = str(price_date).split(' ')[0][:10] if price_date else ''
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': top_concepts,
|
'data': top_concepts,
|
||||||
'trade_date': data.get('price_date'),
|
'trade_date': formatted_date,
|
||||||
'count': len(top_concepts)
|
'count': len(top_concepts)
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
|
||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
|
|
||||||
|
|||||||
@@ -75,9 +75,11 @@ const BytedeskWidget = ({
|
|||||||
const rightVal = parseInt(style.right);
|
const rightVal = parseInt(style.right);
|
||||||
const bottomVal = parseInt(style.bottom);
|
const bottomVal = parseInt(style.bottom);
|
||||||
if (rightVal >= 0 && rightVal < 100 && bottomVal >= 0 && bottomVal < 100) {
|
if (rightVal >= 0 && rightVal < 100 && bottomVal >= 0 && bottomVal < 100) {
|
||||||
// H5 端设置按钮尺寸为 48x48(只执行一次)
|
// H5 端设置按钮尺寸为 48x48 并降低 z-index(只执行一次)
|
||||||
if (isMobile && !el.dataset.bytedeskStyled) {
|
if (isMobile && !el.dataset.bytedeskStyled) {
|
||||||
el.dataset.bytedeskStyled = 'true';
|
el.dataset.bytedeskStyled = 'true';
|
||||||
|
// 降低 z-index,避免遮挡页面内的发布按钮等交互元素
|
||||||
|
el.style.zIndex = 10;
|
||||||
const button = el.querySelector('button');
|
const button = el.querySelector('button');
|
||||||
if (button) {
|
if (button) {
|
||||||
button.style.width = '48px';
|
button.style.width = '48px';
|
||||||
|
|||||||
239
src/components/ConceptStocksModal/index.tsx
Normal file
239
src/components/ConceptStocksModal/index.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalBody,
|
||||||
|
Table,
|
||||||
|
Thead,
|
||||||
|
Tbody,
|
||||||
|
Tr,
|
||||||
|
Th,
|
||||||
|
Td,
|
||||||
|
TableContainer,
|
||||||
|
Box,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Icon,
|
||||||
|
Spinner,
|
||||||
|
useColorModeValue,
|
||||||
|
useBreakpointValue,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { FaTable } from 'react-icons/fa';
|
||||||
|
import marketService from '@services/marketService';
|
||||||
|
import { logger } from '@utils/logger';
|
||||||
|
|
||||||
|
// 股票信息类型
|
||||||
|
interface StockInfo {
|
||||||
|
stock_code: string;
|
||||||
|
stock_name: string;
|
||||||
|
reason?: string;
|
||||||
|
change_pct?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 概念信息类型
|
||||||
|
export interface ConceptInfo {
|
||||||
|
concept_id?: string;
|
||||||
|
concept_name: string;
|
||||||
|
stock_count?: number;
|
||||||
|
stocks?: StockInfo[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 行情数据类型
|
||||||
|
interface MarketData {
|
||||||
|
stock_code: string;
|
||||||
|
close?: number;
|
||||||
|
change_percent?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConceptStocksModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
concept: ConceptInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConceptStocksModal: React.FC<ConceptStocksModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
concept,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const [stockMarketData, setStockMarketData] = useState<Record<string, MarketData>>({});
|
||||||
|
const [loadingStockData, setLoadingStockData] = useState(false);
|
||||||
|
|
||||||
|
// 颜色主题
|
||||||
|
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||||||
|
const hoverBg = useColorModeValue('gray.50', '#2a2a2a');
|
||||||
|
|
||||||
|
// 响应式配置 - 添加 fallback 避免首次渲染时返回 undefined 导致弹窗异常
|
||||||
|
const isMobile = useBreakpointValue({ base: true, md: false }, { fallback: 'md' });
|
||||||
|
// H5 使用 xl 而非 full,配合 maxH 限制高度
|
||||||
|
const modalSize = useBreakpointValue({ base: 'xl', md: '4xl' }, { fallback: 'md' });
|
||||||
|
const tableMaxH = useBreakpointValue({ base: '45vh', md: '60vh' }, { fallback: 'md' });
|
||||||
|
|
||||||
|
// 批量获取股票行情数据
|
||||||
|
const fetchStockMarketData = useCallback(async (stocks: StockInfo[]) => {
|
||||||
|
if (!stocks || stocks.length === 0) return;
|
||||||
|
|
||||||
|
setLoadingStockData(true);
|
||||||
|
const newMarketData: Record<string, MarketData> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const batchSize = 5;
|
||||||
|
for (let i = 0; i < stocks.length; i += batchSize) {
|
||||||
|
const batch = stocks.slice(i, i + batchSize);
|
||||||
|
const promises = batch.map(async (stock) => {
|
||||||
|
if (!stock.stock_code) return null;
|
||||||
|
const seccode = stock.stock_code.substring(0, 6);
|
||||||
|
try {
|
||||||
|
const response = await marketService.getTradeData(seccode, 1);
|
||||||
|
if (response.success && response.data?.length > 0) {
|
||||||
|
const latestData = response.data[response.data.length - 1];
|
||||||
|
return { stock_code: stock.stock_code, ...latestData };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('ConceptStocksModal', '获取股票行情失败', { stockCode: seccode });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
results.forEach((result) => {
|
||||||
|
if (result) newMarketData[result.stock_code] = result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setStockMarketData(newMarketData);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ConceptStocksModal', 'fetchStockMarketData', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingStockData(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 弹窗打开时加载数据
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen && concept?.stocks) {
|
||||||
|
setStockMarketData({});
|
||||||
|
fetchStockMarketData(concept.stocks);
|
||||||
|
}
|
||||||
|
}, [isOpen, concept, fetchStockMarketData]);
|
||||||
|
|
||||||
|
// 点击股票行
|
||||||
|
const handleStockClick = (stockCode: string) => {
|
||||||
|
navigate(`/company?scode=${stockCode}`);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stocks = concept?.stocks || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
size={modalSize}
|
||||||
|
scrollBehavior="inside"
|
||||||
|
isCentered
|
||||||
|
>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent bg={cardBg} maxH={isMobile ? '70vh' : undefined}>
|
||||||
|
<ModalHeader bg="purple.500" color="white" borderTopRadius="md">
|
||||||
|
<HStack>
|
||||||
|
<Icon as={FaTable} />
|
||||||
|
<Text>{concept?.concept_name} - 相关个股</Text>
|
||||||
|
</HStack>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalCloseButton color="white" />
|
||||||
|
|
||||||
|
<ModalBody py={6}>
|
||||||
|
{stocks.length === 0 ? (
|
||||||
|
<Text color="gray.500" textAlign="center">暂无相关股票数据</Text>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
{loadingStockData && (
|
||||||
|
<HStack justify="center" mb={4}>
|
||||||
|
<Spinner size="sm" color="purple.500" />
|
||||||
|
<Text fontSize="sm" color="gray.500">正在获取行情数据...</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableContainer maxH={tableMaxH} overflowY="auto" overflowX="auto">
|
||||||
|
<Table variant="simple" size="sm" minW={isMobile ? '600px' : undefined}>
|
||||||
|
<Thead position="sticky" top={0} bg={cardBg} zIndex={1}>
|
||||||
|
<Tr>
|
||||||
|
<Th whiteSpace="nowrap">股票名称</Th>
|
||||||
|
<Th whiteSpace="nowrap">股票代码</Th>
|
||||||
|
<Th isNumeric whiteSpace="nowrap">现价</Th>
|
||||||
|
<Th isNumeric whiteSpace="nowrap">当日涨跌幅</Th>
|
||||||
|
<Th whiteSpace="nowrap" minW="200px">板块原因</Th>
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
<Tbody>
|
||||||
|
{stocks.map((stock, idx) => {
|
||||||
|
const marketData = stockMarketData[stock.stock_code];
|
||||||
|
const changePercent = marketData?.change_percent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr
|
||||||
|
key={idx}
|
||||||
|
_hover={{ bg: hoverBg }}
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={() => handleStockClick(stock.stock_code)}
|
||||||
|
>
|
||||||
|
<Td color="blue.500" fontWeight="medium">
|
||||||
|
{stock.stock_name}
|
||||||
|
</Td>
|
||||||
|
<Td>{stock.stock_code}</Td>
|
||||||
|
<Td isNumeric>
|
||||||
|
{loadingStockData ? (
|
||||||
|
<Spinner size="xs" />
|
||||||
|
) : marketData?.close ? (
|
||||||
|
`¥${marketData.close.toFixed(2)}`
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
<Td
|
||||||
|
isNumeric
|
||||||
|
fontWeight="bold"
|
||||||
|
color={
|
||||||
|
changePercent && changePercent > 0
|
||||||
|
? 'red.500'
|
||||||
|
: changePercent && changePercent < 0
|
||||||
|
? 'green.500'
|
||||||
|
: 'gray.500'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loadingStockData ? (
|
||||||
|
<Spinner size="xs" />
|
||||||
|
) : changePercent !== undefined ? (
|
||||||
|
`${changePercent > 0 ? '+' : ''}${changePercent.toFixed(2)}%`
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
<Td fontSize="xs" color="gray.600" maxW="300px">
|
||||||
|
<Text noOfLines={2}>{stock.reason || '-'}</Text>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConceptStocksModal;
|
||||||
@@ -161,7 +161,7 @@ export default function HomeNavbar() {
|
|||||||
borderColor={navbarBorder}
|
borderColor={navbarBorder}
|
||||||
py={{ base: 2, md: 3 }}
|
py={{ base: 2, md: 3 }}
|
||||||
>
|
>
|
||||||
<Container maxW="container.xl" px={{ base: 3, md: 4 }}>
|
<Container maxW="container.xl" px={{ base: 3, md: 4 }} style={{ paddingRight: 'max(16px, env(safe-area-inset-right))' }}>
|
||||||
<Flex justify="space-between" align="center">
|
<Flex justify="space-between" align="center">
|
||||||
{/* Logo - 价小前投研 */}
|
{/* Logo - 价小前投研 */}
|
||||||
<BrandLogo />
|
<BrandLogo />
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
|||||||
bg={isActive(['/community', '/concepts']) ? 'blue.600' : 'transparent'}
|
bg={isActive(['/community', '/concepts']) ? 'blue.600' : 'transparent'}
|
||||||
color={isActive(['/community', '/concepts']) ? 'white' : 'inherit'}
|
color={isActive(['/community', '/concepts']) ? 'white' : 'inherit'}
|
||||||
fontWeight={isActive(['/community', '/concepts']) ? 'bold' : 'normal'}
|
fontWeight={isActive(['/community', '/concepts']) ? 'bold' : 'normal'}
|
||||||
borderLeft={isActive(['/community', '/concepts']) ? '3px solid' : 'none'}
|
border={isActive(['/community', '/concepts']) ? '2px solid' : 'none'}
|
||||||
borderColor="white"
|
borderColor={isActive(['/community', '/concepts']) ? 'blue.300' : 'transparent'}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.700' : 'gray.50' }}
|
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.700' : 'gray.50' }}
|
||||||
onMouseEnter={highFreqMenu.handleMouseEnter}
|
onMouseEnter={highFreqMenu.handleMouseEnter}
|
||||||
@@ -128,8 +128,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
|||||||
bg={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.600' : 'transparent'}
|
bg={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.600' : 'transparent'}
|
||||||
color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'white' : 'inherit'}
|
color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'white' : 'inherit'}
|
||||||
fontWeight={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'bold' : 'normal'}
|
fontWeight={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'bold' : 'normal'}
|
||||||
borderLeft={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '3px solid' : 'none'}
|
border={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '2px solid' : 'none'}
|
||||||
borderColor="white"
|
borderColor={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.300' : 'transparent'}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.700' : 'gray.50' }}
|
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.700' : 'gray.50' }}
|
||||||
onMouseEnter={marketReviewMenu.handleMouseEnter}
|
onMouseEnter={marketReviewMenu.handleMouseEnter}
|
||||||
@@ -204,8 +204,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
|||||||
bg={isActive(['/agent-chat', '/value-forum']) ? 'blue.600' : 'transparent'}
|
bg={isActive(['/agent-chat', '/value-forum']) ? 'blue.600' : 'transparent'}
|
||||||
color={isActive(['/agent-chat', '/value-forum']) ? 'white' : 'inherit'}
|
color={isActive(['/agent-chat', '/value-forum']) ? 'white' : 'inherit'}
|
||||||
fontWeight={isActive(['/agent-chat', '/value-forum']) ? 'bold' : 'normal'}
|
fontWeight={isActive(['/agent-chat', '/value-forum']) ? 'bold' : 'normal'}
|
||||||
borderLeft={isActive(['/agent-chat', '/value-forum']) ? '3px solid' : 'none'}
|
border={isActive(['/agent-chat', '/value-forum']) ? '2px solid' : 'none'}
|
||||||
borderColor="white"
|
borderColor={isActive(['/agent-chat', '/value-forum']) ? 'blue.300' : 'transparent'}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
_hover={{ bg: isActive(['/agent-chat', '/value-forum']) ? 'blue.700' : 'gray.50' }}
|
_hover={{ bg: isActive(['/agent-chat', '/value-forum']) ? 'blue.700' : 'gray.50' }}
|
||||||
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// src/components/StockChart/KLineChartModal.tsx - K线图弹窗组件
|
// src/components/StockChart/KLineChartModal.tsx - K线图弹窗组件
|
||||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { stockService } from '@services/eventService';
|
import { stockService } from '@services/eventService';
|
||||||
|
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 股票信息
|
* 股票信息
|
||||||
@@ -83,6 +85,9 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
const [earliestDate, setEarliestDate] = useState<string | null>(null);
|
const [earliestDate, setEarliestDate] = useState<string | null>(null);
|
||||||
const [totalDaysLoaded, setTotalDaysLoaded] = useState(0);
|
const [totalDaysLoaded, setTotalDaysLoaded] = useState(0);
|
||||||
|
|
||||||
|
// H5 响应式适配
|
||||||
|
const isMobile = useSelector(selectIsMobile);
|
||||||
|
|
||||||
// 调试日志
|
// 调试日志
|
||||||
console.log('[KLineChartModal] 渲染状态:', {
|
console.log('[KLineChartModal] 渲染状态:', {
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -296,16 +301,16 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 图表配置
|
// 图表配置(H5 响应式)
|
||||||
const option: echarts.EChartsOption = {
|
const option: echarts.EChartsOption = {
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: '#1a1a1a',
|
||||||
title: {
|
title: {
|
||||||
text: `${stock?.stock_name || stock?.stock_code} - 日K线`,
|
text: `${stock?.stock_name || stock?.stock_code} - 日K线`,
|
||||||
left: 'center',
|
left: 'center',
|
||||||
top: 10,
|
top: isMobile ? 5 : 10,
|
||||||
textStyle: {
|
textStyle: {
|
||||||
color: '#e0e0e0',
|
color: '#e0e0e0',
|
||||||
fontSize: 18,
|
fontSize: isMobile ? 14 : 18,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -370,16 +375,16 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
},
|
},
|
||||||
grid: [
|
grid: [
|
||||||
{
|
{
|
||||||
left: '5%',
|
left: isMobile ? '12%' : '5%',
|
||||||
right: '5%',
|
right: isMobile ? '5%' : '5%',
|
||||||
top: '12%',
|
top: isMobile ? '12%' : '12%',
|
||||||
height: '60%',
|
height: isMobile ? '55%' : '60%',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
left: '5%',
|
left: isMobile ? '12%' : '5%',
|
||||||
right: '5%',
|
right: isMobile ? '5%' : '5%',
|
||||||
top: '77%',
|
top: isMobile ? '72%' : '77%',
|
||||||
height: '18%',
|
height: isMobile ? '20%' : '18%',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
xAxis: [
|
xAxis: [
|
||||||
@@ -394,7 +399,8 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: '#999',
|
color: '#999',
|
||||||
interval: Math.floor(dates.length / 8),
|
fontSize: isMobile ? 10 : 12,
|
||||||
|
interval: Math.floor(dates.length / (isMobile ? 4 : 8)),
|
||||||
},
|
},
|
||||||
splitLine: {
|
splitLine: {
|
||||||
show: false,
|
show: false,
|
||||||
@@ -411,7 +417,8 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: '#999',
|
color: '#999',
|
||||||
interval: Math.floor(dates.length / 8),
|
fontSize: isMobile ? 10 : 12,
|
||||||
|
interval: Math.floor(dates.length / (isMobile ? 4 : 8)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -419,6 +426,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
{
|
{
|
||||||
scale: true,
|
scale: true,
|
||||||
gridIndex: 0,
|
gridIndex: 0,
|
||||||
|
splitNumber: isMobile ? 4 : 5,
|
||||||
splitLine: {
|
splitLine: {
|
||||||
show: true,
|
show: true,
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
@@ -432,12 +440,14 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: '#999',
|
color: '#999',
|
||||||
|
fontSize: isMobile ? 10 : 12,
|
||||||
formatter: (value: number) => value.toFixed(2),
|
formatter: (value: number) => value.toFixed(2),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scale: true,
|
scale: true,
|
||||||
gridIndex: 1,
|
gridIndex: 1,
|
||||||
|
splitNumber: isMobile ? 2 : 3,
|
||||||
splitLine: {
|
splitLine: {
|
||||||
show: false,
|
show: false,
|
||||||
},
|
},
|
||||||
@@ -448,6 +458,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: '#999',
|
color: '#999',
|
||||||
|
fontSize: isMobile ? 10 : 12,
|
||||||
formatter: (value: number) => {
|
formatter: (value: number) => {
|
||||||
if (value >= 100000000) {
|
if (value >= 100000000) {
|
||||||
return (value / 100000000).toFixed(1) + '亿';
|
return (value / 100000000).toFixed(1) + '亿';
|
||||||
@@ -545,7 +556,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
|
|
||||||
return () => clearTimeout(retryTimer);
|
return () => clearTimeout(retryTimer);
|
||||||
}
|
}
|
||||||
}, [data, stock]);
|
}, [data, stock, isMobile]);
|
||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -600,13 +611,13 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
top: '50%',
|
top: '50%',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translate(-50%, -50%)',
|
transform: 'translate(-50%, -50%)',
|
||||||
width: '90vw',
|
width: isMobile ? '96vw' : '90vw',
|
||||||
maxWidth: '1400px',
|
maxWidth: isMobile ? 'none' : '1400px',
|
||||||
maxHeight: '85vh',
|
maxHeight: isMobile ? '85vh' : '85vh',
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: '#1a1a1a',
|
||||||
border: '2px solid #ffd700',
|
border: '2px solid #ffd700',
|
||||||
boxShadow: '0 0 30px rgba(255, 215, 0, 0.5)',
|
boxShadow: '0 0 30px rgba(255, 215, 0, 0.5)',
|
||||||
borderRadius: '8px',
|
borderRadius: isMobile ? '12px' : '8px',
|
||||||
zIndex: 10002,
|
zIndex: 10002,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -616,7 +627,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '16px 24px',
|
padding: isMobile ? '12px 16px' : '16px 24px',
|
||||||
borderBottom: '1px solid #404040',
|
borderBottom: '1px solid #404040',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
@@ -624,18 +635,18 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? '8px' : '12px', flexWrap: isMobile ? 'wrap' : 'nowrap' }}>
|
||||||
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#e0e0e0' }}>
|
<span style={{ fontSize: isMobile ? '14px' : '18px', fontWeight: 'bold', color: '#e0e0e0' }}>
|
||||||
{stock.stock_name || stock.stock_code} ({stock.stock_code})
|
{stock.stock_name || stock.stock_code} ({stock.stock_code})
|
||||||
</span>
|
</span>
|
||||||
{data.length > 0 && (
|
{data.length > 0 && (
|
||||||
<span style={{ fontSize: '12px', color: '#666', fontStyle: 'italic' }}>
|
<span style={{ fontSize: isMobile ? '10px' : '12px', color: '#666', fontStyle: 'italic' }}>
|
||||||
共{data.length}个交易日
|
共{data.length}个交易日
|
||||||
{hasMore ? '(向左滑动加载更多)' : '(已加载全部)'}
|
{hasMore ? '(向左滑动加载更多)' : '(已加载全部)'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{loadingMore && (
|
{loadingMore && (
|
||||||
<span style={{ fontSize: '12px', color: '#3182ce', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
<span style={{ fontSize: isMobile ? '10px' : '12px', color: '#3182ce', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
width: '12px',
|
width: '12px',
|
||||||
height: '12px',
|
height: '12px',
|
||||||
@@ -649,10 +660,10 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginTop: '4px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? '8px' : '16px', marginTop: '4px' }}>
|
||||||
<span style={{ fontSize: '14px', color: '#999' }}>日K线图</span>
|
<span style={{ fontSize: isMobile ? '12px' : '14px', color: '#999' }}>日K线图</span>
|
||||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
<span style={{ fontSize: isMobile ? '10px' : '12px', color: '#666' }}>
|
||||||
💡 鼠标滚轮缩放 | 拖动查看不同时间段
|
💡 {isMobile ? '滚轮缩放 | 拖动查看' : '鼠标滚轮缩放 | 拖动查看不同时间段'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -675,26 +686,33 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div style={{ padding: '16px', flex: 1, overflow: 'auto' }}>
|
<div style={{
|
||||||
|
padding: isMobile ? '8px' : '16px',
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#2a1a1a',
|
backgroundColor: '#2a1a1a',
|
||||||
border: '1px solid #ef5350',
|
border: '1px solid #ef5350',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
padding: '12px 16px',
|
padding: isMobile ? '8px 12px' : '12px 16px',
|
||||||
marginBottom: '16px',
|
marginBottom: isMobile ? '8px' : '16px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: '#ef5350' }}>⚠</span>
|
<span style={{ color: '#ef5350' }}>⚠</span>
|
||||||
<span style={{ color: '#e0e0e0' }}>{error}</span>
|
<span style={{ color: '#e0e0e0', fontSize: isMobile ? '12px' : '14px' }}>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ position: 'relative', height: '680px', width: '100%' }}>
|
<div style={{ position: 'relative', height: isMobile ? '450px' : '680px', width: '100%' }}>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// src/components/StockChart/TimelineChartModal.tsx - 分时图弹窗组件
|
// src/components/StockChart/TimelineChartModal.tsx - 分时图弹窗组件
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
ModalOverlay,
|
ModalOverlay,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { klineDataCache, getCacheKey, fetchKlineData } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
|
import { klineDataCache, getCacheKey, fetchKlineData } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
|
||||||
|
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 股票信息
|
* 股票信息
|
||||||
@@ -68,6 +70,9 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [data, setData] = useState<TimelineDataPoint[]>([]);
|
const [data, setData] = useState<TimelineDataPoint[]>([]);
|
||||||
|
|
||||||
|
// H5 响应式适配
|
||||||
|
const isMobile = useSelector(selectIsMobile);
|
||||||
|
|
||||||
// 加载分时图数据(优先使用缓存)
|
// 加载分时图数据(优先使用缓存)
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
if (!stock?.stock_code) return;
|
if (!stock?.stock_code) return;
|
||||||
@@ -187,16 +192,16 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 图表配置
|
// 图表配置(H5 响应式)
|
||||||
const option: echarts.EChartsOption = {
|
const option: echarts.EChartsOption = {
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: '#1a1a1a',
|
||||||
title: {
|
title: {
|
||||||
text: `${stock?.stock_name || stock?.stock_code} - 分时图`,
|
text: `${stock?.stock_name || stock?.stock_code} - 分时图`,
|
||||||
left: 'center',
|
left: 'center',
|
||||||
top: 10,
|
top: isMobile ? 5 : 10,
|
||||||
textStyle: {
|
textStyle: {
|
||||||
color: '#e0e0e0',
|
color: '#e0e0e0',
|
||||||
fontSize: 18,
|
fontSize: isMobile ? 14 : 18,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -247,16 +252,16 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
|||||||
},
|
},
|
||||||
grid: [
|
grid: [
|
||||||
{
|
{
|
||||||
left: '5%',
|
left: isMobile ? '12%' : '5%',
|
||||||
right: '5%',
|
right: isMobile ? '5%' : '5%',
|
||||||
top: '15%',
|
top: isMobile ? '12%' : '15%',
|
||||||
height: '55%',
|
height: isMobile ? '58%' : '55%',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
left: '5%',
|
left: isMobile ? '12%' : '5%',
|
||||||
right: '5%',
|
right: isMobile ? '5%' : '5%',
|
||||||
top: '75%',
|
top: isMobile ? '75%' : '75%',
|
||||||
height: '15%',
|
height: isMobile ? '18%' : '15%',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
xAxis: [
|
xAxis: [
|
||||||
@@ -271,7 +276,8 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
|||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: '#999',
|
color: '#999',
|
||||||
interval: Math.floor(times.length / 6),
|
fontSize: isMobile ? 10 : 12,
|
||||||
|
interval: Math.floor(times.length / (isMobile ? 4 : 6)),
|
||||||
},
|
},
|
||||||
splitLine: {
|
splitLine: {
|
||||||
show: true,
|
show: true,
|
||||||
@@ -291,7 +297,8 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
|||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: '#999',
|
color: '#999',
|
||||||
interval: Math.floor(times.length / 6),
|
fontSize: isMobile ? 10 : 12,
|
||||||
|
interval: Math.floor(times.length / (isMobile ? 4 : 6)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -299,6 +306,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
|||||||
{
|
{
|
||||||
scale: true,
|
scale: true,
|
||||||
gridIndex: 0,
|
gridIndex: 0,
|
||||||
|
splitNumber: isMobile ? 4 : 5,
|
||||||
splitLine: {
|
splitLine: {
|
||||||
show: true,
|
show: true,
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
@@ -312,12 +320,14 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
|||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: '#999',
|
color: '#999',
|
||||||
|
fontSize: isMobile ? 10 : 12,
|
||||||
formatter: (value: number) => value.toFixed(2),
|
formatter: (value: number) => value.toFixed(2),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scale: true,
|
scale: true,
|
||||||
gridIndex: 1,
|
gridIndex: 1,
|
||||||
|
splitNumber: isMobile ? 2 : 3,
|
||||||
splitLine: {
|
splitLine: {
|
||||||
show: false,
|
show: false,
|
||||||
},
|
},
|
||||||
@@ -328,6 +338,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
|||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: '#999',
|
color: '#999',
|
||||||
|
fontSize: isMobile ? 10 : 12,
|
||||||
formatter: (value: number) => {
|
formatter: (value: number) => {
|
||||||
if (value >= 10000) {
|
if (value >= 10000) {
|
||||||
return (value / 10000).toFixed(1) + '万';
|
return (value / 10000).toFixed(1) + '万';
|
||||||
@@ -443,7 +454,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
|||||||
|
|
||||||
return () => clearTimeout(retryTimer);
|
return () => clearTimeout(retryTimer);
|
||||||
}
|
}
|
||||||
}, [data, stock]);
|
}, [data, stock, isMobile]);
|
||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -455,29 +466,30 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
|||||||
if (!stock) return null;
|
if (!stock) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} size={size}>
|
<Modal isOpen={isOpen} onClose={onClose} size={size} isCentered>
|
||||||
<ModalOverlay bg="blackAlpha.700" />
|
<ModalOverlay bg="blackAlpha.700" />
|
||||||
<ModalContent
|
<ModalContent
|
||||||
maxW="90vw"
|
maxW={isMobile ? '96vw' : '90vw'}
|
||||||
maxH="85vh"
|
maxH="85vh"
|
||||||
|
borderRadius={isMobile ? '12px' : '8px'}
|
||||||
bg="#1a1a1a"
|
bg="#1a1a1a"
|
||||||
borderColor="#404040"
|
border="2px solid #ffd700"
|
||||||
borderWidth="1px"
|
boxShadow="0 0 30px rgba(255, 215, 0, 0.5)"
|
||||||
>
|
>
|
||||||
<ModalHeader pb={3} borderBottomWidth="1px" borderColor="#404040">
|
<ModalHeader pb={isMobile ? 2 : 3} borderBottomWidth="1px" borderColor="#404040">
|
||||||
<VStack align="flex-start" spacing={1}>
|
<VStack align="flex-start" spacing={0}>
|
||||||
<HStack>
|
<HStack>
|
||||||
<Text fontSize="lg" fontWeight="bold" color="#e0e0e0">
|
<Text fontSize={isMobile ? 'md' : 'lg'} fontWeight="bold" color="#e0e0e0">
|
||||||
{stock.stock_name || stock.stock_code} ({stock.stock_code})
|
{stock.stock_name || stock.stock_code} ({stock.stock_code})
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
<Text fontSize="sm" color="#999">
|
<Text fontSize={isMobile ? 'xs' : 'sm'} color="#999">
|
||||||
分时走势图
|
分时走势图
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalCloseButton color="#999" _hover={{ color: '#e0e0e0' }} />
|
<ModalCloseButton color="#999" _hover={{ color: '#e0e0e0' }} />
|
||||||
<ModalBody p={4}>
|
<ModalBody p={isMobile ? 2 : 4}>
|
||||||
{error && (
|
{error && (
|
||||||
<Alert status="error" bg="#2a1a1a" borderColor="#ef5350" mb={4}>
|
<Alert status="error" bg="#2a1a1a" borderColor="#ef5350" mb={4}>
|
||||||
<AlertIcon color="#ef5350" />
|
<AlertIcon color="#ef5350" />
|
||||||
@@ -485,7 +497,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box position="relative" h="600px" w="100%">
|
<Box position="relative" h={isMobile ? '400px' : '600px'} w="100%">
|
||||||
{loading && (
|
{loading && (
|
||||||
<Flex
|
<Flex
|
||||||
position="absolute"
|
position="absolute"
|
||||||
|
|||||||
130
src/components/TradeDatePicker/index.tsx
Normal file
130
src/components/TradeDatePicker/index.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
HStack,
|
||||||
|
Input,
|
||||||
|
Text,
|
||||||
|
Icon,
|
||||||
|
Tooltip,
|
||||||
|
useColorModeValue,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { InfoIcon } from '@chakra-ui/icons';
|
||||||
|
import { FaCalendarAlt } from 'react-icons/fa';
|
||||||
|
|
||||||
|
export interface TradeDatePickerProps {
|
||||||
|
/** 当前选中的日期 */
|
||||||
|
value: Date | null;
|
||||||
|
/** 日期变化回调 */
|
||||||
|
onChange: (date: Date) => void;
|
||||||
|
/** 默认日期(组件初始化时使用) */
|
||||||
|
defaultDate?: Date;
|
||||||
|
/** 最新交易日期(用于显示提示) */
|
||||||
|
latestTradeDate?: Date | null;
|
||||||
|
/** 最大可选日期,默认今天 */
|
||||||
|
maxDate?: Date;
|
||||||
|
/** 标签文字,默认"交易日期" */
|
||||||
|
label?: string;
|
||||||
|
/** 输入框宽度 */
|
||||||
|
inputWidth?: string | object;
|
||||||
|
/** 是否显示标签图标 */
|
||||||
|
showIcon?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易日期选择器组件
|
||||||
|
*
|
||||||
|
* 提供日期输入框和最新交易日期提示,供概念中心、个股中心等页面复用。
|
||||||
|
* 快捷按钮(今天、昨天等)由各页面自行实现。
|
||||||
|
*/
|
||||||
|
const TradeDatePicker: React.FC<TradeDatePickerProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
defaultDate,
|
||||||
|
latestTradeDate,
|
||||||
|
maxDate,
|
||||||
|
label = '交易日期',
|
||||||
|
inputWidth = { base: '100%', lg: '200px' },
|
||||||
|
showIcon = true,
|
||||||
|
}) => {
|
||||||
|
// 颜色主题
|
||||||
|
const labelColor = useColorModeValue('purple.700', 'purple.300');
|
||||||
|
const iconColor = useColorModeValue('purple.500', 'purple.400');
|
||||||
|
const inputBorderColor = useColorModeValue('purple.200', 'purple.600');
|
||||||
|
const tipBg = useColorModeValue('blue.50', 'blue.900');
|
||||||
|
const tipBorderColor = useColorModeValue('blue.200', 'blue.600');
|
||||||
|
const tipTextColor = useColorModeValue('blue.600', 'blue.200');
|
||||||
|
const tipIconColor = useColorModeValue('blue.500', 'blue.300');
|
||||||
|
|
||||||
|
// 使用默认日期初始化(仅在 value 为 null 且有 defaultDate 时)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (value === null && defaultDate) {
|
||||||
|
onChange(defaultDate);
|
||||||
|
}
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// 处理日期变化
|
||||||
|
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const dateStr = e.target.value;
|
||||||
|
if (dateStr) {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
onChange(date);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化日期为 YYYY-MM-DD
|
||||||
|
const formatDateValue = (date: Date | null): string => {
|
||||||
|
if (!date) return '';
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算最大日期
|
||||||
|
const maxDateStr = maxDate
|
||||||
|
? formatDateValue(maxDate)
|
||||||
|
: new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 标签 */}
|
||||||
|
<HStack spacing={3}>
|
||||||
|
{showIcon && <Icon as={FaCalendarAlt} color={iconColor} boxSize={5} />}
|
||||||
|
<Text fontWeight="bold" color={labelColor}>
|
||||||
|
{label}:
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 日期输入框 */}
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formatDateValue(value)}
|
||||||
|
onChange={handleDateChange}
|
||||||
|
max={maxDateStr}
|
||||||
|
width={inputWidth}
|
||||||
|
focusBorderColor="purple.500"
|
||||||
|
borderColor={inputBorderColor}
|
||||||
|
borderRadius="lg"
|
||||||
|
fontWeight="medium"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 最新交易日期提示 */}
|
||||||
|
{latestTradeDate && (
|
||||||
|
<Tooltip label="数据库中最新的交易日期">
|
||||||
|
<HStack
|
||||||
|
spacing={2}
|
||||||
|
bg={tipBg}
|
||||||
|
px={3}
|
||||||
|
py={1.5}
|
||||||
|
borderRadius="full"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={tipBorderColor}
|
||||||
|
>
|
||||||
|
<Icon as={InfoIcon} color={tipIconColor} boxSize={3} />
|
||||||
|
<Text fontSize="sm" color={tipTextColor} fontWeight="medium">
|
||||||
|
最新: {latestTradeDate.toLocaleDateString('zh-CN')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TradeDatePicker;
|
||||||
1
src/data/tradingDays.json
Normal file
1
src/data/tradingDays.json
Normal file
File diff suppressed because one or more lines are too long
@@ -10,7 +10,7 @@ const WATCHLIST_PAGE_SIZE = 10;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 自选股管理 Hook
|
* 自选股管理 Hook
|
||||||
* 提供自选股加载、分页、移除等功能
|
* 提供自选股加载、分页、添加、移除等功能
|
||||||
*
|
*
|
||||||
* @returns {{
|
* @returns {{
|
||||||
* watchlistQuotes: Array,
|
* watchlistQuotes: Array,
|
||||||
@@ -19,7 +19,9 @@ const WATCHLIST_PAGE_SIZE = 10;
|
|||||||
* setWatchlistPage: Function,
|
* setWatchlistPage: Function,
|
||||||
* WATCHLIST_PAGE_SIZE: number,
|
* WATCHLIST_PAGE_SIZE: number,
|
||||||
* loadWatchlistQuotes: Function,
|
* loadWatchlistQuotes: Function,
|
||||||
* handleRemoveFromWatchlist: Function
|
* handleAddToWatchlist: Function,
|
||||||
|
* handleRemoveFromWatchlist: Function,
|
||||||
|
* isInWatchlist: Function
|
||||||
* }}
|
* }}
|
||||||
*/
|
*/
|
||||||
export const useWatchlist = () => {
|
export const useWatchlist = () => {
|
||||||
@@ -58,6 +60,32 @@ export const useWatchlist = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 添加到自选股
|
||||||
|
const handleAddToWatchlist = useCallback(async (stockCode, stockName) => {
|
||||||
|
try {
|
||||||
|
const base = getApiBase();
|
||||||
|
const resp = await fetch(base + '/api/account/watchlist', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ stock_code: stockCode, stock_name: stockName })
|
||||||
|
});
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
if (resp.ok && data.success) {
|
||||||
|
// 刷新自选股列表
|
||||||
|
loadWatchlistQuotes();
|
||||||
|
toast({ title: '已添加至自选股', status: 'success', duration: 1500 });
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast({ title: '添加失败', status: 'error', duration: 2000 });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: '网络错误,添加失败', status: 'error', duration: 2000 });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [toast, loadWatchlistQuotes]);
|
||||||
|
|
||||||
// 从自选股移除
|
// 从自选股移除
|
||||||
const handleRemoveFromWatchlist = useCallback(async (stockCode) => {
|
const handleRemoveFromWatchlist = useCallback(async (stockCode) => {
|
||||||
try {
|
try {
|
||||||
@@ -85,9 +113,20 @@ export const useWatchlist = () => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({ title: '网络错误,移除失败', status: 'error', duration: 2000 });
|
toast({ title: '网络错误,移除失败', status: 'error', duration: 2000 });
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}, [toast]);
|
}, [toast]);
|
||||||
|
|
||||||
|
// 判断股票是否在自选股中
|
||||||
|
const isInWatchlist = useCallback((stockCode) => {
|
||||||
|
const normalize6 = (code) => {
|
||||||
|
const m = String(code || '').match(/(\d{6})/);
|
||||||
|
return m ? m[1] : String(code || '');
|
||||||
|
};
|
||||||
|
const target = normalize6(stockCode);
|
||||||
|
return watchlistQuotes.some(item => normalize6(item.stock_code) === target);
|
||||||
|
}, [watchlistQuotes]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
watchlistQuotes,
|
watchlistQuotes,
|
||||||
watchlistLoading,
|
watchlistLoading,
|
||||||
@@ -95,6 +134,8 @@ export const useWatchlist = () => {
|
|||||||
setWatchlistPage,
|
setWatchlistPage,
|
||||||
WATCHLIST_PAGE_SIZE,
|
WATCHLIST_PAGE_SIZE,
|
||||||
loadWatchlistQuotes,
|
loadWatchlistQuotes,
|
||||||
handleRemoveFromWatchlist
|
handleAddToWatchlist,
|
||||||
|
handleRemoveFromWatchlist,
|
||||||
|
isInWatchlist
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,6 +61,20 @@ export const generateDailyData = (indexCode, days = 30) => {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算简单移动均价(用于分时图均价线)
|
||||||
|
* @param {Array} data - 已有数据
|
||||||
|
* @param {number} currentPrice - 当前价格
|
||||||
|
* @param {number} period - 均线周期(默认5)
|
||||||
|
* @returns {number} 均价
|
||||||
|
*/
|
||||||
|
function calculateAvgPrice(data, currentPrice, period = 5) {
|
||||||
|
const recentPrices = data.slice(-period).map(d => d.price || d.close);
|
||||||
|
recentPrices.push(currentPrice);
|
||||||
|
const sum = recentPrices.reduce((acc, p) => acc + p, 0);
|
||||||
|
return parseFloat((sum / recentPrices.length).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成时间范围内的数据
|
* 生成时间范围内的数据
|
||||||
*/
|
*/
|
||||||
@@ -80,6 +94,11 @@ function generateTimeRange(data, startTime, endTime, basePrice, session) {
|
|||||||
|
|
||||||
// ✅ 修复:为分时图添加完整的 OHLC 字段
|
// ✅ 修复:为分时图添加完整的 OHLC 字段
|
||||||
const closePrice = parseFloat(price.toFixed(2));
|
const closePrice = parseFloat(price.toFixed(2));
|
||||||
|
|
||||||
|
// 计算均价和涨跌幅
|
||||||
|
const avgPrice = calculateAvgPrice(data, closePrice);
|
||||||
|
const changePercent = parseFloat(((closePrice - basePrice) / basePrice * 100).toFixed(2));
|
||||||
|
|
||||||
data.push({
|
data.push({
|
||||||
time: formatTime(current),
|
time: formatTime(current),
|
||||||
timestamp: current.getTime(), // ✅ 新增:毫秒时间戳
|
timestamp: current.getTime(), // ✅ 新增:毫秒时间戳
|
||||||
@@ -88,6 +107,8 @@ function generateTimeRange(data, startTime, endTime, basePrice, session) {
|
|||||||
low: parseFloat((price * 0.9997).toFixed(2)), // ✅ 新增:最低价(略低于收盘)
|
low: parseFloat((price * 0.9997).toFixed(2)), // ✅ 新增:最低价(略低于收盘)
|
||||||
close: closePrice, // ✅ 保留:收盘价
|
close: closePrice, // ✅ 保留:收盘价
|
||||||
price: closePrice, // ✅ 保留:兼容字段(供 MiniTimelineChart 使用)
|
price: closePrice, // ✅ 保留:兼容字段(供 MiniTimelineChart 使用)
|
||||||
|
avg_price: avgPrice, // ✅ 新增:均价(供 TimelineChartModal 使用)
|
||||||
|
change_percent: changePercent, // ✅ 新增:涨跌幅(供 TimelineChartModal 使用)
|
||||||
volume: volume,
|
volume: volume,
|
||||||
prev_close: basePrice
|
prev_close: basePrice
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export const accountHandlers = [
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// 6. 添加自选股
|
// 6. 添加自选股
|
||||||
http.post('/api/account/watchlist/add', async ({ request }) => {
|
http.post('/api/account/watchlist', async ({ request }) => {
|
||||||
await delay(NETWORK_DELAY);
|
await delay(NETWORK_DELAY);
|
||||||
|
|
||||||
const currentUser = getCurrentUser();
|
const currentUser = getCurrentUser();
|
||||||
@@ -188,6 +188,22 @@ export const accountHandlers = [
|
|||||||
|
|
||||||
mockWatchlist.push(newItem);
|
mockWatchlist.push(newItem);
|
||||||
|
|
||||||
|
// 同步添加到 mockRealtimeQuotes(导航栏自选股菜单使用此数组)
|
||||||
|
mockRealtimeQuotes.push({
|
||||||
|
stock_code: stock_code,
|
||||||
|
stock_name: stock_name,
|
||||||
|
current_price: null,
|
||||||
|
change_percent: 0,
|
||||||
|
change: 0,
|
||||||
|
volume: 0,
|
||||||
|
turnover: 0,
|
||||||
|
high: 0,
|
||||||
|
low: 0,
|
||||||
|
open: 0,
|
||||||
|
prev_close: 0,
|
||||||
|
update_time: new Date().toTimeString().slice(0, 8)
|
||||||
|
});
|
||||||
|
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: '添加成功',
|
message: '添加成功',
|
||||||
@@ -210,9 +226,20 @@ export const accountHandlers = [
|
|||||||
const { id } = params;
|
const { id } = params;
|
||||||
console.log('[Mock] 删除自选股:', id);
|
console.log('[Mock] 删除自选股:', id);
|
||||||
|
|
||||||
const index = mockWatchlist.findIndex(item => item.id === parseInt(id));
|
// 支持按 stock_code 或 id 匹配删除
|
||||||
|
const index = mockWatchlist.findIndex(item =>
|
||||||
|
item.stock_code === id || item.id === parseInt(id)
|
||||||
|
);
|
||||||
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
|
const stockCode = mockWatchlist[index].stock_code;
|
||||||
mockWatchlist.splice(index, 1);
|
mockWatchlist.splice(index, 1);
|
||||||
|
|
||||||
|
// 同步从 mockRealtimeQuotes 移除
|
||||||
|
const quotesIndex = mockRealtimeQuotes.findIndex(item => item.stock_code === stockCode);
|
||||||
|
if (quotesIndex !== -1) {
|
||||||
|
mockRealtimeQuotes.splice(quotesIndex, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export const marketHandlers = [
|
|||||||
{ name: '机器人', desc: '人形机器人产业化加速,特斯拉、小米等巨头入局,产业链迎来发展机遇。' },
|
{ name: '机器人', desc: '人形机器人产业化加速,特斯拉、小米等巨头入局,产业链迎来发展机遇。' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 股票池
|
// 股票池(扩展到足够多的股票)
|
||||||
const stockPool = [
|
const stockPool = [
|
||||||
{ stock_code: '600519', stock_name: '贵州茅台' },
|
{ stock_code: '600519', stock_name: '贵州茅台' },
|
||||||
{ stock_code: '300750', stock_name: '宁德时代' },
|
{ stock_code: '300750', stock_name: '宁德时代' },
|
||||||
@@ -104,30 +104,102 @@ export const marketHandlers = [
|
|||||||
{ stock_code: '300274', stock_name: '阳光电源' },
|
{ stock_code: '300274', stock_name: '阳光电源' },
|
||||||
{ stock_code: '688981', stock_name: '中芯国际' },
|
{ stock_code: '688981', stock_name: '中芯国际' },
|
||||||
{ stock_code: '000725', stock_name: '京东方A' },
|
{ stock_code: '000725', stock_name: '京东方A' },
|
||||||
|
{ stock_code: '600036', stock_name: '招商银行' },
|
||||||
|
{ stock_code: '000858', stock_name: '五粮液' },
|
||||||
|
{ stock_code: '601166', stock_name: '兴业银行' },
|
||||||
|
{ stock_code: '600276', stock_name: '恒瑞医药' },
|
||||||
|
{ stock_code: '000333', stock_name: '美的集团' },
|
||||||
|
{ stock_code: '600887', stock_name: '伊利股份' },
|
||||||
|
{ stock_code: '002415', stock_name: '海康威视' },
|
||||||
|
{ stock_code: '601888', stock_name: '中国中免' },
|
||||||
|
{ stock_code: '300059', stock_name: '东方财富' },
|
||||||
|
{ stock_code: '002475', stock_name: '立讯精密' },
|
||||||
|
{ stock_code: '600900', stock_name: '长江电力' },
|
||||||
|
{ stock_code: '601398', stock_name: '工商银行' },
|
||||||
|
{ stock_code: '600030', stock_name: '中信证券' },
|
||||||
|
{ stock_code: '000568', stock_name: '泸州老窖' },
|
||||||
|
{ stock_code: '002352', stock_name: '顺丰控股' },
|
||||||
|
{ stock_code: '600809', stock_name: '山西汾酒' },
|
||||||
|
{ stock_code: '300015', stock_name: '爱尔眼科' },
|
||||||
|
{ stock_code: '002142', stock_name: '宁波银行' },
|
||||||
|
{ stock_code: '601899', stock_name: '紫金矿业' },
|
||||||
|
{ stock_code: '600309', stock_name: '万华化学' },
|
||||||
|
{ stock_code: '002304', stock_name: '洋河股份' },
|
||||||
|
{ stock_code: '600585', stock_name: '海螺水泥' },
|
||||||
|
{ stock_code: '601288', stock_name: '农业银行' },
|
||||||
|
{ stock_code: '600050', stock_name: '中国联通' },
|
||||||
|
{ stock_code: '000001', stock_name: '平安银行' },
|
||||||
|
{ stock_code: '601668', stock_name: '中国建筑' },
|
||||||
|
{ stock_code: '600028', stock_name: '中国石化' },
|
||||||
|
{ stock_code: '601857', stock_name: '中国石油' },
|
||||||
|
{ stock_code: '600000', stock_name: '浦发银行' },
|
||||||
|
{ stock_code: '601328', stock_name: '交通银行' },
|
||||||
|
{ stock_code: '000002', stock_name: '万科A' },
|
||||||
|
{ stock_code: '600104', stock_name: '上汽集团' },
|
||||||
|
{ stock_code: '601601', stock_name: '中国太保' },
|
||||||
|
{ stock_code: '600016', stock_name: '民生银行' },
|
||||||
|
{ stock_code: '601628', stock_name: '中国人寿' },
|
||||||
|
{ stock_code: '600031', stock_name: '三一重工' },
|
||||||
|
{ stock_code: '002230', stock_name: '科大讯飞' },
|
||||||
|
{ stock_code: '300124', stock_name: '汇川技术' },
|
||||||
|
{ stock_code: '002049', stock_name: '紫光国微' },
|
||||||
|
{ stock_code: '688012', stock_name: '中微公司' },
|
||||||
|
{ stock_code: '688008', stock_name: '澜起科技' },
|
||||||
|
{ stock_code: '603501', stock_name: '韦尔股份' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 生成历史触发时间
|
||||||
|
const generateHappenedTimes = (seed) => {
|
||||||
|
const times = [];
|
||||||
|
const count = 3 + (seed % 3); // 3-5个时间点
|
||||||
|
for (let k = 0; k < count; k++) {
|
||||||
|
const daysAgo = 30 + (seed * 7 + k * 11) % 330;
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - daysAgo);
|
||||||
|
times.push(d.toISOString().split('T')[0]);
|
||||||
|
}
|
||||||
|
return times.sort().reverse();
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchTypes = ['hybrid_knn', 'keyword', 'semantic'];
|
||||||
|
|
||||||
// 生成概念数据
|
// 生成概念数据
|
||||||
const concepts = [];
|
const concepts = [];
|
||||||
for (let i = 0; i < Math.min(limit, conceptPool.length); i++) {
|
for (let i = 0; i < Math.min(limit, conceptPool.length); i++) {
|
||||||
const concept = conceptPool[i];
|
const concept = conceptPool[i];
|
||||||
const changePercent = parseFloat((Math.random() * 8 - 1).toFixed(2)); // -1% ~ 7%
|
const changePercent = parseFloat((Math.random() * 8 - 1).toFixed(2)); // -1% ~ 7%
|
||||||
const stockCount = Math.floor(Math.random() * 40) + 20; // 20-60只股票
|
const stockCount = Math.floor(Math.random() * 20) + 15; // 15-35只股票
|
||||||
|
|
||||||
// 随机选取3-4只相关股票
|
// 生成与 stockCount 一致的股票列表(包含完整字段)
|
||||||
const relatedStocks = [];
|
const relatedStocks = [];
|
||||||
const stockIndices = new Set();
|
for (let j = 0; j < stockCount; j++) {
|
||||||
while (stockIndices.size < Math.min(4, stockPool.length)) {
|
const idx = (i * 7 + j) % stockPool.length;
|
||||||
stockIndices.add(Math.floor(Math.random() * stockPool.length));
|
const stock = stockPool[idx];
|
||||||
|
relatedStocks.push({
|
||||||
|
stock_code: stock.stock_code,
|
||||||
|
stock_name: stock.stock_name,
|
||||||
|
reason: `作为行业龙头企业,${stock.stock_name}在该领域具有核心竞争优势,市场份额领先。`,
|
||||||
|
change_pct: parseFloat((Math.random() * 15 - 5).toFixed(2)) // -5% ~ +10%
|
||||||
|
});
|
||||||
}
|
}
|
||||||
stockIndices.forEach(idx => relatedStocks.push(stockPool[idx]));
|
|
||||||
|
|
||||||
concepts.push({
|
concepts.push({
|
||||||
concept_id: `CONCEPT_${1001 + i}`,
|
concept_id: `CONCEPT_${1001 + i}`,
|
||||||
concept_name: concept.name,
|
concept: concept.name, // 原始字段名
|
||||||
change_percent: changePercent,
|
concept_name: concept.name, // 兼容字段名
|
||||||
stock_count: stockCount,
|
|
||||||
description: concept.desc,
|
description: concept.desc,
|
||||||
stocks: relatedStocks
|
stock_count: stockCount,
|
||||||
|
score: parseFloat((Math.random() * 5 + 3).toFixed(2)), // 3-8 分数
|
||||||
|
match_type: matchTypes[i % 3],
|
||||||
|
price_info: {
|
||||||
|
avg_change_pct: changePercent,
|
||||||
|
avg_price: parseFloat((Math.random() * 100 + 10).toFixed(2)),
|
||||||
|
total_market_cap: parseFloat((Math.random() * 1000 + 100).toFixed(2))
|
||||||
|
},
|
||||||
|
change_percent: changePercent, // 兼容字段
|
||||||
|
happened_times: generateHappenedTimes(i),
|
||||||
|
stocks: relatedStocks,
|
||||||
|
hot_score: Math.floor(Math.random() * 100)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -224,4 +224,59 @@ export const stockHandlers = [
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// 批量获取股票K线数据
|
||||||
|
http.post('/api/stock/batch-kline', async ({ request }) => {
|
||||||
|
await delay(400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { codes, type = 'timeline', event_time } = body;
|
||||||
|
|
||||||
|
console.log('[Mock Stock] 批量获取K线数据:', {
|
||||||
|
stockCount: codes?.length,
|
||||||
|
type,
|
||||||
|
eventTime: event_time
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!codes || !Array.isArray(codes) || codes.length === 0) {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ error: '股票代码列表不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每只股票生成数据
|
||||||
|
const batchData = {};
|
||||||
|
codes.forEach(stockCode => {
|
||||||
|
let data;
|
||||||
|
if (type === 'timeline') {
|
||||||
|
data = generateTimelineData('000001.SH');
|
||||||
|
} else if (type === 'daily') {
|
||||||
|
data = generateDailyData('000001.SH', 60);
|
||||||
|
} else {
|
||||||
|
data = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
batchData[stockCode] = {
|
||||||
|
success: true,
|
||||||
|
data: data,
|
||||||
|
stock_code: stockCode
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: batchData,
|
||||||
|
type: type,
|
||||||
|
message: '批量获取成功'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Mock Stock] 批量获取K线数据失败:', error);
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ error: '批量获取K线数据失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -93,6 +93,13 @@ const CompactSearchBox = ({
|
|||||||
loadStocks();
|
loadStocks();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 预加载行业数据(解决第一次点击无数据问题)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!industryData || industryData.length === 0) {
|
||||||
|
dispatch(fetchIndustryData());
|
||||||
|
}
|
||||||
|
}, [dispatch, industryData]);
|
||||||
|
|
||||||
// 初始化筛选条件
|
// 初始化筛选条件
|
||||||
const findIndustryPath = useCallback((targetCode, data, currentPath = []) => {
|
const findIndustryPath = useCallback((targetCode, data, currentPath = []) => {
|
||||||
if (!data || data.length === 0) return null;
|
if (!data || data.length === 0) return null;
|
||||||
|
|||||||
@@ -1,27 +1,24 @@
|
|||||||
// src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
|
// src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
|
||||||
// 动态新闻详情面板主组件(组装所有子组件)
|
// 动态新闻详情面板主组件(组装所有子组件)
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import React, { useState, useCallback, useEffect, useReducer } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
Box,
|
|
||||||
Card,
|
Card,
|
||||||
CardBody,
|
CardBody,
|
||||||
VStack,
|
VStack,
|
||||||
HStack,
|
|
||||||
Text,
|
Text,
|
||||||
Spinner,
|
Spinner,
|
||||||
Center,
|
Center,
|
||||||
Wrap,
|
Wrap,
|
||||||
WrapItem,
|
WrapItem,
|
||||||
useColorModeValue,
|
Box,
|
||||||
useToast,
|
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
import { getImportanceConfig } from '@constants/importanceLevels';
|
||||||
import { eventService } from '../../../../services/eventService';
|
import { eventService } from '@services/eventService';
|
||||||
import { useEventStocks } from '../StockDetailPanel/hooks/useEventStocks';
|
import { useEventStocks } from '../StockDetailPanel/hooks/useEventStocks';
|
||||||
import { toggleEventFollow, selectEventFollowStatus } from '../../../../store/slices/communityDataSlice';
|
import { toggleEventFollow, selectEventFollowStatus } from '@store/slices/communityDataSlice';
|
||||||
import { useAuth } from '../../../../contexts/AuthContext';
|
import { useAuth } from '@contexts/AuthContext';
|
||||||
import EventHeaderInfo from './EventHeaderInfo';
|
import EventHeaderInfo from './EventHeaderInfo';
|
||||||
import CompactMetaBar from './CompactMetaBar';
|
import CompactMetaBar from './CompactMetaBar';
|
||||||
import EventDescriptionSection from './EventDescriptionSection';
|
import EventDescriptionSection from './EventDescriptionSection';
|
||||||
@@ -29,12 +26,56 @@ import RelatedConceptsSection from './RelatedConceptsSection';
|
|||||||
import RelatedStocksSection from './RelatedStocksSection';
|
import RelatedStocksSection from './RelatedStocksSection';
|
||||||
import CompactStockItem from './CompactStockItem';
|
import CompactStockItem from './CompactStockItem';
|
||||||
import CollapsibleSection from './CollapsibleSection';
|
import CollapsibleSection from './CollapsibleSection';
|
||||||
import HistoricalEvents from '../../../EventDetail/components/HistoricalEvents';
|
import HistoricalEvents from '@views/EventDetail/components/HistoricalEvents';
|
||||||
import TransmissionChainAnalysis from '../../../EventDetail/components/TransmissionChainAnalysis';
|
import TransmissionChainAnalysis from '@views/EventDetail/components/TransmissionChainAnalysis';
|
||||||
import SubscriptionBadge from '../../../../components/SubscriptionBadge';
|
import SubscriptionBadge from '@components/SubscriptionBadge';
|
||||||
import SubscriptionUpgradeModal from '../../../../components/SubscriptionUpgradeModal';
|
import SubscriptionUpgradeModal from '@components/SubscriptionUpgradeModal';
|
||||||
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
|
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
|
||||||
import EventCommentSection from '../../../../components/EventCommentSection';
|
import { useWatchlist } from '@hooks/useWatchlist';
|
||||||
|
import EventCommentSection from '@components/EventCommentSection';
|
||||||
|
|
||||||
|
// 折叠区块状态管理 - 使用 useReducer 整合
|
||||||
|
const initialSectionState = {
|
||||||
|
stocks: { isOpen: true, hasLoaded: false, hasLoadedQuotes: false },
|
||||||
|
concepts: { isOpen: false },
|
||||||
|
historical: { isOpen: false, hasLoaded: false },
|
||||||
|
transmission: { isOpen: false, hasLoaded: false }
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionReducer = (state, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'TOGGLE':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[action.section]: {
|
||||||
|
...state[action.section],
|
||||||
|
isOpen: !state[action.section].isOpen
|
||||||
|
}
|
||||||
|
};
|
||||||
|
case 'SET_LOADED':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[action.section]: {
|
||||||
|
...state[action.section],
|
||||||
|
hasLoaded: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
case 'SET_QUOTES_LOADED':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
stocks: { ...state.stocks, hasLoadedQuotes: true }
|
||||||
|
};
|
||||||
|
case 'RESET_ALL':
|
||||||
|
return {
|
||||||
|
stocks: { isOpen: true, hasLoaded: false, hasLoadedQuotes: false },
|
||||||
|
concepts: { isOpen: false },
|
||||||
|
historical: { isOpen: false, hasLoaded: false },
|
||||||
|
transmission: { isOpen: false, hasLoaded: false }
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 动态新闻详情面板主组件
|
* 动态新闻详情面板主组件
|
||||||
@@ -48,7 +89,14 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
|||||||
const cardBg = PROFESSIONAL_COLORS.background.card;
|
const cardBg = PROFESSIONAL_COLORS.background.card;
|
||||||
const borderColor = PROFESSIONAL_COLORS.border.default;
|
const borderColor = PROFESSIONAL_COLORS.border.default;
|
||||||
const textColor = PROFESSIONAL_COLORS.text.secondary;
|
const textColor = PROFESSIONAL_COLORS.text.secondary;
|
||||||
const toast = useToast();
|
|
||||||
|
// 使用 useWatchlist Hook 管理自选股
|
||||||
|
const {
|
||||||
|
handleAddToWatchlist,
|
||||||
|
handleRemoveFromWatchlist,
|
||||||
|
isInWatchlist,
|
||||||
|
loadWatchlistQuotes
|
||||||
|
} = useWatchlist();
|
||||||
|
|
||||||
// 获取用户会员等级(修复:字段名从 subscription_tier 改为 subscription_type)
|
// 获取用户会员等级(修复:字段名从 subscription_tier 改为 subscription_type)
|
||||||
const userTier = user?.subscription_type || 'free';
|
const userTier = user?.subscription_type || 'free';
|
||||||
@@ -101,11 +149,6 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
|||||||
const response = await eventService.getEventDetail(event.id);
|
const response = await eventService.getEventDetail(event.id);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setFullEventDetail(response.data);
|
setFullEventDetail(response.data);
|
||||||
console.log('%c📊 [浏览量] 事件详情加载成功', 'color: #10B981; font-weight: bold;', {
|
|
||||||
eventId: event.id,
|
|
||||||
viewCount: response.data.view_count,
|
|
||||||
title: response.data.title
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[DynamicNewsDetailPanel] loadEventDetail 失败:', error, {
|
console.error('[DynamicNewsDetailPanel] loadEventDetail 失败:', error, {
|
||||||
@@ -122,30 +165,8 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
|||||||
const canAccessHistorical = hasAccess('pro');
|
const canAccessHistorical = hasAccess('pro');
|
||||||
const canAccessTransmission = hasAccess('max');
|
const canAccessTransmission = hasAccess('max');
|
||||||
|
|
||||||
// 子区块折叠状态管理 + 加载追踪
|
// 子区块折叠状态管理 - 使用 useReducer 整合
|
||||||
// 相关股票默认展开
|
const [sectionState, dispatchSection] = useReducer(sectionReducer, initialSectionState);
|
||||||
const [isStocksOpen, setIsStocksOpen] = useState(true);
|
|
||||||
const [hasLoadedStocks, setHasLoadedStocks] = useState(false); // 股票列表是否已加载(获取数量)
|
|
||||||
const [hasLoadedQuotes, setHasLoadedQuotes] = useState(false); // 行情数据是否已加载
|
|
||||||
|
|
||||||
const [isConceptsOpen, setIsConceptsOpen] = useState(false);
|
|
||||||
|
|
||||||
// 历史事件默认折叠,但预加载数量
|
|
||||||
const [isHistoricalOpen, setIsHistoricalOpen] = useState(false);
|
|
||||||
const [hasLoadedHistorical, setHasLoadedHistorical] = useState(false);
|
|
||||||
|
|
||||||
const [isTransmissionOpen, setIsTransmissionOpen] = useState(false);
|
|
||||||
const [hasLoadedTransmission, setHasLoadedTransmission] = useState(false);
|
|
||||||
|
|
||||||
// 自选股管理(使用 localStorage)
|
|
||||||
const [watchlistSet, setWatchlistSet] = useState(() => {
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem('stock_watchlist');
|
|
||||||
return saved ? new Set(JSON.parse(saved)) : new Set();
|
|
||||||
} catch {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 锁定点击处理 - 弹出升级弹窗
|
// 锁定点击处理 - 弹出升级弹窗
|
||||||
const handleLockedClick = useCallback((featureName, requiredLevel) => {
|
const handleLockedClick = useCallback((featureName, requiredLevel) => {
|
||||||
@@ -166,87 +187,62 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 相关股票 - 展开时加载行情(需要 PRO 权限)
|
// 相关股票 - 展开时加载行情(需要 PRO 权限)
|
||||||
// 股票列表在事件切换时预加载(显示数量),行情在展开时才加载
|
|
||||||
const handleStocksToggle = useCallback(() => {
|
const handleStocksToggle = useCallback(() => {
|
||||||
const newState = !isStocksOpen;
|
const willOpen = !sectionState.stocks.isOpen;
|
||||||
setIsStocksOpen(newState);
|
dispatchSection({ type: 'TOGGLE', section: 'stocks' });
|
||||||
|
|
||||||
// 展开时加载行情数据(如果还没加载过)
|
// 展开时加载行情数据(如果还没加载过)
|
||||||
if (newState && !hasLoadedQuotes && stocks.length > 0) {
|
if (willOpen && !sectionState.stocks.hasLoadedQuotes && stocks.length > 0) {
|
||||||
console.log('%c📈 [相关股票] 首次展开,加载行情数据', 'color: #10B981; font-weight: bold;', {
|
|
||||||
eventId: event?.id,
|
|
||||||
stockCount: stocks.length
|
|
||||||
});
|
|
||||||
refreshQuotes();
|
refreshQuotes();
|
||||||
setHasLoadedQuotes(true);
|
dispatchSection({ type: 'SET_QUOTES_LOADED' });
|
||||||
}
|
}
|
||||||
}, [isStocksOpen, hasLoadedQuotes, stocks.length, refreshQuotes, event?.id]);
|
}, [sectionState.stocks, stocks.length, refreshQuotes]);
|
||||||
|
|
||||||
// 相关概念 - 展开/收起(无需加载)
|
// 相关概念 - 展开/收起(无需加载)
|
||||||
const handleConceptsToggle = useCallback(() => {
|
const handleConceptsToggle = useCallback(() => {
|
||||||
setIsConceptsOpen(!isConceptsOpen);
|
dispatchSection({ type: 'TOGGLE', section: 'concepts' });
|
||||||
}, [isConceptsOpen]);
|
}, []);
|
||||||
|
|
||||||
// 历史事件对比 - 数据已预加载,只需切换展开状态
|
// 历史事件对比 - 数据已预加载,只需切换展开状态
|
||||||
const handleHistoricalToggle = useCallback(() => {
|
const handleHistoricalToggle = useCallback(() => {
|
||||||
const newState = !isHistoricalOpen;
|
dispatchSection({ type: 'TOGGLE', section: 'historical' });
|
||||||
setIsHistoricalOpen(newState);
|
}, []);
|
||||||
|
|
||||||
// 数据已在事件切换时预加载,这里只需展开
|
|
||||||
if (newState) {
|
|
||||||
console.log('%c📜 [历史事件] 展开(数据已预加载)', 'color: #3B82F6; font-weight: bold;', {
|
|
||||||
eventId: event?.id,
|
|
||||||
count: historicalEvents?.length || 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isHistoricalOpen, event?.id, historicalEvents?.length]);
|
|
||||||
|
|
||||||
// 传导链分析 - 展开时加载
|
// 传导链分析 - 展开时加载
|
||||||
const handleTransmissionToggle = useCallback(() => {
|
const handleTransmissionToggle = useCallback(() => {
|
||||||
const newState = !isTransmissionOpen;
|
const willOpen = !sectionState.transmission.isOpen;
|
||||||
setIsTransmissionOpen(newState);
|
dispatchSection({ type: 'TOGGLE', section: 'transmission' });
|
||||||
|
|
||||||
if (newState && !hasLoadedTransmission) {
|
if (willOpen && !sectionState.transmission.hasLoaded) {
|
||||||
console.log('%c🔗 [传导链] 首次展开,加载传导链数据', 'color: #8B5CF6; font-weight: bold;', { eventId: event?.id });
|
|
||||||
loadChainAnalysis();
|
loadChainAnalysis();
|
||||||
setHasLoadedTransmission(true);
|
dispatchSection({ type: 'SET_LOADED', section: 'transmission' });
|
||||||
}
|
}
|
||||||
}, [isTransmissionOpen, hasLoadedTransmission, loadChainAnalysis, event?.id]);
|
}, [sectionState.transmission, loadChainAnalysis]);
|
||||||
|
|
||||||
// 事件切换时重置所有子模块状态
|
// 事件切换时重置所有子模块状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('%c🔄 [事件切换] 重置所有子模块状态', 'color: #F59E0B; font-weight: bold;', { eventId: event?.id });
|
// 加载事件详情(增加浏览量)
|
||||||
|
|
||||||
// 🎯 加载事件详情(增加浏览量)
|
|
||||||
loadEventDetail();
|
loadEventDetail();
|
||||||
|
|
||||||
// 重置所有加载状态
|
// 加载自选股数据(用于判断股票是否已关注)
|
||||||
setHasLoadedStocks(false);
|
loadWatchlistQuotes();
|
||||||
setHasLoadedQuotes(false); // 重置行情加载状态
|
|
||||||
setHasLoadedHistorical(false);
|
// 重置所有折叠区块状态
|
||||||
setHasLoadedTransmission(false);
|
dispatchSection({ type: 'RESET_ALL' });
|
||||||
|
|
||||||
// 相关股票默认展开,预加载股票列表和行情数据
|
// 相关股票默认展开,预加载股票列表和行情数据
|
||||||
setIsStocksOpen(true);
|
|
||||||
if (canAccessStocks) {
|
if (canAccessStocks) {
|
||||||
console.log('%c📊 [相关股票] 事件切换,预加载股票列表和行情数据', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
|
|
||||||
loadStocksData();
|
loadStocksData();
|
||||||
setHasLoadedStocks(true);
|
dispatchSection({ type: 'SET_LOADED', section: 'stocks' });
|
||||||
// 由于默认展开,直接加载行情数据
|
dispatchSection({ type: 'SET_QUOTES_LOADED' });
|
||||||
setHasLoadedQuotes(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 历史事件默认折叠,但预加载数据(显示数量吸引点击)
|
// 历史事件默认折叠,但预加载数据(显示数量吸引点击)
|
||||||
setIsHistoricalOpen(false);
|
|
||||||
if (canAccessHistorical) {
|
if (canAccessHistorical) {
|
||||||
console.log('%c📜 [历史事件] 事件切换,预加载历史事件(获取数量)', 'color: #3B82F6; font-weight: bold;', { eventId: event?.id });
|
|
||||||
loadHistoricalData();
|
loadHistoricalData();
|
||||||
setHasLoadedHistorical(true);
|
dispatchSection({ type: 'SET_LOADED', section: 'historical' });
|
||||||
}
|
}
|
||||||
|
}, [event?.id, canAccessStocks, canAccessHistorical, userTier, loadStocksData, loadHistoricalData, loadEventDetail, loadWatchlistQuotes]);
|
||||||
setIsConceptsOpen(false);
|
|
||||||
setIsTransmissionOpen(false);
|
|
||||||
}, [event?.id, canAccessStocks, canAccessHistorical, userTier, loadStocksData, loadHistoricalData, loadEventDetail]);
|
|
||||||
|
|
||||||
// 切换关注状态
|
// 切换关注状态
|
||||||
const handleToggleFollow = useCallback(async () => {
|
const handleToggleFollow = useCallback(async () => {
|
||||||
@@ -254,42 +250,14 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
|||||||
dispatch(toggleEventFollow(event.id));
|
dispatch(toggleEventFollow(event.id));
|
||||||
}, [dispatch, event?.id]);
|
}, [dispatch, event?.id]);
|
||||||
|
|
||||||
// 切换自选股
|
// 切换自选股(使用 useWatchlist Hook)
|
||||||
const handleWatchlistToggle = useCallback(async (stockCode, isInWatchlist) => {
|
const handleWatchlistToggle = useCallback(async (stockCode, stockName, currentlyInWatchlist) => {
|
||||||
try {
|
if (currentlyInWatchlist) {
|
||||||
const newWatchlist = new Set(watchlistSet);
|
await handleRemoveFromWatchlist(stockCode);
|
||||||
|
|
||||||
if (isInWatchlist) {
|
|
||||||
newWatchlist.delete(stockCode);
|
|
||||||
toast({
|
|
||||||
title: '已移除自选股',
|
|
||||||
status: 'info',
|
|
||||||
duration: 2000,
|
|
||||||
isClosable: true,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
newWatchlist.add(stockCode);
|
await handleAddToWatchlist(stockCode, stockName);
|
||||||
toast({
|
|
||||||
title: '已添加至自选股',
|
|
||||||
status: 'success',
|
|
||||||
duration: 2000,
|
|
||||||
isClosable: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
}, [handleAddToWatchlist, handleRemoveFromWatchlist]);
|
||||||
setWatchlistSet(newWatchlist);
|
|
||||||
localStorage.setItem('stock_watchlist', JSON.stringify(Array.from(newWatchlist)));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('切换自选股失败:', error);
|
|
||||||
toast({
|
|
||||||
title: '操作失败',
|
|
||||||
description: error.message,
|
|
||||||
status: 'error',
|
|
||||||
duration: 3000,
|
|
||||||
isClosable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [watchlistSet, toast]);
|
|
||||||
|
|
||||||
// 空状态
|
// 空状态
|
||||||
if (!event) {
|
if (!event) {
|
||||||
@@ -338,15 +306,10 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
|||||||
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 - 支持精简/详细模式 */}
|
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 - 支持精简/详细模式 */}
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
title="相关股票"
|
title="相关股票"
|
||||||
isOpen={isStocksOpen}
|
isOpen={sectionState.stocks.isOpen}
|
||||||
onToggle={handleStocksToggle}
|
onToggle={handleStocksToggle}
|
||||||
count={stocks?.length || 0}
|
count={stocks?.length || 0}
|
||||||
subscriptionBadge={(() => {
|
subscriptionBadge={!canAccessStocks ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
||||||
if (!canAccessStocks) {
|
|
||||||
return <SubscriptionBadge tier="pro" size="sm" />;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
isLocked={!canAccessStocks}
|
isLocked={!canAccessStocks}
|
||||||
onLockedClick={() => handleLockedClick('相关股票', 'pro')}
|
onLockedClick={() => handleLockedClick('相关股票', 'pro')}
|
||||||
showModeToggle={canAccessStocks}
|
showModeToggle={canAccessStocks}
|
||||||
@@ -381,7 +344,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
|||||||
stocks={stocks}
|
stocks={stocks}
|
||||||
quotes={quotes}
|
quotes={quotes}
|
||||||
eventTime={event.created_at}
|
eventTime={event.created_at}
|
||||||
watchlistSet={watchlistSet}
|
isInWatchlist={isInWatchlist}
|
||||||
onWatchlistToggle={handleWatchlistToggle}
|
onWatchlistToggle={handleWatchlistToggle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -392,7 +355,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
|||||||
eventTitle={event.title}
|
eventTitle={event.title}
|
||||||
effectiveTradingDate={event.trading_date || event.created_at}
|
effectiveTradingDate={event.trading_date || event.created_at}
|
||||||
eventTime={event.created_at}
|
eventTime={event.created_at}
|
||||||
isOpen={isConceptsOpen}
|
isOpen={sectionState.concepts.isOpen}
|
||||||
onToggle={handleConceptsToggle}
|
onToggle={handleConceptsToggle}
|
||||||
subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
||||||
isLocked={!canAccessConcepts}
|
isLocked={!canAccessConcepts}
|
||||||
@@ -402,7 +365,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
|||||||
{/* 历史事件对比(可折叠) - 懒加载 - 需要 PRO 权限 */}
|
{/* 历史事件对比(可折叠) - 懒加载 - 需要 PRO 权限 */}
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
title="历史事件对比"
|
title="历史事件对比"
|
||||||
isOpen={isHistoricalOpen}
|
isOpen={sectionState.historical.isOpen}
|
||||||
onToggle={handleHistoricalToggle}
|
onToggle={handleHistoricalToggle}
|
||||||
count={historicalEvents?.length || 0}
|
count={historicalEvents?.length || 0}
|
||||||
subscriptionBadge={!canAccessHistorical ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
subscriptionBadge={!canAccessHistorical ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
||||||
@@ -425,7 +388,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
|||||||
{/* 传导链分析(可折叠) - 懒加载 - 需要 MAX 权限 */}
|
{/* 传导链分析(可折叠) - 懒加载 - 需要 MAX 权限 */}
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
title="传导链分析"
|
title="传导链分析"
|
||||||
isOpen={isTransmissionOpen}
|
isOpen={sectionState.transmission.isOpen}
|
||||||
onToggle={handleTransmissionToggle}
|
onToggle={handleTransmissionToggle}
|
||||||
subscriptionBadge={!canAccessTransmission ? <SubscriptionBadge tier="max" size="sm" /> : null}
|
subscriptionBadge={!canAccessTransmission ? <SubscriptionBadge tier="max" size="sm" /> : null}
|
||||||
isLocked={!canAccessTransmission}
|
isLocked={!canAccessTransmission}
|
||||||
@@ -453,7 +416,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
|||||||
featureName={upgradeModal.featureName}
|
featureName={upgradeModal.featureName}
|
||||||
currentLevel={userTier}
|
currentLevel={userTier}
|
||||||
/>
|
/>
|
||||||
): null }
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ import { logger } from '../../../../utils/logger';
|
|||||||
* @param {Array<Object>} props.stocks - 股票数组
|
* @param {Array<Object>} props.stocks - 股票数组
|
||||||
* @param {Object} props.quotes - 股票行情字典 { [stockCode]: { change: number } }
|
* @param {Object} props.quotes - 股票行情字典 { [stockCode]: { change: number } }
|
||||||
* @param {string} props.eventTime - 事件时间
|
* @param {string} props.eventTime - 事件时间
|
||||||
* @param {Set} props.watchlistSet - 自选股代码集合
|
* @param {Function} props.isInWatchlist - 检查股票是否在自选股中的函数
|
||||||
* @param {Function} props.onWatchlistToggle - 切换自选股回调
|
* @param {Function} props.onWatchlistToggle - 切换自选股回调
|
||||||
*/
|
*/
|
||||||
const RelatedStocksSection = ({
|
const RelatedStocksSection = ({
|
||||||
stocks,
|
stocks,
|
||||||
quotes = {},
|
quotes = {},
|
||||||
eventTime = null,
|
eventTime = null,
|
||||||
watchlistSet = new Set(),
|
isInWatchlist = () => false,
|
||||||
onWatchlistToggle
|
onWatchlistToggle
|
||||||
}) => {
|
}) => {
|
||||||
// 分时图数据状态:{ [stockCode]: data[] }
|
// 分时图数据状态:{ [stockCode]: data[] }
|
||||||
@@ -167,7 +167,7 @@ const RelatedStocksSection = ({
|
|||||||
stock={stock}
|
stock={stock}
|
||||||
quote={quotes[stock.stock_code]}
|
quote={quotes[stock.stock_code]}
|
||||||
eventTime={eventTime}
|
eventTime={eventTime}
|
||||||
isInWatchlist={watchlistSet.has(stock.stock_code)}
|
isInWatchlist={isInWatchlist(stock.stock_code)}
|
||||||
onWatchlistToggle={onWatchlistToggle}
|
onWatchlistToggle={onWatchlistToggle}
|
||||||
timelineData={timelineDataMap[stock.stock_code]}
|
timelineData={timelineDataMap[stock.stock_code]}
|
||||||
timelineLoading={shouldShowTimelineLoading && !timelineDataMap[stock.stock_code]}
|
timelineLoading={shouldShowTimelineLoading && !timelineDataMap[stock.stock_code]}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ const StockListItem = ({
|
|||||||
|
|
||||||
const handleWatchlistClick = (e) => {
|
const handleWatchlistClick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onWatchlistToggle?.(stock.stock_code, isInWatchlist);
|
onWatchlistToggle?.(stock.stock_code, stock.stock_name, isInWatchlist);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 格式化涨跌幅显示
|
// 格式化涨跌幅显示
|
||||||
|
|||||||
@@ -1,36 +1,8 @@
|
|||||||
.event-detail-modal {
|
// 事件详情抽屉样式(从底部弹出)
|
||||||
top: 20% !important;
|
// 注意:大部分样式已在 TSX 的 styles 属性中配置,这里只保留必要的覆盖
|
||||||
margin: 0 auto !important;
|
.event-detail-drawer {
|
||||||
padding-bottom: 0 !important;
|
// 标题样式
|
||||||
|
.ant-drawer-title {
|
||||||
.ant-modal-content {
|
|
||||||
border-radius: 24px !important;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标题样式 - 深色文字(白色背景)
|
|
||||||
.ant-modal-title {
|
|
||||||
color: #1A202C;
|
color: #1A202C;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭按钮样式 - 深色(白色背景)
|
|
||||||
.ant-modal-close {
|
|
||||||
color: #4A5568;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #1A202C;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 自底向上滑入动画
|
|
||||||
@keyframes slideUp {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { Modal } from 'antd';
|
import { Drawer } from 'antd';
|
||||||
|
import { CloseOutlined } from '@ant-design/icons';
|
||||||
import { selectIsMobile } from '@store/slices/deviceSlice';
|
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||||
import DynamicNewsDetailPanel from './DynamicNewsDetail/DynamicNewsDetailPanel';
|
import DynamicNewsDetailPanel from './DynamicNewsDetail/DynamicNewsDetailPanel';
|
||||||
import './EventDetailModal.less';
|
import './EventDetailModal.less';
|
||||||
@@ -15,7 +16,7 @@ interface EventDetailModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 事件详情弹窗组件
|
* 事件详情抽屉组件(从底部弹出)
|
||||||
*/
|
*/
|
||||||
const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
||||||
open,
|
open,
|
||||||
@@ -25,23 +26,35 @@ const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
|||||||
const isMobile = useSelector(selectIsMobile);
|
const isMobile = useSelector(selectIsMobile);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Drawer
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onClose={onClose}
|
||||||
footer={null}
|
placement="bottom"
|
||||||
|
height={isMobile ? 'calc(100vh - 60px)' : 'calc(100vh - 100px)'}
|
||||||
|
width={isMobile ? '100%' : '70vw'}
|
||||||
title={event?.title || '事件详情'}
|
title={event?.title || '事件详情'}
|
||||||
width='100vw'
|
destroyOnHidden
|
||||||
destroyOnClose
|
rootClassName="event-detail-drawer"
|
||||||
className="event-detail-modal"
|
closeIcon={null}
|
||||||
|
extra={
|
||||||
|
<CloseOutlined
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ cursor: 'pointer', fontSize: 16, color: '#4A5568' }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
styles={{
|
styles={{
|
||||||
mask: { background: 'transparent' },
|
wrapper: isMobile ? {} : {
|
||||||
content: { borderRadius: 24, padding: 0, maxWidth: 1400, background: 'transparent', margin: '0 auto' },
|
maxWidth: 1400,
|
||||||
header: { background: '#FFFFFF', borderBottom: '1px solid #E2E8F0', padding: '16px 24px', borderRadius: '24px 24px 0 0', margin: 0 },
|
margin: '0 auto',
|
||||||
body: { padding: 0 },
|
borderRadius: '16px 16px 0 0',
|
||||||
|
},
|
||||||
|
content: { borderRadius: '16px 16px 0 0' },
|
||||||
|
header: { background: '#FFFFFF', borderBottom: '1px solid #E2E8F0', padding: '16px 24px' },
|
||||||
|
body: { padding: 0, background: '#FFFFFF' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{event && <DynamicNewsDetailPanel event={event} showHeader={false} />}
|
{event && <DynamicNewsDetailPanel event={event} showHeader={false} />}
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Card, Badge, Tag, Empty, Carousel, Tooltip } from 'antd';
|
import { Card, Badge, Tag, Empty, Carousel, Tooltip } from 'antd';
|
||||||
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
import { useDisclosure } from '@chakra-ui/react';
|
import { useDisclosure, useBreakpointValue } from '@chakra-ui/react';
|
||||||
import EventDetailModal from './EventDetailModal';
|
import EventDetailModal from './EventDetailModal';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import './HotEvents.css';
|
import './HotEvents.css';
|
||||||
@@ -31,6 +31,8 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
|
|||||||
const [currentSlide, setCurrentSlide] = useState(0);
|
const [currentSlide, setCurrentSlide] = useState(0);
|
||||||
const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure();
|
const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure();
|
||||||
const [modalEvent, setModalEvent] = useState(null);
|
const [modalEvent, setModalEvent] = useState(null);
|
||||||
|
// H5 端不显示 Tooltip(避免触摸触发后无法消除的黑色悬浮框)
|
||||||
|
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||||
|
|
||||||
const renderPriceChange = (value) => {
|
const renderPriceChange = (value) => {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
@@ -154,21 +156,33 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
|
|||||||
>
|
>
|
||||||
{/* Custom layout without Card.Meta */}
|
{/* Custom layout without Card.Meta */}
|
||||||
<div className="event-header">
|
<div className="event-header">
|
||||||
|
{isMobile ? (
|
||||||
|
<span className="event-title">
|
||||||
|
{event.title}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
<Tooltip title={event.title}>
|
<Tooltip title={event.title}>
|
||||||
<span className="event-title">
|
<span className="event-title">
|
||||||
{event.title}
|
{event.title}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
<span className="event-tag">
|
<span className="event-tag">
|
||||||
{renderPriceChange(event.related_avg_chg)}
|
{renderPriceChange(event.related_avg_chg)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isMobile ? (
|
||||||
|
<div className="event-description">
|
||||||
|
{event.description}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Tooltip title={event.description}>
|
<Tooltip title={event.description}>
|
||||||
<div className="event-description">
|
<div className="event-description">
|
||||||
{event.description}
|
{event.description}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="event-footer">
|
<div className="event-footer">
|
||||||
<span className="creator">{event.creator?.username || 'Anonymous'}</span>
|
<span className="creator">{event.creator?.username || 'Anonymous'}</span>
|
||||||
|
|||||||
@@ -107,28 +107,6 @@ const Community = () => {
|
|||||||
}
|
}
|
||||||
}, [events, loading, pagination, filters]);
|
}, [events, loading, pagination, filters]);
|
||||||
|
|
||||||
// ⚡ 首次进入页面时滚动到内容区域(考虑导航栏高度)
|
|
||||||
const hasScrolled = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
// 只在第一次挂载时执行滚动
|
|
||||||
if (hasScrolled.current) return;
|
|
||||||
|
|
||||||
// 延迟执行,确保DOM已完全渲染
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (containerRef.current) {
|
|
||||||
hasScrolled.current = true;
|
|
||||||
// 滚动到容器顶部,自动考虑导航栏的高度
|
|
||||||
containerRef.current.scrollIntoView({
|
|
||||||
behavior: 'auto',
|
|
||||||
block: 'start',
|
|
||||||
inline: 'nearest'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, []); // 空依赖数组,只在组件挂载时执行一次
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⚡ 【核心逻辑】注册 Socket 新事件回调 - 当收到新事件时智能刷新列表
|
* ⚡ 【核心逻辑】注册 Socket 新事件回调 - 当收到新事件时智能刷新列表
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
useDisclosure,
|
useDisclosure,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
useBreakpointValue,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import {
|
import {
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
@@ -111,6 +112,9 @@ const ConceptTimelineModal = ({
|
|||||||
const [selectedNews, setSelectedNews] = useState(null);
|
const [selectedNews, setSelectedNews] = useState(null);
|
||||||
const [isNewsModalOpen, setIsNewsModalOpen] = useState(false);
|
const [isNewsModalOpen, setIsNewsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// 响应式配置
|
||||||
|
const isMobile = useBreakpointValue({ base: true, md: false }, { fallback: 'md' });
|
||||||
|
|
||||||
// 辅助函数:格式化日期显示(包含年份)
|
// 辅助函数:格式化日期显示(包含年份)
|
||||||
const formatDateDisplay = (dateStr) => {
|
const formatDateDisplay = (dateStr) => {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
@@ -602,37 +606,41 @@ const ConceptTimelineModal = ({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size="full"
|
size="full"
|
||||||
scrollBehavior="inside"
|
scrollBehavior="inside"
|
||||||
|
isCentered
|
||||||
>
|
>
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
<ModalContent maxW="1400px" m={4}>
|
<ModalContent maxW="1400px" m={{ base: 0, md: 'auto' }} mx="auto">
|
||||||
<ModalHeader
|
<ModalHeader
|
||||||
bgGradient="linear(135deg, purple.600 0%, purple.500 50%, pink.500 100%)"
|
bgGradient="linear(135deg, purple.600 0%, purple.500 50%, pink.500 100%)"
|
||||||
color="white"
|
color="white"
|
||||||
position="sticky"
|
position="sticky"
|
||||||
top={0}
|
top={0}
|
||||||
zIndex={10}
|
zIndex={10}
|
||||||
py={6}
|
py={{ base: 3, md: 6 }}
|
||||||
|
px={{ base: 3, md: 6 }}
|
||||||
boxShadow="lg"
|
boxShadow="lg"
|
||||||
>
|
>
|
||||||
<HStack spacing={4} flexWrap="wrap">
|
<HStack spacing={{ base: 2, md: 4 }} flexWrap="wrap">
|
||||||
<Icon
|
<Icon
|
||||||
as={FaChartLine}
|
as={FaChartLine}
|
||||||
boxSize={6}
|
boxSize={{ base: 4, md: 6 }}
|
||||||
filter="drop-shadow(0 2px 4px rgba(0,0,0,0.2))"
|
filter="drop-shadow(0 2px 4px rgba(0,0,0,0.2))"
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
fontSize="xl"
|
fontSize={{ base: 'md', md: 'xl' }}
|
||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
textShadow="0 2px 4px rgba(0,0,0,0.2)"
|
textShadow="0 2px 4px rgba(0,0,0,0.2)"
|
||||||
|
noOfLines={1}
|
||||||
|
maxW={{ base: '120px', md: 'none' }}
|
||||||
>
|
>
|
||||||
{conceptName} - 历史时间轴
|
{conceptName} - 历史时间轴
|
||||||
</Text>
|
</Text>
|
||||||
<Badge
|
<Badge
|
||||||
colorScheme="yellow"
|
colorScheme="yellow"
|
||||||
px={3}
|
px={{ base: 2, md: 3 }}
|
||||||
py={1}
|
py={1}
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
fontSize="sm"
|
fontSize={{ base: 'xs', md: 'sm' }}
|
||||||
boxShadow="md"
|
boxShadow="md"
|
||||||
>
|
>
|
||||||
最近100天
|
最近100天
|
||||||
@@ -640,20 +648,29 @@ const ConceptTimelineModal = ({
|
|||||||
<Badge
|
<Badge
|
||||||
bg="whiteAlpha.300"
|
bg="whiteAlpha.300"
|
||||||
color="white"
|
color="white"
|
||||||
px={3}
|
px={{ base: 2, md: 3 }}
|
||||||
py={1}
|
py={1}
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
fontSize="xs"
|
fontSize="xs"
|
||||||
backdropFilter="blur(10px)"
|
backdropFilter="blur(10px)"
|
||||||
|
display={{ base: 'none', sm: 'flex' }}
|
||||||
>
|
>
|
||||||
🔥 Max版功能
|
🔥 Max版功能
|
||||||
</Badge>
|
</Badge>
|
||||||
</HStack>
|
</HStack>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalCloseButton color="white" />
|
<ModalCloseButton
|
||||||
|
color="white"
|
||||||
|
size="lg"
|
||||||
|
top={{ base: 2, md: 4 }}
|
||||||
|
right={{ base: 2, md: 4 }}
|
||||||
|
_hover={{ bg: 'whiteAlpha.300' }}
|
||||||
|
zIndex={20}
|
||||||
|
/>
|
||||||
|
|
||||||
<ModalBody
|
<ModalBody
|
||||||
py={6}
|
py={{ base: 2, md: 6 }}
|
||||||
|
px={{ base: 0, md: 6 }}
|
||||||
bg="gray.50"
|
bg="gray.50"
|
||||||
css={{
|
css={{
|
||||||
'&::-webkit-scrollbar': {
|
'&::-webkit-scrollbar': {
|
||||||
@@ -680,103 +697,116 @@ const ConceptTimelineModal = ({
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Center>
|
</Center>
|
||||||
) : timelineData.length > 0 ? (
|
) : timelineData.length > 0 ? (
|
||||||
<Box position="relative" maxW="1200px" mx="auto" px={4}>
|
<Box position="relative" maxW="1200px" mx="auto" px={{ base: 2, md: 4 }}>
|
||||||
{/* 图例说明 */}
|
{/* 图例说明 - H5端保持一行 */}
|
||||||
<Flex justify="center" mb={6} flexWrap="wrap" gap={4}>
|
<Flex
|
||||||
|
justify="center"
|
||||||
|
mb={{ base: 3, md: 6 }}
|
||||||
|
flexWrap={{ base: 'nowrap', md: 'wrap' }}
|
||||||
|
gap={{ base: 1, md: 4 }}
|
||||||
|
overflowX={{ base: 'auto', md: 'visible' }}
|
||||||
|
pb={{ base: 2, md: 0 }}
|
||||||
|
css={{
|
||||||
|
'&::-webkit-scrollbar': { display: 'none' },
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<HStack
|
<HStack
|
||||||
spacing={2}
|
spacing={{ base: 1, md: 2 }}
|
||||||
px={4}
|
px={{ base: 2, md: 4 }}
|
||||||
py={2}
|
py={{ base: 1, md: 2 }}
|
||||||
bg="purple.50"
|
bg="purple.50"
|
||||||
borderRadius="lg"
|
borderRadius="lg"
|
||||||
border="1px solid"
|
border="1px solid"
|
||||||
borderColor="purple.200"
|
borderColor="purple.200"
|
||||||
boxShadow="sm"
|
boxShadow="sm"
|
||||||
transition="all 0.2s"
|
flexShrink={0}
|
||||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
|
|
||||||
>
|
>
|
||||||
<Box w={3} h={3} bg="#9F7AEA" borderRadius="full" boxShadow="sm" />
|
<Box w={{ base: 2, md: 3 }} h={{ base: 2, md: 3 }} bg="#9F7AEA" borderRadius="full" />
|
||||||
<Text fontSize="sm" fontWeight="medium" color="gray.700">📰 新闻</Text>
|
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">📰 新闻</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack
|
<HStack
|
||||||
spacing={2}
|
spacing={{ base: 1, md: 2 }}
|
||||||
px={4}
|
px={{ base: 2, md: 4 }}
|
||||||
py={2}
|
py={{ base: 1, md: 2 }}
|
||||||
bg="purple.50"
|
bg="purple.50"
|
||||||
borderRadius="lg"
|
borderRadius="lg"
|
||||||
border="1px solid"
|
border="1px solid"
|
||||||
borderColor="purple.300"
|
borderColor="purple.300"
|
||||||
boxShadow="sm"
|
boxShadow="sm"
|
||||||
transition="all 0.2s"
|
flexShrink={0}
|
||||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
|
|
||||||
>
|
>
|
||||||
<Box w={3} h={3} bg="#805AD5" borderRadius="full" boxShadow="sm" />
|
<Box w={{ base: 2, md: 3 }} h={{ base: 2, md: 3 }} bg="#805AD5" borderRadius="full" />
|
||||||
<Text fontSize="sm" fontWeight="medium" color="gray.700">📊 研报</Text>
|
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">📊 研报</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack
|
<HStack
|
||||||
spacing={2}
|
spacing={{ base: 1, md: 2 }}
|
||||||
px={4}
|
px={{ base: 2, md: 4 }}
|
||||||
py={2}
|
py={{ base: 1, md: 2 }}
|
||||||
bg="red.50"
|
bg="red.50"
|
||||||
borderRadius="lg"
|
borderRadius="lg"
|
||||||
border="1px solid"
|
border="1px solid"
|
||||||
borderColor="red.200"
|
borderColor="red.200"
|
||||||
boxShadow="sm"
|
boxShadow="sm"
|
||||||
transition="all 0.2s"
|
flexShrink={0}
|
||||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
|
|
||||||
>
|
>
|
||||||
<Icon as={FaArrowUp} color="red.500" boxSize={3} />
|
<Icon as={FaArrowUp} color="red.500" boxSize={{ base: 2, md: 3 }} />
|
||||||
<Text fontSize="sm" fontWeight="medium" color="gray.700">上涨</Text>
|
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">上涨</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack
|
<HStack
|
||||||
spacing={2}
|
spacing={{ base: 1, md: 2 }}
|
||||||
px={4}
|
px={{ base: 2, md: 4 }}
|
||||||
py={2}
|
py={{ base: 1, md: 2 }}
|
||||||
bg="green.50"
|
bg="green.50"
|
||||||
borderRadius="lg"
|
borderRadius="lg"
|
||||||
border="1px solid"
|
border="1px solid"
|
||||||
borderColor="green.200"
|
borderColor="green.200"
|
||||||
boxShadow="sm"
|
boxShadow="sm"
|
||||||
transition="all 0.2s"
|
flexShrink={0}
|
||||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
|
|
||||||
>
|
>
|
||||||
<Icon as={FaArrowDown} color="green.500" boxSize={3} />
|
<Icon as={FaArrowDown} color="green.500" boxSize={{ base: 2, md: 3 }} />
|
||||||
<Text fontSize="sm" fontWeight="medium" color="gray.700">下跌</Text>
|
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">下跌</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack
|
<HStack
|
||||||
spacing={2}
|
spacing={{ base: 1, md: 2 }}
|
||||||
px={4}
|
px={{ base: 2, md: 4 }}
|
||||||
py={2}
|
py={{ base: 1, md: 2 }}
|
||||||
bg="orange.50"
|
bg="orange.50"
|
||||||
borderRadius="lg"
|
borderRadius="lg"
|
||||||
border="1px solid"
|
border="1px solid"
|
||||||
borderColor="orange.200"
|
borderColor="orange.200"
|
||||||
boxShadow="sm"
|
boxShadow="sm"
|
||||||
transition="all 0.2s"
|
flexShrink={0}
|
||||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
|
|
||||||
>
|
>
|
||||||
<Text fontSize="sm" fontWeight="bold">🔥</Text>
|
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="bold">🔥</Text>
|
||||||
<Text fontSize="sm" fontWeight="medium" color="gray.700">涨3%+</Text>
|
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">涨3%+</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* FullCalendar 日历组件 */}
|
{/* FullCalendar 日历组件 */}
|
||||||
<Box
|
<Box
|
||||||
height={{ base: '600px', md: '700px' }}
|
height={{ base: '500px', md: '700px' }}
|
||||||
bg="white"
|
bg="white"
|
||||||
borderRadius="xl"
|
borderRadius={{ base: 'none', md: 'xl' }}
|
||||||
boxShadow="lg"
|
boxShadow={{ base: 'none', md: 'lg' }}
|
||||||
p={4}
|
p={{ base: 1, md: 4 }}
|
||||||
sx={{
|
sx={{
|
||||||
// FullCalendar 样式定制
|
// FullCalendar 样式定制
|
||||||
'.fc': {
|
'.fc': {
|
||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
'.fc-header-toolbar': {
|
'.fc-header-toolbar': {
|
||||||
marginBottom: '1.5rem',
|
marginBottom: { base: '0.5rem', md: '1.5rem' },
|
||||||
|
padding: { base: '0 4px', md: '0' },
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
gap: { base: '4px', md: '8px' },
|
||||||
|
},
|
||||||
|
'.fc-toolbar-chunk': {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
'.fc-toolbar-title': {
|
'.fc-toolbar-title': {
|
||||||
fontSize: '1.5rem',
|
fontSize: { base: '1rem', md: '1.5rem' },
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: 'purple.600',
|
color: 'purple.600',
|
||||||
},
|
},
|
||||||
@@ -784,6 +814,8 @@ const ConceptTimelineModal = ({
|
|||||||
backgroundColor: '#9F7AEA',
|
backgroundColor: '#9F7AEA',
|
||||||
borderColor: '#9F7AEA',
|
borderColor: '#9F7AEA',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
|
padding: { base: '4px 8px', md: '6px 12px' },
|
||||||
|
fontSize: { base: '12px', md: '14px' },
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: '#805AD5',
|
backgroundColor: '#805AD5',
|
||||||
borderColor: '#805AD5',
|
borderColor: '#805AD5',
|
||||||
@@ -806,14 +838,18 @@ const ConceptTimelineModal = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
'.fc-daygrid-day-number': {
|
'.fc-daygrid-day-number': {
|
||||||
padding: '4px',
|
padding: { base: '2px', md: '4px' },
|
||||||
fontSize: '0.875rem',
|
fontSize: { base: '0.75rem', md: '0.875rem' },
|
||||||
|
},
|
||||||
|
'.fc-col-header-cell-cushion': {
|
||||||
|
fontSize: { base: '0.75rem', md: '0.875rem' },
|
||||||
|
padding: { base: '4px 2px', md: '8px' },
|
||||||
},
|
},
|
||||||
'.fc-event': {
|
'.fc-event': {
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
padding: '2px 4px',
|
padding: { base: '1px 2px', md: '2px 4px' },
|
||||||
fontSize: '0.75rem',
|
fontSize: { base: '0.65rem', md: '0.75rem' },
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
@@ -823,7 +859,13 @@ const ConceptTimelineModal = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
'.fc-daygrid-event-harness': {
|
'.fc-daygrid-event-harness': {
|
||||||
marginBottom: '2px',
|
marginBottom: { base: '1px', md: '2px' },
|
||||||
|
},
|
||||||
|
// H5 端隐藏事件文字,只显示色块
|
||||||
|
'@media (max-width: 768px)': {
|
||||||
|
'.fc-event-title': {
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -882,32 +924,11 @@ const ConceptTimelineModal = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 风险提示 */}
|
{/* 风险提示 */}
|
||||||
<Box px={6}>
|
<Box px={{ base: 2, md: 6 }}>
|
||||||
<RiskDisclaimer variant="default" />
|
<RiskDisclaimer variant="default" />
|
||||||
</Box>
|
</Box>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|
||||||
<ModalFooter
|
|
||||||
borderTop="2px solid"
|
|
||||||
borderColor="purple.100"
|
|
||||||
bg="gray.50"
|
|
||||||
py={4}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
colorScheme="purple"
|
|
||||||
size="lg"
|
|
||||||
px={8}
|
|
||||||
onClick={onClose}
|
|
||||||
boxShadow="md"
|
|
||||||
_hover={{
|
|
||||||
transform: 'translateY(-2px)',
|
|
||||||
boxShadow: 'lg',
|
|
||||||
}}
|
|
||||||
transition="all 0.2s"
|
|
||||||
>
|
|
||||||
关闭
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import {
|
|||||||
MenuList,
|
MenuList,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Collapse,
|
Collapse,
|
||||||
|
useBreakpointValue,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { SearchIcon, ViewIcon, CalendarIcon, ExternalLinkIcon, StarIcon, ChevronDownIcon, InfoIcon, CloseIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
import { SearchIcon, ViewIcon, CalendarIcon, ExternalLinkIcon, StarIcon, ChevronDownIcon, InfoIcon, CloseIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||||||
import { FaThLarge, FaList, FaTags, FaChartLine, FaRobot, FaTable, FaHistory, FaBrain, FaLightbulb, FaRocket, FaShieldAlt, FaCalendarAlt, FaArrowUp, FaArrowDown, FaNewspaper, FaFileAlt, FaExpand, FaCompress, FaClock, FaLock } from 'react-icons/fa';
|
import { FaThLarge, FaList, FaTags, FaChartLine, FaRobot, FaTable, FaHistory, FaBrain, FaLightbulb, FaRocket, FaShieldAlt, FaCalendarAlt, FaArrowUp, FaArrowDown, FaNewspaper, FaFileAlt, FaExpand, FaCompress, FaClock, FaLock } from 'react-icons/fa';
|
||||||
@@ -85,6 +86,8 @@ import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
|||||||
import { keyframes } from '@emotion/react';
|
import { keyframes } from '@emotion/react';
|
||||||
import ConceptTimelineModal from './ConceptTimelineModal';
|
import ConceptTimelineModal from './ConceptTimelineModal';
|
||||||
import ConceptStatsPanel from './components/ConceptStatsPanel';
|
import ConceptStatsPanel from './components/ConceptStatsPanel';
|
||||||
|
import ConceptStocksModal from '@components/ConceptStocksModal';
|
||||||
|
import TradeDatePicker from '@components/TradeDatePicker';
|
||||||
// 导航栏已由 MainLayout 提供,无需在此导入
|
// 导航栏已由 MainLayout 提供,无需在此导入
|
||||||
// 导入订阅权限管理
|
// 导入订阅权限管理
|
||||||
import { useSubscription } from '../../hooks/useSubscription';
|
import { useSubscription } from '../../hooks/useSubscription';
|
||||||
@@ -527,109 +530,6 @@ const ConceptCenter = () => {
|
|||||||
return `https://valuefrontier.cn/company?scode=${seccode}`;
|
return `https://valuefrontier.cn/company?scode=${seccode}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染动态表格列
|
|
||||||
const renderStockTable = () => {
|
|
||||||
if (!selectedConceptStocks || selectedConceptStocks.length === 0) {
|
|
||||||
return <Text>暂无相关股票数据</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const allFields = new Set();
|
|
||||||
selectedConceptStocks.forEach(stock => {
|
|
||||||
Object.keys(stock).forEach(key => allFields.add(key));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 定义固定的列顺序,包含新增的现价和涨跌幅列
|
|
||||||
const orderedFields = ['stock_name', 'stock_code', 'current_price', 'change_percent'];
|
|
||||||
allFields.forEach(field => {
|
|
||||||
if (!orderedFields.includes(field)) {
|
|
||||||
orderedFields.push(field);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
{loadingStockData && (
|
|
||||||
<Box mb={4} textAlign="center">
|
|
||||||
<HStack justify="center" spacing={2}>
|
|
||||||
<Spinner size="sm" color="purple.500" />
|
|
||||||
<Text fontSize="sm" color="gray.600">正在获取行情数据...</Text>
|
|
||||||
</HStack>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TableContainer maxH="60vh" overflowY="auto">
|
|
||||||
<Table variant="simple" size="sm">
|
|
||||||
<Thead position="sticky" top={0} bg="white" zIndex={1}>
|
|
||||||
<Tr>
|
|
||||||
{orderedFields.map(field => (
|
|
||||||
<Th key={field}>
|
|
||||||
{field === 'stock_name' ? '股票名称' :
|
|
||||||
field === 'stock_code' ? '股票代码' :
|
|
||||||
field === 'current_price' ? '现价' :
|
|
||||||
field === 'change_percent' ? '当日涨跌幅' : field}
|
|
||||||
</Th>
|
|
||||||
))}
|
|
||||||
</Tr>
|
|
||||||
</Thead>
|
|
||||||
<Tbody>
|
|
||||||
{selectedConceptStocks.map((stock, idx) => {
|
|
||||||
const marketData = stockMarketData[stock.stock_code];
|
|
||||||
const companyLink = generateCompanyLink(stock.stock_code);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tr key={idx} _hover={{ bg: 'gray.50' }}>
|
|
||||||
{orderedFields.map(field => {
|
|
||||||
let cellContent = stock[field] || '-';
|
|
||||||
let cellProps = {};
|
|
||||||
|
|
||||||
// 处理特殊字段
|
|
||||||
if (field === 'current_price') {
|
|
||||||
cellContent = marketData ? formatPrice(marketData.close) : (loadingStockData ? <Spinner size="xs" /> : '-');
|
|
||||||
} else if (field === 'change_percent') {
|
|
||||||
if (marketData) {
|
|
||||||
cellContent = formatStockChangePercent(marketData.change_percent);
|
|
||||||
cellProps.color = `${getStockChangeColor(marketData.change_percent)}.500`;
|
|
||||||
cellProps.fontWeight = 'bold';
|
|
||||||
} else {
|
|
||||||
cellContent = loadingStockData ? <Spinner size="xs" /> : '-';
|
|
||||||
}
|
|
||||||
} else if (field === 'stock_name' || field === 'stock_code') {
|
|
||||||
// 添加超链接
|
|
||||||
cellContent = (
|
|
||||||
<Text
|
|
||||||
as="a"
|
|
||||||
href={companyLink}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
color="blue.600"
|
|
||||||
textDecoration="underline"
|
|
||||||
_hover={{
|
|
||||||
color: 'blue.800',
|
|
||||||
textDecoration: 'underline'
|
|
||||||
}}
|
|
||||||
cursor="pointer"
|
|
||||||
>
|
|
||||||
{stock[field] || '-'}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Td key={field} {...cellProps}>
|
|
||||||
{cellContent}
|
|
||||||
</Td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Tbody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 格式化添加日期显示
|
// 格式化添加日期显示
|
||||||
const formatAddedDate = (concept) => {
|
const formatAddedDate = (concept) => {
|
||||||
// 优先使用 created_at 或 added_date 字段
|
// 优先使用 created_at 或 added_date 字段
|
||||||
@@ -672,6 +572,10 @@ const ConceptCenter = () => {
|
|||||||
const changePercent = concept.price_info?.avg_change_pct;
|
const changePercent = concept.price_info?.avg_change_pct;
|
||||||
const changeColor = getChangeColor(changePercent);
|
const changeColor = getChangeColor(changePercent);
|
||||||
const hasChange = changePercent !== null && changePercent !== undefined;
|
const hasChange = changePercent !== null && changePercent !== undefined;
|
||||||
|
// H5 端使用更紧凑的尺寸
|
||||||
|
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||||
|
const coverHeight = useBreakpointValue({ base: '100px', md: '180px' });
|
||||||
|
const logoSize = useBreakpointValue({ base: '60px', md: '120px' });
|
||||||
|
|
||||||
// 生成随机涨幅数字背景
|
// 生成随机涨幅数字背景
|
||||||
const generateNumbersBackground = () => {
|
const generateNumbersBackground = () => {
|
||||||
@@ -705,7 +609,7 @@ const ConceptCenter = () => {
|
|||||||
boxShadow="0 4px 12px rgba(0, 0, 0, 0.1)"
|
boxShadow="0 4px 12px rgba(0, 0, 0, 0.1)"
|
||||||
>
|
>
|
||||||
{/* 毛玻璃涨幅数字背景 */}
|
{/* 毛玻璃涨幅数字背景 */}
|
||||||
<Box position="relative" height="180px" overflow="hidden">
|
<Box position="relative" height={coverHeight} overflow="hidden">
|
||||||
{/* 渐变背景层 */}
|
{/* 渐变背景层 */}
|
||||||
<Box
|
<Box
|
||||||
position="absolute"
|
position="absolute"
|
||||||
@@ -757,8 +661,8 @@ const ConceptCenter = () => {
|
|||||||
top="50%"
|
top="50%"
|
||||||
left="50%"
|
left="50%"
|
||||||
transform="translate(-50%, -50%)"
|
transform="translate(-50%, -50%)"
|
||||||
width="120px"
|
width={logoSize}
|
||||||
height="120px"
|
height={logoSize}
|
||||||
opacity={0.15}
|
opacity={0.15}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
@@ -849,11 +753,11 @@ const ConceptCenter = () => {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<CardBody p={4}>
|
<CardBody p={{ base: 3, md: 4 }}>
|
||||||
<VStack align="start" spacing={2}>
|
<VStack align="start" spacing={{ base: 1, md: 2 }}>
|
||||||
{/* 概念名称 */}
|
{/* 概念名称 */}
|
||||||
<Heading
|
<Heading
|
||||||
size="sm"
|
size={{ base: 'xs', md: 'sm' }}
|
||||||
color="gray.800"
|
color="gray.800"
|
||||||
noOfLines={1}
|
noOfLines={1}
|
||||||
bgGradient="linear(to-r, purple.600, pink.600)"
|
bgGradient="linear(to-r, purple.600, pink.600)"
|
||||||
@@ -863,15 +767,15 @@ const ConceptCenter = () => {
|
|||||||
{concept.concept}
|
{concept.concept}
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
{/* 描述信息 */}
|
{/* 描述信息 - H5端显示1行 */}
|
||||||
<Text color="gray.600" fontSize="xs" noOfLines={2} minH="32px">
|
<Text color="gray.600" fontSize="xs" noOfLines={isMobile ? 1 : 2} minH={{ base: '16px', md: '32px' }}>
|
||||||
{concept.description || '暂无描述信息'}
|
{concept.description || '暂无描述信息'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{concept.stocks && concept.stocks.length > 0 && (
|
{concept.stocks && concept.stocks.length > 0 && (
|
||||||
<Box
|
<Box
|
||||||
width="100%"
|
width="100%"
|
||||||
p={3}
|
p={{ base: 2, md: 3 }}
|
||||||
bg="linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(168, 85, 247, 0.05) 100%)"
|
bg="linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(168, 85, 247, 0.05) 100%)"
|
||||||
borderRadius="lg"
|
borderRadius="lg"
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
@@ -886,7 +790,7 @@ const ConceptCenter = () => {
|
|||||||
>
|
>
|
||||||
<Flex align="center" justify="space-between">
|
<Flex align="center" justify="space-between">
|
||||||
<Box flex={1}>
|
<Box flex={1}>
|
||||||
<HStack spacing={2} mb={2}>
|
<HStack spacing={2} mb={{ base: 1, md: 2 }}>
|
||||||
<Icon as={FaChartLine} boxSize={3} color="purple.500" />
|
<Icon as={FaChartLine} boxSize={3} color="purple.500" />
|
||||||
<Text fontSize="xs" color="purple.700" fontWeight="bold">
|
<Text fontSize="xs" color="purple.700" fontWeight="bold">
|
||||||
热门个股
|
热门个股
|
||||||
@@ -942,20 +846,20 @@ const ConceptCenter = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Divider borderColor="purple.100" />
|
<Divider borderColor="purple.100" my={{ base: 1, md: 0 }} />
|
||||||
|
|
||||||
<Flex width="100%" justify="space-between" align="center">
|
<Flex width="100%" justify="space-between" align="center">
|
||||||
{formatAddedDate(concept)}
|
{formatAddedDate(concept)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size={{ base: 'xs', md: 'sm' }}
|
||||||
leftIcon={<FaHistory />}
|
leftIcon={<FaHistory />}
|
||||||
bgGradient="linear(to-r, purple.500, pink.500)"
|
bgGradient="linear(to-r, purple.500, pink.500)"
|
||||||
color="white"
|
color="white"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
onClick={(e) => handleViewContent(e, concept.concept, concept.concept_id)}
|
onClick={(e) => handleViewContent(e, concept.concept, concept.concept_id)}
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
px={4}
|
px={{ base: 2, md: 4 }}
|
||||||
fontWeight="medium"
|
fontWeight="medium"
|
||||||
boxShadow="0 4px 12px rgba(139, 92, 246, 0.3)"
|
boxShadow="0 4px 12px rgba(139, 92, 246, 0.3)"
|
||||||
_hover={{
|
_hover={{
|
||||||
@@ -1179,23 +1083,23 @@ const ConceptCenter = () => {
|
|||||||
align={{ base: 'stretch', lg: 'center' }}
|
align={{ base: 'stretch', lg: 'center' }}
|
||||||
gap={4}
|
gap={4}
|
||||||
>
|
>
|
||||||
<HStack spacing={3}>
|
{/* 使用通用日期选择器组件 */}
|
||||||
<Icon as={FaCalendarAlt} color="purple.500" boxSize={5} />
|
<TradeDatePicker
|
||||||
<Text fontWeight="bold" color="purple.700">交易日期:</Text>
|
value={selectedDate}
|
||||||
</HStack>
|
onChange={(date) => {
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
<Input
|
const previousDate = selectedDate ? selectedDate.toISOString().split('T')[0] : null;
|
||||||
type="date"
|
trackFilterApplied('date', dateStr, previousDate);
|
||||||
value={selectedDate ? selectedDate.toISOString().split('T')[0] : ''}
|
setSelectedDate(date);
|
||||||
onChange={handleDateChange}
|
setCurrentPage(1);
|
||||||
max={new Date().toISOString().split('T')[0]}
|
updateUrlParams({ date: dateStr, page: 1 });
|
||||||
width={{ base: '100%', lg: '200px' }}
|
fetchConcepts(searchQuery, 1, date, sortBy);
|
||||||
focusBorderColor="purple.500"
|
}}
|
||||||
borderColor="purple.200"
|
latestTradeDate={latestTradeDate}
|
||||||
borderRadius="lg"
|
label="交易日期"
|
||||||
fontWeight="medium"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 快捷按钮保留在页面内 */}
|
||||||
<ButtonGroup size="sm" variant="outline" flexWrap="wrap">
|
<ButtonGroup size="sm" variant="outline" flexWrap="wrap">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleQuickDateSelect(0)}
|
onClick={() => handleQuickDateSelect(0)}
|
||||||
@@ -1246,25 +1150,6 @@ const ConceptCenter = () => {
|
|||||||
一月前
|
一月前
|
||||||
</Button>
|
</Button>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
|
||||||
{latestTradeDate && (
|
|
||||||
<Tooltip label="数据库中最新的交易日期">
|
|
||||||
<HStack
|
|
||||||
spacing={2}
|
|
||||||
bg="blue.50"
|
|
||||||
px={3}
|
|
||||||
py={1.5}
|
|
||||||
borderRadius="full"
|
|
||||||
border="1px solid"
|
|
||||||
borderColor="blue.200"
|
|
||||||
>
|
|
||||||
<Icon as={InfoIcon} color="blue.500" boxSize={3} />
|
|
||||||
<Text fontSize="sm" color="blue.600" fontWeight="medium">
|
|
||||||
最新: {latestTradeDate.toLocaleDateString('zh-CN')}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -1463,7 +1348,7 @@ const ConceptCenter = () => {
|
|||||||
fontSize="md"
|
fontSize="md"
|
||||||
transition="all 0.2s"
|
transition="all 0.2s"
|
||||||
border="none"
|
border="none"
|
||||||
height="100%"
|
alignSelf="stretch"
|
||||||
boxShadow="inset 0 1px 0 rgba(255, 255, 255, 0.2)"
|
boxShadow="inset 0 1px 0 rgba(255, 255, 255, 0.2)"
|
||||||
>
|
>
|
||||||
搜索
|
搜索
|
||||||
@@ -1598,7 +1483,7 @@ const ConceptCenter = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
<SimpleGrid columns={{ base: 2, md: 2, lg: 3 }} spacing={{ base: 3, md: 6 }}>
|
||||||
{[...Array(12)].map((_, i) => (
|
{[...Array(12)].map((_, i) => (
|
||||||
<SkeletonCard key={i} />
|
<SkeletonCard key={i} />
|
||||||
))}
|
))}
|
||||||
@@ -1606,7 +1491,7 @@ const ConceptCenter = () => {
|
|||||||
) : concepts.length > 0 ? (
|
) : concepts.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
{viewMode === 'grid' ? (
|
{viewMode === 'grid' ? (
|
||||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6} className="concept-grid">
|
<SimpleGrid columns={{ base: 2, md: 2, lg: 3 }} spacing={{ base: 3, md: 6 }} className="concept-grid">
|
||||||
{concepts.map((concept, index) => (
|
{concepts.map((concept, index) => (
|
||||||
<Box key={concept.concept_id} className="concept-item" role="group">
|
<Box key={concept.concept_id} className="concept-item" role="group">
|
||||||
<ConceptCard concept={concept} position={index} />
|
<ConceptCard concept={concept} position={index} />
|
||||||
@@ -1758,32 +1643,15 @@ const ConceptCenter = () => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
{/* 股票详情Modal */}
|
{/* 股票详情Modal - 复用通用组件 */}
|
||||||
<Modal
|
<ConceptStocksModal
|
||||||
isOpen={isStockModalOpen}
|
isOpen={isStockModalOpen}
|
||||||
onClose={() => setIsStockModalOpen(false)}
|
onClose={() => setIsStockModalOpen(false)}
|
||||||
size="6xl"
|
concept={{
|
||||||
scrollBehavior="inside"
|
concept_name: selectedConceptName,
|
||||||
>
|
stocks: selectedConceptStocks
|
||||||
<ModalOverlay />
|
}}
|
||||||
<ModalContent>
|
/>
|
||||||
<ModalHeader bg="purple.500" color="white">
|
|
||||||
<HStack>
|
|
||||||
<Icon as={FaTable} />
|
|
||||||
<Text>{selectedConceptName} - 相关个股</Text>
|
|
||||||
</HStack>
|
|
||||||
</ModalHeader>
|
|
||||||
<ModalCloseButton color="white" />
|
|
||||||
<ModalBody py={6}>
|
|
||||||
{renderStockTable()}
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter>
|
|
||||||
<Button colorScheme="purple" onClick={() => setIsStockModalOpen(false)}>
|
|
||||||
关闭
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
{/* 时间轴Modal */}
|
{/* 时间轴Modal */}
|
||||||
<ConceptTimelineModal
|
<ConceptTimelineModal
|
||||||
isOpen={isTimelineModalOpen}
|
isOpen={isTimelineModalOpen}
|
||||||
|
|||||||
@@ -56,10 +56,15 @@ import {
|
|||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { SearchIcon, CloseIcon, ArrowForwardIcon, TrendingUpIcon, InfoIcon, ChevronRightIcon, MoonIcon, SunIcon, CalendarIcon } from '@chakra-ui/icons';
|
import { SearchIcon, CloseIcon, ArrowForwardIcon, TrendingUpIcon, InfoIcon, ChevronRightIcon, MoonIcon, SunIcon, CalendarIcon } from '@chakra-ui/icons';
|
||||||
import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight, FaArrowUp, FaArrowDown, FaChartBar } from 'react-icons/fa';
|
import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight, FaArrowUp, FaArrowDown, FaChartBar } from 'react-icons/fa';
|
||||||
|
import ConceptStocksModal from '@components/ConceptStocksModal';
|
||||||
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
import tradingDays from '../../data/tradingDays.json';
|
||||||
import { useStockOverviewEvents } from './hooks/useStockOverviewEvents';
|
import { useStockOverviewEvents } from './hooks/useStockOverviewEvents';
|
||||||
|
|
||||||
|
// 交易日 Set,用于快速查找
|
||||||
|
const tradingDaysSet = new Set(tradingDays);
|
||||||
// Navigation bar now provided by MainLayout
|
// Navigation bar now provided by MainLayout
|
||||||
// import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
// import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
||||||
|
|
||||||
@@ -98,6 +103,10 @@ const StockOverview = () => {
|
|||||||
const [availableDates, setAvailableDates] = useState([]);
|
const [availableDates, setAvailableDates] = useState([]);
|
||||||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||||||
|
|
||||||
|
// 个股列表弹窗状态
|
||||||
|
const [isStockModalOpen, setIsStockModalOpen] = useState(false);
|
||||||
|
const [selectedConcept, setSelectedConcept] = useState(null);
|
||||||
|
|
||||||
// 专业的颜色主题
|
// 专业的颜色主题
|
||||||
const bgColor = useColorModeValue('white', '#0a0a0a');
|
const bgColor = useColorModeValue('white', '#0a0a0a');
|
||||||
const cardBg = useColorModeValue('white', '#1a1a1a');
|
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||||||
@@ -110,6 +119,13 @@ const StockOverview = () => {
|
|||||||
const accentColor = useColorModeValue('purple.600', goldColor);
|
const accentColor = useColorModeValue('purple.600', goldColor);
|
||||||
const heroBg = useColorModeValue('linear(to-br, purple.600, pink.500)', 'linear(to-br, #0a0a0a, #1a1a1a)');
|
const heroBg = useColorModeValue('linear(to-br, purple.600, pink.500)', 'linear(to-br, #0a0a0a, #1a1a1a)');
|
||||||
|
|
||||||
|
// 打开个股列表弹窗
|
||||||
|
const handleViewStocks = useCallback((e, concept) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedConcept(concept);
|
||||||
|
setIsStockModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 防抖搜索
|
// 防抖搜索
|
||||||
const debounceSearch = useCallback(
|
const debounceSearch = useCallback(
|
||||||
(() => {
|
(() => {
|
||||||
@@ -173,7 +189,27 @@ const StockOverview = () => {
|
|||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setTopConcepts(data.data);
|
setTopConcepts(data.data);
|
||||||
if (!selectedDate) setSelectedDate(data.trade_date);
|
// 使用概念接口的日期作为统一数据源(数据最新)
|
||||||
|
setSelectedDate(data.trade_date);
|
||||||
|
// 基于交易日历生成可选日期列表
|
||||||
|
if (data.trade_date && tradingDays.length > 0) {
|
||||||
|
// 找到当前日期或最近的交易日
|
||||||
|
let targetDate = data.trade_date;
|
||||||
|
if (!tradingDaysSet.has(data.trade_date)) {
|
||||||
|
for (let i = tradingDays.length - 1; i >= 0; i--) {
|
||||||
|
if (tradingDays[i] <= data.trade_date) {
|
||||||
|
targetDate = tradingDays[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const idx = tradingDays.indexOf(targetDate);
|
||||||
|
if (idx !== -1) {
|
||||||
|
const startIdx = Math.max(0, idx - 19);
|
||||||
|
const dates = tradingDays.slice(startIdx, idx + 1).reverse();
|
||||||
|
setAvailableDates(dates);
|
||||||
|
}
|
||||||
|
}
|
||||||
logger.debug('StockOverview', '热门概念加载成功', {
|
logger.debug('StockOverview', '热门概念加载成功', {
|
||||||
count: data.data?.length || 0,
|
count: data.data?.length || 0,
|
||||||
date: data.trade_date
|
date: data.trade_date
|
||||||
@@ -204,7 +240,7 @@ const StockOverview = () => {
|
|||||||
falling_count: data.statistics.falling_count
|
falling_count: data.statistics.falling_count
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (!selectedDate) setSelectedDate(data.trade_date);
|
// 日期由 fetchTopConcepts 统一设置,这里不再设置
|
||||||
logger.debug('StockOverview', '热力图数据加载成功', {
|
logger.debug('StockOverview', '热力图数据加载成功', {
|
||||||
count: data.data?.length || 0,
|
count: data.data?.length || 0,
|
||||||
date: data.trade_date
|
date: data.trade_date
|
||||||
@@ -235,11 +271,9 @@ const StockOverview = () => {
|
|||||||
date: data.trade_date
|
date: data.trade_date
|
||||||
};
|
};
|
||||||
setMarketStats(newStats);
|
setMarketStats(newStats);
|
||||||
setAvailableDates(data.available_dates || []);
|
// 日期和可选日期列表由 fetchTopConcepts 统一设置,这里不再设置
|
||||||
if (!selectedDate) setSelectedDate(data.trade_date);
|
|
||||||
logger.debug('StockOverview', '市场统计数据加载成功', {
|
logger.debug('StockOverview', '市场统计数据加载成功', {
|
||||||
date: data.trade_date,
|
date: data.trade_date
|
||||||
availableDatesCount: data.available_dates?.length || 0
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🎯 追踪市场统计数据查看
|
// 🎯 追踪市场统计数据查看
|
||||||
@@ -974,31 +1008,33 @@ const StockOverview = () => {
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<Box w="100%">
|
<Box
|
||||||
|
w="100%"
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={(e) => handleViewStocks(e, concept)}
|
||||||
|
_hover={{ bg: hoverBg }}
|
||||||
|
p={2}
|
||||||
|
borderRadius="md"
|
||||||
|
transition="background 0.2s"
|
||||||
|
>
|
||||||
<Text fontSize="xs" color="gray.500" mb={2}>
|
<Text fontSize="xs" color="gray.500" mb={2}>
|
||||||
包含 {concept.stock_count} 只个股
|
包含 {concept.stock_count} 只个股
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{concept.stocks && concept.stocks.length > 0 && (
|
{concept.stocks && concept.stocks.length > 0 && (
|
||||||
<Flex flexWrap="wrap" gap={2}>
|
<Flex
|
||||||
|
flexWrap="nowrap"
|
||||||
|
gap={2}
|
||||||
|
overflow="hidden"
|
||||||
|
maxH="24px"
|
||||||
|
>
|
||||||
{concept.stocks.map((stock, idx) => (
|
{concept.stocks.map((stock, idx) => (
|
||||||
<Tag
|
<Tag
|
||||||
key={idx}
|
key={idx}
|
||||||
size="sm"
|
size="sm"
|
||||||
colorScheme="purple"
|
colorScheme="purple"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
cursor="pointer"
|
flexShrink={0}
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// 🎯 追踪概念下的股票标签点击
|
|
||||||
trackConceptStockClicked({
|
|
||||||
code: stock.stock_code,
|
|
||||||
name: stock.stock_name
|
|
||||||
}, concept.concept_name);
|
|
||||||
|
|
||||||
navigate(`/company?scode=${stock.stock_code}`);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<TagLabel>{stock.stock_name}</TagLabel>
|
<TagLabel>{stock.stock_name}</TagLabel>
|
||||||
</Tag>
|
</Tag>
|
||||||
@@ -1099,6 +1135,13 @@ const StockOverview = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
|
{/* 个股列表弹窗 */}
|
||||||
|
<ConceptStocksModal
|
||||||
|
isOpen={isStockModalOpen}
|
||||||
|
onClose={() => setIsStockModalOpen(false)}
|
||||||
|
concept={selectedConcept}
|
||||||
|
/>
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user