feat(宏观): ifinance 分类动态挂载为宏观 tab, 报警迁入监控, 下线油价/本地经济数据/策略研究
Docker Build and Push / build-image (push) Successful in 2m37s
Docker Build and Push / build-image (push) Successful in 2m37s
- 宏观只保留本地 新闻/经济文章, 外接 ifinance categories[] 动态成为 tab (纯前端直连, CORS/_headers, https) - 报警从宏观迁到监控第 4 个 tab (保留 Alert 模块, 供未来新增报警源) - 彻底删除油价/经济数据/策略研究: 前端组件+api、后端 apizero/data 路由、cron、fetchers、种子数据 - 新增迁移 DROP Indicator/IndicatorPoint/Strategy/OilPriceHistory; 重新生成 Prisma Client - CSP 放开 *.honor3.com (含端口通配), 避免新增子域反复重装外壳
This commit is contained in:
+1
-1
@@ -43,7 +43,7 @@ export default function App() {
|
||||
<Route path="/task" element={<TaskPage />} />
|
||||
<Route path="/task/list" element={<TaskPage />} />
|
||||
<Route path="/task/:pid" element={<TaskPage />} />
|
||||
{/* 宏观: 报警 / 新闻 / 油价 / 经济数据 / 策略研究 / 经济文章 (V0.2 原首页并入, 默认新闻) */}
|
||||
{/* 宏观: 新闻 / 经济文章 + 外接 ifinance 分类 (动态 tab); 报警已迁至监控 (V0.2.2) */}
|
||||
<Route path="/macro" element={<Navigate to="/macro/news" replace />} />
|
||||
<Route path="/macro/*" element={<MacroPage />} />
|
||||
<Route path="/monitor" element={<MonitorPage />} />
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// 前端油价 API: 一律走后端 /api/apizero/oil/*, 不直接调 apizero (key 不外泄)
|
||||
import { api, getApiBase } from './client.js'
|
||||
|
||||
// 实时: 油价 + 国际原油 + 下次调价预测
|
||||
export async function getOilForecast() {
|
||||
return api('/apizero/oil/forecast')
|
||||
}
|
||||
|
||||
// 32 省油价 (可暂未在 UI 用, 留作 API 暴露)
|
||||
export async function getOilPriceAll() {
|
||||
return api('/apizero/oil/price-all')
|
||||
}
|
||||
|
||||
// 单省油价
|
||||
export async function getOilPrice(province) {
|
||||
return api('/apizero/oil/price?province=' + encodeURIComponent(province))
|
||||
}
|
||||
|
||||
// 某年调价日历 (后端按年缓存, 同年份不会重复调 apizero)
|
||||
export async function getOilSchedule(year) {
|
||||
return api('/apizero/oil/schedule?year=' + encodeURIComponent(year))
|
||||
}
|
||||
|
||||
// 浙江省油价历史 (走 DB, 由 cron 每天 04:00 抓取并落库)
|
||||
// fuel: '92' | '95' | '0' | undefined (留空 = 3 个油品)
|
||||
// days: 默认 180 (半年)
|
||||
export async function getOilHistory({ fuel, days = 180 } = {}) {
|
||||
const qs = new URLSearchParams()
|
||||
if (fuel) qs.set('fuel', fuel)
|
||||
qs.set('days', String(days))
|
||||
return api('/apizero/oil/history?' + qs.toString())
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// 趋势徽标: 上升 / 下降 / 持平 / 震荡
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// ifinance 外接模块 (宿主侧)
|
||||
// ------------------------------------------------------------
|
||||
// ifinance 是静态托管模块包, 唯一索引是 info.json。宿主约定:
|
||||
// - 根地址固定为 https://ifinance.honor3.com (宿主常量, 不写进 info.json)
|
||||
// - 没有统一入口页, 每个 html 都是独立入口 (在 WebView/iframe 中打开)
|
||||
// - categories[] 对应 App 的 tab, items[] 是条目; 未知字段忽略
|
||||
//
|
||||
// 本模块采用「纯前端直连」方式: 直接 fetch 远端 info.json, 条目 HTML 直接
|
||||
// 用 iframe 加载远端地址, 不做端上文件缓存 (始终最新)。
|
||||
// 开发环境 (vite dev) 走 /ifinance-api 代理以规避跨域; 生产环境直连。
|
||||
// 若远端 info.json 拉取失败, 依次回退: 同源内置快照 (public/ifinance-info.json)
|
||||
// → localStorage 上次成功快照, 保证分类 tab 仍可渲染。
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// 根地址: 生产直连; 开发用 vite 代理前缀 (见 vite.config.js)
|
||||
export const FINANCE_BASE = import.meta.env.DEV
|
||||
? '/ifinance-api'
|
||||
: 'https://ifinance.honor3.com'
|
||||
|
||||
// 同源兜底快照 (构建时由 ifinance/info.json 拷贝到 public/, 仅在远端不可用时使用)
|
||||
const FALLBACK_INFO_URL = '/ifinance-info.json'
|
||||
|
||||
const INFO_CACHE_KEY = 'ifinance_info_v1'
|
||||
|
||||
// 模块级缓存: 一次会话内多处 (宏观页 / 详情页) 共享, 避免重复请求
|
||||
let memoInfo = null
|
||||
let inflight = null
|
||||
|
||||
// 拼接 info.json 中给出的相对路径 (如 public/econ/x.html) 为可加载的绝对 URL
|
||||
export function financeUrl(relPath) {
|
||||
const p = String(relPath || '').replace(/^\/+/, '')
|
||||
return FINANCE_BASE + '/' + p
|
||||
}
|
||||
|
||||
// 条目 HTML 地址: Cloudflare Pages 走 clean URL, 去掉 .html 后缀直达 (避免 307 重定向)
|
||||
export function financePageUrl(relPath) {
|
||||
return financeUrl(String(relPath || '').replace(/\.html?$/i, ''))
|
||||
}
|
||||
|
||||
function readLocalInfo() {
|
||||
try {
|
||||
const raw = localStorage.getItem(INFO_CACHE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && parsed.info && Array.isArray(parsed.info.categories)) return parsed.info
|
||||
} catch { /* ignore */ }
|
||||
return null
|
||||
}
|
||||
|
||||
function writeLocalInfo(info) {
|
||||
try {
|
||||
localStorage.setItem(INFO_CACHE_KEY, JSON.stringify({ ts: Date.now(), info }))
|
||||
} catch { /* 配额满等忽略 */ }
|
||||
}
|
||||
|
||||
async function fetchInfoFrom(url) {
|
||||
const res = await fetch(url, { cache: 'no-cache' })
|
||||
if (!res.ok) throw new Error('ifinance 索引加载失败 (' + res.status + ')')
|
||||
const info = await res.json()
|
||||
if (!info || !Array.isArray(info.categories)) throw new Error('ifinance 索引格式无效')
|
||||
return info
|
||||
}
|
||||
|
||||
// 拉取 (并缓存) info.json: 远端优先, 失败回退同源内置快照
|
||||
export async function fetchIfinanceInfo() {
|
||||
if (inflight) return inflight
|
||||
inflight = (async () => {
|
||||
let info
|
||||
try {
|
||||
info = await fetchInfoFrom(financeUrl('info.json'))
|
||||
} catch (remoteErr) {
|
||||
// 兜底: 同源内置快照 (ifinance 未开 CORS / 临时不可达时仍可渲染分类)
|
||||
try {
|
||||
info = await fetchInfoFrom(FALLBACK_INFO_URL)
|
||||
} catch {
|
||||
throw remoteErr
|
||||
}
|
||||
}
|
||||
memoInfo = info
|
||||
writeLocalInfo(info)
|
||||
return info
|
||||
})()
|
||||
try {
|
||||
return await inflight
|
||||
} finally {
|
||||
inflight = null
|
||||
}
|
||||
}
|
||||
|
||||
// React Hook: 获取分类索引。优先内存 → localStorage 快照 → 远端
|
||||
export function useIfinanceInfo() {
|
||||
const [state, setState] = useState(() => {
|
||||
if (memoInfo) return { info: memoInfo, loading: false, error: '' }
|
||||
const cached = readLocalInfo()
|
||||
return { info: cached, loading: true, error: '' }
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setState((s) => ({ ...s, loading: true }))
|
||||
fetchIfinanceInfo()
|
||||
.then((info) => { if (alive) setState({ info, loading: false, error: '' }) })
|
||||
.catch((e) => {
|
||||
if (!alive) return
|
||||
const cached = readLocalInfo()
|
||||
setState({
|
||||
info: cached,
|
||||
loading: false,
|
||||
error: cached ? '' : (e?.message || '外接模块加载失败'),
|
||||
})
|
||||
})
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
return state
|
||||
}
|
||||
+45
-53
@@ -1,43 +1,31 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'
|
||||
import { Badge } from 'antd-mobile'
|
||||
import PageHeader from '../components/PageHeader.jsx'
|
||||
import SwipeTabs from '../components/SwipeTabs.jsx'
|
||||
import DataView from './macro/DataView.jsx'
|
||||
import StrategyView from './macro/StrategyView.jsx'
|
||||
import ArticlesView from './macro/ArticlesView.jsx'
|
||||
import ArticleDetail from './macro/ArticleDetail.jsx'
|
||||
import OilPage from './macro/OilPage.jsx'
|
||||
import AlertsView from './macro/AlertsView.jsx'
|
||||
import NewsView from './macro/NewsView.jsx'
|
||||
import NewsDetail from './news/NewsDetail.jsx'
|
||||
import FinanceCategoryView from './macro/FinanceCategoryView.jsx'
|
||||
import FinanceItemView from './macro/FinanceItemView.jsx'
|
||||
import { useIfinanceInfo } from '../lib/ifinance.js'
|
||||
|
||||
// 宏观: 顶栏 6 tab, 默认 新闻
|
||||
// /macro → 显示 news tab (activeFromPath 决定)
|
||||
// /macro/alerts → 报警 (V0.2: 原「首页」tab 合并进来, 置最左)
|
||||
// /macro/news → 新闻 (V0.2: 原「首页」tab 合并进来, 置第二并作为默认)
|
||||
// /macro/oil → 油价
|
||||
// /macro/data → 经济数据
|
||||
// /macro/strategy → 策略研究
|
||||
// /macro/articles → 经济文章
|
||||
// 详情: /macro/data/:name · /macro/strategy/:name · /macro/articles/:slug · /macro/news/:id
|
||||
// 说明: tab 数量已超出屏宽, SwipeTabs 会在右侧显示渐变 + 箭头提示可继续滑动
|
||||
const TABS = [
|
||||
{ key: 'alerts', label: '报警', path: '/macro/alerts' },
|
||||
// 宏观: 本地固定 2 个 tab (新闻 / 经济文章) + 外接 ifinance 分类动态挂载为 tab
|
||||
// /macro/news → 新闻 (默认)
|
||||
// /macro/articles → 经济文章
|
||||
// /macro/finance/:catId → 外接分类 (经济数据 / 策略研究 / …)
|
||||
// 详情: /macro/news/:id · /macro/articles/:slug · /macro/finance/:catId/:itemId
|
||||
// 说明: 外接分类不写死, 由 info.json 的 categories[] 决定; ifinance 新增分类后
|
||||
// 本页自动多出新 tab, 宿主无需改代码。报警已迁到「监控」。
|
||||
const BASE_TABS = [
|
||||
{ key: 'news', label: '新闻', path: '/macro/news' },
|
||||
{ key: 'oil', label: '油价', path: '/macro/oil' },
|
||||
{ key: 'data', label: '经济数据', path: '/macro/data' },
|
||||
{ key: 'strategy', label: '策略研究', path: '/macro/strategy' },
|
||||
{ key: 'articles', label: '经济文章', path: '/macro/articles' },
|
||||
]
|
||||
|
||||
function activeFromPath(pathname) {
|
||||
if (pathname.startsWith('/macro/alerts')) return 'alerts'
|
||||
if (pathname.startsWith('/macro/news')) return 'news'
|
||||
if (pathname.startsWith('/macro/oil')) return 'oil'
|
||||
if (pathname.startsWith('/macro/data')) return 'data'
|
||||
if (pathname.startsWith('/macro/strategy')) return 'strategy'
|
||||
if (pathname.startsWith('/macro/articles')) return 'articles'
|
||||
const m = pathname.match(/^\/macro\/finance\/([^/]+)/)
|
||||
if (m) return 'finance:' + m[1]
|
||||
// 父路由 /macro 走默认 tab (新闻)
|
||||
return 'news'
|
||||
}
|
||||
@@ -45,43 +33,47 @@ function activeFromPath(pathname) {
|
||||
export default function MacroPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { info } = useIfinanceInfo()
|
||||
const active = activeFromPath(location.pathname)
|
||||
// 报警角标: 由 AlertsView 回传总数
|
||||
const [alertCount, setAlertCount] = useState(0)
|
||||
const categories = info?.categories || []
|
||||
|
||||
const tabs = TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.key === 'alerts'
|
||||
? <span className="tab-label">报警{alertCount > 0 && <Badge content={alertCount} />}</span>
|
||||
: t.label,
|
||||
render: () => {
|
||||
if (t.key === 'oil') return <OilPage />
|
||||
if (t.key === 'data') return <DataView />
|
||||
if (t.key === 'strategy') return <StrategyView />
|
||||
if (t.key === 'articles') return <ArticlesView />
|
||||
if (t.key === 'alerts') return <AlertsView onCount={setAlertCount} />
|
||||
if (t.key === 'news') return <NewsView />
|
||||
return null
|
||||
},
|
||||
}))
|
||||
|
||||
function onChange(key) {
|
||||
const next = TABS.find((t) => t.key === key)
|
||||
if (next) navigate(next.path)
|
||||
}
|
||||
|
||||
// 详情页单独走 React Router 渲染, 不参与滑屏
|
||||
if (location.pathname.match(/^\/macro\/(articles|data|strategy|news)\/[^/]+$/)) {
|
||||
// 详情页单独走 React Router 渲染 (需要 Route 提供 params), 不参与滑屏
|
||||
const p = location.pathname
|
||||
const isNewsDetail = /^\/macro\/news\/[^/]+$/.test(p)
|
||||
const isArticleDetail = /^\/macro\/articles\/[^/]+$/.test(p)
|
||||
const isFinanceDetail = /^\/macro\/finance\/[^/]+\/[^/]+$/.test(p)
|
||||
if (isNewsDetail || isArticleDetail || isFinanceDetail) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="data/:name" element={<DataView />} />
|
||||
<Route path="strategy/:name" element={<StrategyView />} />
|
||||
<Route path="articles/:slug" element={<ArticleDetail />} />
|
||||
<Route path="news/:id" element={<NewsDetail />} />
|
||||
<Route path="articles/:slug" element={<ArticleDetail />} />
|
||||
<Route path="finance/:catId/:itemId" element={<FinanceItemView />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
...BASE_TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
render: () => (t.key === 'news' ? <NewsView /> : <ArticlesView />),
|
||||
})),
|
||||
...categories.map((c) => ({
|
||||
key: 'finance:' + c.id,
|
||||
label: c.name,
|
||||
render: () => <FinanceCategoryView category={c} />,
|
||||
})),
|
||||
]
|
||||
|
||||
function onChange(key) {
|
||||
if (key.startsWith('finance:')) {
|
||||
navigate('/macro/finance/' + key.slice('finance:'.length))
|
||||
return
|
||||
}
|
||||
const next = BASE_TABS.find((t) => t.key === key)
|
||||
if (next) navigate(next.path)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-with-navbar">
|
||||
<PageHeader title="宏观" />
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Empty, DotLoading } from 'antd-mobile'
|
||||
import { Badge, Card, Empty, DotLoading } from 'antd-mobile'
|
||||
import { api } from '../api/client.js'
|
||||
import PageHeader from '../components/PageHeader.jsx'
|
||||
import SwipeTabs from '../components/SwipeTabs.jsx'
|
||||
import useRefreshOnVisible from '../hooks/useRefreshOnVisible.js'
|
||||
import EnvView from './monitor/EnvView.jsx'
|
||||
import AlertsView from './monitor/AlertsView.jsx'
|
||||
|
||||
// 监控页: 两个横向分类
|
||||
// 监控页: 四个横向分类
|
||||
// - 主机监控: 实时数据来自 komari (服务端实时代理, 不落库), 点击节点看 24h 时序
|
||||
// - 服务监控: 暂留空占位
|
||||
// - 环境监控: 米家温湿度
|
||||
// - 报警: V0.2.2 从「宏观」迁入, 数据差异/任务异常按时间倒序
|
||||
function fmtTime(s) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
@@ -40,10 +43,21 @@ function pct(used, total) {
|
||||
|
||||
export default function MonitorPage() {
|
||||
const [active, setActive] = useState('servers')
|
||||
// 报警角标: 由 AlertsView 回传总数
|
||||
const [alertCount, setAlertCount] = useState(0)
|
||||
const tabs = [
|
||||
{ key: 'servers', label: '主机监控', render: () => <ServerView /> },
|
||||
{ key: 'services', label: '服务监控', render: () => <ServiceView /> },
|
||||
{ key: 'env', label: '环境监控', render: () => <EnvView /> },
|
||||
{
|
||||
key: 'alerts',
|
||||
label: (
|
||||
<span className="tab-label">
|
||||
报警{alertCount > 0 && <Badge content={alertCount} />}
|
||||
</span>
|
||||
),
|
||||
render: () => <AlertsView onCount={setAlertCount} />,
|
||||
},
|
||||
]
|
||||
return (
|
||||
<div className="page-with-navbar">
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
import { Card, List, NavBar } from 'antd-mobile'
|
||||
import { api } from '../../api/client.js'
|
||||
import TrendBadge, { calcTrend, lastPoint } from '../../components/TrendBadge.jsx'
|
||||
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
|
||||
|
||||
// 经济数据
|
||||
// 列表态 (默认): 列出所有指标, 每行显示最新值 + 趋势徽标, 点击进入详情
|
||||
// 详情态: 顶部 NavBar (返回) + 折线图 + 数据表
|
||||
// 频率中文标签 (对应 Indicator.frequency)
|
||||
const FREQ_LABEL = { year: '年', quarter: '季', month: '月', day: '日' }
|
||||
|
||||
// 统一数据行的时间字段: 兼容 {month}/{year}/{period} 三种存储形状
|
||||
function periodOf(r) {
|
||||
return (r && (r.period || r.month || r.year)) || ''
|
||||
}
|
||||
|
||||
export default function DataView() {
|
||||
const [indicators, setIndicators] = useState([])
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
// 路由驱动: /macro/data 列表, /macro/data/:name 详情, 浏览器/手机返回键天然可用
|
||||
const { name } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true)
|
||||
api('/indicators')
|
||||
.then((data) => { setErr(''); setIndicators(data.indicators || []) })
|
||||
.catch((e) => setErr(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
// 后台回前台时重新拉取
|
||||
useRefreshOnVisible(load)
|
||||
|
||||
if (loading) return <div className="loading">加载中...</div>
|
||||
if (err) return <div className="error-banner">{err}</div>
|
||||
if (!indicators.length) return <div className="empty">暂无指标数据, 请先执行 npm run db:seed</div>
|
||||
|
||||
// 详情态 (路径 /macro/data/:name)
|
||||
if (name) {
|
||||
const cur = indicators.find((i) => i.name === name)
|
||||
if (cur) return <DataDetail indicator={cur} onBack={() => navigate('/macro/data')} />
|
||||
}
|
||||
|
||||
// 列表态
|
||||
const labelOf = (i) => i.title || i.name
|
||||
|
||||
return (
|
||||
<div className="list-page">
|
||||
<div className="page-title">经济数据</div>
|
||||
<List>
|
||||
{indicators.map((i) => {
|
||||
const lp = lastPoint({ rows: i.points || [] })
|
||||
const { trend, change } = calcTrend(i.points || [])
|
||||
const unit = i.unit || ''
|
||||
return (
|
||||
<List.Item
|
||||
key={i.name}
|
||||
title={labelOf(i)}
|
||||
description={lp ? <><b>{lp.value}{unit}</b> · {periodOf(lp)}</> : '暂无数据'}
|
||||
extra={<TrendBadge trend={trend} />}
|
||||
|
||||
arrowIcon
|
||||
clickable
|
||||
onClick={() => navigate('/macro/data/' + i.name)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 详情: 折线图 + 数据表
|
||||
function DataDetail({ indicator, onBack }) {
|
||||
const i = indicator
|
||||
const rows = (i.points || [])
|
||||
// 图表 X 轴统一用 period 字段
|
||||
const chartRows = rows.map((r) => ({ ...r, period: periodOf(r) }))
|
||||
// 表格按时间反序: 最新在前
|
||||
const tableRows = [...rows].reverse()
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
const visibleRows = showAll ? tableRows : tableRows.slice(0, 10)
|
||||
const lp = lastPoint({ rows })
|
||||
const { trend } = calcTrend(rows)
|
||||
const title = i.title || i.name
|
||||
return (
|
||||
<div className="detail-page">
|
||||
<NavBar onBack={onBack}>{title}</NavBar>
|
||||
<Card className="detail-card">
|
||||
<div className="detail-meta">
|
||||
单位 {i.unit || '-'} · 频率 {FREQ_LABEL[i.frequency] || i.frequency || '-'} · 来源 {i.source || 'seed'} ·
|
||||
{lp && <> 最新 <b style={{ color: 'var(--color-text)' }}>{lp.value}{i.unit || ''}</b> ({periodOf(lp)}) </>}
|
||||
<TrendBadge trend={trend} />
|
||||
</div>
|
||||
<div className="chart-wrap">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={chartRows} margin={{ top: 10, right: 16, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="period" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="value" name={i.unit || '值'} stroke="#1677ff" strokeWidth={2} dot={{ r: 3 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className={'data-table-wrap' + (showAll ? ' scroll' : '')}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr><th>月份</th><th style={{ textAlign: 'right' }}>数值</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleRows.map((r) => (
|
||||
<tr key={periodOf(r)}><td>{periodOf(r)}</td><td style={{ textAlign: 'right' }}>{r.value}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{tableRows.length > 10 && !showAll && (
|
||||
<div className="table-more" onClick={() => setShowAll(true)}>
|
||||
展开全部(共 {tableRows.length} 行)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { List, DotLoading } from 'antd-mobile'
|
||||
import { useIfinanceInfo } from '../../lib/ifinance.js'
|
||||
|
||||
// 外接模块分类列表 (ifinance categories[].items[])
|
||||
// - 数据来源: info.json (运行时动态拉取), 当前分类由 MacroPage 传入
|
||||
// - 点击条目 → /macro/finance/:catId/:itemId 打开该条目独立 HTML
|
||||
const KIND_LABEL = { indicator: '指标', strategy: '策略', dashboard: '看板' }
|
||||
|
||||
export default function FinanceCategoryView({ category }) {
|
||||
const navigate = useNavigate()
|
||||
const { info, loading, error } = useIfinanceInfo()
|
||||
|
||||
// MacroPage 拿到的 category 可能来自缓存; 若为空则用 hook 里的最新数据兜底
|
||||
const cat = category || (info?.categories || []).find((c) => c.id === category?.id)
|
||||
const items = cat?.items || []
|
||||
|
||||
if (!cat && loading) return <div className="loading"><DotLoading /> 加载中...</div>
|
||||
if (!cat && error) return <div className="error-banner">{error}</div>
|
||||
if (!items.length) return <div className="empty">暂无内容</div>
|
||||
|
||||
return (
|
||||
<div className="list-page">
|
||||
<List>
|
||||
{items.map((it) => (
|
||||
<List.Item
|
||||
key={it.id}
|
||||
title={it.title || it.id}
|
||||
description={it.subtitle || ''}
|
||||
extra={<span className="finance-kind">{KIND_LABEL[it.kind] || it.kind || ''}</span>}
|
||||
arrowIcon
|
||||
clickable
|
||||
onClick={() => navigate('/macro/finance/' + cat.id + '/' + it.id)}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { NavBar, DotLoading } from 'antd-mobile'
|
||||
import { useIfinanceInfo, financePageUrl } from '../../lib/ifinance.js'
|
||||
|
||||
// 外接条目详情: 用 iframe 加载 ifinance 生成的独立 HTML。
|
||||
// - 页面本身无导航/头部 (符合 ifinance 规范), 由宿主提供 NavBar 返回
|
||||
// - 直接指向远端地址 (纯前端直连, 不做端上缓存)
|
||||
export default function FinanceItemView() {
|
||||
const { catId, itemId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { info, loading, error } = useIfinanceInfo()
|
||||
|
||||
const cat = (info?.categories || []).find((c) => c.id === catId)
|
||||
const item = (cat?.items || []).find((i) => i.id === itemId)
|
||||
const title = item?.title || '详情'
|
||||
|
||||
return (
|
||||
<div className="finance-item-page">
|
||||
<NavBar onBack={() => navigate('/macro/finance/' + catId)}>{title}</NavBar>
|
||||
{loading && !item && <div className="loading"><DotLoading /> 加载中...</div>}
|
||||
{!loading && error && <div className="error-banner">{error}</div>}
|
||||
{!loading && !error && !item && <div className="empty">内容不存在</div>}
|
||||
{item?.html && (
|
||||
<iframe
|
||||
className="finance-frame"
|
||||
title={title}
|
||||
src={financePageUrl(item.html)}
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Card, DotLoading, Empty, ErrorBlock } from 'antd-mobile'
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import { getOilForecast, getOilHistory, getOilSchedule } from '../../api/oil.js'
|
||||
|
||||
// 油价页 (新):
|
||||
// - 上: 下次调价预测 (无 WTI / 无柱状图)
|
||||
// - 中: 浙江省最近半年价格走势 (3 油品折线, 一张图)
|
||||
// - 下: 调价日历 (按年切换)
|
||||
// 数据源:
|
||||
// - 预测/日历: apizero 实时 (后端代理)
|
||||
// - 走势: DB oil_price_history 表 (cron 每天 04:00 抓浙江 3 油品入库)
|
||||
|
||||
const FUEL_LABELS = {
|
||||
'92': '92# 汽油',
|
||||
'95': '95# 汽油',
|
||||
'0': '0# 柴油',
|
||||
}
|
||||
// 3 油品各自配色 (与原柱状图色调保持一致)
|
||||
const FUEL_COLORS = {
|
||||
'92': '#1677ff',
|
||||
'95': '#52c41a',
|
||||
'0': '#888',
|
||||
}
|
||||
|
||||
function n(v, d = 2) { return Number.isFinite(Number(v)) ? Number(v).toFixed(d) : '-' }
|
||||
|
||||
// 调价预测 (保留, 但不显示 WTI / 布伦特)
|
||||
function ForecastPanel() {
|
||||
const [forecast, setForecast] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setErr('')
|
||||
getOilForecast()
|
||||
.then((d) => { if (!cancelled) setForecast(d) })
|
||||
.catch((e) => !cancelled && setErr(e.message || '加载失败'))
|
||||
.finally(() => !cancelled && setLoading(false))
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="monitor-loading"><DotLoading /> 加载中...</div>
|
||||
if (err) return <ErrorBlock status="default" title="加载失败" description={err} />
|
||||
|
||||
const p = forecast?.prediction
|
||||
return (
|
||||
<Card className="oil-card">
|
||||
<div className="oil-card-title">下次调价预测</div>
|
||||
<div className="oil-prediction">
|
||||
<div className="oil-pred-row">
|
||||
<span className="oil-pred-label">调价日</span>
|
||||
<span className="oil-pred-value">{forecast?.next_adjust_date || '-'}</span>
|
||||
<span className="oil-pred-tag">剩 {forecast?.days_remaining ?? '-'} 天</span>
|
||||
</div>
|
||||
<div className="oil-pred-row">
|
||||
<span className="oil-pred-label">方向</span>
|
||||
<span className={'oil-pred-direction ' + (p?.direction || '')}>
|
||||
{p?.direction_emoji || ''} {p?.direction || '-'}
|
||||
</span>
|
||||
<span className="oil-pred-tag">置信度 {p?.confidence || '-'}</span>
|
||||
</div>
|
||||
<div className="oil-pred-row">
|
||||
<span className="oil-pred-label">预计幅度</span>
|
||||
<span className="oil-pred-value">
|
||||
{p?.estimated_change_per_ton != null
|
||||
? `${p.estimated_change_per_ton > 0 ? '+' : ''}${p.estimated_change_per_ton} 元/吨`
|
||||
: '-'}
|
||||
{p?.estimated_change_per_liter != null && (
|
||||
<span className="oil-pred-sub"> ({p.estimated_change_per_liter > 0 ? '+' : ''}{p.estimated_change_per_liter} 元/升)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{p?.analysis && (
|
||||
<div className="oil-pred-analysis">{p.analysis}</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 浙江省最近半年价格走势
|
||||
function HistoryPanel() {
|
||||
const [data, setData] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setErr('')
|
||||
getOilHistory({ days: 180 })
|
||||
.then((d) => { if (!cancelled) setData(d.items || []) })
|
||||
.catch((e) => !cancelled && setErr(e.message || '加载失败'))
|
||||
.finally(() => !cancelled && setLoading(false))
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
// 整形: rows = [{date, fuel, price}] -> 3 油品并排
|
||||
// pivot: [{date, 92, 95, 0}]
|
||||
const chartData = useMemo(() => {
|
||||
const m = new Map()
|
||||
for (const r of data) {
|
||||
if (!m.has(r.date)) m.set(r.date, { date: r.date })
|
||||
m.get(r.date)[r.fuel] = r.price
|
||||
}
|
||||
return Array.from(m.values()).sort((a, b) => a.date.localeCompare(b.date))
|
||||
}, [data])
|
||||
|
||||
// 3 油品都有数据?
|
||||
const hasData = chartData.length > 0
|
||||
|
||||
return (
|
||||
<Card className="oil-card">
|
||||
<div className="oil-card-title">浙江省最近半年价格走势</div>
|
||||
{loading && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
|
||||
{err && <ErrorBlock status="default" title="加载失败" description={err} />}
|
||||
{!loading && !err && !hasData && (
|
||||
<Empty description="暂无历史数据 (每天 04:00 自动抓取, 调价次日入库)" />
|
||||
)}
|
||||
{!loading && !err && hasData && (
|
||||
<>
|
||||
<div className="oil-chart">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={chartData} margin={{ top: 10, right: 10, left: -10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#eee" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} domain={['dataMin - 0.1', 'dataMax + 0.1']} unit="" />
|
||||
<Tooltip formatter={(v) => `${Number(v).toFixed(3)} 元/升`} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{Object.keys(FUEL_LABELS).map((k) => (
|
||||
<Line
|
||||
key={k}
|
||||
type="monotone"
|
||||
dataKey={k}
|
||||
name={FUEL_LABELS[k]}
|
||||
stroke={FUEL_COLORS[k]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="oil-chart-tip">共 {chartData.length} 个数据点 (调价次日入库)</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 调价日历 (不变)
|
||||
function SchedulePanel() {
|
||||
const [year, setYear] = useState(new Date().getFullYear())
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setErr('')
|
||||
getOilSchedule(year)
|
||||
.then((d) => { if (!cancelled) setData(d) })
|
||||
.catch((e) => !cancelled && setErr(e.message || '加载失败'))
|
||||
.finally(() => !cancelled && setLoading(false))
|
||||
return () => { cancelled = true }
|
||||
}, [year])
|
||||
|
||||
const byMonth = useMemo(() => {
|
||||
const m = {}
|
||||
if (!data?.schedule) return m
|
||||
for (const it of data.schedule) {
|
||||
const mm = it.date.slice(0, 7)
|
||||
if (!m[mm]) m[mm] = []
|
||||
m[mm].push(it)
|
||||
}
|
||||
return m
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<Card className="oil-card">
|
||||
<div className="oil-card-title-row">
|
||||
<div className="oil-card-title">调价日历</div>
|
||||
<div className="oil-year-tabs">
|
||||
{[2025, 2026].map((y) => (
|
||||
<button
|
||||
key={y}
|
||||
className={'oil-year-tab ' + (y === year ? 'active' : '')}
|
||||
onClick={() => setYear(y)}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{data?._cached && <div className="oil-cache-hint">本次来自本地缓存, 不消耗 apizero 配额</div>}
|
||||
{loading && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
|
||||
{err && <ErrorBlock status="default" title="加载失败" description={err} />}
|
||||
{!loading && !err && data && (
|
||||
<>
|
||||
<div className="oil-schedule-meta">
|
||||
{data.year} 年共 <b>{data.total_adjustments}</b> 个调价窗口
|
||||
{data.next_adjust_date && (
|
||||
<span className="oil-pred-tag">下一个: {data.next_adjust_date} (剩 {data.days_remaining} 天)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="oil-schedule-list">
|
||||
{Object.keys(byMonth).sort().map((m) => (
|
||||
<div key={m} className="oil-month">
|
||||
<div className="oil-month-label">{m}</div>
|
||||
<div className="oil-month-items">
|
||||
{byMonth[m].map((it) => (
|
||||
<div key={it.date} className={'oil-day ' + (it.status === '已过' ? 'past' : 'future')}>
|
||||
<div className="oil-day-date">{it.date.slice(8)}</div>
|
||||
<div className="oil-day-status">{it.status}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 油价页入口: 预测 + 走势 + 日历
|
||||
export default function OilPage() {
|
||||
return (
|
||||
<div className="oil-page">
|
||||
<ForecastPanel />
|
||||
<HistoryPanel />
|
||||
<SchedulePanel />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
import { Card, List, NavBar } from 'antd-mobile'
|
||||
import { api } from '../../api/client.js'
|
||||
import TrendBadge, { calcTrend } from '../../components/TrendBadge.jsx'
|
||||
|
||||
// 策略研究
|
||||
// 列表态 (默认): 列出所有策略, 每行显示主指标 (债务/GDP) 最新值 + 趋势
|
||||
// 详情态: 顶部 NavBar (返回) + 4 张图 (debt/gdp/debtToGdp/interestToGdp) + 表格
|
||||
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' },
|
||||
]
|
||||
|
||||
// 取最后一个有效数据点
|
||||
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 [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
// 路由驱动: /macro/strategy 列表, /macro/strategy/:name 详情, 浏览器/手机返回键天然可用
|
||||
const { name } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
api('/strategies')
|
||||
.then((data) => { if (!cancelled) setStrategies(data.strategies || []) })
|
||||
.catch((e) => !cancelled && setErr(e.message))
|
||||
.finally(() => !cancelled && setLoading(false))
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="loading">加载中...</div>
|
||||
if (err) return <div className="error-banner">{err}</div>
|
||||
if (!strategies.length) return <div className="empty">暂无策略数据</div>
|
||||
|
||||
// 详情态 (路径 /macro/strategy/:name)
|
||||
if (name) {
|
||||
const cur = strategies.find((s) => s.name === name)
|
||||
if (cur) return <StrategyDetail strategy={cur} onBack={() => navigate('/macro/strategy')} />
|
||||
}
|
||||
|
||||
// 列表态: 主指标 = 债务/GDP, 趋势基于其最近变化
|
||||
return (
|
||||
<div className="list-page">
|
||||
<div className="page-title">策略研究</div>
|
||||
<List>
|
||||
{strategies.map((s) => {
|
||||
const rows = (s.data && s.data.rows) || []
|
||||
const last = lastRow(rows)
|
||||
const { trend, change } = calcTrend(rows)
|
||||
return (
|
||||
<List.Item
|
||||
key={s.name}
|
||||
title={s.description || s.name}
|
||||
description={last
|
||||
? <><b>{last.debtToGdp}%</b> · 债务利息/GDP {last.interestToGdp}% · {last.year}</>
|
||||
: '暂无数据'}
|
||||
extra={<TrendBadge trend={trend} />}
|
||||
arrowIcon
|
||||
clickable
|
||||
onClick={() => navigate('/macro/strategy/' + s.name)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 详情: 4 张图 + 表格
|
||||
function StrategyDetail({ strategy, onBack }) {
|
||||
const s = strategy
|
||||
const rows = (s.data && s.data.rows) || []
|
||||
const last = lastRow(rows)
|
||||
const { trend } = calcTrend(rows)
|
||||
return (
|
||||
<div className="detail-page">
|
||||
<NavBar onBack={onBack}>{s.description || s.name}</NavBar>
|
||||
<Card className="detail-card">
|
||||
<div className="detail-meta">
|
||||
数据 {rows.length} 条 · 来源 {s.data.source || 'seed'} ·
|
||||
{last && <> 最新 <b style={{ color: 'var(--color-text)' }}>{last.year}</b> 债务/GDP <b style={{ color: 'var(--color-text)' }}>{last.debtToGdp}%</b> </>}
|
||||
<TrendBadge trend={trend} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import { api } from '../../api/client.js'
|
||||
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
|
||||
import { fmtDateTime } from '../../utils/format.js'
|
||||
|
||||
// 报警 (V0.2 从「首页」并入「宏观」Tab)
|
||||
// - 数据: World Bank 与妙想 MCP 跨源同 (指标, 周期) 数据差异超阈值, 按时间倒序
|
||||
// 报警 (V0.2.2 从「宏观」迁到「监控」第 4 个 tab)
|
||||
// - 数据: 跨源同 (指标, 周期) 数据差异超阈值 / cron 任务异常, 按时间倒序
|
||||
// - 无限滚动: 触底自动加载下一页
|
||||
// - onCount: 把总数回传给父级 (宏观 Tab 上显示角标)
|
||||
// - onCount: 把总数回传给父级 (监控的报警 tab 上显示角标)
|
||||
export default function AlertsView({ onCount }) {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
+19
-195
@@ -242,7 +242,7 @@ 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 列表态) ================== */
|
||||
/* ================== 列表视图 (外接分类 FinanceCategoryView) ================== */
|
||||
.list-page { padding: 0 0 16px; }
|
||||
.list-page .page-title { padding: 14px 16px 4px; }
|
||||
.list-page .adm-list { margin: 0 12px; background: transparent; }
|
||||
@@ -256,41 +256,29 @@ input, textarea, select { font: inherit; }
|
||||
.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;
|
||||
|
||||
/* 外接分类条目右侧类型徽标 */
|
||||
.finance-kind {
|
||||
font-size: 11px;
|
||||
color: var(--color-muted);
|
||||
background: rgba(0,0,0,.05);
|
||||
border-radius: 4px;
|
||||
background: rgba(0,0,0,.04);
|
||||
white-space: nowrap;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
.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; }
|
||||
/* 外接条目详情: iframe 铺满 (NavBar 45px + 底部 TabBar 已计入) */
|
||||
.finance-item-page { display: flex; flex-direction: column; height: 100%; background: #f5f5f5; }
|
||||
.finance-item-page .adm-nav-bar { flex: 0 0 auto; background: #fff; border-bottom: 1px solid var(--color-border); }
|
||||
.finance-frame {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
background: #f5f5f5;
|
||||
height: calc(100dvh - var(--safe-top) - var(--tabbar-h) - var(--safe-bottom) - 45px);
|
||||
}
|
||||
|
||||
/* 图表 / 表格 */
|
||||
/* 图表容器 (环境监控等仍在用) */
|
||||
.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; }
|
||||
.data-table-wrap { position: relative; }
|
||||
.data-table-wrap.scroll { max-height: 300px; overflow: auto; }
|
||||
.table-more { text-align: center; padding: 10px 0 2px; font-size: 13px; color: var(--color-primary); cursor: pointer; }
|
||||
|
||||
/* 页面标题 */
|
||||
.page-title { font-size: 20px; font-weight: 700; padding: 14px 16px 4px; }
|
||||
@@ -459,170 +447,6 @@ body.me-safe-top .app-safe-top { height: 0; }
|
||||
}
|
||||
|
||||
|
||||
/* ================== 油价页 (Macro/Oil) ================== */
|
||||
.oil-page {
|
||||
padding: 0 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.oil-card {
|
||||
margin: 0 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.oil-card .adm-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
.oil-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.oil-card-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.oil-card-title-row .oil-card-title { margin-bottom: 0; }
|
||||
.oil-fuel-picker {
|
||||
font-size: 13px;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.oil-crude {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.oil-crude-item {
|
||||
background: #f7f8fa;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.oil-crude-label { font-size: 12px; color: var(--color-muted); }
|
||||
.oil-crude-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.oil-crude-value.up { color: #ff3141; }
|
||||
.oil-crude-value.down { color: #00b578; }
|
||||
.oil-crude-unit { font-size: 12px; font-weight: 400; color: var(--color-muted); margin-left: 2px; }
|
||||
.oil-crude-change { font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.oil-crude-change.up { color: #ff3141; }
|
||||
.oil-crude-change.down { color: #00b578; }
|
||||
|
||||
.oil-prediction {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 8px;
|
||||
}
|
||||
.oil-pred-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.oil-pred-label { color: var(--color-muted); min-width: 76px; }
|
||||
.oil-pred-value { color: var(--color-text); flex: 1; font-weight: 600; }
|
||||
.oil-pred-sub { color: var(--color-muted); font-weight: 400; }
|
||||
.oil-pred-tag {
|
||||
font-size: 11px;
|
||||
background: #f0f5ff;
|
||||
color: var(--color-primary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.oil-pred-direction {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.oil-pred-direction.上涨 { color: #ff3141; }
|
||||
.oil-pred-direction.下跌 { color: #00b578; }
|
||||
.oil-pred-direction.搁浅 { color: #888; }
|
||||
.oil-pred-analysis {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-muted);
|
||||
line-height: 1.55;
|
||||
background: #f7f8fa;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.oil-chart { margin: 0 -4px; }
|
||||
.oil-chart-tip {
|
||||
font-size: 11px;
|
||||
color: var(--color-muted);
|
||||
text-align: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.oil-year-tabs { display: flex; gap: 4px; }
|
||||
.oil-year-tab {
|
||||
border: 1px solid var(--color-border);
|
||||
background: #fff;
|
||||
color: var(--color-text);
|
||||
padding: 4px 10px;
|
||||
border-radius: 14px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.oil-year-tab.active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.oil-cache-hint {
|
||||
font-size: 11px;
|
||||
color: #00b578;
|
||||
background: #e6f7ee;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.oil-schedule-meta {
|
||||
font-size: 13px;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.oil-schedule-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.oil-month-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.oil-month-items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.oil-day {
|
||||
border-radius: 6px;
|
||||
padding: 6px 4px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.oil-day-date { font-weight: 600; font-size: 13px; }
|
||||
.oil-day-status { color: var(--color-muted); margin-top: 2px; }
|
||||
.oil-day.past { background: #f5f5f5; color: #999; }
|
||||
.oil-day.future {
|
||||
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.oil-day.future .oil-day-status { color: rgba(255,255,255,.85); }
|
||||
|
||||
/* ================== 打卡 / 日历 / 清单 (V0.2) ================== */
|
||||
.checkin-content { padding: 0 0 16px; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||
.checkin-content .adm-tabs-content { --content-padding: 0; }
|
||||
|
||||
Reference in New Issue
Block a user