Files
vf_react/src/views/Concept/index.js
2025-11-12 13:51:07 +08:00

1621 lines
73 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,
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;