Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbTaskPanel.ets
T

1688 lines
58 KiB
Plaintext
Raw Normal View History

/**
* 家书 FamilyBook · 任务面板(鸿蒙端 v79)
*
* 🔴 用户 2026-10-22 原话:
* 「鸿蒙端的功能,也需要同样适配,在首页上可以直接修改编辑任务,
* 另外任务模块的功能需要完善。」
*
* 对应网页 v78 的「就地处理面板」(`templates/tasks/_panel.php`):
* 首页点一条任务 / 任务页点一张卡 —— 都在**当前页面**弹出这块面板,
* 不跳模块、不跳整页,就能:
* · 改状态(待办 / 进行中 / 已完成)
* · 挪分组(换看板)
* · 勾子任务、加一条子任务
* · 加图片附件、删附件
* · 发留言
* · 打开编辑表单(标题/备注/期限/看板/负责人/分类/循环)
* · 删任务(二次确认)
*
* ⚠️ 为什么不直接复用 `FbTaskEditSheet`:那是**表单**(点开就在编辑)。
* 用户此刻多数是"看看它是什么、顺手改一下状态"。拿表单当详情,
* 误触一下就把标题改了(v73 的取舍,见 `FbTaskDetailSheet` 文件头)。
* ⇒ 面板只读为主 + 两颗明确的动作(编辑 / 删除),编辑才进表单。
*
* 🔴 三条纪律:
* ① 改状态**只走** `POST /tasks/<id>/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, FbTaskShare,
} from '../model/Types';
import { FbSheetRing } from './FbSheetRing';
import { FbSheetClose } from './FbSheetClose';
import { FbCheckCircle, FbAvatar } 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,
} 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;
/* ---- #77:就地编辑(截止 / 负责人 / 备注)----
🔴 用户 2026-11-05:「截止日期需要可以修改」「负责人可以修改,备注也要修改」
为什么要就地改而不是"点编辑进整页表单":
这个面板本来就是"点开就能改"的定位(状态/看板/子任务/留言/附件都能改),
只有这三项是只读的 —— 用户改个日期要跳到另一个页面,那才叫别扭。
⚠️ 每次改完**立刻保存**,不设「保存」按钮:与状态/看板/子任务/留言同一套
交互(那几处都是点一下就生效)。这里多一颗按钮反而不一致。 */
@Local duePickOpen: boolean = false;
@Local attPickOpen: boolean = false;
@Local noteEdit: boolean = false;
@Local noteDraft: string = '';
@Local inlineBusy: boolean = false;
/* #77c:附件预览浮层 —— 存当前要全屏看的那张图的地址,空 = 没开 */
@Local previewSrc: string = '';
@Local previewName: string = '';
// 共享(工单 #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<void> {
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();
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<void> {
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<void> {
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<void> {
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<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);
}
/**
* 点附件(#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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();
}
/** 选一张图当附件(选完**立刻上传** —— 任务已经存在,不用再点保存) */
/**
* 选一个附件上传(#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.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<void> {
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<void> {
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.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 })
}
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%')
/* 选人浮层:协作人(工单 #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)
}
/** 标题行:状态圈(点=勾完成)+ 标题 + 状态徽标 + 关闭 */
@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(挪分组)
*
* 🔴 #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)
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 已经高亮表达了);
* 负责人前面加头像。
* ⚠️ 其余(分类 / 循环)**保持不变** —— 用户没提,不动。
*/
@Builder
infoRows() {
Column({ space: 0 }) {
/* ---- 截止:可点,弹 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)
}
/* 可编辑的视觉提示:一支小笔。没有它用户不知道这里能点
(本项目反复踩过"纯文字看着不可点"的坑)。 */
Text('✎')
.fontSize(FbMetric.fontTiny)
.fontColor(this.inlineBusy ? FbColors.faint : this.accent)
}
.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())
}
/* ---- 备注:可编辑 ---- */
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 }) {
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)
}
/** 共享给谁(工单 #26):列表 + 共享入口 + 逐条取消 */
@Builder
shareSection() {
Column({ space: 6 }) {
Row({ space: 6 }) {
/* #77(用户 2026-11-05):「共享给谁这个地方都和网页端一样,都改成协作人吧。」 */
Text('协作人')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
if (this.shares().length > 0) {
Text(this.shares().length.toString())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
Blank()
Text(this.shareBusy ? '处理中…' : '+ 共享')
.fontSize(FbMetric.fontTiny)
.fontColor(this.shareBusy ? 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.openSharePicker();
})
}
.width('100%')
.padding({ top: 12 })
.alignItems(VerticalAlign.Center)
if (this.shares().length === 0) {
Text('还没共享给谁;共享后对方能在任务里看到它,权限分「可改 / 只看」')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
} else {
ForEach(this.shares(), (s: FbTaskShare) => {
Row({ space: 8 }) {
Text(s.name)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(s.perm === 'read' ? '只看' : '可改')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text('取消')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.padding({ left: 6, right: 2, top: 2, bottom: 2 })
.onClick((): void => {
this.dropShare(s.personId);
})
}
.width('100%')
.padding({ top: 6, bottom: 6 })
.alignItems(VerticalAlign.Center)
}, (s: FbTaskShare): string => 'tsh_' + s.personId.toString())
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/** 子任务:列表 + 加一条 */
@Builder
kidSection() {
Column({ space: 6 }) {
Row({ space: 6 }) {
Text('子任务')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(this.accent)
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() + '_' + k.status)
/* 加一条:输入框 + 「加」 */
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(this.accent)
if (this.attachs().length > 0) {
Text(this.attachs().length.toString())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
Blank()
/* #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 })
.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 })
/* #77c:点缩略图 → 图片全屏预览 / 文件交给系统应用打开 */
.onClick((): void => {
this.tapAttach(a);
})
}
/** 留言 */
@Builder
commentSection() {
Column({ space: 8 }) {
Row({ space: 6 }) {
/* #77(用户 2026-11-05):「留言那个地方需要是图标。」
⇒ 标题前加一个对话气泡图标,与「附件」「协作人」几个小节拉开辨识度。 */
Text('💬')
.fontSize(FbMetric.fontSmall)
Text('留言')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(this.accent)
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 }) {
/* 🔴 #77e(用户 2026-11-05):「留言里面的人,前面还是没有头像,需要补充。」
⇒ 原来这里画的是"名字首字色块"(当时端上确实没拉头像)。
现在 `FbTaskComment.avatar` 有了(服务端 SQL 一直返回 p.avatar,
端上原来没解析),换成 `FbAvatar`:
有头像图用图、没设头像才退回首字色块。
⚠️ 用公共 `FbAvatar` 而不是自己拼 `Image`:全站头像的口径
(尺寸/圆角/兜底色)都收在那一个组件里,自己拼迟早不一致。 */
FbAvatar({
name: c.name,
color: c.color !== '' ? c.color : '#A84A28',
dia: 24,
fontSizeVp: 10,
imageUrl: c.avatar !== ''
? fbImageSrc(c.avatar, this.token()) : ''
})
Column({ space: 2 }) {
Row({ space: 6 }) {
/* 🔴 #77e(用户 2026-11-05):「根据我们之前样式的规定,
它的标题和里面的文字,应该按照现在这个模块的主题色加粗显示。」
⇒ 留言人名用**模块主题色 + 加粗**(原来是 muted 灰、常规字重)。
这是全站「小节标题/发言人以模块色标识」的既有规矩
(观影用紫、旅程用绿、任务用赭红…),这里原来漏了。 */
Text(c.name)
.fontSize(FbMetric.fontTiny)
.fontWeight(FontWeight.Medium)
.fontColor(this.accent)
Text(c.createdAt)
.fontSize(9)
.fontColor(FbColors.faint)
}
.width('100%')
.alignItems(VerticalAlign.Center)
/* 正文也按主题色加粗?——**不**。
用户说的是"标题和里面的文字",但正文若整段主题色,
简明度会掉(色彩是需要留白的)。
⇒ 名字(=发言标题)主题色加粗;正文保持 ink 正文色。
这条取舍写在 commit 里,若用户要求正文也改色,一行就能调。 */
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 }) {
/* #77(用户 2026-11-05):「下面说一句:回车发送」
⇒ 从 placeholder 里挪出来,单独一行说明。
原来塞在 placeholder 里有两个问题:
① 一开始打字就看不见了;
② placeholder 该放"这里填什么",不该放操作提示。 */
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();
})
/* #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)
.constraintSize({ minWidth: 60 })
.padding({ left: 14, right: 14 })
.height(38)
.backgroundColor(fbAlpha(this.accent, 0.1))
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.sendComment();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
/* 「回车发送」提示:放在输入行**下方**,小字、不抢视线 */
Text('回车发送')
.fontSize(10)
.fontColor(FbColors.faint)
.width('100%')
.padding({ top: 2 })
}
.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 })
}
}