Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbTaskEditSheet.ets
T
laoyang 86de59616c 端上:#47~#55 打包 —— 行程双轨、动态类型、表单全局规范 (#53)
#48 计划/实际双轨:nodeAction(start/done/cancel),完成即转实际行程,
    实际行程不再显示状态;新增 actualStartedAt/actualEndedAt
#49 类型/出行方式/车辆 chip 改内部读状态(@Builder 值传参不刷新)
#50 类型点击立即变色、出行方式平铺、参与人显示已选头像
#51 节点类型动态加载(原硬编码5种)、地址 onPick 拿详细地址与坐标
#52 标签改主题色 Bold、必填红无星号、编辑行程标题同步
#53 表单两排版式(标签独占一行解决左右对不齐);电影弹层 layoutWeight 修滚动
#54 地址联想框加时间窗抑制(600ms 内不搜)、候选限高 180

含附件回填诊断日志(DIAG-ATTACH/FILL),待真机确认后移除
2026-11-05 12:41:04 +08:00

531 lines
20 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 · 「新建 / 编辑任务」浮层(鸿蒙端)
*
* 对应网页版 `templates/tasks/_edit_form.php`。
*
* 🔴 用户 2026-10-20:「新建任务、设置这些东西都这都没有。」
* 端上「新建任务」按钮原来**没有 `onTap`**,列表项也没有 `onClick`。
*
* ⚠️ 编辑态**不显示状态选择**:状态只走 `/tasks/<id>/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<void> {
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/<id>` 会**显式忽略**
请求里的 status(原样回填),只有 `POST /tasks/<id>/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)
}
}