Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbTaskPanel.ets
T
laoyang d9ca9dbe31 下拉框与网页端对齐(照 FeedLite 那套观感)(#86)
用户 2026-11-05:「好了,鸿蒙端也用类似的方式调,效果让它保持一致。」

端上**不需要像网页那样手写浮层** —— 网页端必须自绘是因为
「原生 select 的展开列表由浏览器绘制,CSS 改不动」;
ArkUI 的 Select 本身就是自绘的、选项列表也归框架管。
⇒ 把对应属性都设上就等价了:

  网页端                          ArkUI Select
  ──────────────────────────────────────────────────────
  按钮 40 高 / 圆角 / 细边框       height(40) + borderRadius + border
  箭头打开转 180°                 框架内置(自带动画)
  悬停 tint-1                     不支持(触屏没有 hover)→ 用打开态代替
  选中项底 tint-2                 selectedOptionBgColor(fbMixWhite(accent,0.80))
  选中项文字 主题色                selectedOptionFontColor(accent)
  选中项右侧对勾                   showDefaultSelectedIcon(true)
  浮层白底 + 细边框               menuBackgroundColor(White) + border
  浮层与按钮同宽                   optionWidth(OptionWidthMode.FIT_TRIGGER)
  未选值次要色                     fontColor(有值 ? accent : Faint)

① Theme 新增 fbMixWhite(hex, ratio)
   与网页端 fb_color_tint() **同算法同比例**(混 91/80/72% 白)。
   ⚠️ 为什么不用现成的 fbAlpha():那是「原色 + 透明度」,
      会透出父容器底色 —— 下拉浮层背景不确定,那样会时深时浅;
      这里是实色混合,颜色确定。
   ⚠️ 实测 16 组(4 个主题色 × 4 档比例)与 PHP 输出逐个比对,全部一致。

② 边框单独用一档
   按钮边框 fbMixWhite(accent, 0.62) —— 比 tint-3 再深一点,
   否则在 tint-1 的浅底上几乎看不见边界。

③ 修一个类型坑
   本来写了个 pickFontColor(): string 返回 accent / FbColors.muted,
   但 FbColors.muted 是 Resource($r(...)),返回 string | Resource
   会让 fontColor() 报「Type 'string | Resource' is not assignable to 'string'」。
   ⇒ 改成内联三元,且未选用 FbColors.faint(那个是 string 常量)。

编译通过,已装真机。
2026-11-05 20:25:59 +08:00

2335 lines
92 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 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, 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<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;
}
/* #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<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;
}
/**
* 保存任务标题。
*
* 🔴 #77n:标题改成**常驻输入框**后不再有 `titleEdit` 这个双态标记,
* 所以只靠"值变了没有"判断要不要发请求。
* ⚠️ 空值必须**回填原值**:服务端会把空标题清洗成空串,
* 那等于把标题删了 —— 用户只是清空重打,不该丢标题。
*/
/**
* 底部「保存」:把两个**手动编辑**的输入框(标题 / 备注)落库,然后关窗。
*
* 🔴 #77q(用户 2026-11-05):「和网页版一样,没有保存按钮啊,
* 这应该怎么保存呢?在哪个地方点保存呢?」
*
* 之前的设计是"失焦自动保存",但**用户看不见这件事** —— 没有按钮就没底,
* 不知道到底存没存。所以补一颗显式的保存按钮。
*
* ⚠️ 其它字段(状态/看板/截止/负责人/分类/协作人/子任务/附件/留言)
* **仍然是点一下就生效**,不用按保存 —— 那类操作是"动作",
* 做完就该立刻生效;只有"打字"类才需要一次确认。
* ⚠️ 串行两次请求而不是并发:`saveField()` 内部会 `reload()`,
* 并发会让两次 reload 互相覆盖 detail,出现"改完标题备注没了"。
*/
private async saveAll(): Promise<void> {
if (this.saving) {
return;
}
this.saving = true;
await this.saveTitle();
await this.saveNote();
this.saving = false;
this.onChanged();
this.onClose();
}
private async saveTitle(): Promise<void> {
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<void> {
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<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();
}
/** 选一个附件挂到待发留言上(#77g)——**不立刻上传**,随留言一起发 */
private async pickCmtAttach(): Promise<void> {
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<void> {
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<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 }) {
/* #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 做成"自绘按钮 + 浮层列表"(原生 <select> 的
展开列表 CSS 改不动)。端上**没有那个限制** ——
ArkUI 的 `Select` 本身就是自绘的,选项列表也归框架管,
所以不用再自己写浮层,把对应的属性都设上就等价了:
网页端 ArkUI Select
─────────────────────────────────────────────────
按钮 40 高 / 圆角 / 细边框 height(40) + borderRadius + border
箭头打开转 180° 框架内置(有动画)
悬停 tint-1 不支持 → 用打开态代替
选中项底 tint-2 selectedOptionBgColor
选中项文字 主题色 selectedOptionFontColor
选中项右侧对勾 showDefaultSelectedIcon(true)
浮层白底 + 细边框 + 圆角 menuBackgroundColor + menuOutline
浮层与按钮同宽 optionWidth(FIT_TRIGGER)
⚠️ 底色用 `fbMixWhite(accent, 0.91)` 而**不是** `fbAlpha`:
那个是"原色+透明度"(会透出父容器底色,浮层背景不确定
就会时深时浅);这个是与网页端同算法的实色混合。
⚠️ 未选值(「未分类」/「不循环」)文字用 muted 次要色,
与网页端 `.sel-btn.is-placeholder` 一致。 */
Select(this.catOptions())
.selected(this.catIndex())
.value(this.categoryOf() !== '' ? this.categoryOf() : '未分类')
.font({ size: FbMetric.fontSmall, weight: FontWeight.Medium })
/* #86:有值用主题色、未选用次要灰(占位不该和真值一样显眼) */
.fontColor(this.categoryOf() !== '' ? this.accent : FbColors.faint)
.selectedOptionFont({ size: FbMetric.fontSmall, weight: FontWeight.Medium })
.selectedOptionFontColor(this.accent)
.selectedOptionBgColor(fbMixWhite(this.accent, 0.80))
.optionFont({ size: FbMetric.fontSmall })
.optionFontColor(FbColors.ink)
.optionBgColor(Color.White)
.showDefaultSelectedIcon(true)
.optionWidth(OptionWidthMode.FIT_TRIGGER)
.menuBackgroundColor(Color.White)
.width('100%')
.height(40)
.padding({ left: 12, right: 12 })
.backgroundColor(fbMixWhite(this.accent, 0.91))
.borderRadius(FbMetric.radiusS)
.border({ width: 1, color: fbMixWhite(this.accent, 0.62) })
.onSelect((idx: number): void => {
/* index 0 = 「未分类」→ category_id 传 0 */
const cid: number = idx === 0 ? 0
: (idx - 1 < this.cats.length ? this.cats[idx - 1].id : 0);
this.saveField('category_id', '', cid);
})
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
/* ---- 循环 ---- */
Column({ space: 6 }) {
this.fieldLb('循环')
if (this.inlineBusy) {
Text(this.repeatOf() !== '' ? this.repeatOf() : '不循环')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.width('100%')
.height(40)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
} else {
/* #86:与分类完全同一套(见上面那段的对照表) */
Select(this.repeatOptions())
.selected(this.repeatIndex())
.value(this.repeatOf() !== '' ? this.repeatWordOf(this.repeatOf()) : '不循环')
.font({ size: FbMetric.fontSmall, weight: FontWeight.Medium })
.fontColor(this.repeatOf() !== '' ? this.accent : FbColors.faint)
.selectedOptionFont({ size: FbMetric.fontSmall, weight: FontWeight.Medium })
.selectedOptionFontColor(this.accent)
.selectedOptionBgColor(fbMixWhite(this.accent, 0.80))
.optionFont({ size: FbMetric.fontSmall })
.optionFontColor(FbColors.ink)
.optionBgColor(Color.White)
.showDefaultSelectedIcon(true)
.optionWidth(OptionWidthMode.FIT_TRIGGER)
.menuBackgroundColor(Color.White)
.width('100%')
.height(40)
.padding({ left: 12, right: 12 })
.backgroundColor(fbMixWhite(this.accent, 0.91))
.borderRadius(FbMetric.radiusS)
.border({ width: 1, color: fbMixWhite(this.accent, 0.62) })
.onSelect((idx: number): void => {
const rk: string = idx === 0 ? '' : FbTaskPanel.REPEAT_KEYS[idx - 1];
this.saveField('repeat_rule', rk, -1);
})
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
/* ---- 备注 ---- */
/* 🔴 #77n(用户 2026-11-05):「包括下面的备注也是一样」
⇒ 与任务标题同处理:**常驻输入框**,不做"点一下才变输入框"。
标签(「备注」两字)= 主题色;框里内容 = 墨黑。 */
Column({ space: 6 }) {
this.fieldLb('备注')
TextArea({ text: this.noteDraft, placeholder: '补充说明…' })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontSmall })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.width('100%')
.height(84)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.onChange((v: string): void => {
this.noteDraft = v;
})
.onFocus((): void => {
this.noteFocus = true;
})
.onBlur((): void => {
this.noteFocus = false;
this.saveNote();
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding({ top: 4 })
}
/* ---------------- 分类 / 循环 的下拉选项 ---------------- */
/**
* 循环的可选键(与编辑表单 `FbTaskEditSheet.repeatChip` 同一套)。
* ⚠️ 顺序必须与 `REPEAT_WORDS` 严格对齐 —— 两边错一位就会"选每天存成每周"。
*/
private static readonly REPEAT_KEYS: string[] = ['day', 'week', 'month', 'year'];
private static readonly REPEAT_WORDS: string[] = ['每天', '每周', '每月', '每年'];
/** 分类下拉的选项文本:第 0 项固定是「未分类」(对应 category_id = 0) */
private catOptions(): SelectOption[] {
const out: SelectOption[] = [{ value: '未分类' }];
for (let i = 0; i < this.cats.length; i++) {
out.push({ value: this.cats[i].name });
}
return out;
}
/** 当前选中的下标(0 = 未分类) */
private catIndex(): number {
const d: FbTaskDetail | null = this.detail;
const cid: number = d !== null ? d.categoryId : 0;
if (cid <= 0) {
return 0;
}
for (let i = 0; i < this.cats.length; i++) {
if (this.cats[i].id === cid) {
return i + 1;
}
}
return 0; // 分类被删了/不在这份清单里 → 显示未分类
}
private repeatOptions(): SelectOption[] {
const out: SelectOption[] = [{ value: '不循环' }];
for (let i = 0; i < FbTaskPanel.REPEAT_WORDS.length; i++) {
out.push({ value: FbTaskPanel.REPEAT_WORDS[i] });
}
return out;
}
private repeatIndex(): number {
const r: string = this.repeatOf();
for (let i = 0; i < FbTaskPanel.REPEAT_KEYS.length; i++) {
if (FbTaskPanel.REPEAT_KEYS[i] === r) {
return i + 1;
}
}
return 0;
}
private repeatWordOf(key: string): string {
for (let i = 0; i < FbTaskPanel.REPEAT_KEYS.length; i++) {
if (FbTaskPanel.REPEAT_KEYS[i] === key) {
return FbTaskPanel.REPEAT_WORDS[i];
}
}
return '不循环';
}
/** 截止时间的时分(`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()) : '';
}
/**
* 「更多信息」里的一行字段标题(截止 / 负责人 / 备注 / 分类 / 循环)。
*
* 🔴 #77j(用户 2026-11-05):「截止、负责人、备注没改 ——
* 这几个字的颜色要变主题色粗体。」
*
* ⚠️ 这与「编辑表单」的字段标签口径**不同**,是用户明确要求的:
* 整页编辑表单里 `期限` 那种是 `fontTiny + faint`(浅灰小字),
* 但这个详情面板里用户希望它们**和区块标题一个观感**
* (主题色 + Bold)—— 因为它和「子任务/附件/留言」那些标题同属一个视觉层级,
* 只有它浅灰会显得"这两块不是一回事"。
*
* ⚠️ 不走 `FbSectionTitle` 组件:那个带 `width('100%')` +
* `padding({top:12,bottom:8})`,而这里要的是"44vp 宽的行内标签",
* 塞进去会把右侧控件顶到下一行。只**取它的三个核心属性**
* (Bold / accent / letterSpacing 0.6),宽度与内边距按行内标签给。
*/
@Builder
fieldLb(t: string) {
/* 🔴 #77m(用户 2026-11-05):「状态、分组、看板、任务标题、截止、负责人、
备注、协作人、子任务、附件、留言这些字的大小要保持统一。」
⇒ 标签字号统一走 `fontSmall`(13),**与 `FbSectionTitle` 完全一致**
(那个组件内部就是 `fontSize(FbMetric.fontSmall)`)。
上一版这里是 fontTiny(11),于是"截止"比"状态"小一号,看着不成套。
⚠️ 宽度不再写 48:标签独占一行(控件在它下面),
固定宽度会让它只占左边一小段,与下面的整行控件左缘对不齐。 */
Text(t)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(this.accent)
.letterSpacing(0.6)
.width('100%')
.textAlign(TextAlign.Start)
}
@Builder
kvRow(label: string, value: string) {
/* #77k:与截止/负责人/备注同形态(标签一行、值一行),
否则「分类」「循环」还是左右排,夹在中间会显得错位。 */
Column({ space: 6 }) {
this.fieldLb(label)
Text(value)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.width('100%')
.padding({ left: 12, right: 12, top: 11, bottom: 11 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/** 共享给谁(工单 #26):列表 + 共享入口 + 逐条取消 */
@Builder
shareSection() {
Column({ space: 6 }) {
/* #77(用户 2026-11-05):「共享给谁这个地方都和网页端一样,都改成协作人吧。」
#77i:标题按规范走 `FbSectionTitle`(Bold + 主题色 + 字距)。 */
FbSectionTitle({
title: '协作人',
accent: this.accent,
extra: this.shares().length > 0 ? this.shares().length.toString() : ''
})
Row({ space: 6 }) {
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 }) {
/* #77h:`FbSectionTitle` 是 `width('100%')` 的独立组件,
必须**独占一行**(塞进 Row 会和后面的计数/按钮抢宽度,被压成 0 宽)。
「已完成 2/5」这种附加信息走它自带的 `extra`。 */
FbSectionTitle({
title: '子任务',
accent: this.accent,
extra: this.kids().length > 0
? this.kidDoneCount().toString() + '/' + this.kids().length.toString()
: ''
})
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 }) {
/* 🔴 #77h(用户 2026-11-05:「样式还没变,还记得咱们之前规定的样式是怎么做的吗?」)
规定就在 `Widgets.ets` 的 `FbSectionTitle` 上(它的注释里还留着用户原话):
「"今天的日程",既然是日程,标题颜色就应该用日程的主题色。
…下面的"近期任务"改法跟上面一样,还有"最近纪念日"也不说了,全都是一样的。」
⇒ 区块标题**必须走 `FbSectionTitle`**,不能手写 `Text`:
它给了 **Bold + letterSpacing(0.6) + accent 色** 三样,
而手写 Text 我只改了 fontColor,字重和字距都还是老样子
—— 这就是"样式还没变"的原因。
⚠️ `FbSectionTitle` 自带 `padding({top:12,bottom:8})` 且 `width('100%')`,
所以它必须**独立成行**,不能塞进原来那个 Row 里和按钮并排(会被挤掉宽度)。
⇒ 把「标题 + 计数」交给组件,按钮单独另起一行右对齐(见下)。 */
FbSectionTitle({
title: '附件',
accent: this.accent,
extra: this.attachs().length > 0 ? this.attachs().length.toString() : ''
})
Row() {
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 }) {
/* #77h:留言标题同样独立成行。
图标(#77「留言那个地方需要是图标」)放在标题组件**外面**、
用 Row 包一层 —— 组件本身不接受前插图。 */
Row({ space: 4 }) {
Text('💬')
.fontSize(FbMetric.fontSmall)
Column() {
FbSectionTitle({
title: '留言',
accent: this.accent,
extra: this.comments().length > 0 ? this.comments().length.toString() : ''
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.alignItems(VerticalAlign.Center)
ForEach(this.comments(), (c: FbTaskComment) => {
Row({ space: 8 }) {
/* 🔴 #77e(用户 2026-11-05):「留言里面的人,前面还是没有头像,需要补充。」
⇒ 原来这里画的是"名字首字色块"(当时端上确实没拉头像)。
现在 `FbTaskComment.avatar` 有了(服务端 SQL 一直返回 p.avatar,
端上原来没解析),换成 `FbAvatar`:
有头像图用图、没设头像才退回首字色块。
⚠️ 用公共 `FbAvatar` 而不是自己拼 `Image`:全站头像的口径
(尺寸/圆角/兜底色)都收在那一个组件里,自己拼迟早不一致。 */
/* #77h:头像已由 `FbPersonChip` 内置(它会画 `FbAvatar`),
这里不再自己画一个 —— 否则同一条留言前面出现两个头像。 */
Column({ space: 2 }) {
Row({ space: 6 }) {
/* 🔴 #77h:留言人走规范组件 `FbPersonChip`。
网页端留言人用的就是 `person_chip()`(头像 + 名字的胶囊),
鸿蒙端对应的是 `FbPersonChip` —— 上一轮我手写的
「头像 + 纯文字名字」只是长得像,不是同一套。
⚠️ `FbPersonChip` 自带 `FbAvatar`(dia 18)+ inputBg 胶囊底,
所以外面那个 24vp 的 FbAvatar 要去掉,否则头像画两遍。 */
/* #77i:把头像 URL 传进去 —— `FbPersonChip` 之前不透传 `imageUrl`,
所以换成它之后头像就只剩首字色块了(那个参数我刚在 Widgets.ets 补上)。 */
FbPersonChip({
name: c.name,
color: c.color !== '' ? c.color : '#A84A28',
imageUrl: c.avatar !== '' ? fbImageSrc(c.avatar, this.token()) : ''
})
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)
/* 🔴 #77g(用户 2026-11-05):「留言是可以需要添加附件的。」
服务端**早就支持** —— `tasks/comment_add` 里一直有
`attach_save_files($familyId, 'task_comment', $cid, 'attach', $me)`
(网页端那个表单也已经有 file input),只是**鸿蒙端从来没传过附件**。
⇒ `addTaskComment()` 目前只发 JSON(content 一个字段),
要带附件必须改成 multipart 上传 —— 见 FbTaskRepo 的改动。
⚠️ 交互设计:附件是"随这条留言一起发出",不是立刻上传
(与「任务附件」那块不同 —— 那个传完就生效)。
所以选中的文件先挂在本地,点「发送」时和文字一起提交。
⚠️ 选中的文件名要显示出来,否则用户不知道有没有选上。 */
if (this.cmtAttachNames.length > 0) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.cmtAttachNames, (n: string, i: number) => {
Row({ space: 4 }) {
Text('📎')
.fontSize(9)
Text(n)
.fontSize(9)
.fontColor(FbColors.muted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 120 })
Text('✕')
.fontSize(9)
.fontColor(FbColors.faint)
.onClick((): void => {
this.dropCmtAttach(i);
})
}
.padding({ left: 7, right: 7, top: 3, bottom: 3 })
.margin({ right: 6, bottom: 4 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.alignItems(VerticalAlign.Center)
}, (n: string, i: number): string => 'ca_' + i.toString() + '_' + n)
}
.width('100%')
.padding({ top: 6 })
}
/* 「回车发送」+「添加附件」同一行:一个说明、一个动作 */
Row({ space: 8 }) {
Text('回车发送')
.fontSize(10)
.fontColor(FbColors.faint)
.layoutWeight(1)
Text(this.cmtBusy ? '发送中…' : '+ 添加附件')
.fontSize(FbMetric.fontTiny)
.fontColor(this.cmtBusy ? FbColors.faint : this.accent)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor(fbAlpha(this.accent, 0.10))
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
this.pickCmtAttach();
})
}
.width('100%')
.padding({ top: 6 })
.alignItems(VerticalAlign.Center)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/**
* 危险操作区:删除任务 —— 放在**滚动内容的最底部**。
*
* 🔴🔴 #77s(用户 2026-11-05):「删除按钮不能出现在那里,
* 因为这样太容易误触了。删除按钮应该放在哪里?你考虑考虑。」
*
* 原来它和「保存」并排钉在底栏,两个问题:
* ① **误触**:底栏是"保存"这种高频操作的位置,手一滑就点到删除,
* 而删除**不可恢复**,一次误触 = 数据没了。
* ② **语义错位**:底栏是"我改完了、要留下这个结果"的确认区,
* 不该混进"我要毁掉它"。
*
* 所以放到**滚动内容最末尾**:
* · 用户得主动滚到最底才看得见 —— 低频操作不必首屏可见;
* · 纯文字、无填充底色,不像按钮,不招手指;
* · 与正文隔一条分割线,视觉上属于"其它",不属于"操作";
* · 仍然保留**二次确认**(第一下变成「确定删掉」+「取消」)。
*
* ⚠️ 这是通行做法(Notion / 备忘录 / 各种设置页的删除都在最底部),
* 不是在发明新交互。
* ⚠️ 不放顶栏:顶栏是"知道我在哪 / 怎么退出"的位置,
* 在那里放删除,误触代价同样不可恢复。
* ⚠️ 不用红底大白按钮:那种视觉权重会把人引过去,与"低频 + 危险"正相反。
*/
@Builder
dangerZone() {
Column({ space: 0 }) {
Divider()
.color(FbColors.line)
.margin({ top: 18, bottom: 14 })
if (this.confirmDel) {
/* 第一次已点:写清后果 + 两个出口 */
Text('删了就找不回来了,确定吗?')
.fontSize(FbMetric.fontTiny)
.fontColor('#C0533F')
.width('100%')
.margin({ bottom: 8 })
Row({ space: 10 }) {
Text('确定删掉')
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(40)
.backgroundColor('#C0533F')
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.dropTask();
})
/* 「取消」必须有:误点进来要能退回去,
否则只剩"删"和"点别处"两个选择,后者不明显。 */
Text('取消')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(40)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.confirmDel = false;
this.note = '';
})
}
.width('100%')
} else {
/* 常态:一行小字,居中,不像按钮 */
Text('删除这个任务')
.fontSize(FbMetric.fontTiny)
.fontColor('#C0533F')
.textAlign(TextAlign.Center)
.width('100%')
.padding({ top: 10, bottom: 10 })
.onClick((): void => {
/* 第一下只进确认态,不删 */
this.confirmDel = true;
})
}
}
.width('100%')
}
/** 底部:编辑 / 删除 */
@Builder
footBar() {
Row({ space: 10 }) {
/* 🔴🔴 #77q(用户 2026-11-05):「和网页版一样,没有保存按钮啊,
这应该怎么保存呢?在哪个地方点保存呢?」
原来这里是「编辑」—— 但面板本身已经能改所有字段(标题/备注/截止/
负责人/分类/协作人/子任务/附件/留言),再给一颗"编辑"是自相矛盾的
(点它反而跳到另一张表单)。
⇒ 换成**「保存」**:把"打字"类的两项(标题、备注)落库然后关窗。
与网页端保存后关浮层的行为一致。
⚠️ 状态/看板/截止/负责人这些**不用点保存**,选中即生效 ——
它们是"动作"不是"草稿",多一步确认反而烦。 */
Text(this.saving ? '保存中…' : '保存')
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
.backgroundColor(this.saving ? '#C9CDD2' : this.accent)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
if (this.saving) {
return;
}
this.saveAll();
})
/* 🔴🔴 #77s(用户 2026-11-05):「删除按钮不能出现在那里,
因为这样太容易误触了。」
⇒ 从底栏摘掉,移到滚动内容最末尾的 dangerZone()。
底栏只留全宽的「保存」—— 它是"确认改动"的位置,
不该混进"毁掉数据"的入口。 */
}
.width('100%')
.margin({ top: 12 })
/* #77r:底栏**不许被压缩** —— Scroll 已经 layoutWeight(1) 拿剩余空间,
这里再显式 flexShrink(0) 兜一层,防止某些机型上把 42 高的按钮压扁。
(按钮整个看不见那次,就是没有这层保护、整条栏被挤到 86% 之外。) */
.flexShrink(0)
}
}