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) => (
-
- {t.render()}
-
- ))}
-
+ {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) => (
+
+ {t.render()}
+
+ ))}
+
+ )}
)
}
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 */
+}
+
/* ================== 通用组件辅助类 ================== */