Files
iboard/src/pages/macro/OilPage.jsx
T
2026-08-07 14:36:57 +08:00

251 lines
8.6 KiB
React

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>
)
}