笔记图片块:展示真图 + 插图 + 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
+134 -16
View File
@@ -7,10 +7,14 @@
* 🔴 用户 2026-10-20:「笔记现在也点不进去,记笔记也记不了。」 * 🔴 用户 2026-10-20:「笔记现在也点不进去,记笔记也记不了。」
* ⇒ 端上既没有新建、也没有详情:`FbPrimaryButton` 无 `onTap`、卡片无 `onClick`。 * ⇒ 端上既没有新建、也没有详情:`FbPrimaryButton` 无 `onTap`、卡片无 `onClick`。
* *
* ⚠️ 端上只支持**纯文本**(服务端 `body` → 一个 `text` 块): * 🔴 工单 #23(用户 2026-10-28):图文混排。原来端上只支持纯文本——
* 网页那边可以图文混排(`note_blocks` 有 image 块),端上先不做 —— * · 图片块显示成「[图片]」占位;
* 图片块的内容是附件 key,硬拼进文本框会变成一串乱码。 * · 更糟:保存时只传 `body`,服务端折成单个 text 块**整组替换**,
* 所以已有笔记里的图片块在端上显示成「[图片]」(见 `parseNotes` 的 `noteBodyOf`)。 * 编辑一条带图笔记会把图片块**静默删光**。
* 现在:
* · `editingBlocks` 把原始块(text/image)带进来,image 块真画图;
* · 「插图」按钮 → 选图 → 上传拿 key → 追加到图片列表;
* · 保存走 `saveNoteWithBlocks`(正文 text 块 + 图片 image 块整组提交)。
* *
* ⚠️ **只读**:别人共享给我的笔记能看不能改(服务端 `Note::canWrite` 那一套)。 * ⚠️ **只读**:别人共享给我的笔记能看不能改(服务端 `Note::canWrite` 那一套)。
* 端上按 `mine` 判断 —— 保守一点宁可少给编辑,也不能给个点了报 403 的按钮。 * 端上按 `mine` 判断 —— 保守一点宁可少给编辑,也不能给个点了报 403 的按钮。
@@ -20,8 +24,15 @@ import { FbColors, FbMetric, FB_MASK, fbAlpha } from '../common/Theme';
import { FbSheetClose } from './FbSheetClose'; import { FbSheetClose } from './FbSheetClose';
import { FbSheetRing } from './FbSheetRing'; import { FbSheetRing } from './FbSheetRing';
import { FB_TOKEN_STORE } from '../data/FbTokenStore'; 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 { FbApiResult } from '../data/FbHttp';
import { picker } from '@kit.CoreFileKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
@ComponentV2 @ComponentV2
export struct FbNoteSheet { export struct FbNoteSheet {
@@ -31,6 +42,8 @@ export struct FbNoteSheet {
@Param editingId: number = 0; @Param editingId: number = 0;
@Param editingTitle: string = ''; @Param editingTitle: string = '';
@Param editingBody: string = ''; @Param editingBody: string = '';
/** 正文原始块(工单 #23:image 块借此真画图;为空则回到纯文本) */
@Param editingBlocks: FbNoteBlock[] = [];
@Param editingTags: string = ''; @Param editingTags: string = '';
/** 是不是我写的(不是就只能看) */ /** 是不是我写的(不是就只能看) */
@Param mine: boolean = true; @Param mine: boolean = true;
@@ -41,6 +54,10 @@ export struct FbNoteSheet {
@Local title: string = ''; @Local title: string = '';
@Local body: string = ''; @Local body: string = '';
/** 图片块 key(工单 #23:已存在的 + 新插的,按序保存) */
@Local imageKeys: string[] = [];
/** 正在上传插图 */
@Local uploadingImg: boolean = false;
/** 标签输入(用空格或逗号分隔,服务端会自己归一) */ /** 标签输入(用空格或逗号分隔,服务端会自己归一) */
@Local tags: string = ''; @Local tags: string = '';
@Local saving: boolean = false; @Local saving: boolean = false;
@@ -51,6 +68,17 @@ export struct FbNoteSheet {
this.title = this.editingTitle; this.title = this.editingTitle;
this.body = this.editingBody; this.body = this.editingBody;
this.tags = this.editingTags; 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%') .width('100%')
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
// 图片块在端上只能显示成「[图片]」—— 说清楚,别让人以为内容丢了 // ---- 图片(工单 #23:真画图,不显示「[图片]」占位)----
if (this.editingId > 0 && this.body.indexOf('[图片]') >= 0) { if (this.imageKeys.length > 0) {
Text('这条笔记里的图片在网页端看;App 这边先显示成「[图片]」。') Column({ space: 8 }) {
.fontSize(FbMetric.fontTiny) ForEach(this.imageKeys, (key: string, idx: number) => {
.fontColor(FbColors.faint) 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%') .width('100%')
.maxLines(2) .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%') .width('100%')
@@ -287,19 +354,23 @@ export struct FbNoteSheet {
} }
const t: string = this.title.trim(); const t: string = this.title.trim();
const b: string = this.body.trim(); const b: string = this.body.trim();
if (t === '' && b === '') { const hasImg: boolean = this.imageKeys.length > 0;
this.note = '标题和正文至少写一样(服务端不允许存一条空笔记)。'; if (t === '' && b === '' && !hasImg) {
this.note = '标题、正文或图片至少填一样(服务端不允许存一条空笔记)。';
return; return;
} }
this.saving = true; this.saving = true;
this.note = ''; 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 token: string = FB_TOKEN_STORE.get();
const res: FbApiResult = this.editingId > 0 // 🔴 工单 #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 updateNote(token, this.editingId, useTitle, b, this.tagList())
: await createNote(token, useTitle, b, this.tagList()); : await createNote(token, useTitle, b, this.tagList()));
this.saving = false; this.saving = false;
if (!res.ok) { if (!res.ok) {
this.note = res.error !== '' ? res.error : '保存失败,请稍后再试。'; this.note = res.error !== '' ? res.error : '保存失败,请稍后再试。';
@@ -308,6 +379,53 @@ export struct FbNoteSheet {
this.onSaved(); 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> { private async doDelete(): Promise<void> {
if (this.saving) { if (this.saving) {
return; return;
+65 -21
View File
@@ -5,45 +5,89 @@
* POST /api/v1/notes 新建(title / body 或 blocks / tags) * POST /api/v1/notes 新建(title / body 或 blocks / tags)
* PATCH /api/v1/notes/<id> 改 * PATCH /api/v1/notes/<id> 改
* DELETE /api/v1/notes/<id> 删(**只限作者**) * DELETE /api/v1/notes/<id> 删(**只限作者**)
* POST /api/v1/notes/upload 传图片拿 key(工单 #23,草稿态,见下)
* *
* 🔴 用户 2026-10-20:「笔记现在也点不进去,记笔记也记不了。」 * 🔴 用户 2026-10-20:「笔记现在也点不进去,记笔记也记不了。」
* ⇒ 端上「记一条」按钮没有 `onTap`、卡片没有 `onClick`。 * ⇒ 端上「记一条」按钮没有 `onTap`、卡片没有 `onClick`。
* *
* ⚠️⚠️ 正文的键是 **`body`(纯文本)**,不是 `blocks`: * ⚠️⚠️ 工单 #23(图文混排)**存储键从 `body` 换成 `blocks`**:
* `Note` 那边真正的存储是 `note_blocks` 若干块,服务端 `notes()` 收到 `body` * 服务端 `Note` 真正的存储是 `note_blocks` 若干块。原来端上只传 `body`,
* 会包成一个 `text` 块(见 `ApiHandler::noteBlocksIn` 的说明)。 * 服务端会把它折成**单个 text 块**并**整组替换**(先 DELETE 再 INSERT)⇒
* v65 之前接口传的是 `body` 但 `Note` 只读 `blocks` —— 正文被静默丢掉; * **端上编辑一条带图笔记、点保存,图片块被静默删光**(P0 数据丢失)。
* **现在两边对齐了**:端上传 `body`,服务端转成块。 * ⇒ 现在端上把"正文文本 + 图片块"拼成 `blocks` 数组一起传。
* ⇒ 端上不要自己去拼 `blocks`(那是网页图文混排的形态,手机上先支持纯文本)。
* *
* ⚠️ `PATCH` 时若**不传** `body`/`blocks`,服务端会沿用库里的块(不会清空)。 * ⚠️ 服务端 `noteBlocksIn()` 的判定顺序:**先认 `blocks`,再认 `body`**。
* 这里仍然每次都传 `body`:用户看到的就是那个文本框,保存即整段替换。 * 所以传 `blocks` 时它整组用 `blocks`,不会再碰 `body`。
*/ */
import { fbApi, FbApiResult, FbApiOptions } from './FbHttp'; import { fbApi, FbApiResult, FbApiOptions } from './FbHttp';
/** 新建笔记 */ /** 新建笔记(纯文本:单个 text 块) */
export async function createNote(token: string, title: string, body: string, export async function createNote(token: string, title: string, body: string,
tags: string[]): Promise<FbApiResult> { 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`:请求体必须 /* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:请求体必须
`Record<string, Object> = {}` 逐键赋值(见 `FbTripRepo` 同一说明)。 */ `Record<string, Object> = {}` 逐键赋值(见 `FbTripRepo` 同一说明)。 */
const payload: Record<string, Object> = {}; const payload: Record<string, Object> = {};
payload['title'] = title; payload['title'] = title;
payload['body'] = body; payload['blocks'] = buildBlocks(body, images);
payload['tags'] = tags; payload['tags'] = tags;
const opts: FbApiOptions = { method: 'POST', token: token, body: payload }; const opts: FbApiOptions = {
return await fbApi('/api/v1/notes', opts); 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, * 正文文本 + 图片 key → `note_blocks` 数组。
tags: string[]): Promise<FbApiResult> { *
const payload: Record<string, Object> = {}; * 顺序:正文一个 text 块在前,图片块依次排在后面。
payload['title'] = title; * ⚠️ 正文/图至少一样,否则返回空数组(服务端 `cleanBlocks` 会丢空块,
payload['body'] = body; * 而"标题和正文都空"整个请求也会被服务端拒掉)。
payload['tags'] = tags; */
const opts: FbApiOptions = { method: 'PATCH', token: token, body: payload }; function buildBlocks(body: string, images: string[]): Record<string, Object>[] {
return await fbApi('/api/v1/notes/' + id.toString(), opts); 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;
} }
/** 删笔记(服务端只允许作者删) */ /** 删笔记(服务端只允许作者删) */
+42 -5
View File
@@ -17,7 +17,7 @@
import { import {
FbAccount, FbAnniversaryItem, FbBookItem, FbBookNoteItem, FbCalEvent, FbHomeData, FbKinChip, FbAccount, FbAnniversaryItem, FbBookItem, FbBookNoteItem, FbCalEvent, FbHomeData, FbKinChip,
FbMark, FbMark,
FbLedgerBook, FbLedgerDetail, FbLedgerEntry, FbNoteItem, FbNotificationItem, FbResourceItem, FbLedgerBook, FbLedgerDetail, FbLedgerEntry, FbNoteItem, FbNoteBlock, FbNotificationItem, FbResourceItem,
FbTaskItem, FbTripItem, FbWeather, FbStat, FbMemoryItem, FbRecentDay, FbTaskItem, FbTripItem, FbWeather, FbStat, FbMemoryItem, FbRecentDay,
FbTaskAttach, FbTaskComment, FbTaskDetail, FbTaskKid, FbTaskMeta, FbCatOpt, FbBoardOpt, FbTaskAttach, FbTaskComment, FbTaskDetail, FbTaskKid, FbTaskMeta, FbCatOpt, FbBoardOpt,
} from '../model/Types'; } from '../model/Types';
@@ -614,6 +614,7 @@ export function parseNotes(json: string): FbNoteItem[] {
/* ⚠️ 服务端列表已带 `blocks` / `tags` / `excerpt`(`Note::attachChildren`), /* ⚠️ 服务端列表已带 `blocks` / `tags` / `excerpt`(`Note::attachChildren`),
这里把它们都取出来 —— 原来只拿 blocks 算了个摘要,正文和标签全丢了。 */ 这里把它们都取出来 —— 原来只拿 blocks 算了个摘要,正文和标签全丢了。 */
const body: string = noteBodyOf(r.blocks); const body: string = noteBodyOf(r.blocks);
const blocks: FbNoteBlock[] = noteBlocksOf(r.blocks);
const tags: string[] = []; const tags: string[] = [];
for (const t of arr<string>(r.tags)) { for (const t of arr<string>(r.tags)) {
tags.push(s(t)); tags.push(s(t));
@@ -628,6 +629,7 @@ export function parseNotes(json: string): FbNoteItem[] {
shared: n(r.is_shared) !== 0, shared: n(r.is_shared) !== 0,
attachCount: 0, attachCount: 0,
body: body, body: body,
blocks: blocks,
mine: n(r.is_mine) !== 0, 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 { function noteBodyOf(blocks: RawNoteBlock[] | undefined): string {
const rows: RawNoteBlock[] = arr<RawNoteBlock>(blocks); const rows: RawNoteBlock[] = arr<RawNoteBlock>(blocks);
@@ -646,7 +672,6 @@ function noteBodyOf(blocks: RawNoteBlock[] | undefined): string {
for (const b of rows) { for (const b of rows) {
const kind: string = s(b.kind); const kind: string = s(b.kind);
if (kind === 'image') { if (kind === 'image') {
parts.push('[图片]');
continue; continue;
} }
const txt: string = s(b.content).trim(); const txt: string = s(b.content).trim();
@@ -657,6 +682,18 @@ function noteBodyOf(blocks: RawNoteBlock[] | undefined): string {
return parts.join('\n\n'); 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[] */ /** 通知 → FbNotificationItem[] */
export function parseNotifications(json: string): FbNotificationItem[] { export function parseNotifications(json: string): FbNotificationItem[] {
const root: Record<string, Object> | null = asObject(json); 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, export async function fbUploadOne(path: string, field: string, uri: string,
token: string): Promise<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(); const base: string = fbApiBase();
if (base === '') { if (base === '') {
return '还没有设置服务器地址'; return { ok: false, body: '', error: '还没有设置服务器地址' };
} }
if (token === '') { if (token === '') {
return '还没登录'; return { ok: false, body: '', error: '还没登录' };
} }
if (uri === '') { if (uri === '') {
return '没选到文件'; return { ok: false, body: '', error: '没选到文件' };
} }
let buf: ArrayBuffer; let buf: ArrayBuffer;
try { try {
@@ -61,7 +118,7 @@ export async function fbUploadOne(path: string, field: string, uri: string,
fileIo.closeSync(file); fileIo.closeSync(file);
} catch (err) { } catch (err) {
hilog.warn(DOMAIN, TAG, '读文件失败:%{public}s', JSON.stringify(err)); hilog.warn(DOMAIN, TAG, '读文件失败:%{public}s', JSON.stringify(err));
return '读不出这张图'; return { ok: false, body: '', error: '读不出这张图' };
} }
const boundary: string = '----FbUpload' + Date.now().toString(); 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 expectDataType: http.HttpDataType.STRING
}); });
if (resp.responseCode >= 200 && resp.responseCode < 300) { 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 : ''; const body: string = typeof resp.result === 'string' ? resp.result : '';
// 服务端的错误说明是 JSON 信封里的 `error` —— 尽量把它抠出来给人话 // 服务端的错误说明是 JSON 信封里的 `error` —— 尽量把它抠出来给人话
@@ -99,11 +156,11 @@ export async function fbUploadOne(path: string, field: string, uri: string,
} catch (err) { } catch (err) {
// 不是 JSON(如 Apache 的错误页)⇒ 保留 HTTP 码 // 不是 JSON(如 Apache 的错误页)⇒ 保留 HTTP 码
} }
return msg; return { ok: false, body: body, error: msg };
} catch (err) { } catch (err) {
const e: BusinessError = err as BusinessError; const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '上传失败 %{public}s:%{public}s', path, JSON.stringify(e)); hilog.warn(DOMAIN, TAG, '上传失败 %{public}s:%{public}s', path, JSON.stringify(e));
return '网络异常'; return { ok: false, body: '', error: '网络异常' };
} finally { } finally {
req.destroy(); req.destroy();
} }
+5 -5
View File
@@ -361,27 +361,27 @@ export const FB_DEMO_NOTES: FbNoteItem[] = [
{ {
id: 1, title: '阳台改造清单', excerpt: '1. 换推拉门(断桥铝,考虑隔音)2. 加一组置物架 3. 补两盆绿萝…', id: 1, title: '阳台改造清单', excerpt: '1. 换推拉门(断桥铝,考虑隔音)2. 加一组置物架 3. 补两盆绿萝…',
tags: ['家务', '装修'], updatedAt: '10-11 09:12', shared: false, attachCount: 2, 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: '「地方政府的行为,要从它面对的制度约束去理解。」……', id: 2, title: '读书摘抄 · 置身事内', excerpt: '「地方政府的行为,要从它面对的制度约束去理解。」……',
tags: ['读书'], updatedAt: '10-10 22:40', shared: true, attachCount: 0, 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 钢琴…', id: 3, title: '儿子的兴趣班时间表', excerpt: '周一 16:30 数学;周三 17:00 游泳;周六 09:00 钢琴…',
tags: ['孩子'], updatedAt: '10-09 18:05', shared: true, attachCount: 1, 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…', id: 4, title: '崂山两日流水', excerpt: '第一天仰口 → 太清宫;第二天巨峰。民宿 680,两餐 218…',
tags: ['旅行', '记账'], updatedAt: '10-04 20:30', shared: false, attachCount: 6, 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: '拖车绳、搭电线、应急灯、保温毯、常用药…', id: 5, title: '车里该备的东西', excerpt: '拖车绳、搭电线、应急灯、保温毯、常用药…',
tags: ['车'], updatedAt: '09-25 07:48', shared: false, attachCount: 0, tags: ['车'], updatedAt: '09-25 07:48', shared: false, attachCount: 0,
body: '拖车绳、搭电线、应急灯、保温毯、常用药', mine: true body: '拖车绳、搭电线、应急灯、保温毯、常用药', blocks: [], mine: true
} }
]; ];
+23 -1
View File
@@ -1004,18 +1004,40 @@ export interface FbNoteItem {
/** 附件数 */ /** 附件数 */
attachCount: number; attachCount: number;
/** /**
* 正文**纯文本**(把 `note_blocks` 里 text 块拼起来,图片块记成「[图片]」)。 * 正文**纯文本**(只把 `note_blocks` 里的 text 块拼起来,图片块不进正文)。
* *
* 🔴 v65(用户 2026-10-20「笔记现在也点不进去,记笔记也记不了」): * 🔴 v65(用户 2026-10-20「笔记现在也点不进去,记笔记也记不了」):
* 服务端 `/notes` 列表其实**已经带 `blocks`**(`Note::attachChildren` 一次性挂上), * 服务端 `/notes` 列表其实**已经带 `blocks`**(`Note::attachChildren` 一次性挂上),
* 只是端上原来只用它算了个摘要扔掉 ⇒ 点进详情时正文没了。 * 只是端上原来只用它算了个摘要扔掉 ⇒ 点进详情时正文没了。
* 这里存下来,详情直接显示,不必再打一次 `/notes/<id>`。 * 这里存下来,详情直接显示,不必再打一次 `/notes/<id>`。
* 🔴 v95+(工单 #23):图片块另存 `blocks` 字段,不再挤进正文当「[图片]」。
*/ */
body: string; body: string;
/**
* 正文的**块**(服务端 `note_blocks` 的镜像,工单 #23)。
*
* 🔴 为什么要在 `body`(纯文本摘要)之外再存一份块:
* 端上要"图文混排"就不能只拿一条拼好的文本 —— 图片块的内容是
* 附件 key,`body` 里被记成了「[图片]」;要真显示图、以及编辑时
* **不把图片块清掉**,都得拿到原始块(`kind` + `content` + `position`)。
* 服务端列表(`Note::all` + `attachChildren`)本就下发了 `blocks`,
* 这里不再用一次就扔。
*/
blocks: FbNoteBlock[];
/** 是不是我写的(不是的话只能看,服务端也只让作者删) */ /** 是不是我写的(不是的话只能看,服务端也只让作者删) */
mine: boolean; mine: boolean;
} }
/** 一条笔记正文块(kind ∈ text / image) */
export interface FbNoteBlock {
/** text / image */
kind: string;
/** text = 文本;image = 附件 key(可能带 `|宽度` 后缀,取图时先剥) */
content: string;
/** 块顺序 */
position: number;
}
// ============================================================ // ============================================================
// 通知(notifications) // 通知(notifications)
// ============================================================ // ============================================================
@@ -13,7 +13,7 @@
import { FbColors, FbMetric, FbTint, fbAlpha } from '../../common/Theme'; import { FbColors, FbMetric, FbTint, fbAlpha } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store'; 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 { parseNotes, parseNotifications } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad'; import { loadModule } from '../../data/FbModuleLoad';
import { FbAvatar, FbDot, FbEmptyBox, FbPrimaryButton, FbTag } from '../../components/Widgets'; import { FbAvatar, FbDot, FbEmptyBox, FbPrimaryButton, FbTag } from '../../components/Widgets';
@@ -53,6 +53,8 @@ export struct FbNotesPage {
@Local sheetId: number = 0; @Local sheetId: number = 0;
@Local sheetTitle: string = ''; @Local sheetTitle: string = '';
@Local sheetBody: string = ''; @Local sheetBody: string = '';
/** 正文块(工单 #23:图文混排要原始块) */
@Local sheetBlocks: FbNoteBlock[] = [];
@Local sheetTags: string = ''; @Local sheetTags: string = '';
@Local sheetMine: boolean = true; @Local sheetMine: boolean = true;
@@ -88,6 +90,7 @@ export struct FbNotesPage {
this.sheetId = 0; this.sheetId = 0;
this.sheetTitle = ''; this.sheetTitle = '';
this.sheetBody = ''; this.sheetBody = '';
this.sheetBlocks = [];
this.sheetTags = ''; this.sheetTags = '';
this.sheetMine = true; this.sheetMine = true;
this.sheetOpen = true; this.sheetOpen = true;
@@ -97,6 +100,7 @@ export struct FbNotesPage {
this.sheetId = n.id; this.sheetId = n.id;
this.sheetTitle = n.title; this.sheetTitle = n.title;
this.sheetBody = n.body; this.sheetBody = n.body;
this.sheetBlocks = n.blocks;
this.sheetTags = n.tags.join(' '); this.sheetTags = n.tags.join(' ');
this.sheetMine = n.mine; this.sheetMine = n.mine;
this.sheetOpen = true; this.sheetOpen = true;
@@ -194,6 +198,7 @@ export struct FbNotesPage {
editingId: this.sheetId, editingId: this.sheetId,
editingTitle: this.sheetTitle, editingTitle: this.sheetTitle,
editingBody: this.sheetBody, editingBody: this.sheetBody,
editingBlocks: this.sheetBlocks,
editingTags: this.sheetTags, editingTags: this.sheetTags,
mine: this.sheetMine, mine: this.sheetMine,
onClose: (): void => { onClose: (): void => {