update pay function
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<CategoryTreeResponse> => {
|
||||
try {
|
||||
const response = await fetch(`/category-api/api/category-tree?source=${source}`, {
|
||||
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<MetricDataResponse> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
509
src/views/DataBrowser/MetricDataModal.tsx
Normal file
509
src/views/DataBrowser/MetricDataModal.tsx
Normal file
@@ -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<MetricDataModalProps> = ({ isOpen, onClose, metric }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [metricData, setMetricData] = useState<MetricDataResponse | null>(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 `
|
||||
<div style="padding: 8px;">
|
||||
<div style="color: ${themeColors.text.gold}; font-weight: bold; margin-bottom: 4px;">
|
||||
${param.name}
|
||||
</div>
|
||||
<div style="color: ${themeColors.text.secondary};">
|
||||
${param.seriesName}: ${param.value !== null ? param.value.toLocaleString() : '-'} ${metricData.unit || ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
},
|
||||
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 (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
|
||||
<ModalOverlay bg="blackAlpha.800" />
|
||||
<ModalContent
|
||||
bg={themeColors.bg.card}
|
||||
borderWidth="1px"
|
||||
borderColor={themeColors.border.gold}
|
||||
maxH="90vh"
|
||||
>
|
||||
<ModalHeader
|
||||
bg={themeColors.bg.secondary}
|
||||
borderBottomWidth="1px"
|
||||
borderBottomColor={themeColors.border.gold}
|
||||
>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Text color={themeColors.text.gold} fontSize="lg" fontWeight="bold">
|
||||
{metric.metric_name}
|
||||
</Text>
|
||||
<HStack spacing={2}>
|
||||
<Badge bg={metric.source === 'SMM' ? 'blue.500' : 'green.500'} color="white">
|
||||
{metric.source}
|
||||
</Badge>
|
||||
<Badge bg={themeColors.border.gold} color={themeColors.primary.gold}>
|
||||
{metric.frequency}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<HStack spacing={4} fontSize="sm" color={themeColors.text.secondary}>
|
||||
<Text>ID: {metric.metric_id}</Text>
|
||||
{metric.unit && <Text>单位: {metric.unit}</Text>}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color={themeColors.text.secondary} />
|
||||
|
||||
<ModalBody p={0}>
|
||||
{loading ? (
|
||||
<Flex justify="center" align="center" py={20}>
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color={themeColors.primary.gold} thickness="4px" />
|
||||
<Text color={themeColors.text.secondary}>加载数据中...</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
{/* 筛选工具栏 */}
|
||||
<Box
|
||||
p={4}
|
||||
bg={themeColors.bg.secondary}
|
||||
borderBottomWidth="1px"
|
||||
borderBottomColor={themeColors.border.default}
|
||||
>
|
||||
<HStack spacing={4} wrap="wrap">
|
||||
<HStack flex="1" minW="200px">
|
||||
<Icon as={FaCalendar} color={themeColors.text.muted} />
|
||||
<Input
|
||||
type="date"
|
||||
size="sm"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
bg={themeColors.bg.card}
|
||||
borderColor={themeColors.border.default}
|
||||
color={themeColors.text.primary}
|
||||
_focus={{ borderColor: themeColors.primary.gold }}
|
||||
/>
|
||||
<Text color={themeColors.text.muted}>至</Text>
|
||||
<Input
|
||||
type="date"
|
||||
size="sm"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
bg={themeColors.bg.card}
|
||||
borderColor={themeColors.border.default}
|
||||
color={themeColors.text.primary}
|
||||
_focus={{ borderColor: themeColors.primary.gold }}
|
||||
/>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Text color={themeColors.text.muted} fontSize="sm">
|
||||
限制:
|
||||
</Text>
|
||||
<Input
|
||||
type="number"
|
||||
size="sm"
|
||||
w="100px"
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(parseInt(e.target.value) || 100)}
|
||||
bg={themeColors.bg.card}
|
||||
borderColor={themeColors.border.default}
|
||||
color={themeColors.text.primary}
|
||||
_focus={{ borderColor: themeColors.primary.gold }}
|
||||
/>
|
||||
</HStack>
|
||||
<Button
|
||||
size="sm"
|
||||
bg={themeColors.primary.gold}
|
||||
color={themeColors.bg.primary}
|
||||
_hover={{ bg: themeColors.primary.goldLight }}
|
||||
onClick={loadMetricData}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
borderColor={themeColors.border.gold}
|
||||
color={themeColors.text.gold}
|
||||
leftIcon={<FaDownload />}
|
||||
onClick={handleExportCSV}
|
||||
isDisabled={!metricData || !metricData.data || metricData.data.length === 0}
|
||||
>
|
||||
导出CSV
|
||||
</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 数据展示 */}
|
||||
{metricData && (
|
||||
<Tabs
|
||||
colorScheme="yellow"
|
||||
variant="enclosed"
|
||||
bg={themeColors.bg.primary}
|
||||
>
|
||||
<TabList borderBottomColor={themeColors.border.default}>
|
||||
<Tab
|
||||
color={themeColors.text.secondary}
|
||||
_selected={{
|
||||
color: themeColors.text.gold,
|
||||
borderColor: themeColors.border.gold,
|
||||
bg: themeColors.bg.card,
|
||||
}}
|
||||
>
|
||||
<Icon as={FaChartLine} mr={2} />
|
||||
折线图
|
||||
</Tab>
|
||||
<Tab
|
||||
color={themeColors.text.secondary}
|
||||
_selected={{
|
||||
color: themeColors.text.gold,
|
||||
borderColor: themeColors.border.gold,
|
||||
bg: themeColors.bg.card,
|
||||
}}
|
||||
>
|
||||
<Icon as={FaTable} mr={2} />
|
||||
数据表格
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* 折线图 */}
|
||||
<TabPanel p={4}>
|
||||
{chartOption ? (
|
||||
<Box>
|
||||
<ReactECharts
|
||||
option={chartOption}
|
||||
style={{ height: '500px', width: '100%' }}
|
||||
opts={{ renderer: 'svg' }}
|
||||
/>
|
||||
<Text
|
||||
textAlign="center"
|
||||
color={themeColors.text.muted}
|
||||
fontSize="sm"
|
||||
mt={2}
|
||||
>
|
||||
共 {metricData.data.length} 条数据点
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Flex justify="center" align="center" py={20}>
|
||||
<Text color={themeColors.text.muted}>暂无数据</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* 数据表格 */}
|
||||
<TabPanel p={0}>
|
||||
<Box maxH="500px" overflowY="auto">
|
||||
<Table variant="simple" size="sm">
|
||||
<Thead
|
||||
position="sticky"
|
||||
top={0}
|
||||
bg={themeColors.bg.secondary}
|
||||
zIndex={1}
|
||||
>
|
||||
<Tr>
|
||||
<Th
|
||||
color={themeColors.text.gold}
|
||||
borderColor={themeColors.border.default}
|
||||
>
|
||||
序号
|
||||
</Th>
|
||||
<Th
|
||||
color={themeColors.text.gold}
|
||||
borderColor={themeColors.border.default}
|
||||
>
|
||||
日期
|
||||
</Th>
|
||||
<Th
|
||||
color={themeColors.text.gold}
|
||||
borderColor={themeColors.border.default}
|
||||
isNumeric
|
||||
>
|
||||
数值 {metricData.unit && `(${metricData.unit})`}
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{metricData.data.map((item, index) => (
|
||||
<Tr
|
||||
key={index}
|
||||
_hover={{ bg: themeColors.bg.cardHover }}
|
||||
>
|
||||
<Td
|
||||
color={themeColors.text.muted}
|
||||
borderColor={themeColors.border.default}
|
||||
>
|
||||
{index + 1}
|
||||
</Td>
|
||||
<Td
|
||||
color={themeColors.text.secondary}
|
||||
borderColor={themeColors.border.default}
|
||||
>
|
||||
{item.date}
|
||||
</Td>
|
||||
<Td
|
||||
color={themeColors.text.primary}
|
||||
borderColor={themeColors.border.default}
|
||||
isNumeric
|
||||
fontWeight="bold"
|
||||
>
|
||||
{item.value !== null ? item.value.toLocaleString() : '-'}
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
{metricData.data.length === 0 && (
|
||||
<Flex justify="center" align="center" py={20}>
|
||||
<Text color={themeColors.text.muted}>暂无数据</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetricDataModal;
|
||||
@@ -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<string>;
|
||||
onToggleExpand: (path: string) => void;
|
||||
onToggleExpand: (node: TreeNode) => Promise<void>;
|
||||
searchQuery: string;
|
||||
}> = ({ node, onNodeClick, expandedNodes, onToggleExpand, searchQuery }) => {
|
||||
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;
|
||||
|
||||
@@ -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 ? (
|
||||
<Spinner size="xs" color={themeColors.primary.gold} mr={2} />
|
||||
) : hasChildren || !hasMetrics ? (
|
||||
<Icon
|
||||
as={isExpanded ? FaChevronDown : FaChevronRight}
|
||||
color={themeColors.text.muted}
|
||||
@@ -148,8 +128,8 @@ const TreeNodeComponent: React.FC<{
|
||||
)}
|
||||
|
||||
<Icon
|
||||
as={hasChildren ? (isExpanded ? FaFolderOpen : FaFolder) : FaFile}
|
||||
color={hasChildren ? themeColors.primary.gold : themeColors.text.secondary}
|
||||
as={hasChildren || !hasMetrics ? (isExpanded ? FaFolderOpen : FaFolder) : FaFile}
|
||||
color={hasChildren || !hasMetrics ? themeColors.primary.gold : themeColors.text.secondary}
|
||||
mr={2}
|
||||
/>
|
||||
|
||||
@@ -175,10 +155,12 @@ const TreeNodeComponent: React.FC<{
|
||||
<TreeNodeComponent
|
||||
key={child.path}
|
||||
node={child}
|
||||
source={source}
|
||||
onNodeClick={onNodeClick}
|
||||
expandedNodes={expandedNodes}
|
||||
onToggleExpand={onToggleExpand}
|
||||
searchQuery={searchQuery}
|
||||
loadingNodes={loadingNodes}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
@@ -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 (
|
||||
<MotionCard
|
||||
bg={themeColors.bg.card}
|
||||
@@ -196,6 +178,8 @@ const MetricCard: React.FC<{ metric: TreeMetric }> = ({ 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 }) => {
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Text color={themeColors.text.primary} fontWeight="bold" fontSize="sm">
|
||||
<Text color={themeColors.text.primary} fontWeight="bold" fontSize="sm" flex="1">
|
||||
{metric.metric_name}
|
||||
</Text>
|
||||
<Badge
|
||||
@@ -244,9 +228,20 @@ const MetricCard: React.FC<{ metric: TreeMetric }> = ({ metric }) => {
|
||||
</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>
|
||||
@@ -261,11 +256,13 @@ const DataBrowser: React.FC = () => {
|
||||
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 [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]);
|
||||
@@ -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) => {
|
||||
// 切换节点展开状态(懒加载子节点)
|
||||
const toggleNodeExpand = async (node: TreeNode) => {
|
||||
const isCurrentlyExpanded = expandedNodes.has(node.path);
|
||||
|
||||
if (isCurrentlyExpanded) {
|
||||
// 收起节点
|
||||
setExpandedNodes((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(path)) {
|
||||
newSet.delete(path);
|
||||
} else {
|
||||
newSet.add(path);
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 展开节点
|
||||
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 = () => {
|
||||
<TreeNodeComponent
|
||||
key={node.path}
|
||||
node={node}
|
||||
source={selectedSource}
|
||||
onNodeClick={handleNodeClick}
|
||||
expandedNodes={expandedNodes}
|
||||
onToggleExpand={toggleNodeExpand}
|
||||
searchQuery={searchQuery}
|
||||
loadingNodes={loadingNodes}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
@@ -627,7 +691,11 @@ const DataBrowser: React.FC = () => {
|
||||
{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} />
|
||||
<MetricCard
|
||||
key={metric.metric_id}
|
||||
metric={metric}
|
||||
onClick={() => handleMetricClick(metric)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
@@ -663,6 +731,11 @@ const DataBrowser: React.FC = () => {
|
||||
</MotionBox>
|
||||
</Flex>
|
||||
</Container>
|
||||
|
||||
{/* 指标数据详情模态框 */}
|
||||
{selectedMetric && (
|
||||
<MetricDataModal isOpen={isOpen} onClose={onClose} metric={selectedMetric} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user