笔记图片块:展示真图 + 插图 + blocks 保存(图文混排后半程)laoyang/FamilyBook#23

用户原话(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。
This commit is contained in:
2026-10-28 21:35:11 +08:00
parent 3e910f1ca0
commit 97846029c7
7 changed files with 342 additions and 59 deletions
+136 -18
View File
@@ -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<void> {
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<void> {
if (this.saving) {
return;