feat: display wallet address for perp-dex exchanges with visibility toggle

- Add wallet address display for perp-dex exchanges (hyperliquid, lighter, aster)
- Add eye icon to toggle address visibility (truncated/full)
- Add copy to clipboard functionality
- Fix dashboard refresh losing selected trader
- Use URL slug format (name-id4) for trader identification
This commit is contained in:
tinkle-community
2025-12-19 18:38:21 +08:00
parent 97f58b49f4
commit 5534861fe5
2 changed files with 302 additions and 27 deletions
+179 -26
View File
@@ -22,7 +22,7 @@ import { DecisionCard } from './components/DecisionCard'
import { PunkAvatar, getTraderAvatar } from './components/PunkAvatar'
import { OFFICIAL_LINKS } from './constants/branding'
import { BacktestPage } from './components/BacktestPage'
import { LogOut, Loader2 } from 'lucide-react'
import { LogOut, Loader2, Eye, EyeOff, Copy, Check } from 'lucide-react'
import type {
SystemStatus,
AccountInfo,
@@ -83,6 +83,35 @@ function getExchangeTypeFromList(
return exchange.exchange_type?.toUpperCase() || 'BINANCE'
}
// Helper function to check if exchange is a perp-dex type (wallet-based)
function isPerpDexExchange(exchangeType: string | undefined): boolean {
if (!exchangeType) return false
const perpDexTypes = ['hyperliquid', 'lighter', 'aster']
return perpDexTypes.includes(exchangeType.toLowerCase())
}
// Helper function to get wallet address for perp-dex exchanges
function getWalletAddress(exchange: Exchange | undefined): string | undefined {
if (!exchange) return undefined
const type = exchange.exchange_type?.toLowerCase()
switch (type) {
case 'hyperliquid':
return exchange.hyperliquidWalletAddr
case 'lighter':
return exchange.lighterWalletAddr
case 'aster':
return exchange.asterSigner
default:
return undefined
}
}
// Helper function to truncate wallet address for display
function truncateAddress(address: string, startLen = 6, endLen = 4): string {
if (address.length <= startLen + endLen + 3) return address
return `${address.slice(0, startLen)}...${address.slice(-endLen)}`
}
function App() {
const { language, setLanguage } = useLanguage()
const { user, token, logout, isLoading } = useAuth()
@@ -104,7 +133,33 @@ function App() {
}
const [currentPage, setCurrentPage] = useState<Page>(getInitialPage())
// 从 URL 参数读取初始 trader 标识(格式: name-id前4位)
const [selectedTraderSlug, setSelectedTraderSlug] = useState<string | undefined>(() => {
const params = new URLSearchParams(window.location.search)
return params.get('trader') || undefined
})
const [selectedTraderId, setSelectedTraderId] = useState<string | undefined>()
// 生成 trader URL slugname + ID 前 4 位)
const getTraderSlug = (trader: TraderInfo) => {
const idPrefix = trader.trader_id.slice(0, 4)
return `${trader.trader_name}-${idPrefix}`
}
// 从 slug 解析并匹配 trader
const findTraderBySlug = (slug: string, traderList: TraderInfo[]) => {
// slug 格式: name-xxxx (xxxx 是 ID 前 4 位)
const lastDashIndex = slug.lastIndexOf('-')
if (lastDashIndex === -1) {
// 没有 dash,直接按 name 匹配
return traderList.find(t => t.trader_name === slug)
}
const name = slug.slice(0, lastDashIndex)
const idPrefix = slug.slice(lastDashIndex + 1)
return traderList.find(t =>
t.trader_name === name && t.trader_id.startsWith(idPrefix)
)
}
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--')
const [decisionsLimit, setDecisionsLimit] = useState<number>(5)
@@ -113,6 +168,8 @@ function App() {
const handleRouteChange = () => {
const path = window.location.pathname
const hash = window.location.hash.slice(1)
const params = new URLSearchParams(window.location.search)
const traderParam = params.get('trader')
if (path === '/traders' || hash === 'traders') {
setCurrentPage('traders')
@@ -128,6 +185,10 @@ function App() {
hash === 'details'
) {
setCurrentPage('trader')
// 如果 URL 中有 trader 参数(slug 格式),更新选中的 trader
if (traderParam) {
setSelectedTraderSlug(traderParam)
}
} else if (
path === '/competition' ||
hash === 'competition' ||
@@ -172,12 +233,23 @@ function App() {
}
)
// 当获取到traders后,设置默认选中第一个
// 当获取到traders后,根据 URL 中的 trader slug 设置选中的 trader,或默认选中第一个
useEffect(() => {
if (traders && traders.length > 0 && !selectedTraderId) {
setSelectedTraderId(traders[0].trader_id)
if (selectedTraderSlug) {
// 通过 slug 找到对应的 trader
const trader = findTraderBySlug(selectedTraderSlug, traders)
if (trader) {
setSelectedTraderId(trader.trader_id)
} else {
// 如果找不到,选中第一个
setSelectedTraderId(traders[0].trader_id)
}
} else {
setSelectedTraderId(traders[0].trader_id)
}
}
}, [traders, selectedTraderId])
}, [traders, selectedTraderId, selectedTraderSlug])
// 如果在trader页面,获取该trader的数据
const { data: status } = useSWR<SystemStatus>(
@@ -545,7 +617,16 @@ function App() {
traders={traders}
tradersError={tradersError}
selectedTraderId={selectedTraderId}
onTraderSelect={setSelectedTraderId}
onTraderSelect={(traderId) => {
setSelectedTraderId(traderId)
// 更新 URL 参数(使用 slug: name-id前4位)
const trader = traders?.find(t => t.trader_id === traderId)
if (trader) {
const url = new URL(window.location.href)
url.searchParams.set('trader', getTraderSlug(trader))
window.history.replaceState({}, '', url.toString())
}
}}
onNavigateToTraders={() => {
window.history.pushState({}, '', '/traders')
setRoute('/traders')
@@ -714,6 +795,27 @@ function TraderDetailsPage({
>(undefined)
const [chartUpdateKey, setChartUpdateKey] = useState<number>(0)
const chartSectionRef = useRef<HTMLDivElement>(null)
const [showWalletAddress, setShowWalletAddress] = useState<boolean>(false)
const [copiedAddress, setCopiedAddress] = useState<boolean>(false)
// Get current exchange info for perp-dex wallet display
const currentExchange = exchanges?.find(
(e) => e.id === selectedTrader?.exchange_id
)
const walletAddress = getWalletAddress(currentExchange)
const isPerpDex = isPerpDexExchange(currentExchange?.exchange_type)
// Copy wallet address to clipboard
const handleCopyAddress = async () => {
if (!walletAddress) return
try {
await navigator.clipboard.writeText(walletAddress)
setCopiedAddress(true)
setTimeout(() => setCopiedAddress(false), 2000)
} catch (err) {
console.error('Failed to copy address:', err)
}
}
// 平仓操作
const handleClosePosition = async (symbol: string, side: string) => {
@@ -924,30 +1026,81 @@ function TraderDetailsPage({
{selectedTrader.trader_name}
</h2>
{/* Trader Selector */}
{traders && traders.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm" style={{ color: '#848E9C' }}>
{t('switchTrader', language)}:
</span>
<select
value={selectedTraderId}
onChange={(e) => onTraderSelect(e.target.value)}
className="rounded px-3 py-2 text-sm font-medium cursor-pointer transition-colors"
<div className="flex items-center gap-4">
{/* Trader Selector */}
{traders && traders.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm" style={{ color: '#848E9C' }}>
{t('switchTrader', language)}:
</span>
<select
value={selectedTraderId}
onChange={(e) => onTraderSelect(e.target.value)}
className="rounded px-3 py-2 text-sm font-medium cursor-pointer transition-colors"
style={{
background: '#1E2329',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
>
{traders.map((trader) => (
<option key={trader.trader_id} value={trader.trader_id}>
{trader.trader_name}
</option>
))}
</select>
</div>
)}
{/* Wallet Address Display for Perp-DEX */}
{exchanges && isPerpDex && (
<div
className="flex items-center gap-2 px-3 py-2 rounded"
style={{
background: '#1E2329',
border: '1px solid #2B3139',
color: '#EAECEF',
background: 'rgba(240, 185, 11, 0.1)',
border: '1px solid rgba(240, 185, 11, 0.3)',
}}
>
{traders.map((trader) => (
<option key={trader.trader_id} value={trader.trader_id}>
{trader.trader_name}
</option>
))}
</select>
</div>
)}
{walletAddress ? (
<>
<span className="text-xs font-mono" style={{ color: '#F0B90B' }}>
{showWalletAddress
? walletAddress
: truncateAddress(walletAddress)}
</span>
<button
type="button"
onClick={() => setShowWalletAddress(!showWalletAddress)}
className="p-1 rounded hover:bg-gray-700 transition-colors"
title={showWalletAddress ? (language === 'zh' ? '隐藏地址' : 'Hide address') : (language === 'zh' ? '显示完整地址' : 'Show full address')}
>
{showWalletAddress ? (
<EyeOff className="w-3.5 h-3.5" style={{ color: '#848E9C' }} />
) : (
<Eye className="w-3.5 h-3.5" style={{ color: '#848E9C' }} />
)}
</button>
<button
type="button"
onClick={handleCopyAddress}
className="p-1 rounded hover:bg-gray-700 transition-colors"
title={language === 'zh' ? '复制地址' : 'Copy address'}
>
{copiedAddress ? (
<Check className="w-3.5 h-3.5" style={{ color: '#0ECB81' }} />
) : (
<Copy className="w-3.5 h-3.5" style={{ color: '#848E9C' }} />
)}
</button>
</>
) : (
<span className="text-xs" style={{ color: '#848E9C' }}>
{language === 'zh' ? '未配置地址' : 'No address configured'}
</span>
)}
</div>
)}
</div>
</div>
<div
className="flex items-center gap-4 text-sm flex-wrap"
+123 -1
View File
@@ -28,6 +28,8 @@ import {
Eye,
EyeOff,
ExternalLink,
Copy,
Check,
} from 'lucide-react'
import { confirmToast } from '../lib/notify'
import { toast } from 'sonner'
@@ -108,6 +110,35 @@ function getExchangeDisplayName(exchangeId: string | undefined, exchanges: Excha
return exchange.account_name ? `${typeName} - ${exchange.account_name}` : typeName
}
// Helper function to check if exchange is a perp-dex type (wallet-based)
function isPerpDexExchange(exchangeType: string | undefined): boolean {
if (!exchangeType) return false
const perpDexTypes = ['hyperliquid', 'lighter', 'aster']
return perpDexTypes.includes(exchangeType.toLowerCase())
}
// Helper function to get wallet address for perp-dex exchanges
function getWalletAddress(exchange: Exchange | undefined): string | undefined {
if (!exchange) return undefined
const type = exchange.exchange_type?.toLowerCase()
switch (type) {
case 'hyperliquid':
return exchange.hyperliquidWalletAddr
case 'lighter':
return exchange.lighterWalletAddr
case 'aster':
return exchange.asterSigner
default:
return undefined
}
}
// Helper function to truncate wallet address for display
function truncateAddress(address: string, startLen = 6, endLen = 4): string {
if (address.length <= startLen + endLen + 3) return address
return `${address.slice(0, startLen)}...${address.slice(-endLen)}`
}
export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const { language } = useLanguage()
const { user, token } = useAuth()
@@ -123,6 +154,32 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const [allExchanges, setAllExchanges] = useState<Exchange[]>([])
const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
const [supportedExchanges, setSupportedExchanges] = useState<Exchange[]>([])
const [visibleAddresses, setVisibleAddresses] = useState<Set<string>>(new Set())
const [copiedAddressId, setCopiedAddressId] = useState<string | null>(null)
// Toggle wallet address visibility for a trader
const toggleAddressVisibility = (traderId: string) => {
setVisibleAddresses(prev => {
const next = new Set(prev)
if (next.has(traderId)) {
next.delete(traderId)
} else {
next.add(traderId)
}
return next
})
}
// Copy wallet address to clipboard
const handleCopyAddress = async (traderId: string, address: string) => {
try {
await navigator.clipboard.writeText(address)
setCopiedAddressId(traderId)
setTimeout(() => setCopiedAddressId(null), 2000)
} catch (err) {
console.error('Failed to copy address:', err)
}
}
const { data: traders, mutate: mutateTraders, isLoading: isTradersLoading } = useSWR<TraderInfo[]>(
user && token ? 'traders' : null,
@@ -1047,6 +1104,69 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
</div>
</div>
{/* Wallet Address for Perp-DEX */}
{(() => {
const exchange = allExchanges.find(e => e.id === trader.exchange_id)
const walletAddr = getWalletAddress(exchange)
const isPerpDex = isPerpDexExchange(exchange?.exchange_type)
if (!isPerpDex) return null
const isVisible = visibleAddresses.has(trader.trader_id)
const isCopied = copiedAddressId === trader.trader_id
return (
<div
className="flex items-center gap-2 px-3 py-1.5 rounded flex-shrink-0"
style={{
background: 'rgba(240, 185, 11, 0.1)',
border: '1px solid rgba(240, 185, 11, 0.3)',
}}
>
{walletAddr ? (
<>
<span className="text-xs font-mono" style={{ color: '#F0B90B' }}>
{isVisible ? walletAddr : truncateAddress(walletAddr)}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleAddressVisibility(trader.trader_id)
}}
className="p-1 rounded hover:bg-gray-700 transition-colors"
title={isVisible ? (language === 'zh' ? '隐藏地址' : 'Hide address') : (language === 'zh' ? '显示完整地址' : 'Show full address')}
>
{isVisible ? (
<EyeOff className="w-3.5 h-3.5" style={{ color: '#848E9C' }} />
) : (
<Eye className="w-3.5 h-3.5" style={{ color: '#848E9C' }} />
)}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleCopyAddress(trader.trader_id, walletAddr)
}}
className="p-1 rounded hover:bg-gray-700 transition-colors"
title={language === 'zh' ? '复制地址' : 'Copy address'}
>
{isCopied ? (
<Check className="w-3.5 h-3.5" style={{ color: '#0ECB81' }} />
) : (
<Copy className="w-3.5 h-3.5" style={{ color: '#848E9C' }} />
)}
</button>
</>
) : (
<span className="text-xs" style={{ color: '#848E9C' }}>
{language === 'zh' ? '未配置地址' : 'No address'}
</span>
)}
</div>
)
})()}
<div className="flex items-center gap-3 md:gap-4 flex-wrap md:flex-nowrap">
{/* Status */}
<div className="text-center">
@@ -1084,7 +1204,9 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
if (onTraderSelect) {
onTraderSelect(trader.trader_id)
} else {
navigate(`/dashboard?trader=${trader.trader_id}`)
// 使用 slug 格式: name-id前4位
const slug = `${trader.trader_name}-${trader.trader_id.slice(0, 4)}`
navigate(`/dashboard?trader=${encodeURIComponent(slug)}`)
}
}}
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 flex items-center gap-1 whitespace-nowrap"