将 ConceptCenter 中遗留的 react-icons 图标替换为 lucide-react: - FaCube → BoxIcon - FaSitemap → Network - FaList → List - FaArrowUp/FaArrowDown → ArrowUp/ArrowDown - FaLock → Lock - FaHistory → History - FaChartLine → LineChart 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2174 lines
100 KiB
JavaScript
2174 lines
100 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||
import { logger } from '../../utils/logger';
|
||
import defaultEventImage from '../../assets/img/default-event.jpg';
|
||
import {
|
||
Box,
|
||
Heading,
|
||
Text,
|
||
Input,
|
||
InputGroup,
|
||
InputLeftElement,
|
||
Button,
|
||
SimpleGrid,
|
||
Card,
|
||
CardBody,
|
||
Image,
|
||
Badge,
|
||
Stack,
|
||
HStack,
|
||
VStack,
|
||
Flex,
|
||
Spacer,
|
||
Select,
|
||
Tag,
|
||
TagLabel,
|
||
Wrap,
|
||
WrapItem,
|
||
useToast,
|
||
Spinner,
|
||
Center,
|
||
Icon,
|
||
useColorModeValue,
|
||
IconButton,
|
||
ButtonGroup,
|
||
Skeleton,
|
||
SkeletonText,
|
||
Modal,
|
||
ModalOverlay,
|
||
ModalContent,
|
||
ModalHeader,
|
||
ModalFooter,
|
||
ModalBody,
|
||
ModalCloseButton,
|
||
Table,
|
||
Thead,
|
||
Tbody,
|
||
Tr,
|
||
Th,
|
||
Td,
|
||
TableContainer,
|
||
Tooltip,
|
||
Stat,
|
||
StatLabel,
|
||
StatNumber,
|
||
StatHelpText,
|
||
StatArrow,
|
||
Divider,
|
||
Popover,
|
||
PopoverTrigger,
|
||
PopoverContent,
|
||
PopoverBody,
|
||
PopoverArrow,
|
||
PopoverCloseButton,
|
||
Tabs,
|
||
TabList,
|
||
TabPanels,
|
||
Tab,
|
||
TabPanel,
|
||
Accordion,
|
||
AccordionItem,
|
||
AccordionButton,
|
||
AccordionPanel,
|
||
AccordionIcon,
|
||
useDisclosure,
|
||
Menu,
|
||
MenuButton,
|
||
MenuList,
|
||
MenuItem,
|
||
Collapse,
|
||
useBreakpointValue,
|
||
} from '@chakra-ui/react';
|
||
import {
|
||
Search,
|
||
Eye,
|
||
Calendar,
|
||
ExternalLink,
|
||
Star,
|
||
ChevronDown,
|
||
Info,
|
||
X,
|
||
ChevronRight,
|
||
LayoutGrid,
|
||
List,
|
||
Tags,
|
||
LineChart,
|
||
Bot,
|
||
Table2,
|
||
History,
|
||
Brain,
|
||
Lightbulb,
|
||
Rocket,
|
||
Shield,
|
||
CalendarDays,
|
||
ArrowUp,
|
||
ArrowDown,
|
||
Newspaper,
|
||
FileText,
|
||
Maximize2,
|
||
Minimize2,
|
||
Clock,
|
||
Lock,
|
||
GitBranch,
|
||
Layers,
|
||
Box as BoxIcon,
|
||
Network,
|
||
TrendingUp,
|
||
Zap,
|
||
} from 'lucide-react';
|
||
import { keyframes } from '@emotion/react';
|
||
import ConceptTimelineModal from './ConceptTimelineModal';
|
||
import ConceptStatsPanel from './components/ConceptStatsPanel';
|
||
import HierarchyView from './components/HierarchyView';
|
||
import ForceGraphView from './components/ForceGraphView';
|
||
import BreadcrumbNav from './components/BreadcrumbNav';
|
||
import ConceptStocksModal from '@components/ConceptStocksModal';
|
||
import TradeDatePicker from '@components/TradeDatePicker';
|
||
// 导航栏已由 MainLayout 提供,无需在此导入
|
||
// 导入订阅权限管理
|
||
import { useSubscription } from '../../hooks/useSubscription';
|
||
import SubscriptionUpgradeModal from '../../components/SubscriptionUpgradeModal';
|
||
// 导入市场服务
|
||
import { marketService } from '../../services/marketService';
|
||
// 导入 PostHog 追踪 Hook
|
||
import { useConceptEvents } from './hooks/useConceptEvents';
|
||
|
||
import { getApiBase } from '@utils/apiConfig';
|
||
|
||
// API配置 - 生产环境通过 api.valuefrontier.cn 代理
|
||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
||
? `${getApiBase()}/concept-api`
|
||
: 'http://111.198.58.126:16801';
|
||
|
||
// 新闻和研报API配置
|
||
const NEWS_API_URL = process.env.NODE_ENV === 'production'
|
||
? `${getApiBase()}/news-api`
|
||
: 'http://111.198.58.126:21891';
|
||
|
||
const REPORT_API_URL = process.env.NODE_ENV === 'production'
|
||
? `${getApiBase()}/report-api`
|
||
: 'http://111.198.58.126:8811';
|
||
|
||
// 渐变动画
|
||
const gradientAnimation = keyframes`
|
||
0% { background-position: 0% 50%; }
|
||
50% { background-position: 100% 50%; }
|
||
100% { background-position: 0% 50%; }
|
||
`;
|
||
|
||
// 浮动动画
|
||
const floatAnimation = keyframes`
|
||
0%, 100% { transform: translateY(0px); }
|
||
50% { transform: translateY(-20px); }
|
||
`;
|
||
|
||
// 涨跌幅脉冲动画
|
||
const pulseAnimation = keyframes`
|
||
0% { transform: scale(1); }
|
||
50% { transform: scale(1.05); }
|
||
100% { transform: scale(1); }
|
||
`;
|
||
|
||
// K线流动动画 - 模拟股票走势
|
||
const klineFlowAnimation = keyframes`
|
||
0% { transform: translateX(-100%); }
|
||
100% { transform: translateX(100%); }
|
||
`;
|
||
|
||
// 网格扫描动画
|
||
const gridScanAnimation = keyframes`
|
||
0% { opacity: 0.3; transform: translateY(0); }
|
||
50% { opacity: 0.6; transform: translateY(-20px); }
|
||
100% { opacity: 0.3; transform: translateY(0); }
|
||
`;
|
||
|
||
// 脉冲光点动画
|
||
const pulseGlowAnimation = keyframes`
|
||
0%, 100% { opacity: 0.4; transform: scale(1); }
|
||
50% { opacity: 0.8; transform: scale(1.2); }
|
||
`;
|
||
|
||
/**
|
||
* 金融数据流动背景组件 - 模拟K线走势
|
||
*/
|
||
const FinanceFlowBackground = () => (
|
||
<Box
|
||
position="absolute"
|
||
top={0}
|
||
left={0}
|
||
right={0}
|
||
bottom={0}
|
||
overflow="hidden"
|
||
pointerEvents="none"
|
||
zIndex={0}
|
||
>
|
||
{/* 深色渐变底层 */}
|
||
<Box
|
||
position="absolute"
|
||
top={0}
|
||
left={0}
|
||
right={0}
|
||
bottom={0}
|
||
bgGradient="linear(to-br, gray.900, #0f172a, gray.900)"
|
||
/>
|
||
|
||
{/* 网格线背景 */}
|
||
<Box
|
||
position="absolute"
|
||
top={0}
|
||
left={0}
|
||
right={0}
|
||
bottom={0}
|
||
opacity={0.08}
|
||
backgroundImage={`
|
||
linear-gradient(rgba(139, 92, 246, 0.3) 1px, transparent 1px),
|
||
linear-gradient(90deg, rgba(139, 92, 246, 0.3) 1px, transparent 1px)
|
||
`}
|
||
backgroundSize="60px 60px"
|
||
/>
|
||
|
||
{/* K线流动效果 - 多条线 */}
|
||
{[...Array(6)].map((_, i) => (
|
||
<Box
|
||
key={i}
|
||
position="absolute"
|
||
top={`${15 + i * 15}%`}
|
||
left={0}
|
||
right={0}
|
||
height="2px"
|
||
opacity={0.15 + (i % 3) * 0.05}
|
||
>
|
||
<Box
|
||
position="absolute"
|
||
width="200px"
|
||
height="100%"
|
||
bgGradient={i % 2 === 0
|
||
? "linear(to-r, transparent, rgba(239, 68, 68, 0.8), rgba(239, 68, 68, 0.4), transparent)"
|
||
: "linear(to-r, transparent, rgba(34, 197, 94, 0.8), rgba(34, 197, 94, 0.4), transparent)"
|
||
}
|
||
css={{
|
||
animation: `${klineFlowAnimation} ${8 + i * 2}s linear infinite`,
|
||
animationDelay: `${i * 1.5}s`,
|
||
}}
|
||
/>
|
||
</Box>
|
||
))}
|
||
|
||
{/* 垂直扫描线 */}
|
||
<Box
|
||
position="absolute"
|
||
top={0}
|
||
bottom={0}
|
||
width="1px"
|
||
left="20%"
|
||
bgGradient="linear(to-b, transparent, rgba(139, 92, 246, 0.5), transparent)"
|
||
css={{ animation: `${gridScanAnimation} 4s ease-in-out infinite` }}
|
||
/>
|
||
<Box
|
||
position="absolute"
|
||
top={0}
|
||
bottom={0}
|
||
width="1px"
|
||
left="50%"
|
||
bgGradient="linear(to-b, transparent, rgba(6, 182, 212, 0.5), transparent)"
|
||
css={{ animation: `${gridScanAnimation} 5s ease-in-out infinite 1s` }}
|
||
/>
|
||
<Box
|
||
position="absolute"
|
||
top={0}
|
||
bottom={0}
|
||
width="1px"
|
||
left="80%"
|
||
bgGradient="linear(to-b, transparent, rgba(168, 85, 247, 0.5), transparent)"
|
||
css={{ animation: `${gridScanAnimation} 6s ease-in-out infinite 2s` }}
|
||
/>
|
||
|
||
{/* 脉冲光点 */}
|
||
{[
|
||
{ top: '20%', left: '15%', color: 'rgba(239, 68, 68, 0.6)', delay: '0s' },
|
||
{ top: '40%', left: '75%', color: 'rgba(34, 197, 94, 0.6)', delay: '1s' },
|
||
{ top: '60%', left: '30%', color: 'rgba(139, 92, 246, 0.6)', delay: '2s' },
|
||
{ top: '80%', left: '60%', color: 'rgba(6, 182, 212, 0.6)', delay: '3s' },
|
||
{ top: '30%', left: '90%', color: 'rgba(239, 68, 68, 0.5)', delay: '0.5s' },
|
||
{ top: '70%', left: '10%', color: 'rgba(34, 197, 94, 0.5)', delay: '1.5s' },
|
||
].map((dot, i) => (
|
||
<Box
|
||
key={i}
|
||
position="absolute"
|
||
top={dot.top}
|
||
left={dot.left}
|
||
width="6px"
|
||
height="6px"
|
||
borderRadius="full"
|
||
bg={dot.color}
|
||
boxShadow={`0 0 20px ${dot.color}`}
|
||
css={{
|
||
animation: `${pulseGlowAnimation} 3s ease-in-out infinite`,
|
||
animationDelay: dot.delay,
|
||
}}
|
||
/>
|
||
))}
|
||
|
||
{/* 顶部渐变遮罩 */}
|
||
<Box
|
||
position="absolute"
|
||
top={0}
|
||
left={0}
|
||
right={0}
|
||
height="30%"
|
||
bgGradient="linear(to-b, rgba(15, 23, 42, 0.8), transparent)"
|
||
/>
|
||
|
||
{/* 底部渐变遮罩 */}
|
||
<Box
|
||
position="absolute"
|
||
bottom={0}
|
||
left={0}
|
||
right={0}
|
||
height="30%"
|
||
bgGradient="linear(to-t, rgba(15, 23, 42, 0.9), transparent)"
|
||
/>
|
||
</Box>
|
||
);
|
||
|
||
const ConceptCenter = () => {
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const navigate = useNavigate();
|
||
const toast = useToast();
|
||
|
||
// 🎯 PostHog 事件追踪
|
||
const {
|
||
trackConceptSearched,
|
||
trackFilterApplied,
|
||
trackConceptClicked,
|
||
trackConceptStocksViewed,
|
||
trackConceptStockClicked,
|
||
trackConceptTimelineViewed,
|
||
trackPageChange,
|
||
trackViewModeChanged,
|
||
} = useConceptEvents({ navigate });
|
||
|
||
// 订阅权限管理
|
||
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
|
||
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
|
||
const [upgradeFeature, setUpgradeFeature] = useState('pro');
|
||
|
||
// 状态管理
|
||
const [concepts, setConcepts] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [sortBy, setSortBy] = useState('change_pct');
|
||
const [pageSize] = useState(12);
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [totalConcepts, setTotalConcepts] = useState(0);
|
||
const [totalPages, setTotalPages] = useState(1);
|
||
const [viewMode, setViewMode] = useState('list'); // 默认列表视图
|
||
|
||
// 层级筛选状态
|
||
const [hierarchyFilter, setHierarchyFilter] = useState({ lv1: null, lv2: null, lv3: null });
|
||
|
||
// 日期相关状态
|
||
const [selectedDate, setSelectedDate] = useState(null);
|
||
const [latestTradeDate, setLatestTradeDate] = useState(null);
|
||
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
|
||
const [selectedConceptForContent, setSelectedConceptForContent] = useState('');
|
||
// 股票详情Modal
|
||
const [isStockModalOpen, setIsStockModalOpen] = useState(false);
|
||
const [selectedConceptStocks, setSelectedConceptStocks] = useState(null);
|
||
const [selectedConceptName, setSelectedConceptName] = useState('');
|
||
const [isTimelineModalOpen, setIsTimelineModalOpen] = useState(false);
|
||
const [selectedConceptId, setSelectedConceptId] = useState('');
|
||
const [selectedConceptStocksForTimeline, setSelectedConceptStocksForTimeline] = useState([]);
|
||
// 股票行情数据状态
|
||
const [stockMarketData, setStockMarketData] = useState({});
|
||
const [loadingStockData, setLoadingStockData] = useState(false);
|
||
// 默认图片路径
|
||
const defaultImage = defaultEventImage;
|
||
|
||
// 获取最新交易日期
|
||
const fetchLatestTradeDate = useCallback(async () => {
|
||
try {
|
||
const response = await fetch(`${API_BASE_URL}/price/latest`);
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
if (data.latest_trade_date) {
|
||
const date = new Date(data.latest_trade_date);
|
||
setLatestTradeDate(date);
|
||
setSelectedDate(date);
|
||
return date;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error('ConceptCenter', 'fetchLatestTradeDate', error);
|
||
}
|
||
return null;
|
||
}, []);
|
||
// 打开内容模态框(新闻和研报)- 需要Max版权限
|
||
const handleViewContent = (e, conceptName, conceptId, stocks = []) => {
|
||
e.stopPropagation();
|
||
|
||
// 检查历史时间轴权限
|
||
if (!hasFeatureAccess('concept_timeline')) {
|
||
const recommendation = getUpgradeRecommendation('concept_timeline');
|
||
setUpgradeFeature(recommendation?.required || 'max');
|
||
setUpgradeModalOpen(true);
|
||
return;
|
||
}
|
||
|
||
// 🎯 追踪历史时间轴查看
|
||
trackConceptTimelineViewed(conceptName, conceptId);
|
||
|
||
setSelectedConceptForContent(conceptName);
|
||
setSelectedConceptId(conceptId);
|
||
setSelectedConceptStocksForTimeline(stocks || []);
|
||
setIsTimelineModalOpen(true);
|
||
};
|
||
|
||
|
||
// 格式化日期
|
||
const formatDate = (dateStr) => {
|
||
if (!dateStr) return '未知日期';
|
||
try {
|
||
const date = new Date(dateStr);
|
||
return date.toLocaleDateString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
} catch {
|
||
return dateStr;
|
||
}
|
||
};
|
||
|
||
// 截取文本
|
||
const truncateText = (text, maxLength = 200) => {
|
||
if (!text) return '';
|
||
if (text.length <= maxLength) return text;
|
||
return text.substring(0, maxLength) + '...';
|
||
};
|
||
|
||
// 从URL获取参数
|
||
const getFiltersFromUrl = useCallback(() => {
|
||
const q = searchParams.get('q') || '';
|
||
let defaultSort = q ? '_score' : 'change_pct';
|
||
|
||
return {
|
||
q: q,
|
||
sort: searchParams.get('sort') || defaultSort,
|
||
page: parseInt(searchParams.get('page') || '1', 10),
|
||
date: searchParams.get('date') || null,
|
||
size: 12,
|
||
// 层级筛选参数
|
||
lv1: searchParams.get('lv1') || null,
|
||
lv2: searchParams.get('lv2') || null,
|
||
lv3: searchParams.get('lv3') || null,
|
||
};
|
||
}, [searchParams]);
|
||
|
||
// 更新URL参数
|
||
const updateUrlParams = useCallback((params) => {
|
||
const newParams = new URLSearchParams(searchParams);
|
||
Object.entries(params).forEach(([key, value]) => {
|
||
if (value) {
|
||
newParams.set(key, value);
|
||
} else {
|
||
newParams.delete(key);
|
||
}
|
||
});
|
||
setSearchParams(newParams);
|
||
}, [searchParams, setSearchParams]);
|
||
|
||
// 获取概念数据
|
||
const fetchConcepts = useCallback(async (query = '', page = 1, date = selectedDate, customSortBy = null, filter = hierarchyFilter) => {
|
||
setLoading(true);
|
||
try {
|
||
const sortToUse = customSortBy !== null ? customSortBy : sortBy;
|
||
|
||
const requestBody = {
|
||
query: query,
|
||
size: pageSize,
|
||
page: page,
|
||
sort_by: sortToUse
|
||
};
|
||
|
||
if (date) {
|
||
requestBody.trade_date = date.toISOString().split('T')[0];
|
||
}
|
||
|
||
// 添加层级筛选参数
|
||
if (filter?.lv1) {
|
||
requestBody.filter_lv1 = filter.lv1;
|
||
}
|
||
if (filter?.lv2) {
|
||
requestBody.filter_lv2 = filter.lv2;
|
||
}
|
||
|
||
const response = await fetch(`${API_BASE_URL}/search`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(requestBody),
|
||
});
|
||
|
||
if (!response.ok) throw new Error('搜索失败');
|
||
|
||
const data = await response.json();
|
||
|
||
setConcepts(data.results || []);
|
||
setTotalConcepts(data.total || 0);
|
||
setTotalPages(data.total_pages || 1);
|
||
setCurrentPage(data.page || 1);
|
||
|
||
if (data.price_date) {
|
||
setSelectedDate(new Date(data.price_date));
|
||
}
|
||
} catch (error) {
|
||
logger.error('ConceptCenter', 'fetchConcepts', error, { query, page, date: date?.toISOString(), sortToUse, filter });
|
||
|
||
// ❌ 移除获取数据失败toast
|
||
// toast({ title: '获取数据失败', description: error.message, status: 'error', duration: 3000, isClosable: true });
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [pageSize, sortBy, hierarchyFilter]);
|
||
|
||
// 清除搜索
|
||
const handleClearSearch = () => {
|
||
setSearchQuery('');
|
||
setSortBy('change_pct');
|
||
setCurrentPage(1);
|
||
setHierarchyFilter({ lv1: null, lv2: null, lv3: null });
|
||
updateUrlParams({ q: '', page: 1, sort: 'change_pct', lv1: '', lv2: '', lv3: '' });
|
||
fetchConcepts('', 1, selectedDate, 'change_pct', { lv1: null, lv2: null, lv3: null });
|
||
};
|
||
|
||
// 处理层级筛选选择(从 HierarchyView 点击分类 - 仅在需要时使用)
|
||
// 注意:热力图视图现在是独立的钻取交互,不再自动切换到列表视图
|
||
const handleHierarchySelect = useCallback((filter) => {
|
||
logger.info('ConceptCenter', '层级筛选选择', filter);
|
||
|
||
setHierarchyFilter(filter);
|
||
setCurrentPage(1);
|
||
// 不再自动切换视图,热力图内部自己处理钻取
|
||
|
||
// 更新 URL 参数
|
||
updateUrlParams({
|
||
lv1: filter.lv1 || '',
|
||
lv2: filter.lv2 || '',
|
||
lv3: filter.lv3 || '',
|
||
page: 1
|
||
});
|
||
|
||
// 重新获取数据(用于其他视图)
|
||
fetchConcepts(searchQuery, 1, selectedDate, sortBy, filter);
|
||
}, [searchQuery, selectedDate, sortBy, updateUrlParams, fetchConcepts]);
|
||
|
||
// 清除层级筛选
|
||
const handleClearHierarchyFilter = useCallback(() => {
|
||
setHierarchyFilter({ lv1: null, lv2: null, lv3: null });
|
||
setCurrentPage(1);
|
||
updateUrlParams({ lv1: '', lv2: '', lv3: '', page: 1 });
|
||
fetchConcepts(searchQuery, 1, selectedDate, sortBy, { lv1: null, lv2: null, lv3: null });
|
||
}, [searchQuery, selectedDate, sortBy, updateUrlParams, fetchConcepts]);
|
||
|
||
// 导航到特定层级
|
||
const handleNavigateHierarchy = useCallback((filter) => {
|
||
setHierarchyFilter(filter);
|
||
setCurrentPage(1);
|
||
updateUrlParams({
|
||
lv1: filter.lv1 || '',
|
||
lv2: filter.lv2 || '',
|
||
lv3: filter.lv3 || '',
|
||
page: 1
|
||
});
|
||
fetchConcepts(searchQuery, 1, selectedDate, sortBy, filter);
|
||
}, [searchQuery, selectedDate, sortBy, updateUrlParams, fetchConcepts]);
|
||
|
||
// 处理搜索
|
||
const handleSearch = () => {
|
||
setCurrentPage(1);
|
||
|
||
let newSortBy = sortBy;
|
||
if (searchQuery && searchQuery.trim() !== '') {
|
||
newSortBy = '_score';
|
||
setSortBy('_score');
|
||
} else if (!searchQuery || searchQuery.trim() === '') {
|
||
newSortBy = 'change_pct';
|
||
setSortBy('change_pct');
|
||
}
|
||
|
||
// 🎯 追踪搜索查询(在fetchConcepts后追踪结果数量)
|
||
updateUrlParams({ q: searchQuery, page: 1, sort: newSortBy });
|
||
fetchConcepts(searchQuery, 1, selectedDate, newSortBy).then(() => {
|
||
if (searchQuery && searchQuery.trim() !== '') {
|
||
// 使用当前 concepts.length 作为结果数量
|
||
setTimeout(() => trackConceptSearched(searchQuery, concepts.length), 100);
|
||
}
|
||
});
|
||
};
|
||
|
||
// 处理Enter键搜索
|
||
const handleKeyPress = (e) => {
|
||
if (e.key === 'Enter') {
|
||
handleSearch();
|
||
}
|
||
};
|
||
|
||
// 处理排序变化
|
||
const handleSortChange = (value) => {
|
||
const previousSort = sortBy;
|
||
|
||
// 🎯 追踪排序变化
|
||
trackFilterApplied('sort', value, previousSort);
|
||
|
||
setSortBy(value);
|
||
setCurrentPage(1);
|
||
updateUrlParams({ sort: value, page: 1 });
|
||
fetchConcepts(searchQuery, 1, selectedDate, value);
|
||
};
|
||
|
||
// 处理日期变化
|
||
const handleDateChange = (e) => {
|
||
const date = new Date(e.target.value);
|
||
const previousDate = selectedDate ? selectedDate.toISOString().split('T')[0] : null;
|
||
|
||
// 🎯 追踪日期变化
|
||
trackFilterApplied('date', e.target.value, previousDate);
|
||
|
||
setSelectedDate(date);
|
||
setCurrentPage(1);
|
||
updateUrlParams({ date: e.target.value, page: 1 });
|
||
fetchConcepts(searchQuery, 1, date, sortBy);
|
||
};
|
||
|
||
// 快速选择日期
|
||
const handleQuickDateSelect = (days) => {
|
||
const date = new Date();
|
||
date.setDate(date.getDate() - days);
|
||
setSelectedDate(date);
|
||
setCurrentPage(1);
|
||
const dateStr = date.toISOString().split('T')[0];
|
||
updateUrlParams({ date: dateStr, page: 1 });
|
||
fetchConcepts(searchQuery, 1, date, sortBy);
|
||
};
|
||
|
||
// 处理页码变化
|
||
const handlePageChange = (page) => {
|
||
// 🎯 追踪翻页
|
||
trackPageChange(page, { sort: sortBy, q: searchQuery, date: selectedDate?.toISOString().split('T')[0] });
|
||
|
||
setCurrentPage(page);
|
||
updateUrlParams({ page });
|
||
fetchConcepts(searchQuery, page, selectedDate, sortBy);
|
||
window.scrollTo(0, 0);
|
||
};
|
||
|
||
// 处理概念点击
|
||
const handleConceptClick = (conceptId, conceptName, concept = null, position = 0) => {
|
||
// 🎯 追踪概念点击
|
||
if (concept) {
|
||
trackConceptClicked(concept, position);
|
||
}
|
||
|
||
const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(conceptName)}.html`;
|
||
window.open(htmlPath, '_blank');
|
||
};
|
||
|
||
// 获取股票行情数据(使用批量接口,减少网络请求)
|
||
const fetchStockMarketData = async (stocks) => {
|
||
if (!stocks || stocks.length === 0) return;
|
||
|
||
setLoadingStockData(true);
|
||
const newMarketData = {};
|
||
|
||
try {
|
||
// 提取所有6位股票代码
|
||
const stockCodeMap = {}; // seccode -> fullCode 映射
|
||
const seccodes = [];
|
||
|
||
stocks.forEach((stock) => {
|
||
if (stock.stock_code) {
|
||
const seccode = stock.stock_code.substring(0, 6);
|
||
stockCodeMap[seccode] = stock.stock_code;
|
||
seccodes.push(seccode);
|
||
}
|
||
});
|
||
|
||
if (seccodes.length === 0) return;
|
||
|
||
// 使用批量接口一次性获取所有数据
|
||
const response = await marketService.getBatchTradeData(seccodes, 1);
|
||
|
||
if (response.success && response.data) {
|
||
Object.entries(response.data).forEach(([seccode, stockData]) => {
|
||
const fullCode = stockCodeMap[seccode];
|
||
if (fullCode && stockData.data?.length > 0) {
|
||
const latestData = stockData.data[stockData.data.length - 1];
|
||
newMarketData[fullCode] = { stock_code: fullCode, ...latestData };
|
||
}
|
||
});
|
||
}
|
||
|
||
setStockMarketData(newMarketData);
|
||
logger.info('ConceptCenter', '股票行情数据批量加载完成', { totalStocks: stocks.length, loadedCount: Object.keys(newMarketData).length });
|
||
} catch (error) {
|
||
logger.error('ConceptCenter', 'fetchStockMarketData', error, { stockCount: stocks?.length });
|
||
} finally {
|
||
setLoadingStockData(false);
|
||
}
|
||
};
|
||
|
||
// 打开股票详情Modal - 需要Pro版权限
|
||
const handleViewStocks = (e, concept) => {
|
||
e.stopPropagation();
|
||
|
||
// 检查热门个股权限
|
||
if (!hasFeatureAccess('hot_stocks')) {
|
||
const recommendation = getUpgradeRecommendation('hot_stocks');
|
||
setUpgradeFeature(recommendation?.required || 'pro');
|
||
setUpgradeModalOpen(true);
|
||
return;
|
||
}
|
||
|
||
// 🎯 追踪查看个股
|
||
trackConceptStocksViewed(concept.concept, concept.stocks?.length || 0);
|
||
|
||
setSelectedConceptStocks(concept.stocks || []);
|
||
setSelectedConceptName(concept.concept);
|
||
setStockMarketData({}); // 清空之前的数据
|
||
setIsStockModalOpen(true);
|
||
|
||
// 获取股票行情数据
|
||
fetchStockMarketData(concept.stocks || []);
|
||
};
|
||
|
||
// 格式化涨跌幅显示
|
||
const formatChangePercent = (value) => {
|
||
if (value === null || value === undefined) return null;
|
||
const formatted = value.toFixed(2);
|
||
return formatted > 0 ? `+${formatted}%` : `${formatted}%`;
|
||
};
|
||
|
||
// 获取涨跌幅颜色
|
||
const getChangeColor = (value) => {
|
||
if (value === null || value === undefined) return 'gray';
|
||
return value > 0 ? 'red' : value < 0 ? 'green' : 'gray';
|
||
};
|
||
|
||
// 格式化价格显示
|
||
const formatPrice = (value) => {
|
||
if (value === null || value === undefined) return '-';
|
||
return `¥${value.toFixed(2)}`;
|
||
};
|
||
|
||
// 格式化涨跌幅显示(股票表格专用)
|
||
const formatStockChangePercent = (value) => {
|
||
if (value === null || value === undefined) return '-';
|
||
const formatted = value.toFixed(2);
|
||
return value >= 0 ? `+${formatted}%` : `${formatted}%`;
|
||
};
|
||
|
||
// 获取涨跌幅颜色(股票表格专用)
|
||
const getStockChangeColor = (value) => {
|
||
if (value === null || value === undefined) return 'gray';
|
||
return value > 0 ? 'red' : value < 0 ? 'green' : 'gray';
|
||
};
|
||
|
||
// 生成公司详情链接
|
||
const generateCompanyLink = (stockCode) => {
|
||
if (!stockCode) return '#';
|
||
// 提取6位股票代码
|
||
const seccode = stockCode.substring(0, 6);
|
||
return `https://valuefrontier.cn/company?scode=${seccode}`;
|
||
};
|
||
|
||
// 获取最新爆发日期(从 outbreak_dates 数组中取最新的)
|
||
const getLatestOutbreakDate = (concept) => {
|
||
const dates = concept.outbreak_dates;
|
||
if (!dates || dates.length === 0) return null;
|
||
// 排序获取最新日期
|
||
const sortedDates = [...dates].sort((a, b) => new Date(b) - new Date(a));
|
||
return sortedDates[0];
|
||
};
|
||
|
||
// 格式化添加日期显示
|
||
const formatAddedDate = (concept) => {
|
||
// 优先使用 created_at 或 added_date 字段
|
||
const addedDate = concept.created_at || concept.added_date || concept.happened_times?.[0];
|
||
|
||
if (!addedDate) return null;
|
||
|
||
return (
|
||
<HStack spacing={2}>
|
||
<Icon as={Calendar} boxSize={3} color="blue.500" />
|
||
<Text fontSize="xs" color="gray.600" fontWeight="medium">
|
||
添加于 {new Date(addedDate).toLocaleDateString('zh-CN')}
|
||
</Text>
|
||
</HStack>
|
||
);
|
||
};
|
||
|
||
// 初始化加载
|
||
useEffect(() => {
|
||
const init = async () => {
|
||
const latestDate = await fetchLatestTradeDate();
|
||
const filters = getFiltersFromUrl();
|
||
setSearchQuery(filters.q);
|
||
setSortBy(filters.sort);
|
||
setCurrentPage(filters.page);
|
||
|
||
// 恢复层级筛选状态
|
||
const hierarchyFilterFromUrl = {
|
||
lv1: filters.lv1,
|
||
lv2: filters.lv2,
|
||
lv3: filters.lv3,
|
||
};
|
||
setHierarchyFilter(hierarchyFilterFromUrl);
|
||
|
||
const dateToUse = filters.date ? new Date(filters.date) : latestDate;
|
||
if (dateToUse) {
|
||
setSelectedDate(dateToUse);
|
||
fetchConcepts(filters.q, filters.page, dateToUse, filters.sort, hierarchyFilterFromUrl);
|
||
} else {
|
||
fetchConcepts(filters.q, filters.page, null, filters.sort, hierarchyFilterFromUrl);
|
||
}
|
||
};
|
||
init();
|
||
}, []);
|
||
|
||
// 获取股票名称(兼容新旧API格式)
|
||
const getStockName = (stock) => stock.name || stock.stock_name || '未知';
|
||
const getStockCode = (stock) => stock.code || stock.stock_code || '';
|
||
|
||
// 概念卡片组件 - 深色毛玻璃版(HeroUI风格)
|
||
const ConceptCard = ({ concept, position = 0 }) => {
|
||
const changePercent = concept.price_info?.avg_change_pct;
|
||
const changeColor = getChangeColor(changePercent);
|
||
const hasChange = changePercent !== null && changePercent !== undefined;
|
||
// H5 端使用更紧凑的尺寸
|
||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||
const coverHeight = useBreakpointValue({ base: '100px', md: '160px' });
|
||
const logoSize = useBreakpointValue({ base: '60px', md: '100px' });
|
||
|
||
// 生成随机涨幅数字背景
|
||
const generateNumbersBackground = () => {
|
||
const numbers = [];
|
||
for (let i = 0; i < 30; i++) {
|
||
const isPositive = Math.random() > 0.5;
|
||
const value = (Math.random() * 15).toFixed(2);
|
||
const sign = isPositive ? '+' : '-';
|
||
numbers.push(`${sign}${value}%`);
|
||
}
|
||
return numbers;
|
||
};
|
||
|
||
const backgroundNumbers = generateNumbersBackground();
|
||
|
||
return (
|
||
<Card
|
||
cursor="pointer"
|
||
onClick={() => handleConceptClick(concept.concept_id, concept.concept, concept, position)}
|
||
bg="rgba(15, 23, 42, 0.8)"
|
||
backdropFilter="blur(20px)"
|
||
borderWidth="1px"
|
||
borderColor="whiteAlpha.100"
|
||
overflow="hidden"
|
||
_hover={{
|
||
transform: 'translateY(-6px)',
|
||
boxShadow: '0 20px 40px rgba(139, 92, 246, 0.25)',
|
||
borderColor: 'purple.500',
|
||
}}
|
||
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||
position="relative"
|
||
boxShadow="0 4px 20px rgba(0, 0, 0, 0.3)"
|
||
borderRadius="2xl"
|
||
>
|
||
{/* 毛玻璃涨幅数字背景 */}
|
||
<Box position="relative" height={coverHeight} overflow="hidden">
|
||
{/* 渐变背景层 - 涨红跌绿 */}
|
||
<Box
|
||
position="absolute"
|
||
top={0}
|
||
left={0}
|
||
right={0}
|
||
bottom={0}
|
||
bgGradient={
|
||
hasChange && changePercent > 0
|
||
? "linear(135deg, rgba(153, 27, 27, 0.6) 0%, rgba(239, 68, 68, 0.4) 100%)"
|
||
: hasChange && changePercent < 0
|
||
? "linear(135deg, rgba(20, 83, 45, 0.6) 0%, rgba(34, 197, 94, 0.4) 100%)"
|
||
: "linear(135deg, rgba(71, 85, 105, 0.6) 0%, rgba(100, 116, 139, 0.4) 100%)"
|
||
}
|
||
/>
|
||
|
||
{/* 数字矩阵层 */}
|
||
<Box
|
||
position="absolute"
|
||
top={0}
|
||
left={0}
|
||
right={0}
|
||
bottom={0}
|
||
display="grid"
|
||
gridTemplateColumns="repeat(5, 1fr)"
|
||
gridTemplateRows="repeat(6, 1fr)"
|
||
gap={2}
|
||
p={3}
|
||
opacity={0.1}
|
||
>
|
||
{backgroundNumbers.map((num, idx) => (
|
||
<Flex
|
||
key={idx}
|
||
align="center"
|
||
justify="center"
|
||
fontSize="xs"
|
||
fontWeight="bold"
|
||
color="white"
|
||
transform={`rotate(${Math.random() * 20 - 10}deg)`}
|
||
>
|
||
{num}
|
||
</Flex>
|
||
))}
|
||
</Box>
|
||
|
||
{/* 公司 Logo 层 */}
|
||
<Box
|
||
position="absolute"
|
||
top="50%"
|
||
left="50%"
|
||
transform="translate(-50%, -50%)"
|
||
width={logoSize}
|
||
height={logoSize}
|
||
opacity={0.1}
|
||
>
|
||
<Image
|
||
src={`${process.env.PUBLIC_URL}/LOGO_badge.png`}
|
||
alt="Company Logo"
|
||
width="100%"
|
||
height="100%"
|
||
objectFit="contain"
|
||
/>
|
||
</Box>
|
||
|
||
{/* 高光效果 */}
|
||
<Box
|
||
position="absolute"
|
||
top={0}
|
||
left={0}
|
||
right={0}
|
||
height="50%"
|
||
bg="linear-gradient(180deg, rgba(255,255,255,0.08) 0%, transparent 100%)"
|
||
pointerEvents="none"
|
||
/>
|
||
|
||
{/* 左上角涨跌幅 Badge */}
|
||
{hasChange && (
|
||
<Badge
|
||
position="absolute"
|
||
top={3}
|
||
left={3}
|
||
bg={changeColor === 'red' ? 'rgba(239, 68, 68, 0.9)' : changeColor === 'green' ? 'rgba(34, 197, 94, 0.9)' : 'rgba(100, 116, 139, 0.9)'}
|
||
color="white"
|
||
fontSize="sm"
|
||
px={3}
|
||
py={1}
|
||
borderRadius="full"
|
||
fontWeight="bold"
|
||
boxShadow={`0 4px 12px rgba(${changeColor === 'red' ? '239, 68, 68' : changeColor === 'green' ? '34, 197, 94' : '100, 116, 139'}, 0.4)`}
|
||
display="flex"
|
||
alignItems="center"
|
||
gap={1}
|
||
animation={Math.abs(changePercent) > 5 ? `${pulseAnimation} 2s infinite` : 'none'}
|
||
>
|
||
<Icon
|
||
as={changePercent > 0 ? ArrowUp : changePercent < 0 ? ArrowDown : null}
|
||
boxSize={3}
|
||
/>
|
||
{formatChangePercent(changePercent)}
|
||
</Badge>
|
||
)}
|
||
|
||
{/* 右上角股票数量徽章 - 可点击 */}
|
||
<Badge
|
||
position="absolute"
|
||
top={3}
|
||
right={3}
|
||
bg="rgba(0, 0, 0, 0.4)"
|
||
backdropFilter="blur(10px)"
|
||
color="white"
|
||
fontSize="xs"
|
||
px={3}
|
||
py={1}
|
||
borderRadius="full"
|
||
fontWeight="medium"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.200"
|
||
cursor="pointer"
|
||
_hover={{
|
||
bg: 'rgba(139, 92, 246, 0.6)',
|
||
borderColor: 'purple.400',
|
||
}}
|
||
transition="all 0.2s"
|
||
onClick={(e) => handleViewStocks(e, concept)}
|
||
>
|
||
{concept.stock_count || 0} 只股票
|
||
</Badge>
|
||
</Box>
|
||
|
||
<CardBody p={{ base: 3, md: 4 }} bg="transparent">
|
||
<VStack align="start" spacing={{ base: 1, md: 2 }}>
|
||
{/* 概念名称 */}
|
||
<Heading
|
||
size={{ base: 'xs', md: 'sm' }}
|
||
color="white"
|
||
noOfLines={1}
|
||
fontWeight="bold"
|
||
letterSpacing="0.02em"
|
||
>
|
||
{concept.concept}
|
||
</Heading>
|
||
|
||
{/* 描述信息 - H5端显示1行 */}
|
||
<Text color="whiteAlpha.600" fontSize="xs" noOfLines={isMobile ? 1 : 2} minH={{ base: '16px', md: '32px' }}>
|
||
{concept.description || '暂无描述信息'}
|
||
</Text>
|
||
|
||
{concept.stocks && concept.stocks.length > 0 && (
|
||
<Box
|
||
width="100%"
|
||
p={{ base: 2, md: 3 }}
|
||
bg="whiteAlpha.50"
|
||
backdropFilter="blur(10px)"
|
||
borderRadius="xl"
|
||
cursor="pointer"
|
||
onClick={(e) => handleViewStocks(e, concept)}
|
||
_hover={{
|
||
bg: 'whiteAlpha.100',
|
||
transform: 'translateX(2px)',
|
||
}}
|
||
transition="all 0.2s"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.100"
|
||
>
|
||
<Flex align="center" justify="space-between">
|
||
<Box flex={1}>
|
||
<HStack spacing={2} mb={{ base: 1, md: 2 }}>
|
||
<Icon as={LineChart} boxSize={3} color="purple.300" />
|
||
<Text fontSize="xs" color="purple.200" fontWeight="bold">
|
||
热门个股
|
||
</Text>
|
||
{!hasFeatureAccess('hot_stocks') && (
|
||
<Badge
|
||
bg="yellow.500"
|
||
color="gray.900"
|
||
size="sm"
|
||
borderRadius="full"
|
||
px={2}
|
||
fontSize="xs"
|
||
>
|
||
🔒Pro
|
||
</Badge>
|
||
)}
|
||
</HStack>
|
||
<HStack spacing={1} flexWrap="wrap">
|
||
{hasFeatureAccess('hot_stocks') ? (
|
||
<>
|
||
{concept.stocks.slice(0, 2).map((stock, idx) => (
|
||
<Tag
|
||
key={idx}
|
||
size="sm"
|
||
bg="purple.500"
|
||
color="white"
|
||
borderRadius="full"
|
||
px={2}
|
||
cursor="pointer"
|
||
_hover={{ bg: 'purple.400', transform: 'scale(1.05)' }}
|
||
transition="all 0.2s"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
window.open(generateCompanyLink(getStockCode(stock)), '_blank');
|
||
}}
|
||
>
|
||
<TagLabel fontSize="xs">{getStockName(stock)}</TagLabel>
|
||
</Tag>
|
||
))}
|
||
{concept.stocks.length > 2 && (
|
||
<Text fontSize="xs" color="purple.300" fontWeight="bold">
|
||
+{concept.stocks.length - 2}
|
||
</Text>
|
||
)}
|
||
</>
|
||
) : (
|
||
<HStack spacing={1}>
|
||
<Icon as={Lock} boxSize="10px" color="yellow.400" />
|
||
<Text fontSize="xs" color="yellow.400" fontWeight="medium">
|
||
升级查看{concept.stocks.length}只个股
|
||
</Text>
|
||
</HStack>
|
||
)}
|
||
</HStack>
|
||
</Box>
|
||
<Icon
|
||
as={hasFeatureAccess('hot_stocks') ? ChevronRight : Lock}
|
||
color={hasFeatureAccess('hot_stocks') ? 'purple.300' : 'yellow.400'}
|
||
boxSize={4}
|
||
/>
|
||
</Flex>
|
||
</Box>
|
||
)}
|
||
|
||
<Divider borderColor="whiteAlpha.100" my={{ base: 1, md: 0 }} />
|
||
|
||
<Flex width="100%" justify="space-between" align="center">
|
||
{/* 日期显示 - 根据排序方式显示爆发日期或添加日期 */}
|
||
{(() => {
|
||
const latestOutbreak = getLatestOutbreakDate(concept);
|
||
const addedDate = concept.created_at || concept.added_date || concept.happened_times?.[0];
|
||
|
||
// 优先显示爆发日期(如果存在)
|
||
if (latestOutbreak) {
|
||
return (
|
||
<HStack spacing={2}>
|
||
<Icon as={Zap} boxSize={3} color="orange.400" />
|
||
<Text fontSize="xs" color="orange.300" fontWeight="medium">
|
||
爆发于 {new Date(latestOutbreak).toLocaleDateString('zh-CN')}
|
||
</Text>
|
||
</HStack>
|
||
);
|
||
} else if (addedDate) {
|
||
return (
|
||
<HStack spacing={2}>
|
||
<Icon as={Calendar} boxSize={3} color="cyan.400" />
|
||
<Text fontSize="xs" color="whiteAlpha.600" fontWeight="medium">
|
||
添加于 {new Date(addedDate).toLocaleDateString('zh-CN')}
|
||
</Text>
|
||
</HStack>
|
||
);
|
||
}
|
||
return <Box />;
|
||
})()}
|
||
|
||
<Button
|
||
size={{ base: 'xs', md: 'sm' }}
|
||
leftIcon={<History />}
|
||
bg="purple.500"
|
||
color="white"
|
||
variant="solid"
|
||
onClick={(e) => handleViewContent(e, concept.concept, concept.concept_id, concept.stocks)}
|
||
borderRadius="full"
|
||
px={{ base: 2, md: 4 }}
|
||
fontWeight="medium"
|
||
boxShadow="0 4px 12px rgba(139, 92, 246, 0.4)"
|
||
_hover={{
|
||
bg: 'purple.400',
|
||
transform: 'scale(1.05)',
|
||
boxShadow: '0 6px 16px rgba(139, 92, 246, 0.5)',
|
||
}}
|
||
transition="all 0.2s"
|
||
>
|
||
时间轴
|
||
</Button>
|
||
</Flex>
|
||
</VStack>
|
||
</CardBody>
|
||
|
||
{/* 顶部发光条 */}
|
||
<Box
|
||
position="absolute"
|
||
top={0}
|
||
left={0}
|
||
right={0}
|
||
height="2px"
|
||
bgGradient="linear(to-r, purple.500, pink.500, cyan.500)"
|
||
opacity={0}
|
||
_groupHover={{ opacity: 1 }}
|
||
transition="opacity 0.3s"
|
||
/>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
// 概念列表项组件 - 列表视图(深色主题版)
|
||
const ConceptListItem = ({ concept, position = 0 }) => {
|
||
const changePercent = concept.price_info?.avg_change_pct;
|
||
const changeColor = getChangeColor(changePercent);
|
||
const hasChange = changePercent !== null && changePercent !== undefined;
|
||
|
||
return (
|
||
<Card
|
||
cursor="pointer"
|
||
onClick={() => handleConceptClick(concept.concept_id, concept.concept, concept, position)}
|
||
bg="rgba(15, 23, 42, 0.8)"
|
||
backdropFilter="blur(20px)"
|
||
borderWidth="1px"
|
||
borderColor="whiteAlpha.100"
|
||
overflow="hidden"
|
||
borderRadius="2xl"
|
||
_hover={{
|
||
transform: 'translateX(4px)',
|
||
boxShadow: '0 8px 32px rgba(139, 92, 246, 0.25)',
|
||
borderColor: 'purple.500',
|
||
}}
|
||
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||
>
|
||
<CardBody p={{ base: 4, md: 6 }}>
|
||
<Flex align="center" gap={{ base: 3, md: 6 }} flexWrap={{ base: 'wrap', md: 'nowrap' }}>
|
||
{/* 左侧图标区域 */}
|
||
<Box
|
||
width={{ base: '60px', md: '80px' }}
|
||
height={{ base: '60px', md: '80px' }}
|
||
borderRadius="xl"
|
||
bg={hasChange
|
||
? changePercent > 0
|
||
? "linear-gradient(135deg, rgba(153, 27, 27, 0.6) 0%, rgba(239, 68, 68, 0.4) 100%)"
|
||
: changePercent < 0
|
||
? "linear-gradient(135deg, rgba(20, 83, 45, 0.6) 0%, rgba(34, 197, 94, 0.4) 100%)"
|
||
: "linear-gradient(135deg, rgba(71, 85, 105, 0.6) 0%, rgba(100, 116, 139, 0.4) 100%)"
|
||
: "linear-gradient(135deg, rgba(99, 102, 241, 0.4) 0%, rgba(168, 85, 247, 0.3) 100%)"
|
||
}
|
||
display="flex"
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
position="relative"
|
||
flexShrink={0}
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.100"
|
||
>
|
||
<Icon as={Tags} boxSize={{ base: 6, md: 8 }} color="white" opacity={0.8} />
|
||
{hasChange && (
|
||
<Badge
|
||
position="absolute"
|
||
top={-2}
|
||
right={-2}
|
||
bg={changeColor === 'red' ? 'rgba(239, 68, 68, 0.9)' : changeColor === 'green' ? 'rgba(34, 197, 94, 0.9)' : 'rgba(100, 116, 139, 0.9)'}
|
||
color="white"
|
||
fontSize="xs"
|
||
px={2}
|
||
py={1}
|
||
borderRadius="full"
|
||
fontWeight="bold"
|
||
minW="auto"
|
||
boxShadow={`0 2px 8px rgba(${changeColor === 'red' ? '239, 68, 68' : changeColor === 'green' ? '34, 197, 94' : '100, 116, 139'}, 0.4)`}
|
||
>
|
||
<Icon
|
||
as={changePercent > 0 ? ArrowUp : changePercent < 0 ? ArrowDown : null}
|
||
boxSize={2}
|
||
mr={1}
|
||
/>
|
||
{formatChangePercent(changePercent)}
|
||
</Badge>
|
||
)}
|
||
</Box>
|
||
|
||
{/* 中间内容区域 */}
|
||
<Box flex={1}>
|
||
<VStack align="start" spacing={{ base: 2, md: 3 }}>
|
||
<Heading size={{ base: 'sm', md: 'md' }} color="white" noOfLines={1}>
|
||
{concept.concept}
|
||
</Heading>
|
||
|
||
<Text color="whiteAlpha.600" fontSize="sm" noOfLines={2}>
|
||
{concept.description || '该概念板块涵盖相关技术、产业链和市场应用等多个维度的投资机会'}
|
||
</Text>
|
||
|
||
<HStack spacing={4} flexWrap="wrap">
|
||
<HStack
|
||
spacing={1}
|
||
cursor="pointer"
|
||
onClick={(e) => handleViewStocks(e, concept)}
|
||
_hover={{ color: 'purple.300' }}
|
||
transition="color 0.2s"
|
||
>
|
||
<Icon as={LineChart} boxSize={4} color="purple.300" />
|
||
<Text fontSize="sm" fontWeight="medium" color="whiteAlpha.800" _groupHover={{ color: 'purple.300' }}>
|
||
{concept.stock_count || 0} 只股票
|
||
</Text>
|
||
</HStack>
|
||
|
||
{hasChange && concept.price_info?.trade_date && (
|
||
<HStack spacing={1}>
|
||
<Icon as={CalendarDays} boxSize={4} color="cyan.400" />
|
||
<Text fontSize="sm" color="whiteAlpha.600">
|
||
{new Date(concept.price_info.trade_date).toLocaleDateString('zh-CN')}
|
||
</Text>
|
||
</HStack>
|
||
)}
|
||
|
||
{/* 日期显示 - 优先显示爆发日期 */}
|
||
{(() => {
|
||
const latestOutbreak = getLatestOutbreakDate(concept);
|
||
const addedDate = concept.created_at || concept.added_date || concept.happened_times?.[0];
|
||
|
||
if (latestOutbreak) {
|
||
return (
|
||
<HStack spacing={1}>
|
||
<Icon as={Zap} boxSize={4} color="orange.400" />
|
||
<Text fontSize="sm" color="orange.300" fontWeight="medium">
|
||
爆发于 {new Date(latestOutbreak).toLocaleDateString('zh-CN')}
|
||
</Text>
|
||
</HStack>
|
||
);
|
||
} else if (addedDate) {
|
||
return (
|
||
<HStack spacing={1}>
|
||
<Icon as={Calendar} boxSize={4} color="cyan.400" />
|
||
<Text fontSize="sm" color="whiteAlpha.600">
|
||
添加于 {new Date(addedDate).toLocaleDateString('zh-CN')}
|
||
</Text>
|
||
</HStack>
|
||
);
|
||
}
|
||
return null;
|
||
})()}
|
||
</HStack>
|
||
</VStack>
|
||
</Box>
|
||
|
||
{/* 右侧操作区域 */}
|
||
<VStack spacing={3} align="end" flexShrink={0} display={{ base: 'none', md: 'flex' }}>
|
||
<HStack spacing={3}>
|
||
<Button
|
||
size="sm"
|
||
leftIcon={<Eye size={16} />}
|
||
bg="whiteAlpha.100"
|
||
color="white"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.200"
|
||
onClick={(e) => handleViewStocks(e, concept)}
|
||
borderRadius="full"
|
||
_hover={{ bg: 'whiteAlpha.200', borderColor: 'whiteAlpha.300' }}
|
||
>
|
||
查看个股
|
||
</Button>
|
||
|
||
<Button
|
||
size="sm"
|
||
leftIcon={<LineChart />}
|
||
bg="purple.500"
|
||
color="white"
|
||
onClick={(e) => handleViewContent(e, concept.concept, concept.concept_id, concept.stocks)}
|
||
borderRadius="full"
|
||
boxShadow="0 4px 12px rgba(139, 92, 246, 0.4)"
|
||
_hover={{ bg: 'purple.400', boxShadow: '0 6px 16px rgba(139, 92, 246, 0.5)' }}
|
||
>
|
||
历史时间轴
|
||
</Button>
|
||
</HStack>
|
||
|
||
{concept.stocks && concept.stocks.length > 0 && (
|
||
<Box>
|
||
<HStack spacing={1} mb={2}>
|
||
<Text fontSize="xs" color="whiteAlpha.500">热门个股</Text>
|
||
{!hasFeatureAccess('hot_stocks') && (
|
||
<Badge bg="yellow.500" color="gray.900" size="sm" fontSize="xs">
|
||
🔒需Pro
|
||
</Badge>
|
||
)}
|
||
</HStack>
|
||
<Wrap spacing={1} justify="end">
|
||
{hasFeatureAccess('hot_stocks') ? (
|
||
<>
|
||
{concept.stocks.slice(0, 3).map((stock, idx) => (
|
||
<WrapItem key={idx}>
|
||
<Tag
|
||
size="sm"
|
||
bg="purple.500"
|
||
color="white"
|
||
borderRadius="full"
|
||
cursor="pointer"
|
||
_hover={{ bg: 'purple.400', transform: 'scale(1.05)' }}
|
||
transition="all 0.2s"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
window.open(generateCompanyLink(getStockCode(stock)), '_blank');
|
||
}}
|
||
>
|
||
<TagLabel fontSize="xs">{getStockName(stock)}</TagLabel>
|
||
</Tag>
|
||
</WrapItem>
|
||
))}
|
||
{concept.stocks.length > 3 && (
|
||
<WrapItem>
|
||
<Text
|
||
fontSize="xs"
|
||
color="purple.300"
|
||
fontWeight="medium"
|
||
cursor="pointer"
|
||
_hover={{ color: 'purple.200', textDecoration: 'underline' }}
|
||
onClick={(e) => handleViewStocks(e, concept)}
|
||
>
|
||
+{concept.stocks.length - 3}更多
|
||
</Text>
|
||
</WrapItem>
|
||
)}
|
||
</>
|
||
) : (
|
||
<WrapItem>
|
||
<HStack spacing={1}>
|
||
<Icon as={Lock} boxSize="8px" color="yellow.400" />
|
||
<Text fontSize="xs" color="yellow.400" fontWeight="medium">
|
||
升级查看{concept.stocks.length}只
|
||
</Text>
|
||
</HStack>
|
||
</WrapItem>
|
||
)}
|
||
</Wrap>
|
||
</Box>
|
||
)}
|
||
</VStack>
|
||
</Flex>
|
||
</CardBody>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
// 骨架屏组件 - 深色主题
|
||
const SkeletonCard = () => (
|
||
<Card bg="rgba(15, 23, 42, 0.8)" borderWidth="1px" borderColor="whiteAlpha.100" borderRadius="2xl">
|
||
<Skeleton height="160px" startColor="whiteAlpha.100" endColor="whiteAlpha.200" />
|
||
<CardBody>
|
||
<SkeletonText mt={4} noOfLines={4} spacing={4} startColor="whiteAlpha.100" endColor="whiteAlpha.200" />
|
||
</CardBody>
|
||
</Card>
|
||
);
|
||
|
||
// 日期选择组件 - 深色主题
|
||
const DateSelector = () => (
|
||
<Box
|
||
p={4}
|
||
bg="rgba(15, 23, 42, 0.8)"
|
||
backdropFilter="blur(20px)"
|
||
borderRadius="2xl"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.100"
|
||
boxShadow="0 4px 20px rgba(0, 0, 0, 0.3)"
|
||
>
|
||
<Flex
|
||
direction={{ base: 'column', lg: 'row' }}
|
||
align={{ base: 'stretch', lg: 'center' }}
|
||
gap={4}
|
||
>
|
||
{/* 使用通用日期选择器组件 - 不显示最新日期提示,由下方单独渲染 */}
|
||
<TradeDatePicker
|
||
value={selectedDate}
|
||
onChange={(date) => {
|
||
const dateStr = date.toISOString().split('T')[0];
|
||
const previousDate = selectedDate ? selectedDate.toISOString().split('T')[0] : null;
|
||
trackFilterApplied('date', dateStr, previousDate);
|
||
setSelectedDate(date);
|
||
setCurrentPage(1);
|
||
updateUrlParams({ date: dateStr, page: 1 });
|
||
fetchConcepts(searchQuery, 1, date, sortBy);
|
||
}}
|
||
latestTradeDate={latestTradeDate}
|
||
label="交易日期"
|
||
isDarkMode={true}
|
||
showLatestTradeDateTip={false}
|
||
/>
|
||
|
||
{/* 快捷按钮 - 紧跟日期选择器 */}
|
||
<ButtonGroup size="sm" flexWrap="wrap" spacing={2}>
|
||
<Button
|
||
onClick={() => handleQuickDateSelect(0)}
|
||
bg="whiteAlpha.100"
|
||
color="white"
|
||
borderRadius="full"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.200"
|
||
px={4}
|
||
_hover={{
|
||
bg: 'purple.500',
|
||
borderColor: 'purple.500',
|
||
boxShadow: '0 0 12px rgba(139, 92, 246, 0.4)',
|
||
}}
|
||
transition="all 0.2s"
|
||
>
|
||
今天
|
||
</Button>
|
||
<Button
|
||
onClick={() => handleQuickDateSelect(1)}
|
||
bg="whiteAlpha.100"
|
||
color="white"
|
||
borderRadius="full"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.200"
|
||
px={4}
|
||
_hover={{
|
||
bg: 'purple.500',
|
||
borderColor: 'purple.500',
|
||
boxShadow: '0 0 12px rgba(139, 92, 246, 0.4)',
|
||
}}
|
||
transition="all 0.2s"
|
||
>
|
||
昨天
|
||
</Button>
|
||
<Button
|
||
onClick={() => handleQuickDateSelect(7)}
|
||
bg="whiteAlpha.100"
|
||
color="white"
|
||
borderRadius="full"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.200"
|
||
px={4}
|
||
_hover={{
|
||
bg: 'purple.500',
|
||
borderColor: 'purple.500',
|
||
boxShadow: '0 0 12px rgba(139, 92, 246, 0.4)',
|
||
}}
|
||
transition="all 0.2s"
|
||
>
|
||
一周前
|
||
</Button>
|
||
<Button
|
||
onClick={() => handleQuickDateSelect(30)}
|
||
bg="whiteAlpha.100"
|
||
color="white"
|
||
borderRadius="full"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.200"
|
||
px={4}
|
||
_hover={{
|
||
bg: 'purple.500',
|
||
borderColor: 'purple.500',
|
||
boxShadow: '0 0 12px rgba(139, 92, 246, 0.4)',
|
||
}}
|
||
transition="all 0.2s"
|
||
>
|
||
一月前
|
||
</Button>
|
||
</ButtonGroup>
|
||
|
||
{/* 最新交易日期提示 - 靠右显示 */}
|
||
{latestTradeDate && (
|
||
<Tooltip label="数据库中最新的交易日期">
|
||
<HStack
|
||
spacing={1.5}
|
||
ml="auto"
|
||
px={2}
|
||
py={1}
|
||
opacity={0.7}
|
||
_hover={{ opacity: 1 }}
|
||
transition="opacity 0.2s"
|
||
>
|
||
<Icon as={Info} color="blue.300" boxSize={3} />
|
||
<Text fontSize="xs" color="blue.200">
|
||
数据更新至 {latestTradeDate.toLocaleDateString('zh-CN')}
|
||
</Text>
|
||
</HStack>
|
||
</Tooltip>
|
||
)}
|
||
</Flex>
|
||
</Box>
|
||
);
|
||
|
||
return (
|
||
<Box minH="100vh" position="relative" overflow="hidden">
|
||
{/* 金融数据流动动画背景 */}
|
||
<FinanceFlowBackground />
|
||
|
||
{/* 导航栏已由 MainLayout 提供 */}
|
||
|
||
{/* Hero Section - 精简版 */}
|
||
{/* Hero Section - 使用负 margin 抵消 Layout 的 padding 实现全宽背景 */}
|
||
<Box
|
||
position="relative"
|
||
bgGradient="linear(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%)"
|
||
color="white"
|
||
overflow="hidden"
|
||
zIndex={1}
|
||
mx={{ base: -4, md: -6, lg: '-80px' }}
|
||
>
|
||
{/* 科幻网格背景 */}
|
||
<Box
|
||
position="absolute"
|
||
top="0"
|
||
left="0"
|
||
right="0"
|
||
bottom="0"
|
||
bgImage="linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px)"
|
||
bgSize="40px 40px"
|
||
opacity={0.3}
|
||
/>
|
||
|
||
{/* 发光球体 */}
|
||
<Box
|
||
position="absolute"
|
||
top="20%"
|
||
right="10%"
|
||
width="200px"
|
||
height="200px"
|
||
borderRadius="full"
|
||
bgGradient="radial(circle, rgba(139, 92, 246, 0.4), transparent 70%)"
|
||
filter="blur(40px)"
|
||
/>
|
||
<Box
|
||
position="absolute"
|
||
bottom="10%"
|
||
left="10%"
|
||
width="250px"
|
||
height="250px"
|
||
borderRadius="full"
|
||
bgGradient="radial(circle, rgba(59, 130, 246, 0.3), transparent 70%)"
|
||
filter="blur(50px)"
|
||
/>
|
||
|
||
<Box px={{ base: 4, md: 6, lg: '80px' }} position="relative" py={{ base: 8, md: 12 }}>
|
||
<VStack spacing={6}>
|
||
{/* 标题区域 */}
|
||
<VStack spacing={3} textAlign="center">
|
||
<HStack spacing={3} justify="center">
|
||
<Icon as={Brain} boxSize={10} color="cyan.300" filter="drop-shadow(0 0 10px rgba(6, 182, 212, 0.5))" />
|
||
</HStack>
|
||
|
||
<VStack spacing={1}>
|
||
<Heading
|
||
as="h1"
|
||
fontSize={{ base: "3xl", md: "4xl" }}
|
||
fontWeight="black"
|
||
bgGradient="linear(to-r, cyan.200, purple.200, pink.200)"
|
||
bgClip="text"
|
||
letterSpacing="tight"
|
||
textShadow="0 0 30px rgba(147, 197, 253, 0.3)"
|
||
>
|
||
概念中心
|
||
</Heading>
|
||
<HStack spacing={2} justify="center">
|
||
<Icon as={Clock} boxSize={3} color="cyan.200" />
|
||
<Text fontSize="xs" fontWeight="medium" opacity={0.8}>
|
||
数据约下午4点更新
|
||
</Text>
|
||
</HStack>
|
||
</VStack>
|
||
|
||
<Text fontSize="md" opacity={0.7} maxW="2xl">
|
||
AI驱动的概念板块分析平台 · 实时追踪市场热点 · 智能挖掘投资机会
|
||
</Text>
|
||
</VStack>
|
||
|
||
{/* 核心数据展示 */}
|
||
<HStack
|
||
spacing={6}
|
||
divider={<Box w="1px" h="30px" bg="whiteAlpha.300" />}
|
||
bg="whiteAlpha.100"
|
||
backdropFilter="blur(10px)"
|
||
px={8}
|
||
py={3}
|
||
borderRadius="full"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.300"
|
||
boxShadow="0 8px 32px rgba(0, 0, 0, 0.3)"
|
||
>
|
||
<HStack spacing={2}>
|
||
<Icon as={Tags} boxSize={4} color="cyan.300" />
|
||
<VStack spacing={0} align="start">
|
||
<Text fontSize="xl" fontWeight="bold" color="cyan.300">500+</Text>
|
||
<Text fontSize="xs" opacity={0.7}>概念板块</Text>
|
||
</VStack>
|
||
</HStack>
|
||
<HStack spacing={2}>
|
||
<Icon as={LineChart} boxSize={4} color="purple.300" />
|
||
<VStack spacing={0} align="start">
|
||
<Text fontSize="xl" fontWeight="bold" color="purple.300">5000+</Text>
|
||
<Text fontSize="xs" opacity={0.7}>相关个股</Text>
|
||
</VStack>
|
||
</HStack>
|
||
<HStack spacing={2}>
|
||
<Icon as={Zap} boxSize={4} color="yellow.300" />
|
||
<VStack spacing={0} align="start">
|
||
<Text fontSize="xl" fontWeight="bold" color="yellow.300">24/7</Text>
|
||
<Text fontSize="xs" opacity={0.7}>实时监控</Text>
|
||
</VStack>
|
||
</HStack>
|
||
</HStack>
|
||
|
||
{/* 搜索框 */}
|
||
<Box w="100%" maxW="3xl">
|
||
<VStack spacing={2}>
|
||
<Box w="100%" position="relative">
|
||
<Flex
|
||
align="center"
|
||
bg="rgba(255, 255, 255, 0.95)"
|
||
backdropFilter="blur(10px)"
|
||
borderRadius="full"
|
||
overflow="hidden"
|
||
boxShadow="0 8px 32px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(139, 92, 246, 0.2)"
|
||
transition="all 0.3s"
|
||
_hover={{
|
||
boxShadow: '0 12px 40px rgba(139, 92, 246, 0.4), 0 0 0 2px rgba(139, 92, 246, 0.4)',
|
||
transform: 'translateY(-2px)',
|
||
}}
|
||
>
|
||
<InputGroup size="lg" flex={1}>
|
||
<InputLeftElement pointerEvents="none" h="full">
|
||
<Icon as={Search} color="purple.400" boxSize={5} />
|
||
</InputLeftElement>
|
||
<Input
|
||
placeholder="搜索概念板块、个股、关键词..."
|
||
bg="transparent"
|
||
color="gray.800"
|
||
border="none"
|
||
fontSize="md"
|
||
fontWeight="medium"
|
||
pl={12}
|
||
_placeholder={{ color: 'gray.400' }}
|
||
_focus={{
|
||
outline: 'none',
|
||
boxShadow: 'none'
|
||
}}
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
onKeyPress={handleKeyPress}
|
||
pr={searchQuery ? "50px" : "16px"}
|
||
/>
|
||
{searchQuery && (
|
||
<IconButton
|
||
position="absolute"
|
||
right="10px"
|
||
top="50%"
|
||
transform="translateY(-50%)"
|
||
size="sm"
|
||
aria-label="清除搜索"
|
||
icon={<X size={16} />}
|
||
variant="ghost"
|
||
color="gray.500"
|
||
borderRadius="full"
|
||
_hover={{ color: 'gray.700', bg: 'gray.200' }}
|
||
onClick={handleClearSearch}
|
||
zIndex={1}
|
||
/>
|
||
)}
|
||
</InputGroup>
|
||
<Button
|
||
size="lg"
|
||
borderRadius="0 50px 50px 0"
|
||
bgGradient="linear(135deg, #667eea 0%, #764ba2 100%)"
|
||
color="white"
|
||
leftIcon={<Search size={16} />}
|
||
_hover={{
|
||
bgGradient: 'linear(135deg, #5568d3 0%, #663a8e 100%)',
|
||
transform: 'scale(1.02)',
|
||
}}
|
||
_active={{
|
||
bgGradient: 'linear(135deg, #4a5abf 0%, #58327a 100%)',
|
||
transform: 'scale(0.98)',
|
||
}}
|
||
onClick={handleSearch}
|
||
isLoading={loading}
|
||
loadingText="搜索中"
|
||
px={8}
|
||
minW="140px"
|
||
fontWeight="bold"
|
||
fontSize="md"
|
||
transition="all 0.2s"
|
||
border="none"
|
||
alignSelf="stretch"
|
||
boxShadow="inset 0 1px 0 rgba(255, 255, 255, 0.2)"
|
||
>
|
||
搜索
|
||
</Button>
|
||
</Flex>
|
||
</Box>
|
||
{searchQuery && sortBy === '_score' && (
|
||
<Text fontSize="xs" color="cyan.200" opacity={0.9}>
|
||
正在搜索 "{searchQuery}",已自动切换到相关度排序
|
||
</Text>
|
||
)}
|
||
</VStack>
|
||
</Box>
|
||
</VStack>
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* 主内容区域 - padding 由 MainLayout 统一设置 */}
|
||
<Box py={10} position="relative" zIndex={1}>
|
||
<Box mb={6}>
|
||
<DateSelector />
|
||
</Box>
|
||
|
||
{/* 双栏布局:左侧概念卡片,右侧统计面板 */}
|
||
<Flex gap={8} direction={{ base: 'column', xl: 'row' }}>
|
||
{/* 左侧概念卡片区域 */}
|
||
<Box flex={1}>
|
||
|
||
<Card
|
||
mb={8}
|
||
bg="rgba(15, 23, 42, 0.8)"
|
||
backdropFilter="blur(20px)"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.100"
|
||
boxShadow="0 4px 20px rgba(0, 0, 0, 0.3)"
|
||
borderRadius="2xl"
|
||
>
|
||
<CardBody>
|
||
<Flex
|
||
direction={{ base: 'column', md: 'row' }}
|
||
justify="space-between"
|
||
align={{ base: 'stretch', md: 'center' }}
|
||
gap={4}
|
||
>
|
||
{/* 排序方式 - 仅在列表视图显示 */}
|
||
{viewMode === 'list' && (
|
||
<HStack spacing={4} align="center">
|
||
<Icon as={Tags} boxSize={4} color="purple.300" />
|
||
<Text fontWeight="bold" color="white">排序方式:</Text>
|
||
<Select
|
||
value={sortBy}
|
||
onChange={(e) => handleSortChange(e.target.value)}
|
||
width="200px"
|
||
focusBorderColor="purple.400"
|
||
borderColor="whiteAlpha.300"
|
||
borderRadius="lg"
|
||
fontWeight="medium"
|
||
color="white"
|
||
bg="whiteAlpha.50"
|
||
_hover={{ borderColor: 'purple.400' }}
|
||
sx={{
|
||
option: {
|
||
bg: 'gray.800',
|
||
color: 'white',
|
||
},
|
||
}}
|
||
>
|
||
<option value="change_pct">涨跌幅</option>
|
||
<option value="_score">相关度</option>
|
||
<option value="stock_count">股票数量</option>
|
||
<option value="outbreak_date">爆发日期</option>
|
||
</Select>
|
||
{searchQuery && sortBy === '_score' && (
|
||
<Tooltip label="搜索时自动切换到相关度排序,以显示最匹配的结果。您也可以手动切换其他排序方式。">
|
||
<HStack
|
||
spacing={1}
|
||
bg="blue.500"
|
||
px={3}
|
||
py={1}
|
||
borderRadius="full"
|
||
boxShadow="0 0 10px rgba(59, 130, 246, 0.4)"
|
||
>
|
||
<Icon as={Info} color="white" boxSize={3} />
|
||
<Text fontSize="xs" color="white" fontWeight="medium">
|
||
智能排序
|
||
</Text>
|
||
</HStack>
|
||
</Tooltip>
|
||
)}
|
||
</HStack>
|
||
)}
|
||
|
||
<ButtonGroup size="sm" isAttached variant="outline" ml={viewMode !== 'list' ? 'auto' : undefined}>
|
||
<Tooltip label="概念矩形树图" placement="top">
|
||
<IconButton
|
||
icon={<BoxIcon size={16} />}
|
||
onClick={() => {
|
||
if (viewMode !== 'force3d') {
|
||
trackViewModeChanged('force3d', viewMode);
|
||
setViewMode('force3d');
|
||
}
|
||
}}
|
||
bg={viewMode === 'force3d' ? 'purple.500' : 'transparent'}
|
||
color={viewMode === 'force3d' ? 'white' : 'whiteAlpha.700'}
|
||
borderColor="whiteAlpha.300"
|
||
_hover={{
|
||
bg: viewMode === 'force3d' ? 'purple.400' : 'whiteAlpha.100',
|
||
boxShadow: viewMode === 'force3d' ? '0 0 10px rgba(139, 92, 246, 0.4)' : 'none',
|
||
}}
|
||
aria-label="概念矩形树图"
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip label="层级图" placement="top">
|
||
<IconButton
|
||
icon={<Network />}
|
||
onClick={() => {
|
||
if (viewMode !== 'hierarchy') {
|
||
trackViewModeChanged('hierarchy', viewMode);
|
||
setViewMode('hierarchy');
|
||
}
|
||
}}
|
||
bg={viewMode === 'hierarchy' ? 'purple.500' : 'transparent'}
|
||
color={viewMode === 'hierarchy' ? 'white' : 'whiteAlpha.700'}
|
||
borderColor="whiteAlpha.300"
|
||
_hover={{
|
||
bg: viewMode === 'hierarchy' ? 'purple.400' : 'whiteAlpha.100',
|
||
boxShadow: viewMode === 'hierarchy' ? '0 0 10px rgba(139, 92, 246, 0.4)' : 'none',
|
||
}}
|
||
aria-label="层级图"
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip label="列表视图" placement="top">
|
||
<IconButton
|
||
icon={<List />}
|
||
onClick={() => {
|
||
if (viewMode !== 'list') {
|
||
trackViewModeChanged('list', viewMode);
|
||
setViewMode('list');
|
||
}
|
||
}}
|
||
bg={viewMode === 'list' ? 'purple.500' : 'transparent'}
|
||
color={viewMode === 'list' ? 'white' : 'whiteAlpha.700'}
|
||
borderColor="whiteAlpha.300"
|
||
_hover={{
|
||
bg: viewMode === 'list' ? 'purple.400' : 'whiteAlpha.100',
|
||
boxShadow: viewMode === 'list' ? '0 0 10px rgba(139, 92, 246, 0.4)' : 'none',
|
||
}}
|
||
aria-label="列表视图"
|
||
/>
|
||
</Tooltip>
|
||
</ButtonGroup>
|
||
</Flex>
|
||
</CardBody>
|
||
</Card>
|
||
|
||
{/* 面包屑导航 - 显示当前层级筛选 */}
|
||
<BreadcrumbNav
|
||
filter={hierarchyFilter}
|
||
onClearFilter={handleClearHierarchyFilter}
|
||
onNavigate={handleNavigateHierarchy}
|
||
isDarkMode={true}
|
||
/>
|
||
|
||
{selectedDate && viewMode !== 'hierarchy' && viewMode !== 'force3d' && (
|
||
<Box mb={4} p={3} bg="rgba(59, 130, 246, 0.2)" borderRadius="xl" borderLeft="4px solid" borderColor="blue.400">
|
||
<HStack>
|
||
<Icon as={Info} color="blue.300" />
|
||
<Text fontSize="sm" color="whiteAlpha.800">
|
||
当前显示 <Text as="strong" color="cyan.300">{selectedDate.toLocaleDateString('zh-CN')}</Text> 的概念涨跌幅数据
|
||
{searchQuery && <span>,搜索词:<Text as="strong" color="cyan.300">"{searchQuery}"</Text></span>}
|
||
</Text>
|
||
</HStack>
|
||
</Box>
|
||
)}
|
||
|
||
{/* 3D 力导向图视图 */}
|
||
{viewMode === 'force3d' ? (
|
||
<ForceGraphView
|
||
apiBaseUrl={API_BASE_URL}
|
||
onSelectCategory={handleHierarchySelect}
|
||
selectedDate={selectedDate}
|
||
/>
|
||
) : /* 层级图视图 */
|
||
viewMode === 'hierarchy' ? (
|
||
<HierarchyView
|
||
apiBaseUrl={API_BASE_URL}
|
||
onSelectCategory={handleHierarchySelect}
|
||
selectedDate={selectedDate}
|
||
/>
|
||
) : loading ? (
|
||
<SimpleGrid columns={{ base: 2, md: 2, lg: 3 }} spacing={{ base: 3, md: 6 }}>
|
||
{[...Array(12)].map((_, i) => (
|
||
<SkeletonCard key={i} />
|
||
))}
|
||
</SimpleGrid>
|
||
) : concepts.length > 0 ? (
|
||
<>
|
||
{viewMode === 'grid' ? (
|
||
<SimpleGrid columns={{ base: 2, md: 2, lg: 3 }} spacing={{ base: 3, md: 6 }} className="concept-grid">
|
||
{concepts.map((concept, index) => (
|
||
<Box key={concept.concept_id} className="concept-item" role="group">
|
||
<ConceptCard concept={concept} position={index} />
|
||
</Box>
|
||
))}
|
||
</SimpleGrid>
|
||
) : (
|
||
<VStack spacing={4} align="stretch" className="concept-list">
|
||
{concepts.map((concept, index) => (
|
||
<ConceptListItem key={concept.concept_id} concept={concept} position={index} />
|
||
))}
|
||
</VStack>
|
||
)}
|
||
|
||
<Center mt={12}>
|
||
<HStack
|
||
spacing={3}
|
||
bg="rgba(15, 23, 42, 0.8)"
|
||
backdropFilter="blur(20px)"
|
||
px={6}
|
||
py={3}
|
||
borderRadius="full"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.100"
|
||
boxShadow="0 4px 20px rgba(0, 0, 0, 0.3)"
|
||
>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
|
||
isDisabled={currentPage === 1}
|
||
bg="purple.500"
|
||
color="white"
|
||
variant="solid"
|
||
borderRadius="full"
|
||
_hover={{
|
||
bg: 'purple.400',
|
||
boxShadow: '0 0 15px rgba(139, 92, 246, 0.5)',
|
||
}}
|
||
_disabled={{
|
||
bg: 'whiteAlpha.100',
|
||
color: 'whiteAlpha.400',
|
||
cursor: 'not-allowed',
|
||
}}
|
||
>
|
||
上一页
|
||
</Button>
|
||
|
||
<HStack spacing={2}>
|
||
{[...Array(Math.min(5, totalPages))].map((_, i) => {
|
||
const pageNum = currentPage <= 3 ? i + 1 :
|
||
currentPage >= totalPages - 2 ? totalPages - 4 + i :
|
||
currentPage - 2 + i;
|
||
if (pageNum < 1 || pageNum > totalPages) return null;
|
||
return (
|
||
<Button
|
||
key={pageNum}
|
||
size="sm"
|
||
onClick={() => handlePageChange(pageNum)}
|
||
bg={pageNum === currentPage ? 'purple.500' : 'transparent'}
|
||
color={pageNum === currentPage ? 'white' : 'whiteAlpha.700'}
|
||
variant={pageNum === currentPage ? 'solid' : 'ghost'}
|
||
borderRadius="full"
|
||
minW="40px"
|
||
fontWeight="bold"
|
||
_hover={{
|
||
bg: pageNum === currentPage ? 'purple.400' : 'whiteAlpha.100',
|
||
boxShadow: pageNum === currentPage ? '0 0 10px rgba(139, 92, 246, 0.4)' : 'none',
|
||
}}
|
||
>
|
||
{pageNum}
|
||
</Button>
|
||
);
|
||
})}
|
||
</HStack>
|
||
|
||
<Button
|
||
size="sm"
|
||
onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))}
|
||
isDisabled={currentPage === totalPages}
|
||
bg="purple.500"
|
||
color="white"
|
||
variant="solid"
|
||
borderRadius="full"
|
||
_hover={{
|
||
bg: 'purple.400',
|
||
boxShadow: '0 0 15px rgba(139, 92, 246, 0.5)',
|
||
}}
|
||
_disabled={{
|
||
bg: 'whiteAlpha.100',
|
||
color: 'whiteAlpha.400',
|
||
cursor: 'not-allowed',
|
||
}}
|
||
>
|
||
下一页
|
||
</Button>
|
||
</HStack>
|
||
</Center>
|
||
</>
|
||
) : viewMode !== 'hierarchy' && viewMode !== 'force3d' ? (
|
||
<Center h="400px">
|
||
<VStack spacing={6}>
|
||
<Icon as={Tags} boxSize={20} color="whiteAlpha.300" />
|
||
<VStack spacing={2}>
|
||
<Text fontSize="xl" color="white" fontWeight="medium">暂无概念数据</Text>
|
||
<Text color="whiteAlpha.600">
|
||
{hierarchyFilter?.lv1
|
||
? `「${[hierarchyFilter.lv1, hierarchyFilter.lv2, hierarchyFilter.lv3].filter(Boolean).join(' > ')}」分类下暂无数据`
|
||
: '请尝试其他搜索关键词或选择其他日期'
|
||
}
|
||
</Text>
|
||
{hierarchyFilter?.lv1 && (
|
||
<Button
|
||
size="sm"
|
||
bg="purple.500"
|
||
color="white"
|
||
onClick={handleClearHierarchyFilter}
|
||
_hover={{ bg: 'purple.400' }}
|
||
>
|
||
清除筛选
|
||
</Button>
|
||
)}
|
||
</VStack>
|
||
</VStack>
|
||
</Center>
|
||
) : null}
|
||
</Box>
|
||
|
||
{/* 右侧统计面板 */}
|
||
<Box w={{ base: '100%', xl: '400px' }} flexShrink={0}>
|
||
<Box position="sticky" top={6}>
|
||
{hasFeatureAccess('concept_stats_panel') ? (
|
||
<ConceptStatsPanel
|
||
apiBaseUrl={API_BASE_URL}
|
||
onConceptClick={handleConceptClick}
|
||
isDarkMode={true}
|
||
/>
|
||
) : (
|
||
<Card
|
||
bg="rgba(15, 23, 42, 0.8)"
|
||
backdropFilter="blur(20px)"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.100"
|
||
borderRadius="2xl"
|
||
>
|
||
<CardBody p={6}>
|
||
<VStack spacing={4} textAlign="center">
|
||
<Icon as={LineChart} boxSize={12} color="whiteAlpha.300" />
|
||
<VStack spacing={2}>
|
||
<Heading size="md" color="white">
|
||
概念统计中心
|
||
</Heading>
|
||
<Text fontSize="sm" color="whiteAlpha.600">
|
||
此功能需要Pro版订阅才能使用
|
||
</Text>
|
||
</VStack>
|
||
<Button
|
||
bg="blue.500"
|
||
color="white"
|
||
leftIcon={<Icon as={Rocket} />}
|
||
onClick={() => {
|
||
setUpgradeFeature('pro');
|
||
setUpgradeModalOpen(true);
|
||
}}
|
||
_hover={{ bg: 'blue.400', boxShadow: '0 0 15px rgba(59, 130, 246, 0.5)' }}
|
||
>
|
||
升级到Pro版
|
||
</Button>
|
||
</VStack>
|
||
</CardBody>
|
||
</Card>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
</Flex>
|
||
</Box>
|
||
|
||
{/* 股票详情Modal - 复用通用组件 */}
|
||
<ConceptStocksModal
|
||
isOpen={isStockModalOpen}
|
||
onClose={() => setIsStockModalOpen(false)}
|
||
concept={{
|
||
concept_name: selectedConceptName,
|
||
stocks: selectedConceptStocks
|
||
}}
|
||
/>
|
||
{/* 时间轴Modal */}
|
||
<ConceptTimelineModal
|
||
isOpen={isTimelineModalOpen}
|
||
onClose={() => setIsTimelineModalOpen(false)}
|
||
conceptName={selectedConceptForContent}
|
||
conceptId={selectedConceptId}
|
||
stocks={selectedConceptStocksForTimeline}
|
||
/>
|
||
|
||
{/* 订阅升级Modal */}
|
||
<SubscriptionUpgradeModal
|
||
isOpen={upgradeModalOpen}
|
||
onClose={() => setUpgradeModalOpen(false)}
|
||
requiredLevel={upgradeFeature}
|
||
featureName={
|
||
upgradeFeature === 'pro' ? '概念统计中心和热门个股' : '概念历史时间轴'
|
||
}
|
||
/>
|
||
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export default ConceptCenter; |