feat(knowledge): add FAQ Excel import export
This commit is contained in:
@@ -1,209 +1,36 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { DownloadIcon, FileUpIcon, InfoIcon } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DownloadIcon, FileUpIcon, InfoIcon } from "lucide-react"
|
||||
import { useMemo, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { ProjectDialog } from "@/components/project-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { createKnowledgeFAQ, type CreateKnowledgeFAQPayload } from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
downloadKnowledgeFAQImportTemplate,
|
||||
importKnowledgeFAQs,
|
||||
type KnowledgeFAQImportMode,
|
||||
type KnowledgeFAQImportResult,
|
||||
} from "@/lib/api/admin"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type FAQImportDialogProps = {
|
||||
open: boolean;
|
||||
knowledgeBaseId: number | null;
|
||||
importing: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onImportingChange: (importing: boolean) => void;
|
||||
onImported: () => Promise<void>;
|
||||
};
|
||||
|
||||
type ParsedFAQRow = {
|
||||
rowNo: number;
|
||||
question: string;
|
||||
answer: string;
|
||||
similarQuestions: string[];
|
||||
remark: string;
|
||||
};
|
||||
|
||||
type ParseResult = {
|
||||
rows: ParsedFAQRow[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
const acceptedHeaderMap: Record<string, keyof Omit<ParsedFAQRow, "rowNo">> = {
|
||||
question: "question",
|
||||
answer: "answer",
|
||||
similarquestions: "similarQuestions",
|
||||
"similarQuestions": "similarQuestions",
|
||||
remark: "remark",
|
||||
"\u6807\u51c6\u95ee\u9898": "question",
|
||||
"\u95ee\u9898": "question",
|
||||
"\u7b54\u6848": "answer",
|
||||
"\u76f8\u4f3c\u95ee": "similarQuestions",
|
||||
"\u76f8\u4f3c\u95ee\u9898": "similarQuestions",
|
||||
"\u5907\u6ce8": "remark",
|
||||
};
|
||||
|
||||
function normalizeHeader(value: string) {
|
||||
return value.trim().replace(/^\uFEFF/, "").toLowerCase();
|
||||
}
|
||||
|
||||
function parseDelimitedText(input: string): string[][] {
|
||||
const text = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let cell = "";
|
||||
let inQuotes = false;
|
||||
let delimiter = ",";
|
||||
|
||||
function pushCell() {
|
||||
row.push(cell.trim());
|
||||
cell = "";
|
||||
}
|
||||
|
||||
function pushRow() {
|
||||
if (row.length === 1 && row[0] === "" && rows.length === 0) {
|
||||
row = [];
|
||||
return;
|
||||
}
|
||||
if (row.some((item) => item !== "")) {
|
||||
rows.push(row);
|
||||
}
|
||||
row = [];
|
||||
}
|
||||
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (!inQuotes && rows.length === 0 && row.length === 0 && cell.length > 0 && char === "\t") {
|
||||
delimiter = "\t";
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && next === '"') {
|
||||
cell += '"';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
inQuotes = !inQuotes;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuotes && char === delimiter) {
|
||||
pushCell();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuotes && char === "\n") {
|
||||
pushCell();
|
||||
pushRow();
|
||||
continue;
|
||||
}
|
||||
|
||||
cell += char;
|
||||
}
|
||||
|
||||
if (cell.length > 0 || row.length > 0) {
|
||||
pushCell();
|
||||
pushRow();
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function parseSimilarQuestions(value: string) {
|
||||
return value
|
||||
.split(/\r?\n|\|/g)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseFAQFileContent(input: string, t: TFunction): ParseResult {
|
||||
const table = parseDelimitedText(input);
|
||||
if (table.length === 0) {
|
||||
throw new Error(t("knowledge.fileEmpty"));
|
||||
}
|
||||
|
||||
const headerRow = table[0];
|
||||
const headerMap = new Map<keyof Omit<ParsedFAQRow, "rowNo">, number>();
|
||||
for (let index = 0; index < headerRow.length; index += 1) {
|
||||
const header = acceptedHeaderMap[normalizeHeader(headerRow[index])];
|
||||
if (header && !headerMap.has(header)) {
|
||||
headerMap.set(header, index);
|
||||
}
|
||||
}
|
||||
|
||||
if (!headerMap.has("question") || !headerMap.has("answer")) {
|
||||
throw new Error(t("knowledge.missingFAQColumns"));
|
||||
}
|
||||
|
||||
const rows: ParsedFAQRow[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (let index = 1; index < table.length; index += 1) {
|
||||
const current = table[index];
|
||||
const rowNo = index + 1;
|
||||
const question = current[headerMap.get("question") ?? -1]?.trim() ?? "";
|
||||
const answer = current[headerMap.get("answer") ?? -1]?.trim() ?? "";
|
||||
const similarQuestionsRaw = current[headerMap.get("similarQuestions") ?? -1]?.trim() ?? "";
|
||||
const remark = current[headerMap.get("remark") ?? -1]?.trim() ?? "";
|
||||
|
||||
if (!question && !answer && !similarQuestionsRaw && !remark) {
|
||||
continue;
|
||||
}
|
||||
if (!question || !answer) {
|
||||
warnings.push(t("knowledge.skipRowMissingFAQ", { row: rowNo }));
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
rowNo,
|
||||
question,
|
||||
answer,
|
||||
similarQuestions: parseSimilarQuestions(similarQuestionsRaw),
|
||||
remark,
|
||||
});
|
||||
}
|
||||
|
||||
return { rows, warnings };
|
||||
}
|
||||
|
||||
function downloadTemplate() {
|
||||
const templateContent = [
|
||||
"question,answer,similarQuestions,remark",
|
||||
'"How do I reset my password?","Open profile settings and choose Reset Password.","forgot password|where is reset password","Account FAQ"',
|
||||
'"Which channels are supported?","Web chat and WeCom customer service channels are currently supported.","available channels|supported channels","Channel guide"',
|
||||
].join("\n");
|
||||
const blob = new Blob([templateContent], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "knowledge-faq-import-template.csv";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function buildPayload(row: ParsedFAQRow, knowledgeBaseId: number): CreateKnowledgeFAQPayload {
|
||||
return {
|
||||
knowledgeBaseId,
|
||||
question: row.question,
|
||||
answer: row.answer,
|
||||
similarQuestions: row.similarQuestions,
|
||||
remark: row.remark,
|
||||
};
|
||||
open: boolean
|
||||
knowledgeBaseId: number | null
|
||||
importing: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onImportingChange: (importing: boolean) => void
|
||||
onImported: () => Promise<void>
|
||||
}
|
||||
|
||||
export function FAQImportDialog({
|
||||
@@ -214,85 +41,86 @@ export function FAQImportDialog({
|
||||
onImportingChange,
|
||||
onImported,
|
||||
}: FAQImportDialogProps) {
|
||||
const t = useI18n();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [fileName, setFileName] = useState("");
|
||||
const [rows, setRows] = useState<ParsedFAQRow[]>([]);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const t = useI18n()
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [mode, setMode] = useState<KnowledgeFAQImportMode>("append")
|
||||
const [result, setResult] = useState<KnowledgeFAQImportResult | null>(null)
|
||||
|
||||
const previewRows = useMemo(() => rows.slice(0, 5), [rows]);
|
||||
const modeOptions = useMemo(
|
||||
() => [
|
||||
{ value: "append", label: t("knowledge.importModeAppend") },
|
||||
{ value: "overwrite", label: t("knowledge.importModeOverwrite") },
|
||||
],
|
||||
[t],
|
||||
)
|
||||
|
||||
function resetState() {
|
||||
setFileName("");
|
||||
setRows([]);
|
||||
setWarnings([]);
|
||||
setFile(null)
|
||||
setMode("append")
|
||||
setResult(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const nextFile = event.target.files?.[0] ?? null
|
||||
setResult(null)
|
||||
if (!nextFile) {
|
||||
setFile(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
const parsed = parseFAQFileContent(content, t);
|
||||
setFileName(file.name);
|
||||
setRows(parsed.rows);
|
||||
setWarnings(parsed.warnings);
|
||||
if (parsed.rows.length === 0) {
|
||||
toast.error(t("knowledge.importNoRows"));
|
||||
} else {
|
||||
toast.success(t("knowledge.parsedFAQRows", { count: parsed.rows.length }));
|
||||
if (!nextFile.name.toLowerCase().endsWith(".xlsx")) {
|
||||
setFile(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
toast.error(t("knowledge.importXlsxOnly"))
|
||||
return
|
||||
}
|
||||
setFile(nextFile)
|
||||
}
|
||||
|
||||
async function handleDownloadTemplate() {
|
||||
try {
|
||||
await downloadKnowledgeFAQImportTemplate()
|
||||
} catch (error) {
|
||||
resetState();
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.parseImportFailed"));
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.downloadTemplateFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!knowledgeBaseId || rows.length === 0 || importing) {
|
||||
return;
|
||||
if (!knowledgeBaseId || !file || importing) {
|
||||
return
|
||||
}
|
||||
|
||||
onImportingChange(true);
|
||||
let successCount = 0;
|
||||
const failedRows: string[] = [];
|
||||
|
||||
onImportingChange(true)
|
||||
setResult(null)
|
||||
try {
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await createKnowledgeFAQ(buildPayload(row, knowledgeBaseId));
|
||||
successCount += 1;
|
||||
} catch (error) {
|
||||
failedRows.push(
|
||||
t("knowledge.importRowFailed", {
|
||||
row: row.rowNo,
|
||||
message: error instanceof Error ? error.message : t("knowledge.importFailed"),
|
||||
})
|
||||
);
|
||||
}
|
||||
const data = await importKnowledgeFAQs({
|
||||
knowledgeBaseId,
|
||||
mode,
|
||||
file,
|
||||
})
|
||||
setResult(data)
|
||||
await onImported()
|
||||
toast.success(
|
||||
t("knowledge.importFAQResultToast", {
|
||||
created: data.created,
|
||||
updated: data.updated,
|
||||
skipped: data.skipped,
|
||||
failed: data.failed,
|
||||
}),
|
||||
)
|
||||
if (data.failed === 0) {
|
||||
resetState()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
await onImported();
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(t("knowledge.importSuccess", { count: successCount }));
|
||||
}
|
||||
if (failedRows.length > 0) {
|
||||
toast.error(t("knowledge.importSomeFailed", { count: failedRows.length }));
|
||||
setWarnings((current) => [...current, ...failedRows]);
|
||||
return;
|
||||
}
|
||||
|
||||
resetState();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.importFailed"))
|
||||
} finally {
|
||||
onImportingChange(false);
|
||||
onImportingChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,33 +129,49 @@ export function FAQImportDialog({
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && !importing) {
|
||||
resetState();
|
||||
resetState()
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
onOpenChange(nextOpen)
|
||||
}}
|
||||
title={t("knowledge.importFAQTitle")}
|
||||
description={t("knowledge.importFAQDescription")}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={() => downloadTemplate()}>
|
||||
<Button type="button" variant="outline" onClick={() => void handleDownloadTemplate()}>
|
||||
<DownloadIcon className="size-4" />
|
||||
{t("knowledge.downloadTemplate")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={importing}>
|
||||
{t("knowledge.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void handleImport()} disabled={importing || rows.length === 0}>
|
||||
{importing
|
||||
? t("knowledge.importing")
|
||||
: rows.length > 0
|
||||
? t("knowledge.startImportWithCount", { count: rows.length })
|
||||
: t("knowledge.startImport")}
|
||||
<Button type="button" onClick={() => void handleImport()} disabled={importing || !file}>
|
||||
{importing ? t("knowledge.importing") : t("knowledge.startImport")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel>{t("knowledge.importMode")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<OptionCombobox
|
||||
value={mode}
|
||||
options={modeOptions}
|
||||
placeholder={t("knowledge.selectImportMode")}
|
||||
searchPlaceholder={t("knowledge.searchImportMode")}
|
||||
emptyText={t("knowledge.emptyImportMode")}
|
||||
disabled={importing}
|
||||
onChange={(value) => setMode(value as KnowledgeFAQImportMode)}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{mode === "overwrite"
|
||||
? t("knowledge.importModeOverwriteDescription")
|
||||
: t("knowledge.importModeAppendDescription")}
|
||||
</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="faq-import-file">{t("knowledge.importFile")}</FieldLabel>
|
||||
<FieldContent>
|
||||
@@ -336,79 +180,76 @@ export function FAQImportDialog({
|
||||
id="faq-import-file"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv,.txt"
|
||||
onChange={(event) => void handleFileChange(event)}
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
disabled={importing}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={importing}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<FileUpIcon className="size-4" />
|
||||
{t("knowledge.chooseFile")}
|
||||
</Button>
|
||||
</div>
|
||||
<FieldDescription>
|
||||
{t("knowledge.importFileDescription")}
|
||||
</FieldDescription>
|
||||
<FieldDescription>{t("knowledge.importFileDescription")}</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{fileName ? (
|
||||
{file ? (
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
||||
{t("knowledge.currentFile", { name: fileName })}
|
||||
{t("knowledge.currentFile", { name: file.name })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{warnings.length > 0 ? (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
<div className="mb-2 flex items-center gap-2 font-medium">
|
||||
<InfoIcon className="size-4" />
|
||||
{t("knowledge.importHint")}
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{warnings.map((item, index) => (
|
||||
<li key={`${item}-${index}`}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
<div className="mb-2 flex items-center gap-2 font-medium">
|
||||
<InfoIcon className="size-4" />
|
||||
{t("knowledge.importHint")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-md border">
|
||||
<div className="border-b px-4 py-3 text-sm font-medium">
|
||||
{t("knowledge.importPreview")}
|
||||
</div>
|
||||
{previewRows.length > 0 ? (
|
||||
<ScrollArea className="max-h-80">
|
||||
<div className="divide-y">
|
||||
{previewRows.map((row) => (
|
||||
<div key={row.rowNo} className="space-y-2 px-4 py-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("knowledge.rowNumber", { row: row.rowNo })}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{row.question}</div>
|
||||
<div className="mt-1 whitespace-pre-wrap text-muted-foreground">
|
||||
{row.answer}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t("knowledge.similarQuestionShort", {
|
||||
value: row.similarQuestions.length > 0 ? row.similarQuestions.join(" / ") : t("knowledge.none"),
|
||||
})}
|
||||
</div>
|
||||
<div className="text-muted-foreground">{t("knowledge.remark")}:{row.remark || t("knowledge.none")}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
{t("knowledge.previewAfterUpload")}
|
||||
</div>
|
||||
)}
|
||||
<div>{t("knowledge.importXlsxHint")}</div>
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<div className="rounded-md border">
|
||||
<div className="border-b px-4 py-3 text-sm font-medium">
|
||||
{t("knowledge.importResult")}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 p-4 text-sm sm:grid-cols-5">
|
||||
<ImportMetric label={t("knowledge.importTotal")} value={result.total} />
|
||||
<ImportMetric label={t("knowledge.importCreated")} value={result.created} />
|
||||
<ImportMetric label={t("knowledge.importUpdated")} value={result.updated} />
|
||||
<ImportMetric label={t("knowledge.importSkipped")} value={result.skipped} />
|
||||
<ImportMetric label={t("knowledge.importFailedCount")} value={result.failed} />
|
||||
</div>
|
||||
{result.errors.length > 0 ? (
|
||||
<ScrollArea className="max-h-64 border-t">
|
||||
<ul className="divide-y text-sm">
|
||||
{result.errors.map((item, index) => (
|
||||
<li key={`${item.row}-${index}`} className="px-4 py-2">
|
||||
{t("knowledge.importRowFailed", {
|
||||
row: item.row,
|
||||
message: item.message,
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
</ProjectDialog>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ImportMetric({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/30 px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-lg font-semibold">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@/components/ui/sheet"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import type { KnowledgeBase } from "@/lib/api/admin"
|
||||
import { exportKnowledgeFAQs } from "@/lib/api/admin"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
Bug,
|
||||
@@ -18,9 +19,11 @@ import {
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon
|
||||
RefreshCwIcon,
|
||||
UploadIcon,
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { DebugPanel } from "./_components/debug-panel"
|
||||
import { DocumentList, type DocumentListActionState } from "./_components/document-list"
|
||||
import { FAQList, type FAQListActionState } from "./_components/faq-list"
|
||||
@@ -35,8 +38,24 @@ export default function DashboardKnowledgeDocumentsPage() {
|
||||
const [activeTab, setActiveTab] = useState("documents")
|
||||
const [documentActionState, setDocumentActionState] = useState<DocumentListActionState | null>(null)
|
||||
const [faqActionState, setFAQActionState] = useState<FAQListActionState | null>(null)
|
||||
const [exportingFAQ, setExportingFAQ] = useState(false)
|
||||
const isFAQKnowledgeBase = selectedKnowledgeBase?.knowledgeType === "faq"
|
||||
|
||||
async function handleExportFAQ() {
|
||||
if (!selectedKnowledgeBase || exportingFAQ) {
|
||||
return
|
||||
}
|
||||
setExportingFAQ(true)
|
||||
try {
|
||||
await exportKnowledgeFAQs(selectedKnowledgeBase.id)
|
||||
toast.success(t("knowledge.exportFAQSuccess"))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.exportFAQFailed"))
|
||||
} finally {
|
||||
setExportingFAQ(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)]">
|
||||
<div
|
||||
@@ -142,7 +161,17 @@ export default function DashboardKnowledgeDocumentsPage() {
|
||||
disabled={faqActionState.importing}
|
||||
aria-label={t("knowledge.importFAQ")}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
<UploadIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => void handleExportFAQ()}
|
||||
disabled={exportingFAQ}
|
||||
aria-label={t("knowledge.exportFAQ")}
|
||||
>
|
||||
<DownloadIcon className={exportingFAQ ? "size-4 animate-pulse" : "size-4"} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
+53
-1
@@ -1,5 +1,5 @@
|
||||
import { readSession } from "@/lib/auth"
|
||||
import { request } from "@/lib/api/client"
|
||||
import { request, requestBlob } from "@/lib/api/client"
|
||||
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
|
||||
import { translateCurrentMessage } from "@/i18n/messages"
|
||||
|
||||
@@ -1524,6 +1524,22 @@ export type UpdateKnowledgeFAQPayload = CreateKnowledgeFAQPayload & {
|
||||
id: number
|
||||
}
|
||||
|
||||
export type KnowledgeFAQImportMode = "append" | "overwrite"
|
||||
|
||||
export type KnowledgeFAQImportError = {
|
||||
row: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export type KnowledgeFAQImportResult = {
|
||||
total: number
|
||||
created: number
|
||||
updated: number
|
||||
skipped: number
|
||||
failed: number
|
||||
errors: KnowledgeFAQImportError[]
|
||||
}
|
||||
|
||||
export function fetchKnowledgeBases(
|
||||
query?: Record<string, string | number | undefined>
|
||||
) {
|
||||
@@ -1652,6 +1668,42 @@ export function deleteKnowledgeFAQ(id: number) {
|
||||
})
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export async function downloadKnowledgeFAQImportTemplate() {
|
||||
const result = await requestBlob("/api/dashboard/knowledge-faq/import_template")
|
||||
downloadBlob(result.blob, result.filename || "knowledge-faq-import-template.xlsx")
|
||||
}
|
||||
|
||||
export async function exportKnowledgeFAQs(knowledgeBaseId: number) {
|
||||
const result = await requestBlob(
|
||||
`/api/dashboard/knowledge-faq/export${toQueryString({ knowledgeBaseId })}`
|
||||
)
|
||||
downloadBlob(result.blob, result.filename || `knowledge-faq-${knowledgeBaseId}.xlsx`)
|
||||
}
|
||||
|
||||
export function importKnowledgeFAQs(payload: {
|
||||
knowledgeBaseId: number
|
||||
mode: KnowledgeFAQImportMode
|
||||
file: File
|
||||
}) {
|
||||
const formData = new FormData()
|
||||
formData.set("knowledgeBaseId", String(payload.knowledgeBaseId))
|
||||
formData.set("mode", payload.mode)
|
||||
formData.set("file", payload.file)
|
||||
return request<KnowledgeFAQImportResult>("/api/dashboard/knowledge-faq/import", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
|
||||
export function buildKnowledgeFAQIndex(faqId: number) {
|
||||
return request<void>("/api/dashboard/knowledge-retrieve/build", {
|
||||
method: "POST",
|
||||
|
||||
+65
-9
@@ -18,6 +18,11 @@ type RequestOptions = RequestInit & {
|
||||
onResponse?: (response: Response) => void
|
||||
}
|
||||
|
||||
export type BlobResponse = {
|
||||
blob: Blob
|
||||
filename: string
|
||||
}
|
||||
|
||||
async function parseResult<T>(response: Response) {
|
||||
const payload = (await response.json()) as JsonResult<T>
|
||||
if (!response.ok || !payload.success) {
|
||||
@@ -31,13 +36,7 @@ async function parseResult<T>(response: Response) {
|
||||
return payload.data
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
|
||||
delete (rest as RequestOptions).baseUrl
|
||||
delete (rest as RequestOptions).onResponse
|
||||
function buildRequestHeaders(headers: HeadersInit | undefined, skipAuth?: boolean, body?: BodyInit | null) {
|
||||
const session = readSession()
|
||||
const authHeaders = new Headers(headers)
|
||||
|
||||
@@ -46,14 +45,37 @@ export async function request<T>(
|
||||
}
|
||||
if (
|
||||
!authHeaders.has("Content-Type") &&
|
||||
rest.body &&
|
||||
!(typeof FormData !== "undefined" && rest.body instanceof FormData)
|
||||
body &&
|
||||
!(typeof FormData !== "undefined" && body instanceof FormData)
|
||||
) {
|
||||
authHeaders.set("Content-Type", "application/json")
|
||||
}
|
||||
const locale = readStoredLocale()
|
||||
authHeaders.set("Accept-Language", locale)
|
||||
authHeaders.set("X-Locale", locale)
|
||||
return authHeaders
|
||||
}
|
||||
|
||||
function parseFilename(contentDisposition: string | null) {
|
||||
if (!contentDisposition) {
|
||||
return ""
|
||||
}
|
||||
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
|
||||
if (utf8Match?.[1]) {
|
||||
return decodeURIComponent(utf8Match[1])
|
||||
}
|
||||
const match = contentDisposition.match(/filename="?([^";]+)"?/i)
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : ""
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
|
||||
delete (rest as RequestOptions).baseUrl
|
||||
delete (rest as RequestOptions).onResponse
|
||||
const authHeaders = buildRequestHeaders(headers, skipAuth, rest.body)
|
||||
|
||||
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
|
||||
const response = await fetch(`${requestBaseUrl}${path}`, {
|
||||
@@ -65,3 +87,37 @@ export async function request<T>(
|
||||
|
||||
return parseResult<T>(response)
|
||||
}
|
||||
|
||||
export async function requestBlob(
|
||||
path: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<BlobResponse> {
|
||||
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
|
||||
delete (rest as RequestOptions).baseUrl
|
||||
delete (rest as RequestOptions).onResponse
|
||||
const authHeaders = buildRequestHeaders(headers, skipAuth, rest.body)
|
||||
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
|
||||
const response = await fetch(`${requestBaseUrl}${path}`, {
|
||||
...rest,
|
||||
headers: authHeaders,
|
||||
cache: "no-store",
|
||||
})
|
||||
onResponse?.(response)
|
||||
|
||||
const contentType = response.headers.get("Content-Type") ?? ""
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
await parseResult<never>(response)
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
throw new Error(translateCurrentMessage("api.requestFailed"))
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText || translateCurrentMessage("api.requestFailed"))
|
||||
}
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
filename: parseFilename(response.headers.get("Content-Disposition")),
|
||||
}
|
||||
}
|
||||
|
||||
+23
-15
@@ -1894,32 +1894,40 @@
|
||||
"answer": "Answer",
|
||||
"answerPlaceholder": "Enter the FAQ answer",
|
||||
"similarQuestionsPlaceholder": "One similar question per line",
|
||||
"fileEmpty": "The file is empty.",
|
||||
"missingFAQColumns": "The import template must include question and answer columns.",
|
||||
"skipRowMissingFAQ": "Row {row} is missing a question or answer and was skipped.",
|
||||
"importNoRows": "No FAQ rows to import.",
|
||||
"parsedFAQRows": "{count} FAQ rows parsed.",
|
||||
"parseImportFailed": "Could not parse the import file.",
|
||||
"importRowFailed": "Row {row}: {message}",
|
||||
"importFailed": "Import failed.",
|
||||
"importSuccess": "{count} FAQs imported.",
|
||||
"importSomeFailed": "{count} FAQs failed to import.",
|
||||
"importFAQTitle": "Import FAQ",
|
||||
"importFAQDescription": "Upload a CSV file to import FAQs in bulk. Required columns: question and answer. Separate similarQuestions with | or line breaks.",
|
||||
"importFAQDescription": "Upload an Excel file to import FAQs in bulk. Required columns: primary question and answer. Put one similar question per line in the same cell.",
|
||||
"downloadTemplate": "Download Template",
|
||||
"downloadTemplateFailed": "Could not download the template.",
|
||||
"importing": "Importing...",
|
||||
"startImport": "Start Import",
|
||||
"startImportWithCount": "Start Import ({count})",
|
||||
"importFile": "Import File",
|
||||
"chooseFile": "Choose File",
|
||||
"importFileDescription": "Supports UTF-8 CSV or tab-delimited text files.",
|
||||
"importFileDescription": "Only .xlsx files are supported.",
|
||||
"currentFile": "Current file: {name}",
|
||||
"importHint": "Import Notes",
|
||||
"importPreview": "Import Preview",
|
||||
"rowNumber": "Row {row}",
|
||||
"similarQuestionShort": "Similar: {value}",
|
||||
"importXlsxHint": "The template columns are primary question, answer, similar questions, and remark. Put one similar question per line inside the cell.",
|
||||
"importXlsxOnly": "Only .xlsx files are supported.",
|
||||
"importMode": "Import Mode",
|
||||
"selectImportMode": "Select import mode",
|
||||
"searchImportMode": "Search import mode",
|
||||
"emptyImportMode": "No import modes found",
|
||||
"importModeAppend": "Append Import",
|
||||
"importModeOverwrite": "Overwrite Import",
|
||||
"importModeAppendDescription": "Only creates primary questions that do not already exist. Existing primary questions are skipped.",
|
||||
"importModeOverwriteDescription": "Matches by primary question and updates existing FAQs. Missing primary questions are created.",
|
||||
"importResult": "Import Result",
|
||||
"importTotal": "Total",
|
||||
"importCreated": "Created",
|
||||
"importUpdated": "Updated",
|
||||
"importSkipped": "Skipped",
|
||||
"importFailedCount": "Failed",
|
||||
"importFAQResultToast": "Created {created}, updated {updated}, skipped {skipped}, failed {failed}",
|
||||
"exportFAQ": "Export FAQ",
|
||||
"exportFAQSuccess": "FAQ export download started.",
|
||||
"exportFAQFailed": "Could not export FAQs.",
|
||||
"none": "None",
|
||||
"previewAfterUpload": "Upload a file to preview the first 5 FAQs.",
|
||||
"allChannels": "All channels",
|
||||
"channelIM": "Support chat",
|
||||
"channelAgentAssist": "Agent assist",
|
||||
|
||||
+24
-16
@@ -1893,33 +1893,41 @@
|
||||
"questionPlaceholder": "请输入标准问题",
|
||||
"answer": "答案",
|
||||
"answerPlaceholder": "请输入FAQ答案",
|
||||
"similarQuestionsPlaceholder": "一行一个相似问题"
|
||||
,"fileEmpty": "文件内容为空",
|
||||
"missingFAQColumns": "导入模板缺少 question/answer 列",
|
||||
"skipRowMissingFAQ": "第 {row} 行缺少问题或答案,已跳过",
|
||||
"importNoRows": "没有可导入的FAQ记录",
|
||||
"parsedFAQRows": "已解析 {count} 条FAQ",
|
||||
"parseImportFailed": "解析导入文件失败",
|
||||
"similarQuestionsPlaceholder": "一行一个相似问题",
|
||||
"importRowFailed": "第 {row} 行:{message}",
|
||||
"importFailed": "导入失败",
|
||||
"importSuccess": "成功导入 {count} 条FAQ",
|
||||
"importSomeFailed": "有 {count} 条FAQ导入失败",
|
||||
"importFAQTitle": "导入FAQ",
|
||||
"importFAQDescription": "上传 CSV 文件批量导入 FAQ。必填列为 question、answer;similarQuestions 使用 | 或换行分隔。",
|
||||
"importFAQDescription": "上传 Excel 文件批量导入 FAQ。必填列为标准问题和答案;相似问在同一个单元格内一行一个。",
|
||||
"downloadTemplate": "下载模板",
|
||||
"downloadTemplateFailed": "下载模板失败",
|
||||
"importing": "导入中...",
|
||||
"startImport": "开始导入",
|
||||
"startImportWithCount": "开始导入 ({count})",
|
||||
"importFile": "导入文件",
|
||||
"chooseFile": "选择文件",
|
||||
"importFileDescription": "支持 UTF-8 编码的 CSV 或制表符文本文件。",
|
||||
"importFileDescription": "仅支持 .xlsx 文件。",
|
||||
"currentFile": "当前文件:{name}",
|
||||
"importHint": "导入提示",
|
||||
"importPreview": "导入预览",
|
||||
"rowNumber": "第 {row} 行",
|
||||
"similarQuestionShort": "相似问:{value}",
|
||||
"importXlsxHint": "模板列为标准问题、答案、相似问、备注;相似问请在单元格内一行填写一个。",
|
||||
"importXlsxOnly": "仅支持 .xlsx 文件",
|
||||
"importMode": "导入模式",
|
||||
"selectImportMode": "请选择导入模式",
|
||||
"searchImportMode": "搜索导入模式",
|
||||
"emptyImportMode": "没有可用的导入模式",
|
||||
"importModeAppend": "追加导入",
|
||||
"importModeOverwrite": "覆盖导入",
|
||||
"importModeAppendDescription": "只新增不存在的标准问题;已存在的标准问题会跳过。",
|
||||
"importModeOverwriteDescription": "按标准问题匹配并更新已存在 FAQ;不存在的标准问题会新增。",
|
||||
"importResult": "导入结果",
|
||||
"importTotal": "总行数",
|
||||
"importCreated": "新增",
|
||||
"importUpdated": "更新",
|
||||
"importSkipped": "跳过",
|
||||
"importFailedCount": "失败",
|
||||
"importFAQResultToast": "新增 {created},更新 {updated},跳过 {skipped},失败 {failed}",
|
||||
"exportFAQ": "导出FAQ",
|
||||
"exportFAQSuccess": "FAQ 导出已开始下载",
|
||||
"exportFAQFailed": "导出 FAQ 失败",
|
||||
"none": "无",
|
||||
"previewAfterUpload": "上传文件后可预览前 5 条FAQ",
|
||||
"allChannels": "全部渠道",
|
||||
"channelIM": "客服会话",
|
||||
"channelAgentAssist": "客服助手",
|
||||
|
||||
Reference in New Issue
Block a user