更换 UI

This commit is contained in:
Cheney
2026-08-06 09:32:15 +08:00
parent 28d1b5d1c6
commit 7f72bf809b
21 changed files with 767 additions and 2277 deletions
+40 -17
View File
@@ -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>
)
}
}
+27 -21
View File
@@ -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>
)
}
}
+37
View File
@@ -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>
}