mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 13:06:36 +08:00
优化
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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://')
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user