跨端: 鸿蒙日历写侧(新建/编辑/删除/暂停恢复)+ 表单不再是一面滚轮墙

用户:「是的去吧」(批准上一封列出的下一步)。

- CalendarApi:createEvent / updateEvent / deleteEvent(POST /calendar/events、
  PUT/DELETE /calendar/events/{id});新建与编辑发**同一份** CalendarEventInput
  —— 服务端两个端点同形(CreateCalendarEvent 里那个 Status 字段的注释就写着这句),
  拆两份会在严格解码下 400
- CalendarPage:写侧表单(新建与编辑共用一份)、二次确认删除、暂停/恢复走 update
  (调度器只触发 active;没有暂停就只能删掉重加,而那会丢 event_id 与历史)
- 服务端 400 文案原样显示(收件地址无法解析:…)—— 建事件时就校验地址,
  吞成保存失败等于让人猜,而猜的代价是以为设好了、实际永远发不出去
- 空标题/空收件人在本地就挡住,不发注定 400 的请求
- 提交的是**时间戳**(isoTimestampOfLocal),不是日期键;编辑回填走 localDateOf
- ★ 表单形状:日期/时间选择器合起来 ~1400px,展开着放会把标题与收件人挤出屏幕 ——
  改成一行摘要 + 点开展开(默认收起)。这不是美观问题:展开态下保存在屏幕外,
  要跨过一整面滚轮才够得着
- 判据 +7(端点/载荷/前置校验/文案/二次确认/时间戳往返/变异自检),23 条全绿
- 真机实测(hvigorw + hdc + uitest 逐字段输入 + 逐键点击,每一步都回库核对):
  建 → 库里 event_time 是 2026-09-28T01:00Z(= 本地 09:00 整,默认值正确)、
  暂停 → status=paused、删除第一下只变确认删除(库里还在)、第二下才真删(库里 0 条)
This commit is contained in:
pi
2026-09-14 18:57:53 +08:00
parent 94ba4b9c58
commit d3f6f3baf6
5 changed files with 659 additions and 78 deletions

View File

