Files
vf_react/src/views/DataBrowser/index.tsx
2025-11-26 10:11:02 +08:00

880 lines
30 KiB
TypeScript

import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
Container,
Flex,
Text,
Input,
Button,
VStack,
HStack,
Badge,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Icon,
Spinner,
useToast,
Card,
CardBody,
Divider,
SimpleGrid,
useDisclosure,
} from '@chakra-ui/react';
import {
FaDatabase,
FaFolder,
FaFolderOpen,
FaFile,
FaSearch,
FaHome,
FaChevronRight,
FaChevronDown,
FaTimes,
FaEye,
} from 'react-icons/fa';
import { motion } from 'framer-motion';
import {
fetchCategoryTree,
fetchCategoryNode,
searchMetrics,
TreeNode,
TreeMetric,
CategoryTreeResponse,
MetricSearchResult,
SearchResponse
} from '@services/categoryService';
import MetricDataModal from './MetricDataModal';
// 黑金主题配色
const themeColors = {
bgGradient: 'linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%)',
bgRadialGold: 'radial-gradient(circle at center, rgba(212, 175, 55, 0.1) 0%, transparent 70%)',
primary: {
gold: '#D4AF37',
goldLight: '#F4E3A7',
goldDark: '#B8941F',
},
bg: {
primary: '#0a0a0a',
secondary: '#1a1a1a',
card: '#1e1e1e',
cardHover: '#252525',
},
text: {
primary: '#ffffff',
secondary: '#b8b8b8',
muted: '#808080',
gold: '#D4AF37',
},
border: {
default: 'rgba(255, 255, 255, 0.1)',
gold: 'rgba(212, 175, 55, 0.3)',
goldGlow: 'rgba(212, 175, 55, 0.5)',
},
};
const MotionBox = motion(Box);
const MotionCard = motion(Card);
// 树节点组件(支持懒加载)
const TreeNodeComponent: React.FC<{
node: TreeNode;
source: 'SMM' | 'Mysteel';
onNodeClick: (node: TreeNode) => void;
expandedNodes: Set<string>;
onToggleExpand: (node: TreeNode) => Promise<void>;
searchQuery: string;
loadingNodes: Set<string>;
}> = ({ node, source, onNodeClick, expandedNodes, onToggleExpand, searchQuery, loadingNodes }) => {
const isExpanded = expandedNodes.has(node.path);
const isLoading = loadingNodes.has(node.path);
const hasChildren = node.children && node.children.length > 0;
const hasMetrics = node.metrics && node.metrics.length > 0;
// 高亮搜索关键词
const highlightText = (text: string) => {
if (!searchQuery) return text;
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
return parts.map((part, index) =>
part.toLowerCase() === searchQuery.toLowerCase() ? (
<Text as="span" key={index} color={themeColors.primary.gold} fontWeight="bold">
{part}
</Text>
) : (
part
)
);
};
return (
<Box>
<Flex
align="center"
p={2}
pl={node.level * 4}
cursor="pointer"
bg={isExpanded ? themeColors.bg.cardHover : 'transparent'}
_hover={{ bg: themeColors.bg.cardHover }}
borderRadius="md"
transition="all 0.2s"
onClick={() => {
onToggleExpand(node);
onNodeClick(node);
}}
>
{isLoading ? (
<Spinner size="xs" color={themeColors.primary.gold} mr={2} />
) : hasChildren || !hasMetrics ? (
<Icon
as={isExpanded ? FaChevronDown : FaChevronRight}
color={themeColors.text.muted}
mr={2}
fontSize="xs"
/>
) : (
<Box w="16px" mr={2} />
)}
<Icon
as={hasChildren || !hasMetrics ? (isExpanded ? FaFolderOpen : FaFolder) : FaFile}
color={hasChildren || !hasMetrics ? themeColors.primary.gold : themeColors.text.secondary}
mr={2}
/>
<Text color={themeColors.text.primary} fontSize="sm">
{highlightText(node.name)}
</Text>
{hasMetrics && (
<Badge
ml={2}
bg={themeColors.border.gold}
color={themeColors.primary.gold}
fontSize="xs"
>
{node.metrics.length}
</Badge>
)}
</Flex>
{isExpanded && hasChildren && (
<Box>
{node.children!.map((child) => (
<TreeNodeComponent
key={child.path}
node={child}
source={source}
onNodeClick={onNodeClick}
expandedNodes={expandedNodes}
onToggleExpand={onToggleExpand}
searchQuery={searchQuery}
loadingNodes={loadingNodes}
/>
))}
</Box>
)}
</Box>
);
};
// 指标卡片组件(可点击查看详情)
const MetricCard: React.FC<{ metric: TreeMetric; onClick: () => void }> = ({ metric, onClick }) => {
return (
<MotionCard
bg={themeColors.bg.card}
borderWidth="1px"
borderColor={themeColors.border.default}
borderRadius="lg"
overflow="hidden"
cursor="pointer"
onClick={onClick}
whileHover={{
borderColor: themeColors.border.goldGlow,
scale: 1.02,
}}
transition={{ duration: 0.2 }}
>
<CardBody>
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text color={themeColors.text.primary} fontWeight="bold" fontSize="sm" flex="1">
{metric.metric_name}
</Text>
<Badge
bg={metric.source === 'SMM' ? 'blue.500' : 'green.500'}
color="white"
fontSize="xs"
>
{metric.source}
</Badge>
</HStack>
<Divider borderColor={themeColors.border.default} />
<SimpleGrid columns={2} spacing={2}>
<Box>
<Text color={themeColors.text.muted} fontSize="xs">
</Text>
<Text color={themeColors.text.secondary} fontSize="sm">
{metric.frequency}
</Text>
</Box>
<Box>
<Text color={themeColors.text.muted} fontSize="xs">
</Text>
<Text color={themeColors.text.secondary} fontSize="sm">
{metric.unit || '-'}
</Text>
</Box>
</SimpleGrid>
{metric.description && (
<Text color={themeColors.text.muted} fontSize="xs" noOfLines={2}>
{metric.description}
</Text>
)}
<HStack justify="space-between">
<Text color={themeColors.text.muted} fontSize="xs" fontFamily="monospace">
ID: {metric.metric_id}
</Text>
<Button
size="xs"
variant="ghost"
color={themeColors.primary.gold}
leftIcon={<FaEye />}
_hover={{ bg: themeColors.bg.cardHover }}
>
</Button>
</HStack>
</VStack>
</CardBody>
</MotionCard>
);
};
const DataBrowser: React.FC = () => {
const [selectedSource, setSelectedSource] = useState<'SMM' | 'Mysteel'>('SMM');
const [treeData, setTreeData] = useState<CategoryTreeResponse | null>(null);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResponse | null>(null);
const [searching, setSearching] = useState(false);
const [currentNode, setCurrentNode] = useState<TreeNode | null>(null);
const [breadcrumbs, setBreadcrumbs] = useState<string[]>([]);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [loadingNodes, setLoadingNodes] = useState<Set<string>>(new Set());
const [selectedMetric, setSelectedMetric] = useState<TreeMetric | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
// 加载分类树(只加载第一层)
useEffect(() => {
loadCategoryTree();
}, [selectedSource]);
const loadCategoryTree = async () => {
setLoading(true);
try {
const data = await fetchCategoryTree(selectedSource, 1); // 只加载第一层
setTreeData(data);
setCurrentNode(null);
setBreadcrumbs([]);
setExpandedNodes(new Set());
setSearchResults(null); // 清空搜索结果
} catch (error) {
toast({
title: '加载失败',
description: '无法加载分类树数据',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setLoading(false);
}
};
// 执行搜索
const handleSearch = async () => {
if (!searchQuery.trim()) {
setSearchResults(null);
return;
}
setSearching(true);
try {
const results = await searchMetrics(searchQuery, selectedSource, undefined, 100);
setSearchResults(results);
} catch (error) {
toast({
title: '搜索失败',
description: '无法搜索指标数据',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setSearching(false);
}
};
// 当搜索关键词变化时,自动搜索
useEffect(() => {
const timer = setTimeout(() => {
if (searchQuery.trim()) {
handleSearch();
} else {
setSearchResults(null);
}
}, 500); // 防抖 500ms
return () => clearTimeout(timer);
}, [searchQuery, selectedSource]);
// 切换节点展开状态(懒加载子节点)
const toggleNodeExpand = async (node: TreeNode) => {
const isCurrentlyExpanded = expandedNodes.has(node.path);
if (isCurrentlyExpanded) {
// 收起节点
setExpandedNodes((prev) => {
const newSet = new Set(prev);
newSet.delete(node.path);
return newSet;
});
} else {
// 展开节点 - 始终尝试加载子节点(如果还没加载过)
const hasChildren = node.children && node.children.length > 0;
// 如果没有子节点数据,尝试从服务器加载
if (!hasChildren) {
// 添加加载状态
setLoadingNodes((prev) => new Set(prev).add(node.path));
try {
// 从服务器加载子节点
const nodeData = await fetchCategoryNode(node.path, selectedSource);
// 更新树数据
setTreeData((prevData) => {
if (!prevData) return prevData;
const updateNode = (nodes: TreeNode[]): TreeNode[] => {
return nodes.map((n) => {
if (n.path === node.path) {
return { ...n, children: nodeData.children, metrics: nodeData.metrics };
}
if (n.children) {
return { ...n, children: updateNode(n.children) };
}
return n;
});
};
return {
...prevData,
tree: updateNode(prevData.tree),
};
});
// 更新当前节点(如果是当前选中的节点)
if (currentNode && currentNode.path === node.path) {
setCurrentNode(nodeData);
}
} catch (error) {
toast({
title: '加载失败',
description: '无法加载子节点数据',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setLoadingNodes((prev) => {
const newSet = new Set(prev);
newSet.delete(node.path);
return newSet;
});
}
}
// 展开节点
setExpandedNodes((prev) => new Set(prev).add(node.path));
}
};
// 处理节点点击
const handleNodeClick = (node: TreeNode) => {
setCurrentNode(node);
const pathParts = node.path.split('|');
setBreadcrumbs(pathParts);
};
// 处理面包屑导航
const handleBreadcrumbClick = (index: number) => {
if (index === -1) {
setCurrentNode(null);
setBreadcrumbs([]);
return;
}
const targetPath = breadcrumbs.slice(0, index + 1).join('|');
// 在树中查找对应节点
const findNode = (nodes: TreeNode[], path: string): TreeNode | null => {
for (const node of nodes) {
if (node.path === path) return node;
if (node.children) {
const found = findNode(node.children, path);
if (found) return found;
}
}
return null;
};
if (treeData) {
const node = findNode(treeData.tree, targetPath);
if (node) {
handleNodeClick(node);
}
}
};
// 处理指标点击
const handleMetricClick = (metric: TreeMetric) => {
setSelectedMetric(metric);
onOpen();
};
// 显示的树节点(搜索时不显示树)
const displayTree = useMemo(() => {
if (searchQuery.trim()) {
return []; // 搜索时不显示树
}
return treeData?.tree || [];
}, [treeData, searchQuery]);
return (
<Box
minH="100vh"
bg={themeColors.bg.primary}
bgGradient={themeColors.bgGradient}
position="relative"
pt={{ base: '120px', md: '75px' }}
>
{/* 金色光晕背景 */}
<Box
position="absolute"
top="0"
left="50%"
transform="translateX(-50%)"
width="100%"
height="400px"
bgGradient={themeColors.bgRadialGold}
opacity={0.3}
pointerEvents="none"
/>
<Container maxW="container.xl" position="relative" zIndex={1}>
{/* 标题区域 */}
<MotionBox
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<VStack spacing={4} align="stretch" mb={8}>
<HStack spacing={4}>
<Icon as={FaDatabase} color={themeColors.primary.gold} boxSize={8} />
<VStack align="start" spacing={0}>
<Text
fontSize="3xl"
fontWeight="bold"
color={themeColors.text.primary}
textShadow={`0 0 20px ${themeColors.primary.gold}40`}
>
</Text>
<Text color={themeColors.text.secondary} fontSize="sm">
-
</Text>
</VStack>
</HStack>
{/* 数据源切换 */}
<HStack spacing={4}>
<Button
size="sm"
bg={selectedSource === 'SMM' ? themeColors.primary.gold : 'transparent'}
color={selectedSource === 'SMM' ? themeColors.bg.primary : themeColors.text.secondary}
borderWidth="1px"
borderColor={selectedSource === 'SMM' ? themeColors.primary.gold : themeColors.border.default}
_hover={{
borderColor: themeColors.primary.gold,
color: selectedSource === 'SMM' ? themeColors.bg.primary : themeColors.primary.gold,
}}
onClick={() => setSelectedSource('SMM')}
>
SMM {treeData && selectedSource === 'SMM' && `(${treeData.total_metrics.toLocaleString()} 指标)`}
</Button>
<Button
size="sm"
bg={selectedSource === 'Mysteel' ? themeColors.primary.gold : 'transparent'}
color={selectedSource === 'Mysteel' ? themeColors.bg.primary : themeColors.text.secondary}
borderWidth="1px"
borderColor={selectedSource === 'Mysteel' ? themeColors.primary.gold : themeColors.border.default}
_hover={{
borderColor: themeColors.primary.gold,
color: selectedSource === 'Mysteel' ? themeColors.bg.primary : themeColors.primary.gold,
}}
onClick={() => setSelectedSource('Mysteel')}
>
Mysteel {treeData && selectedSource === 'Mysteel' && `(${treeData.total_metrics.toLocaleString()} 指标)`}
</Button>
</HStack>
</VStack>
</MotionBox>
{/* 搜索和过滤 */}
<MotionBox
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.5 }}
>
<Card
bg={themeColors.bg.card}
borderWidth="1px"
borderColor={themeColors.border.gold}
mb={6}
>
<CardBody>
<VStack spacing={3} align="stretch">
<HStack spacing={4}>
<Input
placeholder="搜索分类或指标名称..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
bg={themeColors.bg.secondary}
borderColor={themeColors.border.default}
color={themeColors.text.primary}
_placeholder={{ color: themeColors.text.muted }}
_focus={{
borderColor: themeColors.primary.gold,
boxShadow: `0 0 0 1px ${themeColors.primary.gold}`,
}}
/>
<Button
leftIcon={<FaSearch />}
bg={themeColors.primary.gold}
color={themeColors.bg.primary}
_hover={{ bg: themeColors.primary.goldLight }}
onClick={handleSearch}
isLoading={searching}
>
</Button>
{searchQuery && (
<Button
leftIcon={<FaTimes />}
variant="ghost"
color={themeColors.text.secondary}
_hover={{ color: themeColors.text.primary }}
onClick={() => setSearchQuery('')}
>
</Button>
)}
</HStack>
{/* 搜索结果提示 */}
{searchResults && (
<Flex align="center" justify="space-between" py={2}>
<Text color={themeColors.text.secondary} fontSize="sm">
<Text as="span" color={themeColors.primary.gold} fontWeight="bold">{searchResults.total}</Text>
</Text>
<Text color={themeColors.text.muted} fontSize="xs">
: "{searchResults.query}"
</Text>
</Flex>
)}
{searching && (
<Flex align="center" justify="center" py={2}>
<Spinner size="sm" color={themeColors.primary.gold} mr={2} />
<Text color={themeColors.text.secondary} fontSize="sm">
...
</Text>
</Flex>
)}
</VStack>
</CardBody>
</Card>
</MotionBox>
{/* 面包屑导航 */}
{breadcrumbs.length > 0 && (
<MotionBox
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
mb={4}
>
<Card bg={themeColors.bg.card} borderWidth="1px" borderColor={themeColors.border.default}>
<CardBody py={2}>
<Breadcrumb
spacing={2}
separator={<Icon as={FaChevronRight} color={themeColors.text.muted} />}
>
<BreadcrumbItem>
<BreadcrumbLink
color={themeColors.text.secondary}
_hover={{ color: themeColors.primary.gold }}
onClick={() => handleBreadcrumbClick(-1)}
>
<Icon as={FaHome} />
</BreadcrumbLink>
</BreadcrumbItem>
{breadcrumbs.map((crumb, index) => (
<BreadcrumbItem key={index} isCurrentPage={index === breadcrumbs.length - 1}>
<BreadcrumbLink
color={index === breadcrumbs.length - 1 ? themeColors.primary.gold : themeColors.text.secondary}
_hover={{ color: themeColors.primary.gold }}
onClick={() => handleBreadcrumbClick(index)}
>
{crumb}
</BreadcrumbLink>
</BreadcrumbItem>
))}
</Breadcrumb>
</CardBody>
</Card>
</MotionBox>
)}
{/* 主内容区域 */}
<Flex gap={6} direction={{ base: 'column', lg: 'row' }}>
{/* 左侧:分类树 */}
<MotionBox
flex={{ base: '1', lg: '0 0 400px' }}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3, duration: 0.5 }}
>
<Card
bg={themeColors.bg.card}
borderWidth="1px"
borderColor={themeColors.border.gold}
maxH="calc(100vh - 400px)"
overflowY="auto"
css={{
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: themeColors.bg.secondary,
},
'&::-webkit-scrollbar-thumb': {
background: themeColors.primary.gold,
borderRadius: '4px',
},
}}
>
<CardBody>
{loading ? (
<Flex justify="center" align="center" py={10}>
<Spinner color={themeColors.primary.gold} size="xl" />
</Flex>
) : searchQuery.trim() ? (
// 搜索模式:显示搜索结果列表
<VStack align="stretch" spacing={1}>
{searchResults && searchResults.results.length > 0 ? (
searchResults.results.map((result) => (
<Box
key={result.metric_id}
p={3}
cursor="pointer"
bg="transparent"
_hover={{
bg: themeColors.bg.cardHover,
borderLeftColor: themeColors.primary.gold,
}}
borderRadius="md"
borderLeftWidth="3px"
borderLeftColor="transparent"
transition="all 0.2s"
onClick={() => {
// 转换搜索结果为 TreeMetric 格式
const metric: TreeMetric = {
metric_id: result.metric_id,
metric_name: result.metric_name,
source: result.source as 'SMM' | 'Mysteel',
frequency: result.frequency,
unit: result.unit,
description: result.description,
};
// 更新面包屑导航为搜索结果的路径
const pathParts = result.category_path.split(' > ');
setBreadcrumbs(pathParts);
// 清空搜索框,显示树结构
setSearchQuery('');
handleMetricClick(metric);
}}
>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Text color={themeColors.text.primary} fontSize="sm" fontWeight="bold" flex="1">
{result.metric_name}
</Text>
<Badge
bg={result.source === 'SMM' ? 'blue.500' : 'green.500'}
color="white"
fontSize="xs"
>
{result.source}
</Badge>
</HStack>
<HStack spacing={4} fontSize="xs" color={themeColors.text.muted}>
<Text>: {result.category_path}</Text>
<Text>: {result.frequency}</Text>
<Text>: {result.unit || '-'}</Text>
</HStack>
{result.score && (
<Text fontSize="xs" color={themeColors.text.muted}>
: {(result.score * 100).toFixed(0)}%
</Text>
)}
</VStack>
</Box>
))
) : searchResults ? (
<Flex justify="center" align="center" py={10}>
<VStack spacing={3}>
<Icon as={FaSearch} color={themeColors.text.muted} boxSize={12} />
<Text color={themeColors.text.muted}></Text>
<Text color={themeColors.text.muted} fontSize="sm">
使
</Text>
</VStack>
</Flex>
) : null}
</VStack>
) : (
// 正常模式:显示分类树
<VStack align="stretch" spacing={1}>
{displayTree.map((node) => (
<TreeNodeComponent
key={node.path}
node={node}
source={selectedSource}
onNodeClick={handleNodeClick}
expandedNodes={expandedNodes}
onToggleExpand={toggleNodeExpand}
searchQuery=""
loadingNodes={loadingNodes}
/>
))}
</VStack>
)}
</CardBody>
</Card>
</MotionBox>
{/* 右侧:指标详情 */}
<MotionBox
flex="1"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4, duration: 0.5 }}
>
<Card
bg={themeColors.bg.card}
borderWidth="1px"
borderColor={themeColors.border.gold}
minH="400px"
>
<CardBody>
{currentNode ? (
<VStack align="stretch" spacing={4}>
<HStack justify="space-between">
<VStack align="start" spacing={1}>
<Text color={themeColors.text.primary} fontSize="2xl" fontWeight="bold">
{currentNode.name}
</Text>
<Text color={themeColors.text.muted} fontSize="sm">
{currentNode.level} | : {currentNode.path}
</Text>
</VStack>
{currentNode.metrics && currentNode.metrics.length > 0 && (
<Badge
bg={themeColors.primary.gold}
color={themeColors.bg.primary}
fontSize="md"
px={3}
py={1}
>
{currentNode.metrics.length}
</Badge>
)}
</HStack>
<Divider borderColor={themeColors.border.gold} />
{currentNode.metrics && currentNode.metrics.length > 0 ? (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4} mt={4}>
{currentNode.metrics.map((metric) => (
<MetricCard
key={metric.metric_id}
metric={metric}
onClick={() => handleMetricClick(metric)}
/>
))}
</SimpleGrid>
) : (
<Flex justify="center" align="center" py={10}>
<VStack spacing={3}>
<Icon as={FaFolder} color={themeColors.text.muted} boxSize={12} />
<Text color={themeColors.text.muted}>
{currentNode.children && currentNode.children.length > 0
? '该节点包含子分类,请展开查看'
: '该节点暂无指标数据'}
</Text>
</VStack>
</Flex>
)}
</VStack>
) : (
<Flex justify="center" align="center" py={20}>
<VStack spacing={4}>
<Icon as={FaDatabase} color={themeColors.primary.gold} boxSize={16} />
<Text color={themeColors.text.secondary} fontSize="lg" textAlign="center">
</Text>
{treeData && (
<Text color={themeColors.text.muted} fontSize="sm">
{treeData.total_metrics.toLocaleString()}
</Text>
)}
</VStack>
</Flex>
)}
</CardBody>
</Card>
</MotionBox>
</Flex>
</Container>
{/* 指标数据详情模态框 */}
{selectedMetric && (
<MetricDataModal isOpen={isOpen} onClose={onClose} metric={selectedMetric} />
)}
</Box>
);
};
export default DataBrowser;