diff --git a/package-lock.json b/package-lock.json index adc3ee7..1123107 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 93b61a7..ecb339b 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/components/SwipeTabs.jsx b/src/components/SwipeTabs.jsx index e885b2b..a825f0a 100644 --- a/src/components/SwipeTabs.jsx +++ b/src/components/SwipeTabs.jsx @@ -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 (
@@ -78,13 +109,42 @@ export default function SwipeTabs({ tabs, active, onChange }) { ›
)} -
- {tabs.map((t) => ( - - ))} -
+ {swipeable ? ( + // 整屏横滑: 首尾橡皮筋 (resistance) + 松手吸附, 竖向滚动仍交给浏览器 + { swiperRef.current = sw }} + onSlideChange={(sw) => { + const t = tabs[sw.activeIndex] + if (t && t.key !== active) onChange(t.key) + }} + > + {panes.map((p) => ( + {p.node} + ))} + + ) : ( +
+ {tabs.map((t) => ( + + ))} +
+ )} ) } diff --git a/src/layouts/MobileShell.jsx b/src/layouts/MobileShell.jsx index 19cc6c3..a21a71d 100644 --- a/src/layouts/MobileShell.jsx +++ b/src/layouts/MobileShell.jsx @@ -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 实测校准) // - 中间
: 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) diff --git a/src/pages/MacroPage.jsx b/src/pages/MacroPage.jsx index ffa4b47..43f2479 100644 --- a/src/pages/MacroPage.jsx +++ b/src/pages/MacroPage.jsx @@ -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' ? : ), + })), + ...(categories || []).map((c) => ({ + key: 'finance:' + c.id, + label: c.name, + render: () => , + })), + ], [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' ? : ), - })), - ...categories.map((c) => ({ - key: 'finance:' + c.id, - label: c.name, - render: () => , - })), - ] - + // 子 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 (
- +
) } diff --git a/src/styles/globals.css b/src/styles/globals.css index 95133c5..9ccba8b 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -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 */ +} + /* ================== 通用组件辅助类 ================== */