Procházet zdrojové kódy

fix: 更新文档启用停用接口参数及状态映射

- 更新 updateDocumentsStatusBatch 接口参数为 action: enable/disable
- 修复 display_status: available 映射为 enabled
- 嵌入状态筛选框添加右边距
Zachary před 4 dny
rodič
revize
98dac537f8

+ 1 - 1
src/AIManagement/KBM/API.md

@@ -823,7 +823,7 @@ Content-Type: application/json;charset=UTF-8
 | 8 | `2026052714301011` | downloadDocumentsBatch | 批量下载 | 已对接 `batchDownloadApi()` |
 | 9 | `2026052714301012` | queryDocumentIndexingStatus | 索引状态查询 | 已对接 `fetchIndexingStatus()` |
 | 10 | `2026052714301013` | createOrUpdateDocumentByText | 新建/更新文本文档 | 待接入 |
-| 11 | `2026052714301016` | updateDocumentsStatusBatch | 批量更新文档状态 | 已对接 `batchUpdateDocStatus()` |
+| 11 | `2026052714301016` | updateDocumentsStatusBatch | 批量更新文档状态 `{ dataset_id, document_ids[], action: 'enable'|'disable' }` | 已对接 `onToggleDocStatus()` |
 | 12 | `2026052715313901` | createDocumentByFile | 文件上传创建文档 | 部分对接(需 OBS attachmentid) |
 
 ---

+ 89 - 32
src/AIManagement/KBM/DocumentTable.vue

@@ -23,58 +23,63 @@
           prefix-icon="el-icon-search"
           clearable
           style="width: 220px;"
+          @input="onSearchInput"
         />
         <el-select
           v-model="filterEmbedStatus"
           placeholder="嵌入状态"
           size="small"
           clearable
-          style="width: 130px; margin-left: 8px;"
+          style="width: 130px; margin-left: 8px; margin-right: 8px;"
         >
+          <el-option label="等待处理" value="waiting" />
+          <el-option label="解析中" value="parsing" />
+          <el-option label="清洗中" value="cleaning" />
+          <el-option label="分段中" value="splitting" />
+          <el-option label="索引中" value="indexing" />
           <el-option label="已完成" value="completed" />
-          <el-option label="处理中" value="processing" />
-          <el-option label="待处理" value="pending" />
-          <el-option label="失败" value="failed" />
+          <el-option label="失败" value="error" />
         </el-select>
       </div>
     </div>
 
     <!-- 表格 -->
     <el-table
-      :data="filteredDocuments"
+      :data="displayDocuments"
       size="small"
       stripe
       style="width: 100%;"
       @selection-change="handleSelectionChange"
       row-key="id"
-      height="calc(100vh - 340px)"
+      height="calc(100vh - 380px)"
     >
       <el-table-column type="selection" width="40" />
-      <el-table-column label="文件名" min-width="200" show-overflow-tooltip>
+      <el-table-column label="文件名" min-width="140" show-overflow-tooltip>
         <template v-slot="{ row }">
-          <span class="dt-file-link" @click="$emit('detail', row)">
+          <span class="dt-file-link" @click="$emit('preview', row)">
             <i :class="fileIcon(row.type)" style="margin-right: 6px;"></i>
             {{ row.name }}
           </span>
         </template>
       </el-table-column>
       <el-table-column label="大小" width="90" prop="size" />
-      <el-table-column label="版本" width="60" align="center">
-        <template v-slot="{ row }">v{{ row.version }}</template>
-      </el-table-column>
       <el-table-column label="上传时间" width="150" prop="uploadTime" />
       <el-table-column label="嵌入状态" width="130" align="center">
         <template v-slot="{ row }">
           <el-progress
-            v-if="row.embedStatus === 'processing'"
+            v-if="['parsing','cleaning','splitting','indexing'].includes(row.embedStatus)"
             :percentage="row.embedProgress"
             :show-text="false"
             :stroke-width="6"
             style="width: 80px; display: inline-block;"
           />
           <el-tag v-else-if="row.embedStatus === 'completed'" type="success" size="mini">已完成</el-tag>
-          <el-tag v-else-if="row.embedStatus === 'failed'" type="danger" size="mini">失败</el-tag>
-          <el-tag v-else type="info" size="mini">待处理</el-tag>
+          <el-tag v-else-if="row.embedStatus === 'error'" type="danger" size="mini">失败</el-tag>
+          <el-tag v-else-if="row.embedStatus === 'parsing'" size="mini">解析中</el-tag>
+          <el-tag v-else-if="row.embedStatus === 'cleaning'" size="mini">清洗中</el-tag>
+          <el-tag v-else-if="row.embedStatus === 'splitting'" size="mini">分段中</el-tag>
+          <el-tag v-else-if="row.embedStatus === 'indexing'" type="warning" size="mini">索引中</el-tag>
+          <el-tag v-else type="info" size="mini">等待处理</el-tag>
         </template>
       </el-table-column>
       <el-table-column label="状态" width="80" align="center">
