| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382 |
- 'use client'
- import { useEffect, useState, useCallback } from 'react'
- import {
- Button,
- Select,
- InputNumber,
- Switch,
- App,
- Spin,
- Image,
- Card,
- } from 'antd'
- import { SwapOutlined } from '@ant-design/icons'
- interface MintInfo {
- mint: string
- symbol: string
- decimals: number
- logoURI: string
- price: string
- address: string
- }
- interface QuoteInfo {
- inAmount: string
- outAmount: string
- outAmountUi: number
- priceImpact: string
- route: string
- }
- const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
- function SwapPageContent() {
- const { message } = App.useApp()
- const [mintList, setMintList] = useState<MintInfo[]>([])
- const [loading, setLoading] = useState(false)
- const [quoting, setQuoting] = useState(false)
- const [swapLoading, setSwapLoading] = useState(false)
- const [inputToken, setInputToken] = useState<string>(USDC_MINT)
- const [outputToken, setOutputToken] = useState<string>('')
- const [inputAmount, setInputAmount] = useState<number | null>(null)
- const [isUsdMode, setIsUsdMode] = useState(true)
- const [quoteInfo, setQuoteInfo] = useState<QuoteInfo | null>(null)
- const fetchMintList = async () => {
- setLoading(true)
- try {
- const response = await fetch('/api/my-lp/mintList')
- const data = await response.json()
- setMintList(data.records || [])
- } catch (error) {
- console.error('Failed to fetch mint list:', error)
- message.error('获取代币列表失败')
- } finally {
- setLoading(false)
- }
- }
- const getQuote = useCallback(async () => {
- if (!inputToken || !outputToken || !inputAmount || inputAmount <= 0) {
- setQuoteInfo(null)
- return
- }
- setQuoting(true)
- try {
- const response = await fetch(
- `/api/swap?inputMint=${inputToken}&outputMint=${outputToken}&amount=${inputAmount}&mode=${isUsdMode ? 'usd' : 'amount'}`
- )
- const data = await response.json()
- if (data.success) {
- setQuoteInfo({
- inAmount: data.inAmount,
- outAmount: data.outAmount,
- outAmountUi: data.outAmountUi,
- priceImpact: data.priceImpact,
- route: data.route,
- })
- } else {
- setQuoteInfo(null)
- console.error('Quote error:', data.error)
- }
- } catch (error) {
- console.error('Failed to get quote:', error)
- setQuoteInfo(null)
- } finally {
- setQuoting(false)
- }
- }, [inputToken, outputToken, inputAmount, isUsdMode])
- useEffect(() => {
- fetchMintList()
- }, [])
- useEffect(() => {
- const timer = setTimeout(() => {
- if (inputAmount && inputAmount > 0) {
- getQuote()
- }
- }, 500)
- return () => clearTimeout(timer)
- }, [inputAmount, inputToken, outputToken, isUsdMode, getQuote])
- const handleSwap = async () => {
- if (!inputToken || !outputToken || !inputAmount || inputAmount <= 0) {
- message.warning('请选择代币并输入金额')
- return
- }
- setSwapLoading(true)
- message.loading({ content: '正在兑换...', key: 'swap', duration: 0 })
- try {
- const response = await fetch('/api/swap', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- inputMint: inputToken,
- outputMint: outputToken,
- amount: inputAmount,
- mode: isUsdMode ? 'usd' : 'amount',
- slippageBps: 200,
- }),
- })
- const data = await response.json()
- if (data.success) {
- message.success({
- content: `兑换成功!交易: ${data.txid.slice(0, 8)}...`,
- key: 'swap',
- })
- window.open(`https://solscan.io/tx/${data.txid}`, '_blank')
- setInputAmount(null)
- setQuoteInfo(null)
- } else {
- message.error({ content: data.error || '兑换失败', key: 'swap' })
- }
- } catch (error) {
- console.error('Swap error:', error)
- message.error({ content: '兑换失败', key: 'swap' })
- } finally {
- setSwapLoading(false)
- }
- }
- const handleSwitchTokens = () => {
- const temp = inputToken
- setInputToken(outputToken)
- setOutputToken(temp)
- setQuoteInfo(null)
- }
- const getTokenInfo = (address: string): MintInfo | undefined => {
- return mintList.find((m) => m.address === address)
- }
- const formatNumber = (num: number, decimals: number = 6): string => {
- if (num >= 1000) {
- return num.toFixed(2)
- } else if (num >= 1) {
- return num.toFixed(4)
- }
- return num.toFixed(decimals)
- }
- const getPlaceholder = (): string => {
- if (isUsdMode) {
- return '输入 USD 金额'
- }
- const token = getTokenInfo(inputToken)
- return `输入 ${token?.symbol || '代币'} 数量`
- }
- return (
- <main style={{ padding: '24px', maxWidth: '500px', margin: '0 auto' }}>
- <Card title="代币兑换">
- <Spin spinning={loading}>
- <div className="space-y-4">
- <div className="flex items-center justify-between mb-4">
- <span className="text-sm text-gray-500">
- {isUsdMode ? '按 USD 金额' : '按代币数量'}
- </span>
- <Switch
- checked={isUsdMode}
- onChange={setIsUsdMode}
- checkedChildren="USD"
- unCheckedChildren="数量"
- />
- </div>
- <div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
- <div className="text-xs text-gray-400 mb-2">支付</div>
- <div className="flex items-center gap-2 mb-2">
- <Select
- value={inputToken}
- onChange={(value) => {
- setInputToken(value)
- setQuoteInfo(null)
- }}
- style={{ width: 150 }}
- showSearch
- placeholder="选择代币"
- optionFilterProp="label"
- options={mintList.map((m) => ({
- label: m.symbol,
- value: m.address,
- }))}
- optionRender={(option) => (
- <div className="flex items-center gap-2">
- <Image
- src={
- mintList.find((m) => m.address === option.value)
- ?.logoURI
- }
- alt={option.label as string}
- width={20}
- height={20}
- style={{ borderRadius: '50%' }}
- preview={false}
- fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
- />
- <span>{option.label}</span>
- </div>
- )}
- />
- <InputNumber
- value={inputAmount}
- onChange={(value) => setInputAmount(value)}
- placeholder={getPlaceholder()}
- style={{ flex: 1 }}
- min={0}
- step={isUsdMode ? 1 : 0.001}
- precision={isUsdMode ? 2 : 6}
- />
- </div>
- {!isUsdMode && getTokenInfo(inputToken)?.price && (
- <div className="text-xs text-gray-400">
- ≈ $
- {formatNumber(
- (inputAmount || 0) *
- Number(getTokenInfo(inputToken)?.price || 0)
- )}
- </div>
- )}
- </div>
- <div className="flex justify-center">
- <Button
- type="text"
- icon={<SwapOutlined />}
- onClick={handleSwitchTokens}
- disabled={!inputToken || !outputToken}
- />
- </div>
- <div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
- <div className="text-xs text-gray-400 mb-2">获得</div>
- <div className="flex items-center gap-2">
- <Select
- value={outputToken}
- onChange={(value) => {
- setOutputToken(value)
- setQuoteInfo(null)
- }}
- style={{ width: 150 }}
- showSearch
- placeholder="选择代币"
- optionFilterProp="label"
- options={mintList.map((m) => ({
- label: m.symbol,
- value: m.address,
- }))}
- optionRender={(option) => (
- <div className="flex items-center gap-2">
- <Image
- src={
- mintList.find((m) => m.address === option.value)
- ?.logoURI
- }
- alt={option.label as string}
- width={20}
- height={20}
- style={{ borderRadius: '50%' }}
- preview={false}
- fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
- />
- <span>{option.label}</span>
- </div>
- )}
- />
- <div className="flex-1 text-right">
- {quoting ? (
- <Spin size="small" />
- ) : quoteInfo ? (
- <div>
- <div className="text-lg font-bold">
- {formatNumber(quoteInfo.outAmountUi)}
- </div>
- {getTokenInfo(outputToken)?.price && (
- <div className="text-xs text-gray-400">
- ≈ $
- {formatNumber(
- quoteInfo.outAmountUi *
- Number(getTokenInfo(outputToken)?.price || 0)
- )}
- </div>
- )}
- </div>
- ) : (
- <span className="text-gray-400">-</span>
- )}
- </div>
- </div>
- </div>
- {quoteInfo && (
- <div className="text-xs text-gray-500 space-y-1 p-2 bg-gray-50 dark:bg-gray-800 rounded">
- <div className="flex justify-between">
- <span>价格影响:</span>
- <span
- style={{
- color:
- Number(quoteInfo.priceImpact) > 1
- ? '#ff4d4f'
- : Number(quoteInfo.priceImpact) > 0.5
- ? '#faad14'
- : '#52c41a',
- }}
- >
- {(Number(quoteInfo.priceImpact) * 100).toFixed(2)}%
- </span>
- </div>
- {quoteInfo.route && (
- <div className="flex justify-between">
- <span>路由:</span>
- <span className="text-right max-w-[200px] truncate">
- {quoteInfo.route}
- </span>
- </div>
- )}
- </div>
- )}
- <Button
- type="primary"
- block
- size="large"
- onClick={handleSwap}
- loading={swapLoading}
- disabled={
- !inputToken ||
- !outputToken ||
- !inputAmount ||
- inputAmount <= 0 ||
- inputToken === outputToken
- }
- >
- {inputToken === outputToken && inputToken
- ? '请选择不同的代币'
- : '兑换'}
- </Button>
- </div>
- </Spin>
- </Card>
- <div className="mt-4 text-xs text-gray-400 text-center">
- <p>使用 Jupiter Ultra API 进行兑换</p>
- <p>滑点: 2%</p>
- </div>
- </main>
- )
- }
- export default function SwapPage() {
- return <SwapPageContent />
- }
|