feat(knowledge): 添加Office文档预览轮询机制

- 引入useRef钩子用于管理轮询定时器和当前处理项目
- 添加Spin组件用于预览加载状态显示
- 新增queryKnowledgeItemPreviewStatusUsingGet API调用接口
- 设置OFFICE_PREVIEW_POLL_INTERVAL和OFFICE_PREVIEW_POLL_MAX_TIMES常量
- 移除原有的Office预览元数据解析相关代码
- 添加officePreviewStatus、officePreviewError状态管理
- 实现pollOfficePreviewStatus函数进行预览状态轮询
- 添加clearOfficePreviewPolling清理轮询定时器功能
- 在handlePreviewItemFile中集成预览状态轮询逻辑
- 更新关闭预览时清理轮询和重置状态
- 移除表格中的Office预览标签显示
- 优化PDF预览界面,在无预览URL时显示加载或错误状态
This commit is contained in:
2026-02-01 22:02:57 +08:00
parent 4d2c9e546c
commit d535d0ac1b

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
App, App,
Breadcrumb, Breadcrumb,
@@ -8,6 +8,7 @@ import {
Empty, Empty,
Input, Input,
Modal, Modal,
Spin,
Tag, Tag,
Table, Table,
Tooltip, Tooltip,
@@ -26,6 +27,7 @@ import {
exportKnowledgeItemsUsingGet, exportKnowledgeItemsUsingGet,
queryKnowledgeDirectoriesUsingGet, queryKnowledgeDirectoriesUsingGet,
queryKnowledgeItemsUsingGet, queryKnowledgeItemsUsingGet,
queryKnowledgeItemPreviewStatusUsingGet,
queryKnowledgeSetByIdUsingGet, queryKnowledgeSetByIdUsingGet,
} from "../knowledge-management.api"; } from "../knowledge-management.api";
import { import {
@@ -64,42 +66,16 @@ const PREVIEW_TEXT_FONT_SIZE = 12;
const PREVIEW_TEXT_PADDING = 12; const PREVIEW_TEXT_PADDING = 12;
const PREVIEW_AUDIO_PADDING = 40; const PREVIEW_AUDIO_PADDING = 40;
const OFFICE_FILE_EXTENSIONS = [".doc", ".docx"]; const OFFICE_FILE_EXTENSIONS = [".doc", ".docx"];
const OFFICE_PREVIEW_POLL_INTERVAL = 2000;
const OFFICE_PREVIEW_POLL_MAX_TIMES = 60;
type OfficePreviewStatus = "UNSET" | "PENDING" | "PROCESSING" | "READY" | "FAILED"; type OfficePreviewStatus = "UNSET" | "PENDING" | "PROCESSING" | "READY" | "FAILED";
const OFFICE_PREVIEW_STATUS_META: Record<OfficePreviewStatus, { label: string; color: string }> = {
UNSET: { label: "未转换", color: "default" },
PENDING: { label: "待转换", color: "default" },
PROCESSING: { label: "转换中", color: "processing" },
READY: { label: "可预览", color: "success" },
FAILED: { label: "转换失败", color: "error" },
};
type OfficePreviewMetadata = {
previewStatus?: string;
previewError?: string;
previewUpdatedAt?: string;
previewPdfPath?: string;
};
const isOfficeFileName = (fileName?: string) => { const isOfficeFileName = (fileName?: string) => {
const lowerName = (fileName || "").toLowerCase(); const lowerName = (fileName || "").toLowerCase();
return OFFICE_FILE_EXTENSIONS.some((ext) => lowerName.endsWith(ext)); return OFFICE_FILE_EXTENSIONS.some((ext) => lowerName.endsWith(ext));
}; };
const parseOfficePreviewMetadata = (metadata?: string): OfficePreviewMetadata => {
if (!metadata) {
return {};
}
try {
const parsed = JSON.parse(metadata) as OfficePreviewMetadata;
return parsed || {};
} catch (error) {
console.warn("解析预览元数据失败", error);
return {};
}
};
const normalizeOfficePreviewStatus = (status?: string): OfficePreviewStatus => { const normalizeOfficePreviewStatus = (status?: string): OfficePreviewStatus => {
if (!status) { if (!status) {
return "UNSET"; return "UNSET";
@@ -155,6 +131,10 @@ const KnowledgeSetDetail = () => {
const [previewFileType, setPreviewFileType] = useState<PreviewFileType>("text"); const [previewFileType, setPreviewFileType] = useState<PreviewFileType>("text");
const [previewMediaUrl, setPreviewMediaUrl] = useState(""); const [previewMediaUrl, setPreviewMediaUrl] = useState("");
const [previewLoadingItemId, setPreviewLoadingItemId] = useState<string | null>(null); const [previewLoadingItemId, setPreviewLoadingItemId] = useState<string | null>(null);
const [officePreviewStatus, setOfficePreviewStatus] = useState<OfficePreviewStatus | null>(null);
const [officePreviewError, setOfficePreviewError] = useState("");
const officePreviewPollingRef = useRef<number | null>(null);
const officePreviewItemRef = useRef<string | null>(null);
const [filePrefix, setFilePrefix] = useState(""); const [filePrefix, setFilePrefix] = useState("");
const [fileKeyword, setFileKeyword] = useState(""); const [fileKeyword, setFileKeyword] = useState("");
@@ -333,21 +313,65 @@ const KnowledgeSetDetail = () => {
return "文件"; return "文件";
}; };
const resolveOfficePreviewDisplay = (record: KnowledgeItemView) => { const clearOfficePreviewPolling = useCallback(() => {
const fileName = resolvePreviewFileName(record); if (officePreviewPollingRef.current) {
if (!isOfficeFileName(fileName)) { window.clearTimeout(officePreviewPollingRef.current);
return null; officePreviewPollingRef.current = null;
} }
const previewMetadata = parseOfficePreviewMetadata(record.metadata); }, []);
const status = normalizeOfficePreviewStatus(previewMetadata.previewStatus);
const meta = OFFICE_PREVIEW_STATUS_META[status]; useEffect(() => {
return { return () => {
status, clearOfficePreviewPolling();
label: meta.label,
color: meta.color,
error: previewMetadata.previewError,
}; };
}; }, [clearOfficePreviewPolling]);
const pollOfficePreviewStatus = useCallback(
async (setId: string, itemId: string, attempt: number) => {
clearOfficePreviewPolling();
officePreviewPollingRef.current = window.setTimeout(async () => {
if (officePreviewItemRef.current !== itemId) {
return;
}
try {
const { data } = await queryKnowledgeItemPreviewStatusUsingGet(setId, itemId);
const status = normalizeOfficePreviewStatus(data?.status);
if (status === "READY") {
setPreviewMediaUrl(`/api/data-management/knowledge-sets/${setId}/items/${itemId}/preview`);
setOfficePreviewStatus("READY");
setOfficePreviewError("");
setPreviewLoadingItemId(null);
fetchItems();
return;
}
if (status === "FAILED") {
setOfficePreviewStatus("FAILED");
setOfficePreviewError(data?.previewError || "转换失败,请稍后重试");
setPreviewLoadingItemId(null);
fetchItems();
return;
}
if (attempt >= OFFICE_PREVIEW_POLL_MAX_TIMES - 1) {
setOfficePreviewStatus("FAILED");
setOfficePreviewError("转换超时,请稍后重试");
setPreviewLoadingItemId(null);
return;
}
pollOfficePreviewStatus(setId, itemId, attempt + 1);
} catch (error) {
console.error("轮询预览状态失败", error);
if (attempt >= OFFICE_PREVIEW_POLL_MAX_TIMES - 1) {
setOfficePreviewStatus("FAILED");
setOfficePreviewError("转换超时,请稍后重试");
setPreviewLoadingItemId(null);
return;
}
pollOfficePreviewStatus(setId, itemId, attempt + 1);
}
}, OFFICE_PREVIEW_POLL_INTERVAL);
},
[clearOfficePreviewPolling, fetchItems]
);
const handlePreviewItemFile = async (record: KnowledgeItemView) => { const handlePreviewItemFile = async (record: KnowledgeItemView) => {
if (!id) return; if (!id) return;
@@ -359,22 +383,48 @@ const KnowledgeSetDetail = () => {
if (isOfficeFileName(fileName)) { if (isOfficeFileName(fileName)) {
setPreviewFileType("pdf"); setPreviewFileType("pdf");
setPreviewVisible(true);
setPreviewLoadingItemId(record.id); setPreviewLoadingItemId(record.id);
setOfficePreviewStatus("PROCESSING");
setOfficePreviewError("");
officePreviewItemRef.current = record.id;
try { try {
const { data: statusData } = await queryKnowledgeItemPreviewStatusUsingGet(id, record.id);
const currentStatus = normalizeOfficePreviewStatus(statusData?.status);
if (currentStatus === "READY") {
setPreviewMediaUrl(previewUrl);
setOfficePreviewStatus("READY");
setPreviewLoadingItemId(null);
fetchItems();
return;
}
if (currentStatus === "FAILED") {
setOfficePreviewStatus("PROCESSING");
}
if (currentStatus === "PROCESSING" || currentStatus === "PENDING") {
pollOfficePreviewStatus(id, record.id, 0);
return;
}
const { data } = await convertKnowledgeItemPreviewUsingPost(id, record.id); const { data } = await convertKnowledgeItemPreviewUsingPost(id, record.id);
const status = normalizeOfficePreviewStatus(data?.status); const status = normalizeOfficePreviewStatus(data?.status);
if (status === "READY") { if (status === "READY") {
setPreviewMediaUrl(previewUrl); setPreviewMediaUrl(previewUrl);
setPreviewVisible(true); setOfficePreviewStatus("READY");
setPreviewLoadingItemId(null);
} else if (status === "FAILED") { } else if (status === "FAILED") {
message.error(data?.previewError || "转换失败,请稍后重试"); setOfficePreviewStatus("FAILED");
setOfficePreviewError(data?.previewError || "转换失败,请稍后重试");
setPreviewLoadingItemId(null);
} else { } else {
message.info("已开始转换,请稍后重试"); setOfficePreviewStatus("PROCESSING");
pollOfficePreviewStatus(id, record.id, 0);
} }
fetchItems(); fetchItems();
} catch (error) { } catch (error) {
console.error("触发预览转换失败", error); console.error("触发预览转换失败", error);
message.error("触发预览转换失败"); message.error("触发预览转换失败");
setOfficePreviewStatus("FAILED");
setOfficePreviewError("触发预览转换失败");
} finally { } finally {
setPreviewLoadingItemId(null); setPreviewLoadingItemId(null);
} }
@@ -412,11 +462,15 @@ const KnowledgeSetDetail = () => {
}; };
const closePreview = () => { const closePreview = () => {
clearOfficePreviewPolling();
officePreviewItemRef.current = null;
setPreviewVisible(false); setPreviewVisible(false);
setPreviewContent(""); setPreviewContent("");
setPreviewMediaUrl(""); setPreviewMediaUrl("");
setPreviewFileName(""); setPreviewFileName("");
setPreviewFileType("text"); setPreviewFileType("text");
setOfficePreviewStatus(null);
setOfficePreviewError("");
}; };
const handleReadItem = async (record: KnowledgeItemView) => { const handleReadItem = async (record: KnowledgeItemView) => {
@@ -758,20 +812,8 @@ const KnowledgeSetDetail = () => {
const isFileRecord = const isFileRecord =
record.contentType === KnowledgeContentType.FILE || record.contentType === KnowledgeContentType.FILE ||
record.sourceType === KnowledgeSourceType.FILE_UPLOAD; record.sourceType === KnowledgeSourceType.FILE_UPLOAD;
const officePreview = isFileRecord ? resolveOfficePreviewDisplay(record) : null;
return ( return (
<> <>
{officePreview && (
<Tooltip
title={
officePreview.status === "FAILED"
? officePreview.error || officePreview.label
: officePreview.label
}
>
<Tag color={officePreview.color}>{officePreview.label}</Tag>
</Tooltip>
)}
{isFileRecord && ( {isFileRecord && (
<Tooltip title="预览"> <Tooltip title="预览">
<Button <Button
@@ -1084,11 +1126,39 @@ const KnowledgeSetDetail = () => {
</div> </div>
)} )}
{previewFileType === "pdf" && ( {previewFileType === "pdf" && (
<iframe <>
src={previewMediaUrl} {previewMediaUrl ? (
title={previewFileName || "PDF 预览"} <iframe
style={{ width: "100%", height: `${PREVIEW_MAX_HEIGHT}px`, border: "none" }} src={previewMediaUrl}
/> title={previewFileName || "PDF 预览"}
style={{ width: "100%", height: `${PREVIEW_MAX_HEIGHT}px`, border: "none" }}
/>
) : (
<div
style={{
height: `${PREVIEW_MAX_HEIGHT}px`,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 12,
color: "#666",
}}
>
{officePreviewStatus === "FAILED" ? (
<>
<div></div>
<div>{officePreviewError || "请稍后重试"}</div>
</>
) : (
<>
<Spin />
<div>...</div>
</>
)}
</div>
)}
</>
)} )}
{previewFileType === "video" && ( {previewFileType === "video" && (
<div style={{ textAlign: "center" }}> <div style={{ textAlign: "center" }}>