feat(knowledge): 添加知识条目文件预览和替换功能

- 后端实现知识条目文件预览接口,支持多种文件类型在线预览
- 后端实现知识条目文件替换功能,保留原有文件管理逻辑
- 前端新增文件预览模态框组件,支持文本、图片、音视频预览
- 前端知识条目编辑器添加文件替换上传功能
- 前端优化文件内容截断预览逻辑,统一使用工具函数处理
- 前端修复 PUT 请求中 FormData 处理问题,确保文件上传正常工作
- 新增文件预览相关工具函数和常量配置
This commit is contained in:
2026-01-29 11:37:36 +08:00
parent d0b5473068
commit ce98be5778
10 changed files with 467 additions and 46 deletions

View File

@@ -42,8 +42,16 @@ import CreateKnowledgeSet from "../components/CreateKnowledgeSet";
import KnowledgeItemEditor from "../components/KnowledgeItemEditor";
import ImportKnowledgeItemsDialog from "../components/ImportKnowledgeItemsDialog";
import { formatDate } from "@/utils/unit";
import { PREVIEW_TEXT_MAX_LENGTH, resolvePreviewFileType, truncatePreviewText } from "@/utils/filePreview";
const MAX_READ_LENGTH = 50000;
const PREVIEW_MAX_HEIGHT = 500;
const PREVIEW_MODAL_WIDTH = {
text: 800,
media: 700,
};
const PREVIEW_TEXT_FONT_SIZE = 12;
const PREVIEW_TEXT_PADDING = 12;
const PREVIEW_AUDIO_PADDING = 40;
const KnowledgeSetDetail = () => {
const navigate = useNavigate();
@@ -57,6 +65,12 @@ const KnowledgeSetDetail = () => {
const [readModalOpen, setReadModalOpen] = useState(false);
const [readContent, setReadContent] = useState("");
const [readTitle, setReadTitle] = useState("");
const [previewVisible, setPreviewVisible] = useState(false);
const [previewContent, setPreviewContent] = useState("");
const [previewFileName, setPreviewFileName] = useState("");
const [previewFileType, setPreviewFileType] = useState<"text" | "image" | "video" | "audio">("text");
const [previewMediaUrl, setPreviewMediaUrl] = useState("");
const [previewLoadingItemId, setPreviewLoadingItemId] = useState<string | null>(null);
const fetchKnowledgeSet = useCallback(async () => {
if (!id) return;
@@ -123,6 +137,66 @@ const KnowledgeSetDetail = () => {
);
};
const resolvePreviewFileName = (record: KnowledgeItemView) => {
if (record.sourceFileId) {
return record.sourceFileId;
}
if (record.content) {
const segments = record.content.split("/");
const lastSegment = segments[segments.length - 1];
if (lastSegment) {
return lastSegment;
}
}
return record.title || "文件";
};
const handlePreviewItemFile = async (record: KnowledgeItemView) => {
if (!id) return;
const fileName = resolvePreviewFileName(record);
const fileType = resolvePreviewFileType(fileName);
if (!fileType) {
message.warning("不支持预览该文件类型");
return;
}
const previewUrl = `/api/data-management/knowledge-sets/${id}/items/${record.id}/preview`;
setPreviewFileName(fileName);
setPreviewFileType(fileType);
setPreviewContent("");
setPreviewMediaUrl("");
if (fileType === "text") {
setPreviewLoadingItemId(record.id);
try {
const response = await fetch(previewUrl);
if (!response.ok) {
throw new Error("下载失败");
}
const text = await response.text();
setPreviewContent(truncatePreviewText(text, PREVIEW_TEXT_MAX_LENGTH));
setPreviewVisible(true);
} catch (error) {
console.error("预览知识条目文件失败", error);
message.error("预览失败,请稍后重试");
} finally {
setPreviewLoadingItemId(null);
}
return;
}
setPreviewMediaUrl(previewUrl);
setPreviewVisible(true);
};
const closePreview = () => {
setPreviewVisible(false);
setPreviewContent("");
setPreviewMediaUrl("");
setPreviewFileName("");
setPreviewFileType("text");
};
const handleReadItem = async (record: KnowledgeItemView) => {
if (
record.contentType === KnowledgeContentType.FILE ||
@@ -136,13 +210,7 @@ const KnowledgeSetDetail = () => {
if (!record.sourceDatasetId || !record.sourceFileId) {
const content = record.content || "";
if (content.length > MAX_READ_LENGTH) {
setReadContent(
`${content.slice(0, MAX_READ_LENGTH)}\n\n... (内容过长,仅显示前 ${MAX_READ_LENGTH} 字符)`
);
} else {
setReadContent(content);
}
setReadContent(truncatePreviewText(content, PREVIEW_TEXT_MAX_LENGTH));
setReadModalOpen(true);
setReadItemId(null);
return;
@@ -156,13 +224,7 @@ const KnowledgeSetDetail = () => {
throw new Error("下载失败");
}
const text = await response.text();
if (text.length > MAX_READ_LENGTH) {
setReadContent(
`${text.slice(0, MAX_READ_LENGTH)}\n\n... (内容过长,仅显示前 ${MAX_READ_LENGTH} 字符)`
);
} else {
setReadContent(text);
}
setReadContent(truncatePreviewText(text, PREVIEW_TEXT_MAX_LENGTH));
setReadModalOpen(true);
} catch (error) {
console.error("读取知识条目失败", error);
@@ -273,6 +335,18 @@ const KnowledgeSetDetail = () => {
/>
</Tooltip>
)}
{(record.contentType === KnowledgeContentType.FILE ||
record.sourceType === KnowledgeSourceType.FILE_UPLOAD) && (
<Tooltip title="预览">
<Button
type="text"
icon={<EyeOutlined />}
onClick={() => handlePreviewItemFile(record)}
loading={previewLoadingItemId === record.id}
aria-label="预览"
/>
</Tooltip>
)}
{(record.contentType === KnowledgeContentType.FILE ||
record.sourceType === KnowledgeSourceType.FILE_UPLOAD) && (
<Tooltip title="下载文件">
@@ -444,6 +518,63 @@ const KnowledgeSetDetail = () => {
}}
/>
<Modal
title={`文件预览:${previewFileName}`}
open={previewVisible}
onCancel={closePreview}
footer={[
<Button key="close" onClick={closePreview}>
</Button>,
]}
width={previewFileType === "text" ? PREVIEW_MODAL_WIDTH.text : PREVIEW_MODAL_WIDTH.media}
>
{previewFileType === "text" && (
<pre
style={{
maxHeight: `${PREVIEW_MAX_HEIGHT}px`,
overflow: "auto",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
fontSize: PREVIEW_TEXT_FONT_SIZE,
color: "#222",
backgroundColor: "#f5f5f5",
padding: `${PREVIEW_TEXT_PADDING}px`,
borderRadius: "4px",
}}
>
{previewContent}
</pre>
)}
{previewFileType === "image" && (
<div style={{ textAlign: "center" }}>
<img
src={previewMediaUrl}
alt={previewFileName}
style={{ maxWidth: "100%", maxHeight: `${PREVIEW_MAX_HEIGHT}px`, objectFit: "contain" }}
/>
</div>
)}
{previewFileType === "video" && (
<div style={{ textAlign: "center" }}>
<video
src={previewMediaUrl}
controls
style={{ maxWidth: "100%", maxHeight: `${PREVIEW_MAX_HEIGHT}px` }}
>
</video>
</div>
)}
{previewFileType === "audio" && (
<div style={{ textAlign: "center", padding: `${PREVIEW_AUDIO_PADDING}px 0` }}>
<audio src={previewMediaUrl} controls style={{ width: "100%" }}>
</audio>
</div>
)}
</Modal>
<Modal
open={readModalOpen}
title={`阅读:${readTitle}`}