@@ -87,22 +92,34 @@
           />
         </template>
       </el-table-column>
-      <el-table-column label="操作" width="200" fixed="right">
+      <el-table-column label="操作" width="150" fixed="right">
         <template v-slot="{ row }">
-          <el-button type="text" size="mini" @click="$emit('detail', row)">详情</el-button>
-          <el-button type="text" size="mini" @click="$emit('download', row)" style="margin-right: 12px;">下载</el-button>
+          <el-button type="text" size="mini" @click="$emit('download', row)">下载</el-button>
           <el-popconfirm
-            v-if="row.status === 'disabled'"
             title="确定删除该文档?"
             confirm-button-text="删除"
             cancel-button-text="取消"
             @confirm="$emit('delete', row.id)"
           >
-            <el-button type="text" size="mini" slot="reference" style="color: #F56C6C;">删除</el-button>
+            <el-button type="text" size="mini" slot="reference" style="color: #F56C6C; margin-left: 10px;">删除</el-button>
           </el-popconfirm>
         </template>
       </el-table-column>
     </el-table>
+
+    <!-- 分页 -->
+    <div class="dt-pagination">
+      <el-pagination
+        small
+        layout="total, sizes, prev, pager, next"
+        :total="total"
+        :page-size="pageSize"
+        :current-page="currentPage"
+        :page-sizes="[10, 20, 50]"
+        @current-change="$emit('page-change', $event)"
+        @size-change="$emit('size-change', $event)"
+      />
+    </div>
   </div>
 </template>
 
@@ -112,29 +129,31 @@ export default {
   props: {
     kb: { type: Object, default: null },
     documents: { type: Array, default: () => [] },
-    metadataFields: { type: Array, default: () => [] }
+    total: { type: Number, default: 0 },
+    pageSize: { type: Number, default: 20 },
+    currentPage: { type: Number, default: 1 }
   },
   data() {
     return {
       searchText: '',
       filterEmbedStatus: '',
-      selectedRows: []
+      selectedRows: [],
+      searchTimer: null
     }
   },
   computed: {
-    filteredDocuments() {
-      let list = this.documents
-      if (this.searchText) {
-        const kw = this.searchText.toLowerCase()
-        list = list.filter(d => d.name.toLowerCase().includes(kw))
-      }
-      if (this.filterEmbedStatus) {
-        list = list.filter(d => d.embedStatus === this.filterEmbedStatus)
-      }
-      return list
+    displayDocuments() {
+      if (!this.filterEmbedStatus) return this.documents
+      return this.documents.filter(d => d.embedStatus === this.filterEmbedStatus)
     }
   },
   methods: {
+    onSearchInput(val) {
+      clearTimeout(this.searchTimer)
+      this.searchTimer = setTimeout(() => {
+        this.$emit('search', val || '')
+      }, 400)
+    },
     handleSelectionChange(rows) {
       this.selectedRows = rows
     },
@@ -169,7 +188,7 @@ export default {
   display: flex;
   justify-content: space-between;
   align-items: center;
-  padding: 12px 0;
+  padding: 12px 0 12px 16px;
   flex-wrap: wrap;
   gap: 8px;
 }
@@ -189,4 +208,42 @@ export default {
 .dt-file-link:hover {
   text-decoration: underline;
 }
+
+.dt-pagination {
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  padding: 12px 16px;
+  box-sizing: border-box;
+}
+
+.dt-pagination ::v-deep .el-pagination {
+  line-height: 1;
+}
+
+.dt-pagination ::v-deep .el-pagination > * {
+  vertical-align: middle !important;
+}
+
+.dt-pagination ::v-deep .el-pagination__sizes {
+  height: 28px;
+}
+
+.dt-pagination ::v-deep .el-pagination__sizes .el-input {
+  height: 28px;
+}
+
+.dt-pagination ::v-deep .el-pagination__sizes .el-input__inner {
+  height: 28px !important;
+  line-height: 28px !important;
+}
+
+.dt-pagination ::v-deep .el-pagination__sizes .el-input .el-input__suffix {
+  height: 28px;
+  line-height: 28px;
+}
+
+.dt-pagination ::v-deep .el-pager li {
+  vertical-align: middle !important;
+}
 </style>

+ 205 - 106
src/AIManagement/KBM/index.vue

@@ -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)