跨端: 鸿蒙端功能同步第一步——日历(只读月视图)上架,入口进底部导航

用户:「要给鸿蒙端做功能同步」。按 API 面盘点(WebUI 62 个 API 函数 vs 鸿蒙 38 个),
最大的用户面缺口是**日历**:纯逻辑(model/Calendar.ts)与判据早就在,一直没页面。

新增:
- api/CalendarApi.ets:GET /calendar/events?from=&to=(与 WebUI 同参;区间按**网格**取,
  不是月首月末 —— 首尾格子会显示邻月,只查当月会让那些格子永远空着)
- pages/CalendarPage.ets:月网格(翻月/回今天)、点某天看当天日程、事件点、今天/选中两态、
  加载失败说出来。**没做**:写侧(增删改)、农历重复、.ics、滑动翻页 —— 逐条写在文件头
- model/Calendar.ts:补 localIsoOf / hhmmAtOffset / deviceOffsetMinutes(偏移是入参 ⇒ 三时区可真跑)
- model/Models.ets:CalendarEvent / CalendarListResponse(字段对齐服务端 JSON)
- NavItems:加「日历」,底部成为 通信/日历/联系人 三项(与 WebUI 同序)
- MainPage:日历是**常驻 pane**(visibility 控制),首次可见才拉数据;today 走
  @Prop @Watch(visible) 在 pane 变可见时重算 ⇒ 结算欠账 calendar-today-recompute
  (DEBTS 15 笔 → 14 笔,余额里不再计这一笔)

判据:harmony-calendar 新增 6 条(网格/表头同源、事件归日走 localIsoOf、三时区钟点、
today 重算路径、翻月走 addMonths、变异自检);harmony-nav ② 分派与 ④ 让位跟着改成结构性判据
(④ 原来那个 400 字符窗口一加 pane 就红 —— 窗口式判据的又一次现身);harmony-logic 两处
「只剩两个平级页签」跟着改成三项。

真机实测(harmony-emu + hvigorw assembleHap + hdc install + uitest click + dumpLayout):
9 月网格星期对齐(周一起始,2026-09-01 落在「二」列)、事件点恰好在有日程的那 6 天
(11/17/18/24/25/30)、点 09-17 列出当天两条日程且钟点是本地时间(DB 里 02:20Z/08:30Z
→ 界面 10:20/16:30)。
This commit is contained in:
pi
2026-09-14 18:38:51 +08:00
parent 79e591aa8b
commit 94ba4b9c58
10 changed files with 677 additions and 37 deletions

View File

