From 96335728f55e29c1e28d8fd3c373851de5f854ad Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 21 Oct 2026 10:17:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BC=96=E8=BE=91=E9=A1=B5):=20=E5=BF=85?= =?UTF-8?q?=E5=A1=AB=E9=A1=B9=E6=A0=A1=E9=AA=8C=E5=A4=B1=E8=B4=A5=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=BC=BA=E6=8F=90=E9=86=92=EF=BC=88Toast=20+=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E6=A0=87=E7=BA=A2=20+=20=E6=BB=9A=E5=9B=9E?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来校验失败只写 statusMsg,而它渲染在表单最底部("删除日程"按钮上方)。 用户在顶部点"保存"时那行红字远在屏幕之外,观感就是"点了没反应"。 新增 showFormError(msg, field) 作为唯一错误出口,同时做三件事: - Toast:系统级浮层,用 bottom:'70%' 抬到屏幕偏上方,不受滚动位置影响,必定可见; - 字段标红:标题输入框 / 时间卡片 / 日历本区域加 error 色边框,并在紧邻位置给出红字说明; - 滚回出错位置:scrollEdge(Top) + 对标题 requestFocus('titleInput')。 配套: - 标题 placeholder 改为「标题(必填)」,静态说明必填属性; - 用户开始输入标题 / 选中日历本后自动撤掉红框与状态行,不会"改了还一直红着"; - 日历本未选也走 showFormError(此前只写状态行); - 底部状态行仅在 errorField==='' 时显示,避免与就地提示重复; - 保存异常分支补 Toast:该分支紧接着 router.back(),状态行来不及被看到。 --- entry/src/main/ets/pages/EventEditPage.ets | 145 +++++++++++++++++++-- 1 file changed, 135 insertions(+), 10 deletions(-) diff --git a/entry/src/main/ets/pages/EventEditPage.ets b/entry/src/main/ets/pages/EventEditPage.ets index 8a6b41b..c4d6eac 100644 --- a/entry/src/main/ets/pages/EventEditPage.ets +++ b/entry/src/main/ets/pages/EventEditPage.ets @@ -70,7 +70,20 @@ struct EventEditPage { @State books: BookChoice[] = []; @State chosenKey: string = ''; @State isSaving: boolean = false; + /** + * 表单状态行文字。 + * ⚠️ 它渲染在表单**最底部**("删除日程"按钮上方),用户在顶部点"保存"时根本看不见。 + * 所以它只能作为"事后可复查的记录",**不能**作为唯一的错误反馈手段 —— + * 真正的提醒必须走 showFormError():Toast(必可见)+ 字段标红 + 滚到出错字段。 + */ @State statusMsg: string = ''; + /** + * ⭐ 哪个字段校验没过('' = 没有)。 + * 用途:把对应输入框描红并显示一条紧贴字段的红字说明, + * 让用户一眼看到"哪儿错了",而不是在一屏之外找一行小字。 + * 取值:'title' | 'time' | '' + */ + @State errorField: string = ''; @State isExisting: boolean = false; @State repeatMode: string = 'none'; // none|daily|workday|weekly|monthly|yearly|custom /** @@ -678,15 +691,68 @@ struct EventEditPage { } } + /** + * ⭐ 统一的表单错误提示(**唯一的错误出口**)。 + * + * 为什么需要专门做这件事: + * 校验提示原本只写进 `statusMsg`,而它渲染在表单最底部("删除日程"按钮上方)。 + * 用户在顶部点"保存"时,那行红字远在屏幕之外 —— 看起来就是"点了没反应"。 + * + * 三件事一起做,保证用户一定看得见、也知道该改哪儿: + * ① **Toast**:系统级浮在屏幕中上部,不受滚动位置影响,必定可见; + * ② **字段标红**:给对应输入框加红框 + 紧贴字段的红字说明; + * ③ **滚回出错字段并聚焦**:把视野直接带到问题所在(标题 → 打光标;时间 → 滚到时间卡片)。 + * + * @param msg 给用户看的话术(要具体,说清"该做什么",不要只说"错误") + * @param field 'title' | 'time' | ''('' 表示没有具体字段,只弹 Toast) + */ + private showFormError(msg: string, field: string): void { + this.statusMsg = msg; + this.errorField = field; + // ① Toast:不走布局,无论滚到哪儿都能看到 + try { + this.getUIContext().getPromptAction().showToast({ + message: msg, + duration: 2500, + bottom: '70%' // 抬到屏幕偏上方,避免被键盘/底部按钮遮挡 + }); + } catch (err) { + // Toast 失败也不影响其它两种提示 + } + // ③ 滚回出错位置:延时一帧,等状态刷新(标红后高度可能变化)后再滚 + setTimeout(() => { + try { + if (field === 'title') { + this.formScroller.scrollEdge(Edge.Top); + try { + this.getUIContext().getFocusController().requestFocus('titleInput'); + } catch (e2) { + // 聚焦失败(例如输入法正在切换)不阻塞流程 + } + } else if (field === 'time') { + // 时间卡片在表单上部,滚到顶基本就能看到;再留一点余量 + this.formScroller.scrollEdge(Edge.Top); + } else { + this.formScroller.scrollEdge(Edge.Top); + } + } catch (err2) { + const e = err2 as BusinessError; + LogUtil.write(`错误提示定位失败: ${e.message}`); + } + }, 60); + } + private validate(): boolean { if (this.title.trim() === '') { - this.statusMsg = '请输入日程标题'; + this.showFormError('请先填写日程标题', 'title'); return false; } if (this.endMs < this.startMs) { - this.statusMsg = '结束时间不能早于开始时间'; + this.showFormError('结束时间不能早于开始时间', 'time'); return false; } + // 通过校验:清掉上一轮的错误标记 + this.errorField = ''; return true; } @@ -952,9 +1018,10 @@ struct EventEditPage { // 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下 const book = this.chosenBook(); if (book === null) { - this.statusMsg = this.books.length === 0 + // 这也是"必填项缺失",同样要弹 Toast + 滚到对应字段,不能只写底部状态行 + this.showFormError(this.books.length === 0 ? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号' - : '请选择要同步到的 CalDAV 日历本'; + : '请选择要同步到的 CalDAV 日历本', 'book'); LogUtil.write(`保存判定:日历本解析失败(选本=${this.books.length} chosenKey=${this.chosenKey})`); return; } @@ -967,19 +1034,20 @@ struct EventEditPage { return; } // ⭐ 范围确认后回到这里再校验一次:范围弹层打开时并未跳过校验, - // 只是把它推迟到"选完范围"之后(失败时弹层已关闭,错误显示在表单状态行)。 + // 只是把它推迟到"选完范围"之后(失败时弹层已关闭,错误用 showFormError 强提示)。 if (!this.validate()) { return; } const book = this.chosenBook(); if (book === null) { - this.statusMsg = this.books.length === 0 + this.showFormError(this.books.length === 0 ? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号' - : '请选择要同步到的 CalDAV 日历本'; + : '请选择要同步到的 CalDAV 日历本', 'book'); return; } this.isSaving = true; this.statusMsg = ''; + this.errorField = ''; try { const context = this.getUIContext().getHostContext(); if (context === undefined) { @@ -1039,6 +1107,16 @@ struct EventEditPage { const ex = err as BusinessError; LogUtil.write(`保存日程流程失败(数据已存本地待重试):${ex.message}`); this.statusMsg = `保存失败:${ex.message}(已保存到本地,稍后同步会重试)`; + // ⭐ 这里紧接着会 router.back(),底部状态行根本来不及被看到 → 必须用 Toast + try { + this.getUIContext().getPromptAction().showToast({ + message: '已保存到本地,同步到服务器失败,稍后会自动重试', + duration: 3000, + bottom: '70%' + }); + } catch (e2) { + // Toast 失败不影响返回 + } // 数据仍在本地且带 dirty 标记,不会丢 router.back(); } @@ -1211,8 +1289,10 @@ struct EventEditPage { Scroll(this.formScroller) { Column({ space: 14 }) { - // 标题 - TextInput({ text: this.title, placeholder: '标题' }) + // 标题(必填) + // ⭐ 校验失败时:输入框描红 + 紧贴下方一条红字说明。 + // 只在 errorField === 'title' 时出现,用户开始输入即自动清除。 + TextInput({ text: this.title, placeholder: '标题(必填)' }) .height(46) .fontSize(16) .backgroundColor($r('app.color.card_bg')) @@ -1222,9 +1302,28 @@ struct EventEditPage { .fontColor($r('app.color.text_primary')) .placeholderColor($r('app.color.text_hint')) .caretColor($r('app.color.brand')) + // 标红:用边框而不是背景,深色/浅色主题下都醒目且不难看 + .border({ + width: this.errorField === 'title' ? 1.5 : 0, + color: $r('app.color.error') + }) .onChange((v: string) => { this.title = v; + // 用户开始填写就把红框撤掉,避免"改了还一直红着" + if (v.trim() !== '' && this.errorField === 'title') { + this.errorField = ''; + this.statusMsg = ''; + } }) + .id('titleInput') + + if (this.errorField === 'title') { + Text('请填写标题后再保存') + .fontSize(12) + .fontColor($r('app.color.error')) + .width('100%') + .padding({ left: 4 }) + } // 全天 Row() { @@ -1253,6 +1352,19 @@ struct EventEditPage { .padding(6) .borderRadius(12) .backgroundColor($r('app.color.card_bg')) + // 时间顺序错误时整卡描红,用户一眼能看到问题在这个卡片里 + .border({ + width: this.errorField === 'time' ? 1.5 : 0, + color: $r('app.color.error') + }) + + if (this.errorField === 'time') { + Text('结束时间不能早于开始时间') + .fontSize(12) + .fontColor($r('app.color.error')) + .width('100%') + .padding({ left: 4 }) + } // 重复 Column({ space: 8 }) { @@ -1414,6 +1526,11 @@ struct EventEditPage { .backgroundColor(this.chosenKey === b.calKey ? b.color : $r('app.color.chip_off_bg')) .onClick(() => { this.chosenKey = b.calKey; + // 选了日历本就把红框撤掉 + if (this.errorField === 'book') { + this.errorField = ''; + this.statusMsg = ''; + } }) }, (b: BookChoice) => b.calKey) } @@ -1424,6 +1541,11 @@ struct EventEditPage { .width('100%') .padding(14) .borderRadius(12) + // 日历本未选且用户点了保存 → 描红,提示"必填" + .border({ + width: this.errorField === 'book' ? 1.5 : 0, + color: $r('app.color.error') + }) .backgroundColor($r('app.color.card_bg')) // 地点(输入几个字 → 系统地图服务补全候选) @@ -1517,7 +1639,10 @@ struct EventEditPage { this.description = v; }) - if (this.statusMsg !== '') { + // 状态行:只用于"保存失败/账号未配置"这类**系统级**信息,且必须有 Toast 同时提示。 + // ⚠️ 校验类错误不走这里 —— 它们已经在对应字段旁就地显示, + // 再在这里重复一遍只会让用户以为有两处不同的问题。 + if (this.statusMsg !== '' && this.errorField === '') { Text(this.statusMsg) .fontSize(13) .fontColor($r('app.color.error'))