From c723664e464702398f813965f0ac52a5f49f5ed7 Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Wed, 16 Sep 2026 18:34:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/LibraryListSortDropdown.tsx | 108 ++++++++++++++++++ web/src/pages/LibrariesPage.tsx | 26 ++++- web/src/pages/LibrariesPageSections.tsx | 83 ++++++++------ web/src/utils/libraryListSort.ts | 100 ++++++++++++++++ 4 files changed, 278 insertions(+), 39 deletions(-) create mode 100644 web/src/components/LibraryListSortDropdown.tsx create mode 100644 web/src/utils/libraryListSort.ts diff --git a/web/src/components/LibraryListSortDropdown.tsx b/web/src/components/LibraryListSortDropdown.tsx new file mode 100644 index 0000000..5a78cb0 --- /dev/null +++ b/web/src/components/LibraryListSortDropdown.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef, useState } from 'react' +import { ArrowDown, ArrowUp, ArrowUpDown, Check } from 'lucide-react' + +import { + LIBRARY_LIST_SORT_OPTIONS, + type LibraryListSortField, + type LibraryListSortOption, + type LibraryListSortOrder, +} from '../utils/libraryListSort' + +type LibraryListSortDropdownProps = { + value: LibraryListSortField + order: LibraryListSortOrder + onChange: (field: LibraryListSortField, order: LibraryListSortOrder) => void + className?: string +} + +export function LibraryListSortDropdown({ + value, + order, + onChange, + className = '', +}: LibraryListSortDropdownProps) { + const [isOpen, setIsOpen] = useState(false) + const dropdownRef = useRef(null) + const currentOption = LIBRARY_LIST_SORT_OPTIONS.find((opt) => opt.id === value) ?? LIBRARY_LIST_SORT_OPTIONS[0] + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside) + } + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [isOpen]) + + const handleSelect = (option: LibraryListSortOption) => { + if (value === option.id) { + onChange(option.id, order === 'asc' ? 'desc' : 'asc') + } else { + onChange(option.id, option.defaultOrder) + } + setIsOpen(false) + } + + return ( +
+ + + {isOpen && ( +
+
+ 排序方式 +
+
+ {LIBRARY_LIST_SORT_OPTIONS.map((option) => { + const isSelected = value === option.id + return ( + + ) + })} +
+
+ )} +
+ ) +} diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx index 8e898e7..f67a841 100644 --- a/web/src/pages/LibrariesPage.tsx +++ b/web/src/pages/LibrariesPage.tsx @@ -17,10 +17,16 @@ import { import type { LibraryPreview } from './librariesPageModel' import type { Library } from '../types' import type { SeriesCard } from '../utils/groupSeries' +import { + readLibraryListSort, + sortLibraryPreviewsByField, + writeLibraryListSort, + type LibraryListSortField, + type LibraryListSortOrder, +} from '../utils/libraryListSort' import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache' import { ALL_TAG_ID, buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags' import type { LibraryTag } from '../utils/libraryTags' -import { sortLibraryPreviews } from '../utils/pinnedLibraries' import { partitionPreviewIDs } from '../utils/remoteEmby' export function LibrariesPage() { @@ -33,6 +39,8 @@ export function LibrariesPage() { const [repairing, setRepairing] = useState(false) const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference() const [repairMsg, setRepairMsg] = useState('') + const [sortField, setSortField] = useState(() => readLibraryListSort().field) + const [sortOrder, setSortOrder] = useState(() => readLibraryListSort().order) // 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。 const fetchedPreviewLimitsRef = useRef>(new Map()) @@ -146,7 +154,16 @@ export function LibrariesPage() { }) }, [libraries, libraryData]) - const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds]) + const sortedPreviews = useMemo( + () => sortLibraryPreviewsByField(previews, pinnedIds, sortField, sortOrder), + [previews, pinnedIds, sortField, sortOrder], + ) + + const handleSortChange = useCallback((field: LibraryListSortField, order: LibraryListSortOrder) => { + setSortField(field) + setSortOrder(order) + writeLibraryListSort(field, order) + }, []) const handleTogglePin = useCallback((libraryId: string) => { void togglePin(libraryId) @@ -180,6 +197,9 @@ export function LibrariesPage() { repairMsg={repairMsg} repairEpisodeArtwork={repairEpisodeArtwork} repairing={repairing} + sortField={sortField} + sortOrder={sortOrder} + onSortChange={handleSortChange} onRepairEpisodeArtworkChange={setRepairEpisodeArtwork} onRepairRescrape={handleRepairRescrape} onManageLibraries={handleManageLibraries} @@ -228,7 +248,7 @@ export function LibrariesPage() { ) } -/** 按选中标签过滤媒体库预览(ALL_TAG_ID 时原样返回,保留置顶/手动排序)。 */ +/** 按选中标签过滤媒体库预览(ALL_TAG_ID 时原样返回,保留置顶/列表排序)。 */ function filterPreviewsByTag(previews: LibraryPreview[], tags: LibraryTag[], tagId: string): LibraryPreview[] { if (!tagId || tagId === ALL_TAG_ID) return previews const ordered = filterLibrariesByTag( diff --git a/web/src/pages/LibrariesPageSections.tsx b/web/src/pages/LibrariesPageSections.tsx index 096de0a..678e8f3 100644 --- a/web/src/pages/LibrariesPageSections.tsx +++ b/web/src/pages/LibrariesPageSections.tsx @@ -3,7 +3,9 @@ import { Link } from 'react-router-dom' import { Library as LibraryIcon, RefreshCw, Sparkles } from 'lucide-react' import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle' +import { LibraryListSortDropdown } from '../components/LibraryListSortDropdown' import { useRememberedListPosition } from '../hooks/useListPositionMemory' +import type { LibraryListSortField, LibraryListSortOrder } from '../utils/libraryListSort' import { HomeLibrariesSection, HomeLibraryRowSection } from './HomePageSections' import type { LibraryPreview } from './librariesPageModel' @@ -18,6 +20,9 @@ export function LibrariesHeader({ repairMsg, repairEpisodeArtwork, repairing, + sortField, + sortOrder, + onSortChange, onRepairEpisodeArtworkChange, onRepairRescrape, onManageLibraries, @@ -28,6 +33,9 @@ export function LibrariesHeader({ repairMsg: string repairEpisodeArtwork: boolean repairing: boolean + sortField: LibraryListSortField + sortOrder: LibraryListSortOrder + onSortChange: (field: LibraryListSortField, order: LibraryListSortOrder) => void onRepairEpisodeArtworkChange: (value: boolean) => void onRepairRescrape: () => void onManageLibraries: () => void @@ -40,42 +48,45 @@ export function LibrariesHeader({ 共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。

- {isAdmin && ( -
- {repairMsg && {repairMsg}} - - - - - 刮削队列 - - -
- )} +
+ + {isAdmin && ( + <> + {repairMsg && {repairMsg}} + + + + + 刮削队列 + + + + )} +
) } diff --git a/web/src/utils/libraryListSort.ts b/web/src/utils/libraryListSort.ts new file mode 100644 index 0000000..1655185 --- /dev/null +++ b/web/src/utils/libraryListSort.ts @@ -0,0 +1,100 @@ +import type { Library } from '../types' + +export type LibraryListSortField = 'name' | 'sort_order' +export type LibraryListSortOrder = 'asc' | 'desc' + +export type LibraryListSortOption = { + id: LibraryListSortField + label: string + defaultOrder: LibraryListSortOrder +} + +export const LIBRARY_LIST_SORT_OPTIONS: LibraryListSortOption[] = [ + { id: 'name', label: '库名', defaultOrder: 'desc' }, + { id: 'sort_order', label: '手动顺序', defaultOrder: 'asc' }, +] + +export const DEFAULT_LIBRARY_LIST_SORT_FIELD: LibraryListSortField = 'name' +export const DEFAULT_LIBRARY_LIST_SORT_ORDER: LibraryListSortOrder = 'desc' + +const FIELD_KEY = 'mebox_libraries_sort_field' +const ORDER_KEY = 'mebox_libraries_sort_order' + +export function readLibraryListSort(): { field: LibraryListSortField; order: LibraryListSortOrder } { + if (typeof window === 'undefined') { + return { field: DEFAULT_LIBRARY_LIST_SORT_FIELD, order: DEFAULT_LIBRARY_LIST_SORT_ORDER } + } + const rawField = window.localStorage.getItem(FIELD_KEY) + const rawOrder = window.localStorage.getItem(ORDER_KEY) + const field: LibraryListSortField = + rawField === 'name' || rawField === 'sort_order' ? rawField : DEFAULT_LIBRARY_LIST_SORT_FIELD + const order: LibraryListSortOrder = + rawOrder === 'asc' || rawOrder === 'desc' + ? rawOrder + : field === 'name' + ? DEFAULT_LIBRARY_LIST_SORT_ORDER + : 'asc' + return { field, order } +} + +export function writeLibraryListSort(field: LibraryListSortField, order: LibraryListSortOrder): void { + if (typeof window === 'undefined') return + window.localStorage.setItem(FIELD_KEY, field) + window.localStorage.setItem(ORDER_KEY, order) +} + +function compareNames(a: string, b: string, order: LibraryListSortOrder): number { + const cmp = a.localeCompare(b, 'zh-CN', { numeric: true, sensitivity: 'base' }) + return order === 'asc' ? cmp : -cmp +} + +function compareSortOrder(a: number, b: number, order: LibraryListSortOrder): number { + const cmp = a - b + return order === 'asc' ? cmp : -cmp +} + +function librarySortKey(library: Library): { name: string; sortOrder: number } { + return { + name: (library.name || '').trim(), + sortOrder: library.sort_order ?? 0, + } +} + +function compareLibraries(a: Library, b: Library, field: LibraryListSortField, order: LibraryListSortOrder): number { + const ka = librarySortKey(a) + const kb = librarySortKey(b) + if (field === 'name') { + const byName = compareNames(ka.name, kb.name, order) + if (byName !== 0) return byName + return compareSortOrder(ka.sortOrder, kb.sortOrder, 'asc') || a.id.localeCompare(b.id) + } + const byOrder = compareSortOrder(ka.sortOrder, kb.sortOrder, order) + if (byOrder !== 0) return byOrder + return compareNames(ka.name, kb.name, 'asc') || a.id.localeCompare(b.id) +} + +/** + * 置顶组始终在前;组内按 field/order 排序。 + * pinnedIds 的相对顺序只决定「是否置顶」,组内不再保留置顶拖拽序。 + */ +export function sortLibraryPreviewsByField( + items: T[], + pinnedIds: string[], + field: LibraryListSortField, + order: LibraryListSortOrder, +): T[] { + if (items.length < 2) return items + + const pinnedRank = new Map(pinnedIds.map((id, index) => [id, index])) + const pinned: T[] = [] + const rest: T[] = [] + for (const item of items) { + if (pinnedRank.has(item.library.id)) pinned.push(item) + else rest.push(item) + } + + const byField = (a: T, b: T) => compareLibraries(a.library, b.library, field, order) + pinned.sort(byField) + rest.sort(byField) + return [...pinned, ...rest] +}