|
|
@@ -8,10 +8,6 @@
|
|
|
:searchKey="kbSearchKey"
|
|
|
@search="kbSearchKey = $event"
|
|
|
@select="onSelectKB"
|
|
|
- @create="onCreateKB"
|
|
|
- @rename="onRenameKB"
|
|
|
- @delete="onDeleteKB"
|
|
|
- @set-tags="onSetTags"
|
|
|
/>
|
|
|
</div>
|
|
|
|
|
|
@@ -42,34 +38,24 @@
|
|
|
</div>
|
|
|
|
|
|
<div class="kb-tabs-wrapper">
|
|
|
- <el-tabs v-model="activeTab" type="card" class="kb-tabs">
|
|
|
- <el-tab-pane label="文档管理" name="docs">
|
|
|
- <DocumentTable
|
|
|
- :kb="activeKB"
|
|
|
- :documents="currentDocuments"
|
|
|
- :metadataFields="metadataFields"
|
|
|
- @upload="openUploadDialog"
|
|
|
- @detail="openDocumentDetail"
|
|
|
- @toggle-status="onToggleDocStatus"
|
|
|
- @delete="onDeleteDocument"
|
|
|
- @download="onDownloadDocument"
|
|
|
- @batch-download="onBatchDownload"
|
|
|
- @refresh="loadDocuments"
|
|
|
- />
|
|
|
- </el-tab-pane>
|
|
|
- <el-tab-pane label="元数据配置" name="metadata">
|
|
|
- <MetadataConfig
|
|
|
- :fields="metadataFields"
|
|
|
- @create="onCreateField"
|
|
|
- @update="onUpdateField"
|
|
|
- @delete="onDeleteField"
|
|
|
- @toggle="onToggleField"
|
|
|
- />
|
|
|
- </el-tab-pane>
|
|
|
- <el-tab-pane label="检索测试" name="search">
|
|
|
- <SearchTest :kbId="activeKBId" :documents="currentDocuments" />
|
|
|
- </el-tab-pane>
|
|
|
- </el-tabs>
|
|
|
+ <DocumentTable
|
|
|
+ :kb="activeKB"
|
|
|
+ :documents="currentDocuments"
|
|
|
+ :total="docTotal"
|
|
|
+ :pageSize="docPageSize"
|
|
|
+ :currentPage="docPage"
|
|
|
+ @upload="openUploadDialog"
|
|
|
+ @detail="openDocumentDetail"
|
|
|
+ @preview="onPreviewFile"
|
|
|
+ @toggle-status="onToggleDocStatus"
|
|
|
+ @delete="onDeleteDocument"
|
|
|
+ @download="onDownloadDocument"
|
|
|
+ @batch-download="onBatchDownload"
|
|
|
+ @refresh="loadDocuments"
|
|
|
+ @search="onDocSearch"
|
|
|
+ @page-change="onDocPageChange"
|
|
|
+ @size-change="onDocSizeChange"
|
|
|
+ />
|
|
|
</div>
|
|
|
</template>
|
|
|
<div v-else class="kb-empty-state">
|
|
|
@@ -81,7 +67,6 @@
|
|
|
<!-- 上传文档弹窗 -->
|
|
|
<UploadDocument
|
|
|
:visible="uploadDialogVisible"
|
|
|
- :metadataFields="enabledMetadataFields"
|
|
|
@close="uploadDialogVisible = false"
|
|
|
@submit="onUploadDocument"
|
|
|
/>
|
|
|
@@ -90,11 +75,8 @@
|
|
|
<DocumentDetail
|
|
|
:visible="detailDrawerVisible"
|
|
|
:document="selectedDocument"
|
|
|
- :metadataFields="enabledMetadataFields"
|
|
|
@close="detailDrawerVisible = false"
|
|
|
- @save-meta="onSaveDocMetadata"
|
|
|
@toggle-status="onToggleDocStatus"
|
|
|
- @retry-embed="onRetryEmbed"
|
|
|
@delete="onDeleteDocument"
|
|
|
/>
|
|
|
</div>
|
|
|
@@ -105,26 +87,19 @@ import KnowledgeBaseList from './KnowledgeBaseList.vue'
|
|
|
import DocumentTable from './DocumentTable.vue'
|
|
|
import UploadDocument from './UploadDocument.vue'
|
|
|
import DocumentDetail from './DocumentDetail.vue'
|
|
|
-import MetadataConfig from './MetadataConfig.vue'
|
|
|
-import SearchTest from './SearchTest.vue'
|
|
|
|
|
|
const TAG_COLORS = [
|
|
|
'#409EFF', '#67C23A', '#E6A23C', '#F56C6C',
|
|
|
'#909399', '#B37FEB', '#13C2C2', '#FA8C16'
|
|
|
]
|
|
|
|
|
|
-// [缺] 后端未提供以下接口,前端暂不支持:
|
|
|
-const GAP_MSG = '该功能暂未开放,请等待后端接口就绪'
|
|
|
-
|
|
|
export default {
|
|
|
name: 'KBManagement',
|
|
|
components: {
|
|
|
KnowledgeBaseList,
|
|
|
DocumentTable,
|
|
|
UploadDocument,
|
|
|
- DocumentDetail,
|
|
|
- MetadataConfig,
|
|
|
- SearchTest
|
|
|
+ DocumentDetail
|
|
|
},
|
|
|
data() {
|
|
|
return {
|
|
|
@@ -135,11 +110,15 @@ export default {
|
|
|
detailDrawerVisible: false,
|
|
|
selectedDocument: null,
|
|
|
tagColorMap: {},
|
|
|
+ previewUrl: '',
|
|
|
|
|
|
// 全部从接口获取
|
|
|
knowledgeBases: [],
|
|
|
allDocuments: [],
|
|
|
- metadataFields: []
|
|
|
+ docPage: 1,
|
|
|
+ docPageSize: 20,
|
|
|
+ docTotal: 0,
|
|
|
+ docKeyword: ''
|
|
|
}
|
|
|
},
|
|
|
computed: {
|
|
|
@@ -152,11 +131,7 @@ export default {
|
|
|
return this.knowledgeBases.filter(kb => kb.name.toLowerCase().includes(keyword))
|
|
|
},
|
|
|
currentDocuments() {
|
|
|
- if (!this.activeKBId) return []
|
|
|
- return this.allDocuments.filter(doc => doc.kbId === this.activeKBId)
|
|
|
- },
|
|
|
- enabledMetadataFields() {
|
|
|
- return this.metadataFields.filter(f => f.enabled)
|
|
|
+ return this.allDocuments
|
|
|
},
|
|
|
},
|
|
|
mounted() {
|
|
|
@@ -175,10 +150,6 @@ export default {
|
|
|
}
|
|
|
},
|
|
|
|
|
|
- gapWarn(feature) {
|
|
|
- console.warn(`[GAP] ${feature}`)
|
|
|
- this.$message.warning(GAP_MSG)
|
|
|
- },
|
|
|
|
|
|
// ====== 知识库 API ======
|
|
|
async fetchKBList() {
|
|
|
@@ -217,28 +188,30 @@ export default {
|
|
|
if (!this.activeKBId) return
|
|
|
const res = await this.apiCall(2026052714301017, {
|
|
|
dataset_id: String(this.activeKBId),
|
|
|
- keyword: '',
|
|
|
+ keyword: this.docKeyword,
|
|
|
status: '',
|
|
|
- pageNumber: 1,
|
|
|
- pageSize: 999
|
|
|
+ pageNumber: this.docPage,
|
|
|
+ pageSize: this.docPageSize
|
|
|
})
|
|
|
if (res && res.code === 1 && res.data) {
|
|
|
- const list = res.data.list || res.data.data || res.data || []
|
|
|
- this.allDocuments = list.map(item => ({
|
|
|
+ const list = res.data.data || res.data.list || res.data || []
|
|
|
+ const total = res.data.total || list.length || 0
|
|
|
+ this.docTotal = total
|
|
|
+ this.allDocuments = Array.isArray(list) ? list.map(item => ({
|
|
|
id: item.id || item.document_id,
|
|
|
kbId: this.activeKBId,
|
|
|
name: item.name || item.document_name || item.file_name || '',
|
|
|
- size: item.size || item.file_size || '-',
|
|
|
+ size: this.formatSize(item.data_source_detail_dict && item.data_source_detail_dict.upload_file && item.data_source_detail_dict.upload_file.size),
|
|
|
type: (item.type || item.file_type || item.name || '').split('.').pop().toLowerCase(),
|
|
|
version: item.version || 1,
|
|
|
- status: item.status || 'enabled',
|
|
|
- embedStatus: item.indexing_status || item.embedStatus || 'pending',
|
|
|
+ status: (item.display_status === 'available' ? 'enabled' : item.display_status) || (item.enabled === false ? 'disabled' : 'enabled'),
|
|
|
+ embedStatus: item.indexing_status || item.embedStatus || 'waiting',
|
|
|
embedProgress: item.completed_segments !== undefined && item.total_segments
|
|
|
? Math.round(item.completed_segments / item.total_segments * 100)
|
|
|
: (item.embedProgress || 0),
|
|
|
- uploadTime: item.created_at || item.uploadTime || '',
|
|
|
+ uploadTime: this.formatTimestamp(item.created_at || item.createdBy),
|
|
|
metadata: item.metadata || item.doc_metadata || {}
|
|
|
- }))
|
|
|
+ })) : []
|
|
|
}
|
|
|
},
|
|
|
|
|
|
@@ -254,10 +227,10 @@ export default {
|
|
|
this.allDocuments[idx] = {
|
|
|
...this.allDocuments[idx],
|
|
|
name: d.name || d.document_name || this.allDocuments[idx].name,
|
|
|
- size: d.size || d.file_size || this.allDocuments[idx].size,
|
|
|
+ size: this.formatSize(d.data_source_detail_dict && d.data_source_detail_dict.upload_file && d.data_source_detail_dict.upload_file.size) || this.allDocuments[idx].size,
|
|
|
type: (d.type || d.file_type || d.name || '').split('.').pop().toLowerCase(),
|
|
|
version: d.version || this.allDocuments[idx].version,
|
|
|
- status: d.status || this.allDocuments[idx].status,
|
|
|
+ status: (d.display_status === 'available' ? 'enabled' : d.display_status) || (d.enabled === false ? 'disabled' : (d.enabled === true ? 'enabled' : this.allDocuments[idx].status)),
|
|
|
embedStatus: d.indexing_status || d.embedStatus || this.allDocuments[idx].embedStatus,
|
|
|
embedProgress: d.embedProgress || this.allDocuments[idx].embedProgress,
|
|
|
metadata: d.metadata || d.doc_metadata || this.allDocuments[idx].metadata
|
|
|
@@ -291,50 +264,161 @@ export default {
|
|
|
this.fetchKBTags()
|
|
|
},
|
|
|
|
|
|
- onCreateKB() {
|
|
|
- this.gapWarn('创建知识库')
|
|
|
- },
|
|
|
|
|
|
- onRenameKB() {
|
|
|
- this.gapWarn('更新知识库')
|
|
|
+ // ====== 文档操作 ======
|
|
|
+ openUploadDialog() {
|
|
|
+ this.uploadDialogVisible = true
|
|
|
},
|
|
|
|
|
|
- onDeleteKB() {
|
|
|
- this.gapWarn('删除知识库')
|
|
|
+ async onUploadDocument({ files, metadata }) {
|
|
|
+ if (!files || files.length === 0) return
|
|
|
+ this.uploadDialogVisible = false
|
|
|
+ const loading = this.$loading({ lock: true, text: '正在上传文档...' })
|
|
|
+
|
|
|
+ try {
|
|
|
+ for (const file of files) {
|
|
|
+ // 1. 获取 OBS 上传地址
|
|
|
+ const ext = file.name.split('.').pop().toLowerCase()
|
|
|
+ const obsRes = await this.$api.requested({
|
|
|
+ classname: 'system.attachment.huawei.OBS',
|
|
|
+ method: 'getFileName',
|
|
|
+ content: { filename: file.name, filetype: ext, parentid: '' }
|
|
|
+ })
|
|
|
+ if (!obsRes || !obsRes.data || !obsRes.data.uploadurl) {
|
|
|
+ this.$message.error(`获取上传地址失败: ${file.name}`)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ const uploadUrl = obsRes.data.uploadurl
|
|
|
+ const serialfilename = obsRes.data.serialfilename
|
|
|
+
|
|
|
+ // 2. 上传文件到华为云 OBS
|
|
|
+ const config = {
|
|
|
+ headers: ext === 'pdf'
|
|
|
+ ? { 'Content-Type': 'application/pdf' }
|
|
|
+ : { 'Content-Type': 'application/octet-stream' },
|
|
|
+ onUploadProgress: (e) => {
|
|
|
+ if (e.total) loading.text = `正在上传 ${file.name} (${Math.round(e.loaded / e.total * 100)}%)`
|
|
|
+ }
|
|
|
+ }
|
|
|
+ await this.$upload.hw_upload(uploadUrl, file, config)
|
|
|
+
|
|
|
+ // 3. 生成附件记录,拿到 attachmentid
|
|
|
+ const attachRes = await this.$api.requested({
|
|
|
+ classname: 'system.attachment.huawei.OBS',
|
|
|
+ method: 'uploadSuccess',
|
|
|
+ content: { serialfilename }
|
|
|
+ })
|
|
|
+ if (!attachRes || !attachRes.data) {
|
|
|
+ this.$message.error(`附件记录创建失败: ${file.name}`)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ const attachmentId = attachRes.data.attachmentids
|
|
|
+ ? attachRes.data.attachmentids[0]
|
|
|
+ : (attachRes.data.attachmentid || attachRes.data.id)
|
|
|
+
|
|
|
+ // 4. 调用 Dify 接口创建文档
|
|
|
+ const difyRes = await this.apiCall(2026052715313901, {
|
|
|
+ dataset_id: String(this.activeKBId),
|
|
|
+ attachmentid: attachmentId
|
|
|
+ })
|
|
|
+ if (difyRes && difyRes.code === 1) {
|
|
|
+ this.$message.success(`${file.name} 上传成功`)
|
|
|
+ } else {
|
|
|
+ this.$message.error(`${file.name} 创建文档失败`)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 刷新文档列表
|
|
|
+ this.fetchDocumentList()
|
|
|
+ } catch (e) {
|
|
|
+ console.error('[Upload] error', e)
|
|
|
+ this.$message.error('上传失败: ' + (e.message || '未知错误'))
|
|
|
+ } finally {
|
|
|
+ loading.close()
|
|
|
+ }
|
|
|
},
|
|
|
|
|
|
- onSetTags() {
|
|
|
- this.gapWarn('设置知识库标签')
|
|
|
+ loadDocuments() {
|
|
|
+ this.fetchDocumentList()
|
|
|
},
|
|
|
|
|
|
- // ====== 文档操作 ======
|
|
|
- openUploadDialog() {
|
|
|
- this.gapWarn('上传文档(需先接入OBS获取attachmentid)')
|
|
|
+ onDocSearch(keyword) {
|
|
|
+ this.docKeyword = keyword
|
|
|
+ this.docPage = 1
|
|
|
+ this.fetchDocumentList()
|
|
|
},
|
|
|
|
|
|
- onUploadDocument() {
|
|
|
- this.uploadDialogVisible = false
|
|
|
+ onDocPageChange(page) {
|
|
|
+ this.docPage = page
|
|
|
+ this.fetchDocumentList()
|
|
|
},
|
|
|
|
|
|
- loadDocuments() {
|
|
|
+ onDocSizeChange(size) {
|
|
|
+ this.docPageSize = size
|
|
|
+ this.docPage = 1
|
|
|
this.fetchDocumentList()
|
|
|
},
|
|
|
|
|
|
- openDocumentDetail(doc) {
|
|
|
+ // 浏览器可直接预览的文件类型
|
|
|
+ isPreviewable(type) {
|
|
|
+ const previewable = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'txt', 'html', 'htm']
|
|
|
+ return previewable.includes((type || '').toLowerCase())
|
|
|
+ },
|
|
|
+
|
|
|
+ async onPreviewFile(doc) {
|
|
|
+ const res = await this.apiCall(2026052714301010, {
|
|
|
+ dataset_id: String(this.activeKBId),
|
|
|
+ document_id: String(doc.id)
|
|
|
+ })
|
|
|
+ if (!res || res.code !== 1 || !res.data) {
|
|
|
+ this.$message.error('获取文件地址失败')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const url = res.data.url || res.data.download_url || res.data.downloadUrl
|
|
|
+ if (!url) return
|
|
|
+ const fullUrl = res.difyBaseUrl ? res.difyBaseUrl + url : url
|
|
|
+
|
|
|
+ if (this.isPreviewable(doc.type)) {
|
|
|
+ // 把 as_attachment=true 改为 false 实现浏览器预览
|
|
|
+ const previewUrl = fullUrl.replace('as_attachment=true', 'as_attachment=false')
|
|
|
+ window.open(previewUrl, '_blank')
|
|
|
+ } else {
|
|
|
+ try {
|
|
|
+ await this.$confirm('该文件类型不支持在线预览,是否直接下载?', '提示', {
|
|
|
+ confirmButtonText: '下载',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ type: 'warning'
|
|
|
+ })
|
|
|
+ this.triggerDownload(fullUrl, doc.name || '')
|
|
|
+ } catch {
|
|
|
+ // 用户取消
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ async openDocumentDetail(doc) {
|
|
|
this.selectedDocument = { ...doc }
|
|
|
this.detailDrawerVisible = true
|
|
|
this.fetchDocumentDetail(doc.id)
|
|
|
+ // 获取预览地址
|
|
|
+ this.previewUrl = ''
|
|
|
+ const res = await this.apiCall(2026052714301010, {
|
|
|
+ dataset_id: String(this.activeKBId),
|
|
|
+ document_id: String(doc.id)
|
|
|
+ })
|
|
|
+ if (res && res.code === 1 && res.data && res.data.url) {
|
|
|
+ const base = res.difyBaseUrl || ''
|
|
|
+ const previewPath = res.data.url.replace('as_attachment=true', 'as_attachment=false')
|
|
|
+ this.previewUrl = base + previewPath
|
|
|
+ }
|
|
|
},
|
|
|
|
|
|
- onSaveDocMetadata() {
|
|
|
- this.gapWarn('设置文档元数据')
|
|
|
- },
|
|
|
+
|
|
|
|
|
|
async onToggleDocStatus(docId) {
|
|
|
const doc = this.allDocuments.find(d => d.id === docId)
|
|
|
if (!doc) return
|
|
|
- const newStatus = doc.status === 'enabled' ? 'disabled' : 'enabled'
|
|
|
- const action = newStatus === 'enabled' ? 'enable' : 'disable'
|
|
|
+ const action = doc.status === 'enabled' ? 'disable' : 'enable'
|
|
|
|
|
|
const ok = await this.apiCall(2026052714301016, {
|
|
|
dataset_id: String(this.activeKBId),
|
|
|
@@ -342,8 +426,8 @@ export default {
|
|
|
action
|
|
|
})
|
|
|
if (ok && ok.code === 1) {
|
|
|
- doc.status = newStatus
|
|
|
- this.$message.success(newStatus === 'enabled' ? '文档已启用' : '文档已禁用')
|
|
|
+ doc.status = action === 'enable' ? 'enabled' : 'disabled'
|
|
|
+ this.$message.success(action === 'enable' ? '文档已启用' : '文档已禁用')
|
|
|
}
|
|
|
},
|
|
|
|
|
|
@@ -355,43 +439,58 @@ export default {
|
|
|
if (ok && ok.code === 1) {
|
|
|
const idx = this.allDocuments.findIndex(d => d.id === docId)
|
|
|
if (idx > -1) this.allDocuments.splice(idx, 1)
|
|
|
+ // 更新分页总数
|
|
|
+ if (this.docTotal > 0) this.docTotal--
|
|
|
+ // 更新左侧知识库列表的文档数
|
|
|
+ const kb = this.knowledgeBases.find(k => k.id === this.activeKBId)
|
|
|
+ if (kb && kb.docCount > 0) kb.docCount--
|
|
|
this.$message.success('文档已删除')
|
|
|
}
|
|
|
},
|
|
|
|
|
|
+ triggerDownload(url, filename) {
|
|
|
+ const a = document.createElement('a')
|
|
|
+ a.href = url
|
|
|
+ a.download = filename || ''
|
|
|
+ a.style.display = 'none'
|
|
|
+ document.body.appendChild(a)
|
|
|
+ a.click()
|
|
|
+ document.body.removeChild(a)
|
|
|
+ },
|
|
|
+
|
|
|
async onDownloadDocument(doc) {
|
|
|
const res = await this.apiCall(2026052714301010, {
|
|
|
dataset_id: String(this.activeKBId),
|
|
|
document_id: String(doc.id)
|
|
|
})
|
|
|
if (res && res.code === 1 && res.data) {
|
|
|
- const url = res.data.download_url || res.data.downloadUrl || res.data.url
|
|
|
- if (url) window.open(url)
|
|
|
+ const url = res.data.url || res.data.download_url || res.data.downloadUrl
|
|
|
+ if (url) {
|
|
|
+ const fullUrl = res.difyBaseUrl ? res.difyBaseUrl + url : url
|
|
|
+ this.triggerDownload(fullUrl, doc.name || '')
|
|
|
+ }
|
|
|
}
|
|
|
},
|
|
|
|
|
|
async onBatchDownload(ids) {
|
|
|
- const res = await this.apiCall(2026052714301011, {
|
|
|
- dataset_id: String(this.activeKBId),
|
|
|
- document_ids: ids.map(String)
|
|
|
- })
|
|
|
- if (res && res.code === 1 && res.data) {
|
|
|
- const url = res.data.download_url || res.data.downloadUrl || res.data.url
|
|
|
- if (url) window.open(url)
|
|
|
+ for (const docId of ids) {
|
|
|
+ const doc = this.allDocuments.find(d => d.id === docId)
|
|
|
+ if (doc) {
|
|
|
+ await this.onDownloadDocument(doc)
|
|
|
+ await new Promise(r => setTimeout(r, 300))
|
|
|
+ }
|
|
|
}
|
|
|
},
|
|
|
|
|
|
- onRetryEmbed() {
|
|
|
- this.gapWarn('重新向量化')
|
|
|
- },
|
|
|
|
|
|
- // ====== 元数据操作 ======
|
|
|
- onCreateField() { this.gapWarn('元数据管理') },
|
|
|
- onUpdateField() { this.gapWarn('元数据管理') },
|
|
|
- onDeleteField() { this.gapWarn('元数据管理') },
|
|
|
- onToggleField() { this.gapWarn('元数据管理') },
|
|
|
|
|
|
// ====== 工具方法 ======
|
|
|
+ formatSize(bytes) {
|
|
|
+ if (!bytes || bytes <= 0) return '-'
|
|
|
+ if (bytes < 1024) return bytes + ' B'
|
|
|
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
|
|
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
|
|
+ },
|
|
|
formatTimestamp(ts) {
|
|
|
if (!ts) return ''
|
|
|
const d = new Date(ts * 1000)
|