Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbTaskPanel.ets
T
laoyang 25a0797a74 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、编译通过并已装机。
2026-10-22 16:40:44 +08:00

1061 lines
32 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 FamilyBook · 任务面板(鸿蒙端 v79)
*
* 🔴 用户 2026-10-22 原话:
* 「鸿蒙端的功能,也需要同样适配,在首页上可以直接修改编辑任务,
* 另外任务模块的功能需要完善。」
*
* 对应网页 v78 的「就地处理面板」(`templates/tasks/_panel.php`):
* 首页点一条任务 / 任务页点一张卡 —— 都在**当前页面**弹出这块面板,
* 不跳模块、不跳整页,就能:
* · 改状态(待办 / 进行中 / 已完成)
* · 挪分组(换看板)
* · 勾子任务、加一条子任务
* · 加图片附件、删附件
* · 发留言
* · 打开编辑表单(标题/备注/期限/看板/负责人/分类/循环)
* · 删任务(二次确认)
*
* ⚠️ 为什么不直接复用 `FbTaskEditSheet`:那是**表单**(点开就在编辑)。
* 用户此刻多数是"看看它是什么、顺手改一下状态"。拿表单当详情,
* 误触一下就把标题改了(v73 的取舍,见 `FbTaskDetailSheet` 文件头)。
* ⇒ 面板只读为主 + 两颗明确的动作(编辑 / 删除),编辑才进表单。
*
* 🔴 三条纪律:
* ① 改状态**只走** `POST /tasks/<id>/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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 })
}
}