Compare commits

...

2 Commits

Author SHA1 Message Date
truewhile f0a6b630c6 优化 2026-09-16 21:19:46 +08:00
truewhile c723664e46 优化 2026-09-16 18:34:24 +08:00
9 changed files with 439 additions and 75 deletions
@@ -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<HTMLDivElement>(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 (
<div className={`relative inline-block text-left ${className}`} ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="inline-flex h-9 items-center gap-1.5 rounded-xl border border-sand-200 bg-white/90 px-3 py-1.5 text-xs font-semibold text-ink-600 shadow-sm transition-all hover:border-brand-300 hover:bg-brand-50/50 hover:text-brand-700 sm:h-10 sm:text-sm"
title="更改媒体库列表排序"
>
<ArrowUpDown size={14} className="text-sand-500" />
<span>排序: {currentOption.label}</span>
{order === 'asc' ? (
<ArrowUp size={13} className="font-bold text-brand-600" />
) : (
<ArrowDown size={13} className="font-bold text-brand-600" />
)}
</button>
{isOpen && (
<div className="absolute left-0 z-50 mt-1.5 w-44 max-w-[calc(100vw-2rem)] origin-top-left rounded-2xl border border-sand-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-md transition-all animate-in fade-in-0 zoom-in-95 sm:left-auto sm:right-0 sm:origin-top-right">
<div className="border-b border-sand-100 px-2.5 py-1.5 text-[11px] font-bold text-sand-500">
排序方式
</div>
<div className="space-y-0.5 py-1">
{LIBRARY_LIST_SORT_OPTIONS.map((option) => {
const isSelected = value === option.id
return (
<button
key={option.id}
type="button"
onClick={() => handleSelect(option)}
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-xs font-medium transition-colors ${
isSelected
? 'bg-brand-50 font-semibold text-brand-700'
: 'text-ink-600 hover:bg-sand-50 hover:text-brand-600'
}`}
>
<div className="flex items-center gap-2">
{isSelected && <Check size={13} className="text-brand-600" />}
<span className={isSelected ? '' : 'pl-5'}>{option.label}</span>
</div>
{isSelected && (
<div className="flex items-center text-brand-600">
{order === 'asc' ? (
<ArrowUp size={14} className="stroke-[2.5]" />
) : (
<ArrowDown size={14} className="stroke-[2.5]" />
)}
</div>
)}
</button>
)
})}
</div>
</div>
)}
</div>
)
}
+74 -8
View File
@@ -1,12 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Layers, Plus, Settings2, X } from 'lucide-react'
import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react'
import { GripVertical, Layers, Plus, Settings2, X } from 'lucide-react'
import { MAX_LIBRARY_TAGS } from '../utils/libraryTags'
import { ALL_TAG_ID, MAX_LIBRARY_TAGS } from '../utils/libraryTags'
import type { LibraryTagTab } from '../utils/libraryTags'
/**
* 媒体库标签栏:固定「全部」+ 每个标签一个页签,选中后只展示该标签下的媒体库。
* 只是管理员/普通用户都能用的展示层过滤,不改动媒体库本身。
* 传入 onReorder 时标签页签可拖拽排序:拖拽只作用于「全部」之后的标签,
* 「全部」永远固定在第一位,不参与拖动。
*/
export function LibraryTagBar({
tabs,
@@ -14,6 +16,7 @@ export function LibraryTagBar({
onSelect,
onCreate,
onManage,
onReorder,
manageLabel = '管理标签',
showCreate = true,
busy = false,
@@ -23,6 +26,8 @@ export function LibraryTagBar({
onSelect: (tagId: string) => void
onCreate?: (name: string) => void
onManage?: () => void
/** 拖拽排序的落点:收到的是拖拽后的标签名顺序(不含「全部」)。 */
onReorder?: (orderedNames: string[]) => void | Promise<void>
manageLabel?: string
showCreate?: boolean
busy?: boolean
@@ -32,6 +37,8 @@ export function LibraryTagBar({
const inputRef = useRef<HTMLInputElement | null>(null)
// 同一帧内 blur 与 Enter 都可能触发提交:用一个标记保证只提交一次。
const submittedRef = useRef(false)
const [draggingId, setDraggingId] = useState<string | null>(null)
const [dragOverId, setDragOverId] = useState<string | null>(null)
useEffect(() => {
if (creating) {
@@ -42,6 +49,25 @@ export function LibraryTagBar({
const hasTags = useMemo(() => tabs.some((tab) => !tab.isAll), [tabs])
/** 拖拽结束后计算落点顺序并回调。 */
const handleDrop = (e: DragEvent, overTabId: string) => {
e.preventDefault()
const fromId = draggingId ?? e.dataTransfer.getData('text/plain')
setDraggingId(null)
setDragOverId(null)
if (!onReorder || !fromId || fromId === ALL_TAG_ID || fromId === overTabId) return
const names = tabs
.filter((tab) => !tab.isAll)
.map((tab) => tab.id)
const fromIndex = names.indexOf(fromId)
const overIndex = names.indexOf(overTabId)
if (fromIndex < 0 || overIndex < 0) return
const next = [...names]
const [moved] = next.splice(fromIndex, 1)
next.splice(overIndex, 0, moved)
void onReorder(next)
}
const submit = () => {
if (submittedRef.current) return
submittedRef.current = true
@@ -63,24 +89,64 @@ export function LibraryTagBar({
<div className="flex flex-wrap items-center gap-1.5">
{tabs.map((tab) => {
const active = tab.id === selectedTagId
const draggable = !tab.isAll && Boolean(onReorder)
return (
<button
key={tab.id}
type="button"
onClick={() => onSelect(tab.id)}
aria-pressed={active}
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-bold transition-colors ${
active
? 'border-brand-500/60 bg-brand-500/10 text-brand-600'
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)] text-[var(--app-subtle)] hover:border-brand-500/40 hover:text-[var(--app-text)]'
draggable={draggable}
onDragStart={(e) => {
if (!draggable) return
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', tab.id)
setDraggingId(tab.id)
setDragOverId(null)
}}
onDragOver={(e) => {
if (draggingId === tab.id || tab.isAll) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
if (dragOverId !== tab.id) setDragOverId(tab.id)
}}
onDragLeave={() => {
setDragOverId((prev) => (prev === tab.id ? null : prev))
}}
onDrop={(e) => handleDrop(e, tab.id)}
onDragEnd={() => {
setDraggingId(null)
setDragOverId(null)
}}
className={`group inline-flex cursor-default items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-bold transition-colors ${
draggingId === tab.id
? 'opacity-40'
: dragOverId === tab.id
? 'border-brand-500 bg-brand-500/10 text-brand-600'
: active
? 'border-brand-500/60 bg-brand-500/10 text-brand-600'
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)] text-[var(--app-subtle)] hover:border-brand-500/40 hover:text-[var(--app-text)]'
}`}
title={tab.isAll ? '显示全部媒体库' : `只看「${tab.name}」标签下的媒体库`}
title={
tab.isAll
? '显示全部媒体库'
: draggable
? `只看「${tab.name}」标签下的媒体库(拖拽可调整顺序)`
: `只看「${tab.name}」标签下的媒体库`
}
>
{tab.isAll && <Layers size={13} />}
<span className="max-w-[8rem] truncate">{tab.name}</span>
<span className={`font-mono text-[10px] ${active ? 'text-brand-500' : 'text-[var(--app-muted)]'}`}>
{tab.count}
</span>
{draggable && (
<GripVertical
size={12}
className="shrink-0 cursor-grab text-[var(--app-muted)] opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
aria-hidden
/>
)}
</button>
)
})}
+15 -1
View File
@@ -11,6 +11,7 @@ import {
normalizeTagName,
readCachedLibraryTags,
readSelectedTagId,
reorderLibraryTags,
resolveSelectedTagId,
saveLibraryTags,
writeCachedLibraryTags,
@@ -32,6 +33,8 @@ export type UseLibraryTagsResult = {
setSelectedTagId: (tagId: string) => void
createTag: (name: string) => Promise<string>
renameTag: (from: string, to: string) => Promise<void>
/** 按给定标签名顺序重排标签栏(拖拽排序的落点)。 */
reorderTags: (orderedNames: string[]) => Promise<void>
removeTag: (name: string) => Promise<void>
/** 覆盖某个标签下的媒体库集合(保持传入顺序)。 */
setTagLibraries: (name: string, libraryIds: string[]) => Promise<void>
@@ -103,13 +106,16 @@ export function useLibraryTags(): UseLibraryTagsResult {
}, [])
// 选中的标签可能被删除,落回「全部」以免标签栏空白。
// 注意:标签首次加载完成前 tags 为空集合,此时不能据此判定标签已被删除,
// 否则从子页面返回时,持久化的选中标签会被误重置为「全部」。
useEffect(() => {
if (loading) return
const resolved = resolveSelectedTagId(tags, selectedTagId)
if (resolved !== selectedTagId) {
selectedRef.current = resolved
setSelectedTagIdState(resolved)
}
}, [tags, selectedTagId])
}, [loading, tags, selectedTagId])
const setSelectedTagId = useCallback((tagId: string) => {
const next = tagId && tagId !== ALL_TAG_ID ? tagId : ALL_TAG_ID
@@ -193,6 +199,13 @@ export function useLibraryTags(): UseLibraryTagsResult {
[mutate, setSelectedTagId],
)
const reorderTags = useCallback(
async (orderedNames: string[]) => {
await mutate((current) => reorderLibraryTags(current, orderedNames))
},
[mutate],
)
const removeTag = useCallback(
async (name: string) => {
await mutate((current) => current.filter((tag) => tag.name !== name))
@@ -239,6 +252,7 @@ export function useLibraryTags(): UseLibraryTagsResult {
setSelectedTagId,
createTag,
renameTag,
reorderTags,
removeTag,
setTagLibraries,
assignLibrary,
+3
View File
@@ -340,6 +340,9 @@ export function HomePage() {
onCreate={(name) => {
void libraryTags.createTag(name)
}}
onReorder={(names) => {
void libraryTags.reorderTags(names)
}}
onManage={() => setLibraryTagsOpen(true)}
busy={libraryTags.saving}
/>
+40 -17
View File
@@ -17,22 +17,32 @@ 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() {
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
const [libraries, setLibraries] = useState<Library[]>([])
// 会话缓存命中时首屏即用缓存渲染(避免先闪一帧占位再被撑高),
// 这样 useScrollMemory 的布局期恢复才能在返回列表时立刻落到原位置。
const [libraries, setLibraries] = useState<Library[]>(() => peekLibraries() ?? [])
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
const { pinnedIds, togglePin } = usePinnedLibraries()
const libraryTags = useLibraryTags()
const [loading, setLoading] = useState(true)
const [loading, setLoading] = useState(() => peekLibraries() === null)
const [repairing, setRepairing] = useState(false)
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
const [repairMsg, setRepairMsg] = useState('')
const [sortField, setSortField] = useState<LibraryListSortField>(() => readLibraryListSort().field)
const [sortOrder, setSortOrder] = useState<LibraryListSortOrder>(() => readLibraryListSort().order)
// 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
@@ -146,7 +156,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)
@@ -167,7 +186,7 @@ export function LibrariesPage() {
[taggedPreviews],
)
if (loading || pinnedLoading) {
if (loading) {
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
}
@@ -180,6 +199,9 @@ export function LibrariesPage() {
repairMsg={repairMsg}
repairEpisodeArtwork={repairEpisodeArtwork}
repairing={repairing}
sortField={sortField}
sortOrder={sortOrder}
onSortChange={handleSortChange}
onRepairEpisodeArtworkChange={setRepairEpisodeArtwork}
onRepairRescrape={handleRepairRescrape}
onManageLibraries={handleManageLibraries}
@@ -192,6 +214,9 @@ export function LibrariesPage() {
onCreate={(name) => {
void libraryTags.createTag(name)
}}
onReorder={(names) => {
void libraryTags.reorderTags(names)
}}
onManage={() => setLibraryTagsOpen(true)}
busy={libraryTags.saving}
/>
@@ -228,19 +253,17 @@ export function LibrariesPage() {
)
}
/** 按选中标签过滤媒体库预览(ALL_TAG_ID 时原样返回,保留置顶/手动排序)。 */
/**
* 按选中标签过滤媒体库预览:复用共享的成员筛选(保持输入顺序),
* 这样排序下拉(含倒序)在标签页与「全部」页行为一致。
*/
function filterPreviewsByTag(previews: LibraryPreview[], tags: LibraryTag[], tagId: string): LibraryPreview[] {
if (!tagId || tagId === ALL_TAG_ID) return previews
const ordered = filterLibrariesByTag(
previews.map((preview) => ({ id: preview.library.id })),
tags,
tagId,
)
const byId = new Map(previews.map((preview) => [preview.library.id, preview]))
const out: LibraryPreview[] = []
for (const item of ordered) {
const preview = byId.get(item.id)
if (preview) out.push(preview)
const kept: LibraryPreview[] = []
for (const library of filterLibrariesByTag(previews.map((preview) => preview.library), tags, tagId)) {
const preview = byId.get(library.id)
if (preview) kept.push(preview)
}
return out
return kept
}
+47 -36
View File
@@ -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()} 个条目。每个目录直接展示最新入库内容。
</p>
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
<EpisodeArtworkToggle
checked={repairEpisodeArtwork}
onChange={onRepairEpisodeArtworkChange}
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
className="h-9 sm:h-10 text-xs sm:text-sm"
/>
<button
type="button"
onClick={onRepairRescrape}
disabled={repairing}
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
>
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
{repairing ? '正在启动…' : '全库修复+重刮'}
</button>
<Link
to="/scraper/queue"
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
title="查看正在进行的刮削任务与进度"
>
<Sparkles size={14} className="text-brand-500" />
<span>刮削队列</span>
</Link>
<button
type="button"
onClick={onManageLibraries}
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
>
管理媒体库
</button>
</div>
)}
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<LibraryListSortDropdown value={sortField} order={sortOrder} onChange={onSortChange} />
{isAdmin && (
<>
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
<EpisodeArtworkToggle
checked={repairEpisodeArtwork}
onChange={onRepairEpisodeArtworkChange}
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
className="h-9 sm:h-10 text-xs sm:text-sm"
/>
<button
type="button"
onClick={onRepairRescrape}
disabled={repairing}
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
>
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
{repairing ? '正在启动…' : '全库修复+重刮'}
</button>
<Link
to="/scraper/queue"
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
title="查看正在进行的刮削任务与进度"
>
<Sparkles size={14} className="text-brand-500" />
<span>刮削队列</span>
</Link>
<button
type="button"
onClick={onManageLibraries}
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
>
管理媒体库
</button>
</>
)}
</div>
</div>
)
}
+100
View File
@@ -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<T extends { library: Library }>(
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]
}
+30 -2
View File
@@ -11,6 +11,7 @@ import {
filterLibrariesForTagging,
normalizeLibraryTags,
normalizeTagName,
reorderLibraryTags,
resolveSelectedTagId,
tagNameError,
} from './libraryTags.ts'
@@ -49,10 +50,10 @@ check('tag tab keeps creation order', tabs[1].name === '动画' && tabs[2].name
check('tag count ignores unavailable libraries', tabs[1].count === 1)
check(
'filtering keeps tag order and drops unknown ids',
'filtering drops unknown ids and keeps input order',
filterLibrariesByTag(libs, [{ name: '动画', library_ids: ['lib-c', 'missing', 'lib-a'] }], '动画')
.map((lib) => lib.id)
.join(',') === 'lib-c,lib-a',
.join(',') === 'lib-a,lib-c',
)
check('全部 returns every library in original order', filterLibrariesByTag(libs, [], ALL_TAG_ID).length === 3)
check('unknown tag yields no libraries', filterLibrariesByTag(libs, [], 'nope').length === 0)
@@ -135,4 +136,31 @@ check(
tagNameError('动画', [{ name: '动画', library_ids: [] }], '动画') === '',
)
const reorderable = [
{ name: '动画', library_ids: [] },
{ name: '电影', library_ids: [] },
{ name: '音乐', library_ids: [] },
]
check(
'reorder moves a tag to the requested slot',
reorderLibraryTags(reorderable, ['电影', '动画', '音乐']).map((tag) => tag.name).join(',') === '电影,动画,音乐',
)
check(
'reorder can move a tag to the front',
reorderLibraryTags(reorderable, ['音乐', '动画', '电影']).map((tag) => tag.name).join(',') === '音乐,动画,电影',
)
check(
'names missing from the order keep relative order at the end',
reorderLibraryTags(reorderable, ['电影']).map((tag) => tag.name).join(',') === '电影,动画,音乐',
)
check(
'empty order leaves tags untouched',
reorderLibraryTags(reorderable, []).map((tag) => tag.name).join(',') === '动画,电影,音乐',
)
check('reorder ignores extra unknown names', reorderLibraryTags(reorderable, ['不存在', '音乐']).map((tag) => tag.name).join(',') === '音乐,动画,电影')
check(
'reordering with fewer than two tags is a no-op',
reorderLibraryTags([{ name: '动画', library_ids: [] }], []).map((tag) => tag.name).join(',') === '动画',
)
console.log('libraryTags.test.ts ok')
+22 -11
View File
@@ -164,8 +164,9 @@ export function isLibraryInTag(tags: LibraryTag[], tagId: string, libraryId: str
}
/**
* 按标签过滤媒体库:`tagId` 为「全部」时原样返回(保持原有置顶/手动排序),
* 否则只保留该标签下的库,并保持标签里记录的顺序。
* 按标签过滤媒体库:`tagId` 为「全部」时原样返回。
* 只做成员筛选、保持输入顺序,展示顺序(置顶、排序下拉等)由调用方决定;
* 标签里记录的 library_ids 顺序只表示归属,不作为展示排序。
*/
export function filterLibrariesByTag<T extends { id: string }>(
libraries: T[],
@@ -173,15 +174,9 @@ export function filterLibrariesByTag<T extends { id: string }>(
tagId: string,
): T[] {
if (!tagId || tagId === ALL_TAG_ID) return libraries
const ids = tagLibraryIds(tags, tagId)
if (ids.length === 0) return []
const byId = new Map(libraries.map((lib) => [lib.id, lib]))
const out: T[] = []
for (const id of ids) {
const lib = byId.get(id)
if (lib) out.push(lib)
}
return out
const ids = new Set(tagLibraryIds(tags, tagId))
if (ids.size === 0) return []
return libraries.filter((lib) => ids.has(lib.id))
}
/** 生成标签栏模型:首项固定为「全部」,其后按标签顺序追加。 */
@@ -202,6 +197,22 @@ export function buildLibraryTagTabs(libraries: Library[], tags: LibraryTag[]): L
return tabs
}
/**
* 按给定标签名顺序重排标签栏:`orderedNames` 是期望的标签名顺序(通常来自拖拽后的顺序)。
* 未出现在顺序里的标签(例如新建、旧数据并发写入)保持原相对顺序,追加到末尾,绝不丢失。
*/
export function reorderLibraryTags(tags: LibraryTag[], orderedNames: string[]): LibraryTag[] {
if (tags.length < 2) return tags
const rank = new Map(orderedNames.map((name, index) => [name, index]))
const ranked: LibraryTag[] = []
const rest: LibraryTag[] = []
for (const tag of tags) {
;(rank.has(tag.name) ? ranked : rest).push(tag)
}
ranked.sort((a, b) => (rank.get(a.name) ?? 0) - (rank.get(b.name) ?? 0))
return [...ranked, ...rest]
}
/** 选中的标签是否仍存在;被删除或被过滤掉时回落到「全部」。 */
export function resolveSelectedTagId(tags: LibraryTag[], selectedId: string): string {
if (!selectedId || selectedId === ALL_TAG_ID) return ALL_TAG_ID