@ -293,3 +293,86 @@ test('★ 判据自检:把错误写法喂进来必须判红', () => {
// ④ 没有 @Watch(today 跨午夜停在昨天)
assert.ok(!/@Prop @Watch\('onVisibleChanged'\)/.test('@Prop visible: boolean = true;'), '缺 @Watch 能被命中');
});
/*
* ═══════════════════════════════════════════════════════════════════
* P6 第 2 步:写侧(新建 / 编辑 / 删除 / 暂停恢复)
*
* 这一层最容易"看起来能用但实际没写进去":表单填了、按钮点了、界面回到日历 ——
* 而请求发错端点、字段名写错(严格解码直接 400)、或者时间戳拿日期键顶替
* (事件被存到另一天的 00:00)。所以判据钉的是**端点与载荷**,不是"有个保存按钮"。
*/
test('写侧:三个端点各自的路径与动词(GET 之外)', () => {
const api = code(join(ETS, 'api', 'CalendarApi.ets'));
assert.match(api, /post<CalendarEvent>\('\/calendar\/events', input\)/, '新建:POST /calendar/events');
assert.match(api, /put<CalendarEvent>\('\/calendar\/events\/' \+ encodeURIComponent\(id\), input\)/,
'编辑:PUT /calendar/events/{id}(id 要编码)');
assert.match(api, /del<CalendarDeleteResponse>\('\/calendar\/events\/' \+ encodeURIComponent\(id\)\)/,
'删除:DELETE /calendar/events/{id}');
// 编辑与新建必须发**同一份**输入类型(服务端两个端点同形;拆两份会在严格解码下 400)
assert.match(api, /createEvent\(input: CalendarEventInput\)/, '新建收 CalendarEventInput');
assert.match(api, /updateEvent\(id: string, input: CalendarEventInput\)/, '编辑收同一份 CalendarEventInput');
});
test('写侧:提交的是**时间戳**(不是日期键),回填走 localDateOf', () => {
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
assert.match(page, /isoTimestampOfLocal\(this\.fDate\.getFullYear\(\)/, '提交时由本地年月日+时分合成时间戳');
assert.ok(!/event_time = .*localIsoOf|input\.event_time = this\.selectedIso/.test(page),
'不许把日期键(YYYY-MM-DD)当 event_time 发出去 —— 那会被解析成当天 00:00,事件落到错误的小时上');
assert.match(page, /localDateOf\(e\.event_time\)/, '编辑回填要用 localDateOf 把时间戳还原成本地 Date');
});
test('★ 写侧:空标题 / 空收件人**在本地就挡住**,不发注定 400 的请求', () => {
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
const save = page.slice(page.indexOf('async saveEvent()'), page.indexOf('async deleteEvent()'));
assert.ok(save.length > 200, '要能取到 saveEvent 的正文');
// 两个前置校验都要在**发起请求之前**(`const input` 之前)
const beforeInput = save.slice(0, save.indexOf('const input'));
assert.match(beforeInput, /this\.fTitle\.trim\(\)\.length === 0/, '标题为空要拦住');
assert.match(beforeInput, /this\.recipientList\(\)\.length === 0/, '收件人为空要拦住');
assert.match(beforeInput, /return;/, '拦住就是 return,不许继续发请求');
});
test('★ 写侧:服务端 400 的文案原样显示(不是"保存失败"四个字)', () => {
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
assert.match(page, /private errorTextOf\(e: Object, fallback: string\): string/,
'要有一个把 ApiError 转成用户可读文案的函数');
assert.match(page, /return err\.message\.length > 0 \? err\.message/, '服务端文案优先');
// 三处调用都走它(保存 / 删除 / 改状态)
const calls = [...page.matchAll(/this\.errorTextOf\(e, '/g)].length;
assert.ok(calls >= 3, `保存、删除、改状态都要走 errorTextOf(实际 ${calls} 处)—— 吞掉服务端文案等于让人猜`);
});
test('写侧:删除有二次确认、暂停恢复走同一个 update', () => {
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
assert.match(page, /this\.confirmDelete \? '确认删除' : '删除'/, '删除按钮要两态');
assert.match(page, /if \(this\.confirmDelete\) \{\s*this\.deleteEvent\(\);/, '第一下只是置位,第二下才真删');
// 暂停/恢复:改 status 后走 update(不是删除重加 —— 那会丢 event_id 与历史)
const toggle = page.slice(page.indexOf('async toggleStatus()'), page.indexOf('private errorTextOf'));
assert.match(toggle, /this\.fStatus === 'active' \? 'paused' : 'active'/, '暂停/恢复就是 active ↔ paused');
assert.match(toggle, /a\.updateEvent\(this\.editingId, input\)/, '走 updateEvent');
});
test('★ isoTimestampOfLocal:本地年月日时 → 时间戳 → 回读还是同一格(含跨日)', () => {
const iso = C.isoTimestampOfLocal(2026, 9, 17, 10, 20);
assert.equal(C.localIsoOf(iso), '2026-09-17', '归日要回到同一天');
assert.equal(C.hhmmAtOffset(iso, C.deviceOffsetMinutes(C.localDateOf(iso))), '10:20', '钟点要回到同一时刻');
// 跨日边界:23:59 与 00:01 必须归到不同的两天(否则"跨午夜"那类事件会串天)
assert.notEqual(C.localIsoOf(C.isoTimestampOfLocal(2026, 9, 17, 23, 59)),
C.localIsoOf(C.isoTimestampOfLocal(2026, 9, 18, 0, 1)));
// 坏值回填不许抛
assert.ok(C.localDateOf('not-a-time') instanceof Date, '坏时间戳要给一个可用 Date,不能让表单崩');
});
test('★ 判据自检:写侧的错误写法必须判红', () => {
// ① 拿日期键当 event_time
assert.ok(/input\.event_time = this\.selectedIso/.test('input.event_time = this.selectedIso;'),
'拿日期键当时间戳能被"不许"那条命中');
// ② 没有前置校验
assert.ok(!/fTitle\.trim\(\)\.length === 0/.test('async saveEvent() { const a = this.api; }'),
'没有标题校验的写法能被命中');
// ③ 端点写错(PUT 写成 POST)
assert.ok(!/put<CalendarEvent>/.test("post<CalendarEvent>('/calendar/events/' + id, input)"),
'编辑用 POST 能被"必须 PUT"那条命中');
});

View File

@ -9,7 +9,7 @@
* 只查当月会让那几个格子永远空着(看着像"那天没事件",其实是没查)。
*/
import { ApiClient } from './ApiClient';
import { CalendarListResponse } from '../model/Models';
import { CalendarListResponse, CalendarEvent, CalendarEventInput, CalendarDeleteResponse } from '../model/Models';
export class CalendarApi {
private client: ApiClient;
@ -29,4 +29,25 @@ export class CalendarApi {
const query: string = 'from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to);
return this.client.get<CalendarListResponse>('/calendar/events', query);
}
/**
* 新建日程。
*
* 服务端在**建事件时**就校验收件地址("地址在这里就校验而不是等到触发时:建事件时报错
* 人能立刻改,而触发时报错只会进 journalctl")。所以这里的 400 必须**显示给用户**,
* 不许吞成"保存失败"——错误文案("收件地址无法解析:…")是唯一能让人立刻改的东西。
*/
async createEvent(input: CalendarEventInput): Promise<CalendarEvent> {
return this.client.post<CalendarEvent>('/calendar/events', input);
}
/** 编辑日程(PUT 与 POST 同形,见 `CalendarEventInput` 的说明)。 */
async updateEvent(id: string, input: CalendarEventInput): Promise<CalendarEvent> {
return this.client.put<CalendarEvent>('/calendar/events/' + encodeURIComponent(id), input);
}
/** 删除日程。 */
async deleteEvent(id: string): Promise<CalendarDeleteResponse> {
return this.client.del<CalendarDeleteResponse>('/calendar/events/' + encodeURIComponent(id));
}
}

View File

@ -182,3 +182,27 @@ export function hhmmAtOffset(timestamp: string, offsetMinutes: number): string {
const shifted = new Date(ms + offsetMinutes * 60000);
return `${pad2(shifted.getUTCHours())}:${pad2(shifted.getUTCMinutes())}`;
}
/**
* 本地年月日 + 时:分 → RFC3339 时间戳(**时间戳,不是日期键**)。
*
* 为什么不用字符串拼 + 时区偏移:设备时区可能是 +08:00 / -05:00 / UTC,手拼偏移
* 就要自己处理夏令时与半小时时区(+05:30)。`Date` 的本地构造 + `toISOString()`
* 拿到的是同一个**瞬间**的 UTC 表示,服务端 `time.Parse(RFC3339)` 直接吃得下。
*
* 与"取日期键"的区别(那条禁令针对的是 `.toISOString().slice(0,10)`):
* 日期键必须是**本地**年月日,时间戳必须是**绝对瞬间** —— 两者混用的那一天,
* 事件会被存到错误的小时上,而界面看起来完全正常。
*/
export function isoTimestampOfLocal(year: number, month: number, day: number, hour: number, minute: number): string {
return new Date(year, month - 1, day, hour, minute, 0, 0).toISOString();
}
/** RFC3339 时间戳 → 本地 `Date`(编辑表单回填用:日期与时间选择器都要 `Date`)。 */
export function localDateOf(timestamp: string): Date {
const ms: number = Date.parse(timestamp);
if (Number.isNaN(ms)) {
return new Date();
}
return new Date(ms);
}

View File

@ -266,3 +266,33 @@ export class CalendarEvent {
export class CalendarListResponse {
events: CalendarEvent[] = [];
}
/**
* 新建/编辑日程的请求体。
*
* **一份类型给两个端点用**(`POST /calendar/events` 与 `PUT /calendar/events/{id}`)——
* 服务端也是这么设计的(`CreateCalendarEvent` 里那个 `Status` 字段的注释写着:
* "前端的 CalendarEventInput 是**一份**类型,新建与编辑发的是同一个对象")。
* 拆成两份的后果在严格解码下是新建直接 400。
*
* 服务端必填:`title`、`event_time`、以及至少一个收件人(`recipients` 之一)。
* `reminder_text` 空则由服务端填默认模板(**变量形式**,触发时才渲染成本次时间)。
*/
export class CalendarEventInput {
title: string = '';
description: string = '';
reminder_text: string = '';
/** 完整三维地址串;这一版只发一个(多收件人与 separate/together 是下一步) */
recipients: string[] = [];
delivery_mode: string = 'separate';
/** RFC3339 时间戳(不是日期键)—— 由 `Calendar.ts` 的 isoTimestampOfLocal 生成 */
event_time: string = '';
remind_before: number = 0;
recurrence: string = 'none';
status: string = 'active';
}
/** 删除日程的响应(服务端给 `{status}`) */
export class CalendarDeleteResponse {
status: string = '';
}

View File

@ -23,7 +23,7 @@
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { CalendarApi } from '../api/CalendarApi';
import { CalendarEvent } from '../model/Models';
import { CalendarEvent, CalendarEventInput } from '../model/Models';
import { Theme } from '../common/Theme';
import {
DayCell,
@ -34,11 +34,26 @@ import {
isoOfLocal,
localIsoOf,
hhmmAtOffset,
deviceOffsetMinutes
deviceOffsetMinutes,
isoTimestampOfLocal,
localDateOf
} from '../model/Calendar';
/** 一格的高度(vp)。7 列等分 + 固定高度才画得成整齐的网格 */
const CELL_HEIGHT: number = 52;
/**
* 重复规则与提前量的**可选项**。
*
* 键与文案分成两个数组(ArkTS 里 `ForEach` 的项要有确定类型):键直接发给服务端
* (`validRecurrence` 只认这几个值),文案只用于界面。农历两种规则(lunar_monthly /
* lunar_yearly)服务端支持,但**鸿蒙这一版的表单还没放** —— 它们要农历日历支撑
* (`server/internal/lunar`),且"每月十五"这类说法在 UI 上要额外一句解释。
*/
const RECURRENCE_KEYS: string[] = ['none', 'daily', 'weekly', 'monthly'];
const RECURRENCE_LABELS: string[] = ['不重复', '每天', '每周', '每月'];
const REMIND_OPTIONS: number[] = [0, 5, 15, 30, 60];
const REMIND_LABELS: string[] = ['准点', '5 分钟', '15 分钟', '30 分钟', '60 分钟'];
/** 周表头高度(vp) */
const WEEKDAY_HEIGHT: number = 24;
/**
@ -68,6 +83,33 @@ export struct CalendarPage {
@State loading: boolean = false;
@State error: string = '';
/* ── 写侧(新建/编辑)的表单状态 ───────────────────────────────── */
@State editorOpen: boolean = false;
/** '' = 新建;否则是被编辑事件的 id */
@State editingId: string = '';
@State fTitle: string = '';
@State fRecipients: string = '';
@State fReminderText: string = '';
@State fRemindBefore: number = 0;
@State fRecurrence: string = 'none';
@State fStatus: string = 'active';
/** 选择的**本地**日期与时刻(提交时由 isoTimestampOfLocal 合成时间戳) */
@State fDate: Date = new Date();
@State fHour: number = 9;
@State fMinute: number = 0;
@State saving: boolean = false;
@State formError: string = '';
@State confirmDelete: boolean = false;
/**
* 日期/时间选择器是否展开。
*
* **默认收起**:两个选择器合起来占 ~1400px(DatePicker 700 + TimePicker 700),
* 展开着放进去,标题与收件人就被挤到屏幕外,用户得先跨过一整面滚轮才够得着「保存」。
* 日程的"什么时候"绝大多数时候就是"选中那天 09:00"(`openNew` 的默认值),
* 只有要改的人才点开 —— 点一下展开,再点一下收起。
*/
@State pickersOpen: boolean = false;
private api: CalendarApi | null = null;
private offsetMinutes: number = 0;
/** 首次**可见**时才拉数据(常驻挂载 ≠ 常拉) */
@ -222,6 +264,170 @@ export struct CalendarPage {
return status === 'active' ? Theme.textSubtle : Theme.warnFg;
}
/** 新建:默认落在**当前选中的那一天** 09:00(从"我在看的那天"出发最省事) */
private openNew(): void {
const d: Date = localDateOf(this.selectedIso.length > 0 ? this.selectedIso + 'T09:00:00' : '');
this.editingId = '';
this.fTitle = '';
this.fRecipients = '';
this.fReminderText = '';
this.fRemindBefore = 0;
this.fRecurrence = 'none';
this.fStatus = 'active';
this.fDate = d;
this.fHour = 9;
this.fMinute = 0;
this.formError = '';
this.confirmDelete = false;
this.pickersOpen = false;
this.editorOpen = true;
}
/** 编辑:把事件回填进表单(时间戳 → 本地 Date + 时/分) */
private openEdit(e: CalendarEvent): void {
const d: Date = localDateOf(e.event_time);
this.editingId = e.event_id;
this.fTitle = e.title;
this.fRecipients = e.recipients.length > 0 ? e.recipients[0] : '';
this.fReminderText = e.reminder_text;
this.fRemindBefore = e.remind_before;
this.fRecurrence = e.recurrence;
this.fStatus = e.status;
this.fDate = d;
this.fHour = d.getHours();
this.fMinute = d.getMinutes();
this.formError = '';
this.confirmDelete = false;
this.pickersOpen = false;
this.editorOpen = true;
}
/** 「什么时候」那一行显示的摘要(本地口径:日期 + 时:分) */
private whenLabel(): string {
const hh: string = this.fHour < 10 ? `0${this.fHour}` : `${this.fHour}`;
const mm: string = this.fMinute < 10 ? `0${this.fMinute}` : `${this.fMinute}`;
return `${isoOfLocal(this.fDate)} ${hh}:${mm}`;
}
/** 收件人输入:逗号/空格/分号分隔都接受(服务端也做归一化,但这里先分好) */
private recipientList(): string[] {
const out: string[] = [];
const parts: string[] = this.fRecipients.split(',');
for (let i = 0; i < parts.length; i++) {
const t: string = parts[i].trim();
if (t.length > 0) {
out.push(t);
}
}
return out;
}
/**
* 保存(新建或编辑同一个入口 —— 服务端的两个端点同形,见 `CalendarEventInput`)。
*
* 客户端只挡**明显的空**(标题、收件人),地址合法性交给服务端并在界面上**显示原文**:
* 服务端那句"收件地址无法解析:xxx"是唯一能让人立刻改的东西,吞掉它等于让人猜。
*/
async saveEvent(): Promise<void> {
if (this.fTitle.trim().length === 0) {
this.formError = '标题不能为空';
return;
}
if (this.recipientList().length === 0) {
this.formError = '至少要有一个收件地址';
return;
}
const a: CalendarApi | null = this.api;
if (a === null) {
return;
}
const input: CalendarEventInput = new CalendarEventInput();
input.title = this.fTitle.trim();
input.recipients = this.recipientList();
input.event_time = isoTimestampOfLocal(this.fDate.getFullYear(), this.fDate.getMonth() + 1,
this.fDate.getDate(), this.fHour, this.fMinute);
input.remind_before = this.fRemindBefore;
input.recurrence = this.fRecurrence;
input.reminder_text = this.fReminderText;
input.status = this.fStatus;
this.saving = true;
this.formError = '';
try {
if (this.editingId.length === 0) {
await a.createEvent(input);
} else {
await a.updateEvent(this.editingId, input);
}
this.editorOpen = false;
this.loadEvents();
} catch (e) {
this.formError = this.errorTextOf(e, '保存失败');
}
this.saving = false;
}
/** 删除(带二次确认 —— 这条路径不可逆,且服务端不给撤销) */
async deleteEvent(): Promise<void> {
const a: CalendarApi | null = this.api;
if (a === null || this.editingId.length === 0) {
return;
}
this.saving = true;
try {
await a.deleteEvent(this.editingId);
this.editorOpen = false;
this.confirmDelete = false;
this.loadEvents();
} catch (e) {
this.formError = this.errorTextOf(e, '删除失败');
}
this.saving = false;
}
/**
* 暂停 / 恢复。
*
* 走 update 而不是另开端点:`status` 本来就在同一份输入类型里。
* 为什么值得一个按钮:调度器只触发 `active`,暂停是"留着但先别响"的**可逆**操作 ——
* 没有它,用户只能删掉再重加,而那会丢掉 event_id 与历史。
*/
async toggleStatus(): Promise<void> {
const a: CalendarApi | null = this.api;
if (a === null || this.editingId.length === 0) {
return;
}
const next: string = this.fStatus === 'active' ? 'paused' : 'active';
const input: CalendarEventInput = new CalendarEventInput();
input.title = this.fTitle.trim();
input.recipients = this.recipientList();
input.event_time = isoTimestampOfLocal(this.fDate.getFullYear(), this.fDate.getMonth() + 1,
this.fDate.getDate(), this.fHour, this.fMinute);
input.remind_before = this.fRemindBefore;
input.recurrence = this.fRecurrence;
input.reminder_text = this.fReminderText;
input.status = next;
this.saving = true;
this.formError = '';
try {
await a.updateEvent(this.editingId, input);
this.fStatus = next;
this.loadEvents();
} catch (e) {
this.formError = this.errorTextOf(e, '状态修改失败');
}
this.saving = false;
}
/** 服务端的 400 文案("收件地址无法解析:…")必须原样带出来,不能只报"失败了" */
private errorTextOf(e: Object, fallback: string): string {
if (e instanceof ApiError) {
const err: ApiError = e as ApiError;
return err.message.length > 0 ? err.message : `${fallback}(HTTP ${err.code})`;
}
return fallback;
}
@Builder
DayCellView(cell: DayCell) {
Column() {
@ -289,104 +495,321 @@ export struct CalendarPage {
.padding(10)
.backgroundColor(Theme.surfaceMuted)
.borderRadius(Theme.radiusControl)
.onClick(() => this.openEdit(e))
}
build() {
/**
* 写侧表单(**新建与编辑共用一份** —— 与服务端"一份 CalendarEventInput 打两个端点"同构)。
*
* # 三个刻意的取舍
*
* ① **不是 CustomDialog**:对话框要另起一套控制器与生命周期,而"正在编辑哪条事件"
* 本来就是这一页的状态。用盖在日历之上的面板,状态归属清楚、也不多一层 API。
* ② **保存的失败文案原样显示**:服务端在**建事件时**就校验收件地址,它那句
* "收件地址无法解析:xxx" 是唯一能让人立刻改的东西;吞成"保存失败"等于让人猜
* (而"猜"的代价是:人以为提醒设好了,实际永远发不出去)。
* ③ **暂停/恢复走同一个 update**:调度器只触发 `active`。没有暂停,用户只能删掉重加,
* 而那会丢掉 event_id 与历史。
*
* 这一版**没有**:农历重复(服务端支持,要农历日历支撑与额外解释)、多收件人与
* separate/together、日程附件、权限档 —— 逐条写在文件头与提交信息里,别读成"做了"。
*/
@Builder
Editor() {
Column() {
/* 头部:翻月 + 回今天 */
Row() {
Button('‹')
.fontSize(Theme.fontBody)
.backgroundColor(Color.Transparent)
.fontColor(Theme.textPrimary)
.onClick(() => this.shiftMonth(-1))
Text(monthLabel(this.year, this.month))
Text(this.editingId.length === 0 ? '新建日程' : '编辑日程')
.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('今天')
Button('取消')
.fontSize(Theme.fontSmall)
.backgroundColor(Theme.accentSoft)
.fontColor(Theme.accentStrong)
.onClick(() => this.goToday())
.backgroundColor(Theme.chipNeutralBg)
.fontColor(Theme.chipNeutralFg)
.onClick(() => {
this.editorOpen = false;
})
}
.width('100%')
.padding({ left: 6, right: 6, top: 4, bottom: 4 })
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
/* 周表头:顺序**必须**来自 weekdayLabels(WEEK_START),不许在这里硬编码 */
Row() {
ForEach(weekdayLabels(WEEK_START), (w: string) => {
Text(w)
Scroll() {
Column() {
Text('标题')
.fontSize(Theme.fontTiny)
.fontColor(Theme.textMuted)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.height(WEEKDAY_HEIGHT)
}, (w: string) => `w${w}`)
}
.width('100%')
.width('100%')
TextInput({ text: this.fTitle, placeholder: '例如:llmsproxy 发布评审' })
.onChange((v: string) => {
this.fTitle = v;
})
/* 月网格:行 ← 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('加载中…')
Text('什么时候')
.fontSize(Theme.fontTiny)
.fontColor(Theme.textSubtle)
}
}
.width('100%')
.padding({ left: 10, right: 10, top: 8, bottom: 2 })
.fontColor(Theme.textMuted)
.width('100%')
.margin({ top: 8 })
Row() {
Text(this.whenLabel())
.fontSize(Theme.fontBody)
.fontColor(Theme.textPrimary)
.layoutWeight(1)
Text(this.pickersOpen ? '收起' : '修改')
.fontSize(Theme.fontSmall)
.fontColor(Theme.accentStrong)
}
.width('100%')
.padding(10)
.backgroundColor(Theme.chipNeutralBg)
.borderRadius(Theme.radiusControl)
.onClick(() => {
this.pickersOpen = !this.pickersOpen;
})
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)
if (this.pickersOpen) {
DatePicker({ start: new Date('2020-01-01'), end: new Date('2035-12-31'), selected: this.fDate })
.onDateChange((value: Date) => {
this.fDate = value;
})
TimePicker({ selected: this.fDate })
.onChange((value: TimePickerResult) => {
this.fHour = value.hour;
this.fMinute = value.minute;
})
}
Text('收件人(三维地址,逗号分隔)')
.fontSize(Theme.fontTiny)
.fontColor(Theme.textMuted)
.width('100%')
.margin({ top: 8 })
TextInput({ text: this.fRecipients, placeholder: 'name@path.session' })
.onChange((v: string) => {
this.fRecipients = v;
})
Text('重复')
.fontSize(Theme.fontTiny)
.fontColor(Theme.textMuted)
.width('100%')
.margin({ top: 8 })
Row() {
ForEach(RECURRENCE_KEYS, (key: string, i: number) => {
Button(RECURRENCE_LABELS[i])
.fontSize(Theme.fontTiny)
.backgroundColor(this.fRecurrence === key ? Theme.accent : Theme.chipNeutralBg)
.fontColor(this.fRecurrence === key ? Theme.accentFg : Theme.chipNeutralFg)
.margin({ right: 6 })
.onClick(() => {
this.fRecurrence = key;
})
}, (key: string) => `rec${key}`)
}
.width('100%')
Text('提前提醒')
.fontSize(Theme.fontTiny)
.fontColor(Theme.textMuted)
.width('100%')
.margin({ top: 8 })
Row() {
ForEach(REMIND_OPTIONS, (mins: number, i: number) => {
Button(REMIND_LABELS[i])
.fontSize(Theme.fontTiny)
.backgroundColor(this.fRemindBefore === mins ? Theme.accent : Theme.chipNeutralBg)
.fontColor(this.fRemindBefore === mins ? Theme.accentFg : Theme.chipNeutralFg)
.margin({ right: 6 })
.onClick(() => {
this.fRemindBefore = mins;
})
}, (mins: number) => `rem${mins}`)
}
.width('100%')
Text('提醒正文(留空 = 服务端默认模板,触发时才填入本次时间)')
.fontSize(Theme.fontTiny)
.fontColor(Theme.textMuted)
.width('100%')
.margin({ top: 8 })
TextArea({ text: this.fReminderText, placeholder: '留空即用默认模板' })
.height(90)
.onChange((v: string) => {
this.fReminderText = v;
})
if (this.formError.length > 0) {
Text(this.formError)
.fontSize(Theme.fontSmall)
.fontColor(Theme.danger)
.width('100%')
.margin({ top: 8 })
}
Row() {
if (this.editingId.length > 0) {
Button(this.fStatus === 'active' ? '暂停' : '恢复')
.fontSize(Theme.fontSmall)
.backgroundColor(Theme.chipWarnBg)
.fontColor(Theme.chipWarnFg)
.onClick(() => {
this.toggleStatus();
})
Button(this.confirmDelete ? '确认删除' : '删除')
.fontSize(Theme.fontSmall)
.backgroundColor(Theme.dangerBg)
.fontColor(Theme.danger)
.margin({ left: 6 })
.onClick(() => {
if (this.confirmDelete) {
this.deleteEvent();
} else {
this.confirmDelete = true;
}
})
}
}, (e: CalendarEvent) => e.event_id)
Blank()
Button(this.saving ? '保存中…' : '保存')
.fontSize(Theme.fontSmall)
.backgroundColor(Theme.accent)
.fontColor(Theme.accentFg)
.enabled(!this.saving)
.onClick(() => {
this.saveEvent();
})
}
.width('100%')
.margin({ top: 12, bottom: 20 })
}
.layoutWeight(1)
.width('100%')
.padding({ left: 10, right: 10 })
}
.layoutWeight(1)
.width('100%')
}
.width('100%')
.height('100%')
}
build() {
Stack() {
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())
Button('+')
.fontSize(Theme.fontBody)
.backgroundColor(Theme.accentSoft)
.fontColor(Theme.accentStrong)
.margin({ left: 6 })
.onClick(() => this.openNew())
}
.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)
/*
* 写侧面板:**盖在日历之上**(不是 CustomDialog —— 那要另起一套控制器与生命周期,
* 而"正在编辑哪条事件"本来就是这一页的状态)。
* 表单**必须不透明**:底下的月网格透出来会让输入框读不清。
*/
if (this.editorOpen) {
Column() {
this.Editor()
}
.width('100%')
.height('100%')
.backgroundColor(Theme.pageBg)
}
}
.width('100%')
.height('100%')
.backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg)
}
}