新增:入金和出金

This commit is contained in:
ivan 2026-02-08 16:11:20 +08:00
parent cb52d8340f
commit 830b8c594d
3 changed files with 866 additions and 38 deletions

View File

@ -0,0 +1,407 @@
<template>
<v-dialog
:model-value="modelValue"
max-width="480"
persistent
scrollable
content-class="deposit-dialog"
@update:model-value="$emit('update:modelValue', $event)"
>
<v-card rounded="lg">
<div class="deposit-header">
<h2 class="deposit-title">Deposit</h2>
<p class="deposit-balance">Polymarket Balance: ${{ balance }}</p>
<v-btn icon variant="text" class="close-btn" aria-label="关闭" @click="close">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<v-card-text class="deposit-body">
<!-- 选择充值方式 -->
<template v-if="step === 'method'">
<div class="method-cards">
<v-card
class="method-card"
variant="outlined"
rounded="lg"
@click="selectMethod('crypto')"
>
<div class="method-icon crypto-icon">
<v-icon size="28">mdi-lightning-bolt</v-icon>
</div>
<div class="method-info">
<div class="method-name">Transfer Crypto</div>
<div class="method-desc">No limit Instant</div>
</div>
<v-icon class="method-arrow">mdi-chevron-right</v-icon>
</v-card>
<v-card
class="method-card"
variant="outlined"
rounded="lg"
@click="selectMethod('exchange')"
>
<div class="method-icon exchange-icon">
<v-icon size="28">mdi-link-variant</v-icon>
</div>
<div class="method-info">
<div class="method-name">Connect Exchange</div>
<div class="method-desc">No limit 2 min</div>
</div>
<v-icon class="method-arrow">mdi-chevron-right</v-icon>
</v-card>
</div>
</template>
<!-- Transfer Crypto选择网络 + 充值地址 -->
<template v-else-if="step === 'crypto'">
<div class="step-header">
<v-btn icon variant="text" size="small" @click="step = 'method'">
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<span class="step-title">Transfer Crypto</span>
</div>
<v-select
v-model="selectedNetwork"
:items="networks"
item-title="label"
item-value="id"
label="Network"
variant="outlined"
density="comfortable"
hide-details
class="network-select"
/>
<p class="support-tip">Supported: USDC, ETH</p>
<div class="address-box">
<label class="address-label">Deposit address</label>
<div class="address-row">
<code class="address-code">{{ depositAddressShort }}</code>
<v-btn size="small" variant="tonal" @click="copyAddress">
{{ copied ? 'Copied' : 'Copy' }}
</v-btn>
</div>
<div class="qr-wrap">
<img
:src="qrCodeUrl"
alt="QR Code"
class="qr-img"
width="120"
height="120"
/>
</div>
</div>
</template>
<!-- Connect Exchange连接钱包后显示地址 -->
<template v-else-if="step === 'exchange'">
<div class="step-header">
<v-btn icon variant="text" size="small" @click="step = 'method'">
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<span class="step-title">Connect Exchange</span>
</div>
<template v-if="!exchangeConnected">
<p class="connect-desc">Connect your wallet to deposit. Send USDC or ETH to your deposit address after connecting.</p>
<div class="wallet-buttons">
<v-btn
class="wallet-btn"
variant="outlined"
rounded="lg"
block
:loading="connecting"
@click="connectMetaMask"
>
<v-icon start>mdi-wallet</v-icon>
MetaMask
</v-btn>
<v-btn
class="wallet-btn"
variant="outlined"
rounded="lg"
block
disabled
>
<v-icon start>mdi-wallet</v-icon>
Coinbase Wallet (Coming soon)
</v-btn>
<v-btn
class="wallet-btn"
variant="outlined"
rounded="lg"
block
disabled
>
<v-icon start>mdi-wallet</v-icon>
WalletConnect (Coming soon)
</v-btn>
</div>
</template>
<template v-else>
<p class="connected-tip">
<v-icon color="success" size="18">mdi-check-circle</v-icon>
Connected. Send USDC or ETH to the address below.
</p>
<div class="address-box">
<label class="address-label">Deposit address</label>
<div class="address-row">
<code class="address-code">{{ depositAddressShort }}</code>
<v-btn size="small" variant="tonal" @click="copyAddress">
{{ copied ? 'Copied' : 'Copy' }}
</v-btn>
</div>
<div class="qr-wrap">
<img :src="qrCodeUrl" alt="QR Code" class="qr-img" width="120" height="120" />
</div>
</div>
</template>
</template>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
const props = withDefaults(
defineProps<{
modelValue: boolean
balance: string
}>(),
{ balance: '0.00' }
)
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const DEPOSIT_ADDRESS = '0x742d35Cc6634C0532925a3b844Bc454e4438f44e'
const step = ref<'method' | 'crypto' | 'exchange'>('method')
const selectedNetwork = ref('ethereum')
const copied = ref(false)
const exchangeConnected = ref(false)
const connecting = ref(false)
const networks = [
{ id: 'ethereum', label: 'Ethereum' },
{ id: 'polygon', label: 'Polygon' },
{ id: 'arbitrum', label: 'Arbitrum One' },
{ id: 'optimism', label: 'Optimism' },
]
const depositAddressShort = computed(() => {
const a = DEPOSIT_ADDRESS
return a ? `${a.slice(0, 6)}...${a.slice(-4)}` : ''
})
const qrCodeUrl = computed(() => {
return `https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=${encodeURIComponent(DEPOSIT_ADDRESS)}`
})
function close() {
emit('update:modelValue', false)
}
function selectMethod(m: 'crypto' | 'exchange') {
step.value = m
if (m === 'exchange') exchangeConnected.value = false
}
async function copyAddress() {
try {
await navigator.clipboard.writeText(DEPOSIT_ADDRESS)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {
//
}
}
async function connectMetaMask() {
if (!window.ethereum) {
alert('Please install MetaMask or another Web3 wallet.')
return
}
connecting.value = true
try {
await window.ethereum.request({ method: 'eth_requestAccounts' })
exchangeConnected.value = true
} catch (e) {
console.error(e)
} finally {
connecting.value = false
}
}
watch(
() => props.modelValue,
(open) => {
if (!open) {
step.value = 'method'
exchangeConnected.value = false
}
}
)
</script>
<style scoped>
.deposit-header {
position: relative;
padding: 20px 20px 8px;
border-bottom: 1px solid #e5e7eb;
}
.deposit-title {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
margin: 0 0 4px 0;
text-align: center;
}
.deposit-balance {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
text-align: center;
}
.close-btn {
position: absolute;
top: 12px;
right: 8px;
}
.deposit-body {
padding: 20px;
min-height: 200px;
}
.method-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.method-card {
display: flex;
align-items: center;
padding: 16px;
cursor: pointer;
transition: background-color 0.2s;
}
.method-card:hover {
background-color: #f9fafb;
}
.method-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 14px;
}
.crypto-icon {
background: #dbeafe;
color: #1d4ed8;
}
.exchange-icon {
background: #d1fae5;
color: #059669;
}
.method-info {
flex: 1;
}
.method-name {
font-weight: 600;
color: #111827;
font-size: 0.95rem;
}
.method-desc {
font-size: 0.8rem;
color: #6b7280;
margin-top: 2px;
}
.method-arrow {
color: #9ca3af;
}
.step-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
}
.step-title {
font-weight: 600;
color: #111827;
font-size: 1rem;
}
.network-select {
margin-bottom: 8px;
}
.support-tip {
font-size: 0.8rem;
color: #6b7280;
margin: 0 0 16px 0;
}
.address-box {
background: #f9fafb;
border-radius: 12px;
padding: 16px;
margin-top: 12px;
}
.address-label {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.02em;
margin-bottom: 8px;
}
.address-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.address-code {
font-size: 0.85rem;
color: #374151;
background: #fff;
padding: 6px 10px;
border-radius: 8px;
flex: 1;
min-width: 0;
}
.qr-wrap {
margin-top: 16px;
display: flex;
justify-content: center;
}
.qr-img {
border-radius: 8px;
}
.connect-desc {
font-size: 0.9rem;
color: #6b7280;
margin: 0 0 20px 0;
line-height: 1.5;
}
.wallet-buttons {
display: flex;
flex-direction: column;
gap: 10px;
}
.wallet-btn {
text-transform: none;
justify-content: flex-start;
}
.connected-tip {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: #059669;
margin: 0 0 16px 0;
}
</style>

