diff --git a/docs/reader-ui-spec.md b/docs/reader-ui-spec.md index f963950..87b601b 100644 --- a/docs/reader-ui-spec.md +++ b/docs/reader-ui-spec.md @@ -39,7 +39,7 @@ **书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)** - 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。 - 接口:`GET /reader/book-groups`(没有分组返回空数组)、`PUT /reader/book-groups`(整份替换)。服务端落库前做两条需要「知道书是否存在」的收敛:只保留该用户书架上的书(书移出书架后分组里不留死 ID)、一本书只归一个组(越靠前的分组优先)。空分组保留,方便先建组再放书。 -- 与影视的差异:书架有两个内置页签「全部」「未分组」(未分组是「没有被任何分组认领」的视图,不持久化,只有存在分组时才出现);书籍不进管理弹窗逐个分配,而是在卡片上用「分组」按钮指定(书架可能有上千本,列出来既慢又难找)。 +- 与影视的差异:书架有两个内置页签「全部」「未分组」(未分组是「没有被任何分组认领」的视图,不持久化,只有存在分组时才出现,且固定在分组栏最后,避免挤开用户自己拖拽排好的分组顺序);书籍不进管理弹窗逐个分配,而是在卡片上用「分组」按钮指定(书架可能有上千本,列出来既慢又难找)。 - 前端:`utils/readerBookGroups.ts`(纯逻辑)+ `hooks/useBookGroups.ts`(乐观更新、串行提交、失败回滚,接口不可用时退回 localStorage,照搬 `useLibraryTags`)+ `components/BookGroupBar.tsx`(分组栏)+ `ManageBookGroupsDialogView.tsx`(新建/重命名/删除/拖拽排序)+ `BookGroupPickerDialog.tsx`(移到分组)。 **阅读器偏好按账号同步(跨设备)** diff --git a/web/src/components/BookGroupBar.tsx b/web/src/components/BookGroupBar.tsx index 7e935e5..457142b 100644 --- a/web/src/components/BookGroupBar.tsx +++ b/web/src/components/BookGroupBar.tsx @@ -4,7 +4,7 @@ import type { BookGroupTab } from '../utils/readerBookGroups' /** * 书架分组栏(对齐影视模块的 LibraryTagBar): - * 固定「全部」+(有分组时)「未分组」+ 每个分组一个页签,选中后只展示该分组的书。 + * 固定「全部」+ 每个分组一个页签 +(有分组时)「未分组」殿后,选中后只展示该分组的书。 * * 只是展示层过滤,不改动书架数据本身。这里只负责展示与选中:新建 / 重命名 / 删除 / * 拖拽排序都在「管理」菜单的「管理分组」弹窗里做,分组栏保持干净。 diff --git a/web/src/utils/readerBookGroups.test.ts b/web/src/utils/readerBookGroups.test.ts index 17652c1..37e9f74 100644 --- a/web/src/utils/readerBookGroups.test.ts +++ b/web/src/utils/readerBookGroups.test.ts @@ -66,13 +66,14 @@ const noGroups = buildBookGroupTabs(books, []) check('no groups → only 全部', noGroups.length === 1 && noGroups[0].id === ALL_GROUP_ID && noGroups[0].count === 4) const tabs = buildBookGroupTabs(books, groups) -check('tabs = 全部 + 未分组 + groups', tabs.map((t) => t.name).join(',') === '全部,未分组,科幻,在读') +check('tabs = 全部 + groups + 未分组(未分组殿后)', tabs.map((t) => t.name).join(',') === '全部,科幻,在读,未分组') check('全部 counts all books', tabs[0].count === 4) -check('未分组 counts unclaimed', tabs[1].count === 1) -check('group counts only existing books', tabs[2].count === 2 && tabs[3].count === 1) +check('ungrouped is the last tab', tabs[tabs.length - 1].kind === 'ungrouped') +check('未分组 counts unclaimed', tabs[3].count === 1) +check('group counts only existing books', tabs[1].count === 2 && tabs[2].count === 1) check('stale ids do not inflate counts', (() => { const withStale = buildBookGroupTabs(books, [{ name: '科幻', book_ids: ['a', 'ghost', 'ghost2'] }]) - return withStale[2].count === 1 && withStale[1].count === 3 + return withStale[1].count === 1 && withStale[2].count === 3 })()) // ── 排序与选中回落 ── diff --git a/web/src/utils/readerBookGroups.ts b/web/src/utils/readerBookGroups.ts index 8895c83..3da9b8d 100644 --- a/web/src/utils/readerBookGroups.ts +++ b/web/src/utils/readerBookGroups.ts @@ -204,7 +204,9 @@ export function filterBooksByGroup( } /** - * 生成分组栏模型:首项固定「全部」,有分组时追加「未分组」,其后按分组顺序追加。 + * 生成分组栏模型:首项固定「全部」,其后按分组顺序追加,最后(有分组时)追加「未分组」。 + * 把「未分组」放到最后:它是一个兜底视图而不是一个真实分组,放在用户分组后面才不会 + * 把用户自己排的分组顺序挤开(用户拖拽排序只影响中间那段)。 * 计数只统计当前书架上仍存在的书,避免分组里残留的死 ID 让数字虚高。 */ export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[]): BookGroupTab[] { @@ -219,12 +221,6 @@ export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[]) { id: ALL_GROUP_ID, name: '全部', count: books.length, kind: 'all' }, ] if (groups.length === 0) return tabs - tabs.push({ - id: UNGROUPED_GROUP_ID, - name: '未分组', - count: books.length - claimed.size, - kind: 'ungrouped', - }) for (const group of groups) { tabs.push({ id: group.name, @@ -233,6 +229,12 @@ export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[]) kind: 'group', }) } + tabs.push({ + id: UNGROUPED_GROUP_ID, + name: '未分组', + count: books.length - claimed.size, + kind: 'ungrouped', + }) return tabs }