/** * 家书 FamilyBook · 任务面板(鸿蒙端 v79) * * 🔴 用户 2026-10-22 原话: * 「鸿蒙端的功能,也需要同样适配,在首页上可以直接修改编辑任务, * 另外任务模块的功能需要完善。」 * * 对应网页 v78 的「就地处理面板」(`templates/tasks/_panel.php`): * 首页点一条任务 / 任务页点一张卡 —— 都在**当前页面**弹出这块面板, * 不跳模块、不跳整页,就能: * · 改状态(待办 / 进行中 / 已完成) * · 挪分组(换看板) * · 勾子任务、加一条子任务 * · 加图片附件、删附件 * · 发留言 * · 打开编辑表单(标题/备注/期限/看板/负责人/分类/循环) * · 删任务(二次确认) * * ⚠️ 为什么不直接复用 `FbTaskEditSheet`:那是**表单**(点开就在编辑)。 * 用户此刻多数是"看看它是什么、顺手改一下状态"。拿表单当详情, * 误触一下就把标题改了(v73 的取舍,见 `FbTaskDetailSheet` 文件头)。 * ⇒ 面板只读为主 + 两颗明确的动作(编辑 / 删除),编辑才进表单。 * * 🔴 三条纪律: * ① 改状态**只走** `POST /tasks//status`(PATCH 会忽略 status); * ② 每个动作成功后再 `getTask()` 重拉一次:一次动作会牵动子任务计数、 * 附件张数、看板色,逐处同步 DOM 必然漏一处(与网页 v78 同一取舍); * ③ 浮层根必须是 `Stack`:遮罩与卡片要**叠**,当 `Column` 子项"排"会把卡片 * 挤出屏幕(本项目 2026-10-20 真机事故,见 pitfalls)。 */ import { FbColors, FbMetric, FB_MASK, fbAlpha, fbTint, fbMixWhite } from '../common/Theme'; import { FbBoardOpt, FbCatOpt, FbTaskAttach, FbTaskComment, FbTaskDetail, FbTaskItem, FbTaskKid, FbTaskShare, } from '../model/Types'; import { FbSheetRing } from './FbSheetRing'; import { FbSheetClose } from './FbSheetClose'; import { FbCheckCircle, FbAvatar, FbSectionTitle, FbPersonChip } from './Widgets'; import { FbPersonPickSheet } from './FbPersonPickSheet'; import { FbDateTimePicker, fbIsoOf, fbJoinDateTime } from './FbDateTimeField'; import { fbAttachOpen } from '../common/FbAttachOpen'; import { common } from '@kit.AbilityKit'; import { FbPersonRepo, FbRosterPerson } from '../data/FbPersonRepo'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { fbApi, FbApiResult, FbApiOptions } from '../data/FbHttp'; import { fbImageSrc } from '../data/FbAttachQueue'; import { addTaskComment, createTask, deleteTask, delTaskAttach, getTask, moveTask, setTaskStatus, uploadTaskAttach, FbTaskDraft, fetchTaskShares, shareTask, unshareTask, addTaskCommentWithFile } from '../data/FbTaskRepo'; import { parseTaskDetail } from '../data/FbParse'; import { picker } from '@kit.CoreFileKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; const FB_PANEL_BOTTOM_GAP: number = 14; @ComponentV2 export struct FbTaskPanel { /** 要看的这条任务 */ @Param taskId: number = 0; /** * 列表里那一份(先用它渲染,详情接口回来再替换)。 * * ⚠️ 为什么不直接等接口:手机上网络慢一点,点开就是一片空白/转圈, * 用户会以为"点不动"。列表里本来就有标题、状态、看板、截止, * 先画出来,再补子任务/留言/附件。 */ @Param seed: FbTaskItem | null = null; /** 全部看板(含空看板)—— 父页面用 `?view=boards` 拿,别从任务里反推 */ @Param boards: FbBoardOpt[] = []; /** * 全部分类(`id=0` 由这里补「未分类」,所以传进来的是"真实分类")。 * * 🔴 #83(用户 2026-11-05):「分类和重复,也就是循环,都需要加上, * 鸿蒙端也得加上。」 * * ⚠️ 与 boards 同一理由:**不能从任务里反推** —— * 反推只能看见"已经被用过的"分类,新建的分类在端上永远选不到。 */ @Param cats: FbCatOpt[] = []; /** 模块主题色(任务 = 赭红) */ @Param accent: string = '#A8574F'; /** * 外部要求重画的脉冲(宿主改完任务后 +1)。 * * 🔴 为什么不用"关掉再打开":会闪一下,还会丢掉滚动位置。 * ⚠️ 只监听这个数字,**不要**把整条任务当 `@Param` 传进来再比对 —— * 对象相等判断会永远认为"没变"(本项目 v54b 的教训)。 */ @Param refreshTick: number = 0; @Event onClose: () => void = (): void => { }; /** * 点「编辑」→ 宿主打开 `FbTaskEditSheet`。 * * ⚠️ 传的是**详情**而不是列表行:编辑要回填「分类 / 循环」, * 而列表行(`FbTaskItem`)里没有这两项 —— 只传列表行会让用户一保存 * 就把分类和循环**静默清空**(端上 v77 已踩过同类"没传=清空")。 */ @Event onEdit: (d: FbTaskDetail) => void = (): void => { }; /** 有任何变化(状态/看板/子任务/附件/留言)→ 宿主重拉列表 */ @Event onChanged: () => void = (): void => { }; /** 删掉了 → 宿主关面板 + 重拉 */ @Event onDeleted: () => void = (): void => { }; @Local detail: FbTaskDetail | null = null; @Local loading: boolean = true; /** 正在提交(所有动作共用;防连点打重复请求) */ @Local busy: boolean = false; /** 一行提示(成功/失败都写这里,就地显示,不弹 toast) */ @Local note: string = ''; @Local kidDraft: string = ''; @Local cmtDraft: string = ''; /** 删除按钮点第一次 → 变成「确定删除?」 */ @Local confirmDel: boolean = false; @Local uploading: boolean = false; /* ---- #77:就地编辑(截止 / 负责人 / 备注)---- 🔴 用户 2026-11-05:「截止日期需要可以修改」「负责人可以修改,备注也要修改」 为什么要就地改而不是"点编辑进整页表单": 这个面板本来就是"点开就能改"的定位(状态/看板/子任务/留言/附件都能改), 只有这三项是只读的 —— 用户改个日期要跳到另一个页面,那才叫别扭。 ⚠️ 每次改完**立刻保存**,不设「保存」按钮:与状态/看板/子任务/留言同一套 交互(那几处都是点一下就生效)。这里多一颗按钮反而不一致。 */ @Local duePickOpen: boolean = false; @Local attPickOpen: boolean = false; /* #77n:标题与备注都是**常驻输入框**(用户:「它应该直接就是输入框」), 所以不再需要 `titleEdit` / `noteEdit` 这类双态标记, 只要两个受控值(框里的文字)。 ⚠️ 必须受控:直接 `TextArea({text: this.titleOf()})` 是非受控的, 每次 reload() 重建组件会丢掉用户正在打的字。 */ @Local titleDraft: string = ''; @Local noteDraft: string = ''; /* 输入框是否有焦点。用来决定「重新载入后要不要用服务端的值覆盖框里的字」—— 有焦点说明用户正在打字,这时覆盖会把刚打的字冲掉。 */ @Local titleFocus: boolean = false; @Local noteFocus: boolean = false; /* 点「保存」时的忙碌态(标题 + 备注两次请求要串行,不能并发) */ @Local saving: boolean = false; @Local inlineBusy: boolean = false; /* #77c:附件预览浮层 —— 存当前要全屏看的那张图的地址,空 = 没开 */ @Local previewSrc: string = ''; @Local previewName: string = ''; /* #77g:留言待发附件(URI + 显示名)。 ⚠️ 只存**一个**:服务端评论附件接口一次收一个 `file`。 要支持多个得改 API 成数组,用户没提,先按单个做(够用且简单)。 */ @Local cmtAttachUri: string = ''; @Local cmtAttachNames: string[] = []; @Local cmtBusy: boolean = false; // 共享(工单 #26) @Local roster: FbRosterPerson[] = []; @Local pickingShare: boolean = false; @Local shareBusy: boolean = false; aboutToAppear(): void { this.reload(); } @Monitor('refreshTick') onTick(): void { this.reload(); } /* ---------------- 数据 ---------------- */ private token(): string { return FB_TOKEN_STORE.get(); } private async reload(): Promise { if (this.taskId <= 0) { this.loading = false; return; } const res: FbApiResult = await getTask(this.token(), this.taskId); this.loading = false; if (!res.ok) { this.note = res.error !== '' ? res.error : '这条任务没载入,先看基本信息。'; return; } const d: FbTaskDetail | null = parseTaskDetail(res.data); if (d !== null) { 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; } } } /** 当前要显示的任务(详情优先,退回列表那一份) */ private cur(): FbTaskItem | null { const d: FbTaskDetail | null = this.detail; if (d !== null) { return d.base; } return this.seed; } private titleOf(): string { const t: FbTaskItem | null = this.cur(); return t !== null && t.title !== '' ? t.title : '未命名任务'; } private statusOf(): string { const t: FbTaskItem | null = this.cur(); return t !== null && t.status !== '' ? t.status : 'todo'; } private boardIdOf(): number { const t: FbTaskItem | null = this.cur(); return t !== null ? t.boardId : 0; } private boardOf(): string { const t: FbTaskItem | null = this.cur(); if (t === null) { return ''; } /* 🔴 v79b:原来只 `return t.board`。`?view=boards` 之外的老接口/某些行 `board` 是空串 ⇒ 面板上就**完全不显示这条属于哪个看板**, 用户没法判断"挪看板"到底挪成了没有(真机验收时就是这么发现 `boardOf()` 定义了却没被任何地方用)。这里补两层兜底:空名 → 按 id 反查 → 默认看板。 */ if (t.board !== '') { return t.board; } for (const b of this.boards) { if (b.id === t.boardId) { return b.name; } } return t.boardId === 0 ? '默认看板' : ''; } private boardColorOf(): string { const t: FbTaskItem | null = this.cur(); return t !== null && t.boardColor !== '' ? t.boardColor : '#9AA0A6'; } private dueOf(): string { const t: FbTaskItem | null = this.cur(); return t !== null ? t.dueText : ''; } private overdueOf(): boolean { const t: FbTaskItem | null = this.cur(); return t !== null && t.overdue && this.statusOf() !== 'done'; } private assigneeOf(): string { const t: FbTaskItem | null = this.cur(); return t !== null ? t.assignee : ''; } private noteOf(): string { const t: FbTaskItem | null = this.cur(); return t !== null ? t.note : ''; } private kids(): FbTaskKid[] { const d: FbTaskDetail | null = this.detail; return d !== null ? d.kids : []; } private kidDoneCount(): number { let k: number = 0; for (const one of this.kids()) { if (one.status === 'done') { k = k + 1; } } return k; } private comments(): FbTaskComment[] { const d: FbTaskDetail | null = this.detail; return d !== null ? d.comments : []; } private attachs(): FbTaskAttach[] { const d: FbTaskDetail | null = this.detail; return d !== null ? d.attachs : []; } /** 共享给谁(工单 #26) */ private shares(): FbTaskShare[] { const d: FbTaskDetail | null = this.detail; return d !== null ? d.shares : []; } private async openSharePicker(): Promise { if (this.roster.length === 0) { try { this.roster = await FbPersonRepo.inst().list(); } catch (e) { this.roster = []; } } this.pickingShare = true; } private async addShares(ids: number[]): Promise { this.pickingShare = false; if (ids.length === 0) { return; } this.shareBusy = true; let bad: string = ''; for (const pid of ids) { const res: FbApiResult = await shareTask(this.token(), this.taskId, pid, 'write'); if (!res.ok && bad === '') { bad = res.error; } } this.shareBusy = false; this.note = bad !== '' ? bad : ''; await this.reload(); } private async dropShare(personId: number): Promise { this.shareBusy = true; const res: FbApiResult = await unshareTask(this.token(), this.taskId, personId); this.shareBusy = false; if (!res.ok) { this.note = res.error !== '' ? res.error : '没取消成。'; return; } 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; } /** * 保存任务标题。 * * 🔴 #77n:标题改成**常驻输入框**后不再有 `titleEdit` 这个双态标记, * 所以只靠"值变了没有"判断要不要发请求。 * ⚠️ 空值必须**回填原值**:服务端会把空标题清洗成空串, * 那等于把标题删了 —— 用户只是清空重打,不该丢标题。 */ /** * 底部「保存」:把两个**手动编辑**的输入框(标题 / 备注)落库,然后关窗。 * * 🔴 #77q(用户 2026-11-05):「和网页版一样,没有保存按钮啊, * 这应该怎么保存呢?在哪个地方点保存呢?」 * * 之前的设计是"失焦自动保存",但**用户看不见这件事** —— 没有按钮就没底, * 不知道到底存没存。所以补一颗显式的保存按钮。 * * ⚠️ 其它字段(状态/看板/截止/负责人/分类/协作人/子任务/附件/留言) * **仍然是点一下就生效**,不用按保存 —— 那类操作是"动作", * 做完就该立刻生效;只有"打字"类才需要一次确认。 * ⚠️ 串行两次请求而不是并发:`saveField()` 内部会 `reload()`, * 并发会让两次 reload 互相覆盖 detail,出现"改完标题备注没了"。 */ private async saveAll(): Promise { if (this.saving) { return; } this.saving = true; await this.saveTitle(); await this.saveNote(); this.saving = false; this.onChanged(); this.onClose(); } private async saveTitle(): Promise { const v: string = this.titleDraft.trim(); if (v === '') { this.titleDraft = this.titleOf(); // 回填,不提交 return; } if (v === this.titleOf()) { return; // 没变,不发请求 } const ok: boolean = await this.saveField('title', v, -1); if (!ok) { /* 保存失败也要把框里改回数据库的真实值,否则框里显示的和库里的不一致 */ this.titleDraft = this.titleOf(); } } /** * 保存备注。 * 🔴 #77n:备注也改成常驻输入框,不再有 `noteEdit`。 * ⚠️ 备注**允许清空**(与标题不同):删掉备注是正常操作, * 所以这里不像 saveTitle 那样回填。 */ private async saveNote(): Promise { const v: string = this.noteDraft.trim(); if (v === this.noteOf()) { return; // 没变,不发请求 } const ok: boolean = await this.saveField('note', v, -1); if (!ok) { this.noteDraft = this.noteOf(); // 失败回滚显示 } } /** * 点附件(#77c)。 * * 🔴 用户 2026-11-05:「附件那个地方如果是Word文档,好像还是不能预览。 * 按道理说,应该打开本机上默认能够打开这个文件的应用,让它把文件打开才对。」 * * 分两种情况: * · **图片** → 就地全屏预览(不跳出 App,看附件不用切来切去) * · **其它**(Word / PDF / Excel…)→ `fbAttachOpen()`: * 先下载一份明文到 cacheDir,再用**隐式 Want + action.viewData** * 交给系统「打开方式」—— 系统自己找能打开 .docx 的应用。 * * ⚠️ 不能用「显式 Want + 包名」:我们不知道用户装的是 WPS 还是 Office, * 也不知道包名。让系统按 MIME/扩展名匹配才是唯一可靠的做法。 * ⚠️ `fbAttachOpen()` 已经处理好"下不下来 / 没有可用应用"的提示文案, * 这里只把它的返回串显示出来,不自己再编一套。 */ private async tapAttach(a: FbTaskAttach): Promise { if (a.isImage) { /* 图片:全屏预览。用 `fbImageSrc`(本地缓存优先,没有就远端 URL) */ this.previewSrc = fbImageSrc(a.key, this.token()); this.previewName = a.name; return; } /* 非图片:交给系统打开 */ const ctx: common.Context | undefined = this.getUIContext().getHostContext() as common.Context | undefined; if (ctx === undefined) { this.note = '打不开:拿不到上下文,请重启应用再试。'; return; } this.note = '正在下载…'; const err: string = await fbAttachOpen(this.token(), a.key, false, ctx); /* `fbAttachOpen` 成功时返回 ''(已交给系统),失败时返回人话 */ this.note = err !== '' ? err : ''; } private categoryOf(): string { const d: FbTaskDetail | null = this.detail; return d !== null ? d.category : ''; } private repeatOf(): string { const d: FbTaskDetail | null = this.detail; if (d === null || d.repeat === '') { return ''; } if (d.repeat === 'day') { return '每天'; } if (d.repeat === 'week') { return '每周'; } if (d.repeat === 'month') { return '每月'; } if (d.repeat === 'year') { return '每年'; } return d.repeat; } private parentOf(): string { const d: FbTaskDetail | null = this.detail; return d !== null ? d.parentTitle : ''; } private isDone(): boolean { return this.statusOf() === 'done'; } /** 主题色的半透明版(**八位色 `#AARRGGBB`,alpha 在前** —— 别写成 CSS 那种) */ private accentAlpha(aa: string): string { const c: string = this.accent.replace('#', ''); const rgb: string = c.length >= 8 ? c.substring(2) : c; return '#' + aa + rgb; } private statusWord(w: string): string { if (w === 'done') { return '已完成'; } return w === 'doing' ? '进行中' : '待做'; } /** * 状态的颜色。 * * ⚠️ 完成用**绿**、进行中用**赭红**、待办用**灰** —— 与任务页/网页同一套语义, * 不跟模块主题色走(状态是事实,不是"我在哪个模块")。 */ private statusTint(w: string): string { if (w === 'done') { return '#3F7D5A'; } return w === 'doing' ? '#A8574F' : '#7C8AA0'; } private statusWash(w: string): string { if (w === 'done') { return '#EAF3EE'; } return w === 'doing' ? '#FBEFEE' : '#F1F3F5'; } /* ---------------- 动作 ---------------- */ /** 改状态(三段 / 标题左边那颗圈都走这里) */ private async setStatus(next: string): Promise { if (this.busy || next === this.statusOf()) { return; } this.busy = true; this.note = ''; const res: FbApiResult = await setTaskStatus(this.token(), this.taskId, next); this.busy = false; if (!res.ok) { this.note = res.error !== '' ? res.error : '状态没改过来,请再试一次。'; return; } this.note = '已标记为' + this.statusWord(next) + '。'; await this.reload(); this.onChanged(); } /** 挪到另一个看板 */ private async moveTo(boardId: number): Promise { if (this.busy || boardId === this.boardIdOf()) { return; } this.busy = true; this.note = ''; const res: FbApiResult = await moveTask(this.token(), this.taskId, boardId); this.busy = false; if (!res.ok) { this.note = res.error !== '' ? res.error : '没挪过去,请再试一次。'; return; } this.note = '已挪到「' + this.boardNameOf(boardId) + '」。'; await this.reload(); this.onChanged(); } private boardNameOf(boardId: number): string { for (const b of this.boards) { if (b.id === boardId) { return b.name; } } return boardId === 0 ? '默认看板' : '该看板'; } /** 勾/取消勾一条子任务 */ private async toggleKid(k: FbTaskKid): Promise { if (this.busy) { return; } this.busy = true; this.note = ''; const next: string = k.status === 'done' ? 'todo' : 'done'; const res: FbApiResult = await setTaskStatus(this.token(), k.id, next); this.busy = false; if (!res.ok) { this.note = res.error !== '' ? res.error : '这条子任务没改成。'; return; } await this.reload(); this.onChanged(); } /** 加一条子任务(挂在当前任务下) */ private async addKid(): Promise { const title: string = this.kidDraft.trim(); if (this.busy || title === '') { return; } this.busy = true; this.note = ''; const d: FbTaskDraft = { title: title, note: '', dueAt: '', /* 子任务默认跟父任务在同一个看板:用户此刻想的是"这件事拆成几步", 不是"另开一摊"。 */ boardId: this.boardIdOf(), status: 'todo', parentId: this.taskId, assigneeId: 0, categoryId: 0, repeat: '', }; const res: FbApiResult = await createTask(this.token(), d); this.busy = false; if (!res.ok) { this.note = res.error !== '' ? res.error : '子任务没加上。'; return; } this.kidDraft = ''; this.note = '子任务已加上。'; await this.reload(); this.onChanged(); } /** 选一个附件挂到待发留言上(#77g)——**不立刻上传**,随留言一起发 */ private async pickCmtAttach(): Promise { if (this.cmtBusy) { return; } try { const opts: picker.DocumentSelectOptions = new picker.DocumentSelectOptions(); opts.maxSelectNumber = 1; const p: picker.DocumentViewPicker = new picker.DocumentViewPicker(); const uris: string[] = await p.select(opts); if (uris.length === 0) { return; } const u: string = uris[0]; const slash: number = u.lastIndexOf('/'); const nm: string = slash >= 0 && slash < u.length - 1 ? u.substring(slash + 1) : '附件'; this.cmtAttachUri = u; /* ⚠️ 用**新数组**赋值:`@Local` 只代理第一层, push 到原数组不会触发刷新(本项目踩过多次)。 */ this.cmtAttachNames = [nm]; this.note = ''; } catch (e) { const be: BusinessError = e as BusinessError; hilog.warn(0x0000, 'FamilyBook', '选留言附件失败:%{public}s', JSON.stringify(be)); this.note = '选文件失败,请再试一次。'; } } /** 去掉待发附件 */ private dropCmtAttach(idx: number): void { this.cmtAttachUri = ''; this.cmtAttachNames = []; } /** 发一条留言 */ /** * 发一条留言(#77g:支持带附件)。 * * 🔴 用户 2026-11-05:「留言是可以需要添加附件的。」 * * 两条路径: * · 没附件 → 原来的 `addTaskComment()`(JSON,轻) * · 有附件 → `addTaskCommentWithFile()`(multipart:content + file) * * ⚠️ **允许只发附件不写字**:拍张照片直接发是常见用法 * (服务端也认,会自动补占位文案「(附件)」)。 * 所以判空从"文字为空就 return"改成"文字和附件都为空才 return"。 * ⚠️ 发成功后要清掉待发附件,否则下一条留言会重复带上这张图。 */ private async sendComment(): Promise { const txt: string = this.cmtDraft.trim(); const hasFile: boolean = this.cmtAttachUri !== ''; if (this.busy || this.cmtBusy) { return; } if (txt === '' && !hasFile) { return; } this.busy = true; this.cmtBusy = true; this.note = ''; if (hasFile) { const err: string = await addTaskCommentWithFile( this.token(), this.taskId, txt, this.cmtAttachUri); this.busy = false; this.cmtBusy = false; if (err !== '') { this.note = err; return; // 附件留着,让用户能重试 } } else { const res: FbApiResult = await addTaskComment(this.token(), this.taskId, txt); this.busy = false; this.cmtBusy = false; if (!res.ok) { this.note = res.error !== '' ? res.error : '留言没发出去。'; return; } } this.cmtDraft = ''; this.cmtAttachUri = ''; this.cmtAttachNames = []; await this.reload(); this.onChanged(); } /** 选一张图当附件(选完**立刻上传** —— 任务已经存在,不用再点保存) */ /** * 选一个附件上传(#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.DocumentSelectOptions = new picker.DocumentSelectOptions(); opts.maxSelectNumber = 1; 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, uris[0]); this.uploading = false; if (err !== '') { this.note = err; return; } this.note = '附件已上传。'; await this.reload(); this.onChanged(); } catch (e) { const be: BusinessError = e as BusinessError; hilog.warn(0x0000, 'FamilyBook', '选附件失败:%{public}s', JSON.stringify(be)); this.uploading = false; this.note = '选图失败,请再试一次。'; } } /** 删一个附件 */ private async dropAttach(a: FbTaskAttach): Promise { if (this.busy) { return; } this.busy = true; this.note = ''; const res: FbApiResult = await delTaskAttach(this.token(), this.taskId, a.id); this.busy = false; if (!res.ok) { this.note = res.error !== '' ? res.error : '附件没删掉。'; return; } this.note = '附件已删除。'; await this.reload(); this.onChanged(); } /** 删任务(点两次才删) */ private async dropTask(): Promise { if (this.busy) { return; } if (!this.confirmDel) { this.confirmDel = true; this.note = '再点一次「确定删掉」就删除了,删了不能恢复。'; return; } this.busy = true; const res: FbApiResult = await deleteTask(this.token(), this.taskId); this.busy = false; if (!res.ok) { this.note = res.error !== '' ? res.error : '没删掉,请再试一次。'; return; } this.onDeleted(); } /* ---------------- UI ---------------- */ build() { /* 🔴 根必须 Stack:全屏遮罩与卡片要**叠**(见文件头第 ③ 条) */ Stack({ alignContent: Alignment.Center }) { Column() .width('100%') .height('100%') .backgroundColor(FB_MASK) .onClick((): void => { this.onClose(); }) /* 包裹层只为了挂宽度:环组件自身不能接属性方法(见 FbTaskDetailSheet 注释) */ Column() { FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) { Column({ space: 0 }) { this.headBar() Divider() .color(FbColors.line) .margin({ top: 10, bottom: 2 }) Scroll() { Column({ space: 0 }) { /* #77m:任务标题放在所有内容之前(用户:「也就是状态之前」) */ this.titleRow() this.statusSeg() this.boardChips() this.infoRows() this.shareSection() this.kidSection() this.attachSection() this.commentSection() if (this.note !== '') { Text(this.note) .fontSize(FbMetric.fontTiny) .fontColor(this.note.indexOf('没') >= 0 || this.note.indexOf('不能') >= 0 ? '#C0533F' : FbColors.muted) .width('100%') .padding({ top: 10 }) } /* 🔴 #77g(用户 2026-11-05):「留言输入框下面还有东西, 现在被挡住了,根本看不见,也滚不动了。」 —— 被挡住的是 `commentSection()` 末尾那行「回车发送」小字。 根因:`commentSection` 是 `Scroll` 里的**最后一个**子元素, 而这里原来只留了 `height(6)`。 `Scroll` 又是 `flexShrink(1)`(内容不够高时按内容收缩), 最后一行的下边距一旦小于"卡片 padding + footBar 高度", 就会被判定为"已到底",滚不过去也看不见。 ⇒ 底部留一块**足够高**的空白(不是 6vp 意思一下), 保证最后一行能滚到可视区中间。 */ /* #77s:删除放在**所有内容之后** —— 要滚到底才看得到, 误触概率基本为零(理由见 dangerZone 的注释)。 */ this.dangerZone() Row().height(28) } .width('100%') .alignItems(HorizontalAlign.Start) } /* 🔴🔴 #77r(用户 2026-11-05):「怎么没有看到任何按钮啊?」 **真根因**:这里是 `flexShrink(1)`,不是 `layoutWeight(1)`。 `flexShrink` 只是"空间不够时允许收缩",但 Scroll 的**内容高度** 仍然会把自身撑开(ArkUI 里 Scroll 竖向内容默认按内容高)。 于是: 头部 + Scroll(按内容撑满) 就把 `maxHeight: 86%` 吃光了 → 下面的 `footBar()`(保存/删除)被推到 86% 之外 → `FbSheetRing` 的 `clip(true)` 把它裁掉 ⇒ 一个按钮都看不见。 `layoutWeight(1)` 是"**占据剩余空间**":Scroll 的高度被限定成 "86% − 头部 − 分割线 − footBar",footBar 因此必然留在可视区内, 内容再长也只是在 Scroll 内部滚动。 ⚠️ 旧注释说 `layoutWeight(1)` 会让"短内容也把卡片顶满"(v60b)。 那个副作用是真的,但它是**观感问题**; 而按钮看不见是**功能问题** —— 两害相权,先保证按钮可用。 要消掉空白的话,正确做法是给外层 Column 用 `.constraintSize({ maxHeight: '86%' })` 配合 **`minHeight: 0`**, 而不是回到 flexShrink。 */ .layoutWeight(1) .width('100%') .scrollBar(BarState.Off) .align(Alignment.TopStart) .nestedScroll({ scrollForward: NestedScrollMode.PARENT_FIRST, scrollBackward: NestedScrollMode.SELF_FIRST }) this.footBar() } .width('100%') .constraintSize({ maxHeight: '86%' }) .padding({ left: 16, right: 16, top: 16, bottom: 16 + FB_PANEL_BOTTOM_GAP }) .alignItems(HorizontalAlign.Start) } } .width('94%') /* 选人浮层:协作人(工单 #26;#77 标题由「共享给谁」改「协作人」) */ if (this.pickingShare) { FbPersonPickSheet({ people: this.roster, selected: [], title: '协作人', accent: this.accent, onConfirm: (ids: number[]): void => { this.addShares(ids); }, onClose: (): void => { this.pickingShare = false; } }) } /* 🔴 #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; } }) } /* 🔴 #77f(用户 2026-11-05):「附件那个地方…按道理说,应该打开本机上 默认能够打开这个文件的应用」——图片走就地全屏预览(不跳出 App), 非图片走 `fbAttachOpen()` 交给系统(见 `tapAttach`)。 ⚠️ 预览层放在**所有浮层最后**:它要盖住面板本身。 ⚠️ 整块用 `Stack` 铺满 + 半透明黑底,点击任意处关闭。 ⚠️ `objectFit(ImageFit.Contain)` —— 预览要看全图, 用 Cover 会裁掉边缘(缩略图用 Cover 是对的,预览不是)。 */ if (this.previewSrc !== '') { Stack({ alignContent: Alignment.Center }) { Column() .width('100%') .height('100%') .backgroundColor('#E6000000') .onClick((): void => { this.previewSrc = ''; this.previewName = ''; }) Image(this.previewSrc) .width('100%') .constraintSize({ maxHeight: '78%' }) .objectFit(ImageFit.Contain) /* 底部:文件名 + 关闭提示 */ Column({ space: 4 }) { Text(this.previewName !== '' ? this.previewName : '附件') .fontSize(FbMetric.fontSmall) .fontColor(Color.White) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) .textAlign(TextAlign.Center) Text('点任意处关闭') .fontSize(10) .fontColor('#B3FFFFFF') } .width('100%') .padding({ left: 24, right: 24, bottom: 40 }) .alignItems(HorizontalAlign.Center) .position({ x: 0, y: '85%' }) } .width('100%') .height('100%') .hitTestBehavior(HitTestMode.Block) .onClick((): void => { this.previewSrc = ''; this.previewName = ''; }) } /* 负责人浮层(单选) */ 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%') .hitTestBehavior(HitTestMode.Transparent) } /** * 顶栏:状态徽标 + 关闭。 * * 🔴🔴 #77l(用户 2026-11-05): * 「这个弹出框的标题是编辑任务,台式机采购这样的东西,它是一个标题, * 也应该可以被修改才对。所以它应该往下放,就是任务标题; * 然后台式机采购计划也往下放,也是可以修改的。 * 左上角那个标志,好像一个圆圈什么的,那个也不要了,去掉。」 * * 三处变化: * · **删掉左上角的 FbCheckCircle**(就是用户说的"圆圈")。它是"点一下勾完成" * 的快捷入口,但下面「状态」区已经有三个明确的 chip,入口重复; * 而且它紧贴标题,看起来像"标题前面的项目符号",容易误解。 * · **删掉这里的大标题**。原来标题画在这里,导致它是只读的 —— * 标题本来就该能改,所以下移到「更多信息」区当第一个字段。 * · 只留状态徽标 + 关闭按钮,这一条变窄(原来 30+标题+徽标+关闭很挤)。 * * ⚠️ 「属于「父任务」」那行**保留在这里**:它是上下文不是内容, * 跟着标题下移反而会离"父任务"这个概念更远。 */ @Builder headBar() { Row({ space: 10 }) { Column({ space: 2 }) { /* 🔴 #77m(用户 2026-11-05):「这个框的标题在左上角,是"编辑任务" 几个字,颜色用主题颜色。你参考一下"编辑行程"的做法。」 与 `FbTripNodeSheet` 的「编辑行程」完全同一套: fontSize(fontBody=15) + Bold + 主题色。 ⚠️ 这里只写固定的「编辑任务」,**不写任务标题** —— 标题是内容(可改),已经下移到 titleRow()。 */ Text('编辑任务') .fontSize(FbMetric.fontBody) .fontWeight(FontWeight.Bold) .fontColor(this.accent) if (this.parentOf() !== '') { Text('属于「' + this.parentOf() + '」') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .maxLines(1) } } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .flexShrink(1) /* 🔴 #77o(用户 2026-11-05):「编辑任务那一行,右上角还有个"进行中", 那个也去掉吧,因为在状态那里已经看到"进行中"了。」 ⇒ 删掉状态徽标。下面「状态」区三个 chip 已经把当前态高亮出来了, 这里再挂一个只读徽标纯属重复信息。 ⚠️ 上一版注释说"只留状态徽标 + 关闭",现在是"只留关闭"。 ⚠️ `statusWord() / statusTint() / statusWash()` 三个方法**保留** —— 状态区(statusChip)还在用它们。 */ FbSheetClose({ onClose: (): void => { this.onClose(); }, color: '' }) } .width('100%') .alignItems(VerticalAlign.Center) } /** 状态三段(点一下即改,没有"保存"按钮) */ @Builder statusSeg() { Column({ space: 6 }) { /* #77i:状态也有标题了(原来只有三个 chip,没有区段标题, 而下面每个区段都有 —— 视觉上它是"飘着的")。 */ FbSectionTitle({ title: '状态', accent: this.accent }) Row({ space: 8 }) { this.statusChip('todo') this.statusChip('doing') this.statusChip('done') } .width('100%') } .width('100%') .padding({ bottom: 4 }) .alignItems(HorizontalAlign.Start) } @Builder statusChip(w: string) { Text(this.statusWord(w)) .fontSize(FbMetric.fontSmall) .fontColor(this.statusOf() === w ? '#FFFFFF' : this.statusTint(w)) .textAlign(TextAlign.Center) .layoutWeight(1) .height(36) .backgroundColor(this.statusOf() === w ? this.statusTint(w) : FbColors.inputBg) .borderRadius(FbMetric.radiusS) .onClick((): void => { this.setStatus(w); }) } /** 看板 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 }) { /* 🔴 #77i(用户 2026-11-05):「分组看板那个地方没改…还是不对,标题的颜色不对。」 上一轮我只把「子任务/附件/留言」三个标题换成了 `FbSectionTitle`, **看板、状态、协作人、以及「更多信息」里那几个字段标签全都漏了** —— 它们还是手写的 `fontTiny + faint`(浅灰小字)。 规范(`Widgets.ets` / `FbSectionTitle`):区块标题 = **Bold + 主题色 + 字距 0.6**。 ⇒ 这一处改成组件。 */ FbSectionTitle({ title: '分组(看板)', accent: this.accent }) 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()) } .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%') } } .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 }) { Column() .width(7) .height(7) .borderRadius(4) .backgroundColor(this.boardIdOf() === id ? '#FFFFFF' : (color !== '' ? color : '#9AA0A6')) Text(name) .fontSize(FbMetric.fontTiny) .fontColor(this.boardIdOf() === id ? '#FFFFFF' : FbColors.muted) .maxLines(1) } .padding({ left: 10, right: 10, top: 7, bottom: 7 }) .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 已经高亮表达了); * 负责人前面加头像。 * ⚠️ 其余(分类 / 循环)**保持不变** —— 用户没提,不动。 */ /** * 任务标题 —— **一直就是输入框**,不是"点了才变输入框"。 * * 🔴🔴 #77n(用户 2026-11-05): * 「这个输入框本来就应该是输入框,怎么现在点了一下才变成输入框呢? * 它应该直接就是输入框,只是把文字放到输入框里面就行了。 * 这样的话,点一下键盘就弹出来了,包括下面的备注也是一样。」 * 「任务标题里面,"台式机采购计划"这样的字样还是改成黑色吧。 * 任务标题这四个字用主题色,但是输入框里面的文字用黑色。」 * * 所以: * · 不设 `titleEdit` 双态,直接把输入框常驻;文字从 `titleOf()` 灌进去。 * · #77p:用**单行 `TextInput`**(不是 TextArea)—— 标题就是一句话, * 多行框把面板下半部分全推下去了。 * · **标签**(「任务标题」四个字)= 主题色粗体(走 fieldLb); * **框里的内容** = `FbColors.ink`(墨黑)。上一版我把内容也染成 accent, * 变成"整段标题都是红的",读起来像报错,也不符合"内容用墨色"。 * · 点输入框 → 系统自己聚焦 + 弹输入法,不需要我 requestFocus。 * * ⚠️ 不能直接 `TextArea({ text: this.titleOf() })` —— 那样是**非受控**: * 每次 `reload()` 后组件重建会丢掉用户正在打的字。 * 用 `titleDraft` 做受控值,`aboutToAppear` 里灌初值、保存后同步。 * ⚠️ 失焦时清空则**恢复原值**(不能把标题存成空串:服务端会把标题清零)。 */ @Builder titleRow() { Column({ space: 6 }) { this.fieldLb('任务标题') /* 🔴🔴 #77p(用户 2026-11-05):「现在这个任务标题是好几行的那种输入框, 不要用这种输入框,用一行的输入框就行了。标题没有那么长,现在太高了。」 ⇒ `TextArea`(多行,84 高)换成 **`TextInput`(单行,40 高)**, 高度与「截止」「负责人」那些控件行对齐(它们是 height 40)。 标题是一句话,本来就不需要多行;多行框还占掉两倍高度, 把下面所有内容都往下推。 ⚠️ `TextInput` 不支持 `.height(84)` 那种"给多少显示多少" —— 它只有一行,高度超了也只是上下留白。所以这里直接给 40。 ⚠️ 单行框**不会换行**,超长文字横向滚动。标题超过一行宽时 用户能看到正在打的部分,打完失焦后由 maxLines 之外的 省略处理在别处(列表卡片)表现,这里不需要额外处理。 */ TextInput({ text: this.titleDraft, placeholder: '要做的事…' }) .placeholderColor(FbColors.faint) .placeholderFont({ size: FbMetric.fontSmall }) .fontSize(FbMetric.fontSmall) /* 内容用墨黑(用户明确要求)—— 只有标签那四个字是主题色 */ .fontColor(FbColors.ink) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) .width('100%') .height(40) .padding({ left: 12, right: 12 }) .onChange((v: string): void => { this.titleDraft = v; }) .onFocus((): void => { this.titleFocus = true; }) /* 失焦保存 —— 改完直接点别处是最自然的动作, 让他再去找"保存"就会以为没存上。 */ .onBlur((): void => { this.titleFocus = false; this.saveTitle(); }) /* 单行框支持"回车提交":用户打完直接按回车就走 saveTitle, 不用先点别处。多行框里回车是换行,所以以前没这一条。 */ .onSubmit((): void => { this.saveTitle(); }) } .width('100%') .alignItems(HorizontalAlign.Start) .padding({ top: 10, bottom: 4 }) } @Builder infoRows() { /* 🔴🔴 #77k(用户 2026-11-05):「上面的标题和下面的输入框,其实要分成两行。 比如说,"截止"不是直接后面排一个日期,而是把日期输入框放在"截止"下面; "负责人"也是,"备注"也是,全部都是这样的。」 ⚠️ 每个字段 = 一个 Column: ① 标题行(主题色 + Bold,独占一行) ② 控件行(浅底圆角框,整行宽) ⚠️ 控件行统一给 height(40) + inputBg + radiusS: 不给高度的话,「设置…」「未指定」这类短文字会把框压扁, 几行之间的间距就会参差不齐。 🔴 #77t(用户 2026-11-05):「截止日期和负责人右侧都有一个编辑用的、 像小铅笔似的图标。我感觉这个东西不需要了。…就把所有小图标 全部去掉,也就是右侧那个小铅笔图标全去掉。」 ⇒ 删掉「截止」「负责人」右侧的 `✎`。 上一版加它是想提示"这里能点"(本项目踩过"纯文字看着不可点"的坑), 但代价是:只有这两行有、别的行没有,视觉上不齐; 而且输入框本身(浅底圆角)已经足够表达"这是个可操作的控件"。 ⚠️ 如果以后想恢复"可点"提示,**必须所有行一起加**, 不能只有截止/负责人有 —— 那才是用户不接受的地方。 */ Column({ space: 14 }) { /* ---- 截止 ---- */ Column({ space: 6 }) { this.fieldLb('截止') Row({ space: 8 }) { 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%') .height(40) .padding({ left: 12, right: 12 }) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) .alignItems(VerticalAlign.Center) .onClick((): void => { this.openDuePicker(); }) } .width('100%') .alignItems(HorizontalAlign.Start) /* ---- 负责人 ---- */ Column({ space: 6 }) { this.fieldLb('负责人') Row({ space: 8 }) { /* 头像(#77「负责人那个地方也需要有图标,就是人的头像」) ⚠️ 端上不一定拿得到头像图(`assigneeAvatar` 可能是空 key)⇒ 有图用图,没图用首字色块兜底(与留言区同一个手法)。 */ if (this.assigneeAvatarSrc() !== '') { Image(this.assigneeAvatarSrc()) .width(24) .height(24) .borderRadius(12) .objectFit(ImageFit.Cover) .backgroundColor(FbColors.line2) } else { Row() { Text(this.assigneeOf() !== '' ? this.assigneeOf().substring(0, 1) : '?') .fontSize(11) .fontColor(this.accent) } .width(24) .height(24) .borderRadius(12) .backgroundColor(FbColors.line2) .justifyContent(FlexAlign.Center) } Text(this.assigneeOf() !== '' ? this.assigneeOf() : '未指定') .fontSize(FbMetric.fontSmall) .fontColor(this.assigneeOf() === '' ? FbColors.faint : FbColors.ink) .layoutWeight(1) } .width('100%') .height(40) .padding({ left: 12, right: 12 }) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) .alignItems(VerticalAlign.Center) .onClick((): void => { this.openAssigneePicker(); }) } .width('100%') .alignItems(HorizontalAlign.Start) /* ---- 分类 ---- */ /* 🔴 #83(用户 2026-11-05):「分类和重复,也就是循环,都需要加上。」 原来这两个只在**有值**时才画一行只读文字 —— 用户不仅改不了,连"这里本来有这一项"都看不出来。 ⇒ 改成与截止/负责人同形态(标签一行 + 控件一行),且始终显示。 */ Column({ space: 6 }) { this.fieldLb('分类') if (this.inlineBusy) { Text(this.categoryOf() !== '' ? this.categoryOf() : '未分类') .fontSize(FbMetric.fontSmall) .fontColor(FbColors.faint) .width('100%') .height(40) .padding({ left: 12, right: 12 }) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) } else { /* 🔴🔴 #86(用户 2026-11-05):「鸿蒙端也用类似的方式调, 效果让它保持一致。」 网页端照 FeedLite 做成"自绘按钮 + 浮层列表"(原生