From f0a6b630c63323f374b8302f7c1f8e5f014161ec Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:19:46 +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 --- web/src/components/LibraryTagBar.tsx | 82 +++++++++++++++++++++++++--- web/src/hooks/useLibraryTags.ts | 16 +++++- web/src/pages/HomePage.tsx | 3 + web/src/pages/LibrariesPage.tsx | 33 ++++++----- web/src/utils/libraryTags.test.ts | 32 ++++++++++- web/src/utils/libraryTags.ts | 33 +++++++---- 6 files changed, 162 insertions(+), 37 deletions(-) diff --git a/web/src/components/LibraryTagBar.tsx b/web/src/components/LibraryTagBar.tsx index b5f8b6b..89d7362 100644 --- a/web/src/components/LibraryTagBar.tsx +++ b/web/src/components/LibraryTagBar.tsx @@ -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 manageLabel?: string showCreate?: boolean busy?: boolean @@ -32,6 +37,8 @@ export function LibraryTagBar({ const inputRef = useRef(null) // 同一帧内 blur 与 Enter 都可能触发提交:用一个标记保证只提交一次。 const submittedRef = useRef(false) + const [draggingId, setDraggingId] = useState(null) + const [dragOverId, setDragOverId] = useState(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({
{tabs.map((tab) => { const active = tab.id === selectedTagId + const draggable = !tab.isAll && Boolean(onReorder) return ( ) })} diff --git a/web/src/hooks/useLibraryTags.ts b/web/src/hooks/useLibraryTags.ts index f53214f..0658358 100644 --- a/web/src/hooks/useLibraryTags.ts +++ b/web/src/hooks/useLibraryTags.ts @@ -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 renameTag: (from: string, to: string) => Promise + /** 按给定标签名顺序重排标签栏(拖拽排序的落点)。 */ + reorderTags: (orderedNames: string[]) => Promise removeTag: (name: string) => Promise /** 覆盖某个标签下的媒体库集合(保持传入顺序)。 */ setTagLibraries: (name: string, libraryIds: string[]) => Promise @@ -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, diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index 36af305..05c3358 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -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} /> diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx index f67a841..69bcb52 100644 --- a/web/src/pages/LibrariesPage.tsx +++ b/web/src/pages/LibrariesPage.tsx @@ -31,11 +31,13 @@ import { partitionPreviewIDs } from '../utils/remoteEmby' export function LibrariesPage() { const isAdmin = useAuthStore((state) => state.user?.role === 'admin') - const [libraries, setLibraries] = useState([]) + // 会话缓存命中时首屏即用缓存渲染(避免先闪一帧占位再被撑高), + // 这样 useScrollMemory 的布局期恢复才能在返回列表时立刻落到原位置。 + const [libraries, setLibraries] = useState(() => peekLibraries() ?? []) const [libraryData, setLibraryData] = useState>({}) - 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('') @@ -184,7 +186,7 @@ export function LibrariesPage() { [taggedPreviews], ) - if (loading || pinnedLoading) { + if (loading) { return

媒体库加载中…

} @@ -212,6 +214,9 @@ export function LibrariesPage() { onCreate={(name) => { void libraryTags.createTag(name) }} + onReorder={(names) => { + void libraryTags.reorderTags(names) + }} onManage={() => setLibraryTagsOpen(true)} busy={libraryTags.saving} /> @@ -248,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 } diff --git a/web/src/utils/libraryTags.test.ts b/web/src/utils/libraryTags.test.ts index d28b884..366e29d 100644 --- a/web/src/utils/libraryTags.test.ts +++ b/web/src/utils/libraryTags.test.ts @@ -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') diff --git a/web/src/utils/libraryTags.ts b/web/src/utils/libraryTags.ts index dddf23c..479837d 100644 --- a/web/src/utils/libraryTags.ts +++ b/web/src/utils/libraryTags.ts @@ -164,8 +164,9 @@ export function isLibraryInTag(tags: LibraryTag[], tagId: string, libraryId: str } /** - * 按标签过滤媒体库:`tagId` 为「全部」时原样返回(保持原有置顶/手动排序), - * 否则只保留该标签下的库,并保持标签里记录的顺序。 + * 按标签过滤媒体库:`tagId` 为「全部」时原样返回。 + * 只做成员筛选、保持输入顺序,展示顺序(置顶、排序下拉等)由调用方决定; + * 标签里记录的 library_ids 顺序只表示归属,不作为展示排序。 */ export function filterLibrariesByTag( libraries: T[], @@ -173,15 +174,9 @@ export function filterLibrariesByTag( 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