diff --git a/category_tree_openapi.json b/category_tree_openapi.json index 16e13635..47dd9f66 100644 --- a/category_tree_openapi.json +++ b/category_tree_openapi.json @@ -30,8 +30,8 @@ "tags": [ "分类树" ], - "summary": "获取完整分类树", - "description": "获取指定数据源的完整分类树状结构。\n\n## 使用场景\n- 前端树形组件初始化\n- 构建完整的分类导航\n- 级联选择器数据源\n\n## 注意事项\n- SMM树约53MB,Mysteel树约152MB\n- 建议前端实现懒加载或缓存策略\n- 响应时间取决于网络带宽\n", + "summary": "获取分类树(支持深度控制)", + "description": "获取指定数据源的分类树状结构,支持深度控制。\n\n## 使用场景\n- 前端树形组件初始化(默认只加载第一层)\n- 懒加载:用户展开时再加载下一层\n- 级联选择器数据源\n\n## 默认行为\n- **默认只返回第一层** (max_depth=1),大幅减少数据传输量\n- SMM第一层约43个节点,Mysteel第一层约2个节点\n- 完整树数据量: SMM约53MB, Mysteel约152MB\n\n## 推荐用法\n1. 首次加载:不传max_depth(默认1层)\n2. 用户点击节点:调用 /api/category-tree/node 获取子节点\n", "operationId": "getCategoryTree", "parameters": [ { @@ -47,6 +47,19 @@ ] }, "example": "SMM" + }, + { + "name": "max_depth", + "in": "query", + "description": "返回的最大层级深度\n- 1: 只返回第一层(默认,推荐)\n- 2: 返回前两层\n- 999: 返回完整树(不推荐,数据量大)\n", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "default": 1 + }, + "example": 1 } ], "responses": { @@ -268,7 +281,8 @@ "required": [ "name", "path", - "level" + "level", + "has_children" ], "properties": { "name": { @@ -287,9 +301,14 @@ "minimum": 1, "example": 3 }, + "has_children": { + "type": "boolean", + "description": "是否有子节点(用于前端判断是否可展开)", + "example": true + }, "children": { "type": "array", - "description": "子节点列表", + "description": "子节点列表(根据max_depth可能为空数组)", "items": { "$ref": "#/components/schemas/TreeNode" } diff --git a/src/services/categoryService.ts b/src/services/categoryService.ts index 3548d1f8..c4c5596d 100644 --- a/src/services/categoryService.ts +++ b/src/services/categoryService.ts @@ -34,21 +34,41 @@ export interface ErrorResponse { detail: string; } +export interface MetricDataPoint { + date: string; + value: number | null; +} + +export interface MetricDataResponse { + metric_id: string; + metric_name: string; + source: string; + frequency: string; + unit: string; + data: MetricDataPoint[]; + total_count: number; +} + /** - * 获取完整分类树 + * 获取分类树(支持深度控制) * @param source 数据源类型 ('SMM' | 'Mysteel') - * @returns 完整的分类树数据 + * @param maxDepth 返回的最大层级深度(默认1层,推荐懒加载) + * @returns 分类树数据 */ export const fetchCategoryTree = async ( - source: 'SMM' | 'Mysteel' + source: 'SMM' | 'Mysteel', + maxDepth: number = 1 ): Promise => { try { - const response = await fetch(`/category-api/api/category-tree?source=${source}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); + const response = await fetch( + `/category-api/api/category-tree?source=${source}&max_depth=${maxDepth}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ); if (!response.ok) { const errorData: ErrorResponse = await response.json(); @@ -190,3 +210,46 @@ export const getParentPaths = (path: string): string[] => { return parentPaths; }; + +/** + * 获取指标数据详情 + * @param metricId 指标ID + * @param startDate 开始日期(可选,格式:YYYY-MM-DD) + * @param endDate 结束日期(可选,格式:YYYY-MM-DD) + * @param limit 返回数据条数(可选,默认100) + * @returns 指标数据 + */ +export const fetchMetricData = async ( + metricId: string, + startDate?: string, + endDate?: string, + limit: number = 100 +): Promise => { + try { + const params = new URLSearchParams({ + metric_id: metricId, + limit: limit.toString(), + }); + + if (startDate) params.append('start_date', startDate); + if (endDate) params.append('end_date', endDate); + + const response = await fetch(`/category-api/api/metric-data?${params.toString()}`, { + 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: MetricDataResponse = await response.json(); + return data; + } catch (error) { + console.error('fetchMetricData error:', error); + throw error; + } +}; diff --git a/src/views/DataBrowser/MetricDataModal.tsx b/src/views/DataBrowser/MetricDataModal.tsx new file mode 100644 index 00000000..5afac629 --- /dev/null +++ b/src/views/DataBrowser/MetricDataModal.tsx @@ -0,0 +1,509 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalCloseButton, + Box, + Tabs, + TabList, + TabPanels, + Tab, + TabPanel, + Table, + Thead, + Tbody, + Tr, + Th, + Td, + Text, + HStack, + VStack, + Badge, + Spinner, + Flex, + Icon, + Button, + Input, + useToast, +} from '@chakra-ui/react'; +import { FaTable, FaChartLine, FaCalendar, FaDownload } from 'react-icons/fa'; +import ReactECharts from 'echarts-for-react'; +import { fetchMetricData, MetricDataResponse, TreeMetric } from '@services/categoryService'; + +// 黑金主题配色 +const themeColors = { + 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)', + }, + primary: { + gold: '#D4AF37', + goldLight: '#F4E3A7', + goldDark: '#B8941F', + }, +}; + +interface MetricDataModalProps { + isOpen: boolean; + onClose: () => void; + metric: TreeMetric; +} + +const MetricDataModal: React.FC = ({ isOpen, onClose, metric }) => { + const [loading, setLoading] = useState(false); + const [metricData, setMetricData] = useState(null); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [limit, setLimit] = useState(100); + const toast = useToast(); + + // 加载数据 + useEffect(() => { + if (isOpen && metric) { + loadMetricData(); + } + }, [isOpen, metric]); + + const loadMetricData = async () => { + setLoading(true); + try { + const data = await fetchMetricData(metric.metric_id, startDate, endDate, limit); + setMetricData(data); + } catch (error) { + toast({ + title: '加载失败', + description: '无法加载指标数据', + status: 'error', + duration: 3000, + isClosable: true, + }); + } finally { + setLoading(false); + } + }; + + // 准备图表数据 + const chartOption = useMemo(() => { + if (!metricData || !metricData.data || metricData.data.length === 0) { + return null; + } + + const dates = metricData.data.map((item) => item.date); + const values = metricData.data.map((item) => item.value); + + return { + backgroundColor: 'transparent', + title: { + text: metricData.metric_name, + left: 'center', + textStyle: { + color: themeColors.text.gold, + fontSize: 16, + fontWeight: 'bold', + }, + }, + tooltip: { + trigger: 'axis', + backgroundColor: themeColors.bg.card, + borderColor: themeColors.border.gold, + textStyle: { + color: themeColors.text.primary, + }, + formatter: (params: any) => { + const param = params[0]; + return ` +
+
+ ${param.name} +
+
+ ${param.seriesName}: ${param.value !== null ? param.value.toLocaleString() : '-'} ${metricData.unit || ''} +
+
+ `; + }, + }, + grid: { + left: '3%', + right: '4%', + bottom: '10%', + top: '15%', + containLabel: true, + }, + xAxis: { + type: 'category', + data: dates, + axisLabel: { + color: themeColors.text.secondary, + rotate: 45, + fontSize: 10, + }, + axisLine: { + lineStyle: { + color: themeColors.border.default, + }, + }, + }, + yAxis: { + type: 'value', + name: metricData.unit || '', + nameTextStyle: { + color: themeColors.text.gold, + }, + axisLabel: { + color: themeColors.text.secondary, + formatter: (value: number) => value.toLocaleString(), + }, + splitLine: { + lineStyle: { + color: themeColors.border.default, + type: 'dashed', + }, + }, + axisLine: { + lineStyle: { + color: themeColors.border.default, + }, + }, + }, + series: [ + { + name: metricData.metric_name, + type: 'line', + smooth: true, + symbol: 'circle', + symbolSize: 6, + lineStyle: { + color: themeColors.primary.gold, + width: 2, + }, + itemStyle: { + color: themeColors.primary.gold, + borderColor: themeColors.primary.goldLight, + borderWidth: 2, + }, + areaStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { + offset: 0, + color: 'rgba(212, 175, 55, 0.3)', + }, + { + offset: 1, + color: 'rgba(212, 175, 55, 0.05)', + }, + ], + }, + }, + data: values, + connectNulls: true, + }, + ], + }; + }, [metricData]); + + // 导出CSV + const handleExportCSV = () => { + if (!metricData || !metricData.data) return; + + const csvContent = [ + ['日期', '数值', '单位'].join(','), + ...metricData.data.map((item) => [item.date, item.value ?? '', metricData.unit || ''].join(',')), + ].join('\n'); + + const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', `${metricData.metric_name}_${Date.now()}.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + toast({ + title: '导出成功', + description: 'CSV 文件已下载', + status: 'success', + duration: 2000, + }); + }; + + return ( + + + + + + + + {metric.metric_name} + + + + {metric.source} + + + {metric.frequency} + + + + + ID: {metric.metric_id} + {metric.unit && 单位: {metric.unit}} + + + + + + + {loading ? ( + + + + 加载数据中... + + + ) : ( + <> + {/* 筛选工具栏 */} + + + + + setStartDate(e.target.value)} + bg={themeColors.bg.card} + borderColor={themeColors.border.default} + color={themeColors.text.primary} + _focus={{ borderColor: themeColors.primary.gold }} + /> + + setEndDate(e.target.value)} + bg={themeColors.bg.card} + borderColor={themeColors.border.default} + color={themeColors.text.primary} + _focus={{ borderColor: themeColors.primary.gold }} + /> + + + + 限制: + + setLimit(parseInt(e.target.value) || 100)} + bg={themeColors.bg.card} + borderColor={themeColors.border.default} + color={themeColors.text.primary} + _focus={{ borderColor: themeColors.primary.gold }} + /> + + + + + + + {/* 数据展示 */} + {metricData && ( + + + + + 折线图 + + + + 数据表格 + + + + + {/* 折线图 */} + + {chartOption ? ( + + + + 共 {metricData.data.length} 条数据点 + + + ) : ( + + 暂无数据 + + )} + + + {/* 数据表格 */} + + + + + + + + + + + + {metricData.data.map((item, index) => ( + + + + + + ))} + +
+ 序号 + + 日期 + + 数值 {metricData.unit && `(${metricData.unit})`} +
+ {index + 1} + + {item.date} + + {item.value !== null ? item.value.toLocaleString() : '-'} +
+
+ {metricData.data.length === 0 && ( + + 暂无数据 + + )} +
+
+
+ )} + + )} +
+
+
+ ); +}; + +export default MetricDataModal; diff --git a/src/views/DataBrowser/index.tsx b/src/views/DataBrowser/index.tsx index 6e0910cf..e432f547 100644 --- a/src/views/DataBrowser/index.tsx +++ b/src/views/DataBrowser/index.tsx @@ -19,7 +19,7 @@ import { CardBody, Divider, SimpleGrid, - Collapse, + useDisclosure, } from '@chakra-ui/react'; import { FaDatabase, @@ -30,12 +30,12 @@ import { FaHome, FaChevronRight, FaChevronDown, - FaChevronUp, - FaFilter, FaTimes, + FaEye, } from 'react-icons/fa'; import { motion } from 'framer-motion'; -import { fetchCategoryTree, fetchCategoryNode } from '@services/categoryService'; +import { fetchCategoryTree, fetchCategoryNode, TreeNode, TreeMetric, CategoryTreeResponse } from '@services/categoryService'; +import MetricDataModal from './MetricDataModal'; // 黑金主题配色 const themeColors = { @@ -68,38 +68,18 @@ const themeColors = { 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; + source: 'SMM' | 'Mysteel'; onNodeClick: (node: TreeNode) => void; expandedNodes: Set; - onToggleExpand: (path: string) => void; + onToggleExpand: (node: TreeNode) => Promise; searchQuery: string; -}> = ({ node, onNodeClick, expandedNodes, onToggleExpand, searchQuery }) => { + loadingNodes: Set; +}> = ({ 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; @@ -130,13 +110,13 @@ const TreeNodeComponent: React.FC<{ borderRadius="md" transition="all 0.2s" onClick={() => { - if (hasChildren) { - onToggleExpand(node.path); - } + onToggleExpand(node); onNodeClick(node); }} > - {hasChildren ? ( + {isLoading ? ( + + ) : hasChildren || !hasMetrics ? ( @@ -175,10 +155,12 @@ const TreeNodeComponent: React.FC<{ ))} @@ -187,8 +169,8 @@ const TreeNodeComponent: React.FC<{ ); }; -// 指标卡片组件 -const MetricCard: React.FC<{ metric: TreeMetric }> = ({ metric }) => { +// 指标卡片组件(可点击查看详情) +const MetricCard: React.FC<{ metric: TreeMetric; onClick: () => void }> = ({ metric, onClick }) => { return ( = ({ metric }) => { borderColor={themeColors.border.default} borderRadius="lg" overflow="hidden" + cursor="pointer" + onClick={onClick} whileHover={{ borderColor: themeColors.border.goldGlow, scale: 1.02, @@ -205,7 +189,7 @@ const MetricCard: React.FC<{ metric: TreeMetric }> = ({ metric }) => { - + {metric.metric_name} = ({ metric }) => { )} - - ID: {metric.metric_id} - + + + ID: {metric.metric_id} + + + @@ -261,11 +256,13 @@ const DataBrowser: React.FC = () => { const [currentNode, setCurrentNode] = useState(null); const [breadcrumbs, setBreadcrumbs] = useState([]); const [expandedNodes, setExpandedNodes] = useState>(new Set()); - const [showFilters, setShowFilters] = useState(false); + const [loadingNodes, setLoadingNodes] = useState>(new Set()); + const [selectedMetric, setSelectedMetric] = useState(null); + const { isOpen, onOpen, onClose } = useDisclosure(); const toast = useToast(); - // 加载分类树 + // 加载分类树(只加载第一层) useEffect(() => { loadCategoryTree(); }, [selectedSource]); @@ -273,10 +270,11 @@ const DataBrowser: React.FC = () => { const loadCategoryTree = async () => { setLoading(true); try { - const data = await fetchCategoryTree(selectedSource); + const data = await fetchCategoryTree(selectedSource, 1); // 只加载第一层 setTreeData(data); setCurrentNode(null); setBreadcrumbs([]); + setExpandedNodes(new Set()); } catch (error) { toast({ title: '加载失败', @@ -290,17 +288,75 @@ const DataBrowser: React.FC = () => { } }; - // 切换节点展开状态 - const toggleNodeExpand = (path: string) => { - setExpandedNodes((prev) => { - const newSet = new Set(prev); - if (newSet.has(path)) { - newSet.delete(path); - } else { - newSet.add(path); + // 切换节点展开状态(懒加载子节点) + 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 needsLoading = !node.children || node.children.length === 0; + + if (needsLoading) { + // 添加加载状态 + 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; + }); + } } - return newSet; - }); + + // 展开节点 + setExpandedNodes((prev) => new Set(prev).add(node.path)); + } }; // 处理节点点击 @@ -339,6 +395,12 @@ const DataBrowser: React.FC = () => { } }; + // 处理指标点击 + const handleMetricClick = (metric: TreeMetric) => { + setSelectedMetric(metric); + onOpen(); + }; + // 过滤树节点(根据搜索关键词) const filteredTree = useMemo(() => { if (!treeData || !searchQuery) return treeData?.tree || []; @@ -572,10 +634,12 @@ const DataBrowser: React.FC = () => { ))} @@ -627,7 +691,11 @@ const DataBrowser: React.FC = () => { {currentNode.metrics && currentNode.metrics.length > 0 ? ( {currentNode.metrics.map((metric) => ( - + handleMetricClick(metric)} + /> ))} ) : ( @@ -663,6 +731,11 @@ const DataBrowser: React.FC = () => { + + {/* 指标数据详情模态框 */} + {selectedMetric && ( + + )} ); };