新增:用户名密码登录

This commit is contained in:
马丁 2026-06-27 00:28:18 +08:00
parent c87099de2a
commit 93090738e1
9 changed files with 720 additions and 38 deletions

75
src/api/auth.ts Normal file
View File

@ -0,0 +1,75 @@
import { post } from './request'
import type { UserInfo } from '@/stores/user'
export interface CaptchaData {
captchaId: string
picPath: string
captchaLength: number
openCaptcha: boolean
}
export interface CaptchaResponse {
code: number
data?: CaptchaData
msg?: string
}
export interface LoginResponseData {
token: string
expiresAt?: number
user?: UserInfo
}
export interface AuthApiResponse {
code: number
data?: LoginResponseData
msg?: string
}
export interface LoginByPasswordParams {
username: string
password: string
captcha: string
captchaId: string
}
export interface RegisterByPasswordParams {
userName: string
passWord: string
nickName?: string
captcha: string
captchaId: string
promotionCode?: string
}
/** POST /base/captcha */
export async function getCaptcha(): Promise<CaptchaResponse> {
return post<CaptchaResponse>('/base/captcha')
}
/** POST /base/login */
export async function loginByPassword(params: LoginByPasswordParams): Promise<AuthApiResponse> {
return post<AuthApiResponse>('/base/login', params)
}
/** POST /base/register */
export async function registerByPassword(
params: RegisterByPasswordParams,
): Promise<AuthApiResponse> {
return post<AuthApiResponse>('/base/register', params)
}
/** 从登录/注册响应中解析 token 与 user供各登录方式复用 */
export function parseLoginResponse(data: LoginResponseData | Record<string, unknown>): {
token: string | undefined
user: UserInfo | undefined
} {
const record = data as Record<string, unknown>
const token = record.token as string | undefined
const user =
(record.user as UserInfo | undefined) ??
(record.ID != null || record.id != null || record.userName != null
? (record as unknown as UserInfo)
: undefined)
return { token, user }
}

View File

