xtraderClient/src/views/Login.vue
2026-08-05 20:59:32 +08:00

384 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<v-container class="login-container">
<v-row justify="center" align="center" class="login-row">
<v-col cols="12" sm="10" md="6" lg="4">
<v-card class="login-card" elevation="0" rounded="lg">
<v-card-title class="login-title">{{ t('login.title') }}</v-card-title>
<v-card-text class="login-body">
<v-tabs v-model="activeTab" class="login-tabs" color="success" grow>
<v-tab value="account">{{ t('login.tabAccount') }}</v-tab>
<v-tab value="wallet">{{ t('login.tabWallet') }}</v-tab>
</v-tabs>
<v-window v-model="activeTab" class="login-window">
<v-window-item value="account">
<p class="login-desc">{{ t('login.descAccount') }}</p>
<v-form @submit.prevent="submitPasswordLogin">
<v-text-field
v-model="username"
:label="t('login.username')"
variant="outlined"
density="comfortable"
autocomplete="username"
class="login-field"
/>
<v-text-field
v-model="password"
:label="t('login.password')"
type="password"
variant="outlined"
density="comfortable"
autocomplete="current-password"
class="login-field"
/>
<div v-if="openCaptcha" class="captcha-row">
<v-text-field
v-model="captcha"
:label="t('login.captcha')"
variant="outlined"
density="comfortable"
class="captcha-input"
inputmode="numeric"
/>
<button
type="button"
class="captcha-img-wrap"
:aria-label="t('login.refreshCaptcha')"
@click="loadCaptcha"
>
<img v-if="captchaImg" :src="captchaImg" alt="" class="captcha-img" />
</button>
</div>
<v-btn
type="submit"
color="success"
size="large"
block
rounded="lg"
class="submit-btn"
:loading="isSubmitting"
:disabled="isSubmitting"
>
{{ t('login.loginBtn') }}
</v-btn>
</v-form>
</v-window-item>
<v-window-item value="wallet">
<p class="login-desc">{{ t('login.descWallet') }}</p>
<v-btn
class="wallet-btn"
color="success"
size="large"
block
rounded="lg"
@click="connectWithWallet"
:loading="isConnecting"
:disabled="isConnecting"
>
<v-icon start>mdi-wallet</v-icon>
{{ isConnecting ? t('login.connecting') : t('login.connectBtn') }}
</v-btn>
</v-window-item>
</v-window>
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<div class="login-links">
<router-link to="/register">{{ t('login.signUp') }}</router-link>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { BrowserProvider } from 'ethers'
import { SiweMessage } from 'siwe'
import { useUserStore } from '@/stores/user'
import { post } from '@/api/request'
import { getCaptcha, loginByPassword, parseLoginResponse, type LoginResponseData } from '@/api/auth'
// EIP-4361 只允许可打印 US-ASCII 的 statement,不能本地化
const SIWE_STATEMENT = 'Sign in to FuturePulse'
const router = useRouter()
const { t } = useI18n()
const userStore = useUserStore()
const activeTab = ref('account')
const username = ref('')
const password = ref('')
const captcha = ref('')
const captchaId = ref('')
const captchaImg = ref('')
const openCaptcha = ref(true)
const isSubmitting = ref(false)
const isConnecting = ref(false)
const errorMessage = ref('')
async function completeLogin(data: LoginResponseData | Record<string, unknown>) {
const { token, user } = parseLoginResponse(data)
userStore.setUser({ token, user })
await userStore.fetchUserInfo()
await userStore.fetchUsdcBalance()
await router.replace('/')
}
async function loadCaptcha() {
try {
const res = await getCaptcha()
if (res.code === 0 && res.data) {
captchaId.value = res.data.captchaId
captchaImg.value = res.data.picPath.startsWith('data:')
? res.data.picPath
: `data:image/png;base64,${res.data.picPath}`
openCaptcha.value = res.data.openCaptcha
}
} catch {
errorMessage.value = t('login.captchaLoadFailed')
}
}
async function submitPasswordLogin() {
isSubmitting.value = true
errorMessage.value = ''
try {
if (!username.value.trim() || !password.value) {
errorMessage.value = t('login.credentialsRequired')
return
}
if (openCaptcha.value && !captcha.value.trim()) {
errorMessage.value = t('login.captchaRequired')
return
}
const res = await loginByPassword({
username: username.value.trim(),
password: password.value,
captcha: captcha.value.trim(),
captchaId: captchaId.value,
})
if (res.code === 0 && res.data) {
await completeLogin(res.data)
return
}
errorMessage.value = res.msg || t('login.loginFailed')
await loadCaptcha()
captcha.value = ''
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : t('login.loginFailed')
await loadCaptcha()
captcha.value = ''
} finally {
isSubmitting.value = false
}
}
const connectWithWallet = async () => {
isConnecting.value = true
errorMessage.value = ''
try {
if (!window.ethereum) {
throw new Error('WALLET_NOT_INSTALLED')
}
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts',
})
const walletAddress = accounts[0]
if (!walletAddress || !/^0x[0-9a-fA-F]{40}$/.test(walletAddress)) {
throw new Error('INVALID_ADDRESS')
}
const chainId = await window.ethereum.request({
method: 'eth_chainId',
})
const scheme = window.location.protocol.slice(0, -1)
const domain = window.location.host
const origin = window.location.origin
const nonce = new Date().getTime().toString()
const provider = new BrowserProvider(window.ethereum)
const signer = await provider.getSigner()
const siwe = new SiweMessage({
scheme,
domain,
address: signer.address,
statement: SIWE_STATEMENT,
uri: origin,
version: '1',
chainId: chainId,
nonce,
})
const message = siwe.prepareMessage()
const message1 = message.replace(/^https?:\/\//, '')
const signature = await window.ethereum.request({
method: 'personal_sign',
params: [message1, signer.address],
})
const loginData = await post<{ code: number; data?: Record<string, unknown>; msg?: string }>(
'/base/walletLogin',
{
Message: message1,
nonce,
signature,
walletAddress,
},
)
if (loginData.code === 0 && loginData.data) {
await completeLogin(loginData.data)
return
}
errorMessage.value = loginData.msg || t('login.connectFailed')
} catch (error: unknown) {
console.error('Error connecting to wallet:', error)
const msg = error instanceof Error ? error.message : ''
if (msg === 'WALLET_NOT_INSTALLED') {
errorMessage.value = t('login.walletNotInstalled')
} else if (msg === 'INVALID_ADDRESS') {
errorMessage.value = t('login.invalidAddress')
} else {
errorMessage.value = msg || t('login.connectFailed')
}
} finally {
isConnecting.value = false
}
}
onMounted(() => {
if (userStore.isLoggedIn) {
router.replace('/')
return
}
loadCaptcha()
})
</script>
<style scoped>
.login-container {
padding: 40px 20px;
min-height: 100vh;
}
.login-row {
min-height: calc(100vh - 80px);
}
.login-card {
border-radius: 16px;
padding: 40px 32px;
border: 1px solid rgba(0, 0, 0, 0.08);
}
.login-title {
font-size: 1.5rem;
font-weight: 600;
color: #111827;
margin-bottom: 8px;
text-align: center;
}
.login-body {
padding-top: 8px;
}
.login-tabs {
margin-bottom: 16px;
}
.login-window {
margin-top: 8px;
}
.login-desc {
font-size: 0.95rem;
color: #6b7280;
text-align: center;
margin: 0 0 20px 0;
line-height: 1.5;
}
.login-field {
margin-bottom: 4px;
}
.captcha-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.captcha-input {
flex: 1;
}
.captcha-img-wrap {
flex-shrink: 0;
width: 120px;
height: 44px;
padding: 0;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
overflow: hidden;
background: #f3f4f6;
cursor: pointer;
}
.captcha-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.submit-btn,
.wallet-btn {
height: 48px;
font-weight: 600;
text-transform: none;
letter-spacing: 0.02em;
margin-top: 8px;
}
.error-message {
margin-top: 20px;
padding: 12px;
color: #dc2626;
background: #fef2f2;
border-radius: 8px;
text-align: center;
font-size: 0.875rem;
}
.login-links {
margin-top: 24px;
text-align: center;
}
.login-links a {
color: #2563eb;
text-decoration: none;
font-size: 0.9rem;
}
.login-links a:hover {
text-decoration: underline;
}
</style>