更换 UI
This commit is contained in:
@@ -1,26 +1,49 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { TabBar } from 'antd-mobile'
|
||||
import TabIcon from './TabIcon.jsx'
|
||||
|
||||
// 底部 Tab 配置: key 既是路由前缀, 又是 icon 名
|
||||
// 使用 antd-mobile TabBar: 自带 safe-area 处理, 仿原生 iOS/Android 风格
|
||||
const TABS = [
|
||||
{ to: '/', label: '首页', icon: '⌂' },
|
||||
{ to: '/macro', label: '宏观', icon: '◫' },
|
||||
{ to: '/monitor', label: '监控', icon: '◉' },
|
||||
{ to: '/me', label: '我', icon: '☺' },
|
||||
{ key: '/', name: 'home', label: '首页' },
|
||||
{ key: '/macro', name: 'macro', label: '宏观' },
|
||||
{ key: '/monitor', name: 'monitor', label: '监控' },
|
||||
{ key: '/me', name: 'me', label: '我' },
|
||||
]
|
||||
|
||||
// 根据当前路径计算激活的 tab key (精确匹配 '/' , 其他前缀匹配)
|
||||
function activeKeyFromPath(pathname) {
|
||||
for (const t of TABS) {
|
||||
if (t.key === '/') {
|
||||
if (pathname === '/') return t.key
|
||||
} else if (pathname === t.key || pathname.startsWith(t.key + '/')) {
|
||||
return t.key
|
||||
}
|
||||
}
|
||||
return '/'
|
||||
}
|
||||
|
||||
// 移动端底部 Tab 导航: antd-mobile TabBar, 内置安全区
|
||||
// 作用: 渲染底部 4 个 tab, 切换路由; 激活态根据当前 URL 计算
|
||||
export default function BottomNav() {
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const activeKey = activeKeyFromPath(pathname)
|
||||
|
||||
return (
|
||||
<nav className="bottom-nav" aria-label="主导航">
|
||||
<TabBar
|
||||
activeKey={activeKey}
|
||||
safeArea
|
||||
className="app-tabbar"
|
||||
onChange={navigate}
|
||||
>
|
||||
{TABS.map((t) => (
|
||||
<NavLink
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
end={t.to === '/'}
|
||||
className={({ isActive }) => 'bottom-nav-item' + (isActive ? ' active' : '')}
|
||||
>
|
||||
<span className="icon" aria-hidden="true">{t.icon}</span>
|
||||
<span>{t.label}</span>
|
||||
</NavLink>
|
||||
<TabBar.Item
|
||||
key={t.key}
|
||||
title={t.label}
|
||||
icon={(active) => <TabIcon name={t.name} active={active} />}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</TabBar>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import { Tabs } from 'antd-mobile'
|
||||
|
||||
// 滑屏容器 + 顶栏 tabs
|
||||
// - 顶部: antd-mobile Tabs (带底部激活线动画, 仿原生)
|
||||
// - 下方: 横向滑屏容器, 手势切换 tab
|
||||
// props:
|
||||
// tabs: [{ key, label, render }]
|
||||
// active: 当前激活 key
|
||||
// tabs: [{ key, label, render }]
|
||||
// active: 当前激活 key
|
||||
// onChange(key): 切换回调 (用户滑动或点击 tab)
|
||||
export default function SwipeTabs({ tabs, active, onChange }) {
|
||||
const idx = Math.max(0, tabs.findIndex((t) => t.key === active))
|
||||
@@ -12,10 +15,21 @@ export default function SwipeTabs({ tabs, active, onChange }) {
|
||||
const [animOff, setAnimOff] = useState(false)
|
||||
const startX = useRef(0)
|
||||
const containerRef = useRef(null)
|
||||
const widthRef = useRef(0)
|
||||
|
||||
// idx 变化时关闭拖动动画 (避免快速切换时滑动残影)
|
||||
useEffect(() => { setOffsetPx(0); setAnimOff(false) }, [idx])
|
||||
|
||||
// 监听容器宽度变化, 拖动偏移按宽度百分比计算
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(() => { widthRef.current = el.offsetWidth })
|
||||
ro.observe(el)
|
||||
widthRef.current = el.offsetWidth
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
function onTouchStart(e) {
|
||||
startX.current = e.touches[0].clientX
|
||||
setDragging(true)
|
||||
@@ -24,7 +38,7 @@ export default function SwipeTabs({ tabs, active, onChange }) {
|
||||
function onTouchMove(e) {
|
||||
if (!dragging) return
|
||||
const dx = e.touches[0].clientX - startX.current
|
||||
const w = containerRef.current?.offsetWidth || window.innerWidth
|
||||
const w = widthRef.current || window.innerWidth
|
||||
// 限制左右溢出 (超过边界阻力增大)
|
||||
const limited = Math.max(Math.min(dx, w * 0.4), -w * 0.4)
|
||||
setOffsetPx(limited)
|
||||
@@ -33,7 +47,7 @@ export default function SwipeTabs({ tabs, active, onChange }) {
|
||||
if (!dragging) return
|
||||
setDragging(false)
|
||||
setAnimOff(false)
|
||||
const w = containerRef.current?.offsetWidth || window.innerWidth
|
||||
const w = widthRef.current || window.innerWidth
|
||||
const dx = offsetPx
|
||||
let next = idx
|
||||
if (dx < -w * 0.18 && idx < tabs.length - 1) next = idx + 1
|
||||
@@ -44,26 +58,18 @@ export default function SwipeTabs({ tabs, active, onChange }) {
|
||||
|
||||
// 用 -33.33% × idx 作为基础位移, 再叠加拖动偏移
|
||||
const basePct = idx * 33.3333
|
||||
const dragPct = containerRef.current
|
||||
? (offsetPx / containerRef.current.offsetWidth) * 100
|
||||
const dragPct = widthRef.current
|
||||
? (offsetPx / widthRef.current) * 100
|
||||
: 0
|
||||
const translate = `calc(-${basePct}% + ${dragPct}%)`
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="top-tabs" role="tablist">
|
||||
<div className="swipe-tabs-wrap">
|
||||
<Tabs activeKey={active} onChange={onChange}>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
role="tab"
|
||||
aria-selected={t.key === active}
|
||||
className={'top-tabs-item' + (t.key === active ? ' active' : '')}
|
||||
onClick={() => onChange(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
<Tabs.Tab key={t.key} title={t.label} />
|
||||
))}
|
||||
</div>
|
||||
</Tabs>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="swipe-container"
|
||||
@@ -74,13 +80,13 @@ export default function SwipeTabs({ tabs, active, onChange }) {
|
||||
>
|
||||
<div
|
||||
className={'swipe-track' + (animOff || dragging ? ' no-anim' : '')}
|
||||
style={{ '--swipe-offset': translate, transform: `translateX(${translate})` }}
|
||||
style={{ transform: `translateX(${translate})` }}
|
||||
>
|
||||
{tabs.map((t) => (
|
||||
<div key={t.key} className="swipe-pane">{t.render()}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// 底部 TabBar 图标: 简洁线性 SVG, 仿原生 App 风格
|
||||
// props: name 字符串, active 布尔
|
||||
// 返回对应的 24x24 SVG 节点
|
||||
const ICONS = {
|
||||
home: (active) => (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 11l9-7 9 7" />
|
||||
<path d="M5 10v10h14V10" />
|
||||
{active ? <path d="M10 20v-5h4v5" fill="currentColor" stroke="none" /> : null}
|
||||
</svg>
|
||||
),
|
||||
macro: (active) => (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="9" rx="1" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.15 : 0} />
|
||||
<rect x="14" y="3" width="7" height="5" rx="1" />
|
||||
<rect x="14" y="12" width="7" height="9" rx="1" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.15 : 0} />
|
||||
<rect x="3" y="16" width="7" height="5" rx="1" />
|
||||
</svg>
|
||||
),
|
||||
monitor: (active) => (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12h4l2-5 4 10 2-5h6" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.12 : 0} />
|
||||
</svg>
|
||||
),
|
||||
me: (active) => (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="8" r="4" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.15 : 0} />
|
||||
<path d="M4 20c1.5-4 4.5-6 8-6s6.5 2 8 6" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
|
||||
export default function TabIcon({ name, active }) {
|
||||
const render = ICONS[name]
|
||||
if (!render) return null
|
||||
return <span className="tab-icon">{render(!!active)}</span>
|
||||
}
|
||||
Reference in New Issue
Block a user