油价集成
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
// 前端油价 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())
|
||||
}
|
||||
+14
-8
@@ -5,23 +5,29 @@ 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'
|
||||
|
||||
// 宏观: 顶栏 3 tab (经济文章 / 经济数据 / 策略研究), 默认 经济数据
|
||||
// /macro → 重定向到 /macro/data
|
||||
// /macro/data → 经济数据 (默认)
|
||||
// /macro/strategy→ 策略研究
|
||||
// /macro/articles→ 经济文章
|
||||
// 宏观: 顶栏 4 tab (油价 / 经济数据 / 策略研究 / 经济文章), 默认 油价
|
||||
// /macro → 显示 oil tab (本组件 activeFromPath 决定)
|
||||
// /macro/oil → 油价 (新增)
|
||||
// /macro/data → 经济数据
|
||||
// /macro/strategy → 策略研究
|
||||
// /macro/articles → 经济文章
|
||||
// /macro/articles/:slug → 文章详情
|
||||
const TABS = [
|
||||
{ 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/oil')) return 'oil'
|
||||
if (pathname.startsWith('/macro/articles')) return 'articles'
|
||||
if (pathname.startsWith('/macro/strategy')) return 'strategy'
|
||||
return 'data'
|
||||
if (pathname.startsWith('/macro/data')) return 'data'
|
||||
// 父路由 /macro 或 / 走 Tab 列表第一个 (油价)
|
||||
return 'oil'
|
||||
}
|
||||
|
||||
export default function MacroPage() {
|
||||
@@ -33,6 +39,7 @@ export default function MacroPage() {
|
||||
key: t.key,
|
||||
label: 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 />
|
||||
@@ -46,7 +53,6 @@ export default function MacroPage() {
|
||||
}
|
||||
|
||||
// 详情页 /macro/articles/:slug 和 /macro/data/:name 单独走 React Router 渲染, 不参与滑屏
|
||||
// 这样浏览器/手机物理返回键也能正常回到列表
|
||||
if (location.pathname.match(/^\/macro\/(articles|data|strategy)\/[^/]+$/)) {
|
||||
return (
|
||||
<Routes>
|
||||
@@ -62,4 +68,4 @@ export default function MacroPage() {
|
||||
<SwipeTabs tabs={tabs} active={active} onChange={onChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -294,3 +294,168 @@ input, textarea, select { font: inherit; }
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
|
||||
/* ================== 油价页 (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); }
|
||||
|
||||
Reference in New Issue
Block a user