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
- {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]
+}