xtraderClient/src/App.vue
2026-03-22 11:23:48 +08:00

288 lines
7.4 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.

<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useUserStore } from './stores/user'
import { useLocaleStore } from './stores/locale'
import type { LocaleCode } from './plugins/i18n'
import Toast from './components/Toast.vue'
const route = useRoute()
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const localeStore = useLocaleStore()
const localeMenuOpen = ref(false)
function chooseLocale(loc: LocaleCode) {
localeStore.setLocale(loc)
localeMenuOpen.value = false
}
const display = useDisplay()
const currentRoute = computed(() => route.path)
const showBottomNav = computed(() => {
if (!display.smAndDown.value) return false
return currentRoute.value === '/' || currentRoute.value === '/search' || currentRoute.value === '/profile'
})
const mineTargetPath = computed(() =>
userStore.isLoggedIn ? '/profile' : '/login',
)
const bottomNavValue = computed({
get() {
const p = currentRoute.value
if (p.startsWith('/profile') || p === '/login') return '/profile'
if (p.startsWith('/search')) return '/search'
return '/'
},
set(v: string) {
if (v === '/profile') router.push(mineTargetPath.value)
else router.push(v)
},
})
async function refreshUserData() {
if (!userStore.isLoggedIn) return
await router.isReady()
await nextTick()
await userStore.fetchUserInfo()
await userStore.fetchUsdcBalance()
}
onMounted(() => {
refreshUserData()
})
watch(
() => userStore.isLoggedIn,
(loggedIn) => {
if (loggedIn) refreshUserData()
},
{ immediate: true },
)
</script>
<template>
<v-app>
<v-app-bar color="surface" elevation="0">
<div class="app-bar-inner">
<v-btn
v-if="currentRoute !== '/'"
icon
variant="text"
class="back-btn"
:aria-label="t('common.back')"
@click="$router.back()"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<v-app-bar-title v-if="currentRoute === '/'">TestMarket</v-app-bar-title>
<v-spacer></v-spacer>
<template v-if="!userStore.isLoggedIn">
<v-menu
v-model="localeMenuOpen"
:close-on-content-click="true"
location="bottom"
transition="scale-transition"
>
<template #activator="{ props }">
<v-btn
icon
variant="text"
class="locale-btn"
:aria-label="t('profile.selectLanguage')"
v-bind="props"
>
<v-icon>mdi-earth</v-icon>
</v-btn>
</template>
<v-list density="compact">
<v-list-item
v-for="opt in localeStore.localeOptions"
:key="opt.value"
:active="localeStore.currentLocale === opt.value"
@click="chooseLocale(opt.value)"
>
<v-list-item-title>{{ opt.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-btn
text
to="/login"
:class="{ active: currentRoute === '/login' }"
>
{{ t('common.login') }}
</v-btn>
</template>
<template v-else>
<v-btn
class="balance-btn"
variant="text"
min-width="auto"
padding="4 12"
@click="$router.push('/wallet')"
>
<span class="balance-text">${{ userStore.balance }}</span>
</v-btn>
<v-btn
icon
variant="text"
class="avatar-btn"
:aria-label="t('common.user')"
@click="$router.push('/profile')"
>
<v-avatar size="36" color="primary">
<v-img v-if="userStore.avatarUrl" :src="userStore.avatarUrl" cover alt="avatar" />
<v-icon v-else>mdi-account</v-icon>
</v-avatar>
</v-btn>
</template>
</div>
</v-app-bar>
<v-main class="app-main">
<div class="app-main-scroll" data-main-scroll>
<div class="main-content">
<router-view v-slot="{ Component }">
<keep-alive :include="['Home']">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
</div>
</v-main>
<v-bottom-navigation
v-if="showBottomNav"
v-model="bottomNavValue"
app
height="56"
elevation="0"
>
<v-btn value="/" :ripple="false">
<v-icon size="24">mdi-home-outline</v-icon>
<span>{{ t('nav.home') }}</span>
</v-btn>
<v-btn value="/search" :ripple="false">
<v-icon size="24">mdi-magnify</v-icon>
<span>{{ t('nav.search') }}</span>
</v-btn>
<v-btn value="/profile" :ripple="false">
<v-icon size="24">mdi-account-outline</v-icon>
<span>{{ t('nav.mine') }}</span>
</v-btn>
</v-bottom-navigation>
<Toast />
</v-app>
</template>
<style scoped>
.app-bar-inner {
max-width: 1280px;
margin: 0 auto;
width: 100%;
display: flex;
align-items: center;
flex: 1;
padding: 0 16px;
}
.app-main {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.app-main-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.main-content {
max-width: 1280px;
margin: 0 auto;
width: 100%;
}
.active {
font-weight: bold;
text-decoration: underline;
}
.balance-btn {
color: rgba(0, 0, 0, 0.87);
text-transform: none;
}
.balance-text {
font-weight: 500;
font-size: 0.95rem;
}
.back-btn {
color: rgba(0, 0, 0, 0.87);
}
.locale-btn {
color: rgba(0, 0, 0, 0.87);
}
/* 底部导航整条栏上方淡投影z-index 确保覆盖滚动条,显示在滚动条上层 */
:deep(.v-bottom-navigation) {
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
z-index: 1100;
background: #fff !important;
transform: translateZ(0);
}
/* 底部导航:三个入口等分屏幕宽度 */
:deep(.v-bottom-navigation__content) {
width: 100%;
}
:deep(.v-bottom-navigation__content > .v-btn) {
flex: 1 1 0;
min-width: 0;
}
/* 底部导航未选中态:图标与文字偏灰 */
:deep(.v-bottom-navigation__content > .v-btn:not(.v-btn--selected):not(.v-btn--active)) {
color: rgba(0, 0, 0, 0.45);
}
:deep(.v-bottom-navigation__content > .v-btn:not(.v-btn--selected):not(.v-btn--active) .v-icon) {
color: rgba(0, 0, 0, 0.45);
}
/* 底部导航选中态:加粗、无底色 */
:deep(.v-bottom-navigation__content > .v-btn.v-btn--selected),
:deep(.v-bottom-navigation__content > .v-btn.v-btn--active) {
font-weight: 700;
background: transparent !important;
}
:deep(.v-bottom-navigation__content > .v-btn .v-btn__overlay),
:deep(.v-bottom-navigation__content > .v-btn .v-btn__underlay) {
display: none !important;
}
:deep(.v-bottom-navigation__content .v-ripple__container) {
display: none !important;
}
/* 全局:禁止 body 滚动,由 app-main-scroll 内部滚动,滚动条不覆盖底部导航 */
:global(html),
:global(body) {
background: rgb(252, 252, 252);
height: 100%;
overflow: hidden;
}
:global(.v-application) {
background: rgb(252, 252, 252);
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
</style>