This commit is contained in:
truewhile
2026-10-02 11:05:59 +08:00
parent 42343212c5
commit 5acfa6292a
35 changed files with 4162 additions and 224 deletions
+13 -1
View File
@@ -1,4 +1,4 @@
import { isRenderableCover } from './coverUrl.ts'
import { isRenderableCover, needsCoverProxy } from './coverUrl.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`coverUrl: ${name}`)
@@ -27,4 +27,16 @@ check('source name is not renderable', !isRenderableCover('光遇聚合'))
check('bare relative path is not renderable', !isRenderableCover('files/a.jpg'))
check('javascript url is not renderable', !isRenderableCover('javascript:alert(1)'))
// needsCoverProxy:只有明文 http 的远端封面需要绕后端代理,否则 https 部署下
// 会被混合内容策略拦掉(书源图床酷我等没有 https,浏览器升级请求必然失败)。
check('http cover needs proxy', needsCoverProxy('http://img4.sycdn.kuwo.cn/star/albumcover/240/a.jpg'))
check('uppercase http cover needs proxy', needsCoverProxy('HTTP://img4.sycdn.kuwo.cn/a.jpg'))
check('padded http cover needs proxy', needsCoverProxy(' http://img4.sycdn.kuwo.cn/a.jpg '))
check('https cover needs no proxy', !needsCoverProxy('https://imagev2.xmcdn.com/a.jpg'))
check('protocol relative cover needs no proxy', !needsCoverProxy('//img.example.com/a.jpg'))
check('local asset path needs no proxy', !needsCoverProxy('/api/reader/local/asset?b=1&p=2&s=3'))
check('data uri needs no proxy', !needsCoverProxy('data:image/png;base64,iVBORw0KGgo='))
check('empty needs no proxy', !needsCoverProxy(''))
check('name that merely contains http needs no proxy', !needsCoverProxy('http 书籍'))
console.log('coverUrl.test.ts ok')
+13
View File
@@ -18,3 +18,16 @@ export function isRenderableCover(url?: string | null): boolean {
return false
}
// needsCoverProxy 判断封面是否必须绕道后端图片代理才能显示。
//
// 明文 http 的封面在 https 部署下会被浏览器当作「混合内容」:浏览器先把 http
// 自动升级成 https 再发请求,而不少书源图床(酷我 sycdn.kuwo.cn 等)根本没有
// https,升级失败后图片被直接丢弃,界面上只剩占位图标。同一份书源在本地 http
// 部署(127.0.0.1:8080)却一切正常,差异就出在这里。
//
// 其余地址不需要代理:https 本身是安全协议,data: 图片不经过网络,应用自身的
// 相对地址天然同源。代理它们只会白白多一次服务端抓取。
export function needsCoverProxy(url?: string | null): boolean {
return (url ?? '').trim().toLowerCase().startsWith('http://')
}
+99
View File
@@ -0,0 +1,99 @@
import {
ALL_GROUP_ID,
UNGROUPED_GROUP_ID,
attachBookToGroup,
buildBookGroupTabs,
dedupeBookGroups,
detachBooksFromGroup,
filterBooksByGroup,
groupIdOfBook,
normalizeBookGroups,
reorderBookGroups,
resolveSelectedGroupId,
} from './readerBookGroups'
// 书架分组的纯逻辑回归测试(对齐 utils/libraryTags.test.ts 的写法)。
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`readerBookGroups: ${name}`)
}
const book = (id: string) => ({ id })
// ── 清洗 ──
check('non-array → empty', normalizeBookGroups(null).length === 0)
check('blank name dropped', normalizeBookGroups([{ name: ' ', book_ids: ['a'] }]).length === 0)
check('name trimmed and ids deduped', (() => {
const got = normalizeBookGroups([{ name: ' 科幻 ', book_ids: ['a', '', 'a', 'b'] }])
return got.length === 1 && got[0].name === '科幻' && got[0].book_ids.join(',') === 'a,b'
})())
check('same name (case-insensitive) merged', (() => {
const got = normalizeBookGroups([
{ name: 'SciFi', book_ids: ['a'] },
{ name: 'scifi', book_ids: ['b'] },
])
return got.length === 1 && got[0].book_ids.join(',') === 'a,b'
})())
// ── 一书一组 ──
const claimed = dedupeBookGroups([
{ name: '一', book_ids: ['a', 'b'] },
{ name: '二', book_ids: ['b', 'c'] },
{ name: '三', book_ids: ['c'] },
])
check('dedupe keeps first claim', claimed[0].book_ids.join(',') === 'a,b')
// 二 原本 [b,c]:b 被 一 抢走后仍留住 c;三 的 [c] 已被 二 认领,因此变空
check('dedupe keeps the still-unclaimed member', claimed[1].book_ids.join(',') === 'c')
check('dedupe empties the group whose books were all claimed', claimed[2].book_ids.length === 0)
// ── 过滤 ──
const books = [book('a'), book('b'), book('c'), book('d')]
const groups = [{ name: '科幻', book_ids: ['a', 'b'] }, { name: '在读', book_ids: ['c'] }]
const ids = (list: { id: string }[]) => list.map((x) => x.id).join(',')
check('ALL returns everything', ids(filterBooksByGroup(books, groups, ALL_GROUP_ID)) === 'a,b,c,d')
check('empty id behaves like ALL', ids(filterBooksByGroup(books, groups, '')) === 'a,b,c,d')
check('group filter keeps members in input order', ids(filterBooksByGroup(books, groups, '科幻')) === 'a,b')
check('unknown group → empty', filterBooksByGroup(books, groups, '不存在').length === 0)
check('ungrouped returns unclaimed books', ids(filterBooksByGroup(books, groups, UNGROUPED_GROUP_ID)) === 'd')
check('stale ids in a group are ignored', (() => {
const withStale = [{ name: '科幻', book_ids: ['a', 'ghost'] }]
return ids(filterBooksByGroup(books, withStale, '科幻')) === 'a'
})())
// ── 分组栏 ──
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('全部 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('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
})())
// ── 排序与选中回落 ──
check(
'reorder follows the given order',
reorderBookGroups(groups, ['在读', '科幻']).map((g) => g.name).join(',') === '在读,科幻',
)
check(
'reorder appends unknown names (never drops)',
reorderBookGroups(groups, ['在读']).map((g) => g.name).join(',') === '在读,科幻',
)
check('selected group falls back to ALL when removed', resolveSelectedGroupId(groups, '已删除') === ALL_GROUP_ID)
check('selected group kept when it exists', resolveSelectedGroupId(groups, '科幻') === '科幻')
check('ungrouped selection is preserved', resolveSelectedGroupId(groups, UNGROUPED_GROUP_ID) === UNGROUPED_GROUP_ID)
// ── 归组 ──
const moved = attachBookToGroup(groups, 'c', '科幻')
check('attach moves the book out of its old group', moved[1].book_ids.length === 0)
check('attach appends to the target group', moved[0].book_ids.join(',') === 'a,b,c')
check('attach to unknown group is a no-op', attachBookToGroup(groups, 'c', '不存在') === groups)
check('detach returns the book to ungrouped', detachBooksFromGroup(groups, ['a'])[0].book_ids.join(',') === 'b')
check('groupIdOfBook finds the owner', groupIdOfBook(groups, 'c') === '在读' && groupIdOfBook(groups, 'd') === '')
console.log('readerBookGroups.test.ts ok')
+316
View File
@@ -0,0 +1,316 @@
import type { BookGroup } from '../api/reader'
// 书架分组的纯逻辑(对齐影视模块的 utils/libraryTags.ts)。
//
// 结构同构:组名 → 成员 ID 列表,「一本书只归一个组」,整份替换。
// 差别在于书架多两个内置页签:全部 / 未分组(未分组不持久化,只是「没归任何组」的视图)。
/** 与后端 model.MaxBookGroupNameLen 保持一致。 */
export const MAX_BOOK_GROUP_NAME_LENGTH = 24
/** 与后端 model.MaxBookGroups 保持一致。 */
export const MAX_BOOK_GROUPS = 50
/** 「全部」伪页签:不参与持久化,仅用于分组栏选中项。 */
export const ALL_GROUP_ID = '__all__'
/** 「未分组」伪页签:没有归入任何分组的书籍。 */
export const UNGROUPED_GROUP_ID = '__ungrouped__'
export type BookGroupTab = {
/** 选中的稳定标识:ALL_GROUP_ID / UNGROUPED_GROUP_ID / 分组名。 */
id: string
name: string
count: number
kind: 'all' | 'ungrouped' | 'group'
}
const STORAGE_KEY = 'mebox_reader_book_groups'
const SELECTED_KEY = 'mebox_reader_book_group_selected'
/** 分组名去空白并按字符数截断,与后端清洗规则一致。 */
export function normalizeBookGroupName(name: string): string {
return Array.from(name.trim()).slice(0, MAX_BOOK_GROUP_NAME_LENGTH).join('').trim()
}
export function normalizeBookGroups(raw: unknown): BookGroup[] {
if (!Array.isArray(raw)) return []
const out: BookGroup[] = []
const indexByName = new Map<string, number>()
for (const item of raw) {
if (!item || typeof item !== 'object') continue
const name = normalizeBookGroupName(String((item as BookGroup).name ?? ''))
if (!name) continue
const key = name.toLowerCase()
let pos = indexByName.get(key)
if (pos === undefined) {
if (out.length >= MAX_BOOK_GROUPS) break
out.push({ name, book_ids: [] })
pos = out.length - 1
indexByName.set(key, pos)
}
const ids = (item as BookGroup).book_ids
if (!Array.isArray(ids)) continue
const seen = new Set(out[pos].book_ids)
for (const id of ids) {
const trimmed = String(id ?? '').trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
out[pos].book_ids.push(trimmed)
}
}
return out
}
/** 读取本地兜底缓存:接口不可用时分组栏仍然可用。 */
export function readCachedBookGroups(): BookGroup[] {
if (typeof window === 'undefined') return []
try {
return normalizeBookGroups(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '[]'))
} catch {
return []
}
}
export function writeCachedBookGroups(groups: BookGroup[]): void {
if (typeof window === 'undefined') return
try {
if (groups.length === 0) {
window.localStorage.removeItem(STORAGE_KEY)
return
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(groups))
} catch {
// 私密模式等场景忽略存储失败。
}
}
export function readSelectedGroupId(): string {
if (typeof window === 'undefined') return ALL_GROUP_ID
try {
return window.localStorage.getItem(SELECTED_KEY)?.trim() || ALL_GROUP_ID
} catch {
return ALL_GROUP_ID
}
}
export function writeSelectedGroupId(groupId: string): void {
if (typeof window === 'undefined') return
try {
if (!groupId || groupId === ALL_GROUP_ID) {
window.localStorage.removeItem(SELECTED_KEY)
return
}
window.localStorage.setItem(SELECTED_KEY, groupId)
} catch {
// 同上。
}
}
export async function loadBookGroups(): Promise<BookGroup[]> {
const { readerAPI } = await import('../api/reader')
const remote = normalizeBookGroups(await readerAPI.getBookGroups())
writeCachedBookGroups(remote)
return remote
}
export async function saveBookGroups(groups: BookGroup[]): Promise<BookGroup[]> {
const { readerAPI } = await import('../api/reader')
const saved = normalizeBookGroups(await readerAPI.setBookGroups(normalizeBookGroups(groups)))
writeCachedBookGroups(saved)
return saved
}
/**
* 收敛为「一本书只归一个组」:越靠前的分组优先,后面重复出现的书会被移除。
* 与后端 dedupeBookGroups 的语义一致。
*/
export function dedupeBookGroups(groups: BookGroup[]): BookGroup[] {
const claimed = new Set<string>()
return groups.map((group) => {
const bookIds: string[] = []
for (const id of group.book_ids) {
if (claimed.has(id)) continue
claimed.add(id)
bookIds.push(id)
}
return { ...group, book_ids: bookIds }
})
}
/** 分组名是否可用:非空、未超长、且(除自身外)没有重名。 */
export function bookGroupNameError(name: string, groups: BookGroup[], exceptName?: string): string {
const trimmed = normalizeBookGroupName(name)
if (!trimmed) return '分组名不能为空'
if (Array.from(name.trim()).length > MAX_BOOK_GROUP_NAME_LENGTH) {
return `分组名最多 ${MAX_BOOK_GROUP_NAME_LENGTH} 个字`
}
const key = trimmed.toLowerCase()
const selfKey = exceptName ? normalizeBookGroupName(exceptName).toLowerCase() : ''
if (key !== selfKey && groups.some((group) => group.name.toLowerCase() === key)) {
return '分组名已存在'
}
return ''
}
export function findBookGroupIdByName(groups: BookGroup[], name: string): string {
const key = normalizeBookGroupName(name).toLowerCase()
if (!key) return ''
return groups.find((group) => group.name.toLowerCase() === key)?.name ?? ''
}
export function groupBookIds(groups: BookGroup[], groupId: string): string[] {
if (!groupId || groupId === ALL_GROUP_ID || groupId === UNGROUPED_GROUP_ID) return []
const group = groups.find((item) => item.name === groupId)
return group ? group.book_ids : []
}
/** 一本书所属的分组名;未归组返回空串。 */
export function groupIdOfBook(groups: BookGroup[], bookId: string): string {
for (const group of groups) {
if (group.book_ids.includes(bookId)) return group.name
}
return ''
}
export function isBookInGroup(groups: BookGroup[], groupId: string, bookId: string): boolean {
return groupBookIds(groups, groupId).includes(bookId)
}
/** 所有已被某个分组认领的书籍 ID。 */
export function groupedBookIds(groups: BookGroup[]): Set<string> {
const out = new Set<string>()
for (const group of groups) {
for (const id of group.book_ids) out.add(id)
}
return out
}
/**
* 按分组过滤书籍:`全部` 原样返回,`未分组` 返回没有被任何分组认领的书。
* 只做成员筛选、保持输入顺序;展示顺序由调用方决定。
*/
export function filterBooksByGroup<T extends { id: string }>(
books: T[],
groups: BookGroup[],
groupId: string,
): T[] {
if (!groupId || groupId === ALL_GROUP_ID) return books
if (groupId === UNGROUPED_GROUP_ID) {
const claimed = groupedBookIds(groups)
return books.filter((book) => !claimed.has(book.id))
}
const ids = new Set(groupBookIds(groups, groupId))
if (ids.size === 0) return []
return books.filter((book) => ids.has(book.id))
}
/**
* 生成分组栏模型:首项固定「全部」,有分组时追加「未分组」,其后按分组顺序追加。
* 计数只统计当前书架上仍存在的书,避免分组里残留的死 ID 让数字虚高。
*/
export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[]): BookGroupTab[] {
const available = new Set(books.map((book) => book.id))
const claimed = new Set<string>()
for (const group of groups) {
for (const id of group.book_ids) {
if (available.has(id)) claimed.add(id)
}
}
const tabs: BookGroupTab[] = [
{ 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,
name: group.name,
count: group.book_ids.filter((id) => available.has(id)).length,
kind: 'group',
})
}
return tabs
}
/**
* 按给定分组名顺序重排:`orderedNames` 是期望顺序(通常来自拖拽结果)。
* 未出现在顺序里的分组保持原相对顺序并追加到末尾,绝不丢失。
*/
export function reorderBookGroups(groups: BookGroup[], orderedNames: string[]): BookGroup[] {
if (groups.length < 2) return groups
const rank = new Map(orderedNames.map((name, index) => [name, index]))
const ranked: BookGroup[] = []
const rest: BookGroup[] = []
for (const group of groups) {
;(rank.has(group.name) ? ranked : rest).push(group)
}
ranked.sort((a, b) => (rank.get(a.name) ?? 0) - (rank.get(b.name) ?? 0))
return [...ranked, ...rest]
}
/** 选中的分组是否仍存在;被删除时回落到「全部」。 */
export function resolveSelectedGroupId(groups: BookGroup[], selectedId: string): string {
if (!selectedId || selectedId === ALL_GROUP_ID || selectedId === UNGROUPED_GROUP_ID) return selectedId || ALL_GROUP_ID
return groups.some((group) => group.name === selectedId) ? selectedId : ALL_GROUP_ID
}
/** 把一本书挂到分组下:先从原分组移除,保证一书一组。 */
export function attachBookToGroup(groups: BookGroup[], bookId: string, groupName: string): BookGroup[] {
const target = findBookGroupIdByName(groups, groupName)
if (!target) return groups
const next = groups.map((group) => ({
...group,
book_ids: group.book_ids.filter((id) => id !== bookId),
}))
return next.map((group) =>
group.name === target ? { ...group, book_ids: [...group.book_ids, bookId] } : group,
)
}
/** 批量把书籍挂到分组下;整批只产生一次写入。 */
export function attachBooksToGroup(groups: BookGroup[], bookIds: string[], groupName: string): BookGroup[] {
const target = findBookGroupIdByName(groups, groupName)
const moving = normalizeBookIds(bookIds)
if (!target || moving.length === 0) return groups
const movingSet = new Set(moving)
const next = groups.map((group) => ({
...group,
book_ids: group.book_ids.filter((id) => !movingSet.has(id)),
}))
return next.map((group) =>
group.name === target ? { ...group, book_ids: [...group.book_ids, ...moving] } : group,
)
}
/** 批量把书籍移出所有分组(即变为「未分组」)。 */
export function detachBooksFromGroup(groups: BookGroup[], bookIds: string[]): BookGroup[] {
const moving = new Set(normalizeBookIds(bookIds))
if (moving.size === 0) return groups
return groups.map((group) => ({
...group,
book_ids: group.book_ids.filter((id) => !moving.has(id)),
}))
}
/** 批量归类:groupName 为空表示移出所有分组。 */
export function assignBooksToGroup(groups: BookGroup[], bookIds: string[], groupName: string): BookGroup[] {
return groupName ? attachBooksToGroup(groups, bookIds, groupName) : detachBooksFromGroup(groups, bookIds)
}
/** 去掉空白与重复,保留首次出现的顺序。 */
export function normalizeBookIds(bookIds: unknown): string[] {
if (!Array.isArray(bookIds)) return []
const seen = new Set<string>()
const out: string[] = []
for (const raw of bookIds) {
const trimmed = String(raw ?? '').trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
out.push(trimmed)
}
return out
}
+14
View File
@@ -0,0 +1,14 @@
import { imageURL } from '../api/client'
import { needsCoverProxy } from './coverUrl'
// readerCoverSrc 把书源给出的封面地址解析成 <img> 可以直接用的地址。
//
// 书源封面常挂在没有 https 的图床上(酷我 sycdn.kuwo.cn 等)。https 部署下直接
// 引用这类明文 http 地址会被浏览器的混合内容策略拦掉,封面退化成占位图标;改由
// 后端 /api/img 抓取后同源下发就没有这个问题。其余地址原样返回,不给本就能直连
// 的封面增加一次服务端中转。
export function readerCoverSrc(url?: string | null): string {
const value = (url ?? '').trim()
if (!value) return ''
return needsCoverProxy(value) ? imageURL(value) : value
}
+63
View File
@@ -0,0 +1,63 @@
import { readerAPI } from '../api/reader'
import { isAuthenticated } from '../stores/auth'
import {
applyReaderSettingsProfile,
readerSettingsPayload,
useReaderSettingsStore,
} from '../stores/readerSettings'
// 阅读器偏好(主题 / 排版 / 听书 / 书架展示)的跨设备同步。
//
// 服务端是权威来源,localStorage(zustand persist)退化成首屏缓存:
// - 登录后 hydrateReaderSettings() 拉一次服务端偏好覆盖本地;
// - 之后本地任何改动防抖回写服务端。
// 该用户还没保存过时服务端返回 null,这时把本地现值推上去「播种」,
// 这样升级前已经在本机调好的设置不会被重置成默认值。
/** 单次回写延迟:把连点字体 +/-、拖滑块这类连续操作合并成一次请求。 */
const PUSH_DEBOUNCE_MS = 600
/** 拉取服务端期间抑制回写,避免把本地旧值当成用户改动推上去。 */
let hydrating = false
let pushTimer: number | null = null
let subscribed = false
function schedulePush(): void {
if (hydrating || !isAuthenticated()) return
if (pushTimer !== null) window.clearTimeout(pushTimer)
pushTimer = window.setTimeout(() => {
pushTimer = null
const payload = readerSettingsPayload(useReaderSettingsStore.getState())
readerAPI.saveReaderSettings(payload).catch(() => undefined)
}, PUSH_DEBOUNCE_MS)
}
/** 订阅本地偏好变化并防抖回写服务端;重复调用只生效一次。 */
export function startReaderSettingsSync(): void {
if (subscribed) return
subscribed = true
useReaderSettingsStore.subscribe(() => schedulePush())
}
/**
* 拉取当前账号的阅读器偏好并应用到本地。
* 服务端异常 / 离线时保持本地值不动,等下一次改动再同步。
*/
export async function hydrateReaderSettings(): Promise<void> {
if (!isAuthenticated()) return
hydrating = true
try {
const remote = await readerAPI.getReaderSettings()
if (remote) {
applyReaderSettingsProfile(remote)
} else {
// 该账号还没有偏好记录:用本地现值播种,保证老用户的设置迁移过去
const payload = readerSettingsPayload(useReaderSettingsStore.getState())
await readerAPI.saveReaderSettings(payload).catch(() => undefined)
}
} catch {
// 保持本地缓存值,不打扰用户
} finally {
hydrating = false
}
}