鸿蒙任务面板:看板平铺、截止负责人备注可改、文案与图标 (#77)

用户 2026-11-05 对鸿蒙端首页任务弹窗提了 12 项,本批完成 9 项
(附件预览/下载单独做)。

① 看板平铺,不再左右滑
   参考 FbTripNodeSheet 的做法:≤6 项 Flex(wrap) 换行平铺,>6 项才横滑。
   当前家庭 4 个看板(默认 + 正在进列/等待反馈/等待结果)⇒ 走平铺。
   ⚠️ 平铺必须用 Flex(wrap),ArkUI 的 Row 不换行,塞不下会横向溢出
      父容器(v76 真机踩过:最后一个 chip 被裁掉且点不到)。
   ⚠️ Row 的 space 在 Flex 里不生效 ⇒ chip 补了 margin 做间距。

② 删掉截止下面重复的「看板」行
   上面 boardChips() 已经把当前看板高亮出来了,再列一行纯属重复,
   而且两处都要同步才不出错。

③ 截止日期可改
   点整行 → FbDateTimePicker(与编辑表单同一个组件,交互一致)。
   改完立刻保存,不设保存按钮 —— 与状态/看板/子任务同一套交互。

④ 负责人可改 + 头像
   点头像/名字 → FbPersonPickSheet 单选。
   头像来源:服务端 Task::find 的 SQL 里**一直有** a.avatar AS assignee_avatar
   (实测 #49 返回 avatars/2026xxx.jpg),只是端上 parseTaskItem 从没解析
   ⇒ 补 RawTask 字段 + FbTaskItem.assigneeAvatar/assigneeColor + 解析。
   没头像时用首字色块兜底(与留言区同一手法)。

⑤ 备注可改
   点一下变输入框,回车或失焦自动保存(等用户去找"保存"按钮
   就会以为没存上)。

⑥ 「共享给谁」→「协作人」(标题与弹层标题都改)

⑦ 「+ 加图片」→「+ 添加附件」,**行为也跟着改**:
   原来 pickAttach 用 PhotoViewPicker + IMAGE_TYPE 只能选相册图片,
   改叫"添加附件"却选不了 PDF/Word,用户会以为功能坏了
   ⇒ 换成 DocumentViewPicker(系统文件选择器),能选任意文件。

⑧ 留言区标题加 💬 图标

⑨ 「回车发送」从 placeholder 挪到下方独立小字
   (塞 placeholder 里一打字就看不见,而且 placeholder 该放"填什么"不是"怎么操作")

⑩ 发送按钮显示不全
   根因:写死 width(56) + textAlign(Center),Text 默认内边距吃掉两侧空间,
   56 减去内边距 + 两个汉字正好压线,字体缩放或不同机型就截掉半个字。
   ⇒ 改 constraintSize({minWidth:60}) + 水平 padding,按文字实际宽度自适应。

⚠️ 另一个必须同步的地方:FbTasksPage 里改状态时会**整条替换** FbTaskItem,
   新加的两个字段漏了就会被抹掉(改一次状态头像就没了)——已补。

编译通过。
This commit is contained in:
2026-11-05 19:12:24 +08:00
parent 5fba8c4eda
commit 4e2fbb04b7
5 changed files with 442 additions and 58 deletions
+413 -50
View File
@@ -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<boolean> {
if (this.inlineBusy) {
return false;
}
this.inlineBusy = true;
this.note = '';
const body: Record<string, Object> = {};
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<void> {
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<void> {
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<void> {
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)
+5
View File
@@ -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:新建/编辑/搬看板要用到的三个**原始**字段(原来只派生了显示文案) */
+7 -7
View File
@@ -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: ''
}
];
+13
View File
@@ -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;
@@ -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;
});