From 5534861fe51feb0afb232bcde12c9447a19638e8 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Fri, 19 Dec 2025 18:38:21 +0800 Subject: [PATCH] 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 --- web/src/App.tsx | 205 +++++++++++++++++++++++---- web/src/components/AITradersPage.tsx | 124 +++++++++++++++- 2 files changed, 302 insertions(+), 27 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index cbf4d1b3..f73be97b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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(getInitialPage()) + // 从 URL 参数读取初始 trader 标识(格式: name-id前4位) + const [selectedTraderSlug, setSelectedTraderSlug] = useState(() => { + const params = new URLSearchParams(window.location.search) + return params.get('trader') || undefined + }) const [selectedTraderId, setSelectedTraderId] = useState() + + // 生成 trader URL slug(name + 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('--:--:--') const [decisionsLimit, setDecisionsLimit] = useState(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( @@ -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(0) const chartSectionRef = useRef(null) + const [showWalletAddress, setShowWalletAddress] = useState(false) + const [copiedAddress, setCopiedAddress] = useState(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} - {/* Trader Selector */} - {traders && traders.length > 0 && ( -
- - {t('switchTrader', language)}: - - 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) => ( + + ))} + +
+ )} + + {/* Wallet Address Display for Perp-DEX */} + {exchanges && isPerpDex && ( +
- {traders.map((trader) => ( - - ))} - -
- )} + {walletAddress ? ( + <> + + {showWalletAddress + ? walletAddress + : truncateAddress(walletAddress)} + + + + + ) : ( + + {language === 'zh' ? '未配置地址' : 'No address configured'} + + )} + + )} +
([]) const [supportedModels, setSupportedModels] = useState([]) const [supportedExchanges, setSupportedExchanges] = useState([]) + const [visibleAddresses, setVisibleAddresses] = useState>(new Set()) + const [copiedAddressId, setCopiedAddressId] = useState(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( user && token ? 'traders' : null, @@ -1047,6 +1104,69 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
+ {/* 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 ( +
+ {walletAddr ? ( + <> + + {isVisible ? walletAddr : truncateAddress(walletAddr)} + + + + + ) : ( + + {language === 'zh' ? '未配置地址' : 'No address'} + + )} +
+ ) + })()} +
{/* Status */}
@@ -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"