669 lines
17 KiB
Vue
669 lines
17 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 { useMenuStore } from './stores/menu'
|
||
import { storeToRefs } from 'pinia'
|
||
import { useSearchHistory } from './composables/useSearchHistory'
|
||
import type { LocaleCode } from './plugins/i18n'
|
||
import Toast from './components/Toast.vue'
|
||
import { getPmTagMain } from './api/category'
|
||
|
||
const route = useRoute()
|
||
const { t } = useI18n()
|
||
const router = useRouter()
|
||
const userStore = useUserStore()
|
||
const localeStore = useLocaleStore()
|
||
const menuStore = useMenuStore()
|
||
const localeMenuOpen = ref(false)
|
||
|
||
const { categoryTree, categoryLayers, layerActiveValues } = storeToRefs(menuStore)
|
||
const searchHistory = useSearchHistory()
|
||
const searchHistoryList = computed(() => searchHistory.list.value)
|
||
const searchExpanded = ref(false)
|
||
const searchKeyword = ref('')
|
||
const searchInputRef = ref<{ focus: () => void } | null>(null)
|
||
|
||
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 === '/earn-activity' || 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'
|
||
if (p.startsWith('/earn-activity')) return '/earn-activity'
|
||
return '/'
|
||
},
|
||
set(v: string) {
|
||
if (v === '/profile') router.push(mineTargetPath.value)
|
||
else router.push(v)
|
||
},
|
||
})
|
||
|
||
function onBackClick() {
|
||
if (currentRoute.value === '/login') {
|
||
router.replace('/')
|
||
} else {
|
||
router.back()
|
||
}
|
||
}
|
||
|
||
async function refreshUserData() {
|
||
if (!userStore.isLoggedIn) return
|
||
await router.isReady()
|
||
await nextTick()
|
||
await userStore.fetchUserInfo()
|
||
await userStore.fetchUsdcBalance()
|
||
await userStore.fetchPositionsValue()
|
||
}
|
||
|
||
function expandSearch() {
|
||
searchExpanded.value = true
|
||
nextTick(() => {
|
||
searchInputRef.value?.focus()
|
||
})
|
||
}
|
||
|
||
function collapseSearch() {
|
||
searchExpanded.value = false
|
||
searchKeyword.value = ''
|
||
}
|
||
|
||
function onSearchBlur() {
|
||
setTimeout(() => {
|
||
collapseSearch()
|
||
}, 200)
|
||
}
|
||
|
||
function onSearchSubmit() {
|
||
const kw = searchKeyword.value.trim()
|
||
if (kw) {
|
||
searchHistory.add(kw)
|
||
// 触发搜索事件的逻辑,可以利用 store 或者 router 跳转
|
||
router.push({ path: '/', query: { q: kw } })
|
||
}
|
||
collapseSearch()
|
||
}
|
||
|
||
function selectHistoryItem(item: string) {
|
||
searchKeyword.value = item
|
||
onSearchSubmit()
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await nextTick()
|
||
console.log('app onMounted')
|
||
if (layerActiveValues.value.length == 0) {
|
||
const res = await getPmTagMain()
|
||
if (res.code === 0 || res.code === 200) {
|
||
categoryTree.value = res.data
|
||
}
|
||
}
|
||
})
|
||
|
||
watch(
|
||
() => userStore.isLoggedIn,
|
||
(loggedIn) => {
|
||
if (loggedIn) refreshUserData()
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
async function onMenuClick(id: string) {
|
||
if (currentRoute.value != '/') {
|
||
router.push('/')
|
||
}
|
||
await nextTick()
|
||
menuStore.onCategorySelect(0, id)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<v-app>
|
||
<v-app-bar color="surface" elevation="0" height="112" class="flex-row">
|
||
<div class="flex-column header-content">
|
||
<div class="app-bar-inner">
|
||
<v-btn v-if="currentRoute !== '/'" icon variant="text" class="back-btn" :aria-label="t('common.back')"
|
||
@click="onBackClick">
|
||
<v-icon>mdi-arrow-left</v-icon>
|
||
</v-btn>
|
||
<v-app-bar-title v-if="currentRoute === '/'" class="brand-title">
|
||
<div class="brand-lockup">
|
||
<img src="/favicon.svg?v=2" alt="FuturePulse logo" class="brand-logo" />
|
||
|
||
<div class="brand-copy">
|
||
<span class="brand-kicker">Prediction Markets</span>
|
||
<span class="brand-name">FuturePulse</span>
|
||
</div>
|
||
</div>
|
||
</v-app-bar-title>
|
||
|
||
<div class="home-category-layer1-row line1">
|
||
<div class="app-tab-bar" height="48" @update:model-value="menuStore.onCategorySelect(0, $event)">
|
||
<div v-for="item in categoryLayers[0]" class="app-tab-bar-item"
|
||
:class="{ 'app-tab-bar-item-active': item.id === layerActiveValues[0] }" :key="item.id" :value="item.id"
|
||
@click="onMenuClick(item.id)">
|
||
{{ item.label }}
|
||
</div>
|
||
</div>
|
||
<v-btn icon variant="text" class="home-search-btn" :aria-label="t('common.search')" @click="expandSearch">
|
||
<v-icon size="24">mdi-magnify</v-icon>
|
||
</v-btn>
|
||
</div>
|
||
|
||
<v-spacer class="line2"></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')">
|
||
<div class="balance-group">
|
||
<div class="balance-item">
|
||
<span class="balance-label">{{ t('wallet.portfolio') }}</span>
|
||
<span class="balance-value">${{ userStore.totalAssetValue }}</span>
|
||
</div>
|
||
<div class="balance-item">
|
||
<span class="balance-label">{{ t('wallet.cash') }}</span>
|
||
<span class="balance-value">${{ userStore.availableAssetValue }}</span>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
|
||
<!-- 提取的顶部菜单栏与搜索功能 -->
|
||
<div v-if="categoryLayers.length > 0" class="home-category-layer1-wrap">
|
||
<div class="home-category-layer1-row line2">
|
||
<div class="app-tab-bar" height="48" @update:model-value="menuStore.onCategorySelect(0, $event)">
|
||
<div v-for="item in categoryLayers[0]" class="app-tab-bar-item"
|
||
:class="{ 'app-tab-bar-item-active': item.id === layerActiveValues[0] }" :key="item.id" :value="item.id"
|
||
@click="onMenuClick(item.id)">
|
||
{{ item.label }}
|
||
</div>
|
||
</div>
|
||
<v-btn icon variant="text" class="home-search-btn" :aria-label="t('common.search')" @click="expandSearch">
|
||
<v-icon size="24">mdi-magnify</v-icon>
|
||
</v-btn>
|
||
</div>
|
||
|
||
<!-- 搜索展开时:浮层输入框 + 历史记录 -->
|
||
<transition name="home-search-overlay">
|
||
<div v-if="searchExpanded" class="home-search-overlay">
|
||
<div class="home-search-overlay-inner">
|
||
<v-text-field ref="searchInputRef" v-model="searchKeyword" density="compact" hide-details
|
||
:placeholder="t('home.searchPlaceholder')" prepend-inner-icon="mdi-magnify" variant="outlined"
|
||
class="home-search-overlay-field" @blur="onSearchBlur" @keydown.enter="onSearchSubmit" />
|
||
<v-btn icon variant="text" size="small" class="home-search-close-btn" :aria-label="t('common.collapse')"
|
||
@click="collapseSearch">
|
||
<v-icon size="22">mdi-close</v-icon>
|
||
</v-btn>
|
||
</div>
|
||
<div v-if="searchHistoryList.length > 0" class="home-search-history">
|
||
<div class="home-search-history-header">
|
||
<span class="home-search-history-title">{{ t('home.searchHistory') }}</span>
|
||
<v-btn variant="text" size="x-small" color="primary" class="home-search-history-clear"
|
||
@click="searchHistory.clearAll">
|
||
{{ t('common.clear') }}
|
||
</v-btn>
|
||
</div>
|
||
<ul class="home-search-history-list">
|
||
<li v-for="(item, idx) in searchHistoryList" :key="`${item}-${idx}`" class="home-search-history-item">
|
||
<button type="button" class="home-search-history-text" @click="selectHistoryItem(item)">
|
||
{{ item }}
|
||
</button>
|
||
<v-btn icon variant="text" size="x-small" class="home-search-history-delete"
|
||
:aria-label="t('common.delete')" @click.stop="searchHistory.remove(idx)">
|
||
<v-icon size="20">mdi-close</v-icon>
|
||
</v-btn>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
</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="/earn-activity" :ripple="false">
|
||
<v-icon size="24">mdi-lock-outline</v-icon>
|
||
<span>{{ t('nav.activity') }}</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 lang="scss">
|
||
.header-content {
|
||
max-width: 1440px;
|
||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||
height: 112px;
|
||
margin: 0 auto;
|
||
flex: 1;
|
||
}
|
||
|
||
.app-bar-inner {
|
||
margin: 0 auto;
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
flex: 1;
|
||
padding: 0 16px;
|
||
}
|
||
|
||
.brand-title {
|
||
flex: 0 1 auto;
|
||
min-width: 0;
|
||
}
|
||
|
||
.brand-lockup {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.brand-mark-wrap {
|
||
width: 52px;
|
||
height: 52px;
|
||
border-radius: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
|
||
border: 1px solid rgba(45, 95, 175, 0.12);
|
||
box-shadow: 0 8px 18px rgba(45, 95, 175, 0.08);
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.brand-logo {
|
||
width: 48px;
|
||
height: 48px;
|
||
flex: 0 0 auto;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.brand-copy {
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
gap: 2px;
|
||
}
|
||
|
||
.brand-kicker {
|
||
font-size: 0.64rem;
|
||
line-height: 1;
|
||
font-weight: 700;
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
color: #6f7f98;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.brand-name {
|
||
font-size: 1.04rem;
|
||
line-height: 1.1;
|
||
font-weight: 800;
|
||
letter-spacing: -0.02em;
|
||
color: #24498a;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.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 {
|
||
width: 100%;
|
||
}
|
||
|
||
.login-active {
|
||
font-weight: bold;
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.balance-btn {
|
||
color: rgba(0, 0, 0, 0.87);
|
||
text-transform: none;
|
||
}
|
||
|
||
.balance-group {
|
||
display: flex;
|
||
align-items: stretch;
|
||
gap: 16px;
|
||
white-space: nowrap;
|
||
line-height: 1.1;
|
||
}
|
||
|
||
.balance-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 2px;
|
||
}
|
||
|
||
.balance-label {
|
||
font-size: 0.7rem;
|
||
color: rgba(0, 0, 0, 0.5);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.balance-value {
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
color: #3a9b5d;
|
||
}
|
||
|
||
.back-btn {
|
||
color: rgba(0, 0, 0, 0.87);
|
||
}
|
||
|
||
.locale-btn {
|
||
color: rgba(0, 0, 0, 0.87);
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.brand-lockup {
|
||
gap: 10px;
|
||
}
|
||
|
||
.brand-mark-wrap {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 14px;
|
||
}
|
||
|
||
.brand-logo {
|
||
width: 32px;
|
||
height: 32px;
|
||
}
|
||
|
||
.brand-kicker {
|
||
font-size: 0.58rem;
|
||
}
|
||
|
||
.brand-name {
|
||
font-size: 0.96rem;
|
||
}
|
||
|
||
.balance-group {
|
||
gap: 12px;
|
||
}
|
||
|
||
.balance-label {
|
||
font-size: 0.64rem;
|
||
}
|
||
|
||
.balance-value {
|
||
font-size: 0.9rem;
|
||
}
|
||
}
|
||
|
||
/* 底部导航:整条栏上方淡投影;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;
|
||
}
|
||
|
||
/* 第一层:置顶、全宽;sticky 参照 app-main-scroll,top:0 贴容器顶(即 app-bar 下方) */
|
||
.home-category-layer1-wrap {
|
||
width: 100vw;
|
||
background-color: white;
|
||
}
|
||
|
||
.home-category-layer1-row {
|
||
display: none;
|
||
align-items: center;
|
||
flex: 1;
|
||
}
|
||
|
||
.line1 {
|
||
@include gt1024 {
|
||
display: flex;
|
||
};
|
||
}
|
||
|
||
.line2 {
|
||
display: none;
|
||
@include lt1024 {
|
||
display: flex;
|
||
};
|
||
}
|
||
|
||
|
||
.app-tab-bar {
|
||
display: flex;
|
||
padding-left: 8px;
|
||
flex: 1;
|
||
cursor: pointer;
|
||
|
||
.app-tab-bar-item {
|
||
padding: 0 8px;
|
||
}
|
||
|
||
.app-tab-bar-item-active {
|
||
font-weight: bold;
|
||
}
|
||
}
|
||
|
||
.home-category-layer1-actions {
|
||
display: flex;
|
||
flex-shrink: 0;
|
||
gap: 0;
|
||
}
|
||
|
||
.home-search-btn {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.home-search-overlay-enter-active,
|
||
.home-search-overlay-leave-active {
|
||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||
}
|
||
|
||
.home-search-overlay-enter-from,
|
||
.home-search-overlay-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-8px);
|
||
}
|
||
|
||
.v-tabs-height {
|
||
height: 48px;
|
||
}
|
||
|
||
.home-search-overlay {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background: #fff;
|
||
z-index: 2000;
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||
padding: 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.home-search-overlay-inner {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.home-search-overlay-field {
|
||
flex: 1;
|
||
}
|
||
|
||
.home-search-history {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding: 0 4px;
|
||
}
|
||
|
||
.home-search-history-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.home-search-history-title {
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
color: rgba(0, 0, 0, 0.6);
|
||
}
|
||
|
||
.home-search-history-list {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.home-search-history-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 6px 8px;
|
||
border-radius: 6px;
|
||
transition: background-color 0.2s;
|
||
}
|
||
|
||
.home-search-history-item:hover {
|
||
background-color: rgba(0, 0, 0, 0.04);
|
||
}
|
||
|
||
.home-search-history-text {
|
||
flex: 1;
|
||
text-align: left;
|
||
font-size: 0.9rem;
|
||
color: rgba(0, 0, 0, 0.87);
|
||
background: transparent;
|
||
border: none;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
}
|
||
</style>
|