/** * 家书 FamilyBook · 任务的**写操作**(鸿蒙端) * * 对应服务端 `app/ApiHandler.php` 的 `tasks()`: * POST /api/v1/tasks 新建(title / note / due_at / board_id / status) * PATCH /api/v1/tasks/ 改(标题/备注/截止/看板/负责人/分类) * POST /api/v1/tasks//status 改状态(todo / doing / done) * DELETE /api/v1/tasks/ 删 * * 🔴 用户 2026-10-20:「任务也是一样啊,任务也点不进去,也没法那个完成, * 新建任务、设置这些东西都这都没有。」 * ⇒ 端上原来这三件事**一件都没接**(按钮没 onTap、列表项没 onClick)。 * * ⚠️ **状态只走 `/status` 子动作**:服务端 `PATCH /tasks/` 里 * `'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 { /* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:请求体必须 `Record = {}` 逐键赋值(见 FbTripRepo 同一说明)。 */ const body: Record = {}; 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 { const body: Record = {}; 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 { const body: Record = {}; 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 { 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 { const opts: FbApiOptions = { method: 'GET', token: token }; return await fbApi('/api/v1/tasks/' + id.toString(), opts); } /** * 挪到另一个看板(网页版「分组」的等价物)。 * * ⚠️ 走 `/move` 而不是 `PATCH`:`/move` 会顺手把这一列重排 position; * PATCH 只改 board_id,同列里会留下重复的 position(网页端拖拽用的是 /move)。 */ export async function moveTask(token: string, id: number, boardId: number): Promise { const body: Record = {}; body['board_id'] = boardId; const opts: FbApiOptions = { method: 'POST', token: token, body: body }; return await fbApi('/api/v1/tasks/' + id.toString() + '/move', opts); } /** 发一条留言(服务端只回 `{id}`,要整块内容就再 `getTask` 一次) */ export async function addTaskComment(token: string, id: number, content: string): Promise { const body: Record = {}; body['content'] = content; const opts: FbApiOptions = { method: 'POST', token: token, body: body }; return await fbApi('/api/v1/tasks/' + id.toString() + '/comments', opts); } /** * 发一条带附件的留言(#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 { 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 = [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 = JSON.parse( typeof resp.result === 'string' ? resp.result : '{}') as Record; 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 { return await fbUploadOne('/api/v1/tasks/' + id.toString() + '/attach', 'file', uri, token); } /** 删一个附件(列表里点 ✕) */ export async function delTaskAttach(token: string, id: number, aid: number): Promise { /* ⚠️ 两处都踩过,别改回去: ① 路由只解析「资源/id/子动作」**三段** ⇒ 不能写成 `/attach/`, 那样服务端只会拿到 `$sub='attach'`,附件 id 被静默丢掉; ② 附件 id 走 **query**,不要塞 JSON body —— 实测 `DELETE` 带 JSON 体时 服务端读到的是空(`Api::param` 拿不到),症状是 「要指定删哪个附件」400,而请求看起来完全正常。 */ const q: Record = {}; q['attachment_id'] = aid.toString(); const opts: FbApiOptions = { method: 'DELETE', token: token, query: q }; return await fbApi('/api/v1/tasks/' + id.toString() + '/attach', opts); } /** 共享给谁(工单 #26) */ export async function fetchTaskShares(token: string, id: number): Promise { 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 { const body: Record = {}; 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 { const q: Record = {}; q['person_id'] = personId.toString(); const opts: FbApiOptions = { method: 'DELETE', token: token, query: q }; return await fbApi('/api/v1/tasks/' + id.toString() + '/shares', opts); }