diff --git a/web/src/components/LibraryTagBar.tsx b/web/src/components/LibraryTagBar.tsx
index 89d7362..6b870ee 100644
--- a/web/src/components/LibraryTagBar.tsx
+++ b/web/src/components/LibraryTagBar.tsx
@@ -1,14 +1,13 @@
-import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react'
-import { GripVertical, Layers, Plus, Settings2, X } from 'lucide-react'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { Layers, Plus, Settings2, X } from 'lucide-react'
-import { ALL_TAG_ID, MAX_LIBRARY_TAGS } from '../utils/libraryTags'
+import { MAX_LIBRARY_TAGS } from '../utils/libraryTags'
import type { LibraryTagTab } from '../utils/libraryTags'
/**
* 媒体库标签栏:固定「全部」+ 每个标签一个页签,选中后只展示该标签下的媒体库。
* 只是管理员/普通用户都能用的展示层过滤,不改动媒体库本身。
- * 传入 onReorder 时标签页签可拖拽排序:拖拽只作用于「全部」之后的标签,
- * 「全部」永远固定在第一位,不参与拖动。
+ * 标签顺序由「管理标签」里的拖拽决定,这里只负责展示与选中。
*/
export function LibraryTagBar({
tabs,
@@ -16,7 +15,6 @@ export function LibraryTagBar({
onSelect,
onCreate,
onManage,
- onReorder,
manageLabel = '管理标签',
showCreate = true,
busy = false,
@@ -26,8 +24,6 @@ export function LibraryTagBar({
onSelect: (tagId: string) => void
onCreate?: (name: string) => void
onManage?: () => void
- /** 拖拽排序的落点:收到的是拖拽后的标签名顺序(不含「全部」)。 */
- onReorder?: (orderedNames: string[]) => void | Promise
manageLabel?: string
showCreate?: boolean
busy?: boolean
@@ -37,8 +33,6 @@ 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) {
@@ -49,25 +43,6 @@ 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
@@ -89,64 +64,24 @@ export function LibraryTagBar({
{tabs.map((tab) => {
const active = tab.id === selectedTagId
- const draggable = !tab.isAll && Boolean(onReorder)
return (
)
})}
@@ -217,7 +152,7 @@ export function LibraryTagBar({
type="button"
onClick={onManage}
className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-1.5 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:border-brand-500/40 hover:text-brand-500"
- title="重命名 / 删除标签,调整标签下的媒体库"
+ title="重命名 / 删除标签,调整标签下的媒体库,拖拽排序"
>
{manageLabel}
@@ -227,4 +162,4 @@ export function LibraryTagBar({
)}
)
-}
+}
\ No newline at end of file
diff --git a/web/src/components/ManageLibraryTagsDialogView.tsx b/web/src/components/ManageLibraryTagsDialogView.tsx
index 5dee860..49de28e 100644
--- a/web/src/components/ManageLibraryTagsDialogView.tsx
+++ b/web/src/components/ManageLibraryTagsDialogView.tsx
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
-import { Check, Loader2, Pencil, Plus, Tag, Trash2, X } from 'lucide-react'
+import { Check, GripVertical, 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 { useDragReorder } from '../hooks/useDragReorder'
import {
MAX_LIBRARY_TAGS,
filterLibrariesForTagging,
@@ -19,6 +20,8 @@ export type ManageLibraryTagsDialogProps = {
onCreate: (name: string) => Promise
onRename: (from: string, to: string) => Promise
onRemove: (name: string) => Promise
+ /** 按给定标签名顺序重排标签(拖拽排序的落点)。 */
+ onReorder: (orderedNames: string[]) => Promise
/** 单个媒体库归类;tagName 为空表示移出所有标签。 */
onAssign: (libraryId: string, tagName: string) => Promise
/** 批量归类;tagName 为空表示批量移出所有标签。一次写入。 */
@@ -42,6 +45,7 @@ export function ManageLibraryTagsDialogView({
onCreate,
onRename,
onRemove,
+ onReorder,
onAssign,
onAssignBatch,
onClose,
@@ -55,6 +59,10 @@ export function ManageLibraryTagsDialogView({
const [selectedIds, setSelectedIds] = useState([])
const [batchTag, setBatchTag] = useState('')
const [applying, setApplying] = useState(false)
+ const { draggingId, dragOverId, dragProps } = useDragReorder(
+ tags.map((tag) => tag.name),
+ onReorder,
+ )
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
@@ -254,86 +262,110 @@ export function ManageLibraryTagsDialogView({
还没有标签。先创建一个,再把媒体库归入其中。
) : (
- tags.map((tag) => (
-
- {renaming === tag.name ? (
- <>
-
setRenameDraft(event.target.value)}
- onKeyDown={(event) => {
- if (event.key === 'Enter') {
- event.preventDefault()
- void handleRename(tag.name)
- }
- if (event.key === 'Escape') {
+ tags.map((tag) => {
+ const drag = dragProps(tag.name)
+ const isDragOver = dragOverId === tag.name && draggingId !== tag.name
+ return (
+
+ {renaming === tag.name ? (
+ <>
+
setRenameDraft(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ void handleRename(tag.name)
+ }
+ if (event.key === 'Escape') {
+ setRenaming('')
+ setRenameDraft('')
+ }
+ }}
+ maxLength={24}
+ className="min-w-0 flex-1 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 text-xs font-semibold text-[var(--app-text)] outline-none focus:border-brand-500"
+ />
+
+
- ))
+ }}
+ className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
+ title="取消"
+ >
+
+
+ >
+ ) : (
+ <>
+
+
+
+
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.library_ids.length}
+
+
{
+ setRenaming(tag.name)
+ setRenameDraft(tag.name)
+ }}
+ className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-brand-500"
+ title="重命名"
+ >
+
+
+
void handleRemove(tag)}
+ className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-red-500"
+ title="删除标签"
+ >
+
+
+ >
+ )}
+
+ )
+ })
)}
diff --git a/web/src/hooks/useDragReorder.ts b/web/src/hooks/useDragReorder.ts
new file mode 100644
index 0000000..2282484
--- /dev/null
+++ b/web/src/hooks/useDragReorder.ts
@@ -0,0 +1,86 @@
+import { useCallback, useState, type DragEvent } from 'react'
+
+/** 一组可直接展开到可拖拽元素上的事件处理器。 */
+export type DragReorderHandlers = {
+ draggable: boolean
+ onDragStart: (event: DragEvent) => void
+ onDragOver: (event: DragEvent) => void
+ onDragLeave: (event: DragEvent) => void
+ onDrop: (event: DragEvent) => void
+ onDragEnd: () => void
+}
+
+/**
+ * 把 `fromId` 移动到 `toId` 当前所在的位置,返回新数组;不改动入参。
+ * 任一 id 不存在,或两者相同时返回 null,表示无需重排。
+ */
+export function moveIdToPosition(ids: string[], fromId: string, toId: string): string[] | null {
+ const fromIndex = ids.indexOf(fromId)
+ const toIndex = ids.indexOf(toId)
+ if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null
+ const next = [...ids]
+ const [moved] = next.splice(fromIndex, 1)
+ next.splice(toIndex, 0, moved)
+ return next
+}
+
+/**
+ * 列表拖拽排序的通用状态机:只负责「一串稳定 id」的拖拽与重排计算,
+ * 不关心渲染。调用方把 `dragProps(id)` 展开到拖拽柄上,并用返回的
+ * `draggingId` / `dragOverId` 决定高亮样式。
+ *
+ * 落点语义:把被拖项插入到目标项所在的位置。未传 `onReorder` 时整体禁用。
+ */
+export function useDragReorder(
+ ids: string[],
+ onReorder?: (orderedIds: string[]) => void | Promise,
+): {
+ draggingId: string | null
+ dragOverId: string | null
+ dragProps: (id: string) => DragReorderHandlers
+} {
+ const [draggingId, setDraggingId] = useState(null)
+ const [dragOverId, setDragOverId] = useState(null)
+
+ const enabled = Boolean(onReorder)
+
+ const dragProps = useCallback(
+ (id: string): DragReorderHandlers => ({
+ draggable: enabled,
+ onDragStart: (event) => {
+ if (!enabled) return
+ event.dataTransfer.effectAllowed = 'move'
+ event.dataTransfer.setData('text/plain', id)
+ setDraggingId(id)
+ setDragOverId(null)
+ },
+ onDragOver: (event) => {
+ if (!enabled || draggingId === id) return
+ event.preventDefault()
+ event.dataTransfer.dropEffect = 'move'
+ setDragOverId((prev) => (prev === id ? prev : id))
+ },
+ onDragLeave: () => {
+ setDragOverId((prev) => (prev === id ? null : prev))
+ },
+ onDrop: (event) => {
+ if (!enabled) return
+ event.preventDefault()
+ // 拖拽期间 state 可能尚未提交,优先信任 dataTransfer 里的来源 id。
+ const fromId = draggingId ?? event.dataTransfer.getData('text/plain')
+ setDraggingId(null)
+ setDragOverId(null)
+ if (!fromId || fromId === id) return
+ const next = moveIdToPosition(ids, fromId, id)
+ if (next) void onReorder?.(next)
+ },
+ onDragEnd: () => {
+ setDraggingId(null)
+ setDragOverId(null)
+ },
+ }),
+ [enabled, draggingId, ids, onReorder],
+ )
+
+ return { draggingId, dragOverId, dragProps }
+}
\ No newline at end of file
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index 5ade32b..58c2c9f 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -347,9 +347,6 @@ export function HomePage() {
onCreate={(name) => {
void libraryTags.createTag(name)
}}
- onReorder={(names) => {
- void libraryTags.reorderTags(names)
- }}
onManage={() => setLibraryTagsOpen(true)}
busy={libraryTags.saving}
/>
@@ -361,6 +358,7 @@ export function HomePage() {
onCreate={libraryTags.createTag}
onRename={libraryTags.renameTag}
onRemove={libraryTags.removeTag}
+ onReorder={libraryTags.reorderTags}
onAssign={libraryTags.assignLibrary}
onAssignBatch={libraryTags.assignLibraries}
onClose={() => setLibraryTagsOpen(false)}
diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx
index 9c4986f..a9b9684 100644
--- a/web/src/pages/LibrariesPage.tsx
+++ b/web/src/pages/LibrariesPage.tsx
@@ -210,9 +210,6 @@ export function LibrariesPage() {
onCreate={(name) => {
void libraryTags.createTag(name)
}}
- onReorder={(names) => {
- void libraryTags.reorderTags(names)
- }}
onManage={() => setLibraryTagsOpen(true)}
busy={libraryTags.saving}
/>
@@ -225,6 +222,7 @@ export function LibrariesPage() {
onCreate={libraryTags.createTag}
onRename={libraryTags.renameTag}
onRemove={libraryTags.removeTag}
+ onReorder={libraryTags.reorderTags}
onAssign={libraryTags.assignLibrary}
onAssignBatch={libraryTags.assignLibraries}
onClose={() => setLibraryTagsOpen(false)}