跨端: 鸿蒙日历补「月/周/日」三档 —— 原来只有月视图

用户列的缺失之一(WebUI `CalendarView.tsx` 的 `type Scale = 'month'|'week'|'day'`)。
鸿蒙这边 `CalendarPage.ets` 自己的注释里就写着"没做"。

## 三处必须一起改,所以档位进 model 而不是页面里一串 if

档位切换同时改变三件事,任一漏改都**不报错、只是静静地不对**:

| | 月 | 周 | 日 |
|---|---|---|---|
| 标题 | `2026年9月` | 跨月时两头写月份 `9.28 – 10.4` | `2026年9月18日 周五` |
| 翻页步长 | ±1 **月** | ±7 天 | ±1 天 |
| 显示格子 | 整月网格 | 一行 7 天 | 一行、只亮一格 |

所以 `model/Calendar.ts`(纯逻辑,node 直接跑)新增:
`CalendarScale` / `addDaysIso` / `weekDaysOf` / `rangeTitleOf` / `stepDaysOf` / `scaleLabel`。
页面只调用,不在渲染里重写 —— 重写就是"三处里漏改一处"。

★ 步长按档走是**必须**的:周档按 1 天翻看起来像日档、日档按 7 天翻会跳过一周,
  两者都不报错。判据钉"三档步长两两不同"。
★ 周/日档的 7 天必须与月网格用**同一个** `weekStart`:不一致的话同一日期在两档下列位置
  不同,用户看到的是"切个视图日期就跳位了"。
★ 日档的格子仍摆在一行的**星期列**里(不是居中大字):上下翻日时格子不会在屏幕上跳。

## 实现要点

- 档位状态叫 `calScale` 而**不是** `scale` —— ArkUI 的 `CustomComponent` 已有 `scale`
  修饰符,同名直接编译失败(实测报 `Property 'scale' ... is not assignable to ...`)。
  判据钉住这个命名。
- 周/日档下 `year/month` 由锚点 `selectedIso` 推出来(`syncYearMonthFrom`),
  不允许两处各自保存"当前是几月" —— 否则会出现"标题写 9 月、周视图显示 10 月那周"。
- `monthKey()` 加上**档位**前缀:切档时那 7 个 iso 就是月网格里的 7 个,
  不带档位的话键完全重叠 ⇒ 一个节点都不被替换 ⇒ 过渡静默不播、旧 `inMonth` 残留。
- 空态补 `layoutWeight(1)`:实测周视图下"这一天没有日程"贴顶、**下半屏是一大片空壁纸**。

## 判据(harmony-calendar 23 → 30 条)

7 条新增,每条对着一个具体错法。**变异自检跑过两个方向**:
- 周档步长改成 1(看起来像日档)⇒ 判红 ✓
- 跨月周标题漏掉结束月份(`9.28 – 4`)⇒ 判红 ✓

## 实测(模拟器,逐档截图)

月/周/日三档切换后:周档标题 `2026年9月14–20日` + 一行 14~20,日档标题
`2026年9月18日 周五` + 只亮 18,月档回到整月网格。三张截图逐一核过。
This commit is contained in:
2026-09-18 11:08:05 +08:00
parent af2d2b5cad
commit fbe7879981
4 changed files with 401 additions and 30 deletions

View File

