384 lines
9.9 KiB
Vue
384 lines
9.9 KiB
Vue
<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>
|