feat(file-preview): 增加PDF文件预览功能并优化预览逻辑

- 引入统一的文件预览工具函数和类型定义
- 添加PDF文件类型的识别和预览支持
- 使用iframe实现PDF文件在线预览
- 重构文件预览逻辑,统一处理不同文件类型的预览
- 优化文本内容预览的长度截取机制
- 更新预览按钮加载状态显示
- 统一预览窗口的最大高度配置
- 修改API调用路径为专门的预览接口
This commit is contained in:
2026-01-30 17:32:36 +08:00
parent bd37858ccc
commit 6dfed934a5
5 changed files with 82 additions and 57 deletions

View File

@@ -6,6 +6,12 @@ import TextArea from "antd/es/input/TextArea";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Eye } from "lucide-react"; import { Eye } from "lucide-react";
import {
PREVIEW_TEXT_MAX_LENGTH,
resolvePreviewFileType,
truncatePreviewText,
type PreviewFileType,
} from "@/utils/filePreview";
import { import {
createAnnotationTaskUsingPost, createAnnotationTaskUsingPost,
getAnnotationTaskByIdUsingGet, getAnnotationTaskByIdUsingGet,
@@ -53,6 +59,7 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value); !!value && typeof value === "object" && !Array.isArray(value);
const DEFAULT_SEGMENTATION_ENABLED = true; const DEFAULT_SEGMENTATION_ENABLED = true;
const FILE_PREVIEW_MAX_HEIGHT = 500;
const SEGMENTATION_OPTIONS = [ const SEGMENTATION_OPTIONS = [
{ label: "需要切片段", value: true }, { label: "需要切片段", value: true },
{ label: "不需要切片段", value: false }, { label: "不需要切片段", value: false },
@@ -116,7 +123,7 @@ export default function CreateAnnotationTask({
const [fileContent, setFileContent] = useState(""); const [fileContent, setFileContent] = useState("");
const [fileContentLoading, setFileContentLoading] = useState(false); const [fileContentLoading, setFileContentLoading] = useState(false);
const [previewFileName, setPreviewFileName] = useState(""); const [previewFileName, setPreviewFileName] = useState("");
const [previewFileType, setPreviewFileType] = useState<"text" | "image" | "video" | "audio">("text"); const [previewFileType, setPreviewFileType] = useState<PreviewFileType>("text");
const [previewMediaUrl, setPreviewMediaUrl] = useState(""); const [previewMediaUrl, setPreviewMediaUrl] = useState("");
// 任务详情加载状态(编辑模式) // 任务详情加载状态(编辑模式)
@@ -297,57 +304,32 @@ export default function CreateAnnotationTask({
// 预览文件内容 // 预览文件内容
const handlePreviewFileContent = async (file: DatasetPreviewFile) => { const handlePreviewFileContent = async (file: DatasetPreviewFile) => {
const fileName = file.fileName?.toLowerCase() || ''; const fileType = resolvePreviewFileType(file.fileName);
if (!fileType) {
// 文件类型扩展名映射
const textExtensions = ['.json', '.jsonl', '.txt', '.csv', '.tsv', '.xml', '.md', '.yaml', '.yml'];
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'];
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi'];
const audioExtensions = ['.mp3', '.wav', '.ogg', '.aac', '.flac', '.m4a'];
const isTextFile = textExtensions.some(ext => fileName.endsWith(ext));
const isImageFile = imageExtensions.some(ext => fileName.endsWith(ext));
const isVideoFile = videoExtensions.some(ext => fileName.endsWith(ext));
const isAudioFile = audioExtensions.some(ext => fileName.endsWith(ext));
if (!isTextFile && !isImageFile && !isVideoFile && !isAudioFile) {
message.warning("不支持预览该文件类型"); message.warning("不支持预览该文件类型");
return; return;
} }
setFileContentLoading(true); setFileContentLoading(true);
setPreviewFileName(file.fileName); setPreviewFileName(file.fileName);
setPreviewFileType(fileType);
setFileContent("");
setPreviewMediaUrl("");
const fileUrl = `/api/data-management/datasets/${selectedDatasetId}/files/${file.id}/download`; const previewUrl = `/api/data-management/datasets/${selectedDatasetId}/files/${file.id}/preview`;
try { try {
if (isTextFile) { if (fileType === "text") {
// 文本文件:获取内容 // 文本文件:获取内容
const response = await fetch(fileUrl); const response = await fetch(previewUrl);
if (!response.ok) { if (!response.ok) {
throw new Error('下载失败'); throw new Error('下载失败');
} }
const text = await response.text(); const text = await response.text();
// 限制预览内容长度 setFileContent(truncatePreviewText(text, PREVIEW_TEXT_MAX_LENGTH));
const maxLength = 50000;
if (text.length > maxLength) {
setFileContent(text.substring(0, maxLength) + '\n\n... (内容过长,仅显示前 50000 字符)');
} else { } else {
setFileContent(text); // 媒体/PDF 文件:直接使用预览地址
} setPreviewMediaUrl(previewUrl);
setPreviewFileType("text");
} else if (isImageFile) {
// 图片文件:直接使用 URL
setPreviewMediaUrl(fileUrl);
setPreviewFileType("image");
} else if (isVideoFile) {
// 视频文件:使用 URL
setPreviewMediaUrl(fileUrl);
setPreviewFileType("video");
} else if (isAudioFile) {
// 音频文件:使用 URL
setPreviewMediaUrl(fileUrl);
setPreviewFileType("audio");
} }
setFileContentVisible(true); setFileContentVisible(true);
} catch (error) { } catch (error) {
@@ -878,7 +860,7 @@ export default function CreateAnnotationTask({
</Button> </Button>
]} ]}
> >
<div className="mb-2 text-xs text-gray-500"></div> <div className="mb-2 text-xs text-gray-500">PDF</div>
<Table <Table
dataSource={datasetPreviewData} dataSource={datasetPreviewData}
columns={[ columns={[
@@ -942,7 +924,7 @@ export default function CreateAnnotationTask({
{previewFileType === "text" && ( {previewFileType === "text" && (
<pre <pre
style={{ style={{
maxHeight: '500px', maxHeight: `${FILE_PREVIEW_MAX_HEIGHT}px`,
overflow: 'auto', overflow: 'auto',
backgroundColor: '#f5f5f5', backgroundColor: '#f5f5f5',
padding: '12px', padding: '12px',
@@ -960,16 +942,23 @@ export default function CreateAnnotationTask({
<img <img
src={previewMediaUrl} src={previewMediaUrl}
alt={previewFileName} alt={previewFileName}
style={{ maxWidth: '100%', maxHeight: '500px', objectFit: 'contain' }} style={{ maxWidth: '100%', maxHeight: `${FILE_PREVIEW_MAX_HEIGHT}px`, objectFit: 'contain' }}
/> />
</div> </div>
)} )}
{previewFileType === "pdf" && (
<iframe
src={previewMediaUrl}
title={previewFileName || "PDF 预览"}
style={{ width: '100%', height: `${FILE_PREVIEW_MAX_HEIGHT}px`, border: 'none' }}
/>
)}
{previewFileType === "video" && ( {previewFileType === "video" && (
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<video <video
src={previewMediaUrl} src={previewMediaUrl}
controls controls
style={{ maxWidth: '100%', maxHeight: '500px' }} style={{ maxWidth: '100%', maxHeight: `${FILE_PREVIEW_MAX_HEIGHT}px` }}
> >
</video> </video>

View File

@@ -332,6 +332,14 @@ export default function Overview({
return ( return (
<div className="flex"> <div className="flex">
<Button
size="small"
type="link"
loading={previewLoading && previewFileName === record.fileName}
onClick={() => handlePreviewFile(record)}
>
</Button>
<Button <Button
size="small" size="small"
type="link" type="link"
@@ -549,6 +557,13 @@ export default function Overview({
/> />
</div> </div>
)} )}
{previewFileType === "pdf" && (
<iframe
src={previewMediaUrl}
title={previewFileName || "PDF 预览"}
style={{ width: "100%", height: `${PREVIEW_MAX_HEIGHT}px`, border: "none" }}
/>
)}
{previewFileType === "video" && ( {previewFileType === "video" && (
<div style={{ textAlign: "center" }}> <div style={{ textAlign: "center" }}>
<video <video

View File

@@ -4,7 +4,12 @@ import type {
} from "@/pages/DataManagement/dataset.model"; } from "@/pages/DataManagement/dataset.model";
import { App } from "antd"; import { App } from "antd";
import { useState } from "react"; import { useState } from "react";
import { PREVIEW_TEXT_MAX_LENGTH, resolvePreviewFileType, truncatePreviewText } from "@/utils/filePreview"; import {
PREVIEW_TEXT_MAX_LENGTH,
resolvePreviewFileType,
truncatePreviewText,
type PreviewFileType,
} from "@/utils/filePreview";
import { import {
deleteDatasetFileUsingDelete, deleteDatasetFileUsingDelete,
downloadFileByIdUsingGet, downloadFileByIdUsingGet,
@@ -35,7 +40,7 @@ export function useFilesOperation(dataset: Dataset) {
const [previewVisible, setPreviewVisible] = useState(false); const [previewVisible, setPreviewVisible] = useState(false);
const [previewContent, setPreviewContent] = useState(""); const [previewContent, setPreviewContent] = useState("");
const [previewFileName, setPreviewFileName] = useState(""); const [previewFileName, setPreviewFileName] = useState("");
const [previewFileType, setPreviewFileType] = useState<"text" | "image" | "video" | "audio">("text"); const [previewFileType, setPreviewFileType] = useState<PreviewFileType>("text");
const [previewMediaUrl, setPreviewMediaUrl] = useState(""); const [previewMediaUrl, setPreviewMediaUrl] = useState("");
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
@@ -111,7 +116,7 @@ export function useFilesOperation(dataset: Dataset) {
return; return;
} }
const fileUrl = `/api/data-management/datasets/${datasetId}/files/${file.id}/download`; const previewUrl = `/api/data-management/datasets/${datasetId}/files/${file.id}/preview`;
setPreviewFileName(file.fileName); setPreviewFileName(file.fileName);
setPreviewFileType(fileType); setPreviewFileType(fileType);
setPreviewContent(""); setPreviewContent("");
@@ -120,7 +125,7 @@ export function useFilesOperation(dataset: Dataset) {
if (fileType === "text") { if (fileType === "text") {
setPreviewLoading(true); setPreviewLoading(true);
try { try {
const response = await fetch(fileUrl); const response = await fetch(previewUrl);
if (!response.ok) { if (!response.ok) {
throw new Error("下载失败"); throw new Error("下载失败");
} }
@@ -136,7 +141,7 @@ export function useFilesOperation(dataset: Dataset) {
return; return;
} }
setPreviewMediaUrl(fileUrl); setPreviewMediaUrl(previewUrl);
setPreviewVisible(true); setPreviewVisible(true);
}; };

View File

@@ -41,7 +41,12 @@ import CreateKnowledgeSet from "../components/CreateKnowledgeSet";
import KnowledgeItemEditor from "../components/KnowledgeItemEditor"; import KnowledgeItemEditor from "../components/KnowledgeItemEditor";
import ImportKnowledgeItemsDialog from "../components/ImportKnowledgeItemsDialog"; import ImportKnowledgeItemsDialog from "../components/ImportKnowledgeItemsDialog";
import { formatDate } from "@/utils/unit"; import { formatDate } from "@/utils/unit";
import { PREVIEW_TEXT_MAX_LENGTH, resolvePreviewFileType, truncatePreviewText } from "@/utils/filePreview"; import {
PREVIEW_TEXT_MAX_LENGTH,
resolvePreviewFileType,
truncatePreviewText,
type PreviewFileType,
} from "@/utils/filePreview";
const PREVIEW_MAX_HEIGHT = 500; const PREVIEW_MAX_HEIGHT = 500;
const PREVIEW_MODAL_WIDTH = { const PREVIEW_MODAL_WIDTH = {
@@ -67,7 +72,7 @@ const KnowledgeSetDetail = () => {
const [previewVisible, setPreviewVisible] = useState(false); const [previewVisible, setPreviewVisible] = useState(false);
const [previewContent, setPreviewContent] = useState(""); const [previewContent, setPreviewContent] = useState("");
const [previewFileName, setPreviewFileName] = useState(""); const [previewFileName, setPreviewFileName] = useState("");
const [previewFileType, setPreviewFileType] = useState<"text" | "image" | "video" | "audio">("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);
@@ -560,6 +565,13 @@ const KnowledgeSetDetail = () => {
/> />
</div> </div>
)} )}
{previewFileType === "pdf" && (
<iframe
src={previewMediaUrl}
title={previewFileName || "PDF 预览"}
style={{ width: "100%", height: `${PREVIEW_MAX_HEIGHT}px`, border: "none" }}
/>
)}
{previewFileType === "video" && ( {previewFileType === "video" && (
<div style={{ textAlign: "center" }}> <div style={{ textAlign: "center" }}>
<video <video

View File

@@ -22,8 +22,9 @@ const IMAGE_FILE_EXTENSIONS = [
]; ];
const VIDEO_FILE_EXTENSIONS = [".mp4", ".webm", ".ogg", ".mov", ".avi"]; const VIDEO_FILE_EXTENSIONS = [".mp4", ".webm", ".ogg", ".mov", ".avi"];
const AUDIO_FILE_EXTENSIONS = [".mp3", ".wav", ".ogg", ".aac", ".flac", ".m4a"]; const AUDIO_FILE_EXTENSIONS = [".mp3", ".wav", ".ogg", ".aac", ".flac", ".m4a"];
const PDF_FILE_EXTENSIONS = [".pdf"];
export type PreviewFileType = "text" | "image" | "video" | "audio"; export type PreviewFileType = "text" | "image" | "video" | "audio" | "pdf";
export const resolvePreviewFileType = (fileName?: string): PreviewFileType | null => { export const resolvePreviewFileType = (fileName?: string): PreviewFileType | null => {
const lowerName = (fileName || "").toLowerCase(); const lowerName = (fileName || "").toLowerCase();
@@ -39,6 +40,9 @@ export const resolvePreviewFileType = (fileName?: string): PreviewFileType | nul
if (AUDIO_FILE_EXTENSIONS.some((ext) => lowerName.endsWith(ext))) { if (AUDIO_FILE_EXTENSIONS.some((ext) => lowerName.endsWith(ext))) {
return "audio"; return "audio";
} }
if (PDF_FILE_EXTENSIONS.some((ext) => lowerName.endsWith(ext))) {
return "pdf";
}
return null; return null;
}; };