/** * 家书 FamilyBook · 在线阅读浮层(鸿蒙端,工单 #28) * * 对应网页版 `templates/books/read.php`:章节列表 + 单章正文。 * * 用户 2026-10-28 原话「鸿蒙端对齐网页端」。原来端上只能看进度数字, * 在线读、上传解析、笔记导入都在网页端。这个浮层把「在线读」补上。 * * ⚠️ 章节正文走 `GET /books//chapter?seq=N`(服务端 `Book::chapter`), * 一次只读一章(正文很长,全量拉会爆内存)。 */ import { FbColors, FbMetric, FB_MASK } from '../common/Theme'; import { FbSheetRing } from './FbSheetRing'; import { FbSheetClose } from './FbSheetClose'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { FbApiResult } from '../data/FbHttp'; import { fetchBookChapters, fetchBookChapter } from '../data/FbBookRepo'; /** 服务端 `Book::chapters()` 的一行(id/seq/title/words) */ interface FbChapterRow { id: number; seq: number; title: string; words: number; } interface RawChapter { id?: number; seq?: number; title?: string | null; words?: number; } @ComponentV2 export struct FbBookReadSheet { @Param bookId: number = 0; @Param bookTitle: string = ''; @Param accent: string = '#6B5B95'; @Event onClose: () => void = (): void => {}; @Local chapters: FbChapterRow[] = []; @Local loadingList: boolean = true; @Local listNote: string = ''; /** 正在读那一章 */ @Local curSeq: number = 0; @Local curTitle: string = ''; @Local curBody: string = ''; @Local loadingChapter: boolean = false; @Local chapterNote: string = ''; aboutToAppear(): void { this.loadChapters(); } private async loadChapters(): Promise { this.loadingList = true; this.listNote = ''; const res: FbApiResult = await fetchBookChapters(FB_TOKEN_STORE.get(), this.bookId); this.loadingList = false; if (!res.ok) { this.listNote = res.error !== '' ? res.error : '章节列表没拉到。'; return; } const out: FbChapterRow[] = []; try { const o: Record = JSON.parse(res.data) as Record; const items: Object | undefined = o['items']; const rows: RawChapter[] = (items !== undefined && items !== null) ? (items as RawChapter[]) : []; for (const c of rows) { out.push({ id: c.id !== undefined ? c.id : 0, seq: c.seq !== undefined ? c.seq : 0, title: c.title !== undefined && c.title !== null ? c.title : '', words: c.words !== undefined ? c.words : 0, }); } } catch (e) { // 解析失败当空列表 } this.chapters = out; } private async openChapter(seq: number, title: string): Promise { this.curSeq = seq; this.curTitle = title; this.loadingChapter = true; this.chapterNote = ''; const res: FbApiResult = await fetchBookChapter(FB_TOKEN_STORE.get(), this.bookId, seq); this.loadingChapter = false; if (!res.ok) { this.chapterNote = res.error !== '' ? res.error : '这一章没读到。'; return; } try { const o: Record = JSON.parse(res.data) as Record; const c: Object | undefined = o['content']; this.curBody = typeof c === 'string' ? c : ''; } catch (e) { this.curBody = ''; this.chapterNote = '正文解析失败。'; } } 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: 0 }) { // 标题栏 Row({ space: 8 }) { Column({ space: 2 }) { Text(this.bookTitle !== '' ? this.bookTitle : '在线阅读') .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) .maxLines(1) Text(this.curSeq > 0 ? this.curTitle : (this.chapters.length.toString() + ' 章')) .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%') .padding({ bottom: 8 }) .alignItems(VerticalAlign.Center) if (this.listNote !== '') { Text(this.listNote) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.red) .width('100%') .padding({ top: 6, bottom: 6 }) } if (this.curSeq > 0) { // 正文态 Column({ space: 8 }) { Row() { Text('← 目录') .fontSize(FbMetric.fontTiny) .fontColor(this.accent) .padding({ left: 8, right: 8, top: 5, bottom: 5 }) .onClick((): void => { this.curSeq = 0; }) } .width('100%') if (this.loadingChapter) { Text('正在取这一章…') .fontSize(FbMetric.fontSmall) .fontColor(FbColors.faint) .width('100%') } else if (this.chapterNote !== '') { Text(this.chapterNote) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.red) .width('100%') } else { Scroll() { Text(this.curBody) .fontSize(16) .lineHeight(26) .fontColor(FbColors.ink) .width('100%') } .constraintSize({ maxHeight: 520 }) .width('100%') .scrollBar(BarState.Off) } } .width('100%') .alignItems(HorizontalAlign.Start) } else { // 目录态 if (this.loadingList) { Text('正在取目录…') .fontSize(FbMetric.fontSmall) .fontColor(FbColors.faint) .width('100%') } else if (this.chapters.length === 0) { Text(this.listNote !== '' ? this.listNote : '这本书还没有解析出章节(可能是 PDF,浏览器里看,或没上传文件)。') .fontSize(FbMetric.fontSmall) .fontColor(FbColors.faint) .width('100%') } else { Scroll() { Column({ space: 0 }) { ForEach(this.chapters, (c: FbChapterRow) => { Row({ space: 8 }) { Column({ space: 2 }) { Text(c.title !== '' ? c.title : ('第 ' + c.seq.toString() + ' 章')) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(c.words.toString() + ' 字') .fontSize(10) .fontColor(FbColors.faint) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) Text('›') .fontSize(FbMetric.fontBody) .fontColor(FbColors.faint) } .width('100%') .padding({ top: 10, bottom: 10 }) .onClick((): void => { this.openChapter(c.seq, c.title); }) }, (c: FbChapterRow): string => 'ch_' + c.seq.toString()) } .width('100%') .alignItems(HorizontalAlign.Start) } .constraintSize({ maxHeight: 520 }) .width('100%') .scrollBar(BarState.Off) } } } .width('100%') .padding({ left: 18, right: 18, top: 18, bottom: 18 }) .alignItems(HorizontalAlign.Start) } } .width('92%') } .width('100%') .height('100%') .hitTestBehavior(HitTestMode.Transparent) } }