新增:底部tab栏

This commit is contained in:
ivan 2026-03-19 14:14:28 +08:00
parent 170e788116
commit 6cde1ab561
14 changed files with 163 additions and 18 deletions

View File

@ -26,7 +26,7 @@ const chart = createLineChart(containerEl, { width: 400, height: 320 })
const series = addLineSeries(chart, { color: '#2563eb', priceFormat: { type: 'percent', precision: 1 } })
series.setData(lwcData)
// 实时追加/更新单点时,使用 update() 替代 setData() 以获得过渡动画
// 实时追加/更新单点时,使用 update() 替代 setData() 以获得更平滑的过渡效果
series.update(toLwcPoint([Date.now(), 52]))
```

View File

@ -8,8 +8,9 @@
## 核心能力
- 顶部导航栏:返回、PolyMarket 标题、Login 或余额+用户名+头像菜单
- 顶部导航栏:返回、TestMarket 标题、Login 或余额+用户名+头像菜单
- 多语言入口:右侧地球图标(`mdi-earth`+ 当前语言文案,点击打开语言选择菜单
- 移动端底部导航Home / Search / Mine三个 tab 等分屏宽与路由联动Mine 未登录跳转 Login选中态仅加粗、无底色未选中项图标与文字偏灰底部导航上方有淡投影
- 登录态:`userStore.isLoggedIn` 控制展示
- 用户名:`nickName``userName` 显示在头像左侧(有值时)
- 挂载时与 `isLoggedIn` 变为 true 时:拉取用户信息与余额(`router.isReady()` + `nextTick` 后执行),确保钱包登录、刷新页面后头像和用户名正确显示

View File

@ -11,6 +11,7 @@ Vue Router 配置,定义路由表与滚动行为。
| path | name | component |
|------|------|-----------|
| / | home | Home |
| /search | search | Search |
| /trade | trade | Trade |
| /login | login | Login |
| /trade-detail/:id | trade-detail | TradeDetail |

View File

@ -10,6 +10,11 @@ Vuetify 4 插件配置,注册组件、指令、主题。引入 VPullToRefresh
- `light`:默认主题,含 primary、secondary、accent 等
- `dark`:暗色主题
- `background`:全局背景色统一为 `#FCFCFC`rgb(252,252,252)
## 全局默认行为
- `ripple`:全局关闭点击水波纹效果(`defaults.global.ripple = false`
## 扩展方式

View File

@ -13,6 +13,11 @@
- **搜索**:可按关键词搜索事件
- **分类筛选**:选中分类后,自动提取所有层级节点的 `tagIds` 进行事件筛选;切换语言时重新请求分类接口并刷新列表
## UI 细节
- **卡片阴影不裁剪**:列表容器 `.home-list-scroll` 不使用 `overflow-x: hidden`,避免卡片两侧阴影被裁剪
- **分类行分隔**`.home-category-layer1-row` 底部有淡色投影,增强与下方内容的层次感
## 数据流
```

View File

@ -1,9 +1,9 @@
<!DOCTYPE html>
<!doctype html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TestMarket</title>
</head>
<body>

View File

@ -43,11 +43,23 @@ function deploy() {
}
console.log(`🔌 正在通过 SSH 部署到 ${target} ...`)
// 目标服务器 SSH 配置可能较老/策略更严格:在 KEX/HostKey/公钥算法上做一点兼容。
// 如果远端没有这些算法,仍会失败;但能显著提高协商成功率并给出更可读的错误信息。
const sshCmd =
'ssh -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o ConnectTimeout=20 -o KexAlgorithms=curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group14-sha1 -o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa -vvv'
console.log(`rsync ssh command: ${sshCmd}`)
const result = spawnSync(
'rsync',
[
'-avz',
'-vv',
'--delete',
'--progress',
'--stats',
'-e',
sshCmd,
'--exclude=.DS_Store',
`${distDir}/`,
`${target}/`,

View File

@ -2,6 +2,7 @@
import { 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 Toast from './components/Toast.vue'
@ -11,6 +12,7 @@ const { t } = useI18n()
const localeStore = useLocaleStore()
const router = useRouter()
const userStore = useUserStore()
const display = useDisplay()
const currentRoute = computed(() => route.path)
const currentLocaleLabel = computed(() => {
@ -20,6 +22,22 @@ const currentLocaleLabel = computed(() => {
)
})
const showBottomNav = computed(() => display.smAndDown.value)
const mineTargetPath = computed(() => (userStore.isLoggedIn ? '/wallet' : '/login'))
const bottomNavValue = computed({
get() {
const p = currentRoute.value
if (p.startsWith('/wallet')) return '/wallet'
if (p.startsWith('/login')) return '/wallet' // Mine
if (p.startsWith('/search')) return '/search'
return '/'
},
set(v: string) {
if (v === '/wallet') router.push(mineTargetPath.value)
else router.push(v)
},
})
async function refreshUserData() {
if (!userStore.isLoggedIn) return
await router.isReady()
@ -54,7 +72,7 @@ watch(
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<v-app-bar-title v-if="currentRoute === '/'">PolyMarket</v-app-bar-title>
<v-app-bar-title v-if="currentRoute === '/'">TestMarket</v-app-bar-title>
<v-spacer></v-spacer>
<v-menu location="bottom" :close-on-content-click="true" class="locale-menu">
<template #activator="{ props }">
@ -65,7 +83,7 @@ watch(
class="locale-btn"
:aria-label="`${t('common.more')} (${currentLocaleLabel})`"
>
<v-icon size="20">mdi-earth</v-icon>
<v-icon size="24">mdi-earth</v-icon>
<span class="locale-label">{{ currentLocaleLabel }}</span>
</v-btn>
</template>
@ -126,6 +144,28 @@ watch(
</router-view>
</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>Home</span>
</v-btn>
<v-btn value="/search" :ripple="false">
<v-icon size="24">mdi-magnify</v-icon>
<span>Search</span>
</v-btn>
<v-btn value="/wallet" :ripple="false">
<v-icon size="24">mdi-account-outline</v-icon>
<span>Mine</span>
</v-btn>
</v-bottom-navigation>
<Toast />
</v-app>
</template>
@ -179,4 +219,49 @@ watch(
text-overflow: ellipsis;
white-space: nowrap;
}
/* 底部导航:整条栏上方淡投影 */
:deep(.v-bottom-navigation) {
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
}
/* 底部导航:三个入口等分屏幕宽度 */
: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;
}
/* 全局背景:统一为 rgb(252,252,252) */
:global(html),
:global(body) {
background: rgb(252, 252, 252);
}
:global(.v-application) {
background: rgb(252, 252, 252);
}
</style>

View File

@ -11,7 +11,9 @@ import {
type IChartApi,
type ISeriesApi,
type UTCTimestamp,
LastPriceAnimationMode,
} from 'lightweight-charts'
import { trunkSelectStrategy } from 'vuetify/lib/composables/nested/selectStrategies.mjs'
/** 将 UTC 秒时间戳转为「本地时间当作 UTC」的秒时间戳使图表时间轴显示用户当地时间 */
function timeToLocal(utcSeconds: number): UTCTimestamp {
@ -62,7 +64,7 @@ export function toLwcPoint(point: [number, number]): { time: UTCTimestamp; value
/** 创建折线图实例 */
export function createLineChart(
container: HTMLElement,
options?: { width?: number; height?: number }
options?: { width?: number; height?: number },
): IChartApi {
const { width = container.clientWidth, height = 320 } = options ?? {}
return createChart(container, {
@ -98,7 +100,7 @@ export function createLineChart(
/** 添加折线系列 */
export function addLineSeries(
chart: IChartApi,
options?: { color?: string; priceFormat?: { type: 'percent' | 'price'; precision?: number } }
options?: { color?: string; priceFormat?: { type: 'percent' | 'price'; precision?: number } },
): ISeriesApi<'Line'> {
return chart.addSeries(LineSeries, {
color: options?.color ?? '#2563eb',
@ -109,13 +111,14 @@ export function addLineSeries(
options?.priceFormat?.type === 'percent'
? { type: 'percent', precision: options.priceFormat.precision ?? 1 }
: { type: 'price', precision: 2 },
lastPriceAnimation: LastPriceAnimationMode.Continuous,
})
}
/** 添加面积系列(带渐变) */
export function addAreaSeries(
chart: IChartApi,
options?: { color?: string; topColor?: string; bottomColor?: string }
options?: { color?: string; topColor?: string; bottomColor?: string },
): ISeriesApi<'Area'> {
const color = options?.color ?? '#2563eb'
const topColor = options?.topColor ?? color + '40'

View File

@ -11,6 +11,11 @@ export default createVuetify({
VPullToRefresh,
},
directives,
defaults: {
global: {
ripple: false,
},
},
theme: {
defaultTheme: 'light',
themes: {
@ -25,7 +30,7 @@ export default createVuetify({
success: '#34A853',
warning: '#FBBC05',
surface: '#FFFFFF',
background: '#F5F5F5',
background: '#FCFCFC',
},
},
dark: {

View File

@ -5,6 +5,7 @@ import Login from '../views/Login.vue'
import TradeDetail from '../views/TradeDetail.vue'
import EventMarkets from '../views/EventMarkets.vue'
import Wallet from '../views/Wallet.vue'
import Search from '../views/Search.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -14,6 +15,11 @@ const router = createRouter({
name: 'home',
component: Home,
},
{
path: '/search',
name: 'search',
component: Search,
},
{
path: '/trade',
name: 'trade',

View File

@ -21,7 +21,7 @@
:aria-label="t('common.search')"
@click="expandSearch"
>
<v-icon size="20">mdi-magnify</v-icon>
<v-icon size="24">mdi-magnify</v-icon>
</v-btn>
<v-btn
icon
@ -58,7 +58,7 @@
:aria-label="t('common.collapse')"
@click="collapseSearch"
>
<v-icon size="18">mdi-close</v-icon>
<v-icon size="22">mdi-close</v-icon>
</v-btn>
</div>
<div v-if="searchHistoryList.length > 0" class="home-search-history">
@ -95,7 +95,7 @@
:aria-label="t('common.delete')"
@click.stop="searchHistory.remove(idx)"
>
<v-icon size="16">mdi-close</v-icon>
<v-icon size="20">mdi-close</v-icon>
</v-btn>
</li>
</ul>
@ -225,6 +225,14 @@
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
/** 进入页面时是否自动展开搜索(供 /search 路由使用) */
initialSearchExpanded?: boolean
}>(),
{ initialSearchExpanded: false },
)
defineOptions({ name: 'Home' })
import {
ref,
@ -640,6 +648,7 @@ function checkScrollLoad() {
}
onMounted(() => {
if (props.initialSearchExpanded) expandSearch()
loadCategory()
nextTick(() => {
const sentinel = sentinelRef.value
@ -710,7 +719,7 @@ onActivated(() => {
max-width: 2560px;
margin-left: auto;
margin-right: auto;
padding-top: 0 !important;
padding: 0 8px !important;
}
.home-header {
@ -728,6 +737,7 @@ onActivated(() => {
width: 100%;
margin-top: 8px;
min-height: 100%;
padding: 8px;
}
.pull-to-refresh-inner {
@ -737,7 +747,7 @@ onActivated(() => {
/* 不设固定高度与 overflow列表随页面窗口滚动便于 Vue Router scrollBehavior 自动恢复位置 */
.home-list-scroll {
width: 100%;
overflow-x: hidden;
overflow-x: visible;
}
/* 列数由 JS 根据容器宽度与 CARD_MIN_WIDTH 连续计算,避免断点导致 6→4 跳变 */
@ -837,6 +847,7 @@ onActivated(() => {
display: flex;
align-items: center;
min-height: 48px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.home-tab-bar--inline {
@ -890,6 +901,9 @@ onActivated(() => {
.home-search-overlay-field :deep(.v-field) {
background-color: #f8fafc;
}
.home-search-overlay-field :deep(.v-field__prepend-inner .v-icon) {
font-size: 22px;
}
.home-search-overlay-enter-active,
.home-search-overlay-leave-active {

View File

@ -85,7 +85,7 @@ const connectWithWallet = async () => {
scheme,
domain,
address: signer.address,
statement: 'Sign in to PolyMarket',
statement: 'Sign in to TestMarket',
uri: origin,
version: '1',
chainId: chainId,

8
src/views/Search.vue Normal file
View File

@ -0,0 +1,8 @@
<template>
<Home :initial-search-expanded="true" />
</template>
<script setup lang="ts">
import Home from './Home.vue'
</script>