/** * 家书 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 } from '../common/Theme'; import { FbBoardOpt, FbTaskAttach, FbTaskComment, FbTaskDetail, FbTaskItem, FbTaskKid, } from '../model/Types'; import { FbSheetRing } from './FbSheetRing'; import { FbSheetClose } from './FbSheetClose'; import { FbCheckCircle } from './Widgets'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { FbApiResult } from '../data/FbHttp'; import { fbImageSrc } from '../data/FbAttachQueue'; import { addTaskComment, createTask, deleteTask, delTaskAttach, getTask, moveTask, setTaskStatus, uploadTaskAttach, FbTaskDraft, } 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[] = []; /** 模块主题色(任务 = 赭红) */ @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; 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; } } /** 当前要显示的任务(详情优先,退回列表那一份) */ 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(); return t !== null ? t.board : ''; } 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 : []; } 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(); } /** 发一条留言 */ private async sendComment(): Promise { const txt: string = this.cmtDraft.trim(); if (this.busy || txt === '') { return; } this.busy = true; this.note = ''; const res: FbApiResult = await addTaskComment(this.token(), this.taskId, txt); this.busy = false; if (!res.ok) { this.note = res.error !== '' ? res.error : '留言没发出去。'; return; } this.cmtDraft = ''; await this.reload(); this.onChanged(); } /** 选一张图当附件(选完**立刻上传** —— 任务已经存在,不用再点保存) */ private async pickAttach(): Promise { if (this.uploading) { return; } try { const opts: picker.PhotoSelectOptions = new picker.PhotoSelectOptions(); opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE; opts.maxSelectNumber = 1; const p: picker.PhotoViewPicker = new picker.PhotoViewPicker(); const sel: picker.PhotoSelectResult = await p.select(opts); if (sel.photoUris.length === 0) { return; } this.uploading = true; this.note = ''; const err: string = await uploadTaskAttach(this.token(), this.taskId, sel.photoUris[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 }) { this.statusSeg() this.boardChips() this.infoRows() 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 }) } Row().height(6) } .width('100%') .alignItems(HorizontalAlign.Start) } /* `flexShrink(1)` 而不是 `layoutWeight(1)`:后者会让短内容也把卡片顶满, 底下留一大片空白(v60b 真机实测) */ .flexShrink(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%') } .width('100%') .height('100%') .hitTestBehavior(HitTestMode.Transparent) } /** 标题行:状态圈(点=勾完成)+ 标题 + 状态徽标 + 关闭 */ @Builder headBar() { Row({ space: 10 }) { Stack({ alignContent: Alignment.Center }) { FbCheckCircle({ dia: 18, done: this.isDone(), doing: this.statusOf() === 'doing', color: this.accent }) } .width(30) .height(30) .onClick((): void => { this.setStatus(this.isDone() ? 'todo' : 'done'); }) Column({ space: 2 }) { Text(this.titleOf()) .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(this.isDone() ? FbColors.faint : FbColors.ink) .decoration({ type: this.isDone() ? TextDecorationType.LineThrough : TextDecorationType.None, color: FbColors.faint }) .maxLines(2) .width('100%') if (this.parentOf() !== '') { Text('属于「' + this.parentOf() + '」') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .maxLines(1) .width('100%') } } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .flexShrink(1) Text(this.statusWord(this.statusOf())) .fontSize(FbMetric.fontTiny) .fontColor(this.statusTint(this.statusOf())) .padding({ left: 8, right: 8, top: 3, bottom: 3 }) .backgroundColor(this.statusWash(this.statusOf())) .borderRadius(FbMetric.radiusPill) FbSheetClose({ onClose: (): void => { this.onClose(); }, color: '' }) } .width('100%') .alignItems(VerticalAlign.Top) } /** 状态三段(点一下即改,没有"保存"按钮) */ @Builder statusSeg() { Row({ space: 8 }) { this.statusChip('todo') this.statusChip('doing') this.statusChip('done') } .width('100%') .padding({ top: 10, bottom: 4 }) } @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(挪分组) */ @Builder boardChips() { Column({ space: 6 }) { Text('分组(看板)') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) 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) } @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) .onClick((): void => { this.moveTo(id); }) } /** 只读信息:截止 / 负责人 / 分类 / 循环 / 备注 */ @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) } } .width('100%') .padding({ top: 7, bottom: 7 }) .alignItems(VerticalAlign.Center) } if (this.assigneeOf() !== '') { this.kvRow('负责人', this.assigneeOf()) } if (this.categoryOf() !== '') { this.kvRow('分类', this.categoryOf()) } if (this.repeatOf() !== '') { this.kvRow('循环', this.repeatOf()) } if (this.noteOf() !== '') { this.kvRow('备注', this.noteOf()) } } .width('100%') .padding({ top: 4 }) } @Builder kvRow(label: string, value: string) { Row({ space: 8 }) { Text(label) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .width(44) Text(value) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .layoutWeight(1) } .width('100%') .padding({ top: 7, bottom: 7 }) .alignItems(VerticalAlign.Top) } /** 子任务:列表 + 加一条 */ @Builder kidSection() { Column({ space: 6 }) { Row({ space: 6 }) { Text('子任务') .fontSize(FbMetric.fontSmall) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) if (this.kids().length > 0) { Text(this.kidDoneCount().toString() + '/' + this.kids().length.toString()) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) } Blank() } .width('100%') .padding({ top: 12 }) .alignItems(VerticalAlign.Center) ForEach(this.kids(), (k: FbTaskKid) => { Row({ space: 8 }) { Stack({ alignContent: Alignment.Center }) { FbCheckCircle({ dia: 14, done: k.status === 'done', doing: k.status === 'doing', color: this.accent }) } .width(28) .height(28) .onClick((): void => { this.toggleKid(k); }) Text(k.title) .fontSize(FbMetric.fontSmall) .fontColor(k.status === 'done' ? FbColors.faint : FbColors.ink) .decoration({ type: k.status === 'done' ? TextDecorationType.LineThrough : TextDecorationType.None }) .layoutWeight(1) .maxLines(2) } .width('100%') .alignItems(VerticalAlign.Center) }, (k: FbTaskKid): string => 'pk_' + k.id.toString()) /* 加一条:输入框 + 「加」 */ Row({ space: 8 }) { TextInput({ text: this.kidDraft, 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 }) .enterKeyType(EnterKeyType.Done) .onChange((v: string): void => { this.kidDraft = v; }) .onSubmit((): void => { this.addKid(); }) Text('加') .fontSize(FbMetric.fontSmall) .fontColor(this.kidDraft.trim() === '' ? FbColors.faint : this.accent) .textAlign(TextAlign.Center) .width(52) .height(38) .backgroundColor(fbAlpha(this.accent, 0.1)) .borderRadius(FbMetric.radiusS) .onClick((): void => { this.addKid(); }) } .width('100%') .alignItems(VerticalAlign.Center) } .width('100%') .alignItems(HorizontalAlign.Start) } /** 附件:缩略图/文件行 + 加图片 */ @Builder attachSection() { Column({ space: 8 }) { Row({ space: 6 }) { Text('附件') .fontSize(FbMetric.fontSmall) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) if (this.attachs().length > 0) { Text(this.attachs().length.toString()) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) } Blank() Text(this.uploading ? '上传中…' : '+ 加图片') .fontSize(FbMetric.fontTiny) .fontColor(this.uploading ? FbColors.faint : this.accent) .padding({ left: 10, right: 10, top: 6, bottom: 6 }) .backgroundColor(fbAlpha(this.accent, 0.1)) .borderRadius(FbMetric.radiusPill) .onClick((): void => { this.pickAttach(); }) } .width('100%') .padding({ top: 12 }) .alignItems(VerticalAlign.Center) if (this.attachs().length > 0) { /* `Flex wrap` 而不是 `Row`:数量不定且要换行。 🔴 `Row` 在 ArkUI 里**不换行**,塞不下就横向溢出父容器(v76 真机事故)。 */ Flex({ wrap: FlexWrap.Wrap }) { ForEach(this.attachs(), (a: FbTaskAttach) => { this.attachCell(a) }, (a: FbTaskAttach): string => 'pa_' + a.id.toString()) } .width('100%') } } .width('100%') .alignItems(HorizontalAlign.Start) } @Builder attachCell(a: FbTaskAttach) { Stack({ alignContent: Alignment.TopEnd }) { if (a.isImage) { Image(fbImageSrc(a.key, this.token())) .width(64) .height(64) .borderRadius(FbMetric.radiusS) .objectFit(ImageFit.Cover) .backgroundColor(FbColors.inputBg) } else { Column({ space: 2 }) { Text(a.kind.toUpperCase()) .fontSize(FbMetric.fontTiny) .fontColor(this.accent) Text(a.name) .fontSize(9) .fontColor(FbColors.faint) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') .textAlign(TextAlign.Center) } .width(64) .height(64) .justifyContent(FlexAlign.Center) .padding(4) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) } /* 删除角标:热区做大一点(手指点得中),视觉上仍是小圆 */ Stack({ alignContent: Alignment.Center }) { Column() .width(16) .height(16) .borderRadius(8) .backgroundColor('#B3000000') Text('✕') .fontSize(9) .fontColor(Color.White) } .width(28) .height(28) .onClick((): void => { this.dropAttach(a); }) } .width(64) .height(64) .margin({ right: 8, bottom: 8 }) } /** 留言 */ @Builder commentSection() { Column({ space: 8 }) { Row({ space: 6 }) { Text('留言') .fontSize(FbMetric.fontSmall) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) if (this.comments().length > 0) { Text(this.comments().length.toString()) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) } Blank() } .width('100%') .padding({ top: 12 }) .alignItems(VerticalAlign.Center) ForEach(this.comments(), (c: FbTaskComment) => { Row({ space: 8 }) { /* 头像位:一个小色点(端上没拉头像,用底色 + 名字首字最直观) */ Row() { Text(c.name.substring(0, 1)) .fontSize(10) .fontColor(this.accent) } .width(22) .height(22) .borderRadius(11) .backgroundColor(c.color !== '' ? c.color : FbColors.inputBg) .justifyContent(FlexAlign.Center) Column({ space: 2 }) { Row({ space: 6 }) { Text(c.name) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) Text(c.createdAt) .fontSize(9) .fontColor(FbColors.faint) } .width('100%') .alignItems(VerticalAlign.Center) Text(c.content) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .width('100%') } .layoutWeight(1) .alignItems(HorizontalAlign.Start) } .width('100%') .padding({ top: 4, bottom: 4 }) .alignItems(VerticalAlign.Top) }, (c: FbTaskComment): string => 'pc_' + c.id.toString()) Row({ space: 8 }) { TextInput({ text: this.cmtDraft, 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 }) .enterKeyType(EnterKeyType.Send) .onChange((v: string): void => { this.cmtDraft = v; }) .onSubmit((): void => { this.sendComment(); }) Text('发送') .fontSize(FbMetric.fontSmall) .fontColor(this.cmtDraft.trim() === '' ? FbColors.faint : this.accent) .textAlign(TextAlign.Center) .width(56) .height(38) .backgroundColor(fbAlpha(this.accent, 0.1)) .borderRadius(FbMetric.radiusS) .onClick((): void => { this.sendComment(); }) } .width('100%') .alignItems(VerticalAlign.Center) } .width('100%') .alignItems(HorizontalAlign.Start) } /** 底部:编辑 / 删除 */ @Builder footBar() { Row({ space: 10 }) { Text('编辑') .fontSize(FbMetric.fontSmall) .fontColor(Color.White) .textAlign(TextAlign.Center) .layoutWeight(1) .height(42) .backgroundColor(this.busy ? '#C9CDD2' : this.accent) .borderRadius(FbMetric.radiusS) .onClick((): void => { if (this.busy) { return; } const d: FbTaskDetail | null = this.detail; if (d === null) { this.note = '还在载入,稍等一下再点编辑。'; return; } this.onEdit(d); }) Text(this.confirmDel ? '确定删掉' : '删除') .fontSize(FbMetric.fontSmall) .fontColor(this.confirmDel ? Color.White : '#C0533F') .textAlign(TextAlign.Center) .width(88) .height(42) .backgroundColor(this.confirmDel ? '#C0533F' : '#FDECEC') .borderRadius(FbMetric.radiusS) .onClick((): void => { this.dropTask(); }) } .width('100%') .margin({ top: 12 }) } }