标题/备注改成常驻输入框 + 内容用墨黑 (#77)

用户 2026-11-05 两点:
  1.「任务标题里面,"台式机采购计划"这样的字样还是改成黑色吧。
     任务标题这四个字用主题色,但是输入框里面的文字用黑色。」
  2.「这个输入框本来就应该是输入框,怎么现在点了一下才变成输入框呢?
     它应该直接就是输入框,只是把文字放到输入框里面就行了。
     这样的话,点一下键盘就弹出来了,包括下面的备注也是一样。」

① 颜色:标签主题色、内容墨黑
   上一版我把 TextArea 的 fontColor 也设成 accent,整段标题变成红的,
   像报错。现在:
     · 「任务标题」四个字 → fieldLb(主题色 + Bold)
     · 框里的文字 → FbColors.ink
   备注同理。

② 常驻输入框(这是关键的结构修正)
   上一版是"只读文字 + 点一下切成 TextArea"的双态(titleEdit / noteEdit),
   所以用户看到的是"点一下才变成输入框"。
   现在**直接就是 TextArea**,值从 titleDraft / noteDraft 来。
   点输入框由系统自己聚焦 + 弹键盘,不需要 requestFocus。

   ⚠️ 必须是**受控**的:`TextArea({ text: this.titleOf() })` 是非受控,
      每次 reload() 重建组件会丢掉用户正在打的字。
   ⚠️ 删掉 titleEdit / noteEdit 两个状态(双态不存在了),
      保存判据改成"值变了没有"。
   ⚠️ 标题**空值回填、不提交**:服务端会把空标题清洗成空串 = 把标题删了。
      用户只是清空重打,不该丢标题。
   ⚠️ 备注**允许清空**:删掉备注是正常操作,所以 note 不像 title 那样回填。

③ 焦点保护(新增 titleFocus / noteFocus)
   `@Monitor(refreshTick)` 会在外部刷新时调 reload(),而 reload 会用
   服务端的值覆盖 titleDraft —— 用户正打字时被覆盖就是"打着打着字没了"。
   ⇒ 有焦点时不覆盖,失焦后再同步(onBlur → save → reload)。

编译通过,已装真机。
This commit is contained in:
2026-11-05 19:48:49 +08:00
parent 4cca4c51be
commit d4b9d08b7e
+110 -129
View File
@@ -121,12 +121,17 @@ export struct FbTaskPanel {
交互(那几处都是点一下就生效)。这里多一颗按钮反而不一致。 */ 交互(那几处都是点一下就生效)。这里多一颗按钮反而不一致。 */
@Local duePickOpen: boolean = false; @Local duePickOpen: boolean = false;
@Local attPickOpen: boolean = false; @Local attPickOpen: boolean = false;
/* #77l:「任务标题」也做成可编辑(用户 2026-11-05: /* #77n:标题与备注都是**常驻输入框**(用户:「它应该直接就是输入框」),
「台式机采购计划也往下放,也是可以修改的」) */ 所以不再需要 `titleEdit` / `noteEdit` 这类双态标记,
@Local titleEdit: boolean = false; 只要两个受控值(框里的文字)。
⚠️ 必须受控:直接 `TextArea({text: this.titleOf()})` 是非受控的,
每次 reload() 重建组件会丢掉用户正在打的字。 */
@Local titleDraft: string = ''; @Local titleDraft: string = '';
@Local noteEdit: boolean = false;
@Local noteDraft: string = ''; @Local noteDraft: string = '';
/* 输入框是否有焦点。用来决定「重新载入后要不要用服务端的值覆盖框里的字」——
有焦点说明用户正在打字,这时覆盖会把刚打的字冲掉。 */
@Local titleFocus: boolean = false;
@Local noteFocus: boolean = false;
@Local inlineBusy: boolean = false; @Local inlineBusy: boolean = false;
/* #77c:附件预览浮层 —— 存当前要全屏看的那张图的地址,空 = 没开 */ /* #77c:附件预览浮层 —— 存当前要全屏看的那张图的地址,空 = 没开 */
@@ -174,6 +179,21 @@ export struct FbTaskPanel {
if (d !== null) { if (d !== null) {
this.detail = d; this.detail = d;
} }
/* #77n:标题/备注是常驻输入框,值要跟着服务端走。
⚠️ 但**有焦点时不能覆盖**:@Monitor('refreshTick') 会在外部刷新时
调 reload(),用户正打字时被覆盖就是"打着打着字没了"。
失焦时会再同步一次(onBlur -> save -> reload)。
⚠️ 比较用 trim:服务端存的可能带首尾空格,
直接赋值会让框里的文字"莫名多一个空格"。 */
const tt: FbTaskItem | null = this.cur();
if (tt !== null) {
if (!this.titleFocus && this.titleDraft !== tt.title) {
this.titleDraft = tt.title;
}
if (!this.noteFocus && this.noteDraft !== tt.note) {
this.noteDraft = tt.note;
}
}
} }
/** 当前要显示的任务(详情优先,退回列表那一份) */ /** 当前要显示的任务(详情优先,退回列表那一份) */
@@ -378,29 +398,45 @@ export struct FbTaskPanel {
} }
/** 保存备注(失焦/回车时触发) */ /** 保存备注(失焦/回车时触发) */
/** 保存任务标题(#77l) */ /**
* 保存任务标题。
*
* 🔴 #77n:标题改成**常驻输入框**后不再有 `titleEdit` 这个双态标记,
* 所以只靠"值变了没有"判断要不要发请求。
* ⚠️ 空值必须**回填原值**:服务端会把空标题清洗成空串,
* 那等于把标题删了 —— 用户只是清空重打,不该丢标题。
*/
private async saveTitle(): Promise<void> { private async saveTitle(): Promise<void> {
if (!this.titleEdit) { const v: string = this.titleDraft.trim();
if (v === '') {
this.titleDraft = this.titleOf(); // 回填,不提交
return; return;
} }
this.titleEdit = false; if (v === this.titleOf()) {
const v: string = this.titleDraft.trim(); return; // 没变,不发请求
if (v === '' || v === this.titleOf()) { }
return; // 空标题不发请求(服务端也会清洗成空,等于把标题删了) const ok: boolean = await this.saveField('title', v, -1);
if (!ok) {
/* 保存失败也要把框里改回数据库的真实值,否则框里显示的和库里的不一致 */
this.titleDraft = this.titleOf();
} }
await this.saveField('title', v, -1);
} }
/**
* 保存备注。
* 🔴 #77n:备注也改成常驻输入框,不再有 `noteEdit`。
* ⚠️ 备注**允许清空**(与标题不同):删掉备注是正常操作,
* 所以这里不像 saveTitle 那样回填。
*/
private async saveNote(): Promise<void> { private async saveNote(): Promise<void> {
if (!this.noteEdit) {
return;
}
this.noteEdit = false;
const v: string = this.noteDraft.trim(); const v: string = this.noteDraft.trim();
if (v === this.noteOf()) { if (v === this.noteOf()) {
return; // 没变,不发请求 return; // 没变,不发请求
} }
await this.saveField('note', v, -1); const ok: boolean = await this.saveField('note', v, -1);
if (!ok) {
this.noteDraft = this.noteOf(); // 失败回滚显示
}
} }
/** /**
@@ -1183,83 +1219,55 @@ export struct FbTaskPanel {
* ⚠️ 其余(分类 / 循环)**保持不变** —— 用户没提,不动。 * ⚠️ 其余(分类 / 循环)**保持不变** —— 用户没提,不动。
*/ */
/** /**
* 任务标题(可编辑)—— 放在**所有内容最前**(状态之前)。 * 任务标题 —— **一直就是输入框**,不是"点了才变输入框"。
* *
* 🔴🔴 #77m(用户 2026-11-05): * 🔴🔴 #77n(用户 2026-11-05):
* 「任务标题应该放在最前面,放在所有内容之前,也就是状态之前。」 * 「这个输入框本来就应该是输入框,怎么现在点了一下才变成输入框呢?
* 「任务和备注是点进去之后,只要点一下,马上就要变成编辑状态, * 它应该直接就是输入框,只是把文字放到输入框里面就行了。
* 然后弹出输入输入法,开始输入。不需要点一下之后先变成一个输入框, * 这样的话,点一下键盘就弹出来了,包括下面的备注也是一样。」
* 再点一下弹出输入法。直接点进去之后就是输入状态。」 * 「任务标题里面,"台式机采购计划"这样的字样还是改成黑色吧。
* 任务标题这四个字用主题色,但是输入框里面的文字用黑色。」
* *
* ⇒ 点一下直接换成 `TextArea` 并**抢焦点拉起输入法**。 * 所以:
* · 不设 `titleEdit` 双态,直接把 TextArea 常驻;文字从 `titleOf()` 灌进去。
* · **标签**(「任务标题」四个字)= 主题色粗体(走 fieldLb);
* **框里的内容** = `FbColors.ink`(墨黑)。上一版我把内容也染成 accent,
* 变成"整段标题都是红的",读起来像报错,也不符合"内容用墨色"。
* · 点输入框 → 系统自己聚焦 + 弹输入法,不需要我 requestFocus。
* *
* ⚠️ 关键:切换成输入框只是"画出来了",**不会自动获得焦点**。 * ⚠️ 不能直接 `TextArea({ text: this.titleOf() })` —— 那样是**非受控**:
* 必须 `focusControl.requestFocus(id)` 显式要焦点,否则用户还得再点一下 —— * 每次 `reload()` 后组件重建会丢掉用户正在打的字。
* 正是用户抱怨的"点一下变输入框、再点一下弹输入法"。 * 用 `titleDraft` 做受控值,`aboutToAppear` 里灌初值、保存后同步。
* ⚠️ 必须 `setTimeout` 延后一帧:`titleEdit = true` 之后框架还没完成 * ⚠️ 失焦时清空则**恢复原值**(不能把标题存成空串:服务端会把标题清零)。
* 这次重建,此刻 `id` 对应的节点尚不存在,直接 requestFocus 会落空。
* 60ms 用户完全无感,但足够框架把新节点挂上。
* ⚠️ `.defaultFocus(true)` 作为兜底:个别机型上 requestFocus 会因
* "节点刚创建"而失败,defaultFocus 能让它自己拿到焦点。
*/ */
@Builder @Builder
titleRow() { titleRow() {
Column({ space: 6 }) { Column({ space: 6 }) {
this.fieldLb('任务标题') this.fieldLb('任务标题')
if (this.titleEdit) { TextArea({ text: this.titleDraft, placeholder: '要做的事…' })
TextArea({ text: this.titleDraft, placeholder: '要做的事…' }) .placeholderColor(FbColors.faint)
.id('tkpTitleInput') .placeholderFont({ size: FbMetric.fontBody })
.defaultFocus(true) .fontSize(FbMetric.fontTitle)
.placeholderColor(FbColors.faint) .fontWeight(FontWeight.Bold)
.placeholderFont({ size: FbMetric.fontSmall }) /* 内容用墨黑(用户明确要求)—— 只有标签那四个字是主题色 */
.fontSize(FbMetric.fontTitle) .fontColor(FbColors.ink)
.fontWeight(FontWeight.Bold)
.fontColor(this.accent)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.width('100%')
.height(64)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.onChange((v: string): void => {
this.titleDraft = v;
})
/* 失焦保存 —— 与备注同一套(用户改完直接点别处是最自然的动作,
让他再去找"保存"就会以为没存上)。 */
.onBlur((): void => {
this.saveTitle();
})
} else {
Row({ space: 8 }) {
Text(this.titleOf())
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Bold)
.fontColor(this.isDone() ? FbColors.faint : this.accent)
.decoration({
type: this.isDone() ? TextDecorationType.LineThrough : TextDecorationType.None,
color: FbColors.faint
})
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text('✎')
.fontSize(FbMetric.fontTiny)
.fontColor(this.inlineBusy ? FbColors.faint : this.accent)
}
.width('100%')
.constraintSize({ minHeight: 44 })
.padding({ left: 12, right: 12, top: 11, bottom: 11 })
.backgroundColor(FbColors.inputBg) .backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS) .borderRadius(FbMetric.radiusS)
.alignItems(VerticalAlign.Top) .width('100%')
.onClick((): void => { .height(64)
if (this.titleEdit || this.inlineBusy) { .padding({ left: 12, right: 12, top: 10, bottom: 10 })
return; .onChange((v: string): void => {
} this.titleDraft = v;
this.titleDraft = this.titleOf(); })
this.titleEdit = true; .onFocus((): void => {
focusControl.requestFocus('tkpTitleInput'); this.titleFocus = true;
})
/* 失焦保存 —— 改完直接点别处是最自然的动作,
让他再去找"保存"就会以为没存上。 */
.onBlur((): void => {
this.titleFocus = false;
this.saveTitle();
}) })
}
} }
.width('100%') .width('100%')
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
@@ -1372,58 +1380,31 @@ export struct FbTaskPanel {
} }
/* ---- 备注 ---- */ /* ---- 备注 ---- */
/* 🔴 #77n(用户 2026-11-05):「包括下面的备注也是一样」
⇒ 与任务标题同处理:**常驻输入框**,不做"点一下才变输入框"。
标签(「备注」两字)= 主题色;框里内容 = 墨黑。 */
Column({ space: 6 }) { Column({ space: 6 }) {
this.fieldLb('备注') this.fieldLb('备注')
if (this.noteEdit) { TextArea({ text: this.noteDraft, placeholder: '补充说明…' })
TextArea({ text: this.noteDraft, placeholder: '补充说明…' }) .placeholderColor(FbColors.faint)
.id('tkpNoteInput') .placeholderFont({ size: FbMetric.fontSmall })
.defaultFocus(true) .fontSize(FbMetric.fontSmall)
.placeholderColor(FbColors.faint) .fontColor(FbColors.ink)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.width('100%')
.height(76)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.onChange((v: string): void => {
this.noteDraft = v;
})
/* 失焦保存 —— 用户改完直接点别处是很自然的动作,
等他再去找"保存"就会以为没存上。 */
.onBlur((): void => {
this.saveNote();
})
} else {
Row({ space: 8 }) {
Text(this.noteOf() !== '' ? this.noteOf() : '点这里添加备注…')
.fontSize(FbMetric.fontSmall)
.fontColor(this.noteOf() === '' ? FbColors.faint : FbColors.ink)
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text('✎')
.fontSize(FbMetric.fontTiny)
.fontColor(this.inlineBusy ? FbColors.faint : this.accent)
}
.width('100%')
.constraintSize({ minHeight: 40 })
.padding({ left: 12, right: 12, top: 11, bottom: 11 })
.backgroundColor(FbColors.inputBg) .backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS) .borderRadius(FbMetric.radiusS)
.alignItems(VerticalAlign.Top) .width('100%')
.onClick((): void => { .height(84)
if (this.noteEdit || this.inlineBusy) { .padding({ left: 12, right: 12, top: 10, bottom: 10 })
return; .onChange((v: string): void => {
} this.noteDraft = v;
this.noteDraft = this.noteOf(); })
this.noteEdit = true; .onFocus((): void => {
/* #77m:与任务标题同一套 —— 点一下就进编辑态并拉起输入法, this.noteFocus = true;
不用"再点一下输入框"(用户明确要求)。 */ })
focusControl.requestFocus('tkpNoteInput'); .onBlur((): void => {
this.noteFocus = false;
this.saveNote();
}) })
}
} }
.width('100%') .width('100%')
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)