页面调整
This commit is contained in:
parent
7f72bf809b
commit
7fcd0c9569
@ -1,68 +1,13 @@
|
|||||||
import { useRef, useState, useEffect } from 'react'
|
|
||||||
import { Tabs } from 'antd-mobile'
|
import { Tabs } from 'antd-mobile'
|
||||||
|
|
||||||
// 滑屏容器 + 顶栏 tabs
|
// 顶部分类 + 下方仅渲染当前激活 tab 的内容
|
||||||
// - 顶部: antd-mobile Tabs (带底部激活线动画, 仿原生)
|
// - 顶部: antd-mobile Tabs (点击切换)
|
||||||
// - 下方: 横向滑屏容器, 手势切换 tab
|
// - 下方: 同时挂载所有 tab, 通过 [hidden] 仅显示激活的那个 (保留各 tab 内部状态)
|
||||||
// props:
|
// props:
|
||||||
// tabs: [{ key, label, render }]
|
// tabs: [{ key, label, render }]
|
||||||
// active: 当前激活 key
|
// active: 当前激活 key
|
||||||
// onChange(key): 切换回调 (用户滑动或点击 tab)
|
// onChange(key): 切换回调
|
||||||
export default function SwipeTabs({ tabs, active, onChange }) {
|
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 (
|
return (
|
||||||
<div className="swipe-tabs-wrap">
|
<div className="swipe-tabs-wrap">
|
||||||
<Tabs activeKey={active} onChange={onChange}>
|
<Tabs activeKey={active} onChange={onChange}>
|
||||||
@ -70,22 +15,12 @@ export default function SwipeTabs({ tabs, active, onChange }) {
|
|||||||
<Tabs.Tab key={t.key} title={t.label} />
|
<Tabs.Tab key={t.key} title={t.label} />
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<div
|
<div className="tab-content">
|
||||||
ref={containerRef}
|
{tabs.map((t) => (
|
||||||
className="swipe-container"
|
<div key={t.key} className="tab-pane" hidden={t.key !== active}>
|
||||||
onTouchStart={onTouchStart}
|
{t.render()}
|
||||||
onTouchMove={onTouchMove}
|
</div>
|
||||||
onTouchEnd={onTouchEnd}
|
))}
|
||||||
onTouchCancel={onTouchEnd}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={'swipe-track' + (animOff || dragging ? ' no-anim' : '')}
|
|
||||||
style={{ transform: `translateX(${translate})` }}
|
|
||||||
>
|
|
||||||
{tabs.map((t) => (
|
|
||||||
<div key={t.key} className="swipe-pane">{t.render()}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
54
src/components/TrendBadge.jsx
Normal file
54
src/components/TrendBadge.jsx
Normal file
@ -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 (
|
||||||
|
<span className={'trend-badge trend-' + (trend || 'flat')}>
|
||||||
|
<span className="trend-icon">{m.icon}</span>
|
||||||
|
{showText && <span>{m.text}</span>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,16 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
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 { 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() {
|
export default function DataView() {
|
||||||
const [indicators, setIndicators] = useState([])
|
const [indicators, setIndicators] = useState([])
|
||||||
const [selected, setSelected] = useState(null)
|
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selected, setSelected] = useState(null) // null = 列表态, 名称 = 详情态
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@ -19,7 +20,6 @@ export default function DataView() {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setIndicators(data.indicators || [])
|
setIndicators(data.indicators || [])
|
||||||
if (data.indicators && data.indicators[0]) setSelected(data.indicators[0].name)
|
|
||||||
})
|
})
|
||||||
.catch((e) => !cancelled && setErr(e.message))
|
.catch((e) => !cancelled && setErr(e.message))
|
||||||
.finally(() => !cancelled && setLoading(false))
|
.finally(() => !cancelled && setLoading(false))
|
||||||
@ -30,65 +30,90 @@ export default function DataView() {
|
|||||||
if (err) return <div className="error-banner">{err}</div>
|
if (err) return <div className="error-banner">{err}</div>
|
||||||
if (!indicators.length) return <div className="empty">暂无指标数据, 请先执行 npm run db:seed</div>
|
if (!indicators.length) return <div className="empty">暂无指标数据, 请先执行 npm run db:seed</div>
|
||||||
|
|
||||||
// 为 chip label 计算: 多个 group 时显示 "分组 / 标题", 单个 group 只显示标题
|
// 详情态
|
||||||
const groupSet = new Set(indicators.map((i) => i.groupName || ''))
|
if (selected) {
|
||||||
const showGroupPrefix = groupSet.size > 1
|
const cur = indicators.find((i) => i.name === selected)
|
||||||
const labelOf = (i) => {
|
if (cur) return <DataDetail indicator={cur} onBack={() => setSelected(null)} />
|
||||||
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) : []
|
const labelOf = (i) => (i.data && i.data.title) || i.name
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="list-page">
|
||||||
<div className="page-title">经济数据</div>
|
<div className="page-title">经济数据</div>
|
||||||
<CapsuleTabs activeKey={selected || undefined} onChange={setSelected}>
|
<List>
|
||||||
{indicators.map((i) => (
|
{indicators.map((i) => {
|
||||||
<CapsuleTabs.Tab key={i.name} title={labelOf(i)} />
|
const lp = lastPoint(i.data)
|
||||||
))}
|
const { trend, change } = calcTrend(i.data && i.data.rows)
|
||||||
</CapsuleTabs>
|
const unit = (i.data && i.data.unit) || ''
|
||||||
|
return (
|
||||||
{cur && (
|
<List.Item
|
||||||
<div style={{ padding: '0 12px 12px' }}>
|
key={i.name}
|
||||||
<Card className="chart-card">
|
title={labelOf(i)}
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-muted)', marginBottom: 8 }}>
|
description={lp ? <><b>{lp.value}{unit}</b> · {lp.month}</> : '暂无数据'}
|
||||||
{labelOf(cur)} · 单位 {cur.data.unit || '-'} · 来源 {cur.data.source || 'seed'}
|
extra={
|
||||||
</div>
|
<div className="row-meta">
|
||||||
<div className="chart-wrap">
|
{lp && Math.abs(change) >= 1 && (
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<span className="row-change">
|
||||||
<LineChart data={chartData} margin={{ top: 10, right: 16, left: 0, bottom: 0 }}>
|
{change > 0 ? '+' : ''}{change.toFixed(1)}%
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
</span>
|
||||||
<XAxis dataKey="month" tick={{ fontSize: 11 }} />
|
)}
|
||||||
<YAxis tick={{ fontSize: 11 }} />
|
<TrendBadge trend={trend} />
|
||||||
<Tooltip />
|
</div>
|
||||||
<Legend />
|
}
|
||||||
<Line type="monotone" dataKey="value" name={cur.data.unit || '值'} stroke="#1677ff" strokeWidth={2} dot={{ r: 3 }} />
|
arrowIcon
|
||||||
</LineChart>
|
clickable
|
||||||
</ResponsiveContainer>
|
onClick={() => setSelected(i.name)}
|
||||||
</div>
|
/>
|
||||||
<div style={{ maxHeight: 240, overflow: 'auto' }}>
|
)
|
||||||
<table className="data-table">
|
})}
|
||||||
<thead>
|
</List>
|
||||||
<tr><th>月份</th><th style={{ textAlign: 'right' }}>数值</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{chartData.map((r) => (
|
|
||||||
<tr key={r.month}><td>{r.month}</td><td style={{ textAlign: 'right' }}>{r.value}</td></tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把 data.rows: [{month, value}, ...] 直接返回, 服务端已经按月排好
|
// 详情: 折线图 + 数据表
|
||||||
function buildMonthlySeries(data) {
|
function DataDetail({ indicator, onBack }) {
|
||||||
return (data && data.rows) || []
|
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 (
|
||||||
|
<div className="detail-page">
|
||||||
|
<NavBar onBack={onBack}>{title}</NavBar>
|
||||||
|
<Card className="detail-card">
|
||||||
|
<div className="detail-meta">
|
||||||
|
单位 {i.data.unit || '-'} · 来源 {i.data.source || 'seed'} ·
|
||||||
|
{lp && <> 最新 <b style={{ color: 'var(--color-text)' }}>{lp.value}{i.data.unit || ''}</b> ({lp.month}) </>}
|
||||||
|
<TrendBadge trend={trend} />
|
||||||
|
</div>
|
||||||
|
<div className="chart-wrap">
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<LineChart data={rows} margin={{ top: 10, right: 16, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="month" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Line type="monotone" dataKey="value" name={i.data.unit || '值'} stroke="#1677ff" strokeWidth={2} dot={{ r: 3 }} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div style={{ maxHeight: 240, overflow: 'auto' }}>
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>月份</th><th style={{ textAlign: 'right' }}>数值</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.month}><td>{r.month}</td><td style={{ textAlign: 'right' }}>{r.value}</td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
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 { 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 = [
|
const SERIES = [
|
||||||
{ key: 'debt', label: '政府债务 (万亿美元)', color: '#8884d8' },
|
{ key: 'debt', label: '政府债务 (万亿美元)', color: '#8884d8' },
|
||||||
{ key: 'gdp', label: 'GDP (万亿美元)', color: '#3cb44b' },
|
{ key: 'gdp', label: 'GDP (万亿美元)', color: '#3cb44b' },
|
||||||
@ -12,21 +14,25 @@ const SERIES = [
|
|||||||
{ key: 'interestToGdp', label: '债务利息/GDP (%)', color: '#f58231' },
|
{ 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() {
|
export default function StrategyView() {
|
||||||
const [strategies, setStrategies] = useState([])
|
const [strategies, setStrategies] = useState([])
|
||||||
const [selected, setSelected] = useState(null)
|
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selected, setSelected] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
api('/strategies')
|
api('/strategies')
|
||||||
.then((data) => {
|
.then((data) => { if (!cancelled) setStrategies(data.strategies || []) })
|
||||||
if (cancelled) return
|
|
||||||
setStrategies(data.strategies || [])
|
|
||||||
if (data.strategies && data.strategies[0]) setSelected(data.strategies[0].name)
|
|
||||||
})
|
|
||||||
.catch((e) => !cancelled && setErr(e.message))
|
.catch((e) => !cancelled && setErr(e.message))
|
||||||
.finally(() => !cancelled && setLoading(false))
|
.finally(() => !cancelled && setLoading(false))
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
@ -36,67 +42,106 @@ export default function StrategyView() {
|
|||||||
if (err) return <div className="error-banner">{err}</div>
|
if (err) return <div className="error-banner">{err}</div>
|
||||||
if (!strategies.length) return <div className="empty">暂无策略数据</div>
|
if (!strategies.length) return <div className="empty">暂无策略数据</div>
|
||||||
|
|
||||||
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 <StrategyDetail strategy={cur} onBack={() => setSelected(null)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表态: 主指标 = 债务/GDP, 趋势基于其最近变化
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="list-page">
|
||||||
<div className="page-title">策略研究</div>
|
<div className="page-title">策略研究</div>
|
||||||
<CapsuleTabs activeKey={selected || undefined} onChange={setSelected}>
|
<List>
|
||||||
{strategies.map((s) => (
|
{strategies.map((s) => {
|
||||||
<CapsuleTabs.Tab key={s.name} title={s.description || s.name} />
|
const rows = (s.data && s.data.rows) || []
|
||||||
))}
|
const last = lastRow(rows)
|
||||||
</CapsuleTabs>
|
const { trend, change } = calcTrend(rows)
|
||||||
{cur && (
|
return (
|
||||||
<div style={{ padding: '0 12px 12px' }}>
|
<List.Item
|
||||||
<Card className="chart-card">
|
key={s.name}
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-muted)', marginBottom: 8 }}>
|
title={s.description || s.name}
|
||||||
数据 {rows.length} 条 · 来源 {cur.data.source || 'seed'}
|
description={last
|
||||||
</div>
|
? <><b>{last.debtToGdp}%</b> · 债务利息/GDP {last.interestToGdp}% · {last.year}</>
|
||||||
{SERIES.map((s) => (
|
: '暂无数据'}
|
||||||
<div key={s.key} style={{ marginBottom: 12 }}>
|
extra={
|
||||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 4 }}>{s.label}</div>
|
<div className="row-meta">
|
||||||
<div className="chart-wrap">
|
{last && Math.abs(change) >= 1 && (
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<span className="row-change">
|
||||||
<LineChart data={rows} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
|
{change > 0 ? '+' : ''}{change.toFixed(1)}%
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
</span>
|
||||||
<XAxis dataKey="year" tick={{ fontSize: 11 }} />
|
)}
|
||||||
<YAxis tick={{ fontSize: 11 }} />
|
<TrendBadge trend={trend} />
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
|
||||||
<Line type="monotone" dataKey={s.key} name={s.label} stroke={s.color} strokeWidth={2} dot={{ r: 3 }} />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
))}
|
arrowIcon
|
||||||
<div style={{ maxHeight: 220, overflow: 'auto', marginTop: 8 }}>
|
clickable
|
||||||
<table className="data-table">
|
onClick={() => setSelected(s.name)}
|
||||||
<thead>
|
/>
|
||||||
<tr>
|
)
|
||||||
<th>年份</th>
|
})}
|
||||||
<th style={{ textAlign: 'right' }}>债务</th>
|
</List>
|
||||||
<th style={{ textAlign: 'right' }}>GDP</th>
|
</div>
|
||||||
<th style={{ textAlign: 'right' }}>债务/GDP</th>
|
)
|
||||||
<th style={{ textAlign: 'right' }}>利息/GDP</th>
|
}
|
||||||
</tr>
|
|
||||||
</thead>
|
// 详情: 4 张图 + 表格
|
||||||
<tbody>
|
function StrategyDetail({ strategy, onBack }) {
|
||||||
{rows.map((r) => (
|
const s = strategy
|
||||||
<tr key={r.year}>
|
const rows = (s.data && s.data.rows) || []
|
||||||
<td>{r.year}</td>
|
const last = lastRow(rows)
|
||||||
<td style={{ textAlign: 'right' }}>{r.debt}</td>
|
const { trend } = calcTrend(rows)
|
||||||
<td style={{ textAlign: 'right' }}>{r.gdp}</td>
|
return (
|
||||||
<td style={{ textAlign: 'right' }}>{r.debtToGdp}</td>
|
<div className="detail-page">
|
||||||
<td style={{ textAlign: 'right' }}>{r.interestToGdp}</td>
|
<NavBar onBack={onBack}>{s.description || s.name}</NavBar>
|
||||||
</tr>
|
<Card className="detail-card">
|
||||||
))}
|
<div className="detail-meta">
|
||||||
</tbody>
|
数据 {rows.length} 条 · 来源 {s.data.source || 'seed'} ·
|
||||||
</table>
|
{last && <> 最新 <b style={{ color: 'var(--color-text)' }}>{last.year}</b> 债务/GDP <b style={{ color: 'var(--color-text)' }}>{last.debtToGdp}%</b> </>}
|
||||||
</div>
|
<TrendBadge trend={trend} />
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
{SERIES.map((s2) => (
|
||||||
)}
|
<div key={s2.key} style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 4 }}>{s2.label}</div>
|
||||||
|
<div className="chart-wrap">
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<LineChart data={rows} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="year" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Line type="monotone" dataKey={s2.key} name={s2.label} stroke={s2.color} strokeWidth={2} dot={{ r: 3 }} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ maxHeight: 220, overflow: 'auto', marginTop: 8 }}>
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>年份</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>债务</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>GDP</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>债务/GDP</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>利息/GDP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.year}>
|
||||||
|
<td>{r.year}</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>{r.debt}</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>{r.gdp}</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>{r.debtToGdp}</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>{r.interestToGdp}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
--safe-left: env(safe-area-inset-left, 0px);
|
--safe-left: env(safe-area-inset-left, 0px);
|
||||||
--safe-right: env(safe-area-inset-right, 0px);
|
--safe-right: env(safe-area-inset-right, 0px);
|
||||||
--tabbar-h: 50px;
|
--tabbar-h: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@ -72,6 +72,11 @@ input, textarea, select { font: inherit; }
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border-top: 1px solid var(--color-border);
|
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; }
|
.tab-icon { display: inline-flex; }
|
||||||
|
|
||||||
/* 宏观页 SwipeTabs */
|
/* 宏观页 SwipeTabs */
|
||||||
@ -81,36 +86,17 @@ input, textarea, select { font: inherit; }
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.swipe-container {
|
.tab-content {
|
||||||
position: relative;
|
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
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-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-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%; }
|
.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-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; }
|
.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; }
|
.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 { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
|||||||
@ -2,11 +2,18 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
// 移动端优先: 默认按移动视口开发, 同时兼容桌面浏览器
|
// 移动端优先: 默认按移动视口开发, 同时兼容桌面浏览器
|
||||||
|
// HMR 需要 WebSocket; 某些受限 webview (VS Code Simple Browser / 老版 Android WebView) 不支持.
|
||||||
|
// 设置 hmr: false 后, Vite 会改用文件监听 + 页面全量刷新兜底, 在任何环境都能用.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
hmr: false, // 关闭 WebSocket HMR, 改用 full-reload
|
||||||
|
watch: {
|
||||||
|
usePolling: true,
|
||||||
|
interval: 300,
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:3001',
|
target: 'http://127.0.0.1:3001',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user