"use client"; import { ChevronDownIcon, ChevronRightIcon, FolderIcon, FolderPlusIcon, LayersIcon, MoreHorizontalIcon, PencilIcon, PlusIcon, Trash2Icon, } from "lucide-react"; import type { ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react"; import { toast } from "sonner"; import { OptionCombobox } from "@/components/option-combobox"; import { ProjectDialog } from "@/components/project-dialog"; 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 { Field, FieldContent, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Textarea } from "@/components/ui/textarea"; import { createKnowledgeDirectory, deleteKnowledgeDirectory, fetchKnowledgeDirectories, updateKnowledgeDirectory, type KnowledgeDirectory, } from "@/lib/api/admin"; import { useI18n } from "@/i18n/provider"; import { cn } from "@/lib/utils"; type KnowledgeDirectoryPanelProps = { knowledgeBaseId: number; selectedDirectoryId: number | null; onSelectDirectory: (directoryId: number | null) => void; onChanged?: () => void; }; type DirectoryDialogState = { open: boolean; id: number | null; parentId: number; name: string; remark: string; }; type DirectoryOption = { value: string; label: string }; const DIRECTORY_PANEL_WIDTH_STORAGE_KEY = "knowledge-directory-panel-width"; const DIRECTORY_PANEL_MIN_WIDTH = 180; const DIRECTORY_PANEL_MAX_WIDTH = 360; const DIRECTORY_PANEL_DEFAULT_WIDTH = 224; function rootDirectoryOptions(items: KnowledgeDirectory[]): DirectoryOption[] { return items.map((item) => ({ value: String(item.id), label: item.name })); } function collectExpandedIds(items: KnowledgeDirectory[]) { const ids = new Set(); const walk = (nodes: KnowledgeDirectory[]) => { for (const node of nodes) { ids.add(node.id); walk(node.children || []); } }; walk(items); return ids; } export function KnowledgeDirectoryPanel({ knowledgeBaseId, selectedDirectoryId, onSelectDirectory, onChanged, }: KnowledgeDirectoryPanelProps) { const t = useI18n(); const directoryNameInputRef = useRef(null); const directoryNameFocusTimerRef = useRef(null); const [panelWidth, setPanelWidth] = useState(() => { if (typeof window === "undefined") { return DIRECTORY_PANEL_DEFAULT_WIDTH; } const saved = Number(localStorage.getItem(DIRECTORY_PANEL_WIDTH_STORAGE_KEY)); if (!Number.isFinite(saved)) { return DIRECTORY_PANEL_DEFAULT_WIDTH; } return Math.min(DIRECTORY_PANEL_MAX_WIDTH, Math.max(DIRECTORY_PANEL_MIN_WIDTH, saved)); }); const [directories, setDirectories] = useState([]); const [expandedIds, setExpandedIds] = useState>(new Set()); const [contextMenuDirectoryId, setContextMenuDirectoryId] = useState(null); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [dialog, setDialog] = useState({ open: false, id: null, parentId: 0, name: "", remark: "", }); const parentOptions = useMemo( () => [ { value: "0", label: t("knowledge.rootDirectory") }, ...rootDirectoryOptions(directories).filter((item) => item.value !== String(dialog.id ?? "")), ], [directories, dialog.id, t], ); const loadDirectories = useCallback(async () => { setLoading(true); try { const data = await fetchKnowledgeDirectories(knowledgeBaseId); setDirectories(data); setExpandedIds(collectExpandedIds(data)); } catch (error) { toast.error(error instanceof Error ? error.message : t("knowledge.loadDirectoriesFailed")); } finally { setLoading(false); } }, [knowledgeBaseId, t]); useEffect(() => { void loadDirectories(); }, [loadDirectories]); useEffect(() => { localStorage.setItem(DIRECTORY_PANEL_WIDTH_STORAGE_KEY, String(panelWidth)); }, [panelWidth]); useEffect(() => { if (!dialog.open) { return; } const frame = requestAnimationFrame(() => { directoryNameFocusTimerRef.current = window.setTimeout(() => { const input = directoryNameInputRef.current ?? document.querySelector("[data-knowledge-directory-name-input='true']"); input?.focus({ preventScroll: true }); input?.select(); }, 80); }); return () => { cancelAnimationFrame(frame); if (directoryNameFocusTimerRef.current !== null) { window.clearTimeout(directoryNameFocusTimerRef.current); directoryNameFocusTimerRef.current = null; } }; }, [dialog.open]); function handleResizePointerDown(event: PointerEvent) { event.preventDefault(); const startX = event.clientX; const startWidth = panelWidth; function handlePointerMove(moveEvent: globalThis.PointerEvent) { const nextWidth = startWidth + moveEvent.clientX - startX; setPanelWidth( Math.min(DIRECTORY_PANEL_MAX_WIDTH, Math.max(DIRECTORY_PANEL_MIN_WIDTH, nextWidth)), ); } function handlePointerUp() { window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); document.body.style.cursor = ""; document.body.style.userSelect = ""; } document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp); } function toggleDirectory(id: number) { setExpandedIds((current) => { const next = new Set(current); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); } function openCreate(parentId = 0) { setDialog({ open: true, id: null, parentId, name: "", remark: "" }); } function openEdit(item: KnowledgeDirectory) { setDialog({ open: true, id: item.id, parentId: item.parentId, name: item.name, remark: item.remark || "", }); } async function handleSubmit() { const name = dialog.name.trim(); if (!name) { toast.error(t("knowledge.directoryNameRequired")); return; } setSaving(true); try { if (dialog.id) { await updateKnowledgeDirectory({ id: dialog.id, knowledgeBaseId, parentId: dialog.parentId, name, remark: dialog.remark.trim(), }); toast.success(t("knowledge.directoryUpdated", { name })); } else { await createKnowledgeDirectory({ knowledgeBaseId, parentId: dialog.parentId, name, remark: dialog.remark.trim(), }); toast.success(t("knowledge.directoryCreated", { name })); } setDialog((current) => ({ ...current, open: false })); await loadDirectories(); onChanged?.(); } catch (error) { toast.error(error instanceof Error ? error.message : t("knowledge.directorySaveFailed")); } finally { setSaving(false); } } async function handleDelete(item: KnowledgeDirectory) { setSaving(true); try { await deleteKnowledgeDirectory(item.id); if (selectedDirectoryId === item.id) { onSelectDirectory(null); } toast.success(t("knowledge.directoryDeleted", { name: item.name })); await loadDirectories(); onChanged?.(); } catch (error) { toast.error(error instanceof Error ? error.message : t("knowledge.directoryDeleteFailed")); } finally { setSaving(false); } } return ( <>
{t("knowledge.directory")}
} label={t("knowledge.allContent")} selected={selectedDirectoryId === null} onClick={() => onSelectDirectory(null)} /> } label={t("knowledge.rootContent")} selected={selectedDirectoryId === 0} onClick={() => onSelectDirectory(0)} /> {directories.map((item) => ( setContextMenuDirectoryId(open ? directoryId : null) } onCreate={openCreate} onEdit={openEdit} onDelete={(directory) => void handleDelete(directory)} t={t} /> ))}
setDialog((current) => ({ ...current, open }))} title={dialog.id ? t("knowledge.editDirectory") : t("knowledge.createDirectory")} size="sm" footer={ <> } >
{t("knowledge.parentDirectory")} setDialog((current) => ({ ...current, parentId: Number(value ?? 0) })) } options={parentOptions} placeholder={t("knowledge.selectDirectory")} searchPlaceholder={t("knowledge.searchDirectory")} emptyText={t("knowledge.emptyDirectory")} /> {t("knowledge.directoryName")} setDialog((current) => ({ ...current, name: event.target.value })) } placeholder={t("knowledge.directoryNamePlaceholder")} /> {t("knowledge.remark")}