Compare commits

...

2 Commits

6 changed files with 261 additions and 16 deletions

View File

@ -1,7 +1,7 @@
import { i18n } from '@/plugins/i18n'
/** 请求基础 URL默认 https://api.xtrader.vip可通过环境变量 VITE_API_BASE_URL 覆盖 */
const BASE_URL =
export const BASE_URL =
(import.meta as { env?: { VITE_API_BASE_URL?: string } }).env?.VITE_API_BASE_URL ??
'https://api.xtrader.vip'

View File

@ -1,4 +1,4 @@
import { get } from './request'
import { get, post } from './request'
export interface DepositAddressData {
address?: string
@ -30,6 +30,17 @@ function pickAddress(d: DepositAddressData | undefined): string | undefined {
)
}
export interface CollectDepositParams {
userId: number
}
export async function collectDeposit(
data: CollectDepositParams,
config?: { headers?: Record<string, string> },
): Promise<{ code: number; msg?: string }> {
return post<{ code: number; msg?: string }>('/wallet/collect', data, config)
}
export async function getDepositAddress(
params: GetDepositAddressParams,
config?: { headers?: Record<string, string> },

View File

@ -75,7 +75,7 @@
<div class="address-box">
<label class="address-label">{{ t('deposit.depositAddress') }}</label>
<div class="address-row">
<code class="address-code">{{ depositAddressShort }}</code>
<code class="address-code">{{ depositAddress }}</code>
<v-btn size="small" variant="tonal" @click="copyAddress">
{{ copied ? t('deposit.copied') : t('deposit.copy') }}
</v-btn>
@ -84,6 +84,27 @@
<img :src="qrCodeUrl" alt="QR Code" class="qr-img" width="120" height="120" />
</div>
</div>
<div class="check-deposit-section mt-4">
<v-btn
block
color="primary"
variant="flat"
:loading="isPolling && checkStatus === 'checking' && nextCheckCountdown <= 0"
:disabled="isPolling"
@click="startDepositCheck"
>
{{ isPolling ? t('deposit.checking') : t('deposit.checkStatus') }}
</v-btn>
<div v-if="isPolling || checkStatus !== 'idle'" class="check-status-msg mt-2 text-center text-caption">
<template v-if="checkStatus === 'checking'">
<span v-if="nextCheckCountdown > 0">{{ t('deposit.nextCheckIn', { seconds: nextCheckCountdown }) }}</span>
<span v-else>{{ t('deposit.checkingNow') }}</span>
</template>
<span v-else-if="checkStatus === 'success'" class="text-success">{{ t('deposit.checkSuccess') }}</span>
<span v-else-if="checkStatus === 'timeout'" class="text-warning">{{ t('deposit.checkTimeout') }}</span>
<span v-else-if="checkStatus === 'error'" class="text-error">{{ checkMessage }}</span>
</div>
</div>
</template>
<!-- Connect Exchange连接钱包后显示地址 -->
@ -128,7 +149,7 @@
<div class="address-box">
<label class="address-label">{{ t('deposit.depositAddress') }}</label>
<div class="address-row">
<code class="address-code">{{ depositAddressShort }}</code>
<code class="address-code">{{ depositAddress }}</code>
<v-btn size="small" variant="tonal" @click="copyAddress">
{{ copied ? t('deposit.copied') : t('deposit.copy') }}
</v-btn>
@ -145,10 +166,12 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { getDepositAddress } from '@/api/wallet'
import { getDepositAddress, collectDeposit } from '@/api/wallet'
import { useUserStore } from '@/stores/user'
import { ethers } from 'ethers'
import { USDC_ADDRESS_BY_CHAIN } from '@/api/pmset'
const { t } = useI18n()
const props = withDefaults(
@ -178,10 +201,191 @@ const networks = [
{ id: 'optimism', label: 'Optimism' },
]
const depositAddressShort = computed(() => {
const a = depositAddress.value
return a ? `${a.slice(0, 6)}...${a.slice(-4)}` : ''
})
const RPC_URLS: Record<string, string> = {
ethereum: 'https://ethereum-rpc.publicnode.com',
polygon: 'https://polygon-bor-rpc.publicnode.com',
arbitrum: 'https://arbitrum-one-rpc.publicnode.com',
optimism: 'https://optimism-rpc.publicnode.com',
}
const CHAIN_IDS: Record<string, number> = {
ethereum: 1,
polygon: 137,
arbitrum: 42161,
optimism: 10,
}
const isPolling = ref(false)
const checkStatus = ref<'idle' | 'checking' | 'success' | 'timeout' | 'error'>('idle')
const checkMessage = ref('')
const nextCheckTime = ref(0)
let pollTimer: ReturnType<typeof setTimeout> | null = null
let countdownTimer: ReturnType<typeof setInterval> | null = null
const POLL_INTERVAL = 60000 // 60 seconds
const MAX_POLL_TIME = 30 * 60 * 1000 // 30 minutes
let initialBalance = BigInt(0)
let startTime = 0
const nextCheckCountdown = ref(0)
function stopDepositCheck() {
isPolling.value = false
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
checkStatus.value = 'idle'
checkMessage.value = ''
}
async function startDepositCheck() {
if (!depositAddress.value || !selectedNetwork.value) return
stopDepositCheck()
isPolling.value = true
checkStatus.value = 'checking'
checkMessage.value = t('deposit.checkingStatus')
startTime = Date.now()
initialBalance = BigInt(0) // Reset initial balance assumption or fetch it first?
// We should probably fetch initial balance first, but for now assuming user deposits > 0 into a fresh-ish account or just checking for > 0 if it's a unique deposit address.
// Ideally we check current balance and see if it increases.
// For simplicity based on prompt "check for balance change", let's fetch current first.
await checkBalance(true)
}
async function checkBalance(isFirst = false) {
if (!isPolling.value) return
try {
const rpcUrl = RPC_URLS[selectedNetwork.value]
const usdcAddr = USDC_ADDRESS_BY_CHAIN[selectedNetwork.value]
if (!rpcUrl || !usdcAddr) {
checkStatus.value = 'error'
checkMessage.value = 'Network configuration error'
isPolling.value = false
return
}
const provider = new ethers.JsonRpcProvider(rpcUrl, CHAIN_IDS[selectedNetwork.value], {
staticNetwork: true,
})
let targetAddr = depositAddress.value
try {
targetAddr = ethers.getAddress(targetAddr)
} catch (e) {
console.warn('Invalid address format', targetAddr)
}
// Prepare contract calls
// For Polygon, check both Bridged USDC (USDC.e) and Native USDC
const contractsToCheck: string[] = [usdcAddr]
if (selectedNetwork.value === 'polygon') {
// Add Polygon Native USDC
contractsToCheck.push('0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359')
}
let totalBalance = BigInt(0)
for (const addr of contractsToCheck) {
try {
const contract = new ethers.Contract(addr, ['function balanceOf(address) view returns (uint256)'], provider)
const bal = await contract.balanceOf(targetAddr)
totalBalance += bal
} catch (err) {
console.warn(`Failed to check balance for token ${addr}`, err)
}
}
const balance = totalBalance
if (isFirst) {
initialBalance = balance
// If first check, we just record baseline.
// Wait, if user already deposited before clicking check?
// If balance > 0 on first check, maybe we should just try to collect?
// Let's assume if balance > 0 we try to collect.
if (balance > BigInt(0)) {
await triggerCollect()
return
}
} else {
if (balance > initialBalance || (initialBalance === BigInt(0) && balance > BigInt(0))) {
await triggerCollect()
return
}
}
// Continue polling
if (Date.now() - startTime > MAX_POLL_TIME) {
checkStatus.value = 'timeout'
isPolling.value = false
return
}
scheduleNextPoll()
} catch (e) {
console.error('Balance check failed:', e)
// Don't stop polling on transient error, just retry
scheduleNextPoll()
}
}
async function triggerCollect() {
try {
const uid = userStore.user?.id ?? userStore.user?.ID
if (!uid) throw new Error('User not logged in')
const res = await collectDeposit({ userId: Number(uid) }, { headers: userStore.getAuthHeaders() || {} })
if (res.code === 0 || res.code === 200) {
checkStatus.value = 'success'
isPolling.value = false
// Optionally refresh balance
userStore.fetchUsdcBalance()
} else {
// If collection failed but balance is there, maybe retry or show error?
// Show error but keep polling? No, better stop to avoid spamming if backend is broken.
checkStatus.value = 'error'
checkMessage.value = res.msg || 'Collection failed'
isPolling.value = false
}
} catch (e) {
console.error('Collect request error:', e)
checkStatus.value = 'error'
checkMessage.value = e instanceof Error ? e.message : 'Collection request failed'
isPolling.value = false
}
}
function scheduleNextPoll() {
if (!isPolling.value) return
nextCheckTime.value = Date.now() + POLL_INTERVAL
checkStatus.value = 'checking' // Waiting
if (countdownTimer) clearInterval(countdownTimer)
updateCountdown()
countdownTimer = setInterval(updateCountdown, 1000)
pollTimer = setTimeout(() => {
checkBalance()
}, POLL_INTERVAL)
}
function updateCountdown() {
const diff = Math.max(0, Math.ceil((nextCheckTime.value - Date.now()) / 1000))
nextCheckCountdown.value = diff
if (diff <= 0 && countdownTimer) {
clearInterval(countdownTimer)
}
}
const qrCodeUrl = computed(() => {
return `https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=${encodeURIComponent(depositAddress.value)}`
@ -268,6 +472,7 @@ watch(
depositAddress.value = ''
loadError.value = ''
loading.value = false
stopDepositCheck()
} else {
if (step.value === 'crypto') fetchAddress()
}
@ -277,6 +482,10 @@ watch(
watch(selectedNetwork, () => {
if (step.value === 'crypto') fetchAddress()
})
onUnmounted(() => {
stopDepositCheck()
})
</script>
<style scoped>
@ -408,6 +617,7 @@ watch(selectedNetwork, () => {
border-radius: 8px;
flex: 1;
min-width: 0;
word-break: break-all;
}
.qr-wrap {
margin-top: 16px;

View File

@ -204,7 +204,15 @@
"coinbaseComingSoon": "Coinbase Wallet (Coming soon)",
"walletConnectComingSoon": "WalletConnect (Coming soon)",
"close": "Close",
"installMetaMask": "Please install MetaMask or another Web3 wallet."
"installMetaMask": "Please install MetaMask or another Web3 wallet.",
"checkStatus": "Check Deposit Status",
"checking": "Checking...",
"checkingStatus": "Checking deposit status...",
"nextCheckIn": "Next check in: {seconds}s",
"checkingNow": "Checking now...",
"checkSuccess": "Deposit detected! Processing...",
"checkTimeout": "No deposit detected. Please try again later.",
"checkError": "Check failed"
},
"withdraw": {
"title": "Withdraw",

View File

@ -204,7 +204,15 @@
"coinbaseComingSoon": "Coinbase Wallet即将推出",
"walletConnectComingSoon": "WalletConnect即将推出",
"close": "关闭",
"installMetaMask": "请安装 MetaMask 或其他 Web3 钱包。"
"installMetaMask": "请安装 MetaMask 或其他 Web3 钱包。",
"checkStatus": "检查充值状态",
"checking": "正在检测...",
"checkingStatus": "正在检测充值状态...",
"nextCheckIn": "下次检测: {seconds}秒",
"checkingNow": "正在检测...",
"checkSuccess": "检测到充值!已提交处理。",
"checkTimeout": "未检测到充值。请稍候再试。",
"checkError": "检测失败"
},
"withdraw": {
"title": "提现",

View File

@ -1,7 +1,7 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { getUsdcBalance, formatUsdcBalance, getUserInfo } from '@/api/user'
import { getUserWsUrl } from '@/api/request'
import { getUserWsUrl, BASE_URL } from '@/api/request'
import { UserSdk, type BalanceData, type PositionData } from '../../sdk/userSocket'
export interface UserInfo {
@ -63,7 +63,15 @@ export const useUserStore = defineStore('user', () => {
const user = ref<UserInfo | null>(stored?.user ?? null)
const isLoggedIn = computed(() => !!token.value && !!user.value)
const avatarUrl = computed(() => user.value?.headerImg ?? '')
const avatarUrl = computed(() => {
const img = user.value?.headerImg
if (!img) return ''
if (img.startsWith('http') || img.startsWith('data:')) return img
// 处理相对路径,拼接 BASE_URL
const baseUrl = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL
const path = img.startsWith('/') ? img : `/${img}`
return `${baseUrl}${path}`
})
/** 钱包余额显示,如 "0.00",可从接口或 UserSocket 推送更新 */
const balance = ref<string>('0.00')