529 lines
16 KiB
TypeScript
529 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
DndContext,
|
|
KeyboardSensor,
|
|
MouseSensor,
|
|
TouchSensor,
|
|
closestCenter,
|
|
useSensor,
|
|
useSensors,
|
|
type DragEndEvent,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
SortableContext,
|
|
arrayMove,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import {
|
|
CircleHelpIcon,
|
|
FileTextIcon,
|
|
MoreHorizontalIcon,
|
|
PencilIcon,
|
|
PlusIcon,
|
|
RefreshCwIcon,
|
|
SearchIcon,
|
|
Trash2Icon,
|
|
} from "lucide-react";
|
|
import type { CSSProperties } from "react";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuTrigger,
|
|
} from "@/components/ui/context-menu";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Input } from "@/components/ui/input";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { OptionCombobox } from "@/components/option-combobox";
|
|
import {
|
|
createKnowledgeBase,
|
|
deleteKnowledgeBase,
|
|
fetchKnowledgeBases,
|
|
rebuildKnowledgeBaseIndex,
|
|
updateKnowledgeBase,
|
|
updateKnowledgeBaseSort,
|
|
type CreateKnowledgeBasePayload,
|
|
type KnowledgeBase,
|
|
} from "@/lib/api/admin";
|
|
import { useI18n } from "@/i18n/provider";
|
|
import { KnowledgeBaseType, Status } from "@/lib/generated/enums";
|
|
import { cn } from "@/lib/utils";
|
|
import { EditDialog } from "./knowledge-base-edit";
|
|
|
|
type KnowledgeBaseListProps = {
|
|
selectedKnowledgeBaseId: number | null;
|
|
onSelectKnowledgeBase: (knowledgeBase: KnowledgeBase | null) => void;
|
|
};
|
|
|
|
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
|
|
|
function getStatusOptions(t: TFunction) {
|
|
return [
|
|
{ value: "all", label: t("knowledge.allStatus") },
|
|
{ value: String(Status.Ok), label: t("knowledge.statusOk") },
|
|
{ value: String(Status.Disabled), label: t("knowledge.statusDisabled") },
|
|
{ value: String(Status.Deleted), label: t("knowledge.statusDeleted") },
|
|
];
|
|
}
|
|
|
|
type SortableKnowledgeBaseCardProps = {
|
|
item: KnowledgeBase;
|
|
isSelected: boolean;
|
|
isContextMenuOpen: boolean;
|
|
disabled: boolean;
|
|
onContextMenuOpenChange: (open: boolean) => void;
|
|
onSelect: () => void;
|
|
onEdit: () => void;
|
|
onDelete: () => void;
|
|
onRebuildIndex: () => void;
|
|
deleteLoadingId: number | null;
|
|
rebuildIndexLoadingId: number | null;
|
|
t: TFunction;
|
|
};
|
|
|
|
function SortableKnowledgeBaseCard({
|
|
item,
|
|
isSelected,
|
|
isContextMenuOpen,
|
|
disabled,
|
|
onContextMenuOpenChange,
|
|
onSelect,
|
|
onEdit,
|
|
onDelete,
|
|
onRebuildIndex,
|
|
deleteLoadingId,
|
|
rebuildIndexLoadingId,
|
|
t,
|
|
}: SortableKnowledgeBaseCardProps) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({
|
|
id: item.id,
|
|
disabled,
|
|
});
|
|
|
|
const style: CSSProperties = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
};
|
|
|
|
return (
|
|
<ContextMenu onOpenChange={onContextMenuOpenChange}>
|
|
<ContextMenuTrigger
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className={cn(
|
|
"group flex items-center gap-1 rounded mx-2 px-2 py-1.5 text-sm transition-colors hover:bg-accent cursor-pointer",
|
|
(isSelected || isContextMenuOpen) && "bg-accent text-accent-foreground",
|
|
isDragging && "bg-muted/60 shadow-sm opacity-80",
|
|
)}
|
|
onClick={onSelect}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
onSelect();
|
|
}
|
|
}}
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
{item.knowledgeType === KnowledgeBaseType.FAQ ? (
|
|
<CircleHelpIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<span className="min-w-0 flex-1 truncate">{item.name}</span>
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|
{item.knowledgeType === "faq" ? item.faqCount : item.documentCount}
|
|
</span>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-6 opacity-0 group-hover:opacity-100"
|
|
/>
|
|
}
|
|
aria-label={t("knowledge.moreActions", { name: item.name })}
|
|
>
|
|
<MoreHorizontalIcon className="size-3.5" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-40 min-w-40">
|
|
<DropdownMenuItem
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onEdit();
|
|
}}
|
|
>
|
|
<PencilIcon className="mr-2 size-3.5" />
|
|
{t("knowledge.edit")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRebuildIndex();
|
|
}}
|
|
>
|
|
<RefreshCwIcon className="mr-2 size-3.5" />
|
|
{rebuildIndexLoadingId === item.id ? t("knowledge.rebuilding") : t("knowledge.rebuildIndex")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDelete();
|
|
}}
|
|
className="text-destructive focus:text-destructive"
|
|
>
|
|
<Trash2Icon className="mr-2 size-3.5" />
|
|
{deleteLoadingId === item.id ? t("knowledge.deleting") : t("knowledge.delete")}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<ContextMenuItem
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onEdit();
|
|
}}
|
|
>
|
|
<PencilIcon className="mr-2 size-3.5" />
|
|
{t("knowledge.edit")}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRebuildIndex();
|
|
}}
|
|
>
|
|
<RefreshCwIcon className="mr-2 size-3.5" />
|
|
{rebuildIndexLoadingId === item.id ? t("knowledge.rebuilding") : t("knowledge.rebuildIndex")}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDelete();
|
|
}}
|
|
variant="destructive"
|
|
>
|
|
<Trash2Icon className="mr-2 size-3.5" />
|
|
{deleteLoadingId === item.id ? t("knowledge.deleting") : t("knowledge.delete")}
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
|
|
export function KnowledgeBaseList({
|
|
selectedKnowledgeBaseId,
|
|
onSelectKnowledgeBase,
|
|
}: KnowledgeBaseListProps) {
|
|
const t = useI18n();
|
|
const [keywordInput, setKeywordInput] = useState("");
|
|
const [statusFilterInput, setStatusFilterInput] = useState("all");
|
|
const [keyword, setKeyword] = useState("");
|
|
const [statusFilter, setStatusFilter] = useState("all");
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [sorting, setSorting] = useState(false);
|
|
const [deleteLoadingId, setDeleteLoadingId] = useState<number | null>(null);
|
|
const [rebuildIndexLoadingId, setRebuildIndexLoadingId] = useState<
|
|
number | null
|
|
>(null);
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [editingItemId, setEditingItemId] = useState<number | null>(null);
|
|
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
|
const [contextMenuKnowledgeBaseId, setContextMenuKnowledgeBaseId] = useState<number | null>(null);
|
|
const statusOptions = useMemo(() => getStatusOptions(t), [t]);
|
|
|
|
const sensors = useSensors(
|
|
useSensor(MouseSensor, {
|
|
activationConstraint: { distance: 8 },
|
|
}),
|
|
useSensor(TouchSensor, {
|
|
activationConstraint: { delay: 150, tolerance: 8 },
|
|
}),
|
|
useSensor(KeyboardSensor, {
|
|
coordinateGetter: sortableKeyboardCoordinates,
|
|
}),
|
|
);
|
|
|
|
const loadData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await fetchKnowledgeBases({
|
|
name: keyword.trim() || undefined,
|
|
status: statusFilter === "all" ? undefined : statusFilter,
|
|
limit: 1000,
|
|
});
|
|
setKnowledgeBases(data.results);
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t("knowledge.loadBasesFailed"));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [keyword, statusFilter, t]);
|
|
|
|
useEffect(() => {
|
|
void loadData();
|
|
}, [loadData]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
selectedKnowledgeBaseId === null &&
|
|
knowledgeBases.length > 0 &&
|
|
!loading
|
|
) {
|
|
onSelectKnowledgeBase(knowledgeBases[0]);
|
|
}
|
|
}, [selectedKnowledgeBaseId, knowledgeBases, loading, onSelectKnowledgeBase]);
|
|
|
|
function handleStatusFilterChange(value: string | null) {
|
|
setStatusFilterInput(value ?? "all");
|
|
}
|
|
|
|
function applyFilters() {
|
|
setKeyword(keywordInput);
|
|
setStatusFilter(statusFilterInput);
|
|
}
|
|
|
|
function handleFilterKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
|
if (event.key !== "Enter") {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
applyFilters();
|
|
}
|
|
|
|
function openCreateDialog() {
|
|
setEditingItemId(null);
|
|
setDialogOpen(true);
|
|
}
|
|
|
|
function openEditDialog(item: KnowledgeBase) {
|
|
setEditingItemId(item.id);
|
|
setDialogOpen(true);
|
|
}
|
|
|
|
function handleDialogOpenChange(open: boolean) {
|
|
if (saving) {
|
|
return;
|
|
}
|
|
if (!open) {
|
|
setEditingItemId(null);
|
|
}
|
|
setDialogOpen(open);
|
|
}
|
|
|
|
async function handleSubmit(payload: CreateKnowledgeBasePayload) {
|
|
if (saving) {
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
try {
|
|
if (editingItemId) {
|
|
await updateKnowledgeBase({
|
|
id: editingItemId,
|
|
...payload,
|
|
});
|
|
const editingItem = knowledgeBases.find(
|
|
(item) => item.id === editingItemId,
|
|
);
|
|
toast.success(t("knowledge.baseUpdated", { name: editingItem?.name || payload.name }));
|
|
} else {
|
|
await createKnowledgeBase(payload);
|
|
toast.success(t("knowledge.baseCreated", { name: payload.name }));
|
|
}
|
|
setDialogOpen(false);
|
|
setEditingItemId(null);
|
|
await loadData();
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t("knowledge.baseSaveFailed"));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleDelete(item: KnowledgeBase) {
|
|
setDeleteLoadingId(item.id);
|
|
try {
|
|
await deleteKnowledgeBase(item.id);
|
|
toast.success(t("knowledge.baseDeleted", { name: item.name }));
|
|
if (selectedKnowledgeBaseId === item.id) {
|
|
onSelectKnowledgeBase(null);
|
|
}
|
|
await loadData();
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t("knowledge.baseDeleteFailed"));
|
|
} finally {
|
|
setDeleteLoadingId(null);
|
|
}
|
|
}
|
|
|
|
async function handleRebuildIndex(item: KnowledgeBase) {
|
|
setRebuildIndexLoadingId(item.id);
|
|
try {
|
|
await rebuildKnowledgeBaseIndex(item.id);
|
|
toast.success(t("knowledge.rebuildStarted", { name: item.name }));
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof Error ? error.message : t("knowledge.rebuildFailed"),
|
|
);
|
|
} finally {
|
|
setRebuildIndexLoadingId(null);
|
|
}
|
|
}
|
|
|
|
async function handleDragEnd(event: DragEndEvent) {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id || sorting) {
|
|
return;
|
|
}
|
|
|
|
const previousResults = knowledgeBases;
|
|
const oldIndex = previousResults.findIndex((item) => item.id === active.id);
|
|
const newIndex = previousResults.findIndex((item) => item.id === over.id);
|
|
if (oldIndex < 0 || newIndex < 0) {
|
|
return;
|
|
}
|
|
|
|
const nextResults = arrayMove(previousResults, oldIndex, newIndex);
|
|
setKnowledgeBases(nextResults);
|
|
setSorting(true);
|
|
|
|
try {
|
|
await updateKnowledgeBaseSort(nextResults.map((item) => item.id));
|
|
toast.success(t("knowledge.sortUpdated"));
|
|
await loadData();
|
|
} catch (error) {
|
|
setKnowledgeBases(previousResults);
|
|
toast.error(
|
|
error instanceof Error ? error.message : t("knowledge.sortUpdateFailed"),
|
|
);
|
|
} finally {
|
|
setSorting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="flex h-full flex-col border-r bg-muted/30">
|
|
<div className="flex flex-col gap-2 border-b bg-background p-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-sm font-semibold">{t("knowledge.title")}</h2>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-7"
|
|
onClick={() => void loadData()}
|
|
disabled={loading || sorting}
|
|
>
|
|
<RefreshCwIcon
|
|
className={loading || sorting ? "animate-spin" : "size-4"}
|
|
/>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-7"
|
|
onClick={openCreateDialog}
|
|
>
|
|
<PlusIcon className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative min-w-0 flex-1">
|
|
<SearchIcon className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={keywordInput}
|
|
onChange={(event) => setKeywordInput(event.target.value)}
|
|
onKeyDown={handleFilterKeyDown}
|
|
placeholder={t("knowledge.searchBase")}
|
|
className="h-8 pl-8 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="w-24 shrink-0">
|
|
<OptionCombobox
|
|
value={statusFilterInput}
|
|
onChange={handleStatusFilterChange}
|
|
options={statusOptions}
|
|
placeholder={t("knowledge.allStatus")}
|
|
searchPlaceholder={t("knowledge.searchStatus")}
|
|
emptyText={t("knowledge.emptyStatus")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<ScrollArea className="flex-1">
|
|
<div className="py-1 space-y-0.5">
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={(event) => void handleDragEnd(event)}
|
|
>
|
|
<SortableContext
|
|
items={knowledgeBases.map((item) => item.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
{knowledgeBases.map((item) => (
|
|
<SortableKnowledgeBaseCard
|
|
key={item.id}
|
|
item={item}
|
|
isSelected={selectedKnowledgeBaseId === item.id}
|
|
isContextMenuOpen={contextMenuKnowledgeBaseId === item.id}
|
|
disabled={loading || sorting}
|
|
onContextMenuOpenChange={(open) =>
|
|
setContextMenuKnowledgeBaseId(open ? item.id : null)
|
|
}
|
|
onSelect={() => onSelectKnowledgeBase(item)}
|
|
onEdit={() => openEditDialog(item)}
|
|
onDelete={() => void handleDelete(item)}
|
|
onRebuildIndex={() => void handleRebuildIndex(item)}
|
|
deleteLoadingId={deleteLoadingId}
|
|
rebuildIndexLoadingId={rebuildIndexLoadingId}
|
|
t={t}
|
|
/>
|
|
))}
|
|
</SortableContext>
|
|
</DndContext>
|
|
{!loading && knowledgeBases.length === 0 ? (
|
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
{t("knowledge.emptyBases")}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
<EditDialog
|
|
open={dialogOpen}
|
|
saving={saving}
|
|
itemId={editingItemId}
|
|
onOpenChange={handleDialogOpenChange}
|
|
onSubmit={handleSubmit}
|
|
/>
|
|
</>
|
|
);
|
|
}
|