page.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. 'use client'
  2. import { useEffect, useState, useCallback } from 'react'
  3. import {
  4. Button,
  5. Select,
  6. InputNumber,
  7. Switch,
  8. App,
  9. Spin,
  10. Image,
  11. Card,
  12. } from 'antd'
  13. import { SwapOutlined } from '@ant-design/icons'
  14. interface MintInfo {
  15. mint: string
  16. symbol: string
  17. decimals: number
  18. logoURI: string
  19. price: string
  20. address: string
  21. }
  22. interface QuoteInfo {
  23. inAmount: string
  24. outAmount: string
  25. outAmountUi: number
  26. priceImpact: string
  27. route: string
  28. }
  29. const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
  30. function SwapPageContent() {
  31. const { message } = App.useApp()
  32. const [mintList, setMintList] = useState<MintInfo[]>([])
  33. const [loading, setLoading] = useState(false)
  34. const [quoting, setQuoting] = useState(false)
  35. const [swapLoading, setSwapLoading] = useState(false)
  36. const [inputToken, setInputToken] = useState<string>(USDC_MINT)
  37. const [outputToken, setOutputToken] = useState<string>('')
  38. const [inputAmount, setInputAmount] = useState<number | null>(null)
  39. const [isUsdMode, setIsUsdMode] = useState(true)
  40. const [quoteInfo, setQuoteInfo] = useState<QuoteInfo | null>(null)
  41. const fetchMintList = async () => {
  42. setLoading(true)
  43. try {
  44. const response = await fetch('/api/my-lp/mintList')
  45. const data = await response.json()
  46. setMintList(data.records || [])
  47. } catch (error) {
  48. console.error('Failed to fetch mint list:', error)
  49. message.error('获取代币列表失败')
  50. } finally {
  51. setLoading(false)
  52. }
  53. }
  54. const getQuote = useCallback(async () => {
  55. if (!inputToken || !outputToken || !inputAmount || inputAmount <= 0) {
  56. setQuoteInfo(null)
  57. return
  58. }
  59. setQuoting(true)
  60. try {
  61. const response = await fetch(
  62. `/api/swap?inputMint=${inputToken}&outputMint=${outputToken}&amount=${inputAmount}&mode=${isUsdMode ? 'usd' : 'amount'}`
  63. )
  64. const data = await response.json()
  65. if (data.success) {
  66. setQuoteInfo({
  67. inAmount: data.inAmount,
  68. outAmount: data.outAmount,
  69. outAmountUi: data.outAmountUi,
  70. priceImpact: data.priceImpact,
  71. route: data.route,
  72. })
  73. } else {
  74. setQuoteInfo(null)
  75. console.error('Quote error:', data.error)
  76. }
  77. } catch (error) {
  78. console.error('Failed to get quote:', error)
  79. setQuoteInfo(null)
  80. } finally {
  81. setQuoting(false)
  82. }
  83. }, [inputToken, outputToken, inputAmount, isUsdMode])
  84. useEffect(() => {
  85. fetchMintList()
  86. }, [])
  87. useEffect(() => {
  88. const timer = setTimeout(() => {
  89. if (inputAmount && inputAmount > 0) {
  90. getQuote()
  91. }
  92. }, 500)
  93. return () => clearTimeout(timer)
  94. }, [inputAmount, inputToken, outputToken, isUsdMode, getQuote])
  95. const handleSwap = async () => {
  96. if (!inputToken || !outputToken || !inputAmount || inputAmount <= 0) {
  97. message.warning('请选择代币并输入金额')
  98. return
  99. }
  100. setSwapLoading(true)
  101. message.loading({ content: '正在兑换...', key: 'swap', duration: 0 })
  102. try {
  103. const response = await fetch('/api/swap', {
  104. method: 'POST',
  105. headers: { 'Content-Type': 'application/json' },
  106. body: JSON.stringify({
  107. inputMint: inputToken,
  108. outputMint: outputToken,
  109. amount: inputAmount,
  110. mode: isUsdMode ? 'usd' : 'amount',
  111. slippageBps: 200,
  112. }),
  113. })
  114. const data = await response.json()
  115. if (data.success) {
  116. message.success({
  117. content: `兑换成功!交易: ${data.txid.slice(0, 8)}...`,
  118. key: 'swap',
  119. })
  120. window.open(`https://solscan.io/tx/${data.txid}`, '_blank')
  121. setInputAmount(null)
  122. setQuoteInfo(null)
  123. } else {
  124. message.error({ content: data.error || '兑换失败', key: 'swap' })
  125. }
  126. } catch (error) {
  127. console.error('Swap error:', error)
  128. message.error({ content: '兑换失败', key: 'swap' })
  129. } finally {
  130. setSwapLoading(false)
  131. }
  132. }
  133. const handleSwitchTokens = () => {
  134. const temp = inputToken
  135. setInputToken(outputToken)
  136. setOutputToken(temp)
  137. setQuoteInfo(null)
  138. }
  139. const getTokenInfo = (address: string): MintInfo | undefined => {
  140. return mintList.find((m) => m.address === address)
  141. }
  142. const formatNumber = (num: number, decimals: number = 6): string => {
  143. if (num >= 1000) {
  144. return num.toFixed(2)
  145. } else if (num >= 1) {
  146. return num.toFixed(4)
  147. }
  148. return num.toFixed(decimals)
  149. }
  150. const getPlaceholder = (): string => {
  151. if (isUsdMode) {
  152. return '输入 USD 金额'
  153. }
  154. const token = getTokenInfo(inputToken)
  155. return `输入 ${token?.symbol || '代币'} 数量`
  156. }
  157. return (
  158. <main style={{ padding: '24px', maxWidth: '500px', margin: '0 auto' }}>
  159. <Card title="代币兑换">
  160. <Spin spinning={loading}>
  161. <div className="space-y-4">
  162. <div className="flex items-center justify-between mb-4">
  163. <span className="text-sm text-gray-500">
  164. {isUsdMode ? '按 USD 金额' : '按代币数量'}
  165. </span>
  166. <Switch
  167. checked={isUsdMode}
  168. onChange={setIsUsdMode}
  169. checkedChildren="USD"
  170. unCheckedChildren="数量"
  171. />
  172. </div>
  173. <div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
  174. <div className="text-xs text-gray-400 mb-2">支付</div>
  175. <div className="flex items-center gap-2 mb-2">
  176. <Select
  177. value={inputToken}
  178. onChange={(value) => {
  179. setInputToken(value)
  180. setQuoteInfo(null)
  181. }}
  182. style={{ width: 150 }}
  183. showSearch
  184. placeholder="选择代币"
  185. optionFilterProp="label"
  186. options={mintList.map((m) => ({
  187. label: m.symbol,
  188. value: m.address,
  189. }))}
  190. optionRender={(option) => (
  191. <div className="flex items-center gap-2">
  192. <Image
  193. src={
  194. mintList.find((m) => m.address === option.value)
  195. ?.logoURI
  196. }
  197. alt={option.label as string}
  198. width={20}
  199. height={20}
  200. style={{ borderRadius: '50%' }}
  201. preview={false}
  202. fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
  203. />
  204. <span>{option.label}</span>
  205. </div>
  206. )}
  207. />
  208. <InputNumber
  209. value={inputAmount}
  210. onChange={(value) => setInputAmount(value)}
  211. placeholder={getPlaceholder()}
  212. style={{ flex: 1 }}
  213. min={0}
  214. step={isUsdMode ? 1 : 0.001}
  215. precision={isUsdMode ? 2 : 6}
  216. />
  217. </div>
  218. {!isUsdMode && getTokenInfo(inputToken)?.price && (
  219. <div className="text-xs text-gray-400">
  220. ≈ $
  221. {formatNumber(
  222. (inputAmount || 0) *
  223. Number(getTokenInfo(inputToken)?.price || 0)
  224. )}
  225. </div>
  226. )}
  227. </div>
  228. <div className="flex justify-center">
  229. <Button
  230. type="text"
  231. icon={<SwapOutlined />}
  232. onClick={handleSwitchTokens}
  233. disabled={!inputToken || !outputToken}
  234. />
  235. </div>
  236. <div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
  237. <div className="text-xs text-gray-400 mb-2">获得</div>
  238. <div className="flex items-center gap-2">
  239. <Select
  240. value={outputToken}
  241. onChange={(value) => {
  242. setOutputToken(value)
  243. setQuoteInfo(null)
  244. }}
  245. style={{ width: 150 }}
  246. showSearch
  247. placeholder="选择代币"
  248. optionFilterProp="label"
  249. options={mintList.map((m) => ({
  250. label: m.symbol,
  251. value: m.address,
  252. }))}
  253. optionRender={(option) => (
  254. <div className="flex items-center gap-2">
  255. <Image
  256. src={
  257. mintList.find((m) => m.address === option.value)
  258. ?.logoURI
  259. }
  260. alt={option.label as string}
  261. width={20}
  262. height={20}
  263. style={{ borderRadius: '50%' }}
  264. preview={false}
  265. fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
  266. />
  267. <span>{option.label}</span>
  268. </div>
  269. )}
  270. />
  271. <div className="flex-1 text-right">
  272. {quoting ? (
  273. <Spin size="small" />
  274. ) : quoteInfo ? (
  275. <div>
  276. <div className="text-lg font-bold">
  277. {formatNumber(quoteInfo.outAmountUi)}
  278. </div>
  279. {getTokenInfo(outputToken)?.price && (
  280. <div className="text-xs text-gray-400">
  281. ≈ $
  282. {formatNumber(
  283. quoteInfo.outAmountUi *
  284. Number(getTokenInfo(outputToken)?.price || 0)
  285. )}
  286. </div>
  287. )}
  288. </div>
  289. ) : (
  290. <span className="text-gray-400">-</span>
  291. )}
  292. </div>
  293. </div>
  294. </div>
  295. {quoteInfo && (
  296. <div className="text-xs text-gray-500 space-y-1 p-2 bg-gray-50 dark:bg-gray-800 rounded">
  297. <div className="flex justify-between">
  298. <span>价格影响:</span>
  299. <span
  300. style={{
  301. color:
  302. Number(quoteInfo.priceImpact) > 1
  303. ? '#ff4d4f'
  304. : Number(quoteInfo.priceImpact) > 0.5
  305. ? '#faad14'
  306. : '#52c41a',
  307. }}
  308. >
  309. {(Number(quoteInfo.priceImpact) * 100).toFixed(2)}%
  310. </span>
  311. </div>
  312. {quoteInfo.route && (
  313. <div className="flex justify-between">
  314. <span>路由:</span>
  315. <span className="text-right max-w-[200px] truncate">
  316. {quoteInfo.route}
  317. </span>
  318. </div>
  319. )}
  320. </div>
  321. )}
  322. <Button
  323. type="primary"
  324. block
  325. size="large"
  326. onClick={handleSwap}
  327. loading={swapLoading}
  328. disabled={
  329. !inputToken ||
  330. !outputToken ||
  331. !inputAmount ||
  332. inputAmount <= 0 ||
  333. inputToken === outputToken
  334. }
  335. >
  336. {inputToken === outputToken && inputToken
  337. ? '请选择不同的代币'
  338. : '兑换'}
  339. </Button>
  340. </div>
  341. </Spin>
  342. </Card>
  343. <div className="mt-4 text-xs text-gray-400 text-center">
  344. <p>使用 Jupiter Ultra API 进行兑换</p>
  345. <p>滑点: 2%</p>
  346. </div>
  347. </main>
  348. )
  349. }
  350. export default function SwapPage() {
  351. return <SwapPageContent />
  352. }