xtraderClient/src/App.vue
2026-07-07 22:35:36 +08:00

669 lines
17 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 { 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-scrolltop: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>