diff --git a/src/@core/utils/image.ts b/src/@core/utils/image.ts index 546c0d92..8ecaf7d0 100644 --- a/src/@core/utils/image.ts +++ b/src/@core/utils/image.ts @@ -1,5 +1,15 @@ import ColorThief from 'colorthief' +const DEFAULT_DOMINANT_COLOR = '#28A9E1' +const DOMINANT_COLOR_CACHE_LIMIT = 100 +const colorThief = new ColorThief() +const dominantColorCache = new Map>() + +interface DominantColorOptions { + fallback?: string + quality?: number +} + // 将 RGB 转换为十六进制 function rgbStringToHex(rgbArray: number[]): string { if (rgbArray.length !== 3 || rgbArray.some(isNaN)) throw new Error('Invalid RGB string format') @@ -14,11 +24,46 @@ function rgbStringToHex(rgbArray: number[]): string { return `#${toHex(r)}${toHex(g)}${toHex(b)}` } +function getImageCacheKey(image: HTMLImageElement) { + return image.currentSrc || image.src || '' +} + +function rememberDominantColor(key: string, colorPromise: Promise) { + if (!key) return colorPromise + + if (dominantColorCache.size >= DOMINANT_COLOR_CACHE_LIMIT) { + const firstKey = dominantColorCache.keys().next().value + if (firstKey) dominantColorCache.delete(firstKey) + } + + dominantColorCache.set(key, colorPromise) + return colorPromise +} + // 提取主要颜色 -export async function getDominantColor(image: HTMLImageElement): Promise { - const colorThief = new ColorThief() - const dominantColor = colorThief.getColor(image) - return rgbStringToHex(dominantColor) +export async function getDominantColor( + image: HTMLImageElement | undefined | null, + options: DominantColorOptions = {}, +): Promise { + const fallback = options.fallback ?? DEFAULT_DOMINANT_COLOR + + if (!image) return fallback + + const cacheKey = getImageCacheKey(image) + const cachedColor = cacheKey ? dominantColorCache.get(cacheKey) : undefined + if (cachedColor) return cachedColor + + const colorPromise = Promise.resolve() + .then(() => { + const dominantColor = colorThief.getColor(image, options.quality ?? 20) + return rgbStringToHex(dominantColor) + }) + .catch(error => { + console.warn('Failed to extract dominant color:', error) + return fallback + }) + + return rememberDominantColor(cacheKey, colorPromise) } // 预加载图片 diff --git a/src/components/dialog/SubscribeHistoryDialog.vue b/src/components/dialog/SubscribeHistoryDialog.vue index 86b1ce17..20fa8dd7 100644 --- a/src/components/dialog/SubscribeHistoryDialog.vue +++ b/src/components/dialog/SubscribeHistoryDialog.vue @@ -6,6 +6,7 @@ import { useDisplay } from 'vuetify' import ProgressDialog from './ProgressDialog.vue' import { useI18n } from 'vue-i18n' import { mediaTypeDict } from '@/api/constants' +import type { InfiniteScrollDone } from '@/composables/usePaginatedInfiniteScroll' // 国际化 const { t } = useI18n() @@ -24,9 +25,6 @@ const emit = defineEmits(['close', 'save']) // 订阅历史列表 const historyList = ref([]) -// 当前加载数据 -const currData = ref([]) - // 当前页 const currentPage = ref(1) @@ -46,7 +44,7 @@ const progressDialog = ref(false) const progressText = ref('') // 调用API查询列表 -async function loadHistory({ done }: { done: any }) { +async function loadHistory({ done }: { done: InfiniteScrollDone }) { // 如果正在加载中,直接返回 if (loading.value) { done('ok') @@ -57,7 +55,7 @@ async function loadHistory({ done }: { done: any }) { try { // 设置加载中 loading.value = true - currData.value = await api.get(`subscribe/history/${props.type}`, { + const currentData: Subscribe[] = await api.get(`subscribe/history/${props.type}`, { params: { page: currentPage.value, count: pageSize.value, @@ -65,12 +63,12 @@ async function loadHistory({ done }: { done: any }) { }) // 标计为已请求完成 isRefreshed.value = true - if (currData.value.length === 0) { + if (currentData.length === 0) { // 如果没有数据,跳出 done('empty') } else { // 合并数据 - historyList.value = [...historyList.value, ...currData.value] + historyList.value.push(...currentData) // 页码+1 currentPage.value++ // 返回加载成功 diff --git a/src/composables/usePaginatedInfiniteScroll.ts b/src/composables/usePaginatedInfiniteScroll.ts new file mode 100644 index 00000000..82795af1 --- /dev/null +++ b/src/composables/usePaginatedInfiniteScroll.ts @@ -0,0 +1,87 @@ +import type { Ref } from 'vue' +import { nextTick } from 'vue' + +export type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error' +export type InfiniteScrollDone = (status: InfiniteScrollStatus) => void + +interface InfiniteScrollPage { + isLastPage?: boolean + items: T[] +} + +interface LoadPaginatedInfiniteScrollOptions { + advancePage: () => void + appendItems: (items: T[]) => void + done: InfiniteScrollDone + hasScroll?: () => boolean + loadPage: () => Promise> + loading: Ref + markLoaded?: () => void + maxAutoLoadPages?: number +} + +const DEFAULT_MAX_AUTO_LOAD_PAGES = 6 + +export function hasDocumentScroll() { + return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2 +} + +function normalizePageResult(result: T[] | InfiniteScrollPage): InfiniteScrollPage { + if (Array.isArray(result)) { + return { + isLastPage: result.length === 0, + items: result, + } + } + + return result +} + +export async function loadPaginatedInfiniteScroll({ + advancePage, + appendItems, + done, + hasScroll = hasDocumentScroll, + loadPage, + loading, + markLoaded, + maxAutoLoadPages = DEFAULT_MAX_AUTO_LOAD_PAGES, +}: LoadPaginatedInfiniteScrollOptions) { + if (loading.value) { + done('ok') + return + } + + loading.value = true + + let status: InfiniteScrollStatus = 'ok' + let loadedPages = 0 + + try { + do { + const { isLastPage, items } = normalizePageResult(await loadPage()) + + markLoaded?.() + + if (isLastPage) { + status = 'empty' + break + } + + if (items.length > 0) { + appendItems(items) + } + + advancePage() + loadedPages += 1 + + await nextTick() + } while (!hasScroll() && loadedPages < maxAutoLoadPages) + } catch (error) { + console.error(error) + status = 'error' + } finally { + loading.value = false + done(status) + } +} diff --git a/src/views/discover/MediaCardListView.vue b/src/views/discover/MediaCardListView.vue index ae3f7ca8..e40525e8 100644 --- a/src/views/discover/MediaCardListView.vue +++ b/src/views/discover/MediaCardListView.vue @@ -4,6 +4,7 @@ import type { MediaInfo } from '@/api/types' import MediaCard from '@/components/cards/MediaCard.vue' import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue' import NoDataFound from '@/components/NoDataFound.vue' +import { loadPaginatedInfiniteScroll, type InfiniteScrollDone } from '@/composables/usePaginatedInfiniteScroll' import { useI18n } from 'vue-i18n' const { t } = useI18n() @@ -14,11 +15,6 @@ const props = defineProps({ params: Object as PropType<{ [key: string]: any }>, }) -// 判断是否有滚动条 -function hasScroll() { - return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2 -} - // 当前页码 const page = ref(1) @@ -60,7 +56,7 @@ const dedupFields = [ function deduplicate(items: MediaInfo[]): MediaInfo[] { return items.filter(item => { - const key = dedupFields.map(field => String(item[field])).join('~') + const key = getMediaDedupKey(item) if (seenKeys.has(key)) { return false } @@ -70,7 +66,16 @@ function deduplicate(items: MediaInfo[]): MediaInfo[] { } function appendData(items: MediaInfo[]) { - dataList.value = dataList.value.concat(items) + dataList.value.push(...items) + triggerRef(dataList) +} + +function getMediaDedupKey(item: MediaInfo) { + return dedupFields.map(field => String(item[field] ?? '')).join('~') +} + +function getMediaItemKey(item: MediaInfo) { + return [getMediaDedupKey(item), item.title ?? ''].join('~') } async function loadPageData() { @@ -79,73 +84,30 @@ async function loadPageData() { }) return { - rawCount: rawData.length, - uniqueData: deduplicate(rawData), + isLastPage: rawData.length === 0, + items: deduplicate(rawData), } } // 获取列表数据 -async function fetchData({ done }: { done: any }) { - try { - if (!props.apipath) return - - // 如果正在加载中,直接返回 - if (loading.value) { - done('ok') - return - } - - // 加载到满屏或者加载出错 - if (!hasScroll()) { - // 加载多次 - while (!hasScroll()) { - // 设置加载中 - loading.value = true - // 请求API - const { rawCount, uniqueData } = await loadPageData() - // 取消加载中 - loading.value = false - // 标计为已请求完成 - isRefreshed.value = true - if (rawCount === 0) { - // 如果没有数据,跳出 - done('empty') - return - } - // 合并数据 - appendData(uniqueData) - // 页码+1 - page.value++ - // 返回加载成功 - done('ok') - } - } else { - // 加载一次 - // 设置加载中 - loading.value = true - // 请求API - const { rawCount, uniqueData } = await loadPageData() - // 标计为已请求完成 - isRefreshed.value = true - if (rawCount === 0) { - // 如果没有数据,跳出 - done('empty') - } else { - // 合并数据 - appendData(uniqueData) - // 页码+1 - page.value++ - // 返回加载成功 - done('ok') - } - } - // 取消加载中 - loading.value = false - } catch (error) { - console.error(error) - // 返回加载失败 - done('error') +async function fetchData({ done }: { done: InfiniteScrollDone }) { + if (!props.apipath) { + done('empty') + return } + + await loadPaginatedInfiniteScroll({ + advancePage: () => { + page.value++ + }, + appendItems: appendData, + done, + loadPage: loadPageData, + loading, + markLoaded: () => { + isRefreshed.value = true + }, + }) } @@ -158,7 +120,7 @@ async function fetchData({ done }: { done: any }) { v-if="dataList.length > 0" :items="dataList" :item-aspect-ratio="1.5" - :get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title" + :get-item-key="getMediaItemKey" tabindex="0" >