油价集成
This commit is contained in:
parent
8999b29646
commit
b9ec6fbe0b
5
.gitignore
vendored
5
.gitignore
vendored
@ -37,3 +37,8 @@ dist/*
|
||||
.reasonix/*
|
||||
# OTA 升级: 产物 (运行时生成, 部署时单独管理) + 私钥 (绝不能提交)
|
||||
ota-package/
|
||||
|
||||
# apizero 文件缓存 (运行时按需生成)
|
||||
cache/apizero/
|
||||
# Dockerfile 持久卷挂载点 (跟 ota-package 同类)
|
||||
.cache/
|
||||
|
||||
24
_server.err
Normal file
24
_server.err
Normal file
@ -0,0 +1,24 @@
|
||||
node:events:497
|
||||
throw er; // Unhandled 'error' event
|
||||
^
|
||||
|
||||
Error: listen EADDRINUSE: address already in use :::3001
|
||||
at Server.setupListenHandle [as _listen2] (node:net:1940:16)
|
||||
at listenInCluster (node:net:1997:12)
|
||||
at Server.listen (node:net:2102:7)
|
||||
at Function.listen (D:\workbench\iboard\node_modules\express\lib\application.js:635:24)
|
||||
at file:///D:/workbench/iboard/server/index.js:57:5
|
||||
at ModuleJob.run (node:internal/modules/esm/module_job:345:25)
|
||||
at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:651:26)
|
||||
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5)
|
||||
Emitted 'error' event on Server instance at:
|
||||
at emitErrorNT (node:net:1976:8)
|
||||
at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
|
||||
code: 'EADDRINUSE',
|
||||
errno: -4091,
|
||||
syscall: 'listen',
|
||||
address: '::',
|
||||
port: 3001
|
||||
}
|
||||
|
||||
Node.js v22.19.0
|
||||
2
_server.out
Normal file
2
_server.out
Normal file
@ -0,0 +1,2 @@
|
||||
[cron] 已注册: 每天 03:00 拉取新闻 + 跨源报警检测
|
||||
[cron] 已注册: 每天 04:00 浙江油价历史抓取
|
||||
@ -77,3 +77,8 @@ export type Econ_SowInventory = Prisma.Econ_SowInventoryModel
|
||||
*
|
||||
*/
|
||||
export type ScraperCache = Prisma.ScraperCacheModel
|
||||
/**
|
||||
* Model OilPriceHistory
|
||||
*
|
||||
*/
|
||||
export type OilPriceHistory = Prisma.OilPriceHistoryModel
|
||||
|
||||
@ -101,3 +101,8 @@ export type Econ_SowInventory = Prisma.Econ_SowInventoryModel
|
||||
*
|
||||
*/
|
||||
export type ScraperCache = Prisma.ScraperCacheModel
|
||||
/**
|
||||
* Model OilPriceHistory
|
||||
*
|
||||
*/
|
||||
export type OilPriceHistory = Prisma.OilPriceHistoryModel
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -395,7 +395,8 @@ export const ModelName = {
|
||||
ServerNodeHistory: 'ServerNodeHistory',
|
||||
ServiceCheck: 'ServiceCheck',
|
||||
Econ_SowInventory: 'Econ_SowInventory',
|
||||
ScraperCache: 'ScraperCache'
|
||||
ScraperCache: 'ScraperCache',
|
||||
OilPriceHistory: 'OilPriceHistory'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
@ -411,7 +412,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
omit: GlobalOmitOptions
|
||||
}
|
||||
meta: {
|
||||
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "news" | "alert" | "serverNode" | "serverNodeHistory" | "serviceCheck" | "econ_SowInventory" | "scraperCache"
|
||||
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "news" | "alert" | "serverNode" | "serverNodeHistory" | "serviceCheck" | "econ_SowInventory" | "scraperCache" | "oilPriceHistory"
|
||||
txIsolationLevel: TransactionIsolationLevel
|
||||
}
|
||||
model: {
|
||||
@ -1207,6 +1208,72 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
}
|
||||
}
|
||||
}
|
||||
OilPriceHistory: {
|
||||
payload: Prisma.$OilPriceHistoryPayload<ExtArgs>
|
||||
fields: Prisma.OilPriceHistoryFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.OilPriceHistoryFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.OilPriceHistoryFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.OilPriceHistoryFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.OilPriceHistoryFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.OilPriceHistoryFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.OilPriceHistoryCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.OilPriceHistoryCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.OilPriceHistoryDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.OilPriceHistoryUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.OilPriceHistoryDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.OilPriceHistoryUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.OilPriceHistoryUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.OilPriceHistoryAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateOilPriceHistory>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.OilPriceHistoryGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.OilPriceHistoryGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.OilPriceHistoryCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.OilPriceHistoryCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} & {
|
||||
other: {
|
||||
@ -1430,6 +1497,19 @@ export const ScraperCacheScalarFieldEnum = {
|
||||
export type ScraperCacheScalarFieldEnum = (typeof ScraperCacheScalarFieldEnum)[keyof typeof ScraperCacheScalarFieldEnum]
|
||||
|
||||
|
||||
export const OilPriceHistoryScalarFieldEnum = {
|
||||
id: 'id',
|
||||
date: 'date',
|
||||
province: 'province',
|
||||
fuel: 'fuel',
|
||||
price: 'price',
|
||||
source: 'source',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type OilPriceHistoryScalarFieldEnum = (typeof OilPriceHistoryScalarFieldEnum)[keyof typeof OilPriceHistoryScalarFieldEnum]
|
||||
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@ -1589,6 +1669,15 @@ export const ScraperCacheOrderByRelevanceFieldEnum = {
|
||||
export type ScraperCacheOrderByRelevanceFieldEnum = (typeof ScraperCacheOrderByRelevanceFieldEnum)[keyof typeof ScraperCacheOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
export const OilPriceHistoryOrderByRelevanceFieldEnum = {
|
||||
province: 'province',
|
||||
fuel: 'fuel',
|
||||
source: 'source'
|
||||
} as const
|
||||
|
||||
export type OilPriceHistoryOrderByRelevanceFieldEnum = (typeof OilPriceHistoryOrderByRelevanceFieldEnum)[keyof typeof OilPriceHistoryOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Field references
|
||||
@ -1757,6 +1846,7 @@ export type GlobalOmitConfig = {
|
||||
serviceCheck?: Prisma.ServiceCheckOmit
|
||||
econ_SowInventory?: Prisma.Econ_SowInventoryOmit
|
||||
scraperCache?: Prisma.ScraperCacheOmit
|
||||
oilPriceHistory?: Prisma.OilPriceHistoryOmit
|
||||
}
|
||||
|
||||
/* Types for Logging */
|
||||
|
||||
@ -62,7 +62,8 @@ export const ModelName = {
|
||||
ServerNodeHistory: 'ServerNodeHistory',
|
||||
ServiceCheck: 'ServiceCheck',
|
||||
Econ_SowInventory: 'Econ_SowInventory',
|
||||
ScraperCache: 'ScraperCache'
|
||||
ScraperCache: 'ScraperCache',
|
||||
OilPriceHistory: 'OilPriceHistory'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
@ -265,6 +266,19 @@ export const ScraperCacheScalarFieldEnum = {
|
||||
export type ScraperCacheScalarFieldEnum = (typeof ScraperCacheScalarFieldEnum)[keyof typeof ScraperCacheScalarFieldEnum]
|
||||
|
||||
|
||||
export const OilPriceHistoryScalarFieldEnum = {
|
||||
id: 'id',
|
||||
date: 'date',
|
||||
province: 'province',
|
||||
fuel: 'fuel',
|
||||
price: 'price',
|
||||
source: 'source',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type OilPriceHistoryScalarFieldEnum = (typeof OilPriceHistoryScalarFieldEnum)[keyof typeof OilPriceHistoryScalarFieldEnum]
|
||||
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@ -423,3 +437,12 @@ export const ScraperCacheOrderByRelevanceFieldEnum = {
|
||||
|
||||
export type ScraperCacheOrderByRelevanceFieldEnum = (typeof ScraperCacheOrderByRelevanceFieldEnum)[keyof typeof ScraperCacheOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
export const OilPriceHistoryOrderByRelevanceFieldEnum = {
|
||||
province: 'province',
|
||||
fuel: 'fuel',
|
||||
source: 'source'
|
||||
} as const
|
||||
|
||||
export type OilPriceHistoryOrderByRelevanceFieldEnum = (typeof OilPriceHistoryOrderByRelevanceFieldEnum)[keyof typeof OilPriceHistoryOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
@ -20,4 +20,5 @@ export type * from './models/ServerNodeHistory.ts'
|
||||
export type * from './models/ServiceCheck.ts'
|
||||
export type * from './models/Econ_SowInventory.ts'
|
||||
export type * from './models/ScraperCache.ts'
|
||||
export type * from './models/OilPriceHistory.ts'
|
||||
export type * from './commonInputTypes.ts'
|
||||
1149
generated/prisma/models/OilPriceHistory.ts
Normal file
1149
generated/prisma/models/OilPriceHistory.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,17 @@
|
||||
-- 浙江省油价历史表
|
||||
-- - date: 调价日次日 (即数据实际抓取日, 便于按时间排序)
|
||||
-- - province + fuel: 浙江 / 92/95/0
|
||||
-- - unique (date, province, fuel): 防 cron 重复入库
|
||||
-- - DOUBLE 容纳 999.999 元/升
|
||||
CREATE TABLE OilPriceHistory (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
date DATETIME(3) NOT NULL,
|
||||
province VARCHAR(191) NOT NULL,
|
||||
fuel VARCHAR(8) NOT NULL,
|
||||
price DOUBLE NOT NULL,
|
||||
source VARCHAR(191) NOT NULL DEFAULT 'apizero',
|
||||
createdAt DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE INDEX OilPriceHistory_date_province_fuel_key(date, province, fuel),
|
||||
INDEX OilPriceHistory_province_fuel_date_idx(province, fuel, date)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@ -181,3 +181,22 @@ model ScraperCache {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// 浙江省油价历史 (apizero price 接口每日抓取)
|
||||
// 每天 04:00 cron 触发: 检查 schedule 里待定且 <= 昨天的调价日, 抓 price?province=浙江 存本表
|
||||
// unique (date, province, fuel) 防重复
|
||||
model OilPriceHistory {
|
||||
id Int @id @default(autoincrement())
|
||||
date DateTime // 调价日次日 (即数据实际抓取日)
|
||||
province String // '浙江'
|
||||
fuel String // '92' | '95' | '0'
|
||||
price Float // 油价 (元/升, 4 位小数足够)
|
||||
source String @default("apizero")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([date, province, fuel])
|
||||
@@index([province, fuel, date])
|
||||
}
|
||||
|
||||
|
||||
|
||||
119
scripts/import-oil-history.mjs
Normal file
119
scripts/import-oil-history.mjs
Normal file
@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
// 一次性导入浙江省油价历史 (2025-2026)
|
||||
// 来源: 手工整理 (省发改委公告 / 潮新闻 / 钱江晚报 / 杭州日报 / 杭州网 等)
|
||||
// 用法: node --experimental-strip-types --no-warnings scripts/import-oil-history.mjs [--dry]
|
||||
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import 'dotenv/config'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
|
||||
// 接入 prisma (用 file:// URL 避免 Windows 绝对路径 import 报错)
|
||||
const adapterMod = await import('@prisma/adapter-mariadb')
|
||||
const clientPath = pathToFileURL(path.join(ROOT, 'generated/prisma/client.ts')).href
|
||||
const { PrismaClient } = await import(clientPath)
|
||||
|
||||
const adapter = new adapterMod.PrismaMariaDb((() => {
|
||||
const u = new URL(process.env.DATABASE_URL)
|
||||
return {
|
||||
host: u.hostname,
|
||||
port: Number(u.port) || 3306,
|
||||
user: decodeURIComponent(u.username),
|
||||
password: decodeURIComponent(u.password),
|
||||
database: u.pathname.replace(/^\//, ''),
|
||||
}
|
||||
})())
|
||||
const prisma = new PrismaClient({ adapter })
|
||||
|
||||
// 数据: [调价日, 92, 95, 0, source]
|
||||
const RAW = [
|
||||
['2026-07-31', 7.94, 8.44, 7.62, 'manual'],
|
||||
['2026-07-17', 7.39, 7.86, 7.06, 'manual'],
|
||||
['2026-06-18', 7.91, 8.41, 7.59, 'manual'],
|
||||
['2026-06-04', 8.32, 8.85, 8.02, 'manual'],
|
||||
['2026-05-21', 8.74, 9.30, 8.45, 'manual'],
|
||||
['2026-02-03', 6.91, 7.35, 6.56, 'manual'],
|
||||
['2025-12-22', 6.68, 7.11, 6.32, 'manual'],
|
||||
['2025-12-08', 6.82, 7.25, 6.46, 'manual'],
|
||||
['2025-11-24', 6.86, null, null, 'manual'],
|
||||
['2025-11-10', 6.92, 7.36, 6.57, 'manual'],
|
||||
['2025-10-13', 7.03, 7.48, 6.68, 'manual'],
|
||||
['2025-06-17', 7.15, 7.60, 6.81, 'manual'],
|
||||
['2025-06-03', 6.94, 7.38, 6.59, 'manual'],
|
||||
]
|
||||
|
||||
const PROVINCE = '浙江'
|
||||
const FUEL_COLS = [
|
||||
{ fuel: '92', idx: 1 },
|
||||
{ fuel: '95', idx: 2 },
|
||||
{ fuel: '0', idx: 3 },
|
||||
]
|
||||
|
||||
const DRY = process.argv.includes('--dry')
|
||||
|
||||
async function main() {
|
||||
console.log('=== 浙江省油价历史导入 ===')
|
||||
console.log('dry run:', DRY)
|
||||
console.log('')
|
||||
|
||||
let inserted = 0
|
||||
let updated = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const row of RAW) {
|
||||
const dateStr = row[0]
|
||||
const source = row[4]
|
||||
for (const { fuel, idx } of FUEL_COLS) {
|
||||
const price = row[idx]
|
||||
if (price == null || Number.isNaN(price)) {
|
||||
console.log('[SKIP] ' + dateStr + ' ' + fuel + ': 价格为空')
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const date = new Date(dateStr + 'T00:00:00')
|
||||
if (DRY) {
|
||||
console.log('[DRY] ' + dateStr + ' ' + fuel + ' = ' + price + ' (source=' + source + ')')
|
||||
inserted++
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await prisma.oilPriceHistory.create({
|
||||
data: { date, province: PROVINCE, fuel, price, source },
|
||||
})
|
||||
console.log('[OK] ' + dateStr + ' ' + fuel + ' = ' + price)
|
||||
inserted++
|
||||
} catch (e) {
|
||||
if (e?.code === 'P2002') {
|
||||
try {
|
||||
await prisma.oilPriceHistory.update({
|
||||
where: { date_province_fuel: { date, province: PROVINCE, fuel } },
|
||||
data: { price, source },
|
||||
})
|
||||
console.log('[UPD] ' + dateStr + ' ' + fuel + ' = ' + price)
|
||||
updated++
|
||||
} catch (e2) {
|
||||
console.log('[ERR] ' + dateStr + ' ' + fuel + ': ' + e2.message)
|
||||
}
|
||||
} else {
|
||||
console.log('[ERR] ' + dateStr + ' ' + fuel + ': ' + e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('')
|
||||
console.log('=== 汇总 ===')
|
||||
console.log('插入: ' + inserted)
|
||||
console.log('更新: ' + updated)
|
||||
console.log('跳过: ' + skipped)
|
||||
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
46
server/apizero/cache.js
Normal file
46
server/apizero/cache.js
Normal file
@ -0,0 +1,46 @@
|
||||
// apizero 接口的文件缓存
|
||||
// - 按 key 存 JSON 到磁盘: <APIZERO_CACHE_DIR>/<key>.json
|
||||
// - 存在性检查: 文件在即视为有效 (用户要求: "同一个年份只要存在就不再调用接口")
|
||||
// - 不做 TTL: 用户没要求, 也避免误命中过期数据
|
||||
//
|
||||
// 环境变量:
|
||||
// APIZERO_CACHE_DIR 缓存目录 (默认 <cwd>/cache/apizero)
|
||||
//
|
||||
// 未来若要加 TTL / DB 后端, 替换本文件即可, 上层调用方不变.
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
function dir() {
|
||||
return process.env.APIZERO_CACHE_DIR
|
||||
? path.resolve(process.env.APIZERO_CACHE_DIR)
|
||||
: path.resolve(process.cwd(), 'cache', 'apizero')
|
||||
}
|
||||
|
||||
// 读缓存, 不存在返回 null
|
||||
export function read(key) {
|
||||
const p = path.join(dir(), key + '.json')
|
||||
if (!fs.existsSync(p)) return null
|
||||
try {
|
||||
const raw = fs.readFileSync(p, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch (e) {
|
||||
console.warn(`[apizero-cache] 读 ${key} 失败, 忽略:`, e.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 写缓存 (原子: 先写 .tmp 再 rename)
|
||||
export function write(key, data) {
|
||||
const d = dir()
|
||||
fs.mkdirSync(d, { recursive: true })
|
||||
const final = path.join(d, key + '.json')
|
||||
const tmp = final + '.tmp.' + process.pid
|
||||
fs.writeFileSync(tmp, JSON.stringify(data), 'utf8')
|
||||
fs.renameSync(tmp, final)
|
||||
return final
|
||||
}
|
||||
|
||||
// 存在性检查
|
||||
export function has(key) {
|
||||
return fs.existsSync(path.join(dir(), key + '.json'))
|
||||
}
|
||||
77
server/apizero/client.js
Normal file
77
server/apizero/client.js
Normal file
@ -0,0 +1,77 @@
|
||||
// apizero (https://apizero.cn) 通用 HTTP 客户端
|
||||
// - 统一从 env APIZERO_KEY 读取 API key (test key 也能用)
|
||||
// - 统一 QPS 节流 (apizero 限制 3 req/s)
|
||||
// - 统一错误格式: 抛出 ApizeroError(code, message, httpStatus)
|
||||
// - 所有 apizero 接口都进这一层, 后续新增模块复用
|
||||
//
|
||||
// 依赖: 原生 fetch (Node 18+)
|
||||
|
||||
// 简易令牌桶, 限速 3 req/s
|
||||
let lastCallAt = 0
|
||||
const MIN_INTERVAL_MS = 1000 / 3 // ~333ms
|
||||
|
||||
async function throttle() {
|
||||
const now = Date.now()
|
||||
const wait = lastCallAt + MIN_INTERVAL_MS - now
|
||||
if (wait > 0) await new Promise((r) => setTimeout(r, wait))
|
||||
lastCallAt = Date.now()
|
||||
}
|
||||
|
||||
// apizero 标准错误
|
||||
export class ApizeroError extends Error {
|
||||
constructor(code, message, httpStatus) {
|
||||
super(message || `apizero error code=${code}`)
|
||||
this.name = 'ApizeroError'
|
||||
this.code = code
|
||||
this.httpStatus = httpStatus
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_URL = 'https://v1.apizero.cn/api'
|
||||
|
||||
// 读 key: 优先 env APIZERO_KEY, 否则用空 (匿名调用, 配额更小)
|
||||
function getKey() {
|
||||
return process.env.APIZERO_KEY || ''
|
||||
}
|
||||
|
||||
// 通用 GET 入口
|
||||
// - endpoint: 'oil-price-forecast' (不带 /api 前缀)
|
||||
// - params: 普通对象
|
||||
// 返回: apizero 的 data 字段 (已解一层)
|
||||
export async function get(endpoint, params = {}) {
|
||||
await throttle()
|
||||
|
||||
const qs = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v))
|
||||
}
|
||||
const key = getKey()
|
||||
if (key) qs.set('key', key)
|
||||
|
||||
const url = `${BASE_URL}/${endpoint}?${qs.toString()}`
|
||||
let res
|
||||
try {
|
||||
res = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json' } })
|
||||
} catch (e) {
|
||||
throw new ApizeroError('NETWORK', `apizero 网络错误: ${e.message}`, 0)
|
||||
}
|
||||
|
||||
// HTTP 层错误
|
||||
if (!res.ok) {
|
||||
let body = ''
|
||||
try { body = await res.text() } catch { /* ignore */ }
|
||||
throw new ApizeroError('HTTP', `apizero HTTP ${res.status}: ${body.substring(0, 200)}`, res.status)
|
||||
}
|
||||
|
||||
// 解析 body
|
||||
let json
|
||||
try { json = await res.json() }
|
||||
catch (e) { throw new ApizeroError('PARSE', 'apizero 响应非 JSON: ' + e.message, res.status) }
|
||||
|
||||
// apizero 业务层错误: code !== 0
|
||||
if (json && typeof json.code === 'number' && json.code !== 0) {
|
||||
throw new ApizeroError(json.code, json.msg || 'apizero 业务错误', res.status)
|
||||
}
|
||||
|
||||
return json && json.data !== undefined ? json.data : json
|
||||
}
|
||||
6
server/apizero/index.js
Normal file
6
server/apizero/index.js
Normal file
@ -0,0 +1,6 @@
|
||||
// apizero 模块统一出口
|
||||
// 后续新增 apizero 接口就在这里再 export 一行
|
||||
export * as oil from './oil-price-forecast.js'
|
||||
export * as history from './oil-history.js'
|
||||
export { ApizeroError } from './client.js'
|
||||
|
||||
87
server/apizero/oil-history.js
Normal file
87
server/apizero/oil-history.js
Normal file
@ -0,0 +1,87 @@
|
||||
// 浙江省油价历史: 走 Prisma 落库 + apizero price 接口写入
|
||||
// 设计:
|
||||
// - 每天 04:00 cron 检查 schedule, 待定且 <= 昨天的调价日 -> 抓 price?province=浙江 存本表
|
||||
// - unique (date, province, fuel) 防重复
|
||||
// - 查询 API: 取某省某油品最近 N 天 (默认 180)
|
||||
import { prisma } from '../db.js'
|
||||
import * as client from './client.js'
|
||||
|
||||
const PROVINCE = '浙江'
|
||||
// 油品 -> apizero prices 对象里的 key (price action 返回 "92号汽油" 这种带后缀的 key)
|
||||
const FUEL_KEYS = {
|
||||
'92': '92号汽油',
|
||||
'95': '95号汽油',
|
||||
'0': '0号柴油',
|
||||
}
|
||||
|
||||
// 解析 apizero price action 返回的 prices (中文 key) -> 标准 fuel code
|
||||
function parsePrice(data) {
|
||||
if (!data || !data.prices) throw new Error('apizero 响应无 prices 字段')
|
||||
const out = {}
|
||||
for (const [fuel, label] of Object.entries(FUEL_KEYS)) {
|
||||
const raw = data.prices[label]
|
||||
if (typeof raw === 'string') {
|
||||
// "7.64 元/升" -> 7.64
|
||||
const m = raw.match(/^([\d.]+)/)
|
||||
if (m) out[fuel] = parseFloat(m[1])
|
||||
} else if (typeof raw === 'number') {
|
||||
out[fuel] = raw
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 抓一次浙江当前价并入库 (date = 今日)
|
||||
// 返回 { inserted, skipped } (按 unique 约束: 已存在则 skip)
|
||||
export async function fetchAndStore() {
|
||||
const data = await client.get('oil-price-forecast', { action: 'price', province: PROVINCE })
|
||||
const prices = parsePrice(data)
|
||||
if (Object.keys(prices).length === 0) {
|
||||
throw new Error('apizero 返回价格为空')
|
||||
}
|
||||
const date = new Date()
|
||||
// 抹掉时分秒, 便于按天比较
|
||||
date.setHours(0, 0, 0, 0)
|
||||
|
||||
let inserted = 0
|
||||
let skipped = 0
|
||||
for (const [fuel, price] of Object.entries(prices)) {
|
||||
try {
|
||||
await prisma.oilPriceHistory.create({
|
||||
data: { date, province: PROVINCE, fuel, price, source: 'apizero' },
|
||||
})
|
||||
inserted++
|
||||
} catch (e) {
|
||||
// P2002: unique 冲突 -> 跳过
|
||||
if (e?.code === 'P2002') skipped++
|
||||
else throw e
|
||||
}
|
||||
}
|
||||
return { inserted, skipped, date: date.toISOString().slice(0, 10) }
|
||||
}
|
||||
|
||||
// 查询某省某油品最近 N 天
|
||||
// province 必填, 默认 浙江
|
||||
// fuel 可选, 留空返回 4 个油品
|
||||
// days 默认 180
|
||||
export async function query({ province = PROVINCE, fuel, days = 180 }) {
|
||||
const since = new Date()
|
||||
since.setDate(since.getDate() - days)
|
||||
since.setHours(0, 0, 0, 0)
|
||||
|
||||
const where = { province, date: { gte: since } }
|
||||
if (fuel) where.fuel = fuel
|
||||
|
||||
const rows = await prisma.oilPriceHistory.findMany({
|
||||
where,
|
||||
orderBy: [{ date: 'asc' }],
|
||||
})
|
||||
|
||||
// 整形: Decimal -> number
|
||||
return rows.map((r) => ({
|
||||
date: r.date.toISOString().slice(0, 10),
|
||||
province: r.province,
|
||||
fuel: r.fuel,
|
||||
price: Number(r.price),
|
||||
}))
|
||||
}
|
||||
41
server/apizero/oil-price-forecast.js
Normal file
41
server/apizero/oil-price-forecast.js
Normal file
@ -0,0 +1,41 @@
|
||||
// apizero: 今日油价 (oil-price-forecast)
|
||||
// 4 个 action:
|
||||
// forecast - 油价 + 国际原油 + 下次调价预测 (每次都查实时, 不缓存)
|
||||
// price - 单省油价
|
||||
// price-all - 32 省全部油价
|
||||
// schedule - 某年调价日历 (按 year 缓存到文件, 同年份不重复调)
|
||||
//
|
||||
// 文档: https://apizero.cn/aidocs/oil-price-forecast/raw.md
|
||||
import * as client from './client.js'
|
||||
import * as cache from './cache.js'
|
||||
|
||||
// 调价日历: 按年份缓存, 文件存在即视为有效, 不重复调用
|
||||
// cache key 形如 "oil-schedule-2026"
|
||||
export async function getSchedule(year) {
|
||||
const y = Number(year)
|
||||
if (!Number.isInteger(y) || y < 2025 || y > 2026) {
|
||||
throw new client.ApizeroError(4000, `year 不在 2025-2026 范围: ${year}`, 400)
|
||||
}
|
||||
const cacheKey = `oil-schedule-${y}`
|
||||
const hit = cache.read(cacheKey)
|
||||
if (hit) return { ...hit, _cached: true }
|
||||
const data = await client.get('oil-price-forecast', { action: 'schedule', year: y })
|
||||
cache.write(cacheKey, data)
|
||||
return { ...data, _cached: false }
|
||||
}
|
||||
|
||||
// 实时: 国际原油 + 下次调价预测
|
||||
export async function getForecast() {
|
||||
return client.get('oil-price-forecast', { action: 'forecast' })
|
||||
}
|
||||
|
||||
// 单省油价
|
||||
export async function getPrice(province) {
|
||||
if (!province) throw new client.ApizeroError(4000, 'province 必填', 400)
|
||||
return client.get('oil-price-forecast', { action: 'price', province })
|
||||
}
|
||||
|
||||
// 32 省全部油价
|
||||
export async function getPriceAll() {
|
||||
return client.get('oil-price-forecast', { action: 'price-all' })
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { prisma } from './db.js'
|
||||
import mysql from 'mysql2/promise'
|
||||
import { fetchNewsList, persistNews } from '../scripts/fetchers/news.fetcher.js'
|
||||
import { checkAfterRefresh } from './alert-engine.js'
|
||||
import { ApizeroError, oil, history } from './apizero/index.js'
|
||||
|
||||
function parseUrl(url) {
|
||||
const u = new URL(url)
|
||||
@ -33,4 +34,43 @@ function scheduleNews() {
|
||||
console.log('[cron] 已注册: 每天 03:00 拉取新闻 + 跨源报警检测')
|
||||
}
|
||||
|
||||
// 每天凌晨 4:00 抓浙江油价历史
|
||||
// 逻辑: 拉今年 schedule, 检查待定且 <= 昨天的调价日, 命中就抓 price?province=浙江 存库
|
||||
// - 0 命中: 不调 apizero (省配额)
|
||||
// - >0 命中: 调一次, 4 个油品批量入库 (unique 兜底去重)
|
||||
function scheduleOilHistory() {
|
||||
cron.schedule('0 4 * * *', async () => {
|
||||
console.log('[cron] 4:00 油价历史触发')
|
||||
try {
|
||||
const year = new Date().getFullYear()
|
||||
const sched = await oil.getSchedule(year)
|
||||
const list = sched.schedule || []
|
||||
// 待定 且 日期 <= 昨天
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const yesterdayMs = today.getTime() - 86400000
|
||||
const pendingPast = list.filter((it) => {
|
||||
if (it.status !== '待定') return false
|
||||
const t = new Date(it.date + 'T00:00:00').getTime()
|
||||
return Number.isFinite(t) && t <= yesterdayMs
|
||||
})
|
||||
console.log('[cron] 待定且已过的调价日: ' + pendingPast.length + ' 个')
|
||||
if (pendingPast.length === 0) {
|
||||
console.log('[cron] 油价历史: 0 命中, 跳过 apizero 调用')
|
||||
return
|
||||
}
|
||||
const r = await history.fetchAndStore()
|
||||
console.log('[cron] 油价历史入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped + ' (date=' + r.date + ')')
|
||||
} catch (e) {
|
||||
if (e instanceof ApizeroError) {
|
||||
console.error('[cron] 油价历史 apizero 错误: code=' + e.code + ' msg=' + e.message)
|
||||
} else {
|
||||
console.error('[cron] 油价历史任务失败:', e.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log('[cron] 已注册: 每天 04:00 浙江油价历史抓取')
|
||||
}
|
||||
|
||||
scheduleNews()
|
||||
scheduleOilHistory()
|
||||
|
||||
@ -9,6 +9,7 @@ import newsRouter from './routes/news.js'
|
||||
import alertsRouter from './routes/alerts.js'
|
||||
import articlesRouter from './routes/articles.js'
|
||||
import otaRouter from './routes/ota.js'
|
||||
import apizeroRouter from './routes/apizero.js'
|
||||
import monitorsRouter from './routes/monitors.js'
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3001
|
||||
@ -29,6 +30,7 @@ app.use('/api', newsRouter)
|
||||
app.use('/api', alertsRouter)
|
||||
app.use('/api', articlesRouter)
|
||||
app.use('/api', otaRouter)
|
||||
app.use('/api', apizeroRouter)
|
||||
app.use('/api', monitorsRouter)
|
||||
|
||||
// 静态资源 (Vite build 产物)
|
||||
@ -56,3 +58,4 @@ app.listen(PORT, () => {
|
||||
console.log(`[iboard-api] listening on http://127.0.0.1:${PORT}`)
|
||||
console.log(`[iboard-api] static dist: ${HAS_DIST ? DIST_DIR : '(not built, API only)'}`)
|
||||
})
|
||||
|
||||
|
||||
63
server/routes/apizero.js
Normal file
63
server/routes/apizero.js
Normal file
@ -0,0 +1,63 @@
|
||||
// apizero 代理路由 (前端不直接调 apizero, 一律走后端, key 不外泄)
|
||||
//
|
||||
// GET /api/apizero/oil/forecast - 油价 + 预测 (实时)
|
||||
// GET /api/apizero/oil/price-all - 32 省油价
|
||||
// GET /api/apizero/oil/price?province= - 单省油价
|
||||
// GET /api/apizero/oil/schedule?year= - 某年调价日历 (按年缓存)
|
||||
//
|
||||
// 错误: apizero 错误统一包成 502 (上游错误) 或 400 (参数错误)
|
||||
import { Router } from 'express'
|
||||
import { authRequired } from '../middleware.js'
|
||||
import { ApizeroError, oil, history } from '../apizero/index.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(authRequired)
|
||||
|
||||
function handleApizeroError(e, res, next) {
|
||||
if (e instanceof ApizeroError) {
|
||||
// 参数错 -> 400, 上游错 -> 502, QPS 限流 / 额度用完 -> 503
|
||||
if (e.httpStatus === 400) return res.status(400).json({ error: e.message, apizero_code: e.code })
|
||||
if (e.code === 4029 || e.code === 4030) return res.status(503).json({ error: e.message, apizero_code: e.code })
|
||||
return res.status(502).json({ error: e.message, apizero_code: e.code })
|
||||
}
|
||||
next(e)
|
||||
}
|
||||
|
||||
// 实时: 油价 + 国际原油 + 下次调价预测
|
||||
router.get('/apizero/oil/forecast', async (req, res, next) => {
|
||||
try { res.json(await oil.getForecast()) }
|
||||
catch (e) { handleApizeroError(e, res, next) }
|
||||
})
|
||||
|
||||
// 32 省油价
|
||||
router.get('/apizero/oil/price-all', async (req, res, next) => {
|
||||
try { res.json(await oil.getPriceAll()) }
|
||||
catch (e) { handleApizeroError(e, res, next) }
|
||||
})
|
||||
|
||||
// 单省油价
|
||||
router.get('/apizero/oil/price', async (req, res, next) => {
|
||||
try { res.json(await oil.getPrice(req.query.province)) }
|
||||
catch (e) { handleApizeroError(e, res, next) }
|
||||
})
|
||||
|
||||
// 调价日历 (按年缓存)
|
||||
router.get('/apizero/oil/schedule', async (req, res, next) => {
|
||||
try { res.json(await oil.getSchedule(req.query.year)) }
|
||||
catch (e) { handleApizeroError(e, res, next) }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
// 浙江省油价历史 (DB)
|
||||
// GET /api/apizero/oil/history?fuel=92&days=180
|
||||
// - fuel: 92 / 95 / 0 (留空 = 3 个油品都返回)
|
||||
// - days: 默认 180 (半年)
|
||||
router.get('/apizero/oil/history', async (req, res, next) => {
|
||||
try {
|
||||
const fuel = req.query.fuel ? String(req.query.fuel) : undefined
|
||||
const days = Math.max(1, Math.min(Number(req.query.days) || 180, 730))
|
||||
const items = await history.query({ fuel, days })
|
||||
res.json({ items, fuel: fuel || 'all', days })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
32
src/api/oil.js
Normal file
32
src/api/oil.js
Normal file
@ -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())
|
||||
}
|
||||
@ -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 → 经济数据 (默认)
|
||||
// 宏观: 顶栏 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>
|
||||
|
||||
250
src/pages/macro/OilPage.jsx
Normal file
250
src/pages/macro/OilPage.jsx
Normal file
@ -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); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user