From 5faa6975d00c943c302191be33f0d37e4fc42f74 Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Wed, 16 Sep 2026 13:50:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ManageLibraryTagsDialogView.tsx | 274 +++++++++++++++--- .../components/manageLibraryTagsDialog.tsx | 27 -- web/src/hooks/useLibraryTags.ts | 11 + web/src/pages/HomePage.tsx | 29 +- web/src/pages/LibrariesPage.tsx | 31 +- web/src/utils/libraryTags.test.ts | 44 +++ web/src/utils/libraryTags.ts | 64 ++++ 7 files changed, 381 insertions(+), 99 deletions(-) delete mode 100644 web/src/components/manageLibraryTagsDialog.tsx diff --git a/web/src/components/ManageLibraryTagsDialogView.tsx b/web/src/components/ManageLibraryTagsDialogView.tsx index 1a4295e..5dee860 100644 --- a/web/src/components/ManageLibraryTagsDialogView.tsx +++ b/web/src/components/ManageLibraryTagsDialogView.tsx @@ -1,10 +1,16 @@ -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Check, Loader2, Pencil, Plus, Tag, Trash2, X } from 'lucide-react' import toast from 'react-hot-toast' import { confirmAction } from './confirmAction' import type { Library } from '../types' -import { MAX_LIBRARY_TAGS, normalizeTagName, tagNameError, type LibraryTag } from '../utils/libraryTags' +import { + MAX_LIBRARY_TAGS, + filterLibrariesForTagging, + normalizeTagName, + tagNameError, + type LibraryTag, +} from '../utils/libraryTags' export type ManageLibraryTagsDialogProps = { tags: LibraryTag[] @@ -13,13 +19,21 @@ export type ManageLibraryTagsDialogProps = { onCreate: (name: string) => Promise onRename: (from: string, to: string) => Promise onRemove: (name: string) => Promise + /** 单个媒体库归类;tagName 为空表示移出所有标签。 */ onAssign: (libraryId: string, tagName: string) => Promise + /** 批量归类;tagName 为空表示批量移出所有标签。一次写入。 */ + onAssignBatch: (libraryIds: string[], tagName: string) => Promise onClose: () => void } +/** 「未分类」在筛选/批量目标里的哨兵值,与标签名不会冲突。 */ +const UNTAGGED = '__untagged__' + /** - * 标签管理对话框:左侧维护标签(新建 / 重命名 / 删除),右侧把每个媒体库归入 + * 标签管理对话框:左侧维护标签(新建 / 重命名 / 删除),右侧把媒体库归入 * 某个标签。一个媒体库同时只属于一个标签,未选择即为「未分类」。 + * + * 右侧支持搜索、按当前标签筛选、多选后一次性批量归类,避免逐条点击下拉框。 */ export function ManageLibraryTagsDialogView({ tags, @@ -29,12 +43,18 @@ export function ManageLibraryTagsDialogView({ onRename, onRemove, onAssign, + onAssignBatch, onClose, }: ManageLibraryTagsDialogProps) { const [draftName, setDraftName] = useState('') const [renaming, setRenaming] = useState('') const [renameDraft, setRenameDraft] = useState('') const [busyLibrary, setBusyLibrary] = useState('') + const [keyword, setKeyword] = useState('') + const [tagFilter, setTagFilter] = useState('') + const [selectedIds, setSelectedIds] = useState([]) + const [batchTag, setBatchTag] = useState('') + const [applying, setApplying] = useState(false) useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -54,6 +74,58 @@ export function ManageLibraryTagsDialogView({ return map }, [tags]) + // 标签被删除/重命名后,批量目标与筛选值可能已经失效,落回默认项。 + useEffect(() => { + if (batchTag && batchTag !== UNTAGGED && !tags.some((tag) => tag.name === batchTag)) { + setBatchTag('') + } + if (tagFilter && tagFilter !== UNTAGGED && !tags.some((tag) => tag.name === tagFilter)) { + setTagFilter('') + } + }, [tags, batchTag, tagFilter]) + + // 媒体库被删除后清掉已选项,避免批量应用时带着不存在的 ID。 + useEffect(() => { + const available = new Set(libraries.map((lib) => lib.id)) + setSelectedIds((prev) => { + const next = prev.filter((id) => available.has(id)) + return next.length === prev.length ? prev : next + }) + }, [libraries]) + + const visibleLibraries = useMemo(() => { + const searched = filterLibrariesForTagging(libraries, keyword) + if (!tagFilter) return searched + return searched.filter((lib) => { + const current = tagByLibrary.get(lib.id) ?? '' + return tagFilter === UNTAGGED ? current === '' : current === tagFilter + }) + }, [libraries, keyword, tagFilter, tagByLibrary]) + + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]) + const visibleSelectedCount = visibleLibraries.reduce( + (sum, lib) => sum + (selectedSet.has(lib.id) ? 1 : 0), + 0, + ) + const allVisibleSelected = visibleLibraries.length > 0 && visibleSelectedCount === visibleLibraries.length + + const toggleLibrary = useCallback((libraryId: string) => { + setSelectedIds((prev) => + prev.includes(libraryId) ? prev.filter((id) => id !== libraryId) : [...prev, libraryId], + ) + }, []) + + const toggleVisible = useCallback(() => { + const visibleIds = visibleLibraries.map((lib) => lib.id) + setSelectedIds((prev) => { + const current = new Set(prev) + const everySelected = visibleIds.length > 0 && visibleIds.every((id) => current.has(id)) + if (everySelected) return prev.filter((id) => !visibleIds.includes(id)) + for (const id of visibleIds) current.add(id) + return Array.from(current) + }) + }, [visibleLibraries]) + const handleCreate = async () => { const error = tagNameError(draftName, tags) if (error) { @@ -103,6 +175,23 @@ export function ManageLibraryTagsDialogView({ } } + const handleApplyBatch = async () => { + if (selectedIds.length === 0 || applying) return + setApplying(true) + try { + await onAssignBatch(selectedIds, batchTag) + const count = selectedIds.length + toast.success( + batchTag ? `已将 ${count} 个媒体库归入「${batchTag}」` : `已将 ${count} 个媒体库移出标签`, + ) + setSelectedIds([]) + } finally { + setApplying(false) + } + } + + const busy = saving || applying + return (
event.stopPropagation()} >

管理标签

- {saving && } + {busy && }
-
+

标签

@@ -150,7 +239,7 @@ export function ManageLibraryTagsDialogView({ {tag.library_ids.length} @@ -248,45 +339,138 @@ export function ManageLibraryTagsDialogView({
-

- 媒体库归类({libraries.length}) -

+
+

+ 媒体库归类({libraries.length}) +

+ 勾选多个后可一次性设置标签 +
+ +
+ setKeyword(event.target.value)} + placeholder="搜索名称 / 路径 / 类型" + className="input-base h-9 min-w-[10rem] flex-1 !py-0 text-xs" + /> + +
+ +
+ + + 已选 {selectedIds.length} 个 + + + + {selectedIds.length > 0 && ( + + )} +
+
- {libraries.length === 0 ? ( + {visibleLibraries.length === 0 ? (

- 暂无可归类的媒体库。 + {libraries.length === 0 ? '暂无可归类的媒体库。' : '没有符合当前筛选条件的媒体库。'}

) : ( - libraries.map((library) => ( -
- { + const current = tagByLibrary.get(library.id) ?? '' + const checked = selectedSet.has(library.id) + return ( +
- {library.name} - - {busyLibrary === library.id && ( - - )} - -
- )) + toggleLibrary(library.id)} + disabled={applying} + className="h-4 w-4 shrink-0 accent-brand-400" + title="勾选后可批量设置标签" + /> + + {library.name} + + {busyLibrary === library.id && ( + + )} + +
+ ) + }) )}
diff --git a/web/src/components/manageLibraryTagsDialog.tsx b/web/src/components/manageLibraryTagsDialog.tsx deleted file mode 100644 index ceaa126..0000000 --- a/web/src/components/manageLibraryTagsDialog.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { createRoot } from 'react-dom/client' - -import { - ManageLibraryTagsDialogView, - type ManageLibraryTagsDialogProps, -} from './ManageLibraryTagsDialogView' - -export type OpenManageLibraryTagsDialogOptions = Omit - -/** 以独立根节点打开标签管理对话框,关闭后 resolve。 */ -export function openManageLibraryTagsDialog( - options: OpenManageLibraryTagsDialogOptions, -): Promise { - return new Promise((resolve) => { - const host = document.createElement('div') - document.body.appendChild(host) - const root = createRoot(host) - - const close = () => { - root.unmount() - host.remove() - resolve() - } - - root.render() - }) -} diff --git a/web/src/hooks/useLibraryTags.ts b/web/src/hooks/useLibraryTags.ts index 280a43f..f53214f 100644 --- a/web/src/hooks/useLibraryTags.ts +++ b/web/src/hooks/useLibraryTags.ts @@ -3,6 +3,7 @@ import toast from 'react-hot-toast' import { ALL_TAG_ID, + assignLibrariesToTag, attachLibraryToTag, dedupeLibraryTags, loadLibraryTags, @@ -36,6 +37,8 @@ export type UseLibraryTagsResult = { setTagLibraries: (name: string, libraryIds: string[]) => Promise /** 把媒体库挂到标签下;tagName 为空表示移出所有标签。 */ assignLibrary: (libraryId: string, tagName: string) => Promise + /** 批量把媒体库挂到标签下;tagName 为空表示批量移出所有标签。一次写入。 */ + assignLibraries: (libraryIds: string[], tagName: string) => Promise } /** @@ -220,6 +223,13 @@ export function useLibraryTags(): UseLibraryTagsResult { [mutate], ) + const assignLibraries = useCallback( + async (libraryIds: string[], tagName: string) => { + await mutate((current) => assignLibrariesToTag(current, libraryIds, tagName)) + }, + [mutate], + ) + return { tags, loading, @@ -232,6 +242,7 @@ export function useLibraryTags(): UseLibraryTagsResult { removeTag, setTagLibraries, assignLibrary, + assignLibraries, } } diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index e720442..36af305 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -8,7 +8,7 @@ import type { SeriesCard } from '../utils/groupSeries' import { fetchLibraries, peekLibraries } from '../utils/libraryCache' import { buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags' import { LibraryTagBar } from '../components/LibraryTagBar' -import { openManageLibraryTagsDialog } from '../components/manageLibraryTagsDialog' +import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView' import { useLibraryTags } from '../hooks/useLibraryTags' import { usePinnedLibraries } from '../hooks/usePinnedLibraries' import { sortByPinnedIds } from '../utils/pinnedLibraries' @@ -103,17 +103,7 @@ export function HomePage() { [sortedLibraries, libraryTags.tags, effectiveTagId], ) - const handleManageTags = useCallback(() => { - void openManageLibraryTagsDialog({ - tags: libraryTags.tags, - libraries, - saving: libraryTags.saving, - onCreate: libraryTags.createTag, - onRename: libraryTags.renameTag, - onRemove: libraryTags.removeTag, - onAssign: libraryTags.assignLibrary, - }) - }, [libraries, libraryTags]) + const [libraryTagsOpen, setLibraryTagsOpen] = useState(false) // 按需拉取卡片预览管理。同一个库可能先以 4 张封面用于入口网格, // 稍后需要 10 张用于内容横排,因此缓存的是已加载数量而不是简单布尔值。 @@ -350,9 +340,22 @@ export function HomePage() { onCreate={(name) => { void libraryTags.createTag(name) }} - onManage={handleManageTags} + onManage={() => setLibraryTagsOpen(true)} busy={libraryTags.saving} /> + {libraryTagsOpen && ( + setLibraryTagsOpen(false)} + /> + )} {taggedLibraries.length === 0 ? (

「{effectiveTagId}」标签下还没有媒体库,可在「管理标签」里把媒体库归入该标签。 diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx index 5914b03..8e898e7 100644 --- a/web/src/pages/LibrariesPage.tsx +++ b/web/src/pages/LibrariesPage.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { libraryAPI } from '../api/library' import { toolsAPI } from '../api/tools' import { LibraryTagBar } from '../components/LibraryTagBar' -import { openManageLibraryTagsDialog } from '../components/manageLibraryTagsDialog' +import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView' import { openManageLibrariesDialog } from '../components/manageLibrariesDialog' import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference' import { useLibraryTags } from '../hooks/useLibraryTags' @@ -152,18 +152,7 @@ export function LibrariesPage() { void togglePin(libraryId) }, [togglePin]) - const handleManageTags = useCallback(() => { - void openManageLibraryTagsDialog({ - tags: libraryTags.tags, - libraries, - saving: libraryTags.saving, - onCreate: libraryTags.createTag, - onRename: libraryTags.renameTag, - onRemove: libraryTags.removeTag, - onAssign: libraryTags.assignLibrary, - }) - }, [libraries, libraryTags]) - + const [libraryTagsOpen, setLibraryTagsOpen] = useState(false) const effectiveTagId = libraryTags.selectedTagId const tagTabs = useMemo( () => buildLibraryTagTabs(sortedPreviews.map((preview) => preview.library), libraryTags.tags), @@ -203,10 +192,24 @@ export function LibrariesPage() { onCreate={(name) => { void libraryTags.createTag(name) }} - onManage={handleManageTags} + onManage={() => setLibraryTagsOpen(true)} busy={libraryTags.saving} /> + {libraryTagsOpen && ( + setLibraryTagsOpen(false)} + /> + )} + {previews.length === 0 ? ( ) : taggedPreviews.length === 0 ? ( diff --git a/web/src/utils/libraryTags.test.ts b/web/src/utils/libraryTags.test.ts index de69038..d28b884 100644 --- a/web/src/utils/libraryTags.test.ts +++ b/web/src/utils/libraryTags.test.ts @@ -1,10 +1,14 @@ import { ALL_TAG_ID, + assignLibrariesToTag, + attachLibrariesToTag, attachLibraryToTag, buildLibraryTagTabs, dedupeLibraryTags, + detachLibrariesFromTag, detachLibraryFromTag, filterLibrariesByTag, + filterLibrariesForTagging, normalizeLibraryTags, normalizeTagName, resolveSelectedTagId, @@ -84,6 +88,46 @@ const detached = detachLibraryFromTag( ) check('detach removes only the requested library', detached[0].library_ids.join(',') === 'b') +const batchAttached = attachLibrariesToTag( + [ + { name: '动画', library_ids: ['a', 'x'] }, + { name: '电影', library_ids: ['b'] }, + ], + ['b', 'x', 'b', ' missing '], + '动画', +) +check('batch attach moves every id out of its old tag', batchAttached[1].library_ids.length === 0) +check('batch attach keeps order and dedupes ids', batchAttached[0].library_ids.join(',') === 'a,b,x,missing') +check('batch attach ignores unknown target tag', attachLibrariesToTag([{ name: '动画', library_ids: [] }], ['a'], '不存在')[0].library_ids.length === 0) +check('batch attach with no ids is a no-op', attachLibrariesToTag([{ name: '动画', library_ids: ['a'] }], [], '动画')[0].library_ids.join(',') === 'a') + +const batchDetached = detachLibrariesFromTag( + [ + { name: '动画', library_ids: ['a', 'b'] }, + { name: '电影', library_ids: ['b', 'c'] }, + ], + ['b', 'c'], +) +check('batch detach removes ids from every tag', batchDetached[0].library_ids.join(',') === 'a') +check('batch detach clears emptied tags', batchDetached[1].library_ids.length === 0) +check( + 'assign with empty tag name clears everything', + assignLibrariesToTag([{ name: '动画', library_ids: ['a'] }], ['a'], '')[0].library_ids.length === 0, +) +check( + 'assign with a tag routes to attach', + assignLibrariesToTag([{ name: '动画', library_ids: [] }], ['a'], '动画')[0].library_ids.join(',') === 'a', +) + +const searchable = [ + { id: 'lib-a', name: '电影库', path: '/media/movies', type: 'movie' }, + { id: 'lib-b', name: 'TV Shows', path: '/media/tv', type: 'tv' }, +] +check('tagging search matches name', filterLibrariesForTagging(searchable as never, '电影').length === 1) +check('tagging search matches path', filterLibrariesForTagging(searchable as never, 'media/tv').length === 1) +check('tagging search matches type case-insensitively', filterLibrariesForTagging(searchable as never, 'MOVIE').length === 1) +check('tagging search with blank keyword returns all', filterLibrariesForTagging(searchable as never, ' ').length === 2) + check('blank name is rejected', tagNameError(' ', []) !== '') check('duplicate name is rejected', tagNameError('动画', [{ name: '动画', library_ids: [] }]) !== '') check( diff --git a/web/src/utils/libraryTags.ts b/web/src/utils/libraryTags.ts index 3399b51..dddf23c 100644 --- a/web/src/utils/libraryTags.ts +++ b/web/src/utils/libraryTags.ts @@ -232,3 +232,67 @@ export function detachLibraryFromTag(tags: LibraryTag[], libraryId: string, tagN tag.name === tagName ? { ...tag, library_ids: tag.library_ids.filter((id) => id !== libraryId) } : tag, ) } + +/** + * 批量把一个或多个媒体库挂到标签下:先从各自原标签移除,再按传入顺序追加, + * 保证「一库一标签」。整批只产生一次写入。 + */ +export function attachLibrariesToTag( + tags: LibraryTag[], + libraryIds: string[], + tagName: string, +): LibraryTag[] { + const target = findTagIdByName(tags, tagName) + const moving = normalizeLibraryIds(libraryIds) + if (!target || moving.length === 0) return tags + const movingSet = new Set(moving) + const next = tags.map((tag) => ({ + ...tag, + library_ids: tag.library_ids.filter((id) => !movingSet.has(id)), + })) + return next.map((tag) => + tag.name === target ? { ...tag, library_ids: [...tag.library_ids, ...moving] } : tag, + ) +} + +/** 批量把媒体库移出所有标签(即变为「未分类」)。 */ +export function detachLibrariesFromTag(tags: LibraryTag[], libraryIds: string[]): LibraryTag[] { + const moving = new Set(normalizeLibraryIds(libraryIds)) + if (moving.size === 0) return tags + return tags.map((tag) => ({ + ...tag, + library_ids: tag.library_ids.filter((id) => !moving.has(id)), + })) +} + +/** 批量归类:tagName 为空表示移出所有标签。 */ +export function assignLibrariesToTag( + tags: LibraryTag[], + libraryIds: string[], + tagName: string, +): LibraryTag[] { + return tagName ? attachLibrariesToTag(tags, libraryIds, tagName) : detachLibrariesFromTag(tags, libraryIds) +} + +/** 去掉空白与重复,保留首次出现的顺序。 */ +export function normalizeLibraryIds(libraryIds: unknown): string[] { + if (!Array.isArray(libraryIds)) return [] + const seen = new Set() + const out: string[] = [] + for (const raw of libraryIds) { + const trimmed = String(raw ?? '').trim() + if (!trimmed || seen.has(trimmed)) continue + seen.add(trimmed) + out.push(trimmed) + } + return out +} + +/** 媒体库列表的模糊匹配(名称 / 路径 / 类型),供标签管理里的搜索框使用。 */ +export function filterLibrariesForTagging(libraries: Library[], keyword: string): Library[] { + const needle = keyword.trim().toLowerCase() + if (!needle) return libraries + return libraries.filter((lib) => + `${lib.name} ${lib.path} ${lib.type}`.toLowerCase().includes(needle), + ) +}