@ -385,3 +385,95 @@ test('★ 判据自检:写侧的错误写法必须判红', () => {
assert.ok(!/put<CalendarEvent>/.test("post<CalendarEvent>('/calendar/events/' + id, input)"),
'编辑用 POST 能被"必须 PUT"那条命中');
});
/* ───────────────────────── 视图档:月 / 周 / 日 ───────────────────────── */
/*
* 2026-09-18:鸿蒙的日历原来**只有月视图**(`CalendarPage.ets` 的注释列过"没做"清单)。
* 对齐 WebUI `CalendarView.tsx` 的 `type Scale = 'month' | 'week' | 'day'`。
*
* 档位切错**不会报错**,只会静静地不对 —— 所以下面每一条都对着一个具体的错法。
*/
test('★ 翻页步长按档走:月=按天 0(按月翻)、周=7、日=1 —— 认错档位就是静默跳过/原地不动', () => {
assert.equal(C.stepDaysOf('month'), 0, '月档按**月**翻,不按天 —— 返回 0 表示走 addMonths');
assert.equal(C.stepDaysOf('week'), 7, '周档必须按 7 天翻(按 1 天翻看起来像日档)');
assert.equal(C.stepDaysOf('day'), 1, '日档按 1 天翻(按 7 天翻会跳过一周且毫无提示)');
// 自检:三个档的步长必须**互不相同**,否则"分档"本身没意义
const steps = C.SCALE_ORDER.map(s => C.stepDaysOf(s));
assert.equal(new Set(steps).size, 3, `三档步长必须两两不同,实际 ${steps.join('/')}`);
});
test('★ 周档跨月时标题两头都写月份(只写"9月28日-4日"看不出 4 日是十月)', () => {
// 2026-09-30 是周三 → 周一是 09-28,周日是 10-04 ⇒ 跨月
const t = C.rangeTitleOf('week', '2026-09-30', 1);
assert.match(t, /9\.28/, `跨月周标题要写左端月日,实际「${t}」`);
assert.match(t, /10\.4/, `跨月周标题要写右端月日(含月份),实际「${t}」`);
// 不跨月时反过来:不该出现"两个月份"
const same = C.rangeTitleOf('week', '2026-09-18', 1);
assert.ok(!same.includes('–') || (same.match(/月/g) ?? []).length === 1,
`同一月内的周不该写两个月份,实际「${same}」`);
});
test('★ 日档标题带星期;月档标题不带(三档各一种写法,别互相串)', () => {
const d = C.rangeTitleOf('day', '2026-09-18', 1);
assert.match(d, /^2026年9月18日 周./, `日档要「年月日 + 周X」,实际「${d}」`);
assert.equal(C.rangeTitleOf('month', '2026-09-18', 1), '2026年9月', '月档只有年月');
});
test('★ 周/日档的 7 天必须与月网格用**同一个** weekStart(否则同一日期两档列位置不同)', () => {
/*
* 这是"看起来只是排版不同"的那类错:月视图第一列是周一、周视图第一列是周日,
* 同一个日期在两档下会落在不同的列 —— 用户会觉得"切个视图日期就跳位了"。
* 两处都必须从**同一个** `weekStart` 参数出发。
*/
for (const ws of [0, 1]) {
const days = C.weekDaysOf('2026-09-18', ws);
assert.equal(days.length, 7, '一周必须恰好 7 天');
// 这 7 天的星期序列必须等于 weekdayLabels(ws) 的顺序
const names = C.weekdayLabels(ws);
const got = days.map(iso => {
const p = iso.split('-').map(Number);
return C.weekdayNameOf(p[0], p[1], p[2]);
});
assert.deepEqual(got, names, `weekStart=${ws} 时两处星期序必须一致`);
}
});
test('★ 周/日档日期推进:跨月、跨年、闰年都对(与月档同一个 Date.UTC 口径)', () => {
assert.equal(C.addDaysIso('2026-09-30', 1), '2026-10-01', '跨月');
assert.equal(C.addDaysIso('2026-01-01', -1), '2025-12-31', '跨年(往回)');
assert.equal(C.addDaysIso('2024-02-28', 1), '2024-02-29', '闰年二月');
assert.equal(C.addDaysIso('2025-02-28', 1), '2025-03-01', '平年二月');
assert.equal(C.addDaysIso('2026-09-18', 7), '2026-09-25', '周档一整步(前)');
assert.equal(C.addDaysIso('2026-09-18', -7), '2026-09-11', '周档一整步(后)');
// 非法输入不许猜一个日子
assert.deepEqual(C.weekDaysOf('not-a-date', 1), [], '坏日期键要给空数组,不能猜');
});
test('★ 页面:档位必须驱动「标题/步长/格子」三处,且三处都来自 Calendar.ts', () => {
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
/*
* 三处里任一处漏改 ⇒ "看起来正常但翻错量/标题不对"。
* 所以判据不只要求"有档位这个状态",而是要求**三个消费者**都在。
*/
assert.match(page, /calScale:\s*CalendarScale/, '要有档位状态(类型来自 Calendar.ts)');
assert.match(page, /stepDaysOf\(\s*this\.calScale\s*\)/, '翻页步长要按档走(不许在页面里写 if/else)');
assert.match(page, /rangeTitleOf\(\s*this\.calScale/, '标题要按档走');
assert.match(page, /this\.calScale === 'month'/, '格子形状要按档走(月=整月网格,其余=一行)');
/*
* ★ 档位状态名**不能**叫 `scale`:ArkUI 的 `CustomComponent` 已经有一个 `scale`
* 属性(通用修饰符 `scale(value: ScaleOptions)`),同名会编译失败
* (实测:`Property 'scale' ... is not assignable to ... '{ (value: ScaleOptions)... }'`)。
* 这条钉住它,免得后人图省事改回去。
*/
assert.ok(!/@State\s+scale\s*:/.test(page),
'档位状态不许叫 `scale` —— 与 ArkUI 内置的 scale 修饰符同名,编译不过');
});
test('★ 页面:周/日档下空态要占满剩余高度(否则下半屏是一大片空壁纸)', () => {
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
const empty = /这一天没有日程[\s\S]{0,200}?layoutWeight\(1\)/.exec(page);
assert.ok(empty,
'空态必须 layoutWeight(1):实测 09-18 周视图下"这一天没有日程"贴顶、下半屏全空');
});

View File

@ -102,7 +102,7 @@ const SUITE = [
// 明文只对公网告警 / 404 说清“少了 /api/v1” / 网络错误码分类 / 归一化只有一份实现。
// 值判据跑真逻辑(model/ApiBase.ts);`.ets` 那几条是**静态**接线判据(无设备)。
['test/harmony-apibase.test.mjs', ['--experimental-strip-types', '--no-warnings'], 13],
['test/harmony-calendar.test.mjs', ['--experimental-strip-types', '--no-warnings'], 23],
['test/harmony-calendar.test.mjs', ['--experimental-strip-types', '--no-warnings'], 30],
['test/debt-visibility.test.mjs', [], 1],
['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 4],
// 判据目录自身的卫生:读文本必须走 test/lib/read.mjs 的具名入口

View File

@ -206,3 +206,117 @@ export function localDateOf(timestamp: string): Date {
}
return new Date(ms);
}
/*
* ── 视图档(月 / 周 / 日)──
*
* 对齐 WebUI `CalendarView.tsx` 的 `type Scale = 'month' | 'week' | 'day'`。
* 鸿蒙这边原来**只有月视图**(`CalendarPage.ets:8` 的注释列过"没做"的清单)。
*
* 档位切换会同时改变三件事,所以它必须是**数据类型**而不是页面上的一串 if:
* ① 标题怎么写(月:`2026年9月`;周:跨月时两头都写月份;日:带星期)
* ② 前后翻动的**步长**(月:±1 月;周:±7 天;日:±1 天)
* ③ 要显示哪些格子(月:整月网格;周:一行 7 天;日:单天)
* 这三处任一漏改都会"看起来正常但翻错量",所以三处都由这里同一组函数算。
*/
/** 视图档。与 WebUI 的 `Scale` 同值(字符串也一致,便于跨端对照)。 */
export type CalendarScale = 'month' | 'week' | 'day';
/** 档位的中文短名(给切换按钮)。顺序与 WebUI 的 `['month','week','day']` 一致。 */
export const SCALE_ORDER: CalendarScale[] = ['month', 'week', 'day'];
export function scaleLabel(scale: CalendarScale): string {
if (scale === 'month') {
return '月';
}
return scale === 'week' ? '周' : '日';
}
/**
* 把 `YYYY-MM-DD` 位移若干天。
*
* 用 `Date.UTC` 构造再位移:与 `firstWeekday` 同一口径(UTC 取数、不受设备时区影响)。
* 日期键是"本地年月日"的**字面量**,不承载瞬间语义 —— 见文件顶部关于
* "日期键 vs 时间戳"的那条说明。
*/
export function addDaysIso(iso: string, delta: number): string {
const parts: number[] = iso.split('-').map((s: string) => parseInt(s, 10));
const d: Date = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2]));
d.setUTCDate(d.getUTCDate() + delta);
const y: number = d.getUTCFullYear();
const m: number = d.getUTCMonth() + 1;
const dd: number = d.getUTCDate();
return isoOf(y, m, dd);
}
/**
* 某一天所在周的 7 天(按 `weekStart` 起始),返回 `YYYY-MM-DD` 数组。
*
* ★ 必须与月网格用**同一个** `weekStart` —— 两处不一致的话,月视图第一列是周日、
* 周视图第一列是周一,同一个日期在两个档位下的列位置不同,看起来像错位。
* 判据钉"两处用同一个常量"。
*
* 返回空数组表示入参不是合法日期键(调用方据此跳过,不猜一个日子)。
*/
export function weekDaysOf(iso: string, weekStart: number): string[] {
const parts: number[] = iso.split('-').map((s: string) => parseInt(s, 10));
if (parts.length !== 3 || parts.some((n: number) => Number.isNaN(n))) {
return [];
}
const d: Date = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2]));
const w: number = d.getUTCDay();
const back: number = (w - weekStart + 7) % 7;
const out: string[] = [];
for (let i = 0; i < 7; i++) {
out.push(addDaysIso(iso, i - back));
}
return out;
}
/**
* 标题文案 —— 三档各一种写法(与 WebUI `CalendarView.tsx:242-258` 同口径)。
*
* ★ 周视图**跨月时两头都写月**(`9.28 – 10.4`):
* 只写 `9月28日 - 4日` 的话,"4日"看不出是十月 —— 这是 WebUI 注释里明说的坑。
* ★ 日视图带星期:单天视图上"今天是周几"比"几号"更常用。
*/
export function rangeTitleOf(scale: CalendarScale, iso: string, weekStart: number): string {
const parts: number[] = iso.split('-').map((s: string) => parseInt(s, 10));
if (parts.length !== 3 || parts.some((n: number) => Number.isNaN(n))) {
return '';
}
const year: number = parts[0];
const month: number = parts[1];
const day: number = parts[2];
if (scale === 'month') {
return `${year}年${month}月`;
}
if (scale === 'week') {
const days: string[] = weekDaysOf(iso, weekStart);
if (days.length === 0) {
return '';
}
const a: number[] = days[0].split('-').map((s: string) => parseInt(s, 10));
const b: number[] = days[6].split('-').map((s: string) => parseInt(s, 10));
if (a[1] === b[1]) {
return `${a[0]}年${a[1]}月${a[2]}–${b[2]}日`;
}
return `${a[1]}.${a[2]} – ${b[1]}.${b[2]}`;
}
/* 日视图:星期名走 weekdayNameOf(与月网格同一处算法,不另写一份) */
return `${year}年${month}月${day}日 周${weekdayNameOf(year, month, day)}`;
}
/**
* 前后翻动的步长(天)—— 月档返回 0,因为月档按**月**翻而不是按天。
*
* 单独抽出来是防"档位改了但步长没改":日档按 7 天翻会跳过一周,
* 周档按 1 天翻看起来像日档 —— 两者都不会报错,只会静静地不对。
*/
export function stepDaysOf(scale: CalendarScale): number {
if (scale === 'week') {
return 7;
}
return scale === 'day' ? 1 : 0;
}

