跨端: 鸿蒙端功能同步第一步——日历(只读月视图)上架,入口进底部导航
用户:「要给鸿蒙端做功能同步」。按 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:
392
client/harmony/entry/src/main/ets/pages/CalendarPage.ets
Normal file
392
client/harmony/entry/src/main/ets/pages/CalendarPage.ets
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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%')
|
||||
|
||||
Reference in New Issue
Block a user