refactor(theme): 主题判定移到服务端,前端加载时获取

- 后端新增 GET /api/theme(公开):启动后按部署地定位 + NOAA 计算当天
  日出/日落 + 判定当前主题,按日期与坐标缓存;极昼/极夜返回 theme=null
- 删除 /api/location 路由,前端不再拉坐标
- 前端 useTheme:页面加载时拉 /api/theme 应用服务端主题与时刻表,
  离线回退本地 NOAA(含 RA 象限修正);手动覆盖/自动开关逻辑不变
This commit is contained in:
DuMate
2026-09-25 22:49:25 +08:00
parent e430707e06
commit c1dcc16236
6 changed files with 119 additions and 39 deletions
+16
View File
@@ -0,0 +1,16 @@
/**
* 主题 API —— GET /api/theme(公开,无需登录:登录页加载时也要获取主题)
* 返回服务端判定的主题与当天日出/日落时刻,前端据此应用主题并安排自动切换
*/
import express from 'express'
import { getThemeInfo } from '../services/theme.js'
export const themeRouter = express.Router()
themeRouter.get('/theme', async (req, res) => {
try {
res.json(await getThemeInfo())
} catch {
res.json({ theme: null, sunrise: null, sunset: null, lat: 31.23, lng: 121.47, src: 'fallback' })
}
})
+3 -3
View File
@@ -32,7 +32,7 @@ import { syncRouter } from './routes/sync.js'
import { toplistRouter } from './routes/toplist.js'
import { cleanRouter } from './routes/clean.js'
import { logsRouter } from './routes/logs.js'
import { locationRouter } from './routes/location.js'
import { themeRouter } from './routes/theme.js'
import { apiNotFound, errorHandler } from './http.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -41,7 +41,8 @@ app.use(express.json({ limit: '1mb' }))
const PORT = Number(process.env.PORT || 3000)
// —— 路由装配:登录/登出公开,其余 /api/* 需鉴权 ——
// —— 路由装配:登录/登出/主题公开,其余 /api/* 需鉴权 ——
app.use('/api', themeRouter) // 公开:登录页加载时也需要主题
app.use('/api', authRouter)
app.use('/api', authMiddleware)
app.use('/api', thresholdsRouter)
@@ -51,7 +52,6 @@ app.use('/api', syncRouter)
app.use('/api', toplistRouter)
app.use('/api', cleanRouter)
app.use('/api', logsRouter)
app.use('/api', locationRouter)
// —— /api 未匹配任何路由 → JSON 404(Express 默认对非 GET 返回 HTML) ——
app.use('/api', apiNotFound)
+60
View File
@@ -0,0 +1,60 @@
/**
* 主题信息 —— 服务端定位 + 计算日出/日落 + 判定当前主题
* 前端页面加载时调用 GET /api/theme 获取,失败时才回退前端本地算法
* 逻辑:部署地坐标(getDeployLocation)→ NOAA 算法算当天日出/日落 →
* 当前时刻在日出前/日落后为暗色,日出至日落为亮色;极昼极夜返回 theme=null(不自动切)
* 按日期 + 坐标内存缓存,避免每次请求都重复计算
*/
import { getDeployLocation } from './location.js'
const ZENITH = 90.833
const toRad = d => (d * Math.PI) / 180
const toDeg = r => (r * 180) / Math.PI
const dayOfYear = d => Math.floor((d - new Date(d.getFullYear(), 0, 0)) / 86400000)
/** NOAA 日出日落:返回当天服务器本地时间的日出/日落 Date;极昼/极夜返回 null */
function sunTimes(date, lat, lng) {
const ymd = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const N = dayOfYear(date)
const lngHour = lng / 15
const calc = hour => {
const t = N + (hour - lngHour) / 24
const M = 0.9856 * t - 3.289
const L = M + 1.916 * Math.sin(toRad(M)) + 0.02 * Math.sin(toRad(2 * M)) + 282.634
// 右赤经:atan 主值范围 [-90,90],需把 RA 校正到与太阳黄经 λ 同象限(否则日出/日落会颠倒 ~12h)
let RA = toDeg(Math.atan(0.91764 * Math.tan(toRad(L))))
RA = ((RA % 360) + 360) % 360
const lam = ((L % 360) + 360) % 360
while (Math.floor(RA / 90) % 4 !== Math.floor(lam / 90) % 4) RA += 180
RA = (RA % 360) / 15
const sinDec = 0.39782 * Math.sin(toRad(L))
const cosDec = Math.cos(Math.asin(sinDec))
const cosH = (Math.cos(toRad(ZENITH)) - sinDec * Math.sin(toRad(lat))) / (cosDec * Math.cos(toRad(lat)))
if (cosH > 1 || cosH < -1) return null
const H = hour < 12 ? (360 - toDeg(Math.acos(cosH))) / 15 : toDeg(Math.acos(cosH)) / 15
const ut = (H + RA - 0.0653 * t - 6.622 - lngHour + 24) % 24
return new Date(Date.UTC(ymd.getFullYear(), ymd.getMonth(), ymd.getDate(), Math.floor(ut), Math.round((ut % 1) * 60)))
}
return { sunrise: calc(6), sunset: calc(18) }
}
let cache = null // { dateKey, lat, lng, theme, sunrise, sunset }
export async function getThemeInfo() {
const loc = await getDeployLocation()
const now = new Date()
const dateKey = now.toDateString() // 服务器本地日期(TZ=Asia/Shanghai)
if (cache && cache.dateKey === dateKey && cache.lat === loc.lat && cache.lng === loc.lng) return cache
const s = sunTimes(now, loc.lat, loc.lng)
let theme = null
if (s.sunrise && s.sunset) {
theme = now < s.sunrise || now >= s.sunset ? 'dark' : 'bright'
}
cache = {
dateKey, lat: loc.lat, lng: loc.lng, src: loc.src,
theme,
sunrise: s.sunrise ? s.sunrise.toISOString() : null,
sunset: s.sunset ? s.sunset.toISOString() : null,
}
return cache
}