View File

@ -0,0 +1,301 @@
<template>
<v-dialog
:model-value="modelValue"
max-width="480"
persistent
scrollable
content-class="withdraw-dialog"
@update:model-value="$emit('update:modelValue', $event)"
>
<v-card rounded="lg">
<div class="withdraw-header">
<h2 class="withdraw-title">Withdraw</h2>
<p class="withdraw-balance">Polymarket Balance: ${{ balance }}</p>
<v-btn icon variant="text" class="close-btn" aria-label="关闭" @click="close">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<v-card-text class="withdraw-body">
<v-text-field
v-model="amount"
type="number"
min="0"
step="0.01"
label="Amount (USD)"
variant="outlined"
density="comfortable"
hide-details
class="amount-field"
placeholder="0.00"
@keypress="allowDecimal"
>
<template #append-inner>
<v-btn variant="text" size="small" class="max-btn" @click="setMax">Max</v-btn>
</template>
</v-text-field>
<v-select
v-model="selectedNetwork"
:items="networks"
item-title="label"
item-value="id"
label="Network"
variant="outlined"
density="comfortable"
hide-details
class="network-select"
/>
<div class="destination-section">
<label class="section-label">Withdraw to</label>
<v-radio-group v-model="destinationType" hide-details class="destination-radio">
<v-radio value="wallet" label="Connected wallet" />
<v-radio value="address" label="Custom address" />
</v-radio-group>
<template v-if="destinationType === 'wallet'">
<v-btn
v-if="!connectedAddress"
class="connect-wallet-btn"
variant="outlined"
rounded="lg"
block
:loading="connecting"
@click="connectWallet"
>
<v-icon start>mdi-wallet</v-icon>
Connect Wallet
</v-btn>
<div v-else class="connected-address">
<v-icon color="success" size="18">mdi-check-circle</v-icon>
<code>{{ shortAddress(connectedAddress) }}</code>
</div>
</template>
<v-text-field
v-else
v-model="customAddress"
label="Wallet address"
variant="outlined"
density="comfortable"
hide-details
placeholder="0x..."
class="address-field"
/>
</div>
<div v-if="amountError" class="error-msg">{{ amountError }}</div>
<v-btn
class="withdraw-btn"
color="primary"
variant="flat"
block
size="large"
rounded="lg"
:loading="submitting"
:disabled="!canSubmit"
@click="submitWithdraw"
>
Withdraw
</v-btn>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
const props = withDefaults(
defineProps<{
modelValue: boolean
balance: string
}>(),
{ balance: '0.00' }
)
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; success: [] }>()
const amount = ref('')
const selectedNetwork = ref('ethereum')
const destinationType = ref<'wallet' | 'address'>('wallet')
const customAddress = ref('')
const connectedAddress = ref('')
const connecting = ref(false)
const submitting = ref(false)
const networks = [
{ id: 'ethereum', label: 'Ethereum' },
{ id: 'polygon', label: 'Polygon' },
{ id: 'arbitrum', label: 'Arbitrum One' },
{ id: 'optimism', label: 'Optimism' },
]
const balanceNum = computed(() => parseFloat(props.balance) || 0)
const amountNum = computed(() => parseFloat(amount.value) || 0)
const amountError = computed(() => {
if (!amount.value) return ''
if (amountNum.value <= 0) return 'Amount must be greater than 0'
if (amountNum.value > balanceNum.value) return 'Insufficient balance'
return ''
})
const hasValidDestination = computed(() => {
if (destinationType.value === 'wallet') return !!connectedAddress.value
return /^0x[a-fA-F0-9]{40}$/.test(customAddress.value.trim())
})
const canSubmit = computed(
() => amountNum.value > 0 && amountNum.value <= balanceNum.value && hasValidDestination.value && !amountError.value
)
function shortAddress(addr: string) {
return addr ? `${addr.slice(0, 6)}...${addr.slice(-4)}` : ''
}
function close() {
emit('update:modelValue', false)
}
function setMax() {
amount.value = props.balance
}
function allowDecimal(e: KeyboardEvent) {
const key = e.key
if (['Backspace', 'Tab', 'Delete', 'ArrowLeft', 'ArrowRight'].includes(key)) return
if (key === '.' && (e.target as HTMLInputElement).value.includes('.')) e.preventDefault()
if (key !== '.' && !/^\d$/.test(key)) e.preventDefault()
}
async function connectWallet() {
if (!window.ethereum) {
alert('Please install MetaMask or another Web3 wallet.')
return
}
connecting.value = true
try {
const accounts = (await window.ethereum.request({ method: 'eth_requestAccounts' })) as string[]
connectedAddress.value = accounts[0] || ''
} catch (e) {
console.error(e)
} finally {
connecting.value = false
}
}
async function submitWithdraw() {
if (!canSubmit.value) return
submitting.value = true
try {
await new Promise((r) => setTimeout(r, 800))
const dest = destinationType.value === 'wallet' ? connectedAddress.value : customAddress.value.trim()
console.log('Withdraw', { amount: amount.value, network: selectedNetwork.value, to: dest })
emit('success')
close()
} finally {
submitting.value = false
}
}
watch(
() => props.modelValue,
(open) => {
if (!open) {
amount.value = ''
destinationType.value = 'wallet'
customAddress.value = ''
connectedAddress.value = ''
}
}
)
</script>
<style scoped>
.withdraw-header {
position: relative;
padding: 20px 20px 8px;
border-bottom: 1px solid #e5e7eb;
}
.withdraw-title {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
margin: 0 0 4px 0;
text-align: center;
}
.withdraw-balance {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
text-align: center;
}
.close-btn {
position: absolute;
top: 12px;
right: 8px;
}
.withdraw-body {
padding: 20px;
}
.amount-field {
margin-bottom: 16px;
}
.max-btn {
text-transform: none;
font-size: 0.8rem;
}
.network-select {
margin-bottom: 20px;
}
.destination-section {
margin-bottom: 20px;
}
.section-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
margin-bottom: 8px;
}
.destination-radio {
margin-bottom: 12px;
}
.destination-radio :deep(.v-radio-group) {
flex-direction: row;
gap: 24px;
}
.connect-wallet-btn {
text-transform: none;
justify-content: flex-start;
}
.connected-address {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: #f0fdf4;
border-radius: 8px;
font-size: 0.875rem;
}
.connected-address code {
color: #166534;
}
.address-field {
margin-top: 8px;
}
.error-msg {
font-size: 0.8rem;
color: #dc2626;
margin-bottom: 12px;
}
.withdraw-btn {
text-transform: none;
font-weight: 600;
}
</style>

