Files
FamilyBook_harmonyos/entry/src/main/ets/data/FbTaskRepo.ets
T

322 lines
14 KiB
Plaintext
Raw Normal View History

/**
* 家书 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);
}