@ -192,7 +192,9 @@ test('★ 鸿蒙树里不许用 toISOString 取日期键(今天标错格的直
* 只是残留发生在**调用侧**。
*
* 纯逻辑侧能钉的是:同一次输入给同一结果、不同输入各自独立、**不许跨调用残留**。
* 调用侧义务(pane 变可见时重算)登记在 docs/DEBTS.json 的 `calendar-today-recompute`。
* 调用侧义务(pane 变可见时重算)原登记在 docs/DEBTS.json 的 `calendar-today-recompute`,
* **2026-09-14 已结算**:日历页落地时用 `@Prop @Watch('onVisibleChanged') visible` 实现,
* 判据见本文件末尾的「★ today 在 pane **变可见时**重算」。
*/
test('★ today 不许跨调用残留:连续两次不同输入各自独立,且推进一天后值必须变', () => {
const mark = g => g.flat().filter(c => c.isToday).map(c => c.iso);
@ -210,3 +212,84 @@ test('★ today 不许跨调用残留:连续两次不同输入各自独立,
assert.equal(mark(C.monthGrid(2026, 9, 1)).length, 0,
'不传就不许有今天(不许内部兜底取系统时间 —— 那正是"取一次"的写法)');
});
/*
* ═══════════════════════════════════════════════════════════════════
* P6 第 1 步的**页面那一半**(2026-09-14:日历入口上架)
*
* 上面那些钉的是"算术对不对";从这里开始钉"页面有没有如实消费它"。
* 两者分工明确:算术错 → 日子错位;消费错 → 数据对但画错、或永远画不出来。
*/
test('页面:网格与表头都走 Calendar.ts 的同一套入参(WEEK_START=1,与 WebUI 同值)', () => {
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
// 网格:行来自 monthGrid,且 weekStart 传的是常量(不是字面量 0/1 —— 那会与表头分叉)
assert.match(page, /monthGrid\(this\.year, this\.month, WEEK_START, this\.todayIso\)/,
'网格必须由 monthGrid 生成,并把 WEEK_START 与 todayIso 一起传进去');
assert.match(page, /weekdayLabels\(WEEK_START\)/,
'表头顺序必须来自 weekdayLabels(WEEK_START) —— 硬编码成 日/一/二… 就是第二个分叉点(表头整体错一列)');
const decl = /const WEEK_START: number = (\d)/.exec(page);
assert.ok(decl, 'WEEK_START 要是常量,判据要能读到它');
assert.equal(decl[1], '1', '周起始必须是周一(1)—— 与 WebUI lib/calendar.ts 的 startOfWeek 同值,两端不同值会让同一天落在不同列');
});
test('页面:事件按 localIsoOf 归日,且不自己切时间戳', () => {
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
assert.match(page, /localIsoOf\(/, '事件归日必须走 localIsoOf');
assert.ok(!/\.slice\(0, 10\)/.test(page),
'页面里不许切时间戳前 10 位取日期(那是 UTC 日期 ⇒ UTC+8 的凌晨会把事件画到前一天)');
// 会话/列表侧的日期格式化也不许自己切(同一族:只有 localIsoOf/hhmmAtOffset 两个出口)
assert.match(page, /hhmmAtOffset\(/, '钟点显示走 hhmmAtOffset(偏移是入参,可判据)');
});
test('★ localIsoOf / hhmmAtOffset:偏移是入参,所以三个时区都能真跑', () => {
// UTC+8 的 09-15 00:30 在 UTC 里是 09-14T16:30Z —— 切字符串会给 09-14(错一天)
assert.equal(C.localIsoOf('2026-09-14T16:30:00Z'), C.isoAtOffset(new Date('2026-09-14T16:30:00Z'), C.deviceOffsetMinutes(new Date('2026-09-14T16:30:00Z'))),
'localIsoOf 必须与 isoAtOffset(设备偏移) 同口径');
assert.equal(C.hhmmAtOffset('2026-09-14T16:30:00Z', 480), '00:30', 'UTC+8 的钟点');
assert.equal(C.hhmmAtOffset('2026-09-14T16:30:00Z', -480), '08:30', 'UTC-8 的钟点');
assert.equal(C.hhmmAtOffset('2026-09-14T16:30:00Z', 0), '16:30', 'UTC 的钟点');
// 坏值不许把整个月画崩
assert.equal(C.localIsoOf('not-a-time'), '');
assert.equal(C.hhmmAtOffset('not-a-time', 480), '');
});
test('★ today 在 pane **变可见时**重算(DEBTS 的 calendar-today-recompute)', () => {
/*
* 这条欠账的到期前提就是"日历页面骨架写完后立刻",现在到期了 —— 于是要么实现、要么改前提。
* 实现方式:`@Prop @Watch('onVisibleChanged') visible` + aboutToAppear 各自重算一次。
*
* 为什么两条都要:
* · 重新挂载(切页签)→ aboutToAppear 重算 ✓;
* · 常驻 pane 一直开着跨过午夜 → **只有 @Watch(visible)** 能救(卸载重挂不会发生)。
* 判据盯的是"重算路径存在且绑定在可见性上",不是"今天恰好是几号"(那个由纯逻辑判据管)。
*/
const raw = code(join(ETS, 'pages', 'CalendarPage.ets'));
assert.match(raw, /@Prop @Watch\('onVisibleChanged'\) visible: boolean/,
'visible 必须挂 @Watch —— 否则常驻 pane 跨午夜后 today 停在昨天');
assert.match(raw, /onVisibleChanged\([^)]*\): void \{[\s\S]{0,200}?refreshToday\(\)/,
'onVisibleChanged 里必须重算 today');
assert.match(raw, /refreshToday\(\)[\s\S]{0,60}isoOfLocal\(new Date\(\)\)/,
'today 只能由 isoOfLocal(new Date()) 求(toISOString 是 UTC 口径,清晨会给出昨天)');
// 首次可见才拉数据:常驻挂载 ≠ 常拉(隐藏着也发请求是给用户白花的流量/电)
assert.match(raw, /if \(!this\.loadedOnce\)/,
'首次可见才拉数据 —— 常驻挂载不等于启动就拉');
});
test('页面:翻月走 addMonths(跨年两个方向已被纯逻辑钉过),不自己算 month+1', () => {
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
assert.match(page, /addMonths\(this\.year, this\.month, delta\)/, '翻月必须复用 addMonths');
assert.ok(!/this\.month \+ 1|this\.month - 1/.test(page),
'页面里不许自己算 month±1 —— "翻到 13 月"就是那样来的(纯逻辑判据已覆盖 addMonths,别在页面重写一遍)');
});
test('★ 判据自检:把错误写法喂进来必须判红', () => {
// ① 自己切时间戳取日期键(UTC 口径 ⇒ 错一天)
assert.ok(/\.slice\(0, 10\)/.test("localIsoOf(e.event_time.slice(0, 10))"), '切时间戳能被"不许切"这条命中');
// ② 表头硬编码(与网格分叉)
assert.ok(!/weekdayLabels/.test("['日','一','二','三','四','五','六']"), '硬编码表头能被"必须来自 weekdayLabels"命中');
// ③ month±1 自算
assert.ok(/this\.month \+ 1/.test('const m = this.month + 1;'), '自算 month+1 能被命中');
// ④ 没有 @Watch(today 跨午夜停在昨天)
assert.ok(!/@Prop @Watch\('onVisibleChanged'\)/.test('@Prop visible: boolean = true;'), '缺 @Watch 能被命中');
});

View File

@ -219,7 +219,7 @@ test('页面把折叠逻辑真正接上了(不是"逻辑写好了没人用")
// ───────────────────────── 撤掉平级「会话」tab(P2a 收尾) ─────────────────────────
test('底部只剩 通信 / 联系人 两个平级页签,「会话」不再是入口', () => {
test('底部是 通信 / 日历 / 联系人 三个平级页签,「会话」不再是入口', () => {
/*
* pi 的判断:WebUI 里「会话」从来不是一个入口,它是**两处已有视图**(联系人页的卡片视图
* + 收件箱的会话折叠)。鸿蒙原来把它单列成 tab,等于把"卡片视图"放错了位置。
@ -238,7 +238,9 @@ test('底部只剩 通信 / 联系人 两个平级页签,「会话」不再是
assert.ok(navLabels > 0, '底栏要由 NAV_ITEMS 驱动');
const navSource = code(join(HARMONY_ETS, 'model/NavItems.ts'));
const labels = [...navSource.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
assert.deepEqual(labels, ['通信', '联系人'], `平级项应只剩两个,实际:${labels.join('、')}`);
// 2026-09-14:日历页(P6 第 1 步)落地后入口上架 ⇒ 三项。撤掉「会话」这条判断本身没变:
// 它不是第三个地方,而是"同一批数据的另一种看法"(收件箱折叠 + 联系人卡片视图)。
assert.deepEqual(labels, ['通信', '日历', '联系人'], `平级项应为 通信/日历/联系人,实际:${labels.join('、')}`);
assert.ok(!/struct\s+SessionsTab/.test(pageCode), 'SessionsTab 已经撤了,不该再留在页面里');
assert.ok(!/sessions\(\)/.test(pageCode), '撤了入口就不该再拉 /me/sessions(否则是没人看的请求)');
});
@ -512,7 +514,8 @@ test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 +
// P5:底栏标签现在来自 NAV_ITEMS(自绘浮动条),不是 TabBarBuilder 的参数
const navSource2 = code(join(HARMONY_ETS, 'model/NavItems.ts'));
const tabLabels = [...navSource2.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
assert.deepEqual(tabLabels, ['通信', '联系人'], `底部应只剩两项且第一项是通信,实际:${tabLabels.join('、')}`);
// 2026-09-14:日历页(P6 第 1 步)做完后入口上架 —— 三项且顺序固定(通信/日历/联系人)
assert.deepEqual(tabLabels, ['通信', '日历', '联系人'], `底部应为通信/日历/联系人三项,实际:${tabLabels.join('、')}`);
// 通信页现在还要收一个 `bgActive`(背景开着时让出页面底,否则壁纸全被盖住)——
// 所以这里钉的是"带参数地渲染通信页",不是光有个名字
assert.match(sendCode, /CommPage\(\{ bgActive: this\.bgActive \}\)/, '第一项要渲染通信页(并把背景开关传下去)');

View File

@ -85,8 +85,12 @@ test('① 点击:每个导航项点下去落到**它自己**那个 index(配
test('② 挂载:index 决定挂哪个页面(点第 2 项必须挂联系人)', () => {
/*
* 换了条之后最容易出的错是"点了没反应"——点击改了状态,但内容仍写死挂第一个页面。
* 所以把 index → 页面的映射钉死:0 → CommPage,否则 → ContactsTab,
* 且两边都要收到 `bgActive`(背景开着时让出页面底,否则壁纸被内容盖住)。
* 所以把 index → 页面的映射钉死:0 → CommPage、2 → ContactsTab、1 → CalendarPage,
* 且三边都要收到 `bgActive`(背景开着时让出页面底,否则壁纸被内容盖住)。
*
* 日历与另两个不同:它**常驻挂载**(不是 if/else 的一支),用 `visibility` 控制显示 ——
* 理由写在 `CalendarPage.ets` 与 build() 的注释里(today 跨午夜、保住"在看哪个月")。
* 所以这里断言的是**两件事**:索引→页面的配对,以及日历那一支确实是"常驻 + 可见性开关"。
*/
const stack = main.slice(main.indexOf('Stack({ alignContent: Alignment.Bottom })'));
// 只在**根 build()** 里数分派(页面内部也有 if/else,扫全文会数错)
@ -96,13 +100,32 @@ test('② 挂载:index 决定挂哪个页面(点第 2 项必须挂联系人
const first = braceBody(root, 'if (this.currentIndex === 0)');
const second = braceBody(root.slice(root.indexOf('if (this.currentIndex === 0)')), 'else');
assert.match(first, /CommPage\(\{ bgActive: this\.bgActive \}\)/, 'index 0 要挂通信页');
assert.match(second, /ContactsTab\(\{ bgActive: this\.bgActive \}\)/, 'index 1 要挂联系人页');
assert.match(second, /ContactsTab\(\{ bgActive: this\.bgActive \}\)/, 'index 2 要挂联系人页');
/*
* 分派必须只有这两支,且必须**穷尽** NAV_ITEMS —— 现在只有两项,用 if/else 就够了;
* 一旦 NAV_ITEMS 变长,这条会红并提醒改成按清单挂载(否则第 3 项点了没内容)。
* 联系人那一支必须**显式绑在 index 2**。注意条件在 `else if (...)` 里,
* 不在 `else` 的**花括号正文**里 —— braceBody 只取正文,所以这条要看 root 原文。
* 写成"否则就挂联系人"的后果:以后再加一项,新索引会被它静默接住(点了显示联系人)。
*/
assert.equal(N.NAV_ITEMS.length, 2,
`NAV_ITEMS 现在是 ${N.NAV_ITEMS.length} 项,而内容分派只有 if/else 两支 —— 加了项必须同时加分派`);
assert.match(root, /else if \(this\.currentIndex === 2\)/, '联系人必须显式绑在 index 2,不许用 else 兜底');
/*
* 日历(index 1):**常驻挂载 + visible 由索引驱动 + Visibility 开关**,三样缺一不可。
* · `visible: this.currentIndex === 1` 是 `@Watch` 的触发源 —— 不带它,today 就不会在
* pane 变可见时重算(DEBTS 的 calendar-today-recompute);
* · `visibility(...=== 1 ? Visible : None)` 是显示开关;常驻 + 不隐藏 = 三个 pane 叠在一起。
*/
assert.match(root, /CalendarPage\(\{ bgActive: this\.bgActive, visible: this\.currentIndex === 1 \}\)/,
'index 1 要挂日历页,并把"是否可见"传下去(它是 today 重算的触发源)');
assert.match(root, /\.visibility\(this\.currentIndex === 1 \? Visibility\.Visible : Visibility\.None\)/,
'常驻挂载就要用 visibility 控制显示');
assert.ok(!/if \(this\.currentIndex === 1\)/.test(root),
'日历不该写成 if/else 的一支:它是常驻 pane(卸载重挂会丢掉"在看哪个月",且 today 只能靠挂载重算)');
/*
* 分派必须**穷尽** NAV_ITEMS。2026-09-14 日历入口上架时这条**如约变红**
* (当时是 `length === 2` + 两支 if/else)—— 加项必须同时加分派,否则第 3 项点了没内容。
* 现在的形状与"三项"绑定:通信(0) / 日历(1) / 联系人(2),日历那一支**常驻挂载**。
*/
assert.equal(N.NAV_ITEMS.length, 3,
`NAV_ITEMS 现在是 ${N.NAV_ITEMS.length} 项,与分派(通信/日历/联系人)不匹配 —— 加了项必须同时加分派`);
// 内容挂在浮动条**下面**(先内容后条),否则条会被内容盖住、点不到
const navAt = stack.indexOf('this.NavBar()');
const contentAt = stack.indexOf('CommPage({ bgActive: this.bgActive })');
@ -180,13 +203,31 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),
assert.ok(N.NAV_CONTENT_RESERVE >= N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM,
`内容让位(${N.NAV_CONTENT_RESERVE})必须 ≥ 条高 + 离底留白(${N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM})`);
assert.match(main, /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/, '内容底部要让出这段高度');
// 让位要生效在**内容**上,不是条上(条自己 padding 不解决遮挡)
const contentPad = main.slice(main.indexOf('Column() {\n if (this.currentIndex === 0)'));
assert.match(contentPad.slice(0, 400), /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/,
'让位要加在挂载内容的那层上');
/*
* 让位要生效在**内容**上,不是条上(条自己 padding 不解决遮挡)。
*
* 这里原来是 `main.slice(定位).slice(0, 400)` 的**窗口式**断言 —— 2026-09-14 加日历
* 那一支时它红了:内容层里多了一个 pane,`.padding(...)` 落到了 400 字符之外。
* 判的是"同一件事",红的原因却只是"变长了",正是本仓库反复记的窗口式判据。
* 改成**按花括号配对取正文**:内容层那个 Column 的 `{...}` 里必须出现让位。
*/
const contentOpen = main.indexOf('Column() {\n if (this.currentIndex === 0)');
assert.ok(contentOpen > 0, '要能找到挂载内容的那层 Column');
// `.padding(...)` 是**链在 `}` 之后**的,所以不能只看 {} 里面 —— 从配对结束处往后取到 NavBar 之前
const braceAt = main.indexOf('{', contentOpen);
let depth = 0;
let end = -1;
for (let i = braceAt; i < main.length; i++) {
if (main[i] === '{') depth++;
else if (main[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
}
assert.ok(end > 0, '内容层的花括号要闭合');
const chain = main.slice(end, main.indexOf('this.NavBar()', end));
assert.match(chain, /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/,
'让位要加在挂载内容的那层上(链在那个 Column 的 } 之后)');
});
test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且平级项仍是两项', () => {
test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且平级项是 通信/日历/联系人', () => {
/*
* 反面:如果只是"加了自绘条但仍留着系统 bar",界面上会出现两条导航(且系统那条
* 依然贴边、依然占位),这正是这一期要消除的东西。所以断言根导航里**没有**
@ -194,7 +235,7 @@ test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且
* 注意:**通信页内部**的三栏仍然用系统 `Tabs`(那是页面内部页签,不是根导航)——
* 所以这条只在"根组件"的范围内断言,别扫全文。
*/
const root = main.slice(main.indexOf('build() {\n /*\n * 底部两项'));
const root = main.slice(main.indexOf('build() {\n /*\n * 底部三项'));
assert.ok(root.length > 300, '要能取到根 build() 的正文');
const navCode = stripComments(root);
assert.ok(!/\bTabs\(/.test(navCode), '根导航里不该再有系统 Tabs');
@ -203,8 +244,8 @@ test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且
// 反面之二:也不许把旧 builder 留着不用(留着就是死代码,下一个人会以为它还在生效)
assert.ok(!/TabBarBuilder/.test(stripComments(main)), 'TabBarBuilder 已被 NavBar 取代,不该留在文件里');
// 平级项仍是两项且顺序不变(信息架构这一期不动)
assert.deepEqual(N.NAV_ITEMS.map(i => i.label), ['通信', '联系人'], '平级项与顺序');
// 平级项:通信/日历/联系人(日历是 P6 第 1 步做完后上架的,顺序与 WebUI 一致)
assert.deepEqual(N.NAV_ITEMS.map(i => i.label), ['通信', '日历', '联系人'], '平级项与顺序');
assert.equal(new Set(N.NAV_ITEMS.map(i => i.key)).size, N.NAV_ITEMS.length, '键要唯一(ForEach 的 key)');
assert.deepEqual(N.NAV_ITEMS.map(i => N.navKeyAt(N.NAV_ITEMS.indexOf(i))), N.NAV_ITEMS.map(i => i.key),
'navKeyAt 与清单一致');
@ -212,6 +253,7 @@ test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且
assert.equal(N.normalizeNavIndex(-1), 0);
assert.equal(N.normalizeNavIndex(99), 0);
assert.equal(N.normalizeNavIndex(1), 1);
assert.equal(N.normalizeNavIndex(2), 2, '第三项(联系人)也必须归一到自己');
assert.throws(() => N.navKeyAt(1.5) === undefined, undefined, '非整数下标不该悄悄生效');
});

View File

@ -0,0 +1,32 @@
/*
* AgentMail 鸿蒙客户端 — 日历 API
* GET /calendar/events?from=&to=
*
* 与 WebUI 同源同参(`client/electron/src/api/client.ts` 的 listCalendarEvents):
* from/to 是 **RFC3339 区间**,服务端按 `event_time` 落在区间内筛。
*
* 区间必须按**网格**算,不是按月首月末算 —— 月视图首尾会显示上/下月的格子,
* 只查当月会让那几个格子永远空着(看着像"那天没事件",其实是没查)。
*/
import { ApiClient } from './ApiClient';
import { CalendarListResponse } from '../model/Models';
export class CalendarApi {
private client: ApiClient;
constructor(client: ApiClient) {
this.client = client;
}
/**
* 列区间内的事件。
*
* `from`/`to` 走 `encodeURIComponent`:RFC3339 里的 `+08:00` 若不编码,
* 查询串里的 `+` 会被服务端解成空格,`time.Parse` 直接失败 ⇒ 静默返回空列表
* (界面表现为"这个月没有日程",而实际是参数被吃掉了)。
*/
async listEvents(from: string, to: string): Promise<CalendarListResponse> {
const query: string = 'from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to);
return this.client.get<CalendarListResponse>('/calendar/events', query);
}
}

View File

@ -144,3 +144,41 @@ export function isoAtOffset(now: Date, offsetMinutes: number): string {
const shifted = new Date(now.getTime() + offsetMinutes * 60000);
return `${shifted.getUTCFullYear()}-${pad2(shifted.getUTCMonth() + 1)}-${pad2(shifted.getUTCDate())}`;
}
/**
* 把服务端的 RFC3339 时间戳归到**本地**那一天 —— 网格 `iso` 键的口径。
*
* 为什么不能 `.slice(0, 10)`:那是**时间戳的前 10 个字符**,也就是 UTC 日期。
* UTC+8 的 09-15 00:30 在 UTC 里是 09-14T16:30Z,切字符串会把事件画到**前一天的格子**里
* —— 而网格、表头、"今天"标记全都正常,是肉眼最难发现的一类错。
*
* 解析失败(服务端不会给,但别让一个坏值把整个月画崩)返回空串,
* 调用侧对空串一律当"没有日期"处理。
*/
export function localIsoOf(timestamp: string): string {
const ms: number = Date.parse(timestamp);
if (Number.isNaN(ms)) {
return '';
}
return isoOfLocal(new Date(ms));
}
/** 设备本地时区相对 UTC 的偏移(分钟,东为正)。页面用它把时间戳显示成本地钟点。 */
export function deviceOffsetMinutes(now: Date): number {
return -now.getTimezoneOffset();
}
/**
* 时间戳在**给定偏移**下的 `HH:MM`。
*
* 偏移做成入参而不是读进程时区:这样"UTC+8 / UTC-8 / UTC 三种下显示都对"这条
* 判据能**真跑**,不必依赖跑判据的机器正好在哪个时区(与 `isoAtOffset` 同一个理由)。
*/
export function hhmmAtOffset(timestamp: string, offsetMinutes: number): string {
const ms: number = Date.parse(timestamp);
if (Number.isNaN(ms)) {
return '';
}
const shifted = new Date(ms + offsetMinutes * 60000);
return `${pad2(shifted.getUTCHours())}:${pad2(shifted.getUTCMinutes())}`;
}

View File

@ -235,3 +235,34 @@ export class DecideResponse {
expires_at: string = '';
warning: string = '';
}
/**
* 日历事件(`GET /calendar/events` 的一项)。
*
* 字段名与类型**严格对齐服务端 JSON**(`server/internal/models/calendar.go` 的
* `CalendarEvent`)—— 鸿蒙侧模型层对齐 docs/API.md 是既定纪律,不是"照着 WebUI 抄"。
* 只声明这一版日历用得上的字段:写全了却没人读,等于给下一个人一份会过期的副本。
*
* `event_time` 是 RFC3339 时间戳(不是日期)—— 归到**本地哪一天**由
* `model/Calendar.ts` 的 `localIsoOf()` 决定,页面不自己切字符串。
*/
export class CalendarEvent {
event_id: string = '';
title: string = '';
description: string = '';
/** 提醒正文(触发时发出去的那封信的开头) */
reminder_text: string = '';
/** 完整三维地址串;空时服务端会退回 agent_name/to_address(本版不展示) */
recipients: string[] = [];
delivery_mode: string = 'separate'; // separate | together
event_time: string = '';
remind_before: number = 0; // 提前多少分钟
recurrence: string = 'none'; // none|daily|weekly|monthly|yearly|lunar_monthly|lunar_yearly
status: string = 'active'; // active | paused | cancelled
permission_mode: string = '';
}
/** `GET /calendar/events` 的响应 */
export class CalendarListResponse {
events: CalendarEvent[] = [];
}

View File

@ -19,14 +19,19 @@ export interface NavItem {
}
/**
* 平级导航项。**只有两项**:通信(内部三栏:收件箱/发件箱/授权)与联系人。
* 平级导航项。**三项**:通信(内部三栏:收件箱/发件箱/授权)、日历、联系人。
*
* 「会话」原先是个平级 tab,已撤 —— 它不是第三个地方,而是"同一批数据的另一种看法"
* (收件箱按会话折叠、联系人卡片视图就是会话的进度视角)。理由与时机见 `MainPage.build()` 的注释。
* 「日历」等 P6 的内容做完再上入口 —— 不留点进去空着的页签(§7.15)。
* (收件箱按会话折叠、联系人卡片视图就是会话的进度视角)。理由见 `MainPage.build()` 的注释。
*
* 「日历」原来写着"等 P6 内容做完再上入口 —— 不留点进去空着的页签"。P6 第一步
* (只读月视图:`pages/CalendarPage.ets`)已经做完,所以入口**现在**上:
* 空页签那条理由不再成立,而日历的纯逻辑(`model/Calendar.ts`)本来就是照着
* 「顶层是 通信/日历/联系人 三个平级 pane」写判据的。
*/
export const NAV_ITEMS: NavItem[] = [
{ key: 'comm', label: '通信', icon: '✉️' },
{ key: 'calendar', label: '日历', icon: '📅' },
{ key: 'contacts', label: '联系人', icon: '👤' }
];

View File

@ -0,0 +1,392 @@
/*
* AgentMail 鸿蒙客户端 — 日历(P6 第一步:只读月视图)
*
* # 这一版做了什么、没做什么(别把"没做的"读成"做了")
*
* 做了:月网格(可翻月、可回"今天")、点某一天看当天日程、事件点标记、
* 今天/选中两态、加载失败**说出来**。
* 没做:新建/编辑/删除事件(写侧)、农历重复、.ics 导入导出、左右滑动翻页(P6 第 3 步)。
* 滑动那条还牵着 `docs/DEBTS.json` 的 `gesture-semantics`("鸿蒙侧出现滑动手势代码时立即建判据")
* —— 没写手势就不该先建那条假判据,所以这一版用按钮翻月。
*
* # 为什么先做只读
*
* 日历真正会**悄悄错**的是"月份网格"这类算术(闰年、月首星期、跨年翻月、今天标哪格),
* 错了在界面上是"看起来正常但日子错位"。这一层已经由 `model/Calendar.ts` +
* `test/harmony-calendar.test.mjs` 钉住;页面只要**如实消费**它。
*
* # 为什么网格与事件必须用同一套日期口径
*
* 网格的格子键是 `iso`(`YYYY-MM-DD`,本地口径),事件来自服务端的 RFC3339 时间戳。
* 两者若各切各的字符串,UTC+8 的凌晨会把事件画到**前一天的格子**里,而所有判据都绿。
* 所以页面**只**用 `localIsoOf()` 把时间戳归一化,不自己 slice。
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { CalendarApi } from '../api/CalendarApi';
import { CalendarEvent } from '../model/Models';
import { Theme } from '../common/Theme';
import {
DayCell,
monthGrid,
monthLabel,
addMonths,
weekdayLabels,
isoOfLocal,
localIsoOf,
hhmmAtOffset,
deviceOffsetMinutes
} from '../model/Calendar';
/** 一格的高度(vp)。7 列等分 + 固定高度才画得成整齐的网格 */
const CELL_HEIGHT: number = 52;
/** 周表头高度(vp) */
const WEEKDAY_HEIGHT: number = 24;
/**
* 周起始:**1 = 周一**,与 WebUI 的 `startOfWeek()`(`lib/calendar.ts`,dow===0 时退到上周一)一致。
* 两端必须同值 —— 否则同一天在两端的格子位置不同,是跨端最容易被一眼看出来的差异。
*/
const WEEK_START: number = 1;
@Component
export struct CalendarPage {
/** 背景开着时要让出页面底(与其它 pane 同一约定) */
@Prop bgActive: boolean = false;
/**
* 这个 pane 是否可见。
*
* `@Watch` 不是装饰:`today` 是**唯一随时间变**的输入,而日历是常驻 pane
* (`docs/DEBTS.json` 的 `calendar-today-recompute`)。跨过午夜后不重算,
* 标记就停在昨天,而所有纯逻辑判据全绿(模型没错,是喂进去的值过期了)。
*/
@Prop @Watch('onVisibleChanged') visible: boolean = true;
@State year: number = 1970;
@State month: number = 1;
@State todayIso: string = '';
@State selectedIso: string = '';
@State events: CalendarEvent[] = [];
@State loading: boolean = false;
@State error: string = '';
private api: CalendarApi | null = null;
private offsetMinutes: number = 0;
/** 首次**可见**时才拉数据(常驻挂载 ≠ 常拉) */
private loadedOnce: boolean = false;
aboutToAppear(): void {
const now: Date = new Date();
this.offsetMinutes = deviceOffsetMinutes(now);
this.year = now.getFullYear();
this.month = now.getMonth() + 1;
this.refreshToday();
this.selectedIso = this.todayIso;
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
this.api = new CalendarApi(ApiClient.getInstance(ctx));
}
// 挂载时就可见(如直接落在日历)才立刻拉;隐藏着的不替用户发请求
if (this.visible) {
this.onVisibleChanged('visible');
}
}
/**
* pane 变可见:重算今天 + 首次拉数据。
*
* 今天必须在这里重算 —— 日历是常驻 pane,跨午夜后不重算,标记就停在昨天,
* 而所有纯逻辑判据仍然全绿(模型没错,是喂进去的值过期了)。
*/
onVisibleChanged(_propName: string): void {
if (!this.visible) {
return;
}
this.refreshToday();
if (!this.loadedOnce) {
this.loadedOnce = true;
this.loadEvents();
}
}
private refreshToday(): void {
this.todayIso = isoOfLocal(new Date());
}
/**
* 查询区间:**按网格的最坏情况**取(上月 1 日 → 下月 1 日)。
*
* 月视图首尾会显示邻月的格子,只查当月会让那几个格子永远空着 ——
* 那不是"那天没日程",是没查。(WebUI 的 `listCalendarEvents` 注释里同一句话。)
* `Date.UTC` 的月份溢出语义在这里是**有意使用**的:它是区间的算术,不是"哪一天"的键。
*/
private rangeFrom(): string {
return new Date(Date.UTC(this.year, this.month - 2, 1)).toISOString();
}
private rangeTo(): string {
return new Date(Date.UTC(this.year, this.month, 1)).toISOString();
}
async loadEvents(): Promise<void> {
const a: CalendarApi | null = this.api;
if (a === null) {
return;
}
this.loading = true;
this.error = '';
try {
const resp = await a.listEvents(this.rangeFrom(), this.rangeTo());
this.events = resp.events;
} catch (e) {
/*
* 失败必须**说出来**:静默吞掉会让"这个月没有日程"和"服务端没答上来"
* 长得一模一样(本仓库反复防的就是这种"看着正常")。
*/
this.error = e instanceof ApiError ? `日程加载失败(HTTP ${(e as ApiError).code})` : '日程加载失败';
this.events = [];
}
this.loading = false;
}
/** 翻月走 `addMonths`(纯逻辑判据钉过跨年两个方向),不自己算 month+1 */
private shiftMonth(delta: number): void {
const ym: number[] = addMonths(this.year, this.month, delta);
this.year = ym[0];
this.month = ym[1];
this.loadEvents();
}
private goToday(): void {
const now: Date = new Date();
this.refreshToday();
this.year = now.getFullYear();
this.month = now.getMonth() + 1;
this.selectedIso = this.todayIso;
this.loadEvents();
}
private rows(): DayCell[][] {
return monthGrid(this.year, this.month, WEEK_START, this.todayIso);
}
/** 这一天有几条日程(画事件点用)。数量很小,线性扫即可,不引入第二份索引。 */
private eventCountOf(iso: string): number {
if (iso.length === 0) {
return 0;
}
let n: number = 0;
for (let i = 0; i < this.events.length; i++) {
if (localIsoOf(this.events[i].event_time) === iso) {
n = n + 1;
}
}
return n;
}
/** 选中那天的日程,按时间升序 */
private selectedEvents(): CalendarEvent[] {
const out: CalendarEvent[] = [];
for (let i = 0; i < this.events.length; i++) {
const e: CalendarEvent = this.events[i];
if (localIsoOf(e.event_time) === this.selectedIso) {
out.push(e);
}
}
out.sort((a: CalendarEvent, b: CalendarEvent) => Date.parse(a.event_time) - Date.parse(b.event_time));
return out;
}
private cellFg(cell: DayCell): ResourceColor {
if (cell.iso === this.selectedIso) {
return Theme.accentFg;
}
if (cell.iso === this.todayIso) {
return Theme.accent;
}
if (!cell.inMonth) {
return Theme.textSubtle;
}
return Theme.textPrimary;
}
private statusLabel(status: string): string {
if (status === 'paused') {
return '已暂停';
}
if (status === 'cancelled') {
return '已取消';
}
return '';
}
private statusFg(status: string): ResourceColor {
return status === 'active' ? Theme.textSubtle : Theme.warnFg;
}
@Builder
DayCellView(cell: DayCell) {
Column() {
if (cell.label > 0) {
Text(`${cell.label}`)
.fontSize(Theme.fontBody)
.fontWeight(cell.iso === this.todayIso ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.cellFg(cell))
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 })
}
}
}
.layoutWeight(1)
.height(CELL_HEIGHT)
.justifyContent(FlexAlign.Center)
/* 选中态用**底色 + 文字反白**(日历里的"选中某天"与导航条的"选中某项"不是同一件事:
导航项上色即可,因为文字本身就在报位置;格子里只有一个数字,没有底色看不出选的是哪格) */
.backgroundColor(cell.iso === this.selectedIso ? Theme.accent : Color.Transparent)
.borderRadius(Theme.radiusControl)
.onClick(() => {
if (cell.label > 0) {
this.selectedIso = cell.iso;
}
})
}
@Builder
EventRow(e: CalendarEvent) {
Row() {
Text(hhmmAtOffset(e.event_time, this.offsetMinutes))
.fontSize(Theme.fontSmall)
.fontColor(Theme.textMuted)
.width(46)
Column() {
Text(e.title)
.fontSize(Theme.fontBody)
.fontColor(Theme.textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (e.reminder_text.length > 0) {
Text(e.reminder_text)
.fontSize(Theme.fontTiny)
.fontColor(Theme.textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
if (this.statusLabel(e.status).length > 0) {
Text(this.statusLabel(e.status))
.fontSize(Theme.fontTiny)
.fontColor(this.statusFg(e.status))
.margin({ left: 6 })
}
}
.width('100%')
.padding(10)
.backgroundColor(Theme.surfaceMuted)
.borderRadius(Theme.radiusControl)
}
build() {
Column() {
/* 头部:翻月 + 回今天 */
Row() {
Button('‹')
.fontSize(Theme.fontBody)
.backgroundColor(Color.Transparent)
.fontColor(Theme.textPrimary)
.onClick(() => this.shiftMonth(-1))
Text(monthLabel(this.year, this.month))
.fontSize(Theme.fontBody)
.fontColor(Theme.textPrimary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
Button('›')
.fontSize(Theme.fontBody)
.backgroundColor(Color.Transparent)
.fontColor(Theme.textPrimary)
.onClick(() => this.shiftMonth(1))
Button('今天')
.fontSize(Theme.fontSmall)
.backgroundColor(Theme.accentSoft)
.fontColor(Theme.accentStrong)
.onClick(() => this.goToday())
}
.width('100%')
.padding({ left: 6, right: 6, top: 4, bottom: 4 })
/* 周表头:顺序**必须**来自 weekdayLabels(WEEK_START),不许在这里硬编码 */
Row() {
ForEach(weekdayLabels(WEEK_START), (w: string) => {
Text(w)
.fontSize(Theme.fontTiny)
.fontColor(Theme.textMuted)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.height(WEEKDAY_HEIGHT)
}, (w: string) => `w${w}`)
}
.width('100%')
/* 月网格:行 ← monthGrid,列 ← 7 格(嵌套 ForEach 的项名必须不同) */
ForEach(this.rows(), (row: DayCell[], rowIndex: number) => {
Row() {
ForEach(row, (cell: DayCell) => {
this.DayCellView(cell)
}, (cell: DayCell) => `c${cell.iso}#${cell.label}`)
}
.width('100%')
}, (row: DayCell[], rowIndex: number) => `r${rowIndex}`)
if (this.error.length > 0) {
Text(this.error)
.fontSize(Theme.fontSmall)
.fontColor(Theme.danger)
.padding(8)
}
/* 选中那天的日程 */
Row() {
Text(this.selectedIso)
.fontSize(Theme.fontSmall)
.fontColor(Theme.textMuted)
Blank()
if (this.loading) {
Text('加载中…')
.fontSize(Theme.fontTiny)
.fontColor(Theme.textSubtle)
}
}
.width('100%')
.padding({ left: 10, right: 10, top: 8, bottom: 2 })
if (this.selectedEvents().length === 0) {
Column() {
Text('这一天没有日程')
.fontSize(Theme.fontSmall)
.fontColor(Theme.textSubtle)
}
.width('100%')
.padding(16)
} else {
List({ space: 8 }) {
ForEach(this.selectedEvents(), (e: CalendarEvent) => {
ListItem() {
this.EventRow(e)
}
}, (e: CalendarEvent) => e.event_id)
}
.layoutWeight(1)
.width('100%')
.padding({ left: 10, right: 10 })
}
}
.width('100%')
.height('100%')
.backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg)
}
}

View File

@ -1,9 +1,10 @@
/*
* AgentMail 鸿蒙客户端 — 主框架(底部 Tab 导航)
*
* 底部两项:**通信**(内部三栏:收件箱 / 发件箱 / 授权,见 `CommTabs.ts`)与 **联系人**。
* 平级的「会话」已并入这两处(见文件末尾的说明);「日历」等它的内容(P6)一起上,
* 不留一个点进去空着的入口。
* 底部三项:**通信**(内部三栏:收件箱 / 发件箱 / 授权,见 `CommTabs.ts`)、**日历**、**联系人**。
* 平级的「会话」已并入前两处(见文件末尾的说明)。
* 「日历」原先写着"等 P6 内容做完再上入口",P6 第一步(只读月视图,见 `pages/CalendarPage.ets`)
* 已完成,所以入口现在上;日历是整个客户端里**常驻挂载**的那一个 pane(见 build() 里的说明)。
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme';
@ -48,6 +49,7 @@ import {
emptyHint
} from '../model/CommTabs';
import { MailDetailParams, ComposeParams } from '../model/RouteParams';
import { CalendarPage } from './CalendarPage';
import {
NAV_BAR_BOTTOM,
NAV_BAR_HEIGHT,
@ -1756,14 +1758,15 @@ struct MainPage {
build() {
/*
* 底部两项:**通信** / 联系人。
* 底部三项:**通信** / 日历 / 联系人。
*
* 通信不再等于收件箱:它内部有三栏(收件箱 / 发件箱 / 授权 + 徽标),
* 见 `CommPage` 与 `model/CommTabs.ts`。这也是 WebUI 现在的信息架构
* (用户 2026-09-14:「收件发件授权改为一个导航项,通过内部导航区分」)。
*
* 日历**还没有**入口:它的内容(P6:网格 + 事件读写 + 左右滑动翻页)还没做,
* 先放一个点进去空着的入口不如等它一起上 —— 这条选择记在计划文档 §7.15。
* 日历**已有入口**(P6 第 1 步:只读月视图)。还没做的是写侧(新建/编辑/删除事件)、
* 农历重复、.ics 导入导出、左右滑动翻页(P6 第 3 步)—— 逐条写在 `CalendarPage.ets` 的文件头,
* 别把"没做的"读成"做了"。
*
* 「会话」原先是个平级 tab,现在**撤掉**了 —— 它不是第三个地方,
* 而是"同一批数据的另一种看法":收件箱那栏按会话折叠(组头就是会话),
@ -1786,9 +1789,26 @@ struct MainPage {
Column() {
if (this.currentIndex === 0) {
CommPage({ bgActive: this.bgActive })
} else {
} else if (this.currentIndex === 2) {
ContactsTab({ bgActive: this.bgActive })
}
/*
* 日历与另外两个 pane 不同:**常驻挂载**,用 `visibility` 控制显示。
*
* 理由两条,都不是美观问题:
* ① `today` 是日历里**唯一随时间变**的输入,而日历是"放着不动的 pane"。
* 卸载重挂会重算(`aboutToAppear`),但"一直开着跨过午夜"不会 ——
* 常驻 + `@Watch(visible)` 才能在它重新可见时重算
* (`docs/DEBTS.json` 的 `calendar-today-recompute`)。
* ② 常驻顺带保住"正在看哪个月",切页签回来不会被重置回本月。
* 常驻 ≠ 常拉:首次**可见**时才发请求(见 `CalendarPage.onVisibleChanged`)。
*/
Column() {
CalendarPage({ bgActive: this.bgActive, visible: this.currentIndex === 1 })
}
.width('100%')
.height('100%')
.visibility(this.currentIndex === 1 ? Visibility.Visible : Visibility.None)
}
.width('100%')
.height('100%')