288 lines
7.4 KiB
Vue
288 lines
7.4 KiB
Vue
<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>
|