Files
FamilyBook_harmonyos/entry/src/main/ets/data/FbTaskRepo.ets
T
laoyang 32a35ca222 留言底部被裁 + 留言支持附件 (#77)
用户 2026-11-05:
  「留言输入框下面还有东西,现在被挡住了,根本看不见,也滚不动了。
   但下面应该还有字什么的,看看是什么。」
  「留言是可以需要添加附件的。」

① 被挡住的是什么
   是 #77 刚加的「回车发送」那行小字。

   根因:`commentSection()` 是 `Scroll` 里**最后一个**子元素,
   而 Scroll 内容末尾只留了 `height(6)`。
   Scroll 本身是 `flexShrink(1)`(内容不够高时按内容收缩),
   最后一行下边距一旦小于"卡片 padding + footBar 高度",
   就会被判定为"已到底" —— 滚不过去、也看不见。
   ⇒ 底部留白从 6 改成 28。

② 留言带附件
   服务端网页端早就有(tasks/comment_add 里的 attach_save_files),
   但 **API 的 comments 端点只收 content**,且空文字直接 422
   ⇒ 端上根本没有传附件的入口。三处一起改:

   · app/ApiHandler.php:comments POST 支持 multipart
       - 有 file 就走附件流程:先 addComment 拿 cid,再 Attachment::save
         (顺序不能反:save 要 owner_id,cid 是插入后才有的)
       - kind 用 task_comment,与网页端 attach_save_files 同一归属类型
         (不同的话同一条留言的附件在两端互相看不见)
       - **允许只发附件不写字**:自动补占位文案「(附件)」,与网页端同口径
   · FbTaskRepo.addTaskCommentWithFile():手拼 multipart
       - 不能复用 fbUploadOne(那个是"一个文件 + 一个字段",
         评论要同时传 content + file)
       - 文件段必须带 Content-Type:服务端按它判"是不是图片"
       - boundary 之间用 \r\n(用 \n 有些解析器会把整段当一个字段,
         表现为"文字上去了、附件不见了")
   · FbTaskPanel:
       - 「+ 添加附件」按钮(DocumentViewPicker,任意文件)
       - 选中的文件名显示成 chip,可点 ✕ 去掉
       - 附件**不立刻上传**,随留言一起发(与「任务附件」那块语义不同:
         那个传完就生效)
       - 发成功后清空待发附件,否则下一条会重复带上

   实测(模拟上传链路):
     只有文字          => cid=18 ✅
     文字 + 附件        => cid=19,附件数 1 ✅(key=files/6b4e...txt)
     只发附件(占位)   => cid=20 ✅
   测试数据已清理。
2026-11-05 19:25:32 +08:00

322 lines
14 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 · 任务的**写操作**(鸿蒙端)
*
* 对应服务端 `app/ApiHandler.php` 的 `tasks()`:
* POST /api/v1/tasks 新建(title / note / due_at / board_id / status)
* PATCH /api/v1/tasks/<id> 改(标题/备注/截止/看板/负责人/分类)
* POST /api/v1/tasks/<id>/status 改状态(todo / doing / done)
* DELETE /api/v1/tasks/<id> 删
*
* 🔴 用户 2026-10-20:「任务也是一样啊,任务也点不进去,也没法那个完成,
* 新建任务、设置这些东西都这都没有。」
* ⇒ 端上原来这三件事**一件都没接**(按钮没 onTap、列表项没 onClick)。
*
* ⚠️ **状态只走 `/status` 子动作**:服务端 `PATCH /tasks/<id>` 里
* `'status' => (string)$t['status']` 是**原样回填**,显式忽略请求里的 status
* (见 ApiHandler 那行注释「状态只走 /status 子动作」)。
* 所以端上 toggle 完成必须打 `/status`,打 PATCH 会"看起来成功但状态没变"。
*/
import { fbApi, fbApiBase, FbApiResult, FbApiOptions } from './FbHttp';
import { fbUploadOne } from './FbUpload';
import { http } from '@kit.NetworkKit';
import { fileIo } from '@kit.CoreFileKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
/** 新建/编辑任务提交的字段 */
export interface FbTaskDraft {
title: string;
note: string;
/** `YYYY-MM-DD HH:MM`;空串 = 没有期限 */
dueAt: string;
/** 看板 id(0 = 不归指定看板 → 服务端落到「其他」那一列) */
boardId: number;
/** todo / doing / done(只在**新建**时生效;改状态走 `setTaskStatus`) */
status: string;
/**
* 父任务 id(v79;0 = 顶层任务)。
* 面板里「加一条子任务」就是 `parentId = 当前任务` 的新建请求。
*/
parentId: number;
/**
* 负责人 id。
*
* `> 0` = 指定这个人;`0` = 新建时"用默认值(我自己)"、编辑时"清空负责人";
* **`-1` = 这次不改**(编辑表单目前没有负责人这一项 ⇒ 不能传 0 把它抹掉)。
*/
assigneeId: number;
/** 分类 id(0 = 不分类) */
categoryId: number;
/** 循环规则:空 / day / week / month / year */
repeat: string;
}
/** 新建任务 */
export async function createTask(token: string, d: FbTaskDraft): Promise<FbApiResult> {
/* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:请求体必须
`Record<string, Object> = {}` 逐键赋值(见 FbTripRepo 同一说明)。 */
const body: Record<string, Object> = {};
body['title'] = d.title;
body['note'] = d.note;
body['due_at'] = d.dueAt;
body['board_id'] = d.boardId;
body['status'] = d.status;
/* v79:这三个原来没传 —— 端上因此**建不出子任务**、也指定不了负责人/分类。
⚠️ `assignee_id` 只在 >0 时才传:服务端 `Api::int('assignee_id', $pid)`
的默认值是"我自己",传 0 会把它覆盖成"没人负责"。 */
if (d.parentId > 0) {
body['parent_id'] = d.parentId;
}
if (d.assigneeId > 0) {
body['assignee_id'] = d.assigneeId;
}
if (d.categoryId > 0) {
body['category_id'] = d.categoryId;
}
if (d.repeat !== '') {
body['repeat_rule'] = d.repeat;
}
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
return await fbApi('/api/v1/tasks', opts);
}
/**
* 改任务(标题 / 备注 / 截止 / 看板 / 负责人 / 分类 / 循环)。
*
* ⚠️ **不带 status**:服务端 PATCH 会忽略它(见文件头说明),
* 带上只会让人以为"改成功了",实际状态没动。
*/
export async function updateTask(token: string, id: number, d: FbTaskDraft): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['title'] = d.title;
body['note'] = d.note;
body['due_at'] = d.dueAt;
body['board_id'] = d.boardId;
/* v79:负责人/分类/循环也走 PATCH(服务端这三个字段都认)。
⚠️ `assignee_id` 只在**明确要改**(>=0)时才传:编辑表单里没有负责人这一项,
传 0 会把原来指派的负责人**静默清空**(与 v77「没传 ≠ 清空」同一类 bug)。
⚠️ 分类和循环则**必须**照传(0 / 空串就是"清空",那是合法操作)。 */
if (d.assigneeId >= 0) {
body['assignee_id'] = d.assigneeId;
}
body['category_id'] = d.categoryId;
body['repeat_rule'] = d.repeat;
const opts: FbApiOptions = { method: 'PATCH', token: token, body: body };
return await fbApi('/api/v1/tasks/' + id.toString(), opts);
}
/** 改状态(勾完成 / 取消完成 / 标记进行中) */
export async function setTaskStatus(token: string, id: number, status: string): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['status'] = status;
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
return await fbApi('/api/v1/tasks/' + id.toString() + '/status', opts);
}
/** 删任务 */
export async function deleteTask(token: string, id: number): Promise<FbApiResult> {
const opts: FbApiOptions = { method: 'DELETE', token: token };
return await fbApi('/api/v1/tasks/' + id.toString(), opts);
}
/* ═══════════════════════════════════════════════════════════════
v79(用户 2026-10-22):「鸿蒙端的功能,也需要同样适配……
任务模块的功能需要完善。」
⇒ 下面这些是网页 v78「就地面板」在端上的对等物。
⚠️ 除 `getTask` 外全部返回 `FbApiResult`,且**服务端都会回整条任务** ——
调用方拿到 `res.data` 直接重画面板,不用再补一次 GET。
═══════════════════════════════════════════════════════════════ */
/** 取一条任务的详情(含子任务 / 留言 / 附件 / 共享人) */
export async function getTask(token: string, id: number): Promise<FbApiResult> {
const opts: FbApiOptions = { method: 'GET', token: token };
return await fbApi('/api/v1/tasks/' + id.toString(), opts);
}
/**
* 挪到另一个看板(网页版「分组」的等价物)。
*
* ⚠️ 走 `/move` 而不是 `PATCH`:`/move` 会顺手把这一列重排 position;
* PATCH 只改 board_id,同列里会留下重复的 position(网页端拖拽用的是 /move)。
*/
export async function moveTask(token: string, id: number, boardId: number): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['board_id'] = boardId;
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
return await fbApi('/api/v1/tasks/' + id.toString() + '/move', opts);
}
/** 发一条留言(服务端只回 `{id}`,要整块内容就再 `getTask` 一次) */
export async function addTaskComment(token: string, id: number, content: string): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['content'] = content;
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
return await fbApi('/api/v1/tasks/' + id.toString() + '/comments', opts);
}
/**
* 发一条带附件的留言(#77g)。
*
* 🔴 用户 2026-11-05:「留言是可以需要添加附件的。」
*
* ⚠️ 为什么不能复用 `fbUploadOne()`:那个是"**一个文件 + 一个字段名**",
* 而评论要同时提交 `content`(文字)+ `file`(附件)两个字段。
* ⇒ 自己拼 multipart:文字段在前、文件段在后、最后收尾 boundary。
*
* ⚠️ 文件段必须带 `Content-Type`,不能只给 filename:
* 服务端 `Attachment::save()` 按这个 mime 判"是不是图片"。
* 这里从扩展名猜一个(`image/jpeg` / `application/pdf` / …),
* 猜不到就当 `application/octet-stream`(服务端会再嗅探一次)。
*
* ⚠️ 多个 boundary 之间用 `\r\n` —— 用 `\n` 的话部分服务端解析器会
* 把整段当成一个字段,表现为"文字传上去了、附件不见了"。
*
* @param uri 附件 URI(空串 = 不带附件,走原来的 JSON 路径)
* @return 空串 = 成功;否则是给人看的错误说明
*/
export async function addTaskCommentWithFile(token: string, id: number,
content: string, uri: string): Promise<string> {
const base: string = fbApiBase();
if (base === '') {
return '还没有设置服务器地址';
}
if (token === '') {
return '还没登录';
}
let buf: ArrayBuffer = new ArrayBuffer(0);
let fileName: string = 'file';
if (uri !== '') {
try {
const f: fileIo.File = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
const stat: fileIo.Stat = fileIo.statSync(f.fd);
buf = new ArrayBuffer(stat.size);
fileIo.readSync(f.fd, buf);
fileIo.closeSync(f);
/* 从 URI 尾部取文件名(picker 给的 URI 末段就是原名) */
const slash: number = uri.lastIndexOf('/');
if (slash >= 0 && slash < uri.length - 1) {
fileName = uri.substring(slash + 1);
}
} catch (err) {
hilog.warn(0x0000, 'FbTaskRepo', '读附件失败:%{public}s', JSON.stringify(err));
return '读不出这个文件';
}
}
const boundary: string = '----FbCmt' + Date.now().toString();
/* ① 文字段 */
const textPart: string = '--' + boundary + '\r\n'
+ 'Content-Disposition: form-data; name="content"\r\n\r\n'
+ content + '\r\n';
let extra: Array<string | ArrayBuffer> = [textPart];
/* ② 文件段(有才加) */
if (uri !== '') {
const ext: string = fileName.lastIndexOf('.') > 0
? fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase() : '';
let mime: string = 'application/octet-stream';
if (ext === 'jpg' || ext === 'jpeg') {
mime = 'image/jpeg';
} else if (ext === 'png') {
mime = 'image/png';
} else if (ext === 'gif') {
mime = 'image/gif';
} else if (ext === 'webp') {
mime = 'image/webp';
} else if (ext === 'pdf') {
mime = 'application/pdf';
}
const fileHead: string = '--' + boundary + '\r\n'
+ 'Content-Disposition: form-data; name="file"; filename="' + fileName + '"\r\n'
+ 'Content-Type: ' + mime + '\r\n\r\n';
extra.push(fileHead);
extra.push(buf);
extra.push('\r\n');
}
extra.push('--' + boundary + '--\r\n');
const req: http.HttpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await req.request(
base + '/api/v1/tasks/' + id.toString() + '/comments', {
method: http.RequestMethod.POST,
header: {
'Content-Type': 'multipart/form-data; boundary=' + boundary,
'Authorization': 'Bearer ' + token,
'Accept': 'application/json'
},
extraData: extra,
connectTimeout: 20000,
readTimeout: 60000,
expectDataType: http.HttpDataType.STRING
});
req.destroy();
if (resp.responseCode >= 200 && resp.responseCode < 300) {
return '';
}
/* 服务端会回 `{ok:false, error:'人话'}`,尽量把它带出来 */
try {
const o: Record<string, Object> = JSON.parse(
typeof resp.result === 'string' ? resp.result : '{}') as Record<string, Object>;
const e: Object | undefined = o['error'];
if (typeof e === 'string' && e !== '') {
return e;
}
} catch (e) {
/* 忽略 */
}
return '发送失败(' + resp.responseCode.toString() + ')';
} catch (err) {
req.destroy();
hilog.warn(0x0000, 'FbTaskRepo', '发留言失败:%{public}s', JSON.stringify(err));
return '网络不好,留言没发出去';
}
}
/**
* 传一个附件(图片/文档都行,服务端按 mime 自己分)。
*
* @return 空串 = 成功;否则是给人看的错误说明(与 `fbUploadOne` 的约定一致)
*/
export async function uploadTaskAttach(token: string, id: number, uri: string): Promise<string> {
return await fbUploadOne('/api/v1/tasks/' + id.toString() + '/attach', 'file', uri, token);
}
/** 删一个附件(列表里点 ✕) */
export async function delTaskAttach(token: string, id: number, aid: number): Promise<FbApiResult> {
/* ⚠️ 两处都踩过,别改回去:
① 路由只解析「资源/id/子动作」**三段** ⇒ 不能写成 `/attach/<aid>`,
那样服务端只会拿到 `$sub='attach'`,附件 id 被静默丢掉;
② 附件 id 走 **query**,不要塞 JSON body —— 实测 `DELETE` 带 JSON 体时
服务端读到的是空(`Api::param` 拿不到),症状是
「要指定删哪个附件」400,而请求看起来完全正常。 */
const q: Record<string, string> = {};
q['attachment_id'] = aid.toString();
const opts: FbApiOptions = { method: 'DELETE', token: token, query: q };
return await fbApi('/api/v1/tasks/' + id.toString() + '/attach', opts);
}
/** 共享给谁(工单 #26) */
export async function fetchTaskShares(token: string, id: number): Promise<FbApiResult> {
const opts: FbApiOptions = { token: token, authProbe: true };
return await fbApi('/api/v1/tasks/' + id.toString() + '/shares', opts);
}
/** 共享给某人(perm = read / write;任务默认 write,与 Web 端 tasks/share 一致) */
export async function shareTask(token: string, id: number, personId: number,
perm: string): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['person_id'] = personId;
body['perm'] = perm;
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
return await fbApi('/api/v1/tasks/' + id.toString() + '/shares', opts);
}
/** 取消某人的共享(person_id 走 query,DELETE 带 body 服务端读不到) */
export async function unshareTask(token: string, id: number, personId: number): Promise<FbApiResult> {
const q: Record<string, string> = {};
q['person_id'] = personId.toString();
const opts: FbApiOptions = { method: 'DELETE', token: token, query: q };
return await fbApi('/api/v1/tasks/' + id.toString() + '/shares', opts);
}