v67:读书模块补齐(书能点进去 / 加一本 / 读书笔记 / 统计)

用户 2026-10-20:「其他的功能也逐步完成……读书点不进去,也没法加书,
设置也没有任何的东西。」

## 先修三处「对着服务端抄错的口径」(都不报错,只是显示是错的)
1. 状态值:服务端 `Book::STATUS` 只有 wish/reading/done/paused,**没有 want**;
   `Book::status()` 遇到不认识的值会静默改成 `wish`,而端上分组键写的 `want`
   ⇒ 书架里「想读」那一组**整组不显示**(页头还数着它,看着像书丢了)。
   `paused`(搁置)同样没列 ⇒ 搁置的书也人间蒸发。
2. 评分:服务端 `rating` 是 0–10,端上当 0–5 用 ⇒ 8 分显示成 `8.0/5`。
   现在解析时折半、写回时 ×2。
3. 进度:服务端 `progress` 是「读到第几章」,端上当百分比 ⇒ 读到第 30 章 = 30%。
   现在按 `chapter_count` 换算,没解析过章节的书不画假进度。

## 补齐三个死掉的入口
- 书行整行可点 → `FbBookSheet`(状态 / 评分 / 章节进度 / 读书笔记 / 删除二次确认)
- 页头「+ 加一本」→ 同一浮层的新建态(书名 / 作者 / 状态)
- 设置「读书统计」→ 端上现算(藏书/在读/读完/想读/搁置/笔记条数);
  另两条(导入笔记 / 微信读书)要文件选择与授权,仍标「网页端」,不摆假入口

## 服务端能力边界(照实做)
- `PATCH /books/<id>` 只认 status / rating ⇒ 详情里不给改书名/作者的输入框
- 章节进度只读(`progress` 由阅读器翻页写,接口没有回写入口)
- 读书笔记走真实接口 `GET/POST /books/<id>/notes`

## 验证
- `hvigorw assembleHap` → BUILD SUCCESSFUL
- `_fb_arkts_guard.js` → 797 / 0(新增 groupV67 28 条,含「端上分组键 ==
  服务端枚举」的跨语言对照)
