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) { func KnowledgeDirectoryPostUpdate_sort(ctx *gin.Context) {
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeBaseUpdate); err != nil {
httpx.WriteJSON(ctx, err)
return
}
var req struct { var req struct {
KnowledgeBaseID int64 `json:"knowledgeBaseId"` KnowledgeBaseID int64 `json:"knowledgeBaseId"`
ParentID int64 `json:"parentId"` ParentID int64 `json:"parentId"`
@@ -1,5 +1,22 @@
"use client"; "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 { import {
ChevronDownIcon, ChevronDownIcon,
ChevronRightIcon, ChevronRightIcon,
@@ -11,7 +28,7 @@ import {
PlusIcon, PlusIcon,
Trash2Icon, Trash2Icon,
} from "lucide-react"; } 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 { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -39,10 +56,12 @@ import {
deleteKnowledgeDirectory, deleteKnowledgeDirectory,
fetchKnowledgeDirectories, fetchKnowledgeDirectories,
updateKnowledgeDirectory, updateKnowledgeDirectory,
updateKnowledgeDirectorySort,
type KnowledgeDirectory, type KnowledgeDirectory,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider"; import { useI18n } from "@/i18n/provider";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { findDirectoryParentId, moveDirectoryWithinParent } from "./knowledge-directory-sort";
type KnowledgeDirectoryPanelProps = { type KnowledgeDirectoryPanelProps = {
knowledgeBaseId: number; knowledgeBaseId: number;
@@ -106,6 +125,7 @@ export function KnowledgeDirectoryPanel({
const [contextMenuDirectoryId, setContextMenuDirectoryId] = useState<number | null>(null); const [contextMenuDirectoryId, setContextMenuDirectoryId] = useState<number | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [sorting, setSorting] = useState(false);
const [dialog, setDialog] = useState<DirectoryDialogState>({ const [dialog, setDialog] = useState<DirectoryDialogState>({
open: false, open: false,
id: null, id: null,
@@ -120,6 +140,17 @@ export function KnowledgeDirectoryPanel({
], ],
[directories, dialog.id, t], [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 () => { const loadDirectories = useCallback(async () => {
setLoading(true); 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 ( return (
<> <>
<div <div
@@ -301,26 +374,37 @@ export function KnowledgeDirectoryPanel({
selected={selectedDirectoryId === 0} selected={selectedDirectoryId === 0}
onClick={() => onSelectDirectory(0)} onClick={() => onSelectDirectory(0)}
/> />
{directories.map((item) => ( <DndContext
<DirectoryNode sensors={sensors}
key={item.id} collisionDetection={closestCenter}
item={item} onDragEnd={(event) => void handleDirectoryDragEnd(event)}
depth={0} >
expandedIds={expandedIds} <SortableContext
selectedDirectoryId={selectedDirectoryId} items={directories.map((item) => item.id)}
contextMenuDirectoryId={contextMenuDirectoryId} strategy={verticalListSortingStrategy}
saving={saving} >
onToggle={toggleDirectory} {directories.map((item) => (
onSelect={onSelectDirectory} <DirectoryNode
onContextMenuOpenChange={(directoryId, open) => key={item.id}
setContextMenuDirectoryId(open ? directoryId : null) item={item}
} depth={0}
onCreate={openCreate} expandedIds={expandedIds}
onEdit={openEdit} selectedDirectoryId={selectedDirectoryId}
onDelete={(directory) => void handleDelete(directory)} contextMenuDirectoryId={contextMenuDirectoryId}
t={t} 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> </div>
</ScrollArea> </ScrollArea>
<div <div
@@ -459,21 +543,39 @@ function DirectoryNode({
onDelete, onDelete,
t, t,
}: DirectoryNodeProps) { }: DirectoryNodeProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: item.id,
disabled: saving,
});
const expanded = expandedIds.has(item.id); const expanded = expandedIds.has(item.id);
const hasChildren = (item.children || []).length > 0; const hasChildren = (item.children || []).length > 0;
const active = selectedDirectoryId === item.id || contextMenuDirectoryId === item.id; const active = selectedDirectoryId === item.id || contextMenuDirectoryId === item.id;
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return ( return (
<div> <div ref={setNodeRef} style={style}>
<ContextMenu onOpenChange={(open) => onContextMenuOpenChange(item.id, open)}> <ContextMenu onOpenChange={(open) => onContextMenuOpenChange(item.id, open)}>
<ContextMenuTrigger className="block"> <ContextMenuTrigger className="block">
<div <div
className={cn( className={cn(
"group flex cursor-pointer items-center gap-1 px-2 py-1.5 text-sm hover:bg-accent", "group flex cursor-pointer items-center gap-1 px-2 py-1.5 text-sm hover:bg-accent",
active && "bg-accent text-accent-foreground", active && "bg-accent text-accent-foreground",
isDragging && "bg-muted/60 shadow-sm opacity-80",
)} )}
style={{ paddingLeft: 8 + depth * 16 }} style={{ paddingLeft: 8 + depth * 16 }}
onClick={() => onSelect(item.id)} onClick={() => onSelect(item.id)}
{...attributes}
{...listeners}
> >
<Button <Button
variant="ghost" variant="ghost"
@@ -550,24 +652,31 @@ function DirectoryNode({
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
{expanded {expanded
? (item.children || []).map((child) => ( ? (
<DirectoryNode <SortableContext
key={child.id} items={(item.children || []).map((child) => child.id)}
item={child} strategy={verticalListSortingStrategy}
depth={depth + 1} >
expandedIds={expandedIds} {(item.children || []).map((child) => (
selectedDirectoryId={selectedDirectoryId} <DirectoryNode
contextMenuDirectoryId={contextMenuDirectoryId} key={child.id}
saving={saving} item={child}
onToggle={onToggle} depth={depth + 1}
onSelect={onSelect} expandedIds={expandedIds}
onContextMenuOpenChange={onContextMenuOpenChange} selectedDirectoryId={selectedDirectoryId}
onCreate={onCreate} contextMenuDirectoryId={contextMenuDirectoryId}
onEdit={onEdit} saving={saving}
onDelete={onDelete} onToggle={onToggle}
t={t} onSelect={onSelect}
/> onContextMenuOpenChange={onContextMenuOpenChange}
)) onCreate={onCreate}
onEdit={onEdit}
onDelete={onDelete}
t={t}
/>
))}
</SortableContext>
)
: null} : null}
</div> </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( export function fetchKnowledgeDocuments(
query?: Record<string, string | number | undefined> query?: Record<string, string | number | undefined>
) { ) {
+2
View File
@@ -1804,6 +1804,8 @@
"directoryDeleted": "Directory deleted: {name}", "directoryDeleted": "Directory deleted: {name}",
"directorySaveFailed": "Could not save the directory.", "directorySaveFailed": "Could not save the directory.",
"directoryDeleteFailed": "Could not delete the directory.", "directoryDeleteFailed": "Could not delete the directory.",
"directorySortUpdated": "Directory order updated.",
"directorySortUpdateFailed": "Could not update directory order.",
"directoryNameRequired": "Directory name is required.", "directoryNameRequired": "Directory name is required.",
"rebuildStarted": "Knowledge base reindex started: {name}", "rebuildStarted": "Knowledge base reindex started: {name}",
"rebuildFailed": "Could not rebuild the knowledge base index.", "rebuildFailed": "Could not rebuild the knowledge base index.",
+2
View File
@@ -1804,6 +1804,8 @@
"directoryDeleted": "已删除目录:{name}", "directoryDeleted": "已删除目录:{name}",
"directorySaveFailed": "保存目录失败", "directorySaveFailed": "保存目录失败",
"directoryDeleteFailed": "删除目录失败", "directoryDeleteFailed": "删除目录失败",
"directorySortUpdated": "目录排序已更新",
"directorySortUpdateFailed": "更新目录排序失败",
"directoryNameRequired": "目录名称不能为空", "directoryNameRequired": "目录名称不能为空",
"rebuildStarted": "已开始重建知识库索引:{name}", "rebuildStarted": "已开始重建知识库索引:{name}",
"rebuildFailed": "重建知识库索引失败", "rebuildFailed": "重建知识库索引失败",