View File

@ -30,7 +30,15 @@ import {
monthGrid,
monthLabel,
addMonths,
addDaysIso,
weekDaysOf,
rangeTitleOf,
stepDaysOf,
scaleLabel,
SCALE_ORDER,
CalendarScale,
weekdayLabels,
weekdayNameOf,
isoOfLocal,
localIsoOf,
hhmmAtOffset,
@ -89,6 +97,14 @@ export struct CalendarPage {
@State month: number = 1;
@State todayIso: string = '';
@State selectedIso: string = '';
/**
* 视图档:`month` | `week` | `day`(对齐 WebUI `type Scale`)。
*
* 它同时决定三件事:标题怎么写、前后翻多大一步、要显示哪些格子 ——
* 三处算法都在 `model/Calendar.ts` 里(那里有单测判据),页面只负责调用,
* 不在渲染里重写一遍(重写就是"三处里漏改一处"那种静默错位)。
*/
@State calScale: CalendarScale = 'month';
@State events: CalendarEvent[] = [];
@State loading: boolean = false;
@State error: string = '';
@ -199,30 +215,84 @@ export struct CalendarPage {
this.loading = false;
}
/** 翻月走 `addMonths`(纯逻辑判据钉过跨年两个方向),不自己算 month+1 */
private shiftMonth(delta: number): void {
/*
* 翻月要有**方向感的横向滑入**(对齐 WebUI `@keyframes cal-in-next/prev`)。
*
* WebUI 那边靠 `cal-slide-next` / `cal-slide-prev` 两个类 + 200ms 动画;
* ArkUI 这里靠 `animateTo` 开窗口 + 格子挂 `calendarSlide(forward)` 过渡。
*
* ★ 两个都**不能少**(实测过:只包 animateTo 与硬切看不出区别)——
* 与 `switchIndex` 里那条注释是同一回事。
*/
/**
* 前后翻动 —— **步长取决于视图档**(月:±1 月;周:±7 天;日:±1 天)。
*
* 步长去 `stepDaysOf(scale)` 要,不在这里写 if/else:
* 档位与步长分开写的话,"加了日档但忘了改步长"会静静地按一天翻(看着像周档)。
*
* ★ 翻页要有**方向感的横向滑入**(对齐 WebUI `@keyframes cal-in-next/prev`)。
* WebUI 靠 `cal-slide-next/prev` 两个类 + 200ms 动画;这里靠 `animateTo` 开窗口
* + 格子挂 `calendarSlide(forward)` 过渡。两个都**不能少**
* (实测过:只包 animateTo 与硬切看不出区别)。
*/
private shiftRange(delta: number): void {
const forward: boolean = delta > 0;
const step: number = stepDaysOf(this.calScale);
this.getUIContext().animateTo({
duration: Theme.durCal,
curve: Theme.easeRise
}, () => {
this.slideForward = forward;
const ym: number[] = addMonths(this.year, this.month, delta);
this.year = ym[0];
this.month = ym[1];
if (step === 0) {
const ym: number[] = addMonths(this.year, this.month, delta);
this.year = ym[0];
this.month = ym[1];
} else {
/* 锚点位移后再把年月同步过去(锚点是唯一真相,年月是它的投影) */
this.selectedIso = addDaysIso(this.selectedIso, delta * step);
this.syncYearMonthFrom(this.selectedIso);
}
});
this.loadEvents();
}
/**
* 把 `year/month` 对齐到某个日期键 —— 周/日档翻动后调用。
*
* 不允许两处各自保存"当前是几月":锚点一变而年月没跟上,
* 就会出现"标题写着 9 月而周视图显示 10 月那一周"。
*/
private syncYearMonthFrom(iso: string): void {
const parts: number[] = iso.split('-').map((s: string) => parseInt(s, 10));
if (parts.length === 3 && !parts.some((n: number) => Number.isNaN(n))) {
this.year = parts[0];
this.month = parts[1];
}
}
/** 切视图档 —— 同时把标题/步长/格子一起换(三处都由 scale 驱动) */
private setScale(next: CalendarScale): void {
if (next === this.calScale) {
return;
}
this.getUIContext().animateTo({
duration: Theme.durCal,
curve: Theme.easeRise
}, () => {
this.calScale = next;
/* 切档时锚点不变(用户看的是同一个日子),但年月要跟着锚点对齐 */
this.syncYearMonthFrom(this.selectedIso);
});
}
/** 标题文案 —— 按月/周/日三种写法(算法在 model/Calendar.ts,有单测) */
private rangeTitle(): string {
if (this.calScale === 'month') {
return monthLabel(this.year, this.month);
}
return rangeTitleOf(this.calScale, this.selectedIso, WEEK_START);
}
/**
* 翻月(保留旧名 —— 别处还有调用)。
*
* 现在统一走 `shiftRange`:月档的步长就是 ±1 月,行为与旧实现一致。
*/
private shiftMonth(delta: number): void {
this.shiftRange(delta);
}
private goToday(): void {
const now: Date = new Date();
this.refreshToday();
@ -232,8 +302,62 @@ export struct CalendarPage {
this.loadEvents();
}
/**
* 要显示哪些格子 —— **由视图档决定**(月:整月网格;周:一行 7 天;日:单天)。
*
* ★ 三档都返回**同一个形状**(`DayCell[][]`,每行 7 格):
* 周/日档只是行数少(周 1 行、日 1 行但只亮一格),渲染代码不需要按档分支。
* 换成"三档各自一种渲染"的话,改一处样式要改三遍,迟早漏一个。
*
* ★ 月档的日期范围由 `year/month` 定,周/日档由 `selectedIso`(锚点)定 ——
* 这两者靠 `syncYearMonthFrom()` 保持同步(见 `shiftRange`)。
*/
private rows(): DayCell[][] {
return monthGrid(this.year, this.month, WEEK_START, this.todayIso);
if (this.calScale === 'month') {
return monthGrid(this.year, this.month, WEEK_START, this.todayIso);
}
if (this.calScale === 'week') {
const days: string[] = weekDaysOf(this.selectedIso, WEEK_START);
if (days.length === 0) {
return [];
}
return [days.map((iso: string) => this.cellOf(iso))];
}
/* 日档:只要一天。仍然摆在一行里(列位置由本周的星期决定),
这样「上下翻日」时格子**不会在屏幕上跳来跳去** —— 它始终在星期列里。 */
const week: string[] = weekDaysOf(this.selectedIso, WEEK_START);
if (week.length === 0) {
return [];
}
const only: DayCell[] = [];
for (let i = 0; i < week.length; i++) {
const cell: DayCell = this.cellOf(week[i]);
/* 单天视图:不是那天的格子标成"不在本月"(渲染上就是淡出),不画数字 */
if (week[i] !== this.selectedIso) {
cell.inMonth = false;
cell.label = 0;
}
only.push(cell);
}
return [only];
}
/**
* 把一个日期键包成一个 `DayCell`(周/日档用)。
*
* ★ `inMonth` 在周/日档里表示"属于**本周**"(而不是"属于本月"):
* 渲染层用它决定是不是淡出,语义是"这是当前视野里的日子吗"。
* 叫 `inMonth` 是沿用现有字段名(改名会动到渲染与判据)。
*/
private cellOf(iso: string): DayCell {
const cell: DayCell = new DayCell();
const parts: number[] = iso.split('-').map((s: string) => parseInt(s, 10));
cell.day = parts.length === 3 ? parts[2] : 0;
cell.label = cell.day;
cell.inMonth = true;
cell.isToday = iso === this.todayIso;
cell.iso = iso;
return cell;
}
/**
@ -246,16 +370,16 @@ export struct CalendarPage {
@State slideForward: boolean = true;
/**
* 换一个"格子内容"的身份 —— `ForEach` 的键挂上它,翻月时才会重放入场过渡。
* 换一个"格子内容"的身份 —— `ForEach` 的键挂上它,翻页时才会重放入场过渡。
*
* ★ 为什么需要这个:ArkUI 的过渡只在**节点被替换**时播。`rows()` 直接由
* `this.year/month` 算出来,键是 `c${cell.iso}#${cell.label}` —— 翻月后
* 日期确实变了(键也会变),所以过渡会播。但同一个键在"跨年同月同名"等
* 边角下可能不变,那时动画会静默不播。带上 `ym` 前缀把这个可能性去掉:
* 月份一变,所有键必然全变。
* ★ 为什么需要这个:ArkUI 的过渡只在**节点被替换**时播。带上 `月份 + 档位` 前缀:
* ① 翻月 —— 日期变了键本来就变,但"跨年同名月"那类边角可能不变;
* ② 切档 —— 从整月网格变成一行 7 天,**格子的键可能完全重叠**
* (周档的那 7 个 iso 就是月档里的 7 个),不带档位前缀的话
* 切档时一个节点都不会被替换 ⇒ 动画静默不播、且旧格子的 `inMonth` 残留。
*/
private monthKey(): string {
return `${this.year}-${this.month}`;
return `${this.calScale}-${this.year}-${this.month}-${this.selectedIso}`;
}
/** 这一天有几条日程(画事件点用)。数量很小,线性扫即可,不引入第二份索引。 */
@ -743,37 +867,71 @@ export struct CalendarPage {
build() {
Stack() {
Column() {
/* 头部:翻月 + 回今天 */
/*
* 头部:翻页 + 标题 + 视图档 + 新建。
*
* 对齐 WebUI `CalendarView.tsx:293-370` 那条工具条的分组与顺序:
* ‹ › 今天 | 标题 | 月/周/日 | 新建
* WebUI 还有导入/导出 .ics 两个图标,鸿蒙这边暂未做(那两个要文件选择器 + .ics 解析,
* 与"月份/星期"这类算术不同,属于一个独立的工作量)。
* 这里是**窄屏**,四段挤一行会溢出 —— 拆成两行:
* 上行 = 翻页 + 标题;下行 = 今天 + 视图档 + 新建。
*/
Row() {
Button('‹')
.fontSize(Theme.fontBody)
.backgroundColor(Color.Transparent)
.fontColor(Theme.textPrimary)
.onClick(() => this.shiftMonth(-1))
Text(monthLabel(this.year, this.month))
Text(this.rangeTitle())
.fontSize(Theme.fontBody)
.fontColor(Theme.textPrimary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Button('›')
.fontSize(Theme.fontBody)
.backgroundColor(Color.Transparent)
.fontColor(Theme.textPrimary)
.onClick(() => this.shiftMonth(1))
}
.width('100%')
.padding({ left: 6, right: 6, top: 4, bottom: 0 })
Row() {
Button('今天')
.fontSize(Theme.fontSmall)
.backgroundColor(Theme.accentSoft)
.fontColor(Theme.accentStrong)
.onClick(() => this.goToday())
Blank()
/*
* 视图档:**分段式**三个按钮(对齐 WebUI 的 `flex rounded border` 那一组)。
* 选中态只换**底色与文字色**(与底栏导航同纪律:不引入额外的形状/指示条)。
*/
ForEach(SCALE_ORDER, (s: CalendarScale) => {
Text(scaleLabel(s))
.fontSize(Theme.fontSmall)
.fontColor(this.calScale === s ? Theme.accentFg : Theme.textPrimary)
.backgroundColor(this.calScale === s ? Theme.accent : Theme.accentSoft)
.textAlign(TextAlign.Center)
.width(34)
.height(26)
.borderRadius(6)
.margin({ left: 4 })
.onClick(() => this.setScale(s))
}, (s: CalendarScale) => `scale${s}`)
Button('+')
.fontSize(Theme.fontBody)
.backgroundColor(Theme.accentSoft)
.fontColor(Theme.accentStrong)
.margin({ left: 6 })
.backgroundColor(Theme.accent)
.fontColor(Theme.accentFg)
.height(26)
.margin({ left: 8 })
.onClick(() => this.openNew())
}
.width('100%')
.padding({ left: 6, right: 6, top: 4, bottom: 4 })
.padding({ left: 10, right: 10, top: 6, bottom: 4 })
/* 周表头:顺序**必须**来自 weekdayLabels(WEEK_START),不许在这里硬编码 */
Row() {
@ -832,13 +990,20 @@ export struct CalendarPage {
.padding({ left: 10, right: 10, top: 8, bottom: 2 })
if (this.selectedEvents().length === 0) {
/*
* 空态也要 `layoutWeight(1)` —— 原来只有 `padding(16)`,
* 于是周/日档下网格只有一行,"这一天没有日程"贴在上面、
* 下面留出一大片空白壁纸(实测 09-18:周视图整个下半屏是空的)。
* 占满剩余高度后空态居中,三种档位的观感才是同一个版式。
*/
Column() {
Text('这一天没有日程')
.fontSize(Theme.fontSmall)
.fontColor(Theme.textSubtle)
}
.width('100%')
.padding(16)
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else {
List({ space: 8 }) {
ForEach(this.selectedEvents(), (e: CalendarEvent) => {