- `_fb_v59d_falsify.py` → 130 / 0(v67 新增 13 条注入全部变红)
- ⚠️ 设备仍断连(`hdc list targets` → `[Empty]`),真机未验收
This commit is contained in:
2026-10-20 17:14:40 +08:00
parent 58f5509c1c
commit ec2cb14c4c
7 changed files with 1013 additions and 31 deletions
@@ -0,0 +1,680 @@
/**
* 家书 FamilyBook · 读书浮层:一本书(新建 / 详情 / 改 / 删 / 读书笔记)+ 读书统计
*
* 对应网页版 `templates/books/index.php` 的 `#bookEdit` 弹框与 `books/notes.php`。
*
* 🔴 用户 2026-10-20:「其他的功能也逐步完成……读书点不进去,也没法加书。」
* 端上原来只有**书架**:书行没有 `onClick`、页头没有"加一本"、
* 设置里三条 `action` 全是空串 ⇒ 三个入口全是死的。
*
* ⚠️⚠️ **服务端能力边界**(照实做,不摆假入口):
* · `PATCH /books/<id>` 只认 `status` / `rating`
* ⇒ 详情里**不提供改书名/作者**的输入框(改了也存不进去,比没做更糟),
* 只显示书名 + 一句"书名在网页端改"。
* · 章节进度是**只读**的(`progress` 由阅读器翻页写,接口没有回写入口)。
* · 上传 EPUB / 笔记导入 / 微信读书同步都在网页端(要文件选择与解析)。
*
* ⚠️ 状态取值必须是服务端枚举 `wish / reading / done / paused`,
* **不能写 `want`**(服务端会静默改成 `wish`,端上分组又认不出 `wish`)。
*/
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 { createBook, updateBook, deleteBook, fetchBookNotes, addBookNote } from '../data/FbBookRepo';
import { parseBookNotes } from '../data/FbParse';
import { FbApiResult } from '../data/FbHttp';
import { FbBookItem, FbBookNoteItem } from '../model/Types';
/** 状态选项:`key` 必须与服务端 `Book::STATUS` 逐字一致 */
const FB_BOOK_STATUS: string[] = ['wish', 'reading', 'done', 'paused'];
const FB_BOOK_STATUS_LABEL: string[] = ['想读', '在读', '读完', '搁置'];
/** 评分星:1..5(服务端 0–10,写回时 ×2) */
const FB_BOOK_STARS: number[] = [1, 2, 3, 4, 5];
/** 笔记种类(服务端 `Book::NOTE_KINDS`) */
const FB_NOTE_KINDS: string[] = ['note', 'quote'];
const FB_NOTE_KIND_LABEL: string[] = ['想法', '划线'];
@ComponentV2
export struct FbBookSheet {
/** 模块主题色(读书 = 藕紫) */
@Param accent: string = '#6B5B95';
/** 书的 id(0 = 加一本) */
@Param editingId: number = 0;
@Param initTitle: string = '';
@Param initAuthor: string = '';
/** 服务端枚举之一 */
@Param initStatus: string = 'wish';
/** 0–5 星(服务端 0–10 折半后) */
@Param initRate: number = 0;
/** 读到第几章 / 共几章(只读展示) */
@Param curChapter: number = 0;
@Param chapters: number = 0;
@Event onClose: () => void = (): void => {};
@Event onSaved: () => void = (): void => {};
@Event onDeleted: () => void = (): void => {};
@Local fTitle: string = '';
@Local fAuthor: string = '';
/** 当前选中的状态(改动后点保存才提交) */
@Local fStatus: string = 'wish';
/** 当前星级 0–5(0 = 未评) */
@Local fStars: number = 0;
@Local saving: boolean = false;
@Local confirmingDelete: boolean = false;
@Local note: string = '';
// ---- 读书笔记(只有详情态才有) ----
@Local notes: FbBookNoteItem[] = [];
@Local notesLoading: boolean = false;
@Local notesNote: string = '';
/** 新笔记的草稿与种类 */
@Local draft: string = '';
@Local draftKind: string = 'note';
/** 正在提交笔记(与「保存这本书」分开:两个请求互不影响) */
@Local storing: boolean = false;
private isNew(): boolean {
return this.editingId <= 0;
}
aboutToAppear(): void {
this.fTitle = this.initTitle;
this.fAuthor = this.initAuthor;
this.fStatus = this.initStatus !== '' ? this.initStatus : 'wish';
this.fStars = this.initRate;
if (this.editingId > 0) {
this.loadNotes();
}
}
build() {
/* 🔴🔴 根必须 `Stack`(叠),不能 `Column`(排)——
全屏遮罩当 Column 子项会把卡片挤出屏幕,见 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: 10 }) {
// ---- 标题栏 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.isNew() ? '加一本书' : this.fTitle)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.subtitleOf())
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.flexShrink(1)
FbSheetClose({
onClose: (): void => {
this.onClose();
},
color: this.accent
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 12 }) {
// ---- 加一本:书名 / 作者可填 ----
if (this.isNew()) {
/* ⚠️ 不传"当前值"进来:`@Builder` 的按值参数**不参与状态观察**,
输入框的 `text` 一律在 builder 内部直接读 `this.fTitle` / `this.fAuthor`
(否则打字时框里不刷新 —— v66 车辆表单踩过)。 */
this.labeledInput('书名', '如:置身事内', true)
this.labeledInput('作者', '可不填', false)
}
// ---- 状态 ----
Row({ space: 8 }) {
Text('状态')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(34)
Row({ space: 8 }) {
ForEach(FB_BOOK_STATUS, (k: string, idx: number) => {
this.statusChip(k, FB_BOOK_STATUS_LABEL[idx])
}, (k: string): string => 'bs_' + k)
}
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
// ---- 评分(0–5 星,写回时 ×2 成服务端的 0–10)----
Row({ space: 8 }) {
Text('评分')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(34)
Row({ space: 4 }) {
ForEach(FB_BOOK_STARS, (i: number) => {
Text('★')
.fontSize(22)
.fontColor(i <= this.fStars ? '#C08A2E' : '#D9D2C2')
.onClick((): void => {
// 再点同一颗 = 取消这一档(退回上一档),点第 1 颗时归零
this.fStars = this.fStars === i ? i - 1 : i;
this.note = '';
})
}, (i: number): string => 'star_' + i.toString())
Text(this.fStars > 0 ? this.fStars.toString() + ' 星' : '未评')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.margin({ left: 4 })
}
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
// ---- 章节进度(只读:由阅读器翻页写,接口没有回写入口)----
if (!this.isNew()) {
Row({ space: 8 }) {
Text('进度')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(34)
Text(this.progressText())
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.layoutWeight(1)
.maxLines(2)
}
.width('100%')
.alignItems(VerticalAlign.Top)
}
// ---- 读书笔记(只有详情态;这里是"点进去"能看见的正事)----
if (!this.isNew()) {
Divider()
.color(FbColors.line)
.strokeWidth(0.5)
Row({ space: 6 }) {
Text('读书笔记')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.notes.length.toString() + ' 条')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Blank()
}
.width('100%')
.alignItems(VerticalAlign.Center)
if (this.notesLoading && this.notes.length === 0) {
Text('正在取笔记…')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
} else if (this.notesNote !== '') {
Text(this.notesNote)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.width('100%')
.maxLines(3)
} else if (this.notes.length === 0) {
Text('这本书还没记过笔记。下面写一句就能存下来。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.maxLines(3)
} else {
Column({ space: 8 }) {
ForEach(this.notes, (it: FbBookNoteItem) => {
this.noteRow(it)
}, (it: FbBookNoteItem): string => 'bn_' + it.id.toString())
}
.width('100%')
}
// ---- 记一条 ----
Row({ space: 8 }) {
ForEach(FB_NOTE_KINDS, (k: string, idx: number) => {
Text(FB_NOTE_KIND_LABEL[idx])
.fontSize(FbMetric.fontTiny)
.fontColor(this.draftKind === k ? '#FFFFFF' : FbColors.muted)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(FbMetric.radiusS)
.backgroundColor(this.draftKind === k ? fbAlpha(this.accent, 0.95) : FbColors.inputBg)
.onClick((): void => {
this.draftKind = k;
})
}, (k: string): string => 'nk_' + k)
Blank()
}
.width('100%')
.alignItems(VerticalAlign.Center)
TextArea({ text: this.draft, placeholder: '读到哪句、想到什么…' })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(76)
.width('100%')
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.onChange((v: string): void => {
this.draft = v;
this.notesNote = '';
})
Text(this.storing ? '记着呢…' : '记下来')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(this.draft.trim() === '' || this.storing ? FbColors.faint : this.accent)
.textAlign(TextAlign.Center)
.width('100%')
.height(38)
.borderRadius(FbMetric.radiusS)
.backgroundColor(FbColors.inputBg)
.onClick((): void => {
this.storeNote();
})
Text('上传电子书 / 导入笔记 / 微信读书同步在网页端「读书」里做 —— App 这边只记笔记。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.maxLines(3)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.constraintSize({ maxHeight: 400 })
.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.isNew()) {
Text(this.confirmingDelete ? '真的删掉?' : '删除')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.textAlign(TextAlign.Center)
.height(44)
.padding({ left: 16, right: 16 })
.borderRadius(FbMetric.radiusS)
.backgroundColor(this.confirmingDelete ? '#C0533F' : fbAlpha('#C0533F', 0.86))
.onClick((): void => {
if (this.confirmingDelete) {
this.doDelete();
} else {
this.confirmingDelete = true;
}
})
}
Text(this.saving ? '保存中…' : (this.isNew() ? '加进来' : '保存修改'))
.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();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.width('100%')
.padding({ left: 18, right: 18, top: 16, bottom: 16 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
}
/** 副标题:新建说"挂在你自己名下",详情说作者 + 状态 */
private subtitleOf(): string {
if (this.isNew()) {
return '存进书架,之后可以补评分和笔记';
}
const who: string = this.fAuthor !== '' ? this.fAuthor : '作者不详';
return who;
}
/** 章节进度的人话(`chapters = 0` 表示这本书没解析过章节 ⇒ 别编百分比) */
private progressText(): string {
if (this.chapters <= 0) {
return this.fStatus === 'done' ? '读完了' : '这本书没解析过章节,进度无从算起';
}
const pct: number = this.fStatus === 'done' ? 100
: Math.min(100, Math.round(this.curChapter * 100 / this.chapters));
return '读到第 ' + this.curChapter.toString() + ' 章 / 共 '
+ this.chapters.toString() + ' 章(' + pct.toString() + '%)';
}
/** 一行标签 + 输入框(新建时用;`isTitle` 区分书名/作者) */
@Builder
labeledInput(label: string, placeholder: string, isTitle: boolean) {
Row({ space: 8 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(34)
TextInput({ text: isTitle ? this.fTitle : this.fAuthor, placeholder: 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 })
.onChange((v: string): void => {
if (isTitle) {
this.fTitle = v;
} else {
this.fAuthor = v;
}
this.note = '';
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
/**
* 状态胶囊。
*
* ⚠️ 这里**必须**在 builder 内部读 `this.fStatus`(而不是靠参数 `on` 传值):
* `@Builder` 的**按值参数不参与状态观察** —— 传 `on: boolean` 进来,
* 点完状态改了、胶囊配色不会变(v66 车辆表单踩过同一个坑)。
*/
@Builder
statusChip(key: string, label: string) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(this.fStatus === key ? '#FFFFFF' : FbColors.muted)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(FbMetric.radiusS)
.backgroundColor(this.fStatus === key ? fbAlpha(this.accent, 0.95) : FbColors.inputBg)
.onClick((): void => {
this.fStatus = key;
this.note = '';
})
}
/** 一条读书笔记 */
@Builder
noteRow(it: FbBookNoteItem) {
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(it.kind === 'quote' ? '划线' : (it.kind === 'review' ? '书评' : '想法'))
.fontSize(10)
.fontColor(this.accent)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor(fbAlpha(this.accent, 0.12))
if (it.personName !== '') {
Text(it.personName)
.fontSize(10)
.fontColor(FbColors.faint)
}
Blank()
if (it.createdAt !== '') {
Text(it.createdAt)
.fontSize(10)
.fontColor(FbColors.faint)
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
Text(it.content)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.width('100%')
.maxLines(4)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.padding(10)
.borderRadius(FbMetric.radiusS)
.backgroundColor(FbColors.surfaceAlt)
.alignItems(HorizontalAlign.Start)
}
/** 正在提交笔记 */
private async loadNotes(): Promise<void> {
if (this.notesLoading) {
return;
}
this.notesLoading = true;
this.notesNote = '';
const res: FbApiResult = await fetchBookNotes(FB_TOKEN_STORE.get(), this.editingId);
this.notesLoading = false;
if (!res.ok) {
// 笔记取不到不该拦着"改状态/评分",所以只在这里写一行提示,不往外抛
this.notesNote = res.error !== '' ? res.error : '笔记没取到。';
return;
}
this.notes = parseBookNotes(res.data);
}
private async storeNote(): Promise<void> {
if (this.storing) {
return;
}
const txt: string = this.draft.trim();
if (txt === '') {
this.notesNote = '先写一句话再记。';
return;
}
this.storing = true;
this.notesNote = '';
const res: FbApiResult = await addBookNote(FB_TOKEN_STORE.get(), this.editingId, txt, this.draftKind);
this.storing = false;
if (!res.ok) {
this.notesNote = res.error !== '' ? res.error : '没记上,稍后再试。';
return;
}
this.draft = '';
await this.loadNotes();
}
private async save(): Promise<void> {
if (this.saving) {
return;
}
const title: string = this.fTitle.trim();
if (this.isNew()) {
if (title === '') {
this.note = '书总得有个名字。';
return;
}
this.saving = true;
this.note = '';
const res: FbApiResult = await createBook(FB_TOKEN_STORE.get(), title, this.fAuthor.trim(),
this.fStatus);
this.saving = false;
if (!res.ok) {
this.note = res.error !== '' ? res.error : '没加进去,稍后再试。';
return;
}
this.onSaved();
return;
}
this.saving = true;
this.note = '';
/* 服务端 `rating` 是 0–10;端上是 0–5 星 ⇒ 这里 ×2 */
const rating: number = Math.round(this.fStars * 2);
const res2: FbApiResult = await updateBook(FB_TOKEN_STORE.get(), this.editingId, this.fStatus, rating);
this.saving = false;
if (!res2.ok) {
this.note = res2.error !== '' ? res2.error : '没存上,稍后再试。';
return;
}
this.onSaved();
}
private async doDelete(): Promise<void> {
if (this.saving) {
return;
}
this.saving = true;
this.note = '';
const res: FbApiResult = await deleteBook(FB_TOKEN_STORE.get(), this.editingId);
this.saving = false;
this.confirmingDelete = false;
if (!res.ok) {
this.note = res.error !== '' ? res.error : '没删掉,稍后再试。';
return;
}
this.onDeleted();
}
}
/**
* 读书统计(端上直接按书架数据算,不需要服务端新接口)。
*
* 🔴 用户 2026-10-20:「设置也没有任何的东西」—— 这条原来 `action` 是空串。
* ⚠️ 只做**能算准**的几个数:本数、在读、读完、搁置、笔记条数。
* "今年读完几本"要 `finished_at`(接口有返回但 `FbBookItem` 没收),
* "读了多少页"要 `pages` 且有值才准 —— 没做,别拿估算数字糊弄。
*/
@ComponentV2
export struct FbBookStatsSheet {
@Param accent: string = '#6B5B95';
@Param items: FbBookItem[] = [];
@Event onClose: () => void = (): void => {};
build() {
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: 14 }) {
Row({ space: 8 }) {
Text('读书统计')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.layoutWeight(1)
FbSheetClose({
onClose: (): void => {
this.onClose();
},
color: this.accent
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row({ space: 10 }) {
this.statCell('藏书', this.countOf('').toString() + ' 本')
this.statCell('在读', this.countOf('reading').toString() + ' 本')
this.statCell('读完', this.countOf('done').toString() + ' 本')
}
.width('100%')
Row({ space: 10 }) {
this.statCell('想读', this.countOf('wish').toString() + ' 本')
this.statCell('搁置', this.countOf('paused').toString() + ' 本')
this.statCell('笔记', this.noteTotal().toString() + ' 条')
}
.width('100%')
Text('页数 / 字数 / 每年读了几本要去网页端「读书 → 统计」看 —— App 这边按书架现算。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.maxLines(3)
}
.width('100%')
.padding({ left: 18, right: 18, top: 16, bottom: 16 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
}
@Builder
statCell(label: string, value: string) {
Column({ space: 4 }) {
Text(value)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.layoutWeight(1)
.padding({ top: 12, bottom: 12 })
.borderRadius(FbMetric.radiusS)
.backgroundColor(FbColors.surfaceAlt)
.alignItems(HorizontalAlign.Center)
}
/** `status === ''` = 全部 */
private countOf(status: string): number {
if (status === '') {
return this.items.length;
}
return this.items.filter((b: FbBookItem): boolean => b.status === status).length;
}
private noteTotal(): number {
let n: number = 0;
for (const b of this.items) {
n = n + b.noteCount;
}
return n;
}
}
+87
View File
@@ -0,0 +1,87 @@
/**
* 家书 FamilyBook · 读书模块的**写操作与子资源**(鸿蒙端)
*
* 对应服务端 `app/ApiHandler.php` 的 `books()`:
* GET /api/v1/books 书架(支持 status / category / owner_id / keyword)
* POST /api/v1/books 加一本(title / author / category / status)
* GET /api/v1/books/<id> 一本
* PATCH /api/v1/books/<id> 改(**只认 status / rating / current_page 三个键**)
* DELETE /api/v1/books/<id> 删(连带封面与文件)
* GET /api/v1/books/<id>/notes 这本书的读书笔记(划线 / 想法 / 书评)
* POST /api/v1/books/<id>/notes 记一条(content / kind / chapter / loc)
*
* 🔴 用户 2026-10-20:「其他的功能也逐步完成……设置也没有任何的东西。」
* 读书页那时是**纯书架**:书行点不动、没有"加一本"、设置三项全灰。
*
* ⚠️⚠️ 两处**服务端能力边界**(写在这里,免得以后又摆出假入口):
* 1. `PATCH` **不能改书名/作者** —— `books()` 只从请求体取
* `status` / `current_page` / `rating`,其它键根本不读。
* (改书名要等接口扩,别在界面上放个"书名"输入框然后存不进去。)
* 2. `PATCH` 里的 `current_page` **不对应任何列** —— `books` 表只有
* `progress`(读到第几章),而 `Book::update()` 不读 `current_page`
* ⇒ 端上**不要**回写章节进度,只**显示**。
* 3. 上传电子书 / EPUB 解析 / 笔记导入 / 微信读书同步都在网页端,
* 移动端没有文件选择与解析链路(`BookParser`、`BookNoteImport` 只有网页在调)。
*/
import { fbApi, FbApiResult, FbApiOptions } from './FbHttp';
/**
* 加一本书。
*
* @param status 必须用**服务端枚举**:`wish` / `reading` / `done` / `paused`
* (服务端 `Book::STATUS`;传别的会被 `Book::status()` **静默改成 `wish`**)
*/
export async function createBook(token: string, title: string, author: string,
status: string): Promise<FbApiResult> {
/* ⚠️ `arkts-no-untyped-obj-literals`:请求体必须 `Record<string, Object> = {}` 逐键赋值 */
const body: Record<string, Object> = {};
body['title'] = title;
body['author'] = author;
body['status'] = status;
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
return await fbApi('/api/v1/books', opts);
}
/**
* 改一本书的状态 / 评分。
*
* @param status 服务端枚举之一
* @param rating 服务端是 **0–10**(端上 `FbBookItem.rate` 是 0–5 ⇒ 调用方乘 2);
* 传 0 表示"取消评分"(`Book::rating()` 把 0 当合法值,落库为 0)
*/
export async function updateBook(token: string, id: number, status: string,
rating: number): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['status'] = status;
body['rating'] = rating;
const opts: FbApiOptions = { method: 'PATCH', token: token, body: body };
return await fbApi('/api/v1/books/' + id.toString(), opts);
}
/** 删一本书(服务端会连带删封面与原书文件) */
export async function deleteBook(token: string, id: number): Promise<FbApiResult> {
const opts: FbApiOptions = { method: 'DELETE', token: token };
return await fbApi('/api/v1/books/' + id.toString(), opts);
}
/** 这本书的读书笔记(服务端直接返回数组,不是 `{items:…}` 信封) */
export async function fetchBookNotes(token: string, id: number): Promise<FbApiResult> {
const opts: FbApiOptions = { token: token };
return await fbApi('/api/v1/books/' + id.toString() + '/notes', opts);
}
/**
* 记一条读书笔记。
*
* @param kind `quote` 划线 / `note` 想法 / `review` 书评(服务端 `Book::NOTE_KINDS`,
* 传别的会被改成 `note`)
*/
export async function addBookNote(token: string, id: number, content: string,
kind: string): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['content'] = content;
body['kind'] = kind;
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
return await fbApi('/api/v1/books/' + id.toString() + '/notes', opts);
}
+46 -6
View File
@@ -15,7 +15,7 @@
*/
import {
FbAccount, FbAnniversaryItem, FbBookItem, FbCalEvent, FbHomeData, FbKinChip,
FbAccount, FbAnniversaryItem, FbBookItem, FbBookNoteItem, FbCalEvent, FbHomeData, FbKinChip,
FbKinGraph, FbKinPerson, FbMark,
FbLedgerBook, FbLedgerDetail, FbLedgerEntry, FbNoteItem, FbNotificationItem, FbResourceItem,
FbTaskItem, FbTripItem, FbWeather, FbStat, FbMemoryItem, FbRecentDay,
@@ -199,6 +199,15 @@ interface RawBook {
id?: number; title?: string; author?: string | null; status?: string | null;
rating?: number | null; progress?: number | null; note_count?: number | null;
cover_key?: string | null; category?: string | null; publisher?: string | null;
/** 共几章(0 = 没解析过章节)。`progress` 是"读到第几章",没有它就没法算百分比 */
chapter_count?: number | null;
}
interface RawBookNote {
id?: number; kind?: string | null; content?: string | null;
chapter?: string | null; loc?: string | null;
person_name?: string | null; my_nickname?: string | null;
created_at?: string | null;
}
interface RawNote {
@@ -507,23 +516,54 @@ export function parseBooks(json: string): FbBookItem[] {
const rows: RawBook[] = asArray<RawBook>(json);
const out: FbBookItem[] = [];
for (const r of rows) {
const st: string = s(r.status) !== '' ? s(r.status) : 'want';
/* 🔴🔴 v67:回落值必须是 **`wish`**(服务端枚举),原来写的 `want` 服务端不认
⇒ `Book::status()` 把不认识的静默改成 `wish`,而端上分组只认 `want`
⇒ "想读"的书在书架里一本都看不见。 */
const st: string = s(r.status) !== '' ? s(r.status) : 'wish';
const chapters: number = n(r.chapter_count);
const cur: number = n(r.progress);
const it: FbBookItem = {
id: n(r.id),
title: s(r.title),
author: s(r.author),
status: st,
rate: n(r.rating),
/* 🔴 服务端 `rating` 是 0–10(`Book::rating()` 校验 `0 <= n <= 10`),
端上 `FbRating` 的 max 是 5 ⇒ 必须折半,否则 8 分显示成 `8.0/5`。 */
rate: clamp(n(r.rating) / 2, 0, 5),
noteCount: n(r.note_count),
// 服务端 progress 记的是"读到第几页"还是"百分比"不确定
// ⇒ 只信 status:读完就是 100,否则按 progress 原样(0~100 之外夹紧)
progress: st === 'done' ? 100 : clamp(n(r.progress), 0, 100),
/* 🔴 `progress` 是**章序号**不是百分比(`Book::setProgress` 注释:读到第几章)
⇒ 有 `chapter_count` 才能换算;没解析过章节(=0)就只能是 0,
不要拿章序号冒充百分比(读到第 30 章 ≠ 30%)。 */
progress: st === 'done' ? 100
: (chapters > 0 ? clamp(cur * 100 / chapters, 0, 100) : 0),
curChapter: cur,
chapters: chapters,
};
out.push(it);
}
return out;
}
/** 读书笔记 → FbBookNoteItem[] */
export function parseBookNotes(json: string): FbBookNoteItem[] {
const rows: RawBookNote[] = asArray<RawBookNote>(json);
const out: FbBookNoteItem[] = [];
for (const r of rows) {
const nick: string = s(r.my_nickname);
out.push({
id: n(r.id),
kind: s(r.kind) !== '' ? s(r.kind) : 'note',
content: s(r.content),
chapter: s(r.chapter),
loc: s(r.loc),
/* 我称呼这个人的名字优先(`nicknames` 二元组),没有再退回他的本名 */
personName: nick !== '' ? nick : s(r.person_name),
createdAt: s(r.created_at).substring(0, 10),
});
}
return out;
}
/** 笔记 → FbNoteItem[] */
export function parseNotes(json: string): FbNoteItem[] {
const root: Record<string, Object> | null = asObject(json);
+21 -9
View File
@@ -298,13 +298,16 @@ export const FB_DEMO_RESOURCES: FbResourceItem[] = [
// 读书
// ============================================================
/* ⚠️ 状态用**服务端枚举**(`wish`),别写 `want` —— 见 `fbGroupBook` 上方的说明。
示例数据目前没有引用处(书架只吃接口/缓存),但它必须能编译、且不能把错的
取值示范给后来的人看。 */
export const FB_DEMO_BOOKS: FbBookItem[] = [
{ id: 1, title: '置身事内', author: '兰小欢', status: 'reading', rate: 5, noteCount: 12, progress: 62 },
{ id: 2, title: '地球的红飘带', author: '魏巍', status: 'reading', rate: 4, noteCount: 5, progress: 28 },
{ id: 3, title: '蛤蟆先生去看心理医生', author: '罗伯特·戴博德', status: 'done', rate: 5, noteCount: 9, progress: 100 },
{ id: 4, title: '一日三秋', author: '刘震云', status: 'done', rate: 4, noteCount: 7, progress: 100 },
{ id: 5, title: '显微镜下的大明', author: '马伯庸', status: 'done', rate: 5, noteCount: 15, progress: 100 },
{ id: 6, title: '也许你该找个人聊聊', author: '洛莉·戈特利布', status: 'want', rate: 0, noteCount: 0, progress: 0 }
{ id: 1, title: '置身事内', author: '兰小欢', status: 'reading', rate: 5, noteCount: 12, progress: 62, curChapter: 31, chapters: 50 },
{ id: 2, title: '地球的红飘带', author: '魏巍', status: 'reading', rate: 4, noteCount: 5, progress: 28, curChapter: 7, chapters: 25 },
{ id: 3, title: '蛤蟆先生去看心理医生', author: '罗伯特·戴博德', status: 'done', rate: 5, noteCount: 9, progress: 100, curChapter: 16, chapters: 16 },
{ id: 4, title: '一日三秋', author: '刘震云', status: 'done', rate: 4, noteCount: 7, progress: 100, curChapter: 24, chapters: 24 },
{ id: 5, title: '显微镜下的大明', author: '马伯庸', status: 'done', rate: 5, noteCount: 15, progress: 100, curChapter: 12, chapters: 12 },
{ id: 6, title: '也许你该找个人聊聊', author: '洛莉·戈特利布', status: 'wish', rate: 0, noteCount: 0, progress: 0, curChapter: 0, chapters: 0 }
];
// ============================================================
@@ -392,10 +395,19 @@ export function fbGroupResource(list: FbResourceItem[]): FbGroup<FbResourceItem>
return fbGroupBy(list, (it: FbResourceItem): string => it.ownerName);
}
/** 按 status 分组建书架(在读 / 想读 / 读完) */
/**
* 按 status 分组建书架(在读 / 想读 / 搁置 / 读完)
*
* 🔴🔴 v67:这四个键**必须**和服务端 `Book::STATUS` 一模一样
* (`wish / reading / done / paused`)。原来写的是 `want`:
* · 服务端不认 `want`,落库时被 `Book::status()` 静默改成 `wish`;
* · 这里又没列 `wish` ⇒ **"想读"的书在书架里整组消失**(页头还数着它)。
* `paused`(搁置)同理,原来也没列 ⇒ 搁置的书同样人间蒸发。
* ⚠️ 顺序按"手头正在看的排最前",与服务端 `ORDER BY FIELD(status, …)` 一致。
*/
export function fbGroupBook(list: FbBookItem[]): FbGroup<FbBookItem>[] {
const order: string[] = ['reading', 'want', 'done'];
const titles: string[] = ['在读', '想读', '读完'];
const order: string[] = ['reading', 'wish', 'paused', 'done'];
const titles: string[] = ['在读', '想读', '搁置', '读完'];
const groups: FbGroup<FbBookItem>[] = [];
for (let i = 0; i < order.length; i++) {
const key: string = order[i];
+13 -4
View File
@@ -34,11 +34,20 @@ export const FB_SET_MOVIES: FbSettingItem[] = [
{ icon: 'notes', label: '年度报告', hint: '这一年你看了些什么', action: 'moviereport' }
];
/** 读书(templates/books/_settings_menu.php) */
/**
* 读书(templates/books/_settings_menu.php)
*
* 🔴 v67(2026-10-20 用户「设置也没有任何的东西」):原来三条**全是空 `action`**
* ⇒ 整张菜单画成灰色、点哪条都没反应。
* · `bookstats` → 端上按书架现算(藏书/在读/读完/想读/搁置/笔记条数),**能进**;
* · 另两条要**文件选择**(导入读书笔记要读 .md/.txt)与**微信读书授权**,
* 移动端没有这条链路(`BookNoteImport` / `BookParser` 只被网页调用)
* ⇒ 仍然是「暂未开放」,但 hint 写明白"去网页端",别让人以为按钮坏了。
*/
export const FB_SET_BOOKS: FbSettingItem[] = [
{ icon: 'chart', label: '读书统计', hint: '每年读了多少、多少页', action: '' },
{ icon: 'plus', label: '导入读书笔记', hint: '从笔记文件批量导入', action: '' },
{ icon: 'share', label: '微信读书导入', hint: '同步微信读书的书单', action: '' }
{ icon: 'chart', label: '读书统计', hint: '藏书 / 在读 / 读完 / 笔记条数', action: 'bookstats' },
{ icon: 'plus', label: '导入读书笔记', hint: '要在网页端选文件', action: '' },
{ icon: 'share', label: '微信读书导入', hint: '要在网页端授权同步', action: '' }
];
/** 纪念日(templates/days/_settings_menu.php)
+37 -4
View File
@@ -756,19 +756,52 @@ export interface FbResourceItem {
// 读书(books)
// ============================================================
/** 一本书(books/index.php 书架) */
/**
* 一本书(books/index.php 书架)
*
* 🔴🔴 v67(2026-10-20)——把**三个和服务端口径不一致**的字段一次捋对,
* 这三个都是「不报错、不崩溃,但显示的东西是错的」:
*
* ① `status`:服务端 `Book::STATUS` 只有 `wish / reading / done / paused`,
* **没有 `want`**。而 `Book::status()` 遇到不认识的值会**静默改成 `wish`**,
* 端上分组又只认 `want` ⇒ 线上的"想读"的书在书架里**一本都不显示**
* (页头数字还数着它)—— 这正是"读书列表不对"的来源。
* ② `rate`:服务端 `rating` 是 **0–10**,端上一直当 0–5 用 ⇒ 8 分的书显示成 `8.0/5`。
* ③ `progress`:服务端 `progress` 是**读到第几章**(`Book::setProgress` 的注释写着
* 「读到第几章」),不是百分比 ⇒ 读到第 30 章的书,进度条永远停在 30%。
*/
export interface FbBookItem {
id: number;
title: string;
author: string;
/** 状态:want(想读)/ reading(在读)/ done(读完) */
/** 状态:`wish` 想读 / `reading` 在读 / `done` 读完 / `paused` 搁置(**必须以服务端枚举为准**) */
status: string;
/** 评分 0–5(0 表示未评) */
/** 评分 0–5(0 表示未评)。服务端存 0–10,解析时折半、写回时乘 2 */
rate: number;
/** 笔记条数 */
noteCount: number;
/** 阅读进度(0–100,done 时为 100) */
/** 阅读进度(0–100;读完恒 100,没解析过章节的书为 0) */
progress: number;
/** 读到第几章(服务端 `progress` 原值,0 = 还没开始) */
curChapter: number;
/** 共几章(服务端 `chapter_count`,0 = 这本书没解析过章节 ⇒ 进度无从谈起) */
chapters: number;
}
/** 一条读书笔记(`book_notes` 表:划线 / 想法 / 书评) */
export interface FbBookNoteItem {
id: number;
/** quote 划线 / note 想法 / review 书评 */
kind: string;
content: string;
/** 章节(导入时带得上的话) */
chapter: string;
/** 位置(页数 / 百分比 / 微信读书的定位串) */
loc: string;
/** 谁写的(显示名,可能为空 = 导入时不知道是谁) */
personName: string;
/** 记下的时间(只取日期) */
createdAt: string;
}
// ============================================================
+129 -8
View File
@@ -2,10 +2,17 @@
* 家书 FamilyBook · 读书模块(鸿蒙端)
*
* 对应 Web 端 `templates/books/index.php`(书架 + 上传解析 + 在线读 + 笔记 + 统计)。
* 端上先做**书架**这一层:按状态分组(在读 / 想读 / 读完),
* 每本书显示作者、评分、笔记数、阅读进度。
* 端上做**书架 + 一本书的详情**这两层:
* · 书架按状态分组(在读 / 想读 / 搁置 / 读完),显示作者、评分、笔记数、进度;
* · 点一本书 → `FbBookSheet`(状态 / 评分 / 章节进度 / 读书笔记 / 删除);
* · 页头 + → 同一浮层的新建态(书名 / 作者 / 状态);
* · 设置菜单 → 读书统计(端上现算)+ 两条网页端能力的入口说明。
*
* 「在线读」「上传 EPUB 解析」「笔记导入」这些属于交互+文件能力,留给第二轮。
* 🔴 v67(用户 2026-10-20「其他的功能也逐步完成……读书点不进去」):
* 原来这里**只有书架**:书行没有 `onClick`、页头"上传/统计"按钮没有 `onTap`、
* 设置三项全是空 `action`。三处现在都接上了。
*
* 「在线读」「上传 EPUB 解析」「笔记导入」属于交互+文件能力,仍在网页端。
*/
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK } from '../../common/Theme';
@@ -19,6 +26,7 @@ import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FbBookSheet, FbBookStatsSheet } from '../../components/FbBookSheet';
import { FB_BACK } from '../../common/FbBack';
import { FB_SET_BOOKS } from '../../model/FbSettingsMenus';
@@ -45,6 +53,20 @@ export struct FbBooksPage {
@Local all: FbBookItem[] = [];
// ---- 一本书的浮层(v67:原来点了没反应)----
@Local bookOpen: boolean = false;
/** 0 = 加一本;>0 = 这本书的详情 */
@Local bookId: number = 0;
@Local bookTitle: string = '';
@Local bookAuthor: string = '';
@Local bookStatus: string = 'wish';
@Local bookRate: number = 0;
@Local bookCur: number = 0;
@Local bookChapters: number = 0;
/** 读书统计浮层 */
@Local statsOpen: boolean = false;
/**
* 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。
*
@@ -61,6 +83,14 @@ export struct FbBooksPage {
}
private backOnce(): boolean {
if (this.bookOpen) {
this.bookOpen = false;
return true;
}
if (this.statsOpen) {
this.statsOpen = false;
return true;
}
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
@@ -95,7 +125,13 @@ export struct FbBooksPage {
*/
@Monitor('settingsOpen')
onSettingsChanged(): void {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
fbAppState().sheetCount = (this.bookOpen || this.statsOpen || this.settingsOpen) ? 1 : 0;
}
/** 三个浮层:任一个开着都要让底部悬浮栏让位(它们各自带全屏遮罩,会盖住悬浮栏) */
@Monitor('bookOpen', 'statsOpen')
onSheetsChanged(): void {
fbAppState().sheetCount = (this.bookOpen || this.statsOpen || this.settingsOpen) ? 1 : 0;
}
build() {
@@ -120,8 +156,22 @@ export struct FbBooksPage {
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 上传 / 统计(Web 端在设置菜单里)
FbIconButton({ icon: $r('app.media.fb_ic_chart'), tint: FbTint.muted })
/* 🔴 v67:原来这颗是「统计」图标且**没有 `onTap`**(纯摆设,点了没反应)。
现在拆成两颗:+ 加一本(主操作)、统计(设置里也有一条,这里给个快捷)。 */
FbIconButton({
icon: $r('app.media.fb_ic_plus'),
tint: this.accent,
onTap: (): void => {
this.openNewBook();
}
})
FbIconButton({
icon: $r('app.media.fb_ic_chart'),
tint: FbTint.muted,
onTap: (): void => {
this.statsOpen = true;
}
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
FbIconButton({
@@ -196,8 +246,51 @@ export struct FbBooksPage {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
/* 🔴 v67:三条原来是空 `action`(画灰、点了没反应 = 用户说的"设置没有任何东西")。
现在 `bookstats` 真的能进去;另两条要文件选择/微信授权,
网页端才有 ⇒ 在组件里仍是「暂未开放」,这里的 hint 也写清楚了。 */
if (action === 'bookstats') {
this.settingsOpen = false;
this.statsOpen = true;
}
}
})
.transition(fbSheetTransition())
}
// ---- 一本书(新建 / 详情)----
if (this.bookOpen) {
FbBookSheet({
accent: this.accent,
editingId: this.bookId,
initTitle: this.bookTitle,
initAuthor: this.bookAuthor,
initStatus: this.bookStatus,
initRate: this.bookRate,
curChapter: this.bookCur,
chapters: this.bookChapters,
onClose: (): void => {
this.bookOpen = false;
},
onSaved: (): void => {
this.bookOpen = false;
this.load();
},
onDeleted: (): void => {
this.bookOpen = false;
this.load();
}
})
.transition(fbSheetTransition())
}
// ---- 读书统计 ----
if (this.statsOpen) {
FbBookStatsSheet({
accent: this.accent,
items: this.all,
onClose: (): void => {
this.statsOpen = false;
}
})
.transition(fbSheetTransition())
@@ -207,6 +300,30 @@ export struct FbBooksPage {
.height('100%')
}
/** 点一本书 → 详情 */
private openBook(b: FbBookItem): void {
this.bookId = b.id;
this.bookTitle = b.title;
this.bookAuthor = b.author;
this.bookStatus = b.status;
this.bookRate = b.rate;
this.bookCur = b.curChapter;
this.bookChapters = b.chapters;
this.bookOpen = true;
}
/** 页头 + → 走同一个浮层的新建态 */
private openNewBook(): void {
this.bookId = 0;
this.bookTitle = '';
this.bookAuthor = '';
this.bookStatus = 'wish';
this.bookRate = 0;
this.bookCur = 0;
this.bookChapters = 0;
this.bookOpen = true;
}
/** 一本书:书脊色块(代替封面)+ 书名/作者 + 评分/笔记/进度 */
@Builder
bookRow(b: FbBookItem) {
@@ -285,6 +402,10 @@ export struct FbBooksPage {
.borderRadius(FbMetric.radius)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
/* 🔴 v67:整行可点 → 打开这本书(原来**没有 `onClick`** ⇒ "点不进去") */
.onClick((): void => {
this.openBook(b);
})
}
@Builder