diff --git a/src/components/SwipeTabs.jsx b/src/components/SwipeTabs.jsx
index 41bee5e..5aa1b55 100644
--- a/src/components/SwipeTabs.jsx
+++ b/src/components/SwipeTabs.jsx
@@ -1,68 +1,13 @@
-import { useRef, useState, useEffect } from 'react'
import { Tabs } from 'antd-mobile'
-// 滑屏容器 + 顶栏 tabs
-// - 顶部: antd-mobile Tabs (带底部激活线动画, 仿原生)
-// - 下方: 横向滑屏容器, 手势切换 tab
+// 顶部分类 + 下方仅渲染当前激活 tab 的内容
+// - 顶部: antd-mobile Tabs (点击切换)
+// - 下方: 同时挂载所有 tab, 通过 [hidden] 仅显示激活的那个 (保留各 tab 内部状态)
// props:
// tabs: [{ key, label, render }]
// active: 当前激活 key
-// onChange(key): 切换回调 (用户滑动或点击 tab)
+// onChange(key): 切换回调
export default function SwipeTabs({ tabs, active, onChange }) {
- const idx = Math.max(0, tabs.findIndex((t) => t.key === active))
- const [dragging, setDragging] = useState(false)
- const [offsetPx, setOffsetPx] = useState(0)
- 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)
- setAnimOff(true)
- }
- function onTouchMove(e) {
- if (!dragging) return
- const dx = e.touches[0].clientX - startX.current
- const w = widthRef.current || window.innerWidth
- // 限制左右溢出 (超过边界阻力增大)
- const limited = Math.max(Math.min(dx, w * 0.4), -w * 0.4)
- setOffsetPx(limited)
- }
- function onTouchEnd() {
- if (!dragging) return
- setDragging(false)
- setAnimOff(false)
- const w = widthRef.current || window.innerWidth
- const dx = offsetPx
- let next = idx
- if (dx < -w * 0.18 && idx < tabs.length - 1) next = idx + 1
- else if (dx > w * 0.18 && idx > 0) next = idx - 1
- setOffsetPx(0)
- if (next !== idx) onChange(tabs[next].key)
- }
-
- // 用 -33.33% × idx 作为基础位移, 再叠加拖动偏移
- const basePct = idx * 33.3333
- const dragPct = widthRef.current
- ? (offsetPx / widthRef.current) * 100
- : 0
- const translate = `calc(-${basePct}% + ${dragPct}%)`
-
return (
@@ -70,22 +15,12 @@ export default function SwipeTabs({ tabs, active, onChange }) {
))}
-
-
- {tabs.map((t) => (
-
{t.render()}
- ))}
-
+
+ {tabs.map((t) => (
+
+ {t.render()}
+
+ ))}
)
diff --git a/src/components/TrendBadge.jsx b/src/components/TrendBadge.jsx
new file mode 100644
index 0000000..3561623
--- /dev/null
+++ b/src/components/TrendBadge.jsx
@@ -0,0 +1,54 @@
+// 趋势徽标: 上升 / 下降 / 持平 / 震荡
+// props:
+// trend: 'up' | 'down' | 'flat' | 'volatile'
+// showText: 是否显示文字 (默认 true)
+const META = {
+ up: { icon: '\u2191', text: '上升' },
+ down: { icon: '\u2193', text: '下降' },
+ flat: { icon: '=', text: '持平' },
+ volatile: { icon: '\u223F', text: '震荡' },
+}
+
+// 根据最近 N 个数据点计算趋势:
+// - 数据不足 2 个 → flat
+// - 取最近 6 个点的变化, 整体涨幅 < 1% → flat
+// - 单调递增 → up, 单调递减 → down
+// - 其余 (有升有降) → volatile
+// 返回: { trend, change } (change 为百分比变化, 保留 1 位小数)
+export function calcTrend(rows) {
+ if (!rows || rows.length < 2) return { trend: 'flat', change: 0 }
+ const N = Math.min(6, rows.length)
+ const recent = rows.slice(-N).map((r) => r.value).filter((v) => typeof v === 'number')
+ if (recent.length < 2) return { trend: 'flat', change: 0 }
+ const first = recent[0]
+ const last = recent[recent.length - 1]
+ const change = first === 0 ? 0 : ((last - first) / Math.abs(first)) * 100
+ if (Math.abs(change) < 1) return { trend: 'flat', change }
+ let up = 0, down = 0
+ for (let i = 1; i < recent.length; i++) {
+ if (recent[i] > recent[i - 1]) up++
+ else if (recent[i] < recent[i - 1]) down++
+ }
+ if (up === recent.length - 1) return { trend: 'up', change }
+ if (down === recent.length - 1) return { trend: 'down', change }
+ return { trend: 'volatile', change }
+}
+
+// 取数据序列最后一个有效点: { month, value } 或 null
+export function lastPoint(data) {
+ const rows = (data && data.rows) || []
+ for (let i = rows.length - 1; i >= 0; i--) {
+ if (rows[i] && typeof rows[i].value === 'number') return rows[i]
+ }
+ return null
+}
+
+export default function TrendBadge({ trend, showText = true }) {
+ const m = META[trend] || META.flat
+ return (
+
+ {m.icon}
+ {showText && {m.text}}
+
+ )
+}
diff --git a/src/pages/macro/DataView.jsx b/src/pages/macro/DataView.jsx
index 7ce52d5..a73acac 100644
--- a/src/pages/macro/DataView.jsx
+++ b/src/pages/macro/DataView.jsx
@@ -1,16 +1,17 @@
import { useEffect, useState } from 'react'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
-import { CapsuleTabs, Card } from 'antd-mobile'
+import { Card, List, NavBar } from 'antd-mobile'
import { api } from '../../api/client.js'
+import TrendBadge, { calcTrend, lastPoint } from '../../components/TrendBadge.jsx'
-// 经济数据: 顶部 CapsuleTabs 选指标, 下面渲染图表 + 表格
-// 使用 antd-mobile: CapsuleTabs / Card
-// 多 groupName 的指标: 在 chip 文本中携带分组前缀以便区分
+// 经济数据
+// 列表态 (默认): 列出所有指标, 每行显示最新值 + 趋势徽标, 点击进入详情
+// 详情态: 顶部 NavBar (返回) + 折线图 + 数据表
export default function DataView() {
const [indicators, setIndicators] = useState([])
- const [selected, setSelected] = useState(null)
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
+ const [selected, setSelected] = useState(null) // null = 列表态, 名称 = 详情态
useEffect(() => {
let cancelled = false
@@ -19,7 +20,6 @@ export default function DataView() {
.then((data) => {
if (cancelled) return
setIndicators(data.indicators || [])
- if (data.indicators && data.indicators[0]) setSelected(data.indicators[0].name)
})
.catch((e) => !cancelled && setErr(e.message))
.finally(() => !cancelled && setLoading(false))
@@ -30,65 +30,90 @@ export default function DataView() {
if (err) return
{err}
if (!indicators.length) return
暂无指标数据, 请先执行 npm run db:seed
- // 为 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
+ // 详情态
+ if (selected) {
+ const cur = indicators.find((i) => i.name === selected)
+ if (cur) return
setSelected(null)} />
}
- const cur = indicators.find((i) => i.name === selected)
- const chartData = cur ? buildMonthlySeries(cur.data) : []
+ // 列表态
+ const labelOf = (i) => (i.data && i.data.title) || i.name
return (
-
+
经济数据
-
- {indicators.map((i) => (
-
- ))}
-
-
- {cur && (
-
-
-
- {labelOf(cur)} · 单位 {cur.data.unit || '-'} · 来源 {cur.data.source || 'seed'}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- | 月份 | 数值 |
-
-
- {chartData.map((r) => (
- | {r.month} | {r.value} |
- ))}
-
-
-
-
-
- )}
+
+ {indicators.map((i) => {
+ const lp = lastPoint(i.data)
+ const { trend, change } = calcTrend(i.data && i.data.rows)
+ const unit = (i.data && i.data.unit) || ''
+ return (
+ {lp.value}{unit} · {lp.month}> : '暂无数据'}
+ extra={
+
+ {lp && Math.abs(change) >= 1 && (
+
+ {change > 0 ? '+' : ''}{change.toFixed(1)}%
+
+ )}
+
+
+ }
+ arrowIcon
+ clickable
+ onClick={() => setSelected(i.name)}
+ />
+ )
+ })}
+
)
}
-// 把 data.rows: [{month, value}, ...] 直接返回, 服务端已经按月排好
-function buildMonthlySeries(data) {
- return (data && data.rows) || []
+// 详情: 折线图 + 数据表
+function DataDetail({ indicator, onBack }) {
+ const i = indicator
+ const rows = (i.data && i.data.rows) || []
+ const lp = lastPoint(i.data)
+ const { trend } = calcTrend(rows)
+ const title = (i.data && i.data.title) || i.name
+ return (
+
+
{title}
+
+
+ 单位 {i.data.unit || '-'} · 来源 {i.data.source || 'seed'} ·
+ {lp && <> 最新 {lp.value}{i.data.unit || ''} ({lp.month}) >}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 月份 | 数值 |
+
+
+ {rows.map((r) => (
+ | {r.month} | {r.value} |
+ ))}
+
+
+
+
+
+ )
}
diff --git a/src/pages/macro/StrategyView.jsx b/src/pages/macro/StrategyView.jsx
index 5f2fd8e..91ca552 100644
--- a/src/pages/macro/StrategyView.jsx
+++ b/src/pages/macro/StrategyView.jsx
@@ -1,10 +1,12 @@
import { useEffect, useState } from 'react'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
-import { CapsuleTabs, Card } from 'antd-mobile'
+import { Card, List, NavBar } from 'antd-mobile'
import { api } from '../../api/client.js'
+import TrendBadge, { calcTrend } from '../../components/TrendBadge.jsx'
-// 策略研究: 顶部 CapsuleTabs 选策略, 下面 4 张图 + 表格
-// 使用 antd-mobile: CapsuleTabs / Card
+// 策略研究
+// 列表态 (默认): 列出所有策略, 每行显示主指标 (债务/GDP) 最新值 + 趋势
+// 详情态: 顶部 NavBar (返回) + 4 张图 (debt/gdp/debtToGdp/interestToGdp) + 表格
const SERIES = [
{ key: 'debt', label: '政府债务 (万亿美元)', color: '#8884d8' },
{ key: 'gdp', label: 'GDP (万亿美元)', color: '#3cb44b' },
@@ -12,21 +14,25 @@ const SERIES = [
{ key: 'interestToGdp', label: '债务利息/GDP (%)', color: '#f58231' },
]
+// 取最后一个有效数据点
+function lastRow(rows) {
+ for (let i = rows.length - 1; i >= 0; i--) {
+ if (rows[i] && typeof rows[i].debtToGdp === 'number') return rows[i]
+ }
+ return null
+}
+
export default function StrategyView() {
const [strategies, setStrategies] = useState([])
- const [selected, setSelected] = useState(null)
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
+ const [selected, setSelected] = useState(null)
useEffect(() => {
let cancelled = false
setLoading(true)
api('/strategies')
- .then((data) => {
- if (cancelled) return
- setStrategies(data.strategies || [])
- if (data.strategies && data.strategies[0]) setSelected(data.strategies[0].name)
- })
+ .then((data) => { if (!cancelled) setStrategies(data.strategies || []) })
.catch((e) => !cancelled && setErr(e.message))
.finally(() => !cancelled && setLoading(false))
return () => { cancelled = true }
@@ -36,67 +42,106 @@ export default function StrategyView() {
if (err) return
{err}
if (!strategies.length) return
暂无策略数据
- const cur = strategies.find((s) => s.name === selected)
- const rows = cur ? cur.data.rows : []
+ // 详情态
+ if (selected) {
+ const cur = strategies.find((s) => s.name === selected)
+ if (cur) return
setSelected(null)} />
+ }
+ // 列表态: 主指标 = 债务/GDP, 趋势基于其最近变化
return (
-
+
策略研究
-
- {strategies.map((s) => (
-
- ))}
-
- {cur && (
-
-
-
- 数据 {rows.length} 条 · 来源 {cur.data.source || 'seed'}
-
- {SERIES.map((s) => (
-
-
{s.label}
-
-
-
-
-
-
-
-
-
-
-
+
+ {strategies.map((s) => {
+ const rows = (s.data && s.data.rows) || []
+ const last = lastRow(rows)
+ const { trend, change } = calcTrend(rows)
+ return (
+ {last.debtToGdp}% · 债务利息/GDP {last.interestToGdp}% · {last.year}>
+ : '暂无数据'}
+ extra={
+
+ {last && Math.abs(change) >= 1 && (
+
+ {change > 0 ? '+' : ''}{change.toFixed(1)}%
+
+ )}
+
-
- ))}
-
-
-
-
- | 年份 |
- 债务 |
- GDP |
- 债务/GDP |
- 利息/GDP |
-
-
-
- {rows.map((r) => (
-
- | {r.year} |
- {r.debt} |
- {r.gdp} |
- {r.debtToGdp} |
- {r.interestToGdp} |
-
- ))}
-
-
-
-
+ }
+ arrowIcon
+ clickable
+ onClick={() => setSelected(s.name)}
+ />
+ )
+ })}
+
+
+ )
+}
+
+// 详情: 4 张图 + 表格
+function StrategyDetail({ strategy, onBack }) {
+ const s = strategy
+ const rows = (s.data && s.data.rows) || []
+ const last = lastRow(rows)
+ const { trend } = calcTrend(rows)
+ return (
+
+
{s.description || s.name}
+
+
+ 数据 {rows.length} 条 · 来源 {s.data.source || 'seed'} ·
+ {last && <> 最新 {last.year} 债务/GDP {last.debtToGdp}% >}
+
- )}
+ {SERIES.map((s2) => (
+
+
{s2.label}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ | 年份 |
+ 债务 |
+ GDP |
+ 债务/GDP |
+ 利息/GDP |
+
+
+
+ {rows.map((r) => (
+
+ | {r.year} |
+ {r.debt} |
+ {r.gdp} |
+ {r.debtToGdp} |
+ {r.interestToGdp} |
+
+ ))}
+
+
+
+
)
}
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 17ec4e5..aea0b77 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -14,7 +14,7 @@
--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;
+ --tabbar-h: 72px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -72,6 +72,11 @@ input, textarea, select { font: inherit; }
background: #fff;
border-top: 1px solid var(--color-border);
}
+.app-tabbar.adm-tab-bar .adm-tab-bar-wrap { min-height: var(--tabbar-h); }
+.app-tabbar.adm-tab-bar .adm-tab-bar-item {
+ /* 调高 padding, 把文字在 TabBar 中向上推, 避免下半部被裁切 */
+ padding: 8px 8px 4px;
+}
.tab-icon { display: inline-flex; }
/* 宏观页 SwipeTabs */
@@ -81,36 +86,17 @@ input, textarea, select { font: inherit; }
height: 100%;
min-height: 0;
}
-.swipe-container {
- position: relative;
+.tab-content {
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;
}
+.tab-pane[hidden] { display: none; }
/* ================== 通用组件辅助类 ================== */
-/* 图表卡片: 用于 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%; }
@@ -129,6 +115,47 @@ input, textarea, select { font: inherit; }
.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; }
+/* ================== 列表视图 (DataView/StrategyView 列表态) ================== */
+.list-page { padding: 0 0 16px; }
+.list-page .page-title { padding: 14px 16px 4px; }
+.list-page .adm-list { margin: 0 12px; background: transparent; }
+.list-page .adm-list .adm-list-item {
+ background: #fff;
+ border-radius: 10px;
+ margin-bottom: 10px;
+ padding: 12px 14px;
+ box-shadow: 0 1px 2px rgba(0,0,0,.04);
+}
+.list-page .adm-list .adm-list-item:last-child { margin-bottom: 0; }
+.list-page .adm-list-item-title { font-size: 15px; font-weight: 600; color: var(--color-text); }
+.list-page .adm-list-item-description { color: var(--color-muted); margin-top: 4px; }
+.list-page .row-meta { display: flex; align-items: center; gap: 6px; font-variant-numeric: tabular-nums; }
+.list-page .row-change { font-size: 12px; color: var(--color-muted); }
+/* 趋势徽标: 上升(绿) / 下降(红) / 持平(灰) / 震荡(橙) */
+.trend-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ font-size: 12px;
+ font-weight: 500;
+ padding: 1px 6px;
+ border-radius: 4px;
+ background: rgba(0,0,0,.04);
+ white-space: nowrap;
+}
+.trend-badge .trend-icon { font-weight: 700; }
+.trend-up { color: #00b578; background: rgba(0,181,120,.1); }
+.trend-down { color: #ff3141; background: rgba(255,49,65,.1); }
+.trend-flat { color: #666; background: rgba(0,0,0,.05); }
+.trend-volatile { color: #ff8f1f; background: rgba(255,143,31,.12); }
+
+/* 详情页 (DataView/StrategyView 详情态) */
+.detail-page { display: flex; flex-direction: column; min-height: 100%; }
+.detail-page .adm-nav-bar { background: #fff; border-bottom: 1px solid var(--color-border); }
+.detail-card.adm-card { margin: 12px; border-radius: 10px; }
+.detail-card.adm-card .adm-card-body { padding: 12px; }
+.detail-meta { font-size: 12px; color: var(--color-muted); margin-bottom: 8px; }
+
/* 图表 / 表格 */
.chart-wrap { background: #fafafa; border-radius: 8px; padding: 8px; margin: 8px 0 16px; }
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
diff --git a/vite.config.js b/vite.config.js
index cca0a6a..0630be1 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -2,11 +2,18 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// 移动端优先: 默认按移动视口开发, 同时兼容桌面浏览器
+// HMR 需要 WebSocket; 某些受限 webview (VS Code Simple Browser / 老版 Android WebView) 不支持.
+// 设置 hmr: false 后, Vite 会改用文件监听 + 页面全量刷新兜底, 在任何环境都能用.
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
+ hmr: false, // 关闭 WebSocket HMR, 改用 full-reload
+ watch: {
+ usePolling: true,
+ interval: 300,
+ },
proxy: {
'/api': {
target: 'http://127.0.0.1:3001',
@@ -19,4 +26,4 @@ export default defineConfig({
emptyOutDir: true,
sourcemap: true,
},
-})
\ No newline at end of file
+})