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,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>
);
}