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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user