feat(lunar): 双端农历换算层(Go + TS,同作者同算法)

日历要支持「每农历月十五」「农历生日」这类规则。公历与农历的换算不能
自己算,两端各引一个库:Go 用 6tail/lunar-go v1.4.6,前端用同作者的
lunar-javascript 1.7.7 —— 同算法保证两端结果一致(前端要在格子上显示
农历日、在编辑器里预览接下来几次触发)。

为什么要包一层而不直接用库:

**1. 库在非法日期上 panic 而不是返回 error。**
`NewLunarFromYmd(2027, 9, 30)` 直接 panic("only 29 days in lunar year
2027 month 9")。农历月是 29 或 30 天不定,「每月农历三十」这条规则必然
撞上短月份。调度器里一次 panic 就让那条提醒永久卡住。
修法是夹到该月实际天数并返回 clamped 标记 —— 夹而不滚:「每月三十」的
语义是「月末那天」,滚到下月初一会让提醒与前一次只隔一天。

**2. 闰月用负数月份表示**(-6 = 闰六月),这个约定藏在库内部。
2025 有闰六月、2028 有闰五月,2026/2027 没有。AddYears 从闰月出发而
目标年没有同一闰月时退回正月份 —— 静默让重复事件消失更糟。

**3. 按农历推进不能加固定天数。**
农历月 29~30 天、农历年 353~385 天(闰年多一整月),AddDate 近似一年
能偏半个月。

前端另有一个 TS 陷阱:日名有五种前缀形态(初一/十一/二十/廿一/三十),
原来用正则从 toString() 截取时漏了「二十」,20 号会显示整串「七月二十」。
改成查表。

测试:Go 12 例 / TS 37 例。含「同一农历日在六年公历里落到至少 4 个不同
月日上」—— 那正是农历重复存在的理由(公历 yearly 会固定在同一天)。
This commit is contained in:
2026-09-04 06:27:15 +08:00
parent 473c46659a
commit 4d211c84d6
9 changed files with 1085 additions and 1 deletions

57
web/src/types/lunar-javascript.d.ts vendored Normal file
View File

@ -0,0 +1,57 @@
/**
* lunar-javascript 的类型声明。
*
* 上游没有发布 .d.ts也没有 @types/lunar-javascript不声明的话
* `import` 直接 TS7016 编译失败。
*
* 只声明我们真正用到的成员而不是 `declare module 'lunar-javascript'`
* (那等于放弃整个模块的类型)—— 写错方法名时仍要能在编译期发现,
* 否则会变成运行时的「undefined is not a function」而日历页面
* 一旦抛错整片区域白屏。
*/
declare module 'lunar-javascript' {
export interface LunarMonthLike {
getYear(): number;
/** 负数表示闰月 */
getMonth(): number;
/** 29 或 30 */
getDayCount(): number;
}
export interface SolarLike {
getYear(): number;
/** 1-12 */
getMonth(): number;
getDay(): number;
toYmd(): string;
toString(): string;
}
export interface LunarLike {
getYear(): number;
/** 负数表示闰月 */
getMonth(): number;
getDay(): number;
getSolar(): SolarLike;
/** 「二〇二六年七月廿二」 */
toString(): string;
}
export const Solar: {
fromYmd(year: number, month: number, day: number): SolarLike & { getLunar(): LunarLike };
};
export const Lunar: {
/** month 传负数表示闰月。**非法日期会 throw** —— 农历月是 29 或 30 天不定。 */
fromYmd(year: number, month: number, day: number): LunarLike;
};
export const LunarYear: {
fromYear(year: number): {
/** 0 = 无闰月 */
getLeapMonth(): number;
/** 含跨年边界的月份,因此使用时必须同时比对 getYear() */
getMonths(): LunarMonthLike[];
};
};
}