update pay function
This commit is contained in:
@@ -31,7 +31,7 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { FaTable, FaChartLine, FaCalendar, FaDownload } from 'react-icons/fa';
|
||||
import { fetchMetricData, MetricDataResponse, TreeMetric } from '@services/categoryService';
|
||||
import KLineChartView from './KLineChartView';
|
||||
import SimpleLineChart from './SimpleLineChart';
|
||||
|
||||
// 黑金主题配色
|
||||
const themeColors = {
|
||||
@@ -277,10 +277,10 @@ const MetricDataModal: React.FC<MetricDataModalProps> = ({ isOpen, onClose, metr
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* 折线图 - 使用 KLineChart */}
|
||||
{/* 折线图 - 使用简单 Canvas 实现 */}
|
||||
<TabPanel p={4}>
|
||||
{metricData && metricData.data.length > 0 ? (
|
||||
<KLineChartView
|
||||
<SimpleLineChart
|
||||
data={metricData.data}
|
||||
metricName={metricData.metric_name}
|
||||
unit={metricData.unit}
|
||||
|
||||
544
src/views/DataBrowser/SimpleLineChart.tsx
Normal file
544
src/views/DataBrowser/SimpleLineChart.tsx
Normal file
@@ -0,0 +1,544 @@
|
||||
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Flex,
|
||||
Icon,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FaExpand,
|
||||
FaCompress,
|
||||
FaCamera,
|
||||
FaRedo,
|
||||
} from 'react-icons/fa';
|
||||
import { MetricDataPoint } from '@services/categoryService';
|
||||
|
||||
// 黑金主题配色
|
||||
const themeColors = {
|
||||
bg: {
|
||||
primary: '#0a0a0a',
|
||||
secondary: '#1a1a1a',
|
||||
card: '#1e1e1e',
|
||||
},
|
||||
text: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#b8b8b8',
|
||||
muted: '#808080',
|
||||
gold: '#D4AF37',
|
||||
},
|
||||
border: {
|
||||
default: 'rgba(255, 255, 255, 0.1)',
|
||||
gold: 'rgba(212, 175, 55, 0.3)',
|
||||
},
|
||||
primary: {
|
||||
gold: '#D4AF37',
|
||||
goldLight: '#F4E3A7',
|
||||
},
|
||||
};
|
||||
|
||||
interface SimpleLineChartProps {
|
||||
data: MetricDataPoint[];
|
||||
metricName: string;
|
||||
unit: string;
|
||||
frequency: string;
|
||||
}
|
||||
|
||||
type TimeRange = '1M' | '3M' | '6M' | '1Y' | 'YTD' | 'ALL';
|
||||
|
||||
const SimpleLineChart: React.FC<SimpleLineChartProps> = ({
|
||||
data,
|
||||
metricName,
|
||||
unit,
|
||||
frequency,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [selectedRange, setSelectedRange] = useState<TimeRange>('ALL');
|
||||
const [hoveredPoint, setHoveredPoint] = useState<{ x: number; y: number; data: MetricDataPoint } | null>(null);
|
||||
|
||||
// 过滤并排序数据
|
||||
const processedData = useMemo(() => {
|
||||
const filtered = data
|
||||
.filter((item) => item.value !== null)
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
|
||||
// 根据时间范围筛选
|
||||
if (selectedRange === 'ALL' || filtered.length === 0) {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
let startDate: Date;
|
||||
|
||||
switch (selectedRange) {
|
||||
case '1M':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
|
||||
break;
|
||||
case '3M':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
|
||||
break;
|
||||
case '6M':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 6, now.getDate());
|
||||
break;
|
||||
case '1Y':
|
||||
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
break;
|
||||
case 'YTD':
|
||||
startDate = new Date(now.getFullYear(), 0, 1);
|
||||
break;
|
||||
default:
|
||||
return filtered;
|
||||
}
|
||||
|
||||
return filtered.filter((item) => new Date(item.date) >= startDate);
|
||||
}, [data, selectedRange]);
|
||||
|
||||
// 计算统计数据
|
||||
const stats = useMemo(() => {
|
||||
const values = processedData.map((item) => item.value as number);
|
||||
|
||||
if (values.length === 0) {
|
||||
return { min: 0, max: 0, avg: 0, latest: 0, change: 0, changePercent: 0 };
|
||||
}
|
||||
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const avg = values.reduce((sum, val) => sum + val, 0) / values.length;
|
||||
const latest = values[values.length - 1];
|
||||
const first = values[0];
|
||||
const change = latest - first;
|
||||
const changePercent = first !== 0 ? (change / first) * 100 : 0;
|
||||
|
||||
return { min, max, avg, latest, change, changePercent };
|
||||
}, [processedData]);
|
||||
|
||||
// 绘制图表
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || processedData.length === 0) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// 设置 canvas 尺寸(高分辨率)
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
const padding = { top: 30, right: 60, bottom: 60, left: 60 };
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
|
||||
// 清空画布
|
||||
ctx.fillStyle = themeColors.bg.card;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// 获取数据范围
|
||||
const values = processedData.map((item) => item.value as number);
|
||||
const minValue = Math.min(...values);
|
||||
const maxValue = Math.max(...values);
|
||||
const valueRange = maxValue - minValue || 1;
|
||||
|
||||
// 绘制网格线
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// 横向网格线(5条)
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const y = padding.top + (chartHeight * i) / 5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, y);
|
||||
ctx.lineTo(padding.left + chartWidth, y);
|
||||
ctx.stroke();
|
||||
|
||||
// Y轴刻度标签
|
||||
const value = maxValue - (valueRange * i) / 5;
|
||||
ctx.fillStyle = themeColors.text.secondary;
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(value.toFixed(2), padding.left - 10, y + 4);
|
||||
}
|
||||
|
||||
// 纵向网格线(根据数据点数量决定)
|
||||
const xAxisSteps = Math.min(10, processedData.length);
|
||||
for (let i = 0; i <= xAxisSteps; i++) {
|
||||
const x = padding.left + (chartWidth * i) / xAxisSteps;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, padding.top);
|
||||
ctx.lineTo(x, padding.top + chartHeight);
|
||||
ctx.stroke();
|
||||
|
||||
// X轴日期标签(只显示部分)
|
||||
if (i % Math.ceil(xAxisSteps / 5) === 0 || i === xAxisSteps) {
|
||||
const dataIndex = Math.floor((processedData.length - 1) * (i / xAxisSteps));
|
||||
const date = processedData[dataIndex]?.date || '';
|
||||
ctx.fillStyle = themeColors.text.secondary;
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.save();
|
||||
ctx.translate(x, padding.top + chartHeight + 15);
|
||||
ctx.rotate(-Math.PI / 6);
|
||||
ctx.fillText(date, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制折线
|
||||
ctx.strokeStyle = themeColors.primary.gold;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
|
||||
processedData.forEach((item, index) => {
|
||||
const x = padding.left + (chartWidth * index) / (processedData.length - 1 || 1);
|
||||
const y = padding.top + chartHeight - ((item.value as number - minValue) / valueRange) * chartHeight;
|
||||
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制数据点
|
||||
ctx.fillStyle = themeColors.primary.gold;
|
||||
processedData.forEach((item, index) => {
|
||||
const x = padding.left + (chartWidth * index) / (processedData.length - 1 || 1);
|
||||
const y = padding.top + chartHeight - ((item.value as number - minValue) / valueRange) * chartHeight;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 3, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
// 绘制悬停点
|
||||
if (hoveredPoint) {
|
||||
ctx.strokeStyle = themeColors.primary.goldLight;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([5, 5]);
|
||||
|
||||
// 垂直线
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(hoveredPoint.x, padding.top);
|
||||
ctx.lineTo(hoveredPoint.x, padding.top + chartHeight);
|
||||
ctx.stroke();
|
||||
|
||||
// 水平线
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, hoveredPoint.y);
|
||||
ctx.lineTo(padding.left + chartWidth, hoveredPoint.y);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// 高亮点
|
||||
ctx.fillStyle = themeColors.primary.goldLight;
|
||||
ctx.beginPath();
|
||||
ctx.arc(hoveredPoint.x, hoveredPoint.y, 5, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}
|
||||
}, [processedData, hoveredPoint]);
|
||||
|
||||
// 鼠标移动事件
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!canvasRef.current || processedData.length === 0) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const padding = { top: 30, right: 60, bottom: 60, left: 60 };
|
||||
const chartWidth = rect.width - padding.left - padding.right;
|
||||
const chartHeight = rect.height - padding.top - padding.bottom;
|
||||
|
||||
// 判断是否在图表区域内
|
||||
if (
|
||||
x >= padding.left &&
|
||||
x <= padding.left + chartWidth &&
|
||||
y >= padding.top &&
|
||||
y <= padding.top + chartHeight
|
||||
) {
|
||||
// 找到最近的数据点
|
||||
const relativeX = x - padding.left;
|
||||
const index = Math.round((relativeX / chartWidth) * (processedData.length - 1));
|
||||
const dataPoint = processedData[index];
|
||||
|
||||
if (dataPoint) {
|
||||
const values = processedData.map((item) => item.value as number);
|
||||
const minValue = Math.min(...values);
|
||||
const maxValue = Math.max(...values);
|
||||
const valueRange = maxValue - minValue || 1;
|
||||
|
||||
const pointX = padding.left + (chartWidth * index) / (processedData.length - 1 || 1);
|
||||
const pointY =
|
||||
padding.top + chartHeight - ((dataPoint.value as number - minValue) / valueRange) * chartHeight;
|
||||
|
||||
setHoveredPoint({ x: pointX, y: pointY, data: dataPoint });
|
||||
}
|
||||
} else {
|
||||
setHoveredPoint(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredPoint(null);
|
||||
};
|
||||
|
||||
// 时间范围切换
|
||||
const handleTimeRangeChange = (range: TimeRange) => {
|
||||
setSelectedRange(range);
|
||||
};
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
setSelectedRange('ALL');
|
||||
};
|
||||
|
||||
// 截图
|
||||
const handleScreenshot = () => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
canvasRef.current.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${metricName}_${new Date().toISOString().split('T')[0]}.png`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
};
|
||||
|
||||
// 全屏
|
||||
const toggleFullscreen = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (!isFullscreen) {
|
||||
if (containerRef.current.requestFullscreen) {
|
||||
containerRef.current.requestFullscreen();
|
||||
}
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num: number) => {
|
||||
if (Math.abs(num) >= 1e9) {
|
||||
return (num / 1e9).toFixed(2) + 'B';
|
||||
}
|
||||
if (Math.abs(num) >= 1e6) {
|
||||
return (num / 1e6).toFixed(2) + 'M';
|
||||
}
|
||||
if (Math.abs(num) >= 1e3) {
|
||||
return (num / 1e3).toFixed(2) + 'K';
|
||||
}
|
||||
return num.toFixed(2);
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4} w="100%" ref={containerRef}>
|
||||
{/* 工具栏 */}
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={4}>
|
||||
{/* 时间范围选择 */}
|
||||
<ButtonGroup size="sm" isAttached variant="outline">
|
||||
{(['1M', '3M', '6M', '1Y', 'YTD', 'ALL'] as TimeRange[]).map((range) => (
|
||||
<Button
|
||||
key={range}
|
||||
onClick={() => handleTimeRangeChange(range)}
|
||||
bg={selectedRange === range ? themeColors.primary.gold : 'transparent'}
|
||||
color={
|
||||
selectedRange === range ? themeColors.bg.primary : themeColors.text.secondary
|
||||
}
|
||||
borderColor={themeColors.border.gold}
|
||||
_hover={{
|
||||
bg: selectedRange === range ? themeColors.primary.goldLight : themeColors.bg.card,
|
||||
}}
|
||||
>
|
||||
{range}
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
|
||||
{/* 图表操作 */}
|
||||
<HStack spacing={2}>
|
||||
<Tooltip label="重置视图">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color={themeColors.text.secondary}
|
||||
_hover={{ color: themeColors.primary.gold }}
|
||||
onClick={handleReset}
|
||||
>
|
||||
<Icon as={FaRedo} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="截图">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color={themeColors.text.secondary}
|
||||
_hover={{ color: themeColors.primary.gold }}
|
||||
onClick={handleScreenshot}
|
||||
>
|
||||
<Icon as={FaCamera} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label={isFullscreen ? '退出全屏' : '全屏'}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color={themeColors.text.secondary}
|
||||
_hover={{ color: themeColors.primary.gold }}
|
||||
onClick={toggleFullscreen}
|
||||
>
|
||||
<Icon as={isFullscreen ? FaCompress : FaExpand} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<Flex
|
||||
justify="space-around"
|
||||
align="center"
|
||||
bg={themeColors.bg.secondary}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={themeColors.border.default}
|
||||
wrap="wrap"
|
||||
gap={4}
|
||||
>
|
||||
<VStack spacing={0}>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
最新值
|
||||
</Text>
|
||||
<Text color={themeColors.text.gold} fontSize="lg" fontWeight="bold">
|
||||
{formatNumber(stats.latest)} {unit}
|
||||
</Text>
|
||||
<Text
|
||||
color={stats.change >= 0 ? '#00ff88' : '#ff4444'}
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{stats.change >= 0 ? '+' : ''}
|
||||
{formatNumber(stats.change)} ({stats.changePercent.toFixed(2)}%)
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack spacing={0}>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
平均值
|
||||
</Text>
|
||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||||
{formatNumber(stats.avg)} {unit}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack spacing={0}>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
最高值
|
||||
</Text>
|
||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||||
{formatNumber(stats.max)} {unit}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack spacing={0}>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
最低值
|
||||
</Text>
|
||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||||
{formatNumber(stats.min)} {unit}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack spacing={0}>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
数据点数
|
||||
</Text>
|
||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||||
{processedData.length}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack spacing={0}>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
频率
|
||||
</Text>
|
||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||||
{frequency}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
|
||||
{/* 图表容器 */}
|
||||
<Box position="relative">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={800}
|
||||
height={500}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '500px',
|
||||
borderRadius: '6px',
|
||||
border: `1px solid ${themeColors.border.gold}`,
|
||||
backgroundColor: themeColors.bg.card,
|
||||
cursor: 'crosshair',
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
|
||||
{/* 悬停提示 */}
|
||||
{hoveredPoint && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={2}
|
||||
left={2}
|
||||
bg={themeColors.bg.secondary}
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={themeColors.border.gold}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Text color={themeColors.text.gold} fontSize="xs" fontWeight="bold">
|
||||
{hoveredPoint.data.date}
|
||||
</Text>
|
||||
<Text color={themeColors.text.primary} fontSize="sm">
|
||||
{hoveredPoint.data.value} {unit}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<Flex justify="space-between" align="center" fontSize="xs" color={themeColors.text.muted}>
|
||||
<HStack spacing={4}>
|
||||
<Text>💡 提示:鼠标悬停查看详细数据</Text>
|
||||
</HStack>
|
||||
<Text>数据来源: {metricName}</Text>
|
||||
</Flex>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleLineChart;
|
||||
Reference in New Issue
Block a user