update pay function
This commit is contained in:
@@ -30,8 +30,8 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"分类树"
|
"分类树"
|
||||||
],
|
],
|
||||||
"summary": "获取完整分类树",
|
"summary": "获取分类树(支持深度控制)",
|
||||||
"description": "获取指定数据源的完整分类树状结构。\n\n## 使用场景\n- 前端树形组件初始化\n- 构建完整的分类导航\n- 级联选择器数据源\n\n## 注意事项\n- SMM树约53MB,Mysteel树约152MB\n- 建议前端实现懒加载或缓存策略\n- 响应时间取决于网络带宽\n",
|
"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",
|
"operationId": "getCategoryTree",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -47,6 +47,19 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"example": "SMM"
|
"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": {
|
"responses": {
|
||||||
@@ -268,7 +281,8 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"name",
|
"name",
|
||||||
"path",
|
"path",
|
||||||
"level"
|
"level",
|
||||||
|
"has_children"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -287,9 +301,14 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"example": 3
|
"example": 3
|
||||||
},
|
},
|
||||||
|
"has_children": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "是否有子节点(用于前端判断是否可展开)",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
"children": {
|
"children": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"description": "子节点列表",
|
"description": "子节点列表(根据max_depth可能为空数组)",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/TreeNode"
|
"$ref": "#/components/schemas/TreeNode"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,21 +34,41 @@ export interface ErrorResponse {
|
|||||||
detail: string;
|
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')
|
* @param source 数据源类型 ('SMM' | 'Mysteel')
|
||||||
* @returns 完整的分类树数据
|
* @param maxDepth 返回的最大层级深度(默认1层,推荐懒加载)
|
||||||
|
* @returns 分类树数据
|
||||||
*/
|
*/
|
||||||
export const fetchCategoryTree = async (
|
export const fetchCategoryTree = async (
|
||||||
source: 'SMM' | 'Mysteel'
|
source: 'SMM' | 'Mysteel',
|
||||||
|
maxDepth: number = 1
|
||||||
): Promise<CategoryTreeResponse> => {
|
): Promise<CategoryTreeResponse> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/category-api/api/category-tree?source=${source}`, {
|
const response = await fetch(
|
||||||
method: 'GET',
|
`/category-api/api/category-tree?source=${source}&max_depth=${maxDepth}`,
|
||||||
headers: {
|
{
|
||||||
'Content-Type': 'application/json',
|
method: 'GET',
|
||||||
},
|
headers: {
|
||||||
});
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData: ErrorResponse = await response.json();
|
const errorData: ErrorResponse = await response.json();
|
||||||
@@ -190,3 +210,46 @@ export const getParentPaths = (path: string): string[] => {
|
|||||||
|
|
||||||
return parentPaths;
|
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,
|
CardBody,
|
||||||
Divider,
|
Divider,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Collapse,
|
useDisclosure,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import {
|
import {
|
||||||
FaDatabase,
|
FaDatabase,
|
||||||
@@ -30,12 +30,12 @@ import {
|
|||||||
FaHome,
|
FaHome,
|
||||||
FaChevronRight,
|
FaChevronRight,
|
||||||
FaChevronDown,
|
FaChevronDown,
|
||||||
FaChevronUp,
|
|
||||||
FaFilter,
|
|
||||||
FaTimes,
|
FaTimes,
|
||||||
|
FaEye,
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import { motion } from 'framer-motion';
|
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 = {
|
const themeColors = {
|
||||||
@@ -68,38 +68,18 @@ const themeColors = {
|
|||||||
const MotionBox = motion(Box);
|
const MotionBox = motion(Box);
|
||||||
const MotionCard = motion(Card);
|
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<{
|
const TreeNodeComponent: React.FC<{
|
||||||
node: TreeNode;
|
node: TreeNode;
|
||||||
|
source: 'SMM' | 'Mysteel';
|
||||||
onNodeClick: (node: TreeNode) => void;
|
onNodeClick: (node: TreeNode) => void;
|
||||||
expandedNodes: Set<string>;
|
expandedNodes: Set<string>;
|
||||||
onToggleExpand: (path: string) => void;
|
onToggleExpand: (node: TreeNode) => Promise<void>;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
}> = ({ node, onNodeClick, expandedNodes, onToggleExpand, searchQuery }) => {
|
loadingNodes: Set<string>;
|
||||||
|
}> = ({ node, source, onNodeClick, expandedNodes, onToggleExpand, searchQuery, loadingNodes }) => {
|
||||||
const isExpanded = expandedNodes.has(node.path);
|
const isExpanded = expandedNodes.has(node.path);
|
||||||
|
const isLoading = loadingNodes.has(node.path);
|
||||||
const hasChildren = node.children && node.children.length > 0;
|
const hasChildren = node.children && node.children.length > 0;
|
||||||
const hasMetrics = node.metrics && node.metrics.length > 0;
|
const hasMetrics = node.metrics && node.metrics.length > 0;
|
||||||
|
|
||||||
@@ -130,13 +110,13 @@ const TreeNodeComponent: React.FC<{
|
|||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
transition="all 0.2s"
|
transition="all 0.2s"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (hasChildren) {
|
onToggleExpand(node);
|
||||||
onToggleExpand(node.path);
|
|
||||||
}
|
|
||||||
onNodeClick(node);
|
onNodeClick(node);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasChildren ? (
|
{isLoading ? (
|
||||||
|
<Spinner size="xs" color={themeColors.primary.gold} mr={2} />
|
||||||
|
) : hasChildren || !hasMetrics ? (
|
||||||
<Icon
|
<Icon
|
||||||
as={isExpanded ? FaChevronDown : FaChevronRight}
|
as={isExpanded ? FaChevronDown : FaChevronRight}
|
||||||
color={themeColors.text.muted}
|
color={themeColors.text.muted}
|
||||||
@@ -148,8 +128,8 @@ const TreeNodeComponent: React.FC<{
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Icon
|
<Icon
|
||||||
as={hasChildren ? (isExpanded ? FaFolderOpen : FaFolder) : FaFile}
|
as={hasChildren || !hasMetrics ? (isExpanded ? FaFolderOpen : FaFolder) : FaFile}
|
||||||
color={hasChildren ? themeColors.primary.gold : themeColors.text.secondary}
|
color={hasChildren || !hasMetrics ? themeColors.primary.gold : themeColors.text.secondary}
|
||||||
mr={2}
|
mr={2}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -175,10 +155,12 @@ const TreeNodeComponent: React.FC<{
|
|||||||
<TreeNodeComponent
|
<TreeNodeComponent
|
||||||
key={child.path}
|
key={child.path}
|
||||||
node={child}
|
node={child}
|
||||||
|
source={source}
|
||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
expandedNodes={expandedNodes}
|
expandedNodes={expandedNodes}
|
||||||
onToggleExpand={onToggleExpand}
|
onToggleExpand={onToggleExpand}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
|
loadingNodes={loadingNodes}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</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 (
|
return (
|
||||||
<MotionCard
|
<MotionCard
|
||||||
bg={themeColors.bg.card}
|
bg={themeColors.bg.card}
|
||||||
@@ -196,6 +178,8 @@ const MetricCard: React.FC<{ metric: TreeMetric }> = ({ metric }) => {
|
|||||||
borderColor={themeColors.border.default}
|
borderColor={themeColors.border.default}
|
||||||
borderRadius="lg"
|
borderRadius="lg"
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={onClick}
|
||||||
whileHover={{
|
whileHover={{
|
||||||
borderColor: themeColors.border.goldGlow,
|
borderColor: themeColors.border.goldGlow,
|
||||||
scale: 1.02,
|
scale: 1.02,
|
||||||
@@ -205,7 +189,7 @@ const MetricCard: React.FC<{ metric: TreeMetric }> = ({ metric }) => {
|
|||||||
<CardBody>
|
<CardBody>
|
||||||
<VStack align="stretch" spacing={3}>
|
<VStack align="stretch" spacing={3}>
|
||||||
<HStack justify="space-between">
|
<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}
|
{metric.metric_name}
|
||||||
</Text>
|
</Text>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -244,9 +228,20 @@ const MetricCard: React.FC<{ metric: TreeMetric }> = ({ metric }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text color={themeColors.text.muted} fontSize="xs" fontFamily="monospace">
|
<HStack justify="space-between">
|
||||||
ID: {metric.metric_id}
|
<Text color={themeColors.text.muted} fontSize="xs" fontFamily="monospace">
|
||||||
</Text>
|
ID: {metric.metric_id}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
color={themeColors.primary.gold}
|
||||||
|
leftIcon={<FaEye />}
|
||||||
|
_hover={{ bg: themeColors.bg.cardHover }}
|
||||||
|
>
|
||||||
|
查看数据
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</MotionCard>
|
</MotionCard>
|
||||||
@@ -261,11 +256,13 @@ const DataBrowser: React.FC = () => {
|
|||||||
const [currentNode, setCurrentNode] = useState<TreeNode | null>(null);
|
const [currentNode, setCurrentNode] = useState<TreeNode | null>(null);
|
||||||
const [breadcrumbs, setBreadcrumbs] = useState<string[]>([]);
|
const [breadcrumbs, setBreadcrumbs] = useState<string[]>([]);
|
||||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
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();
|
const toast = useToast();
|
||||||
|
|
||||||
// 加载分类树
|
// 加载分类树(只加载第一层)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCategoryTree();
|
loadCategoryTree();
|
||||||
}, [selectedSource]);
|
}, [selectedSource]);
|
||||||
@@ -273,10 +270,11 @@ const DataBrowser: React.FC = () => {
|
|||||||
const loadCategoryTree = async () => {
|
const loadCategoryTree = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await fetchCategoryTree(selectedSource);
|
const data = await fetchCategoryTree(selectedSource, 1); // 只加载第一层
|
||||||
setTreeData(data);
|
setTreeData(data);
|
||||||
setCurrentNode(null);
|
setCurrentNode(null);
|
||||||
setBreadcrumbs([]);
|
setBreadcrumbs([]);
|
||||||
|
setExpandedNodes(new Set());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: '加载失败',
|
title: '加载失败',
|
||||||
@@ -290,17 +288,75 @@ const DataBrowser: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 切换节点展开状态
|
// 切换节点展开状态(懒加载子节点)
|
||||||
const toggleNodeExpand = (path: string) => {
|
const toggleNodeExpand = async (node: TreeNode) => {
|
||||||
setExpandedNodes((prev) => {
|
const isCurrentlyExpanded = expandedNodes.has(node.path);
|
||||||
const newSet = new Set(prev);
|
|
||||||
if (newSet.has(path)) {
|
if (isCurrentlyExpanded) {
|
||||||
newSet.delete(path);
|
// 收起节点
|
||||||
} else {
|
setExpandedNodes((prev) => {
|
||||||
newSet.add(path);
|
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(() => {
|
const filteredTree = useMemo(() => {
|
||||||
if (!treeData || !searchQuery) return treeData?.tree || [];
|
if (!treeData || !searchQuery) return treeData?.tree || [];
|
||||||
@@ -572,10 +634,12 @@ const DataBrowser: React.FC = () => {
|
|||||||
<TreeNodeComponent
|
<TreeNodeComponent
|
||||||
key={node.path}
|
key={node.path}
|
||||||
node={node}
|
node={node}
|
||||||
|
source={selectedSource}
|
||||||
onNodeClick={handleNodeClick}
|
onNodeClick={handleNodeClick}
|
||||||
expandedNodes={expandedNodes}
|
expandedNodes={expandedNodes}
|
||||||
onToggleExpand={toggleNodeExpand}
|
onToggleExpand={toggleNodeExpand}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
|
loadingNodes={loadingNodes}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</VStack>
|
</VStack>
|
||||||
@@ -627,7 +691,11 @@ const DataBrowser: React.FC = () => {
|
|||||||
{currentNode.metrics && currentNode.metrics.length > 0 ? (
|
{currentNode.metrics && currentNode.metrics.length > 0 ? (
|
||||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4} mt={4}>
|
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4} mt={4}>
|
||||||
{currentNode.metrics.map((metric) => (
|
{currentNode.metrics.map((metric) => (
|
||||||
<MetricCard key={metric.metric_id} metric={metric} />
|
<MetricCard
|
||||||
|
key={metric.metric_id}
|
||||||
|
metric={metric}
|
||||||
|
onClick={() => handleMetricClick(metric)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
) : (
|
) : (
|
||||||
@@ -663,6 +731,11 @@ const DataBrowser: React.FC = () => {
|
|||||||
</MotionBox>
|
</MotionBox>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
|
{/* 指标数据详情模态框 */}
|
||||||
|
{selectedMetric && (
|
||||||
|
<MetricDataModal isOpen={isOpen} onClose={onClose} metric={selectedMetric} />
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user