Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions docs/superpowers/specs/2026-06-08-local-txt-books-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# 本地 TXT 小说服务端书架导入设计

## 目标

允许用户上传本地 `.txt` 小说到服务端书架。上传后小说像普通书籍一样出现在书架里,可打开阅读、查看目录、保存阅读进度,并能在同一服务端账号下跨设备读取。

## 推荐方案

采用“服务端本地书籍”方案:

1. 前端提供上传 `.txt` 文件入口。
2. 后端接收文件,校验扩展名和内容大小。
3. 后端把原始文本保存到 `storage/local_books/`。
4. 后端解析章节并保存章节索引。
5. 后端创建或更新一个书架 `Book`,使用本地协议标识:`local-txt:<hash>`。
6. 现有阅读器通过新增本地 TXT 分支读取目录和章节内容。

## 数据模型

本地 TXT 书籍复用现有 `Book` 和 `BookChapter`:

- `Book.bookUrl`: `local-txt:<hash>`
- `Book.origin`: `local-txt`
- `Book.originName`: `本地 TXT`
- `Book.canUpdate`: `false`
- `Book.totalChapterNum`: 解析出的章节数量
- `Book.latestChapterTitle`: 最后一章标题
- `Book.name`: 文件名去扩展名,后续可支持用户编辑
- `Book.author`: 默认 `本地导入`

本地文件存储建议:

```text
storage/local_books/<hash>/book.txt
storage/local_books/<hash>/chapters.json
```

`chapters.json` 记录每章标题、章节序号、正文起止字节或字符区间。实现优先选择字符区间,简单稳定。

## 章节解析规则

章节标题按常见中文小说格式识别:

- `第十二章 标题`
- `第12章 标题`
- `第十二回 标题`
- `第12节 标题`
- `卷一 标题`

规则要求:

- 标题必须出现在单独一行。
- 标题行长度限制在 80 个字符以内,避免误切正文。
- 识别不到章节时,把整本书作为 `正文` 一章。
- 每章内容保留原文本换行。

## 后端接口

新增接口:

```http
POST /reader3/uploadTxtBook
Content-Type: multipart/form-data
field: file
```

返回上传后创建的 `Book`。

后端阅读分支:

- 获取目录时,如果 `bookSourceUrl/origin` 是 `local-txt`,读取 `chapters.json`。
- 获取正文时,如果章节 URL 是 `local-txt:<hash>#<index>`,读取 `book.txt` 对应区间。

错误处理:

- 非 `.txt`:返回 400。
- 空文件:返回 400。
- 文件过大:返回 400,限制由实现中的常量控制,默认 50MB。
- 解析或保存失败:返回现有统一错误格式。

## 前端交互

在书架/阅读器入口提供“上传 TXT”按钮:

1. 选择 `.txt` 文件。
2. 调用 `/reader3/uploadTxtBook`。
3. 上传成功后刷新书架。
4. 默认打开新导入书籍的第一章。

显示约束:

- 上传中按钮禁用并显示“上传中”。
- 上传失败显示现有错误提示风格。
- 本地 TXT 书籍没有封面时继续使用现有占位封面。

## 测试策略

后端优先测试纯解析逻辑:

- 能切分 `第1章` / `第二章`。
- 识别不到章节时生成单章。
- 章节 URL 使用 `local-txt:<hash>#<index>`。

接口测试聚焦:

- 上传 TXT 返回 Book。
- 非 TXT 被拒绝。

前端测试或构建聚焦:

- API 封装存在。
- 类型通过。
- `npm run build` 通过。

## 范围外

本次不做:

- EPUB/PDF/Word 导入。
- 封面提取。
- TXT 编码手动选择 UI。
- 在线更新本地 TXT。
- 删除书籍时自动物理删除本地文件。
8 changes: 8 additions & 0 deletions frontend/src/api/bookshelf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ export function saveBooks(books: Partial<Book>[]) {
return http.post<Book[]>('/saveBooks', books).then((r) => r.data)
}

export function uploadTxtBook(file: File) {
const formData = new FormData()
formData.append('file', file)
return http.post<Book>('/uploadTxtBook', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}).then((r) => r.data)
}

export function deleteBook(book: Partial<Book>) {
return http.post<string>('/deleteBook', book).then((r) => r.data)
}
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/BookCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { getCoverUrl } from '../api/bookshelf'
import { isLocalTxtBook } from '../utils/localBook'
import type { Book, SearchBook } from '../types'

