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 {
App,
Breadcrumb,
@@ -8,6 +8,7 @@ import {
Empty,
Input,
Modal,
Spin,
Tag,
Table,
Tooltip,
@@ -26,6 +27,7 @@ import {
exportKnowledgeItemsUsingGet,
queryKnowledgeDirectoriesUsingGet,
queryKnowledgeItemsUsingGet,
queryKnowledgeItemPreviewStatusUsingGet,
queryKnowledgeSetByIdUsingGet,
} from "../knowledge-management.api";
import {
@@ -64,42 +66,16 @@ const PREVIEW_TEXT_FONT_SIZE = 12;
const PREVIEW_TEXT_PADDING = 12;
const PREVIEW_AUDIO_PADDING = 40;
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";
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 lowerName = (fileName || "").toLowerCase();
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 => {
if (!status) {
return "UNSET";
@@ -155,6 +131,10 @@ const KnowledgeSetDetail = () => {
const [previewFileType, setPreviewFileType] = useState<PreviewFileType>("text");
const [previewMediaUrl, setPreviewMediaUrl] = useState("");
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 [fileKeyword, setFileKeyword] = useState("");
@@ -333,21 +313,65 @@ const KnowledgeSetDetail = () => {
return "文件";
};
const resolveOfficePreviewDisplay = (record: KnowledgeItemView) => {
const fileName = resolvePreviewFileName(record);
if (!isOfficeFileName(fileName)) {
return null;
const clearOfficePreviewPolling = useCallback(() => {
if (officePreviewPollingRef.current) {
window.clearTimeout(officePreviewPollingRef.current);
officePreviewPollingRef.current = null;
}
const previewMetadata = parseOfficePreviewMetadata(record.metadata);
const status = normalizeOfficePreviewStatus(previewMetadata.previewStatus);
const meta = OFFICE_PREVIEW_STATUS_META[status];
return {
status,
label: meta.label,
color: meta.color,
error: previewMetadata.previewError,
};
}, []);
useEffect(() => {
return () => {
clearOfficePreviewPolling();
};
}, [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) => {
if (!id) return;
@@ -359,22 +383,48 @@ const KnowledgeSetDetail = () => {
if (isOfficeFileName(fileName)) {
setPreviewFileType("pdf");
setPreviewVisible(true);
setPreviewLoadingItemId(record.id);
setOfficePreviewStatus("PROCESSING");
setOfficePreviewError("");
officePreviewItemRef.current = record.id;
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 status = normalizeOfficePreviewStatus(data?.status);
if (status === "READY") {
setPreviewMediaUrl(previewUrl);
setPreviewVisible(true);
setOfficePreviewStatus("READY");
setPreviewLoadingItemId(null);
} else if (status === "FAILED") {
message.error(data?.previewError || "转换失败,请稍后重试");
setOfficePreviewStatus("FAILED");
setOfficePreviewError(data?.previewError || "转换失败,请稍后重试");
setPreviewLoadingItemId(null);
} else {
message.info("已开始转换,请稍后重试");
setOfficePreviewStatus("PROCESSING");
pollOfficePreviewStatus(id, record.id, 0);
}
fetchItems();
} catch (error) {
console.error("触发预览转换失败", error);
message.error("触发预览转换失败");
setOfficePreviewStatus("FAILED");
setOfficePreviewError("触发预览转换失败");
} finally {
setPreviewLoadingItemId(null);
}
@@ -412,11 +462,15 @@ const KnowledgeSetDetail = () => {
};
const closePreview = () => {
clearOfficePreviewPolling();
officePreviewItemRef.current = null;
setPreviewVisible(false);
setPreviewContent("");
setPreviewMediaUrl("");
setPreviewFileName("");
setPreviewFileType("text");
setOfficePreviewStatus(null);
setOfficePreviewError("");
};
const handleReadItem = async (record: KnowledgeItemView) => {
@@ -758,20 +812,8 @@ const KnowledgeSetDetail = () => {
const isFileRecord =
record.contentType === KnowledgeContentType.FILE ||
record.sourceType === KnowledgeSourceType.FILE_UPLOAD;
const officePreview = isFileRecord ? resolveOfficePreviewDisplay(record) : null;
return (
<>
{officePreview && (
<Tooltip
title={
officePreview.status === "FAILED"
? officePreview.error || officePreview.label
: officePreview.label
}
>
<Tag color={officePreview.color}>{officePreview.label}</Tag>
</Tooltip>
)}
{isFileRecord && (
<Tooltip title="预览">
<Button
@@ -1084,11 +1126,39 @@ const KnowledgeSetDetail = () => {
</div>
)}
{previewFileType === "pdf" && (
<>
{previewMediaUrl ? (
<iframe
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" && (
<div style={{ textAlign: "center" }}>