234 lines
8.1 KiB
TypeScript
234 lines
8.1 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useState, useEffect, useRef } from "react";
|
|||
|
|
import { useQuery } from "@tanstack/react-query";
|
|||
|
|
import { useApp } from "@/contexts/AppContext";
|
|||
|
|
import { fetchProductHistory, HistoryPoint } from "@/lib/api/fundmarket";
|
|||
|
|
import * as echarts from "echarts";
|
|||
|
|
|
|||
|
|
interface APYHistoryCardProps {
|
|||
|
|
productId: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default function APYHistoryCard({ productId }: APYHistoryCardProps) {
|
|||
|
|
const { t } = useApp();
|
|||
|
|
const [activeTab, setActiveTab] = useState<"apy" | "price">("apy");
|
|||
|
|
const chartRef = useRef<HTMLDivElement>(null);
|
|||
|
|
const chartInstance = useRef<echarts.ECharts | null>(null);
|
|||
|
|
|
|||
|
|
const { data: history = [] } = useQuery<HistoryPoint[]>({
|
|||
|
|
queryKey: ["product-history", productId],
|
|||
|
|
queryFn: () => fetchProductHistory(productId),
|
|||
|
|
staleTime: 60 * 60 * 1000, // 1h
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const colors = [
|
|||
|
|
"#FBEADE", "#F5D4BE", "#EFBF9E", "#E9AA7E", "#E3955E",
|
|||
|
|
"#DD804E", "#D76B3E", "#D1562E", "#C65122",
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const isEmpty = history.length < 2;
|
|||
|
|
|
|||
|
|
const labels = isEmpty ? [] : history.map((p) => {
|
|||
|
|
const d = new Date(p.time);
|
|||
|
|
return `${d.getMonth() + 1}/${d.getDate()}`;
|
|||
|
|
});
|
|||
|
|
const apyData = isEmpty ? [] : history.map((p) => p.apy);
|
|||
|
|
const priceData = isEmpty ? [] : history.map((p) => p.price);
|
|||
|
|
|
|||
|
|
const activeData = activeTab === "apy" ? apyData : priceData;
|
|||
|
|
const highest = activeData.length > 0 ? Math.max(...activeData) : 0;
|
|||
|
|
const lowest = activeData.length > 0 ? Math.min(...activeData) : 0;
|
|||
|
|
|
|||
|
|
const updateChart = () => {
|
|||
|
|
if (!chartInstance.current) return;
|
|||
|
|
|
|||
|
|
// price tab 自适应 Y 轴,上下各留 20% padding;APY bar 图保持从 0 开始
|
|||
|
|
let yAxisMin: number | undefined;
|
|||
|
|
let yAxisMax: number | undefined;
|
|||
|
|
if (activeTab === "price" && priceData.length > 0) {
|
|||
|
|
const yMin = Math.min(...priceData);
|
|||
|
|
const yMax = Math.max(...priceData);
|
|||
|
|
const range = yMax - yMin || yMax * 0.01 || 0.01;
|
|||
|
|
yAxisMin = yMin - range * 0.2;
|
|||
|
|
yAxisMax = yMax + range * 0.2;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const option: echarts.EChartsOption = {
|
|||
|
|
grid: { left: 2, right: 2, top: 10, bottom: 0 },
|
|||
|
|
tooltip: {
|
|||
|
|
trigger: "axis",
|
|||
|
|
show: true,
|
|||
|
|
confine: true,
|
|||
|
|
backgroundColor: "rgba(17, 24, 39, 0.9)",
|
|||
|
|
borderColor: "#374151",
|
|||
|
|
textStyle: { color: "#f9fafb", fontSize: 12, fontWeight: 500 },
|
|||
|
|
formatter: (params: any) => {
|
|||
|
|
const d = params[0];
|
|||
|
|
const suffix = activeTab === "apy" ? "%" : " USDC";
|
|||
|
|
return `<div style="padding:4px 8px">
|
|||
|
|
<span style="color:#9ca3af;font-size:11px">${labels[d.dataIndex]}</span><br/>
|
|||
|
|
<span style="color:#10b981;font-weight:600;font-size:14px">${Number(d.value).toFixed(activeTab === "apy" ? 2 : 4)}${suffix}</span>
|
|||
|
|
</div>`;
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
xAxis: {
|
|||
|
|
type: "category",
|
|||
|
|
data: labels,
|
|||
|
|
axisLine: { show: false },
|
|||
|
|
axisTick: { show: false },
|
|||
|
|
axisLabel: {
|
|||
|
|
show: labels.length > 0,
|
|||
|
|
color: "#9ca3af",
|
|||
|
|
fontSize: 10,
|
|||
|
|
fontWeight: 500,
|
|||
|
|
interval: Math.max(0, Math.floor(labels.length / 7) - 1),
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
yAxis: {
|
|||
|
|
type: "value",
|
|||
|
|
min: yAxisMin,
|
|||
|
|
max: yAxisMax,
|
|||
|
|
axisLine: { show: false },
|
|||
|
|
axisTick: { show: false },
|
|||
|
|
axisLabel: { show: false },
|
|||
|
|
splitLine: { show: false },
|
|||
|
|
},
|
|||
|
|
series: [
|
|||
|
|
activeTab === "apy"
|
|||
|
|
? {
|
|||
|
|
data: apyData.map((value, index) => ({
|
|||
|
|
value,
|
|||
|
|
itemStyle: {
|
|||
|
|
color: colors[index % colors.length],
|
|||
|
|
borderRadius: [2, 2, 0, 0],
|
|||
|
|
},
|
|||
|
|
})),
|
|||
|
|
type: "bar",
|
|||
|
|
barWidth: "60%",
|
|||
|
|
barMaxWidth: 24,
|
|||
|
|
barMinHeight: 2,
|
|||
|
|
}
|
|||
|
|
: {
|
|||
|
|
data: priceData,
|
|||
|
|
type: "line",
|
|||
|
|
smooth: true,
|
|||
|
|
symbol: "circle",
|
|||
|
|
symbolSize: 6,
|
|||
|
|
lineStyle: { color: "#10b981", width: 2 },
|
|||
|
|
itemStyle: { color: "#10b981" },
|
|||
|
|
areaStyle: {
|
|||
|
|
color: {
|
|||
|
|
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
|
|||
|
|
colorStops: [
|
|||
|
|
{ offset: 0, color: "rgba(16,185,129,0.3)" },
|
|||
|
|
{ offset: 1, color: "rgba(16,185,129,0)" },
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
chartInstance.current.setOption(option, true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
// Use requestAnimationFrame to ensure the container has been laid out
|
|||
|
|
// before ECharts tries to measure its dimensions
|
|||
|
|
const frame = requestAnimationFrame(() => {
|
|||
|
|
if (!chartRef.current) return;
|
|||
|
|
if (!chartInstance.current) {
|
|||
|
|
chartInstance.current = echarts.init(chartRef.current);
|
|||
|
|
}
|
|||
|
|
updateChart();
|
|||
|
|
chartInstance.current?.resize();
|
|||
|
|
});
|
|||
|
|
const handleResize = () => chartInstance.current?.resize();
|
|||
|
|
window.addEventListener("resize", handleResize);
|
|||
|
|
return () => {
|
|||
|
|
cancelAnimationFrame(frame);
|
|||
|
|
window.removeEventListener("resize", handleResize);
|
|||
|
|
};
|
|||
|
|
}, [activeTab, history]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
return () => { chartInstance.current?.dispose(); };
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-6 flex flex-col gap-6">
|
|||
|
|
{/* Tabs */}
|
|||
|
|
<div className="flex gap-6 border-b border-border-gray dark:border-gray-700">
|
|||
|
|
<button
|
|||
|
|
onClick={() => setActiveTab("apy")}
|
|||
|
|
className={`pb-3 px-1 text-body-small font-bold transition-colors ${
|
|||
|
|
activeTab === "apy"
|
|||
|
|
? "text-text-primary dark:text-white border-b-2 border-text-primary dark:border-white -mb-[1px]"
|
|||
|
|
: "text-text-tertiary dark:text-gray-400"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
{t("apy.apyHistory")}
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => setActiveTab("price")}
|
|||
|
|
className={`pb-3 px-1 text-body-small font-bold transition-colors ${
|
|||
|
|
activeTab === "price"
|
|||
|
|
? "text-text-primary dark:text-white border-b-2 border-text-primary dark:border-white -mb-[1px]"
|
|||
|
|
: "text-text-tertiary dark:text-gray-400"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
{t("apy.priceHistory")}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Chart Area */}
|
|||
|
|
<div className="flex flex-col gap-4">
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">
|
|||
|
|
{isEmpty ? t("apy.lastDays") : `${t("apy.lastDays")} (${history.length} snapshots)`}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* ECharts Chart */}
|
|||
|
|
{isEmpty ? (
|
|||
|
|
<div className="w-full h-[140px] flex items-center justify-center text-caption-tiny text-text-tertiary dark:text-gray-500">
|
|||
|
|
{t("common.noData")}
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div
|
|||
|
|
ref={chartRef}
|
|||
|
|
className="w-full"
|
|||
|
|
style={{
|
|||
|
|
height: "140px",
|
|||
|
|
background: activeTab === "price"
|
|||
|
|
? "linear-gradient(0deg, rgba(16, 185, 129, 0.1) 0%, transparent 100%)"
|
|||
|
|
: undefined,
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Stats */}
|
|||
|
|
<div className="flex flex-col gap-2">
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">
|
|||
|
|
{t("apy.highest")}
|
|||
|
|
</span>
|
|||
|
|
<span className="text-body-small font-bold text-text-primary dark:text-white">
|
|||
|
|
{isEmpty ? '--' : activeTab === "apy" ? `${highest.toFixed(2)}%` : `$${highest.toFixed(4)}`}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">
|
|||
|
|
{t("apy.lowest")}
|
|||
|
|
</span>
|
|||
|
|
<span className="text-body-small font-bold text-text-primary dark:text-white">
|
|||
|
|
{isEmpty ? '--' : activeTab === "apy" ? `${lowest.toFixed(2)}%` : `$${lowest.toFixed(4)}`}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|