mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
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:
+179
-26
@@ -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 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<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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user