feat: 10.10线上最新代码提交

This commit is contained in:
zdl
2025-10-11 16:16:02 +08:00
parent 4d0dc109bc
commit 495ad758ea
3338 changed files with 460147 additions and 152745 deletions

View File

@@ -122,14 +122,13 @@ const ConceptTimelineModal = ({
})
);
// 获取新闻(使用与原代码相同的参数
// 获取新闻(精确匹配最近100天最多100条
const newsParams = new URLSearchParams({
query: conceptName,
exact_match: 1,
start_date: startDateStr,
end_date: endDateStr,
top_k: 100, // 与原代码一致
pagenum: 1, // 第一页
pagesize: 100 // 一次获取100条避免分页
top_k: 100
});
const newsUrl = `${NEWS_API_URL}/search_china_news?${newsParams}`;
@@ -150,14 +149,13 @@ const ConceptTimelineModal = ({
})
);
// 获取研报(使用与原代码相同的参数结构
// 获取研报(文本模式、精确匹配最近100天最多30条
const reportParams = new URLSearchParams({
query: conceptName,
start_date: startDateStr,
end_date: endDateStr,
size: 100, // 一次获取100条
from: 0, // 从第0条开始
mode: 'hybrid' // 与原代码一致
mode: 'text',
exact_match: 1,
size: 30,
start_date: startDateStr
});
const reportUrl = `${REPORT_API_URL}/search?${reportParams}`;
@@ -235,34 +233,41 @@ const ConceptTimelineModal = ({
});
}
// 处理研报(按时间降序排序,最新的在前)
if (reportResult && reportResult.results && Array.isArray(reportResult.results)) {
// 先排序
const sortedReports = reportResult.results.sort((a, b) => {
const dateA = new Date(a.declare_date || 0);
const dateB = new Date(b.declare_date || 0);
return dateB - dateA; // 降序
});
// 处理研报(按时间降序排序,最新的在前),兼容 data.results 与 results
if (reportResult) {
const reports = (reportResult.data && Array.isArray(reportResult.data.results))
? reportResult.data.results
: (Array.isArray(reportResult.results) ? reportResult.results : []);
sortedReports.forEach(report => {
if (report.declare_date) {
// 研报日期格式已经是 YYYY-MM-DD
const dateOnly = report.declare_date;
if (reports.length > 0) {
const sortedReports = reports.sort((a, b) => {
const dateA = new Date((a.declare_date || '').replace(' ', 'T'));
const dateB = new Date((b.declare_date || '').replace(' ', 'T'));
return dateB - dateA; // 降序
});
events.push({
type: 'report',
date: dateOnly, // 日期
time: report.declare_date, // 研报没有具体时间
title: report.report_title,
content: report.content,
publisher: report.publisher,
author: report.author,
rating: report.rating,
security_name: report.security_name,
content_url: report.content_url
});
}
});
sortedReports.forEach(report => {
const rawDate = report.declare_date || '';
if (rawDate) {
const dateOnly = rawDate.includes('T') ? rawDate.split('T')[0]
: rawDate.includes(' ') ? rawDate.split(' ')[0]
: rawDate;
events.push({
type: 'report',
date: dateOnly,
time: rawDate,
title: report.report_title,
content: report.content,
publisher: report.publisher,
author: report.author,
rating: report.rating,
security_name: report.security_name,
content_url: report.content_url
});
}
});
}
}
// 按日期分组
@@ -321,18 +326,17 @@ const ConceptTimelineModal = ({
}));
};
// 格式化日期显示
// 格式化日期显示(包含年份)
const formatDateDisplay = (dateStr) => {
const date = new Date(dateStr);
const today = new Date();
const diffTime = today - date;
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
const formatted = date.toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit',
weekday: 'short'
});
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const formatted = `${year}-${month}-${day}`;
if (diffDays === 0) return `今天 ${formatted}`;
if (diffDays === 1) return `昨天 ${formatted}`;
@@ -342,6 +346,20 @@ const ConceptTimelineModal = ({
return formatted;
};
// 格式化完整时间YYYY-MM-DD HH:mm
const formatDateTime = (dateTimeStr) => {
if (!dateTimeStr) return '-';
const normalized = typeof dateTimeStr === 'string' ? dateTimeStr.replace(' ', 'T') : dateTimeStr;
const dt = new Date(normalized);
if (isNaN(dt.getTime())) return '-';
const y = dt.getFullYear();
const m = String(dt.getMonth() + 1).padStart(2, '0');
const d = String(dt.getDate()).padStart(2, '0');
const hh = String(dt.getHours()).padStart(2, '0');
const mm = String(dt.getMinutes()).padStart(2, '0');
return `${y}-${m}-${d} ${hh}:${mm}`;
};
// 获取涨跌幅颜色和图标
const getPriceInfo = (price) => {
if (!price || price.avg_change_pct === null) {
@@ -393,11 +411,33 @@ const ConceptTimelineModal = ({
<Badge colorScheme="yellow" ml={2}>
最近100天
</Badge>
<Badge colorScheme="purple" ml={2} fontSize="xs">
🔥 Max版功能
</Badge>
</HStack>
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody py={6} bg="gray.50">
<ModalBody
py={6}
bg="gray.50"
css={{
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: '#f1f1f1',
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: '#c1c1c1',
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: '#a8a8a8',
},
}}
>
{loading ? (
<Center py={20}>
<VStack spacing={4}>
@@ -614,6 +654,22 @@ const ConceptTimelineModal = ({
boxShadow="sm"
border="1px solid"
borderColor="gray.200"
css={{
'&::-webkit-scrollbar': {
width: '6px',
},
'&::-webkit-scrollbar-track': {
background: '#f1f1f1',
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: '#c1c1c1',
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: '#a8a8a8',
},
}}
>
{item.events.map((event, eventIdx) => (
<Box
@@ -667,17 +723,38 @@ const ConceptTimelineModal = ({
{event.content || '暂无内容'}
</Text>
{event.url && (
<Button
size="xs"
variant="link"
colorScheme="blue"
rightIcon={<ExternalLinkIcon />}
onClick={() => window.open(event.url, '_blank')}
>
查看原文
</Button>
)}
<Button
size="xs"
variant="link"
colorScheme="blue"
leftIcon={<ViewIcon />}
onClick={() => {
if (event.type === 'news') {
setSelectedNews({
title: event.title,
content: event.content,
source: event.source,
time: event.time,
url: event.url
});
setIsNewsModalOpen(true);
} else if (event.type === 'report') {
setSelectedReport({
title: event.title,
content: event.content,
publisher: event.publisher,
author: event.author,
time: event.time,
rating: event.rating,
security_name: event.security_name,
content_url: event.content_url
});
setIsReportModalOpen(true);
}
}}
>
查看详情
</Button>
</VStack>
</Box>
))}
@@ -757,7 +834,7 @@ const ConceptTimelineModal = ({
<Text>{selectedReport.author}</Text>
)}
{selectedReport?.time && (
<Text>{selectedReport.time}</Text>
<Text>{formatDateTime(selectedReport.time)}</Text>
)}
{selectedReport?.rating && (
<Badge colorScheme="orange" variant="solid">
@@ -835,17 +912,8 @@ const ConceptTimelineModal = ({
{selectedNews.source === 'zsxq' ? '知识星球' : selectedNews.source}
</Badge>
)}
{selectedNews?.time && selectedNews.time.includes('T') && (
<Text>
{new Date(selectedNews.time).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})}
</Text>
{selectedNews?.time && (
<Text>{formatDateTime(selectedNews.time)}</Text>
)}
</HStack>
</VStack>

View File

@@ -78,12 +78,18 @@ import {
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 } from 'react-icons/fa';
import { FaThLarge, FaList, FaTags, FaChartLine, FaRobot, FaTable, FaHistory, FaBrain, FaLightbulb, FaRocket, FaShieldAlt, FaCalendarAlt, FaArrowUp, FaArrowDown, FaNewspaper, FaFileAlt, FaExpand, FaCompress, FaClock, FaLock } from 'react-icons/fa';
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
import { keyframes } from '@emotion/react';
import ConceptTimelineModal from './ConceptTimelineModal';
import ConceptStatsPanel from './components/ConceptStatsPanel';
// 导入导航栏组件
import HomeNavbar from '../../components/Navbars/HomeNavbar';
// 导入订阅权限管理
import { useSubscription } from '../../hooks/useSubscription';
import SubscriptionUpgradeModal from '../../components/SubscriptionUpgradeModal';
// 导入市场服务
import { marketService } from '../../services/marketService';
const API_BASE_URL = process.env.NODE_ENV === 'production'
? '/concept-api'
@@ -123,6 +129,11 @@ const ConceptCenter = () => {
const navigate = useNavigate();
const toast = useToast();
// 订阅权限管理
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
const [upgradeFeature, setUpgradeFeature] = useState('pro');
// 状态管理
const [concepts, setConcepts] = useState([]);
const [loading, setLoading] = useState(false);
@@ -145,6 +156,9 @@ const ConceptCenter = () => {
const [selectedConceptName, setSelectedConceptName] = useState('');
const [isTimelineModalOpen, setIsTimelineModalOpen] = useState(false);
const [selectedConceptId, setSelectedConceptId] = useState('');
// 股票行情数据状态
const [stockMarketData, setStockMarketData] = useState({});
const [loadingStockData, setLoadingStockData] = useState(false);
// 默认图片路径
const defaultImage = '/assets/img/default-event.jpg';
@@ -166,9 +180,18 @@ const ConceptCenter = () => {
}
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;
}
setSelectedConceptForContent(conceptName);
setSelectedConceptId(conceptId);
setIsTimelineModalOpen(true);
@@ -347,16 +370,78 @@ const ConceptCenter = () => {
// 处理概念点击
const handleConceptClick = (conceptId, conceptName) => {
const htmlPath = `/htmls/${conceptName}.html`;
const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(conceptName)}.html`;
window.open(htmlPath, '_blank');
};
// 打开股票详情Modal
// 获取股票行情数据
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) {
console.warn(`获取股票 ${seccode} 行情数据失败:`, error);
}
return null;
});
const batchResults = await Promise.all(promises);
batchResults.forEach(result => {
if (result) {
newMarketData[result.stock_code] = result;
}
});
}
setStockMarketData(newMarketData);
} catch (error) {
console.error('批量获取股票行情数据失败:', error);
} 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;
}
setSelectedConceptStocks(concept.stocks || []);
setSelectedConceptName(concept.concept);
setStockMarketData({}); // 清空之前的数据
setIsStockModalOpen(true);
// 获取股票行情数据
fetchStockMarketData(concept.stocks || []);
};
// 格式化涨跌幅显示
@@ -372,6 +457,33 @@ const ConceptCenter = () => {
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) {
@@ -383,7 +495,8 @@ const ConceptCenter = () => {
Object.keys(stock).forEach(key => allFields.add(key));
});
const orderedFields = ['stock_name', 'stock_code'];
// 定义固定的列顺序,包含新增的现价和涨跌幅列
const orderedFields = ['stock_name', 'stock_code', 'current_price', 'change_percent'];
allFields.forEach(field => {
if (!orderedFields.includes(field)) {
orderedFields.push(field);
@@ -391,31 +504,86 @@ const ConceptCenter = () => {
});
return (
<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}
</Th>
))}
</Tr>
</Thead>
<Tbody>
{selectedConceptStocks.map((stock, idx) => (
<Tr key={idx} _hover={{ bg: 'gray.50' }}>
<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 => (
<Td key={field}>
{stock[field] || '-'}
</Td>
<Th key={field}>
{field === 'stock_name' ? '股票名称' :
field === 'stock_code' ? '股票代码' :
field === 'current_price' ? '现价' :
field === 'change_percent' ? '当日涨跌幅' : field}
</Th>
))}
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</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>
);
};
@@ -608,23 +776,45 @@ const ConceptCenter = () => {
>
<Flex align="center" justify="space-between">
<Box flex={1}>
<Text fontSize="xs" color="gray.600" fontWeight="medium" mb={1}>
热门个股
</Text>
<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">
{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>
{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={ChevronRightIcon} color="purple.500" boxSize={4} />
<Icon
as={hasFeatureAccess('hot_stocks') ? ChevronRightIcon : FaLock}
color={hasFeatureAccess('hot_stocks') ? 'purple.500' : 'yellow.600'}
boxSize={4}
/>
</Flex>
</Box>
)}
@@ -668,6 +858,173 @@ const ConceptCenter = () => {
);
};
// 概念列表项组件 - 列表视图
const ConceptListItem = ({ concept }) => {
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)}
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">
@@ -768,31 +1125,38 @@ const ConceptCenter = () => {
opacity={0.5}
/>
<Container maxW="container.xl" position="relative" py={{ base: 20, md: 32 }}>
<VStack spacing={12}>
<VStack spacing={6} textAlign="center">
<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={3}>
<VStack spacing={2}>
<Heading
as="h1"
size="3xl"
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}>
AI驱动的概念板块智能分析平台
大模型辅助的信息整理与呈现平台
</Text>
<Text fontSize="lg" opacity={0.8} maxW="3xl" lineHeight="tall">
基于深度学习算法实时监控市场动态精准捕捉概念热点
以大模型协助汇聚与清洗多源信息结合自主训练的领域知识图谱
<br />
为您的投资决策提供全方位的数据支持和智能分析
并由资深分析师进行人工整合与校准提供结构化参考信息
</Text>
</VStack>
</VStack>
@@ -826,7 +1190,7 @@ const ConceptCenter = () => {
<Icon as={FaRocket} boxSize={8} color="cyan.300" />
<Text fontWeight="bold" fontSize="lg">智能追踪</Text>
<Text fontSize="sm" opacity={0.8} textAlign="center">
AI算法精准定位
算法智能追踪
</Text>
</VStack>
@@ -865,11 +1229,11 @@ const ConceptCenter = () => {
<HStack spacing={8} divider={<Divider orientation="vertical" height="40px" borderColor="whiteAlpha.400" />}>
<VStack>
<Text fontSize="3xl" fontWeight="bold" color="yellow.300">{totalConcepts}</Text>
<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">10K+</Text>
<Text fontSize="3xl" fontWeight="bold" color="cyan.300">5000+</Text>
<Text fontSize="sm" opacity={0.8}>相关个股</Text>
</VStack>
<VStack>
@@ -959,146 +1323,199 @@ const ConceptCenter = () => {
<DateSelector />
</Box>
<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"
{/* 双栏布局:左侧概念卡片,右侧统计面板 */}
<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}
>
<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>
<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={() => 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={() => 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, xl: 4 }} spacing={8}>
{[...Array(12)].map((_, i) => (
<SkeletonCard key={i} />
))}
</SimpleGrid>
) : concepts.length > 0 ? (
<>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing={8} className="concept-grid">
{concepts.map((concept) => (
<Box key={concept.concept_id} className="concept-item" role="group">
<ConceptCard concept={concept} />
</Box>
))}
</SimpleGrid>
<Center mt={12}>
<HStack spacing={2}>
<Button
size="sm"
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
isDisabled={currentPage === 1}
colorScheme="purple"
variant="outline"
>
上一页
</Button>
<ButtonGroup size="sm" isAttached variant="outline">
<IconButton
icon={<FaThLarge />}
onClick={() => 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={() => 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>
{[...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>
);
})}
<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>
)}
<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>
)}
{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) => (
<Box key={concept.concept_id} className="concept-item" role="group">
<ConceptCard concept={concept} />
</Box>
))}
</SimpleGrid>
) : (
<VStack spacing={4} align="stretch" className="concept-list">
{concepts.map((concept) => (
<ConceptListItem key={concept.concept_id} concept={concept} />
))}
</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 */}
@@ -1135,6 +1552,16 @@ const ConceptCenter = () => {
conceptId={selectedConceptId}
/>
{/* 订阅升级Modal */}
<SubscriptionUpgradeModal
isOpen={upgradeModalOpen}
onClose={() => setUpgradeModalOpen(false)}
requiredLevel={upgradeFeature}
featureName={
upgradeFeature === 'pro' ? '概念统计中心和热门个股' : '概念历史时间轴'
}
/>
</Box>
);
};