Files
assetx/webapp/components/lending/LendingStats.tsx
default 2ee4553b71 init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、
antdesign(管理后台)、landingpage(营销落地页)、
数据库 SQL 和配置文件。
2026-03-27 11:26:43 +00:00

217 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 },
});
// 链上供应利率(年化 APR1e18 精度)
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;
// 计算供应 APYgetSupplyRate 返回年化 APR1e18 精度)
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>
);
}