mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
优化标签
This commit is contained in:
@@ -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 { Check, Loader2, Pencil, Plus, Tag, Trash2, X } from 'lucide-react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
import { confirmAction } from './confirmAction'
|
import { confirmAction } from './confirmAction'
|
||||||
import type { Library } from '../types'
|
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 = {
|
export type ManageLibraryTagsDialogProps = {
|
||||||
tags: LibraryTag[]
|
tags: LibraryTag[]
|
||||||
@@ -13,13 +19,21 @@ export type ManageLibraryTagsDialogProps = {
|
|||||||
onCreate: (name: string) => Promise<string>
|
onCreate: (name: string) => Promise<string>
|
||||||
onRename: (from: string, to: string) => Promise<void>
|
onRename: (from: string, to: string) => Promise<void>
|
||||||
onRemove: (name: string) => Promise<void>
|
onRemove: (name: string) => Promise<void>
|
||||||
|
/** 单个媒体库归类;tagName 为空表示移出所有标签。 */
|
||||||
onAssign: (libraryId: string, tagName: string) => Promise<void>
|
onAssign: (libraryId: string, tagName: string) => Promise<void>
|
||||||
|
/** 批量归类;tagName 为空表示批量移出所有标签。一次写入。 */
|
||||||
|
onAssignBatch: (libraryIds: string[], tagName: string) => Promise<void>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 「未分类」在筛选/批量目标里的哨兵值,与标签名不会冲突。 */
|
||||||
|
const UNTAGGED = '__untagged__'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标签管理对话框:左侧维护标签(新建 / 重命名 / 删除),右侧把每个媒体库归入
|
* 标签管理对话框:左侧维护标签(新建 / 重命名 / 删除),右侧把媒体库归入
|
||||||
* 某个标签。一个媒体库同时只属于一个标签,未选择即为「未分类」。
|
* 某个标签。一个媒体库同时只属于一个标签,未选择即为「未分类」。
|
||||||
|
*
|
||||||
|
* 右侧支持搜索、按当前标签筛选、多选后一次性批量归类,避免逐条点击下拉框。
|
||||||
*/
|
*/
|
||||||
export function ManageLibraryTagsDialogView({
|
export function ManageLibraryTagsDialogView({
|
||||||
tags,
|
tags,
|
||||||
@@ -29,12 +43,18 @@ export function ManageLibraryTagsDialogView({
|
|||||||
onRename,
|
onRename,
|
||||||
onRemove,
|
onRemove,
|
||||||
onAssign,
|
onAssign,
|
||||||
|
onAssignBatch,
|
||||||
onClose,
|
onClose,
|
||||||
}: ManageLibraryTagsDialogProps) {
|
}: ManageLibraryTagsDialogProps) {
|
||||||
const [draftName, setDraftName] = useState('')
|
const [draftName, setDraftName] = useState('')
|
||||||
const [renaming, setRenaming] = useState('')
|
const [renaming, setRenaming] = useState('')
|
||||||
const [renameDraft, setRenameDraft] = useState('')
|
const [renameDraft, setRenameDraft] = useState('')
|
||||||
const [busyLibrary, setBusyLibrary] = useState('')
|
const [busyLibrary, setBusyLibrary] = useState('')
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [tagFilter, setTagFilter] = useState('')
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||||
|
const [batchTag, setBatchTag] = useState('')
|
||||||
|
const [applying, setApplying] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -54,6 +74,58 @@ export function ManageLibraryTagsDialogView({
|
|||||||
return map
|
return map
|
||||||
}, [tags])
|
}, [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 handleCreate = async () => {
|
||||||
const error = tagNameError(draftName, tags)
|
const error = tagNameError(draftName, tags)
|
||||||
if (error) {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 backdrop-blur-sm sm:p-4"
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 backdrop-blur-sm sm:p-4"
|
||||||
@@ -111,14 +200,14 @@ export function ManageLibraryTagsDialogView({
|
|||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
className="flex h-[calc(100dvh-1rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-white/70 bg-[var(--app-panel)] shadow-2xl sm:h-[80vh] sm:rounded-3xl"
|
className="flex h-[calc(100dvh-1rem)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-white/70 bg-[var(--app-panel)] shadow-2xl sm:h-[82vh] sm:rounded-3xl"
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-4 py-3 sm:px-6 sm:py-4">
|
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-4 py-3 sm:px-6 sm:py-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tag size={18} className="text-brand-500" />
|
<Tag size={18} className="text-brand-500" />
|
||||||
<h3 className="font-display text-base font-bold text-[var(--app-text)] sm:text-lg">管理标签</h3>
|
<h3 className="font-display text-base font-bold text-[var(--app-text)] sm:text-lg">管理标签</h3>
|
||||||
{saving && <Loader2 size={14} className="animate-spin text-[var(--app-muted)]" />}
|
{busy && <Loader2 size={14} className="animate-spin text-[var(--app-muted)]" />}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -130,7 +219,7 @@ export function ManageLibraryTagsDialogView({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid flex-1 grid-cols-1 gap-4 overflow-hidden p-3 sm:p-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]">
|
<div className="grid flex-1 grid-cols-1 gap-4 overflow-hidden p-3 sm:p-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
|
||||||
<section className="flex min-h-0 flex-col gap-3">
|
<section className="flex min-h-0 flex-col gap-3">
|
||||||
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">标签</h4>
|
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">标签</h4>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -150,7 +239,7 @@ export function ManageLibraryTagsDialogView({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleCreate()}
|
onClick={() => void handleCreate()}
|
||||||
disabled={saving || !draftName.trim()}
|
disabled={busy || !draftName.trim()}
|
||||||
className="btn-outline shrink-0 !px-3 !py-2 text-xs"
|
className="btn-outline shrink-0 !px-3 !py-2 text-xs"
|
||||||
title="创建标签"
|
title="创建标签"
|
||||||
>
|
>
|
||||||
@@ -211,12 +300,14 @@ export function ManageLibraryTagsDialogView({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span
|
<button
|
||||||
className="min-w-0 flex-1 truncate text-sm font-bold text-[var(--app-text)]"
|
type="button"
|
||||||
title={tag.name}
|
onClick={() => setTagFilter(tag.name)}
|
||||||
|
className="min-w-0 flex-1 truncate text-left text-sm font-bold text-[var(--app-text)] transition-colors hover:text-brand-500"
|
||||||
|
title={`只看「${tag.name}」标签下的媒体库`}
|
||||||
>
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</span>
|
</button>
|
||||||
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
|
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
|
||||||
{tag.library_ids.length}
|
{tag.library_ids.length}
|
||||||
</span>
|
</span>
|
||||||
@@ -248,45 +339,138 @@ export function ManageLibraryTagsDialogView({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex min-h-0 flex-col gap-3 border-t border-[var(--app-border)] pt-4 md:border-l md:border-t-0 md:pl-4 md:pt-0">
|
<section className="flex min-h-0 flex-col gap-3 border-t border-[var(--app-border)] pt-4 md:border-l md:border-t-0 md:pl-4 md:pt-0">
|
||||||
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
媒体库归类({libraries.length})
|
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">
|
||||||
</h4>
|
媒体库归类({libraries.length})
|
||||||
|
</h4>
|
||||||
|
<span className="text-xs text-[var(--app-muted)]">勾选多个后可一次性设置标签</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
value={keyword}
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
placeholder="搜索名称 / 路径 / 类型"
|
||||||
|
className="input-base h-9 min-w-[10rem] flex-1 !py-0 text-xs"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={tagFilter}
|
||||||
|
onChange={(event) => setTagFilter(event.target.value)}
|
||||||
|
className="input-base h-9 w-32 shrink-0 !py-0 text-xs"
|
||||||
|
title="筛选媒体库"
|
||||||
|
>
|
||||||
|
<option value="">全部标签</option>
|
||||||
|
<option value={UNTAGGED}>未分类</option>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<option key={tag.name} value={tag.name}>
|
||||||
|
{tag.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleVisible}
|
||||||
|
disabled={visibleLibraries.length === 0}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2.5 py-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:border-brand-500/40 hover:text-brand-500 disabled:opacity-40"
|
||||||
|
title="全选 / 取消全选当前列表中的媒体库"
|
||||||
|
>
|
||||||
|
<Check size={13} />
|
||||||
|
{allVisibleSelected ? '取消全选' : '全选'}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-semibold text-[var(--app-subtle)]">
|
||||||
|
已选 {selectedIds.length} 个
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={batchTag}
|
||||||
|
onChange={(event) => setBatchTag(event.target.value)}
|
||||||
|
className="input-base ml-auto h-8 w-36 shrink-0 !py-0 text-xs"
|
||||||
|
title="选择要批量设置的标签"
|
||||||
|
>
|
||||||
|
<option value="">未分类(移出标签)</option>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<option key={tag.name} value={tag.name}>
|
||||||
|
{tag.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleApplyBatch()}
|
||||||
|
disabled={busy || selectedIds.length === 0}
|
||||||
|
className="btn-outline shrink-0 !px-3 !py-1.5 text-xs"
|
||||||
|
title="把选中媒体库的标签一次性设为左侧所选值"
|
||||||
|
>
|
||||||
|
{applying && <Loader2 size={13} className="animate-spin" />}
|
||||||
|
应用到已选
|
||||||
|
</button>
|
||||||
|
{selectedIds.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedIds([])}
|
||||||
|
disabled={applying}
|
||||||
|
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
|
||||||
|
title="清空选择"
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
|
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
|
||||||
{libraries.length === 0 ? (
|
{visibleLibraries.length === 0 ? (
|
||||||
<p className="rounded-xl border border-dashed border-[var(--app-border)] px-3 py-6 text-center text-xs text-[var(--app-muted)]">
|
<p className="rounded-xl border border-dashed border-[var(--app-border)] px-3 py-6 text-center text-xs text-[var(--app-muted)]">
|
||||||
暂无可归类的媒体库。
|
{libraries.length === 0 ? '暂无可归类的媒体库。' : '没有符合当前筛选条件的媒体库。'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
libraries.map((library) => (
|
visibleLibraries.map((library) => {
|
||||||
<div
|
const current = tagByLibrary.get(library.id) ?? ''
|
||||||
key={library.id}
|
const checked = selectedSet.has(library.id)
|
||||||
className="flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-2"
|
return (
|
||||||
>
|
<div
|
||||||
<span
|
key={library.id}
|
||||||
className="min-w-0 flex-1 truncate text-xs font-semibold text-[var(--app-text)]"
|
className={`flex items-center gap-2 rounded-xl border px-2.5 py-2 transition-colors ${
|
||||||
title={library.name}
|
checked
|
||||||
|
? 'border-brand-500/50 bg-brand-500/5'
|
||||||
|
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{library.name}
|
<input
|
||||||
</span>
|
type="checkbox"
|
||||||
{busyLibrary === library.id && (
|
checked={checked}
|
||||||
<Loader2 size={13} className="shrink-0 animate-spin text-[var(--app-muted)]" />
|
onChange={() => toggleLibrary(library.id)}
|
||||||
)}
|
disabled={applying}
|
||||||
<select
|
className="h-4 w-4 shrink-0 accent-brand-400"
|
||||||
value={tagByLibrary.get(library.id) ?? ''}
|
title="勾选后可批量设置标签"
|
||||||
disabled={saving || busyLibrary === library.id}
|
/>
|
||||||
onChange={(event) => void handleAssign(library, event.target.value)}
|
<span
|
||||||
className="input-base h-8 w-32 shrink-0 !py-0 text-xs"
|
className="min-w-0 flex-1 truncate text-xs font-semibold text-[var(--app-text)]"
|
||||||
title="选择该媒体库所属标签"
|
title={library.path || library.name}
|
||||||
>
|
>
|
||||||
<option value="">未分类</option>
|
{library.name}
|
||||||
{tags.map((tag) => (
|
</span>
|
||||||
<option key={tag.name} value={tag.name}>
|
{busyLibrary === library.id && (
|
||||||
{tag.name}
|
<Loader2 size={13} className="shrink-0 animate-spin text-[var(--app-muted)]" />
|
||||||
</option>
|
)}
|
||||||
))}
|
<select
|
||||||
</select>
|
value={current}
|
||||||
</div>
|
disabled={busy || busyLibrary === library.id}
|
||||||
))
|
onChange={(event) => void handleAssign(library, event.target.value)}
|
||||||
|
className="input-base h-8 w-32 shrink-0 !py-0 text-xs"
|
||||||
|
title="选择该媒体库所属标签"
|
||||||
|
>
|
||||||
|
<option value="">未分类</option>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<option key={tag.name} value={tag.name}>
|
||||||
|
{tag.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { createRoot } from 'react-dom/client'
|
|
||||||
|
|
||||||
import {
|
|
||||||
ManageLibraryTagsDialogView,
|
|
||||||
type ManageLibraryTagsDialogProps,
|
|
||||||
} from './ManageLibraryTagsDialogView'
|
|
||||||
|
|
||||||
export type OpenManageLibraryTagsDialogOptions = Omit<ManageLibraryTagsDialogProps, 'onClose'>
|
|
||||||
|
|
||||||
/** 以独立根节点打开标签管理对话框,关闭后 resolve。 */
|
|
||||||
export function openManageLibraryTagsDialog(
|
|
||||||
options: OpenManageLibraryTagsDialogOptions,
|
|
||||||
): Promise<void> {
|
|
||||||
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(<ManageLibraryTagsDialogView {...options} onClose={close} />)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ import toast from 'react-hot-toast'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ALL_TAG_ID,
|
ALL_TAG_ID,
|
||||||
|
assignLibrariesToTag,
|
||||||
attachLibraryToTag,
|
attachLibraryToTag,
|
||||||
dedupeLibraryTags,
|
dedupeLibraryTags,
|
||||||
loadLibraryTags,
|
loadLibraryTags,
|
||||||
@@ -36,6 +37,8 @@ export type UseLibraryTagsResult = {
|
|||||||
setTagLibraries: (name: string, libraryIds: string[]) => Promise<void>
|
setTagLibraries: (name: string, libraryIds: string[]) => Promise<void>
|
||||||
/** 把媒体库挂到标签下;tagName 为空表示移出所有标签。 */
|
/** 把媒体库挂到标签下;tagName 为空表示移出所有标签。 */
|
||||||
assignLibrary: (libraryId: string, tagName: string) => Promise<void>
|
assignLibrary: (libraryId: string, tagName: string) => Promise<void>
|
||||||
|
/** 批量把媒体库挂到标签下;tagName 为空表示批量移出所有标签。一次写入。 */
|
||||||
|
assignLibraries: (libraryIds: string[], tagName: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -220,6 +223,13 @@ export function useLibraryTags(): UseLibraryTagsResult {
|
|||||||
[mutate],
|
[mutate],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const assignLibraries = useCallback(
|
||||||
|
async (libraryIds: string[], tagName: string) => {
|
||||||
|
await mutate((current) => assignLibrariesToTag(current, libraryIds, tagName))
|
||||||
|
},
|
||||||
|
[mutate],
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tags,
|
tags,
|
||||||
loading,
|
loading,
|
||||||
@@ -232,6 +242,7 @@ export function useLibraryTags(): UseLibraryTagsResult {
|
|||||||
removeTag,
|
removeTag,
|
||||||
setTagLibraries,
|
setTagLibraries,
|
||||||
assignLibrary,
|
assignLibrary,
|
||||||
|
assignLibraries,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-13
@@ -8,7 +8,7 @@ import type { SeriesCard } from '../utils/groupSeries'
|
|||||||
import { fetchLibraries, peekLibraries } from '../utils/libraryCache'
|
import { fetchLibraries, peekLibraries } from '../utils/libraryCache'
|
||||||
import { buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
|
import { buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
|
||||||
import { LibraryTagBar } from '../components/LibraryTagBar'
|
import { LibraryTagBar } from '../components/LibraryTagBar'
|
||||||
import { openManageLibraryTagsDialog } from '../components/manageLibraryTagsDialog'
|
import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView'
|
||||||
import { useLibraryTags } from '../hooks/useLibraryTags'
|
import { useLibraryTags } from '../hooks/useLibraryTags'
|
||||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||||
import { sortByPinnedIds } from '../utils/pinnedLibraries'
|
import { sortByPinnedIds } from '../utils/pinnedLibraries'
|
||||||
@@ -103,17 +103,7 @@ export function HomePage() {
|
|||||||
[sortedLibraries, libraryTags.tags, effectiveTagId],
|
[sortedLibraries, libraryTags.tags, effectiveTagId],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleManageTags = useCallback(() => {
|
const [libraryTagsOpen, setLibraryTagsOpen] = useState(false)
|
||||||
void openManageLibraryTagsDialog({
|
|
||||||
tags: libraryTags.tags,
|
|
||||||
libraries,
|
|
||||||
saving: libraryTags.saving,
|
|
||||||
onCreate: libraryTags.createTag,
|
|
||||||
onRename: libraryTags.renameTag,
|
|
||||||
onRemove: libraryTags.removeTag,
|
|
||||||
onAssign: libraryTags.assignLibrary,
|
|
||||||
})
|
|
||||||
}, [libraries, libraryTags])
|
|
||||||
|
|
||||||
// 按需拉取卡片预览管理。同一个库可能先以 4 张封面用于入口网格,
|
// 按需拉取卡片预览管理。同一个库可能先以 4 张封面用于入口网格,
|
||||||
// 稍后需要 10 张用于内容横排,因此缓存的是已加载数量而不是简单布尔值。
|
// 稍后需要 10 张用于内容横排,因此缓存的是已加载数量而不是简单布尔值。
|
||||||
@@ -350,9 +340,22 @@ export function HomePage() {
|
|||||||
onCreate={(name) => {
|
onCreate={(name) => {
|
||||||
void libraryTags.createTag(name)
|
void libraryTags.createTag(name)
|
||||||
}}
|
}}
|
||||||
onManage={handleManageTags}
|
onManage={() => setLibraryTagsOpen(true)}
|
||||||
busy={libraryTags.saving}
|
busy={libraryTags.saving}
|
||||||
/>
|
/>
|
||||||
|
{libraryTagsOpen && (
|
||||||
|
<ManageLibraryTagsDialogView
|
||||||
|
tags={libraryTags.tags}
|
||||||
|
libraries={libraries}
|
||||||
|
saving={libraryTags.saving}
|
||||||
|
onCreate={libraryTags.createTag}
|
||||||
|
onRename={libraryTags.renameTag}
|
||||||
|
onRemove={libraryTags.removeTag}
|
||||||
|
onAssign={libraryTags.assignLibrary}
|
||||||
|
onAssignBatch={libraryTags.assignLibraries}
|
||||||
|
onClose={() => setLibraryTagsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{taggedLibraries.length === 0 ? (
|
{taggedLibraries.length === 0 ? (
|
||||||
<p className="rounded-2xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-12 text-center text-sm text-[var(--app-muted)]">
|
<p className="rounded-2xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-12 text-center text-sm text-[var(--app-muted)]">
|
||||||
「{effectiveTagId}」标签下还没有媒体库,可在「管理标签」里把媒体库归入该标签。
|
「{effectiveTagId}」标签下还没有媒体库,可在「管理标签」里把媒体库归入该标签。
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { libraryAPI } from '../api/library'
|
import { libraryAPI } from '../api/library'
|
||||||
import { toolsAPI } from '../api/tools'
|
import { toolsAPI } from '../api/tools'
|
||||||
import { LibraryTagBar } from '../components/LibraryTagBar'
|
import { LibraryTagBar } from '../components/LibraryTagBar'
|
||||||
import { openManageLibraryTagsDialog } from '../components/manageLibraryTagsDialog'
|
import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView'
|
||||||
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
||||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||||
import { useLibraryTags } from '../hooks/useLibraryTags'
|
import { useLibraryTags } from '../hooks/useLibraryTags'
|
||||||
@@ -152,18 +152,7 @@ export function LibrariesPage() {
|
|||||||
void togglePin(libraryId)
|
void togglePin(libraryId)
|
||||||
}, [togglePin])
|
}, [togglePin])
|
||||||
|
|
||||||
const handleManageTags = useCallback(() => {
|
const [libraryTagsOpen, setLibraryTagsOpen] = useState(false)
|
||||||
void openManageLibraryTagsDialog({
|
|
||||||
tags: libraryTags.tags,
|
|
||||||
libraries,
|
|
||||||
saving: libraryTags.saving,
|
|
||||||
onCreate: libraryTags.createTag,
|
|
||||||
onRename: libraryTags.renameTag,
|
|
||||||
onRemove: libraryTags.removeTag,
|
|
||||||
onAssign: libraryTags.assignLibrary,
|
|
||||||
})
|
|
||||||
}, [libraries, libraryTags])
|
|
||||||
|
|
||||||
const effectiveTagId = libraryTags.selectedTagId
|
const effectiveTagId = libraryTags.selectedTagId
|
||||||
const tagTabs = useMemo(
|
const tagTabs = useMemo(
|
||||||
() => buildLibraryTagTabs(sortedPreviews.map((preview) => preview.library), libraryTags.tags),
|
() => buildLibraryTagTabs(sortedPreviews.map((preview) => preview.library), libraryTags.tags),
|
||||||
@@ -203,10 +192,24 @@ export function LibrariesPage() {
|
|||||||
onCreate={(name) => {
|
onCreate={(name) => {
|
||||||
void libraryTags.createTag(name)
|
void libraryTags.createTag(name)
|
||||||
}}
|
}}
|
||||||
onManage={handleManageTags}
|
onManage={() => setLibraryTagsOpen(true)}
|
||||||
busy={libraryTags.saving}
|
busy={libraryTags.saving}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{libraryTagsOpen && (
|
||||||
|
<ManageLibraryTagsDialogView
|
||||||
|
tags={libraryTags.tags}
|
||||||
|
libraries={libraries}
|
||||||
|
saving={libraryTags.saving}
|
||||||
|
onCreate={libraryTags.createTag}
|
||||||
|
onRename={libraryTags.renameTag}
|
||||||
|
onRemove={libraryTags.removeTag}
|
||||||
|
onAssign={libraryTags.assignLibrary}
|
||||||
|
onAssignBatch={libraryTags.assignLibraries}
|
||||||
|
onClose={() => setLibraryTagsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{previews.length === 0 ? (
|
{previews.length === 0 ? (
|
||||||
<LibrariesEmptyState isAdmin={isAdmin} />
|
<LibrariesEmptyState isAdmin={isAdmin} />
|
||||||
) : taggedPreviews.length === 0 ? (
|
) : taggedPreviews.length === 0 ? (
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
ALL_TAG_ID,
|
ALL_TAG_ID,
|
||||||
|
assignLibrariesToTag,
|
||||||
|
attachLibrariesToTag,
|
||||||
attachLibraryToTag,
|
attachLibraryToTag,
|
||||||
buildLibraryTagTabs,
|
buildLibraryTagTabs,
|
||||||
dedupeLibraryTags,
|
dedupeLibraryTags,
|
||||||
|
detachLibrariesFromTag,
|
||||||
detachLibraryFromTag,
|
detachLibraryFromTag,
|
||||||
filterLibrariesByTag,
|
filterLibrariesByTag,
|
||||||
|
filterLibrariesForTagging,
|
||||||
normalizeLibraryTags,
|
normalizeLibraryTags,
|
||||||
normalizeTagName,
|
normalizeTagName,
|
||||||
resolveSelectedTagId,
|
resolveSelectedTagId,
|
||||||
@@ -84,6 +88,46 @@ const detached = detachLibraryFromTag(
|
|||||||
)
|
)
|
||||||
check('detach removes only the requested library', detached[0].library_ids.join(',') === 'b')
|
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('blank name is rejected', tagNameError(' ', []) !== '')
|
||||||
check('duplicate name is rejected', tagNameError('动画', [{ name: '动画', library_ids: [] }]) !== '')
|
check('duplicate name is rejected', tagNameError('动画', [{ name: '动画', library_ids: [] }]) !== '')
|
||||||
check(
|
check(
|
||||||
|
|||||||
@@ -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,
|
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<string>()
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user