diff --git a/entry/src/main/ets/components/FbTaskDetailSheet.ets b/entry/src/main/ets/components/FbTaskDetailSheet.ets deleted file mode 100644 index 616e178..0000000 --- a/entry/src/main/ets/components/FbTaskDetailSheet.ets +++ /dev/null @@ -1,387 +0,0 @@ -/** - * 家书 FamilyBook · 任务详情卡(鸿蒙端) - * - * 🔴 用户 2026-10-21 原话: - * 「任务也是一样,点击任务之后弹出来详情,可以标记可以标记它是不是完成了什么之类的。」 - * ⇒ 首页「近期任务」里点一行 → **就地弹出详情卡**(不跳任务模块), - * 卡上能直接勾「完成 / 未完成」。 - * - * ⚠️ 为什么单独一张卡,而不是复用 `FbTaskEditSheet`(任务页的编辑浮层): - * 那是**表单**(标题框 / 备注框 / 截止时间 / 看板选择 / 保存按钮), - * 适合"我要改这个任务";用户此刻要的是"**看看它是什么、顺手勾一下完成**"。 - * 拿表单当详情,点开就有五个可编辑控件 + 一颗"保存",观感是"我是不是要改什么"—— - * 而且误触一下就把标题改了。两件事用两个界面。 - * - * ⚠️ 与 `FbEventSheet`(日程详情)同一套视觉语言: - * 外圈主题色描边环(`FbSheetRing`)、关闭统一右上角 ✕(`FbSheetClose`)、 - * 底部只放动作按钮、`92%` 宽居中、内容超高才滚动。 - * ⇒ 用户在首页连点一个日程、一条任务,两张卡长得像"同一个应用的弹框"。 - * - * 🔴 两处容易做错的地方(都已按正确方式实现,别改回去): - * ① 完成状态**必须走 `/tasks//status`**(`setTaskStatus`), - * 不能走 `PATCH /tasks/` —— 服务端 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) - } -} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbTaskEditSheet.ets b/entry/src/main/ets/components/FbTaskEditSheet.ets index 84e2e16..03e0099 100644 --- a/entry/src/main/ets/components/FbTaskEditSheet.ets +++ b/entry/src/main/ets/components/FbTaskEditSheet.ets @@ -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 }) { diff --git a/entry/src/main/ets/components/FbTaskPanel.ets b/entry/src/main/ets/components/FbTaskPanel.ets new file mode 100644 index 0000000..9a46e27 --- /dev/null +++ b/entry/src/main/ets/components/FbTaskPanel.ets @@ -0,0 +1,1060 @@ +/** + * 家书 FamilyBook · 任务面板(鸿蒙端 v79) + * + * 🔴 用户 2026-10-22 原话: + * 「鸿蒙端的功能,也需要同样适配,在首页上可以直接修改编辑任务, + * 另外任务模块的功能需要完善。」 + * + * 对应网页 v78 的「就地处理面板」(`templates/tasks/_panel.php`): + * 首页点一条任务 / 任务页点一张卡 —— 都在**当前页面**弹出这块面板, + * 不跳模块、不跳整页,就能: + * · 改状态(待办 / 进行中 / 已完成) + * · 挪分组(换看板) + * · 勾子任务、加一条子任务 + * · 加图片附件、删附件 + * · 发留言 + * · 打开编辑表单(标题/备注/期限/看板/负责人/分类/循环) + * · 删任务(二次确认) + * + * ⚠️ 为什么不直接复用 `FbTaskEditSheet`:那是**表单**(点开就在编辑)。 + * 用户此刻多数是"看看它是什么、顺手改一下状态"。拿表单当详情, + * 误触一下就把标题改了(v73 的取舍,见 `FbTaskDetailSheet` 文件头)。 + * ⇒ 面板只读为主 + 两颗明确的动作(编辑 / 删除),编辑才进表单。 + * + * 🔴 三条纪律: + * ① 改状态**只走** `POST /tasks//status`(PATCH 会忽略 status); + * ② 每个动作成功后再 `getTask()` 重拉一次:一次动作会牵动子任务计数、 + * 附件张数、看板色,逐处同步 DOM 必然漏一处(与网页 v78 同一取舍); + * ③ 浮层根必须是 `Stack`:遮罩与卡片要**叠**,当 `Column` 子项"排"会把卡片 + * 挤出屏幕(本项目 2026-10-20 真机事故,见 pitfalls)。 + */ + +import { FbColors, FbMetric, FB_MASK, fbAlpha, fbTint } from '../common/Theme'; +import { + FbBoardOpt, FbTaskAttach, FbTaskComment, FbTaskDetail, FbTaskItem, FbTaskKid, +} from '../model/Types'; +import { FbSheetRing } from './FbSheetRing'; +import { FbSheetClose } from './FbSheetClose'; +import { FbCheckCircle } from './Widgets'; +import { FB_TOKEN_STORE } from '../data/FbTokenStore'; +import { FbApiResult } from '../data/FbHttp'; +import { fbImageSrc } from '../data/FbAttachQueue'; +import { + addTaskComment, createTask, deleteTask, delTaskAttach, getTask, moveTask, + setTaskStatus, uploadTaskAttach, FbTaskDraft, +} 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[] = []; + /** 模块主题色(任务 = 赭红) */ + @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; + + aboutToAppear(): void { + this.reload(); + } + + @Monitor('refreshTick') + onTick(): void { + this.reload(); + } + + /* ---------------- 数据 ---------------- */ + + private token(): string { + return FB_TOKEN_STORE.get(); + } + + private async reload(): Promise { + 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; + } + } + + /** 当前要显示的任务(详情优先,退回列表那一份) */ + 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(); + return t !== null ? t.board : ''; + } + + 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 : []; + } + + 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 { + 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 { + 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 { + 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 { + 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(); + } + + /** 发一条留言 */ + private async sendComment(): Promise { + const txt: string = this.cmtDraft.trim(); + if (this.busy || txt === '') { + return; + } + this.busy = true; + this.note = ''; + const res: FbApiResult = await addTaskComment(this.token(), this.taskId, txt); + this.busy = false; + if (!res.ok) { + this.note = res.error !== '' ? res.error : '留言没发出去。'; + return; + } + this.cmtDraft = ''; + await this.reload(); + this.onChanged(); + } + + /** 选一张图当附件(选完**立刻上传** —— 任务已经存在,不用再点保存) */ + private async pickAttach(): Promise { + if (this.uploading) { + return; + } + try { + const opts: picker.PhotoSelectOptions = new picker.PhotoSelectOptions(); + opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE; + opts.maxSelectNumber = 1; + const p: picker.PhotoViewPicker = new picker.PhotoViewPicker(); + const sel: picker.PhotoSelectResult = await p.select(opts); + if (sel.photoUris.length === 0) { + return; + } + this.uploading = true; + this.note = ''; + const err: string = await uploadTaskAttach(this.token(), this.taskId, sel.photoUris[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 { + 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 { + 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 }) { + this.statusSeg() + this.boardChips() + this.infoRows() + 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 }) + } + Row().height(6) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + /* `flexShrink(1)` 而不是 `layoutWeight(1)`:后者会让短内容也把卡片顶满, + 底下留一大片空白(v60b 真机实测) */ + .flexShrink(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%') + } + .width('100%') + .height('100%') + .hitTestBehavior(HitTestMode.Transparent) + } + + /** 标题行:状态圈(点=勾完成)+ 标题 + 状态徽标 + 关闭 */ + @Builder + headBar() { + Row({ space: 10 }) { + Stack({ alignContent: Alignment.Center }) { + FbCheckCircle({ + dia: 18, + done: this.isDone(), + doing: this.statusOf() === 'doing', + color: this.accent + }) + } + .width(30) + .height(30) + .onClick((): void => { + this.setStatus(this.isDone() ? 'todo' : 'done'); + }) + + Column({ space: 2 }) { + 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) + .width('100%') + if (this.parentOf() !== '') { + Text('属于「' + this.parentOf() + '」') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .maxLines(1) + .width('100%') + } + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + .flexShrink(1) + + Text(this.statusWord(this.statusOf())) + .fontSize(FbMetric.fontTiny) + .fontColor(this.statusTint(this.statusOf())) + .padding({ left: 8, right: 8, top: 3, bottom: 3 }) + .backgroundColor(this.statusWash(this.statusOf())) + .borderRadius(FbMetric.radiusPill) + + FbSheetClose({ + onClose: (): void => { + this.onClose(); + }, + color: '' + }) + } + .width('100%') + .alignItems(VerticalAlign.Top) + } + + /** 状态三段(点一下即改,没有"保存"按钮) */ + @Builder + statusSeg() { + Row({ space: 8 }) { + this.statusChip('todo') + this.statusChip('doing') + this.statusChip('done') + } + .width('100%') + .padding({ top: 10, bottom: 4 }) + } + + @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(挪分组) */ + @Builder + boardChips() { + Column({ space: 6 }) { + Text('分组(看板)') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + 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) + } + + @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) + .onClick((): void => { + this.moveTo(id); + }) + } + + /** 只读信息:截止 / 负责人 / 分类 / 循环 / 备注 */ + @Builder + infoRows() { + Column({ space: 0 }) { + if (this.dueOf() !== '') { + Row({ space: 8 }) { + Text('截止') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(44) + Text(this.dueOf()) + .fontSize(FbMetric.fontSmall) + .fontColor(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%') + .padding({ top: 7, bottom: 7 }) + .alignItems(VerticalAlign.Center) + } + if (this.assigneeOf() !== '') { + this.kvRow('负责人', this.assigneeOf()) + } + if (this.categoryOf() !== '') { + this.kvRow('分类', this.categoryOf()) + } + if (this.repeatOf() !== '') { + this.kvRow('循环', this.repeatOf()) + } + if (this.noteOf() !== '') { + this.kvRow('备注', this.noteOf()) + } + } + .width('100%') + .padding({ top: 4 }) + } + + @Builder + kvRow(label: string, value: string) { + Row({ space: 8 }) { + Text(label) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(44) + Text(value) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .layoutWeight(1) + } + .width('100%') + .padding({ top: 7, bottom: 7 }) + .alignItems(VerticalAlign.Top) + } + + /** 子任务:列表 + 加一条 */ + @Builder + kidSection() { + Column({ space: 6 }) { + Row({ space: 6 }) { + Text('子任务') + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + if (this.kids().length > 0) { + Text(this.kidDoneCount().toString() + '/' + this.kids().length.toString()) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + } + Blank() + } + .width('100%') + .padding({ top: 12 }) + .alignItems(VerticalAlign.Center) + + 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()) + + /* 加一条:输入框 + 「加」 */ + 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 }) { + Row({ space: 6 }) { + Text('附件') + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + if (this.attachs().length > 0) { + Text(this.attachs().length.toString()) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + } + Blank() + 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 }) + } + + /** 留言 */ + @Builder + commentSection() { + Column({ space: 8 }) { + Row({ space: 6 }) { + Text('留言') + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + if (this.comments().length > 0) { + Text(this.comments().length.toString()) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + } + Blank() + } + .width('100%') + .padding({ top: 12 }) + .alignItems(VerticalAlign.Center) + + ForEach(this.comments(), (c: FbTaskComment) => { + Row({ space: 8 }) { + /* 头像位:一个小色点(端上没拉头像,用底色 + 名字首字最直观) */ + Row() { + Text(c.name.substring(0, 1)) + .fontSize(10) + .fontColor(this.accent) + } + .width(22) + .height(22) + .borderRadius(11) + .backgroundColor(c.color !== '' ? c.color : FbColors.inputBg) + .justifyContent(FlexAlign.Center) + + Column({ space: 2 }) { + Row({ space: 6 }) { + Text(c.name) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + Text(c.createdAt) + .fontSize(9) + .fontColor(FbColors.faint) + } + .width('100%') + .alignItems(VerticalAlign.Center) + + 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 }) { + 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(); + }) + + Text('发送') + .fontSize(FbMetric.fontSmall) + .fontColor(this.cmtDraft.trim() === '' ? FbColors.faint : this.accent) + .textAlign(TextAlign.Center) + .width(56) + .height(38) + .backgroundColor(fbAlpha(this.accent, 0.1)) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.sendComment(); + }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + + /** 底部:编辑 / 删除 */ + @Builder + footBar() { + Row({ space: 10 }) { + Text('编辑') + .fontSize(FbMetric.fontSmall) + .fontColor(Color.White) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(42) + .backgroundColor(this.busy ? '#C9CDD2' : this.accent) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + if (this.busy) { + return; + } + const d: FbTaskDetail | null = this.detail; + if (d === null) { + this.note = '还在载入,稍等一下再点编辑。'; + return; + } + this.onEdit(d); + }) + + Text(this.confirmDel ? '确定删掉' : '删除') + .fontSize(FbMetric.fontSmall) + .fontColor(this.confirmDel ? Color.White : '#C0533F') + .textAlign(TextAlign.Center) + .width(88) + .height(42) + .backgroundColor(this.confirmDel ? '#C0533F' : '#FDECEC') + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.dropTask(); + }) + } + .width('100%') + .margin({ top: 12 }) + } +} diff --git a/entry/src/main/ets/data/FbParse.ets b/entry/src/main/ets/data/FbParse.ets index a25dd47..34dd6db 100644 --- a/entry/src/main/ets/data/FbParse.ets +++ b/entry/src/main/ets/data/FbParse.ets @@ -19,6 +19,7 @@ import { FbMark, FbLedgerBook, FbLedgerDetail, FbLedgerEntry, FbNoteItem, FbNotificationItem, FbResourceItem, FbTaskItem, FbTripItem, FbWeather, FbStat, FbMemoryItem, FbRecentDay, + FbTaskAttach, FbTaskComment, FbTaskDetail, FbTaskKid, FbTaskMeta, FbCatOpt, FbBoardOpt, } from '../model/Types'; // ============================================================ @@ -171,6 +172,45 @@ interface RawTaskBoard { id?: number; name?: string; color?: string; tasks?: RawTask[]; } +/* ---- v79:任务详情(`GET /tasks/`)才有那几块 ---- */ + +interface RawTaskKid { + id?: number; title?: string; status?: string; +} + +interface RawTaskComment { + id?: number; person_id?: number; content?: string; created_at?: string; + name?: string | null; my_nickname?: string | null; avatar_color?: string | null; +} + +interface RawTaskAttach { + id?: number; key?: string; name?: string | null; mime?: string | null; + size?: number; is_image?: number | null; +} + +/** 任务详情的原始行:任务本体 + 详情独占的几列 + 三块子集合 */ +interface RawTaskDetail extends RawTask { + assignee_id?: number | null; + /** 我对负责人的称呼(优先于 `assignee_name`,与网页 `person_name()` 同口径) */ + assignee_nick?: string | null; + creator_name?: string | null; + creator_nick?: string | null; + repeat_rule?: string | null; + parent_title?: string | null; + children?: RawTaskKid[]; + comments?: RawTaskComment[]; + attachs?: RawTaskAttach[]; +} + +/** `?view=boards` 的看板元数据(v79) */ +interface RawBoardMeta { + id?: number; name?: string; color?: string; open?: number; +} + +interface RawCatMeta { + id?: number; name?: string; +} + interface RawLedgerBook { id?: number; name?: string; my_perm?: string | null; entry_count?: number; share_perm?: string | null; @@ -799,6 +839,18 @@ export function parseMemories(v: Object | null | undefined): FbMemoryItem[] { return out; } +/** + * 扁平的任务列表 JSON(`?view=done` / `?view=status`)→ `FbTaskItem[]`。 + * + * ⚠️ 与 `parseTasks`(看板视图)**不能互换**:那个认 `{boards:[...]}`, + * 这个认 `[...]`。缓存键也必须分开(`tasks` vs `tasks_done`), + * 否则两个形状会互相覆盖,表现是"切一次已完成,看板全空了"。 + */ +export function parseTaskList(json: string): FbTaskItem[] { + const rows: RawTask[] = asArray(json); + return parseTaskRows(rows); +} + /** 一批任务行 → FbTaskItem[](首页与任务页共用同一段形状转换) */ export function parseTaskRows(rows: RawTask[]): FbTaskItem[] { const out: FbTaskItem[] = []; @@ -808,6 +860,156 @@ export function parseTaskRows(rows: RawTask[]): FbTaskItem[] { return out; } +/** + * `?view=boards` → 看板 + 分类(v79)。 + * + * 🔴 端上原来是从已加载任务里**反推**看板的(`FbTasksPage.boardOpts`), + * 那样只能看到"有任务的看板" —— 用户刚建好、还没放任务的看板在端上 + * 永远选不到(表现为"网页能建,端上挪不过去")。 + */ +export function parseTaskMeta(json: string): FbTaskMeta { + const root: Record | null = asObject(json); + const boards: FbBoardOpt[] = []; + const cats: FbCatOpt[] = []; + if (root === null) { + const empty: FbTaskMeta = { boards: boards, cats: cats }; + return empty; + } + for (const b of arr(root['boards'] as RawBoardMeta[])) { + const id: number = n(b.id); + const name: string = s(b.name); + if (name === '') { + continue; + } + const one: FbBoardOpt = { id: id, name: name, color: s(b.color) }; + boards.push(one); + } + for (const c of arr(root['categories'] as RawCatMeta[])) { + const name: string = s(c.name); + if (name === '') { + continue; + } + const one: FbCatOpt = { id: n(c.id), name: name }; + cats.push(one); + } + const meta: FbTaskMeta = { boards: boards, cats: cats }; + return meta; +} + +/** + * 一条任务的详情 → `FbTaskDetail`(v79 面板用)。 + * + * ⚠️ `base` 走**同一个** `taskRow()`(与首页/任务页列表同源)—— + * 三处各写一份转换,迟早出现"同一张卡在两个页面显示不一样"。 + */ +export function parseTaskDetail(json: string): FbTaskDetail | null { + const root: Record | null = asObject(json); + if (root === null) { + return null; + } + const d: RawTaskDetail = root as RawTaskDetail; + if (n(d.id) <= 0) { + return null; + } + const base: FbTaskItem = taskRow(d, s(d.board_name), s(d.board_color_calc)); + + const kids: FbTaskKid[] = []; + for (const k of arr(d.children)) { + if (n(k.id) <= 0) { + continue; + } + const one: FbTaskKid = { + id: n(k.id), + title: s(k.title), + status: s(k.status) !== '' ? s(k.status) : 'todo', + }; + kids.push(one); + } + + const comments: FbTaskComment[] = []; + for (const c of arr(d.comments)) { + const nick: string = s(c.my_nickname); + const one: FbTaskComment = { + id: n(c.id), + content: s(c.content), + name: nick !== '' ? nick : s(c.name), + color: s(c.avatar_color), + createdAt: s(c.created_at), + }; + comments.push(one); + } + + const attachs: FbTaskAttach[] = []; + for (const a of arr(d.attachs)) { + const name: string = s(a.name); + const mime: string = s(a.mime); + const isImg: boolean = n(a.is_image) === 1 || mime.indexOf('image/') === 0; + const one: FbTaskAttach = { + id: n(a.id), + name: name !== '' ? name : '附件', + kind: attachKindOf(mime, name, isImg), + isImage: isImg, + key: s(a.key), + sizeWord: sizeWordOf(n(a.size)), + }; + attachs.push(one); + } + + const creatorNick: string = s(d.creator_nick); + const detail: FbTaskDetail = { + base: base, + assigneeId: n(d.assignee_id), + categoryId: n(d.category_id), + category: s(d.category_name), + repeat: s(d.repeat_rule), + parentTitle: s(d.parent_title), + creator: creatorNick !== '' ? creatorNick : s(d.creator_name), + kids: kids, + comments: comments, + attachs: attachs, + }; + return detail; +} + +/** 附件是「哪一类」—— 决定画缩略图还是画一个图标 + 后缀 */ +function attachKindOf(mime: string, name: string, isImg: boolean): string { + if (isImg) { + return 'image'; + } + const dot: number = name.lastIndexOf('.'); + const ext: string = dot >= 0 ? name.substring(dot + 1).toLowerCase() : ''; + if (mime === 'application/pdf' || ext === 'pdf') { + return 'pdf'; + } + if (ext === 'doc' || ext === 'docx') { + return 'doc'; + } + if (ext === 'xls' || ext === 'xlsx' || ext === 'csv') { + return 'xls'; + } + if (ext === 'ppt' || ext === 'pptx') { + return 'ppt'; + } + if (ext === 'txt' || ext === 'md') { + return 'text'; + } + return 'file'; +} + +/** 附件大小(给人看的那个串) */ +function sizeWordOf(bytes: number): string { + if (bytes <= 0) { + return ''; + } + if (bytes < 1024) { + return bytes.toString() + ' B'; + } + if (bytes < 1024 * 1024) { + return (Math.round(bytes / 102.4) / 10).toString() + ' KB'; + } + return (Math.round(bytes / 104857.6) / 10).toString() + ' MB'; +} + // ============================================================ // 内部工具 // ============================================================ diff --git a/entry/src/main/ets/data/FbTaskRepo.ets b/entry/src/main/ets/data/FbTaskRepo.ets index baeb542..8319e0f 100644 --- a/entry/src/main/ets/data/FbTaskRepo.ets +++ b/entry/src/main/ets/data/FbTaskRepo.ets @@ -18,6 +18,7 @@ */ import { fbApi, FbApiResult, FbApiOptions } from './FbHttp'; +import { fbUploadOne } from './FbUpload'; /** 新建/编辑任务提交的字段 */ export interface FbTaskDraft { @@ -29,6 +30,22 @@ export interface FbTaskDraft { boardId: number; /** todo / doing / done(只在**新建**时生效;改状态走 `setTaskStatus`) */ status: string; + /** + * 父任务 id(v79;0 = 顶层任务)。 + * 面板里「加一条子任务」就是 `parentId = 当前任务` 的新建请求。 + */ + parentId: number; + /** + * 负责人 id。 + * + * `> 0` = 指定这个人;`0` = 新建时"用默认值(我自己)"、编辑时"清空负责人"; + * **`-1` = 这次不改**(编辑表单目前没有负责人这一项 ⇒ 不能传 0 把它抹掉)。 + */ + assigneeId: number; + /** 分类 id(0 = 不分类) */ + categoryId: number; + /** 循环规则:空 / day / week / month / year */ + repeat: string; } /** 新建任务 */ @@ -41,12 +58,27 @@ export async function createTask(token: string, d: FbTaskDraft): Promise0 时才传:服务端 `Api::int('assignee_id', $pid)` + 的默认值是"我自己",传 0 会把它覆盖成"没人负责"。 */ + if (d.parentId > 0) { + body['parent_id'] = d.parentId; + } + if (d.assigneeId > 0) { + body['assignee_id'] = d.assigneeId; + } + if (d.categoryId > 0) { + body['category_id'] = d.categoryId; + } + if (d.repeat !== '') { + body['repeat_rule'] = d.repeat; + } const opts: FbApiOptions = { method: 'POST', token: token, body: body }; return await fbApi('/api/v1/tasks', opts); } /** - * 改任务(标题 / 备注 / 截止 / 看板)。 + * 改任务(标题 / 备注 / 截止 / 看板 / 负责人 / 分类 / 循环)。 * * ⚠️ **不带 status**:服务端 PATCH 会忽略它(见文件头说明), * 带上只会让人以为"改成功了",实际状态没动。 @@ -57,6 +89,15 @@ export async function updateTask(token: string, id: number, d: FbTaskDraft): Pro body['note'] = d.note; body['due_at'] = d.dueAt; body['board_id'] = d.boardId; + /* v79:负责人/分类/循环也走 PATCH(服务端这三个字段都认)。 + ⚠️ `assignee_id` 只在**明确要改**(>=0)时才传:编辑表单里没有负责人这一项, + 传 0 会把原来指派的负责人**静默清空**(与 v77「没传 ≠ 清空」同一类 bug)。 + ⚠️ 分类和循环则**必须**照传(0 / 空串就是"清空",那是合法操作)。 */ + if (d.assigneeId >= 0) { + body['assignee_id'] = d.assigneeId; + } + body['category_id'] = d.categoryId; + body['repeat_rule'] = d.repeat; const opts: FbApiOptions = { method: 'PATCH', token: token, body: body }; return await fbApi('/api/v1/tasks/' + id.toString(), opts); } @@ -74,3 +115,62 @@ export async function deleteTask(token: string, id: number): Promise { + const opts: FbApiOptions = { method: 'GET', token: token }; + return await fbApi('/api/v1/tasks/' + id.toString(), opts); +} + +/** + * 挪到另一个看板(网页版「分组」的等价物)。 + * + * ⚠️ 走 `/move` 而不是 `PATCH`:`/move` 会顺手把这一列重排 position; + * PATCH 只改 board_id,同列里会留下重复的 position(网页端拖拽用的是 /move)。 + */ +export async function moveTask(token: string, id: number, boardId: number): Promise { + const body: Record = {}; + body['board_id'] = boardId; + const opts: FbApiOptions = { method: 'POST', token: token, body: body }; + return await fbApi('/api/v1/tasks/' + id.toString() + '/move', opts); +} + +/** 发一条留言(服务端只回 `{id}`,要整块内容就再 `getTask` 一次) */ +export async function addTaskComment(token: string, id: number, content: string): Promise { + const body: Record = {}; + body['content'] = content; + const opts: FbApiOptions = { method: 'POST', token: token, body: body }; + return await fbApi('/api/v1/tasks/' + id.toString() + '/comments', opts); +} + +/** + * 传一个附件(图片/文档都行,服务端按 mime 自己分)。 + * + * @return 空串 = 成功;否则是给人看的错误说明(与 `fbUploadOne` 的约定一致) + */ +export async function uploadTaskAttach(token: string, id: number, uri: string): Promise { + return await fbUploadOne('/api/v1/tasks/' + id.toString() + '/attach', 'file', uri, token); +} + +/** 删一个附件(列表里点 ✕) */ +export async function delTaskAttach(token: string, id: number, aid: number): Promise { + /* ⚠️ 两处都踩过,别改回去: + ① 路由只解析「资源/id/子动作」**三段** ⇒ 不能写成 `/attach/`, + 那样服务端只会拿到 `$sub='attach'`,附件 id 被静默丢掉; + ② 附件 id 走 **query**,不要塞 JSON body —— 实测 `DELETE` 带 JSON 体时 + 服务端读到的是空(`Api::param` 拿不到),症状是 + 「要指定删哪个附件」400,而请求看起来完全正常。 */ + const q: Record = {}; + q['attachment_id'] = aid.toString(); + const opts: FbApiOptions = { method: 'DELETE', token: token, query: q }; + return await fbApi('/api/v1/tasks/' + id.toString() + '/attach', opts); +} + diff --git a/entry/src/main/ets/model/Types.ets b/entry/src/main/ets/model/Types.ets index fb03f20..e73f42c 100644 --- a/entry/src/main/ets/model/Types.ets +++ b/entry/src/main/ets/model/Types.ets @@ -702,6 +702,78 @@ export interface FbBoardOpt { color: string; } +/** 分类选项(任务的「分类」;`id=0` = 不分类) */ +export interface FbCatOpt { + id: number; + name: string; +} + +/** + * 任务元数据(v79):**全部**看板 + 全部分类 + 状态/循环词表。 + * + * 🔴 为什么单开一个类型、而不是继续从任务列表里反推看板: + * 反推只能看见"**有任务的**看板" ⇒ 刚建好、还没放任务的看板在端上永远选不到 + * (用户建了个看板,端上挪不过去 —— 这在 v78 网页版是能做的)。 + * 元数据走 `GET /tasks?view=boards`(很轻,不带任务)。 + */ +export interface FbTaskMeta { + boards: FbBoardOpt[]; + cats: FbCatOpt[]; +} + +/** 子任务(面板里那几行;只勾状态、不展开二级) */ +export interface FbTaskKid { + id: number; + title: string; + status: string; +} + +/** 任务下的一条留言 */ +export interface FbTaskComment { + id: number; + content: string; + /** 留言人显示名(服务端已按"我怎么称呼他"处理过) */ + name: string; + color: string; + createdAt: string; +} + +/** 任务下的一个附件 */ +export interface FbTaskAttach { + id: number; + name: string; + /** image / pdf / doc / file …(决定画缩略图还是画图标) */ + kind: string; + isImage: boolean; + /** 存储键(`photo/xxx.jpg`),画图时交给 fbImageUrl */ + key: string; + sizeWord: string; +} + +/** + * 任务详情(v79 面板用):列表行 + 只有详情接口才给的那几块。 + * + * ⚠️ `base` 复用 `FbTaskItem`:用户点开时先用列表那一份渲染(不空白), + * 详情接口回来再把 `base` 换掉。两处形状一致才不会"点开闪一下再变样"。 + */ +export interface FbTaskDetail { + base: FbTaskItem; + /** 负责人 id(编辑时回填;0 = 未指派) */ + assigneeId: number; + /** 负责人显示名(`base.assignee` 的同源,详情以这里为准) */ + categoryId: number; + category: string; + /** 循环规则(day/week/month/year;空 = 不循环) */ + repeat: string; + /** 父任务标题(子任务才有;空 = 本来就是顶层任务) */ + parentTitle: string; + /** 建这条的人显示名 */ + creator: string; + kids: FbTaskKid[]; + comments: FbTaskComment[]; + attachs: FbTaskAttach[]; +} + // ============================================================ // 纪念日(days) // ============================================================ diff --git a/entry/src/main/ets/pages/home/FbHomePage.ets b/entry/src/main/ets/pages/home/FbHomePage.ets index 10b83e8..1068088 100644 --- a/entry/src/main/ets/pages/home/FbHomePage.ets +++ b/entry/src/main/ets/pages/home/FbHomePage.ets @@ -39,24 +39,26 @@ import { FbColors, FbMetric, FB_MASK } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; -import { FbCalEvent, FbHomeData, FbMark, FbMemoryItem, FbRecentDay, FbTaskItem, +import { FbCalEvent, FbCatOpt, FbHomeData, FbMark, FbMemoryItem, FbRecentDay, FbTaskItem, + FbBoardOpt, FbTaskDetail, FbTaskMeta, FbWeather, isEditableEvent } from '../../model/Types'; import { common } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { parseHome } from '../../data/FbParse'; -import { loadModule } from '../../data/FbModuleLoad'; +import { loadModule, loadPath } from '../../data/FbModuleLoad'; import { FB_CAL_SETTINGS, FbCalStyle } from '../../data/FbCalSettings'; import { FbCalRepo, FbCalEventFull } from '../../data/FbCalRepo'; import { FB_TOKEN_STORE } from '../../data/FbTokenStore'; -import { setTaskStatus } from '../../data/FbTaskRepo'; +import { parseTaskMeta } from '../../data/FbParse'; import { FB_CARD_FORMS } from '../../data/FbCardForms'; import { fbApi, FbApiResult } from '../../data/FbHttp'; import { FbSectionTitle } from '../../components/Widgets'; import { FbStatCell } from '../../components/FbStatCell'; import { FbDayEventPanel } from '../../components/FbDayEventPanel'; import { FbEventSheet } from '../../components/FbEventSheet'; -import { FbTaskDetailSheet } from '../../components/FbTaskDetailSheet'; +import { FbTaskPanel } from '../../components/FbTaskPanel'; +import { FbTaskEditSheet } from '../../components/FbTaskEditSheet'; import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; @@ -142,14 +144,36 @@ export struct FbHomePage { /** 丢弃在途请求的令牌(连点两条日程时,先回来的那条不该覆盖后点的) */ @Local evtToken: number = 0; - /** 摘要卡显示的那条任务(非 null = 任务详情卡开着) */ + /** 摘要卡显示的那条任务(非 null = 任务面板开着) */ @Local tskBase: FbTaskItem | null = null; - /** 任务卡上显示的当前状态(勾完**立刻**变,不等网络) */ - @Local tskStatus: string = 'todo'; - /** 任务状态正在提交(防连点) */ - @Local tskBusy: boolean = false; - /** 任务卡的提示(失败原因 / 成功说明) */ - @Local tskNote: string = ''; + /** + * v79:面板里能用的**全部**看板 / 分类(`?view=boards`)。 + * + * 🔴 为什么不在页面加载时就拉:首页 `/home` 不返回看板表, + * 而"挪分组/选分类"只有点开任务后才用得上 —— 按需拉更省一次请求。 + * ⚠️ 拉的是**全部看板**(含空看板),不是从任务里反推 + * (反推会让新建的空看板永远选不到)。 + */ + @Local tskBoards: FbBoardOpt[] = []; + @Local tskCats: FbCatOpt[] = []; + /** 任务编辑表单是否打开(v79:在首页直接改,不再跳任务页) */ + @Local tskEditOpen: boolean = false; + /** 编辑表单回填的字段 */ + @Local tskEditId: number = 0; + @Local tskEditTitle: string = ''; + @Local tskEditNote: string = ''; + @Local tskEditDueAt: string = ''; + @Local tskEditBoardId: number = 0; + @Local tskEditCatId: number = 0; + @Local tskEditRepeat: string = ''; + /** + * 面板的"重画脉冲"(v79)。 + * + * 🔴 为什么需要:编辑表单保存成功后,面板里的标题/截止/分类都变了, + * 但面板的数据是它**自己**拉的 —— 宿主只重拉首页列表,它不会跟着变。 + * ⚠️ 别用"关掉再打开"代替:会闪一下、还会丢掉滚动位置。 + */ + @Local tskTick: number = 0; /** * 详情卡开着 ⇒ 底部悬浮快捷栏让位。 @@ -158,9 +182,9 @@ export struct FbHomePage { * `Index` 的 Stack** 里,层级比本页高 ⇒ 不让位就会盖住卡片底部的按钮、 * 还把点击抢走(名册/日程页都踩过)。 */ - @Monitor('evtBase', 'tskBase') + @Monitor('evtBase', 'tskBase', 'tskEditOpen') onDetailChanged(): void { - this.app.sheetCount = (this.evtBase !== null || this.tskBase !== null) ? 1 : 0; + this.app.sheetCount = (this.evtBase !== null || this.tskBase !== null || this.tskEditOpen) ? 1 : 0; } aboutToAppear(): void { @@ -181,6 +205,12 @@ export struct FbHomePage { /** 返回手势:先关详情卡,再交给 `Index`(返回 true = 已消费) */ private backOnce(): boolean { + /* v79:**后打开的先关** —— 编辑表单是压在面板之上的,返回一次只该关它。 + 顺序反了会"按一次返回,两层一起没了"(用户会以为点错了)。 */ + if (this.tskEditOpen) { + this.tskEditOpen = false; + return true; + } if (this.evtBase !== null) { this.closeEvent(); return true; @@ -333,69 +363,50 @@ export struct FbHomePage { } // ============================================================ - // v73:任务详情卡 + // v79:任务面板(v73 的"详情卡"升级成可就地处理的面板) // ============================================================ private openTask(t: FbTaskItem): void { this.tskBase = t; - this.tskStatus = t.status; - this.tskBusy = false; - this.tskNote = ''; + /* 看板/分类表按需拉一次(拉过就不再拉;它是"元数据",几条而己) */ + this.loadTaskMeta(); } private closeTask(): void { this.tskBase = null; - this.tskBusy = false; - this.tskNote = ''; + this.tskEditOpen = false; } - /** - * 勾完成 / 取消完成。 - * - * 🔴🔴 **必须走 `/tasks//status`**(`setTaskStatus`)—— - * 服务端 `PATCH /tasks/` 会把请求里的 status **原样忽略** - * (见 `FbTaskRepo` 文件头),打 PATCH 会"看起来成功、状态没变"。 - * ⚠️ 这是本项目已经踩过一次的坑,别改成 updateTask。 - * - * ⚠️ 已完成 → 取消完成统一回 `todo`(**不回 `doing`**): - * 用户只想撤销一次误勾,把它标成"进行中"是替他做了他没表达的决定。 - * (与任务页 `toggleDone` 同一套规则。) - */ - private async toggleTask(): Promise { - const t: FbTaskItem | null = this.tskBase; - if (t === null || this.tskBusy) { + /** 拉全部看板 + 分类(面板的"挪分组"、表单的"分类"都要用) */ + private async loadTaskMeta(): Promise { + if (this.tskBoards.length > 0) { return; } - this.tskBusy = true; - this.tskNote = ''; - const next: string = this.tskStatus === 'done' ? 'todo' : 'done'; - const res: FbApiResult = await setTaskStatus(FB_TOKEN_STORE.get(), t.id, next); - this.tskBusy = false; - if (!res.ok) { - this.tskNote = res.error !== '' ? res.error : '改状态失败,请稍后再试。'; - return; - } - /* 本地先把状态改掉(卡上立刻有反馈),再重拉首页数据对齐派生字段 - (逾期、看板计数、"近期任务"里这条还在不在,都是服务端算的)。 - ⚠️ 顺序不能反 —— 等网络回来再改,用户会以为"没点上"又点一次。 */ - this.tskStatus = next; - this.tskNote = next === 'done' ? '已标记完成' : '已改回未完成'; - this.load(); - // 桌面卡片上的数字(今天日程/在做/待办)也可能受影响 —— 顺手推一次 - this.pushCards(); + await loadPath('tasks_meta', '/api/v1/tasks?view=boards', parseTaskMeta, + (m: FbTaskMeta): void => { + this.tskBoards = m.boards; + this.tskCats = m.cats; + }, + (note: string): void => { + /* ⚠️ 失败**不拦路**:面板里状态/子任务/留言都还能用, + 只是"换看板"那一排会少几个(甚至只剩默认看板)。 */ + this.syncNote = note; + }); } - /** - * 任务卡上点「改一改」→ 切到任务模块。 - * - * ⚠️ 与日程同理:改标题/改截止/看板/负责人是一整套表单,属于任务页的职责。 - * 用户要的"弹详情、能勾完成"这里已经给了;"改内容"跳过去是对的。 - */ - private editTaskFromDetail(): void { - this.closeTask(); - this.app.switchModule('tasks'); + /** 面板里点「编辑」→ 在首页就地打开编辑表单(v79,不再跳任务页) */ + private openTaskEdit(d: FbTaskDetail): void { + this.tskEditId = d.base.id; + this.tskEditTitle = d.base.title; + this.tskEditNote = d.base.note; + this.tskEditDueAt = d.base.dueAt; + this.tskEditBoardId = d.base.boardId; + this.tskEditCatId = d.categoryId; + this.tskEditRepeat = d.repeat; + this.tskEditOpen = true; } + /** * 把最新数据推给桌面上所有日程卡片。 * @@ -873,31 +884,61 @@ export struct FbHomePage { .transition(fbSheetTransition()) } - // ==================== 任务详情卡(v73)==================== + // ==================== 任务面板(v79:可就地处理)==================== + /* 🔴 用户 2026-10-22:「在首页上可以直接修改编辑任务。」 + ⇒ v73 的"只读详情卡"升级成面板:状态 / 看板 / 子任务 / 附件 / 留言 + 全在这张卡里做完,**不跳任务模块**(跳模块是用户明确否掉的)。 + ⚠️ 面板根节点自带遮罩与 `Stack`,这里**不要**再包一层遮罩 —— + 包了就会有两层暗色叠在一起(越点越黑)。 */ if (this.tskBase !== null) { - Column() - .width('100%') - .height('100%') - .backgroundColor(FB_MASK) - .transition(fbMaskTransition()) - .onClick((): void => { - this.closeTask(); - }) - - FbTaskDetailSheet({ - task: this.tskBase, - status: this.tskStatus, - busy: this.tskBusy, - note: this.tskNote, + FbTaskPanel({ + taskId: this.tskBase.id, + seed: this.tskBase, + boards: this.tskBoards, accent: this.taskAccent(), - onToggle: (): void => { - this.toggleTask(); - }, - onEdit: (): void => { - this.editTaskFromDetail(); - }, + refreshTick: this.tskTick, onClose: (): void => { this.closeTask(); + }, + onEdit: (d: FbTaskDetail): void => { + this.openTaskEdit(d); + }, + onChanged: (): void => { + /* 一次动作会牵动"近期任务"这张卡的排序/计数(服务端算的)⇒ 重拉首页 */ + this.load(); + this.pushCards(); + }, + onDeleted: (): void => { + this.closeTask(); + this.load(); + this.pushCards(); + } + }) + .transition(fbSheetTransition()) + } + + // ==================== 任务编辑表单(v79:就地改,不跳任务页)==================== + if (this.tskEditOpen) { + FbTaskEditSheet({ + accent: this.taskAccent(), + boards: this.tskBoards, + cats: this.tskCats, + editingId: this.tskEditId, + editingTitle: this.tskEditTitle, + editingNote: this.tskEditNote, + editingDueAt: this.tskEditDueAt, + editingBoardId: this.tskEditBoardId, + editingCatId: this.tskEditCatId, + editingRepeat: this.tskEditRepeat, + onClose: (): void => { + this.tskEditOpen = false; + }, + onSaved: (): void => { + this.tskEditOpen = false; + /* 面板还开着 ⇒ 让它自己重拉一次(标题/截止/分类都变了) */ + this.tskTick = this.tskTick + 1; + this.load(); + this.pushCards(); } }) .transition(fbSheetTransition()) diff --git a/entry/src/main/ets/pages/tasks/FbTasksPage.ets b/entry/src/main/ets/pages/tasks/FbTasksPage.ets index 05b1ee2..8d9e0c9 100644 --- a/entry/src/main/ets/pages/tasks/FbTasksPage.ets +++ b/entry/src/main/ets/pages/tasks/FbTasksPage.ets @@ -11,10 +11,10 @@ import { FbColors, FbMetric, fbTint, FbTint, FB_MASK, fbAlpha } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; -import { FbBoardOpt, FbGroup, FbTaskItem } from '../../model/Types'; +import { FbBoardOpt, FbCatOpt, FbGroup, FbTaskItem, FbTaskDetail, FbTaskMeta } from '../../model/Types'; import { fbGroupTask } from '../../model/DemoData'; -import { parseTasks } from '../../data/FbParse'; -import { loadModule } from '../../data/FbModuleLoad'; +import { parseTasks, parseTaskList, parseTaskMeta } from '../../data/FbParse'; +import { loadModule, loadPath } from '../../data/FbModuleLoad'; import { FbCheckCircle, FbDot, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets'; import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll'; @@ -25,6 +25,7 @@ import { FbSheetClose } from '../../components/FbSheetClose'; import { FB_BACK } from '../../common/FbBack'; import { FB_SET_TASKS } from '../../model/FbSettingsMenus'; import { FbTaskEditSheet } from '../../components/FbTaskEditSheet'; +import { FbTaskPanel } from '../../components/FbTaskPanel'; import { setTaskStatus } from '../../data/FbTaskRepo'; import { FB_TOKEN_STORE } from '../../data/FbTokenStore'; import { FbApiResult } from '../../data/FbHttp'; @@ -77,12 +78,32 @@ export struct FbTasksPage { /** `2026-10-25 18:00`;空 = 没有期限 */ @Local editingDueAt: string = ''; @Local editingBoardId: number = 0; + /** v79:分类 / 循环(编辑时回填;0 / '' = 不分类、不循环) */ + @Local editingCatId: number = 0; + @Local editingRepeat: string = ''; /** 正在改状态的那条(防连点打重复请求) */ @Local busyId: number = 0; /** 「分类与看板」浮层是否打开 */ @Local boardSheetOpen: boolean = false; /** 操作失败时的一行红字(如勾完成失败) */ @Local opNote: string = ''; + /* ---- v79:任务面板(点卡片打开;与首页共用 `FbTaskPanel`)---- */ + /** 面板正在看的那条(0 = 面板关着) */ + @Local panelId: number = 0; + /** 先用列表里那一份渲染(不空白等网络) */ + @Local panelSeed: FbTaskItem | null = null; + /** 面板重画脉冲(编辑保存后 +1) */ + @Local panelTick: number = 0; + /** + * 全部看板 / 分类(`?view=boards`)。 + * + * 🔴 原来 `boardOpts()` 是**从已加载任务里反推**的 ⇒ 只能看见"有任务的看板", + * 新建的空看板在端上永远选不到(网页能建、端上挪不过去)。 + */ + @Local metaBoards: FbBoardOpt[] = []; + @Local metaCats: FbCatOpt[] = []; + /** 已完成列表(`?view=done`;看板视图**不含 done**,所以必须单独拉) */ + @Local doneTasks: FbTaskItem[] = []; /** * 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。 @@ -101,11 +122,17 @@ export struct FbTasksPage { private backOnce(): boolean { /* v65:返回手势要**先关新的浮层**,再关设置菜单 —— - 顺序按"后打开的在前",否则按一次返回会跳关两层。 */ + 顺序按"后打开的在前",否则按一次返回会跳关两层。 + v79:面板与编辑表单又叠了一层(编辑在面板之上),所以顺序是 + 编辑 → 面板 → 分类与看板 → 设置。 */ if (this.editOpen) { this.editOpen = false; return true; } + if (this.panelId > 0) { + this.closePanel(); + return true; + } if (this.boardSheetOpen) { this.boardSheetOpen = false; return true; @@ -120,9 +147,30 @@ export struct FbTasksPage { aboutToAppear(): void { FB_BACK.register('tasks', (): boolean => this.backOnce()); this.load(); + this.loadMeta(); + } + + /** 拉全部看板 + 分类(筛选条、编辑表单、面板的"挪分组"共用一份) */ + private async loadMeta(): Promise { + await loadPath('tasks_meta', '/api/v1/tasks?view=boards', parseTaskMeta, + (m: FbTaskMeta): void => { + this.metaBoards = m.boards; + this.metaCats = m.cats; + }, + (note: string): void => { + this.syncNote = note; + }); } private async load(): Promise { + /* 🔴 v79 修一个真 bug:设置里的「只看已完成」以前筛的是**看板视图**的结果, + 而看板视图在服务端就写死了 `status <> 'done'`(`Task::boardView` 那条 SQL) + ⇒ 筛出来永远是空,用户看到的是"已完成里一条都没有"。 + 现在改成真的打 `?view=done`(网页 `Task::doneList` 同一口径)。 */ + if (this.statusFilter === 'done') { + await this.loadDone(); + return; + } await loadModule('tasks', parseTasks, (rows: FbTaskItem[]): void => { this.tasks = rows; @@ -132,31 +180,38 @@ export struct FbTasksPage { }); } + /** 已完成列表(单独缓存键:与看板视图**形状不同**,不能共用 'tasks' 那个键) */ + private async loadDone(): Promise { + await loadPath('tasks_done', '/api/v1/tasks?view=done', parseTaskList, + (rows: FbTaskItem[]): void => { + this.doneTasks = rows; + }, + (note: string): void => { + this.syncNote = note; + }); + } + /** 顶部提示(同步失败 / 未登录时显示一行小字) */ @Local syncNote: string = ''; /** - * 设置浮层开着 ⇒ 让底部悬浮快捷栏让位。 + * 本页任何一个浮层开着 ⇒ 底部悬浮快捷栏让位。 * * 🔴 悬浮栏在**更外层 Index 的 Stack** 里,层级比本页高 ⇒ * 浮层底部的按钮会被它盖住、点击被抢走(名册/日程页都踩过)。 + * + * ⚠️🔴 本页所有浮层开关(设置 / 分类看板 / 新建编辑 / 任务面板)**只能有 + * **这一个** `@Monitor` 赋 `sheetCount`,且必须**取并集**。 + * 曾经这里是两个函数(一个只管 `boardSheetOpen`、一个管四个)—— + * 两个都监听同一个字段、都写同一个值,关掉其中一个时谁最后执行不确定 + * ⇒ 会把另一个还开着的浮层"顺便让位结束",悬浮栏浮上来盖住面板。 + * (2026-10-22 自查发现;守卫里有断言防回退) */ - @Monitor('settingsOpen') - onSettingsChanged(): void { - fbAppState().sheetCount = this.settingsOpen ? 1 : 0; - } - - /** 新建/编辑浮层开着 ⇒ 底部悬浮快捷栏也要让位(与 `settingsOpen` 不会同时开) */ - @Monitor('editOpen') - onEditChanged(): void { - fbAppState().sheetCount = this.editOpen ? 1 : 0; - } - - /** 「分类与看板」浮层同理 */ - @Monitor('boardSheetOpen') - onBoardSheetChanged(): void { - fbAppState().sheetCount = this.boardSheetOpen ? 1 : 0; + @Monitor('settingsOpen', 'boardSheetOpen', 'editOpen', 'panelId') + onAnySheetChanged(): void { + const any: boolean = this.settingsOpen || this.boardSheetOpen || this.editOpen || this.panelId > 0; + fbAppState().sheetCount = any ? 1 : 0; } /** 打开「新建」浮层(清空回填字段) */ @@ -166,17 +221,46 @@ export struct FbTasksPage { this.editingNote = ''; this.editingDueAt = ''; this.editingBoardId = 0; + this.editingCatId = 0; + this.editingRepeat = ''; this.opNote = ''; this.editOpen = true; } - /** 打开「编辑」浮层(把这条任务的字段回填进去) */ - private openEdit(t: FbTaskItem): void { - this.editingId = t.id; - this.editingTitle = t.title; - this.editingNote = t.note; - this.editingDueAt = t.dueAt; - this.editingBoardId = t.boardId; + /** + * 打开任务面板(v79)。 + * + * 🔴 用户 2026-10-22:「任务模块的功能需要完善。」 + * 以前点卡片**直接进编辑表单** —— 想勾个子任务、看留言、加个照片都没地方去; + * 而且误触一下卡片就开始改标题。现在点卡片先开面板(只读 + 就地动作), + * 面板里的「编辑」才进表单。 + */ + private openPanel(t: FbTaskItem): void { + this.panelId = t.id; + this.panelSeed = t; + this.opNote = ''; + } + + private closePanel(): void { + this.panelId = 0; + this.panelSeed = null; + this.editOpen = false; + } + + /** + * 打开「编辑」浮层(回填字段)。 + * + * ⚠️ 入参是**详情**不是列表行:分类/循环只有详情接口才给, + * 拿列表行回填会把这两项当成"空"提交 ⇒ 一保存就清空(v77 同类坑)。 + */ + private openEdit(d: FbTaskDetail): void { + this.editingId = d.base.id; + this.editingTitle = d.base.title; + this.editingNote = d.base.note; + this.editingDueAt = d.base.dueAt; + this.editingBoardId = d.base.boardId; + this.editingCatId = d.categoryId; + this.editingRepeat = d.repeat; this.opNote = ''; this.editOpen = true; } @@ -220,8 +304,16 @@ export struct FbTasksPage { this.load(); } - /** 看板列表(从已加载的任务里反推:`/tasks` 的 boards 里没任务的那些画出来也没用) */ + /** + * 看板列表:**优先用元数据**(`?view=boards`,含空看板)。 + * + * ⚠️ 元数据还没回来时退回"从任务里反推"(老行为),这样首屏不会空着; + * 但**真正可选**的看板以元数据为准 —— 反推漏掉空看板是老 bug。 + */ private boardOpts(): FbBoardOpt[] { + if (this.metaBoards.length > 0) { + return this.metaBoards; + } const out: FbBoardOpt[] = []; const seen: number[] = []; for (const t of this.tasks) { @@ -326,6 +418,7 @@ export struct FbTasksPage { .borderRadius(FbMetric.radiusPill) .onClick((): void => { this.statusFilter = ''; + this.load(); }) } } @@ -405,6 +498,8 @@ export struct FbTasksPage { if (action === 'taskdone') { this.statusFilter = 'done'; this.settingsOpen = false; + /* 🔴 必须重拉:看板视图里根本没有 done,本地筛只会得到空列表 */ + this.load(); return; } if (action === 'taskboards') { @@ -417,16 +512,46 @@ export struct FbTasksPage { .transition(fbSheetTransition()) } + // ---- 任务面板(v79:点卡片打开,可就地处理)---- + /* ⚠️ 面板自带遮罩与 Stack,这里**不要**再包一层 —— 两层暗色会叠在一起。 */ + if (this.panelId > 0) { + FbTaskPanel({ + taskId: this.panelId, + seed: this.panelSeed, + boards: this.boardOpts(), + accent: this.accent, + refreshTick: this.panelTick, + onClose: (): void => { + this.closePanel(); + }, + onEdit: (d: FbTaskDetail): void => { + this.openEdit(d); + }, + onChanged: (): void => { + /* 状态/看板/子任务都可能改变排序与计数 ⇒ 重拉列表 */ + this.load(); + }, + onDeleted: (): void => { + this.closePanel(); + this.load(); + } + }) + .transition(fbSheetTransition()) + } + // ---- 新建 / 编辑任务浮层(v65)---- if (this.editOpen) { FbTaskEditSheet({ accent: this.accent, boards: this.boardOpts(), + cats: this.metaCats, editingId: this.editingId, editingTitle: this.editingTitle, editingNote: this.editingNote, editingDueAt: this.editingDueAt, editingBoardId: this.editingBoardId, + editingCatId: this.editingCatId, + editingRepeat: this.editingRepeat, onClose: (): void => { this.editOpen = false; }, @@ -434,6 +559,8 @@ export struct FbTasksPage { this.editOpen = false; // 服务端会派生逾期/看板色,保存后重拉一遍 this.load(); + // 面板还开着 ⇒ 让它自己重拉(标题/截止/分类都变了) + this.panelTick = this.panelTick + 1; } }) .transition(fbSheetTransition()) @@ -663,14 +790,19 @@ export struct FbTasksPage { .borderRadius(FbMetric.radius) .margin({ bottom: 8 }) .alignItems(HorizontalAlign.Start) - /* 🔴 v65:整卡可点 → 打开编辑(原来列表项**没有 `onClick`** ⇒ "点不进去") */ + /* 🔴 v65:整卡可点 ⇒ "点得进去";v79 起进的是**面板**(不是直接进编辑表单) */ .onClick((): void => { - this.openEdit(t); + this.openPanel(t); }) } + /** 当前数据源:看板视图 或 已完成列表(两者的形状不同,不能混用) */ + private source(): FbTaskItem[] { + return this.statusFilter === 'done' ? this.doneTasks : this.tasks; + } + private filtered(): FbTaskItem[] { - let list: FbTaskItem[] = this.tasks; + let list: FbTaskItem[] = this.source(); if (this.boardFilter.length > 0) { list = list.filter((t: FbTaskItem): boolean => t.board === this.boardFilter); }