跨端: 鸿蒙日历写侧(新建/编辑/删除/暂停恢复)+ 表单不再是一面滚轮墙
用户:「是的去吧」(批准上一封列出的下一步)。
- 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:
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 = '';
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user