feat: implement drag-and-drop sorting for knowledge directories and add localization for sort actions

This commit is contained in:
mlogclub
2026-06-07 22:25:02 +08:00
parent 994bec1b3f
commit 409d04854d
7 changed files with 342 additions and 40 deletions
@@ -84,6 +84,10 @@ func KnowledgeDirectoryPostDelete(ctx *gin.Context) {
}
func KnowledgeDirectoryPostUpdate_sort(ctx *gin.Context) {
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeBaseUpdate); err != nil {
httpx.WriteJSON(ctx, err)
return
}
var req struct {
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
ParentID int64 `json:"parentId"`
@@ -1,5 +1,22 @@
"use client";
import {
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ChevronDownIcon,
ChevronRightIcon,
@@ -11,7 +28,7 @@ import {
PlusIcon,
Trash2Icon,
} from "lucide-react";
import type { ReactNode } from "react";
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
import { toast } from "sonner";
@@ -39,10 +56,12 @@ import {
deleteKnowledgeDirectory,
fetchKnowledgeDirectories,
updateKnowledgeDirectory,
updateKnowledgeDirectorySort,
type KnowledgeDirectory,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
import { cn } from "@/lib/utils";
import { findDirectoryParentId, moveDirectoryWithinParent } from "./knowledge-directory-sort";
type KnowledgeDirectoryPanelProps = {
knowledgeBaseId: number;
@@ -106,6 +125,7 @@ export function KnowledgeDirectoryPanel({
const [contextMenuDirectoryId, setContextMenuDirectoryId] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [sorting, setSorting] = useState(false);
const [dialog, setDialog] = useState<DirectoryDialogState>({
open: false,
id: null,
@@ -120,6 +140,17 @@ export function KnowledgeDirectoryPanel({
],
[directories, dialog.id, t],
);
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(TouchSensor, {
activationConstraint: { delay: 150, tolerance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const loadDirectories = useCallback(async () => {
setLoading(true);
@@ -268,6 +299,48 @@ export function KnowledgeDirectoryPanel({
}
}
async function handleDirectoryDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id || sorting) {
return;
}
const activeId = Number(active.id);
const overId = Number(over.id);
if (!Number.isFinite(activeId) || !Number.isFinite(overId)) {
return;
}
const activeParentId = findDirectoryParentId(directories, activeId);
const overParentId = findDirectoryParentId(directories, overId);
if (activeParentId === null || overParentId === null || activeParentId !== overParentId) {
return;
}
const previousDirectories = directories;
const moved = moveDirectoryWithinParent(previousDirectories, activeParentId, activeId, overId);
if (!moved.changed) {
return;
}
setDirectories(moved.items);
setSorting(true);
try {
await updateKnowledgeDirectorySort({
knowledgeBaseId,
parentId: moved.parentId,
ids: moved.orderedIds,
});
toast.success(t("knowledge.directorySortUpdated"));
await loadDirectories();
onChanged?.();
} catch (error) {
setDirectories(previousDirectories);
toast.error(error instanceof Error ? error.message : t("knowledge.directorySortUpdateFailed"));
} finally {
setSorting(false);
}
}
return (
<>
<div
@@ -301,26 +374,37 @@ export function KnowledgeDirectoryPanel({
selected={selectedDirectoryId === 0}
onClick={() => onSelectDirectory(0)}
/>
{directories.map((item) => (
<DirectoryNode
key={item.id}
item={item}
depth={0}
expandedIds={expandedIds}
selectedDirectoryId={selectedDirectoryId}
contextMenuDirectoryId={contextMenuDirectoryId}
saving={saving}
onToggle={toggleDirectory}
onSelect={onSelectDirectory}
onContextMenuOpenChange={(directoryId, open) =>
setContextMenuDirectoryId(open ? directoryId : null)
}
onCreate={openCreate}
onEdit={openEdit}
onDelete={(directory) => void handleDelete(directory)}
t={t}
/>
))}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => void handleDirectoryDragEnd(event)}
>
<SortableContext
items={directories.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
{directories.map((item) => (
<DirectoryNode
key={item.id}
item={item}
depth={0}
expandedIds={expandedIds}
selectedDirectoryId={selectedDirectoryId}
contextMenuDirectoryId={contextMenuDirectoryId}
saving={saving || sorting}
onToggle={toggleDirectory}
onSelect={onSelectDirectory}
onContextMenuOpenChange={(directoryId, open) =>
setContextMenuDirectoryId(open ? directoryId : null)
}
onCreate={openCreate}
onEdit={openEdit}
onDelete={(directory) => void handleDelete(directory)}
t={t}
/>
))}
</SortableContext>
</DndContext>
</div>
</ScrollArea>
<div
@@ -459,21 +543,39 @@ function DirectoryNode({
onDelete,
t,
}: DirectoryNodeProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: item.id,
disabled: saving,
});
const expanded = expandedIds.has(item.id);
const hasChildren = (item.children || []).length > 0;
const active = selectedDirectoryId === item.id || contextMenuDirectoryId === item.id;
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div>
<div ref={setNodeRef} style={style}>
<ContextMenu onOpenChange={(open) => onContextMenuOpenChange(item.id, open)}>
<ContextMenuTrigger className="block">
<div
className={cn(
"group flex cursor-pointer items-center gap-1 px-2 py-1.5 text-sm hover:bg-accent",
active && "bg-accent text-accent-foreground",
isDragging && "bg-muted/60 shadow-sm opacity-80",
)}
style={{ paddingLeft: 8 + depth * 16 }}
onClick={() => onSelect(item.id)}
{...attributes}
{...listeners}
>
<Button
variant="ghost"
@@ -550,24 +652,31 @@ function DirectoryNode({
</ContextMenuContent>
</ContextMenu>
{expanded
? (item.children || []).map((child) => (
<DirectoryNode
key={child.id}
item={child}
depth={depth + 1}
expandedIds={expandedIds}
selectedDirectoryId={selectedDirectoryId}
contextMenuDirectoryId={contextMenuDirectoryId}
saving={saving}
onToggle={onToggle}
onSelect={onSelect}
onContextMenuOpenChange={onContextMenuOpenChange}
onCreate={onCreate}
onEdit={onEdit}
onDelete={onDelete}
t={t}
/>
))
? (
<SortableContext
items={(item.children || []).map((child) => child.id)}
strategy={verticalListSortingStrategy}
>
{(item.children || []).map((child) => (
<DirectoryNode
key={child.id}
item={child}
depth={depth + 1}
expandedIds={expandedIds}
selectedDirectoryId={selectedDirectoryId}
contextMenuDirectoryId={contextMenuDirectoryId}
saving={saving}
onToggle={onToggle}
onSelect={onSelect}
onContextMenuOpenChange={onContextMenuOpenChange}
onCreate={onCreate}
onEdit={onEdit}
onDelete={onDelete}
t={t}
/>
))}
</SortableContext>
)
: null}
</div>
);
@@ -0,0 +1,76 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
function plain(value) {
return JSON.parse(JSON.stringify(value))
}
async function loadModule() {
const source = await readFile(
new URL("./knowledge-directory-sort.ts", import.meta.url),
"utf8"
)
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "knowledge-directory-sort.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
const tree = [
{ id: 1, parentId: 0, name: "A", children: [
{ id: 11, parentId: 1, name: "A-1", children: [] },
{ id: 12, parentId: 1, name: "A-2", children: [] },
] },
{ id: 2, parentId: 0, name: "B", children: [] },
{ id: 3, parentId: 0, name: "C", children: [] },
]
describe("knowledge directory sorting", () => {
it("moves root directories within the same parent", async () => {
const { moveDirectoryWithinParent } = await loadModule()
const next = moveDirectoryWithinParent(tree, 0, 3, 1)
assert.deepEqual(plain(next.items).map((item) => item.id), [3, 1, 2])
assert.equal(next.changed, true)
assert.equal(next.parentId, 0)
assert.deepEqual(plain(next.orderedIds), [3, 1, 2])
})
it("moves child directories within their parent", async () => {
const { moveDirectoryWithinParent } = await loadModule()
const next = moveDirectoryWithinParent(tree, 1, 12, 11)
assert.deepEqual(plain(next.items[0].children).map((item) => item.id), [12, 11])
assert.equal(next.changed, true)
assert.equal(next.parentId, 1)
assert.deepEqual(plain(next.orderedIds), [12, 11])
})
it("does not move directories across parents", async () => {
const { findDirectoryParentId, moveDirectoryWithinParent } = await loadModule()
assert.equal(findDirectoryParentId(tree, 11), 1)
assert.equal(findDirectoryParentId(tree, 2), 0)
const next = moveDirectoryWithinParent(tree, 1, 11, 2)
assert.equal(next.changed, false)
assert.equal(next.items, tree)
assert.deepEqual(plain(next.orderedIds), [])
})
})
@@ -0,0 +1,98 @@
export type SortableKnowledgeDirectory = {
id: number
parentId: number
children?: SortableKnowledgeDirectory[]
}
export type MoveDirectoryResult<T extends SortableKnowledgeDirectory> = {
items: T[]
changed: boolean
parentId: number
orderedIds: number[]
}
function arrayMove<T>(items: T[], fromIndex: number, toIndex: number) {
const next = [...items]
const [item] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, item)
return next
}
export function findDirectoryParentId<T extends SortableKnowledgeDirectory>(
items: T[],
id: number,
): number | null {
for (const item of items) {
if (item.id === id) {
return item.parentId
}
const childParentId = findDirectoryParentId(item.children as T[] | undefined ?? [], id)
if (childParentId !== null) {
return childParentId
}
}
return null
}
export function moveDirectoryWithinParent<T extends SortableKnowledgeDirectory>(
items: T[],
parentId: number,
activeId: number,
overId: number,
): MoveDirectoryResult<T> {
if (activeId === overId) {
return { items, changed: false, parentId, orderedIds: [] }
}
if (parentId === 0) {
return moveSiblingList(items, parentId, activeId, overId)
}
let changed = false
let orderedIds: number[] = []
const nextItems = items.map((item) => {
if (item.id === parentId) {
const moved = moveSiblingList((item.children as T[] | undefined) ?? [], parentId, activeId, overId)
changed = moved.changed
orderedIds = moved.orderedIds
return { ...item, children: moved.items }
}
if (item.children?.length) {
const moved = moveDirectoryWithinParent(item.children as T[], parentId, activeId, overId)
if (moved.changed) {
changed = true
orderedIds = moved.orderedIds
return { ...item, children: moved.items }
}
}
return item
})
return {
items: changed ? nextItems : items,
changed,
parentId,
orderedIds,
}
}
function moveSiblingList<T extends SortableKnowledgeDirectory>(
items: T[],
parentId: number,
activeId: number,
overId: number,
): MoveDirectoryResult<T> {
const oldIndex = items.findIndex((item) => item.id === activeId)
const newIndex = items.findIndex((item) => item.id === overId)
if (oldIndex < 0 || newIndex < 0) {
return { items, changed: false, parentId, orderedIds: [] }
}
const nextItems = arrayMove(items, oldIndex, newIndex)
return {
items: nextItems,
changed: true,
parentId,
orderedIds: nextItems.map((item) => item.id),
}
}
+11
View File
@@ -1663,6 +1663,17 @@ export function deleteKnowledgeDirectory(id: number) {
})
}
export function updateKnowledgeDirectorySort(payload: {
knowledgeBaseId: number
parentId: number
ids: number[]
}) {
return request<void>("/api/dashboard/knowledge-directory/update_sort", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function fetchKnowledgeDocuments(
query?: Record<string, string | number | undefined>
) {
+2
View File
@@ -1804,6 +1804,8 @@
"directoryDeleted": "Directory deleted: {name}",
"directorySaveFailed": "Could not save the directory.",
"directoryDeleteFailed": "Could not delete the directory.",
"directorySortUpdated": "Directory order updated.",
"directorySortUpdateFailed": "Could not update directory order.",
"directoryNameRequired": "Directory name is required.",
"rebuildStarted": "Knowledge base reindex started: {name}",
"rebuildFailed": "Could not rebuild the knowledge base index.",
+2
View File
@@ -1804,6 +1804,8 @@
"directoryDeleted": "已删除目录:{name}",
"directorySaveFailed": "保存目录失败",
"directoryDeleteFailed": "删除目录失败",
"directorySortUpdated": "目录排序已更新",
"directorySortUpdateFailed": "更新目录排序失败",
"directoryNameRequired": "目录名称不能为空",
"rebuildStarted": "已开始重建知识库索引:{name}",
"rebuildFailed": "重建知识库索引失败",