2085 lines
64 KiB
TypeScript
2085 lines
64 KiB
TypeScript
import React, {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import {
|
||
Button,
|
||
Empty,
|
||
Form,
|
||
Input,
|
||
Modal,
|
||
Select,
|
||
Spin,
|
||
Table,
|
||
Tabs,
|
||
Tag,
|
||
Tooltip,
|
||
message,
|
||
} from "antd";
|
||
import {
|
||
ArrowLeftOutlined,
|
||
CheckOutlined,
|
||
CloudUploadOutlined,
|
||
DatabaseOutlined,
|
||
DeleteOutlined,
|
||
DownloadOutlined,
|
||
EditOutlined,
|
||
FileAddOutlined,
|
||
FileTextOutlined,
|
||
PictureOutlined,
|
||
PlusOutlined,
|
||
ReloadOutlined,
|
||
SearchOutlined,
|
||
TeamOutlined,
|
||
UploadOutlined,
|
||
} from "@ant-design/icons";
|
||
import type { ColumnsType } from "antd/es/table";
|
||
import ImageWithFallback from "../common/ImageWithFallback";
|
||
import { getCurrentAuth } from "../../services/core/auth";
|
||
import {
|
||
AiAgentContext,
|
||
AiManualComponent,
|
||
addAiAgentKnowledgeComponent,
|
||
enableAiAgentKnowledgeForChat,
|
||
fetchAiAgentComponents,
|
||
isAiKnowledgeChatSelectable,
|
||
resolveAiAgentContext,
|
||
} from "../../services/core/aiChat";
|
||
import {
|
||
fetchPublishedWorkflows,
|
||
fetchSpaces,
|
||
PublishedWorkflow,
|
||
SpaceInfo,
|
||
} from "../../services/core/marketplace";
|
||
import {
|
||
KnowledgeComponent,
|
||
KnowledgeConfigSaveParams,
|
||
KnowledgeDocumentInfo,
|
||
KnowledgeInfo,
|
||
KnowledgeModelOption,
|
||
KnowledgeQaInfo,
|
||
KnowledgeRawSegmentInfo,
|
||
addCustomKnowledgeDocument,
|
||
addKnowledgeDocuments,
|
||
addKnowledgeQa,
|
||
addKnowledgeRawSegment,
|
||
createKnowledgeConfig,
|
||
deleteKnowledgeConfig,
|
||
deleteKnowledgeDocument,
|
||
deleteKnowledgeQa,
|
||
deleteKnowledgeRawSegment,
|
||
downloadKnowledgeQaTemplate,
|
||
fetchEmbeddingModels,
|
||
fetchKnowledgeComponents,
|
||
fetchKnowledgeDetail,
|
||
fetchKnowledgeDocuments,
|
||
fetchKnowledgeQaList,
|
||
fetchKnowledgeRawSegments,
|
||
generateKnowledgeDocumentEmbeddings,
|
||
generateKnowledgeDocumentQa,
|
||
getKnowledgeId,
|
||
importKnowledgeQaExcel,
|
||
queryKnowledgeDocumentStatus,
|
||
retryKnowledgeDocumentTask,
|
||
updateKnowledgeConfig,
|
||
updateKnowledgeDocumentName,
|
||
updateKnowledgeQa,
|
||
updateKnowledgeRawSegment,
|
||
uploadKnowledgeAsset,
|
||
uploadKnowledgeFile,
|
||
validateKnowledgeFile,
|
||
} from "../../services/core/knowledge";
|
||
import styles from "../../styles/components/KnowledgePage.module.css";
|
||
|
||
const DOCUMENT_PAGE_SIZE = 48;
|
||
const SEGMENT_PAGE_SIZE = 30;
|
||
const QA_PAGE_SIZE = 20;
|
||
const KNOWLEDGE_ICON_MAX_SIZE = 2 * 1024 * 1024;
|
||
|
||
type DetailTabKey = "documents" | "segments" | "qa" | "graph";
|
||
|
||
interface KnowledgeFormValues {
|
||
name: string;
|
||
description?: string;
|
||
embeddingModelId: number;
|
||
dataParsingMethod?: "default" | "workflow";
|
||
workflowId?: number;
|
||
}
|
||
|
||
interface CustomDocumentValues {
|
||
name: string;
|
||
fileContent: string;
|
||
}
|
||
|
||
interface RenameDocumentValues {
|
||
name: string;
|
||
}
|
||
|
||
interface SegmentFormValues {
|
||
rawTxt: string;
|
||
sortIndex?: number;
|
||
}
|
||
|
||
interface QaFormValues {
|
||
question: string;
|
||
answer: string;
|
||
}
|
||
|
||
function formatDateTime(value?: string): string {
|
||
if (!value) return "-";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return value;
|
||
return date.toLocaleString("zh-CN", {
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
}
|
||
|
||
function formatBytes(value?: number): string {
|
||
if (!value) return "0 B";
|
||
if (value >= 1024 * 1024 * 1024) {
|
||
return `${(value / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||
}
|
||
if (value >= 1024 * 1024) {
|
||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||
}
|
||
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||
return `${value} B`;
|
||
}
|
||
|
||
function getInitial(name?: string): string {
|
||
const trimmed = (name || "").trim();
|
||
return trimmed ? trimmed.slice(0, 1).toUpperCase() : "知";
|
||
}
|
||
|
||
function getDocumentStatus(doc: KnowledgeDocumentInfo): {
|
||
color: string;
|
||
text: string;
|
||
} {
|
||
const code = Number(doc.docStatusCode || 0);
|
||
if (code === 2) return { color: "success", text: "分析成功" };
|
||
if (code === 10) return { color: "error", text: "分析失败" };
|
||
if ([1, 3, 4, 5].includes(code)) {
|
||
return { color: "processing", text: doc.docStatusDesc || "处理中" };
|
||
}
|
||
return {
|
||
color: "default",
|
||
text: doc.docStatusDesc || doc.docStatus || "未知",
|
||
};
|
||
}
|
||
|
||
function isKnowledgeComponent(component: AiManualComponent): boolean {
|
||
return String(component.type || "").toLowerCase() === "knowledge";
|
||
}
|
||
|
||
function getPublishedWorkflowId(workflow: PublishedWorkflow): number {
|
||
return Number(workflow.targetId || workflow.id);
|
||
}
|
||
|
||
export default function KnowledgePage() {
|
||
const [currentUserId, setCurrentUserId] = useState<number | null>(null);
|
||
const [spaces, setSpaces] = useState<SpaceInfo[]>([]);
|
||
const [selectedSpaceId, setSelectedSpaceId] = useState<number | null>(null);
|
||
const [knowledges, setKnowledges] = useState<KnowledgeComponent[]>([]);
|
||
const [loadingKnowledges, setLoadingKnowledges] = useState(false);
|
||
const [knowledgeKeyword, setKnowledgeKeyword] = useState("");
|
||
const [knowledgeError, setKnowledgeError] = useState<string | null>(null);
|
||
|
||
const [selectedKnowledgeId, setSelectedKnowledgeId] = useState<number | null>(
|
||
null,
|
||
);
|
||
const [knowledgeInfo, setKnowledgeInfo] = useState<KnowledgeInfo | null>(
|
||
null,
|
||
);
|
||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||
const [activeTab, setActiveTab] = useState<DetailTabKey>("documents");
|
||
|
||
const [documents, setDocuments] = useState<KnowledgeDocumentInfo[]>([]);
|
||
const [documentTotal, setDocumentTotal] = useState(0);
|
||
const [documentKeyword, setDocumentKeyword] = useState("");
|
||
const [loadingDocuments, setLoadingDocuments] = useState(false);
|
||
const [selectedDocument, setSelectedDocument] =
|
||
useState<KnowledgeDocumentInfo | null>(null);
|
||
const [uploadingDocs, setUploadingDocs] = useState(false);
|
||
|
||
const [segments, setSegments] = useState<KnowledgeRawSegmentInfo[]>([]);
|
||
const [segmentTotal, setSegmentTotal] = useState(0);
|
||
const [loadingSegments, setLoadingSegments] = useState(false);
|
||
const [segmentModalOpen, setSegmentModalOpen] = useState(false);
|
||
const [editingSegment, setEditingSegment] =
|
||
useState<KnowledgeRawSegmentInfo | null>(null);
|
||
|
||
const [qaList, setQaList] = useState<KnowledgeQaInfo[]>([]);
|
||
const [qaTotal, setQaTotal] = useState(0);
|
||
const [qaKeyword, setQaKeyword] = useState("");
|
||
const [loadingQa, setLoadingQa] = useState(false);
|
||
const [qaModalOpen, setQaModalOpen] = useState(false);
|
||
const [editingQa, setEditingQa] = useState<KnowledgeQaInfo | null>(null);
|
||
|
||
const [models, setModels] = useState<KnowledgeModelOption[]>([]);
|
||
const [loadingModels, setLoadingModels] = useState(false);
|
||
const [parsingWorkflows, setParsingWorkflows] = useState<PublishedWorkflow[]>(
|
||
[],
|
||
);
|
||
const [loadingParsingWorkflows, setLoadingParsingWorkflows] = useState(false);
|
||
const [iconUrl, setIconUrl] = useState("");
|
||
const [uploadingIcon, setUploadingIcon] = useState(false);
|
||
const [knowledgeModalOpen, setKnowledgeModalOpen] = useState(false);
|
||
const [editingKnowledge, setEditingKnowledge] =
|
||
useState<KnowledgeInfo | null>(null);
|
||
const [savingKnowledge, setSavingKnowledge] = useState(false);
|
||
|
||
const [customDocModalOpen, setCustomDocModalOpen] = useState(false);
|
||
const [savingCustomDoc, setSavingCustomDoc] = useState(false);
|
||
const [renameDocModalOpen, setRenameDocModalOpen] = useState(false);
|
||
const [renamingDoc, setRenamingDoc] = useState<KnowledgeDocumentInfo | null>(
|
||
null,
|
||
);
|
||
const [savingRename, setSavingRename] = useState(false);
|
||
|
||
const [agentContext, setAgentContext] = useState<AiAgentContext | null>(null);
|
||
const [joinedKnowledgeMap, setJoinedKnowledgeMap] = useState<
|
||
Map<number, AiManualComponent>
|
||
>(new Map());
|
||
const [joiningIds, setJoiningIds] = useState<Set<number>>(() => new Set());
|
||
|
||
const [knowledgeForm] = Form.useForm<KnowledgeFormValues>();
|
||
const [customDocForm] = Form.useForm<CustomDocumentValues>();
|
||
const [renameDocForm] = Form.useForm<RenameDocumentValues>();
|
||
const [segmentForm] = Form.useForm<SegmentFormValues>();
|
||
const [qaForm] = Form.useForm<QaFormValues>();
|
||
const dataParsingMethod =
|
||
Form.useWatch("dataParsingMethod", knowledgeForm) || "default";
|
||
|
||
const iconFileInputRef = useRef<HTMLInputElement | null>(null);
|
||
const documentFileInputRef = useRef<HTMLInputElement | null>(null);
|
||
const qaFileInputRef = useRef<HTMLInputElement | null>(null);
|
||
|
||
const filteredKnowledges = useMemo(() => {
|
||
const keyword = knowledgeKeyword.trim().toLowerCase();
|
||
if (!keyword) return knowledges;
|
||
return knowledges.filter((item) =>
|
||
[item.name, item.description]
|
||
.filter(Boolean)
|
||
.some((value) => String(value).toLowerCase().includes(keyword)),
|
||
);
|
||
}, [knowledgeKeyword, knowledges]);
|
||
|
||
const selectedKnowledgeComponent = useMemo(
|
||
() =>
|
||
selectedKnowledgeId
|
||
? knowledges.find(
|
||
(item) => getKnowledgeId(item) === selectedKnowledgeId,
|
||
) || null
|
||
: null,
|
||
[knowledges, selectedKnowledgeId],
|
||
);
|
||
|
||
const isKnowledgeOwner = useCallback(
|
||
(knowledge?: KnowledgeComponent | KnowledgeInfo | null) => {
|
||
if (!knowledge) return false;
|
||
const creator = (knowledge as KnowledgeComponent).creator;
|
||
const creatorId = Number(knowledge.creatorId || creator?.id || 0);
|
||
return Boolean(currentUserId && creatorId && currentUserId === creatorId);
|
||
},
|
||
[currentUserId],
|
||
);
|
||
|
||
const canManageSelectedKnowledge = isKnowledgeOwner(
|
||
knowledgeInfo || selectedKnowledgeComponent,
|
||
);
|
||
|
||
const ensureCanManageSelectedKnowledge = useCallback(() => {
|
||
if (canManageSelectedKnowledge) return true;
|
||
message.warning("该知识库由其他用户创建,仅支持查看和加入对话");
|
||
return false;
|
||
}, [canManageSelectedKnowledge]);
|
||
|
||
const loadAgentComponents = useCallback(async () => {
|
||
try {
|
||
const context = await resolveAiAgentContext();
|
||
setAgentContext(context);
|
||
const components = await fetchAiAgentComponents(context.agentId);
|
||
const map = new Map<number, AiManualComponent>();
|
||
components.filter(isKnowledgeComponent).forEach((component) => {
|
||
const targetId = Number(component.targetId || 0);
|
||
if (targetId) map.set(targetId, component);
|
||
});
|
||
setJoinedKnowledgeMap(map);
|
||
} catch (error) {
|
||
console.warn("[KnowledgePage] Failed to load agent components:", error);
|
||
setAgentContext(null);
|
||
setJoinedKnowledgeMap(new Map());
|
||
}
|
||
}, []);
|
||
|
||
const loadSpaces = useCallback(async (): Promise<SpaceInfo[]> => {
|
||
try {
|
||
const result = await fetchSpaces();
|
||
const nextSpaces = result || [];
|
||
setSpaces(nextSpaces);
|
||
setSelectedSpaceId((prev) => prev || nextSpaces[0]?.id || null);
|
||
return nextSpaces;
|
||
} catch (error) {
|
||
console.warn("[KnowledgePage] Failed to load spaces:", error);
|
||
return [];
|
||
}
|
||
}, []);
|
||
|
||
const ensureDefaultSpaceId = useCallback(
|
||
async (showError = true): Promise<number | null> => {
|
||
if (selectedSpaceId) return selectedSpaceId;
|
||
|
||
const list = spaces.length > 0 ? spaces : await loadSpaces();
|
||
const defaultSpaceId = list[0]?.id || null;
|
||
if (defaultSpaceId) {
|
||
setSelectedSpaceId(defaultSpaceId);
|
||
return defaultSpaceId;
|
||
}
|
||
|
||
if (showError) {
|
||
message.error("无法获取默认工作区,暂时不能创建知识库");
|
||
}
|
||
return null;
|
||
},
|
||
[loadSpaces, selectedSpaceId, spaces],
|
||
);
|
||
|
||
const loadKnowledges = useCallback(async () => {
|
||
if (!selectedSpaceId) {
|
||
setKnowledges([]);
|
||
return;
|
||
}
|
||
setLoadingKnowledges(true);
|
||
setKnowledgeError(null);
|
||
try {
|
||
const list = await fetchKnowledgeComponents(selectedSpaceId);
|
||
setKnowledges(list);
|
||
} catch (error) {
|
||
const errorMessage =
|
||
error instanceof Error ? error.message : "知识库加载失败";
|
||
setKnowledgeError(errorMessage);
|
||
setKnowledges([]);
|
||
} finally {
|
||
setLoadingKnowledges(false);
|
||
}
|
||
}, [selectedSpaceId]);
|
||
|
||
const loadKnowledgeDetail = useCallback(
|
||
async (knowledgeId = selectedKnowledgeId) => {
|
||
if (!knowledgeId) return;
|
||
setLoadingDetail(true);
|
||
try {
|
||
const detail = await fetchKnowledgeDetail(knowledgeId);
|
||
setKnowledgeInfo(detail);
|
||
} catch (error) {
|
||
message.error(
|
||
error instanceof Error ? error.message : "知识库详情加载失败",
|
||
);
|
||
} finally {
|
||
setLoadingDetail(false);
|
||
}
|
||
},
|
||
[selectedKnowledgeId],
|
||
);
|
||
|
||
const loadDocuments = useCallback(
|
||
async (keyword = documentKeyword) => {
|
||
if (!selectedKnowledgeId) return;
|
||
setLoadingDocuments(true);
|
||
try {
|
||
const result = await fetchKnowledgeDocuments({
|
||
kbId: selectedKnowledgeId,
|
||
name: keyword.trim() || undefined,
|
||
current: 1,
|
||
pageSize: DOCUMENT_PAGE_SIZE,
|
||
});
|
||
const records = result.records || [];
|
||
setDocuments(records);
|
||
setDocumentTotal(result.total || 0);
|
||
setSelectedDocument((prev) => {
|
||
if (prev && records.some((item) => item.id === prev.id)) return prev;
|
||
return records[0] || null;
|
||
});
|
||
} catch (error) {
|
||
message.error(
|
||
error instanceof Error ? error.message : "文档列表加载失败",
|
||
);
|
||
setDocuments([]);
|
||
setDocumentTotal(0);
|
||
setSelectedDocument(null);
|
||
} finally {
|
||
setLoadingDocuments(false);
|
||
}
|
||
},
|
||
[documentKeyword, selectedKnowledgeId],
|
||
);
|
||
|
||
const loadSegments = useCallback(async () => {
|
||
if (!selectedDocument) {
|
||
setSegments([]);
|
||
setSegmentTotal(0);
|
||
return;
|
||
}
|
||
setLoadingSegments(true);
|
||
try {
|
||
const result = await fetchKnowledgeRawSegments({
|
||
docId: selectedDocument.id,
|
||
current: 1,
|
||
pageSize: SEGMENT_PAGE_SIZE,
|
||
});
|
||
setSegments(result.records || []);
|
||
setSegmentTotal(result.total || 0);
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : "分段加载失败");
|
||
setSegments([]);
|
||
setSegmentTotal(0);
|
||
} finally {
|
||
setLoadingSegments(false);
|
||
}
|
||
}, [selectedDocument]);
|
||
|
||
const loadQaList = useCallback(
|
||
async (keyword = qaKeyword) => {
|
||
if (!selectedKnowledgeId) return;
|
||
setLoadingQa(true);
|
||
try {
|
||
const result = await fetchKnowledgeQaList({
|
||
kbId: selectedKnowledgeId,
|
||
spaceId: selectedSpaceId || undefined,
|
||
question: keyword.trim() || undefined,
|
||
current: 1,
|
||
pageSize: QA_PAGE_SIZE,
|
||
});
|
||
setQaList(result.records || []);
|
||
setQaTotal(result.total || 0);
|
||
} catch (error) {
|
||
message.error(
|
||
error instanceof Error ? error.message : "QA 列表加载失败",
|
||
);
|
||
setQaList([]);
|
||
setQaTotal(0);
|
||
} finally {
|
||
setLoadingQa(false);
|
||
}
|
||
},
|
||
[qaKeyword, selectedKnowledgeId, selectedSpaceId],
|
||
);
|
||
|
||
const loadEmbeddingModels = useCallback(async (spaceId?: number) => {
|
||
setLoadingModels(true);
|
||
try {
|
||
const result = await fetchEmbeddingModels(spaceId);
|
||
setModels(result || []);
|
||
} catch (error) {
|
||
message.error(
|
||
error instanceof Error ? error.message : "嵌入模型加载失败",
|
||
);
|
||
setModels([]);
|
||
} finally {
|
||
setLoadingModels(false);
|
||
}
|
||
}, []);
|
||
|
||
const loadParsingWorkflows = useCallback(async (spaceId?: number) => {
|
||
setLoadingParsingWorkflows(true);
|
||
try {
|
||
const result = await fetchPublishedWorkflows({
|
||
page: 1,
|
||
pageSize: 80,
|
||
category: "all",
|
||
spaceId,
|
||
});
|
||
setParsingWorkflows(result.records || []);
|
||
} catch (error) {
|
||
message.error(
|
||
error instanceof Error ? error.message : "解析工作流加载失败",
|
||
);
|
||
setParsingWorkflows([]);
|
||
} finally {
|
||
setLoadingParsingWorkflows(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadSpaces();
|
||
loadAgentComponents();
|
||
void getCurrentAuth().then((auth) => {
|
||
setCurrentUserId(auth.userInfo?.userId || null);
|
||
});
|
||
}, [loadAgentComponents, loadSpaces]);
|
||
|
||
useEffect(() => {
|
||
loadKnowledges();
|
||
}, [loadKnowledges]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedKnowledgeId) return;
|
||
loadKnowledgeDetail(selectedKnowledgeId);
|
||
loadDocuments("");
|
||
}, [loadDocuments, loadKnowledgeDetail, selectedKnowledgeId]);
|
||
|
||
useEffect(() => {
|
||
if (activeTab === "segments") loadSegments();
|
||
}, [activeTab, loadSegments]);
|
||
|
||
useEffect(() => {
|
||
if (activeTab === "qa") loadQaList();
|
||
}, [activeTab, loadQaList]);
|
||
|
||
useEffect(() => {
|
||
if (!knowledgeModalOpen || dataParsingMethod !== "workflow") return;
|
||
if (loadingParsingWorkflows || parsingWorkflows.length > 0) return;
|
||
|
||
void (async () => {
|
||
const spaceId = selectedSpaceId || (await ensureDefaultSpaceId(false));
|
||
await loadParsingWorkflows(spaceId || undefined);
|
||
})();
|
||
}, [
|
||
dataParsingMethod,
|
||
ensureDefaultSpaceId,
|
||
knowledgeModalOpen,
|
||
loadParsingWorkflows,
|
||
loadingParsingWorkflows,
|
||
parsingWorkflows.length,
|
||
selectedSpaceId,
|
||
]);
|
||
|
||
const openKnowledgeForm = async (
|
||
knowledge?: KnowledgeComponent | KnowledgeInfo,
|
||
) => {
|
||
if (knowledge && !isKnowledgeOwner(knowledge)) {
|
||
message.warning("该知识库由其他用户创建,不能编辑");
|
||
return;
|
||
}
|
||
setKnowledgeModalOpen(true);
|
||
void (async () => {
|
||
const spaceId =
|
||
(knowledge as KnowledgeInfo | undefined)?.spaceId ||
|
||
selectedSpaceId ||
|
||
(await ensureDefaultSpaceId(false));
|
||
await loadEmbeddingModels(spaceId || undefined);
|
||
})();
|
||
if (knowledge) {
|
||
try {
|
||
const detail =
|
||
"embeddingModelId" in knowledge && knowledge.embeddingModelId
|
||
? (knowledge as KnowledgeInfo)
|
||
: await fetchKnowledgeDetail(getKnowledgeId(knowledge));
|
||
setEditingKnowledge(detail);
|
||
setIconUrl(detail.icon || "");
|
||
knowledgeForm.setFieldsValue({
|
||
name: detail.name,
|
||
description: detail.description,
|
||
embeddingModelId: detail.embeddingModelId,
|
||
dataParsingMethod: detail.workflowId ? "workflow" : "default",
|
||
workflowId: detail.workflowId ? Number(detail.workflowId) : undefined,
|
||
});
|
||
if (detail.workflowId) {
|
||
await loadParsingWorkflows(
|
||
detail.spaceId || selectedSpaceId || undefined,
|
||
);
|
||
setParsingWorkflows((prev) => {
|
||
const workflowId = Number(detail.workflowId);
|
||
if (
|
||
prev.some((item) => getPublishedWorkflowId(item) === workflowId)
|
||
) {
|
||
return prev;
|
||
}
|
||
return [
|
||
{
|
||
id: workflowId,
|
||
targetId: workflowId,
|
||
name: detail.workflowName || `工作流 ${workflowId}`,
|
||
description: detail.workflowDescription,
|
||
icon: detail.workflowIcon,
|
||
} as PublishedWorkflow,
|
||
...prev,
|
||
];
|
||
});
|
||
}
|
||
} catch (error) {
|
||
message.error(
|
||
error instanceof Error ? error.message : "知识库详情加载失败",
|
||
);
|
||
setKnowledgeModalOpen(false);
|
||
}
|
||
} else {
|
||
setEditingKnowledge(null);
|
||
setIconUrl("");
|
||
knowledgeForm.resetFields();
|
||
knowledgeForm.setFieldsValue({ dataParsingMethod: "default" });
|
||
}
|
||
};
|
||
|
||
const saveKnowledge = async () => {
|
||
if (editingKnowledge && !isKnowledgeOwner(editingKnowledge)) {
|
||
message.warning("该知识库由其他用户创建,不能编辑");
|
||
return;
|
||
}
|
||
let values: KnowledgeFormValues;
|
||
try {
|
||
values = await knowledgeForm.validateFields();
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
if (values.dataParsingMethod === "workflow" && !values.workflowId) {
|
||
message.error("请选择解析工作流");
|
||
return;
|
||
}
|
||
|
||
const spaceId =
|
||
editingKnowledge?.spaceId ||
|
||
selectedSpaceId ||
|
||
(await ensureDefaultSpaceId());
|
||
if (!spaceId) return;
|
||
setSavingKnowledge(true);
|
||
try {
|
||
const params: KnowledgeConfigSaveParams = {
|
||
id: editingKnowledge?.id,
|
||
spaceId,
|
||
name: values.name,
|
||
description: values.description || "",
|
||
embeddingModelId: values.embeddingModelId,
|
||
icon: iconUrl || "",
|
||
dataType: 1,
|
||
workflowId:
|
||
values.dataParsingMethod === "workflow" && values.workflowId
|
||
? values.workflowId
|
||
: null,
|
||
};
|
||
if (editingKnowledge?.id) {
|
||
await updateKnowledgeConfig(params);
|
||
message.success("知识库已更新");
|
||
await loadKnowledgeDetail(editingKnowledge.id);
|
||
} else {
|
||
const id = await createKnowledgeConfig(params);
|
||
message.success("知识库已创建");
|
||
setSelectedKnowledgeId(Number(id));
|
||
setActiveTab("documents");
|
||
}
|
||
setKnowledgeModalOpen(false);
|
||
await loadKnowledges();
|
||
} catch (error) {
|
||
if (error instanceof Error) message.error(error.message);
|
||
} finally {
|
||
setSavingKnowledge(false);
|
||
}
|
||
};
|
||
|
||
const confirmDeleteKnowledge = (knowledge: KnowledgeComponent) => {
|
||
if (!isKnowledgeOwner(knowledge)) {
|
||
message.warning("该知识库由其他用户创建,不能删除");
|
||
return;
|
||
}
|
||
const knowledgeId = getKnowledgeId(knowledge);
|
||
Modal.confirm({
|
||
title: "删除知识库",
|
||
content: `确认删除「${knowledge.name}」吗?该操作不可恢复。`,
|
||
okText: "删除",
|
||
okButtonProps: { danger: true },
|
||
cancelText: "取消",
|
||
onOk: async () => {
|
||
await deleteKnowledgeConfig(knowledgeId);
|
||
message.success("知识库已删除");
|
||
if (selectedKnowledgeId === knowledgeId) {
|
||
setSelectedKnowledgeId(null);
|
||
setKnowledgeInfo(null);
|
||
}
|
||
await loadKnowledges();
|
||
},
|
||
});
|
||
};
|
||
|
||
const joinKnowledgeToChat = async (
|
||
knowledge: KnowledgeComponent | KnowledgeInfo,
|
||
) => {
|
||
const knowledgeId = getKnowledgeId(knowledge);
|
||
const joinedComponent = joinedKnowledgeMap.get(knowledgeId);
|
||
if (joinedComponent && isAiKnowledgeChatSelectable(joinedComponent)) {
|
||
message.info("该知识库已加入对话");
|
||
return;
|
||
}
|
||
|
||
const context = agentContext || (await resolveAiAgentContext());
|
||
setAgentContext(context);
|
||
setJoiningIds((prev) => new Set(prev).add(knowledgeId));
|
||
try {
|
||
let componentId = joinedComponent?.id;
|
||
if (!componentId) {
|
||
componentId = await addAiAgentKnowledgeComponent({
|
||
agentId: context.agentId,
|
||
knowledgeId,
|
||
});
|
||
}
|
||
const resolvedComponentId = componentId;
|
||
const components = await fetchAiAgentComponents(context.agentId);
|
||
const component =
|
||
components.find((item) => item.id === resolvedComponentId) ||
|
||
components.find(
|
||
(item) =>
|
||
String(item.type || "").toLowerCase() === "knowledge" &&
|
||
Number(item.targetId || 0) === knowledgeId,
|
||
) ||
|
||
({
|
||
id: resolvedComponentId,
|
||
type: "Knowledge",
|
||
targetId: knowledgeId,
|
||
name: knowledge.name || "知识库",
|
||
icon: knowledge.icon,
|
||
description: knowledge.description,
|
||
} as AiManualComponent);
|
||
const enabledComponent = await enableAiAgentKnowledgeForChat(component);
|
||
setJoinedKnowledgeMap((prev) => {
|
||
const next = new Map(prev);
|
||
next.set(knowledgeId, enabledComponent);
|
||
return next;
|
||
});
|
||
message.success("知识库已加入 AI 对话");
|
||
await loadAgentComponents();
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : "加入对话失败");
|
||
} finally {
|
||
setJoiningIds((prev) => {
|
||
const next = new Set(prev);
|
||
next.delete(knowledgeId);
|
||
return next;
|
||
});
|
||
}
|
||
};
|
||
|
||
const openKnowledgeDetail = (knowledge: KnowledgeComponent) => {
|
||
setSelectedKnowledgeId(getKnowledgeId(knowledge));
|
||
setKnowledgeInfo(null);
|
||
setSelectedDocument(null);
|
||
setDocuments([]);
|
||
setSegments([]);
|
||
setQaList([]);
|
||
setDocumentKeyword("");
|
||
setQaKeyword("");
|
||
setActiveTab("documents");
|
||
};
|
||
|
||
const handleIconFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
event.target.value = "";
|
||
if (!file) return;
|
||
|
||
const isValidImage =
|
||
file.type === "image/jpeg" ||
|
||
file.type === "image/jpg" ||
|
||
file.type === "image/png";
|
||
if (!isValidImage) {
|
||
message.error("图标仅支持 JPG、JPEG、PNG 格式");
|
||
return;
|
||
}
|
||
if (file.size > KNOWLEDGE_ICON_MAX_SIZE) {
|
||
message.error("图标不能超过 2MB");
|
||
return;
|
||
}
|
||
|
||
setUploadingIcon(true);
|
||
try {
|
||
const url = await uploadKnowledgeAsset(file);
|
||
setIconUrl(url);
|
||
message.success("图标已上传");
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : "图标上传失败");
|
||
} finally {
|
||
setUploadingIcon(false);
|
||
}
|
||
};
|
||
|
||
const handleDocumentFiles = async (
|
||
event: React.ChangeEvent<HTMLInputElement>,
|
||
) => {
|
||
const files = Array.from(event.target.files || []);
|
||
event.target.value = "";
|
||
if (!files.length || !selectedKnowledgeId) return;
|
||
if (!ensureCanManageSelectedKnowledge()) return;
|
||
|
||
const validationError = files.map(validateKnowledgeFile).find(Boolean);
|
||
if (validationError) {
|
||
message.error(validationError);
|
||
return;
|
||
}
|
||
|
||
setUploadingDocs(true);
|
||
try {
|
||
const uploaded = await Promise.all(files.map(uploadKnowledgeFile));
|
||
await addKnowledgeDocuments({
|
||
kbId: selectedKnowledgeId,
|
||
files: uploaded,
|
||
});
|
||
message.success("文档已添加,后台将继续分析处理");
|
||
await loadDocuments();
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : "文档上传失败");
|
||
} finally {
|
||
setUploadingDocs(false);
|
||
}
|
||
};
|
||
|
||
const saveCustomDocument = async () => {
|
||
if (!selectedKnowledgeId) return;
|
||
if (!ensureCanManageSelectedKnowledge()) return;
|
||
const values = await customDocForm.validateFields();
|
||
setSavingCustomDoc(true);
|
||
try {
|
||
await addCustomKnowledgeDocument({
|
||
kbId: selectedKnowledgeId,
|
||
name: values.name,
|
||
fileContent: values.fileContent,
|
||
});
|
||
message.success("自定义文本已添加");
|
||
setCustomDocModalOpen(false);
|
||
customDocForm.resetFields();
|
||
await loadDocuments();
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : "添加文本失败");
|
||
} finally {
|
||
setSavingCustomDoc(false);
|
||
}
|
||
};
|
||
|
||
const openRenameDocument = (doc: KnowledgeDocumentInfo) => {
|
||
if (!ensureCanManageSelectedKnowledge()) return;
|
||
setRenamingDoc(doc);
|
||
renameDocForm.setFieldsValue({ name: doc.name });
|
||
setRenameDocModalOpen(true);
|
||
};
|
||
|
||
const saveDocumentName = async () => {
|
||
if (!renamingDoc) return;
|
||
if (!ensureCanManageSelectedKnowledge()) return;
|
||
const values = await renameDocForm.validateFields();
|
||
setSavingRename(true);
|
||
try {
|
||
await updateKnowledgeDocumentName({
|
||
docId: renamingDoc.id,
|
||
name: values.name,
|
||
});
|
||
message.success("文档名称已更新");
|
||
setRenameDocModalOpen(false);
|
||
await loadDocuments();
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : "文档重命名失败");
|
||
} finally {
|
||
setSavingRename(false);
|
||
}
|
||
};
|
||
|
||
const refreshDocumentStatus = async () => {
|
||
if (documents.length === 0) return;
|
||
try {
|
||
const statusList = await queryKnowledgeDocumentStatus(
|
||
documents.map((doc) => doc.id),
|
||
);
|
||
const statusMap = new Map(statusList.map((item) => [item.id, item]));
|
||
setDocuments((prev) =>
|
||
prev.map((doc) => ({
|
||
...doc,
|
||
...(statusMap.get(doc.id) || {}),
|
||
})),
|
||
);
|
||
message.success("文档状态已刷新");
|
||
} catch {
|
||
await loadDocuments();
|
||
}
|
||
};
|
||
|
||
const confirmDeleteDocument = (doc: KnowledgeDocumentInfo) => {
|
||
if (!ensureCanManageSelectedKnowledge()) return;
|
||
Modal.confirm({
|
||
title: "删除文档",
|
||
content: `确认删除「${doc.name}」吗?`,
|
||
okText: "删除",
|
||
okButtonProps: { danger: true },
|
||
cancelText: "取消",
|
||
onOk: async () => {
|
||
await deleteKnowledgeDocument(doc.id);
|
||
message.success("文档已删除");
|
||
await loadDocuments();
|
||
},
|
||
});
|
||
};
|
||
|
||
const runDocumentAction = async (
|
||
doc: KnowledgeDocumentInfo,
|
||
action: "qa" | "embedding" | "retry",
|
||
) => {
|
||
if (!ensureCanManageSelectedKnowledge()) return;
|
||
try {
|
||
if (action === "qa") {
|
||
await generateKnowledgeDocumentQa(doc.id);
|
||
message.success("已提交生成 QA 任务");
|
||
} else if (action === "embedding") {
|
||
await generateKnowledgeDocumentEmbeddings(doc.id);
|
||
message.success("已提交向量化任务");
|
||
} else {
|
||
await retryKnowledgeDocumentTask(doc.id);
|
||
message.success("已提交重试任务");
|
||
}
|
||
await loadDocuments();
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : "操作失败");
|
||
}
|
||
};
|
||
|
||
const openSegmentModal = (segment?: KnowledgeRawSegmentInfo) => {
|
||
if (!ensureCanManageSelectedKnowledge()) return;
|
||
setEditingSegment(segment || null);
|
||
segmentForm.setFieldsValue({
|
||
rawTxt: segment?.rawTxt || "",
|
||
sortIndex: segment?.sortIndex || segments.length,
|
||
});
|
||
setSegmentModalOpen(true);
|
||
};
|
||
|
||
const saveSegment = async () => {
|
||
if (!ensureCanManageSelectedKnowledge()) return;
|
||
const spaceId =
|
||
selectedDocument?.spaceId || knowledgeInfo?.spaceId || selectedSpaceId;
|
||
if (!selectedDocument || !spaceId) return;
|
||
const values = await segmentForm.validateFields();
|
||
try {
|
||
if (editingSegment) {
|
||
await updateKnowledgeRawSegment({
|
||
spaceId,
|
||
rawSegmentId: editingSegment.id,
|
||
docId: selectedDocument.id,
|
||
rawTxt: values.rawTxt,
|
||
sortIndex: values.sortIndex,
|
||
});
|
||
message.success("分段已更新");
|
||
} else {
|
||
await addKnowledgeRawSegment({
|
||
spaceId,
|
||
docId: selectedDocument.id,
|
||
rawTxt: values.rawTxt,
|
||
sortIndex: values.sortIndex,
|
||
});
|
||
message.success("分段已新增");
|
||
}
|
||
setSegmentModalOpen(false);
|
||
await loadSegments();
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : "保存分段失败");
|
||
}
|
||
};
|
||
|
||
const confirmDeleteSegment = (segment: KnowledgeRawSegmentInfo) => {
|
||
if (!ensureCanManageSelectedKnowledge()) return;
|
||
Modal.confirm({
|
||
title: "删除分段",
|
||
content: "确认删除该分段吗?",
|
||
okText: "删除",
|
||
okButtonProps: { danger: true },
|
||
cancelText: "取消",
|
||
onOk: async () => {
|
||
await deleteKnowledgeRawSegment(segment.id);
|
||
message.success("分段已删除");
|
||
await loadSegments();
|
||
},
|
||
});
|
||
};
|
||
|
||
const openQaModal = (qa?: KnowledgeQaInfo) => {
|
||
if (!ensureCanManageSelectedKnowledge()) return;
|
||
setEditingQa(qa || null);
|
||
qaForm.setFieldsValue({
|
||
question: qa?.question || "",
|
||
answer: qa?.answer || "",
|
||
});
|
||
setQaModalOpen(true);
|
||
};
|
||
|
||
const saveQa = async () => {
|
||
if (!selectedKnowledgeId) return;
|
||
if (!ensureCanManageSelectedKnowledge()) return;
|
||
const values = await qaForm.validateFields();
|
||
try {
|
||
if (editingQa) {
|
||
await updateKnowledgeQa({
|
||
id: editingQa.id,
|
||
question: values.question,
|
||
answer: values.answer,
|
||
});
|
||
message.success("QA 已更新");
|
||
} else {
|
||
await addKnowledgeQa({
|
||
kbId: selectedKnowledgeId,
|
||
spaceId: selectedSpaceId || undefined,
|
||
question: values.question,
|
||
answer: values.answer,
|
||
});
|
||
message.success("QA 已新增");
|
||
}
|
||
setQaModalOpen(false);
|
||
await loadQaList();
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : "保存 QA 失败");
|
||
}
|
||
};
|
||
|
||
const confirmDeleteQa = (qa: KnowledgeQaInfo) => {
|
||
if (!ensureCanManageSelectedKnowledge()) return;
|
||
Modal.confirm({
|
||
title: "删除 QA",
|
||
content: "确认删除该问答吗?",
|
||
okText: "删除",
|
||
okButtonProps: { danger: true },
|
||
cancelText: "取消",
|
||
onOk: async () => {
|
||
await deleteKnowledgeQa(qa.id);
|
||
message.success("QA 已删除");
|
||
await loadQaList();
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleQaImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
event.target.value = "";
|
||
if (!file || !selectedKnowledgeId) return;
|
||
if (!ensureCanManageSelectedKnowledge()) return;
|
||
try {
|
||
await importKnowledgeQaExcel({ kbId: selectedKnowledgeId, file });
|
||
message.success("QA 已导入");
|
||
await loadQaList();
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : "QA 导入失败");
|
||
}
|
||
};
|
||
|
||
const handleDownloadQaTemplate = async () => {
|
||
try {
|
||
const blob = await downloadKnowledgeQaTemplate();
|
||
const url = URL.createObjectURL(blob);
|
||
const anchor = document.createElement("a");
|
||
anchor.href = url;
|
||
anchor.download = "知识库QA导入模板.xlsx";
|
||
anchor.click();
|
||
URL.revokeObjectURL(url);
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : "模板下载失败");
|
||
}
|
||
};
|
||
|
||
const documentColumns: ColumnsType<KnowledgeDocumentInfo> = [
|
||
{
|
||
title: "文档",
|
||
dataIndex: "name",
|
||
ellipsis: true,
|
||
render: (name: string, record) => (
|
||
<button
|
||
type="button"
|
||
className={styles.linkButton}
|
||
onClick={() => {
|
||
setSelectedDocument(record);
|
||
setActiveTab("segments");
|
||
}}
|
||
>
|
||
<FileTextOutlined />
|
||
<span>{name}</span>
|
||
</button>
|
||
),
|
||
},
|
||
{
|
||
title: "状态",
|
||
width: 120,
|
||
render: (_, record) => {
|
||
const status = getDocumentStatus(record);
|
||
return <Tag color={status.color}>{status.text}</Tag>;
|
||
},
|
||
},
|
||
{
|
||
title: "QA",
|
||
width: 82,
|
||
render: (_, record) => (record.hasQa ? "已生成" : "未生成"),
|
||
},
|
||
{
|
||
title: "向量",
|
||
width: 82,
|
||
render: (_, record) => (record.hasEmbedding ? "已完成" : "未完成"),
|
||
},
|
||
{
|
||
title: "更新时间",
|
||
width: 170,
|
||
render: (_, record) => formatDateTime(record.modified || record.created),
|
||
},
|
||
{
|
||
title: "操作",
|
||
width: canManageSelectedKnowledge ? 260 : 120,
|
||
fixed: "right",
|
||
render: (_, record) => (
|
||
<div className={styles.tableActions}>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => {
|
||
setSelectedDocument(record);
|
||
setActiveTab("segments");
|
||
}}
|
||
>
|
||
分段
|
||
</Button>
|
||
{canManageSelectedKnowledge && (
|
||
<>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => runDocumentAction(record, "qa")}
|
||
>
|
||
生成 QA
|
||
</Button>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => runDocumentAction(record, "embedding")}
|
||
>
|
||
向量化
|
||
</Button>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => runDocumentAction(record, "retry")}
|
||
>
|
||
重试
|
||
</Button>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => openRenameDocument(record)}
|
||
>
|
||
重命名
|
||
</Button>
|
||
</>
|
||
)}
|
||
{record.docUrl && (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => window.open(record.docUrl, "_blank")}
|
||
>
|
||
打开
|
||
</Button>
|
||
)}
|
||
{canManageSelectedKnowledge && (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
danger
|
||
onClick={() => confirmDeleteDocument(record)}
|
||
>
|
||
删除
|
||
</Button>
|
||
)}
|
||
</div>
|
||
),
|
||
},
|
||
];
|
||
|
||
const segmentColumns: ColumnsType<KnowledgeRawSegmentInfo> = [
|
||
{
|
||
title: "序号",
|
||
dataIndex: "sortIndex",
|
||
width: 90,
|
||
render: (value, _, index) => value ?? index + 1,
|
||
},
|
||
{
|
||
title: "分段内容",
|
||
dataIndex: "rawTxt",
|
||
render: (value: string) => <p className={styles.segmentText}>{value}</p>,
|
||
},
|
||
{
|
||
title: "更新时间",
|
||
width: 170,
|
||
render: (_, record) => formatDateTime(record.modified || record.created),
|
||
},
|
||
...(canManageSelectedKnowledge
|
||
? [
|
||
{
|
||
title: "操作",
|
||
width: 120,
|
||
render: (_: unknown, record: KnowledgeRawSegmentInfo) => (
|
||
<div className={styles.tableActions}>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => openSegmentModal(record)}
|
||
>
|
||
编辑
|
||
</Button>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
danger
|
||
onClick={() => confirmDeleteSegment(record)}
|
||
>
|
||
删除
|
||
</Button>
|
||
</div>
|
||
),
|
||
},
|
||
]
|
||
: []),
|
||
];
|
||
|
||
const qaColumns: ColumnsType<KnowledgeQaInfo> = [
|
||
{
|
||
title: "问题",
|
||
dataIndex: "question",
|
||
width: "34%",
|
||
render: (value: string) => <p className={styles.qaText}>{value}</p>,
|
||
},
|
||
{
|
||
title: "答案",
|
||
dataIndex: "answer",
|
||
render: (value: string) => <p className={styles.qaText}>{value}</p>,
|
||
},
|
||
{
|
||
title: "更新时间",
|
||
width: 170,
|
||
render: (_, record) => formatDateTime(record.modified || record.created),
|
||
},
|
||
...(canManageSelectedKnowledge
|
||
? [
|
||
{
|
||
title: "操作",
|
||
width: 120,
|
||
render: (_: unknown, record: KnowledgeQaInfo) => (
|
||
<div className={styles.tableActions}>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => openQaModal(record)}
|
||
>
|
||
编辑
|
||
</Button>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
danger
|
||
onClick={() => confirmDeleteQa(record)}
|
||
>
|
||
删除
|
||
</Button>
|
||
</div>
|
||
),
|
||
},
|
||
]
|
||
: []),
|
||
];
|
||
|
||
const renderKnowledgeIcon = (
|
||
knowledge: KnowledgeComponent | KnowledgeInfo | null,
|
||
) => (
|
||
<ImageWithFallback
|
||
className={styles.knowledgeIconImage}
|
||
src={knowledge?.icon}
|
||
alt=""
|
||
fallback={
|
||
<span className={styles.knowledgeIconText}>
|
||
{getInitial(knowledge?.name)}
|
||
</span>
|
||
}
|
||
/>
|
||
);
|
||
|
||
const renderCreateButton = (variant: "header" | "toolbar" | "empty") => (
|
||
<Button
|
||
type="primary"
|
||
icon={<PlusOutlined />}
|
||
className={variant === "toolbar" ? styles.toolbarCreateButton : undefined}
|
||
onClick={() => openKnowledgeForm()}
|
||
>
|
||
新建知识库
|
||
</Button>
|
||
);
|
||
|
||
const renderList = () => (
|
||
<>
|
||
<header className={styles.header}>
|
||
<div>
|
||
<div className={styles.breadcrumb}>
|
||
工作台 <span>/</span> <strong>知识库</strong>
|
||
</div>
|
||
<div className={styles.titleRow}>
|
||
<h1>知识库</h1>
|
||
<p>管理文档、分段和 QA,让 AI 对话可检索企业知识。</p>
|
||
</div>
|
||
</div>
|
||
{renderCreateButton("header")}
|
||
</header>
|
||
<main className={styles.content}>
|
||
<div className={styles.toolbar}>
|
||
<Input
|
||
allowClear
|
||
prefix={<SearchOutlined />}
|
||
placeholder="搜索知识库"
|
||
value={knowledgeKeyword}
|
||
onChange={(event) => setKnowledgeKeyword(event.target.value)}
|
||
className={styles.search}
|
||
/>
|
||
{renderCreateButton("toolbar")}
|
||
<div className={styles.count}>
|
||
<span>{filteredKnowledges.length}</span> 个知识库
|
||
</div>
|
||
</div>
|
||
<div className={styles.listArea}>
|
||
{loadingKnowledges ? (
|
||
<div className={styles.centerState}>
|
||
<Spin />
|
||
<span>知识库加载中...</span>
|
||
</div>
|
||
) : knowledgeError ? (
|
||
<div className={styles.centerState}>
|
||
<Empty
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description={knowledgeError}
|
||
/>
|
||
<Button icon={<ReloadOutlined />} onClick={loadKnowledges}>
|
||
重新加载
|
||
</Button>
|
||
</div>
|
||
) : filteredKnowledges.length === 0 ? (
|
||
<div className={styles.centerState}>
|
||
<Empty
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description="暂无知识库"
|
||
/>
|
||
{renderCreateButton("empty")}
|
||
</div>
|
||
) : (
|
||
<div className={styles.grid}>
|
||
{filteredKnowledges.map((knowledge) => {
|
||
const knowledgeId = getKnowledgeId(knowledge);
|
||
const owned = isKnowledgeOwner(knowledge);
|
||
const creatorName =
|
||
knowledge.creator?.nickName ||
|
||
knowledge.creator?.userName ||
|
||
knowledge.creatorName ||
|
||
"未知创建人";
|
||
const joinedComponent = joinedKnowledgeMap.get(knowledgeId);
|
||
const joined =
|
||
!!joinedComponent &&
|
||
isAiKnowledgeChatSelectable(joinedComponent);
|
||
return (
|
||
<article
|
||
key={knowledgeId}
|
||
className={styles.card}
|
||
onClick={() => openKnowledgeDetail(knowledge)}
|
||
>
|
||
<div className={styles.cardHeader}>
|
||
<div className={styles.knowledgeIcon}>
|
||
{renderKnowledgeIcon(knowledge)}
|
||
</div>
|
||
<div className={styles.cardTitle}>
|
||
<h3>{knowledge.name}</h3>
|
||
<div className={styles.cardOwnerRow}>
|
||
<Tag
|
||
color={owned ? "cyan" : "default"}
|
||
className={styles.ownerTag}
|
||
>
|
||
{owned ? "我的知识库" : "只读"}
|
||
</Tag>
|
||
{!owned && (
|
||
<span title={creatorName}>
|
||
创建人:{creatorName}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p className={styles.cardDescription}>
|
||
{knowledge.description || "暂无描述"}
|
||
</p>
|
||
<div className={styles.cardMeta}>
|
||
<span>
|
||
容量 {formatBytes(Number(knowledge.fileSize || 0))}
|
||
</span>
|
||
<span
|
||
title={formatDateTime(
|
||
knowledge.modified || knowledge.created,
|
||
)}
|
||
>
|
||
更新{" "}
|
||
{formatDateTime(
|
||
knowledge.modified || knowledge.created,
|
||
)}
|
||
</span>
|
||
</div>
|
||
<div className={styles.cardActions}>
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
icon={joined ? <CheckOutlined /> : <TeamOutlined />}
|
||
loading={joiningIds.has(knowledgeId)}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
joinKnowledgeToChat(knowledge);
|
||
}}
|
||
>
|
||
{joined ? "已加入" : "加入对话"}
|
||
</Button>
|
||
{owned && (
|
||
<div className={styles.cardOwnerActions}>
|
||
<Tooltip title="编辑知识库">
|
||
<Button
|
||
size="small"
|
||
icon={<EditOutlined />}
|
||
aria-label="编辑知识库"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
openKnowledgeForm(knowledge);
|
||
}}
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip title="删除知识库">
|
||
<Button
|
||
size="small"
|
||
danger
|
||
icon={<DeleteOutlined />}
|
||
aria-label="删除知识库"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
confirmDeleteKnowledge(knowledge);
|
||
}}
|
||
/>
|
||
</Tooltip>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</main>
|
||
</>
|
||
);
|
||
|
||
const renderDocuments = () => (
|
||
<div className={styles.tabPane}>
|
||
<div className={styles.tabToolbar}>
|
||
<Input.Search
|
||
allowClear
|
||
placeholder="搜索文档"
|
||
value={documentKeyword}
|
||
onChange={(event) => {
|
||
const value = event.target.value;
|
||
setDocumentKeyword(value);
|
||
if (!value) void loadDocuments("");
|
||
}}
|
||
onSearch={(value) => loadDocuments(value)}
|
||
className={styles.innerSearch}
|
||
/>
|
||
<input
|
||
ref={documentFileInputRef}
|
||
type="file"
|
||
multiple
|
||
className={styles.hiddenInput}
|
||
onChange={handleDocumentFiles}
|
||
/>
|
||
{canManageSelectedKnowledge && (
|
||
<>
|
||
<Button
|
||
icon={<CloudUploadOutlined />}
|
||
loading={uploadingDocs}
|
||
onClick={() => documentFileInputRef.current?.click()}
|
||
>
|
||
上传文档
|
||
</Button>
|
||
<Button
|
||
icon={<FileAddOutlined />}
|
||
onClick={() => {
|
||
customDocForm.resetFields();
|
||
setCustomDocModalOpen(true);
|
||
}}
|
||
>
|
||
自定义文本
|
||
</Button>
|
||
</>
|
||
)}
|
||
<Tooltip title="刷新文档状态">
|
||
<Button icon={<ReloadOutlined />} onClick={refreshDocumentStatus} />
|
||
</Tooltip>
|
||
</div>
|
||
<Table
|
||
rowKey="id"
|
||
size="middle"
|
||
loading={loadingDocuments}
|
||
columns={documentColumns}
|
||
dataSource={documents}
|
||
scroll={{ x: 980 }}
|
||
pagination={{
|
||
pageSize: DOCUMENT_PAGE_SIZE,
|
||
total: documentTotal,
|
||
showSizeChanger: false,
|
||
}}
|
||
locale={{
|
||
emptyText: (
|
||
<Empty
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description="暂无文档"
|
||
/>
|
||
),
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
|
||
const renderSegments = () => (
|
||
<div className={styles.tabPane}>
|
||
<div className={styles.tabToolbar}>
|
||
<Select
|
||
value={selectedDocument?.id}
|
||
placeholder="选择文档查看分段"
|
||
className={styles.documentSelect}
|
||
onChange={(id) => {
|
||
setSelectedDocument(documents.find((doc) => doc.id === id) || null);
|
||
}}
|
||
options={documents.map((doc) => ({ label: doc.name, value: doc.id }))}
|
||
/>
|
||
{canManageSelectedKnowledge && (
|
||
<Button
|
||
icon={<PlusOutlined />}
|
||
disabled={!selectedDocument}
|
||
onClick={() => openSegmentModal()}
|
||
>
|
||
新增分段
|
||
</Button>
|
||
)}
|
||
<Button
|
||
icon={<ReloadOutlined />}
|
||
disabled={!selectedDocument}
|
||
onClick={loadSegments}
|
||
>
|
||
刷新
|
||
</Button>
|
||
<span className={styles.mutedText}>共 {segmentTotal} 个分段</span>
|
||
</div>
|
||
{!selectedDocument ? (
|
||
<div className={styles.centerState}>
|
||
<Empty
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description="请先选择文档"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<Table
|
||
rowKey="id"
|
||
size="middle"
|
||
loading={loadingSegments}
|
||
columns={segmentColumns}
|
||
dataSource={segments}
|
||
pagination={{
|
||
pageSize: SEGMENT_PAGE_SIZE,
|
||
total: segmentTotal,
|
||
showSizeChanger: false,
|
||
}}
|
||
locale={{
|
||
emptyText: (
|
||
<Empty
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description="暂无分段"
|
||
/>
|
||
),
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const renderQa = () => (
|
||
<div className={styles.tabPane}>
|
||
<div className={styles.tabToolbar}>
|
||
<Input.Search
|
||
allowClear
|
||
placeholder="搜索问题"
|
||
value={qaKeyword}
|
||
onChange={(event) => {
|
||
const value = event.target.value;
|
||
setQaKeyword(value);
|
||
if (!value) void loadQaList("");
|
||
}}
|
||
onSearch={(value) => loadQaList(value)}
|
||
className={styles.innerSearch}
|
||
/>
|
||
{canManageSelectedKnowledge && (
|
||
<Button icon={<PlusOutlined />} onClick={() => openQaModal()}>
|
||
新增 QA
|
||
</Button>
|
||
)}
|
||
<input
|
||
ref={qaFileInputRef}
|
||
type="file"
|
||
accept=".xls,.xlsx"
|
||
className={styles.hiddenInput}
|
||
onChange={handleQaImport}
|
||
/>
|
||
{canManageSelectedKnowledge && (
|
||
<Button
|
||
icon={<CloudUploadOutlined />}
|
||
onClick={() => qaFileInputRef.current?.click()}
|
||
>
|
||
导入 Excel
|
||
</Button>
|
||
)}
|
||
<Button icon={<DownloadOutlined />} onClick={handleDownloadQaTemplate}>
|
||
下载模板
|
||
</Button>
|
||
<Button icon={<ReloadOutlined />} onClick={() => loadQaList()}>
|
||
刷新
|
||
</Button>
|
||
<span className={styles.mutedText}>共 {qaTotal} 条 QA</span>
|
||
</div>
|
||
<Table
|
||
rowKey="id"
|
||
size="middle"
|
||
loading={loadingQa}
|
||
columns={qaColumns}
|
||
dataSource={qaList}
|
||
scroll={{ x: 900 }}
|
||
pagination={{
|
||
pageSize: QA_PAGE_SIZE,
|
||
total: qaTotal,
|
||
showSizeChanger: false,
|
||
}}
|
||
locale={{
|
||
emptyText: (
|
||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无 QA" />
|
||
),
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
|
||
const renderGraphPlaceholder = () => (
|
||
<div className={styles.graphPlaceholder}>
|
||
<DatabaseOutlined />
|
||
<h3>知识图谱后续接入</h3>
|
||
<p>第一版先完成知识库核心管理。知识图谱和命中测试将在后续阶段迁移。</p>
|
||
</div>
|
||
);
|
||
|
||
const renderDetail = () => {
|
||
const joined =
|
||
selectedKnowledgeId !== null &&
|
||
!!joinedKnowledgeMap.get(selectedKnowledgeId) &&
|
||
isAiKnowledgeChatSelectable(joinedKnowledgeMap.get(selectedKnowledgeId)!);
|
||
const joining =
|
||
selectedKnowledgeId !== null && joiningIds.has(selectedKnowledgeId);
|
||
const knowledgeForAction = knowledgeInfo || selectedKnowledgeComponent;
|
||
const owned = isKnowledgeOwner(knowledgeForAction);
|
||
const creator = (knowledgeForAction as KnowledgeComponent | null)?.creator;
|
||
const creatorName =
|
||
creator?.nickName ||
|
||
creator?.userName ||
|
||
knowledgeForAction?.creatorName ||
|
||
"未知创建人";
|
||
|
||
return (
|
||
<>
|
||
<header className={styles.detailHeader}>
|
||
<Button
|
||
type="text"
|
||
icon={<ArrowLeftOutlined />}
|
||
onClick={() => setSelectedKnowledgeId(null)}
|
||
>
|
||
返回
|
||
</Button>
|
||
<div className={styles.detailTitleBlock}>
|
||
<div className={styles.detailIcon}>
|
||
{renderKnowledgeIcon(knowledgeForAction)}
|
||
</div>
|
||
<div>
|
||
<div className={styles.breadcrumb}>
|
||
知识库 <span>/</span>{" "}
|
||
<strong>{knowledgeForAction?.name || "知识库详情"}</strong>
|
||
</div>
|
||
<h1>{knowledgeForAction?.name || "知识库详情"}</h1>
|
||
<div className={styles.detailOwnerRow}>
|
||
<Tag color={owned ? "cyan" : "default"}>
|
||
{owned ? "我的知识库" : "只读知识库"}
|
||
</Tag>
|
||
{!owned && <span>创建人:{creatorName}</span>}
|
||
</div>
|
||
<p>{knowledgeForAction?.description || "暂无描述"}</p>
|
||
</div>
|
||
</div>
|
||
<div className={styles.detailActions}>
|
||
<Button
|
||
icon={joined ? <CheckOutlined /> : <TeamOutlined />}
|
||
loading={joining}
|
||
disabled={!knowledgeForAction}
|
||
onClick={() =>
|
||
knowledgeForAction && joinKnowledgeToChat(knowledgeForAction)
|
||
}
|
||
>
|
||
{joined ? "已加入对话" : "加入对话"}
|
||
</Button>
|
||
{owned && (
|
||
<Button
|
||
icon={<EditOutlined />}
|
||
disabled={!knowledgeForAction}
|
||
onClick={() =>
|
||
knowledgeForAction && openKnowledgeForm(knowledgeForAction)
|
||
}
|
||
>
|
||
编辑
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</header>
|
||
<main className={styles.detailContent}>
|
||
{loadingDetail && !knowledgeInfo ? (
|
||
<div className={styles.centerState}>
|
||
<Spin />
|
||
<span>详情加载中...</span>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className={styles.statRow}>
|
||
<div className={styles.statCard}>
|
||
<span>文档数</span>
|
||
<strong>{documentTotal}</strong>
|
||
</div>
|
||
<div className={styles.statCard}>
|
||
<span>容量</span>
|
||
<strong>
|
||
{formatBytes(Number(knowledgeInfo?.fileSize || 0))}
|
||
</strong>
|
||
</div>
|
||
<div className={styles.statCard}>
|
||
<span>状态</span>
|
||
<strong>{knowledgeInfo?.pubStatus || "Published"}</strong>
|
||
</div>
|
||
<div className={styles.statCard}>
|
||
<span>更新时间</span>
|
||
<strong>
|
||
{formatDateTime(
|
||
knowledgeInfo?.modified || knowledgeInfo?.created,
|
||
)}
|
||
</strong>
|
||
</div>
|
||
</div>
|
||
<Tabs
|
||
activeKey={activeTab}
|
||
onChange={(key) => setActiveTab(key as DetailTabKey)}
|
||
items={[
|
||
{
|
||
key: "documents",
|
||
label: "文档",
|
||
children: renderDocuments(),
|
||
},
|
||
{
|
||
key: "segments",
|
||
label: "分段",
|
||
children: renderSegments(),
|
||
},
|
||
{
|
||
key: "qa",
|
||
label: "QA",
|
||
children: renderQa(),
|
||
},
|
||
{
|
||
key: "graph",
|
||
label: "知识图谱",
|
||
children: renderGraphPlaceholder(),
|
||
},
|
||
]}
|
||
/>
|
||
</>
|
||
)}
|
||
</main>
|
||
</>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className={styles.page}>
|
||
{selectedKnowledgeId ? renderDetail() : renderList()}
|
||
|
||
<Modal
|
||
open={knowledgeModalOpen}
|
||
title={editingKnowledge ? "编辑知识库" : "新建知识库"}
|
||
okText={editingKnowledge ? "保存" : "创建"}
|
||
cancelText="取消"
|
||
confirmLoading={savingKnowledge}
|
||
onOk={saveKnowledge}
|
||
onCancel={() => setKnowledgeModalOpen(false)}
|
||
destroyOnClose
|
||
width={620}
|
||
>
|
||
<input
|
||
ref={iconFileInputRef}
|
||
type="file"
|
||
accept=".jpg,.jpeg,.png,image/jpeg,image/png"
|
||
className={styles.hiddenInput}
|
||
onChange={handleIconFile}
|
||
/>
|
||
<Form
|
||
form={knowledgeForm}
|
||
layout="vertical"
|
||
preserve={false}
|
||
initialValues={{ dataParsingMethod: "default" }}
|
||
>
|
||
<Form.Item
|
||
name="name"
|
||
label="知识库名称"
|
||
rules={[
|
||
{ required: true, message: "请输入知识库名称" },
|
||
{ max: 100, message: "知识库名称不能超过 100 个字符" },
|
||
]}
|
||
>
|
||
<Input placeholder="请输入知识库名称" showCount maxLength={100} />
|
||
</Form.Item>
|
||
<Form.Item name="description" label="描述">
|
||
<Input.TextArea
|
||
placeholder="请输入知识库描述"
|
||
showCount
|
||
maxLength={10000}
|
||
autoSize={{ minRows: 3, maxRows: 5 }}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="embeddingModelId"
|
||
label="嵌入模型"
|
||
rules={[{ required: true, message: "请选择嵌入模型" }]}
|
||
tooltip="用于文档向量化和检索召回,请选择 Embeddings 类型模型"
|
||
>
|
||
<Select
|
||
loading={loadingModels}
|
||
placeholder="请选择嵌入模型"
|
||
allowClear
|
||
showSearch
|
||
optionFilterProp="label"
|
||
notFoundContent={
|
||
loadingModels ? (
|
||
<Spin size="small" />
|
||
) : (
|
||
"暂无向量模型,请先配置 Embeddings 模型"
|
||
)
|
||
}
|
||
options={models.map((model) => ({
|
||
label: model.name || model.model || `模型 ${model.id}`,
|
||
value: model.id,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="dataParsingMethod"
|
||
label="文档解析方式"
|
||
tooltip="系统默认解析适合常规文档;自定义工作流解析适合需要专门抽取、清洗或结构化处理的文档"
|
||
>
|
||
<Select
|
||
placeholder="请选择文档解析方式"
|
||
options={[
|
||
{ label: "系统默认", value: "default" },
|
||
{ label: "自定义工作流", value: "workflow" },
|
||
]}
|
||
onChange={(value) => {
|
||
if (value === "default") {
|
||
knowledgeForm.setFieldValue("workflowId", undefined);
|
||
return;
|
||
}
|
||
void (async () => {
|
||
const spaceId =
|
||
selectedSpaceId || (await ensureDefaultSpaceId(false));
|
||
await loadParsingWorkflows(spaceId || undefined);
|
||
})();
|
||
}}
|
||
/>
|
||
</Form.Item>
|
||
{dataParsingMethod === "workflow" && (
|
||
<Form.Item
|
||
name="workflowId"
|
||
label="解析工作流"
|
||
rules={[{ required: true, message: "请选择解析工作流" }]}
|
||
>
|
||
<Select
|
||
loading={loadingParsingWorkflows}
|
||
placeholder="请选择用于文档解析的工作流"
|
||
allowClear
|
||
showSearch
|
||
optionFilterProp="label"
|
||
notFoundContent={
|
||
loadingParsingWorkflows ? (
|
||
<Spin size="small" />
|
||
) : (
|
||
"暂无可用工作流,请先发布工作流"
|
||
)
|
||
}
|
||
options={parsingWorkflows.map((workflow) => ({
|
||
label:
|
||
workflow.name ||
|
||
`工作流 ${getPublishedWorkflowId(workflow)}`,
|
||
value: getPublishedWorkflowId(workflow),
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
)}
|
||
<Form.Item label="图标">
|
||
<div className={styles.iconUploadRow}>
|
||
<button
|
||
type="button"
|
||
className={styles.iconUploadCard}
|
||
disabled={uploadingIcon}
|
||
onClick={() => iconFileInputRef.current?.click()}
|
||
>
|
||
<span className={styles.iconPreview}>
|
||
<ImageWithFallback
|
||
src={iconUrl}
|
||
alt=""
|
||
fallback={<PictureOutlined />}
|
||
/>
|
||
</span>
|
||
<span className={styles.iconUploadText}>
|
||
<strong>
|
||
{uploadingIcon
|
||
? "图标上传中..."
|
||
: iconUrl
|
||
? "已上传图标"
|
||
: "默认知识库图标"}
|
||
</strong>
|
||
<span>支持 JPG、PNG,最大 2MB</span>
|
||
</span>
|
||
<UploadOutlined />
|
||
</button>
|
||
<Button
|
||
disabled={!iconUrl || uploadingIcon}
|
||
onClick={() => setIconUrl("")}
|
||
>
|
||
清除
|
||
</Button>
|
||
</div>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
open={customDocModalOpen}
|
||
title="新增自定义文本"
|
||
okText="添加"
|
||
cancelText="取消"
|
||
confirmLoading={savingCustomDoc}
|
||
onOk={saveCustomDocument}
|
||
onCancel={() => setCustomDocModalOpen(false)}
|
||
destroyOnClose
|
||
>
|
||
<Form form={customDocForm} layout="vertical" preserve={false}>
|
||
<Form.Item
|
||
name="name"
|
||
label="文档名称"
|
||
rules={[{ required: true, message: "请输入文档名称" }]}
|
||
>
|
||
<Input placeholder="请输入文档名称" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="fileContent"
|
||
label="文本内容"
|
||
rules={[{ required: true, message: "请输入文本内容" }]}
|
||
>
|
||
<Input.TextArea
|
||
placeholder="请输入文本内容"
|
||
autoSize={{ minRows: 8, maxRows: 14 }}
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
open={renameDocModalOpen}
|
||
title="重命名文档"
|
||
okText="保存"
|
||
cancelText="取消"
|
||
confirmLoading={savingRename}
|
||
onOk={saveDocumentName}
|
||
onCancel={() => setRenameDocModalOpen(false)}
|
||
destroyOnClose
|
||
>
|
||
<Form form={renameDocForm} layout="vertical" preserve={false}>
|
||
<Form.Item
|
||
name="name"
|
||
label="文档名称"
|
||
rules={[{ required: true, message: "请输入文档名称" }]}
|
||
>
|
||
<Input placeholder="请输入文档名称" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
open={segmentModalOpen}
|
||
title={editingSegment ? "编辑分段" : "新增分段"}
|
||
okText="保存"
|
||
cancelText="取消"
|
||
onOk={saveSegment}
|
||
onCancel={() => setSegmentModalOpen(false)}
|
||
destroyOnClose
|
||
width={720}
|
||
>
|
||
<Form form={segmentForm} layout="vertical" preserve={false}>
|
||
<Form.Item name="sortIndex" label="排序">
|
||
<Input type="number" placeholder="排序序号" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="rawTxt"
|
||
label="分段内容"
|
||
rules={[{ required: true, message: "请输入分段内容" }]}
|
||
>
|
||
<Input.TextArea
|
||
placeholder="请输入分段内容"
|
||
autoSize={{ minRows: 8, maxRows: 16 }}
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
open={qaModalOpen}
|
||
title={editingQa ? "编辑 QA" : "新增 QA"}
|
||
okText="保存"
|
||
cancelText="取消"
|
||
onOk={saveQa}
|
||
onCancel={() => setQaModalOpen(false)}
|
||
destroyOnClose
|
||
width={720}
|
||
>
|
||
<Form form={qaForm} layout="vertical" preserve={false}>
|
||
<Form.Item
|
||
name="question"
|
||
label="问题"
|
||
rules={[{ required: true, message: "请输入问题" }]}
|
||
>
|
||
<Input.TextArea
|
||
placeholder="请输入问题"
|
||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="answer"
|
||
label="答案"
|
||
rules={[{ required: true, message: "请输入答案" }]}
|
||
>
|
||
<Input.TextArea
|
||
placeholder="请输入答案"
|
||
autoSize={{ minRows: 6, maxRows: 12 }}
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|