农历走服务端端点:换算只在服务端做一次(两边各写一遍天文算法迟早差一天)

用户定的方案:「加 api 端点」。

## 为什么不移植到 ArkTS

`lunar-javascript` 的 `lunar.js` 有 **43 万字节**,内部是**日月位置的级数展开**
(实测:全文件最大的数字字面量是 16KB 的系数数组,**不是**"某年到某年的月长表")。
即"照搬一张小数据表"这条路**不存在** —— 移植等于在 ArkTS 里再实现一遍天文算法。
两份实现迟早会在某个闰月或某个朔日上差一天,而那种错**表现为日期错位、不是报错**,
界面上完全看不出(用户得自己去查日历才知道)。

所以:`GET /api/v1/calendar/lunar?from=&to=`(服务端 `internal/lunar`,同一作者的 lunar-go)。

## 形状是「按日期键索引的映射」,不是数组

客户端拿到 `map[iso] -> 标签` 直接按格子键查,不用自己遍历比对。
`text` 字段是**格子里直接显示的那个串**(初一=月名、其余=日名)——
由服务端定,两端同源。客户端各拼一份的话,同一天在两边日历上可能长得不一样
(例如闰月到底写不写「闰」)。

几个刻意的取舍:
- **不设默认 from/to**:默认范围会让「我要 3 月」与「服务端以为我要这个月」悄悄不一致;
- 入参只收 `YYYY-MM-DD`(**日期键**,不是 RFC3339):农历是"这一天是农历几号"的
  纯日期语义,混用时间戳会被时区挪一天;
- 换不出来的日子**不进 map**(客户端查不到 ⇒ 那格不显示农历),而不是塞空对象 ——
  空对象会让客户端以为"有农历、只是没内容";
- 区间上限 400 天(不是安全边界,是防客户端传十年前到十年后)。

## 判据(`server/internal/handler/lunar_test.go`)

参照物是服务端的 `internal/lunar`(权威实现),**不抄一份答案表** —— 库升级时判据跟着走。
钉的点各自对着一个会静默出错的错法:
- `Full` 与权威实现逐字一致(5 个日期,含春节、跨世纪、29 天月的边界年份);
- 日名表覆盖 1..30 且**五种前缀形态都在**(WebUI 那版漏过「二十」);
- ★ 初一显示**月名**、其余显示**日名**(与 WebUI `cellLunarLabel()` 同一口径);
- 闰月必须带「闰」字(不带的话闰六月与六月在格子里一样);
- 极端值(公元 1 年 / 1900 / 2100 / 9999)**不许 panic**,且换出来时文字里不许含
  「无效/NaN」这类失败标记。

★ 最后一条我第一版**写错了**:断言「`time.Time{}` 应当换不出来」,实测库**换得出来**
  (0001-01-01 → 「〇年冬月十八」)—— 我断言的是自己的想象而不是实际行为。
  改成断言真正的契约(不 panic / 失败就不给 / 给了就得是真结果)后才对。

## 客户端

`LunarLabels` / `LunarRangeResponse` 两个模型 + `CalendarApi.listLunar(fromIso, toIso)`;
`CalendarPage` 在 `loadEvents()` 之后**不 await** 地拉农历(附加信息不该拖慢事件列表),
失败**不算 `this.error`**(否则"农历服务抖一下"会变成"整个日历打不开"),
只写 hilog 留痕。区间按**网格**取(不只本月 —— 月视图首尾显示上/下月格子)。
This commit is contained in:
2026-09-18 11:23:12 +08:00
parent fbe7879981
commit 2da38bba83
6 changed files with 548 additions and 3 deletions

View File

