feat(宏观): 子 tab 整屏左右滑动并与顶部同步
Docker Build and Push / build-image (push) Successful in 4m3s

SwipeTabs 新增 swipeable 开关, Macro 开启, 打卡/监控保持原行为; 使用 swiper 14 实现整屏分页 + 首尾橡皮筋 + 松手吸附 + 方向锁; 滑动/点击/路由双向同步, 子 tab 切换用 replace 不堆历史; 每个 slide 独立竖向滚动, pane 节点缓存避免横滑时重渲染列表; 宏观内部切 tab 不再重复播放整页入场动画
This commit is contained in:
jif2.zhang
2026-11-09 15:12:11 +08:00
parent ec6c9c7677
commit 902c6a0ebe
6 changed files with 151 additions and 29 deletions
+20
View File
@@ -30,6 +30,7 @@
"react-dom": "^18.2.0",
"react-router-dom": "^6.26.0",
"recharts": "^3.8.1",
"swiper": "^14.4.0",
"tauri-plugin-hotswap-api": "^0.0.4",
"ws": "^8.21.2",
"zod": "^4.6.5"
@@ -6773,6 +6774,25 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/swiper": {
"version": "14.4.0",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-14.4.0.tgz",
"integrity": "sha512-6axnAuBsttFJe0gwsM5tT203/Bts0vcI+FwIX6dqFE4pUFVW6qrdRprNFQVxOYMW7V0S6A/KIqJ6JaPpNN2MCw==",
"funding": [
{
"type": "custom",
"url": "https://sponsors.nolimits4web.com"
},
{
"type": "github",
"url": "https://github.com/sponsors/nolimits4web"
}
],
"license": "MIT",
"engines": {
"node": ">= 4.7.0"
}
},
"node_modules/tauri-plugin-hotswap-api": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/tauri-plugin-hotswap-api/-/tauri-plugin-hotswap-api-0.0.4.tgz",
+1
View File
@@ -54,6 +54,7 @@
"react-dom": "^18.2.0",
"react-router-dom": "^6.26.0",
"recharts": "^3.8.1",
"swiper": "^14.4.0",
"tauri-plugin-hotswap-api": "^0.0.4",
"ws": "^8.21.2",
"zod": "^4.6.5"
+71 -11
View File
@@ -1,17 +1,25 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Tabs } from 'antd-mobile'
import { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css'
// 顶部分类 + 下方仅渲染当前激活 tab 的内容
// 顶部分类 + 下方 tab 内容
// - 顶部: antd-mobile Tabs (点击切换)
// - 下方: 同时挂载所有 tab, 通过 [hidden] 仅显示激活的那个 (保留各 tab 内部状态)
// - 下方: 同时挂载所有 tab, 保留各 tab 内部状态
// - 右侧溢出提示: 当 tab 条还能向右滚动时, 右边缘显示渐变 + 箭头,
// 提示"右侧还有更多 tab"; 滚动到末尾后自动消失
// props:
// tabs: [{ key, label, render }]
// active: 当前激活 key
// onChange(key): 切换回调
export default function SwipeTabs({ tabs, active, onChange }) {
// swipeable: 是否开启整屏左右滑动 (默认 false, 打卡/监控保持纯点击切换)
// - true: 每个 tab 一屏, Swiper 提供首尾橡皮筋 + 松手吸附 + 方向锁
// - false: 旧的 [hidden] 方案, 行为与改造前完全一致
const SWIPE_MS = 260
export default function SwipeTabs({ tabs, active, onChange, swipeable = false }) {
const wrapRef = useRef(null)
const swiperRef = useRef(null)
const [more, setMore] = useState(false)
const [headerH, setHeaderH] = useState(45)
@@ -59,6 +67,29 @@ export default function SwipeTabs({ tabs, active, onChange }) {
list.scrollTo({ left: Math.max(0, target), behavior: 'smooth' })
}, [active, tabs.length])
// 外部原因改变 active (点击 tab / 前进后退 / 分类动态加载) 时, 让 Swiper 跟到对应整屏。
// 手指滑动自身触发的 active 更新不会重复动画: 此时 swiper.activeIndex 已等于目标索引。
useEffect(() => {
if (!swipeable) return
const sw = swiperRef.current
if (!sw) return
const idx = tabs.findIndex((t) => t.key === active)
if (idx < 0 || sw.activeIndex === idx) return
sw.slideTo(idx, SWIPE_MS)
}, [swipeable, active, tabs])
const activeIndex = useMemo(() => {
const idx = tabs.findIndex((t) => t.key === active)
return idx < 0 ? 0 : idx
}, [tabs, active])
// 所有 pane 的 React 节点按 tabs 缓存: 切页 (active 变化) 时不重新渲染各 tab 内容,
// 既保留内部状态, 也避免横滑过程中多个列表一起重渲染造成掉帧。
const panes = useMemo(
() => (swipeable ? tabs.map((t) => ({ key: t.key, node: t.render() })) : []),
[swipeable, tabs],
)
return (
<div className="swipe-tabs-wrap" ref={wrapRef}>
<Tabs activeKey={active} onChange={onChange}>
@@ -78,13 +109,42 @@ export default function SwipeTabs({ tabs, active, onChange }) {
<span className="tab-more-arrow">›</span>
</div>
)}
<div className="tab-content">
{tabs.map((t) => (
<div key={t.key} className="tab-pane" hidden={t.key !== active}>
{t.render()}
</div>
))}
</div>
{swipeable ? (
// 整屏横滑: 首尾橡皮筋 (resistance) + 松手吸附, 竖向滚动仍交给浏览器
<Swiper
className="swipe-tabs-swiper"
initialSlide={activeIndex}
slidesPerView={1}
spaceBetween={0}
speed={SWIPE_MS}
threshold={6}
resistance
resistanceRatio={0.65}
followFinger
longSwipesRatio={0.35}
longSwipesMs={250}
shortSwipes
touchAngle={45}
roundLengths
onSwiper={(sw) => { swiperRef.current = sw }}
onSlideChange={(sw) => {
const t = tabs[sw.activeIndex]
if (t && t.key !== active) onChange(t.key)
}}
>
{panes.map((p) => (
<SwiperSlide key={p.key}>{p.node}</SwiperSlide>
))}
</Swiper>
) : (
<div className="tab-content">
{tabs.map((t) => (
<div key={t.key} className="tab-pane" hidden={t.key !== active}>
{t.render()}
</div>
))}
</div>
)}
</div>
)
}
+18 -1
View File
@@ -1,8 +1,17 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import BottomNav from '../components/BottomNav.jsx'
import { isOffline, subscribeOffline } from '../api/client.js'
// 宏观页内部子 tab 的列表路径 (整屏横滑只在这些路径之间切换)
// /macro/news · /macro/articles · /macro/finance/:catId
// 详情页 (多一段) 不算, 进出详情仍要播放整页入场动画
function isMacroListPath(p) {
return p === '/macro/news'
|| p === '/macro/articles'
|| /^\/macro\/finance\/[^/]+\/?$/.test(p)
}
// 移动端应用外壳
// - 顶部安全区: 用 --safe-top 撑开 (由 main.jsx 的 calibrateSafeTop 实测校准)
// - 中间 <main>: flex:1 可滚动主区
@@ -18,7 +27,15 @@ export default function MobileShell() {
// pathname 变化时给页面容器加动画 class (仅播放入场动画, 不重挂载子树,
// 避免破坏 MacroPage 等页内子路由的挂载状态与滚动位置)
const [animating, setAnimating] = useState(false)
const prevPathRef = useRef(null)
useEffect(() => {
const prev = prevPathRef.current
prevPathRef.current = location.pathname
// 宏观内部子 tab 切换已由整屏横滑提供动效, 不要再叠一次整页淡入 + 上移
if (prev !== null && isMacroListPath(prev) && isMacroListPath(location.pathname)) {
setAnimating(false)
return
}
setAnimating(true)
const t = setTimeout(() => setAnimating(false), 320)
return () => clearTimeout(t)
+23 -17
View File
@@ -1,3 +1,4 @@
import { useMemo } from 'react'
import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'
import PageHeader from '../components/PageHeader.jsx'
import SwipeTabs from '../components/SwipeTabs.jsx'
@@ -35,7 +36,23 @@ export default function MacroPage() {
const navigate = useNavigate()
const { info } = useIfinanceInfo()
const active = activeFromPath(location.pathname)
const categories = info?.categories || []
const categories = info?.categories
// tabs 只在分类索引变化时重建: 横滑切页时 SwipeTabs 可复用已缓存的 pane 节点,
// 不会因为父组件重渲染而把所有列表重新渲染一遍
// (必须放在详情页提前 return 之前: hooks 不能有条件跳过)
const tabs = useMemo(() => [
...BASE_TABS.map((t) => ({
key: t.key,
label: t.label,
render: () => (t.key === 'news' ? <NewsView /> : <ArticlesView />),
})),
...(categories || []).map((c) => ({
key: 'finance:' + c.id,
label: c.name,
render: () => <FinanceCategoryView category={c} />,
})),
], [categories])
// 详情页单独走 React Router 渲染 (需要 Route 提供 params), 不参与滑屏
const p = location.pathname
@@ -52,32 +69,21 @@ export default function MacroPage() {
)
}
const tabs = [
...BASE_TABS.map((t) => ({
key: t.key,
label: t.label,
render: () => (t.key === 'news' ? <NewsView /> : <ArticlesView />),
})),
...categories.map((c) => ({
key: 'finance:' + c.id,
label: c.name,
render: () => <FinanceCategoryView category={c} />,
})),
]
// 子 tab 切换用 replace: 横滑多次不会在历史里堆一串记录,
// 返回键语义保持为「离开宏观」; 详情页仍按默认 push
function onChange(key) {
if (key.startsWith('finance:')) {
navigate('/macro/finance/' + key.slice('finance:'.length))
navigate('/macro/finance/' + key.slice('finance:'.length), { replace: true })
return
}
const next = BASE_TABS.find((t) => t.key === key)
if (next) navigate(next.path)
if (next) navigate(next.path, { replace: true })
}
return (
<div className="page-with-navbar">
<PageHeader title="宏观" />
<SwipeTabs tabs={tabs} active={active} onChange={onChange} />
<SwipeTabs tabs={tabs} active={active} onChange={onChange} swipeable />
</div>
)
}
+18
View File
@@ -222,6 +222,24 @@ input, textarea, select { font: inherit; }
}
.tab-pane[hidden] { display: none; }
/* 宏观整屏横滑 (SwipeTabs swipeable): 每个 tab 一屏, 每个 slide 自己竖向滚动。
.tab-content 仍保留给打卡/监控 (纯点击切换, 不进 Swiper)。 */
.swipe-tabs-wrap .swipe-tabs-swiper {
flex: 1 1 0%;
min-height: 0;
width: 100%;
overflow: hidden;
}
.swipe-tabs-swiper .swiper-wrapper { height: 100%; }
.swipe-tabs-swiper .swiper-slide {
height: 100%;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
touch-action: pan-y; /* 竖向滚动交给浏览器, 横向手势交给 Swiper */
}
/* ================== 通用组件辅助类 ================== */