This commit is contained in:
truewhile
2026-09-16 21:19:46 +08:00
parent c723664e46
commit f0a6b630c6
6 changed files with 162 additions and 37 deletions
+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>
)
})}