diff --git a/entry/src/main/ets/components/FbTaskPanel.ets b/entry/src/main/ets/components/FbTaskPanel.ets index 1c32c24..1f0465a 100644 --- a/entry/src/main/ets/components/FbTaskPanel.ets +++ b/entry/src/main/ets/components/FbTaskPanel.ets @@ -47,8 +47,7 @@ import { fbImageSrc } from '../data/FbAttachQueue'; import { addTaskComment, createTask, deleteTask, delTaskAttach, getTask, moveTask, setTaskStatus, uploadTaskAttach, FbTaskDraft, - fetchTaskShares, shareTask, unshareTask, -} from '../data/FbTaskRepo'; + fetchTaskShares, shareTask, unshareTask, addTaskCommentWithFile } from '../data/FbTaskRepo'; import { parseTaskDetail } from '../data/FbParse'; import { picker } from '@kit.CoreFileKit'; import { BusinessError } from '@kit.BasicServicesKit'; @@ -129,6 +128,13 @@ export struct FbTaskPanel { /* #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; @@ -583,21 +589,90 @@ export struct FbTaskPanel { this.onChanged(); } + /** 选一个附件挂到待发留言上(#77g)——**不立刻上传**,随留言一起发 */ + private async pickCmtAttach(): Promise { + 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 { const txt: string = this.cmtDraft.trim(); - if (this.busy || txt === '') { + const hasFile: boolean = this.cmtAttachUri !== ''; + if (this.busy || this.cmtBusy) { + return; + } + if (txt === '' && !hasFile) { return; } this.busy = true; + this.cmtBusy = true; this.note = ''; - const res: FbApiResult = await addTaskComment(this.token(), this.taskId, txt); - this.busy = false; - if (!res.ok) { - this.note = res.error !== '' ? res.error : '留言没发出去。'; - return; + + 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(); } @@ -730,7 +805,18 @@ export struct FbTaskPanel { .width('100%') .padding({ top: 10 }) } - Row().height(6) + /* 🔴 #77g(用户 2026-11-05):「留言输入框下面还有东西, + 现在被挡住了,根本看不见,也滚不动了。」 + —— 被挡住的是 `commentSection()` 末尾那行「回车发送」小字。 + + 根因:`commentSection` 是 `Scroll` 里的**最后一个**子元素, + 而这里原来只留了 `height(6)`。 + `Scroll` 又是 `flexShrink(1)`(内容不够高时按内容收缩), + 最后一行的下边距一旦小于"卡片 padding + footBar 高度", + 就会被判定为"已到底",滚不过去也看不见。 + ⇒ 底部留一块**足够高**的空白(不是 6vp 意思一下), + 保证最后一行能滚到可视区中间。 */ + Row().height(28) } .width('100%') .alignItems(HorizontalAlign.Start) @@ -1634,12 +1720,67 @@ export struct FbTaskPanel { .width('100%') .alignItems(VerticalAlign.Center) - /* 「回车发送」提示:放在输入行**下方**,小字、不抢视线 */ - Text('回车发送') - .fontSize(10) - .fontColor(FbColors.faint) + /* 🔴 #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: 2 }) + .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) diff --git a/entry/src/main/ets/data/FbTaskRepo.ets b/entry/src/main/ets/data/FbTaskRepo.ets index 099193e..c7c3ab7 100644 --- a/entry/src/main/ets/data/FbTaskRepo.ets +++ b/entry/src/main/ets/data/FbTaskRepo.ets @@ -17,8 +17,11 @@ * 所以端上 toggle 完成必须打 `/status`,打 PATCH 会"看起来成功但状态没变"。 */ -import { fbApi, FbApiResult, FbApiOptions } from './FbHttp'; +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 { @@ -151,6 +154,124 @@ export async function addTaskComment(token: string, id: number, content: string) 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 自己分)。 *