笔记图片块:展示真图 + 插图 + 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
+66 -22
View File
@@ -5,49 +5,93 @@
* POST /api/v1/notes 新建(title / body 或 blocks / tags)
* PATCH /api/v1/notes/<id> 改
* DELETE /api/v1/notes/<id> 删(**只限作者**)
* 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<FbApiResult> {
return saveNote(token, 0, title, body, [], tags);
}
/** 改笔记(纯文本;🔴 仅限作者能改,但这里不判——服务端会拦) */
export async function updateNote(token: string, id: number, title: string, body: string,
tags: string[]): Promise<FbApiResult> {
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<FbApiResult> {
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<FbApiResult> {
/* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:请求体必须
`Record<string, Object> = {}` 逐键赋值(见 `FbTripRepo` 同一说明)。 */
const payload: Record<string, Object> = {};
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<FbApiResult> {
const payload: Record<string, Object> = {};
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<string, Object>[] {
const blocks: Record<string, Object>[] = [];
const txt: string = body.trim();
if (txt !== '') {
const t: Record<string, Object> = {};
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<string, Object> = {};
im['kind'] = 'image';
im['content'] = k;
blocks.push(im);
}
return blocks;
}
/** 删笔记(服务端只允许作者删) */
export async function deleteNote(token: string, id: number): Promise<FbApiResult> {
const opts: FbApiOptions = { method: 'DELETE', token: token };
return await fbApi('/api/v1/notes/' + id.toString(), opts);
}
}
+42 -5
View File
@@ -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<string>(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<RawNoteBlock>(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<RawNoteBlock>(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<string, Object> | null = asObject(json);
+64 -7
View File
@@ -42,15 +42,72 @@ const TAG: string = 'FbUpload';
*/
export async function fbUploadOne(path: string, field: string, uri: string,
token: string): Promise<string> {
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<FbUploadKey> {
const r: FbUploadRaw = await fbUploadRaw(path, field, uri, token);
if (!r.ok) {
return { key: '', error: r.error };
}
try {
const o: Record<string, Object> = JSON.parse(r.body) as Record<string, Object>;
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<string, Object>)['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<FbUploadRaw> {
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();
}