188 lines
7.4 KiB
JavaScript
188 lines
7.4 KiB
JavaScript
const axios = require('axios')
|
||
const fs = require('fs')
|
||
const path = require('path')
|
||
const Database = require('better-sqlite3')
|
||
const { logJSON } = require('./logger')
|
||
|
||
const SH_LAT = 31.2304
|
||
const SH_LON = 121.4737
|
||
|
||
const fetchWeather = async (lat, lon) => {
|
||
const latitude = Number(lat) || SH_LAT
|
||
const longitude = Number(lon) || SH_LON
|
||
try {
|
||
const urlWeather = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m,apparent_temperature,precipitation&hourly=precipitation_probability&daily=temperature_2m_max,temperature_2m_min&timezone=auto`
|
||
const urlAir = `https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${latitude}&longitude=${longitude}&hourly=us_aqi,pm2_5,european_aqi&timezone=auto`
|
||
const [wRes, aRes] = await Promise.all([
|
||
axios.get(urlWeather, { timeout: 8000 }),
|
||
axios.get(urlAir, { timeout: 8000 })
|
||
])
|
||
const w = wRes.data || {}
|
||
const a = aRes.data || {}
|
||
const cur = (w.current || {})
|
||
let prob = null
|
||
try {
|
||
const times = (w.hourly && w.hourly.time) || []
|
||
const probs = (w.hourly && w.hourly.precipitation_probability) || []
|
||
const nowIso = new Date().toISOString().slice(0,13)
|
||
const idx = times.findIndex(t => String(t).slice(0,13) === nowIso)
|
||
if (idx >= 0) prob = probs[idx]
|
||
} catch {}
|
||
const hAqi = (a.hourly || {})
|
||
let tmin = null, tmax = null
|
||
try {
|
||
const dTimes = (w.daily && w.daily.time) || []
|
||
const dMin = (w.daily && w.daily.temperature_2m_min) || []
|
||
const dMax = (w.daily && w.daily.temperature_2m_max) || []
|
||
const today = new Date().toISOString().slice(0,10)
|
||
const di = dTimes.findIndex(t => String(t).slice(0,10) === today)
|
||
if (di >= 0) { tmin = dMin[di]; tmax = dMax[di] }
|
||
} catch {}
|
||
const info = {
|
||
source: 'open-meteo',
|
||
city: '上海',
|
||
temperature: cur.temperature_2m,
|
||
apparent_temperature: cur.apparent_temperature,
|
||
precipitation: cur.precipitation,
|
||
precipitation_probability: prob,
|
||
us_aqi: hAqi.us_aqi ? hAqi.us_aqi[0] : undefined,
|
||
european_aqi: hAqi.european_aqi ? hAqi.european_aqi[0] : undefined,
|
||
pm2_5: hAqi.pm2_5 ? hAqi.pm2_5[0] : undefined,
|
||
temperature_min: tmin,
|
||
temperature_max: tmax
|
||
}
|
||
logJSON('wall.weather', { latitude, longitude, info }, 'wall')
|
||
return info
|
||
} catch (e) {
|
||
logJSON('wall.weather.error', { error: String(e.message || e) }, 'wall')
|
||
return { source: 'open-meteo', city: '上海', temperature: null, apparent_temperature: null, precipitation: null, precipitation_probability: null, us_aqi: null, pm2_5: null }
|
||
}
|
||
}
|
||
|
||
module.exports = { fetchWeather }
|
||
|
||
const cfgPath = path.join(process.cwd(), 'config', 'wall.json')
|
||
if (!fs.existsSync(cfgPath)) fs.writeFileSync(cfgPath, JSON.stringify({
|
||
autoplay_ms: 45000,
|
||
swipe_pause_ms: 5 * 60 * 1000,
|
||
max_cards_on_screen: 2,
|
||
background: { url: "", mode: "tile", size: 120, opacity: 0.08 },
|
||
cards: [
|
||
{ title: '轻断食 · 吃什么', items: ['清汤/水', '清蒸蛋/少量蛋白', '蔬菜少油', '避免高糖与精制', '避免冷饮'], meta: '场景:晨间/午后 · 时长≤3秒读' },
|
||
{ title: '冷水澡 · 准备步骤', items: ['热身2–3分钟', '先温水→冷水渐进', '重点避头部直冲', '备干毛巾与保暖', '注意心率与眩晕'], meta: '场景:沐浴前 · 安全优先' },
|
||
{ title: '跑步 · 前置准备', items: ['热身动态拉伸', '检查鞋与袜', '补水少量', '路线与天气确认', '佩戴反光/夜跑灯'], meta: '场景:运动前 · 目标安全' }
|
||
]
|
||
}, null, 2))
|
||
|
||
const getConfig = () => JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
|
||
const setConfig = cfg => fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2))
|
||
|
||
module.exports.getConfig = getConfig
|
||
module.exports.setConfig = setConfig
|
||
|
||
const dataDir = path.join(process.cwd(), 'data')
|
||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
|
||
const wallDbPath = path.join(dataDir, 'call.db')
|
||
const wallDb = new Database(wallDbPath)
|
||
|
||
wallDb.exec(`CREATE TABLE IF NOT EXISTS wall_cards (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
title TEXT NOT NULL,
|
||
items_json TEXT NOT NULL,
|
||
meta TEXT,
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
updated_at TEXT DEFAULT (datetime('now'))
|
||
);`)
|
||
|
||
const countCardsRow = () => {
|
||
try {
|
||
const row = wallDb.prepare('SELECT COUNT(*) AS c FROM wall_cards').get()
|
||
if (row && typeof row.c === 'number') return row.c
|
||
return 0
|
||
} catch {
|
||
return 0
|
||
}
|
||
}
|
||
|
||
const ensureInitialCardsFromConfig = () => {
|
||
try {
|
||
if (countCardsRow() > 0) return
|
||
const cfg = getConfig()
|
||
const cards = Array.isArray(cfg.cards) ? cfg.cards : []
|
||
if (!cards.length) return
|
||
const insert = wallDb.prepare(`INSERT INTO wall_cards (title, items_json, meta) VALUES (@title, @items_json, @meta)`)
|
||
const tx = wallDb.transaction((list) => {
|
||
for (const c of list) {
|
||
const items = Array.isArray(c.items) ? c.items : []
|
||
insert.run({ title: String(c.title || ''), items_json: JSON.stringify(items), meta: String(c.meta || '') })
|
||
}
|
||
})
|
||
tx(cards)
|
||
try { logJSON('wall.cards.migrate_from_config', { count: cards.length }, 'wall') } catch {}
|
||
} catch (e) {
|
||
try { logJSON('wall.cards.migrate_from_config.error', { error: String(e.message || e) }, 'wall') } catch {}
|
||
}
|
||
}
|
||
|
||
ensureInitialCardsFromConfig()
|
||
|
||
const listCardsStmt = wallDb.prepare(`SELECT id, title, items_json, meta, created_at, updated_at FROM wall_cards ORDER BY id ASC`)
|
||
const insertCardStmt = wallDb.prepare(`INSERT INTO wall_cards (title, items_json, meta) VALUES (@title, @items_json, @meta)`)
|
||
const updateCardStmt = wallDb.prepare(`UPDATE wall_cards SET title=@title, items_json=@items_json, meta=@meta, updated_at=datetime('now') WHERE id=@id`)
|
||
const deleteCardStmt = wallDb.prepare(`DELETE FROM wall_cards WHERE id=?`)
|
||
|
||
const normalizeItems = (items) => {
|
||
if (Array.isArray(items)) return items.map(x => String(x || ''))
|
||
const text = String(items || '')
|
||
if (!text.trim()) return []
|
||
return text.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
|
||
}
|
||
|
||
const listCards = () => {
|
||
const rows = listCardsStmt.all()
|
||
return rows.map(r => {
|
||
let items = []
|
||
try {
|
||
const parsed = JSON.parse(r.items_json || '[]')
|
||
if (Array.isArray(parsed)) items = parsed.map(x => String(x || ''))
|
||
} catch {}
|
||
return {
|
||
id: r.id,
|
||
title: r.title || '',
|
||
items,
|
||
meta: r.meta || '',
|
||
created_at: r.created_at,
|
||
updated_at: r.updated_at
|
||
}
|
||
})
|
||
}
|
||
|
||
const createCard = (payload) => {
|
||
const items = normalizeItems(payload.items)
|
||
const items_json = JSON.stringify(items)
|
||
const title = String(payload.title || '')
|
||
const meta = String(payload.meta || '')
|
||
const info = insertCardStmt.run({ title, items_json, meta })
|
||
try { logJSON('wall.card.create', { id: info.lastInsertRowid, title }, 'wall') } catch {}
|
||
return info.lastInsertRowid
|
||
}
|
||
|
||
const updateCardById = (id, payload) => {
|
||
const items = normalizeItems(payload.items)
|
||
const items_json = JSON.stringify(items)
|
||
const title = String(payload.title || '')
|
||
const meta = String(payload.meta || '')
|
||
updateCardStmt.run({ id, title, items_json, meta })
|
||
try { logJSON('wall.card.update', { id, title }, 'wall') } catch {}
|
||
}
|
||
|
||
const deleteCardById = (id) => {
|
||
deleteCardStmt.run(id)
|
||
try { logJSON('wall.card.delete', { id }, 'wall') } catch {}
|
||
}
|
||
|
||
module.exports.listCards = listCards
|
||
module.exports.createCard = createCard
|
||
module.exports.updateCardById = updateCardById
|
||
module.exports.deleteCardById = deleteCardById
|