feat: add knowledge directory management for documents and FAQs

- Implemented directory validation in Create and Update knowledge document services.
- Added directory selection in document and FAQ edit dialogs with a new KnowledgeDirectoryPanel component.
- Enhanced API to support fetching, creating, updating, and deleting knowledge directories.
- Updated data models to include directory information for knowledge documents and FAQs.
- Added translations for directory-related messages in English and Chinese.
This commit is contained in:
mlogclub
2026-06-02 15:37:50 +08:00
parent 32af090ab8
commit 853ae71b3d
26 changed files with 1442 additions and 19 deletions
@@ -6,13 +6,16 @@ import { useForm, type Resolver } from "react-hook-form";
import { z } from "zod/v4";
import { ProjectDialog } from "@/components/project-dialog";
import { OptionCombobox } from "@/components/option-combobox";
import { Button } from "@/components/ui/button";
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
fetchKnowledgeFAQ,
fetchKnowledgeDirectories,
type CreateKnowledgeFAQPayload,
type KnowledgeDirectory,
type KnowledgeFAQ,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
@@ -22,6 +25,7 @@ type FAQEditDialogProps = {
saving: boolean;
itemId: number | null;
knowledgeBaseId: number | null;
initialDirectoryId?: number;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateKnowledgeFAQPayload) => Promise<void>;
};
@@ -30,6 +34,7 @@ type TFunction = (key: string, values?: Record<string, string | number>) => stri
function createFormSchema(t: TFunction) {
return z.object({
directoryId: z.string().trim(),
question: z.string().trim().min(1, t("knowledge.faqQuestionRequired")).max(500, t("knowledge.faqQuestionMax")),
answer: z.string().trim().min(1, t("knowledge.faqAnswerRequired")),
similarQuestionsText: z.string(),
@@ -38,6 +43,7 @@ function createFormSchema(t: TFunction) {
}
type EditForm = {
directoryId: string;
question: string;
answer: string;
similarQuestionsText: string;
@@ -45,17 +51,28 @@ type EditForm = {
};
const emptyForm: EditForm = {
directoryId: "0",
question: "",
answer: "",
similarQuestionsText: "",
remark: "",
};
function buildForm(item: KnowledgeFAQ | null): EditForm {
type DirectoryOption = { value: string; label: string };
function flattenDirectoryOptions(items: KnowledgeDirectory[], depth = 0): DirectoryOption[] {
return items.flatMap((item) => [
{ value: String(item.id), label: `${depth > 0 ? " " : ""}${item.name}` },
...flattenDirectoryOptions(item.children || [], depth + 1),
]);
}
function buildForm(item: KnowledgeFAQ | null, initialDirectoryId = 0): EditForm {
if (!item) {
return emptyForm;
return { ...emptyForm, directoryId: String(initialDirectoryId) };
}
return {
directoryId: String(item.directoryId || 0),
question: item.question,
answer: item.answer,
similarQuestionsText: (item.similarQuestions ?? []).join("\n"),
@@ -66,6 +83,7 @@ function buildForm(item: KnowledgeFAQ | null): EditForm {
function buildPayload(form: EditForm, knowledgeBaseId: number): CreateKnowledgeFAQPayload {
return {
knowledgeBaseId,
directoryId: Number(form.directoryId),
question: form.question.trim(),
answer: form.answer.trim(),
similarQuestions: form.similarQuestionsText
@@ -81,6 +99,7 @@ export function FAQEditDialog({
saving,
itemId,
knowledgeBaseId,
initialDirectoryId = 0,
onOpenChange,
onSubmit,
}: FAQEditDialogProps) {
@@ -94,6 +113,7 @@ export function FAQEditDialog({
saving={saving}
itemId={itemId}
knowledgeBaseId={knowledgeBaseId}
initialDirectoryId={initialDirectoryId}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
@@ -105,6 +125,7 @@ type FAQEditDialogBodyProps = {
saving: boolean;
itemId: number | null;
knowledgeBaseId: number;
initialDirectoryId: number;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateKnowledgeFAQPayload) => Promise<void>;
};
@@ -114,11 +135,13 @@ function FAQEditDialogBody({
saving,
itemId,
knowledgeBaseId,
initialDirectoryId,
onOpenChange,
onSubmit,
}: FAQEditDialogBodyProps) {
const t = useI18n();
const [loading, setLoading] = useState(false);
const [directories, setDirectories] = useState<KnowledgeDirectory[]>([]);
const formId = "knowledge-faq-edit-form";
const formSchema = useMemo(() => createFormSchema(t), [t]);
const resolver = useMemo(
@@ -135,11 +158,18 @@ function FAQEditDialogBody({
register,
formState: { errors },
} = form;
const directoryOptions = useMemo(
() => [
{ value: "0", label: t("knowledge.rootContent") },
...flattenDirectoryOptions(directories),
],
[directories, t],
);
useEffect(() => {
async function loadDetail() {
if (!itemId) {
reset(emptyForm);
reset(buildForm(null, initialDirectoryId));
return;
}
setLoading(true);
@@ -153,7 +183,28 @@ function FAQEditDialogBody({
if (open) {
void loadDetail();
}
}, [itemId, open, reset]);
}, [itemId, initialDirectoryId, open, reset]);
useEffect(() => {
if (!open) {
return;
}
let cancelled = false;
async function loadDirectories() {
try {
const data = await fetchKnowledgeDirectories(knowledgeBaseId);
if (!cancelled) {
setDirectories(data);
}
} catch (error) {
console.error("Failed to load knowledge directories:", error);
}
}
void loadDirectories();
return () => {
cancelled = true;
};
}, [knowledgeBaseId, open]);
async function onFormSubmit(values: EditForm) {
await onSubmit(buildPayload(values, knowledgeBaseId));
@@ -181,6 +232,21 @@ function FAQEditDialogBody({
<div className="flex items-center justify-center py-12 text-muted-foreground">{t("knowledge.loading")}</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.directoryId}>
<FieldLabel>{t("knowledge.directory")}</FieldLabel>
<FieldContent>
<OptionCombobox
value={form.watch("directoryId")}
onChange={(value) => form.setValue("directoryId", value ?? "0", { shouldDirty: true })}
options={directoryOptions}
placeholder={t("knowledge.selectDirectory")}
searchPlaceholder={t("knowledge.searchDirectory")}
emptyText={t("knowledge.emptyDirectory")}
/>
<FieldError errors={[errors.directoryId]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.question}>
<FieldLabel htmlFor="faq-question">{t("knowledge.standardQuestion")}</FieldLabel>
<FieldContent>