init: 初始化 AssetX 项目仓库

包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、
antdesign(管理后台)、landingpage(营销落地页)、
数据库 SQL 和配置文件。
This commit is contained in:
2026-03-27 11:26:43 +00:00
commit 2ee4553b71
634 changed files with 988255 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
"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>
);
}

View File

@@ -0,0 +1,331 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { buttonStyles } from "@/lib/buttonStyles";
import { useUSDCBalance } from '@/hooks/useBalance';
import { useLendingSupply, useSuppliedBalance } from '@/hooks/useLendingSupply';
import { getTxUrl } from '@/lib/contracts';
import { useTokenDecimalsFromAddress } from '@/hooks/useTokenDecimals';
import { useHealthFactor, useSupplyAPY } from '@/hooks/useHealthFactor';
import { toast } from "sonner";
import { useWalletStatus } from '@/hooks/useWalletStatus';
import TokenSelector from '@/components/common/TokenSelector';
import { useAppKit } from "@reown/appkit/react";
import { Token } from '@/lib/api/tokens';
type StablecoinType = 'USDC' | 'USDT';
export default function SupplyPanel() {
const { t } = useApp();
const [amount, setAmount] = useState("");
// 稳定币选择(用于余额显示)
const [stablecoin, setStablecoin] = useState<StablecoinType>('USDC');
const [stablecoinObj, setStablecoinObj] = useState<Token | undefined>();
// 使用统一的钱包状态 Hook
const { address, isConnected, chainId, mounted } = useWalletStatus();
const { open } = useAppKit();
// 处理稳定币选择
const handleStablecoinSelect = useCallback((token: Token) => {
setStablecoinObj(token);
setStablecoin(token.symbol as StablecoinType);
setAmount(""); // 清空输入金额
}, []);
// 优先从产品合约地址读取精度,失败时回退到 token.decimals
const usdcInputDecimals = useTokenDecimalsFromAddress(
stablecoinObj?.contractAddress,
stablecoinObj?.decimals ?? 18
);
const usdcDisplayDecimals = Math.min(usdcInputDecimals, 6);
const truncateDecimals = (s: string, d: number) => { const p = s.split('.'); return p.length > 1 ? `${p[0]}.${p[1].slice(0, d)}` : s; };
const isValidAmount = (v: string) => v !== '' && !isNaN(parseFloat(v)) && parseFloat(v) > 0;
const handleAmountChange = (value: string) => {
if (value === '') { setAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > usdcDisplayDecimals) return;
if (parseFloat(value) > parseFloat(usdcBalance)) { setAmount(truncateDecimals(usdcBalance, usdcDisplayDecimals)); return; }
setAmount(value);
};
const { formattedBalance: usdcBalance, isLoading: isBalanceLoading, refetch: refetchBalance } = useUSDCBalance();
const { formattedBalance: suppliedBalance, refetch: refetchSupplied } = useSuppliedBalance();
// 根据选择的 token 类型决定使用哪个 hook
// 目前只支持 USDC supplyYT token 选择仅用于显示
const {
status: supplyStatus,
error: supplyError,
isLoading: isSupplyLoading,
approveHash,
supplyHash,
executeApproveAndSupply,
reset: resetSupply,
} = useLendingSupply();
// 健康因子和 APY
const {
formattedHealthFactor,
status: healthStatus,
utilization,
isLoading: isHealthLoading,
refetch: refetchHealthFactor,
} = useHealthFactor();
const { apy } = useSupplyAPY();
const SUPPLY_TOAST_ID = 'lending-supply-tx';
// Approve 交易提交
useEffect(() => {
if (approveHash && supplyStatus === 'approving') {
toast.loading(t("supply.toast.approvalSubmitted"), {
id: SUPPLY_TOAST_ID,
description: t("supply.toast.waitingConfirmation"),
action: {
label: t("supply.toast.viewTx"),
onClick: () => window.open(getTxUrl(approveHash), '_blank'),
},
});
}
}, [approveHash, supplyStatus]);
// Supply 交易提交
useEffect(() => {
if (supplyHash && supplyStatus === 'supplying') {
toast.loading(t("supply.toast.supplySubmitted"), {
id: SUPPLY_TOAST_ID,
description: t("supply.toast.supplyingNow"),
action: {
label: t("supply.toast.viewTx"),
onClick: () => window.open(getTxUrl(supplyHash), '_blank'),
},
});
}
}, [supplyHash, supplyStatus]);
// 成功后刷新余额
useEffect(() => {
if (supplyStatus === 'success') {
toast.success(t("supply.toast.suppliedSuccess"), {
id: SUPPLY_TOAST_ID,
description: t("supply.toast.suppliedSuccessDesc"),
duration: 5000,
});
refetchBalance();
refetchSupplied();
refetchHealthFactor();
const timer = setTimeout(() => { resetSupply(); setAmount(''); }, 3000);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [supplyStatus]);
// 错误提示
useEffect(() => {
if (supplyError) {
if (supplyError === 'Transaction cancelled') {
toast.dismiss(SUPPLY_TOAST_ID);
} else {
toast.error(t("supply.toast.supplyFailed"), {
id: SUPPLY_TOAST_ID,
duration: 5000,
});
}
}
}, [supplyError]);
return (
<div className="p-6 flex flex-col gap-6 flex-1">
{/* Token Balance & Supplied */}
<div className="flex flex-col gap-4">
{/* Token Balance */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-3 flex flex-col gap-2">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.tokenBalance")}
</span>
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col gap-1 flex-1">
<span className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
{!mounted ? '0' : isBalanceLoading ? '...' : parseFloat(truncateDecimals(usdcBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
${!mounted ? '0' : isBalanceLoading ? '...' : parseFloat(truncateDecimals(usdcBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
</div>
{/* 稳定币选择器 */}
<div className="flex-shrink-0">
<TokenSelector
selectedToken={stablecoinObj}
onSelect={handleStablecoinSelect}
filterTypes={['stablecoin']}
defaultSymbol={stablecoin}
/>
</div>
</div>
</div>
{/* Supplied - 同步显示选中的稳定币 */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-3 flex flex-col gap-1">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.supplied")}
</span>
<div className="flex items-center justify-between">
<span className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
{!mounted ? '0' : parseFloat(suppliedBalance).toLocaleString('en-US', { maximumFractionDigits: 6 })}
</span>
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{stablecoin}
</span>
</div>
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
${!mounted ? '0' : parseFloat(suppliedBalance).toLocaleString('en-US', { maximumFractionDigits: 6 })}
</span>
</div>
</div>
{/* Deposit Section */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-4">
{/* Deposit Label and Available */}
<div className="flex items-center gap-2">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("supply.deposit")}
</span>
<div className="flex items-center gap-2 ml-auto">
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} />
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{!mounted ? `0 ${stablecoin}` : isBalanceLoading ? '...' : `${parseFloat(truncateDecimals(usdcBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })} ${stablecoin}`}
</span>
</div>
<Button
size="sm"
color="default"
variant="solid"
className={buttonStyles({ intent: "max" })}
onPress={() => setAmount(truncateDecimals(usdcBalance, usdcDisplayDecimals))}
>
{t("supply.max")}
</Button>
</div>
{/* Input Row */}
<div className="flex items-center justify-between gap-4 h-12">
{/* Amount Input */}
<div className="flex flex-col items-end flex-1">
<input
type="text" inputMode="decimal"
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
placeholder="0.00"
className="text-heading-h3 font-bold text-text-input-box dark:text-gray-500 leading-[130%] tracking-[-0.005em] text-right bg-transparent outline-none w-full"
/>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
--
</span>
</div>
</div>
</div>
{/* Health Factor */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.healthFactor")}: {isHealthLoading ? '...' : formattedHealthFactor}
</span>
<span className="text-body-small font-bold text-text-primary dark:text-white leading-[150%]">
{utilization.toFixed(1)}% {t("supply.utilization")}
</span>
</div>
<span className={`text-caption-tiny font-bold leading-[150%] tracking-[0.01em] ${
healthStatus === 'safe' ? 'text-[#10b981] dark:text-green-400' :
healthStatus === 'warning' ? 'text-[#ffb933] dark:text-yellow-400' :
healthStatus === 'danger' ? 'text-[#ff6900] dark:text-orange-400' :
'text-[#ef4444] dark:text-red-400'
}`}>
{healthStatus === 'safe' ? t("supply.safe") :
healthStatus === 'warning' ? t("supply.warning") :
healthStatus === 'danger' ? t("supply.danger") :
t("supply.critical")}
</span>
</div>
{/* Rainbow Gradient Progress Bar */}
<div className="relative w-full h-2.5 rounded-full overflow-hidden">
{/* Background Rainbow Gradient */}
<div
className="absolute inset-0 opacity-30"
style={{
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 20%, #8b5cf6 40%, #ec4899 60%, #f59e0b 80%, #ef4444 100%)',
}}
/>
{/* Active Progress with Rainbow Gradient */}
<div
className="absolute left-0 top-0 h-full rounded-full transition-all duration-500 ease-out"
style={{
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 20%, #8b5cf6 40%, #ec4899 60%, #f59e0b 80%, #ef4444 100%)',
width: `${Math.min(utilization, 100)}%`,
boxShadow: utilization > 0 ? '0 0 8px rgba(59, 130, 246, 0.5)' : 'none',
}}
/>
</div>
</div>
{/* Estimated Returns */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-2xl p-4 flex flex-col gap-2">
<span className="text-body-small font-medium text-text-secondary dark:text-gray-300 leading-[150%]">
{t("supply.estimatedReturns")}
</span>
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("supply.estApy")}
</span>
<span className="text-body-small font-bold text-[#ff6900] dark:text-orange-400 leading-[150%]">
{apy > 0 ? `${apy.toFixed(2)}%` : '--'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("supply.estReturnsYear")}
</span>
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400 leading-[150%]">
{amount && apy > 0
? `~ $${(parseFloat(amount) * apy / 100).toFixed(2)}`
: '~ $0.00'}
</span>
</div>
</div>
{/* Supply Button */}
<Button
isDisabled={!mounted || (isConnected && (!isValidAmount(amount) || isSupplyLoading))}
color="default"
variant="solid"
className={`${buttonStyles({ intent: "theme" })} mt-auto`}
endContent={<Image src="/icons/actions/arrow-right-icon.svg" alt="" width={20} height={20} />}
onPress={() => {
if (!isConnected) { open(); return; }
if (amount && parseFloat(amount) > 0) {
resetSupply();
executeApproveAndSupply(amount);
}
}}
>
{!mounted && t("common.loading")}
{mounted && !isConnected && t("common.connectWallet")}
{mounted && isConnected && supplyStatus === 'idle' && !!amount && !isValidAmount(amount) && t("common.invalidAmount")}
{mounted && isConnected && supplyStatus === 'idle' && (!amount || isValidAmount(amount)) && t("supply.supply")}
{mounted && supplyStatus === 'approving' && t("common.approving")}
{mounted && supplyStatus === 'approved' && t("supply.approvedSupplying")}
{mounted && supplyStatus === 'supplying' && t("supply.supplying")}
{mounted && supplyStatus === 'success' && t("common.success")}
{mounted && supplyStatus === 'error' && t("common.failed")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,311 @@
"use client";
import { useState, useEffect, useRef } from "react";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { buttonStyles } from "@/lib/buttonStyles";
import { useUSDCBalance } from '@/hooks/useBalance';
import { useLendingWithdraw } from '@/hooks/useLendingWithdraw';
import { useSuppliedBalance } from '@/hooks/useLendingSupply';
import { getTxUrl } from '@/lib/contracts';
import { useTokenDecimalsFromAddress } from '@/hooks/useTokenDecimals';
import { useTokenBySymbol } from '@/hooks/useTokenBySymbol';
import { useHealthFactor, useSupplyAPY, useBorrowBalance } from '@/hooks/useHealthFactor';
import { toast } from 'sonner';
import { useWalletStatus } from '@/hooks/useWalletStatus';
export default function WithdrawPanel() {
const { t } = useApp();
const [amount, setAmount] = useState("");
// 使用统一的钱包状态 Hook
const { isConnected, mounted } = useWalletStatus();
const { formattedBalance: usdcBalance, isLoading: isBalanceLoading, refetch: refetchBalance } = useUSDCBalance();
const { formattedBalance: suppliedBalance, refetch: refetchSupplied } = useSuppliedBalance();
const {
status: withdrawStatus,
error: withdrawError,
isLoading: isWithdrawLoading,
withdrawHash,
executeWithdraw,
reset: resetWithdraw,
} = useLendingWithdraw();
// 健康因子和 APY
const {
formattedHealthFactor,
status: healthStatus,
utilization,
isLoading: isHealthLoading
} = useHealthFactor();
const { apy } = useSupplyAPY();
const { formattedBalance: borrowBalance } = useBorrowBalance();
const hasShownBorrowWarning = useRef(false);
// 从产品 API 获取 USDC token 信息,优先从合约地址读取精度
const usdcToken = useTokenBySymbol('USDC');
const usdcInputDecimals = useTokenDecimalsFromAddress(usdcToken?.contractAddress, usdcToken?.decimals ?? 18);
const usdcDisplayDecimals = Math.min(usdcInputDecimals, 6);
const truncateDecimals = (s: string, d: number) => { const p = s.split('.'); return p.length > 1 ? `${p[0]}.${p[1].slice(0, d)}` : s; };
const isValidAmount = (v: string) => v !== '' && !isNaN(parseFloat(v)) && parseFloat(v) > 0;
const handleAmountChange = (value: string) => {
if (value === '') { setAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > usdcDisplayDecimals) return;
if (parseFloat(value) > parseFloat(suppliedBalance)) { setAmount(truncateDecimals(suppliedBalance, usdcDisplayDecimals)); return; }
setAmount(value);
};
// 有借款时,挂载后弹出一次提示
useEffect(() => {
if (!mounted || hasShownBorrowWarning.current) return;
if (parseFloat(borrowBalance) > 0) {
toast.warning(t("supply.toast.borrowWarning"), {
description: t("supply.toast.borrowWarningDesc"),
duration: 5000,
});
hasShownBorrowWarning.current = true;
}
}, [mounted, borrowBalance]);
const WITHDRAW_TOAST_ID = 'lending-withdraw-tx';
// Withdraw 交易提交
useEffect(() => {
if (withdrawHash && withdrawStatus === 'withdrawing') {
toast.loading(t("supply.toast.withdrawSubmitted"), {
id: WITHDRAW_TOAST_ID,
description: t("supply.toast.processingWithdrawal"),
action: {
label: t("supply.toast.viewTx"),
onClick: () => window.open(getTxUrl(withdrawHash), '_blank'),
},
});
}
}, [withdrawHash, withdrawStatus]);
// 成功后刷新余额
useEffect(() => {
if (withdrawStatus === 'success') {
toast.success(t("supply.toast.withdrawnSuccess"), {
id: WITHDRAW_TOAST_ID,
description: t("supply.toast.withdrawnSuccessDesc"),
duration: 5000,
});
refetchBalance();
refetchSupplied();
const timer = setTimeout(() => { resetWithdraw(); setAmount(''); }, 3000);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [withdrawStatus]);
// 错误提示
useEffect(() => {
if (withdrawError) {
if (withdrawError === 'Transaction cancelled') {
toast.dismiss(WITHDRAW_TOAST_ID);
} else {
toast.error(t("supply.toast.withdrawFailed"), {
id: WITHDRAW_TOAST_ID,
duration: 5000,
});
}
}
}, [withdrawError]);
return (
<div className="p-6 flex flex-col gap-6 flex-1">
{/* Token Balance & Supplied */}
<div className="flex flex-col gap-4">
{/* Token Balance */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-3 flex flex-col gap-1">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.tokenBalance")}
</span>
<div className="flex items-center justify-between">
<span className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
{!mounted ? '0' : isBalanceLoading ? '...' : parseFloat(truncateDecimals(usdcBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
USDC
</span>
</div>
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
${!mounted ? '0' : isBalanceLoading ? '...' : parseFloat(truncateDecimals(usdcBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
</div>
{/* Supplied */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-3 flex flex-col gap-1">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.supplied")}
</span>
<div className="flex items-center justify-between">
<span className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
{!mounted ? '0' : parseFloat(truncateDecimals(suppliedBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
USDC
</span>
</div>
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
${!mounted ? '0' : parseFloat(truncateDecimals(suppliedBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
</div>
</div>
{/* Withdraw Section */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-4">
{/* Withdraw Label and Available */}
<div className="flex items-center gap-2">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("supply.withdraw")}
</span>
<div className="flex items-center gap-2 ml-auto">
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} />
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{!mounted ? '0 USDC' : `${parseFloat(truncateDecimals(suppliedBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })} USDC`}
</span>
</div>
<Button
size="sm"
className={buttonStyles({ intent: "max" })}
onPress={() => setAmount(truncateDecimals(suppliedBalance, usdcDisplayDecimals))}
>
{t("supply.max")}
</Button>
</div>
{/* Input Row */}
<div className="flex items-center justify-between h-12">
{/* USDC Token Button */}
<div className="bg-bg-surface dark:bg-gray-800 rounded-full border border-border-normal dark:border-gray-600 p-2 flex items-center gap-2 h-12">
<Image
src="/assets/tokens/usd-coin-usdc-logo-10.svg"
alt="USDC"
width={32}
height={32}
/>
<span className="text-body-default font-bold text-text-primary dark:text-white leading-[150%]">
USDC
</span>
</div>
{/* Amount Input */}
<div className="flex flex-col items-end">
<input
type="text" inputMode="decimal"
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
placeholder="0"
className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em] text-right bg-transparent outline-none w-24"
/>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{amount ? `$${amount}` : "--"}
</span>
</div>
</div>
</div>
{/* Health Factor */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.healthFactor")}: {isHealthLoading ? '...' : formattedHealthFactor}
</span>
<span className="text-body-small font-bold text-text-primary dark:text-white leading-[150%]">
{utilization.toFixed(1)}% {t("supply.utilization")}
</span>
</div>
<span className={`text-caption-tiny font-bold leading-[150%] tracking-[0.01em] ${
healthStatus === 'safe' ? 'text-[#10b981] dark:text-green-400' :
healthStatus === 'warning' ? 'text-[#ffb933] dark:text-yellow-400' :
healthStatus === 'danger' ? 'text-[#ff6900] dark:text-orange-400' :
'text-[#ef4444] dark:text-red-400'
}`}>
{healthStatus === 'safe' ? t("supply.safe") :
healthStatus === 'warning' ? t("supply.warning") :
healthStatus === 'danger' ? t("supply.danger") :
t("supply.critical")}
</span>
</div>
{/* Progress Bar */}
<div className="relative w-full h-2.5 rounded-full overflow-hidden">
<div
className="absolute inset-0 opacity-30"
style={{
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 20%, #8b5cf6 40%, #ec4899 60%, #f59e0b 80%, #ef4444 100%)',
}}
/>
<div
className="absolute left-0 top-0 h-full rounded-full transition-all duration-500 ease-out"
style={{
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 20%, #8b5cf6 40%, #ec4899 60%, #f59e0b 80%, #ef4444 100%)',
width: `${Math.min(utilization, 100)}%`,
boxShadow: utilization > 0 ? '0 0 8px rgba(59, 130, 246, 0.5)' : 'none',
}}
/>
</div>
</div>
{/* Current APY */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-2xl p-4 flex flex-col gap-2">
<span className="text-body-small font-medium text-text-secondary dark:text-gray-300 leading-[150%]">
{t("supply.currentReturns")}
</span>
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("supply.supplyApy")}
</span>
<span className="text-body-small font-bold text-[#ff6900] dark:text-orange-400 leading-[150%]">
{apy > 0 ? `${apy.toFixed(2)}%` : '--'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("supply.yearlyEarnings")}
</span>
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400 leading-[150%]">
{apy > 0 && parseFloat(suppliedBalance) > 0
? `~ $${(parseFloat(suppliedBalance) * apy / 100).toFixed(2)}`
: '~ $0.00'}
</span>
</div>
</div>
{/* Withdraw Button */}
<Button
isDisabled={
!mounted ||
!isConnected ||
!isValidAmount(amount) ||
parseFloat(amount) > parseFloat(suppliedBalance) ||
isWithdrawLoading
}
className={`${buttonStyles({ intent: "theme" })} mt-auto`}
endContent={<Image src="/icons/actions/arrow-right-icon.svg" alt="" width={20} height={20} />}
onPress={() => {
if (amount && parseFloat(amount) > 0) {
resetWithdraw();
executeWithdraw(amount);
}
}}
>
{!mounted && t("common.loading")}
{mounted && !isConnected && t("common.connectWallet")}
{mounted && isConnected && !!amount && !isValidAmount(amount) && t("common.invalidAmount")}
{mounted && isConnected && isValidAmount(amount) && parseFloat(amount) > parseFloat(suppliedBalance) && t("supply.insufficientBalance")}
{mounted && isConnected && withdrawStatus === 'idle' && (!amount || isValidAmount(amount)) && parseFloat(amount) <= parseFloat(suppliedBalance) && t("supply.withdraw")}
{mounted && withdrawStatus === 'withdrawing' && t("supply.withdrawing")}
{mounted && withdrawStatus === 'success' && t("common.success")}
{mounted && withdrawStatus === 'error' && t("common.failed")}
</Button>
</div>
);
}