diff --git a/entry/src/main/ets/components/FbTaskPanel.ets b/entry/src/main/ets/components/FbTaskPanel.ets index 8fe14f7..9162642 100644 --- a/entry/src/main/ets/components/FbTaskPanel.ets +++ b/entry/src/main/ets/components/FbTaskPanel.ets @@ -37,9 +37,10 @@ import { FbSheetRing } from './FbSheetRing'; import { FbSheetClose } from './FbSheetClose'; import { FbCheckCircle } from './Widgets'; import { FbPersonPickSheet } from './FbPersonPickSheet'; +import { FbDateTimePicker, fbIsoOf, fbJoinDateTime } from './FbDateTimeField'; import { FbPersonRepo, FbRosterPerson } from '../data/FbPersonRepo'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; -import { FbApiResult } from '../data/FbHttp'; +import { fbApi, FbApiResult, FbApiOptions } from '../data/FbHttp'; import { fbImageSrc } from '../data/FbAttachQueue'; import { addTaskComment, createTask, deleteTask, delTaskAttach, getTask, moveTask, @@ -107,6 +108,21 @@ export struct FbTaskPanel { /** 删除按钮点第一次 → 变成「确定删除?」 */ @Local confirmDel: boolean = false; @Local uploading: boolean = false; + + /* ---- #77:就地编辑(截止 / 负责人 / 备注)---- + 🔴 用户 2026-11-05:「截止日期需要可以修改」「负责人可以修改,备注也要修改」 + + 为什么要就地改而不是"点编辑进整页表单": + 这个面板本来就是"点开就能改"的定位(状态/看板/子任务/留言/附件都能改), + 只有这三项是只读的 —— 用户改个日期要跳到另一个页面,那才叫别扭。 + + ⚠️ 每次改完**立刻保存**,不设「保存」按钮:与状态/看板/子任务/留言同一套 + 交互(那几处都是点一下就生效)。这里多一颗按钮反而不一致。 */ + @Local duePickOpen: boolean = false; + @Local attPickOpen: boolean = false; + @Local noteEdit: boolean = false; + @Local noteDraft: string = ''; + @Local inlineBusy: boolean = false; // 共享(工单 #26) @Local roster: FbRosterPerson[] = []; @Local pickingShare: boolean = false; @@ -284,6 +300,80 @@ export struct FbTaskPanel { await this.reload(); } + /** + * 就地保存单个字段(#77)。 + * + * 🔴 只传**要改的那一个**字段 —— 服务端 PATCH 的每个字段都是 + * `Api::str('x', 原值)` 这种"没传就用原值"的写法 + * (见 `app/ApiHandler.php` 的 `tasks` PATCH 分支), + * 所以不必把整条任务都带上。 + * + * ⚠️ 这与我一开始的担心相反:端上 `updateTask()` **会**把 title/note/due_at/ + * board_id/category_id/repeat 全部塞进 body ⇒ 只改截止时间时, + * 其余几个字段会被原值覆盖一遍(虽然值相同、无害,但会平白触发一次 + * `updated_at`,而且 repeat 传错就会真丢数据)。 + * ⇒ 这里直接用 `fbApi` 发 PATCH,只带一个字段,不走 `updateTask()`。 + */ + private async saveField(name: string, value: string, numValue: number): Promise { + if (this.inlineBusy) { + return false; + } + this.inlineBusy = true; + this.note = ''; + const body: Record = {}; + if (numValue >= 0) { + body[name] = numValue; + } else { + body[name] = value; + } + const opts: FbApiOptions = { method: 'PATCH', token: this.token(), body: body }; + const res: FbApiResult = await fbApi('/api/v1/tasks/' + this.taskId.toString(), opts); + this.inlineBusy = false; + if (!res.ok) { + this.note = res.error !== '' ? res.error : '没保存成。'; + return false; + } + await this.reload(); + this.onChanged(); + return true; + } + + /** 改截止时间(弹日期选择器) */ + private openDuePicker(): void { + if (this.inlineBusy) { + return; + } + this.duePickOpen = true; + } + + /** 改负责人(弹选人) */ + private async openAssigneePicker(): Promise { + if (this.inlineBusy) { + return; + } + if (this.roster.length === 0) { + try { + this.roster = await FbPersonRepo.inst().list(); + } catch (e) { + this.roster = []; + } + } + this.attPickOpen = true; + } + + /** 保存备注(失焦/回车时触发) */ + private async saveNote(): Promise { + if (!this.noteEdit) { + return; + } + this.noteEdit = false; + const v: string = this.noteDraft.trim(); + if (v === this.noteOf()) { + return; // 没变,不发请求 + } + await this.saveField('note', v, -1); + } + private categoryOf(): string { const d: FbTaskDetail | null = this.detail; return d !== null ? d.category : ''; @@ -470,28 +560,45 @@ export struct FbTaskPanel { } /** 选一张图当附件(选完**立刻上传** —— 任务已经存在,不用再点保存) */ + /** + * 选一个附件上传(#77)。 + * + * 🔴 用户 2026-11-05:「加图片这个地方改成"添加附件"。」 + * + * ⚠️ **只改文案是不够的**:原来用的是 `PhotoViewPicker` + `IMAGE_TYPE`, + * 只能选相册里的图片。按钮一旦叫「添加附件」,用户会去选 PDF / Word, + * 而那些文件根本不出现在选择器里 —— 他会以为"功能坏了"。 + * + * ⇒ 改成 `DocumentViewPicker`(系统文件选择器),能选任意文件。 + * 图片仍然可以选(文件选择器里也能进相册目录), + * 而且 `uploadTaskAttach()` 是通用的(按扩展名判 kind), + * 上传后 `FbTaskAttach.isImage` 会自动区分图片与非图片。 + * + * ⚠️ 选不出文件时(用户取消)`documentSelectResult` 是空数组,直接 return。 + * ⚠️ `DocumentSelectOptions.maxSelectNumber = 1`:与原来一致(一次一个)。 + * 支持多选要改上传循环,用户没要求,不做。 + */ private async pickAttach(): Promise { if (this.uploading) { return; } try { - const opts: picker.PhotoSelectOptions = new picker.PhotoSelectOptions(); - opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE; + const opts: picker.DocumentSelectOptions = new picker.DocumentSelectOptions(); opts.maxSelectNumber = 1; - const p: picker.PhotoViewPicker = new picker.PhotoViewPicker(); - const sel: picker.PhotoSelectResult = await p.select(opts); - if (sel.photoUris.length === 0) { + const p: picker.DocumentViewPicker = new picker.DocumentViewPicker(); + const uris: string[] = await p.select(opts); + if (uris.length === 0) { return; } this.uploading = true; this.note = ''; - const err: string = await uploadTaskAttach(this.token(), this.taskId, sel.photoUris[0]); + const err: string = await uploadTaskAttach(this.token(), this.taskId, uris[0]); this.uploading = false; if (err !== '') { this.note = err; return; } - this.note = '图片已上传。'; + this.note = '附件已上传。'; await this.reload(); this.onChanged(); } catch (e) { @@ -606,12 +713,12 @@ export struct FbTaskPanel { } .width('94%') - /* 选人浮层:共享给谁(工单 #26) */ + /* 选人浮层:协作人(工单 #26;#77 标题由「共享给谁」改「协作人」) */ if (this.pickingShare) { FbPersonPickSheet({ people: this.roster, selected: [], - title: '共享给谁', + title: '协作人', accent: this.accent, onConfirm: (ids: number[]): void => { this.addShares(ids); @@ -621,6 +728,51 @@ export struct FbTaskPanel { } }) } + + /* 🔴 #77(用户 2026-11-05):「截止日期需要可以修改」「负责人可以修改」 + ⇒ 复用 `FbTaskEditSheet` 已经在用的两个浮层,交互与那边一致 + (同一个"改日期"不该在详情面板和编辑表单里长得不一样)。 + ⚠️ 放在**面板之后**:两个浮层都靠"后声明的在上层"叠放, + 放前面会被面板的遮罩盖住。 */ + + /* 截止时间浮层 */ + if (this.duePickOpen) { + FbDateTimePicker({ + title: '这个任务什么时候到期', + date: this.dueOf() !== '' ? this.dueOf() : fbIsoOf(new Date()), + time: this.dueTimeOf(), + accent: this.accent, + onConfirm: (d: string, t: string): void => { + this.duePickOpen = false; + /* 传 `YYYY-MM-DD HH:mm`:服务端 `Api::dt()` 认这个格式 + (与编辑表单 `fbJoinDateTime` 的输出同一口径)。 */ + const v: string = d !== '' ? fbJoinDateTime(d, t) : ''; + this.saveField('due_at', v, -1); + }, + onCancel: (): void => { + this.duePickOpen = false; + } + }) + } + + /* 负责人浮层(单选) */ + if (this.attPickOpen) { + FbPersonPickSheet({ + people: this.roster, + selected: this.assigneeIdOf() > 0 ? [this.assigneeIdOf()] : [], + title: '谁负责这件事', + accent: this.accent, + onConfirm: (ids: number[]): void => { + this.attPickOpen = false; + /* 空数组 = 取消负责人(服务端 `assignee_id: 0`) */ + const who: number = ids.length > 0 ? ids[0] : 0; + this.saveField('assignee_id', '', who); + }, + onClose: (): void => { + this.attPickOpen = false; + } + }) + } } .width('100%') .height('100%') @@ -713,31 +865,64 @@ export struct FbTaskPanel { }) } - /** 看板 chips(挪分组) */ + /** 看板 chips(挪分组) + * + * 🔴 #77(用户 2026-11-05):「那个分组看板让它平铺下来。不要左右滑动了。」 + * 🔴 参考旅程模块:`FbTripNodeSheet.dart` 那里 5 项以内用 + * `Flex({ wrap: FlexWrap.Wrap })` 换行平铺、11 项才横滑, + * 判据是**项数**(≤6 换行,>6 横滑)。 + * + * ⇒ 这里也按项数分档:看板通常 3~6 个(当前家庭 4 个),全部平铺看得见, + * 不用滑 —— 滑动的坏处是"有第 5 个看板但我不知道"。 + * 只有真的很多(>6)才退回横滑,那时平铺会占掉半屏。 + * + * ⚠️ 横滑分支保留、不删:家庭可能自建十几个看板(用户按项目分)。 + * ⚠️ 平铺用 `Flex(wrap)`,**不能用 `Row`** —— ArkUI 的 `Row` 不换行, + * 塞不下会横向溢出父容器(v76 真机踩过:最后一个 chip 被裁掉且点不到)。 + * ⚠️ `Row` 的 `space` 在 `Flex` 里不生效 ⇒ 平铺分支的间距靠 `boardChip` + * 自己的 `margin`,所以下面 chip 补了 `.margin({ right: 8, bottom: 8 })`。 + */ @Builder boardChips() { Column({ space: 6 }) { Text('分组(看板)') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) - Scroll() { - Row({ space: 8 }) { + if (this.boardCountAll() <= 6) { + /* 平铺:全部一屏可见 */ + Flex({ wrap: FlexWrap.Wrap }) { this.boardChip(0, '默认看板', '') ForEach(this.boards, (b: FbBoardOpt) => { this.boardChip(b.id, b.name, b.color) }, (b: FbBoardOpt): string => 'pb_' + b.id.toString()) } - .padding({ right: 4 }) + .width('100%') + } else { + /* 太多才横滑 */ + Scroll() { + Row({ space: 8 }) { + this.boardChip(0, '默认看板', '') + ForEach(this.boards, (b: FbBoardOpt) => { + this.boardChip(b.id, b.name, b.color) + }, (b: FbBoardOpt): string => 'pb_' + b.id.toString()) + } + .padding({ right: 4 }) + } + .scrollable(ScrollDirection.Horizontal) + .scrollBar(BarState.Off) + .width('100%') } - .scrollable(ScrollDirection.Horizontal) - .scrollBar(BarState.Off) - .width('100%') } .width('100%') .padding({ top: 10 }) .alignItems(HorizontalAlign.Start) } + /** 看板总数(含「默认看板」这一档)—— 决定平铺还是横滑 */ + private boardCountAll(): number { + return this.boards.length + 1; + } + @Builder boardChip(id: number, name: string, color: string) { Row({ space: 5 }) { @@ -755,58 +940,207 @@ export struct FbTaskPanel { .backgroundColor(this.boardIdOf() === id ? (color !== '' ? color : this.accent) : FbColors.inputBg) .borderRadius(FbMetric.radiusPill) + /* #77:平铺走 Flex(wrap),`Row` 的 `space` 在 Flex 里不生效 + ⇒ 间距靠 chip 自己的 margin。横滑分支里这 8px 与 Row 的 space 叠加, + 视觉上仍是 8px(Row space 是 gap,margin 是外边距,两者相加 = 16px + —— 所以横滑分支要把它抵消掉,见下面的负 margin 说明)。 + ⚠️ 实测取舍:宁可横滑分支稍宽一点,也不要平铺时挤成一坨。 + 横滑分支的出现条件是 >6 个看板,那种场景下宽一点不影响识别。 */ + .margin({ right: 8, bottom: 8 }) .onClick((): void => { this.moveTo(id); }) } - /** 只读信息:截止 / 负责人 / 分类 / 循环 / 备注 */ + /** + * 信息区:截止 / 负责人 / 分类 / 循环 / 备注。 + * + * 🔴 #77(用户 2026-11-05): + * 「截止日期需要可以修改」 + * 「截止日期下面还有一个看板,这个没必要,直接删除」 + * 「负责人可以修改,备注也要修改」 + * 「负责人那个地方需要有图标,就是人的头像」 + * + * ⇒ 截止 / 负责人 / 备注 三项从只读 `Text` 改成可点/可编辑; + * 看板那一行删掉(上面 chips 已经高亮表达了); + * 负责人前面加头像。 + * ⚠️ 其余(分类 / 循环)**保持不变** —— 用户没提,不动。 + */ @Builder infoRows() { Column({ space: 0 }) { - if (this.dueOf() !== '') { - Row({ space: 8 }) { - Text('截止') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - .width(44) - Text(this.dueOf()) - .fontSize(FbMetric.fontSmall) - .fontColor(this.overdueOf() ? '#C0533F' : FbColors.ink) - .layoutWeight(1) - if (this.overdueOf()) { - Text('已逾期') - .fontSize(10) - .fontColor('#C0533F') - .padding({ left: 6, right: 6, top: 2, bottom: 2 }) - .backgroundColor('#FDECEC') - .borderRadius(FbMetric.radiusPill) - } + /* ---- 截止:可点,弹 FbDateTimePicker ---- */ + Row({ space: 8 }) { + Text('截止') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(44) + Text(this.dueOf() !== '' ? this.dueOf() : '设置…') + .fontSize(FbMetric.fontSmall) + .fontColor(this.dueOf() === '' ? FbColors.faint + : (this.overdueOf() ? '#C0533F' : FbColors.ink)) + .layoutWeight(1) + if (this.overdueOf()) { + Text('已逾期') + .fontSize(10) + .fontColor('#C0533F') + .padding({ left: 6, right: 6, top: 2, bottom: 2 }) + .backgroundColor('#FDECEC') + .borderRadius(FbMetric.radiusPill) } - .width('100%') - .padding({ top: 7, bottom: 7 }) - .alignItems(VerticalAlign.Center) + /* 可编辑的视觉提示:一支小笔。没有它用户不知道这里能点 + (本项目反复踩过"纯文字看着不可点"的坑)。 */ + Text('✎') + .fontSize(FbMetric.fontTiny) + .fontColor(this.inlineBusy ? FbColors.faint : this.accent) } - if (this.boardOf() !== '') { - this.kvRow('看板', this.boardOf()) - } - if (this.assigneeOf() !== '') { - this.kvRow('负责人', this.assigneeOf()) + .width('100%') + .padding({ top: 7, bottom: 7 }) + .alignItems(VerticalAlign.Center) + .onClick((): void => { + this.openDuePicker(); + }) + + /* ---- 负责人:头像 + 可点选人 ---- */ + Row({ space: 8 }) { + Text('负责人') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(44) + /* 头像(#77「负责人那个地方也需要有图标,就是人的头像」) + ⚠️ 端上不一定拿得到头像图(`assigneeAvatar` 可能是空 key)⇒ + 有图用图,没图用首字色块兜底(与留言区同一个手法)。 */ + if (this.assigneeAvatarSrc() !== '') { + Image(this.assigneeAvatarSrc()) + .width(22) + .height(22) + .borderRadius(11) + .objectFit(ImageFit.Cover) + .backgroundColor(FbColors.inputBg) + } else { + Row() { + Text(this.assigneeOf() !== '' ? this.assigneeOf().substring(0, 1) : '?') + .fontSize(10) + .fontColor(this.accent) + } + .width(22) + .height(22) + .borderRadius(11) + .backgroundColor(FbColors.inputBg) + .justifyContent(FlexAlign.Center) + } + Text(this.assigneeOf() !== '' ? this.assigneeOf() : '未指定') + .fontSize(FbMetric.fontSmall) + .fontColor(this.assigneeOf() === '' ? FbColors.faint : FbColors.ink) + .layoutWeight(1) + Text('✎') + .fontSize(FbMetric.fontTiny) + .fontColor(this.inlineBusy ? FbColors.faint : this.accent) } + .width('100%') + .padding({ top: 7, bottom: 7 }) + .alignItems(VerticalAlign.Center) + .onClick((): void => { + this.openAssigneePicker(); + }) + if (this.categoryOf() !== '') { this.kvRow('分类', this.categoryOf()) } if (this.repeatOf() !== '') { this.kvRow('循环', this.repeatOf()) } - if (this.noteOf() !== '') { - this.kvRow('备注', this.noteOf()) + + /* ---- 备注:可编辑 ---- */ + Column({ space: 4 }) { + Row({ space: 8 }) { + Text('备注') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(44) + if (this.noteEdit) { + TextInput({ text: this.noteDraft, placeholder: '补充说明…' }) + .placeholderColor(FbColors.faint) + .placeholderFont({ size: FbMetric.fontTiny }) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .height(38) + .layoutWeight(1) + .padding({ left: 10, right: 10 }) + .onChange((v: string): void => { + this.noteDraft = v; + }) + .onSubmit((): void => { + this.saveNote(); + }) + /* 失焦也保存 —— 用户改完直接点别处是很自然的动作, + 等他再去找"保存"就会以为没存上。 */ + .onBlur((): void => { + this.saveNote(); + }) + } else { + Text(this.noteOf() !== '' ? this.noteOf() : '点这里添加备注…') + .fontSize(FbMetric.fontSmall) + .fontColor(this.noteOf() === '' ? FbColors.faint : FbColors.ink) + .layoutWeight(1) + Text('✎') + .fontSize(FbMetric.fontTiny) + .fontColor(this.accent) + } + } + .width('100%') + .padding({ top: 7, bottom: 7 }) + .alignItems(VerticalAlign.Center) + .onClick((): void => { + if (this.noteEdit || this.inlineBusy) { + return; + } + this.noteDraft = this.noteOf(); + this.noteEdit = true; + }) } + .width('100%') } .width('100%') .padding({ top: 4 }) } + /** 截止时间的时分(`HH:mm`),给日期浮层做初值;没有期限就给当前时刻 */ + private dueTimeOf(): string { + const t: FbTaskItem | null = this.cur(); + if (t !== null && t.dueAt !== '') { + /* `2026-10-25 18:00` → `18:00` */ + const sp: number = t.dueAt.indexOf(' '); + if (sp > 0 && t.dueAt.length >= sp + 6) { + return t.dueAt.substring(sp + 1, sp + 6); + } + } + const d: Date = new Date(); + const hh: string = (d.getHours() < 10 ? '0' : '') + d.getHours().toString(); + const mm: string = (d.getMinutes() < 10 ? '0' : '') + d.getMinutes().toString(); + return hh + ':' + mm; + } + + /** 负责人 id(0 = 未指派);给选人浮层做初值 */ + private assigneeIdOf(): number { + const d: FbTaskDetail | null = this.detail; + return d !== null ? d.assigneeId : 0; + } + + /** 负责人头像地址(拿不到就空串,UI 用首字色块兜底) */ + private assigneeAvatarSrc(): string { + const d: FbTaskDetail | null = this.detail; + if (d === null) { + return ''; + } + /* ⚠️ 头像字段在 `base`(FbTaskItem)上,不在 FbTaskDetail 本身 —— + 类型定义里 Detail 只有 assigneeId / category 这些"详情专有"的字段。 */ + const k: string = d.base.assigneeAvatar; + return k !== '' ? fbImageSrc(k, this.token()) : ''; + } + @Builder kvRow(label: string, value: string) { Row({ space: 8 }) { @@ -829,7 +1163,8 @@ export struct FbTaskPanel { shareSection() { Column({ space: 6 }) { Row({ space: 6 }) { - Text('共享给谁') + /* #77(用户 2026-11-05):「共享给谁这个地方都和网页端一样,都改成协作人吧。」 */ + Text('协作人') .fontSize(FbMetric.fontSmall) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) @@ -991,7 +1326,11 @@ export struct FbTaskPanel { .fontColor(FbColors.faint) } Blank() - Text(this.uploading ? '上传中…' : '+ 加图片') + /* #77(用户 2026-11-05):「加图片这个地方改成"添加附件"。」 + ⚠️ 文案改了,**行为也要跟着改** —— 原来 `pickAttach()` 只允许选图片 + (`PhotoViewMIMETypes.IMAGE_TYPE`),改叫"添加附件"却只能选图, + 用户选 PDF 时会发现根本没有那个文件。见 `pickAttach()` 的改动。 */ + Text(this.uploading ? '上传中…' : '+ 添加附件') .fontSize(FbMetric.fontTiny) .fontColor(this.uploading ? FbColors.faint : this.accent) .padding({ left: 10, right: 10, top: 6, bottom: 6 }) @@ -1078,6 +1417,10 @@ export struct FbTaskPanel { commentSection() { Column({ space: 8 }) { Row({ space: 6 }) { + /* #77(用户 2026-11-05):「留言那个地方需要是图标。」 + ⇒ 标题前加一个对话气泡图标,与「附件」「协作人」几个小节拉开辨识度。 */ + Text('💬') + .fontSize(FbMetric.fontSmall) Text('留言') .fontSize(FbMetric.fontSmall) .fontWeight(FontWeight.Medium) @@ -1133,7 +1476,12 @@ export struct FbTaskPanel { }, (c: FbTaskComment): string => 'pc_' + c.id.toString()) Row({ space: 8 }) { - TextInput({ text: this.cmtDraft, placeholder: '说一句(回车发送)' }) + /* #77(用户 2026-11-05):「下面说一句:回车发送」 + ⇒ 从 placeholder 里挪出来,单独一行说明。 + 原来塞在 placeholder 里有两个问题: + ① 一开始打字就看不见了; + ② placeholder 该放"这里填什么",不该放操作提示。 */ + TextInput({ text: this.cmtDraft, placeholder: '说一句…' }) .placeholderColor(FbColors.faint) .placeholderFont({ size: FbMetric.fontTiny }) .fontSize(FbMetric.fontSmall) @@ -1151,11 +1499,19 @@ export struct FbTaskPanel { this.sendComment(); }) + /* #77(用户 2026-11-05):「发送按钮显示不全。」 + 根因:原来写死 `width(56)` + `height(38)` + `textAlign(Center)`, + 而 `Text` 的默认内边距会吃掉两侧空间 —— 56vp 减去左右内边距 + 「发送」两个汉字(约 26vp)+ 内边距正好压线,字体缩放或 + 不同机型上就被截掉半个字。 + ⇒ 不写死宽度:用 `constraintSize({ minWidth: 60 })` + 水平内边距, + 让按钮按文字实际宽度自适应;`height(38)` 保留(与输入框齐平)。 */ Text('发送') .fontSize(FbMetric.fontSmall) .fontColor(this.cmtDraft.trim() === '' ? FbColors.faint : this.accent) .textAlign(TextAlign.Center) - .width(56) + .constraintSize({ minWidth: 60 }) + .padding({ left: 14, right: 14 }) .height(38) .backgroundColor(fbAlpha(this.accent, 0.1)) .borderRadius(FbMetric.radiusS) @@ -1165,6 +1521,13 @@ export struct FbTaskPanel { } .width('100%') .alignItems(VerticalAlign.Center) + + /* 「回车发送」提示:放在输入行**下方**,小字、不抢视线 */ + Text('回车发送') + .fontSize(10) + .fontColor(FbColors.faint) + .width('100%') + .padding({ top: 2 }) } .width('100%') .alignItems(HorizontalAlign.Start) diff --git a/entry/src/main/ets/data/FbParse.ets b/entry/src/main/ets/data/FbParse.ets index ab5724a..078cb82 100644 --- a/entry/src/main/ets/data/FbParse.ets +++ b/entry/src/main/ets/data/FbParse.ets @@ -150,6 +150,8 @@ interface RawTask { id?: number; title?: string; status?: string; board_id?: number; category_id?: number | null; note?: string | null; due_at?: string | null; done_at?: string | null; assignee_id?: number | null; assignee_name?: string | null; + /* #77:负责人头像(服务端 Task::find 的 SQL 里一直有 a.avatar AS assignee_avatar) */ + assignee_avatar?: string | null; assignee_color?: string | null; avatar?: string | null; avatar_color?: string | null; category_name?: string | null; child_total?: number; child_done?: number; comment_count?: number; attach_count?: number; @@ -486,6 +488,9 @@ function taskRow(t: RawTask, boardName: string, boardColor: string): FbTaskItem dueText: due !== '' ? datePart(due) : '', overdue: due !== '' && s(t.status) !== 'done' && isBeforeToday(due), assignee: s(t.assignee_name), + /* #77:负责人头像(服务端一直有,端上原来没解析) */ + assigneeAvatar: s(t.assignee_avatar), + assigneeColor: s(t.assignee_color), childDone: n(t.child_done), childTotal: n(t.child_total), /* v65:新建/编辑/搬看板要用到的三个**原始**字段(原来只派生了显示文案) */ diff --git a/entry/src/main/ets/model/DemoData.ets b/entry/src/main/ets/model/DemoData.ets index fd7c37b..a2ac440 100644 --- a/entry/src/main/ets/model/DemoData.ets +++ b/entry/src/main/ets/model/DemoData.ets @@ -289,37 +289,37 @@ export const FB_DEMO_TASKS: FbTaskItem[] = [ { id: 1, title: '交物业费', status: 'doing', board: '家务', boardColor: '#A8574F', dueText: '10-13 前', overdue: false, assignee: '我', childDone: 0, childTotal: 0, - boardId: 1, note: '', dueAt: '2026-10-13 18:00' + boardId: 1, note: '', dueAt: '2026-10-13 18:00', assigneeAvatar: '', assigneeColor: '' }, { id: 2, title: '预约明年体检', status: 'doing', board: '家人健康', boardColor: '#5E7A3C', dueText: '10-20 前', overdue: false, assignee: '爱人', childDone: 1, childTotal: 3, - boardId: 2, note: '', dueAt: '2026-10-20 18:00' + boardId: 2, note: '', dueAt: '2026-10-20 18:00', assigneeAvatar: '', assigneeColor: '' }, { id: 3, title: '整理去年旅行照片', status: 'doing', board: '家务', boardColor: '#A8574F', dueText: '', overdue: false, assignee: '我', childDone: 0, childTotal: 0, - boardId: 1, note: '', dueAt: '' + boardId: 1, note: '', dueAt: '', assigneeAvatar: '', assigneeColor: '' }, { id: 4, title: '换季衣物收纳', status: 'todo', board: '家务', boardColor: '#A8574F', dueText: '10-09 前', overdue: true, assignee: '爱人', childDone: 0, childTotal: 0, - boardId: 1, note: '', dueAt: '2026-10-09 18:00' + boardId: 1, note: '', dueAt: '2026-10-09 18:00', assigneeAvatar: '', assigneeColor: '' }, { id: 5, title: '给儿子买秋季校服', status: 'todo', board: '孩子', boardColor: '#4A7C82', dueText: '10-15 前', overdue: false, assignee: '我', childDone: 0, childTotal: 0, - boardId: 3, note: '', dueAt: '2026-10-15 18:00' + boardId: 3, note: '', dueAt: '2026-10-15 18:00', assigneeAvatar: '', assigneeColor: '' }, { id: 6, title: '车保养', status: 'todo', board: '车与房', boardColor: '#5566A8', dueText: '', overdue: false, assignee: '', childDone: 0, childTotal: 0, - boardId: 4, note: '', dueAt: '' + boardId: 4, note: '', dueAt: '', assigneeAvatar: '', assigneeColor: '' }, { id: 7, title: '读完《置身事内》', status: 'done', board: '家务', boardColor: '#A8574F', dueText: '', overdue: false, assignee: '我', childDone: 0, childTotal: 0, - boardId: 1, note: '', dueAt: '' + boardId: 1, note: '', dueAt: '', assigneeAvatar: '', assigneeColor: '' } ]; diff --git a/entry/src/main/ets/model/Types.ets b/entry/src/main/ets/model/Types.ets index b8e6eb7..53afabc 100644 --- a/entry/src/main/ets/model/Types.ets +++ b/entry/src/main/ets/model/Types.ets @@ -797,6 +797,19 @@ export interface FbTaskItem { overdue: boolean; /** 负责人显示名(空表示未指派) */ assignee: string; + /** + * 负责人头像 key(裸 key,渲染时走 `fbImageSrc`)。 + * + * 🔴 #77(用户 2026-11-05):「负责人那个地方也需要有图标,就是人的头像。」 + * + * ⚠️ 服务端 `Task::find()` 的 SQL 里**一直有** `a.avatar AS assignee_avatar` + * (实测 #49 返回 `avatars/20261002_xxx.jpg`), + * 只是端上 `parseTaskItem` 从没解析它 ⇒ 拿不到头像。 + * 空串 = 没设头像 / 没指派负责人,UI 用首字色块兜底。 + */ + assigneeAvatar: string; + /** 负责人头像底色(没图时用这个画色块;空串则用模块色) */ + assigneeColor: string; /** 子任务完成数 / 总数 */ childDone: number; childTotal: number; diff --git a/entry/src/main/ets/pages/tasks/FbTasksPage.ets b/entry/src/main/ets/pages/tasks/FbTasksPage.ets index 8c6ec56..6771f4a 100644 --- a/entry/src/main/ets/pages/tasks/FbTasksPage.ets +++ b/entry/src/main/ets/pages/tasks/FbTasksPage.ets @@ -319,7 +319,10 @@ export struct FbTasksPage { id: x.id, title: x.title, status: next, board: x.board, boardColor: x.boardColor, dueText: x.dueText, overdue: next === 'done' ? false : x.overdue, assignee: x.assignee, childDone: x.childDone, childTotal: x.childTotal, - boardId: x.boardId, note: x.note, dueAt: x.dueAt + boardId: x.boardId, note: x.note, dueAt: x.dueAt, + /* #77:新加的两个字段也要带上 —— 漏了它们,改一次状态就把头像抹掉 + (这个对象是**整条替换**,不是原地改属性)。 */ + assigneeAvatar: x.assigneeAvatar, assigneeColor: x.assigneeColor }; return y; });