View File

@ -17,10 +17,22 @@
<div class="card-value">${{ portfolioBalance }}</div>
<div class="card-timeframe">Today</div>
<div class="card-actions">
<v-btn color="primary" variant="flat" class="action-btn" prepend-icon="mdi-arrow-down">
<v-btn
color="primary"
variant="flat"
class="action-btn"
prepend-icon="mdi-arrow-down"
@click="depositDialogOpen = true"
>
Deposit
</v-btn>
<v-btn variant="outlined" color="grey" class="action-btn" prepend-icon="mdi-arrow-up">
<v-btn
variant="outlined"
color="grey"
class="action-btn"
prepend-icon="mdi-arrow-up"
@click="withdrawDialogOpen = true"
>
Withdraw
</v-btn>
</div>
@ -52,12 +64,7 @@
<v-icon size="18" class="info-icon">mdi-information-outline</v-icon>
</div>
<div class="card-timeframe">All-Time</div>
<div class="polymarket-logo">
<span class="logo-p">P</span>
</div>
<div class="pl-bar">
<div class="pl-bar-fill" />
</div>
<div ref="plChartRef" class="pl-chart"></div>
</v-card>
</v-col>
</v-row>
@ -119,11 +126,25 @@
</v-table>
</v-card>
</div>
<DepositDialog
v-model="depositDialogOpen"
:balance="portfolioBalance"
/>
<WithdrawDialog
v-model="withdrawDialogOpen"
:balance="portfolioBalance"
@success="onWithdrawSuccess"
/>
</v-container>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import DepositDialog from '../components/DepositDialog.vue'
import WithdrawDialog from '../components/WithdrawDialog.vue'
import { useUserStore } from '../stores/user'
const userStore = useUserStore()
@ -133,7 +154,131 @@ const plRange = ref('ALL')
const plTimeRanges = ['1D', '1W', '1M', 'ALL']
const activeTab = ref('positions')
const search = ref('')
const depositDialogOpen = ref(false)
const withdrawDialogOpen = ref(false)
const positions = ref<{ id: string; market: string; avgNow: string; bet: string; toWin: string; value: string }[]>([])
const plChartRef = ref<HTMLElement | null>(null)
let plChartInstance: ECharts | null = null
/** 根据时间范围生成盈亏折线数据 [timestamp, pnl] */
function generatePlData(range: string): [number, number][] {
const now = Date.now()
let stepMs: number
let count: number
switch (range) {
case '1D':
stepMs = 60 * 60 * 1000
count = 24
break
case '1W':
stepMs = 24 * 60 * 60 * 1000
count = 7
break
case '1M':
stepMs = 24 * 60 * 60 * 1000
count = 30
break
case 'ALL':
default:
stepMs = 24 * 60 * 60 * 1000
count = 30
break
}
const data: [number, number][] = []
let pnl = 0
for (let i = count; i >= 0; i--) {
const t = now - i * stepMs
pnl += (Math.random() - 0.48) * 20
data.push([t, Math.round(pnl * 100) / 100])
}
return data
}
function buildPlChartOption(chartData: [number, number][]) {
const lastPoint = chartData[chartData.length - 1]
const lastVal = lastPoint != null ? lastPoint[1] : 0
const lineColor = lastVal >= 0 ? '#059669' : '#dc2626'
return {
animation: false,
tooltip: {
trigger: 'axis',
formatter: (params: unknown) => {
const p = (Array.isArray(params) ? params[0] : params) as { name: string | number; value: unknown }
const date = new Date(p.name as number)
const val = Array.isArray(p.value) ? (p.value as number[])[1] : p.value
const sign = Number(val) >= 0 ? '+' : ''
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}<br/>${sign}$${Number(val).toFixed(2)}`
},
axisPointer: { animation: false },
},
grid: { left: 8, right: 8, top: 8, bottom: 24, containLabel: false },
xAxis: {
type: 'time',
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#9ca3af', fontSize: 10 },
splitLine: { show: false },
},
yAxis: {
type: 'value',
position: 'right',
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#9ca3af', fontSize: 10, formatter: (v: number) => `$${v}` },
splitLine: { lineStyle: { type: 'dashed', color: '#e5e7eb' } },
},
series: [
{
type: 'line',
data: chartData,
smooth: true,
showSymbol: false,
lineStyle: { width: 2, color: lineColor },
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: lineColor + '40' }, { offset: 1, color: lineColor + '08' }]) },
},
],
}
}
const plChartData = ref<[number, number][]>([])
function updatePlChart() {
plChartData.value = generatePlData(plRange.value)
const last = plChartData.value[plChartData.value.length - 1]
if (last) profitLoss.value = last[1].toFixed(2)
if (plChartInstance) plChartInstance.setOption(buildPlChartOption(plChartData.value), { replaceMerge: ['series'] })
}
function initPlChart() {
if (!plChartRef.value) return
plChartData.value = generatePlData(plRange.value)
const last = plChartData.value[plChartData.value.length - 1]
if (last) profitLoss.value = last[1].toFixed(2)
plChartInstance = echarts.init(plChartRef.value)
plChartInstance.setOption(buildPlChartOption(plChartData.value))
}
const handleResize = () => plChartInstance?.resize()
watch(plRange, () => updatePlChart())
onMounted(() => {
nextTick(() => {
initPlChart()
})
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
plChartInstance?.dispose()
plChartInstance = null
})
function onWithdrawSuccess() {
withdrawDialogOpen.value = false
}
</script>
<style scoped>
@ -233,35 +378,10 @@ const positions = ref<{ id: string; market: string; avgNow: string; bet: string;
font-size: 12px;
}
.polymarket-logo {
margin-bottom: 12px;
}
.logo-p {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: linear-gradient(135deg, #1a73e8 0%, #34a853 100%);
color: white;
font-weight: 700;
font-size: 14px;
border-radius: 6px;
}
.pl-bar {
height: 6px;
background: #e5e7eb;
border-radius: 3px;
overflow: hidden;
}
.pl-bar-fill {
height: 100%;
width: 40%;
background: linear-gradient(90deg, #93c5fd 0%, #bfdbfe 100%);
border-radius: 3px;
.pl-chart {
height: 120px;
width: 100%;
margin-top: 4px;
}
.wallet-section {