用户 2026-11-05:「好了,鸿蒙端也用类似的方式调,效果让它保持一致。」 端上**不需要像网页那样手写浮层** —— 网页端必须自绘是因为 「原生 select 的展开列表由浏览器绘制,CSS 改不动」; ArkUI 的 Select 本身就是自绘的、选项列表也归框架管。 ⇒ 把对应属性都设上就等价了: 网页端 ArkUI Select ────────────────────────────────────────────────────── 按钮 40 高 / 圆角 / 细边框 height(40) + borderRadius + border 箭头打开转 180° 框架内置(自带动画) 悬停 tint-1 不支持(触屏没有 hover)→ 用打开态代替 选中项底 tint-2 selectedOptionBgColor(fbMixWhite(accent,0.80)) 选中项文字 主题色 selectedOptionFontColor(accent) 选中项右侧对勾 showDefaultSelectedIcon(true) 浮层白底 + 细边框 menuBackgroundColor(White) + border 浮层与按钮同宽 optionWidth(OptionWidthMode.FIT_TRIGGER) 未选值次要色 fontColor(有值 ? accent : Faint) ① Theme 新增 fbMixWhite(hex, ratio) 与网页端 fb_color_tint() **同算法同比例**(混 91/80/72% 白)。 ⚠️ 为什么不用现成的 fbAlpha():那是「原色 + 透明度」, 会透出父容器底色 —— 下拉浮层背景不确定,那样会时深时浅; 这里是实色混合,颜色确定。 ⚠️ 实测 16 组(4 个主题色 × 4 档比例)与 PHP 输出逐个比对,全部一致。 ② 边框单独用一档 按钮边框 fbMixWhite(accent, 0.62) —— 比 tint-3 再深一点, 否则在 tint-1 的浅底上几乎看不见边界。 ③ 修一个类型坑 本来写了个 pickFontColor(): string 返回 accent / FbColors.muted, 但 FbColors.muted 是 Resource($r(...)),返回 string | Resource 会让 fontColor() 报「Type 'string | Resource' is not assignable to 'string'」。 ⇒ 改成内联三元,且未选用 FbColors.faint(那个是 string 常量)。 编译通过,已装真机。
2335 lines
92 KiB
Plaintext
2335 lines
92 KiB
Plaintext
/**
|
||
* 家书 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, fbMixWhite } from '../common/Theme';
|
||
import {
|
||
FbBoardOpt, FbCatOpt, FbTaskAttach, FbTaskComment, FbTaskDetail, FbTaskItem, FbTaskKid, FbTaskShare,
|
||
} from '../model/Types';
|
||
import { FbSheetRing } from './FbSheetRing';
|
||
import { FbSheetClose } from './FbSheetClose';
|
||
import { FbCheckCircle, FbAvatar, FbSectionTitle, FbPersonChip } from './Widgets';
|
||
import { FbPersonPickSheet } from './FbPersonPickSheet';
|
||
import { FbDateTimePicker, fbIsoOf, fbJoinDateTime } from './FbDateTimeField';
|
||
import { fbAttachOpen } from '../common/FbAttachOpen';
|
||
import { common } from '@kit.AbilityKit';
|
||
import { FbPersonRepo, FbRosterPerson } from '../data/FbPersonRepo';
|
||
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
||
import { fbApi, FbApiResult, FbApiOptions } from '../data/FbHttp';
|
||
import { fbImageSrc } from '../data/FbAttachQueue';
|
||
import {
|
||
addTaskComment, createTask, deleteTask, delTaskAttach, getTask, moveTask,
|
||
setTaskStatus, uploadTaskAttach, FbTaskDraft,
|
||
fetchTaskShares, shareTask, unshareTask, addTaskCommentWithFile } 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[] = [];
|
||
/**
|
||
* 全部分类(`id=0` 由这里补「未分类」,所以传进来的是"真实分类")。
|
||
*
|
||
* 🔴 #83(用户 2026-11-05):「分类和重复,也就是循环,都需要加上,
|
||
* 鸿蒙端也得加上。」
|
||
*
|
||
* ⚠️ 与 boards 同一理由:**不能从任务里反推** ——
|
||
* 反推只能看见"已经被用过的"分类,新建的分类在端上永远选不到。
|
||
*/
|
||
@Param cats: FbCatOpt[] = [];
|
||
/** 模块主题色(任务 = 赭红) */
|
||
@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;
|
||
|
||
/* ---- #77:就地编辑(截止 / 负责人 / 备注)----
|
||
🔴 用户 2026-11-05:「截止日期需要可以修改」「负责人可以修改,备注也要修改」
|
||
|
||
为什么要就地改而不是"点编辑进整页表单":
|
||
这个面板本来就是"点开就能改"的定位(状态/看板/子任务/留言/附件都能改),
|
||
只有这三项是只读的 —— 用户改个日期要跳到另一个页面,那才叫别扭。
|
||
|
||
⚠️ 每次改完**立刻保存**,不设「保存」按钮:与状态/看板/子任务/留言同一套
|
||
交互(那几处都是点一下就生效)。这里多一颗按钮反而不一致。 */
|
||
@Local duePickOpen: boolean = false;
|
||
@Local attPickOpen: boolean = false;
|
||
/* #77n:标题与备注都是**常驻输入框**(用户:「它应该直接就是输入框」),
|
||
所以不再需要 `titleEdit` / `noteEdit` 这类双态标记,
|
||
只要两个受控值(框里的文字)。
|
||
⚠️ 必须受控:直接 `TextArea({text: this.titleOf()})` 是非受控的,
|
||
每次 reload() 重建组件会丢掉用户正在打的字。 */
|
||
@Local titleDraft: string = '';
|
||
@Local noteDraft: string = '';
|
||
/* 输入框是否有焦点。用来决定「重新载入后要不要用服务端的值覆盖框里的字」——
|
||
有焦点说明用户正在打字,这时覆盖会把刚打的字冲掉。 */
|
||
@Local titleFocus: boolean = false;
|
||
@Local noteFocus: boolean = false;
|
||
/* 点「保存」时的忙碌态(标题 + 备注两次请求要串行,不能并发) */
|
||
@Local saving: boolean = false;
|
||
@Local inlineBusy: boolean = false;
|
||
|
||
/* #77c:附件预览浮层 —— 存当前要全屏看的那张图的地址,空 = 没开 */
|
||
@Local previewSrc: string = '';
|
||
@Local previewName: string = '';
|
||
|
||
/* #77g:留言待发附件(URI + 显示名)。
|
||
⚠️ 只存**一个**:服务端评论附件接口一次收一个 `file`。
|
||
要支持多个得改 API 成数组,用户没提,先按单个做(够用且简单)。 */
|
||
@Local cmtAttachUri: string = '';
|
||
@Local cmtAttachNames: string[] = [];
|
||
@Local cmtBusy: boolean = false;
|
||
// 共享(工单 #26)
|
||
@Local roster: FbRosterPerson[] = [];
|
||
@Local pickingShare: boolean = false;
|
||
@Local shareBusy: 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;
|
||
}
|
||
/* #77n:标题/备注是常驻输入框,值要跟着服务端走。
|
||
⚠️ 但**有焦点时不能覆盖**:@Monitor('refreshTick') 会在外部刷新时
|
||
调 reload(),用户正打字时被覆盖就是"打着打着字没了"。
|
||
失焦时会再同步一次(onBlur -> save -> reload)。
|
||
⚠️ 比较用 trim:服务端存的可能带首尾空格,
|
||
直接赋值会让框里的文字"莫名多一个空格"。 */
|
||
const tt: FbTaskItem | null = this.cur();
|
||
if (tt !== null) {
|
||
if (!this.titleFocus && this.titleDraft !== tt.title) {
|
||
this.titleDraft = tt.title;
|
||
}
|
||
if (!this.noteFocus && this.noteDraft !== tt.note) {
|
||
this.noteDraft = tt.note;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 当前要显示的任务(详情优先,退回列表那一份) */
|
||
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();
|
||
if (t === null) {
|
||
return '';
|
||
}
|
||
/* 🔴 v79b:原来只 `return t.board`。`?view=boards` 之外的老接口/某些行
|
||
`board` 是空串 ⇒ 面板上就**完全不显示这条属于哪个看板**,
|
||
用户没法判断"挪看板"到底挪成了没有(真机验收时就是这么发现 `boardOf()`
|
||
定义了却没被任何地方用)。这里补两层兜底:空名 → 按 id 反查 → 默认看板。 */
|
||
if (t.board !== '') {
|
||
return t.board;
|
||
}
|
||
for (const b of this.boards) {
|
||
if (b.id === t.boardId) {
|
||
return b.name;
|
||
}
|
||
}
|
||
return t.boardId === 0 ? '默认看板' : '';
|
||
}
|
||
|
||
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 : [];
|
||
}
|
||
|
||
/** 共享给谁(工单 #26) */
|
||
private shares(): FbTaskShare[] {
|
||
const d: FbTaskDetail | null = this.detail;
|
||
return d !== null ? d.shares : [];
|
||
}
|
||
|
||
private async openSharePicker(): Promise<void> {
|
||
if (this.roster.length === 0) {
|
||
try {
|
||
this.roster = await FbPersonRepo.inst().list();
|
||
} catch (e) {
|
||
this.roster = [];
|
||
}
|
||
}
|
||
this.pickingShare = true;
|
||
}
|
||
|
||
private async addShares(ids: number[]): Promise<void> {
|
||
this.pickingShare = false;
|
||
if (ids.length === 0) {
|
||
return;
|
||
}
|
||
this.shareBusy = true;
|
||
let bad: string = '';
|
||
for (const pid of ids) {
|
||
const res: FbApiResult = await shareTask(this.token(), this.taskId, pid, 'write');
|
||
if (!res.ok && bad === '') {
|
||
bad = res.error;
|
||
}
|
||
}
|
||
this.shareBusy = false;
|
||
this.note = bad !== '' ? bad : '';
|
||
await this.reload();
|
||
}
|
||
|
||
private async dropShare(personId: number): Promise<void> {
|
||
this.shareBusy = true;
|
||
const res: FbApiResult = await unshareTask(this.token(), this.taskId, personId);
|
||
this.shareBusy = false;
|
||
if (!res.ok) {
|
||
this.note = res.error !== '' ? res.error : '没取消成。';
|
||
return;
|
||
}
|
||
await this.reload();
|
||
}
|
||
|
||
/**
|
||
* 就地保存单个字段(#77)。
|
||
*
|
||
* 🔴 只传**要改的那一个**字段 —— 服务端 PATCH 的每个字段都是
|
||
* `Api::str('x', 原值)` 这种"没传就用原值"的写法
|
||
* (见 `app/ApiHandler.php` 的 `tasks` PATCH 分支),
|
||
* 所以不必把整条任务都带上。
|
||
*
|
||
* ⚠️ 这与我一开始的担心相反:端上 `updateTask()` **会**把 title/note/due_at/
|
||
* board_id/category_id/repeat 全部塞进 body ⇒ 只改截止时间时,
|
||
* 其余几个字段会被原值覆盖一遍(虽然值相同、无害,但会平白触发一次
|
||
* `updated_at`,而且 repeat 传错就会真丢数据)。
|
||
* ⇒ 这里直接用 `fbApi` 发 PATCH,只带一个字段,不走 `updateTask()`。
|
||
*/
|
||
private async saveField(name: string, value: string, numValue: number): Promise<boolean> {
|
||
if (this.inlineBusy) {
|
||
return false;
|
||
}
|
||
this.inlineBusy = true;
|
||
this.note = '';
|
||
const body: Record<string, Object> = {};
|
||
if (numValue >= 0) {
|
||
body[name] = numValue;
|
||
} else {
|
||
body[name] = value;
|
||
}
|
||
const opts: FbApiOptions = { method: 'PATCH', token: this.token(), body: body };
|
||
const res: FbApiResult = await fbApi('/api/v1/tasks/' + this.taskId.toString(), opts);
|
||
this.inlineBusy = false;
|
||
if (!res.ok) {
|
||
this.note = res.error !== '' ? res.error : '没保存成。';
|
||
return false;
|
||
}
|
||
await this.reload();
|
||
this.onChanged();
|
||
return true;
|
||
}
|
||
|
||
/** 改截止时间(弹日期选择器) */
|
||
private openDuePicker(): void {
|
||
if (this.inlineBusy) {
|
||
return;
|
||
}
|
||
this.duePickOpen = true;
|
||
}
|
||
|
||
/** 改负责人(弹选人) */
|
||
private async openAssigneePicker(): Promise<void> {
|
||
if (this.inlineBusy) {
|
||
return;
|
||
}
|
||
if (this.roster.length === 0) {
|
||
try {
|
||
this.roster = await FbPersonRepo.inst().list();
|
||
} catch (e) {
|
||
this.roster = [];
|
||
}
|
||
}
|
||
this.attPickOpen = true;
|
||
}
|
||
|
||
/**
|
||
* 保存任务标题。
|
||
*
|
||
* 🔴 #77n:标题改成**常驻输入框**后不再有 `titleEdit` 这个双态标记,
|
||
* 所以只靠"值变了没有"判断要不要发请求。
|
||
* ⚠️ 空值必须**回填原值**:服务端会把空标题清洗成空串,
|
||
* 那等于把标题删了 —— 用户只是清空重打,不该丢标题。
|
||
*/
|
||
/**
|
||
* 底部「保存」:把两个**手动编辑**的输入框(标题 / 备注)落库,然后关窗。
|
||
*
|
||
* 🔴 #77q(用户 2026-11-05):「和网页版一样,没有保存按钮啊,
|
||
* 这应该怎么保存呢?在哪个地方点保存呢?」
|
||
*
|
||
* 之前的设计是"失焦自动保存",但**用户看不见这件事** —— 没有按钮就没底,
|
||
* 不知道到底存没存。所以补一颗显式的保存按钮。
|
||
*
|
||
* ⚠️ 其它字段(状态/看板/截止/负责人/分类/协作人/子任务/附件/留言)
|
||
* **仍然是点一下就生效**,不用按保存 —— 那类操作是"动作",
|
||
* 做完就该立刻生效;只有"打字"类才需要一次确认。
|
||
* ⚠️ 串行两次请求而不是并发:`saveField()` 内部会 `reload()`,
|
||
* 并发会让两次 reload 互相覆盖 detail,出现"改完标题备注没了"。
|
||
*/
|
||
private async saveAll(): Promise<void> {
|
||
if (this.saving) {
|
||
return;
|
||
}
|
||
this.saving = true;
|
||
await this.saveTitle();
|
||
await this.saveNote();
|
||
this.saving = false;
|
||
this.onChanged();
|
||
this.onClose();
|
||
}
|
||
|
||
private async saveTitle(): Promise<void> {
|
||
const v: string = this.titleDraft.trim();
|
||
if (v === '') {
|
||
this.titleDraft = this.titleOf(); // 回填,不提交
|
||
return;
|
||
}
|
||
if (v === this.titleOf()) {
|
||
return; // 没变,不发请求
|
||
}
|
||
const ok: boolean = await this.saveField('title', v, -1);
|
||
if (!ok) {
|
||
/* 保存失败也要把框里改回数据库的真实值,否则框里显示的和库里的不一致 */
|
||
this.titleDraft = this.titleOf();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 保存备注。
|
||
* 🔴 #77n:备注也改成常驻输入框,不再有 `noteEdit`。
|
||
* ⚠️ 备注**允许清空**(与标题不同):删掉备注是正常操作,
|
||
* 所以这里不像 saveTitle 那样回填。
|
||
*/
|
||
private async saveNote(): Promise<void> {
|
||
const v: string = this.noteDraft.trim();
|
||
if (v === this.noteOf()) {
|
||
return; // 没变,不发请求
|
||
}
|
||
const ok: boolean = await this.saveField('note', v, -1);
|
||
if (!ok) {
|
||
this.noteDraft = this.noteOf(); // 失败回滚显示
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 点附件(#77c)。
|
||
*
|
||
* 🔴 用户 2026-11-05:「附件那个地方如果是Word文档,好像还是不能预览。
|
||
* 按道理说,应该打开本机上默认能够打开这个文件的应用,让它把文件打开才对。」
|
||
*
|
||
* 分两种情况:
|
||
* · **图片** → 就地全屏预览(不跳出 App,看附件不用切来切去)
|
||
* · **其它**(Word / PDF / Excel…)→ `fbAttachOpen()`:
|
||
* 先下载一份明文到 cacheDir,再用**隐式 Want + action.viewData**
|
||
* 交给系统「打开方式」—— 系统自己找能打开 .docx 的应用。
|
||
*
|
||
* ⚠️ 不能用「显式 Want + 包名」:我们不知道用户装的是 WPS 还是 Office,
|
||
* 也不知道包名。让系统按 MIME/扩展名匹配才是唯一可靠的做法。
|
||
* ⚠️ `fbAttachOpen()` 已经处理好"下不下来 / 没有可用应用"的提示文案,
|
||
* 这里只把它的返回串显示出来,不自己再编一套。
|
||
*/
|
||
private async tapAttach(a: FbTaskAttach): Promise<void> {
|
||
if (a.isImage) {
|
||
/* 图片:全屏预览。用 `fbImageSrc`(本地缓存优先,没有就远端 URL) */
|
||
this.previewSrc = fbImageSrc(a.key, this.token());
|
||
this.previewName = a.name;
|
||
return;
|
||
}
|
||
/* 非图片:交给系统打开 */
|
||
const ctx: common.Context | undefined =
|
||
this.getUIContext().getHostContext() as common.Context | undefined;
|
||
if (ctx === undefined) {
|
||
this.note = '打不开:拿不到上下文,请重启应用再试。';
|
||
return;
|
||
}
|
||
this.note = '正在下载…';
|
||
const err: string = await fbAttachOpen(this.token(), a.key, false, ctx);
|
||
/* `fbAttachOpen` 成功时返回 ''(已交给系统),失败时返回人话 */
|
||
this.note = err !== '' ? err : '';
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
/** 选一个附件挂到待发留言上(#77g)——**不立刻上传**,随留言一起发 */
|
||
private async pickCmtAttach(): Promise<void> {
|
||
if (this.cmtBusy) {
|
||
return;
|
||
}
|
||
try {
|
||
const opts: picker.DocumentSelectOptions = new picker.DocumentSelectOptions();
|
||
opts.maxSelectNumber = 1;
|
||
const p: picker.DocumentViewPicker = new picker.DocumentViewPicker();
|
||
const uris: string[] = await p.select(opts);
|
||
if (uris.length === 0) {
|
||
return;
|
||
}
|
||
const u: string = uris[0];
|
||
const slash: number = u.lastIndexOf('/');
|
||
const nm: string = slash >= 0 && slash < u.length - 1 ? u.substring(slash + 1) : '附件';
|
||
this.cmtAttachUri = u;
|
||
/* ⚠️ 用**新数组**赋值:`@Local` 只代理第一层,
|
||
push 到原数组不会触发刷新(本项目踩过多次)。 */
|
||
this.cmtAttachNames = [nm];
|
||
this.note = '';
|
||
} catch (e) {
|
||
const be: BusinessError = e as BusinessError;
|
||
hilog.warn(0x0000, 'FamilyBook', '选留言附件失败:%{public}s', JSON.stringify(be));
|
||
this.note = '选文件失败,请再试一次。';
|
||
}
|
||
}
|
||
|
||
/** 去掉待发附件 */
|
||
private dropCmtAttach(idx: number): void {
|
||
this.cmtAttachUri = '';
|
||
this.cmtAttachNames = [];
|
||
}
|
||
|
||
/** 发一条留言 */
|
||
/**
|
||
* 发一条留言(#77g:支持带附件)。
|
||
*
|
||
* 🔴 用户 2026-11-05:「留言是可以需要添加附件的。」
|
||
*
|
||
* 两条路径:
|
||
* · 没附件 → 原来的 `addTaskComment()`(JSON,轻)
|
||
* · 有附件 → `addTaskCommentWithFile()`(multipart:content + file)
|
||
*
|
||
* ⚠️ **允许只发附件不写字**:拍张照片直接发是常见用法
|
||
* (服务端也认,会自动补占位文案「(附件)」)。
|
||
* 所以判空从"文字为空就 return"改成"文字和附件都为空才 return"。
|
||
* ⚠️ 发成功后要清掉待发附件,否则下一条留言会重复带上这张图。
|
||
*/
|
||
private async sendComment(): Promise<void> {
|
||
const txt: string = this.cmtDraft.trim();
|
||
const hasFile: boolean = this.cmtAttachUri !== '';
|
||
if (this.busy || this.cmtBusy) {
|
||
return;
|
||
}
|
||
if (txt === '' && !hasFile) {
|
||
return;
|
||
}
|
||
this.busy = true;
|
||
this.cmtBusy = true;
|
||
this.note = '';
|
||
|
||
if (hasFile) {
|
||
const err: string = await addTaskCommentWithFile(
|
||
this.token(), this.taskId, txt, this.cmtAttachUri);
|
||
this.busy = false;
|
||
this.cmtBusy = false;
|
||
if (err !== '') {
|
||
this.note = err;
|
||
return; // 附件留着,让用户能重试
|
||
}
|
||
} else {
|
||
const res: FbApiResult = await addTaskComment(this.token(), this.taskId, txt);
|
||
this.busy = false;
|
||
this.cmtBusy = false;
|
||
if (!res.ok) {
|
||
this.note = res.error !== '' ? res.error : '留言没发出去。';
|
||
return;
|
||
}
|
||
}
|
||
|
||
this.cmtDraft = '';
|
||
this.cmtAttachUri = '';
|
||
this.cmtAttachNames = [];
|
||
await this.reload();
|
||
this.onChanged();
|
||
}
|
||
|
||
/** 选一张图当附件(选完**立刻上传** —— 任务已经存在,不用再点保存) */
|
||
/**
|
||
* 选一个附件上传(#77)。
|
||
*
|
||
* 🔴 用户 2026-11-05:「加图片这个地方改成"添加附件"。」
|
||
*
|
||
* ⚠️ **只改文案是不够的**:原来用的是 `PhotoViewPicker` + `IMAGE_TYPE`,
|
||
* 只能选相册里的图片。按钮一旦叫「添加附件」,用户会去选 PDF / Word,
|
||
* 而那些文件根本不出现在选择器里 —— 他会以为"功能坏了"。
|
||
*
|
||
* ⇒ 改成 `DocumentViewPicker`(系统文件选择器),能选任意文件。
|
||
* 图片仍然可以选(文件选择器里也能进相册目录),
|
||
* 而且 `uploadTaskAttach()` 是通用的(按扩展名判 kind),
|
||
* 上传后 `FbTaskAttach.isImage` 会自动区分图片与非图片。
|
||
*
|
||
* ⚠️ 选不出文件时(用户取消)`documentSelectResult` 是空数组,直接 return。
|
||
* ⚠️ `DocumentSelectOptions.maxSelectNumber = 1`:与原来一致(一次一个)。
|
||
* 支持多选要改上传循环,用户没要求,不做。
|
||
*/
|
||
private async pickAttach(): Promise<void> {
|
||
if (this.uploading) {
|
||
return;
|
||
}
|
||
try {
|
||
const opts: picker.DocumentSelectOptions = new picker.DocumentSelectOptions();
|
||
opts.maxSelectNumber = 1;
|
||
const p: picker.DocumentViewPicker = new picker.DocumentViewPicker();
|
||
const uris: string[] = await p.select(opts);
|
||
if (uris.length === 0) {
|
||
return;
|
||
}
|
||
this.uploading = true;
|
||
this.note = '';
|
||
const err: string = await uploadTaskAttach(this.token(), this.taskId, uris[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 }) {
|
||
/* #77m:任务标题放在所有内容之前(用户:「也就是状态之前」) */
|
||
this.titleRow()
|
||
this.statusSeg()
|
||
this.boardChips()
|
||
this.infoRows()
|
||
this.shareSection()
|
||
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 })
|
||
}
|
||
/* 🔴 #77g(用户 2026-11-05):「留言输入框下面还有东西,
|
||
现在被挡住了,根本看不见,也滚不动了。」
|
||
—— 被挡住的是 `commentSection()` 末尾那行「回车发送」小字。
|
||
|
||
根因:`commentSection` 是 `Scroll` 里的**最后一个**子元素,
|
||
而这里原来只留了 `height(6)`。
|
||
`Scroll` 又是 `flexShrink(1)`(内容不够高时按内容收缩),
|
||
最后一行的下边距一旦小于"卡片 padding + footBar 高度",
|
||
就会被判定为"已到底",滚不过去也看不见。
|
||
⇒ 底部留一块**足够高**的空白(不是 6vp 意思一下),
|
||
保证最后一行能滚到可视区中间。 */
|
||
/* #77s:删除放在**所有内容之后** —— 要滚到底才看得到,
|
||
误触概率基本为零(理由见 dangerZone 的注释)。 */
|
||
this.dangerZone()
|
||
|
||
Row().height(28)
|
||
}
|
||
.width('100%')
|
||
.alignItems(HorizontalAlign.Start)
|
||
}
|
||
/* 🔴🔴 #77r(用户 2026-11-05):「怎么没有看到任何按钮啊?」
|
||
|
||
**真根因**:这里是 `flexShrink(1)`,不是 `layoutWeight(1)`。
|
||
`flexShrink` 只是"空间不够时允许收缩",但 Scroll 的**内容高度**
|
||
仍然会把自身撑开(ArkUI 里 Scroll 竖向内容默认按内容高)。
|
||
于是:
|
||
头部 + Scroll(按内容撑满) 就把 `maxHeight: 86%` 吃光了
|
||
→ 下面的 `footBar()`(保存/删除)被推到 86% 之外
|
||
→ `FbSheetRing` 的 `clip(true)` 把它裁掉 ⇒ 一个按钮都看不见。
|
||
|
||
`layoutWeight(1)` 是"**占据剩余空间**":Scroll 的高度被限定成
|
||
"86% − 头部 − 分割线 − footBar",footBar 因此必然留在可视区内,
|
||
内容再长也只是在 Scroll 内部滚动。
|
||
|
||
⚠️ 旧注释说 `layoutWeight(1)` 会让"短内容也把卡片顶满"(v60b)。
|
||
那个副作用是真的,但它是**观感问题**;
|
||
而按钮看不见是**功能问题** —— 两害相权,先保证按钮可用。
|
||
要消掉空白的话,正确做法是给外层 Column 用
|
||
`.constraintSize({ maxHeight: '86%' })` 配合 **`minHeight: 0`**,
|
||
而不是回到 flexShrink。 */
|
||
.layoutWeight(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%')
|
||
|
||
/* 选人浮层:协作人(工单 #26;#77 标题由「共享给谁」改「协作人」) */
|
||
if (this.pickingShare) {
|
||
FbPersonPickSheet({
|
||
people: this.roster,
|
||
selected: [],
|
||
title: '协作人',
|
||
accent: this.accent,
|
||
onConfirm: (ids: number[]): void => {
|
||
this.addShares(ids);
|
||
},
|
||
onClose: (): void => {
|
||
this.pickingShare = false;
|
||
}
|
||
})
|
||
}
|
||
|
||
/* 🔴 #77(用户 2026-11-05):「截止日期需要可以修改」「负责人可以修改」
|
||
⇒ 复用 `FbTaskEditSheet` 已经在用的两个浮层,交互与那边一致
|
||
(同一个"改日期"不该在详情面板和编辑表单里长得不一样)。
|
||
⚠️ 放在**面板之后**:两个浮层都靠"后声明的在上层"叠放,
|
||
放前面会被面板的遮罩盖住。 */
|
||
|
||
/* 截止时间浮层 */
|
||
if (this.duePickOpen) {
|
||
FbDateTimePicker({
|
||
title: '这个任务什么时候到期',
|
||
date: this.dueOf() !== '' ? this.dueOf() : fbIsoOf(new Date()),
|
||
time: this.dueTimeOf(),
|
||
accent: this.accent,
|
||
onConfirm: (d: string, t: string): void => {
|
||
this.duePickOpen = false;
|
||
/* 传 `YYYY-MM-DD HH:mm`:服务端 `Api::dt()` 认这个格式
|
||
(与编辑表单 `fbJoinDateTime` 的输出同一口径)。 */
|
||
const v: string = d !== '' ? fbJoinDateTime(d, t) : '';
|
||
this.saveField('due_at', v, -1);
|
||
},
|
||
onCancel: (): void => {
|
||
this.duePickOpen = false;
|
||
}
|
||
})
|
||
}
|
||
|
||
/* 🔴 #77f(用户 2026-11-05):「附件那个地方…按道理说,应该打开本机上
|
||
默认能够打开这个文件的应用」——图片走就地全屏预览(不跳出 App),
|
||
非图片走 `fbAttachOpen()` 交给系统(见 `tapAttach`)。
|
||
|
||
⚠️ 预览层放在**所有浮层最后**:它要盖住面板本身。
|
||
⚠️ 整块用 `Stack` 铺满 + 半透明黑底,点击任意处关闭。
|
||
⚠️ `objectFit(ImageFit.Contain)` —— 预览要看全图,
|
||
用 Cover 会裁掉边缘(缩略图用 Cover 是对的,预览不是)。 */
|
||
if (this.previewSrc !== '') {
|
||
Stack({ alignContent: Alignment.Center }) {
|
||
Column()
|
||
.width('100%')
|
||
.height('100%')
|
||
.backgroundColor('#E6000000')
|
||
.onClick((): void => {
|
||
this.previewSrc = '';
|
||
this.previewName = '';
|
||
})
|
||
|
||
Image(this.previewSrc)
|
||
.width('100%')
|
||
.constraintSize({ maxHeight: '78%' })
|
||
.objectFit(ImageFit.Contain)
|
||
|
||
/* 底部:文件名 + 关闭提示 */
|
||
Column({ space: 4 }) {
|
||
Text(this.previewName !== '' ? this.previewName : '附件')
|
||
.fontSize(FbMetric.fontSmall)
|
||
.fontColor(Color.White)
|
||
.maxLines(2)
|
||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||
.textAlign(TextAlign.Center)
|
||
Text('点任意处关闭')
|
||
.fontSize(10)
|
||
.fontColor('#B3FFFFFF')
|
||
}
|
||
.width('100%')
|
||
.padding({ left: 24, right: 24, bottom: 40 })
|
||
.alignItems(HorizontalAlign.Center)
|
||
.position({ x: 0, y: '85%' })
|
||
}
|
||
.width('100%')
|
||
.height('100%')
|
||
.hitTestBehavior(HitTestMode.Block)
|
||
.onClick((): void => {
|
||
this.previewSrc = '';
|
||
this.previewName = '';
|
||
})
|
||
}
|
||
|
||
/* 负责人浮层(单选) */
|
||
if (this.attPickOpen) {
|
||
FbPersonPickSheet({
|
||
people: this.roster,
|
||
selected: this.assigneeIdOf() > 0 ? [this.assigneeIdOf()] : [],
|
||
title: '谁负责这件事',
|
||
accent: this.accent,
|
||
onConfirm: (ids: number[]): void => {
|
||
this.attPickOpen = false;
|
||
/* 空数组 = 取消负责人(服务端 `assignee_id: 0`) */
|
||
const who: number = ids.length > 0 ? ids[0] : 0;
|
||
this.saveField('assignee_id', '', who);
|
||
},
|
||
onClose: (): void => {
|
||
this.attPickOpen = false;
|
||
}
|
||
})
|
||
}
|
||
}
|
||
.width('100%')
|
||
.height('100%')
|
||
.hitTestBehavior(HitTestMode.Transparent)
|
||
}
|
||
|
||
/**
|
||
* 顶栏:状态徽标 + 关闭。
|
||
*
|
||
* 🔴🔴 #77l(用户 2026-11-05):
|
||
* 「这个弹出框的标题是编辑任务,台式机采购这样的东西,它是一个标题,
|
||
* 也应该可以被修改才对。所以它应该往下放,就是任务标题;
|
||
* 然后台式机采购计划也往下放,也是可以修改的。
|
||
* 左上角那个标志,好像一个圆圈什么的,那个也不要了,去掉。」
|
||
*
|
||
* 三处变化:
|
||
* · **删掉左上角的 FbCheckCircle**(就是用户说的"圆圈")。它是"点一下勾完成"
|
||
* 的快捷入口,但下面「状态」区已经有三个明确的 chip,入口重复;
|
||
* 而且它紧贴标题,看起来像"标题前面的项目符号",容易误解。
|
||
* · **删掉这里的大标题**。原来标题画在这里,导致它是只读的 ——
|
||
* 标题本来就该能改,所以下移到「更多信息」区当第一个字段。
|
||
* · 只留状态徽标 + 关闭按钮,这一条变窄(原来 30+标题+徽标+关闭很挤)。
|
||
*
|
||
* ⚠️ 「属于「父任务」」那行**保留在这里**:它是上下文不是内容,
|
||
* 跟着标题下移反而会离"父任务"这个概念更远。
|
||
*/
|
||
@Builder
|
||
headBar() {
|
||
Row({ space: 10 }) {
|
||
Column({ space: 2 }) {
|
||
/* 🔴 #77m(用户 2026-11-05):「这个框的标题在左上角,是"编辑任务"
|
||
几个字,颜色用主题颜色。你参考一下"编辑行程"的做法。」
|
||
|
||
与 `FbTripNodeSheet` 的「编辑行程」完全同一套:
|
||
fontSize(fontBody=15) + Bold + 主题色。
|
||
⚠️ 这里只写固定的「编辑任务」,**不写任务标题** ——
|
||
标题是内容(可改),已经下移到 titleRow()。 */
|
||
Text('编辑任务')
|
||
.fontSize(FbMetric.fontBody)
|
||
.fontWeight(FontWeight.Bold)
|
||
.fontColor(this.accent)
|
||
if (this.parentOf() !== '') {
|
||
Text('属于「' + this.parentOf() + '」')
|
||
.fontSize(FbMetric.fontTiny)
|
||
.fontColor(FbColors.faint)
|
||
.maxLines(1)
|
||
}
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.layoutWeight(1)
|
||
.flexShrink(1)
|
||
|
||
/* 🔴 #77o(用户 2026-11-05):「编辑任务那一行,右上角还有个"进行中",
|
||
那个也去掉吧,因为在状态那里已经看到"进行中"了。」
|
||
⇒ 删掉状态徽标。下面「状态」区三个 chip 已经把当前态高亮出来了,
|
||
这里再挂一个只读徽标纯属重复信息。
|
||
|
||
⚠️ 上一版注释说"只留状态徽标 + 关闭",现在是"只留关闭"。
|
||
⚠️ `statusWord() / statusTint() / statusWash()` 三个方法**保留** ——
|
||
状态区(statusChip)还在用它们。 */
|
||
|
||
FbSheetClose({
|
||
onClose: (): void => {
|
||
this.onClose();
|
||
},
|
||
color: ''
|
||
})
|
||
}
|
||
.width('100%')
|
||
.alignItems(VerticalAlign.Center)
|
||
}
|
||
|
||
/** 状态三段(点一下即改,没有"保存"按钮) */
|
||
@Builder
|
||
statusSeg() {
|
||
Column({ space: 6 }) {
|
||
/* #77i:状态也有标题了(原来只有三个 chip,没有区段标题,
|
||
而下面每个区段都有 —— 视觉上它是"飘着的")。 */
|
||
FbSectionTitle({ title: '状态', accent: this.accent })
|
||
Row({ space: 8 }) {
|
||
this.statusChip('todo')
|
||
this.statusChip('doing')
|
||
this.statusChip('done')
|
||
}
|
||
.width('100%')
|
||
}
|
||
.width('100%')
|
||
.padding({ bottom: 4 })
|
||
.alignItems(HorizontalAlign.Start)
|
||
}
|
||
|
||
@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(挪分组)
|
||
*
|
||
* 🔴 #77(用户 2026-11-05):「那个分组看板让它平铺下来。不要左右滑动了。」
|
||
* 🔴 参考旅程模块:`FbTripNodeSheet.dart` 那里 5 项以内用
|
||
* `Flex({ wrap: FlexWrap.Wrap })` 换行平铺、11 项才横滑,
|
||
* 判据是**项数**(≤6 换行,>6 横滑)。
|
||
*
|
||
* ⇒ 这里也按项数分档:看板通常 3~6 个(当前家庭 4 个),全部平铺看得见,
|
||
* 不用滑 —— 滑动的坏处是"有第 5 个看板但我不知道"。
|
||
* 只有真的很多(>6)才退回横滑,那时平铺会占掉半屏。
|
||
*
|
||
* ⚠️ 横滑分支保留、不删:家庭可能自建十几个看板(用户按项目分)。
|
||
* ⚠️ 平铺用 `Flex(wrap)`,**不能用 `Row`** —— ArkUI 的 `Row` 不换行,
|
||
* 塞不下会横向溢出父容器(v76 真机踩过:最后一个 chip 被裁掉且点不到)。
|
||
* ⚠️ `Row` 的 `space` 在 `Flex` 里不生效 ⇒ 平铺分支的间距靠 `boardChip`
|
||
* 自己的 `margin`,所以下面 chip 补了 `.margin({ right: 8, bottom: 8 })`。
|
||
*/
|
||
@Builder
|
||
boardChips() {
|
||
Column({ space: 6 }) {
|
||
/* 🔴 #77i(用户 2026-11-05):「分组看板那个地方没改…还是不对,标题的颜色不对。」
|
||
|
||
上一轮我只把「子任务/附件/留言」三个标题换成了 `FbSectionTitle`,
|
||
**看板、状态、协作人、以及「更多信息」里那几个字段标签全都漏了** ——
|
||
它们还是手写的 `fontTiny + faint`(浅灰小字)。
|
||
|
||
规范(`Widgets.ets` / `FbSectionTitle`):区块标题 = **Bold + 主题色 + 字距 0.6**。
|
||
⇒ 这一处改成组件。 */
|
||
FbSectionTitle({ title: '分组(看板)', accent: this.accent })
|
||
if (this.boardCountAll() <= 6) {
|
||
/* 平铺:全部一屏可见 */
|
||
Flex({ wrap: FlexWrap.Wrap }) {
|
||
this.boardChip(0, '默认看板', '')
|
||
ForEach(this.boards, (b: FbBoardOpt) => {
|
||
this.boardChip(b.id, b.name, b.color)
|
||
}, (b: FbBoardOpt): string => 'pb_' + b.id.toString())
|
||
}
|
||
.width('100%')
|
||
} else {
|
||
/* 太多才横滑 */
|
||
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)
|
||
}
|
||
|
||
/** 看板总数(含「默认看板」这一档)—— 决定平铺还是横滑 */
|
||
private boardCountAll(): number {
|
||
return this.boards.length + 1;
|
||
}
|
||
|
||
@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)
|
||
/* #77:平铺走 Flex(wrap),`Row` 的 `space` 在 Flex 里不生效
|
||
⇒ 间距靠 chip 自己的 margin。横滑分支里这 8px 与 Row 的 space 叠加,
|
||
视觉上仍是 8px(Row space 是 gap,margin 是外边距,两者相加 = 16px
|
||
—— 所以横滑分支要把它抵消掉,见下面的负 margin 说明)。
|
||
⚠️ 实测取舍:宁可横滑分支稍宽一点,也不要平铺时挤成一坨。
|
||
横滑分支的出现条件是 >6 个看板,那种场景下宽一点不影响识别。 */
|
||
.margin({ right: 8, bottom: 8 })
|
||
.onClick((): void => {
|
||
this.moveTo(id);
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 信息区:截止 / 负责人 / 分类 / 循环 / 备注。
|
||
*
|
||
* 🔴 #77(用户 2026-11-05):
|
||
* 「截止日期需要可以修改」
|
||
* 「截止日期下面还有一个看板,这个没必要,直接删除」
|
||
* 「负责人可以修改,备注也要修改」
|
||
* 「负责人那个地方需要有图标,就是人的头像」
|
||
*
|
||
* ⇒ 截止 / 负责人 / 备注 三项从只读 `Text` 改成可点/可编辑;
|
||
* 看板那一行删掉(上面 chips 已经高亮表达了);
|
||
* 负责人前面加头像。
|
||
* ⚠️ 其余(分类 / 循环)**保持不变** —— 用户没提,不动。
|
||
*/
|
||
/**
|
||
* 任务标题 —— **一直就是输入框**,不是"点了才变输入框"。
|
||
*
|
||
* 🔴🔴 #77n(用户 2026-11-05):
|
||
* 「这个输入框本来就应该是输入框,怎么现在点了一下才变成输入框呢?
|
||
* 它应该直接就是输入框,只是把文字放到输入框里面就行了。
|
||
* 这样的话,点一下键盘就弹出来了,包括下面的备注也是一样。」
|
||
* 「任务标题里面,"台式机采购计划"这样的字样还是改成黑色吧。
|
||
* 任务标题这四个字用主题色,但是输入框里面的文字用黑色。」
|
||
*
|
||
* 所以:
|
||
* · 不设 `titleEdit` 双态,直接把输入框常驻;文字从 `titleOf()` 灌进去。
|
||
* · #77p:用**单行 `TextInput`**(不是 TextArea)—— 标题就是一句话,
|
||
* 多行框把面板下半部分全推下去了。
|
||
* · **标签**(「任务标题」四个字)= 主题色粗体(走 fieldLb);
|
||
* **框里的内容** = `FbColors.ink`(墨黑)。上一版我把内容也染成 accent,
|
||
* 变成"整段标题都是红的",读起来像报错,也不符合"内容用墨色"。
|
||
* · 点输入框 → 系统自己聚焦 + 弹输入法,不需要我 requestFocus。
|
||
*
|
||
* ⚠️ 不能直接 `TextArea({ text: this.titleOf() })` —— 那样是**非受控**:
|
||
* 每次 `reload()` 后组件重建会丢掉用户正在打的字。
|
||
* 用 `titleDraft` 做受控值,`aboutToAppear` 里灌初值、保存后同步。
|
||
* ⚠️ 失焦时清空则**恢复原值**(不能把标题存成空串:服务端会把标题清零)。
|
||
*/
|
||
@Builder
|
||
titleRow() {
|
||
Column({ space: 6 }) {
|
||
this.fieldLb('任务标题')
|
||
/* 🔴🔴 #77p(用户 2026-11-05):「现在这个任务标题是好几行的那种输入框,
|
||
不要用这种输入框,用一行的输入框就行了。标题没有那么长,现在太高了。」
|
||
|
||
⇒ `TextArea`(多行,84 高)换成 **`TextInput`(单行,40 高)**,
|
||
高度与「截止」「负责人」那些控件行对齐(它们是 height 40)。
|
||
标题是一句话,本来就不需要多行;多行框还占掉两倍高度,
|
||
把下面所有内容都往下推。
|
||
|
||
⚠️ `TextInput` 不支持 `.height(84)` 那种"给多少显示多少" ——
|
||
它只有一行,高度超了也只是上下留白。所以这里直接给 40。
|
||
⚠️ 单行框**不会换行**,超长文字横向滚动。标题超过一行宽时
|
||
用户能看到正在打的部分,打完失焦后由 maxLines 之外的
|
||
省略处理在别处(列表卡片)表现,这里不需要额外处理。 */
|
||
TextInput({ text: this.titleDraft, placeholder: '要做的事…' })
|
||
.placeholderColor(FbColors.faint)
|
||
.placeholderFont({ size: FbMetric.fontSmall })
|
||
.fontSize(FbMetric.fontSmall)
|
||
/* 内容用墨黑(用户明确要求)—— 只有标签那四个字是主题色 */
|
||
.fontColor(FbColors.ink)
|
||
.backgroundColor(FbColors.inputBg)
|
||
.borderRadius(FbMetric.radiusS)
|
||
.width('100%')
|
||
.height(40)
|
||
.padding({ left: 12, right: 12 })
|
||
.onChange((v: string): void => {
|
||
this.titleDraft = v;
|
||
})
|
||
.onFocus((): void => {
|
||
this.titleFocus = true;
|
||
})
|
||
/* 失焦保存 —— 改完直接点别处是最自然的动作,
|
||
让他再去找"保存"就会以为没存上。 */
|
||
.onBlur((): void => {
|
||
this.titleFocus = false;
|
||
this.saveTitle();
|
||
})
|
||
/* 单行框支持"回车提交":用户打完直接按回车就走 saveTitle,
|
||
不用先点别处。多行框里回车是换行,所以以前没这一条。 */
|
||
.onSubmit((): void => {
|
||
this.saveTitle();
|
||
})
|
||
}
|
||
.width('100%')
|
||
.alignItems(HorizontalAlign.Start)
|
||
.padding({ top: 10, bottom: 4 })
|
||
}
|
||
|
||
@Builder
|
||
infoRows() {
|
||
/* 🔴🔴 #77k(用户 2026-11-05):「上面的标题和下面的输入框,其实要分成两行。
|
||
比如说,"截止"不是直接后面排一个日期,而是把日期输入框放在"截止"下面;
|
||
"负责人"也是,"备注"也是,全部都是这样的。」
|
||
|
||
⚠️ 每个字段 = 一个 Column:
|
||
① 标题行(主题色 + Bold,独占一行)
|
||
② 控件行(浅底圆角框,整行宽)
|
||
|
||
⚠️ 控件行统一给 height(40) + inputBg + radiusS:
|
||
不给高度的话,「设置…」「未指定」这类短文字会把框压扁,
|
||
几行之间的间距就会参差不齐。
|
||
|
||
🔴 #77t(用户 2026-11-05):「截止日期和负责人右侧都有一个编辑用的、
|
||
像小铅笔似的图标。我感觉这个东西不需要了。…就把所有小图标
|
||
全部去掉,也就是右侧那个小铅笔图标全去掉。」
|
||
|
||
⇒ 删掉「截止」「负责人」右侧的 `✎`。
|
||
上一版加它是想提示"这里能点"(本项目踩过"纯文字看着不可点"的坑),
|
||
但代价是:只有这两行有、别的行没有,视觉上不齐;
|
||
而且输入框本身(浅底圆角)已经足够表达"这是个可操作的控件"。
|
||
⚠️ 如果以后想恢复"可点"提示,**必须所有行一起加**,
|
||
不能只有截止/负责人有 —— 那才是用户不接受的地方。 */
|
||
Column({ space: 14 }) {
|
||
/* ---- 截止 ---- */
|
||
Column({ space: 6 }) {
|
||
this.fieldLb('截止')
|
||
Row({ space: 8 }) {
|
||
Text(this.dueOf() !== '' ? this.dueOf() : '设置截止时间…')
|
||
.fontSize(FbMetric.fontSmall)
|
||
.fontColor(this.dueOf() === '' ? FbColors.faint
|
||
: (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%')
|
||
.height(40)
|
||
.padding({ left: 12, right: 12 })
|
||
.backgroundColor(FbColors.inputBg)
|
||
.borderRadius(FbMetric.radiusS)
|
||
.alignItems(VerticalAlign.Center)
|
||
.onClick((): void => {
|
||
this.openDuePicker();
|
||
})
|
||
}
|
||
.width('100%')
|
||
.alignItems(HorizontalAlign.Start)
|
||
|
||
/* ---- 负责人 ---- */
|
||
Column({ space: 6 }) {
|
||
this.fieldLb('负责人')
|
||
Row({ space: 8 }) {
|
||
/* 头像(#77「负责人那个地方也需要有图标,就是人的头像」)
|
||
⚠️ 端上不一定拿得到头像图(`assigneeAvatar` 可能是空 key)⇒
|
||
有图用图,没图用首字色块兜底(与留言区同一个手法)。 */
|
||
if (this.assigneeAvatarSrc() !== '') {
|
||
Image(this.assigneeAvatarSrc())
|
||
.width(24)
|
||
.height(24)
|
||
.borderRadius(12)
|
||
.objectFit(ImageFit.Cover)
|
||
.backgroundColor(FbColors.line2)
|
||
} else {
|
||
Row() {
|
||
Text(this.assigneeOf() !== '' ? this.assigneeOf().substring(0, 1) : '?')
|
||
.fontSize(11)
|
||
.fontColor(this.accent)
|
||
}
|
||
.width(24)
|
||
.height(24)
|
||
.borderRadius(12)
|
||
.backgroundColor(FbColors.line2)
|
||
.justifyContent(FlexAlign.Center)
|
||
}
|
||
Text(this.assigneeOf() !== '' ? this.assigneeOf() : '未指定')
|
||
.fontSize(FbMetric.fontSmall)
|
||
.fontColor(this.assigneeOf() === '' ? FbColors.faint : FbColors.ink)
|
||
.layoutWeight(1)
|
||
}
|
||
.width('100%')
|
||
.height(40)
|
||
.padding({ left: 12, right: 12 })
|
||
.backgroundColor(FbColors.inputBg)
|
||
.borderRadius(FbMetric.radiusS)
|
||
.alignItems(VerticalAlign.Center)
|
||
.onClick((): void => {
|
||
this.openAssigneePicker();
|
||
})
|
||
}
|
||
.width('100%')
|
||
.alignItems(HorizontalAlign.Start)
|
||
|
||
/* ---- 分类 ---- */
|
||
/* 🔴 #83(用户 2026-11-05):「分类和重复,也就是循环,都需要加上。」
|
||
原来这两个只在**有值**时才画一行只读文字 ——
|
||
用户不仅改不了,连"这里本来有这一项"都看不出来。
|
||
⇒ 改成与截止/负责人同形态(标签一行 + 控件一行),且始终显示。 */
|
||
Column({ space: 6 }) {
|
||
this.fieldLb('分类')
|
||
if (this.inlineBusy) {
|
||
Text(this.categoryOf() !== '' ? this.categoryOf() : '未分类')
|
||
.fontSize(FbMetric.fontSmall)
|
||
.fontColor(FbColors.faint)
|
||
.width('100%')
|
||
.height(40)
|
||
.padding({ left: 12, right: 12 })
|
||
.backgroundColor(FbColors.inputBg)
|
||
.borderRadius(FbMetric.radiusS)
|
||
} else {
|
||
/* 🔴🔴 #86(用户 2026-11-05):「鸿蒙端也用类似的方式调,
|
||
效果让它保持一致。」
|
||
|
||
网页端照 FeedLite 做成"自绘按钮 + 浮层列表"(原生 <select> 的
|
||
展开列表 CSS 改不动)。端上**没有那个限制** ——
|
||
ArkUI 的 `Select` 本身就是自绘的,选项列表也归框架管,
|
||
所以不用再自己写浮层,把对应的属性都设上就等价了:
|
||
|
||
网页端 ArkUI Select
|
||
─────────────────────────────────────────────────
|
||
按钮 40 高 / 圆角 / 细边框 height(40) + borderRadius + border
|
||
箭头打开转 180° 框架内置(有动画)
|
||
悬停 tint-1 不支持 → 用打开态代替
|
||
选中项底 tint-2 selectedOptionBgColor
|
||
选中项文字 主题色 selectedOptionFontColor
|
||
选中项右侧对勾 showDefaultSelectedIcon(true)
|
||
浮层白底 + 细边框 + 圆角 menuBackgroundColor + menuOutline
|
||
浮层与按钮同宽 optionWidth(FIT_TRIGGER)
|
||
|
||
⚠️ 底色用 `fbMixWhite(accent, 0.91)` 而**不是** `fbAlpha`:
|
||
那个是"原色+透明度"(会透出父容器底色,浮层背景不确定
|
||
就会时深时浅);这个是与网页端同算法的实色混合。
|
||
⚠️ 未选值(「未分类」/「不循环」)文字用 muted 次要色,
|
||
与网页端 `.sel-btn.is-placeholder` 一致。 */
|
||
Select(this.catOptions())
|
||
.selected(this.catIndex())
|
||
.value(this.categoryOf() !== '' ? this.categoryOf() : '未分类')
|
||
.font({ size: FbMetric.fontSmall, weight: FontWeight.Medium })
|
||
/* #86:有值用主题色、未选用次要灰(占位不该和真值一样显眼) */
|
||
.fontColor(this.categoryOf() !== '' ? this.accent : FbColors.faint)
|
||
.selectedOptionFont({ size: FbMetric.fontSmall, weight: FontWeight.Medium })
|
||
.selectedOptionFontColor(this.accent)
|
||
.selectedOptionBgColor(fbMixWhite(this.accent, 0.80))
|
||
.optionFont({ size: FbMetric.fontSmall })
|
||
.optionFontColor(FbColors.ink)
|
||
.optionBgColor(Color.White)
|
||
.showDefaultSelectedIcon(true)
|
||
.optionWidth(OptionWidthMode.FIT_TRIGGER)
|
||
.menuBackgroundColor(Color.White)
|
||
.width('100%')
|
||
.height(40)
|
||
.padding({ left: 12, right: 12 })
|
||
.backgroundColor(fbMixWhite(this.accent, 0.91))
|
||
.borderRadius(FbMetric.radiusS)
|
||
.border({ width: 1, color: fbMixWhite(this.accent, 0.62) })
|
||
.onSelect((idx: number): void => {
|
||
/* index 0 = 「未分类」→ category_id 传 0 */
|
||
const cid: number = idx === 0 ? 0
|
||
: (idx - 1 < this.cats.length ? this.cats[idx - 1].id : 0);
|
||
this.saveField('category_id', '', cid);
|
||
})
|
||
}
|
||
}
|
||
.width('100%')
|
||
.alignItems(HorizontalAlign.Start)
|
||
|
||
/* ---- 循环 ---- */
|
||
Column({ space: 6 }) {
|
||
this.fieldLb('循环')
|
||
if (this.inlineBusy) {
|
||
Text(this.repeatOf() !== '' ? this.repeatOf() : '不循环')
|
||
.fontSize(FbMetric.fontSmall)
|
||
.fontColor(FbColors.faint)
|
||
.width('100%')
|
||
.height(40)
|
||
.padding({ left: 12, right: 12 })
|
||
.backgroundColor(FbColors.inputBg)
|
||
.borderRadius(FbMetric.radiusS)
|
||
} else {
|
||
/* #86:与分类完全同一套(见上面那段的对照表) */
|
||
Select(this.repeatOptions())
|
||
.selected(this.repeatIndex())
|
||
.value(this.repeatOf() !== '' ? this.repeatWordOf(this.repeatOf()) : '不循环')
|
||
.font({ size: FbMetric.fontSmall, weight: FontWeight.Medium })
|
||
.fontColor(this.repeatOf() !== '' ? this.accent : FbColors.faint)
|
||
.selectedOptionFont({ size: FbMetric.fontSmall, weight: FontWeight.Medium })
|
||
.selectedOptionFontColor(this.accent)
|
||
.selectedOptionBgColor(fbMixWhite(this.accent, 0.80))
|
||
.optionFont({ size: FbMetric.fontSmall })
|
||
.optionFontColor(FbColors.ink)
|
||
.optionBgColor(Color.White)
|
||
.showDefaultSelectedIcon(true)
|
||
.optionWidth(OptionWidthMode.FIT_TRIGGER)
|
||
.menuBackgroundColor(Color.White)
|
||
.width('100%')
|
||
.height(40)
|
||
.padding({ left: 12, right: 12 })
|
||
.backgroundColor(fbMixWhite(this.accent, 0.91))
|
||
.borderRadius(FbMetric.radiusS)
|
||
.border({ width: 1, color: fbMixWhite(this.accent, 0.62) })
|
||
.onSelect((idx: number): void => {
|
||
const rk: string = idx === 0 ? '' : FbTaskPanel.REPEAT_KEYS[idx - 1];
|
||
this.saveField('repeat_rule', rk, -1);
|
||
})
|
||
}
|
||
}
|
||
.width('100%')
|
||
.alignItems(HorizontalAlign.Start)
|
||
|
||
/* ---- 备注 ---- */
|
||
/* 🔴 #77n(用户 2026-11-05):「包括下面的备注也是一样」
|
||
⇒ 与任务标题同处理:**常驻输入框**,不做"点一下才变输入框"。
|
||
标签(「备注」两字)= 主题色;框里内容 = 墨黑。 */
|
||
Column({ space: 6 }) {
|
||
this.fieldLb('备注')
|
||
TextArea({ text: this.noteDraft, placeholder: '补充说明…' })
|
||
.placeholderColor(FbColors.faint)
|
||
.placeholderFont({ size: FbMetric.fontSmall })
|
||
.fontSize(FbMetric.fontSmall)
|
||
.fontColor(FbColors.ink)
|
||
.backgroundColor(FbColors.inputBg)
|
||
.borderRadius(FbMetric.radiusS)
|
||
.width('100%')
|
||
.height(84)
|
||
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
|
||
.onChange((v: string): void => {
|
||
this.noteDraft = v;
|
||
})
|
||
.onFocus((): void => {
|
||
this.noteFocus = true;
|
||
})
|
||
.onBlur((): void => {
|
||
this.noteFocus = false;
|
||
this.saveNote();
|
||
})
|
||
}
|
||
.width('100%')
|
||
.alignItems(HorizontalAlign.Start)
|
||
}
|
||
.width('100%')
|
||
.alignItems(HorizontalAlign.Start)
|
||
.padding({ top: 4 })
|
||
}
|
||
|
||
/* ---------------- 分类 / 循环 的下拉选项 ---------------- */
|
||
|
||
/**
|
||
* 循环的可选键(与编辑表单 `FbTaskEditSheet.repeatChip` 同一套)。
|
||
* ⚠️ 顺序必须与 `REPEAT_WORDS` 严格对齐 —— 两边错一位就会"选每天存成每周"。
|
||
*/
|
||
private static readonly REPEAT_KEYS: string[] = ['day', 'week', 'month', 'year'];
|
||
private static readonly REPEAT_WORDS: string[] = ['每天', '每周', '每月', '每年'];
|
||
|
||
/** 分类下拉的选项文本:第 0 项固定是「未分类」(对应 category_id = 0) */
|
||
private catOptions(): SelectOption[] {
|
||
const out: SelectOption[] = [{ value: '未分类' }];
|
||
for (let i = 0; i < this.cats.length; i++) {
|
||
out.push({ value: this.cats[i].name });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 当前选中的下标(0 = 未分类) */
|
||
private catIndex(): number {
|
||
const d: FbTaskDetail | null = this.detail;
|
||
const cid: number = d !== null ? d.categoryId : 0;
|
||
if (cid <= 0) {
|
||
return 0;
|
||
}
|
||
for (let i = 0; i < this.cats.length; i++) {
|
||
if (this.cats[i].id === cid) {
|
||
return i + 1;
|
||
}
|
||
}
|
||
return 0; // 分类被删了/不在这份清单里 → 显示未分类
|
||
}
|
||
|
||
private repeatOptions(): SelectOption[] {
|
||
const out: SelectOption[] = [{ value: '不循环' }];
|
||
for (let i = 0; i < FbTaskPanel.REPEAT_WORDS.length; i++) {
|
||
out.push({ value: FbTaskPanel.REPEAT_WORDS[i] });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
private repeatIndex(): number {
|
||
const r: string = this.repeatOf();
|
||
for (let i = 0; i < FbTaskPanel.REPEAT_KEYS.length; i++) {
|
||
if (FbTaskPanel.REPEAT_KEYS[i] === r) {
|
||
return i + 1;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
private repeatWordOf(key: string): string {
|
||
for (let i = 0; i < FbTaskPanel.REPEAT_KEYS.length; i++) {
|
||
if (FbTaskPanel.REPEAT_KEYS[i] === key) {
|
||
return FbTaskPanel.REPEAT_WORDS[i];
|
||
}
|
||
}
|
||
return '不循环';
|
||
}
|
||
|
||
/** 截止时间的时分(`HH:mm`),给日期浮层做初值;没有期限就给当前时刻 */
|
||
private dueTimeOf(): string {
|
||
const t: FbTaskItem | null = this.cur();
|
||
if (t !== null && t.dueAt !== '') {
|
||
/* `2026-10-25 18:00` → `18:00` */
|
||
const sp: number = t.dueAt.indexOf(' ');
|
||
if (sp > 0 && t.dueAt.length >= sp + 6) {
|
||
return t.dueAt.substring(sp + 1, sp + 6);
|
||
}
|
||
}
|
||
const d: Date = new Date();
|
||
const hh: string = (d.getHours() < 10 ? '0' : '') + d.getHours().toString();
|
||
const mm: string = (d.getMinutes() < 10 ? '0' : '') + d.getMinutes().toString();
|
||
return hh + ':' + mm;
|
||
}
|
||
|
||
/** 负责人 id(0 = 未指派);给选人浮层做初值 */
|
||
private assigneeIdOf(): number {
|
||
const d: FbTaskDetail | null = this.detail;
|
||
return d !== null ? d.assigneeId : 0;
|
||
}
|
||
|
||
/** 负责人头像地址(拿不到就空串,UI 用首字色块兜底) */
|
||
private assigneeAvatarSrc(): string {
|
||
const d: FbTaskDetail | null = this.detail;
|
||
if (d === null) {
|
||
return '';
|
||
}
|
||
/* ⚠️ 头像字段在 `base`(FbTaskItem)上,不在 FbTaskDetail 本身 ——
|
||
类型定义里 Detail 只有 assigneeId / category 这些"详情专有"的字段。 */
|
||
const k: string = d.base.assigneeAvatar;
|
||
return k !== '' ? fbImageSrc(k, this.token()) : '';
|
||
}
|
||
|
||
/**
|
||
* 「更多信息」里的一行字段标题(截止 / 负责人 / 备注 / 分类 / 循环)。
|
||
*
|
||
* 🔴 #77j(用户 2026-11-05):「截止、负责人、备注没改 ——
|
||
* 这几个字的颜色要变主题色粗体。」
|
||
*
|
||
* ⚠️ 这与「编辑表单」的字段标签口径**不同**,是用户明确要求的:
|
||
* 整页编辑表单里 `期限` 那种是 `fontTiny + faint`(浅灰小字),
|
||
* 但这个详情面板里用户希望它们**和区块标题一个观感**
|
||
* (主题色 + Bold)—— 因为它和「子任务/附件/留言」那些标题同属一个视觉层级,
|
||
* 只有它浅灰会显得"这两块不是一回事"。
|
||
*
|
||
* ⚠️ 不走 `FbSectionTitle` 组件:那个带 `width('100%')` +
|
||
* `padding({top:12,bottom:8})`,而这里要的是"44vp 宽的行内标签",
|
||
* 塞进去会把右侧控件顶到下一行。只**取它的三个核心属性**
|
||
* (Bold / accent / letterSpacing 0.6),宽度与内边距按行内标签给。
|
||
*/
|
||
@Builder
|
||
fieldLb(t: string) {
|
||
/* 🔴 #77m(用户 2026-11-05):「状态、分组、看板、任务标题、截止、负责人、
|
||
备注、协作人、子任务、附件、留言这些字的大小要保持统一。」
|
||
|
||
⇒ 标签字号统一走 `fontSmall`(13),**与 `FbSectionTitle` 完全一致**
|
||
(那个组件内部就是 `fontSize(FbMetric.fontSmall)`)。
|
||
上一版这里是 fontTiny(11),于是"截止"比"状态"小一号,看着不成套。
|
||
|
||
⚠️ 宽度不再写 48:标签独占一行(控件在它下面),
|
||
固定宽度会让它只占左边一小段,与下面的整行控件左缘对不齐。 */
|
||
Text(t)
|
||
.fontSize(FbMetric.fontSmall)
|
||
.fontWeight(FontWeight.Bold)
|
||
.fontColor(this.accent)
|
||
.letterSpacing(0.6)
|
||
.width('100%')
|
||
.textAlign(TextAlign.Start)
|
||
}
|
||
|
||
@Builder
|
||
kvRow(label: string, value: string) {
|
||
/* #77k:与截止/负责人/备注同形态(标签一行、值一行),
|
||
否则「分类」「循环」还是左右排,夹在中间会显得错位。 */
|
||
Column({ space: 6 }) {
|
||
this.fieldLb(label)
|
||
Text(value)
|
||
.fontSize(FbMetric.fontSmall)
|
||
.fontColor(FbColors.ink)
|
||
.width('100%')
|
||
.padding({ left: 12, right: 12, top: 11, bottom: 11 })
|
||
.backgroundColor(FbColors.inputBg)
|
||
.borderRadius(FbMetric.radiusS)
|
||
}
|
||
.width('100%')
|
||
.alignItems(HorizontalAlign.Start)
|
||
}
|
||
|
||
/** 共享给谁(工单 #26):列表 + 共享入口 + 逐条取消 */
|
||
@Builder
|
||
shareSection() {
|
||
Column({ space: 6 }) {
|
||
/* #77(用户 2026-11-05):「共享给谁这个地方都和网页端一样,都改成协作人吧。」
|
||
#77i:标题按规范走 `FbSectionTitle`(Bold + 主题色 + 字距)。 */
|
||
FbSectionTitle({
|
||
title: '协作人',
|
||
accent: this.accent,
|
||
extra: this.shares().length > 0 ? this.shares().length.toString() : ''
|
||
})
|
||
Row({ space: 6 }) {
|
||
Blank()
|
||
Text(this.shareBusy ? '处理中…' : '+ 共享')
|
||
.fontSize(FbMetric.fontTiny)
|
||
.fontColor(this.shareBusy ? 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.openSharePicker();
|
||
})
|
||
}
|
||
.width('100%')
|
||
.padding({ top: 12 })
|
||
.alignItems(VerticalAlign.Center)
|
||
|
||
if (this.shares().length === 0) {
|
||
Text('还没共享给谁;共享后对方能在任务里看到它,权限分「可改 / 只看」')
|
||
.fontSize(FbMetric.fontTiny)
|
||
.fontColor(FbColors.faint)
|
||
.width('100%')
|
||
} else {
|
||
ForEach(this.shares(), (s: FbTaskShare) => {
|
||
Row({ space: 8 }) {
|
||
Text(s.name)
|
||
.fontSize(FbMetric.fontSmall)
|
||
.fontColor(FbColors.ink)
|
||
.layoutWeight(1)
|
||
.maxLines(1)
|
||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||
Text(s.perm === 'read' ? '只看' : '可改')
|
||
.fontSize(FbMetric.fontTiny)
|
||
.fontColor(FbColors.faint)
|
||
Text('取消')
|
||
.fontSize(FbMetric.fontTiny)
|
||
.fontColor(FbColors.red)
|
||
.padding({ left: 6, right: 2, top: 2, bottom: 2 })
|
||
.onClick((): void => {
|
||
this.dropShare(s.personId);
|
||
})
|
||
}
|
||
.width('100%')
|
||
.padding({ top: 6, bottom: 6 })
|
||
.alignItems(VerticalAlign.Center)
|
||
}, (s: FbTaskShare): string => 'tsh_' + s.personId.toString())
|
||
}
|
||
}
|
||
.width('100%')
|
||
.alignItems(HorizontalAlign.Start)
|
||
}
|
||
|
||
/** 子任务:列表 + 加一条 */
|
||
@Builder
|
||
kidSection() {
|
||
Column({ space: 6 }) {
|
||
/* #77h:`FbSectionTitle` 是 `width('100%')` 的独立组件,
|
||
必须**独占一行**(塞进 Row 会和后面的计数/按钮抢宽度,被压成 0 宽)。
|
||
「已完成 2/5」这种附加信息走它自带的 `extra`。 */
|
||
FbSectionTitle({
|
||
title: '子任务',
|
||
accent: this.accent,
|
||
extra: this.kids().length > 0
|
||
? this.kidDoneCount().toString() + '/' + this.kids().length.toString()
|
||
: ''
|
||
})
|
||
|
||
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() + '_' + k.status)
|
||
|
||
/* 加一条:输入框 + 「加」 */
|
||
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 }) {
|
||
/* 🔴 #77h(用户 2026-11-05:「样式还没变,还记得咱们之前规定的样式是怎么做的吗?」)
|
||
|
||
规定就在 `Widgets.ets` 的 `FbSectionTitle` 上(它的注释里还留着用户原话):
|
||
「"今天的日程",既然是日程,标题颜色就应该用日程的主题色。
|
||
…下面的"近期任务"改法跟上面一样,还有"最近纪念日"也不说了,全都是一样的。」
|
||
|
||
⇒ 区块标题**必须走 `FbSectionTitle`**,不能手写 `Text`:
|
||
它给了 **Bold + letterSpacing(0.6) + accent 色** 三样,
|
||
而手写 Text 我只改了 fontColor,字重和字距都还是老样子
|
||
—— 这就是"样式还没变"的原因。
|
||
|
||
⚠️ `FbSectionTitle` 自带 `padding({top:12,bottom:8})` 且 `width('100%')`,
|
||
所以它必须**独立成行**,不能塞进原来那个 Row 里和按钮并排(会被挤掉宽度)。
|
||
⇒ 把「标题 + 计数」交给组件,按钮单独另起一行右对齐(见下)。 */
|
||
FbSectionTitle({
|
||
title: '附件',
|
||
accent: this.accent,
|
||
extra: this.attachs().length > 0 ? this.attachs().length.toString() : ''
|
||
})
|
||
Row() {
|
||
Blank()
|
||
/* #77(用户 2026-11-05):「加图片这个地方改成"添加附件"。」
|
||
⚠️ 文案改了,**行为也要跟着改** —— 原来 `pickAttach()` 只允许选图片
|
||
(`PhotoViewMIMETypes.IMAGE_TYPE`),改叫"添加附件"却只能选图,
|
||
用户选 PDF 时会发现根本没有那个文件。见 `pickAttach()` 的改动。 */
|
||
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 })
|
||
/* #77c:点缩略图 → 图片全屏预览 / 文件交给系统应用打开 */
|
||
.onClick((): void => {
|
||
this.tapAttach(a);
|
||
})
|
||
}
|
||
|
||
/** 留言 */
|
||
@Builder
|
||
commentSection() {
|
||
Column({ space: 8 }) {
|
||
/* #77h:留言标题同样独立成行。
|
||
图标(#77「留言那个地方需要是图标」)放在标题组件**外面**、
|
||
用 Row 包一层 —— 组件本身不接受前插图。 */
|
||
Row({ space: 4 }) {
|
||
Text('💬')
|
||
.fontSize(FbMetric.fontSmall)
|
||
Column() {
|
||
FbSectionTitle({
|
||
title: '留言',
|
||
accent: this.accent,
|
||
extra: this.comments().length > 0 ? this.comments().length.toString() : ''
|
||
})
|
||
}
|
||
.layoutWeight(1)
|
||
.alignItems(HorizontalAlign.Start)
|
||
}
|
||
.width('100%')
|
||
.alignItems(VerticalAlign.Center)
|
||
|
||
ForEach(this.comments(), (c: FbTaskComment) => {
|
||
Row({ space: 8 }) {
|
||
/* 🔴 #77e(用户 2026-11-05):「留言里面的人,前面还是没有头像,需要补充。」
|
||
⇒ 原来这里画的是"名字首字色块"(当时端上确实没拉头像)。
|
||
现在 `FbTaskComment.avatar` 有了(服务端 SQL 一直返回 p.avatar,
|
||
端上原来没解析),换成 `FbAvatar`:
|
||
有头像图用图、没设头像才退回首字色块。
|
||
⚠️ 用公共 `FbAvatar` 而不是自己拼 `Image`:全站头像的口径
|
||
(尺寸/圆角/兜底色)都收在那一个组件里,自己拼迟早不一致。 */
|
||
/* #77h:头像已由 `FbPersonChip` 内置(它会画 `FbAvatar`),
|
||
这里不再自己画一个 —— 否则同一条留言前面出现两个头像。 */
|
||
Column({ space: 2 }) {
|
||
Row({ space: 6 }) {
|
||
/* 🔴 #77h:留言人走规范组件 `FbPersonChip`。
|
||
网页端留言人用的就是 `person_chip()`(头像 + 名字的胶囊),
|
||
鸿蒙端对应的是 `FbPersonChip` —— 上一轮我手写的
|
||
「头像 + 纯文字名字」只是长得像,不是同一套。
|
||
|
||
⚠️ `FbPersonChip` 自带 `FbAvatar`(dia 18)+ inputBg 胶囊底,
|
||
所以外面那个 24vp 的 FbAvatar 要去掉,否则头像画两遍。 */
|
||
/* #77i:把头像 URL 传进去 —— `FbPersonChip` 之前不透传 `imageUrl`,
|
||
所以换成它之后头像就只剩首字色块了(那个参数我刚在 Widgets.ets 补上)。 */
|
||
FbPersonChip({
|
||
name: c.name,
|
||
color: c.color !== '' ? c.color : '#A84A28',
|
||
imageUrl: c.avatar !== '' ? fbImageSrc(c.avatar, this.token()) : ''
|
||
})
|
||
Text(c.createdAt)
|
||
.fontSize(9)
|
||
.fontColor(FbColors.faint)
|
||
}
|
||
.width('100%')
|
||
.alignItems(VerticalAlign.Center)
|
||
|
||
/* 正文也按主题色加粗?——**不**。
|
||
用户说的是"标题和里面的文字",但正文若整段主题色,
|
||
简明度会掉(色彩是需要留白的)。
|
||
⇒ 名字(=发言标题)主题色加粗;正文保持 ink 正文色。
|
||
这条取舍写在 commit 里,若用户要求正文也改色,一行就能调。 */
|
||
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 }) {
|
||
/* #77(用户 2026-11-05):「下面说一句:回车发送」
|
||
⇒ 从 placeholder 里挪出来,单独一行说明。
|
||
原来塞在 placeholder 里有两个问题:
|
||
① 一开始打字就看不见了;
|
||
② placeholder 该放"这里填什么",不该放操作提示。 */
|
||
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();
|
||
})
|
||
|
||
/* #77(用户 2026-11-05):「发送按钮显示不全。」
|
||
根因:原来写死 `width(56)` + `height(38)` + `textAlign(Center)`,
|
||
而 `Text` 的默认内边距会吃掉两侧空间 —— 56vp 减去左右内边距
|
||
「发送」两个汉字(约 26vp)+ 内边距正好压线,字体缩放或
|
||
不同机型上就被截掉半个字。
|
||
⇒ 不写死宽度:用 `constraintSize({ minWidth: 60 })` + 水平内边距,
|
||
让按钮按文字实际宽度自适应;`height(38)` 保留(与输入框齐平)。 */
|
||
Text('发送')
|
||
.fontSize(FbMetric.fontSmall)
|
||
.fontColor(this.cmtDraft.trim() === '' ? FbColors.faint : this.accent)
|
||
.textAlign(TextAlign.Center)
|
||
.constraintSize({ minWidth: 60 })
|
||
.padding({ left: 14, right: 14 })
|
||
.height(38)
|
||
.backgroundColor(fbAlpha(this.accent, 0.1))
|
||
.borderRadius(FbMetric.radiusS)
|
||
.onClick((): void => {
|
||
this.sendComment();
|
||
})
|
||
}
|
||
.width('100%')
|
||
.alignItems(VerticalAlign.Center)
|
||
|
||
/* 🔴 #77g(用户 2026-11-05):「留言是可以需要添加附件的。」
|
||
|
||
服务端**早就支持** —— `tasks/comment_add` 里一直有
|
||
`attach_save_files($familyId, 'task_comment', $cid, 'attach', $me)`
|
||
(网页端那个表单也已经有 file input),只是**鸿蒙端从来没传过附件**。
|
||
⇒ `addTaskComment()` 目前只发 JSON(content 一个字段),
|
||
要带附件必须改成 multipart 上传 —— 见 FbTaskRepo 的改动。
|
||
|
||
⚠️ 交互设计:附件是"随这条留言一起发出",不是立刻上传
|
||
(与「任务附件」那块不同 —— 那个传完就生效)。
|
||
所以选中的文件先挂在本地,点「发送」时和文字一起提交。
|
||
⚠️ 选中的文件名要显示出来,否则用户不知道有没有选上。 */
|
||
if (this.cmtAttachNames.length > 0) {
|
||
Flex({ wrap: FlexWrap.Wrap }) {
|
||
ForEach(this.cmtAttachNames, (n: string, i: number) => {
|
||
Row({ space: 4 }) {
|
||
Text('📎')
|
||
.fontSize(9)
|
||
Text(n)
|
||
.fontSize(9)
|
||
.fontColor(FbColors.muted)
|
||
.maxLines(1)
|
||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||
.constraintSize({ maxWidth: 120 })
|
||
Text('✕')
|
||
.fontSize(9)
|
||
.fontColor(FbColors.faint)
|
||
.onClick((): void => {
|
||
this.dropCmtAttach(i);
|
||
})
|
||
}
|
||
.padding({ left: 7, right: 7, top: 3, bottom: 3 })
|
||
.margin({ right: 6, bottom: 4 })
|
||
.backgroundColor(FbColors.inputBg)
|
||
.borderRadius(FbMetric.radiusPill)
|
||
.alignItems(VerticalAlign.Center)
|
||
}, (n: string, i: number): string => 'ca_' + i.toString() + '_' + n)
|
||
}
|
||
.width('100%')
|
||
.padding({ top: 6 })
|
||
}
|
||
|
||
/* 「回车发送」+「添加附件」同一行:一个说明、一个动作 */
|
||
Row({ space: 8 }) {
|
||
Text('回车发送')
|
||
.fontSize(10)
|
||
.fontColor(FbColors.faint)
|
||
.layoutWeight(1)
|
||
Text(this.cmtBusy ? '发送中…' : '+ 添加附件')
|
||
.fontSize(FbMetric.fontTiny)
|
||
.fontColor(this.cmtBusy ? FbColors.faint : this.accent)
|
||
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
|
||
.backgroundColor(fbAlpha(this.accent, 0.10))
|
||
.borderRadius(FbMetric.radiusPill)
|
||
.onClick((): void => {
|
||
this.pickCmtAttach();
|
||
})
|
||
}
|
||
.width('100%')
|
||
.padding({ top: 6 })
|
||
.alignItems(VerticalAlign.Center)
|
||
}
|
||
.width('100%')
|
||
.alignItems(HorizontalAlign.Start)
|
||
}
|
||
|
||
/**
|
||
* 危险操作区:删除任务 —— 放在**滚动内容的最底部**。
|
||
*
|
||
* 🔴🔴 #77s(用户 2026-11-05):「删除按钮不能出现在那里,
|
||
* 因为这样太容易误触了。删除按钮应该放在哪里?你考虑考虑。」
|
||
*
|
||
* 原来它和「保存」并排钉在底栏,两个问题:
|
||
* ① **误触**:底栏是"保存"这种高频操作的位置,手一滑就点到删除,
|
||
* 而删除**不可恢复**,一次误触 = 数据没了。
|
||
* ② **语义错位**:底栏是"我改完了、要留下这个结果"的确认区,
|
||
* 不该混进"我要毁掉它"。
|
||
*
|
||
* 所以放到**滚动内容最末尾**:
|
||
* · 用户得主动滚到最底才看得见 —— 低频操作不必首屏可见;
|
||
* · 纯文字、无填充底色,不像按钮,不招手指;
|
||
* · 与正文隔一条分割线,视觉上属于"其它",不属于"操作";
|
||
* · 仍然保留**二次确认**(第一下变成「确定删掉」+「取消」)。
|
||
*
|
||
* ⚠️ 这是通行做法(Notion / 备忘录 / 各种设置页的删除都在最底部),
|
||
* 不是在发明新交互。
|
||
* ⚠️ 不放顶栏:顶栏是"知道我在哪 / 怎么退出"的位置,
|
||
* 在那里放删除,误触代价同样不可恢复。
|
||
* ⚠️ 不用红底大白按钮:那种视觉权重会把人引过去,与"低频 + 危险"正相反。
|
||
*/
|
||
@Builder
|
||
dangerZone() {
|
||
Column({ space: 0 }) {
|
||
Divider()
|
||
.color(FbColors.line)
|
||
.margin({ top: 18, bottom: 14 })
|
||
|
||
if (this.confirmDel) {
|
||
/* 第一次已点:写清后果 + 两个出口 */
|
||
Text('删了就找不回来了,确定吗?')
|
||
.fontSize(FbMetric.fontTiny)
|
||
.fontColor('#C0533F')
|
||
.width('100%')
|
||
.margin({ bottom: 8 })
|
||
|
||
Row({ space: 10 }) {
|
||
Text('确定删掉')
|
||
.fontSize(FbMetric.fontSmall)
|
||
.fontColor(Color.White)
|
||
.textAlign(TextAlign.Center)
|
||
.layoutWeight(1)
|
||
.height(40)
|
||
.backgroundColor('#C0533F')
|
||
.borderRadius(FbMetric.radiusS)
|
||
.onClick((): void => {
|
||
this.dropTask();
|
||
})
|
||
|
||
/* 「取消」必须有:误点进来要能退回去,
|
||
否则只剩"删"和"点别处"两个选择,后者不明显。 */
|
||
Text('取消')
|
||
.fontSize(FbMetric.fontSmall)
|
||
.fontColor(FbColors.muted)
|
||
.textAlign(TextAlign.Center)
|
||
.layoutWeight(1)
|
||
.height(40)
|
||
.backgroundColor(FbColors.inputBg)
|
||
.borderRadius(FbMetric.radiusS)
|
||
.onClick((): void => {
|
||
this.confirmDel = false;
|
||
this.note = '';
|
||
})
|
||
}
|
||
.width('100%')
|
||
} else {
|
||
/* 常态:一行小字,居中,不像按钮 */
|
||
Text('删除这个任务')
|
||
.fontSize(FbMetric.fontTiny)
|
||
.fontColor('#C0533F')
|
||
.textAlign(TextAlign.Center)
|
||
.width('100%')
|
||
.padding({ top: 10, bottom: 10 })
|
||
.onClick((): void => {
|
||
/* 第一下只进确认态,不删 */
|
||
this.confirmDel = true;
|
||
})
|
||
}
|
||
}
|
||
.width('100%')
|
||
}
|
||
|
||
/** 底部:编辑 / 删除 */
|
||
@Builder
|
||
footBar() {
|
||
Row({ space: 10 }) {
|
||
/* 🔴🔴 #77q(用户 2026-11-05):「和网页版一样,没有保存按钮啊,
|
||
这应该怎么保存呢?在哪个地方点保存呢?」
|
||
|
||
原来这里是「编辑」—— 但面板本身已经能改所有字段(标题/备注/截止/
|
||
负责人/分类/协作人/子任务/附件/留言),再给一颗"编辑"是自相矛盾的
|
||
(点它反而跳到另一张表单)。
|
||
⇒ 换成**「保存」**:把"打字"类的两项(标题、备注)落库然后关窗。
|
||
与网页端保存后关浮层的行为一致。
|
||
⚠️ 状态/看板/截止/负责人这些**不用点保存**,选中即生效 ——
|
||
它们是"动作"不是"草稿",多一步确认反而烦。 */
|
||
Text(this.saving ? '保存中…' : '保存')
|
||
.fontSize(FbMetric.fontSmall)
|
||
.fontColor(Color.White)
|
||
.textAlign(TextAlign.Center)
|
||
.layoutWeight(1)
|
||
.height(42)
|
||
.backgroundColor(this.saving ? '#C9CDD2' : this.accent)
|
||
.borderRadius(FbMetric.radiusS)
|
||
.onClick((): void => {
|
||
if (this.saving) {
|
||
return;
|
||
}
|
||
this.saveAll();
|
||
})
|
||
|
||
/* 🔴🔴 #77s(用户 2026-11-05):「删除按钮不能出现在那里,
|
||
因为这样太容易误触了。」
|
||
⇒ 从底栏摘掉,移到滚动内容最末尾的 dangerZone()。
|
||
底栏只留全宽的「保存」—— 它是"确认改动"的位置,
|
||
不该混进"毁掉数据"的入口。 */
|
||
}
|
||
.width('100%')
|
||
.margin({ top: 12 })
|
||
/* #77r:底栏**不许被压缩** —— Scroll 已经 layoutWeight(1) 拿剩余空间,
|
||
这里再显式 flexShrink(0) 兜一层,防止某些机型上把 42 高的按钮压扁。
|
||
(按钮整个看不见那次,就是没有这层保护、整条栏被挤到 86% 之外。) */
|
||
.flexShrink(0)
|
||
}
|
||
}
|
||
|