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,105 @@
"use client";
import { useReadContracts } from 'wagmi';
import { useAccount } from 'wagmi';
import { formatUnits } from 'viem';
import { useQuery } from '@tanstack/react-query';
import { useApp } from "@/contexts/AppContext";
import { getContractAddress, abis } from '@/lib/contracts';
function formatUSD(v: number): string {
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
if (v >= 1_000) return `$${(v / 1_000).toFixed(2)}K`;
return `$${v.toFixed(2)}`;
}
export default function ALPStatsCards() {
const { t } = useApp();
const { chainId } = useAccount();
const poolManagerAddress = chainId ? getContractAddress('YTPoolManager', chainId) : undefined;
const lpTokenAddress = chainId ? getContractAddress('YTLPToken', chainId) : undefined;
const { data: tvlData, isLoading } = useReadContracts({
contracts: [
{
address: poolManagerAddress,
abi: abis.YTPoolManager as any,
functionName: 'getAumInUsdy',
args: [true],
chainId: chainId,
},
{
address: lpTokenAddress,
abi: abis.YTLPToken as any,
functionName: 'totalSupply',
args: [],
chainId: chainId,
},
],
query: { refetchInterval: 30000, enabled: !!poolManagerAddress && !!lpTokenAddress && !!chainId },
});
const aumRaw = tvlData?.[0]?.result as bigint | undefined;
const supplyRaw = tvlData?.[1]?.result as bigint | undefined;
const tvlValue = aumRaw ? formatUSD(parseFloat(formatUnits(aumRaw, 18))) : (isLoading ? '...' : '--');
const alpPrice = aumRaw && supplyRaw && supplyRaw > 0n
? `$${(parseFloat(formatUnits(aumRaw, 18)) / parseFloat(formatUnits(supplyRaw, 18))).toFixed(4)}`
: (isLoading ? '...' : '--');
// Fetch APR from backend (requires historical snapshots)
const { data: aprData } = useQuery({
queryKey: ['alp-stats'],
queryFn: async () => {
const res = await fetch('/api/alp/stats');
const json = await res.json();
return json.data as { poolAPR: number; rewardAPR: number };
},
refetchInterval: 5 * 60 * 1000, // refresh every 5 min
});
const poolAPR = aprData?.poolAPR != null
? `${aprData.poolAPR.toFixed(4)}%`
: '--';
const stats = [
{
label: t("stats.totalValueLocked"),
value: tvlValue,
isGreen: false,
},
{
label: t("alp.price"),
value: alpPrice,
isGreen: false,
},
{
label: t("alp.poolAPR"),
value: poolAPR,
isGreen: aprData?.poolAPR != null && aprData.poolAPR > 0,
},
{
label: t("alp.rewardAPR"),
value: "--",
isGreen: true,
},
];
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4">
{stats.map((stat, index) => (
<div
key={index}
className="bg-bg-subtle dark:bg-gray-800 rounded-2xl border border-border-gray dark:border-gray-700 px-4 md:px-6 py-4 flex flex-col gap-2"
>
<div className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400">
{stat.label}
</div>
<div className={`text-xl md:text-[32px] font-bold leading-[130%] tracking-[-0.01em] ${stat.isGreen ? 'text-[#10b981]' : 'text-text-primary dark:text-white'}`}>
{stat.value}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,317 @@
"use client";
import Image from "next/image";
import { useEffect, useMemo } from "react";
import { useReadContracts } from 'wagmi';
import { useAccount } from 'wagmi';
import { formatUnits } from 'viem';
import { useQuery } from '@tanstack/react-query';
import { useApp } from "@/contexts/AppContext";
import { getContractAddress, abis } from '@/lib/contracts';
function formatUSD(v: number): string {
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
if (v >= 1_000) return `$${(v / 1_000).toFixed(2)}K`;
return `$${v.toFixed(2)}`;
}
export default function LiquidityAllocationTable({ refreshTrigger }: { refreshTrigger?: number }) {
const { t } = useApp();
const { chainId } = useAccount();
// Fetch all products from backend; filter non-stablecoins to build YT token list
const { data: products = [] } = useQuery<any[]>({
queryKey: ['products'],
queryFn: async () => {
const res = await fetch('/api/fundmarket/products?token_list=1');
const json = await res.json();
return json.data ?? [];
},
staleTime: 5 * 60 * 1000,
});
// Build dynamic YT token list from DB products (only yt_token role)
// Use contractAddress and chainId directly from product data
const ytTokens = useMemo(() =>
products
.filter((p: any) => p.token_role === 'yt_token' && p.contractAddress)
.map((p: any) => ({
key: p.tokenSymbol as string,
address: p.contractAddress as string,
chainId: p.chainId as number,
meta: p,
})),
[products]);
// Derive target chainId from products, fallback to connected wallet
const targetChainId = ytTokens[0]?.chainId ?? chainId;
// System contract addresses from registry
const ytVaultAddr = targetChainId ? getContractAddress('YTVault', targetChainId) : undefined;
const usdcAddr = targetChainId ? getContractAddress('USDC', targetChainId) : undefined;
const priceFeedAddr = targetChainId ? getContractAddress('YTPriceFeed', targetChainId) : undefined;
const N = ytTokens.length;
const addressesReady = !!(ytVaultAddr && usdcAddr && priceFeedAddr && N > 0 && ytTokens.every(t => t.address));
// Dynamic batch on-chain reads — indices scale with N = ytTokens.length:
// [0] YTVault.getPoolValue(true)
// [1..N] YTVault.usdyAmounts(ytToken[i])
// [N+1..2N] YTPriceFeed.getPrice(ytToken[i], false)
// [2N+1] USDC.balanceOf(ytVaultAddr)
// [2N+2] YTPriceFeed.getPrice(usdcAddr, false)
// [2N+3] YTVault.totalTokenWeights()
// [2N+4..3N+3] YTVault.tokenWeights(ytToken[i])
// [3N+4] YTVault.tokenWeights(usdcAddr)
const { data: chainData, refetch: refetchChain } = useReadContracts({
contracts: [
{
address: ytVaultAddr,
abi: abis.YTVault as any,
functionName: 'getPoolValue',
args: [true],
chainId: targetChainId,
} as const,
...ytTokens.map(tk => ({
address: ytVaultAddr,
abi: abis.YTVault as any,
functionName: 'usdyAmounts',
args: [tk.address],
chainId: targetChainId,
} as const)),
...ytTokens.map(tk => ({
address: priceFeedAddr,
abi: abis.YTPriceFeed as any,
functionName: 'getPrice',
args: [tk.address, false],
chainId: targetChainId,
} as const)),
{
address: usdcAddr,
abi: abis.USDY as any,
functionName: 'balanceOf',
args: [ytVaultAddr],
chainId: targetChainId,
} as const,
{
address: priceFeedAddr,
abi: abis.YTPriceFeed as any,
functionName: 'getPrice',
args: [usdcAddr, false],
chainId: targetChainId,
} as const,
{
address: ytVaultAddr,
abi: abis.YTVault as any,
functionName: 'totalTokenWeights',
chainId: targetChainId,
} as const,
...ytTokens.map(tk => ({
address: ytVaultAddr,
abi: abis.YTVault as any,
functionName: 'tokenWeights',
args: [tk.address],
chainId: targetChainId,
} as const)),
{
address: ytVaultAddr,
abi: abis.YTVault as any,
functionName: 'tokenWeights',
args: [usdcAddr],
chainId: targetChainId,
} as const,
],
query: { refetchInterval: 30000, enabled: addressesReady },
});
useEffect(() => {
if (refreshTrigger) refetchChain();
}, [refreshTrigger]);
// Parse results with dynamic indices
const totalPoolRaw = chainData?.[0]?.result as bigint | undefined;
const totalWeightsRaw = chainData?.[2 * N + 3]?.result as bigint | undefined;
const totalPool = totalPoolRaw ? parseFloat(formatUnits(totalPoolRaw, 18)) : 0;
const totalWeights = totalWeightsRaw ? Number(totalWeightsRaw) : 0;
const ytAllocations = ytTokens.map((token, i) => {
const usdyRaw = chainData?.[1 + i]?.result as bigint | undefined;
const priceRaw = chainData?.[N + 1 + i]?.result as bigint | undefined;
const twRaw = chainData?.[2 * N + 4 + i]?.result as bigint | undefined;
const poolSizeUSD = usdyRaw ? parseFloat(formatUnits(usdyRaw, 18)) : 0;
const ytPrice = priceRaw ? parseFloat(formatUnits(priceRaw, 30)) : 0;
const balance = ytPrice > 0 ? poolSizeUSD / ytPrice : 0;
const currentWeight = totalPool > 0 ? poolSizeUSD / totalPool * 100 : 0;
const targetWeight = totalWeights > 0 && twRaw != null ? Number(twRaw) / totalWeights * 100 : 0;
return {
key: token.key,
name: token.meta?.name ?? token.key,
category: token.meta?.category ?? '--',
iconUrl: token.meta?.iconUrl,
poolSizeUSD,
balance,
ytPrice,
currentWeight,
targetWeight,
isStable: false,
};
});
const usdcRaw = chainData?.[2 * N + 1]?.result as bigint | undefined;
const usdcPriceRaw = chainData?.[2 * N + 2]?.result as bigint | undefined;
const usdcTWRaw = chainData?.[3 * N + 4]?.result as bigint | undefined;
const usdcBalance = usdcRaw ? parseFloat(formatUnits(usdcRaw, 18)) : 0;
const usdcPrice = usdcPriceRaw ? parseFloat(formatUnits(usdcPriceRaw, 30)) : 0;
const usdcPoolSize = usdcBalance * usdcPrice;
const usdcCurWeight = totalPool > 0 ? usdcPoolSize / totalPool * 100 : 0;
const usdcTgtWeight = totalWeights > 0 && usdcTWRaw != null ? Number(usdcTWRaw) / totalWeights * 100 : 0;
const usdcMeta = products.find((p: any) => p.tokenSymbol === 'USDC');
const allocations = [
...ytAllocations,
{
key: 'USDC',
name: usdcMeta?.name ?? 'USD Coin',
category: usdcMeta?.category ?? 'Stablecoin',
iconUrl: usdcMeta?.iconUrl,
poolSizeUSD: usdcPoolSize,
balance: usdcBalance,
ytPrice: usdcPrice,
currentWeight: usdcCurWeight,
targetWeight: usdcTgtWeight,
isStable: true,
},
];
return (
<div className="flex flex-col gap-3">
{/* Title */}
<h2 className="text-heading-h3 font-bold text-text-primary dark:text-white">
{t("alp.liquidityAllocation")}
</h2>
{/* 移动端:卡片布局 */}
<div className="md:hidden flex flex-col gap-3">
{allocations.map((item) => (
<div
key={item.key}
className="bg-bg-surface dark:bg-gray-800 rounded-2xl border border-border-gray dark:border-gray-700 p-4 flex flex-col gap-3"
>
{/* Token Header */}
<div className="flex items-center gap-3">
<Image
src={item.iconUrl || '/assets/tokens/default.svg'}
alt={item.key}
width={36}
height={36}
className="rounded-full"
onError={(e) => { (e.target as HTMLImageElement).src = '/assets/tokens/default.svg'; }}
/>
<div className="flex flex-col">
<span className="text-body-small font-bold text-text-primary dark:text-white">{item.name}</span>
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">{item.category}</span>
</div>
</div>
{/* Info Grid */}
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">{t("alp.poolSize")}</span>
<span className="text-body-small font-bold text-text-primary dark:text-white">
{item.poolSizeUSD > 0 ? formatUSD(item.poolSizeUSD) : '--'}
</span>
<span className="text-caption-tiny text-[#6b7280] dark:text-gray-400">
{item.balance > 0 ? `${item.balance.toFixed(2)} ${item.key}` : '--'}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">Weight</span>
<span className="text-body-small font-bold text-text-primary dark:text-white">
{totalPool > 0 ? `${item.currentWeight.toFixed(1)}%` : '--'}
</span>
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">
{item.targetWeight > 0 ? `${item.targetWeight.toFixed(1)}%` : '--'}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">{t("alp.currentPrice")}</span>
<span className="text-body-small font-bold text-text-primary dark:text-white">
{item.ytPrice > 0 ? `$${item.ytPrice.toFixed(4)}` : '--'}
</span>
</div>
</div>
</div>
))}
</div>
{/* 桌面端:表格布局 */}
<div className="hidden md:block bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 overflow-hidden">
<div className="flex flex-col">
{/* Header */}
<div className="flex border-b border-border-gray dark:border-gray-700 flex-shrink-0">
<div className="flex-1 px-6 py-4">
<div className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">{t("alp.token")}</div>
</div>
<div className="flex-1 px-6 py-4">
<div className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">{t("alp.poolSize")}</div>
</div>
<div className="flex-1 px-6 py-4">
<div className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">Weight (Current / Target)</div>
</div>
<div className="flex-1 px-6 py-4">
<div className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">{t("alp.currentPrice")}</div>
</div>
</div>
{/* Body */}
{allocations.map((item) => (
<div key={item.key} className="flex items-center border-b border-border-gray dark:border-gray-700 last:border-b-0">
<div className="flex-1 px-6 py-4 flex items-center gap-3">
<Image
src={item.iconUrl || '/assets/tokens/default.svg'}
alt={item.key}
width={32}
height={32}
className="rounded-full"
onError={(e) => { (e.target as HTMLImageElement).src = '/assets/tokens/default.svg'; }}
/>
<div className="flex flex-col">
<span className="text-body-small font-bold text-text-primary dark:text-white">{item.name}</span>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">{item.category}</span>
</div>
</div>
<div className="flex-1 px-6 py-4">
<div className="flex flex-col">
<span className="text-body-small font-bold text-text-primary dark:text-white">
{item.poolSizeUSD > 0 ? formatUSD(item.poolSizeUSD) : '--'}
</span>
<span className="text-caption-tiny font-regular text-[#6b7280] dark:text-gray-400">
{item.balance > 0 ? `${item.balance.toFixed(2)} ${item.key}` : '--'}
</span>
</div>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-body-small font-bold text-text-primary dark:text-white">
{totalPool > 0 ? (
<>
{item.currentWeight.toFixed(1)}%
<span className="font-regular text-text-tertiary dark:text-gray-400">
{' / '}{item.targetWeight > 0 ? `${item.targetWeight.toFixed(1)}%` : '--'}
</span>
</>
) : '--'}
</span>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-body-small font-bold text-text-primary dark:text-white">
{item.ytPrice > 0 ? `$${item.ytPrice.toFixed(4)}` : '--'}
</span>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,541 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Image from "next/image";
import { Button, Tabs, Tab } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { buttonStyles } from "@/lib/buttonStyles";
import { useAccount, useReadContract } from 'wagmi';
import { parseUnits, formatUnits } from 'viem';
import { useTokenBalance, useYTLPBalance } from '@/hooks/useBalance';
import { usePoolDeposit } from '@/hooks/usePoolDeposit';
import { usePoolWithdraw } from '@/hooks/usePoolWithdraw';
import { toast } from "sonner";
import TokenSelector from '@/components/common/TokenSelector';
import { Token } from '@/lib/api/tokens';
import { useTokenDecimalsFromAddress } from '@/hooks/useTokenDecimals';
import { useTokenBySymbol } from '@/hooks/useTokenBySymbol';
import { getContractAddress, abis } from '@/lib/contracts';
interface PoolDepositPanelProps {
onSuccess?: () => void;
}
export default function PoolDepositPanel({ onSuccess }: PoolDepositPanelProps) {
const { t } = useApp();
const [activeTab, setActiveTab] = useState<string>("deposit");
const [selectedToken, setSelectedToken] = useState<string>('USDC');
const [selectedTokenObj, setSelectedTokenObj] = useState<Token | undefined>();
const [outputToken, setOutputToken] = useState<string>('USDC');
const [outputTokenObj, setOutputTokenObj] = useState<Token | undefined>();
const [depositAmount, setDepositAmount] = useState<string>("");
const [withdrawAmount, setWithdrawAmount] = useState<string>("");
const [mounted, setMounted] = useState(false);
const [withdrawCooldownLeft, setWithdrawCooldownLeft] = useState(0); // seconds remaining
useEffect(() => {
setMounted(true);
}, []);
// Track 15-min withdraw cooldown after deposit
useEffect(() => {
const COOLDOWN_MS = 15 * 60 * 1000;
const tick = () => {
const lastDeposit = parseInt(localStorage.getItem('alp_last_deposit_time') ?? '0', 10);
if (!lastDeposit) { setWithdrawCooldownLeft(0); return; }
const remaining = Math.ceil((lastDeposit + COOLDOWN_MS - Date.now()) / 1000);
setWithdrawCooldownLeft(remaining > 0 ? remaining : 0);
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, []);
// 处理存入 Token 选择
const handleYTTokenSelect = useCallback((token: Token) => {
setSelectedTokenObj(token);
setSelectedToken(token.symbol);
setDepositAmount("");
}, []);
// 处理输出 Token 选择
const handleOutputTokenSelect = useCallback((token: Token) => {
setOutputTokenObj(token);
setOutputToken(token.symbol);
setWithdrawAmount("");
}, []);
// Web3 集成
const { address, isConnected, chainId } = useAccount();
// 优先从产品合约地址读取精度,失败时回退到 token.decimals
const depositInputDecimals = useTokenDecimalsFromAddress(
selectedTokenObj?.contractAddress,
selectedTokenObj?.decimals ?? 18
);
const depositDisplayDecimals = Math.min(depositInputDecimals, 6);
const ytLPAddress = chainId ? getContractAddress('YTLPToken', chainId) : undefined;
const poolManagerAddress = chainId ? getContractAddress('YTPoolManager', chainId) : undefined;
const withdrawDecimals = useTokenDecimalsFromAddress(ytLPAddress, 18);
const withdrawDisplayDecimals = Math.min(withdrawDecimals, 6);
const outputDecimals = useTokenDecimalsFromAddress(outputTokenObj?.contractAddress, outputTokenObj?.decimals ?? 18);
// 存入估算getAddLiquidityOutput(token, amount) → [usdyAmount, ytLPMintAmount]
const depositAmountWei = depositAmount !== '' && parseFloat(depositAmount) > 0 && selectedTokenObj
? parseUnits(depositAmount, depositInputDecimals)
: undefined;
const { data: depositEstData, isLoading: isDepositEstLoading } = useReadContract({
address: poolManagerAddress,
abi: abis.YTPoolManager as any,
functionName: 'getAddLiquidityOutput',
args: [selectedTokenObj?.contractAddress as `0x${string}`, depositAmountWei ?? 0n],
query: { enabled: !!poolManagerAddress && !!selectedTokenObj?.contractAddress && !!depositAmountWei },
});
const estLPTokens = depositEstData
? parseFloat(formatUnits((depositEstData as [bigint, bigint])[1], 18))
: null;
// 提出估算getRemoveLiquidityOutput(tokenOut, ytLPAmount) → [usdyAmount, amountOut]
const withdrawAmountWei = withdrawAmount !== '' && parseFloat(withdrawAmount) > 0
? parseUnits(withdrawAmount, withdrawDecimals)
: undefined;
const { data: withdrawEstData, isLoading: isWithdrawEstLoading } = useReadContract({
address: poolManagerAddress,
abi: abis.YTPoolManager as any,
functionName: 'getRemoveLiquidityOutput',
args: [outputTokenObj?.contractAddress as `0x${string}`, withdrawAmountWei ?? 0n],
query: { enabled: !!poolManagerAddress && !!outputTokenObj?.contractAddress && !!withdrawAmountWei },
});
const estAmountOut = withdrawEstData
? parseFloat(formatUnits((withdrawEstData as [bigint, bigint])[1], outputDecimals))
: null;
const { formattedBalance: depositBalance, isLoading: isBalanceLoading, refetch: refetchBalance } = useTokenBalance(
selectedTokenObj?.contractAddress,
depositInputDecimals
);
const { formattedBalance: outputTokenBalance } = useTokenBalance(
outputTokenObj?.contractAddress,
outputDecimals
);
const { formattedBalance: lpBalance, refetch: refetchLP } = useYTLPBalance();
// Deposit hooks
const {
status: depositStatus,
error: depositError,
isLoading: isDepositLoading,
approveHash: depositApproveHash,
depositHash,
executeApproveAndDeposit,
reset: resetDeposit,
} = usePoolDeposit();
// Withdraw hooks
const {
status: withdrawStatus,
error: withdrawError,
isLoading: isWithdrawLoading,
approveHash: withdrawApproveHash,
withdrawHash: withdrawTxHash,
executeApproveAndWithdraw,
reset: resetWithdraw,
} = usePoolWithdraw();
// 存款成功后刷新余额,并记录 15 分钟提现冷却开始时间
useEffect(() => {
if (depositStatus === 'success') {
toast.success(t("alp.toast.liquidityAdded"), {
description: t("alp.toast.liquidityAddedDesc"),
duration: 5000,
});
localStorage.setItem('alp_last_deposit_time', Date.now().toString());
refetchBalance();
refetchLP();
onSuccess?.();
const timer = setTimeout(() => {
resetDeposit();
setDepositAmount("");
}, 3000);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [depositStatus]);
// 提款成功后刷新余额
useEffect(() => {
if (withdrawStatus === 'success') {
toast.success(t("alp.toast.liquidityRemoved"), {
description: t("alp.toast.liquidityRemovedDesc"),
duration: 5000,
});
refetchBalance();
refetchLP();
onSuccess?.();
const timer = setTimeout(() => {
resetWithdraw();
setWithdrawAmount("");
}, 3000);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [withdrawStatus]);
// 显示错误提示
useEffect(() => {
if (depositError) {
if (depositError === 'Transaction cancelled') {
toast.warning(t("alp.toast.txCancelled"), {
description: t("alp.toast.txCancelledDesc"),
duration: 3000,
});
} else {
toast.error(t("alp.toast.depositFailed"), {
description: depositError,
duration: 5000,
});
}
}
}, [depositError]);
useEffect(() => {
if (withdrawError) {
if (withdrawError === 'Transaction cancelled') {
toast.warning(t("alp.toast.txCancelled"), {
description: t("alp.toast.txCancelledDesc"),
duration: 3000,
});
} else {
toast.error(t("alp.toast.withdrawFailed"), {
description: withdrawError,
duration: 5000,
});
}
}
}, [withdrawError]);
// 显示交易hash toast
useEffect(() => {
if (depositApproveHash && depositStatus === 'approving') {
toast.info(t("alp.toast.approvalSubmitted"), {
description: t("alp.toast.approvingTokens"),
action: {
label: t("alp.toast.viewTx"),
onClick: () => window.open(`https://testnet.bscscan.com/tx/${depositApproveHash}`, '_blank'),
},
duration: 10000,
});
}
}, [depositApproveHash, depositStatus]);
useEffect(() => {
if (depositHash && depositStatus === 'depositing') {
toast.info(t("alp.toast.depositSubmitted"), {
description: t("alp.toast.addingLiquidity"),
action: {
label: t("alp.toast.viewTx"),
onClick: () => window.open(`https://testnet.bscscan.com/tx/${depositHash}`, '_blank'),
},
duration: 10000,
});
}
}, [depositHash, depositStatus]);
useEffect(() => {
if (withdrawApproveHash && withdrawStatus === 'approving') {
toast.info(t("alp.toast.approvalSubmitted"), {
description: t("alp.toast.approvingLP"),
action: {
label: t("alp.toast.viewTx"),
onClick: () => window.open(`https://testnet.bscscan.com/tx/${withdrawApproveHash}`, '_blank'),
},
duration: 10000,
});
}
}, [withdrawApproveHash, withdrawStatus]);
useEffect(() => {
if (withdrawTxHash && withdrawStatus === 'withdrawing') {
toast.info(t("alp.toast.withdrawSubmitted"), {
description: t("alp.toast.removingLiquidity"),
action: {
label: t("alp.toast.viewTx"),
onClick: () => window.open(`https://testnet.bscscan.com/tx/${withdrawTxHash}`, '_blank'),
},
duration: 10000,
});
}
}, [withdrawTxHash, withdrawStatus]);
// 切换Tab时重置错误状态和输入框
useEffect(() => {
resetDeposit();
resetWithdraw();
setDepositAmount("");
setWithdrawAmount("");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab]);
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 handleDepositPercentage = (pct: number) => {
const bal = parseFloat(depositBalance);
if (bal > 0) setDepositAmount(truncateDecimals((bal * pct / 100).toString(), depositDisplayDecimals));
};
const handleWithdrawPercentage = (pct: number) => {
const bal = parseFloat(lpBalance);
if (bal > 0) setWithdrawAmount(truncateDecimals((bal * pct / 100).toString(), withdrawDisplayDecimals));
};
const pctBtnClass = "flex-1 sm:flex-none bg-[#e5e7eb] dark:bg-gray-600 rounded-full px-2 sm:px-2.5 h-[22px] text-[10px] font-medium text-text-primary dark:text-white hover:bg-[#d1d5db] dark:hover:bg-gray-500 transition-colors";
const handleDepositAmountChange = (value: string) => {
if (value === '') { setDepositAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > depositDisplayDecimals) return;
if (parseFloat(value) > parseFloat(depositBalance)) { setDepositAmount(truncateDecimals(depositBalance, depositDisplayDecimals)); return; }
setDepositAmount(value);
};
const handleWithdrawAmountChange = (value: string) => {
if (value === '') { setWithdrawAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > withdrawDisplayDecimals) return;
if (parseFloat(value) > parseFloat(lpBalance)) { setWithdrawAmount(truncateDecimals(lpBalance, withdrawDisplayDecimals)); return; }
setWithdrawAmount(value);
};
const handleDeposit = () => {
if (depositAmount && parseFloat(depositAmount) > 0 && selectedTokenObj) {
executeApproveAndDeposit(selectedTokenObj.contractAddress, depositInputDecimals, depositAmount);
}
};
const handleWithdraw = () => {
if (withdrawAmount && parseFloat(withdrawAmount) > 0 && outputTokenObj) {
executeApproveAndWithdraw(outputTokenObj.contractAddress, outputTokenObj.decimals ?? 18, withdrawAmount);
}
};
const currentStatus = activeTab === 'deposit' ? depositStatus : withdrawStatus;
const isLoading = activeTab === 'deposit' ? isDepositLoading : isWithdrawLoading;
const currentAmount = activeTab === 'deposit' ? depositAmount : withdrawAmount;
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 flex flex-col overflow-hidden h-full">
{/* Content */}
<div className="flex flex-col gap-6 p-6 flex-1">
{/* Title */}
<div className="flex flex-col gap-1">
<h2 className="text-heading-h3 font-bold text-text-primary dark:text-white">
{t("alp.liquidityPool")}
</h2>
<p className="text-body-small text-text-tertiary dark:text-gray-400">
{t("alp.subtitle")}
</p>
</div>
{/* Tabs */}
<Tabs
selectedKey={activeTab}
onSelectionChange={(key) => setActiveTab(key as string)}
classNames={{
base: "w-full",
tabList: "w-full bg-bg-subtle dark:bg-gray-700 p-1 rounded-xl",
tab: "h-10",
cursor: "bg-white dark:bg-gray-600",
tabContent: "group-data-[selected=true]:text-text-primary dark:group-data-[selected=true]:text-white",
}}
>
<Tab key="deposit" title={t("alp.deposit")}>
{/* Deposit Panel */}
<div className="flex flex-col gap-4 mt-4">
{/* Input Area */}
<div className="flex flex-col gap-2">
<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-3">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
{t("mintSwap.deposit")}
</span>
<div className="flex items-center gap-1 sm:hidden">
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} />
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
{!mounted ? '0' : (isBalanceLoading ? '...' : parseFloat(truncateDecimals(depositBalance, depositDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: depositDisplayDecimals }))} {selectedToken}
</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<button onClick={() => handleDepositPercentage(25)} className={pctBtnClass}>25%</button>
<button onClick={() => handleDepositPercentage(50)} className={pctBtnClass}>50%</button>
<button onClick={() => handleDepositPercentage(75)} className={pctBtnClass}>75%</button>
<button onClick={() => handleDepositPercentage(100)} className={pctBtnClass}>{t("mintSwap.max")}</button>
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} className="ml-1 hidden sm:block" />
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400 hidden sm:block">
{!mounted ? '0' : (isBalanceLoading ? '...' : parseFloat(truncateDecimals(depositBalance, depositDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: depositDisplayDecimals }))} {selectedToken}
</span>
</div>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col items-start flex-1">
<input
type="text" inputMode="decimal"
placeholder="0.00"
value={depositAmount}
onChange={(e) => handleDepositAmountChange(e.target.value)}
className="w-full text-left text-heading-h3 font-bold text-text-primary dark:text-white placeholder:text-[#d1d5db] dark:placeholder:text-gray-500 bg-transparent border-none outline-none"
/>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">
{depositAmount ? `$${depositAmount}` : "--"}
</span>
</div>
<div className="flex flex-col items-end gap-1">
<TokenSelector
selectedToken={selectedTokenObj}
onSelect={handleYTTokenSelect}
filterTypes={['stablecoin', 'yield-token']}
defaultSymbol={selectedToken}
/>
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-400">
{!mounted ? `0 ${selectedToken}` : (isBalanceLoading ? '...' : `${parseFloat(truncateDecimals(depositBalance, depositDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: depositDisplayDecimals })} ${selectedToken}`)}
</span>
</div>
</div>
</div>
</div>
{/* Pool Info */}
<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-2">
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400">
{t("alp.estimatedLP")}
</span>
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400">
{estLPTokens != null
? `${estLPTokens.toLocaleString('en-US', { maximumFractionDigits: 6 })} LP`
: depositAmount ? (isDepositEstLoading ? '...' : '--') : '--'}
</span>
</div>
</div>
</div>
</Tab>
<Tab key="withdraw" title={t("alp.withdraw")}>
{/* Withdraw Panel */}
<div className="flex flex-col gap-4 mt-4">
{/* Input Area */}
<div className="flex flex-col gap-2">
<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-3">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
{t("alp.amountToWithdraw")}
</span>
<div className="flex items-center gap-1 sm:hidden">
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} />
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
{parseFloat(truncateDecimals(lpBalance, withdrawDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: withdrawDisplayDecimals })} YT LP
</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<button onClick={() => handleWithdrawPercentage(25)} className={pctBtnClass}>25%</button>
<button onClick={() => handleWithdrawPercentage(50)} className={pctBtnClass}>50%</button>
<button onClick={() => handleWithdrawPercentage(75)} className={pctBtnClass}>75%</button>
<button onClick={() => handleWithdrawPercentage(100)} className={pctBtnClass}>{t("mintSwap.max")}</button>
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} className="ml-1 hidden sm:block" />
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400 hidden sm:block">
{parseFloat(truncateDecimals(lpBalance, withdrawDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: withdrawDisplayDecimals })} YT LP
</span>
</div>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col items-start flex-1">
<input
type="text" inputMode="decimal"
placeholder="0.00"
value={withdrawAmount}
onChange={(e) => handleWithdrawAmountChange(e.target.value)}
className="w-full text-left text-heading-h3 font-bold text-text-primary dark:text-white placeholder:text-[#d1d5db] dark:placeholder:text-gray-500 bg-transparent border-none outline-none"
/>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">
{withdrawAmount ? `${withdrawAmount} LP` : "--"}
</span>
</div>
<div className="flex flex-col items-end gap-1">
<TokenSelector
selectedToken={outputTokenObj}
onSelect={handleOutputTokenSelect}
filterTypes={['stablecoin', 'yield-token']}
defaultSymbol={outputToken}
/>
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-400">
{!mounted ? `0 ${outputToken}` : `${parseFloat(truncateDecimals(outputTokenBalance, 6)).toLocaleString('en-US', { maximumFractionDigits: 6 })} ${outputToken}`}
</span>
</div>
</div>
</div>
</div>
{/* Withdraw Info */}
<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-2">
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400">
{t("alp.youWillReceive")}
</span>
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400">
{estAmountOut != null
? `${estAmountOut.toLocaleString('en-US', { maximumFractionDigits: 6 })} ${outputToken}`
: withdrawAmount ? (isWithdrawEstLoading ? '...' : '--') : '--'}
</span>
</div>
</div>
</div>
</Tab>
</Tabs>
{/* Submit Button */}
{(() => {
const amountInvalid = !!currentAmount && !isValidAmount(currentAmount);
return (
<Button
isDisabled={!mounted || !isConnected || !isValidAmount(currentAmount) || isLoading || (activeTab === 'withdraw' && withdrawCooldownLeft > 0)}
color="default"
variant="solid"
className={buttonStyles({ intent: "theme" })}
endContent={<Image src="/icons/actions/arrow-right-icon.svg" alt="" width={20} height={20} />}
onPress={activeTab === 'deposit' ? handleDeposit : handleWithdraw}
>
{!mounted && t("common.connectWallet")}
{mounted && !isConnected && t("common.connectWallet")}
{mounted && isConnected && currentStatus === 'idle' && amountInvalid && t("common.invalidAmount")}
{mounted && isConnected && currentStatus === 'idle' && !amountInvalid && activeTab === 'deposit' && t("alp.addLiquidity")}
{mounted && isConnected && currentStatus === 'idle' && !amountInvalid && activeTab === 'withdraw' && withdrawCooldownLeft > 0 && `15-min Withdrawal Lock (${String(Math.floor(withdrawCooldownLeft / 60)).padStart(2, '0')}:${String(withdrawCooldownLeft % 60).padStart(2, '0')})`}
{mounted && isConnected && currentStatus === 'idle' && !amountInvalid && activeTab === 'withdraw' && withdrawCooldownLeft === 0 && t("alp.removeLiquidity")}
{mounted && currentStatus === 'approving' && t("common.approving")}
{mounted && currentStatus === 'approved' && (activeTab === 'deposit' ? t("alp.approvedAdding") : t("alp.approvedRemoving"))}
{mounted && (currentStatus === 'depositing' || currentStatus === 'withdrawing') && (activeTab === 'deposit' ? t("alp.adding") : t("alp.removing"))}
{mounted && currentStatus === 'success' && t("common.success")}
{mounted && currentStatus === 'error' && t("common.failed")}
</Button>
);
})()}
{/* Info Note */}
<div className="text-center">
<span className="text-caption-tiny font-regular text-[#9ca1af] dark:text-gray-400">
{activeTab === 'deposit' ? t("alp.depositNote") : t("alp.withdrawNote")}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,230 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useApp } from "@/contexts/AppContext";
import * as echarts from "echarts";
interface HistoryPoint {
time: string;
ts: number;
poolAPR: number;
alpPrice: number;
feeSurplus: number;
poolValue: number;
}
export default function PriceHistoryCard() {
const { t } = useApp();
const [activeTab, setActiveTab] = useState<"price" | "apr">("price");
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
const { data: history = [], isLoading } = useQuery<HistoryPoint[]>({
queryKey: ["alp-history"],
queryFn: async () => {
const res = await fetch("/api/alp/history?days=30");
const json = await res.json();
return json.data ?? [];
},
refetchInterval: 5 * 60 * 1000,
});
const isEmpty = history.length < 2;
const labels = isEmpty ? [] : history.map(p => p.time);
const priceData = isEmpty ? [] : history.map(p => p.alpPrice);
const aprData = isEmpty ? [] : history.map(p => p.poolAPR);
const activeData = activeTab === "price" ? priceData : aprData;
const activeColor = activeTab === "price" ? "#10b981" : "#1447e6";
const areaColor0 = activeTab === "price" ? "rgba(16,185,129,0.3)" : "rgba(20,71,230,0.25)";
const areaColor1 = activeTab === "price" ? "rgba(16,185,129,0)" : "rgba(20,71,230,0)";
const suffix = activeTab === "price" ? " USDC" : "%";
const precision = activeTab === "price" ? 4 : 4;
const highest = activeData.length > 0 ? Math.max(...activeData) : 0;
const lowest = activeData.length > 0 ? Math.min(...activeData) : 0;
const current = activeData.length > 0 ? activeData[activeData.length - 1] : 0;
// Avg APR from feeSurplus / poolValue delta
const actualDays = history.length >= 2
? (history[history.length - 1].ts - history[0].ts) / 86400
: 0;
const avgAPR = (() => {
if (history.length < 2 || actualDays <= 0) return 0;
const first = history[0];
const last = history[history.length - 1];
const surplus = (last.feeSurplus ?? 0) - (first.feeSurplus ?? 0);
const pv = last.poolValue ?? 0;
if (pv <= 0 || surplus <= 0) return 0;
return surplus / pv / actualDays * 365 * 100;
})();
const periodLabel = actualDays >= 1
? `Last ${Math.round(actualDays)} days`
: actualDays > 0
? `Last ${Math.round(actualDays * 24)}h`
: "Last 30 days";
const fmt = (v: number) =>
activeTab === "price" ? `$${v.toFixed(precision)}` : `${v.toFixed(precision)}%`;
const updateChart = () => {
if (!chartInstance.current) return;
// 自适应 Y 轴:以数据范围为基础,上下各留 20% 的 padding
const yMin = activeData.length > 0 ? Math.min(...activeData) : 0;
const yMax = activeData.length > 0 ? Math.max(...activeData) : 1;
const range = yMax - yMin || yMax * 0.01 || 0.01;
const yAxisMin = yMin - range * 0.2;
const yAxisMax = yMax + range * 0.2;
chartInstance.current.setOption({
grid: { left: 0, right: 0, top: 10, bottom: 24 },
tooltip: {
trigger: "axis",
confine: true,
backgroundColor: "rgba(17,24,39,0.9)",
borderColor: "#374151",
textStyle: { color: "#f9fafb", fontSize: 12 },
formatter: (params: any) => {
const d = params[0];
const val = Number(d.value).toFixed(precision);
const display = activeTab === "price" ? `$${val}` : `${val}%`;
return `<div style="padding:4px 8px"><span style="color:#9ca3af;font-size:11px">${d.name}</span><br/><span style="color:${activeColor};font-weight:600;font-size:14px">${display}</span></div>`;
},
},
xAxis: {
type: "category",
data: labels,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
show: true,
color: "#9ca3af",
fontSize: 11,
interval: Math.max(0, Math.floor(labels.length / 6) - 1),
},
boundaryGap: false,
},
yAxis: {
type: "value",
min: yAxisMin,
max: yAxisMax,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
},
series: [{
data: activeData,
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 5,
lineStyle: { color: activeColor, width: 2 },
itemStyle: { color: activeColor },
areaStyle: {
color: {
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: areaColor0 },
{ offset: 1, color: areaColor1 },
],
},
},
}],
}, true);
};
useEffect(() => {
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); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 px-6 py-6 flex flex-col gap-0 h-full">
{/* Tabs */}
<div className="flex gap-6 border-b border-border-gray dark:border-gray-700 mb-6">
<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("alp.priceHistory")}
</button>
<button
onClick={() => setActiveTab("apr")}
className={`pb-3 px-1 text-body-small font-bold transition-colors ${
activeTab === "apr"
? "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("alp.aprHistory")}
</button>
</div>
<div className="flex flex-col gap-6">
{/* Period label + avg for APR */}
<div className="flex items-center justify-between">
<div className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">
{periodLabel}
</div>
{activeTab === "apr" && (
<div className="text-caption-tiny font-bold text-[#1447e6]">
Avg APR: {avgAPR.toFixed(4)}%
</div>
)}
</div>
{/* Chart */}
<div className="relative w-full border-b border-border-gray dark:border-gray-600" style={{ height: "260px" }}>
{isLoading ? (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm text-text-tertiary dark:text-gray-400">{t("common.loading")}</span>
</div>
) : isEmpty ? (
<div className="w-full h-full flex items-center justify-center text-sm text-text-tertiary dark:text-gray-500">
{t("common.noData")}
</div>
) : (
<div ref={chartRef} className="w-full h-full" />
)}
</div>
{/* Stats */}
<div className="flex flex-col gap-3">
{[
{ label: t("alp.highest"), value: isEmpty ? "--" : fmt(highest) },
{ label: t("alp.lowest"), value: isEmpty ? "--" : fmt(lowest) },
{ label: t("alp.current"), value: isEmpty ? "--" : fmt(current) },
].map(({ label, value }) => (
<div key={label} className="flex items-start justify-between">
<div className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">{label}</div>
<div className="text-caption-tiny font-bold text-text-primary dark:text-white tabular-nums">{value}</div>
</div>
))}
</div>
</div>
</div>
);
}