DataTable.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697
  1. 'use client'
  2. import { useEffect, useState } from 'react'
  3. import { Table, Select, Typography, Tag, Button, InputNumber, App } from 'antd'
  4. interface TableData {
  5. key: string
  6. liquidity: string
  7. earnedUsd: string
  8. positionAgeMs: number
  9. tokenAaddress?: string
  10. tokenBaddress?: string
  11. [key: string]: unknown
  12. }
  13. interface ApiResponse {
  14. retCode: number
  15. result: {
  16. data: {
  17. records: Record<string, unknown>[]
  18. total: number
  19. current: number
  20. pageSize: number
  21. pages: number
  22. poolMap?: Record<string, PoolInfo>
  23. }
  24. }
  25. retMsg?: string
  26. }
  27. interface PoolOption {
  28. label: string
  29. value: string
  30. }
  31. interface PoolInfo {
  32. poolAddress: string
  33. mintA?: {
  34. address: string
  35. symbol?: string
  36. mintInfo?: {
  37. symbol?: string
  38. }
  39. }
  40. mintB?: {
  41. address: string
  42. symbol?: string
  43. mintInfo?: {
  44. symbol?: string
  45. }
  46. }
  47. }
  48. interface PoolsListResponse {
  49. retCode: number
  50. result?: {
  51. data?: {
  52. records?: PoolInfo[]
  53. }
  54. }
  55. retMsg?: string
  56. }
  57. function DataTableContent() {
  58. const { message } = App.useApp()
  59. const [data, setData] = useState<TableData[]>([])
  60. const [loading, setLoading] = useState(true)
  61. const [poolOptions, setPoolOptions] = useState<PoolOption[]>([])
  62. const [quickCopyAmount, setQuickCopyAmount] = useState<number>(1)
  63. const [selectedPoolAddress, setSelectedPoolAddress] = useState<string>(
  64. 'FPBW9dtVRoUug2BeUKZAzaknd6iiet9jHM8RcTvwUkyC'
  65. )
  66. const [pagination, setPagination] = useState({
  67. current: 1,
  68. pageSize: 100,
  69. total: 0,
  70. })
  71. const [sortField, setSortField] = useState<string>('liquidity')
  72. const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([])
  73. const [childTableData, setChildTableData] = useState<
  74. Record<string, TableData[]>
  75. >({})
  76. const [childTableLoading, setChildTableLoading] = useState<
  77. Record<string, boolean>
  78. >({})
  79. // 获取 pools 列表
  80. const fetchPoolsList = async () => {
  81. try {
  82. const response = await fetch('/api/pools/list?page=1&pageSize=500')
  83. if (!response.ok) {
  84. throw new Error(`HTTP error! status: ${response.status}`)
  85. }
  86. const result: PoolsListResponse = await response.json()
  87. if (result.result?.data?.records) {
  88. const options: PoolOption[] = result.result.data.records.map((pool) => {
  89. const symbolA = pool.mintA?.mintInfo?.symbol || ''
  90. const symbolB = pool.mintB?.mintInfo?.symbol || ''
  91. const label = `${symbolA}/${symbolB}`
  92. return {
  93. label,
  94. value: pool.poolAddress,
  95. }
  96. })
  97. setPoolOptions(options)
  98. }
  99. } catch (error) {
  100. console.error('Error fetching pools list:', error)
  101. message.error('获取 pools 列表失败')
  102. }
  103. }
  104. const fetchData = async (
  105. page: number = 1,
  106. pageSize: number = 100,
  107. poolAddress?: string,
  108. currentSortField?: string
  109. ) => {
  110. setLoading(true)
  111. try {
  112. const response = await fetch('/api/top-positions', {
  113. method: 'POST',
  114. headers: {
  115. 'content-type': 'application/json',
  116. },
  117. body: JSON.stringify({
  118. poolAddress: poolAddress || selectedPoolAddress,
  119. page,
  120. pageSize,
  121. sortField: currentSortField || sortField,
  122. status: 0,
  123. }),
  124. })
  125. // console.log(response.json())
  126. if (!response.ok) {
  127. throw new Error(`HTTP error! status: ${response.status}`)
  128. }
  129. const result: ApiResponse = await response.json()
  130. console.log(result)
  131. if (result.result) {
  132. const { records, total, current, pageSize, poolMap } =
  133. result.result.data
  134. const poolMapData = poolMap
  135. ? (Object.values(poolMap)[0] as PoolInfo)
  136. : undefined
  137. const tokenAaddress = poolMapData?.mintA?.address
  138. const tokenBaddress = poolMapData?.mintB?.address
  139. setData(
  140. records.map((item, index) => ({
  141. key: `${item.id || index}`,
  142. tokenAaddress,
  143. tokenBaddress,
  144. ...item,
  145. })) as TableData[]
  146. )
  147. setExpandedRowKeys([])
  148. setPagination({
  149. current: current,
  150. pageSize: pageSize,
  151. total: total,
  152. })
  153. } else {
  154. message.error(result.retMsg || '获取数据失败')
  155. }
  156. } catch (error) {
  157. console.error('Error fetching data:', error)
  158. message.error('网络请求失败,请稍后重试')
  159. } finally {
  160. setLoading(false)
  161. }
  162. }
  163. const init = async () => {
  164. await fetchPoolsList()
  165. await fetchData(1, 100)
  166. }
  167. const calculateAPR = (record: TableData) => {
  168. const useEarnSecond =
  169. Number(record.earnedUsd) / (Number(record.positionAgeMs) / 1000)
  170. const apr =
  171. (useEarnSecond * 60 * 60 * 24 * 365) / Number(record.liquidityUsd)
  172. return (apr * 100).toFixed(2) + '%'
  173. }
  174. const calculateAPRValue = (record: TableData) => {
  175. const useEarnSecond =
  176. Number(record.earnedUsd) / (Number(record.positionAgeMs) / 1000)
  177. const apr =
  178. (useEarnSecond * 60 * 60 * 24 * 365) / Number(record.liquidityUsd)
  179. return apr * 100
  180. }
  181. function handleQuickCopy(record: TableData) {
  182. message.loading({
  183. key: 'quickCopy',
  184. content: '复制中...',
  185. duration: 0,
  186. })
  187. fetch('/api/lp-copy', {
  188. method: 'POST',
  189. headers: {
  190. 'Content-Type': 'application/json',
  191. },
  192. body: JSON.stringify({
  193. positionAddress: record.positionAddress,
  194. nftMintAddress: record.nftMintAddress,
  195. maxUsdValue: quickCopyAmount,
  196. }),
  197. })
  198. .then((res) => res.json())
  199. .then((data) => {
  200. message.destroy('quickCopy')
  201. if (data.success) {
  202. message.success('快速复制成功')
  203. } else {
  204. message.error(data.error || '快速复制失败')
  205. }
  206. })
  207. .catch((err) => {
  208. console.error('Error quick copying:', err)
  209. message.error('快速复制失败')
  210. })
  211. }
  212. useEffect(() => {
  213. init()
  214. // eslint-disable-next-line react-hooks/exhaustive-deps
  215. }, [])
  216. const columns = [
  217. {
  218. title: '创建地址',
  219. dataIndex: 'walletAddress',
  220. key: 'walletAddress',
  221. render: (text: string) => {
  222. return (
  223. <span className="font-bold text-lg">
  224. {text.slice(0, 6)}...{text.slice(-4)}
  225. </span>
  226. )
  227. },
  228. },
  229. {
  230. title: 'Liquidity',
  231. dataIndex: 'liquidityUsd',
  232. key: 'liquidityUsd',
  233. sorter: (a: TableData, b: TableData) => {
  234. return Number(a.liquidityUsd) - Number(b.liquidityUsd)
  235. },
  236. render: (text: string) => {
  237. return (
  238. <span
  239. className="text-orange-500 font-bold text-lg"
  240. style={{ color: '#00B098' }}
  241. >
  242. ${Number(text).toFixed(2)}
  243. </span>
  244. )
  245. },
  246. },
  247. {
  248. title: '复制数',
  249. dataIndex: 'copies',
  250. key: 'copies',
  251. render: (text: string) => {
  252. return (
  253. <span
  254. className={`font-bold text-lg ${Number(text) === 0 ? 'text-red-500' : 'text-green-500'}`}
  255. >
  256. {text}
  257. </span>
  258. )
  259. },
  260. },
  261. {
  262. title: '奖励',
  263. dataIndex: 'bonusUsd',
  264. key: 'bonusUsd',
  265. render: (text: string) => {
  266. return (
  267. <span className="text-orange-500 font-bold text-lg">
  268. ${Number(text).toFixed(2)}
  269. </span>
  270. )
  271. },
  272. },
  273. {
  274. title: 'APR',
  275. dataIndex: 'apr',
  276. key: 'apr',
  277. sorter: (a: TableData, b: TableData) => {
  278. return calculateAPRValue(a) - calculateAPRValue(b)
  279. },
  280. render: (_text: string, record: TableData) => {
  281. return (
  282. <span className="font-bold text-lg" style={{ color: '#00B098' }}>
  283. {calculateAPR(record)}
  284. </span>
  285. )
  286. },
  287. },
  288. {
  289. title: 'FEE',
  290. dataIndex: 'earnedUsd',
  291. key: 'earnedUsd',
  292. render: (text: string) => {
  293. return (
  294. <span
  295. className="text-orange-500 font-bold text-lg"
  296. style={{ color: Number(text) > 0 ? '#00B098' : '#FF0000' }}
  297. >
  298. ${Number(text).toFixed(2)}
  299. </span>
  300. )
  301. },
  302. },
  303. {
  304. title: 'PNL',
  305. dataIndex: 'pnlUsd',
  306. key: 'pnlUsd',
  307. render: (text: string) => {
  308. return (
  309. <span
  310. className="text-orange-500 font-bold text-lg"
  311. style={{ color: Number(text) > 0 ? '#00B098' : '#FF0000' }}
  312. >
  313. ${Number(text).toFixed(2)}
  314. </span>
  315. )
  316. },
  317. },
  318. {
  319. title: '创建时间',
  320. dataIndex: 'positionAgeMs',
  321. key: 'positionAgeMs',
  322. sorter: (a: TableData, b: TableData) => {
  323. return Number(a.positionAgeMs) - Number(b.positionAgeMs)
  324. },
  325. render: (text: string) => {
  326. return (
  327. <span className="font-bold text-lg">
  328. {Math.floor(Number(text) / 1000 / 60 / 60)} 小时
  329. </span>
  330. )
  331. },
  332. },
  333. {
  334. title: '操作',
  335. dataIndex: 'walletAddress',
  336. key: 'walletAddress',
  337. render: (text: string, record: TableData) => {
  338. return (
  339. <div className="flex items-center gap-2">
  340. <Typography.Link
  341. href={`https://www.byreal.io/en/portfolio?userAddress=${text}&tab=current&positionAddress=${record.positionAddress}&tokenAddress=${record.tokenAaddress}&tokenAddress=${record.tokenBaddress}`}
  342. target="_blank"
  343. >
  344. 复制
  345. </Typography.Link>
  346. <Typography.Link
  347. onClick={() => handleQuickCopy(record)}
  348. target="_blank"
  349. >
  350. 快速复制
  351. </Typography.Link>
  352. </div>
  353. )
  354. },
  355. },
  356. ]
  357. const handleTableChange = (
  358. newPagination: {
  359. current?: number
  360. pageSize?: number
  361. },
  362. _filters: unknown,
  363. sorter:
  364. | {
  365. field?: string
  366. order?: 'ascend' | 'descend' | null
  367. }
  368. | unknown
  369. ) => {
  370. let currentSortField = sortField
  371. let shouldResetPage = false
  372. if (newPagination.current && newPagination.current !== pagination.current) {
  373. const targetPage = newPagination.current
  374. const targetPageSize = newPagination.pageSize || pagination.pageSize
  375. setPagination({
  376. ...pagination,
  377. current: targetPage,
  378. pageSize: targetPageSize,
  379. })
  380. fetchData(
  381. targetPage,
  382. targetPageSize,
  383. selectedPoolAddress,
  384. currentSortField
  385. )
  386. } else {
  387. if (sorter && typeof sorter === 'object' && 'field' in sorter) {
  388. const sorterObj = sorter as {
  389. field?: string
  390. order?: 'ascend' | 'descend' | null
  391. }
  392. if (sorterObj.field && sorterObj.order) {
  393. // 映射字段名到 API 的 sortField
  394. const fieldMap: Record<string, string> = {
  395. copies: 'copies',
  396. earnedUsd: 'earnedUsd',
  397. pnlUsd: 'pnlUsd',
  398. liquidityUsd: 'liquidity',
  399. positionAgeMs: 'positionAgeMs',
  400. }
  401. const newSortField = fieldMap[sorterObj.field] || sortField
  402. if (newSortField !== sortField) {
  403. currentSortField = newSortField
  404. shouldResetPage = true
  405. }
  406. }
  407. }
  408. }
  409. }
  410. const handlePoolChange = (value: string) => {
  411. setSelectedPoolAddress(value)
  412. fetchData(1, 100, value)
  413. }
  414. // 获取子表格数据
  415. const fetchChildData = async (parentPositionAddress: string) => {
  416. if (childTableData[parentPositionAddress]) {
  417. // 如果已经加载过,直接返回
  418. return
  419. }
  420. setChildTableLoading((prev) => ({
  421. ...prev,
  422. [parentPositionAddress]: true,
  423. }))
  424. try {
  425. const response = await fetch('/api/top-positions', {
  426. method: 'POST',
  427. headers: {
  428. 'content-type': 'application/json',
  429. },
  430. body: JSON.stringify({
  431. poolAddress: selectedPoolAddress,
  432. parentPositionAddress,
  433. page: 1,
  434. pageSize: 500,
  435. sortField: 'liquidity',
  436. }),
  437. })
  438. if (!response.ok) {
  439. throw new Error(`HTTP error! status: ${response.status}`)
  440. }
  441. const result: ApiResponse = await response.json()
  442. if (result.result) {
  443. const { records } = result.result.data
  444. const childData: TableData[] = records.map((item, index) => ({
  445. key: `${parentPositionAddress}-${item.id || index}`,
  446. ...item,
  447. })) as TableData[]
  448. setChildTableData((prev) => ({
  449. ...prev,
  450. [parentPositionAddress]: childData,
  451. }))
  452. }
  453. } catch (error) {
  454. console.error('Error fetching child data:', error)
  455. message.error('获取子表格数据失败')
  456. } finally {
  457. setChildTableLoading((prev) => ({
  458. ...prev,
  459. [parentPositionAddress]: false,
  460. }))
  461. }
  462. }
  463. // 处理展开/收起
  464. const handleExpand = (expanded: boolean, record: TableData) => {
  465. const positionAddress = record.positionAddress as string
  466. if (expanded && positionAddress) {
  467. setExpandedRowKeys((prev) => [...prev, record.key])
  468. fetchChildData(positionAddress)
  469. } else {
  470. setExpandedRowKeys((prev) => prev.filter((key) => key !== record.key))
  471. }
  472. }
  473. const handleRefresh = () => {
  474. fetchData(1, 100, selectedPoolAddress, sortField)
  475. }
  476. // 子表格的列定义
  477. const childColumns = [
  478. {
  479. title: '创建地址',
  480. dataIndex: 'walletAddress',
  481. key: 'walletAddress',
  482. render: (text: string) => {
  483. return (
  484. <span className="font-bold text-base">
  485. {text.slice(0, 6)}...{text.slice(-4)}
  486. </span>
  487. )
  488. },
  489. },
  490. {
  491. title: 'Liquidity',
  492. dataIndex: 'liquidityUsd',
  493. key: 'liquidityUsd',
  494. render: (text: string) => {
  495. return (
  496. <span
  497. className="text-orange-500 font-bold text-base"
  498. style={{ color: '#00B098' }}
  499. >
  500. ${Number(text).toFixed(2)}
  501. </span>
  502. )
  503. },
  504. },
  505. {
  506. title: '复制数',
  507. dataIndex: 'copies',
  508. key: 'copies',
  509. render: (text: string) => {
  510. return (
  511. <span className="text-green-500 font-bold text-base">{text}</span>
  512. )
  513. },
  514. },
  515. {
  516. title: '奖励',
  517. dataIndex: 'bonusUsd',
  518. key: 'bonusUsd',
  519. render: (text: string) => {
  520. return (
  521. <span className="text-orange-500 font-bold text-base">
  522. ${Number(text).toFixed(2)}
  523. </span>
  524. )
  525. },
  526. },
  527. {
  528. title: 'FEE',
  529. dataIndex: 'earnedUsd',
  530. key: 'earnedUsd',
  531. render: (text: string) => {
  532. return (
  533. <span
  534. className="text-orange-500 font-bold text-base"
  535. style={{ color: Number(text) > 0 ? '#00B098' : '#FF0000' }}
  536. >
  537. ${Number(text).toFixed(2)}
  538. </span>
  539. )
  540. },
  541. },
  542. {
  543. title: 'PNL',
  544. dataIndex: 'pnlUsd',
  545. key: 'pnlUsd',
  546. render: (text: string) => {
  547. return (
  548. <span
  549. className="text-orange-500 font-bold text-base"
  550. style={{ color: Number(text) > 0 ? '#00B098' : '#FF0000' }}
  551. >
  552. ${Number(text).toFixed(2)}
  553. </span>
  554. )
  555. },
  556. },
  557. {
  558. title: '创建时间',
  559. dataIndex: 'positionAgeMs',
  560. key: 'positionAgeMs',
  561. render: (text: string) => {
  562. return (
  563. <span className="font-bold text-base">
  564. {Math.floor(Number(text) / 1000 / 60 / 60)} 小时
  565. </span>
  566. )
  567. },
  568. },
  569. {
  570. title: '状态',
  571. dataIndex: 'status',
  572. key: 'status',
  573. render: (status: number) => {
  574. return status === 0 ? (
  575. <Tag color="green">Active</Tag>
  576. ) : (
  577. <Tag color="red">Inactive</Tag>
  578. )
  579. },
  580. },
  581. {
  582. title: '操作',
  583. dataIndex: 'walletAddress',
  584. key: 'walletAddress',
  585. render: (text: string, record: TableData) => {
  586. return (
  587. <Typography.Link
  588. href={`https://www.byreal.io/en/portfolio?userAddress=${text}&tab=current&positionAddress=${record.positionAddress}`}
  589. target="_blank"
  590. >
  591. 复制
  592. </Typography.Link>
  593. )
  594. },
  595. },
  596. ]
  597. return (
  598. <div style={{ padding: '24px' }}>
  599. <div style={{ marginBottom: '16px' }}>
  600. <Select
  601. style={{ width: 300 }}
  602. placeholder="选择 Pool"
  603. value={selectedPoolAddress}
  604. onChange={handlePoolChange}
  605. options={poolOptions}
  606. showSearch
  607. filterOption={(input, option) =>
  608. (option?.label ?? '').toLowerCase().includes(input.toLowerCase())
  609. }
  610. />
  611. <Button type="primary" className="ml-2 mr-2" onClick={handleRefresh}>
  612. 刷新
  613. </Button>
  614. <span className="mr-2">快速复制金额($):</span>
  615. <InputNumber
  616. placeholder="快速复制金额($)"
  617. value={quickCopyAmount}
  618. min={0.01}
  619. step={0.1}
  620. onChange={(value) => setQuickCopyAmount(value as number)}
  621. />
  622. </div>
  623. <Table
  624. columns={columns}
  625. dataSource={data}
  626. loading={loading}
  627. pagination={{
  628. ...pagination,
  629. showSizeChanger: true,
  630. showTotal: (total) => `共 ${total} 条`,
  631. }}
  632. onChange={handleTableChange}
  633. scroll={{ x: 'max-content' }}
  634. rowClassName={(record: TableData) => {
  635. return Number(record.copies) === 0 ? 'bg-red-100' : ''
  636. }}
  637. expandable={{
  638. expandedRowKeys,
  639. onExpand: handleExpand,
  640. expandedRowRender: (record: TableData) => {
  641. const positionAddress = record.positionAddress as string
  642. const childData = childTableData[positionAddress] || []
  643. const isLoading = childTableLoading[positionAddress] || false
  644. return (
  645. <Table
  646. columns={childColumns}
  647. dataSource={childData}
  648. loading={isLoading}
  649. pagination={false}
  650. size="small"
  651. scroll={{ x: 'max-content' }}
  652. rowClassName={(childRecord: TableData) => {
  653. return Number(childRecord.copies) === 0 ? 'bg-red-100' : ''
  654. }}
  655. />
  656. )
  657. },
  658. }}
  659. />
  660. </div>
  661. )
  662. }
  663. export default function DataTable() {
  664. return <DataTableContent />
  665. }