笔记图片块:展示真图 + 插图 + 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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user