const props = defineProps<{
Expand Down Expand Up @@ -156,8 +157,8 @@ const unreadCount = computed(() => {
return Math.max(0, b.totalChapterNum - 1 - b.durChapterIndex)
})

const browserCachedCount = computed(() => Math.max(0, asBook.value.browserCachedChapterCount || 0))
const serverCachedCount = computed(() => Math.max(0, asBook.value.cachedChapterCount || 0))
const browserCachedCount = computed(() => isLocalTxtBook(asBook.value) ? 0 : Math.max(0, asBook.value.browserCachedChapterCount || 0))
const serverCachedCount = computed(() => isLocalTxtBook(asBook.value) ? 0 : Math.max(0, asBook.value.cachedChapterCount || 0))
const latestChapterText = computed(() => {
if (props.isSearch) {
return asSearchBook.value.lastChapter || asBook.value.latestChapterTitle || ''
Expand Down
19 changes: 11 additions & 8 deletions frontend/src/components/CacheLibraryModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import type { Book } from '../types'
import { deleteBrowserBookCache, listBrowserCacheSummary } from '../utils/browserCache'
import { cacheBookToBrowser } from '../utils/bookCache'
import { cacheBookSSE } from '../api/cache'
import { isLocalTxtBook } from '../utils/localBook'

const props = defineProps<{
modelValue: boolean
Expand All @@ -94,14 +95,16 @@ const mergedBooks = computed(() => {
const serverMap = new Map(serverBooks.value.map((book) => [book.bookUrl, book.cachedChapterCount || 0]))
const browserMap = new Map(browserSummaries.value.map((item) => [item.bookUrl, item.cachedChapterCount]))

return shelfStore.books.map((book) => ({
book,
bookUrl: book.bookUrl,
name: book.name,
author: book.author,
serverCachedCount: serverMap.get(book.bookUrl) || 0,
browserCachedCount: browserMap.get(book.bookUrl) || 0,
}))
return shelfStore.books
.filter((book) => !isLocalTxtBook(book))
.map((book) => ({
book,
bookUrl: book.bookUrl,
name: book.name,
author: book.author,
serverCachedCount: serverMap.get(book.bookUrl) || 0,
browserCachedCount: browserMap.get(book.bookUrl) || 0,
}))
})

const offlineReadyCount = computed(() => mergedBooks.value.filter((item) => item.browserCachedCount > 0).length)
Expand Down
121 changes: 67 additions & 54 deletions frontend/src/components/reader/CacheManager.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
</div>

<div class="cache-body">
<div class="summary-grid">
<div v-if="!isLocalTxt" class="summary-grid">
<div class="summary-card">
<span class="summary-label">服务端缓存</span>
<strong>{{ serverCachedCount }}</strong>
Expand Down Expand Up @@ -42,59 +42,65 @@
</div>

<div v-else class="cache-sections">
<div class="info-card">
<p>服务端缓存保存在后端存储目录;浏览器缓存保存在当前设备的 IndexedDB。断网时阅读页会优先读取浏览器已缓存章节。</p>
<div v-if="isLocalTxt" class="info-card">
<p>本地 TXT 已存放在服务端书架文件中,不需要额外缓存;阅读时会直接读取上传后的本地文件。</p>
</div>

<section class="cache-section">
<div class="section-head">
<h4>缓存到服务端</h4>
<button class="link-btn" @click="refreshStats">刷新</button>
<template v-else>
<div class="info-card">
<p>服务端缓存保存在后端存储目录;浏览器缓存保存在当前设备的 IndexedDB。断网时阅读页会优先读取浏览器已缓存章节。</p>
</div>
<div class="option-list">
<button class="cache-opt" @click="startServerCaching(50)">
<span class="label">缓存后续 50 章</span>
<span class="sub">适合当前追更</span>
</button>
<button class="cache-opt" @click="startServerCaching(100)">
<span class="label">缓存后续 100 章</span>
<span class="sub">中度离线阅读</span>
</button>
<button class="cache-opt primary" @click="startServerCaching(0)">
<span class="label">全本缓存到服务端</span>
<span class="sub">保存到服务器磁盘</span>
</button>
<button class="cache-opt danger" @click="clearServerCache">
<span class="label">清除服务端缓存</span>
<span class="sub">删除当前书所有服务端缓存</span>
</button>
</div>
</section>

<section class="cache-section">
<div class="section-head">
<h4>缓存到浏览器</h4>
<button class="link-btn" @click="refreshStats">刷新</button>
</div>
<div class="option-list">
<button class="cache-opt" @click="startBrowserCaching(50)">
<span class="label">缓存后续 50 章</span>
<span class="sub">只保留在当前浏览器</span>
</button>
<button class="cache-opt" @click="startBrowserCaching(100)">
<span class="label">缓存后续 100 章</span>
<span class="sub">适合本地离线使用</span>
</button>
<button class="cache-opt primary" @click="startBrowserCaching(0)">
<span class="label">全本缓存到浏览器</span>
<span class="sub">持久化到 IndexedDB</span>
</button>
<button class="cache-opt danger" @click="clearBrowserCache">
<span class="label">清除浏览器缓存</span>
<span class="sub">删除当前设备离线缓存</span>
</button>
</div>
</section>
<section class="cache-section">
<div class="section-head">
<h4>缓存到服务端</h4>
<button class="link-btn" @click="refreshStats">刷新</button>
</div>
<div class="option-list">
<button class="cache-opt" @click="startServerCaching(50)">
<span class="label">缓存后续 50 章</span>
<span class="sub">适合当前追更</span>
</button>
<button class="cache-opt" @click="startServerCaching(100)">
<span class="label">缓存后续 100 章</span>
<span class="sub">中度离线阅读</span>
</button>
<button class="cache-opt primary" @click="startServerCaching(0)">
<span class="label">全本缓存到服务端</span>
<span class="sub">保存到服务器磁盘</span>
</button>
<button class="cache-opt danger" @click="clearServerCache">
<span class="label">清除服务端缓存</span>
<span class="sub">删除当前书所有服务端缓存</span>
</button>
</div>
</section>

<section class="cache-section">
<div class="section-head">
<h4>缓存到浏览器</h4>
<button class="link-btn" @click="refreshStats">刷新</button>
</div>
<div class="option-list">
<button class="cache-opt" @click="startBrowserCaching(50)">
<span class="label">缓存后续 50 章</span>
<span class="sub">只保留在当前浏览器</span>
</button>
<button class="cache-opt" @click="startBrowserCaching(100)">
<span class="label">缓存后续 100 章</span>
<span class="sub">适合本地离线使用</span>
</button>
<button class="cache-opt primary" @click="startBrowserCaching(0)">
<span class="label">全本缓存到浏览器</span>
<span class="sub">持久化到 IndexedDB</span>
</button>
<button class="cache-opt danger" @click="clearBrowserCache">
<span class="label">清除浏览器缓存</span>
<span class="sub">删除当前设备离线缓存</span>
</button>
</div>
</section>
</template>
</div>
</div>
</div>
Expand All @@ -108,6 +114,7 @@ import { cacheBookSSE } from '../../api/cache'
import { getBookshelfWithCacheInfo, deleteBookCache } from '../../api/bookshelf'
import { countBrowserBookCache, deleteBrowserBookCache } from '../../utils/browserCache'
import { cacheBookToBrowser, resolveBookChapters } from '../../utils/bookCache'
import { isLocalTxtBook } from '../../utils/localBook'

const store = useReaderStore()
const appStore = useAppStore()
Expand All @@ -119,6 +126,7 @@ const currentStatus = ref('准备中...')
const currentChapterName = ref('')
const serverCachedCount = ref(0)
const browserCachedCount = ref(0)
const isLocalTxt = computed(() => isLocalTxtBook(store.book))
let sse: EventSource | null = null
let browserSignal = { cancelled: false }

Expand All @@ -132,6 +140,11 @@ onUnmounted(() => {

async function refreshStats() {
if (!store.book) return
if (isLocalTxt.value) {
serverCachedCount.value = 0
browserCachedCount.value = 0
return
}
const [serverList, browserCount] = await Promise.all([
getBookshelfWithCacheInfo().catch(() => []),
countBrowserBookCache(store.book.bookUrl).catch(() => 0),
Expand All @@ -142,7 +155,7 @@ async function refreshStats() {
}

function startServerCaching(count: number) {
if (!store.book) return
if (!store.book || isLocalTxt.value) return
stopWorking()
working.value = true
progress.value = 0
Expand Down Expand Up @@ -198,7 +211,7 @@ function startServerCaching(count: number) {
}

async function startBrowserCaching(count: number) {
if (!store.book) return
if (!store.book || isLocalTxt.value) return
stopWorking()
browserSignal = { cancelled: false }
working.value = true
Expand Down Expand Up @@ -240,14 +253,14 @@ async function startBrowserCaching(count: number) {
}

async function clearServerCache() {
if (!store.book) return
if (!store.book || isLocalTxt.value) return
await deleteBookCache(store.book.bookUrl)
appStore.showToast('服务端缓存已清除', 'success')
await refreshStats()
}

async function clearBrowserCache() {
if (!store.book) return
if (!store.book || isLocalTxt.value) return
await deleteBrowserBookCache(store.book.bookUrl)
appStore.showToast('浏览器缓存已清除', 'success')
await refreshStats()
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/reader/ReaderCatalog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ import { useReaderStore } from '../../stores/reader'
import { useAppStore } from '../../stores/app'
import type { Bookmark } from '../../types'
import { listBrowserCachedChapterUrls } from '../../utils/browserCache'
import { isLocalTxtBook } from '../../utils/localBook'

const props = withDefaults(defineProps<{
initialTab?: 'chapters' | 'bookmarks'
Expand Down Expand Up @@ -227,7 +228,7 @@ async function refreshCatalog() {
}

async function refreshCachedChapterState() {
if (!store.book || !store.chapters.length) {
if (!store.book || isLocalTxtBook(store.book) || !store.chapters.length) {
cachedChapterUrls.value = new Set()
return
}
Expand Down
Loading