Selaa lähdekoodia

feat(my-lp): 表格可展开展示 childPositionList,子表增加狙击列

- 表格支持 expand,展开显示 childPositionList 子表
- 子表字段:bonusUsd、持仓时长、liquidityUsd、pnlUsd、walletAddress
- 狙击列:金额输入框(默认 liquidityUsd)、1-5 倍滑块、狙击图标
- 点击狙击调用加仓 API,金额为输入框值×倍数

Co-authored-by: Cursor <cursoragent@cursor.com>
lushdog@outlook.com 1 kuukausi sitten
vanhempi
commit
90f73c5855
2 muutettua tiedostoa jossa 217 lisäystä ja 24 poistoa
  1. 209 1
      src/app/my-lp/page.tsx
  2. 8 23
      src/lib/jupiter.ts

+ 209 - 1
src/app/my-lp/page.tsx

@@ -14,11 +14,13 @@ import {
 	InputNumber,
 	Select,
 	Tooltip,
+	Slider,
 } from 'antd'
 import {
 	ExportOutlined,
 	CloseCircleOutlined,
 	PlusCircleOutlined,
+	AimOutlined,
 } from '@ant-design/icons'
 import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'
 import dayjs from 'dayjs'
@@ -63,6 +65,8 @@ interface RecordInfo {
 	isParentPositionClosed?: boolean
 	parentLiquidityUsd?: string
 	bonusUsd?: string
+	positionAgeMs?: number
+	childPositionList?: RecordInfo[]
 	bonusInfo?: {
 		fromCreatorWallet: string
 		fromCreatorPositionStatus: number
@@ -88,6 +92,9 @@ function MyLPPageContent() {
 	const [tokenAddress, setTokenAddress] = useState<string | undefined>(
 		undefined
 	)
+	const [snipeChildState, setSnipeChildState] = useState<
+		Record<string, { amount: number; multiplier: number }>
+	>({})
 
 	const fetchLPDetail = async (positions: RecordInfo[]) => {
 		message.loading(`正在查询当前页面仓位详细信息,请稍等...`)
@@ -110,6 +117,7 @@ function MyLPPageContent() {
 					let allCopys = 0
 					let isParentPositionClosed = false
 					let parentLiquidityUsd = 0
+					let childPositionList: RecordInfo[] = []
 
 					if (bonusInfo?.fromCreatorPosition) {
 						const copyInfo = await Promise.all([
@@ -124,6 +132,7 @@ function MyLPPageContent() {
 							copyInfo.map((res) => res.json())
 						)
 						if (copyInfoData.retCode === 0 && copyInfoData.result) {
+							childPositionList = copyInfoData.result.data.records
 							allCopys = copyInfoData.result.data.total
 							allCopyAmount = copyInfoData.result.data.records
 								.filter((item: RecordInfo) => item.status === 0)
@@ -138,6 +147,7 @@ function MyLPPageContent() {
 					}
 					const newData = {
 						...data.result.data,
+						childPositionList,
 						isParentPositionClosed,
 						parentLiquidityUsd,
 						allCopyAmount,
@@ -350,6 +360,39 @@ function MyLPPageContent() {
 		})
 	}
 
+	function handleSnipeAddPosition(record: RecordInfo, addUsdValue: number) {
+		if (!addUsdValue || addUsdValue <= 0) {
+			message.warning('请输入有效加仓金额')
+			return
+		}
+		message.loading({
+			key: 'snipeAdd',
+			content: '正在狙击加仓...',
+			duration: 0,
+		})
+		fetch('/api/lp-index/lp-add', {
+			method: 'POST',
+			body: JSON.stringify({
+				nftMintAddress: record.nftMintAddress,
+				addUsdValue,
+			}),
+		})
+			.then((res) => res.json())
+			.then((data) => {
+				message.destroy('snipeAdd')
+				if (data.success) {
+					message.success('加仓成功')
+				} else {
+					message.error(data.error || '加仓失败')
+				}
+			})
+			.catch((err) => {
+				console.error('Error snipe adding position:', err)
+				message.destroy('snipeAdd')
+				message.error('加仓失败')
+			})
+	}
+
 	function getColor(text: string, allCopyAmount: string) {
 		const percentage = (Number(text) / Number(allCopyAmount)) * 100
 		if (percentage > 50) return 'green'
@@ -357,6 +400,151 @@ function MyLPPageContent() {
 		else return 'red'
 	}
 
+	function formatPositionAgeMs(ms: number | undefined, openTime?: number): string {
+		if (ms != null && !Number.isNaN(ms)) {
+			const days = Math.floor(ms / (24 * 60 * 60 * 1000))
+			const hours = Math.floor((ms % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000))
+			const mins = Math.floor((ms % (60 * 60 * 1000)) / (60 * 1000))
+			const parts = []
+			if (days > 0) parts.push(`${days}天`)
+			if (hours > 0 || days > 0) parts.push(`${hours}小时`)
+			parts.push(`${mins}分钟`)
+			return parts.join('/')
+		}
+		if (openTime != null) {
+			const open = dayjs(openTime)
+			const days = dayjs().diff(open, 'day')
+			const hours = dayjs().diff(open, 'hour') % 24
+			const minutes = dayjs().diff(open, 'minute') % 60
+			const parts = []
+			if (days > 0) parts.push(`${days}天/`)
+			if (hours > 0 || days > 0) parts.push(`${hours}小时/`)
+			parts.push(`${minutes}分钟`)
+			return parts.join('')
+		}
+		return '-'
+	}
+
+	const childColumns: ColumnsType<RecordInfo> = [
+		{
+			title: 'Bonus USD',
+			dataIndex: 'bonusUsd',
+			key: 'bonusUsd',
+			render: (text: string) => (
+				<span className="font-mono text-sm text-orange-500">
+					${Number(text ?? 0).toFixed(2)}
+				</span>
+			),
+		},
+		{
+			title: '持仓时长',
+			dataIndex: 'positionAgeMs',
+			key: 'positionAgeMs',
+			render: (_: unknown, record: RecordInfo) => (
+				<span className="font-mono text-sm">
+					{formatPositionAgeMs(record.positionAgeMs, record.openTime)}
+				</span>
+			),
+		},
+		{
+			title: 'Liquidity USD',
+			dataIndex: 'liquidityUsd',
+			key: 'liquidityUsd',
+			render: (text: string) => (
+				<span className="font-mono text-sm text-orange-500">
+					${Number(text ?? 0).toFixed(2)}
+				</span>
+			),
+		},
+		{
+			title: 'PNL USD',
+			dataIndex: 'pnlUsd',
+			key: 'pnlUsd',
+			render: (text: string) => (
+				<span
+					className="font-mono text-sm"
+					style={{ color: Number(text ?? 0) > 0 ? '#00B098' : '#FF0000' }}
+				>
+					${Number(text ?? 0).toFixed(2)}
+				</span>
+			),
+		},
+		{
+			title: 'Wallet Address',
+			dataIndex: 'walletAddress',
+			key: 'walletAddress',
+			render: (text: string, record: RecordInfo) => (
+				<span className="font-mono text-sm">
+					<Typography.Link
+						href={`https://www.byreal.io/en/portfolio?userAddress=${record.walletAddress}&tab=current&positionAddress=${record.positionAddress}&tokenAddress=${getPoolInfo(record.poolAddress).tokenAAddress}&tokenAddress=${getPoolInfo(record.poolAddress).tokenBAddress}`}
+						target="_blank"
+					>
+						{String(text ?? '').slice(0, 6)}...{String(text ?? '').slice(-4)}
+					</Typography.Link>
+				</span>
+			),
+		},
+		{
+			title: '狙击',
+			key: 'snipe',
+			width: 280,
+			render: (_: unknown, record: RecordInfo) => {
+				const key = record.positionAddress
+				const baseAmount = Number(record.liquidityUsd) || 0
+				const amount = snipeChildState[key]?.amount ?? baseAmount
+				const multiplier = snipeChildState[key]?.multiplier ?? 1
+				const finalAmount = amount * multiplier
+				return (
+					<div className="flex items-center gap-2 flex-wrap">
+						<InputNumber
+							size="small"
+							className="font-mono w-24"
+							min={0}
+							step={0.01}
+							value={amount}
+							onChange={(val) =>
+								setSnipeChildState((prev) => ({
+									...prev,
+									[key]: {
+										amount: Number(val) ?? 0,
+										multiplier: prev[key]?.multiplier ?? 1,
+									},
+								}))
+							}
+						/>
+						<Slider
+							className="w-20! m-0! shrink-0"
+							min={1}
+							max={5}
+							step={1}
+							value={multiplier}
+							onChange={(val) =>
+								setSnipeChildState((prev) => ({
+									...prev,
+									[key]: {
+										amount: prev[key]?.amount ?? baseAmount,
+										multiplier: val ?? 1,
+									},
+								}))
+							}
+						/>
+						<span className="font-mono text-xs text-gray-500 w-6">
+							×{multiplier}
+						</span>
+						<Tooltip title="狙击">
+							<Typography.Link
+								onClick={() => handleSnipeAddPosition(record, finalAmount)}
+								className="inline-flex items-center"
+							>
+								<AimOutlined style={{ fontSize: '18px', color: '#fa8c16' }} />
+							</Typography.Link>
+						</Tooltip>
+					</div>
+				)
+			},
+		},
+	]
+
 	const columns: ColumnsType<RecordInfo> = [
 		{
 			title: 'LP Token',
@@ -588,7 +776,7 @@ function MyLPPageContent() {
 		{
 			title: '仓位来源',
 			dataIndex: 'bonusInfo',
-			width: 120,
+			width: 140,
 			fixed: 'right',
 			key: 'bonusInfoSource',
 			render: (text: string, record: RecordInfo) => (
@@ -892,6 +1080,26 @@ function MyLPPageContent() {
 						rowClassName={(record: RecordInfo) => {
 							return record?.hasDetail ? '' : 'bg-green-100'
 						}}
+						expandable={{
+							expandedRowRender: (record: RecordInfo) => {
+								const list = record.childPositionList ?? []
+								if (list.length === 0) return null
+								return (
+									<div className="bg-gray-50/80 py-2 pr-4">
+										<Table
+											size="small"
+											columns={childColumns}
+											dataSource={list}
+											rowKey="positionAddress"
+											pagination={false}
+											scroll={{ x: 'max-content' }}
+										/>
+									</div>
+								)
+							},
+							rowExpandable: (record: RecordInfo) =>
+								(record.childPositionList?.length ?? 0) > 0,
+						}}
 						rowSelection={{
 							selectedRowKeys,
 							onChange: (newSelectedRowKeys) => {

+ 8 - 23
src/lib/jupiter.ts

@@ -5,11 +5,11 @@ import {
 	VersionedTransaction,
 } from '@solana/web3.js'
 import ky from 'ky'
+import { Token } from '@/lib/byreal-clmm-sdk/src/client/token'
 
 // USDC 和 USDT 作为默认的输入代币(用于兑换)
 const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
 const USDT_MINT = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'
-const SOL_MINT = 'So11111111111111111111111111111111111111112'
 
 /** 稳定币 mint:用 USDC 换这些币时无需 swap */
 const STABLECOIN_MINTS = [
@@ -39,7 +39,8 @@ export interface JupiterSwapResponse {
 }
 
 /**
- * 获取代币余额(汇总该 mint 在所有代币账户中的余额,不限于 ATA)
+ * 获取代币余额(兼容 Token-2022 和标准 SPL Token)
+ * 使用 Token.detectTokenTypeAndGetBalance,避免 Token-2022 代币查询返回 0
  */
 export async function getTokenBalance(
 	connection: Connection,
@@ -47,28 +48,12 @@ export async function getTokenBalance(
 	mintAddress: string
 ): Promise<number> {
 	try {
-		// SOL 余额
-		if (mintAddress === SOL_MINT) {
-			const balance = await connection.getBalance(walletAddress)
-			return balance / 1e9
-		}
-
-		// SPL Token:用 getTokenAccountsByOwner 按 mint 筛选,汇总所有账户余额
-		// 避免只查 ATA 导致 pump 等代币在其它账户时被误判为 0
-		const accounts = await connection.getParsedTokenAccountsByOwner(
-			walletAddress,
-			{ mint: new PublicKey(mintAddress) }
+		const token = new Token(connection)
+		const result = await token.detectTokenTypeAndGetBalance(
+			walletAddress.toBase58(),
+			mintAddress
 		)
-
-		let total = 0
-		for (const item of accounts.value) {
-			const parsed = (item.account?.data as { parsed?: { info?: { tokenAmount?: { uiAmount?: number | null } } } })?.parsed
-			const ui = parsed?.info?.tokenAmount?.uiAmount
-			if (ui != null && typeof ui === 'number') {
-				total += ui
-			}
-		}
-		return total
+		return result.balance
 	} catch (error) {
 		console.error(`Error getting balance for ${mintAddress}:`, error)
 		return 0