217 lines
10 KiB
TypeScript
217 lines
10 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import Image from "next/image";
|
|||
|
|
import { useState, useEffect } from "react";
|
|||
|
|
import { useApp } from "@/contexts/AppContext";
|
|||
|
|
import { useReadContract } from "wagmi";
|
|||
|
|
import { useAccount } from "wagmi";
|
|||
|
|
import { formatUnits } from "viem";
|
|||
|
|
import { abis, getContractAddress } from "@/lib/contracts";
|
|||
|
|
import { useTokenBySymbol } from "@/hooks/useTokenBySymbol";
|
|||
|
|
import { useQuery } from "@tanstack/react-query";
|
|||
|
|
import { fetchLendingAPYHistory } from "@/lib/api/lending";
|
|||
|
|
import { useLTV } from "@/hooks/useCollateral";
|
|||
|
|
|
|||
|
|
export default function LendingStats() {
|
|||
|
|
const { t } = useApp();
|
|||
|
|
const { chainId } = useAccount();
|
|||
|
|
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined;
|
|||
|
|
const usdcToken = useTokenBySymbol('USDC');
|
|||
|
|
const usdcDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18;
|
|||
|
|
|
|||
|
|
// 链上总借款量
|
|||
|
|
const { data: totalBorrow } = useReadContract({
|
|||
|
|
address: lendingProxyAddress,
|
|||
|
|
abi: abis.lendingProxy,
|
|||
|
|
functionName: 'getTotalBorrow',
|
|||
|
|
query: { enabled: !!lendingProxyAddress },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 链上利用率(1e18 精度)
|
|||
|
|
const { data: utilization } = useReadContract({
|
|||
|
|
address: lendingProxyAddress,
|
|||
|
|
abi: abis.lendingProxy,
|
|||
|
|
functionName: 'getUtilization',
|
|||
|
|
query: { enabled: !!lendingProxyAddress },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 链上供应利率(年化 APR,1e18 精度)
|
|||
|
|
const { data: supplyRate } = useReadContract({
|
|||
|
|
address: lendingProxyAddress,
|
|||
|
|
abi: abis.lendingProxy,
|
|||
|
|
functionName: 'getSupplyRate',
|
|||
|
|
query: { enabled: !!lendingProxyAddress },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 格式化总借款
|
|||
|
|
const displayBorrowed = totalBorrow != null
|
|||
|
|
? `$${parseFloat(formatUnits(totalBorrow as bigint, usdcDecimals)).toLocaleString('en-US', { maximumFractionDigits: 2 })}`
|
|||
|
|
: '--';
|
|||
|
|
|
|||
|
|
// 格式化利用率
|
|||
|
|
const utilizationPct = utilization != null
|
|||
|
|
? (Number(formatUnits(utilization as bigint, 18)) * 100).toFixed(1)
|
|||
|
|
: null;
|
|||
|
|
|
|||
|
|
// 计算供应 APY(getSupplyRate 返回年化 APR,1e18 精度)
|
|||
|
|
const SECONDS_PER_YEAR = 365 * 24 * 60 * 60;
|
|||
|
|
const annualApr = supplyRate != null ? Number(formatUnits(supplyRate as bigint, 18)) : 0;
|
|||
|
|
const perSecondRate = annualApr / SECONDS_PER_YEAR;
|
|||
|
|
const supplyApy = perSecondRate > 0
|
|||
|
|
? (Math.pow(1 + perSecondRate, SECONDS_PER_YEAR) - 1) * 100
|
|||
|
|
: null;
|
|||
|
|
const displayApy = supplyApy != null ? `${supplyApy.toFixed(2)}%` : '--';
|
|||
|
|
|
|||
|
|
// APY 历史数据 → 迷你柱状图
|
|||
|
|
const { data: apyHistory } = useQuery({
|
|||
|
|
queryKey: ['lending-apy-history-mini', chainId],
|
|||
|
|
queryFn: () => fetchLendingAPYHistory('1W', chainId),
|
|||
|
|
staleTime: 5 * 60 * 1000,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 从历史数据中均匀采样 6 个点,归一化为柱高百分比
|
|||
|
|
const chartBars = (() => {
|
|||
|
|
const BAR_COLORS = ["#f2fcf7", "#e1f8ec", "#cef3e0", "#b8ecd2", "#00ad76", "#10b981"];
|
|||
|
|
const points = apyHistory?.history ?? [];
|
|||
|
|
if (points.length === 0) {
|
|||
|
|
return BAR_COLORS.map((color, i) => ({ height: [40, 55, 45, 65, 80, 95][i], color, apy: null as number | null, time: null as string | null }));
|
|||
|
|
}
|
|||
|
|
const step = Math.max(1, Math.floor(points.length / 6));
|
|||
|
|
const sampled = Array.from({ length: 6 }, (_, i) => {
|
|||
|
|
const idx = Math.min(i * step, points.length - 1);
|
|||
|
|
return points[idx];
|
|||
|
|
});
|
|||
|
|
const apyValues = sampled.map(p => p.supply_apy);
|
|||
|
|
const min = Math.min(...apyValues);
|
|||
|
|
const max = Math.max(...apyValues);
|
|||
|
|
const range = max - min || 1;
|
|||
|
|
return sampled.map((p, i) => ({
|
|||
|
|
height: Math.round(20 + ((p.supply_apy - min) / range) * 75),
|
|||
|
|
color: BAR_COLORS[i],
|
|||
|
|
apy: p.supply_apy,
|
|||
|
|
time: p.time,
|
|||
|
|
}));
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
const [hoveredBar, setHoveredBar] = useState<number | null>(null);
|
|||
|
|
|
|||
|
|
// 用户当前 LTV
|
|||
|
|
const { ltv, ltvRaw } = useLTV();
|
|||
|
|
const [mounted, setMounted] = useState(false);
|
|||
|
|
useEffect(() => { setMounted(true); }, []);
|
|||
|
|
|
|||
|
|
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">
|
|||
|
|
{/* 移动端:2列网格 + 柱状图 */}
|
|||
|
|
<div className="md:hidden flex flex-col gap-3">
|
|||
|
|
<div className="grid grid-cols-2 gap-3">
|
|||
|
|
{/* USDC Borrowed */}
|
|||
|
|
<div className="flex flex-col gap-1">
|
|||
|
|
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400">{t("lending.usdcBorrowed")}</span>
|
|||
|
|
<span className="text-xl font-bold text-text-primary dark:text-white">{displayBorrowed}</span>
|
|||
|
|
<div className="flex items-center gap-1">
|
|||
|
|
<Image src="/components/buy/icon2.svg" alt="" width={16} height={16} />
|
|||
|
|
<span className="text-[11px] font-medium text-[#10b981]">
|
|||
|
|
{utilizationPct != null ? `${utilizationPct}% ${t("lending.utilization")}` : t("lending.vsLastMonth")}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{/* Avg. APY */}
|
|||
|
|
<div className="flex flex-col gap-1">
|
|||
|
|
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400">{t("lending.avgApy")}</span>
|
|||
|
|
<span className="text-xl font-bold text-[#10b981]">{displayApy}</span>
|
|||
|
|
<span className="text-[11px] text-text-tertiary dark:text-gray-400">{t("lending.stableYield")}</span>
|
|||
|
|
</div>
|
|||
|
|
{/* LTV */}
|
|||
|
|
<div className="flex flex-col gap-1">
|
|||
|
|
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400">{t("lending.ltv")}</span>
|
|||
|
|
<span className={`text-xl font-bold ${
|
|||
|
|
!mounted ? 'text-text-primary dark:text-white' :
|
|||
|
|
ltvRaw === 0 ? 'text-text-tertiary dark:text-gray-400' :
|
|||
|
|
ltvRaw < 50 ? 'text-[#10b981]' :
|
|||
|
|
ltvRaw < 70 ? 'text-[#ff6900]' : 'text-[#ef4444]'
|
|||
|
|
}`}>{!mounted ? '--' : `${ltv}%`}</span>
|
|||
|
|
<span className="text-[11px] text-text-tertiary dark:text-gray-400">Loan-to-Value</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 移动端柱状图 */}
|
|||
|
|
<div className="flex items-end gap-1 h-16 relative">
|
|||
|
|
{chartBars.map((bar, index) => (
|
|||
|
|
<div
|
|||
|
|
key={index}
|
|||
|
|
className="flex-1 flex flex-col items-center justify-end gap-0.5"
|
|||
|
|
style={{ height: '100%' }}
|
|||
|
|
>
|
|||
|
|
{bar.apy !== null && (
|
|||
|
|
<span className="text-[9px] font-medium text-[#10b981] leading-none whitespace-nowrap">
|
|||
|
|
{bar.apy.toFixed(2)}%
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
<div
|
|||
|
|
className="w-full rounded-t-md"
|
|||
|
|
style={{ backgroundColor: bar.color, height: `${bar.height}%` }}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 桌面端:横排 + 图表 */}
|
|||
|
|
<div className="hidden md:flex items-center gap-6 h-[116px]">
|
|||
|
|
<div className="flex items-center gap-12 flex-1">
|
|||
|
|
<div className="flex flex-col gap-1">
|
|||
|
|
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">{t("lending.usdcBorrowed")}</span>
|
|||
|
|
<span className="text-heading-h2 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.01em]">{displayBorrowed}</span>
|
|||
|
|
<div className="flex items-center gap-1">
|
|||
|
|
<Image src="/components/buy/icon2.svg" alt="" width={20} height={20} />
|
|||
|
|
<span className="text-[12px] font-medium text-[#10b981] dark:text-green-400 leading-[16px]">
|
|||
|
|
{utilizationPct != null ? `${utilizationPct}% ${t("lending.utilization")}` : t("lending.vsLastMonth")}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="w-px h-12 bg-border-gray dark:bg-gray-600" />
|
|||
|
|
<div className="flex flex-col gap-1">
|
|||
|
|
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">{t("lending.avgApy")}</span>
|
|||
|
|
<span className="text-heading-h2 font-bold text-[#10b981] dark:text-green-400 leading-[130%] tracking-[-0.01em]">{displayApy}</span>
|
|||
|
|
<span className="text-[12px] font-regular text-text-tertiary dark:text-gray-400 leading-[16px]">{t("lending.stableYield")}</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="w-px h-12 bg-border-gray dark:bg-gray-600" />
|
|||
|
|
<div className="flex flex-col gap-1">
|
|||
|
|
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">{t("lending.ltv")}</span>
|
|||
|
|
<span className={`text-heading-h2 font-bold leading-[130%] tracking-[-0.01em] ${
|
|||
|
|
!mounted ? 'text-text-primary dark:text-white' :
|
|||
|
|
ltvRaw === 0 ? 'text-text-tertiary dark:text-gray-400' :
|
|||
|
|
ltvRaw < 50 ? 'text-[#10b981] dark:text-green-400' :
|
|||
|
|
ltvRaw < 70 ? 'text-[#ff6900] dark:text-orange-400' :
|
|||
|
|
'text-[#ef4444] dark:text-red-400'
|
|||
|
|
}`}>{!mounted ? '--' : `${ltv}%`}</span>
|
|||
|
|
<span className="text-[12px] font-regular text-text-tertiary dark:text-gray-400 leading-[16px]">Loan-to-Value</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{/* Mini Chart */}
|
|||
|
|
<div className="flex items-end gap-1 w-48 h-16 relative">
|
|||
|
|
{chartBars.map((bar, index) => (
|
|||
|
|
<div
|
|||
|
|
key={index}
|
|||
|
|
className="flex-1 relative group"
|
|||
|
|
style={{ height: '100%', display: 'flex', alignItems: 'flex-end' }}
|
|||
|
|
onMouseEnter={() => setHoveredBar(index)}
|
|||
|
|
onMouseLeave={() => setHoveredBar(null)}
|
|||
|
|
>
|
|||
|
|
{hoveredBar === index && bar.apy !== null && (
|
|||
|
|
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 z-10 whitespace-nowrap bg-gray-900 dark:bg-gray-700 text-white rounded-lg px-2.5 py-1.5 shadow-lg pointer-events-none flex flex-col items-center gap-0.5">
|
|||
|
|
<span className="text-[11px] font-bold text-[#10b981]">{bar.apy.toFixed(4)}%</span>
|
|||
|
|
{bar.time && <span className="text-[10px] text-gray-400">{new Date(bar.time).toLocaleDateString('en-GB', { month: 'short', day: 'numeric' })}</span>}
|
|||
|
|
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700" />
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
<div className="w-full rounded-t-md transition-all duration-150" style={{ backgroundColor: hoveredBar === index ? '#10b981' : bar.color, height: `${bar.height}%` }} />
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|