You've already forked DataMate
feat(data-management): 添加Office文档预览功能
- 集成LibreOffice转换器实现DOC/DOCX转PDF功能 - 新增DatasetFilePreviewService处理预览文件管理 - 新增DatasetFilePreviewAsyncService异步转换任务 - 在文件删除时同步清理预览文件 - 前端实现Office文档预览状态轮询机制 - 添加预览API接口支持状态查询和转换触发 - 优化文件预览界面显示转换进度和错误信息
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Descriptions,
|
||||
DescriptionsProps,
|
||||
Modal,
|
||||
Table,
|
||||
Input,
|
||||
} from "antd";
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Descriptions,
|
||||
DescriptionsProps,
|
||||
Modal,
|
||||
Spin,
|
||||
Table,
|
||||
Input,
|
||||
} from "antd";
|
||||
import { formatBytes, formatDateTime } from "@/utils/unit";
|
||||
import { Download, Trash2, Folder, File } from "lucide-react";
|
||||
import { datasetTypeMap } from "../../dataset.const";
|
||||
@@ -49,10 +50,12 @@ export default function Overview({
|
||||
previewVisible,
|
||||
previewFileName,
|
||||
previewContent,
|
||||
previewFileType,
|
||||
previewMediaUrl,
|
||||
previewLoading,
|
||||
closePreview,
|
||||
previewFileType,
|
||||
previewMediaUrl,
|
||||
previewLoading,
|
||||
officePreviewStatus,
|
||||
officePreviewError,
|
||||
closePreview,
|
||||
handleDeleteFile,
|
||||
handleDownloadFile,
|
||||
handleBatchDeleteFiles,
|
||||
@@ -446,13 +449,41 @@ export default function Overview({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{previewFileType === "pdf" && (
|
||||
<iframe
|
||||
src={previewMediaUrl}
|
||||
title={previewFileName || "PDF 预览"}
|
||||
style={{ width: "100%", height: `${PREVIEW_MAX_HEIGHT}px`, border: "none" }}
|
||||
/>
|
||||
)}
|
||||
{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" }}>
|
||||
<video
|
||||
|
||||
@@ -4,25 +4,49 @@ import type {
|
||||
} from "@/pages/DataManagement/dataset.model";
|
||||
import { DatasetType } from "@/pages/DataManagement/dataset.model";
|
||||
import { App } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
PREVIEW_TEXT_MAX_LENGTH,
|
||||
resolvePreviewFileType,
|
||||
truncatePreviewText,
|
||||
type PreviewFileType,
|
||||
} from "@/utils/filePreview";
|
||||
import {
|
||||
deleteDatasetFileUsingDelete,
|
||||
downloadFileByIdUsingGet,
|
||||
exportDatasetUsingPost,
|
||||
queryDatasetFilesUsingGet,
|
||||
createDatasetDirectoryUsingPost,
|
||||
downloadDirectoryUsingGet,
|
||||
deleteDirectoryUsingDelete,
|
||||
} from "../dataset.api";
|
||||
import {
|
||||
deleteDatasetFileUsingDelete,
|
||||
downloadFileByIdUsingGet,
|
||||
exportDatasetUsingPost,
|
||||
queryDatasetFilesUsingGet,
|
||||
createDatasetDirectoryUsingPost,
|
||||
downloadDirectoryUsingGet,
|
||||
deleteDirectoryUsingDelete,
|
||||
queryDatasetFilePreviewStatusUsingGet,
|
||||
convertDatasetFilePreviewUsingPost,
|
||||
} from "../dataset.api";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
|
||||
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 isOfficeFileName = (fileName?: string) => {
|
||||
const lowerName = (fileName || "").toLowerCase();
|
||||
return OFFICE_FILE_EXTENSIONS.some((ext) => lowerName.endsWith(ext));
|
||||
};
|
||||
|
||||
const normalizeOfficePreviewStatus = (status?: string): OfficePreviewStatus => {
|
||||
if (!status) {
|
||||
return "UNSET";
|
||||
}
|
||||
const upper = status.toUpperCase();
|
||||
if (upper === "PENDING" || upper === "PROCESSING" || upper === "READY" || upper === "FAILED") {
|
||||
return upper as OfficePreviewStatus;
|
||||
}
|
||||
return "UNSET";
|
||||
};
|
||||
|
||||
|
||||
export function useFilesOperation(dataset: Dataset) {
|
||||
const { message } = App.useApp();
|
||||
const { id } = useParams(); // 获取动态路由参数
|
||||
@@ -44,6 +68,23 @@ export function useFilesOperation(dataset: Dataset) {
|
||||
const [previewFileType, setPreviewFileType] = useState<PreviewFileType>("text");
|
||||
const [previewMediaUrl, setPreviewMediaUrl] = useState("");
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [officePreviewStatus, setOfficePreviewStatus] = useState<OfficePreviewStatus | null>(null);
|
||||
const [officePreviewError, setOfficePreviewError] = useState("");
|
||||
const officePreviewPollingRef = useRef<number | null>(null);
|
||||
const officePreviewFileRef = useRef<string | null>(null);
|
||||
|
||||
const clearOfficePreviewPolling = useCallback(() => {
|
||||
if (officePreviewPollingRef.current) {
|
||||
window.clearTimeout(officePreviewPollingRef.current);
|
||||
officePreviewPollingRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearOfficePreviewPolling();
|
||||
};
|
||||
}, [clearOfficePreviewPolling]);
|
||||
|
||||
const fetchFiles = async (
|
||||
prefix?: string,
|
||||
@@ -113,17 +154,61 @@ export function useFilesOperation(dataset: Dataset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewUrl = `/api/data-management/datasets/${datasetId}/files/${file.id}/preview`;
|
||||
setPreviewFileName(file.fileName);
|
||||
setPreviewContent("");
|
||||
setPreviewMediaUrl("");
|
||||
|
||||
if (isOfficeFileName(file?.fileName)) {
|
||||
setPreviewFileType("pdf");
|
||||
setPreviewVisible(true);
|
||||
setPreviewLoading(true);
|
||||
setOfficePreviewStatus("PROCESSING");
|
||||
setOfficePreviewError("");
|
||||
officePreviewFileRef.current = file.id;
|
||||
try {
|
||||
const { data: statusData } = await queryDatasetFilePreviewStatusUsingGet(datasetId, file.id);
|
||||
const currentStatus = normalizeOfficePreviewStatus(statusData?.status);
|
||||
if (currentStatus === "READY") {
|
||||
setPreviewMediaUrl(previewUrl);
|
||||
setOfficePreviewStatus("READY");
|
||||
setPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
if (currentStatus === "PROCESSING" || currentStatus === "PENDING") {
|
||||
pollOfficePreviewStatus(datasetId, file.id, 0);
|
||||
return;
|
||||
}
|
||||
const { data } = await convertDatasetFilePreviewUsingPost(datasetId, file.id);
|
||||
const status = normalizeOfficePreviewStatus(data?.status);
|
||||
if (status === "READY") {
|
||||
setPreviewMediaUrl(previewUrl);
|
||||
setOfficePreviewStatus("READY");
|
||||
} else if (status === "FAILED") {
|
||||
setOfficePreviewStatus("FAILED");
|
||||
setOfficePreviewError(data?.previewError || "转换失败,请稍后重试");
|
||||
} else {
|
||||
setOfficePreviewStatus("PROCESSING");
|
||||
pollOfficePreviewStatus(datasetId, file.id, 0);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("触发预览转换失败", error);
|
||||
message.error({ content: "触发预览转换失败" });
|
||||
setOfficePreviewStatus("FAILED");
|
||||
setOfficePreviewError("触发预览转换失败");
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const fileType = resolvePreviewFileType(file?.fileName);
|
||||
if (!fileType) {
|
||||
message.warning({ content: "不支持预览该文件类型" });
|
||||
return;
|
||||
}
|
||||
|
||||
const previewUrl = `/api/data-management/datasets/${datasetId}/files/${file.id}/preview`;
|
||||
setPreviewFileName(file.fileName);
|
||||
setPreviewFileType(fileType);
|
||||
setPreviewContent("");
|
||||
setPreviewMediaUrl("");
|
||||
|
||||
if (fileType === "text") {
|
||||
setPreviewLoading(true);
|
||||
@@ -149,13 +234,62 @@ export function useFilesOperation(dataset: Dataset) {
|
||||
};
|
||||
|
||||
const closePreview = () => {
|
||||
clearOfficePreviewPolling();
|
||||
officePreviewFileRef.current = null;
|
||||
setPreviewVisible(false);
|
||||
setPreviewContent("");
|
||||
setPreviewMediaUrl("");
|
||||
setPreviewFileName("");
|
||||
setPreviewFileType("text");
|
||||
setOfficePreviewStatus(null);
|
||||
setOfficePreviewError("");
|
||||
};
|
||||
|
||||
const pollOfficePreviewStatus = useCallback(
|
||||
async (datasetId: string, fileId: string, attempt: number) => {
|
||||
clearOfficePreviewPolling();
|
||||
officePreviewPollingRef.current = window.setTimeout(async () => {
|
||||
if (officePreviewFileRef.current !== fileId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await queryDatasetFilePreviewStatusUsingGet(datasetId, fileId);
|
||||
const status = normalizeOfficePreviewStatus(data?.status);
|
||||
if (status === "READY") {
|
||||
setPreviewMediaUrl(`/api/data-management/datasets/${datasetId}/files/${fileId}/preview`);
|
||||
setOfficePreviewStatus("READY");
|
||||
setOfficePreviewError("");
|
||||
setPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
if (status === "FAILED") {
|
||||
setOfficePreviewStatus("FAILED");
|
||||
setOfficePreviewError(data?.previewError || "转换失败,请稍后重试");
|
||||
setPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
if (attempt >= OFFICE_PREVIEW_POLL_MAX_TIMES - 1) {
|
||||
setOfficePreviewStatus("FAILED");
|
||||
setOfficePreviewError("转换超时,请稍后重试");
|
||||
setPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
pollOfficePreviewStatus(datasetId, fileId, attempt + 1);
|
||||
} catch (error) {
|
||||
console.error("轮询预览状态失败", error);
|
||||
if (attempt >= OFFICE_PREVIEW_POLL_MAX_TIMES - 1) {
|
||||
setOfficePreviewStatus("FAILED");
|
||||
setOfficePreviewError("转换超时,请稍后重试");
|
||||
setPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
pollOfficePreviewStatus(datasetId, fileId, attempt + 1);
|
||||
}
|
||||
}, OFFICE_PREVIEW_POLL_INTERVAL);
|
||||
},
|
||||
[clearOfficePreviewPolling]
|
||||
);
|
||||
|
||||
const handleDeleteFile = async (file: DatasetFile) => {
|
||||
try {
|
||||
await deleteDatasetFileUsingDelete(dataset.id, file.id);
|
||||
@@ -198,6 +332,8 @@ export function useFilesOperation(dataset: Dataset) {
|
||||
previewFileType,
|
||||
previewMediaUrl,
|
||||
previewLoading,
|
||||
officePreviewStatus,
|
||||
officePreviewError,
|
||||
closePreview,
|
||||
fetchFiles,
|
||||
setFileList,
|
||||
|
||||
@@ -107,17 +107,33 @@ export function deleteDirectoryUsingDelete(
|
||||
return del(`/api/data-management/datasets/${id}/files/directories?prefix=${encodeURIComponent(directoryPath)}`);
|
||||
}
|
||||
|
||||
export function downloadFileByIdUsingGet(
|
||||
id: string | number,
|
||||
fileId: string | number,
|
||||
fileName: string
|
||||
) {
|
||||
return download(
|
||||
`/api/data-management/datasets/${id}/files/${fileId}/download`,
|
||||
null,
|
||||
fileName
|
||||
);
|
||||
}
|
||||
export function downloadFileByIdUsingGet(
|
||||
id: string | number,
|
||||
fileId: string | number,
|
||||
fileName: string
|
||||
) {
|
||||
return download(
|
||||
`/api/data-management/datasets/${id}/files/${fileId}/download`,
|
||||
null,
|
||||
fileName
|
||||
);
|
||||
}
|
||||
|
||||
// 数据集文件预览状态
|
||||
export function queryDatasetFilePreviewStatusUsingGet(
|
||||
datasetId: string | number,
|
||||
fileId: string | number
|
||||
) {
|
||||
return get(`/api/data-management/datasets/${datasetId}/files/${fileId}/preview/status`);
|
||||
}
|
||||
|
||||
// 触发数据集文件预览转换
|
||||
export function convertDatasetFilePreviewUsingPost(
|
||||
datasetId: string | number,
|
||||
fileId: string | number
|
||||
) {
|
||||
return post(`/api/data-management/datasets/${datasetId}/files/${fileId}/preview/convert`, {});
|
||||
}
|
||||
|
||||
// 删除数据集文件
|
||||
export function deleteDatasetFileUsingDelete(
|
||||
|
||||
Reference in New Issue
Block a user