880 lines
30 KiB
TypeScript
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;
|