用户 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 ✅ 测试数据已清理。
322 lines
14 KiB
Plaintext
322 lines
14 KiB
Plaintext
/**
|
||
* 家书 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);
|
||
}
|
||
|