更换 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>
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { SafeArea } from 'antd-mobile'
|
||||
import BottomNav from '../components/BottomNav.jsx'
|
||||
|
||||
// 移动端应用外壳: 底栏 + 滚动主区
|
||||
// 移动端应用外壳
|
||||
// - 顶部 SafeArea: 让出手机状态栏高度
|
||||
// - 中间 <main>: flex:1 可滚动主区
|
||||
// - 底部 TabBar: antd-mobile TabBar 自带底部安全区
|
||||
// 解决: 状态栏遮挡/底部 TabBar 遮挡/左右贴边
|
||||
export default function MobileShell() {
|
||||
return (
|
||||
<div className="app-root">
|
||||
<SafeArea position="top" />
|
||||
<main className="app-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -1,6 +1,9 @@
|
||||
// 远程调试: 仅在 Tauri WebView 内启用, web 模式自动跳过
|
||||
import './debug/pagespy.js'
|
||||
|
||||
// 移动端 UI 框架: 全局样式 + 主题 (antd-mobile 5)
|
||||
import 'antd-mobile/es/global'
|
||||
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
@@ -16,4 +19,4 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
)
|
||||
|
||||
+11
-5
@@ -1,10 +1,16 @@
|
||||
import { ErrorBlock, NavBar } from 'antd-mobile'
|
||||
|
||||
// 首页: 暂留占位, 后续接入监控告警 / 个性化推荐
|
||||
// 使用 antd-mobile ErrorBlock 仿原生空状态页
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="placeholder-page">
|
||||
<div className="icon">⌂</div>
|
||||
<div>首页</div>
|
||||
<div style={{ fontSize: 12, opacity: .7 }}>后续接入: 监控告警 / 个性化推荐 / 收藏的指标</div>
|
||||
<div className="page-with-navbar">
|
||||
<NavBar back={null}>首页</NavBar>
|
||||
<ErrorBlock
|
||||
status="empty"
|
||||
title="首页"
|
||||
description="后续接入: 监控告警 / 个性化推荐 / 收藏的指标"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+53
-23
@@ -1,7 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useLocation, Navigate } from 'react-router-dom'
|
||||
import { Button, Card, Checkbox, Input, Toast } from 'antd-mobile'
|
||||
import { useAuth } from '../auth/AuthContext.jsx'
|
||||
|
||||
// 登录页 (未走 MobileShell, 独立全屏布局)
|
||||
// 使用 antd-mobile: Input / Checkbox / Button / Card / Toast
|
||||
export default function LoginPage() {
|
||||
const { user, login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
@@ -22,6 +25,7 @@ export default function LoginPage() {
|
||||
setBusy(true)
|
||||
try {
|
||||
await login(email.trim(), password, remember)
|
||||
Toast.show({ icon: 'success', content: '登录成功' })
|
||||
navigate(from, { replace: true })
|
||||
} catch (e) {
|
||||
setErr(e.message)
|
||||
@@ -32,30 +36,56 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<form className="login-card" onSubmit={onSubmit}>
|
||||
<div className="login-title">iBoard</div>
|
||||
<form className="login-form" onSubmit={onSubmit}>
|
||||
<div className="login-brand">iBoard</div>
|
||||
<div className="login-subtitle">登录后继续</div>
|
||||
{err && <div className="login-error">{err}</div>}
|
||||
<div className="login-field">
|
||||
<label htmlFor="email">邮箱</label>
|
||||
<input id="email" type="email" autoComplete="username" required value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="login-field">
|
||||
<label htmlFor="password">密码</label>
|
||||
<input id="password" type="password" autoComplete="current-password" required value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<label className="login-options">
|
||||
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} />
|
||||
<span>记住登录 (7 天免登录)</span>
|
||||
</label>
|
||||
<button className="login-btn" type="submit" disabled={busy}>
|
||||
{busy ? '登录中...' : '登录'}
|
||||
</button>
|
||||
<div className="login-hint">
|
||||
默认账号: admin@iboard.local<br />
|
||||
首次登录后请尽快修改密码
|
||||
</div>
|
||||
<Card className="login-card">
|
||||
{err && <div className="login-error">{err}</div>}
|
||||
<div className="login-field">
|
||||
<label htmlFor="email">邮箱</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="请输入邮箱"
|
||||
/>
|
||||
</div>
|
||||
<div className="login-field">
|
||||
<label htmlFor="password">密码</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
<div className="login-options">
|
||||
<Checkbox checked={remember} onChange={setRemember} block>
|
||||
记住登录 (7 天免登录)
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
size="large"
|
||||
type="submit"
|
||||
loading={busy}
|
||||
loadingText="登录中"
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
<div className="login-hint">
|
||||
默认账号: admin@iboard.local<br />
|
||||
首次登录后请尽快修改密码
|
||||
</div>
|
||||
</Card>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+31
-18
@@ -1,36 +1,49 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Avatar, List, NavBar, Toast } from 'antd-mobile'
|
||||
import { useAuth } from '../auth/AuthContext.jsx'
|
||||
|
||||
// 我的页: 顶部 NavBar + 用户头像卡片 + 信息列表
|
||||
// 使用 antd-mobile: NavBar / List / Avatar / Toast
|
||||
export default function MePage() {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function onLogout() {
|
||||
if (!confirm('确定退出登录?')) return
|
||||
const confirmed = window.confirm('确定退出登录?')
|
||||
if (!confirmed) return
|
||||
await logout()
|
||||
Toast.show({ icon: 'success', content: '已退出' })
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="me-header">
|
||||
<div className="me-page">
|
||||
<NavBar back={null} className="me-navbar">我的</NavBar>
|
||||
<div className="me-profile">
|
||||
<Avatar
|
||||
className="me-avatar"
|
||||
src={user?.avatar}
|
||||
style={{ '--size': '64px', '--border-radius': '50%' }}
|
||||
>
|
||||
{(user?.name || '?').slice(0, 1).toUpperCase()}
|
||||
</Avatar>
|
||||
<div className="me-name">{user?.name || '用户'}</div>
|
||||
<div className="me-email">{user?.email}</div>
|
||||
</div>
|
||||
<div className="me-list">
|
||||
<div className="list-item">
|
||||
<span className="label">用户 ID</span>
|
||||
<span className="value">#{user?.id}</span>
|
||||
</div>
|
||||
<div className="list-item">
|
||||
<span className="label">注册时间</span>
|
||||
<span className="value">{user?.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}</span>
|
||||
</div>
|
||||
<div className="list-item" onClick={onLogout} role="button" style={{ cursor: 'pointer' }}>
|
||||
<span className="label danger">退出登录</span>
|
||||
<span className="value">›</span>
|
||||
</div>
|
||||
</div>
|
||||
<List className="me-list" mode="card">
|
||||
<List.Item extra={'#' + (user?.id || '-')}>用户 ID</List.Item>
|
||||
<List.Item extra={user?.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}>
|
||||
注册时间
|
||||
</List.Item>
|
||||
<List.Item
|
||||
onClick={onLogout}
|
||||
clickable
|
||||
arrowIcon={false}
|
||||
className="me-logout"
|
||||
>
|
||||
退出登录
|
||||
</List.Item>
|
||||
</List>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { ErrorBlock, NavBar } from 'antd-mobile'
|
||||
|
||||
// 监控: 暂留占位
|
||||
// 使用 antd-mobile ErrorBlock 仿原生空状态页
|
||||
export default function MonitorPage() {
|
||||
return (
|
||||
<div className="placeholder-page">
|
||||
<div className="icon">◉</div>
|
||||
<div>监控</div>
|
||||
<div style={{ fontSize: 12, opacity: .7 }}>后续接入: 指标阈值告警 / 时序趋势</div>
|
||||
<div className="page-with-navbar">
|
||||
<NavBar back={null}>监控</NavBar>
|
||||
<ErrorBlock
|
||||
status="empty"
|
||||
title="监控"
|
||||
description="后续接入: 指标阈值告警 / 时序趋势"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { NavBar } from 'antd-mobile'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// 文章详情: /macro/articles/:slug
|
||||
// 使用 antd-mobile NavBar 提供原生风格的返回按钮
|
||||
export default function ArticleDetail() {
|
||||
const { slug } = useParams()
|
||||
const navigate = useNavigate()
|
||||
@@ -20,16 +22,19 @@ export default function ArticleDetail() {
|
||||
return () => { cancelled = true }
|
||||
}, [slug])
|
||||
|
||||
if (loading) return <div className="loading">加载中...</div>
|
||||
if (err) return <div className="error-banner">{err}</div>
|
||||
if (!article) return <div className="empty">文章不存在</div>
|
||||
|
||||
return (
|
||||
<div className="article-detail">
|
||||
<div className="article-back" onClick={() => navigate(-1)} role="button">‹ 返回</div>
|
||||
<h1>{article.title}</h1>
|
||||
<div className="meta">{article.date}</div>
|
||||
<div className="article-content" dangerouslySetInnerHTML={{ __html: article.contentHtml }} />
|
||||
<div className="article-page">
|
||||
<NavBar onBack={() => navigate(-1)}>文章详情</NavBar>
|
||||
{loading && <div className="loading">加载中...</div>}
|
||||
{err && <div className="error-banner">{err}</div>}
|
||||
{!loading && !err && !article && <div className="empty">文章不存在</div>}
|
||||
{!loading && !err && article && (
|
||||
<div className="article-detail">
|
||||
<h1>{article.title}</h1>
|
||||
<div className="meta">{article.date}</div>
|
||||
<div className="article-content" dangerouslySetInnerHTML={{ __html: article.contentHtml }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { List } from 'antd-mobile'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// 经济文章: 列表, 点进 /macro/articles/:slug
|
||||
// 使用 antd-mobile List 仿原生设置/通讯录样式
|
||||
export default function ArticlesView() {
|
||||
const [articles, setArticles] = useState([])
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -25,15 +28,23 @@ export default function ArticlesView() {
|
||||
return (
|
||||
<div>
|
||||
<div className="page-title">经济文章</div>
|
||||
<div className="list">
|
||||
<List className="articles-list" mode="card">
|
||||
{articles.map((a) => (
|
||||
<Link key={a.id} to={`/macro/articles/${encodeURIComponent(a.slug)}`} className="list-item">
|
||||
<div className="list-item-title">{a.title}</div>
|
||||
<div className="list-item-meta">{a.date}</div>
|
||||
<div className="list-item-excerpt">{a.excerpt}</div>
|
||||
</Link>
|
||||
<List.Item
|
||||
key={a.id}
|
||||
title={a.title}
|
||||
description={
|
||||
<>
|
||||
<div className="list-item-meta">{a.date}</div>
|
||||
<div className="list-item-excerpt">{a.excerpt}</div>
|
||||
</>
|
||||
}
|
||||
arrowIcon
|
||||
clickable
|
||||
onClick={() => navigate('/macro/articles/' + encodeURIComponent(a.slug))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</List>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
import { CapsuleTabs, Card } from 'antd-mobile'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// 经济数据: 从 /api/indicators 拉所有指标, 顶上一个 chip 行切换, 下面渲染图表+表格
|
||||
// 经济数据: 顶部 CapsuleTabs 选指标, 下面渲染图表 + 表格
|
||||
// 使用 antd-mobile: CapsuleTabs / Card
|
||||
// 多 groupName 的指标: 在 chip 文本中携带分组前缀以便区分
|
||||
export default function DataView() {
|
||||
const [indicators, setIndicators] = useState([])
|
||||
const [selected, setSelected] = useState(null)
|
||||
@@ -27,37 +30,32 @@ export default function DataView() {
|
||||
if (err) return <div className="error-banner">{err}</div>
|
||||
if (!indicators.length) return <div className="empty">暂无指标数据, 请先执行 npm run db:seed</div>
|
||||
|
||||
// 按 groupName 分组, chip 行按组排列
|
||||
const groups = groupBy(indicators, 'groupName')
|
||||
// 为 chip label 计算: 多个 group 时显示 "分组 / 标题", 单个 group 只显示标题
|
||||
const groupSet = new Set(indicators.map((i) => i.groupName || ''))
|
||||
const showGroupPrefix = groupSet.size > 1
|
||||
const labelOf = (i) => {
|
||||
const t = (i.data && i.data.title) || i.name
|
||||
if (showGroupPrefix && i.groupName) return i.groupName + ' · ' + t
|
||||
return t
|
||||
}
|
||||
|
||||
const cur = indicators.find((i) => i.name === selected)
|
||||
const chartData = cur ? buildMonthlySeries(cur.data) : []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-title">经济数据</div>
|
||||
{Object.entries(groups).map(([groupName, list]) => (
|
||||
<div key={groupName}>
|
||||
<div className="section-title">{groupName}</div>
|
||||
<div className="chip-row">
|
||||
{list.map((i) => (
|
||||
<button
|
||||
key={i.name}
|
||||
className={'chip' + (selected === i.name ? ' active' : '')}
|
||||
onClick={() => setSelected(i.name)}
|
||||
>
|
||||
{shortTitle(i)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<CapsuleTabs activeKey={selected || undefined} onChange={setSelected}>
|
||||
{indicators.map((i) => (
|
||||
<CapsuleTabs.Tab key={i.name} title={labelOf(i)} />
|
||||
))}
|
||||
</CapsuleTabs>
|
||||
|
||||
{cur && (
|
||||
<div style={{ padding: '0 12px 12px' }}>
|
||||
<div className="card">
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{cur.data.title || cur.name}</div>
|
||||
<Card className="chart-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--color-muted)', marginBottom: 8 }}>
|
||||
单位 {cur.data.unit || '-'} · 来源 {cur.data.source || 'seed'}
|
||||
{labelOf(cur)} · 单位 {cur.data.unit || '-'} · 来源 {cur.data.source || 'seed'}
|
||||
</div>
|
||||
<div className="chart-wrap">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
@@ -67,7 +65,7 @@ export default function DataView() {
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="value" name={cur.data.unit || '值'} stroke="#2563eb" strokeWidth={2} dot={{ r: 3 }} />
|
||||
<Line type="monotone" dataKey="value" name={cur.data.unit || '值'} stroke="#1677ff" strokeWidth={2} dot={{ r: 3 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -83,27 +81,14 @@ export default function DataView() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function groupBy(arr, key) {
|
||||
return arr.reduce((acc, x) => {
|
||||
const k = x[key] || '其它'
|
||||
;(acc[k] = acc[k] || []).push(x)
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
function shortTitle(i) {
|
||||
if (i.data && i.data.title) return i.data.title
|
||||
return i.name
|
||||
}
|
||||
|
||||
// 把 data.rows: [{month, value}, ...] 直接返回, 服务端已经按月排好
|
||||
function buildMonthlySeries(data) {
|
||||
return (data && data.rows) || []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
import { CapsuleTabs, Card } from 'antd-mobile'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// 策略研究: 顶部 chip 行选策略, 下面显示 4 张图 (debt / gdp / debtToGdp / interestToGdp) + 表格
|
||||
// 策略研究: 顶部 CapsuleTabs 选策略, 下面 4 张图 + 表格
|
||||
// 使用 antd-mobile: CapsuleTabs / Card
|
||||
const SERIES = [
|
||||
{ key: 'debt', label: '政府债务 (万亿美元)', color: '#8884d8' },
|
||||
{ key: 'gdp', label: 'GDP (万亿美元)', color: '#3cb44b' },
|
||||
{ key: 'debtToGdp', label: '债务/GDP (%)', color: '#4363d8' },
|
||||
{ key: 'interestToGdp', label: '债务利息/GDP (%)', color: '#f58231' },
|
||||
{ key: 'debt', label: '政府债务 (万亿美元)', color: '#8884d8' },
|
||||
{ key: 'gdp', label: 'GDP (万亿美元)', color: '#3cb44b' },
|
||||
{ key: 'debtToGdp', label: '债务/GDP (%)', color: '#4363d8' },
|
||||
{ key: 'interestToGdp', label: '债务利息/GDP (%)', color: '#f58231' },
|
||||
]
|
||||
|
||||
export default function StrategyView() {
|
||||
@@ -40,22 +42,14 @@ export default function StrategyView() {
|
||||
return (
|
||||
<div>
|
||||
<div className="page-title">策略研究</div>
|
||||
<div className="chip-row" style={{ paddingLeft: 12 }}>
|
||||
<CapsuleTabs activeKey={selected || undefined} onChange={setSelected}>
|
||||
{strategies.map((s) => (
|
||||
<button
|
||||
key={s.name}
|
||||
className={'chip' + (selected === s.name ? ' active' : '')}
|
||||
onClick={() => setSelected(s.name)}
|
||||
>
|
||||
{s.description || s.name}
|
||||
</button>
|
||||
<CapsuleTabs.Tab key={s.name} title={s.description || s.name} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
</CapsuleTabs>
|
||||
{cur && (
|
||||
<div style={{ padding: '0 12px 12px' }}>
|
||||
<div className="card">
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{cur.description || cur.name}</div>
|
||||
<Card className="chart-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--color-muted)', marginBottom: 8 }}>
|
||||
数据 {rows.length} 条 · 来源 {cur.data.source || 'seed'}
|
||||
</div>
|
||||
@@ -100,9 +94,9 @@ export default function StrategyView() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+105
-652
@@ -1,513 +1,20 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.navbar {
|
||||
background-color: #333;
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.logo a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
/* Global link styles */
|
||||
a {
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background-color: #333;
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Data page layout */
|
||||
.data-layout {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.data-sidebar {
|
||||
width: 250px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.data-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.indicator-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.indicator-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.indicator-item {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background-color: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.indicator-item:hover {
|
||||
background-color: #f0f0f0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.indicator-item.active {
|
||||
background-color: #333;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.indicator-detail {
|
||||
background-color: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.indicator-title {
|
||||
font-size: 1.8rem;
|
||||
color: #333;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.indicator-description {
|
||||
color: #666;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
margin-bottom: 3rem;
|
||||
padding: 1rem;
|
||||
background-color: #fafafa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.data-table-container {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-size: 1.3rem;
|
||||
color: #333;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
/* Articles section */
|
||||
.articles-section {
|
||||
margin-top: 4rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 2rem;
|
||||
color: #333;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.articles-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.article-card {
|
||||
background-color: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.article-card:hover {
|
||||
background-color: #f9f9f9;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.article-date {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.article-excerpt {
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.page-link {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.page-link:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.page-link.active {
|
||||
background-color: #333;
|
||||
color: white;
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
/* Article detail page */
|
||||
.article-detail {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.article-detail-title {
|
||||
font-size: 2.5rem;
|
||||
color: #333;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.article-detail-date {
|
||||
font-size: 1rem;
|
||||
color: #666;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.article-detail-content {
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.article-detail-content h1,
|
||||
.article-detail-content h2,
|
||||
.article-detail-content h3,
|
||||
.article-detail-content h4,
|
||||
.article-detail-content h5,
|
||||
.article-detail-content h6 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.article-detail-content p {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.article-detail-content ul,
|
||||
.article-detail-content ol {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.article-detail-content li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.article-detail-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 2rem 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.data-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.data-sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.article-detail {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.article-detail-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.strategy-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.strategy-sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Strategy page layout */
|
||||
.strategy-layout {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.strategy-sidebar {
|
||||
width: 250px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.strategy-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.strategy-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.strategy-item {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background-color: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.strategy-item:hover {
|
||||
background-color: #f0f0f0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.strategy-item.active {
|
||||
background-color: #333;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.strategy-detail {
|
||||
background-color: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.strategy-title {
|
||||
font-size: 1.8rem;
|
||||
color: #333;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.strategy-description {
|
||||
color: #666;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 1.3rem;
|
||||
color: #333;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
|
||||
/* ================== 移动端优先布局 (mobile-first) ================== */
|
||||
/* ================== 移动端优先布局 (mobile-first, antd-mobile) ================== */
|
||||
|
||||
/* 主题变量: 与 antd-mobile 的 --adm-* 保持一致, 便于组件统一 */
|
||||
:root {
|
||||
--color-bg: #f5f5f5;
|
||||
--color-card: #ffffff;
|
||||
--color-text: #333333;
|
||||
--color-muted: #666666;
|
||||
--color-border: #e5e7eb;
|
||||
--color-primary: #1f2937;
|
||||
--color-accent: #2563eb;
|
||||
--color-danger: #dc2626;
|
||||
--bottom-nav-h: 56px;
|
||||
--top-tabs-h: 44px;
|
||||
--color-border: #eeeeee;
|
||||
--color-primary: #1677ff;
|
||||
--color-accent: #1677ff;
|
||||
--color-danger: #ff3141;
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-left: env(safe-area-inset-left, 0px);
|
||||
--safe-right: env(safe-area-inset-right, 0px);
|
||||
--tabbar-h: 50px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -528,178 +35,86 @@ a { color: inherit; text-decoration: none; cursor: pointer; }
|
||||
button { font: inherit; cursor: pointer; border: none; background: none; color: inherit; }
|
||||
input, textarea, select { font: inherit; }
|
||||
|
||||
/* 应用主框架 */
|
||||
/* 应用主框架: 全屏 flex 列布局, 不再 max-width 居中 (解决"左右不贴边") */
|
||||
.app-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background: var(--color-bg);
|
||||
position: relative;
|
||||
padding-left: var(--safe-left);
|
||||
padding-right: var(--safe-right);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1 1 auto;
|
||||
padding-bottom: calc(var(--bottom-nav-h) + var(--safe-bottom) + 8px);
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom));
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.app-main.has-top-tabs {
|
||||
padding-top: var(--top-tabs-h);
|
||||
}
|
||||
/* 带有 antd-mobile NavBar 的页面 (Home/Monitor/Me/Article) */
|
||||
.page-with-navbar { display: flex; flex-direction: column; min-height: 100%; }
|
||||
.page-with-navbar .adm-error-block { flex: 1; }
|
||||
|
||||
/* 顶栏 tabs (宏观页用) */
|
||||
.top-tabs {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
height: var(--top-tabs-h);
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.top-tabs-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: var(--color-muted);
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.top-tabs-item.active {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.top-tabs-item.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 30%;
|
||||
right: 30%;
|
||||
bottom: 6px;
|
||||
height: 2px;
|
||||
background: var(--color-primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 滑屏容器 */
|
||||
.swipe-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.swipe-track {
|
||||
display: flex;
|
||||
width: 300%;
|
||||
transform: translateX(var(--swipe-offset, 0%));
|
||||
transition: transform 0.25s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.swipe-track.no-anim { transition: none; }
|
||||
|
||||
.swipe-pane {
|
||||
width: 33.3333%;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 底部 tab */
|
||||
.bottom-nav {
|
||||
/* 底部 antd-mobile TabBar 定位 */
|
||||
.app-tabbar.adm-tab-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
height: calc(var(--bottom-nav-h) + var(--safe-bottom));
|
||||
padding-bottom: var(--safe-bottom);
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-top: 1px solid var(--color-border);
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.tab-icon { display: inline-flex; }
|
||||
|
||||
.bottom-nav-item {
|
||||
flex: 1;
|
||||
/* 宏观页 SwipeTabs */
|
||||
.swipe-tabs-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--color-muted);
|
||||
user-select: none;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.swipe-container {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
.swipe-track {
|
||||
display: flex;
|
||||
width: 300%;
|
||||
height: 100%;
|
||||
transform: translateX(var(--swipe-offset, 0%));
|
||||
transition: transform 0.25s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
.swipe-track.no-anim { transition: none; }
|
||||
.swipe-pane {
|
||||
width: 33.3333%;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.bottom-nav-item .icon { font-size: 20px; line-height: 1; }
|
||||
.bottom-nav-item.active { color: var(--color-primary); font-weight: 600; }
|
||||
/* ================== 通用组件辅助类 ================== */
|
||||
|
||||
/* 通用卡片 */
|
||||
.card { background: #fff; border-radius: 10px; padding: 14px 16px; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
|
||||
.card + .card { margin-top: 10px; }
|
||||
|
||||
.page-title { font-size: 20px; font-weight: 700; padding: 14px 16px 4px; }
|
||||
.page-desc { font-size: 13px; color: var(--color-muted); padding: 0 16px 12px; }
|
||||
.section-title { font-size: 16px; font-weight: 600; padding: 14px 16px 8px; }
|
||||
|
||||
/* 列表 */
|
||||
.list { padding: 0 12px; }
|
||||
.list-item { background: #fff; padding: 14px; border-radius: 10px; margin-bottom: 10px; box-shadow: 0 1px 2px rgba(0,0,0,.04); display: block; }
|
||||
.list-item-title { font-size: 15px; font-weight: 600; margin-bottom: 4px; line-height: 1.4; }
|
||||
.list-item-meta { font-size: 12px; color: var(--color-muted); margin-bottom: 6px; }
|
||||
.list-item-excerpt { font-size: 13px; color: var(--color-muted); line-height: 1.5; }
|
||||
|
||||
/* 图表 */
|
||||
.chart-wrap { background: #fafafa; border-radius: 8px; padding: 8px; margin: 8px 0 16px; }
|
||||
|
||||
/* 表格 */
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.data-table th, .data-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--color-border); }
|
||||
.data-table th { background: #f8f8f8; font-weight: 600; }
|
||||
|
||||
/* 登录 */
|
||||
.login-page { min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 24px; background: linear-gradient(180deg, #1f2937 0%, #1f2937 35%, #f5f5f5 35%); }
|
||||
.login-card { background: #fff; width: 100%; max-width: 380px; border-radius: 14px; padding: 24px; box-shadow: 0 4px 24px rgba(0,0,0,.1); }
|
||||
.login-title { font-size: 20px; font-weight: 700; text-align: center; margin-bottom: 6px; }
|
||||
.login-subtitle { text-align: center; color: var(--color-muted); margin-bottom: 20px; font-size: 13px; }
|
||||
.login-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
|
||||
.login-field label { font-size: 12px; color: var(--color-muted); }
|
||||
.login-field input { padding: 12px; border: 1px solid var(--color-border); border-radius: 8px; outline: none; }
|
||||
.login-field input:focus { border-color: var(--color-primary); }
|
||||
.login-options { display: flex; align-items: center; gap: 6px; margin-bottom: 18px; font-size: 13px; color: var(--color-muted); }
|
||||
.login-btn { width: 100%; padding: 13px; background: var(--color-primary); color: #fff; border-radius: 8px; font-weight: 600; }
|
||||
.login-btn:disabled { opacity: 0.6; }
|
||||
.login-hint { text-align: center; font-size: 12px; color: var(--color-muted); margin-top: 14px; line-height: 1.6; }
|
||||
.login-error { background: #fef2f2; color: var(--color-danger); padding: 10px; border-radius: 6px; font-size: 13px; margin-bottom: 12px; }
|
||||
|
||||
/* 我 */
|
||||
.me-header { background: var(--color-primary); color: #fff; padding: 30px 16px 24px; }
|
||||
.me-name { font-size: 18px; font-weight: 600; }
|
||||
.me-email { font-size: 13px; opacity: .8; margin-top: 2px; }
|
||||
.me-list { padding: 12px; }
|
||||
.me-list .list-item { display: flex; align-items: center; justify-content: space-between; }
|
||||
.me-list .list-item .label { font-size: 14px; }
|
||||
.me-list .list-item .value { font-size: 13px; color: var(--color-muted); }
|
||||
.me-list .danger { color: var(--color-danger); }
|
||||
|
||||
.loading, .page-loading { padding: 40px; text-align: center; color: var(--color-muted); }
|
||||
.empty { padding: 40px; text-align: center; color: var(--color-muted); font-size: 13px; }
|
||||
.error-banner { background: #fef2f2; color: var(--color-danger); padding: 10px 14px; font-size: 13px; border-radius: 6px; margin: 12px; }
|
||||
|
||||
/* 占位页 */
|
||||
.placeholder-page { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh; padding: 24px; text-align: center; color: var(--color-muted); gap: 12px; }
|
||||
.placeholder-page .icon { font-size: 48px; opacity: .6; }
|
||||
/* 图表卡片: 用于 DataView/StrategyView */
|
||||
.chart-card.adm-card { padding: 12px; }
|
||||
.chart-card.adm-card .adm-card-body { padding: 0; }
|
||||
|
||||
/* 文章详情 */
|
||||
.article-page { display: flex; flex-direction: column; min-height: 100%; }
|
||||
.article-page .adm-nav-bar { background: #fff; border-bottom: 1px solid var(--color-border); }
|
||||
.article-detail { background: #fff; padding: 18px 16px 24px; }
|
||||
.article-detail h1 { font-size: 20px; line-height: 1.35; margin-bottom: 8px; }
|
||||
.article-detail .meta { font-size: 12px; color: var(--color-muted); margin-bottom: 16px; padding-bottom: 10px; border-bottom: 1px solid var(--color-border); }
|
||||
@@ -708,10 +123,48 @@ input, textarea, select { font: inherit; }
|
||||
.article-content p { margin-bottom: 0.9em; }
|
||||
.article-content ul, .article-content ol { margin: 0 0 1em 1.4em; }
|
||||
.article-content img { max-width: 100%; border-radius: 6px; margin: 0.6em 0; }
|
||||
.article-back { display: inline-flex; align-items: center; gap: 4px; padding: 8px 0; color: var(--color-accent); font-size: 13px; }
|
||||
|
||||
/* 侧边栏式选择器 (移动端改为顶部 chip 行) */
|
||||
.chip-row { display: flex; gap: 8px; overflow-x: auto; padding: 8px 12px; }
|
||||
.chip-row::-webkit-scrollbar { display: none; }
|
||||
.chip { padding: 7px 12px; background: #fff; border: 1px solid var(--color-border); border-radius: 999px; font-size: 13px; white-space: nowrap; }
|
||||
.chip.active { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
|
||||
/* 文章列表项 (用于 ArticlesView) */
|
||||
.articles-list .adm-list-item-description { color: var(--color-muted); }
|
||||
.list-item-meta { font-size: 12px; color: var(--color-muted); margin-bottom: 4px; }
|
||||
.list-item-excerpt { font-size: 13px; color: var(--color-muted); line-height: 1.5; }
|
||||
|
||||
/* 图表 / 表格 */
|
||||
.chart-wrap { background: #fafafa; border-radius: 8px; padding: 8px; margin: 8px 0 16px; }
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.data-table th, .data-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--color-border); }
|
||||
.data-table th { background: #f8f8f8; font-weight: 600; }
|
||||
|
||||
/* 页面标题 */
|
||||
.page-title { font-size: 20px; font-weight: 700; padding: 14px 16px 4px; }
|
||||
|
||||
/* 通用状态 */
|
||||
.loading, .page-loading { padding: 40px; text-align: center; color: var(--color-muted); }
|
||||
.empty { padding: 40px; text-align: center; color: var(--color-muted); font-size: 13px; }
|
||||
.error-banner { background: #fef2f2; color: var(--color-danger); padding: 10px 14px; font-size: 13px; border-radius: 6px; margin: 12px; }
|
||||
|
||||
/* ================== 登录页 (独立全屏, 未走 MobileShell) ================== */
|
||||
.login-page { min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 24px; background: linear-gradient(180deg, #1f2937 0%, #1f2937 38%, #f5f5f5 38%); }
|
||||
.login-form { width: 100%; max-width: 380px; }
|
||||
.login-brand { color: #fff; font-size: 28px; font-weight: 700; text-align: center; margin-bottom: 4px; }
|
||||
.login-subtitle { color: rgba(255,255,255,.7); text-align: center; margin-bottom: 16px; font-size: 13px; }
|
||||
.login-card.adm-card { border-radius: 14px; }
|
||||
.login-card.adm-card .adm-card-body { padding: 22px 18px; }
|
||||
.login-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
|
||||
.login-field label { font-size: 12px; color: var(--color-muted); }
|
||||
.login-field .adm-input { padding: 8px 10px; border: 1px solid var(--color-border); border-radius: 8px; }
|
||||
.login-field .adm-input:focus-within { border-color: var(--color-primary); }
|
||||
.login-options { margin-bottom: 18px; }
|
||||
.login-hint { text-align: center; font-size: 12px; color: var(--color-muted); margin-top: 14px; line-height: 1.6; }
|
||||
.login-error { background: #fef2f2; color: var(--color-danger); padding: 10px; border-radius: 6px; font-size: 13px; margin-bottom: 12px; }
|
||||
|
||||
/* ================== 我的页 ================== */
|
||||
.me-page { display: flex; flex-direction: column; min-height: 100%; background: var(--color-bg); }
|
||||
.me-navbar.adm-nav-bar { background: #fff; border-bottom: 1px solid var(--color-border); }
|
||||
.me-profile { background: linear-gradient(135deg, #1677ff, #4096ff); color: #fff; padding: 24px 16px 28px; display: flex; flex-direction: column; align-items: center; gap: 6px; }
|
||||
.me-avatar { background: rgba(255,255,255,.2); color: #fff; font-size: 28px; }
|
||||
.me-name { font-size: 18px; font-weight: 600; }
|
||||
.me-email { font-size: 13px; opacity: .85; }
|
||||
.me-list { margin-top: 12px; }
|
||||
.me-list .adm-list-item { padding-left: 16px; padding-right: 16px; }
|
||||
.me-logout { color: var(--color-danger); }
|
||||
Reference in New Issue
Block a user