feat(knowledge-base): 添加文档预览功能
Some checks failed
Lint Code / Lint Code (push) Failing after 34s

- 将KbDocumentVO的fileSize类型由数字改为字符串,并新增fileUrl字段
- 引入vue-pdf-embed组件实现PDF预览
- 新增预览相关响应式状态变量及控制方法
- 支持PDF分页显示、全部页显示、旋转和打印功能
- 支持文本和Markdown文件通过iframe预览
- 对不支持直接预览的文件类型显示提示并提供打开下载链接
- 在操作栏新增“预览”按钮,符合文档状态才显示
- 添加预览对话框及配套样式,提升用户体验
This commit is contained in:
2026-03-30 19:17:29 +08:00
parent 50a301db0e
commit 919577365d
2 changed files with 258 additions and 2 deletions

View File

@@ -54,8 +54,9 @@ export interface KbDocumentVO {
title: string;
docType: string; // report/document/text/data/other
fileType: string; // pdf/doc/txt/md等
fileSize: number; // 字节
fileSize: string; // 字节
filePath: string;
fileUrl?: string; // 文件访问URL
sourceType: string; // upload/project/risk等
chunkCount: number; // 分块数量
status: "pending" | "processing" | "active" | "error";

View File

@@ -2,6 +2,7 @@
import { ref, onMounted, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import VuePdfEmbed from "vue-pdf-embed";
import {
getDocuments,
uploadDocument,
@@ -25,6 +26,7 @@ import DocIcon from "~icons/ri/file-word-line";
import TxtIcon from "~icons/ri/file-text-line";
import ViewIcon from "~icons/ri/eye-line";
import ChunkIcon from "~icons/ri/file-list-3-line";
import PreviewIcon from "~icons/ri/file-search-line";
defineOptions({
name: "KnowledgeBase"
@@ -51,6 +53,17 @@ const currentChunkDoc = ref<KbDocumentVO | null>(null);
const selectedChunk = ref<DocumentChunkVO | null>(null);
const chunkDetailVisible = ref(false);
// 预览相关
const previewDialogVisible = ref(false);
const previewDoc = ref<KbDocumentVO | null>(null);
const previewLoading = ref(false);
const previewPageCount = ref(1);
const previewCurrentPage = ref(1);
const previewRotation = ref(0);
const previewPdfRef = ref<any>(null);
const previewAllPages = ref(false);
const previewRotations = [0, 90, 180, 270];
// 加载项目列表
async function loadProjects() {
projectLoading.value = true;
@@ -234,6 +247,62 @@ function handleViewChunkDetail(chunk: DocumentChunkVO) {
chunkDetailVisible.value = true;
}
// 预览文档
function handlePreview(doc: KbDocumentVO) {
previewDoc.value = doc;
previewDialogVisible.value = true;
previewLoading.value = true;
previewCurrentPage.value = 1;
previewRotation.value = 0;
previewAllPages.value = false;
}
// PDF渲染完成
function handlePdfRender() {
previewLoading.value = false;
if (previewPdfRef.value?.doc) {
previewPageCount.value = previewPdfRef.value.doc.numPages;
}
}
// 切换显示所有页面
function handlePreviewAllPagesChange() {
previewCurrentPage.value = previewAllPages.value ? null : 1;
}
// 旋转PDF
function handleRotatePdf() {
previewRotation.value =
previewRotation.value === 3 ? 0 : previewRotation.value + 1;
}
// 打印PDF
function handlePrintPdf() {
previewPdfRef.value?.print();
}
// 打开文件链接
function handleOpenFile(url: string) {
window.open(url, "_blank");
}
// 判断是否可预览
function canPreview(doc: KbDocumentVO): boolean {
return !!doc.fileUrl && doc.status === "active";
}
// 获取文件类型显示名
function getFileTypeName(fileType: string): string {
const typeMap: Record<string, string> = {
pdf: "PDF",
doc: "Word",
docx: "Word",
txt: "文本",
md: "Markdown"
};
return typeMap[fileType.toLowerCase()] || fileType.toUpperCase();
}
// 获取状态标签类型
function getStatusType(
status: DocumentStatus
@@ -402,8 +471,17 @@ onMounted(() => {
</template>
</el-table-column>
<el-table-column label="操作" width="240" align="center" fixed="right">
<el-table-column label="操作" width="300" align="center" fixed="right">
<template #default="{ row }">
<el-button
v-if="canPreview(row)"
link
type="primary"
@click="handlePreview(row)"
>
<component :is="useRenderIcon(PreviewIcon)" class="mr-1" />
预览
</el-button>
<el-button
v-if="row.status === 'active'"
link
@@ -579,6 +657,109 @@ onMounted(() => {
</div>
</template>
</el-dialog>
<!-- 文档预览对话框 -->
<el-dialog
v-model="previewDialogVisible"
:title="`预览 - ${previewDoc?.title || ''}`"
width="900px"
:close-on-click-modal="false"
destroy-on-close
>
<template v-if="previewDoc">
<!-- PDF预览 -->
<template v-if="previewDoc.fileType?.toLowerCase() === 'pdf'">
<div
v-loading="previewLoading"
class="pdf-preview-container"
element-loading-text="加载中..."
>
<div class="pdf-toolbar">
<div v-if="previewAllPages" class="page-info">
{{ previewPageCount }}
</div>
<div v-else>
<el-pagination
v-model:current-page="previewCurrentPage"
background
layout="prev, slot, next"
:page-size="1"
:total="previewPageCount"
>
{{ previewCurrentPage }} / {{ previewPageCount }}
</el-pagination>
</div>
<div class="pdf-actions">
<el-checkbox
v-model="previewAllPages"
@change="handlePreviewAllPagesChange"
>
显示所有页面
</el-checkbox>
<el-button link @click="handleRotatePdf">
<component
:is="useRenderIcon('ri/anticlockwise-line')"
:size="18"
/>
旋转
</el-button>
<el-button link @click="handlePrintPdf">
<component
:is="useRenderIcon('ri/printer-line')"
:size="18"
/>
打印
</el-button>
</div>
</div>
<el-scrollbar class="pdf-scrollbar">
<vue-pdf-embed
ref="previewPdfRef"
:rotation="previewRotations[previewRotation]"
:page="previewCurrentPage"
:source="previewDoc.fileUrl"
class="pdf-viewer"
@rendered="handlePdfRender"
/>
</el-scrollbar>
</div>
</template>
<!-- 文本/Markdown预览 -->
<template
v-else-if="['txt', 'md'].includes(previewDoc.fileType?.toLowerCase())"
>
<div class="text-preview-container">
<iframe :src="previewDoc.fileUrl" class="text-preview-iframe" />
</div>
</template>
<!-- Word/其他文件 - 使用iframe尝试预览 -->
<template v-else>
<div class="other-preview-container">
<div class="preview-tip">
<component
:is="useRenderIcon('ri/file-info-line')"
:size="48"
class="tip-icon"
/>
<p>{{ getFileTypeName(previewDoc.fileType) }} 文件预览</p>
<p class="tip-desc">此文件类型不支持直接预览您可以下载后查看</p>
<el-button
type="primary"
@click="handleOpenFile(previewDoc.fileUrl)"
>
<component
:is="useRenderIcon('ri/download-line')"
class="mr-1"
/>
打开文件
</el-button>
</div>
</div>
</template>
</template>
</el-dialog>
</div>
</template>
@@ -658,4 +839,78 @@ onMounted(() => {
border-radius: 4px;
}
}
// PDF预览样式
.pdf-preview-container {
display: flex;
flex-direction: column;
height: 70vh;
.pdf-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
margin-bottom: 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
.page-info {
font-size: 14px;
font-weight: 500;
}
.pdf-actions {
display: flex;
gap: 12px;
align-items: center;
}
}
.pdf-scrollbar {
flex: 1;
overflow: auto;
}
.pdf-viewer {
width: 100%;
}
}
// 文本预览样式
.text-preview-container {
height: 70vh;
.text-preview-iframe {
width: 100%;
height: 100%;
border: none;
}
}
// 其他文件预览样式
.other-preview-container {
display: flex;
align-items: center;
justify-content: center;
height: 50vh;
.preview-tip {
text-align: center;
.tip-icon {
margin-bottom: 16px;
color: var(--el-text-color-secondary);
}
p {
margin: 8px 0;
font-size: 16px;
}
.tip-desc {
font-size: 14px;
color: var(--el-text-color-secondary);
}
}
}
</style>