SwipeTabs 新增 swipeable 开关, Macro 开启, 打卡/监控保持原行为; 使用 swiper 14 实现整屏分页 + 首尾橡皮筋 + 松手吸附 + 方向锁; 滑动/点击/路由双向同步, 子 tab 切换用 replace 不堆历史; 每个 slide 独立竖向滚动, pane 节点缓存避免横滑时重渲染列表; 宏观内部切 tab 不再重复播放整页入场动画
This commit is contained in:
Generated
+20
@@ -30,6 +30,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"swiper": "^14.4.0",
|
||||||
"tauri-plugin-hotswap-api": "^0.0.4",
|
"tauri-plugin-hotswap-api": "^0.0.4",
|
||||||
"ws": "^8.21.2",
|
"ws": "^8.21.2",
|
||||||
"zod": "^4.6.5"
|
"zod": "^4.6.5"
|
||||||
@@ -6773,6 +6774,25 @@
|
|||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
"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": {
|
"node_modules/tauri-plugin-hotswap-api": {
|
||||||
"version": "0.0.4",
|
"version": "0.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/tauri-plugin-hotswap-api/-/tauri-plugin-hotswap-api-0.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/tauri-plugin-hotswap-api/-/tauri-plugin-hotswap-api-0.0.4.tgz",
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"swiper": "^14.4.0",
|
||||||
"tauri-plugin-hotswap-api": "^0.0.4",
|
"tauri-plugin-hotswap-api": "^0.0.4",
|
||||||
"ws": "^8.21.2",
|
"ws": "^8.21.2",
|
||||||
"zod": "^4.6.5"
|
"zod": "^4.6.5"
|
||||||
|
|||||||
@@ -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 { Tabs } from 'antd-mobile'
|
||||||
|
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||||
|
import 'swiper/css'
|
||||||
|
|
||||||
// 顶部分类 + 下方仅渲染当前激活 tab 的内容
|
// 顶部分类 + 下方 tab 内容
|
||||||
// - 顶部: antd-mobile Tabs (点击切换)
|
// - 顶部: antd-mobile Tabs (点击切换)
|
||||||
// - 下方: 同时挂载所有 tab, 通过 [hidden] 仅显示激活的那个 (保留各 tab 内部状态)
|
// - 下方: 同时挂载所有 tab, 保留各 tab 内部状态
|
||||||
// - 右侧溢出提示: 当 tab 条还能向右滚动时, 右边缘显示渐变 + 箭头,
|
// - 右侧溢出提示: 当 tab 条还能向右滚动时, 右边缘显示渐变 + 箭头,
|
||||||
// 提示"右侧还有更多 tab"; 滚动到末尾后自动消失
|
// 提示"右侧还有更多 tab"; 滚动到末尾后自动消失
|
||||||
// props:
|
// props:
|
||||||
// tabs: [{ key, label, render }]
|
// tabs: [{ key, label, render }]
|
||||||
// active: 当前激活 key
|
// active: 当前激活 key
|
||||||
// onChange(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 wrapRef = useRef(null)
|
||||||
|
const swiperRef = useRef(null)
|
||||||
const [more, setMore] = useState(false)
|
const [more, setMore] = useState(false)
|
||||||
const [headerH, setHeaderH] = useState(45)
|
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' })
|
list.scrollTo({ left: Math.max(0, target), behavior: 'smooth' })
|
||||||
}, [active, tabs.length])
|
}, [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 (
|
return (
|
||||||
<div className="swipe-tabs-wrap" ref={wrapRef}>
|
<div className="swipe-tabs-wrap" ref={wrapRef}>
|
||||||
<Tabs activeKey={active} onChange={onChange}>
|
<Tabs activeKey={active} onChange={onChange}>
|
||||||
@@ -78,6 +109,34 @@ export default function SwipeTabs({ tabs, active, onChange }) {
|
|||||||
<span className="tab-more-arrow">›</span>
|
<span className="tab-more-arrow">›</span>
|
||||||
</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">
|
<div className="tab-content">
|
||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
<div key={t.key} className="tab-pane" hidden={t.key !== active}>
|
<div key={t.key} className="tab-pane" hidden={t.key !== active}>
|
||||||
@@ -85,6 +144,7 @@ export default function SwipeTabs({ tabs, active, onChange }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Outlet, useLocation } from 'react-router-dom'
|
import { Outlet, useLocation } from 'react-router-dom'
|
||||||
import BottomNav from '../components/BottomNav.jsx'
|
import BottomNav from '../components/BottomNav.jsx'
|
||||||
import { isOffline, subscribeOffline } from '../api/client.js'
|
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 实测校准)
|
// - 顶部安全区: 用 --safe-top 撑开 (由 main.jsx 的 calibrateSafeTop 实测校准)
|
||||||
// - 中间 <main>: flex:1 可滚动主区
|
// - 中间 <main>: flex:1 可滚动主区
|
||||||
@@ -18,7 +27,15 @@ export default function MobileShell() {
|
|||||||
// pathname 变化时给页面容器加动画 class (仅播放入场动画, 不重挂载子树,
|
// pathname 变化时给页面容器加动画 class (仅播放入场动画, 不重挂载子树,
|
||||||
// 避免破坏 MacroPage 等页内子路由的挂载状态与滚动位置)
|
// 避免破坏 MacroPage 等页内子路由的挂载状态与滚动位置)
|
||||||
const [animating, setAnimating] = useState(false)
|
const [animating, setAnimating] = useState(false)
|
||||||
|
const prevPathRef = useRef(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const prev = prevPathRef.current
|
||||||
|
prevPathRef.current = location.pathname
|
||||||
|
// 宏观内部子 tab 切换已由整屏横滑提供动效, 不要再叠一次整页淡入 + 上移
|
||||||
|
if (prev !== null && isMacroListPath(prev) && isMacroListPath(location.pathname)) {
|
||||||
|
setAnimating(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
setAnimating(true)
|
setAnimating(true)
|
||||||
const t = setTimeout(() => setAnimating(false), 320)
|
const t = setTimeout(() => setAnimating(false), 320)
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
|
|||||||
+23
-17
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'
|
import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'
|
||||||
import PageHeader from '../components/PageHeader.jsx'
|
import PageHeader from '../components/PageHeader.jsx'
|
||||||
import SwipeTabs from '../components/SwipeTabs.jsx'
|
import SwipeTabs from '../components/SwipeTabs.jsx'
|
||||||
@@ -35,7 +36,23 @@ export default function MacroPage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { info } = useIfinanceInfo()
|
const { info } = useIfinanceInfo()
|
||||||
const active = activeFromPath(location.pathname)
|
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), 不参与滑屏
|
// 详情页单独走 React Router 渲染 (需要 Route 提供 params), 不参与滑屏
|
||||||
const p = location.pathname
|
const p = location.pathname
|
||||||
@@ -52,32 +69,21 @@ export default function MacroPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
// 子 tab 切换用 replace: 横滑多次不会在历史里堆一串记录,
|
||||||
...BASE_TABS.map((t) => ({
|
// 返回键语义保持为「离开宏观」; 详情页仍按默认 push
|
||||||
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} />,
|
|
||||||
})),
|
|
||||||
]
|
|
||||||
|
|
||||||
function onChange(key) {
|
function onChange(key) {
|
||||||
if (key.startsWith('finance:')) {
|
if (key.startsWith('finance:')) {
|
||||||
navigate('/macro/finance/' + key.slice('finance:'.length))
|
navigate('/macro/finance/' + key.slice('finance:'.length), { replace: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const next = BASE_TABS.find((t) => t.key === key)
|
const next = BASE_TABS.find((t) => t.key === key)
|
||||||
if (next) navigate(next.path)
|
if (next) navigate(next.path, { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-with-navbar">
|
<div className="page-with-navbar">
|
||||||
<PageHeader title="宏观" />
|
<PageHeader title="宏观" />
|
||||||
<SwipeTabs tabs={tabs} active={active} onChange={onChange} />
|
<SwipeTabs tabs={tabs} active={active} onChange={onChange} swipeable />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,6 +222,24 @@ input, textarea, select { font: inherit; }
|
|||||||
}
|
}
|
||||||
.tab-pane[hidden] { display: none; }
|
.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 */
|
||||||
|
}
|
||||||
|
|
||||||
/* ================== 通用组件辅助类 ================== */
|
/* ================== 通用组件辅助类 ================== */
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user