2026-06-01 13:57:49 +08:00
|
|
|
"use client"
|
2026-04-09 10:01:23 +08:00
|
|
|
|
2026-06-01 13:57:49 +08:00
|
|
|
import { DownloadIcon, FileUpIcon, InfoIcon } from "lucide-react"
|
|
|
|
|
import { useMemo, useRef, useState } from "react"
|
|
|
|
|
import { toast } from "sonner"
|
2026-04-09 10:01:23 +08:00
|
|
|
|
2026-06-01 13:57:49 +08:00
|
|
|
import { OptionCombobox } from "@/components/option-combobox"
|
|
|
|
|
import { ProjectDialog } from "@/components/project-dialog"
|
|
|
|
|
import { Button } from "@/components/ui/button"
|
2026-04-09 10:01:23 +08:00
|
|
|
import {
|
|
|
|
|
Field,
|
|
|
|
|
FieldContent,
|
|
|
|
|
FieldDescription,
|
|
|
|
|
FieldGroup,
|
|
|
|
|
FieldLabel,
|
2026-06-01 13:57:49 +08:00
|
|
|
} 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"
|
2026-04-09 10:01:23 +08:00
|
|
|
|
|
|
|
|
type FAQImportDialogProps = {
|
2026-06-01 13:57:49 +08:00
|
|
|
open: boolean
|
|
|
|
|
knowledgeBaseId: number | null
|
|
|
|
|
importing: boolean
|
|
|
|
|
onOpenChange: (open: boolean) => void
|
|
|
|
|
onImportingChange: (importing: boolean) => void
|
|
|
|
|
onImported: () => Promise<void>
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function FAQImportDialog({
|
|
|
|
|
open,
|
|
|
|
|
knowledgeBaseId,
|
|
|
|
|
importing,
|
|
|
|
|
onOpenChange,
|
|
|
|
|
onImportingChange,
|
|
|
|
|
onImported,
|
|
|
|
|
}: FAQImportDialogProps) {
|
2026-06-01 13:57:49 +08:00
|
|
|
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 modeOptions = useMemo(
|
|
|
|
|
() => [
|
|
|
|
|
{ value: "append", label: t("knowledge.importModeAppend") },
|
|
|
|
|
{ value: "overwrite", label: t("knowledge.importModeOverwrite") },
|
|
|
|
|
],
|
|
|
|
|
[t],
|
|
|
|
|
)
|
2026-04-09 10:01:23 +08:00
|
|
|
|
|
|
|
|
function resetState() {
|
2026-06-01 13:57:49 +08:00
|
|
|
setFile(null)
|
|
|
|
|
setMode("append")
|
|
|
|
|
setResult(null)
|
2026-04-09 10:01:23 +08:00
|
|
|
if (fileInputRef.current) {
|
2026-06-01 13:57:49 +08:00
|
|
|
fileInputRef.current.value = ""
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 13:57:49 +08:00
|
|
|
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
|
|
|
|
const nextFile = event.target.files?.[0] ?? null
|
|
|
|
|
setResult(null)
|
|
|
|
|
if (!nextFile) {
|
|
|
|
|
setFile(null)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (!nextFile.name.toLowerCase().endsWith(".xlsx")) {
|
|
|
|
|
setFile(null)
|
|
|
|
|
if (fileInputRef.current) {
|
|
|
|
|
fileInputRef.current.value = ""
|
|
|
|
|
}
|
|
|
|
|
toast.error(t("knowledge.importXlsxOnly"))
|
|
|
|
|
return
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
2026-06-01 13:57:49 +08:00
|
|
|
setFile(nextFile)
|
|
|
|
|
}
|
2026-04-09 10:01:23 +08:00
|
|
|
|
2026-06-01 13:57:49 +08:00
|
|
|
async function handleDownloadTemplate() {
|
2026-04-09 10:01:23 +08:00
|
|
|
try {
|
2026-06-01 13:57:49 +08:00
|
|
|
await downloadKnowledgeFAQImportTemplate()
|
2026-04-09 10:01:23 +08:00
|
|
|
} catch (error) {
|
2026-06-01 13:57:49 +08:00
|
|
|
toast.error(error instanceof Error ? error.message : t("knowledge.downloadTemplateFailed"))
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleImport() {
|
2026-06-01 13:57:49 +08:00
|
|
|
if (!knowledgeBaseId || !file || importing) {
|
|
|
|
|
return
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-01 13:57:49 +08:00
|
|
|
onImportingChange(true)
|
|
|
|
|
setResult(null)
|
2026-04-09 10:01:23 +08:00
|
|
|
try {
|
2026-06-01 13:57:49 +08:00
|
|
|
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)
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
2026-06-01 13:57:49 +08:00
|
|
|
} catch (error) {
|
|
|
|
|
toast.error(error instanceof Error ? error.message : t("knowledge.importFailed"))
|
2026-04-09 10:01:23 +08:00
|
|
|
} finally {
|
2026-06-01 13:57:49 +08:00
|
|
|
onImportingChange(false)
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ProjectDialog
|
|
|
|
|
open={open}
|
|
|
|
|
onOpenChange={(nextOpen) => {
|
|
|
|
|
if (!nextOpen && !importing) {
|
2026-06-01 13:57:49 +08:00
|
|
|
resetState()
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
2026-06-01 13:57:49 +08:00
|
|
|
onOpenChange(nextOpen)
|
2026-04-09 10:01:23 +08:00
|
|
|
}}
|
2026-05-25 12:06:15 +08:00
|
|
|
title={t("knowledge.importFAQTitle")}
|
|
|
|
|
description={t("knowledge.importFAQDescription")}
|
2026-04-09 10:01:23 +08:00
|
|
|
size="lg"
|
|
|
|
|
footer={
|
|
|
|
|
<>
|
2026-06-01 13:57:49 +08:00
|
|
|
<Button type="button" variant="outline" onClick={() => void handleDownloadTemplate()}>
|
2026-04-09 10:01:23 +08:00
|
|
|
<DownloadIcon className="size-4" />
|
2026-05-25 12:06:15 +08:00
|
|
|
{t("knowledge.downloadTemplate")}
|
2026-04-09 10:01:23 +08:00
|
|
|
</Button>
|
|
|
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={importing}>
|
2026-05-25 12:06:15 +08:00
|
|
|
{t("knowledge.cancel")}
|
2026-04-09 10:01:23 +08:00
|
|
|
</Button>
|
2026-06-01 13:57:49 +08:00
|
|
|
<Button type="button" onClick={() => void handleImport()} disabled={importing || !file}>
|
|
|
|
|
{importing ? t("knowledge.importing") : t("knowledge.startImport")}
|
2026-04-09 10:01:23 +08:00
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<FieldGroup>
|
2026-06-01 13:57:49 +08:00
|
|
|
<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>
|
|
|
|
|
|
2026-04-09 10:01:23 +08:00
|
|
|
<Field>
|
2026-05-25 12:06:15 +08:00
|
|
|
<FieldLabel htmlFor="faq-import-file">{t("knowledge.importFile")}</FieldLabel>
|
2026-04-09 10:01:23 +08:00
|
|
|
<FieldContent>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Input
|
|
|
|
|
id="faq-import-file"
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
type="file"
|
2026-06-01 13:57:49 +08:00
|
|
|
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
|
|
|
disabled={importing}
|
|
|
|
|
onChange={handleFileChange}
|
2026-04-09 10:01:23 +08:00
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
2026-06-01 13:57:49 +08:00
|
|
|
disabled={importing}
|
2026-04-09 10:01:23 +08:00
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
>
|
|
|
|
|
<FileUpIcon className="size-4" />
|
2026-05-25 12:06:15 +08:00
|
|
|
{t("knowledge.chooseFile")}
|
2026-04-09 10:01:23 +08:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-06-01 13:57:49 +08:00
|
|
|
<FieldDescription>{t("knowledge.importFileDescription")}</FieldDescription>
|
2026-04-09 10:01:23 +08:00
|
|
|
</FieldContent>
|
|
|
|
|
</Field>
|
|
|
|
|
|
2026-06-01 13:57:49 +08:00
|
|
|
{file ? (
|
2026-04-09 10:01:23 +08:00
|
|
|
<div className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
2026-06-01 13:57:49 +08:00
|
|
|
{t("knowledge.currentFile", { name: file.name })}
|
2026-04-09 10:01:23 +08:00
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-06-01 13:57:49 +08:00
|
|
|
<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")}
|
2026-04-09 10:01:23 +08:00
|
|
|
</div>
|
2026-06-01 13:57:49 +08:00
|
|
|
<div>{t("knowledge.importXlsxHint")}</div>
|
|
|
|
|
</div>
|
2026-04-09 10:01:23 +08:00
|
|
|
|
2026-06-01 13:57:49 +08:00
|
|
|
{result ? (
|
|
|
|
|
<div className="rounded-md border">
|
|
|
|
|
<div className="border-b px-4 py-3 text-sm font-medium">
|
|
|
|
|
{t("knowledge.importResult")}
|
2026-04-09 10:01:23 +08:00
|
|
|
</div>
|
2026-06-01 13:57:49 +08:00
|
|
|
<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}
|
2026-04-09 10:01:23 +08:00
|
|
|
</FieldGroup>
|
|
|
|
|
</ProjectDialog>
|
2026-06-01 13:57:49 +08:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
)
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|