/** * 家书 FamilyBook · 笔记浮层(新建 / 看 / 改 / 删)(鸿蒙端) * * 对应网页版 `templates/notes/`(网页是"卡片 + 详情页",端上合成一个浮层: * 卡片是一段短文,为它单开一屏不值当)。 * * 🔴 用户 2026-10-20:「笔记现在也点不进去,记笔记也记不了。」 * ⇒ 端上既没有新建、也没有详情:`FbPrimaryButton` 无 `onTap`、卡片无 `onClick`。 * * ⚠️ 端上只支持**纯文本**(服务端 `body` → 一个 `text` 块): * 网页那边可以图文混排(`note_blocks` 有 image 块),端上先不做 —— * 图片块的内容是附件 key,硬拼进文本框会变成一串乱码。 * 所以已有笔记里的图片块在端上显示成「[图片]」(见 `parseNotes` 的 `noteBodyOf`)。 * * ⚠️ **只读**:别人共享给我的笔记能看不能改(服务端 `Note::canWrite` 那一套)。 * 端上按 `mine` 判断 —— 保守一点宁可少给编辑,也不能给个点了报 403 的按钮。 */ 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 { FbApiResult } from '../data/FbHttp'; @ComponentV2 export struct FbNoteSheet { /** 模块主题色(笔记 = 褐灰) */ @Param accent: string = '#7A6A4F'; /** 正在看/改的笔记 id(0 = 新建) */ @Param editingId: number = 0; @Param editingTitle: string = ''; @Param editingBody: string = ''; @Param editingTags: string = ''; /** 是不是我写的(不是就只能看) */ @Param mine: boolean = true; @Event onClose: () => void = (): void => {}; @Event onSaved: () => void = (): void => {}; @Event onDeleted: () => void = (): void => {}; @Local title: string = ''; @Local body: string = ''; /** 标签输入(用空格或逗号分隔,服务端会自己归一) */ @Local tags: string = ''; @Local saving: boolean = false; @Local confirmingDelete: boolean = false; @Local note: string = ''; aboutToAppear(): void { this.title = this.editingTitle; this.body = this.editingBody; this.tags = this.editingTags; } /** 能不能改:新建时当然能;已有笔记只有自己写的能改 */ private editOk(): boolean { return this.editingId <= 0 || this.mine; } /** 标签串 → 数组(逗号 / 顿号 / 空格 / # 都当分隔) */ private tagList(): string[] { const norm: string = this.tags.replace(new RegExp('#', 'g'), ' ') .replace(new RegExp(',', 'g'), ' ') .replace(new RegExp(',', 'g'), ' ') .replace(new RegExp('、', 'g'), ' '); const parts: string[] = norm.split(' '); const out: string[] = []; for (const p of parts) { const t: string = p.trim(); if (t !== '' && out.indexOf(t) < 0) { out.push(t); } } return out; } build() { /* 🔴🔴 根必须 `Stack`:遮罩与卡片要"叠",不能当 `Column` 子项"排" (排会把卡片挤出屏幕 —— 2026-10-20 真机事故,见 pitfalls.md) */ Stack({ alignContent: Alignment.Center }) { Column() .width('100%') .height('100%') .backgroundColor(FB_MASK) .onClick((): void => { this.onClose(); }) Column() { FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) { Column({ space: 12 }) { // ---- 标题栏 ---- Row({ space: 8 }) { Column({ space: 2 }) { Text(this.editingId > 0 ? (this.mine ? '笔记' : '别人共享的笔记') : '记一条') .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) .maxLines(1) Text(this.editOk() ? '随手记点什么,按标签归类和检索' : '只读:只能看,改要作者来') .fontSize(FbMetric.fontTiny) .fontColor(this.accent) .maxLines(1) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .flexShrink(1) FbSheetClose({ onClose: (): void => { this.onClose(); }, color: this.accent }) } .width('100%') .alignItems(VerticalAlign.Center) Scroll() { Column({ space: 12 }) { // ---- 标题 ---- Row({ space: 8 }) { Text('标题') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .width(34) TextInput({ text: this.title, placeholder: '给这条起个名字' }) .placeholderColor(FbColors.faint) .placeholderFont({ size: FbMetric.fontTiny }) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) .height(42) .layoutWeight(1) .padding({ left: 10, right: 10 }) .enabled(this.editOk()) .onChange((v: string): void => { this.title = v; this.note = ''; }) } .width('100%') .alignItems(VerticalAlign.Center) // ---- 正文 ---- Row({ space: 8 }) { Text('正文') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .width(34) TextArea({ text: this.body, placeholder: '想记的都写在这儿' }) .placeholderColor(FbColors.faint) .placeholderFont({ size: FbMetric.fontTiny }) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) .height(200) .layoutWeight(1) .padding({ left: 10, right: 10, top: 8, bottom: 8 }) .enabled(this.editOk()) .onChange((v: string): void => { this.body = v; }) } .width('100%') .alignItems(VerticalAlign.Top) // ---- 标签 ---- Row({ space: 8 }) { Text('标签') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .width(34) TextInput({ text: this.tags, placeholder: '空格或逗号分隔,如:读书 摘抄' }) .placeholderColor(FbColors.faint) .placeholderFont({ size: FbMetric.fontTiny }) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) .height(42) .layoutWeight(1) .padding({ left: 10, right: 10 }) .enabled(this.editOk()) .onChange((v: string): void => { this.tags = v; }) } .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) } } .width('100%') .alignItems(HorizontalAlign.Start) } .constraintSize({ maxHeight: 420 }) .width('100%') .scrollBar(BarState.Off) .align(Alignment.TopStart) if (this.note !== '') { Text(this.note) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.red) .width('100%') .maxLines(3) } // ---- 底部:删除(左,只对自己写的)+ 保存 ---- Row({ space: 10 }) { if (this.editingId > 0 && this.mine) { Text(this.confirmingDelete ? '真的删掉?' : '删除') .fontSize(FbMetric.fontBody) .fontWeight(FontWeight.Medium) .fontColor('#FFFFFF') .textAlign(TextAlign.Center) .height(44) .padding({ left: 16, right: 16 }) .borderRadius(FbMetric.radiusS) /* 🔴 v61:删除按钮统一红底白字。红值取自 color.json 的 `red` */ .backgroundColor(this.confirmingDelete ? '#C0533F' : fbAlpha('#C0533F', 0.86)) .onClick((): void => { if (this.confirmingDelete) { this.doDelete(); } else { this.confirmingDelete = true; } }) } if (this.editOk()) { Text(this.saving ? '保存中…' : (this.editingId > 0 ? '保存修改' : '记下来')) .fontSize(FbMetric.fontBody) .fontWeight(FontWeight.Medium) .fontColor(this.saving ? FbColors.faint : '#FFFFFF') .textAlign(TextAlign.Center) .layoutWeight(1) .height(44) .borderRadius(FbMetric.radiusS) .backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98)) .onClick((): void => { this.save(); }) } else { Text('知道了') .fontSize(FbMetric.fontBody) .fontWeight(FontWeight.Medium) .fontColor('#FFFFFF') .textAlign(TextAlign.Center) .layoutWeight(1) .height(44) .borderRadius(FbMetric.radiusS) .backgroundColor(fbAlpha(this.accent, 0.9)) .onClick((): void => { this.onClose(); }) } } .width('100%') .alignItems(VerticalAlign.Center) } .width('100%') .padding({ left: 18, right: 18, top: 18, bottom: 18 }) .alignItems(HorizontalAlign.Start) } } .width('92%') } .width('100%') .height('100%') .hitTestBehavior(HitTestMode.Transparent) } private async save(): Promise { if (this.saving) { return; } const t: string = this.title.trim(); const b: string = this.body.trim(); if (t === '' && b === '') { this.note = '标题和正文至少写一样(服务端不允许存一条空笔记)。'; return; } this.saving = true; this.note = ''; /* ⚠️ 标题空着也能存(服务端允许纯正文笔记),但列表卡片上没标题会很难认 ⇒ 用正文首行兜个标题(只影响显示,不改变用户输入)。 */ const useTitle: string = t !== '' ? t : 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()); this.saving = false; if (!res.ok) { this.note = res.error !== '' ? res.error : '保存失败,请稍后再试。'; return; } this.onSaved(); } private async doDelete(): Promise { if (this.saving) { return; } this.saving = true; this.note = ''; const res: FbApiResult = await deleteNote(FB_TOKEN_STORE.get(), this.editingId); this.saving = false; this.confirmingDelete = false; if (!res.ok) { this.note = res.error !== '' ? res.error : '删除失败,请稍后再试。'; return; } this.onDeleted(); } }