/** * 家书 FamilyBook · 「新建 / 编辑任务」浮层(鸿蒙端) * * 对应网页版 `templates/tasks/_edit_form.php`。 * * 🔴 用户 2026-10-20:「新建任务、设置这些东西都这都没有。」 * 端上「新建任务」按钮原来**没有 `onTap`**,列表项也没有 `onClick`。 * * ⚠️ 编辑态**不显示状态选择**:状态只走 `/tasks//status` 子动作 * (服务端 `PATCH` 会忽略请求里的 status,见 `FbTaskRepo` 文件头)。 * 在这里放个状态选择器 → 用户改完保存 → 状态没变 ⇒ 典型的"看起来成功了"。 * 改成完成请用列表上的那个圈(见 `FbTasksPage` 的 `toggleDone`)。 */ import { FbColors, FbMetric, FB_MASK, fbAlpha, fbTint } from '../common/Theme'; import { FbBoardOpt, FbCatOpt } from '../model/Types'; import { FbSheetClose } from './FbSheetClose'; import { FbSheetRing } from './FbSheetRing'; import { FbDateTimeRow, FbDateTimePicker, fbIsoOf, fbJoinDateTime } from './FbDateTimeField'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { createTask, updateTask, FbTaskDraft, setTaskStatus } from '../data/FbTaskRepo'; import { FbApiResult } from '../data/FbHttp'; @ComponentV2 export struct FbTaskEditSheet { /** 模块主题色(任务 = 赭红) */ @Param accent: string = '#A8574F'; /** 可选的看板(父页面用 `?view=boards` 拿**全部**看板,含空看板) */ @Param boards: FbBoardOpt[] = []; /** 可选的分类(v79;同上,来自 `?view=boards` 的 categories) */ @Param cats: FbCatOpt[] = []; /** 编辑的这条(0 = 新建) */ @Param editingId: number = 0; @Param editingTitle: string = ''; @Param editingNote: string = ''; @Param editingDueAt: string = ''; @Param editingBoardId: number = 0; /** v79:分类 / 循环(编辑时回填) */ @Param editingCatId: number = 0; @Param editingRepeat: string = ''; /** * 编辑时的原状态(`todo` / `doing` / `done`)。 * * 🔴 2026-11-04:没有它的话,编辑一条**已完成**的任务会显示成"待办"(默认值), * 保存时又把 'todo' 发出去 ⇒ **静默把已完成改回待办**。比不做还糟。 */ @Param editingStatus: string = 'todo'; @Event onClose: () => void = (): void => {}; @Event onSaved: () => void = (): void => {}; @Local title: string = ''; @Local note: string = ''; @Local boardId: number = 0; /** v79:分类 id(0 = 不分类) */ @Local catId: number = 0; /** * 任务状态:`todo`(待办)/ `doing`(进行中)/ `done`(已完成)。 * * 🔴 2026-11-04 新增。用户指出:「新建任务这个功能里面,怎么没有选择是待办、 * 正在进行,还是已完成的地方呢?」—— 原来 `save()` 里写死 `'todo'`, * 用户建"进行中"的任务只能先建成待办再回去拖看板。 * * ⚠️ 服务端 `Task::STATUS` 就是这三个值,别自造。 */ @Local status: string = 'todo'; /** 编辑时记住原状态 —— 用于判断"用户改没改",改过才额外打 /status */ @Local status0: string = 'todo'; /** v79:循环规则('' = 不循环) */ @Local repeat: string = ''; /** 截止日期 `YYYY-MM-DD`,空 = 没有期限 */ @Local dueDate: string = ''; /** 截止时分 */ @Local dueTime: string = '18:00'; @Local pickerOpen: boolean = false; @Local saving: boolean = false; @Local note2: string = ''; aboutToAppear(): void { this.title = this.editingTitle; this.note = this.editingNote; this.boardId = this.editingBoardId; this.catId = this.editingCatId; this.repeat = this.editingRepeat; // 状态回填:新建时 editingStatus 是默认 'todo',编辑时是这条的原状态 this.status = this.editingStatus !== '' ? this.editingStatus : 'todo'; this.status0 = this.status; // `2026-10-25 18:00` → 日期 + 时分(服务端给的是空格分隔) const raw: string = this.editingDueAt.trim(); if (raw.length >= 16) { this.dueDate = raw.substring(0, 10); this.dueTime = raw.substring(11, 16); } else if (raw.length >= 10) { this.dueDate = raw.substring(0, 10); } } build() { /* 🔴🔴 根必须 `Stack`(见 pitfalls.md:全屏遮罩不能当 Column 子项排) */ Stack({ alignContent: Alignment.Center }) { Column() .width('100%') .height('100%') .backgroundColor(FB_MASK) .onClick((): void => { this.onClose(); }) Column() { FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) { Column({ space: 12 }) { // ---- 标题栏 ---- Row({ space: 8 }) { Column({ space: 2 }) { Text(this.editingId > 0 ? '编辑任务' : '新建任务') .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) .maxLines(1) Text('写清楚要做什么;期限可以留空') .fontSize(FbMetric.fontTiny) .fontColor(this.accent) .maxLines(1) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .flexShrink(1) FbSheetClose({ onClose: (): void => { this.onClose(); }, color: this.accent }) } .width('100%') .alignItems(VerticalAlign.Center) Scroll() { Column({ space: 12 }) { // ---- 标题 ---- this.textField('做什么', '如「交电费」「给爸配眼镜」', this.title, (v: string): void => { this.title = v; this.note2 = ''; }) // ---- 备注 ---- this.areaField('备注', '想补充的细节(可空)', this.note, (v: string): void => { this.note = v; }) // ---- 截止时间 ---- Column({ space: 6 }) { Row({ space: 8 }) { Text('期限') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .width(28) if (this.dueDate !== '') { Text('不设期限') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) .padding({ left: 8, right: 8, top: 3, bottom: 3 }) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusPill) .onClick((): void => { this.dueDate = ''; }) } else { Column().layoutWeight(1) } } .width('100%') .alignItems(VerticalAlign.Center) if (this.dueDate === '') { Row({ space: 6 }) { Image($r('app.media.fb_ic_plus')) .width(12) .height(12) .renderMode(ImageRenderMode.Template) .colorFilter(fbTint(this.accent)) Text('设一个期限') .fontSize(FbMetric.fontTiny) .fontColor(this.accent) } .padding({ left: 10, right: 12, top: 7, bottom: 7 }) .backgroundColor(fbAlpha(this.accent, 0.08)) .borderRadius(FbMetric.radiusPill) .alignItems(VerticalAlign.Center) .onClick((): void => { this.dueDate = fbIsoOf(new Date()); this.pickerOpen = true; }) } else { FbDateTimeRow({ label: '截止', date: this.dueDate, time: this.dueTime, accent: this.accent, onTap: (): void => { this.pickerOpen = true; } }) } } .width('100%') .alignItems(HorizontalAlign.Start) /* ---- 状态(2026-11-04 新增)---- 🔴 用户指出「怎么没有选择是待办、正在进行,还是已完成的地方」。 放在「看板」之前:状态是任务最基本的属性, 而看板是"归到哪一类",后者依赖前者。 */ Column({ space: 6 }) { Text('状态') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) Row({ space: 8 }) { this.statusChip('todo', '待做') this.statusChip('doing', '进行中') this.statusChip('done', '已完成') } .width('100%') } .width('100%') .alignItems(HorizontalAlign.Start) // ---- 看板 ---- Column({ space: 6 }) { Text('看板') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) Scroll() { Row({ space: 8 }) { this.boardChip(0, '不指定', '#A8574F') ForEach(this.boards, (b: FbBoardOpt) => { this.boardChip(b.id, b.name, b.color) }, (b: FbBoardOpt): string => 'tb_' + b.id.toString()) } .padding({ right: 4 }) } .scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Off) .width('100%') } .width('100%') .alignItems(HorizontalAlign.Start) // ---- 分类(v79)---- if (this.cats.length > 0) { Column({ space: 6 }) { Text('分类') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) Scroll() { Row({ space: 8 }) { this.catChip(0, '不分类') ForEach(this.cats, (c: FbCatOpt) => { this.catChip(c.id, c.name) }, (c: FbCatOpt): string => 'tc_' + c.id.toString()) } .padding({ right: 4 }) } .scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Off) .width('100%') } .width('100%') .alignItems(HorizontalAlign.Start) } /* ---- 循环(v79)---- ⚠️ 循环的"生成下一条"是**服务端**做的(`Task::setStatus` 完成时 spawnNext),端上只需要把 `repeat_rule` 存进去 —— 别在这儿 自己再排一遍,否则会出现两条一模一样的任务。 */ Column({ space: 6 }) { Text('循环') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) Scroll() { Row({ space: 8 }) { this.repeatChip('', '不循环') this.repeatChip('day', '每天') this.repeatChip('week', '每周') this.repeatChip('month', '每月') this.repeatChip('year', '每年') } .padding({ right: 4 }) } .scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Off) .width('100%') } .width('100%') .alignItems(HorizontalAlign.Start) } .width('100%') .alignItems(HorizontalAlign.Start) } .constraintSize({ maxHeight: 430 }) .width('100%') .scrollBar(BarState.Off) .align(Alignment.TopStart) if (this.note2 !== '') { Text(this.note2) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.red) .width('100%') .maxLines(3) } // ---- 保存 ---- Text(this.saving ? '保存中…' : (this.editingId > 0 ? '保存修改' : '建好这件事')) .fontSize(FbMetric.fontBody) .fontWeight(FontWeight.Medium) .fontColor(this.saving ? FbColors.faint : '#FFFFFF') .textAlign(TextAlign.Center) .width('100%') .height(44) .borderRadius(FbMetric.radiusS) .backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98)) .onClick((): void => { this.save(); }) } .width('100%') .padding({ left: 18, right: 18, top: 18, bottom: 18 }) .alignItems(HorizontalAlign.Start) } } .width('92%') // ---- 日期时间选择 ---- if (this.pickerOpen) { FbDateTimePicker({ title: '这个任务什么时候到期', date: this.dueDate !== '' ? this.dueDate : fbIsoOf(new Date()), time: this.dueTime, accent: this.accent, onConfirm: (d: string, t: string): void => { this.dueDate = d; this.dueTime = t; this.pickerOpen = false; }, onCancel: (): void => { this.pickerOpen = false; } }) } } .width('100%') .height('100%') .hitTestBehavior(HitTestMode.Transparent) } private async save(): Promise { if (this.saving) { return; } const title: string = this.title.trim(); if (title === '') { this.note2 = '任务得写清楚要做什么。'; return; } this.saving = true; this.note2 = ''; const d: FbTaskDraft = { title: title, note: this.note.trim(), // 没有期限就发空串(服务端 `Api::dt('due_at', '')` → null) dueAt: this.dueDate !== '' ? fbJoinDateTime(this.dueDate, this.dueTime) : '', boardId: this.boardId, /* 🔴 2026-11-04:不再写死 'todo' —— 用户可以在表单里选状态。 ⚠️ 新建走 `createTask()`,服务端 POST 是**认** status 的(见 FbTaskRepo 文件头)。 */ status: this.status, /* 0 = 顶层任务(新建子任务走 `FbTaskPanel.addKid`,不在这里) */ parentId: 0, /* -1 = 这次不改负责人:表单里没有这一项,传 0 会把已指派的负责人清空 */ assigneeId: -1, categoryId: this.catId, repeat: this.repeat }; const token: string = FB_TOKEN_STORE.get(); const res: FbApiResult = this.editingId > 0 ? await updateTask(token, this.editingId, d) : await createTask(token, d); if (!res.ok) { this.saving = false; this.note2 = res.error !== '' ? res.error : '保存失败,请稍后再试。'; return; } /* 🔴 2026-11-04:编辑时若改了状态,**必须额外打 `/status`**。 原因(见 FbTaskRepo 文件头):服务端 `PATCH /tasks/` 会**显式忽略** 请求里的 status(原样回填),只有 `POST /tasks//status` 才生效。 ⇒ 不补这一下,用户改了状态、界面说"保存成功"、回去看还是原状态。 ⚠️ 只在"改过"时才打:没改就多一次无谓请求(还会白刷一次服务端 update)。 ⚠️ 新建不用补:`POST /tasks` 是认 status 的。 */ if (this.editingId > 0 && this.status !== this.status0) { const st: FbApiResult = await setTaskStatus(token, this.editingId, this.status); if (!st.ok) { this.saving = false; // 内容已存好,只有状态没改成功 —— 说清楚,别让用户以为整条都没保存 this.note2 = '内容已保存,但状态没改成功,请再试一次。'; return; } } this.saving = false; this.onSaved(); } /** 看板单选胶囊 */ @Builder boardChip(id: number, name: string, color: string) { Text(name) .fontSize(FbMetric.fontTiny) .fontColor(this.boardId === id ? '#FFFFFF' : FbColors.muted) .padding({ left: 10, right: 10, top: 6, bottom: 6 }) .backgroundColor(this.boardId === id ? (id === 0 ? this.accent : color) : FbColors.inputBg) .borderRadius(FbMetric.radiusPill) .onClick((): void => { this.boardId = id; }) } /** 分类单选胶囊(v79) */ @Builder /** * 状态 chip。 * * ⚠️ 用 `!==` 而不是 `===` 判断"未选中":`status` 理论上只会是三个合法值, * 但万一服务端返回了别的(脏数据),不能三个都不亮 —— 那样用户会以为控件坏了。 */ @Builder statusChip(v: string, name: string) { Text(name) .fontSize(FbMetric.fontTiny) .fontColor(this.status === v ? '#FFFFFF' : FbColors.muted) .padding({ left: 12, right: 12, top: 6, bottom: 6 }) .backgroundColor(this.status === v ? this.accent : FbColors.inputBg) .borderRadius(FbMetric.radiusPill) .onClick((): void => { this.status = v; }) } @Builder catChip(id: number, name: string) { Text(name) .fontSize(FbMetric.fontTiny) .fontColor(this.catId === id ? '#FFFFFF' : FbColors.muted) .padding({ left: 10, right: 10, top: 6, bottom: 6 }) .backgroundColor(this.catId === id ? this.accent : FbColors.inputBg) .borderRadius(FbMetric.radiusPill) .onClick((): void => { this.catId = id; }) } /** 循环单选胶囊(v79;'' = 不循环) */ @Builder repeatChip(v: string, name: string) { Text(name) .fontSize(FbMetric.fontTiny) .fontColor(this.repeat === v ? '#FFFFFF' : FbColors.muted) .padding({ left: 10, right: 10, top: 6, bottom: 6 }) .backgroundColor(this.repeat === v ? this.accent : FbColors.inputBg) .borderRadius(FbMetric.radiusPill) .onClick((): void => { this.repeat = v; }) } @Builder textField(label: string, placeholder: string, value: string, onChange: (v: string) => void) { Row({ space: 8 }) { Text(label) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .width(44) TextInput({ text: value, placeholder: placeholder }) .placeholderColor(FbColors.faint) .placeholderFont({ size: FbMetric.fontTiny }) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) .height(42) .layoutWeight(1) .padding({ left: 10, right: 10 }) .onChange((v: string): void => { onChange(v); }) } .width('100%') .alignItems(VerticalAlign.Center) } @Builder areaField(label: string, placeholder: string, value: string, onChange: (v: string) => void) { Row({ space: 8 }) { Text(label) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .width(44) TextArea({ text: value, placeholder: placeholder }) .placeholderColor(FbColors.faint) .placeholderFont({ size: FbMetric.fontTiny }) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) .height(80) .layoutWeight(1) .padding({ left: 10, right: 10, top: 8, bottom: 8 }) .onChange((v: string): void => { onChange(v); }) } .width('100%') .alignItems(VerticalAlign.Top) } }