refactor: enhance FAQList component with improved filtering and action handling

This commit is contained in:
mlogclub
2026-06-02 15:49:40 +08:00
parent 24447d9cdf
commit d2e65415a8
@@ -1,16 +1,37 @@
"use client"; "use client";
import { WrenchIcon } from "lucide-react"; import {
import { useEffect, useMemo, useState } from "react"; MoreHorizontalIcon,
PencilIcon,
SearchIcon,
Trash2Icon,
WrenchIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
DashboardCrudPage, useDashboardPagedList,
type DashboardCrudActionState, type DashboardPagedListFilter,
type DashboardCrudColumn, } from "@/components/dashboard/list";
type DashboardCrudFilter, import { ListPagination } from "@/components/list-pagination";
} from "@/components/dashboard/crud"; import { OptionCombobox } from "@/components/option-combobox";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
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 { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -38,8 +59,11 @@ type FAQListProps = {
onActionStateChange?: (state: FAQListActionState) => void; onActionStateChange?: (state: FAQListActionState) => void;
}; };
export type FAQListActionState = DashboardCrudActionState & { export type FAQListActionState = {
onRefresh: () => void;
onCreate: () => void;
onImport: () => void; onImport: () => void;
loading: boolean;
importing: boolean; importing: boolean;
}; };
@@ -48,31 +72,16 @@ type TFunction = (key: string, values?: Record<string, string | number>) => stri
function getIndexStatusOptions(t: TFunction) { function getIndexStatusOptions(t: TFunction) {
return [ return [
{ value: "all", label: t("knowledge.allIndexStatus") }, { value: "all", label: t("knowledge.allIndexStatus") },
{ { value: KnowledgeDocumentIndexStatus.Pending, label: t("knowledge.indexPending") },
value: KnowledgeDocumentIndexStatus.Pending, { value: KnowledgeDocumentIndexStatus.Indexed, label: t("knowledge.indexIndexed") },
label: t("knowledge.indexPending"), { value: KnowledgeDocumentIndexStatus.Failed, label: t("knowledge.indexFailed") },
},
{
value: KnowledgeDocumentIndexStatus.Indexed,
label: t("knowledge.indexIndexed"),
},
{
value: KnowledgeDocumentIndexStatus.Failed,
label: t("knowledge.indexFailed"),
},
]; ];
} }
function getIndexStatusLabel(status: string, t: TFunction) { function getIndexStatusLabel(status: string, t: TFunction) {
if (status === KnowledgeDocumentIndexStatus.Pending) { if (status === KnowledgeDocumentIndexStatus.Pending) return t("knowledge.indexPending");
return t("knowledge.indexPending"); if (status === KnowledgeDocumentIndexStatus.Indexed) return t("knowledge.indexIndexed");
} if (status === KnowledgeDocumentIndexStatus.Failed) return t("knowledge.indexFailed");
if (status === KnowledgeDocumentIndexStatus.Indexed) {
return t("knowledge.indexIndexed");
}
if (status === KnowledgeDocumentIndexStatus.Failed) {
return t("knowledge.indexFailed");
}
return status || "-"; return status || "-";
} }
@@ -120,87 +129,151 @@ export function FAQList({
onActionStateChange, onActionStateChange,
}: FAQListProps) { }: FAQListProps) {
const t = useI18n(); const t = useI18n();
const [saving, setSaving] = useState(false);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedDirectoryId, setSelectedDirectoryId] = useState<number | null>(null); const [selectedDirectoryId, setSelectedDirectoryId] = useState<number | null>(null);
const [crudActionState, setCrudActionState] = const [dialogOpen, setDialogOpen] = useState(false);
useState<DashboardCrudActionState | null>(null); const [editingItem, setEditingItem] = useState<KnowledgeFAQ | null>(null);
const [actionLoadingMap, setActionLoadingMap] = useState<Record<number, { rebuildIndex: boolean; delete: boolean }>>({});
const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]); const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]);
useEffect(() => { useEffect(() => {
setSelectedDirectoryId(null); setSelectedDirectoryId(null);
}, [knowledgeBaseId]); }, [knowledgeBaseId]);
const filters = useMemo<DashboardPagedListFilter[]>(() => [
{
name: "question",
defaultValue: "",
trim: true,
},
{
name: "indexStatus",
defaultValue: "all",
allValue: "all",
},
], []);
const fetchList = useCallback(async (query: Record<string, string | number | undefined>) => {
return fetchKnowledgeFAQs({
knowledgeBaseId: knowledgeBaseId ?? 0,
directoryId: selectedDirectoryId === null ? undefined : selectedDirectoryId,
question: typeof query.question === "string" ? query.question : undefined,
indexStatus: typeof query.indexStatus === "string" ? query.indexStatus : undefined,
page: typeof query.page === "number" ? query.page : Number(query.page ?? 1),
limit: typeof query.limit === "number" ? query.limit : Number(query.limit ?? 20),
});
}, [knowledgeBaseId, selectedDirectoryId]);
const {
draftFilters,
setDraftFilter,
applyFilter,
applyFilters,
loading,
result: faqs,
loadData,
handlePageChange,
handleLimitChange,
} = useDashboardPagedList<KnowledgeFAQ>({
filters,
fetchList,
enabled: Boolean(knowledgeBaseId),
reloadKey: `${knowledgeBaseId ?? 0}-${selectedDirectoryId ?? "all"}`,
loadFailed: t("knowledge.loadFAQFailed"),
});
const openCreateDialog = useCallback(() => {
setEditingItem(null);
setDialogOpen(true);
}, []);
useEffect(() => { useEffect(() => {
if (!crudActionState) {
return;
}
onActionStateChange?.({ onActionStateChange?.({
...crudActionState, onRefresh: () => void loadData(),
onCreate: openCreateDialog,
onImport: () => setImportDialogOpen(true), onImport: () => setImportDialogOpen(true),
loading,
importing, importing,
}); });
}, [crudActionState, importing, onActionStateChange]); }, [loadData, loading, importing, onActionStateChange, openCreateDialog]);
const filters = useMemo<DashboardCrudFilter[]>( function handleIndexStatusFilterChange(value: string | null) {
() => [ applyFilter("indexStatus", value ?? "all");
{ }
name: "question",
label: t("knowledge.searchFAQ"),
placeholder: t("knowledge.searchFAQ"),
defaultValue: "",
trim: true,
className: "max-w-md flex-1",
},
{
name: "indexStatus",
label: t("knowledge.allIndexStatus"),
type: "select",
defaultValue: "all",
allValue: "all",
options: indexStatusOptions,
className: "w-full sm:w-48",
},
],
[indexStatusOptions, t],
);
const columns = useMemo<DashboardCrudColumn<KnowledgeFAQ>[]>( function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
() => [ if (event.key !== "Enter") {
{ return;
key: "question", }
label: t("knowledge.question"), event.preventDefault();
className: "max-w-sm", applyFilters();
render: (item) => ( }
<>
<div className="font-medium">{item.question}</div> function openEditDialog(item: KnowledgeFAQ) {
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground"> setEditingItem(item);
{item.answer} setDialogOpen(true);
</div> }
</>
), function handleDialogOpenChange(open: boolean) {
}, if (saving) {
{ return;
key: "indexStatus", }
label: t("knowledge.indexStatus"), if (!open) {
render: (item) => renderIndexStatusBadge(item, t), setEditingItem(null);
}, }
{ setDialogOpen(open);
key: "similarQuestions", }
label: t("knowledge.similarQuestions"),
render: (item) => async function handleSubmit(payload: CreateKnowledgeFAQPayload) {
Array.isArray(item.similarQuestions) if (saving) {
? item.similarQuestions.length return;
: 0, }
}, setSaving(true);
{ try {
key: "updatedAt", if (editingItem) {
label: t("knowledge.updatedAt"), await updateKnowledgeFAQ({ id: editingItem.id, ...payload });
render: (item) => formatDateTime(item.updatedAt), toast.success(t("knowledge.faqSaved"));
}, } else {
], await createKnowledgeFAQ(payload);
[t], toast.success(t("knowledge.faqSaved"));
); }
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.faqSaveFailed"));
} finally {
setSaving(false);
}
}
async function handleDelete(item: KnowledgeFAQ) {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: true } }));
try {
await deleteKnowledgeFAQ(item.id);
toast.success(t("knowledge.faqDeleted"));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.faqDeleteFailed"));
} finally {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: false } }));
}
}
async function handleBuildIndex(item: KnowledgeFAQ) {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: true } }));
try {
await buildKnowledgeFAQIndex(item.id);
toast.success(t("knowledge.faqIndexRebuilt"));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.faqIndexRebuildFailed"));
} finally {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: false } }));
}
}
if (!knowledgeBaseId) { if (!knowledgeBaseId) {
return ( return (
@@ -217,97 +290,130 @@ export function FAQList({
knowledgeBaseId={knowledgeBaseId} knowledgeBaseId={knowledgeBaseId}
selectedDirectoryId={selectedDirectoryId} selectedDirectoryId={selectedDirectoryId}
onSelectDirectory={setSelectedDirectoryId} onSelectDirectory={setSelectedDirectoryId}
onChanged={() => crudActionState?.onRefresh()} onChanged={() => void loadData()}
/> />
<div className="min-w-0 flex-1 p-4"> <div className="flex min-w-0 flex-1 flex-col">
<DashboardCrudPage<KnowledgeFAQ, CreateKnowledgeFAQPayload> <div className="flex flex-col gap-2 border-b bg-background px-6 py-2">
key={`${knowledgeBaseId}-${selectedDirectoryId ?? "all"}`} <div className="flex min-w-0 gap-2">
layout="fragment" <div className="relative min-w-0 flex-1">
showToolbarActions={false} <SearchIcon className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
filters={filters} <Input
columns={columns} value={String(draftFilters.question ?? "")}
fetchList={(query) => onChange={(event) => setDraftFilter("question", event.target.value)}
fetchKnowledgeFAQs({ onKeyDown={handleFilterKeyDown}
knowledgeBaseId, placeholder={t("knowledge.searchFAQ")}
directoryId: className="h-8 pl-8 text-xs"
selectedDirectoryId === null ? undefined : selectedDirectoryId, />
question: </div>
typeof query.question === "string" ? query.question : undefined, <div className="w-32 shrink-0">
indexStatus: <OptionCombobox
typeof query.indexStatus === "string" value={String(draftFilters.indexStatus ?? "all")}
? query.indexStatus onChange={handleIndexStatusFilterChange}
: undefined, options={indexStatusOptions}
page: Number(query.page), placeholder={t("knowledge.allIndexStatus")}
limit: Number(query.limit), searchPlaceholder={t("knowledge.searchStatus")}
}) emptyText={t("knowledge.emptyStatus")}
} />
getItemId={(item) => item.id} </div>
createItem={createKnowledgeFAQ} </div>
updateItem={(item, payload) => </div>
updateKnowledgeFAQ({ id: item.id, ...payload }) <div className="min-h-0 flex-1">
} <ScrollArea className="h-full">
deleteItem={(item) => deleteKnowledgeFAQ(item.id)} <div className="p-2 space-y-0.5">
rowActions={[ {faqs.results.map((item) => (
{ <ContextMenu key={item.id}>
key: "rebuild-index", <ContextMenuTrigger className="w-full">
icon: <WrenchIcon />, <div className="flex items-center gap-3 bg-background p-2 transition-colors hover:bg-accent w-full">
label: t("knowledge.rebuildIndex"), <div className="min-w-0 flex-1">
run: async ({ item, reload }) => { <div className="flex items-center gap-2">
try { <div className="truncate text-sm font-medium">{item.question}</div>
await buildKnowledgeFAQIndex(item.id); {renderIndexStatusBadge(item, t)}
toast.success(t("knowledge.faqIndexRebuilt")); </div>
await reload(); <div className="mt-1 line-clamp-1 text-xs text-muted-foreground">
} catch (error) { {item.answer}
toast.error( </div>
error instanceof Error </div>
? error.message <div className="hidden shrink-0 text-xs text-muted-foreground sm:block">
: t("knowledge.faqIndexRebuildFailed"), {formatDateTime(item.updatedAt)}
); </div>
} <DropdownMenu>
}, <DropdownMenuTrigger
}, render={<Button variant="ghost" size="icon" className="size-6" />}
]} aria-label={t("knowledge.moreActions", { name: item.question })}
renderEditDialog={({ >
open, <MoreHorizontalIcon className="size-3.5" />
saving, </DropdownMenuTrigger>
itemId, <DropdownMenuContent align="end" className="w-32 min-w-32">
onOpenChange, <DropdownMenuItem onClick={() => openEditDialog(item)}>
onSubmit, <PencilIcon className="mr-2 size-3.5" />
}) => ( {t("knowledge.edit")}
<FAQEditDialog </DropdownMenuItem>
open={open} <DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
saving={saving} <WrenchIcon className="mr-2 size-3.5" />
itemId={itemId} {actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
knowledgeBaseId={knowledgeBaseId} </DropdownMenuItem>
initialDirectoryId={selectedDirectoryId ?? 0} <DropdownMenuItem
onOpenChange={onOpenChange} onClick={() => void handleDelete(item)}
onSubmit={onSubmit} className="text-destructive focus:text-destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</ContextMenuItem>
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => void handleDelete(item)}
variant="destructive"
disabled={actionLoadingMap[item.id]?.delete}
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
{!loading && faqs.results.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t("knowledge.emptyFAQ")}
</div>
) : null}
</div>
</ScrollArea>
</div>
<div className="border-t px-6 py-3">
<ListPagination
page={faqs.page.page}
limit={faqs.page.limit}
total={faqs.page.total}
loading={loading}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/> />
)} </div>
onActionStateChange={setCrudActionState}
labels={{
refresh: t("knowledge.refreshFAQ"),
create: t("knowledge.newFAQ"),
query: t("knowledge.query"),
loading: t("knowledge.loadingFAQ"),
empty: t("knowledge.emptyFAQ"),
actions: t("knowledge.actions"),
edit: t("knowledge.edit"),
delete: t("knowledge.delete"),
processing: t("knowledge.deleting"),
moreActions: (item) =>
t("knowledge.moreActions", { name: item.question }),
loadFailed: t("knowledge.loadFAQFailed"),
saveFailed: t("knowledge.faqSaveFailed"),
deleteFailed: t("knowledge.faqDeleteFailed"),
created: () => t("knowledge.faqSaved"),
updated: () => t("knowledge.faqSaved"),
deleted: () => t("knowledge.faqDeleted"),
}}
/>
</div> </div>
</div> </div>
<FAQEditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
knowledgeBaseId={knowledgeBaseId}
initialDirectoryId={selectedDirectoryId ?? 0}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
<FAQImportDialog <FAQImportDialog
open={importDialogOpen} open={importDialogOpen}
knowledgeBaseId={knowledgeBaseId} knowledgeBaseId={knowledgeBaseId}
@@ -315,7 +421,7 @@ export function FAQList({
onOpenChange={setImportDialogOpen} onOpenChange={setImportDialogOpen}
onImportingChange={setImporting} onImportingChange={setImporting}
onImported={async () => { onImported={async () => {
crudActionState?.onRefresh(); await loadData();
}} }}
/> />
</> </>