v79 端上:任务面板(首页/任务页共用)+ 任务模块功能补齐

用户原话:「鸿蒙端的功能,也需要同样适配,在首页上可以直接修改编辑任务,
另外任务模块的功能需要完善。」

端上原来只有"看列表 / 勾完成 / 新建改标题",网页 v78 那套「就地处理」
(改状态、挪分组、子任务、附件、留言、删除)一件都没有;首页点任务
弹的还是 v73 的只读卡。

一、新增 FbTaskPanel(首页与任务页共用同一组件)
  · 改状态三段(待办/进行中/已完成)+ 标题左侧状态圈
  · 挪看板 chips、勾/加子任务、加图片附件并删、发留言
  · 编辑 → 交给宿主开 FbTaskEditSheet;删除要**点两次**(端上没有 undo)
  · 每个动作成功后再 getTask() 重拉一次:一次动作会牵动子任务计数、
    附件张数、看板色,逐处同步 DOM 必然漏一处

二、退役 FbTaskDetailSheet(v73 只读卡,387 行)
  ⚠️ 它已无人挂载,而守卫还在逐条"通过" —— 等于给死代码盖章。

三、任务模块补齐
  · 卡片点击 → 面板(不再直接进编辑表单:误触一下就开始改标题,
    且子任务/附件/留言没地方放)
  · 「只看已完成」改真打 ?view=done:看板视图服务端就写死
    status <> 'done',在客户端筛永远是空
  · 看板列表改走 ?view=boards:原来从任务反推 ⇒ 空看板永远选不到
  · 编辑表单补分类/循环两排

四、顺带修掉一个自查发现的真 bug
  任务页里 sheetCount 有**两个** @Monitor 都监听 boardSheetOpen、都写同一个值
  ⇒ 关掉设置浮层时谁最后执行不确定,会把还开着的面板"顺便让位结束",
  底部悬浮栏浮上来盖住面板按钮。已并成一个取并集的 monitor。

服务端(另一仓):新增 GET /tasks?view=boards、POST/DELETE /tasks/<id>/attach,
详情补 board_name/board_color_calc,越权删别条任务的附件 → 404。

