update pay function

This commit is contained in:
2025-11-20 13:57:11 +08:00
parent 03aee75235
commit 78f7dca1f6
2 changed files with 482 additions and 144 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect } from 'react';
import {
Modal,
ModalOverlay,
@@ -30,8 +30,8 @@ import {
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';
import TradingViewChart from './TradingViewChart';
// 黑金主题配色
const themeColors = {
@@ -98,131 +98,7 @@ const MetricDataModal: React.FC<MetricDataModalProps> = ({ isOpen, onClose, metr
}
};
// 准备图表数据
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]);
// 数据已经在 metricData 中,直接传递给 TradingViewChart
// 导出CSV
const handleExportCSV = () => {
@@ -401,24 +277,15 @@ const MetricDataModal: React.FC<MetricDataModalProps> = ({ isOpen, onClose, metr
</TabList>
<TabPanels>
{/* 折线图 */}
{/* 折线图 - 使用 TradingView Lightweight Charts */}
<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>
{metricData && metricData.data.length > 0 ? (
<TradingViewChart
data={metricData.data}
metricName={metricData.metric_name}
unit={metricData.unit}
frequency={metricData.frequency}
/>
) : (
<Flex justify="center" align="center" py={20}>
<Text color={themeColors.text.muted}></Text>

View File

@@ -0,0 +1,471 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Box,
VStack,
HStack,
Text,
Button,
ButtonGroup,
Flex,
Icon,
useColorMode,
Tooltip,
} from '@chakra-ui/react';
import { createChart, IChartApi, ISeriesApi, LineData, Time } from 'lightweight-charts';
import {
FaExpand,
FaCompress,
FaCamera,
FaRedo,
FaCog,
} 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 TradingViewChartProps {
data: MetricDataPoint[];
metricName: string;
unit: string;
frequency: string;
}
type TimeRange = '1M' | '3M' | '6M' | '1Y' | 'YTD' | 'ALL';
const TradingViewChart: React.FC<TradingViewChartProps> = ({
data,
metricName,
unit,
frequency,
}) => {
const chartContainerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const lineSeriesRef = useRef<ISeriesApi<'Line'> | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [selectedRange, setSelectedRange] = useState<TimeRange>('ALL');
const { colorMode } = useColorMode();
// 初始化图表
useEffect(() => {
if (!chartContainerRef.current || data.length === 0) return;
// 创建图表
const chart = createChart(chartContainerRef.current, {
width: chartContainerRef.current.clientWidth,
height: 500,
layout: {
background: { color: themeColors.bg.card },
textColor: themeColors.text.secondary,
},
grid: {
vertLines: {
color: 'rgba(255, 255, 255, 0.05)',
style: 1, // 实线
},
horzLines: {
color: 'rgba(255, 255, 255, 0.05)',
style: 1,
},
},
crosshair: {
mode: 1, // 正常十字线模式
vertLine: {
color: themeColors.primary.gold,
width: 1,
style: 3, // 虚线
labelBackgroundColor: themeColors.primary.gold,
},
horzLine: {
color: themeColors.primary.gold,
width: 1,
style: 3,
labelBackgroundColor: themeColors.primary.gold,
},
},
rightPriceScale: {
borderColor: themeColors.border.default,
scaleMargins: {
top: 0.1,
bottom: 0.1,
},
},
timeScale: {
borderColor: themeColors.border.default,
timeVisible: true,
secondsVisible: false,
rightOffset: 5,
barSpacing: 10,
minBarSpacing: 3,
fixLeftEdge: true,
fixRightEdge: true,
},
handleScroll: {
mouseWheel: true,
pressedMouseMove: true,
horzTouchDrag: true,
vertTouchDrag: true,
},
handleScale: {
axisPressedMouseMove: true,
mouseWheel: true,
pinch: true,
},
});
// 创建折线系列
const lineSeries = chart.addLineSeries({
color: themeColors.primary.gold,
lineWidth: 2,
lineStyle: 0, // 实线
crosshairMarkerVisible: true,
crosshairMarkerRadius: 6,
crosshairMarkerBorderColor: themeColors.primary.goldLight,
crosshairMarkerBackgroundColor: themeColors.primary.gold,
lastValueVisible: true,
priceLineVisible: true,
priceLineColor: themeColors.primary.gold,
priceLineWidth: 1,
priceLineStyle: 3, // 虚线
title: metricName,
});
// 转换数据格式
const chartData: LineData[] = data
.filter((item) => item.value !== null)
.map((item) => ({
time: item.date as Time,
value: item.value as number,
}))
.sort((a, b) => {
// 确保时间从左到右递增
const timeA = new Date(a.time as string).getTime();
const timeB = new Date(b.time as string).getTime();
return timeA - timeB;
});
// 设置数据
lineSeries.setData(chartData);
// 自动缩放到合适的视图
chart.timeScale().fitContent();
chartRef.current = chart;
lineSeriesRef.current = lineSeries;
// 响应式调整
const handleResize = () => {
if (chartContainerRef.current && chart) {
chart.applyOptions({
width: chartContainerRef.current.clientWidth,
});
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
chart.remove();
};
}, [data, metricName]);
// 时间范围筛选
const handleTimeRangeChange = (range: TimeRange) => {
setSelectedRange(range);
if (!chartRef.current || data.length === 0) return;
const now = new Date();
let startDate: Date;
switch (range) {
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); // 当年1月1日
break;
case 'ALL':
default:
chartRef.current.timeScale().fitContent();
return;
}
// 设置可见范围
const startTimestamp = startDate.getTime() / 1000;
const endTimestamp = now.getTime() / 1000;
chartRef.current.timeScale().setVisibleRange({
from: startTimestamp as Time,
to: endTimestamp as Time,
});
};
// 重置缩放
const handleReset = () => {
if (chartRef.current) {
chartRef.current.timeScale().fitContent();
setSelectedRange('ALL');
}
};
// 截图功能
const handleScreenshot = () => {
if (!chartRef.current) return;
const canvas = chartContainerRef.current?.querySelector('canvas');
if (!canvas) return;
canvas.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 (!chartContainerRef.current) return;
if (!isFullscreen) {
if (chartContainerRef.current.requestFullscreen) {
chartContainerRef.current.requestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
setIsFullscreen(!isFullscreen);
};
// 计算统计数据
const stats = React.useMemo(() => {
const values = data.filter((item) => item.value !== null).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 };
}, [data]);
// 格式化数字
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%">
{/* 工具栏 */}
<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">
{data.filter((item) => item.value !== null).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
ref={chartContainerRef}
w="100%"
h="500px"
borderRadius="md"
borderWidth="1px"
borderColor={themeColors.border.gold}
overflow="hidden"
position="relative"
bg={themeColors.bg.card}
/>
{/* 提示信息 */}
<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 TradingViewChart;