#48 计划/实际双轨:nodeAction(start/done/cancel),完成即转实际行程, 实际行程不再显示状态;新增 actualStartedAt/actualEndedAt #49 类型/出行方式/车辆 chip 改内部读状态(@Builder 值传参不刷新) #50 类型点击立即变色、出行方式平铺、参与人显示已选头像 #51 节点类型动态加载(原硬编码5种)、地址 onPick 拿详细地址与坐标 #52 标签改主题色 Bold、必填红无星号、编辑行程标题同步 #53 表单两排版式(标签独占一行解决左右对不齐);电影弹层 layoutWeight 修滚动 #54 地址联想框加时间窗抑制(600ms 内不搜)、候选限高 180 含附件回填诊断日志(DIAG-ATTACH/FILL),待真机确认后移除
531 lines
20 KiB
Plaintext
531 lines
20 KiB
Plaintext
/**
|
||
* 家书 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)
|
||
}
|
||
}
|