新增:底部tab栏
This commit is contained in:
parent
170e788116
commit
6cde1ab561
@ -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]))
|
||||
```
|
||||
|
||||
|
||||
@ -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` 后执行),确保钱包登录、刷新页面后头像和用户名正确显示
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -10,6 +10,11 @@ Vuetify 4 插件配置,注册组件、指令、主题。引入 VPullToRefresh
|
||||
|
||||
- `light`:默认主题,含 primary、secondary、accent 等
|
||||
- `dark`:暗色主题
|
||||
- `background`:全局背景色统一为 `#FCFCFC`(rgb(252,252,252))
|
||||
|
||||
## 全局默认行为
|
||||
|
||||
- `ripple`:全局关闭点击水波纹效果(`defaults.global.ripple = false`)
|
||||
|
||||
## 扩展方式
|
||||
|
||||
|
||||
@ -13,6 +13,11 @@
|
||||
- **搜索**:可按关键词搜索事件
|
||||
- **分类筛选**:选中分类后,自动提取所有层级节点的 `tagIds` 进行事件筛选;切换语言时重新请求分类接口并刷新列表
|
||||
|
||||
## UI 细节
|
||||
- **卡片阴影不裁剪**:列表容器 `.home-list-scroll` 不使用 `overflow-x: hidden`,避免卡片两侧阴影被裁剪
|
||||
|
||||
- **分类行分隔**:`.home-category-layer1-row` 底部有淡色投影,增强与下方内容的层次感
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}/`,
|
||||
|
||||
89
src/App.vue
89
src/App.vue
@ -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>
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
8
src/views/Search.vue
Normal file
@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<Home :initial-search-expanded="true" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Home from './Home.vue'
|
||||
</script>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user