验证:静态守卫 1055/0(新增 v79 一组,含"全页只许一个 sheetCount 赋值点")、
反向验证 215/0(新增 15 条注入,逐条证明新断言会红)、
服务端 e2e 1558/0、_fb_v79_api.py 26/0、编译通过并已装机。
This commit is contained in:
2026-10-22 16:40:44 +08:00
parent c7bd26dd1b
commit 25a0797a74
8 changed files with 1816 additions and 504 deletions
@@ -1,387 +0,0 @@
/**
* 家书 FamilyBook · 任务详情卡(鸿蒙端)
*
* 🔴 用户 2026-10-21 原话:
* 「任务也是一样,点击任务之后弹出来详情,可以标记可以标记它是不是完成了什么之类的。」
* ⇒ 首页「近期任务」里点一行 → **就地弹出详情卡**(不跳任务模块),
* 卡上能直接勾「完成 / 未完成」。
*
* ⚠️ 为什么单独一张卡,而不是复用 `FbTaskEditSheet`(任务页的编辑浮层):
* 那是**表单**(标题框 / 备注框 / 截止时间 / 看板选择 / 保存按钮),
* 适合"我要改这个任务";用户此刻要的是"**看看它是什么、顺手勾一下完成**"。
* 拿表单当详情,点开就有五个可编辑控件 + 一颗"保存",观感是"我是不是要改什么"——
* 而且误触一下就把标题改了。两件事用两个界面。
*
* ⚠️ 与 `FbEventSheet`(日程详情)同一套视觉语言:
* 外圈主题色描边环(`FbSheetRing`)、关闭统一右上角 ✕(`FbSheetClose`)、
* 底部只放动作按钮、`92%` 宽居中、内容超高才滚动。
* ⇒ 用户在首页连点一个日程、一条任务,两张卡长得像"同一个应用的弹框"。
*
* 🔴 两处容易做错的地方(都已按正确方式实现,别改回去):
* ① 完成状态**必须走 `/tasks/<id>/status`**(`setTaskStatus`),
* 不能走 `PATCH /tasks/<id>` —— 服务端 PATCH 会把 status 原样忽略,
* 表现是"提示保存成功、但状态没变"(`FbTaskRepo` 文件头写着)。
* ② 勾完**立刻**把卡片上的状态改掉(本地先改),再让宿主去重拉对齐派生字段
* (逾期、子任务进度这些是服务端算的)。等网络回来再改,用户会以为没点上。
*/
import { FbColors, FbMetric, FbTint } from '../common/Theme';
import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules';
import { FbTaskItem } from '../model/Types';
import { FbSheetRing } from './FbSheetRing';
import { FbSheetClose } from './FbSheetClose';
const FB_SHEET_BOTTOM_GAP: number = 16;
@ComponentV2
export struct FbTaskDetailSheet {
/**
* 这条任务(首页给的那份 `FbTaskItem`)。
*
* ⚠️ 用 `| null` 而不是给一份对象字面量默认值 —— 与本工程其它浮层
* (`FbEventSheet.event`)同一个写法。理由:
* ① 对象字面量默认值在 `@Param` 上会走 `arkts-no-untyped-obj-literals` 那条规则;
* ② 这份数据**永远由宿主传入**(开着卡就一定有它),默认值只是"没给时不崩",
* 给一个空壳反而会画出「未命名任务」这种假内容。
*/
@Param task: FbTaskItem | null = null;
/**
* 卡上显示的**当前状态**(`todo` / `doing` / `done`)。
*
* ⚠️ 从 `task.status` 拷一份出来放在宿主那边管,是因为"勾完"之后要**立刻**变:
* 如果把 `task` 整个换掉,宿主得重建这份数据(还要重拉接口),中间会有一次闪烁。
* 这里只用一个字符串,宿主那边改一次就行。
*/
@Param status: string = 'todo';
/** 正在提交(按钮变灰,防连点) */
@Param busy: boolean = false;
/** 提交失败/成功的说明(空 = 不显示) */
@Param note: string = '';
/** 环色 —— 任务模块主题色(与任务页浮层一致) */
@Param accent: string = '';
/** 点「完成 / 取消完成」 */
@Event onToggle: () => void = (): void => {
};
/** 点「改一改」(跳任务页的编辑浮层) */
@Event onEdit: () => void = (): void => {
};
@Event onClose: () => void = (): void => {
};
private accentColor(): string {
if (this.accent !== '') {
return this.accent;
}
const m: FbModuleDef | undefined = fbModuleOf('tasks');
return m !== undefined ? fbThemeVars(m.color).brand : FbTint.brand;
}
/** 取主题色的半透明版本(八位色 `#AARRGGBB`:**alpha 在前**) */
private accentAlpha(aa: string): string {
const c: string = this.accentColor().replace('#', '');
const rgb: string = c.length >= 8 ? c.substring(2) : c;
return '#' + aa + rgb;
}
private isDone(): boolean {
return this.status === 'done';
}
/* ---- 取值器 ----
⚠️ 为什么要一串 `xxxOf()` 而不是在 `build()` 里直接写 `this.task!.xxx`:
① `build()` 里**不能声明变量**(只有 UI 组件语法),条件与文案都得靠方法;
② `task` 是可空类型,每个访问点都写 `!` 会让"哪些地方依赖它"看不出来;
③ 这些取值器同时是**兜底**(空 → 空串),漏传一份任务也不会崩。
⚠️ 不要把它们改成 `@Param` 多参数 `@Builder` —— 那会踩"多值参数不参与观察"。 */
private titleOf(): string {
const t: FbTaskItem | null = this.task;
return t !== null && t.title !== '' ? t.title : '未命名任务';
}
private boardOf(): string {
const t: FbTaskItem | null = this.task;
return t !== null ? t.board : '';
}
private boardColorOf(): string {
const t: FbTaskItem | null = this.task;
return t !== null && t.boardColor !== '' ? t.boardColor : '#9AA0A6';
}
private dueOf(): string {
const t: FbTaskItem | null = this.task;
return t !== null ? t.dueText : '';
}
private overdueOf(): boolean {
const t: FbTaskItem | null = this.task;
return t !== null && t.overdue && !this.isDone();
}
private assigneeOf(): string {
const t: FbTaskItem | null = this.task;
return t !== null ? t.assignee : '';
}
private childOf(): number {
const t: FbTaskItem | null = this.task;
return t !== null ? t.childTotal : 0;
}
private childText(): string {
const t: FbTaskItem | null = this.task;
if (t === null) {
return '';
}
return t.childDone.toString() + ' / ' + t.childTotal.toString() + ' 已完成';
}
private noteOf(): string {
const t: FbTaskItem | null = this.task;
return t !== null ? t.note : '';
}
/** 状态中文(`待办` / `进行中` / `已完成`) */
private statusText(): string {
if (this.status === 'done') {
return '已完成';
}
return this.status === 'doing' ? '进行中' : '待办';
}
/**
* 状态的强调色。
*
* ⚠️ 完成用**绿**、逾期用**红** —— 与任务页/网页版同一套语义。
* 这里不跟模块主题色走:状态是"事实",不是"我在哪个模块"。
*/
private statusTint(): string {
if (this.status === 'done') {
return '#3F7D5A';
}
return this.status === 'doing' ? '#A8574F' : '#7C8AA0';
}
private statusWash(): string {
if (this.status === 'done') {
return '#EAF3EE';
}
return this.status === 'doing' ? '#FBEFEE' : '#F1F3F5';
}
@Builder
row(label: string, value: string) {
Row({ space: 10 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(56)
Text(value)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.alignItems(VerticalAlign.Top)
}
build() {
Column() {
/* 🔴 包裹这层 `Column` 只为了挂 `.width('92%')`。
⚠️ **不能**把属性直接挂在 `FbSheetRing` 上(`FbSheetRing(...) { … }.width(...)`):
带**尾随闭包**的自定义组件后面接属性方法,ArkTS 编译器会报
「Declaration or statement expected」外加一条「Cannot find name 'width'」,
位置正好落在那行属性的开头 —— 看着像"括号没配平",其实是语法不支持。
⇒ 照 `FbEventSheet` / `FbMovieDetailSheet` 的既有形态:环组件**自身不挂属性**,
外面包一层容器,把宽度/边距挂到容器上。
(编译实测:不包这层就红在 `FbTaskDetailSheet.ets:370`。) */
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) {
Column({ space: 0 }) {
// ---- 标题区:状态徽标 + 标题 + 关闭 ----
Row({ space: 10 }) {
/* 左侧那颗**可点的状态圆**:点一下就是"完成/取消完成"。
🔴 用户要的就是"可以标记它是不是完成了"——
把这件事做成**标题左边那颗圈**(与任务页列表里那颗同一个交互),
而不是只在底部放个按钮:这是用户最习惯的位置。 */
Stack() {
Column()
.width(22)
.height(22)
.borderRadius(11)
.backgroundColor(this.isDone() ? this.statusTint() : Color.Transparent)
.border({ width: 1.5, color: this.isDone() ? this.statusTint() : '#C9CDD2' })
if (this.isDone()) {
Text('✓')
.fontSize(13)
.fontColor(Color.White)
.fontWeight(FontWeight.Medium)
}
}
.width(30)
.height(30)
.onClick((): void => {
if (!this.busy) {
this.onToggle();
}
})
Text(this.titleOf())
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(this.isDone() ? FbColors.faint : FbColors.ink)
.decoration({
type: this.isDone() ? TextDecorationType.LineThrough : TextDecorationType.None,
color: FbColors.faint
})
.maxLines(2)
.layoutWeight(1)
Text(this.statusText())
.fontSize(FbMetric.fontTiny)
.fontColor(this.statusTint())
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(this.statusWash())
.borderRadius(FbMetric.radiusPill)
FbSheetClose({
onClose: (): void => {
this.onClose();
},
color: ''
})
}
.width('100%')
.alignItems(VerticalAlign.Top)
Divider()
.color(FbColors.line)
.margin({ top: 12, bottom: 4 })
Scroll() {
Column({ space: 0 }) {
if (this.boardOf() !== '') {
// 看板:左侧带看板本色的小点(与首页那行同一个色,视觉上能对上号)
Row({ space: 10 }) {
Text('看板')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(56)
Row({ space: 6 }) {
Column()
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor(this.boardColorOf())
Text(this.boardOf())
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
}
.layoutWeight(1)
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.alignItems(VerticalAlign.Center)
}
if (this.dueOf() !== '') {
Row({ space: 10 }) {
Text('截止')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(56)
Text(this.dueOf())
.fontSize(FbMetric.fontSmall)
.fontColor(this.overdueOf() ? '#C0533F' : FbColors.ink)
.layoutWeight(1)
if (this.overdueOf()) {
Text('已逾期')
.fontSize(FbMetric.fontTiny)
.fontColor('#C0533F')
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.backgroundColor('#FDECEC')
.borderRadius(FbMetric.radiusPill)
}
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.alignItems(VerticalAlign.Center)
}
if (this.assigneeOf() !== '') {
this.row('负责人', this.assigneeOf())
}
if (this.childOf() > 0) {
this.row('子任务', this.childText())
}
if (this.noteOf() !== '') {
this.row('备注', this.noteOf())
}
if (this.note !== '') {
Text(this.note)
.fontSize(FbMetric.fontTiny)
.fontColor(this.statusTint())
.width('100%')
.padding({ top: 8 })
}
Row().height(8)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/* 与 `FbEventSheet` 同一条纪律:
`flexShrink(1)` 而不是 `layoutWeight(1)` —— 后者会让短内容也把卡片顶到 72%,
底下留一大片空白。 */
.flexShrink(1)
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
// ---- 底部:主操作(勾完成)+ 次操作(改一改)----
Row({ space: 10 }) {
Text(this.busy ? '处理中…' : (this.isDone() ? '标记为未完成' : '标记为已完成'))
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
/* 已完成时按钮**不用主色实底**:那时候它是"撤销"而不是"完成",
视觉强度该低一档(与 `FbTaskEditSheet` 删除按钮的取舍同一个思路)。 */
.backgroundColor(this.busy ? '#C9CDD2'
: (this.isDone() ? this.statusTint() : this.accentColor()))
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
if (!this.busy) {
this.onToggle();
}
})
Text('改一改')
.fontSize(FbMetric.fontSmall)
.fontColor(this.accentColor())
.textAlign(TextAlign.Center)
.width(88)
.height(42)
.backgroundColor(this.accentAlpha('14'))
.borderRadius(FbMetric.radiusS)
.border({ width: 1, color: this.accentAlpha('33') })
.onClick((): void => {
this.onEdit();
})
}
.width('100%')
.margin({ top: 14 })
}
.width('100%')
.constraintSize({ maxHeight: '72%' })
.padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.hitTestBehavior(HitTestMode.Transparent)
}
}
@@ -13,7 +13,7 @@
*/
import { FbColors, FbMetric, FB_MASK, fbAlpha, fbTint } from '../common/Theme';
import { FbBoardOpt } from '../model/Types';
import { FbBoardOpt, FbCatOpt } from '../model/Types';
import { FbSheetClose } from './FbSheetClose';
import { FbSheetRing } from './FbSheetRing';
import { FbDateTimeRow, FbDateTimePicker, fbIsoOf, fbJoinDateTime } from './FbDateTimeField';
@@ -25,14 +25,19 @@ import { FbApiResult } from '../data/FbHttp';
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 = '';
@Event onClose: () => void = (): void => {};
@Event onSaved: () => void = (): void => {};
@@ -40,6 +45,10 @@ export struct FbTaskEditSheet {
@Local title: string = '';
@Local note: string = '';
@Local boardId: number = 0;
/** v79:分类 id(0 = 不分类) */
@Local catId: number = 0;
/** v79:循环规则('' = 不循环) */
@Local repeat: string = '';
/** 截止日期 `YYYY-MM-DD`,空 = 没有期限 */
@Local dueDate: string = '';
/** 截止时分 */
@@ -52,6 +61,8 @@ export struct FbTaskEditSheet {
this.title = this.editingTitle;
this.note = this.editingNote;
this.boardId = this.editingBoardId;
this.catId = this.editingCatId;
this.repeat = this.editingRepeat;
// `2026-10-25 18:00` → 日期 + 时分(服务端给的是空格分隔)
const raw: string = this.editingDueAt.trim();
if (raw.length >= 16) {
@@ -196,11 +207,58 @@ export struct FbTaskEditSheet {
}
.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: 360 })
.constraintSize({ maxHeight: 430 })
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
@@ -274,7 +332,13 @@ export struct FbTaskEditSheet {
// 没有期限就发空串(服务端 `Api::dt('due_at', '')` → null)
dueAt: this.dueDate !== '' ? fbJoinDateTime(this.dueDate, this.dueTime) : '',
boardId: this.boardId,
status: 'todo'
status: 'todo',
/* 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
@@ -302,6 +366,34 @@ export struct FbTaskEditSheet {
})
}
/** 分类单选胶囊(v79) */
@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 }) {
File diff suppressed because it is too large Load Diff