diff --git a/client/electron/test/harmony-calendar.test.mjs b/client/electron/test/harmony-calendar.test.mjs index 9c72b96..ff9a726 100644 --- a/client/electron/test/harmony-calendar.test.mjs +++ b/client/electron/test/harmony-calendar.test.mjs @@ -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\('\/calendar\/events', input\)/, '新建:POST /calendar/events'); + assert.match(api, /put\('\/calendar\/events\/' \+ encodeURIComponent\(id\), input\)/, + '编辑:PUT /calendar/events/{id}(id 要编码)'); + assert.match(api, /del\('\/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/.test("post('/calendar/events/' + id, input)"), + '编辑用 POST 能被"必须 PUT"那条命中'); +}); diff --git a/client/harmony/entry/src/main/ets/api/CalendarApi.ets b/client/harmony/entry/src/main/ets/api/CalendarApi.ets index a6506b2..ba38916 100644 --- a/client/harmony/entry/src/main/ets/api/CalendarApi.ets +++ b/client/harmony/entry/src/main/ets/api/CalendarApi.ets @@ -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('/calendar/events', query); } + + /** + * 新建日程。 + * + * 服务端在**建事件时**就校验收件地址("地址在这里就校验而不是等到触发时:建事件时报错 + * 人能立刻改,而触发时报错只会进 journalctl")。所以这里的 400 必须**显示给用户**, + * 不许吞成"保存失败"——错误文案("收件地址无法解析:…")是唯一能让人立刻改的东西。 + */ + async createEvent(input: CalendarEventInput): Promise { + return this.client.post('/calendar/events', input); + } + + /** 编辑日程(PUT 与 POST 同形,见 `CalendarEventInput` 的说明)。 */ + async updateEvent(id: string, input: CalendarEventInput): Promise { + return this.client.put('/calendar/events/' + encodeURIComponent(id), input); + } + + /** 删除日程。 */ + async deleteEvent(id: string): Promise { + return this.client.del('/calendar/events/' + encodeURIComponent(id)); + } } diff --git a/client/harmony/entry/src/main/ets/model/Calendar.ts b/client/harmony/entry/src/main/ets/model/Calendar.ts index 8ecffac..5cbbefa 100644 --- a/client/harmony/entry/src/main/ets/model/Calendar.ts +++ b/client/harmony/entry/src/main/ets/model/Calendar.ts @@ -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); +} diff --git a/client/harmony/entry/src/main/ets/model/Models.ets b/client/harmony/entry/src/main/ets/model/Models.ets index d06af07..9a166c5 100644 --- a/client/harmony/entry/src/main/ets/model/Models.ets +++ b/client/harmony/entry/src/main/ets/model/Models.ets @@ -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 = ''; +} diff --git a/client/harmony/entry/src/main/ets/pages/CalendarPage.ets b/client/harmony/entry/src/main/ets/pages/CalendarPage.ets index 1d8a256..37e3180 100644 --- a/client/harmony/entry/src/main/ets/pages/CalendarPage.ets +++ b/client/harmony/entry/src/main/ets/pages/CalendarPage.ets @@ -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 { + 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 { + 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 { + 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) } }