From 7fcd0c9569d294e296d1f8ab096738d4eb1cd239 Mon Sep 17 00:00:00 2001 From: cheney Date: Thu, 6 Aug 2026 11:26:54 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SwipeTabs.jsx | 85 ++------------- src/components/TrendBadge.jsx | 54 ++++++++++ src/pages/macro/DataView.jsx | 141 +++++++++++++++---------- src/pages/macro/StrategyView.jsx | 175 +++++++++++++++++++------------ src/styles/globals.css | 71 +++++++++---- vite.config.js | 9 +- 6 files changed, 314 insertions(+), 221 deletions(-) create mode 100644 src/components/TrendBadge.jsx 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) => ( + + ))}
) 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)}% + + )} +
-
- ))} -
- - - - - - - - - - - - {rows.map((r) => ( - - - - - - - - ))} - -
年份债务GDP债务/GDP利息/GDP
{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}
+
+ + + + + + + + + + +
+
+ ))} +
+ + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + ))} + +
年份债务GDP债务/GDP利息/GDP
{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 +})