From 97846029c7733257df5e4a9e2dca6a211317fc1a Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 28 Oct 2026 21:35:11 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=94=E8=AE=B0=E5=9B=BE=E7=89=87=E5=9D=97?= =?UTF-8?q?=EF=BC=9A=E5=B1=95=E7=A4=BA=E7=9C=9F=E5=9B=BE=20+=20=E6=8F=92?= =?UTF-8?q?=E5=9B=BE=20+=20blocks=20=E4=BF=9D=E5=AD=98=EF=BC=88=E5=9B=BE?= =?UTF-8?q?=E6=96=87=E6=B7=B7=E6=8E=92=E5=90=8E=E5=8D=8A=E7=A8=8B=EF=BC=89?= =?UTF-8?q?laoyang/FamilyBook#23?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户原话(2026-10-28):「鸿蒙端对齐网页端」,笔记端上只支持纯文本。 这半程改鸿蒙端,配合服务端 cd56a9d 补的 /notes/upload: 1) 展示:FbNoteItem 新增 blocks(FbNoteBlock[]),parseNotes 把原始块 带进来;FbNoteSheet 里 image 块真画图(fbImageSrc),不再显示「[图片]」。 2) 插图:正文区下加「+ 插图」,选图 → fbUploadKey 上传拿 key → 加入 待保存列表;每张图带「×」可删。 3) 保存(P0 修复):带图时改走 saveNoteWithBlocks —— 正文 text 块 + 图片 image 块整组提交。原来只传 body,服务端折成单个 text 块整组替换, **编辑带图笔记会把图片块静默删光**。 4) fbUploadKey 是 fbUpload 抽出的第二出口:复用同一 multipart 核心拿 key, 不复制第二份实现(本项目"两份实现漂移"栽过多次)。 编译 BUILD SUCCESSFUL;守卫 _fb_arkts_guard.js 1324/0;服务端冒烟 _i23_note_image_api.py 11/0(含 PATCH 后图片块不被删)+ 反证命中 2/2。 --- entry/src/main/ets/components/FbNoteSheet.ets | 154 ++++++++++++++++-- entry/src/main/ets/data/FbNoteRepo.ets | 88 +++++++--- entry/src/main/ets/data/FbParse.ets | 47 +++++- entry/src/main/ets/data/FbUpload.ets | 71 +++++++- entry/src/main/ets/model/DemoData.ets | 10 +- entry/src/main/ets/model/Types.ets | 24 ++- entry/src/main/ets/pages/misc/FbMiscPages.ets | 7 +- 7 files changed, 342 insertions(+), 59 deletions(-) diff --git a/entry/src/main/ets/components/FbNoteSheet.ets b/entry/src/main/ets/components/FbNoteSheet.ets index 15dcde7..67407d2 100644 --- a/entry/src/main/ets/components/FbNoteSheet.ets +++ b/entry/src/main/ets/components/FbNoteSheet.ets @@ -7,10 +7,14 @@ * 🔴 用户 2026-10-20:「笔记现在也点不进去,记笔记也记不了。」 * ⇒ 端上既没有新建、也没有详情:`FbPrimaryButton` 无 `onTap`、卡片无 `onClick`。 * - * ⚠️ 端上只支持**纯文本**(服务端 `body` → 一个 `text` 块): - * 网页那边可以图文混排(`note_blocks` 有 image 块),端上先不做 —— - * 图片块的内容是附件 key,硬拼进文本框会变成一串乱码。 - * 所以已有笔记里的图片块在端上显示成「[图片]」(见 `parseNotes` 的 `noteBodyOf`)。 + * 🔴 工单 #23(用户 2026-10-28):图文混排。原来端上只支持纯文本—— + * · 图片块显示成「[图片]」占位; + * · 更糟:保存时只传 `body`,服务端折成单个 text 块**整组替换**, + * 编辑一条带图笔记会把图片块**静默删光**。 + * 现在: + * · `editingBlocks` 把原始块(text/image)带进来,image 块真画图; + * · 「插图」按钮 → 选图 → 上传拿 key → 追加到图片列表; + * · 保存走 `saveNoteWithBlocks`(正文 text 块 + 图片 image 块整组提交)。 * * ⚠️ **只读**:别人共享给我的笔记能看不能改(服务端 `Note::canWrite` 那一套)。 * 端上按 `mine` 判断 —— 保守一点宁可少给编辑,也不能给个点了报 403 的按钮。 @@ -20,8 +24,15 @@ import { FbColors, FbMetric, FB_MASK, fbAlpha } from '../common/Theme'; import { FbSheetClose } from './FbSheetClose'; import { FbSheetRing } from './FbSheetRing'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; -import { createNote, updateNote, deleteNote } from '../data/FbNoteRepo'; +import { createNote, updateNote, saveNoteWithBlocks, deleteNote } from '../data/FbNoteRepo'; +import { fbUploadKey } from '../data/FbUpload'; +import { fbImageSrc } from '../data/FbAttachQueue'; +import { noteImageKey } from '../data/FbParse'; +import { FbNoteBlock } from '../model/Types'; import { FbApiResult } from '../data/FbHttp'; +import { picker } from '@kit.CoreFileKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; @ComponentV2 export struct FbNoteSheet { @@ -31,6 +42,8 @@ export struct FbNoteSheet { @Param editingId: number = 0; @Param editingTitle: string = ''; @Param editingBody: string = ''; + /** 正文原始块(工单 #23:image 块借此真画图;为空则回到纯文本) */ + @Param editingBlocks: FbNoteBlock[] = []; @Param editingTags: string = ''; /** 是不是我写的(不是就只能看) */ @Param mine: boolean = true; @@ -41,6 +54,10 @@ export struct FbNoteSheet { @Local title: string = ''; @Local body: string = ''; + /** 图片块 key(工单 #23:已存在的 + 新插的,按序保存) */ + @Local imageKeys: string[] = []; + /** 正在上传插图 */ + @Local uploadingImg: boolean = false; /** 标签输入(用空格或逗号分隔,服务端会自己归一) */ @Local tags: string = ''; @Local saving: boolean = false; @@ -51,6 +68,17 @@ export struct FbNoteSheet { this.title = this.editingTitle; this.body = this.editingBody; this.tags = this.editingTags; + // 从原始块里挑出 image 块的 key(剥 `|宽度` 后缀),作为已存在图片 + const keys: string[] = []; + for (const b of this.editingBlocks) { + if (b.kind === 'image') { + const k: string = noteImageKey(b.content); + if (k !== '') { + keys.push(k); + } + } + } + this.imageKeys = keys; } /** 能不能改:新建时当然能;已有笔记只有自己写的能改 */ @@ -192,13 +220,52 @@ export struct FbNoteSheet { .width('100%') .alignItems(VerticalAlign.Center) - // 图片块在端上只能显示成「[图片]」—— 说清楚,别让人以为内容丢了 - if (this.editingId > 0 && this.body.indexOf('[图片]') >= 0) { - Text('这条笔记里的图片在网页端看;App 这边先显示成「[图片]」。') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - .width('100%') - .maxLines(2) + // ---- 图片(工单 #23:真画图,不显示「[图片]」占位)---- + if (this.imageKeys.length > 0) { + Column({ space: 8 }) { + ForEach(this.imageKeys, (key: string, idx: number) => { + Row({ space: 8 }) { + Image(fbImageSrc(key, FB_TOKEN_STORE.get())) + .width(72) + .height(72) + .objectFit(ImageFit.Cover) + .borderRadius(FbMetric.radiusS) + .backgroundColor(FbColors.inputBg) + if (this.editOk()) { + Text('×') + .fontSize(18) + .fontColor('#FFFFFF') + .textAlign(TextAlign.Center) + .width(30) + .height(30) + .borderRadius(15) + .backgroundColor(fbAlpha('#C0533F', 0.86)) + .onClick((): void => { + this.removeImage(idx); + }) + } + } + .width('100%') + .alignItems(VerticalAlign.Center) + }, (key: string, idx: number): string => 'nimg_' + idx.toString() + '_' + key) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + + // 插图按钮(只给能改的人) + if (this.editOk()) { + Row({ space: 6 }) { + Text(this.uploadingImg ? '正在上传…' : '+ 插图') + .fontSize(FbMetric.fontSmall) + .fontColor(this.accent) + } + .padding({ left: 12, right: 12, top: 8, bottom: 8 }) + .borderRadius(FbMetric.radiusS) + .backgroundColor(fbAlpha(this.accent, 0.08)) + .onClick((): void => { + this.pickImage(); + }) } } .width('100%') @@ -287,19 +354,23 @@ export struct FbNoteSheet { } const t: string = this.title.trim(); const b: string = this.body.trim(); - if (t === '' && b === '') { - this.note = '标题和正文至少写一样(服务端不允许存一条空笔记)。'; + const hasImg: boolean = this.imageKeys.length > 0; + if (t === '' && b === '' && !hasImg) { + this.note = '标题、正文或图片至少填一样(服务端不允许存一条空笔记)。'; return; } this.saving = true; this.note = ''; /* ⚠️ 标题空着也能存(服务端允许纯正文笔记),但列表卡片上没标题会很难认 ⇒ 用正文首行兜个标题(只影响显示,不改变用户输入)。 */ - const useTitle: string = t !== '' ? t : b.substring(0, 20); + const useTitle: string = t !== '' ? t : (b !== '' ? b.substring(0, 20) : '图文笔记'); const token: string = FB_TOKEN_STORE.get(); - const res: FbApiResult = this.editingId > 0 - ? await updateNote(token, this.editingId, useTitle, b, this.tagList()) - : await createNote(token, useTitle, b, this.tagList()); + // 🔴 工单 #23:带图则走 blocks 传法,保住图片块;纯文本走老函数(等价)。 + const res: FbApiResult = hasImg + ? await saveNoteWithBlocks(token, this.editingId, useTitle, b, this.imageKeys, this.tagList()) + : (this.editingId > 0 + ? await updateNote(token, this.editingId, useTitle, b, this.tagList()) + : await createNote(token, useTitle, b, this.tagList())); this.saving = false; if (!res.ok) { this.note = res.error !== '' ? res.error : '保存失败,请稍后再试。'; @@ -308,6 +379,53 @@ export struct FbNoteSheet { this.onSaved(); } + /** 选一张图插入(选完立刻上传拿 key,等保存时随 blocks 一起提交) */ + private async pickImage(): Promise { + if (this.uploadingImg) { + return; + } + try { + const opts: picker.PhotoSelectOptions = new picker.PhotoSelectOptions(); + opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE; + opts.maxSelectNumber = 1; + const p: picker.PhotoViewPicker = new picker.PhotoViewPicker(); + const sel: picker.PhotoSelectResult = await p.select(opts); + if (sel.photoUris.length === 0) { + return; + } + this.uploadingImg = true; + this.note = ''; + const r = await fbUploadKey('/api/v1/notes/upload', 'file', sel.photoUris[0], + FB_TOKEN_STORE.get()); + this.uploadingImg = false; + if (r.error !== '') { + this.note = r.error; + return; + } + if (r.key === '') { + this.note = '图片没传上去,请再试一次。'; + return; + } + this.imageKeys = this.imageKeys.concat([r.key]); + } catch (e) { + const be: BusinessError = e as BusinessError; + hilog.warn(0x0000, 'FamilyBook', '笔记插图失败:%{public}s', JSON.stringify(be)); + this.uploadingImg = false; + this.note = '选图失败,请再试一次。'; + } + } + + /** 从待保存列表里拿掉第 idx 张(新建时直接删;已有图片下次保存整组替换即可) */ + private removeImage(idx: number): void { + const next: string[] = []; + for (let i: number = 0; i < this.imageKeys.length; i++) { + if (i !== idx) { + next.push(this.imageKeys[i]); + } + } + this.imageKeys = next; + } + private async doDelete(): Promise { if (this.saving) { return; diff --git a/entry/src/main/ets/data/FbNoteRepo.ets b/entry/src/main/ets/data/FbNoteRepo.ets index 9c2dcba..24a68c5 100644 --- a/entry/src/main/ets/data/FbNoteRepo.ets +++ b/entry/src/main/ets/data/FbNoteRepo.ets @@ -5,49 +5,93 @@ * POST /api/v1/notes 新建(title / body 或 blocks / tags) * PATCH /api/v1/notes/ 改 * DELETE /api/v1/notes/ 删(**只限作者**) + * POST /api/v1/notes/upload 传图片拿 key(工单 #23,草稿态,见下) * * 🔴 用户 2026-10-20:「笔记现在也点不进去,记笔记也记不了。」 * ⇒ 端上「记一条」按钮没有 `onTap`、卡片没有 `onClick`。 * - * ⚠️⚠️ 正文的键是 **`body`(纯文本)**,不是 `blocks`: - * `Note` 那边真正的存储是 `note_blocks` 若干块,服务端 `notes()` 收到 `body` - * 会包成一个 `text` 块(见 `ApiHandler::noteBlocksIn` 的说明)。 - * v65 之前接口传的是 `body` 但 `Note` 只读 `blocks` —— 正文被静默丢掉; - * **现在两边对齐了**:端上传 `body`,服务端转成块。 - * ⇒ 端上不要自己去拼 `blocks`(那是网页图文混排的形态,手机上先支持纯文本)。 + * ⚠️⚠️ 工单 #23(图文混排)**存储键从 `body` 换成 `blocks`**: + * 服务端 `Note` 真正的存储是 `note_blocks` 若干块。原来端上只传 `body`, + * 服务端会把它折成**单个 text 块**并**整组替换**(先 DELETE 再 INSERT)⇒ + * **端上编辑一条带图笔记、点保存,图片块被静默删光**(P0 数据丢失)。 + * ⇒ 现在端上把"正文文本 + 图片块"拼成 `blocks` 数组一起传。 * - * ⚠️ `PATCH` 时若**不传** `body`/`blocks`,服务端会沿用库里的块(不会清空)。 - * 这里仍然每次都传 `body`:用户看到的就是那个文本框,保存即整段替换。 + * ⚠️ 服务端 `noteBlocksIn()` 的判定顺序:**先认 `blocks`,再认 `body`**。 + * 所以传 `blocks` 时它整组用 `blocks`,不会再碰 `body`。 */ import { fbApi, FbApiResult, FbApiOptions } from './FbHttp'; -/** 新建笔记 */ +/** 新建笔记(纯文本:单个 text 块) */ export async function createNote(token: string, title: string, body: string, tags: string[]): Promise { + return saveNote(token, 0, title, body, [], tags); +} + +/** 改笔记(纯文本;🔴 仅限作者能改,但这里不判——服务端会拦) */ +export async function updateNote(token: string, id: number, title: string, body: string, + tags: string[]): Promise { + return saveNote(token, id, title, body, [], tags); +} + +/** + * 新建 / 改一条笔记(工单 #23:带图片块一起存)。 + * + * @param images 图片块(每项是已上传拿回来的附件 key),排在正文后面。 + */ +export async function saveNoteWithBlocks(token: string, id: number, title: string, + body: string, images: string[], tags: string[]): Promise { + return saveNote(token, id, title, body, images, tags); +} + +/** 公共落地:把正文与图片拼成 `blocks` 数组交服务端 */ +async function saveNote(token: string, id: number, title: string, body: string, + images: string[], tags: string[]): Promise { /* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:请求体必须 `Record = {}` 逐键赋值(见 `FbTripRepo` 同一说明)。 */ const payload: Record = {}; payload['title'] = title; - payload['body'] = body; + payload['blocks'] = buildBlocks(body, images); payload['tags'] = tags; - const opts: FbApiOptions = { method: 'POST', token: token, body: payload }; - return await fbApi('/api/v1/notes', opts); + const opts: FbApiOptions = { + method: id > 0 ? 'PATCH' : 'POST', + token: token, + body: payload + }; + return await fbApi(id > 0 ? ('/api/v1/notes/' + id.toString()) : '/api/v1/notes', opts); } -/** 改笔记(标题 / 正文 / 标签) */ -export async function updateNote(token: string, id: number, title: string, body: string, - tags: string[]): Promise { - const payload: Record = {}; - payload['title'] = title; - payload['body'] = body; - payload['tags'] = tags; - const opts: FbApiOptions = { method: 'PATCH', token: token, body: payload }; - return await fbApi('/api/v1/notes/' + id.toString(), opts); +/** + * 正文文本 + 图片 key → `note_blocks` 数组。 + * + * 顺序:正文一个 text 块在前,图片块依次排在后面。 + * ⚠️ 正文/图至少一样,否则返回空数组(服务端 `cleanBlocks` 会丢空块, + * 而"标题和正文都空"整个请求也会被服务端拒掉)。 + */ +function buildBlocks(body: string, images: string[]): Record[] { + const blocks: Record[] = []; + const txt: string = body.trim(); + if (txt !== '') { + const t: Record = {}; + t['kind'] = 'text'; + t['content'] = txt; + blocks.push(t); + } + for (const key of images) { + const k: string = key.trim(); + if (k === '') { + continue; + } + const im: Record = {}; + im['kind'] = 'image'; + im['content'] = k; + blocks.push(im); + } + return blocks; } /** 删笔记(服务端只允许作者删) */ export async function deleteNote(token: string, id: number): Promise { const opts: FbApiOptions = { method: 'DELETE', token: token }; return await fbApi('/api/v1/notes/' + id.toString(), opts); -} +} \ No newline at end of file diff --git a/entry/src/main/ets/data/FbParse.ets b/entry/src/main/ets/data/FbParse.ets index 34dd6db..c830ad6 100644 --- a/entry/src/main/ets/data/FbParse.ets +++ b/entry/src/main/ets/data/FbParse.ets @@ -17,7 +17,7 @@ import { FbAccount, FbAnniversaryItem, FbBookItem, FbBookNoteItem, FbCalEvent, FbHomeData, FbKinChip, FbMark, - FbLedgerBook, FbLedgerDetail, FbLedgerEntry, FbNoteItem, FbNotificationItem, FbResourceItem, + FbLedgerBook, FbLedgerDetail, FbLedgerEntry, FbNoteItem, FbNoteBlock, FbNotificationItem, FbResourceItem, FbTaskItem, FbTripItem, FbWeather, FbStat, FbMemoryItem, FbRecentDay, FbTaskAttach, FbTaskComment, FbTaskDetail, FbTaskKid, FbTaskMeta, FbCatOpt, FbBoardOpt, } from '../model/Types'; @@ -614,6 +614,7 @@ export function parseNotes(json: string): FbNoteItem[] { /* ⚠️ 服务端列表已带 `blocks` / `tags` / `excerpt`(`Note::attachChildren`), 这里把它们都取出来 —— 原来只拿 blocks 算了个摘要,正文和标签全丢了。 */ const body: string = noteBodyOf(r.blocks); + const blocks: FbNoteBlock[] = noteBlocksOf(r.blocks); const tags: string[] = []; for (const t of arr(r.tags)) { tags.push(s(t)); @@ -628,6 +629,7 @@ export function parseNotes(json: string): FbNoteItem[] { shared: n(r.is_shared) !== 0, attachCount: 0, body: body, + blocks: blocks, mine: n(r.is_mine) !== 0, }); } @@ -635,10 +637,34 @@ export function parseNotes(json: string): FbNoteItem[] { } /** - * `note_blocks` → 一段纯文本。 + * `note_blocks` → 端上块数组(工单 #23:图文混排要原始块,别只留一张拼好的文本)。 * - * ⚠️ 图片块的内容是**附件 key**(不是正文),拼进来会变成一串乱码 key - * ⇒ 记成「[图片]」,用户一眼知道那里有张图。 + * ⚠️ 只保留白名单里的 `text` / `image`(服务端 `Note::BLOCK_KINDS`), + * 其它一律归一成 text 并交给 noteBodyOf 语义处理;这里只吐给展示/编辑用。 + */ +function noteBlocksOf(blocks: RawNoteBlock[] | undefined): FbNoteBlock[] { + const rows: RawNoteBlock[] = arr(blocks); + const out: FbNoteBlock[] = []; + for (const bk of rows) { + const kind: string = s(bk.kind); + if (kind !== 'text' && kind !== 'image') { + continue; + } + out.push({ + kind: kind, + content: s(bk.content), + position: n(bk.position), + }); + } + return out; +} + +/** + * `note_blocks` → 一段纯文本(**只拼 text 块**,图片块跳过)。 + * + * 🔴 工单 #23:原来图片块被记成「[图片]」占位字,正文框显示它、保存还会把 + * 它当正文写回去。现在图片块由 `FbNoteItem.blocks` 单独承载,正文只留纯文本。 + * 这样:① 显示真图不靠占位字;② 保存时用 `blocks` 传法,图片不会被覆盖丢。 */ function noteBodyOf(blocks: RawNoteBlock[] | undefined): string { const rows: RawNoteBlock[] = arr(blocks); @@ -646,7 +672,6 @@ function noteBodyOf(blocks: RawNoteBlock[] | undefined): string { for (const b of rows) { const kind: string = s(b.kind); if (kind === 'image') { - parts.push('[图片]'); continue; } const txt: string = s(b.content).trim(); @@ -657,6 +682,18 @@ function noteBodyOf(blocks: RawNoteBlock[] | undefined): string { return parts.join('\n\n'); } +/** + * 图片块 content → 纯净附件 key(剥掉「`|宽度`」后缀)。 + * + * 与 Web 端 `Note::imageKey` 同语义:图片块 content 可能是 `key` 或 `key|50`, + * 拼图片 URL 时必须取 `|` 之前的 key,否则带后缀的那张图永远加载不出来。 + */ +export function noteImageKey(content: string): string { + const c: string = content.trim(); + const i: number = c.indexOf('|'); + return i >= 0 ? c.substring(0, i).trim() : c; +} + /** 通知 → FbNotificationItem[] */ export function parseNotifications(json: string): FbNotificationItem[] { const root: Record | null = asObject(json); diff --git a/entry/src/main/ets/data/FbUpload.ets b/entry/src/main/ets/data/FbUpload.ets index 3f416ae..f1d511a 100644 --- a/entry/src/main/ets/data/FbUpload.ets +++ b/entry/src/main/ets/data/FbUpload.ets @@ -42,15 +42,72 @@ const TAG: string = 'FbUpload'; */ export async function fbUploadOne(path: string, field: string, uri: string, token: string): Promise { + const r: FbUploadRaw = await fbUploadRaw(path, field, uri, token); + return r.ok ? '' : r.error; +} + +/** + * 上传图片并**拿回响应载荷**(工单 #23 用)。 + * + * 笔记插图要拿到服务端刚存的那张图的附件 key(存进 `image` 块), + * 而 `fbUploadOne` 只返回"成没成"、把 key 丢了。这里复用同一个 + * multipart 核心(`fbUploadRaw`),成功时把信封里的 `key` 吐出来。 + * + * @returns key = 附件相对键;空串且 error 非空 = 失败 + */ +export interface FbUploadKey { + /** 附件 key(成功时有值) */ + key: string; + /** 错误说明(空 = 成功) */ + error: string; +} + +export async function fbUploadKey(path: string, field: string, uri: string, + token: string): Promise { + const r: FbUploadRaw = await fbUploadRaw(path, field, uri, token); + if (!r.ok) { + return { key: '', error: r.error }; + } + try { + const o: Record = JSON.parse(r.body) as Record; + const k: Object | undefined = o['key']; + if (typeof k === 'string' && k !== '') { + return { key: k, error: '' }; + } + // 没拿到 key:再按信封的 `data` 兜一把(不同端点习惯不同) + const d: Object | undefined = o['data']; + if (d !== undefined && typeof d === 'object' && d !== null) { + const dk: Object | undefined = (d as Record)['key']; + if (typeof dk === 'string' && dk !== '') { + return { key: dk, error: '' }; + } + } + return { key: '', error: '服务端没回图片地址' }; + } catch (err) { + return { key: '', error: '响应不是合法 JSON' }; + } +} + +/** `fbUploadOne` 与 `fbUploadKey` 共用的 multipart 核心(勿复制,见文件头说明) */ +interface FbUploadRaw { + ok: boolean; + /** 响应正文(成功时是非空 JSON) */ + body: string; + /** 给人看的错误说明 */ + error: string; +} + +async function fbUploadRaw(path: string, field: string, uri: string, + token: string): Promise { const base: string = fbApiBase(); if (base === '') { - return '还没有设置服务器地址'; + return { ok: false, body: '', error: '还没有设置服务器地址' }; } if (token === '') { - return '还没登录'; + return { ok: false, body: '', error: '还没登录' }; } if (uri === '') { - return '没选到文件'; + return { ok: false, body: '', error: '没选到文件' }; } let buf: ArrayBuffer; try { @@ -61,7 +118,7 @@ export async function fbUploadOne(path: string, field: string, uri: string, fileIo.closeSync(file); } catch (err) { hilog.warn(DOMAIN, TAG, '读文件失败:%{public}s', JSON.stringify(err)); - return '读不出这张图'; + return { ok: false, body: '', error: '读不出这张图' }; } const boundary: string = '----FbUpload' + Date.now().toString(); @@ -85,7 +142,7 @@ export async function fbUploadOne(path: string, field: string, uri: string, expectDataType: http.HttpDataType.STRING }); if (resp.responseCode >= 200 && resp.responseCode < 300) { - return ''; + return { ok: true, body: typeof resp.result === 'string' ? resp.result : '', error: '' }; } const body: string = typeof resp.result === 'string' ? resp.result : ''; // 服务端的错误说明是 JSON 信封里的 `error` —— 尽量把它抠出来给人话 @@ -99,11 +156,11 @@ export async function fbUploadOne(path: string, field: string, uri: string, } catch (err) { // 不是 JSON(如 Apache 的错误页)⇒ 保留 HTTP 码 } - return msg; + return { ok: false, body: body, error: msg }; } catch (err) { const e: BusinessError = err as BusinessError; hilog.warn(DOMAIN, TAG, '上传失败 %{public}s:%{public}s', path, JSON.stringify(e)); - return '网络异常'; + return { ok: false, body: '', error: '网络异常' }; } finally { req.destroy(); } diff --git a/entry/src/main/ets/model/DemoData.ets b/entry/src/main/ets/model/DemoData.ets index 14b6161..9fad2f0 100644 --- a/entry/src/main/ets/model/DemoData.ets +++ b/entry/src/main/ets/model/DemoData.ets @@ -361,27 +361,27 @@ export const FB_DEMO_NOTES: FbNoteItem[] = [ { id: 1, title: '阳台改造清单', excerpt: '1. 换推拉门(断桥铝,考虑隔音)2. 加一组置物架 3. 补两盆绿萝…', tags: ['家务', '装修'], updatedAt: '10-11 09:12', shared: false, attachCount: 2, - body: '1. 换推拉门(断桥铝,考虑隔音)\n2. 加一组置物架\n3. 补两盆绿萝', mine: true + body: '1. 换推拉门(断桥铝,考虑隔音)\n2. 加一组置物架\n3. 补两盆绿萝', blocks: [], mine: true }, { id: 2, title: '读书摘抄 · 置身事内', excerpt: '「地方政府的行为,要从它面对的制度约束去理解。」……', tags: ['读书'], updatedAt: '10-10 22:40', shared: true, attachCount: 0, - body: '「地方政府的行为,要从它面对的制度约束去理解。」', mine: true + body: '「地方政府的行为,要从它面对的制度约束去理解。」', blocks: [], mine: true }, { id: 3, title: '儿子的兴趣班时间表', excerpt: '周一 16:30 数学;周三 17:00 游泳;周六 09:00 钢琴…', tags: ['孩子'], updatedAt: '10-09 18:05', shared: true, attachCount: 1, - body: '周一 16:30 数学\n周三 17:00 游泳\n周六 09:00 钢琴', mine: false + body: '周一 16:30 数学\n周三 17:00 游泳\n周六 09:00 钢琴', blocks: [], mine: false }, { id: 4, title: '崂山两日流水', excerpt: '第一天仰口 → 太清宫;第二天巨峰。民宿 680,两餐 218…', tags: ['旅行', '记账'], updatedAt: '10-04 20:30', shared: false, attachCount: 6, - body: '第一天仰口 → 太清宫;第二天巨峰。民宿 680,两餐 218', mine: true + body: '第一天仰口 → 太清宫;第二天巨峰。民宿 680,两餐 218', blocks: [], mine: true }, { id: 5, title: '车里该备的东西', excerpt: '拖车绳、搭电线、应急灯、保温毯、常用药…', tags: ['车'], updatedAt: '09-25 07:48', shared: false, attachCount: 0, - body: '拖车绳、搭电线、应急灯、保温毯、常用药', mine: true + body: '拖车绳、搭电线、应急灯、保温毯、常用药', blocks: [], mine: true } ]; diff --git a/entry/src/main/ets/model/Types.ets b/entry/src/main/ets/model/Types.ets index c5d6c58..9ab38e2 100644 --- a/entry/src/main/ets/model/Types.ets +++ b/entry/src/main/ets/model/Types.ets @@ -1004,18 +1004,40 @@ export interface FbNoteItem { /** 附件数 */ attachCount: number; /** - * 正文**纯文本**(把 `note_blocks` 里 text 块拼起来,图片块记成「[图片]」)。 + * 正文**纯文本**(只把 `note_blocks` 里的 text 块拼起来,图片块不进正文)。 * * 🔴 v65(用户 2026-10-20「笔记现在也点不进去,记笔记也记不了」): * 服务端 `/notes` 列表其实**已经带 `blocks`**(`Note::attachChildren` 一次性挂上), * 只是端上原来只用它算了个摘要扔掉 ⇒ 点进详情时正文没了。 * 这里存下来,详情直接显示,不必再打一次 `/notes/`。 + * 🔴 v95+(工单 #23):图片块另存 `blocks` 字段,不再挤进正文当「[图片]」。 */ body: string; + /** + * 正文的**块**(服务端 `note_blocks` 的镜像,工单 #23)。 + * + * 🔴 为什么要在 `body`(纯文本摘要)之外再存一份块: + * 端上要"图文混排"就不能只拿一条拼好的文本 —— 图片块的内容是 + * 附件 key,`body` 里被记成了「[图片]」;要真显示图、以及编辑时 + * **不把图片块清掉**,都得拿到原始块(`kind` + `content` + `position`)。 + * 服务端列表(`Note::all` + `attachChildren`)本就下发了 `blocks`, + * 这里不再用一次就扔。 + */ + blocks: FbNoteBlock[]; /** 是不是我写的(不是的话只能看,服务端也只让作者删) */ mine: boolean; } +/** 一条笔记正文块(kind ∈ text / image) */ +export interface FbNoteBlock { + /** text / image */ + kind: string; + /** text = 文本;image = 附件 key(可能带 `|宽度` 后缀,取图时先剥) */ + content: string; + /** 块顺序 */ + position: number; +} + // ============================================================ // 通知(notifications) // ============================================================ diff --git a/entry/src/main/ets/pages/misc/FbMiscPages.ets b/entry/src/main/ets/pages/misc/FbMiscPages.ets index 1cb09d1..ece250c 100644 --- a/entry/src/main/ets/pages/misc/FbMiscPages.ets +++ b/entry/src/main/ets/pages/misc/FbMiscPages.ets @@ -13,7 +13,7 @@ import { FbColors, FbMetric, FbTint, fbAlpha } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; -import { FbNoteItem, FbNotificationItem } from '../../model/Types'; +import { FbNoteItem, FbNoteBlock, FbNotificationItem } from '../../model/Types'; import { parseNotes, parseNotifications } from '../../data/FbParse'; import { loadModule } from '../../data/FbModuleLoad'; import { FbAvatar, FbDot, FbEmptyBox, FbPrimaryButton, FbTag } from '../../components/Widgets'; @@ -53,6 +53,8 @@ export struct FbNotesPage { @Local sheetId: number = 0; @Local sheetTitle: string = ''; @Local sheetBody: string = ''; + /** 正文块(工单 #23:图文混排要原始块) */ + @Local sheetBlocks: FbNoteBlock[] = []; @Local sheetTags: string = ''; @Local sheetMine: boolean = true; @@ -88,6 +90,7 @@ export struct FbNotesPage { this.sheetId = 0; this.sheetTitle = ''; this.sheetBody = ''; + this.sheetBlocks = []; this.sheetTags = ''; this.sheetMine = true; this.sheetOpen = true; @@ -97,6 +100,7 @@ export struct FbNotesPage { this.sheetId = n.id; this.sheetTitle = n.title; this.sheetBody = n.body; + this.sheetBlocks = n.blocks; this.sheetTags = n.tags.join(' '); this.sheetMine = n.mine; this.sheetOpen = true; @@ -194,6 +198,7 @@ export struct FbNotesPage { editingId: this.sheetId, editingTitle: this.sheetTitle, editingBody: this.sheetBody, + editingBlocks: this.sheetBlocks, editingTags: this.sheetTags, mine: this.sheetMine, onClose: (): void => {