From 94ba4b9c58d9fdd332f74edfa302a2d58bf08f65 Mon Sep 17 00:00:00 2001 From: pi Date: Mon, 14 Sep 2026 18:38:51 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E9=B8=BF=E8=92=99?= =?UTF-8?q?=E7=AB=AF=E5=8A=9F=E8=83=BD=E5=90=8C=E6=AD=A5=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E6=AD=A5=E2=80=94=E2=80=94=E6=97=A5=E5=8E=86=EF=BC=88=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E6=9C=88=E8=A7=86=E5=9B=BE=EF=BC=89=E4=B8=8A=E6=9E=B6?= =?UTF-8?q?=EF=BC=8C=E5=85=A5=E5=8F=A3=E8=BF=9B=E5=BA=95=E9=83=A8=E5=AF=BC?= =?UTF-8?q?=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户:「要给鸿蒙端做功能同步」。按 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)。 --- .../electron/test/harmony-calendar.test.mjs | 85 +++- client/electron/test/harmony-logic.test.mjs | 9 +- client/electron/test/harmony-nav.test.mjs | 72 +++- .../entry/src/main/ets/api/CalendarApi.ets | 32 ++ .../entry/src/main/ets/model/Calendar.ts | 38 ++ .../entry/src/main/ets/model/Models.ets | 31 ++ .../entry/src/main/ets/model/NavItems.ts | 11 +- .../entry/src/main/ets/pages/CalendarPage.ets | 392 ++++++++++++++++++ .../entry/src/main/ets/pages/MainPage.ets | 34 +- docs/DEBTS.json | 10 +- 10 files changed, 677 insertions(+), 37 deletions(-) create mode 100644 client/harmony/entry/src/main/ets/api/CalendarApi.ets create mode 100644 client/harmony/entry/src/main/ets/pages/CalendarPage.ets diff --git a/client/electron/test/harmony-calendar.test.mjs b/client/electron/test/harmony-calendar.test.mjs index d19578a..9c72b96 100644 --- a/client/electron/test/harmony-calendar.test.mjs +++ b/client/electron/test/harmony-calendar.test.mjs @@ -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 能被命中'); +}); diff --git a/client/electron/test/harmony-logic.test.mjs b/client/electron/test/harmony-logic.test.mjs index 07f884d..0830a08 100644 --- a/client/electron/test/harmony-logic.test.mjs +++ b/client/electron/test/harmony-logic.test.mjs @@ -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 \}\)/, '第一项要渲染通信页(并把背景开关传下去)'); diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index a279f7d..ccc2658 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -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, '非整数下标不该悄悄生效'); }); diff --git a/client/harmony/entry/src/main/ets/api/CalendarApi.ets b/client/harmony/entry/src/main/ets/api/CalendarApi.ets new file mode 100644 index 0000000..a6506b2 --- /dev/null +++ b/client/harmony/entry/src/main/ets/api/CalendarApi.ets @@ -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 { + const query: string = 'from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to); + return this.client.get('/calendar/events', query); + } +} diff --git a/client/harmony/entry/src/main/ets/model/Calendar.ts b/client/harmony/entry/src/main/ets/model/Calendar.ts index 4b01fb2..8ecffac 100644 --- a/client/harmony/entry/src/main/ets/model/Calendar.ts +++ b/client/harmony/entry/src/main/ets/model/Calendar.ts @@ -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())}`; +} diff --git a/client/harmony/entry/src/main/ets/model/Models.ets b/client/harmony/entry/src/main/ets/model/Models.ets index 658be2e..d06af07 100644 --- a/client/harmony/entry/src/main/ets/model/Models.ets +++ b/client/harmony/entry/src/main/ets/model/Models.ets @@ -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[] = []; +} diff --git a/client/harmony/entry/src/main/ets/model/NavItems.ts b/client/harmony/entry/src/main/ets/model/NavItems.ts index dc4b157..dc19877 100644 --- a/client/harmony/entry/src/main/ets/model/NavItems.ts +++ b/client/harmony/entry/src/main/ets/model/NavItems.ts @@ -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: '👤' } ]; diff --git a/client/harmony/entry/src/main/ets/pages/CalendarPage.ets b/client/harmony/entry/src/main/ets/pages/CalendarPage.ets new file mode 100644 index 0000000..1d8a256 --- /dev/null +++ b/client/harmony/entry/src/main/ets/pages/CalendarPage.ets @@ -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 { + 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) + } +} diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index ad18722..42288eb 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -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%') diff --git a/docs/DEBTS.json b/docs/DEBTS.json index 2730097..062d801 100644 --- a/docs/DEBTS.json +++ b/docs/DEBTS.json @@ -4,7 +4,8 @@ "为什么要一个文件:三笔原先各自表达(RESULT static=5 / t.Skip / 登记在文档里的到期前提),", "没有一处能看全 —— 而『欠账不显形,就等于没有』;分散在多处的登记,审计时只会被找到一处就当全部。", "两端都读这个文件:Go 侧判据断言自己的条目与**实测**一致(不许留一份手写的数字),", - "electron 套件把它打进 RESULT 行(那是常态可见的位置)。" + "electron 套件把它打进 RESULT 行(那是常态可见的位置)。", + "已结算(2026-09-14):calendar-today-recompute —— P6 第 1 步(日历页 pages/CalendarPage.ets)落地,today 走 `@Prop @Watch('onVisibleChanged') visible` 在 pane 变可见时重算(另有 aboutToAppear 覆盖重新挂载),判据 test/harmony-calendar.test.mjs 的「★ today 在 pane **变可见时**重算」。结算即从此清单移除,余额里不再计这一笔。" ], "debts": [ { @@ -70,13 +71,6 @@ "where": "client/electron/test/debt-visibility.test.mjs(词表键控的盲区:**已知未覆盖**——词表是采样、不是完备)", "kind": "env" }, - { - "id": "calendar-today-recompute", - "count": 1, - "kind": "scope", - "due": "**日历页面骨架写完后立刻**:`today` 必须在 pane **变为可见时**重算(Tabs 切换回调 / 页面可见回调,API 以 ArkTS 规范为准),**不许**只在 aboutToAppear 取一次。判据形式:同一页面实例推进一天后重新求值,`today` 必须跟着变", - "where": "client/harmony/entry/src/main/ets/model/Calendar.ts 的纯逻辑判据(test/harmony-calendar.test.mjs)只钉到「喂进去的值对」;**喂进去的值会不会过期是调用侧的事**" - }, { "id": "radius-card-numeric-divergence", "count": 1,