Files
wxserver/utils/lunar.js
T
yuming 59ed635dcf
部署到群晖 / deploy (push) Successful in 42s
修复 lunarToSolar 系统性少 1 天的 bug
BASE_DATE 用 new Date(1900, 0, 31)(本地时间)在 1900 年早期日期上某些
JS 引擎有时区偏差,导致每个农历月初对应公历都比真实少 1 天。改用
Date.UTC(1900, 0, 31) 算时间戳,再用 UTC 字段重新构造本地 Date 对象,
彻底避免歧义。

同时月循环改用 `leapM <= m` 写法(与权威 jjonline/calendar.js 一致),
和 solarToLunar 保持完美互逆。

修复验证(互逆 9 个用例全 OK):
- 2026-02-17 ↔ 正月初一
- 2026-06-09 ↔ 农历四月廿四(用户实测发现的差 1 天)
- 2023-03-22 ↔ 闰二月初一
- 2023-04-19 ↔ 闰二月廿九
- 2025-07-25 ↔ 闰六月初一
- 2025-08-22 ↔ 闰六月廿九

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 06:34:00 +08:00

221 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 农历日期转换工具
* 基于寿星万年历算法(1900-2100年)
*/
// 农历数据:每条数据记录该年的月份大小和闰月信息
// 格式:bit 16=闰月大小(1 大月 / 0 小月)bit 15-4=12 个月大小(1大/0小)bit 3-0=闰月位置(0 无闰)
// 数据源:github.com/jjonline/calendar.js(权威,经过修订)
const lunarInfo = [
0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2, // 1900-1909
0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977, // 1910-1919
0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970, // 1920-1929
0x06566, 0x0d4a0, 0x0ea50, 0x16a95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950, // 1930-1939
0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557, // 1940-1949
0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0, // 1950-1959
0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, // 1960-1969
0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6, // 1970-1979
0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570, // 1980-1989
0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x05ac0, 0x0ab60, 0x096d5, 0x092e0, // 1990-1999
0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, // 2000-2009
0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930, // 2010-2019
0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530, // 2020-2029
0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, // 2030-2039
0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, // 2040-2049
0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06b20, 0x1a6c4, 0x0aae0, // 2050-2059
0x092e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4, // 2060-2069
0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0, // 2070-2079
0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160, // 2080-2089
0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a2d0, 0x0d150, 0x0f252, // 2090-2099
0x0d520 // 2100
]
// 1900年1月31日是农历正月初一(庚子年)
const BASE_DATE = new Date(1900, 0, 31)
const LUNAR_MONTHS = ['正','二','三','四','五','六','七','八','九','十','冬','腊']
const LUNAR_DAYS_TENS = ['初','十','廿','三']
const LUNAR_DAYS_UNITS = ['一','二','三','四','五','六','七','八','九','十']
/**
* 获取农历某年的总天数
*/
function _lunarYearDays(year) {
let total = 348
for (let i = 0x8000; i > 0x8; i >>= 1) {
total += (lunarInfo[year - 1900] & i) ? 1 : 0
}
return total + _leapDays(year)
}
/**
* 获取农历某年闰月的天数
*/
function _leapDays(year) {
if (_leapMonth(year)) {
return (lunarInfo[year - 1900] & 0x10000) ? 30 : 29
}
return 0
}
/**
* 获取农历某年的闰月月份,0表示无闰月
*/
function _leapMonth(year) {
return lunarInfo[year - 1900] & 0xf
}
/**
* 获取农历某年某月的天数
*/
function _monthDays(year, month) {
return (lunarInfo[year - 1900] & (0x10000 >> month)) ? 30 : 29
}
/**
* 公历转农历
* 算法移植自 github.com/jjonline/calendar.js(权威),与本地原版的差异在月循环退出时
* 必须保留 temp 保存最后一次月份天数,否则闰月非初一的日期会被错误地归到下一个普通月份。
* @param {Date} solarDate
* @returns {{ year, month, day, isLeap, lunarText }}
*/
function solarToLunar(solarDate) {
const y = solarDate.getFullYear()
const m = solarDate.getMonth() + 1
const d = solarDate.getDate()
let offset = (Date.UTC(y, m - 1, d) - Date.UTC(1900, 0, 31)) / 86400000
let i, leap = 0, temp = 0
for (i = 1900; i < 2101 && offset > 0; i++) {
temp = _lunarYearDays(i)
offset -= temp
}
if (offset < 0) {
offset += temp
i--
}
const year = i
leap = _leapMonth(i)
let isLeap = false
for (i = 1; i < 13 && offset > 0; i++) {
if (leap > 0 && i === (leap + 1) && isLeap === false) {
--i
isLeap = true
temp = _leapDays(year)
} else {
temp = _monthDays(year, i)
}
if (isLeap === true && i === (leap + 1)) {
isLeap = false
}
offset -= temp
}
// 闰月下标重叠导致 offset 恰好为 0 时的取反
if (offset === 0 && leap > 0 && i === leap + 1) {
if (isLeap) {
isLeap = false
} else {
isLeap = true
--i
}
}
if (offset < 0) {
offset += temp
--i
}
const month = i
const day = offset + 1
return {
year,
month,
day,
isLeap,
lunarText: `${isLeap ? '闰' : ''}${getLunarMonthName(month)}${getLunarDayName(day)}`
}
}
/**
* 农历转公历(指定年份)。算法贴近 jjonline/calendar.js 权威实现。
* 关键修正:早期版用 `new Date(1900, 0, 31)`(本地时间)作为 BASE,在 1900 年早期日期
* 上某些 JS 引擎有时区偏差,导致每个农历月初对应公历都少 1 天。改用 UTC 时间戳后
* 再构造本地 Date 对象,避免歧义。
* @returns {Date} 本地视角下的公历日期(getDate 取出来的是该农历日对应的公历日)
*/
function lunarToSolar(lunarYear, lunarMonth, lunarDay, isLeap) {
let offset = 0
for (let y = 1900; y < lunarYear; y++) {
offset += _lunarYearDays(y)
}
const leapM = _leapMonth(lunarYear)
let isAdd = false
for (let m = 1; m < lunarMonth; m++) {
if (!isAdd && leapM > 0 && leapM <= m) {
offset += _leapDays(lunarYear)
isAdd = true
}
offset += _monthDays(lunarYear, m)
}
if (isLeap && lunarMonth === leapM) {
offset += _monthDays(lunarYear, lunarMonth)
}
offset += lunarDay - 1
// 用 UTC 计算时间戳避免早期年份时区歧义;再用 UTC 字段构造本地 Date 对象
const utc = Date.UTC(1900, 0, 31) + offset * 86400000
const d = new Date(utc)
return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
}
/**
* 获取指定农历月日在今年或明年的公历日期
*/
function getNextLunarDate(lunarMonth, lunarDay) {
const today = new Date()
const currentYear = today.getFullYear()
const lunarToday = solarToLunar(today)
// 尝试今年
let candidate = lunarToSolar(lunarToday.year, lunarMonth, lunarDay, false)
if (candidate >= today) return candidate
// 明年
return lunarToSolar(lunarToday.year + 1, lunarMonth, lunarDay, false)
}
function getLunarMonthName(month) {
return LUNAR_MONTHS[month - 1] || ''
}
function getLunarDayName(day) {
if (day === 10) return '初十'
if (day === 20) return '二十'
if (day === 30) return '三十'
const tens = Math.floor(day / 10)
const units = day % 10
return LUNAR_DAYS_TENS[tens] + (units > 0 ? LUNAR_DAYS_UNITS[units - 1] : '')
}
function formatLunarText(lunarDate) {
return solarToLunar(new Date(lunarDate.year, lunarDate.month - 1, lunarDate.day)).lunarText
}
module.exports = {
solarToLunar,
lunarToSolar,
getNextLunarDate,
formatLunarText,
getLunarMonthName,
getLunarDayName
}