@ -142,15 +142,44 @@
},
"login": {
"title": "Sign In",
"desc": "Connect your wallet to sign in",
"descAccount": "Sign in with your username and password",
"descWallet": "Connect your wallet to sign in",
"tabAccount": "Account",
"tabWallet": "Wallet",
"username": "Username",
"password": "Password",
"captcha": "Captcha",
"refreshCaptcha": "Refresh captcha",
"loginBtn": "Sign In",
"connecting": "Connecting...",
"connectBtn": "Connect with Wallet",
"signUp": "Don't have an account? Sign Up",
"walletNotInstalled": "Ethereum wallet is not installed. Please install MetaMask, TokenPocket or another Ethereum wallet to continue.",
"invalidAddress": "Invalid wallet address format. Please check your wallet connection.",
"connectFailed": "Failed to connect with wallet. Please check your wallet and try again.",
"loginFailed": "Sign in failed. Please check your credentials and try again.",
"credentialsRequired": "Please enter username and password.",
"captchaRequired": "Please enter the captcha.",
"captchaLoadFailed": "Failed to load captcha. Please refresh and try again.",
"siweStatement": "Sign in to Alpha Market"
},
"register": {
"title": "Sign Up",
"desc": "Create an account to start trading",
"username": "Username",
"nickName": "Nickname (optional)",
"password": "Password",
"confirmPassword": "Confirm Password",
"captcha": "Captcha",
"refreshCaptcha": "Refresh captcha",
"submitBtn": "Create Account",
"signIn": "Already have an account? Sign In",
"passwordMismatch": "Passwords do not match.",
"credentialsRequired": "Please enter username and password.",
"captchaRequired": "Please enter the captcha.",
"captchaLoadFailed": "Failed to load captcha. Please refresh and try again.",
"failed": "Registration failed. Please try again."
},
"apiKey": {
"title": "API Key Management",
"createBtn": "Create Key",

View File

@ -142,15 +142,44 @@
},
"login": {
"title": "サインイン",
"desc": "ウォレットを接続してサインイン",
"descAccount": "ユーザー名とパスワードでサインイン",
"descWallet": "ウォレットを接続してサインイン",
"tabAccount": "アカウント",
"tabWallet": "ウォレット",
"username": "ユーザー名",
"password": "パスワード",
"captcha": "認証コード",
"refreshCaptcha": "認証コードを更新",
"loginBtn": "サインイン",
"connecting": "接続中...",
"connectBtn": "ウォレットで接続",
"signUp": "アカウントをお持ちでないですか?新規登録",
"walletNotInstalled": "イーサリアムウォレットがインストールされていません。MetaMask、TokenPocket などのウォレットをインストールしてください。",
"invalidAddress": "ウォレットアドレスの形式が無効です。接続を確認してください。",
"connectFailed": "ウォレットの接続に失敗しました。ウォレットを確認して再試行してください。",
"loginFailed": "サインインに失敗しました。認証情報を確認してください。",
"credentialsRequired": "ユーザー名とパスワードを入力してください。",
"captchaRequired": "認証コードを入力してください。",
"captchaLoadFailed": "認証コードの読み込みに失敗しました。更新して再試行してください。",
"siweStatement": "Alpha Market にサインイン"
},
"register": {
"title": "新規登録",
"desc": "アカウントを作成して取引を開始",
"username": "ユーザー名",
"nickName": "ニックネーム(任意)",
"password": "パスワード",
"confirmPassword": "パスワード確認",
"captcha": "認証コード",
"refreshCaptcha": "認証コードを更新",
"submitBtn": "アカウント作成",
"signIn": "すでにアカウントをお持ちですか?サインイン",
"passwordMismatch": "パスワードが一致しません。",
"credentialsRequired": "ユーザー名とパスワードを入力してください。",
"captchaRequired": "認証コードを入力してください。",
"captchaLoadFailed": "認証コードの読み込みに失敗しました。更新して再試行してください。",
"failed": "登録に失敗しました。再試行してください。"
},
"apiKey": {
"title": "API Key 管理",
"createBtn": "Key を作成",

View File

@ -142,15 +142,44 @@
},
"login": {
"title": "로그인",
"desc": "지갑을 연결하여 로그인",
"descAccount": "사용자 이름과 비밀번호로 로그인",
"descWallet": "지갑을 연결하여 로그인",
"tabAccount": "계정",
"tabWallet": "지갑",
"username": "사용자 이름",
"password": "비밀번호",
"captcha": "인증 코드",
"refreshCaptcha": "인증 코드 새로고침",
"loginBtn": "로그인",
"connecting": "연결 중...",
"connectBtn": "지갑 연결",
"signUp": "계정이 없으신가요? 회원가입",
"walletNotInstalled": "이더리움 지갑이 설치되어 있지 않습니다. MetaMask, TokenPocket 등 지갑을 설치해 주세요.",
"invalidAddress": "지갑 주소 형식이 올바르지 않습니다. 지갑 연결을 확인해 주세요.",
"connectFailed": "지갑 연결에 실패했습니다. 지갑을 확인하고 다시 시도해 주세요.",
"loginFailed": "로그인에 실패했습니다. 계정 정보를 확인해 주세요.",
"credentialsRequired": "사용자 이름과 비밀번호를 입력해 주세요.",
"captchaRequired": "인증 코드를 입력해 주세요.",
"captchaLoadFailed": "인증 코드를 불러오지 못했습니다. 새로고침 후 다시 시도해 주세요.",
"siweStatement": "Alpha Market 로그인"
},
"register": {
"title": "회원가입",
"desc": "계정을 만들고 거래를 시작하세요",
"username": "사용자 이름",
"nickName": "닉네임 (선택)",
"password": "비밀번호",
"confirmPassword": "비밀번호 확인",
"captcha": "인증 코드",
"refreshCaptcha": "인증 코드 새로고침",
"submitBtn": "계정 만들기",
"signIn": "이미 계정이 있으신가요? 로그인",
"passwordMismatch": "비밀번호가 일치하지 않습니다.",
"credentialsRequired": "사용자 이름과 비밀번호를 입력해 주세요.",
"captchaRequired": "인증 코드를 입력해 주세요.",
"captchaLoadFailed": "인증 코드를 불러오지 못했습니다. 새로고침 후 다시 시도해 주세요.",
"failed": "회원가입에 실패했습니다. 다시 시도해 주세요."
},
"apiKey": {
"title": "API Key 관리",
"createBtn": "Key 생성",

View File

@ -142,15 +142,44 @@
},
"login": {
"title": "登录",
"desc": "连接钱包以登录",
"descAccount": "使用用户名和密码登录",
"descWallet": "连接钱包以登录",
"tabAccount": "账号密码",
"tabWallet": "钱包登录",
"username": "用户名",
"password": "密码",
"captcha": "验证码",
"refreshCaptcha": "刷新验证码",
"loginBtn": "登录",
"connecting": "连接中...",
"connectBtn": "连接钱包",
"signUp": "没有账号?立即注册",
"walletNotInstalled": "未检测到以太坊钱包。请安装 MetaMask、TokenPocket 或其他以太坊钱包后重试。",
"invalidAddress": "钱包地址格式无效,请检查钱包连接。",
"connectFailed": "连接钱包失败,请检查钱包后重试。",
"loginFailed": "登录失败,请检查账号密码后重试。",
"credentialsRequired": "请输入用户名和密码。",
"captchaRequired": "请输入验证码。",
"captchaLoadFailed": "验证码加载失败,请刷新后重试。",
"siweStatement": "登录 Alpha Market"
},
"register": {
"title": "注册",
"desc": "创建账号开始交易",
"username": "用户名",
"nickName": "昵称(可选)",
"password": "密码",
"confirmPassword": "确认密码",
"captcha": "验证码",
"refreshCaptcha": "刷新验证码",
"submitBtn": "注册",
"signIn": "已有账号?立即登录",
"passwordMismatch": "两次输入的密码不一致。",
"credentialsRequired": "请输入用户名和密码。",
"captchaRequired": "请输入验证码。",
"captchaLoadFailed": "验证码加载失败,请刷新后重试。",
"failed": "注册失败,请稍后重试。"
},
"apiKey": {
"title": "API Key 管理",
"createBtn": "创建 Key",

View File

@ -142,15 +142,44 @@
},
"login": {
"title": "登入",
"desc": "連接錢包以登入",
"descAccount": "使用使用者名稱和密碼登入",
"descWallet": "連接錢包以登入",
"tabAccount": "帳號密碼",
"tabWallet": "錢包登入",
"username": "使用者名稱",
"password": "密碼",
"captcha": "驗證碼",
"refreshCaptcha": "刷新驗證碼",
"loginBtn": "登入",
"connecting": "連接中...",
"connectBtn": "連接錢包",
"signUp": "沒有帳號?立即註冊",
"walletNotInstalled": "未偵測到以太坊錢包。請安裝 MetaMask、TokenPocket 或其他以太坊錢包後重試。",
"invalidAddress": "錢包地址格式無效,請檢查錢包連接。",
"connectFailed": "連接錢包失敗,請檢查錢包後重試。",
"loginFailed": "登入失敗,請檢查帳號密碼後重試。",
"credentialsRequired": "請輸入使用者名稱和密碼。",
"captchaRequired": "請輸入驗證碼。",
"captchaLoadFailed": "驗證碼載入失敗,請刷新後重試。",
"siweStatement": "登入 Alpha Market"
},
"register": {
"title": "註冊",
"desc": "建立帳號開始交易",
"username": "使用者名稱",
"nickName": "暱稱(可選)",
"password": "密碼",
"confirmPassword": "確認密碼",
"captcha": "驗證碼",
"refreshCaptcha": "刷新驗證碼",
"submitBtn": "註冊",
"signIn": "已有帳號?立即登入",
"passwordMismatch": "兩次輸入的密碼不一致。",
"credentialsRequired": "請輸入使用者名稱和密碼。",
"captchaRequired": "請輸入驗證碼。",
"captchaLoadFailed": "驗證碼載入失敗,請刷新後重試。",
"failed": "註冊失敗,請稍後重試。"
},
"apiKey": {
"title": "API Key 管理",
"createBtn": "創建 Key",

View File

@ -3,6 +3,7 @@ import { HOME_MAIN_SCROLL_STORAGE_KEY } from './scrollKeys'
import Home from '../views/Home.vue'
import Trade from '../views/Trade.vue'
import Login from '../views/Login.vue'
import Register from '../views/Register.vue'
import TradeDetail from '../views/TradeDetail.vue'
import EventMarkets from '../views/EventMarkets.vue'
import Wallet from '../views/Wallet.vue'
@ -39,6 +40,11 @@ const router = createRouter({
name: 'login',
component: Login,
},
{
path: '/register',
name: 'register',
component: Register,
},
{
path: '/trade-detail/:id',
name: 'trade-detail',

View File

@ -5,7 +5,67 @@
<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">
<p class="login-desc">{{ t('login.desc') }}</p>
<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"
@ -19,6 +79,8 @@
<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 }}
@ -35,21 +97,87 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
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 type { UserInfo } from '@/stores/user'
import { post } from '@/api/request'
import { getCaptcha, loginByPassword, parseLoginResponse, type LoginResponseData } from '@/api/auth'
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 = ''
@ -85,7 +213,7 @@ const connectWithWallet = async () => {
scheme,
domain,
address: signer.address,
statement: 'Sign in to Alpha Market',
statement: t('login.siweStatement'),
uri: origin,
version: '1',
chainId: chainId,
@ -99,7 +227,7 @@ const connectWithWallet = async () => {
params: [message1, signer.address],
})
const loginData = await post<{ code: number; data?: { token: string; user?: UserInfo } }>(
const loginData = await post<{ code: number; data?: Record<string, unknown>; msg?: string }>(
'/base/walletLogin',
{
Message: message1,
@ -110,19 +238,10 @@ const connectWithWallet = async () => {
)
if (loginData.code === 0 && loginData.data) {
const data = loginData.data as Record<string, unknown>
const token = data.token as string | undefined
const user =
(data.user as UserInfo | undefined) ??
(data.ID != null || data.id != null || data.userName != null
? (data as unknown as UserInfo)
: undefined)
userStore.setUser({ token, user })
await userStore.fetchUserInfo()
await userStore.fetchUsdcBalance()
await completeLogin(loginData.data)
return
}
router.push('/')
errorMessage.value = loginData.msg || t('login.connectFailed')
} catch (error: unknown) {
console.error('Error connecting to wallet:', error)
const msg = error instanceof Error ? error.message : ''
@ -137,6 +256,14 @@ const connectWithWallet = async () => {
isConnecting.value = false
}
}
onMounted(() => {
if (userStore.isLoggedIn) {
router.replace('/')
return
}
loadCaptcha()
})
</script>
<style scoped>
@ -167,19 +294,63 @@ const connectWithWallet = async () => {
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 28px 0;
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 {

285
src/views/Register.vue Normal file
View File

@ -0,0 +1,285 @@
<template>
<v-container class="register-container">
<v-row justify="center" align="center" class="register-row">
<v-col cols="12" sm="10" md="6" lg="4">
<v-card class="register-card" elevation="0" rounded="lg">
<v-card-title class="register-title">{{ t('register.title') }}</v-card-title>
<v-card-text class="register-body">
<p class="register-desc">{{ t('register.desc') }}</p>
<v-form @submit.prevent="submitRegister">
<v-text-field
v-model="userName"
:label="t('register.username')"
variant="outlined"
density="comfortable"
autocomplete="username"
class="register-field"
/>
<v-text-field
v-model="nickName"
:label="t('register.nickName')"
variant="outlined"
density="comfortable"
class="register-field"
/>
<v-text-field
v-model="passWord"
:label="t('register.password')"
type="password"
variant="outlined"
density="comfortable"
autocomplete="new-password"
class="register-field"
/>
<v-text-field
v-model="confirmPassword"
:label="t('register.confirmPassword')"
type="password"
variant="outlined"
density="comfortable"
autocomplete="new-password"
class="register-field"
/>
<div class="captcha-row">
<v-text-field
v-model="captcha"
:label="t('register.captcha')"
variant="outlined"
density="comfortable"
class="captcha-input"
inputmode="numeric"
/>
<button
type="button"
class="captcha-img-wrap"
:aria-label="t('register.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('register.submitBtn') }}
</v-btn>
</v-form>
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<div class="register-links">
<router-link to="/login">{{ t('register.signIn') }}</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 { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user'
import { getCaptcha, registerByPassword, parseLoginResponse } from '@/api/auth'
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const userStore = useUserStore()
const userName = ref('')
const nickName = ref('')
const passWord = ref('')
const confirmPassword = ref('')
const captcha = ref('')
const captchaId = ref('')
const captchaImg = ref('')
const promotionCode = ref('')
const isSubmitting = ref(false)
const errorMessage = ref('')
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}`
}
} catch {
errorMessage.value = t('register.captchaLoadFailed')
}
}
async function submitRegister() {
isSubmitting.value = true
errorMessage.value = ''
try {
if (!userName.value.trim() || !passWord.value) {
errorMessage.value = t('register.credentialsRequired')
return
}
if (passWord.value !== confirmPassword.value) {
errorMessage.value = t('register.passwordMismatch')
return
}
if (!captcha.value.trim()) {
errorMessage.value = t('register.captchaRequired')
return
}
const res = await registerByPassword({
userName: userName.value.trim(),
passWord: passWord.value,
nickName: nickName.value.trim() || undefined,
captcha: captcha.value.trim(),
captchaId: captchaId.value,
promotionCode: promotionCode.value.trim() || undefined,
})
if (res.code === 0 && res.data) {
const { token, user } = parseLoginResponse(res.data)
userStore.setUser({ token, user })
await userStore.fetchUserInfo()
await userStore.fetchUsdcBalance()
await router.replace('/')
return
}
errorMessage.value = res.msg || t('register.failed')
await loadCaptcha()
captcha.value = ''
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : t('register.failed')
await loadCaptcha()
captcha.value = ''
} finally {
isSubmitting.value = false
}
}
onMounted(() => {
if (userStore.isLoggedIn) {
router.replace('/')
return
}
const refCode = route.query.ref
if (typeof refCode === 'string' && refCode.trim()) {
promotionCode.value = refCode.trim()
}
loadCaptcha()
})
</script>
<style scoped>
.register-container {
padding: 40px 20px;
min-height: 100vh;
}
.register-row {
min-height: calc(100vh - 80px);
}
.register-card {
border-radius: 16px;
padding: 40px 32px;
border: 1px solid rgba(0, 0, 0, 0.08);
}
.register-title {
font-size: 1.5rem;
font-weight: 600;
color: #111827;
margin-bottom: 8px;
text-align: center;
}
.register-body {
padding-top: 8px;
}
.register-desc {
font-size: 0.95rem;
color: #6b7280;
text-align: center;
margin: 0 0 20px 0;
line-height: 1.5;
}
.register-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 {
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;
}
.register-links {
margin-top: 24px;
text-align: center;
}
.register-links a {
color: #2563eb;
text-decoration: none;
font-size: 0.9rem;
}
.register-links a:hover {
text-decoration: underline;
}
</style>