@ -9,7 +9,7 @@
* 只查当月会让那几个格子永远空着(看着像"那天没事件",其实是没查)。
*/
import { ApiClient } from './ApiClient';
import { CalendarListResponse, CalendarEvent, CalendarEventInput, CalendarDeleteResponse } from '../model/Models';
import { CalendarListResponse, CalendarEvent, CalendarEventInput, CalendarDeleteResponse, LunarRangeResponse } from '../model/Models';
export class CalendarApi {
private client: ApiClient;
@ -50,4 +50,19 @@ export class CalendarApi {
async deleteEvent(id: string): Promise<CalendarDeleteResponse> {
return this.client.del<CalendarDeleteResponse>('/calendar/events/' + encodeURIComponent(id));
}
/**
* 一段日期区间的农历标签(按 `YYYY-MM-DD` 索引)。
*
* ★ 入参是**日期键**(`YYYY-MM-DD`),不是 RFC3339 —— 与 `listEvents` 的区别就在这里:
* 事件查询是"瞬间落在区间内",而农历是"这一天是农历几号",是纯日期语义。
* 传时间戳进去会被服务端拒(它只收 `YYYY-MM-DD`),这比"悄悄按 UTC 挪一天"好。
*
* ★ 区间是**闭区间**且必须给全:服务端**不设默认值**(默认范围会让"我要 3 月"
* 与"服务端以为我要这个月"悄悄不一致)。上限 400 天。
*/
async listLunar(fromIso: string, toIso: string): Promise<LunarRangeResponse> {
const query: string = 'from=' + encodeURIComponent(fromIso) + '&to=' + encodeURIComponent(toIso);
return this.client.get<LunarRangeResponse>('/calendar/lunar', query);
}
}

View File

@ -426,3 +426,36 @@ export class AdminUserResponse {
export class AdminStatusResponse {
status: string = '';
}
/*
* ── 农历标签(日历格子用)──
*
* ★ 换算**不在这里做**:农历是天文算法(`lunar-javascript` 的 lunar.js 有 43 万字节、
* 内部是日月位置的级数展开,**没有可照搬的小数据表**)。在 ArkTS 里再实现一遍
* 等于写第二份天文算法,两边迟早会在某个闰月或朔日上差一天 ——
* 而那种错表现为**日期错位,不是报错**,界面看起来完全正常。
*
* 所以服务端算一次(`GET /calendar/lunar`),这里只装结果。
* 与 WebUI 的分工不同(它自带 lib/lunar.ts),但**契约相同**:
* 同一个日期在两端拿到的 `text` 必须是同一个串,否则同一天在两边日历上长得不一样。
*/
export class LunarLabels {
/** 农历日名(初一…三十) */
day_name: string = '';
/** 农历月名(正…冬/腊,闰月带「闰」) */
month_name: string = '';
/** 是否初一 —— 格子文案在初一那天换写月名 */
is_first_day: boolean = false;
/** **格子里直接显示的那个串**(初一=月名,其余=日名),由服务端定,两端同源 */
text: string = '';
/** 是否闰月 */
leap: boolean = false;
/** 完整中文表示(「二〇二六年七月廿二」),给详情/表单预览用 */
full: string = '';
}
/** `GET /calendar/lunar` 的响应:按日期键(YYYY-MM-DD)索引 */
export class LunarRangeResponse {
lunar: Record<string, LunarLabels> = {};
}

View File

@ -23,7 +23,8 @@
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { CalendarApi } from '../api/CalendarApi';
import { CalendarEvent, CalendarEventInput } from '../model/Models';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { CalendarEvent, CalendarEventInput, LunarLabels, LunarRangeResponse } from '../model/Models';
import { Theme } from '../common/Theme';
import {
DayCell,
@ -105,6 +106,13 @@ export struct CalendarPage {
* 不在渲染里重写一遍(重写就是"三处里漏改一处"那种静默错位)。
*/
@State calScale: CalendarScale = 'month';
/**
* 农历标签(按日期键索引),由 `loadLunar()` 从服务端拉。
*
* ★ 换算不在客户端做:农历是天文算法、没有可照搬的小数据表(见 Models.ets 的说明)。
* 拉不到时就是空 map ⇒ 格子少一行小字,日历照常用。
*/
@State lunarMap: Record<string, LunarLabels> = {};
@State events: CalendarEvent[] = [];
@State loading: boolean = false;
@State error: string = '';
@ -194,6 +202,65 @@ export struct CalendarPage {
return new Date(Date.UTC(this.year, this.month, 1)).toISOString();
}
/**
* 要拉农历的**日期键**区间(`YYYY-MM-DD`,闭区间)—— 与 `rangeFrom/rangeTo` 区别:
*
* 那两个是**瞬间区间**(RFC3339),给事件查询用;农历端点的入参是**日期键**,
* 因为"这一天是农历几号"是纯日期语义、与时刻无关。
*
* 区间按**网格**取(不只本月):月视图首尾会显示上/下月的格子,
* 只查当月会让那几个格子永远没有农历 —— 看着像"那几天没有农历",其实是没查。
* 按档位宽窄给:月档要整月网格(前后各多一周),周/日档只要那一周。
*/
private lunarRange(): string[] {
const rows: DayCell[][] = this.rows();
if (rows.length === 0 || rows[0].length === 0) {
return [];
}
const first: string = rows[0][0].iso;
const lastRow: DayCell[] = rows[rows.length - 1];
const last: string = lastRow[lastRow.length - 1].iso;
/* 网格首尾可能是 `iso=''`(空占位格)—— 找第一个/最后一个非空的,不让空串进参数 */
const all: string[] = [];
for (let r = 0; r < rows.length; r++) {
for (let c = 0; c < rows[r].length; c++) {
if (rows[r][c].iso.length > 0) {
all.push(rows[r][c].iso);
}
}
}
if (all.length === 0) {
return [];
}
return [all[0], all[all.length - 1]];
}
/**
* 拉农历标签。
*
* ★ 失败**不算错误**,也不设 `this.error`:农历是格子的**附加信息**,
* 它拉不到时日历本身照常能用(只是格子少一行小字)。
* 把它当致命错误会让“农历服务抖一下”变成“整个日历打不开”—— 那是本末倒置。
* 但也不静默吞掉:写 hilog 留下痕迹(便于排查为什么格子上没农历)。
*/
private async loadLunar(): Promise<void> {
const a: CalendarApi | null = this.api;
if (a === null) {
return;
}
const range: string[] = this.lunarRange();
if (range.length !== 2) {
return;
}
try {
const resp: LunarRangeResponse = await a.listLunar(range[0], range[1]);
this.lunarMap = resp.lunar;
} catch (e) {
hilog.warn(0x0000, 'calendar', '农历标签拉取失败(格子少一行小字,日历照常用):%{public}s',
JSON.stringify(e));
}
}
async loadEvents(): Promise<void> {
const a: CalendarApi | null = this.api;
if (a === null) {
@ -213,6 +280,8 @@ export struct CalendarPage {
this.events = [];
}
this.loading = false;
/* 事件拉完再拉农历(不 await:农历是附加信息,不该拖慢事件列表的出现) */
this.loadLunar();
}
/**
@ -608,13 +677,26 @@ export struct CalendarPage {
.fontSize(Theme.fontBody)
.fontWeight(cell.iso === this.todayIso ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.cellFg(cell))
/*
* 农历小字 —— 与 WebUI 同一位置(公历数字下方一行)。
*
* ★ 文案直接用服务端给的 `text`(初一=月名、其余=日名),
* 客户端**不自己拼**:两端各拼一份的话,同一天在两边日历上
* 可能长得不一样(例如闰月到底写不写「闰」)。
* ★ 拉不到农历时(服务端抖了一下/未登录)这里就是空字串,
* 格子少一行小字、日历照常用 —— 不报错、不留空白占位。
*/
Text(this.lunarTextOf(cell.iso))
.fontSize(9)
.fontColor(cell.iso === this.selectedIso ? Theme.accentFg : Theme.textSubtle)
.maxLines(1)
if (this.eventCountOf(cell.iso) > 0) {
Column()
.width(4)
.height(4)
.borderRadius(2)
.backgroundColor(cell.iso === this.selectedIso ? Theme.accentFg : Theme.accent)
.margin({ top: 3 })
.margin({ top: 1 })
}
}
}
@ -632,6 +714,15 @@ export struct CalendarPage {
})
}
/** 某个格子的农历文案(取不到就是空字串 —— 不占位、不报错) */
private lunarTextOf(iso: string): string {
if (iso.length === 0 || this.lunarMap === undefined) {
return '';
}
const labels: LunarLabels | undefined = this.lunarMap[iso];
return labels !== undefined ? labels.text : '';
}
@Builder
EventRow(e: CalendarEvent) {
Row() {