feat: 增强 Knowledge 页面功能

- 优化文档预览功能
- 添加 CSV 文件解析支持
- 增强知识库详情展示
- 优化样式和交互

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 15:42:55 +08:00
parent 4a7199de93
commit 49433f1681
3 changed files with 366 additions and 19 deletions

View File

@@ -3,6 +3,10 @@ import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { useModelSettings } from './settings/useModelSettings'
import { fetchKnowledgeBases, createKnowledgeBase as apiCreateKnowledgeBase, deleteKnowledgeBase as apiDeleteKnowledgeBase, fetchKnowledgeDocuments } from './knowledge/useKnowledge'
import VueOfficeDocx from '@vue-office/docx'
import VueOfficeExcel from '@vue-office/excel'
import Papa from 'papaparse'
import { marked } from 'marked'
import './knowledge/knowledge.css'
// 获取已配置的模型列表
@@ -135,7 +139,12 @@ const loadingDocuments = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
const previewUrl = ref('') // 文档预览URL (blob URL)
const previewHtml = ref('') // HTML内容预览
const previewContentType = ref('') // content_type: url 或 html
const previewDownloadUrl = ref('') // 原始下载链接
const previewFileType = ref('') // 文件类型: pdf, docx, xlsx, csv
const previewCsvData = ref<any[]>([]) // CSV数据
const previewCsvHeaders = ref<string[]>([]) // CSV表头
const loadingPreview = ref(false)
const previewPage = ref(1) // 当前页码
const previewTotalPages = ref(1) // 总页数
@@ -157,6 +166,20 @@ const loadPdfWithProxy = async (doc: any): Promise<string> => {
}
}
// 将URL转换为blob URL (用于Excel组件)
const loadFileAsBlob = async (url: string): Promise<Blob> => {
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`)
}
return await response.blob()
} catch (error) {
console.error('Failed to load file as blob:', error)
throw error
}
}
const newKbForm = ref({
name: '',
description: '',
@@ -337,6 +360,11 @@ const enterKnowledge = async (kb: any) => {
selectedKnowledge.value = kb
selectedFile.value = null
previewUrl.value = ''
previewHtml.value = ''
previewContentType.value = ''
previewFileType.value = ''
previewCsvData.value = []
previewCsvHeaders.value = []
previewDownloadUrl.value = ''
// 获取文档列表
@@ -375,13 +403,126 @@ const selectDocument = async (doc: any) => {
selectedFile.value = doc.id
selectedDocument.value = doc
previewUrl.value = ''
previewHtml.value = ''
previewContentType.value = ''
previewDownloadUrl.value = ''
previewFileType.value = ''
previewCsvData.value = []
previewCsvHeaders.value = []
previewPage.value = 1
previewTotalPages.value = 1
// 优先使用代理接口加载PDF
// 检测文件类型
const fileName = doc.name || ''
const ext = fileName.split('.').pop()?.toLowerCase() || ''
console.log('File name:', fileName, 'Ext:', ext)
if (ext === 'pdf') {
previewFileType.value = 'pdf'
} else if (ext === 'docx' || ext === 'doc') {
previewFileType.value = 'docx'
} else if (ext === 'xlsx' || ext === 'xls') {
previewFileType.value = 'xlsx'
} else if (ext === 'csv') {
previewFileType.value = 'csv'
} else if (ext === 'txt' || ext === 'md') {
previewFileType.value = 'text'
}
console.log('Preview file type:', previewFileType.value)
// 纯文本文件 (txt, md) 直接读取内容显示
if (previewFileType.value === 'text') {
// 优先使用 file_url上传时后端返回的
let url = doc.file_url || doc.fileUrl || doc.url || doc.FileURL || ''
// 如果没有 file_url尝试用 file_key 通过代理
if (!url && doc.file_key && selectedKnowledge.value) {
url = await loadPdfWithProxy(doc)
}
console.log('Text file URL:', url)
if (url) {
try {
const response = await fetch(url)
console.log('Response status:', response.status)
const arrayBuffer = await response.arrayBuffer()
const decoder = new TextDecoder('utf-8')
const content = decoder.decode(arrayBuffer)
console.log('Content length:', content.length, 'ext:', ext)
// .md 文件用 marked 渲染
if (ext === 'md') {
// marked.parse 返回 Promise需要 await
const html = await marked.parse(content)
previewHtml.value = html as string
} else {
// .txt 文件直接显示
previewHtml.value = '<pre style="white-space: pre-wrap; word-wrap: break-word; color: #e8eaed;">' + content + '</pre>'
}
console.log('PreviewHtml set, length:', previewHtml.value.length)
return
} catch (error) {
console.error('Failed to load text file:', error)
}
} else {
console.log('No URL for text file, file_key:', doc.file_key, 'file_url:', doc.file_url)
}
}
// CSV需要特殊处理用PapaParse解析
if (previewFileType.value === 'csv' && (doc.file_key || doc.file_url)) {
const url = doc.file_key && selectedKnowledge.value
? await loadPdfWithProxy(doc)
: doc.file_url || doc.fileUrl || doc.url || ''
if (url) {
try {
const response = await fetch(url)
const arrayBuffer = await response.arrayBuffer()
// 尝试多种编码解码
let csvText = ''
const encodings = ['utf-8', 'gbk', 'gb2312', 'big5']
for (const encoding of encodings) {
try {
const decoder = new TextDecoder(encoding)
csvText = decoder.decode(arrayBuffer)
// 检查是否成功解码(如果没有乱码字符)
if (!csvText.includes('\uFFFD')) {
console.log(`CSV decoded with: ${encoding}`)
break
}
} catch (e) {
continue
}
}
const result = Papa.parse(csvText, { header: false, skipEmptyLines: true })
if (result.data && result.data.length > 0) {
previewCsvHeaders.value = result.data[0] as string[]
previewCsvData.value = result.data.slice(1) as any[][]
}
return
} catch (error) {
console.error('Failed to parse CSV:', error)
}
}
}
// 使用代理接口加载文件
if (doc.file_key && selectedKnowledge.value) {
previewUrl.value = await loadPdfWithProxy(doc)
const proxyUrl = await loadPdfWithProxy(doc)
// Excel/Word需要blob URL
if ((previewFileType.value === 'xlsx' || previewFileType.value === 'docx') && proxyUrl) {
try {
const blob = await loadFileAsBlob(proxyUrl)
const blobUrl = URL.createObjectURL(blob)
previewUrl.value = blobUrl
return
} catch (error) {
console.error('Failed to convert to blob:', error)
}
}
previewUrl.value = proxyUrl
if (previewUrl.value) {
return
}
@@ -397,11 +538,19 @@ const selectDocument = async (doc: any) => {
// 如果没有file_url调用预览API获取
if (selectedKnowledge.value) {
loadingPreview.value = true
previewUrl.value = ''
previewHtml.value = ''
previewContentType.value = ''
try {
const { getDocumentPreview } = await import('./knowledge/useKnowledge')
const result = await getDocumentPreview(selectedKnowledge.value.id, doc.id)
if (result.success && result.data?.content) {
previewUrl.value = result.data.content
previewContentType.value = result.data.content_type || 'url'
if (previewContentType.value === 'html') {
previewHtml.value = result.data.content
} else {
previewUrl.value = result.data.content
}
previewTotalPages.value = result.data.total_pages || 1
previewPage.value = result.data.current_page || 1
}
@@ -416,6 +565,8 @@ const selectDocument = async (doc: any) => {
// 翻页
const changePreviewPage = async (page: number) => {
if (!selectedKnowledge.value || !selectedFile.value || page < 1 || page > previewTotalPages.value) return
// HTML内容不支持翻页
if (previewContentType.value === 'html') return
loadingPreview.value = true
previewPage.value = page
@@ -423,7 +574,12 @@ const changePreviewPage = async (page: number) => {
const { getDocumentPreview } = await import('./knowledge/useKnowledge')
const result = await getDocumentPreview(selectedKnowledge.value.id, selectedFile.value, page)
if (result.success && result.data?.content) {
previewUrl.value = result.data.content
previewContentType.value = result.data.content_type || 'url'
if (previewContentType.value === 'html') {
previewHtml.value = result.data.content
} else {
previewUrl.value = result.data.content
}
previewTotalPages.value = result.data.total_pages || 1
previewPage.value = result.data.current_page || page
}
@@ -459,16 +615,8 @@ const handleFileSelect = async (event: Event) => {
// 获取刚上传的文档
const uploadedDoc = knowledgeDocuments.value.find(d => d.id === result.id)
if (uploadedDoc) {
// 选中新上传的文档
selectedFile.value = result.id
selectedDocument.value = uploadedDoc
// 使用代理接口加载PDF
if (uploadedDoc.file_key) {
previewUrl.value = await loadPdfWithProxy(uploadedDoc)
} else if (uploadedDoc.file_url) {
previewUrl.value = uploadedDoc.file_url
}
// 选中新上传的文档,调用 selectDocument 处理预览
await selectDocument(uploadedDoc)
}
} else {
ElMessage.error(result.message || 'Failed to upload file')
@@ -497,7 +645,12 @@ const deleteDocument = async (docId: string) => {
selectedFile.value = null
selectedDocument.value = null
previewUrl.value = ''
previewDownloadUrl.value = ''
previewHtml.value = ''
previewContentType.value = ''
previewFileType.value = ''
previewCsvData.value = []
previewCsvHeaders.value = []
previewDownloadUrl.value = ''
}
// 刷新文档列表
await changeFileFilter(fileFilter.value)
@@ -873,7 +1026,7 @@ const deleteDocument = async (docId: string) => {
type="file"
ref="fileInput"
style="display: none"
accept=".pdf,.doc,.docx,.txt,.md"
accept=".pdf,.doc,.docx,.docx,.txt,.md,.csv,.xlsx,.xls,.pptx,.ppt"
@change="handleFileSelect"
/>
<button class="btn-primary" @click="triggerFileUpload">
@@ -987,7 +1140,40 @@ const deleteDocument = async (docId: string) => {
<i class="fa-solid fa-spinner fa-spin"></i>
<span>Loading preview...</span>
</div>
<!-- 有blob预览URL时显示PDF (使用iframe) -->
<!-- Word文件预览 -->
<VueOfficeDocx
v-else-if="previewUrl && previewFileType === 'docx'"
:src="previewUrl"
class="office-embed"
@rendered="console.log('Docx rendered')"
@error="console.error('Docx error', $event)"
/>
<!-- Excel文件预览 -->
<VueOfficeExcel
v-else-if="previewUrl && previewFileType === 'xlsx'"
:src="previewUrl"
class="office-embed"
@rendered="console.log('Excel rendered')"
@error="console.error('Excel error', $event)"
/>
<!-- CSV文件预览 -->
<div v-else-if="previewFileType === 'csv' && previewCsvData.length > 0" class="csv-preview">
<table>
<thead>
<tr>
<th v-for="(header, index) in previewCsvHeaders" :key="index">{{ header }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in previewCsvData" :key="rowIndex">
<td v-for="(cell, cellIndex) in row" :key="cellIndex">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 文本文件预览 (txt, md) -->
<div v-else-if="previewHtml" class="text-preview" v-html="previewHtml"></div>
<!-- PDF/其他文件预览 (使用iframe) -->
<iframe
v-else-if="previewUrl"
:src="previewUrl"
@@ -996,9 +1182,9 @@ const deleteDocument = async (docId: string) => {
<!-- 无预览但有下载链接时显示下载按钮 -->
<div v-else-if="previewDownloadUrl" class="preview-no-file">
<i class="fa-solid fa-file-pdf"></i>
<span>Cannot preview PDF directly</span>
<span>Cannot preview file directly</span>
<a :href="previewDownloadUrl" target="_blank" class="download-link">
<i class="fa-solid fa-download"></i> Download PDF
<i class="fa-solid fa-download"></i> Download File
</a>
</div>
<!-- 无预览也无下载链接时显示提示 -->

View File

@@ -241,6 +241,7 @@ const showChangePassword = () => {
description="Configure your model settings"
icon="fa-solid fa-brain"
icon-class="bg-gradient-to-br from-primary-orange to-red-500"
class="add-model-dialog"
>
<div class="space-y-4">
<div>

View File

@@ -943,6 +943,166 @@
border: none;
}
/* HTML内容预览 */
.html-preview {
width: 100%;
height: 100%;
overflow: auto;
padding: 20px;
background-color: #1e1e24;
color: #e8eaed;
line-height: 1.6;
}
.html-preview table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
.html-preview table th,
.html-preview table td {
border: 1px solid #3a3a4a;
padding: 8px 12px;
text-align: left;
}
.html-preview table th {
background-color: #2a2a3a;
}
.html-preview h1, .html-preview h2, .html-preview h3 {
margin-top: 16px;
margin-bottom: 8px;
color: #ffffff;
}
.html-preview pre {
background-color: #2a2a3a;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
.html-preview code {
background-color: #2a2a3a;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
}
/* Office 组件预览 */
.office-embed {
width: 100%;
height: 100%;
overflow: auto;
background-color: #ffffff;
}
/* 修复 vue-office 表格黑色背景问题 */
.office-embed :deep(table) {
background-color: #ffffff !important;
color: #333333 !important;
}
.office-embed :deep(td),
.office-embed :deep(th) {
background-color: #ffffff !important;
color: #333333 !important;
border: 1px solid #dddddd !important;
}
/* CSV 表格预览 */
.csv-preview {
width: 100%;
height: 100%;
overflow: auto;
background-color: #ffffff;
padding: 16px;
}
.csv-preview table {
width: 100%;
border-collapse: collapse;
}
.csv-preview th,
.csv-preview td {
border: 1px solid #dddddd;
padding: 8px 12px;
text-align: left;
color: #333333;
}
.csv-preview th {
background-color: #f5f5f5;
font-weight: 600;
}
.csv-preview tr:nth-child(even) {
background-color: #fafafa;
}
/* 文本文件预览 */
.text-preview {
width: 100%;
height: 100%;
overflow: auto;
padding: 16px;
background-color: #1e1e24;
color: #e8eaed;
}
.text-preview pre {
white-space: pre-wrap;
word-wrap: break-word;
color: #e8eaed;
font-family: monospace;
font-size: 14px;
line-height: 1.6;
}
/* Markdown 渲染样式 */
.text-preview h1, .text-preview h2, .text-preview h3,
.text-preview h4, .text-preview h5, .text-preview h6 {
color: #ffffff;
margin-top: 16px;
margin-bottom: 8px;
}
.text-preview code {
background-color: #2a2a3a;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
}
.text-preview pre {
background-color: #2a2a3a;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
.text-preview ul, .text-preview ol {
padding-left: 24px;
}
.text-preview li {
margin-bottom: 4px;
}
.text-preview a {
color: #f97316;
}
.text-preview blockquote {
border-left: 4px solid #f97316;
padding-left: 16px;
margin-left: 0;
color: #9ca3af;
}
.preview-info {
display: grid;
grid-template-columns: repeat(2, 1fr);