1621 lines
73 KiB
JavaScript
1621 lines
73 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,
|
||
Container,
|
||
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,
|
||
} from '@chakra-ui/react';
|
||
import { SearchIcon, ViewIcon, CalendarIcon, ExternalLinkIcon, StarIcon, ChevronDownIcon, InfoIcon, CloseIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||
import { FaThLarge, FaList, FaTags, FaChartLine, FaRobot, FaTable, FaHistory, FaBrain, FaLightbulb, FaRocket, FaShieldAlt, FaCalendarAlt, FaArrowUp, FaArrowDown, FaNewspaper, FaFileAlt, FaExpand, FaCompress, FaClock, FaLock } from 'react-icons/fa';
|
||
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
||
import { keyframes } from '@emotion/react';
|
||
import ConceptTimelineModal from './ConceptTimelineModal';
|
||
import ConceptStatsPanel from './components/ConceptStatsPanel';
|
||
// 导航栏已由 MainLayout 提供,无需在此导入
|
||
// 导入订阅权限管理
|
||
import { useSubscription } from '../../hooks/useSubscription';
|
||
import SubscriptionUpgradeModal from '../../components/SubscriptionUpgradeModal';
|
||
// 导入市场服务
|
||
import { marketService } from '../../services/marketService';
|
||
// 导入 PostHog 追踪 Hook
|
||
import { useConceptEvents } from './hooks/useConceptEvents';
|
||
|
||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
||
? '/concept-api'
|
||
: 'http://111.198.58.126:16801';
|
||
|
||
// 新闻和研报API配置
|
||
const NEWS_API_URL = process.env.NODE_ENV === 'production'
|
||
? '/news-api'
|
||
: 'http://111.198.58.126:21891';
|
||
|
||
const REPORT_API_URL = process.env.NODE_ENV === 'production'
|
||
? '/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); }
|
||
`;
|
||
|
||
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('grid');
|
||
|
||
// 日期相关状态
|
||
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 [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) => {
|
||
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);
|
||
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
|
||
};
|
||
}, [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) => {
|
||
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];
|
||
}
|
||
|
||
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 });
|
||
|
||
// ❌ 移除获取数据失败toast
|
||
// toast({ title: '获取数据失败', description: error.message, status: 'error', duration: 3000, isClosable: true });
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [pageSize, sortBy]);
|
||
|
||
// 清除搜索
|
||
const handleClearSearch = () => {
|
||
setSearchQuery('');
|
||
setSortBy('change_pct');
|
||
setCurrentPage(1);
|
||
updateUrlParams({ q: '', page: 1, sort: 'change_pct' });
|
||
fetchConcepts('', 1, selectedDate, 'change_pct');
|
||
};
|
||
|
||
// 处理搜索
|
||
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 {
|
||
// 批量获取股票数据,每次处理5个股票以避免并发过多
|
||
const batchSize = 5;
|
||
for (let i = 0; i < stocks.length; i += batchSize) {
|
||
const batch = stocks.slice(i, i + batchSize);
|
||
const promises = batch.map(async (stock) => {
|
||
if (!stock.stock_code) return null;
|
||
|
||
// 提取6位股票代码(去掉交易所后缀)
|
||
const seccode = stock.stock_code.substring(0, 6);
|
||
|
||
try {
|
||
const response = await marketService.getTradeData(seccode, 1);
|
||
if (response.success && response.data && response.data.length > 0) {
|
||
const latestData = response.data[response.data.length - 1];
|
||
return {
|
||
stock_code: stock.stock_code,
|
||
...latestData
|
||
};
|
||
}
|
||
} catch (error) {
|
||
logger.warn('ConceptCenter', `获取股票行情数据失败`, { stockCode: seccode, error: error.message });
|
||
}
|
||
return null;
|
||
});
|
||
|
||
const batchResults = await Promise.all(promises);
|
||
batchResults.forEach(result => {
|
||
if (result) {
|
||
newMarketData[result.stock_code] = result;
|
||
}
|
||
});
|
||
}
|
||
|
||
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}`;
|
||
};
|
||
|
||
// 渲染动态表格列
|
||
const renderStockTable = () => {
|
||
if (!selectedConceptStocks || selectedConceptStocks.length === 0) {
|
||
return <Text>暂无相关股票数据</Text>;
|
||
}
|
||
|
||
const allFields = new Set();
|
||
selectedConceptStocks.forEach(stock => {
|
||
Object.keys(stock).forEach(key => allFields.add(key));
|
||
});
|
||
|
||
// 定义固定的列顺序,包含新增的现价和涨跌幅列
|
||
const orderedFields = ['stock_name', 'stock_code', 'current_price', 'change_percent'];
|
||
allFields.forEach(field => {
|
||
if (!orderedFields.includes(field)) {
|
||
orderedFields.push(field);
|
||
}
|
||
});
|
||
|
||
return (
|
||
<Box>
|
||
{loadingStockData && (
|
||
<Box mb={4} textAlign="center">
|
||
<HStack justify="center" spacing={2}>
|
||
<Spinner size="sm" color="purple.500" />
|
||
<Text fontSize="sm" color="gray.600">正在获取行情数据...</Text>
|
||
</HStack>
|
||
</Box>
|
||
)}
|
||
|
||
<TableContainer maxH="60vh" overflowY="auto">
|
||
<Table variant="simple" size="sm">
|
||
<Thead position="sticky" top={0} bg="white" zIndex={1}>
|
||
<Tr>
|
||
{orderedFields.map(field => (
|
||
<Th key={field}>
|
||
{field === 'stock_name' ? '股票名称' :
|
||
field === 'stock_code' ? '股票代码' :
|
||
field === 'current_price' ? '现价' :
|
||
field === 'change_percent' ? '当日涨跌幅' : field}
|
||
</Th>
|
||
))}
|
||
</Tr>
|
||
</Thead>
|
||
<Tbody>
|
||
{selectedConceptStocks.map((stock, idx) => {
|
||
const marketData = stockMarketData[stock.stock_code];
|
||
const companyLink = generateCompanyLink(stock.stock_code);
|
||
|
||
return (
|
||
<Tr key={idx} _hover={{ bg: 'gray.50' }}>
|
||
{orderedFields.map(field => {
|
||
let cellContent = stock[field] || '-';
|
||
let cellProps = {};
|
||
|
||
// 处理特殊字段
|
||
if (field === 'current_price') {
|
||
cellContent = marketData ? formatPrice(marketData.close) : (loadingStockData ? <Spinner size="xs" /> : '-');
|
||
} else if (field === 'change_percent') {
|
||
if (marketData) {
|
||
cellContent = formatStockChangePercent(marketData.change_percent);
|
||
cellProps.color = `${getStockChangeColor(marketData.change_percent)}.500`;
|
||
cellProps.fontWeight = 'bold';
|
||
} else {
|
||
cellContent = loadingStockData ? <Spinner size="xs" /> : '-';
|
||
}
|
||
} else if (field === 'stock_name' || field === 'stock_code') {
|
||
// 添加超链接
|
||
cellContent = (
|
||
<Text
|
||
as="a"
|
||
href={companyLink}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
color="blue.600"
|
||
textDecoration="underline"
|
||
_hover={{
|
||
color: 'blue.800',
|
||
textDecoration: 'underline'
|
||
}}
|
||
cursor="pointer"
|
||
>
|
||
{stock[field] || '-'}
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Td key={field} {...cellProps}>
|
||
{cellContent}
|
||
</Td>
|
||
);
|
||
})}
|
||
</Tr>
|
||
);
|
||
})}
|
||
</Tbody>
|
||
</Table>
|
||
</TableContainer>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
// 格式化日期显示
|
||
const formatHappenedTimes = (times) => {
|
||
if (!times || times.length === 0) return null;
|
||
|
||
if (times.length === 1) {
|
||
return (
|
||
<HStack spacing={1}>
|
||
<Icon as={CalendarIcon} boxSize={3} color="purple.500" />
|
||
<Text fontSize="xs" color="gray.600">{new Date(times[0]).toLocaleDateString('zh-CN')}</Text>
|
||
</HStack>
|
||
);
|
||
}
|
||
|
||
const sortedTimes = [...times].sort((a, b) => new Date(b) - new Date(a));
|
||
const latestDate = new Date(sortedTimes[0]).toLocaleDateString('zh-CN');
|
||
|
||
return (
|
||
<Tooltip
|
||
label={
|
||
<VStack align="start" spacing={1}>
|
||
<Text fontWeight="bold">历史爆发日期:</Text>
|
||
{sortedTimes.map((time, idx) => (
|
||
<Text key={idx} fontSize="xs">
|
||
{new Date(time).toLocaleDateString('zh-CN')}
|
||
</Text>
|
||
))}
|
||
</VStack>
|
||
}
|
||
bg="purple.600"
|
||
color="white"
|
||
borderRadius="md"
|
||
p={3}
|
||
>
|
||
<HStack spacing={2} cursor="pointer">
|
||
<Icon as={FaHistory} boxSize={3} color="orange.500" />
|
||
<Text fontSize="xs" color="orange.600" fontWeight="medium">
|
||
{latestDate} (共{times.length}次)
|
||
</Text>
|
||
</HStack>
|
||
</Tooltip>
|
||
);
|
||
};
|
||
|
||
// 初始化加载
|
||
useEffect(() => {
|
||
const init = async () => {
|
||
const latestDate = await fetchLatestTradeDate();
|
||
const filters = getFiltersFromUrl();
|
||
setSearchQuery(filters.q);
|
||
setSortBy(filters.sort);
|
||
setCurrentPage(filters.page);
|
||
|
||
const dateToUse = filters.date ? new Date(filters.date) : latestDate;
|
||
if (dateToUse) {
|
||
setSelectedDate(dateToUse);
|
||
fetchConcepts(filters.q, filters.page, dateToUse, filters.sort);
|
||
} else {
|
||
fetchConcepts(filters.q, filters.page, null, filters.sort);
|
||
}
|
||
};
|
||
init();
|
||
}, []);
|
||
|
||
// 概念卡片组件 - 优化版
|
||
const ConceptCard = ({ 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="white"
|
||
borderWidth="1px"
|
||
borderColor="gray.200"
|
||
overflow="hidden"
|
||
_hover={{
|
||
transform: 'translateY(-8px)',
|
||
boxShadow: 'xl',
|
||
borderColor: 'purple.300',
|
||
}}
|
||
transition="all 0.3s"
|
||
position="relative"
|
||
>
|
||
<Box position="relative" height="180px" overflow="hidden">
|
||
<Image
|
||
src={defaultImage}
|
||
alt={concept.concept}
|
||
height="100%"
|
||
width="100%"
|
||
objectFit="cover"
|
||
fallback={
|
||
<Box
|
||
height="100%"
|
||
bgGradient="linear(to-br, purple.100, pink.100)"
|
||
display="flex"
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
>
|
||
<Icon as={FaTags} boxSize={12} color="purple.300" opacity={0.5} />
|
||
</Box>
|
||
}
|
||
/>
|
||
<Box
|
||
position="absolute"
|
||
top={0}
|
||
left={0}
|
||
right={0}
|
||
bottom={0}
|
||
bgGradient="linear(to-t, blackAlpha.700, transparent)"
|
||
/>
|
||
|
||
{hasChange && (
|
||
<Badge
|
||
position="absolute"
|
||
top={3}
|
||
left={3}
|
||
bg={changeColor === 'red' ? 'red.500' : changeColor === 'green' ? 'green.500' : 'gray.500'}
|
||
color="white"
|
||
fontSize="sm"
|
||
px={2}
|
||
py={1}
|
||
borderRadius="full"
|
||
fontWeight="bold"
|
||
boxShadow="lg"
|
||
display="flex"
|
||
alignItems="center"
|
||
gap={1}
|
||
animation={Math.abs(changePercent) > 5 ? `${pulseAnimation} 2s infinite` : 'none'}
|
||
>
|
||
<Icon
|
||
as={changePercent > 0 ? FaArrowUp : changePercent < 0 ? FaArrowDown : null}
|
||
boxSize={3}
|
||
/>
|
||
{formatChangePercent(changePercent)}
|
||
</Badge>
|
||
)}
|
||
|
||
<Badge
|
||
position="absolute"
|
||
top={3}
|
||
right={3}
|
||
bgGradient="linear(to-r, purple.500, pink.500)"
|
||
color="white"
|
||
fontSize="xs"
|
||
px={2}
|
||
py={1}
|
||
borderRadius="full"
|
||
fontWeight="bold"
|
||
boxShadow="md"
|
||
>
|
||
{concept.stock_count || 0} 只股票
|
||
</Badge>
|
||
</Box>
|
||
|
||
<CardBody p={4}>
|
||
<VStack align="start" spacing={2}>
|
||
<Heading size="sm" color="gray.800" noOfLines={1}>
|
||
{concept.concept}
|
||
</Heading>
|
||
|
||
<Text color="gray.600" fontSize="xs" noOfLines={2} minH="32px">
|
||
{concept.description || '暂无描述信息'}
|
||
</Text>
|
||
|
||
{hasChange && concept.price_info?.trade_date && (
|
||
<HStack spacing={3} fontSize="xs" color="gray.500" w="100%">
|
||
<Text>交易日期: {new Date(concept.price_info.trade_date).toLocaleDateString('zh-CN')}</Text>
|
||
<Spacer />
|
||
<Text fontWeight="bold" color={`${changeColor}.600`}>
|
||
{formatChangePercent(changePercent)}
|
||
</Text>
|
||
</HStack>
|
||
)}
|
||
|
||
{concept.stocks && concept.stocks.length > 0 && (
|
||
<Box
|
||
width="100%"
|
||
p={2}
|
||
bg="gray.50"
|
||
borderRadius="md"
|
||
cursor="pointer"
|
||
onClick={(e) => handleViewStocks(e, concept)}
|
||
_hover={{ bg: 'gray.100' }}
|
||
transition="background 0.2s"
|
||
>
|
||
<Flex align="center" justify="space-between">
|
||
<Box flex={1}>
|
||
<HStack spacing={2} mb={1}>
|
||
<Text fontSize="xs" color="gray.600" fontWeight="medium">
|
||
热门个股
|
||
</Text>
|
||
{!hasFeatureAccess('hot_stocks') && (
|
||
<Badge colorScheme="yellow" size="sm">
|
||
🔒需Pro
|
||
</Badge>
|
||
)}
|
||
</HStack>
|
||
<HStack spacing={1} flexWrap="wrap">
|
||
{hasFeatureAccess('hot_stocks') ? (
|
||
<>
|
||
{concept.stocks.slice(0, 2).map((stock, idx) => (
|
||
<Tag key={idx} size="sm" colorScheme="purple" variant="subtle">
|
||
<TagLabel fontSize="xs">{stock.stock_name}</TagLabel>
|
||
</Tag>
|
||
))}
|
||
{concept.stocks.length > 2 && (
|
||
<Text fontSize="xs" color="purple.600" fontWeight="medium">
|
||
+{concept.stocks.length - 2}更多
|
||
</Text>
|
||
)}
|
||
</>
|
||
) : (
|
||
<HStack spacing={1}>
|
||
<Icon as={FaLock} boxSize="10px" color="yellow.600" />
|
||
<Text fontSize="xs" color="yellow.600" fontWeight="medium">
|
||
升级查看{concept.stocks.length}只个股
|
||
</Text>
|
||
</HStack>
|
||
)}
|
||
</HStack>
|
||
</Box>
|
||
<Icon
|
||
as={hasFeatureAccess('hot_stocks') ? ChevronRightIcon : FaLock}
|
||
color={hasFeatureAccess('hot_stocks') ? 'purple.500' : 'yellow.600'}
|
||
boxSize={4}
|
||
/>
|
||
</Flex>
|
||
</Box>
|
||
)}
|
||
|
||
<Divider />
|
||
|
||
<Flex width="100%" justify="space-between" align="center">
|
||
{formatHappenedTimes(concept.happened_times)}
|
||
|
||
<Button
|
||
size="sm"
|
||
leftIcon={<FaChartLine />} // 改成时间轴图标
|
||
colorScheme="purple" // 改成紫色主题
|
||
variant="solid"
|
||
onClick={(e) => handleViewContent(e, concept.concept, concept.concept_id)} // 传入concept_id
|
||
borderRadius="full"
|
||
px={4}
|
||
fontWeight="medium"
|
||
boxShadow="sm"
|
||
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
|
||
transition="all 0.2s"
|
||
>
|
||
历史时间轴 {/* 改按钮文字 */}
|
||
</Button>
|
||
</Flex>
|
||
</VStack>
|
||
</CardBody>
|
||
|
||
<Box
|
||
position="absolute"
|
||
top={0}
|
||
left={0}
|
||
right={0}
|
||
height="3px"
|
||
bgGradient="linear(to-r, purple.400, pink.400, blue.400)"
|
||
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="white"
|
||
borderWidth="1px"
|
||
borderColor="gray.200"
|
||
overflow="hidden"
|
||
_hover={{
|
||
transform: 'translateX(4px)',
|
||
boxShadow: 'lg',
|
||
borderColor: 'purple.300',
|
||
}}
|
||
transition="all 0.3s"
|
||
>
|
||
<CardBody p={6}>
|
||
<Flex align="center" gap={6}>
|
||
{/* 左侧图标区域 */}
|
||
<Box
|
||
width="80px"
|
||
height="80px"
|
||
borderRadius="xl"
|
||
bgGradient="linear(to-br, purple.100, pink.100)"
|
||
display="flex"
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
position="relative"
|
||
flexShrink={0}
|
||
>
|
||
<Icon as={FaTags} boxSize={8} color="purple.400" />
|
||
{hasChange && (
|
||
<Badge
|
||
position="absolute"
|
||
top={-2}
|
||
right={-2}
|
||
bg={changeColor === 'red' ? 'red.500' : changeColor === 'green' ? 'green.500' : 'gray.500'}
|
||
color="white"
|
||
fontSize="xs"
|
||
px={2}
|
||
py={1}
|
||
borderRadius="full"
|
||
fontWeight="bold"
|
||
minW="auto"
|
||
>
|
||
<Icon
|
||
as={changePercent > 0 ? FaArrowUp : changePercent < 0 ? FaArrowDown : null}
|
||
boxSize={2}
|
||
mr={1}
|
||
/>
|
||
{formatChangePercent(changePercent)}
|
||
</Badge>
|
||
)}
|
||
</Box>
|
||
|
||
{/* 中间内容区域 */}
|
||
<Box flex={1}>
|
||
<VStack align="start" spacing={3}>
|
||
<Heading size="md" color="gray.800" noOfLines={1}>
|
||
{concept.concept}
|
||
</Heading>
|
||
|
||
<Text color="gray.600" fontSize="sm" noOfLines={2}>
|
||
{concept.description || '该概念板块涵盖相关技术、产业链和市场应用等多个维度的投资机会'}
|
||
</Text>
|
||
|
||
<HStack spacing={4} flexWrap="wrap">
|
||
<HStack spacing={1}>
|
||
<Icon as={FaChartLine} boxSize={4} color="purple.500" />
|
||
<Text fontSize="sm" fontWeight="medium" color="gray.700">
|
||
{concept.stock_count || 0} 只股票
|
||
</Text>
|
||
</HStack>
|
||
|
||
{hasChange && concept.price_info?.trade_date && (
|
||
<HStack spacing={1}>
|
||
<Icon as={FaCalendarAlt} boxSize={4} color="blue.500" />
|
||
<Text fontSize="sm" color="gray.600">
|
||
{new Date(concept.price_info.trade_date).toLocaleDateString('zh-CN')}
|
||
</Text>
|
||
</HStack>
|
||
)}
|
||
|
||
{formatHappenedTimes(concept.happened_times)}
|
||
</HStack>
|
||
</VStack>
|
||
</Box>
|
||
|
||
{/* 右侧操作区域 */}
|
||
<VStack spacing={3} align="end" flexShrink={0}>
|
||
<HStack spacing={3}>
|
||
<Button
|
||
size="sm"
|
||
leftIcon={<ViewIcon />}
|
||
colorScheme="blue"
|
||
variant="outline"
|
||
onClick={(e) => handleViewStocks(e, concept)}
|
||
borderRadius="full"
|
||
>
|
||
查看个股
|
||
</Button>
|
||
|
||
<Button
|
||
size="sm"
|
||
leftIcon={<FaChartLine />}
|
||
colorScheme="purple"
|
||
variant="solid"
|
||
onClick={(e) => handleViewContent(e, concept.concept, concept.concept_id)}
|
||
borderRadius="full"
|
||
>
|
||
历史时间轴
|
||
</Button>
|
||
</HStack>
|
||
|
||
{concept.stocks && concept.stocks.length > 0 && (
|
||
<Box>
|
||
<HStack spacing={1} mb={2}>
|
||
<Text fontSize="xs" color="gray.500">热门个股</Text>
|
||
{!hasFeatureAccess('hot_stocks') && (
|
||
<Badge colorScheme="yellow" size="sm">
|
||
🔒需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" colorScheme="purple" variant="subtle">
|
||
<TagLabel fontSize="xs">{stock.stock_name}</TagLabel>
|
||
</Tag>
|
||
</WrapItem>
|
||
))}
|
||
{concept.stocks.length > 3 && (
|
||
<WrapItem>
|
||
<Text fontSize="xs" color="purple.600" fontWeight="medium">
|
||
+{concept.stocks.length - 3}更多
|
||
</Text>
|
||
</WrapItem>
|
||
)}
|
||
</>
|
||
) : (
|
||
<WrapItem>
|
||
<HStack spacing={1}>
|
||
<Icon as={FaLock} boxSize="8px" color="yellow.600" />
|
||
<Text fontSize="xs" color="yellow.600" fontWeight="medium">
|
||
升级查看{concept.stocks.length}只
|
||
</Text>
|
||
</HStack>
|
||
</WrapItem>
|
||
)}
|
||
</Wrap>
|
||
</Box>
|
||
)}
|
||
</VStack>
|
||
</Flex>
|
||
</CardBody>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
// 骨架屏组件
|
||
const SkeletonCard = () => (
|
||
<Card bg="white" borderWidth="1px" borderColor="gray.200">
|
||
<Skeleton height="200px" />
|
||
<CardBody>
|
||
<SkeletonText mt={4} noOfLines={4} spacing={4} />
|
||
</CardBody>
|
||
</Card>
|
||
);
|
||
|
||
// 日期选择组件
|
||
const DateSelector = () => (
|
||
<HStack spacing={4} p={4} bg="white" borderRadius="lg" shadow="sm">
|
||
<Icon as={FaCalendarAlt} color="purple.500" />
|
||
<Text fontWeight="medium" color="gray.700">交易日期:</Text>
|
||
|
||
<Input
|
||
type="date"
|
||
value={selectedDate ? selectedDate.toISOString().split('T')[0] : ''}
|
||
onChange={handleDateChange}
|
||
max={new Date().toISOString().split('T')[0]}
|
||
width="200px"
|
||
focusBorderColor="purple.500"
|
||
/>
|
||
|
||
<ButtonGroup size="sm" variant="outline">
|
||
<Button onClick={() => handleQuickDateSelect(0)} colorScheme="purple">
|
||
今天
|
||
</Button>
|
||
<Button onClick={() => handleQuickDateSelect(1)} colorScheme="purple">
|
||
昨天
|
||
</Button>
|
||
<Button onClick={() => handleQuickDateSelect(7)} colorScheme="purple">
|
||
一周前
|
||
</Button>
|
||
<Button onClick={() => handleQuickDateSelect(30)} colorScheme="purple">
|
||
一月前
|
||
</Button>
|
||
</ButtonGroup>
|
||
|
||
{latestTradeDate && (
|
||
<Tooltip label="数据库中最新的交易日期">
|
||
<HStack spacing={2} ml={4}>
|
||
<Icon as={InfoIcon} color="blue.500" />
|
||
<Text fontSize="sm" color="blue.600">
|
||
最新数据: {latestTradeDate.toLocaleDateString('zh-CN')}
|
||
</Text>
|
||
</HStack>
|
||
</Tooltip>
|
||
)}
|
||
</HStack>
|
||
);
|
||
|
||
return (
|
||
<Box minH="100vh" bg="gray.50">
|
||
{/* 导航栏已由 MainLayout 提供 */}
|
||
|
||
{/* Hero Section */}
|
||
<Box
|
||
position="relative"
|
||
bgGradient="linear(135deg, #667eea 0%, #764ba2 100%)"
|
||
color="white"
|
||
overflow="hidden"
|
||
>
|
||
<Box
|
||
position="absolute"
|
||
top="-50%"
|
||
left="-50%"
|
||
width="200%"
|
||
height="200%"
|
||
bgImage="radial-gradient(circle, rgba(255,255,255,0.1) 1px, transparent 1px)"
|
||
bgSize="50px 50px"
|
||
animation={`${floatAnimation} 20s ease-in-out infinite`}
|
||
opacity={0.3}
|
||
/>
|
||
|
||
<Box
|
||
position="absolute"
|
||
top="10%"
|
||
right="10%"
|
||
width="300px"
|
||
height="300px"
|
||
borderRadius="full"
|
||
bgGradient="radial(purple.300, transparent)"
|
||
filter="blur(40px)"
|
||
opacity={0.6}
|
||
/>
|
||
<Box
|
||
position="absolute"
|
||
bottom="10%"
|
||
left="5%"
|
||
width="400px"
|
||
height="400px"
|
||
borderRadius="full"
|
||
bgGradient="radial(pink.300, transparent)"
|
||
filter="blur(60px)"
|
||
opacity={0.5}
|
||
/>
|
||
|
||
<Container maxW="container.xl" position="relative" py={{ base: 12, md: 20 }}>
|
||
<VStack spacing={8}>
|
||
<VStack spacing={4} textAlign="center">
|
||
<HStack spacing={4} justify="center">
|
||
<Icon as={FaBrain} boxSize={16} color="yellow.300" />
|
||
</HStack>
|
||
|
||
<VStack spacing={2}>
|
||
<Heading
|
||
as="h1"
|
||
size="2xl"
|
||
fontWeight="black"
|
||
bgGradient="linear(to-r, white, yellow.200)"
|
||
bgClip="text"
|
||
letterSpacing="tight"
|
||
lineHeight="shorter"
|
||
>
|
||
概念中心
|
||
</Heading>
|
||
<HStack spacing={2} justify="center">
|
||
<Icon as={FaClock} boxSize={4} color="yellow.200" />
|
||
<Text fontSize="sm" fontWeight="medium" opacity={0.95}>
|
||
约下午4点更新
|
||
</Text>
|
||
</HStack>
|
||
<Text fontSize="2xl" fontWeight="medium" opacity={0.95}>
|
||
大模型辅助的信息整理与呈现平台
|
||
</Text>
|
||
<Text fontSize="lg" opacity={0.8} maxW="3xl" lineHeight="tall">
|
||
以大模型协助汇聚与清洗多源信息,结合自主训练的领域知识图谱,
|
||
<br />
|
||
并由资深分析师进行人工整合与校准,提供结构化参考信息
|
||
</Text>
|
||
</VStack>
|
||
</VStack>
|
||
|
||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={6} w="full" maxW="4xl">
|
||
<VStack
|
||
bg="whiteAlpha.100"
|
||
backdropFilter="blur(10px)"
|
||
p={6}
|
||
borderRadius="xl"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.300"
|
||
spacing={3}
|
||
>
|
||
<Icon as={BsLightningFill} boxSize={8} color="yellow.300" />
|
||
<Text fontWeight="bold" fontSize="lg">实时更新</Text>
|
||
<Text fontSize="sm" opacity={0.8} textAlign="center">
|
||
毫秒级数据同步
|
||
</Text>
|
||
</VStack>
|
||
|
||
<VStack
|
||
bg="whiteAlpha.100"
|
||
backdropFilter="blur(10px)"
|
||
p={6}
|
||
borderRadius="xl"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.300"
|
||
spacing={3}
|
||
>
|
||
<Icon as={FaRocket} boxSize={8} color="cyan.300" />
|
||
<Text fontWeight="bold" fontSize="lg">智能追踪</Text>
|
||
<Text fontSize="sm" opacity={0.8} textAlign="center">
|
||
算法智能追踪
|
||
</Text>
|
||
</VStack>
|
||
|
||
<VStack
|
||
bg="whiteAlpha.100"
|
||
backdropFilter="blur(10px)"
|
||
p={6}
|
||
borderRadius="xl"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.300"
|
||
spacing={3}
|
||
>
|
||
<Icon as={FaLightbulb} boxSize={8} color="orange.300" />
|
||
<Text fontWeight="bold" fontSize="lg">深度分析</Text>
|
||
<Text fontSize="sm" opacity={0.8} textAlign="center">
|
||
多维度数据挖掘
|
||
</Text>
|
||
</VStack>
|
||
|
||
<VStack
|
||
bg="whiteAlpha.100"
|
||
backdropFilter="blur(10px)"
|
||
p={6}
|
||
borderRadius="xl"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.300"
|
||
spacing={3}
|
||
>
|
||
<Icon as={FaShieldAlt} boxSize={8} color="green.300" />
|
||
<Text fontWeight="bold" fontSize="lg">专业可靠</Text>
|
||
<Text fontSize="sm" opacity={0.8} textAlign="center">
|
||
权威数据源保障
|
||
</Text>
|
||
</VStack>
|
||
</SimpleGrid>
|
||
|
||
<HStack spacing={8} divider={<Divider orientation="vertical" height="40px" borderColor="whiteAlpha.400" />}>
|
||
<VStack>
|
||
<Text fontSize="3xl" fontWeight="bold" color="yellow.300">500+</Text>
|
||
<Text fontSize="sm" opacity={0.8}>概念板块</Text>
|
||
</VStack>
|
||
<VStack>
|
||
<Text fontSize="3xl" fontWeight="bold" color="cyan.300">5000+</Text>
|
||
<Text fontSize="sm" opacity={0.8}>相关个股</Text>
|
||
</VStack>
|
||
<VStack>
|
||
<Text fontSize="3xl" fontWeight="bold" color="pink.300">24/7</Text>
|
||
<Text fontSize="sm" opacity={0.8}>全天候监控</Text>
|
||
</VStack>
|
||
</HStack>
|
||
|
||
<Box w="100%" maxW="3xl">
|
||
<VStack spacing={2}>
|
||
<Box w="100%" position="relative">
|
||
<HStack spacing={0} w="100%" boxShadow="2xl" borderRadius="full" overflow="hidden" bg="white">
|
||
<InputGroup size="lg" flex={1}>
|
||
<InputLeftElement pointerEvents="none">
|
||
<SearchIcon color="gray.400" />
|
||
</InputLeftElement>
|
||
<Input
|
||
placeholder="搜索概念板块、个股、关键词..."
|
||
bg="transparent"
|
||
color="gray.800"
|
||
border="none"
|
||
_placeholder={{ color: 'gray.400' }}
|
||
_focus={{
|
||
outline: 'none',
|
||
boxShadow: 'none'
|
||
}}
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
onKeyPress={handleKeyPress}
|
||
pr={searchQuery ? "40px" : "16px"}
|
||
/>
|
||
{searchQuery && (
|
||
<IconButton
|
||
position="absolute"
|
||
right="8px"
|
||
top="50%"
|
||
transform="translateY(-50%)"
|
||
size="sm"
|
||
aria-label="清除搜索"
|
||
icon={<CloseIcon />}
|
||
variant="ghost"
|
||
color="gray.500"
|
||
_hover={{ color: 'gray.700', bg: 'gray.100' }}
|
||
onClick={handleClearSearch}
|
||
zIndex={1}
|
||
/>
|
||
)}
|
||
</InputGroup>
|
||
<Button
|
||
size="lg"
|
||
borderRadius="0"
|
||
bgGradient="linear(to-r, purple.500, pink.500)"
|
||
color="white"
|
||
_hover={{
|
||
bgGradient: 'linear(to-r, purple.600, pink.600)'
|
||
}}
|
||
_active={{
|
||
bgGradient: 'linear(to-r, purple.700, pink.700)',
|
||
}}
|
||
onClick={handleSearch}
|
||
isLoading={loading}
|
||
loadingText="搜索中"
|
||
px={8}
|
||
minW="120px"
|
||
transition="all 0.2s"
|
||
border="none"
|
||
height="100%"
|
||
>
|
||
搜索
|
||
</Button>
|
||
</HStack>
|
||
</Box>
|
||
{searchQuery && sortBy === '_score' && (
|
||
<Text fontSize="xs" color="yellow.200" opacity={0.9}>
|
||
正在搜索 "{searchQuery}",已自动切换到相关度排序
|
||
</Text>
|
||
)}
|
||
</VStack>
|
||
</Box>
|
||
</VStack>
|
||
</Container>
|
||
</Box>
|
||
|
||
{/* 主内容区域 */}
|
||
<Container maxW="container.xl" py={10}>
|
||
<Box mb={6}>
|
||
<DateSelector />
|
||
</Box>
|
||
|
||
{/* 双栏布局:左侧概念卡片,右侧统计面板 */}
|
||
<Flex gap={8} direction={{ base: 'column', xl: 'row' }}>
|
||
{/* 左侧概念卡片区域 */}
|
||
<Box flex={1}>
|
||
|
||
<Card mb={8} shadow="sm">
|
||
<CardBody>
|
||
<Flex
|
||
direction={{ base: 'column', md: 'row' }}
|
||
justify="space-between"
|
||
align={{ base: 'stretch', md: 'center' }}
|
||
gap={4}
|
||
>
|
||
<HStack spacing={4} align="center">
|
||
<Text fontWeight="medium" color="gray.700">排序方式:</Text>
|
||
<Select
|
||
value={sortBy}
|
||
onChange={(e) => handleSortChange(e.target.value)}
|
||
width="200px"
|
||
focusBorderColor="purple.500"
|
||
>
|
||
<option value="change_pct">涨跌幅</option>
|
||
<option value="_score">相关度</option>
|
||
<option value="stock_count">股票数量</option>
|
||
<option value="concept_name">概念名称</option>
|
||
</Select>
|
||
{searchQuery && sortBy === '_score' && (
|
||
<Tooltip label="搜索时自动切换到相关度排序,以显示最匹配的结果。您也可以手动切换其他排序方式。">
|
||
<HStack spacing={1}>
|
||
<Icon as={InfoIcon} color="blue.500" boxSize={4} />
|
||
<Text fontSize="sm" color="blue.600">
|
||
智能排序
|
||
</Text>
|
||
</HStack>
|
||
</Tooltip>
|
||
)}
|
||
</HStack>
|
||
|
||
<ButtonGroup size="sm" isAttached variant="outline">
|
||
<IconButton
|
||
icon={<FaThLarge />}
|
||
onClick={() => {
|
||
if (viewMode !== 'grid') {
|
||
trackViewModeChanged('grid', viewMode);
|
||
setViewMode('grid');
|
||
}
|
||
}}
|
||
bg={viewMode === 'grid' ? 'purple.500' : 'transparent'}
|
||
color={viewMode === 'grid' ? 'white' : 'purple.500'}
|
||
borderColor="purple.500"
|
||
_hover={{ bg: viewMode === 'grid' ? 'purple.600' : 'purple.50' }}
|
||
aria-label="网格视图"
|
||
/>
|
||
<IconButton
|
||
icon={<FaList />}
|
||
onClick={() => {
|
||
if (viewMode !== 'list') {
|
||
trackViewModeChanged('list', viewMode);
|
||
setViewMode('list');
|
||
}
|
||
}}
|
||
bg={viewMode === 'list' ? 'purple.500' : 'transparent'}
|
||
color={viewMode === 'list' ? 'white' : 'purple.500'}
|
||
borderColor="purple.500"
|
||
_hover={{ bg: viewMode === 'list' ? 'purple.600' : 'purple.50' }}
|
||
aria-label="列表视图"
|
||
/>
|
||
</ButtonGroup>
|
||
</Flex>
|
||
</CardBody>
|
||
</Card>
|
||
|
||
{selectedDate && (
|
||
<Box mb={4} p={3} bg="blue.50" borderRadius="md" borderLeft="4px solid" borderColor="blue.500">
|
||
<HStack>
|
||
<Icon as={InfoIcon} color="blue.500" />
|
||
<Text fontSize="sm" color="blue.700">
|
||
当前显示 <strong>{selectedDate.toLocaleDateString('zh-CN')}</strong> 的概念涨跌幅数据
|
||
{searchQuery && <span>,搜索词:<strong>"{searchQuery}"</strong></span>}
|
||
</Text>
|
||
</HStack>
|
||
</Box>
|
||
)}
|
||
|
||
{loading ? (
|
||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
||
{[...Array(12)].map((_, i) => (
|
||
<SkeletonCard key={i} />
|
||
))}
|
||
</SimpleGrid>
|
||
) : concepts.length > 0 ? (
|
||
<>
|
||
{viewMode === 'grid' ? (
|
||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={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={2}>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
|
||
isDisabled={currentPage === 1}
|
||
colorScheme="purple"
|
||
variant="outline"
|
||
>
|
||
上一页
|
||
</Button>
|
||
|
||
<HStack>
|
||
{[...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)}
|
||
colorScheme="purple"
|
||
variant={pageNum === currentPage ? 'solid' : 'outline'}
|
||
>
|
||
{pageNum}
|
||
</Button>
|
||
);
|
||
})}
|
||
</HStack>
|
||
|
||
<Button
|
||
size="sm"
|
||
onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))}
|
||
isDisabled={currentPage === totalPages}
|
||
colorScheme="purple"
|
||
variant="outline"
|
||
>
|
||
下一页
|
||
</Button>
|
||
</HStack>
|
||
</Center>
|
||
</>
|
||
) : (
|
||
<Center h="400px">
|
||
<VStack spacing={6}>
|
||
<Icon as={FaTags} boxSize={20} color="gray.300" />
|
||
<VStack spacing={2}>
|
||
<Text fontSize="xl" color="gray.600" fontWeight="medium">暂无概念数据</Text>
|
||
<Text color="gray.500">请尝试其他搜索关键词或选择其他日期</Text>
|
||
</VStack>
|
||
</VStack>
|
||
</Center>
|
||
)}
|
||
</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}
|
||
/>
|
||
) : (
|
||
<Card>
|
||
<CardBody p={6}>
|
||
<VStack spacing={4} textAlign="center">
|
||
<Icon as={FaChartLine} boxSize={12} color="gray.300" />
|
||
<VStack spacing={2}>
|
||
<Heading size="md" color="gray.600">
|
||
概念统计中心
|
||
</Heading>
|
||
<Text fontSize="sm" color="gray.500">
|
||
此功能需要Pro版订阅才能使用
|
||
</Text>
|
||
</VStack>
|
||
<Button
|
||
colorScheme="blue"
|
||
leftIcon={<Icon as={FaRocket} />}
|
||
onClick={() => {
|
||
setUpgradeFeature('pro');
|
||
setUpgradeModalOpen(true);
|
||
}}
|
||
>
|
||
升级到Pro版
|
||
</Button>
|
||
</VStack>
|
||
</CardBody>
|
||
</Card>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
</Flex>
|
||
</Container>
|
||
|
||
{/* 股票详情Modal */}
|
||
<Modal
|
||
isOpen={isStockModalOpen}
|
||
onClose={() => setIsStockModalOpen(false)}
|
||
size="6xl"
|
||
scrollBehavior="inside"
|
||
>
|
||
<ModalOverlay />
|
||
<ModalContent>
|
||
<ModalHeader bg="purple.500" color="white">
|
||
<HStack>
|
||
<Icon as={FaTable} />
|
||
<Text>{selectedConceptName} - 相关个股</Text>
|
||
</HStack>
|
||
</ModalHeader>
|
||
<ModalCloseButton color="white" />
|
||
<ModalBody py={6}>
|
||
{renderStockTable()}
|
||
</ModalBody>
|
||
<ModalFooter>
|
||
<Button colorScheme="purple" onClick={() => setIsStockModalOpen(false)}>
|
||
关闭
|
||
</Button>
|
||
</ModalFooter>
|
||
</ModalContent>
|
||
</Modal>
|
||
{/* 时间轴Modal */}
|
||
<ConceptTimelineModal
|
||
isOpen={isTimelineModalOpen}
|
||
onClose={() => setIsTimelineModalOpen(false)}
|
||
conceptName={selectedConceptForContent}
|
||
conceptId={selectedConceptId}
|
||
/>
|
||
|
||
{/* 订阅升级Modal */}
|
||
<SubscriptionUpgradeModal
|
||
isOpen={upgradeModalOpen}
|
||
onClose={() => setUpgradeModalOpen(false)}
|
||
requiredLevel={upgradeFeature}
|
||
featureName={
|
||
upgradeFeature === 'pro' ? '概念统计中心和热门个股' : '概念历史时间轴'
|
||
}
|
||
/>
|
||
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export default ConceptCenter; |