Files
vf_react/src/views/Concept/index.js
zdl 2841cec6f3 fix(Concept): 修复未导入的 react-icons 图标引用
将 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>
2025-12-25 14:03:38 +08:00

2174 lines
100 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;