update pay function
This commit is contained in:
@@ -102,6 +102,17 @@ export const homeRoutes = [
|
||||
}
|
||||
},
|
||||
|
||||
// 数据浏览器 - /home/data-browser
|
||||
{
|
||||
path: 'data-browser',
|
||||
component: lazyComponents.DataBrowser,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
meta: {
|
||||
title: '数据浏览器',
|
||||
description: '化工商品数据分类树浏览器'
|
||||
}
|
||||
},
|
||||
|
||||
// 回退路由 - 匹配任何未定义的 /home/* 路径
|
||||
{
|
||||
path: '*',
|
||||
|
||||
@@ -42,6 +42,9 @@ export const lazyComponents = {
|
||||
// 价值论坛模块
|
||||
ValueForum: React.lazy(() => import('../views/ValueForum')),
|
||||
ForumPostDetail: React.lazy(() => import('../views/ValueForum/PostDetail')),
|
||||
|
||||
// 数据浏览器模块
|
||||
DataBrowser: React.lazy(() => import('../views/DataBrowser')),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -69,4 +72,5 @@ export const {
|
||||
AgentChat,
|
||||
ValueForum,
|
||||
ForumPostDetail,
|
||||
DataBrowser,
|
||||
} = lazyComponents;
|
||||
|
||||
192
src/services/categoryService.ts
Normal file
192
src/services/categoryService.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 商品分类树数据服务
|
||||
* 对接化工商品数据分类树API
|
||||
* API文档: category_tree_openapi.json
|
||||
*/
|
||||
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
|
||||
// 类型定义
|
||||
export interface TreeMetric {
|
||||
metric_id: string;
|
||||
metric_name: string;
|
||||
source: 'SMM' | 'Mysteel';
|
||||
frequency: string;
|
||||
unit: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
level: number;
|
||||
children?: TreeNode[];
|
||||
metrics?: TreeMetric[];
|
||||
}
|
||||
|
||||
export interface CategoryTreeResponse {
|
||||
source: 'SMM' | 'Mysteel';
|
||||
total_metrics: number;
|
||||
tree: TreeNode[];
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整分类树
|
||||
* @param source 数据源类型 ('SMM' | 'Mysteel')
|
||||
* @returns 完整的分类树数据
|
||||
*/
|
||||
export const fetchCategoryTree = async (
|
||||
source: 'SMM' | 'Mysteel'
|
||||
): Promise<CategoryTreeResponse> => {
|
||||
try {
|
||||
const response = await fetch(`/category-api/api/category-tree?source=${source}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData: ErrorResponse = await response.json();
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data: CategoryTreeResponse = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('fetchCategoryTree error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取特定节点及其子树
|
||||
* @param path 节点完整路径(用 | 分隔)
|
||||
* @param source 数据源类型 ('SMM' | 'Mysteel')
|
||||
* @returns 节点数据及其子树
|
||||
*/
|
||||
export const fetchCategoryNode = async (
|
||||
path: string,
|
||||
source: 'SMM' | 'Mysteel'
|
||||
): Promise<TreeNode> => {
|
||||
try {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const response = await fetch(
|
||||
`/category-api/api/category-tree/node?path=${encodedPath}&source=${source}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData: ErrorResponse = await response.json();
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data: TreeNode = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('fetchCategoryNode error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 搜索指标
|
||||
* @param query 搜索关键词
|
||||
* @param source 数据源类型 ('SMM' | 'Mysteel')
|
||||
* @returns 匹配的指标列表
|
||||
*/
|
||||
export const searchMetrics = async (
|
||||
query: string,
|
||||
source: 'SMM' | 'Mysteel'
|
||||
): Promise<TreeMetric[]> => {
|
||||
try {
|
||||
// 注意:这个接口可能需要后端额外实现
|
||||
// 如果后端没有提供搜索接口,可以在前端基于完整树进行过滤
|
||||
const response = await fetch(
|
||||
`/category-api/api/metrics/search?query=${encodeURIComponent(query)}&source=${source}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data: TreeMetric[] = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('searchMetrics error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从树中提取所有指标(用于前端搜索)
|
||||
* @param nodes 树节点数组
|
||||
* @returns 所有指标的扁平化数组
|
||||
*/
|
||||
export const extractAllMetrics = (nodes: TreeNode[]): TreeMetric[] => {
|
||||
const metrics: TreeMetric[] = [];
|
||||
|
||||
const traverse = (node: TreeNode) => {
|
||||
if (node.metrics && node.metrics.length > 0) {
|
||||
metrics.push(...node.metrics);
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
node.children.forEach(traverse);
|
||||
}
|
||||
};
|
||||
|
||||
nodes.forEach(traverse);
|
||||
return metrics;
|
||||
};
|
||||
|
||||
/**
|
||||
* 在树中查找节点
|
||||
* @param nodes 树节点数组
|
||||
* @param path 节点路径
|
||||
* @returns 找到的节点或 null
|
||||
*/
|
||||
export const findNodeByPath = (nodes: TreeNode[], path: string): TreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.path === path) {
|
||||
return node;
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeByPath(node.children, path);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取节点的所有父节点路径
|
||||
* @param path 节点路径(用 | 分隔)
|
||||
* @returns 父节点路径数组
|
||||
*/
|
||||
export const getParentPaths = (path: string): string[] => {
|
||||
const parts = path.split('|');
|
||||
const parentPaths: string[] = [];
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
parentPaths.push(parts.slice(0, i).join('|'));
|
||||
}
|
||||
|
||||
return parentPaths;
|
||||
};
|
||||
670
src/views/DataBrowser/index.tsx
Normal file
670
src/views/DataBrowser/index.tsx
Normal file
@@ -0,0 +1,670 @@
|
||||
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,
|
||||
Collapse,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FaDatabase,
|
||||
FaFolder,
|
||||
FaFolderOpen,
|
||||
FaFile,
|
||||
FaSearch,
|
||||
FaHome,
|
||||
FaChevronRight,
|
||||
FaChevronDown,
|
||||
FaChevronUp,
|
||||
FaFilter,
|
||||
FaTimes,
|
||||
} from 'react-icons/fa';
|
||||
import { motion } from 'framer-motion';
|
||||
import { fetchCategoryTree, fetchCategoryNode } from '@services/categoryService';
|
||||
|
||||
// 黑金主题配色
|
||||
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);
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
level: number;
|
||||
children?: TreeNode[];
|
||||
metrics?: TreeMetric[];
|
||||
}
|
||||
|
||||
interface TreeMetric {
|
||||
metric_id: string;
|
||||
metric_name: string;
|
||||
source: string;
|
||||
frequency: string;
|
||||
unit: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface CategoryTreeResponse {
|
||||
source: string;
|
||||
total_metrics: number;
|
||||
tree: TreeNode[];
|
||||
}
|
||||
|
||||
// 树节点组件
|
||||
const TreeNodeComponent: React.FC<{
|
||||
node: TreeNode;
|
||||
onNodeClick: (node: TreeNode) => void;
|
||||
expandedNodes: Set<string>;
|
||||
onToggleExpand: (path: string) => void;
|
||||
searchQuery: string;
|
||||
}> = ({ node, onNodeClick, expandedNodes, onToggleExpand, searchQuery }) => {
|
||||
const isExpanded = expandedNodes.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={() => {
|
||||
if (hasChildren) {
|
||||
onToggleExpand(node.path);
|
||||
}
|
||||
onNodeClick(node);
|
||||
}}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<Icon
|
||||
as={isExpanded ? FaChevronDown : FaChevronRight}
|
||||
color={themeColors.text.muted}
|
||||
mr={2}
|
||||
fontSize="xs"
|
||||
/>
|
||||
) : (
|
||||
<Box w="16px" mr={2} />
|
||||
)}
|
||||
|
||||
<Icon
|
||||
as={hasChildren ? (isExpanded ? FaFolderOpen : FaFolder) : FaFile}
|
||||
color={hasChildren ? 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}
|
||||
onNodeClick={onNodeClick}
|
||||
expandedNodes={expandedNodes}
|
||||
onToggleExpand={onToggleExpand}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 指标卡片组件
|
||||
const MetricCard: React.FC<{ metric: TreeMetric }> = ({ metric }) => {
|
||||
return (
|
||||
<MotionCard
|
||||
bg={themeColors.bg.card}
|
||||
borderWidth="1px"
|
||||
borderColor={themeColors.border.default}
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
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">
|
||||
{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>
|
||||
)}
|
||||
|
||||
<Text color={themeColors.text.muted} fontSize="xs" fontFamily="monospace">
|
||||
ID: {metric.metric_id}
|
||||
</Text>
|
||||
</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 [currentNode, setCurrentNode] = useState<TreeNode | null>(null);
|
||||
const [breadcrumbs, setBreadcrumbs] = useState<string[]>([]);
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
// 加载分类树
|
||||
useEffect(() => {
|
||||
loadCategoryTree();
|
||||
}, [selectedSource]);
|
||||
|
||||
const loadCategoryTree = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchCategoryTree(selectedSource);
|
||||
setTreeData(data);
|
||||
setCurrentNode(null);
|
||||
setBreadcrumbs([]);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: '无法加载分类树数据',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换节点展开状态
|
||||
const toggleNodeExpand = (path: string) => {
|
||||
setExpandedNodes((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(path)) {
|
||||
newSet.delete(path);
|
||||
} else {
|
||||
newSet.add(path);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// 处理节点点击
|
||||
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 filteredTree = useMemo(() => {
|
||||
if (!treeData || !searchQuery) return treeData?.tree || [];
|
||||
|
||||
const filterNodes = (nodes: TreeNode[]): TreeNode[] => {
|
||||
return nodes
|
||||
.map((node) => {
|
||||
const matchesName = node.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const filteredChildren = node.children ? filterNodes(node.children) : [];
|
||||
|
||||
if (matchesName || filteredChildren.length > 0) {
|
||||
return {
|
||||
...node,
|
||||
children: filteredChildren,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as TreeNode[];
|
||||
};
|
||||
|
||||
return filterNodes(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>
|
||||
<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 }}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
leftIcon={<FaTimes />}
|
||||
variant="ghost"
|
||||
color={themeColors.text.secondary}
|
||||
_hover={{ color: themeColors.text.primary }}
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</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>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={1}>
|
||||
{filteredTree.map((node) => (
|
||||
<TreeNodeComponent
|
||||
key={node.path}
|
||||
node={node}
|
||||
onNodeClick={handleNodeClick}
|
||||
expandedNodes={expandedNodes}
|
||||
onToggleExpand={toggleNodeExpand}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
))}
|
||||
</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} />
|
||||
))}
|
||||
</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>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataBrowser;
|
||||
Reference in New Issue
Block a user