xtraderClient/src/views/Profile.vue
2026-03-22 17:47:44 +08:00

817 lines
19 KiB
Vue

<template>
<div class="profile-page">
<div class="profile-screen">
<section class="card profile-card">
<div class="top-row">
<label class="avatar-wrap" :aria-label="t('profile.changeAvatar')">
<input
type="file"
accept="image/*"
class="avatar-input"
@change="onAvatarFileChange"
/>
<div class="avatar">
<v-progress-circular
v-if="avatarUploading"
indeterminate
size="32"
width="2"
class="avatar-loading"
/>
<template v-else>
<img v-if="avatarImage" :src="avatarImage" :alt="displayName" class="avatar-img" />
<span v-else>{{ avatarText }}</span>
<span class="avatar-overlay">
<v-icon size="18">mdi-camera</v-icon>
</span>
</template>
</div>
</label>
<div class="info-col">
<div class="name-text">{{ displayName }}</div>
<div class="acc-text">{{ t('profile.uidLabel', { uid: userIdText }) }}</div>
<div class="tag-text">{{ userTag }}</div>
</div>
<button class="edit-btn" type="button" @click="onEditProfile">{{ t('profile.edit') }}</button>
</div>
</section>
<section class="card wallet-card">
<div class="wallet-head">
<span class="wallet-title">{{ t('profile.walletOverview') }}</span>
<button class="wallet-link" type="button" @click="goWallet">{{ t('profile.walletDetail') }} &gt;</button>
</div>
<div class="wallet-balance">${{ totalBalance }}</div>
<div class="wallet-sub">
{{ t('profile.walletSub', { available: availableBalance, frozen: frozenBalance }) }}
</div>
</section>
<section class="card menu-card">
<div class="menu-title">{{ t('profile.accountSettings') }}</div>
<button v-for="item in settingItems" :key="item.label" class="menu-item" type="button" @click="goSetting(item)">
<span>{{ item.label }}</span>
<span v-if="item.action === 'locale'" class="menu-locale">{{ currentLocaleLabel }}</span>
<span v-else-if="item.action === 'wallet'" class="menu-locale">{{ walletAddressShort }}</span>
<span v-else class="menu-arrow">&gt;</span>
</button>
</section>
<button class="logout-btn" type="button" :disabled="logoutLoading" @click="logout">
{{ t('common.logout') }}
</button>
</div>
<v-dialog v-model="localeDialogOpen" max-width="360">
<v-card class="locale-dialog-card" rounded="xl" elevation="0">
<div class="locale-dialog-title">{{ t('profile.selectLanguage') }}</div>
<button
v-for="opt in localeStore.localeOptions"
:key="opt.value"
class="locale-option"
type="button"
@click="chooseLocale(opt.value)"
>
<span>{{ opt.label }}</span>
<span v-if="opt.value === localeStore.currentLocale" class="locale-selected"></span>
</button>
</v-card>
</v-dialog>
<v-dialog v-model="walletDialogOpen" max-width="420">
<v-card class="wallet-dialog-card" rounded="xl" elevation="0">
<div class="wallet-dialog-title">{{ t('profile.currentWalletAddress') }}</div>
<div class="wallet-dialog-address">{{ walletAddressText }}</div>
<div class="wallet-dialog-actions">
<button class="wallet-copy-btn" type="button" :disabled="!walletAddress" @click="copyWalletAddress">
{{ t('profile.copyAddress') }}
</button>
</div>
</v-card>
</v-dialog>
<v-dialog v-model="editNameDialogOpen" max-width="420">
<v-card class="name-dialog-card" rounded="xl" elevation="0">
<div class="name-dialog-title">{{ t('profile.editNameTitle') }}</div>
<v-text-field
v-model="editingName"
:label="t('profile.newUserName')"
variant="outlined"
density="comfortable"
hide-details="auto"
:error-messages="nameError ? [nameError] : []"
:hint="t('profile.nameFormatHint')"
persistent-hint
/>
<div class="name-dialog-actions">
<button class="name-dialog-cancel-btn" type="button" :disabled="isSaving" @click="closeEditNameDialog">
{{ t('profile.cancel') }}
</button>
<button class="name-dialog-save-btn" type="button" :disabled="isSaving" @click="saveName">
<v-progress-circular
v-if="isSaving"
indeterminate
size="18"
width="2"
class="save-btn-spinner"
/>
<span v-else>{{ t('profile.save') }}</span>
</button>
</div>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useLocaleStore } from '../stores/locale'
import { useToastStore } from '../stores/toast'
import { useUserStore, type UserInfo } from '../stores/user'
import { setSelfUsername, setSelfHeaderImg } from '@/api/user'
import { upload } from '@/api/fileUpload'
import type { LocaleCode } from '@/plugins/i18n'
interface SettingItem {
label: string
route?: string
action?: 'locale' | 'wallet'
}
const router = useRouter()
const { t } = useI18n()
const localeStore = useLocaleStore()
const userStore = useUserStore()
const toastStore = useToastStore()
const localeDialogOpen = ref(false)
const walletDialogOpen = ref(false)
const logoutLoading = ref(false)
const editNameDialogOpen = ref(false)
const editingName = ref('')
const nameError = ref<string | null>(null)
const isSaving = ref(false)
const avatarUploading = ref(false)
const currentLocaleLabel = computed(() => {
return (
localeStore.localeOptions.find((opt) => opt.value === localeStore.currentLocale)?.label ??
String(localeStore.currentLocale)
)
})
function readStringFromUser(keys: string[]): string {
const user = userStore.user as Record<string, unknown> | null
if (!user) return ''
for (const key of keys) {
const value = user[key]
if (typeof value === 'string' && value.trim()) return value.trim()
}
return ''
}
const walletAddress = computed(() => {
const user = userStore.user as Record<string, unknown> | null
if (!user) return ''
const candidateKeys = ['walletAddress', 'address', 'wallet', 'walletAddr', 'ethAddress']
for (const key of candidateKeys) {
const value = user[key]
if (typeof value === 'string' && value.trim()) return value.trim()
}
return ''
})
const walletAddressText = computed(() => walletAddress.value || t('profile.walletAddressUnavailable'))
const walletAddressShort = computed(() => {
const value = walletAddress.value
if (!value) return t('profile.unbound')
if (value.length <= 14) return value
return `${value.slice(0, 6)}...${value.slice(-4)}`
})
const rawUser = computed(() => (userStore.user ?? {}) as Record<string, unknown>)
const userNameRaw = computed(() => {
const v = rawUser.value.userName
return typeof v === 'string' && v.trim() ? v.trim() : ''
})
const displayName = computed(() => userNameRaw.value || t('profile.defaultName'))
const userIdText = computed(() => {
const uid = rawUser.value.id ?? rawUser.value.ID
if (uid == null || uid === '') return '--'
return String(uid)
})
const avatarImage = computed(() => userStore.avatarUrl || '')
const avatarText = computed(() => {
const first = displayName.value.trim().charAt(0)
return first ? first.toUpperCase() : 'U'
})
const hasVip = computed(() => {
const user = rawUser.value
const candidates = [user.isVip, user.vip, user.vipLevel, user.memberLevel]
return candidates.some((v) => {
if (typeof v === 'boolean') return v
if (typeof v === 'number') return v > 0
if (typeof v === 'string') return v.trim() === '1' || v.trim().toLowerCase() === 'vip'
return false
})
})
const userTag = computed(() => (hasVip.value ? t('profile.vipTrader') : t('profile.trader')))
const totalBalance = computed(() => userStore.balance || '0.00')
const availableBalance = computed(() => {
const val = readStringFromUser(['availableBalance', 'available', 'walletAvailable'])
return val || totalBalance.value
})
const frozenBalance = computed(() => {
return readStringFromUser(['frozenBalance', 'frozen', 'walletFrozen']) || '0.00'
})
const settingItems = computed<SettingItem[]>(() => [
{ label: t('profile.walletManage'), action: 'wallet' },
{ label: t('profile.apiKeyManage'), route: '/api-key' },
{ label: t('profile.language'), action: 'locale' },
])
function goSetting(item: SettingItem) {
if (item.action === 'wallet') {
walletDialogOpen.value = true
return
}
if (item.action === 'locale') {
localeDialogOpen.value = true
return
}
if (!item.route) return
router.push(item.route)
}
function chooseLocale(locale: LocaleCode) {
localeStore.setLocale(locale)
localeDialogOpen.value = false
}
async function copyWalletAddress() {
if (!walletAddress.value) return
try {
await navigator.clipboard.writeText(walletAddress.value)
walletDialogOpen.value = false
toastStore.show(t('profile.copySuccess'))
} catch {
toastStore.show(t('profile.copyFailed'), 'error')
}
}
function onEditProfile() {
editNameDialogOpen.value = true
editingName.value = userNameRaw.value
nameError.value = null
}
function goWallet() {
router.push('/wallet')
}
function validateUserName(name: string): string | null {
const v = name.trim()
if (!v) return t('profile.nameRequired')
if (v.length < MIN_USER_NAME_LEN) return t('profile.nameTooShort', { min: MIN_USER_NAME_LEN })
if (v.length > MAX_USER_NAME_LEN) return t('profile.nameTooLong', { max: MAX_USER_NAME_LEN })
const allowedRe = /^[a-z0-9_]+$/i
if (!allowedRe.test(v)) return t('profile.nameInvalidFormat')
return null
}
function closeEditNameDialog() {
if (isSaving.value) return
editNameDialogOpen.value = false
nameError.value = null
}
async function saveName() {
if (isSaving.value) return
const err = validateUserName(editingName.value)
if (err) {
nameError.value = err
return
}
const trimmed = editingName.value.trim()
const token = userStore.token
const u = userStore.user as UserInfo | null
if (!token || !u) {
toastStore.show(t('error.pleaseLogin'), 'error')
return
}
isSaving.value = true
nameError.value = null
try {
const authHeaders = userStore.getAuthHeaders()
if (!authHeaders) {
toastStore.show(t('error.pleaseLogin'), 'error')
return
}
const res = await setSelfUsername(authHeaders, { username: trimmed })
if (res.code !== 0 && res.code !== 200) {
const errMsg = res.msg || t('profile.nameSaveFailed')
nameError.value = errMsg
toastStore.show(errMsg, 'error')
return
}
// 接口成功后刷新用户信息,确保 userName/headerImg 等字段一致
await userStore.fetchUserInfo()
toastStore.show(t('profile.nameSaved'))
editNameDialogOpen.value = false
} catch (e) {
const errMsg = e instanceof Error ? e.message : t('profile.nameSaveFailed')
nameError.value = errMsg
toastStore.show(errMsg, 'error')
} finally {
isSaving.value = false
}
}
async function onAvatarFileChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file || !file.type.startsWith('image/')) return
if (!userStore.isLoggedIn) {
toastStore.show(t('error.pleaseLogin'), 'error')
return
}
const authHeaders = userStore.getAuthHeaders()
if (!authHeaders) {
toastStore.show(t('error.pleaseLogin'), 'error')
return
}
avatarUploading.value = true
try {
const uploadRes = await upload(file, authHeaders)
const fileUrl = uploadRes.data?.file?.url
if (!fileUrl || (uploadRes.code !== 0 && uploadRes.code !== 200)) {
toastStore.show(uploadRes.msg || t('profile.avatarUploadFailed'), 'error')
return
}
const setRes = await setSelfHeaderImg(authHeaders, { headerImg: fileUrl })
if (setRes.code !== 0 && setRes.code !== 200) {
toastStore.show(setRes.msg || t('profile.avatarUploadFailed'), 'error')
return
}
await userStore.fetchUserInfo()
toastStore.show(t('profile.avatarUploadSuccess'))
} catch (err) {
const msg = err instanceof Error ? err.message : t('profile.avatarUploadFailed')
toastStore.show(msg, 'error')
} finally {
avatarUploading.value = false
input.value = ''
}
}
const MIN_USER_NAME_LEN = 2
const MAX_USER_NAME_LEN = 20
async function logout() {
if (logoutLoading.value) return
logoutLoading.value = true
try {
await userStore.logout()
router.push('/login')
} catch {
toastStore.show(t('error.requestFailed'), 'error')
} finally {
logoutLoading.value = false
}
}
onMounted(() => {
if (!userStore.isLoggedIn) return
userStore.fetchUserInfo()
userStore.fetchUsdcBalance()
})
</script>
<style scoped>
.profile-page {
min-height: 100vh;
background: #ffffff;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 0;
}
.profile-screen {
width: 100%;
max-width: 100%;
min-height: 0;
background: #fcfcfc;
font-family: Inter, sans-serif;
padding: 16px 16px 16px;
display: flex;
flex-direction: column;
gap: 16px;
box-sizing: border-box;
}
.card {
width: 100%;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 16px;
box-sizing: border-box;
}
.profile-card {
padding: 20px;
}
.top-row {
display: flex;
align-items: center;
gap: 12px;
}
.avatar-wrap {
cursor: pointer;
display: block;
flex-shrink: 0;
}
.avatar-input {
position: absolute;
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
z-index: -1;
}
.avatar {
width: 56px;
height: 56px;
border-radius: 999px;
background: #5b5bd6;
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
flex-shrink: 0;
overflow: hidden;
position: relative;
}
.avatar-loading {
color: #ffffff !important;
}
.avatar-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
opacity: 0;
transition: opacity 0.2s;
}
.avatar-wrap:hover .avatar-overlay {
opacity: 1;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.info-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.name-text {
color: #111827;
font-size: 20px;
font-weight: 700;
line-height: 1.1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acc-text {
color: #6b7280;
font-size: 13px;
font-weight: 500;
line-height: 1.2;
}
.tag-text {
color: #5b5bd6;
font-size: 12px;
font-weight: 600;
line-height: 1.2;
}
.edit-btn {
height: 34px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid #e5e7eb;
background: #fcfcfc;
color: #111827;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.wallet-card {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.wallet-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.wallet-title {
color: #111827;
font-size: 15px;
font-weight: 600;
line-height: 1.2;
}
.wallet-link {
border: 0;
background: transparent;
color: #5b5bd6;
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 0;
}
.wallet-balance {
color: #111827;
font-size: 28px;
font-weight: 700;
line-height: 1.1;
}
.wallet-sub {
color: #6b7280;
font-size: 12px;
font-weight: 500;
line-height: 1.2;
}
.wallet-actions {
display: flex;
gap: 10px;
}
.action-btn {
flex: 1;
height: 40px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.action-primary {
border: 0;
background: #5b5bd6;
color: #ffffff;
}
.action-secondary {
border: 1px solid #e5e7eb;
background: #fcfcfc;
color: #111827;
}
.menu-card {
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.menu-title {
color: #111827;
font-size: 15px;
font-weight: 600;
line-height: 1.2;
}
.menu-item {
height: 44px;
padding: 0 4px;
border: 0;
background: transparent;
display: flex;
align-items: center;
justify-content: space-between;
color: #111827;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.menu-arrow {
color: #9ca3af;
font-size: 14px;
font-weight: 600;
}
.menu-locale {
color: #6b7280;
font-size: 13px;
font-weight: 600;
}
.logout-btn {
width: 100%;
height: 48px;
border: 0;
border-radius: 12px;
background: #fee2e2;
color: #dc2626;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.logout-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.locale-dialog-card {
border: 1px solid #e5e7eb;
background: #ffffff;
padding: 8px;
}
.locale-dialog-title {
padding: 10px 10px 6px;
color: #111827;
font-size: 14px;
font-weight: 700;
}
.locale-option {
width: 100%;
height: 42px;
border: 0;
border-radius: 10px;
background: transparent;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
color: #111827;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.locale-option:hover {
background: #f9fafb;
}
.locale-selected {
color: #5b5bd6;
font-weight: 700;
}
.wallet-dialog-card {
border: 1px solid #e5e7eb;
background: #ffffff;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.wallet-dialog-title {
color: #111827;
font-size: 15px;
font-weight: 700;
}
.wallet-dialog-address {
border-radius: 10px;
border: 1px solid #e5e7eb;
background: #fcfcfc;
padding: 12px;
color: #111827;
font-size: 13px;
font-weight: 500;
line-height: 1.35;
overflow-wrap: anywhere;
}
.wallet-dialog-actions {
display: flex;
justify-content: flex-end;
}
.wallet-copy-btn {
height: 34px;
border-radius: 10px;
border: 0;
padding: 0 14px;
background: #5b5bd6;
color: #ffffff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wallet-copy-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.name-dialog-card {
border: 1px solid #e5e7eb;
background: #ffffff;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.name-dialog-title {
color: #111827;
font-size: 15px;
font-weight: 700;
padding: 2px 2px 0;
}
.name-dialog-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
padding-top: 2px;
}
.name-dialog-cancel-btn {
height: 34px;
border-radius: 10px;
border: 1px solid #e5e7eb;
background: #fcfcfc;
color: #111827;
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 0 14px;
}
.name-dialog-cancel-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.name-dialog-save-btn {
height: 34px;
min-width: 72px;
border-radius: 10px;
border: 0;
background: #5b5bd6;
color: #ffffff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.name-dialog-save-btn:disabled {
opacity: 0.8;
cursor: not-allowed;
}
.save-btn-spinner {
color: #ffffff;
}
</style>