179 lines
6.7 KiB
TypeScript
179 lines
6.7 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useState, useEffect, useRef } from "react";
|
|||
|
|
import Image from "next/image";
|
|||
|
|
import { Button } from "@heroui/react";
|
|||
|
|
import { useApp } from "@/contexts/AppContext";
|
|||
|
|
import { useQuery } from "@tanstack/react-query";
|
|||
|
|
import { useChainId } from "wagmi";
|
|||
|
|
import * as echarts from "echarts";
|
|||
|
|
import { fetchLendingAPYHistory } from "@/lib/api/lending";
|
|||
|
|
|
|||
|
|
type Period = "1W" | "1M" | "1Y";
|
|||
|
|
|
|||
|
|
export default function SupplyContent() {
|
|||
|
|
const { t } = useApp();
|
|||
|
|
const chainId = useChainId();
|
|||
|
|
const [selectedPeriod, setSelectedPeriod] = useState<Period>("1W");
|
|||
|
|
const chartRef = useRef<HTMLDivElement>(null);
|
|||
|
|
const chartInstance = useRef<echarts.ECharts | null>(null);
|
|||
|
|
|
|||
|
|
const { data: apyData, isLoading } = useQuery({
|
|||
|
|
queryKey: ["lending-apy-history", selectedPeriod, chainId],
|
|||
|
|
queryFn: () => fetchLendingAPYHistory(selectedPeriod, chainId),
|
|||
|
|
staleTime: 5 * 60 * 1000,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const chartData = apyData?.history.map((p) => p.supply_apy) ?? [];
|
|||
|
|
const xLabels = apyData?.history.map((p) => {
|
|||
|
|
const d = new Date(p.time);
|
|||
|
|
if (selectedPeriod === "1W") return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, "0")}:00`;
|
|||
|
|
if (selectedPeriod === "1M") return `${d.getMonth() + 1}/${d.getDate()}`;
|
|||
|
|
return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
|
|||
|
|
}) ?? [];
|
|||
|
|
|
|||
|
|
const currentAPY = apyData?.current_supply_apy ?? 0;
|
|||
|
|
const apyChange = apyData?.apy_change ?? 0;
|
|||
|
|
|
|||
|
|
// 初始化 & 销毁(只在 mount/unmount 执行)
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!chartRef.current) return;
|
|||
|
|
chartInstance.current = echarts.init(chartRef.current);
|
|||
|
|
const observer = new ResizeObserver(() => chartInstance.current?.resize());
|
|||
|
|
observer.observe(chartRef.current);
|
|||
|
|
return () => {
|
|||
|
|
observer.disconnect();
|
|||
|
|
chartInstance.current?.dispose();
|
|||
|
|
chartInstance.current = null;
|
|||
|
|
};
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
// 更新图表数据(period/data 变化时)
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!chartInstance.current) return;
|
|||
|
|
|
|||
|
|
chartInstance.current.setOption({
|
|||
|
|
grid: { left: 0, right: 0, 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];
|
|||
|
|
return `<div style="padding: 4px 8px;">
|
|||
|
|
<span style="color: #9ca3af; font-size: 11px;">${d.axisValueLabel}</span><br/>
|
|||
|
|
<span style="color: #10b981; font-weight: 600; font-size: 14px;">${Number(d.value).toFixed(4)}%</span>
|
|||
|
|
</div>`;
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
xAxis: {
|
|||
|
|
type: "category",
|
|||
|
|
data: xLabels,
|
|||
|
|
axisLine: { show: false },
|
|||
|
|
axisTick: { show: false },
|
|||
|
|
axisLabel: {
|
|||
|
|
show: xLabels.length > 0 && xLabels.length <= 24,
|
|||
|
|
color: "#9ca3af",
|
|||
|
|
fontSize: 10,
|
|||
|
|
fontWeight: 500,
|
|||
|
|
interval: Math.max(0, Math.floor(xLabels.length / 7) - 1),
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
yAxis: {
|
|||
|
|
type: "value",
|
|||
|
|
axisLine: { show: false },
|
|||
|
|
axisTick: { show: false },
|
|||
|
|
axisLabel: { show: false },
|
|||
|
|
splitLine: { show: false },
|
|||
|
|
},
|
|||
|
|
series: [
|
|||
|
|
{
|
|||
|
|
data: chartData,
|
|||
|
|
type: "line",
|
|||
|
|
smooth: true,
|
|||
|
|
symbol: chartData.length <= 30 ? "circle" : "none",
|
|||
|
|
symbolSize: 4,
|
|||
|
|
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)" },
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
}, true);
|
|||
|
|
}, [chartData, xLabels]);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-4 md:p-8 flex flex-col gap-4 w-full shadow-md">
|
|||
|
|
{/* Header */}
|
|||
|
|
<div className="flex items-center gap-3 flex-shrink-0">
|
|||
|
|
<Image src="/assets/tokens/usd-coin-usdc-logo-10.svg" alt="USDC" width={32} height={32} />
|
|||
|
|
<h1 className="text-heading-h2 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.01em]">
|
|||
|
|
{t("supply.usdcLendPool")}
|
|||
|
|
</h1>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Historical APY Section */}
|
|||
|
|
<div className="flex items-start justify-between flex-shrink-0">
|
|||
|
|
{/* Left - APY Display */}
|
|||
|
|
<div className="flex flex-col gap-1">
|
|||
|
|
<span className="text-[12px] font-bold text-text-tertiary dark:text-gray-400 leading-[16px] tracking-[1.2px] uppercase">
|
|||
|
|
{t("supply.historicalApy")}
|
|||
|
|
</span>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<span className="text-heading-h2 font-bold text-[#10b981] dark:text-green-400 leading-[130%] tracking-[-0.01em]">
|
|||
|
|
{isLoading ? "--" : currentAPY > 0 ? `${currentAPY.toFixed(4)}%` : "--"}
|
|||
|
|
</span>
|
|||
|
|
{!isLoading && apyData && apyData.history.length > 1 && (
|
|||
|
|
<div className={`rounded-full px-2 py-0.5 flex items-center justify-center ${apyChange >= 0 ? "bg-[#e1f8ec] dark:bg-green-900/30" : "bg-red-50 dark:bg-red-900/30"}`}>
|
|||
|
|
<span className={`text-caption-tiny font-bold leading-[150%] tracking-[0.01em] ${apyChange >= 0 ? "text-[#10b981] dark:text-green-400" : "text-red-500 dark:text-red-400"}`}>
|
|||
|
|
{apyChange >= 0 ? "+" : ""}{apyChange.toFixed(4)}%
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Right - Period Selector */}
|
|||
|
|
<div className="bg-[#F9FAFB] dark:bg-gray-700 rounded-xl p-1 flex items-center gap-1">
|
|||
|
|
{(["1W", "1M", "1Y"] as Period[]).map((p) => (
|
|||
|
|
<Button
|
|||
|
|
key={p}
|
|||
|
|
size="sm"
|
|||
|
|
variant={selectedPeriod === p ? "solid" : "light"}
|
|||
|
|
onPress={() => setSelectedPeriod(p)}
|
|||
|
|
>
|
|||
|
|
{p}
|
|||
|
|
</Button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Chart Section — 始终渲染 div,避免 echarts 实例因 DOM 销毁而失效 */}
|
|||
|
|
<div className="w-full relative" style={{ height: "260px" }}>
|
|||
|
|
<div
|
|||
|
|
ref={chartRef}
|
|||
|
|
className="w-full h-full"
|
|||
|
|
style={{ background: "linear-gradient(0deg, rgba(16, 185, 129, 0.1) 0%, transparent 100%)" }}
|
|||
|
|
/>
|
|||
|
|
{/* Loading 遮罩 */}
|
|||
|
|
{isLoading && (
|
|||
|
|
<div className="absolute inset-0 flex items-center justify-center bg-white/60 dark:bg-gray-800/60">
|
|||
|
|
<span className="text-sm text-text-tertiary dark:text-gray-400">{t("common.loading")}</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|