diff --git a/scripts/lib/cron-alert.js b/scripts/lib/cron-alert.js new file mode 100644 index 0000000..a87dd2c --- /dev/null +++ b/scripts/lib/cron-alert.js @@ -0,0 +1,36 @@ +// 定时任务失败报警: 向 Alert 表插入一条错误记录 +// 供 server/cron.js 与 scripts/scheduler/index.js 共用 +// 首页报警 tab 会展示 Alert 表, 字段按错误语义填充 (indicatorName=任务+错误摘要) +import 'dotenv/config' +import mysql from 'mysql2/promise' + +function parseUrl(url) { + const u = new URL(url) + return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') } +} + +export async function insertCronAlert(taskName, message) { + if (!process.env.DATABASE_URL) { + console.error('[cron-alert] 缺少 DATABASE_URL, 跳过报警写入') + return + } + let conn = null + try { + conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL)) + // 用 Asia/Shanghai 时区的当天日期, 避免 UTC 偏移导致 period 显示成昨天 + const today = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' }) + const cleanMsg = String(message || '').replace(/\s+/g, ' ').trim() + const name = ('[cron] ' + taskName + ' 失败: ' + cleanMsg).slice(0, 180) + await conn.execute( + 'INSERT INTO `Alert` (`indicatorName`, `period`, `valueA`, `valueB`, `sourceA`, `sourceB`, `diff`, `diffRatio`, `createdAt`) ' + + 'VALUES (?, ?, 0, 0, ?, ?, 0, 0, NOW(3))', + [name, today, 'cron', 'error'], + ) + console.error('[cron-alert] 已写入报警:', name) + } catch (e) { + // 报警写入本身失败 (如 DB 不可达) 不能反噬主流程/再次抛错, 只记录 + console.error('[cron-alert] 写入报警失败:', e.message) + } finally { + if (conn) await conn.end().catch(() => {}) + } +} diff --git a/scripts/scheduler/index.js b/scripts/scheduler/index.js index 52740fd..cc64a35 100644 --- a/scripts/scheduler/index.js +++ b/scripts/scheduler/index.js @@ -1,13 +1,14 @@ import cron from 'node-cron'; import { trigger as triggerUSDCNYRate } from '../scraper/usd-cny-rate.scraper.js'; import { trigger as triggerSH300Data } from '../scraper/sh300.scraper.js'; +import { insertCronAlert } from '../lib/cron-alert.js'; console.log('='.repeat(70)); console.log('🚀 启动定时任务调度器'); console.log('='.repeat(70)); -// 注册定时任务 - USD-CNY 汇率数据 -const jobUSDCNY = cron.schedule('0 0 * * *', async () => { +// 注册定时任务 - USD-CNY 汇率数据 (每天 6:20, 出错生成报警) +const jobUSDCNY = cron.schedule('20 6 * * *', async () => { console.log('\n' + '='.repeat(70)); console.log('⏰ 执行定时任务: USD-CNY 汇率数据更新'); console.log('📅 执行时间:', new Date().toISOString()); @@ -18,6 +19,7 @@ const jobUSDCNY = cron.schedule('0 0 * * *', async () => { console.log('\n✅ 任务执行成功!'); } catch (error) { console.error('\n❌ 任务执行失败:', error.message); + await insertCronAlert('USD-CNY 汇率更新', error.message); } finally { console.log('='.repeat(70)); } @@ -26,8 +28,8 @@ const jobUSDCNY = cron.schedule('0 0 * * *', async () => { timezone: 'Asia/Shanghai' }); -// 注册定时任务 - 沪深300指数数据 -const jobSH300 = cron.schedule('0 0 * * *', async () => { +// 注册定时任务 - 沪深300指数数据 (每天 6:30, 出错生成报警) +const jobSH300 = cron.schedule('30 6 * * *', async () => { console.log('\n' + '='.repeat(70)); console.log('⏰ 执行定时任务: 沪深300指数数据更新'); console.log('📅 执行时间:', new Date().toISOString()); @@ -38,6 +40,7 @@ const jobSH300 = cron.schedule('0 0 * * *', async () => { console.log('\n✅ 任务执行成功!'); } catch (error) { console.error('\n❌ 任务执行失败:', error.message); + await insertCronAlert('沪深300指数更新', error.message); } finally { console.log('='.repeat(70)); } @@ -47,12 +50,12 @@ const jobSH300 = cron.schedule('0 0 * * *', async () => { }); console.log('✅ 定时任务已启动: USD-CNY 汇率数据更新'); -console.log('📅 执行表达式: 0 0 * * * (每天 0 点)'); -console.log('📅 下次执行时间: 明天 00:00'); +console.log('📅 执行表达式: 20 6 * * * (每天 6:20)'); +console.log('📅 下次执行时间: 明天 06:20'); console.log(''); console.log('✅ 定时任务已启动: 沪深300指数数据更新'); -console.log('📅 执行表达式: 0 0 * * * (每天 0 点)'); -console.log('📅 下次执行时间: 明天 00:00'); +console.log('📅 执行表达式: 30 6 * * * (每天 6:30)'); +console.log('📅 下次执行时间: 明天 06:30'); console.log(''); console.log('按 Ctrl+C 停止...'); console.log('='.repeat(70)); diff --git a/server/cron.js b/server/cron.js index 666808d..5491ba0 100644 --- a/server/cron.js +++ b/server/cron.js @@ -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 { insertCronAlert } from '../scripts/lib/cron-alert.js' import { ApizeroError, oil, history } from './apizero/index.js' function parseUrl(url) { @@ -14,10 +15,10 @@ async function openConn() { return mysql.createConnection(parseUrl(process.env.DATABASE_URL)) } -// 每天凌晨 3:00 (本地时间) 拉取新闻 + 跑跨源对比 +// 每天 6:00 (Asia/Shanghai) 拉取新闻 + 跑跨源对比, 出错生成报警 function scheduleNews() { - cron.schedule('0 3 * * *', async () => { - console.log('[cron] 3:00 触发') + cron.schedule('0 6 * * *', async () => { + console.log('[cron] 6:00 触发') try { const list = await fetchNewsList() const r = await persistNews(list) @@ -29,25 +30,26 @@ function scheduleNews() { } finally { await conn.end() } } catch (e) { console.error('[cron] 任务失败:', e.message) + await insertCronAlert('新闻拉取+报警检测', e.message) } - }) - console.log('[cron] 已注册: 每天 03:00 拉取新闻 + 跨源报警检测') + }, { timezone: 'Asia/Shanghai' }) + console.log('[cron] 已注册: 每天 06:00 拉取新闻 + 跨源报警检测') } -// 每天凌晨 4:00 抓浙江油价历史 +// 每天 6:10 (Asia/Shanghai) 抓浙江油价历史, 出错生成报警 // 逻辑: 拉今年 schedule, 检查待定且 <= 昨天的调价日, 命中就抓 price?province=浙江 存库 // - 0 命中: 不调 apizero (省配额) // - >0 命中: 调一次, 4 个油品批量入库 (unique 兜底去重) function scheduleOilHistory() { - cron.schedule('0 4 * * *', async () => { - console.log('[cron] 4:00 油价历史触发') + cron.schedule('10 6 * * *', async () => { + console.log('[cron] 6:10 油价历史触发') 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) + // 待定 且 日期 <= 昨天 (用 Asia/Shanghai 时区推导, 与 cron 触发时区对齐) + const todayStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' }) + const today = new Date(todayStr + 'T00:00:00') const yesterdayMs = today.getTime() - 86400000 const pendingPast = list.filter((it) => { if (it.status !== '待定') return false @@ -67,9 +69,10 @@ function scheduleOilHistory() { } else { console.error('[cron] 油价历史任务失败:', e.message) } + await insertCronAlert('浙江油价历史抓取', e.message) } - }) - console.log('[cron] 已注册: 每天 04:00 浙江油价历史抓取') + }, { timezone: 'Asia/Shanghai' }) + console.log('[cron] 已注册: 每天 06:10 浙江油价历史抓取') } scheduleNews() diff --git a/src/pages/macro/DataView.jsx b/src/pages/macro/DataView.jsx index cb17ed6..2f1e01a 100644 --- a/src/pages/macro/DataView.jsx +++ b/src/pages/macro/DataView.jsx @@ -82,6 +82,10 @@ function DataDetail({ indicator, onBack }) { 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 @@ -106,17 +110,22 @@ function DataDetail({ indicator, onBack }) { -
+
- {rows.map((r) => ( + {visibleRows.map((r) => ( ))}
月份数值
{periodOf(r)}{r.value}
+ {tableRows.length > 10 && !showAll && ( +
setShowAll(true)}> + 展开全部(共 {tableRows.length} 行) +
+ )}
diff --git a/src/styles/globals.css b/src/styles/globals.css index 379a93a..dc61b04 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -170,6 +170,9 @@ input, textarea, select { font: inherit; } .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; }